@codeyam-editor/codeyam-editor 0.1.2 → 0.1.4
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/npm/cloud.js +779 -116
- package/npm/container.js +11 -0
- package/npm/disk-pressure.js +89 -0
- package/npm/editor-dev.js +144 -52
- package/npm/editor.js +47 -17
- package/npm/fleet-branch.js +49 -0
- package/npm/fleet-broker-death.js +44 -0
- package/npm/fleet-broker-debounce.js +63 -0
- package/npm/fleet-broker-status.js +45 -0
- package/npm/fleet-build-data.js +81 -0
- package/npm/fleet-cleanup.js +216 -16
- package/npm/fleet-effect-status.js +179 -0
- package/npm/fleet-grouping.js +56 -0
- package/npm/fleet-job-badge.js +83 -0
- package/npm/fleet-memory.js +35 -5
- package/npm/fleet-plans.js +96 -0
- package/npm/fleet-reconcile.js +58 -1
- package/npm/fleet-reset.js +94 -16
- package/npm/launch-config.js +197 -14
- package/npm/preflight-guidance.js +9 -0
- package/npm/scenario-check.js +106 -1
- package/npm/scenario-handlers.js +16 -0
- package/npm/scenario-interactivity.js +53 -7
- package/npm/scenario-playwright.js +184 -5
- package/npm/utils.js +238 -53
- package/package.json +5 -5
- package/ui/dist/assets/EditorShell-tMMdarZD.js +187 -0
- package/ui/dist/assets/ScenarioDataPanel-xWYGZOpo.js +5 -0
- package/ui/dist/assets/{ScenarioDataPanelFullPage-C0YHQB-T.js → ScenarioDataPanelFullPage-5G6KuZgw.js} +1 -1
- package/ui/dist/assets/ScenarioRenderer-CSdhX1Ak.js +1 -0
- package/ui/dist/assets/index-BGqairFV.css +1 -0
- package/ui/dist/assets/{index-BUYNFqKK.js → index-BuK0lizt.js} +9 -9
- package/ui/dist/index.html +2 -2
- package/ui/dist/assets/EditorShell-DWaeBxgj.js +0 -186
- package/ui/dist/assets/ScenarioDataPanel-zJSXtXL4.js +0 -5
- package/ui/dist/assets/ScenarioRenderer-CgDxuigm.js +0 -1
- package/ui/dist/assets/index-DVr5rY2d.css +0 -1
package/npm/cloud.js
CHANGED
|
@@ -51,6 +51,12 @@ const AGENT_ENV_VARS = {
|
|
|
51
51
|
gemini: "GEMINI_API_KEY",
|
|
52
52
|
};
|
|
53
53
|
|
|
54
|
+
const AGENT_NPM_PACKAGES = {
|
|
55
|
+
claude: "@anthropic-ai/claude-code",
|
|
56
|
+
codex: "@openai/codex",
|
|
57
|
+
gemini: "@google/gemini-cli",
|
|
58
|
+
};
|
|
59
|
+
|
|
54
60
|
const KNOWN_FLAGS = [
|
|
55
61
|
"repo", "branch", "instance-name", "project", "zone", "agent",
|
|
56
62
|
// Editor-source selection, decoupled from the client project (repo/branch):
|
|
@@ -110,11 +116,11 @@ const CLOUDFLARED_URLS_FILE =
|
|
|
110
116
|
// image out of the client repo.
|
|
111
117
|
const CANONICAL_EDITOR_REPO = "https://github.com/codeyam-ai/codeyam-editor.git";
|
|
112
118
|
|
|
113
|
-
// The editor distribution channels an operator can pick per VM
|
|
119
|
+
// The editor distribution channels an operator can pick per VM. All three are
|
|
120
|
+
// provisionable (gcp-bootstrap dispatches on --editor-variant):
|
|
114
121
|
// dev — build the editor image from the editor repo @ editorBranch
|
|
115
|
-
//
|
|
116
|
-
//
|
|
117
|
-
// production — `npm i -g @codeyam-editor/codeyam-editor` (Phase 2)
|
|
122
|
+
// staging — run the published `@codeyam-editor/codeyam-editor@staging` image
|
|
123
|
+
// production — run the published `@codeyam-editor/codeyam-editor` (latest) image
|
|
118
124
|
const EDITOR_VARIANTS = ["dev", "staging", "production"];
|
|
119
125
|
|
|
120
126
|
/** Map an agent name to its API-key env var. Throws on unknown agents so
|
|
@@ -129,6 +135,17 @@ function agentEnvVarName(agent) {
|
|
|
129
135
|
return AGENT_ENV_VARS[agent];
|
|
130
136
|
}
|
|
131
137
|
|
|
138
|
+
/** Map an agent name to its global npm package. Throws on unknown agents so
|
|
139
|
+
* adding an agent is a one-entry change in `AGENT_NPM_PACKAGES`. */
|
|
140
|
+
function agentNpmPackage(agent) {
|
|
141
|
+
if (!Object.prototype.hasOwnProperty.call(AGENT_NPM_PACKAGES, agent)) {
|
|
142
|
+
throw new Error(
|
|
143
|
+
`Unknown agent: ${agent}. Supported: ${Object.keys(AGENT_NPM_PACKAGES).join(", ")}`,
|
|
144
|
+
);
|
|
145
|
+
}
|
|
146
|
+
return AGENT_NPM_PACKAGES[agent];
|
|
147
|
+
}
|
|
148
|
+
|
|
132
149
|
/** Parse `--flag value` argv into an object. Unknown flags or missing values
|
|
133
150
|
* throw with a usage hint — silently dropping them would let typos like
|
|
134
151
|
* `--branchh main` succeed with no branch flag at all. */
|
|
@@ -302,33 +319,98 @@ function resolveSeedPreviewOrigin(
|
|
|
302
319
|
urls = {},
|
|
303
320
|
appPort = VITE_PORT,
|
|
304
321
|
env = process.env,
|
|
322
|
+
sameOriginSafe = true,
|
|
305
323
|
) {
|
|
324
|
+
// Same-origin-safe stack (the default — Vite et al.): ALWAYS serve the app
|
|
325
|
+
// same-origin under the reserved relative path, in every fleet mode. The
|
|
326
|
+
// editor mounts `/__codeyam_preview` on its own origin and forwards to the
|
|
327
|
+
// in-VM app-port reverse proxy, so the iframe loads from the exact origin the
|
|
328
|
+
// editor is served on (public quick-tunnel, named tunnel, SSH-tunnel
|
|
329
|
+
// localhost, anything). It is a CONSTANT relative path: it never rotates and
|
|
330
|
+
// depends only on the editor's own origin being reachable, so it can never rot
|
|
331
|
+
// the way a frozen managed quick-tunnel URL does (the VM-1/2/6 leftover this
|
|
332
|
+
// plan fixes). No `_managedPreviewOrigin` marker is stamped — the marker is
|
|
333
|
+
// reserved for the genuinely-managed dedicated origins below, so the self-heal
|
|
334
|
+
// predicate can tell a rotting managed URL apart from this constant path.
|
|
335
|
+
if (sameOriginSafe) {
|
|
336
|
+
return { previewOrigin: SAME_ORIGIN_PREVIEW_PATH };
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
// NOT same-origin-safe (client-routed stacks — Next.js etc.): the client
|
|
340
|
+
// router breaks the same-origin subpath proxy (it pushState's to root-absolute
|
|
341
|
+
// paths and recurses into the editor), so the preview must come from a
|
|
342
|
+
// DEDICATED origin. We stamp `_managedPreviewOrigin` so the (legacy) monitor
|
|
343
|
+
// recognises the value as ours and won't mistake it for a user pin.
|
|
306
344
|
const vm = resolveFleetVmNumber(instanceName);
|
|
307
345
|
const domain = resolveFleetDomain(env);
|
|
308
|
-
// Named
|
|
309
|
-
//
|
|
346
|
+
// Named-tunnel mode: the deterministic `vm-<N>.preview.<domain>` host is a
|
|
347
|
+
// dedicated sibling subdomain of the editor and never rotates. A non-fleet
|
|
348
|
+
// client project (vm == null) keeps the localhost seed even with a domain set.
|
|
310
349
|
if (domain && vm != null) {
|
|
311
350
|
return {
|
|
312
351
|
previewOrigin: vmUrls(vm, domain).preview,
|
|
313
352
|
_managedPreviewOrigin: MANAGED_PREVIEW_ORIGIN,
|
|
314
353
|
};
|
|
315
354
|
}
|
|
316
|
-
// Quick-tunnel / domain-free mode:
|
|
317
|
-
//
|
|
318
|
-
//
|
|
319
|
-
//
|
|
320
|
-
//
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
355
|
+
// Quick-tunnel / domain-free mode: prefer the operator's published
|
|
356
|
+
// `<N>-preview` quick-tunnel URL (the public origin a remote browser can
|
|
357
|
+
// actually load), else the SSH-tunnel bootstrap localhost origin
|
|
358
|
+
// (`http://localhost:<appPort>`, reached via the laptop `-L` forward). Never
|
|
359
|
+
// the same-origin subpath — that is exactly what recurses for this stack.
|
|
360
|
+
const previewKey = vm != null ? `${vm}-preview` : null;
|
|
361
|
+
const tunnelUrl =
|
|
362
|
+
previewKey &&
|
|
363
|
+
typeof urls[previewKey] === "string" &&
|
|
364
|
+
urls[previewKey].length > 0
|
|
365
|
+
? urls[previewKey]
|
|
366
|
+
: `http://localhost:${appPort}`;
|
|
326
367
|
return {
|
|
327
|
-
previewOrigin:
|
|
368
|
+
previewOrigin: tunnelUrl,
|
|
328
369
|
_managedPreviewOrigin: MANAGED_PREVIEW_ORIGIN,
|
|
329
370
|
};
|
|
330
371
|
}
|
|
331
372
|
|
|
373
|
+
/** Extract the stack's `preview.sameOriginSafe` flag from a parsed
|
|
374
|
+
* `.codeyam/stack.json` object. Absent / non-object / non-boolean ⇒ `true`
|
|
375
|
+
* (the same-origin default), mirroring `PreviewConfig::same_origin_safe()` on
|
|
376
|
+
* the Rust side so the seeder and the editor agree on the truth table. Pure. */
|
|
377
|
+
function previewSameOriginSafeFromStack(stackJson) {
|
|
378
|
+
const preview =
|
|
379
|
+
stackJson && typeof stackJson === "object" ? stackJson.preview : null;
|
|
380
|
+
const flag =
|
|
381
|
+
preview && typeof preview === "object" ? preview.sameOriginSafe : undefined;
|
|
382
|
+
return typeof flag === "boolean" ? flag : true;
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
// Matches an absolute cloudflared quick-tunnel origin
|
|
386
|
+
// (`https://<sub>.trycloudflare.com`, optionally with a port/path/query). These
|
|
387
|
+
// URLs ROTATE on every tunnel respawn, which is exactly why a frozen one rots.
|
|
388
|
+
const TRYCLOUDFLARE_ORIGIN_RE =
|
|
389
|
+
/^https:\/\/[a-z0-9-]+\.trycloudflare\.com(?:[:/?#]|$)/i;
|
|
390
|
+
|
|
391
|
+
/** Decide whether a VM's current `editor.local.json` carries a ROTTED managed
|
|
392
|
+
* cloudflared previewOrigin that must be self-healed back to the same-origin
|
|
393
|
+
* path. True iff ALL hold:
|
|
394
|
+
* - the stack is same-origin-safe (a client-routed stack legitimately keeps a
|
|
395
|
+
* dedicated origin — never heal it);
|
|
396
|
+
* - the value is stamped `_managedPreviewOrigin: cloudflared-monitor` (WE
|
|
397
|
+
* wrote it, not a user pin); and
|
|
398
|
+
* - the `previewOrigin` is an absolute `*.trycloudflare.com` quick-tunnel URL
|
|
399
|
+
* (the rotating kind that goes dead — a deterministic named-tunnel host or
|
|
400
|
+
* the constant `/__codeyam_preview` path is left alone).
|
|
401
|
+
* Idempotent by construction: a VM already on `/__codeyam_preview` (or any
|
|
402
|
+
* non-trycloudflare origin) returns false, so re-running the heal is a no-op.
|
|
403
|
+
* Pure — `localJson` is a parsed editor.local.json object. */
|
|
404
|
+
function isRottedManagedPreviewOrigin(localJson, sameOriginSafe) {
|
|
405
|
+
if (!sameOriginSafe) return false;
|
|
406
|
+
if (!localJson || typeof localJson !== "object") return false;
|
|
407
|
+
if (localJson._managedPreviewOrigin !== MANAGED_PREVIEW_ORIGIN) return false;
|
|
408
|
+
return (
|
|
409
|
+
typeof localJson.previewOrigin === "string" &&
|
|
410
|
+
TRYCLOUDFLARE_ORIGIN_RE.test(localJson.previewOrigin)
|
|
411
|
+
);
|
|
412
|
+
}
|
|
413
|
+
|
|
332
414
|
/** Read the operator-laptop cloudflared-urls.json sidecar. Missing/unparseable
|
|
333
415
|
* → {} (the monitor may not have run yet; the seed falls back to localhost). */
|
|
334
416
|
function readCloudflaredUrls() {
|
|
@@ -370,26 +452,33 @@ function validateEditorVariant(variant) {
|
|
|
370
452
|
return variant;
|
|
371
453
|
}
|
|
372
454
|
|
|
373
|
-
/**
|
|
374
|
-
*
|
|
375
|
-
*
|
|
455
|
+
/** Assert a variant can be provisioned onto a cloud VM. Every known variant is
|
|
456
|
+
* provisionable now — `dev` builds the editor image from source, while
|
|
457
|
+
* `staging`/`production` run the published npm-package image (gcp-bootstrap
|
|
458
|
+
* pulls/builds Dockerfile.npm keyed off the variant). So this collapses to the
|
|
459
|
+
* unknown-variant guard: a typo'd `--editor-variant` fails loud rather than
|
|
460
|
+
* silently launching the wrong (or no) image. */
|
|
376
461
|
function assertVariantProvisionable(variant) {
|
|
377
|
-
validateEditorVariant(variant);
|
|
378
|
-
if (variant !== "dev") {
|
|
379
|
-
throw new Error(
|
|
380
|
-
`Editor variant '${variant}' is not yet wired for cloud provisioning ` +
|
|
381
|
-
`(Phase 2 — the @staging/production npm-install image). Use --editor-variant dev.`,
|
|
382
|
-
);
|
|
383
|
-
}
|
|
384
|
-
return variant;
|
|
462
|
+
return validateEditorVariant(variant);
|
|
385
463
|
}
|
|
386
464
|
|
|
387
|
-
/** Args for scripts/gcp-bootstrap.sh, which
|
|
388
|
-
* image
|
|
465
|
+
/** Args for scripts/gcp-bootstrap.sh, which produces the codeyam-editor:local
|
|
466
|
+
* image. For the `dev` variant that image is built from the EDITOR source
|
|
467
|
+
* (--repo/--branch); for packaged variants (staging/production) it comes from
|
|
468
|
+
* the published npm package, so the editor source repo/branch is irrelevant and
|
|
469
|
+
* omitted — gcp-bootstrap pulls/builds Dockerfile.npm keyed off --editor-variant.
|
|
470
|
+
* The CLIENT project repo/branch is unaffected: it flows through cloud:up's own
|
|
471
|
+
* project bootstrap, not this function. */
|
|
389
472
|
function editorImageBootstrapArgs(cfg) {
|
|
390
|
-
const { editorRepo, editorBranch } = resolveEditorConfig(cfg);
|
|
391
|
-
const args = ["--agent", cfg.agent, "--
|
|
392
|
-
|
|
473
|
+
const { editorRepo, editorBranch, editorVariant } = resolveEditorConfig(cfg);
|
|
474
|
+
const args = ["--agent", cfg.agent, "--editor-variant", editorVariant];
|
|
475
|
+
// Stack assumption: only the `dev` variant builds the editor image from source,
|
|
476
|
+
// so only it needs the editor repo/branch plumbing gcp-bootstrap's source path
|
|
477
|
+
// consumes. Packaged variants install the binary from the published npm package.
|
|
478
|
+
if (editorVariant === "dev") {
|
|
479
|
+
args.push("--repo", editorRepo);
|
|
480
|
+
if (editorBranch) args.push("--branch", editorBranch);
|
|
481
|
+
}
|
|
393
482
|
return args;
|
|
394
483
|
}
|
|
395
484
|
|
|
@@ -468,14 +557,26 @@ function saveCloudConfig(projectDir, cfg) {
|
|
|
468
557
|
}
|
|
469
558
|
|
|
470
559
|
/** Build the JSON patch to merge into the VM's `editor.local.json` so the
|
|
471
|
-
* configured agent
|
|
472
|
-
*
|
|
473
|
-
*
|
|
560
|
+
* configured agent becomes the editor's build-agent provider AND (when the
|
|
561
|
+
* key is available) picks up the user's local API key on first launch. The
|
|
562
|
+
* top-level `provider` field (lowercase — matching `BuildAgentProvider`'s
|
|
563
|
+
* serde) makes the VM's editor default to the chosen agent instead of Claude;
|
|
564
|
+
* `editor.local.json` is deep-merged over `editor.json` on the VM, so this
|
|
565
|
+
* overrides the packaged default.
|
|
566
|
+
*
|
|
567
|
+
* The patch ALWAYS carries `provider` — the agent is a launch *choice* that
|
|
568
|
+
* must persist on the VM regardless of whether the operator happens to have
|
|
569
|
+
* the agent's API key exported locally (Gemini, for instance, is normally
|
|
570
|
+
* authenticated interactively on the VM). `env.<KEY>` is the separate,
|
|
571
|
+
* opportunistic seed: it's included only when the matching key is set in
|
|
572
|
+
* `localEnv`. The function never returns `null`. Writing `provider: "claude"`
|
|
573
|
+
* when claude is the default deep-merges to a no-op, so the provider can be
|
|
574
|
+
* written unconditionally without special-casing the default. */
|
|
474
575
|
function buildEditorLocalEnvPatch(localEnv, agent) {
|
|
475
576
|
const varName = agentEnvVarName(agent);
|
|
476
577
|
const value = localEnv[varName];
|
|
477
|
-
if (!value) return
|
|
478
|
-
return { env: { [varName]: value } };
|
|
578
|
+
if (!value) return { provider: agent };
|
|
579
|
+
return { provider: agent, env: { [varName]: value } };
|
|
479
580
|
}
|
|
480
581
|
|
|
481
582
|
/** Parse `docker compose ps --format json` (compose v2 — one JSON object per
|
|
@@ -568,10 +669,19 @@ function buildGcloudArgs(verb, cfg = {}, extras = {}) {
|
|
|
568
669
|
// shell-quoting through gcloud → ssh → remote bash. The script body
|
|
569
670
|
// is delivered as one argument; the wrapper feeds the base64 into
|
|
570
671
|
// `extras.command` and the caller sets `input` to the script body.
|
|
672
|
+
//
|
|
673
|
+
// Keepalive flags are defense-in-depth for the long-running bootstrap
|
|
674
|
+
// follow session: a quiet stretch during `cargo build` (no stdout for
|
|
675
|
+
// minutes) must not trip an idle disconnect. ServerAliveInterval pings
|
|
676
|
+
// the VM every 30s and ServerAliveCountMax tolerates 6 missed replies
|
|
677
|
+
// (~3 min) before giving up, while TCPKeepAlive keeps NAT mappings warm.
|
|
678
|
+
// The detached server-side runner (bootstrapLaunchCommand) makes a real
|
|
679
|
+
// drop non-fatal; keepalive just makes spurious drops rarer.
|
|
571
680
|
return [
|
|
572
681
|
"compute", "ssh", instanceName,
|
|
573
682
|
"--project", project, "--zone", zone,
|
|
574
683
|
"--tunnel-through-iap",
|
|
684
|
+
...SSH_KEEPALIVE_FLAGS,
|
|
575
685
|
"--command", extras.command,
|
|
576
686
|
];
|
|
577
687
|
case "ssh-tunnel": {
|
|
@@ -619,6 +729,72 @@ function runGcloud(args, opts = {}) {
|
|
|
619
729
|
});
|
|
620
730
|
}
|
|
621
731
|
|
|
732
|
+
// Build a bounded, de-noised failure detail from a runGcloud (spawnSync)
|
|
733
|
+
// result: prefer stderr, fall back to stdout, and surface a spawn-level error
|
|
734
|
+
// (e.g. ENOENT when gcloud is not on PATH) when there is no captured output.
|
|
735
|
+
// Trims the known gcloud noise lines — the same `/WARNING|NumPy|google.com/iap/`
|
|
736
|
+
// filter the dashboard's tailLog applies — and keeps the last ~15 lines so the
|
|
737
|
+
// actionable message is not buried under auth/IAP warnings. Pure: result in,
|
|
738
|
+
// string out, so it is unit-testable without spawning gcloud.
|
|
739
|
+
function formatGcloudFailure(result) {
|
|
740
|
+
const denoise = (text) =>
|
|
741
|
+
String(text || "")
|
|
742
|
+
.split("\n")
|
|
743
|
+
.map((l) => l.replace(/\r$/, ""))
|
|
744
|
+
.filter((l) => l.trim() !== "")
|
|
745
|
+
.filter((l) => !/WARNING|NumPy|google\.com\/iap/.test(l))
|
|
746
|
+
.slice(-15)
|
|
747
|
+
.join("\n");
|
|
748
|
+
|
|
749
|
+
const stderr = denoise(result && result.stderr);
|
|
750
|
+
if (stderr) return stderr;
|
|
751
|
+
const stdout = denoise(result && result.stdout);
|
|
752
|
+
if (stdout) return stdout;
|
|
753
|
+
if (result && result.error && result.error.message) {
|
|
754
|
+
return result.error.message;
|
|
755
|
+
}
|
|
756
|
+
return "(no output captured from gcloud)";
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
// A bounded, pure classifier that keys on status === 255 (gcloud-ssh's exit
|
|
760
|
+
// for an ssh failure) and a signature match against known transient strings.
|
|
761
|
+
function isTransientSshFailure(result) {
|
|
762
|
+
if (!result || result.status !== 255) {
|
|
763
|
+
return false;
|
|
764
|
+
}
|
|
765
|
+
const output = String(result.stderr || "") + String(result.stdout || "");
|
|
766
|
+
const transientRegex = /kex_exchange_identification|Connection closed|failed to connect to backend|Failed to connect to port 22|Connection refused|Connection reset|Connection timed out|4003|4047/i;
|
|
767
|
+
return transientRegex.test(output);
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
// A retry wrapper for gcloud ssh-script calls that handles transient connection drops
|
|
771
|
+
// with exponential backoff, mimicking the IAP ready backoff.
|
|
772
|
+
async function runGcloudSshWithRetry(verb, cfg, extras, { stdio, retries = 5, label, sleepFn } = {}) {
|
|
773
|
+
let lastResult;
|
|
774
|
+
for (let attempt = 1; attempt <= retries; attempt++) {
|
|
775
|
+
const result = runGcloud(buildGcloudArgs(verb, cfg, extras), { stdio });
|
|
776
|
+
if (result.status === 0) {
|
|
777
|
+
return result;
|
|
778
|
+
}
|
|
779
|
+
lastResult = result;
|
|
780
|
+
if (isTransientSshFailure(result)) {
|
|
781
|
+
if (attempt < retries) {
|
|
782
|
+
const delay = Math.min(2000 * Math.pow(1.5, attempt - 1), 8000);
|
|
783
|
+
console.error(` ↺ transient SSH failure (attempt ${attempt}/${retries}) [${label || verb}] ... retrying in ${delay / 1000}s`);
|
|
784
|
+
if (sleepFn) {
|
|
785
|
+
await sleepFn(delay);
|
|
786
|
+
} else {
|
|
787
|
+
await new Promise((r) => setTimeout(r, delay));
|
|
788
|
+
}
|
|
789
|
+
continue;
|
|
790
|
+
}
|
|
791
|
+
}
|
|
792
|
+
// For non-transient failure, return immediately, or if we exhausted our attempts.
|
|
793
|
+
return result;
|
|
794
|
+
}
|
|
795
|
+
return lastResult;
|
|
796
|
+
}
|
|
797
|
+
|
|
622
798
|
function ensureGcloud() {
|
|
623
799
|
const result = spawnSync("gcloud", ["version"], { stdio: "ignore" });
|
|
624
800
|
if (result.error || result.status !== 0) {
|
|
@@ -718,9 +894,11 @@ async function ensureFirewallRule(cfg) {
|
|
|
718
894
|
async function ensureInstance(cfg) {
|
|
719
895
|
console.error(`Checking instance ${cfg.instanceName}...`);
|
|
720
896
|
const initialState = fetchInstanceState(cfg);
|
|
897
|
+
const createdThisRun = initialState === null;
|
|
898
|
+
|
|
721
899
|
if (initialState === "RUNNING") {
|
|
722
900
|
console.error(` ✓ ${cfg.instanceName} is RUNNING`);
|
|
723
|
-
return;
|
|
901
|
+
return { createdThisRun };
|
|
724
902
|
}
|
|
725
903
|
if (initialState === "TERMINATED") {
|
|
726
904
|
console.error(` ${cfg.instanceName} is TERMINATED — starting...`);
|
|
@@ -742,7 +920,7 @@ async function ensureInstance(cfg) {
|
|
|
742
920
|
if (fetchInstanceState(cfg) === "RUNNING") {
|
|
743
921
|
console.error(` ✓ ${cfg.instanceName} is RUNNING`);
|
|
744
922
|
await waitForIapReady(cfg);
|
|
745
|
-
return;
|
|
923
|
+
return { createdThisRun };
|
|
746
924
|
}
|
|
747
925
|
await new Promise((r) => setTimeout(r, 2000));
|
|
748
926
|
}
|
|
@@ -807,23 +985,198 @@ function resolveSetupForwardEnv(env = process.env) {
|
|
|
807
985
|
return out;
|
|
808
986
|
}
|
|
809
987
|
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
988
|
+
// --ssh-flag values gcloud forwards verbatim to the underlying `ssh`. Shaped as
|
|
989
|
+
// `-o Key=Value` so they survive the gcloud → ssh hop unchanged. Used by the
|
|
990
|
+
// ssh-script verb (the long bootstrap follow session) — short ssh-probe calls
|
|
991
|
+
// don't need them. See the ssh-script case in buildGcloudArgs for the rationale.
|
|
992
|
+
const SSH_KEEPALIVE_FLAGS = [
|
|
993
|
+
"--ssh-flag=-o ServerAliveInterval=30",
|
|
994
|
+
"--ssh-flag=-o ServerAliveCountMax=6",
|
|
995
|
+
"--ssh-flag=-o TCPKeepAlive=yes",
|
|
996
|
+
];
|
|
997
|
+
|
|
998
|
+
// Canonical VM-side paths for the detached bootstrap. The build runs from
|
|
999
|
+
// BOOTSTRAP_RUNNER_PATH (a tiny wrapper that execs the real script), streams to
|
|
1000
|
+
// BOOTSTRAP_LOG_PATH, and writes its exit status into BOOTSTRAP_DONE_PATH on
|
|
1001
|
+
// completion. Completion + success are derived from these files (plus the
|
|
1002
|
+
// docker image), NOT from the SSH exit code — so a dropped follow-session over
|
|
1003
|
+
// a healthy VM is no longer misreported as "failed on the VM".
|
|
1004
|
+
const BOOTSTRAP_SCRIPT_PATH = "$HOME/.codeyam-bootstrap.sh";
|
|
1005
|
+
const BOOTSTRAP_RUNNER_PATH = "$HOME/.codeyam-bootstrap-run.sh";
|
|
1006
|
+
const BOOTSTRAP_LOG_PATH = "$HOME/.codeyam-bootstrap.log";
|
|
1007
|
+
const BOOTSTRAP_DONE_PATH = "$HOME/.codeyam-bootstrap.done";
|
|
1008
|
+
const BOOTSTRAP_PID_PATH = "$HOME/.codeyam-bootstrap.pid";
|
|
1009
|
+
const BOOTSTRAP_LAUNCHED_MARKER = "CODEYAM_BOOTSTRAP_LAUNCHED";
|
|
1010
|
+
const BOOTSTRAP_STATUS_MARKER = "CYBOOT";
|
|
1011
|
+
// improve40 — VM-side path of the PROJECT-bootstrap terminal completion marker.
|
|
1012
|
+
// gcp-project-bootstrap.sh writes this as its very last step (only reached after
|
|
1013
|
+
// `set -euo pipefail` carried every provisioning step to success), and
|
|
1014
|
+
// ensureProjectBootstrapped's done-probe gates on it so a half-finished
|
|
1015
|
+
// bootstrap is correctly re-run instead of false-skipped (the VM-7 incident).
|
|
1016
|
+
// Lives outside the project's git tree (a $HOME dotfile, keyed by repo name) so
|
|
1017
|
+
// it never churns the repo. Pure so the bash writer and the JS probe derive the
|
|
1018
|
+
// EXACT same path; tested without a live VM.
|
|
1019
|
+
function projectBootstrapDoneMarker(repoName) {
|
|
1020
|
+
return `$HOME/.codeyam-project-bootstrap-${repoName}.done`;
|
|
1021
|
+
}
|
|
1022
|
+
// improve40 — the VM-side shell `test` chain ensureProjectBootstrapped runs to
|
|
1023
|
+
// decide "is this project already bootstrapped?". Gates on the terminal `.done`
|
|
1024
|
+
// marker FIRST (the VM-7 false-skip fix — both structural artifacts can exist on
|
|
1025
|
+
// a half-done bootstrap), then keeps the `.codeyam/` + docker-compose.cloud.yml
|
|
1026
|
+
// checks as belt-and-suspenders for a legacy VM whose marker predates this change.
|
|
1027
|
+
// Pure (just the command string) so the gating is unit-testable without gcloud.
|
|
1028
|
+
function projectBootstrapDoneProbeCommand(repoName) {
|
|
1029
|
+
return (
|
|
1030
|
+
`test -f ${projectBootstrapDoneMarker(repoName)} ` +
|
|
1031
|
+
`&& test -d ~/projects/${repoName}/.codeyam ` +
|
|
1032
|
+
`&& test -f ~/projects/${repoName}/docker-compose.cloud.yml`
|
|
1033
|
+
);
|
|
1034
|
+
}
|
|
1035
|
+
// One follow session caps at 30 min (comfortably over the ~14-min source
|
|
1036
|
+
// build); the overall wait caps at 40 min across re-attaches before we bail
|
|
1037
|
+
// loudly with the VM-side log location.
|
|
1038
|
+
const BOOTSTRAP_FOLLOW_SECS = 1800;
|
|
1039
|
+
const BOOTSTRAP_DEADLINE_MS = 40 * 60 * 1000;
|
|
1040
|
+
|
|
1041
|
+
// Quote a shell value the same way scriptArgs are quoted: single-quote it and
|
|
1042
|
+
// escape any embedded apostrophe. Shared by the env-prefix and arg encoders so
|
|
1043
|
+
// a token with an exotic character can't break out of its quoting.
|
|
1044
|
+
function shQuote(v) {
|
|
1045
|
+
return `'${String(v).replace(/'/g, "'\\''")}'`;
|
|
1046
|
+
}
|
|
1047
|
+
|
|
1048
|
+
// Encode env vars as a shell-assignment PREFIX (`KEY='value' …`). Assignment
|
|
1049
|
+
// prefixes set the variable in the launched command's environment only — never
|
|
1050
|
+
// in its argv — so tokens stay out of `ps` on the VM. Empty / nullish values
|
|
1051
|
+
// are dropped so the no-token path is unchanged.
|
|
1052
|
+
function envAssignmentPrefix(envVars) {
|
|
1053
|
+
const s = Object.entries(envVars)
|
|
819
1054
|
.filter(([, v]) => v !== undefined && v !== null && v !== "")
|
|
820
|
-
.map(([k, v]) => `${k}
|
|
1055
|
+
.map(([k, v]) => `${k}=${shQuote(v)}`)
|
|
821
1056
|
.join(" ");
|
|
822
|
-
|
|
1057
|
+
return s ? `${s} ` : "";
|
|
1058
|
+
}
|
|
1059
|
+
|
|
1060
|
+
// The tiny VM-side wrapper the detached launch execs. It runs the real
|
|
1061
|
+
// bootstrap, redirects all output to the log, and records the exit status in
|
|
1062
|
+
// the sentinel — the single source of truth for "did the build finish, and
|
|
1063
|
+
// did it succeed?" once the SSH follow-session is gone. Contains NO secrets
|
|
1064
|
+
// (tokens arrive via the launch env prefix, never written to disk).
|
|
1065
|
+
function bootstrapRunnerScript() {
|
|
1066
|
+
return [
|
|
1067
|
+
"#!/usr/bin/env bash",
|
|
1068
|
+
"# Detached bootstrap runner (written by npm/cloud.js ensureBootstrapped).",
|
|
1069
|
+
"# Survives SSH disconnect: the laptop only *follows* the log below; this",
|
|
1070
|
+
"# process keeps running under its own session if the follow drops.",
|
|
1071
|
+
"# Records its own PID so the status probe can check liveness via `kill -0`",
|
|
1072
|
+
"# (a `pgrep` on the script path would also match the probe command itself).",
|
|
1073
|
+
`echo "$$" > "$HOME/.codeyam-bootstrap.pid"`,
|
|
1074
|
+
`bash "$HOME/.codeyam-bootstrap.sh" "$@" > "$HOME/.codeyam-bootstrap.log" 2>&1`,
|
|
1075
|
+
"status=$?",
|
|
1076
|
+
`echo "$status" > "$HOME/.codeyam-bootstrap.done"`,
|
|
1077
|
+
`rm -f "$HOME/.codeyam-bootstrap.pid"`,
|
|
1078
|
+
"",
|
|
1079
|
+
].join("\n");
|
|
1080
|
+
}
|
|
1081
|
+
|
|
1082
|
+
// Build the remote command that (1) materializes the bootstrap script + runner
|
|
1083
|
+
// on the VM, (2) clears any stale sentinel, (3) launches the runner DETACHED
|
|
1084
|
+
// (`nohup setsid`, new session, stdio to /dev/null) so it outlives the SSH
|
|
1085
|
+
// session, and (4) prints a launched marker. The detach is the durable fix:
|
|
1086
|
+
// the build no longer dies when the laptop's SSH pipe breaks.
|
|
1087
|
+
function bootstrapLaunchCommand(scriptBody, scriptArgs = [], envVars = {}) {
|
|
1088
|
+
const scriptB64 = Buffer.from(scriptBody, "utf8").toString("base64");
|
|
1089
|
+
const runnerB64 = Buffer.from(bootstrapRunnerScript(), "utf8").toString("base64");
|
|
1090
|
+
const argsString = scriptArgs.map(shQuote).join(" ");
|
|
1091
|
+
const envPrefix = envAssignmentPrefix(envVars);
|
|
1092
|
+
// Writes run synchronously (fast, before any backgrounding) so a decode error
|
|
1093
|
+
// aborts before launch; then the runner is detached. The env prefix is on the
|
|
1094
|
+
// setsid command so the tokens are inherited through nohup→setsid→bash but
|
|
1095
|
+
// never appear in any argv. `& echo MARKER` (no `;` before echo) keeps the
|
|
1096
|
+
// launch in the background while still confirming it fired.
|
|
1097
|
+
const writes = [
|
|
1098
|
+
`echo '${scriptB64}' | base64 -d > ${BOOTSTRAP_SCRIPT_PATH}`,
|
|
1099
|
+
`echo '${runnerB64}' | base64 -d > ${BOOTSTRAP_RUNNER_PATH}`,
|
|
1100
|
+
`rm -f ${BOOTSTRAP_DONE_PATH}`,
|
|
1101
|
+
].join(" && ");
|
|
1102
|
+
const launch =
|
|
1103
|
+
`${envPrefix}nohup setsid bash ${BOOTSTRAP_RUNNER_PATH} ${argsString}`.trimEnd() +
|
|
1104
|
+
` </dev/null >/dev/null 2>&1 &`;
|
|
1105
|
+
return `${writes}; ${launch} echo ${BOOTSTRAP_LAUNCHED_MARKER}`;
|
|
1106
|
+
}
|
|
1107
|
+
|
|
1108
|
+
// Build the remote command that FOLLOWS the detached build: tail the log live
|
|
1109
|
+
// for the operator, polling for the sentinel, and exit when the build finishes
|
|
1110
|
+
// OR a per-session deadline elapses. If the SSH session drops mid-follow this
|
|
1111
|
+
// command dies but the detached build keeps going — the caller then re-attaches.
|
|
1112
|
+
function bootstrapFollowCommand(deadlineSecs = BOOTSTRAP_FOLLOW_SECS) {
|
|
1113
|
+
return [
|
|
1114
|
+
`touch ${BOOTSTRAP_LOG_PATH}`,
|
|
1115
|
+
`tail -n +1 -F ${BOOTSTRAP_LOG_PATH} 2>/dev/null & TP=$!`,
|
|
1116
|
+
`waited=0`,
|
|
1117
|
+
`while [ ! -f ${BOOTSTRAP_DONE_PATH} ]; do ` +
|
|
1118
|
+
`sleep 3; waited=$((waited+3)); ` +
|
|
1119
|
+
`if [ "$waited" -ge ${deadlineSecs} ]; then kill "$TP" 2>/dev/null; exit 0; fi; ` +
|
|
1120
|
+
`done`,
|
|
1121
|
+
`sleep 1`,
|
|
1122
|
+
`kill "$TP" 2>/dev/null`,
|
|
1123
|
+
].join("; ");
|
|
1124
|
+
}
|
|
1125
|
+
|
|
1126
|
+
// Build the remote command that reports terminal VM state in one parseable
|
|
1127
|
+
// line: whether the image exists, the sentinel's exit code (or `none`), and
|
|
1128
|
+
// whether a bootstrap process is still alive. Cheap — runs over ssh-probe.
|
|
1129
|
+
function bootstrapStatusCommand() {
|
|
1130
|
+
// Liveness via the runner's PID file + `kill -0`, NOT pgrep: the status
|
|
1131
|
+
// command's own argv contains the bootstrap path, so a `pgrep -f` on it would
|
|
1132
|
+
// match this very probe and always report running. A stale PID file from a
|
|
1133
|
+
// killed runner reads as not-running because `kill -0` fails.
|
|
1134
|
+
return [
|
|
1135
|
+
`img=absent; docker image inspect codeyam-editor:local >/dev/null 2>&1 && img=present`,
|
|
1136
|
+
`done=none; [ -f ${BOOTSTRAP_DONE_PATH} ] && done=$(cat ${BOOTSTRAP_DONE_PATH} 2>/dev/null)`,
|
|
1137
|
+
`run=no; if [ -f ${BOOTSTRAP_PID_PATH} ]; then p=$(cat ${BOOTSTRAP_PID_PATH} 2>/dev/null); ` +
|
|
1138
|
+
`{ [ -n "$p" ] && kill -0 "$p" 2>/dev/null; } && run=yes; fi`,
|
|
1139
|
+
`echo "${BOOTSTRAP_STATUS_MARKER} img=$img done=$done running=$run"`,
|
|
1140
|
+
].join("; ");
|
|
1141
|
+
}
|
|
1142
|
+
|
|
1143
|
+
// Parse the single status line emitted by bootstrapStatusCommand. Returns
|
|
1144
|
+
// `{ img, done, running }` or null when no status line is present (e.g. the
|
|
1145
|
+
// probe ssh itself failed and produced no stdout).
|
|
1146
|
+
function parseBootstrapStatus(stdout) {
|
|
1147
|
+
const m = String(stdout || "").match(
|
|
1148
|
+
new RegExp(`${BOOTSTRAP_STATUS_MARKER} img=(\\S+) done=(\\S+) running=(\\S+)`),
|
|
1149
|
+
);
|
|
1150
|
+
if (!m) return null;
|
|
1151
|
+
return { img: m[1], done: m[2], running: m[3] };
|
|
1152
|
+
}
|
|
1153
|
+
|
|
1154
|
+
// Decide the bootstrap outcome from VM-side state alone — never the SSH exit
|
|
1155
|
+
// code. The image is the real artifact, so its presence is success regardless
|
|
1156
|
+
// of how the follow session ended. A live process means "re-attach". A sentinel
|
|
1157
|
+
// without an image means the build ran and failed. Nothing at all means it was
|
|
1158
|
+
// never started (or the probe couldn't see it).
|
|
1159
|
+
function decideBootstrapOutcome(status) {
|
|
1160
|
+
if (!status) return "absent";
|
|
1161
|
+
const { img, done, running } = status;
|
|
1162
|
+
if (img === "present") return "success";
|
|
1163
|
+
if (running === "yes") return "running";
|
|
1164
|
+
if (done && done !== "none") return "failed";
|
|
1165
|
+
return "absent";
|
|
1166
|
+
}
|
|
1167
|
+
|
|
1168
|
+
function sshScriptCommand(scriptBody, scriptArgs = [], envVars = {}) {
|
|
1169
|
+
const b64 = Buffer.from(scriptBody, "utf8").toString("base64");
|
|
1170
|
+
// Args + env share the quoting/encoding helpers with bootstrapLaunchCommand:
|
|
1171
|
+
// single-quoted args (shQuote) and a `KEY='value'` assignment prefix
|
|
1172
|
+
// (envAssignmentPrefix) that sets the vars for the `bash -s` process only —
|
|
1173
|
+
// no persistence in the VM's shell history, no argv exposure of tokens.
|
|
1174
|
+
const argsString = scriptArgs.map(shQuote).join(" ");
|
|
1175
|
+
const envPrefix = envAssignmentPrefix(envVars);
|
|
823
1176
|
return `echo '${b64}' | base64 -d | ${envPrefix}bash -s -- ${argsString}`.trim();
|
|
824
1177
|
}
|
|
825
1178
|
|
|
826
|
-
async function ensureBootstrapped(cfg) {
|
|
1179
|
+
async function ensureBootstrapped(cfg, { sleepFn } = {}) {
|
|
827
1180
|
console.error("Checking VM bootstrap state...");
|
|
828
1181
|
const probe = runGcloud(
|
|
829
1182
|
buildGcloudArgs("ssh-probe", cfg, {
|
|
@@ -850,42 +1203,129 @@ async function ensureBootstrapped(cfg) {
|
|
|
850
1203
|
// wrapper / Dockerfile patches from a feature branch; without it the image
|
|
851
1204
|
// build uses the editor repo's default branch.
|
|
852
1205
|
const bootstrapArgs = editorImageBootstrapArgs(cfg);
|
|
853
|
-
//
|
|
854
|
-
//
|
|
855
|
-
//
|
|
856
|
-
//
|
|
857
|
-
//
|
|
858
|
-
//
|
|
859
|
-
|
|
1206
|
+
// Token threading into the bootstrap env (assignment prefix, never argv):
|
|
1207
|
+
// CODEYAM_GHCR_TOKEN — read:packages, for `docker login` + the PRIVATE
|
|
1208
|
+
// prebuilt base/binary PULLS. Without it every pull 403s and the
|
|
1209
|
+
// bootstrap slow-builds (~14 min) from source.
|
|
1210
|
+
// CODEYAM_GHCR_PUSH_TOKEN — write:packages, used ONLY by the post-compile
|
|
1211
|
+
// publish step's push. Distinct from the read token because a push needs
|
|
1212
|
+
// a strictly broader scope; reusing the read token guarantees a 403.
|
|
1213
|
+
// Absent → the bootstrap skips the publish cleanly (no doomed push).
|
|
1214
|
+
// CODEYAM_GHCR_USER / CODEYAM_GHCR_PUSH_USER — optional login-username
|
|
1215
|
+
// overrides. Empty values are dropped, so the no-registry path is
|
|
1216
|
+
// unchanged.
|
|
1217
|
+
const bootstrapEnv = {
|
|
860
1218
|
GITHUB_TOKEN: resolveGithubToken(),
|
|
861
1219
|
CODEYAM_GHCR_TOKEN: resolveGhcrToken(),
|
|
862
1220
|
CODEYAM_GHCR_USER: process.env.CODEYAM_GHCR_USER || "",
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
1221
|
+
CODEYAM_GHCR_PUSH_TOKEN: resolveGhcrPushToken(),
|
|
1222
|
+
CODEYAM_GHCR_PUSH_USER: process.env.CODEYAM_GHCR_PUSH_USER || "",
|
|
1223
|
+
};
|
|
1224
|
+
|
|
1225
|
+
// Idempotent re-attach: a prior cloud:up whose follow-session dropped may
|
|
1226
|
+
// have left a detached build still running (or already finished). Converge
|
|
1227
|
+
// on it instead of starting over.
|
|
1228
|
+
const initial = probeBootstrapOutcome(cfg);
|
|
1229
|
+
if (initial === "success") {
|
|
1230
|
+
console.error(" ✓ a previous detached bootstrap already completed");
|
|
1231
|
+
return;
|
|
1232
|
+
}
|
|
1233
|
+
if (initial === "running") {
|
|
1234
|
+
console.error(" ↺ a detached bootstrap is already in progress — re-attaching to its log...");
|
|
1235
|
+
} else {
|
|
1236
|
+
// "absent" (never started) OR "failed" (a prior run died) → (re)launch a
|
|
1237
|
+
// fresh detached build that will survive an SSH disconnect.
|
|
1238
|
+
console.error(" launching detached bootstrap on the VM (survives SSH disconnect; ~1-14 min)...");
|
|
1239
|
+
const launch = bootstrapLaunchCommand(scriptBody, bootstrapArgs, bootstrapEnv);
|
|
1240
|
+
const launched = await runGcloudSshWithRetry(
|
|
1241
|
+
"ssh-script",
|
|
1242
|
+
cfg,
|
|
1243
|
+
{ command: launch },
|
|
1244
|
+
{ stdio: ["ignore", "pipe", "pipe"], label: "bootstrap launch", sleepFn }
|
|
1245
|
+
);
|
|
1246
|
+
if (launched.status !== 0) {
|
|
1247
|
+
const detail = formatGcloudFailure(launched);
|
|
1248
|
+
throw new Error(
|
|
1249
|
+
`failed to launch the detached bootstrap on the VM (gcloud ssh exit ${launched.status}).\n${detail}`,
|
|
1250
|
+
);
|
|
1251
|
+
}
|
|
1252
|
+
}
|
|
1253
|
+
|
|
1254
|
+
// Follow the live log, re-attaching across any follow-session drops, until
|
|
1255
|
+
// the VM reports a terminal state or the overall deadline elapses. Success
|
|
1256
|
+
// and failure are read from VM state (image + sentinel), never the SSH exit
|
|
1257
|
+
// code — so "my laptop's SSH dropped" no longer masquerades as a build error.
|
|
1258
|
+
const deadline = Date.now() + BOOTSTRAP_DEADLINE_MS;
|
|
1259
|
+
for (;;) {
|
|
1260
|
+
runGcloud(
|
|
1261
|
+
buildGcloudArgs("ssh-script", cfg, { command: bootstrapFollowCommand() }),
|
|
1262
|
+
{ stdio: "inherit" },
|
|
1263
|
+
);
|
|
1264
|
+
const outcome = probeBootstrapOutcome(cfg);
|
|
1265
|
+
if (outcome === "success") {
|
|
1266
|
+
console.error(" ✓ VM bootstrap complete");
|
|
1267
|
+
return;
|
|
1268
|
+
}
|
|
1269
|
+
if (outcome === "failed") {
|
|
1270
|
+
const tail = bootstrapLogTail(cfg);
|
|
1271
|
+
throw new Error(
|
|
1272
|
+
"scripts/gcp-bootstrap.sh failed on the VM. " +
|
|
1273
|
+
`Last lines of ~/.codeyam-bootstrap.log:\n${tail}`,
|
|
1274
|
+
);
|
|
1275
|
+
}
|
|
1276
|
+
if (Date.now() >= deadline) {
|
|
1277
|
+
throw new Error(
|
|
1278
|
+
"Timed out waiting for the VM bootstrap. It may still be running — " +
|
|
1279
|
+
"re-run cloud:up to re-attach, or inspect ~/.codeyam-bootstrap.log on the VM.",
|
|
1280
|
+
);
|
|
1281
|
+
}
|
|
1282
|
+
// outcome === "running": the follow session ended but the build is alive.
|
|
1283
|
+
console.error(" ↺ follow session ended but the build is still running — re-attaching...");
|
|
870
1284
|
}
|
|
871
1285
|
}
|
|
872
1286
|
|
|
1287
|
+
// Probe the VM for the detached bootstrap's terminal state and reduce it to a
|
|
1288
|
+
// single outcome string ("success" | "failed" | "running" | "absent"). Used
|
|
1289
|
+
// both for the idempotent re-attach decision and after each follow session.
|
|
1290
|
+
function probeBootstrapOutcome(cfg) {
|
|
1291
|
+
const r = runGcloud(
|
|
1292
|
+
buildGcloudArgs("ssh-probe", cfg, { command: bootstrapStatusCommand() }),
|
|
1293
|
+
{ stdio: ["ignore", "pipe", "pipe"] },
|
|
1294
|
+
);
|
|
1295
|
+
return decideBootstrapOutcome(parseBootstrapStatus(r.stdout));
|
|
1296
|
+
}
|
|
1297
|
+
|
|
1298
|
+
// Fetch the tail of the VM-side bootstrap log for an actionable failure
|
|
1299
|
+
// message — the operator sees WHY the build failed, not a bare exit code.
|
|
1300
|
+
function bootstrapLogTail(cfg, lines = 40) {
|
|
1301
|
+
const r = runGcloud(
|
|
1302
|
+
buildGcloudArgs("ssh-probe", cfg, {
|
|
1303
|
+
command: `tail -n ${lines} ${BOOTSTRAP_LOG_PATH} 2>/dev/null`,
|
|
1304
|
+
}),
|
|
1305
|
+
{ stdio: ["ignore", "pipe", "pipe"] },
|
|
1306
|
+
);
|
|
1307
|
+
return String(r.stdout || "").trim() || "(log unavailable)";
|
|
1308
|
+
}
|
|
1309
|
+
|
|
873
1310
|
async function ensureProjectBootstrapped(cfg) {
|
|
874
1311
|
const repoName = resolveProject(cfg).name;
|
|
875
1312
|
console.error(`Checking project bootstrap state for ${repoName}...`);
|
|
876
|
-
//
|
|
877
|
-
//
|
|
878
|
-
//
|
|
879
|
-
//
|
|
880
|
-
//
|
|
881
|
-
//
|
|
882
|
-
// The
|
|
883
|
-
// the
|
|
1313
|
+
// Gate on the TERMINAL completion marker the script writes as its very last
|
|
1314
|
+
// step (scripts/gcp-project-bootstrap.sh → ~/.codeyam-project-bootstrap-<repo>.done),
|
|
1315
|
+
// not on intermediate structural artifacts. Earlier this probed only `.codeyam/`
|
|
1316
|
+
// + `docker-compose.cloud.yml` — but BOTH can exist while the bootstrap is
|
|
1317
|
+
// still incomplete (init scaffolds `.codeyam/` and the compose render happens
|
|
1318
|
+
// mid-script, before the creds/claude-dir setup and `npm install`/`setup`
|
|
1319
|
+
// finish). The improve40 VM-7 incident hit exactly this: a recovery re-run saw
|
|
1320
|
+
// the two markers, declared "already bootstrapped", and SKIPPED the unfinished
|
|
1321
|
+
// work. The `.done` marker is written only after `set -euo pipefail` has
|
|
1322
|
+
// carried every step to success, so an interrupted bootstrap lacks it and is
|
|
1323
|
+
// correctly re-run (the re-run is idempotent — skip-on-exists per step). The
|
|
1324
|
+
// two structural checks stay as belt-and-suspenders for a legacy VM whose
|
|
1325
|
+
// marker predates this change but is otherwise wired.
|
|
884
1326
|
const probe = runGcloud(
|
|
885
1327
|
buildGcloudArgs("ssh-probe", cfg, {
|
|
886
|
-
command:
|
|
887
|
-
`test -d ~/projects/${repoName}/.codeyam ` +
|
|
888
|
-
`&& test -f ~/projects/${repoName}/docker-compose.cloud.yml`,
|
|
1328
|
+
command: projectBootstrapDoneProbeCommand(repoName),
|
|
889
1329
|
}),
|
|
890
1330
|
{ stdio: ["ignore", "pipe", "pipe"] },
|
|
891
1331
|
);
|
|
@@ -942,6 +1382,7 @@ async function ensureEditorContainerUp(cfg) {
|
|
|
942
1382
|
);
|
|
943
1383
|
if (probe.status === 0 && parseEditorComposeState(probe.stdout) === "running") {
|
|
944
1384
|
console.error(" ✓ editor container is already running");
|
|
1385
|
+
await ensureAgentCliInstalled(cfg);
|
|
945
1386
|
return;
|
|
946
1387
|
}
|
|
947
1388
|
console.error(" starting editor container (docker compose up -d)...");
|
|
@@ -954,6 +1395,34 @@ async function ensureEditorContainerUp(cfg) {
|
|
|
954
1395
|
if (up.status !== 0) {
|
|
955
1396
|
throw new Error("docker compose up -d failed on the VM");
|
|
956
1397
|
}
|
|
1398
|
+
await ensureAgentCliInstalled(cfg);
|
|
1399
|
+
}
|
|
1400
|
+
|
|
1401
|
+
// Hard gate: provisioning success REQUIRES a running editor container. Reuses
|
|
1402
|
+
// the exact compose-ps probe ensureEditorContainerUp uses, but as a TERMINAL
|
|
1403
|
+
// re-check rather than a precondition — `docker compose up -d` can exit 0 over
|
|
1404
|
+
// a container that then crash-loops or exits, and an upstream phase that never
|
|
1405
|
+
// ran leaves no /workspace at all. Either way `cmdUp` would otherwise report
|
|
1406
|
+
// success. This throws (non-zero exit) when the editor service isn't
|
|
1407
|
+
// `running`, converting a silent partial provision into a loud, actionable
|
|
1408
|
+
// failure the operator (and the dashboard's fail-fast-on-nonzero path) can act
|
|
1409
|
+
// on. The `|| true` keeps the probe's exit status 0 so the real signal is the
|
|
1410
|
+
// parsed compose state, not the SSH/cd exit code.
|
|
1411
|
+
async function assertEditorContainerRunning(cfg) {
|
|
1412
|
+
const repoName = resolveProject(cfg).name;
|
|
1413
|
+
const composeCmd = `docker compose -f docker-compose.cloud.yml`;
|
|
1414
|
+
const probe = runGcloud(
|
|
1415
|
+
buildGcloudArgs("ssh-probe", cfg, {
|
|
1416
|
+
command: `cd ~/projects/${repoName} 2>/dev/null && ${composeCmd} ps --format json 2>/dev/null || true`,
|
|
1417
|
+
}),
|
|
1418
|
+
{ stdio: ["ignore", "pipe", "ignore"] },
|
|
1419
|
+
);
|
|
1420
|
+
if (!(probe.status === 0 && parseEditorComposeState(probe.stdout) === "running")) {
|
|
1421
|
+
throw new Error(
|
|
1422
|
+
`provisioning incomplete: editor container is not running on ${cfg.instanceName} ` +
|
|
1423
|
+
`(no /workspace or container). Re-run: npm run cloud:up -- --instance-name ${cfg.instanceName}`,
|
|
1424
|
+
);
|
|
1425
|
+
}
|
|
957
1426
|
}
|
|
958
1427
|
|
|
959
1428
|
/** Read the laptop's git author identity (`user.name`, `user.email`) so
|
|
@@ -991,7 +1460,7 @@ function resolveGithubToken() {
|
|
|
991
1460
|
return "";
|
|
992
1461
|
}
|
|
993
1462
|
|
|
994
|
-
/** A `read:packages
|
|
1463
|
+
/** A PULL-ONLY (`read:packages`) token the VM bootstrap uses to `docker login
|
|
995
1464
|
* ghcr.io` and pull the PRIVATE prebuilt cloud-smoke base + branch-binary
|
|
996
1465
|
* packages. Prefer an explicit CODEYAM_GHCR_TOKEN (the operator's scoped
|
|
997
1466
|
* fleet secret); fall back to the same GitHub token already used for the
|
|
@@ -999,11 +1468,29 @@ function resolveGithubToken() {
|
|
|
999
1468
|
* in the common fleet setup. Empty string when neither is available — the
|
|
1000
1469
|
* bootstrap then skips login and the private pulls fall back to a source
|
|
1001
1470
|
* build (today's behavior, never worse). Threaded into gcp-bootstrap.sh via
|
|
1002
|
-
* the env channel in ensureBootstrapped; never passed as an argv.
|
|
1471
|
+
* the env channel in ensureBootstrapped; never passed as an argv.
|
|
1472
|
+
*
|
|
1473
|
+
* This token is NOT sufficient for the post-compile PUBLISH — a `docker push`
|
|
1474
|
+
* needs write:packages. Reusing it for the push is exactly the 403 that kept
|
|
1475
|
+
* publish-on-compile from ever priming the fast path. Use resolveGhcrPushToken
|
|
1476
|
+
* for that. */
|
|
1003
1477
|
function resolveGhcrToken() {
|
|
1004
1478
|
return process.env.CODEYAM_GHCR_TOKEN || resolveGithubToken();
|
|
1005
1479
|
}
|
|
1006
1480
|
|
|
1481
|
+
/** A WRITE-scoped (`write:packages`) token used ONLY for the post-compile
|
|
1482
|
+
* publish — `publish_compiled_binary`'s `docker push` of the just-built
|
|
1483
|
+
* binary. Deliberately has NO fallback to the read/clone token: a `docker
|
|
1484
|
+
* push` with a read-only token 403s, so silently reusing the read token would
|
|
1485
|
+
* reintroduce the exact failure that kept the fast path from priming. Returns
|
|
1486
|
+
* the empty string when CODEYAM_GHCR_PUSH_TOKEN is unset — the bootstrap then
|
|
1487
|
+
* SKIPS the publish loudly (no doomed push, no 403 spam) and the next VM
|
|
1488
|
+
* simply recompiles that commit. Threaded into gcp-bootstrap.sh via the env
|
|
1489
|
+
* channel in ensureBootstrapped; never passed as an argv. */
|
|
1490
|
+
function resolveGhcrPushToken() {
|
|
1491
|
+
return process.env.CODEYAM_GHCR_PUSH_TOKEN || "";
|
|
1492
|
+
}
|
|
1493
|
+
|
|
1007
1494
|
/** Write the laptop's GitHub token into ~/.codeyam-cloud-git-credentials on
|
|
1008
1495
|
* the VM so the cloud compose can mount it into /root/.git-credentials —
|
|
1009
1496
|
* without this, claude / codex / gemini inside the container can clone the
|
|
@@ -1121,37 +1608,76 @@ function buildEditorLocalSeedWriteCommand({ repoName, remotePath, b64 }) {
|
|
|
1121
1608
|
async function seedPreviewOrigin(cfg, appPort = VITE_PORT) {
|
|
1122
1609
|
const repoName = resolveProject(cfg).name;
|
|
1123
1610
|
const remotePath = `~/projects/${repoName}/.codeyam/editor.local.json`;
|
|
1124
|
-
const
|
|
1125
|
-
cfg.instanceName,
|
|
1126
|
-
readCloudflaredUrls(),
|
|
1127
|
-
appPort,
|
|
1128
|
-
);
|
|
1611
|
+
const stackPath = `~/projects/${repoName}/.codeyam/stack.json`;
|
|
1129
1612
|
|
|
1613
|
+
// One round-trip reads both the current editor.local.json (for the
|
|
1614
|
+
// skip-on-already-set guard) and stack.json (for the sameOriginSafe flag that
|
|
1615
|
+
// decides same-origin subpath vs dedicated origin). Mirrors readVmAppPort's
|
|
1616
|
+
// separator-delimited cat.
|
|
1130
1617
|
const read = runGcloud(
|
|
1131
1618
|
buildGcloudArgs("ssh-probe", cfg, {
|
|
1132
|
-
command:
|
|
1619
|
+
command:
|
|
1620
|
+
`cat ${remotePath} 2>/dev/null || echo '{}'; ` +
|
|
1621
|
+
`echo '<<<STACK>>>'; ` +
|
|
1622
|
+
`cat ${stackPath} 2>/dev/null || echo '{}'`,
|
|
1133
1623
|
}),
|
|
1134
1624
|
{ stdio: ["ignore", "pipe", "ignore"] },
|
|
1135
1625
|
);
|
|
1136
|
-
|
|
1137
|
-
if (read.status === 0) {
|
|
1626
|
+
const parseObj = (s) => {
|
|
1138
1627
|
try {
|
|
1139
|
-
const
|
|
1140
|
-
|
|
1141
|
-
current = parsed;
|
|
1142
|
-
}
|
|
1628
|
+
const v = JSON.parse(s);
|
|
1629
|
+
return v && typeof v === "object" && !Array.isArray(v) ? v : {};
|
|
1143
1630
|
} catch {
|
|
1144
1631
|
// Unparseable remote content is treated as `{}` — overwriting a
|
|
1145
1632
|
// corrupt local override file with a known-good one is the safer
|
|
1146
1633
|
// move than refusing to seed at all.
|
|
1634
|
+
return {};
|
|
1147
1635
|
}
|
|
1636
|
+
};
|
|
1637
|
+
let current = {};
|
|
1638
|
+
let sameOriginSafe = true;
|
|
1639
|
+
if (read.status === 0 && typeof read.stdout === "string") {
|
|
1640
|
+
const [localJson = "", stackJson = ""] = read.stdout.split("<<<STACK>>>");
|
|
1641
|
+
current = parseObj(localJson);
|
|
1642
|
+
sameOriginSafe = previewSameOriginSafeFromStack(parseObj(stackJson));
|
|
1148
1643
|
}
|
|
1149
|
-
|
|
1644
|
+
|
|
1645
|
+
const seed = resolveSeedPreviewOrigin(
|
|
1646
|
+
cfg.instanceName,
|
|
1647
|
+
readCloudflaredUrls(),
|
|
1648
|
+
appPort,
|
|
1649
|
+
process.env,
|
|
1650
|
+
sameOriginSafe,
|
|
1651
|
+
);
|
|
1652
|
+
|
|
1653
|
+
// Skip-on-already-set guard, EXCEPT for self-heal. A non-empty previewOrigin
|
|
1654
|
+
// is normally a user pin or a good prior seed and is left untouched. The one
|
|
1655
|
+
// exception is a ROTTED managed cloudflared quick-tunnel URL on a
|
|
1656
|
+
// same-origin-safe VM (the VM-1/2/6 leftover from before the improve38
|
|
1657
|
+
// same-origin migration): that frozen URL points at a dead rotated tunnel
|
|
1658
|
+
// (NXDOMAIN / blank preview), so we heal it back to the same-origin path
|
|
1659
|
+
// instead of preserving it. Because every dashboard action (Reset / Update /
|
|
1660
|
+
// Add / bootstrap-recovery) re-runs this seed over cloud:up, the heal reaches
|
|
1661
|
+
// every touch-point without a separate per-VM hand-fix.
|
|
1662
|
+
const originAlreadySet =
|
|
1663
|
+
typeof current.previewOrigin === "string" && current.previewOrigin.length > 0;
|
|
1664
|
+
const healing =
|
|
1665
|
+
originAlreadySet && isRottedManagedPreviewOrigin(current, sameOriginSafe);
|
|
1666
|
+
if (originAlreadySet && !healing) {
|
|
1150
1667
|
return;
|
|
1151
1668
|
}
|
|
1152
1669
|
|
|
1153
|
-
|
|
1154
|
-
|
|
1670
|
+
// On heal, also DROP the legacy `_managedPreviewOrigin` marker (deepMergeJson
|
|
1671
|
+
// deletes any key whose overlay value is null) — the same-origin path is a
|
|
1672
|
+
// constant the retired monitor push has nothing to manage, exactly matching
|
|
1673
|
+
// the manual `config-override --unset _managedPreviewOrigin` fix.
|
|
1674
|
+
const overlay = healing ? { ...seed, _managedPreviewOrigin: null } : seed;
|
|
1675
|
+
console.error(
|
|
1676
|
+
healing
|
|
1677
|
+
? `Healing rotted managed previewOrigin (${current.previewOrigin}) → ${seed.previewOrigin} in ${remotePath}...`
|
|
1678
|
+
: `Seeding previewOrigin=${seed.previewOrigin} into ${remotePath}...`,
|
|
1679
|
+
);
|
|
1680
|
+
const merged = deepMergeJson(current, overlay);
|
|
1155
1681
|
const mergedJson = JSON.stringify(merged, null, 2);
|
|
1156
1682
|
const b64 = Buffer.from(mergedJson, "utf8").toString("base64");
|
|
1157
1683
|
const write = runGcloud(
|
|
@@ -1165,20 +1691,35 @@ async function seedPreviewOrigin(cfg, appPort = VITE_PORT) {
|
|
|
1165
1691
|
}
|
|
1166
1692
|
}
|
|
1167
1693
|
|
|
1694
|
+
/** Write the chosen agent provider and local API keys into editor.local.json on
|
|
1695
|
+
* the VM BEFORE compose up so the file exists when the bind-mount happens —
|
|
1696
|
+
* the provider seed MUST run before ensureEditorContainerUp, because the editor
|
|
1697
|
+
* caches config.provider at startup and reports it as currentProvider; a post-start
|
|
1698
|
+
* seed leaves the running editor (and therefore the dashboard badge) on the default
|
|
1699
|
+
* "claude" until a config reload. */
|
|
1168
1700
|
async function seedAgentApiKey(cfg) {
|
|
1701
|
+
// The provider seed is unconditional — `buildEditorLocalEnvPatch` always
|
|
1702
|
+
// returns a patch carrying `provider`, so the chosen agent always lands on
|
|
1703
|
+
// the VM. The API-key seed is opportunistic: when the matching key isn't set
|
|
1704
|
+
// locally the patch carries `provider` only, and we still print the
|
|
1705
|
+
// interactive-auth fallback line so the operator knows to authenticate the
|
|
1706
|
+
// agent on the VM later (Gemini's normal path).
|
|
1169
1707
|
const patch = buildEditorLocalEnvPatch(process.env, cfg.agent);
|
|
1170
|
-
|
|
1708
|
+
const hasKey = Boolean(patch.env);
|
|
1709
|
+
if (!hasKey) {
|
|
1171
1710
|
console.error(
|
|
1172
|
-
`\nNo ${agentEnvVarName(cfg.agent)} set locally —
|
|
1711
|
+
`\nNo ${agentEnvVarName(cfg.agent)} set locally — seeding provider only.\n` +
|
|
1173
1712
|
`Authenticate ${cfg.agent} later with:\n` +
|
|
1174
1713
|
` gcloud compute ssh ${cfg.instanceName} --project ${cfg.project} --zone ${cfg.zone} ` +
|
|
1175
1714
|
`--tunnel-through-iap -- ${cfg.agent} auth login\n`,
|
|
1176
1715
|
);
|
|
1177
|
-
return;
|
|
1178
1716
|
}
|
|
1179
1717
|
const repoName = resolveProject(cfg).name;
|
|
1180
1718
|
const remotePath = `~/projects/${repoName}/.codeyam/editor.local.json`;
|
|
1181
|
-
|
|
1719
|
+
const seedLabel = hasKey
|
|
1720
|
+
? `Seeding ${agentEnvVarName(cfg.agent)} into ${remotePath}...`
|
|
1721
|
+
: `Seeding provider '${cfg.agent}' into ${remotePath}...`;
|
|
1722
|
+
console.error(seedLabel);
|
|
1182
1723
|
|
|
1183
1724
|
const read = runGcloud(
|
|
1184
1725
|
buildGcloudArgs("ssh-probe", cfg, {
|
|
@@ -1211,6 +1752,75 @@ async function seedAgentApiKey(cfg) {
|
|
|
1211
1752
|
if (write.status !== 0) {
|
|
1212
1753
|
throw new Error(`Failed to write ${remotePath} on VM`);
|
|
1213
1754
|
}
|
|
1755
|
+
|
|
1756
|
+
await ensureAgentCliInstalled(cfg);
|
|
1757
|
+
}
|
|
1758
|
+
|
|
1759
|
+
/** Install the selected agent's CLI inside the editor container on the live VM */
|
|
1760
|
+
async function ensureAgentCliInstalled(cfg) {
|
|
1761
|
+
const agent = cfg.agent;
|
|
1762
|
+
const pkg = agentNpmPackage(agent);
|
|
1763
|
+
const container = process.env.CONTAINER || "codeyam-editor-editor-1";
|
|
1764
|
+
|
|
1765
|
+
// First, check if the container is running on the VM host.
|
|
1766
|
+
// If the container is not running, we do nothing and return.
|
|
1767
|
+
const checkProbe = runGcloud(
|
|
1768
|
+
buildGcloudArgs("ssh-probe", cfg, {
|
|
1769
|
+
command: `docker ps --filter name=^/${container}$ --filter status=running --format '{{.Name}}'`,
|
|
1770
|
+
}),
|
|
1771
|
+
{ stdio: ["ignore", "pipe", "ignore"] },
|
|
1772
|
+
);
|
|
1773
|
+
|
|
1774
|
+
const isRunning = checkProbe.status === 0 && checkProbe.stdout.trim() !== "";
|
|
1775
|
+
if (!isRunning) {
|
|
1776
|
+
console.error(` Container ${container} is not running. Skipping build-agent CLI install for now.`);
|
|
1777
|
+
return;
|
|
1778
|
+
}
|
|
1779
|
+
|
|
1780
|
+
console.error(`Ensuring build-agent CLI for '${agent}' is installed in container '${container}'...`);
|
|
1781
|
+
|
|
1782
|
+
const containerScript = [
|
|
1783
|
+
"#!/usr/bin/env bash",
|
|
1784
|
+
"set -euo pipefail",
|
|
1785
|
+
`BINARY="${agent}"`,
|
|
1786
|
+
`PACKAGE="${pkg}"`,
|
|
1787
|
+
`AGENT="${agent}"`,
|
|
1788
|
+
"",
|
|
1789
|
+
"if command -v \"$BINARY\" >/dev/null 2>&1; then",
|
|
1790
|
+
" if [ \"$AGENT\" = \"claude\" ]; then",
|
|
1791
|
+
" if [ -x /usr/local/bin/claude ]; then",
|
|
1792
|
+
" echo \" ✓ $BINARY already installed and executable at /usr/local/bin/claude\"",
|
|
1793
|
+
" exit 0",
|
|
1794
|
+
" fi",
|
|
1795
|
+
" else",
|
|
1796
|
+
" echo \" ✓ $BINARY already installed on PATH\"",
|
|
1797
|
+
" exit 0",
|
|
1798
|
+
" fi",
|
|
1799
|
+
"fi",
|
|
1800
|
+
"",
|
|
1801
|
+
"echo \"Installing $PACKAGE globally in container...\"",
|
|
1802
|
+
"if [ \"$AGENT\" = \"claude\" ]; then",
|
|
1803
|
+
" rm -rf /usr/lib/node_modules/@anthropic-ai/.claude-code-*",
|
|
1804
|
+
" npm install -g \"$PACKAGE\"",
|
|
1805
|
+
" ln -sf /usr/lib/node_modules/@anthropic-ai/claude-code/node_modules/@anthropic-ai/claude-code-linux-x64/claude /usr/local/bin/claude",
|
|
1806
|
+
"else",
|
|
1807
|
+
" npm install -g \"$PACKAGE\"",
|
|
1808
|
+
"fi",
|
|
1809
|
+
"echo \" ✓ Successfully installed $BINARY\"",
|
|
1810
|
+
].join("\n");
|
|
1811
|
+
|
|
1812
|
+
const hostCommand = `docker exec -i ${container} bash -s <<'EOF'\n${containerScript}\nEOF`;
|
|
1813
|
+
|
|
1814
|
+
const installCmd = runGcloud(
|
|
1815
|
+
buildGcloudArgs("ssh-probe", cfg, {
|
|
1816
|
+
command: hostCommand,
|
|
1817
|
+
}),
|
|
1818
|
+
{ stdio: ["ignore", "inherit", "inherit"] }
|
|
1819
|
+
);
|
|
1820
|
+
|
|
1821
|
+
if (installCmd.status !== 0) {
|
|
1822
|
+
throw new Error(`Failed to install build-agent CLI in container for agent: ${agent}`);
|
|
1823
|
+
}
|
|
1214
1824
|
}
|
|
1215
1825
|
|
|
1216
1826
|
async function openTunnel(cfg, appPort = VITE_PORT) {
|
|
@@ -1268,24 +1878,55 @@ async function cmdUp(args) {
|
|
|
1268
1878
|
assertVariantProvisionable(resolveEditorConfig(cfg).editorVariant);
|
|
1269
1879
|
|
|
1270
1880
|
await ensureFirewallRule(cfg);
|
|
1271
|
-
await ensureInstance(cfg);
|
|
1272
|
-
|
|
1273
|
-
|
|
1274
|
-
|
|
1275
|
-
|
|
1276
|
-
|
|
1277
|
-
|
|
1278
|
-
|
|
1279
|
-
|
|
1280
|
-
|
|
1281
|
-
|
|
1282
|
-
|
|
1283
|
-
|
|
1284
|
-
|
|
1285
|
-
|
|
1286
|
-
|
|
1287
|
-
|
|
1288
|
-
|
|
1881
|
+
const { createdThisRun } = await ensureInstance(cfg);
|
|
1882
|
+
|
|
1883
|
+
try {
|
|
1884
|
+
await ensureBootstrapped(cfg);
|
|
1885
|
+
await ensureProjectBootstrapped(cfg);
|
|
1886
|
+
// Resolve the app's dev-server port from the VM's now-bootstrapped config so
|
|
1887
|
+
// the previewOrigin seed and the laptop tunnel both target it (not 5173).
|
|
1888
|
+
const appPort = await readVmAppPort(cfg);
|
|
1889
|
+
await seedPreviewOrigin(cfg, appPort);
|
|
1890
|
+
// Write the GitHub token BEFORE compose up so the credentials file exists
|
|
1891
|
+
// when the bind-mount happens — bind-mounting a missing file would silently
|
|
1892
|
+
// create a directory instead and the agent's first `git pull` would fail.
|
|
1893
|
+
await seedGithubCredentials(cfg);
|
|
1894
|
+
await seedAgentApiKey(cfg);
|
|
1895
|
+
await ensureEditorContainerUp(cfg);
|
|
1896
|
+
// Roster the VM into the fleet dashboard NOW that it's provisioned and the
|
|
1897
|
+
// editor container is up. Runs BEFORE the --no-tunnel early-return so the
|
|
1898
|
+
// dashboard's own `cloud:up --no-tunnel` Add path also rosters (idempotent
|
|
1899
|
+
// with the dashboard's onSuccess roster — a double-add is a harmless no-op).
|
|
1900
|
+
rosterProvisionedVm(cfg.instanceName);
|
|
1901
|
+
// Terminal gate: provisioning is NOT "done" until the editor container is
|
|
1902
|
+
// verifiably Up. Placed BEFORE the --no-tunnel early-return so the dashboard's
|
|
1903
|
+
// own `cloud:up --no-tunnel` Add path is covered too (it's the path that bit
|
|
1904
|
+
// us — a run reported success with no container). A failure here is a loud
|
|
1905
|
+
// non-zero exit, which the dashboard fail-fast-on-nonzero path acts on.
|
|
1906
|
+
await assertEditorContainerRunning(cfg);
|
|
1907
|
+
} catch (err) {
|
|
1908
|
+
if (createdThisRun) {
|
|
1909
|
+
console.error(
|
|
1910
|
+
`✗ provisioning aborted — tearing down the VM cloud:up created this run (no orphan left behind)`,
|
|
1911
|
+
);
|
|
1912
|
+
try {
|
|
1913
|
+
console.error(`Deleting instance ${cfg.instanceName}...`);
|
|
1914
|
+
const del = runGcloud(buildGcloudArgs("instances-delete", cfg));
|
|
1915
|
+
if (del.status !== 0) {
|
|
1916
|
+
console.error(
|
|
1917
|
+
` Warning: failed to delete instance ${cfg.instanceName} during abort teardown. ` +
|
|
1918
|
+
`Please delete it manually with 'npm run cloud:destroy'.`,
|
|
1919
|
+
);
|
|
1920
|
+
}
|
|
1921
|
+
} catch (delErr) {
|
|
1922
|
+
console.error(
|
|
1923
|
+
` Warning: error deleting instance ${cfg.instanceName} during abort teardown: ${delErr.message}. ` +
|
|
1924
|
+
`Please delete it manually with 'npm run cloud:destroy'.`,
|
|
1925
|
+
);
|
|
1926
|
+
}
|
|
1927
|
+
}
|
|
1928
|
+
throw err;
|
|
1929
|
+
}
|
|
1289
1930
|
// --no-tunnel: provisioning-only mode (the fleet dashboard opens its own
|
|
1290
1931
|
// per-VM tunnel). Skipping openTunnel lets cloud:up EXIT cleanly instead of
|
|
1291
1932
|
// hanging on a tunnel, and — critically for parallel multi-add — avoids N
|
|
@@ -1466,7 +2107,10 @@ async function cloudMain() {
|
|
|
1466
2107
|
module.exports = {
|
|
1467
2108
|
// pure helpers
|
|
1468
2109
|
agentEnvVarName,
|
|
2110
|
+
agentNpmPackage,
|
|
1469
2111
|
AGENT_ENV_VARS,
|
|
2112
|
+
AGENT_NPM_PACKAGES,
|
|
2113
|
+
ensureAgentCliInstalled,
|
|
1470
2114
|
KNOWN_FLAGS,
|
|
1471
2115
|
FIREWALL_RULE_NAME,
|
|
1472
2116
|
INSTANCE_TAG,
|
|
@@ -1485,12 +2129,15 @@ module.exports = {
|
|
|
1485
2129
|
rosterProvisionedVm,
|
|
1486
2130
|
resolveConfiguredAppPort,
|
|
1487
2131
|
resolveSeedPreviewOrigin,
|
|
2132
|
+
previewSameOriginSafeFromStack,
|
|
2133
|
+
isRottedManagedPreviewOrigin,
|
|
1488
2134
|
resolveEditorConfig,
|
|
1489
2135
|
validateEditorVariant,
|
|
1490
2136
|
assertVariantProvisionable,
|
|
1491
2137
|
editorImageBootstrapArgs,
|
|
1492
2138
|
reconcileProjectExclusivity,
|
|
1493
2139
|
resolveGhcrToken,
|
|
2140
|
+
resolveGhcrPushToken,
|
|
1494
2141
|
resolveProject,
|
|
1495
2142
|
clientProjectBootstrapArgs,
|
|
1496
2143
|
cloudConfigPath,
|
|
@@ -1498,12 +2145,28 @@ module.exports = {
|
|
|
1498
2145
|
saveCloudConfig,
|
|
1499
2146
|
buildEditorLocalEnvPatch,
|
|
1500
2147
|
buildEditorLocalSeedWriteCommand,
|
|
2148
|
+
seedPreviewOrigin,
|
|
1501
2149
|
buildGcloudArgs,
|
|
1502
2150
|
sshScriptCommand,
|
|
2151
|
+
bootstrapRunnerScript,
|
|
2152
|
+
bootstrapLaunchCommand,
|
|
2153
|
+
bootstrapFollowCommand,
|
|
2154
|
+
bootstrapStatusCommand,
|
|
2155
|
+
parseBootstrapStatus,
|
|
2156
|
+
decideBootstrapOutcome,
|
|
2157
|
+
projectBootstrapDoneMarker,
|
|
2158
|
+
projectBootstrapDoneProbeCommand,
|
|
2159
|
+
SSH_KEEPALIVE_FLAGS,
|
|
1503
2160
|
resolveSetupForwardEnv,
|
|
1504
2161
|
FORWARD_ENV_KEYS_ENV,
|
|
1505
2162
|
parseEditorComposeState,
|
|
1506
2163
|
fetchInstanceState,
|
|
2164
|
+
formatGcloudFailure,
|
|
2165
|
+
isTransientSshFailure,
|
|
2166
|
+
runGcloudSshWithRetry,
|
|
2167
|
+
ensureInstance,
|
|
2168
|
+
ensureBootstrapped,
|
|
2169
|
+
assertEditorContainerRunning,
|
|
1507
2170
|
// side-effecting handlers
|
|
1508
2171
|
cmdUp,
|
|
1509
2172
|
cmdTunnel,
|