@codeyam-editor/codeyam-editor 0.1.4 → 0.1.6

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 (97) hide show
  1. package/npm/cli.js +5 -1
  2. package/npm/cloud-branch-switch.js +1 -1
  3. package/npm/cloud.js +3166 -150
  4. package/npm/codesign-plan.js +68 -0
  5. package/npm/container.js +3 -0
  6. package/npm/disk-pressure.js +78 -0
  7. package/npm/editor-dev.js +96 -15
  8. package/npm/editor.js +85 -37
  9. package/npm/empty-project-dir.js +92 -7
  10. package/npm/fleet-add-failure.js +252 -0
  11. package/npm/fleet-asset-version.js +37 -0
  12. package/npm/fleet-auth-health.js +213 -0
  13. package/npm/fleet-auth.js +6 -1
  14. package/npm/fleet-build-data.js +109 -5
  15. package/npm/fleet-cleanup.js +232 -16
  16. package/npm/fleet-destroy-safety.js +272 -0
  17. package/npm/fleet-early-roster.js +87 -0
  18. package/npm/fleet-effect-poll.js +110 -0
  19. package/npm/fleet-effect-status.js +33 -32
  20. package/npm/fleet-env.js +86 -0
  21. package/npm/fleet-ghcr-secret.js +129 -0
  22. package/npm/fleet-git-maintenance.js +142 -0
  23. package/npm/fleet-host-op-limiter.js +68 -0
  24. package/npm/fleet-host.js +74 -0
  25. package/npm/fleet-iap-token.js +165 -0
  26. package/npm/fleet-job-badge.js +1 -1
  27. package/npm/fleet-launchd.js +171 -0
  28. package/npm/fleet-live-status.js +155 -0
  29. package/npm/fleet-memory.js +245 -9
  30. package/npm/fleet-plans.js +233 -4
  31. package/npm/fleet-pool.js +75 -16
  32. package/npm/fleet-prewarm.js +241 -0
  33. package/npm/fleet-project-drift.js +58 -0
  34. package/npm/fleet-public-url.js +394 -0
  35. package/npm/fleet-queue.js +95 -1
  36. package/npm/fleet-reachability-debounce.js +69 -0
  37. package/npm/fleet-reachability.js +80 -0
  38. package/npm/fleet-reconcile.js +43 -1
  39. package/npm/fleet-reset.js +330 -13
  40. package/npm/fleet-roster.js +6 -8
  41. package/npm/fleet-running-plan.js +85 -0
  42. package/npm/fleet-self-update.js +142 -0
  43. package/npm/fleet-session-token-cache.js +65 -0
  44. package/npm/fleet-setup-steps.js +112 -0
  45. package/npm/fleet-sleep-status.js +33 -0
  46. package/npm/fleet-status-refs.js +96 -0
  47. package/npm/fleet-strand.js +181 -0
  48. package/npm/fleet-stuck-status.js +117 -0
  49. package/npm/fleet-vm-access.js +404 -0
  50. package/npm/fleet-vm-probe-target.js +282 -0
  51. package/npm/fleet-vm-readiness.js +237 -0
  52. package/npm/fleet-vm-slugs.js +116 -0
  53. package/npm/google-ingress.js +226 -0
  54. package/npm/ingress-invoke.js +770 -0
  55. package/npm/launch-config.js +386 -9
  56. package/npm/scenario-check.js +880 -37
  57. package/npm/scenario-handlers.js +154 -30
  58. package/npm/scenario-interactivity.js +145 -6
  59. package/npm/scenario-issues.js +13 -1
  60. package/npm/scenario-metrics.js +116 -1
  61. package/npm/scenario-mocks.js +144 -21
  62. package/npm/scenario-playwright.js +649 -38
  63. package/npm/script-drift.js +152 -0
  64. package/npm/utils.js +365 -52
  65. package/npm/vm-urls.js +69 -8
  66. package/package.json +5 -5
  67. package/ui/dist/assets/ContainerInspector-DgM0pTAx.js +2 -0
  68. package/ui/dist/assets/EditorShell-B7Q06uJ_.js +252 -0
  69. package/ui/dist/assets/EditorShell-DP5UY20G.css +1 -0
  70. package/ui/dist/assets/{Markdown-BHBqz-Mj.js → Markdown-Chgdf3PW.js} +1 -1
  71. package/ui/dist/assets/ProjectLauncherScreen-BRrEr8Ng.js +1 -0
  72. package/ui/dist/assets/ScenarioDataPanel-BeQfa28g.js +9 -0
  73. package/ui/dist/assets/{ScenarioDataPanel-DYP7pi_n.css → ScenarioDataPanel-C1LukYI6.css} +1 -1
  74. package/ui/dist/assets/ScenarioDataPanelFullPage-BLqRCxQX.js +1 -0
  75. package/ui/dist/assets/ScenarioRenderer-CIAxfHPh.js +21 -0
  76. package/ui/dist/assets/StepsSimulator-CymGgfbu.js +1 -0
  77. package/ui/dist/assets/index-BygCiJSb.css +1 -0
  78. package/ui/dist/assets/{index-BuK0lizt.js → index-DCHqRrwO.js} +9 -9
  79. package/ui/dist/assets/{markdown-v0F0UEt3.js → markdown-C9tbsVHX.js} +1 -1
  80. package/ui/dist/assets/react-nrLBr15I.js +28 -0
  81. package/ui/dist/assets/useEvents-S3Qk1Y_d.js +1 -0
  82. package/ui/dist/assets/useServerIdentity-B7rI0u3O.js +1 -0
  83. package/ui/dist/codeyam-mark.svg +4 -0
  84. package/ui/dist/codeyam-wordmark.svg +5 -0
  85. package/ui/dist/index.html +4 -4
  86. package/ui/dist/open-source/codeyam-counter.png +0 -0
  87. package/ui/dist/open-source/el-carot.png +0 -0
  88. package/ui/dist/open-source/tabcommand.png +0 -0
  89. package/ui/dist/assets/ContainerInspector-C8EkH4PP.js +0 -2
  90. package/ui/dist/assets/EditorShell-tMMdarZD.js +0 -187
  91. package/ui/dist/assets/ScenarioDataPanel-xWYGZOpo.js +0 -5
  92. package/ui/dist/assets/ScenarioDataPanelFullPage-5G6KuZgw.js +0 -1
  93. package/ui/dist/assets/ScenarioRenderer-CSdhX1Ak.js +0 -1
  94. package/ui/dist/assets/StepsSimulator-CRtmlyNZ.js +0 -1
  95. package/ui/dist/assets/index-BGqairFV.css +0 -1
  96. package/ui/dist/assets/react-CSS0HapR.js +0 -28
  97. package/ui/dist/assets/useEvents-DggWrtHe.js +0 -1
