@codeyam-editor/codeyam-editor 0.1.0 → 0.1.2

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 (47) hide show
  1. package/npm/cli.js +5 -2
  2. package/npm/cloud-branch-switch.js +442 -0
  3. package/npm/cloud.js +1521 -0
  4. package/npm/container.js +28 -5
  5. package/npm/editor-dev.js +378 -0
  6. package/npm/editor.js +199 -12
  7. package/npm/empty-project-dir.js +33 -0
  8. package/npm/fleet-auth.js +111 -0
  9. package/npm/fleet-cleanup.js +124 -0
  10. package/npm/fleet-env.js +125 -0
  11. package/npm/fleet-memory.js +127 -0
  12. package/npm/fleet-pool-policy.js +78 -0
  13. package/npm/fleet-pool.js +401 -0
  14. package/npm/fleet-queue.js +40 -0
  15. package/npm/fleet-reconcile.js +36 -0
  16. package/npm/fleet-reset.js +144 -0
  17. package/npm/fleet-roster.js +81 -0
  18. package/npm/launch-config.js +544 -0
  19. package/npm/preflight-guidance.js +68 -0
  20. package/npm/scenario-check.js +355 -290
  21. package/npm/scenario-handlers.js +82 -0
  22. package/npm/scenario-interactivity.js +199 -0
  23. package/npm/scenario-issues.js +35 -0
  24. package/npm/scenario-metrics.js +108 -0
  25. package/npm/scenario-mocks.js +107 -0
  26. package/npm/scenario-playwright.js +355 -0
  27. package/npm/utils.js +548 -22
  28. package/npm/vm-urls.js +117 -0
  29. package/package.json +13 -6
  30. package/ui/dist/assets/ContainerInspector-C8EkH4PP.js +2 -0
  31. package/ui/dist/assets/EditorShell-DWaeBxgj.js +186 -0
  32. package/ui/dist/assets/Markdown-BHBqz-Mj.js +1 -0
  33. package/ui/dist/assets/ScenarioDataPanel-DYP7pi_n.css +32 -0
  34. package/ui/dist/assets/ScenarioDataPanel-zJSXtXL4.js +5 -0
  35. package/ui/dist/assets/ScenarioDataPanelFullPage-C0YHQB-T.js +1 -0
  36. package/ui/dist/assets/ScenarioRenderer-CgDxuigm.js +1 -0
  37. package/ui/dist/assets/StepsSimulator-CRtmlyNZ.js +1 -0
  38. package/ui/dist/assets/index-BUYNFqKK.js +27 -0
  39. package/ui/dist/assets/index-DVr5rY2d.css +1 -0
  40. package/ui/dist/assets/markdown-v0F0UEt3.js +37 -0
  41. package/ui/dist/assets/react-CSS0HapR.js +28 -0
  42. package/ui/dist/assets/useEvents-DggWrtHe.js +1 -0
  43. package/ui/dist/assets/xterm--24IGk-x.js +9 -0
  44. package/ui/dist/favicon.ico +0 -0
  45. package/ui/dist/favicon.png +0 -0
  46. package/ui/dist/index.html +16 -0
  47. package/npm/postinstall.js +0 -95
