Setting the file. One moment.
Shopify CLI · Rp Source Shopify · wix/skills · Skills Docs
ContentsBack to the top of the page 160
function nodeRuntimeMessage
— line 160
This file
Number 38.10
Position 10 of 51
Type JavaScript
Size 20 KB
Lines 446 lib/ shopify-cli.js
JavaScript · 446 lines · 20 KB
// One pinned CLI, one pinned API version:
15 // - every CLI process is `[process.execPath, <tools>/shopify-cli-4.8.3/node_modules/@shopify/cli/bin/run.js, ...]`,
16 // so the Node.js the session check verified is the one that runs it, and a `shopify` or
17 // `node` elsewhere on PATH is never used;
18 // - every process gets the parent environment with `CI=1` and without
19 // `SHOPIFY_CLI_FORCE_AUTO_UPGRADE`, so the CLI never runs its machine-wide self-update;
20 // - the installed package's version is read before every `store execute`;
21 // - every `store execute` passes `--version 2026-10`.
22 //
23 // Result contract for every call:
24 // { data, errors: [] } the store answered
25 // { data: null, errors } the store answered with GraphQL errors (returned to the caller)
26 // throws ShopifyCliError anything else, with a class code:
27 // source_auth_expired the CLI has no valid stored session for the store
28 // shopify_throttled THROTTLED on all 6 calls of one read (31 s of waits)
29 // shopify_api_version_unsupported the CLI rejected the pinned API version
30 // shopify_cli_incompatible the CLI has no `store` commands
31 // shopify_cli_version_changed the installed CLI is not 4.8.3 (checked before the call)
32 // shopify_cli_failed anything else: stop the step; never retried
33
34 const { execFile , spawn } = require ( 'node:child_process' );
35 const fs = require ( 'node:fs' );
36 const os = require ( 'node:os' );
37 const path = require ( 'node:path' );
38
39 const SHOPIFY_CLI_VERSION = '4.8.3' ;
40 const SHOPIFY_CLI_NODE_MIN = '22.12.0' ;
41 // What the pinned package's `engines.node` must say; a pin moved without moving the runtime
42 // minimum stops at the session check.
43 const SHOPIFY_CLI_ENGINES_NODE = `>=${ SHOPIFY_CLI_NODE_MIN }` ;
44 const SHOPIFY_ADMIN_API_VERSION = '2026-10' ;
45
46 const DEFAULT_TIMEOUT_MS = 120000 ;
47 const MAX_BUFFER = 64 * 1024 * 1024 ;
48 // Paging pace and throttling: first: 250 with cursors, 500 ms between paged calls; a
49 // THROTTLED answer is retried after 1, 2, 4, 8 and 16 s -- at most 6 calls per read.
50 const PAGE_SIZE = 250 ;
51 const PAGE_GAP_MS = 500 ;
52 const THROTTLE_WAITS_MS = Object. freeze ([ 1000 , 2000 , 4000 , 8000 , 16000 ]);
53
54 // The only mutation this adapter ever sends. `groupObjects` stays at its
55 // schema default (false): the reassembler folds children by `__parentId` in any line order.
56 const BULK_RUN_MUTATION = 'mutation RpBulkRunQuery($query: String!) { bulkOperationRunQuery(query: $query) { bulkOperation { id status createdAt } userErrors { field message code } } }' ;
57
58 class ShopifyCliError extends Error {
59 constructor ( message , { code = 'shopify_cli_failed' , exitCode = null , stderr = '' , errors = null } = {}) {
60 super (message);
61 this .name = 'ShopifyCliError' ;
62 this .code = code;
63 this .exitCode = exitCode;
64 // A short tail only: the CLI's own output can echo the query, never a secret, but keep it bounded.
65 this .stderrTail = String (stderr || '' ). slice ( - 2000 );
66 if (errors) this .errors = errors;
67 }
68 }
69
70 // ---------------------------------------------------------------------------------------------
71 // The adapter's own CLI install
72 // ---------------------------------------------------------------------------------------------
73
74 function defaultToolsDir () {
75 return path. join (os. homedir (), '.replatform' , 'tools' );
76 }
77
78 function cliInstallDir ( toolsDir = defaultToolsDir ()) {
79 return path. join (toolsDir, `shopify-cli-${ SHOPIFY_CLI_VERSION }` );
80 }
81
82 function cliPackageDir ( toolsDir = defaultToolsDir ()) {
83 return path. join ( cliInstallDir (toolsDir), 'node_modules' , '@shopify' , 'cli' );
84 }
85
86 function cliEntryPath ( toolsDir = defaultToolsDir ()) {
87 return path. join ( cliPackageDir (toolsDir), 'bin' , 'run.js' );
88 }
89
90 // The argv prefix of every CLI process: the checked runtime, then the adapter's entry script.
91 // Never `.bin/shopify` (its `env node` shebang) and never a `shopify` on PATH.
92 function adapterCommand ( toolsDir = defaultToolsDir ()) {
93 return [process.execPath, cliEntryPath (toolsDir)];
94 }
95
96 // A file read, no process: { present, version, enginesNode }.
97 function readInstalledPackage ( toolsDir = defaultToolsDir ()) {
98 const file = path. join ( cliPackageDir (toolsDir), 'package.json' );
99 let text;
100 try {
101 text = fs. readFileSync (file, 'utf8' );
102 } catch (error) {
103 if (error.code === 'ENOENT' ) return { present: false , version: null , enginesNode: null , file };
104 throw error;
105 }
106 let parsed = null ;
107 try {
108 parsed = JSON . parse (text);
109 } catch {
110 parsed = null ;
111 }
112 return {
113 present: true ,
114 version: parsed && typeof parsed.version === 'string' ? parsed.version : null ,
115 enginesNode: parsed && parsed.engines && typeof parsed.engines.node === 'string' ? parsed.engines.node : null ,
116 file,
117 };
118 }
119
120 function cliVersionChanged ( found ) {
121 return new ShopifyCliError (
122 `the adapter's Shopify CLI is ${ found || 'unreadable'}, not the tested ${ SHOPIFY_CLI_VERSION }. Nothing was read. Run the Shopify session check with --install to reinstall ${ SHOPIFY_CLI_VERSION } (with the operator's consent), then run the step again.` ,
123 { code: 'shopify_cli_version_changed' },
124 );
125 }
126
127 // Rule 3: before every `store execute`. Anything but the pinned version stops the step.
128 function assertInstalledVersion ( toolsDir = defaultToolsDir ()) {
129 const installed = readInstalledPackage (toolsDir);
130 if ( ! installed.present) {
131 throw new ShopifyCliError (
132 `the adapter's Shopify CLI ${ SHOPIFY_CLI_VERSION } is not installed. Nothing was read. Run the Shopify session check (it installs the CLI with the operator's consent), then run the step again.` ,
133 { code: 'shopify_cli_not_installed' },
134 );
135 }
136 if (installed.version !== SHOPIFY_CLI_VERSION ) throw cliVersionChanged (installed.version);
137 return installed;
138 }
139
140 // ---------------------------------------------------------------------------------------------
141 // The runtime
142 // ---------------------------------------------------------------------------------------------
143
144 function parseVersion ( value ) {
145 const match = / ^ v ? ( \d + ) \. ( \d + ) \. ( \d + )/ . exec ( String (value || '' ). trim ());
146 return match ? match. slice ( 1 , 4 ). map (Number) : null ;
147 }
148
149 // Numeric major.minor.patch comparison: 22.9.0 < 22.12.0.
150 function compareVersions ( left , right ) {
151 const a = parseVersion (left);
152 const b = parseVersion (right);
153 if ( ! a || ! b) throw new Error ( `cannot compare versions ${ left } and ${ right }` );
154 for ( let i = 0 ; i < 3 ; i += 1 ) {
155 if (a[i] !== b[i]) return a[i] < b[i] ? - 1 : 1 ;
156 }
157 return 0 ;
158 }
159
160 function nodeRuntimeMessage ( nodeVersion ) {
161 return `Shopify CLI ${ SHOPIFY_CLI_VERSION } needs Node.js 22.12 or later; this migration is running on Node.js ${ nodeVersion }. Switch this terminal to Node.js 22.12 or later (for example the current LTS) with your usual Node.js installer or version manager, check with \` node --version \` , then run the Shopify session check again. Nothing was installed or read.` ;
162 }
163
164 function checkNodeRuntime ( nodeVersion = process.versions.node) {
165 const supported = parseVersion (nodeVersion) !== null && compareVersions (nodeVersion, SHOPIFY_CLI_NODE_MIN ) >= 0 ;
166 if ( ! supported) throw new ShopifyCliError ( nodeRuntimeMessage (nodeVersion), { code: 'shopify_cli_node_unsupported' });
167 return { nodePath: process.execPath, nodeVersion: String (nodeVersion) };
168 }
169
170 // ---------------------------------------------------------------------------------------------
171 // Process start: one environment rule for every CLI process
172 // ---------------------------------------------------------------------------------------------
173
174 // The parent environment with CI=1 (4.8.3 skips its upgrade attempt) and without the variable that
175 // would force it. Applied to whatever parent environment is passed, including a test's.
176 function cliChildEnv ( parent = process.env) {
177 const env = { ... parent };
178 env. CI = '1' ;
179 delete env. SHOPIFY_CLI_FORCE_AUTO_UPGRADE ;
180 return env;
181 }
182
183 function runFile ( file , args , options ) {
184 if (options && options.stdio === 'inherit' ) {
185 return new Promise (( resolve ) => {
186 const child = spawn (file, args, { env: options.env, stdio: 'inherit' , windowsHide: true });
187 child. on ( 'error' , ( error ) => resolve ({ error, stdout: '' , stderr: '' }));
188 child. on ( 'close' , ( code ) => resolve ({ error: code === 0 ? null : Object. assign ( new Error ( `exited with ${ code }` ), { code }), stdout: '' , stderr: '' }));
189 });
190 }
191 return new Promise (( resolve ) => {
192 execFile (file, args, options, ( error , stdout , stderr ) => resolve ({ error, stdout: String (stdout || '' ), stderr: String (stderr || '' ) }));
193 });
194 }
195
196 // Starts one CLI process. `inherit` hands the terminal to it (the browser approval).
197 async function startCliProcess ( args , { command = null , toolsDir = defaultToolsDir (), env = process.env, run = runFile, timeoutMs = DEFAULT_TIMEOUT_MS , inherit = false , onCall = null } = {}) {
198 const argv = command || adapterCommand (toolsDir);
199 const fullArgs = [ ... argv. slice ( 1 ), ... args];
200 const childEnv = cliChildEnv (env);
201 if ( typeof onCall === 'function' ) onCall ({ file: argv[ 0 ], args: [ ... fullArgs], env: childEnv });
202 const options = inherit
203 ? { env: childEnv, stdio: 'inherit' , windowsHide: true }
204 : { env: childEnv, timeout: timeoutMs, maxBuffer: MAX_BUFFER , windowsHide: true };
205 const result = await run (argv[ 0 ], fullArgs, options);
206 const exitCode = result.error ? ( typeof result.error.code === 'number' ? result.error.code : null ) : 0 ;
207 return { ... result, exitCode, file: argv[ 0 ] };
208 }
209
210 // ---------------------------------------------------------------------------------------------
211 // Output parsing and failure classes
212 // ---------------------------------------------------------------------------------------------
213
214 // The operation keyword of the first definition, after comments. Shorthand `{ ... }` is a query.
215 function operationKind ( document ) {
216 const text = String (document || '' ). replace ( /# [ ^ \n] * / g , ' ' ). trim ();
217 const match = / ^ (query | mutation | subscription) \b / . exec (text);
218 return match ? match[ 1 ] : 'query' ;
219 }
220
221 function parseJsonText ( text ) {
222 const trimmed = String (text || '' ). trim ();
223 if ( ! trimmed) return undefined ;
224 try {
225 return JSON . parse (trimmed);
226 } catch {
227 const start = trimmed. indexOf ( '{' );
228 const end = trimmed. lastIndexOf ( '}' );
229 if (start === - 1 || end <= start) return undefined ;
230 try {
231 return JSON . parse (trimmed. slice (start, end + 1 ));
232 } catch {
233 return undefined ;
234 }
235 }
236 }
237
238 // On a GraphQL failure the CLI exits non-zero with "GraphQL operation failed." and the response's
239 // `errors` array as JSON. Recover that array; when the JSON is wrapped by terminal rendering, fall
240 // back to the `code`/`message` pairs it carries.
241 function graphqlErrorsFrom ( stderr ) {
242 const text = String (stderr || '' );
243 if ( ! /GraphQL operation failed/ i . test (text) && ! /"errors" \s * :/ . test (text)) return null ;
244 const parsed = parseJsonText (text);
245 if (parsed && Array. isArray (parsed.errors) && parsed.errors. length ) return parsed.errors;
246 const codes = [ ... text. matchAll ( /"code" \s * : \s * "( [A-Z_] + )"/ g )]. map (( match ) => match[ 1 ]);
247 const messages = [ ... text. matchAll ( /"message" \s * : \s * "((?: [ ^ " \\ ] | \\ . ) * )"/ g )]. map (( match ) => match[ 1 ]);
248 if ( ! codes. length && ! messages. length ) return null ;
249 const count = Math. max (codes. length , messages. length );
250 return Array. from ({ length: count }, ( _ , i ) => ({
251 message: messages[i] || 'GraphQL error' ,
252 ... (codes[i] ? { extensions: { code: codes[i] } } : {}),
253 }));
254 }
255
256 function unwrapData ( parsed ) {
257 if (parsed && typeof parsed === 'object' && ! Array. isArray (parsed)) {
258 const keys = Object. keys (parsed);
259 if (keys. includes ( 'data' ) && keys. every (( key ) => [ 'data' , 'errors' , 'extensions' ]. includes (key))) {
260 return { data: parsed.data, errors: Array. isArray (parsed.errors) ? parsed.errors : [] };
261 }
262 }
263 return { data: parsed, errors: [] };
264 }
265
266 // GraphQL error codes, deduplicated, for evidence pointers and stop reasons.
267 function errorCodes ( errors ) {
268 return [ ...new Set ((errors || []). map (( error ) => (error && error.extensions && error.extensions.code) || 'GRAPHQL_ERROR' ))];
269 }
270
271 function isThrottled ( errors ) {
272 return Array. isArray (errors) && errors. length > 0 && errorCodes (errors). includes ( 'THROTTLED' );
273 }
274
275 // The CLI messages these patterns match are read from the shipped 4.8.3 code: "No stored app
276 // authentication found for <store>." and "Stored app authentication for <store> is no longer
277 // valid." (both followed by "to (re-)authenticate"), and "Invalid API version: <v>". The live
278 // expiry text is not captured yet: an unmatched failure is shopify_cli_failed, a stop.
279 const AUTH_PATTERNS = [
280 /No stored app authentication found/ i ,
281 /Stored app authentication for \S + is no longer valid/ i ,
282 /to re- ? authenticate/ i ,
283 ];
284 const INCOMPATIBLE_PATTERNS = [
285 /command \s + \S * store \S * \s + not found/ i ,
286 /is not a shopify command/ i ,
287 /Unknown command/ i ,
288 ];
289
290 function classifyCliFailure ( text ) {
291 const value = String (text || '' );
292 if ( /Invalid API version/ i . test (value)) return 'shopify_api_version_unsupported' ;
293 if ( AUTH_PATTERNS . some (( pattern ) => pattern. test (value))) return 'source_auth_expired' ;
294 if ( INCOMPATIBLE_PATTERNS . some (( pattern ) => pattern. test (value))) return 'shopify_cli_incompatible' ;
295 return 'shopify_cli_failed' ;
296 }
297
298 const CLASS_MESSAGES = {
299 source_auth_expired: 'the Shopify connection for this store expired or is missing' ,
300 shopify_api_version_unsupported: `the Shopify CLI rejected the pinned Admin API version ${ SHOPIFY_ADMIN_API_VERSION }` ,
301 shopify_cli_incompatible: 'the Shopify CLI has no store commands' ,
302 };
303
304 function defaultSleep ( ms ) {
305 return new Promise (( resolve ) => setTimeout (resolve, ms));
306 }
307
308 // ---------------------------------------------------------------------------------------------
309 // store execute
310 // ---------------------------------------------------------------------------------------------
311
312 // `command`, `env`, `run` and `toolsDir` are injectable for tests only; product callers pass just
313 // `store`. The installed version is checked before every call whenever the command is the
314 // adapter's own install (an explicit test `command` has no package to check).
315 function createStoreExecutor ({
316 store ,
317 command = null ,
318 apiVersion = SHOPIFY_ADMIN_API_VERSION ,
319 timeoutMs = DEFAULT_TIMEOUT_MS ,
320 env = process.env,
321 run = runFile,
322 onCall = null ,
323 toolsDir = defaultToolsDir (),
324 sleep = defaultSleep,
325 throttleWaitsMs = THROTTLE_WAITS_MS ,
326 } = {}) {
327 if ( typeof store !== 'string' || ! / ^ [a-z0-9][a-z0-9-] * \. myshopify \. com $ / i . test (store)) {
328 throw new ShopifyCliError ( 'store must be the *.myshopify.com domain the Shopify CLI session is bound to' , { code: 'shopify_store_invalid' });
329 }
330 if (command !== null && ( ! Array. isArray (command) || command. length === 0 )) throw new ShopifyCliError ( 'command must be a non-empty argv prefix' , { code: 'shopify_cli_invalid' });
331 const checkVersion = command === null ;
332
333 async function once ( query , variables , { allowMutations = false } = {}) {
334 if (checkVersion) assertInstalledVersion (toolsDir);
335 const args = [ 'store' , 'execute' , '--store' , store, '--query' , query, '--json' , '--no-color' ];
336 if (variables && Object. keys (variables). length ) args. push ( '--variables' , JSON . stringify (variables));
337 if (allowMutations) args. push ( '--allow-mutations' );
338 if (apiVersion) args. push ( '--version' , apiVersion);
339 const { error , stdout , stderr , exitCode , file } = await startCliProcess (args, { command, toolsDir, env, run, timeoutMs, onCall });
340 if (error) {
341 const errors = graphqlErrorsFrom (stderr) || graphqlErrorsFrom (stdout);
342 if (errors) return { data: null , errors };
343 const cls = error.code === 'ENOENT' ? 'shopify_cli_failed' : classifyCliFailure ( `${ stderr } \n ${ stdout }` );
344 const why = error.code === 'ENOENT'
345 ? `the Shopify CLI was not found (${ file })`
346 : CLASS_MESSAGES [cls] || `the Shopify CLI exited${ exitCode === null ? '' : ` with ${ exitCode }`}` ;
347 throw new ShopifyCliError ( `${ why } without a GraphQL answer` , { code: cls, exitCode, stderr });
348 }
349 const parsed = parseJsonText (stdout);
350 if (parsed === undefined ) throw new ShopifyCliError ( 'the Shopify CLI printed no JSON result' , { stderr });
351 return unwrapData (parsed);
352 }
353
354 // One read: the first call plus at most throttleWaitsMs.length retries, only on THROTTLED.
355 async function withThrottleRetries ( query , variables , options ) {
356 for ( let attempt = 0 ; ; attempt += 1 ) {
357 const result = await once (query, variables, options);
358 if ( ! isThrottled (result.errors)) return result;
359 if (attempt >= throttleWaitsMs. length ) {
360 throw new ShopifyCliError ( `Shopify throttled this read on all ${ attempt + 1 } calls` , { code: 'shopify_throttled' , errors: result.errors });
361 }
362 await sleep (throttleWaitsMs[attempt]);
363 }
364 }
365
366 async function execute ( query , variables = null ) {
367 if ( operationKind (query) !== 'query' ) {
368 throw new ShopifyCliError ( 'this adapter is read-only: only GraphQL queries may be executed' , { code: 'shopify_mutation_refused' });
369 }
370 return withThrottleRetries (query, variables, {});
371 }
372
373 // A caller with its own approved retry schedule (the gift-card read-bound count gate) uses
374 // one physical CLI call at a time, so its advertised three attempts are exactly three calls
375 // rather than three nested six-call throttle budgets.
376 execute. once = async function executeOnce ( query , variables = null ) {
377 if ( operationKind (query) !== 'query' ) {
378 throw new ShopifyCliError ( 'this adapter is read-only: only GraphQL queries may be executed' , { code: 'shopify_mutation_refused' });
379 }
380 return once (query, variables, {});
381 };
382
383 // Starts one bulk read job. The mutation document is the constant above;
384 // `bulkQuery` is data and must itself be a plain query, so nothing else can ride on the flag.
385 execute. startBulkOperation = async function startBulkOperation ( bulkQuery ) {
386 if ( typeof bulkQuery !== 'string' || operationKind (bulkQuery) !== 'query' ) {
387 throw new ShopifyCliError ( 'a bulk operation runs only a GraphQL query' , { code: 'shopify_mutation_refused' });
388 }
389 return withThrottleRetries ( BULK_RUN_MUTATION , { query: bulkQuery }, { allowMutations: true });
390 };
391
392 return execute;
393 }
394
395 // Paged read: first: 250 with cursors and a fixed gap between paged calls. Each page is its own
396 // read with its own throttle budget. `connectionPath` names the connection in `data`; the query
397 // must take `$first` and `$after` and select `pageInfo { hasNextPage endCursor }`.
398 async function readAllPages ( execute , query , { connectionPath , variables = {}, sleep = defaultSleep, gapMs = PAGE_GAP_MS , pageSize = PAGE_SIZE } = {}) {
399 const nodes = [];
400 let after = null ;
401 for ( let page = 0 ; ; page += 1 ) {
402 if (page > 0 ) await sleep (gapMs);
403 const result = await execute (query, { ... variables, first: pageSize, after });
404 if (result.errors && result.errors. length ) return { nodes, errors: result.errors, pages: page + 1 };
405 const connection = connectionPath. reduce (( value , key ) => (value && typeof value === 'object' ? value[key] : undefined ), result.data);
406 if ( ! connection || ! Array. isArray (connection.nodes)) {
407 return { nodes, errors: [{ message: 'the paged read returned no connection at its path' , extensions: { code: 'CONNECTION_MISSING' } }], pages: page + 1 };
408 }
409 nodes. push ( ... connection.nodes);
410 const info = connection.pageInfo || {};
411 if ( ! info.hasNextPage || ! info.endCursor) return { nodes, errors: [], pages: page + 1 };
412 after = info.endCursor;
413 }
414 }
415
416 module . exports = {
417 SHOPIFY_CLI_VERSION,
418 SHOPIFY_CLI_NODE_MIN,
419 SHOPIFY_CLI_ENGINES_NODE,
420 SHOPIFY_ADMIN_API_VERSION,
421 PAGE_SIZE,
422 PAGE_GAP_MS,
423 THROTTLE_WAITS_MS,
424 BULK_RUN_MUTATION,
425 ShopifyCliError,
426 defaultToolsDir,
427 cliInstallDir,
428 cliPackageDir,
429 cliEntryPath,
430 adapterCommand,
431 readInstalledPackage,
432 assertInstalledVersion,
433 cliVersionChanged,
434 compareVersions,
435 checkNodeRuntime,
436 nodeRuntimeMessage,
437 cliChildEnv,
438 startCliProcess,
439 runFile,
440 operationKind,
441 graphqlErrorsFrom,
442 classifyCliFailure,
443 createStoreExecutor,
444 readAllPages,
445 errorCodes,
446 };