@@ -9,6 +9,8 @@
9
9
  // and past launches one click to re-run.
10
10
 
11
11
  const { CANONICAL_EDITOR_REPO, EDITOR_VARIANTS, repoBasename, AGENT_ENV_VARS } = require("./cloud");
12
+ const { normalizeVmSlug } = require("./vm-urls");
13
+ const { normalizeAccessList } = require("./fleet-vm-access");
12
14
 
13
15
  /** The build agents a VM's editor terminal can run, as the keys of cloud.js's
14
16
  * AGENT_ENV_VARS. Deriving the selectable set from that map keeps it a single
@@ -28,8 +30,30 @@ const EMPTY_CONFIG = Object.freeze({
28
30
  // Build agent provider the VM's editor terminal runs (claude | codex | gemini).
29
31
  // Defaults to claude; cloudUpArgs forwards it as cloud:up's --agent flag.
30
32
  agent: "claude",
33
+ // Optional human-readable hostname slug for a SINGLE VM. When set, cloudUpArgs
34
+ // forwards it as cloud:up's --vm-slug flag so the VM's public URLs become
35
+ // `<slug>.editor.<domain>`. Empty ⇒ the numeric vm-<N> scheme (unchanged).
36
+ vmSlug: "",
37
+ // Optional per-VM IAP access whitelist for a SINGLE VM: a comma-separated list
38
+ // of principals (user:/group:/domain:, a bare email defaults to user:) granted
39
+ // resource-level access to this VM's surfaces. cloudUpArgs forwards it as
40
+ // cloud:up's --vm-access flag. Empty ⇒ only the owner's project-wide grant.
41
+ vmAccess: "",
31
42
  });
32
43
 
44
+ /** Collapse a free-text access list (the add-form textarea, where the operator
45
+ * may separate principals by newlines or commas) into the canonical
46
+ * comma-separated string cloud:up's --vm-access expects. Pure + never throws —
47
+ * shape validation is validateLaunchConfig's job. */
48
+ function normalizeAccessText(raw) {
49
+ if (typeof raw !== "string") return "";
50
+ return raw
51
+ .split(/[\n,]+/)
52
+ .map((s) => s.trim())
53
+ .filter(Boolean)
54
+ .join(",");
55
+ }
56
+
33
57
  /** Coerce a raw form/query object into the canonical launch-config shape: trim
34
58
  * strings, default the variant to a known value. Never throws — validation is
35
59
  * a separate, explicit step. */
@@ -47,6 +71,8 @@ function normalizeLaunchConfig(input = {}) {
47
71
  editorVariant,
48
72
  editorBranch: str(input.editorBranch),
49
73
  agent,
74
+ vmSlug: str(input.vmSlug),
75
+ vmAccess: normalizeAccessText(input.vmAccess),
50
76
  };
51
77
  }
52
78
 