@@ -0,0 +1,81 @@
1
+ 'use strict';
2
+
3
+ // npm/fleet-roster.js — the fleet roster (`.codeyam/logs/fleet-vms.json`) is a
4
+ // shared contract: the dashboard, the CLI provisioner (`cloud:up`), the
5
+ // `cloud:branch-switch` orchestrator, the vm-watchdog, and the bash tunnel
6
+ // publishers (`vm-urls.sh` / `cloudflared-monitor.sh`) all read it to learn
7
+ // which VMs exist. Before improve38 the *writer* lived only inside the dashboard
8
+ // process (`server.js::saveVmsState`), so a VM provisioned by the CLI never
9
+ // appeared until someone hand-edited the file. This module is the single
10
+ // read+write seam every Node consumer shares, so the writer is no longer locked
11
+ // inside the dashboard.
12
+ //
13
+ // File format (unchanged): a JSON array of VM-number integers. Readers tolerate
14
+ // a missing or malformed file by treating it as the empty roster.
15
+ //
16
+ // Env override: `VMS_STATE_FILE` relocates the file (honored by server.js and
17
+ // cloud-branch-switch.js already); resolved once at module load.
18
+
19
+ const fs = require('fs');
20
+ const path = require('path');
21
+
22
+ const { rootDir } = require('./utils');
23
+
24
+ const VMS_STATE_FILE = process.env.VMS_STATE_FILE
25
+ || path.join(rootDir, '.codeyam', 'logs', 'fleet-vms.json');
26
+
27
+ // Canonical roster shape: a sorted, de-duped set of integers. Every read and
28
+ // write funnels through here so on-disk content stays normalized regardless of
29
+ // which consumer last wrote it (or hand-edited it).
30
+ function normalizeRoster(arr) {
31
+ return [...new Set((arr || []).filter(Number.isInteger))].sort((a, b) => a - b);
32
+ }
33
+
34
+ // Read the roster, returning a normalized integer array. A missing or malformed
35
+ // file is treated as the empty roster `[]` (the documented contract the previous
36
+ // `readRoster`/`loadVmsState` duplicates shared) so a truncated write or an
37
+ // absent file never throws into a consumer's poll loop.
38
+ function readRoster() {
39
+ try {
40
+ const data = JSON.parse(fs.readFileSync(VMS_STATE_FILE, 'utf8'));
41
+ if (Array.isArray(data)) return normalizeRoster(data);
42
+ } catch { /* missing or malformed — treated as empty */ }
43
+ return [];
44
+ }
45
+
46
+ // Persist `arr` as the roster, atomically: write a temp file then `rename` it
47
+ // over the target so a concurrent reader never observes a half-written file.
48
+ // The temp name carries the pid so two *processes* writing at once (CLI +
49
+ // dashboard) don't clobber each other's temp file before their renames land.
50
+ // Returns the normalized roster that was written.
51
+ function writeRoster(arr) {
52
+ const next = normalizeRoster(arr);
53
+ fs.mkdirSync(path.dirname(VMS_STATE_FILE), { recursive: true });
54
+ const tmp = `${VMS_STATE_FILE}.${process.pid}.tmp`;
55
+ fs.writeFileSync(tmp, JSON.stringify(next));
56
+ fs.renameSync(tmp, VMS_STATE_FILE); // atomic replace
57
+ return next;
58
+ }
59
+
60
+ // Append `n` to the roster if absent (idempotent), returning the new roster.
61
+ // A double-roster — e.g. the CLI rosters on `cloud:up` and the dashboard's Add
62
+ // onSuccess rosters again — is a harmless no-op, which is exactly what closes
63
+ // the race where one writer's roster write could fail.
64
+ function addToRoster(n) {
65
+ return writeRoster([...readRoster(), n]);
66
+ }
67
+
68
+ // Remove `n` from the roster, returning the new roster. Removing an absent
69
+ // entry is a no-op.
70
+ function removeFromRoster(n) {
71
+ return writeRoster(readRoster().filter((x) => x !== n));
72
+ }
73
+
74
+ module.exports = {
75
+ VMS_STATE_FILE,
76
+ normalizeRoster,
77
+ readRoster,
78
+ writeRoster,
79
+ addToRoster,
80
+ removeFromRoster,
81
+ };
@@ -0,0 +1,544 @@
1
+ "use strict";
2
+ // Pure helpers for the fleet-dashboard's parametrized launch + cached history.
3
+ //
4
+ // Dependency-free and unit-tested (npm/launch-config.test.js) so the dashboard
5
+ // server (scripts/operator/fleet-dashboard/server.js) can require them without
6
+ // pulling logic into its poll/HTTP path. A "launch config" is the operator's
7
+ // choice of WHICH client project (repo/branch) + WHICH editor (variant/branch)
8
+ // runs on a VM; the history is the cache that makes the last choice the default
9
+ // and past launches one click to re-run.
10
+
11
+ const { CANONICAL_EDITOR_REPO, EDITOR_VARIANTS, repoBasename } = require("./cloud");
12
+
13
+ const EMPTY_CONFIG = Object.freeze({
14
+ clientRepo: "",
15
+ clientBranch: "",
16
+ // Greenfield: when set (and clientRepo empty), launch against a NEW empty
17
+ // folder of this name instead of cloning a repo.
18
+ newProjectName: "",
19
+ editorVariant: "dev",
20
+ editorBranch: "",
21
+ });
22
+
23
+ /** Coerce a raw form/query object into the canonical launch-config shape: trim
24
+ * strings, default the variant to a known value. Never throws — validation is
25
+ * a separate, explicit step. */
26
+ function normalizeLaunchConfig(input = {}) {
27
+ const str = (v) => (typeof v === "string" ? v.trim() : "");
28
+ const editorVariant = EDITOR_VARIANTS.includes(input.editorVariant) ? input.editorVariant : "dev";
29
+ return {
30
+ clientRepo: str(input.clientRepo),
31
+ clientBranch: str(input.clientBranch),
32
+ newProjectName: str(input.newProjectName),
33
+ editorVariant,
34
+ editorBranch: str(input.editorBranch),
35
+ };
36
+ }
37
+
38
+ /** Validate a normalized config. Returns { ok, msg } so the HTTP layer can
39
+ * surface the reason without a try/catch. */
40
+ function validateLaunchConfig(cfg) {
41
+ const hasRepo = !!(cfg && cfg.clientRepo);
42
+ const hasNew = !!(cfg && cfg.newProjectName);
43
+ if (hasRepo && hasNew) {
44
+ return { ok: false, msg: "specify either a client repo or a new-project name, not both" };
45
+ }
46
+ if (!hasRepo && !hasNew) {
47
+ return { ok: false, msg: "a client repo or a new-project name is required" };
48
+ }
49
+ if (hasRepo && !/^(https?:\/\/|git@)/.test(cfg.clientRepo)) {
50
+ return { ok: false, msg: "client repo must be an https:// or git@ URL" };
51
+ }
52
+ if (hasNew && !/^[A-Za-z0-9._-]+$/.test(cfg.newProjectName)) {
53
+ return { ok: false, msg: "new-project name may contain only letters, numbers, dot, dash, underscore" };
54
+ }
55
+ if (!EDITOR_VARIANTS.includes(cfg.editorVariant)) {
56
+ return { ok: false, msg: `unknown editor variant: ${cfg.editorVariant}` };
57
+ }
58
+ return { ok: true };
59
+ }
60
+
61
+ /** Phase-1 gate mirror of cloud.js::assertVariantProvisionable — only `dev`
62
+ * provisions today; staging/production are Phase 2. */
63
+ function isVariantProvisionable(variant) {
64
+ return variant === "dev";
65
+ }
66
+
67
+ /** Build the `cloud:up` flag array for a launch config + instance name. The
68
+ * editor source is the canonical codeyam-editor repo (dev variant builds the
69
+ * image from editorBranch); the client repo/branch is what lands in
70
+ * /workspace. Decoupled exactly as cloud.js consumes them. */
71
+ function cloudUpArgs(cfg, instanceName, agent = "claude") {
72
+ const args = ["--instance-name", instanceName];
73
+ // Greenfield (new folder) vs clone an existing repo.
74
+ if (cfg.newProjectName) args.push("--new-project", cfg.newProjectName);
75
+ else args.push("--repo", cfg.clientRepo);
76
+ args.push("--agent", agent, "--editor-repo", CANONICAL_EDITOR_REPO, "--editor-variant", cfg.editorVariant);
77
+ if (!cfg.newProjectName && cfg.clientBranch) args.push("--branch", cfg.clientBranch);
78
+ if (cfg.editorBranch) args.push("--editor-branch", cfg.editorBranch);
79
+ // The dashboard opens its own per-VM tunnel, so cloud:up must NOT open one:
80
+ // it lets cloud:up exit cleanly and avoids parallel batches colliding on the
81
+ // fixed local ports 14199/5173 (the cause of multi-add "false failures").
82
+ args.push("--no-tunnel");
83
+ return args;
84
+ }
85
+
86
+ /** Classify one container-up wait tick into a terminal/continue decision from
87
+ * what's been observed so far. This is the race-semantics seam fragility #33
88
+ * got wrong (`scripts/operator/fleet-dashboard/server.js::addVm`): the wait
89
+ * must poll container status on its own cadence and treat ANY single `Up` as
90
+ * terminal success.
91
+ *
92
+ * Rules:
93
+ * - everSeenUp ⇒ "up" any single `Up` is terminal —
94
+ * transient probe blips before it
95
+ * are irrelevant.
96
+ * - instanceGone ⇒ "failed" fast-bail: the VM is gone, no
97
+ * point waiting out the deadline.
98
+ * - deadlineSec>0 & elapsed>=it ⇒ "failed" genuine timeout, no `Up` ever seen
99
+ * (a non-positive deadline means no
100
+ * deadline set yet, so never fail).
101
+ * - otherwise ⇒ "waiting"
102
+ *
103
+ * Deliberately NOT an input: whether `cloud:up` is still alive (its `UP_PID`).
104
+ * Under `--no-tunnel` cloud:up exits cleanly the instant the container is up
105
+ * (see cloudUpArgs), so its death is the NORMAL success path and must never
106
+ * decide failure. The bash loop mirrors this — `kill -0 $UP_PID` is logged as
107
+ * informational only. Callers may pass `upPidAlive` for readable test intent;
108
+ * it is ignored by design. */
109
+ function classifyContainerWait({ everSeenUp = false, instanceGone = false, elapsedSec = 0, deadlineSec = 0 } = {}) {
110
+ if (everSeenUp) return "up";
111
+ if (instanceGone) return "failed";
112
+ if (deadlineSec > 0 && elapsedSec >= deadlineSec) return "failed";
113
+ return "waiting";
114
+ }
115
+
116
+ /** Decide whether a newly-reserved Add slot should start immediately or be
117
+ * queued, given the current in-flight count + the configured cap. Pure: pulls
118
+ * no globals, has no side effects.
119
+ *
120
+ * Returns "start" when there is headroom under maxConcurrency; "queue"
121
+ * otherwise. A non-positive or non-numeric maxConcurrency falls back to
122
+ * unbounded ("start" always) — the historical behavior before throttling
123
+ * existed, so a misconfigured env var degrades to "no throttle" instead of
124
+ * silently freezing all adds.
125
+ *
126
+ * The caller is responsible for the side effects: pushing to a pending
127
+ * queue + posting a `queued` job entry on "queue", or starting addVm on
128
+ * "start". Decoupling the decision from the I/O makes it unit-testable
129
+ * against arbitrary (cap, active) tuples — the test signal the OOM bug
130
+ * needed and the queue never had. */
131
+ function decideAddSlot({ maxConcurrency, activeCount = 0 } = {}) {
132
+ const cap = Number(maxConcurrency);
133
+ if (!Number.isFinite(cap) || cap <= 0) return "start";
134
+ return Number(activeCount) < cap ? "start" : "queue";
135
+ }
136
+
137
+ /** Closed set of "heavy" action names — those that invoke `npm run cloud:up`
138
+ * (over gcloud-ssh) and so contend for the shared spawn budget that prevents
139
+ * the dashboard's launchd-managed node process from OOMing under burst. The
140
+ * set is the single source of truth shared between server.js's throttle gate
141
+ * (tryStartOrQueue) and the per-action queued-status mapping below; adding a
142
+ * new heavy action means editing both this set and queuedStatusForAction.
143
+ * Other actions (set-env, run-setup, reset-*, update-*, destroy) are a single
144
+ * ssh round-trip with no long-running child and bypass the throttle entirely
145
+ * — they can never OOM the dashboard on burst. */
146
+ const HEAVY_ACTIONS = Object.freeze(["add", "rebuild", "reconfigure", "reset-all", "fleet-ready"]);
147
+
148
+ /** Map a heavy action to its per-action "queued" UI status string. Used by
149
+ * server.js's deriveStatus to render the throttled-but-not-yet-started card
150
+ * with a status that matches the eventual running status family
151
+ * (add → add-queued / adding, rebuild → rebuild-queued / rebuilding,
152
+ * reconfigure → reconfigure-queued / reconfiguring). Returns null for any
153
+ * action not in HEAVY_ACTIONS (so the caller can fall through to the
154
+ * generic running-state branch). Pure; no I/O. */
155
+ function queuedStatusForAction(action) {
156
+ switch (action) {
157
+ case "add": return "add-queued";
158
+ case "rebuild": return "rebuild-queued";
159
+ case "reconfigure": return "reconfigure-queued";
160
+ case "reset-all": return "reset-all-queued";
161
+ // fleet-ready runs cloud:up/redeploy children, and deriveStatus already
162
+ // renders a *running* fleet-ready job as "rebuilding"; its queued card
163
+ // mirrors that same family rather than introducing an unstyled
164
+ // fleet-ready-queued status the dashboard CSS has no class for.
165
+ case "fleet-ready": return "rebuild-queued";
166
+ default: return null;
167
+ }
168
+ }
169
+
170
+ /** Push a launch entry onto the newest-first history, capped to `cap`. No
171
+ * cross-VM dedup at write time — the full log is what powers per-VM config
172
+ * lookups (configForVm); the "recent launches" picker de-dups at read time
173
+ * (distinctConfigs). Returns a new array; does not mutate the input. */
174
+ function pushHistory(history, entry, cap = 30) {
175
+ const arr = Array.isArray(history) ? history.slice() : [];
176
+ arr.unshift(entry);
177
+ return arr.slice(0, Math.max(1, cap));
178
+ }
179
+
180
+ /** Distinct configs, newest-first, for the "recent launches" picker (so the
181
+ * same config launched on several VMs shows once — the most-recent
182
+ * occurrence). */
183
+ function distinctConfigs(history, limit = 12) {
184
+ const seen = new Set();
185
+ const out = [];
186
+ for (const e of Array.isArray(history) ? history : []) {
187
+ const k = JSON.stringify(e && e.config);
188
+ if (seen.has(k)) continue;
189
+ seen.add(k);
190
+ out.push(e);
191
+ if (out.length >= limit) break;
192
+ }
193
+ return out;
194
+ }
195
+
196
+ /** The default config to pre-fill the launch form: the most recent launch's
197
+ * config, or an empty config when there's no history. */
198
+ function defaultConfig(history) {
199
+ const h = Array.isArray(history) && history.length ? history[0] : null;
200
+ return h && h.config ? { ...EMPTY_CONFIG, ...h.config } : { ...EMPTY_CONFIG };
201
+ }
202
+
203
+ /** The most recent config used on a specific VM number (for the per-card
204
+ * Reconfigure form + the card's project/variant display). null when unknown
205
+ * (e.g. a VM launched before this feature existed). */
206
+ function configForVm(history, n) {
207
+ const h = (Array.isArray(history) ? history : []).find((e) => e && e.vm === n);
208
+ return h && h.config ? { ...EMPTY_CONFIG, ...h.config } : null;
209
+ }
210
+
211
+ /** Resolve the card's display identity, preferring LIVE truth (the editor's
212
+ * `/api/session-info` `clientProject` + `editorIdentity`) over STALE launch
213
+ * history. This is the fix for fragility #33's lying label: a CLI branch
214
+ * switch (`git checkout -B … && rebuild-self`) never updates launch history,
215
+ * so a history-sourced label keeps showing the old branch — but the editor's
216
+ * live self-report tracks the actual branch.
217
+ *
218
+ * `live` is the polled VM object ({ clientProject?, editorIdentity? }); an
219
+ * older VM whose editor predates this feature reports neither, so the result
220
+ * falls back to `historyCfg` (a launch-config shape) field-by-field and
221
+ * `source` flags the card as showing inferred-not-live data.
222
+ *
223
+ * Returns { project, clientBranch, editorLabel, source }:
224
+ * - project bare project name (no @branch) — live name wins, else the
225
+ * history repo basename / new-project name.
226
+ * - clientBranch live client branch wins, else historyCfg.clientBranch; null
227
+ * when neither (e.g. a non-git client project).
228
+ * - editorLabel from editorIdentity — the branch when kind === "branch",
229
+ * "npm v<version>" when kind === "npm"; else the history
230
+ * variant(@branch) fallback.
231
+ * - source "live" when editorIdentity drove editorLabel, else
232
+ * "history" — the affordance the card uses to mark inferred
233
+ * data. Pure; no I/O. */
234
+ function resolveCardIdentity(live, historyCfg) {
235
+ const cfg = historyCfg || {};
236
+ const lcp = live && live.clientProject;
237
+ const lei = live && live.editorIdentity;
238
+
239
+ const historyName = cfg.clientRepo ? repoBasename(cfg.clientRepo) : (cfg.newProjectName || "");
240
+ const project = (lcp && lcp.name) || historyName || null;
241
+
242
+ const clientBranch = (lcp && lcp.branch) || cfg.clientBranch || null;
243
+
244
+ let editorLabel;
245
+ let source;
246
+ if (lei && lei.kind === "branch" && lei.branch) {
247
+ editorLabel = lei.branch;
248
+ source = "live";
249
+ } else if (lei && lei.kind === "npm") {
250
+ editorLabel = "npm v" + (lei.version || "?");
251
+ source = "live";
252
+ } else {
253
+ const variant = cfg.editorVariant || "dev";
254
+ editorLabel = variant + (cfg.editorBranch ? "@" + cfg.editorBranch : "");
255
+ source = "history";
256
+ }
257
+
258
+ return { project, clientBranch, editorLabel, source };
259
+ }
260
+
261
+ /** Classify a VM's project KIND as `"self-host"` vs `"guest"` from the live
262
+ * identity reported in `/api/session-info` (`clientProject.repo`), with
263
+ * launch-history `clientRepo` as a fallback when live is absent. A guest is
264
+ * any VM whose repo basename differs from `CANONICAL_EDITOR_REPO`'s
265
+ * (`codeyam-editor`) — Margo today, future guests tomorrow.
266
+ *
267
+ * The dashboard surfaces the result with a small `🛡 guest` chip so the
268
+ * "leave-the-margo-one-alone"-style caveats become a visible guardrail at
269
+ * glance instead of a memo the operator has to remember. The kind is a
270
+ * *type*, not an alert — the status badge remains the primary signal.
271
+ *
272
+ * Defaults: both signals missing → `"self-host"` (the dominant fleet shape;
273
+ * a falsely visible "guest" badge on a real self-host VM would be more
274
+ * confusing than a missing one). Empty-string repos collapse to the same
275
+ * unknown-→-self-host default. Reuses `CANONICAL_EDITOR_REPO` so the
276
+ * self-host identity stays a single source of truth shared with
277
+ * `cloudUpArgs`. Pure; no I/O. */
278
+ function classifyProjectKind(live, historyCfg) {
279
+ const cfg = historyCfg || {};
280
+ const lcp = live && live.clientProject;
281
+ const liveRepo = lcp && typeof lcp.repo === "string" ? lcp.repo : "";
282
+ const repoUrl = liveRepo || (typeof cfg.clientRepo === "string" ? cfg.clientRepo : "");
283
+ if (!repoUrl) return "self-host";
284
+ const canonical = repoBasename(CANONICAL_EDITOR_REPO);
285
+ return repoBasename(repoUrl) === canonical ? "self-host" : "guest";
286
+ }
287
+
288
+ /** Resolve where a VM's codeyam-editor SOURCE lives, given its shape. This is
289
+ * the load-bearing per-VM-shape branch for the dashboard's "update editor
290
+ * source" + "rebuild binary" actions: the editor source — and how you rebuild
291
+ * from it — differs between a self-host VM (the client project IS
292
+ * codeyam-editor) and a non-self-host one (editing some other project).
293
+ *
294
+ * - Self-host: the editor source IS the agent's workspace, so it's `/workspace`
295
+ * INSIDE the container; the rebuild runs there in place (`rebuild-self`).
296
+ * - Non-self-host: the editor binary comes from the `codeyam-editor:local`
297
+ * image, built on the VM HOST. `scripts/gcp-bootstrap.sh` clones the editor
298
+ * repo to `$HOME/codeyam-editor` and builds the image from there, so that is
299
+ * the authoritative image source. A self-host VM ALSO carries a creds-bearing
300
+ * workspace clone at `$HOME/projects/codeyam-editor` (docs/cloud-editor-stability.md
301
+ * "VM-side paths"); the image-rebuild flow prefers it WHEN PRESENT (it can
302
+ * `git pull` private repos), else falls back to the canonical image clone.
303
+ * The caller checks `path` then `fallbackPath` on disk at runtime.
304
+ *
305
+ * Stack assumption: this encodes the cloud-VM editor-source layout (Docker
306
+ * image + ~/projects mount). A non-Docker editor host would need its own
307
+ * resolver branch. Pure; no I/O. */
308
+ function resolveEditorSource({ selfHost = false, home = "$HOME" } = {}) {
309
+ if (selfHost) {
310
+ return { selfHost: true, location: "container", path: "/workspace", fallbackPath: null };
311
+ }
312
+ return {
313
+ selfHost: false,
314
+ location: "host",
315
+ path: `${home}/projects/codeyam-editor`,
316
+ fallbackPath: `${home}/codeyam-editor`,
317
+ };
318
+ }
319
+
320
+ /** Emit the shell snippet that resolves the host editor-source clone ON THE
321
+ * REMOTE VM, assigning the chosen path to `$ESRC`. Prefer the credentialed
322
+ * projects clone (`src.path`); fall back to the image clone (`src.fallbackPath`)
323
+ * only when the primary has no `.git`.
324
+ *
325
+ * CRITICAL — the paths must EXPAND on the VM, not on the laptop. `resolveEditorSource`
326
+ * returns `$HOME`-relative CONSTANTS (the dashboard doesn't know the VM's home dir),
327
+ * so they go into DOUBLE quotes here, never `shq()`/single-quotes. Single-quoting a
328
+ * `$HOME`-bearing path defeats remote expansion: `cd "$ESRC"` then fails with
329
+ * `cd: $HOME/...: No such file or directory` on every non-self-host VM (the improve34
330
+ * bug). These paths are fixed constants, not user input — no injection surface, so
331
+ * `shq()` (which exists to neutralize user input) is the wrong tool. Pure; no I/O. */
332
+ function editorSourceShellResolve(src) {
333
+ const primary = `ESRC="${src.path}"`;
334
+ if (!src.fallbackPath) return primary;
335
+ return `${primary}; [ -d "$ESRC/.git" ] || ESRC="${src.fallbackPath}"`;
336
+ }
337
+
338
+ /** Choose HOW to rebuild the editor binary on a VM, from its shape + the editor
339
+ * variant. The three mechanisms map 1:1 to the EDITOR_VARIANTS contract:
340
+ * - "rebuild-self" — self-host `dev`: compile in place inside the container
341
+ * (`rebuild-self --force-in-container`); /workspace is the
342
+ * editor source.
343
+ * - "image-rebuild" — non-self-host `dev`: the binary ships in the
344
+ * `codeyam-editor:local` image, so it needs a host-side
345
+ * `docker build` + container recreate.
346
+ * - "npm-reinstall" — a packaged variant (`staging`/`production`, Phase 2):
347
+ * the binary is the published editor npm package, so a
348
+ * rebuild is a reinstall, not a compile.
349
+ * Pure; no I/O. The dashboard reuses `isVariantProvisionable` to gate the
350
+ * not-yet-wired npm-reinstall path; the self-host axis is detected at runtime
351
+ * on the VM (the `[ -f /workspace/Cargo.toml ]` signal is authoritative even
352
+ * for VMs the dashboard has no launch-history record for). */
353
+ function rebuildStrategy({ selfHost = false, editorVariant = "dev" } = {}) {
354
+ if (editorVariant !== "dev") return "npm-reinstall";
355
+ return selfHost ? "rebuild-self" : "image-rebuild";
356
+ }
357
+
358
+ /** Decide whether a new per-VM job may start, given the VM's currently-tracked
359
+ * job. `state.jobs[n]` is a SINGLE slot per VM, so a second `startJob` would
360
+ * OVERWRITE an in-flight job's tracker — the bug where `reapplyVmEnv`'s
361
+ * follow-on `set-env` clobbered a still-running Rebuild's card, so the operator
362
+ * saw a `set-env` completion instead of the rebuild they asked for, with no
363
+ * error surfaced. Any job already `running` blocks a new one:
364
+ * - `idle` — no in-flight job (or the slot's job already finished);
365
+ * the caller starts normally.
366
+ * - `same-action` — a job with the SAME action is already running; reject
367
+ * (the pre-existing duplicate-click guard).
368
+ * - `different-action`— a DIFFERENT action is running; reject rather than
369
+ * clobber its tracker. Operators sequence the two jobs
370
+ * manually (the rare legitimate case).
371
+ * Returns `{ decision: 'start' }` or
372
+ * `{ decision: 'reject', reason, runningAction }` so the caller builds the
373
+ * user-facing message (the two reject reasons word it differently). Pure; no
374
+ * I/O — `existing` is the job record, `requested` the action about to start. */
375
+ function decideStartJob({ existing, requested } = {}) {
376
+ if (!existing || existing.state !== "running") return { decision: "start" };
377
+ const runningAction = existing.action;
378
+ if (runningAction === requested) {
379
+ return { decision: "reject", reason: "same-action", runningAction };
380
+ }
381
+ return { decision: "reject", reason: "different-action", runningAction };
382
+ }
383
+
384
+ /** Decide where a freshly-added VM's credentials come from. The dashboard's
385
+ * default is a container→container copy from a live donor VM, but a full
386
+ * teardown empties the roster and leaves no donor — so Add must fall back to
387
+ * operator-configured sources. Precedence, highest first:
388
+ * - "donor" — a reachable donor exists; copy claude + git from it (the
389
+ * unchanged default). `donor` = the chosen VM number.
390
+ * - "host-stash" — no donor, but CLAUDE_CREDS_SRC points at a known-good
391
+ * .credentials.json to push into the new container.
392
+ * - "token" — no donor and no stash, but GITHUB_TOKEN is set (cloud:up
393
+ * seeds git creds at boot). claude auth is NOT seeded.
394
+ * - "none" — nothing available; wire + roster only, claude + git both
395
+ * unseeded.
396
+ * Modes "token" and "none" carry no claude auth, so the caller marks the VM
397
+ * add-degraded with a `claude /login` remediation. `reachableDonors` must be
398
+ * pre-filtered to VMs that actually answered (a destroyed-but-rostered orphan
399
+ * is not a donor — fragility #24). Pure; no I/O. */
400
+ function resolveCredsSource({ reachableDonors = [], claudeCredsSrc = "", githubToken = "" } = {}) {
401
+ const donor = Array.isArray(reachableDonors) && reachableDonors.length ? reachableDonors[0] : null;
402
+ if (donor != null) return { mode: "donor", donor };
403
+ if (claudeCredsSrc) return { mode: "host-stash", donor: null };
404
+ if (githubToken) return { mode: "token", donor: null };
405
+ return { mode: "none", donor: null };
406
+ }
407
+
408
+ // Single-quote a string for safe inclusion in a remote shell command. Mirrors
409
+ // the server.js helper so callers don't have to thread one through.
410
+ function _shq(s) { return `'${String(s).replace(/'/g, `'\\''`)}'`; }
411
+
412
+ /** Build the shell steps that seed a freshly-added VM's claude + git credentials,
413
+ * for the resolved `mode` (see `resolveCredsSource`). Returns an array of shell
414
+ * strings the caller newline-joins into the larger provisioning script — each
415
+ * step is independent; a `CY_DEGRADED <message>` from one does not block the
416
+ * next, and the dashboard's job-attribution path lifts those markers onto the
417
+ * card as `degradedReasons`.
418
+ *
419
+ * Donor mode (the default) copies, container→container from the donor VM:
420
+ * 1. host-file git credentials → `$HOME/.codeyam-cloud-git-credentials`
421
+ * 2. claude `.credentials.json` → `/root/.claude/.credentials.json`
422
+ * 3. claude `.claude.json` → `/root/.claude.json`
423
+ * Step 3 (onboarding state — `hasCompletedOnboarding` + sibling keys) is what
424
+ * keeps the first interactive `claude` TUI from popping the welcome "Select
425
+ * login method" menu; without it the non-interactive `claude --print` probe
426
+ * still passes (so `/api/config` reports `authenticated`), but the Build tab
427
+ * PTY stalls at the picker. Failure of any single step emits its own
428
+ * `CY_DEGRADED` marker — the credentials copy is the load-bearing one; the
429
+ * onboarding-state copy is recoverable via `claude /login`.
430
+ *
431
+ * host-stash / token / none modes don't have a donor `.claude.json` to copy —
432
+ * for now those paths still land degraded for the same reason; a separate plan
433
+ * can extend host-stash with a sibling onboarding-stash env knob.
434
+ *
435
+ * Pure; no I/O. */
436
+ function buildCredsSteps({ mode, refInst = "", inst, container, gcloud, zone, n, claudeCredsSrc = "" } = {}) {
437
+ const claudeSink = `docker exec -i ${container} bash -c "umask 077; base64 -d > /root/.claude/.credentials.json && chmod 600 /root/.claude/.credentials.json"`;
438
+ const claudeJsonSink = `docker exec -i ${container} bash -c "umask 077; base64 -d > /root/.claude.json && chmod 600 /root/.claude.json"`;
439
+ const loginHint = `run: docker exec -it ${container} claude /login on ${inst}`;
440
+ if (mode === "donor") {
441
+ return [
442
+ `echo "copying git creds from ${refInst}..."`,
443
+ `${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)"`,
444
+ `echo "copying claude auth from ${refInst} (container->container)..."`,
445
+ `${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}"`,
446
+ `echo "copying claude.json (onboarding state) from ${refInst} (container->container)..."`,
447
+ `${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"`,
448
+ ];
449
+ }
450
+ if (mode === "host-stash") {
451
+ return [
452
+ `echo "no reachable donor — seeding claude auth from host stash..."`,
453
+ `base64 < ${_shq(claudeCredsSrc)} | ${gcloud} compute ssh ${inst} --tunnel-through-iap --zone=${zone} --command=${_shq(claudeSink)} 2>/dev/null && echo "claude auth seeded from host stash" || echo "WARN claude stash seed failed — ${loginHint}"`,
454
+ ];
455
+ }
456
+ if (mode === "token") {
457
+ return [
458
+ `echo "no reachable donor and no CLAUDE_CREDS_SRC — git creds seeded by cloud:up; claude auth NOT seeded. ${loginHint}"`,
459
+ ];
460
+ }
461
+ return [
462
+ `echo "no reachable donor and no configured creds source — wiring + rostering only; claude AND git auth NOT seeded. ${loginHint}, and set CLAUDE_CREDS_SRC/GITHUB_TOKEN in the dashboard env"`,
463
+ ];
464
+ }
465
+
466
+ /** Build the two host-side shell snippets that PRESERVE the container's
467
+ * /root/.claude.json (claude onboarding state) across a non-self-host Rebuild's
468
+ * container recreate. The whole /root/.claude DIRECTORY is bind-mounted from the
469
+ * host ($HOME/.codeyam-cloud-claude → /root/.claude, see docker-compose.cloud.yml),
470
+ * so /root/.claude/.credentials.json survives `docker compose down && up`. But
471
+ * /root/.claude.json is a SIBLING in the container's EPHEMERAL fs — NOT in the
472
+ * bind mount — so a recreate resets it to the image baseline, dropping the
473
+ * operator's `hasCompletedOnboarding`. The first interactive `claude` TUI then
474
+ * pops the "Select login method" welcome menu and the Build tab is blocked,
475
+ * even though the non-interactive `claude --print` auth probe still passes
476
+ * (improve36). The good copy is still in the LIVE container right up until
477
+ * `docker compose down`, so we capture it to a host temp file, then restore it
478
+ * into the freshly-recreated container — no donor required.
479
+ *
480
+ * Returns { capture, restore }, each an array of shell lines for the on-host
481
+ * rebuild script (run via gcloud-ssh, direct `docker exec` — same execution
482
+ * context as gitReseedHostStep). `capture` runs BEFORE `docker compose down`;
483
+ * `restore` runs AFTER `up`, BEFORE the editor verify probe. The lines share a
484
+ * `bakVar` shell variable holding the temp-file path, so they MUST be spread
485
+ * into the SAME bash invocation. A failed restore emits a `CY_DEGRADED` marker
486
+ * (the dashboard lifts it onto the card as a degradedReason) with a
487
+ * `claude /login` remediation — the rebuild itself still succeeded, so the job
488
+ * lands `done`, not `error`. Pure; no I/O. */
489
+ function buildClaudeJsonPreserveSteps({ container, n = "", bakVar = "CY_CLAUDE_JSON_BAK" } = {}) {
490
+ const loginHint = `run: docker exec -it ${container} claude /login`;
491
+ const capture = [
492
+ `${bakVar}=$(mktemp)`,
493
+ `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`,
494
+ ];
495
+ const restore = [
496
+ `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`,
497
+ `rm -f "$${bakVar}"`,
498
+ ];
499
+ return { capture, restore };
500
+ }
501
+
502
+ /** Build the shell command that base64-pipes a VM's persisted env over ssh +
503
+ * docker-exec stdin into the container's /workspace/.env (umask 077, chmod
504
+ * 600), chowns it to the workspace owner so the bind-mounted host file is
505
+ * readable by `docker compose --force-recreate` (Rebuild / Reconfigure /
506
+ * Add — host-side, as the unprivileged SSH user, not root), and restarts
507
+ * the dev server. The docker exec runs as root inside the container, so
508
+ * without the chown the file lands `root:root 0600` on the host bind-mount
509
+ * and the next compose recreate fails `open .env: permission denied`
510
+ * (fragility — improve36). `chown --reference=/workspace` inherits whatever
511
+ * uid the image's entrypoint assigned to the workspace (currently 1003 =
512
+ * the host SSH user), so the fix is robust against future UID changes.
513
+ * Values stay out of argv/logs: they ride as base64 over the ssh + docker
514
+ * exec stdin, never as CLI arguments. Pure; no I/O. */
515
+ function buildEnvApplyCommand({ gcloud, instance, zone, envFile, container, editorBin } = {}) {
516
+ const sink = `docker exec -i ${container} bash -lc "umask 077; base64 -d > /workspace/.env && chmod 600 /workspace/.env && chown --reference=/workspace /workspace/.env && cd /workspace && ${editorBin} editor restart-dev-server"`;
517
+ return `base64 < ${_shq(envFile)} | ${gcloud} compute ssh ${instance} --tunnel-through-iap --zone=${zone} --command=${_shq(sink)}`;
518
+ }
519
+
520
+ module.exports = {
521
+ EMPTY_CONFIG,
522
+ normalizeLaunchConfig,
523
+ validateLaunchConfig,
524
+ isVariantProvisionable,
525
+ cloudUpArgs,
526
+ classifyContainerWait,
527
+ decideAddSlot,
528
+ HEAVY_ACTIONS,
529
+ queuedStatusForAction,
530
+ pushHistory,
531
+ distinctConfigs,
532
+ defaultConfig,
533
+ configForVm,
534
+ resolveCardIdentity,
535
+ classifyProjectKind,
536
+ resolveCredsSource,
537
+ buildCredsSteps,
538
+ buildClaudeJsonPreserveSteps,
539
+ buildEnvApplyCommand,
540
+ resolveEditorSource,
541
+ editorSourceShellResolve,
542
+ rebuildStrategy,
543
+ decideStartJob,
544
+ };