@codeyam-editor/codeyam-editor 0.1.4 → 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 +1415 -73
  3. package/npm/container.js +3 -0
  4. package/npm/disk-pressure.js +78 -0
  5. package/npm/editor-dev.js +96 -15
  6. package/npm/editor.js +85 -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 +51 -2
  10. package/npm/fleet-cleanup.js +118 -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 +1 -1
  15. package/npm/fleet-launchd.js +171 -0
  16. package/npm/fleet-live-status.js +123 -0
  17. package/npm/fleet-plans.js +135 -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 +43 -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 +204 -3
  35. package/npm/scenario-check.js +603 -32
  36. package/npm/scenario-handlers.js +78 -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 +329 -50
  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-BuK0lizt.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-tMMdarZD.js +0 -187
  61. package/ui/dist/assets/ScenarioDataPanel-xWYGZOpo.js +0 -5
  62. package/ui/dist/assets/ScenarioDataPanelFullPage-5G6KuZgw.js +0 -1
  63. package/ui/dist/assets/ScenarioRenderer-CSdhX1Ak.js +0 -1
  64. package/ui/dist/assets/StepsSimulator-CRtmlyNZ.js +0 -1
  65. package/ui/dist/assets/index-BGqairFV.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
@@ -73,11 +120,39 @@ const KNOWN_FLAGS = [
73
120
  // runs setup by default when the project ships one; pass this for projects
74
121
  // where a cloud setup is genuinely wrong (the script's --skip-setup hatch).
75
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",
76
151
  ];
77
152
 
78
153
  // Flags that take no value (presence = true). parseFlags must not demand a
79
154
  // value for these or `--no-tunnel` would error "requires a value".
80
- const BOOLEAN_FLAGS = ["no-tunnel", "skip-setup"];
155
+ const BOOLEAN_FLAGS = ["no-tunnel", "skip-setup", "list", "allow-no-git-credentials"];
81
156
 
82
157
  const FIREWALL_RULE_NAME = "codeyam-editor-iap-ssh";
83
158
  const INSTANCE_TAG = "codeyam-editor";