@@ -70,6 +96,72 @@ function validateLaunchConfig(cfg) {
70
96
  if (!EDITOR_VARIANTS.includes(cfg.editorVariant)) {
71
97
  return { ok: false, msg: `unknown editor variant: ${cfg.editorVariant}` };
72
98
  }
99
+ // A custom slug, when supplied, must be a single DNS label (same rule cloud:up
100
+ // enforces) — surface a friendly reason here rather than letting the spawned
101
+ // job fail loud later. Empty ⇒ numeric vm-<N>, no check.
102
+ if (cfg.vmSlug) {
103
+ try {
104
+ normalizeVmSlug(cfg.vmSlug);
105
+ } catch (e) {
106
+ return { ok: false, msg: e.message };
107
+ }
108
+ }
109
+ // A per-VM access whitelist, when supplied, must be a list of valid principals
110
+ // (same rule cloud:up enforces) — surface a friendly reason here rather than
111
+ // letting the spawned job fail loud later. Empty ⇒ no check.
112
+ if (cfg.vmAccess) {
113
+ try {
114
+ normalizeAccessList(cfg.vmAccess);
115
+ } catch (e) {
116
+ return { ok: false, msg: e.message };
117
+ }
118
+ }
119
+ return { ok: true };
120
+ }
121
+
122
+ /** Pre-flight the operator-host CONFIG a dashboard Add depends on, BEFORE
123
+ * spawning the ~20-min cloud:up. Pure classifier: it takes the ALREADY-resolved
124
+ * zone, project, and GitHub token (the side-effecting resolution — env lookups,
125
+ * `gh auth token`, gcloud defaults — is the caller's job) and returns { ok, msg }
126
+ * naming the exact fix for the FIRST blocking gap. This turns a config fault into
127
+ * a seconds-fast, actionable add-rejection instead of a cryptic `cloud:up FAILED
128
+ * (exit 1)` the operator has to dig out of a 20-min job log.
129
+ *
130
+ * It guards the two config faults that silently broke every fleet add on
131
+ * 2026-07-20:
132
+ * - no resolvable compute zone → cloud:up aborts at its own --zone check in ~15s
133
+ * before creating any instance;
134
+ * - no GitHub token → the fresh VM's bootstrap dies cloning the PRIVATE editor
135
+ * repo at `could not read Username for 'https://github.com'`.
136
+ * `allowNoGitCredentials` mirrors cloud:up's --allow-no-git-credentials opt-out
137
+ * for an intentionally read-only VM (no push token needed). Pure; no I/O. */
138
+ function addPreflight({ zone, project, token, allowNoGitCredentials = false } = {}) {
139
+ const blank = (v) => typeof v !== "string" || v.trim() === "";
140
+ if (blank(zone)) {
141
+ return {
142
+ ok: false,
143
+ msg:
144
+ "no compute zone configured — set ZONE in /etc/codeyam/operator-host.env " +
145
+ "(or `gcloud config set compute/zone <zone>`) before adding a VM.",
146
+ };
147
+ }
148
+ if (blank(project)) {
149
+ return {
150
+ ok: false,
151
+ msg:
152
+ "no GCP project configured — set GCP_PROJECT (or a non-placeholder " +
153
+ "project in .codeyam/cloud.json) before adding a VM.",
154
+ };
155
+ }
156
+ if (!allowNoGitCredentials && blank(token)) {
157
+ return {
158
+ ok: false,
159
+ msg:
160
+ "no GitHub token — set GITHUB_TOKEN/GH_TOKEN in /etc/codeyam/operator-host.env " +
161
+ "(the VM bootstrap needs it to clone the private editor repo), or provision a " +
162
+ "read-only VM with --allow-no-git-credentials.",
163
+ };
164
+ }
73
165
  return { ok: true };
74
166
  }
75
167
 
@@ -123,13 +215,27 @@ function variantImageTag(variant) {
123
215
  return null; // dev / unknown — no packaged image
124
216
  }
125
217
 
