rastack 0.0.61 → 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.
@@ -11,7 +11,7 @@
11
11
  * Pure string builders — `rastack-init.ts` is the thin wrapper that writes them.
12
12
  */
13
13
 
14
- import { DeployConfig } from "./config";
14
+ import { DeployConfig, EnvName, resolveEnvironment } from "./config";
15
15
 
16
16
  /** A templated file: repo-relative path + its contents. */
17
17
  export interface ScaffoldFile {
@@ -27,6 +27,8 @@ const NODE_VERSION = "22";
27
27
  * every push/PR. The clean replacement for hand-written compile/check steps.
28
28
  */
29
29
  export function renderCiWorkflow(cfg: DeployConfig): string {
30
+ const prod = resolveEnvironment(cfg, "prod");
31
+ const dev = resolveEnvironment(cfg, "dev");
30
32
  return `name: CI
31
33
 
32
34
  # Validate the resource graph and compile the manifest on every change.
@@ -35,7 +37,7 @@ export function renderCiWorkflow(cfg: DeployConfig): string {
35
37
 
36
38
  on:
37
39
  push:
38
- branches: [${cfg.github.branch}]
40
+ branches: [${prod.branch}, ${dev.branch}]
39
41
  pull_request:
40
42
 
41
43
  permissions:
@@ -65,19 +67,28 @@ jobs:
65
67
  */
66
68
  export function renderDeployWorkflow(cfg: DeployConfig): string {
67
69
  const arch = cfg.lambda.arch === "arm64" ? "arm64" : "x86_64";
70
+ const prod = resolveEnvironment(cfg, "prod");
71
+ const dev = resolveEnvironment(cfg, "dev");
72
+ // Pick the environment (and rastack --env flag) from the pushed branch.
73
+ const ghEnv = `\${{ github.ref_name == '${prod.branch}' && '${prod.ghEnvironment}' || '${dev.ghEnvironment}' }}`;
74
+ const envFlag = `\${{ github.ref_name == '${prod.branch}' && 'prod' || 'dev' }}`;
68
75
  return `name: Deploy backend
69
76
 
70
- # Ship the Rust API (rastack-server) to AWS Lambda. Infrastructure is created
71
- # once by a human via CloudFormation (docs/serverless-deploy.md); this workflow
77
+ # Ship the Rust API (rastack-server) to AWS Lambda — dev and prod. Infrastructure
78
+ # is created once by \`rastack init\` (docs/serverless-deploy.md); this workflow
72
79
  # only ever runs \`aws lambda update-function-code\`, wrapped by \`rastack ci\`.
73
80
  #
74
- # Wire these from the CloudFormation stack Outputs as GitHub repo Variables:
81
+ # The environment is chosen from the branch:
82
+ # ${dev.branch} → ${dev.ghEnvironment} (dev stack) ${prod.branch} → ${prod.ghEnvironment} (prod stack)
83
+ #
84
+ # Each GitHub Actions Environment holds its own AWS Variables (wired by
85
+ # \`rastack init\` from that environment's stack Outputs):
75
86
  # AWS_DEPLOY_ROLE_ARN DeployRoleArn (the OIDC role this job assumes)
76
87
  # AWS_REGION Region (${cfg.region})
77
88
 
78
89
  on:
79
90
  push:
80
- branches: [${cfg.github.branch}]
91
+ branches: [${prod.branch}, ${dev.branch}]
81
92
 
82
93
  # Least privilege: read the repo + mint an OIDC token to assume the deploy role.
83
94
  permissions:
@@ -85,15 +96,20 @@ permissions:
85
96
  id-token: write
86
97
 
87
98
  concurrency:
88
- group: deploy-backend
99
+ # One in-flight deploy per branch, so dev and prod never cancel each other.
100
+ group: deploy-backend-\${{ github.ref_name }}
89
101
  cancel-in-progress: true
90
102
 
91
103
  jobs:
92
104
  deploy:
93
105
  name: Build & deploy the Lambda (${arch})
94
106
  runs-on: ubuntu-latest
95
- # Skip on forks / repos that never wired the deploy role, so the job does
96
- # not fail red for contributors who cannot (and should not) deploy.
107
+ # Select the GitHub Environment from the branch. Its scoped Variables
108
+ # (AWS_DEPLOY_ROLE_ARN / AWS_REGION) then resolve to the right stack, and
109
+ # prod protection rules (if any) gate the run.
110
+ environment: ${ghEnv}
111
+ # Skip on forks / environments that never wired the deploy role, so the job
112
+ # does not fail red for contributors who cannot (and should not) deploy.
97
113
  if: vars.AWS_DEPLOY_ROLE_ARN != ''
98
114
  steps:
99
115
  - uses: actions/checkout@v4
@@ -120,10 +136,10 @@ jobs:
120
136
  role-to-assume: \${{ vars.AWS_DEPLOY_ROLE_ARN }}
121
137
  aws-region: \${{ vars.AWS_REGION }}
122
138
 
123
- # One line: compile the manifest, cross-build the bundle, bake the JWKS,
124
- # and update-function-code. See \`rastack ci --plan deploy\`.
139
+ # One line: compile the manifest, cross-build the bundle, bake the env's
140
+ # Cognito JWKS, and update-function-code for that env's function.
125
141
  - name: Build & deploy
126
- run: rastack ci deploy
142
+ run: rastack ci deploy --env ${envFlag}
127
143
  `;
128
144
  }
129
145
 
@@ -135,6 +151,100 @@ export function renderConfigFile(cfg: DeployConfig): string {
135
151
  export const CONFIG_PATH = ".rastack/deploy.json";
136
152
  export const CI_WORKFLOW_PATH = ".github/workflows/ci.yml";
137
153
  export const DEPLOY_WORKFLOW_PATH = ".github/workflows/deploy-backend.yml";
154
+ export const ENV_PATH = ".env";
155
+ export const GITIGNORE_PATH = ".gitignore";
156
+
157
+ /**
158
+ * The generated `.env` — the app's **local** environment. Every value is
159
+ * non-secret (client ids, pool ids, public URLs from the stack Outputs), but the
160
+ * file is git-ignored by convention (`gitignoreWith`) so a developer can add
161
+ * their own secrets beside these without ever committing them. Values are blank
162
+ * until the matching stack is deployed; `rastack init` rewrites the file with the
163
+ * resolved Outputs after standing the backends up.
164
+ *
165
+ * Keys are `RASTACK_<ENV>_*` (a neutral prefix). For a browser bundle, re-export
166
+ * the ones you need under your bundler's public prefix (EXPO_PUBLIC_/VITE_/…);
167
+ * `rastack dev` also serves the dev pool config live at `/__rastack/cognito.json`.
168
+ */
169
+ export function renderEnvFile(cfg: DeployConfig): string {
170
+ const lines: string[] = [
171
+ "# Generated by `rastack init` — local app environment (git-ignored).",
172
+ "# Non-secret values from the CloudFormation stack Outputs; add your own",
173
+ "# secrets below. Re-run `rastack init` to refresh after a deploy.",
174
+ "",
175
+ `RASTACK_APP=${cfg.app}`,
176
+ `RASTACK_REGION=${cfg.region}`,
177
+ "",
178
+ ];
179
+ const envKeys: [EnvName, string][] = [
180
+ ["dev", "DEV"],
181
+ ["prod", "PROD"],
182
+ ];
183
+ for (const [env, prefix] of envKeys) {
184
+ const e = resolveEnvironment(cfg, env);
185
+ const o = e.outputs ?? {};
186
+ lines.push(
187
+ `# ${env} (${e.branch} → ${e.ghEnvironment})`,
188
+ `RASTACK_${prefix}_API_URL=${o.apiUrl ?? ""}`,
189
+ `RASTACK_${prefix}_USER_POOL_ID=${o.userPoolId ?? ""}`,
190
+ `RASTACK_${prefix}_USER_POOL_CLIENT_ID=${o.userPoolClientId ?? ""}`,
191
+ `RASTACK_${prefix}_HOSTED_UI_URL=${o.hostedUiUrl ?? ""}`,
192
+ `RASTACK_${prefix}_PROXY_URL=${o.proxyUrl ?? ""}`,
193
+ "",
194
+ );
195
+ }
196
+ return lines.join("\n");
197
+ }
198
+
199
+ /**
200
+ * Upsert the generated `RASTACK_*` keys from `renderEnvFile(cfg)` into an
201
+ * existing `.env`, preserving every other line (a developer's own secrets,
202
+ * comments, extra keys). A matching `RASTACK_*=` line is updated in place;
203
+ * generated keys not yet present are appended. When there is no existing file,
204
+ * this is exactly `renderEnvFile(cfg)`. Pure — the IO wrapper reads/writes.
205
+ */
206
+ export function applyEnvValues(existing: string, cfg: DeployConfig): string {
207
+ const rendered = renderEnvFile(cfg);
208
+ if (!existing.trim()) return rendered;
209
+
210
+ const generated = new Map<string, string>();
211
+ for (const line of rendered.split("\n")) {
212
+ const m = /^(RASTACK_[A-Z0-9_]+)=(.*)$/.exec(line);
213
+ if (m) generated.set(m[1], m[2]);
214
+ }
215
+
216
+ const seen = new Set<string>();
217
+ const out = existing.split("\n").map((line) => {
218
+ const m = /^(RASTACK_[A-Z0-9_]+)=/.exec(line);
219
+ if (m && generated.has(m[1])) {
220
+ seen.add(m[1]);
221
+ return `${m[1]}=${generated.get(m[1])}`;
222
+ }
223
+ return line;
224
+ });
225
+
226
+ const missing = [...generated].filter(([k]) => !seen.has(k));
227
+ if (missing.length) {
228
+ if (out.length && out[out.length - 1].trim() !== "") out.push("");
229
+ for (const [k, v] of missing) out.push(`${k}=${v}`);
230
+ out.push("");
231
+ }
232
+ return out.join("\n");
233
+ }
234
+
235
+ /**
236
+ * Return `.gitignore` content with a `.env` entry present (idempotent). Appends
237
+ * under a labelled block when missing, leaves the file untouched when `.env` is
238
+ * already ignored — so `rastack init` never duplicates the entry or clobbers a
239
+ * developer's existing ignore rules.
240
+ */
241
+ export function gitignoreWith(existing: string, entry = ".env"): string {
242
+ const lines = existing.split("\n").map((l) => l.trim());
243
+ if (lines.includes(entry)) return existing;
244
+ const base = existing.replace(/\n+$/, "");
245
+ const block = `# rastack — local environment (secrets)\n${entry}\n`;
246
+ return base ? `${base}\n\n${block}` : block;
247
+ }
138
248
 
