@codeyam-editor/codeyam-editor 0.1.3 → 0.1.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (66) hide show
  1. package/npm/cloud-branch-switch.js +1 -1
  2. package/npm/cloud.js +1693 -96
  3. package/npm/container.js +14 -0
  4. package/npm/disk-pressure.js +167 -0
  5. package/npm/editor-dev.js +107 -15
  6. package/npm/editor.js +97 -37
  7. package/npm/fleet-auth-health.js +75 -0
  8. package/npm/fleet-auth.js +6 -1
  9. package/npm/fleet-build-data.js +130 -0
  10. package/npm/fleet-cleanup.js +145 -7
  11. package/npm/fleet-destroy-safety.js +55 -0
  12. package/npm/fleet-effect-status.js +33 -32
  13. package/npm/fleet-host.js +74 -0
  14. package/npm/fleet-job-badge.js +46 -2
  15. package/npm/fleet-launchd.js +171 -0
  16. package/npm/fleet-live-status.js +123 -0
  17. package/npm/fleet-plans.js +231 -0
  18. package/npm/fleet-prewarm.js +165 -0
  19. package/npm/fleet-public-url.js +160 -0
  20. package/npm/fleet-queue.js +38 -1
  21. package/npm/fleet-reconcile.js +100 -1
  22. package/npm/fleet-reset.js +298 -13
  23. package/npm/fleet-roster.js +6 -8
  24. package/npm/fleet-self-update.js +142 -0
  25. package/npm/fleet-setup-steps.js +105 -0
  26. package/npm/fleet-sleep-status.js +33 -0
  27. package/npm/fleet-strand.js +150 -0
  28. package/npm/fleet-stuck-status.js +111 -0
  29. package/npm/fleet-vm-access.js +234 -0
  30. package/npm/fleet-vm-readiness.js +104 -0
  31. package/npm/fleet-vm-slugs.js +116 -0
  32. package/npm/google-ingress.js +211 -0
  33. package/npm/ingress-invoke.js +292 -0
  34. package/npm/launch-config.js +310 -4
  35. package/npm/scenario-check.js +684 -33
  36. package/npm/scenario-handlers.js +94 -12
  37. package/npm/scenario-metrics.js +116 -1
  38. package/npm/scenario-mocks.js +20 -0
  39. package/npm/scenario-playwright.js +366 -29
  40. package/npm/script-drift.js +81 -0
  41. package/npm/utils.js +453 -43
  42. package/npm/vm-urls.js +69 -8
  43. package/package.json +5 -5
  44. package/ui/dist/assets/ContainerInspector-D7VZkkaj.js +2 -0
  45. package/ui/dist/assets/EditorShell-DHhIaotd.css +1 -0
  46. package/ui/dist/assets/EditorShell-r-796N_W.js +191 -0
  47. package/ui/dist/assets/{Markdown-BHBqz-Mj.js → Markdown-BoLHHFXH.js} +1 -1
  48. package/ui/dist/assets/ProjectLauncherScreen-Ck-XWkQN.js +1 -0
  49. package/ui/dist/assets/ScenarioDataPanel-B9iDSUpA.js +5 -0
  50. package/ui/dist/assets/ScenarioDataPanelFullPage-CBLTGC4-.js +1 -0
  51. package/ui/dist/assets/ScenarioRenderer-BhLfT-nX.js +21 -0
  52. package/ui/dist/assets/StepsSimulator-BFldWyoX.js +1 -0
  53. package/ui/dist/assets/index-CIXZkwfB.css +1 -0
  54. package/ui/dist/assets/{index-KDFAgt2e.js → index-QsOiOG9x.js} +9 -9
  55. package/ui/dist/assets/useEvents-DHkLJZR4.js +1 -0
  56. package/ui/dist/codeyam-mark.svg +4 -0
  57. package/ui/dist/codeyam-wordmark.svg +5 -0
  58. package/ui/dist/index.html +2 -2
  59. package/ui/dist/assets/ContainerInspector-C8EkH4PP.js +0 -2
  60. package/ui/dist/assets/EditorShell-CGsBSA6u.js +0 -186
  61. package/ui/dist/assets/ScenarioDataPanel-nuLXJzLf.js +0 -5
  62. package/ui/dist/assets/ScenarioDataPanelFullPage-Bnghtj8a.js +0 -1
  63. package/ui/dist/assets/ScenarioRenderer-B_Za9jL8.js +0 -1
  64. package/ui/dist/assets/StepsSimulator-CRtmlyNZ.js +0 -1
  65. package/ui/dist/assets/index-Bih6HJ_u.css +0 -1
  66. package/ui/dist/assets/useEvents-DggWrtHe.js +0 -1
package/npm/cloud.js CHANGED
@@ -11,6 +11,7 @@
11
11
 
12
12
  const { spawn, spawnSync } = require("child_process");
13
13
  const fs = require("fs");
14
+ const os = require("os");
14
15
  const path = require("path");
15
16
  const readline = require("readline");
