rastack 0.0.42 → 0.0.44

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 (41) hide show
  1. package/CHANGELOG.md +15 -0
  2. package/admin/AdminApp.tsx +308 -0
  3. package/admin/adapters.ts +247 -0
  4. package/admin/index.tsx +88 -0
  5. package/admin/useAdmin.ts +299 -0
  6. package/dist/admin.js +81 -0
  7. package/dist/dev/harness.d.ts +53 -0
  8. package/dist/dev/harness.js +151 -0
  9. package/dist/rastack-dev.d.ts +18 -0
  10. package/dist/rastack-dev.js +197 -0
  11. package/dist/rastack-init.d.ts +3 -2
  12. package/dist/rastack-init.js +23 -44
  13. package/dist/rastack-update.js +16 -8
  14. package/dist/rastack-wasm-build.d.ts +9 -4
  15. package/dist/rastack-wasm-build.js +44 -33
  16. package/dist/rastack.d.ts +1 -2
  17. package/dist/rastack.js +7 -10
  18. package/dist/update.d.ts +11 -0
  19. package/dist/update.js +14 -0
  20. package/dist/wasm/package.json +4 -0
  21. package/dist/wasm/rastack_wasm.d.ts +100 -0
  22. package/dist/wasm/rastack_wasm.js +745 -0
  23. package/dist/wasm/rastack_wasm_bg.wasm +0 -0
  24. package/dist/wasm/rastack_wasm_bg.wasm.d.ts +28 -0
  25. package/package.json +6 -2
  26. package/scripts/build-admin.js +48 -0
  27. package/scripts/copy-wasm.js +35 -0
  28. package/src/dev/harness.ts +130 -0
  29. package/src/rastack-dev.ts +197 -0
  30. package/src/rastack-init.ts +26 -62
  31. package/src/rastack-update.ts +22 -9
  32. package/src/rastack-wasm-build.ts +52 -41
  33. package/src/rastack.ts +7 -10
  34. package/src/update.ts +14 -0
  35. package/test/dev.spec.ts +277 -0
  36. package/test/update.spec.ts +13 -0
  37. package/wasm/package.json +4 -0
  38. package/wasm/rastack_wasm.d.ts +100 -0
  39. package/wasm/rastack_wasm.js +745 -0
  40. package/wasm/rastack_wasm_bg.wasm +0 -0
  41. package/wasm/rastack_wasm_bg.wasm.d.ts +28 -0
Binary file
@@ -0,0 +1,28 @@
1
+ /* tslint:disable */
2
+ /* eslint-disable */
3
+ export const memory: WebAssembly.Memory;
4
+ export const __wbg_rastackapi_free: (a: number, b: number) => void;
5
+ export const rastackapi_exportWarehouse: (a: number) => any;
6
+ export const rastackapi_handle: (a: number, b: number, c: number, d: number, e: number, f: any, g: any) => any;
7
+ export const rastackapi_importWarehouse: (a: number, b: number, c: number) => any;
8
+ export const rastackapi_new: (a: number, b: number, c: number, d: number) => [number, number, number];
9
+ export const rastackapi_seedFile: (a: number, b: number, c: number, d: number, e: number) => any;
10
+ export const rastackapi_setAuth: (a: number, b: number, c: number, d: any) => [number, number];
11
+ export const rust_zstd_wasm_shim_calloc: (a: number, b: number) => number;
12
+ export const rust_zstd_wasm_shim_free: (a: number) => void;
13
+ export const rust_zstd_wasm_shim_malloc: (a: number) => number;
14
+ export const rust_zstd_wasm_shim_memcmp: (a: number, b: number, c: number) => number;
15
+ export const rust_zstd_wasm_shim_memcpy: (a: number, b: number, c: number) => number;
16
+ export const rust_zstd_wasm_shim_memmove: (a: number, b: number, c: number) => number;
17
+ export const rust_zstd_wasm_shim_memset: (a: number, b: number, c: number) => number;
18
+ export const rust_zstd_wasm_shim_qsort: (a: number, b: number, c: number, d: number) => void;
19
+ export const wasm_bindgen_1a7aa9db5392c486___convert__closures_____invoke___wasm_bindgen_1a7aa9db5392c486___JsValue__core_7d5f0a2ba6a62c33___result__Result_____wasm_bindgen_1a7aa9db5392c486___JsError___true_: (a: number, b: number, c: any) => [number, number];
20
+ export const wasm_bindgen_1a7aa9db5392c486___convert__closures_____invoke___js_sys_191ff9fb2bd47800___Function_fn_wasm_bindgen_1a7aa9db5392c486___JsValue_____wasm_bindgen_1a7aa9db5392c486___sys__Undefined___js_sys_191ff9fb2bd47800___Function_fn_wasm_bindgen_1a7aa9db5392c486___JsValue_____wasm_bindgen_1a7aa9db5392c486___sys__Undefined_______true_: (a: number, b: number, c: any, d: any) => void;
21
+ export const __wbindgen_malloc: (a: number, b: number) => number;
22
+ export const __wbindgen_realloc: (a: number, b: number, c: number, d: number) => number;
23
+ export const __wbindgen_exn_store: (a: number) => void;
24
+ export const __externref_table_alloc: () => number;
25
+ export const __wbindgen_externrefs: WebAssembly.Table;
26
+ export const __wbindgen_destroy_closure: (a: number, b: number) => void;
27
+ export const __externref_table_dealloc: (a: number) => void;
28
+ export const __wbindgen_start: () => void;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "rastack",
3
- "version": "0.0.42",
3
+ "version": "0.0.44",
4
4
  "description": "",
