@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
@@ -0,0 +1,292 @@
1
+ 'use strict';
2
+ // Pure builders for the LOCAL shell invocations that wire the fleet's Google
3
+ // HTTPS Load Balancer ingress: the shared, VM-independent infra
4
+ // (scripts/gcp/google-ingress.sh) and one VM's per-surface backends
5
+ // (scripts/gcp/vm-ingress.sh). cloud.js calls these to decide WHEN to wire and
6
+ // to build the exact `{ command, args, env }` it spawns — keeping the decision
7
+ // and the argv/env shape unit-testable without ever spawning a process, exactly
8
+ // like npm/vm-urls.js and npm/google-ingress.js single-source their derivations.
9
+ //
10
+ // Pure logic only — no I/O, no env reads beyond the explicit `env`/`cfg`
11
+ // arguments callers pass in. The fleet domain governing ingress comes from the
12
+ // project's `.codeyam/cloud.json` (`fleetDomain`, persisted by cloud.js) with a
13
+ // `FLEET_DOMAIN` env fallback; when neither is set every builder reports the
14
+ // legacy quick-tunnel mode (resolveIngressDomain → null) and cloud.js leaves the
15
+ // Add/Destroy path byte-for-byte unchanged.
16
+ //
17
+ // Stack note: this is fleet-operator infrastructure (the cloud GCE fleet that
18
+ // runs codeyam-editor itself), GCP-specific by design — the same posture as the
19
+ // scripts it invokes. A project that never joins the fleet never sets
20
+ // fleetDomain and never reaches a builder here.
21
+
22
+ const { resolveFleetDomain } = require('./vm-urls');
23
+
24
+ // Cloud DNS managed-zone name google-ingress.sh defaults to. Mirrored here so a
25
+ // cloud.json without an explicit `dnsZone` resolves to the same zone the script
26
+ // would, rather than passing an empty DNS_ZONE that shadows the script default.
27
+ const DEFAULT_DNS_ZONE = 'fleet-zone';
28
+
29
+ /** Resolve the fleet domain that governs ingress wiring, or null when none is
30
+ * configured (legacy quick-tunnel mode). `cfg.fleetDomain` (persisted in
31
+ * cloud.json) wins; the `FLEET_DOMAIN` env value is the fallback. Both paths go
32
+ * through vm-urls.js::resolveFleetDomain, so a present-but-malformed value
33
+ * throws here (a typo'd domain must fail loud, never bake a dead host into the
34
+ * URL map) and a blank value reads as "unset". */
35
+ function resolveIngressDomain(cfg = {}, env = {}) {
36
+ const fromCfg = cfg.fleetDomain;
37
+ if (fromCfg != null && String(fromCfg).trim() !== '') {
38
+ return resolveFleetDomain({ FLEET_DOMAIN: fromCfg });
39
+ }
40
+ return resolveFleetDomain(env);
41
+ }
42
+
43
+ /** Resolve the Cloud DNS managed-zone name: `cfg.dnsZone`, then a `DNS_ZONE`
44
+ * env override, then the `fleet-zone` default google-ingress.sh itself uses. */
45
+ function resolveDnsZone(cfg = {}, env = {}) {
46
+ const fromCfg = cfg.dnsZone;
47
+ if (fromCfg != null && String(fromCfg).trim() !== '') return String(fromCfg).trim();
48
+ const fromEnv = env.DNS_ZONE;
49
+ if (fromEnv != null && String(fromEnv).trim() !== '') return String(fromEnv).trim();
50
+ return DEFAULT_DNS_ZONE;
51
+ }
52
+
53
+ /** Derive the GCE region from a zone (`us-central1-a` → `us-central1`), or null
54
+ * when the zone is missing/unparseable so the caller omits REGION and lets
55
+ * google-ingress.sh apply its own default. A GCE zone is a region plus a
56
+ * trailing `-<letter>`. */
57
+ function regionFromZone(zone) {
58
+ if (zone == null) return null;
59
+ const z = String(zone).trim();
60
+ if (z === '') return null;
61
+ const m = /^(.+)-[a-z]$/.exec(z);
62
+ return m ? m[1] : null;
63
+ }
64
+
65
+ /** Build the local invocation for `scripts/gcp/vm-ingress.sh <N> <up|down>` —
66
+ * one VM's per-surface NEG → IAP backend → URL-map host rule. Returns
67
+ * `{ command, args, env }`; `env` carries only the keys the script reads beyond
68
+ * its own defaults (GCP_PROJECT, ZONE, FLEET_DOMAIN, APP_PORT, INSTANCE_PREFIX,
69
+ * VM_SLUG). `appPort` is the resolved per-project dev-server port the preview
70
+ * NEG targets; omit it (down, or unknown) to let the script's bootstrap default
71
+ * stand. An optional `slug` becomes `VM_SLUG`, which makes the script route the
72
+ * VM's custom `<slug>.<surface>.<domain>` host (the same slug must be passed on
73
+ * `down` so the matching host rule is removed); omit it to keep the numeric
74
+ * `vm-<N>` host. */
75
+ function vmIngressInvocation({
76
+ scriptPath,
77
+ vmNumber,
78
+ action,
79
+ domain,
80
+ project,
81
+ zone,
82
+ appPort,
83
+ instancePrefix,
84
+ slug,
85
+ } = {}) {
86
+ if (!scriptPath) throw new Error('vmIngressInvocation requires scriptPath');
87
+ if (action !== 'up' && action !== 'down') {
88
+ throw new Error(`vm-ingress action must be 'up' or 'down', got: ${action}`);
89
+ }
90
+ const n = Number(vmNumber);
91
+ if (!Number.isInteger(n) || n <= 0) {
92
+ throw new Error(`vm-ingress requires a positive VM number, got: ${vmNumber}`);
93
+ }
94
+ if (!domain) throw new Error('vmIngressInvocation requires a fleet domain');
95
+ const env = { FLEET_DOMAIN: domain };
96
+ if (project) env.GCP_PROJECT = project;
97
+ if (zone) env.ZONE = zone;
98
+ if (appPort != null && String(appPort).trim() !== '') env.APP_PORT = String(appPort);
99
+ if (instancePrefix) env.INSTANCE_PREFIX = instancePrefix;
100
+ if (slug != null && String(slug).trim() !== '') env.VM_SLUG = String(slug).trim();
101
+ return { command: 'bash', args: [scriptPath, String(n), action], env };
102
+ }
103
+
104
+ /** Build the local invocation for `scripts/gcp/vm-ingress.sh <N> status` — the
105
+ * read-only probe that reports which of {NEG, backend service, host rule} exist
106
+ * per surface plus an overall `verdict=wired|partial|unwired`. Returns
107
+ * `{ command, args, env }`. Like `vmIngressInvocation` but the action is fixed
108
+ * to `status` and APP_PORT is omitted — status describes resources by NAME, and
109
+ * names key off the VM number, never the port. An optional `slug` rides through
110
+ * as VM_SLUG so the host-rule presence check matches the VM's custom
111
+ * `<slug>.<surface>.<domain>` host rather than the numeric `vm-<N>` one. */
112
+ function vmIngressStatusInvocation({
113
+ scriptPath,
114
+ vmNumber,
115
+ domain,
116
+ project,
117
+ zone,
118
+ slug,
119
+ } = {}) {
120
+ if (!scriptPath) throw new Error('vmIngressStatusInvocation requires scriptPath');
121
+ const n = Number(vmNumber);
122
+ if (!Number.isInteger(n) || n <= 0) {
123
+ throw new Error(`vm-ingress status requires a positive VM number, got: ${vmNumber}`);
124
+ }
125
+ if (!domain) throw new Error('vmIngressStatusInvocation requires a fleet domain');
126
+ const env = { FLEET_DOMAIN: domain };
127
+ if (project) env.GCP_PROJECT = project;
128
+ if (zone) env.ZONE = zone;
129
+ if (slug != null && String(slug).trim() !== '') env.VM_SLUG = String(slug).trim();
130
+ return { command: 'bash', args: [scriptPath, String(n), 'status'], env };
131
+ }
132
+
133
+ /** Parse the `verdict=<wired|partial|unwired>` line vm-ingress.sh `status`
134
+ * prints, returning that verdict or null when the output carries none (e.g. the
135
+ * script errored before emitting it). Pure string parsing so the reconcile
136
+ * decision below stays unit-testable without spawning the script. */
137
+ function parseIngressStatusVerdict(stdout) {
138
+ if (typeof stdout !== 'string') return null;
139
+ for (const line of stdout.split('\n')) {
140
+ const m = /^verdict=(wired|partial|unwired)\s*$/.exec(line.trim());
141
+ if (m) return m[1];
142
+ }
143
+ return null;
144
+ }
145
+
146
+ /** Pick which rostered VMs reconcile must re-wire from their status verdicts.
147
+ * `statuses` is `[{ vmNumber, verdict, ... }]`; a VM is selected iff its verdict
148
+ * is `unwired` or `partial` (it has missing LB resources). `wired` VMs are left
149
+ * untouched — no redundant gcloud churn — and an unknown/null verdict (the probe
150
+ * failed) is conservatively SKIPPED rather than blindly re-wired. Returns the
151
+ * selected entries in their input order, preserving any extra fields (slug) the
152
+ * caller threads through. Pure. */
153
+ function selectIngressReconcileTargets(statuses) {
154
+ if (!Array.isArray(statuses)) return [];
155
+ return statuses.filter((s) => s && (s.verdict === 'unwired' || s.verdict === 'partial'));
156
+ }
157
+
158
+ // =============================================================================
159
+ // Async add-time wiring (vm--async-add-time-lb-ingress-wiring)
160
+ // =============================================================================
161
+ // The Add used to BLOCK ~10 min on synchronous LB ingress wiring even after the
162
+ // container was healthy and serving. The fix returns the Add at container-healthy
163
+ // and converges the wiring in a DETACHED background tail (wire → access, the same
164
+ // ordering cmdUp ran inline) that outlives `cloud:up` — so the dashboard's reap of
165
+ // `cloud:up` can never SIGTERM an in-flight `backend-services create` (the add-time
166
+ // 503 strand the old 900s synchronous await was protecting against). The
167
+ // already-existing per-VM auto-rewire gather is the convergence backstop; it must
168
+ // NOT race the in-flight detached tail, so the tail drops an in-flight marker the
169
+ // dashboard reads via the pure `isWiringInflight` below.
170
+
171
+ /** Build the local invocation that re-execs THIS node entry (`scriptPath`, i.e.
172
+ * cloud.js) as a DETACHED `wire-add-tail` child: it runs the slow wiring + access
173
+ * grant in the background so the Add returns at container-healthy. Returns
174
+ * `{ command, args, env }`; the child reads `cfg`/`appPort` back out of the env
175
+ * (`CY_WIRE_ADD_CFG` JSON, `CY_WIRE_ADD_PORT`) rather than re-deriving them, so the
176
+ * background tail wires EXACTLY what the foreground Add would have. `nodePath` is
177
+ * the node executable to re-exec (caller passes `process.execPath`); kept a
178
+ * parameter so this stays pure/testable without reading the ambient env. */
179
+ function detachedAddTailInvocation({ nodePath, scriptPath, cfg, appPort } = {}) {
180
+ if (!nodePath) throw new Error('detachedAddTailInvocation requires nodePath');
181
+ if (!scriptPath) throw new Error('detachedAddTailInvocation requires scriptPath');
182
+ const env = { CY_WIRE_ADD_CFG: JSON.stringify(cfg || {}) };
183
+ if (appPort != null && String(appPort).trim() !== '') {
184
+ env.CY_WIRE_ADD_PORT = String(appPort);
185
+ }
186
+ return { command: nodePath, args: [scriptPath, 'wire-add-tail'], env };
187
+ }
188
+
189
+ /** The per-VM marker file the detached add-tail holds while its wiring is still
190
+ * running, so the dashboard's auto-rewire gather skips a VM whose FIRST wiring
191
+ * attempt is in flight (never firing a competing `vm-ingress.sh up`). Pure path
192
+ * derivation; the create/unlink/stat I/O lives at the call sites (cloud.js writes
193
+ * it, server.js reads it), mirroring how the status/verdict split keeps the
194
+ * decision testable without touching the filesystem. */
195
+ function ingressInflightMarkerPath(dir, vmNumber) {
196
+ const n = Number(vmNumber);
197
+ if (!Number.isInteger(n) || n <= 0) {
198
+ throw new Error(`ingressInflightMarkerPath requires a positive VM number, got: ${vmNumber}`);
199
+ }
200
+ return `${dir.replace(/\/+$/, '')}/cy-ingress-inflight-vm-${n}`;
201
+ }
202
+
203
+ /** Decide whether a freshly-added VM's detached wiring is still in flight, given
204
+ * the marker file's CONTENTS (the unix-ms start timestamp the tail wrote) and the
205
+ * current time. Returns false when the marker is missing/empty (`content` null) —
206
+ * no tail running — or STALE (older than `maxAgeMs`): a tail that crashed without
207
+ * unlinking its marker must not pin auto-rewire off forever, so once the staleness
208
+ * bound (comfortably past the ~10-min worst-case wiring) elapses the backstop takes
209
+ * over. Pure: the caller does the `readFileSync`/`existsSync`. */
210
+ function isWiringInflight(content, now, maxAgeMs) {
211
+ if (content == null) return false;
212
+ const started = Number(String(content).trim());
213
+ if (!Number.isFinite(started) || started <= 0) return false;
214
+ return (Number(now) - started) < Number(maxAgeMs);
215
+ }
216
+
217
+ /** Build the local invocation for `scripts/gcp/iap-access.sh <N> <grant|revoke>`
218
+ * — grant or revoke `roles/iap.httpsResourceAccessor` on one VM's per-surface
219
+ * backend services for a set of principals (the per-VM access whitelist).
220
+ * Returns `{ command, args, env }`; `env.VM_ACCESS` is the comma-joined,
221
+ * trimmed principal list the script reads (omitted when empty, so a revoke with
222
+ * nothing to remove is a clean no-op), plus GCP_PROJECT / ZONE when supplied.
223
+ * Mirrors `vmIngressInvocation` so the spawn shape stays unit-testable without
224
+ * ever invoking gcloud. The principal STRINGS are passed through verbatim —
225
+ * validation/normalization is the sidecar's job (npm/fleet-vm-access.js); this
226
+ * builder only shapes the argv/env. */
227
+ function iapAccessInvocation({
228
+ scriptPath,
229
+ vmNumber,
230
+ action,
231
+ principals,
232
+ project,
233
+ zone,
234
+ } = {}) {
235
+ if (!scriptPath) throw new Error('iapAccessInvocation requires scriptPath');
236
+ if (action !== 'grant' && action !== 'revoke') {
237
+ throw new Error(`iap-access action must be 'grant' or 'revoke', got: ${action}`);
238
+ }
239
+ const n = Number(vmNumber);
240
+ if (!Number.isInteger(n) || n <= 0) {
241
+ throw new Error(`iap-access requires a positive VM number, got: ${vmNumber}`);
242
+ }
243
+ const list = Array.isArray(principals)
244
+ ? principals
245
+ : String(principals == null ? '' : principals).split(',');
246
+ const joined = list.map((p) => String(p).trim()).filter(Boolean).join(',');
247
+ const env = {};
248
+ if (joined) env.VM_ACCESS = joined;
249
+ if (project) env.GCP_PROJECT = project;
250
+ if (zone) env.ZONE = zone;
251
+ return { command: 'bash', args: [scriptPath, String(n), action], env };
252
+ }
253
+
254
+ /** Build the local invocation for `scripts/gcp/google-ingress.sh` — the shared,
255
+ * VM-independent LB infra (static IP, wildcard cert, Cloud DNS, URL map, IAP
256
+ * brand). Returns `{ command, args, env }`; `env` carries only the keys the
257
+ * script reads beyond its defaults (GCP_PROJECT, REGION, ZONE, FLEET_DOMAIN,
258
+ * DNS_ZONE). The script is idempotent, so re-invoking against existing infra is
259
+ * a safe no-op. */
260
+ function sharedIngressInvocation({
261
+ scriptPath,
262
+ domain,
263
+ project,
264
+ zone,
265
+ dnsZone,
266
+ region,
267
+ } = {}) {
268
+ if (!scriptPath) throw new Error('sharedIngressInvocation requires scriptPath');
269
+ if (!domain) throw new Error('sharedIngressInvocation requires a fleet domain');
270
+ const env = { FLEET_DOMAIN: domain };
271
+ if (project) env.GCP_PROJECT = project;
272
+ if (region) env.REGION = region;
273
+ if (zone) env.ZONE = zone;
274
+ if (dnsZone) env.DNS_ZONE = dnsZone;
275
+ return { command: 'bash', args: [scriptPath], env };
276
+ }
277
+
278
+ module.exports = {
279
+ DEFAULT_DNS_ZONE,
280
+ resolveIngressDomain,
281
+ resolveDnsZone,
282
+ regionFromZone,
283
+ vmIngressInvocation,
284
+ vmIngressStatusInvocation,
285
+ parseIngressStatusVerdict,
286
+ selectIngressReconcileTargets,
287
+ detachedAddTailInvocation,
288
+ ingressInflightMarkerPath,
289
+ isWiringInflight,
290
+ iapAccessInvocation,
291
+ sharedIngressInvocation,
292
+ };
@@ -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,26 @@ 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
+ }
73
119
  return { ok: true };
