From b41bea75d5f23d3fa9d2a436cb5fbdb9920cdf33 Mon Sep 17 00:00:00 2001 From: Maciej Krajowski-Kukiel Date: Mon, 8 Jun 2026 10:37:15 +0200 Subject: [PATCH] provide params to pos-cli exec graphql --- README.md | 8 +++ bin/pos-cli-exec-graphql.js | 43 ++++------- bin/pos-cli-exec-liquid.js | 41 +++-------- lib/exec/graphql.js | 37 ++++++++++ lib/exec/liquid.js | 14 ++++ lib/exec/run.js | 50 +++++++++++++ test/unit/exec/graphql.test.js | 104 +++++++++++++++++++++++++++ test/unit/exec/liquid.test.js | 59 +++++++++++++++ test/unit/exec/run.test.js | 128 +++++++++++++++++++++++++++++++++ 9 files changed, 424 insertions(+), 60 deletions(-) create mode 100644 lib/exec/graphql.js create mode 100644 lib/exec/liquid.js create mode 100644 lib/exec/run.js create mode 100644 test/unit/exec/graphql.test.js create mode 100644 test/unit/exec/liquid.test.js create mode 100644 test/unit/exec/run.test.js diff --git a/README.md b/README.md index 73a088f7..14d88125 100644 --- a/README.md +++ b/README.md @@ -838,6 +838,14 @@ You can also execute GraphQL from a file using the `-f` flag: pos-cli exec graphql staging -f path/to/query.graphql +Pass GraphQL variables as a JSON object with the `-p, --params` flag: + + pos-cli exec graphql staging 'query q($id: ID) { record(id: $id) { id } }' --params '{"id":"42"}' + +The `--params` flag can be combined with `-f` to supply variables for a query read from a file: + + pos-cli exec graphql staging -f path/to/query.graphql --params '{"id":"42"}' + **Note:** When executing on production environments (environment name contains "prod" or "production"), you will be prompted for confirmation before execution. ### Running Tests diff --git a/bin/pos-cli-exec-graphql.js b/bin/pos-cli-exec-graphql.js index 362f5d43..d14006da 100644 --- a/bin/pos-cli-exec-graphql.js +++ b/bin/pos-cli-exec-graphql.js @@ -1,46 +1,29 @@ #!/usr/bin/env node -import fs from 'fs'; import { program } from '../lib/program.js'; -import Gateway from '../lib/proxy.js'; -import { fetchSettings } from '../lib/settings.js'; import logger from '../lib/logger.js'; -import { isProductionEnvironment, confirmProductionExecution } from '../lib/productionEnvironment.js'; +import { execGraphql } from '../lib/exec/graphql.js'; program .name('pos-cli exec graphql') .argument('', 'name of environment. Example: staging') .argument('[graphql]', 'graphql query to execute as string') .option('-f, --file ', 'path to graphql file to execute') + .option('-p, --params ', 'GraphQL variables as a JSON object. Example: \'{"id":"42"}\'') .action(async (environment, graphql, options) => { - let query = graphql; - - if (options.file) { - if (!fs.existsSync(options.file)) { - await logger.Error(`File not found: ${options.file}`); - process.exit(1); - } - query = fs.readFileSync(options.file, 'utf8'); - } - - if (!query) { - await logger.Error("error: missing required argument 'graphql'"); - process.exit(1); - } - - const authData = await fetchSettings(environment, program); - const gateway = new Gateway(authData); - - if (isProductionEnvironment(environment)) { - const confirmed = await confirmProductionExecution(environment); - if (!confirmed) { + try { + const { response, cancelled } = await execGraphql({ + environment, + query: graphql, + file: options.file, + params: options.params, + program, + }); + + if (cancelled) { logger.Info('Execution cancelled.'); process.exit(0); } - } - - try { - const response = await gateway.graph({ query }); if (response.errors) { await logger.Error(`GraphQL execution error: ${JSON.stringify(response.errors, null, 2)}`); @@ -56,4 +39,4 @@ program } }); -program.parse(process.argv); \ No newline at end of file +program.parse(process.argv); diff --git a/bin/pos-cli-exec-liquid.js b/bin/pos-cli-exec-liquid.js index 03cbc2b6..0c6ee47b 100644 --- a/bin/pos-cli-exec-liquid.js +++ b/bin/pos-cli-exec-liquid.js @@ -1,11 +1,8 @@ #!/usr/bin/env node -import fs from 'fs'; import { program } from '../lib/program.js'; -import Gateway from '../lib/proxy.js'; -import { fetchSettings } from '../lib/settings.js'; import logger from '../lib/logger.js'; -import { isProductionEnvironment, confirmProductionExecution } from '../lib/productionEnvironment.js'; +import { execLiquid } from '../lib/exec/liquid.js'; program .name('pos-cli exec liquid') @@ -13,34 +10,18 @@ program .argument('[code]', 'liquid code to execute as string') .option('-f, --file ', 'path to liquid file to execute') .action(async (environment, code, options) => { - let liquidCode = code; - - if (options.file) { - if (!fs.existsSync(options.file)) { - await logger.Error(`File not found: ${options.file}`); - process.exit(1); - } - liquidCode = fs.readFileSync(options.file, 'utf8'); - } - - if (!liquidCode) { - await logger.Error("error: missing required argument 'code'"); - process.exit(1); - } - - const authData = await fetchSettings(environment, program); - const gateway = new Gateway(authData); - - if (isProductionEnvironment(environment)) { - const confirmed = await confirmProductionExecution(environment); - if (!confirmed) { + try { + const { response, cancelled } = await execLiquid({ + environment, + code, + file: options.file, + program, + }); + + if (cancelled) { logger.Info('Execution cancelled.'); process.exit(0); } - } - - try { - const response = await gateway.liquid({ content: liquidCode }); if (response.error) { await logger.Error(`Liquid execution error: ${response.error}`); @@ -56,4 +37,4 @@ program } }); -program.parse(process.argv); \ No newline at end of file +program.parse(process.argv); diff --git a/lib/exec/graphql.js b/lib/exec/graphql.js new file mode 100644 index 00000000..d85b8b4d --- /dev/null +++ b/lib/exec/graphql.js @@ -0,0 +1,37 @@ +import { runExec } from './run.js'; + +// Parse the --params option into a GraphQL variables object. +// Accepts a JSON object string; returns {} when nothing was provided. +export function parseParams(raw) { + if (raw === undefined || raw === null || raw === '') { + return {}; + } + + let parsed; + try { + parsed = JSON.parse(raw); + } catch (e) { + throw new Error(`Invalid --params value: expected a JSON object of variables (${e.message})`); + } + + if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) { + throw new Error('Invalid --params value: expected a JSON object of variables'); + } + + return parsed; +} + +// Core logic for `pos-cli exec graphql`. +export async function execGraphql({ environment, query, file, params, program, deps = {} }) { + const variables = parseParams(params); + + return runExec({ + environment, + source: query, + file, + program, + missingArgName: 'graphql', + execute: (gateway, resolvedQuery) => gateway.graph({ query: resolvedQuery, variables }), + deps, + }); +} diff --git a/lib/exec/liquid.js b/lib/exec/liquid.js new file mode 100644 index 00000000..f96a352e --- /dev/null +++ b/lib/exec/liquid.js @@ -0,0 +1,14 @@ +import { runExec } from './run.js'; + +// Core logic for `pos-cli exec liquid`. +export async function execLiquid({ environment, code, file, program, deps = {} }) { + return runExec({ + environment, + source: code, + file, + program, + missingArgName: 'code', + execute: (gateway, content) => gateway.liquid({ content }), + deps, + }); +} diff --git a/lib/exec/run.js b/lib/exec/run.js new file mode 100644 index 00000000..b109bd37 --- /dev/null +++ b/lib/exec/run.js @@ -0,0 +1,50 @@ +import fs from 'fs'; +import Gateway from '../proxy.js'; +import { fetchSettings } from '../settings.js'; +import { isProductionEnvironment, confirmProductionExecution } from '../productionEnvironment.js'; + +// Resolve the source to execute from either an inline argument or a --file path. +export function resolveSource({ source, file, fileReader = fs }) { + if (file) { + if (!fileReader.existsSync(file)) { + throw new Error(`File not found: ${file}`); + } + return fileReader.readFileSync(file, 'utf8'); + } + + return source; +} + +// Shared core for the `pos-cli exec *` commands, decoupled from the CLI shell so +// it can be unit tested. It resolves the source, sets up the gateway, enforces +// production confirmation, then delegates the actual API call to `execute`. +// +// Dependencies are injectable via `deps` for testing; the bin passes none and +// gets the real implementations. +export async function runExec({ environment, source, file, program, missingArgName, execute, deps = {} }) { + const { + GatewayCtor = Gateway, + fetchSettingsFn = fetchSettings, + fileReader = fs, + isProductionEnvironmentFn = isProductionEnvironment, + confirmProductionExecutionFn = confirmProductionExecution, + } = deps; + + const resolved = resolveSource({ source, file, fileReader }); + if (!resolved) { + throw new Error(`missing required argument '${missingArgName}'`); + } + + const authData = await fetchSettingsFn(environment, program); + const gateway = new GatewayCtor(authData); + + if (isProductionEnvironmentFn(environment)) { + const confirmed = await confirmProductionExecutionFn(environment); + if (!confirmed) { + return { cancelled: true }; + } + } + + const response = await execute(gateway, resolved); + return { response }; +} diff --git a/test/unit/exec/graphql.test.js b/test/unit/exec/graphql.test.js new file mode 100644 index 00000000..4cf3f53b --- /dev/null +++ b/test/unit/exec/graphql.test.js @@ -0,0 +1,104 @@ +import { describe, test, expect, vi } from 'vitest'; +import { parseParams, execGraphql } from '#lib/exec/graphql.js'; + +describe('parseParams', () => { + test('returns {} when nothing provided', () => { + expect(parseParams(undefined)).toEqual({}); + expect(parseParams(null)).toEqual({}); + expect(parseParams('')).toEqual({}); + }); + + test('parses a JSON object of variables', () => { + expect(parseParams('{"id":"42","tags":["a","b"]}')).toEqual({ id: '42', tags: ['a', 'b'] }); + }); + + test('throws on invalid JSON', () => { + expect(() => parseParams('{not json')).toThrow(/expected a JSON object of variables/); + }); + + test('throws when JSON is not an object', () => { + expect(() => parseParams('[1,2]')).toThrow(/expected a JSON object of variables/); + expect(() => parseParams('"foo"')).toThrow(/expected a JSON object of variables/); + expect(() => parseParams('null')).toThrow(/expected a JSON object of variables/); + }); +}); + +describe('execGraphql', () => { + const baseDeps = (graphImpl) => { + const graph = vi.fn(graphImpl || (async () => ({ data: { ok: true } }))); + return { + graph, + deps: { + GatewayCtor: class { + constructor(auth) { + this.auth = auth; + } + graph(body) { + return graph(body); + } + }, + fetchSettingsFn: vi.fn().mockResolvedValue({ url: 'https://x', token: 't', email: 'e' }), + isProductionEnvironmentFn: vi.fn().mockReturnValue(false), + confirmProductionExecutionFn: vi.fn().mockResolvedValue(true), + fileReader: { existsSync: vi.fn().mockReturnValue(true), readFileSync: vi.fn() }, + }, + }; + }; + + test('passes parsed params as GraphQL variables', async () => { + const { graph, deps } = baseDeps(); + const { response } = await execGraphql({ + environment: 'dev', + query: 'query q($id: ID) { record(id: $id) { id } }', + params: '{"id":"42"}', + deps, + }); + + expect(graph).toHaveBeenCalledWith({ + query: 'query q($id: ID) { record(id: $id) { id } }', + variables: { id: '42' }, + }); + expect(response.data.ok).toBe(true); + }); + + test('defaults variables to {} when no params given', async () => { + const { graph, deps } = baseDeps(); + await execGraphql({ environment: 'dev', query: 'query { ok }', deps }); + expect(graph).toHaveBeenCalledWith({ query: 'query { ok }', variables: {} }); + }); + + test('reads the query from --file', async () => { + const { graph, deps } = baseDeps(); + deps.fileReader.readFileSync.mockReturnValue('query FromFile { ok }'); + await execGraphql({ environment: 'dev', file: '/tmp/q.graphql', params: '{"x":1}', deps }); + expect(graph).toHaveBeenCalledWith({ query: 'query FromFile { ok }', variables: { x: 1 } }); + }); + + test('throws when neither query nor file resolves a query', async () => { + const { deps } = baseDeps(); + await expect(execGraphql({ environment: 'dev', deps })).rejects.toThrow(/missing required argument 'graphql'/); + }); + + test('propagates invalid --params error before any API call', async () => { + const { graph, deps } = baseDeps(); + await expect( + execGraphql({ environment: 'dev', query: 'query { ok }', params: '{bad', deps }) + ).rejects.toThrow(/expected a JSON object of variables/); + expect(graph).not.toHaveBeenCalled(); + }); + + test('returns the raw GraphQL response including errors', async () => { + const { deps } = baseDeps(async () => ({ errors: [{ message: 'boom' }], data: null })); + const { response } = await execGraphql({ environment: 'dev', query: 'query { ok }', deps }); + expect(response.errors[0].message).toBe('boom'); + }); + + test('cancels on production when confirmation is declined', async () => { + const { graph, deps } = baseDeps(); + deps.isProductionEnvironmentFn.mockReturnValue(true); + deps.confirmProductionExecutionFn.mockResolvedValue(false); + const result = await execGraphql({ environment: 'production', query: 'query { ok }', deps }); + expect(result.cancelled).toBe(true); + expect(graph).not.toHaveBeenCalled(); + }); +}); diff --git a/test/unit/exec/liquid.test.js b/test/unit/exec/liquid.test.js new file mode 100644 index 00000000..fbbad2a6 --- /dev/null +++ b/test/unit/exec/liquid.test.js @@ -0,0 +1,59 @@ +import { describe, test, expect, vi } from 'vitest'; +import { execLiquid } from '#lib/exec/liquid.js'; + +describe('execLiquid', () => { + const baseDeps = (liquidImpl) => { + const liquid = vi.fn(liquidImpl || (async () => ({ result: 'hello' }))); + return { + liquid, + deps: { + GatewayCtor: class { + constructor(auth) { + this.auth = auth; + } + liquid(body) { + return liquid(body); + } + }, + fetchSettingsFn: vi.fn().mockResolvedValue({ url: 'https://x', token: 't', email: 'e' }), + isProductionEnvironmentFn: vi.fn().mockReturnValue(false), + confirmProductionExecutionFn: vi.fn().mockResolvedValue(true), + fileReader: { existsSync: vi.fn().mockReturnValue(true), readFileSync: vi.fn() }, + }, + }; + }; + + test('sends inline code as the liquid content', async () => { + const { liquid, deps } = baseDeps(); + const { response } = await execLiquid({ environment: 'dev', code: '{{ 1 | plus: 1 }}', deps }); + expect(liquid).toHaveBeenCalledWith({ content: '{{ 1 | plus: 1 }}' }); + expect(response.result).toBe('hello'); + }); + + test('reads the code from --file', async () => { + const { liquid, deps } = baseDeps(); + deps.fileReader.readFileSync.mockReturnValue('{% assign x = 1 %}'); + await execLiquid({ environment: 'dev', file: '/tmp/s.liquid', deps }); + expect(liquid).toHaveBeenCalledWith({ content: '{% assign x = 1 %}' }); + }); + + test('throws when neither code nor file resolves', async () => { + const { deps } = baseDeps(); + await expect(execLiquid({ environment: 'dev', deps })).rejects.toThrow(/missing required argument 'code'/); + }); + + test('returns the raw liquid response including errors', async () => { + const { deps } = baseDeps(async () => ({ error: 'Liquid syntax error' })); + const { response } = await execLiquid({ environment: 'dev', code: '{{ broken', deps }); + expect(response.error).toBe('Liquid syntax error'); + }); + + test('cancels on production when confirmation is declined', async () => { + const { liquid, deps } = baseDeps(); + deps.isProductionEnvironmentFn.mockReturnValue(true); + deps.confirmProductionExecutionFn.mockResolvedValue(false); + const result = await execLiquid({ environment: 'production', code: '{{ 1 }}', deps }); + expect(result.cancelled).toBe(true); + expect(liquid).not.toHaveBeenCalled(); + }); +}); diff --git a/test/unit/exec/run.test.js b/test/unit/exec/run.test.js new file mode 100644 index 00000000..aafe9c78 --- /dev/null +++ b/test/unit/exec/run.test.js @@ -0,0 +1,128 @@ +import { describe, test, expect, vi } from 'vitest'; +import { resolveSource, runExec } from '#lib/exec/run.js'; + +describe('resolveSource', () => { + test('returns the inline source when no file given', () => { + expect(resolveSource({ source: 'query { ok }' })).toBe('query { ok }'); + }); + + test('returns undefined when neither source nor file given', () => { + expect(resolveSource({})).toBeUndefined(); + }); + + test('reads the source from a file when --file given', () => { + const fileReader = { + existsSync: vi.fn().mockReturnValue(true), + readFileSync: vi.fn().mockReturnValue('contents from file'), + }; + expect(resolveSource({ file: '/tmp/q.graphql', fileReader })).toBe('contents from file'); + expect(fileReader.readFileSync).toHaveBeenCalledWith('/tmp/q.graphql', 'utf8'); + }); + + test('throws when the file does not exist', () => { + const fileReader = { existsSync: vi.fn().mockReturnValue(false), readFileSync: vi.fn() }; + expect(() => resolveSource({ file: '/tmp/missing.graphql', fileReader })).toThrow(/File not found/); + expect(fileReader.readFileSync).not.toHaveBeenCalled(); + }); +}); + +describe('runExec', () => { + const baseDeps = (overrides = {}) => { + const execute = vi.fn(async () => ({ data: { ok: true } })); + const deps = { + GatewayCtor: class { + constructor(auth) { + this.auth = auth; + } + }, + fetchSettingsFn: vi.fn().mockResolvedValue({ url: 'https://x', token: 't', email: 'e' }), + isProductionEnvironmentFn: vi.fn().mockReturnValue(false), + confirmProductionExecutionFn: vi.fn().mockResolvedValue(true), + fileReader: { existsSync: vi.fn().mockReturnValue(true), readFileSync: vi.fn() }, + ...overrides, + }; + return { execute, deps }; + }; + + test('resolves the source and passes it to execute with the gateway', async () => { + const { execute, deps } = baseDeps(); + const { response } = await runExec({ + environment: 'dev', + source: 'inline source', + missingArgName: 'code', + execute, + deps, + }); + + expect(deps.fetchSettingsFn).toHaveBeenCalledWith('dev', undefined); + const [gatewayArg, sourceArg] = execute.mock.calls[0]; + expect(gatewayArg).toBeInstanceOf(deps.GatewayCtor); + expect(gatewayArg.auth).toEqual({ url: 'https://x', token: 't', email: 'e' }); + expect(sourceArg).toBe('inline source'); + expect(response.data.ok).toBe(true); + }); + + test('reads the source from --file', async () => { + const { execute, deps } = baseDeps(); + deps.fileReader.readFileSync.mockReturnValue('from file'); + await runExec({ environment: 'dev', file: '/tmp/x', missingArgName: 'code', execute, deps }); + expect(execute.mock.calls[0][1]).toBe('from file'); + }); + + test('throws with the supplied argument name when nothing resolves', async () => { + const { execute, deps } = baseDeps(); + await expect( + runExec({ environment: 'dev', missingArgName: 'graphql', execute, deps }) + ).rejects.toThrow(/missing required argument 'graphql'/); + expect(execute).not.toHaveBeenCalled(); + }); + + test('propagates file-not-found errors', async () => { + const { execute, deps } = baseDeps(); + deps.fileReader.existsSync.mockReturnValue(false); + await expect( + runExec({ environment: 'dev', file: '/tmp/missing', missingArgName: 'code', execute, deps }) + ).rejects.toThrow(/File not found/); + expect(execute).not.toHaveBeenCalled(); + }); + + describe('production protection', () => { + test('prompts for confirmation on production environments', async () => { + const { execute, deps } = baseDeps(); + deps.isProductionEnvironmentFn.mockReturnValue(true); + deps.confirmProductionExecutionFn.mockResolvedValue(true); + + await runExec({ environment: 'production', source: 'q', missingArgName: 'code', execute, deps }); + + expect(deps.confirmProductionExecutionFn).toHaveBeenCalledWith('production'); + expect(execute).toHaveBeenCalled(); + }); + + test('cancels without executing when production confirmation is declined', async () => { + const { execute, deps } = baseDeps(); + deps.isProductionEnvironmentFn.mockReturnValue(true); + deps.confirmProductionExecutionFn.mockResolvedValue(false); + + const result = await runExec({ + environment: 'production', + source: 'q', + missingArgName: 'code', + execute, + deps, + }); + + expect(result).toEqual({ cancelled: true }); + expect(execute).not.toHaveBeenCalled(); + }); + + test('does not prompt on non-production environments', async () => { + const { execute, deps } = baseDeps(); + deps.isProductionEnvironmentFn.mockReturnValue(false); + + await runExec({ environment: 'staging', source: 'q', missingArgName: 'code', execute, deps }); + + expect(deps.confirmProductionExecutionFn).not.toHaveBeenCalled(); + expect(execute).toHaveBeenCalled(); + }); + }); +});