rastack 0.0.62 → 0.0.64

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.
Files changed (44) hide show
  1. package/CHANGELOG.md +14 -0
  2. package/admin/AdminApp.tsx +56 -3
  3. package/admin/index.tsx +8 -2
  4. package/admin/ui/icons.tsx +9 -0
  5. package/admin/ui/styles.ts +3 -0
  6. package/admin/useAdmin.ts +41 -4
  7. package/auth/provider.ts +71 -8
  8. package/dist/admin.js +17 -14
  9. package/dist/compile/analyze.js +11 -1
  10. package/dist/define/index.d.ts +8 -1
  11. package/dist/define/index.js +2 -2
  12. package/dist/define/manifest.js +7 -1
  13. package/dist/deploy/ci.d.ts +10 -3
  14. package/dist/deploy/ci.js +22 -11
  15. package/dist/deploy/config.d.ts +155 -5
  16. package/dist/deploy/config.js +216 -6
  17. package/dist/deploy/io.d.ts +57 -0
  18. package/dist/deploy/io.js +141 -0
  19. package/dist/deploy/workflows.d.ts +30 -0
  20. package/dist/deploy/workflows.js +117 -12
  21. package/dist/rastack-bootstrap.js +18 -47
  22. package/dist/rastack-ci.js +19 -1
  23. package/dist/rastack-dev.js +48 -2
  24. package/dist/rastack-init.d.ts +6 -3
  25. package/dist/rastack-init.js +231 -92
  26. package/dist/rastack.js +1 -0
  27. package/dist/templates/cloudformation/app.yaml +133 -2
  28. package/dist/wasm/rastack_wasm.js +1 -1
  29. package/dist/wasm/rastack_wasm_bg.wasm +0 -0
  30. package/package.json +1 -1
  31. package/provider/provider.tsx +23 -0
  32. package/provider/types.ts +9 -0
  33. package/src/compile/analyze.ts +11 -1
  34. package/src/define/index.ts +5 -1
  35. package/src/define/manifest.ts +7 -1
  36. package/src/deploy/ci.ts +50 -16
  37. package/src/deploy/config.ts +340 -7
  38. package/src/deploy/io.ts +179 -2
  39. package/src/deploy/workflows.ts +122 -12
  40. package/src/rastack-bootstrap.ts +24 -69
  41. package/src/rastack-ci.ts +20 -2
  42. package/src/rastack-dev.ts +61 -6
  43. package/src/rastack-init.ts +283 -115
  44. package/src/rastack.ts +1 -0
@@ -12,14 +12,17 @@
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 — 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);
15
+ * 2. verifies the AWS CLI is logged in, then ensures the tier-0 bootstrap stack
16
+ * exists — creating it (the S3 template/placeholder buckets + GitHub OIDC
17
+ * provider) when missing, reconciling it when it already does;
18
18
  * 3. uploads the Lambda placeholder bundle to that bucket;
19
19
  * 4. deploys the per-app backend (app.yaml) with the resolved ArtifactsBucket;
20
20
  * 5. reads the stack Outputs back into the config and prints the two GitHub repo
21
21
  * Variables CI needs.
22
22
  *
23
+ * The CloudFormation templates ship inside the `rastack` package; a partial
24
+ * install falls back to downloading them from reactapistack.com.
25
+ *
23
26
  * Thereafter the recurring code deploy is CI's job — `rastack ci deploy`, which
24
27
  * `rastack deploy` also runs. `--no-deploy` scaffolds only; `--print` shows the
25
28
  * exact single-line `aws cloudformation deploy` commands without running them.
@@ -30,32 +33,46 @@ import * as path from "path";
30
33
  import {
31
34
  APP_STACK_OUTPUTS,
32
35
  BOOTSTRAP_ARTIFACTS_OUTPUT,
33
- BOOTSTRAP_TEMPLATES_OUTPUT,
34
36
  DeployConfig,
35
37
  DeployOutputs,
38
+ EnvName,
39
+ ENV_NAMES,
36
40
  PartialDeployConfig,
41
+ bootstrapStackCommand,
37
42
  bootstrapStackName,
38
- deployStackArgs,
39
- deployStackCommand,
43
+ deployStackCommandForEnv,
44
+ deployStackArgsForEnv,
45
+ ghEnvironmentCreateArgs,
40
46
  ghVariableSetArgs,
41
47
  mergeConfig,
42
48
  normalizeConfig,
43
49
  parseGitHubRemote,
44
- repoVariables,
50
+ repoVariablesForEnv,
51
+ resolveEnvironment,
45
52
  validateConfig,
46
53
  } from "./deploy/config";
