@ninemind/agentgem 0.1.0

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.
@@ -0,0 +1,233 @@
1
+ import { readGemArchive, computeLock, verifyLock, writeGemArchive, readGemMeta } from "./archive.js";
2
+ import { materialize } from "./targets.js";
3
+ export const REGISTRY_FORMAT_VERSION = 1;
4
+ const SEG = /^[a-z0-9-]+$/;
5
+ export function parseRef(input) {
6
+ const at = input.indexOf("@", 1); // a version "@" can only appear after the leading "@scope/name"
7
+ const body = at > 0 ? input.slice(0, at) : input;
8
+ const range = at > 0 ? input.slice(at + 1) : "latest";
9
+ if (!body.startsWith("@"))
10
+ throw new Error(`invalid ref '${input}': must start with a scope, e.g. @scope/name`);
11
+ const slash = body.indexOf("/");
12
+ if (slash < 0)
13
+ throw new Error(`invalid ref '${input}': missing scope separator '/'`);
14
+ const scope = body.slice(1, slash);
15
+ const name = body.slice(slash + 1);
16
+ if (!SEG.test(scope) || !SEG.test(name))
17
+ throw new Error(`invalid ref '${input}': scope/name must match [a-z0-9-]`);
18
+ if (range !== "latest" && !/^\^?\d+\.\d+\.\d+$/.test(range))
19
+ throw new Error(`invalid ref '${input}': bad version range '${range}'`);
20
+ return { key: `@${scope}/${name}`, scope, name, range };
21
+ }
22
+ // ── minimal semver (exact + caret only; no external dep) ──
23
+ function parseSemver(v) {
24
+ const m = /^(\d+)\.(\d+)\.(\d+)$/.exec(v);
25
+ if (!m)
26
+ throw new Error(`invalid semver '${v}'`);
27
+ return [Number(m[1]), Number(m[2]), Number(m[3])];
28
+ }
29
+ function cmpSemver(a, b) {
30
+ const x = parseSemver(a), y = parseSemver(b);
31
+ for (let i = 0; i < 3; i++)
32
+ if (x[i] !== y[i])
33
+ return x[i] - y[i];
34
+ return 0;
35
+ }
36
+ function satisfies(version, range) {
37
+ if (!range.startsWith("^"))
38
+ return cmpSemver(version, range) === 0;
39
+ const base = range.slice(1);
40
+ const [bMaj, bMin] = parseSemver(base);
41
+ const [vMaj, vMin] = parseSemver(version);
42
+ if (cmpSemver(version, base) < 0)
43
+ return false; // must be >= base
44
+ if (bMaj > 0)
45
+ return vMaj === bMaj; // ^1.2.3 := >=1.2.3 <2.0.0
46
+ if (bMin > 0)
47
+ return vMaj === 0 && vMin === bMin; // ^0.2.3 := >=0.2.3 <0.3.0
48
+ return cmpSemver(version, base) === 0; // ^0.0.3 := exact
49
+ }
50
+ export function selectVersion(item, range) {
51
+ if (range === "latest") {
52
+ if (item.versions[item.latest] === undefined)
53
+ throw new Error(`latest version '${item.latest}' is not present in the item's versions`);
54
+ return item.latest;
55
+ }
56
+ const matches = Object.keys(item.versions).filter((v) => satisfies(v, range));
57
+ if (matches.length === 0)
58
+ throw new Error(`no version of item satisfies '${range}'`);
59
+ return matches.sort(cmpSemver)[matches.length - 1];
60
+ }
61
+ export function resolveGraph(rootRefs, index) {
62
+ const chosen = new Map(); // key -> selection
63
+ const choose = (ref, requestedBy) => {
64
+ const { key, range } = parseRef(ref);
65
+ const item = index.items[key];
66
+ if (!item)
67
+ throw new Error(`unknown item '${key}' (requested by ${requestedBy})`);
68
+ const version = selectVersion(item, range);
69
+ const prev = chosen.get(key);
70
+ if (prev && prev.version !== version) {
71
+ throw new Error(`version conflict for ${key}: ${prev.by} wants ${prev.version}, ${requestedBy} wants ${version}`);
72
+ }
73
+ if (!prev)
74
+ chosen.set(key, { version, by: requestedBy });
75
+ return { key, version };
76
+ };
77
+ const order = [];
78
+ const state = new Map();
79
+ const visit = (key, version, trail) => {
80
+ const s = state.get(key);
81
+ if (s === "done")
82
+ return;
83
+ if (s === "visiting")
84
+ throw new Error(`dependency cycle: ${[...trail, key].join(" -> ")}`);
85
+ state.set(key, "visiting");
86
+ const v = index.items[key].versions[version];
87
+ if (v === undefined)
88
+ throw new Error(`resolved version ${key}@${version} not found in the index`);
89
+ const depKeys = [];
90
+ for (const depRef of v.dependencies) {
91
+ const { key: dKey, version: dVer } = choose(depRef, `${key}@${version}`);
92
+ depKeys.push(dKey);
93
+ visit(dKey, dVer, [...trail, key]);
94
+ }
95
+ order.push({ key, version, path: v.path, gemDigest: v.gemDigest, deps: depKeys });
96
+ state.set(key, "done");
97
+ };
98
+ for (const ref of rootRefs) {
99
+ const { key, version } = choose(ref, "(root)");
100
+ visit(key, version, []);
101
+ }
102
+ return order;
103
+ }
104
+ const artifactContentKey = (a) => JSON.stringify(a);
105
+ export async function mergeGems(graph, source) {
106
+ // ancestor sets: which keys is `key` (transitively) built on? deps appear before dependents in `graph`.
107
+ const directDeps = new Map(graph.map((n) => [n.key, n.deps]));
108
+ const ancestorsOf = (key) => {
109
+ const out = new Set();
110
+ const stack = [...(directDeps.get(key) ?? [])];
111
+ while (stack.length) {
112
+ const k = stack.pop();
113
+ if (!out.has(k)) {
114
+ out.add(k);
115
+ stack.push(...(directDeps.get(k) ?? []));
116
+ }
117
+ }
118
+ return out;
119
+ };
120
+ const byName = new Map();
121
+ const secrets = new Map();
122
+ const byCheckName = new Map();
123
+ const provenance = { items: [], overrides: [] };
124
+ for (const node of graph) {
125
+ const files = await source.fetchItem(node.path);
126
+ if (files["gem.lock"] === undefined)
127
+ throw new Error(`archive for ${node.key}@${node.version} is missing gem.lock`);
128
+ const v = verifyLock(files, JSON.parse(files["gem.lock"]));
129
+ if (!v.ok)
130
+ throw new Error(`integrity failure for ${node.key}@${node.version}: lock mismatch [${v.mismatches.join(",")}]`);
131
+ if (computeLock(files).gemDigest !== node.gemDigest) {
132
+ throw new Error(`integrity failure for ${node.key}@${node.version}: digest disagrees with the registry index`);
133
+ }
134
+ const gem = readGemArchive(files);
135
+ provenance.items.push({ key: node.key, version: node.version });
136
+ for (const artifact of gem.artifacts) {
137
+ const contentKey = artifactContentKey(artifact);
138
+ const prev = byName.get(artifact.name);
139
+ if (!prev) {
140
+ byName.set(artifact.name, { artifact, owner: node.key, contentKey });
141
+ continue;
142
+ }
143
+ if (prev.contentKey === contentKey)
144
+ continue; // identical via two paths → dedup
145
+ if (ancestorsOf(node.key).has(prev.owner)) { // dependent overrides ancestor
146
+ byName.set(artifact.name, { artifact, owner: node.key, contentKey });
147
+ provenance.overrides.push({ artifact: artifact.name, winner: node.key, loser: prev.owner });
148
+ continue;
149
+ }
150
+ throw new Error(`artifact name collision: '${artifact.name}' defined by unrelated items ${prev.owner} and ${node.key}`);
151
+ }
152
+ for (const s of gem.requiredSecrets)
153
+ secrets.set(`${s.name}:${s.location}`, s);
154
+ for (const c of gem.checks) {
155
+ const contentKey = JSON.stringify(c);
156
+ const prev = byCheckName.get(c.name);
157
+ if (!prev) {
158
+ byCheckName.set(c.name, { check: c, owner: node.key, contentKey });
159
+ continue;
160
+ }
161
+ if (prev.contentKey === contentKey)
162
+ continue; // identical via two paths → dedup
163
+ if (ancestorsOf(node.key).has(prev.owner)) { // dependent overrides ancestor
164
+ byCheckName.set(c.name, { check: c, owner: node.key, contentKey });
165
+ continue;
166
+ }
167
+ throw new Error(`check name collision: '${c.name}' defined by unrelated items ${prev.owner} and ${node.key}`);
168
+ }
169
+ }
170
+ const rootKey = graph.length ? graph[graph.length - 1].key : "(empty)";
171
+ const rootVer = graph.length ? graph[graph.length - 1].version : "0.0.0";
172
+ const merged = {
173
+ name: rootKey.split("/").pop() ?? "gem",
174
+ createdFrom: `registry:${rootKey}@${rootVer}`,
175
+ artifacts: [...byName.values()].map((e) => e.artifact),
176
+ checks: [...byCheckName.values()].map((e) => e.check),
177
+ requiredSecrets: [...secrets.values()],
178
+ };
179
+ return { gem: merged, provenance };
180
+ }
181
+ export function updateIndex(index, e) {
182
+ const items = { ...index.items };
183
+ const existing = items[e.key];
184
+ const versions = { ...(existing?.versions ?? {}) };
185
+ const existingVersion = existing?.versions[e.version];
186
+ if (existingVersion && existingVersion.gemDigest !== e.gemDigest) {
187
+ throw new Error(`${e.key}@${e.version} is immutable (published ${existingVersion.gemDigest}, attempted ${e.gemDigest})`);
188
+ }
189
+ versions[e.version] = { path: e.path, gemDigest: e.gemDigest, dependencies: e.dependencies };
190
+ const latest = existing && cmpSemver(existing.latest, e.version) >= 0 ? existing.latest : e.version;
191
+ items[e.key] = { latest, versions };
192
+ return { formatVersion: REGISTRY_FORMAT_VERSION, items };
193
+ }
194
+ export async function publishGem(args) {
195
+ const name = args.name ?? args.gem.name;
196
+ if (!SEG.test(args.scope) || !SEG.test(name))
197
+ throw new Error(`invalid scope/name '@${args.scope}/${name}': must match [a-z0-9-]`);
198
+ parseSemver(args.version); // validate
199
+ const key = `@${args.scope}/${name}`;
200
+ const path = `items/${args.scope}/${name}/${args.version}`;
201
+ const { files } = writeGemArchive(args.gem, { version: args.version, dependencies: args.dependencies });
202
+ const { gemDigest, dependencies } = readGemMeta(files);
203
+ const prior = args.index.items[key]?.versions[args.version];
204
+ if (prior && prior.gemDigest !== gemDigest) {
205
+ throw new Error(`${key}@${args.version} is already published and immutable (published ${prior.gemDigest}, attempted ${gemDigest})`);
206
+ }
207
+ if (prior && prior.gemDigest === gemDigest) {
208
+ return { ref: key, version: args.version, gemDigest, commit: "", path };
209
+ }
210
+ const nextIndex = updateIndex(args.index, { key, version: args.version, path, gemDigest, dependencies });
211
+ const commitFiles = { "registry.json": JSON.stringify(nextIndex, null, 2) };
212
+ for (const [rel, content] of Object.entries(files))
213
+ commitFiles[`${path}/${rel}`] = content;
214
+ const { commit } = await args.publisher.putCommit(commitFiles, `publish ${key}@${args.version}`);
215
+ return { ref: key, version: args.version, gemDigest, commit, path };
216
+ }
217
+ export async function resolveInstall(args) {
218
+ const index = await args.source.getIndex();
219
+ const graph = resolveGraph(args.refs, index);
220
+ const { gem, provenance } = await mergeGems(graph, args.source);
221
+ const plan = {
222
+ items: provenance.items,
223
+ totalArtifacts: gem.artifacts.length,
224
+ requiredSecrets: gem.requiredSecrets,
225
+ overrides: provenance.overrides,
226
+ };
227
+ if (args.mode === "materialize") {
228
+ if (!args.target)
229
+ throw new Error("materialize mode requires a target harness id");
230
+ plan.materialize = materialize(gem, args.target);
231
+ }
232
+ return { plan, gem };
233
+ }
@@ -0,0 +1,74 @@
1
+ const API = "https://api.github.com";
2
+ const defaultHttp = async (url, init) => {
3
+ const res = await fetch(url, init);
4
+ return { status: res.status, text: () => res.text() };
5
+ };
6
+ function headers(cfg) {
7
+ const h = { Accept: "application/vnd.github+json", "User-Agent": "agentgem" };
8
+ if (cfg.token)
9
+ h.Authorization = `Bearer ${cfg.token}`;
10
+ return h;
11
+ }
12
+ async function ghJson(http, cfg, path, init) {
13
+ const res = await http(`${API}/repos/${cfg.repo}/${path}`, { ...init, headers: headers(cfg) });
14
+ const body = await res.text();
15
+ if (res.status >= 300)
16
+ throw new Error(`GitHub ${init?.method ?? "GET"} ${path} → ${res.status}: ${body}`);
17
+ return body ? JSON.parse(body) : null;
18
+ }
19
+ export function githubRegistrySource(cfg, http = defaultHttp) {
20
+ const contents = (p) => ghJson(http, cfg, `contents/${encodeURIComponent(p).replace(/%2F/g, "/")}?ref=${encodeURIComponent(cfg.ref)}`);
21
+ return {
22
+ id: "github", label: `GitHub ${cfg.repo}`,
23
+ ready: () => cfg.repo.length > 0,
24
+ async getIndex() {
25
+ const node = (await contents("registry.json"));
26
+ return JSON.parse(Buffer.from(node.content, "base64").toString("utf8"));
27
+ },
28
+ async fetchItem(itemPath) {
29
+ const files = {};
30
+ const walk = async (p) => {
31
+ const node = await contents(p);
32
+ if (Array.isArray(node)) {
33
+ for (const e of node)
34
+ await walk(e.path);
35
+ }
36
+ else {
37
+ const f = node;
38
+ files[p.slice(itemPath.length + 1)] = Buffer.from(f.content, "base64").toString("utf8");
39
+ }
40
+ };
41
+ await walk(itemPath);
42
+ return files;
43
+ },
44
+ };
45
+ }
46
+ export function githubRegistryPublisher(cfg, http = defaultHttp) {
47
+ if (!cfg.token)
48
+ throw new Error("publishing requires GITHUB_TOKEN");
49
+ return {
50
+ async putCommit(files, message) {
51
+ // GitHub API asymmetry: GET a single ref is singular "git/ref/...", PATCH update is plural "git/refs/..." (below). Do not "fix" to plural — plural GET returns an array.
52
+ const ref = (await ghJson(http, cfg, `git/ref/heads/${cfg.ref}`));
53
+ const base = ref.object.sha;
54
+ const baseCommit = (await ghJson(http, cfg, `git/commits/${base}`));
55
+ const tree = await Promise.all(Object.entries(files).map(async ([path, content]) => {
56
+ const blob = (await ghJson(http, cfg, "git/blobs", { method: "POST", body: JSON.stringify({ content, encoding: "utf-8" }) }));
57
+ return { path, mode: "100644", type: "blob", sha: blob.sha };
58
+ }));
59
+ const newTree = (await ghJson(http, cfg, "git/trees", { method: "POST", body: JSON.stringify({ base_tree: baseCommit.tree.sha, tree }) }));
60
+ const commit = (await ghJson(http, cfg, "git/commits", { method: "POST", body: JSON.stringify({ message, tree: newTree.sha, parents: [base] }) }));
61
+ await ghJson(http, cfg, `git/refs/heads/${cfg.ref}`, { method: "PATCH", body: JSON.stringify({ sha: commit.sha }) });
62
+ return { commit: commit.sha };
63
+ },
64
+ };
65
+ }
66
+ export function registryConfigFromEnv() {
67
+ const repo = process.env.AGENTGEM_REGISTRY_REPO;
68
+ if (!repo)
69
+ return null;
70
+ return { repo, ref: process.env.AGENTGEM_REGISTRY_REF ?? "main", token: process.env.GITHUB_TOKEN };
71
+ }
72
+ export function registryReady() {
73
+ return registryConfigFromEnv() !== null;
74
+ }
@@ -0,0 +1,322 @@
1
+ // src/gem/run.ts
2
+ // Run/deploy the rendered eve project. Side-effecting orchestration (peer of workspaces.ts).
3
+ // Process spawning is injected via ProcessRunner so command/env/state logic is unit-testable.
4
+ import { spawn as nodeSpawn } from "node:child_process";
5
+ import { existsSync, mkdirSync, rmSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
6
+ import { join } from "node:path";
7
+ import { workspaceDir } from "./workspaces.js";
8
+ import { readGemArchive } from "./archive.js";
9
+ import { readArchiveDir, writeArchiveDir } from "./archiveFs.js";
10
+ import { materialize, flueWorkerName } from "./targets.js";
11
+ import { writeDeployRecord, readDeployRecord, clearDeployRecord } from "./deployRecord.js";
12
+ const LOG_CAP = 200;
13
+ export function pushLog(buf, line) {
14
+ buf.push(line);
15
+ if (buf.length > LOG_CAP)
16
+ buf.splice(0, buf.length - LOG_CAP);
17
+ return buf;
18
+ }
19
+ export function nodeMajor(version) {
20
+ const m = /^v?(\d+)/.exec(version);
21
+ return m ? Number(m[1]) : 0;
22
+ }
23
+ export function runReadiness() {
24
+ return { local: nodeMajor(process.version) >= 24, vercel: !!process.env.VERCEL_TOKEN, cloudflare: !!process.env.CLOUDFLARE_API_TOKEN };
25
+ }
26
+ // eve start prints a localhost URL once listening; grab the first http(s) URL.
27
+ export function parseEveUrl(lines) {
28
+ for (const l of lines) {
29
+ const m = /(https?:\/\/[^\s]+)/.exec(l);
30
+ if (m)
31
+ return m[1];
32
+ }
33
+ return undefined;
34
+ }
35
+ // vercel deploy prints the deployment URL (a bare https://<id>.vercel.app line).
36
+ export function parseVercelUrl(lines) {
37
+ for (const l of lines) {
38
+ const m = /(https:\/\/[^\s]+\.vercel\.app[^\s]*)/.exec(l);
39
+ if (m)
40
+ return m[1];
41
+ }
42
+ return undefined;
43
+ }
44
+ // When `vercel deploy` runs non-interactively under a token whose account has teams, the CLI
45
+ // refuses with a structured "missing_scope" response listing the available teams. If there is
46
+ // exactly ONE team, return its name so we can retry with --scope; otherwise undefined (ambiguous).
47
+ export function parseSingleTeamScope(lines) {
48
+ const text = lines.join("\n");
49
+ if (!/missing_scope|action_required/.test(text))
50
+ return undefined;
51
+ const start = text.indexOf("{"), end = text.lastIndexOf("}");
52
+ if (start < 0 || end <= start)
53
+ return undefined;
54
+ try {
55
+ const obj = JSON.parse(text.slice(start, end + 1));
56
+ const choices = Array.isArray(obj.choices) ? obj.choices : [];
57
+ if (choices.length === 1 && typeof choices[0].name === "string")
58
+ return choices[0].name;
59
+ }
60
+ catch { /* not the structured response */ }
61
+ return undefined;
62
+ }
63
+ // wrangler prints the deployed Worker URL (https://<name>.<acct>.workers.dev).
64
+ export function parseWorkersUrl(lines) {
65
+ for (const l of lines) {
66
+ const m = /(https:\/\/[^\s]+\.workers\.dev[^\s]*)/.exec(l);
67
+ if (m)
68
+ return m[1];
69
+ }
70
+ return undefined;
71
+ }
72
+ // Real runner: line-buffer stdout/stderr; deliver whole lines.
73
+ export const realRunner = {
74
+ spawn(cmd, args, opts) {
75
+ const child = nodeSpawn(cmd, args, { cwd: opts.cwd, env: opts.env });
76
+ const lineCbs = [];
77
+ const exitCbs = [];
78
+ const wire = (stream, which) => {
79
+ if (!stream)
80
+ return;
81
+ let buf = "";
82
+ stream.on("data", (d) => {
83
+ buf += d.toString();
84
+ let i;
85
+ while ((i = buf.indexOf("\n")) >= 0) {
86
+ const line = buf.slice(0, i);
87
+ buf = buf.slice(i + 1);
88
+ lineCbs.forEach((cb) => cb(line, which));
89
+ }
90
+ });
91
+ };
92
+ wire(child.stdout, "out");
93
+ wire(child.stderr, "err");
94
+ child.on("exit", (code) => exitCbs.forEach((cb) => cb(code)));
95
+ child.on("error", () => exitCbs.forEach((cb) => cb(1)));
96
+ return {
97
+ onLine: (cb) => { lineCbs.push(cb); },
98
+ onExit: (cb) => { exitCbs.push(cb); },
99
+ kill: () => { child.kill(); },
100
+ };
101
+ },
102
+ };
103
+ // Run one command to completion; pipe its lines into `log`; resolve with the exit code.
104
+ export function runToEnd(runner, cmd, args, cwd, env, log) {
105
+ return new Promise((resolve) => {
106
+ const h = runner.spawn(cmd, args, { cwd, env });
107
+ h.onLine((line) => pushLog(log, line));
108
+ h.onExit((code) => resolve(code ?? 0));
109
+ });
110
+ }
111
+ // Re-render <target> into a stable .run/<target> dir (preserving node_modules) and npm-install when needed.
112
+ export async function ensureRunProject(name, target, runner, log, opts = {}) {
113
+ const dir = workspaceDir(name);
114
+ if (!existsSync(join(dir, "gem.json")))
115
+ throw new Error(`no workspace '${name}'`);
116
+ const gem = readGemArchive(readArchiveDir(dir));
117
+ const { files } = materialize(gem, target, opts);
118
+ const runDir = target === "eve" ? join(dir, ".run", vercelProject(name)) : join(dir, ".run", target);
119
+ mkdirSync(runDir, { recursive: true });
120
+ // Drop stale rendered sources + build caches; keep node_modules + the install marker.
121
+ for (const entry of readdirSync(runDir)) {
122
+ if (entry === "node_modules" || entry === ".installed-package.json")
123
+ continue;
124
+ rmSync(join(runDir, entry), { recursive: true, force: true });
125
+ }
126
+ writeArchiveDir(runDir, files);
127
+ const pkg = readFileSync(join(runDir, "package.json"), "utf8");
128
+ const marker = join(runDir, ".installed-package.json");
129
+ const installed = existsSync(marker) ? readFileSync(marker, "utf8") : "";
130
+ if (!existsSync(join(runDir, "node_modules")) || installed !== pkg) {
131
+ const code = await runToEnd(runner, "npm", ["install", "--no-audit", "--no-fund"], runDir, process.env, log);
132
+ if (code !== 0)
133
+ throw new Error("npm install failed");
134
+ writeFileSync(marker, pkg, "utf8");
135
+ }
136
+ return runDir;
137
+ }
138
+ // Per-gem Vercel project name: eve-<slug(name)>. Slug = lowercase, non-alnum→'-', trimmed.
139
+ // Vercel derives the project name from the deploy directory's basename, so we name the runDir accordingly.
140
+ export const vercelProject = (name) => "eve-" + (name.normalize("NFKC").toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "") || "agent");
141
+ const registry = new Map();
142
+ const EVE_BIN = (runDir) => join(runDir, "node_modules", ".bin", "eve");
143
+ export async function startLocal(name, runner = realRunner) {
144
+ for (const e of registry.values()) {
145
+ if (e.state.mode === "local" && e.state.state === "running")
146
+ throw new Error("a local run is already active");
147
+ }
148
+ const state = { mode: "local", state: "installing", logTail: [] };
149
+ registry.set(`${name}:eve`, { state });
150
+ try {
151
+ const runDir = await ensureRunProject(name, "eve", runner, state.logTail);
152
+ state.state = "building";
153
+ const buildCode = await runToEnd(runner, EVE_BIN(runDir), ["build"], runDir, process.env, state.logTail);
154
+ if (buildCode !== 0) {
155
+ state.state = "failed";
156
+ return state;
157
+ }
158
+ const handle = runner.spawn(EVE_BIN(runDir), ["start"], { cwd: runDir, env: process.env });
159
+ registry.set(`${name}:eve`, { state, handle });
160
+ state.state = "running";
161
+ handle.onLine((line) => {
162
+ pushLog(state.logTail, line);
163
+ if (!state.url) {
164
+ const u = parseEveUrl([line]);
165
+ if (u)
166
+ state.url = u;
167
+ }
168
+ });
169
+ handle.onExit((code) => { if (state.state === "running")
170
+ state.state = code === 0 ? "idle" : "failed"; });
171
+ return state;
172
+ }
173
+ catch (err) {
174
+ state.state = "failed";
175
+ pushLog(state.logTail, err instanceof Error ? err.message : String(err));
176
+ return state;
177
+ }
178
+ }
179
+ export function stopLocal(name, target) {
180
+ const e = registry.get(`${name}:${target}`);
181
+ if (!e?.handle)
182
+ return { stopped: false };
183
+ e.handle.kill();
184
+ e.state.state = "idle";
185
+ return { stopped: true };
186
+ }
187
+ export function getRunStatus(name, target) {
188
+ return registry.get(`${name}:${target}`)?.state ?? { mode: "local", state: "idle", logTail: [] };
189
+ }
190
+ const binIn = (runDir, name) => join(runDir, "node_modules", ".bin", name);
191
+ // agentgem's own pinned vercel CLI (installed as a dependency), run with cwd = the eve run dir.
192
+ const VERCEL_BIN = join(process.cwd(), "node_modules", ".bin", "vercel");
193
+ // Deploy the eve project to Vercel from SOURCE (not --prebuilt): eve warns that a local prebuilt
194
+ // build skips Vercel sandbox-template prewarm, so Vercel must build it. Scope: use VERCEL_SCOPE if
195
+ // set, else deploy without scope and — when the CLI refuses with a single available team — retry
196
+ // with --scope <that team>.
197
+ export async function deployVercel(name, runner = realRunner, opts = {}) {
198
+ const token = process.env.VERCEL_TOKEN;
199
+ if (!token)
200
+ throw new Error("VERCEL_TOKEN is not set on the server — cannot deploy to Vercel.");
201
+ const state = { mode: "vercel", state: "installing", logTail: [] };
202
+ registry.set(`${name}:eve`, { state });
203
+ const vercelDeploy = (runDir, scope) => {
204
+ const args = ["deploy", "--yes", `--token=${token}`, ...(scope ? [`--scope=${scope}`] : [])];
205
+ const lines = [];
206
+ return new Promise((resolve) => {
207
+ const h = runner.spawn(VERCEL_BIN, args, { cwd: runDir, env: process.env });
208
+ h.onLine((line) => { pushLog(state.logTail, line); lines.push(line); });
209
+ h.onExit((c) => resolve({ code: c ?? 0, lines }));
210
+ });
211
+ };
212
+ try {
213
+ const runDir = await ensureRunProject(name, "eve", runner, state.logTail, opts);
214
+ state.state = "deploying";
215
+ const explicitScope = process.env.VERCEL_SCOPE;
216
+ let { code, lines } = await vercelDeploy(runDir, explicitScope);
217
+ if (code !== 0 && !explicitScope) {
218
+ const team = parseSingleTeamScope(lines);
219
+ if (team) {
220
+ pushLog(state.logTail, `↻ retrying with --scope ${team}`);
221
+ ({ code, lines } = await vercelDeploy(runDir, team));
222
+ }
223
+ }
224
+ if (code !== 0) {
225
+ state.state = "failed";
226
+ return state;
227
+ }
228
+ state.url = parseVercelUrl(lines);
229
+ state.state = "idle";
230
+ writeDeployRecord(name, { backend: "eve", at: new Date().toISOString(), url: state.url, project: vercelProject(name) });
231
+ return state;
232
+ }
233
+ catch (err) {
234
+ state.state = "failed";
235
+ pushLog(state.logTail, err instanceof Error ? err.message : String(err));
236
+ return state;
237
+ }
238
+ }
239
+ export async function undeployVercel(name, runner = realRunner) {
240
+ const token = process.env.VERCEL_TOKEN;
241
+ if (!token)
242
+ throw new Error("VERCEL_TOKEN is not set on the server — cannot undeploy from Vercel.");
243
+ const rec = readDeployRecord(name, "eve");
244
+ if (!rec?.project)
245
+ throw new Error(`no recorded eve/Vercel deploy for '${name}'`);
246
+ const logTail = [];
247
+ const scope = process.env.VERCEL_SCOPE;
248
+ const run = (s) => new Promise((resolve) => {
249
+ const lines = [];
250
+ const args = ["remove", rec.project, "--yes", `--token=${token}`, ...(s ? [`--scope=${s}`] : [])];
251
+ const h = runner.spawn(VERCEL_BIN, args, { cwd: workspaceDir(name), env: process.env });
252
+ h.onLine((l) => { pushLog(logTail, l); lines.push(l); });
253
+ h.onExit((c) => resolve({ code: c ?? 0, lines }));
254
+ });
255
+ let { code, lines } = await run(scope);
256
+ if (code !== 0 && !scope) {
257
+ const team = parseSingleTeamScope(lines);
258
+ if (team)
259
+ ({ code } = await run(team));
260
+ }
261
+ if (code !== 0)
262
+ return { removed: false, logTail };
263
+ clearDeployRecord(name, "eve");
264
+ return { removed: true, logTail };
265
+ }
266
+ export async function deployCloudflare(name, runner = realRunner) {
267
+ const token = process.env.CLOUDFLARE_API_TOKEN;
268
+ if (!token)
269
+ throw new Error("CLOUDFLARE_API_TOKEN is not set on the server — cannot deploy to Cloudflare.");
270
+ const state = { mode: "cloudflare", state: "installing", logTail: [] };
271
+ registry.set(`${name}:flue`, { state });
272
+ try {
273
+ const runDir = await ensureRunProject(name, "flue", runner, state.logTail);
274
+ state.state = "building";
275
+ const buildCode = await runToEnd(runner, binIn(runDir, "flue"), ["build", "--target", "cloudflare"], runDir, process.env, state.logTail);
276
+ if (buildCode !== 0) {
277
+ state.state = "failed";
278
+ return state;
279
+ }
280
+ state.state = "deploying";
281
+ const lines = [];
282
+ const env = { ...process.env, CLOUDFLARE_API_TOKEN: token };
283
+ const code = await new Promise((resolve) => {
284
+ const h = runner.spawn(binIn(runDir, "wrangler"), ["deploy"], { cwd: runDir, env });
285
+ h.onLine((line) => { pushLog(state.logTail, line); lines.push(line); });
286
+ h.onExit((c) => resolve(c ?? 0));
287
+ });
288
+ if (code !== 0) {
289
+ state.state = "failed";
290
+ return state;
291
+ }
292
+ state.url = parseWorkersUrl(lines);
293
+ writeDeployRecord(name, { backend: "flue", at: new Date().toISOString(), url: state.url, worker: flueWorkerName(name) });
294
+ state.state = "idle";
295
+ return state;
296
+ }
297
+ catch (err) {
298
+ state.state = "failed";
299
+ pushLog(state.logTail, err instanceof Error ? err.message : String(err));
300
+ return state;
301
+ }
302
+ }
303
+ export async function undeployCloudflare(name, runner = realRunner) {
304
+ const token = process.env.CLOUDFLARE_API_TOKEN;
305
+ if (!token)
306
+ throw new Error("CLOUDFLARE_API_TOKEN is not set on the server — cannot undeploy from Cloudflare.");
307
+ const rec = readDeployRecord(name, "flue");
308
+ if (!rec?.worker)
309
+ throw new Error(`no recorded flue/Cloudflare deploy for '${name}'`);
310
+ const runDir = join(workspaceDir(name), ".run", "flue");
311
+ const logTail = [];
312
+ const env = { ...process.env, CLOUDFLARE_API_TOKEN: token };
313
+ const code = await new Promise((resolve) => {
314
+ const h = runner.spawn(binIn(runDir, "wrangler"), ["delete", "--name", rec.worker, "--force"], { cwd: runDir, env });
315
+ h.onLine((l) => pushLog(logTail, l));
316
+ h.onExit((c) => resolve(c ?? 0));
317
+ });
318
+ if (code !== 0)
319
+ return { removed: false, logTail };
320
+ clearDeployRecord(name, "flue");
321
+ return { removed: true, logTail };
322
+ }