rastack 0.0.35 → 0.0.36

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.
@@ -1,34 +1,60 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  /**
4
- * `rastack init` — template the base GitHub Actions + deploy config into a repo.
4
+ * `rastack init` — scaffold a repo's deploy config + GitHub Actions AND stand the
5
+ * serverless backend up.
5
6
  *
6
7
  * rastack init [--app <slug>] [--owner <o>] [--repo <r>] [--branch <b>]
7
- * [--region <r>] [--force] [--no-workflows] [--yes]
8
+ * [--region <r>] [--prefix <p>] [--namespace <n>]
9
+ * [--force] [--no-workflows] [--no-deploy] [--print] [--yes]
8
10
  *
9
- * Scaffolds a new project (or reconfigures an existing one) so it is ready to
10
- * deploy: `.rastack/deploy.json` plus `.github/workflows/{ci,deploy-backend}.yml`,
11
- * both of which run `rastack ci`. GitHub coordinates default from the repo's
12
- * `origin` remote; missing fields are prompted for on a TTY. Existing files are
13
- * left untouched unless `--force`.
11
+ * This is the one command that takes you from nothing to deployed. It:
12
+ * 1. writes `.rastack/deploy.json` + `.github/workflows/{ci,deploy-backend}.yml`
13
+ * (GitHub coordinates default from the `origin` remote; missing fields are
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;
17
+ * 3. uploads the Lambda placeholder bundle to that bucket;
18
+ * 4. deploys the per-app backend (app.yaml) with the resolved ArtifactsBucket;
19
+ * 5. reads the stack Outputs back into the config and prints the two GitHub repo
20
+ * Variables CI needs.
21
+ *
22
+ * Thereafter the recurring code deploy is CI's job — `rastack ci deploy`, which
23
+ * `rastack deploy` also runs. `--no-deploy` scaffolds only; `--print` shows the
24
+ * exact single-line `aws cloudformation deploy` commands without running them.
14
25
  */
15
26
 
16
27
  import * as fs from "fs";
17
28
  import * as path from "path";
18
29
  import {
30
+ APP_STACK_OUTPUTS,
31
+ BOOTSTRAP_ARTIFACTS_OUTPUT,
32
+ BOOTSTRAP_TEMPLATES_OUTPUT,
19
33
  DeployConfig,
34
+ DeployOutputs,
20
35
  PartialDeployConfig,
36
+ bootstrapStackArgs,
37
+ bootstrapStackCommand,
38
+ bootstrapStackName,
39
+ deployStackArgs,
40
+ deployStackCommand,
21
41
  mergeConfig,
22
42
  normalizeConfig,
23
43
  parseGitHubRemote,
44
+ repoVariables,
24
45
  validateConfig,
25
46
  } from "./deploy/config";
26
47
  import { scaffoldFiles } from "./deploy/workflows";
27
48
  import {
28
49
  ask,
50
+ awsAvailable,
51
+ awsRun,
29
52
  canPrompt,
30
53
  gitRemoteUrl,
31
54
  loadConfig,
55
+ saveConfig,
56
+ stackOutput,
57
+ uploadPlaceholder,
32
58
  withPrompt,
33
59
  } from "./deploy/io";
34
60
 
@@ -36,6 +62,8 @@ interface Flags {
36
62
  patch: PartialDeployConfig;
37
63
  force: boolean;
38
64
  noWorkflows: boolean;
65
+ noDeploy: boolean;
66
+ print: boolean;
39
67
  nonInteractive: boolean;
40
68
  }
41
69
 
@@ -43,6 +71,8 @@ function parseFlags(argv: string[]): Flags {
43
71
  const patch: PartialDeployConfig = { github: {} };
44
72
  let force = false;
45
73
  let noWorkflows = false;
74
+ let noDeploy = false;
75
+ let print = false;
46
76
  let nonInteractive = false;
47
77
  for (let i = 0; i < argv.length; i++) {
48
78
  const a = argv[i];
@@ -66,12 +96,22 @@ function parseFlags(argv: string[]): Flags {
66
96
  case "--prefix":
67
97
  patch.namePrefix = next();
68
98
  break;
99
+ case "--namespace":
100
+ patch.namespace = next();
101
+ break;
69
102
  case "--force":
70
103
  force = true;
71
104
  break;
72
105
  case "--no-workflows":
73
106
  noWorkflows = true;
74
107
  break;
108
+ case "--no-deploy":
109
+ noDeploy = true;
110
+ break;
111
+ case "--print":
112
+ case "--dry-run":
113
+ print = true;
114
+ break;
75
115
  case "--yes":
76
116
  case "-y":
77
117
  case "--non-interactive":
@@ -84,7 +124,7 @@ function parseFlags(argv: string[]): Flags {
84
124
  }
85
125
  }
86
126
  }
87
- return { patch, force, noWorkflows, nonInteractive };
127
+ return { patch, force, noWorkflows, noDeploy, print, nonInteractive };
88
128
  }
