zooid 0.6.1 → 0.7.1
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/LICENSE +1 -1
- package/README.md +36 -444
- package/dist/bin.js +1891 -0
- package/dist/bin.js.map +1 -0
- package/dist/chunk-SUPTPSN3.js +31434 -0
- package/dist/chunk-SUPTPSN3.js.map +1 -0
- package/dist/index.d.ts +64 -0
- package/dist/index.js +4 -3790
- package/dist/index.js.map +1 -0
- package/dist/web/assets/geist-cyrillic-wght-normal-CHSlOQsW.woff2 +0 -0
- package/dist/web/assets/geist-latin-ext-wght-normal-DMtmJ5ZE.woff2 +0 -0
- package/dist/web/assets/geist-latin-wght-normal-Dm3htQBi.woff2 +0 -0
- package/dist/web/assets/index-C2MAIew3.css +1 -0
- package/dist/web/assets/index-CqfWXqvO.js +118066 -0
- package/dist/web/assets/index-Cz5fEZBc.js +295 -0
- package/dist/web/assets/reaction-picker-emoji-cME0vOsX.js +716 -0
- package/dist/web/favicon.svg +1 -0
- package/dist/web/index.html +14 -0
- package/package.json +43 -22
- package/src/bin.ts +115 -0
- package/src/bootstrap/admin.test.ts +69 -0
- package/src/bootstrap/admin.ts +37 -0
- package/src/bootstrap/configs.test.ts +24 -0
- package/src/bootstrap/configs.ts +64 -0
- package/src/bootstrap/data-layout.test.ts +25 -0
- package/src/bootstrap/data-layout.ts +20 -0
- package/src/bootstrap/derive.test.ts +74 -0
- package/src/bootstrap/derive.ts +40 -0
- package/src/bootstrap/paths.test.ts +22 -0
- package/src/bootstrap/paths.ts +27 -0
- package/src/bootstrap/tokens.test.ts +44 -0
- package/src/bootstrap/tokens.ts +43 -0
- package/src/build-registry.context.test.ts +61 -0
- package/src/build-registry.test.ts +59 -0
- package/src/build-registry.ts +343 -0
- package/src/build-registry.zod043.test.ts +64 -0
- package/src/build-registry.zod044.test.ts +261 -0
- package/src/commands/dev-cascade.test.ts +38 -0
- package/src/commands/dev-cascade.ts +35 -0
- package/src/commands/dev-data-layout.test.ts +55 -0
- package/src/commands/dev.ts +316 -0
- package/src/commands/init/generators.test.ts +97 -0
- package/src/commands/init/generators.ts +124 -0
- package/src/commands/init/prompts.ts +123 -0
- package/src/commands/init/registry.test.ts +33 -0
- package/src/commands/init/registry.ts +75 -0
- package/src/commands/init/sniff.test.ts +41 -0
- package/src/commands/init/sniff.ts +30 -0
- package/src/commands/init.test.ts +131 -0
- package/src/commands/init.ts +158 -0
- package/src/commands/logs.test.ts +105 -0
- package/src/commands/logs.ts +108 -0
- package/src/commands/start.ts +28 -0
- package/src/commands/status.test.ts +73 -0
- package/src/commands/status.ts +105 -0
- package/src/daemon/start-daemon.test.ts +67 -0
- package/src/daemon/start-daemon.ts +270 -0
- package/src/index.ts +2 -0
- package/src/observability/capture-agent.ts +58 -0
- package/src/observability/capture-tuwunel.test.ts +57 -0
- package/src/observability/capture-tuwunel.ts +24 -0
- package/src/observability/file-sink.test.ts +68 -0
- package/src/observability/file-sink.ts +78 -0
- package/src/observability/observability.integration.test.ts +162 -0
- package/src/observability/paths.test.ts +104 -0
- package/src/observability/paths.ts +94 -0
- package/src/prepull-images.test.ts +149 -0
- package/src/prepull-images.ts +100 -0
- package/src/services/tuwunel.test.ts +64 -0
- package/src/services/tuwunel.ts +91 -0
- package/src/web/resolve.test.ts +50 -0
- package/src/web/resolve.ts +31 -0
- package/src/web/static.test.ts +51 -0
- package/src/web/static.ts +61 -0
- package/src/web/watch.ts +103 -0
- package/dist/chunk-67ZRMVHO.js +0 -174
- package/dist/chunk-AR456MHY.js +0 -29
- package/dist/chunk-VBGU2NST.js +0 -139
- package/dist/client-4VMFEFDX.js +0 -22
- package/dist/config-2KK5GX42.js +0 -27
- package/dist/template-T5IB4YWC.js +0 -92
package/dist/index.js
CHANGED
|
@@ -1,3794 +1,8 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import {
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
createSubscribeClient,
|
|
6
|
-
normalizeServerUrl,
|
|
7
|
-
resolveChannel
|
|
8
|
-
} from "./chunk-VBGU2NST.js";
|
|
9
|
-
import {
|
|
10
|
-
getConfigDir,
|
|
11
|
-
getStatePath,
|
|
12
|
-
loadConfig,
|
|
13
|
-
loadConfigFile,
|
|
14
|
-
loadDirectoryToken,
|
|
15
|
-
recordTailHistory,
|
|
16
|
-
resolveServer,
|
|
17
|
-
saveConfig,
|
|
18
|
-
saveDirectoryToken,
|
|
19
|
-
switchServer
|
|
20
|
-
} from "./chunk-67ZRMVHO.js";
|
|
21
|
-
import {
|
|
22
|
-
parseGitHubUrl
|
|
23
|
-
} from "./chunk-AR456MHY.js";
|
|
24
|
-
|
|
25
|
-
// src/index.ts
|
|
26
|
-
import { Command } from "commander";
|
|
27
|
-
|
|
28
|
-
// src/lib/telemetry.ts
|
|
29
|
-
import fs from "fs";
|
|
30
|
-
import path from "path";
|
|
31
|
-
import { randomUUID } from "crypto";
|
|
32
|
-
import { spawn } from "child_process";
|
|
33
|
-
var TELEMETRY_ENDPOINT = "https://telemetry.zooid.dev/v1/events";
|
|
34
|
-
var QUEUE_FILENAME = "telemetry.json";
|
|
35
|
-
var MAX_QUEUE_SIZE = 1e3;
|
|
36
|
-
function isEnabled() {
|
|
37
|
-
const envVar = process.env.ZOOID_TELEMETRY;
|
|
38
|
-
if (envVar !== void 0) {
|
|
39
|
-
return envVar !== "0" && envVar.toLowerCase() !== "false" && envVar.toLowerCase() !== "off";
|
|
40
|
-
}
|
|
41
|
-
const flag = readTelemetryFlag();
|
|
42
|
-
if (flag === false) return false;
|
|
43
|
-
return true;
|
|
44
|
-
}
|
|
45
|
-
function showNoticeIfNeeded() {
|
|
46
|
-
if (process.env.ZOOID_TELEMETRY === "0") return false;
|
|
47
|
-
const flag = readTelemetryFlag();
|
|
48
|
-
if (flag !== void 0) return false;
|
|
49
|
-
console.log("");
|
|
50
|
-
console.log(" Zooid collects anonymous usage metrics to improve the tool");
|
|
51
|
-
console.log(" and help rank channels in the public directory.");
|
|
52
|
-
console.log("");
|
|
53
|
-
console.log(" To opt out: npx zooid config set telemetry off");
|
|
54
|
-
console.log(" Or set ZOOID_TELEMETRY=0 in your environment.");
|
|
55
|
-
console.log("");
|
|
56
|
-
writeTelemetryFlag(true);
|
|
57
|
-
return true;
|
|
58
|
-
}
|
|
59
|
-
function writeEvent(event) {
|
|
60
|
-
const queuePath = getQueuePath();
|
|
61
|
-
const dir = getConfigDir();
|
|
62
|
-
try {
|
|
63
|
-
fs.mkdirSync(dir, { recursive: true });
|
|
64
|
-
let queue = [];
|
|
65
|
-
try {
|
|
66
|
-
const raw = fs.readFileSync(queuePath, "utf-8");
|
|
67
|
-
queue = JSON.parse(raw);
|
|
68
|
-
} catch {
|
|
69
|
-
}
|
|
70
|
-
queue.push(event);
|
|
71
|
-
if (queue.length > MAX_QUEUE_SIZE) {
|
|
72
|
-
queue = queue.slice(-MAX_QUEUE_SIZE);
|
|
73
|
-
}
|
|
74
|
-
fs.writeFileSync(queuePath, JSON.stringify(queue, null, 2) + "\n");
|
|
75
|
-
} catch {
|
|
76
|
-
}
|
|
77
|
-
}
|
|
78
|
-
function flushInBackground() {
|
|
79
|
-
const queuePath = getQueuePath();
|
|
80
|
-
try {
|
|
81
|
-
const raw = fs.readFileSync(queuePath, "utf-8");
|
|
82
|
-
const parsed = JSON.parse(raw);
|
|
83
|
-
if (!Array.isArray(parsed) || parsed.length === 0) return;
|
|
84
|
-
} catch {
|
|
85
|
-
return;
|
|
86
|
-
}
|
|
87
|
-
try {
|
|
88
|
-
const child = spawn(process.execPath, ["-e", flushScript(queuePath)], {
|
|
89
|
-
detached: true,
|
|
90
|
-
stdio: "ignore",
|
|
91
|
-
env: { ...process.env }
|
|
92
|
-
});
|
|
93
|
-
child.unref();
|
|
94
|
-
} catch {
|
|
95
|
-
}
|
|
96
|
-
}
|
|
97
|
-
function getInstallId() {
|
|
98
|
-
const configPath = getStatePath();
|
|
99
|
-
try {
|
|
100
|
-
const raw = fs.readFileSync(configPath, "utf-8");
|
|
101
|
-
const parsed = JSON.parse(raw);
|
|
102
|
-
if (parsed.install_id) return parsed.install_id;
|
|
103
|
-
} catch {
|
|
104
|
-
}
|
|
105
|
-
const id = randomUUID();
|
|
106
|
-
persistToConfig("install_id", id);
|
|
107
|
-
return id;
|
|
108
|
-
}
|
|
109
|
-
function getQueuePath() {
|
|
110
|
-
return path.join(getConfigDir(), QUEUE_FILENAME);
|
|
111
|
-
}
|
|
112
|
-
function readTelemetryFlag() {
|
|
113
|
-
const configPath = getStatePath();
|
|
114
|
-
try {
|
|
115
|
-
const raw = fs.readFileSync(configPath, "utf-8");
|
|
116
|
-
const parsed = JSON.parse(raw);
|
|
117
|
-
if (parsed.telemetry === true) return true;
|
|
118
|
-
if (parsed.telemetry === false) return false;
|
|
119
|
-
return void 0;
|
|
120
|
-
} catch {
|
|
121
|
-
return void 0;
|
|
122
|
-
}
|
|
123
|
-
}
|
|
124
|
-
function writeTelemetryFlag(enabled) {
|
|
125
|
-
persistToConfig("telemetry", enabled);
|
|
126
|
-
}
|
|
127
|
-
function persistToConfig(key, value) {
|
|
128
|
-
const dir = getConfigDir();
|
|
129
|
-
const configPath = getStatePath();
|
|
130
|
-
try {
|
|
131
|
-
fs.mkdirSync(dir, { recursive: true });
|
|
132
|
-
let config = {};
|
|
133
|
-
try {
|
|
134
|
-
const raw = fs.readFileSync(configPath, "utf-8");
|
|
135
|
-
config = JSON.parse(raw);
|
|
136
|
-
} catch {
|
|
137
|
-
}
|
|
138
|
-
config[key] = value;
|
|
139
|
-
fs.writeFileSync(configPath, JSON.stringify(config, null, 2) + "\n");
|
|
140
|
-
} catch {
|
|
141
|
-
}
|
|
142
|
-
}
|
|
143
|
-
function flushScript(queuePath) {
|
|
144
|
-
return `
|
|
145
|
-
const fs = require('fs');
|
|
146
|
-
const https = require('https');
|
|
147
|
-
const http = require('http');
|
|
148
|
-
|
|
149
|
-
try {
|
|
150
|
-
const raw = fs.readFileSync(${JSON.stringify(queuePath)}, 'utf-8');
|
|
151
|
-
const events = JSON.parse(raw);
|
|
152
|
-
if (!Array.isArray(events) || events.length === 0) process.exit(0);
|
|
153
|
-
|
|
154
|
-
const body = JSON.stringify({ events });
|
|
155
|
-
const url = new URL(${JSON.stringify(TELEMETRY_ENDPOINT)});
|
|
156
|
-
const transport = url.protocol === 'https:' ? https : http;
|
|
157
|
-
|
|
158
|
-
const req = transport.request(url, {
|
|
159
|
-
method: 'POST',
|
|
160
|
-
headers: {
|
|
161
|
-
'Content-Type': 'application/json',
|
|
162
|
-
'Content-Length': Buffer.byteLength(body),
|
|
163
|
-
},
|
|
164
|
-
timeout: 5000,
|
|
165
|
-
}, (res) => {
|
|
166
|
-
if (res.statusCode >= 200 && res.statusCode < 300) {
|
|
167
|
-
fs.writeFileSync(${JSON.stringify(queuePath)}, '[]\\n');
|
|
168
|
-
}
|
|
169
|
-
process.exit(0);
|
|
170
|
-
});
|
|
171
|
-
|
|
172
|
-
req.on('error', () => process.exit(0));
|
|
173
|
-
req.on('timeout', () => { req.destroy(); process.exit(0); });
|
|
174
|
-
req.write(body);
|
|
175
|
-
req.end();
|
|
176
|
-
} catch (e) {
|
|
177
|
-
process.exit(0);
|
|
178
|
-
}
|
|
179
|
-
`.trim();
|
|
180
|
-
}
|
|
181
|
-
|
|
182
|
-
// src/commands/config.ts
|
|
183
|
-
var VALID_KEYS = ["server", "admin-token", "telemetry"];
|
|
184
|
-
function runConfigSet(key, value) {
|
|
185
|
-
if (key === "server") {
|
|
186
|
-
switchServer(value);
|
|
187
|
-
} else if (key === "admin-token") {
|
|
188
|
-
saveConfig({ admin_token: value });
|
|
189
|
-
} else if (key === "telemetry") {
|
|
190
|
-
const enabled = value === "on" || value === "true" || value === "1";
|
|
191
|
-
writeTelemetryFlag(enabled);
|
|
192
|
-
} else {
|
|
193
|
-
throw new Error(
|
|
194
|
-
`Unknown config key: "${key}". Valid keys: ${VALID_KEYS.join(", ")}`
|
|
195
|
-
);
|
|
196
|
-
}
|
|
197
|
-
}
|
|
198
|
-
function runConfigGet(key) {
|
|
199
|
-
const config = loadConfig();
|
|
200
|
-
if (key === "server") return config.server;
|
|
201
|
-
if (key === "admin-token") return config.admin_token;
|
|
202
|
-
if (key === "telemetry") return isEnabled() ? "on" : "off";
|
|
203
|
-
throw new Error(
|
|
204
|
-
`Unknown config key: "${key}". Valid keys: ${VALID_KEYS.join(", ")}`
|
|
205
|
-
);
|
|
206
|
-
}
|
|
207
|
-
|
|
208
|
-
// src/lib/workforce.ts
|
|
209
|
-
import fs3 from "fs";
|
|
210
|
-
import path3 from "path";
|
|
211
|
-
|
|
212
|
-
// src/lib/project.ts
|
|
213
|
-
import fs2 from "fs";
|
|
214
|
-
import path2 from "path";
|
|
215
|
-
function findProjectRoot(from) {
|
|
216
|
-
let dir = fs2.realpathSync(from ?? process.cwd());
|
|
217
|
-
while (true) {
|
|
218
|
-
if (fs2.existsSync(path2.join(dir, "zooid.json")) || fs2.existsSync(path2.join(dir, ".zooid"))) {
|
|
219
|
-
return dir;
|
|
220
|
-
}
|
|
221
|
-
const parent = path2.dirname(dir);
|
|
222
|
-
if (parent === dir) return null;
|
|
223
|
-
dir = parent;
|
|
224
|
-
}
|
|
225
|
-
}
|
|
226
|
-
function getZooidDir(from) {
|
|
227
|
-
const root = findProjectRoot(from);
|
|
228
|
-
if (!root) {
|
|
229
|
-
throw new Error(
|
|
230
|
-
"Not a Zooid project (no zooid.json or .zooid/ found). Run `npx zooid init` first."
|
|
231
|
-
);
|
|
232
|
-
}
|
|
233
|
-
return path2.join(root, ".zooid");
|
|
234
|
-
}
|
|
235
|
-
|
|
236
|
-
// src/lib/workforce.ts
|
|
237
|
-
var WORKFORCE_FILENAME = "workforce.json";
|
|
238
|
-
var SLUG_RE = /^[a-z0-9][a-z0-9-]{1,62}[a-z0-9]$/;
|
|
239
|
-
function isValidSlug(s) {
|
|
240
|
-
return SLUG_RE.test(s);
|
|
241
|
-
}
|
|
242
|
-
function validateWorkforceFile(raw, filePath) {
|
|
243
|
-
if (raw.meta && typeof raw.meta === "object") {
|
|
244
|
-
const meta = raw.meta;
|
|
245
|
-
if (meta.slug !== void 0) {
|
|
246
|
-
if (typeof meta.slug !== "string" || !isValidSlug(meta.slug)) {
|
|
247
|
-
throw new Error(
|
|
248
|
-
`Invalid meta.slug in ${filePath}: "${meta.slug}" \u2014 must be a valid slug (lowercase alphanumeric + hyphens, 3-64 chars)`
|
|
249
|
-
);
|
|
250
|
-
}
|
|
251
|
-
}
|
|
252
|
-
}
|
|
253
|
-
if (raw.include) {
|
|
254
|
-
if (!Array.isArray(raw.include)) {
|
|
255
|
-
throw new Error(`"include" must be an array in ${filePath}`);
|
|
256
|
-
}
|
|
257
|
-
for (const p of raw.include) {
|
|
258
|
-
if (typeof p !== "string") {
|
|
259
|
-
throw new Error(`Include entries must be strings in ${filePath}`);
|
|
260
|
-
}
|
|
261
|
-
if (path3.isAbsolute(p)) {
|
|
262
|
-
throw new Error(`Include path must be relative in ${filePath}: ${p}`);
|
|
263
|
-
}
|
|
264
|
-
}
|
|
265
|
-
}
|
|
266
|
-
if (raw.channels && typeof raw.channels === "object") {
|
|
267
|
-
for (const [id, ch] of Object.entries(
|
|
268
|
-
raw.channels
|
|
269
|
-
)) {
|
|
270
|
-
if (!isValidSlug(id)) {
|
|
271
|
-
throw new Error(
|
|
272
|
-
`Invalid channel slug "${id}" in ${filePath} \u2014 must be lowercase alphanumeric + hyphens, 3-64 chars`
|
|
273
|
-
);
|
|
274
|
-
}
|
|
275
|
-
if (!ch || typeof ch !== "object" || !ch.visibility) {
|
|
276
|
-
throw new Error(
|
|
277
|
-
`Channel "${id}" in ${filePath} must have a "visibility" field`
|
|
278
|
-
);
|
|
279
|
-
}
|
|
280
|
-
}
|
|
281
|
-
}
|
|
282
|
-
if (raw.roles && typeof raw.roles === "object") {
|
|
283
|
-
for (const [id, role] of Object.entries(
|
|
284
|
-
raw.roles
|
|
285
|
-
)) {
|
|
286
|
-
if (!isValidSlug(id)) {
|
|
287
|
-
throw new Error(
|
|
288
|
-
`Invalid role slug "${id}" in ${filePath} \u2014 must be lowercase alphanumeric + hyphens, 3-64 chars`
|
|
289
|
-
);
|
|
290
|
-
}
|
|
291
|
-
if (!role || typeof role !== "object" || !Array.isArray(role.scopes)) {
|
|
292
|
-
throw new Error(
|
|
293
|
-
`Role "${id}" in ${filePath} must have a "scopes" array`
|
|
294
|
-
);
|
|
295
|
-
}
|
|
296
|
-
}
|
|
297
|
-
}
|
|
298
|
-
}
|
|
299
|
-
function resolveIncludes(filePath, ancestors, isRoot = false) {
|
|
300
|
-
const realPath = fs3.realpathSync(filePath);
|
|
301
|
-
if (ancestors.has(realPath)) {
|
|
302
|
-
const chain = [...ancestors, realPath].map((p) => path3.basename(p)).join(" \u2192 ");
|
|
303
|
-
throw new Error(`Circular include: ${chain}`);
|
|
304
|
-
}
|
|
305
|
-
const childAncestors = new Set(ancestors);
|
|
306
|
-
childAncestors.add(realPath);
|
|
307
|
-
const raw = JSON.parse(fs3.readFileSync(filePath, "utf-8"));
|
|
308
|
-
validateWorkforceFile(raw, filePath);
|
|
309
|
-
const wf = raw;
|
|
310
|
-
const baseDir = path3.dirname(filePath);
|
|
311
|
-
const result = {
|
|
312
|
-
channels: {},
|
|
313
|
-
roles: {},
|
|
314
|
-
agents: {},
|
|
315
|
-
provenance: { channels: {}, roles: {}, agents: {} }
|
|
316
|
-
};
|
|
317
|
-
if (wf.include) {
|
|
318
|
-
for (const includePath of wf.include) {
|
|
319
|
-
const resolved = path3.resolve(baseDir, includePath);
|
|
320
|
-
try {
|
|
321
|
-
const zooidDir = getZooidDir();
|
|
322
|
-
const realZooidDir = fs3.realpathSync(zooidDir);
|
|
323
|
-
if (!resolved.startsWith(realZooidDir + path3.sep) && resolved !== realZooidDir) {
|
|
324
|
-
throw new Error(
|
|
325
|
-
`Include path escapes .zooid/ in ${filePath}: ${includePath}`
|
|
326
|
-
);
|
|
327
|
-
}
|
|
328
|
-
} catch (e) {
|
|
329
|
-
if (e instanceof Error && e.message.includes("escapes")) throw e;
|
|
330
|
-
}
|
|
331
|
-
if (!fs3.existsSync(resolved)) {
|
|
332
|
-
throw new Error(
|
|
333
|
-
`Included file not found: ${includePath} (resolved to ${resolved})`
|
|
334
|
-
);
|
|
335
|
-
}
|
|
336
|
-
const included = resolveIncludes(resolved, childAncestors);
|
|
337
|
-
if (!isRoot) {
|
|
338
|
-
for (const id of Object.keys(included.channels)) {
|
|
339
|
-
if (id in result.channels) {
|
|
340
|
-
const prev = path3.basename(result.provenance.channels[id]);
|
|
341
|
-
const curr = path3.basename(included.provenance.channels[id]);
|
|
342
|
-
console.warn(
|
|
343
|
-
`\u26A0 Channel "${id}" defined in both ${prev} and ${curr} \u2014 using ${curr} (last wins)`
|
|
344
|
-
);
|
|
345
|
-
}
|
|
346
|
-
}
|
|
347
|
-
for (const id of Object.keys(included.roles)) {
|
|
348
|
-
if (id in result.roles) {
|
|
349
|
-
const prev = path3.basename(result.provenance.roles[id]);
|
|
350
|
-
const curr = path3.basename(included.provenance.roles[id]);
|
|
351
|
-
console.warn(
|
|
352
|
-
`\u26A0 Role "${id}" defined in both ${prev} and ${curr} \u2014 using ${curr} (last wins)`
|
|
353
|
-
);
|
|
354
|
-
}
|
|
355
|
-
}
|
|
356
|
-
}
|
|
357
|
-
Object.assign(result.channels, included.channels);
|
|
358
|
-
Object.assign(result.roles, included.roles);
|
|
359
|
-
Object.assign(result.agents, included.agents);
|
|
360
|
-
Object.assign(result.provenance.channels, included.provenance.channels);
|
|
361
|
-
Object.assign(result.provenance.roles, included.provenance.roles);
|
|
362
|
-
Object.assign(result.provenance.agents, included.provenance.agents);
|
|
363
|
-
}
|
|
364
|
-
}
|
|
365
|
-
if (wf.channels) {
|
|
366
|
-
for (const [id, def] of Object.entries(wf.channels)) {
|
|
367
|
-
result.channels[id] = def;
|
|
368
|
-
result.provenance.channels[id] = filePath;
|
|
369
|
-
}
|
|
370
|
-
}
|
|
371
|
-
if (wf.roles) {
|
|
372
|
-
for (const [id, def] of Object.entries(wf.roles)) {
|
|
373
|
-
result.roles[id] = def;
|
|
374
|
-
result.provenance.roles[id] = filePath;
|
|
375
|
-
}
|
|
376
|
-
}
|
|
377
|
-
if (wf.agents) {
|
|
378
|
-
for (const [id, def] of Object.entries(wf.agents)) {
|
|
379
|
-
result.agents[id] = def;
|
|
380
|
-
result.provenance.agents[id] = filePath;
|
|
381
|
-
}
|
|
382
|
-
}
|
|
383
|
-
return result;
|
|
384
|
-
}
|
|
385
|
-
function loadWorkforce() {
|
|
386
|
-
let zooidDir;
|
|
387
|
-
try {
|
|
388
|
-
zooidDir = getZooidDir();
|
|
389
|
-
} catch {
|
|
390
|
-
return {
|
|
391
|
-
channels: {},
|
|
392
|
-
roles: {},
|
|
393
|
-
provenance: { channels: {}, roles: {} }
|
|
394
|
-
};
|
|
395
|
-
}
|
|
396
|
-
const filePath = path3.join(zooidDir, WORKFORCE_FILENAME);
|
|
397
|
-
if (!fs3.existsSync(filePath)) {
|
|
398
|
-
return {
|
|
399
|
-
channels: {},
|
|
400
|
-
roles: {},
|
|
401
|
-
provenance: { channels: {}, roles: {} }
|
|
402
|
-
};
|
|
403
|
-
}
|
|
404
|
-
const resolved = resolveIncludes(filePath, /* @__PURE__ */ new Set(), true);
|
|
405
|
-
if (Object.keys(resolved.agents).length > 0) {
|
|
406
|
-
for (const agentId of Object.keys(resolved.agents)) {
|
|
407
|
-
if (agentId in resolved.roles) {
|
|
408
|
-
throw new Error(
|
|
409
|
-
`agent "${agentId}" collides with a role of the same name. Use one or the other.`
|
|
410
|
-
);
|
|
411
|
-
}
|
|
412
|
-
}
|
|
413
|
-
const derivedRoles = compileAgents(resolved.agents);
|
|
414
|
-
Object.assign(resolved.roles, derivedRoles);
|
|
415
|
-
for (const id of Object.keys(derivedRoles)) {
|
|
416
|
-
resolved.provenance.roles[id] = resolved.provenance.agents[id] ?? filePath;
|
|
417
|
-
}
|
|
418
|
-
}
|
|
419
|
-
return {
|
|
420
|
-
channels: resolved.channels,
|
|
421
|
-
roles: resolved.roles,
|
|
422
|
-
provenance: resolved.provenance
|
|
423
|
-
};
|
|
424
|
-
}
|
|
425
|
-
function saveWorkforce(data, options) {
|
|
426
|
-
let filePath;
|
|
427
|
-
if (options?.targetFile) {
|
|
428
|
-
filePath = options.targetFile;
|
|
429
|
-
} else {
|
|
430
|
-
let zooidDir;
|
|
431
|
-
try {
|
|
432
|
-
zooidDir = getZooidDir();
|
|
433
|
-
} catch {
|
|
434
|
-
zooidDir = path3.join(process.cwd(), ".zooid");
|
|
435
|
-
}
|
|
436
|
-
fs3.mkdirSync(zooidDir, { recursive: true });
|
|
437
|
-
filePath = path3.join(zooidDir, WORKFORCE_FILENAME);
|
|
438
|
-
}
|
|
439
|
-
let existing = {};
|
|
440
|
-
if (fs3.existsSync(filePath)) {
|
|
441
|
-
existing = JSON.parse(fs3.readFileSync(filePath, "utf-8"));
|
|
442
|
-
}
|
|
443
|
-
const output = {};
|
|
444
|
-
if (existing.$schema) output.$schema = existing.$schema;
|
|
445
|
-
if (existing.meta) output.meta = existing.meta;
|
|
446
|
-
if (existing.include) output.include = existing.include;
|
|
447
|
-
output.channels = data.channels;
|
|
448
|
-
output.roles = data.roles;
|
|
449
|
-
if (existing.agents) output.agents = existing.agents;
|
|
450
|
-
fs3.writeFileSync(filePath, JSON.stringify(output, null, 2) + "\n");
|
|
451
|
-
}
|
|
452
|
-
function updateInFile(filePath, section, id, def) {
|
|
453
|
-
const raw = JSON.parse(fs3.readFileSync(filePath, "utf-8"));
|
|
454
|
-
if (!raw[section]) raw[section] = {};
|
|
455
|
-
raw[section][id] = def;
|
|
456
|
-
fs3.writeFileSync(filePath, JSON.stringify(raw, null, 2) + "\n");
|
|
457
|
-
}
|
|
458
|
-
function removeFromFile(filePath, section, id) {
|
|
459
|
-
const raw = JSON.parse(fs3.readFileSync(filePath, "utf-8"));
|
|
460
|
-
if (raw[section]) {
|
|
461
|
-
delete raw[section][id];
|
|
462
|
-
}
|
|
463
|
-
fs3.writeFileSync(filePath, JSON.stringify(raw, null, 2) + "\n");
|
|
464
|
-
}
|
|
465
|
-
function compileAgents(agents) {
|
|
466
|
-
const roles = {};
|
|
467
|
-
for (const [id, agent] of Object.entries(agents)) {
|
|
468
|
-
const scopes = [];
|
|
469
|
-
if (agent.subscribes) {
|
|
470
|
-
for (const ch of agent.subscribes) {
|
|
471
|
-
scopes.push(`sub:${ch}`);
|
|
472
|
-
}
|
|
473
|
-
}
|
|
474
|
-
if (agent.publishes) {
|
|
475
|
-
for (const ch of agent.publishes) {
|
|
476
|
-
scopes.push(`pub:${ch}`);
|
|
477
|
-
}
|
|
478
|
-
}
|
|
479
|
-
const role = { scopes };
|
|
480
|
-
if (agent.name) role.name = agent.name;
|
|
481
|
-
if (agent.description) role.description = agent.description;
|
|
482
|
-
roles[id] = role;
|
|
483
|
-
}
|
|
484
|
-
return roles;
|
|
485
|
-
}
|
|
486
|
-
|
|
487
|
-
// src/commands/channel.ts
|
|
488
|
-
async function runChannelCreate(id, options, client) {
|
|
489
|
-
const c = client ?? createClient();
|
|
490
|
-
let config = options.config;
|
|
491
|
-
if (options.strict !== void 0) {
|
|
492
|
-
config = { ...config, strict_types: options.strict };
|
|
493
|
-
}
|
|
494
|
-
const result = await c.createChannel({
|
|
495
|
-
id,
|
|
496
|
-
name: options.name ?? id,
|
|
497
|
-
description: options.description,
|
|
498
|
-
is_public: options.public ?? false,
|
|
499
|
-
config
|
|
500
|
-
});
|
|
501
|
-
if (!client) {
|
|
502
|
-
const config2 = loadConfig();
|
|
503
|
-
const channels = config2.channels ?? {};
|
|
504
|
-
channels[id] = { token: result.token };
|
|
505
|
-
saveConfig({ channels });
|
|
506
|
-
}
|
|
507
|
-
if (findProjectRoot()) {
|
|
508
|
-
try {
|
|
509
|
-
const wf = loadWorkforce();
|
|
510
|
-
wf.channels[id] = {
|
|
511
|
-
visibility: options.public ? "public" : "private",
|
|
512
|
-
...options.name && { name: options.name },
|
|
513
|
-
...options.description && { description: options.description },
|
|
514
|
-
...config && { config }
|
|
515
|
-
};
|
|
516
|
-
saveWorkforce(wf);
|
|
517
|
-
} catch {
|
|
518
|
-
}
|
|
519
|
-
}
|
|
520
|
-
return result;
|
|
521
|
-
}
|
|
522
|
-
async function runChannelList(client) {
|
|
523
|
-
const c = client ?? createClient();
|
|
524
|
-
return c.listChannels();
|
|
525
|
-
}
|
|
526
|
-
async function runChannelUpdate(channelId, options, client) {
|
|
527
|
-
const c = client ?? createClient();
|
|
528
|
-
const result = await c.updateChannel(channelId, options);
|
|
529
|
-
if (findProjectRoot()) {
|
|
530
|
-
try {
|
|
531
|
-
const wf = loadWorkforce();
|
|
532
|
-
const def = {
|
|
533
|
-
visibility: result.is_public ? "public" : "private",
|
|
534
|
-
...result.name && result.name !== channelId && { name: result.name },
|
|
535
|
-
...result.description && { description: result.description },
|
|
536
|
-
...result.config && { config: result.config }
|
|
537
|
-
};
|
|
538
|
-
const targetFile = wf.provenance.channels[channelId];
|
|
539
|
-
if (targetFile) {
|
|
540
|
-
updateInFile(targetFile, "channels", channelId, def);
|
|
541
|
-
} else {
|
|
542
|
-
wf.channels[channelId] = def;
|
|
543
|
-
saveWorkforce(wf);
|
|
544
|
-
}
|
|
545
|
-
} catch {
|
|
546
|
-
}
|
|
547
|
-
}
|
|
548
|
-
return result;
|
|
549
|
-
}
|
|
550
|
-
async function runChannelDelete(channelId, client) {
|
|
551
|
-
const c = client ?? createClient();
|
|
552
|
-
await c.deleteChannel(channelId);
|
|
553
|
-
if (!client) {
|
|
554
|
-
const file = loadConfigFile();
|
|
555
|
-
const serverUrl = resolveServer();
|
|
556
|
-
if (serverUrl && file.servers?.[serverUrl]?.channels?.[channelId]) {
|
|
557
|
-
delete file.servers[serverUrl].channels[channelId];
|
|
558
|
-
const fs13 = await import("fs");
|
|
559
|
-
fs13.writeFileSync(getStatePath(), JSON.stringify(file, null, 2) + "\n");
|
|
560
|
-
}
|
|
561
|
-
}
|
|
562
|
-
if (findProjectRoot()) {
|
|
563
|
-
try {
|
|
564
|
-
const wf = loadWorkforce();
|
|
565
|
-
const targetFile = wf.provenance.channels[channelId];
|
|
566
|
-
if (targetFile) {
|
|
567
|
-
removeFromFile(targetFile, "channels", channelId);
|
|
568
|
-
} else {
|
|
569
|
-
delete wf.channels[channelId];
|
|
570
|
-
saveWorkforce(wf);
|
|
571
|
-
}
|
|
572
|
-
} catch {
|
|
573
|
-
}
|
|
574
|
-
}
|
|
575
|
-
}
|
|
576
|
-
|
|
577
|
-
// src/lib/zoon.ts
|
|
578
|
-
var DEFAULT_PLATFORM_URL = "https://api.zooid.dev";
|
|
579
|
-
function getPlatformUrl() {
|
|
580
|
-
return process.env.ZOON_PLATFORM_URL || DEFAULT_PLATFORM_URL;
|
|
581
|
-
}
|
|
582
|
-
function extractSubdomain(serverUrl) {
|
|
583
|
-
try {
|
|
584
|
-
const url = new URL(serverUrl);
|
|
585
|
-
const match = url.hostname.match(/^([^.]+)\.zoon\.eco$/);
|
|
586
|
-
return match ? match[1] : null;
|
|
587
|
-
} catch {
|
|
588
|
-
return null;
|
|
589
|
-
}
|
|
590
|
-
}
|
|
591
|
-
function isZoonHosted(serverUrl) {
|
|
592
|
-
return extractSubdomain(serverUrl) !== null;
|
|
593
|
-
}
|
|
594
|
-
function authHeaders(token) {
|
|
595
|
-
return {
|
|
596
|
-
Authorization: `Bearer ${token}`,
|
|
597
|
-
"Content-Type": "application/json"
|
|
598
|
-
};
|
|
599
|
-
}
|
|
600
|
-
function platformUrl(subdomain, path10) {
|
|
601
|
-
return `${getPlatformUrl()}/api/v1/servers/${subdomain}${path10}`;
|
|
602
|
-
}
|
|
603
|
-
async function syncRolesToZoon(serverUrl, token, roles, options) {
|
|
604
|
-
const subdomain = extractSubdomain(serverUrl);
|
|
605
|
-
if (!subdomain) {
|
|
606
|
-
throw new Error(`${serverUrl} is not a Zoon-hosted server`);
|
|
607
|
-
}
|
|
608
|
-
const _fetch = options?.fetch ?? globalThis.fetch;
|
|
609
|
-
const res = await _fetch(platformUrl(subdomain, "/roles"), {
|
|
610
|
-
method: "PUT",
|
|
611
|
-
headers: authHeaders(token),
|
|
612
|
-
body: JSON.stringify(roles)
|
|
613
|
-
});
|
|
614
|
-
if (!res.ok) {
|
|
615
|
-
const err = await res.json().catch(() => ({}));
|
|
616
|
-
throw new Error(
|
|
617
|
-
`Role sync failed: ${err.message || res.status}`
|
|
618
|
-
);
|
|
619
|
-
}
|
|
620
|
-
return res.json();
|
|
621
|
-
}
|
|
622
|
-
async function createCredential(serverUrl, token, name, roleNames, options) {
|
|
623
|
-
const subdomain = extractSubdomain(serverUrl);
|
|
624
|
-
const _fetch = options?.fetch ?? globalThis.fetch;
|
|
625
|
-
const rolesRes = await _fetch(platformUrl(subdomain, "/roles"), {
|
|
626
|
-
headers: authHeaders(token)
|
|
627
|
-
});
|
|
628
|
-
const roles = await rolesRes.json();
|
|
629
|
-
const roleSlugs = roleNames.map((n) => roles.find((r) => r.slug === n || r.name === n)?.slug).filter(Boolean);
|
|
630
|
-
if (roleSlugs.length === 0) {
|
|
631
|
-
throw new Error(`No matching roles found for: ${roleNames.join(", ")}`);
|
|
632
|
-
}
|
|
633
|
-
const res = await _fetch(platformUrl(subdomain, "/credentials"), {
|
|
634
|
-
method: "POST",
|
|
635
|
-
headers: authHeaders(token),
|
|
636
|
-
body: JSON.stringify({ name, role_slugs: roleSlugs })
|
|
637
|
-
});
|
|
638
|
-
if (!res.ok) {
|
|
639
|
-
const err = await res.json().catch(() => ({}));
|
|
640
|
-
throw new Error(
|
|
641
|
-
`Credential creation failed: ${err.message || res.status}`
|
|
642
|
-
);
|
|
643
|
-
}
|
|
644
|
-
return res.json();
|
|
645
|
-
}
|
|
646
|
-
async function listCredentials(serverUrl, token, options) {
|
|
647
|
-
const subdomain = extractSubdomain(serverUrl);
|
|
648
|
-
const _fetch = options?.fetch ?? globalThis.fetch;
|
|
649
|
-
const res = await _fetch(platformUrl(subdomain, "/credentials"), {
|
|
650
|
-
headers: authHeaders(token)
|
|
651
|
-
});
|
|
652
|
-
if (!res.ok) throw new Error(`Failed to list credentials: ${res.status}`);
|
|
653
|
-
return await res.json();
|
|
654
|
-
}
|
|
655
|
-
async function rotateCredential(serverUrl, token, clientId, options) {
|
|
656
|
-
const subdomain = extractSubdomain(serverUrl);
|
|
657
|
-
const _fetch = options?.fetch ?? globalThis.fetch;
|
|
658
|
-
const res = await _fetch(
|
|
659
|
-
platformUrl(subdomain, `/credentials/${clientId}/rotate`),
|
|
660
|
-
{ method: "POST", headers: authHeaders(token) }
|
|
661
|
-
);
|
|
662
|
-
if (!res.ok) throw new Error(`Failed to rotate credential: ${res.status}`);
|
|
663
|
-
return res.json();
|
|
664
|
-
}
|
|
665
|
-
async function listRolesFromZoon(serverUrl, token, options) {
|
|
666
|
-
const subdomain = extractSubdomain(serverUrl);
|
|
667
|
-
const _fetch = options?.fetch ?? globalThis.fetch;
|
|
668
|
-
const res = await _fetch(platformUrl(subdomain, "/roles"), {
|
|
669
|
-
headers: authHeaders(token)
|
|
670
|
-
});
|
|
671
|
-
if (!res.ok) throw new Error(`Failed to list roles: ${res.status}`);
|
|
672
|
-
return res.json();
|
|
673
|
-
}
|
|
674
|
-
async function revokeCredential(serverUrl, token, clientId, options) {
|
|
675
|
-
const subdomain = extractSubdomain(serverUrl);
|
|
676
|
-
const _fetch = options?.fetch ?? globalThis.fetch;
|
|
677
|
-
const res = await _fetch(platformUrl(subdomain, `/credentials/${clientId}`), {
|
|
678
|
-
method: "DELETE",
|
|
679
|
-
headers: authHeaders(token)
|
|
680
|
-
});
|
|
681
|
-
if (!res.ok) throw new Error(`Failed to revoke credential: ${res.status}`);
|
|
682
|
-
}
|
|
683
|
-
|
|
684
|
-
// src/lib/output.ts
|
|
685
|
-
function printSuccess(message) {
|
|
686
|
-
console.log(`\u2713 ${message}`);
|
|
687
|
-
}
|
|
688
|
-
function printError(message) {
|
|
689
|
-
console.error(`\u2717 ${message}`);
|
|
690
|
-
}
|
|
691
|
-
function printInfo(label, value) {
|
|
692
|
-
console.log(` ${label}: ${value}`);
|
|
693
|
-
}
|
|
694
|
-
function printStep(message) {
|
|
695
|
-
console.log(` ${message}`);
|
|
696
|
-
}
|
|
697
|
-
function formatRelative(isoString) {
|
|
698
|
-
const diff = Date.now() - new Date(isoString).getTime();
|
|
699
|
-
const minutes = Math.floor(diff / 6e4);
|
|
700
|
-
if (minutes < 1) return "just now";
|
|
701
|
-
if (minutes < 60) return `${minutes}m ago`;
|
|
702
|
-
const hours = Math.floor(minutes / 60);
|
|
703
|
-
if (hours < 24) return `${hours}h ago`;
|
|
704
|
-
const days = Math.floor(hours / 24);
|
|
705
|
-
return `${days}d ago`;
|
|
706
|
-
}
|
|
707
|
-
|
|
708
|
-
// src/commands/pull.ts
|
|
709
|
-
async function runPull(client) {
|
|
710
|
-
const c = client ?? createClient();
|
|
711
|
-
const wf = loadWorkforce();
|
|
712
|
-
const written = [];
|
|
713
|
-
let newChannelsAdded = false;
|
|
714
|
-
const channels = await c.listChannels();
|
|
715
|
-
if (channels.length > 0) {
|
|
716
|
-
printStep("Pulling channels...");
|
|
717
|
-
for (const ch of channels) {
|
|
718
|
-
const def = {
|
|
719
|
-
visibility: ch.is_public ? "public" : "private"
|
|
720
|
-
};
|
|
721
|
-
if (ch.name && ch.name !== ch.id) def.name = ch.name;
|
|
722
|
-
if (ch.description) def.description = ch.description;
|
|
723
|
-
if (ch.config) def.config = ch.config;
|
|
724
|
-
const targetFile = wf.provenance.channels[ch.id];
|
|
725
|
-
if (targetFile) {
|
|
726
|
-
updateInFile(targetFile, "channels", ch.id, def);
|
|
727
|
-
} else {
|
|
728
|
-
wf.channels[ch.id] = def;
|
|
729
|
-
newChannelsAdded = true;
|
|
730
|
-
}
|
|
731
|
-
written.push(ch.id);
|
|
732
|
-
}
|
|
733
|
-
}
|
|
734
|
-
let newRolesAdded = false;
|
|
735
|
-
try {
|
|
736
|
-
const server = resolveServer();
|
|
737
|
-
const file = loadConfigFile();
|
|
738
|
-
const entry = server ? file.servers?.[server] : void 0;
|
|
739
|
-
let roles;
|
|
740
|
-
if (server && isZoonHosted(server) && entry?.platform_token) {
|
|
741
|
-
roles = await listRolesFromZoon(server, entry.platform_token);
|
|
742
|
-
} else {
|
|
743
|
-
roles = await c.listRoles();
|
|
744
|
-
}
|
|
745
|
-
if (roles.length > 0) {
|
|
746
|
-
printStep("Pulling roles...");
|
|
747
|
-
for (const role of roles) {
|
|
748
|
-
const roleKey = role.slug ?? role.id;
|
|
749
|
-
const def = { scopes: role.scopes };
|
|
750
|
-
if (role.name) def.name = role.name;
|
|
751
|
-
if (role.description) def.description = role.description;
|
|
752
|
-
const targetFile = wf.provenance.roles[roleKey];
|
|
753
|
-
if (targetFile) {
|
|
754
|
-
updateInFile(targetFile, "roles", roleKey, def);
|
|
755
|
-
} else {
|
|
756
|
-
wf.roles[roleKey] = def;
|
|
757
|
-
newRolesAdded = true;
|
|
758
|
-
}
|
|
759
|
-
}
|
|
760
|
-
}
|
|
761
|
-
} catch {
|
|
762
|
-
}
|
|
763
|
-
if (newChannelsAdded || newRolesAdded) {
|
|
764
|
-
saveWorkforce(wf);
|
|
765
|
-
}
|
|
766
|
-
if (written.length > 0 || Object.keys(wf.roles).length > 0) {
|
|
767
|
-
printSuccess(
|
|
768
|
-
`Pulled ${written.length} channel(s) and ${Object.keys(wf.roles).length} role(s) into .zooid/workforce.json`
|
|
769
|
-
);
|
|
770
|
-
} else {
|
|
771
|
-
printInfo("Nothing to pull", "no channels or roles on server");
|
|
772
|
-
}
|
|
773
|
-
return written;
|
|
774
|
-
}
|
|
775
|
-
|
|
776
|
-
// src/commands/publish.ts
|
|
777
|
-
import fs4 from "fs";
|
|
778
|
-
import readline from "readline";
|
|
779
|
-
function parseJSON(raw, source) {
|
|
780
|
-
try {
|
|
781
|
-
return JSON.parse(raw);
|
|
782
|
-
} catch {
|
|
783
|
-
throw new Error(`Invalid JSON from ${source}: ${raw.slice(0, 100)}`);
|
|
784
|
-
}
|
|
785
|
-
}
|
|
786
|
-
function readStdin() {
|
|
787
|
-
return new Promise((resolve, reject) => {
|
|
788
|
-
if (process.stdin.isTTY) {
|
|
789
|
-
reject(
|
|
790
|
-
new Error(
|
|
791
|
-
"No data provided. Usage: zooid publish <channel> <json> or pipe via stdin"
|
|
792
|
-
)
|
|
793
|
-
);
|
|
794
|
-
return;
|
|
795
|
-
}
|
|
796
|
-
let data = "";
|
|
797
|
-
process.stdin.setEncoding("utf-8");
|
|
798
|
-
process.stdin.on("data", (chunk) => data += chunk);
|
|
799
|
-
process.stdin.on("end", () => resolve(data.trim()));
|
|
800
|
-
process.stdin.on("error", reject);
|
|
801
|
-
});
|
|
802
|
-
}
|
|
803
|
-
async function runPublish(channelId, options, client, dataArg) {
|
|
804
|
-
const c = client ?? createPublishClient(channelId);
|
|
805
|
-
let type;
|
|
806
|
-
let data;
|
|
807
|
-
if (options.file) {
|
|
808
|
-
const raw = fs4.readFileSync(options.file, "utf-8");
|
|
809
|
-
const parsed = parseJSON(raw, options.file);
|
|
810
|
-
if (typeof parsed === "object" && parsed !== null && "type" in parsed) {
|
|
811
|
-
const obj = parsed;
|
|
812
|
-
type = obj.type;
|
|
813
|
-
data = obj.data ?? parsed;
|
|
814
|
-
} else {
|
|
815
|
-
data = parsed;
|
|
816
|
-
}
|
|
817
|
-
} else if (options.data) {
|
|
818
|
-
data = parseJSON(options.data, "--data");
|
|
819
|
-
type = options.type;
|
|
820
|
-
} else if (dataArg) {
|
|
821
|
-
data = parseJSON(dataArg, "argument");
|
|
822
|
-
type = options.type;
|
|
823
|
-
} else {
|
|
824
|
-
const raw = await readStdin();
|
|
825
|
-
data = parseJSON(raw, "stdin");
|
|
826
|
-
type = options.type;
|
|
827
|
-
}
|
|
828
|
-
const publishOpts = { data };
|
|
829
|
-
if (type) publishOpts.type = type;
|
|
830
|
-
return c.publish(channelId, publishOpts);
|
|
831
|
-
}
|
|
832
|
-
async function runPublishStream(channelId, options, client, onEvent) {
|
|
833
|
-
if (process.stdin.isTTY) {
|
|
834
|
-
throw new Error(
|
|
835
|
-
"--stream requires piped input (e.g. cat events.jsonl | zooid publish channel --stream)"
|
|
836
|
-
);
|
|
837
|
-
}
|
|
838
|
-
const c = client ?? createPublishClient(channelId);
|
|
839
|
-
const rl = readline.createInterface({ input: process.stdin });
|
|
840
|
-
let published = 0;
|
|
841
|
-
let errors = 0;
|
|
842
|
-
let lineNum = 0;
|
|
843
|
-
for await (const line of rl) {
|
|
844
|
-
lineNum++;
|
|
845
|
-
const trimmed = line.trim();
|
|
846
|
-
if (!trimmed) continue;
|
|
847
|
-
const data = parseJSON(trimmed, `stdin line ${lineNum}`);
|
|
848
|
-
const publishOpts = { data };
|
|
849
|
-
if (options.type) publishOpts.type = options.type;
|
|
850
|
-
try {
|
|
851
|
-
const event = await c.publish(channelId, publishOpts);
|
|
852
|
-
published++;
|
|
853
|
-
onEvent?.(event, lineNum);
|
|
854
|
-
} catch (err) {
|
|
855
|
-
errors++;
|
|
856
|
-
const msg = err instanceof Error ? err.message : String(err);
|
|
857
|
-
process.stderr.write(`Line ${lineNum}: ${msg}
|
|
858
|
-
`);
|
|
859
|
-
}
|
|
860
|
-
}
|
|
861
|
-
return { published, errors };
|
|
862
|
-
}
|
|
863
|
-
|
|
864
|
-
// src/commands/subscribe.ts
|
|
865
|
-
async function runSubscribePoll(channelId, options = {}, client) {
|
|
866
|
-
const c = client ?? createSubscribeClient(channelId);
|
|
867
|
-
const callback = options.callback ?? ((event) => {
|
|
868
|
-
console.log(JSON.stringify(event));
|
|
869
|
-
});
|
|
870
|
-
return c.subscribe(channelId, callback, {
|
|
871
|
-
interval: options.interval ?? 5e3,
|
|
872
|
-
mode: options.mode,
|
|
873
|
-
type: options.type
|
|
874
|
-
});
|
|
875
|
-
}
|
|
876
|
-
async function runSubscribeWebhook(channelId, url, client) {
|
|
877
|
-
const c = client ?? createSubscribeClient(channelId);
|
|
878
|
-
return c.registerWebhook(channelId, url);
|
|
879
|
-
}
|
|
880
|
-
|
|
881
|
-
// src/commands/tail.ts
|
|
882
|
-
async function runTail(channelId, options = {}, client) {
|
|
883
|
-
const c = client ?? createSubscribeClient(channelId);
|
|
884
|
-
if (options.follow) {
|
|
885
|
-
return runTailFollow(c, channelId, options);
|
|
886
|
-
}
|
|
887
|
-
const pollOpts = {};
|
|
888
|
-
if (options.limit !== void 0) pollOpts.limit = options.limit;
|
|
889
|
-
if (options.type) pollOpts.type = options.type;
|
|
890
|
-
if (options.since) pollOpts.since = options.since;
|
|
891
|
-
if (options.cursor) pollOpts.cursor = options.cursor;
|
|
892
|
-
return c.tail(channelId, pollOpts);
|
|
893
|
-
}
|
|
894
|
-
async function runTailFollow(client, channelId, options) {
|
|
895
|
-
const tailOpts = {
|
|
896
|
-
follow: true,
|
|
897
|
-
mode: options.mode,
|
|
898
|
-
interval: options.interval,
|
|
899
|
-
type: options.type
|
|
900
|
-
};
|
|
901
|
-
const stream = client.tail(channelId, tailOpts);
|
|
902
|
-
for await (const event of stream) {
|
|
903
|
-
console.log(JSON.stringify(event));
|
|
904
|
-
}
|
|
905
|
-
return void 0;
|
|
906
|
-
}
|
|
907
|
-
|
|
908
|
-
// src/commands/status.ts
|
|
909
|
-
async function runStatus(client) {
|
|
910
|
-
const c = client ?? createClient();
|
|
911
|
-
const [discovery, identity] = await Promise.all([
|
|
912
|
-
c.getMetadata(),
|
|
913
|
-
c.getServerMeta()
|
|
914
|
-
]);
|
|
915
|
-
return { discovery, identity };
|
|
916
|
-
}
|
|
917
|
-
|
|
918
|
-
// src/commands/history.ts
|
|
919
|
-
function runHistory() {
|
|
920
|
-
const file = loadConfigFile();
|
|
921
|
-
const entries = [];
|
|
922
|
-
if (!file.servers) return entries;
|
|
923
|
-
const seen = /* @__PURE__ */ new Map();
|
|
924
|
-
for (const [serverUrl, serverConfig] of Object.entries(file.servers)) {
|
|
925
|
-
if (!serverConfig.channels) continue;
|
|
926
|
-
const normalizedServer = normalizeServerUrl(serverUrl);
|
|
927
|
-
for (const [channelId, channelData] of Object.entries(
|
|
928
|
-
serverConfig.channels
|
|
929
|
-
)) {
|
|
930
|
-
if (!channelData.stats) continue;
|
|
931
|
-
const key = `${normalizedServer}\0${channelId}`;
|
|
932
|
-
const existingIdx = seen.get(key);
|
|
933
|
-
if (existingIdx !== void 0) {
|
|
934
|
-
const existing = entries[existingIdx];
|
|
935
|
-
existing.num_tails += channelData.stats.num_tails;
|
|
936
|
-
if (channelData.stats.last_tailed_at > existing.last_tailed_at) {
|
|
937
|
-
existing.last_tailed_at = channelData.stats.last_tailed_at;
|
|
938
|
-
existing.name = channelData.name ?? existing.name;
|
|
939
|
-
}
|
|
940
|
-
if (channelData.stats.first_tailed_at < existing.first_tailed_at) {
|
|
941
|
-
existing.first_tailed_at = channelData.stats.first_tailed_at;
|
|
942
|
-
}
|
|
943
|
-
} else {
|
|
944
|
-
seen.set(key, entries.length);
|
|
945
|
-
entries.push({
|
|
946
|
-
server: normalizedServer,
|
|
947
|
-
channel_id: channelId,
|
|
948
|
-
name: channelData.name,
|
|
949
|
-
num_tails: channelData.stats.num_tails,
|
|
950
|
-
last_tailed_at: channelData.stats.last_tailed_at,
|
|
951
|
-
first_tailed_at: channelData.stats.first_tailed_at
|
|
952
|
-
});
|
|
953
|
-
}
|
|
954
|
-
}
|
|
955
|
-
}
|
|
956
|
-
entries.sort(
|
|
957
|
-
(a, b) => new Date(b.last_tailed_at).getTime() - new Date(a.last_tailed_at).getTime()
|
|
958
|
-
);
|
|
959
|
-
return entries;
|
|
960
|
-
}
|
|
961
|
-
|
|
962
|
-
// src/commands/share.ts
|
|
963
|
-
import readline2 from "readline/promises";
|
|
964
|
-
|
|
965
|
-
// src/lib/directory.ts
|
|
966
|
-
var DIRECTORY_BASE_URL = "https://directory.zooid.dev";
|
|
967
|
-
var TOKEN_PREFIX = "zd_";
|
|
968
|
-
var POLL_INTERVAL_MS = 3e3;
|
|
969
|
-
var POLL_TIMEOUT_MS = 2 * 60 * 1e3;
|
|
970
|
-
function getDirectoryToken() {
|
|
971
|
-
const token = loadDirectoryToken();
|
|
972
|
-
if (token && token.startsWith(TOKEN_PREFIX)) {
|
|
973
|
-
return token;
|
|
974
|
-
}
|
|
975
|
-
return void 0;
|
|
976
|
-
}
|
|
977
|
-
async function ensureDirectoryToken() {
|
|
978
|
-
const existing = getDirectoryToken();
|
|
979
|
-
if (existing) return existing;
|
|
980
|
-
return deviceAuth();
|
|
981
|
-
}
|
|
982
|
-
async function deviceAuth() {
|
|
983
|
-
const res = await fetch(`${DIRECTORY_BASE_URL}/api/auth/device`, {
|
|
984
|
-
method: "POST",
|
|
985
|
-
headers: { "Content-Type": "application/json" }
|
|
986
|
-
});
|
|
987
|
-
if (!res.ok) {
|
|
988
|
-
throw new Error(`Directory auth failed: ${res.status} ${res.statusText}`);
|
|
989
|
-
}
|
|
990
|
-
const data = await res.json();
|
|
991
|
-
const { device_code, verification_url } = data;
|
|
992
|
-
console.log("");
|
|
993
|
-
printInfo("Authorize", verification_url);
|
|
994
|
-
console.log(" Opening browser to complete GitHub sign-in...\n");
|
|
995
|
-
try {
|
|
996
|
-
const { exec: exec2 } = await import("child_process");
|
|
997
|
-
const platform = process.platform;
|
|
998
|
-
const cmd = platform === "darwin" ? "open" : platform === "win32" ? "start" : "xdg-open";
|
|
999
|
-
exec2(`${cmd} "${verification_url}"`);
|
|
1000
|
-
} catch {
|
|
1001
|
-
console.log(
|
|
1002
|
-
" Could not open browser automatically. Please visit the URL above.\n"
|
|
1003
|
-
);
|
|
1004
|
-
}
|
|
1005
|
-
const deadline = Date.now() + POLL_TIMEOUT_MS;
|
|
1006
|
-
while (Date.now() < deadline) {
|
|
1007
|
-
await sleep(POLL_INTERVAL_MS);
|
|
1008
|
-
const statusRes = await fetch(
|
|
1009
|
-
`${DIRECTORY_BASE_URL}/api/auth/status?code=${encodeURIComponent(device_code)}`
|
|
1010
|
-
);
|
|
1011
|
-
if (!statusRes.ok) continue;
|
|
1012
|
-
const status = await statusRes.json();
|
|
1013
|
-
if (status.status === "complete" && status.token) {
|
|
1014
|
-
saveDirectoryToken(status.token);
|
|
1015
|
-
console.log(" Authenticated with Zooid Directory.\n");
|
|
1016
|
-
return status.token;
|
|
1017
|
-
}
|
|
1018
|
-
if (status.status === "expired") {
|
|
1019
|
-
throw new Error("Device authorization expired. Please try again.");
|
|
1020
|
-
}
|
|
1021
|
-
}
|
|
1022
|
-
throw new Error(
|
|
1023
|
-
"Authorization timed out. You need your human to authorize you. Run `npx zooid share` again to retry."
|
|
1024
|
-
);
|
|
1025
|
-
}
|
|
1026
|
-
async function directoryFetch(path10, options = {}) {
|
|
1027
|
-
let token = await ensureDirectoryToken();
|
|
1028
|
-
const doFetch = (t) => {
|
|
1029
|
-
const headers = new Headers(options.headers);
|
|
1030
|
-
headers.set("Authorization", `Bearer ${t}`);
|
|
1031
|
-
headers.set("Content-Type", "application/json");
|
|
1032
|
-
return fetch(`${DIRECTORY_BASE_URL}${path10}`, { ...options, headers });
|
|
1033
|
-
};
|
|
1034
|
-
let res = await doFetch(token);
|
|
1035
|
-
if (res.status === 401) {
|
|
1036
|
-
console.log(" Directory token expired. Re-authenticating...\n");
|
|
1037
|
-
token = await deviceAuth();
|
|
1038
|
-
res = await doFetch(token);
|
|
1039
|
-
}
|
|
1040
|
-
return res;
|
|
1041
|
-
}
|
|
1042
|
-
function sleep(ms) {
|
|
1043
|
-
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
1044
|
-
}
|
|
1045
|
-
async function formatDirectoryError(res) {
|
|
1046
|
-
let msg = `Directory returned ${res.status}`;
|
|
1047
|
-
try {
|
|
1048
|
-
const body = await res.json();
|
|
1049
|
-
const parts = [];
|
|
1050
|
-
if (body.error) parts.push(String(body.error));
|
|
1051
|
-
if (body.message) parts.push(String(body.message));
|
|
1052
|
-
if (parts.length > 0) msg = parts.join(": ");
|
|
1053
|
-
} catch {
|
|
1054
|
-
}
|
|
1055
|
-
return msg;
|
|
1056
|
-
}
|
|
1057
|
-
|
|
1058
|
-
// src/lib/prompts.ts
|
|
1059
|
-
async function ask(rl, label, defaultValue) {
|
|
1060
|
-
const hint = defaultValue ? ` [${defaultValue}]` : "";
|
|
1061
|
-
const answer = await rl.question(` ${label}${hint}: `);
|
|
1062
|
-
return answer.trim() || defaultValue;
|
|
1063
|
-
}
|
|
1064
|
-
|
|
1065
|
-
// src/commands/share.ts
|
|
1066
|
-
async function runShare(channelIds, options = {}) {
|
|
1067
|
-
const client = createClient();
|
|
1068
|
-
const config = loadConfig();
|
|
1069
|
-
const serverUrl = config.server;
|
|
1070
|
-
if (!serverUrl) {
|
|
1071
|
-
throw new Error(
|
|
1072
|
-
"No server configured. Run: npx zooid config set server <url>"
|
|
1073
|
-
);
|
|
1074
|
-
}
|
|
1075
|
-
const allChannels = await client.listChannels();
|
|
1076
|
-
const publicChannels = allChannels.filter((ch) => ch.is_public);
|
|
1077
|
-
if (publicChannels.length === 0) {
|
|
1078
|
-
throw new Error("No public channels found on this server.");
|
|
1079
|
-
}
|
|
1080
|
-
let selected;
|
|
1081
|
-
if (channelIds.length > 0) {
|
|
1082
|
-
const byId = new Map(allChannels.map((ch) => [ch.id, ch]));
|
|
1083
|
-
const missing = [];
|
|
1084
|
-
selected = [];
|
|
1085
|
-
for (const id of channelIds) {
|
|
1086
|
-
const ch = byId.get(id);
|
|
1087
|
-
if (!ch) {
|
|
1088
|
-
missing.push(id);
|
|
1089
|
-
} else {
|
|
1090
|
-
selected.push(ch);
|
|
1091
|
-
}
|
|
1092
|
-
}
|
|
1093
|
-
if (missing.length > 0) {
|
|
1094
|
-
throw new Error(`Channels not found: ${missing.join(", ")}`);
|
|
1095
|
-
}
|
|
1096
|
-
const privateChannels = selected.filter((ch) => !ch.is_public);
|
|
1097
|
-
if (privateChannels.length > 0) {
|
|
1098
|
-
throw new Error(
|
|
1099
|
-
`Cannot share private channels: ${privateChannels.map((ch) => ch.id).join(", ")}. Only public channels can be listed in the directory.`
|
|
1100
|
-
);
|
|
1101
|
-
}
|
|
1102
|
-
} else if (options.yes) {
|
|
1103
|
-
selected = publicChannels;
|
|
1104
|
-
} else {
|
|
1105
|
-
selected = await pickChannels(publicChannels);
|
|
1106
|
-
if (selected.length === 0) {
|
|
1107
|
-
throw new Error("No channels selected.");
|
|
1108
|
-
}
|
|
1109
|
-
}
|
|
1110
|
-
const channelDetails = await promptChannelDetails(selected, options.yes);
|
|
1111
|
-
const ids = selected.map((ch) => ch.id);
|
|
1112
|
-
const { claim, signature } = await client.getClaim(ids);
|
|
1113
|
-
const channels = selected.map((ch) => {
|
|
1114
|
-
const details = channelDetails.get(ch.id);
|
|
1115
|
-
const entry = {
|
|
1116
|
-
channel_id: ch.id,
|
|
1117
|
-
name: ch.name
|
|
1118
|
-
};
|
|
1119
|
-
if (details.description) entry.description = details.description;
|
|
1120
|
-
if (details.tags.length > 0) entry.tags = details.tags;
|
|
1121
|
-
return entry;
|
|
1122
|
-
});
|
|
1123
|
-
const res = await directoryFetch("/api/servers", {
|
|
1124
|
-
method: "POST",
|
|
1125
|
-
body: JSON.stringify({ server_url: serverUrl, claim, signature, channels })
|
|
1126
|
-
});
|
|
1127
|
-
if (!res.ok) {
|
|
1128
|
-
throw new Error(await formatDirectoryError(res));
|
|
1129
|
-
}
|
|
1130
|
-
await res.json();
|
|
1131
|
-
console.log("");
|
|
1132
|
-
for (const ch of selected) {
|
|
1133
|
-
console.log(` ${ch.id} \u2192 ${serverUrl}/${ch.id}`);
|
|
1134
|
-
}
|
|
1135
|
-
console.log("");
|
|
1136
|
-
console.log(` Any zooid can find your channel using:`);
|
|
1137
|
-
console.log(` npx zooid discover --query ${selected[0].id}`);
|
|
1138
|
-
const tags = [
|
|
1139
|
-
...new Set(selected.flatMap((ch) => channelDetails.get(ch.id)?.tags ?? []))
|
|
1140
|
-
];
|
|
1141
|
-
if (tags.length > 0) {
|
|
1142
|
-
console.log(` npx zooid discover --tag ${tags[0]}`);
|
|
1143
|
-
}
|
|
1144
|
-
console.log("");
|
|
1145
|
-
}
|
|
1146
|
-
async function pickChannels(channels) {
|
|
1147
|
-
const { default: checkbox } = await import("@inquirer/checkbox");
|
|
1148
|
-
const selected = await checkbox({
|
|
1149
|
-
message: "Select channels to share",
|
|
1150
|
-
choices: channels.map((ch) => ({
|
|
1151
|
-
name: ch.description ? `${ch.id} \u2014 ${ch.description}` : ch.id,
|
|
1152
|
-
value: ch.id,
|
|
1153
|
-
checked: true
|
|
1154
|
-
})),
|
|
1155
|
-
theme: {
|
|
1156
|
-
icon: { cursor: "> " },
|
|
1157
|
-
style: { highlight: (text) => text }
|
|
1158
|
-
}
|
|
1159
|
-
});
|
|
1160
|
-
const selectedSet = new Set(selected);
|
|
1161
|
-
return channels.filter((ch) => selectedSet.has(ch.id));
|
|
1162
|
-
}
|
|
1163
|
-
async function promptChannelDetails(channels, skipPrompt) {
|
|
1164
|
-
const result = /* @__PURE__ */ new Map();
|
|
1165
|
-
if (skipPrompt) {
|
|
1166
|
-
for (const ch of channels) {
|
|
1167
|
-
result.set(ch.id, {
|
|
1168
|
-
description: ch.description ?? "",
|
|
1169
|
-
tags: ch.tags
|
|
1170
|
-
});
|
|
1171
|
-
}
|
|
1172
|
-
return result;
|
|
1173
|
-
}
|
|
1174
|
-
const rl = readline2.createInterface({
|
|
1175
|
-
input: process.stdin,
|
|
1176
|
-
output: process.stdout
|
|
1177
|
-
});
|
|
1178
|
-
try {
|
|
1179
|
-
console.log("");
|
|
1180
|
-
console.log(" Set description and tags for each channel.");
|
|
1181
|
-
console.log(" Press Enter to accept defaults shown in [brackets].\n");
|
|
1182
|
-
for (const ch of channels) {
|
|
1183
|
-
console.log(` ${ch.id}:`);
|
|
1184
|
-
const desc = await ask(rl, "Description", ch.description ?? "");
|
|
1185
|
-
const tagsRaw = await ask(
|
|
1186
|
-
rl,
|
|
1187
|
-
"Tags (comma-separated)",
|
|
1188
|
-
ch.tags.join(", ")
|
|
1189
|
-
);
|
|
1190
|
-
const tags = tagsRaw.split(",").map((t) => t.trim()).filter(Boolean);
|
|
1191
|
-
result.set(ch.id, { description: desc, tags });
|
|
1192
|
-
console.log("");
|
|
1193
|
-
}
|
|
1194
|
-
} finally {
|
|
1195
|
-
rl.close();
|
|
1196
|
-
}
|
|
1197
|
-
return result;
|
|
1198
|
-
}
|
|
1199
|
-
|
|
1200
|
-
// src/commands/unshare.ts
|
|
1201
|
-
async function runUnshare(channelId) {
|
|
1202
|
-
const client = createClient();
|
|
1203
|
-
const config = loadConfig();
|
|
1204
|
-
const serverUrl = config.server;
|
|
1205
|
-
if (!serverUrl) {
|
|
1206
|
-
throw new Error(
|
|
1207
|
-
"No server configured. Run: npx zooid config set server <url>"
|
|
1208
|
-
);
|
|
1209
|
-
}
|
|
1210
|
-
const { claim, signature } = await client.getClaim([channelId], "delete");
|
|
1211
|
-
const res = await directoryFetch("/api/servers/channels", {
|
|
1212
|
-
method: "DELETE",
|
|
1213
|
-
body: JSON.stringify({
|
|
1214
|
-
server_url: serverUrl,
|
|
1215
|
-
channel_id: channelId,
|
|
1216
|
-
claim,
|
|
1217
|
-
signature
|
|
1218
|
-
})
|
|
1219
|
-
});
|
|
1220
|
-
if (!res.ok) {
|
|
1221
|
-
throw new Error(await formatDirectoryError(res));
|
|
1222
|
-
}
|
|
1223
|
-
}
|
|
1224
|
-
|
|
1225
|
-
// src/commands/discover.ts
|
|
1226
|
-
async function runDiscover(options) {
|
|
1227
|
-
const params = new URLSearchParams();
|
|
1228
|
-
if (options.query) params.set("q", options.query);
|
|
1229
|
-
if (options.tag) params.set("tag", options.tag);
|
|
1230
|
-
if (options.limit) params.set("limit", String(options.limit));
|
|
1231
|
-
const qs = params.toString();
|
|
1232
|
-
const url = `${DIRECTORY_BASE_URL}/api/discover${qs ? `?${qs}` : ""}`;
|
|
1233
|
-
const res = await fetch(url);
|
|
1234
|
-
if (!res.ok) {
|
|
1235
|
-
throw new Error(`Directory returned ${res.status}`);
|
|
1236
|
-
}
|
|
1237
|
-
const data = await res.json();
|
|
1238
|
-
if (data.channels.length === 0) {
|
|
1239
|
-
console.log("No channels found.");
|
|
1240
|
-
return;
|
|
1241
|
-
}
|
|
1242
|
-
console.log("");
|
|
1243
|
-
for (const ch of data.channels) {
|
|
1244
|
-
const host = new URL(ch.server_url).host;
|
|
1245
|
-
const tags = ch.tags.length > 0 ? ` [${ch.tags.join(", ")}]` : "";
|
|
1246
|
-
const desc = ch.description ? ` \u2014 ${ch.description}` : "";
|
|
1247
|
-
console.log(` ${host}/${ch.channel_id}${desc}${tags}`);
|
|
1248
|
-
}
|
|
1249
|
-
console.log(`
|
|
1250
|
-
${data.total} channel${data.total === 1 ? "" : "s"} found.`);
|
|
1251
|
-
console.log("");
|
|
1252
|
-
}
|
|
1253
|
-
|
|
1254
|
-
// src/commands/server.ts
|
|
1255
|
-
async function runServerGet(client) {
|
|
1256
|
-
const c = client ?? createClient();
|
|
1257
|
-
return c.getServerMeta();
|
|
1258
|
-
}
|
|
1259
|
-
async function runServerSet(fields, client) {
|
|
1260
|
-
const c = client ?? createClient();
|
|
1261
|
-
return c.updateServerMeta(fields);
|
|
1262
|
-
}
|
|
1263
|
-
|
|
1264
|
-
// src/lib/roles.ts
|
|
1265
|
-
function loadRoleDefs() {
|
|
1266
|
-
const wf = loadWorkforce();
|
|
1267
|
-
return new Map(Object.entries(wf.roles));
|
|
1268
|
-
}
|
|
1269
|
-
function rolesToScopeMapping(roles) {
|
|
1270
|
-
const mapping = {};
|
|
1271
|
-
for (const [id, def] of roles) {
|
|
1272
|
-
mapping[id] = def.scopes;
|
|
1273
|
-
}
|
|
1274
|
-
return JSON.stringify(mapping);
|
|
1275
|
-
}
|
|
1276
|
-
|
|
1277
|
-
// src/lib/resolve-roles.ts
|
|
1278
|
-
function resolveRoleScopes(roleNames) {
|
|
1279
|
-
const roles = loadRoleDefs();
|
|
1280
|
-
const allScopes = /* @__PURE__ */ new Set();
|
|
1281
|
-
for (const name of roleNames) {
|
|
1282
|
-
const role = roles.get(name);
|
|
1283
|
-
if (!role) {
|
|
1284
|
-
throw new Error(
|
|
1285
|
-
`Role "${name}" not found in .zooid/workforce.json. Available: ${[...roles.keys()].join(", ") || "(none)"}`
|
|
1286
|
-
);
|
|
1287
|
-
}
|
|
1288
|
-
for (const scope of role.scopes) {
|
|
1289
|
-
allScopes.add(scope);
|
|
1290
|
-
}
|
|
1291
|
-
}
|
|
1292
|
-
return [...allScopes];
|
|
1293
|
-
}
|
|
1294
|
-
|
|
1295
|
-
// src/commands/token.ts
|
|
1296
|
-
async function runTokenMint(scopes, options) {
|
|
1297
|
-
const client = createClient();
|
|
1298
|
-
if (options.role?.length && scopes.length) {
|
|
1299
|
-
throw new Error("Cannot combine --role with explicit scopes");
|
|
1300
|
-
}
|
|
1301
|
-
if (options.role?.length) {
|
|
1302
|
-
scopes = resolveRoleScopes(options.role);
|
|
1303
|
-
}
|
|
1304
|
-
const body = { scopes };
|
|
1305
|
-
if (options.sub) body.sub = options.sub;
|
|
1306
|
-
if (options.name) body.name = options.name;
|
|
1307
|
-
if (options.expiresIn) body.expires_in = options.expiresIn;
|
|
1308
|
-
if (options.role?.length) body.groups = options.role;
|
|
1309
|
-
return client.mintToken(body);
|
|
1310
|
-
}
|
|
1311
|
-
|
|
1312
|
-
// src/commands/dev.ts
|
|
1313
|
-
import { execSync, spawn as spawn2 } from "child_process";
|
|
1314
|
-
import crypto2 from "crypto";
|
|
1315
|
-
import fs5 from "fs";
|
|
1316
|
-
import path4 from "path";
|
|
1317
|
-
import { fileURLToPath } from "url";
|
|
1318
|
-
|
|
1319
|
-
// src/lib/crypto.ts
|
|
1320
|
-
function base64url(buf) {
|
|
1321
|
-
return buf.toString("base64url");
|
|
1322
|
-
}
|
|
1323
|
-
async function createAdminToken(secret) {
|
|
1324
|
-
const header = base64url(
|
|
1325
|
-
Buffer.from(JSON.stringify({ alg: "HS256", typ: "JWT" }))
|
|
1326
|
-
);
|
|
1327
|
-
const payload = base64url(
|
|
1328
|
-
Buffer.from(
|
|
1329
|
-
JSON.stringify({
|
|
1330
|
-
scope: "admin",
|
|
1331
|
-
iat: Math.floor(Date.now() / 1e3)
|
|
1332
|
-
})
|
|
1333
|
-
)
|
|
1334
|
-
);
|
|
1335
|
-
const data = `${header}.${payload}`;
|
|
1336
|
-
const key = await crypto.subtle.importKey(
|
|
1337
|
-
"raw",
|
|
1338
|
-
new TextEncoder().encode(secret),
|
|
1339
|
-
{ name: "HMAC", hash: "SHA-256" },
|
|
1340
|
-
false,
|
|
1341
|
-
["sign"]
|
|
1342
|
-
);
|
|
1343
|
-
const sig = await crypto.subtle.sign(
|
|
1344
|
-
"HMAC",
|
|
1345
|
-
key,
|
|
1346
|
-
new TextEncoder().encode(data)
|
|
1347
|
-
);
|
|
1348
|
-
const signature = base64url(Buffer.from(sig));
|
|
1349
|
-
return `${data}.${signature}`;
|
|
1350
|
-
}
|
|
1351
|
-
async function createEdDSAAdminToken(privateKeyJwk, kid) {
|
|
1352
|
-
const header = base64url(
|
|
1353
|
-
Buffer.from(JSON.stringify({ alg: "EdDSA", typ: "JWT", kid }))
|
|
1354
|
-
);
|
|
1355
|
-
const payload = base64url(
|
|
1356
|
-
Buffer.from(
|
|
1357
|
-
JSON.stringify({
|
|
1358
|
-
scope: "admin",
|
|
1359
|
-
iat: Math.floor(Date.now() / 1e3)
|
|
1360
|
-
})
|
|
1361
|
-
)
|
|
1362
|
-
);
|
|
1363
|
-
const message = `${header}.${payload}`;
|
|
1364
|
-
const privateKey = await crypto.subtle.importKey(
|
|
1365
|
-
"jwk",
|
|
1366
|
-
privateKeyJwk,
|
|
1367
|
-
{ name: "Ed25519" },
|
|
1368
|
-
false,
|
|
1369
|
-
["sign"]
|
|
1370
|
-
);
|
|
1371
|
-
const sig = await crypto.subtle.sign(
|
|
1372
|
-
"Ed25519",
|
|
1373
|
-
privateKey,
|
|
1374
|
-
new TextEncoder().encode(message)
|
|
1375
|
-
);
|
|
1376
|
-
const signature = base64url(Buffer.from(sig));
|
|
1377
|
-
return `${message}.${signature}`;
|
|
1378
|
-
}
|
|
1379
|
-
|
|
1380
|
-
// src/commands/dev.ts
|
|
1381
|
-
function findServerDir() {
|
|
1382
|
-
const cliDir = path4.dirname(fileURLToPath(import.meta.url));
|
|
1383
|
-
return path4.resolve(cliDir, "../../server");
|
|
1384
|
-
}
|
|
1385
|
-
async function runDev(port = 8787) {
|
|
1386
|
-
const serverDir = findServerDir();
|
|
1387
|
-
if (!fs5.existsSync(path4.join(serverDir, "wrangler.toml"))) {
|
|
1388
|
-
throw new Error(
|
|
1389
|
-
`Server directory not found at ${serverDir}. Make sure you're running from the zooid monorepo.`
|
|
1390
|
-
);
|
|
1391
|
-
}
|
|
1392
|
-
const jwtSecret = crypto2.randomUUID();
|
|
1393
|
-
const devVarsPath = path4.join(serverDir, ".dev.vars");
|
|
1394
|
-
fs5.writeFileSync(devVarsPath, `ZOOID_JWT_SECRET=${jwtSecret}
|
|
1395
|
-
`);
|
|
1396
|
-
const adminToken = await createAdminToken(jwtSecret);
|
|
1397
|
-
const serverUrl = `http://localhost:${port}`;
|
|
1398
|
-
saveConfig({ admin_token: adminToken }, serverUrl);
|
|
1399
|
-
const schemaPath = path4.join(serverDir, "src/db/schema.sql");
|
|
1400
|
-
if (fs5.existsSync(schemaPath)) {
|
|
1401
|
-
try {
|
|
1402
|
-
execSync(
|
|
1403
|
-
`npx wrangler d1 execute zooid-db --local --file=${schemaPath}`,
|
|
1404
|
-
{
|
|
1405
|
-
cwd: serverDir,
|
|
1406
|
-
stdio: "pipe"
|
|
1407
|
-
}
|
|
1408
|
-
);
|
|
1409
|
-
printSuccess("Database schema initialized");
|
|
1410
|
-
} catch {
|
|
1411
|
-
printSuccess("Database ready (schema already exists)");
|
|
1412
|
-
}
|
|
1413
|
-
}
|
|
1414
|
-
printSuccess("Local server configured");
|
|
1415
|
-
printInfo("Server", serverUrl);
|
|
1416
|
-
printInfo("Admin token", adminToken.slice(0, 20) + "...");
|
|
1417
|
-
printInfo("Config saved to", "~/.zooid/state.json");
|
|
1418
|
-
console.log("");
|
|
1419
|
-
console.log("Starting wrangler dev...");
|
|
1420
|
-
console.log("");
|
|
1421
|
-
const child = spawn2(
|
|
1422
|
-
"npx",
|
|
1423
|
-
["wrangler", "dev", "--local", "--port", String(port)],
|
|
1424
|
-
{
|
|
1425
|
-
cwd: serverDir,
|
|
1426
|
-
stdio: "inherit",
|
|
1427
|
-
shell: true
|
|
1428
|
-
}
|
|
1429
|
-
);
|
|
1430
|
-
child.on("error", (err) => {
|
|
1431
|
-
console.error(`Failed to start local server: ${err.message}`);
|
|
1432
|
-
process.exit(1);
|
|
1433
|
-
});
|
|
1434
|
-
child.on("exit", (code) => {
|
|
1435
|
-
process.exit(code ?? 0);
|
|
1436
|
-
});
|
|
1437
|
-
}
|
|
1438
|
-
|
|
1439
|
-
// src/commands/init.ts
|
|
1440
|
-
import fs7 from "fs";
|
|
1441
|
-
import path6 from "path";
|
|
1442
|
-
import readline3 from "readline/promises";
|
|
1443
|
-
|
|
1444
|
-
// src/commands/use.ts
|
|
1445
|
-
import fs6 from "fs";
|
|
1446
|
-
import path5 from "path";
|
|
1447
|
-
var SLUG_RE2 = /^[a-z0-9][a-z0-9-]{1,62}[a-z0-9]$/;
|
|
1448
|
-
function deriveTemplateName(url) {
|
|
1449
|
-
const parts = parseGitHubUrl(url);
|
|
1450
|
-
if (!parts) throw new Error("Cannot derive template name from URL");
|
|
1451
|
-
if (parts.path) {
|
|
1452
|
-
const segments = parts.path.split("/").filter(Boolean);
|
|
1453
|
-
return segments[segments.length - 1];
|
|
1454
|
-
}
|
|
1455
|
-
return parts.repo;
|
|
1456
|
-
}
|
|
1457
|
-
function resolveTemplateName(url, workforce) {
|
|
1458
|
-
const meta = workforce.meta;
|
|
1459
|
-
if (meta?.slug) {
|
|
1460
|
-
const slug = meta.slug;
|
|
1461
|
-
if (!SLUG_RE2.test(slug)) {
|
|
1462
|
-
throw new Error(
|
|
1463
|
-
`Invalid meta.slug "${slug}" \u2014 must be lowercase alphanumeric + hyphens, 3-64 chars`
|
|
1464
|
-
);
|
|
1465
|
-
}
|
|
1466
|
-
return slug;
|
|
1467
|
-
}
|
|
1468
|
-
return deriveTemplateName(url);
|
|
1469
|
-
}
|
|
1470
|
-
function addToInclude(relativePath) {
|
|
1471
|
-
const zooidDir = getZooidDir();
|
|
1472
|
-
const workforcePath = path5.join(zooidDir, "workforce.json");
|
|
1473
|
-
let raw;
|
|
1474
|
-
if (fs6.existsSync(workforcePath)) {
|
|
1475
|
-
raw = JSON.parse(fs6.readFileSync(workforcePath, "utf-8"));
|
|
1476
|
-
} else {
|
|
1477
|
-
raw = { channels: {}, roles: {} };
|
|
1478
|
-
}
|
|
1479
|
-
const include = raw.include ?? [];
|
|
1480
|
-
if (!include.includes(relativePath)) {
|
|
1481
|
-
include.push(relativePath);
|
|
1482
|
-
}
|
|
1483
|
-
raw.include = include;
|
|
1484
|
-
fs6.writeFileSync(workforcePath, JSON.stringify(raw, null, 2) + "\n");
|
|
1485
|
-
}
|
|
1486
|
-
async function runUse(url, options) {
|
|
1487
|
-
printStep("Fetching template...");
|
|
1488
|
-
const zooidDir = getZooidDir();
|
|
1489
|
-
const tmpDir = fs6.mkdtempSync(path5.join(zooidDir, ".tmp-template-"));
|
|
1490
|
-
try {
|
|
1491
|
-
const { fetchTemplate } = await import("./template-T5IB4YWC.js");
|
|
1492
|
-
const result = await fetchTemplate(url, tmpDir, {
|
|
1493
|
-
fetch: options?.fetch
|
|
1494
|
-
});
|
|
1495
|
-
const fetchedZooidDir = path5.join(tmpDir, ".zooid");
|
|
1496
|
-
const fetchedWorkforce = path5.join(fetchedZooidDir, "workforce.json");
|
|
1497
|
-
if (!fs6.existsSync(fetchedWorkforce)) {
|
|
1498
|
-
throw new Error("Template has no .zooid/workforce.json");
|
|
1499
|
-
}
|
|
1500
|
-
const wfRaw = JSON.parse(fs6.readFileSync(fetchedWorkforce, "utf-8"));
|
|
1501
|
-
const slug = resolveTemplateName(url, wfRaw);
|
|
1502
|
-
const targetDir = path5.join(zooidDir, slug);
|
|
1503
|
-
if (fs6.existsSync(targetDir)) {
|
|
1504
|
-
fs6.rmSync(targetDir, { recursive: true });
|
|
1505
|
-
}
|
|
1506
|
-
fs6.cpSync(fetchedZooidDir, targetDir, { recursive: true });
|
|
1507
|
-
addToInclude(`./${slug}/workforce.json`);
|
|
1508
|
-
printSuccess(
|
|
1509
|
-
`Saved .zooid/${slug}/ (${result.channelCount} channel(s), ${result.roleCount} role(s))`
|
|
1510
|
-
);
|
|
1511
|
-
printInfo("Added to include", "workforce.json");
|
|
1512
|
-
} finally {
|
|
1513
|
-
fs6.rmSync(tmpDir, { recursive: true, force: true });
|
|
1514
|
-
}
|
|
1515
|
-
}
|
|
1516
|
-
|
|
1517
|
-
// src/commands/init.ts
|
|
1518
|
-
var CONFIG_FILENAME = "zooid.json";
|
|
1519
|
-
function getConfigPath() {
|
|
1520
|
-
return path6.join(process.cwd(), CONFIG_FILENAME);
|
|
1521
|
-
}
|
|
1522
|
-
function loadServerConfig() {
|
|
1523
|
-
const configPath = getConfigPath();
|
|
1524
|
-
if (!fs7.existsSync(configPath)) return null;
|
|
1525
|
-
const raw = fs7.readFileSync(configPath, "utf-8");
|
|
1526
|
-
return JSON.parse(raw);
|
|
1527
|
-
}
|
|
1528
|
-
function saveServerConfig(config) {
|
|
1529
|
-
const configPath = getConfigPath();
|
|
1530
|
-
fs7.writeFileSync(configPath, JSON.stringify(config, null, 2) + "\n");
|
|
1531
|
-
}
|
|
1532
|
-
async function runInit(options) {
|
|
1533
|
-
const configPath = getConfigPath();
|
|
1534
|
-
const existing = loadServerConfig();
|
|
1535
|
-
if (existing?.url && options?.use) {
|
|
1536
|
-
const workforcePath = path6.join(process.cwd(), ".zooid", "workforce.json");
|
|
1537
|
-
if (!fs7.existsSync(workforcePath)) {
|
|
1538
|
-
fs7.mkdirSync(path6.join(process.cwd(), ".zooid"), { recursive: true });
|
|
1539
|
-
fs7.writeFileSync(
|
|
1540
|
-
workforcePath,
|
|
1541
|
-
JSON.stringify({ channels: {}, roles: {} }, null, 2) + "\n"
|
|
1542
|
-
);
|
|
1543
|
-
}
|
|
1544
|
-
await runUse(options.use);
|
|
1545
|
-
console.log("");
|
|
1546
|
-
printInfo("Server", existing.url);
|
|
1547
|
-
printInfo("Workforce", ".zooid/workforce.json");
|
|
1548
|
-
console.log("");
|
|
1549
|
-
printSuccess("Ready to deploy. Run: npx zooid deploy");
|
|
1550
|
-
console.log("");
|
|
1551
|
-
return;
|
|
1552
|
-
}
|
|
1553
|
-
if (existing) {
|
|
1554
|
-
printInfo("Found existing", configPath);
|
|
1555
|
-
console.log("");
|
|
1556
|
-
}
|
|
1557
|
-
const rl = readline3.createInterface({
|
|
1558
|
-
input: process.stdin,
|
|
1559
|
-
output: process.stdout
|
|
1560
|
-
});
|
|
1561
|
-
try {
|
|
1562
|
-
console.log("");
|
|
1563
|
-
console.log(" Configure your Zooid server");
|
|
1564
|
-
console.log(" Press Enter to accept defaults shown in [brackets].\n");
|
|
1565
|
-
const name = await ask(rl, "Server name", existing?.name ?? "");
|
|
1566
|
-
const description = await ask(
|
|
1567
|
-
rl,
|
|
1568
|
-
"Description",
|
|
1569
|
-
existing?.description ?? ""
|
|
1570
|
-
);
|
|
1571
|
-
const owner = await ask(rl, "Owner", existing?.owner ?? "");
|
|
1572
|
-
const company = await ask(rl, "Company", existing?.company ?? "");
|
|
1573
|
-
const email = await ask(rl, "Email", existing?.email ?? "");
|
|
1574
|
-
const tagsRaw = await ask(
|
|
1575
|
-
rl,
|
|
1576
|
-
"Tags (comma-separated)",
|
|
1577
|
-
existing?.tags?.join(", ") ?? ""
|
|
1578
|
-
);
|
|
1579
|
-
const url = await ask(
|
|
1580
|
-
rl,
|
|
1581
|
-
"URL (leave empty to assign on deploy)",
|
|
1582
|
-
existing?.url ?? ""
|
|
1583
|
-
);
|
|
1584
|
-
const tags = tagsRaw.split(",").map((t) => t.trim()).filter(Boolean);
|
|
1585
|
-
const config = {
|
|
1586
|
-
name,
|
|
1587
|
-
description,
|
|
1588
|
-
owner,
|
|
1589
|
-
company,
|
|
1590
|
-
email,
|
|
1591
|
-
tags,
|
|
1592
|
-
url
|
|
1593
|
-
};
|
|
1594
|
-
fs7.writeFileSync(configPath, JSON.stringify(config, null, 2) + "\n");
|
|
1595
|
-
const workforcePath = path6.join(process.cwd(), ".zooid", "workforce.json");
|
|
1596
|
-
if (!fs7.existsSync(workforcePath)) {
|
|
1597
|
-
fs7.mkdirSync(path6.join(process.cwd(), ".zooid"), { recursive: true });
|
|
1598
|
-
fs7.writeFileSync(
|
|
1599
|
-
workforcePath,
|
|
1600
|
-
JSON.stringify({ channels: {}, roles: {} }, null, 2) + "\n"
|
|
1601
|
-
);
|
|
1602
|
-
}
|
|
1603
|
-
console.log("");
|
|
1604
|
-
printSuccess(`Saved ${CONFIG_FILENAME}`);
|
|
1605
|
-
console.log("");
|
|
1606
|
-
printInfo("Name", config.name || "(not set)");
|
|
1607
|
-
printInfo("Description", config.description || "(not set)");
|
|
1608
|
-
printInfo("Owner", config.owner || "(not set)");
|
|
1609
|
-
printInfo("Company", config.company || "(not set)");
|
|
1610
|
-
printInfo("Email", config.email || "(not set)");
|
|
1611
|
-
printInfo(
|
|
1612
|
-
"Tags",
|
|
1613
|
-
config.tags.length > 0 ? config.tags.join(", ") : "(none)"
|
|
1614
|
-
);
|
|
1615
|
-
printInfo("URL", config.url || "(not set)");
|
|
1616
|
-
printInfo("Workforce", ".zooid/workforce.json");
|
|
1617
|
-
console.log("");
|
|
1618
|
-
} finally {
|
|
1619
|
-
rl.close();
|
|
1620
|
-
}
|
|
1621
|
-
if (options?.use) {
|
|
1622
|
-
await runUse(options.use);
|
|
1623
|
-
}
|
|
1624
|
-
}
|
|
1625
|
-
|
|
1626
|
-
// src/commands/deploy.ts
|
|
1627
|
-
import { execSync as execSync2, spawnSync } from "child_process";
|
|
1628
|
-
import crypto3 from "crypto";
|
|
1629
|
-
import fs9 from "fs";
|
|
1630
|
-
import os from "os";
|
|
1631
|
-
import path7 from "path";
|
|
1632
|
-
import readline5 from "readline/promises";
|
|
1633
|
-
import { createRequire } from "module";
|
|
1634
|
-
import { ZooidClient } from "@zooid/sdk";
|
|
1635
|
-
|
|
1636
|
-
// src/lib/channels.ts
|
|
1637
|
-
function loadChannelDefs() {
|
|
1638
|
-
const wf = loadWorkforce();
|
|
1639
|
-
return new Map(Object.entries(wf.channels));
|
|
1640
|
-
}
|
|
1641
|
-
|
|
1642
|
-
// src/lib/wrangler-vars.ts
|
|
1643
|
-
import fs8 from "fs";
|
|
1644
|
-
function setWranglerVar(tomlPath, key, value) {
|
|
1645
|
-
const content = fs8.readFileSync(tomlPath, "utf-8");
|
|
1646
|
-
const lines = content.split("\n");
|
|
1647
|
-
let varsStart = -1;
|
|
1648
|
-
let varsEnd = lines.length;
|
|
1649
|
-
for (let i = 0; i < lines.length; i++) {
|
|
1650
|
-
if (/^\[vars\]\s*$/.test(lines[i])) {
|
|
1651
|
-
varsStart = i;
|
|
1652
|
-
for (let j = i + 1; j < lines.length; j++) {
|
|
1653
|
-
if (/^\[/.test(lines[j]) && !/^\[vars\]/.test(lines[j])) {
|
|
1654
|
-
varsEnd = j;
|
|
1655
|
-
break;
|
|
1656
|
-
}
|
|
1657
|
-
}
|
|
1658
|
-
break;
|
|
1659
|
-
}
|
|
1660
|
-
}
|
|
1661
|
-
const keyPattern = new RegExp(`^${key}\\s*=`);
|
|
1662
|
-
let existingLine = -1;
|
|
1663
|
-
const searchStart = varsStart >= 0 ? varsStart + 1 : 0;
|
|
1664
|
-
const searchEnd = varsStart >= 0 ? varsEnd : lines.length;
|
|
1665
|
-
for (let i = searchStart; i < searchEnd; i++) {
|
|
1666
|
-
if (keyPattern.test(lines[i])) {
|
|
1667
|
-
existingLine = i;
|
|
1668
|
-
break;
|
|
1669
|
-
}
|
|
1670
|
-
}
|
|
1671
|
-
if (value === null) {
|
|
1672
|
-
if (existingLine >= 0) {
|
|
1673
|
-
lines.splice(existingLine, 1);
|
|
1674
|
-
}
|
|
1675
|
-
} else {
|
|
1676
|
-
const newLine = `${key} = '${value}'`;
|
|
1677
|
-
if (existingLine >= 0) {
|
|
1678
|
-
lines[existingLine] = newLine;
|
|
1679
|
-
} else if (varsStart >= 0) {
|
|
1680
|
-
lines.splice(varsStart + 1, 0, newLine);
|
|
1681
|
-
} else {
|
|
1682
|
-
lines.push("", "[vars]", newLine);
|
|
1683
|
-
}
|
|
1684
|
-
}
|
|
1685
|
-
fs8.writeFileSync(tomlPath, lines.join("\n"));
|
|
1686
|
-
}
|
|
1687
|
-
|
|
1688
|
-
// src/lib/channel-sync.ts
|
|
1689
|
-
import readline4 from "readline/promises";
|
|
1690
|
-
async function syncChannelsToServer(client, options = {}) {
|
|
1691
|
-
const localDefs = loadChannelDefs();
|
|
1692
|
-
const remoteChannels = await client.listChannels();
|
|
1693
|
-
const remoteIds = new Set(remoteChannels.map((ch) => ch.id));
|
|
1694
|
-
const localIds = new Set(localDefs.keys());
|
|
1695
|
-
let created = 0;
|
|
1696
|
-
let updated = 0;
|
|
1697
|
-
let deleted = 0;
|
|
1698
|
-
for (const [id, def] of localDefs) {
|
|
1699
|
-
if (!remoteIds.has(id)) {
|
|
1700
|
-
await client.createChannel({
|
|
1701
|
-
id,
|
|
1702
|
-
name: def.name ?? id,
|
|
1703
|
-
description: def.description,
|
|
1704
|
-
is_public: def.visibility === "public",
|
|
1705
|
-
config: def.config
|
|
1706
|
-
});
|
|
1707
|
-
printSuccess(`Channel created: ${id}`);
|
|
1708
|
-
created++;
|
|
1709
|
-
}
|
|
1710
|
-
}
|
|
1711
|
-
for (const [id, def] of localDefs) {
|
|
1712
|
-
if (remoteIds.has(id)) {
|
|
1713
|
-
await client.updateChannel(id, {
|
|
1714
|
-
name: def.name,
|
|
1715
|
-
description: def.description,
|
|
1716
|
-
is_public: def.visibility === "public",
|
|
1717
|
-
config: def.config ?? {}
|
|
1718
|
-
});
|
|
1719
|
-
printSuccess(`Channel updated: ${id}`);
|
|
1720
|
-
updated++;
|
|
1721
|
-
}
|
|
1722
|
-
}
|
|
1723
|
-
const orphaned = remoteChannels.filter((ch) => !localIds.has(ch.id));
|
|
1724
|
-
if (orphaned.length > 0) {
|
|
1725
|
-
if (options.prune) {
|
|
1726
|
-
for (const ch of orphaned) {
|
|
1727
|
-
await client.deleteChannel(ch.id);
|
|
1728
|
-
printSuccess(`Channel deleted: ${ch.id}`);
|
|
1729
|
-
deleted++;
|
|
1730
|
-
}
|
|
1731
|
-
} else if (options.confirmDelete) {
|
|
1732
|
-
const confirmed = await options.confirmDelete(orphaned);
|
|
1733
|
-
if (confirmed) {
|
|
1734
|
-
for (const ch of orphaned) {
|
|
1735
|
-
await client.deleteChannel(ch.id);
|
|
1736
|
-
printSuccess(`Channel deleted: ${ch.id}`);
|
|
1737
|
-
deleted++;
|
|
1738
|
-
}
|
|
1739
|
-
} else {
|
|
1740
|
-
printInfo("Skipped", "Remote-only channels left unchanged");
|
|
1741
|
-
}
|
|
1742
|
-
} else {
|
|
1743
|
-
printInfo(
|
|
1744
|
-
"Warning",
|
|
1745
|
-
`${orphaned.length} channel(s) on server not in workforce.json (use --prune to remove)`
|
|
1746
|
-
);
|
|
1747
|
-
for (const ch of orphaned) {
|
|
1748
|
-
printInfo(" -", `${ch.id}${ch.name ? ` (${ch.name})` : ""}`);
|
|
1749
|
-
}
|
|
1750
|
-
}
|
|
1751
|
-
}
|
|
1752
|
-
return { created, updated, deleted };
|
|
1753
|
-
}
|
|
1754
|
-
|
|
1755
|
-
// src/commands/deploy.ts
|
|
1756
|
-
var require2 = createRequire(import.meta.url);
|
|
1757
|
-
function resolvePackageDir(packageName) {
|
|
1758
|
-
const pkgJson = require2.resolve(`${packageName}/package.json`);
|
|
1759
|
-
return path7.dirname(pkgJson);
|
|
1760
|
-
}
|
|
1761
|
-
var USER_WRANGLER_TOML = path7.join(process.cwd(), "wrangler.toml");
|
|
1762
|
-
function ejectWranglerToml(opts) {
|
|
1763
|
-
const serverDir = resolvePackageDir("@zooid/server");
|
|
1764
|
-
let toml = fs9.readFileSync(path7.join(serverDir, "wrangler.toml"), "utf-8");
|
|
1765
|
-
toml = toml.replace(/directory\s*=\s*"[^"]*"/, 'directory = "./web-dist/"');
|
|
1766
|
-
toml = toml.replace(/name = "[^"]*"/, `name = "${opts.workerName}"`);
|
|
1767
|
-
toml = toml.replace(
|
|
1768
|
-
/database_name = "[^"]*"/,
|
|
1769
|
-
`database_name = "${opts.dbName}"`
|
|
1770
|
-
);
|
|
1771
|
-
toml = toml.replace(
|
|
1772
|
-
/database_id = "[^"]*"/,
|
|
1773
|
-
`database_id = "${opts.databaseId}"`
|
|
1774
|
-
);
|
|
1775
|
-
toml = toml.replace(
|
|
1776
|
-
/ZOOID_SERVER_ID = "[^"]*"/,
|
|
1777
|
-
`ZOOID_SERVER_ID = "${opts.serverSlug}"`
|
|
1778
|
-
);
|
|
1779
|
-
fs9.writeFileSync(USER_WRANGLER_TOML, toml);
|
|
1780
|
-
}
|
|
1781
|
-
function prepareStagingDir() {
|
|
1782
|
-
const serverDir = resolvePackageDir("@zooid/server");
|
|
1783
|
-
const serverRequire = createRequire(path7.join(serverDir, "package.json"));
|
|
1784
|
-
const webDir = path7.dirname(serverRequire.resolve("@zooid/web/package.json"));
|
|
1785
|
-
const webDistDir = path7.join(webDir, "dist");
|
|
1786
|
-
if (!fs9.existsSync(webDistDir)) {
|
|
1787
|
-
throw new Error(`Web dashboard not built. Missing: ${webDistDir}`);
|
|
1788
|
-
}
|
|
1789
|
-
const tmpDir = fs9.mkdtempSync(path7.join(os.tmpdir(), "zooid-deploy-"));
|
|
1790
|
-
copyDirSync(path7.join(serverDir, "src"), path7.join(tmpDir, "src"));
|
|
1791
|
-
copyDirSync(webDistDir, path7.join(tmpDir, "web-dist"));
|
|
1792
|
-
if (fs9.existsSync(USER_WRANGLER_TOML)) {
|
|
1793
|
-
fs9.copyFileSync(USER_WRANGLER_TOML, path7.join(tmpDir, "wrangler.toml"));
|
|
1794
|
-
} else {
|
|
1795
|
-
if (!fs9.existsSync(path7.join(serverDir, "wrangler.toml"))) {
|
|
1796
|
-
throw new Error(`Server package missing wrangler.toml at ${serverDir}`);
|
|
1797
|
-
}
|
|
1798
|
-
let toml = fs9.readFileSync(path7.join(serverDir, "wrangler.toml"), "utf-8");
|
|
1799
|
-
toml = toml.replace(/directory\s*=\s*"[^"]*"/, 'directory = "./web-dist/"');
|
|
1800
|
-
fs9.writeFileSync(path7.join(tmpDir, "wrangler.toml"), toml);
|
|
1801
|
-
}
|
|
1802
|
-
const nodeModules = findServerNodeModules(serverDir);
|
|
1803
|
-
if (nodeModules) {
|
|
1804
|
-
fs9.symlinkSync(nodeModules, path7.join(tmpDir, "node_modules"), "junction");
|
|
1805
|
-
}
|
|
1806
|
-
return tmpDir;
|
|
1807
|
-
}
|
|
1808
|
-
function findServerNodeModules(serverDir) {
|
|
1809
|
-
const local = path7.join(serverDir, "node_modules");
|
|
1810
|
-
if (fs9.existsSync(path7.join(local, "hono"))) return local;
|
|
1811
|
-
const storeNodeModules = path7.resolve(serverDir, "..", "..");
|
|
1812
|
-
if (fs9.existsSync(path7.join(storeNodeModules, "hono")))
|
|
1813
|
-
return storeNodeModules;
|
|
1814
|
-
let dir = serverDir;
|
|
1815
|
-
while (dir !== path7.dirname(dir)) {
|
|
1816
|
-
dir = path7.dirname(dir);
|
|
1817
|
-
const nm = path7.join(dir, "node_modules");
|
|
1818
|
-
if (fs9.existsSync(path7.join(nm, "hono"))) return nm;
|
|
1819
|
-
}
|
|
1820
|
-
return null;
|
|
1821
|
-
}
|
|
1822
|
-
function copyDirSync(src, dest) {
|
|
1823
|
-
fs9.mkdirSync(dest, { recursive: true });
|
|
1824
|
-
for (const entry of fs9.readdirSync(src, { withFileTypes: true })) {
|
|
1825
|
-
const srcPath = path7.join(src, entry.name);
|
|
1826
|
-
const destPath = path7.join(dest, entry.name);
|
|
1827
|
-
if (entry.isDirectory()) {
|
|
1828
|
-
copyDirSync(srcPath, destPath);
|
|
1829
|
-
} else {
|
|
1830
|
-
fs9.copyFileSync(srcPath, destPath);
|
|
1831
|
-
}
|
|
1832
|
-
}
|
|
1833
|
-
}
|
|
1834
|
-
function wranglerEnv(creds) {
|
|
1835
|
-
const env = {
|
|
1836
|
-
...process.env,
|
|
1837
|
-
CLOUDFLARE_API_TOKEN: creds.apiToken
|
|
1838
|
-
};
|
|
1839
|
-
if (creds.accountId) {
|
|
1840
|
-
env.CLOUDFLARE_ACCOUNT_ID = creds.accountId;
|
|
1841
|
-
}
|
|
1842
|
-
return env;
|
|
1843
|
-
}
|
|
1844
|
-
function wrangler(cmd, cwd, creds, opts) {
|
|
1845
|
-
return execSync2(`npx wrangler ${cmd}`, {
|
|
1846
|
-
cwd,
|
|
1847
|
-
stdio: "pipe",
|
|
1848
|
-
encoding: "utf-8",
|
|
1849
|
-
env: wranglerEnv(creds),
|
|
1850
|
-
input: opts?.input
|
|
1851
|
-
});
|
|
1852
|
-
}
|
|
1853
|
-
function wranglerVerbose(cmd, cwd, creds) {
|
|
1854
|
-
const result = spawnSync("npx", ["wrangler", ...cmd.split(" ")], {
|
|
1855
|
-
cwd,
|
|
1856
|
-
stdio: "pipe",
|
|
1857
|
-
encoding: "utf-8",
|
|
1858
|
-
env: wranglerEnv(creds),
|
|
1859
|
-
timeout: 5 * 60 * 1e3,
|
|
1860
|
-
// 5 minute timeout
|
|
1861
|
-
shell: true
|
|
1862
|
-
});
|
|
1863
|
-
if (result.stderr) process.stderr.write(result.stderr);
|
|
1864
|
-
if (result.stdout) process.stdout.write(result.stdout);
|
|
1865
|
-
if (result.error) {
|
|
1866
|
-
throw result.error;
|
|
1867
|
-
}
|
|
1868
|
-
if (result.status !== 0) {
|
|
1869
|
-
throw new Error(`wrangler ${cmd} exited with code ${result.status}`);
|
|
1870
|
-
}
|
|
1871
|
-
return result.stdout ?? "";
|
|
1872
|
-
}
|
|
1873
|
-
function parseDeployUrls(output) {
|
|
1874
|
-
const workersDev = output.match(/https:\/\/[^\s]+\.workers\.dev/);
|
|
1875
|
-
const custom = output.match(
|
|
1876
|
-
/^\s+(\S+\.(?!workers\.dev)\S+)\s+\(custom domain\)/m
|
|
1877
|
-
);
|
|
1878
|
-
return {
|
|
1879
|
-
workerUrl: workersDev ? workersDev[0] : null,
|
|
1880
|
-
customDomain: custom ? `https://${custom[1]}` : null
|
|
1881
|
-
};
|
|
1882
|
-
}
|
|
1883
|
-
function loadDotEnv() {
|
|
1884
|
-
const envPath = path7.join(process.cwd(), ".env");
|
|
1885
|
-
if (!fs9.existsSync(envPath)) return {};
|
|
1886
|
-
const content = fs9.readFileSync(envPath, "utf-8");
|
|
1887
|
-
const tokenMatch = content.match(/^CLOUDFLARE_API_TOKEN=(.+)$/m);
|
|
1888
|
-
const accountMatch = content.match(/^CLOUDFLARE_ACCOUNT_ID=(.+)$/m);
|
|
1889
|
-
return {
|
|
1890
|
-
apiToken: tokenMatch ? tokenMatch[1].trim() : void 0,
|
|
1891
|
-
accountId: accountMatch ? accountMatch[1].trim() : void 0
|
|
1892
|
-
};
|
|
1893
|
-
}
|
|
1894
|
-
async function getCfCredentials() {
|
|
1895
|
-
const envToken = process.env.CLOUDFLARE_API_TOKEN;
|
|
1896
|
-
const envAccount = process.env.CLOUDFLARE_ACCOUNT_ID;
|
|
1897
|
-
if (envToken) {
|
|
1898
|
-
return { apiToken: envToken, accountId: envAccount };
|
|
1899
|
-
}
|
|
1900
|
-
const dotEnv = loadDotEnv();
|
|
1901
|
-
if (dotEnv.apiToken) {
|
|
1902
|
-
printInfo("Using credentials from", ".env");
|
|
1903
|
-
return { apiToken: dotEnv.apiToken, accountId: dotEnv.accountId };
|
|
1904
|
-
}
|
|
1905
|
-
const rl = readline5.createInterface({
|
|
1906
|
-
input: process.stdin,
|
|
1907
|
-
output: process.stdout
|
|
1908
|
-
});
|
|
1909
|
-
try {
|
|
1910
|
-
console.log("");
|
|
1911
|
-
console.log(" Cloudflare API token required for deployment.");
|
|
1912
|
-
console.log(" Go to: https://dash.cloudflare.com/profile/api-tokens");
|
|
1913
|
-
console.log(
|
|
1914
|
-
' Use the "Edit Cloudflare Workers" template, then add D1 Edit permission.'
|
|
1915
|
-
);
|
|
1916
|
-
console.log(" Tip: save credentials in .env to skip this prompt.");
|
|
1917
|
-
console.log("");
|
|
1918
|
-
const token = await rl.question(" API token: ");
|
|
1919
|
-
const accountId = await rl.question(
|
|
1920
|
-
" Account ID (from the dashboard URL or Workers overview): "
|
|
1921
|
-
);
|
|
1922
|
-
return {
|
|
1923
|
-
apiToken: token.trim(),
|
|
1924
|
-
accountId: accountId.trim() || void 0
|
|
1925
|
-
};
|
|
1926
|
-
} finally {
|
|
1927
|
-
rl.close();
|
|
1928
|
-
}
|
|
1929
|
-
}
|
|
1930
|
-
async function runDeploy(opts) {
|
|
1931
|
-
let config = loadServerConfig();
|
|
1932
|
-
if (!config) {
|
|
1933
|
-
printInfo("No zooid.json found", "starting setup...");
|
|
1934
|
-
console.log("");
|
|
1935
|
-
await runInit();
|
|
1936
|
-
config = loadServerConfig();
|
|
1937
|
-
}
|
|
1938
|
-
if (!config) {
|
|
1939
|
-
printError("Failed to load zooid.json after init");
|
|
1940
|
-
process.exit(1);
|
|
1941
|
-
}
|
|
1942
|
-
const serverUrl = config.url;
|
|
1943
|
-
if (serverUrl && isZoonHosted(serverUrl)) {
|
|
1944
|
-
await deployZoonHosted(config, serverUrl, opts);
|
|
1945
|
-
return;
|
|
1946
|
-
}
|
|
1947
|
-
let stagingDir;
|
|
1948
|
-
try {
|
|
1949
|
-
stagingDir = prepareStagingDir();
|
|
1950
|
-
} catch (err) {
|
|
1951
|
-
printError(err.message);
|
|
1952
|
-
process.exit(1);
|
|
1953
|
-
}
|
|
1954
|
-
const serverSlug = config.name.toLowerCase().replace(/[^a-z0-9-]/g, "-").replace(/-+/g, "-");
|
|
1955
|
-
const dbName = `zooid-db-${serverSlug}`;
|
|
1956
|
-
const workerName = `zooid-${serverSlug}`;
|
|
1957
|
-
try {
|
|
1958
|
-
execSync2("npx wrangler --version", { cwd: stagingDir, stdio: "pipe" });
|
|
1959
|
-
} catch {
|
|
1960
|
-
printError("wrangler not found. Install with: npm install -g wrangler");
|
|
1961
|
-
process.exit(1);
|
|
1962
|
-
}
|
|
1963
|
-
const creds = await getCfCredentials();
|
|
1964
|
-
try {
|
|
1965
|
-
wrangler("whoami", stagingDir, creds);
|
|
1966
|
-
printSuccess("Cloudflare authentication verified");
|
|
1967
|
-
} catch {
|
|
1968
|
-
printError("Invalid Cloudflare API token");
|
|
1969
|
-
cleanup(stagingDir);
|
|
1970
|
-
process.exit(1);
|
|
1971
|
-
}
|
|
1972
|
-
let isFirstDeploy = false;
|
|
1973
|
-
try {
|
|
1974
|
-
const output = wrangler("d1 list --json", stagingDir, creds);
|
|
1975
|
-
const databases = JSON.parse(output);
|
|
1976
|
-
isFirstDeploy = !databases.some((db) => db.name === dbName);
|
|
1977
|
-
} catch {
|
|
1978
|
-
isFirstDeploy = true;
|
|
1979
|
-
}
|
|
1980
|
-
let adminToken;
|
|
1981
|
-
if (isFirstDeploy) {
|
|
1982
|
-
console.log("");
|
|
1983
|
-
printInfo("Deploy type", "First deploy \u2014 setting up database and secrets");
|
|
1984
|
-
console.log("");
|
|
1985
|
-
printStep(`Creating D1 database (${dbName})...`);
|
|
1986
|
-
const d1Output = wrangler(`d1 create ${dbName}`, stagingDir, creds);
|
|
1987
|
-
const dbIdMatch = d1Output.match(/database_id\s*=\s*"([^"]+)"/);
|
|
1988
|
-
if (!dbIdMatch) {
|
|
1989
|
-
printError("Failed to parse database ID from wrangler output");
|
|
1990
|
-
console.log(d1Output);
|
|
1991
|
-
cleanup(stagingDir);
|
|
1992
|
-
process.exit(1);
|
|
1993
|
-
}
|
|
1994
|
-
const databaseId = dbIdMatch[1];
|
|
1995
|
-
printSuccess(`D1 database created (${databaseId})`);
|
|
1996
|
-
ejectWranglerToml({ workerName, dbName, databaseId, serverSlug });
|
|
1997
|
-
fs9.copyFileSync(USER_WRANGLER_TOML, path7.join(stagingDir, "wrangler.toml"));
|
|
1998
|
-
printSuccess(
|
|
1999
|
-
"Created wrangler.toml (edit to add vars, observability, etc.)"
|
|
2000
|
-
);
|
|
2001
|
-
const schemaPath = path7.join(stagingDir, "src/db/schema.sql");
|
|
2002
|
-
if (fs9.existsSync(schemaPath)) {
|
|
2003
|
-
printStep("Running database schema migration...");
|
|
2004
|
-
wrangler(
|
|
2005
|
-
`d1 execute ${dbName} --remote --file=${schemaPath}`,
|
|
2006
|
-
stagingDir,
|
|
2007
|
-
creds
|
|
2008
|
-
);
|
|
2009
|
-
printSuccess("Database schema initialized");
|
|
2010
|
-
}
|
|
2011
|
-
printStep("Generating secrets...");
|
|
2012
|
-
const keyPair = await crypto3.subtle.generateKey("Ed25519", true, [
|
|
2013
|
-
"sign",
|
|
2014
|
-
"verify"
|
|
2015
|
-
]);
|
|
2016
|
-
const privateKeyRaw = await crypto3.subtle.exportKey(
|
|
2017
|
-
"pkcs8",
|
|
2018
|
-
keyPair.privateKey
|
|
2019
|
-
);
|
|
2020
|
-
const publicKeyRaw = await crypto3.subtle.exportKey(
|
|
2021
|
-
"raw",
|
|
2022
|
-
keyPair.publicKey
|
|
2023
|
-
);
|
|
2024
|
-
const privateKeyJwk = await crypto3.subtle.exportKey(
|
|
2025
|
-
"jwk",
|
|
2026
|
-
keyPair.privateKey
|
|
2027
|
-
);
|
|
2028
|
-
const publicKeyJwk = await crypto3.subtle.exportKey(
|
|
2029
|
-
"jwk",
|
|
2030
|
-
keyPair.publicKey
|
|
2031
|
-
);
|
|
2032
|
-
const privateKeyB64 = Buffer.from(privateKeyRaw).toString("base64");
|
|
2033
|
-
const publicKeyB64 = Buffer.from(publicKeyRaw).toString("base64");
|
|
2034
|
-
wrangler("secret put ZOOID_SIGNING_KEY", stagingDir, creds, {
|
|
2035
|
-
input: privateKeyB64
|
|
2036
|
-
});
|
|
2037
|
-
printSuccess("Set ZOOID_SIGNING_KEY (Ed25519 private)");
|
|
2038
|
-
wrangler("secret put ZOOID_PUBLIC_KEY", stagingDir, creds, {
|
|
2039
|
-
input: publicKeyB64
|
|
2040
|
-
});
|
|
2041
|
-
printSuccess("Set ZOOID_PUBLIC_KEY (Ed25519 public)");
|
|
2042
|
-
const kid = "local-1";
|
|
2043
|
-
const xValue = publicKeyJwk.x;
|
|
2044
|
-
const insertSql = `INSERT INTO trusted_keys (kid, x, issuer) VALUES ('${kid}', '${xValue}', 'local');`;
|
|
2045
|
-
wrangler(
|
|
2046
|
-
`d1 execute ${dbName} --remote --command="${insertSql}"`,
|
|
2047
|
-
stagingDir,
|
|
2048
|
-
creds
|
|
2049
|
-
);
|
|
2050
|
-
printSuccess(`Registered EdDSA public key (kid: ${kid})`);
|
|
2051
|
-
adminToken = await createEdDSAAdminToken(privateKeyJwk, kid);
|
|
2052
|
-
printSuccess("EdDSA admin token generated");
|
|
2053
|
-
} else {
|
|
2054
|
-
console.log("");
|
|
2055
|
-
printInfo("Deploy type", "Redeploying existing server");
|
|
2056
|
-
console.log("");
|
|
2057
|
-
if (!fs9.existsSync(USER_WRANGLER_TOML)) {
|
|
2058
|
-
printStep("Ejecting wrangler.toml...");
|
|
2059
|
-
let databaseId = "";
|
|
2060
|
-
try {
|
|
2061
|
-
const output = wrangler("d1 list --json", stagingDir, creds);
|
|
2062
|
-
const databases = JSON.parse(output);
|
|
2063
|
-
const db = databases.find((d) => d.name === dbName);
|
|
2064
|
-
if (db) databaseId = db.uuid;
|
|
2065
|
-
} catch {
|
|
2066
|
-
}
|
|
2067
|
-
ejectWranglerToml({ workerName, dbName, databaseId, serverSlug });
|
|
2068
|
-
printSuccess(
|
|
2069
|
-
"Created wrangler.toml (edit to add vars, observability, etc.)"
|
|
2070
|
-
);
|
|
2071
|
-
}
|
|
2072
|
-
fs9.copyFileSync(USER_WRANGLER_TOML, path7.join(stagingDir, "wrangler.toml"));
|
|
2073
|
-
const schemaPath = path7.join(stagingDir, "src/db/schema.sql");
|
|
2074
|
-
if (fs9.existsSync(schemaPath)) {
|
|
2075
|
-
printStep("Running schema migration...");
|
|
2076
|
-
wrangler(
|
|
2077
|
-
`d1 execute ${dbName} --remote --file=${schemaPath}`,
|
|
2078
|
-
stagingDir,
|
|
2079
|
-
creds
|
|
2080
|
-
);
|
|
2081
|
-
printSuccess("Schema up to date");
|
|
2082
|
-
}
|
|
2083
|
-
const migrationsDir = path7.join(stagingDir, "src/db/migrations");
|
|
2084
|
-
if (fs9.existsSync(migrationsDir)) {
|
|
2085
|
-
const migrationFiles = fs9.readdirSync(migrationsDir).filter((f) => f.endsWith(".sql")).sort();
|
|
2086
|
-
for (const file of migrationFiles) {
|
|
2087
|
-
const migrationPath = path7.join(migrationsDir, file);
|
|
2088
|
-
try {
|
|
2089
|
-
wrangler(
|
|
2090
|
-
`d1 execute ${dbName} --remote --file=${migrationPath}`,
|
|
2091
|
-
stagingDir,
|
|
2092
|
-
creds
|
|
2093
|
-
);
|
|
2094
|
-
} catch {
|
|
2095
|
-
}
|
|
2096
|
-
}
|
|
2097
|
-
}
|
|
2098
|
-
try {
|
|
2099
|
-
const keysOutput = wrangler(
|
|
2100
|
-
`d1 execute ${dbName} --remote --json --command="SELECT kid FROM trusted_keys WHERE issuer = 'local' LIMIT 1"`,
|
|
2101
|
-
stagingDir,
|
|
2102
|
-
creds
|
|
2103
|
-
);
|
|
2104
|
-
const keysResult = JSON.parse(keysOutput);
|
|
2105
|
-
const hasLocalKey = keysResult?.[0]?.results?.length > 0;
|
|
2106
|
-
if (!hasLocalKey) {
|
|
2107
|
-
printStep("Upgrading to EdDSA auth...");
|
|
2108
|
-
const keyPair = await crypto3.subtle.generateKey("Ed25519", true, [
|
|
2109
|
-
"sign",
|
|
2110
|
-
"verify"
|
|
2111
|
-
]);
|
|
2112
|
-
const privateKeyRaw = await crypto3.subtle.exportKey(
|
|
2113
|
-
"pkcs8",
|
|
2114
|
-
keyPair.privateKey
|
|
2115
|
-
);
|
|
2116
|
-
const publicKeyRaw = await crypto3.subtle.exportKey(
|
|
2117
|
-
"raw",
|
|
2118
|
-
keyPair.publicKey
|
|
2119
|
-
);
|
|
2120
|
-
const privateKeyJwk = await crypto3.subtle.exportKey(
|
|
2121
|
-
"jwk",
|
|
2122
|
-
keyPair.privateKey
|
|
2123
|
-
);
|
|
2124
|
-
const publicKeyJwk = await crypto3.subtle.exportKey(
|
|
2125
|
-
"jwk",
|
|
2126
|
-
keyPair.publicKey
|
|
2127
|
-
);
|
|
2128
|
-
const privateKeyB64 = Buffer.from(privateKeyRaw).toString("base64");
|
|
2129
|
-
const publicKeyB64 = Buffer.from(publicKeyRaw).toString("base64");
|
|
2130
|
-
wrangler("secret put ZOOID_SIGNING_KEY", stagingDir, creds, {
|
|
2131
|
-
input: privateKeyB64
|
|
2132
|
-
});
|
|
2133
|
-
wrangler("secret put ZOOID_PUBLIC_KEY", stagingDir, creds, {
|
|
2134
|
-
input: publicKeyB64
|
|
2135
|
-
});
|
|
2136
|
-
const kid = "local-1";
|
|
2137
|
-
const xValue = publicKeyJwk.x;
|
|
2138
|
-
const insertSql = `INSERT INTO trusted_keys (kid, x, issuer) VALUES ('${kid}', '${xValue}', 'local');`;
|
|
2139
|
-
wrangler(
|
|
2140
|
-
`d1 execute ${dbName} --remote --command="${insertSql}"`,
|
|
2141
|
-
stagingDir,
|
|
2142
|
-
creds
|
|
2143
|
-
);
|
|
2144
|
-
printSuccess("EdDSA keypair generated and registered");
|
|
2145
|
-
adminToken = await createEdDSAAdminToken(privateKeyJwk, kid);
|
|
2146
|
-
printSuccess("Upgraded to EdDSA admin token");
|
|
2147
|
-
}
|
|
2148
|
-
} catch {
|
|
2149
|
-
printInfo(
|
|
2150
|
-
"Note",
|
|
2151
|
-
"Could not check EdDSA key status, keeping existing token"
|
|
2152
|
-
);
|
|
2153
|
-
}
|
|
2154
|
-
if (!adminToken) {
|
|
2155
|
-
const existingConfig = loadConfig();
|
|
2156
|
-
adminToken = existingConfig.admin_token;
|
|
2157
|
-
}
|
|
2158
|
-
if (!adminToken) {
|
|
2159
|
-
printError("No admin token found in ~/.zooid/state.json for this server");
|
|
2160
|
-
console.log(
|
|
2161
|
-
"If this is a first deploy, remove the D1 database and try again."
|
|
2162
|
-
);
|
|
2163
|
-
cleanup(stagingDir);
|
|
2164
|
-
process.exit(1);
|
|
2165
|
-
}
|
|
2166
|
-
}
|
|
2167
|
-
const roles = loadRoleDefs();
|
|
2168
|
-
if (roles.size > 0) {
|
|
2169
|
-
printStep("Syncing roles to wrangler.toml...");
|
|
2170
|
-
const mapping = rolesToScopeMapping(roles);
|
|
2171
|
-
setWranglerVar(USER_WRANGLER_TOML, "ZOOID_SCOPE_MAPPING", mapping);
|
|
2172
|
-
fs9.copyFileSync(USER_WRANGLER_TOML, path7.join(stagingDir, "wrangler.toml"));
|
|
2173
|
-
printSuccess(`${roles.size} role(s) synced to ZOOID_SCOPE_MAPPING`);
|
|
2174
|
-
} else {
|
|
2175
|
-
setWranglerVar(USER_WRANGLER_TOML, "ZOOID_SCOPE_MAPPING", null);
|
|
2176
|
-
fs9.copyFileSync(USER_WRANGLER_TOML, path7.join(stagingDir, "wrangler.toml"));
|
|
2177
|
-
}
|
|
2178
|
-
printStep("Deploying worker...");
|
|
2179
|
-
const deployOutput = wranglerVerbose("deploy", stagingDir, creds);
|
|
2180
|
-
const { workerUrl, customDomain } = parseDeployUrls(deployOutput);
|
|
2181
|
-
printSuccess("Worker deployed");
|
|
2182
|
-
if (workerUrl) {
|
|
2183
|
-
printInfo("Worker URL", workerUrl);
|
|
2184
|
-
}
|
|
2185
|
-
if (customDomain) {
|
|
2186
|
-
printInfo("Custom domain", customDomain);
|
|
2187
|
-
}
|
|
2188
|
-
const canonicalUrl = config.url || customDomain || workerUrl;
|
|
2189
|
-
await new Promise((r) => setTimeout(r, 2e3));
|
|
2190
|
-
if (canonicalUrl && adminToken) {
|
|
2191
|
-
try {
|
|
2192
|
-
const client = new ZooidClient({
|
|
2193
|
-
server: canonicalUrl,
|
|
2194
|
-
token: adminToken
|
|
2195
|
-
});
|
|
2196
|
-
await client.updateServerMeta({
|
|
2197
|
-
name: config.name || void 0,
|
|
2198
|
-
description: config.description || void 0,
|
|
2199
|
-
tags: config.tags.length > 0 ? config.tags : void 0,
|
|
2200
|
-
owner: config.owner || void 0,
|
|
2201
|
-
company: config.company || void 0,
|
|
2202
|
-
email: config.email || void 0
|
|
2203
|
-
});
|
|
2204
|
-
printSuccess("Server identity updated");
|
|
2205
|
-
} catch (err) {
|
|
2206
|
-
printError(
|
|
2207
|
-
`Failed to push server identity: ${err instanceof Error ? err.message : err}`
|
|
2208
|
-
);
|
|
2209
|
-
}
|
|
2210
|
-
}
|
|
2211
|
-
if (!config.url && (customDomain || workerUrl)) {
|
|
2212
|
-
config.url = customDomain || workerUrl;
|
|
2213
|
-
saveServerConfig(config);
|
|
2214
|
-
printSuccess("Saved URL to zooid.json");
|
|
2215
|
-
}
|
|
2216
|
-
const configToSave = {
|
|
2217
|
-
worker_url: workerUrl || void 0,
|
|
2218
|
-
admin_token: adminToken
|
|
2219
|
-
};
|
|
2220
|
-
if (isFirstDeploy) {
|
|
2221
|
-
configToSave.channels = {};
|
|
2222
|
-
}
|
|
2223
|
-
saveConfig(configToSave, canonicalUrl || void 0);
|
|
2224
|
-
printSuccess("Saved connection config to ~/.zooid/state.json");
|
|
2225
|
-
cleanup(stagingDir);
|
|
2226
|
-
console.log("");
|
|
2227
|
-
console.log(" \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500");
|
|
2228
|
-
console.log(" \u{1FAB8} Zooid server deployed!");
|
|
2229
|
-
console.log(" \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500");
|
|
2230
|
-
printInfo("Server", canonicalUrl || "(unknown)");
|
|
2231
|
-
if (workerUrl && workerUrl !== canonicalUrl) {
|
|
2232
|
-
printInfo("Worker URL", workerUrl);
|
|
2233
|
-
}
|
|
2234
|
-
printInfo("Name", config.name || "(not set)");
|
|
2235
|
-
if (isFirstDeploy) {
|
|
2236
|
-
printInfo("Admin token", adminToken.slice(0, 20) + "...");
|
|
2237
|
-
}
|
|
2238
|
-
printInfo("Config", "~/.zooid/state.json");
|
|
2239
|
-
console.log("");
|
|
2240
|
-
if (canonicalUrl && adminToken) {
|
|
2241
|
-
const channelDefs = loadChannelDefs();
|
|
2242
|
-
if (channelDefs.size > 0 || !isFirstDeploy) {
|
|
2243
|
-
printStep("Syncing channels to server...");
|
|
2244
|
-
try {
|
|
2245
|
-
const client = new ZooidClient({
|
|
2246
|
-
server: canonicalUrl,
|
|
2247
|
-
token: adminToken
|
|
2248
|
-
});
|
|
2249
|
-
const result = await syncChannelsToServer(client, {
|
|
2250
|
-
prune: opts?.prune
|
|
2251
|
-
});
|
|
2252
|
-
if (result.created || result.updated || result.deleted) {
|
|
2253
|
-
printSuccess(
|
|
2254
|
-
`Channels synced (${result.created} created, ${result.updated} updated, ${result.deleted} deleted)`
|
|
2255
|
-
);
|
|
2256
|
-
} else {
|
|
2257
|
-
printSuccess("Channels up to date");
|
|
2258
|
-
}
|
|
2259
|
-
} catch (err) {
|
|
2260
|
-
printError(
|
|
2261
|
-
`Failed to sync channels: ${err instanceof Error ? err.message : err}`
|
|
2262
|
-
);
|
|
2263
|
-
}
|
|
2264
|
-
}
|
|
2265
|
-
}
|
|
2266
|
-
if (isFirstDeploy) {
|
|
2267
|
-
console.log(" Next steps:");
|
|
2268
|
-
console.log(" Edit wrangler.toml to add env vars, observability, etc.");
|
|
2269
|
-
console.log(" npx zooid channel create my-channel");
|
|
2270
|
-
console.log(
|
|
2271
|
-
` npx zooid publish my-channel --data='{"hello": "world"}'`
|
|
2272
|
-
);
|
|
2273
|
-
console.log("");
|
|
2274
|
-
}
|
|
2275
|
-
if (canonicalUrl) {
|
|
2276
|
-
try {
|
|
2277
|
-
const openCmd = process.platform === "darwin" ? "open" : process.platform === "win32" ? "start" : "xdg-open";
|
|
2278
|
-
execSync2(`${openCmd} ${canonicalUrl}`, { stdio: "ignore" });
|
|
2279
|
-
} catch {
|
|
2280
|
-
}
|
|
2281
|
-
}
|
|
2282
|
-
}
|
|
2283
|
-
function cleanup(dir) {
|
|
2284
|
-
try {
|
|
2285
|
-
fs9.rmSync(dir, { recursive: true, force: true });
|
|
2286
|
-
} catch {
|
|
2287
|
-
}
|
|
2288
|
-
}
|
|
2289
|
-
async function deployZoonHosted(config, serverUrl, opts) {
|
|
2290
|
-
const file = loadConfigFile();
|
|
2291
|
-
const entry = file.servers?.[serverUrl];
|
|
2292
|
-
const adminToken = entry?.admin_token;
|
|
2293
|
-
const platformToken = entry?.platform_token;
|
|
2294
|
-
if (!platformToken) {
|
|
2295
|
-
printError("Not authenticated with Zoon platform. Run: npx zooid login");
|
|
2296
|
-
process.exit(1);
|
|
2297
|
-
}
|
|
2298
|
-
console.log("");
|
|
2299
|
-
printInfo("Deploy type", `Zoon-hosted (${serverUrl})`);
|
|
2300
|
-
console.log("");
|
|
2301
|
-
const roles = loadRoleDefs();
|
|
2302
|
-
const roleDefs = Array.from(roles.entries()).filter(([id]) => id !== "owner").map(([id, def]) => {
|
|
2303
|
-
if (id === "public") {
|
|
2304
|
-
printInfo(
|
|
2305
|
-
"Deprecation",
|
|
2306
|
-
'The "public" role has been renamed to "authenticated". Please update your workforce.json.'
|
|
2307
|
-
);
|
|
2308
|
-
}
|
|
2309
|
-
return {
|
|
2310
|
-
slug: id === "public" ? "authenticated" : id,
|
|
2311
|
-
...def.name ? { name: def.name } : {},
|
|
2312
|
-
scopes: def.scopes,
|
|
2313
|
-
...def.description ? { description: def.description } : {}
|
|
2314
|
-
};
|
|
2315
|
-
});
|
|
2316
|
-
if (roleDefs.length > 0) {
|
|
2317
|
-
printStep("Syncing roles to Zoon...");
|
|
2318
|
-
try {
|
|
2319
|
-
const result = await syncRolesToZoon(serverUrl, platformToken, roleDefs);
|
|
2320
|
-
printSuccess(
|
|
2321
|
-
`Roles synced (${result.synced} synced, ${result.deleted} deleted)`
|
|
2322
|
-
);
|
|
2323
|
-
} catch (err) {
|
|
2324
|
-
printError(
|
|
2325
|
-
`Failed to sync roles: ${err instanceof Error ? err.message : err}`
|
|
2326
|
-
);
|
|
2327
|
-
}
|
|
2328
|
-
}
|
|
2329
|
-
if (adminToken) {
|
|
2330
|
-
const channelDefs = loadChannelDefs();
|
|
2331
|
-
if (channelDefs.size > 0) {
|
|
2332
|
-
printStep("Syncing channels to server...");
|
|
2333
|
-
try {
|
|
2334
|
-
const client = new ZooidClient({
|
|
2335
|
-
server: serverUrl,
|
|
2336
|
-
token: adminToken
|
|
2337
|
-
});
|
|
2338
|
-
const result = await syncChannelsToServer(client, {
|
|
2339
|
-
prune: opts?.prune
|
|
2340
|
-
});
|
|
2341
|
-
if (result.created || result.updated || result.deleted) {
|
|
2342
|
-
printSuccess(
|
|
2343
|
-
`Channels synced (${result.created} created, ${result.updated} updated, ${result.deleted} deleted)`
|
|
2344
|
-
);
|
|
2345
|
-
} else {
|
|
2346
|
-
printSuccess("Channels up to date");
|
|
2347
|
-
}
|
|
2348
|
-
} catch (err) {
|
|
2349
|
-
printError(
|
|
2350
|
-
`Failed to sync channels: ${err instanceof Error ? err.message : err}`
|
|
2351
|
-
);
|
|
2352
|
-
}
|
|
2353
|
-
}
|
|
2354
|
-
}
|
|
2355
|
-
console.log("");
|
|
2356
|
-
printSuccess("Deploy complete (Zoon-hosted \u2014 no wrangler deploy needed)");
|
|
2357
|
-
console.log("");
|
|
2358
|
-
}
|
|
2359
|
-
|
|
2360
|
-
// src/commands/destroy.ts
|
|
2361
|
-
import fs10 from "fs";
|
|
2362
|
-
import path8 from "path";
|
|
2363
|
-
import readline6 from "readline/promises";
|
|
2364
|
-
import { ZooidClient as ZooidClient2 } from "@zooid/sdk";
|
|
2365
|
-
function parseWranglerToml(content) {
|
|
2366
|
-
const nameMatch = content.match(/^name\s*=\s*"([^"]+)"/m);
|
|
2367
|
-
const dbNameMatch = content.match(/database_name\s*=\s*"([^"]+)"/);
|
|
2368
|
-
const dbIdMatch = content.match(/database_id\s*=\s*"([^"]+)"/);
|
|
2369
|
-
return {
|
|
2370
|
-
workerName: nameMatch?.[1] ?? null,
|
|
2371
|
-
dbName: dbNameMatch?.[1] ?? null,
|
|
2372
|
-
databaseId: dbIdMatch?.[1] ?? null
|
|
2373
|
-
};
|
|
2374
|
-
}
|
|
2375
|
-
function removeServerFromState(serverUrl) {
|
|
2376
|
-
const statePath = getStatePath();
|
|
2377
|
-
if (!fs10.existsSync(statePath)) return;
|
|
2378
|
-
const file = JSON.parse(fs10.readFileSync(statePath, "utf-8"));
|
|
2379
|
-
if (file.servers) {
|
|
2380
|
-
delete file.servers[serverUrl];
|
|
2381
|
-
}
|
|
2382
|
-
if (file.current === serverUrl) {
|
|
2383
|
-
delete file.current;
|
|
2384
|
-
}
|
|
2385
|
-
fs10.writeFileSync(statePath, JSON.stringify(file, null, 2) + "\n");
|
|
2386
|
-
}
|
|
2387
|
-
async function cfApiFetch(apiPath, apiToken, opts) {
|
|
2388
|
-
return fetch(`https://api.cloudflare.com/client/v4${apiPath}`, {
|
|
2389
|
-
...opts,
|
|
2390
|
-
headers: {
|
|
2391
|
-
Authorization: `Bearer ${apiToken}`,
|
|
2392
|
-
"Content-Type": "application/json",
|
|
2393
|
-
...opts?.headers ?? {}
|
|
2394
|
-
}
|
|
2395
|
-
});
|
|
2396
|
-
}
|
|
2397
|
-
async function deleteD1Database(accountId, databaseId, apiToken) {
|
|
2398
|
-
const res = await cfApiFetch(
|
|
2399
|
-
`/accounts/${accountId}/d1/database/${databaseId}`,
|
|
2400
|
-
apiToken,
|
|
2401
|
-
{ method: "DELETE" }
|
|
2402
|
-
);
|
|
2403
|
-
return res.ok;
|
|
2404
|
-
}
|
|
2405
|
-
async function deleteWorker(accountId, scriptName, apiToken) {
|
|
2406
|
-
const res = await cfApiFetch(
|
|
2407
|
-
`/accounts/${accountId}/workers/scripts/${scriptName}`,
|
|
2408
|
-
apiToken,
|
|
2409
|
-
{ method: "DELETE" }
|
|
2410
|
-
);
|
|
2411
|
-
return res.ok;
|
|
2412
|
-
}
|
|
2413
|
-
function loadDotEnvValue(key) {
|
|
2414
|
-
const envPath = path8.join(process.cwd(), ".env");
|
|
2415
|
-
if (!fs10.existsSync(envPath)) return void 0;
|
|
2416
|
-
const content = fs10.readFileSync(envPath, "utf-8");
|
|
2417
|
-
const match = content.match(new RegExp(`^${key}=(.+)$`, "m"));
|
|
2418
|
-
return match?.[1]?.trim();
|
|
2419
|
-
}
|
|
2420
|
-
async function runDestroy(opts = {}) {
|
|
2421
|
-
const config = loadServerConfig();
|
|
2422
|
-
if (!config) {
|
|
2423
|
-
printError(
|
|
2424
|
-
"No zooid.json found. Run this from your Zooid project directory."
|
|
2425
|
-
);
|
|
2426
|
-
process.exit(1);
|
|
2427
|
-
}
|
|
2428
|
-
const serverUrl = config.url;
|
|
2429
|
-
if (serverUrl && isZoonHosted(serverUrl)) {
|
|
2430
|
-
printError("Zoon-hosted server destroy is not yet supported.");
|
|
2431
|
-
printInfo("Use the Zoon dashboard to delete your server", serverUrl);
|
|
2432
|
-
process.exit(1);
|
|
2433
|
-
}
|
|
2434
|
-
const wranglerPath = path8.join(process.cwd(), "wrangler.toml");
|
|
2435
|
-
if (!fs10.existsSync(wranglerPath)) {
|
|
2436
|
-
printError("No wrangler.toml found. Is this a deployed Zooid project?");
|
|
2437
|
-
process.exit(1);
|
|
2438
|
-
}
|
|
2439
|
-
const wranglerContent = fs10.readFileSync(wranglerPath, "utf-8");
|
|
2440
|
-
const wrangler2 = parseWranglerToml(wranglerContent);
|
|
2441
|
-
if (!wrangler2.workerName) {
|
|
2442
|
-
printError("Could not determine Worker name from wrangler.toml");
|
|
2443
|
-
process.exit(1);
|
|
2444
|
-
}
|
|
2445
|
-
const apiToken = process.env.CLOUDFLARE_API_TOKEN || loadDotEnvValue("CLOUDFLARE_API_TOKEN");
|
|
2446
|
-
const accountId = process.env.CLOUDFLARE_ACCOUNT_ID || loadDotEnvValue("CLOUDFLARE_ACCOUNT_ID");
|
|
2447
|
-
if (!apiToken) {
|
|
2448
|
-
printError(
|
|
2449
|
-
"Cloudflare credentials required. Set CLOUDFLARE_API_TOKEN and CLOUDFLARE_ACCOUNT_ID."
|
|
2450
|
-
);
|
|
2451
|
-
process.exit(1);
|
|
2452
|
-
}
|
|
2453
|
-
if (!accountId) {
|
|
2454
|
-
printError(
|
|
2455
|
-
"CLOUDFLARE_ACCOUNT_ID required. Set it in environment or .env file."
|
|
2456
|
-
);
|
|
2457
|
-
process.exit(1);
|
|
2458
|
-
}
|
|
2459
|
-
const configFile = loadConfigFile();
|
|
2460
|
-
const serverEntry = serverUrl ? configFile.servers?.[serverUrl] : void 0;
|
|
2461
|
-
const adminToken = serverEntry?.admin_token;
|
|
2462
|
-
let channelCount = 0;
|
|
2463
|
-
if (adminToken && serverUrl) {
|
|
2464
|
-
try {
|
|
2465
|
-
const client = new ZooidClient2({
|
|
2466
|
-
server: serverUrl,
|
|
2467
|
-
token: adminToken
|
|
2468
|
-
});
|
|
2469
|
-
const channels = await client.listChannels();
|
|
2470
|
-
channelCount = channels.length;
|
|
2471
|
-
} catch {
|
|
2472
|
-
}
|
|
2473
|
-
}
|
|
2474
|
-
if (!opts.force) {
|
|
2475
|
-
console.log("");
|
|
2476
|
-
console.log(
|
|
2477
|
-
" \u26A0 This will permanently delete your Zooid server and all its data."
|
|
2478
|
-
);
|
|
2479
|
-
console.log("");
|
|
2480
|
-
printInfo("Worker", wrangler2.workerName);
|
|
2481
|
-
if (wrangler2.dbName) printInfo("Database", wrangler2.dbName);
|
|
2482
|
-
if (channelCount > 0) printInfo("Channels", `${channelCount}`);
|
|
2483
|
-
console.log("");
|
|
2484
|
-
console.log(" This action cannot be undone.");
|
|
2485
|
-
console.log("");
|
|
2486
|
-
const serverSlug = wrangler2.workerName.replace(/^zooid-/, "");
|
|
2487
|
-
const rl = readline6.createInterface({
|
|
2488
|
-
input: process.stdin,
|
|
2489
|
-
output: process.stdout
|
|
2490
|
-
});
|
|
2491
|
-
try {
|
|
2492
|
-
const answer = await rl.question(
|
|
2493
|
-
` Type the server name to confirm (${serverSlug}): `
|
|
2494
|
-
);
|
|
2495
|
-
if (answer.trim() !== serverSlug) {
|
|
2496
|
-
printError("Name does not match. Aborting.");
|
|
2497
|
-
process.exit(1);
|
|
2498
|
-
}
|
|
2499
|
-
} finally {
|
|
2500
|
-
rl.close();
|
|
2501
|
-
}
|
|
2502
|
-
}
|
|
2503
|
-
if (adminToken && serverUrl) {
|
|
2504
|
-
printStep("Destroying Durable Objects...");
|
|
2505
|
-
try {
|
|
2506
|
-
const res = await fetch(`${serverUrl}/api/v1/admin/destroy`, {
|
|
2507
|
-
method: "POST",
|
|
2508
|
-
headers: { Authorization: `Bearer ${adminToken}` }
|
|
2509
|
-
});
|
|
2510
|
-
if (res.ok) {
|
|
2511
|
-
const body = await res.json();
|
|
2512
|
-
printSuccess(`${body.destroyed} Durable Object(s) destroyed`);
|
|
2513
|
-
} else {
|
|
2514
|
-
console.warn(
|
|
2515
|
-
" Warning: Could not destroy DOs \u2014 server may be unreachable."
|
|
2516
|
-
);
|
|
2517
|
-
}
|
|
2518
|
-
} catch {
|
|
2519
|
-
console.warn(
|
|
2520
|
-
" Warning: Server unreachable \u2014 Durable Objects may not be fully cleaned up."
|
|
2521
|
-
);
|
|
2522
|
-
}
|
|
2523
|
-
}
|
|
2524
|
-
if (wrangler2.databaseId) {
|
|
2525
|
-
printStep("Deleting D1 database...");
|
|
2526
|
-
const dbOk = await deleteD1Database(
|
|
2527
|
-
accountId,
|
|
2528
|
-
wrangler2.databaseId,
|
|
2529
|
-
apiToken
|
|
2530
|
-
);
|
|
2531
|
-
if (dbOk) {
|
|
2532
|
-
printSuccess(`Deleted ${wrangler2.dbName ?? wrangler2.databaseId}`);
|
|
2533
|
-
} else {
|
|
2534
|
-
console.warn(
|
|
2535
|
-
" Warning: Could not delete D1 database (may already be deleted)."
|
|
2536
|
-
);
|
|
2537
|
-
}
|
|
2538
|
-
}
|
|
2539
|
-
printStep("Deleting Worker...");
|
|
2540
|
-
const workerOk = await deleteWorker(accountId, wrangler2.workerName, apiToken);
|
|
2541
|
-
if (workerOk) {
|
|
2542
|
-
printSuccess(`Deleted ${wrangler2.workerName}`);
|
|
2543
|
-
} else {
|
|
2544
|
-
console.warn(
|
|
2545
|
-
" Warning: Could not delete Worker (may already be deleted)."
|
|
2546
|
-
);
|
|
2547
|
-
}
|
|
2548
|
-
if (!opts.keepLocal) {
|
|
2549
|
-
printStep("Cleaning up local files...");
|
|
2550
|
-
if (fs10.existsSync(wranglerPath)) {
|
|
2551
|
-
fs10.unlinkSync(wranglerPath);
|
|
2552
|
-
printSuccess("Removed wrangler.toml");
|
|
2553
|
-
}
|
|
2554
|
-
if (serverUrl) {
|
|
2555
|
-
removeServerFromState(serverUrl);
|
|
2556
|
-
printSuccess("Removed server entry from ~/.zooid/state.json");
|
|
2557
|
-
}
|
|
2558
|
-
}
|
|
2559
|
-
console.log("");
|
|
2560
|
-
printSuccess("Server destroyed.");
|
|
2561
|
-
console.log(
|
|
2562
|
-
" If you configured a custom domain, remember to remove the DNS record."
|
|
2563
|
-
);
|
|
2564
|
-
}
|
|
2565
|
-
|
|
2566
|
-
// src/commands/login.ts
|
|
2567
|
-
import fs11 from "fs";
|
|
2568
|
-
import path9 from "path";
|
|
2569
|
-
|
|
2570
|
-
// src/lib/device-auth.ts
|
|
2571
|
-
import { exec } from "child_process";
|
|
2572
|
-
function defaultOpenBrowser(url) {
|
|
2573
|
-
const cmd = process.platform === "darwin" ? "open" : process.platform === "win32" ? "start" : "xdg-open";
|
|
2574
|
-
try {
|
|
2575
|
-
exec(`${cmd} ${JSON.stringify(url)}`);
|
|
2576
|
-
} catch {
|
|
2577
|
-
}
|
|
2578
|
-
}
|
|
2579
|
-
async function pollDeviceAuth(accountsUrl, options) {
|
|
2580
|
-
const _fetch = options?.fetch ?? globalThis.fetch;
|
|
2581
|
-
const openBrowser = options?.openBrowser ?? defaultOpenBrowser;
|
|
2582
|
-
const initRes = await _fetch(`${accountsUrl}/api/auth/device/code`, {
|
|
2583
|
-
method: "POST",
|
|
2584
|
-
headers: { "Content-Type": "application/json" },
|
|
2585
|
-
body: JSON.stringify({ client_id: "zooid-cli" })
|
|
2586
|
-
});
|
|
2587
|
-
if (!initRes.ok) {
|
|
2588
|
-
throw new Error(`Failed to initiate device auth: ${initRes.status}`);
|
|
2589
|
-
}
|
|
2590
|
-
const init = await initRes.json();
|
|
2591
|
-
process.stderr.write(
|
|
2592
|
-
`If the browser doesn't open, visit:
|
|
2593
|
-
${init.verification_uri_complete}
|
|
2594
|
-
`
|
|
2595
|
-
);
|
|
2596
|
-
openBrowser(init.verification_uri_complete);
|
|
2597
|
-
const deadline = Date.now() + init.expires_in * 1e3;
|
|
2598
|
-
const interval = (init.interval ?? 5) * 1e3;
|
|
2599
|
-
return new Promise((resolve, reject) => {
|
|
2600
|
-
const poll = async () => {
|
|
2601
|
-
if (Date.now() > deadline) {
|
|
2602
|
-
reject(new Error("Authentication timed out. Please try again."));
|
|
2603
|
-
return;
|
|
2604
|
-
}
|
|
2605
|
-
try {
|
|
2606
|
-
const res = await _fetch(`${accountsUrl}/api/auth/device/token`, {
|
|
2607
|
-
method: "POST",
|
|
2608
|
-
headers: { "Content-Type": "application/json" },
|
|
2609
|
-
body: JSON.stringify({
|
|
2610
|
-
grant_type: "urn:ietf:params:oauth:grant-type:device_code",
|
|
2611
|
-
device_code: init.device_code,
|
|
2612
|
-
client_id: "zooid-cli"
|
|
2613
|
-
})
|
|
2614
|
-
});
|
|
2615
|
-
if (res.status === 200) {
|
|
2616
|
-
const data = await res.json();
|
|
2617
|
-
const sessionRes = await _fetch(
|
|
2618
|
-
`${accountsUrl}/api/auth/get-session`,
|
|
2619
|
-
{
|
|
2620
|
-
headers: { Authorization: `Bearer ${data.access_token}` }
|
|
2621
|
-
}
|
|
2622
|
-
);
|
|
2623
|
-
let user = {
|
|
2624
|
-
email: "unknown",
|
|
2625
|
-
name: void 0
|
|
2626
|
-
};
|
|
2627
|
-
if (sessionRes.ok) {
|
|
2628
|
-
const session = await sessionRes.json();
|
|
2629
|
-
if (session.user) {
|
|
2630
|
-
user = { email: session.user.email, name: session.user.name };
|
|
2631
|
-
}
|
|
2632
|
-
}
|
|
2633
|
-
resolve({ sessionToken: data.access_token, user });
|
|
2634
|
-
return;
|
|
2635
|
-
}
|
|
2636
|
-
if (res.status === 400) {
|
|
2637
|
-
const error = await res.json();
|
|
2638
|
-
if (error.error === "expired_token") {
|
|
2639
|
-
reject(new Error("Device code expired. Please try again."));
|
|
2640
|
-
return;
|
|
2641
|
-
}
|
|
2642
|
-
}
|
|
2643
|
-
} catch {
|
|
2644
|
-
}
|
|
2645
|
-
setTimeout(poll, interval);
|
|
2646
|
-
};
|
|
2647
|
-
setTimeout(poll, interval);
|
|
2648
|
-
});
|
|
2649
|
-
}
|
|
2650
|
-
async function exchangeToken(accountsUrl, sessionToken, serverUrl, options) {
|
|
2651
|
-
const _fetch = options?.fetch ?? globalThis.fetch;
|
|
2652
|
-
const res = await _fetch(`${accountsUrl}/api/auth/device-code/exchange`, {
|
|
2653
|
-
method: "POST",
|
|
2654
|
-
headers: {
|
|
2655
|
-
Authorization: `Bearer ${sessionToken}`,
|
|
2656
|
-
"Content-Type": "application/json"
|
|
2657
|
-
},
|
|
2658
|
-
body: JSON.stringify({ server_url: serverUrl })
|
|
2659
|
-
});
|
|
2660
|
-
if (!res.ok) {
|
|
2661
|
-
const error = await res.json().catch(() => ({}));
|
|
2662
|
-
throw new Error(
|
|
2663
|
-
`Token exchange failed: ${error.message || error.error || res.status}`
|
|
2664
|
-
);
|
|
2665
|
-
}
|
|
2666
|
-
return await res.json();
|
|
2667
|
-
}
|
|
2668
|
-
async function fetchServers(accountsUrl, sessionToken, options) {
|
|
2669
|
-
const _fetch = options?.fetch ?? globalThis.fetch;
|
|
2670
|
-
const res = await _fetch(`${accountsUrl}/api/auth/device-code/servers`, {
|
|
2671
|
-
headers: { Authorization: `Bearer ${sessionToken}` }
|
|
2672
|
-
});
|
|
2673
|
-
if (!res.ok) return [];
|
|
2674
|
-
const data = await res.json();
|
|
2675
|
-
return data.servers;
|
|
2676
|
-
}
|
|
2677
|
-
|
|
2678
|
-
// src/commands/login.ts
|
|
2679
|
-
var ACCOUNTS_URL = "https://accounts.zooid.dev";
|
|
2680
|
-
function writeProjectConfig(serverUrl) {
|
|
2681
|
-
const configPath = path9.join(process.cwd(), "zooid.json");
|
|
2682
|
-
let existing = {};
|
|
2683
|
-
try {
|
|
2684
|
-
existing = JSON.parse(fs11.readFileSync(configPath, "utf-8"));
|
|
2685
|
-
} catch {
|
|
2686
|
-
}
|
|
2687
|
-
existing.url = serverUrl;
|
|
2688
|
-
fs11.writeFileSync(configPath, JSON.stringify(existing, null, 2) + "\n");
|
|
2689
|
-
}
|
|
2690
|
-
async function runLogin(url, options) {
|
|
2691
|
-
const _fetch = options?.fetch ?? globalThis.fetch;
|
|
2692
|
-
if (url) {
|
|
2693
|
-
return loginToServer(normalizeServerUrl(url), _fetch);
|
|
2694
|
-
}
|
|
2695
|
-
return loginToZoon(_fetch);
|
|
2696
|
-
}
|
|
2697
|
-
async function loginToZoon(_fetch) {
|
|
2698
|
-
process.stderr.write("\nOpening browser to authenticate with Zoon...\n");
|
|
2699
|
-
const result = await pollDeviceAuth(ACCOUNTS_URL, { fetch: _fetch });
|
|
2700
|
-
process.stderr.write(
|
|
2701
|
-
`
|
|
2702
|
-
Logged in as ${result.user.name || result.user.email}
|
|
2703
|
-
`
|
|
2704
|
-
);
|
|
2705
|
-
const servers = await fetchServers(ACCOUNTS_URL, result.sessionToken, {
|
|
2706
|
-
fetch: _fetch
|
|
2707
|
-
});
|
|
2708
|
-
let targetServer;
|
|
2709
|
-
try {
|
|
2710
|
-
const fs13 = await import("fs");
|
|
2711
|
-
const path10 = await import("path");
|
|
2712
|
-
const configPath = path10.join(process.cwd(), "zooid.json");
|
|
2713
|
-
if (fs13.existsSync(configPath)) {
|
|
2714
|
-
const raw = JSON.parse(fs13.readFileSync(configPath, "utf-8"));
|
|
2715
|
-
if (raw.url) {
|
|
2716
|
-
const hasAccess = servers.some((s) => s.url === raw.url);
|
|
2717
|
-
if (hasAccess) {
|
|
2718
|
-
targetServer = raw.url;
|
|
2719
|
-
} else {
|
|
2720
|
-
printInfo(
|
|
2721
|
-
"Note",
|
|
2722
|
-
`zooid.json references ${raw.url} but you don't have access to it`
|
|
2723
|
-
);
|
|
2724
|
-
}
|
|
2725
|
-
}
|
|
2726
|
-
}
|
|
2727
|
-
} catch {
|
|
2728
|
-
}
|
|
2729
|
-
if (!targetServer && servers.length > 0) {
|
|
2730
|
-
targetServer = servers[0].url;
|
|
2731
|
-
}
|
|
2732
|
-
if (!targetServer) {
|
|
2733
|
-
saveConfig(
|
|
2734
|
-
{
|
|
2735
|
-
platform_token: result.sessionToken,
|
|
2736
|
-
auth_method: "oidc"
|
|
2737
|
-
},
|
|
2738
|
-
ACCOUNTS_URL,
|
|
2739
|
-
{ setCurrent: false }
|
|
2740
|
-
);
|
|
2741
|
-
printSuccess("Authenticated with Zoon");
|
|
2742
|
-
printInfo("Note", "No servers found. Create one at app.zooid.dev");
|
|
2743
|
-
process.stderr.write("\n");
|
|
2744
|
-
return;
|
|
2745
|
-
}
|
|
2746
|
-
const exchangeResult = await exchangeToken(
|
|
2747
|
-
ACCOUNTS_URL,
|
|
2748
|
-
result.sessionToken,
|
|
2749
|
-
targetServer,
|
|
2750
|
-
{ fetch: _fetch }
|
|
2751
|
-
);
|
|
2752
|
-
saveConfig(
|
|
2753
|
-
{
|
|
2754
|
-
admin_token: exchangeResult.token,
|
|
2755
|
-
platform_token: result.sessionToken,
|
|
2756
|
-
auth_method: "oidc"
|
|
2757
|
-
},
|
|
2758
|
-
targetServer,
|
|
2759
|
-
{ setCurrent: true }
|
|
2760
|
-
);
|
|
2761
|
-
writeProjectConfig(targetServer);
|
|
2762
|
-
printSuccess(`Server: ${targetServer} (set as current)`);
|
|
2763
|
-
printInfo("Project", `zooid.json \u2192 ${targetServer}`);
|
|
2764
|
-
process.stderr.write("\n");
|
|
2765
|
-
}
|
|
2766
|
-
async function loginToServer(serverUrl, _fetch) {
|
|
2767
|
-
const url = new URL(serverUrl);
|
|
2768
|
-
if (url.hostname.endsWith(".zoon.eco")) {
|
|
2769
|
-
process.stderr.write("\nOpening browser to authenticate with Zoon...\n");
|
|
2770
|
-
const result = await pollDeviceAuth(ACCOUNTS_URL, { fetch: _fetch });
|
|
2771
|
-
process.stderr.write(
|
|
2772
|
-
`
|
|
2773
|
-
Logged in as ${result.user.name || result.user.email}
|
|
2774
|
-
`
|
|
2775
|
-
);
|
|
2776
|
-
const exchangeResult = await exchangeToken(
|
|
2777
|
-
ACCOUNTS_URL,
|
|
2778
|
-
result.sessionToken,
|
|
2779
|
-
serverUrl,
|
|
2780
|
-
{ fetch: _fetch }
|
|
2781
|
-
);
|
|
2782
|
-
saveConfig(
|
|
2783
|
-
{
|
|
2784
|
-
admin_token: exchangeResult.token,
|
|
2785
|
-
platform_token: result.sessionToken,
|
|
2786
|
-
auth_method: "oidc"
|
|
2787
|
-
},
|
|
2788
|
-
serverUrl,
|
|
2789
|
-
{ setCurrent: true }
|
|
2790
|
-
);
|
|
2791
|
-
writeProjectConfig(serverUrl);
|
|
2792
|
-
printSuccess(`Server: ${serverUrl} (set as current)`);
|
|
2793
|
-
printInfo("Project", `zooid.json \u2192 ${serverUrl}`);
|
|
2794
|
-
return;
|
|
2795
|
-
}
|
|
2796
|
-
throw new Error(
|
|
2797
|
-
"CLI login for self-hosted servers with external OIDC is coming soon.\nFor now, use `npx zooid token mint` to create a token manually."
|
|
2798
|
-
);
|
|
2799
|
-
}
|
|
2800
|
-
|
|
2801
|
-
// src/commands/logout.ts
|
|
2802
|
-
import fs12 from "fs";
|
|
2803
|
-
async function runLogout(options) {
|
|
2804
|
-
const file = loadConfigFile();
|
|
2805
|
-
if (options.all) {
|
|
2806
|
-
for (const url of Object.keys(file.servers ?? {})) {
|
|
2807
|
-
clearServerAuth(file, url);
|
|
2808
|
-
}
|
|
2809
|
-
process.stderr.write("Logged out of all servers\n");
|
|
2810
|
-
} else {
|
|
2811
|
-
const server = resolveServer();
|
|
2812
|
-
if (!server) {
|
|
2813
|
-
throw new Error("No server configured.");
|
|
2814
|
-
}
|
|
2815
|
-
clearServerAuth(file, server);
|
|
2816
|
-
process.stderr.write(`Logged out of ${server}
|
|
2817
|
-
`);
|
|
2818
|
-
}
|
|
2819
|
-
const dir = getConfigDir();
|
|
2820
|
-
fs12.mkdirSync(dir, { recursive: true });
|
|
2821
|
-
fs12.writeFileSync(getStatePath(), JSON.stringify(file, null, 2) + "\n");
|
|
2822
|
-
}
|
|
2823
|
-
function clearServerAuth(file, url) {
|
|
2824
|
-
const entry = file.servers?.[url];
|
|
2825
|
-
if (!entry) return;
|
|
2826
|
-
delete entry.admin_token;
|
|
2827
|
-
delete entry.refresh_token;
|
|
2828
|
-
delete entry.auth_method;
|
|
2829
|
-
}
|
|
2830
|
-
|
|
2831
|
-
// src/commands/whoami.ts
|
|
2832
|
-
async function runWhoami() {
|
|
2833
|
-
const config = loadConfig();
|
|
2834
|
-
const file = loadConfigFile();
|
|
2835
|
-
if (!config.server) {
|
|
2836
|
-
throw new Error("No server configured. Run: npx zooid login");
|
|
2837
|
-
}
|
|
2838
|
-
const client = createClient();
|
|
2839
|
-
const claims = await client.getTokenClaims();
|
|
2840
|
-
const entry = file.servers?.[config.server];
|
|
2841
|
-
return {
|
|
2842
|
-
server: config.server,
|
|
2843
|
-
sub: claims.sub ?? "unknown",
|
|
2844
|
-
name: claims.name,
|
|
2845
|
-
scopes: claims.scopes ?? [],
|
|
2846
|
-
exp: claims.exp,
|
|
2847
|
-
authMethod: entry?.auth_method
|
|
2848
|
-
};
|
|
2849
|
-
}
|
|
2850
|
-
|
|
2851
|
-
// src/commands/credentials.ts
|
|
2852
|
-
function requireZoonServer() {
|
|
2853
|
-
const server = resolveServer();
|
|
2854
|
-
if (!server) {
|
|
2855
|
-
throw new Error("No server configured. Run: npx zooid login");
|
|
2856
|
-
}
|
|
2857
|
-
if (!isZoonHosted(server)) {
|
|
2858
|
-
throw new Error(
|
|
2859
|
-
"Credentials are only available for Zoon-hosted servers (*.zoon.eco)"
|
|
2860
|
-
);
|
|
2861
|
-
}
|
|
2862
|
-
const file = loadConfigFile();
|
|
2863
|
-
const entry = file.servers?.[server];
|
|
2864
|
-
if (!entry?.platform_token) {
|
|
2865
|
-
throw new Error(
|
|
2866
|
-
"Not authenticated with Zoon platform. Run: npx zooid login"
|
|
2867
|
-
);
|
|
2868
|
-
}
|
|
2869
|
-
return { server, platformToken: entry.platform_token };
|
|
2870
|
-
}
|
|
2871
|
-
function formatEnv(server, clientId, clientSecret) {
|
|
2872
|
-
return [
|
|
2873
|
-
`ZOOID_SERVER=${server}`,
|
|
2874
|
-
`ZOOID_CLIENT_ID=${clientId}`,
|
|
2875
|
-
`ZOOID_CLIENT_SECRET=${clientSecret}`
|
|
2876
|
-
].join("\n");
|
|
2877
|
-
}
|
|
2878
|
-
async function runCredentialsCreate(name, options) {
|
|
2879
|
-
const { server, platformToken } = requireZoonServer();
|
|
2880
|
-
let roleNames = options.role;
|
|
2881
|
-
if (!roleNames || roleNames.length === 0) {
|
|
2882
|
-
const wf = loadWorkforce();
|
|
2883
|
-
if (wf.roles[name]) {
|
|
2884
|
-
roleNames = [name];
|
|
2885
|
-
} else {
|
|
2886
|
-
throw new Error(
|
|
2887
|
-
`No roles specified and no matching agent/role "${name}" found in workforce.json`
|
|
2888
|
-
);
|
|
2889
|
-
}
|
|
2890
|
-
}
|
|
2891
|
-
const result = await createCredential(server, platformToken, name, roleNames);
|
|
2892
|
-
process.stderr.write(
|
|
2893
|
-
`
|
|
2894
|
-
Created credential "${name}" on ${server} (roles: ${roleNames.join(", ")})
|
|
2895
|
-
|
|
2896
|
-
`
|
|
2897
|
-
);
|
|
2898
|
-
return formatEnv(server, result.client_id, result.client_secret);
|
|
2899
|
-
}
|
|
2900
|
-
async function runCredentialsList() {
|
|
2901
|
-
const { server, platformToken } = requireZoonServer();
|
|
2902
|
-
return listCredentials(server, platformToken);
|
|
2903
|
-
}
|
|
2904
|
-
async function resolveClientId(nameOrId) {
|
|
2905
|
-
const { server, platformToken } = requireZoonServer();
|
|
2906
|
-
const creds = await listCredentials(server, platformToken);
|
|
2907
|
-
const match = creds.find((c) => c.name === nameOrId) || creds.find((c) => c.client_id === nameOrId);
|
|
2908
|
-
if (!match) {
|
|
2909
|
-
throw new Error(
|
|
2910
|
-
`Credential "${nameOrId}" not found. Run: npx zooid credentials list`
|
|
2911
|
-
);
|
|
2912
|
-
}
|
|
2913
|
-
return { clientId: match.client_id, name: match.name };
|
|
2914
|
-
}
|
|
2915
|
-
async function runCredentialsRotate(nameOrId) {
|
|
2916
|
-
const { server, platformToken } = requireZoonServer();
|
|
2917
|
-
const { clientId, name } = await resolveClientId(nameOrId);
|
|
2918
|
-
const result = await rotateCredential(server, platformToken, clientId);
|
|
2919
|
-
process.stderr.write(`
|
|
2920
|
-
Rotated credential "${name}" on ${server}
|
|
2921
|
-
|
|
2922
|
-
`);
|
|
2923
|
-
return formatEnv(server, result.client_id, result.client_secret);
|
|
2924
|
-
}
|
|
2925
|
-
async function runCredentialsRevoke(nameOrId) {
|
|
2926
|
-
const { server, platformToken } = requireZoonServer();
|
|
2927
|
-
const { clientId, name } = await resolveClientId(nameOrId);
|
|
2928
|
-
await revokeCredential(server, platformToken, clientId);
|
|
2929
|
-
process.stderr.write(`
|
|
2930
|
-
Revoked credential "${name}" on ${server}
|
|
2931
|
-
`);
|
|
2932
|
-
}
|
|
2933
|
-
|
|
2934
|
-
// src/lib/auto-refresh.ts
|
|
2935
|
-
function decodeJwtPayload(jwt) {
|
|
2936
|
-
try {
|
|
2937
|
-
const parts = jwt.split(".");
|
|
2938
|
-
if (parts.length !== 3) return null;
|
|
2939
|
-
const payload = atob(parts[1]);
|
|
2940
|
-
return JSON.parse(payload);
|
|
2941
|
-
} catch {
|
|
2942
|
-
return null;
|
|
2943
|
-
}
|
|
2944
|
-
}
|
|
2945
|
-
async function maybeRefreshToken(serverConfig, _serverUrl, _options) {
|
|
2946
|
-
if (serverConfig.auth_method !== "oidc") return;
|
|
2947
|
-
if (!serverConfig.admin_token) return;
|
|
2948
|
-
const payload = decodeJwtPayload(serverConfig.admin_token);
|
|
2949
|
-
if (!payload?.exp) return;
|
|
2950
|
-
const expiresAt = payload.exp * 1e3;
|
|
2951
|
-
const twoMinutes = 2 * 60 * 1e3;
|
|
2952
|
-
if (Date.now() < expiresAt - twoMinutes) return;
|
|
2953
|
-
process.stderr.write(
|
|
2954
|
-
"Session expired. Run `npx zooid login` to re-authenticate.\n"
|
|
2955
|
-
);
|
|
2956
|
-
}
|
|
2957
|
-
|
|
2958
|
-
// src/commands/role.ts
|
|
2959
|
-
function runRoleCreate(id, options) {
|
|
2960
|
-
const wf = loadWorkforce();
|
|
2961
|
-
if (id in wf.roles) {
|
|
2962
|
-
throw new Error(
|
|
2963
|
-
`Role "${id}" already exists. Use "zooid role update" to modify it.`
|
|
2964
|
-
);
|
|
2965
|
-
}
|
|
2966
|
-
const def = { scopes: options.scopes };
|
|
2967
|
-
if (options.name) def.name = options.name;
|
|
2968
|
-
if (options.description) def.description = options.description;
|
|
2969
|
-
wf.roles[id] = def;
|
|
2970
|
-
saveWorkforce(wf);
|
|
2971
|
-
}
|
|
2972
|
-
function runRoleList() {
|
|
2973
|
-
const wf = loadWorkforce();
|
|
2974
|
-
return Object.keys(wf.roles);
|
|
2975
|
-
}
|
|
2976
|
-
function runRoleUpdate(id, fields) {
|
|
2977
|
-
const wf = loadWorkforce();
|
|
2978
|
-
const existing = wf.roles[id];
|
|
2979
|
-
if (!existing) {
|
|
2980
|
-
throw new Error(`Role "${id}" not found. Use "zooid role create" first.`);
|
|
2981
|
-
}
|
|
2982
|
-
if (fields.name !== void 0) {
|
|
2983
|
-
if (fields.name === null) {
|
|
2984
|
-
delete existing.name;
|
|
2985
|
-
} else {
|
|
2986
|
-
existing.name = fields.name;
|
|
2987
|
-
}
|
|
2988
|
-
}
|
|
2989
|
-
if (fields.description !== void 0) {
|
|
2990
|
-
if (fields.description === null) {
|
|
2991
|
-
delete existing.description;
|
|
2992
|
-
} else {
|
|
2993
|
-
existing.description = fields.description;
|
|
2994
|
-
}
|
|
2995
|
-
}
|
|
2996
|
-
if (fields.scopes !== void 0) {
|
|
2997
|
-
existing.scopes = fields.scopes;
|
|
2998
|
-
}
|
|
2999
|
-
const targetFile = wf.provenance.roles[id];
|
|
3000
|
-
if (targetFile) {
|
|
3001
|
-
updateInFile(targetFile, "roles", id, existing);
|
|
3002
|
-
} else {
|
|
3003
|
-
wf.roles[id] = existing;
|
|
3004
|
-
saveWorkforce(wf);
|
|
3005
|
-
}
|
|
3006
|
-
return existing;
|
|
3007
|
-
}
|
|
3008
|
-
function runRoleDelete(id) {
|
|
3009
|
-
const wf = loadWorkforce();
|
|
3010
|
-
if (!(id in wf.roles)) {
|
|
3011
|
-
throw new Error(`Role "${id}" not found in .zooid/workforce.json`);
|
|
3012
|
-
}
|
|
3013
|
-
const targetFile = wf.provenance.roles[id];
|
|
3014
|
-
if (targetFile) {
|
|
3015
|
-
removeFromFile(targetFile, "roles", id);
|
|
3016
|
-
} else {
|
|
3017
|
-
delete wf.roles[id];
|
|
3018
|
-
saveWorkforce(wf);
|
|
3019
|
-
}
|
|
3020
|
-
}
|
|
3021
|
-
|
|
3022
|
-
// src/index.ts
|
|
3023
|
-
async function resolveAndRecord(channel, opts) {
|
|
3024
|
-
const result = resolveChannel(channel, {
|
|
3025
|
-
token: opts.token,
|
|
3026
|
-
tokenType: "subscribe"
|
|
3027
|
-
});
|
|
3028
|
-
setTelemetryChannel(result.channelId);
|
|
3029
|
-
if (result.tokenSaved) {
|
|
3030
|
-
printInfo(
|
|
3031
|
-
"Token saved",
|
|
3032
|
-
`for ${result.channelId} \u2014 won't need --token next time`
|
|
3033
|
-
);
|
|
3034
|
-
}
|
|
3035
|
-
try {
|
|
3036
|
-
const channels = await result.client.listChannels();
|
|
3037
|
-
const ch = channels.find((c) => c.id === result.channelId);
|
|
3038
|
-
recordTailHistory(result.channelId, result.server, ch?.name);
|
|
3039
|
-
} catch {
|
|
3040
|
-
recordTailHistory(result.channelId, result.server);
|
|
3041
|
-
}
|
|
3042
|
-
return result;
|
|
3043
|
-
}
|
|
3044
|
-
var program = new Command();
|
|
3045
|
-
program.name("zooid").description("\u{1FAB8} Pub/sub for AI agents").version("0.6.1");
|
|
3046
|
-
var telemetryCtx = { startTime: 0 };
|
|
3047
|
-
function setTelemetryChannel(channelId) {
|
|
3048
|
-
telemetryCtx.channelId = channelId;
|
|
3049
|
-
const config = loadConfig();
|
|
3050
|
-
const channelTokens = config.channels?.[channelId];
|
|
3051
|
-
const hasChannelToken = !!(channelTokens?.token || channelTokens?.publish_token || channelTokens?.subscribe_token);
|
|
3052
|
-
telemetryCtx.usedToken = hasChannelToken || !!config.admin_token;
|
|
3053
|
-
}
|
|
3054
|
-
function getCommandPath(cmd) {
|
|
3055
|
-
const parts = [];
|
|
3056
|
-
let current = cmd;
|
|
3057
|
-
while (current && current !== program) {
|
|
3058
|
-
parts.unshift(current.name());
|
|
3059
|
-
current = current.parent;
|
|
3060
|
-
}
|
|
3061
|
-
return parts.join(" ");
|
|
3062
|
-
}
|
|
3063
|
-
function handleError(commandName, err) {
|
|
3064
|
-
const message = err instanceof Error ? err.message : String(err);
|
|
3065
|
-
sendTelemetry(commandName, 1, message);
|
|
3066
|
-
printError(message);
|
|
3067
|
-
process.exit(1);
|
|
3068
|
-
}
|
|
3069
|
-
program.hook("preAction", async () => {
|
|
3070
|
-
telemetryCtx.startTime = Date.now();
|
|
3071
|
-
if (isEnabled()) {
|
|
3072
|
-
showNoticeIfNeeded();
|
|
3073
|
-
}
|
|
3074
|
-
try {
|
|
3075
|
-
const file = loadConfigFile();
|
|
3076
|
-
const server = resolveServer();
|
|
3077
|
-
if (server && file.servers?.[server]) {
|
|
3078
|
-
const entry = file.servers[server];
|
|
3079
|
-
await maybeRefreshToken(entry, server, {
|
|
3080
|
-
save: (partial) => saveConfig(partial, server)
|
|
3081
|
-
});
|
|
3082
|
-
}
|
|
3083
|
-
} catch {
|
|
3084
|
-
}
|
|
3085
|
-
});
|
|
3086
|
-
function sendTelemetry(commandName, exitCode, error) {
|
|
3087
|
-
if (!isEnabled()) return;
|
|
3088
|
-
try {
|
|
3089
|
-
const event = {
|
|
3090
|
-
install_id: getInstallId(),
|
|
3091
|
-
command: commandName,
|
|
3092
|
-
exit_code: exitCode,
|
|
3093
|
-
duration_ms: Date.now() - telemetryCtx.startTime,
|
|
3094
|
-
cli_version: program.version() ?? "0.0.0",
|
|
3095
|
-
os: process.platform,
|
|
3096
|
-
arch: process.arch,
|
|
3097
|
-
node_version: process.version,
|
|
3098
|
-
ts: (/* @__PURE__ */ new Date()).toISOString()
|
|
3099
|
-
};
|
|
3100
|
-
if (error) event.error = error;
|
|
3101
|
-
if (telemetryCtx.channelId && !telemetryCtx.usedToken) {
|
|
3102
|
-
event.channel_id = telemetryCtx.channelId;
|
|
3103
|
-
const config = loadConfig();
|
|
3104
|
-
if (config.server) event.server_url = config.server;
|
|
3105
|
-
}
|
|
3106
|
-
writeEvent(event);
|
|
3107
|
-
flushInBackground();
|
|
3108
|
-
} catch {
|
|
3109
|
-
}
|
|
3110
|
-
telemetryCtx.channelId = void 0;
|
|
3111
|
-
telemetryCtx.usedToken = void 0;
|
|
3112
|
-
}
|
|
3113
|
-
program.hook("postAction", (_thisCommand, actionCommand) => {
|
|
3114
|
-
sendTelemetry(getCommandPath(actionCommand), 0);
|
|
3115
|
-
});
|
|
3116
|
-
program.command("dev").description("Start local development server").option("--port <port>", "Server port", "8787").action(async (opts) => {
|
|
3117
|
-
try {
|
|
3118
|
-
await runDev(parseInt(opts.port, 10));
|
|
3119
|
-
} catch (err) {
|
|
3120
|
-
handleError("dev", err);
|
|
3121
|
-
}
|
|
3122
|
-
});
|
|
3123
|
-
program.command("init").description("Create zooid.json with server identity").option("--use <url>", "Include a template from a GitHub URL").action(async (opts) => {
|
|
3124
|
-
try {
|
|
3125
|
-
await runInit({ use: opts.use });
|
|
3126
|
-
} catch (err) {
|
|
3127
|
-
handleError("init", err);
|
|
3128
|
-
}
|
|
3129
|
-
});
|
|
3130
|
-
program.command("use <url>").description("Add a template to your workforce via include").action(async (url) => {
|
|
3131
|
-
try {
|
|
3132
|
-
await runUse(url);
|
|
3133
|
-
} catch (err) {
|
|
3134
|
-
handleError("use", err);
|
|
3135
|
-
}
|
|
3136
|
-
});
|
|
3137
|
-
program.command("deploy").description("Deploy Zooid server to Cloudflare Workers").option("--prune", "Delete server resources not in workforce.json").action(async (opts) => {
|
|
3138
|
-
try {
|
|
3139
|
-
await runDeploy(opts);
|
|
3140
|
-
} catch (err) {
|
|
3141
|
-
handleError("deploy", err);
|
|
3142
|
-
}
|
|
3143
|
-
});
|
|
3144
|
-
program.command("destroy").description("Destroy a deployed Zooid server and all its data").option("--force", "Skip confirmation prompt").option("--keep-local", "Keep wrangler.toml and state entries").action(async (options) => {
|
|
3145
|
-
try {
|
|
3146
|
-
await runDestroy({
|
|
3147
|
-
force: options.force,
|
|
3148
|
-
keepLocal: options.keepLocal
|
|
3149
|
-
});
|
|
3150
|
-
} catch (err) {
|
|
3151
|
-
handleError("destroy", err);
|
|
3152
|
-
}
|
|
3153
|
-
});
|
|
3154
|
-
program.command("login").argument("[url]", "Server URL (e.g., https://beno.zoon.eco)").description("Authenticate with Zoon or a specific server").action(async (url) => {
|
|
3155
|
-
try {
|
|
3156
|
-
await runLogin(url);
|
|
3157
|
-
} catch (err) {
|
|
3158
|
-
handleError("login", err);
|
|
3159
|
-
}
|
|
3160
|
-
});
|
|
3161
|
-
program.command("logout").option("--all", "Log out of all servers").description("Clear authentication for the current server").action(async (opts) => {
|
|
3162
|
-
try {
|
|
3163
|
-
await runLogout(opts);
|
|
3164
|
-
} catch (err) {
|
|
3165
|
-
handleError("logout", err);
|
|
3166
|
-
}
|
|
3167
|
-
});
|
|
3168
|
-
program.command("whoami").description("Show current identity and auth status").action(async () => {
|
|
3169
|
-
try {
|
|
3170
|
-
const result = await runWhoami();
|
|
3171
|
-
console.log(`Server: ${result.server}`);
|
|
3172
|
-
console.log(`User: ${result.name || result.sub}`);
|
|
3173
|
-
console.log(`Scopes: ${result.scopes.join(", ")}`);
|
|
3174
|
-
if (result.authMethod) {
|
|
3175
|
-
const expStr = result.exp ? ` (expires ${new Date(result.exp * 1e3).toISOString()})` : "";
|
|
3176
|
-
console.log(`Auth: ${result.authMethod}${expStr}`);
|
|
3177
|
-
}
|
|
3178
|
-
} catch (err) {
|
|
3179
|
-
handleError("whoami", err);
|
|
3180
|
-
}
|
|
3181
|
-
});
|
|
3182
|
-
var configCmd = program.command("config").description("Manage Zooid configuration");
|
|
3183
|
-
configCmd.command("set <key> <value>").description("Set a config value (server, admin-token, telemetry)").action((key, value) => {
|
|
3184
|
-
try {
|
|
3185
|
-
runConfigSet(key, value);
|
|
3186
|
-
printSuccess(`Set ${key}`);
|
|
3187
|
-
} catch (err) {
|
|
3188
|
-
handleError("config set", err);
|
|
3189
|
-
}
|
|
3190
|
-
});
|
|
3191
|
-
configCmd.command("get <key>").description("Get a config value").action((key) => {
|
|
3192
|
-
try {
|
|
3193
|
-
const value = runConfigGet(key);
|
|
3194
|
-
if (value) {
|
|
3195
|
-
console.log(value);
|
|
3196
|
-
} else {
|
|
3197
|
-
console.log("(not set)");
|
|
3198
|
-
}
|
|
3199
|
-
} catch (err) {
|
|
3200
|
-
handleError("config get", err);
|
|
3201
|
-
}
|
|
3202
|
-
});
|
|
3203
|
-
var channelCmd = program.command("channel").description("Manage channels");
|
|
3204
|
-
channelCmd.command("create <id>").description("Create a new channel").option("--name <name>", "Display name (defaults to id)").option("--description <desc>", "Channel description").option("--public", "Make channel public").option("--private", "Make channel private (default)", true).option("--strict", "Enable strict schema validation on publish").option(
|
|
3205
|
-
"--config <file>",
|
|
3206
|
-
"Path to channel config JSON file (types, policies, storage)"
|
|
3207
|
-
).option(
|
|
3208
|
-
"--schema <file>",
|
|
3209
|
-
"Path to JSON schema file (map of event types to JSON schemas)"
|
|
3210
|
-
).action(async (id, opts) => {
|
|
3211
|
-
try {
|
|
3212
|
-
let config;
|
|
3213
|
-
if (opts.config) {
|
|
3214
|
-
const fs13 = await import("fs");
|
|
3215
|
-
const raw = fs13.readFileSync(opts.config, "utf-8");
|
|
3216
|
-
config = JSON.parse(raw);
|
|
3217
|
-
} else if (opts.schema) {
|
|
3218
|
-
const fs13 = await import("fs");
|
|
3219
|
-
const raw = fs13.readFileSync(opts.schema, "utf-8");
|
|
3220
|
-
const parsed = JSON.parse(raw);
|
|
3221
|
-
const types = {};
|
|
3222
|
-
for (const [eventType, schemaDef] of Object.entries(parsed)) {
|
|
3223
|
-
types[eventType] = { schema: schemaDef };
|
|
3224
|
-
}
|
|
3225
|
-
config = { types };
|
|
3226
|
-
}
|
|
3227
|
-
const result = await runChannelCreate(id, {
|
|
3228
|
-
name: opts.name,
|
|
3229
|
-
description: opts.description,
|
|
3230
|
-
public: opts.public ? true : false,
|
|
3231
|
-
strict: opts.strict,
|
|
3232
|
-
config
|
|
3233
|
-
});
|
|
3234
|
-
printSuccess(`Created channel: ${id}`);
|
|
3235
|
-
printInfo("Token", result.token);
|
|
3236
|
-
} catch (err) {
|
|
3237
|
-
handleError("channel create", err);
|
|
3238
|
-
}
|
|
3239
|
-
});
|
|
3240
|
-
channelCmd.command("update <id>").description("Update a channel").option("--name <name>", "Display name").option("--description <desc>", "Channel description").option("--tags <tags>", "Comma-separated tags").option("--public", "Make channel public").option("--private", "Make channel private").option("--strict", "Enable strict schema validation on publish").option("--no-strict", "Disable strict schema validation").option(
|
|
3241
|
-
"--config <file>",
|
|
3242
|
-
"Path to channel config JSON file (types, policies, storage)"
|
|
3243
|
-
).option(
|
|
3244
|
-
"--schema <file>",
|
|
3245
|
-
"Path to JSON schema file (map of event types to JSON schemas)"
|
|
3246
|
-
).action(async (id, opts) => {
|
|
3247
|
-
try {
|
|
3248
|
-
const fields = {};
|
|
3249
|
-
if (opts.name !== void 0) fields.name = opts.name;
|
|
3250
|
-
if (opts.description !== void 0) fields.description = opts.description;
|
|
3251
|
-
if (opts.tags !== void 0)
|
|
3252
|
-
fields.tags = opts.tags.split(",").map((t) => t.trim());
|
|
3253
|
-
if (opts.public) fields.is_public = true;
|
|
3254
|
-
if (opts.private) fields.is_public = false;
|
|
3255
|
-
if (opts.config) {
|
|
3256
|
-
const fs13 = await import("fs");
|
|
3257
|
-
const raw = fs13.readFileSync(opts.config, "utf-8");
|
|
3258
|
-
fields.config = JSON.parse(raw);
|
|
3259
|
-
} else if (opts.schema) {
|
|
3260
|
-
const fs13 = await import("fs");
|
|
3261
|
-
const raw = fs13.readFileSync(opts.schema, "utf-8");
|
|
3262
|
-
const parsed = JSON.parse(raw);
|
|
3263
|
-
const types = {};
|
|
3264
|
-
for (const [eventType, schemaDef] of Object.entries(parsed)) {
|
|
3265
|
-
types[eventType] = { schema: schemaDef };
|
|
3266
|
-
}
|
|
3267
|
-
fields.config = { types };
|
|
3268
|
-
}
|
|
3269
|
-
if (opts.strict !== void 0) {
|
|
3270
|
-
const existing = fields.config ?? {};
|
|
3271
|
-
fields.config = { ...existing, strict_types: opts.strict };
|
|
3272
|
-
}
|
|
3273
|
-
if (Object.keys(fields).length === 0) {
|
|
3274
|
-
throw new Error(
|
|
3275
|
-
"No fields specified. Use --name, --description, --tags, --public, --private, --config, --schema, or --strict."
|
|
3276
|
-
);
|
|
3277
|
-
}
|
|
3278
|
-
const channel = await runChannelUpdate(id, fields);
|
|
3279
|
-
printSuccess(`Updated channel: ${id}`);
|
|
3280
|
-
printInfo("Name", channel.name);
|
|
3281
|
-
} catch (err) {
|
|
3282
|
-
handleError("channel update", err);
|
|
3283
|
-
}
|
|
3284
|
-
});
|
|
3285
|
-
channelCmd.command("list").description("List all channels").action(async () => {
|
|
3286
|
-
try {
|
|
3287
|
-
const channels = await runChannelList();
|
|
3288
|
-
if (channels.length === 0) {
|
|
3289
|
-
console.log(
|
|
3290
|
-
"No channels yet. Create one with: npx zooid channel create <name>"
|
|
3291
|
-
);
|
|
3292
|
-
} else {
|
|
3293
|
-
for (const ch of channels) {
|
|
3294
|
-
const visibility = ch.is_public ? "public" : "private";
|
|
3295
|
-
console.log(
|
|
3296
|
-
` ${ch.id} \u2014 ${ch.name} (${visibility}, ${ch.event_count} events)`
|
|
3297
|
-
);
|
|
3298
|
-
}
|
|
3299
|
-
}
|
|
3300
|
-
} catch (err) {
|
|
3301
|
-
handleError("channel list", err);
|
|
3302
|
-
}
|
|
3303
|
-
});
|
|
3304
|
-
channelCmd.command("delete <id>").description("Delete a channel and all its data").option("-y, --yes", "Skip confirmation prompt").action(async (id, opts) => {
|
|
3305
|
-
try {
|
|
3306
|
-
if (!opts.yes) {
|
|
3307
|
-
const readline7 = await import("readline");
|
|
3308
|
-
const rl = readline7.createInterface({
|
|
3309
|
-
input: process.stdin,
|
|
3310
|
-
output: process.stdout
|
|
3311
|
-
});
|
|
3312
|
-
const answer = await new Promise((resolve) => {
|
|
3313
|
-
rl.question(
|
|
3314
|
-
`Delete channel "${id}" and all its events and webhooks? [y/N] `,
|
|
3315
|
-
resolve
|
|
3316
|
-
);
|
|
3317
|
-
});
|
|
3318
|
-
rl.close();
|
|
3319
|
-
if (answer.toLowerCase() !== "y") {
|
|
3320
|
-
console.log("Aborted.");
|
|
3321
|
-
return;
|
|
3322
|
-
}
|
|
3323
|
-
}
|
|
3324
|
-
await runChannelDelete(id);
|
|
3325
|
-
printSuccess(`Deleted channel: ${id}`);
|
|
3326
|
-
} catch (err) {
|
|
3327
|
-
handleError("channel delete", err);
|
|
3328
|
-
}
|
|
3329
|
-
});
|
|
3330
|
-
program.command("pull").description(
|
|
3331
|
-
"Pull channel and role definitions from server into workforce.json"
|
|
3332
|
-
).action(async () => {
|
|
3333
|
-
try {
|
|
3334
|
-
await runPull();
|
|
3335
|
-
} catch (err) {
|
|
3336
|
-
handleError("pull", err);
|
|
3337
|
-
}
|
|
3338
|
-
});
|
|
3339
|
-
program.command("publish <channel> [data]").description(
|
|
3340
|
-
"Publish an event to a channel (accepts JSON arg, --data, --file, or stdin)"
|
|
3341
|
-
).option("--type <type>", "Event type").option("--data <json>", "Event data as JSON string").option("--file <path>", "Read event from JSON file").option(
|
|
3342
|
-
"--stream",
|
|
3343
|
-
"Stream mode: read newline-delimited JSON from stdin, publish each line"
|
|
3344
|
-
).option("--token <token>", "Auth token (for remote/private channels)").action(async (channel, dataArg, opts) => {
|
|
3345
|
-
try {
|
|
3346
|
-
const { client, channelId, tokenSaved } = resolveChannel(channel, {
|
|
3347
|
-
token: opts.token,
|
|
3348
|
-
tokenType: "publish"
|
|
3349
|
-
});
|
|
3350
|
-
setTelemetryChannel(channelId);
|
|
3351
|
-
if (tokenSaved) {
|
|
3352
|
-
printInfo(
|
|
3353
|
-
"Token saved",
|
|
3354
|
-
`for ${channelId} \u2014 won't need --token next time`
|
|
3355
|
-
);
|
|
3356
|
-
}
|
|
3357
|
-
if (opts.stream) {
|
|
3358
|
-
const { published, errors } = await runPublishStream(
|
|
3359
|
-
channelId,
|
|
3360
|
-
opts,
|
|
3361
|
-
client,
|
|
3362
|
-
(event) => printSuccess(`Published event: ${event.id}`)
|
|
3363
|
-
);
|
|
3364
|
-
const summary = `${published} published`;
|
|
3365
|
-
printSuccess(errors ? `${summary}, ${errors} failed` : summary);
|
|
3366
|
-
} else {
|
|
3367
|
-
const event = await runPublish(channelId, opts, client, dataArg);
|
|
3368
|
-
printSuccess(`Published event: ${event.id}`);
|
|
3369
|
-
}
|
|
3370
|
-
} catch (err) {
|
|
3371
|
-
handleError("publish", err);
|
|
3372
|
-
}
|
|
3373
|
-
});
|
|
3374
|
-
program.command("delete-event <channel> <event-id>").description("Delete a single event by ID").option("-y, --yes", "Skip confirmation prompt").action(async (channel, eventId, opts) => {
|
|
3375
|
-
try {
|
|
3376
|
-
const { client, channelId } = resolveChannel(channel, {
|
|
3377
|
-
tokenType: "publish"
|
|
3378
|
-
});
|
|
3379
|
-
if (!opts.yes) {
|
|
3380
|
-
const readline7 = await import("readline");
|
|
3381
|
-
const rl = readline7.createInterface({
|
|
3382
|
-
input: process.stdin,
|
|
3383
|
-
output: process.stdout
|
|
3384
|
-
});
|
|
3385
|
-
const answer = await new Promise((resolve) => {
|
|
3386
|
-
rl.question(
|
|
3387
|
-
`Delete event "${eventId}" from channel "${channelId}"? [y/N] `,
|
|
3388
|
-
resolve
|
|
3389
|
-
);
|
|
3390
|
-
});
|
|
3391
|
-
rl.close();
|
|
3392
|
-
if (answer.toLowerCase() !== "y") {
|
|
3393
|
-
console.log("Aborted.");
|
|
3394
|
-
return;
|
|
3395
|
-
}
|
|
3396
|
-
}
|
|
3397
|
-
await client.deleteEvent(channelId, eventId);
|
|
3398
|
-
printSuccess(`Deleted event: ${eventId}`);
|
|
3399
|
-
} catch (err) {
|
|
3400
|
-
handleError("delete-event", err);
|
|
3401
|
-
}
|
|
3402
|
-
});
|
|
3403
|
-
program.command("tail <channel>").description("Fetch latest events, or stream live with -f").option("-n, --limit <n>", "Max events to return", "50").option("-f, --follow", "Follow mode \u2014 stream new events as they arrive").option("--type <type>", "Filter events by type").option("--since <iso>", "Only events after this ISO 8601 timestamp").option("--cursor <cursor>", "Resume from a previous cursor").option(
|
|
3404
|
-
"--mode <mode>",
|
|
3405
|
-
"Transport mode for follow: auto, ws, or poll",
|
|
3406
|
-
"auto"
|
|
3407
|
-
).option("--interval <ms>", "Poll interval in ms for follow mode", "5000").option("--unseen", "Only show events since your last tail").option("--token <token>", "Auth token (for remote/private channels)").action(async (channel, opts) => {
|
|
3408
|
-
try {
|
|
3409
|
-
let unseenCursor;
|
|
3410
|
-
let unseenSince;
|
|
3411
|
-
if (opts.unseen) {
|
|
3412
|
-
const file = loadConfigFile();
|
|
3413
|
-
const { parseChannelUrl } = await import("./client-4VMFEFDX.js");
|
|
3414
|
-
const { resolveServer: resolveServer2 } = await import("./config-2KK5GX42.js");
|
|
3415
|
-
const parsed = parseChannelUrl(channel);
|
|
3416
|
-
const channelId2 = parsed?.channelId ?? channel;
|
|
3417
|
-
const serverUrl = parsed?.server ?? resolveServer2();
|
|
3418
|
-
const stats = serverUrl ? file.servers?.[serverUrl]?.channels?.[channelId2]?.stats : void 0;
|
|
3419
|
-
if (!stats) {
|
|
3420
|
-
throw new Error(
|
|
3421
|
-
`No tail history for ${channelId2} \u2014 tail it at least once first`
|
|
3422
|
-
);
|
|
3423
|
-
}
|
|
3424
|
-
if (stats.last_event_id) {
|
|
3425
|
-
unseenCursor = stats.last_event_id;
|
|
3426
|
-
} else if (stats.last_tailed_at) {
|
|
3427
|
-
unseenSince = stats.last_tailed_at;
|
|
3428
|
-
}
|
|
3429
|
-
}
|
|
3430
|
-
const { client, channelId, server } = await resolveAndRecord(
|
|
3431
|
-
channel,
|
|
3432
|
-
opts
|
|
3433
|
-
);
|
|
3434
|
-
if (opts.unseen) {
|
|
3435
|
-
if (unseenCursor) {
|
|
3436
|
-
opts.cursor = unseenCursor;
|
|
3437
|
-
} else if (unseenSince) {
|
|
3438
|
-
opts.since = unseenSince;
|
|
3439
|
-
}
|
|
3440
|
-
}
|
|
3441
|
-
if (opts.follow) {
|
|
3442
|
-
const mode = opts.mode;
|
|
3443
|
-
const transport = mode === "auto" ? "auto (WebSocket \u2192 poll fallback)" : mode;
|
|
3444
|
-
console.log(
|
|
3445
|
-
`Tailing ${channelId} [${transport}]${opts.type ? ` type=${opts.type}` : ""}...`
|
|
3446
|
-
);
|
|
3447
|
-
console.log("Press Ctrl+C to stop.\n");
|
|
3448
|
-
await runTail(
|
|
3449
|
-
channelId,
|
|
3450
|
-
{
|
|
3451
|
-
follow: true,
|
|
3452
|
-
mode,
|
|
3453
|
-
interval: parseInt(opts.interval, 10),
|
|
3454
|
-
type: opts.type
|
|
3455
|
-
},
|
|
3456
|
-
client
|
|
3457
|
-
);
|
|
3458
|
-
} else {
|
|
3459
|
-
const result = await runTail(
|
|
3460
|
-
channelId,
|
|
3461
|
-
{
|
|
3462
|
-
limit: parseInt(opts.limit, 10),
|
|
3463
|
-
type: opts.type,
|
|
3464
|
-
since: opts.since,
|
|
3465
|
-
cursor: opts.cursor
|
|
3466
|
-
},
|
|
3467
|
-
client
|
|
3468
|
-
);
|
|
3469
|
-
if (result.events.length > 0) {
|
|
3470
|
-
const lastEvent = result.events[result.events.length - 1];
|
|
3471
|
-
recordTailHistory(channelId, server, void 0, lastEvent.id);
|
|
3472
|
-
}
|
|
3473
|
-
if (result.events.length === 0) {
|
|
3474
|
-
console.log("No events.");
|
|
3475
|
-
} else {
|
|
3476
|
-
for (const event of result.events) {
|
|
3477
|
-
console.log(JSON.stringify(event));
|
|
3478
|
-
}
|
|
3479
|
-
}
|
|
3480
|
-
if (result.cursor) {
|
|
3481
|
-
printInfo("Cursor", result.cursor);
|
|
3482
|
-
}
|
|
3483
|
-
}
|
|
3484
|
-
} catch (err) {
|
|
3485
|
-
handleError("tail", err);
|
|
3486
|
-
}
|
|
3487
|
-
});
|
|
3488
|
-
program.command("subscribe <channel>").description("Subscribe to a channel").option("--webhook <url>", "Register a webhook instead of polling").option("--interval <ms>", "Poll interval in milliseconds", "5000").option("--mode <mode>", "Transport mode: auto, ws, or poll", "auto").option("--type <type>", "Filter events by type").option("--token <token>", "Auth token (for remote/private channels)").action(async (channel, opts) => {
|
|
3489
|
-
try {
|
|
3490
|
-
const { client, channelId } = await resolveAndRecord(channel, opts);
|
|
3491
|
-
if (opts.webhook) {
|
|
3492
|
-
const wh = await runSubscribeWebhook(channelId, opts.webhook, client);
|
|
3493
|
-
printSuccess(`Registered webhook: ${wh.id}`);
|
|
3494
|
-
printInfo("URL", wh.url);
|
|
3495
|
-
printInfo("Expires", wh.expires_at);
|
|
3496
|
-
} else {
|
|
3497
|
-
const mode = opts.mode;
|
|
3498
|
-
const transport = mode === "auto" ? "auto (WebSocket \u2192 poll fallback)" : mode;
|
|
3499
|
-
console.log(
|
|
3500
|
-
`Subscribing to ${channelId} [${transport}]${opts.type ? ` type=${opts.type}` : ""}...`
|
|
3501
|
-
);
|
|
3502
|
-
console.log("Press Ctrl+C to stop.\n");
|
|
3503
|
-
await runSubscribePoll(
|
|
3504
|
-
channelId,
|
|
3505
|
-
{
|
|
3506
|
-
interval: parseInt(opts.interval, 10),
|
|
3507
|
-
mode,
|
|
3508
|
-
type: opts.type
|
|
3509
|
-
},
|
|
3510
|
-
client
|
|
3511
|
-
);
|
|
3512
|
-
await new Promise(() => {
|
|
3513
|
-
});
|
|
3514
|
-
}
|
|
3515
|
-
} catch (err) {
|
|
3516
|
-
handleError("subscribe", err);
|
|
3517
|
-
}
|
|
3518
|
-
});
|
|
3519
|
-
var serverCmd = program.command("server").description("Manage server metadata");
|
|
3520
|
-
serverCmd.command("get").description("Show server metadata").action(async () => {
|
|
3521
|
-
try {
|
|
3522
|
-
const meta = await runServerGet();
|
|
3523
|
-
console.log(`
|
|
3524
|
-
${meta.name}
|
|
3525
|
-
`);
|
|
3526
|
-
if (meta.description) printInfo("Description", meta.description);
|
|
3527
|
-
if (meta.tags.length > 0) printInfo("Tags", meta.tags.join(", "));
|
|
3528
|
-
if (meta.owner) printInfo("Owner", meta.owner);
|
|
3529
|
-
if (meta.company) printInfo("Company", meta.company);
|
|
3530
|
-
if (meta.email) printInfo("Email", meta.email);
|
|
3531
|
-
printInfo("Updated", meta.updated_at);
|
|
3532
|
-
console.log("");
|
|
3533
|
-
} catch (err) {
|
|
3534
|
-
handleError("server get", err);
|
|
3535
|
-
}
|
|
3536
|
-
});
|
|
3537
|
-
serverCmd.command("set").description("Update server metadata").option("--name <name>", "Server name").option("--description <desc>", "Server description").option("--tags <tags>", "Comma-separated tags").option("--owner <owner>", "Server owner").option("--company <company>", "Company name").option("--email <email>", "Contact email").action(async (opts) => {
|
|
3538
|
-
try {
|
|
3539
|
-
const fields = {};
|
|
3540
|
-
if (opts.name !== void 0) fields.name = opts.name;
|
|
3541
|
-
if (opts.description !== void 0) fields.description = opts.description;
|
|
3542
|
-
if (opts.tags !== void 0)
|
|
3543
|
-
fields.tags = opts.tags.split(",").map((t) => t.trim());
|
|
3544
|
-
if (opts.owner !== void 0) fields.owner = opts.owner;
|
|
3545
|
-
if (opts.company !== void 0) fields.company = opts.company;
|
|
3546
|
-
if (opts.email !== void 0) fields.email = opts.email;
|
|
3547
|
-
if (Object.keys(fields).length === 0) {
|
|
3548
|
-
throw new Error(
|
|
3549
|
-
"No fields specified. Use --name, --description, --tags, --owner, --company, or --email."
|
|
3550
|
-
);
|
|
3551
|
-
}
|
|
3552
|
-
const meta = await runServerSet(fields);
|
|
3553
|
-
printSuccess(`Updated server metadata`);
|
|
3554
|
-
printInfo("Name", meta.name);
|
|
3555
|
-
console.log("");
|
|
3556
|
-
} catch (err) {
|
|
3557
|
-
handleError("server set", err);
|
|
3558
|
-
}
|
|
3559
|
-
});
|
|
3560
|
-
var tokenCmd = program.command("token").description("Manage tokens");
|
|
3561
|
-
tokenCmd.command("mint").description(
|
|
3562
|
-
"Mint a new token. Scopes: admin, pub:<channel>, sub:<channel>. Wildcards: pub:*, sub:prefix-*"
|
|
3563
|
-
).argument(
|
|
3564
|
-
"[scopes...]",
|
|
3565
|
-
"Scopes to grant (e.g. admin, pub:my-channel, sub:*)"
|
|
3566
|
-
).option(
|
|
3567
|
-
"--role <roles...>",
|
|
3568
|
-
"Mint with scopes from named roles (reads workforce.json)"
|
|
3569
|
-
).option("--sub <sub>", "Subject identifier (e.g. publisher ID)").option("--name <name>", "Display name (used for publisher identity)").option("--expires-in <duration>", "Token expiry (e.g. 5m, 1h, 7d, 30d)").action(async (scopes, opts) => {
|
|
3570
|
-
try {
|
|
3571
|
-
if (!opts.role?.length && (!scopes || scopes.length === 0)) {
|
|
3572
|
-
printError("Provide scopes or --role");
|
|
3573
|
-
process.exit(1);
|
|
3574
|
-
}
|
|
3575
|
-
if (!opts.role?.length) {
|
|
3576
|
-
for (const s of scopes) {
|
|
3577
|
-
if (s !== "admin" && !s.startsWith("pub:") && !s.startsWith("sub:")) {
|
|
3578
|
-
printError(
|
|
3579
|
-
`Invalid scope "${s}". Must be "admin", "pub:<channel>", or "sub:<channel>"`
|
|
3580
|
-
);
|
|
3581
|
-
process.exit(1);
|
|
3582
|
-
}
|
|
3583
|
-
}
|
|
3584
|
-
}
|
|
3585
|
-
const result = await runTokenMint(scopes ?? [], {
|
|
3586
|
-
sub: opts.sub,
|
|
3587
|
-
name: opts.name,
|
|
3588
|
-
expiresIn: opts.expiresIn,
|
|
3589
|
-
role: opts.role
|
|
3590
|
-
});
|
|
3591
|
-
console.log(result.token);
|
|
3592
|
-
} catch (err) {
|
|
3593
|
-
handleError("token", err);
|
|
3594
|
-
}
|
|
3595
|
-
});
|
|
3596
|
-
program.command("status").description("Check server status").action(async () => {
|
|
3597
|
-
try {
|
|
3598
|
-
const { discovery, identity } = await runStatus();
|
|
3599
|
-
console.log(`
|
|
3600
|
-
${identity.name} v${discovery.version}
|
|
3601
|
-
`);
|
|
3602
|
-
printInfo("Server ID", discovery.server_id);
|
|
3603
|
-
printInfo("Algorithm", discovery.algorithm);
|
|
3604
|
-
printInfo("Poll interval", `${discovery.poll_interval}s`);
|
|
3605
|
-
printInfo("Delivery", discovery.delivery.join(", "));
|
|
3606
|
-
console.log("");
|
|
3607
|
-
} catch (err) {
|
|
3608
|
-
handleError("status", err);
|
|
3609
|
-
}
|
|
3610
|
-
});
|
|
3611
|
-
program.command("history").description("Show tail/subscribe history").option("-n, --limit <n>", "Max entries to show", "20").option("--json", "Output as JSON").action((opts) => {
|
|
3612
|
-
try {
|
|
3613
|
-
const entries = runHistory();
|
|
3614
|
-
const limit = parseInt(opts.limit, 10);
|
|
3615
|
-
const shown = entries.slice(0, limit);
|
|
3616
|
-
if (shown.length === 0) {
|
|
3617
|
-
console.log("No tail/subscribe history yet.");
|
|
3618
|
-
return;
|
|
3619
|
-
}
|
|
3620
|
-
if (opts.json) {
|
|
3621
|
-
console.log(JSON.stringify(shown, null, 2));
|
|
3622
|
-
return;
|
|
3623
|
-
}
|
|
3624
|
-
console.log("");
|
|
3625
|
-
for (const entry of shown) {
|
|
3626
|
-
const ago = formatRelative(entry.last_tailed_at);
|
|
3627
|
-
const label = entry.name ?? entry.channel_id;
|
|
3628
|
-
const url = `${entry.server}/${entry.channel_id}`;
|
|
3629
|
-
console.log(` ${label} ${url}`);
|
|
3630
|
-
console.log(
|
|
3631
|
-
` ${entry.num_tails} tail${entry.num_tails === 1 ? "" : "s"}, last ${ago}`
|
|
3632
|
-
);
|
|
3633
|
-
}
|
|
3634
|
-
console.log("");
|
|
3635
|
-
} catch (err) {
|
|
3636
|
-
handleError("history", err);
|
|
3637
|
-
}
|
|
3638
|
-
});
|
|
3639
|
-
program.command("share [channels...]").description("List channels in the Zooid Directory").option("--channel <id>", "Channel to share (alternative to positional args)").option("-y, --yes", "Skip prompts, use server values for description/tags").action(async (channels, opts) => {
|
|
3640
|
-
try {
|
|
3641
|
-
const ids = opts.channel ? [opts.channel, ...channels] : channels;
|
|
3642
|
-
await runShare(ids, { yes: opts.yes });
|
|
3643
|
-
printSuccess("Channels shared to directory");
|
|
3644
|
-
} catch (err) {
|
|
3645
|
-
handleError("share", err);
|
|
3646
|
-
}
|
|
3647
|
-
});
|
|
3648
|
-
program.command("discover").description("Browse public channels in the Zooid Directory").option("-q, --query <text>", "Search by keyword").option("-t, --tag <tag>", "Filter by tag").option("-n, --limit <n>", "Max results", "20").action(async (opts) => {
|
|
3649
|
-
try {
|
|
3650
|
-
await runDiscover({
|
|
3651
|
-
query: opts.query,
|
|
3652
|
-
tag: opts.tag,
|
|
3653
|
-
limit: parseInt(opts.limit, 10)
|
|
3654
|
-
});
|
|
3655
|
-
} catch (err) {
|
|
3656
|
-
handleError("discover", err);
|
|
3657
|
-
}
|
|
3658
|
-
});
|
|
3659
|
-
program.command("unshare <channel>").description("Remove a channel from the Zooid Directory").action(async (channel) => {
|
|
3660
|
-
try {
|
|
3661
|
-
await runUnshare(channel);
|
|
3662
|
-
printSuccess(`Removed ${channel} from directory`);
|
|
3663
|
-
} catch (err) {
|
|
3664
|
-
handleError("unshare", err);
|
|
3665
|
-
}
|
|
3666
|
-
});
|
|
3667
|
-
var roleCmd = program.command("role").description("Manage role definitions");
|
|
3668
|
-
roleCmd.command("create <id>").description("Create a role definition in workforce.json").option("--name <name>", "Display name").option("--description <desc>", "Role description").argument("<scopes...>", "Scopes to grant (e.g. pub:signals sub:market-data)").action((id, scopes, opts) => {
|
|
3669
|
-
try {
|
|
3670
|
-
for (const s of scopes) {
|
|
3671
|
-
if (s !== "admin" && !s.startsWith("pub:") && !s.startsWith("sub:")) {
|
|
3672
|
-
printError(
|
|
3673
|
-
`Invalid scope "${s}". Must be "admin", "pub:<channel>", or "sub:<channel>"`
|
|
3674
|
-
);
|
|
3675
|
-
process.exit(1);
|
|
3676
|
-
}
|
|
3677
|
-
}
|
|
3678
|
-
runRoleCreate(id, {
|
|
3679
|
-
name: opts.name,
|
|
3680
|
-
description: opts.description,
|
|
3681
|
-
scopes
|
|
3682
|
-
});
|
|
3683
|
-
printSuccess(`Created role "${id}" in workforce.json`);
|
|
3684
|
-
printInfo("Next", "Run `npx zooid deploy` to sync to server");
|
|
3685
|
-
} catch (err) {
|
|
3686
|
-
handleError("role create", err);
|
|
3687
|
-
}
|
|
3688
|
-
});
|
|
3689
|
-
roleCmd.command("list").description("List role definitions in workforce.json").action(() => {
|
|
3690
|
-
try {
|
|
3691
|
-
const ids = runRoleList();
|
|
3692
|
-
if (ids.length === 0) {
|
|
3693
|
-
console.log(
|
|
3694
|
-
"No roles defined. Create one with: npx zooid role create <id> <scopes...>"
|
|
3695
|
-
);
|
|
3696
|
-
} else {
|
|
3697
|
-
for (const id of ids) {
|
|
3698
|
-
console.log(` ${id}`);
|
|
3699
|
-
}
|
|
3700
|
-
}
|
|
3701
|
-
} catch (err) {
|
|
3702
|
-
handleError("role list", err);
|
|
3703
|
-
}
|
|
3704
|
-
});
|
|
3705
|
-
roleCmd.command("update <id>").description("Update a role definition in workforce.json").option("--name <name>", "Display name").option("--description <desc>", "Role description").option("--scopes <scopes...>", "Replace scopes").action((id, opts) => {
|
|
3706
|
-
try {
|
|
3707
|
-
const fields = {};
|
|
3708
|
-
if (opts.name !== void 0) fields.name = opts.name;
|
|
3709
|
-
if (opts.description !== void 0) fields.description = opts.description;
|
|
3710
|
-
if (opts.scopes !== void 0) fields.scopes = opts.scopes;
|
|
3711
|
-
if (Object.keys(fields).length === 0) {
|
|
3712
|
-
throw new Error(
|
|
3713
|
-
"No fields specified. Use --name, --description, or --scopes."
|
|
3714
|
-
);
|
|
3715
|
-
}
|
|
3716
|
-
runRoleUpdate(id, fields);
|
|
3717
|
-
printSuccess(`Updated role "${id}" in workforce.json`);
|
|
3718
|
-
printInfo("Next", "Run `npx zooid deploy` to sync to server");
|
|
3719
|
-
} catch (err) {
|
|
3720
|
-
handleError("role update", err);
|
|
3721
|
-
}
|
|
3722
|
-
});
|
|
3723
|
-
roleCmd.command("delete <id>").description("Delete a role definition from workforce.json").option("-y, --yes", "Skip confirmation prompt").action(async (id, opts) => {
|
|
3724
|
-
try {
|
|
3725
|
-
if (!opts.yes) {
|
|
3726
|
-
const readline7 = await import("readline");
|
|
3727
|
-
const rl = readline7.createInterface({
|
|
3728
|
-
input: process.stdin,
|
|
3729
|
-
output: process.stdout
|
|
3730
|
-
});
|
|
3731
|
-
const answer = await new Promise((resolve) => {
|
|
3732
|
-
rl.question(
|
|
3733
|
-
`Delete role "${id}" from workforce.json? [y/N] `,
|
|
3734
|
-
resolve
|
|
3735
|
-
);
|
|
3736
|
-
});
|
|
3737
|
-
rl.close();
|
|
3738
|
-
if (answer.toLowerCase() !== "y") {
|
|
3739
|
-
console.log("Aborted.");
|
|
3740
|
-
return;
|
|
3741
|
-
}
|
|
3742
|
-
}
|
|
3743
|
-
runRoleDelete(id);
|
|
3744
|
-
printSuccess(`Deleted role "${id}" from workforce.json`);
|
|
3745
|
-
printInfo("Next", "Run `npx zooid deploy` to sync to server");
|
|
3746
|
-
} catch (err) {
|
|
3747
|
-
handleError("role delete", err);
|
|
3748
|
-
}
|
|
3749
|
-
});
|
|
3750
|
-
var credentialsCmd = program.command("credentials").description("Manage M2M agent credentials");
|
|
3751
|
-
credentialsCmd.command("create <name>").option("--role <role...>", "Role names to assign").description("Create a new credential (outputs .env to stdout)").action(async (name, opts) => {
|
|
3752
|
-
try {
|
|
3753
|
-
const env = await runCredentialsCreate(name, opts);
|
|
3754
|
-
process.stdout.write(env + "\n");
|
|
3755
|
-
} catch (err) {
|
|
3756
|
-
handleError("credentials create", err);
|
|
3757
|
-
}
|
|
3758
|
-
});
|
|
3759
|
-
credentialsCmd.command("list").description("List all credentials for the current server").action(async () => {
|
|
3760
|
-
try {
|
|
3761
|
-
const creds = await runCredentialsList();
|
|
3762
|
-
if (creds.length === 0) {
|
|
3763
|
-
printInfo("No credentials", "found for this server");
|
|
3764
|
-
return;
|
|
3765
|
-
}
|
|
3766
|
-
for (const c of creds) {
|
|
3767
|
-
const roleNames = c.roles.map((r) => r.name ?? r).join(", ");
|
|
3768
|
-
console.log(
|
|
3769
|
-
` ${c.name.padEnd(20)} ${c.client_id.padEnd(35)} roles: ${roleNames}`
|
|
3770
|
-
);
|
|
3771
|
-
}
|
|
3772
|
-
} catch (err) {
|
|
3773
|
-
handleError("credentials list", err);
|
|
3774
|
-
}
|
|
3775
|
-
});
|
|
3776
|
-
credentialsCmd.command("rotate <name>").description("Rotate credential secret (outputs .env to stdout)").action(async (name) => {
|
|
3777
|
-
try {
|
|
3778
|
-
const env = await runCredentialsRotate(name);
|
|
3779
|
-
process.stdout.write(env + "\n");
|
|
3780
|
-
} catch (err) {
|
|
3781
|
-
handleError("credentials rotate", err);
|
|
3782
|
-
}
|
|
3783
|
-
});
|
|
3784
|
-
credentialsCmd.command("revoke <name>").description("Revoke (delete) a credential").action(async (name) => {
|
|
3785
|
-
try {
|
|
3786
|
-
await runCredentialsRevoke(name);
|
|
3787
|
-
} catch (err) {
|
|
3788
|
-
handleError("credentials revoke", err);
|
|
3789
|
-
}
|
|
3790
|
-
});
|
|
3791
|
-
program.parse();
|
|
3
|
+
buildAcpRegistry
|
|
4
|
+
} from "./chunk-SUPTPSN3.js";
|
|
3792
5
|
export {
|
|
3793
|
-
|
|
6
|
+
buildAcpRegistry
|
|
3794
7
|
};
|
|
8
|
+
//# sourceMappingURL=index.js.map
|