74
120
  }
75
121
 
@@ -123,6 +169,18 @@ function variantImageTag(variant) {
123
169
  return null; // dev / unknown — no packaged image
124
170
  }
125
171
 
172
+ /** Return true if the value matches a known test-fixture placeholder (e.g., "gcp-proj"),
173
+ * is null, empty, or whitespace, signaling that it is not a valid GCP project name.
174
+ * Documented as the home for future fixture sentinels. Pure; no I/O. */
175
+ function isPlaceholderProject(value) {
176
+ if (!value) return true;
177
+ if (typeof value !== "string") return true;
178
+ const trimmed = value.trim();
179
+ if (trimmed === "") return true;
180
+ if (trimmed.toLowerCase() === "gcp-proj") return true;
181
+ return false;
182
+ }
183
+
126
184
  /** Build the `cloud:up` flag array for a launch config + instance name. The
127
185
  * editor source is the canonical codeyam-editor repo (dev variant builds the
128
186
  * image from editorBranch); the client repo/branch is what lands in
@@ -137,9 +195,10 @@ function cloudUpArgs(cfg, instanceName, project) {
137
195
  // a persisted .codeyam/cloud.json `project` (cloud.js merges
138
196
  // `{ ...existing, ...flags }`), which a bare cloud:up reuses verbatim with no
139
197
  // 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).
198
+ // placeholder in cloud.json (validated via isPlaceholderProject) wedges every
199
+ // re-provision with a firewall-create PERMISSION_DENIED. Omitted when unset to
200
+ // preserve the pre-existing arg shape (cloud.js then falls back to the gcloud
201
+ // default, as before).
143
202
  if (project) args.push("--project", project);
144
203
  // Greenfield (new folder) vs clone an existing repo.
145
204
  if (cfg.newProjectName) args.push("--new-project", cfg.newProjectName);
@@ -147,6 +206,15 @@ function cloudUpArgs(cfg, instanceName, project) {
147
206
  args.push("--agent", cfg.agent || "claude", "--editor-repo", CANONICAL_EDITOR_REPO, "--editor-variant", cfg.editorVariant);
148
207
  if (!cfg.newProjectName && cfg.clientBranch) args.push("--branch", cfg.clientBranch);
149
208
  if (cfg.editorBranch) args.push("--editor-branch", cfg.editorBranch);
209
+ // Forward an optional custom hostname slug. Only single-VM Adds carry one (the
210
+ // dashboard strips it for batch count > 1 — N VMs can't share one hostname), so
211
+ // cloudUpArgs simply emits it when present and trusts the caller's batch guard.
212
+ if (cfg.vmSlug) args.push("--vm-slug", cfg.vmSlug);
213
+ // Forward an optional per-VM access whitelist. Like the slug, only single-VM
214
+ // Adds carry one (the dashboard strips it for batch count > 1 — the same guard
215
+ // actionAdd applies to vmSlug), so cloudUpArgs emits it when present and trusts
216
+ // the caller's batch guard.
217
+ if (cfg.vmAccess) args.push("--vm-access", cfg.vmAccess);
150
218
  // The dashboard opens its own per-VM tunnel, so cloud:up must NOT open one:
151
219
  // it lets cloud:up exit cleanly and avoids parallel batches colliding on the
152
220
  // fixed local ports 14199/5173 (the cause of multi-add "false failures").
@@ -313,6 +381,28 @@ function configForVm(history, n) {
313
381
  return h && h.config ? { ...EMPTY_CONFIG, ...h.config } : null;
314
382
  }
315
383
 
384
+ /** The most-recent launch timestamp for VM `n`, or null when it has no recorded
385
+ * launch. The history is newest-first (pushHistory unshifts), so the FIRST entry
386
+ * for the VM is its latest launch — the same lookup shape configForVm uses.
387
+ * Powers the editor-starting grace window (server.js's deriveStatus): "was this
388
+ * unreachable VM launched recently enough to still be starting up?" Pure. */
389
+ function latestLaunchTs(history, n) {
390
+ const h = (Array.isArray(history) ? history : []).find((e) => e && e.vm === n);
391
+ return h && typeof h.ts === "string" ? h.ts : null;
392
+ }
393
+
394
+ /** True when ISO launch timestamp `ts` is within `graceMs` of `nowMs` — the
395
+ * editor-starting grace window. A null/unparseable `ts` (no launch on record) is
396
+ * never "recent", so a VM with no history never reads as starting. Half-open
397
+ * window: exactly at the boundary (`nowMs - ts === graceMs`) is NOT recent, so
398
+ * the calm-blue window has a crisp, testable end. Pure; caller owns `nowMs`. */
399
+ function recentlyLaunchedWithin(ts, nowMs, graceMs) {
400
+ if (typeof ts !== "string") return false;
401
+ const t = Date.parse(ts);
402
+ if (Number.isNaN(t)) return false;
403
+ return nowMs - t < graceMs;
404
+ }
405
+
316
406
  /** Resolve the card's display identity, preferring LIVE truth (the editor's
317
407
  * `/api/session-info` `clientProject` + `editorIdentity`) over STALE launch
318
408
  * history. This is the fix for fragility #33's lying label: a CLI branch
@@ -382,6 +472,31 @@ function stickyClientProject(prev, info) {
382
472
  return prev || null;
383
473
  }
384
474
 
475
+ /** Resolve a VM's app dev-server port stickily: prefer the freshly-polled
476
+ * numeric port, else retain the last-known-good `prevAppPort`, so a transient
477
+ * null poll (timeout / partial probe) never blanks a port the VM has already
478
+ * reported. Mirrors `stickyClientProject` — a populated `prev` survives a
479
+ * null/absent fresh poll, a genuinely-changed fresh poll always wins
480
+ * (stickiness must NOT pin a stale port: a real 3000→4321 move is honored),
481
+ * and `null` results ONLY when neither a polled nor a previous port exists —
482
+ * the true first-provision case, where the caller applies its 5173 bootstrap
483
+ * fallback.
484
+ *
485
+ * Without this, `appPort` was `(info && info.appPort) || null`, so one
486
+ * timed-out poll blanked the resolved port; the tunnel-open fallback then
487
+ * re-seeded the hardcoded 5173 Vite default and the next poll saw a 5173→3000
488
+ * drift and re-wired — a churn loop that never converged. Pure; no I/O.
489
+ *
490
+ * @param prevAppPort the previous `state.vms[n].appPort` (null/undefined ok)
491
+ * @param info the fresh poll result object (null when the probe failed)
492
+ * @returns the app port to store: fresh numeric port when present, else the
493
+ * last-known-good `prevAppPort`, else null. */
494
+ function stickyAppPort(prevAppPort, info) {
495
+ const fresh = info && info.appPort;
496
+ if (typeof fresh === 'number' && !Number.isNaN(fresh)) return fresh;
497
+ return typeof prevAppPort === 'number' && !Number.isNaN(prevAppPort) ? prevAppPort : null;
498
+ }
499
+
385
500
  /** Classify a VM's project KIND as `"self-host"` vs `"guest"` from the live
386
501
  * identity reported in `/api/session-info` (`clientProject.repo`), with
387
502
  * launch-history `clientRepo` as a fallback when live is absent. A guest is
@@ -540,6 +655,21 @@ function resolveCredsSource({ reachableDonors = [], claudeCredsSrc = "", githubT
540
655
  return { mode: "none", donor: null };
541
656
  }
542
657
 
658
+ /** True when the resolved creds `mode` (see `resolveCredsSource`) carries a real
659
+ * Claude-auth seed source, so the add path can seed it automatically and must
660
+ * NOT raise the `claude-auth-not-seeded` setup step:
661
+ * - "donor" — copies the donor's `.credentials.json` + `.claude.json`.
662
+ * - "host-stash" — pushes the operator's CLAUDE_CREDS_SRC `.credentials.json`.
663
+ * The token-only and none modes carry NO Claude credentials — a GITHUB_TOKEN
664
+ * seeds *git* auth at boot but not Claude, so those genuinely have nothing to
665
+ * seed Claude from and the add path raises `claude-auth-not-seeded` (rendered
666
+ * as the calm Plan-1 `auth` setup step with the one-click Login). Branching on
667
+ * this instead of unconditionally landing degraded is what keeps the reason from
668
+ * appearing when a donor/stash WAS available. Pure; no I/O. */
669
+ function claudeAuthSeeded(mode) {
670
+ return mode === "donor" || mode === "host-stash";
671
+ }
672
+
543
673
  // Single-quote a string for safe inclusion in a remote shell command. Mirrors