89
129
 
90
130
  /** Seed a config: existing file → flags → git remote → defaults. */
@@ -128,31 +168,220 @@ async function promptMissing(cfg: DeployConfig): Promise<DeployConfig> {
128
168
  });
129
169
  }
130
170
 
171
+ /** Yes/no confirmation with a default of "no" — gates the cloud-mutating steps. */
172
+ async function confirm(question: string): Promise<boolean> {
173
+ return withPrompt(async (rl) => {
174
+ const answer = await ask(rl, question, "no");
175
+ return /^y(es)?$/i.test(answer.trim());
176
+ });
177
+ }
178
+
179
+ /** Pad `Key Value` rows into an aligned block. */
180
+ function table(rows: { key: string; value: string }[]): string {
181
+ const width = Math.max(0, ...rows.map((r) => r.key.length));
182
+ return rows.map((r) => ` ${r.key.padEnd(width)} ${r.value}`).join("\n");
183
+ }
184
+
185
+ /**
186
+ * Read the app.yaml stack Outputs into a `DeployOutputs`, skipping any that are
187
+ * not resolvable yet (best-effort — a freshly created stack has them all).
188
+ */
189
+ function readAppOutputs(cfg: DeployConfig): DeployOutputs {
190
+ const outputs: DeployOutputs = {};
191
+ for (const { key, field } of APP_STACK_OUTPUTS) {
192
+ const value = stackOutput(cfg.stackName, key, cfg.region);
193
+ if (value) outputs[field] = value;
194
+ }
195
+ return outputs;
196
+ }
197
+
198
+ /**
199
+ * Print the exact commands `init` would run, without touching the cloud
200
+ * (`--print`). The artifacts bucket is read from the bootstrap stack when the
201
+ * AWS CLI is available, else shown as a placeholder.
202
+ */
203
+ function printPlan(cfg: DeployConfig): void {
204
+ const bucket = awsAvailable()
205
+ ? stackOutput(
206
+ bootstrapStackName(cfg),
207
+ BOOTSTRAP_ARTIFACTS_OUTPUT,
208
+ cfg.region,
209
+ )
210
+ : null;
211
+ const withBucket = mergeConfig(cfg, {
212
+ artifactsBucket: bucket ?? "<ArtifactsBucketName from bootstrap stack>",
213
+ });
214
+ console.log(`\n Deployment plan (${cfg.stackName} @ ${cfg.region})\n`);
215
+ console.log(
216
+ ` ── 1. Bootstrap stack (${bootstrapStackName(cfg)}) — run only if it does not exist ──`,
217
+ );
218
+ console.log("\n " + bootstrapStackCommand(cfg) + "\n");
219
+ console.log(" ── 2. Per-app backend (app.yaml) ──");
220
+ console.log("\n " + deployStackCommand(withBucket) + "\n");
221
+ console.log(
222
+ " ── 3. Wire these GitHub repo Variables from the stack Outputs ──",
223
+ );
224
+ console.log(
225
+ table(
226
+ repoVariables(cfg).map((v) => ({
227
+ key: v.name,
228
+ value: v.value || `<${v.from}>`,
229
+ })),
230
+ ) + "\n",
231
+ );
232
+ }
233
+
234
+ /**
235
+ * Stand the infrastructure up: ensure the bootstrap stack + artifacts bucket,
236
+ * upload the Lambda placeholder, deploy app.yaml, and read the Outputs back into
237
+ * the config. Returns the config enriched with the resolved buckets + outputs.
238
+ */
239
+ async function standUp(cfg: DeployConfig): Promise<DeployConfig> {
240
+ if (!awsAvailable()) {
241
+ console.error(
242
+ " ✗ the AWS CLI is not on PATH. Install it and configure credentials,\n" +
243
+ " or re-run with --print to see the commands, or --no-deploy to skip.",
244
+ );
245
+ process.exit(1);
246
+ }
247
+
248
+ // 1. Resolve the artifacts bucket; bootstrap the tier-0 stack if it is absent.
249
+ let artifactsBucket = stackOutput(
250
+ bootstrapStackName(cfg),
251
+ BOOTSTRAP_ARTIFACTS_OUTPUT,
252
+ cfg.region,
253
+ );
254
+ let templatesBucket = stackOutput(
255
+ bootstrapStackName(cfg),
256
+ BOOTSTRAP_TEMPLATES_OUTPUT,
257
+ cfg.region,
258
+ );
259
+ if (!artifactsBucket) {
260
+ console.log(
261
+ `\n▸ Bootstrap stack ${bootstrapStackName(cfg)} not found — creating it (once per account+region).`,
262
+ );
263
+ deployBootstrap(cfg);
264
+ artifactsBucket = stackOutput(
265
+ bootstrapStackName(cfg),
266
+ BOOTSTRAP_ARTIFACTS_OUTPUT,
267
+ cfg.region,
268
+ );
269
+ templatesBucket = stackOutput(
270
+ bootstrapStackName(cfg),
271
+ BOOTSTRAP_TEMPLATES_OUTPUT,
272
+ cfg.region,
273
+ );
274
+ if (!artifactsBucket) {
275
+ console.error(
276
+ " ✗ bootstrap stack created but its ArtifactsBucket output could not be read.",
277
+ );
278
+ process.exit(1);
279
+ }
280
+ }
281
+ console.log(` ✓ artifacts bucket: ${artifactsBucket}`);
282
+
283
+ // 2. Upload the placeholder the function is created from.
284
+ console.log("\n▸ Uploading the Lambda placeholder bundle");
285
+ if (!uploadPlaceholder(artifactsBucket, cfg.region)) {
286
+ console.error(
287
+ " ✗ deploy/cloudformation/placeholder/bootstrap not found — cannot create the function.",
288
+ );
289
+ process.exit(1);
290
+ }
291
+
292
+ // 3. Deploy the per-app backend with the resolved bucket wired in.
293
+ cfg = mergeConfig(cfg, {
294
+ artifactsBucket,
295
+ templatesBucket: templatesBucket ?? undefined,
296
+ });
297
+ console.log(`\n▸ Deploying ${cfg.stackName}`);
298
+ try {
299
+ awsRun(deployStackArgs(cfg));
300
+ } catch (err) {
301
+ console.error(
302
+ `\n ✗ aws cloudformation deploy failed for ${cfg.stackName}.\n ${(err as Error).message}`,
303
+ );
304
+ process.exit(1);
305
+ }
306
+
307
+ // 4. Read the Outputs back into the config so CI has them without a live lookup.
308
+ const outputs = readAppOutputs(cfg);
309
+ cfg = mergeConfig(cfg, { outputs });
310
+ saveConfig(cfg);
311
+ return cfg;
312
+ }
313
+
314
+ /** Deploy the bootstrap stack, retrying once against an existing OIDC provider. */
315
+ function deployBootstrap(cfg: DeployConfig): void {
316
+ try {
317
+ awsRun(bootstrapStackArgs(cfg));
318
+ } catch {
319
+ // The most common cause is an account that already has a GitHub OIDC
320
+ // provider (only one is allowed per URL). Retry telling the template to
321
+ // reuse it instead of creating a second.
322
+ console.log(
323
+ " • retrying bootstrap, reusing the account's existing GitHub OIDC provider",
324
+ );
325
+ try {
326
+ awsRun(bootstrapStackArgs(cfg, true));
327
+ } catch (err) {
328
+ console.error(
329
+ `\n ✗ bootstrap stack deploy failed.\n ${(err as Error).message}`,
330
+ );
331
+ process.exit(1);
332
+ }
333
+ }
334
+ }
335
+
336
+ function printSummary(cfg: DeployConfig): void {
337
+ console.log(
338
+ `\n ✓ ${cfg.app} is deployed (${cfg.stackName} @ ${cfg.region})`,
339
+ );
340
+ if (cfg.outputs?.apiUrl) console.log(` API: ${cfg.outputs.apiUrl}`);
341
+ console.log(
342
+ "\n Wire these GitHub repo Variables so CI can ship code (Settings → Variables):",
343
+ );
344
+ console.log(
345
+ table(
346
+ repoVariables(cfg).map((v) => ({
347
+ key: v.name,
348
+ value: v.value || `<${v.from}>`,
349
+ })),
350
+ ),
351
+ );
352
+ console.log(
353
+ `\n Then every push to ${cfg.github.branch} ships code via \`rastack ci deploy\`` +
354
+ ` (also runnable locally as \`rastack deploy\`).\n`,
355
+ );
356
+ }
357
+
131
358
  async function main(argv: string[]): Promise<void> {
132
359
  const flags = parseFlags(argv);
133
360
  let cfg = seedConfig(flags);
134
361
 
135
362
  if (canPrompt(flags.nonInteractive)) {
136
363
  console.log(
137
- "\n rastack init — scaffold the deploy config + GitHub Actions\n",
364
+ "\n rastack init — scaffold the deploy config + stand the backend up\n",
138
365
  );
139
366
  cfg = await promptMissing(cfg);
140
367
  }
141
368
 
142
369
  const errors = validateConfig(cfg);
143
- // In non-interactive mode a still-incomplete config is fatal; interactively we
144
- // still write what we have so the user can finish it in the file, but warn.
370
+ // Any deploy needs a complete config; a still-incomplete one is fatal unless we
371
+ // are only scaffolding files.
145
372
  if (errors.length) {
146
- const label = flags.nonInteractive ? "✗" : "⚠";
373
+ const fatal = !flags.noDeploy || flags.nonInteractive;
374
+ const label = fatal ? "✗" : "⚠";
147
375
  for (const e of errors) console.error(` ${label} ${e}`);
148
- if (flags.nonInteractive) {
376
+ if (fatal) {
149
377
  console.error(
150
- `\n ${errors.length} problem(s). Pass the missing values as flags.`,
378
+ `\n ${errors.length} problem(s). Pass the missing values as flags, or --no-deploy to scaffold only.`,
151
379
  );
152
380
  process.exit(1);
153
381
  }
154
382
  }
155
383
 
384
+ // 1. Scaffold the config + workflows.
156
385
  const files = scaffoldFiles(cfg, { noWorkflows: flags.noWorkflows });
157
386
  const written: string[] = [];
158
387
  const skipped: string[] = [];
@@ -166,17 +395,38 @@ async function main(argv: string[]): Promise<void> {
166
395
  fs.writeFileSync(abs, f.content);
167
396
  written.push(f.path);
168
397
  }
169
-
170
398
  console.log("");
171
399
  for (const w of written) console.log(` ✓ wrote ${w}`);
172
400
  for (const s of skipped)
173
401
  console.log(` • skipped ${s} (exists — pass --force to overwrite)`);
174
- console.log(
175
- `\n Next:\n` +
176
- ` 1. rastack deploy — review the config and print the stack launch command\n` +
177
- ` 2. create the CloudFormation stack (once), then wire the repo Variables it prints\n` +
178
- ` 3. push to ${cfg.github.branch} — CI runs \`rastack ci deploy\`\n`,
179
- );
402
+
403
+ // 2. Deploy (unless opted out).
404
+ if (flags.print) {
405
+ printPlan(cfg);
406
+ return;
407
+ }
408
+ if (flags.noDeploy) {
409
+ console.log(
410
+ `\n Config scaffolded. Run \`rastack init\` again (without --no-deploy) to deploy,\n` +
411
+ ` or \`rastack init --print\` to see the exact commands.\n`,
412
+ );
413
+ return;
414
+ }
415
+
416
+ if (canPrompt(flags.nonInteractive)) {
417
+ const ok = await confirm(
418
+ `Deploy ${cfg.stackName} to AWS ${cfg.region} now? (creates the bootstrap stack if needed) [y/N]`,
419
+ );
420
+ if (!ok) {
421
+ console.log(
422
+ "\n Skipped the deploy. Re-run to deploy, or `rastack init --print` for the commands.\n",
423
+ );
424
+ return;
425
+ }
426
+ }
427
+
428
+ cfg = await standUp(cfg);
429
+ printSummary(cfg);
180
430
  }
181
431
 
182
432
  if (require.main === module) {
@@ -0,0 +1,124 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * `rastack update` — update the CLI in place.
5
+ *
6
+ * rastack update [<version>] [--version <v>] [--check] [--dry-run]
7
+ *
8
+ * (no args) update to the latest published version
9
+ * <version> pin a target, e.g. `rastack update 0.0.40`
10
+ * --check report current vs. latest, install nothing
11
+ * --dry-run print the npm command that would run, don't run it
12
+ *
13
+ * `rastack` is a global npm package installed into a self-contained per-user
14
+ * prefix (`~/.rastack`, via the hosted installer). Updating re-runs
15
+ * `npm install -g rastack@<target>` against that SAME prefix — derived from where
16
+ * the running CLI lives — so it never scatters a second copy into npm's default
17
+ * global location. Pure decisions live in `update.ts`; this is the IO wrapper.
18
+ */
19
+
20
+ import { execFileSync } from "child_process";
21
+ import { readFileSync } from "fs";
22
+ import * as path from "path";
23
+ import {
24
+ globalPrefixFromPackageDir,
25
+ npmInstallArgs,
26
+ parseUpdateFlags,
27
+ updateAvailable,
28
+ } from "./update";
29
+
30
+ /** The installed rastack package directory (…/rastack), one up from dist/. */
31
+ const PKG_DIR = path.join(__dirname, "..");
32
+ const PKG_JSON = path.join(PKG_DIR, "package.json");
33
+
34
+ /** Read the version from a package.json on disk, or "unknown" if unreadable. */
35
+ function readVersion(file: string): string {
36
+ try {
37
+ return JSON.parse(readFileSync(file, "utf-8")).version ?? "unknown";
38
+ } catch {
39
+ return "unknown";
40
+ }
41
+ }
42
+
43
+ /** Capture `npm <args>` stdout, or null when npm is absent / the call fails. */
44
+ function npmCapture(args: string[]): string | null {
45
+ try {
46
+ return execFileSync("npm", args, {
47
+ stdio: ["ignore", "pipe", "pipe"],
48
+ })
49
+ .toString()
50
+ .trim();
51
+ } catch {
52
+ return null;
53
+ }
54
+ }
55
+
56
+ function main(argv: string[]): void {
57
+ const flags = parseUpdateFlags(argv);
58
+ const current = readVersion(PKG_JSON);
59
+ const prefix = globalPrefixFromPackageDir(PKG_DIR);
60
+
61
+ // The npm prefix must land the update where this CLI actually lives.
62
+ const env = { ...process.env };
63
+ if (prefix) env.npm_config_prefix = prefix;
64
+
65
+ if (flags.check) {
66
+ const latest = npmCapture(["view", "rastack@latest", "version"]);
67
+ if (!latest) {
68
+ console.error(
69
+ " ✗ could not reach the npm registry to check for updates.",
70
+ );
71
+ process.exit(1);
72
+ }
73
+ console.log(` rastack ${current} (installed) — latest is ${latest}`);
74
+ if (updateAvailable(current, latest)) {
75
+ console.log(` ▸ run \`rastack update\` to upgrade to ${latest}.`);
76
+ } else {
77
+ console.log(" ✓ you are on the latest version.");
78
+ }
79
+ return;
80
+ }
81
+
82
+ const args = npmInstallArgs(flags.version);
83
+ if (flags.dryRun) {
84
+ const pfx = prefix ? `npm_config_prefix=${prefix} ` : "";
85
+ console.log(`\n ${pfx}npm ${args.join(" ")}\n`);
86
+ return;
87
+ }
88
+
89
+ if (!prefix) {
90
+ // Running from a source checkout (not a global install) — nothing to self-update.
91
+ console.error(
92
+ " ✗ rastack is not running from a global install, so `update` does not apply.\n" +
93
+ " Reinstall with the hosted installer, or `npm install -g rastack` to manage it.",
94
+ );
95
+ process.exit(1);
96
+ }
97
+
98
+ console.log(
99
+ `\n▸ Updating rastack (${current} → ${flags.version}) in ${prefix}\n`,
100
+ );
101
+ try {
102
+ // Re-runs the developer's own npm to update this global package — no
103
+ // untrusted input, and execFileSync (no shell) avoids any injection.
104
+ // nosemgrep: javascript.lang.security.detect-child-process.detect-child-process
105
+ execFileSync("npm", args, { stdio: "inherit", env });
106
+ } catch (err) {
107
+ console.error(
108
+ `\n ✗ npm failed to update rastack. Re-run for detail:\n npm install -g ${npmInstallArgs(flags.version)[2]}\n ${(err as Error).message}`,
109
+ );
110
+ process.exit(1);
111
+ }
112
+
113
+ // Re-read the (now replaced) package.json to report where we landed.
114
+ const updated = readVersion(PKG_JSON);
115
+ if (updated === current) {
116
+ console.log(`\n ✓ rastack is up to date (${updated}).\n`);
117
+ } else {
118
+ console.log(`\n ✓ rastack updated ${current} → ${updated}.\n`);
119
+ }
120
+ }
121
+
122
+ if (require.main === module) {
123
+ main(process.argv.slice(2));
124
+ }
package/src/rastack.ts CHANGED
@@ -16,9 +16,10 @@
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 init [--app slug] [--force] Template the base GitHub Actions + deploy config
20
- * rastack deploy [--print] [--run] Configure the serverless deployment
19
+ * rastack init [--app slug] [--force] Scaffold config + GitHub Actions AND stand the backend up
20
+ * rastack deploy [--plan] Ship code to the Lambda (alias for `rastack ci deploy`)
21
21
  * rastack ci <check|build|deploy> [--plan] Run the CI pipeline (keeps Actions a one-liner)
22
+ * rastack update [<version>] [--check] Update the CLI in place (self-update)
22
23
  */
23
24
 
24
25
  import { execSync, spawnSync } from "child_process";
@@ -118,17 +119,23 @@ switch (command) {
118
119
  run("seed.js");
119
120
  break;
120
121
  case "init":
121
- // Template the base GitHub Actions + deploy config into a repo.
122
+ // Scaffold config + GitHub Actions and stand the serverless backend up.
122
123
  run("rastack-init.js", rest);
123
124
  break;
124
125
  case "deploy":
125
- // Configure the serverless deployment (writes .rastack/deploy.json).
126
- run("rastack-deploy.js", rest);
126
+ // Ship code to the Lambda — an alias for the CI code deploy. Infrastructure
127
+ // is stood up once by `rastack init`; `deploy` only ever ships code.
128
+ run("rastack-ci.js", ["deploy", ...rest]);
127
129
  break;
128
130
  case "ci":
129
131
  // Run a CI pipeline phase (check | build | deploy).
130
132
  run("rastack-ci.js", rest);
131
133
  break;
134
+ case "update":
135
+ case "upgrade":
136
+ // Self-update the globally-installed CLI in place.
137
+ run("rastack-update.js", rest);
138
+ break;
132
139
  case "generate":
133
140
  case "gen":
134
141
  // Proxy to existing rastack codegen pipeline
@@ -147,9 +154,10 @@ switch (command) {
147
154
  ` rastack wasm Compile the API to WebAssembly (browser local dev)\n` +
148
155
  ` rastack scan [files...] [--fail-on-pii]\n` +
149
156
  ` rastack seed [--env dev|staging] [--source gs://bucket/ | ./myapp-dev/]\n` +
150
- ` rastack init [--app slug] [--owner o] [--repo r] [--force] Template GitHub Actions + deploy config\n` +
151
- ` rastack deploy [--app slug] [--print] [--run] Configure the serverless deployment\n` +
152
- ` rastack ci <check|build|deploy> [--plan] Run the CI pipeline (cleaner Actions)\n`,
157
+ ` rastack init [--app slug] [--owner o] [--repo r] [--print] [--no-deploy] Scaffold + stand the backend up\n` +
158
+ ` rastack deploy [--plan] Ship code to the Lambda (alias for ci deploy)\n` +
159
+ ` rastack ci <check|build|deploy> [--plan] Run the CI pipeline (cleaner Actions)\n` +
160
+ ` rastack update [<version>] [--check] [--dry-run] Update the CLI in place (self-update)\n`,
153
161
  );
154
162
  process.exit(1);
155
163
  }
package/src/update.ts ADDED
@@ -0,0 +1,114 @@
1
+ /**
2
+ * Pure core of `rastack update` — the CLI's self-updater.
3
+ *
4
+ * `rastack` is installed with `npm install -g rastack` into a self-contained
5
+ * per-user prefix (`~/.rastack`, wired by web/public/setup.{sh,ps1}), so updating
6
+ * is just re-running that global install against the SAME prefix the running CLI
7
+ * lives in — then reporting the version change. Everything decidable without IO
8
+ * lives here (flag parsing, the npm argv, deriving the prefix from the install
9
+ * path, semver comparison) so it is unit-tested without npm or the network;
10
+ * `rastack-update.ts` is the thin wrapper that shells out.
11
+ */
12
+
13
+ export interface UpdateFlags {
14
+ /** `latest`, or a pinned semver like `0.0.40`. */
15
+ version: string;
16
+ /** Only report current vs. latest — do not install. */
17
+ check: boolean;
18
+ /** Print the npm command that would run, without running it. */
19
+ dryRun: boolean;
20
+ }
21
+
22
+ /**
23
+ * Parse `rastack update` args. Accepts `--check`, `--dry-run`/`--print`, and a
24
+ * version either as `--version <v>` or as a bare positional (`rastack update
25
+ * 0.0.40`). Unknown values default to `latest`.
26
+ */
27
+ export function parseUpdateFlags(argv: string[]): UpdateFlags {
28
+ let version = "latest";
29
+ let check = false;
30
+ let dryRun = false;
31
+ for (let i = 0; i < argv.length; i++) {
32
+ const a = argv[i];
33
+ switch (a) {
34
+ case "--check":
35
+ check = true;
36
+ break;
37
+ case "--dry-run":
38
+ case "--print":
39
+ dryRun = true;
40
+ break;
41
+ case "--version":
42
+ case "--to":
43
+ version = argv[++i] ?? "latest";
44
+ break;
45
+ default:
46
+ // A bare, non-flag token is taken as the target version.
47
+ if (!a.startsWith("-")) version = a;
48
+ }
49
+ }
50
+ return { version, check, dryRun };
51
+ }
52
+
53
+ /** The npm package spec to install for a target version. */
54
+ export function installSpec(version: string): string {
55
+ return !version || version === "latest" ? "rastack" : `rastack@${version}`;
56
+ }
57
+
58
+ /** The `npm <args>` (after the `npm` binary) that installs the target globally. */
59
+ export function npmInstallArgs(version: string): string[] {
60
+ return [
61
+ "install",
62
+ "-g",
63
+ installSpec(version),
64
+ "--no-fund",
65
+ "--no-audit",
66
+ "--loglevel=error",
67
+ ];
68
+ }
69
+
70
+ /**
71
+ * Derive the npm global prefix from the installed rastack package directory, so
72
+ * the update lands in the SAME location the running CLI was installed to (not
73
+ * npm's default global prefix, which may differ from the per-user `~/.rastack`).
74
+ *
75
+ * Global installs put the package at `<prefix>/lib/node_modules/rastack` (POSIX)
76
+ * or `<prefix>/node_modules/rastack` (Windows). Returns null when rastack is not
77
+ * under a `node_modules` at all — e.g. running from a source checkout — where
78
+ * self-update does not apply.
79
+ */
80
+ export function globalPrefixFromPackageDir(pkgDir: string): string | null {
81
+ const norm = pkgDir.replace(/\\/g, "/");
82
+ // Length-preserving normalization, so indices map back onto the original.
83
+ const posix = norm.lastIndexOf("/lib/node_modules/");
84
+ if (posix !== -1) return pkgDir.slice(0, posix);
85
+ const win = norm.lastIndexOf("/node_modules/");
86
+ if (win !== -1) return pkgDir.slice(0, win);
87
+ return null;
88
+ }
89
+
90
+ /**
91
+ * Compare two dotted versions numerically (major.minor.patch; any pre-release
92
+ * suffix is ignored). Returns -1 if a < b, 1 if a > b, 0 if equal — enough to
93
+ * tell `--check` whether an update is available.
94
+ */
95
+ export function compareSemver(a: string, b: string): number {
96
+ const parts = (v: string) =>
97
+ v
98
+ .replace(/^v/, "")
99
+ .split("-")[0]
100
+ .split(".")
101
+ .map((n) => parseInt(n, 10) || 0);
102
+ const pa = parts(a);
103
+ const pb = parts(b);
104
+ for (let i = 0; i < 3; i++) {
105
+ const d = (pa[i] ?? 0) - (pb[i] ?? 0);
106
+ if (d !== 0) return d < 0 ? -1 : 1;
107
+ }
108
+ return 0;
109
+ }
110
+
111
+ /** True when `latest` is a strictly newer version than `current`. */
112
+ export function updateAvailable(current: string, latest: string): boolean {
113
+ return compareSemver(current, latest) < 0;
114
+ }