5
5
  "main": "runtime.ts",
6
6
  "types": "runtime.ts",
@@ -13,7 +13,7 @@
13
13
  },
14
14
  "scripts": {
15
15
  "format": "prettier --write \"**/*.{ts,tsx,md}\"",
16
- "build": "tsc && cp src/.prettierrc dist/.prettierrc && node scripts/copy-templates.js",
16
+ "build": "tsc && cp src/.prettierrc dist/.prettierrc && node scripts/copy-templates.js && node scripts/build-admin.js && node scripts/copy-wasm.js",
17
17
  "start": "node dist/index.js",
18
18
  "test": "jest"
19
19
  },
@@ -50,8 +50,12 @@
50
50
  "@types/js-yaml": "^4.0.9",
51
51
  "@types/mocha": "^10.0.6",
52
52
  "@types/node": "^26.1.0",
53
+ "@types/react-dom": "^19.2.0",
54
+ "esbuild": "^0.25.0",
53
55
  "eslint": "^10.6.0",
54
56
  "jest": "^30.4.2",
57
+ "react": "^19.2.0",
58
+ "react-dom": "^19.2.0",
55
59
  "ts-jest": "^29.4.11"
56
60
  }
57
61
  }
@@ -0,0 +1,48 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Bundle the in-browser admin (`admin/index.tsx` + the reused `rastack/components`
4
+ * / `rastack/theme` / `rastack/provider` React source) into a single static
5
+ * `dist/admin.js` that `rastack dev` serves at `/dev/admin.js`.
6
+ *
7
+ * `tools/` otherwise builds with plain `tsc`, and the React runtime dirs ship as
8
+ * `.tsx` source (never compiled), so a bundler is required to turn the admin
9
+ * entrypoint into a browser asset. esbuild is that bundler. The bare
10
+ * `rastack/tokens` + `rastack/theme` subpath imports the components use are
11
+ * aliased to the in-repo sources (mirroring tsconfig `paths` / jest
12
+ * `moduleNameMapper`). The WASM engine is loaded at runtime via a hidden
13
+ * `import()` (see admin/index.tsx), so esbuild never tries to resolve it.
14
+ */
15
+ const path = require("path");
16
+ const esbuild = require("esbuild");
17
+
18
+ const root = path.join(__dirname, "..");
19
+
20
+ esbuild
21
+ .build({
22
+ entryPoints: [path.join(root, "admin", "index.tsx")],
23
+ bundle: true,
24
+ outfile: path.join(root, "dist", "admin.js"),
25
+ format: "iife",
26
+ platform: "browser",
27
+ target: ["es2020"],
28
+ minify: true,
29
+ sourcemap: false,
30
+ jsx: "automatic",
31
+ // The reused provider/hooks read `process.env.*` (e.g. NEXT_PUBLIC_BASE_URL).
32
+ // `define` folds NODE_ENV; the banner shims a `process.env` object so any
33
+ // other lookups resolve to undefined instead of throwing in the browser.
34
+ define: { "process.env.NODE_ENV": '"production"' },
35
+ banner: {
36
+ js: "window.process=window.process||{env:{}};",
37
+ },
38
+ alias: {
39
+ "rastack/tokens": path.join(root, "tokens.ts"),
40
+ "rastack/theme": path.join(root, "theme", "index.ts"),
41
+ },
42
+ logLevel: "info",
43
+ })
44
+ .then(() => console.log("build-admin: wrote dist/admin.js"))
45
+ .catch((err) => {
46
+ console.error("build-admin: failed\n", err);
47
+ process.exit(1);
48
+ });
@@ -0,0 +1,35 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Copy the prebuilt WASM engine (`tools/wasm/`, produced by
4
+ * `rastack-wasm-build.js`) into the published package at `dist/wasm/`, so
5
+ * `rastack dev` serves it from any repo with no cargo. A no-op when no bundle
6
+ * has been built yet (a normal `pnpm build` on a machine without the Rust
7
+ * toolchain), so ordinary builds still succeed — the release CI builds the
8
+ * engine before this runs, and `rastack dev` has a from-source fallback.
9
+ */
10
+ const fs = require("fs");
11
+ const path = require("path");
12
+
13
+ const root = path.join(__dirname, "..");
14
+ const src = path.join(root, "wasm");
15
+ const dest = path.join(root, "dist", "wasm");
16
+ const entry = path.join(src, "rastack_wasm.js");
17
+
18
+ if (!fs.existsSync(entry)) {
19
+ console.log(
20
+ "copy-wasm: no prebuilt engine at tools/wasm (skipping — build it with " +
21
+ "rastack-wasm-build.js to ship it).",
22
+ );
23
+ process.exit(0);
24
+ }
25
+
26
+ fs.mkdirSync(dest, { recursive: true });
27
+ let n = 0;
28
+ for (const name of fs.readdirSync(src)) {
29
+ const from = path.join(src, name);
30
+ if (fs.statSync(from).isFile()) {
31
+ fs.copyFileSync(from, path.join(dest, name));
32
+ n++;
33
+ }
34
+ }
35
+ console.log(`copy-wasm: copied ${n} file(s) into dist/wasm`);
@@ -0,0 +1,130 @@
1
+ /**
2
+ * Pure core of `rastack dev` — the local, always-WASM development server.
3
+ *
4
+ * `rastack dev` boots the app + admin **in the browser** over the WebAssembly
5
+ * engine (the same app-generic `rastack-api-core` the Lambda runs, fed the app's
6
+ * compiled manifest). No native server, no cargo: a dependency-free static
7
+ * server hands the browser the prebuilt wasm engine, the compiled schema, and
8
+ * the committed Iceberg warehouse, and a bundled React admin drives it.
9
+ *
10
+ * Everything decidable without IO lives here (arg parsing, MIME types, locating
11
+ * the wasm bundle, the HTML shell) so it is unit-tested without a socket or a
12
+ * filesystem; `rastack-dev.ts` is the thin IO wrapper that compiles, resolves
13
+ * the engine, and serves.
14
+ */
15
+
16
+ import * as path from "path";
17
+
18
+ export interface DevArgs {
19
+ /** Where the TypeScript resource definitions live (compiled on start). */
20
+ resourcesDir: string;
21
+ /** Where `rastack compile` writes schema.rastack.json + openapi.json. */
22
+ outDir: string;
23
+ /** The committed Iceberg warehouse served to seed the browser engine. */
24
+ warehouseDir: string;
25
+ /** Port the dev server listens on. */
26
+ port: number;
27
+ /** Open the browser on start (false with `--no-open`). */
28
+ open: boolean;
29
+ }
30
+
31
+ export const DEV_DEFAULTS: DevArgs = {
32
+ resourcesDir: "resources",
33
+ outDir: ".rastack",
34
+ warehouseDir: "data/warehouse",
35
+ port: 4321,
36
+ open: true,
37
+ };
38
+
39
+ /**
40
+ * Parse `rastack dev` args: `--port`, `--warehouse`, `--out`, `--no-open`, and a
41
+ * single positional resources dir. Unknown flags are ignored (forgiving CLI).
42
+ */
43
+ export function parseDevArgs(argv: string[]): DevArgs {
44
+ const positional: string[] = [];
45
+ let { resourcesDir, outDir, warehouseDir, port, open } = DEV_DEFAULTS;
46
+ for (let i = 0; i < argv.length; i++) {
47
+ const a = argv[i];
48
+ if (a === "--port") port = Number(argv[++i]) || port;
49
+ else if (a === "--warehouse") warehouseDir = argv[++i] ?? warehouseDir;
50
+ else if (a === "--out") outDir = argv[++i] ?? outDir;
51
+ else if (a === "--no-open") open = false;
52
+ else if (!a.startsWith("-")) positional.push(a);
53
+ }
54
+ return {
55
+ resourcesDir: positional[0] || resourcesDir,
56
+ outDir,
57
+ warehouseDir,
58
+ port,
59
+ open,
60
+ };
61
+ }
62
+
63
+ const MIME: Record<string, string> = {
64
+ ".html": "text/html; charset=utf-8",
65
+ ".js": "text/javascript; charset=utf-8",
66
+ ".mjs": "text/javascript; charset=utf-8",
67
+ ".css": "text/css; charset=utf-8",
68
+ ".json": "application/json; charset=utf-8",
69
+ // Critical: `WebAssembly.instantiateStreaming` requires this exact type.
70
+ ".wasm": "application/wasm",
71
+ ".text": "text/plain; charset=utf-8",
72
+ ".parquet": "application/octet-stream",
73
+ };
74
+
75
+ /** MIME type for a served path, defaulting to octet-stream (Iceberg data files). */
76
+ export function contentType(filePath: string): string {
77
+ const ext = filePath.slice(filePath.lastIndexOf("."));
78
+ return MIME[ext] ?? "application/octet-stream";
79
+ }
80
+
81
+ /**
82
+ * Ordered directories to look for the prebuilt wasm bundle (`rastack_wasm.js` +
83
+ * `rastack_wasm_bg.wasm`). The bundle is shipped inside the package
84
+ * (`dist/wasm/`, copied at build), so `rastack dev` works from any repo with no
85
+ * cargo; the repo-local `tools/wasm/` (from a source build) and a sibling of the
86
+ * running module are fallbacks for development. `dirnameDir` is the directory of
87
+ * the compiled `rastack-dev.js` (`dist/`), so `../wasm` and `./wasm` cover both
88
+ * `dist/rastack-dev.js` → `dist/wasm` and `dist/dev/…` layouts.
89
+ */
90
+ export function wasmBundleCandidates(
91
+ dirnameDir: string,
92
+ cwd = process.cwd(),
93
+ ): string[] {
94
+ return [
95
+ path.join(dirnameDir, "wasm"),
96
+ path.join(dirnameDir, "..", "wasm"),
97
+ path.join(cwd, "tools", "wasm"),
98
+ path.join(cwd, "wasm"),
99
+ ];
100
+ }
101
+
102
+ /** The name of the file that marks a directory as a usable wasm bundle. */
103
+ export const WASM_ENTRY = "rastack_wasm.js";
104
+
105
+ /**
106
+ * The HTML shell served at `/`. It loads the bundled React admin
107
+ * (`/dev/admin.js`), which fetches the schema/openapi, boots the wasm engine,
108
+ * seeds it from `/data/warehouse/`, and renders the admin. Deliberately tiny —
109
+ * all logic lives in the bundle — mirroring the `rastack design` studio.
110
+ */
111
+ export function renderShell(title = "React API Stack — dev"): string {
112
+ return `<!doctype html>
113
+ <html lang="en">
114
+ <head>
115
+ <meta charset="utf-8" />
116
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
117
+ <title>${title}</title>
118
+ <style>
119
+ html, body { margin: 0; height: 100%; }
120
+ #root { min-height: 100%; }
121
+ .rastack-boot { font: 14px system-ui, sans-serif; padding: 2rem; color: #555; }
122
+ </style>
123
+ </head>
124
+ <body>
125
+ <div id="root"><div class="rastack-boot">Loading the WASM engine…</div></div>
126
+ <script src="/dev/admin.js"></script>
127
+ </body>
128
+ </html>
129
+ `;
130
+ }
@@ -0,0 +1,197 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * `rastack dev` — local development, always over the WASM engine.
5
+ *
6
+ * rastack dev [resourcesDir] [--port <n>] [--warehouse <dir>] [--out <dir>] [--no-open]
7
+ *
8
+ * Compiles the resources, then boots a dependency-free static server (à la
9
+ * `rastack design`) that hands the browser everything it needs to run the app +
10
+ * admin locally over WebAssembly — no native server, no cargo:
11
+ * • the bundled React admin (`/dev/admin.js`),
12
+ * • the prebuilt WASM engine (`/wasm/…`) shipped in the package,
13
+ * • the compiled `schema.rastack.json` / `openapi.json`,
14
+ * • the committed Iceberg warehouse (`/data/warehouse/…`) to seed from.
15
+ *
16
+ * The engine is resolved from the package's prebuilt bundle; if none is present
17
+ * but a Rust + wasm toolchain is, it is built from source as a fallback.
18
+ */
19
+
20
+ import { execFileSync, spawnSync } from "child_process";
21
+ import * as fs from "fs";
22
+ import * as http from "http";
23
+ import * as path from "path";
24
+ import {
25
+ WASM_ENTRY,
26
+ contentType,
27
+ parseDevArgs,
28
+ renderShell,
29
+ wasmBundleCandidates,
30
+ } from "./dev/harness";
31
+
32
+ const HERE = __dirname; // dist/ when installed
33
+
34
+ /** Compile the resources to schema.rastack.json + openapi.json in outDir. */
35
+ function compile(resourcesDir: string, outDir: string): boolean {
36
+ const compiler = path.join(HERE, "rastack-compile.js");
37
+ const result = spawnSync(
38
+ process.execPath,
39
+ [compiler, "compile", resourcesDir, outDir],
40
+ { stdio: "inherit" },
41
+ );
42
+ if (result.status !== 0) return false;
43
+ return fs.existsSync(path.join(outDir, "schema.rastack.json"));
44
+ }
45
+
46
+ /** True when `cargo` is on PATH (the from-source wasm fallback needs it). */
47
+ function cargoAvailable(): boolean {
48
+ try {
49
+ return (
50
+ spawnSync("cargo", ["--version"], { stdio: "ignore" }).status === 0
51
+ );
52
+ } catch {
53
+ return false;
54
+ }
55
+ }
56
+
57
+ /** Locate the prebuilt wasm bundle dir, building from source as a fallback. */
58
+ function resolveWasmDir(): string | null {
59
+ for (const dir of wasmBundleCandidates(HERE)) {
60
+ if (fs.existsSync(path.join(dir, WASM_ENTRY))) return dir;
61
+ }
62
+ if (cargoAvailable()) {
63
+ console.log(
64
+ "\n▸ No prebuilt WASM engine found — building it from source (cargo)…\n",
65
+ );
66
+ try {
67
+ // Lazy require so a normal `dev` with a prebuilt bundle never loads it.
68
+ // eslint-disable-next-line @typescript-eslint/no-var-requires
69
+ require("./rastack-wasm-build").buildWasm();
70
+ } catch (err) {
71
+ console.error(` ✗ wasm build failed: ${(err as Error).message}`);
72
+ return null;
73
+ }
74
+ for (const dir of wasmBundleCandidates(HERE)) {
75
+ if (fs.existsSync(path.join(dir, WASM_ENTRY))) return dir;
76
+ }
77
+ }
78
+ return null;
79
+ }
80
+
81
+ /** Serve one file if it exists and stays within `root`; else 404/403. */
82
+ function serveUnder(
83
+ res: http.ServerResponse,
84
+ root: string,
85
+ relPath: string,
86
+ ): void {
87
+ const safeRoot = path.resolve(root);
88
+ const rel = decodeURIComponent(relPath).replace(/^\/+/, "");
89
+ const target = path.resolve(safeRoot, rel);
90
+ // Path-traversal guard: the resolved target must stay inside the root.
91
+ if (target !== safeRoot && !target.startsWith(safeRoot + path.sep)) {
92
+ res.writeHead(403).end("Forbidden");
93
+ return;
94
+ }
95
+ fs.readFile(target, (err, data) => {
96
+ if (err) {
97
+ res.writeHead(404).end("Not found");
98
+ return;
99
+ }
100
+ res.writeHead(200, { "Content-Type": contentType(target) }).end(data);
101
+ });
102
+ }
103
+
104
+ /** Serve an exact file (no traversal concern — a fixed internal artifact). */
105
+ function serveFile(res: http.ServerResponse, file: string): void {
106
+ fs.readFile(file, (err, data) => {
107
+ if (err) {
108
+ res.writeHead(404).end("Not found");
109
+ return;
110
+ }
111
+ res.writeHead(200, { "Content-Type": contentType(file) }).end(data);
112
+ });
113
+ }
114
+
115
+ /** Best-effort open the default browser at `url`. */
116
+ function openBrowser(url: string): void {
117
+ const cmd =
118
+ process.platform === "darwin"
119
+ ? "open"
120
+ : process.platform === "win32"
121
+ ? "start"
122
+ : "xdg-open";
123
+ try {
124
+ execFileSync(cmd, [url], { stdio: "ignore" });
125
+ } catch {
126
+ /* non-fatal — the URL is printed anyway */
127
+ }
128
+ }
129
+
130
+ function main(argv: string[]): void {
131
+ const args = parseDevArgs(argv);
132
+
133
+ console.log(`\n rastack dev — the app + admin, in your browser over WASM\n`);
134
+ console.log(`▸ Compiling ${args.resourcesDir} → ${args.outDir}`);
135
+ if (!compile(args.resourcesDir, args.outDir)) {
136
+ console.error(" ✗ compile failed — fix the resource graph and re-run.");
137
+ process.exit(1);
138
+ }
139
+
140
+ const wasmDir = resolveWasmDir();
141
+ if (!wasmDir) {
142
+ console.error(
143
+ "\n ✗ No WASM engine available.\n" +
144
+ " Upgrade rastack for the prebuilt bundle (`rastack update`), or install\n" +
145
+ " the Rust toolchain to build it from source:\n" +
146
+ " rustup target add wasm32-unknown-unknown\n" +
147
+ " cargo install wasm-bindgen-cli\n",
148
+ );
149
+ process.exit(1);
150
+ }
151
+
152
+ const adminBundle = path.join(HERE, "admin.js");
153
+ if (!fs.existsSync(adminBundle)) {
154
+ console.error(
155
+ " ✗ admin bundle (dist/admin.js) not found — reinstall rastack.",
156
+ );
157
+ process.exit(1);
158
+ }
159
+
160
+ const server = http.createServer((req, res) => {
161
+ const url = (req.url || "/").split("?")[0];
162
+ if (req.method !== "GET") {
163
+ res.writeHead(405).end("Method not allowed");
164
+ return;
165
+ }
166
+ if (url === "/" || url === "/index.html") {
167
+ res
168
+ .writeHead(200, { "Content-Type": "text/html; charset=utf-8" })
169
+ .end(renderShell());
170
+ return;
171
+ }
172
+ if (url === "/dev/admin.js") return serveFile(res, adminBundle);
173
+ if (url.startsWith("/wasm/"))
174
+ return serveUnder(res, wasmDir, url.slice("/wasm/".length));
175
+ if (url === "/schema.rastack.json" || url === "/openapi.json")
176
+ return serveUnder(res, args.outDir, path.basename(url));
177
+ if (url.startsWith("/data/warehouse/"))
178
+ return serveUnder(
179
+ res,
180
+ args.warehouseDir,
181
+ url.slice("/data/warehouse/".length),
182
+ );
183
+ res.writeHead(404).end("Not found");
184
+ });
185
+
186
+ server.listen(args.port, () => {
187
+ const url = `http://localhost:${args.port}`;
188
+ console.log(` ✓ WASM engine: ${path.relative(process.cwd(), wasmDir)}`);
189
+ console.log(`\n ▸ ${url} (admin at ${url}/#/)\n`);
190
+ console.log(" Press Ctrl+C to stop.\n");
191
+ if (args.open) openBrowser(url);
192
+ });
193
+ }
194
+
195
+ if (require.main === module) {
196
+ main(process.argv.slice(2));
197
+ }
@@ -12,8 +12,9 @@
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 — and CREATES the
16
- * bootstrap stack if it does not exist yet;
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);
17
18
  * 3. uploads the Lambda placeholder bundle to that bucket;