544
674
  // the server.js helper so callers don't have to thread one through.
545
675
  function _shq(s) { return `'${String(s).replace(/'/g, `'\\''`)}'`; }
@@ -652,6 +782,69 @@ function buildEnvApplyCommand({ gcloud, instance, zone, envFile, container, edit
652
782
  return `base64 < ${_shq(envFile)} | ${gcloud} compute ssh ${instance} --tunnel-through-iap --zone=${zone} --command=${_shq(sink)}`;
653
783
  }
654
784
 
785
+ /** Build the `nohup … gcloud compute ssh … -N -L …` shell string that opens the
786
+ * EDITOR reachability forward (`editorPort` → VM:14199) as its OWN ssh process,
787
+ * logging to `/tmp/ssh-tunnel-vm-<n>.log`. The editor forward is the dashboard's
788
+ * reachability signal — the probe to `PORT_BASE+N` connects through it — so it
789
+ * must have a lifetime INDEPENDENT of the preview forward (whose port legitimately
790
+ * changes as the app's dev server is resolved). Splitting the two forwards into
791
+ * separate ssh processes is what makes a preview re-wire non-disruptive: a
792
+ * preview-port change restarts only the preview tunnel and never blips :14199, so
793
+ * the reachability probe can't flip the VM to a false "unreachable" (the
794
+ * VM-1/2/3 flapping observed 2026-06-22). Pairs with `previewTunnelCmd`; the two
795
+ * builders are the single source of truth for the attach (addVm) and re-wire call
796
+ * sites so neither forward's `-L` spec can drift back into the other's process.
797
+ * Pure; no I/O. */
798
+ function editorTunnelCmd({ gcloud, inst, zone, editorPort, n } = {}) {
799
+ 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 &`;
800
+ }
801
+
802
+ /** Build the `nohup … gcloud compute ssh … -N -L …` shell string that opens the
803
+ * LIVE-PREVIEW forward (`previewPort` → VM:<appPort>) as its OWN ssh process,
804
+ * logging to `/tmp/ssh-preview-vm-<n>.log`. The preview forward's target port
805
+ * changes whenever the app's dev-server port is (re)resolved, so it lives apart
806
+ * from the editor reachability forward (`editorTunnelCmd`): a preview re-wire
807
+ * pkills + respawns ONLY this process, leaving the editor :14199 forward — and
808
+ * the reachability probe riding it — untouched. The preview local port is unique
809
+ * per VM and never collides with the editor port, so the re-wire/destroy
810
+ * `pkill -f "<previewPort>:127.0.0.1:"` targets exactly this process. Pure; no
811
+ * I/O. */
812
+ function previewTunnelCmd({ gcloud, inst, zone, previewPort, appPort, n } = {}) {
813
+ 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 &`;
814
+ }
815
+
816
+ /** Build the shell snippet the Add script runs to AWAIT cloud:up's clean exit at
817
+ * container-healthy instead of reaping it mid-flight.
818
+ *
819
+ * History — two bugs, one helper: (1) vm-add-ingress-survives-bes-create: the Add
820
+ * used to `kill $UP_PID` ~15s after the container was Up, SIGTERMing
821
+ * `backend-services create` mid-flight (the 503 strand), so this began polling
822
+ * `cloud:up` to exit on its own rather than reaping it. (2)
823
+ * vm--async-add-time-lb-ingress-wiring: `cloud:up` no longer BLOCKS on the wiring
824
+ * tail — it kicks the EXTERNAL_MANAGED `backend-services create` + propagation off
825
+ * DETACHED (see cloud.js startDetachedAddWiring) and returns at container-healthy,
826
+ * so the detached child can no longer be reaped by anything that kills `cloud:up`.
827
+ * This poll therefore now resolves in ~seconds on the happy path (cloud:up has
828
+ * already exited); it stays only as the bounded backstop that reaps a `cloud:up`
829
+ * genuinely wedged BEFORE the early return (e.g. a stuck container gate). The
830
+ * background wiring converges via the dashboard's auto-rewire gather, so
831
+ * `deadlineSec` shrinks from the old 900s wiring budget to a container-readiness
832
+ * bound. `pidVar` is referenced by name so it expands at run time. Pure; no I/O. */
833
+ function awaitWiringCmd({ inst, deadlineSec = 180, pidVar = "UP_PID" } = {}) {
834
+ return [
835
+ `echo "awaiting cloud:up exit at container-healthy for ${inst} (up to ${deadlineSec}s; LB wiring converges in the background)..."`,
836
+ `__cy_wiring_waited=0`,
837
+ `while kill -0 $${pidVar} 2>/dev/null; do`,
838
+ ` if [ "$__cy_wiring_waited" -ge ${deadlineSec} ]; then`,
839
+ ` 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)"`,
840
+ ` kill $${pidVar} 2>/dev/null; pkill -f "cloud.js up --instance-name ${inst}" 2>/dev/null; break`,
841
+ ` fi`,
842
+ ` sleep 5; __cy_wiring_waited=$((__cy_wiring_waited + 5))`,
843
+ `done`,
844
+ `wait $${pidVar} 2>/dev/null || true`,
845
+ ].join("\n");
846
+ }
847
+
655
848
  /** Get authenticated Git URL for checking client branch. */
656
849
  function getAuthRepoUrl(repo, token) {
657
850
  if (!repo) return "";
@@ -711,10 +904,14 @@ module.exports = {
711
904
  recentLaunchDisplay,
712
905
  defaultConfig,
713
906
  configForVm,
907
+ latestLaunchTs,
908
+ recentlyLaunchedWithin,
714
909
  resolveCardIdentity,
715
910
  stickyClientProject,
911
+ stickyAppPort,
716
912
  classifyProjectKind,
717
913
  resolveCredsSource,
914
+ claudeAuthSeeded,
718
915
  buildCredsSteps,
719
916
  buildClaudeJsonPreserveSteps,
720
917
  buildEnvApplyCommand,
@@ -722,6 +919,10 @@ module.exports = {
722
919
  editorSourceShellResolve,
723
920
  rebuildStrategy,
724
921
  decideStartJob,
922
+ editorTunnelCmd,
923
+ previewTunnelCmd,
924
+ awaitWiringCmd,
725
925
  getAuthRepoUrl,
726
926
  interpretLsRemote,
927
+ isPlaceholderProject,
727
928
  };