rastack 0.0.63 → 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.
@@ -179,7 +179,17 @@ function analyzeField(
179
179
  const app = literalTypeProp(fieldType, "__app", checker, initializer);
180
180
  const model = literalTypeProp(fieldType, "__model", checker, initializer);
181
181
  if (app && model) {
182
- return { name, type: "fk", relation: { app, model } };
182
+ const fk: FieldModel = { name, type: "fk", relation: { app, model } };
183
+ // `s.ref(() => X, { null: true })` — a nullable relation (a shared secret
184
+ // with no project, an unassigned task). The options object is the ref
185
+ // call's second argument, read the same way scalar constraints are.
186
+ if (ts.isCallExpression(initializer)) {
187
+ const opts = literalToValue(initializer.arguments[1]);
188
+ if (opts && typeof opts === "object" && opts.null === true) {
189
+ fk.null = true;
190
+ }
191
+ }
192
+ return fk;
183
193
  }
184
194
  return undefined;
185
195
  }
@@ -66,6 +66,9 @@ export interface Ref<App extends string, Model extends string> {
66
66
  readonly __model: Model;
67
67
  /** The authored `() => Resource` thunk — carried at runtime. */
68
68
  readonly target?: () => ResourceType<App, Model, any>;
69
+ /** Optional FK constraints — `null: true` makes the relation nullable
70
+ * (e.g. a shared secret with no project, or an unassigned task). */
71
+ readonly options?: { null?: boolean };
69
72
  }
70
73
 
71
74
  export interface StringOptions {
@@ -153,8 +156,9 @@ export const s = {
153
156
  */
154
157
  ref<A extends string, M extends string>(
155
158
  thunk: () => ResourceType<A, M, any>,
159
+ options?: { null?: boolean },
156
160
  ): Ref<A, M> {
157
- return { __rastackRef: true, target: thunk } as Ref<A, M>;
161
+ return { __rastackRef: true, target: thunk, options } as Ref<A, M>;
158
162
  },
159
163
  };
160
164
 
@@ -121,11 +121,17 @@ function runtimeField(
121
121
  `manifestFromResources: ${res.app}.${res.model}.${name} is an s.ref(...) whose target thunk did not return a resource() value`,
122
122
  );
123
123
  }
124
- return {
124
+ const fk: FieldModel = {
125
125
  name,
126
126
  type: "fk",
127
127
  relation: { app: target.app, model: target.model },
128
128
  };
129
+ // A nullable FK (`s.ref(() => X, { null: true })`) — e.g. a shared secret
130
+ // with no project — so the engine allows and stores a null relation.
131
+ if ((field.options as { null?: boolean } | undefined)?.null === true) {
132
+ fk.null = true;
133
+ }
134
+ return fk;
129
135
  }
130
136
 
131
137
  if (typeof field.__rastackScalar !== "string") return undefined;
package/src/deploy/io.ts CHANGED
@@ -6,11 +6,20 @@
6
6
 
7
7
  import { execFileSync, execSync } from "child_process";
8
8
  import * as fs from "fs";
9
+ import * as https from "https";
9
10
  import * as os from "os";
10
11
  import * as path from "path";
11
12
  import * as readline from "readline";
12
13
  import { CONFIG_PATH } from "./workflows";
13
- import { DeployConfig, PartialDeployConfig, normalizeConfig } from "./config";
14
+ import {
15
+ BOOTSTRAP_ARTIFACTS_OUTPUT,
16
+ BOOTSTRAP_TEMPLATES_OUTPUT,
17
+ DeployConfig,
18
+ PartialDeployConfig,
19
+ bootstrapStackArgs,
20
+ bootstrapStackName,
21
+ normalizeConfig,
22
+ } from "./config";
14
23
  import { zipStore } from "./zip";
15
24
 
16
25
  /** Absolute path to the deploy config for a project root. */
@@ -50,6 +59,65 @@ export function resolveDeployAsset(
50
59
  return null;
51
60
  }
52
61
 
