rastack 0.0.38 → 0.0.39

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,8 @@
2
2
 
3
3
  All notable changes to this project will be documented in this file. See [standard-version](https://github.com/conventional-changelog/standard-version) for commit guidelines.
4
4
 
5
+ ### [0.0.39](https://github.com/theserverkid/reactapistack/compare/v0.0.38...v0.0.39) (2026-07-11)
6
+
5
7
  ### [0.0.38](https://github.com/theserverkid/reactapistack/compare/v0.0.37...v0.0.38) (2026-07-11)
6
8
 
7
9
  ### [0.0.37](https://github.com/theserverkid/reactapistack/compare/v0.0.36...v0.0.37) (2026-07-11)
@@ -91,6 +91,14 @@ export declare function normalizeConfig(partial?: PartialDeployConfig): DeployCo
91
91
  * config that passes here also passes CloudFormation's own validation.
92
92
  */
93
93
  export declare function validateConfig(cfg: DeployConfig): string[];
94
+ /**
95
+ * Return the subset of problems that block standing the **tier-0 bootstrap
96
+ * stack** up. The bootstrap stack (bootstrap.yaml) is account+region scoped — it
97
+ * only needs the name prefix, the region, and the GitHub coordinates that anchor
98
+ * the OIDC trust — so `rastack bootstrap` validates just those, not the per-app
99
+ * fields (`app`, namespace, Lambda build) that `validateConfig` also requires.
100
+ */
101
+ export declare function validateBootstrapConfig(cfg: DeployConfig): string[];
94
102
  /** Deep-merge a patch over a base config (used by `deploy`/`init` reconfigure). */
95
103
  export declare function mergeConfig(base: DeployConfig, patch: PartialDeployConfig): DeployConfig;
96
104
  /**
@@ -21,6 +21,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
21
21
  exports.APP_STACK_OUTPUTS = exports.BOOTSTRAP_TEMPLATES_OUTPUT = exports.BOOTSTRAP_ARTIFACTS_OUTPUT = exports.BOOTSTRAP_TEMPLATE_FILE = exports.APP_TEMPLATE_FILE = exports.REGIONS_WITHOUT_COGNITO_ASF = exports.DEFAULTS = exports.DEFAULT_LAMBDA = void 0;
22
22
  exports.normalizeConfig = normalizeConfig;
23
23
  exports.validateConfig = validateConfig;
24
+ exports.validateBootstrapConfig = validateBootstrapConfig;
24
25
  exports.mergeConfig = mergeConfig;
25
26
  exports.parseGitHubRemote = parseGitHubRemote;
26
27
  exports.cognitoAdvancedSecuritySupported = cognitoAdvancedSecuritySupported;
@@ -121,6 +122,33 @@ function validateConfig(cfg) {
121
122
  errors.push("lambda.bin is required.");
122
123
  return errors;
123
124
  }
125
+ /**
126
+ * Return the subset of problems that block standing the **tier-0 bootstrap
127
+ * stack** up. The bootstrap stack (bootstrap.yaml) is account+region scoped — it
128
+ * only needs the name prefix, the region, and the GitHub coordinates that anchor
129
+ * the OIDC trust — so `rastack bootstrap` validates just those, not the per-app
130
+ * fields (`app`, namespace, Lambda build) that `validateConfig` also requires.
131
+ */
132
+ function validateBootstrapConfig(cfg) {
133
+ const errors = [];
134
+ if (!SLUG.test(cfg.namePrefix))
135
+ errors.push(`namePrefix "${cfg.namePrefix}" must match ${SLUG}.`);
136
+ if (!cfg.region)
137
+ errors.push("region is required (e.g. `af-south-1`).");
138
+ else if (!REGION.test(cfg.region))
139
+ errors.push(`region "${cfg.region}" does not look like an AWS region (e.g. af-south-1).`);
140
+ if (!cfg.github.owner)
141
+ errors.push("github.owner is required.");
142
+ else if (!GH_NAME.test(cfg.github.owner))
143
+ errors.push(`github.owner "${cfg.github.owner}" is not a valid GitHub name.`);
144
+ if (!cfg.github.repo)
145
+ errors.push("github.repo is required.");
146
+ else if (!GH_NAME.test(cfg.github.repo))
147
+ errors.push(`github.repo "${cfg.github.repo}" is not a valid GitHub name.`);
148
+ if (!cfg.github.branch)
149
+ errors.push("github.branch is required.");
150
+ return errors;
151
+ }
124
152
  /** Deep-merge a patch over a base config (used by `deploy`/`init` reconfigure). */
125
153
  function mergeConfig(base, patch) {
126
154
  return normalizeConfig({
@@ -0,0 +1,24 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * `rastack bootstrap` — stand the **tier-0 bootstrap stack** up for an AWS
4
+ * account. This is the once-per-account+region step that prepares an account to
5
+ * host React API Stack backends, decoupled from any single app.
6
+ *
7
+ * rastack bootstrap [--owner <o>] [--repo <r>] [--branch <b>]
8
+ * [--region <r>] [--prefix <p>]
9
+ * [--reuse-oidc] [--print] [--yes]
10
+ *
11
+ * It deploys `deploy/cloudformation/bootstrap.yaml`, which creates:
12
+ * • the S3 buckets that hold the Lambda placeholder + hosted templates, and
13
+ * • the GitHub OIDC provider + trust that lets CI assume a deploy role.
14
+ * It then uploads the Lambda placeholder bundle to the artifacts bucket, so the
15
+ * account is immediately ready for per-app deploys (`rastack init`).
16
+ *
17
+ * `rastack init` still creates this stack on demand if it is missing, so
18
+ * `bootstrap` is the explicit, app-independent way to do the same thing up front
19
+ * (e.g. an account admin preparing an account before app teams run `init`).
20
+ * `--reuse-oidc` skips creating the GitHub OIDC provider (AWS allows only one per
21
+ * URL per account); `--print` shows the exact single-line `aws cloudformation
22
+ * deploy` command without touching the cloud.
23
+ */
24
+ export {};
@@ -0,0 +1,222 @@
1
+ #!/usr/bin/env node
2
+ "use strict";
3
+ /**
4
+ * `rastack bootstrap` — stand the **tier-0 bootstrap stack** up for an AWS
5
+ * account. This is the once-per-account+region step that prepares an account to
6
+ * host React API Stack backends, decoupled from any single app.
7
+ *
8
+ * rastack bootstrap [--owner <o>] [--repo <r>] [--branch <b>]
9
+ * [--region <r>] [--prefix <p>]
10
+ * [--reuse-oidc] [--print] [--yes]
11
+ *
12
+ * It deploys `deploy/cloudformation/bootstrap.yaml`, which creates:
13
+ * • the S3 buckets that hold the Lambda placeholder + hosted templates, and
14
+ * • the GitHub OIDC provider + trust that lets CI assume a deploy role.
15
+ * It then uploads the Lambda placeholder bundle to the artifacts bucket, so the
16
+ * account is immediately ready for per-app deploys (`rastack init`).
17
+ *
18
+ * `rastack init` still creates this stack on demand if it is missing, so
19
+ * `bootstrap` is the explicit, app-independent way to do the same thing up front
20
+ * (e.g. an account admin preparing an account before app teams run `init`).
21
+ * `--reuse-oidc` skips creating the GitHub OIDC provider (AWS allows only one per
22
+ * URL per account); `--print` shows the exact single-line `aws cloudformation
23
+ * deploy` command without touching the cloud.
24
+ */
25
+ Object.defineProperty(exports, "__esModule", { value: true });
26
+ const config_1 = require("./deploy/config");
27
+ const io_1 = require("./deploy/io");
28
+ function parseFlags(argv) {
29
+ const patch = { github: {} };
30
+ let reuseOidc = false;
31
+ let print = false;
32
+ let nonInteractive = false;
33
+ for (let i = 0; i < argv.length; i++) {
34
+ const a = argv[i];
35
+ const next = () => argv[++i] ?? "";
36
+ switch (a) {
37
+ case "--owner":
38
+ patch.github.owner = next();
39
+ break;
40
+ case "--repo":
41
+ patch.github.repo = next();
42
+ break;
43
+ case "--branch":
44
+ patch.github.branch = next();
45
+ break;
46
+ case "--region":
47
+ patch.region = next();
48
+ break;
49
+ case "--prefix":
50
+ patch.namePrefix = next();
51
+ break;
52
+ case "--reuse-oidc":
53
+ reuseOidc = true;
54
+ break;
55
+ case "--print":
56
+ case "--dry-run":
57
+ print = true;
58
+ break;
59
+ case "--yes":
60
+ case "-y":
61
+ case "--non-interactive":
62
+ nonInteractive = true;
63
+ break;
64
+ default:
65
+ if (a.startsWith("-")) {
66
+ console.error(`Unknown flag: ${a}`);
67
+ process.exit(1);
68
+ }
69
+ }
70
+ }
71
+ return { patch, reuseOidc, print, nonInteractive };
72
+ }
73
+ /** Seed a config: existing file → flags → git remote → defaults. */
74
+ function seedConfig(flags) {
75
+ const existing = (0, io_1.loadConfig)();
76
+ let cfg = existing
77
+ ? (0, config_1.mergeConfig)(existing, flags.patch)
78
+ : (0, config_1.normalizeConfig)(flags.patch);
79
+ // Default the GitHub coordinates from `origin` when not already known — they
80
+ // anchor the OIDC trust the bootstrap stack sets up.
81
+ if (!cfg.github.owner || !cfg.github.repo) {
82
+ const url = (0, io_1.gitRemoteUrl)();
83
+ const remote = url ? (0, config_1.parseGitHubRemote)(url) : null;
84
+ if (remote) {
85
+ cfg = (0, config_1.mergeConfig)(cfg, {
86
+ github: {
87
+ owner: cfg.github.owner || remote.owner,
88
+ repo: cfg.github.repo || remote.repo,
89
+ },
90
+ });
91
+ }
92
+ }
93
+ return cfg;
94
+ }
95
+ async function promptMissing(cfg) {
96
+ return (0, io_1.withPrompt)(async (rl) => {
97
+ const owner = await (0, io_1.ask)(rl, "GitHub owner", cfg.github.owner);
98
+ const repo = await (0, io_1.ask)(rl, "GitHub repo", cfg.github.repo);
99
+ const branch = await (0, io_1.ask)(rl, "Deploy branch", cfg.github.branch);
100
+ const region = await (0, io_1.ask)(rl, "AWS region", cfg.region);
101
+ const prefix = await (0, io_1.ask)(rl, "Name prefix", cfg.namePrefix);
102
+ return (0, config_1.mergeConfig)(cfg, {
103
+ region,
104
+ namePrefix: prefix,
105
+ github: { owner, repo, branch },
106
+ });
107
+ });
108
+ }
109
+ /** Yes/no confirmation with a default of "no" — gates the cloud-mutating step. */
110
+ async function confirm(question) {
111
+ return (0, io_1.withPrompt)(async (rl) => {
112
+ const answer = await (0, io_1.ask)(rl, question, "no");
113
+ return /^y(es)?$/i.test(answer.trim());
114
+ });
115
+ }
116
+ /** Print the exact command `bootstrap` would run, without touching the cloud. */
117
+ function printPlan(cfg, reuseOidc) {
118
+ console.log(`\n Bootstrap plan (${(0, config_1.bootstrapStackName)(cfg)} @ ${cfg.region})\n`);
119
+ console.log(" ── Tier-0 bootstrap stack (bootstrap.yaml) ──");
120
+ console.log("\n " + (0, config_1.bootstrapStackCommand)(cfg, reuseOidc) + "\n");
121
+ console.log(" Then `rastack init` (or `rastack bootstrap` again) resolves this stack's\n" +
122
+ " ArtifactsBucket to create app backends. Nothing here is app-specific.\n");
123
+ }
124
+ /**
125
+ * Deploy the bootstrap stack, retrying once against an account that already has
126
+ * a GitHub OIDC provider (AWS allows only one per URL) when the caller did not
127
+ * already ask to reuse it.
128
+ */
129
+ function deployBootstrap(cfg, reuseOidc) {
130
+ try {
131
+ (0, io_1.awsRun)((0, config_1.bootstrapStackArgs)(cfg, reuseOidc));
132
+ }
133
+ catch (err) {
134
+ if (reuseOidc) {
135
+ console.error(`\n ✗ bootstrap stack deploy failed.\n ${err.message}`);
136
+ process.exit(1);
137
+ }
138
+ // The most common cause is an account that already has a GitHub OIDC
139
+ // provider. Retry telling the template to reuse it instead of creating a
140
+ // second (which CloudFormation rejects).
141
+ console.log(" • retrying bootstrap, reusing the account's existing GitHub OIDC provider");
142
+ try {
143
+ (0, io_1.awsRun)((0, config_1.bootstrapStackArgs)(cfg, true));
144
+ }
145
+ catch (retryErr) {
146
+ console.error(`\n ✗ bootstrap stack deploy failed.\n ${retryErr.message}`);
147
+ process.exit(1);
148
+ }
149
+ }
150
+ }
151
+ /** Stand the tier-0 bootstrap stack up and upload the Lambda placeholder. */
152
+ function standUp(cfg, reuseOidc) {
153
+ if (!(0, io_1.awsAvailable)()) {
154
+ console.error(" ✗ the AWS CLI is not on PATH. Install it and configure credentials,\n" +
155
+ " or re-run with --print to see the command.");
156
+ process.exit(1);
157
+ }
158
+ const stack = (0, config_1.bootstrapStackName)(cfg);
159
+ const existing = (0, io_1.stackOutput)(stack, config_1.BOOTSTRAP_ARTIFACTS_OUTPUT, cfg.region);
160
+ if (existing) {
161
+ console.log(`\n▸ Bootstrap stack ${stack} already exists — reconciling (idempotent).`);
162
+ }
163
+ else {
164
+ console.log(`\n▸ Creating bootstrap stack ${stack} (@ ${cfg.region})`);
165
+ }
166
+ deployBootstrap(cfg, reuseOidc);
167
+ const artifactsBucket = (0, io_1.stackOutput)(stack, config_1.BOOTSTRAP_ARTIFACTS_OUTPUT, cfg.region);
168
+ const templatesBucket = (0, io_1.stackOutput)(stack, config_1.BOOTSTRAP_TEMPLATES_OUTPUT, cfg.region);
169
+ if (!artifactsBucket) {
170
+ console.error(" ✗ bootstrap stack deployed but its ArtifactsBucket output could not be read.");
171
+ process.exit(1);
172
+ }
173
+ console.log(` ✓ artifacts bucket: ${artifactsBucket}`);
174
+ if (templatesBucket)
175
+ console.log(` ✓ templates bucket: ${templatesBucket}`);
176
+ // Upload the placeholder the app.yaml function is later created from, so the
177
+ // account is ready for `rastack init` without a second manual step.
178
+ console.log("\n▸ Uploading the Lambda placeholder bundle");
179
+ if (!(0, io_1.uploadPlaceholder)(artifactsBucket, cfg.region)) {
180
+ console.error(" ✗ deploy/cloudformation/placeholder/bootstrap not found — the placeholder\n" +
181
+ " was not uploaded. `rastack init` will upload it on the first app deploy.");
182
+ }
183
+ else {
184
+ console.log(" ✓ placeholder uploaded");
185
+ }
186
+ console.log(`\n ✓ account bootstrapped (${stack} @ ${cfg.region}).\n` +
187
+ ` Run \`rastack init\` in an app repo to deploy a backend against it.\n`);
188
+ }
189
+ async function main(argv) {
190
+ const flags = parseFlags(argv);
191
+ let cfg = seedConfig(flags);
192
+ if ((0, io_1.canPrompt)(flags.nonInteractive) && !flags.print) {
193
+ console.log("\n rastack bootstrap — stand the tier-0 AWS account stack up\n");
194
+ cfg = await promptMissing(cfg);
195
+ }
196
+ const errors = (0, config_1.validateBootstrapConfig)(cfg);
197
+ if (errors.length) {
198
+ for (const e of errors)
199
+ console.error(` ✗ ${e}`);
200
+ console.error(`\n ${errors.length} problem(s). Pass the missing values as flags` +
201
+ ` (--owner, --repo, --branch, --region, --prefix).`);
202
+ process.exit(1);
203
+ }
204
+ if (flags.print) {
205
+ printPlan(cfg, flags.reuseOidc);
206
+ return;
207
+ }
208
+ if ((0, io_1.canPrompt)(flags.nonInteractive)) {
209
+ const ok = await confirm(`Create/update bootstrap stack ${(0, config_1.bootstrapStackName)(cfg)} in AWS ${cfg.region} now? [y/N]`);
210
+ if (!ok) {
211
+ console.log("\n Skipped. Re-run to deploy, or `rastack bootstrap --print` for the command.\n");
212
+ return;
213
+ }
214
+ }
215
+ standUp(cfg, flags.reuseOidc);
216
+ }
217
+ if (require.main === module) {
218
+ main(process.argv.slice(2)).catch((err) => {
219
+ console.error(` ✗ ${err.message}`);
220
+ process.exit(1);
221
+ });
222
+ }
package/dist/rastack.d.ts CHANGED
@@ -15,6 +15,7 @@
15
15
  * rastack wasm Compile the API to WebAssembly (in-browser local dev)
16
16
  * rastack scan [files...] [--fail-on-pii] Scan CSVs for PII and schema info
17
17
  * rastack seed [--env dev] [--source gs://] Load CSVs into Iceberg namespace
18
+ * rastack bootstrap [--owner o] [--repo r] Stand the tier-0 AWS account stack up (once per account+region)
18
19
  * rastack init [--app slug] [--force] Scaffold config + GitHub Actions AND stand the backend up
19
20
  * rastack deploy [--plan] Ship code to the Lambda (alias for `rastack ci deploy`)
20
21
  * rastack ci <check|build|deploy> [--plan] Run the CI pipeline (keeps Actions a one-liner)
package/dist/rastack.js CHANGED
@@ -16,6 +16,7 @@
16
16
  * rastack wasm Compile the API to WebAssembly (in-browser local dev)
17
17
  * rastack scan [files...] [--fail-on-pii] Scan CSVs for PII and schema info
18
18
  * rastack seed [--env dev] [--source gs://] Load CSVs into Iceberg namespace
19
+ * rastack bootstrap [--owner o] [--repo r] Stand the tier-0 AWS account stack up (once per account+region)
19
20
  * rastack init [--app slug] [--force] Scaffold config + GitHub Actions AND stand the backend up
20
21
  * rastack deploy [--plan] Ship code to the Lambda (alias for `rastack ci deploy`)
21
22
  * rastack ci <check|build|deploy> [--plan] Run the CI pipeline (keeps Actions a one-liner)
@@ -113,6 +114,11 @@ switch (command) {
113
114
  case "seed":
114
115
  run("seed.js");
115
116
  break;
117
+ case "bootstrap":
118
+ // Stand the tier-0 bootstrap stack up (buckets + GitHub OIDC) for an AWS
119
+ // account — the once-per-account+region step, decoupled from any app.
120
+ run("rastack-bootstrap.js", rest);
121
+ break;
116
122
  case "init":
117
123
  // Scaffold config + GitHub Actions and stand the serverless backend up.
118
124
  run("rastack-init.js", rest);
@@ -148,6 +154,7 @@ switch (command) {
148
154
  ` rastack wasm Compile the API to WebAssembly (browser local dev)\n` +
149
155
  ` rastack scan [files...] [--fail-on-pii]\n` +
150
156
  ` rastack seed [--env dev|staging] [--source gs://bucket/ | ./myapp-dev/]\n` +
157
+ ` rastack bootstrap [--owner o] [--repo r] [--region r] [--prefix p] [--reuse-oidc] [--print] Stand the AWS account stack up\n` +
151
158
  ` rastack init [--app slug] [--owner o] [--repo r] [--print] [--no-deploy] Scaffold + stand the backend up\n` +
152
159
  ` rastack deploy [--plan] Ship code to the Lambda (alias for ci deploy)\n` +
153
160
  ` rastack ci <check|build|deploy> [--plan] Run the CI pipeline (cleaner Actions)\n` +
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "rastack",
3
- "version": "0.0.38",
3
+ "version": "0.0.39",
4
4
  "description": "",
5
5
  "main": "runtime.ts",
6
6
  "types": "runtime.ts",
@@ -184,6 +184,37 @@ export function validateConfig(cfg: DeployConfig): string[] {
184
184
  return errors;
185
185
  }
186
186
 
187
+ /**
188
+ * Return the subset of problems that block standing the **tier-0 bootstrap
189
+ * stack** up. The bootstrap stack (bootstrap.yaml) is account+region scoped — it
190
+ * only needs the name prefix, the region, and the GitHub coordinates that anchor
191
+ * the OIDC trust — so `rastack bootstrap` validates just those, not the per-app
192
+ * fields (`app`, namespace, Lambda build) that `validateConfig` also requires.
193
+ */
194
+ export function validateBootstrapConfig(cfg: DeployConfig): string[] {
195
+ const errors: string[] = [];
196
+ if (!SLUG.test(cfg.namePrefix))
197
+ errors.push(`namePrefix "${cfg.namePrefix}" must match ${SLUG}.`);
198
+
199
+ if (!cfg.region) errors.push("region is required (e.g. `af-south-1`).");
200
+ else if (!REGION.test(cfg.region))
201
+ errors.push(
202
+ `region "${cfg.region}" does not look like an AWS region (e.g. af-south-1).`,
203
+ );
204
+
205
+ if (!cfg.github.owner) errors.push("github.owner is required.");
206
+ else if (!GH_NAME.test(cfg.github.owner))
207
+ errors.push(
208
+ `github.owner "${cfg.github.owner}" is not a valid GitHub name.`,
209
+ );
210
+ if (!cfg.github.repo) errors.push("github.repo is required.");
211
+ else if (!GH_NAME.test(cfg.github.repo))
212
+ errors.push(`github.repo "${cfg.github.repo}" is not a valid GitHub name.`);
213
+ if (!cfg.github.branch) errors.push("github.branch is required.");
214
+
215
+ return errors;
216
+ }
217
+
187
218
  /** Deep-merge a patch over a base config (used by `deploy`/`init` reconfigure). */
188
219
  export function mergeConfig(
189
220
  base: DeployConfig,
@@ -0,0 +1,299 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * `rastack bootstrap` — stand the **tier-0 bootstrap stack** up for an AWS
5
+ * account. This is the once-per-account+region step that prepares an account to
6
+ * host React API Stack backends, decoupled from any single app.
7
+ *
8
+ * rastack bootstrap [--owner <o>] [--repo <r>] [--branch <b>]
9
+ * [--region <r>] [--prefix <p>]
10
+ * [--reuse-oidc] [--print] [--yes]
11
+ *
12
+ * It deploys `deploy/cloudformation/bootstrap.yaml`, which creates:
13
+ * • the S3 buckets that hold the Lambda placeholder + hosted templates, and
14
+ * • the GitHub OIDC provider + trust that lets CI assume a deploy role.
15
+ * It then uploads the Lambda placeholder bundle to the artifacts bucket, so the
16
+ * account is immediately ready for per-app deploys (`rastack init`).
17
+ *
18
+ * `rastack init` still creates this stack on demand if it is missing, so
19
+ * `bootstrap` is the explicit, app-independent way to do the same thing up front
20
+ * (e.g. an account admin preparing an account before app teams run `init`).
21
+ * `--reuse-oidc` skips creating the GitHub OIDC provider (AWS allows only one per
22
+ * URL per account); `--print` shows the exact single-line `aws cloudformation
23
+ * deploy` command without touching the cloud.
24
+ */
25
+
26
+ import {
27
+ BOOTSTRAP_ARTIFACTS_OUTPUT,
28
+ BOOTSTRAP_TEMPLATES_OUTPUT,
29
+ DeployConfig,
30
+ PartialDeployConfig,
31
+ bootstrapStackArgs,
32
+ bootstrapStackCommand,
33
+ bootstrapStackName,
34
+ mergeConfig,
35
+ normalizeConfig,
36
+ parseGitHubRemote,
37
+ validateBootstrapConfig,
38
+ } from "./deploy/config";
39
+ import {
40
+ ask,
41
+ awsAvailable,
42
+ awsRun,
43
+ canPrompt,
44
+ gitRemoteUrl,
45
+ loadConfig,
46
+ stackOutput,
47
+ uploadPlaceholder,
48
+ withPrompt,
49
+ } from "./deploy/io";
50
+
51
+ interface Flags {
52
+ patch: PartialDeployConfig;
53
+ reuseOidc: boolean;
54
+ print: boolean;
55
+ nonInteractive: boolean;
56
+ }
57
+
58
+ function parseFlags(argv: string[]): Flags {
59
+ const patch: PartialDeployConfig = { github: {} };
60
+ let reuseOidc = false;
61
+ let print = false;
62
+ let nonInteractive = false;
63
+ for (let i = 0; i < argv.length; i++) {
64
+ const a = argv[i];
65
+ const next = () => argv[++i] ?? "";
66
+ switch (a) {
67
+ case "--owner":
68
+ patch.github!.owner = next();
69
+ break;
70
+ case "--repo":
71
+ patch.github!.repo = next();
72
+ break;
73
+ case "--branch":
74
+ patch.github!.branch = next();
75
+ break;
76
+ case "--region":
77
+ patch.region = next();
78
+ break;
79
+ case "--prefix":
80
+ patch.namePrefix = next();
81
+ break;
82
+ case "--reuse-oidc":
83
+ reuseOidc = true;
84
+ break;
85
+ case "--print":
86
+ case "--dry-run":
87
+ print = true;
88
+ break;
89
+ case "--yes":
90
+ case "-y":
91
+ case "--non-interactive":
92
+ nonInteractive = true;
93
+ break;
94
+ default:
95
+ if (a.startsWith("-")) {
96
+ console.error(`Unknown flag: ${a}`);
97
+ process.exit(1);
98
+ }
99
+ }
100
+ }
101
+ return { patch, reuseOidc, print, nonInteractive };
102
+ }
103
+
104
+ /** Seed a config: existing file → flags → git remote → defaults. */
105
+ function seedConfig(flags: Flags): DeployConfig {
106
+ const existing = loadConfig();
107
+ let cfg = existing
108
+ ? mergeConfig(existing, flags.patch)
109
+ : normalizeConfig(flags.patch);
110
+ // Default the GitHub coordinates from `origin` when not already known — they
111
+ // anchor the OIDC trust the bootstrap stack sets up.
112
+ if (!cfg.github.owner || !cfg.github.repo) {
113
+ const url = gitRemoteUrl();
114
+ const remote = url ? parseGitHubRemote(url) : null;
115
+ if (remote) {
116
+ cfg = mergeConfig(cfg, {
117
+ github: {
118
+ owner: cfg.github.owner || remote.owner,
119
+ repo: cfg.github.repo || remote.repo,
120
+ },
121
+ });
122
+ }
123
+ }
124
+ return cfg;
125
+ }
126
+
127
+ async function promptMissing(cfg: DeployConfig): Promise<DeployConfig> {
128
+ return withPrompt(async (rl) => {
129
+ const owner = await ask(rl, "GitHub owner", cfg.github.owner);
130
+ const repo = await ask(rl, "GitHub repo", cfg.github.repo);
131
+ const branch = await ask(rl, "Deploy branch", cfg.github.branch);
132
+ const region = await ask(rl, "AWS region", cfg.region);
133
+ const prefix = await ask(rl, "Name prefix", cfg.namePrefix);
134
+ return mergeConfig(cfg, {
135
+ region,
136
+ namePrefix: prefix,
137
+ github: { owner, repo, branch },
138
+ });
139
+ });
140
+ }
141
+
142
+ /** Yes/no confirmation with a default of "no" — gates the cloud-mutating step. */
143
+ async function confirm(question: string): Promise<boolean> {
144
+ return withPrompt(async (rl) => {
145
+ const answer = await ask(rl, question, "no");
146
+ return /^y(es)?$/i.test(answer.trim());
147
+ });
148
+ }
149
+
150
+ /** Print the exact command `bootstrap` would run, without touching the cloud. */
151
+ function printPlan(cfg: DeployConfig, reuseOidc: boolean): void {
152
+ console.log(
153
+ `\n Bootstrap plan (${bootstrapStackName(cfg)} @ ${cfg.region})\n`,
154
+ );
155
+ console.log(" ── Tier-0 bootstrap stack (bootstrap.yaml) ──");
156
+ console.log("\n " + bootstrapStackCommand(cfg, reuseOidc) + "\n");
157
+ console.log(
158
+ " Then `rastack init` (or `rastack bootstrap` again) resolves this stack's\n" +
159
+ " ArtifactsBucket to create app backends. Nothing here is app-specific.\n",
160
+ );
161
+ }
162
+
163
+ /**
164
+ * Deploy the bootstrap stack, retrying once against an account that already has
165
+ * a GitHub OIDC provider (AWS allows only one per URL) when the caller did not
166
+ * already ask to reuse it.
167
+ */
168
+ function deployBootstrap(cfg: DeployConfig, reuseOidc: boolean): void {
169
+ try {
170
+ awsRun(bootstrapStackArgs(cfg, reuseOidc));
171
+ } catch (err) {
172
+ if (reuseOidc) {
173
+ console.error(
174
+ `\n ✗ bootstrap stack deploy failed.\n ${(err as Error).message}`,
175
+ );
176
+ process.exit(1);
177
+ }
178
+ // The most common cause is an account that already has a GitHub OIDC
179
+ // provider. Retry telling the template to reuse it instead of creating a
180
+ // second (which CloudFormation rejects).
181
+ console.log(
182
+ " • retrying bootstrap, reusing the account's existing GitHub OIDC provider",
183
+ );
184
+ try {
185
+ awsRun(bootstrapStackArgs(cfg, true));
186
+ } catch (retryErr) {
187
+ console.error(
188
+ `\n ✗ bootstrap stack deploy failed.\n ${(retryErr as Error).message}`,
189
+ );
190
+ process.exit(1);
191
+ }
192
+ }
193
+ }
194
+
195
+ /** Stand the tier-0 bootstrap stack up and upload the Lambda placeholder. */
196
+ function standUp(cfg: DeployConfig, reuseOidc: boolean): void {
197
+ if (!awsAvailable()) {
198
+ console.error(
199
+ " ✗ the AWS CLI is not on PATH. Install it and configure credentials,\n" +
200
+ " or re-run with --print to see the command.",
201
+ );
202
+ process.exit(1);
203
+ }
204
+
205
+ const stack = bootstrapStackName(cfg);
206
+ const existing = stackOutput(stack, BOOTSTRAP_ARTIFACTS_OUTPUT, cfg.region);
207
+ if (existing) {
208
+ console.log(
209
+ `\n▸ Bootstrap stack ${stack} already exists — reconciling (idempotent).`,
210
+ );
211
+ } else {
212
+ console.log(`\n▸ Creating bootstrap stack ${stack} (@ ${cfg.region})`);
213
+ }
214
+ deployBootstrap(cfg, reuseOidc);
215
+
216
+ const artifactsBucket = stackOutput(
217
+ stack,
218
+ BOOTSTRAP_ARTIFACTS_OUTPUT,
219
+ cfg.region,
220
+ );
221
+ const templatesBucket = stackOutput(
222
+ stack,
223
+ BOOTSTRAP_TEMPLATES_OUTPUT,
224
+ cfg.region,
225
+ );
226
+ if (!artifactsBucket) {
227
+ console.error(
228
+ " ✗ bootstrap stack deployed but its ArtifactsBucket output could not be read.",
229
+ );
230
+ process.exit(1);
231
+ }
232
+ console.log(` ✓ artifacts bucket: ${artifactsBucket}`);
233
+ if (templatesBucket) console.log(` ✓ templates bucket: ${templatesBucket}`);
234
+
235
+ // Upload the placeholder the app.yaml function is later created from, so the
236
+ // account is ready for `rastack init` without a second manual step.
237
+ console.log("\n▸ Uploading the Lambda placeholder bundle");
238
+ if (!uploadPlaceholder(artifactsBucket, cfg.region)) {
239
+ 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.",
242
+ );
243
+ } else {
244
+ console.log(" ✓ placeholder uploaded");
245
+ }
246
+
247
+ console.log(
248
+ `\n ✓ account bootstrapped (${stack} @ ${cfg.region}).\n` +
249
+ ` Run \`rastack init\` in an app repo to deploy a backend against it.\n`,
250
+ );
251
+ }
252
+
253
+ async function main(argv: string[]): Promise<void> {
254
+ const flags = parseFlags(argv);
255
+ let cfg = seedConfig(flags);
256
+
257
+ if (canPrompt(flags.nonInteractive) && !flags.print) {
258
+ console.log(
259
+ "\n rastack bootstrap — stand the tier-0 AWS account stack up\n",
260
+ );
261
+ cfg = await promptMissing(cfg);
262
+ }
263
+
264
+ const errors = validateBootstrapConfig(cfg);
265
+ if (errors.length) {
266
+ for (const e of errors) console.error(` ✗ ${e}`);
267
+ console.error(
268
+ `\n ${errors.length} problem(s). Pass the missing values as flags` +
269
+ ` (--owner, --repo, --branch, --region, --prefix).`,
270
+ );
271
+ process.exit(1);
272
+ }
273
+
274
+ if (flags.print) {
275
+ printPlan(cfg, flags.reuseOidc);
276
+ return;
277
+ }
278
+
279
+ if (canPrompt(flags.nonInteractive)) {
280
+ const ok = await confirm(
281
+ `Create/update bootstrap stack ${bootstrapStackName(cfg)} in AWS ${cfg.region} now? [y/N]`,
282
+ );
283
+ if (!ok) {
284
+ console.log(
285
+ "\n Skipped. Re-run to deploy, or `rastack bootstrap --print` for the command.\n",
286
+ );
287
+ return;
288
+ }
289
+ }
290
+
291
+ standUp(cfg, flags.reuseOidc);
292
+ }
293
+
294
+ if (require.main === module) {
295
+ main(process.argv.slice(2)).catch((err) => {
296
+ console.error(` ✗ ${(err as Error).message}`);
297
+ process.exit(1);
298
+ });
299
+ }
package/src/rastack.ts CHANGED
@@ -16,6 +16,7 @@
16
16
  * rastack wasm Compile the API to WebAssembly (in-browser local dev)
17
17
  * rastack scan [files...] [--fail-on-pii] Scan CSVs for PII and schema info
18
18
  * rastack seed [--env dev] [--source gs://] Load CSVs into Iceberg namespace
19
+ * rastack bootstrap [--owner o] [--repo r] Stand the tier-0 AWS account stack up (once per account+region)
19
20
  * rastack init [--app slug] [--force] Scaffold config + GitHub Actions AND stand the backend up
20
21
  * rastack deploy [--plan] Ship code to the Lambda (alias for `rastack ci deploy`)
21
22
  * rastack ci <check|build|deploy> [--plan] Run the CI pipeline (keeps Actions a one-liner)
@@ -118,6 +119,11 @@ switch (command) {
118
119
  case "seed":
119
120
  run("seed.js");
120
121
  break;
122
+ case "bootstrap":
123
+ // Stand the tier-0 bootstrap stack up (buckets + GitHub OIDC) for an AWS
124
+ // account — the once-per-account+region step, decoupled from any app.
125
+ run("rastack-bootstrap.js", rest);
126
+ break;
121
127
  case "init":
122
128
  // Scaffold config + GitHub Actions and stand the serverless backend up.
123
129
  run("rastack-init.js", rest);
@@ -154,6 +160,7 @@ switch (command) {
154
160
  ` rastack wasm Compile the API to WebAssembly (browser local dev)\n` +
155
161
  ` rastack scan [files...] [--fail-on-pii]\n` +
156
162
  ` rastack seed [--env dev|staging] [--source gs://bucket/ | ./myapp-dev/]\n` +
163
+ ` rastack bootstrap [--owner o] [--repo r] [--region r] [--prefix p] [--reuse-oidc] [--print] Stand the AWS account stack up\n` +
157
164
  ` rastack init [--app slug] [--owner o] [--repo r] [--print] [--no-deploy] Scaffold + stand the backend up\n` +
158
165
  ` rastack deploy [--plan] Ship code to the Lambda (alias for ci deploy)\n` +
159
166
  ` rastack ci <check|build|deploy> [--plan] Run the CI pipeline (cleaner Actions)\n` +
@@ -21,6 +21,7 @@ import {
21
21
  scaffoldFiles,
22
22
  stackParameters,
23
23
  validateConfig,
24
+ validateBootstrapConfig,
24
25
  zipStore,
25
26
  renderCiWorkflow,
26
27
  renderDeployWorkflow,
@@ -278,6 +279,31 @@ describe("bootstrap stack", () => {
278
279
  expect(cmd).toContain("--stack-name rastack-bootstrap");
279
280
  expect(`aws ${bootstrapStackArgs(base()).join(" ")}`).toBe(cmd);
280
281
  });
282
+
283
+ it("validates only the account-level fields, not the per-app ones", () => {
284
+ // A config with no `app` is fine for bootstrap — it never touches app.yaml.
285
+ const cfg = normalizeConfig({
286
+ github: { owner: "theserverkid", repo: "reactapistack-demo" },
287
+ });
288
+ expect(validateConfig(cfg)).toContain(
289
+ "app is required (a short slug, e.g. `airline`).",
290
+ );
291
+ expect(validateBootstrapConfig(cfg)).toEqual([]);
292
+ });
293
+
294
+ it("still flags a bad prefix, region, or GitHub coordinates", () => {
295
+ expect(
296
+ validateBootstrapConfig(base({ namePrefix: "Bad_Prefix" })),
297
+ ).toContainEqual(expect.stringContaining("namePrefix"));
298
+ expect(
299
+ validateBootstrapConfig(base({ region: "not-a-region" })),
300
+ ).toContainEqual(expect.stringContaining("region"));
301
+ expect(
302
+ validateBootstrapConfig(
303
+ normalizeConfig({ app: "airline", github: { repo: "demo" } }),
304
+ ),
305
+ ).toContain("github.owner is required.");
306
+ });
281
307
  });
282
308
 
283
309
  describe("app stack outputs map", () => {