139
249
  export interface ScaffoldOptions {
140
250
  /** Omit the GitHub Actions workflows (config only). */
package/src/rastack-ci.ts CHANGED
@@ -19,12 +19,20 @@
19
19
 
20
20
  import { execSync } from "child_process";
21
21
  import { runCompile } from "./compile";
22
- import { DeployConfig } from "./deploy/config";
22
+ import { DeployConfig, EnvName, ENV_NAMES } from "./deploy/config";
23
23
  import { CiPhase, CiPlanOptions, CiStep, ciPlan } from "./deploy/ci";
24
24
  import { loadConfig } from "./deploy/io";
25
25
 
26
26
  const PHASES: CiPhase[] = ["check", "build", "deploy"];
27
27
 
28
+ /** Coerce a string to an `EnvName`, or undefined when unset/unrecognised. */
29
+ function parseEnv(value: string | undefined): EnvName | undefined {
30
+ const v = (value ?? "").trim();
31
+ return (ENV_NAMES as readonly string[]).includes(v)
32
+ ? (v as EnvName)
33
+ : undefined;
34
+ }
35
+
28
36
  interface Flags {
29
37
  phase: CiPhase;
30
38
  plan: boolean;
@@ -38,6 +46,7 @@ function parseFlags(argv: string[]): Flags {
38
46
  bin: process.env.RASTACK_BIN || "rastack",
39
47
  functionName: process.env.RASTACK_FUNCTION || undefined,
40
48
  jwksUrl: process.env.RASTACK_JWKS_URL || undefined,
49
+ env: parseEnv(process.env.RASTACK_ENV),
41
50
  };
42
51
  for (let i = 0; i < argv.length; i++) {
43
52
  const a = argv[i];
@@ -56,6 +65,15 @@ function parseFlags(argv: string[]): Flags {
56
65
  case "--jwks-url":
57
66
  opts.jwksUrl = next();
58
67
  break;
68
+ case "--env": {
69
+ const env = parseEnv(next());
70
+ if (!env) {
71
+ console.error(`Unknown --env: expected ${ENV_NAMES.join(" | ")}.`);
72
+ process.exit(1);
73
+ }
74
+ opts.env = env;
75
+ break;
76
+ }
59
77
  case "--bin":
60
78
  opts.bin = next();
61
79
  break;
@@ -75,7 +93,7 @@ function parseFlags(argv: string[]): Flags {
75
93
  }
76
94
  if (!phase) {
77
95
  console.error(
78
- `Usage: rastack ci <${PHASES.join(" | ")}> [--plan] [--skip-build]\n`,
96
+ `Usage: rastack ci <${PHASES.join(" | ")}> [--env dev|prod] [--plan] [--skip-build]\n`,
79
97
  );
80
98
  process.exit(1);
81
99
  }
@@ -21,12 +21,19 @@
21
21
  * but a Rust + wasm toolchain is, it is built from source as a fallback.
22
22
  */
23
23
 
24
- import { execFileSync, spawn, spawnSync, type ChildProcess } from "child_process";
24
+ import {
25
+ execFileSync,
26
+ spawn,
27
+ spawnSync,
28
+ type ChildProcess,
29
+ } from "child_process";
25
30
  import * as fs from "fs";
26
31
  import * as http from "http";
27
32
  import * as net from "net";
28
33
  import * as path from "path";
29
34
  import { runCompile } from "./compile";
35
+ import { cognitoRuntimeConfig } from "./deploy/config";
36
+ import { loadConfig } from "./deploy/io";
30
37
  import {
31
38
  APP_ENTRY,
32
39
  WASM_ENTRY,
@@ -115,6 +122,37 @@ function serveFile(res: http.ServerResponse, file: string): void {
115
122
  });
116
123
  }
117
124
 
125
+ /**
126
+ * Serve the dev environment's Cognito coordinates (`/__rastack/cognito.json`) so
127
+ * a locally-running app can sign in against the *real* dev user pool. Read from
128
+ * `.rastack/deploy.json`'s dev outputs; `{}` when the dev stack isn't deployed
129
+ * yet (the app then falls back to `mode:"local"`). Returns whether a real config
130
+ * was found, for the startup hint.
131
+ */
132
+ function serveDevCognito(res: http.ServerResponse, cwd: string): void {
133
+ let body = "{}";
134
+ try {
135
+ const cfg = loadConfig(cwd);
136
+ const runtime = cfg ? cognitoRuntimeConfig(cfg, "dev") : null;
137
+ if (runtime) body = JSON.stringify(runtime);
138
+ } catch {
139
+ /* no/invalid config → empty object, app uses the local shim */
140
+ }
141
+ res
142
+ .writeHead(200, { "Content-Type": "application/json; charset=utf-8" })
143
+ .end(body);
144
+ }
145
+
146
+ /** Whether a real dev Cognito config is available to advertise on startup. */
147
+ function hasDevCognito(cwd: string): boolean {
148
+ try {
149
+ const cfg = loadConfig(cwd);
150
+ return Boolean(cfg && cognitoRuntimeConfig(cfg, "dev"));
151
+ } catch {
152
+ return false;
153
+ }
154
+ }
155
+
118
156
  /** Best-effort open the default browser at `url`. */
119
157
  function openBrowser(url: string): void {
120
158
  const cmd =
@@ -177,7 +215,9 @@ function getFreePort(): Promise<number> {
177
215
  srv.listen(0, "127.0.0.1", () => {
178
216
  const addr = srv.address();
179
217
  const port = addr && typeof addr === "object" ? addr.port : 0;
180
- srv.close(() => (port ? resolve(port) : reject(new Error("no free port"))));
218
+ srv.close(() =>
219
+ port ? resolve(port) : reject(new Error("no free port")),
220
+ );
181
221
  });
182
222
  });
183
223
  }
@@ -241,7 +281,13 @@ function proxyToMetro(
241
281
  port: number,
242
282
  ): void {
243
283
  const proxyReq = http.request(
244
- { host: "127.0.0.1", port, method: req.method, path: req.url, headers: req.headers },
284
+ {
285
+ host: "127.0.0.1",
286
+ port,
287
+ method: req.method,
288
+ path: req.url,
289
+ headers: req.headers,
290
+ },
245
291
  (proxyRes) => {
246
292
  res.writeHead(proxyRes.statusCode || 502, proxyRes.headers);
247
293
  proxyRes.pipe(res);
@@ -318,8 +364,13 @@ async function runMetroMode(args: DevArgs, cwd: string): Promise<void> {
318
364
  return serveUnder(res, wasmDir, url.slice("/wasm/".length));
319
365
  if (url === "/schema.rastack.json" || url === "/openapi.json")
320
366
  return serveUnder(res, args.outDir, path.basename(url));
367
+ if (url === "/__rastack/cognito.json") return serveDevCognito(res, cwd);
321
368
  if (url.startsWith("/data/warehouse/"))
322
- return serveUnder(res, args.warehouseDir, url.slice("/data/warehouse/".length));
369
+ return serveUnder(
370
+ res,
371
+ args.warehouseDir,
372
+ url.slice("/data/warehouse/".length),
373
+ );
323
374
  // Everything else → the live Metro bundle (HTML, JS, assets, HMR polls).
324
375
  return proxyToMetro(req, res, metroPort);
325
376
  });
@@ -341,8 +392,7 @@ async function runMetroMode(args: DevArgs, cwd: string): Promise<void> {
341
392
  server.listen(args.port, () => {
342
393
  const url = `http://localhost:${args.port}`;
343
394
  console.log(` ✓ Metro live on :${metroPort} — fast refresh on`);
344
- if (wasmDir)
345
- console.log(` ✓ WASM engine: ${path.relative(cwd, wasmDir)}`);
395
+ if (wasmDir) console.log(` ✓ WASM engine: ${path.relative(cwd, wasmDir)}`);
346
396
  console.log(`\n ▸ ${url}\n`);
347
397
  console.log(
348
398
  " Edits reload live. Need the database console? Run `rastack admin`. Ctrl+C to stop.\n",
@@ -393,6 +443,7 @@ async function main(argv: string[]): Promise<void> {
393
443
  return serveUnder(res, wasmDir, url.slice("/wasm/".length));
394
444
  if (url === "/schema.rastack.json" || url === "/openapi.json")
395
445
  return serveUnder(res, args.outDir, path.basename(url));
446
+ if (url === "/__rastack/cognito.json") return serveDevCognito(res, cwd);
396
447
  if (url.startsWith("/data/warehouse/"))
397
448
  return serveUnder(
398
449
  res,
@@ -432,6 +483,10 @@ async function main(argv: string[]): Promise<void> {
432
483
  if (appRoot)
433
484
  console.log(` ✓ app: ${path.relative(process.cwd(), appRoot) || "."}`);
434
485
  else console.log(` ○ no app found — serving the welcome page`);
486
+ if (hasDevCognito(cwd))
487
+ console.log(
488
+ ` ✓ dev Cognito: /__rastack/cognito.json (sign in against the real dev pool)`,
489
+ );
435
490
  console.log(`\n ▸ ${url}\n`);
436
491
  console.log(
437
492
  " Need the database console? Run `rastack admin`. Ctrl+C to stop.\n",