rastack 0.0.39 → 0.0.41
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 +9 -0
- package/dist/deploy/config.d.ts +10 -0
- package/dist/deploy/config.js +21 -0
- package/dist/deploy/io.d.ts +24 -1
- package/dist/deploy/io.js +74 -1
- package/dist/rastack-init.d.ts +1 -1
- package/dist/rastack-init.js +70 -20
- package/dist/rastack-update.js +12 -3
- package/dist/update.d.ts +21 -0
- package/dist/update.js +31 -2
- package/package.json +1 -1
- package/src/deploy/config.ts +25 -0
- package/src/deploy/io.ts +74 -1
- package/src/rastack-init.ts +95 -27
- package/src/rastack-update.ts +14 -3
- package/src/update.ts +31 -2
- package/test/deploy.spec.ts +27 -0
- package/test/update.spec.ts +35 -0
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,15 @@
|
|
|
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.41](https://github.com/theserverkid/reactapistack/compare/v0.0.40...v0.0.41) (2026-07-12)
|
|
6
|
+
|
|
7
|
+
### [0.0.40](https://github.com/theserverkid/reactapistack/compare/v0.0.39...v0.0.40) (2026-07-12)
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
### Bug Fixes
|
|
11
|
+
|
|
12
|
+
* `rastack init` now wires the GitHub→Lambda deploy variables ([fa35b97](https://github.com/theserverkid/reactapistack/commit/fa35b9744c2149ff7a9c663ffecdd2d6ad1edec6))
|
|
13
|
+
|
|
5
14
|
### [0.0.39](https://github.com/theserverkid/reactapistack/compare/v0.0.38...v0.0.39) (2026-07-11)
|
|
6
15
|
|
|
7
16
|
### [0.0.38](https://github.com/theserverkid/reactapistack/compare/v0.0.37...v0.0.38) (2026-07-11)
|
package/dist/deploy/config.d.ts
CHANGED
|
@@ -185,6 +185,16 @@ export interface RepoVariable {
|
|
|
185
185
|
* Console.
|
|
186
186
|
*/
|
|
187
187
|
export declare function repoVariables(cfg: DeployConfig): RepoVariable[];
|
|
188
|
+
/**
|
|
189
|
+
* The argv (after `gh`) that sets one GitHub repo **Variable** on the config's
|
|
190
|
+
* repo. `init` runs this for each resolved `repoVariables` entry so the
|
|
191
|
+
* `deploy-backend.yml` guard (`if: vars.AWS_DEPLOY_ROLE_ARN != ''`) passes and
|
|
192
|
+
* the very first push actually ships — instead of the variables being a manual
|
|
193
|
+
* Console step the user has to remember. Uses `gh` (already authenticated to the
|
|
194
|
+
* user's GitHub) rather than an API token; a pure argv so it is inspectable and
|
|
195
|
+
* shell-free (`execFileSync`).
|
|
196
|
+
*/
|
|
197
|
+
export declare function ghVariableSetArgs(cfg: DeployConfig, name: string, value: string): string[];
|
|
188
198
|
/**
|
|
189
199
|
* The `RASTACK_*` environment the Lambda reads (docs/serverless-deploy.md §1).
|
|
190
200
|
* The IaC sets these on the function; surfaced here for `rastack deploy` to
|
package/dist/deploy/config.js
CHANGED
|
@@ -33,6 +33,7 @@ exports.bootstrapStackParameters = bootstrapStackParameters;
|
|
|
33
33
|
exports.bootstrapStackArgs = bootstrapStackArgs;
|
|
34
34
|
exports.bootstrapStackCommand = bootstrapStackCommand;
|
|
35
35
|
exports.repoVariables = repoVariables;
|
|
36
|
+
exports.ghVariableSetArgs = ghVariableSetArgs;
|
|
36
37
|
exports.lambdaEnv = lambdaEnv;
|
|
37
38
|
exports.DEFAULT_LAMBDA = {
|
|
38
39
|
bin: "bootstrap",
|
|
@@ -345,6 +346,26 @@ function repoVariables(cfg) {
|
|
|
345
346
|
{ name: "AWS_REGION", value: cfg.region, from: "Region stack output" },
|
|
346
347
|
];
|
|
347
348
|
}
|
|
349
|
+
/**
|
|
350
|
+
* The argv (after `gh`) that sets one GitHub repo **Variable** on the config's
|
|
351
|
+
* repo. `init` runs this for each resolved `repoVariables` entry so the
|
|
352
|
+
* `deploy-backend.yml` guard (`if: vars.AWS_DEPLOY_ROLE_ARN != ''`) passes and
|
|
353
|
+
* the very first push actually ships — instead of the variables being a manual
|
|
354
|
+
* Console step the user has to remember. Uses `gh` (already authenticated to the
|
|
355
|
+
* user's GitHub) rather than an API token; a pure argv so it is inspectable and
|
|
356
|
+
* shell-free (`execFileSync`).
|
|
357
|
+
*/
|
|
358
|
+
function ghVariableSetArgs(cfg, name, value) {
|
|
359
|
+
return [
|
|
360
|
+
"variable",
|
|
361
|
+
"set",
|
|
362
|
+
name,
|
|
363
|
+
"--repo",
|
|
364
|
+
`${cfg.github.owner}/${cfg.github.repo}`,
|
|
365
|
+
"--body",
|
|
366
|
+
value,
|
|
367
|
+
];
|
|
368
|
+
}
|
|
348
369
|
/**
|
|
349
370
|
* The `RASTACK_*` environment the Lambda reads (docs/serverless-deploy.md §1).
|
|
350
371
|
* The IaC sets these on the function; surfaced here for `rastack deploy` to
|
package/dist/deploy/io.d.ts
CHANGED
|
@@ -17,7 +17,7 @@ export declare function saveConfig(cfg: DeployConfig, cwd?: string): string;
|
|
|
17
17
|
*/
|
|
18
18
|
export declare function gitRemoteUrl(cwd?: string): string | null;
|
|
19
19
|
/** True when we can prompt the user (a TTY, and not explicitly opted out). */
|
|
20
|
-
export declare function canPrompt(nonInteractive
|
|
20
|
+
export declare function canPrompt(nonInteractive?: boolean): boolean;
|
|
21
21
|
/** Capture `aws <args>` stdout, or null when the CLI is absent / the call fails. */
|
|
22
22
|
export declare function awsCapture(args: string[], cwd?: string): string | null;
|
|
23
23
|
/** Run `aws <args>` streaming output; throws on a non-zero exit. */
|
|
@@ -37,6 +37,29 @@ export declare function stackOutput(stackName: string, key: string, region: stri
|
|
|
37
37
|
* on every `init`. Returns false when the placeholder file is missing.
|
|
38
38
|
*/
|
|
39
39
|
export declare function uploadPlaceholder(artifactsBucket: string, region: string, cwd?: string): boolean;
|
|
40
|
+
/** True when the GitHub CLI is on PATH. */
|
|
41
|
+
export declare function ghAvailable(): boolean;
|
|
42
|
+
/**
|
|
43
|
+
* True when `gh` already holds valid credentials for github.com. `gh auth
|
|
44
|
+
* status` exits non-zero when logged out, so a thrown call means "not connected".
|
|
45
|
+
*/
|
|
46
|
+
export declare function ghAuthenticated(cwd?: string): boolean;
|
|
47
|
+
/**
|
|
48
|
+
* Connect `gh` to GitHub interactively (`gh auth login`) as part of `init`, so
|
|
49
|
+
* the repo Variables can be set right after. Streamed to the terminal — the
|
|
50
|
+
* browser/device-code flow needs the TTY — and left to `gh`'s own prompts so the
|
|
51
|
+
* user picks how to authenticate (browser or a pasted token). Returns true when
|
|
52
|
+
* the login exits 0.
|
|
53
|
+
*/
|
|
54
|
+
export declare function ghAuthLogin(cwd?: string): boolean;
|
|
55
|
+
/**
|
|
56
|
+
* Set one GitHub repo Variable via `gh variable set`. Returns true on success,
|
|
57
|
+
* false when `gh` is missing, unauthenticated, or the call otherwise fails —
|
|
58
|
+
* the caller falls back to printing the value for the user to wire by hand.
|
|
59
|
+
* `execFileSync` (no shell) so the ARN/region values are never interpolated
|
|
60
|
+
* into a command line.
|
|
61
|
+
*/
|
|
62
|
+
export declare function setRepoVariable(args: string[], cwd?: string): boolean;
|
|
40
63
|
/**
|
|
41
64
|
* Ask a single question with a default, echoed as `label [default]: `. Returns
|
|
42
65
|
* the trimmed answer, or the default on an empty line.
|
package/dist/deploy/io.js
CHANGED
|
@@ -48,6 +48,10 @@ exports.awsRun = awsRun;
|
|
|
48
48
|
exports.awsAvailable = awsAvailable;
|
|
49
49
|
exports.stackOutput = stackOutput;
|
|
50
50
|
exports.uploadPlaceholder = uploadPlaceholder;
|
|
51
|
+
exports.ghAvailable = ghAvailable;
|
|
52
|
+
exports.ghAuthenticated = ghAuthenticated;
|
|
53
|
+
exports.ghAuthLogin = ghAuthLogin;
|
|
54
|
+
exports.setRepoVariable = setRepoVariable;
|
|
51
55
|
exports.ask = ask;
|
|
52
56
|
exports.withPrompt = withPrompt;
|
|
53
57
|
const child_process_1 = require("child_process");
|
|
@@ -98,7 +102,7 @@ function gitRemoteUrl(cwd = process.cwd()) {
|
|
|
98
102
|
}
|
|
99
103
|
}
|
|
100
104
|
/** True when we can prompt the user (a TTY, and not explicitly opted out). */
|
|
101
|
-
function canPrompt(nonInteractive) {
|
|
105
|
+
function canPrompt(nonInteractive = false) {
|
|
102
106
|
return !nonInteractive && Boolean(process.stdin.isTTY);
|
|
103
107
|
}
|
|
104
108
|
// ── AWS CLI boundary ─────────────────────────────────────────────────────────
|
|
@@ -175,6 +179,75 @@ function uploadPlaceholder(artifactsBucket, region, cwd = process.cwd()) {
|
|
|
175
179
|
]);
|
|
176
180
|
return true;
|
|
177
181
|
}
|
|
182
|
+
// ── GitHub CLI boundary ──────────────────────────────────────────────────────
|
|
183
|
+
// After the stack is up `init` wires the two repo Variables CI reads
|
|
184
|
+
// (AWS_DEPLOY_ROLE_ARN / AWS_REGION) through the developer's own `gh` — already
|
|
185
|
+
// authenticated to their GitHub — so the pipeline fires on the first push
|
|
186
|
+
// instead of leaving the wiring as a manual Console step. Best-effort: absent or
|
|
187
|
+
// unauthenticated `gh` degrades to the printed instructions.
|
|
188
|
+
/** True when the GitHub CLI is on PATH. */
|
|
189
|
+
function ghAvailable() {
|
|
190
|
+
try {
|
|
191
|
+
(0, child_process_1.execFileSync)("gh", ["--version"], { stdio: ["ignore", "pipe", "pipe"] });
|
|
192
|
+
return true;
|
|
193
|
+
}
|
|
194
|
+
catch {
|
|
195
|
+
return false;
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
/**
|
|
199
|
+
* True when `gh` already holds valid credentials for github.com. `gh auth
|
|
200
|
+
* status` exits non-zero when logged out, so a thrown call means "not connected".
|
|
201
|
+
*/
|
|
202
|
+
function ghAuthenticated(cwd = process.cwd()) {
|
|
203
|
+
try {
|
|
204
|
+
(0, child_process_1.execFileSync)("gh", ["auth", "status", "--hostname", "github.com"], {
|
|
205
|
+
cwd,
|
|
206
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
207
|
+
});
|
|
208
|
+
return true;
|
|
209
|
+
}
|
|
210
|
+
catch {
|
|
211
|
+
return false;
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
/**
|
|
215
|
+
* Connect `gh` to GitHub interactively (`gh auth login`) as part of `init`, so
|
|
216
|
+
* the repo Variables can be set right after. Streamed to the terminal — the
|
|
217
|
+
* browser/device-code flow needs the TTY — and left to `gh`'s own prompts so the
|
|
218
|
+
* user picks how to authenticate (browser or a pasted token). Returns true when
|
|
219
|
+
* the login exits 0.
|
|
220
|
+
*/
|
|
221
|
+
function ghAuthLogin(cwd = process.cwd()) {
|
|
222
|
+
try {
|
|
223
|
+
// nosemgrep: javascript.lang.security.detect-child-process.detect-child-process
|
|
224
|
+
(0, child_process_1.execFileSync)("gh", ["auth", "login", "--hostname", "github.com"], {
|
|
225
|
+
cwd,
|
|
226
|
+
stdio: "inherit",
|
|
227
|
+
});
|
|
228
|
+
return true;
|
|
229
|
+
}
|
|
230
|
+
catch {
|
|
231
|
+
return false;
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
/**
|
|
235
|
+
* Set one GitHub repo Variable via `gh variable set`. Returns true on success,
|
|
236
|
+
* false when `gh` is missing, unauthenticated, or the call otherwise fails —
|
|
237
|
+
* the caller falls back to printing the value for the user to wire by hand.
|
|
238
|
+
* `execFileSync` (no shell) so the ARN/region values are never interpolated
|
|
239
|
+
* into a command line.
|
|
240
|
+
*/
|
|
241
|
+
function setRepoVariable(args, cwd = process.cwd()) {
|
|
242
|
+
try {
|
|
243
|
+
// nosemgrep: javascript.lang.security.detect-child-process.detect-child-process
|
|
244
|
+
(0, child_process_1.execFileSync)("gh", args, { cwd, stdio: ["ignore", "pipe", "pipe"] });
|
|
245
|
+
return true;
|
|
246
|
+
}
|
|
247
|
+
catch {
|
|
248
|
+
return false;
|
|
249
|
+
}
|
|
250
|
+
}
|
|
178
251
|
/**
|
|
179
252
|
* Ask a single question with a default, echoed as `label [default]: `. Returns
|
|
180
253
|
* the trimmed answer, or the default on an empty line.
|
package/dist/rastack-init.d.ts
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
*
|
|
6
6
|
* rastack init [--app <slug>] [--owner <o>] [--repo <r>] [--branch <b>]
|
|
7
7
|
* [--region <r>] [--prefix <p>] [--namespace <n>]
|
|
8
|
-
* [--force] [--no-workflows] [--no-deploy] [--print]
|
|
8
|
+
* [--force] [--no-workflows] [--no-deploy] [--print]
|
|
9
9
|
*
|
|
10
10
|
* This is the one command that takes you from nothing to deployed. It:
|
|
11
11
|
* 1. writes `.rastack/deploy.json` + `.github/workflows/{ci,deploy-backend}.yml`
|
package/dist/rastack-init.js
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
*
|
|
7
7
|
* rastack init [--app <slug>] [--owner <o>] [--repo <r>] [--branch <b>]
|
|
8
8
|
* [--region <r>] [--prefix <p>] [--namespace <n>]
|
|
9
|
-
* [--force] [--no-workflows] [--no-deploy] [--print]
|
|
9
|
+
* [--force] [--no-workflows] [--no-deploy] [--print]
|
|
10
10
|
*
|
|
11
11
|
* This is the one command that takes you from nothing to deployed. It:
|
|
12
12
|
* 1. writes `.rastack/deploy.json` + `.github/workflows/{ci,deploy-backend}.yml`
|
|
@@ -68,7 +68,6 @@ function parseFlags(argv) {
|
|
|
68
68
|
let noWorkflows = false;
|
|
69
69
|
let noDeploy = false;
|
|
70
70
|
let print = false;
|
|
71
|
-
let nonInteractive = false;
|
|
72
71
|
for (let i = 0; i < argv.length; i++) {
|
|
73
72
|
const a = argv[i];
|
|
74
73
|
const next = () => argv[++i] ?? "";
|
|
@@ -107,11 +106,6 @@ function parseFlags(argv) {
|
|
|
107
106
|
case "--dry-run":
|
|
108
107
|
print = true;
|
|
109
108
|
break;
|
|
110
|
-
case "--yes":
|
|
111
|
-
case "-y":
|
|
112
|
-
case "--non-interactive":
|
|
113
|
-
nonInteractive = true;
|
|
114
|
-
break;
|
|
115
109
|
default:
|
|
116
110
|
if (a.startsWith("-")) {
|
|
117
111
|
console.error(`Unknown flag: ${a}`);
|
|
@@ -119,7 +113,7 @@ function parseFlags(argv) {
|
|
|
119
113
|
}
|
|
120
114
|
}
|
|
121
115
|
}
|
|
122
|
-
return { patch, force, noWorkflows, noDeploy, print
|
|
116
|
+
return { patch, force, noWorkflows, noDeploy, print };
|
|
123
117
|
}
|
|
124
118
|
/** Seed a config: existing file → flags → git remote → defaults. */
|
|
125
119
|
function seedConfig(flags) {
|
|
@@ -209,7 +203,7 @@ function printPlan(cfg) {
|
|
|
209
203
|
* upload the Lambda placeholder, deploy app.yaml, and read the Outputs back into
|
|
210
204
|
* the config. Returns the config enriched with the resolved buckets + outputs.
|
|
211
205
|
*/
|
|
212
|
-
async function standUp(cfg) {
|
|
206
|
+
async function standUp(cfg, interactive) {
|
|
213
207
|
if (!(0, io_1.awsAvailable)()) {
|
|
214
208
|
console.error(" ✗ the AWS CLI is not on PATH. Install it and configure credentials,\n" +
|
|
215
209
|
" or re-run with --print to see the commands, or --no-deploy to skip.");
|
|
@@ -252,8 +246,57 @@ async function standUp(cfg) {
|
|
|
252
246
|
const outputs = readAppOutputs(cfg);
|
|
253
247
|
cfg = (0, config_1.mergeConfig)(cfg, { outputs });
|
|
254
248
|
(0, io_1.saveConfig)(cfg);
|
|
249
|
+
// 5. Connect the GitHub CLI and wire the repo Variables CI reads, so the FIRST
|
|
250
|
+
// push actually ships. Without this the deploy-backend workflow's `if:
|
|
251
|
+
// vars.AWS_DEPLOY_ROLE_ARN != ''` guard silently skips every run.
|
|
252
|
+
wireRepoVariables(cfg, interactive);
|
|
255
253
|
return cfg;
|
|
256
254
|
}
|
|
255
|
+
/**
|
|
256
|
+
* Set the `AWS_DEPLOY_ROLE_ARN` / `AWS_REGION` repo Variables via `gh` so the
|
|
257
|
+
* GitHub → Lambda pipeline is live the moment `init` finishes. Connects `gh` to
|
|
258
|
+
* GitHub first — running `gh auth login` when it is installed but not yet
|
|
259
|
+
* authenticated (on a TTY) — so a fresh machine goes from nothing to a working
|
|
260
|
+
* pipeline in one command. Best-effort throughout: no `gh`, a declined login, or
|
|
261
|
+
* a value the stack Outputs did not yield falls back to the printed instructions
|
|
262
|
+
* in `printSummary`. Records the names it wired so the summary reports what is
|
|
263
|
+
* (and isn't) done.
|
|
264
|
+
*/
|
|
265
|
+
const wiredVariables = new Set();
|
|
266
|
+
function wireRepoVariables(cfg, interactive) {
|
|
267
|
+
const vars = (0, config_1.repoVariables)(cfg).filter((v) => v.value);
|
|
268
|
+
if (!vars.length)
|
|
269
|
+
return;
|
|
270
|
+
if (!(0, io_1.ghAvailable)()) {
|
|
271
|
+
console.log("\n▸ GitHub CLI (`gh`) not found — skipping repo Variable wiring.\n" +
|
|
272
|
+
" Install `gh` (https://cli.github.com) and re-run, or wire the\n" +
|
|
273
|
+
" Variables below by hand so pushes deploy.");
|
|
274
|
+
return;
|
|
275
|
+
}
|
|
276
|
+
// Connect `gh` to GitHub if it isn't already, so it can set the Variables.
|
|
277
|
+
if (!(0, io_1.ghAuthenticated)()) {
|
|
278
|
+
if (interactive) {
|
|
279
|
+
console.log("\n▸ Connecting the GitHub CLI to your account (`gh auth login`)");
|
|
280
|
+
(0, io_1.ghAuthLogin)();
|
|
281
|
+
}
|
|
282
|
+
if (!(0, io_1.ghAuthenticated)()) {
|
|
283
|
+
console.log("\n▸ GitHub CLI is not connected — skipping repo Variable wiring.\n" +
|
|
284
|
+
" Run `gh auth login`, then wire the Variables below by hand.");
|
|
285
|
+
return;
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
console.log("\n▸ Wiring the GitHub repo Variables CI reads (via `gh`)");
|
|
289
|
+
for (const v of vars) {
|
|
290
|
+
const ok = (0, io_1.setRepoVariable)((0, config_1.ghVariableSetArgs)(cfg, v.name, v.value));
|
|
291
|
+
if (ok) {
|
|
292
|
+
wiredVariables.add(v.name);
|
|
293
|
+
console.log(` ✓ set ${v.name}`);
|
|
294
|
+
}
|
|
295
|
+
else {
|
|
296
|
+
console.log(` • could not set ${v.name} — wire it by hand (below)`);
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
}
|
|
257
300
|
/** Deploy the bootstrap stack, retrying once against an existing OIDC provider. */
|
|
258
301
|
function deployBootstrap(cfg) {
|
|
259
302
|
try {
|
|
@@ -277,26 +320,33 @@ function printSummary(cfg) {
|
|
|
277
320
|
console.log(`\n ✓ ${cfg.app} is deployed (${cfg.stackName} @ ${cfg.region})`);
|
|
278
321
|
if (cfg.outputs?.apiUrl)
|
|
279
322
|
console.log(` API: ${cfg.outputs.apiUrl}`);
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
323
|
+
// Only the Variables `init` could NOT wire itself remain a manual step.
|
|
324
|
+
const pending = (0, config_1.repoVariables)(cfg).filter((v) => !wiredVariables.has(v.name));
|
|
325
|
+
if (pending.length) {
|
|
326
|
+
console.log("\n Wire these GitHub repo Variables so CI can ship code (Settings → Variables):");
|
|
327
|
+
console.log(table(pending.map((v) => ({
|
|
328
|
+
key: v.name,
|
|
329
|
+
value: v.value || `<${v.from}>`,
|
|
330
|
+
}))));
|
|
331
|
+
}
|
|
332
|
+
else {
|
|
333
|
+
console.log("\n ✓ wired the GitHub repo Variables CI reads (AWS_DEPLOY_ROLE_ARN, AWS_REGION).");
|
|
334
|
+
}
|
|
335
|
+
console.log(`\n Every push to ${cfg.github.branch} now ships code via \`rastack ci deploy\`` +
|
|
286
336
|
` (also runnable locally as \`rastack deploy\`).\n`);
|
|
287
337
|
}
|
|
288
338
|
async function main(argv) {
|
|
289
339
|
const flags = parseFlags(argv);
|
|
290
340
|
let cfg = seedConfig(flags);
|
|
291
|
-
if ((0, io_1.canPrompt)(
|
|
341
|
+
if ((0, io_1.canPrompt)()) {
|
|
292
342
|
console.log("\n rastack init — scaffold the deploy config + stand the backend up\n");
|
|
293
343
|
cfg = await promptMissing(cfg);
|
|
294
344
|
}
|
|
295
345
|
const errors = (0, config_1.validateConfig)(cfg);
|
|
296
346
|
// Any deploy needs a complete config; a still-incomplete one is fatal unless we
|
|
297
|
-
// are only scaffolding files.
|
|
347
|
+
// are only scaffolding files, and always fatal off a TTY (no prompt to fix it).
|
|
298
348
|
if (errors.length) {
|
|
299
|
-
const fatal = !flags.noDeploy ||
|
|
349
|
+
const fatal = !flags.noDeploy || !(0, io_1.canPrompt)();
|
|
300
350
|
const label = fatal ? "✗" : "⚠";
|
|
301
351
|
for (const e of errors)
|
|
302
352
|
console.error(` ${label} ${e}`);
|
|
@@ -334,14 +384,14 @@ async function main(argv) {
|
|
|
334
384
|
` or \`rastack init --print\` to see the exact commands.\n`);
|
|
335
385
|
return;
|
|
336
386
|
}
|
|
337
|
-
if ((0, io_1.canPrompt)(
|
|
387
|
+
if ((0, io_1.canPrompt)()) {
|
|
338
388
|
const ok = await confirm(`Deploy ${cfg.stackName} to AWS ${cfg.region} now? (creates the bootstrap stack if needed) [y/N]`);
|
|
339
389
|
if (!ok) {
|
|
340
390
|
console.log("\n Skipped the deploy. Re-run to deploy, or `rastack init --print` for the commands.\n");
|
|
341
391
|
return;
|
|
342
392
|
}
|
|
343
393
|
}
|
|
344
|
-
cfg = await standUp(cfg);
|
|
394
|
+
cfg = await standUp(cfg, (0, io_1.canPrompt)());
|
|
345
395
|
printSummary(cfg);
|
|
346
396
|
}
|
|
347
397
|
if (require.main === module) {
|
package/dist/rastack-update.js
CHANGED
|
@@ -56,6 +56,12 @@ const path = __importStar(require("path"));
|
|
|
56
56
|
const update_1 = require("./update");
|
|
57
57
|
/** The npm executable for this platform (`npm.cmd` on Windows, else `npm`). */
|
|
58
58
|
const NPM = (0, update_1.npmBinary)();
|
|
59
|
+
/**
|
|
60
|
+
* Whether to launch npm through a shell. Required on Windows, where a bare
|
|
61
|
+
* `.cmd` spawn is rejected with `EINVAL` since the Node CVE-2024-27980 fix;
|
|
62
|
+
* false on POSIX so the direct spawn stays injection-free.
|
|
63
|
+
*/
|
|
64
|
+
const NPM_SHELL = (0, update_1.npmSpawnShell)();
|
|
59
65
|
/** The installed rastack package directory (…/rastack), one up from dist/. */
|
|
60
66
|
const PKG_DIR = path.join(__dirname, "..");
|
|
61
67
|
const PKG_JSON = path.join(PKG_DIR, "package.json");
|
|
@@ -73,6 +79,7 @@ function npmCapture(args) {
|
|
|
73
79
|
try {
|
|
74
80
|
return (0, child_process_1.execFileSync)(NPM, args, {
|
|
75
81
|
stdio: ["ignore", "pipe", "pipe"],
|
|
82
|
+
shell: NPM_SHELL,
|
|
76
83
|
})
|
|
77
84
|
.toString()
|
|
78
85
|
.trim();
|
|
@@ -118,10 +125,12 @@ function main(argv) {
|
|
|
118
125
|
}
|
|
119
126
|
console.log(`\n▸ Updating rastack (${current} → ${flags.version}) in ${prefix}\n`);
|
|
120
127
|
try {
|
|
121
|
-
// Re-runs the developer's own npm to update this global package
|
|
122
|
-
//
|
|
128
|
+
// Re-runs the developer's own npm to update this global package. On Windows
|
|
129
|
+
// the `.cmd` shim is launched through a shell (EINVAL otherwise); the only
|
|
130
|
+
// caller-supplied token — the version — is sanitized in `parseUpdateFlags`,
|
|
131
|
+
// and every other arg is a fixed literal, so nothing injectable reaches it.
|
|
123
132
|
// nosemgrep: javascript.lang.security.detect-child-process.detect-child-process
|
|
124
|
-
(0, child_process_1.execFileSync)(NPM, args, { stdio: "inherit", env });
|
|
133
|
+
(0, child_process_1.execFileSync)(NPM, args, { stdio: "inherit", env, shell: NPM_SHELL });
|
|
125
134
|
}
|
|
126
135
|
catch (err) {
|
|
127
136
|
console.error(`\n ✗ npm failed to update rastack. Re-run for detail:\n npm install -g ${(0, update_1.npmInstallArgs)(flags.version)[2]}\n ${err.message}`);
|
package/dist/update.d.ts
CHANGED
|
@@ -23,14 +23,35 @@ export interface UpdateFlags {
|
|
|
23
23
|
* 0.0.40`). Unknown values default to `latest`.
|
|
24
24
|
*/
|
|
25
25
|
export declare function parseUpdateFlags(argv: string[]): UpdateFlags;
|
|
26
|
+
/**
|
|
27
|
+
* Constrain the update target to a safe npm version or dist-tag. The Windows
|
|
28
|
+
* update path runs npm through a shell (see `npmSpawnShell`), so the target —
|
|
29
|
+
* the one caller-supplied token that reaches the command line — must not be
|
|
30
|
+
* able to carry shell metacharacters. A valid npm spec starts alphanumeric and
|
|
31
|
+
* uses only `A-Za-z0-9._-` (e.g. `latest`, `next`, `0.0.40`, `1.2.3-beta.1`);
|
|
32
|
+
* anything else falls back to `latest`.
|
|
33
|
+
*/
|
|
34
|
+
export declare function sanitizeVersion(version: string | undefined): string;
|
|
26
35
|
/**
|
|
27
36
|
* The npm executable to spawn for a platform. On Windows npm is a `.cmd` shim,
|
|
28
37
|
* not a real executable, so `execFile("npm", …)` (no shell) fails with
|
|
29
38
|
* `spawnSync npm ENOENT` — the direct-spawn path only resolves real binaries,
|
|
30
39
|
* never `PATHEXT` scripts. Spawn `npm.cmd` there; POSIX keeps plain `npm`.
|
|
31
40
|
* (`aws` is unaffected — it installs as `aws.exe`, a real executable.)
|
|
41
|
+
*
|
|
42
|
+
* The `.cmd` must still be launched through a shell — see `npmSpawnShell`.
|
|
32
43
|
*/
|
|
33
44
|
export declare function npmBinary(platform?: string): string;
|
|
45
|
+
/**
|
|
46
|
+
* Whether npm must be spawned through a shell on this platform. Since the Node
|
|
47
|
+
* fix for CVE-2024-27980 (18.20.2 / 20.12.2 / 21.7.2), spawning a `.cmd` shim
|
|
48
|
+
* like `npm.cmd` *without* a shell is rejected with `spawnSync npm.cmd EINVAL`
|
|
49
|
+
* — the very failure a Windows/PowerShell `rastack update` hits. cmd.exe knows
|
|
50
|
+
* how to run a `.cmd`, so Windows spawns through the shell; POSIX keeps the
|
|
51
|
+
* direct, shell-free spawn (npm is a real script resolved on PATH there, and no
|
|
52
|
+
* shell means no injection surface).
|
|
53
|
+
*/
|
|
54
|
+
export declare function npmSpawnShell(platform?: string): boolean;
|
|
34
55
|
/** The npm package spec to install for a target version. */
|
|
35
56
|
export declare function installSpec(version: string): string;
|
|
36
57
|
/** The `npm <args>` (after the `npm` binary) that installs the target globally. */
|
package/dist/update.js
CHANGED
|
@@ -12,7 +12,9 @@
|
|
|
12
12
|
*/
|
|
13
13
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
14
14
|
exports.parseUpdateFlags = parseUpdateFlags;
|
|
15
|
+
exports.sanitizeVersion = sanitizeVersion;
|
|
15
16
|
exports.npmBinary = npmBinary;
|
|
17
|
+
exports.npmSpawnShell = npmSpawnShell;
|
|
16
18
|
exports.installSpec = installSpec;
|
|
17
19
|
exports.npmInstallArgs = npmInstallArgs;
|
|
18
20
|
exports.globalPrefixFromPackageDir = globalPrefixFromPackageDir;
|
|
@@ -39,26 +41,53 @@ function parseUpdateFlags(argv) {
|
|
|
39
41
|
break;
|
|
40
42
|
case "--version":
|
|
41
43
|
case "--to":
|
|
42
|
-
version = argv[++i]
|
|
44
|
+
version = sanitizeVersion(argv[++i]);
|
|
43
45
|
break;
|
|
44
46
|
default:
|
|
45
47
|
// A bare, non-flag token is taken as the target version.
|
|
46
48
|
if (!a.startsWith("-"))
|
|
47
|
-
version = a;
|
|
49
|
+
version = sanitizeVersion(a);
|
|
48
50
|
}
|
|
49
51
|
}
|
|
50
52
|
return { version, check, dryRun };
|
|
51
53
|
}
|
|
54
|
+
/**
|
|
55
|
+
* Constrain the update target to a safe npm version or dist-tag. The Windows
|
|
56
|
+
* update path runs npm through a shell (see `npmSpawnShell`), so the target —
|
|
57
|
+
* the one caller-supplied token that reaches the command line — must not be
|
|
58
|
+
* able to carry shell metacharacters. A valid npm spec starts alphanumeric and
|
|
59
|
+
* uses only `A-Za-z0-9._-` (e.g. `latest`, `next`, `0.0.40`, `1.2.3-beta.1`);
|
|
60
|
+
* anything else falls back to `latest`.
|
|
61
|
+
*/
|
|
62
|
+
function sanitizeVersion(version) {
|
|
63
|
+
return version && /^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(version)
|
|
64
|
+
? version
|
|
65
|
+
: "latest";
|
|
66
|
+
}
|
|
52
67
|
/**
|
|
53
68
|
* The npm executable to spawn for a platform. On Windows npm is a `.cmd` shim,
|
|
54
69
|
* not a real executable, so `execFile("npm", …)` (no shell) fails with
|
|
55
70
|
* `spawnSync npm ENOENT` — the direct-spawn path only resolves real binaries,
|
|
56
71
|
* never `PATHEXT` scripts. Spawn `npm.cmd` there; POSIX keeps plain `npm`.
|
|
57
72
|
* (`aws` is unaffected — it installs as `aws.exe`, a real executable.)
|
|
73
|
+
*
|
|
74
|
+
* The `.cmd` must still be launched through a shell — see `npmSpawnShell`.
|
|
58
75
|
*/
|
|
59
76
|
function npmBinary(platform = process.platform) {
|
|
60
77
|
return platform === "win32" ? "npm.cmd" : "npm";
|
|
61
78
|
}
|
|
79
|
+
/**
|
|
80
|
+
* Whether npm must be spawned through a shell on this platform. Since the Node
|
|
81
|
+
* fix for CVE-2024-27980 (18.20.2 / 20.12.2 / 21.7.2), spawning a `.cmd` shim
|
|
82
|
+
* like `npm.cmd` *without* a shell is rejected with `spawnSync npm.cmd EINVAL`
|
|
83
|
+
* — the very failure a Windows/PowerShell `rastack update` hits. cmd.exe knows
|
|
84
|
+
* how to run a `.cmd`, so Windows spawns through the shell; POSIX keeps the
|
|
85
|
+
* direct, shell-free spawn (npm is a real script resolved on PATH there, and no
|
|
86
|
+
* shell means no injection surface).
|
|
87
|
+
*/
|
|
88
|
+
function npmSpawnShell(platform = process.platform) {
|
|
89
|
+
return platform === "win32";
|
|
90
|
+
}
|
|
62
91
|
/** The npm package spec to install for a target version. */
|
|
63
92
|
function installSpec(version) {
|
|
64
93
|
return !version || version === "latest" ? "rastack" : `rastack@${version}`;
|
package/package.json
CHANGED
package/src/deploy/config.ts
CHANGED
|
@@ -471,6 +471,31 @@ export function repoVariables(cfg: DeployConfig): RepoVariable[] {
|
|
|
471
471
|
];
|
|
472
472
|
}
|
|
473
473
|
|
|
474
|
+
/**
|
|
475
|
+
* The argv (after `gh`) that sets one GitHub repo **Variable** on the config's
|
|
476
|
+
* repo. `init` runs this for each resolved `repoVariables` entry so the
|
|
477
|
+
* `deploy-backend.yml` guard (`if: vars.AWS_DEPLOY_ROLE_ARN != ''`) passes and
|
|
478
|
+
* the very first push actually ships — instead of the variables being a manual
|
|
479
|
+
* Console step the user has to remember. Uses `gh` (already authenticated to the
|
|
480
|
+
* user's GitHub) rather than an API token; a pure argv so it is inspectable and
|
|
481
|
+
* shell-free (`execFileSync`).
|
|
482
|
+
*/
|
|
483
|
+
export function ghVariableSetArgs(
|
|
484
|
+
cfg: DeployConfig,
|
|
485
|
+
name: string,
|
|
486
|
+
value: string,
|
|
487
|
+
): string[] {
|
|
488
|
+
return [
|
|
489
|
+
"variable",
|
|
490
|
+
"set",
|
|
491
|
+
name,
|
|
492
|
+
"--repo",
|
|
493
|
+
`${cfg.github.owner}/${cfg.github.repo}`,
|
|
494
|
+
"--body",
|
|
495
|
+
value,
|
|
496
|
+
];
|
|
497
|
+
}
|
|
498
|
+
|
|
474
499
|
/**
|
|
475
500
|
* The `RASTACK_*` environment the Lambda reads (docs/serverless-deploy.md §1).
|
|
476
501
|
* The IaC sets these on the function; surfaced here for `rastack deploy` to
|
package/src/deploy/io.ts
CHANGED
|
@@ -55,7 +55,7 @@ export function gitRemoteUrl(cwd = process.cwd()): string | null {
|
|
|
55
55
|
}
|
|
56
56
|
|
|
57
57
|
/** True when we can prompt the user (a TTY, and not explicitly opted out). */
|
|
58
|
-
export function canPrompt(nonInteractive
|
|
58
|
+
export function canPrompt(nonInteractive = false): boolean {
|
|
59
59
|
return !nonInteractive && Boolean(process.stdin.isTTY);
|
|
60
60
|
}
|
|
61
61
|
|
|
@@ -144,6 +144,79 @@ export function uploadPlaceholder(
|
|
|
144
144
|
return true;
|
|
145
145
|
}
|
|
146
146
|
|
|
147
|
+
// ── GitHub CLI boundary ──────────────────────────────────────────────────────
|
|
148
|
+
// After the stack is up `init` wires the two repo Variables CI reads
|
|
149
|
+
// (AWS_DEPLOY_ROLE_ARN / AWS_REGION) through the developer's own `gh` — already
|
|
150
|
+
// authenticated to their GitHub — so the pipeline fires on the first push
|
|
151
|
+
// instead of leaving the wiring as a manual Console step. Best-effort: absent or
|
|
152
|
+
// unauthenticated `gh` degrades to the printed instructions.
|
|
153
|
+
|
|
154
|
+
/** True when the GitHub CLI is on PATH. */
|
|
155
|
+
export function ghAvailable(): boolean {
|
|
156
|
+
try {
|
|
157
|
+
execFileSync("gh", ["--version"], { stdio: ["ignore", "pipe", "pipe"] });
|
|
158
|
+
return true;
|
|
159
|
+
} catch {
|
|
160
|
+
return false;
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* True when `gh` already holds valid credentials for github.com. `gh auth
|
|
166
|
+
* status` exits non-zero when logged out, so a thrown call means "not connected".
|
|
167
|
+
*/
|
|
168
|
+
export function ghAuthenticated(cwd = process.cwd()): boolean {
|
|
169
|
+
try {
|
|
170
|
+
execFileSync("gh", ["auth", "status", "--hostname", "github.com"], {
|
|
171
|
+
cwd,
|
|
172
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
173
|
+
});
|
|
174
|
+
return true;
|
|
175
|
+
} catch {
|
|
176
|
+
return false;
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Connect `gh` to GitHub interactively (`gh auth login`) as part of `init`, so
|
|
182
|
+
* the repo Variables can be set right after. Streamed to the terminal — the
|
|
183
|
+
* browser/device-code flow needs the TTY — and left to `gh`'s own prompts so the
|
|
184
|
+
* user picks how to authenticate (browser or a pasted token). Returns true when
|
|
185
|
+
* the login exits 0.
|
|
186
|
+
*/
|
|
187
|
+
export function ghAuthLogin(cwd = process.cwd()): boolean {
|
|
188
|
+
try {
|
|
189
|
+
// nosemgrep: javascript.lang.security.detect-child-process.detect-child-process
|
|
190
|
+
execFileSync("gh", ["auth", "login", "--hostname", "github.com"], {
|
|
191
|
+
cwd,
|
|
192
|
+
stdio: "inherit",
|
|
193
|
+
});
|
|
194
|
+
return true;
|
|
195
|
+
} catch {
|
|
196
|
+
return false;
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Set one GitHub repo Variable via `gh variable set`. Returns true on success,
|
|
202
|
+
* false when `gh` is missing, unauthenticated, or the call otherwise fails —
|
|
203
|
+
* the caller falls back to printing the value for the user to wire by hand.
|
|
204
|
+
* `execFileSync` (no shell) so the ARN/region values are never interpolated
|
|
205
|
+
* into a command line.
|
|
206
|
+
*/
|
|
207
|
+
export function setRepoVariable(
|
|
208
|
+
args: string[],
|
|
209
|
+
cwd = process.cwd(),
|
|
210
|
+
): boolean {
|
|
211
|
+
try {
|
|
212
|
+
// nosemgrep: javascript.lang.security.detect-child-process.detect-child-process
|
|
213
|
+
execFileSync("gh", args, { cwd, stdio: ["ignore", "pipe", "pipe"] });
|
|
214
|
+
return true;
|
|
215
|
+
} catch {
|
|
216
|
+
return false;
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
147
220
|
/**
|
|
148
221
|
* Ask a single question with a default, echoed as `label [default]: `. Returns
|
|
149
222
|
* the trimmed answer, or the default on an empty line.
|
package/src/rastack-init.ts
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
*
|
|
7
7
|
* rastack init [--app <slug>] [--owner <o>] [--repo <r>] [--branch <b>]
|
|
8
8
|
* [--region <r>] [--prefix <p>] [--namespace <n>]
|
|
9
|
-
* [--force] [--no-workflows] [--no-deploy] [--print]
|
|
9
|
+
* [--force] [--no-workflows] [--no-deploy] [--print]
|
|
10
10
|
*
|
|
11
11
|
* This is the one command that takes you from nothing to deployed. It:
|
|
12
12
|
* 1. writes `.rastack/deploy.json` + `.github/workflows/{ci,deploy-backend}.yml`
|
|
@@ -38,6 +38,7 @@ import {
|
|
|
38
38
|
bootstrapStackName,
|
|
39
39
|
deployStackArgs,
|
|
40
40
|
deployStackCommand,
|
|
41
|
+
ghVariableSetArgs,
|
|
41
42
|
mergeConfig,
|
|
42
43
|
normalizeConfig,
|
|
43
44
|
parseGitHubRemote,
|
|
@@ -50,9 +51,13 @@ import {
|
|
|
50
51
|
awsAvailable,
|
|
51
52
|
awsRun,
|
|
52
53
|
canPrompt,
|
|
54
|
+
ghAuthLogin,
|
|
55
|
+
ghAuthenticated,
|
|
56
|
+
ghAvailable,
|
|
53
57
|
gitRemoteUrl,
|
|
54
58
|
loadConfig,
|
|
55
59
|
saveConfig,
|
|
60
|
+
setRepoVariable,
|
|
56
61
|
stackOutput,
|
|
57
62
|
uploadPlaceholder,
|
|
58
63
|
withPrompt,
|
|
@@ -64,7 +69,6 @@ interface Flags {
|
|
|
64
69
|
noWorkflows: boolean;
|
|
65
70
|
noDeploy: boolean;
|
|
66
71
|
print: boolean;
|
|
67
|
-
nonInteractive: boolean;
|
|
68
72
|
}
|
|
69
73
|
|
|
70
74
|
function parseFlags(argv: string[]): Flags {
|
|
@@ -73,7 +77,6 @@ function parseFlags(argv: string[]): Flags {
|
|
|
73
77
|
let noWorkflows = false;
|
|
74
78
|
let noDeploy = false;
|
|
75
79
|
let print = false;
|
|
76
|
-
let nonInteractive = false;
|
|
77
80
|
for (let i = 0; i < argv.length; i++) {
|
|
78
81
|
const a = argv[i];
|
|
79
82
|
const next = () => argv[++i] ?? "";
|
|
@@ -112,11 +115,6 @@ function parseFlags(argv: string[]): Flags {
|
|
|
112
115
|
case "--dry-run":
|
|
113
116
|
print = true;
|
|
114
117
|
break;
|
|
115
|
-
case "--yes":
|
|
116
|
-
case "-y":
|
|
117
|
-
case "--non-interactive":
|
|
118
|
-
nonInteractive = true;
|
|
119
|
-
break;
|
|
120
118
|
default:
|
|
121
119
|
if (a.startsWith("-")) {
|
|
122
120
|
console.error(`Unknown flag: ${a}`);
|
|
@@ -124,7 +122,7 @@ function parseFlags(argv: string[]): Flags {
|
|
|
124
122
|
}
|
|
125
123
|
}
|
|
126
124
|
}
|
|
127
|
-
return { patch, force, noWorkflows, noDeploy, print
|
|
125
|
+
return { patch, force, noWorkflows, noDeploy, print };
|
|
128
126
|
}
|
|
129
127
|
|
|
130
128
|
/** Seed a config: existing file → flags → git remote → defaults. */
|
|
@@ -236,7 +234,10 @@ function printPlan(cfg: DeployConfig): void {
|
|
|
236
234
|
* upload the Lambda placeholder, deploy app.yaml, and read the Outputs back into
|
|
237
235
|
* the config. Returns the config enriched with the resolved buckets + outputs.
|
|
238
236
|
*/
|
|
239
|
-
async function standUp(
|
|
237
|
+
async function standUp(
|
|
238
|
+
cfg: DeployConfig,
|
|
239
|
+
interactive: boolean,
|
|
240
|
+
): Promise<DeployConfig> {
|
|
240
241
|
if (!awsAvailable()) {
|
|
241
242
|
console.error(
|
|
242
243
|
" ✗ the AWS CLI is not on PATH. Install it and configure credentials,\n" +
|
|
@@ -308,9 +309,67 @@ async function standUp(cfg: DeployConfig): Promise<DeployConfig> {
|
|
|
308
309
|
const outputs = readAppOutputs(cfg);
|
|
309
310
|
cfg = mergeConfig(cfg, { outputs });
|
|
310
311
|
saveConfig(cfg);
|
|
312
|
+
|
|
313
|
+
// 5. Connect the GitHub CLI and wire the repo Variables CI reads, so the FIRST
|
|
314
|
+
// push actually ships. Without this the deploy-backend workflow's `if:
|
|
315
|
+
// vars.AWS_DEPLOY_ROLE_ARN != ''` guard silently skips every run.
|
|
316
|
+
wireRepoVariables(cfg, interactive);
|
|
311
317
|
return cfg;
|
|
312
318
|
}
|
|
313
319
|
|
|
320
|
+
/**
|
|
321
|
+
* Set the `AWS_DEPLOY_ROLE_ARN` / `AWS_REGION` repo Variables via `gh` so the
|
|
322
|
+
* GitHub → Lambda pipeline is live the moment `init` finishes. Connects `gh` to
|
|
323
|
+
* GitHub first — running `gh auth login` when it is installed but not yet
|
|
324
|
+
* authenticated (on a TTY) — so a fresh machine goes from nothing to a working
|
|
325
|
+
* pipeline in one command. Best-effort throughout: no `gh`, a declined login, or
|
|
326
|
+
* a value the stack Outputs did not yield falls back to the printed instructions
|
|
327
|
+
* in `printSummary`. Records the names it wired so the summary reports what is
|
|
328
|
+
* (and isn't) done.
|
|
329
|
+
*/
|
|
330
|
+
const wiredVariables = new Set<string>();
|
|
331
|
+
function wireRepoVariables(cfg: DeployConfig, interactive: boolean): void {
|
|
332
|
+
const vars = repoVariables(cfg).filter((v) => v.value);
|
|
333
|
+
if (!vars.length) return;
|
|
334
|
+
|
|
335
|
+
if (!ghAvailable()) {
|
|
336
|
+
console.log(
|
|
337
|
+
"\n▸ GitHub CLI (`gh`) not found — skipping repo Variable wiring.\n" +
|
|
338
|
+
" Install `gh` (https://cli.github.com) and re-run, or wire the\n" +
|
|
339
|
+
" Variables below by hand so pushes deploy.",
|
|
340
|
+
);
|
|
341
|
+
return;
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
// Connect `gh` to GitHub if it isn't already, so it can set the Variables.
|
|
345
|
+
if (!ghAuthenticated()) {
|
|
346
|
+
if (interactive) {
|
|
347
|
+
console.log(
|
|
348
|
+
"\n▸ Connecting the GitHub CLI to your account (`gh auth login`)",
|
|
349
|
+
);
|
|
350
|
+
ghAuthLogin();
|
|
351
|
+
}
|
|
352
|
+
if (!ghAuthenticated()) {
|
|
353
|
+
console.log(
|
|
354
|
+
"\n▸ GitHub CLI is not connected — skipping repo Variable wiring.\n" +
|
|
355
|
+
" Run `gh auth login`, then wire the Variables below by hand.",
|
|
356
|
+
);
|
|
357
|
+
return;
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
console.log("\n▸ Wiring the GitHub repo Variables CI reads (via `gh`)");
|
|
362
|
+
for (const v of vars) {
|
|
363
|
+
const ok = setRepoVariable(ghVariableSetArgs(cfg, v.name, v.value));
|
|
364
|
+
if (ok) {
|
|
365
|
+
wiredVariables.add(v.name);
|
|
366
|
+
console.log(` ✓ set ${v.name}`);
|
|
367
|
+
} else {
|
|
368
|
+
console.log(` • could not set ${v.name} — wire it by hand (below)`);
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
|
|
314
373
|
/** Deploy the bootstrap stack, retrying once against an existing OIDC provider. */
|
|
315
374
|
function deployBootstrap(cfg: DeployConfig): void {
|
|
316
375
|
try {
|
|
@@ -338,19 +397,28 @@ function printSummary(cfg: DeployConfig): void {
|
|
|
338
397
|
`\n ✓ ${cfg.app} is deployed (${cfg.stackName} @ ${cfg.region})`,
|
|
339
398
|
);
|
|
340
399
|
if (cfg.outputs?.apiUrl) console.log(` API: ${cfg.outputs.apiUrl}`);
|
|
400
|
+
|
|
401
|
+
// Only the Variables `init` could NOT wire itself remain a manual step.
|
|
402
|
+
const pending = repoVariables(cfg).filter((v) => !wiredVariables.has(v.name));
|
|
403
|
+
if (pending.length) {
|
|
404
|
+
console.log(
|
|
405
|
+
"\n Wire these GitHub repo Variables so CI can ship code (Settings → Variables):",
|
|
406
|
+
);
|
|
407
|
+
console.log(
|
|
408
|
+
table(
|
|
409
|
+
pending.map((v) => ({
|
|
410
|
+
key: v.name,
|
|
411
|
+
value: v.value || `<${v.from}>`,
|
|
412
|
+
})),
|
|
413
|
+
),
|
|
414
|
+
);
|
|
415
|
+
} else {
|
|
416
|
+
console.log(
|
|
417
|
+
"\n ✓ wired the GitHub repo Variables CI reads (AWS_DEPLOY_ROLE_ARN, AWS_REGION).",
|
|
418
|
+
);
|
|
419
|
+
}
|
|
341
420
|
console.log(
|
|
342
|
-
|
|
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\`` +
|
|
421
|
+
`\n Every push to ${cfg.github.branch} now ships code via \`rastack ci deploy\`` +
|
|
354
422
|
` (also runnable locally as \`rastack deploy\`).\n`,
|
|
355
423
|
);
|
|
356
424
|
}
|
|
@@ -359,7 +427,7 @@ async function main(argv: string[]): Promise<void> {
|
|
|
359
427
|
const flags = parseFlags(argv);
|
|
360
428
|
let cfg = seedConfig(flags);
|
|
361
429
|
|
|
362
|
-
if (canPrompt(
|
|
430
|
+
if (canPrompt()) {
|
|
363
431
|
console.log(
|
|
364
432
|
"\n rastack init — scaffold the deploy config + stand the backend up\n",
|
|
365
433
|
);
|
|
@@ -368,9 +436,9 @@ async function main(argv: string[]): Promise<void> {
|
|
|
368
436
|
|
|
369
437
|
const errors = validateConfig(cfg);
|
|
370
438
|
// Any deploy needs a complete config; a still-incomplete one is fatal unless we
|
|
371
|
-
// are only scaffolding files.
|
|
439
|
+
// are only scaffolding files, and always fatal off a TTY (no prompt to fix it).
|
|
372
440
|
if (errors.length) {
|
|
373
|
-
const fatal = !flags.noDeploy ||
|
|
441
|
+
const fatal = !flags.noDeploy || !canPrompt();
|
|
374
442
|
const label = fatal ? "✗" : "⚠";
|
|
375
443
|
for (const e of errors) console.error(` ${label} ${e}`);
|
|
376
444
|
if (fatal) {
|
|
@@ -413,7 +481,7 @@ async function main(argv: string[]): Promise<void> {
|
|
|
413
481
|
return;
|
|
414
482
|
}
|
|
415
483
|
|
|
416
|
-
if (canPrompt(
|
|
484
|
+
if (canPrompt()) {
|
|
417
485
|
const ok = await confirm(
|
|
418
486
|
`Deploy ${cfg.stackName} to AWS ${cfg.region} now? (creates the bootstrap stack if needed) [y/N]`,
|
|
419
487
|
);
|
|
@@ -425,7 +493,7 @@ async function main(argv: string[]): Promise<void> {
|
|
|
425
493
|
}
|
|
426
494
|
}
|
|
427
495
|
|
|
428
|
-
cfg = await standUp(cfg);
|
|
496
|
+
cfg = await standUp(cfg, canPrompt());
|
|
429
497
|
printSummary(cfg);
|
|
430
498
|
}
|
|
431
499
|
|
package/src/rastack-update.ts
CHANGED
|
@@ -24,6 +24,7 @@ import {
|
|
|
24
24
|
globalPrefixFromPackageDir,
|
|
25
25
|
npmBinary,
|
|
26
26
|
npmInstallArgs,
|
|
27
|
+
npmSpawnShell,
|
|
27
28
|
parseUpdateFlags,
|
|
28
29
|
updateAvailable,
|
|
29
30
|
} from "./update";
|
|
@@ -31,6 +32,13 @@ import {
|
|
|
31
32
|
/** The npm executable for this platform (`npm.cmd` on Windows, else `npm`). */
|
|
32
33
|
const NPM = npmBinary();
|
|
33
34
|
|
|
35
|
+
/**
|
|
36
|
+
* Whether to launch npm through a shell. Required on Windows, where a bare
|
|
37
|
+
* `.cmd` spawn is rejected with `EINVAL` since the Node CVE-2024-27980 fix;
|
|
38
|
+
* false on POSIX so the direct spawn stays injection-free.
|
|
39
|
+
*/
|
|
40
|
+
const NPM_SHELL = npmSpawnShell();
|
|
41
|
+
|
|
34
42
|
/** The installed rastack package directory (…/rastack), one up from dist/. */
|
|
35
43
|
const PKG_DIR = path.join(__dirname, "..");
|
|
36
44
|
const PKG_JSON = path.join(PKG_DIR, "package.json");
|
|
@@ -49,6 +57,7 @@ function npmCapture(args: string[]): string | null {
|
|
|
49
57
|
try {
|
|
50
58
|
return execFileSync(NPM, args, {
|
|
51
59
|
stdio: ["ignore", "pipe", "pipe"],
|
|
60
|
+
shell: NPM_SHELL,
|
|
52
61
|
})
|
|
53
62
|
.toString()
|
|
54
63
|
.trim();
|
|
@@ -103,10 +112,12 @@ function main(argv: string[]): void {
|
|
|
103
112
|
`\n▸ Updating rastack (${current} → ${flags.version}) in ${prefix}\n`,
|
|
104
113
|
);
|
|
105
114
|
try {
|
|
106
|
-
// Re-runs the developer's own npm to update this global package
|
|
107
|
-
//
|
|
115
|
+
// Re-runs the developer's own npm to update this global package. On Windows
|
|
116
|
+
// the `.cmd` shim is launched through a shell (EINVAL otherwise); the only
|
|
117
|
+
// caller-supplied token — the version — is sanitized in `parseUpdateFlags`,
|
|
118
|
+
// and every other arg is a fixed literal, so nothing injectable reaches it.
|
|
108
119
|
// nosemgrep: javascript.lang.security.detect-child-process.detect-child-process
|
|
109
|
-
execFileSync(NPM, args, { stdio: "inherit", env });
|
|
120
|
+
execFileSync(NPM, args, { stdio: "inherit", env, shell: NPM_SHELL });
|
|
110
121
|
} catch (err) {
|
|
111
122
|
console.error(
|
|
112
123
|
`\n ✗ npm failed to update rastack. Re-run for detail:\n npm install -g ${npmInstallArgs(flags.version)[2]}\n ${(err as Error).message}`,
|
package/src/update.ts
CHANGED
|
@@ -40,27 +40,56 @@ export function parseUpdateFlags(argv: string[]): UpdateFlags {
|
|
|
40
40
|
break;
|
|
41
41
|
case "--version":
|
|
42
42
|
case "--to":
|
|
43
|
-
version = argv[++i]
|
|
43
|
+
version = sanitizeVersion(argv[++i]);
|
|
44
44
|
break;
|
|
45
45
|
default:
|
|
46
46
|
// A bare, non-flag token is taken as the target version.
|
|
47
|
-
if (!a.startsWith("-")) version = a;
|
|
47
|
+
if (!a.startsWith("-")) version = sanitizeVersion(a);
|
|
48
48
|
}
|
|
49
49
|
}
|
|
50
50
|
return { version, check, dryRun };
|
|
51
51
|
}
|
|
52
52
|
|
|
53
|
+
/**
|
|
54
|
+
* Constrain the update target to a safe npm version or dist-tag. The Windows
|
|
55
|
+
* update path runs npm through a shell (see `npmSpawnShell`), so the target —
|
|
56
|
+
* the one caller-supplied token that reaches the command line — must not be
|
|
57
|
+
* able to carry shell metacharacters. A valid npm spec starts alphanumeric and
|
|
58
|
+
* uses only `A-Za-z0-9._-` (e.g. `latest`, `next`, `0.0.40`, `1.2.3-beta.1`);
|
|
59
|
+
* anything else falls back to `latest`.
|
|
60
|
+
*/
|
|
61
|
+
export function sanitizeVersion(version: string | undefined): string {
|
|
62
|
+
return version && /^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(version)
|
|
63
|
+
? version
|
|
64
|
+
: "latest";
|
|
65
|
+
}
|
|
66
|
+
|
|
53
67
|
/**
|
|
54
68
|
* The npm executable to spawn for a platform. On Windows npm is a `.cmd` shim,
|
|
55
69
|
* not a real executable, so `execFile("npm", …)` (no shell) fails with
|
|
56
70
|
* `spawnSync npm ENOENT` — the direct-spawn path only resolves real binaries,
|
|
57
71
|
* never `PATHEXT` scripts. Spawn `npm.cmd` there; POSIX keeps plain `npm`.
|
|
58
72
|
* (`aws` is unaffected — it installs as `aws.exe`, a real executable.)
|
|
73
|
+
*
|
|
74
|
+
* The `.cmd` must still be launched through a shell — see `npmSpawnShell`.
|
|
59
75
|
*/
|
|
60
76
|
export function npmBinary(platform: string = process.platform): string {
|
|
61
77
|
return platform === "win32" ? "npm.cmd" : "npm";
|
|
62
78
|
}
|
|
63
79
|
|
|
80
|
+
/**
|
|
81
|
+
* Whether npm must be spawned through a shell on this platform. Since the Node
|
|
82
|
+
* fix for CVE-2024-27980 (18.20.2 / 20.12.2 / 21.7.2), spawning a `.cmd` shim
|
|
83
|
+
* like `npm.cmd` *without* a shell is rejected with `spawnSync npm.cmd EINVAL`
|
|
84
|
+
* — the very failure a Windows/PowerShell `rastack update` hits. cmd.exe knows
|
|
85
|
+
* how to run a `.cmd`, so Windows spawns through the shell; POSIX keeps the
|
|
86
|
+
* direct, shell-free spawn (npm is a real script resolved on PATH there, and no
|
|
87
|
+
* shell means no injection surface).
|
|
88
|
+
*/
|
|
89
|
+
export function npmSpawnShell(platform: string = process.platform): boolean {
|
|
90
|
+
return platform === "win32";
|
|
91
|
+
}
|
|
92
|
+
|
|
64
93
|
/** The npm package spec to install for a target version. */
|
|
65
94
|
export function installSpec(version: string): string {
|
|
66
95
|
return !version || version === "latest" ? "rastack" : `rastack@${version}`;
|
package/test/deploy.spec.ts
CHANGED
|
@@ -13,6 +13,7 @@ import {
|
|
|
13
13
|
bootstrapStackParameters,
|
|
14
14
|
cognitoAdvancedSecuritySupported,
|
|
15
15
|
APP_STACK_OUTPUTS,
|
|
16
|
+
ghVariableSetArgs,
|
|
16
17
|
lambdaEnv,
|
|
17
18
|
mergeConfig,
|
|
18
19
|
normalizeConfig,
|
|
@@ -345,6 +346,32 @@ describe("repo variables + lambda env", () => {
|
|
|
345
346
|
expect(vars.find((v) => v.name === "AWS_REGION")!.value).toBe("af-south-1");
|
|
346
347
|
});
|
|
347
348
|
|
|
349
|
+
it("builds the `gh variable set` argv init wires the pipeline with", () => {
|
|
350
|
+
const cfg = base({ outputs: { deployRoleArn: "arn:aws:iam::1:role/x" } });
|
|
351
|
+
const roleVar = repoVariables(cfg).find(
|
|
352
|
+
(v) => v.name === "AWS_DEPLOY_ROLE_ARN",
|
|
353
|
+
)!;
|
|
354
|
+
expect(ghVariableSetArgs(cfg, roleVar.name, roleVar.value)).toEqual([
|
|
355
|
+
"variable",
|
|
356
|
+
"set",
|
|
357
|
+
"AWS_DEPLOY_ROLE_ARN",
|
|
358
|
+
"--repo",
|
|
359
|
+
"theserverkid/reactapistack-demo",
|
|
360
|
+
"--body",
|
|
361
|
+
"arn:aws:iam::1:role/x",
|
|
362
|
+
]);
|
|
363
|
+
// Region variable targets the same repo with the region value.
|
|
364
|
+
expect(ghVariableSetArgs(cfg, "AWS_REGION", cfg.region)).toEqual([
|
|
365
|
+
"variable",
|
|
366
|
+
"set",
|
|
367
|
+
"AWS_REGION",
|
|
368
|
+
"--repo",
|
|
369
|
+
"theserverkid/reactapistack-demo",
|
|
370
|
+
"--body",
|
|
371
|
+
"af-south-1",
|
|
372
|
+
]);
|
|
373
|
+
});
|
|
374
|
+
|
|
348
375
|
it("fills the RASTACK_* env, using outputs where available", () => {
|
|
349
376
|
const cfg = base({
|
|
350
377
|
outputs: {
|
package/test/update.spec.ts
CHANGED
|
@@ -4,7 +4,9 @@ import {
|
|
|
4
4
|
installSpec,
|
|
5
5
|
npmBinary,
|
|
6
6
|
npmInstallArgs,
|
|
7
|
+
npmSpawnShell,
|
|
7
8
|
parseUpdateFlags,
|
|
9
|
+
sanitizeVersion,
|
|
8
10
|
updateAvailable,
|
|
9
11
|
} from "../src/update";
|
|
10
12
|
|
|
@@ -56,6 +58,39 @@ describe("update — npm spec + args", () => {
|
|
|
56
58
|
expect(npmBinary("linux")).toBe("npm");
|
|
57
59
|
expect(npmBinary("darwin")).toBe("npm");
|
|
58
60
|
});
|
|
61
|
+
|
|
62
|
+
it("runs npm through a shell only on Windows", () => {
|
|
63
|
+
// A `.cmd` shim can't be spawned directly since the Node CVE-2024-27980 fix
|
|
64
|
+
// (spawnSync npm.cmd EINVAL) — it must go through cmd.exe. POSIX spawns npm
|
|
65
|
+
// directly so there is no shell and no injection surface.
|
|
66
|
+
expect(npmSpawnShell("win32")).toBe(true);
|
|
67
|
+
expect(npmSpawnShell("linux")).toBe(false);
|
|
68
|
+
expect(npmSpawnShell("darwin")).toBe(false);
|
|
69
|
+
});
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
describe("update — version sanitization", () => {
|
|
73
|
+
it("keeps valid semver, dist-tags, and pinned versions", () => {
|
|
74
|
+
expect(sanitizeVersion("latest")).toBe("latest");
|
|
75
|
+
expect(sanitizeVersion("next")).toBe("next");
|
|
76
|
+
expect(sanitizeVersion("0.0.40")).toBe("0.0.40");
|
|
77
|
+
expect(sanitizeVersion("1.2.3-beta.1")).toBe("1.2.3-beta.1");
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it("falls back to latest for empty or shell-unsafe input", () => {
|
|
81
|
+
expect(sanitizeVersion(undefined)).toBe("latest");
|
|
82
|
+
expect(sanitizeVersion("")).toBe("latest");
|
|
83
|
+
// Shell metacharacters must never reach the Windows shell-spawned npm.
|
|
84
|
+
expect(sanitizeVersion("0.0.40; rm -rf /")).toBe("latest");
|
|
85
|
+
expect(sanitizeVersion("$(whoami)")).toBe("latest");
|
|
86
|
+
expect(sanitizeVersion("a b")).toBe("latest");
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it("is applied when parsing the bare positional and --version", () => {
|
|
90
|
+
expect(parseUpdateFlags(["0.0.40; evil"]).version).toBe("latest");
|
|
91
|
+
expect(parseUpdateFlags(["--version", "&& evil"]).version).toBe("latest");
|
|
92
|
+
expect(parseUpdateFlags(["0.0.40"]).version).toBe("0.0.40");
|
|
93
|
+
});
|
|
59
94
|
});
|
|
60
95
|
|
|
61
96
|
describe("update — global prefix derivation", () => {
|