rastack 0.0.42 → 0.0.43

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,14 @@
2
2
 
3
3
  All notable changes to this project will be documented in this file. See [standard-version](https://github.com/conventional-changelog/standard-version) for commit guidelines.
4
4
 
5
+ ### [0.0.43](https://github.com/theserverkid/reactapistack/compare/v0.0.42...v0.0.43) (2026-07-12)
6
+
7
+
8
+ ### Bug Fixes
9
+
10
+ * **init:** require the bootstrap stack instead of creating it ([085c59d](https://github.com/theserverkid/reactapistack/commit/085c59df4a61c5d28312edb7d0895c5b2f0442b5))
11
+ * **update:** silence Node DEP0190 when launching npm on Windows ([959fe61](https://github.com/theserverkid/reactapistack/commit/959fe6177ec4e6e0d634e2751a1099717f2bda3b))
12
+
5
13
  ### [0.0.42](https://github.com/theserverkid/reactapistack/compare/v0.0.41...v0.0.42) (2026-07-12)
6
14
 
7
15
 
@@ -11,8 +11,9 @@
11
11
  * 1. writes `.rastack/deploy.json` + `.github/workflows/{ci,deploy-backend}.yml`
12
12
  * (GitHub coordinates default from the `origin` remote; missing fields are
13
13
  * prompted for on a TTY);
14
- * 2. resolves the tier-0 bootstrap stack's artifacts bucket — and CREATES the
15
- * bootstrap stack if it does not exist yet;
14
+ * 2. resolves the tier-0 bootstrap stack's artifacts bucket — erroring with a
15
+ * pointer to `rastack bootstrap` when that account+region stack is missing
16
+ * (init is per-app and never creates account-level infrastructure);
16
17
  * 3. uploads the Lambda placeholder bundle to that bucket;
17
18
  * 4. deploys the per-app backend (app.yaml) with the resolved ArtifactsBucket;
18
19
  * 5. reads the stack Outputs back into the config and prints the two GitHub repo
@@ -12,8 +12,9 @@
12
12
  * 1. writes `.rastack/deploy.json` + `.github/workflows/{ci,deploy-backend}.yml`
13
13
  * (GitHub coordinates default from the `origin` remote; missing fields are
14
14
  * prompted for on a TTY);
15
- * 2. resolves the tier-0 bootstrap stack's artifacts bucket — and CREATES the
16
- * bootstrap stack if it does not exist yet;
15
+ * 2. resolves the tier-0 bootstrap stack's artifacts bucket — erroring with a
16
+ * pointer to `rastack bootstrap` when that account+region stack is missing
17
+ * (init is per-app and never creates account-level infrastructure);
17
18
  * 3. uploads the Lambda placeholder bundle to that bucket;
18
19
  * 4. deploys the per-app backend (app.yaml) with the resolved ArtifactsBucket;
19
20
  * 5. reads the stack Outputs back into the config and prints the two GitHub repo
@@ -187,14 +188,13 @@ function printPlan(cfg) {
187
188
  const withBucket = (0, config_1.mergeConfig)(cfg, {
188
189
  artifactsBucket: bucket ?? "<ArtifactsBucketName from bootstrap stack>",
189
190
  });
190
- const bootstrapTemplate = (0, io_1.resolveDeployAsset)("bootstrap.yaml") ?? undefined;
191
191
  const appTemplate = (0, io_1.resolveDeployAsset)("app.yaml") ?? undefined;
192
192
  console.log(`\n Deployment plan (${cfg.stackName} @ ${cfg.region})\n`);
193
- console.log(` ── 1. Bootstrap stack (${(0, config_1.bootstrapStackName)(cfg)}) — run only if it does not exist ──`);
194
- console.log("\n " + (0, config_1.bootstrapStackCommand)(cfg, false, bootstrapTemplate) + "\n");
195
- console.log(" ── 2. Per-app backend (app.yaml) ──");
193
+ console.log(` ── Prerequisite: the tier-0 bootstrap stack (${(0, config_1.bootstrapStackName)(cfg)}) must exist ──`);
194
+ console.log(` Create it once per account+region (init does not): rastack bootstrap --region ${cfg.region}\n`);
195
+ console.log(" ── 1. Per-app backend (app.yaml) — what init deploys ──");
196
196
  console.log("\n " + (0, config_1.deployStackCommand)(withBucket, appTemplate) + "\n");
197
- console.log(" ── 3. Wire these GitHub repo Variables from the stack Outputs ──");
197
+ console.log(" ── 2. Wire these GitHub repo Variables from the stack Outputs ──");
198
198
  console.log(table((0, config_1.repoVariables)(cfg).map((v) => ({
199
199
  key: v.name,
200
200
  value: v.value || `<${v.from}>`,
@@ -211,18 +211,22 @@ async function standUp(cfg, interactive) {
211
211
  " or re-run with --print to see the commands, or --no-deploy to skip.");
212
212
  process.exit(1);
213
213
  }
214
- // 1. Resolve the artifacts bucket; bootstrap the tier-0 stack if it is absent.
215
- let artifactsBucket = (0, io_1.stackOutput)((0, config_1.bootstrapStackName)(cfg), config_1.BOOTSTRAP_ARTIFACTS_OUTPUT, cfg.region);
216
- let templatesBucket = (0, io_1.stackOutput)((0, config_1.bootstrapStackName)(cfg), config_1.BOOTSTRAP_TEMPLATES_OUTPUT, cfg.region);
214
+ // 1. Resolve the artifacts bucket from the tier-0 bootstrap stack. That stack
215
+ // is an account+region once-off — the S3 buckets that host the templates +
216
+ // Lambda placeholder, and the GitHub OIDC provider. `init` is a per-app
217
+ // command and deliberately does NOT create it: standing up account-level
218
+ // infrastructure is `rastack bootstrap`'s job (run once by an account admin),
219
+ // so a missing bootstrap stack is a clear, actionable error, not an implicit
220
+ // account mutation buried inside an app deploy.
221
+ const artifactsBucket = (0, io_1.stackOutput)((0, config_1.bootstrapStackName)(cfg), config_1.BOOTSTRAP_ARTIFACTS_OUTPUT, cfg.region);
222
+ const templatesBucket = (0, io_1.stackOutput)((0, config_1.bootstrapStackName)(cfg), config_1.BOOTSTRAP_TEMPLATES_OUTPUT, cfg.region);
217
223
  if (!artifactsBucket) {
218
- console.log(`\n▸ Bootstrap stack ${(0, config_1.bootstrapStackName)(cfg)} not found — creating it (once per account+region).`);
219
- deployBootstrap(cfg);
220
- artifactsBucket = (0, io_1.stackOutput)((0, config_1.bootstrapStackName)(cfg), config_1.BOOTSTRAP_ARTIFACTS_OUTPUT, cfg.region);
221
- templatesBucket = (0, io_1.stackOutput)((0, config_1.bootstrapStackName)(cfg), config_1.BOOTSTRAP_TEMPLATES_OUTPUT, cfg.region);
222
- if (!artifactsBucket) {
223
- console.error(" ✗ bootstrap stack created but its ArtifactsBucket output could not be read.");
224
- process.exit(1);
225
- }
224
+ console.error(`\n ✗ Bootstrap stack ${(0, config_1.bootstrapStackName)(cfg)} not found in ${cfg.region}.\n` +
225
+ " It is the once-per-account+region step that hosts the CloudFormation\n" +
226
+ " templates and the Lambda placeholder every app deploy reads. Create it\n" +
227
+ " first (an account admin does this once), then re-run `rastack init`:\n\n" +
228
+ ` rastack bootstrap --region ${cfg.region}\n`);
229
+ process.exit(1);
226
230
  }
227
231
  console.log(` ✓ artifacts bucket: ${artifactsBucket}`);
228
232
  // 2. Upload the placeholder the function is created from.
@@ -305,31 +309,6 @@ function wireRepoVariables(cfg, interactive) {
305
309
  }
306
310
  }
307
311
  }
308
- /** Deploy the bootstrap stack, retrying once against an existing OIDC provider. */
309
- function deployBootstrap(cfg) {
310
- const template = (0, io_1.resolveDeployAsset)("bootstrap.yaml");
311
- if (!template) {
312
- console.error(" ✗ bootstrap.yaml template not found in the rastack package. Reinstall\n" +
313
- " rastack (`npm i -g rastack`) or run from the framework repo.");
314
- process.exit(1);
315
- }
316
- try {
317
- (0, io_1.awsRun)((0, config_1.bootstrapStackArgs)(cfg, false, template));
318
- }
319
- catch {
320
- // The most common cause is an account that already has a GitHub OIDC
321
- // provider (only one is allowed per URL). Retry telling the template to
322
- // reuse it instead of creating a second.
323
- console.log(" • retrying bootstrap, reusing the account's existing GitHub OIDC provider");
324
- try {
325
- (0, io_1.awsRun)((0, config_1.bootstrapStackArgs)(cfg, true, template));
326
- }
327
- catch (err) {
328
- console.error(`\n ✗ bootstrap stack deploy failed.\n ${err.message}`);
329
- process.exit(1);
330
- }
331
- }
332
- }
333
312
  function printSummary(cfg) {
334
313
  console.log(`\n ✓ ${cfg.app} is deployed (${cfg.stackName} @ ${cfg.region})`);
335
314
  if (cfg.outputs?.apiUrl)
@@ -399,7 +378,7 @@ async function main(argv) {
399
378
  return;
400
379
  }
401
380
  if ((0, io_1.canPrompt)()) {
402
- const ok = await confirm(`Deploy ${cfg.stackName} to AWS ${cfg.region} now? (creates the bootstrap stack if needed) [y/N]`);
381
+ const ok = await confirm(`Deploy ${cfg.stackName} to AWS ${cfg.region} now? (requires the bootstrap stack to exist) [y/N]`);
403
382
  if (!ok) {
404
383
  console.log("\n Skipped the deploy. Re-run to deploy, or `rastack init --print` for the commands.\n");
405
384
  return;
@@ -74,15 +74,24 @@ function readVersion(file) {
74
74
  return "unknown";
75
75
  }
76
76
  }
77
+ /**
78
+ * Run npm and return stdout, or throw on failure. Windows must launch the
79
+ * `npm.cmd` shim through a shell; we pass one command string to `execSync`
80
+ * rather than an args array with `shell: true` (Node 22+ DEP0190 — args would be
81
+ * concatenated unescaped). The only non-literal token, the version, is already
82
+ * sanitized (`sanitizeVersion`). POSIX keeps the shell-free `execFile`.
83
+ */
84
+ function runNpm(args, opts) {
85
+ // nosemgrep: javascript.lang.security.detect-child-process.detect-child-process
86
+ return NPM_SHELL
87
+ ? (0, child_process_1.execSync)((0, update_1.npmShellCommand)(NPM, args), opts)
88
+ : (0, child_process_1.execFileSync)(NPM, args, opts);
89
+ }
77
90
  /** Capture `npm <args>` stdout, or null when npm is absent / the call fails. */
78
91
  function npmCapture(args) {
79
92
  try {
80
- return (0, child_process_1.execFileSync)(NPM, args, {
81
- stdio: ["ignore", "pipe", "pipe"],
82
- shell: NPM_SHELL,
83
- })
84
- .toString()
85
- .trim();
93
+ const out = runNpm(args, { stdio: ["ignore", "pipe", "pipe"] });
94
+ return (out ?? "").toString().trim();
86
95
  }
87
96
  catch {
88
97
  return null;
@@ -129,8 +138,7 @@ function main(argv) {
129
138
  // the `.cmd` shim is launched through a shell (EINVAL otherwise); the only
130
139
  // caller-supplied token — the version — is sanitized in `parseUpdateFlags`,
131
140
  // and every other arg is a fixed literal, so nothing injectable reaches it.
132
- // nosemgrep: javascript.lang.security.detect-child-process.detect-child-process
133
- (0, child_process_1.execFileSync)(NPM, args, { stdio: "inherit", env, shell: NPM_SHELL });
141
+ runNpm(args, { stdio: "inherit", env });
134
142
  }
135
143
  catch (err) {
136
144
  console.error(`\n ✗ npm failed to update rastack. Re-run for detail:\n npm install -g ${(0, update_1.npmInstallArgs)(flags.version)[2]}\n ${err.message}`);
package/dist/update.d.ts CHANGED
@@ -52,6 +52,17 @@ export declare function npmBinary(platform?: string): string;
52
52
  * shell means no injection surface).
53
53
  */
54
54
  export declare function npmSpawnShell(platform?: string): boolean;
55
+ /**
56
+ * The single command string used to launch npm on platforms that must go through
57
+ * a shell (Windows — see `npmSpawnShell`). Node 22+ deprecates passing an args
58
+ * *array* together with `shell: true` (DEP0190: the args are concatenated, not
59
+ * escaped), so the shell path builds one string and runs it via `execSync`
60
+ * instead. Joining with spaces is safe here because every token is a fixed
61
+ * literal except the version, which `sanitizeVersion` constrains to
62
+ * `[A-Za-z0-9._-]` (no shell metacharacters). POSIX never uses this — it keeps
63
+ * the shell-free, array-based `execFile`, which has no concatenation at all.
64
+ */
65
+ export declare function npmShellCommand(binary: string, args: string[]): string;
55
66
  /** The npm package spec to install for a target version. */
56
67
  export declare function installSpec(version: string): string;
57
68
  /** The `npm <args>` (after the `npm` binary) that installs the target globally. */
package/dist/update.js CHANGED
@@ -15,6 +15,7 @@ exports.parseUpdateFlags = parseUpdateFlags;
15
15
  exports.sanitizeVersion = sanitizeVersion;
16
16
  exports.npmBinary = npmBinary;
17
17
  exports.npmSpawnShell = npmSpawnShell;
18
+ exports.npmShellCommand = npmShellCommand;
18
19
  exports.installSpec = installSpec;
19
20
  exports.npmInstallArgs = npmInstallArgs;
20
21
  exports.globalPrefixFromPackageDir = globalPrefixFromPackageDir;
@@ -88,6 +89,19 @@ function npmBinary(platform = process.platform) {
88
89
  function npmSpawnShell(platform = process.platform) {
89
90
  return platform === "win32";
90
91
  }
92
+ /**
93
+ * The single command string used to launch npm on platforms that must go through
94
+ * a shell (Windows — see `npmSpawnShell`). Node 22+ deprecates passing an args
95
+ * *array* together with `shell: true` (DEP0190: the args are concatenated, not
96
+ * escaped), so the shell path builds one string and runs it via `execSync`
97
+ * instead. Joining with spaces is safe here because every token is a fixed
98
+ * literal except the version, which `sanitizeVersion` constrains to
99
+ * `[A-Za-z0-9._-]` (no shell metacharacters). POSIX never uses this — it keeps
100
+ * the shell-free, array-based `execFile`, which has no concatenation at all.
101
+ */
102
+ function npmShellCommand(binary, args) {
103
+ return [binary, ...args].join(" ");
104
+ }
91
105
  /** The npm package spec to install for a target version. */
92
106
  function installSpec(version) {
93
107
  return !version || version === "latest" ? "rastack" : `rastack@${version}`;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "rastack",
3
- "version": "0.0.42",
3
+ "version": "0.0.43",
4
4
  "description": "",
5
5
  "main": "runtime.ts",
6
6
  "types": "runtime.ts",
@@ -12,8 +12,9 @@
12
12
  * 1. writes `.rastack/deploy.json` + `.github/workflows/{ci,deploy-backend}.yml`
13
13
  * (GitHub coordinates default from the `origin` remote; missing fields are
14
14
  * prompted for on a TTY);
15
- * 2. resolves the tier-0 bootstrap stack's artifacts bucket — and CREATES the
16
- * bootstrap stack if it does not exist yet;
15
+ * 2. resolves the tier-0 bootstrap stack's artifacts bucket — erroring with a
16
+ * pointer to `rastack bootstrap` when that account+region stack is missing
17
+ * (init is per-app and never creates account-level infrastructure);
17
18
  * 3. uploads the Lambda placeholder bundle to that bucket;
18
19
  * 4. deploys the per-app backend (app.yaml) with the resolved ArtifactsBucket;
19
20
  * 5. reads the stack Outputs back into the config and prints the two GitHub repo
@@ -33,8 +34,6 @@ import {
33
34
  DeployConfig,
34
35
  DeployOutputs,
35
36
  PartialDeployConfig,
36
- bootstrapStackArgs,
37
- bootstrapStackCommand,
38
37
  bootstrapStackName,
39
38
  deployStackArgs,
40
39
  deployStackCommand,
@@ -210,17 +209,18 @@ function printPlan(cfg: DeployConfig): void {
210
209
  const withBucket = mergeConfig(cfg, {
211
210
  artifactsBucket: bucket ?? "<ArtifactsBucketName from bootstrap stack>",
212
211
  });
213
- const bootstrapTemplate = resolveDeployAsset("bootstrap.yaml") ?? undefined;
214
212
  const appTemplate = resolveDeployAsset("app.yaml") ?? undefined;
215
213
  console.log(`\n Deployment plan (${cfg.stackName} @ ${cfg.region})\n`);
216
214
  console.log(
217
- ` ── 1. Bootstrap stack (${bootstrapStackName(cfg)}) — run only if it does not exist ──`,
215
+ ` ── Prerequisite: the tier-0 bootstrap stack (${bootstrapStackName(cfg)}) must exist ──`,
218
216
  );
219
- console.log("\n " + bootstrapStackCommand(cfg, false, bootstrapTemplate) + "\n");
220
- console.log(" ── 2. Per-app backend (app.yaml) ──");
217
+ console.log(
218
+ ` Create it once per account+region (init does not): rastack bootstrap --region ${cfg.region}\n`,
219
+ );
220
+ console.log(" ── 1. Per-app backend (app.yaml) — what init deploys ──");
221
221
  console.log("\n " + deployStackCommand(withBucket, appTemplate) + "\n");
222
222
  console.log(
223
- " ── 3. Wire these GitHub repo Variables from the stack Outputs ──",
223
+ " ── 2. Wire these GitHub repo Variables from the stack Outputs ──",
224
224
  );
225
225
  console.log(
226
226
  table(
@@ -249,38 +249,32 @@ async function standUp(
249
249
  process.exit(1);
250
250
  }
251
251
 
252
- // 1. Resolve the artifacts bucket; bootstrap the tier-0 stack if it is absent.
253
- let artifactsBucket = stackOutput(
252
+ // 1. Resolve the artifacts bucket from the tier-0 bootstrap stack. That stack
253
+ // is an account+region once-off — the S3 buckets that host the templates +
254
+ // Lambda placeholder, and the GitHub OIDC provider. `init` is a per-app
255
+ // command and deliberately does NOT create it: standing up account-level
256
+ // infrastructure is `rastack bootstrap`'s job (run once by an account admin),
257
+ // so a missing bootstrap stack is a clear, actionable error, not an implicit
258
+ // account mutation buried inside an app deploy.
259
+ const artifactsBucket = stackOutput(
254
260
  bootstrapStackName(cfg),
255
261
  BOOTSTRAP_ARTIFACTS_OUTPUT,
256
262
  cfg.region,
257
263
  );
258
- let templatesBucket = stackOutput(
264
+ const templatesBucket = stackOutput(
259
265
  bootstrapStackName(cfg),
260
266
  BOOTSTRAP_TEMPLATES_OUTPUT,
261
267
  cfg.region,
262
268
  );
263
269
  if (!artifactsBucket) {
264
- console.log(
265
- `\n▸ Bootstrap stack ${bootstrapStackName(cfg)} not found — creating it (once per account+region).`,
266
- );
267
- deployBootstrap(cfg);
268
- artifactsBucket = stackOutput(
269
- bootstrapStackName(cfg),
270
- BOOTSTRAP_ARTIFACTS_OUTPUT,
271
- cfg.region,
272
- );
273
- templatesBucket = stackOutput(
274
- bootstrapStackName(cfg),
275
- BOOTSTRAP_TEMPLATES_OUTPUT,
276
- cfg.region,
270
+ console.error(
271
+ `\n ✗ Bootstrap stack ${bootstrapStackName(cfg)} not found in ${cfg.region}.\n` +
272
+ " It is the once-per-account+region step that hosts the CloudFormation\n" +
273
+ " templates and the Lambda placeholder every app deploy reads. Create it\n" +
274
+ " first (an account admin does this once), then re-run `rastack init`:\n\n" +
275
+ ` rastack bootstrap --region ${cfg.region}\n`,
277
276
  );
278
- if (!artifactsBucket) {
279
- console.error(
280
- " ✗ bootstrap stack created but its ArtifactsBucket output could not be read.",
281
- );
282
- process.exit(1);
283
- }
277
+ process.exit(1);
284
278
  }
285
279
  console.log(` ✓ artifacts bucket: ${artifactsBucket}`);
286
280
 
@@ -381,36 +375,6 @@ function wireRepoVariables(cfg: DeployConfig, interactive: boolean): void {
381
375
  }
382
376
  }
383
377
 
384
- /** Deploy the bootstrap stack, retrying once against an existing OIDC provider. */
385
- function deployBootstrap(cfg: DeployConfig): void {
386
- const template = resolveDeployAsset("bootstrap.yaml");
387
- if (!template) {
388
- console.error(
389
- " ✗ bootstrap.yaml template not found in the rastack package. Reinstall\n" +
390
- " rastack (`npm i -g rastack`) or run from the framework repo.",
391
- );
392
- process.exit(1);
393
- }
394
- try {
395
- awsRun(bootstrapStackArgs(cfg, false, template));
396
- } catch {
397
- // The most common cause is an account that already has a GitHub OIDC
398
- // provider (only one is allowed per URL). Retry telling the template to
399
- // reuse it instead of creating a second.
400
- console.log(
401
- " • retrying bootstrap, reusing the account's existing GitHub OIDC provider",
402
- );
403
- try {
404
- awsRun(bootstrapStackArgs(cfg, true, template));
405
- } catch (err) {
406
- console.error(
407
- `\n ✗ bootstrap stack deploy failed.\n ${(err as Error).message}`,
408
- );
409
- process.exit(1);
410
- }
411
- }
412
- }
413
-
414
378
  function printSummary(cfg: DeployConfig): void {
415
379
  console.log(
416
380
  `\n ✓ ${cfg.app} is deployed (${cfg.stackName} @ ${cfg.region})`,
@@ -502,7 +466,7 @@ async function main(argv: string[]): Promise<void> {
502
466
 
503
467
  if (canPrompt()) {
504
468
  const ok = await confirm(
505
- `Deploy ${cfg.stackName} to AWS ${cfg.region} now? (creates the bootstrap stack if needed) [y/N]`,
469
+ `Deploy ${cfg.stackName} to AWS ${cfg.region} now? (requires the bootstrap stack to exist) [y/N]`,
506
470
  );
507
471
  if (!ok) {
508
472
  console.log(
@@ -17,13 +17,14 @@
17
17
  * global location. Pure decisions live in `update.ts`; this is the IO wrapper.
18
18
  */
19
19
 
20
- import { execFileSync } from "child_process";
20
+ import { execFileSync, execSync } from "child_process";
21
21
  import { readFileSync } from "fs";
22
22
  import * as path from "path";
23
23
  import {
24
24
  globalPrefixFromPackageDir,
25
25
  npmBinary,
26
26
  npmInstallArgs,
27
+ npmShellCommand,
27
28
  npmSpawnShell,
28
29
  parseUpdateFlags,
29
30
  updateAvailable,
@@ -52,15 +53,28 @@ function readVersion(file: string): string {
52
53
  }
53
54
  }
54
55
 
56
+ /**
57
+ * Run npm and return stdout, or throw on failure. Windows must launch the
58
+ * `npm.cmd` shim through a shell; we pass one command string to `execSync`
59
+ * rather than an args array with `shell: true` (Node 22+ DEP0190 — args would be
60
+ * concatenated unescaped). The only non-literal token, the version, is already
61
+ * sanitized (`sanitizeVersion`). POSIX keeps the shell-free `execFile`.
62
+ */
63
+ function runNpm(
64
+ args: string[],
65
+ opts: { stdio: "inherit" | ["ignore", "pipe", "pipe"]; env?: NodeJS.ProcessEnv },
66
+ ): Buffer | null {
67
+ // nosemgrep: javascript.lang.security.detect-child-process.detect-child-process
68
+ return NPM_SHELL
69
+ ? execSync(npmShellCommand(NPM, args), opts)
70
+ : execFileSync(NPM, args, opts);
71
+ }
72
+
55
73
  /** Capture `npm <args>` stdout, or null when npm is absent / the call fails. */
56
74
  function npmCapture(args: string[]): string | null {
57
75
  try {
58
- return execFileSync(NPM, args, {
59
- stdio: ["ignore", "pipe", "pipe"],
60
- shell: NPM_SHELL,
61
- })
62
- .toString()
63
- .trim();
76
+ const out = runNpm(args, { stdio: ["ignore", "pipe", "pipe"] });
77
+ return (out ?? "").toString().trim();
64
78
  } catch {
65
79
  return null;
66
80
  }
@@ -116,8 +130,7 @@ function main(argv: string[]): void {
116
130
  // the `.cmd` shim is launched through a shell (EINVAL otherwise); the only
117
131
  // caller-supplied token — the version — is sanitized in `parseUpdateFlags`,
118
132
  // and every other arg is a fixed literal, so nothing injectable reaches it.
119
- // nosemgrep: javascript.lang.security.detect-child-process.detect-child-process
120
- execFileSync(NPM, args, { stdio: "inherit", env, shell: NPM_SHELL });
133
+ runNpm(args, { stdio: "inherit", env });
121
134
  } catch (err) {
122
135
  console.error(
123
136
  `\n ✗ npm failed to update rastack. Re-run for detail:\n npm install -g ${npmInstallArgs(flags.version)[2]}\n ${(err as Error).message}`,
package/src/update.ts CHANGED
@@ -90,6 +90,20 @@ export function npmSpawnShell(platform: string = process.platform): boolean {
90
90
  return platform === "win32";
91
91
  }
92
92
 
93
+ /**
94
+ * The single command string used to launch npm on platforms that must go through
95
+ * a shell (Windows — see `npmSpawnShell`). Node 22+ deprecates passing an args
96
+ * *array* together with `shell: true` (DEP0190: the args are concatenated, not
97
+ * escaped), so the shell path builds one string and runs it via `execSync`
98
+ * instead. Joining with spaces is safe here because every token is a fixed
99
+ * literal except the version, which `sanitizeVersion` constrains to
100
+ * `[A-Za-z0-9._-]` (no shell metacharacters). POSIX never uses this — it keeps
101
+ * the shell-free, array-based `execFile`, which has no concatenation at all.
102
+ */
103
+ export function npmShellCommand(binary: string, args: string[]): string {
104
+ return [binary, ...args].join(" ");
105
+ }
106
+
93
107
  /** The npm package spec to install for a target version. */
94
108
  export function installSpec(version: string): string {
95
109
  return !version || version === "latest" ? "rastack" : `rastack@${version}`;
@@ -4,6 +4,7 @@ import {
4
4
  installSpec,
5
5
  npmBinary,
6
6
  npmInstallArgs,
7
+ npmShellCommand,
7
8
  npmSpawnShell,
8
9
  parseUpdateFlags,
9
10
  sanitizeVersion,
@@ -67,6 +68,18 @@ describe("update — npm spec + args", () => {
67
68
  expect(npmSpawnShell("linux")).toBe(false);
68
69
  expect(npmSpawnShell("darwin")).toBe(false);
69
70
  });
71
+
72
+ it("joins the shell command as one string (avoids the Node DEP0190 warning)", () => {
73
+ // The Windows path runs npm via `execSync(<string>)` rather than
74
+ // `execFileSync(npm, args, { shell: true })`, which Node 22+ deprecates
75
+ // (args concatenated, not escaped). The sanitized version keeps it safe.
76
+ expect(npmShellCommand("npm.cmd", npmInstallArgs("latest"))).toBe(
77
+ "npm.cmd install -g rastack --no-fund --no-audit --loglevel=error",
78
+ );
79
+ expect(npmShellCommand("npm.cmd", npmInstallArgs("0.0.42"))).toContain(
80
+ "rastack@0.0.42",
81
+ );
82
+ });
70
83
  });
71
84
 
72
85
  describe("update — version sanitization", () => {