47
- import { scaffoldFiles } from "./deploy/workflows";
54
+ import {
55
+ ENV_PATH,
56
+ GITIGNORE_PATH,
57
+ applyEnvValues,
58
+ gitignoreWith,
59
+ scaffoldFiles,
60
+ } from "./deploy/workflows";
48
61
  import {
49
62
  ask,
50
63
  awsAvailable,
64
+ awsIdentity,
51
65
  awsRun,
52
66
  canPrompt,
67
+ ensureBootstrapStack,
53
68
  ghAuthLogin,
54
69
  ghAuthenticated,
55
70
  ghAvailable,
71
+ ghEnvironmentCreate,
56
72
  gitRemoteUrl,
57
73
  loadConfig,
58
74
  resolveDeployAsset,
75
+ resolveTemplateFile,
59
76
  saveConfig,
60
77
  setRepoVariable,
61
78
  stackOutput,
@@ -65,18 +82,30 @@ import {
65
82
 
66
83
  interface Flags {
67
84
  patch: PartialDeployConfig;
85
+ /** Which environments to stand up (default: both). */
86
+ envs: EnvName[];
68
87
  force: boolean;
69
88
  noWorkflows: boolean;
70
89
  noDeploy: boolean;
71
90
  print: boolean;
72
91
  }
73
92
 
93
+ function parseEnvFlag(value: string): EnvName[] {
94
+ const v = value.trim().toLowerCase();
95
+ if (v === "all" || v === "") return [...ENV_NAMES];
96
+ if ((ENV_NAMES as readonly string[]).includes(v)) return [v as EnvName];
97
+ console.error(`Unknown --env: expected dev | prod | all.`);
98
+ process.exit(1);
99
+ }
100
+
74
101
  function parseFlags(argv: string[]): Flags {
75
- const patch: PartialDeployConfig = { github: {} };
102
+ const patch: PartialDeployConfig = { github: {}, environments: {} };
103
+ let envs: EnvName[] = [...ENV_NAMES];
76
104
  let force = false;
77
105
  let noWorkflows = false;
78
106
  let noDeploy = false;
79
107
  let print = false;
108
+ const devPatch = () => (patch.environments!.dev ??= {});
80
109
  for (let i = 0; i < argv.length; i++) {
81
110
  const a = argv[i];
82
111
  const next = () => argv[++i] ?? "";
@@ -93,6 +122,17 @@ function parseFlags(argv: string[]): Flags {
93
122
  case "--branch":
94
123
  patch.github!.branch = next();
95
124
  break;
125
+ case "--dev-branch":
126
+ devPatch().branch = next();
127
+ break;
128
+ case "--dev-proxy":
129
+ case "--dev-proxy-upstream":
130
+ // The dev external-API proxy upstream (a UAT/prod backend URL).
131
+ devPatch().proxyUpstream = next();
132
+ break;
133
+ case "--env":
134
+ envs = parseEnvFlag(next());
135
+ break;
96
136
  case "--region":
97
137
  patch.region = next();
98
138
  break;
@@ -122,7 +162,9 @@ function parseFlags(argv: string[]): Flags {
122
162
  }
123
163
  }
124
164
  }
125
- return { patch, force, noWorkflows, noDeploy, print };
165
+ // Drop the scratch environments container when nothing was set on it.
166
+ if (patch.environments && !patch.environments.dev) delete patch.environments;
167
+ return { patch, envs, force, noWorkflows, noDeploy, print };
126
168
  }
127
169
 
128
170
  /** Seed a config: existing file → flags → git remote → defaults. */
@@ -166,6 +208,38 @@ async function promptMissing(cfg: DeployConfig): Promise<DeployConfig> {
166
208
  });
167
209
  }
168
210
 
