rastack 0.0.41 → 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.
@@ -9,7 +9,8 @@
9
9
  * [--region <r>] [--prefix <p>]
10
10
  * [--reuse-oidc] [--print] [--yes]
11
11
  *
12
- * It deploys `deploy/cloudformation/bootstrap.yaml`, which creates:
12
+ * It deploys the `bootstrap.yaml` template bundled with the `rastack` package
13
+ * (resolved by `resolveDeployAsset`, so it works from any repo), which creates:
13
14
  * • the S3 buckets that hold the Lambda placeholder + hosted templates, and
14
15
  * • the GitHub OIDC provider + trust that lets CI assume a deploy role.
15
16
  * It then uploads the Lambda placeholder bundle to the artifacts bucket, so the
@@ -43,6 +44,7 @@ import {
43
44
  canPrompt,
44
45
  gitRemoteUrl,
45
46
  loadConfig,
47
+ resolveDeployAsset,
46
48
  stackOutput,
47
49
  uploadPlaceholder,
48
50
  withPrompt,
@@ -152,8 +154,9 @@ function printPlan(cfg: DeployConfig, reuseOidc: boolean): void {
152
154
  console.log(
153
155
  `\n Bootstrap plan (${bootstrapStackName(cfg)} @ ${cfg.region})\n`,
154
156
  );
157
+ const template = resolveDeployAsset("bootstrap.yaml") ?? undefined;
155
158
  console.log(" ── Tier-0 bootstrap stack (bootstrap.yaml) ──");
156
- console.log("\n " + bootstrapStackCommand(cfg, reuseOidc) + "\n");
159
+ console.log("\n " + bootstrapStackCommand(cfg, reuseOidc, template) + "\n");
157
160
  console.log(
158
161
  " Then `rastack init` (or `rastack bootstrap` again) resolves this stack's\n" +
159
162
  " ArtifactsBucket to create app backends. Nothing here is app-specific.\n",
@@ -166,8 +169,16 @@ function printPlan(cfg: DeployConfig, reuseOidc: boolean): void {
166
169
  * already ask to reuse it.
167
170
  */
168
171
  function deployBootstrap(cfg: DeployConfig, reuseOidc: boolean): void {
172
+ const template = resolveDeployAsset("bootstrap.yaml");
173
+ if (!template) {
174
+ console.error(
175
+ " ✗ bootstrap.yaml template not found in the rastack package. Reinstall\n" +
176
+ " rastack (`npm i -g rastack`) or run from the framework repo.",
177
+ );
178
+ process.exit(1);
179
+ }
169
180
  try {
170
- awsRun(bootstrapStackArgs(cfg, reuseOidc));
181
+ awsRun(bootstrapStackArgs(cfg, reuseOidc, template));
171
182
  } catch (err) {
172
183
  if (reuseOidc) {
173
184
  console.error(
@@ -182,7 +193,7 @@ function deployBootstrap(cfg: DeployConfig, reuseOidc: boolean): void {
182
193
  " • retrying bootstrap, reusing the account's existing GitHub OIDC provider",
183
194
  );
184
195
  try {
185
- awsRun(bootstrapStackArgs(cfg, true));
196
+ awsRun(bootstrapStackArgs(cfg, true, template));
186
197
  } catch (retryErr) {
187
198
  console.error(
188
199
  `\n ✗ bootstrap stack deploy failed.\n ${(retryErr as Error).message}`,
@@ -237,8 +248,8 @@ function standUp(cfg: DeployConfig, reuseOidc: boolean): void {
237
248
  console.log("\n▸ Uploading the Lambda placeholder bundle");
238
249
  if (!uploadPlaceholder(artifactsBucket, cfg.region)) {
239
250
  console.error(
240
- " ✗ deploy/cloudformation/placeholder/bootstrap not found — the placeholder\n" +
241
- " was not uploaded. `rastack init` will upload it on the first app deploy.",
251
+ " ✗ Lambda placeholder bundle not found in the rastack package — the\n" +
252
+ " placeholder was not uploaded. `rastack init` will upload it on the first app deploy.",
242
253
  );
243
254
  } else {
244
255
  console.log(" ✓ placeholder uploaded");
@@ -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,
@@ -56,6 +55,7 @@ import {
56
55
  ghAvailable,
57
56
  gitRemoteUrl,
58
57
  loadConfig,
58
+ resolveDeployAsset,
59
59
  saveConfig,
60
60
  setRepoVariable,
61
61
  stackOutput,
@@ -209,15 +209,18 @@ function printPlan(cfg: DeployConfig): void {
209
209
  const withBucket = mergeConfig(cfg, {
210
210
  artifactsBucket: bucket ?? "<ArtifactsBucketName from bootstrap stack>",
211
211
  });
212
+ const appTemplate = resolveDeployAsset("app.yaml") ?? undefined;
212
213
  console.log(`\n Deployment plan (${cfg.stackName} @ ${cfg.region})\n`);
213
214
  console.log(
214
- ` ── 1. Bootstrap stack (${bootstrapStackName(cfg)}) — run only if it does not exist ──`,
215
+ ` ── Prerequisite: the tier-0 bootstrap stack (${bootstrapStackName(cfg)}) must exist ──`,
215
216
  );
216
- console.log("\n " + bootstrapStackCommand(cfg) + "\n");
217
- console.log(" ── 2. Per-app backend (app.yaml) ──");
218
- console.log("\n " + deployStackCommand(withBucket) + "\n");
219
217
  console.log(
220
- " ── 3. Wire these GitHub repo Variables from the stack Outputs ──",
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
+ console.log("\n " + deployStackCommand(withBucket, appTemplate) + "\n");
222
+ console.log(
223
+ " ── 2. Wire these GitHub repo Variables from the stack Outputs ──",
221
224
  );
222
225
  console.log(
223
226
  table(
@@ -246,38 +249,32 @@ async function standUp(
246
249
  process.exit(1);
247
250
  }
248
251
 
249
- // 1. Resolve the artifacts bucket; bootstrap the tier-0 stack if it is absent.
250
- 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(
251
260
  bootstrapStackName(cfg),
252
261
  BOOTSTRAP_ARTIFACTS_OUTPUT,
253
262
  cfg.region,
254
263
  );
255
- let templatesBucket = stackOutput(
264
+ const templatesBucket = stackOutput(
256
265
  bootstrapStackName(cfg),
257
266
  BOOTSTRAP_TEMPLATES_OUTPUT,
258
267
  cfg.region,
259
268
  );
260
269
  if (!artifactsBucket) {
261
- console.log(
262
- `\n▸ Bootstrap stack ${bootstrapStackName(cfg)} not found — creating it (once per account+region).`,
263
- );
264
- deployBootstrap(cfg);
265
- artifactsBucket = stackOutput(
266
- bootstrapStackName(cfg),
267
- BOOTSTRAP_ARTIFACTS_OUTPUT,
268
- cfg.region,
269
- );
270
- templatesBucket = stackOutput(
271
- bootstrapStackName(cfg),
272
- BOOTSTRAP_TEMPLATES_OUTPUT,
273
- 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`,
274
276
  );
275
- if (!artifactsBucket) {
276
- console.error(
277
- " ✗ bootstrap stack created but its ArtifactsBucket output could not be read.",
278
- );
279
- process.exit(1);
280
- }
277
+ process.exit(1);
281
278
  }
282
279
  console.log(` ✓ artifacts bucket: ${artifactsBucket}`);
283
280
 
@@ -285,7 +282,7 @@ async function standUp(
285
282
  console.log("\n▸ Uploading the Lambda placeholder bundle");
286
283
  if (!uploadPlaceholder(artifactsBucket, cfg.region)) {
287
284
  console.error(
288
- " ✗ deploy/cloudformation/placeholder/bootstrap not found — cannot create the function.",
285
+ " ✗ Lambda placeholder bundle not found in the rastack package — cannot create the function.",
289
286
  );
290
287
  process.exit(1);
291
288
  }
@@ -295,9 +292,17 @@ async function standUp(
295
292
  artifactsBucket,
296
293
  templatesBucket: templatesBucket ?? undefined,
297
294
  });
295
+ const appTemplate = resolveDeployAsset("app.yaml");
296
+ if (!appTemplate) {
297
+ console.error(
298
+ " ✗ app.yaml template not found in the rastack package. Reinstall rastack\n" +
299
+ " (`npm i -g rastack`) or run from the framework repo.",
300
+ );
301
+ process.exit(1);
302
+ }
298
303
  console.log(`\n▸ Deploying ${cfg.stackName}`);
299
304
  try {
300
- awsRun(deployStackArgs(cfg));
305
+ awsRun(deployStackArgs(cfg, appTemplate));
301
306
  } catch (err) {
302
307
  console.error(
303
308
  `\n ✗ aws cloudformation deploy failed for ${cfg.stackName}.\n ${(err as Error).message}`,
@@ -370,28 +375,6 @@ function wireRepoVariables(cfg: DeployConfig, interactive: boolean): void {
370
375
  }
371
376
  }
372
377
 
373
- /** Deploy the bootstrap stack, retrying once against an existing OIDC provider. */
374
- function deployBootstrap(cfg: DeployConfig): void {
375
- try {
376
- awsRun(bootstrapStackArgs(cfg));
377
- } catch {
378
- // The most common cause is an account that already has a GitHub OIDC
379
- // provider (only one is allowed per URL). Retry telling the template to
380
- // reuse it instead of creating a second.
381
- console.log(
382
- " • retrying bootstrap, reusing the account's existing GitHub OIDC provider",
383
- );
384
- try {
385
- awsRun(bootstrapStackArgs(cfg, true));
386
- } catch (err) {
387
- console.error(
388
- `\n ✗ bootstrap stack deploy failed.\n ${(err as Error).message}`,
389
- );
390
- process.exit(1);
391
- }
392
- }
393
- }
394
-
395
378
  function printSummary(cfg: DeployConfig): void {
396
379
  console.log(
397
380
  `\n ✓ ${cfg.app} is deployed (${cfg.stackName} @ ${cfg.region})`,
@@ -483,7 +466,7 @@ async function main(argv: string[]): Promise<void> {
483
466
 
484
467
  if (canPrompt()) {
485
468
  const ok = await confirm(
486
- `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]`,
487
470
  );
488
471
  if (!ok) {
489
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}`;
@@ -243,6 +243,19 @@ describe("stack parameters + launch command", () => {
243
243
  "deploy",
244
244
  ]);
245
245
  });
246
+
247
+ it("accepts a template-file override (the package-resolved path)", () => {
248
+ // `init` runs in a consumer repo that carries no framework IaC, so it passes
249
+ // the path the template was resolved to inside the `rastack` package.
250
+ const cfg = base({ artifactsBucket: "art" });
251
+ const abs = "/opt/rastack/dist/templates/cloudformation/app.yaml";
252
+ expect(deployStackArgs(cfg, abs)).toContain(abs);
253
+ expect(deployStackCommand(cfg, abs)).toContain(`--template-file ${abs}`);
254
+ // Default is unchanged when no override is given.
255
+ expect(deployStackCommand(cfg)).toContain(
256
+ "--template-file deploy/cloudformation/app.yaml",
257
+ );
258
+ });
246
259
  });
247
260
 
248
261
  describe("bootstrap stack", () => {
@@ -281,6 +294,21 @@ describe("bootstrap stack", () => {
281
294
  expect(`aws ${bootstrapStackArgs(base()).join(" ")}`).toBe(cmd);
282
295
  });
283
296
 
297
+ it("accepts a template-file override (the package-resolved path)", () => {
298
+ const abs = "/opt/rastack/dist/templates/cloudformation/bootstrap.yaml";
299
+ expect(bootstrapStackArgs(base(), false, abs)).toContain(abs);
300
+ expect(bootstrapStackCommand(base(), true, abs)).toContain(
301
+ `--template-file ${abs}`,
302
+ );
303
+ // Reuse-OIDC + override compose without disturbing the default path.
304
+ expect(bootstrapStackArgs(base(), true, abs)).toContain(
305
+ "CreateOIDCProvider=false",
306
+ );
307
+ expect(bootstrapStackCommand(base())).toContain(
308
+ "--template-file deploy/cloudformation/bootstrap.yaml",
309
+ );
310
+ });
311
+
284
312
  it("validates only the account-level fields, not the per-app ones", () => {
285
313
  // A config with no `app` is fine for bootstrap — it never touches app.yaml.
286
314
  const cfg = normalizeConfig({
@@ -513,3 +541,28 @@ describe("scaffold", () => {
513
541
  expect(files.map((f) => f.path)).toEqual([CONFIG_PATH]);
514
542
  });
515
543
  });
544
+
545
+ describe("resolveDeployAsset — bundled template resolution", () => {
546
+ // eslint-disable-next-line @typescript-eslint/no-var-requires
547
+ const { resolveDeployAsset } = require("../src/deploy/io");
548
+ const path = require("path");
549
+
550
+ it("resolves the framework templates to an existing file", () => {
551
+ for (const name of ["app.yaml", "bootstrap.yaml"]) {
552
+ const resolved = resolveDeployAsset(name);
553
+ expect(resolved).not.toBeNull();
554
+ expect(path.isAbsolute(resolved)).toBe(true);
555
+ expect(resolved.endsWith(name)).toBe(true);
556
+ }
557
+ });
558
+
559
+ it("resolves the Lambda placeholder bundle", () => {
560
+ const resolved = resolveDeployAsset(path.join("placeholder", "bootstrap"));
561
+ expect(resolved).not.toBeNull();
562
+ expect(resolved.endsWith(path.join("placeholder", "bootstrap"))).toBe(true);
563
+ });
564
+
565
+ it("returns null for an asset that does not exist", () => {
566
+ expect(resolveDeployAsset("does-not-exist.yaml")).toBeNull();
567
+ });
568
+ });
@@ -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", () => {