zooid 0.0.0 → 0.0.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Potentially problematic release.
This version of zooid might be problematic. Click here for more details.
- package/dist/index.js +1680 -0
- package/package.json +35 -10
- package/README.md +0 -322
package/dist/index.js
ADDED
|
@@ -0,0 +1,1680 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
var __require = /* @__PURE__ */ ((x) => typeof require !== "undefined" ? require : typeof Proxy !== "undefined" ? new Proxy(x, {
|
|
3
|
+
get: (a, b) => (typeof require !== "undefined" ? require : a)[b]
|
|
4
|
+
}) : x)(function(x) {
|
|
5
|
+
if (typeof require !== "undefined") return require.apply(this, arguments);
|
|
6
|
+
throw Error('Dynamic require of "' + x + '" is not supported');
|
|
7
|
+
});
|
|
8
|
+
|
|
9
|
+
// src/index.ts
|
|
10
|
+
import { Command } from "commander";
|
|
11
|
+
|
|
12
|
+
// src/lib/config.ts
|
|
13
|
+
import fs from "fs";
|
|
14
|
+
import os from "os";
|
|
15
|
+
import path from "path";
|
|
16
|
+
function getConfigDir() {
|
|
17
|
+
return process.env.ZOOID_CONFIG_DIR ?? path.join(os.homedir(), ".zooid");
|
|
18
|
+
}
|
|
19
|
+
function getConfigPath() {
|
|
20
|
+
return path.join(getConfigDir(), "config.json");
|
|
21
|
+
}
|
|
22
|
+
function loadConfigFile() {
|
|
23
|
+
try {
|
|
24
|
+
const raw = fs.readFileSync(getConfigPath(), "utf-8");
|
|
25
|
+
const parsed = JSON.parse(raw);
|
|
26
|
+
if (parsed.server && !parsed.servers) {
|
|
27
|
+
const serverUrl = parsed.server;
|
|
28
|
+
const migrated = {
|
|
29
|
+
current: serverUrl,
|
|
30
|
+
servers: {
|
|
31
|
+
[serverUrl]: {
|
|
32
|
+
worker_url: parsed.worker_url,
|
|
33
|
+
admin_token: parsed.admin_token,
|
|
34
|
+
channels: parsed.channels
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
};
|
|
38
|
+
const dir = getConfigDir();
|
|
39
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
40
|
+
fs.writeFileSync(
|
|
41
|
+
getConfigPath(),
|
|
42
|
+
JSON.stringify(migrated, null, 2) + "\n"
|
|
43
|
+
);
|
|
44
|
+
return migrated;
|
|
45
|
+
}
|
|
46
|
+
return parsed;
|
|
47
|
+
} catch {
|
|
48
|
+
return {};
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
function resolveServer() {
|
|
52
|
+
const file = loadConfigFile();
|
|
53
|
+
let cwdUrl;
|
|
54
|
+
try {
|
|
55
|
+
const zooidJsonPath = path.join(process.cwd(), "zooid.json");
|
|
56
|
+
if (fs.existsSync(zooidJsonPath)) {
|
|
57
|
+
const raw = fs.readFileSync(zooidJsonPath, "utf-8");
|
|
58
|
+
const parsed = JSON.parse(raw);
|
|
59
|
+
cwdUrl = parsed.url || void 0;
|
|
60
|
+
}
|
|
61
|
+
} catch {
|
|
62
|
+
}
|
|
63
|
+
if (cwdUrl) {
|
|
64
|
+
if (file.current && file.current !== cwdUrl) {
|
|
65
|
+
console.log(
|
|
66
|
+
` Note: using server from zooid.json (${cwdUrl}), current is ${file.current}`
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
return cwdUrl;
|
|
70
|
+
}
|
|
71
|
+
return file.current;
|
|
72
|
+
}
|
|
73
|
+
function loadConfig() {
|
|
74
|
+
const file = loadConfigFile();
|
|
75
|
+
const serverUrl = resolveServer();
|
|
76
|
+
const entry = serverUrl ? file.servers?.[serverUrl] : void 0;
|
|
77
|
+
return {
|
|
78
|
+
server: serverUrl,
|
|
79
|
+
worker_url: entry?.worker_url,
|
|
80
|
+
admin_token: entry?.admin_token,
|
|
81
|
+
channels: entry?.channels
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
function saveConfig(partial, serverUrl) {
|
|
85
|
+
const dir = getConfigDir();
|
|
86
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
87
|
+
const file = loadConfigFile();
|
|
88
|
+
const url = serverUrl ?? resolveServer();
|
|
89
|
+
if (!url) {
|
|
90
|
+
throw new Error(
|
|
91
|
+
"No server URL to save config for. Deploy first or set url in zooid.json."
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
if (!file.servers) file.servers = {};
|
|
95
|
+
const existing = file.servers[url] ?? {};
|
|
96
|
+
const merged = { ...existing, ...partial };
|
|
97
|
+
if (partial.channels && existing.channels) {
|
|
98
|
+
merged.channels = { ...existing.channels, ...partial.channels };
|
|
99
|
+
}
|
|
100
|
+
file.servers[url] = merged;
|
|
101
|
+
file.current = url;
|
|
102
|
+
fs.writeFileSync(getConfigPath(), JSON.stringify(file, null, 2) + "\n");
|
|
103
|
+
}
|
|
104
|
+
function loadDirectoryToken() {
|
|
105
|
+
const file = loadConfigFile();
|
|
106
|
+
return file.directory_token;
|
|
107
|
+
}
|
|
108
|
+
function saveDirectoryToken(token) {
|
|
109
|
+
const dir = getConfigDir();
|
|
110
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
111
|
+
const file = loadConfigFile();
|
|
112
|
+
file.directory_token = token;
|
|
113
|
+
fs.writeFileSync(getConfigPath(), JSON.stringify(file, null, 2) + "\n");
|
|
114
|
+
}
|
|
115
|
+
function switchServer(url) {
|
|
116
|
+
const dir = getConfigDir();
|
|
117
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
118
|
+
const file = loadConfigFile();
|
|
119
|
+
if (!file.servers) file.servers = {};
|
|
120
|
+
if (!file.servers[url]) file.servers[url] = {};
|
|
121
|
+
file.current = url;
|
|
122
|
+
fs.writeFileSync(getConfigPath(), JSON.stringify(file, null, 2) + "\n");
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// src/lib/telemetry.ts
|
|
126
|
+
import fs2 from "fs";
|
|
127
|
+
import path2 from "path";
|
|
128
|
+
import { randomUUID } from "crypto";
|
|
129
|
+
var TELEMETRY_ENDPOINT = "https://telemetry.zooid.dev/v1/events";
|
|
130
|
+
var QUEUE_FILENAME = "telemetry.json";
|
|
131
|
+
var CONFIG_FILENAME = "config.json";
|
|
132
|
+
var MAX_QUEUE_SIZE = 1e3;
|
|
133
|
+
function isEnabled() {
|
|
134
|
+
const envVar = process.env.ZOOID_TELEMETRY;
|
|
135
|
+
if (envVar !== void 0) {
|
|
136
|
+
return envVar !== "0" && envVar.toLowerCase() !== "false" && envVar.toLowerCase() !== "off";
|
|
137
|
+
}
|
|
138
|
+
const flag = readTelemetryFlag();
|
|
139
|
+
if (flag === false) return false;
|
|
140
|
+
return true;
|
|
141
|
+
}
|
|
142
|
+
function showNoticeIfNeeded() {
|
|
143
|
+
if (process.env.ZOOID_TELEMETRY === "0") return false;
|
|
144
|
+
const flag = readTelemetryFlag();
|
|
145
|
+
if (flag !== void 0) return false;
|
|
146
|
+
console.log("");
|
|
147
|
+
console.log(" Zooid collects anonymous usage metrics to improve the tool");
|
|
148
|
+
console.log(" and help rank channels in the public directory.");
|
|
149
|
+
console.log("");
|
|
150
|
+
console.log(" To opt out: npx zooid config set telemetry off");
|
|
151
|
+
console.log(" Or set ZOOID_TELEMETRY=0 in your environment.");
|
|
152
|
+
console.log("");
|
|
153
|
+
writeTelemetryFlag(true);
|
|
154
|
+
return true;
|
|
155
|
+
}
|
|
156
|
+
function writeEvent(event) {
|
|
157
|
+
const queuePath = getQueuePath();
|
|
158
|
+
const dir = getConfigDir();
|
|
159
|
+
try {
|
|
160
|
+
fs2.mkdirSync(dir, { recursive: true });
|
|
161
|
+
let queue = [];
|
|
162
|
+
try {
|
|
163
|
+
const raw = fs2.readFileSync(queuePath, "utf-8");
|
|
164
|
+
queue = JSON.parse(raw);
|
|
165
|
+
} catch {
|
|
166
|
+
}
|
|
167
|
+
queue.push(event);
|
|
168
|
+
if (queue.length > MAX_QUEUE_SIZE) {
|
|
169
|
+
queue = queue.slice(-MAX_QUEUE_SIZE);
|
|
170
|
+
}
|
|
171
|
+
fs2.writeFileSync(queuePath, JSON.stringify(queue, null, 2) + "\n");
|
|
172
|
+
} catch {
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
function flushInBackground() {
|
|
176
|
+
const queuePath = getQueuePath();
|
|
177
|
+
try {
|
|
178
|
+
const raw = fs2.readFileSync(queuePath, "utf-8");
|
|
179
|
+
const parsed = JSON.parse(raw);
|
|
180
|
+
if (!Array.isArray(parsed) || parsed.length === 0) return;
|
|
181
|
+
} catch {
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
184
|
+
try {
|
|
185
|
+
const { spawn: spawn2 } = __require("child_process");
|
|
186
|
+
const child = spawn2(process.execPath, ["-e", flushScript(queuePath)], {
|
|
187
|
+
detached: true,
|
|
188
|
+
stdio: "ignore",
|
|
189
|
+
env: { ...process.env }
|
|
190
|
+
});
|
|
191
|
+
child.unref();
|
|
192
|
+
} catch {
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
function getInstallId() {
|
|
196
|
+
const configPath = path2.join(getConfigDir(), CONFIG_FILENAME);
|
|
197
|
+
try {
|
|
198
|
+
const raw = fs2.readFileSync(configPath, "utf-8");
|
|
199
|
+
const parsed = JSON.parse(raw);
|
|
200
|
+
if (parsed.install_id) return parsed.install_id;
|
|
201
|
+
} catch {
|
|
202
|
+
}
|
|
203
|
+
const id = randomUUID();
|
|
204
|
+
persistToConfig("install_id", id);
|
|
205
|
+
return id;
|
|
206
|
+
}
|
|
207
|
+
function getQueuePath() {
|
|
208
|
+
return path2.join(getConfigDir(), QUEUE_FILENAME);
|
|
209
|
+
}
|
|
210
|
+
function readTelemetryFlag() {
|
|
211
|
+
const configPath = path2.join(getConfigDir(), CONFIG_FILENAME);
|
|
212
|
+
try {
|
|
213
|
+
const raw = fs2.readFileSync(configPath, "utf-8");
|
|
214
|
+
const parsed = JSON.parse(raw);
|
|
215
|
+
if (parsed.telemetry === true) return true;
|
|
216
|
+
if (parsed.telemetry === false) return false;
|
|
217
|
+
return void 0;
|
|
218
|
+
} catch {
|
|
219
|
+
return void 0;
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
function writeTelemetryFlag(enabled) {
|
|
223
|
+
persistToConfig("telemetry", enabled);
|
|
224
|
+
}
|
|
225
|
+
function persistToConfig(key, value) {
|
|
226
|
+
const dir = getConfigDir();
|
|
227
|
+
const configPath = path2.join(dir, CONFIG_FILENAME);
|
|
228
|
+
try {
|
|
229
|
+
fs2.mkdirSync(dir, { recursive: true });
|
|
230
|
+
let config = {};
|
|
231
|
+
try {
|
|
232
|
+
const raw = fs2.readFileSync(configPath, "utf-8");
|
|
233
|
+
config = JSON.parse(raw);
|
|
234
|
+
} catch {
|
|
235
|
+
}
|
|
236
|
+
config[key] = value;
|
|
237
|
+
fs2.writeFileSync(configPath, JSON.stringify(config, null, 2) + "\n");
|
|
238
|
+
} catch {
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
function flushScript(queuePath) {
|
|
242
|
+
return `
|
|
243
|
+
const fs = require('fs');
|
|
244
|
+
const https = require('https');
|
|
245
|
+
const http = require('http');
|
|
246
|
+
|
|
247
|
+
try {
|
|
248
|
+
const raw = fs.readFileSync(${JSON.stringify(queuePath)}, 'utf-8');
|
|
249
|
+
const events = JSON.parse(raw);
|
|
250
|
+
if (!Array.isArray(events) || events.length === 0) process.exit(0);
|
|
251
|
+
|
|
252
|
+
const body = JSON.stringify({ events });
|
|
253
|
+
const url = new URL(${JSON.stringify(TELEMETRY_ENDPOINT)});
|
|
254
|
+
const transport = url.protocol === 'https:' ? https : http;
|
|
255
|
+
|
|
256
|
+
const req = transport.request(url, {
|
|
257
|
+
method: 'POST',
|
|
258
|
+
headers: {
|
|
259
|
+
'Content-Type': 'application/json',
|
|
260
|
+
'Content-Length': Buffer.byteLength(body),
|
|
261
|
+
},
|
|
262
|
+
timeout: 5000,
|
|
263
|
+
}, (res) => {
|
|
264
|
+
if (res.statusCode >= 200 && res.statusCode < 300) {
|
|
265
|
+
fs.writeFileSync(${JSON.stringify(queuePath)}, '[]\\n');
|
|
266
|
+
}
|
|
267
|
+
process.exit(0);
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
req.on('error', () => process.exit(0));
|
|
271
|
+
req.on('timeout', () => { req.destroy(); process.exit(0); });
|
|
272
|
+
req.write(body);
|
|
273
|
+
req.end();
|
|
274
|
+
} catch (e) {
|
|
275
|
+
process.exit(0);
|
|
276
|
+
}
|
|
277
|
+
`.trim();
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
// src/commands/config.ts
|
|
281
|
+
var VALID_KEYS = ["server", "admin-token", "telemetry"];
|
|
282
|
+
function runConfigSet(key, value) {
|
|
283
|
+
if (key === "server") {
|
|
284
|
+
switchServer(value);
|
|
285
|
+
} else if (key === "admin-token") {
|
|
286
|
+
saveConfig({ admin_token: value });
|
|
287
|
+
} else if (key === "telemetry") {
|
|
288
|
+
const enabled = value === "on" || value === "true" || value === "1";
|
|
289
|
+
writeTelemetryFlag(enabled);
|
|
290
|
+
} else {
|
|
291
|
+
throw new Error(
|
|
292
|
+
`Unknown config key: "${key}". Valid keys: ${VALID_KEYS.join(", ")}`
|
|
293
|
+
);
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
function runConfigGet(key) {
|
|
297
|
+
const config = loadConfig();
|
|
298
|
+
if (key === "server") return config.server;
|
|
299
|
+
if (key === "admin-token") return config.admin_token;
|
|
300
|
+
if (key === "telemetry") return isEnabled() ? "on" : "off";
|
|
301
|
+
throw new Error(
|
|
302
|
+
`Unknown config key: "${key}". Valid keys: ${VALID_KEYS.join(", ")}`
|
|
303
|
+
);
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
// src/lib/client.ts
|
|
307
|
+
import { ZooidClient } from "@zooid/sdk";
|
|
308
|
+
function createClient(token) {
|
|
309
|
+
const config = loadConfig();
|
|
310
|
+
const server = config.server;
|
|
311
|
+
if (!server) {
|
|
312
|
+
throw new Error(
|
|
313
|
+
"No server configured. Run: npx zooid config set server <url>"
|
|
314
|
+
);
|
|
315
|
+
}
|
|
316
|
+
return new ZooidClient({ server, token: token ?? config.admin_token });
|
|
317
|
+
}
|
|
318
|
+
function createPublishClient(channelId) {
|
|
319
|
+
const config = loadConfig();
|
|
320
|
+
const server = config.server;
|
|
321
|
+
if (!server) {
|
|
322
|
+
throw new Error(
|
|
323
|
+
"No server configured. Run: npx zooid config set server <url>"
|
|
324
|
+
);
|
|
325
|
+
}
|
|
326
|
+
const channelToken = config.channels?.[channelId]?.publish_token;
|
|
327
|
+
return new ZooidClient({ server, token: channelToken ?? config.admin_token });
|
|
328
|
+
}
|
|
329
|
+
function createSubscribeClient(channelId) {
|
|
330
|
+
const config = loadConfig();
|
|
331
|
+
const server = config.server;
|
|
332
|
+
if (!server) {
|
|
333
|
+
throw new Error(
|
|
334
|
+
"No server configured. Run: npx zooid config set server <url>"
|
|
335
|
+
);
|
|
336
|
+
}
|
|
337
|
+
const channelToken = config.channels?.[channelId]?.subscribe_token;
|
|
338
|
+
return new ZooidClient({ server, token: channelToken ?? config.admin_token });
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
// src/commands/channel.ts
|
|
342
|
+
async function runChannelCreate(id, options, client) {
|
|
343
|
+
const c = client ?? createClient();
|
|
344
|
+
const result = await c.createChannel({
|
|
345
|
+
id,
|
|
346
|
+
name: options.name ?? id,
|
|
347
|
+
description: options.description,
|
|
348
|
+
is_public: options.public ?? true,
|
|
349
|
+
strict: options.strict,
|
|
350
|
+
schema: options.schema
|
|
351
|
+
});
|
|
352
|
+
if (!client) {
|
|
353
|
+
const config = loadConfig();
|
|
354
|
+
const channels = config.channels ?? {};
|
|
355
|
+
channels[id] = {
|
|
356
|
+
publish_token: result.publish_token,
|
|
357
|
+
subscribe_token: result.subscribe_token
|
|
358
|
+
};
|
|
359
|
+
saveConfig({ channels });
|
|
360
|
+
}
|
|
361
|
+
return result;
|
|
362
|
+
}
|
|
363
|
+
async function runChannelList(client) {
|
|
364
|
+
const c = client ?? createClient();
|
|
365
|
+
return c.listChannels();
|
|
366
|
+
}
|
|
367
|
+
async function runChannelAddPublisher(channelId, name, client) {
|
|
368
|
+
const c = client ?? createClient();
|
|
369
|
+
return c.addPublisher(channelId, name);
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
// src/commands/publish.ts
|
|
373
|
+
import fs3 from "fs";
|
|
374
|
+
async function runPublish(channelId, options, client) {
|
|
375
|
+
const c = client ?? createPublishClient(channelId);
|
|
376
|
+
let type;
|
|
377
|
+
let data;
|
|
378
|
+
if (options.file) {
|
|
379
|
+
const raw = fs3.readFileSync(options.file, "utf-8");
|
|
380
|
+
const parsed = JSON.parse(raw);
|
|
381
|
+
type = parsed.type;
|
|
382
|
+
data = parsed.data ?? parsed;
|
|
383
|
+
} else if (options.data) {
|
|
384
|
+
data = JSON.parse(options.data);
|
|
385
|
+
type = options.type;
|
|
386
|
+
} else {
|
|
387
|
+
throw new Error("Either --data or --file is required");
|
|
388
|
+
}
|
|
389
|
+
const publishOpts = { data };
|
|
390
|
+
if (type) publishOpts.type = type;
|
|
391
|
+
return c.publish(channelId, publishOpts);
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
// src/commands/subscribe.ts
|
|
395
|
+
async function runSubscribePoll(channelId, options = {}, client) {
|
|
396
|
+
const c = client ?? createSubscribeClient(channelId);
|
|
397
|
+
const callback = options.callback ?? ((event) => {
|
|
398
|
+
console.log(JSON.stringify(event));
|
|
399
|
+
});
|
|
400
|
+
return c.subscribe(channelId, callback, {
|
|
401
|
+
interval: options.interval ?? 5e3,
|
|
402
|
+
mode: options.mode,
|
|
403
|
+
type: options.type
|
|
404
|
+
});
|
|
405
|
+
}
|
|
406
|
+
async function runSubscribeWebhook(channelId, url, client) {
|
|
407
|
+
const c = client ?? createSubscribeClient(channelId);
|
|
408
|
+
return c.registerWebhook(channelId, url);
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
// src/commands/tail.ts
|
|
412
|
+
async function runTail(channelId, options = {}, client) {
|
|
413
|
+
const c = client ?? createSubscribeClient(channelId);
|
|
414
|
+
if (options.follow) {
|
|
415
|
+
return runTailFollow(c, channelId, options);
|
|
416
|
+
}
|
|
417
|
+
const pollOpts = {};
|
|
418
|
+
if (options.limit !== void 0) pollOpts.limit = options.limit;
|
|
419
|
+
if (options.type) pollOpts.type = options.type;
|
|
420
|
+
if (options.since) pollOpts.since = options.since;
|
|
421
|
+
if (options.cursor) pollOpts.cursor = options.cursor;
|
|
422
|
+
return c.tail(channelId, pollOpts);
|
|
423
|
+
}
|
|
424
|
+
async function runTailFollow(client, channelId, options) {
|
|
425
|
+
const tailOpts = {
|
|
426
|
+
follow: true,
|
|
427
|
+
mode: options.mode,
|
|
428
|
+
interval: options.interval,
|
|
429
|
+
type: options.type
|
|
430
|
+
};
|
|
431
|
+
const stream = client.tail(channelId, tailOpts);
|
|
432
|
+
for await (const event of stream) {
|
|
433
|
+
console.log(JSON.stringify(event));
|
|
434
|
+
}
|
|
435
|
+
return void 0;
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
// src/commands/status.ts
|
|
439
|
+
async function runStatus(client) {
|
|
440
|
+
const c = client ?? createClient();
|
|
441
|
+
const [discovery, identity] = await Promise.all([
|
|
442
|
+
c.getMetadata(),
|
|
443
|
+
c.getServerMeta()
|
|
444
|
+
]);
|
|
445
|
+
return { discovery, identity };
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
// src/commands/share.ts
|
|
449
|
+
import readline from "readline/promises";
|
|
450
|
+
|
|
451
|
+
// src/lib/output.ts
|
|
452
|
+
function printSuccess(message) {
|
|
453
|
+
console.log(`\u2713 ${message}`);
|
|
454
|
+
}
|
|
455
|
+
function printError(message) {
|
|
456
|
+
console.error(`\u2717 ${message}`);
|
|
457
|
+
}
|
|
458
|
+
function printInfo(label, value) {
|
|
459
|
+
console.log(` ${label}: ${value}`);
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
// src/lib/directory.ts
|
|
463
|
+
var DIRECTORY_BASE_URL = "https://directory.zooid.dev";
|
|
464
|
+
var TOKEN_PREFIX = "zd_";
|
|
465
|
+
var POLL_INTERVAL_MS = 3e3;
|
|
466
|
+
var POLL_TIMEOUT_MS = 2 * 60 * 1e3;
|
|
467
|
+
function getDirectoryToken() {
|
|
468
|
+
const token = loadDirectoryToken();
|
|
469
|
+
if (token && token.startsWith(TOKEN_PREFIX)) {
|
|
470
|
+
return token;
|
|
471
|
+
}
|
|
472
|
+
return void 0;
|
|
473
|
+
}
|
|
474
|
+
async function ensureDirectoryToken() {
|
|
475
|
+
const existing = getDirectoryToken();
|
|
476
|
+
if (existing) return existing;
|
|
477
|
+
return deviceAuth();
|
|
478
|
+
}
|
|
479
|
+
async function deviceAuth() {
|
|
480
|
+
const res = await fetch(`${DIRECTORY_BASE_URL}/api/auth/device`, {
|
|
481
|
+
method: "POST",
|
|
482
|
+
headers: { "Content-Type": "application/json" }
|
|
483
|
+
});
|
|
484
|
+
if (!res.ok) {
|
|
485
|
+
throw new Error(`Directory auth failed: ${res.status} ${res.statusText}`);
|
|
486
|
+
}
|
|
487
|
+
const data = await res.json();
|
|
488
|
+
const { device_code, verification_url } = data;
|
|
489
|
+
console.log("");
|
|
490
|
+
printInfo("Authorize", verification_url);
|
|
491
|
+
console.log(" Opening browser to complete GitHub sign-in...\n");
|
|
492
|
+
try {
|
|
493
|
+
const { exec } = await import("child_process");
|
|
494
|
+
const platform = process.platform;
|
|
495
|
+
const cmd = platform === "darwin" ? "open" : platform === "win32" ? "start" : "xdg-open";
|
|
496
|
+
exec(`${cmd} "${verification_url}"`);
|
|
497
|
+
} catch {
|
|
498
|
+
console.log(
|
|
499
|
+
" Could not open browser automatically. Please visit the URL above.\n"
|
|
500
|
+
);
|
|
501
|
+
}
|
|
502
|
+
const deadline = Date.now() + POLL_TIMEOUT_MS;
|
|
503
|
+
while (Date.now() < deadline) {
|
|
504
|
+
await sleep(POLL_INTERVAL_MS);
|
|
505
|
+
const statusRes = await fetch(
|
|
506
|
+
`${DIRECTORY_BASE_URL}/api/auth/status?code=${encodeURIComponent(device_code)}`
|
|
507
|
+
);
|
|
508
|
+
if (!statusRes.ok) continue;
|
|
509
|
+
const status = await statusRes.json();
|
|
510
|
+
if (status.status === "complete" && status.token) {
|
|
511
|
+
saveDirectoryToken(status.token);
|
|
512
|
+
console.log(" Authenticated with Zooid Directory.\n");
|
|
513
|
+
return status.token;
|
|
514
|
+
}
|
|
515
|
+
if (status.status === "expired") {
|
|
516
|
+
throw new Error("Device authorization expired. Please try again.");
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
throw new Error(
|
|
520
|
+
"Authorization timed out. You need your human to authorize you. Run `npx zooid share` again to retry."
|
|
521
|
+
);
|
|
522
|
+
}
|
|
523
|
+
async function directoryFetch(path6, options = {}) {
|
|
524
|
+
let token = await ensureDirectoryToken();
|
|
525
|
+
const doFetch = (t) => {
|
|
526
|
+
const headers = new Headers(options.headers);
|
|
527
|
+
headers.set("Authorization", `Bearer ${t}`);
|
|
528
|
+
headers.set("Content-Type", "application/json");
|
|
529
|
+
return fetch(`${DIRECTORY_BASE_URL}${path6}`, { ...options, headers });
|
|
530
|
+
};
|
|
531
|
+
let res = await doFetch(token);
|
|
532
|
+
if (res.status === 401) {
|
|
533
|
+
console.log(" Directory token expired. Re-authenticating...\n");
|
|
534
|
+
token = await deviceAuth();
|
|
535
|
+
res = await doFetch(token);
|
|
536
|
+
}
|
|
537
|
+
return res;
|
|
538
|
+
}
|
|
539
|
+
function sleep(ms) {
|
|
540
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
// src/commands/share.ts
|
|
544
|
+
async function runShare(channelIds, options = {}) {
|
|
545
|
+
const client = createClient();
|
|
546
|
+
const config = loadConfig();
|
|
547
|
+
const serverUrl = config.server;
|
|
548
|
+
if (!serverUrl) {
|
|
549
|
+
throw new Error(
|
|
550
|
+
"No server configured. Run: npx zooid config set server <url>"
|
|
551
|
+
);
|
|
552
|
+
}
|
|
553
|
+
const allChannels = await client.listChannels();
|
|
554
|
+
const publicChannels = allChannels.filter((ch) => ch.is_public);
|
|
555
|
+
if (publicChannels.length === 0) {
|
|
556
|
+
throw new Error("No public channels found on this server.");
|
|
557
|
+
}
|
|
558
|
+
let selected;
|
|
559
|
+
if (channelIds.length > 0) {
|
|
560
|
+
const byId = new Map(allChannels.map((ch) => [ch.id, ch]));
|
|
561
|
+
const missing = [];
|
|
562
|
+
selected = [];
|
|
563
|
+
for (const id of channelIds) {
|
|
564
|
+
const ch = byId.get(id);
|
|
565
|
+
if (!ch) {
|
|
566
|
+
missing.push(id);
|
|
567
|
+
} else {
|
|
568
|
+
selected.push(ch);
|
|
569
|
+
}
|
|
570
|
+
}
|
|
571
|
+
if (missing.length > 0) {
|
|
572
|
+
throw new Error(`Channels not found: ${missing.join(", ")}`);
|
|
573
|
+
}
|
|
574
|
+
const privateChannels = selected.filter((ch) => !ch.is_public);
|
|
575
|
+
if (privateChannels.length > 0) {
|
|
576
|
+
throw new Error(
|
|
577
|
+
`Cannot share private channels: ${privateChannels.map((ch) => ch.id).join(", ")}. Only public channels can be listed in the directory.`
|
|
578
|
+
);
|
|
579
|
+
}
|
|
580
|
+
} else if (options.yes) {
|
|
581
|
+
selected = publicChannels;
|
|
582
|
+
} else {
|
|
583
|
+
selected = await pickChannels(publicChannels);
|
|
584
|
+
if (selected.length === 0) {
|
|
585
|
+
throw new Error("No channels selected.");
|
|
586
|
+
}
|
|
587
|
+
}
|
|
588
|
+
const channelDetails = await promptChannelDetails(selected, options.yes);
|
|
589
|
+
const ids = selected.map((ch) => ch.id);
|
|
590
|
+
const { claim, signature } = await client.getClaim(ids);
|
|
591
|
+
const channels = selected.map((ch) => {
|
|
592
|
+
const details = channelDetails.get(ch.id);
|
|
593
|
+
const entry = {
|
|
594
|
+
channel_id: ch.id,
|
|
595
|
+
name: ch.name
|
|
596
|
+
};
|
|
597
|
+
if (details.description) entry.description = details.description;
|
|
598
|
+
if (details.tags.length > 0) entry.tags = details.tags;
|
|
599
|
+
return entry;
|
|
600
|
+
});
|
|
601
|
+
const res = await directoryFetch("/api/servers", {
|
|
602
|
+
method: "POST",
|
|
603
|
+
body: JSON.stringify({ server_url: serverUrl, claim, signature, channels })
|
|
604
|
+
});
|
|
605
|
+
if (!res.ok) {
|
|
606
|
+
throw new Error(await formatDirectoryError(res));
|
|
607
|
+
}
|
|
608
|
+
const result = await res.json();
|
|
609
|
+
console.log("");
|
|
610
|
+
for (const ch of selected) {
|
|
611
|
+
const dirChannel = result.channels?.find((c) => c.channel_id === ch.id);
|
|
612
|
+
const url = dirChannel?.directory_url ?? `${DIRECTORY_BASE_URL}/servers/${encodeURIComponent(serverUrl)}/${ch.id}`;
|
|
613
|
+
console.log(` ${ch.id} \u2192 ${url}`);
|
|
614
|
+
}
|
|
615
|
+
console.log("");
|
|
616
|
+
}
|
|
617
|
+
async function pickChannels(channels) {
|
|
618
|
+
const { default: checkbox } = await import("@inquirer/checkbox");
|
|
619
|
+
const selected = await checkbox({
|
|
620
|
+
message: "Select channels to share",
|
|
621
|
+
choices: channels.map((ch) => ({
|
|
622
|
+
name: ch.description ? `${ch.id} \u2014 ${ch.description}` : ch.id,
|
|
623
|
+
value: ch.id,
|
|
624
|
+
checked: true
|
|
625
|
+
})),
|
|
626
|
+
theme: {
|
|
627
|
+
icon: { cursor: "> " },
|
|
628
|
+
style: { highlight: (text) => text }
|
|
629
|
+
}
|
|
630
|
+
});
|
|
631
|
+
const selectedSet = new Set(selected);
|
|
632
|
+
return channels.filter((ch) => selectedSet.has(ch.id));
|
|
633
|
+
}
|
|
634
|
+
async function promptChannelDetails(channels, skipPrompt) {
|
|
635
|
+
const result = /* @__PURE__ */ new Map();
|
|
636
|
+
if (skipPrompt) {
|
|
637
|
+
for (const ch of channels) {
|
|
638
|
+
result.set(ch.id, {
|
|
639
|
+
description: ch.description ?? "",
|
|
640
|
+
tags: ch.tags
|
|
641
|
+
});
|
|
642
|
+
}
|
|
643
|
+
return result;
|
|
644
|
+
}
|
|
645
|
+
const rl = readline.createInterface({
|
|
646
|
+
input: process.stdin,
|
|
647
|
+
output: process.stdout
|
|
648
|
+
});
|
|
649
|
+
try {
|
|
650
|
+
console.log("");
|
|
651
|
+
console.log(" Set description and tags for each channel.");
|
|
652
|
+
console.log(" Press Enter to accept defaults shown in [brackets].\n");
|
|
653
|
+
for (const ch of channels) {
|
|
654
|
+
console.log(` ${ch.id}:`);
|
|
655
|
+
const desc = await ask(rl, "Description", ch.description ?? "");
|
|
656
|
+
const tagsRaw = await ask(
|
|
657
|
+
rl,
|
|
658
|
+
"Tags (comma-separated)",
|
|
659
|
+
ch.tags.join(", ")
|
|
660
|
+
);
|
|
661
|
+
const tags = tagsRaw.split(",").map((t) => t.trim()).filter(Boolean);
|
|
662
|
+
result.set(ch.id, { description: desc, tags });
|
|
663
|
+
console.log("");
|
|
664
|
+
}
|
|
665
|
+
} finally {
|
|
666
|
+
rl.close();
|
|
667
|
+
}
|
|
668
|
+
return result;
|
|
669
|
+
}
|
|
670
|
+
async function ask(rl, label, defaultValue) {
|
|
671
|
+
const hint = defaultValue ? ` [${defaultValue}]` : "";
|
|
672
|
+
const answer = await rl.question(` ${label}${hint}: `);
|
|
673
|
+
return answer.trim() || defaultValue;
|
|
674
|
+
}
|
|
675
|
+
async function formatDirectoryError(res) {
|
|
676
|
+
let msg = `Directory returned ${res.status}`;
|
|
677
|
+
try {
|
|
678
|
+
const body = await res.json();
|
|
679
|
+
const parts = [];
|
|
680
|
+
if (body.error) parts.push(String(body.error));
|
|
681
|
+
if (body.message) parts.push(String(body.message));
|
|
682
|
+
if (parts.length > 0) msg = parts.join(": ");
|
|
683
|
+
} catch {
|
|
684
|
+
}
|
|
685
|
+
return msg;
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
// src/commands/unshare.ts
|
|
689
|
+
async function runUnshare(channelId) {
|
|
690
|
+
const client = createClient();
|
|
691
|
+
const config = loadConfig();
|
|
692
|
+
const serverUrl = config.server;
|
|
693
|
+
if (!serverUrl) {
|
|
694
|
+
throw new Error(
|
|
695
|
+
"No server configured. Run: npx zooid config set server <url>"
|
|
696
|
+
);
|
|
697
|
+
}
|
|
698
|
+
const { claim, signature } = await client.getClaim([channelId], "delete");
|
|
699
|
+
const res = await directoryFetch("/api/servers/channels", {
|
|
700
|
+
method: "DELETE",
|
|
701
|
+
body: JSON.stringify({
|
|
702
|
+
server_url: serverUrl,
|
|
703
|
+
channel_id: channelId,
|
|
704
|
+
claim,
|
|
705
|
+
signature
|
|
706
|
+
})
|
|
707
|
+
});
|
|
708
|
+
if (!res.ok) {
|
|
709
|
+
throw new Error(await formatDirectoryError2(res));
|
|
710
|
+
}
|
|
711
|
+
}
|
|
712
|
+
async function formatDirectoryError2(res) {
|
|
713
|
+
let msg = `Directory returned ${res.status}`;
|
|
714
|
+
try {
|
|
715
|
+
const body = await res.json();
|
|
716
|
+
const parts = [];
|
|
717
|
+
if (body.error) parts.push(String(body.error));
|
|
718
|
+
if (body.message) parts.push(String(body.message));
|
|
719
|
+
if (parts.length > 0) msg = parts.join(": ");
|
|
720
|
+
} catch {
|
|
721
|
+
}
|
|
722
|
+
return msg;
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
// src/commands/discover.ts
|
|
726
|
+
async function runDiscover(options) {
|
|
727
|
+
const params = new URLSearchParams();
|
|
728
|
+
if (options.query) params.set("q", options.query);
|
|
729
|
+
if (options.tag) params.set("tag", options.tag);
|
|
730
|
+
if (options.limit) params.set("limit", String(options.limit));
|
|
731
|
+
const qs = params.toString();
|
|
732
|
+
const url = `${DIRECTORY_BASE_URL}/api/discover${qs ? `?${qs}` : ""}`;
|
|
733
|
+
const res = await fetch(url);
|
|
734
|
+
if (!res.ok) {
|
|
735
|
+
throw new Error(`Directory returned ${res.status}`);
|
|
736
|
+
}
|
|
737
|
+
const data = await res.json();
|
|
738
|
+
if (data.channels.length === 0) {
|
|
739
|
+
console.log("No channels found.");
|
|
740
|
+
return;
|
|
741
|
+
}
|
|
742
|
+
console.log("");
|
|
743
|
+
for (const ch of data.channels) {
|
|
744
|
+
const host = new URL(ch.server_url).host;
|
|
745
|
+
const tags = ch.tags.length > 0 ? ` [${ch.tags.join(", ")}]` : "";
|
|
746
|
+
const desc = ch.description ? ` \u2014 ${ch.description}` : "";
|
|
747
|
+
console.log(` ${host}/${ch.channel_id}${desc}${tags}`);
|
|
748
|
+
}
|
|
749
|
+
console.log(`
|
|
750
|
+
${data.total} channel${data.total === 1 ? "" : "s"} found.`);
|
|
751
|
+
console.log("");
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
// src/commands/server.ts
|
|
755
|
+
async function runServerGet(client) {
|
|
756
|
+
const c = client ?? createClient();
|
|
757
|
+
return c.getServerMeta();
|
|
758
|
+
}
|
|
759
|
+
async function runServerSet(fields, client) {
|
|
760
|
+
const c = client ?? createClient();
|
|
761
|
+
return c.updateServerMeta(fields);
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
// src/commands/dev.ts
|
|
765
|
+
import { execSync, spawn } from "child_process";
|
|
766
|
+
import crypto from "crypto";
|
|
767
|
+
import fs4 from "fs";
|
|
768
|
+
import path3 from "path";
|
|
769
|
+
import { fileURLToPath } from "url";
|
|
770
|
+
function findServerDir() {
|
|
771
|
+
const cliDir = path3.dirname(fileURLToPath(import.meta.url));
|
|
772
|
+
return path3.resolve(cliDir, "../../server");
|
|
773
|
+
}
|
|
774
|
+
function base64url(buf) {
|
|
775
|
+
return buf.toString("base64url");
|
|
776
|
+
}
|
|
777
|
+
async function createAdminToken(secret) {
|
|
778
|
+
const header = base64url(
|
|
779
|
+
Buffer.from(JSON.stringify({ alg: "HS256", typ: "JWT" }))
|
|
780
|
+
);
|
|
781
|
+
const payload = base64url(
|
|
782
|
+
Buffer.from(
|
|
783
|
+
JSON.stringify({
|
|
784
|
+
scope: "admin",
|
|
785
|
+
iat: Math.floor(Date.now() / 1e3)
|
|
786
|
+
})
|
|
787
|
+
)
|
|
788
|
+
);
|
|
789
|
+
const data = `${header}.${payload}`;
|
|
790
|
+
const key = await crypto.subtle.importKey(
|
|
791
|
+
"raw",
|
|
792
|
+
new TextEncoder().encode(secret),
|
|
793
|
+
{ name: "HMAC", hash: "SHA-256" },
|
|
794
|
+
false,
|
|
795
|
+
["sign"]
|
|
796
|
+
);
|
|
797
|
+
const sig = await crypto.subtle.sign(
|
|
798
|
+
"HMAC",
|
|
799
|
+
key,
|
|
800
|
+
new TextEncoder().encode(data)
|
|
801
|
+
);
|
|
802
|
+
const signature = base64url(Buffer.from(sig));
|
|
803
|
+
return `${data}.${signature}`;
|
|
804
|
+
}
|
|
805
|
+
async function runDev(port = 8787) {
|
|
806
|
+
const serverDir = findServerDir();
|
|
807
|
+
if (!fs4.existsSync(path3.join(serverDir, "wrangler.toml"))) {
|
|
808
|
+
throw new Error(
|
|
809
|
+
`Server directory not found at ${serverDir}. Make sure you're running from the zooid monorepo.`
|
|
810
|
+
);
|
|
811
|
+
}
|
|
812
|
+
const jwtSecret = crypto.randomUUID();
|
|
813
|
+
const devVarsPath = path3.join(serverDir, ".dev.vars");
|
|
814
|
+
fs4.writeFileSync(devVarsPath, `ZOOID_JWT_SECRET=${jwtSecret}
|
|
815
|
+
`);
|
|
816
|
+
const adminToken = await createAdminToken(jwtSecret);
|
|
817
|
+
const serverUrl = `http://localhost:${port}`;
|
|
818
|
+
saveConfig({ admin_token: adminToken }, serverUrl);
|
|
819
|
+
const schemaPath = path3.join(serverDir, "src/db/schema.sql");
|
|
820
|
+
if (fs4.existsSync(schemaPath)) {
|
|
821
|
+
try {
|
|
822
|
+
execSync(
|
|
823
|
+
`npx wrangler d1 execute zooid-db --local --file=${schemaPath}`,
|
|
824
|
+
{
|
|
825
|
+
cwd: serverDir,
|
|
826
|
+
stdio: "pipe"
|
|
827
|
+
}
|
|
828
|
+
);
|
|
829
|
+
printSuccess("Database schema initialized");
|
|
830
|
+
} catch {
|
|
831
|
+
printSuccess("Database ready (schema already exists)");
|
|
832
|
+
}
|
|
833
|
+
}
|
|
834
|
+
printSuccess("Local server configured");
|
|
835
|
+
printInfo("Server", serverUrl);
|
|
836
|
+
printInfo("Admin token", adminToken.slice(0, 20) + "...");
|
|
837
|
+
printInfo("Config saved to", "~/.zooid/config.json");
|
|
838
|
+
console.log("");
|
|
839
|
+
console.log("Starting wrangler dev...");
|
|
840
|
+
console.log("");
|
|
841
|
+
const child = spawn(
|
|
842
|
+
"npx",
|
|
843
|
+
["wrangler", "dev", "--local", "--port", String(port)],
|
|
844
|
+
{
|
|
845
|
+
cwd: serverDir,
|
|
846
|
+
stdio: "inherit",
|
|
847
|
+
shell: true
|
|
848
|
+
}
|
|
849
|
+
);
|
|
850
|
+
child.on("error", (err) => {
|
|
851
|
+
console.error(`Failed to start local server: ${err.message}`);
|
|
852
|
+
process.exit(1);
|
|
853
|
+
});
|
|
854
|
+
child.on("exit", (code) => {
|
|
855
|
+
process.exit(code ?? 0);
|
|
856
|
+
});
|
|
857
|
+
}
|
|
858
|
+
|
|
859
|
+
// src/commands/init.ts
|
|
860
|
+
import fs5 from "fs";
|
|
861
|
+
import path4 from "path";
|
|
862
|
+
import readline2 from "readline/promises";
|
|
863
|
+
var CONFIG_FILENAME2 = "zooid.json";
|
|
864
|
+
function getConfigPath2() {
|
|
865
|
+
return path4.join(process.cwd(), CONFIG_FILENAME2);
|
|
866
|
+
}
|
|
867
|
+
function loadServerConfig() {
|
|
868
|
+
const configPath = getConfigPath2();
|
|
869
|
+
if (!fs5.existsSync(configPath)) return null;
|
|
870
|
+
const raw = fs5.readFileSync(configPath, "utf-8");
|
|
871
|
+
return JSON.parse(raw);
|
|
872
|
+
}
|
|
873
|
+
function saveServerConfig(config) {
|
|
874
|
+
const configPath = getConfigPath2();
|
|
875
|
+
fs5.writeFileSync(configPath, JSON.stringify(config, null, 2) + "\n");
|
|
876
|
+
}
|
|
877
|
+
async function runInit() {
|
|
878
|
+
const configPath = getConfigPath2();
|
|
879
|
+
const existing = loadServerConfig();
|
|
880
|
+
if (existing) {
|
|
881
|
+
printInfo("Found existing", configPath);
|
|
882
|
+
console.log("");
|
|
883
|
+
}
|
|
884
|
+
const rl = readline2.createInterface({
|
|
885
|
+
input: process.stdin,
|
|
886
|
+
output: process.stdout
|
|
887
|
+
});
|
|
888
|
+
try {
|
|
889
|
+
console.log("");
|
|
890
|
+
console.log(" Configure your Zooid server");
|
|
891
|
+
console.log(" Press Enter to accept defaults shown in [brackets].\n");
|
|
892
|
+
const name = await ask2(rl, "Server name", existing?.name ?? "");
|
|
893
|
+
const description = await ask2(
|
|
894
|
+
rl,
|
|
895
|
+
"Description",
|
|
896
|
+
existing?.description ?? ""
|
|
897
|
+
);
|
|
898
|
+
const owner = await ask2(rl, "Owner", existing?.owner ?? "");
|
|
899
|
+
const company = await ask2(rl, "Company", existing?.company ?? "");
|
|
900
|
+
const email = await ask2(rl, "Email", existing?.email ?? "");
|
|
901
|
+
const tagsRaw = await ask2(
|
|
902
|
+
rl,
|
|
903
|
+
"Tags (comma-separated)",
|
|
904
|
+
existing?.tags?.join(", ") ?? ""
|
|
905
|
+
);
|
|
906
|
+
const url = await ask2(rl, "URL", existing?.url ?? "");
|
|
907
|
+
const tags = tagsRaw.split(",").map((t) => t.trim()).filter(Boolean);
|
|
908
|
+
const config = {
|
|
909
|
+
name,
|
|
910
|
+
description,
|
|
911
|
+
owner,
|
|
912
|
+
company,
|
|
913
|
+
email,
|
|
914
|
+
tags,
|
|
915
|
+
url
|
|
916
|
+
};
|
|
917
|
+
fs5.writeFileSync(configPath, JSON.stringify(config, null, 2) + "\n");
|
|
918
|
+
console.log("");
|
|
919
|
+
printSuccess(`Saved ${CONFIG_FILENAME2}`);
|
|
920
|
+
console.log("");
|
|
921
|
+
printInfo("Name", config.name || "(not set)");
|
|
922
|
+
printInfo("Description", config.description || "(not set)");
|
|
923
|
+
printInfo("Owner", config.owner || "(not set)");
|
|
924
|
+
printInfo("Company", config.company || "(not set)");
|
|
925
|
+
printInfo("Email", config.email || "(not set)");
|
|
926
|
+
printInfo(
|
|
927
|
+
"Tags",
|
|
928
|
+
config.tags.length > 0 ? config.tags.join(", ") : "(none)"
|
|
929
|
+
);
|
|
930
|
+
printInfo("URL", config.url || "(not set)");
|
|
931
|
+
console.log("");
|
|
932
|
+
} finally {
|
|
933
|
+
rl.close();
|
|
934
|
+
}
|
|
935
|
+
}
|
|
936
|
+
async function ask2(rl, label, defaultValue) {
|
|
937
|
+
const hint = defaultValue ? ` [${defaultValue}]` : "";
|
|
938
|
+
const answer = await rl.question(` ${label}${hint}: `);
|
|
939
|
+
return answer.trim() || defaultValue;
|
|
940
|
+
}
|
|
941
|
+
|
|
942
|
+
// src/commands/deploy.ts
|
|
943
|
+
import { execSync as execSync2 } from "child_process";
|
|
944
|
+
import crypto2 from "crypto";
|
|
945
|
+
import fs6 from "fs";
|
|
946
|
+
import os2 from "os";
|
|
947
|
+
import path5 from "path";
|
|
948
|
+
import readline3 from "readline/promises";
|
|
949
|
+
import { createRequire } from "module";
|
|
950
|
+
import { ZooidClient as ZooidClient2 } from "@zooid/sdk";
|
|
951
|
+
var require2 = createRequire(import.meta.url);
|
|
952
|
+
function resolvePackageDir(packageName) {
|
|
953
|
+
const pkgJson = require2.resolve(`${packageName}/package.json`);
|
|
954
|
+
return path5.dirname(pkgJson);
|
|
955
|
+
}
|
|
956
|
+
function prepareStagingDir() {
|
|
957
|
+
const serverDir = resolvePackageDir("@zooid/server");
|
|
958
|
+
const webDir = resolvePackageDir("@zooid/web");
|
|
959
|
+
const webDistDir = path5.join(webDir, "dist");
|
|
960
|
+
if (!fs6.existsSync(path5.join(serverDir, "wrangler.toml"))) {
|
|
961
|
+
throw new Error(`Server package missing wrangler.toml at ${serverDir}`);
|
|
962
|
+
}
|
|
963
|
+
if (!fs6.existsSync(webDistDir)) {
|
|
964
|
+
throw new Error(`Web dashboard not built. Missing: ${webDistDir}`);
|
|
965
|
+
}
|
|
966
|
+
const tmpDir = fs6.mkdtempSync(path5.join(os2.tmpdir(), "zooid-deploy-"));
|
|
967
|
+
copyDirSync(path5.join(serverDir, "src"), path5.join(tmpDir, "src"));
|
|
968
|
+
copyDirSync(webDistDir, path5.join(tmpDir, "web-dist"));
|
|
969
|
+
let toml = fs6.readFileSync(path5.join(serverDir, "wrangler.toml"), "utf-8");
|
|
970
|
+
toml = toml.replace(/directory\s*=\s*"[^"]*"/, 'directory = "./web-dist/"');
|
|
971
|
+
fs6.writeFileSync(path5.join(tmpDir, "wrangler.toml"), toml);
|
|
972
|
+
const nodeModules = findServerNodeModules(serverDir);
|
|
973
|
+
if (nodeModules) {
|
|
974
|
+
fs6.symlinkSync(nodeModules, path5.join(tmpDir, "node_modules"), "junction");
|
|
975
|
+
}
|
|
976
|
+
return tmpDir;
|
|
977
|
+
}
|
|
978
|
+
function findServerNodeModules(serverDir) {
|
|
979
|
+
const local = path5.join(serverDir, "node_modules");
|
|
980
|
+
if (fs6.existsSync(path5.join(local, "hono"))) return local;
|
|
981
|
+
const storeNodeModules = path5.resolve(serverDir, "..", "..");
|
|
982
|
+
if (fs6.existsSync(path5.join(storeNodeModules, "hono")))
|
|
983
|
+
return storeNodeModules;
|
|
984
|
+
let dir = serverDir;
|
|
985
|
+
while (dir !== path5.dirname(dir)) {
|
|
986
|
+
dir = path5.dirname(dir);
|
|
987
|
+
const nm = path5.join(dir, "node_modules");
|
|
988
|
+
if (fs6.existsSync(path5.join(nm, "hono"))) return nm;
|
|
989
|
+
}
|
|
990
|
+
return null;
|
|
991
|
+
}
|
|
992
|
+
function copyDirSync(src, dest) {
|
|
993
|
+
fs6.mkdirSync(dest, { recursive: true });
|
|
994
|
+
for (const entry of fs6.readdirSync(src, { withFileTypes: true })) {
|
|
995
|
+
const srcPath = path5.join(src, entry.name);
|
|
996
|
+
const destPath = path5.join(dest, entry.name);
|
|
997
|
+
if (entry.isDirectory()) {
|
|
998
|
+
copyDirSync(srcPath, destPath);
|
|
999
|
+
} else {
|
|
1000
|
+
fs6.copyFileSync(srcPath, destPath);
|
|
1001
|
+
}
|
|
1002
|
+
}
|
|
1003
|
+
}
|
|
1004
|
+
function base64url2(buf) {
|
|
1005
|
+
return buf.toString("base64url");
|
|
1006
|
+
}
|
|
1007
|
+
async function createAdminToken2(secret) {
|
|
1008
|
+
const header = base64url2(
|
|
1009
|
+
Buffer.from(JSON.stringify({ alg: "HS256", typ: "JWT" }))
|
|
1010
|
+
);
|
|
1011
|
+
const payload = base64url2(
|
|
1012
|
+
Buffer.from(
|
|
1013
|
+
JSON.stringify({
|
|
1014
|
+
scope: "admin",
|
|
1015
|
+
iat: Math.floor(Date.now() / 1e3)
|
|
1016
|
+
})
|
|
1017
|
+
)
|
|
1018
|
+
);
|
|
1019
|
+
const data = `${header}.${payload}`;
|
|
1020
|
+
const key = await crypto2.subtle.importKey(
|
|
1021
|
+
"raw",
|
|
1022
|
+
new TextEncoder().encode(secret),
|
|
1023
|
+
{ name: "HMAC", hash: "SHA-256" },
|
|
1024
|
+
false,
|
|
1025
|
+
["sign"]
|
|
1026
|
+
);
|
|
1027
|
+
const sig = await crypto2.subtle.sign(
|
|
1028
|
+
"HMAC",
|
|
1029
|
+
key,
|
|
1030
|
+
new TextEncoder().encode(data)
|
|
1031
|
+
);
|
|
1032
|
+
const signature = base64url2(Buffer.from(sig));
|
|
1033
|
+
return `${data}.${signature}`;
|
|
1034
|
+
}
|
|
1035
|
+
function wrangler(cmd, cwd, creds, opts) {
|
|
1036
|
+
const env = {
|
|
1037
|
+
...process.env,
|
|
1038
|
+
CLOUDFLARE_API_TOKEN: creds.apiToken
|
|
1039
|
+
};
|
|
1040
|
+
if (creds.accountId) {
|
|
1041
|
+
env.CLOUDFLARE_ACCOUNT_ID = creds.accountId;
|
|
1042
|
+
}
|
|
1043
|
+
return execSync2(`npx wrangler ${cmd}`, {
|
|
1044
|
+
cwd,
|
|
1045
|
+
stdio: "pipe",
|
|
1046
|
+
encoding: "utf-8",
|
|
1047
|
+
env,
|
|
1048
|
+
input: opts?.input
|
|
1049
|
+
});
|
|
1050
|
+
}
|
|
1051
|
+
function parseDeployUrls(output) {
|
|
1052
|
+
const workersDev = output.match(/https:\/\/[^\s]+\.workers\.dev/);
|
|
1053
|
+
const custom = output.match(
|
|
1054
|
+
/^\s+(\S+\.(?!workers\.dev)\S+)\s+\(custom domain\)/m
|
|
1055
|
+
);
|
|
1056
|
+
return {
|
|
1057
|
+
workerUrl: workersDev ? workersDev[0] : null,
|
|
1058
|
+
customDomain: custom ? `https://${custom[1]}` : null
|
|
1059
|
+
};
|
|
1060
|
+
}
|
|
1061
|
+
function loadDotEnv() {
|
|
1062
|
+
const envPath = path5.join(process.cwd(), ".env");
|
|
1063
|
+
if (!fs6.existsSync(envPath)) return {};
|
|
1064
|
+
const content = fs6.readFileSync(envPath, "utf-8");
|
|
1065
|
+
const tokenMatch = content.match(/^CLOUDFLARE_API_TOKEN=(.+)$/m);
|
|
1066
|
+
const accountMatch = content.match(/^CLOUDFLARE_ACCOUNT_ID=(.+)$/m);
|
|
1067
|
+
return {
|
|
1068
|
+
apiToken: tokenMatch ? tokenMatch[1].trim() : void 0,
|
|
1069
|
+
accountId: accountMatch ? accountMatch[1].trim() : void 0
|
|
1070
|
+
};
|
|
1071
|
+
}
|
|
1072
|
+
async function getCfCredentials() {
|
|
1073
|
+
const envToken = process.env.CLOUDFLARE_API_TOKEN;
|
|
1074
|
+
const envAccount = process.env.CLOUDFLARE_ACCOUNT_ID;
|
|
1075
|
+
if (envToken) {
|
|
1076
|
+
return { apiToken: envToken, accountId: envAccount };
|
|
1077
|
+
}
|
|
1078
|
+
const dotEnv = loadDotEnv();
|
|
1079
|
+
if (dotEnv.apiToken) {
|
|
1080
|
+
printInfo("Using credentials from", ".env");
|
|
1081
|
+
return { apiToken: dotEnv.apiToken, accountId: dotEnv.accountId };
|
|
1082
|
+
}
|
|
1083
|
+
const rl = readline3.createInterface({
|
|
1084
|
+
input: process.stdin,
|
|
1085
|
+
output: process.stdout
|
|
1086
|
+
});
|
|
1087
|
+
try {
|
|
1088
|
+
console.log("");
|
|
1089
|
+
console.log(" Cloudflare API token required for deployment.");
|
|
1090
|
+
console.log(" Go to: https://dash.cloudflare.com/profile/api-tokens");
|
|
1091
|
+
console.log(
|
|
1092
|
+
' Use the "Edit Cloudflare Workers" template, then add D1 Edit permission.'
|
|
1093
|
+
);
|
|
1094
|
+
console.log(" Tip: save credentials in .env to skip this prompt.");
|
|
1095
|
+
console.log("");
|
|
1096
|
+
const token = await rl.question(" API token: ");
|
|
1097
|
+
const accountId = await rl.question(
|
|
1098
|
+
" Account ID (from the dashboard URL or Workers overview): "
|
|
1099
|
+
);
|
|
1100
|
+
return {
|
|
1101
|
+
apiToken: token.trim(),
|
|
1102
|
+
accountId: accountId.trim() || void 0
|
|
1103
|
+
};
|
|
1104
|
+
} finally {
|
|
1105
|
+
rl.close();
|
|
1106
|
+
}
|
|
1107
|
+
}
|
|
1108
|
+
async function runDeploy() {
|
|
1109
|
+
let config = loadServerConfig();
|
|
1110
|
+
if (!config) {
|
|
1111
|
+
printInfo("No zooid.json found", "starting setup...");
|
|
1112
|
+
console.log("");
|
|
1113
|
+
await runInit();
|
|
1114
|
+
config = loadServerConfig();
|
|
1115
|
+
}
|
|
1116
|
+
if (!config) {
|
|
1117
|
+
printError("Failed to load zooid.json after init");
|
|
1118
|
+
process.exit(1);
|
|
1119
|
+
}
|
|
1120
|
+
let stagingDir;
|
|
1121
|
+
try {
|
|
1122
|
+
stagingDir = prepareStagingDir();
|
|
1123
|
+
} catch (err) {
|
|
1124
|
+
printError(err.message);
|
|
1125
|
+
process.exit(1);
|
|
1126
|
+
}
|
|
1127
|
+
const serverSlug = config.name.toLowerCase().replace(/[^a-z0-9-]/g, "-").replace(/-+/g, "-");
|
|
1128
|
+
const dbName = `zooid-db-${serverSlug}`;
|
|
1129
|
+
const workerName = `zooid-${serverSlug}`;
|
|
1130
|
+
try {
|
|
1131
|
+
execSync2("npx wrangler --version", { cwd: stagingDir, stdio: "pipe" });
|
|
1132
|
+
} catch {
|
|
1133
|
+
printError("wrangler not found. Install with: npm install -g wrangler");
|
|
1134
|
+
process.exit(1);
|
|
1135
|
+
}
|
|
1136
|
+
const creds = await getCfCredentials();
|
|
1137
|
+
try {
|
|
1138
|
+
wrangler("whoami", stagingDir, creds);
|
|
1139
|
+
printSuccess("Cloudflare authentication verified");
|
|
1140
|
+
} catch {
|
|
1141
|
+
printError("Invalid Cloudflare API token");
|
|
1142
|
+
cleanup(stagingDir);
|
|
1143
|
+
process.exit(1);
|
|
1144
|
+
}
|
|
1145
|
+
let isFirstDeploy = false;
|
|
1146
|
+
try {
|
|
1147
|
+
const output = wrangler("d1 list --json", stagingDir, creds);
|
|
1148
|
+
const databases = JSON.parse(output);
|
|
1149
|
+
isFirstDeploy = !databases.some((db) => db.name === dbName);
|
|
1150
|
+
} catch {
|
|
1151
|
+
isFirstDeploy = true;
|
|
1152
|
+
}
|
|
1153
|
+
let adminToken;
|
|
1154
|
+
if (isFirstDeploy) {
|
|
1155
|
+
console.log("");
|
|
1156
|
+
printInfo("Deploy type", "First deploy \u2014 setting up database and secrets");
|
|
1157
|
+
console.log("");
|
|
1158
|
+
console.log(`Creating D1 database (${dbName})...`);
|
|
1159
|
+
const d1Output = wrangler(`d1 create ${dbName}`, stagingDir, creds);
|
|
1160
|
+
const dbIdMatch = d1Output.match(/database_id\s*=\s*"([^"]+)"/);
|
|
1161
|
+
if (!dbIdMatch) {
|
|
1162
|
+
printError("Failed to parse database ID from wrangler output");
|
|
1163
|
+
console.log(d1Output);
|
|
1164
|
+
cleanup(stagingDir);
|
|
1165
|
+
process.exit(1);
|
|
1166
|
+
}
|
|
1167
|
+
const databaseId = dbIdMatch[1];
|
|
1168
|
+
printSuccess(`D1 database created (${databaseId})`);
|
|
1169
|
+
const wranglerTomlPath = path5.join(stagingDir, "wrangler.toml");
|
|
1170
|
+
let tomlContent = fs6.readFileSync(wranglerTomlPath, "utf-8");
|
|
1171
|
+
tomlContent = tomlContent.replace(
|
|
1172
|
+
/name = "[^"]*"/,
|
|
1173
|
+
`name = "${workerName}"`
|
|
1174
|
+
);
|
|
1175
|
+
tomlContent = tomlContent.replace(
|
|
1176
|
+
/database_name = "[^"]*"/,
|
|
1177
|
+
`database_name = "${dbName}"`
|
|
1178
|
+
);
|
|
1179
|
+
tomlContent = tomlContent.replace(
|
|
1180
|
+
/database_id = "[^"]*"/,
|
|
1181
|
+
`database_id = "${databaseId}"`
|
|
1182
|
+
);
|
|
1183
|
+
tomlContent = tomlContent.replace(
|
|
1184
|
+
/ZOOID_SERVER_ID = "[^"]*"/,
|
|
1185
|
+
`ZOOID_SERVER_ID = "${serverSlug}"`
|
|
1186
|
+
);
|
|
1187
|
+
fs6.writeFileSync(wranglerTomlPath, tomlContent);
|
|
1188
|
+
printSuccess("Configured wrangler.toml");
|
|
1189
|
+
const schemaPath = path5.join(stagingDir, "src/db/schema.sql");
|
|
1190
|
+
if (fs6.existsSync(schemaPath)) {
|
|
1191
|
+
console.log("Running database schema migration...");
|
|
1192
|
+
wrangler(
|
|
1193
|
+
`d1 execute ${dbName} --remote --file=${schemaPath}`,
|
|
1194
|
+
stagingDir,
|
|
1195
|
+
creds
|
|
1196
|
+
);
|
|
1197
|
+
printSuccess("Database schema initialized");
|
|
1198
|
+
}
|
|
1199
|
+
console.log("Generating secrets...");
|
|
1200
|
+
const jwtSecret = crypto2.randomBytes(32).toString("base64");
|
|
1201
|
+
const keyPair = await crypto2.subtle.generateKey("Ed25519", true, [
|
|
1202
|
+
"sign",
|
|
1203
|
+
"verify"
|
|
1204
|
+
]);
|
|
1205
|
+
const privateKeyRaw = await crypto2.subtle.exportKey(
|
|
1206
|
+
"pkcs8",
|
|
1207
|
+
keyPair.privateKey
|
|
1208
|
+
);
|
|
1209
|
+
const publicKeyRaw = await crypto2.subtle.exportKey(
|
|
1210
|
+
"raw",
|
|
1211
|
+
keyPair.publicKey
|
|
1212
|
+
);
|
|
1213
|
+
const privateKeyB64 = Buffer.from(privateKeyRaw).toString("base64");
|
|
1214
|
+
const publicKeyB64 = Buffer.from(publicKeyRaw).toString("base64");
|
|
1215
|
+
wrangler("secret put ZOOID_JWT_SECRET", stagingDir, creds, {
|
|
1216
|
+
input: jwtSecret
|
|
1217
|
+
});
|
|
1218
|
+
printSuccess("Set ZOOID_JWT_SECRET");
|
|
1219
|
+
wrangler("secret put ZOOID_SIGNING_KEY", stagingDir, creds, {
|
|
1220
|
+
input: privateKeyB64
|
|
1221
|
+
});
|
|
1222
|
+
printSuccess("Set ZOOID_SIGNING_KEY (Ed25519 private)");
|
|
1223
|
+
wrangler("secret put ZOOID_PUBLIC_KEY", stagingDir, creds, {
|
|
1224
|
+
input: publicKeyB64
|
|
1225
|
+
});
|
|
1226
|
+
printSuccess("Set ZOOID_PUBLIC_KEY (Ed25519 public)");
|
|
1227
|
+
adminToken = await createAdminToken2(jwtSecret);
|
|
1228
|
+
printSuccess("Admin token generated");
|
|
1229
|
+
} else {
|
|
1230
|
+
console.log("");
|
|
1231
|
+
printInfo("Deploy type", "Redeploying existing server");
|
|
1232
|
+
console.log("");
|
|
1233
|
+
const wranglerTomlPath = path5.join(stagingDir, "wrangler.toml");
|
|
1234
|
+
let tomlContent = fs6.readFileSync(wranglerTomlPath, "utf-8");
|
|
1235
|
+
tomlContent = tomlContent.replace(
|
|
1236
|
+
/name = "[^"]*"/,
|
|
1237
|
+
`name = "${workerName}"`
|
|
1238
|
+
);
|
|
1239
|
+
tomlContent = tomlContent.replace(
|
|
1240
|
+
/ZOOID_SERVER_ID = "[^"]*"/,
|
|
1241
|
+
`ZOOID_SERVER_ID = "${serverSlug}"`
|
|
1242
|
+
);
|
|
1243
|
+
try {
|
|
1244
|
+
const output = wrangler("d1 list --json", stagingDir, creds);
|
|
1245
|
+
const databases = JSON.parse(output);
|
|
1246
|
+
const db = databases.find((d) => d.name === dbName);
|
|
1247
|
+
if (db) {
|
|
1248
|
+
tomlContent = tomlContent.replace(
|
|
1249
|
+
/database_name = "[^"]*"/,
|
|
1250
|
+
`database_name = "${dbName}"`
|
|
1251
|
+
);
|
|
1252
|
+
tomlContent = tomlContent.replace(
|
|
1253
|
+
/database_id = "[^"]*"/,
|
|
1254
|
+
`database_id = "${db.uuid}"`
|
|
1255
|
+
);
|
|
1256
|
+
}
|
|
1257
|
+
} catch {
|
|
1258
|
+
}
|
|
1259
|
+
fs6.writeFileSync(wranglerTomlPath, tomlContent);
|
|
1260
|
+
const existingConfig = loadConfig();
|
|
1261
|
+
adminToken = existingConfig.admin_token;
|
|
1262
|
+
if (!adminToken) {
|
|
1263
|
+
printError(
|
|
1264
|
+
"No admin token found in ~/.zooid/config.json for this server"
|
|
1265
|
+
);
|
|
1266
|
+
console.log(
|
|
1267
|
+
"If this is a first deploy, remove the D1 database and try again."
|
|
1268
|
+
);
|
|
1269
|
+
cleanup(stagingDir);
|
|
1270
|
+
process.exit(1);
|
|
1271
|
+
}
|
|
1272
|
+
}
|
|
1273
|
+
console.log("Deploying worker...");
|
|
1274
|
+
const deployOutput = wrangler("deploy", stagingDir, creds);
|
|
1275
|
+
const { workerUrl, customDomain } = parseDeployUrls(deployOutput);
|
|
1276
|
+
printSuccess("Worker deployed");
|
|
1277
|
+
if (workerUrl) {
|
|
1278
|
+
printInfo("Worker URL", workerUrl);
|
|
1279
|
+
}
|
|
1280
|
+
if (customDomain) {
|
|
1281
|
+
printInfo("Custom domain", customDomain);
|
|
1282
|
+
}
|
|
1283
|
+
const canonicalUrl = config.url || customDomain || workerUrl;
|
|
1284
|
+
await new Promise((r) => setTimeout(r, 2e3));
|
|
1285
|
+
if (canonicalUrl && adminToken) {
|
|
1286
|
+
try {
|
|
1287
|
+
const client = new ZooidClient2({
|
|
1288
|
+
server: canonicalUrl,
|
|
1289
|
+
token: adminToken
|
|
1290
|
+
});
|
|
1291
|
+
await client.updateServerMeta({
|
|
1292
|
+
name: config.name || void 0,
|
|
1293
|
+
description: config.description || void 0,
|
|
1294
|
+
tags: config.tags.length > 0 ? config.tags : void 0,
|
|
1295
|
+
owner: config.owner || void 0,
|
|
1296
|
+
company: config.company || void 0,
|
|
1297
|
+
email: config.email || void 0
|
|
1298
|
+
});
|
|
1299
|
+
printSuccess("Server identity updated");
|
|
1300
|
+
} catch (err) {
|
|
1301
|
+
printError(
|
|
1302
|
+
`Failed to push server identity: ${err instanceof Error ? err.message : err}`
|
|
1303
|
+
);
|
|
1304
|
+
}
|
|
1305
|
+
}
|
|
1306
|
+
if (!config.url && (customDomain || workerUrl)) {
|
|
1307
|
+
config.url = customDomain || workerUrl;
|
|
1308
|
+
saveServerConfig(config);
|
|
1309
|
+
printSuccess("Saved URL to zooid.json");
|
|
1310
|
+
}
|
|
1311
|
+
const configToSave = {
|
|
1312
|
+
worker_url: workerUrl || void 0,
|
|
1313
|
+
admin_token: adminToken
|
|
1314
|
+
};
|
|
1315
|
+
if (isFirstDeploy) {
|
|
1316
|
+
configToSave.channels = {};
|
|
1317
|
+
}
|
|
1318
|
+
saveConfig(configToSave, canonicalUrl || void 0);
|
|
1319
|
+
printSuccess("Saved connection config to ~/.zooid/config.json");
|
|
1320
|
+
cleanup(stagingDir);
|
|
1321
|
+
console.log("");
|
|
1322
|
+
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");
|
|
1323
|
+
console.log(" \u{1FAB8} Zooid server deployed!");
|
|
1324
|
+
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");
|
|
1325
|
+
printInfo("Server", canonicalUrl || "(unknown)");
|
|
1326
|
+
if (workerUrl && workerUrl !== canonicalUrl) {
|
|
1327
|
+
printInfo("Worker URL", workerUrl);
|
|
1328
|
+
}
|
|
1329
|
+
printInfo("Name", config.name || "(not set)");
|
|
1330
|
+
if (isFirstDeploy) {
|
|
1331
|
+
printInfo("Admin token", adminToken.slice(0, 20) + "...");
|
|
1332
|
+
}
|
|
1333
|
+
printInfo("Config", "~/.zooid/config.json");
|
|
1334
|
+
console.log("");
|
|
1335
|
+
if (isFirstDeploy) {
|
|
1336
|
+
console.log(" Next steps:");
|
|
1337
|
+
console.log(" npx zooid channel create my-channel");
|
|
1338
|
+
console.log(
|
|
1339
|
+
` npx zooid publish my-channel --data='{"hello": "world"}'`
|
|
1340
|
+
);
|
|
1341
|
+
console.log("");
|
|
1342
|
+
}
|
|
1343
|
+
}
|
|
1344
|
+
function cleanup(dir) {
|
|
1345
|
+
try {
|
|
1346
|
+
fs6.rmSync(dir, { recursive: true, force: true });
|
|
1347
|
+
} catch {
|
|
1348
|
+
}
|
|
1349
|
+
}
|
|
1350
|
+
|
|
1351
|
+
// src/index.ts
|
|
1352
|
+
var program = new Command();
|
|
1353
|
+
program.name("zooid").description("\u{1FAB8} Pub/sub for AI agents").version("0.0.0");
|
|
1354
|
+
var telemetryCtx = { startTime: 0 };
|
|
1355
|
+
function setTelemetryChannel(channelId) {
|
|
1356
|
+
telemetryCtx.channelId = channelId;
|
|
1357
|
+
const config = loadConfig();
|
|
1358
|
+
const channelTokens = config.channels?.[channelId];
|
|
1359
|
+
const hasChannelToken = !!(channelTokens?.publish_token || channelTokens?.subscribe_token);
|
|
1360
|
+
telemetryCtx.usedToken = hasChannelToken || !!config.admin_token;
|
|
1361
|
+
}
|
|
1362
|
+
function getCommandPath(cmd) {
|
|
1363
|
+
const parts = [];
|
|
1364
|
+
let current = cmd;
|
|
1365
|
+
while (current && current !== program) {
|
|
1366
|
+
parts.unshift(current.name());
|
|
1367
|
+
current = current.parent;
|
|
1368
|
+
}
|
|
1369
|
+
return parts.join(" ");
|
|
1370
|
+
}
|
|
1371
|
+
program.hook("preAction", () => {
|
|
1372
|
+
telemetryCtx.startTime = Date.now();
|
|
1373
|
+
if (isEnabled()) {
|
|
1374
|
+
showNoticeIfNeeded();
|
|
1375
|
+
}
|
|
1376
|
+
});
|
|
1377
|
+
program.hook("postAction", (thisCommand) => {
|
|
1378
|
+
if (!isEnabled()) return;
|
|
1379
|
+
try {
|
|
1380
|
+
const event = {
|
|
1381
|
+
install_id: getInstallId(),
|
|
1382
|
+
command: getCommandPath(thisCommand),
|
|
1383
|
+
exit_code: 0,
|
|
1384
|
+
duration_ms: Date.now() - telemetryCtx.startTime,
|
|
1385
|
+
cli_version: program.version() ?? "0.0.0",
|
|
1386
|
+
os: process.platform,
|
|
1387
|
+
arch: process.arch,
|
|
1388
|
+
node_version: process.version,
|
|
1389
|
+
ts: (/* @__PURE__ */ new Date()).toISOString()
|
|
1390
|
+
};
|
|
1391
|
+
if (telemetryCtx.channelId && !telemetryCtx.usedToken) {
|
|
1392
|
+
event.channel_id = telemetryCtx.channelId;
|
|
1393
|
+
const config = loadConfig();
|
|
1394
|
+
if (config.server) event.server_url = config.server;
|
|
1395
|
+
}
|
|
1396
|
+
writeEvent(event);
|
|
1397
|
+
flushInBackground();
|
|
1398
|
+
} catch {
|
|
1399
|
+
}
|
|
1400
|
+
telemetryCtx.channelId = void 0;
|
|
1401
|
+
telemetryCtx.usedToken = void 0;
|
|
1402
|
+
});
|
|
1403
|
+
program.command("dev").description("Start local development server").option("--port <port>", "Server port", "8787").action(async (opts) => {
|
|
1404
|
+
try {
|
|
1405
|
+
await runDev(parseInt(opts.port, 10));
|
|
1406
|
+
} catch (err) {
|
|
1407
|
+
printError(err.message);
|
|
1408
|
+
process.exit(1);
|
|
1409
|
+
}
|
|
1410
|
+
});
|
|
1411
|
+
program.command("init").description("Create zooid-server.json with server identity").action(async () => {
|
|
1412
|
+
try {
|
|
1413
|
+
await runInit();
|
|
1414
|
+
} catch (err) {
|
|
1415
|
+
printError(err.message);
|
|
1416
|
+
process.exit(1);
|
|
1417
|
+
}
|
|
1418
|
+
});
|
|
1419
|
+
program.command("deploy").description("Deploy Zooid server to Cloudflare Workers").action(async () => {
|
|
1420
|
+
try {
|
|
1421
|
+
await runDeploy();
|
|
1422
|
+
} catch (err) {
|
|
1423
|
+
printError(err.message);
|
|
1424
|
+
process.exit(1);
|
|
1425
|
+
}
|
|
1426
|
+
});
|
|
1427
|
+
var configCmd = program.command("config").description("Manage Zooid configuration");
|
|
1428
|
+
configCmd.command("set <key> <value>").description("Set a config value (server, admin-token, telemetry)").action((key, value) => {
|
|
1429
|
+
try {
|
|
1430
|
+
runConfigSet(key, value);
|
|
1431
|
+
printSuccess(`Set ${key}`);
|
|
1432
|
+
} catch (err) {
|
|
1433
|
+
printError(err.message);
|
|
1434
|
+
process.exit(1);
|
|
1435
|
+
}
|
|
1436
|
+
});
|
|
1437
|
+
configCmd.command("get <key>").description("Get a config value").action((key) => {
|
|
1438
|
+
try {
|
|
1439
|
+
const value = runConfigGet(key);
|
|
1440
|
+
if (value) {
|
|
1441
|
+
console.log(value);
|
|
1442
|
+
} else {
|
|
1443
|
+
console.log("(not set)");
|
|
1444
|
+
}
|
|
1445
|
+
} catch (err) {
|
|
1446
|
+
printError(err.message);
|
|
1447
|
+
process.exit(1);
|
|
1448
|
+
}
|
|
1449
|
+
});
|
|
1450
|
+
var channelCmd = program.command("channel").description("Manage channels");
|
|
1451
|
+
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 (default)", true).option("--private", "Make channel private").option("--strict", "Enable strict schema validation on publish").option("--schema <file>", "Path to JSON schema file").action(async (id, opts) => {
|
|
1452
|
+
try {
|
|
1453
|
+
let schema;
|
|
1454
|
+
if (opts.schema) {
|
|
1455
|
+
const fs7 = await import("fs");
|
|
1456
|
+
const raw = fs7.readFileSync(opts.schema, "utf-8");
|
|
1457
|
+
schema = JSON.parse(raw);
|
|
1458
|
+
}
|
|
1459
|
+
const result = await runChannelCreate(id, {
|
|
1460
|
+
name: opts.name,
|
|
1461
|
+
description: opts.description,
|
|
1462
|
+
public: opts.private ? false : true,
|
|
1463
|
+
strict: opts.strict,
|
|
1464
|
+
schema
|
|
1465
|
+
});
|
|
1466
|
+
printSuccess(`Created channel: ${id}`);
|
|
1467
|
+
printInfo("Publish token", result.publish_token);
|
|
1468
|
+
printInfo("Subscribe token", result.subscribe_token);
|
|
1469
|
+
} catch (err) {
|
|
1470
|
+
printError(err.message);
|
|
1471
|
+
process.exit(1);
|
|
1472
|
+
}
|
|
1473
|
+
});
|
|
1474
|
+
channelCmd.command("list").description("List all channels").action(async () => {
|
|
1475
|
+
try {
|
|
1476
|
+
const channels = await runChannelList();
|
|
1477
|
+
if (channels.length === 0) {
|
|
1478
|
+
console.log(
|
|
1479
|
+
"No channels yet. Create one with: npx zooid channel create <name>"
|
|
1480
|
+
);
|
|
1481
|
+
} else {
|
|
1482
|
+
for (const ch of channels) {
|
|
1483
|
+
const visibility = ch.is_public ? "public" : "private";
|
|
1484
|
+
console.log(
|
|
1485
|
+
` ${ch.id} \u2014 ${ch.name} (${visibility}, ${ch.event_count} events)`
|
|
1486
|
+
);
|
|
1487
|
+
}
|
|
1488
|
+
}
|
|
1489
|
+
} catch (err) {
|
|
1490
|
+
printError(err.message);
|
|
1491
|
+
process.exit(1);
|
|
1492
|
+
}
|
|
1493
|
+
});
|
|
1494
|
+
channelCmd.command("add-publisher <channel>").description("Add a publisher to a channel").requiredOption("--name <name>", "Publisher name").action(async (channel, opts) => {
|
|
1495
|
+
try {
|
|
1496
|
+
const result = await runChannelAddPublisher(channel, opts.name);
|
|
1497
|
+
printSuccess(`Added publisher: ${result.name}`);
|
|
1498
|
+
printInfo("Publisher ID", result.id);
|
|
1499
|
+
printInfo("Publish token", result.publish_token);
|
|
1500
|
+
} catch (err) {
|
|
1501
|
+
printError(err.message);
|
|
1502
|
+
process.exit(1);
|
|
1503
|
+
}
|
|
1504
|
+
});
|
|
1505
|
+
program.command("publish <channel>").description("Publish an event to a channel").option("--type <type>", "Event type").option("--data <json>", "Event data as JSON string").option("--file <path>", "Read event from JSON file").action(async (channel, opts) => {
|
|
1506
|
+
setTelemetryChannel(channel);
|
|
1507
|
+
try {
|
|
1508
|
+
const event = await runPublish(channel, opts);
|
|
1509
|
+
printSuccess(`Published event: ${event.id}`);
|
|
1510
|
+
} catch (err) {
|
|
1511
|
+
printError(err.message);
|
|
1512
|
+
process.exit(1);
|
|
1513
|
+
}
|
|
1514
|
+
});
|
|
1515
|
+
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(
|
|
1516
|
+
"--mode <mode>",
|
|
1517
|
+
"Transport mode for follow: auto, ws, or poll",
|
|
1518
|
+
"auto"
|
|
1519
|
+
).option("--interval <ms>", "Poll interval in ms for follow mode", "5000").action(async (channel, opts) => {
|
|
1520
|
+
setTelemetryChannel(channel);
|
|
1521
|
+
try {
|
|
1522
|
+
if (opts.follow) {
|
|
1523
|
+
const mode = opts.mode;
|
|
1524
|
+
const transport = mode === "auto" ? "auto (WebSocket \u2192 poll fallback)" : mode;
|
|
1525
|
+
console.log(
|
|
1526
|
+
`Tailing ${channel} [${transport}]${opts.type ? ` type=${opts.type}` : ""}...`
|
|
1527
|
+
);
|
|
1528
|
+
console.log("Press Ctrl+C to stop.\n");
|
|
1529
|
+
await runTail(channel, {
|
|
1530
|
+
follow: true,
|
|
1531
|
+
mode,
|
|
1532
|
+
interval: parseInt(opts.interval, 10),
|
|
1533
|
+
type: opts.type
|
|
1534
|
+
});
|
|
1535
|
+
} else {
|
|
1536
|
+
const result = await runTail(channel, {
|
|
1537
|
+
limit: parseInt(opts.limit, 10),
|
|
1538
|
+
type: opts.type,
|
|
1539
|
+
since: opts.since,
|
|
1540
|
+
cursor: opts.cursor
|
|
1541
|
+
});
|
|
1542
|
+
if (result.events.length === 0) {
|
|
1543
|
+
console.log("No events.");
|
|
1544
|
+
} else {
|
|
1545
|
+
for (const event of result.events) {
|
|
1546
|
+
console.log(JSON.stringify(event));
|
|
1547
|
+
}
|
|
1548
|
+
}
|
|
1549
|
+
if (result.cursor) {
|
|
1550
|
+
printInfo("Cursor", result.cursor);
|
|
1551
|
+
}
|
|
1552
|
+
}
|
|
1553
|
+
} catch (err) {
|
|
1554
|
+
printError(err.message);
|
|
1555
|
+
process.exit(1);
|
|
1556
|
+
}
|
|
1557
|
+
});
|
|
1558
|
+
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").action(async (channel, opts) => {
|
|
1559
|
+
setTelemetryChannel(channel);
|
|
1560
|
+
try {
|
|
1561
|
+
if (opts.webhook) {
|
|
1562
|
+
const wh = await runSubscribeWebhook(channel, opts.webhook);
|
|
1563
|
+
printSuccess(`Registered webhook: ${wh.id}`);
|
|
1564
|
+
printInfo("URL", wh.url);
|
|
1565
|
+
printInfo("Expires", wh.expires_at);
|
|
1566
|
+
} else {
|
|
1567
|
+
const mode = opts.mode;
|
|
1568
|
+
const transport = mode === "auto" ? "auto (WebSocket \u2192 poll fallback)" : mode;
|
|
1569
|
+
console.log(
|
|
1570
|
+
`Subscribing to ${channel} [${transport}]${opts.type ? ` type=${opts.type}` : ""}...`
|
|
1571
|
+
);
|
|
1572
|
+
console.log("Press Ctrl+C to stop.\n");
|
|
1573
|
+
await runSubscribePoll(channel, {
|
|
1574
|
+
interval: parseInt(opts.interval, 10),
|
|
1575
|
+
mode,
|
|
1576
|
+
type: opts.type
|
|
1577
|
+
});
|
|
1578
|
+
await new Promise(() => {
|
|
1579
|
+
});
|
|
1580
|
+
}
|
|
1581
|
+
} catch (err) {
|
|
1582
|
+
printError(err.message);
|
|
1583
|
+
process.exit(1);
|
|
1584
|
+
}
|
|
1585
|
+
});
|
|
1586
|
+
var serverCmd = program.command("server").description("Manage server metadata");
|
|
1587
|
+
serverCmd.command("get").description("Show server metadata").action(async () => {
|
|
1588
|
+
try {
|
|
1589
|
+
const meta = await runServerGet();
|
|
1590
|
+
console.log(`
|
|
1591
|
+
${meta.name}
|
|
1592
|
+
`);
|
|
1593
|
+
if (meta.description) printInfo("Description", meta.description);
|
|
1594
|
+
if (meta.tags.length > 0) printInfo("Tags", meta.tags.join(", "));
|
|
1595
|
+
if (meta.owner) printInfo("Owner", meta.owner);
|
|
1596
|
+
if (meta.company) printInfo("Company", meta.company);
|
|
1597
|
+
if (meta.email) printInfo("Email", meta.email);
|
|
1598
|
+
printInfo("Updated", meta.updated_at);
|
|
1599
|
+
console.log("");
|
|
1600
|
+
} catch (err) {
|
|
1601
|
+
printError(err.message);
|
|
1602
|
+
process.exit(1);
|
|
1603
|
+
}
|
|
1604
|
+
});
|
|
1605
|
+
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) => {
|
|
1606
|
+
try {
|
|
1607
|
+
const fields = {};
|
|
1608
|
+
if (opts.name !== void 0) fields.name = opts.name;
|
|
1609
|
+
if (opts.description !== void 0) fields.description = opts.description;
|
|
1610
|
+
if (opts.tags !== void 0)
|
|
1611
|
+
fields.tags = opts.tags.split(",").map((t) => t.trim());
|
|
1612
|
+
if (opts.owner !== void 0) fields.owner = opts.owner;
|
|
1613
|
+
if (opts.company !== void 0) fields.company = opts.company;
|
|
1614
|
+
if (opts.email !== void 0) fields.email = opts.email;
|
|
1615
|
+
if (Object.keys(fields).length === 0) {
|
|
1616
|
+
printError(
|
|
1617
|
+
"No fields specified. Use --name, --description, --tags, --owner, --company, or --email."
|
|
1618
|
+
);
|
|
1619
|
+
process.exit(1);
|
|
1620
|
+
}
|
|
1621
|
+
const meta = await runServerSet(fields);
|
|
1622
|
+
printSuccess(`Updated server metadata`);
|
|
1623
|
+
printInfo("Name", meta.name);
|
|
1624
|
+
console.log("");
|
|
1625
|
+
} catch (err) {
|
|
1626
|
+
printError(err.message);
|
|
1627
|
+
process.exit(1);
|
|
1628
|
+
}
|
|
1629
|
+
});
|
|
1630
|
+
program.command("status").description("Check server status").action(async () => {
|
|
1631
|
+
try {
|
|
1632
|
+
const { discovery, identity } = await runStatus();
|
|
1633
|
+
console.log(`
|
|
1634
|
+
${identity.name} v${discovery.version}
|
|
1635
|
+
`);
|
|
1636
|
+
printInfo("Server ID", discovery.server_id);
|
|
1637
|
+
printInfo("Algorithm", discovery.algorithm);
|
|
1638
|
+
printInfo("Poll interval", `${discovery.poll_interval}s`);
|
|
1639
|
+
printInfo("Delivery", discovery.delivery.join(", "));
|
|
1640
|
+
console.log("");
|
|
1641
|
+
} catch (err) {
|
|
1642
|
+
printError(err.message);
|
|
1643
|
+
process.exit(1);
|
|
1644
|
+
}
|
|
1645
|
+
});
|
|
1646
|
+
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) => {
|
|
1647
|
+
try {
|
|
1648
|
+
const ids = opts.channel ? [opts.channel, ...channels] : channels;
|
|
1649
|
+
await runShare(ids, { yes: opts.yes });
|
|
1650
|
+
printSuccess("Channels shared to directory");
|
|
1651
|
+
} catch (err) {
|
|
1652
|
+
printError(err.message);
|
|
1653
|
+
process.exit(1);
|
|
1654
|
+
}
|
|
1655
|
+
});
|
|
1656
|
+
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) => {
|
|
1657
|
+
try {
|
|
1658
|
+
await runDiscover({
|
|
1659
|
+
query: opts.query,
|
|
1660
|
+
tag: opts.tag,
|
|
1661
|
+
limit: parseInt(opts.limit, 10)
|
|
1662
|
+
});
|
|
1663
|
+
} catch (err) {
|
|
1664
|
+
printError(err.message);
|
|
1665
|
+
process.exit(1);
|
|
1666
|
+
}
|
|
1667
|
+
});
|
|
1668
|
+
program.command("unshare <channel>").description("Remove a channel from the Zooid Directory").action(async (channel) => {
|
|
1669
|
+
try {
|
|
1670
|
+
await runUnshare(channel);
|
|
1671
|
+
printSuccess(`Removed ${channel} from directory`);
|
|
1672
|
+
} catch (err) {
|
|
1673
|
+
printError(err.message);
|
|
1674
|
+
process.exit(1);
|
|
1675
|
+
}
|
|
1676
|
+
});
|
|
1677
|
+
program.parse();
|
|
1678
|
+
export {
|
|
1679
|
+
setTelemetryChannel
|
|
1680
|
+
};
|