218
+ /** Return true if the value matches a known test-fixture placeholder (e.g., "gcp-proj"),
219
+ * is null, empty, or whitespace, signaling that it is not a valid GCP project name.
220
+ * Documented as the home for future fixture sentinels. Pure; no I/O. */
221
+ function isPlaceholderProject(value) {
222
+ if (!value) return true;
223
+ if (typeof value !== "string") return true;
224
+ const trimmed = value.trim();
225
+ if (trimmed === "") return true;
226
+ if (trimmed.toLowerCase() === "gcp-proj") return true;
227
+ return false;
228
+ }
229
+
126
230
  /** Build the `cloud:up` flag array for a launch config + instance name. The
127
231
  * editor source is the canonical codeyam-editor repo (dev variant builds the
128
232
  * image from editorBranch); the client repo/branch is what lands in
129
233
  * /workspace. The build agent rides on `cfg.agent` (normalizeLaunchConfig
130
234
  * guarantees a known value, defaulting to claude), so a single config object
131
- * carries every launch choice. Decoupled exactly as cloud.js consumes them. */
132
- function cloudUpArgs(cfg, instanceName, project) {
235
+ * carries every launch choice. Decoupled exactly as cloud.js consumes them.
236
+ * `zone` is the resolved compute zone (the dashboard's ZONE); passing it
237
+ * explicitly is what keeps an add off the ambient gcloud default. */
238
+ function cloudUpArgs(cfg, instanceName, project, zone) {
133
239
  const args = ["--instance-name", instanceName];
134
240
  // Pass the authoritative GCP project (the dashboard's resolved PROJECT)
135
241
  // explicitly so cloud:up never relies on the spawning shell's ambient
@@ -137,16 +243,33 @@ function cloudUpArgs(cfg, instanceName, project) {
137
243
  // a persisted .codeyam/cloud.json `project` (cloud.js merges
138
244
  // `{ ...existing, ...flags }`), which a bare cloud:up reuses verbatim with no
139
245
  // gcloud fallback — the sticky-wrong-project failure mode where a stale
140
- // "gcp-proj" in cloud.json wedges every re-provision with a firewall-create
141
- // PERMISSION_DENIED. Omitted when unset to preserve the pre-existing arg shape
142
- // (cloud.js then falls back to the gcloud default, as before).
246
+ // placeholder in cloud.json (validated via isPlaceholderProject) wedges every
247
+ // re-provision with a firewall-create PERMISSION_DENIED. Omitted when unset to
248
+ // preserve the pre-existing arg shape (cloud.js then falls back to the gcloud
249
+ // default, as before).
143
250
  if (project) args.push("--project", project);
251
+ // Pass the resolved compute zone explicitly, for the SAME reason as --project:
252
+ // a bare cloud:up otherwise falls back to `gcloud config get-value
253
+ // compute/zone`, and an operator host with no default compute/zone aborts
254
+ // EVERY add at cloud:up's own --zone check in ~15s, before any instance is
255
+ // created (the 2026-07-20 fleet-wide add failure). Omitted only when the caller
256
+ // resolves no zone, preserving the pre-existing arg shape for legacy callers.
257
+ if (zone) args.push("--zone", zone);
144
258
  // Greenfield (new folder) vs clone an existing repo.
145
259
  if (cfg.newProjectName) args.push("--new-project", cfg.newProjectName);
146
260
  else args.push("--repo", cfg.clientRepo);
147
261
  args.push("--agent", cfg.agent || "claude", "--editor-repo", CANONICAL_EDITOR_REPO, "--editor-variant", cfg.editorVariant);
148
262
  if (!cfg.newProjectName && cfg.clientBranch) args.push("--branch", cfg.clientBranch);
149
263
  if (cfg.editorBranch) args.push("--editor-branch", cfg.editorBranch);
264
+ // Forward an optional custom hostname slug. Only single-VM Adds carry one (the
265
+ // dashboard strips it for batch count > 1 — N VMs can't share one hostname), so
266
+ // cloudUpArgs simply emits it when present and trusts the caller's batch guard.
267
+ if (cfg.vmSlug) args.push("--vm-slug", cfg.vmSlug);
268
+ // Forward an optional per-VM access whitelist. Like the slug, only single-VM
269
+ // Adds carry one (the dashboard strips it for batch count > 1 — the same guard
270
+ // actionAdd applies to vmSlug), so cloudUpArgs emits it when present and trusts
271
+ // the caller's batch guard.
272
+ if (cfg.vmAccess) args.push("--vm-access", cfg.vmAccess);
150
273
  // The dashboard opens its own per-VM tunnel, so cloud:up must NOT open one:
151
274
  // it lets cloud:up exit cleanly and avoids parallel batches colliding on the
152
275
  // fixed local ports 14199/5173 (the cause of multi-add "false failures").
@@ -313,6 +436,28 @@ function configForVm(history, n) {
313
436
  return h && h.config ? { ...EMPTY_CONFIG, ...h.config } : null;
314
437
  }
315
438
 
439
+ /** The most-recent launch timestamp for VM `n`, or null when it has no recorded
440
+ * launch. The history is newest-first (pushHistory unshifts), so the FIRST entry
441
+ * for the VM is its latest launch — the same lookup shape configForVm uses.
442
+ * Powers the editor-starting grace window (server.js's deriveStatus): "was this
443
+ * unreachable VM launched recently enough to still be starting up?" Pure. */
444
+ function latestLaunchTs(history, n) {
445
+ const h = (Array.isArray(history) ? history : []).find((e) => e && e.vm === n);
446
+ return h && typeof h.ts === "string" ? h.ts : null;
447
+ }
448
+
449
+ /** True when ISO launch timestamp `ts` is within `graceMs` of `nowMs` — the
450
+ * editor-starting grace window. A null/unparseable `ts` (no launch on record) is
451
+ * never "recent", so a VM with no history never reads as starting. Half-open
452
+ * window: exactly at the boundary (`nowMs - ts === graceMs`) is NOT recent, so
453
+ * the calm-blue window has a crisp, testable end. Pure; caller owns `nowMs`. */
454
+ function recentlyLaunchedWithin(ts, nowMs, graceMs) {
455
+ if (typeof ts !== "string") return false;
456
+ const t = Date.parse(ts);
457
+ if (Number.isNaN(t)) return false;
458
+ return nowMs - t < graceMs;
459
+ }
460
+
316
461
  /** Resolve the card's display identity, preferring LIVE truth (the editor's
317
462
  * `/api/session-info` `clientProject` + `editorIdentity`) over STALE launch
318
463
  * history. This is the fix for fragility #33's lying label: a CLI branch
@@ -382,6 +527,86 @@ function stickyClientProject(prev, info) {
382
527
  return prev || null;
383
528
  }
384
529
 
530
+ /** Resolve a VM's app dev-server port stickily: prefer the freshly-polled
531
+ * numeric port, else retain the last-known-good `prevAppPort`, so a transient
532
+ * null poll (timeout / partial probe) never blanks a port the VM has already
533
+ * reported. Mirrors `stickyClientProject` — a populated `prev` survives a
534
+ * null/absent fresh poll, a genuinely-changed fresh poll always wins
535
+ * (stickiness must NOT pin a stale port: a real 3000→4321 move is honored),
536
+ * and `null` results ONLY when neither a polled nor a previous port exists —
537
+ * the true first-provision case, where the caller applies its 5173 bootstrap
538
+ * fallback.
539
+ *
540
+ * Without this, `appPort` was `(info && info.appPort) || null`, so one
541
+ * timed-out poll blanked the resolved port; the tunnel-open fallback then
542
+ * re-seeded the hardcoded 5173 Vite default and the next poll saw a 5173→3000
543
+ * drift and re-wired — a churn loop that never converged. Pure; no I/O.
544
+ *
545
+ * @param prevAppPort the previous `state.vms[n].appPort` (null/undefined ok)
546
+ * @param info the fresh poll result object (null when the probe failed)
547
+ * @returns the app port to store: fresh numeric port when present, else the
548
+ * last-known-good `prevAppPort`, else null. */
549
+ function stickyAppPort(prevAppPort, info) {
550
+ const fresh = info && info.appPort;
551
+ if (typeof fresh === 'number' && !Number.isNaN(fresh)) return fresh;
552
+ return typeof prevAppPort === 'number' && !Number.isNaN(prevAppPort) ? prevAppPort : null;
553
+ }
554
+
555
+ /** Resolve a VM's preview-tunnel reachability stickily, mirroring `stickyAppPort`.
556
+ * A single flaky preview probe must not blink the 👁 Live Preview link off:
557
+ * an explicit `true`/`false` from the fresh poll passes through (a genuine
558
+ * dead/alive transition always wins), but a missing / null / undefined
559
+ * `info.previewReachable` (transient poll — the probe was skipped, timed out,
560
+ * or the whole probe object is absent) retains the last-known-good
561
+ * `prevPreviewReachable` instead of blanking it to null. `null` results only
562
+ * when neither a fresh boolean nor a previous boolean exists — the true
563
+ * never-probed first-poll case. Pure; no I/O.
564
+ *
565
+ * @param prevPreviewReachable the previous `state.vms[n].previewReachable`
566
+ * @param info the fresh probe result ({ previewReachable } | null)
567
+ * @returns true | false | null per the rules above. */
568
+ function stickyPreviewReachable(prevPreviewReachable, info) {
569
+ const fresh = info && info.previewReachable;
570
+ if (fresh === true || fresh === false) return fresh;
571
+ return prevPreviewReachable === true || prevPreviewReachable === false
572
+ ? prevPreviewReachable
573
+ : null;
574
+ }
575
+
576
+ /** Resolve a VM's preview-SERVING signal — "is the VM's dev server up?" —
577
+ * stickily from the `previewState` field the editor already emits on
578
+ * `/api/session-info`. This is the operator-host-safe replacement for the
579
+ * loopback-only `previewReachable` probe: session-info reaches every VM over
580
+ * the IAP-LB seam on the GCE operator host, where the `127.0.0.1` preview
581
+ * forward `previewReachable` depended on cannot exist (so it read `false`
582
+ * fleet-wide there). It measures the SAME thing the old loopback probe did —
583
+ * "the dev server serves" — not public LB ingress; that is `previewIngress`'s job.
584
+ *
585
+ * `compute_preview_state` classifies it authoritatively from inside the VM
586
+ * (immune to tunnel flakiness, with a recompile-grace debounce):
587
+ * `"forwarding"` → true (dev server up / serving).
588
+ * `"placeholder"` → false (dev server down or never started).
589
+ * anything else → sticky: a missing field (legacy editor predating
590
+ * `previewState`, or `info` null on a transient/failed
591
+ * poll) retains the last-known-good `prevPreviewServing`
592
+ * rather than blinking the 👁 link off. `null` only when
593
+ * neither a fresh state nor a previous boolean exists —
594
+ * the true never-probed first-poll case. Pure; no I/O.
595
+ *
596
+ * Mirrors `stickyPreviewReachable`'s tri-state stickiness exactly.
597
+ *
598
+ * @param prevPreviewServing the previous `state.vms[n].previewServing`
599
+ * @param info the fresh `/api/session-info` result ({ previewState } | null)
600
+ * @returns true | false | null per the rules above. */
601
+ function stickyPreviewServing(prevPreviewServing, info) {
602
+ const state = info && info.previewState;
603
+ if (state === 'forwarding') return true;
604
+ if (state === 'placeholder') return false;
605
+ return prevPreviewServing === true || prevPreviewServing === false
606
+ ? prevPreviewServing
607
+ : null;
608
+ }
609
+
385
610
  /** Classify a VM's project KIND as `"self-host"` vs `"guest"` from the live
386
611
  * identity reported in `/api/session-info` (`clientProject.repo`), with
387
612
  * launch-history `clientRepo` as a fallback when live is absent. A guest is
@@ -540,10 +765,80 @@ function resolveCredsSource({ reachableDonors = [], claudeCredsSrc = "", githubT
540
765
  return { mode: "none", donor: null };
541
766
  }
542
767
 
768
+ /** True when the resolved creds `mode` (see `resolveCredsSource`) carries a real
769
+ * Claude-auth seed source, so the add path can seed it automatically and must
770
+ * NOT raise the `claude-auth-not-seeded` setup step:
771
+ * - "donor" — copies the donor's `.credentials.json` + `.claude.json`.
772
+ * - "host-stash" — pushes the operator's CLAUDE_CREDS_SRC `.credentials.json`.
773
+ * The token-only and none modes carry NO Claude credentials — a GITHUB_TOKEN
774
+ * seeds *git* auth at boot but not Claude, so those genuinely have nothing to
775
+ * seed Claude from and the add path raises `claude-auth-not-seeded` (rendered
776
+ * as the calm Plan-1 `auth` setup step with the one-click Login). Branching on
777
+ * this instead of unconditionally landing degraded is what keeps the reason from
778
+ * appearing when a donor/stash WAS available. Pure; no I/O. */
779
+ function claudeAuthSeeded(mode) {
780
+ return mode === "donor" || mode === "host-stash";
781
+ }
782
+
543
783
  // Single-quote a string for safe inclusion in a remote shell command. Mirrors
544
784
  // the server.js helper so callers don't have to thread one through.
545
785
  function _shq(s) { return `'${String(s).replace(/'/g, `'\\''`)}'`; }
546
786
 
787
+ /** Build a `docker exec … bash -c` sink that stages stdin to a same-directory
788
+ * `.cytmp` sibling and only `mv`s it onto `target` when the staged file is
789
+ * NON-EMPTY — exiting 1 (so the caller's `… || echo CY_DEGRADED` fires) and
790
+ * leaving any existing `target` UNTOUCHED on empty/unreadable input.
791
+ *
792
+ * Closes the truncate-to-0 hazard the plan targets: an empty/unreadable donor's
793
+ * `base64 -d > /root/.claude/.credentials.json` used to clobber the live creds
794
+ * to a 0-byte file AND still report success, because the pipeline's exit status
795
+ * is the sink's, not the empty upstream's. This mirrors the non-empty `-s` guard
796
+ * the git-cred cache path already uses (scripts/cloud-editor-entrypoint.sh).
797
+ *
798
+ * `decode:true` base64-decodes stdin (the donor / host-stash cred copies);
799
+ * `decode:false` copies stdin verbatim (the Rebuild onboarding-state re-seed).
800
+ *
801
+ * `requireCredentialShape:true` adds, ON TOP of the non-empty guard, a Claude
802
+ * `.credentials.json` shape check before the `mv`: a positive plausible
803
+ * `expiresAt` (rejects the observed corrupt-write `expiresAt: 0`) AND non-empty
804
+ * access/refresh tokens. A non-empty-but-malformed donor (valid-looking file
805
+ * with a sentinel expiry or empty tokens) therefore never overwrites the
806
+ * target — closing the remaining gap the non-empty `[ -s ]` guard alone leaves.
807
+ * The checks are quote-free single-quoted ERE patterns (no `"`), which stay
808
+ * intact through the ssh→docker→`bash -c "…"` quoting layers without escaping.
809
+ * The editor-side self-heal (control-api `creds_refresh` / `auth_session`)
810
+ * remains the durable defense against a bad credential from ANY writer; this
811
+ * hardens the in-repo copy path so it is not itself a source.
812
+ *
813
+ * Deliberately uses NO shell variables or command substitution: a fixed
814
+ * `<target>.cytmp` sibling keeps the script free of `$var`, which the VM-host
815
+ * shell that runs the `gcloud … --command` would otherwise expand (to empty)
816
+ * before docker exec ever sees it. The `.cytmp` sibling sits in the same
817
+ * directory as `target`, so the `mv` is an atomic same-filesystem rename. */
818
+ function verifyBeforeMoveSink(
819
+ container,
820
+ target,
821
+ { decode = true, requireCredentialShape = false } = {},
822
+ ) {
823
+ const tmp = `${target}.cytmp`;
824
+ const read = decode ? "base64 -d" : "cat";
825
+ // Positive plausible `expiresAt` (13+ digit millis, first digit non-zero — so
826
+ // `0`/negatives/tiny values fail), plus non-empty access + refresh tokens
827
+ // (≥20 token-ish chars). `[^0-9-]*` / `[^A-Za-z0-9]*` bridge the `":"`
828
+ // punctuation without literal quotes; the value `0` in a corrupt file is a
829
+ // digit that blocks the skip, so the pattern can't false-match a later field.
830
+ const shape = requireCredentialShape
831
+ ? ` && grep -Eq 'expiresAt[^0-9-]*[1-9][0-9]{12,}' ${tmp}` +
832
+ ` && grep -Eq 'accessToken[^A-Za-z0-9]*[A-Za-z0-9_-]{20,}' ${tmp}` +
833
+ ` && grep -Eq 'refreshToken[^A-Za-z0-9]*[A-Za-z0-9_-]{20,}' ${tmp}`
834
+ : "";
835
+ const script =
836
+ `umask 077; ${read} > ${tmp}; ` +
837
+ `if [ -s ${tmp} ]${shape}; then mv ${tmp} ${target} && chmod 600 ${target}; ` +
838
+ `else rm -f ${tmp}; exit 1; fi`;
839
+ return `docker exec -i ${container} bash -c "${script}"`;
840
+ }
841
+
547
842
  /** Build the shell steps that seed a freshly-added VM's claude + git credentials,
548
843
  * for the resolved `mode` (see `resolveCredsSource`). Returns an array of shell
549
844
  * strings the caller newline-joins into the larger provisioning script — each
@@ -569,15 +864,22 @@ function _shq(s) { return `'${String(s).replace(/'/g, `'\\''`)}'`; }
569
864
  *
570
865
  * Pure; no I/O. */
571
866
  function buildCredsSteps({ mode, refInst = "", inst, container, gcloud, zone, n, claudeCredsSrc = "" } = {}) {
572
- const claudeSink = `docker exec -i ${container} bash -c "umask 077; base64 -d > /root/.claude/.credentials.json && chmod 600 /root/.claude/.credentials.json"`;
573
- const claudeJsonSink = `docker exec -i ${container} bash -c "umask 077; base64 -d > /root/.claude.json && chmod 600 /root/.claude.json"`;
867
+ // The `.credentials.json` copy additionally validates the donor's credential
868
+ // shape (positive `expiresAt`, non-empty tokens) before overwriting — a
869
+ // non-empty-but-corrupt donor must not clobber a working target. The
870
+ // `.claude.json` (onboarding-state) copy has a different shape, so it keeps
871
+ // the plain non-empty guard.
872
+ const claudeSink = verifyBeforeMoveSink(container, "/root/.claude/.credentials.json", {
873
+ requireCredentialShape: true,
874
+ });
875
+ const claudeJsonSink = verifyBeforeMoveSink(container, "/root/.claude.json");
574
876
  const loginHint = `run: docker exec -it ${container} claude /login on ${inst}`;
575
877
  if (mode === "donor") {
576
878
  return [
577
879
  `echo "copying git creds from ${refInst}..."`,
578
880
  `${gcloud} compute ssh ${refInst} --tunnel-through-iap --zone=${zone} --command='cat $HOME/.codeyam-cloud-git-credentials' 2>/dev/null | ${gcloud} compute ssh ${inst} --tunnel-through-iap --zone=${zone} --command='umask 077; cat > $HOME/.codeyam-cloud-git-credentials' 2>/dev/null || echo "CY_DEGRADED git creds copy failed on VM-${n} — re-copy creds from a live donor (the agent can't git pull/push until then)"`,
579
881
  `echo "copying claude auth from ${refInst} (container->container)..."`,
580
- `${gcloud} compute ssh ${refInst} --tunnel-through-iap --zone=${zone} --command='docker exec ${container} base64 /root/.claude/.credentials.json' 2>/dev/null | ${gcloud} compute ssh ${inst} --tunnel-through-iap --zone=${zone} --command=${_shq(claudeSink)} 2>/dev/null && echo "claude auth copied" || echo "CY_DEGRADED claude auth copy failed on VM-${n} — ${loginHint}"`,
882
+ `${gcloud} compute ssh ${refInst} --tunnel-through-iap --zone=${zone} --command='docker exec ${container} base64 /root/.claude/.credentials.json' 2>/dev/null | ${gcloud} compute ssh ${inst} --tunnel-through-iap --zone=${zone} --command=${_shq(claudeSink)} 2>/dev/null && echo "claude auth copied" || echo "CY_DEGRADED claude auth copy failed on VM-${n} (empty/unreadable/malformed donor — existing creds left untouched, not overwritten with a corrupt credential) — ${loginHint}"`,
581
883
  `echo "copying claude.json (onboarding state) from ${refInst} (container->container)..."`,
582
884
  `${gcloud} compute ssh ${refInst} --tunnel-through-iap --zone=${zone} --command='docker exec ${container} base64 /root/.claude.json' 2>/dev/null | ${gcloud} compute ssh ${inst} --tunnel-through-iap --zone=${zone} --command=${_shq(claudeJsonSink)} 2>/dev/null && echo "claude.json copied" || echo "CY_DEGRADED claude.json copy failed on VM-${n} — first interactive claude launch may show 'Select login method' menu; ${loginHint} once to complete onboarding"`,
583
885
  ];
@@ -628,7 +930,7 @@ function buildClaudeJsonPreserveSteps({ container, n = "", bakVar = "CY_CLAUDE_J
628
930
  `if docker exec ${container} cat /root/.claude.json > "$${bakVar}" 2>/dev/null && [ -s "$${bakVar}" ]; then echo "captured /root/.claude.json ($(wc -c < "$${bakVar}") bytes) to preserve onboarding state across recreate"; else echo "no /root/.claude.json to preserve (new container will use the image baseline)"; : > "$${bakVar}"; fi`,
629
931
  ];
630
932
  const restore = [
631
- `if [ -s "$${bakVar}" ]; then docker exec -i ${container} bash -c 'umask 077; cat > /root/.claude.json && chmod 600 /root/.claude.json' < "$${bakVar}" && echo "re-seeded /root/.claude.json (onboarding state preserved across recreate)" || echo "CY_DEGRADED claude.json re-seed failed on VM-${n} after Rebuild — first interactive claude launch may show 'Select login method'; ${loginHint} once to complete onboarding"; else echo "no preserved /root/.claude.json; ${loginHint} if the Build tab shows the welcome menu"; fi`,
933
+ `if [ -s "$${bakVar}" ]; then ${verifyBeforeMoveSink(container, "/root/.claude.json", { decode: false })} < "$${bakVar}" && echo "re-seeded /root/.claude.json (onboarding state preserved across recreate)" || echo "CY_DEGRADED claude.json re-seed failed on VM-${n} after Rebuild — first interactive claude launch may show 'Select login method'; ${loginHint} once to complete onboarding"; else echo "no preserved /root/.claude.json; ${loginHint} if the Build tab shows the welcome menu"; fi`,
632
934
  `rm -f "$${bakVar}"`,
633
935
  ];
634
936
  return { capture, restore };
@@ -652,6 +954,69 @@ function buildEnvApplyCommand({ gcloud, instance, zone, envFile, container, edit
652
954
  return `base64 < ${_shq(envFile)} | ${gcloud} compute ssh ${instance} --tunnel-through-iap --zone=${zone} --command=${_shq(sink)}`;
653
955
  }
654
956
 
957
+ /** Build the `nohup … gcloud compute ssh … -N -L …` shell string that opens the
958
+ * EDITOR reachability forward (`editorPort` → VM:14199) as its OWN ssh process,
959
+ * logging to `/tmp/ssh-tunnel-vm-<n>.log`. The editor forward is the dashboard's
960
+ * reachability signal — the probe to `PORT_BASE+N` connects through it — so it
961
+ * must have a lifetime INDEPENDENT of the preview forward (whose port legitimately
962
+ * changes as the app's dev server is resolved). Splitting the two forwards into
963
+ * separate ssh processes is what makes a preview re-wire non-disruptive: a
964
+ * preview-port change restarts only the preview tunnel and never blips :14199, so
965
+ * the reachability probe can't flip the VM to a false "unreachable" (the
966
+ * VM-1/2/3 flapping observed 2026-06-22). Pairs with `previewTunnelCmd`; the two
967
+ * builders are the single source of truth for the attach (addVm) and re-wire call
968
+ * sites so neither forward's `-L` spec can drift back into the other's process.
969
+ * Pure; no I/O. */
970
+ function editorTunnelCmd({ gcloud, inst, zone, editorPort, n } = {}) {
971
+ return `nohup ${gcloud} compute ssh ${inst} --tunnel-through-iap --zone=${zone} -- -N -L ${editorPort}:127.0.0.1:14199 > /tmp/ssh-tunnel-vm-${n}.log 2>&1 &`;
972
+ }
973
+
974
+ /** Build the `nohup … gcloud compute ssh … -N -L …` shell string that opens the
975
+ * LIVE-PREVIEW forward (`previewPort` → VM:<appPort>) as its OWN ssh process,
976
+ * logging to `/tmp/ssh-preview-vm-<n>.log`. The preview forward's target port
977
+ * changes whenever the app's dev-server port is (re)resolved, so it lives apart
978
+ * from the editor reachability forward (`editorTunnelCmd`): a preview re-wire
979
+ * pkills + respawns ONLY this process, leaving the editor :14199 forward — and
980
+ * the reachability probe riding it — untouched. The preview local port is unique
981
+ * per VM and never collides with the editor port, so the re-wire/destroy
982
+ * `pkill -f "<previewPort>:127.0.0.1:"` targets exactly this process. Pure; no
983
+ * I/O. */
984
+ function previewTunnelCmd({ gcloud, inst, zone, previewPort, appPort, n } = {}) {
985
+ return `nohup ${gcloud} compute ssh ${inst} --tunnel-through-iap --zone=${zone} -- -N -L ${previewPort}:127.0.0.1:${appPort} > /tmp/ssh-preview-vm-${n}.log 2>&1 &`;
986
+ }
987
+
988
+ /** Build the shell snippet the Add script runs to AWAIT cloud:up's clean exit at
989
+ * container-healthy instead of reaping it mid-flight.
990
+ *
991
+ * History — two bugs, one helper: (1) vm-add-ingress-survives-bes-create: the Add
992
+ * used to `kill $UP_PID` ~15s after the container was Up, SIGTERMing
993
+ * `backend-services create` mid-flight (the 503 strand), so this began polling
994
+ * `cloud:up` to exit on its own rather than reaping it. (2)
995
+ * vm--async-add-time-lb-ingress-wiring: `cloud:up` no longer BLOCKS on the wiring
996
+ * tail — it kicks the EXTERNAL_MANAGED `backend-services create` + propagation off
997
+ * DETACHED (see cloud.js startDetachedAddWiring) and returns at container-healthy,
998
+ * so the detached child can no longer be reaped by anything that kills `cloud:up`.
999
+ * This poll therefore now resolves in ~seconds on the happy path (cloud:up has
1000
+ * already exited); it stays only as the bounded backstop that reaps a `cloud:up`
1001
+ * genuinely wedged BEFORE the early return (e.g. a stuck container gate). The
1002
+ * background wiring converges via the dashboard's auto-rewire gather, so
1003
+ * `deadlineSec` shrinks from the old 900s wiring budget to a container-readiness
1004
+ * bound. `pidVar` is referenced by name so it expands at run time. Pure; no I/O. */
1005
+ function awaitWiringCmd({ inst, deadlineSec = 180, pidVar = "UP_PID" } = {}) {
1006
+ return [
1007
+ `echo "awaiting cloud:up exit at container-healthy for ${inst} (up to ${deadlineSec}s; LB wiring converges in the background)..."`,
1008
+ `__cy_wiring_waited=0`,
1009
+ `while kill -0 $${pidVar} 2>/dev/null; do`,
1010
+ ` if [ "$__cy_wiring_waited" -ge ${deadlineSec} ]; then`,
1011
+ ` echo "WARN cloud:up still running after ${deadlineSec}s (wedged before the container-healthy early return) — reaping it (the dashboard self-heal will converge any half-wired backend)"`,
1012
+ ` kill $${pidVar} 2>/dev/null; pkill -f "cloud.js up --instance-name ${inst}" 2>/dev/null; break`,
1013
+ ` fi`,
1014
+ ` sleep 5; __cy_wiring_waited=$((__cy_wiring_waited + 5))`,
1015
+ `done`,
1016
+ `wait $${pidVar} 2>/dev/null || true`,
1017
+ ].join("\n");
1018
+ }
1019
+
655
1020
  /** Get authenticated Git URL for checking client branch. */
656
1021
  function getAuthRepoUrl(repo, token) {
657
1022
  if (!repo) return "";
@@ -697,6 +1062,7 @@ module.exports = {
697
1062
  EMPTY_CONFIG,
698
1063
  normalizeLaunchConfig,
699
1064
  validateLaunchConfig,
1065
+ addPreflight,
700
1066
  isVariantProvisionable,
701
1067
  isVariantRebuildable,
702
1068
  variantNpmDistTag,
@@ -711,17 +1077,28 @@ module.exports = {
711
1077
  recentLaunchDisplay,
712
1078
  defaultConfig,
713
1079
  configForVm,
1080
+ latestLaunchTs,
1081
+ recentlyLaunchedWithin,
714
1082
  resolveCardIdentity,
715
1083
  stickyClientProject,
1084
+ stickyAppPort,
1085
+ stickyPreviewReachable,
1086
+ stickyPreviewServing,
716
1087
  classifyProjectKind,
717
1088
  resolveCredsSource,
1089
+ claudeAuthSeeded,
718
1090
  buildCredsSteps,
1091
+ verifyBeforeMoveSink,
719
1092
  buildClaudeJsonPreserveSteps,
720
1093
  buildEnvApplyCommand,
721
1094
  resolveEditorSource,
722
1095
  editorSourceShellResolve,
723
1096
  rebuildStrategy,
724
1097
  decideStartJob,
1098
+ editorTunnelCmd,
1099
+ previewTunnelCmd,
1100
+ awaitWiringCmd,
725
1101
  getAuthRepoUrl,
726
1102
  interpretLsRemote,
1103
+ isPlaceholderProject,
727
1104
  };