@codeyam-editor/codeyam-editor 0.1.0-staging.0f231be
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 +19 -0
- package/npm/cloud.js +966 -0
- package/npm/container.js +49 -0
- package/npm/editor-dev.js +142 -0
- package/npm/editor.js +210 -0
- package/npm/open.js +15 -0
- package/npm/scenario-check.js +218 -0
- package/npm/scenario-handlers.js +82 -0
- package/npm/scenario-issues.js +35 -0
- package/npm/scenario-metrics.js +94 -0
- package/npm/scenario-mocks.js +70 -0
- package/npm/scenario-playwright.js +192 -0
- package/npm/utils.js +623 -0
- package/package.json +32 -0
- package/ui/dist/assets/ContainerInspector-CVhTYjTR.js +2 -0
- package/ui/dist/assets/EditorShell-DMepjAfe.js +38 -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-DbnhCRfn.js +5 -0
- package/ui/dist/assets/ScenarioDataPanelFullPage-CNgkwmsl.js +1 -0
- package/ui/dist/assets/ScenarioRenderer-xciw2Qja.js +1 -0
- package/ui/dist/assets/StepsSimulator-CRtmlyNZ.js +1 -0
- package/ui/dist/assets/index-CnDCJXUg.js +27 -0
- package/ui/dist/assets/index-Dnu7mncO.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/cloud.js
ADDED
|
@@ -0,0 +1,966 @@
|
|
|
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
|
+
|
|
25
|
+
// =============================================================================
|
|
26
|
+
// Pure helpers — every export here is unit-tested in cloud.test.js without
|
|
27
|
+
// touching `gcloud` or the network.
|
|
28
|
+
// =============================================================================
|
|
29
|
+
|
|
30
|
+
const AGENT_ENV_VARS = {
|
|
31
|
+
claude: "ANTHROPIC_API_KEY",
|
|
32
|
+
codex: "OPENAI_API_KEY",
|
|
33
|
+
gemini: "GEMINI_API_KEY",
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
const KNOWN_FLAGS = ["repo", "branch", "instance-name", "project", "zone", "agent"];
|
|
37
|
+
|
|
38
|
+
const FIREWALL_RULE_NAME = "codeyam-editor-iap-ssh";
|
|
39
|
+
const INSTANCE_TAG = "codeyam-editor";
|
|
40
|
+
const EDITOR_PORT = 14199;
|
|
41
|
+
const VITE_PORT = 5173;
|
|
42
|
+
|
|
43
|
+
/** Map an agent name to its API-key env var. Throws on unknown agents so
|
|
44
|
+
* adding an agent is a one-entry change in `AGENT_ENV_VARS`, never a
|
|
45
|
+
* silent fallthrough that ships unauthenticated. */
|
|
46
|
+
function agentEnvVarName(agent) {
|
|
47
|
+
if (!Object.prototype.hasOwnProperty.call(AGENT_ENV_VARS, agent)) {
|
|
48
|
+
throw new Error(
|
|
49
|
+
`Unknown agent: ${agent}. Supported: ${Object.keys(AGENT_ENV_VARS).join(", ")}`,
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
return AGENT_ENV_VARS[agent];
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Parse `--flag value` argv into an object. Unknown flags or missing values
|
|
56
|
+
* throw with a usage hint — silently dropping them would let typos like
|
|
57
|
+
* `--branchh main` succeed with no branch flag at all. */
|
|
58
|
+
function parseFlags(argv) {
|
|
59
|
+
const flags = {};
|
|
60
|
+
for (let i = 0; i < argv.length; i++) {
|
|
61
|
+
const arg = argv[i];
|
|
62
|
+
if (!arg.startsWith("--")) {
|
|
63
|
+
throw new Error(
|
|
64
|
+
`Unexpected positional argument: ${arg}\n` +
|
|
65
|
+
`Usage: npm run cloud:<up|tunnel|stop|destroy|status> -- [${KNOWN_FLAGS
|
|
66
|
+
.map((f) => `--${f} <value>`)
|
|
67
|
+
.join("] [")}]`,
|
|
68
|
+
);
|
|
69
|
+
}
|
|
70
|
+
const key = arg.slice(2);
|
|
71
|
+
if (!KNOWN_FLAGS.includes(key)) {
|
|
72
|
+
throw new Error(`Unknown flag: ${arg}. Known: --${KNOWN_FLAGS.join(", --")}`);
|
|
73
|
+
}
|
|
74
|
+
const value = argv[i + 1];
|
|
75
|
+
if (value === undefined || value.startsWith("--")) {
|
|
76
|
+
throw new Error(`${arg} requires a value`);
|
|
77
|
+
}
|
|
78
|
+
const camel = key.replace(/-([a-z])/g, (_, c) => c.toUpperCase());
|
|
79
|
+
flags[camel] = value;
|
|
80
|
+
i++;
|
|
81
|
+
}
|
|
82
|
+
return flags;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Strip `.git`, take the basename, lowercase, collapse non-alphanumerics to
|
|
86
|
+
* hyphens. Mirrors the existing `scripts/gcp-project-bootstrap.sh` derivation
|
|
87
|
+
* so the directory layout on the VM matches the instance name 1:1. */
|
|
88
|
+
function repoBasename(repoUrl) {
|
|
89
|
+
const stripped = repoUrl.replace(/\.git$/i, "");
|
|
90
|
+
const parts = stripped.split(/[/:]+/).filter(Boolean);
|
|
91
|
+
return parts[parts.length - 1] || "project";
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Construct a deterministic instance name from a repo URL. GCE caps
|
|
95
|
+
* instance names at 63 chars; we cap at 40 to keep room for hyphen
|
|
96
|
+
* separators and stay well below the limit. */
|
|
97
|
+
function deriveInstanceName(repoUrl) {
|
|
98
|
+
const base = repoBasename(repoUrl);
|
|
99
|
+
let slug = base
|
|
100
|
+
.toLowerCase()
|
|
101
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
102
|
+
.replace(/^-+|-+$/g, "");
|
|
103
|
+
if (!slug) slug = "project";
|
|
104
|
+
let name = `codeyam-editor-${slug}`;
|
|
105
|
+
if (name.length > 40) name = name.slice(0, 40).replace(/-+$/g, "");
|
|
106
|
+
return name;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function cloudConfigPath(projectDir) {
|
|
110
|
+
return path.join(projectDir, ".codeyam", "cloud.json");
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** Returns the parsed config, or `null` when the file is absent. Throws
|
|
114
|
+
* with the full path on malformed JSON so the user knows which file to
|
|
115
|
+
* fix — silently returning `null` would mask corruption as "no config". */
|
|
116
|
+
function loadCloudConfig(projectDir) {
|
|
117
|
+
const p = cloudConfigPath(projectDir);
|
|
118
|
+
if (!fs.existsSync(p)) return null;
|
|
119
|
+
let raw;
|
|
120
|
+
try {
|
|
121
|
+
raw = fs.readFileSync(p, "utf8");
|
|
122
|
+
} catch (e) {
|
|
123
|
+
throw new Error(`Failed to read ${p}: ${e.message}`);
|
|
124
|
+
}
|
|
125
|
+
try {
|
|
126
|
+
return JSON.parse(raw);
|
|
127
|
+
} catch (e) {
|
|
128
|
+
throw new Error(`Failed to parse ${p}: ${e.message}`);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function saveCloudConfig(projectDir, cfg) {
|
|
133
|
+
const p = cloudConfigPath(projectDir);
|
|
134
|
+
fs.mkdirSync(path.dirname(p), { recursive: true });
|
|
135
|
+
fs.writeFileSync(p, JSON.stringify(cfg, null, 2) + "\n");
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** Build the JSON patch to merge into the VM's `editor.local.json` so the
|
|
139
|
+
* configured agent picks up the user's local API key on first launch.
|
|
140
|
+
* Returns `null` when the key isn't set locally — the wrapper then prints
|
|
141
|
+
* the interactive-auth fallback line instead of writing an empty patch. */
|
|
142
|
+
function buildEditorLocalEnvPatch(localEnv, agent) {
|
|
143
|
+
const varName = agentEnvVarName(agent);
|
|
144
|
+
const value = localEnv[varName];
|
|
145
|
+
if (!value) return null;
|
|
146
|
+
return { env: { [varName]: value } };
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** Parse `docker compose ps --format json` (compose v2 — one JSON object per
|
|
150
|
+
* line) and return the editor service's `State` string, or null when the
|
|
151
|
+
* service is absent / output is unparseable. Two callers shared an identical
|
|
152
|
+
* inline parser; this centralizes the shape so a compose-output change is
|
|
153
|
+
* one update, not two. */
|
|
154
|
+
function parseEditorComposeState(stdout) {
|
|
155
|
+
if (!stdout) return null;
|
|
156
|
+
const lines = String(stdout).trim().split("\n").filter(Boolean);
|
|
157
|
+
for (const line of lines) {
|
|
158
|
+
let svc;
|
|
159
|
+
try {
|
|
160
|
+
svc = JSON.parse(line);
|
|
161
|
+
} catch {
|
|
162
|
+
continue;
|
|
163
|
+
}
|
|
164
|
+
if (svc && svc.Service === "editor") {
|
|
165
|
+
return svc.State || null;
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
return null;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** Translate one of the wrapper's verbs into the exact `gcloud` argv. The
|
|
172
|
+
* switch's job is to keep every per-verb command shape in one reviewable
|
|
173
|
+
* place — tests assert the arrays without ever spawning a process. */
|
|
174
|
+
function buildGcloudArgs(verb, cfg = {}, extras = {}) {
|
|
175
|
+
const { project, zone, instanceName } = cfg;
|
|
176
|
+
switch (verb) {
|
|
177
|
+
case "config-get-project":
|
|
178
|
+
return ["config", "get-value", "project"];
|
|
179
|
+
case "config-get-zone":
|
|
180
|
+
return ["config", "get-value", "compute/zone"];
|
|
181
|
+
case "instances-create":
|
|
182
|
+
// The VM keeps the default ephemeral external IP so apt / docker /
|
|
183
|
+
// NodeSource / npm can reach the public internet during bootstrap.
|
|
184
|
+
// Inbound is still IAP-SSH-only via the firewall rule above; the
|
|
185
|
+
// external IP is for egress, not exposure. `--no-address` was the
|
|
186
|
+
// earlier default but produced a VM that could not install Docker
|
|
187
|
+
// without a Cloud NAT + Cloud Router pair the wrapper does not set up.
|
|
188
|
+
return [
|
|
189
|
+
"compute", "instances", "create", instanceName,
|
|
190
|
+
"--project", project, "--zone", zone,
|
|
191
|
+
"--machine-type=e2-standard-4",
|
|
192
|
+
"--image-family=debian-12",
|
|
193
|
+
"--image-project=debian-cloud",
|
|
194
|
+
"--boot-disk-size=100GB",
|
|
195
|
+
"--boot-disk-type=pd-balanced",
|
|
196
|
+
`--tags=${INSTANCE_TAG}`,
|
|
197
|
+
];
|
|
198
|
+
case "instances-start":
|
|
199
|
+
return ["compute", "instances", "start", instanceName, "--project", project, "--zone", zone];
|
|
200
|
+
case "instances-stop":
|
|
201
|
+
return ["compute", "instances", "stop", instanceName, "--project", project, "--zone", zone];
|
|
202
|
+
case "instances-delete":
|
|
203
|
+
return ["compute", "instances", "delete", instanceName, "--project", project, "--zone", zone, "--quiet"];
|
|
204
|
+
case "instances-describe":
|
|
205
|
+
return ["compute", "instances", "describe", instanceName, "--project", project, "--zone", zone, "--format=json"];
|
|
206
|
+
case "instances-list-tagged":
|
|
207
|
+
return [
|
|
208
|
+
"compute", "instances", "list",
|
|
209
|
+
"--project", project,
|
|
210
|
+
`--filter=tags.items:${INSTANCE_TAG}`,
|
|
211
|
+
"--format=value(name)",
|
|
212
|
+
];
|
|
213
|
+
case "firewall-describe":
|
|
214
|
+
return ["compute", "firewall-rules", "describe", FIREWALL_RULE_NAME, "--project", project, "--format=json"];
|
|
215
|
+
case "firewall-create":
|
|
216
|
+
return [
|
|
217
|
+
"compute", "firewall-rules", "create", FIREWALL_RULE_NAME,
|
|
218
|
+
"--project", project,
|
|
219
|
+
"--direction=INGRESS",
|
|
220
|
+
"--action=ALLOW",
|
|
221
|
+
"--rules=tcp:22",
|
|
222
|
+
"--source-ranges=35.235.240.0/20",
|
|
223
|
+
`--target-tags=${INSTANCE_TAG}`,
|
|
224
|
+
];
|
|
225
|
+
case "firewall-delete":
|
|
226
|
+
return ["compute", "firewall-rules", "delete", FIREWALL_RULE_NAME, "--project", project, "--quiet"];
|
|
227
|
+
case "ssh-probe":
|
|
228
|
+
return [
|
|
229
|
+
"compute", "ssh", instanceName,
|
|
230
|
+
"--project", project, "--zone", zone,
|
|
231
|
+
"--tunnel-through-iap",
|
|
232
|
+
"--command", extras.command,
|
|
233
|
+
];
|
|
234
|
+
case "ssh-script":
|
|
235
|
+
// Stream a script body as base64 over --command so we don't fight
|
|
236
|
+
// shell-quoting through gcloud → ssh → remote bash. The script body
|
|
237
|
+
// is delivered as one argument; the wrapper feeds the base64 into
|
|
238
|
+
// `extras.command` and the caller sets `input` to the script body.
|
|
239
|
+
return [
|
|
240
|
+
"compute", "ssh", instanceName,
|
|
241
|
+
"--project", project, "--zone", zone,
|
|
242
|
+
"--tunnel-through-iap",
|
|
243
|
+
"--command", extras.command,
|
|
244
|
+
];
|
|
245
|
+
case "ssh-tunnel":
|
|
246
|
+
return [
|
|
247
|
+
"compute", "ssh", instanceName,
|
|
248
|
+
"--project", project, "--zone", zone,
|
|
249
|
+
"--tunnel-through-iap",
|
|
250
|
+
"--",
|
|
251
|
+
"-L", `${EDITOR_PORT}:127.0.0.1:${EDITOR_PORT}`,
|
|
252
|
+
"-L", `${VITE_PORT}:127.0.0.1:${VITE_PORT}`,
|
|
253
|
+
"-N",
|
|
254
|
+
];
|
|
255
|
+
default:
|
|
256
|
+
throw new Error(`Unknown gcloud verb: ${verb}`);
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
// =============================================================================
|
|
261
|
+
// Side-effecting helpers — exercised end-to-end by `npm run cloud:up` in
|
|
262
|
+
// real use. Unit tests pin their exports so a refactor cannot drop them.
|
|
263
|
+
// =============================================================================
|
|
264
|
+
|
|
265
|
+
function runGcloud(args, opts = {}) {
|
|
266
|
+
return spawnSync("gcloud", args, {
|
|
267
|
+
stdio: opts.stdio || "inherit",
|
|
268
|
+
input: opts.input,
|
|
269
|
+
encoding: opts.encoding || "utf8",
|
|
270
|
+
});
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
function ensureGcloud() {
|
|
274
|
+
const result = spawnSync("gcloud", ["version"], { stdio: "ignore" });
|
|
275
|
+
if (result.error || result.status !== 0) {
|
|
276
|
+
console.error(
|
|
277
|
+
"gcloud is not on PATH. Install: https://cloud.google.com/sdk/docs/install",
|
|
278
|
+
);
|
|
279
|
+
process.exit(1);
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
function gcloudGetValue(verb) {
|
|
284
|
+
const result = runGcloud(buildGcloudArgs(verb), { stdio: ["ignore", "pipe", "ignore"] });
|
|
285
|
+
if (result.error || result.status !== 0) return null;
|
|
286
|
+
const val = String(result.stdout || "").trim();
|
|
287
|
+
if (!val || val === "(unset)") return null;
|
|
288
|
+
return val;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/** Run `gcloud compute instances describe ... --format=json` and return the
|
|
292
|
+
* parsed `.status` field (e.g. "RUNNING", "TERMINATED", "PROVISIONING"), or
|
|
293
|
+
* null when the instance is absent or describe returned unparseable output.
|
|
294
|
+
* Centralizes the describe-and-parse pattern shared by ensureInstance (used
|
|
295
|
+
* twice — initial probe + poll), cmdTunnel, and cmdStatus. */
|
|
296
|
+
function fetchInstanceState(cfg) {
|
|
297
|
+
const result = runGcloud(buildGcloudArgs("instances-describe", cfg), {
|
|
298
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
299
|
+
});
|
|
300
|
+
if (result.status !== 0) return null;
|
|
301
|
+
try {
|
|
302
|
+
const parsed = JSON.parse(result.stdout);
|
|
303
|
+
return parsed && typeof parsed.status === "string" ? parsed.status : null;
|
|
304
|
+
} catch {
|
|
305
|
+
return null;
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
function loadOrInitCloudConfig(projectDir, flags) {
|
|
310
|
+
const existing = loadCloudConfig(projectDir);
|
|
311
|
+
if (existing) {
|
|
312
|
+
const merged = { ...existing, ...flags };
|
|
313
|
+
if (JSON.stringify(merged) !== JSON.stringify(existing)) {
|
|
314
|
+
saveCloudConfig(projectDir, merged);
|
|
315
|
+
}
|
|
316
|
+
return merged;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
const cfg = { ...flags };
|
|
320
|
+
cfg.project = cfg.project || gcloudGetValue("config-get-project");
|
|
321
|
+
cfg.zone = cfg.zone || gcloudGetValue("config-get-zone");
|
|
322
|
+
cfg.agent = cfg.agent || "claude";
|
|
323
|
+
agentEnvVarName(cfg.agent);
|
|
324
|
+
|
|
325
|
+
if (!cfg.repo) {
|
|
326
|
+
throw new Error(
|
|
327
|
+
"--repo <url> is required on first run. Subsequent runs read .codeyam/cloud.json.",
|
|
328
|
+
);
|
|
329
|
+
}
|
|
330
|
+
if (!cfg.project) {
|
|
331
|
+
throw new Error(
|
|
332
|
+
"--project not provided and `gcloud config get-value project` returned no default.\n" +
|
|
333
|
+
"Run `gcloud config set project <id>` or pass --project <id>.",
|
|
334
|
+
);
|
|
335
|
+
}
|
|
336
|
+
if (!cfg.zone) {
|
|
337
|
+
throw new Error(
|
|
338
|
+
"--zone not provided and `gcloud config get-value compute/zone` returned no default.\n" +
|
|
339
|
+
"Run `gcloud config set compute/zone <zone>` or pass --zone <zone>.",
|
|
340
|
+
);
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
cfg.instanceName = cfg.instanceName || deriveInstanceName(cfg.repo);
|
|
344
|
+
saveCloudConfig(projectDir, cfg);
|
|
345
|
+
return cfg;
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
async function ensureFirewallRule(cfg) {
|
|
349
|
+
console.error(`Checking firewall rule ${FIREWALL_RULE_NAME}...`);
|
|
350
|
+
const describe = runGcloud(buildGcloudArgs("firewall-describe", cfg), {
|
|
351
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
352
|
+
});
|
|
353
|
+
if (describe.status === 0) {
|
|
354
|
+
console.error(` ✓ ${FIREWALL_RULE_NAME} already exists`);
|
|
355
|
+
return;
|
|
356
|
+
}
|
|
357
|
+
console.error(` creating ${FIREWALL_RULE_NAME}...`);
|
|
358
|
+
const create = runGcloud(buildGcloudArgs("firewall-create", cfg));
|
|
359
|
+
if (create.status !== 0) {
|
|
360
|
+
throw new Error(`Failed to create firewall rule ${FIREWALL_RULE_NAME}`);
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
async function ensureInstance(cfg) {
|
|
365
|
+
console.error(`Checking instance ${cfg.instanceName}...`);
|
|
366
|
+
const initialState = fetchInstanceState(cfg);
|
|
367
|
+
if (initialState === "RUNNING") {
|
|
368
|
+
console.error(` ✓ ${cfg.instanceName} is RUNNING`);
|
|
369
|
+
return;
|
|
370
|
+
}
|
|
371
|
+
if (initialState === "TERMINATED") {
|
|
372
|
+
console.error(` ${cfg.instanceName} is TERMINATED — starting...`);
|
|
373
|
+
const start = runGcloud(buildGcloudArgs("instances-start", cfg));
|
|
374
|
+
if (start.status !== 0) {
|
|
375
|
+
throw new Error(`Failed to start instance ${cfg.instanceName}`);
|
|
376
|
+
}
|
|
377
|
+
} else if (initialState === null) {
|
|
378
|
+
console.error(` ${cfg.instanceName} not found — creating...`);
|
|
379
|
+
const create = runGcloud(buildGcloudArgs("instances-create", cfg));
|
|
380
|
+
if (create.status !== 0) {
|
|
381
|
+
throw new Error(`Failed to create instance ${cfg.instanceName}`);
|
|
382
|
+
}
|
|
383
|
+
} else {
|
|
384
|
+
console.error(` ${cfg.instanceName} is ${initialState}; waiting...`);
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
for (let i = 0; i < 60; i++) {
|
|
388
|
+
if (fetchInstanceState(cfg) === "RUNNING") {
|
|
389
|
+
console.error(` ✓ ${cfg.instanceName} is RUNNING`);
|
|
390
|
+
await waitForIapReady(cfg);
|
|
391
|
+
return;
|
|
392
|
+
}
|
|
393
|
+
await new Promise((r) => setTimeout(r, 2000));
|
|
394
|
+
}
|
|
395
|
+
throw new Error(`Instance ${cfg.instanceName} did not reach RUNNING within 2 minutes`);
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
/** GCE reports the instance as RUNNING the moment the boot disk is attached,
|
|
399
|
+
* but the IAP control plane has its own catalog that updates a few seconds
|
|
400
|
+
* later. The first `gcloud compute ssh --tunnel-through-iap` after a fresh
|
|
401
|
+
* create races that catalog and fails with `[4047: 'Failed to lookup
|
|
402
|
+
* instance']`. Probe an IAP-tunneled SSH that just runs `true` until it
|
|
403
|
+
* succeeds — cheap on the warm path (one ~1s round-trip) and bounded so a
|
|
404
|
+
* genuinely unreachable VM fails loud rather than silently. */
|
|
405
|
+
async function waitForIapReady(cfg) {
|
|
406
|
+
console.error(` waiting for IAP to register ${cfg.instanceName}...`);
|
|
407
|
+
const deadline = Date.now() + 60_000;
|
|
408
|
+
let attempts = 0;
|
|
409
|
+
let lastErr = "";
|
|
410
|
+
while (Date.now() < deadline) {
|
|
411
|
+
attempts++;
|
|
412
|
+
const probe = runGcloud(
|
|
413
|
+
buildGcloudArgs("ssh-probe", cfg, { command: "true" }),
|
|
414
|
+
{ stdio: ["ignore", "pipe", "pipe"] },
|
|
415
|
+
);
|
|
416
|
+
if (probe.status === 0) {
|
|
417
|
+
console.error(` ✓ IAP ready (${attempts} ${attempts === 1 ? "probe" : "probes"})`);
|
|
418
|
+
return;
|
|
419
|
+
}
|
|
420
|
+
lastErr = String(probe.stderr || "").trim().split("\n").slice(-1)[0] || "";
|
|
421
|
+
// Exponential-ish backoff: 2s, 3s, 5s, 8s, then cap at 8s.
|
|
422
|
+
const delay = Math.min(2000 * Math.pow(1.5, attempts - 1), 8000);
|
|
423
|
+
await new Promise((r) => setTimeout(r, delay));
|
|
424
|
+
}
|
|
425
|
+
throw new Error(
|
|
426
|
+
`IAP did not become ready for ${cfg.instanceName} within 60s ` +
|
|
427
|
+
`(${attempts} probes; last error: ${lastErr || "unknown"})`,
|
|
428
|
+
);
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
function sshScriptCommand(scriptBody, scriptArgs = [], envVars = {}) {
|
|
432
|
+
const b64 = Buffer.from(scriptBody, "utf8").toString("base64");
|
|
433
|
+
const argsString = scriptArgs.map((a) => `'${String(a).replace(/'/g, "'\\''")}'`).join(" ");
|
|
434
|
+
// Prefix each env var as `KEY='value'` so they're set for the bash -s
|
|
435
|
+
// process only — no persistence in the VM's shell history, no /etc/environment
|
|
436
|
+
// edit. Single-quoted values are safe against shell metacharacters; an
|
|
437
|
+
// embedded apostrophe in the value would be exotic for these (token,
|
|
438
|
+
// bool flag), but escape it the same way as scriptArgs for symmetry.
|
|
439
|
+
const envString = Object.entries(envVars)
|
|
440
|
+
.filter(([, v]) => v !== undefined && v !== null && v !== "")
|
|
441
|
+
.map(([k, v]) => `${k}='${String(v).replace(/'/g, "'\\''")}'`)
|
|
442
|
+
.join(" ");
|
|
443
|
+
const envPrefix = envString ? `${envString} ` : "";
|
|
444
|
+
return `echo '${b64}' | base64 -d | ${envPrefix}bash -s -- ${argsString}`.trim();
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
async function ensureBootstrapped(cfg) {
|
|
448
|
+
console.error("Checking VM bootstrap state...");
|
|
449
|
+
const probe = runGcloud(
|
|
450
|
+
buildGcloudArgs("ssh-probe", cfg, {
|
|
451
|
+
command: "command -v docker >/dev/null && docker image inspect codeyam-editor:local >/dev/null 2>&1",
|
|
452
|
+
}),
|
|
453
|
+
{ stdio: ["ignore", "pipe", "pipe"] },
|
|
454
|
+
);
|
|
455
|
+
if (probe.status === 0) {
|
|
456
|
+
console.error(" ✓ VM already bootstrapped (docker + codeyam-editor:local present)");
|
|
457
|
+
return;
|
|
458
|
+
}
|
|
459
|
+
console.error(" streaming scripts/gcp-bootstrap.sh to VM (this may take ~10 minutes)...");
|
|
460
|
+
const scriptPath = path.join(rootDir, "scripts", "gcp-bootstrap.sh");
|
|
461
|
+
const scriptBody = fs.readFileSync(scriptPath, "utf8");
|
|
462
|
+
// GITHUB_TOKEN is passed through to the bootstrap script so it can clone
|
|
463
|
+
// a private editor repo (the bootstrap clones $REPO_URL to build the
|
|
464
|
+
// codeyam-editor:local image). Without this the git clone hits the
|
|
465
|
+
// "could not read Username for 'https://github.com'" wall whenever the
|
|
466
|
+
// repo isn't public — codeyam-editor itself today.
|
|
467
|
+
//
|
|
468
|
+
// --branch is mandatory when the user is testing wrapper / Dockerfile
|
|
469
|
+
// patches from a feature branch: without it, the bootstrap clones the
|
|
470
|
+
// repo's default branch and the image build uses a stale Dockerfile,
|
|
471
|
+
// even though the user invoked cloud:up with --branch.
|
|
472
|
+
const bootstrapArgs = ["--agent", cfg.agent, "--repo", cfg.repo];
|
|
473
|
+
if (cfg.branch) {
|
|
474
|
+
bootstrapArgs.push("--branch", cfg.branch);
|
|
475
|
+
}
|
|
476
|
+
const command = sshScriptCommand(scriptBody, bootstrapArgs, {
|
|
477
|
+
GITHUB_TOKEN: resolveGithubToken(),
|
|
478
|
+
});
|
|
479
|
+
const result = runGcloud(
|
|
480
|
+
buildGcloudArgs("ssh-script", cfg, { command }),
|
|
481
|
+
{ stdio: "inherit" },
|
|
482
|
+
);
|
|
483
|
+
if (result.status !== 0) {
|
|
484
|
+
throw new Error("scripts/gcp-bootstrap.sh failed on the VM");
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
async function ensureProjectBootstrapped(cfg) {
|
|
489
|
+
const repoName = repoBasename(cfg.repo);
|
|
490
|
+
console.error(`Checking project bootstrap state for ${repoName}...`);
|
|
491
|
+
// Probe both .codeyam/ (created by `editor init`) AND
|
|
492
|
+
// docker-compose.cloud.yml (written near the end of the script). A previous
|
|
493
|
+
// run that crashed between init and the compose write would leave the
|
|
494
|
+
// first marker but not the second; the old probe wrongly considered the
|
|
495
|
+
// project bootstrapped and the next `cloud:up` then hit
|
|
496
|
+
// "open docker-compose.cloud.yml: no such file or directory".
|
|
497
|
+
// The probe is the seam where idempotency lives — make sure it gates on
|
|
498
|
+
// the LAST artifact the script produces, not the first.
|
|
499
|
+
const probe = runGcloud(
|
|
500
|
+
buildGcloudArgs("ssh-probe", cfg, {
|
|
501
|
+
command:
|
|
502
|
+
`test -d ~/projects/${repoName}/.codeyam ` +
|
|
503
|
+
`&& test -f ~/projects/${repoName}/docker-compose.cloud.yml`,
|
|
504
|
+
}),
|
|
505
|
+
{ stdio: ["ignore", "pipe", "pipe"] },
|
|
506
|
+
);
|
|
507
|
+
if (probe.status === 0) {
|
|
508
|
+
console.error(` ✓ ${repoName} already bootstrapped on VM`);
|
|
509
|
+
return;
|
|
510
|
+
}
|
|
511
|
+
console.error(` streaming scripts/gcp-project-bootstrap.sh to VM for ${cfg.repo}...`);
|
|
512
|
+
const scriptPath = path.join(rootDir, "scripts", "gcp-project-bootstrap.sh");
|
|
513
|
+
const scriptBody = fs.readFileSync(scriptPath, "utf8");
|
|
514
|
+
const scriptArgs = [cfg.repo];
|
|
515
|
+
if (cfg.branch) {
|
|
516
|
+
scriptArgs.push("--branch", cfg.branch);
|
|
517
|
+
}
|
|
518
|
+
const command = sshScriptCommand(scriptBody, scriptArgs, {
|
|
519
|
+
GITHUB_TOKEN: resolveGithubToken(),
|
|
520
|
+
});
|
|
521
|
+
const result = runGcloud(
|
|
522
|
+
buildGcloudArgs("ssh-script", cfg, { command }),
|
|
523
|
+
{ stdio: "inherit" },
|
|
524
|
+
);
|
|
525
|
+
if (result.status !== 0) {
|
|
526
|
+
throw new Error("scripts/gcp-project-bootstrap.sh failed on the VM");
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
async function ensureEditorContainerUp(cfg) {
|
|
531
|
+
const repoName = repoBasename(cfg.repo);
|
|
532
|
+
// -f docker-compose.cloud.yml everywhere: the editor repo's own
|
|
533
|
+
// docker-compose.yml does `build: .` from source and would shadow the
|
|
534
|
+
// cloud variant when self-hosting (see gcp-project-bootstrap.sh).
|
|
535
|
+
const composeCmd = `docker compose -f docker-compose.cloud.yml`;
|
|
536
|
+
console.error("Checking editor container state...");
|
|
537
|
+
const probe = runGcloud(
|
|
538
|
+
buildGcloudArgs("ssh-probe", cfg, {
|
|
539
|
+
command: `cd ~/projects/${repoName} && ${composeCmd} ps --format json 2>/dev/null || true`,
|
|
540
|
+
}),
|
|
541
|
+
{ stdio: ["ignore", "pipe", "ignore"] },
|
|
542
|
+
);
|
|
543
|
+
if (probe.status === 0 && parseEditorComposeState(probe.stdout) === "running") {
|
|
544
|
+
console.error(" ✓ editor container is already running");
|
|
545
|
+
return;
|
|
546
|
+
}
|
|
547
|
+
console.error(" starting editor container (docker compose up -d)...");
|
|
548
|
+
const up = runGcloud(
|
|
549
|
+
buildGcloudArgs("ssh-probe", cfg, {
|
|
550
|
+
command: `cd ~/projects/${repoName} && ${composeCmd} up -d`,
|
|
551
|
+
}),
|
|
552
|
+
{ stdio: "inherit" },
|
|
553
|
+
);
|
|
554
|
+
if (up.status !== 0) {
|
|
555
|
+
throw new Error("docker compose up -d failed on the VM");
|
|
556
|
+
}
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
/** Look up a GitHub token from local environment, falling back to `gh auth
|
|
560
|
+
* token`. Returns empty string if neither is available — callers should
|
|
561
|
+
* treat empty as "no auth" and either skip or print a hint. Memoizing
|
|
562
|
+
* intentionally not done: the cost of one `gh` subprocess per cloud:up is
|
|
563
|
+
* trivial and lets a user `gh auth login` between operations. */
|
|
564
|
+
function resolveGithubToken() {
|
|
565
|
+
const env = process.env.GITHUB_TOKEN || process.env.GH_TOKEN;
|
|
566
|
+
if (env) return env;
|
|
567
|
+
const ghOut = spawnSync("gh", ["auth", "token"], { encoding: "utf8" });
|
|
568
|
+
if (ghOut.status === 0) {
|
|
569
|
+
return ghOut.stdout.trim();
|
|
570
|
+
}
|
|
571
|
+
return "";
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
/** Write the laptop's GitHub token into ~/.codeyam-cloud-git-credentials on
|
|
575
|
+
* the VM so the cloud compose can mount it into /root/.git-credentials —
|
|
576
|
+
* without this, claude / codex / gemini inside the container can clone the
|
|
577
|
+
* initial repo (the wrapper does that step out-of-band with a one-shot
|
|
578
|
+
* inlined URL) but cannot `git pull` / `git push` afterward. The file is
|
|
579
|
+
* chmod 600 and never committed; if the local environment has no token,
|
|
580
|
+
* the placeholder file from gcp-project-bootstrap.sh stays in place. */
|
|
581
|
+
async function seedGithubCredentials(cfg) {
|
|
582
|
+
const token = resolveGithubToken();
|
|
583
|
+
if (!token) {
|
|
584
|
+
console.error(
|
|
585
|
+
"\nNo GITHUB_TOKEN / GH_TOKEN set and `gh auth token` is unavailable —\n" +
|
|
586
|
+
"claude inside the cloud container will not be able to git pull/push.\n" +
|
|
587
|
+
"Export GITHUB_TOKEN or run `gh auth login` locally, then `cloud:up` again.\n",
|
|
588
|
+
);
|
|
589
|
+
return;
|
|
590
|
+
}
|
|
591
|
+
console.error("Writing GitHub token into ~/.codeyam-cloud-git-credentials on the VM...");
|
|
592
|
+
const credLine = `https://x-access-token:${token}@github.com`;
|
|
593
|
+
// Quote the token through one shell layer (gcloud ssh --command); the
|
|
594
|
+
// value is base64'd to keep it out of the shell history.
|
|
595
|
+
const b64 = Buffer.from(`${credLine}\n`).toString("base64");
|
|
596
|
+
const remoteCmd =
|
|
597
|
+
`f=$HOME/.codeyam-cloud-git-credentials && ` +
|
|
598
|
+
`echo ${b64} | base64 -d > $f && chmod 600 $f && echo OK`;
|
|
599
|
+
const result = runGcloud(
|
|
600
|
+
buildGcloudArgs("ssh-probe", cfg, { command: remoteCmd }),
|
|
601
|
+
{ stdio: ["ignore", "pipe", "inherit"] },
|
|
602
|
+
);
|
|
603
|
+
if (result.status !== 0) {
|
|
604
|
+
throw new Error("Failed to write ~/.codeyam-cloud-git-credentials on the VM");
|
|
605
|
+
}
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
/** Write a default `previewOrigin` into the VM's `editor.local.json` so the
|
|
609
|
+
* Live Preview iframe URL the laptop browser loads is whatever the SSH
|
|
610
|
+
* tunnel exposes, decoupled from the in-VM dev-server port. Without this,
|
|
611
|
+
* the iframe falls back to `http://localhost:${apps[0].port}` — fine when
|
|
612
|
+
* the user happens to have a same-port tunnel (today's `VITE_PORT=5173`),
|
|
613
|
+
* but broken the moment they tunnel a different local port. Skips when
|
|
614
|
+
* the user has already set `previewOrigin` themselves so per-VM overrides
|
|
615
|
+
* aren't clobbered on every `cloud:up`. */
|
|
616
|
+
async function seedPreviewOrigin(cfg) {
|
|
617
|
+
const repoName = repoBasename(cfg.repo);
|
|
618
|
+
const remotePath = `~/projects/${repoName}/.codeyam/editor.local.json`;
|
|
619
|
+
const desiredOrigin = `http://localhost:${VITE_PORT}`;
|
|
620
|
+
|
|
621
|
+
const read = runGcloud(
|
|
622
|
+
buildGcloudArgs("ssh-probe", cfg, {
|
|
623
|
+
command: `cat ${remotePath} 2>/dev/null || echo '{}'`,
|
|
624
|
+
}),
|
|
625
|
+
{ stdio: ["ignore", "pipe", "ignore"] },
|
|
626
|
+
);
|
|
627
|
+
let current = {};
|
|
628
|
+
if (read.status === 0) {
|
|
629
|
+
try {
|
|
630
|
+
const parsed = JSON.parse(read.stdout);
|
|
631
|
+
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
|
|
632
|
+
current = parsed;
|
|
633
|
+
}
|
|
634
|
+
} catch {
|
|
635
|
+
// Unparseable remote content is treated as `{}` — overwriting a
|
|
636
|
+
// corrupt local override file with a known-good one is the safer
|
|
637
|
+
// move than refusing to seed at all.
|
|
638
|
+
}
|
|
639
|
+
}
|
|
640
|
+
if (typeof current.previewOrigin === "string" && current.previewOrigin.length > 0) {
|
|
641
|
+
return;
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
console.error(`Seeding previewOrigin=${desiredOrigin} into ${remotePath}...`);
|
|
645
|
+
const merged = deepMergeJson(current, { previewOrigin: desiredOrigin });
|
|
646
|
+
const mergedJson = JSON.stringify(merged, null, 2);
|
|
647
|
+
const b64 = Buffer.from(mergedJson, "utf8").toString("base64");
|
|
648
|
+
const write = runGcloud(
|
|
649
|
+
buildGcloudArgs("ssh-probe", cfg, {
|
|
650
|
+
command: `mkdir -p ~/projects/${repoName}/.codeyam && echo '${b64}' | base64 -d > ${remotePath}`,
|
|
651
|
+
}),
|
|
652
|
+
{ stdio: ["ignore", "inherit", "inherit"] },
|
|
653
|
+
);
|
|
654
|
+
if (write.status !== 0) {
|
|
655
|
+
throw new Error(`Failed to write ${remotePath} on VM`);
|
|
656
|
+
}
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
async function seedAgentApiKey(cfg) {
|
|
660
|
+
const patch = buildEditorLocalEnvPatch(process.env, cfg.agent);
|
|
661
|
+
if (!patch) {
|
|
662
|
+
console.error(
|
|
663
|
+
`\nNo ${agentEnvVarName(cfg.agent)} set locally — skipping agent auth seed.\n` +
|
|
664
|
+
`Authenticate ${cfg.agent} later with:\n` +
|
|
665
|
+
` gcloud compute ssh ${cfg.instanceName} --project ${cfg.project} --zone ${cfg.zone} ` +
|
|
666
|
+
`--tunnel-through-iap -- ${cfg.agent} auth login\n`,
|
|
667
|
+
);
|
|
668
|
+
return;
|
|
669
|
+
}
|
|
670
|
+
const repoName = repoBasename(cfg.repo);
|
|
671
|
+
const remotePath = `~/projects/${repoName}/.codeyam/editor.local.json`;
|
|
672
|
+
console.error(`Seeding ${agentEnvVarName(cfg.agent)} into ${remotePath}...`);
|
|
673
|
+
|
|
674
|
+
const read = runGcloud(
|
|
675
|
+
buildGcloudArgs("ssh-probe", cfg, {
|
|
676
|
+
command: `cat ${remotePath} 2>/dev/null || echo '{}'`,
|
|
677
|
+
}),
|
|
678
|
+
{ stdio: ["ignore", "pipe", "ignore"] },
|
|
679
|
+
);
|
|
680
|
+
let current = {};
|
|
681
|
+
if (read.status === 0) {
|
|
682
|
+
try {
|
|
683
|
+
const parsed = JSON.parse(read.stdout);
|
|
684
|
+
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
|
|
685
|
+
current = parsed;
|
|
686
|
+
}
|
|
687
|
+
} catch {
|
|
688
|
+
// Treat unparseable remote content as `{}` — overwriting a corrupt
|
|
689
|
+
// local override file with a known-good one is the safer move than
|
|
690
|
+
// refusing to seed at all.
|
|
691
|
+
}
|
|
692
|
+
}
|
|
693
|
+
const merged = deepMergeJson(current, patch);
|
|
694
|
+
const mergedJson = JSON.stringify(merged, null, 2);
|
|
695
|
+
const b64 = Buffer.from(mergedJson, "utf8").toString("base64");
|
|
696
|
+
const write = runGcloud(
|
|
697
|
+
buildGcloudArgs("ssh-probe", cfg, {
|
|
698
|
+
command: `mkdir -p ~/projects/${repoName}/.codeyam && echo '${b64}' | base64 -d > ${remotePath}`,
|
|
699
|
+
}),
|
|
700
|
+
{ stdio: ["ignore", "inherit", "inherit"] },
|
|
701
|
+
);
|
|
702
|
+
if (write.status !== 0) {
|
|
703
|
+
throw new Error(`Failed to write ${remotePath} on VM`);
|
|
704
|
+
}
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
async function openTunnel(cfg) {
|
|
708
|
+
console.error(`Opening IAP tunnel to ${cfg.instanceName}...`);
|
|
709
|
+
const child = spawn("gcloud", buildGcloudArgs("ssh-tunnel", cfg), { stdio: "inherit" });
|
|
710
|
+
const ready14199 = await waitForPort(EDITOR_PORT, 30000, 500);
|
|
711
|
+
const ready5173 = await waitForPort(VITE_PORT, 30000, 500);
|
|
712
|
+
if (!ready14199 || !ready5173) {
|
|
713
|
+
console.error(
|
|
714
|
+
`Warning: tunnel ports not reachable on localhost (${EDITOR_PORT}=${ready14199}, ${VITE_PORT}=${ready5173}).`,
|
|
715
|
+
);
|
|
716
|
+
}
|
|
717
|
+
console.error(` Editor UI: http://localhost:${EDITOR_PORT}`);
|
|
718
|
+
console.error(` Live Preview: http://localhost:${VITE_PORT}`);
|
|
719
|
+
console.error(" (Ctrl+C to tear down the tunnel)");
|
|
720
|
+
return child;
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
function attachKeepAliveAndTeardown(child) {
|
|
724
|
+
const keepAlive = setInterval(() => {}, 60_000);
|
|
725
|
+
process.on("SIGINT", () => {
|
|
726
|
+
clearInterval(keepAlive);
|
|
727
|
+
killChildProcess(child);
|
|
728
|
+
process.exit(0);
|
|
729
|
+
});
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
function promptForInstanceName(expected) {
|
|
733
|
+
return new Promise((resolve) => {
|
|
734
|
+
const rl = readline.createInterface({
|
|
735
|
+
input: process.stdin,
|
|
736
|
+
output: process.stderr,
|
|
737
|
+
});
|
|
738
|
+
rl.question(
|
|
739
|
+
`Type the instance name '${expected}' to confirm destroy: `,
|
|
740
|
+
(ans) => {
|
|
741
|
+
rl.close();
|
|
742
|
+
resolve(ans.trim() === expected);
|
|
743
|
+
},
|
|
744
|
+
);
|
|
745
|
+
});
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
// =============================================================================
|
|
749
|
+
// Subcommand handlers
|
|
750
|
+
// =============================================================================
|
|
751
|
+
|
|
752
|
+
async function cmdUp(args) {
|
|
753
|
+
ensureGcloud();
|
|
754
|
+
const flags = parseFlags(args);
|
|
755
|
+
const cfg = loadOrInitCloudConfig(rootDir, flags);
|
|
756
|
+
|
|
757
|
+
await ensureFirewallRule(cfg);
|
|
758
|
+
await ensureInstance(cfg);
|
|
759
|
+
await ensureBootstrapped(cfg);
|
|
760
|
+
await ensureProjectBootstrapped(cfg);
|
|
761
|
+
await seedPreviewOrigin(cfg);
|
|
762
|
+
// Write the GitHub token BEFORE compose up so the credentials file exists
|
|
763
|
+
// when the bind-mount happens — bind-mounting a missing file would silently
|
|
764
|
+
// create a directory instead and the agent's first `git pull` would fail.
|
|
765
|
+
await seedGithubCredentials(cfg);
|
|
766
|
+
await ensureEditorContainerUp(cfg);
|
|
767
|
+
await seedAgentApiKey(cfg);
|
|
768
|
+
const tunnelChild = await openTunnel(cfg);
|
|
769
|
+
open(`http://localhost:${EDITOR_PORT}`);
|
|
770
|
+
attachKeepAliveAndTeardown(tunnelChild);
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
async function cmdTunnel(args) {
|
|
774
|
+
ensureGcloud();
|
|
775
|
+
const flags = parseFlags(args);
|
|
776
|
+
const existing = loadCloudConfig(rootDir);
|
|
777
|
+
if (!existing) {
|
|
778
|
+
throw new Error(
|
|
779
|
+
"No .codeyam/cloud.json found. Run `npm run cloud:up -- --repo <url>` first.",
|
|
780
|
+
);
|
|
781
|
+
}
|
|
782
|
+
const cfg = { ...existing, ...flags };
|
|
783
|
+
|
|
784
|
+
const state = fetchInstanceState(cfg);
|
|
785
|
+
if (state === null) {
|
|
786
|
+
throw new Error(
|
|
787
|
+
`Instance ${cfg.instanceName} not found in project ${cfg.project} / zone ${cfg.zone}. ` +
|
|
788
|
+
"Has it been deleted? Try `npm run cloud:up`.",
|
|
789
|
+
);
|
|
790
|
+
}
|
|
791
|
+
if (state !== "RUNNING") {
|
|
792
|
+
throw new Error(
|
|
793
|
+
`Instance ${cfg.instanceName} is ${state}; run \`npm run cloud:up\` to start it.`,
|
|
794
|
+
);
|
|
795
|
+
}
|
|
796
|
+
|
|
797
|
+
const tunnelChild = await openTunnel(cfg);
|
|
798
|
+
open(`http://localhost:${EDITOR_PORT}`);
|
|
799
|
+
attachKeepAliveAndTeardown(tunnelChild);
|
|
800
|
+
}
|
|
801
|
+
|
|
802
|
+
async function cmdStop(args) {
|
|
803
|
+
ensureGcloud();
|
|
804
|
+
parseFlags(args);
|
|
805
|
+
const cfg = loadCloudConfig(rootDir);
|
|
806
|
+
if (!cfg) {
|
|
807
|
+
throw new Error("No .codeyam/cloud.json found. Nothing to stop.");
|
|
808
|
+
}
|
|
809
|
+
console.error(`Stopping ${cfg.instanceName}...`);
|
|
810
|
+
const result = runGcloud(buildGcloudArgs("instances-stop", cfg));
|
|
811
|
+
if (result.status !== 0) {
|
|
812
|
+
throw new Error(`Failed to stop instance ${cfg.instanceName}`);
|
|
813
|
+
}
|
|
814
|
+
console.error(
|
|
815
|
+
`\n ✓ ${cfg.instanceName} is stopped.\n` +
|
|
816
|
+
" Persistent-disk billing continues (~$10/month for 100 GB pd-balanced).\n" +
|
|
817
|
+
" Resume with `npm run cloud:up`.\n",
|
|
818
|
+
);
|
|
819
|
+
}
|
|
820
|
+
|
|
821
|
+
async function cmdDestroy(args) {
|
|
822
|
+
ensureGcloud();
|
|
823
|
+
parseFlags(args);
|
|
824
|
+
const cfg = loadCloudConfig(rootDir);
|
|
825
|
+
if (!cfg) {
|
|
826
|
+
throw new Error("No .codeyam/cloud.json found. Nothing to destroy.");
|
|
827
|
+
}
|
|
828
|
+
|
|
829
|
+
const confirmed = await promptForInstanceName(cfg.instanceName);
|
|
830
|
+
if (!confirmed) {
|
|
831
|
+
console.error("Aborted.");
|
|
832
|
+
return;
|
|
833
|
+
}
|
|
834
|
+
|
|
835
|
+
console.error(`Deleting instance ${cfg.instanceName}...`);
|
|
836
|
+
const del = runGcloud(buildGcloudArgs("instances-delete", cfg));
|
|
837
|
+
if (del.status !== 0) {
|
|
838
|
+
throw new Error(`Failed to delete instance ${cfg.instanceName}`);
|
|
839
|
+
}
|
|
840
|
+
|
|
841
|
+
const list = runGcloud(buildGcloudArgs("instances-list-tagged", cfg), {
|
|
842
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
843
|
+
});
|
|
844
|
+
const others = String(list.stdout || "").trim().split("\n").filter(Boolean);
|
|
845
|
+
if (others.length === 0) {
|
|
846
|
+
console.error(`No other ${INSTANCE_TAG}-tagged instances remain — deleting firewall rule...`);
|
|
847
|
+
const fwDel = runGcloud(buildGcloudArgs("firewall-delete", cfg));
|
|
848
|
+
if (fwDel.status !== 0) {
|
|
849
|
+
console.error(` Warning: failed to delete firewall rule ${FIREWALL_RULE_NAME}.`);
|
|
850
|
+
}
|
|
851
|
+
} else {
|
|
852
|
+
console.error(
|
|
853
|
+
`Leaving ${FIREWALL_RULE_NAME} in place (${others.length} other ${INSTANCE_TAG}-tagged instance(s) still use it).`,
|
|
854
|
+
);
|
|
855
|
+
}
|
|
856
|
+
|
|
857
|
+
try {
|
|
858
|
+
fs.unlinkSync(cloudConfigPath(rootDir));
|
|
859
|
+
console.error("Deleted .codeyam/cloud.json");
|
|
860
|
+
} catch (e) {
|
|
861
|
+
if (e.code !== "ENOENT") throw e;
|
|
862
|
+
}
|
|
863
|
+
}
|
|
864
|
+
|
|
865
|
+
async function cmdStatus(args) {
|
|
866
|
+
ensureGcloud();
|
|
867
|
+
parseFlags(args);
|
|
868
|
+
const cfg = loadCloudConfig(rootDir);
|
|
869
|
+
if (!cfg) {
|
|
870
|
+
console.error("No .codeyam/cloud.json found. Run `npm run cloud:up` first.");
|
|
871
|
+
return;
|
|
872
|
+
}
|
|
873
|
+
|
|
874
|
+
const vmState = fetchInstanceState(cfg) || "NOT_FOUND";
|
|
875
|
+
console.error(`VM: ${cfg.instanceName} (${cfg.zone}) — ${vmState}`);
|
|
876
|
+
|
|
877
|
+
if (vmState !== "RUNNING") {
|
|
878
|
+
console.error(" (image / container / tunnel checks skipped — VM is not RUNNING)");
|
|
879
|
+
return;
|
|
880
|
+
}
|
|
881
|
+
|
|
882
|
+
const imgProbe = runGcloud(
|
|
883
|
+
buildGcloudArgs("ssh-probe", cfg, {
|
|
884
|
+
command:
|
|
885
|
+
"docker image inspect codeyam-editor:local >/dev/null 2>&1 && echo built || echo missing",
|
|
886
|
+
}),
|
|
887
|
+
{ stdio: ["ignore", "pipe", "ignore"] },
|
|
888
|
+
);
|
|
889
|
+
const imgStatus = String(imgProbe.stdout || "").trim() || "?";
|
|
890
|
+
console.error(` image: ${imgStatus}`);
|
|
891
|
+
|
|
892
|
+
const repoName = repoBasename(cfg.repo);
|
|
893
|
+
const containerProbe = runGcloud(
|
|
894
|
+
buildGcloudArgs("ssh-probe", cfg, {
|
|
895
|
+
command: `cd ~/projects/${repoName} 2>/dev/null && docker compose -f docker-compose.cloud.yml ps --format json 2>/dev/null || echo`,
|
|
896
|
+
}),
|
|
897
|
+
{ stdio: ["ignore", "pipe", "ignore"] },
|
|
898
|
+
);
|
|
899
|
+
const containerStatus = parseEditorComposeState(containerProbe.stdout) || "missing";
|
|
900
|
+
console.error(` editor container: ${containerStatus}`);
|
|
901
|
+
|
|
902
|
+
const p14199 = await isPortInUse(EDITOR_PORT);
|
|
903
|
+
const p5173 = await isPortInUse(VITE_PORT);
|
|
904
|
+
console.error(
|
|
905
|
+
` tunnel: ${EDITOR_PORT} ${p14199 ? "✓" : "✗"} ${VITE_PORT} ${p5173 ? "✓" : "✗"}`,
|
|
906
|
+
);
|
|
907
|
+
}
|
|
908
|
+
|
|
909
|
+
const HANDLERS = {
|
|
910
|
+
up: cmdUp,
|
|
911
|
+
tunnel: cmdTunnel,
|
|
912
|
+
stop: cmdStop,
|
|
913
|
+
destroy: cmdDestroy,
|
|
914
|
+
status: cmdStatus,
|
|
915
|
+
};
|
|
916
|
+
|
|
917
|
+
async function cloudMain() {
|
|
918
|
+
const subcmd = process.argv[2];
|
|
919
|
+
const args = process.argv.slice(3);
|
|
920
|
+
if (!subcmd || !Object.prototype.hasOwnProperty.call(HANDLERS, subcmd)) {
|
|
921
|
+
console.error(
|
|
922
|
+
`Usage: npm run cloud:<${Object.keys(HANDLERS).join("|")}> [-- flags]`,
|
|
923
|
+
);
|
|
924
|
+
process.exit(2);
|
|
925
|
+
}
|
|
926
|
+
try {
|
|
927
|
+
await HANDLERS[subcmd](args);
|
|
928
|
+
} catch (e) {
|
|
929
|
+
console.error(`Error: ${e.message}`);
|
|
930
|
+
process.exit(1);
|
|
931
|
+
}
|
|
932
|
+
}
|
|
933
|
+
|
|
934
|
+
module.exports = {
|
|
935
|
+
// pure helpers
|
|
936
|
+
agentEnvVarName,
|
|
937
|
+
AGENT_ENV_VARS,
|
|
938
|
+
KNOWN_FLAGS,
|
|
939
|
+
FIREWALL_RULE_NAME,
|
|
940
|
+
INSTANCE_TAG,
|
|
941
|
+
EDITOR_PORT,
|
|
942
|
+
VITE_PORT,
|
|
943
|
+
parseFlags,
|
|
944
|
+
repoBasename,
|
|
945
|
+
deriveInstanceName,
|
|
946
|
+
cloudConfigPath,
|
|
947
|
+
loadCloudConfig,
|
|
948
|
+
saveCloudConfig,
|
|
949
|
+
buildEditorLocalEnvPatch,
|
|
950
|
+
buildGcloudArgs,
|
|
951
|
+
sshScriptCommand,
|
|
952
|
+
parseEditorComposeState,
|
|
953
|
+
fetchInstanceState,
|
|
954
|
+
// side-effecting handlers
|
|
955
|
+
cmdUp,
|
|
956
|
+
cmdTunnel,
|
|
957
|
+
cmdStop,
|
|
958
|
+
cmdDestroy,
|
|
959
|
+
cmdStatus,
|
|
960
|
+
cloudMain,
|
|
961
|
+
seedGithubCredentials,
|
|
962
|
+
};
|
|
963
|
+
|
|
964
|
+
if (require.main === module) {
|
|
965
|
+
cloudMain();
|
|
966
|
+
}
|