rastack 0.0.62 → 0.0.63

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.
@@ -33,18 +33,28 @@ import {
33
33
  BOOTSTRAP_TEMPLATES_OUTPUT,
34
34
  DeployConfig,
35
35
  DeployOutputs,
36
+ EnvName,
37
+ ENV_NAMES,
36
38
  PartialDeployConfig,
37
39
  bootstrapStackName,
38
- deployStackArgs,
39
- deployStackCommand,
40
+ deployStackCommandForEnv,
41
+ deployStackArgsForEnv,
42
+ ghEnvironmentCreateArgs,
40
43
  ghVariableSetArgs,
41
44
  mergeConfig,
42
45
  normalizeConfig,
43
46
  parseGitHubRemote,
44
- repoVariables,
47
+ repoVariablesForEnv,
48
+ resolveEnvironment,
45
49
  validateConfig,
46
50
  } from "./deploy/config";
47
- import { scaffoldFiles } from "./deploy/workflows";
51
+ import {
52
+ ENV_PATH,
53
+ GITIGNORE_PATH,
54
+ applyEnvValues,
55
+ gitignoreWith,
56
+ scaffoldFiles,
57
+ } from "./deploy/workflows";
48
58
  import {
49
59
  ask,
50
60
  awsAvailable,
@@ -53,6 +63,7 @@ import {
53
63
  ghAuthLogin,
54
64
  ghAuthenticated,
55
65
  ghAvailable,
66
+ ghEnvironmentCreate,
56
67
  gitRemoteUrl,
57
68
  loadConfig,
58
69
  resolveDeployAsset,
@@ -65,18 +76,30 @@ import {
65
76
 
66
77
  interface Flags {
67
78
  patch: PartialDeployConfig;
79
+ /** Which environments to stand up (default: both). */
80
+ envs: EnvName[];
68
81
  force: boolean;
69
82
  noWorkflows: boolean;
70
83
  noDeploy: boolean;
71
84
  print: boolean;
72
85
  }
73
86
 
87
+ function parseEnvFlag(value: string): EnvName[] {
88
+ const v = value.trim().toLowerCase();
89
+ if (v === "all" || v === "") return [...ENV_NAMES];
90
+ if ((ENV_NAMES as readonly string[]).includes(v)) return [v as EnvName];
91
+ console.error(`Unknown --env: expected dev | prod | all.`);
92
+ process.exit(1);
93
+ }
94
+
74
95
  function parseFlags(argv: string[]): Flags {
75
- const patch: PartialDeployConfig = { github: {} };
96
+ const patch: PartialDeployConfig = { github: {}, environments: {} };
97
+ let envs: EnvName[] = [...ENV_NAMES];
76
98
  let force = false;
77
99
  let noWorkflows = false;
78
100
  let noDeploy = false;
79
101
  let print = false;
102
+ const devPatch = () => (patch.environments!.dev ??= {});
80
103
  for (let i = 0; i < argv.length; i++) {
81
104
  const a = argv[i];
82
105
  const next = () => argv[++i] ?? "";
@@ -93,6 +116,17 @@ function parseFlags(argv: string[]): Flags {
93
116
  case "--branch":
94
117
  patch.github!.branch = next();
95
118
  break;
119
+ case "--dev-branch":
120
+ devPatch().branch = next();
121
+ break;
122
+ case "--dev-proxy":
123
+ case "--dev-proxy-upstream":
124
+ // The dev external-API proxy upstream (a UAT/prod backend URL).
125
+ devPatch().proxyUpstream = next();
126
+ break;
127
+ case "--env":
128
+ envs = parseEnvFlag(next());
129
+ break;
96
130
  case "--region":
97
131
  patch.region = next();
98
132
  break;
@@ -122,7 +156,9 @@ function parseFlags(argv: string[]): Flags {
122
156
  }
123
157
  }
124
158
  }
125
- return { patch, force, noWorkflows, noDeploy, print };
159
+ // Drop the scratch environments container when nothing was set on it.
160
+ if (patch.environments && !patch.environments.dev) delete patch.environments;
161
+ return { patch, envs, force, noWorkflows, noDeploy, print };
126
162
  }
127
163
 
128
164
  /** Seed a config: existing file → flags → git remote → defaults. */
@@ -166,6 +202,38 @@ async function promptMissing(cfg: DeployConfig): Promise<DeployConfig> {
166
202
  });
167
203
  }
168
204
 
205
+ /**
206
+ * Ensure `.env` is git-ignored and (re)write the generated `.env` with the
207
+ * current config, preserving any secrets a developer already added. Returns the
208
+ * repo-relative paths touched, for the written/skipped summary.
209
+ */
210
+ function writeEnvAndGitignore(cfg: DeployConfig): string[] {
211
+ const touched: string[] = [];
212
+
213
+ // 1. git-ignore `.env` (append-merge; never duplicate or clobber).
214
+ const giAbs = path.join(process.cwd(), GITIGNORE_PATH);
215
+ const existingGi = fs.existsSync(giAbs)
216
+ ? fs.readFileSync(giAbs, "utf-8")
217
+ : "";
218
+ const nextGi = gitignoreWith(existingGi, ENV_PATH);
219
+ if (nextGi !== existingGi) {
220
+ fs.writeFileSync(giAbs, nextGi);
221
+ touched.push(GITIGNORE_PATH);
222
+ }
223
+
224
+ // 2. Upsert the generated RASTACK_* values into `.env` (secrets preserved).
225
+ const envAbs = path.join(process.cwd(), ENV_PATH);
226
+ const existingEnv = fs.existsSync(envAbs)
227
+ ? fs.readFileSync(envAbs, "utf-8")
228
+ : "";
229
+ const nextEnv = applyEnvValues(existingEnv, cfg);
230
+ if (nextEnv !== existingEnv) {
231
+ fs.writeFileSync(envAbs, nextEnv);
232
+ touched.push(ENV_PATH);
233
+ }
234
+ return touched;
235
+ }
236
+
169
237
  /** Yes/no confirmation with a default of "no" — gates the cloud-mutating steps. */
170
238
  async function confirm(question: string): Promise<boolean> {
171
239
  return withPrompt(async (rl) => {
@@ -181,24 +249,42 @@ function table(rows: { key: string; value: string }[]): string {
181
249
  }
182
250
 
183
251
  /**
184
- * Read the app.yaml stack Outputs into a `DeployOutputs`, skipping any that are
185
- * not resolvable yet (best-effort — a freshly created stack has them all).
252
+ * Read one environment's app.yaml stack Outputs into a `DeployOutputs`, skipping
253
+ * any not resolvable yet (best-effort — a freshly created stack has them all).
186
254
  */
187
- function readAppOutputs(cfg: DeployConfig): DeployOutputs {
255
+ function readEnvOutputs(cfg: DeployConfig, env: EnvName): DeployOutputs {
256
+ const stackName = resolveEnvironment(cfg, env).stackName;
188
257
  const outputs: DeployOutputs = {};
189
258
  for (const { key, field } of APP_STACK_OUTPUTS) {
190
- const value = stackOutput(cfg.stackName, key, cfg.region);
259
+ const value = stackOutput(stackName, key, cfg.region);
191
260
  if (value) outputs[field] = value;
192
261
  }
193
262
  return outputs;
194
263
  }
195
264
 
265
+ /** Merge an environment's resolved outputs back into the config in the right slot. */
266
+ function withEnvOutputs(
267
+ cfg: DeployConfig,
268
+ env: EnvName,
269
+ outputs: DeployOutputs,
270
+ ): DeployConfig {
271
+ return env === "prod"
272
+ ? mergeConfig(cfg, { outputs })
273
+ : mergeConfig(cfg, { environments: { dev: { outputs } } });
274
+ }
275
+
276
+ /** Human label for an environment ("dev" / "prod") plus its branch. */
277
+ function envLabel(cfg: DeployConfig, env: EnvName): string {
278
+ const e = resolveEnvironment(cfg, env);
279
+ return `${env} (${e.branch} → ${e.ghEnvironment})`;
280
+ }
281
+
196
282
  /**
197
283
  * Print the exact commands `init` would run, without touching the cloud
198
284
  * (`--print`). The artifacts bucket is read from the bootstrap stack when the
199
285
  * AWS CLI is available, else shown as a placeholder.
200
286
  */
201
- function printPlan(cfg: DeployConfig): void {
287
+ function printPlan(cfg: DeployConfig, envs: EnvName[]): void {
202
288
  const bucket = awsAvailable()
203
289
  ? stackOutput(
204
290
  bootstrapStackName(cfg),
@@ -210,26 +296,37 @@ function printPlan(cfg: DeployConfig): void {
210
296
  artifactsBucket: bucket ?? "<ArtifactsBucketName from bootstrap stack>",
211
297
  });
212
298
  const appTemplate = resolveDeployAsset("app.yaml") ?? undefined;
213
- console.log(`\n Deployment plan (${cfg.stackName} @ ${cfg.region})\n`);
299
+ console.log(`\n Deployment plan (${envs.join(" + ")} @ ${cfg.region})\n`);
214
300
  console.log(
215
301
  ` ── Prerequisite: the tier-0 bootstrap stack (${bootstrapStackName(cfg)}) must exist ──`,
216
302
  );
217
303
  console.log(
218
304
  ` Create it once per account+region (init does not): rastack bootstrap --region ${cfg.region}\n`,
219
305
  );
220
- console.log(" ── 1. Per-app backend (app.yaml) — what init deploys ──");
221
- console.log("\n " + deployStackCommand(withBucket, appTemplate) + "\n");
222
306
  console.log(
223
- " ── 2. Wire these GitHub repo Variables from the stack Outputs ──",
307
+ " ── 1. Per-app backend (app.yaml) — one stack per environment ──",
224
308
  );
309
+ for (const env of envs) {
310
+ const e = resolveEnvironment(withBucket, env);
311
+ console.log(`\n # ${envLabel(withBucket, env)} — stack ${e.stackName}`);
312
+ console.log(" " + deployStackCommandForEnv(withBucket, env, appTemplate));
313
+ }
225
314
  console.log(
226
- table(
227
- repoVariables(cfg).map((v) => ({
228
- key: v.name,
229
- value: v.value || `<${v.from}>`,
230
- })),
231
- ) + "\n",
315
+ "\n ── 2. Wire each GitHub Environment's Variables from its stack Outputs ──",
232
316
  );
317
+ for (const env of envs) {
318
+ const e = resolveEnvironment(cfg, env);
319
+ console.log(`\n # GitHub Environment: ${e.ghEnvironment}`);
320
+ console.log(
321
+ table(
322
+ repoVariablesForEnv(cfg, env).map((v) => ({
323
+ key: v.name,
324
+ value: v.value || `<${v.from}>`,
325
+ })),
326
+ ),
327
+ );
328
+ }
329
+ console.log("");
233
330
  }
234
331
 
235
332
  /**
@@ -239,6 +336,7 @@ function printPlan(cfg: DeployConfig): void {
239
336
  */
240
337
  async function standUp(
241
338
  cfg: DeployConfig,
339
+ envs: EnvName[],
242
340
  interactive: boolean,
243
341
  ): Promise<DeployConfig> {
244
342
  if (!awsAvailable()) {
@@ -278,7 +376,7 @@ async function standUp(
278
376
  }
279
377
  console.log(` ✓ artifacts bucket: ${artifactsBucket}`);
280
378
 
281
- // 2. Upload the placeholder the function is created from.
379
+ // 2. Upload the placeholder the function is created from (shared by all envs).
282
380
  console.log("\n▸ Uploading the Lambda placeholder bundle");
283
381
  if (!uploadPlaceholder(artifactsBucket, cfg.region)) {
284
382
  console.error(
@@ -287,7 +385,6 @@ async function standUp(
287
385
  process.exit(1);
288
386
  }
289
387
 
290
- // 3. Deploy the per-app backend with the resolved bucket wired in.
291
388
  cfg = mergeConfig(cfg, {
292
389
  artifactsBucket,
293
390
  templatesBucket: templatesBucket ?? undefined,
@@ -300,25 +397,28 @@ async function standUp(
300
397
  );
301
398
  process.exit(1);
302
399
  }
303
- console.log(`\n▸ Deploying ${cfg.stackName}`);
304
- try {
305
- awsRun(deployStackArgs(cfg, appTemplate));
306
- } catch (err) {
307
- console.error(
308
- `\n ✗ aws cloudformation deploy failed for ${cfg.stackName}.\n ${(err as Error).message}`,
309
- );
310
- process.exit(1);
311
- }
312
400
 
313
- // 4. Read the Outputs back into the config so CI has them without a live lookup.
314
- const outputs = readAppOutputs(cfg);
315
- cfg = mergeConfig(cfg, { outputs });
316
- saveConfig(cfg);
401
+ // 3. Deploy one stack per environment — each with its own Cognito pool,
402
+ // warehouse, Lambda and API — reading the Outputs back into the config.
403
+ for (const env of envs) {
404
+ const stackName = resolveEnvironment(cfg, env).stackName;
405
+ console.log(`\n▸ Deploying ${envLabel(cfg, env)} — ${stackName}`);
406
+ try {
407
+ awsRun(deployStackArgsForEnv(cfg, env, appTemplate));
408
+ } catch (err) {
409
+ console.error(
410
+ `\n ✗ aws cloudformation deploy failed for ${stackName}.\n ${(err as Error).message}`,
411
+ );
412
+ process.exit(1);
413
+ }
414
+ cfg = withEnvOutputs(cfg, env, readEnvOutputs(cfg, env));
415
+ saveConfig(cfg);
416
+ }
317
417
 
318
- // 5. Connect the GitHub CLI and wire the repo Variables CI reads, so the FIRST
319
- // push actually ships. Without this the deploy-backend workflow's `if:
320
- // vars.AWS_DEPLOY_ROLE_ARN != ''` guard silently skips every run.
321
- wireRepoVariables(cfg, interactive);
418
+ // 4. Connect the GitHub CLI and wire each environment's scoped Variables, so
419
+ // the FIRST push actually ships. Without this the deploy-backend workflow's
420
+ // `if: vars.AWS_DEPLOY_ROLE_ARN != ''` guard silently skips every run.
421
+ wireEnvironments(cfg, envs, interactive);
322
422
  return cfg;
323
423
  }
324
424
 
@@ -333,20 +433,37 @@ async function standUp(
333
433
  * (and isn't) done.
334
434
  */
335
435
  const wiredVariables = new Set<string>();
336
- function wireRepoVariables(cfg: DeployConfig, interactive: boolean): void {
337
- const vars = repoVariables(cfg).filter((v) => v.value);
338
- if (!vars.length) return;
436
+
437
+ /** Key a wired Variable by its GitHub Environment so dev/prod don't collide. */
438
+ function wiredKey(ghEnvironment: string | undefined, name: string): string {
439
+ return `${ghEnvironment ?? ""}:${name}`;
440
+ }
441
+
442
+ /**
443
+ * Create each environment's GitHub Actions Environment and wire its scoped
444
+ * `AWS_DEPLOY_ROLE_ARN` / `AWS_REGION` Variables via `gh`, so the GitHub →
445
+ * Lambda pipeline is live for both dev and prod the moment `init` finishes.
446
+ */
447
+ function wireEnvironments(
448
+ cfg: DeployConfig,
449
+ envs: EnvName[],
450
+ interactive: boolean,
451
+ ): void {
452
+ const anyValue = envs.some((env) =>
453
+ repoVariablesForEnv(cfg, env).some((v) => v.value),
454
+ );
455
+ if (!anyValue) return;
339
456
 
340
457
  if (!ghAvailable()) {
341
458
  console.log(
342
- "\n▸ GitHub CLI (`gh`) not found — skipping repo Variable wiring.\n" +
459
+ "\n▸ GitHub CLI (`gh`) not found — skipping environment + Variable wiring.\n" +
343
460
  " Install `gh` (https://cli.github.com) and re-run, or wire the\n" +
344
461
  " Variables below by hand so pushes deploy.",
345
462
  );
346
463
  return;
347
464
  }
348
465
 
349
- // Connect `gh` to GitHub if it isn't already, so it can set the Variables.
466
+ // Connect `gh` to GitHub if it isn't already, so it can create environments.
350
467
  if (!ghAuthenticated()) {
351
468
  if (interactive) {
352
469
  console.log(
@@ -356,53 +473,86 @@ function wireRepoVariables(cfg: DeployConfig, interactive: boolean): void {
356
473
  }
357
474
  if (!ghAuthenticated()) {
358
475
  console.log(
359
- "\n▸ GitHub CLI is not connected — skipping repo Variable wiring.\n" +
476
+ "\n▸ GitHub CLI is not connected — skipping environment wiring.\n" +
360
477
  " Run `gh auth login`, then wire the Variables below by hand.",
361
478
  );
362
479
  return;
363
480
  }
364
481
  }
365
482
 
366
- console.log("\n▸ Wiring the GitHub repo Variables CI reads (via `gh`)");
367
- for (const v of vars) {
368
- const ok = setRepoVariable(ghVariableSetArgs(cfg, v.name, v.value));
369
- if (ok) {
370
- wiredVariables.add(v.name);
371
- console.log(` ✓ set ${v.name}`);
372
- } else {
373
- console.log(` • could not set ${v.name} — wire it by hand (below)`);
483
+ console.log(
484
+ "\n▸ Creating GitHub Environments + wiring their Variables (via `gh`)",
485
+ );
486
+ for (const env of envs) {
487
+ const e = resolveEnvironment(cfg, env);
488
+ // Create the Environment first — a Variable can only be scoped to one that
489
+ // already exists. Idempotent (PUT), so re-running init is safe.
490
+ if (!ghEnvironmentCreate(ghEnvironmentCreateArgs(cfg, e.ghEnvironment))) {
491
+ console.log(
492
+ ` • could not create GitHub Environment ${e.ghEnvironment} — create it by hand`,
493
+ );
494
+ continue;
495
+ }
496
+ for (const v of repoVariablesForEnv(cfg, env).filter((x) => x.value)) {
497
+ const ok = setRepoVariable(
498
+ ghVariableSetArgs(cfg, v.name, v.value, v.ghEnvironment),
499
+ );
500
+ if (ok) {
501
+ wiredVariables.add(wiredKey(v.ghEnvironment, v.name));
502
+ console.log(` ✓ set ${e.ghEnvironment}/${v.name}`);
503
+ } else {
504
+ console.log(
505
+ ` • could not set ${e.ghEnvironment}/${v.name} — wire it by hand (below)`,
506
+ );
507
+ }
374
508
  }
375
509
  }
376
510
  }
377
511
 
378
- function printSummary(cfg: DeployConfig): void {
379
- console.log(
380
- `\n ✓ ${cfg.app} is deployed (${cfg.stackName} @ ${cfg.region})`,
381
- );
382
- if (cfg.outputs?.apiUrl) console.log(` API: ${cfg.outputs.apiUrl}`);
512
+ function printSummary(cfg: DeployConfig, envs: EnvName[]): void {
513
+ console.log(`\n ✓ ${cfg.app} is deployed @ ${cfg.region}`);
514
+ for (const env of envs) {
515
+ const e = resolveEnvironment(cfg, env);
516
+ console.log(`\n ${env} — ${e.stackName}`);
517
+ if (e.outputs?.apiUrl) console.log(` API: ${e.outputs.apiUrl}`);
518
+ if (e.outputs?.hostedUiUrl)
519
+ console.log(` Hosted UI: ${e.outputs.hostedUiUrl}`);
520
+ if (e.outputs?.userPoolId)
521
+ console.log(` User Pool: ${e.outputs.userPoolId}`);
522
+ if (e.outputs?.proxyUrl)
523
+ console.log(` Proxy: ${e.outputs.proxyUrl}`);
524
+ }
383
525
 
384
526
  // Only the Variables `init` could NOT wire itself remain a manual step.
385
- const pending = repoVariables(cfg).filter((v) => !wiredVariables.has(v.name));
527
+ const pending: { key: string; value: string }[] = [];
528
+ for (const env of envs) {
529
+ const e = resolveEnvironment(cfg, env);
530
+ for (const v of repoVariablesForEnv(cfg, env)) {
531
+ if (!wiredVariables.has(wiredKey(v.ghEnvironment, v.name)))
532
+ pending.push({
533
+ key: `${e.ghEnvironment}/${v.name}`,
534
+ value: v.value || `<${v.from}>`,
535
+ });
536
+ }
537
+ }
386
538
  if (pending.length) {
387
539
  console.log(
388
- "\n Wire these GitHub repo Variables so CI can ship code (Settings → Variables):",
389
- );
390
- console.log(
391
- table(
392
- pending.map((v) => ({
393
- key: v.name,
394
- value: v.value || `<${v.from}>`,
395
- })),
396
- ),
540
+ "\n Wire these per-environment GitHub Variables so CI can ship code\n" +
541
+ " (Settings → Environments → <env> → Variables):",
397
542
  );
543
+ console.log(table(pending));
398
544
  } else {
399
545
  console.log(
400
- "\n ✓ wired the GitHub repo Variables CI reads (AWS_DEPLOY_ROLE_ARN, AWS_REGION).",
546
+ "\n ✓ wired every GitHub Environment's Variables (AWS_DEPLOY_ROLE_ARN, AWS_REGION).",
401
547
  );
402
548
  }
549
+
550
+ const dev = resolveEnvironment(cfg, "dev");
551
+ const prod = resolveEnvironment(cfg, "prod");
403
552
  console.log(
404
- `\n Every push to ${cfg.github.branch} now ships code via \`rastack ci deploy\`` +
405
- ` (also runnable locally as \`rastack deploy\`).\n`,
553
+ `\n Pushes now ship code via \`rastack ci deploy\`:\n` +
554
+ ` ${prod.branch} → prod (${prod.ghEnvironment}) ${dev.branch} → dev (${dev.ghEnvironment})\n` +
555
+ ` Run one locally with \`rastack deploy --env dev\` / \`--env prod\`.\n`,
406
556
  );
407
557
  }
408
558
 
@@ -446,6 +596,10 @@ async function main(argv: string[]): Promise<void> {
446
596
  fs.writeFileSync(abs, f.content);
447
597
  written.push(f.path);
448
598
  }
599
+ // The `.env` (local app environment) + its `.gitignore` entry are standard —
600
+ // generated on every init, secrets preserved, refreshed with Outputs post-deploy.
601
+ for (const p of writeEnvAndGitignore(cfg)) written.push(p);
602
+
449
603
  console.log("");
450
604
  for (const w of written) console.log(` ✓ wrote ${w}`);
451
605
  for (const s of skipped)
@@ -453,7 +607,7 @@ async function main(argv: string[]): Promise<void> {
453
607
 
454
608
  // 2. Deploy (unless opted out).
455
609
  if (flags.print) {
456
- printPlan(cfg);
610
+ printPlan(cfg, flags.envs);
457
611
  return;
458
612
  }
459
613
  if (flags.noDeploy) {
@@ -465,8 +619,11 @@ async function main(argv: string[]): Promise<void> {
465
619
  }
466
620
 
467
621
  if (canPrompt()) {
622
+ const stacks = flags.envs
623
+ .map((env) => resolveEnvironment(cfg, env).stackName)
624
+ .join(" + ");
468
625
  const ok = await confirm(
469
- `Deploy ${cfg.stackName} to AWS ${cfg.region} now? (requires the bootstrap stack to exist) [y/N]`,
626
+ `Deploy ${stacks} to AWS ${cfg.region} now? (requires the bootstrap stack to exist) [y/N]`,
470
627
  );
471
628
  if (!ok) {
472
629
  console.log(
@@ -476,8 +633,10 @@ async function main(argv: string[]): Promise<void> {
476
633
  }
477
634
  }
478
635
 
479
- cfg = await standUp(cfg, canPrompt());
480
- printSummary(cfg);
636
+ cfg = await standUp(cfg, flags.envs, canPrompt());
637
+ // Refresh `.env` with the resolved stack Outputs (API URLs, pool/client ids).
638
+ for (const p of writeEnvAndGitignore(cfg)) console.log(` ✓ updated ${p}`);
639
+ printSummary(cfg, flags.envs);
481
640
  }
482
641
 
483
642
  if (require.main === module) {
package/src/rastack.ts CHANGED
@@ -137,6 +137,7 @@ switch (command) {
137
137
  case "deploy":
138
138
  // Ship code to the Lambda — an alias for the CI code deploy. Infrastructure
139
139
  // is stood up once by `rastack init`; `deploy` only ever ships code.
140
+ // `--env dev|prod` picks which environment's function to update.
140
141
  run("rastack-ci.js", ["deploy", ...rest]);
141
142
  break;
142
143
  case "ci":