@@ -320,6 +395,7 @@ function resolveSeedPreviewOrigin(
320
395
  appPort = VITE_PORT,
321
396
  env = process.env,
322
397
  sameOriginSafe = true,
398
+ slug = null,
323
399
  ) {
324
400
  // Same-origin-safe stack (the default — Vite et al.): ALWAYS serve the app
325
401
  // same-origin under the reserved relative path, in every fleet mode. The
@@ -348,15 +424,27 @@ function resolveSeedPreviewOrigin(
348
424
  // client project (vm == null) keeps the localhost seed even with a domain set.
349
425
  if (domain && vm != null) {
350
426
  return {
351
- 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}`,
352
444
  _managedPreviewOrigin: MANAGED_PREVIEW_ORIGIN,
353
445
  };
354
446
  }
355
- // Quick-tunnel / domain-free mode: prefer the operator's published
356
- // `<N>-preview` quick-tunnel URL (the public origin a remote browser can
357
- // actually load), else the SSH-tunnel bootstrap localhost origin
358
- // (`http://localhost:<appPort>`, reached via the laptop `-L` forward). Never
359
- // the same-origin subpath — that is exactly what recurses for this stack.
447
+ // Quick-tunnel 'tunnel' mode fallback / non-fleet project fallback:
360
448
  const previewKey = vm != null ? `${vm}-preview` : null;
361
449
  const tunnelUrl =
362
450
  previewKey &&
@@ -411,6 +499,46 @@ function isRottedManagedPreviewOrigin(localJson, sameOriginSafe) {
411
499
  );
412
500
  }
413
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
+
414
542
  /** Read the operator-laptop cloudflared-urls.json sidecar. Missing/unparseable
415
543
  * → {} (the monitor may not have run yet; the seed falls back to localhost). */
416
544
  function readCloudflaredUrls() {
@@ -491,9 +619,25 @@ function editorImageBootstrapArgs(cfg) {
491
619
  * so a stale value — e.g. a prior greenfield's `newProject` left in cloud.json
492
620
  * — can't override the new intent. Without this, launching `--repo X` after a
493
621
  * greenfield run silently provisions the greenfield project (resolveProject
494
- * prioritizes newProject). Mutates and returns cfg. */
495
- function reconcileProjectExclusivity(cfg, flags = {}) {
496
- 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
+ }
497
641
  if (flags.newProject) { delete cfg.repo; delete cfg.branch; }
498
642
  return cfg;
499
643
  }
@@ -546,14 +690,27 @@ function loadCloudConfig(projectDir) {
546
690
  try {
547
691
  return JSON.parse(raw);
548
692
  } catch (e) {
549
- 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
+ }
550
699
  }
551
700
  }
552
701
 
553
702
  function saveCloudConfig(projectDir, cfg) {
554
703
  const p = cloudConfigPath(projectDir);
555
- fs.mkdirSync(path.dirname(p), { recursive: true });
556
- 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
+ }
557
714
  }
558
715
 
559
716
  /** Build the JSON patch to merge into the VM's `editor.local.json` so the
@@ -835,9 +992,21 @@ function loadOrInitCloudConfig(projectDir, flags) {
835
992
  const existing = loadCloudConfig(projectDir);
836
993
  // noTunnel is a per-invocation behavior, never persisted — else a later manual
837
994
  // `cloud:up` (zero-arg, reading cloud.json) would silently skip its tunnel.
838
- 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; };
839
1000
  if (existing) {
840
- 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
+ }
841
1010
  const toSave = persist(merged);
842
1011
  if (JSON.stringify(toSave) !== JSON.stringify(existing)) {
843
1012
  saveCloudConfig(projectDir, toSave);
@@ -847,6 +1016,15 @@ function loadOrInitCloudConfig(projectDir, flags) {
847
1016
 
848
1017
  const cfg = reconcileProjectExclusivity({ ...flags }, flags);
849
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
+
850
1028
  cfg.zone = cfg.zone || gcloudGetValue("config-get-zone");
851
1029
  cfg.agent = cfg.agent || "claude";
852
1030
  agentEnvVarName(cfg.agent);
@@ -1352,10 +1530,19 @@ async function ensureProjectBootstrapped(cfg) {
1352
1530
  // Whitelisted by the dashboard via CY_FORWARD_ENV_KEYS — empty on the
1353
1531
  // standalone `cloud:up` path, so that flow is unchanged.
1354
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;
1355
1541
  const command = sshScriptCommand(scriptBody, scriptArgs, {
1356
1542
  GITHUB_TOKEN: resolveGithubToken(),
1357
1543
  CY_GIT_USER_NAME: laptopGit.name,
1358
1544
  CY_GIT_USER_EMAIL: laptopGit.email,
1545
+ ...ingressEnv,
1359
1546
  ...forwardEnv,
1360
1547
  });
1361
1548
  const result = runGcloud(
@@ -1451,13 +1638,52 @@ function resolveLaptopGitIdentity(spawnImpl = spawnSync) {
1451
1638
  * intentionally not done: the cost of one `gh` subprocess per cloud:up is
1452
1639
  * trivial and lets a user `gh auth login` between operations. */
1453
1640
  function resolveGithubToken() {
1454
- const env = process.env.GITHUB_TOKEN || process.env.GH_TOKEN;
1455
- 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
+ }
1456
1658
  const ghOut = spawnSync("gh", ["auth", "token"], { encoding: "utf8" });
1457
1659
  if (ghOut.status === 0) {
1458
- 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";
1459
1686
  }
1460
- return "";
1461
1687
  }
1462
1688
 
1463
1689
  /** A PULL-ONLY (`read:packages`) token the VM bootstrap uses to `docker login
@@ -1491,6 +1717,139 @@ function resolveGhcrPushToken() {
1491
1717
  return process.env.CODEYAM_GHCR_PUSH_TOKEN || "";
1492
1718
  }
1493
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
+
1494
1853
  /** Write the laptop's GitHub token into ~/.codeyam-cloud-git-credentials on
1495
1854
  * the VM so the cloud compose can mount it into /root/.git-credentials —
1496
1855
  * without this, claude / codex / gemini inside the container can clone the
@@ -1498,17 +1857,24 @@ function resolveGhcrPushToken() {
1498
1857
  * inlined URL) but cannot `git pull` / `git push` afterward. The file is
1499
1858
  * chmod 600 and never committed; if the local environment has no token,
1500
1859
  * the placeholder file from gcp-project-bootstrap.sh stays in place. */