16
17
  const {
@@ -33,12 +34,58 @@ const { FORWARD_ENV_KEYS_ENV } = require("./fleet-env");
33
34
  // stable `https://vm-<N>.preview.<domain>` previewOrigin from its number. When
34
35
  // no domain is configured these stay dormant and the legacy quick-tunnel seed
35
36
  // path runs unchanged.
36
- const { resolveFleetDomain, vmUrls } = require("./vm-urls");
37
- // Shared fleet-roster writer. `cloud:up` rosters the VM it just provisioned so
38
- // the dashboard, the cloudflared-monitor tunnel publisher, and the watchdog see
39
- // it within one poll cycle — without a manual fleet-vms.json edit (the
40
- // invisible-VM gap improve38 closes).
41
- const { addToRoster } = require("./fleet-roster");
37
+ const { resolveFleetDomain, vmUrls, normalizeVmSlug } = require("./vm-urls");
38
+ // Per-VM custom-slug sidecar (`.codeyam/logs/fleet-vm-slugs.json`). A slug is an
39
+ // optional human-readable override for a VM's leftmost hostname label; Add
40
+ // persists it (`setSlug`), Add/Destroy resolve it (`slugFor`) to route the same
41
+ // `<slug>.<surface>.<domain>` host, and Destroy (`cmdDestroy`) clears it
42
+ // unconditionally (`removeSlug`, decoupled from LB ingress teardown). Absent
43
+ // ⇒ the numeric `vm-<N>` scheme is unchanged.
44
+ const {
45
+ slugFor,
46
+ vmForSlug,
47
+ setSlug,
48
+ removeSlug,
49
+ } = require("./fleet-vm-slugs");
50
+ // Per-VM IAP access whitelist sidecar (`.codeyam/logs/fleet-vm-access.json`).
51
+ // Resource-level grants so a named collaborator can reach ONE VM's surfaces:
52
+ // Add *reconciles* the whitelist to exactly the requested list (`accessFor` +
53
+ // `setAccess`), revoking a reused number's stale grants for a clean slate; the
54
+ // edit CLI mutates the delta (`addAccess`/`removeAccess`); and Destroy revokes +
55
+ // clears (`accessFor` + `clearAccess`). Independent of the owner's project-wide grant.
56
+ const {
57
+ normalizeAccessList,
58
+ normalizePrincipal,
59
+ accessFor,
60
+ setAccess,
61
+ addAccess,
62
+ removeAccess,
63
+ clearAccess,
64
+ } = require("./fleet-vm-access");
65
+ // LB-ingress shell-invocation builders (pure; unit-tested in ingress-invoke.test.js).
66
+ // `resolveIngressDomain` reads the fleet domain from cloud.json (`fleetDomain`)
67
+ // with a FLEET_DOMAIN env fallback — null ⇒ legacy quick-tunnel mode, where the
68
+ // Add/Destroy path is unchanged. The *Invocation builders shape the exact
69
+ // `{ command, args, env }` cloud.js spawns for scripts/gcp/{google,vm}-ingress.sh.
70
+ const {
71
+ resolveIngressDomain,
72
+ resolveDnsZone,
73
+ regionFromZone,
74
+ vmIngressInvocation,
75
+ vmIngressStatusInvocation,
76
+ parseIngressStatusVerdict,
77
+ selectIngressReconcileTargets,
78
+ detachedAddTailInvocation,
79
+ ingressInflightMarkerPath,
80
+ iapAccessInvocation,
81
+ sharedIngressInvocation,
82
+ } = require("./ingress-invoke");
83
+ // Shared fleet-roster writer/reader. `cloud:up` rosters the VM it just
84
+ // provisioned so the dashboard, the cloudflared-monitor tunnel publisher, and
85
+ // the watchdog see it within one poll cycle — without a manual fleet-vms.json
86
+ // edit (the invisible-VM gap improve38 closes). `readRoster` enumerates the
87
+ // fleet for `reconcile-ingress`.
88
+ const { addToRoster, readRoster } = require("./fleet-roster");
42
89
 
43
90
  // =============================================================================
44
91
  // Pure helpers — every export here is unit-tested in cloud.test.js without
@@ -51,6 +98,12 @@ const AGENT_ENV_VARS = {
51
98
  gemini: "GEMINI_API_KEY",
52
99
  };
53
100
 
101
+ const AGENT_NPM_PACKAGES = {
102
+ claude: "@anthropic-ai/claude-code",
103
+ codex: "@openai/codex",
104
+ gemini: "@google/gemini-cli",
105
+ };
106
+
54
107
  const KNOWN_FLAGS = [
55
108
  "repo", "branch", "instance-name", "project", "zone", "agent",
56
109
  // Editor-source selection, decoupled from the client project (repo/branch):
@@ -67,11 +120,39 @@ const KNOWN_FLAGS = [
67
120
  // runs setup by default when the project ships one; pass this for projects
68
121
  // where a cloud setup is genuinely wrong (the script's --skip-setup hatch).
69
122
  "skip-setup",
123
+ // Fleet LB-ingress domain (e.g. fleet.codeyam.com). When set + persisted in
124
+ // cloud.json, Add wires the VM onto the shared Google HTTPS LB and Destroy
125
+ // unwires it; unset = legacy quick-tunnel mode (Add/Destroy unchanged). The
126
+ // Cloud DNS managed-zone name defaults to fleet-zone; override with --dns-zone.
127
+ "fleet-domain",
128
+ "dns-zone",
129
+ // Optional human-readable hostname slug for THIS VM (single VM only). When
130
+ // set on a numbered fleet VM with a fleet domain, its public URLs become
131
+ // `<slug>.editor.<domain>` / `<slug>.preview.<domain>` instead of the
132
+ // number-derived `vm-<N>...`. Persisted in the slug sidecar (NOT cloud.json,
133
+ // like --no-tunnel), so a later bare `cloud:up` doesn't re-apply a stale slug.
134
+ "vm-slug",
135
+ // Per-VM IAP access whitelist: comma-separated principals (user:/group:/domain:,
136
+ // a bare email defaults to user:) granted resource-level access to THIS VM's
137
+ // surfaces on Add. Persisted in the access sidecar (NOT cloud.json, like
138
+ // --vm-slug), so a later bare `cloud:up` doesn't re-apply a stale list. Edited
139
+ // after the fact with `cloud:access`.
140
+ "vm-access",
141
+ // `cloud:access` edit-an-existing-VM flags: add/remove one principal, or list
142
+ // the current whitelist. Single-VM, resolved from --instance-name.
143
+ "add",
144
+ "remove",
145
+ "list",
146
+ // Escape hatch for an intentionally read-only VM: suppress the fail-loud /
147
+ // degraded-badge when no GitHub push token resolves at provision time. The
148
+ // default is loud — a tokenless VM cannot sync plan claims and must never
149
+ // report healthy silently. Use only when push genuinely isn't wanted.
150
+ "allow-no-git-credentials",
70
151
  ];
71
152
 
72
153
  // Flags that take no value (presence = true). parseFlags must not demand a
73
154
  // value for these or `--no-tunnel` would error "requires a value".
74
- const BOOLEAN_FLAGS = ["no-tunnel", "skip-setup"];
155
+ const BOOLEAN_FLAGS = ["no-tunnel", "skip-setup", "list", "allow-no-git-credentials"];
75
156
 
76
157
  const FIREWALL_RULE_NAME = "codeyam-editor-iap-ssh";
77
158
  const INSTANCE_TAG = "codeyam-editor";
@@ -129,6 +210,17 @@ function agentEnvVarName(agent) {
129
210
  return AGENT_ENV_VARS[agent];
130
211
  }
131
212
 
213
+ /** Map an agent name to its global npm package. Throws on unknown agents so
214
+ * adding an agent is a one-entry change in `AGENT_NPM_PACKAGES`. */
215
+ function agentNpmPackage(agent) {
216
+ if (!Object.prototype.hasOwnProperty.call(AGENT_NPM_PACKAGES, agent)) {
217
+ throw new Error(
218
+ `Unknown agent: ${agent}. Supported: ${Object.keys(AGENT_NPM_PACKAGES).join(", ")}`,
219
+ );
220
+ }
221
+ return AGENT_NPM_PACKAGES[agent];
222
+ }
223
+
132
224
  /** Parse `--flag value` argv into an object. Unknown flags or missing values
133
225
  * throw with a usage hint — silently dropping them would let typos like
134
226
  * `--branchh main` succeed with no branch flag at all. */
@@ -303,6 +395,7 @@ function resolveSeedPreviewOrigin(
303
395
  appPort = VITE_PORT,
304
396
  env = process.env,
305
397
  sameOriginSafe = true,
398
+ slug = null,
306
399
  ) {
307
400
  // Same-origin-safe stack (the default — Vite et al.): ALWAYS serve the app
308
401
  // same-origin under the reserved relative path, in every fleet mode. The
@@ -331,15 +424,27 @@ function resolveSeedPreviewOrigin(
331
424
  // client project (vm == null) keeps the localhost seed even with a domain set.
332
425
  if (domain && vm != null) {
333
426
  return {
334
- previewOrigin: vmUrls(vm, domain).preview,
427
+ // A custom slug overrides the leftmost `vm-<N>` label so the iframe origin
428
+ // matches the slug host the LB routes; absent, this is the numeric host.
429
+ previewOrigin: vmUrls(vm, domain, slug).preview,
430
+ _managedPreviewOrigin: MANAGED_PREVIEW_ORIGIN,
431
+ };
432
+ }
433
+ // Quick-tunnel / domain-free mode: support both 'local' (default) and 'tunnel' modes.
434
+ // In 'local' mode, we use http://localhost:<15172 + VM_NUMBER> directly.
435
+ // In 'tunnel' mode, we prefer the operator's published quick-tunnel URL.
436
+ const mode = env.PREVIEW_ORIGIN_MODE || "local";
437
+ if (mode === "local" && vm != null) {
438
+ // http://localhost inside an https:// editor page is allowed.
439
+ // Browsers treat http://localhost as a potentially-trustworthy origin,
440
+ // so the mixed-content blocker does not apply.
441
+ const previewPort = 15172 + vm;
442
+ return {
443
+ previewOrigin: `http://localhost:${previewPort}`,
335
444
  _managedPreviewOrigin: MANAGED_PREVIEW_ORIGIN,
336
445
  };
337
446
  }
338
- // Quick-tunnel / domain-free mode: prefer the operator's published
339
- // `<N>-preview` quick-tunnel URL (the public origin a remote browser can
340
- // actually load), else the SSH-tunnel bootstrap localhost origin
341
- // (`http://localhost:<appPort>`, reached via the laptop `-L` forward). Never
342
- // the same-origin subpath — that is exactly what recurses for this stack.
447
+ // Quick-tunnel 'tunnel' mode fallback / non-fleet project fallback:
343
448
  const previewKey = vm != null ? `${vm}-preview` : null;
344
449
  const tunnelUrl =
345
450
  previewKey &&
@@ -394,6 +499,46 @@ function isRottedManagedPreviewOrigin(localJson, sameOriginSafe) {
394
499
  );
395
500
  }
396
501
 
502
+ // A loopback / localhost previewOrigin: `http://127.0.0.1:<port>`,
503
+ // `http://localhost:<port>`, `http://[::1]:<port>`, with or without a port,
504
+ // path, or query. These are reachable only from the editor host itself (or a
505
+ // laptop `-L` forward), never from a remote browser — exactly the dead-end the
506
+ // dedicated_preview_origin fallback lands on when the seed was wrong-moded.
507
+ const LOOPBACK_ORIGIN_RE =
508
+ /^https?:\/\/(?:127\.0\.0\.1|localhost|\[::1\])(?:[:/?#]|$)/i;
509
+
510
+ /** Decide whether a VM's current `editor.local.json` carries a WRONG-MODE
511
+ * previewOrigin for a stack that is NOT same-origin-safe (Next.js etc.) and so
512
+ * must be self-healed to a dedicated origin. This is the inverse of
513
+ * `isRottedManagedPreviewOrigin`: it fires when the stack.json race froze the
514
+ * same-origin-safe default (or a loopback bootstrap) onto a client-routed
515
+ * stack — the VM7 failure where the iframe shows `127.0.0.1 refused to
516
+ * connect`. True iff BOTH hold:
517
+ * - the stack is NOT same-origin-safe (a same-origin-safe stack legitimately
518
+ * keeps the `/__codeyam_preview` subpath — never heal it; that is
519
+ * `isRottedManagedPreviewOrigin`'s job in the other direction); and
520
+ * - the `previewOrigin` is the same-origin subpath sentinel, any other
521
+ * relative path, or a loopback/localhost origin — none of which a remote
522
+ * browser can load for a dedicated-origin stack. A real dedicated origin
523
+ * (an absolute non-loopback URL — quick-tunnel or named-tunnel host) is
524
+ * left alone, so re-running the heal once a good origin is in place is a
525
+ * no-op.
526
+ * Symmetric with `isRottedManagedPreviewOrigin` and tested as a pair. Pure —
527
+ * `localJson` is a parsed editor.local.json object. */
528
+ function isWrongModePreviewOrigin(localJson, sameOriginSafe) {
529
+ if (sameOriginSafe) return false;
530
+ if (!localJson || typeof localJson !== "object") return false;
531
+ const origin = localJson.previewOrigin;
532
+ if (typeof origin !== "string" || origin.length === 0) return false;
533
+ // The reserved same-origin subpath, or any relative path (no scheme): these
534
+ // recurse into the editor SPA for a client-routed stack.
535
+ if (origin === SAME_ORIGIN_PREVIEW_PATH) return true;
536
+ if (!/^[a-z][a-z0-9+.-]*:\/\//i.test(origin)) return true;
537
+ // An absolute loopback/localhost origin: reachable only from the host, dead
538
+ // for a remote browser.
539
+ return LOOPBACK_ORIGIN_RE.test(origin);
540
+ }
541
+
397
542
  /** Read the operator-laptop cloudflared-urls.json sidecar. Missing/unparseable
398
543
  * → {} (the monitor may not have run yet; the seed falls back to localhost). */
399
544
  function readCloudflaredUrls() {
@@ -474,9 +619,25 @@ function editorImageBootstrapArgs(cfg) {
474
619
  * so a stale value — e.g. a prior greenfield's `newProject` left in cloud.json
475
620
  * — can't override the new intent. Without this, launching `--repo X` after a
476
621
  * greenfield run silently provisions the greenfield project (resolveProject
477
- * prioritizes newProject). Mutates and returns cfg. */
478
- function reconcileProjectExclusivity(cfg, flags = {}) {
479
- if (flags.repo) delete cfg.newProject;
622
+ * prioritizes newProject). Mutates and returns cfg.
623
+ *
624
+ * `prev` is the pre-merge persisted config (default `{}`). When `--repo`
625
+ * SWITCHES to a different repo and no `--branch` was passed this run, the
626
+ * inherited client branch is dropped: a persisted branch belongs to the OLD
627
+ * repo and may not exist on the new one. (The harvardintech VM was torn down
628
+ * because a `--repo harvardintech` re-up reused `editor-improvements-48` — a
629
+ * branch that lives only on the editor repo — and `git clone --branch` failed.)
630
+ * An explicit `--branch` this run always wins (the drop is gated on
631
+ * `!flags.branch`), and a same-repo re-up keeps its persisted branch — that is
632
+ * the legitimate "set the branch once, reuse it" workflow. `sameRepo` is used
633
+ * for the comparison so `.git`/case differences don't trigger a spurious drop. */
634
+ function reconcileProjectExclusivity(cfg, flags = {}, prev = {}) {
635
+ if (flags.repo) {
636
+ delete cfg.newProject;
637
+ if (!flags.branch && prev.repo && !sameRepo(prev.repo, flags.repo)) {
638
+ delete cfg.branch;
639
+ }
640
+ }
480
641
  if (flags.newProject) { delete cfg.repo; delete cfg.branch; }
481
642
  return cfg;
482
643
  }
@@ -529,25 +690,50 @@ function loadCloudConfig(projectDir) {
529
690
  try {
530
691
  return JSON.parse(raw);
531
692
  } catch (e) {
532
- throw new Error(`Failed to parse ${p}: ${e.message}`);
693
+ try {
694
+ raw = fs.readFileSync(p, "utf8");
695
+ return JSON.parse(raw);
696
+ } catch (e2) {
697
+ throw new Error(`Failed to parse ${p}: ${e2.message}`);
698
+ }
533
699
  }
534
700
  }
535
701
 
536
702
  function saveCloudConfig(projectDir, cfg) {
537
703
  const p = cloudConfigPath(projectDir);
538
- fs.mkdirSync(path.dirname(p), { recursive: true });
539
- fs.writeFileSync(p, JSON.stringify(cfg, null, 2) + "\n");
704
+ const dir = path.dirname(p);
705
+ fs.mkdirSync(dir, { recursive: true });
706
+ const tmpPath = path.join(dir, `cloud.json.${process.pid}.tmp`);
707
+ try {
708
+ fs.writeFileSync(tmpPath, JSON.stringify(cfg, null, 2) + "\n");
709
+ fs.renameSync(tmpPath, p);
710
+ } catch (e) {
711
+ fs.rmSync(tmpPath, { force: true });
712
+ throw e;
713
+ }
540
714
  }
541
715
 
542
716
  /** Build the JSON patch to merge into the VM's `editor.local.json` so the
543
- * configured agent picks up the user's local API key on first launch.
544
- * Returns `null` when the key isn't set locally — the wrapper then prints
545
- * the interactive-auth fallback line instead of writing an empty patch. */
717
+ * configured agent becomes the editor's build-agent provider AND (when the
718
+ * key is available) picks up the user's local API key on first launch. The
719
+ * top-level `provider` field (lowercase — matching `BuildAgentProvider`'s
720
+ * serde) makes the VM's editor default to the chosen agent instead of Claude;
721
+ * `editor.local.json` is deep-merged over `editor.json` on the VM, so this
722
+ * overrides the packaged default.
723
+ *
724
+ * The patch ALWAYS carries `provider` — the agent is a launch *choice* that
725
+ * must persist on the VM regardless of whether the operator happens to have
726
+ * the agent's API key exported locally (Gemini, for instance, is normally
727
+ * authenticated interactively on the VM). `env.<KEY>` is the separate,
728
+ * opportunistic seed: it's included only when the matching key is set in
729
+ * `localEnv`. The function never returns `null`. Writing `provider: "claude"`
730
+ * when claude is the default deep-merges to a no-op, so the provider can be
731
+ * written unconditionally without special-casing the default. */
546
732
  function buildEditorLocalEnvPatch(localEnv, agent) {
547
733
  const varName = agentEnvVarName(agent);
548
734
  const value = localEnv[varName];
549
- if (!value) return null;
550
- return { env: { [varName]: value } };
735
+ if (!value) return { provider: agent };
736
+ return { provider: agent, env: { [varName]: value } };
551
737
  }
552
738
 
553
739
  /** Parse `docker compose ps --format json` (compose v2 — one JSON object per
@@ -700,6 +886,72 @@ function runGcloud(args, opts = {}) {
700
886
  });
701
887
  }
702
888
 
889
+ // Build a bounded, de-noised failure detail from a runGcloud (spawnSync)
890
+ // result: prefer stderr, fall back to stdout, and surface a spawn-level error
891
+ // (e.g. ENOENT when gcloud is not on PATH) when there is no captured output.
892
+ // Trims the known gcloud noise lines — the same `/WARNING|NumPy|google.com/iap/`
893
+ // filter the dashboard's tailLog applies — and keeps the last ~15 lines so the
894
+ // actionable message is not buried under auth/IAP warnings. Pure: result in,
895
+ // string out, so it is unit-testable without spawning gcloud.
896
+ function formatGcloudFailure(result) {
897
+ const denoise = (text) =>
898
+ String(text || "")
899
+ .split("\n")
900
+ .map((l) => l.replace(/\r$/, ""))
901
+ .filter((l) => l.trim() !== "")
902
+ .filter((l) => !/WARNING|NumPy|google\.com\/iap/.test(l))
903
+ .slice(-15)
904
+ .join("\n");
905
+
906
+ const stderr = denoise(result && result.stderr);
907
+ if (stderr) return stderr;
908
+ const stdout = denoise(result && result.stdout);
909
+ if (stdout) return stdout;
910
+ if (result && result.error && result.error.message) {
911
+ return result.error.message;
912
+ }
913
+ return "(no output captured from gcloud)";
914
+ }
915
+
916
+ // A bounded, pure classifier that keys on status === 255 (gcloud-ssh's exit
917
+ // for an ssh failure) and a signature match against known transient strings.
918
+ function isTransientSshFailure(result) {
919
+ if (!result || result.status !== 255) {
920
+ return false;
921
+ }
922
+ const output = String(result.stderr || "") + String(result.stdout || "");
923
+ 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;
924
+ return transientRegex.test(output);
925
+ }
926
+
927
+ // A retry wrapper for gcloud ssh-script calls that handles transient connection drops
928
+ // with exponential backoff, mimicking the IAP ready backoff.
929
+ async function runGcloudSshWithRetry(verb, cfg, extras, { stdio, retries = 5, label, sleepFn } = {}) {
930
+ let lastResult;
931
+ for (let attempt = 1; attempt <= retries; attempt++) {
932
+ const result = runGcloud(buildGcloudArgs(verb, cfg, extras), { stdio });
933
+ if (result.status === 0) {
934
+ return result;
935
+ }
936
+ lastResult = result;
937
+ if (isTransientSshFailure(result)) {
938
+ if (attempt < retries) {
939
+ const delay = Math.min(2000 * Math.pow(1.5, attempt - 1), 8000);
940
+ console.error(` ↺ transient SSH failure (attempt ${attempt}/${retries}) [${label || verb}] ... retrying in ${delay / 1000}s`);
941
+ if (sleepFn) {
942
+ await sleepFn(delay);
943
+ } else {
944
+ await new Promise((r) => setTimeout(r, delay));
945
+ }
946
+ continue;
947
+ }
948
+ }
949
+ // For non-transient failure, return immediately, or if we exhausted our attempts.
950
+ return result;
951
+ }
952
+ return lastResult;
953
+ }
954
+
703
955
  function ensureGcloud() {
704
956
  const result = spawnSync("gcloud", ["version"], { stdio: "ignore" });
705
957
  if (result.error || result.status !== 0) {
@@ -740,9 +992,21 @@ function loadOrInitCloudConfig(projectDir, flags) {
740
992
  const existing = loadCloudConfig(projectDir);
741
993
  // noTunnel is a per-invocation behavior, never persisted — else a later manual
742
994
  // `cloud:up` (zero-arg, reading cloud.json) would silently skip its tunnel.
743
- const persist = (c) => { const { noTunnel, ...rest } = c; return rest; };
995
+ // vmSlug is likewise a per-Add input that lives in the slug sidecar keyed by VM
996
+ // number, never in this project config — else a later bare `cloud:up` would
997
+ // re-stamp a stale slug onto the VM. vmAccess is the same shape: a per-Add input
998
+ // that lives in the access sidecar, never in cloud.json.
999
+ const persist = (c) => { const { noTunnel, vmSlug, vmAccess, ...rest } = c; return rest; };
744
1000
  if (existing) {
745
- const merged = reconcileProjectExclusivity({ ...existing, ...flags }, flags);
1001
+ const merged = reconcileProjectExclusivity({ ...existing, ...flags }, flags, existing);
1002
+ const { isPlaceholderProject } = require("./launch-config");
1003
+ if (isPlaceholderProject(merged.project)) {
1004
+ const configPath = cloudConfigPath(projectDir);
1005
+ throw new Error(
1006
+ `Failed to load cloud config: project '${merged.project}' in ${configPath} is a test-fixture placeholder. ` +
1007
+ `Please configure a real GCP project or set the GCP_PROJECT environment variable.`
1008
+ );
1009
+ }
746
1010
  const toSave = persist(merged);
747
1011
  if (JSON.stringify(toSave) !== JSON.stringify(existing)) {
748
1012
  saveCloudConfig(projectDir, toSave);
@@ -752,6 +1016,15 @@ function loadOrInitCloudConfig(projectDir, flags) {
752
1016
 
753
1017
  const cfg = reconcileProjectExclusivity({ ...flags }, flags);
754
1018
  cfg.project = cfg.project || gcloudGetValue("config-get-project");
1019
+
1020
+ const { isPlaceholderProject } = require("./launch-config");
1021
+ if (isPlaceholderProject(cfg.project)) {
1022
+ throw new Error(
1023
+ `Failed to load cloud config: resolved project '${cfg.project}' is a test-fixture placeholder. ` +
1024
+ `Please configure a real GCP project or set the GCP_PROJECT environment variable.`
1025
+ );
1026
+ }
1027
+
755
1028
  cfg.zone = cfg.zone || gcloudGetValue("config-get-zone");
756
1029
  cfg.agent = cfg.agent || "claude";
757
1030
  agentEnvVarName(cfg.agent);
@@ -799,9 +1072,11 @@ async function ensureFirewallRule(cfg) {
799
1072
  async function ensureInstance(cfg) {
800
1073
  console.error(`Checking instance ${cfg.instanceName}...`);
801
1074
  const initialState = fetchInstanceState(cfg);
1075
+ const createdThisRun = initialState === null;
1076
+
802
1077
  if (initialState === "RUNNING") {
803
1078
  console.error(` ✓ ${cfg.instanceName} is RUNNING`);
804
- return;
1079
+ return { createdThisRun };
805
1080
  }
806
1081
  if (initialState === "TERMINATED") {
807
1082
  console.error(` ${cfg.instanceName} is TERMINATED — starting...`);
@@ -823,7 +1098,7 @@ async function ensureInstance(cfg) {
823
1098
  if (fetchInstanceState(cfg) === "RUNNING") {
824
1099
  console.error(` ✓ ${cfg.instanceName} is RUNNING`);
825
1100
  await waitForIapReady(cfg);
826
- return;
1101
+ return { createdThisRun };
827
1102
  }
828
1103
  await new Promise((r) => setTimeout(r, 2000));
829
1104
  }
@@ -1079,7 +1354,7 @@ function sshScriptCommand(scriptBody, scriptArgs = [], envVars = {}) {
1079
1354
  return `echo '${b64}' | base64 -d | ${envPrefix}bash -s -- ${argsString}`.trim();
1080
1355
  }
1081
1356
 
1082
- async function ensureBootstrapped(cfg) {
1357
+ async function ensureBootstrapped(cfg, { sleepFn } = {}) {
1083
1358
  console.error("Checking VM bootstrap state...");
1084
1359
  const probe = runGcloud(
1085
1360
  buildGcloudArgs("ssh-probe", cfg, {
@@ -1140,12 +1415,17 @@ async function ensureBootstrapped(cfg) {
1140
1415
  // fresh detached build that will survive an SSH disconnect.
1141
1416
  console.error(" launching detached bootstrap on the VM (survives SSH disconnect; ~1-14 min)...");
1142
1417
  const launch = bootstrapLaunchCommand(scriptBody, bootstrapArgs, bootstrapEnv);
1143
- const launched = runGcloud(
1144
- buildGcloudArgs("ssh-script", cfg, { command: launch }),
1145
- { stdio: ["ignore", "pipe", "pipe"] },
1418
+ const launched = await runGcloudSshWithRetry(
1419
+ "ssh-script",
1420
+ cfg,
1421
+ { command: launch },
1422
+ { stdio: ["ignore", "pipe", "pipe"], label: "bootstrap launch", sleepFn }
1146
1423
  );
1147
1424
  if (launched.status !== 0) {
1148
- throw new Error("failed to launch the detached bootstrap on the VM");
1425
+ const detail = formatGcloudFailure(launched);
1426
+ throw new Error(
1427
+ `failed to launch the detached bootstrap on the VM (gcloud ssh exit ${launched.status}).\n${detail}`,
1428
+ );
1149
1429
  }
1150
1430
  }
1151
1431
 
@@ -1250,10 +1530,19 @@ async function ensureProjectBootstrapped(cfg) {
1250
1530
  // Whitelisted by the dashboard via CY_FORWARD_ENV_KEYS — empty on the
1251
1531
  // standalone `cloud:up` path, so that flow is unchanged.
1252
1532
  const forwardEnv = resolveSetupForwardEnv();
1533
+ // Forward the fleet domain so the bootstrap's compose render publishes the
1534
+ // editor/app ports on 0.0.0.0 (the LB NEG reaches them on the VM's internal
1535
+ // IP) instead of loopback-only. Only when a fleet domain is configured AND
1536
+ // this is a numbered fleet VM — a non-fleet client project keeps the
1537
+ // loopback-only publish (legacy parity). Absent ⇒ key omitted entirely.
1538
+ const ingressEnv = {};
1539
+ const ingressPlan = ingressWiringPlan(cfg);
1540
+ if (ingressPlan.wire) ingressEnv.FLEET_DOMAIN = ingressPlan.domain;
1253
1541
  const command = sshScriptCommand(scriptBody, scriptArgs, {
1254
1542
  GITHUB_TOKEN: resolveGithubToken(),
1255
1543
  CY_GIT_USER_NAME: laptopGit.name,
1256
1544
  CY_GIT_USER_EMAIL: laptopGit.email,
1545
+ ...ingressEnv,
1257
1546
  ...forwardEnv,
1258
1547
  });
1259
1548
  const result = runGcloud(
@@ -1280,6 +1569,7 @@ async function ensureEditorContainerUp(cfg) {
1280
1569
  );
1281
1570
  if (probe.status === 0 && parseEditorComposeState(probe.stdout) === "running") {
1282
1571
  console.error(" ✓ editor container is already running");
1572
+ await ensureAgentCliInstalled(cfg);
1283
1573
  return;
1284
1574
  }
1285
1575
  console.error(" starting editor container (docker compose up -d)...");
@@ -1292,6 +1582,34 @@ async function ensureEditorContainerUp(cfg) {
1292
1582
  if (up.status !== 0) {
1293
1583
  throw new Error("docker compose up -d failed on the VM");
1294
1584
  }
1585
+ await ensureAgentCliInstalled(cfg);
1586
+ }
1587
+
1588
+ // Hard gate: provisioning success REQUIRES a running editor container. Reuses
1589
+ // the exact compose-ps probe ensureEditorContainerUp uses, but as a TERMINAL
1590
+ // re-check rather than a precondition — `docker compose up -d` can exit 0 over
1591
+ // a container that then crash-loops or exits, and an upstream phase that never
1592
+ // ran leaves no /workspace at all. Either way `cmdUp` would otherwise report
1593
+ // success. This throws (non-zero exit) when the editor service isn't
1594
+ // `running`, converting a silent partial provision into a loud, actionable
1595
+ // failure the operator (and the dashboard's fail-fast-on-nonzero path) can act
1596
+ // on. The `|| true` keeps the probe's exit status 0 so the real signal is the
1597
+ // parsed compose state, not the SSH/cd exit code.
1598
+ async function assertEditorContainerRunning(cfg) {
1599
+ const repoName = resolveProject(cfg).name;
1600
+ const composeCmd = `docker compose -f docker-compose.cloud.yml`;
1601
+ const probe = runGcloud(
1602
+ buildGcloudArgs("ssh-probe", cfg, {
1603
+ command: `cd ~/projects/${repoName} 2>/dev/null && ${composeCmd} ps --format json 2>/dev/null || true`,
1604
+ }),
1605
+ { stdio: ["ignore", "pipe", "ignore"] },
1606
+ );
1607
+ if (!(probe.status === 0 && parseEditorComposeState(probe.stdout) === "running")) {
1608
+ throw new Error(
1609
+ `provisioning incomplete: editor container is not running on ${cfg.instanceName} ` +
1610
+ `(no /workspace or container). Re-run: npm run cloud:up -- --instance-name ${cfg.instanceName}`,
1611
+ );
1612
+ }
1295
1613
  }
1296
1614
 
1297
1615
  /** Read the laptop's git author identity (`user.name`, `user.email`) so
@@ -1320,13 +1638,52 @@ function resolveLaptopGitIdentity(spawnImpl = spawnSync) {
1320
1638
  * intentionally not done: the cost of one `gh` subprocess per cloud:up is
1321
1639
  * trivial and lets a user `gh auth login` between operations. */
1322
1640
  function resolveGithubToken() {
1323
- const env = process.env.GITHUB_TOKEN || process.env.GH_TOKEN;
1324
- if (env) return env;
1641
+ return resolveGithubTokenWithSource().token;
1642
+ }
1643
+
1644
+ /** Like {@link resolveGithubToken} but also reports WHICH channel resolved the
1645
+ * token (`GITHUB_TOKEN` env, `GH_TOKEN` env, or `gh auth token`). The source is
1646
+ * logged on a successful seed so a misconfigured add-environment — e.g. a
1647
+ * launchd dashboard agent whose plist lacks the token env and whose `gh` can't
1648
+ * reach the operator's keychain — is diagnosable from the add log instead of
1649
+ * surfacing days later as a fleet-wide plan-claim-sync outage. `source` is
1650
+ * `null` when no token resolves. */
1651
+ function resolveGithubTokenWithSource() {
1652
+ if (process.env.GITHUB_TOKEN) {
1653
+ return { token: process.env.GITHUB_TOKEN, source: "GITHUB_TOKEN" };
1654
+ }
1655
+ if (process.env.GH_TOKEN) {
1656
+ return { token: process.env.GH_TOKEN, source: "GH_TOKEN" };
1657
+ }
1325
1658
  const ghOut = spawnSync("gh", ["auth", "token"], { encoding: "utf8" });
1326
1659
  if (ghOut.status === 0) {
1327
- return ghOut.stdout.trim();
1660
+ const token = String(ghOut.stdout || "").trim();
1661
+ if (token) return { token, source: "gh auth token" };
1662
+ }
1663
+ return { token: "", source: null };
1664
+ }
1665
+
1666
+ /** Thrown by {@link seedGithubCredentials} when no GitHub token resolves and the
1667
+ * caller has NOT opted into a credential-less VM. The root harm this guards is
1668
+ * a VM that comes up "healthy" yet cannot `git push` its plan-claim refs to
1669
+ * origin, silently stranding fleet-wide claim sync (observed 2026-06-29 across
1670
+ * all 9 VMs). `degradedReason` is the machine-readable `CY_DEGRADED` body the
1671
+ * add path emits so the dashboard card lands amber with a recovery command
1672
+ * instead of a falsely-green healthy card. */
1673
+ class MissingGitPushCredentialsError extends Error {
1674
+ constructor() {
1675
+ super(
1676
+ "No GITHUB_TOKEN / GH_TOKEN set and `gh auth token` is unavailable —\n" +
1677
+ "the cloud VM cannot git pull/push, so plan-claim sync will not reach origin.\n" +
1678
+ "Export GITHUB_TOKEN or run `gh auth login` locally, then `cloud:up` again,\n" +
1679
+ "or re-seed a running VM with `npm run cloud:reconcile-credentials`.\n" +
1680
+ "Pass --allow-no-git-credentials to provision an intentionally read-only VM.",
1681
+ );
1682
+ this.name = "MissingGitPushCredentialsError";
1683
+ this.degradedReason =
1684
+ "no-git-credentials: no GitHub push token resolved — plan-claim sync will not " +
1685
+ "reach origin. Recover: npm run cloud:reconcile-credentials";
1328
1686
  }
1329
- return "";
1330
1687
  }
1331
1688
 
1332
1689
  /** A PULL-ONLY (`read:packages`) token the VM bootstrap uses to `docker login
@@ -1360,6 +1717,139 @@ function resolveGhcrPushToken() {
1360
1717
  return process.env.CODEYAM_GHCR_PUSH_TOKEN || "";
1361
1718
  }
1362
1719
 
1720
+ /** Read-only probe: does a usable prebuilt editor image already exist on the
1721
+ * registry for `branch`? Used by the fleet dashboard BEFORE a concurrent batch
1722
+ * add to decide whether to pace one VM ahead of the rest (see
1723
+ * npm/fleet-prewarm.js::decideBatchPrewarm). A `docker manifest inspect` is a
1724
+ * HEAD-equivalent — it reads the manifest without pulling the (multi-GB) image.
1725
+ *
1726
+ * Returns:
1727
+ * true — at least one of the usable tags exists (a cold add can pull; no pacer)
1728
+ * false — every tag was checked and none exists (known-absent; pacing pays)
1729
+ * null — undetermined: no read token, docker missing, or a probe error that
1730
+ * can't be distinguished from "absent". The caller treats null as
1731
+ * "fan out" (today's behavior, never worse) rather than risk a
1732
+ * 10-min serialization that might buy nothing.
1733
+ *
1734
+ * The private packages require auth, so we `docker login` with the read token
1735
+ * first; without a token we can't tell absent from forbidden, so → null.
1736
+ * `spawnImpl` is injectable for unit tests. */
1737
+ function probeUsableEditorImage({ branch, registry, token, spawnImpl } = {}) {
1738
+ const fp = require("./fleet-prewarm");
1739
+ const spawn = spawnImpl || spawnSync;
1740
+ const reg = registry || process.env.CODEYAM_REGISTRY || "ghcr.io/codeyam-ai";
1741
+ const readToken = token || resolveGhcrToken();
1742
+ if (!readToken) return null; // can't auth → can't distinguish absent from forbidden
1743
+ const host = String(reg).split("/")[0];
1744
+ const login = spawn(
1745
+ "docker",
1746
+ ["login", host, "-u", process.env.CODEYAM_GHCR_USER || "codeyam", "--password-stdin"],
1747
+ { input: readToken, encoding: "utf8" },
1748
+ );
1749
+ // spawn error (docker missing) or a non-zero login → undetermined.
1750
+ if (!login || login.error || login.status !== 0) return null;
1751
+ const tags = fp.usableImageTags({ registry: reg, branch });
1752
+ let allClean = true;
1753
+ for (const tag of tags) {
1754
+ const res = spawn("docker", ["manifest", "inspect", tag], { encoding: "utf8" });
1755
+ if (!res || res.error) {
1756
+ allClean = false; // probe itself failed to run — can't claim "absent"
1757
+ continue;
1758
+ }
1759
+ if (res.status === 0) return true; // a usable image exists
1760
+ }
1761
+ // Every tag inspected cleanly and none existed → definitively absent. If any
1762
+ // probe failed to run, we can't be sure → undetermined.
1763
+ return allClean ? false : null;
1764
+ }
1765
+
1766
+ /** JS mirror of crates/control-api/src/plan_claims_sync.rs::classify_sync_error.
1767
+ * Categorizes a `git ls-remote` / push stderr into the SAME buckets the Rust
1768
+ * sync task uses (`credential` / `git-spawn` / `network` / `other`) so the
1769
+ * credential self-heal only re-seeds on a real credential gap, never a
1770
+ * transient network blip. Deliberate duplication: cloud.js (the fleet tool)
1771
+ * cannot call into the Rust crate, and the category strings are a stable
1772
+ * contract — keep the two lists in sync if either changes. */
1773
+ function classifyGitRemoteError(stderr) {
1774
+ const lower = String(stderr || "").toLowerCase();
1775
+ if (
1776
+ [
1777
+ "could not read username",
1778
+ "terminal prompts disabled",
1779
+ "authentication failed",
1780
+ "device not configured",
1781
+ ].some((s) => lower.includes(s))
1782
+ ) {
1783
+ return "credential";
1784
+ }
1785
+ if (lower.includes("no such file") && lower.includes("git")) {
1786
+ return "git-spawn";
1787
+ }
1788
+ if (
1789
+ [
1790
+ "could not resolve host",
1791
+ "connection refused",
1792
+ "connection timed out",
1793
+ "ssl certificate problem",
1794
+ "tls",
1795
+ ].some((s) => lower.includes(s))
1796
+ ) {
1797
+ return "network";
1798
+ }
1799
+ return "other";
1800
+ }
1801
+
1802
+ /** Pure decision for the credential self-heal. Given a probe of a running VM —
1803
+ * whether the host staging file (`~/.codeyam-cloud-git-credentials`) is empty,
1804
+ * and the outcome of an in-container `git ls-remote origin` — decide whether to
1805
+ * re-seed credentials in place.
1806
+ *
1807
+ * Re-seed iff the host staging file is empty (so a restart wouldn't recover) OR
1808
+ * `ls-remote` failed with the `credential` signature. A `network` failure must
1809
+ * NOT trigger a re-write (a transient blip is not a credential gap), and a
1810
+ * healthy VM (ls-remote ok AND staging seeded) is a no-op. Returns
1811
+ * `{ heal, category, reason }`. */
1812
+ function decideCredentialHeal({ stagingEmpty, lsRemoteOk, lsRemoteStderr }) {
1813
+ if (lsRemoteOk && !stagingEmpty) {
1814
+ return {
1815
+ heal: false,
1816
+ category: "ok",
1817
+ reason: "git ls-remote origin succeeds and the staging file is seeded",
1818
+ };
1819
+ }
1820
+ if (!lsRemoteOk) {
1821
+ const category = classifyGitRemoteError(lsRemoteStderr);
1822
+ if (category === "network") {
1823
+ return {
1824
+ heal: false,
1825
+ category,
1826
+ reason: "ls-remote failed with a network error — not re-seeding (transient)",
1827
+ };
1828
+ }
1829
+ if (category === "git-spawn") {
1830
+ return {
1831
+ heal: false,
1832
+ category,
1833
+ reason: "git binary failed to launch — not a credential gap, not re-seeding",
1834
+ };
1835
+ }
1836
+ return {
1837
+ heal: true,
1838
+ category,
1839
+ reason: stagingEmpty
1840
+ ? "staging file empty and ls-remote failing — re-seeding"
1841
+ : "ls-remote failed with a credential error — re-seeding",
1842
+ };
1843
+ }
1844
+ // ls-remote ok but the durable host file is empty (live cache seeded
1845
+ // out-of-band): re-seed so the next container restart survives.
1846
+ return {
1847
+ heal: true,
1848
+ category: "credential",
1849
+ reason: "staging file empty (re-seed for restart durability)",
1850
+ };
1851
+ }
1852
+
1363
1853
  /** Write the laptop's GitHub token into ~/.codeyam-cloud-git-credentials on
1364
1854
  * the VM so the cloud compose can mount it into /root/.git-credentials —
1365
1855
  * without this, claude / codex / gemini inside the container can clone the
@@ -1367,17 +1857,24 @@ function resolveGhcrPushToken() {
1367
1857
  * inlined URL) but cannot `git pull` / `git push` afterward. The file is
1368
1858
  * chmod 600 and never committed; if the local environment has no token,
1369
1859
  * the placeholder file from gcp-project-bootstrap.sh stays in place. */
1370
- async function seedGithubCredentials(cfg) {
1371
- const token = resolveGithubToken();
1860
+ async function seedGithubCredentials(cfg, opts = {}) {
1861
+ const { token, source } = resolveGithubTokenWithSource();
1372
1862
  if (!token) {
1373
- console.error(
1374
- "\nNo GITHUB_TOKEN / GH_TOKEN set and `gh auth token` is unavailable —\n" +
1375
- "claude inside the cloud container will not be able to git pull/push.\n" +
1376
- "Export GITHUB_TOKEN or run `gh auth login` locally, then `cloud:up` again.\n",
1377
- );
1378
- return;
1863
+ if (opts.allowNoCredentials) {
1864
+ // Explicit opt-out: an intentionally read-only VM. Warn loudly (so it is
1865
+ // never invisible) but do not throw — the caller asked for this.
1866
+ console.error(
1867
+ "\n--allow-no-git-credentials: no GitHub token resolved; provisioning a\n" +
1868
+ "read-only VM. It cannot git push, so plan-claim sync will not reach origin.\n",
1869
+ );
1870
+ return;
1871
+ }
1872
+ // Fail loud: a VM that can't push plan-claim refs is degraded by definition.
1873
+ throw new MissingGitPushCredentialsError();
1379
1874
  }
1380
- console.error("Writing GitHub token into ~/.codeyam-cloud-git-credentials on the VM...");
1875
+ console.error(
1876
+ `Writing GitHub token (from ${source}) into ~/.codeyam-cloud-git-credentials on the VM...`,
1877
+ );
1381
1878
  const credLine = `https://x-access-token:${token}@github.com`;
1382
1879
  // Quote the token through one shell layer (gcloud ssh --command); the
1383
1880
  // value is base64'd to keep it out of the shell history.
@@ -1511,36 +2008,63 @@ async function seedPreviewOrigin(cfg, appPort = VITE_PORT) {
1511
2008
  sameOriginSafe = previewSameOriginSafeFromStack(parseObj(stackJson));
1512
2009
  }
1513
2010
 
2011
+ // Resolve this VM's custom slug (if any) so a dedicated-origin seed uses the
2012
+ // slug host. The slug rides on cfg at Add time (the --vm-slug flag, validated
2013
+ // early); for any other entry point it's resolved from the sidecar by number.
2014
+ const seedVm = resolveFleetVmNumber(cfg.instanceName);
2015
+ const seedSlug =
2016
+ cfg.vmSlug && String(cfg.vmSlug).trim() !== ""
2017
+ ? cfg.vmSlug
2018
+ : seedVm != null
2019
+ ? slugFor(seedVm)
2020
+ : null;
1514
2021
  const seed = resolveSeedPreviewOrigin(
1515
2022
  cfg.instanceName,
1516
2023
  readCloudflaredUrls(),
1517
2024
  appPort,
1518
2025
  process.env,
1519
2026
  sameOriginSafe,
2027
+ seedSlug,
1520
2028
  );
1521
2029
 
1522
2030
  // Skip-on-already-set guard, EXCEPT for self-heal. A non-empty previewOrigin
1523
- // is normally a user pin or a good prior seed and is left untouched. The one
1524
- // exception is a ROTTED managed cloudflared quick-tunnel URL on a
1525
- // same-origin-safe VM (the VM-1/2/6 leftover from before the improve38
1526
- // same-origin migration): that frozen URL points at a dead rotated tunnel
1527
- // (NXDOMAIN / blank preview), so we heal it back to the same-origin path
1528
- // instead of preserving it. Because every dashboard action (Reset / Update /
1529
- // Add / bootstrap-recovery) re-runs this seed over cloud:up, the heal reaches
1530
- // every touch-point without a separate per-VM hand-fix.
2031
+ // is normally a user pin or a good prior seed and is left untouched. There are
2032
+ // TWO symmetric heal exceptions, both of which the frozen skip-guard would
2033
+ // otherwise pin forever:
2034
+ // 1. a ROTTED managed cloudflared quick-tunnel URL on a same-origin-safe VM
2035
+ // (the VM-1/2/6 leftover from before the improve38 same-origin
2036
+ // migration): that frozen URL points at a dead rotated tunnel (NXDOMAIN /
2037
+ // blank preview), so we heal it back to the same-origin path; and
2038
+ // 2. a WRONG-MODE previewOrigin on a NOT-same-origin-safe VM (the VM7 race:
2039
+ // the first seed ran before onboarding wrote stack.json, defaulted to
2040
+ // `sameOriginSafe: true`, and seeded `/__codeyam_preview` — which a
2041
+ // remote browser then loads as `127.0.0.1 refused to connect`). Once
2042
+ // stack.json exists, heal it forward to the dedicated origin.
2043
+ // Because every dashboard action (Reset / Update / Add / bootstrap-recovery)
2044
+ // re-runs this seed over cloud:up, either heal reaches every touch-point the
2045
+ // moment stack.json is present — closing the race without re-ordering
2046
+ // provisioning, and without a separate per-VM hand-fix.
1531
2047
  const originAlreadySet =
1532
2048
  typeof current.previewOrigin === "string" && current.previewOrigin.length > 0;
1533
2049
  const healing =
1534
- originAlreadySet && isRottedManagedPreviewOrigin(current, sameOriginSafe);
2050
+ originAlreadySet &&
2051
+ (isRottedManagedPreviewOrigin(current, sameOriginSafe) ||
2052
+ isWrongModePreviewOrigin(current, sameOriginSafe));
1535
2053
  if (originAlreadySet && !healing) {
1536
2054
  return;
1537
2055
  }
1538
2056
 
1539
- // On heal, also DROP the legacy `_managedPreviewOrigin` marker (deepMergeJson
1540
- // deletes any key whose overlay value is null) — the same-origin path is a
1541
- // constant the retired monitor push has nothing to manage, exactly matching
1542
- // the manual `config-override --unset _managedPreviewOrigin` fix.
1543
- const overlay = healing ? { ...seed, _managedPreviewOrigin: null } : seed;
2057
+ // On heal, reconcile the `_managedPreviewOrigin` marker to the healed value's
2058
+ // own shape. The rotted→same-origin heal (seed carries NO marker) DROPS any
2059
+ // stale marker (deepMergeJson deletes a key whose overlay value is null) — the
2060
+ // constant same-origin path has nothing for the monitor to manage, exactly
2061
+ // matching the manual `config-override --unset _managedPreviewOrigin` fix. The
2062
+ // wrong-mode→dedicated heal (seed carries the marker) KEEPS it — that origin
2063
+ // IS managed (the monitor's gated rotation push owns it), so the marker must
2064
+ // survive so the next respawn recognises the value as ours.
2065
+ const overlay = healing
2066
+ ? { ...seed, _managedPreviewOrigin: seed._managedPreviewOrigin ?? null }
2067
+ : seed;
1544
2068
  console.error(
1545
2069
  healing
1546
2070
  ? `Healing rotted managed previewOrigin (${current.previewOrigin}) → ${seed.previewOrigin} in ${remotePath}...`
@@ -1560,20 +2084,114 @@ async function seedPreviewOrigin(cfg, appPort = VITE_PORT) {
1560
2084
  }
1561
2085
  }
1562
2086
 
2087
+ /** READY-probe auto-heal: after the editor container is verifiably Up, ask the
2088
+ * running editor's own `/api/config` for its stack verdict and effective
2089
+ * preview origin, and heal a wrong-moded seed in place — never failing the add.
2090
+ *
2091
+ * The race this closes (VM7, 2026-06-10): the pre-container `seedPreviewOrigin`
2092
+ * at line ~1906 runs BEFORE onboarding writes stack.json, so it defaults to
2093
+ * `sameOriginSafe: true` and seeds the `/__codeyam_preview` subpath. For a
2094
+ * client-routed stack (Next.js) the editor then reports `previewSameOriginSafe:
2095
+ * false` but, with only the subpath in editor.local.json, falls its
2096
+ * `dedicated_preview_origin` back to `http://127.0.0.1:<appPort>` — which a
2097
+ * remote browser renders as "refused to connect". If stack.json appeared since
2098
+ * the first seed (the editor reads it live from disk), we re-run the seed — now
2099
+ * the wrong-mode heal fires (`isWrongModePreviewOrigin`) — and POST
2100
+ * `/api/editor-reload-config` so the running server serves the healed origin
2101
+ * with no restart.
2102
+ *
2103
+ * Best-effort and non-fatal by contract: a probe failure, a still-absent
2104
+ * stack.json (verdict stays `true`), or a heal that can only reach a loopback
2105
+ * bootstrap origin (the operator's `<N>-preview` tunnel not published yet) all
2106
+ * leave the add green. The cloudflared-monitor's periodic tick (section 2) is
2107
+ * the durable owner that heals post-add flips — onboarding finishing minutes
2108
+ * after READY. */
2109
+ async function healPreviewOriginAtReady(cfg, appPort = VITE_PORT) {
2110
+ const probe = runGcloud(
2111
+ buildGcloudArgs("ssh-probe", cfg, {
2112
+ command: `curl -fsS --max-time 5 http://localhost:${EDITOR_PORT}/api/config 2>/dev/null || echo '{}'`,
2113
+ }),
2114
+ { stdio: ["ignore", "pipe", "ignore"] },
2115
+ );
2116
+ if (probe.status !== 0 || typeof probe.stdout !== "string") {
2117
+ return;
2118
+ }
2119
+ let config;
2120
+ try {
2121
+ config = JSON.parse(probe.stdout);
2122
+ } catch {
2123
+ return;
2124
+ }
2125
+ if (!config || typeof config !== "object") return;
2126
+
2127
+ // The editor's own verdict (section 3's `/api/config` field) plus the
2128
+ // wrong-mode predicate (a subpath / relative / loopback origin on a
2129
+ // not-same-origin-safe stack) — the exact dead-end the dedicated-origin
2130
+ // fallback lands on. Same-origin-safe stacks (verdict true) never enter here.
2131
+ if (config.previewSameOriginSafe !== false) return;
2132
+ if (!isWrongModePreviewOrigin(config, false)) return;
2133
+
2134
+ console.error(
2135
+ `READY auto-heal: ${cfg.instanceName} reports a not-same-origin-safe stack ` +
2136
+ `but a wrong-mode previewOrigin (${config.previewOrigin}); re-seeding...`,
2137
+ );
2138
+ // Re-run the seed: stack.json is now readable, so the wrong-mode heal writes
2139
+ // the dedicated origin into editor.local.json (a published `<N>-preview`
2140
+ // tunnel URL, else the localhost bootstrap for the window before the monitor's
2141
+ // first tick).
2142
+ await seedPreviewOrigin(cfg, appPort);
2143
+ // Tell the running server to re-read editor.local.json — no restart (the
2144
+ // footgun that killed VM7's live Claude session).
2145
+ const reload = runGcloud(
2146
+ buildGcloudArgs("ssh-probe", cfg, {
2147
+ command: `curl -fsS --max-time 5 -X POST http://localhost:${EDITOR_PORT}/api/editor-reload-config 2>/dev/null || true`,
2148
+ }),
2149
+ { stdio: ["ignore", "inherit", "inherit"] },
2150
+ );
2151
+ if (reload.status !== 0) {
2152
+ console.error(
2153
+ ` Warning: heal wrote editor.local.json but /api/editor-reload-config did ` +
2154
+ `not return cleanly — the next monitor tick or cloud:up touch-point will reconcile.`,
2155
+ );
2156
+ }
2157
+ console.error(
2158
+ ` Live Preview origin healed. If the operator <N>-preview tunnel is not ` +
2159
+ `published yet, the cloudflared-monitor's periodic tick provides the ongoing heal.`,
2160
+ );
2161
+ }
2162
+
2163
+ /** Write the chosen agent provider and local API keys into editor.local.json on
2164
+ * the VM BEFORE compose up so the file exists when the bind-mount happens —
2165
+ * the provider seed MUST run before ensureEditorContainerUp, because the editor
2166
+ * caches config.provider at startup and reports it as currentProvider; a post-start
2167
+ * seed leaves the running editor (and therefore the dashboard badge) on the default
2168
+ * "claude" until a config reload. */
1563
2169
  async function seedAgentApiKey(cfg) {
2170
+ // The provider seed is unconditional — `buildEditorLocalEnvPatch` always
2171
+ // returns a patch carrying `provider`, so the chosen agent always lands on
2172
+ // the VM. The API-key seed is opportunistic: when the matching key isn't set
2173
+ // locally the patch carries `provider` only, and we still print the
2174
+ // interactive-auth fallback line so the operator knows to authenticate the
2175
+ // agent on the VM later (Gemini's normal path).
1564
2176
  const patch = buildEditorLocalEnvPatch(process.env, cfg.agent);
1565
- if (!patch) {
2177
+ const hasKey = Boolean(patch.env);
2178
+ if (!hasKey) {
1566
2179
  console.error(
1567
- `\nNo ${agentEnvVarName(cfg.agent)} set locally — skipping agent auth seed.\n` +
1568
- `Authenticate ${cfg.agent} later with:\n` +
1569
- ` gcloud compute ssh ${cfg.instanceName} --project ${cfg.project} --zone ${cfg.zone} ` +
1570
- `--tunnel-through-iap -- ${cfg.agent} auth login\n`,
2180
+ `\nNo ${agentEnvVarName(cfg.agent)} set locally — seeding provider only.\n` +
2181
+ `Authenticate ${cfg.agent} on the VM by either:\n` +
2182
+ ` • exporting ${agentEnvVarName(cfg.agent)} locally and re-running cloud:up, or\n` +
2183
+ ` • pasting the key into the Build tab's auth gate in the editor.\n` +
2184
+ `Either path now flows the key through to the agent process on the VM.\n` +
2185
+ `Interactive browser-based OAuth login (e.g. \`codex login\`) is not yet ` +
2186
+ `supported headlessly on the VM.\n`,
1571
2187
  );
1572
- return;
1573
2188
  }
1574
2189
  const repoName = resolveProject(cfg).name;
1575
2190
  const remotePath = `~/projects/${repoName}/.codeyam/editor.local.json`;
1576
- console.error(`Seeding ${agentEnvVarName(cfg.agent)} into ${remotePath}...`);
2191
+ const seedLabel = hasKey
2192
+ ? `Seeding ${agentEnvVarName(cfg.agent)} into ${remotePath}...`
2193
+ : `Seeding provider '${cfg.agent}' into ${remotePath}...`;
2194
+ console.error(seedLabel);
1577
2195
 
1578
2196
  const read = runGcloud(
1579
2197
  buildGcloudArgs("ssh-probe", cfg, {
@@ -1606,6 +2224,71 @@ async function seedAgentApiKey(cfg) {
1606
2224
  if (write.status !== 0) {
1607
2225
  throw new Error(`Failed to write ${remotePath} on VM`);
1608
2226
  }
2227
+
2228
+ await ensureAgentCliInstalled(cfg);
2229
+ }
2230
+
2231
+ /** Install the selected agent's CLI inside the editor container on the live VM */
2232
+ async function ensureAgentCliInstalled(cfg) {
2233
+ const agent = cfg.agent;
2234
+ const pkg = agentNpmPackage(agent);
2235
+ const container = process.env.CONTAINER || "codeyam-editor-editor-1";
2236
+
2237
+ // First, check if the container is running on the VM host.
2238
+ // If the container is not running, we do nothing and return.
2239
+ const checkProbe = runGcloud(
2240
+ buildGcloudArgs("ssh-probe", cfg, {
2241
+ command: `docker ps --filter name=^/${container}$ --filter status=running --format '{{.Name}}'`,
2242
+ }),
2243
+ { stdio: ["ignore", "pipe", "ignore"] },
2244
+ );
2245
+
2246
+ const isRunning = checkProbe.status === 0 && checkProbe.stdout.trim() !== "";
2247
+ if (!isRunning) {
2248
+ console.error(` Container ${container} is not running. Skipping build-agent CLI install for now.`);
2249
+ return;
2250
+ }
2251
+
2252
+ console.error(`Ensuring build-agent CLI for '${agent}' is installed in container '${container}'...`);
2253
+
2254
+ const containerScript = [
2255
+ "#!/usr/bin/env bash",
2256
+ "set -euo pipefail",
2257
+ `BINARY="${agent}"`,
2258
+ `PACKAGE="${pkg}"`,
2259
+ `AGENT="${agent}"`,
2260
+ "",
2261
+ "if command -v \"$BINARY\" >/dev/null 2>&1; then",
2262
+ " echo \" ✓ $BINARY already installed on PATH\"",
2263
+ " exit 0",
2264
+ "fi",
2265
+ "",
2266
+ "echo \"Installing $PACKAGE globally in container...\"",
2267
+ "if [ \"$AGENT\" = \"claude\" ]; then",
2268
+ " rm -rf /usr/lib/node_modules/@anthropic-ai/.claude-code-*",
2269
+ " npm install -g \"$PACKAGE\"",
2270
+ " RESOLVED=$(find /usr/lib/node_modules/@anthropic-ai -name claude -type f -path '*linux-x64*' 2>/dev/null | head -1)",
2271
+ " if [ -n \"$RESOLVED\" ]; then",
2272
+ " ln -sf \"$RESOLVED\" /usr/local/bin/claude",
2273
+ " fi",
2274
+ "else",
2275
+ " npm install -g \"$PACKAGE\"",
2276
+ "fi",
2277
+ "echo \" ✓ Successfully installed $BINARY\"",
2278
+ ].join("\n");
2279
+
2280
+ const hostCommand = `docker exec -i ${container} bash -s <<'EOF'\n${containerScript}\nEOF`;
2281
+
2282
+ const installCmd = runGcloud(
2283
+ buildGcloudArgs("ssh-probe", cfg, {
2284
+ command: hostCommand,
2285
+ }),
2286
+ { stdio: ["ignore", "inherit", "inherit"] }
2287
+ );
2288
+
2289
+ if (installCmd.status !== 0) {
2290
+ throw new Error(`Failed to install build-agent CLI in container for agent: ${agent}`);
2291
+ }
1609
2292
  }
1610
2293
 
1611
2294
  async function openTunnel(cfg, appPort = VITE_PORT) {
@@ -1649,38 +2332,644 @@ function promptForInstanceName(expected) {
1649
2332
  });
1650
2333
  }
1651
2334
 
2335
+ const STREAMED_SCRIPTS = ["scripts/gcp-bootstrap.sh", "scripts/gcp-project-bootstrap.sh"];
2336
+
2337
+ /** Check if the streamed bootstrap scripts have drifted from the remote repository
2338
+ * and abort provisioning if they are out of date to prevent silent container/VM failures. */
2339
+ function checkScriptDrift(cfg) {
2340
+ if (process.env.CODEYAM_SKIP_SCRIPT_DRIFT_CHECK === "1") {
2341
+ console.error(" [Warning] CODEYAM_SKIP_SCRIPT_DRIFT_CHECK=1 active. Skipping streamed-script drift preflight check.");
2342
+ return;
2343
+ }
2344
+
2345
+ const { editorBranch = "main" } = resolveEditorConfig(cfg);
2346
+
2347
+ console.error(`Checking streamed-script drift against origin/${editorBranch}...`);
2348
+
2349
+ // 1. git fetch origin <editorBranch>
2350
+ const fetchResult = spawnSync("git", ["fetch", "origin", editorBranch], { encoding: "utf8" });
2351
+ if (fetchResult.status !== 0) {
2352
+ console.error(` [Warning] Failed to fetch origin ${editorBranch} (${(fetchResult.stderr || "").trim() || "unknown error"}). Skipping drift check.`);
2353
+ return;
2354
+ }
2355
+
2356
+ // 2. Find the merge base between HEAD and origin/<editorBranch>
2357
+ const mbResult = spawnSync("git", ["merge-base", "HEAD", `origin/${editorBranch}`], { encoding: "utf8" });
2358
+ let mergeBase = null;
2359
+ if (mbResult.status === 0) {
2360
+ mergeBase = mbResult.stdout.trim();
2361
+ }
2362
+
2363
+ const filesData = [];
2364
+
2365
+ for (const scriptPath of STREAMED_SCRIPTS) {
2366
+ // a. localHash
2367
+ const localHashResult = spawnSync("git", ["hash-object", scriptPath], { encoding: "utf8" });
2368
+ if (localHashResult.status !== 0) {
2369
+ console.error(` [Warning] Failed to compute local hash for ${scriptPath}. Skipping drift check.`);
2370
+ return;
2371
+ }
2372
+ const localHash = localHashResult.stdout.trim();
2373
+
2374
+ // b. remoteHash (from origin/<editorBranch>)
2375
+ let remoteHash = null;
2376
+ const remoteHashResult = spawnSync("git", ["rev-parse", `origin/${editorBranch}:${scriptPath}`], { encoding: "utf8" });
2377
+ if (remoteHashResult.status === 0) {
2378
+ remoteHash = remoteHashResult.stdout.trim();
2379
+ }
2380
+
2381
+ // c. baseHash (from mergeBase)
2382
+ let baseHash = null;
2383
+ if (mergeBase) {
2384
+ const baseHashResult = spawnSync("git", ["rev-parse", `${mergeBase}:${scriptPath}`], { encoding: "utf8" });
2385
+ if (baseHashResult.status === 0) {
2386
+ baseHash = baseHashResult.stdout.trim();
2387
+ }
2388
+ }
2389
+
2390
+ // d. dirty status
2391
+ const statusResult = spawnSync("git", ["status", "--porcelain", scriptPath], { encoding: "utf8" });
2392
+ const dirty = statusResult.status === 0 && statusResult.stdout.trim() !== "";
2393
+
2394
+ filesData.push({
2395
+ path: scriptPath,
2396
+ localHash,
2397
+ remoteHash,
2398
+ baseHash,
2399
+ dirty
2400
+ });
2401
+ }
2402
+
2403
+ const { classifyScriptDrift } = require("./script-drift");
2404
+ const verdictResult = classifyScriptDrift({ files: filesData });
2405
+
2406
+ if (verdictResult.verdict === "stale" || verdictResult.verdict === "diverged") {
2407
+ console.error("");
2408
+ console.error("================================================================================");
2409
+ console.error(`✗ ERROR: Streamed scripts have drifted from origin/${editorBranch}!`);
2410
+ console.error(` Verdict: ${verdictResult.verdict.toUpperCase()}`);
2411
+ console.error(" The following files are out of date on your machine:");
2412
+ for (const f of verdictResult.staleFiles) {
2413
+ console.error(` - ${f}`);
2414
+ }
2415
+ console.error("");
2416
+ console.error(" To proceed safely, please update your local checkout first:");
2417
+ console.error(` git pull --no-rebase origin ${editorBranch}`);
2418
+ console.error("");
2419
+ console.error(" If you must bypass this check (emergency provisioning), set:");
2420
+ console.error(" CODEYAM_SKIP_SCRIPT_DRIFT_CHECK=1");
2421
+ console.error("================================================================================");
2422
+ console.error("");
2423
+ process.exit(1);
2424
+ } else if (verdictResult.verdict === "local-ahead") {
2425
+ console.error(" [Warning] Streaming locally-modified scripts to VM (developer mode).");
2426
+ } else {
2427
+ console.error(" ✓ Streamed scripts are in sync with remote.");
2428
+ }
2429
+ }
2430
+
1652
2431
  // =============================================================================
1653
2432
  // Subcommand handlers
1654
2433
  // =============================================================================
1655
2434
 
1656
- async function cmdUp(args) {
2435
+ /** Decide whether a VM's Add/Destroy should wire LB ingress, and with what
2436
+ * coordinates. Wiring fires iff a fleet domain is configured (cloud.json
2437
+ * `fleetDomain` or FLEET_DOMAIN env) AND the instance is a numbered fleet VM
2438
+ * (`resolveFleetVmNumber` → non-null). A non-fleet client project, or no domain,
2439
+ * returns `{ wire: false }` so the legacy quick-tunnel path is untouched. A
2440
+ * malformed domain throws (via resolveIngressDomain) — a typo must fail loud.
2441
+ * `env` is injectable for tests. Pure decision; the spawn lives in runVmIngress. */
2442
+ function ingressWiringPlan(cfg = {}, env = process.env) {
2443
+ const domain = resolveIngressDomain(cfg, env);
2444
+ if (!domain) return { wire: false, reason: "no-domain" };
2445
+ const vmNumber = resolveFleetVmNumber(cfg.instanceName);
2446
+ if (vmNumber == null) return { wire: false, reason: "not-fleet-vm", domain };
2447
+ // Resolve the custom hostname slug, if any: the Add-time --vm-slug flag on cfg
2448
+ // wins (validated/normalized in cmdUp), else the persisted sidecar entry (the
2449
+ // Destroy path, where cfg carries no slug). null ⇒ the numeric vm-<N> host.
2450
+ const slug =
2451
+ cfg.vmSlug && String(cfg.vmSlug).trim() !== ""
2452
+ ? normalizeVmSlug(cfg.vmSlug)
2453
+ : slugFor(vmNumber);
2454
+ return { wire: true, domain, vmNumber, slug };
2455
+ }
2456
+
2457
+ /** The fleet instance-name prefix vm-ingress.sh expects (`<base>-`, e.g.
2458
+ * `codeyam-editor-codeyam-editor-`). Derived from the canonical editor repo so
2459
+ * it stays in lockstep with `resolveFleetVmNumber`'s base, rather than relying
2460
+ * on the script's hard-coded default. */
2461
+ function fleetInstancePrefix() {
2462
+ return `${deriveInstanceName(CANONICAL_EDITOR_REPO)}-`;
2463
+ }
2464
+
2465
+ /** Wire (`up`) or unwire (`down`) one VM's LB backends by spawning
2466
+ * scripts/gcp/vm-ingress.sh with the resolved coordinates. Returns the spawnSync
2467
+ * result so callers decide fatality — Add treats a non-zero as a loud warning
2468
+ * (the VM is provisioned; ingress can be re-wired), Destroy likewise warns so a
2469
+ * teardown still removes the instance. */
2470
+ function runVmIngress(cfg, action, vmNumber, domain, appPort, slug) {
2471
+ const scriptPath = path.join(rootDir, "scripts", "gcp", "vm-ingress.sh");
2472
+ const inv = vmIngressInvocation({
2473
+ scriptPath,
2474
+ vmNumber,
2475
+ action,
2476
+ domain,
2477
+ project: cfg.project,
2478
+ zone: cfg.zone,
2479
+ appPort,
2480
+ instancePrefix: fleetInstancePrefix(),
2481
+ slug,
2482
+ });
2483
+ return spawnSync(inv.command, inv.args, {
2484
+ stdio: "inherit",
2485
+ env: { ...process.env, ...inv.env },
2486
+ });
2487
+ }
2488
+
2489
+ /** Add-time hook: wire VM-N onto the shared LB when a fleet domain is set.
2490
+ * Best-effort + loud — a wiring hiccup must never abort an otherwise-green Add
2491
+ * (the VM is reachable operator-side over SSH regardless), so on failure it
2492
+ * prints the exact re-run command rather than throwing. No-op (and silent) in
2493
+ * legacy quick-tunnel mode or for a non-fleet client project. */
2494
+ function wireVmIngressOnAdd(cfg, appPort, env = process.env) {
2495
+ const plan = ingressWiringPlan(cfg, env);
2496
+ if (!plan.wire) return;
2497
+ const label = plan.slug || `vm-${plan.vmNumber}`;
2498
+ console.error(` wiring LB ingress for VM-${plan.vmNumber} on ${plan.domain}...`);
2499
+ const res = runVmIngress(cfg, "up", plan.vmNumber, plan.domain, appPort, plan.slug);
2500
+ if (res.status === 0) {
2501
+ console.error(` ✓ LB ingress wired: https://${label}.editor.${plan.domain}`);
2502
+ } else {
2503
+ const rewire =
2504
+ `FLEET_DOMAIN=${plan.domain}${plan.slug ? ` VM_SLUG=${plan.slug}` : ""} ` +
2505
+ `bash scripts/gcp/vm-ingress.sh ${plan.vmNumber} up`;
2506
+ console.error(
2507
+ ` ⚠ LB ingress wiring for VM-${plan.vmNumber} did not complete cleanly. ` +
2508
+ `The VM is provisioned; re-wire with:\n ${rewire}`,
2509
+ );
2510
+ // Machine-readable degraded marker so the unwired VM lands an amber card with
2511
+ // its remediation instead of a falsely-green "+ wired" (the VM7-10 bug). The
2512
+ // dashboard's add-job scanner (server.js) keys off `CY_DEGRADED <reason>`,
2513
+ // exactly as it does for a failed creds copy / `npm run setup`. Best-effort:
2514
+ // the Add stays green (the VM is reachable operator-side) — the marker makes
2515
+ // the missing public ingress VISIBLE, it doesn't abort provisioning.
2516
+ console.log(
2517
+ `CY_DEGRADED ingress-not-wired: VM-${plan.vmNumber} has no public LB ingress ` +
2518
+ `(vm-ingress.sh up exited ${res.status == null ? "abnormally" : res.status}). Re-wire: ${rewire}`,
2519
+ );
2520
+ }
2521
+ }
2522
+
2523
+ /** Destroy-time hook: unwire VM-N's LB backends before the instance is deleted,
2524
+ * mirroring the destroy-time quick-tunnel cleanup so no orphaned NEG / backend
2525
+ * service / host rule is left behind. Best-effort + loud, and wrapped so a
2526
+ * malformed-domain throw can't block the instance deletion the operator asked
2527
+ * for. No-op in legacy mode / for a non-fleet project. Purely LB ingress
2528
+ * teardown — slug cleanup is NOT done here (it was, but that coupled slug
2529
+ * release to `plan.wire` and stranded non-wired VMs' slugs); `cmdDestroy` now
2530
+ * owns the unconditional `removeSlug`. */
2531
+ function unwireVmIngressOnDestroy(cfg, env = process.env) {
2532
+ let plan;
2533
+ try {
2534
+ plan = ingressWiringPlan(cfg, env);
2535
+ } catch (e) {
2536
+ console.error(` Warning: skipped LB ingress teardown (${e.message}).`);
2537
+ return;
2538
+ }
2539
+ if (!plan.wire) return;
2540
+ console.error(` unwiring LB ingress for VM-${plan.vmNumber} on ${plan.domain}...`);
2541
+ // Pass the SAME slug the host rule was wired with so the slug host (not a stale
2542
+ // vm-<N> one) is removed; resolved from the sidecar by ingressWiringPlan.
2543
+ const res = runVmIngress(cfg, "down", plan.vmNumber, plan.domain, undefined, plan.slug);
2544
+ if (res.status !== 0) {
2545
+ console.error(
2546
+ ` Warning: LB ingress teardown for VM-${plan.vmNumber} did not complete; ` +
2547
+ `orphaned LB resources may remain. Re-run: ` +
2548
+ `FLEET_DOMAIN=${plan.domain}${plan.slug ? ` VM_SLUG=${plan.slug}` : ""} bash scripts/gcp/vm-ingress.sh ${plan.vmNumber} down`,
2549
+ );
2550
+ }
2551
+ }
2552
+
2553
+ /** Grant or revoke per-VM IAP access by spawning scripts/gcp/iap-access.sh with
2554
+ * the resolved coordinates. Returns the spawnSync result so callers decide
2555
+ * fatality — Add treats a non-zero as a loud warning (the VM is provisioned;
2556
+ * access can be re-applied), Destroy likewise warns so a teardown still removes
2557
+ * the instance. `principals` is the validated list to grant/revoke. */
2558
+ function runIapAccess(cfg, vmNumber, action, principals) {
2559
+ const scriptPath = path.join(rootDir, "scripts", "gcp", "iap-access.sh");
2560
+ const inv = iapAccessInvocation({
2561
+ scriptPath,
2562
+ vmNumber,
2563
+ action,
2564
+ principals,
2565
+ project: cfg.project,
2566
+ zone: cfg.zone,
2567
+ });
2568
+ return spawnSync(inv.command, inv.args, {
2569
+ stdio: "inherit",
2570
+ env: { ...process.env, ...inv.env },
2571
+ });
2572
+ }
2573
+
2574
+ /** Add-time hook: reconcile VM-N's access whitelist to *exactly* the requested
2575
+ * list, making provision the authoritative clean-slate point so a freed-and-
2576
+ * reused VM number never inherits the previous tenant's grants. Computes the
2577
+ * principals the prior tenant had that this Add does not want (`staleOnly`) and
2578
+ * revokes them from the per-VM backend services, grants the requested list, then
2579
+ * rewrites the sidecar to exactly the desired list (clearing the entry when the
2580
+ * list is empty). The gcloud revoke/grant only fire when the VM is wired onto the
2581
+ * LB (a fleet domain is set) — the backend services the bindings target exist
2582
+ * only then — but the sidecar is reconciled regardless, so a domain-less project
2583
+ * still starts clean and a later domain'd re-add inherits nothing stale. Add no
2584
+ * longer depends on Destroy having cleaned up (a dashboard destroy bypasses the
2585
+ * CLI revoke path). Best-effort + loud, the same posture as wireVmIngressOnAdd:
2586
+ * a revoke/grant hiccup (e.g. the SA missing IAP-admin) prints the exact re-run
2587
+ * command rather than aborting an otherwise-green Add. `cfg.vmAccess` is the
2588
+ * array (or comma-separated string) cmdUp validated; absent ⇒ an empty desired
2589
+ * list (the clean-slate case). */
2590
+ function applyVmAccessOnAdd(cfg, env = process.env) {
2591
+ const desired = normalizeAccessList(cfg.vmAccess || []);
2592
+ let plan;
2593
+ try {
2594
+ plan = ingressWiringPlan(cfg, env);
2595
+ } catch {
2596
+ // A malformed domain must not block an otherwise-green Add; fall back to the
2597
+ // name-derived number so the sidecar still reconciles to a clean slate.
2598
+ plan = { wire: false };
2599
+ }
2600
+ const vmNumber = plan.wire ? plan.vmNumber : resolveFleetVmNumber(cfg.instanceName);
2601
+ if (vmNumber == null) return; // not a fleet VM — nothing to reconcile
2602
+ const stale = accessFor(vmNumber);
2603
+ const staleOnly = stale.filter((p) => !desired.includes(p));
2604
+
2605
+ if (plan.wire) {
2606
+ if (staleOnly.length > 0) {
2607
+ console.error(` revoking ${staleOnly.length} stale IAP principal(s) from VM-${vmNumber} (prior tenant)...`);
2608
+ const res = runIapAccess(cfg, vmNumber, "revoke", staleOnly);
2609
+ if (res.status !== 0) {
2610
+ console.error(
2611
+ ` ⚠ IAP access revoke for VM-${vmNumber} did not complete cleanly. ` +
2612
+ `The sidecar is reconciled; re-revoke with:\n` +
2613
+ ` VM_ACCESS=${staleOnly.join(",")} bash scripts/gcp/iap-access.sh ${vmNumber} revoke`,
2614
+ );
2615
+ }
2616
+ }
2617
+ if (desired.length > 0) {
2618
+ console.error(` granting IAP access to VM-${vmNumber} for ${desired.length} principal(s)...`);
2619
+ const res = runIapAccess(cfg, vmNumber, "grant", desired);
2620
+ if (res.status === 0) {
2621
+ console.error(` ✓ IAP access granted: ${desired.join(", ")}`);
2622
+ } else {
2623
+ console.error(
2624
+ ` ⚠ IAP access grant for VM-${vmNumber} did not complete cleanly. ` +
2625
+ `The VM is provisioned; re-grant with:\n` +
2626
+ ` VM_ACCESS=${desired.join(",")} bash scripts/gcp/iap-access.sh ${vmNumber} grant`,
2627
+ );
2628
+ }
2629
+ }
2630
+ } else if (desired.length > 0) {
2631
+ console.error(
2632
+ ` (no fleet domain / not a fleet VM — IAP access for ${desired.length} principal(s) not applied; ` +
2633
+ `set a fleet domain and re-run, or use cloud:access)`,
2634
+ );
2635
+ }
2636
+
2637
+ // Sidecar reconcile (always, for a fleet VM): record exactly what was granted —
2638
+ // the desired list when wired, or empty when not (nothing was applied without
2639
+ // backend services). Either way setAccess deletes the entry on empty, so a
2640
+ // reused number never carries a prior tenant's stale entry forward into the
2641
+ // dashboard display or a later domain'd re-add.
2642
+ const granted = plan.wire ? desired : [];
2643
+ try {
2644
+ setAccess(vmNumber, granted);
2645
+ } catch (e) {
2646
+ console.error(` ⚠ could not persist VM-${vmNumber} access list (${e.message}).`);
2647
+ }
2648
+ }
2649
+
2650
+ // The directory the add-tail in-flight marker lives in — `os.tmpdir()` (not a
2651
+ // hard-coded `/tmp`) so the windows-portability static check stays happy and the
2652
+ // dashboard reads the same path via the shared `ingressInflightMarkerPath` builder.
2653
+ const INGRESS_INFLIGHT_DIR = os.tmpdir();
2654
+
2655
+ /** Start the slow LB-ingress wiring (+ IAP access grant) for a just-added VM as a
2656
+ * DETACHED background child and return immediately, so the Add completes at
2657
+ * container-healthy instead of blocking ~10 min on the wiring tail. The detached
2658
+ * child re-execs THIS cloud.js as `wire-add-tail` in its OWN session
2659
+ * (`detached: true` + `unref`), so when the dashboard reaps `cloud:up` the wiring
2660
+ * — and any in-flight `backend-services create` — is never SIGTERMed (the add-time
2661
+ * 503 strand). cmdUp writes the in-flight marker BEFORE the spawn so it exists the
2662
+ * instant this returns (the dashboard auto-rewire gather then skips this VM until
2663
+ * its first wiring finishes); the child unlinks it on completion. A no-op when the
2664
+ * add wouldn't wire anyway (legacy quick-tunnel / non-fleet) — there the cheap
2665
+ * sidecar reconcile runs inline. Best-effort + loud: a spawn failure falls back to
2666
+ * running the tail inline rather than dropping the wiring on the floor. */
2667
+ function startDetachedAddWiring(cfg, appPort, env = process.env) {
2668
+ let plan;
2669
+ try {
2670
+ plan = ingressWiringPlan(cfg, env);
2671
+ } catch {
2672
+ plan = { wire: false };
2673
+ }
2674
+ if (!plan.wire) {
2675
+ // Nothing slow to detach: wiring is a no-op and the access reconcile is a cheap
2676
+ // sidecar write. Run them inline so the clean-slate invariant still holds.
2677
+ wireVmIngressOnAdd(cfg, appPort, env);
2678
+ applyVmAccessOnAdd(cfg, env);
2679
+ return;
2680
+ }
2681
+ const marker = ingressInflightMarkerPath(INGRESS_INFLIGHT_DIR, plan.vmNumber);
2682
+ const logPath = path.join(INGRESS_INFLIGHT_DIR, `cy-wire-add-vm-${plan.vmNumber}.log`);
2683
+ try {
2684
+ fs.writeFileSync(marker, String(Date.now()));
2685
+ const out = fs.openSync(logPath, "a");
2686
+ const inv = detachedAddTailInvocation({
2687
+ nodePath: process.execPath,
2688
+ scriptPath: __filename,
2689
+ cfg,
2690
+ appPort,
2691
+ });
2692
+ const child = spawn(inv.command, inv.args, {
2693
+ detached: true,
2694
+ stdio: ["ignore", out, out],
2695
+ env: { ...process.env, ...inv.env },
2696
+ });
2697
+ child.unref();
2698
+ fs.closeSync(out);
2699
+ console.error(
2700
+ ` LB ingress wiring for VM-${plan.vmNumber} on ${plan.domain} continues in the ` +
2701
+ `background (pid ${child.pid}; log ${logPath}). The Add is complete at ` +
2702
+ `container-healthy; the dashboard reconcile loop converges the public URL.`,
2703
+ );
2704
+ } catch (e) {
2705
+ console.error(
2706
+ ` ⚠ could not detach LB ingress wiring for VM-${plan.vmNumber} (${e && e.message}); ` +
2707
+ `running it inline instead.`,
2708
+ );
2709
+ try { fs.unlinkSync(marker); } catch { /* marker may not exist */ }
2710
+ wireVmIngressOnAdd(cfg, appPort, env);
2711
+ applyVmAccessOnAdd(cfg, env);
2712
+ }
2713
+ }
2714
+
2715
+ /** `wire-add-tail` (internal): the DETACHED background continuation
2716
+ * `startDetachedAddWiring` spawns. Reads the Add's `cfg`/`appPort` back out of the
2717
+ * env (so it wires EXACTLY what the foreground Add resolved), runs the wiring then
2718
+ * the IAP access grant in the SAME order cmdUp ran them inline, and ALWAYS unlinks
2719
+ * the in-flight marker on the way out — even on a wiring failure — so the
2720
+ * dashboard's auto-rewire backstop takes over instead of being pinned off. Not a
2721
+ * user-facing subcommand; invoked only via re-exec. */
2722
+ function cmdWireAddTail() {
2723
+ let cfg = {};
2724
+ try {
2725
+ cfg = JSON.parse(process.env.CY_WIRE_ADD_CFG || "{}");
2726
+ } catch (e) {
2727
+ console.error(`wire-add-tail: could not parse CY_WIRE_ADD_CFG (${e && e.message}).`);
2728
+ return;
2729
+ }
2730
+ const appPort = process.env.CY_WIRE_ADD_PORT || undefined;
2731
+ let marker = null;
2732
+ try {
2733
+ const vmNumber = resolveFleetVmNumber(cfg.instanceName);
2734
+ if (vmNumber != null) {
2735
+ marker = ingressInflightMarkerPath(INGRESS_INFLIGHT_DIR, vmNumber);
2736
+ }
2737
+ } catch { /* fall through — marker cleanup is best-effort */ }
2738
+ try {
2739
+ wireVmIngressOnAdd(cfg, appPort);
2740
+ applyVmAccessOnAdd(cfg);
2741
+ } finally {
2742
+ if (marker) {
2743
+ try { fs.unlinkSync(marker); } catch { /* already gone — fine */ }
2744
+ }
2745
+ }
2746
+ }
2747
+
2748
+ /** Destroy-time hook: revoke VM-N's per-backend IAP bindings and clear its access
2749
+ * sidecar entry so a freed-and-reused VM number doesn't inherit a stale
2750
+ * whitelist. Runs BEFORE unwireVmIngressOnDestroy (which deletes the backend
2751
+ * services the bindings live on) so the revoke targets resources that still
2752
+ * exist. Best-effort + loud; the sidecar is always cleared even if the gcloud
2753
+ * revoke warns. No-op for a non-fleet instance or an empty whitelist. */
2754
+ function revokeVmAccessOnDestroy(cfg, env = process.env) {
2755
+ let vmNumber;
2756
+ try {
2757
+ const plan = ingressWiringPlan(cfg, env);
2758
+ vmNumber = plan.wire ? plan.vmNumber : resolveFleetVmNumber(cfg.instanceName);
2759
+ } catch {
2760
+ // A malformed domain must not block teardown — fall back to the name-derived
2761
+ // number so the sidecar still gets cleared.
2762
+ vmNumber = resolveFleetVmNumber(cfg.instanceName);
2763
+ }
2764
+ if (vmNumber == null) return;
2765
+ const list = accessFor(vmNumber);
2766
+ if (list.length > 0) {
2767
+ console.error(` revoking IAP access for VM-${vmNumber} (${list.length} principal(s))...`);
2768
+ const res = runIapAccess(cfg, vmNumber, "revoke", list);
2769
+ if (res.status !== 0) {
2770
+ console.error(
2771
+ ` Warning: IAP access revoke for VM-${vmNumber} did not complete; ` +
2772
+ `bindings are removed when the backend service is deleted, but verify with: ` +
2773
+ `VM_ACCESS=${list.join(",")} bash scripts/gcp/iap-access.sh ${vmNumber} revoke`,
2774
+ );
2775
+ }
2776
+ }
2777
+ try {
2778
+ clearAccess(vmNumber);
2779
+ } catch (e) {
2780
+ console.error(` Warning: could not clear access list for VM-${vmNumber} (${e.message}).`);
2781
+ }
2782
+ }
2783
+
2784
+ /** Destroy-time hook: free the VM's custom slug so a freed-and-reused VM number
2785
+ * doesn't inherit a stale slug — and, critically, so the name is re-Addable.
2786
+ * Resolved purely from the instance name (NOT the ingress plan) so a non-wired /
2787
+ * legacy / quick-tunnel VM still releases its slug; coupling this to
2788
+ * `plan.wire` was the bug that stranded VM-7's `harvardintech`. Best-effort +
2789
+ * loud so a sidecar write hiccup can't block the teardown. No-op for a
2790
+ * non-fleet instance. Returns the released VM number, or null when none. */
2791
+ function releaseVmSlugOnDestroy(cfg) {
2792
+ const vmNumber = resolveFleetVmNumber(cfg.instanceName);
2793
+ if (vmNumber == null) return null;
2794
+ try {
2795
+ removeSlug(vmNumber);
2796
+ } catch (e) {
2797
+ console.error(` Warning: could not clear slug for VM-${vmNumber} (${e.message}).`);
2798
+ }
2799
+ return vmNumber;
2800
+ }
2801
+
2802
+ async function cmdUp(args, projectDir = rootDir) {
1657
2803
  ensureGcloud();
1658
2804
  const flags = parseFlags(args);
1659
- const cfg = loadOrInitCloudConfig(rootDir, flags);
2805
+ const cfg = loadOrInitCloudConfig(projectDir, flags);
1660
2806
  // Fail loud BEFORE creating a GCE instance if an unprovisionable editor
1661
2807
  // variant was requested — otherwise we'd spin up a VM, build a dev image,
1662
2808
  // and silently ignore the operator's staging/production intent.
1663
2809
  assertVariantProvisionable(resolveEditorConfig(cfg).editorVariant);
1664
2810
 
2811
+ // Validate a custom --vm-slug BEFORE creating any GCE instance so a bad or
2812
+ // duplicate name fails loud at the boundary, never after a VM is provisioned.
2813
+ // A slug is only meaningful for a numbered fleet VM; on a non-fleet client
2814
+ // project it's dropped (with a note) so the legacy path is untouched.
2815
+ if (cfg.vmSlug != null && String(cfg.vmSlug).trim() !== "") {
2816
+ const slug = normalizeVmSlug(cfg.vmSlug); // throws on bad format/reserved/vm-N
2817
+ const vmNumber = resolveFleetVmNumber(cfg.instanceName);
2818
+ if (vmNumber == null) {
2819
+ console.error(
2820
+ ` (instance '${cfg.instanceName}' is not a fleet VM — ignoring --vm-slug ${slug})`,
2821
+ );
2822
+ delete cfg.vmSlug;
2823
+ } else {
2824
+ const owner = vmForSlug(slug);
2825
+ if (owner != null && owner !== vmNumber) {
2826
+ throw new Error(
2827
+ `VM slug "${slug}" is already assigned to VM-${owner} — pick another name`,
2828
+ );
2829
+ }
2830
+ cfg.vmSlug = slug; // carry the normalized form for seeding + wiring
2831
+ }
2832
+ }
2833
+
2834
+ // Validate a custom --vm-access BEFORE creating any GCE instance so a malformed
2835
+ // principal fails loud at the boundary, never after a VM is provisioned (and
2836
+ // never reaches gcloud). Like --vm-slug it's only meaningful for a numbered
2837
+ // fleet VM; on a non-fleet client project it's dropped with a note. The
2838
+ // normalized array is carried on cfg for the post-provision grant.
2839
+ if (cfg.vmAccess != null && String(cfg.vmAccess).trim() !== "") {
2840
+ const principals = normalizeAccessList(cfg.vmAccess); // throws on malformed
2841
+ const vmNumber = resolveFleetVmNumber(cfg.instanceName);
2842
+ if (vmNumber == null) {
2843
+ console.error(
2844
+ ` (instance '${cfg.instanceName}' is not a fleet VM — ignoring --vm-access)`,
2845
+ );
2846
+ delete cfg.vmAccess;
2847
+ } else {
2848
+ cfg.vmAccess = principals; // carry the normalized list for the grant hook
2849
+ }
2850
+ }
2851
+
2852
+ checkScriptDrift(cfg);
2853
+
1665
2854
  await ensureFirewallRule(cfg);
1666
- await ensureInstance(cfg);
1667
- await ensureBootstrapped(cfg);
1668
- await ensureProjectBootstrapped(cfg);
1669
- // Resolve the app's dev-server port from the VM's now-bootstrapped config so
1670
- // the previewOrigin seed and the laptop tunnel both target it (not 5173).
1671
- const appPort = await readVmAppPort(cfg);
1672
- await seedPreviewOrigin(cfg, appPort);
1673
- // Write the GitHub token BEFORE compose up so the credentials file exists
1674
- // when the bind-mount happens — bind-mounting a missing file would silently
1675
- // create a directory instead and the agent's first `git pull` would fail.
1676
- await seedGithubCredentials(cfg);
1677
- await ensureEditorContainerUp(cfg);
1678
- await seedAgentApiKey(cfg);
1679
- // Roster the VM into the fleet dashboard NOW that it's provisioned and the
1680
- // editor container is up. Runs BEFORE the --no-tunnel early-return so the
1681
- // dashboard's own `cloud:up --no-tunnel` Add path also rosters (idempotent
1682
- // with the dashboard's onSuccess roster — a double-add is a harmless no-op).
1683
- rosterProvisionedVm(cfg.instanceName);
2855
+ const { createdThisRun } = await ensureInstance(cfg);
2856
+
2857
+ try {
2858
+ await ensureBootstrapped(cfg);
2859
+ await ensureProjectBootstrapped(cfg);
2860
+ // Resolve the app's dev-server port from the VM's now-bootstrapped config so
2861
+ // the previewOrigin seed and the laptop tunnel both target it (not 5173).
2862
+ const appPort = await readVmAppPort(cfg);
2863
+ await seedPreviewOrigin(cfg, appPort);
2864
+ // Write the GitHub token BEFORE compose up so the credentials file exists
2865
+ // when the bind-mount happens — bind-mounting a missing file would silently
2866
+ // create a directory instead and the agent's first `git pull` would fail.
2867
+ try {
2868
+ await seedGithubCredentials(cfg, { allowNoCredentials: cfg.allowNoGitCredentials });
2869
+ } catch (credErr) {
2870
+ if (credErr instanceof MissingGitPushCredentialsError) {
2871
+ // Never silently healthy: land an amber `CY_DEGRADED` card with the
2872
+ // recovery command (same machine-readable channel the dashboard scanner
2873
+ // reads for ingress-not-wired / failed setup) instead of aborting the
2874
+ // whole add. The VM is otherwise reachable + the Claude session works;
2875
+ // `cloud:reconcile-credentials` heals the push gap in place.
2876
+ console.log(`CY_DEGRADED ${credErr.degradedReason}`);
2877
+ console.error(credErr.message);
2878
+ } else {
2879
+ throw credErr;
2880
+ }
2881
+ }
2882
+ await seedAgentApiKey(cfg);
2883
+ await ensureEditorContainerUp(cfg);
2884
+ // Roster the VM into the fleet dashboard NOW that it's provisioned and the
2885
+ // editor container is up. Runs BEFORE the --no-tunnel early-return so the
2886
+ // dashboard's own `cloud:up --no-tunnel` Add path also rosters (idempotent
2887
+ // with the dashboard's onSuccess roster — a double-add is a harmless no-op).
2888
+ rosterProvisionedVm(cfg.instanceName);
2889
+ // Persist this VM's custom slug into the slug sidecar so the dashboard, the
2890
+ // ingress wiring below, and a later Destroy all resolve the same hostname.
2891
+ // Best-effort + loud (matching the roster-write posture): a sidecar-write
2892
+ // failure warns but never aborts an otherwise-green Add — the URLs simply
2893
+ // fall back to the numeric vm-<N> scheme. Only fires for a slug supplied on
2894
+ // a numbered fleet VM (the early validation already dropped a non-fleet one).
2895
+ if (cfg.vmSlug) {
2896
+ const slugVm = resolveFleetVmNumber(cfg.instanceName);
2897
+ try {
2898
+ setSlug(slugVm, cfg.vmSlug);
2899
+ console.error(` ✓ assigned slug "${cfg.vmSlug}" to VM-${slugVm}`);
2900
+ } catch (e) {
2901
+ console.error(
2902
+ ` ⚠ failed to persist slug "${cfg.vmSlug}" for VM-${slugVm} (${e && e.message}); ` +
2903
+ `URLs fall back to vm-${slugVm}.`,
2904
+ );
2905
+ delete cfg.vmSlug; // keep wiring + display consistent with what was persisted
2906
+ }
2907
+ }
2908
+ // Terminal gate: provisioning is NOT "done" until the editor container is
2909
+ // verifiably Up. Placed BEFORE the --no-tunnel early-return so the dashboard's
2910
+ // own `cloud:up --no-tunnel` Add path is covered too (it's the path that bit
2911
+ // us — a run reported success with no container). A failure here is a loud
2912
+ // non-zero exit, which the dashboard fail-fast-on-nonzero path acts on.
2913
+ await assertEditorContainerRunning(cfg);
2914
+ // Wire this VM onto the shared Google HTTPS LB when a fleet domain is
2915
+ // configured — the stable-URL replacement for the quick-tunnel mint. Runs
2916
+ // AFTER the terminal container gate so the editor/preview ports the NEG
2917
+ // targets are live, and is best-effort (the VM is reachable over SSH
2918
+ // regardless). The wiring (+ the IAP access grant that depends on it) is kicked
2919
+ // off DETACHED so the Add returns at container-healthy instead of blocking
2920
+ // ~10 min on the EXTERNAL_MANAGED backend-services create + propagation tail;
2921
+ // the detached child outlives this process so the dashboard's reap of cloud:up
2922
+ // can't SIGTERM an in-flight `backend-services create` (the add-time 503
2923
+ // strand), and the dashboard's auto-rewire gather converges the public URL in
2924
+ // the background. A no-op in legacy quick-tunnel mode / for a non-fleet project
2925
+ // (there the cheap sidecar reconcile runs inline). See
2926
+ // startDetachedAddWiring → wire-add-tail.
2927
+ startDetachedAddWiring(cfg, appPort);
2928
+ // Now that the editor answers on its own surface, ask it whether the
2929
+ // pre-container seed wrong-moded the previewOrigin (the stack.json race) and
2930
+ // heal it in place. Best-effort, never fatal — wrapped so a probe hiccup
2931
+ // can't abort an otherwise-green add.
2932
+ try {
2933
+ await healPreviewOriginAtReady(cfg, appPort);
2934
+ } catch (healErr) {
2935
+ console.error(
2936
+ ` Warning: READY preview-origin auto-heal probe failed (${healErr.message}); ` +
2937
+ `the cloudflared-monitor tick will reconcile.`,
2938
+ );
2939
+ }
2940
+ } catch (err) {
2941
+ if (createdThisRun) {
2942
+ console.error(
2943
+ `✗ provisioning aborted — tearing down the VM cloud:up created this run (no orphan left behind)`,
2944
+ );
2945
+ // Drop the slug + access sidecar entries for this brand-new VM so a
2946
+ // torn-down number doesn't leave a dangling slug or whitelist behind
2947
+ // (best-effort — never block teardown).
2948
+ const torndownVm = resolveFleetVmNumber(cfg.instanceName);
2949
+ if (cfg.vmSlug && torndownVm != null) {
2950
+ try { removeSlug(torndownVm); } catch { /* best-effort cleanup */ }
2951
+ }
2952
+ if (cfg.vmAccess && torndownVm != null) {
2953
+ try { clearAccess(torndownVm); } catch { /* best-effort cleanup */ }
2954
+ }
2955
+ try {
2956
+ console.error(`Deleting instance ${cfg.instanceName}...`);
2957
+ const del = runGcloud(buildGcloudArgs("instances-delete", cfg));
2958
+ if (del.status !== 0) {
2959
+ console.error(
2960
+ ` Warning: failed to delete instance ${cfg.instanceName} during abort teardown. ` +
2961
+ `Please delete it manually with 'npm run cloud:destroy'.`,
2962
+ );
2963
+ }
2964
+ } catch (delErr) {
2965
+ console.error(
2966
+ ` Warning: error deleting instance ${cfg.instanceName} during abort teardown: ${delErr.message}. ` +
2967
+ `Please delete it manually with 'npm run cloud:destroy'.`,
2968
+ );
2969
+ }
2970
+ }
2971
+ throw err;
2972
+ }
1684
2973
  // --no-tunnel: provisioning-only mode (the fleet dashboard opens its own
1685
2974
  // per-VM tunnel). Skipping openTunnel lets cloud:up EXIT cleanly instead of
1686
2975
  // hanging on a tunnel, and — critically for parallel multi-add — avoids N
@@ -1694,10 +2983,10 @@ async function cmdUp(args) {
1694
2983
  attachKeepAliveAndTeardown(tunnelChild);
1695
2984
  }
1696
2985
 
1697
- async function cmdTunnel(args) {
2986
+ async function cmdTunnel(args, projectDir = rootDir) {
1698
2987
  ensureGcloud();
1699
2988
  const flags = parseFlags(args);
1700
- const existing = loadCloudConfig(rootDir);
2989
+ const existing = loadCloudConfig(projectDir);
1701
2990
  if (!existing) {
1702
2991
  throw new Error(
1703
2992
  "No .codeyam/cloud.json found. Run `npm run cloud:up -- --repo <url>` first.",
@@ -1725,10 +3014,10 @@ async function cmdTunnel(args) {
1725
3014
  attachKeepAliveAndTeardown(tunnelChild);
1726
3015
  }
1727
3016
 
1728
- async function cmdStop(args) {
3017
+ async function cmdStop(args, projectDir = rootDir) {
1729
3018
  ensureGcloud();
1730
3019
  parseFlags(args);
1731
- const cfg = loadCloudConfig(rootDir);
3020
+ const cfg = loadCloudConfig(projectDir);
1732
3021
  if (!cfg) {
1733
3022
  throw new Error("No .codeyam/cloud.json found. Nothing to stop.");
1734
3023
  }
@@ -1744,10 +3033,10 @@ async function cmdStop(args) {
1744
3033
  );
1745
3034
  }
1746
3035
 
1747
- async function cmdDestroy(args) {
3036
+ async function cmdDestroy(args, projectDir = rootDir) {
1748
3037
  ensureGcloud();
1749
3038
  parseFlags(args);
1750
- const cfg = loadCloudConfig(rootDir);
3039
+ const cfg = loadCloudConfig(projectDir);
1751
3040
  if (!cfg) {
1752
3041
  throw new Error("No .codeyam/cloud.json found. Nothing to destroy.");
1753
3042
  }
@@ -1758,12 +3047,28 @@ async function cmdDestroy(args) {
1758
3047
  return;
1759
3048
  }
1760
3049
 
3050
+ // Revoke the VM's per-backend IAP access + clear its whitelist sidecar BEFORE
3051
+ // unwiring, so the revoke targets backend services that still exist (unwire
3052
+ // deletes them). No-op for a non-fleet instance / empty whitelist.
3053
+ revokeVmAccessOnDestroy(cfg);
3054
+
3055
+ // Unwire the VM's LB backends BEFORE deleting the instance (when a fleet
3056
+ // domain is set) so no orphaned NEG / backend service / host rule survives —
3057
+ // mirrors the destroy-time quick-tunnel cleanup. No-op in legacy mode.
3058
+ unwireVmIngressOnDestroy(cfg);
3059
+
1761
3060
  console.error(`Deleting instance ${cfg.instanceName}...`);
1762
3061
  const del = runGcloud(buildGcloudArgs("instances-delete", cfg));
1763
3062
  if (del.status !== 0) {
1764
3063
  throw new Error(`Failed to delete instance ${cfg.instanceName}`);
1765
3064
  }
1766
3065
 
3066
+ // Free the VM's custom slug now that the instance is gone, so the name is
3067
+ // re-Addable. Decoupled from unwireVmIngressOnDestroy (whose `plan.wire` gate
3068
+ // used to strand non-wired VMs' slugs); a sibling destroy-time hook like the
3069
+ // access/ingress teardown above.
3070
+ releaseVmSlugOnDestroy(cfg);
3071
+
1767
3072
  const list = runGcloud(buildGcloudArgs("instances-list-tagged", cfg), {
1768
3073
  stdio: ["ignore", "pipe", "ignore"],
1769
3074
  });
@@ -1781,17 +3086,17 @@ async function cmdDestroy(args) {
1781
3086
  }
1782
3087
 
1783
3088
  try {
1784
- fs.unlinkSync(cloudConfigPath(rootDir));
3089
+ fs.unlinkSync(cloudConfigPath(projectDir));
1785
3090
  console.error("Deleted .codeyam/cloud.json");
1786
3091
  } catch (e) {
1787
3092
  if (e.code !== "ENOENT") throw e;
1788
3093
  }
1789
3094
  }
1790
3095
 
1791
- async function cmdStatus(args) {
3096
+ async function cmdStatus(args, projectDir = rootDir) {
1792
3097
  ensureGcloud();
1793
3098
  parseFlags(args);
1794
- const cfg = loadCloudConfig(rootDir);
3099
+ const cfg = loadCloudConfig(projectDir);
1795
3100
  if (!cfg) {
1796
3101
  console.error("No .codeyam/cloud.json found. Run `npm run cloud:up` first.");
1797
3102
  return;
@@ -1833,12 +3138,269 @@ async function cmdStatus(args) {
1833
3138
  );
1834
3139
  }
1835
3140
 
3141
+ /** `cloud:ingress-up` — provision the SHARED, VM-independent LB infra (static
3142
+ * IP, wildcard managed cert, Cloud DNS records, URL map, IAP brand) once for the
3143
+ * fleet by spawning scripts/gcp/google-ingress.sh. Idempotent: re-running against
3144
+ * already-provisioned infra is a safe no-op. Requires a fleet domain (cloud.json
3145
+ * `fleetDomain` or FLEET_DOMAIN env); the per-VM backends are wired by cloud:up's
3146
+ * Add path. Run this before the first fleetDomain Add (or after flipping it on). */
3147
+ async function cmdIngressUp(args, projectDir = rootDir) {
3148
+ ensureGcloud();
3149
+ parseFlags(args);
3150
+ const cfg = loadCloudConfig(projectDir);
3151
+ if (!cfg) {
3152
+ throw new Error(
3153
+ "No .codeyam/cloud.json found. Run `npm run cloud:up -- --repo <url>` first.",
3154
+ );
3155
+ }
3156
+ const domain = resolveIngressDomain(cfg, process.env);
3157
+ if (!domain) {
3158
+ throw new Error(
3159
+ "No fleet domain configured. Set `fleetDomain` in .codeyam/cloud.json " +
3160
+ "(e.g. via `cloud:up -- --fleet-domain fleet.example.com`) or export FLEET_DOMAIN.",
3161
+ );
3162
+ }
3163
+ const dnsZone = resolveDnsZone(cfg, process.env);
3164
+ const scriptPath = path.join(rootDir, "scripts", "gcp", "google-ingress.sh");
3165
+ const inv = sharedIngressInvocation({
3166
+ scriptPath,
3167
+ domain,
3168
+ project: cfg.project,
3169
+ zone: cfg.zone,
3170
+ dnsZone,
3171
+ region: regionFromZone(cfg.zone),
3172
+ });
3173
+ console.error(
3174
+ `Provisioning shared LB ingress for ${domain} (project ${cfg.project}, DNS zone ${dnsZone})...`,
3175
+ );
3176
+ const res = spawnSync(inv.command, inv.args, {
3177
+ stdio: "inherit",
3178
+ env: { ...process.env, ...inv.env },
3179
+ });
3180
+ if (res.status !== 0) {
3181
+ throw new Error("scripts/gcp/google-ingress.sh failed; see output above.");
3182
+ }
3183
+ console.error(
3184
+ "✓ shared LB ingress provisioned. Per-VM backends are wired automatically by " +
3185
+ "`cloud:up` (Add) once `fleetDomain` is set in cloud.json.",
3186
+ );
3187
+ }
3188
+
3189
+ /** Detect unwired / half-wired rostered VMs and re-wire them idempotently.
3190
+ * Enumerates the fleet roster (the same source the dashboard reads), runs the
3191
+ * read-only `vm-ingress.sh <N> status` probe per VM, and for any reporting
3192
+ * `unwired` or `partial` re-runs `vm-ingress.sh <N> up` — which skips the
3193
+ * resources already present and creates only the missing backend / host rule.
3194
+ * A no-op in legacy quick-tunnel mode (no fleet domain). Invoked on demand and
3195
+ * automatically at dashboard boot so a fleet that lost ingress self-heals. */
3196
+ async function cmdReconcileIngress(args, projectDir = rootDir) {
3197
+ ensureGcloud();
3198
+ parseFlags(args);
3199
+ const cfg = loadCloudConfig(projectDir) || {};
3200
+ const domain = resolveIngressDomain(cfg, process.env);
3201
+ if (!domain) {
3202
+ console.error(
3203
+ "No fleet domain configured — ingress reconcile is a no-op (legacy quick-tunnel mode).",
3204
+ );
3205
+ return;
3206
+ }
3207
+ const roster = readRoster() || [];
3208
+ if (!roster.length) {
3209
+ console.error("Fleet roster is empty — nothing to reconcile.");
3210
+ return;
3211
+ }
3212
+ const scriptPath = path.join(rootDir, "scripts", "gcp", "vm-ingress.sh");
3213
+ console.error(`Reconciling LB ingress for ${roster.length} rostered VM(s) on ${domain}...`);
3214
+ const statuses = roster.map((n) => {
3215
+ const slug = slugFor(n);
3216
+ const inv = vmIngressStatusInvocation({
3217
+ scriptPath,
3218
+ vmNumber: n,
3219
+ domain,
3220
+ project: cfg.project,
3221
+ zone: cfg.zone,
3222
+ slug,
3223
+ });
3224
+ const res = spawnSync(inv.command, inv.args, {
3225
+ env: { ...process.env, ...inv.env },
3226
+ encoding: "utf8",
3227
+ });
3228
+ return { vmNumber: n, slug, verdict: parseIngressStatusVerdict(res.stdout || "") };
3229
+ });
3230
+ const targets = selectIngressReconcileTargets(statuses);
3231
+ const targetNums = new Set(targets.map((t) => t.vmNumber));
3232
+ for (const s of statuses) {
3233
+ if (!targetNums.has(s.vmNumber)) {
3234
+ const label = s.slug || `vm-${s.vmNumber}`;
3235
+ console.error(` VM-${s.vmNumber} ${label}: ${s.verdict || "unknown"} → ok`);
3236
+ }
3237
+ }
3238
+ for (const t of targets) {
3239
+ const label = t.slug || `vm-${t.vmNumber}`;
3240
+ // Resolve the VM's real dev-server port so a re-created preview NEG targets
3241
+ // the right port (not the 5173 bootstrap fallback). Best-effort — readVmAppPort
3242
+ // itself falls back to VITE_PORT on any SSH/parse failure.
3243
+ const perVmCfg = { ...cfg, instanceName: `${fleetInstancePrefix()}${t.vmNumber}` };
3244
+ const appPort = await readVmAppPort(perVmCfg);
3245
+ const res = runVmIngress(perVmCfg, "up", t.vmNumber, domain, appPort, t.slug);
3246
+ console.error(
3247
+ ` VM-${t.vmNumber} ${label}: ${t.verdict} → ${res.status === 0 ? "re-wired" : "re-wire FAILED"}`,
3248
+ );
3249
+ }
3250
+ }
3251
+
3252
+ /** `cloud:reconcile-credentials` — self-heal a running VM that came up (or
3253
+ * drifted) without git push credentials, WITHOUT a container restart so a live
3254
+ * Claude session survives. Probes the host staging file + an in-container
3255
+ * `git ls-remote origin`, classifies any failure via {@link decideCredentialHeal}
3256
+ * (so a transient network blip never triggers a needless re-write), and on a
3257
+ * credential gap re-seeds BOTH the durable host file (survives restart via the
3258
+ * entrypoint) and the live in-memory credential-cache daemon (immediate, no
3259
+ * restart). This codifies the manual recovery run on all 9 fleet VMs on
3260
+ * 2026-06-29. */
3261
+ async function cmdReconcileCredentials(args, projectDir = rootDir) {
3262
+ ensureGcloud();
3263
+ parseFlags(args);
3264
+ const cfg = loadCloudConfig(projectDir);
3265
+ if (!cfg) {
3266
+ throw new Error("No .codeyam/cloud.json found. Run `npm run cloud:up` first.");
3267
+ }
3268
+ const repoName = resolveProject(cfg).name;
3269
+ const projectCd = `cd ~/projects/${repoName}`;
3270
+ const composeExec = `docker compose -f docker-compose.cloud.yml exec -T editor`;
3271
+
3272
+ // One SSH round-trip: report staging-file emptiness and the in-container
3273
+ // ls-remote outcome with stable markers we parse below.
3274
+ console.error(`Probing git push credentials on ${cfg.instanceName}...`);
3275
+ const probeCmd =
3276
+ `f=$HOME/.codeyam-cloud-git-credentials; ` +
3277
+ `if [ -s "$f" ]; then echo STAGING=nonempty; else echo STAGING=empty; fi; ` +
3278
+ `${projectCd} && if ${composeExec} git -C /workspace ls-remote origin -h HEAD ` +
3279
+ `>/dev/null 2>/tmp/cy-lsr.err; then echo LSREMOTE=ok; ` +
3280
+ `else echo LSREMOTE=fail; sed 's/^/LSRERR:/' /tmp/cy-lsr.err; fi`;
3281
+ const probe = runGcloud(
3282
+ buildGcloudArgs("ssh-probe", cfg, { command: probeCmd }),
3283
+ { stdio: ["ignore", "pipe", "ignore"] },
3284
+ );
3285
+ if (probe.status !== 0 || typeof probe.stdout !== "string") {
3286
+ throw new Error(`Failed to probe credentials on ${cfg.instanceName} (SSH error).`);
3287
+ }
3288
+ const out = probe.stdout;
3289
+ const stagingEmpty = /STAGING=empty/.test(out);
3290
+ const lsRemoteOk = /LSREMOTE=ok/.test(out);
3291
+ const lsRemoteStderr = out
3292
+ .split("\n")
3293
+ .filter((l) => l.startsWith("LSRERR:"))
3294
+ .map((l) => l.slice("LSRERR:".length))
3295
+ .join("\n");
3296
+
3297
+ const decision = decideCredentialHeal({ stagingEmpty, lsRemoteOk, lsRemoteStderr });
3298
+ console.error(` category=${decision.category}: ${decision.reason}`);
3299
+ if (!decision.heal) {
3300
+ console.error(" ✓ no re-seed needed.");
3301
+ return;
3302
+ }
3303
+
3304
+ // Re-seed needs a token. Reuse seedGithubCredentials for the durable host-file
3305
+ // write (it throws MissingGitPushCredentialsError when none resolves — the
3306
+ // honest "can't heal without a token" outcome).
3307
+ await seedGithubCredentials(cfg);
3308
+
3309
+ // Seed the LIVE in-memory credential-cache daemon over the socket — the same
3310
+ // `git credential approve` shape the entrypoint uses
3311
+ // (scripts/cloud-editor-entrypoint.sh) — so push works immediately with no
3312
+ // container restart. base64 keeps the token out of the shell history across
3313
+ // the gcloud-ssh → docker layers.
3314
+ const { token } = resolveGithubTokenWithSource();
3315
+ const socket = "/run/codeyam/git-credential-cache.socket";
3316
+ const approveStdin = `protocol=https\nhost=github.com\nusername=x-access-token\npassword=${token}\n\n`;
3317
+ const b64 = Buffer.from(approveStdin).toString("base64");
3318
+ const seedCmd =
3319
+ `${projectCd} && echo ${b64} | base64 -d | ${composeExec} ` +
3320
+ `git -c 'credential.helper=cache --timeout=31536000 --socket=${socket}' ` +
3321
+ `credential approve && echo APPROVED`;
3322
+ const seed = runGcloud(
3323
+ buildGcloudArgs("ssh-probe", cfg, { command: seedCmd }),
3324
+ { stdio: ["ignore", "pipe", "inherit"] },
3325
+ );
3326
+ if (seed.status !== 0 || !/APPROVED/.test(String(seed.stdout || ""))) {
3327
+ throw new Error(
3328
+ `Re-seeded the host file but failed to seed the live credential cache on ${cfg.instanceName}.`,
3329
+ );
3330
+ }
3331
+ console.error(
3332
+ ` ✓ re-seeded host file + live credential cache on ${cfg.instanceName}; ` +
3333
+ `plan-claim sync will recover on the next tick.`,
3334
+ );
3335
+ }
3336
+
3337
+ /** `cloud:access` — edit an existing VM's IAP access whitelist without
3338
+ * re-provisioning. Resolves the VM number from --instance-name (or cloud.json),
3339
+ * mutates the access sidecar, and applies just the delta against the live
3340
+ * backend services via scripts/gcp/iap-access.sh:
3341
+ * --add <principal> grant one principal (idempotent)
3342
+ * --remove <principal> revoke one principal
3343
+ * --list print the current whitelist (no gcloud call)
3344
+ * Exactly one of the three is required. */
3345
+ async function cmdAccess(args, projectDir = rootDir) {
3346
+ const flags = parseFlags(args);
3347
+ const cfg = { ...(loadCloudConfig(projectDir) || {}), ...flags };
3348
+ const instanceName = cfg.instanceName;
3349
+ if (!instanceName) {
3350
+ throw new Error(
3351
+ "cloud:access requires --instance-name <name> (or a .codeyam/cloud.json with one).",
3352
+ );
3353
+ }
3354
+ const vmNumber = resolveFleetVmNumber(instanceName);
3355
+ if (vmNumber == null) {
3356
+ throw new Error(`'${instanceName}' is not a numbered fleet VM — per-VM access does not apply.`);
3357
+ }
3358
+
3359
+ if (flags.list) {
3360
+ const list = accessFor(vmNumber);
3361
+ console.error(`VM-${vmNumber} IAP access whitelist (${list.length}):`);
3362
+ if (list.length === 0) console.error(" (none — only the owner's project-wide grant applies)");
3363
+ for (const p of list) console.error(` ${p}`);
3364
+ return;
3365
+ }
3366
+
3367
+ const adding = flags.add != null && String(flags.add).trim() !== "";
3368
+ const removing = flags.remove != null && String(flags.remove).trim() !== "";
3369
+ if (adding === removing) {
3370
+ throw new Error("cloud:access requires exactly one of --add <principal> / --remove <principal> / --list.");
3371
+ }
3372
+
3373
+ ensureGcloud();
3374
+ const principal = normalizePrincipal(adding ? flags.add : flags.remove); // throws on malformed
3375
+ const action = adding ? "grant" : "revoke";
3376
+ // Mutate the sidecar first so the source of truth reflects intent even if the
3377
+ // gcloud apply later warns; both calls are idempotent.
3378
+ if (adding) addAccess(vmNumber, principal);
3379
+ else removeAccess(vmNumber, principal);
3380
+
3381
+ console.error(`${action === "grant" ? "Granting" : "Revoking"} IAP access for VM-${vmNumber}: ${principal}`);
3382
+ const res = runIapAccess(cfg, vmNumber, action, [principal]);
3383
+ if (res.status === 0) {
3384
+ console.error(` ✓ ${action}ed. Current whitelist: ${accessFor(vmNumber).join(", ") || "(none)"}`);
3385
+ } else {
3386
+ console.error(
3387
+ ` ⚠ gcloud ${action} did not complete cleanly; the sidecar was updated. Re-run:\n` +
3388
+ ` VM_ACCESS=${principal} bash scripts/gcp/iap-access.sh ${vmNumber} ${action}`,
3389
+ );
3390
+ }
3391
+ }
3392
+
1836
3393
  const HANDLERS = {
1837
3394
  up: cmdUp,
1838
3395
  tunnel: cmdTunnel,
1839
3396
  stop: cmdStop,
1840
3397
  destroy: cmdDestroy,
1841
3398
  status: cmdStatus,
3399
+ "ingress-up": cmdIngressUp,
3400
+ "reconcile-ingress": cmdReconcileIngress,
3401
+ "wire-add-tail": cmdWireAddTail,
3402
+ "reconcile-credentials": cmdReconcileCredentials,
3403
+ access: cmdAccess,
1842
3404
  };
1843
3405
 
1844
3406
  async function cloudMain() {
@@ -1861,7 +3423,10 @@ async function cloudMain() {
1861
3423
  module.exports = {
1862
3424
  // pure helpers
1863
3425
  agentEnvVarName,
3426
+ agentNpmPackage,
1864
3427
  AGENT_ENV_VARS,
3428
+ AGENT_NPM_PACKAGES,
3429
+ ensureAgentCliInstalled,
1865
3430
  KNOWN_FLAGS,
1866
3431
  FIREWALL_RULE_NAME,
1867
3432
  INSTANCE_TAG,
@@ -1882,6 +3447,7 @@ module.exports = {
1882
3447
  resolveSeedPreviewOrigin,
1883
3448
  previewSameOriginSafeFromStack,
1884
3449
  isRottedManagedPreviewOrigin,
3450
+ isWrongModePreviewOrigin,
1885
3451
  resolveEditorConfig,
1886
3452
  validateEditorVariant,
1887
3453
  assertVariantProvisionable,
@@ -1889,14 +3455,17 @@ module.exports = {
1889
3455
  reconcileProjectExclusivity,
1890
3456
  resolveGhcrToken,
1891
3457
  resolveGhcrPushToken,
3458
+ probeUsableEditorImage,
1892
3459
  resolveProject,
1893
3460
  clientProjectBootstrapArgs,
1894
3461
  cloudConfigPath,
1895
3462
  loadCloudConfig,
1896
3463
  saveCloudConfig,
3464
+ loadOrInitCloudConfig,
1897
3465
  buildEditorLocalEnvPatch,
1898
3466
  buildEditorLocalSeedWriteCommand,
1899
3467
  seedPreviewOrigin,
3468
+ healPreviewOriginAtReady,
1900
3469
  buildGcloudArgs,
1901
3470
  sshScriptCommand,
1902
3471
  bootstrapRunnerScript,
@@ -1912,14 +3481,42 @@ module.exports = {
1912
3481
  FORWARD_ENV_KEYS_ENV,
1913
3482
  parseEditorComposeState,
1914
3483
  fetchInstanceState,
3484
+ formatGcloudFailure,
3485
+ isTransientSshFailure,
3486
+ runGcloudSshWithRetry,
3487
+ ensureInstance,
3488
+ ensureBootstrapped,
3489
+ assertEditorContainerRunning,
3490
+ checkScriptDrift,
3491
+ STREAMED_SCRIPTS,
3492
+ // ingress wiring (pure decision + spawn wrappers)
3493
+ ingressWiringPlan,
3494
+ fleetInstancePrefix,
3495
+ runVmIngress,
3496
+ wireVmIngressOnAdd,
3497
+ unwireVmIngressOnDestroy,
3498
+ // per-VM IAP access (spawn wrapper + Add/Destroy hooks)
3499
+ runIapAccess,
3500
+ applyVmAccessOnAdd,
3501
+ revokeVmAccessOnDestroy,
3502
+ releaseVmSlugOnDestroy,
1915
3503
  // side-effecting handlers
1916
3504
  cmdUp,
1917
3505
  cmdTunnel,
1918
3506
  cmdStop,
1919
3507
  cmdDestroy,
1920
3508
  cmdStatus,
3509
+ cmdIngressUp,
3510
+ cmdReconcileIngress,
3511
+ cmdReconcileCredentials,
3512
+ cmdAccess,
1921
3513
  cloudMain,
1922
3514
  seedGithubCredentials,
3515
+ resolveGithubToken,
3516
+ resolveGithubTokenWithSource,
3517
+ MissingGitPushCredentialsError,
3518
+ classifyGitRemoteError,
3519
+ decideCredentialHeal,
1923
3520
  readVmAppPort,
1924
3521
  resolveLaptopGitIdentity,
1925
3522
  };