62
+ /**
63
+ * Base URL the framework's CloudFormation templates are published at. The same
64
+ * site that hosts the installer (`reactapistack.com/setup.sh`) serves the base
65
+ * templates, so a CLI whose bundled copy is missing (a partial install, or a
66
+ * globally-linked dev build) can still fetch them.
67
+ */
68
+ export const TEMPLATE_BASE_URL = "https://reactapistack.com/cloudformation";
69
+
70
+ /**
71
+ * Download a published CloudFormation template to a temp file, returning its
72
+ * path — or null on any failure (offline, 404, write error). Used as the
73
+ * fallback when the template is not bundled with the install.
74
+ */
75
+ export function downloadTemplate(
76
+ name: string,
77
+ base = TEMPLATE_BASE_URL,
78
+ ): Promise<string | null> {
79
+ const url = `${base}/${name}`;
80
+ return new Promise((resolve) => {
81
+ https
82
+ .get(url, (res) => {
83
+ if (res.statusCode !== 200) {
84
+ res.resume();
85
+ resolve(null);
86
+ return;
87
+ }
88
+ const chunks: Buffer[] = [];
89
+ res.on("data", (c: Buffer) => chunks.push(c));
90
+ res.on("end", () => {
91
+ try {
92
+ const dest = path.join(
93
+ os.tmpdir(),
94
+ `rastack-${path.basename(name)}`,
95
+ );
96
+ fs.writeFileSync(dest, Buffer.concat(chunks));
97
+ resolve(dest);
98
+ } catch {
99
+ resolve(null);
100
+ }
101
+ });
102
+ })
103
+ .on("error", () => resolve(null));
104
+ });
105
+ }
106
+
107
+ /**
108
+ * Resolve a CloudFormation template to a local file path for `aws cloudformation
109
+ * deploy` (which accepts only `--template-file`). Prefers the copy bundled with
110
+ * the `rastack` install; when that is missing, downloads it from the React API
111
+ * Stack website. Returns null only when the template is neither bundled nor
112
+ * fetchable.
113
+ */
114
+ export async function resolveTemplateFile(
115
+ name: string,
116
+ cwd = process.cwd(),
117
+ ): Promise<string | null> {
118
+ return resolveDeployAsset(name, cwd) ?? (await downloadTemplate(name));
119
+ }
120
+
53
121
  /** Read + normalize the deploy config, or null when it does not exist yet. */