1501
- async function seedGithubCredentials(cfg) {
1502
- const token = resolveGithubToken();
1860
+ async function seedGithubCredentials(cfg, opts = {}) {
1861
+ const { token, source } = resolveGithubTokenWithSource();
1503
1862
  if (!token) {
1504
- console.error(
1505
- "\nNo GITHUB_TOKEN / GH_TOKEN set and `gh auth token` is unavailable —\n" +
1506
- "claude inside the cloud container will not be able to git pull/push.\n" +
1507
- "Export GITHUB_TOKEN or run `gh auth login` locally, then `cloud:up` again.\n",
1508
- );
1509
- 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();
1510
1874
  }
1511
- 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
+ );
1512
1878
  const credLine = `https://x-access-token:${token}@github.com`;
1513
1879
  // Quote the token through one shell layer (gcloud ssh --command); the
1514
1880
  // value is base64'd to keep it out of the shell history.
@@ -1642,36 +2008,63 @@ async function seedPreviewOrigin(cfg, appPort = VITE_PORT) {
1642
2008
  sameOriginSafe = previewSameOriginSafeFromStack(parseObj(stackJson));
1643
2009
  }
1644
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;
1645
2021
  const seed = resolveSeedPreviewOrigin(
1646
2022
  cfg.instanceName,
1647
2023
  readCloudflaredUrls(),
1648
2024
  appPort,
1649
2025
  process.env,
1650
2026
  sameOriginSafe,
2027
+ seedSlug,
1651
2028
  );
1652
2029
 
1653
2030
  // Skip-on-already-set guard, EXCEPT for self-heal. A non-empty previewOrigin
1654
- // is normally a user pin or a good prior seed and is left untouched. The one
1655
- // exception is a ROTTED managed cloudflared quick-tunnel URL on a
1656
- // same-origin-safe VM (the VM-1/2/6 leftover from before the improve38
1657
- // same-origin migration): that frozen URL points at a dead rotated tunnel
1658
- // (NXDOMAIN / blank preview), so we heal it back to the same-origin path
1659
- // instead of preserving it. Because every dashboard action (Reset / Update /
1660
- // Add / bootstrap-recovery) re-runs this seed over cloud:up, the heal reaches
1661
- // every touch-point without a separate per-VM hand-fix.
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.
1662
2047
  const originAlreadySet =
1663
2048
  typeof current.previewOrigin === "string" && current.previewOrigin.length > 0;
1664
2049
  const healing =
1665
- originAlreadySet && isRottedManagedPreviewOrigin(current, sameOriginSafe);
2050
+ originAlreadySet &&
2051
+ (isRottedManagedPreviewOrigin(current, sameOriginSafe) ||
2052
+ isWrongModePreviewOrigin(current, sameOriginSafe));
1666
2053
  if (originAlreadySet && !healing) {
1667
2054
  return;
1668
2055
  }
1669
2056
 
1670
- // On heal, also DROP the legacy `_managedPreviewOrigin` marker (deepMergeJson
1671
- // deletes any key whose overlay value is null) — the same-origin path is a
1672
- // constant the retired monitor push has nothing to manage, exactly matching
1673
- // the manual `config-override --unset _managedPreviewOrigin` fix.
1674
- const overlay = healing ? { ...seed, _managedPreviewOrigin: null } : seed;
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;
1675
2068
  console.error(
1676
2069
  healing
1677
2070
  ? `Healing rotted managed previewOrigin (${current.previewOrigin}) → ${seed.previewOrigin} in ${remotePath}...`
@@ -1691,6 +2084,82 @@ async function seedPreviewOrigin(cfg, appPort = VITE_PORT) {
1691
2084
  }
1692
2085
  }
1693
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
+
1694
2163
  /** Write the chosen agent provider and local API keys into editor.local.json on
1695
2164
  * the VM BEFORE compose up so the file exists when the bind-mount happens —
1696
2165
  * the provider seed MUST run before ensureEditorContainerUp, because the editor
@@ -1709,9 +2178,12 @@ async function seedAgentApiKey(cfg) {
1709
2178
  if (!hasKey) {
1710
2179
  console.error(
1711
2180
  `\nNo ${agentEnvVarName(cfg.agent)} set locally — seeding provider only.\n` +
1712
- `Authenticate ${cfg.agent} later with:\n` +
1713
- ` gcloud compute ssh ${cfg.instanceName} --project ${cfg.project} --zone ${cfg.zone} ` +
1714
- `--tunnel-through-iap -- ${cfg.agent} auth login\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`,
1715
2187
  );
1716
2188
  }
1717
2189
  const repoName = resolveProject(cfg).name;
@@ -1787,22 +2259,18 @@ async function ensureAgentCliInstalled(cfg) {
1787
2259
  `AGENT="${agent}"`,
1788
2260
  "",
1789
2261
  "if command -v \"$BINARY\" >/dev/null 2>&1; then",
1790
- " if [ \"$AGENT\" = \"claude\" ]; then",
1791
- " if [ -x /usr/local/bin/claude ]; then",
1792
- " echo \" ✓ $BINARY already installed and executable at /usr/local/bin/claude\"",
1793
- " exit 0",
1794
- " fi",
1795
- " else",
1796
- " echo \" ✓ $BINARY already installed on PATH\"",
1797
- " exit 0",
1798
- " fi",
2262
+ " echo \" ✓ $BINARY already installed on PATH\"",
2263
+ " exit 0",
1799
2264
  "fi",
1800
2265
  "",
1801
2266
  "echo \"Installing $PACKAGE globally in container...\"",
1802
2267
  "if [ \"$AGENT\" = \"claude\" ]; then",
1803
2268
  " rm -rf /usr/lib/node_modules/@anthropic-ai/.claude-code-*",
1804
2269
  " npm install -g \"$PACKAGE\"",
1805
- " ln -sf /usr/lib/node_modules/@anthropic-ai/claude-code/node_modules/@anthropic-ai/claude-code-linux-x64/claude /usr/local/bin/claude",
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",
1806
2274
  "else",
1807
2275
  " npm install -g \"$PACKAGE\"",
1808
2276
  "fi",
@@ -1864,19 +2332,525 @@ function promptForInstanceName(expected) {
1864
2332
  });
1865
2333
  }
1866
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
+
1867
2431
  // =============================================================================
1868
2432
  // Subcommand handlers
1869
2433
  // =============================================================================
1870
2434
 
1871
- 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) {
1872
2803
  ensureGcloud();
1873
2804
  const flags = parseFlags(args);
1874
- const cfg = loadOrInitCloudConfig(rootDir, flags);
2805
+ const cfg = loadOrInitCloudConfig(projectDir, flags);
1875
2806
  // Fail loud BEFORE creating a GCE instance if an unprovisionable editor
1876
2807
  // variant was requested — otherwise we'd spin up a VM, build a dev image,
1877
2808
  // and silently ignore the operator's staging/production intent.
1878
2809
  assertVariantProvisionable(resolveEditorConfig(cfg).editorVariant);
1879
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
+
1880
2854
  await ensureFirewallRule(cfg);
1881
2855
  const { createdThisRun } = await ensureInstance(cfg);
1882
2856
 
@@ -1890,7 +2864,21 @@ async function cmdUp(args) {
1890
2864
  // Write the GitHub token BEFORE compose up so the credentials file exists
1891
2865
  // when the bind-mount happens — bind-mounting a missing file would silently
1892
2866
  // create a directory instead and the agent's first `git pull` would fail.
1893
- await seedGithubCredentials(cfg);
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
+ }
1894
2882
  await seedAgentApiKey(cfg);
1895
2883
  await ensureEditorContainerUp(cfg);
1896
2884
  // Roster the VM into the fleet dashboard NOW that it's provisioned and the
@@ -1898,17 +2886,72 @@ async function cmdUp(args) {
1898
2886
  // dashboard's own `cloud:up --no-tunnel` Add path also rosters (idempotent
1899
2887
  // with the dashboard's onSuccess roster — a double-add is a harmless no-op).
1900
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
+ }
1901
2908
  // Terminal gate: provisioning is NOT "done" until the editor container is
1902
2909
  // verifiably Up. Placed BEFORE the --no-tunnel early-return so the dashboard's
1903
2910
  // own `cloud:up --no-tunnel` Add path is covered too (it's the path that bit
1904
2911
  // us — a run reported success with no container). A failure here is a loud
1905
2912
  // non-zero exit, which the dashboard fail-fast-on-nonzero path acts on.
1906
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
+ }
1907
2940
  } catch (err) {
1908
2941
  if (createdThisRun) {
1909
2942
  console.error(
1910
2943
  `✗ provisioning aborted — tearing down the VM cloud:up created this run (no orphan left behind)`,
1911
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
+ }
1912
2955
  try {
1913
2956
  console.error(`Deleting instance ${cfg.instanceName}...`);
1914
2957
  const del = runGcloud(buildGcloudArgs("instances-delete", cfg));
@@ -1940,10 +2983,10 @@ async function cmdUp(args) {
1940
2983
  attachKeepAliveAndTeardown(tunnelChild);
1941
2984
  }
1942
2985
 
1943
- async function cmdTunnel(args) {
2986
+ async function cmdTunnel(args, projectDir = rootDir) {
1944
2987
  ensureGcloud();
1945
2988
  const flags = parseFlags(args);
1946
- const existing = loadCloudConfig(rootDir);
2989
+ const existing = loadCloudConfig(projectDir);
1947
2990
  if (!existing) {
1948
2991
  throw new Error(
1949
2992
  "No .codeyam/cloud.json found. Run `npm run cloud:up -- --repo <url>` first.",
@@ -1971,10 +3014,10 @@ async function cmdTunnel(args) {
1971
3014
  attachKeepAliveAndTeardown(tunnelChild);
1972
3015
  }
1973
3016
 
1974
- async function cmdStop(args) {
3017
+ async function cmdStop(args, projectDir = rootDir) {
1975
3018
  ensureGcloud();
1976
3019
  parseFlags(args);
1977
- const cfg = loadCloudConfig(rootDir);
3020
+ const cfg = loadCloudConfig(projectDir);
1978
3021
  if (!cfg) {
1979
3022
  throw new Error("No .codeyam/cloud.json found. Nothing to stop.");
1980
3023
  }
@@ -1990,10 +3033,10 @@ async function cmdStop(args) {
1990
3033
  );
1991
3034
  }
1992
3035
 
1993
- async function cmdDestroy(args) {
3036
+ async function cmdDestroy(args, projectDir = rootDir) {
1994
3037
  ensureGcloud();
1995
3038
  parseFlags(args);
1996
- const cfg = loadCloudConfig(rootDir);
3039
+ const cfg = loadCloudConfig(projectDir);
1997
3040
  if (!cfg) {
1998
3041
  throw new Error("No .codeyam/cloud.json found. Nothing to destroy.");
1999
3042
  }
@@ -2004,12 +3047,28 @@ async function cmdDestroy(args) {
2004
3047
  return;
2005
3048
  }
2006
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
+
2007
3060
  console.error(`Deleting instance ${cfg.instanceName}...`);
2008
3061
  const del = runGcloud(buildGcloudArgs("instances-delete", cfg));
2009
3062
  if (del.status !== 0) {
2010
3063
  throw new Error(`Failed to delete instance ${cfg.instanceName}`);
2011
3064
  }
2012
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
+
2013
3072
  const list = runGcloud(buildGcloudArgs("instances-list-tagged", cfg), {
2014
3073
  stdio: ["ignore", "pipe", "ignore"],
2015
3074
  });
@@ -2027,17 +3086,17 @@ async function cmdDestroy(args) {
2027
3086
  }
2028
3087
 
2029
3088
  try {
2030
- fs.unlinkSync(cloudConfigPath(rootDir));
3089
+ fs.unlinkSync(cloudConfigPath(projectDir));
2031
3090
  console.error("Deleted .codeyam/cloud.json");
2032
3091
  } catch (e) {
2033
3092
  if (e.code !== "ENOENT") throw e;
2034
3093
  }
2035
3094
  }
2036
3095
 
2037
- async function cmdStatus(args) {
3096
+ async function cmdStatus(args, projectDir = rootDir) {
2038
3097
  ensureGcloud();
2039
3098
  parseFlags(args);
2040
- const cfg = loadCloudConfig(rootDir);
3099
+ const cfg = loadCloudConfig(projectDir);
2041
3100
  if (!cfg) {
2042
3101
  console.error("No .codeyam/cloud.json found. Run `npm run cloud:up` first.");
2043
3102
  return;
@@ -2079,12 +3138,269 @@ async function cmdStatus(args) {
2079
3138
  );
2080
3139
  }
2081
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
+
2082
3393
  const HANDLERS = {
2083
3394
  up: cmdUp,
2084
3395
  tunnel: cmdTunnel,
2085
3396
  stop: cmdStop,
2086
3397
  destroy: cmdDestroy,
2087
3398
  status: cmdStatus,
3399
+ "ingress-up": cmdIngressUp,
3400
+ "reconcile-ingress": cmdReconcileIngress,
3401
+ "wire-add-tail": cmdWireAddTail,
3402
+ "reconcile-credentials": cmdReconcileCredentials,
3403
+ access: cmdAccess,
2088
3404
  };
2089
3405
 
2090
3406
  async function cloudMain() {
@@ -2131,6 +3447,7 @@ module.exports = {
2131
3447
  resolveSeedPreviewOrigin,
2132
3448
  previewSameOriginSafeFromStack,
2133
3449
  isRottedManagedPreviewOrigin,
3450
+ isWrongModePreviewOrigin,
2134
3451
  resolveEditorConfig,
2135
3452
  validateEditorVariant,
2136
3453
  assertVariantProvisionable,
@@ -2138,14 +3455,17 @@ module.exports = {
2138
3455
  reconcileProjectExclusivity,
2139
3456
  resolveGhcrToken,
2140
3457
  resolveGhcrPushToken,
3458
+ probeUsableEditorImage,
2141
3459
  resolveProject,
2142
3460
  clientProjectBootstrapArgs,
2143
3461
  cloudConfigPath,
2144
3462
  loadCloudConfig,
2145
3463
  saveCloudConfig,
3464
+ loadOrInitCloudConfig,
2146
3465
  buildEditorLocalEnvPatch,
2147
3466
  buildEditorLocalSeedWriteCommand,
2148
3467
  seedPreviewOrigin,
3468
+ healPreviewOriginAtReady,
2149
3469
  buildGcloudArgs,
2150
3470
  sshScriptCommand,
2151
3471
  bootstrapRunnerScript,
@@ -2167,14 +3487,36 @@ module.exports = {
2167
3487
  ensureInstance,
2168
3488
  ensureBootstrapped,
2169
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,
2170
3503
  // side-effecting handlers
2171
3504
  cmdUp,
2172
3505
  cmdTunnel,
2173
3506
  cmdStop,
2174
3507
  cmdDestroy,
2175
3508
  cmdStatus,
3509
+ cmdIngressUp,
3510
+ cmdReconcileIngress,
3511
+ cmdReconcileCredentials,
3512
+ cmdAccess,
2176
3513
  cloudMain,
2177
3514
  seedGithubCredentials,
3515
+ resolveGithubToken,
3516
+ resolveGithubTokenWithSource,
3517
+ MissingGitPushCredentialsError,
3518
+ classifyGitRemoteError,
3519
+ decideCredentialHeal,
2178
3520
  readVmAppPort,
2179
3521
  resolveLaptopGitIdentity,
2180
3522
  };