@codeyam-editor/codeyam-editor 0.1.3 → 0.1.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (66) hide show
  1. package/npm/cloud-branch-switch.js +1 -1
  2. package/npm/cloud.js +1693 -96
  3. package/npm/container.js +14 -0
  4. package/npm/disk-pressure.js +167 -0
  5. package/npm/editor-dev.js +107 -15
  6. package/npm/editor.js +97 -37
  7. package/npm/fleet-auth-health.js +75 -0
  8. package/npm/fleet-auth.js +6 -1
  9. package/npm/fleet-build-data.js +130 -0
  10. package/npm/fleet-cleanup.js +145 -7
  11. package/npm/fleet-destroy-safety.js +55 -0
  12. package/npm/fleet-effect-status.js +33 -32
  13. package/npm/fleet-host.js +74 -0
  14. package/npm/fleet-job-badge.js +46 -2
  15. package/npm/fleet-launchd.js +171 -0
  16. package/npm/fleet-live-status.js +123 -0
  17. package/npm/fleet-plans.js +231 -0
  18. package/npm/fleet-prewarm.js +165 -0
  19. package/npm/fleet-public-url.js +160 -0
  20. package/npm/fleet-queue.js +38 -1
  21. package/npm/fleet-reconcile.js +100 -1
  22. package/npm/fleet-reset.js +298 -13
  23. package/npm/fleet-roster.js +6 -8
  24. package/npm/fleet-self-update.js +142 -0
  25. package/npm/fleet-setup-steps.js +105 -0
  26. package/npm/fleet-sleep-status.js +33 -0
  27. package/npm/fleet-strand.js +150 -0
  28. package/npm/fleet-stuck-status.js +111 -0
  29. package/npm/fleet-vm-access.js +234 -0
  30. package/npm/fleet-vm-readiness.js +104 -0
  31. package/npm/fleet-vm-slugs.js +116 -0
  32. package/npm/google-ingress.js +211 -0
  33. package/npm/ingress-invoke.js +292 -0
  34. package/npm/launch-config.js +310 -4
  35. package/npm/scenario-check.js +684 -33
  36. package/npm/scenario-handlers.js +94 -12
  37. package/npm/scenario-metrics.js +116 -1
  38. package/npm/scenario-mocks.js +20 -0
  39. package/npm/scenario-playwright.js +366 -29
  40. package/npm/script-drift.js +81 -0
  41. package/npm/utils.js +453 -43
  42. package/npm/vm-urls.js +69 -8
  43. package/package.json +5 -5
  44. package/ui/dist/assets/ContainerInspector-D7VZkkaj.js +2 -0
  45. package/ui/dist/assets/EditorShell-DHhIaotd.css +1 -0
  46. package/ui/dist/assets/EditorShell-r-796N_W.js +191 -0
  47. package/ui/dist/assets/{Markdown-BHBqz-Mj.js → Markdown-BoLHHFXH.js} +1 -1
  48. package/ui/dist/assets/ProjectLauncherScreen-Ck-XWkQN.js +1 -0
  49. package/ui/dist/assets/ScenarioDataPanel-B9iDSUpA.js +5 -0
  50. package/ui/dist/assets/ScenarioDataPanelFullPage-CBLTGC4-.js +1 -0
  51. package/ui/dist/assets/ScenarioRenderer-BhLfT-nX.js +21 -0
  52. package/ui/dist/assets/StepsSimulator-BFldWyoX.js +1 -0
  53. package/ui/dist/assets/index-CIXZkwfB.css +1 -0
  54. package/ui/dist/assets/{index-KDFAgt2e.js → index-QsOiOG9x.js} +9 -9
  55. package/ui/dist/assets/useEvents-DHkLJZR4.js +1 -0
  56. package/ui/dist/codeyam-mark.svg +4 -0
  57. package/ui/dist/codeyam-wordmark.svg +5 -0
  58. package/ui/dist/index.html +2 -2
  59. package/ui/dist/assets/ContainerInspector-C8EkH4PP.js +0 -2
  60. package/ui/dist/assets/EditorShell-CGsBSA6u.js +0 -186
  61. package/ui/dist/assets/ScenarioDataPanel-nuLXJzLf.js +0 -5
  62. package/ui/dist/assets/ScenarioDataPanelFullPage-Bnghtj8a.js +0 -1
  63. package/ui/dist/assets/ScenarioRenderer-B_Za9jL8.js +0 -1
  64. package/ui/dist/assets/StepsSimulator-CRtmlyNZ.js +0 -1
  65. package/ui/dist/assets/index-Bih6HJ_u.css +0 -1
  66. package/ui/dist/assets/useEvents-DggWrtHe.js +0 -1
