@codeyam-editor/codeyam-editor 0.1.1 → 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.
- package/npm/cli.js +5 -2
- package/npm/cloud-branch-switch.js +442 -0
- package/npm/cloud.js +1521 -0
- package/npm/container.js +22 -3
- package/npm/editor-dev.js +286 -36
- package/npm/editor.js +98 -35
- package/npm/empty-project-dir.js +33 -0
- package/npm/fleet-auth.js +111 -0
- package/npm/fleet-cleanup.js +124 -0
- package/npm/fleet-env.js +125 -0
- package/npm/fleet-memory.js +127 -0
- package/npm/fleet-pool-policy.js +78 -0
- package/npm/fleet-pool.js +401 -0
- package/npm/fleet-queue.js +40 -0
- package/npm/fleet-reconcile.js +36 -0
- package/npm/fleet-reset.js +144 -0
- package/npm/fleet-roster.js +81 -0
- package/npm/launch-config.js +544 -0
- package/npm/preflight-guidance.js +68 -0
- package/npm/scenario-check.js +298 -14
- package/npm/scenario-handlers.js +16 -5
- package/npm/scenario-interactivity.js +199 -0
- package/npm/scenario-metrics.js +16 -2
- package/npm/scenario-mocks.js +62 -25
- package/npm/scenario-playwright.js +169 -6
- package/npm/utils.js +507 -21
- package/npm/vm-urls.js +117 -0
- package/package.json +8 -7
- package/ui/dist/assets/ContainerInspector-C8EkH4PP.js +2 -0
- package/ui/dist/assets/EditorShell-DWaeBxgj.js +186 -0
- package/ui/dist/assets/Markdown-BHBqz-Mj.js +1 -0
- package/ui/dist/assets/ScenarioDataPanel-DYP7pi_n.css +32 -0
- package/ui/dist/assets/ScenarioDataPanel-zJSXtXL4.js +5 -0
- package/ui/dist/assets/ScenarioDataPanelFullPage-C0YHQB-T.js +1 -0
- package/ui/dist/assets/ScenarioRenderer-CgDxuigm.js +1 -0
- package/ui/dist/assets/StepsSimulator-CRtmlyNZ.js +1 -0
- package/ui/dist/assets/index-BUYNFqKK.js +27 -0
- package/ui/dist/assets/index-DVr5rY2d.css +1 -0
- package/ui/dist/assets/markdown-v0F0UEt3.js +37 -0
- package/ui/dist/assets/react-CSS0HapR.js +28 -0
- package/ui/dist/assets/useEvents-DggWrtHe.js +1 -0
- package/ui/dist/assets/xterm--24IGk-x.js +9 -0
- package/ui/dist/favicon.ico +0 -0
- package/ui/dist/favicon.png +0 -0
- package/ui/dist/index.html +16 -0
- package/npm/postinstall.js +0 -132
package/npm/cloud.js
ADDED
|
@@ -0,0 +1,1521 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* cloud.js — one-command orchestrator for hosting a codeyam-editor session
|
|
5
|
+
* on a GCE VM. Reuses `scripts/gcp-bootstrap.sh` and
|
|
6
|
+
* `scripts/gcp-project-bootstrap.sh` verbatim by streaming them over
|
|
7
|
+
* `gcloud compute ssh`. Per-project state lives in `.codeyam/cloud.json`
|
|
8
|
+
* (gitignored) so the second invocation is zero-arg. See
|
|
9
|
+
* `docs/cloud-gcp.md` for the runbook this wraps.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
const { spawn, spawnSync } = require("child_process");
|
|
13
|
+
const fs = require("fs");
|
|
14
|
+
const path = require("path");
|
|
15
|
+
const readline = require("readline");
|
|
16
|
+
const {
|
|
17
|
+
rootDir,
|
|
18
|
+
isPortInUse,
|
|
19
|
+
waitForPort,
|
|
20
|
+
deepMergeJson,
|
|
21
|
+
killChildProcess,
|
|
22
|
+
} = require("./utils");
|
|
23
|
+
const { open } = require("./open");
|
|
24
|
+
// `FORWARD_ENV_KEYS_ENV` is the env-var name (`CY_FORWARD_ENV_KEYS`) the
|
|
25
|
+
// fleet dashboard sets on `cloud:up`'s child to name the subset of
|
|
26
|
+
// `process.env` keys that came from the operator-supplied launch form.
|
|
27
|
+
// Imported from `fleet-env` (single source of truth) so the dashboard and
|
|
28
|
+
// cloud:up can never drift on the contract — a string mismatch would
|
|
29
|
+
// silently disable the forwarding.
|
|
30
|
+
const { FORWARD_ENV_KEYS_ENV } = require("./fleet-env");
|
|
31
|
+
// Deterministic fleet-ingress URL derivation. `resolveFleetDomain` reads the
|
|
32
|
+
// operator's owned domain from env (null when unset); `vmUrls` derives a VM's
|
|
33
|
+
// stable `https://vm-<N>.preview.<domain>` previewOrigin from its number. When
|
|
34
|
+
// no domain is configured these stay dormant and the legacy quick-tunnel seed
|
|
35
|
+
// path runs unchanged.
|
|
36
|
+
const { resolveFleetDomain, vmUrls } = require("./vm-urls");
|
|
37
|
+
// Shared fleet-roster writer. `cloud:up` rosters the VM it just provisioned so
|
|
38
|
+
// the dashboard, the cloudflared-monitor tunnel publisher, and the watchdog see
|
|
39
|
+
// it within one poll cycle — without a manual fleet-vms.json edit (the
|
|
40
|
+
// invisible-VM gap improve38 closes).
|
|
41
|
+
const { addToRoster } = require("./fleet-roster");
|
|
42
|
+
|
|
43
|
+
// =============================================================================
|
|
44
|
+
// Pure helpers — every export here is unit-tested in cloud.test.js without
|
|
45
|
+
// touching `gcloud` or the network.
|
|
46
|
+
// =============================================================================
|
|
47
|
+
|
|
48
|
+
const AGENT_ENV_VARS = {
|
|
49
|
+
claude: "ANTHROPIC_API_KEY",
|
|
50
|
+
codex: "OPENAI_API_KEY",
|
|
51
|
+
gemini: "GEMINI_API_KEY",
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
const KNOWN_FLAGS = [
|
|
55
|
+
"repo", "branch", "instance-name", "project", "zone", "agent",
|
|
56
|
+
// Editor-source selection, decoupled from the client project (repo/branch):
|
|
57
|
+
"editor-repo", "editor-branch", "editor-variant",
|
|
58
|
+
// Greenfield: launch against a NEW empty folder (no repo to clone) named
|
|
59
|
+
// <name>; the bootstrap mkdir+git-init+inits it and the editor's onboarding
|
|
60
|
+
// workflow takes over. Mutually exclusive with --repo.
|
|
61
|
+
"new-project",
|
|
62
|
+
// Provisioning-only: skip cloud:up's own laptop tunnel (the caller opens its
|
|
63
|
+
// own). Lets cloud:up exit cleanly + avoids parallel runs contending on the
|
|
64
|
+
// fixed local ports 14199/5173.
|
|
65
|
+
"no-tunnel",
|
|
66
|
+
// Opt out of the project's `npm run setup` at provision time. The bootstrap
|
|
67
|
+
// runs setup by default when the project ships one; pass this for projects
|
|
68
|
+
// where a cloud setup is genuinely wrong (the script's --skip-setup hatch).
|
|
69
|
+
"skip-setup",
|
|
70
|
+
];
|
|
71
|
+
|
|
72
|
+
// Flags that take no value (presence = true). parseFlags must not demand a
|
|
73
|
+
// value for these or `--no-tunnel` would error "requires a value".
|
|
74
|
+
const BOOLEAN_FLAGS = ["no-tunnel", "skip-setup"];
|
|
75
|
+
|
|
76
|
+
const FIREWALL_RULE_NAME = "codeyam-editor-iap-ssh";
|
|
77
|
+
const INSTANCE_TAG = "codeyam-editor";
|
|
78
|
+
const EDITOR_PORT = 14199;
|
|
79
|
+
const VITE_PORT = 5173;
|
|
80
|
+
|
|
81
|
+
// Sentinel stamped into editor.local.json's `_managedPreviewOrigin` whenever
|
|
82
|
+
// WE (cloud:up's seed, or the operator's cloudflared-monitor) write the
|
|
83
|
+
// previewOrigin. It lets the monitor tell its own writes (overwrite freely on
|
|
84
|
+
// respawn) apart from a user-pinned origin (never clobber). Must match the
|
|
85
|
+
// literal the bash monitor writes — see scripts/operator/cloudflared-monitor.sh.
|
|
86
|
+
const MANAGED_PREVIEW_ORIGIN = "cloudflared-monitor";
|
|
87
|
+
|
|
88
|
+
// The reserved same-origin path the Live Preview is served under. Seeded as the
|
|
89
|
+
// quick-tunnel `previewOrigin` so the iframe loads the app from the editor's own
|
|
90
|
+
// origin (relative path, no `localhost:<port>` URL) — killing Chrome's Private
|
|
91
|
+
// Network Access block and any cross-VM port collision. A constant relative path
|
|
92
|
+
// never rotates, so the cloudflared-monitor has nothing to push for it. Mirrors
|
|
93
|
+
// the Rust `preview_proxy_route::PREVIEW_PATH_PREFIX`.
|
|
94
|
+
const SAME_ORIGIN_PREVIEW_PATH = "/__codeyam_preview";
|
|
95
|
+
|
|
96
|
+
// Operator-laptop sidecar the cloudflared-monitor writes per-VM tunnel URLs
|
|
97
|
+
// into. cloud:up reads it to seed a VM's previewOrigin with the public
|
|
98
|
+
// `<N>-preview` URL when the monitor has already published one. Same default
|
|
99
|
+
// path + env override the monitor and fleet dashboard use.
|
|
100
|
+
const CLOUDFLARED_URLS_FILE =
|
|
101
|
+
process.env.URLS_FILE ||
|
|
102
|
+
path.join(rootDir, ".codeyam", "logs", "cloudflared-urls.json");
|
|
103
|
+
|
|
104
|
+
// Canonical codeyam-editor repository — the default source for the editor
|
|
105
|
+
// IMAGE, independent of the CLIENT project being edited (cfg.repo). Decoupling
|
|
106
|
+
// these is what lets one VM edit client project A while running the editor
|
|
107
|
+
// built from codeyam-editor branch B. Before this split, cfg.repo/cfg.branch
|
|
108
|
+
// fed BOTH the image build and the /workspace clone, so only the self-host case
|
|
109
|
+
// (client === editor) worked; a non-self-host VM tried to build the editor
|
|
110
|
+
// image out of the client repo.
|
|
111
|
+
const CANONICAL_EDITOR_REPO = "https://github.com/codeyam-ai/codeyam-editor.git";
|
|
112
|
+
|
|
113
|
+
// The editor distribution channels an operator can pick per VM:
|
|
114
|
+
// dev — build the editor image from the editor repo @ editorBranch
|
|
115
|
+
// (today's path; the only one wired into provisioning so far)
|
|
116
|
+
// staging — `npm i -g @codeyam-editor/codeyam-editor@staging` (Phase 2)
|
|
117
|
+
// production — `npm i -g @codeyam-editor/codeyam-editor` (Phase 2)
|
|
118
|
+
const EDITOR_VARIANTS = ["dev", "staging", "production"];
|
|
119
|
+
|
|
120
|
+
/** Map an agent name to its API-key env var. Throws on unknown agents so
|
|
121
|
+
* adding an agent is a one-entry change in `AGENT_ENV_VARS`, never a
|
|
122
|
+
* silent fallthrough that ships unauthenticated. */
|
|
123
|
+
function agentEnvVarName(agent) {
|
|
124
|
+
if (!Object.prototype.hasOwnProperty.call(AGENT_ENV_VARS, agent)) {
|
|
125
|
+
throw new Error(
|
|
126
|
+
`Unknown agent: ${agent}. Supported: ${Object.keys(AGENT_ENV_VARS).join(", ")}`,
|
|
127
|
+
);
|
|
128
|
+
}
|
|
129
|
+
return AGENT_ENV_VARS[agent];
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** Parse `--flag value` argv into an object. Unknown flags or missing values
|
|
133
|
+
* throw with a usage hint — silently dropping them would let typos like
|
|
134
|
+
* `--branchh main` succeed with no branch flag at all. */
|
|
135
|
+
function parseFlags(argv) {
|
|
136
|
+
const flags = {};
|
|
137
|
+
for (let i = 0; i < argv.length; i++) {
|
|
138
|
+
const arg = argv[i];
|
|
139
|
+
if (!arg.startsWith("--")) {
|
|
140
|
+
throw new Error(
|
|
141
|
+
`Unexpected positional argument: ${arg}\n` +
|
|
142
|
+
`Usage: npm run cloud:<up|tunnel|stop|destroy|status> -- [${KNOWN_FLAGS
|
|
143
|
+
.map((f) => `--${f} <value>`)
|
|
144
|
+
.join("] [")}]`,
|
|
145
|
+
);
|
|
146
|
+
}
|
|
147
|
+
const key = arg.slice(2);
|
|
148
|
+
if (!KNOWN_FLAGS.includes(key)) {
|
|
149
|
+
throw new Error(`Unknown flag: ${arg}. Known: --${KNOWN_FLAGS.join(", --")}`);
|
|
150
|
+
}
|
|
151
|
+
const camel = key.replace(/-([a-z])/g, (_, c) => c.toUpperCase());
|
|
152
|
+
if (BOOLEAN_FLAGS.includes(key)) {
|
|
153
|
+
flags[camel] = true;
|
|
154
|
+
continue;
|
|
155
|
+
}
|
|
156
|
+
const value = argv[i + 1];
|
|
157
|
+
if (value === undefined || value.startsWith("--")) {
|
|
158
|
+
throw new Error(`${arg} requires a value`);
|
|
159
|
+
}
|
|
160
|
+
flags[camel] = value;
|
|
161
|
+
i++;
|
|
162
|
+
}
|
|
163
|
+
return flags;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** Strip `.git`, take the basename, lowercase, collapse non-alphanumerics to
|
|
167
|
+
* hyphens. Mirrors the existing `scripts/gcp-project-bootstrap.sh` derivation
|
|
168
|
+
* so the directory layout on the VM matches the instance name 1:1. */
|
|
169
|
+
function repoBasename(repoUrl) {
|
|
170
|
+
const stripped = repoUrl.replace(/\.git$/i, "");
|
|
171
|
+
const parts = stripped.split(/[/:]+/).filter(Boolean);
|
|
172
|
+
return parts[parts.length - 1] || "project";
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/** Construct a deterministic instance name from a repo URL. GCE caps
|
|
176
|
+
* instance names at 63 chars; we cap at 40 to keep room for hyphen
|
|
177
|
+
* separators and stay well below the limit. */
|
|
178
|
+
function deriveInstanceName(repoUrl) {
|
|
179
|
+
const base = repoBasename(repoUrl);
|
|
180
|
+
let slug = base
|
|
181
|
+
.toLowerCase()
|
|
182
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
183
|
+
.replace(/^-+|-+$/g, "");
|
|
184
|
+
if (!slug) slug = "project";
|
|
185
|
+
let name = `codeyam-editor-${slug}`;
|
|
186
|
+
if (name.length > 40) name = name.slice(0, 40).replace(/-+$/g, "");
|
|
187
|
+
return name;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/** Normalize two repo URLs for equality: strip a trailing `.git`, lowercase.
|
|
191
|
+
* Used to detect the self-host case (client repo IS the editor repo). */
|
|
192
|
+
function sameRepo(a, b) {
|
|
193
|
+
const norm = (s) => String(s || "").replace(/\.git$/i, "").toLowerCase();
|
|
194
|
+
return !!a && !!b && norm(a) === norm(b);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/** Pick the default GCE instance name a freshly launched `cloud:up` should
|
|
198
|
+
* mint for `repoUrl`. The canonical fleet repo (`codeyam-editor`) is part of
|
|
199
|
+
* the numbered fleet — VM-1 must match the dashboard's `INSTANCE_PREFIX + 1`
|
|
200
|
+
* shape (`<base>-1`), every VM is `<base>-<n>`. A client repo (single-VM)
|
|
201
|
+
* keeps the un-suffixed base (`codeyam-editor-<slug>`). Pure / disk-free so
|
|
202
|
+
* the convention is testable without going through loadOrInitCloudConfig. */
|
|
203
|
+
function defaultInstanceNameFor(repoUrl) {
|
|
204
|
+
const base = deriveInstanceName(repoUrl);
|
|
205
|
+
return sameRepo(repoUrl, CANONICAL_EDITOR_REPO) ? `${base}-1` : base;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/** Resolve the fleet VM number from an instance name, or null when the name
|
|
209
|
+
* isn't a fleet self-host instance. The fleet base is the instance name
|
|
210
|
+
* `deriveInstanceName` returns for the canonical editor repo; every VM is
|
|
211
|
+
* `<base>-<n>` (VM-1 = `<base>-1`, VM-N = `<base>-N`), matching the
|
|
212
|
+
* dashboard's INSTANCE_PREFIX + n. As a back-compat read path the bare
|
|
213
|
+
* `<base>` (no suffix) also resolves to VM-1, so any lingering legacy
|
|
214
|
+
* instance or old log is still recognized. A non-fleet client project
|
|
215
|
+
* (`codeyam-editor-<slug>`) returns null so its previewOrigin seed never
|
|
216
|
+
* picks up a fleet tunnel URL. */
|
|
217
|
+
function resolveFleetVmNumber(instanceName) {
|
|
218
|
+
const base = deriveInstanceName(CANONICAL_EDITOR_REPO);
|
|
219
|
+
const name = String(instanceName || "");
|
|
220
|
+
if (name === base) return 1;
|
|
221
|
+
const m = new RegExp(`^${base}-(\\d+)$`).exec(name);
|
|
222
|
+
return m ? Number(m[1]) : null;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/** Roster a just-provisioned VM into the fleet dashboard's view so it (and the
|
|
226
|
+
* cloudflared-monitor + watchdog that read the same roster) pick it up within
|
|
227
|
+
* one poll cycle — closing the invisible-VM gap where a CLI `cloud:up` left the
|
|
228
|
+
* VM out of `fleet-vms.json` until a manual edit.
|
|
229
|
+
*
|
|
230
|
+
* Best-effort and non-fatal: a roster-write failure warns loudly (surfacing it
|
|
231
|
+
* is the whole point of this fix) but never aborts an otherwise-successful
|
|
232
|
+
* provision. A non-fleet instance name (`resolveFleetVmNumber` → null, e.g. a
|
|
233
|
+
* guest/standalone editor) is skipped — only fleet-numbered VMs roster.
|
|
234
|
+
*
|
|
235
|
+
* Returns `{ status, vm }` where `status` is `"rostered" | "skipped" |
|
|
236
|
+
* "failed"`. `deps` is injectable so the unit test drives every branch without
|
|
237
|
+
* touching the real roster file. */
|
|
238
|
+
function rosterProvisionedVm(instanceName, deps = {}) {
|
|
239
|
+
const resolve = deps.resolveFleetVmNumber || resolveFleetVmNumber;
|
|
240
|
+
const add = deps.addToRoster || addToRoster;
|
|
241
|
+
const log = deps.log || ((m) => console.error(m));
|
|
242
|
+
const n = resolve(instanceName);
|
|
243
|
+
if (n == null) {
|
|
244
|
+
log(`(instance '${instanceName}' is not a fleet VM — skipping dashboard roster)`);
|
|
245
|
+
return { status: "skipped", vm: null };
|
|
246
|
+
}
|
|
247
|
+
try {
|
|
248
|
+
add(n);
|
|
249
|
+
log(`✓ rostered VM-${n} into the fleet dashboard`);
|
|
250
|
+
return { status: "rostered", vm: n };
|
|
251
|
+
} catch (e) {
|
|
252
|
+
log(
|
|
253
|
+
`⚠ failed to roster VM-${n} (${e && e.message}). The VM is provisioned ` +
|
|
254
|
+
"but the dashboard won't show it until it's rostered — add it via the " +
|
|
255
|
+
"dashboard's Adopt action or re-run cloud:up.",
|
|
256
|
+
);
|
|
257
|
+
return { status: "failed", vm: n };
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/** Resolve the app's dev-server port from a parsed editor.json plus an optional
|
|
262
|
+
* editor.local.json overlay, mirroring `EditorConfig::resolved_port()`:
|
|
263
|
+
* `apps[0].port` wins, then the top-level `port`, then `VITE_PORT` (5173) as
|
|
264
|
+
* the final fallback for a config that specifies neither. editor.local.json
|
|
265
|
+
* fields override editor.json (deep-merge precedence — the same rule the editor
|
|
266
|
+
* applies via `load_config_with_overrides`). The runtime env override
|
|
267
|
+
* (`CODEYAM_APP_PORT_OVERRIDE`) is intentionally NOT consulted here: cloud:up
|
|
268
|
+
* reads the static, committed config at provision time. */
|
|
269
|
+
function resolveConfiguredAppPort(editorJson = {}, editorLocalJson = {}) {
|
|
270
|
+
const merged = deepMergeJson(editorJson || {}, editorLocalJson || {});
|
|
271
|
+
const apps0 =
|
|
272
|
+
Array.isArray(merged.apps) && merged.apps[0] ? merged.apps[0] : {};
|
|
273
|
+
const raw = apps0.port != null ? apps0.port : merged.port;
|
|
274
|
+
const n = Number(raw);
|
|
275
|
+
return Number.isInteger(n) && n > 0 && n < 65536 ? n : VITE_PORT;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/** Decide the previewOrigin patch to seed into a VM's editor.local.json.
|
|
279
|
+
*
|
|
280
|
+
* Named-tunnel mode (FLEET_DOMAIN set, the VM is a fleet self-host VM): the
|
|
281
|
+
* previewOrigin is the VM's *deterministic* preview hostname
|
|
282
|
+
* (`https://vm-<N>.preview.<domain>`), derived purely from the VM number. It
|
|
283
|
+
* never rotates, so there is no monitor URL to look up and — crucially — no
|
|
284
|
+
* `http://localhost:<port>` fallback that could silently point the iframe at an
|
|
285
|
+
* unreachable origin (the failure class improve38 closes). The named tunnel
|
|
286
|
+
* fronts the dev server on whatever port it serves, so the seed stays
|
|
287
|
+
* port-agnostic in this mode.
|
|
288
|
+
*
|
|
289
|
+
* Legacy quick-tunnel mode (no FLEET_DOMAIN, or a non-fleet client project):
|
|
290
|
+
* when the operator's cloudflared-monitor has already published a `<N>-preview`
|
|
291
|
+
* quick-tunnel URL in cloudflared-urls.json, that URL is the steady-state
|
|
292
|
+
* origin; otherwise fall back to the in-VM dev-server port (`appPort`, defaults
|
|
293
|
+
* to 5173) — the bootstrap value for the window before the monitor's first
|
|
294
|
+
* tick. This is the unchanged behavior for any fleet that hasn't adopted a
|
|
295
|
+
* named-tunnel domain yet.
|
|
296
|
+
*
|
|
297
|
+
* Either way we stamp `_managedPreviewOrigin` so the (legacy) monitor
|
|
298
|
+
* recognises the value as ours and won't mistake the cloud:up seed for a user
|
|
299
|
+
* pin. `env` is injectable so the unit test can drive both modes. */
|
|
300
|
+
function resolveSeedPreviewOrigin(
|
|
301
|
+
instanceName,
|
|
302
|
+
urls = {},
|
|
303
|
+
appPort = VITE_PORT,
|
|
304
|
+
env = process.env,
|
|
305
|
+
) {
|
|
306
|
+
const vm = resolveFleetVmNumber(instanceName);
|
|
307
|
+
const domain = resolveFleetDomain(env);
|
|
308
|
+
// Named tunnels are per fleet-VM; a non-fleet client project (vm == null)
|
|
309
|
+
// keeps the legacy localhost seed even when a domain is configured.
|
|
310
|
+
if (domain && vm != null) {
|
|
311
|
+
return {
|
|
312
|
+
previewOrigin: vmUrls(vm, domain).preview,
|
|
313
|
+
_managedPreviewOrigin: MANAGED_PREVIEW_ORIGIN,
|
|
314
|
+
};
|
|
315
|
+
}
|
|
316
|
+
// Quick-tunnel / domain-free mode: serve the app same-origin under the
|
|
317
|
+
// reserved relative path. The editor mounts `/__codeyam_preview` on its own
|
|
318
|
+
// origin and forwards to the in-VM app-port reverse proxy, so the iframe loads
|
|
319
|
+
// from the exact origin the editor is served on (public quick-tunnel,
|
|
320
|
+
// SSH-tunnel localhost, anything). This replaces the prior
|
|
321
|
+
// `http://localhost:${appPort}` seed — a loopback URL a public editor page
|
|
322
|
+
// could not load (Private Network Access block) — and the monitor's rotating
|
|
323
|
+
// `<N>-preview` quick-tunnel URL, which a constant relative path makes moot.
|
|
324
|
+
// `appPort` / `urls` are retained in the signature for callers but no longer
|
|
325
|
+
// seeded — the same-origin path is a constant.
|
|
326
|
+
return {
|
|
327
|
+
previewOrigin: SAME_ORIGIN_PREVIEW_PATH,
|
|
328
|
+
_managedPreviewOrigin: MANAGED_PREVIEW_ORIGIN,
|
|
329
|
+
};
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
/** Read the operator-laptop cloudflared-urls.json sidecar. Missing/unparseable
|
|
333
|
+
* → {} (the monitor may not have run yet; the seed falls back to localhost). */
|
|
334
|
+
function readCloudflaredUrls() {
|
|
335
|
+
try {
|
|
336
|
+
const parsed = JSON.parse(fs.readFileSync(CLOUDFLARED_URLS_FILE, "utf8"));
|
|
337
|
+
return parsed && typeof parsed === "object" && !Array.isArray(parsed)
|
|
338
|
+
? parsed
|
|
339
|
+
: {};
|
|
340
|
+
} catch {
|
|
341
|
+
return {};
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
/** Resolve the editor-source config (repo/branch/variant) from cfg, applying
|
|
346
|
+
* defaults. `editorRepo` defaults to CANONICAL_EDITOR_REPO so a non-self-host
|
|
347
|
+
* client project still builds its editor image from the editor source, not the
|
|
348
|
+
* client repo. Self-host back-compat: when the client repo IS the editor repo
|
|
349
|
+
* and no explicit editorBranch was given, the client branch doubles as the
|
|
350
|
+
* editor source branch — so `cloud:up --repo codeyam-editor --branch X` still
|
|
351
|
+
* builds the editor at X, exactly as it did before the decoupling. */
|
|
352
|
+
function resolveEditorConfig(cfg = {}) {
|
|
353
|
+
const editorRepo = cfg.editorRepo || CANONICAL_EDITOR_REPO;
|
|
354
|
+
let editorBranch = cfg.editorBranch;
|
|
355
|
+
if ((editorBranch == null || editorBranch === "") && sameRepo(cfg.repo, editorRepo)) {
|
|
356
|
+
editorBranch = cfg.branch;
|
|
357
|
+
}
|
|
358
|
+
const editorVariant = cfg.editorVariant || "dev";
|
|
359
|
+
return { editorRepo, editorBranch: editorBranch || undefined, editorVariant };
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
/** Throw on an unknown editor variant (a typo'd `--editor-variant` should fail,
|
|
363
|
+
* not silently fall through to dev). */
|
|
364
|
+
function validateEditorVariant(variant) {
|
|
365
|
+
if (!EDITOR_VARIANTS.includes(variant)) {
|
|
366
|
+
throw new Error(
|
|
367
|
+
`Unknown editor variant: ${variant}. Supported: ${EDITOR_VARIANTS.join(", ")}`,
|
|
368
|
+
);
|
|
369
|
+
}
|
|
370
|
+
return variant;
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
/** Phase-1 gate: only `dev` is wired into cloud provisioning. The
|
|
374
|
+
* staging/production npm-install container image lands in Phase 2. Fails loud
|
|
375
|
+
* with an actionable message rather than silently building a dev image. */
|
|
376
|
+
function assertVariantProvisionable(variant) {
|
|
377
|
+
validateEditorVariant(variant);
|
|
378
|
+
if (variant !== "dev") {
|
|
379
|
+
throw new Error(
|
|
380
|
+
`Editor variant '${variant}' is not yet wired for cloud provisioning ` +
|
|
381
|
+
`(Phase 2 — the @staging/production npm-install image). Use --editor-variant dev.`,
|
|
382
|
+
);
|
|
383
|
+
}
|
|
384
|
+
return variant;
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
/** Args for scripts/gcp-bootstrap.sh, which builds the codeyam-editor:local
|
|
388
|
+
* image from the EDITOR source — NOT the client project. */
|
|
389
|
+
function editorImageBootstrapArgs(cfg) {
|
|
390
|
+
const { editorRepo, editorBranch } = resolveEditorConfig(cfg);
|
|
391
|
+
const args = ["--agent", cfg.agent, "--repo", editorRepo];
|
|
392
|
+
if (editorBranch) args.push("--branch", editorBranch);
|
|
393
|
+
return args;
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
/** Resolve the CLIENT project from cfg: either an existing repo to clone, or a
|
|
397
|
+
* greenfield new folder to scaffold. `name` is the on-VM directory basename
|
|
398
|
+
* (under ~/projects/) — derived from the repo for the clone case, or the
|
|
399
|
+
* given folder name for greenfield. */
|
|
400
|
+
/** --repo and --new-project are mutually exclusive. When one is explicitly
|
|
401
|
+
* passed THIS run, drop the other from the (possibly cloud.json-merged) config
|
|
402
|
+
* so a stale value — e.g. a prior greenfield's `newProject` left in cloud.json
|
|
403
|
+
* — can't override the new intent. Without this, launching `--repo X` after a
|
|
404
|
+
* greenfield run silently provisions the greenfield project (resolveProject
|
|
405
|
+
* prioritizes newProject). Mutates and returns cfg. */
|
|
406
|
+
function reconcileProjectExclusivity(cfg, flags = {}) {
|
|
407
|
+
if (flags.repo) delete cfg.newProject;
|
|
408
|
+
if (flags.newProject) { delete cfg.repo; delete cfg.branch; }
|
|
409
|
+
return cfg;
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
function resolveProject(cfg = {}) {
|
|
413
|
+
if (cfg.newProject) {
|
|
414
|
+
return { greenfield: true, name: cfg.newProject, repo: null, branch: null };
|
|
415
|
+
}
|
|
416
|
+
return {
|
|
417
|
+
greenfield: false,
|
|
418
|
+
name: cfg.repo ? repoBasename(cfg.repo) : null,
|
|
419
|
+
repo: cfg.repo || null,
|
|
420
|
+
branch: cfg.branch || null,
|
|
421
|
+
};
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
/** Args for scripts/gcp-project-bootstrap.sh. For an existing repo: the repo URL
|
|
425
|
+
* (+ optional --branch) to clone. For greenfield: --new-project <name>, so the
|
|
426
|
+
* bootstrap creates an empty git-init'd folder and inits it instead of cloning. */
|
|
427
|
+
function clientProjectBootstrapArgs(cfg) {
|
|
428
|
+
const p = resolveProject(cfg);
|
|
429
|
+
// The bootstrap now runs `npm run setup` by default when the project ships
|
|
430
|
+
// one; --skip-setup is the opt-out, threaded from the cloud:up flag. (A
|
|
431
|
+
// greenfield launch starts from an empty git-init'd folder with no setup
|
|
432
|
+
// script, so the flag is a harmless no-op there.)
|
|
433
|
+
const setupArgs = cfg.skipSetup ? ["--skip-setup"] : [];
|
|
434
|
+
if (p.greenfield) return ["--new-project", p.name, ...setupArgs];
|
|
435
|
+
const args = [cfg.repo];
|
|
436
|
+
if (cfg.branch) args.push("--branch", cfg.branch);
|
|
437
|
+
args.push(...setupArgs);
|
|
438
|
+
return args;
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
function cloudConfigPath(projectDir) {
|
|
442
|
+
return path.join(projectDir, ".codeyam", "cloud.json");
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
/** Returns the parsed config, or `null` when the file is absent. Throws
|
|
446
|
+
* with the full path on malformed JSON so the user knows which file to
|
|
447
|
+
* fix — silently returning `null` would mask corruption as "no config". */
|
|
448
|
+
function loadCloudConfig(projectDir) {
|
|
449
|
+
const p = cloudConfigPath(projectDir);
|
|
450
|
+
if (!fs.existsSync(p)) return null;
|
|
451
|
+
let raw;
|
|
452
|
+
try {
|
|
453
|
+
raw = fs.readFileSync(p, "utf8");
|
|
454
|
+
} catch (e) {
|
|
455
|
+
throw new Error(`Failed to read ${p}: ${e.message}`);
|
|
456
|
+
}
|
|
457
|
+
try {
|
|
458
|
+
return JSON.parse(raw);
|
|
459
|
+
} catch (e) {
|
|
460
|
+
throw new Error(`Failed to parse ${p}: ${e.message}`);
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
function saveCloudConfig(projectDir, cfg) {
|
|
465
|
+
const p = cloudConfigPath(projectDir);
|
|
466
|
+
fs.mkdirSync(path.dirname(p), { recursive: true });
|
|
467
|
+
fs.writeFileSync(p, JSON.stringify(cfg, null, 2) + "\n");
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
/** Build the JSON patch to merge into the VM's `editor.local.json` so the
|
|
471
|
+
* configured agent picks up the user's local API key on first launch.
|
|
472
|
+
* Returns `null` when the key isn't set locally — the wrapper then prints
|
|
473
|
+
* the interactive-auth fallback line instead of writing an empty patch. */
|
|
474
|
+
function buildEditorLocalEnvPatch(localEnv, agent) {
|
|
475
|
+
const varName = agentEnvVarName(agent);
|
|
476
|
+
const value = localEnv[varName];
|
|
477
|
+
if (!value) return null;
|
|
478
|
+
return { env: { [varName]: value } };
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
/** Parse `docker compose ps --format json` (compose v2 — one JSON object per
|
|
482
|
+
* line) and return the editor service's `State` string, or null when the
|
|
483
|
+
* service is absent / output is unparseable. Two callers shared an identical
|
|
484
|
+
* inline parser; this centralizes the shape so a compose-output change is
|
|
485
|
+
* one update, not two. */
|
|
486
|
+
function parseEditorComposeState(stdout) {
|
|
487
|
+
if (!stdout) return null;
|
|
488
|
+
const lines = String(stdout).trim().split("\n").filter(Boolean);
|
|
489
|
+
for (const line of lines) {
|
|
490
|
+
let svc;
|
|
491
|
+
try {
|
|
492
|
+
svc = JSON.parse(line);
|
|
493
|
+
} catch {
|
|
494
|
+
continue;
|
|
495
|
+
}
|
|
496
|
+
if (svc && svc.Service === "editor") {
|
|
497
|
+
return svc.State || null;
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
return null;
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
/** Translate one of the wrapper's verbs into the exact `gcloud` argv. The
|
|
504
|
+
* switch's job is to keep every per-verb command shape in one reviewable
|
|
505
|
+
* place — tests assert the arrays without ever spawning a process. */
|
|
506
|
+
function buildGcloudArgs(verb, cfg = {}, extras = {}) {
|
|
507
|
+
const { project, zone, instanceName } = cfg;
|
|
508
|
+
switch (verb) {
|
|
509
|
+
case "config-get-project":
|
|
510
|
+
return ["config", "get-value", "project"];
|
|
511
|
+
case "config-get-zone":
|
|
512
|
+
return ["config", "get-value", "compute/zone"];
|
|
513
|
+
case "instances-create":
|
|
514
|
+
// The VM keeps the default ephemeral external IP so apt / docker /
|
|
515
|
+
// NodeSource / npm can reach the public internet during bootstrap.
|
|
516
|
+
// Inbound is still IAP-SSH-only via the firewall rule above; the
|
|
517
|
+
// external IP is for egress, not exposure. `--no-address` was the
|
|
518
|
+
// earlier default but produced a VM that could not install Docker
|
|
519
|
+
// without a Cloud NAT + Cloud Router pair the wrapper does not set up.
|
|
520
|
+
return [
|
|
521
|
+
"compute", "instances", "create", instanceName,
|
|
522
|
+
"--project", project, "--zone", zone,
|
|
523
|
+
"--machine-type=e2-standard-4",
|
|
524
|
+
"--image-family=debian-12",
|
|
525
|
+
"--image-project=debian-cloud",
|
|
526
|
+
"--boot-disk-size=100GB",
|
|
527
|
+
"--boot-disk-type=pd-balanced",
|
|
528
|
+
`--tags=${INSTANCE_TAG}`,
|
|
529
|
+
];
|
|
530
|
+
case "instances-start":
|
|
531
|
+
return ["compute", "instances", "start", instanceName, "--project", project, "--zone", zone];
|
|
532
|
+
case "instances-stop":
|
|
533
|
+
return ["compute", "instances", "stop", instanceName, "--project", project, "--zone", zone];
|
|
534
|
+
case "instances-delete":
|
|
535
|
+
return ["compute", "instances", "delete", instanceName, "--project", project, "--zone", zone, "--quiet"];
|
|
536
|
+
case "instances-describe":
|
|
537
|
+
return ["compute", "instances", "describe", instanceName, "--project", project, "--zone", zone, "--format=json"];
|
|
538
|
+
case "instances-list-tagged":
|
|
539
|
+
return [
|
|
540
|
+
"compute", "instances", "list",
|
|
541
|
+
"--project", project,
|
|
542
|
+
`--filter=tags.items:${INSTANCE_TAG}`,
|
|
543
|
+
"--format=value(name)",
|
|
544
|
+
];
|
|
545
|
+
case "firewall-describe":
|
|
546
|
+
return ["compute", "firewall-rules", "describe", FIREWALL_RULE_NAME, "--project", project, "--format=json"];
|
|
547
|
+
case "firewall-create":
|
|
548
|
+
return [
|
|
549
|
+
"compute", "firewall-rules", "create", FIREWALL_RULE_NAME,
|
|
550
|
+
"--project", project,
|
|
551
|
+
"--direction=INGRESS",
|
|
552
|
+
"--action=ALLOW",
|
|
553
|
+
"--rules=tcp:22",
|
|
554
|
+
"--source-ranges=35.235.240.0/20",
|
|
555
|
+
`--target-tags=${INSTANCE_TAG}`,
|
|
556
|
+
];
|
|
557
|
+
case "firewall-delete":
|
|
558
|
+
return ["compute", "firewall-rules", "delete", FIREWALL_RULE_NAME, "--project", project, "--quiet"];
|
|
559
|
+
case "ssh-probe":
|
|
560
|
+
return [
|
|
561
|
+
"compute", "ssh", instanceName,
|
|
562
|
+
"--project", project, "--zone", zone,
|
|
563
|
+
"--tunnel-through-iap",
|
|
564
|
+
"--command", extras.command,
|
|
565
|
+
];
|
|
566
|
+
case "ssh-script":
|
|
567
|
+
// Stream a script body as base64 over --command so we don't fight
|
|
568
|
+
// shell-quoting through gcloud → ssh → remote bash. The script body
|
|
569
|
+
// is delivered as one argument; the wrapper feeds the base64 into
|
|
570
|
+
// `extras.command` and the caller sets `input` to the script body.
|
|
571
|
+
return [
|
|
572
|
+
"compute", "ssh", instanceName,
|
|
573
|
+
"--project", project, "--zone", zone,
|
|
574
|
+
"--tunnel-through-iap",
|
|
575
|
+
"--command", extras.command,
|
|
576
|
+
];
|
|
577
|
+
case "ssh-tunnel": {
|
|
578
|
+
// Bind BOTH IPv4 and IPv6 loopback for each port. The IPv4 `-L` is
|
|
579
|
+
// load-bearing for everything that explicitly dials 127.0.0.1; the
|
|
580
|
+
// `[::1]` `-L` exists because cloudflared (`tunnel --url
|
|
581
|
+
// http://localhost:N`) resolves `localhost` to `[::1]` first and,
|
|
582
|
+
// with an IPv4-only forward, hits connect:connection-refused until
|
|
583
|
+
// the QUIC stream cancels and the tunnel proc exits (~80% of
|
|
584
|
+
// cloudflared deaths in the 2026-05-21 fleet session). Binding the
|
|
585
|
+
// IPv6 loopback too lets that first dial succeed.
|
|
586
|
+
//
|
|
587
|
+
// The preview forward uses the resolved app port (extras.appPort,
|
|
588
|
+
// default VITE_PORT) on BOTH ends — laptop:<appPort> → VM:<appPort> —
|
|
589
|
+
// so the whole single-user path uses one number for a Vite 5173, a
|
|
590
|
+
// Next.js 3000, anything. The editor `-L` (14199) is unconditional.
|
|
591
|
+
const appPort = extras.appPort != null ? extras.appPort : VITE_PORT;
|
|
592
|
+
return [
|
|
593
|
+
"compute", "ssh", instanceName,
|
|
594
|
+
"--project", project, "--zone", zone,
|
|
595
|
+
"--tunnel-through-iap",
|
|
596
|
+
"--",
|
|
597
|
+
"-L", `${EDITOR_PORT}:127.0.0.1:${EDITOR_PORT}`,
|
|
598
|
+
"-L", `[::1]:${EDITOR_PORT}:127.0.0.1:${EDITOR_PORT}`,
|
|
599
|
+
"-L", `${appPort}:127.0.0.1:${appPort}`,
|
|
600
|
+
"-L", `[::1]:${appPort}:127.0.0.1:${appPort}`,
|
|
601
|
+
"-N",
|
|
602
|
+
];
|
|
603
|
+
}
|
|
604
|
+
default:
|
|
605
|
+
throw new Error(`Unknown gcloud verb: ${verb}`);
|
|
606
|
+
}
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
// =============================================================================
|
|
610
|
+
// Side-effecting helpers — exercised end-to-end by `npm run cloud:up` in
|
|
611
|
+
// real use. Unit tests pin their exports so a refactor cannot drop them.
|
|
612
|
+
// =============================================================================
|
|
613
|
+
|
|
614
|
+
function runGcloud(args, opts = {}) {
|
|
615
|
+
return spawnSync("gcloud", args, {
|
|
616
|
+
stdio: opts.stdio || "inherit",
|
|
617
|
+
input: opts.input,
|
|
618
|
+
encoding: opts.encoding || "utf8",
|
|
619
|
+
});
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
function ensureGcloud() {
|
|
623
|
+
const result = spawnSync("gcloud", ["version"], { stdio: "ignore" });
|
|
624
|
+
if (result.error || result.status !== 0) {
|
|
625
|
+
console.error(
|
|
626
|
+
"gcloud is not on PATH. Install: https://cloud.google.com/sdk/docs/install",
|
|
627
|
+
);
|
|
628
|
+
process.exit(1);
|
|
629
|
+
}
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
function gcloudGetValue(verb) {
|
|
633
|
+
const result = runGcloud(buildGcloudArgs(verb), { stdio: ["ignore", "pipe", "ignore"] });
|
|
634
|
+
if (result.error || result.status !== 0) return null;
|
|
635
|
+
const val = String(result.stdout || "").trim();
|
|
636
|
+
if (!val || val === "(unset)") return null;
|
|
637
|
+
return val;
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
/** Run `gcloud compute instances describe ... --format=json` and return the
|
|
641
|
+
* parsed `.status` field (e.g. "RUNNING", "TERMINATED", "PROVISIONING"), or
|
|
642
|
+
* null when the instance is absent or describe returned unparseable output.
|
|
643
|
+
* Centralizes the describe-and-parse pattern shared by ensureInstance (used
|
|
644
|
+
* twice — initial probe + poll), cmdTunnel, and cmdStatus. */
|
|
645
|
+
function fetchInstanceState(cfg) {
|
|
646
|
+
const result = runGcloud(buildGcloudArgs("instances-describe", cfg), {
|
|
647
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
648
|
+
});
|
|
649
|
+
if (result.status !== 0) return null;
|
|
650
|
+
try {
|
|
651
|
+
const parsed = JSON.parse(result.stdout);
|
|
652
|
+
return parsed && typeof parsed.status === "string" ? parsed.status : null;
|
|
653
|
+
} catch {
|
|
654
|
+
return null;
|
|
655
|
+
}
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
function loadOrInitCloudConfig(projectDir, flags) {
|
|
659
|
+
const existing = loadCloudConfig(projectDir);
|
|
660
|
+
// noTunnel is a per-invocation behavior, never persisted — else a later manual
|
|
661
|
+
// `cloud:up` (zero-arg, reading cloud.json) would silently skip its tunnel.
|
|
662
|
+
const persist = (c) => { const { noTunnel, ...rest } = c; return rest; };
|
|
663
|
+
if (existing) {
|
|
664
|
+
const merged = reconcileProjectExclusivity({ ...existing, ...flags }, flags);
|
|
665
|
+
const toSave = persist(merged);
|
|
666
|
+
if (JSON.stringify(toSave) !== JSON.stringify(existing)) {
|
|
667
|
+
saveCloudConfig(projectDir, toSave);
|
|
668
|
+
}
|
|
669
|
+
return merged;
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
const cfg = reconcileProjectExclusivity({ ...flags }, flags);
|
|
673
|
+
cfg.project = cfg.project || gcloudGetValue("config-get-project");
|
|
674
|
+
cfg.zone = cfg.zone || gcloudGetValue("config-get-zone");
|
|
675
|
+
cfg.agent = cfg.agent || "claude";
|
|
676
|
+
agentEnvVarName(cfg.agent);
|
|
677
|
+
|
|
678
|
+
if (!cfg.repo && !cfg.newProject) {
|
|
679
|
+
throw new Error(
|
|
680
|
+
"--repo <url> (clone an existing project) or --new-project <name> (greenfield) " +
|
|
681
|
+
"is required on first run. Subsequent runs read .codeyam/cloud.json.",
|
|
682
|
+
);
|
|
683
|
+
}
|
|
684
|
+
if (!cfg.project) {
|
|
685
|
+
throw new Error(
|
|
686
|
+
"--project not provided and `gcloud config get-value project` returned no default.\n" +
|
|
687
|
+
"Run `gcloud config set project <id>` or pass --project <id>.",
|
|
688
|
+
);
|
|
689
|
+
}
|
|
690
|
+
if (!cfg.zone) {
|
|
691
|
+
throw new Error(
|
|
692
|
+
"--zone not provided and `gcloud config get-value compute/zone` returned no default.\n" +
|
|
693
|
+
"Run `gcloud config set compute/zone <zone>` or pass --zone <zone>.",
|
|
694
|
+
);
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
cfg.instanceName = cfg.instanceName || defaultInstanceNameFor(cfg.repo || cfg.newProject);
|
|
698
|
+
saveCloudConfig(projectDir, persist(cfg));
|
|
699
|
+
return cfg;
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
async function ensureFirewallRule(cfg) {
|
|
703
|
+
console.error(`Checking firewall rule ${FIREWALL_RULE_NAME}...`);
|
|
704
|
+
const describe = runGcloud(buildGcloudArgs("firewall-describe", cfg), {
|
|
705
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
706
|
+
});
|
|
707
|
+
if (describe.status === 0) {
|
|
708
|
+
console.error(` ✓ ${FIREWALL_RULE_NAME} already exists`);
|
|
709
|
+
return;
|
|
710
|
+
}
|
|
711
|
+
console.error(` creating ${FIREWALL_RULE_NAME}...`);
|
|
712
|
+
const create = runGcloud(buildGcloudArgs("firewall-create", cfg));
|
|
713
|
+
if (create.status !== 0) {
|
|
714
|
+
throw new Error(`Failed to create firewall rule ${FIREWALL_RULE_NAME}`);
|
|
715
|
+
}
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
async function ensureInstance(cfg) {
|
|
719
|
+
console.error(`Checking instance ${cfg.instanceName}...`);
|
|
720
|
+
const initialState = fetchInstanceState(cfg);
|
|
721
|
+
if (initialState === "RUNNING") {
|
|
722
|
+
console.error(` ✓ ${cfg.instanceName} is RUNNING`);
|
|
723
|
+
return;
|
|
724
|
+
}
|
|
725
|
+
if (initialState === "TERMINATED") {
|
|
726
|
+
console.error(` ${cfg.instanceName} is TERMINATED — starting...`);
|
|
727
|
+
const start = runGcloud(buildGcloudArgs("instances-start", cfg));
|
|
728
|
+
if (start.status !== 0) {
|
|
729
|
+
throw new Error(`Failed to start instance ${cfg.instanceName}`);
|
|
730
|
+
}
|
|
731
|
+
} else if (initialState === null) {
|
|
732
|
+
console.error(` ${cfg.instanceName} not found — creating...`);
|
|
733
|
+
const create = runGcloud(buildGcloudArgs("instances-create", cfg));
|
|
734
|
+
if (create.status !== 0) {
|
|
735
|
+
throw new Error(`Failed to create instance ${cfg.instanceName}`);
|
|
736
|
+
}
|
|
737
|
+
} else {
|
|
738
|
+
console.error(` ${cfg.instanceName} is ${initialState}; waiting...`);
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
for (let i = 0; i < 60; i++) {
|
|
742
|
+
if (fetchInstanceState(cfg) === "RUNNING") {
|
|
743
|
+
console.error(` ✓ ${cfg.instanceName} is RUNNING`);
|
|
744
|
+
await waitForIapReady(cfg);
|
|
745
|
+
return;
|
|
746
|
+
}
|
|
747
|
+
await new Promise((r) => setTimeout(r, 2000));
|
|
748
|
+
}
|
|
749
|
+
throw new Error(`Instance ${cfg.instanceName} did not reach RUNNING within 2 minutes`);
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
/** GCE reports the instance as RUNNING the moment the boot disk is attached,
|
|
753
|
+
* but the IAP control plane has its own catalog that updates a few seconds
|
|
754
|
+
* later. The first `gcloud compute ssh --tunnel-through-iap` after a fresh
|
|
755
|
+
* create races that catalog and fails with `[4047: 'Failed to lookup
|
|
756
|
+
* instance']`. Probe an IAP-tunneled SSH that just runs `true` until it
|
|
757
|
+
* succeeds — cheap on the warm path (one ~1s round-trip) and bounded so a
|
|
758
|
+
* genuinely unreachable VM fails loud rather than silently. */
|
|
759
|
+
async function waitForIapReady(cfg) {
|
|
760
|
+
console.error(` waiting for IAP to register ${cfg.instanceName}...`);
|
|
761
|
+
// 180s, not 60s: on a fresh instance the IAP catalog + SSH-key (osLogin)
|
|
762
|
+
// propagation can take well over a minute under fleet load, and each failed
|
|
763
|
+
// probe burns ~25-30s before gcloud gives up — so a 60s budget fit only ~2
|
|
764
|
+
// probes and aborted cloud:up before the VM was ever reachable, leaving a
|
|
765
|
+
// RUNNING-but-unbootstrapped instance.
|
|
766
|
+
const deadline = Date.now() + 180_000;
|
|
767
|
+
let attempts = 0;
|
|
768
|
+
let lastErr = "";
|
|
769
|
+
while (Date.now() < deadline) {
|
|
770
|
+
attempts++;
|
|
771
|
+
const probe = runGcloud(
|
|
772
|
+
buildGcloudArgs("ssh-probe", cfg, { command: "true" }),
|
|
773
|
+
{ stdio: ["ignore", "pipe", "pipe"] },
|
|
774
|
+
);
|
|
775
|
+
if (probe.status === 0) {
|
|
776
|
+
console.error(` ✓ IAP ready (${attempts} ${attempts === 1 ? "probe" : "probes"})`);
|
|
777
|
+
return;
|
|
778
|
+
}
|
|
779
|
+
lastErr = String(probe.stderr || "").trim().split("\n").slice(-1)[0] || "";
|
|
780
|
+
// Exponential-ish backoff: 2s, 3s, 5s, 8s, then cap at 8s.
|
|
781
|
+
const delay = Math.min(2000 * Math.pow(1.5, attempts - 1), 8000);
|
|
782
|
+
await new Promise((r) => setTimeout(r, delay));
|
|
783
|
+
}
|
|
784
|
+
throw new Error(
|
|
785
|
+
`IAP did not become ready for ${cfg.instanceName} within 180s ` +
|
|
786
|
+
`(${attempts} probes; last error: ${lastErr || "unknown"})`,
|
|
787
|
+
);
|
|
788
|
+
}
|
|
789
|
+
|
|
790
|
+
/** Read `process.env[FORWARD_ENV_KEYS_ENV]` (comma-separated key list set by
|
|
791
|
+
* the fleet dashboard) and return the subset of `process.env` that should
|
|
792
|
+
* ride along into the VM's project-bootstrap shell. Whitelisted to the
|
|
793
|
+
* named keys: a fork of the dashboard's launch-form env, never the whole
|
|
794
|
+
* laptop env (which would leak PATH / NODE_OPTIONS / unrelated secrets).
|
|
795
|
+
* Empty / unset → {} so the standalone `cloud:up` path is unchanged. */
|
|
796
|
+
function resolveSetupForwardEnv(env = process.env) {
|
|
797
|
+
const list = env[FORWARD_ENV_KEYS_ENV];
|
|
798
|
+
if (!list) return {};
|
|
799
|
+
const out = {};
|
|
800
|
+
for (const raw of String(list).split(",")) {
|
|
801
|
+
const k = raw.trim();
|
|
802
|
+
if (!k) continue;
|
|
803
|
+
const v = env[k];
|
|
804
|
+
if (v == null || v === "") continue;
|
|
805
|
+
out[k] = v;
|
|
806
|
+
}
|
|
807
|
+
return out;
|
|
808
|
+
}
|
|
809
|
+
|
|
810
|
+
function sshScriptCommand(scriptBody, scriptArgs = [], envVars = {}) {
|
|
811
|
+
const b64 = Buffer.from(scriptBody, "utf8").toString("base64");
|
|
812
|
+
const argsString = scriptArgs.map((a) => `'${String(a).replace(/'/g, "'\\''")}'`).join(" ");
|
|
813
|
+
// Prefix each env var as `KEY='value'` so they're set for the bash -s
|
|
814
|
+
// process only — no persistence in the VM's shell history, no /etc/environment
|
|
815
|
+
// edit. Single-quoted values are safe against shell metacharacters; an
|
|
816
|
+
// embedded apostrophe in the value would be exotic for these (token,
|
|
817
|
+
// bool flag), but escape it the same way as scriptArgs for symmetry.
|
|
818
|
+
const envString = Object.entries(envVars)
|
|
819
|
+
.filter(([, v]) => v !== undefined && v !== null && v !== "")
|
|
820
|
+
.map(([k, v]) => `${k}='${String(v).replace(/'/g, "'\\''")}'`)
|
|
821
|
+
.join(" ");
|
|
822
|
+
const envPrefix = envString ? `${envString} ` : "";
|
|
823
|
+
return `echo '${b64}' | base64 -d | ${envPrefix}bash -s -- ${argsString}`.trim();
|
|
824
|
+
}
|
|
825
|
+
|
|
826
|
+
async function ensureBootstrapped(cfg) {
|
|
827
|
+
console.error("Checking VM bootstrap state...");
|
|
828
|
+
const probe = runGcloud(
|
|
829
|
+
buildGcloudArgs("ssh-probe", cfg, {
|
|
830
|
+
command: "command -v docker >/dev/null && docker image inspect codeyam-editor:local >/dev/null 2>&1",
|
|
831
|
+
}),
|
|
832
|
+
{ stdio: ["ignore", "pipe", "pipe"] },
|
|
833
|
+
);
|
|
834
|
+
if (probe.status === 0) {
|
|
835
|
+
console.error(" ✓ VM already bootstrapped (docker + codeyam-editor:local present)");
|
|
836
|
+
return;
|
|
837
|
+
}
|
|
838
|
+
console.error(" streaming scripts/gcp-bootstrap.sh to VM (this may take ~10 minutes)...");
|
|
839
|
+
const scriptPath = path.join(rootDir, "scripts", "gcp-bootstrap.sh");
|
|
840
|
+
const scriptBody = fs.readFileSync(scriptPath, "utf8");
|
|
841
|
+
// GITHUB_TOKEN is passed through to the bootstrap script so it can clone
|
|
842
|
+
// a private editor repo (the bootstrap clones $REPO_URL to build the
|
|
843
|
+
// codeyam-editor:local image). Without this the git clone hits the
|
|
844
|
+
// "could not read Username for 'https://github.com'" wall whenever the
|
|
845
|
+
// repo isn't public — codeyam-editor itself today.
|
|
846
|
+
//
|
|
847
|
+
// Build the editor image from the EDITOR source (cfg.editorRepo/editorBranch),
|
|
848
|
+
// decoupled from the client project (cfg.repo/branch) which gets cloned into
|
|
849
|
+
// /workspace by ensureProjectBootstrapped. --branch matters when testing
|
|
850
|
+
// wrapper / Dockerfile patches from a feature branch; without it the image
|
|
851
|
+
// build uses the editor repo's default branch.
|
|
852
|
+
const bootstrapArgs = editorImageBootstrapArgs(cfg);
|
|
853
|
+
// CODEYAM_GHCR_TOKEN lets the bootstrap `docker login ghcr.io` and pull the
|
|
854
|
+
// PRIVATE prebuilt base + branch-binary packages — without it every pull
|
|
855
|
+
// 403s and the bootstrap silently slow-builds (~14 min) from source.
|
|
856
|
+
// CODEYAM_GHCR_USER is an optional login-username override (the bootstrap
|
|
857
|
+
// otherwise derives it from the registry owner). Empty values are dropped by
|
|
858
|
+
// sshScriptCommand, so the no-registry / local-only path is unchanged.
|
|
859
|
+
const command = sshScriptCommand(scriptBody, bootstrapArgs, {
|
|
860
|
+
GITHUB_TOKEN: resolveGithubToken(),
|
|
861
|
+
CODEYAM_GHCR_TOKEN: resolveGhcrToken(),
|
|
862
|
+
CODEYAM_GHCR_USER: process.env.CODEYAM_GHCR_USER || "",
|
|
863
|
+
});
|
|
864
|
+
const result = runGcloud(
|
|
865
|
+
buildGcloudArgs("ssh-script", cfg, { command }),
|
|
866
|
+
{ stdio: "inherit" },
|
|
867
|
+
);
|
|
868
|
+
if (result.status !== 0) {
|
|
869
|
+
throw new Error("scripts/gcp-bootstrap.sh failed on the VM");
|
|
870
|
+
}
|
|
871
|
+
}
|
|
872
|
+
|
|
873
|
+
async function ensureProjectBootstrapped(cfg) {
|
|
874
|
+
const repoName = resolveProject(cfg).name;
|
|
875
|
+
console.error(`Checking project bootstrap state for ${repoName}...`);
|
|
876
|
+
// Probe both .codeyam/ (created by `editor init`) AND
|
|
877
|
+
// docker-compose.cloud.yml (written near the end of the script). A previous
|
|
878
|
+
// run that crashed between init and the compose write would leave the
|
|
879
|
+
// first marker but not the second; the old probe wrongly considered the
|
|
880
|
+
// project bootstrapped and the next `cloud:up` then hit
|
|
881
|
+
// "open docker-compose.cloud.yml: no such file or directory".
|
|
882
|
+
// The probe is the seam where idempotency lives — make sure it gates on
|
|
883
|
+
// the LAST artifact the script produces, not the first.
|
|
884
|
+
const probe = runGcloud(
|
|
885
|
+
buildGcloudArgs("ssh-probe", cfg, {
|
|
886
|
+
command:
|
|
887
|
+
`test -d ~/projects/${repoName}/.codeyam ` +
|
|
888
|
+
`&& test -f ~/projects/${repoName}/docker-compose.cloud.yml`,
|
|
889
|
+
}),
|
|
890
|
+
{ stdio: ["ignore", "pipe", "pipe"] },
|
|
891
|
+
);
|
|
892
|
+
if (probe.status === 0) {
|
|
893
|
+
console.error(` ✓ ${repoName} already bootstrapped on VM`);
|
|
894
|
+
return;
|
|
895
|
+
}
|
|
896
|
+
console.error(` streaming scripts/gcp-project-bootstrap.sh to VM for ${cfg.repo}...`);
|
|
897
|
+
const scriptPath = path.join(rootDir, "scripts", "gcp-project-bootstrap.sh");
|
|
898
|
+
const scriptBody = fs.readFileSync(scriptPath, "utf8");
|
|
899
|
+
const scriptArgs = clientProjectBootstrapArgs(cfg);
|
|
900
|
+
// Forward the laptop's git author identity so the rendered compose file
|
|
901
|
+
// carries CY_GIT_USER_{NAME,EMAIL} → the entrypoint writes them to
|
|
902
|
+
// `git config --global` → plan_claim_refs::create_claim's commit-tree call
|
|
903
|
+
// succeeds. Empty strings are fine (laptop has no identity, e.g. a CI
|
|
904
|
+
// runner); the entrypoint warns and proceeds.
|
|
905
|
+
const laptopGit = resolveLaptopGitIdentity();
|
|
906
|
+
// Forward the operator's launch-form env keys (DATABASE_URL, etc.) into the
|
|
907
|
+
// bootstrap shell so the bootstrap-time `npm run setup` sees them on the
|
|
908
|
+
// first try — without this, Prisma `db:push`/`db:seed` fail for lack of
|
|
909
|
+
// env and the bootstrap's fail-soft branch leaves the app schema-less until
|
|
910
|
+
// the post-add `reapplyVmEnv` re-fires setup. See
|
|
911
|
+
// `improve37-launch-form-env-injected-into-cloud-up-bootstrap-not-just-applied-after`.
|
|
912
|
+
// Whitelisted by the dashboard via CY_FORWARD_ENV_KEYS — empty on the
|
|
913
|
+
// standalone `cloud:up` path, so that flow is unchanged.
|
|
914
|
+
const forwardEnv = resolveSetupForwardEnv();
|
|
915
|
+
const command = sshScriptCommand(scriptBody, scriptArgs, {
|
|
916
|
+
GITHUB_TOKEN: resolveGithubToken(),
|
|
917
|
+
CY_GIT_USER_NAME: laptopGit.name,
|
|
918
|
+
CY_GIT_USER_EMAIL: laptopGit.email,
|
|
919
|
+
...forwardEnv,
|
|
920
|
+
});
|
|
921
|
+
const result = runGcloud(
|
|
922
|
+
buildGcloudArgs("ssh-script", cfg, { command }),
|
|
923
|
+
{ stdio: "inherit" },
|
|
924
|
+
);
|
|
925
|
+
if (result.status !== 0) {
|
|
926
|
+
throw new Error("scripts/gcp-project-bootstrap.sh failed on the VM");
|
|
927
|
+
}
|
|
928
|
+
}
|
|
929
|
+
|
|
930
|
+
async function ensureEditorContainerUp(cfg) {
|
|
931
|
+
const repoName = resolveProject(cfg).name;
|
|
932
|
+
// -f docker-compose.cloud.yml everywhere: the editor repo's own
|
|
933
|
+
// docker-compose.yml does `build: .` from source and would shadow the
|
|
934
|
+
// cloud variant when self-hosting (see gcp-project-bootstrap.sh).
|
|
935
|
+
const composeCmd = `docker compose -f docker-compose.cloud.yml`;
|
|
936
|
+
console.error("Checking editor container state...");
|
|
937
|
+
const probe = runGcloud(
|
|
938
|
+
buildGcloudArgs("ssh-probe", cfg, {
|
|
939
|
+
command: `cd ~/projects/${repoName} && ${composeCmd} ps --format json 2>/dev/null || true`,
|
|
940
|
+
}),
|
|
941
|
+
{ stdio: ["ignore", "pipe", "ignore"] },
|
|
942
|
+
);
|
|
943
|
+
if (probe.status === 0 && parseEditorComposeState(probe.stdout) === "running") {
|
|
944
|
+
console.error(" ✓ editor container is already running");
|
|
945
|
+
return;
|
|
946
|
+
}
|
|
947
|
+
console.error(" starting editor container (docker compose up -d)...");
|
|
948
|
+
const up = runGcloud(
|
|
949
|
+
buildGcloudArgs("ssh-probe", cfg, {
|
|
950
|
+
command: `cd ~/projects/${repoName} && ${composeCmd} up -d`,
|
|
951
|
+
}),
|
|
952
|
+
{ stdio: "inherit" },
|
|
953
|
+
);
|
|
954
|
+
if (up.status !== 0) {
|
|
955
|
+
throw new Error("docker compose up -d failed on the VM");
|
|
956
|
+
}
|
|
957
|
+
}
|
|
958
|
+
|
|
959
|
+
/** Read the laptop's git author identity (`user.name`, `user.email`) so
|
|
960
|
+
* cloud:up can forward it to the VM's container entrypoint, which writes
|
|
961
|
+
* it to `git config --global`. Without identity in the container,
|
|
962
|
+
* plan_claim_refs::create_claim's `git commit-tree` fails with "Author
|
|
963
|
+
* identity unknown" and plan-claim refs are silently never created — the
|
|
964
|
+
* In-Flight panel goes blind to that VM's work. Returns `{ name, email }`
|
|
965
|
+
* with empty strings for whichever field is unset locally; the entrypoint
|
|
966
|
+
* fails-soft on either-empty, so a CI runner without git config simply
|
|
967
|
+
* warns and boots. The two `git config` calls are cheap (single fs read)
|
|
968
|
+
* and not memoized so a user can `git config --global user.email …`
|
|
969
|
+
* between operations and have it picked up on the next cloud:up. */
|
|
970
|
+
function resolveLaptopGitIdentity(spawnImpl = spawnSync) {
|
|
971
|
+
const read = (key) => {
|
|
972
|
+
const r = spawnImpl("git", ["config", "--get", key], { encoding: "utf8" });
|
|
973
|
+
if (r.status !== 0) return "";
|
|
974
|
+
return String(r.stdout || "").trim();
|
|
975
|
+
};
|
|
976
|
+
return { name: read("user.name"), email: read("user.email") };
|
|
977
|
+
}
|
|
978
|
+
|
|
979
|
+
/** Look up a GitHub token from local environment, falling back to `gh auth
|
|
980
|
+
* token`. Returns empty string if neither is available — callers should
|
|
981
|
+
* treat empty as "no auth" and either skip or print a hint. Memoizing
|
|
982
|
+
* intentionally not done: the cost of one `gh` subprocess per cloud:up is
|
|
983
|
+
* trivial and lets a user `gh auth login` between operations. */
|
|
984
|
+
function resolveGithubToken() {
|
|
985
|
+
const env = process.env.GITHUB_TOKEN || process.env.GH_TOKEN;
|
|
986
|
+
if (env) return env;
|
|
987
|
+
const ghOut = spawnSync("gh", ["auth", "token"], { encoding: "utf8" });
|
|
988
|
+
if (ghOut.status === 0) {
|
|
989
|
+
return ghOut.stdout.trim();
|
|
990
|
+
}
|
|
991
|
+
return "";
|
|
992
|
+
}
|
|
993
|
+
|
|
994
|
+
/** A `read:packages`-scoped token the VM bootstrap uses to `docker login
|
|
995
|
+
* ghcr.io` and pull the PRIVATE prebuilt cloud-smoke base + branch-binary
|
|
996
|
+
* packages. Prefer an explicit CODEYAM_GHCR_TOKEN (the operator's scoped
|
|
997
|
+
* fleet secret); fall back to the same GitHub token already used for the
|
|
998
|
+
* git clone, which carries read:packages for a classic PAT / `gh auth token`
|
|
999
|
+
* in the common fleet setup. Empty string when neither is available — the
|
|
1000
|
+
* bootstrap then skips login and the private pulls fall back to a source
|
|
1001
|
+
* build (today's behavior, never worse). Threaded into gcp-bootstrap.sh via
|
|
1002
|
+
* the env channel in ensureBootstrapped; never passed as an argv. */
|
|
1003
|
+
function resolveGhcrToken() {
|
|
1004
|
+
return process.env.CODEYAM_GHCR_TOKEN || resolveGithubToken();
|
|
1005
|
+
}
|
|
1006
|
+
|
|
1007
|
+
/** Write the laptop's GitHub token into ~/.codeyam-cloud-git-credentials on
|
|
1008
|
+
* the VM so the cloud compose can mount it into /root/.git-credentials —
|
|
1009
|
+
* without this, claude / codex / gemini inside the container can clone the
|
|
1010
|
+
* initial repo (the wrapper does that step out-of-band with a one-shot
|
|
1011
|
+
* inlined URL) but cannot `git pull` / `git push` afterward. The file is
|
|
1012
|
+
* chmod 600 and never committed; if the local environment has no token,
|
|
1013
|
+
* the placeholder file from gcp-project-bootstrap.sh stays in place. */
|
|
1014
|
+
async function seedGithubCredentials(cfg) {
|
|
1015
|
+
const token = resolveGithubToken();
|
|
1016
|
+
if (!token) {
|
|
1017
|
+
console.error(
|
|
1018
|
+
"\nNo GITHUB_TOKEN / GH_TOKEN set and `gh auth token` is unavailable —\n" +
|
|
1019
|
+
"claude inside the cloud container will not be able to git pull/push.\n" +
|
|
1020
|
+
"Export GITHUB_TOKEN or run `gh auth login` locally, then `cloud:up` again.\n",
|
|
1021
|
+
);
|
|
1022
|
+
return;
|
|
1023
|
+
}
|
|
1024
|
+
console.error("Writing GitHub token into ~/.codeyam-cloud-git-credentials on the VM...");
|
|
1025
|
+
const credLine = `https://x-access-token:${token}@github.com`;
|
|
1026
|
+
// Quote the token through one shell layer (gcloud ssh --command); the
|
|
1027
|
+
// value is base64'd to keep it out of the shell history.
|
|
1028
|
+
const b64 = Buffer.from(`${credLine}\n`).toString("base64");
|
|
1029
|
+
const remoteCmd =
|
|
1030
|
+
`f=$HOME/.codeyam-cloud-git-credentials && ` +
|
|
1031
|
+
`echo ${b64} | base64 -d > $f && chmod 600 $f && echo OK`;
|
|
1032
|
+
const result = runGcloud(
|
|
1033
|
+
buildGcloudArgs("ssh-probe", cfg, { command: remoteCmd }),
|
|
1034
|
+
{ stdio: ["ignore", "pipe", "inherit"] },
|
|
1035
|
+
);
|
|
1036
|
+
if (result.status !== 0) {
|
|
1037
|
+
throw new Error("Failed to write ~/.codeyam-cloud-git-credentials on the VM");
|
|
1038
|
+
}
|
|
1039
|
+
}
|
|
1040
|
+
|
|
1041
|
+
/** Best-effort read of the VM's resolved app dev-server port so cloud:up's
|
|
1042
|
+
* laptop `-L` forward, readiness probe, and previewOrigin seed all target the
|
|
1043
|
+
* port the app actually serves on instead of a hardcoded 5173. Cats the VM's
|
|
1044
|
+
* editor.json and editor.local.json in one SSH round-trip and resolves via
|
|
1045
|
+
* `resolveConfiguredAppPort`. Any failure (SSH error, missing/corrupt JSON)
|
|
1046
|
+
* falls back to `VITE_PORT` — the same default `resolved_port()` lands on for
|
|
1047
|
+
* a portless config, so a 5173/greenfield project sees no behavior change. */
|
|
1048
|
+
async function readVmAppPort(cfg) {
|
|
1049
|
+
const repoName = resolveProject(cfg).name;
|
|
1050
|
+
const base = `~/projects/${repoName}/.codeyam`;
|
|
1051
|
+
const read = runGcloud(
|
|
1052
|
+
buildGcloudArgs("ssh-probe", cfg, {
|
|
1053
|
+
command:
|
|
1054
|
+
`cat ${base}/editor.json 2>/dev/null || echo '{}'; ` +
|
|
1055
|
+
`echo '<<<LOCAL>>>'; ` +
|
|
1056
|
+
`cat ${base}/editor.local.json 2>/dev/null || echo '{}'`,
|
|
1057
|
+
}),
|
|
1058
|
+
{ stdio: ["ignore", "pipe", "ignore"] },
|
|
1059
|
+
);
|
|
1060
|
+
if (read.status !== 0 || typeof read.stdout !== "string") return VITE_PORT;
|
|
1061
|
+
const parse = (s) => {
|
|
1062
|
+
try {
|
|
1063
|
+
const v = JSON.parse(s);
|
|
1064
|
+
return v && typeof v === "object" && !Array.isArray(v) ? v : {};
|
|
1065
|
+
} catch {
|
|
1066
|
+
return {};
|
|
1067
|
+
}
|
|
1068
|
+
};
|
|
1069
|
+
const [baseJson = "", localJson = ""] = read.stdout.split("<<<LOCAL>>>");
|
|
1070
|
+
return resolveConfiguredAppPort(parse(baseJson), parse(localJson));
|
|
1071
|
+
}
|
|
1072
|
+
|
|
1073
|
+
/** Build the shell command that seeds a JSON patch into a host-side
|
|
1074
|
+
* `editor.local.json` over SSH. The pre-chown matters: the file lives at
|
|
1075
|
+
* `~/projects/<repo>/.codeyam/editor.local.json` on the VM host, but it is
|
|
1076
|
+
* bind-mounted into the running container at `/workspace/.codeyam/`. When
|
|
1077
|
+
* the container writes the file (e.g. provider-auth updates, dashboard
|
|
1078
|
+
* edits), the write runs as root inside the container, so the host file
|
|
1079
|
+
* lands `root:root 0600` on the bind-mount. The next `cloud:up` —
|
|
1080
|
+
* triggered by dashboard Reconfigure — runs as the unprivileged SSH user
|
|
1081
|
+
* (`jaredcosulich` on fleet) and EACCES'es trying to overwrite it. The
|
|
1082
|
+
* `sudo chown $USER:$USER ${remotePath} 2>/dev/null || true` flips
|
|
1083
|
+
* ownership back when the file is root-owned, no-ops when the file
|
|
1084
|
+
* doesn't exist yet (`|| true` swallows the missing-file error), and
|
|
1085
|
+
* silently fails on the rare missing-sudo misconfig (so we never make
|
|
1086
|
+
* things worse than they were). Leaving the file under SSH-user
|
|
1087
|
+
* ownership permanently also keeps subsequent host-side reads
|
|
1088
|
+
* (`docker compose` `.env` interpolation, operator scripts) from
|
|
1089
|
+
* EACCESsing. Sister fix to `buildEnvApplyCommand` in launch-config.js
|
|
1090
|
+
* (which solves the symmetric `.env` problem with
|
|
1091
|
+
* `chown --reference=/workspace`). Pure; no I/O. */
|
|
1092
|
+
function buildEditorLocalSeedWriteCommand({ repoName, remotePath, b64 }) {
|
|
1093
|
+
return (
|
|
1094
|
+
`sudo chown $USER:$USER ${remotePath} 2>/dev/null || true && ` +
|
|
1095
|
+
`mkdir -p ~/projects/${repoName}/.codeyam && ` +
|
|
1096
|
+
`echo '${b64}' | base64 -d > ${remotePath}`
|
|
1097
|
+
);
|
|
1098
|
+
}
|
|
1099
|
+
|
|
1100
|
+
/** Seed a *bootstrap* `previewOrigin` into the VM's `editor.local.json` so the
|
|
1101
|
+
* Live Preview iframe URL the laptop browser loads is decoupled from the
|
|
1102
|
+
* in-VM dev-server port. `appPort` is the VM's resolved app port (see
|
|
1103
|
+
* readVmAppPort); it defaults to `VITE_PORT` for a Vite/greenfield app or any
|
|
1104
|
+
* unresolvable config. Two topologies:
|
|
1105
|
+
*
|
|
1106
|
+
* - SSH-tunneled laptop user: the seed is `http://localhost:${appPort}`,
|
|
1107
|
+
* which the user's `-L ${appPort}:…` tunnel (openTunnel) forwards into the
|
|
1108
|
+
* VM. Works for a Vite 5173, a Next.js 3000, anything.
|
|
1109
|
+
* - Operator public trycloudflare URL: a localhost origin points at the laptop
|
|
1110
|
+
* itself, not the VM, so the iframe sticks on "Reconnecting Live Preview"
|
|
1111
|
+
* forever. The fix is a sibling `<N>-preview` cloudflared tunnel; once the
|
|
1112
|
+
* operator's cloudflared-monitor publishes its URL, that public origin is
|
|
1113
|
+
* seeded instead (see resolveSeedPreviewOrigin).
|
|
1114
|
+
*
|
|
1115
|
+
* This is the bootstrap value only — the cloudflared-monitor owns the
|
|
1116
|
+
* steady-state value and rewrites it on every respawn (matching our
|
|
1117
|
+
* `_managedPreviewOrigin` sentinel). We stamp the sentinel here too so the
|
|
1118
|
+
* monitor doesn't mistake the cloud:up seed for a user pin. The
|
|
1119
|
+
* skip-on-already-set guard stays: a non-empty previewOrigin (whether a user
|
|
1120
|
+
* pin or a monitor write) is left untouched on every `cloud:up`. */
|
|
1121
|
+
async function seedPreviewOrigin(cfg, appPort = VITE_PORT) {
|
|
1122
|
+
const repoName = resolveProject(cfg).name;
|
|
1123
|
+
const remotePath = `~/projects/${repoName}/.codeyam/editor.local.json`;
|
|
1124
|
+
const seed = resolveSeedPreviewOrigin(
|
|
1125
|
+
cfg.instanceName,
|
|
1126
|
+
readCloudflaredUrls(),
|
|
1127
|
+
appPort,
|
|
1128
|
+
);
|
|
1129
|
+
|
|
1130
|
+
const read = runGcloud(
|
|
1131
|
+
buildGcloudArgs("ssh-probe", cfg, {
|
|
1132
|
+
command: `cat ${remotePath} 2>/dev/null || echo '{}'`,
|
|
1133
|
+
}),
|
|
1134
|
+
{ stdio: ["ignore", "pipe", "ignore"] },
|
|
1135
|
+
);
|
|
1136
|
+
let current = {};
|
|
1137
|
+
if (read.status === 0) {
|
|
1138
|
+
try {
|
|
1139
|
+
const parsed = JSON.parse(read.stdout);
|
|
1140
|
+
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
|
|
1141
|
+
current = parsed;
|
|
1142
|
+
}
|
|
1143
|
+
} catch {
|
|
1144
|
+
// Unparseable remote content is treated as `{}` — overwriting a
|
|
1145
|
+
// corrupt local override file with a known-good one is the safer
|
|
1146
|
+
// move than refusing to seed at all.
|
|
1147
|
+
}
|
|
1148
|
+
}
|
|
1149
|
+
if (typeof current.previewOrigin === "string" && current.previewOrigin.length > 0) {
|
|
1150
|
+
return;
|
|
1151
|
+
}
|
|
1152
|
+
|
|
1153
|
+
console.error(`Seeding previewOrigin=${seed.previewOrigin} into ${remotePath}...`);
|
|
1154
|
+
const merged = deepMergeJson(current, seed);
|
|
1155
|
+
const mergedJson = JSON.stringify(merged, null, 2);
|
|
1156
|
+
const b64 = Buffer.from(mergedJson, "utf8").toString("base64");
|
|
1157
|
+
const write = runGcloud(
|
|
1158
|
+
buildGcloudArgs("ssh-probe", cfg, {
|
|
1159
|
+
command: buildEditorLocalSeedWriteCommand({ repoName, remotePath, b64 }),
|
|
1160
|
+
}),
|
|
1161
|
+
{ stdio: ["ignore", "inherit", "inherit"] },
|
|
1162
|
+
);
|
|
1163
|
+
if (write.status !== 0) {
|
|
1164
|
+
throw new Error(`Failed to write ${remotePath} on VM`);
|
|
1165
|
+
}
|
|
1166
|
+
}
|
|
1167
|
+
|
|
1168
|
+
async function seedAgentApiKey(cfg) {
|
|
1169
|
+
const patch = buildEditorLocalEnvPatch(process.env, cfg.agent);
|
|
1170
|
+
if (!patch) {
|
|
1171
|
+
console.error(
|
|
1172
|
+
`\nNo ${agentEnvVarName(cfg.agent)} set locally — skipping agent auth seed.\n` +
|
|
1173
|
+
`Authenticate ${cfg.agent} later with:\n` +
|
|
1174
|
+
` gcloud compute ssh ${cfg.instanceName} --project ${cfg.project} --zone ${cfg.zone} ` +
|
|
1175
|
+
`--tunnel-through-iap -- ${cfg.agent} auth login\n`,
|
|
1176
|
+
);
|
|
1177
|
+
return;
|
|
1178
|
+
}
|
|
1179
|
+
const repoName = resolveProject(cfg).name;
|
|
1180
|
+
const remotePath = `~/projects/${repoName}/.codeyam/editor.local.json`;
|
|
1181
|
+
console.error(`Seeding ${agentEnvVarName(cfg.agent)} into ${remotePath}...`);
|
|
1182
|
+
|
|
1183
|
+
const read = runGcloud(
|
|
1184
|
+
buildGcloudArgs("ssh-probe", cfg, {
|
|
1185
|
+
command: `cat ${remotePath} 2>/dev/null || echo '{}'`,
|
|
1186
|
+
}),
|
|
1187
|
+
{ stdio: ["ignore", "pipe", "ignore"] },
|
|
1188
|
+
);
|
|
1189
|
+
let current = {};
|
|
1190
|
+
if (read.status === 0) {
|
|
1191
|
+
try {
|
|
1192
|
+
const parsed = JSON.parse(read.stdout);
|
|
1193
|
+
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
|
|
1194
|
+
current = parsed;
|
|
1195
|
+
}
|
|
1196
|
+
} catch {
|
|
1197
|
+
// Treat unparseable remote content as `{}` — overwriting a corrupt
|
|
1198
|
+
// local override file with a known-good one is the safer move than
|
|
1199
|
+
// refusing to seed at all.
|
|
1200
|
+
}
|
|
1201
|
+
}
|
|
1202
|
+
const merged = deepMergeJson(current, patch);
|
|
1203
|
+
const mergedJson = JSON.stringify(merged, null, 2);
|
|
1204
|
+
const b64 = Buffer.from(mergedJson, "utf8").toString("base64");
|
|
1205
|
+
const write = runGcloud(
|
|
1206
|
+
buildGcloudArgs("ssh-probe", cfg, {
|
|
1207
|
+
command: buildEditorLocalSeedWriteCommand({ repoName, remotePath, b64 }),
|
|
1208
|
+
}),
|
|
1209
|
+
{ stdio: ["ignore", "inherit", "inherit"] },
|
|
1210
|
+
);
|
|
1211
|
+
if (write.status !== 0) {
|
|
1212
|
+
throw new Error(`Failed to write ${remotePath} on VM`);
|
|
1213
|
+
}
|
|
1214
|
+
}
|
|
1215
|
+
|
|
1216
|
+
async function openTunnel(cfg, appPort = VITE_PORT) {
|
|
1217
|
+
console.error(`Opening IAP tunnel to ${cfg.instanceName}...`);
|
|
1218
|
+
const child = spawn("gcloud", buildGcloudArgs("ssh-tunnel", cfg, { appPort }), { stdio: "inherit" });
|
|
1219
|
+
const readyEditor = await waitForPort(EDITOR_PORT, 30000, 500);
|
|
1220
|
+
const readyApp = await waitForPort(appPort, 30000, 500);
|
|
1221
|
+
if (!readyEditor || !readyApp) {
|
|
1222
|
+
console.error(
|
|
1223
|
+
`Warning: tunnel ports not reachable on localhost (${EDITOR_PORT}=${readyEditor}, ${appPort}=${readyApp}).`,
|
|
1224
|
+
);
|
|
1225
|
+
}
|
|
1226
|
+
console.error(` Editor UI: http://localhost:${EDITOR_PORT}`);
|
|
1227
|
+
console.error(` Live Preview: http://localhost:${appPort}`);
|
|
1228
|
+
console.error(" (Ctrl+C to tear down the tunnel)");
|
|
1229
|
+
return child;
|
|
1230
|
+
}
|
|
1231
|
+
|
|
1232
|
+
function attachKeepAliveAndTeardown(child) {
|
|
1233
|
+
const keepAlive = setInterval(() => {}, 60_000);
|
|
1234
|
+
process.on("SIGINT", () => {
|
|
1235
|
+
clearInterval(keepAlive);
|
|
1236
|
+
killChildProcess(child);
|
|
1237
|
+
process.exit(0);
|
|
1238
|
+
});
|
|
1239
|
+
}
|
|
1240
|
+
|
|
1241
|
+
function promptForInstanceName(expected) {
|
|
1242
|
+
return new Promise((resolve) => {
|
|
1243
|
+
const rl = readline.createInterface({
|
|
1244
|
+
input: process.stdin,
|
|
1245
|
+
output: process.stderr,
|
|
1246
|
+
});
|
|
1247
|
+
rl.question(
|
|
1248
|
+
`Type the instance name '${expected}' to confirm destroy: `,
|
|
1249
|
+
(ans) => {
|
|
1250
|
+
rl.close();
|
|
1251
|
+
resolve(ans.trim() === expected);
|
|
1252
|
+
},
|
|
1253
|
+
);
|
|
1254
|
+
});
|
|
1255
|
+
}
|
|
1256
|
+
|
|
1257
|
+
// =============================================================================
|
|
1258
|
+
// Subcommand handlers
|
|
1259
|
+
// =============================================================================
|
|
1260
|
+
|
|
1261
|
+
async function cmdUp(args) {
|
|
1262
|
+
ensureGcloud();
|
|
1263
|
+
const flags = parseFlags(args);
|
|
1264
|
+
const cfg = loadOrInitCloudConfig(rootDir, flags);
|
|
1265
|
+
// Fail loud BEFORE creating a GCE instance if an unprovisionable editor
|
|
1266
|
+
// variant was requested — otherwise we'd spin up a VM, build a dev image,
|
|
1267
|
+
// and silently ignore the operator's staging/production intent.
|
|
1268
|
+
assertVariantProvisionable(resolveEditorConfig(cfg).editorVariant);
|
|
1269
|
+
|
|
1270
|
+
await ensureFirewallRule(cfg);
|
|
1271
|
+
await ensureInstance(cfg);
|
|
1272
|
+
await ensureBootstrapped(cfg);
|
|
1273
|
+
await ensureProjectBootstrapped(cfg);
|
|
1274
|
+
// Resolve the app's dev-server port from the VM's now-bootstrapped config so
|
|
1275
|
+
// the previewOrigin seed and the laptop tunnel both target it (not 5173).
|
|
1276
|
+
const appPort = await readVmAppPort(cfg);
|
|
1277
|
+
await seedPreviewOrigin(cfg, appPort);
|
|
1278
|
+
// Write the GitHub token BEFORE compose up so the credentials file exists
|
|
1279
|
+
// when the bind-mount happens — bind-mounting a missing file would silently
|
|
1280
|
+
// create a directory instead and the agent's first `git pull` would fail.
|
|
1281
|
+
await seedGithubCredentials(cfg);
|
|
1282
|
+
await ensureEditorContainerUp(cfg);
|
|
1283
|
+
await seedAgentApiKey(cfg);
|
|
1284
|
+
// Roster the VM into the fleet dashboard NOW that it's provisioned and the
|
|
1285
|
+
// editor container is up. Runs BEFORE the --no-tunnel early-return so the
|
|
1286
|
+
// dashboard's own `cloud:up --no-tunnel` Add path also rosters (idempotent
|
|
1287
|
+
// with the dashboard's onSuccess roster — a double-add is a harmless no-op).
|
|
1288
|
+
rosterProvisionedVm(cfg.instanceName);
|
|
1289
|
+
// --no-tunnel: provisioning-only mode (the fleet dashboard opens its own
|
|
1290
|
+
// per-VM tunnel). Skipping openTunnel lets cloud:up EXIT cleanly instead of
|
|
1291
|
+
// hanging on a tunnel, and — critically for parallel multi-add — avoids N
|
|
1292
|
+
// invocations all contending on the fixed local ports 14199/5173.
|
|
1293
|
+
if (cfg.noTunnel) {
|
|
1294
|
+
console.error("--no-tunnel: provisioning complete; skipping local tunnel + browser open.");
|
|
1295
|
+
return;
|
|
1296
|
+
}
|
|
1297
|
+
const tunnelChild = await openTunnel(cfg, appPort);
|
|
1298
|
+
open(`http://localhost:${EDITOR_PORT}`);
|
|
1299
|
+
attachKeepAliveAndTeardown(tunnelChild);
|
|
1300
|
+
}
|
|
1301
|
+
|
|
1302
|
+
async function cmdTunnel(args) {
|
|
1303
|
+
ensureGcloud();
|
|
1304
|
+
const flags = parseFlags(args);
|
|
1305
|
+
const existing = loadCloudConfig(rootDir);
|
|
1306
|
+
if (!existing) {
|
|
1307
|
+
throw new Error(
|
|
1308
|
+
"No .codeyam/cloud.json found. Run `npm run cloud:up -- --repo <url>` first.",
|
|
1309
|
+
);
|
|
1310
|
+
}
|
|
1311
|
+
const cfg = { ...existing, ...flags };
|
|
1312
|
+
|
|
1313
|
+
const state = fetchInstanceState(cfg);
|
|
1314
|
+
if (state === null) {
|
|
1315
|
+
throw new Error(
|
|
1316
|
+
`Instance ${cfg.instanceName} not found in project ${cfg.project} / zone ${cfg.zone}. ` +
|
|
1317
|
+
"Has it been deleted? Try `npm run cloud:up`.",
|
|
1318
|
+
);
|
|
1319
|
+
}
|
|
1320
|
+
if (state !== "RUNNING") {
|
|
1321
|
+
throw new Error(
|
|
1322
|
+
`Instance ${cfg.instanceName} is ${state}; run \`npm run cloud:up\` to start it.`,
|
|
1323
|
+
);
|
|
1324
|
+
}
|
|
1325
|
+
|
|
1326
|
+
// Forward the laptop preview `-L` to the VM's resolved app port, not 5173.
|
|
1327
|
+
const appPort = await readVmAppPort(cfg);
|
|
1328
|
+
const tunnelChild = await openTunnel(cfg, appPort);
|
|
1329
|
+
open(`http://localhost:${EDITOR_PORT}`);
|
|
1330
|
+
attachKeepAliveAndTeardown(tunnelChild);
|
|
1331
|
+
}
|
|
1332
|
+
|
|
1333
|
+
async function cmdStop(args) {
|
|
1334
|
+
ensureGcloud();
|
|
1335
|
+
parseFlags(args);
|
|
1336
|
+
const cfg = loadCloudConfig(rootDir);
|
|
1337
|
+
if (!cfg) {
|
|
1338
|
+
throw new Error("No .codeyam/cloud.json found. Nothing to stop.");
|
|
1339
|
+
}
|
|
1340
|
+
console.error(`Stopping ${cfg.instanceName}...`);
|
|
1341
|
+
const result = runGcloud(buildGcloudArgs("instances-stop", cfg));
|
|
1342
|
+
if (result.status !== 0) {
|
|
1343
|
+
throw new Error(`Failed to stop instance ${cfg.instanceName}`);
|
|
1344
|
+
}
|
|
1345
|
+
console.error(
|
|
1346
|
+
`\n ✓ ${cfg.instanceName} is stopped.\n` +
|
|
1347
|
+
" Persistent-disk billing continues (~$10/month for 100 GB pd-balanced).\n" +
|
|
1348
|
+
" Resume with `npm run cloud:up`.\n",
|
|
1349
|
+
);
|
|
1350
|
+
}
|
|
1351
|
+
|
|
1352
|
+
async function cmdDestroy(args) {
|
|
1353
|
+
ensureGcloud();
|
|
1354
|
+
parseFlags(args);
|
|
1355
|
+
const cfg = loadCloudConfig(rootDir);
|
|
1356
|
+
if (!cfg) {
|
|
1357
|
+
throw new Error("No .codeyam/cloud.json found. Nothing to destroy.");
|
|
1358
|
+
}
|
|
1359
|
+
|
|
1360
|
+
const confirmed = await promptForInstanceName(cfg.instanceName);
|
|
1361
|
+
if (!confirmed) {
|
|
1362
|
+
console.error("Aborted.");
|
|
1363
|
+
return;
|
|
1364
|
+
}
|
|
1365
|
+
|
|
1366
|
+
console.error(`Deleting instance ${cfg.instanceName}...`);
|
|
1367
|
+
const del = runGcloud(buildGcloudArgs("instances-delete", cfg));
|
|
1368
|
+
if (del.status !== 0) {
|
|
1369
|
+
throw new Error(`Failed to delete instance ${cfg.instanceName}`);
|
|
1370
|
+
}
|
|
1371
|
+
|
|
1372
|
+
const list = runGcloud(buildGcloudArgs("instances-list-tagged", cfg), {
|
|
1373
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
1374
|
+
});
|
|
1375
|
+
const others = String(list.stdout || "").trim().split("\n").filter(Boolean);
|
|
1376
|
+
if (others.length === 0) {
|
|
1377
|
+
console.error(`No other ${INSTANCE_TAG}-tagged instances remain — deleting firewall rule...`);
|
|
1378
|
+
const fwDel = runGcloud(buildGcloudArgs("firewall-delete", cfg));
|
|
1379
|
+
if (fwDel.status !== 0) {
|
|
1380
|
+
console.error(` Warning: failed to delete firewall rule ${FIREWALL_RULE_NAME}.`);
|
|
1381
|
+
}
|
|
1382
|
+
} else {
|
|
1383
|
+
console.error(
|
|
1384
|
+
`Leaving ${FIREWALL_RULE_NAME} in place (${others.length} other ${INSTANCE_TAG}-tagged instance(s) still use it).`,
|
|
1385
|
+
);
|
|
1386
|
+
}
|
|
1387
|
+
|
|
1388
|
+
try {
|
|
1389
|
+
fs.unlinkSync(cloudConfigPath(rootDir));
|
|
1390
|
+
console.error("Deleted .codeyam/cloud.json");
|
|
1391
|
+
} catch (e) {
|
|
1392
|
+
if (e.code !== "ENOENT") throw e;
|
|
1393
|
+
}
|
|
1394
|
+
}
|
|
1395
|
+
|
|
1396
|
+
async function cmdStatus(args) {
|
|
1397
|
+
ensureGcloud();
|
|
1398
|
+
parseFlags(args);
|
|
1399
|
+
const cfg = loadCloudConfig(rootDir);
|
|
1400
|
+
if (!cfg) {
|
|
1401
|
+
console.error("No .codeyam/cloud.json found. Run `npm run cloud:up` first.");
|
|
1402
|
+
return;
|
|
1403
|
+
}
|
|
1404
|
+
|
|
1405
|
+
const vmState = fetchInstanceState(cfg) || "NOT_FOUND";
|
|
1406
|
+
console.error(`VM: ${cfg.instanceName} (${cfg.zone}) — ${vmState}`);
|
|
1407
|
+
|
|
1408
|
+
if (vmState !== "RUNNING") {
|
|
1409
|
+
console.error(" (image / container / tunnel checks skipped — VM is not RUNNING)");
|
|
1410
|
+
return;
|
|
1411
|
+
}
|
|
1412
|
+
|
|
1413
|
+
const imgProbe = runGcloud(
|
|
1414
|
+
buildGcloudArgs("ssh-probe", cfg, {
|
|
1415
|
+
command:
|
|
1416
|
+
"docker image inspect codeyam-editor:local >/dev/null 2>&1 && echo built || echo missing",
|
|
1417
|
+
}),
|
|
1418
|
+
{ stdio: ["ignore", "pipe", "ignore"] },
|
|
1419
|
+
);
|
|
1420
|
+
const imgStatus = String(imgProbe.stdout || "").trim() || "?";
|
|
1421
|
+
console.error(` image: ${imgStatus}`);
|
|
1422
|
+
|
|
1423
|
+
const repoName = resolveProject(cfg).name;
|
|
1424
|
+
const containerProbe = runGcloud(
|
|
1425
|
+
buildGcloudArgs("ssh-probe", cfg, {
|
|
1426
|
+
command: `cd ~/projects/${repoName} 2>/dev/null && docker compose -f docker-compose.cloud.yml ps --format json 2>/dev/null || echo`,
|
|
1427
|
+
}),
|
|
1428
|
+
{ stdio: ["ignore", "pipe", "ignore"] },
|
|
1429
|
+
);
|
|
1430
|
+
const containerStatus = parseEditorComposeState(containerProbe.stdout) || "missing";
|
|
1431
|
+
console.error(` editor container: ${containerStatus}`);
|
|
1432
|
+
|
|
1433
|
+
const appPort = await readVmAppPort(cfg);
|
|
1434
|
+
const pEditor = await isPortInUse(EDITOR_PORT);
|
|
1435
|
+
const pApp = await isPortInUse(appPort);
|
|
1436
|
+
console.error(
|
|
1437
|
+
` tunnel: ${EDITOR_PORT} ${pEditor ? "✓" : "✗"} ${appPort} ${pApp ? "✓" : "✗"}`,
|
|
1438
|
+
);
|
|
1439
|
+
}
|
|
1440
|
+
|
|
1441
|
+
const HANDLERS = {
|
|
1442
|
+
up: cmdUp,
|
|
1443
|
+
tunnel: cmdTunnel,
|
|
1444
|
+
stop: cmdStop,
|
|
1445
|
+
destroy: cmdDestroy,
|
|
1446
|
+
status: cmdStatus,
|
|
1447
|
+
};
|
|
1448
|
+
|
|
1449
|
+
async function cloudMain() {
|
|
1450
|
+
const subcmd = process.argv[2];
|
|
1451
|
+
const args = process.argv.slice(3);
|
|
1452
|
+
if (!subcmd || !Object.prototype.hasOwnProperty.call(HANDLERS, subcmd)) {
|
|
1453
|
+
console.error(
|
|
1454
|
+
`Usage: npm run cloud:<${Object.keys(HANDLERS).join("|")}> [-- flags]`,
|
|
1455
|
+
);
|
|
1456
|
+
process.exit(2);
|
|
1457
|
+
}
|
|
1458
|
+
try {
|
|
1459
|
+
await HANDLERS[subcmd](args);
|
|
1460
|
+
} catch (e) {
|
|
1461
|
+
console.error(`Error: ${e.message}`);
|
|
1462
|
+
process.exit(1);
|
|
1463
|
+
}
|
|
1464
|
+
}
|
|
1465
|
+
|
|
1466
|
+
module.exports = {
|
|
1467
|
+
// pure helpers
|
|
1468
|
+
agentEnvVarName,
|
|
1469
|
+
AGENT_ENV_VARS,
|
|
1470
|
+
KNOWN_FLAGS,
|
|
1471
|
+
FIREWALL_RULE_NAME,
|
|
1472
|
+
INSTANCE_TAG,
|
|
1473
|
+
EDITOR_PORT,
|
|
1474
|
+
VITE_PORT,
|
|
1475
|
+
parseFlags,
|
|
1476
|
+
repoBasename,
|
|
1477
|
+
deriveInstanceName,
|
|
1478
|
+
defaultInstanceNameFor,
|
|
1479
|
+
CANONICAL_EDITOR_REPO,
|
|
1480
|
+
EDITOR_VARIANTS,
|
|
1481
|
+
MANAGED_PREVIEW_ORIGIN,
|
|
1482
|
+
SAME_ORIGIN_PREVIEW_PATH,
|
|
1483
|
+
sameRepo,
|
|
1484
|
+
resolveFleetVmNumber,
|
|
1485
|
+
rosterProvisionedVm,
|
|
1486
|
+
resolveConfiguredAppPort,
|
|
1487
|
+
resolveSeedPreviewOrigin,
|
|
1488
|
+
resolveEditorConfig,
|
|
1489
|
+
validateEditorVariant,
|
|
1490
|
+
assertVariantProvisionable,
|
|
1491
|
+
editorImageBootstrapArgs,
|
|
1492
|
+
reconcileProjectExclusivity,
|
|
1493
|
+
resolveGhcrToken,
|
|
1494
|
+
resolveProject,
|
|
1495
|
+
clientProjectBootstrapArgs,
|
|
1496
|
+
cloudConfigPath,
|
|
1497
|
+
loadCloudConfig,
|
|
1498
|
+
saveCloudConfig,
|
|
1499
|
+
buildEditorLocalEnvPatch,
|
|
1500
|
+
buildEditorLocalSeedWriteCommand,
|
|
1501
|
+
buildGcloudArgs,
|
|
1502
|
+
sshScriptCommand,
|
|
1503
|
+
resolveSetupForwardEnv,
|
|
1504
|
+
FORWARD_ENV_KEYS_ENV,
|
|
1505
|
+
parseEditorComposeState,
|
|
1506
|
+
fetchInstanceState,
|
|
1507
|
+
// side-effecting handlers
|
|
1508
|
+
cmdUp,
|
|
1509
|
+
cmdTunnel,
|
|
1510
|
+
cmdStop,
|
|
1511
|
+
cmdDestroy,
|
|
1512
|
+
cmdStatus,
|
|
1513
|
+
cloudMain,
|
|
1514
|
+
seedGithubCredentials,
|
|
1515
|
+
readVmAppPort,
|
|
1516
|
+
resolveLaptopGitIdentity,
|
|
1517
|
+
};
|
|
1518
|
+
|
|
1519
|
+
if (require.main === module) {
|
|
1520
|
+
cloudMain();
|
|
1521
|
+
}
|