211
+ /**
212
+ * Ensure `.env` is git-ignored and (re)write the generated `.env` with the
213
+ * current config, preserving any secrets a developer already added. Returns the
214
+ * repo-relative paths touched, for the written/skipped summary.
215
+ */
216
+ function writeEnvAndGitignore(cfg: DeployConfig): string[] {
217
+ const touched: string[] = [];
218
+
219
+ // 1. git-ignore `.env` (append-merge; never duplicate or clobber).
220
+ const giAbs = path.join(process.cwd(), GITIGNORE_PATH);
221
+ const existingGi = fs.existsSync(giAbs)
222
+ ? fs.readFileSync(giAbs, "utf-8")
223
+ : "";
224
+ const nextGi = gitignoreWith(existingGi, ENV_PATH);
225
+ if (nextGi !== existingGi) {
226
+ fs.writeFileSync(giAbs, nextGi);
227
+ touched.push(GITIGNORE_PATH);
228
+ }
229
+
230
+ // 2. Upsert the generated RASTACK_* values into `.env` (secrets preserved).
231
+ const envAbs = path.join(process.cwd(), ENV_PATH);
232
+ const existingEnv = fs.existsSync(envAbs)
233
+ ? fs.readFileSync(envAbs, "utf-8")
234
+ : "";
235
+ const nextEnv = applyEnvValues(existingEnv, cfg);
236
+ if (nextEnv !== existingEnv) {
237
+ fs.writeFileSync(envAbs, nextEnv);
238
+ touched.push(ENV_PATH);
239
+ }
240
+ return touched;
241
+ }
242
+
169
243
  /** Yes/no confirmation with a default of "no" — gates the cloud-mutating steps. */