18
19
  * 4. deploys the per-app backend (app.yaml) with the resolved ArtifactsBucket;
19
20
  * 5. reads the stack Outputs back into the config and prints the two GitHub repo
@@ -33,8 +34,6 @@ import {
33
34
  DeployConfig,
34
35
  DeployOutputs,
35
36
  PartialDeployConfig,
36
- bootstrapStackArgs,
37
- bootstrapStackCommand,
38
37
  bootstrapStackName,
39
38
  deployStackArgs,
40
39
  deployStackCommand,
@@ -210,17 +209,18 @@ function printPlan(cfg: DeployConfig): void {
210
209
  const withBucket = mergeConfig(cfg, {
211
210
  artifactsBucket: bucket ?? "<ArtifactsBucketName from bootstrap stack>",
212
211
  });
213
- const bootstrapTemplate = resolveDeployAsset("bootstrap.yaml") ?? undefined;
214
212
  const appTemplate = resolveDeployAsset("app.yaml") ?? undefined;
215
213
  console.log(`\n Deployment plan (${cfg.stackName} @ ${cfg.region})\n`);
216
214
  console.log(
217
- ` ── 1. Bootstrap stack (${bootstrapStackName(cfg)}) — run only if it does not exist ──`,
215
+ ` ── Prerequisite: the tier-0 bootstrap stack (${bootstrapStackName(cfg)}) must exist ──`,
218
216
  );
219
- console.log("\n " + bootstrapStackCommand(cfg, false, bootstrapTemplate) + "\n");
220
- console.log(" ── 2. Per-app backend (app.yaml) ──");
217
+ console.log(
218
+ ` Create it once per account+region (init does not): rastack bootstrap --region ${cfg.region}\n`,
219
+ );
220
+ console.log(" ── 1. Per-app backend (app.yaml) — what init deploys ──");
221
221
  console.log("\n " + deployStackCommand(withBucket, appTemplate) + "\n");
222
222
  console.log(
223
- " ── 3. Wire these GitHub repo Variables from the stack Outputs ──",
223
+ " ── 2. Wire these GitHub repo Variables from the stack Outputs ──",
224
224
  );
225
225
  console.log(
226
226
  table(
@@ -249,38 +249,32 @@ async function standUp(
249
249
  process.exit(1);
250
250
  }
251
251
 
252
- // 1. Resolve the artifacts bucket; bootstrap the tier-0 stack if it is absent.
253
- let artifactsBucket = stackOutput(
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(
254
260
  bootstrapStackName(cfg),
255
261
  BOOTSTRAP_ARTIFACTS_OUTPUT,
256
262
  cfg.region,
257
263
  );
258
- let templatesBucket = stackOutput(
264
+ const templatesBucket = stackOutput(
259
265
  bootstrapStackName(cfg),
260
266
  BOOTSTRAP_TEMPLATES_OUTPUT,
261
267
  cfg.region,
262
268
  );
263
269
  if (!artifactsBucket) {
264
- console.log(
265
- `\n▸ Bootstrap stack ${bootstrapStackName(cfg)} not found — creating it (once per account+region).`,
266
- );
267
- deployBootstrap(cfg);
268
- artifactsBucket = stackOutput(
269
- bootstrapStackName(cfg),
270
- BOOTSTRAP_ARTIFACTS_OUTPUT,
271
- cfg.region,
272
- );
273
- templatesBucket = stackOutput(
274
- bootstrapStackName(cfg),
275
- BOOTSTRAP_TEMPLATES_OUTPUT,
276
- cfg.region,
270
+ 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`,
277
276
  );
278
- if (!artifactsBucket) {
279
- console.error(
280
- " ✗ bootstrap stack created but its ArtifactsBucket output could not be read.",
281
- );
282
- process.exit(1);
283
- }
277
+ process.exit(1);
284
278
  }
285
279
  console.log(` ✓ artifacts bucket: ${artifactsBucket}`);
286
280
 
@@ -381,36 +375,6 @@ function wireRepoVariables(cfg: DeployConfig, interactive: boolean): void {
381
375
  }
382
376
  }
383
377
 
384
- /** Deploy the bootstrap stack, retrying once against an existing OIDC provider. */
385
- function deployBootstrap(cfg: DeployConfig): void {
386
- const template = resolveDeployAsset("bootstrap.yaml");
387
- if (!template) {
388
- console.error(
389
- " ✗ bootstrap.yaml template not found in the rastack package. Reinstall\n" +
390
- " rastack (`npm i -g rastack`) or run from the framework repo.",
391
- );
392
- process.exit(1);
393
- }
394
- try {
395
- awsRun(bootstrapStackArgs(cfg, false, template));
396
- } catch {
397
- // The most common cause is an account that already has a GitHub OIDC
398
- // provider (only one is allowed per URL). Retry telling the template to
399
- // reuse it instead of creating a second.
400
- console.log(
401
- " • retrying bootstrap, reusing the account's existing GitHub OIDC provider",
402
- );
403
- try {
404
- awsRun(bootstrapStackArgs(cfg, true, template));
405
- } catch (err) {
406
- console.error(
407
- `\n ✗ bootstrap stack deploy failed.\n ${(err as Error).message}`,
408
- );
409
- process.exit(1);
410
- }
411
- }
412
- }
413
-
414
378
  function printSummary(cfg: DeployConfig): void {
415
379
  console.log(
416
380
  `\n ✓ ${cfg.app} is deployed (${cfg.stackName} @ ${cfg.region})`,
@@ -502,7 +466,7 @@ async function main(argv: string[]): Promise<void> {
502
466
 
503
467
  if (canPrompt()) {
504
468
  const ok = await confirm(
505
- `Deploy ${cfg.stackName} to AWS ${cfg.region} now? (creates the bootstrap stack if needed) [y/N]`,
469
+ `Deploy ${cfg.stackName} to AWS ${cfg.region} now? (requires the bootstrap stack to exist) [y/N]`,
506
470
  );
507
471
  if (!ok) {
508
472
  console.log(
@@ -17,13 +17,14 @@
17
17
  * global location. Pure decisions live in `update.ts`; this is the IO wrapper.
18
18
  */
19
19
 
20
- import { execFileSync } from "child_process";
20
+ import { execFileSync, execSync } from "child_process";
21
21
  import { readFileSync } from "fs";
22
22
  import * as path from "path";
23
23
  import {
24
24
  globalPrefixFromPackageDir,
25
25
  npmBinary,
26
26
  npmInstallArgs,
27
+ npmShellCommand,
27
28
  npmSpawnShell,
28
29
  parseUpdateFlags,
29
30
  updateAvailable,
@@ -52,15 +53,28 @@ function readVersion(file: string): string {
52
53
  }
53
54
  }
54
55
 
56
+ /**
57
+ * Run npm and return stdout, or throw on failure. Windows must launch the
58
+ * `npm.cmd` shim through a shell; we pass one command string to `execSync`
59
+ * rather than an args array with `shell: true` (Node 22+ DEP0190 — args would be
60
+ * concatenated unescaped). The only non-literal token, the version, is already
61
+ * sanitized (`sanitizeVersion`). POSIX keeps the shell-free `execFile`.
62
+ */
63
+ function runNpm(
64
+ args: string[],
65
+ opts: { stdio: "inherit" | ["ignore", "pipe", "pipe"]; env?: NodeJS.ProcessEnv },
66
+ ): Buffer | null {
67
+ // nosemgrep: javascript.lang.security.detect-child-process.detect-child-process
68
+ return NPM_SHELL
69
+ ? execSync(npmShellCommand(NPM, args), opts)
70
+ : execFileSync(NPM, args, opts);
71
+ }
72
+
55
73
  /** Capture `npm <args>` stdout, or null when npm is absent / the call fails. */
56
74
  function npmCapture(args: string[]): string | null {
57
75
  try {
58
- return execFileSync(NPM, args, {
59
- stdio: ["ignore", "pipe", "pipe"],
60
- shell: NPM_SHELL,
61
- })
62
- .toString()
63
- .trim();
76
+ const out = runNpm(args, { stdio: ["ignore", "pipe", "pipe"] });
77
+ return (out ?? "").toString().trim();
64
78
  } catch {
65
79
  return null;
66
80
  }
@@ -116,8 +130,7 @@ function main(argv: string[]): void {
116
130
  // the `.cmd` shim is launched through a shell (EINVAL otherwise); the only
117
131
  // caller-supplied token — the version — is sanitized in `parseUpdateFlags`,
118
132
  // and every other arg is a fixed literal, so nothing injectable reaches it.
119
- // nosemgrep: javascript.lang.security.detect-child-process.detect-child-process
120
- execFileSync(NPM, args, { stdio: "inherit", env, shell: NPM_SHELL });
133
+ runNpm(args, { stdio: "inherit", env });
121
134
  } catch (err) {
122
135
  console.error(
123
136
  `\n ✗ npm failed to update rastack. Re-run for detail:\n npm install -g ${npmInstallArgs(flags.version)[2]}\n ${(err as Error).message}`,