@@ -8,7 +8,16 @@
8
8
  // runs on a VM; the history is the cache that makes the last choice the default
9
9
  // and past launches one click to re-run.
10
10
 
11
- const { CANONICAL_EDITOR_REPO, EDITOR_VARIANTS, repoBasename } = require("./cloud");
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");
14
+
15
+ /** The build agents a VM's editor terminal can run, as the keys of cloud.js's
16
+ * AGENT_ENV_VARS. Deriving the selectable set from that map keeps it a single
17
+ * source of truth with the agent→API-key-env-var mapping (cloud.js) and the
18
+ * gcp-bootstrap CLI installer, so adding an agent is still a one-line change in
19
+ * AGENT_ENV_VARS rather than a list to keep in sync here. */
20
+ const AGENTS = Object.freeze(Object.keys(AGENT_ENV_VARS));
12
21
 
13
22
  const EMPTY_CONFIG = Object.freeze({
14
23
  clientRepo: "",
@@ -18,20 +27,52 @@ const EMPTY_CONFIG = Object.freeze({
18
27
  newProjectName: "",
19
28
  editorVariant: "dev",
20
29
  editorBranch: "",
30
+ // Build agent provider the VM's editor terminal runs (claude | codex | gemini).
31
+ // Defaults to claude; cloudUpArgs forwards it as cloud:up's --agent flag.
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: "",
21
42
  });
22
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
+
23
57
  /** Coerce a raw form/query object into the canonical launch-config shape: trim
24
58
  * strings, default the variant to a known value. Never throws — validation is
25
59
  * a separate, explicit step. */
26
60
  function normalizeLaunchConfig(input = {}) {
27
61
  const str = (v) => (typeof v === "string" ? v.trim() : "");
28
62
  const editorVariant = EDITOR_VARIANTS.includes(input.editorVariant) ? input.editorVariant : "dev";
63
+ // Coerce an unknown/absent agent to the default rather than rejecting — the
64
+ // form's agent selector only ever submits a known value, so an out-of-set
65
+ // agent is a stale query param, not an error.
66
+ const agent = AGENTS.includes(input.agent) ? input.agent : "claude";
29
67
  return {
30
68
  clientRepo: str(input.clientRepo),
31
69
  clientBranch: str(input.clientBranch),
32
70
  newProjectName: str(input.newProjectName),
33
71
  editorVariant,
34
72
  editorBranch: str(input.editorBranch),
73
+ agent,
74
+ vmSlug: str(input.vmSlug),
75
+ vmAccess: normalizeAccessText(input.vmAccess),
35
76
  };
36
77
  }
37
78
 
@@ -55,6 +96,26 @@ function validateLaunchConfig(cfg) {
55
96
  if (!EDITOR_VARIANTS.includes(cfg.editorVariant)) {
56
97
  return { ok: false, msg: `unknown editor variant: ${cfg.editorVariant}` };
57
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
+ }
58
119
  return { ok: true };
59
120
  }
60
121
 
@@ -108,18 +169,52 @@ function variantImageTag(variant) {
108
169
  return null; // dev / unknown — no packaged image
109
170
  }
110
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
+
111
184
  /** Build the `cloud:up` flag array for a launch config + instance name. The
112
185
  * editor source is the canonical codeyam-editor repo (dev variant builds the
113
186
  * image from editorBranch); the client repo/branch is what lands in
114
- * /workspace. Decoupled exactly as cloud.js consumes them. */
115
- function cloudUpArgs(cfg, instanceName, agent = "claude") {
187
+ * /workspace. The build agent rides on `cfg.agent` (normalizeLaunchConfig
188
+ * guarantees a known value, defaulting to claude), so a single config object
189
+ * carries every launch choice. Decoupled exactly as cloud.js consumes them. */
190
+ function cloudUpArgs(cfg, instanceName, project) {
116
191
  const args = ["--instance-name", instanceName];
192
+ // Pass the authoritative GCP project (the dashboard's resolved PROJECT)
193
+ // explicitly so cloud:up never relies on the spawning shell's ambient
194
+ // `gcloud config get-value project`. Critically, a `--project` flag OVERRIDES
195
+ // a persisted .codeyam/cloud.json `project` (cloud.js merges
196
+ // `{ ...existing, ...flags }`), which a bare cloud:up reuses verbatim with no
197
+ // gcloud fallback — the sticky-wrong-project failure mode where a stale
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).
202
+ if (project) args.push("--project", project);
117
203
  // Greenfield (new folder) vs clone an existing repo.
118
204
  if (cfg.newProjectName) args.push("--new-project", cfg.newProjectName);
119
205
  else args.push("--repo", cfg.clientRepo);
120
- args.push("--agent", agent, "--editor-repo", CANONICAL_EDITOR_REPO, "--editor-variant", cfg.editorVariant);
206
+ args.push("--agent", cfg.agent || "claude", "--editor-repo", CANONICAL_EDITOR_REPO, "--editor-variant", cfg.editorVariant);
121
207
  if (!cfg.newProjectName && cfg.clientBranch) args.push("--branch", cfg.clientBranch);
122
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);
123
218
  // The dashboard opens its own per-VM tunnel, so cloud:up must NOT open one:
124
219
  // it lets cloud:up exit cleanly and avoids parallel batches colliding on the
125
220
  // fixed local ports 14199/5173 (the cause of multi-add "false failures").
@@ -237,6 +332,40 @@ function distinctConfigs(history, limit = 12) {
237
332
  return out;
238
333
  }
239
334
 
335
+ /** Display-side companion to `distinctConfigs`: derive the two label strings the
336
+ * "recent launches" picker renders for one config, so two configs that differ
337
+ * only by build agent (or greenfield project name) look different instead of
338
+ * collapsing to identical rows. `distinctConfigs` de-dups on the FULL config, so
339
+ * the rows are genuinely distinct launches — the old inline label just never
340
+ * surfaced the distinguishing fields (agent, greenfield name).
341
+ *
342
+ * Returns { primary, secondary }:
343
+ * - primary: greenfield (`!clientRepo && newProjectName`) → `new: <name>` so
344
+ * two different greenfield projects no longer both read `(none)`;
345
+ * else `repoBasename(clientRepo)` + (`@<branch>` when set); else
346
+ * `(none)` when there's neither a repo nor a project name.
347
+ * - secondary: `editor <variant>` + (`@<editorBranch>` when set) + ` · <agent>`.
348
+ *
349
+ * Normalizes through EMPTY_CONFIG so a pre-`agent` history entry still renders
350
+ * `· claude`. Reuses `repoBasename` (cloud.js) so server/browser basename
351
+ * semantics stay single-sourced. Pure; no I/O. */
352
+ function recentLaunchDisplay(config) {
353
+ const cfg = { ...EMPTY_CONFIG, ...(config || {}) };
354
+ let primary;
355
+ if (!cfg.clientRepo && cfg.newProjectName) {
356
+ primary = `new: ${cfg.newProjectName}`;
357
+ } else if (cfg.clientRepo) {
358
+ primary = repoBasename(cfg.clientRepo) + (cfg.clientBranch ? `@${cfg.clientBranch}` : "");
359
+ } else {
360
+ primary = "(none)";
361
+ }
362
+ const secondary =
363
+ `editor ${cfg.editorVariant || "dev"}` +
364
+ (cfg.editorBranch ? `@${cfg.editorBranch}` : "") +
365
+ ` · ${cfg.agent || "claude"}`;
366
+ return { primary, secondary };
367
+ }
368
+
240
369
  /** The default config to pre-fill the launch form: the most recent launch's
241
370
  * config, or an empty config when there's no history. */
242
371
  function defaultConfig(history) {
@@ -252,6 +381,28 @@ function configForVm(history, n) {
252
381
  return h && h.config ? { ...EMPTY_CONFIG, ...h.config } : null;
253
382
  }
254
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
+
255
406
  /** Resolve the card's display identity, preferring LIVE truth (the editor's
256
407
  * `/api/session-info` `clientProject` + `editorIdentity`) over STALE launch
257
408
  * history. This is the fix for fragility #33's lying label: a CLI branch
@@ -321,6 +472,31 @@ function stickyClientProject(prev, info) {
321
472
  return prev || null;
322
473
  }
323
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
+
324
500
  /** Classify a VM's project KIND as `"self-host"` vs `"guest"` from the live
325
501
  * identity reported in `/api/session-info` (`clientProject.repo`), with
326
502
  * launch-history `clientRepo` as a fallback when live is absent. A guest is
@@ -479,6 +655,21 @@ function resolveCredsSource({ reachableDonors = [], claudeCredsSrc = "", githubT
479
655
  return { mode: "none", donor: null };
480
656
  }
481
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
+
482
673
  // Single-quote a string for safe inclusion in a remote shell command. Mirrors
483
674
  // the server.js helper so callers don't have to thread one through.
484
675
  function _shq(s) { return `'${String(s).replace(/'/g, `'\\''`)}'`; }
@@ -591,7 +782,111 @@ function buildEnvApplyCommand({ gcloud, instance, zone, envFile, container, edit
591
782
  return `base64 < ${_shq(envFile)} | ${gcloud} compute ssh ${instance} --tunnel-through-iap --zone=${zone} --command=${_shq(sink)}`;
592
783
  }
593
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
+
848
+ /** Get authenticated Git URL for checking client branch. */
849
+ function getAuthRepoUrl(repo, token) {
850
+ if (!repo) return "";
851
+ if (!token) return repo;
852
+ const sshRegex = /^git@github\.com:([^/]+)\/(.+?)(?:\.git)?$/;
853
+ const sshMatch = repo.match(sshRegex);
854
+ if (sshMatch) {
855
+ return `https://x-access-token:${token}@github.com/${sshMatch[1]}/${sshMatch[2]}`;
856
+ }
857
+ if (repo.startsWith("https://github.com/")) {
858
+ return repo.replace(/^https:\/\/github\.com\//, `https://x-access-token:${token}@github.com/`);
859
+ }
860
+ return repo;
861
+ }
862
+
863
+ /** Interpret the output of git ls-remote to see if a branch exists. */
864
+ function interpretLsRemote({ stdout = "", code = 0, err = null, repo = "", branch = "" } = {}) {
865
+ const b = (branch || "").trim();
866
+ if (!b) {
867
+ return { ok: true, msg: "using repo default branch" };
868
+ }
869
+ if (err || code !== 0) {
870
+ const errMsg = (err && (err.message || String(err))) || "command failed";
871
+ return {
872
+ ok: false,
873
+ soft: true,
874
+ msg: `couldn't verify branch: ${errMsg.trim()}`
875
+ };
876
+ }
877
+ const trimmed = (stdout || "").trim();
878
+ if (trimmed) {
879
+ return { ok: true };
880
+ } else {
881
+ return {
882
+ ok: false,
883
+ msg: `branch '${b}' not found in ${repo || "repository"}`
884
+ };
885
+ }
886
+ }
887
+
594
888
  module.exports = {
889
+ AGENTS,
595
890
  EMPTY_CONFIG,
596
891
  normalizeLaunchConfig,
597
892
  validateLaunchConfig,
@@ -606,12 +901,17 @@ module.exports = {
606
901
  queuedStatusForAction,
607
902
  pushHistory,
608
903
  distinctConfigs,
904
+ recentLaunchDisplay,
609
905
  defaultConfig,
610
906
  configForVm,
907
+ latestLaunchTs,
908
+ recentlyLaunchedWithin,
611
909
  resolveCardIdentity,
612
910
  stickyClientProject,
911
+ stickyAppPort,
613
912
  classifyProjectKind,
614
913
  resolveCredsSource,
914
+ claudeAuthSeeded,
615
915
  buildCredsSteps,
616
916
  buildClaudeJsonPreserveSteps,
617
917
  buildEnvApplyCommand,
@@ -619,4 +919,10 @@ module.exports = {
619
919
  editorSourceShellResolve,
620
920
  rebuildStrategy,
621
921
  decideStartJob,
922
+ editorTunnelCmd,
923
+ previewTunnelCmd,
924
+ awaitWiringCmd,
925
+ getAuthRepoUrl,
926
+ interpretLsRemote,
927
+ isPlaceholderProject,
622
928
  };