170
244
  async function confirm(question: string): Promise<boolean> {
171
245
  return withPrompt(async (rl) => {
@@ -181,24 +255,44 @@ function table(rows: { key: string; value: string }[]): string {
181
255
  }
182
256
 
183
257
  /**
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).
258
+ * Read one environment's app.yaml stack Outputs into a `DeployOutputs`, skipping
259
+ * any not resolvable yet (best-effort — a freshly created stack has them all).
186
260
  */
187
- function readAppOutputs(cfg: DeployConfig): DeployOutputs {
261
+ function readEnvOutputs(cfg: DeployConfig, env: EnvName): DeployOutputs {
262
+ const stackName = resolveEnvironment(cfg, env).stackName;
188
263
  const outputs: DeployOutputs = {};
189
264
  for (const { key, field } of APP_STACK_OUTPUTS) {
190
- const value = stackOutput(cfg.stackName, key, cfg.region);
265
+ const value = stackOutput(stackName, key, cfg.region);
191
266
  if (value) outputs[field] = value;
192
267
  }
193
268
  return outputs;
194
269
  }
195
270
 
271
+ /** Merge an environment's resolved outputs back into the config in the right slot. */
272
+ function withEnvOutputs(
273
+ cfg: DeployConfig,
274
+ env: EnvName,
275
+ outputs: DeployOutputs,
276
+ ): DeployConfig {
277
+ return env === "prod"
278
+ ? mergeConfig(cfg, { outputs })
279
+ : mergeConfig(cfg, { environments: { dev: { outputs } } });
280
+ }
281
+
282
+ /** Human label for an environment ("dev" / "prod") plus its branch. */
283
+ function envLabel(cfg: DeployConfig, env: EnvName): string {
284
+ const e = resolveEnvironment(cfg, env);
285
+ return `${env} (${e.branch} → ${e.ghEnvironment})`;
286
+ }
287
+
196
288
  /**
197
289
  * Print the exact commands `init` would run, without touching the cloud
198
- * (`--print`). The artifacts bucket is read from the bootstrap stack when the
199
- * AWS CLI is available, else shown as a placeholder.
290
+ * (`--print`). init stands up *both* stacks, so both `aws cloudformation deploy`
291
+ * commands are shown: the tier-0 bootstrap stack (created only when missing) and
292
+ * the per-app backend. The artifacts bucket is read from an existing bootstrap
293
+ * stack when the AWS CLI is available, else shown as a placeholder.
200
294
  */
201
- function printPlan(cfg: DeployConfig): void {
295
+ function printPlan(cfg: DeployConfig, envs: EnvName[]): void {
202
296
  const bucket = awsAvailable()
203
297
  ? stackOutput(
204
298
  bootstrapStackName(cfg),
@@ -209,36 +303,50 @@ function printPlan(cfg: DeployConfig): void {
209
303
  const withBucket = mergeConfig(cfg, {
210
304
  artifactsBucket: bucket ?? "<ArtifactsBucketName from bootstrap stack>",
211
305
  });
306
+ const bootstrapTemplate = resolveDeployAsset("bootstrap.yaml") ?? undefined;
212
307
  const appTemplate = resolveDeployAsset("app.yaml") ?? undefined;
213
- console.log(`\n Deployment plan (${cfg.stackName} @ ${cfg.region})\n`);
214
- console.log(
215
- ` ── Prerequisite: the tier-0 bootstrap stack (${bootstrapStackName(cfg)}) must exist ──`,
216
- );
308
+ console.log(`\n Deployment plan (${envs.join(" + ")} @ ${cfg.region})\n`);
217
309
  console.log(
218
- ` Create it once per account+region (init does not): rastack bootstrap --region ${cfg.region}\n`,
310
+ ` ── 1. Tier-0 bootstrap stack (${bootstrapStackName(cfg)}) — created only when missing ──`,
219
311
  );
220
- console.log(" ── 1. Per-app backend (app.yaml) — what init deploys ──");
221
- console.log("\n " + deployStackCommand(withBucket, appTemplate) + "\n");
312
+ console.log("\n " + bootstrapStackCommand(cfg, false, bootstrapTemplate) + "\n");
222
313
  console.log(
223
- " ── 2. Wire these GitHub repo Variables from the stack Outputs ──",
314
+ " ── 2. Per-app backend (app.yaml) — one stack per environment ──",
224
315
  );
316
+ for (const env of envs) {
317
+ const e = resolveEnvironment(withBucket, env);
318
+ console.log(`\n # ${envLabel(withBucket, env)} — stack ${e.stackName}`);
319
+ console.log(" " + deployStackCommandForEnv(withBucket, env, appTemplate));
320
+ }
225
321
  console.log(
226
- table(
227
- repoVariables(cfg).map((v) => ({
228
- key: v.name,
229
- value: v.value || `<${v.from}>`,
230
- })),
231
- ) + "\n",
322
+ "\n ── 2. Wire each GitHub Environment's Variables from its stack Outputs ──",
232
323
  );
324
+ for (const env of envs) {
325
+ const e = resolveEnvironment(cfg, env);
326
+ console.log(`\n # GitHub Environment: ${e.ghEnvironment}`);
327
+ console.log(
328
+ table(
329
+ repoVariablesForEnv(cfg, env).map((v) => ({
330
+ key: v.name,
331
+ value: v.value || `<${v.from}>`,
332
+ })),
333
+ ),
334
+ );
335
+ }
336
+ console.log("");
233
337
  }
234
338
 
235
339
  /**
236
- * Stand the infrastructure up: ensure the bootstrap stack + artifacts bucket,
237
- * upload the Lambda placeholder, deploy app.yaml, and read the Outputs back into
238
- * the config. Returns the config enriched with the resolved buckets + outputs.
340
+ * Stand the infrastructure up: verify the AWS CLI is logged in, ensure the
341
+ * tier-0 bootstrap stack (creating it when missing), upload the Lambda
342
+ * placeholder, deploy app.yaml, and read the Outputs back into the config.
343
+ * `init` is the single command that stands up *everything* — there is no
344
+ * separate manual bootstrap step. Returns the config enriched with the resolved
345
+ * buckets + outputs.
239
346
  */
240
347
  async function standUp(
241
348
  cfg: DeployConfig,
349
+ envs: EnvName[],
242
350
  interactive: boolean,
243
351
  ): Promise<DeployConfig> {
244
352
  if (!awsAvailable()) {
@@ -249,36 +357,35 @@ async function standUp(
249
357
  process.exit(1);
250
358
  }
251
359
 
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(
260
- bootstrapStackName(cfg),
261
- BOOTSTRAP_ARTIFACTS_OUTPUT,
262
- cfg.region,
263
- );
264
- const templatesBucket = stackOutput(
265
- bootstrapStackName(cfg),
266
- BOOTSTRAP_TEMPLATES_OUTPUT,
267
- cfg.region,
268
- );
269
- if (!artifactsBucket) {
360
+ // Verify the AWS CLI is actually logged in before creating any stacks, so a
361
+ // missing/expired credential fails fast with an actionable message rather than
362
+ // a cryptic `cloudformation deploy` error partway through.
363
+ const account = awsIdentity(cfg.region);
364
+ if (!account) {
270
365
  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`,
366
+ " ✗ the AWS CLI has no usable credentials — it is not logged in.\n" +
367
+ " Configure credentials, then re-run `rastack init`:\n" +
368
+ " aws configure # static access key / secret\n" +
369
+ " aws sso login # or an existing SSO session\n" +
370
+ " Or re-run with --print to see the commands, or --no-deploy to skip.",
276
371
  );
277
372
  process.exit(1);
278
373
  }
374
+ console.log(` ✓ AWS account ${account} (${cfg.region})`);
375
+
376
+ // 1. Ensure the tier-0 bootstrap stack exists — the S3 buckets that host the
377
+ // templates + Lambda placeholder and the GitHub OIDC provider. init creates
378
+ // it when missing (idempotent for an existing one), so a single command
379
+ // takes an account+repo from nothing to a deployed backend.
380
+ const { artifactsBucket, templatesBucket } = await ensureBootstrapStack(
381
+ cfg,
382
+ ).catch((err: Error) => {
383
+ console.error(`\n ✗ ${err.message}`);
384
+ process.exit(1);
385
+ });
279
386
  console.log(` ✓ artifacts bucket: ${artifactsBucket}`);
280
387
 
281
- // 2. Upload the placeholder the function is created from.
388
+ // 2. Upload the placeholder the function is created from (shared by all envs).
282
389
  console.log("\n▸ Uploading the Lambda placeholder bundle");
283
390
  if (!uploadPlaceholder(artifactsBucket, cfg.region)) {
284
391
  console.error(
@@ -287,38 +394,40 @@ async function standUp(
287
394
  process.exit(1);
288
395
  }
289
396
 
290
- // 3. Deploy the per-app backend with the resolved bucket wired in.
291
397
  cfg = mergeConfig(cfg, {
292
398
  artifactsBucket,
293
399
  templatesBucket: templatesBucket ?? undefined,
294
400
  });
295
- const appTemplate = resolveDeployAsset("app.yaml");
401
+ const appTemplate = await resolveTemplateFile("app.yaml");
296
402
  if (!appTemplate) {
297
403
  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
- }
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}`,
404
+ " ✗ app.yaml template not found in the rastack package and could not be\n" +
405
+ " downloaded from reactapistack.com. Reinstall rastack (`npm i -g rastack`).",
309
406
  );
310
407
  process.exit(1);
311
408
  }
312
409
 
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);
410
+ // 3. Deploy one stack per environment — each with its own Cognito pool,
411
+ // warehouse, Lambda and API — reading the Outputs back into the config.
412
+ for (const env of envs) {
413
+ const stackName = resolveEnvironment(cfg, env).stackName;
414
+ console.log(`\n▸ Deploying ${envLabel(cfg, env)} — ${stackName}`);
415
+ try {
416
+ awsRun(deployStackArgsForEnv(cfg, env, appTemplate));
417
+ } catch (err) {
418
+ console.error(
419
+ `\n ✗ aws cloudformation deploy failed for ${stackName}.\n ${(err as Error).message}`,
420
+ );
421
+ process.exit(1);
422
+ }
423
+ cfg = withEnvOutputs(cfg, env, readEnvOutputs(cfg, env));
424
+ saveConfig(cfg);
425
+ }
317
426
 
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);
427
+ // 4. Connect the GitHub CLI and wire each environment's scoped Variables, so
428
+ // the FIRST push actually ships. Without this the deploy-backend workflow's
429
+ // `if: vars.AWS_DEPLOY_ROLE_ARN != ''` guard silently skips every run.
430
+ wireEnvironments(cfg, envs, interactive);
322
431
  return cfg;
323
432
  }
324
433
 
@@ -333,20 +442,37 @@ async function standUp(
333
442
  * (and isn't) done.
334
443
  */
335
444
  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;
445
+
446
+ /** Key a wired Variable by its GitHub Environment so dev/prod don't collide. */
447
+ function wiredKey(ghEnvironment: string | undefined, name: string): string {
448
+ return `${ghEnvironment ?? ""}:${name}`;
449
+ }
450
+
451
+ /**
452
+ * Create each environment's GitHub Actions Environment and wire its scoped
453
+ * `AWS_DEPLOY_ROLE_ARN` / `AWS_REGION` Variables via `gh`, so the GitHub →
454
+ * Lambda pipeline is live for both dev and prod the moment `init` finishes.
455
+ */
456
+ function wireEnvironments(
457
+ cfg: DeployConfig,
458
+ envs: EnvName[],
459
+ interactive: boolean,
460
+ ): void {
461
+ const anyValue = envs.some((env) =>
462
+ repoVariablesForEnv(cfg, env).some((v) => v.value),
463
+ );
464
+ if (!anyValue) return;
339
465
 
340
466
  if (!ghAvailable()) {
341
467
  console.log(
342
- "\n▸ GitHub CLI (`gh`) not found — skipping repo Variable wiring.\n" +
468
+ "\n▸ GitHub CLI (`gh`) not found — skipping environment + Variable wiring.\n" +
343
469
  " Install `gh` (https://cli.github.com) and re-run, or wire the\n" +
344
470
  " Variables below by hand so pushes deploy.",
345
471
  );
346
472
  return;
347
473
  }
348
474
 
349
- // Connect `gh` to GitHub if it isn't already, so it can set the Variables.
475
+ // Connect `gh` to GitHub if it isn't already, so it can create environments.
350
476
  if (!ghAuthenticated()) {
351
477
  if (interactive) {
352
478
  console.log(
@@ -356,53 +482,86 @@ function wireRepoVariables(cfg: DeployConfig, interactive: boolean): void {
356
482
  }
357
483
  if (!ghAuthenticated()) {
358
484
  console.log(
359
- "\n▸ GitHub CLI is not connected — skipping repo Variable wiring.\n" +
485
+ "\n▸ GitHub CLI is not connected — skipping environment wiring.\n" +
360
486
  " Run `gh auth login`, then wire the Variables below by hand.",
361
487
  );
362
488
  return;
363
489
  }
364
490
  }
365
491
 
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)`);
492
+ console.log(
493
+ "\n▸ Creating GitHub Environments + wiring their Variables (via `gh`)",
494
+ );
495
+ for (const env of envs) {
496
+ const e = resolveEnvironment(cfg, env);
497
+ // Create the Environment first — a Variable can only be scoped to one that
498
+ // already exists. Idempotent (PUT), so re-running init is safe.
499
+ if (!ghEnvironmentCreate(ghEnvironmentCreateArgs(cfg, e.ghEnvironment))) {
500
+ console.log(
501
+ ` • could not create GitHub Environment ${e.ghEnvironment} — create it by hand`,
502
+ );
503
+ continue;
504
+ }
505
+ for (const v of repoVariablesForEnv(cfg, env).filter((x) => x.value)) {
506
+ const ok = setRepoVariable(
507
+ ghVariableSetArgs(cfg, v.name, v.value, v.ghEnvironment),
508
+ );
509
+ if (ok) {
510
+ wiredVariables.add(wiredKey(v.ghEnvironment, v.name));
511
+ console.log(` ✓ set ${e.ghEnvironment}/${v.name}`);
512
+ } else {
513
+ console.log(
514
+ ` • could not set ${e.ghEnvironment}/${v.name} — wire it by hand (below)`,
515
+ );
516
+ }
374
517
  }
375
518
  }
376
519
  }
377
520
 
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}`);
521
+ function printSummary(cfg: DeployConfig, envs: EnvName[]): void {
522
+ console.log(`\n ✓ ${cfg.app} is deployed @ ${cfg.region}`);
523
+ for (const env of envs) {
524
+ const e = resolveEnvironment(cfg, env);
525
+ console.log(`\n ${env} — ${e.stackName}`);
526
+ if (e.outputs?.apiUrl) console.log(` API: ${e.outputs.apiUrl}`);
527
+ if (e.outputs?.hostedUiUrl)
528
+ console.log(` Hosted UI: ${e.outputs.hostedUiUrl}`);
529
+ if (e.outputs?.userPoolId)
530
+ console.log(` User Pool: ${e.outputs.userPoolId}`);
531
+ if (e.outputs?.proxyUrl)
532
+ console.log(` Proxy: ${e.outputs.proxyUrl}`);
533
+ }
383
534
 
384
535
  // Only the Variables `init` could NOT wire itself remain a manual step.
385
- const pending = repoVariables(cfg).filter((v) => !wiredVariables.has(v.name));
536
+ const pending: { key: string; value: string }[] = [];
537
+ for (const env of envs) {
538
+ const e = resolveEnvironment(cfg, env);
539
+ for (const v of repoVariablesForEnv(cfg, env)) {
540
+ if (!wiredVariables.has(wiredKey(v.ghEnvironment, v.name)))
541
+ pending.push({
542
+ key: `${e.ghEnvironment}/${v.name}`,
543
+ value: v.value || `<${v.from}>`,
544
+ });
545
+ }
546
+ }
386
547
  if (pending.length) {
387
548
  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
- ),
549
+ "\n Wire these per-environment GitHub Variables so CI can ship code\n" +
550
+ " (Settings → Environments → <env> → Variables):",
397
551
  );
552
+ console.log(table(pending));
398
553
  } else {
399
554
  console.log(
400
- "\n ✓ wired the GitHub repo Variables CI reads (AWS_DEPLOY_ROLE_ARN, AWS_REGION).",
555
+ "\n ✓ wired every GitHub Environment's Variables (AWS_DEPLOY_ROLE_ARN, AWS_REGION).",
401
556
  );
402
557
  }
558
+
559
+ const dev = resolveEnvironment(cfg, "dev");
560
+ const prod = resolveEnvironment(cfg, "prod");
403
561
  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`,
562
+ `\n Pushes now ship code via \`rastack ci deploy\`:\n` +
563
+ ` ${prod.branch} → prod (${prod.ghEnvironment}) ${dev.branch} → dev (${dev.ghEnvironment})\n` +
564
+ ` Run one locally with \`rastack deploy --env dev\` / \`--env prod\`.\n`,
406
565
  );
407
566
  }
408
567
 
@@ -446,6 +605,10 @@ async function main(argv: string[]): Promise<void> {
446
605
  fs.writeFileSync(abs, f.content);
447
606
  written.push(f.path);
448
607
  }
608
+ // The `.env` (local app environment) + its `.gitignore` entry are standard —
609
+ // generated on every init, secrets preserved, refreshed with Outputs post-deploy.
610
+ for (const p of writeEnvAndGitignore(cfg)) written.push(p);
611
+
449
612
  console.log("");
450
613
  for (const w of written) console.log(` ✓ wrote ${w}`);
451
614
  for (const s of skipped)
@@ -453,7 +616,7 @@ async function main(argv: string[]): Promise<void> {
453
616
 
454
617
  // 2. Deploy (unless opted out).
455
618
  if (flags.print) {
456
- printPlan(cfg);
619
+ printPlan(cfg, flags.envs);
457
620
  return;
458
621
  }
459
622
  if (flags.noDeploy) {
@@ -465,8 +628,11 @@ async function main(argv: string[]): Promise<void> {
465
628
  }
466
629
 
467
630
  if (canPrompt()) {
631
+ const stacks = flags.envs
632
+ .map((env) => resolveEnvironment(cfg, env).stackName)
633
+ .join(" + ");
468
634
  const ok = await confirm(
469
- `Deploy ${cfg.stackName} to AWS ${cfg.region} now? (requires the bootstrap stack to exist) [y/N]`,
635
+ `Deploy ${stacks} to AWS ${cfg.region} now? (creates the bootstrap stack if missing) [y/N]`,
470
636
  );
471
637
  if (!ok) {
472
638
  console.log(
@@ -476,8 +642,10 @@ async function main(argv: string[]): Promise<void> {
476
642
  }
477
643
  }
478
644
 
479
- cfg = await standUp(cfg, canPrompt());
480
- printSummary(cfg);
645
+ cfg = await standUp(cfg, flags.envs, canPrompt());
646
+ // Refresh `.env` with the resolved stack Outputs (API URLs, pool/client ids).
647
+ for (const p of writeEnvAndGitignore(cfg)) console.log(` ✓ updated ${p}`);
648
+ printSummary(cfg, flags.envs);
481
649
  }
482
650
 
483
651
  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":