54
122
  export function loadConfig(cwd = process.cwd()): DeployConfig | null {
55
123
  const file = configPath(cwd);
@@ -121,6 +189,26 @@ export function awsAvailable(): boolean {
121
189
  return awsCapture(["--version"]) !== null;
122
190
  }
123
191
 
192
+ /**
193
+ * The AWS account id the CLI's active credentials resolve to, or null when the
194
+ * CLI has no usable credentials (not logged in, an expired SSO session, or no
195
+ * configured profile). `init`/`bootstrap` call this to fail fast with an
196
+ * actionable "log in" message before attempting a stack deploy, instead of
197
+ * letting the first `cloudformation deploy` fail with a cryptic AWS error.
198
+ */
199
+ export function awsIdentity(region?: string): string | null {
200
+ const args = [
201
+ "sts",
202
+ "get-caller-identity",
203
+ "--query",
204
+ "Account",
205
+ "--output",
206
+ "text",
207
+ ];
208
+ if (region) args.push("--region", region);
209
+ return awsCapture(args);
210
+ }
211
+
124
212
  /**
125
213
  * A single Output value of a CloudFormation stack, or null when the stack does
126
214
  * not exist or the key is unset. `describe-stacks` prints `None` for a missing
@@ -176,6 +264,79 @@ export function uploadPlaceholder(
176
264
  return true;
177
265
  }
178
266
 
267
+ /**
268
+ * Deploy the tier-0 bootstrap stack, retrying once against an account that
269
+ * already has a GitHub OIDC provider — AWS allows only one provider per URL, so
270
+ * a second create is rejected and the retry tells the template to reuse the
271
+ * existing one. Throws on failure (after the retry). Shared by `rastack init`
272
+ * and `rastack bootstrap` so both stand the stack up identically.
273
+ */
274
+ export function deployBootstrap(
275
+ cfg: DeployConfig,
276
+ reuseOidc: boolean,
277
+ template: string,
278
+ ): void {
279
+ try {
280
+ awsRun(bootstrapStackArgs(cfg, reuseOidc, template));
281
+ } catch (err) {
282
+ if (reuseOidc) throw err;
283
+ console.log(
284
+ " • retrying bootstrap, reusing the account's existing GitHub OIDC provider",
285
+ );
286
+ awsRun(bootstrapStackArgs(cfg, true, template));
287
+ }
288
+ }
289
+
290
+ /**
291
+ * Ensure the tier-0 bootstrap stack exists for this account+region, **creating
292
+ * it when missing**, and return its artifacts/templates buckets. This is why
293
+ * `rastack init` is a single command that stands up *everything* — the
294
+ * account-level bootstrap stack and the per-app backend — with no separate
295
+ * `rastack bootstrap` step. Idempotent: an existing stack is reconciled in
296
+ * place. Throws with an actionable message when the template cannot be resolved,
297
+ * the deploy fails, or the buckets can't be read back.
298
+ */
299
+ export async function ensureBootstrapStack(
300
+ cfg: DeployConfig,
301
+ reuseOidc = false,
302
+ ): Promise<{ artifactsBucket: string; templatesBucket: string | null }> {
303
+ const stack = bootstrapStackName(cfg);
304
+ const existing = stackOutput(stack, BOOTSTRAP_ARTIFACTS_OUTPUT, cfg.region);
305
+ if (existing) {
306
+ console.log(` ✓ bootstrap stack ${stack} already exists — reconciling`);
307
+ } else {
308
+ console.log(
309
+ `\n▸ Creating the tier-0 bootstrap stack ${stack} (@ ${cfg.region})`,
310
+ );
311
+ }
312
+
313
+ const template = await resolveTemplateFile("bootstrap.yaml");
314
+ if (!template) {
315
+ throw new Error(
316
+ "bootstrap.yaml template not found in the rastack package and could not be\n" +
317
+ " downloaded from reactapistack.com. Reinstall rastack (`npm i -g rastack`).",
318
+ );
319
+ }
320
+ deployBootstrap(cfg, reuseOidc, template);
321
+
322
+ const artifactsBucket = stackOutput(
323
+ stack,
324
+ BOOTSTRAP_ARTIFACTS_OUTPUT,
325
+ cfg.region,
326
+ );
327
+ const templatesBucket = stackOutput(
328
+ stack,
329
+ BOOTSTRAP_TEMPLATES_OUTPUT,
330
+ cfg.region,
331
+ );
332
+ if (!artifactsBucket) {
333
+ throw new Error(
334
+ `bootstrap stack ${stack} deployed but its ArtifactsBucket output could not be read.`,
335
+ );
336
+ }
337
+ return { artifactsBucket, templatesBucket };
338
+ }
339
+
179
340
  // ── GitHub CLI boundary ──────────────────────────────────────────────────────
180
341
  // After the stack is up `init` wires the two repo Variables CI reads
181
342
  // (AWS_DEPLOY_ROLE_ARN / AWS_REGION) through the developer's own `gh` — already
@@ -25,11 +25,8 @@
25
25
  */
26
26
 
27
27
  import {
28
- BOOTSTRAP_ARTIFACTS_OUTPUT,
29
- BOOTSTRAP_TEMPLATES_OUTPUT,
30
28
  DeployConfig,
31
29
  PartialDeployConfig,
32
- bootstrapStackArgs,
33
30
  bootstrapStackCommand,
34
31
  bootstrapStackName,
35
32
  mergeConfig,
@@ -40,12 +37,12 @@ import {
40
37
  import {
41
38
  ask,
42
39
  awsAvailable,
43
- awsRun,
40
+ awsIdentity,
44
41
  canPrompt,
42
+ ensureBootstrapStack,
45
43
  gitRemoteUrl,
46
44
  loadConfig,
47
45
  resolveDeployAsset,
48
- stackOutput,
49
46
  uploadPlaceholder,
50
47
  withPrompt,
51
48
  } from "./deploy/io";
@@ -164,47 +161,12 @@ function printPlan(cfg: DeployConfig, reuseOidc: boolean): void {
164
161
  }
165
162
 
166
163
  /**
167
- * Deploy the bootstrap stack, retrying once against an account that already has
168
- * a GitHub OIDC provider (AWS allows only one per URL) when the caller did not
169
- * already ask to reuse it.
164
+ * Stand the tier-0 bootstrap stack up and upload the Lambda placeholder. The
165
+ * deploy (with the GitHub-OIDC-reuse retry) and template resolution are shared
166
+ * with `rastack init` via `ensureBootstrapStack`, so both stand the stack up
167
+ * identically.
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
- }
180
- try {
181
- awsRun(bootstrapStackArgs(cfg, reuseOidc, template));
182
- } catch (err) {
183
- if (reuseOidc) {
184
- console.error(
185
- `\n ✗ bootstrap stack deploy failed.\n ${(err as Error).message}`,
186
- );
187
- process.exit(1);
188
- }
189
- // The most common cause is an account that already has a GitHub OIDC
190
- // provider. Retry telling the template to reuse it instead of creating a
191
- // second (which CloudFormation rejects).
192
- console.log(
193
- " • retrying bootstrap, reusing the account's existing GitHub OIDC provider",
194
- );
195
- try {
196
- awsRun(bootstrapStackArgs(cfg, true, template));
197
- } catch (retryErr) {
198
- console.error(
199
- `\n ✗ bootstrap stack deploy failed.\n ${(retryErr as Error).message}`,
200
- );
201
- process.exit(1);
202
- }
203
- }
204
- }
205
-
206
- /** Stand the tier-0 bootstrap stack up and upload the Lambda placeholder. */
207
- function standUp(cfg: DeployConfig, reuseOidc: boolean): void {
169
+ async function standUp(cfg: DeployConfig, reuseOidc: boolean): Promise<void> {
208
170
  if (!awsAvailable()) {
209
171
  console.error(
210
172
  " ✗ the AWS CLI is not on PATH. Install it and configure credentials,\n" +
@@ -213,33 +175,26 @@ function standUp(cfg: DeployConfig, reuseOidc: boolean): void {
213
175
  process.exit(1);
214
176
  }
215
177
 
216
- const stack = bootstrapStackName(cfg);
217
- const existing = stackOutput(stack, BOOTSTRAP_ARTIFACTS_OUTPUT, cfg.region);
218
- if (existing) {
219
- console.log(
220
- `\n▸ Bootstrap stack ${stack} already exists — reconciling (idempotent).`,
221
- );
222
- } else {
223
- console.log(`\n▸ Creating bootstrap stack ${stack} (@ ${cfg.region})`);
224
- }
225
- deployBootstrap(cfg, reuseOidc);
226
-
227
- const artifactsBucket = stackOutput(
228
- stack,
229
- BOOTSTRAP_ARTIFACTS_OUTPUT,
230
- cfg.region,
231
- );
232
- const templatesBucket = stackOutput(
233
- stack,
234
- BOOTSTRAP_TEMPLATES_OUTPUT,
235
- cfg.region,
236
- );
237
- if (!artifactsBucket) {
178
+ // Verify the AWS CLI is logged in before attempting a stack deploy.
179
+ const account = awsIdentity(cfg.region);
180
+ if (!account) {
238
181
  console.error(
239
- " ✗ bootstrap stack deployed but its ArtifactsBucket output could not be read.",
182
+ " ✗ the AWS CLI has no usable credentials — it is not logged in.\n" +
183
+ " Run `aws configure` (or `aws sso login`), then re-run `rastack bootstrap`.\n" +
184
+ " Or re-run with --print to see the command.",
240
185
  );
241
186
  process.exit(1);
242
187
  }
188
+ console.log(` ✓ AWS account ${account} (${cfg.region})`);
189
+
190
+ const stack = bootstrapStackName(cfg);
191
+ const { artifactsBucket, templatesBucket } = await ensureBootstrapStack(
192
+ cfg,
193
+ reuseOidc,
194
+ ).catch((err: Error) => {
195
+ console.error(`\n ✗ ${err.message}`);
196
+ process.exit(1);
197
+ });
243
198
  console.log(` ✓ artifacts bucket: ${artifactsBucket}`);
244
199
  if (templatesBucket) console.log(` ✓ templates bucket: ${templatesBucket}`);
245
200
 
@@ -299,7 +254,7 @@ async function main(argv: string[]): Promise<void> {
299
254
  }
300
255
  }
301
256
 
302
- standUp(cfg, flags.reuseOidc);
257
+ await standUp(cfg, flags.reuseOidc);
303
258
  }
304
259
 
305
260
  if (require.main === module) {
@@ -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,12 +33,12 @@ 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,
36
38
  EnvName,
37
39
  ENV_NAMES,
38
40
  PartialDeployConfig,
41
+ bootstrapStackCommand,
39
42
  bootstrapStackName,
40
43
  deployStackCommandForEnv,
41
44
  deployStackArgsForEnv,
@@ -58,8 +61,10 @@ import {
58
61
  import {
59
62
  ask,
60
63
  awsAvailable,
64
+ awsIdentity,
61
65
  awsRun,
62
66
  canPrompt,
67
+ ensureBootstrapStack,
63
68
  ghAuthLogin,
64
69
  ghAuthenticated,
65
70
  ghAvailable,
@@ -67,6 +72,7 @@ import {
67
72
  gitRemoteUrl,
68
73
  loadConfig,
69
74
  resolveDeployAsset,
75
+ resolveTemplateFile,
70
76
  saveConfig,
71
77
  setRepoVariable,
72
78
  stackOutput,
@@ -281,8 +287,10 @@ function envLabel(cfg: DeployConfig, env: EnvName): string {
281
287
 
282
288
  /**
283
289
  * Print the exact commands `init` would run, without touching the cloud
284
- * (`--print`). The artifacts bucket is read from the bootstrap stack when the
285
- * 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.
286
294
  */
287
295
  function printPlan(cfg: DeployConfig, envs: EnvName[]): void {
288
296
  const bucket = awsAvailable()
@@ -295,16 +303,15 @@ function printPlan(cfg: DeployConfig, envs: EnvName[]): void {
295
303
  const withBucket = mergeConfig(cfg, {
296
304
  artifactsBucket: bucket ?? "<ArtifactsBucketName from bootstrap stack>",
297
305
  });
306
+ const bootstrapTemplate = resolveDeployAsset("bootstrap.yaml") ?? undefined;
298
307
  const appTemplate = resolveDeployAsset("app.yaml") ?? undefined;
299
308
  console.log(`\n Deployment plan (${envs.join(" + ")} @ ${cfg.region})\n`);
300
309
  console.log(
301
- ` ── Prerequisite: the tier-0 bootstrap stack (${bootstrapStackName(cfg)}) must exist ──`,
302
- );
303
- console.log(
304
- ` 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 ──`,
305
311
  );
312
+ console.log("\n " + bootstrapStackCommand(cfg, false, bootstrapTemplate) + "\n");
306
313
  console.log(
307
- " ── 1. Per-app backend (app.yaml) — one stack per environment ──",
314
+ " ── 2. Per-app backend (app.yaml) — one stack per environment ──",
308
315
  );
309
316
  for (const env of envs) {
310
317
  const e = resolveEnvironment(withBucket, env);
@@ -330,9 +337,12 @@ function printPlan(cfg: DeployConfig, envs: EnvName[]): void {
330
337
  }
331
338
 
332
339
  /**
333
- * Stand the infrastructure up: ensure the bootstrap stack + artifacts bucket,
334
- * upload the Lambda placeholder, deploy app.yaml, and read the Outputs back into
335
- * 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.
336
346
  */
337
347
  async function standUp(
338
348
  cfg: DeployConfig,
@@ -347,33 +357,32 @@ async function standUp(
347
357
  process.exit(1);
348
358
  }
349
359
 
350
- // 1. Resolve the artifacts bucket from the tier-0 bootstrap stack. That stack
351
- // is an account+region once-off — the S3 buckets that host the templates +
352
- // Lambda placeholder, and the GitHub OIDC provider. `init` is a per-app
353
- // command and deliberately does NOT create it: standing up account-level
354
- // infrastructure is `rastack bootstrap`'s job (run once by an account admin),
355
- // so a missing bootstrap stack is a clear, actionable error, not an implicit
356
- // account mutation buried inside an app deploy.
357
- const artifactsBucket = stackOutput(
358
- bootstrapStackName(cfg),
359
- BOOTSTRAP_ARTIFACTS_OUTPUT,
360
- cfg.region,
361
- );
362
- const templatesBucket = stackOutput(
363
- bootstrapStackName(cfg),
364
- BOOTSTRAP_TEMPLATES_OUTPUT,
365
- cfg.region,
366
- );
367
- 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) {
368
365
  console.error(
369
- `\n ✗ Bootstrap stack ${bootstrapStackName(cfg)} not found in ${cfg.region}.\n` +
370
- " It is the once-per-account+region step that hosts the CloudFormation\n" +
371
- " templates and the Lambda placeholder every app deploy reads. Create it\n" +
372
- " first (an account admin does this once), then re-run `rastack init`:\n\n" +
373
- ` 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.",
374
371
  );
375
372
  process.exit(1);
376
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
+ });
377
386
  console.log(` ✓ artifacts bucket: ${artifactsBucket}`);
378
387
 
379
388
  // 2. Upload the placeholder the function is created from (shared by all envs).
@@ -389,11 +398,11 @@ async function standUp(
389
398
  artifactsBucket,
390
399
  templatesBucket: templatesBucket ?? undefined,
391
400
  });
392
- const appTemplate = resolveDeployAsset("app.yaml");
401
+ const appTemplate = await resolveTemplateFile("app.yaml");
393
402
  if (!appTemplate) {
394
403
  console.error(
395
- " ✗ app.yaml template not found in the rastack package. Reinstall rastack\n" +
396
- " (`npm i -g rastack`) or run from the framework repo.",
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`).",
397
406
  );
398
407
  process.exit(1);
399
408
  }
@@ -623,7 +632,7 @@ async function main(argv: string[]): Promise<void> {
623
632
  .map((env) => resolveEnvironment(cfg, env).stackName)
624
633
  .join(" + ");
625
634
  const ok = await confirm(
626
- `Deploy ${stacks} 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]`,
627
636
  );
628
637
  if (!ok) {
629
638
  console.log(