#!/usr/bin/env node // Shopify Admin GraphQL runner for this repo. Dependency-free (Node 18+). // // Usage: // node scripts/shopify-api.mjs [--vars '{"k":"v"}'] [--vars-file vars.json] [--write] // node scripts/shopify-api.mjs --query '{ shop { name } }' // // Auth: Dev Dashboard apps have no static admin token. This script exchanges // SHOPIFY_CLIENT_ID/SHOPIFY_CLIENT_SECRET (from the repo-root .env; created by // scripts/setup-shopify-app.sh) for a ~24h access token via the client // credentials grant, and caches it in .shopify/admin-token.json (gitignored). // Mutations are refused without --write — see SHOPIFY-API.md for the write // protocol. Exit codes: 0 ok, 1 usage/config, 2 HTTP/GraphQL/auth errors, // 3 userErrors returned by a mutation. import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'node:fs'; import { dirname, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..'); const TOKEN_CACHE = resolve(ROOT, '.shopify', 'admin-token.json'); function fail(msg, code = 1) { console.error(`shopify-api: ${msg}`); process.exit(code); } function loadEnv() { const path = resolve(ROOT, '.env'); if (!existsSync(path)) fail(`.env not found at ${path} — run scripts/setup-shopify-app.sh first`); const env = {}; for (const line of readFileSync(path, 'utf8').split(/\r?\n/)) { const m = line.match(/^\s*([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*?)\s*$/); if (m) env[m[1]] = m[2].replace(/^(["'])(.*)\1$/, '$2'); } for (const key of ['SHOPIFY_STORE', 'SHOPIFY_API_VERSION', 'SHOPIFY_CLIENT_ID', 'SHOPIFY_CLIENT_SECRET']) { if (!env[key]) fail(`${key} missing from .env`); } return env; } function parseArgs(argv) { const args = { write: false, query: null, vars: null }; for (let i = 0; i < argv.length; i++) { const a = argv[i]; if (a === '--write') args.write = true; else if (a === '--query') args.query = argv[++i]; else if (a === '--vars') args.vars = argv[++i]; else if (a === '--vars-file') args.vars = readFileSync(argv[++i], 'utf8'); else if (!a.startsWith('--') && !args.query) args.query = readFileSync(a, 'utf8'); else fail(`unknown argument: ${a}`); } if (!args.query) fail('no query given (pass a .graphql file path or --query)'); return args; } // Fail-closed write gate: strip strings and comments, then look for the // mutation keyword anywhere. False positives are fine; false negatives are not. function isMutation(doc) { const stripped = doc .replace(/"""[\s\S]*?"""/g, '""') .replace(/"(?:\\.|[^"\\])*"/g, '""') .replace(/#[^\n]*/g, ''); return /\bmutation\b/.test(stripped); } function collectUserErrors(node, found = []) { if (Array.isArray(node)) node.forEach((n) => collectUserErrors(n, found)); else if (node && typeof node === 'object') { for (const [key, value] of Object.entries(node)) { if (/userErrors$/i.test(key) && Array.isArray(value) && value.length) found.push(...value); else collectUserErrors(value, found); } } return found; } // Client credentials grant: tokens last ~24h; cache with a 5-minute margin. async function getToken(env, { forceFresh = false } = {}) { if (!forceFresh && existsSync(TOKEN_CACHE)) { try { const cached = JSON.parse(readFileSync(TOKEN_CACHE, 'utf8')); if (cached.access_token && cached.expires_at - 300_000 > Date.now()) return cached.access_token; } catch { /* corrupt cache — re-mint */ } } const res = await fetch(`https://${env.SHOPIFY_STORE}.myshopify.com/admin/oauth/access_token`, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: new URLSearchParams({ grant_type: 'client_credentials', client_id: env.SHOPIFY_CLIENT_ID, client_secret: env.SHOPIFY_CLIENT_SECRET, }), }); const body = await res.json().catch(() => ({})); if (!res.ok || !body.access_token) { fail(`token request failed (HTTP ${res.status}): ${JSON.stringify(body).slice(0, 500)}\n` + 'Check SHOPIFY_CLIENT_ID/SHOPIFY_CLIENT_SECRET, that the app is installed on the store,\n' + 'and that the app and store are in the same Shopify organization.', 2); } mkdirSync(dirname(TOKEN_CACHE), { recursive: true }); writeFileSync(TOKEN_CACHE, JSON.stringify({ access_token: body.access_token, expires_at: Date.now() + (body.expires_in ?? 86399) * 1000, })); return body.access_token; } async function graphql(env, token, args) { const endpoint = `https://${env.SHOPIFY_STORE}.myshopify.com/admin/api/${env.SHOPIFY_API_VERSION}/graphql.json`; let variables = {}; if (args.vars) { try { variables = JSON.parse(args.vars); } catch (e) { fail(`--vars is not valid JSON: ${e.message}`); } } return fetch(endpoint, { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-Shopify-Access-Token': token }, body: JSON.stringify({ query: args.query, variables }), }); } const args = parseArgs(process.argv.slice(2)); if (isMutation(args.query) && !args.write) { fail('this document contains a mutation — writes require the --write flag.\n' + 'Protocol: show the exact mutation + variables to the human, get their\n' + 'confirmation, then re-run with --write. See SHOPIFY-API.md.'); } const env = loadEnv(); let res = await graphql(env, await getToken(env), args); if (res.status === 401) { // Cached token revoked or expired early — mint a fresh one and retry once. res = await graphql(env, await getToken(env, { forceFresh: true }), args); } const text = await res.text(); let body; try { body = JSON.parse(text); } catch { fail(`HTTP ${res.status} — non-JSON response:\n${text.slice(0, 2000)}`, 2); } console.log(JSON.stringify(body, null, 2)); // Set exitCode rather than process.exit(): a hard exit while undici sockets // are closing crashes Node on Windows (libuv assertion in async.c). if (!res.ok || body.errors?.length) { console.error(`shopify-api: request failed (HTTP ${res.status}${body.errors ? `, ${body.errors.length} GraphQL error(s)` : ''})`); process.exitCode = 2; } else { const userErrors = collectUserErrors(body.data); if (userErrors.length) { console.error(`shopify-api: mutation returned ${userErrors.length} userError(s) — the write did NOT fully apply`); process.exitCode = 3; } }