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