zooid 0.5.0 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +202 -164
- package/dist/chunk-AR456MHY.js +29 -0
- package/dist/index.js +1965 -144
- package/dist/template-T5IB4YWC.js +92 -0
- package/package.json +5 -5
package/dist/index.js
CHANGED
|
@@ -18,6 +18,9 @@ import {
|
|
|
18
18
|
saveDirectoryToken,
|
|
19
19
|
switchServer
|
|
20
20
|
} from "./chunk-67ZRMVHO.js";
|
|
21
|
+
import {
|
|
22
|
+
parseGitHubUrl
|
|
23
|
+
} from "./chunk-AR456MHY.js";
|
|
21
24
|
|
|
22
25
|
// src/index.ts
|
|
23
26
|
import { Command } from "commander";
|
|
@@ -202,6 +205,285 @@ function runConfigGet(key) {
|
|
|
202
205
|
);
|
|
203
206
|
}
|
|
204
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
|
+
|
|
205
487
|
// src/commands/channel.ts
|
|
206
488
|
async function runChannelCreate(id, options, client) {
|
|
207
489
|
const c = client ?? createClient();
|
|
@@ -213,7 +495,7 @@ async function runChannelCreate(id, options, client) {
|
|
|
213
495
|
id,
|
|
214
496
|
name: options.name ?? id,
|
|
215
497
|
description: options.description,
|
|
216
|
-
is_public: options.public ??
|
|
498
|
+
is_public: options.public ?? false,
|
|
217
499
|
config
|
|
218
500
|
});
|
|
219
501
|
if (!client) {
|
|
@@ -222,6 +504,19 @@ async function runChannelCreate(id, options, client) {
|
|
|
222
504
|
channels[id] = { token: result.token };
|
|
223
505
|
saveConfig({ channels });
|
|
224
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
|
+
}
|
|
225
520
|
return result;
|
|
226
521
|
}
|
|
227
522
|
async function runChannelList(client) {
|
|
@@ -230,7 +525,27 @@ async function runChannelList(client) {
|
|
|
230
525
|
}
|
|
231
526
|
async function runChannelUpdate(channelId, options, client) {
|
|
232
527
|
const c = client ?? createClient();
|
|
233
|
-
|
|
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;
|
|
234
549
|
}
|
|
235
550
|
async function runChannelDelete(channelId, client) {
|
|
236
551
|
const c = client ?? createClient();
|
|
@@ -240,33 +555,311 @@ async function runChannelDelete(channelId, client) {
|
|
|
240
555
|
const serverUrl = resolveServer();
|
|
241
556
|
if (serverUrl && file.servers?.[serverUrl]?.channels?.[channelId]) {
|
|
242
557
|
delete file.servers[serverUrl].channels[channelId];
|
|
243
|
-
const
|
|
244
|
-
|
|
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();
|
|
245
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 {
|
|
246
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;
|
|
247
774
|
}
|
|
248
775
|
|
|
249
776
|
// src/commands/publish.ts
|
|
250
|
-
import
|
|
251
|
-
|
|
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) {
|
|
252
804
|
const c = client ?? createPublishClient(channelId);
|
|
253
805
|
let type;
|
|
254
806
|
let data;
|
|
255
807
|
if (options.file) {
|
|
256
|
-
const raw =
|
|
257
|
-
const parsed =
|
|
258
|
-
type
|
|
259
|
-
|
|
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
|
+
}
|
|
260
817
|
} else if (options.data) {
|
|
261
|
-
data =
|
|
818
|
+
data = parseJSON(options.data, "--data");
|
|
819
|
+
type = options.type;
|
|
820
|
+
} else if (dataArg) {
|
|
821
|
+
data = parseJSON(dataArg, "argument");
|
|
262
822
|
type = options.type;
|
|
263
823
|
} else {
|
|
264
|
-
|
|
824
|
+
const raw = await readStdin();
|
|
825
|
+
data = parseJSON(raw, "stdin");
|
|
826
|
+
type = options.type;
|
|
265
827
|
}
|
|
266
828
|
const publishOpts = { data };
|
|
267
829
|
if (type) publishOpts.type = type;
|
|
268
830
|
return c.publish(channelId, publishOpts);
|
|
269
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
|
+
}
|
|
270
863
|
|
|
271
864
|
// src/commands/subscribe.ts
|
|
272
865
|
async function runSubscribePoll(channelId, options = {}, client) {
|
|
@@ -367,31 +960,7 @@ function runHistory() {
|
|
|
367
960
|
}
|
|
368
961
|
|
|
369
962
|
// src/commands/share.ts
|
|
370
|
-
import
|
|
371
|
-
|
|
372
|
-
// src/lib/output.ts
|
|
373
|
-
function printSuccess(message) {
|
|
374
|
-
console.log(`\u2713 ${message}`);
|
|
375
|
-
}
|
|
376
|
-
function printError(message) {
|
|
377
|
-
console.error(`\u2717 ${message}`);
|
|
378
|
-
}
|
|
379
|
-
function printInfo(label, value) {
|
|
380
|
-
console.log(` ${label}: ${value}`);
|
|
381
|
-
}
|
|
382
|
-
function printStep(message) {
|
|
383
|
-
console.log(` ${message}`);
|
|
384
|
-
}
|
|
385
|
-
function formatRelative(isoString) {
|
|
386
|
-
const diff = Date.now() - new Date(isoString).getTime();
|
|
387
|
-
const minutes = Math.floor(diff / 6e4);
|
|
388
|
-
if (minutes < 1) return "just now";
|
|
389
|
-
if (minutes < 60) return `${minutes}m ago`;
|
|
390
|
-
const hours = Math.floor(minutes / 60);
|
|
391
|
-
if (hours < 24) return `${hours}h ago`;
|
|
392
|
-
const days = Math.floor(hours / 24);
|
|
393
|
-
return `${days}d ago`;
|
|
394
|
-
}
|
|
963
|
+
import readline2 from "readline/promises";
|
|
395
964
|
|
|
396
965
|
// src/lib/directory.ts
|
|
397
966
|
var DIRECTORY_BASE_URL = "https://directory.zooid.dev";
|
|
@@ -424,10 +993,10 @@ async function deviceAuth() {
|
|
|
424
993
|
printInfo("Authorize", verification_url);
|
|
425
994
|
console.log(" Opening browser to complete GitHub sign-in...\n");
|
|
426
995
|
try {
|
|
427
|
-
const { exec } = await import("child_process");
|
|
996
|
+
const { exec: exec2 } = await import("child_process");
|
|
428
997
|
const platform = process.platform;
|
|
429
998
|
const cmd = platform === "darwin" ? "open" : platform === "win32" ? "start" : "xdg-open";
|
|
430
|
-
|
|
999
|
+
exec2(`${cmd} "${verification_url}"`);
|
|
431
1000
|
} catch {
|
|
432
1001
|
console.log(
|
|
433
1002
|
" Could not open browser automatically. Please visit the URL above.\n"
|
|
@@ -454,13 +1023,13 @@ async function deviceAuth() {
|
|
|
454
1023
|
"Authorization timed out. You need your human to authorize you. Run `npx zooid share` again to retry."
|
|
455
1024
|
);
|
|
456
1025
|
}
|
|
457
|
-
async function directoryFetch(
|
|
1026
|
+
async function directoryFetch(path10, options = {}) {
|
|
458
1027
|
let token = await ensureDirectoryToken();
|
|
459
1028
|
const doFetch = (t) => {
|
|
460
1029
|
const headers = new Headers(options.headers);
|
|
461
1030
|
headers.set("Authorization", `Bearer ${t}`);
|
|
462
1031
|
headers.set("Content-Type", "application/json");
|
|
463
|
-
return fetch(`${DIRECTORY_BASE_URL}${
|
|
1032
|
+
return fetch(`${DIRECTORY_BASE_URL}${path10}`, { ...options, headers });
|
|
464
1033
|
};
|
|
465
1034
|
let res = await doFetch(token);
|
|
466
1035
|
if (res.status === 401) {
|
|
@@ -602,7 +1171,7 @@ async function promptChannelDetails(channels, skipPrompt) {
|
|
|
602
1171
|
}
|
|
603
1172
|
return result;
|
|
604
1173
|
}
|
|
605
|
-
const rl =
|
|
1174
|
+
const rl = readline2.createInterface({
|
|
606
1175
|
input: process.stdin,
|
|
607
1176
|
output: process.stdout
|
|
608
1177
|
});
|
|
@@ -692,21 +1261,59 @@ async function runServerSet(fields, client) {
|
|
|
692
1261
|
return c.updateServerMeta(fields);
|
|
693
1262
|
}
|
|
694
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
|
+
|
|
695
1295
|
// src/commands/token.ts
|
|
696
1296
|
async function runTokenMint(scopes, options) {
|
|
697
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
|
+
}
|
|
698
1304
|
const body = { scopes };
|
|
699
1305
|
if (options.sub) body.sub = options.sub;
|
|
700
1306
|
if (options.name) body.name = options.name;
|
|
701
1307
|
if (options.expiresIn) body.expires_in = options.expiresIn;
|
|
1308
|
+
if (options.role?.length) body.groups = options.role;
|
|
702
1309
|
return client.mintToken(body);
|
|
703
1310
|
}
|
|
704
1311
|
|
|
705
1312
|
// src/commands/dev.ts
|
|
706
1313
|
import { execSync, spawn as spawn2 } from "child_process";
|
|
707
1314
|
import crypto2 from "crypto";
|
|
708
|
-
import
|
|
709
|
-
import
|
|
1315
|
+
import fs5 from "fs";
|
|
1316
|
+
import path4 from "path";
|
|
710
1317
|
import { fileURLToPath } from "url";
|
|
711
1318
|
|
|
712
1319
|
// src/lib/crypto.ts
|
|
@@ -772,25 +1379,25 @@ async function createEdDSAAdminToken(privateKeyJwk, kid) {
|
|
|
772
1379
|
|
|
773
1380
|
// src/commands/dev.ts
|
|
774
1381
|
function findServerDir() {
|
|
775
|
-
const cliDir =
|
|
776
|
-
return
|
|
1382
|
+
const cliDir = path4.dirname(fileURLToPath(import.meta.url));
|
|
1383
|
+
return path4.resolve(cliDir, "../../server");
|
|
777
1384
|
}
|
|
778
1385
|
async function runDev(port = 8787) {
|
|
779
1386
|
const serverDir = findServerDir();
|
|
780
|
-
if (!
|
|
1387
|
+
if (!fs5.existsSync(path4.join(serverDir, "wrangler.toml"))) {
|
|
781
1388
|
throw new Error(
|
|
782
1389
|
`Server directory not found at ${serverDir}. Make sure you're running from the zooid monorepo.`
|
|
783
1390
|
);
|
|
784
1391
|
}
|
|
785
1392
|
const jwtSecret = crypto2.randomUUID();
|
|
786
|
-
const devVarsPath =
|
|
787
|
-
|
|
1393
|
+
const devVarsPath = path4.join(serverDir, ".dev.vars");
|
|
1394
|
+
fs5.writeFileSync(devVarsPath, `ZOOID_JWT_SECRET=${jwtSecret}
|
|
788
1395
|
`);
|
|
789
1396
|
const adminToken = await createAdminToken(jwtSecret);
|
|
790
1397
|
const serverUrl = `http://localhost:${port}`;
|
|
791
1398
|
saveConfig({ admin_token: adminToken }, serverUrl);
|
|
792
|
-
const schemaPath =
|
|
793
|
-
if (
|
|
1399
|
+
const schemaPath = path4.join(serverDir, "src/db/schema.sql");
|
|
1400
|
+
if (fs5.existsSync(schemaPath)) {
|
|
794
1401
|
try {
|
|
795
1402
|
execSync(
|
|
796
1403
|
`npx wrangler d1 execute zooid-db --local --file=${schemaPath}`,
|
|
@@ -830,31 +1437,124 @@ async function runDev(port = 8787) {
|
|
|
830
1437
|
}
|
|
831
1438
|
|
|
832
1439
|
// src/commands/init.ts
|
|
833
|
-
import
|
|
834
|
-
import
|
|
835
|
-
import
|
|
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
|
|
836
1518
|
var CONFIG_FILENAME = "zooid.json";
|
|
837
1519
|
function getConfigPath() {
|
|
838
|
-
return
|
|
1520
|
+
return path6.join(process.cwd(), CONFIG_FILENAME);
|
|
839
1521
|
}
|
|
840
1522
|
function loadServerConfig() {
|
|
841
1523
|
const configPath = getConfigPath();
|
|
842
|
-
if (!
|
|
843
|
-
const raw =
|
|
1524
|
+
if (!fs7.existsSync(configPath)) return null;
|
|
1525
|
+
const raw = fs7.readFileSync(configPath, "utf-8");
|
|
844
1526
|
return JSON.parse(raw);
|
|
845
1527
|
}
|
|
846
1528
|
function saveServerConfig(config) {
|
|
847
1529
|
const configPath = getConfigPath();
|
|
848
|
-
|
|
1530
|
+
fs7.writeFileSync(configPath, JSON.stringify(config, null, 2) + "\n");
|
|
849
1531
|
}
|
|
850
|
-
async function runInit() {
|
|
1532
|
+
async function runInit(options) {
|
|
851
1533
|
const configPath = getConfigPath();
|
|
852
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
|
+
}
|
|
853
1553
|
if (existing) {
|
|
854
1554
|
printInfo("Found existing", configPath);
|
|
855
1555
|
console.log("");
|
|
856
1556
|
}
|
|
857
|
-
const rl =
|
|
1557
|
+
const rl = readline3.createInterface({
|
|
858
1558
|
input: process.stdin,
|
|
859
1559
|
output: process.stdout
|
|
860
1560
|
});
|
|
@@ -891,7 +1591,15 @@ async function runInit() {
|
|
|
891
1591
|
tags,
|
|
892
1592
|
url
|
|
893
1593
|
};
|
|
894
|
-
|
|
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
|
+
}
|
|
895
1603
|
console.log("");
|
|
896
1604
|
printSuccess(`Saved ${CONFIG_FILENAME}`);
|
|
897
1605
|
console.log("");
|
|
@@ -905,30 +1613,155 @@ async function runInit() {
|
|
|
905
1613
|
config.tags.length > 0 ? config.tags.join(", ") : "(none)"
|
|
906
1614
|
);
|
|
907
1615
|
printInfo("URL", config.url || "(not set)");
|
|
1616
|
+
printInfo("Workforce", ".zooid/workforce.json");
|
|
908
1617
|
console.log("");
|
|
909
1618
|
} finally {
|
|
910
1619
|
rl.close();
|
|
911
1620
|
}
|
|
1621
|
+
if (options?.use) {
|
|
1622
|
+
await runUse(options.use);
|
|
1623
|
+
}
|
|
912
1624
|
}
|
|
913
1625
|
|
|
914
1626
|
// src/commands/deploy.ts
|
|
915
1627
|
import { execSync as execSync2, spawnSync } from "child_process";
|
|
916
1628
|
import crypto3 from "crypto";
|
|
917
|
-
import
|
|
1629
|
+
import fs9 from "fs";
|
|
918
1630
|
import os from "os";
|
|
919
|
-
import
|
|
920
|
-
import
|
|
1631
|
+
import path7 from "path";
|
|
1632
|
+
import readline5 from "readline/promises";
|
|
921
1633
|
import { createRequire } from "module";
|
|
922
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
|
|
923
1756
|
var require2 = createRequire(import.meta.url);
|
|
924
1757
|
function resolvePackageDir(packageName) {
|
|
925
1758
|
const pkgJson = require2.resolve(`${packageName}/package.json`);
|
|
926
|
-
return
|
|
1759
|
+
return path7.dirname(pkgJson);
|
|
927
1760
|
}
|
|
928
|
-
var USER_WRANGLER_TOML =
|
|
1761
|
+
var USER_WRANGLER_TOML = path7.join(process.cwd(), "wrangler.toml");
|
|
929
1762
|
function ejectWranglerToml(opts) {
|
|
930
1763
|
const serverDir = resolvePackageDir("@zooid/server");
|
|
931
|
-
let toml =
|
|
1764
|
+
let toml = fs9.readFileSync(path7.join(serverDir, "wrangler.toml"), "utf-8");
|
|
932
1765
|
toml = toml.replace(/directory\s*=\s*"[^"]*"/, 'directory = "./web-dist/"');
|
|
933
1766
|
toml = toml.replace(/name = "[^"]*"/, `name = "${opts.workerName}"`);
|
|
934
1767
|
toml = toml.replace(
|
|
@@ -943,58 +1776,58 @@ function ejectWranglerToml(opts) {
|
|
|
943
1776
|
/ZOOID_SERVER_ID = "[^"]*"/,
|
|
944
1777
|
`ZOOID_SERVER_ID = "${opts.serverSlug}"`
|
|
945
1778
|
);
|
|
946
|
-
|
|
1779
|
+
fs9.writeFileSync(USER_WRANGLER_TOML, toml);
|
|
947
1780
|
}
|
|
948
1781
|
function prepareStagingDir() {
|
|
949
1782
|
const serverDir = resolvePackageDir("@zooid/server");
|
|
950
|
-
const serverRequire = createRequire(
|
|
951
|
-
const webDir =
|
|
952
|
-
const webDistDir =
|
|
953
|
-
if (!
|
|
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)) {
|
|
954
1787
|
throw new Error(`Web dashboard not built. Missing: ${webDistDir}`);
|
|
955
1788
|
}
|
|
956
|
-
const tmpDir =
|
|
957
|
-
copyDirSync(
|
|
958
|
-
copyDirSync(webDistDir,
|
|
959
|
-
if (
|
|
960
|
-
|
|
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"));
|
|
961
1794
|
} else {
|
|
962
|
-
if (!
|
|
1795
|
+
if (!fs9.existsSync(path7.join(serverDir, "wrangler.toml"))) {
|
|
963
1796
|
throw new Error(`Server package missing wrangler.toml at ${serverDir}`);
|
|
964
1797
|
}
|
|
965
|
-
let toml =
|
|
1798
|
+
let toml = fs9.readFileSync(path7.join(serverDir, "wrangler.toml"), "utf-8");
|
|
966
1799
|
toml = toml.replace(/directory\s*=\s*"[^"]*"/, 'directory = "./web-dist/"');
|
|
967
|
-
|
|
1800
|
+
fs9.writeFileSync(path7.join(tmpDir, "wrangler.toml"), toml);
|
|
968
1801
|
}
|
|
969
1802
|
const nodeModules = findServerNodeModules(serverDir);
|
|
970
1803
|
if (nodeModules) {
|
|
971
|
-
|
|
1804
|
+
fs9.symlinkSync(nodeModules, path7.join(tmpDir, "node_modules"), "junction");
|
|
972
1805
|
}
|
|
973
1806
|
return tmpDir;
|
|
974
1807
|
}
|
|
975
1808
|
function findServerNodeModules(serverDir) {
|
|
976
|
-
const local =
|
|
977
|
-
if (
|
|
978
|
-
const storeNodeModules =
|
|
979
|
-
if (
|
|
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")))
|
|
980
1813
|
return storeNodeModules;
|
|
981
1814
|
let dir = serverDir;
|
|
982
|
-
while (dir !==
|
|
983
|
-
dir =
|
|
984
|
-
const nm =
|
|
985
|
-
if (
|
|
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;
|
|
986
1819
|
}
|
|
987
1820
|
return null;
|
|
988
1821
|
}
|
|
989
1822
|
function copyDirSync(src, dest) {
|
|
990
|
-
|
|
991
|
-
for (const entry of
|
|
992
|
-
const srcPath =
|
|
993
|
-
const destPath =
|
|
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);
|
|
994
1827
|
if (entry.isDirectory()) {
|
|
995
1828
|
copyDirSync(srcPath, destPath);
|
|
996
1829
|
} else {
|
|
997
|
-
|
|
1830
|
+
fs9.copyFileSync(srcPath, destPath);
|
|
998
1831
|
}
|
|
999
1832
|
}
|
|
1000
1833
|
}
|
|
@@ -1048,9 +1881,9 @@ function parseDeployUrls(output) {
|
|
|
1048
1881
|
};
|
|
1049
1882
|
}
|
|
1050
1883
|
function loadDotEnv() {
|
|
1051
|
-
const envPath =
|
|
1052
|
-
if (!
|
|
1053
|
-
const content =
|
|
1884
|
+
const envPath = path7.join(process.cwd(), ".env");
|
|
1885
|
+
if (!fs9.existsSync(envPath)) return {};
|
|
1886
|
+
const content = fs9.readFileSync(envPath, "utf-8");
|
|
1054
1887
|
const tokenMatch = content.match(/^CLOUDFLARE_API_TOKEN=(.+)$/m);
|
|
1055
1888
|
const accountMatch = content.match(/^CLOUDFLARE_ACCOUNT_ID=(.+)$/m);
|
|
1056
1889
|
return {
|
|
@@ -1069,7 +1902,7 @@ async function getCfCredentials() {
|
|
|
1069
1902
|
printInfo("Using credentials from", ".env");
|
|
1070
1903
|
return { apiToken: dotEnv.apiToken, accountId: dotEnv.accountId };
|
|
1071
1904
|
}
|
|
1072
|
-
const rl =
|
|
1905
|
+
const rl = readline5.createInterface({
|
|
1073
1906
|
input: process.stdin,
|
|
1074
1907
|
output: process.stdout
|
|
1075
1908
|
});
|
|
@@ -1094,7 +1927,7 @@ async function getCfCredentials() {
|
|
|
1094
1927
|
rl.close();
|
|
1095
1928
|
}
|
|
1096
1929
|
}
|
|
1097
|
-
async function runDeploy() {
|
|
1930
|
+
async function runDeploy(opts) {
|
|
1098
1931
|
let config = loadServerConfig();
|
|
1099
1932
|
if (!config) {
|
|
1100
1933
|
printInfo("No zooid.json found", "starting setup...");
|
|
@@ -1106,6 +1939,11 @@ async function runDeploy() {
|
|
|
1106
1939
|
printError("Failed to load zooid.json after init");
|
|
1107
1940
|
process.exit(1);
|
|
1108
1941
|
}
|
|
1942
|
+
const serverUrl = config.url;
|
|
1943
|
+
if (serverUrl && isZoonHosted(serverUrl)) {
|
|
1944
|
+
await deployZoonHosted(config, serverUrl, opts);
|
|
1945
|
+
return;
|
|
1946
|
+
}
|
|
1109
1947
|
let stagingDir;
|
|
1110
1948
|
try {
|
|
1111
1949
|
stagingDir = prepareStagingDir();
|
|
@@ -1156,12 +1994,12 @@ async function runDeploy() {
|
|
|
1156
1994
|
const databaseId = dbIdMatch[1];
|
|
1157
1995
|
printSuccess(`D1 database created (${databaseId})`);
|
|
1158
1996
|
ejectWranglerToml({ workerName, dbName, databaseId, serverSlug });
|
|
1159
|
-
|
|
1997
|
+
fs9.copyFileSync(USER_WRANGLER_TOML, path7.join(stagingDir, "wrangler.toml"));
|
|
1160
1998
|
printSuccess(
|
|
1161
1999
|
"Created wrangler.toml (edit to add vars, observability, etc.)"
|
|
1162
2000
|
);
|
|
1163
|
-
const schemaPath =
|
|
1164
|
-
if (
|
|
2001
|
+
const schemaPath = path7.join(stagingDir, "src/db/schema.sql");
|
|
2002
|
+
if (fs9.existsSync(schemaPath)) {
|
|
1165
2003
|
printStep("Running database schema migration...");
|
|
1166
2004
|
wrangler(
|
|
1167
2005
|
`d1 execute ${dbName} --remote --file=${schemaPath}`,
|
|
@@ -1216,7 +2054,7 @@ async function runDeploy() {
|
|
|
1216
2054
|
console.log("");
|
|
1217
2055
|
printInfo("Deploy type", "Redeploying existing server");
|
|
1218
2056
|
console.log("");
|
|
1219
|
-
if (!
|
|
2057
|
+
if (!fs9.existsSync(USER_WRANGLER_TOML)) {
|
|
1220
2058
|
printStep("Ejecting wrangler.toml...");
|
|
1221
2059
|
let databaseId = "";
|
|
1222
2060
|
try {
|
|
@@ -1231,9 +2069,9 @@ async function runDeploy() {
|
|
|
1231
2069
|
"Created wrangler.toml (edit to add vars, observability, etc.)"
|
|
1232
2070
|
);
|
|
1233
2071
|
}
|
|
1234
|
-
|
|
1235
|
-
const schemaPath =
|
|
1236
|
-
if (
|
|
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)) {
|
|
1237
2075
|
printStep("Running schema migration...");
|
|
1238
2076
|
wrangler(
|
|
1239
2077
|
`d1 execute ${dbName} --remote --file=${schemaPath}`,
|
|
@@ -1242,11 +2080,11 @@ async function runDeploy() {
|
|
|
1242
2080
|
);
|
|
1243
2081
|
printSuccess("Schema up to date");
|
|
1244
2082
|
}
|
|
1245
|
-
const migrationsDir =
|
|
1246
|
-
if (
|
|
1247
|
-
const migrationFiles =
|
|
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();
|
|
1248
2086
|
for (const file of migrationFiles) {
|
|
1249
|
-
const migrationPath =
|
|
2087
|
+
const migrationPath = path7.join(migrationsDir, file);
|
|
1250
2088
|
try {
|
|
1251
2089
|
wrangler(
|
|
1252
2090
|
`d1 execute ${dbName} --remote --file=${migrationPath}`,
|
|
@@ -1326,6 +2164,17 @@ async function runDeploy() {
|
|
|
1326
2164
|
process.exit(1);
|
|
1327
2165
|
}
|
|
1328
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
|
+
}
|
|
1329
2178
|
printStep("Deploying worker...");
|
|
1330
2179
|
const deployOutput = wranglerVerbose("deploy", stagingDir, creds);
|
|
1331
2180
|
const { workerUrl, customDomain } = parseDeployUrls(deployOutput);
|
|
@@ -1388,6 +2237,32 @@ async function runDeploy() {
|
|
|
1388
2237
|
}
|
|
1389
2238
|
printInfo("Config", "~/.zooid/state.json");
|
|
1390
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
|
+
}
|
|
1391
2266
|
if (isFirstDeploy) {
|
|
1392
2267
|
console.log(" Next steps:");
|
|
1393
2268
|
console.log(" Edit wrangler.toml to add env vars, observability, etc.");
|
|
@@ -1407,8 +2282,738 @@ async function runDeploy() {
|
|
|
1407
2282
|
}
|
|
1408
2283
|
function cleanup(dir) {
|
|
1409
2284
|
try {
|
|
1410
|
-
|
|
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
|
+
auth_method: "oidc"
|
|
2756
|
+
},
|
|
2757
|
+
targetServer,
|
|
2758
|
+
{ setCurrent: true }
|
|
2759
|
+
);
|
|
2760
|
+
writeProjectConfig(targetServer);
|
|
2761
|
+
printSuccess(`Server: ${targetServer} (set as current)`);
|
|
2762
|
+
printInfo("Project", `zooid.json \u2192 ${targetServer}`);
|
|
2763
|
+
process.stderr.write("\n");
|
|
2764
|
+
}
|
|
2765
|
+
async function loginToServer(serverUrl, _fetch) {
|
|
2766
|
+
const url = new URL(serverUrl);
|
|
2767
|
+
if (url.hostname.endsWith(".zoon.eco")) {
|
|
2768
|
+
process.stderr.write("\nOpening browser to authenticate with Zoon...\n");
|
|
2769
|
+
const result = await pollDeviceAuth(ACCOUNTS_URL, { fetch: _fetch });
|
|
2770
|
+
process.stderr.write(
|
|
2771
|
+
`
|
|
2772
|
+
Logged in as ${result.user.name || result.user.email}
|
|
2773
|
+
`
|
|
2774
|
+
);
|
|
2775
|
+
const exchangeResult = await exchangeToken(
|
|
2776
|
+
ACCOUNTS_URL,
|
|
2777
|
+
result.sessionToken,
|
|
2778
|
+
serverUrl,
|
|
2779
|
+
{ fetch: _fetch }
|
|
2780
|
+
);
|
|
2781
|
+
saveConfig(
|
|
2782
|
+
{
|
|
2783
|
+
admin_token: exchangeResult.token,
|
|
2784
|
+
auth_method: "oidc"
|
|
2785
|
+
},
|
|
2786
|
+
serverUrl,
|
|
2787
|
+
{ setCurrent: true }
|
|
2788
|
+
);
|
|
2789
|
+
writeProjectConfig(serverUrl);
|
|
2790
|
+
printSuccess(`Server: ${serverUrl} (set as current)`);
|
|
2791
|
+
printInfo("Project", `zooid.json \u2192 ${serverUrl}`);
|
|
2792
|
+
return;
|
|
2793
|
+
}
|
|
2794
|
+
throw new Error(
|
|
2795
|
+
"CLI login for self-hosted servers with external OIDC is coming soon.\nFor now, use `npx zooid token mint` to create a token manually."
|
|
2796
|
+
);
|
|
2797
|
+
}
|
|
2798
|
+
|
|
2799
|
+
// src/commands/logout.ts
|
|
2800
|
+
import fs12 from "fs";
|
|
2801
|
+
async function runLogout(options) {
|
|
2802
|
+
const file = loadConfigFile();
|
|
2803
|
+
if (options.all) {
|
|
2804
|
+
for (const url of Object.keys(file.servers ?? {})) {
|
|
2805
|
+
clearServerAuth(file, url);
|
|
2806
|
+
}
|
|
2807
|
+
process.stderr.write("Logged out of all servers\n");
|
|
2808
|
+
} else {
|
|
2809
|
+
const server = resolveServer();
|
|
2810
|
+
if (!server) {
|
|
2811
|
+
throw new Error("No server configured.");
|
|
2812
|
+
}
|
|
2813
|
+
clearServerAuth(file, server);
|
|
2814
|
+
process.stderr.write(`Logged out of ${server}
|
|
2815
|
+
`);
|
|
2816
|
+
}
|
|
2817
|
+
const dir = getConfigDir();
|
|
2818
|
+
fs12.mkdirSync(dir, { recursive: true });
|
|
2819
|
+
fs12.writeFileSync(getStatePath(), JSON.stringify(file, null, 2) + "\n");
|
|
2820
|
+
}
|
|
2821
|
+
function clearServerAuth(file, url) {
|
|
2822
|
+
const entry = file.servers?.[url];
|
|
2823
|
+
if (!entry) return;
|
|
2824
|
+
delete entry.admin_token;
|
|
2825
|
+
delete entry.refresh_token;
|
|
2826
|
+
delete entry.auth_method;
|
|
2827
|
+
}
|
|
2828
|
+
|
|
2829
|
+
// src/commands/whoami.ts
|
|
2830
|
+
async function runWhoami() {
|
|
2831
|
+
const config = loadConfig();
|
|
2832
|
+
const file = loadConfigFile();
|
|
2833
|
+
if (!config.server) {
|
|
2834
|
+
throw new Error("No server configured. Run: npx zooid login");
|
|
2835
|
+
}
|
|
2836
|
+
const client = createClient();
|
|
2837
|
+
const claims = await client.getTokenClaims();
|
|
2838
|
+
const entry = file.servers?.[config.server];
|
|
2839
|
+
return {
|
|
2840
|
+
server: config.server,
|
|
2841
|
+
sub: claims.sub ?? "unknown",
|
|
2842
|
+
name: claims.name,
|
|
2843
|
+
scopes: claims.scopes ?? [],
|
|
2844
|
+
exp: claims.exp,
|
|
2845
|
+
authMethod: entry?.auth_method
|
|
2846
|
+
};
|
|
2847
|
+
}
|
|
2848
|
+
|
|
2849
|
+
// src/commands/credentials.ts
|
|
2850
|
+
function requireZoonServer() {
|
|
2851
|
+
const server = resolveServer();
|
|
2852
|
+
if (!server) {
|
|
2853
|
+
throw new Error("No server configured. Run: npx zooid login");
|
|
2854
|
+
}
|
|
2855
|
+
if (!isZoonHosted(server)) {
|
|
2856
|
+
throw new Error(
|
|
2857
|
+
"Credentials are only available for Zoon-hosted servers (*.zoon.eco)"
|
|
2858
|
+
);
|
|
2859
|
+
}
|
|
2860
|
+
const file = loadConfigFile();
|
|
2861
|
+
const entry = file.servers?.[server];
|
|
2862
|
+
if (!entry?.platform_token) {
|
|
2863
|
+
throw new Error(
|
|
2864
|
+
"Not authenticated with Zoon platform. Run: npx zooid login"
|
|
2865
|
+
);
|
|
2866
|
+
}
|
|
2867
|
+
return { server, platformToken: entry.platform_token };
|
|
2868
|
+
}
|
|
2869
|
+
function formatEnv(server, clientId, clientSecret) {
|
|
2870
|
+
return [
|
|
2871
|
+
`ZOOID_SERVER=${server}`,
|
|
2872
|
+
`ZOOID_CLIENT_ID=${clientId}`,
|
|
2873
|
+
`ZOOID_CLIENT_SECRET=${clientSecret}`
|
|
2874
|
+
].join("\n");
|
|
2875
|
+
}
|
|
2876
|
+
async function runCredentialsCreate(name, options) {
|
|
2877
|
+
const { server, platformToken } = requireZoonServer();
|
|
2878
|
+
let roleNames = options.role;
|
|
2879
|
+
if (!roleNames || roleNames.length === 0) {
|
|
2880
|
+
const wf = loadWorkforce();
|
|
2881
|
+
if (wf.roles[name]) {
|
|
2882
|
+
roleNames = [name];
|
|
2883
|
+
} else {
|
|
2884
|
+
throw new Error(
|
|
2885
|
+
`No roles specified and no matching agent/role "${name}" found in workforce.json`
|
|
2886
|
+
);
|
|
2887
|
+
}
|
|
2888
|
+
}
|
|
2889
|
+
const result = await createCredential(server, platformToken, name, roleNames);
|
|
2890
|
+
process.stderr.write(
|
|
2891
|
+
`
|
|
2892
|
+
Created credential "${name}" on ${server} (roles: ${roleNames.join(", ")})
|
|
2893
|
+
|
|
2894
|
+
`
|
|
2895
|
+
);
|
|
2896
|
+
return formatEnv(server, result.client_id, result.client_secret);
|
|
2897
|
+
}
|
|
2898
|
+
async function runCredentialsList() {
|
|
2899
|
+
const { server, platformToken } = requireZoonServer();
|
|
2900
|
+
return listCredentials(server, platformToken);
|
|
2901
|
+
}
|
|
2902
|
+
async function resolveClientId(nameOrId) {
|
|
2903
|
+
const { server, platformToken } = requireZoonServer();
|
|
2904
|
+
const creds = await listCredentials(server, platformToken);
|
|
2905
|
+
const match = creds.find((c) => c.name === nameOrId) || creds.find((c) => c.client_id === nameOrId);
|
|
2906
|
+
if (!match) {
|
|
2907
|
+
throw new Error(
|
|
2908
|
+
`Credential "${nameOrId}" not found. Run: npx zooid credentials list`
|
|
2909
|
+
);
|
|
2910
|
+
}
|
|
2911
|
+
return { clientId: match.client_id, name: match.name };
|
|
2912
|
+
}
|
|
2913
|
+
async function runCredentialsRotate(nameOrId) {
|
|
2914
|
+
const { server, platformToken } = requireZoonServer();
|
|
2915
|
+
const { clientId, name } = await resolveClientId(nameOrId);
|
|
2916
|
+
const result = await rotateCredential(server, platformToken, clientId);
|
|
2917
|
+
process.stderr.write(`
|
|
2918
|
+
Rotated credential "${name}" on ${server}
|
|
2919
|
+
|
|
2920
|
+
`);
|
|
2921
|
+
return formatEnv(server, result.client_id, result.client_secret);
|
|
2922
|
+
}
|
|
2923
|
+
async function runCredentialsRevoke(nameOrId) {
|
|
2924
|
+
const { server, platformToken } = requireZoonServer();
|
|
2925
|
+
const { clientId, name } = await resolveClientId(nameOrId);
|
|
2926
|
+
await revokeCredential(server, platformToken, clientId);
|
|
2927
|
+
process.stderr.write(`
|
|
2928
|
+
Revoked credential "${name}" on ${server}
|
|
2929
|
+
`);
|
|
2930
|
+
}
|
|
2931
|
+
|
|
2932
|
+
// src/lib/auto-refresh.ts
|
|
2933
|
+
function decodeJwtPayload(jwt) {
|
|
2934
|
+
try {
|
|
2935
|
+
const parts = jwt.split(".");
|
|
2936
|
+
if (parts.length !== 3) return null;
|
|
2937
|
+
const payload = atob(parts[1]);
|
|
2938
|
+
return JSON.parse(payload);
|
|
1411
2939
|
} catch {
|
|
2940
|
+
return null;
|
|
2941
|
+
}
|
|
2942
|
+
}
|
|
2943
|
+
async function maybeRefreshToken(serverConfig, _serverUrl, _options) {
|
|
2944
|
+
if (serverConfig.auth_method !== "oidc") return;
|
|
2945
|
+
if (!serverConfig.admin_token) return;
|
|
2946
|
+
const payload = decodeJwtPayload(serverConfig.admin_token);
|
|
2947
|
+
if (!payload?.exp) return;
|
|
2948
|
+
const expiresAt = payload.exp * 1e3;
|
|
2949
|
+
const twoMinutes = 2 * 60 * 1e3;
|
|
2950
|
+
if (Date.now() < expiresAt - twoMinutes) return;
|
|
2951
|
+
process.stderr.write(
|
|
2952
|
+
"Session expired. Run `npx zooid login` to re-authenticate.\n"
|
|
2953
|
+
);
|
|
2954
|
+
}
|
|
2955
|
+
|
|
2956
|
+
// src/commands/role.ts
|
|
2957
|
+
function runRoleCreate(id, options) {
|
|
2958
|
+
const wf = loadWorkforce();
|
|
2959
|
+
if (id in wf.roles) {
|
|
2960
|
+
throw new Error(
|
|
2961
|
+
`Role "${id}" already exists. Use "zooid role update" to modify it.`
|
|
2962
|
+
);
|
|
2963
|
+
}
|
|
2964
|
+
const def = { scopes: options.scopes };
|
|
2965
|
+
if (options.name) def.name = options.name;
|
|
2966
|
+
if (options.description) def.description = options.description;
|
|
2967
|
+
wf.roles[id] = def;
|
|
2968
|
+
saveWorkforce(wf);
|
|
2969
|
+
}
|
|
2970
|
+
function runRoleList() {
|
|
2971
|
+
const wf = loadWorkforce();
|
|
2972
|
+
return Object.keys(wf.roles);
|
|
2973
|
+
}
|
|
2974
|
+
function runRoleUpdate(id, fields) {
|
|
2975
|
+
const wf = loadWorkforce();
|
|
2976
|
+
const existing = wf.roles[id];
|
|
2977
|
+
if (!existing) {
|
|
2978
|
+
throw new Error(`Role "${id}" not found. Use "zooid role create" first.`);
|
|
2979
|
+
}
|
|
2980
|
+
if (fields.name !== void 0) {
|
|
2981
|
+
if (fields.name === null) {
|
|
2982
|
+
delete existing.name;
|
|
2983
|
+
} else {
|
|
2984
|
+
existing.name = fields.name;
|
|
2985
|
+
}
|
|
2986
|
+
}
|
|
2987
|
+
if (fields.description !== void 0) {
|
|
2988
|
+
if (fields.description === null) {
|
|
2989
|
+
delete existing.description;
|
|
2990
|
+
} else {
|
|
2991
|
+
existing.description = fields.description;
|
|
2992
|
+
}
|
|
2993
|
+
}
|
|
2994
|
+
if (fields.scopes !== void 0) {
|
|
2995
|
+
existing.scopes = fields.scopes;
|
|
2996
|
+
}
|
|
2997
|
+
const targetFile = wf.provenance.roles[id];
|
|
2998
|
+
if (targetFile) {
|
|
2999
|
+
updateInFile(targetFile, "roles", id, existing);
|
|
3000
|
+
} else {
|
|
3001
|
+
wf.roles[id] = existing;
|
|
3002
|
+
saveWorkforce(wf);
|
|
3003
|
+
}
|
|
3004
|
+
return existing;
|
|
3005
|
+
}
|
|
3006
|
+
function runRoleDelete(id) {
|
|
3007
|
+
const wf = loadWorkforce();
|
|
3008
|
+
if (!(id in wf.roles)) {
|
|
3009
|
+
throw new Error(`Role "${id}" not found in .zooid/workforce.json`);
|
|
3010
|
+
}
|
|
3011
|
+
const targetFile = wf.provenance.roles[id];
|
|
3012
|
+
if (targetFile) {
|
|
3013
|
+
removeFromFile(targetFile, "roles", id);
|
|
3014
|
+
} else {
|
|
3015
|
+
delete wf.roles[id];
|
|
3016
|
+
saveWorkforce(wf);
|
|
1412
3017
|
}
|
|
1413
3018
|
}
|
|
1414
3019
|
|
|
@@ -1435,7 +3040,7 @@ async function resolveAndRecord(channel, opts) {
|
|
|
1435
3040
|
return result;
|
|
1436
3041
|
}
|
|
1437
3042
|
var program = new Command();
|
|
1438
|
-
program.name("zooid").description("\u{1FAB8} Pub/sub for AI agents").version("0.
|
|
3043
|
+
program.name("zooid").description("\u{1FAB8} Pub/sub for AI agents").version("0.6.0");
|
|
1439
3044
|
var telemetryCtx = { startTime: 0 };
|
|
1440
3045
|
function setTelemetryChannel(channelId) {
|
|
1441
3046
|
telemetryCtx.channelId = channelId;
|
|
@@ -1459,11 +3064,22 @@ function handleError(commandName, err) {
|
|
|
1459
3064
|
printError(message);
|
|
1460
3065
|
process.exit(1);
|
|
1461
3066
|
}
|
|
1462
|
-
program.hook("preAction", () => {
|
|
3067
|
+
program.hook("preAction", async () => {
|
|
1463
3068
|
telemetryCtx.startTime = Date.now();
|
|
1464
3069
|
if (isEnabled()) {
|
|
1465
3070
|
showNoticeIfNeeded();
|
|
1466
3071
|
}
|
|
3072
|
+
try {
|
|
3073
|
+
const file = loadConfigFile();
|
|
3074
|
+
const server = resolveServer();
|
|
3075
|
+
if (server && file.servers?.[server]) {
|
|
3076
|
+
const entry = file.servers[server];
|
|
3077
|
+
await maybeRefreshToken(entry, server, {
|
|
3078
|
+
save: (partial) => saveConfig(partial, server)
|
|
3079
|
+
});
|
|
3080
|
+
}
|
|
3081
|
+
} catch {
|
|
3082
|
+
}
|
|
1467
3083
|
});
|
|
1468
3084
|
function sendTelemetry(commandName, exitCode, error) {
|
|
1469
3085
|
if (!isEnabled()) return;
|
|
@@ -1502,20 +3118,65 @@ program.command("dev").description("Start local development server").option("--p
|
|
|
1502
3118
|
handleError("dev", err);
|
|
1503
3119
|
}
|
|
1504
3120
|
});
|
|
1505
|
-
program.command("init").description("Create zooid
|
|
3121
|
+
program.command("init").description("Create zooid.json with server identity").option("--use <url>", "Include a template from a GitHub URL").action(async (opts) => {
|
|
1506
3122
|
try {
|
|
1507
|
-
await runInit();
|
|
3123
|
+
await runInit({ use: opts.use });
|
|
1508
3124
|
} catch (err) {
|
|
1509
3125
|
handleError("init", err);
|
|
1510
3126
|
}
|
|
1511
3127
|
});
|
|
1512
|
-
program.command("
|
|
3128
|
+
program.command("use <url>").description("Add a template to your workforce via include").action(async (url) => {
|
|
3129
|
+
try {
|
|
3130
|
+
await runUse(url);
|
|
3131
|
+
} catch (err) {
|
|
3132
|
+
handleError("use", err);
|
|
3133
|
+
}
|
|
3134
|
+
});
|
|
3135
|
+
program.command("deploy").description("Deploy Zooid server to Cloudflare Workers").option("--prune", "Delete server resources not in workforce.json").action(async (opts) => {
|
|
1513
3136
|
try {
|
|
1514
|
-
await runDeploy();
|
|
3137
|
+
await runDeploy(opts);
|
|
1515
3138
|
} catch (err) {
|
|
1516
3139
|
handleError("deploy", err);
|
|
1517
3140
|
}
|
|
1518
3141
|
});
|
|
3142
|
+
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) => {
|
|
3143
|
+
try {
|
|
3144
|
+
await runDestroy({
|
|
3145
|
+
force: options.force,
|
|
3146
|
+
keepLocal: options.keepLocal
|
|
3147
|
+
});
|
|
3148
|
+
} catch (err) {
|
|
3149
|
+
handleError("destroy", err);
|
|
3150
|
+
}
|
|
3151
|
+
});
|
|
3152
|
+
program.command("login").argument("[url]", "Server URL (e.g., https://beno.zoon.eco)").description("Authenticate with Zoon or a specific server").action(async (url) => {
|
|
3153
|
+
try {
|
|
3154
|
+
await runLogin(url);
|
|
3155
|
+
} catch (err) {
|
|
3156
|
+
handleError("login", err);
|
|
3157
|
+
}
|
|
3158
|
+
});
|
|
3159
|
+
program.command("logout").option("--all", "Log out of all servers").description("Clear authentication for the current server").action(async (opts) => {
|
|
3160
|
+
try {
|
|
3161
|
+
await runLogout(opts);
|
|
3162
|
+
} catch (err) {
|
|
3163
|
+
handleError("logout", err);
|
|
3164
|
+
}
|
|
3165
|
+
});
|
|
3166
|
+
program.command("whoami").description("Show current identity and auth status").action(async () => {
|
|
3167
|
+
try {
|
|
3168
|
+
const result = await runWhoami();
|
|
3169
|
+
console.log(`Server: ${result.server}`);
|
|
3170
|
+
console.log(`User: ${result.name || result.sub}`);
|
|
3171
|
+
console.log(`Scopes: ${result.scopes.join(", ")}`);
|
|
3172
|
+
if (result.authMethod) {
|
|
3173
|
+
const expStr = result.exp ? ` (expires ${new Date(result.exp * 1e3).toISOString()})` : "";
|
|
3174
|
+
console.log(`Auth: ${result.authMethod}${expStr}`);
|
|
3175
|
+
}
|
|
3176
|
+
} catch (err) {
|
|
3177
|
+
handleError("whoami", err);
|
|
3178
|
+
}
|
|
3179
|
+
});
|
|
1519
3180
|
var configCmd = program.command("config").description("Manage Zooid configuration");
|
|
1520
3181
|
configCmd.command("set <key> <value>").description("Set a config value (server, admin-token, telemetry)").action((key, value) => {
|
|
1521
3182
|
try {
|
|
@@ -1538,9 +3199,9 @@ configCmd.command("get <key>").description("Get a config value").action((key) =>
|
|
|
1538
3199
|
}
|
|
1539
3200
|
});
|
|
1540
3201
|
var channelCmd = program.command("channel").description("Manage channels");
|
|
1541
|
-
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
|
|
3202
|
+
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(
|
|
1542
3203
|
"--config <file>",
|
|
1543
|
-
"Path to channel config JSON file (
|
|
3204
|
+
"Path to channel config JSON file (types, policies, storage)"
|
|
1544
3205
|
).option(
|
|
1545
3206
|
"--schema <file>",
|
|
1546
3207
|
"Path to JSON schema file (map of event types to JSON schemas)"
|
|
@@ -1548,12 +3209,12 @@ channelCmd.command("create <id>").description("Create a new channel").option("--
|
|
|
1548
3209
|
try {
|
|
1549
3210
|
let config;
|
|
1550
3211
|
if (opts.config) {
|
|
1551
|
-
const
|
|
1552
|
-
const raw =
|
|
3212
|
+
const fs13 = await import("fs");
|
|
3213
|
+
const raw = fs13.readFileSync(opts.config, "utf-8");
|
|
1553
3214
|
config = JSON.parse(raw);
|
|
1554
3215
|
} else if (opts.schema) {
|
|
1555
|
-
const
|
|
1556
|
-
const raw =
|
|
3216
|
+
const fs13 = await import("fs");
|
|
3217
|
+
const raw = fs13.readFileSync(opts.schema, "utf-8");
|
|
1557
3218
|
const parsed = JSON.parse(raw);
|
|
1558
3219
|
const types = {};
|
|
1559
3220
|
for (const [eventType, schemaDef] of Object.entries(parsed)) {
|
|
@@ -1564,7 +3225,7 @@ channelCmd.command("create <id>").description("Create a new channel").option("--
|
|
|
1564
3225
|
const result = await runChannelCreate(id, {
|
|
1565
3226
|
name: opts.name,
|
|
1566
3227
|
description: opts.description,
|
|
1567
|
-
public: opts.
|
|
3228
|
+
public: opts.public ? true : false,
|
|
1568
3229
|
strict: opts.strict,
|
|
1569
3230
|
config
|
|
1570
3231
|
});
|
|
@@ -1576,7 +3237,7 @@ channelCmd.command("create <id>").description("Create a new channel").option("--
|
|
|
1576
3237
|
});
|
|
1577
3238
|
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(
|
|
1578
3239
|
"--config <file>",
|
|
1579
|
-
"Path to channel config JSON file (
|
|
3240
|
+
"Path to channel config JSON file (types, policies, storage)"
|
|
1580
3241
|
).option(
|
|
1581
3242
|
"--schema <file>",
|
|
1582
3243
|
"Path to JSON schema file (map of event types to JSON schemas)"
|
|
@@ -1590,12 +3251,12 @@ channelCmd.command("update <id>").description("Update a channel").option("--name
|
|
|
1590
3251
|
if (opts.public) fields.is_public = true;
|
|
1591
3252
|
if (opts.private) fields.is_public = false;
|
|
1592
3253
|
if (opts.config) {
|
|
1593
|
-
const
|
|
1594
|
-
const raw =
|
|
3254
|
+
const fs13 = await import("fs");
|
|
3255
|
+
const raw = fs13.readFileSync(opts.config, "utf-8");
|
|
1595
3256
|
fields.config = JSON.parse(raw);
|
|
1596
3257
|
} else if (opts.schema) {
|
|
1597
|
-
const
|
|
1598
|
-
const raw =
|
|
3258
|
+
const fs13 = await import("fs");
|
|
3259
|
+
const raw = fs13.readFileSync(opts.schema, "utf-8");
|
|
1599
3260
|
const parsed = JSON.parse(raw);
|
|
1600
3261
|
const types = {};
|
|
1601
3262
|
for (const [eventType, schemaDef] of Object.entries(parsed)) {
|
|
@@ -1641,8 +3302,8 @@ channelCmd.command("list").description("List all channels").action(async () => {
|
|
|
1641
3302
|
channelCmd.command("delete <id>").description("Delete a channel and all its data").option("-y, --yes", "Skip confirmation prompt").action(async (id, opts) => {
|
|
1642
3303
|
try {
|
|
1643
3304
|
if (!opts.yes) {
|
|
1644
|
-
const
|
|
1645
|
-
const rl =
|
|
3305
|
+
const readline7 = await import("readline");
|
|
3306
|
+
const rl = readline7.createInterface({
|
|
1646
3307
|
input: process.stdin,
|
|
1647
3308
|
output: process.stdout
|
|
1648
3309
|
});
|
|
@@ -1664,7 +3325,21 @@ channelCmd.command("delete <id>").description("Delete a channel and all its data
|
|
|
1664
3325
|
handleError("channel delete", err);
|
|
1665
3326
|
}
|
|
1666
3327
|
});
|
|
1667
|
-
program.command("
|
|
3328
|
+
program.command("pull").description(
|
|
3329
|
+
"Pull channel and role definitions from server into workforce.json"
|
|
3330
|
+
).action(async () => {
|
|
3331
|
+
try {
|
|
3332
|
+
await runPull();
|
|
3333
|
+
} catch (err) {
|
|
3334
|
+
handleError("pull", err);
|
|
3335
|
+
}
|
|
3336
|
+
});
|
|
3337
|
+
program.command("publish <channel> [data]").description(
|
|
3338
|
+
"Publish an event to a channel (accepts JSON arg, --data, --file, or stdin)"
|
|
3339
|
+
).option("--type <type>", "Event type").option("--data <json>", "Event data as JSON string").option("--file <path>", "Read event from JSON file").option(
|
|
3340
|
+
"--stream",
|
|
3341
|
+
"Stream mode: read newline-delimited JSON from stdin, publish each line"
|
|
3342
|
+
).option("--token <token>", "Auth token (for remote/private channels)").action(async (channel, dataArg, opts) => {
|
|
1668
3343
|
try {
|
|
1669
3344
|
const { client, channelId, tokenSaved } = resolveChannel(channel, {
|
|
1670
3345
|
token: opts.token,
|
|
@@ -1677,8 +3352,19 @@ program.command("publish <channel>").description("Publish an event to a channel"
|
|
|
1677
3352
|
`for ${channelId} \u2014 won't need --token next time`
|
|
1678
3353
|
);
|
|
1679
3354
|
}
|
|
1680
|
-
|
|
1681
|
-
|
|
3355
|
+
if (opts.stream) {
|
|
3356
|
+
const { published, errors } = await runPublishStream(
|
|
3357
|
+
channelId,
|
|
3358
|
+
opts,
|
|
3359
|
+
client,
|
|
3360
|
+
(event) => printSuccess(`Published event: ${event.id}`)
|
|
3361
|
+
);
|
|
3362
|
+
const summary = `${published} published`;
|
|
3363
|
+
printSuccess(errors ? `${summary}, ${errors} failed` : summary);
|
|
3364
|
+
} else {
|
|
3365
|
+
const event = await runPublish(channelId, opts, client, dataArg);
|
|
3366
|
+
printSuccess(`Published event: ${event.id}`);
|
|
3367
|
+
}
|
|
1682
3368
|
} catch (err) {
|
|
1683
3369
|
handleError("publish", err);
|
|
1684
3370
|
}
|
|
@@ -1689,8 +3375,8 @@ program.command("delete-event <channel> <event-id>").description("Delete a singl
|
|
|
1689
3375
|
tokenType: "publish"
|
|
1690
3376
|
});
|
|
1691
3377
|
if (!opts.yes) {
|
|
1692
|
-
const
|
|
1693
|
-
const rl =
|
|
3378
|
+
const readline7 = await import("readline");
|
|
3379
|
+
const rl = readline7.createInterface({
|
|
1694
3380
|
input: process.stdin,
|
|
1695
3381
|
output: process.stdout
|
|
1696
3382
|
});
|
|
@@ -1873,21 +3559,32 @@ var tokenCmd = program.command("token").description("Manage tokens");
|
|
|
1873
3559
|
tokenCmd.command("mint").description(
|
|
1874
3560
|
"Mint a new token. Scopes: admin, pub:<channel>, sub:<channel>. Wildcards: pub:*, sub:prefix-*"
|
|
1875
3561
|
).argument(
|
|
1876
|
-
"
|
|
3562
|
+
"[scopes...]",
|
|
1877
3563
|
"Scopes to grant (e.g. admin, pub:my-channel, sub:*)"
|
|
3564
|
+
).option(
|
|
3565
|
+
"--role <roles...>",
|
|
3566
|
+
"Mint with scopes from named roles (reads workforce.json)"
|
|
1878
3567
|
).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) => {
|
|
1879
3568
|
try {
|
|
1880
|
-
|
|
1881
|
-
|
|
1882
|
-
|
|
1883
|
-
|
|
1884
|
-
|
|
3569
|
+
if (!opts.role?.length && (!scopes || scopes.length === 0)) {
|
|
3570
|
+
printError("Provide scopes or --role");
|
|
3571
|
+
process.exit(1);
|
|
3572
|
+
}
|
|
3573
|
+
if (!opts.role?.length) {
|
|
3574
|
+
for (const s of scopes) {
|
|
3575
|
+
if (s !== "admin" && !s.startsWith("pub:") && !s.startsWith("sub:")) {
|
|
3576
|
+
printError(
|
|
3577
|
+
`Invalid scope "${s}". Must be "admin", "pub:<channel>", or "sub:<channel>"`
|
|
3578
|
+
);
|
|
3579
|
+
process.exit(1);
|
|
3580
|
+
}
|
|
1885
3581
|
}
|
|
1886
3582
|
}
|
|
1887
|
-
const result = await runTokenMint(scopes, {
|
|
3583
|
+
const result = await runTokenMint(scopes ?? [], {
|
|
1888
3584
|
sub: opts.sub,
|
|
1889
3585
|
name: opts.name,
|
|
1890
|
-
expiresIn: opts.expiresIn
|
|
3586
|
+
expiresIn: opts.expiresIn,
|
|
3587
|
+
role: opts.role
|
|
1891
3588
|
});
|
|
1892
3589
|
console.log(result.token);
|
|
1893
3590
|
} catch (err) {
|
|
@@ -1965,6 +3662,130 @@ program.command("unshare <channel>").description("Remove a channel from the Zooi
|
|
|
1965
3662
|
handleError("unshare", err);
|
|
1966
3663
|
}
|
|
1967
3664
|
});
|
|
3665
|
+
var roleCmd = program.command("role").description("Manage role definitions");
|
|
3666
|
+
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) => {
|
|
3667
|
+
try {
|
|
3668
|
+
for (const s of scopes) {
|
|
3669
|
+
if (s !== "admin" && !s.startsWith("pub:") && !s.startsWith("sub:")) {
|
|
3670
|
+
printError(
|
|
3671
|
+
`Invalid scope "${s}". Must be "admin", "pub:<channel>", or "sub:<channel>"`
|
|
3672
|
+
);
|
|
3673
|
+
process.exit(1);
|
|
3674
|
+
}
|
|
3675
|
+
}
|
|
3676
|
+
runRoleCreate(id, {
|
|
3677
|
+
name: opts.name,
|
|
3678
|
+
description: opts.description,
|
|
3679
|
+
scopes
|
|
3680
|
+
});
|
|
3681
|
+
printSuccess(`Created role "${id}" in workforce.json`);
|
|
3682
|
+
printInfo("Next", "Run `npx zooid deploy` to sync to server");
|
|
3683
|
+
} catch (err) {
|
|
3684
|
+
handleError("role create", err);
|
|
3685
|
+
}
|
|
3686
|
+
});
|
|
3687
|
+
roleCmd.command("list").description("List role definitions in workforce.json").action(() => {
|
|
3688
|
+
try {
|
|
3689
|
+
const ids = runRoleList();
|
|
3690
|
+
if (ids.length === 0) {
|
|
3691
|
+
console.log(
|
|
3692
|
+
"No roles defined. Create one with: npx zooid role create <id> <scopes...>"
|
|
3693
|
+
);
|
|
3694
|
+
} else {
|
|
3695
|
+
for (const id of ids) {
|
|
3696
|
+
console.log(` ${id}`);
|
|
3697
|
+
}
|
|
3698
|
+
}
|
|
3699
|
+
} catch (err) {
|
|
3700
|
+
handleError("role list", err);
|
|
3701
|
+
}
|
|
3702
|
+
});
|
|
3703
|
+
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) => {
|
|
3704
|
+
try {
|
|
3705
|
+
const fields = {};
|
|
3706
|
+
if (opts.name !== void 0) fields.name = opts.name;
|
|
3707
|
+
if (opts.description !== void 0) fields.description = opts.description;
|
|
3708
|
+
if (opts.scopes !== void 0) fields.scopes = opts.scopes;
|
|
3709
|
+
if (Object.keys(fields).length === 0) {
|
|
3710
|
+
throw new Error(
|
|
3711
|
+
"No fields specified. Use --name, --description, or --scopes."
|
|
3712
|
+
);
|
|
3713
|
+
}
|
|
3714
|
+
runRoleUpdate(id, fields);
|
|
3715
|
+
printSuccess(`Updated role "${id}" in workforce.json`);
|
|
3716
|
+
printInfo("Next", "Run `npx zooid deploy` to sync to server");
|
|
3717
|
+
} catch (err) {
|
|
3718
|
+
handleError("role update", err);
|
|
3719
|
+
}
|
|
3720
|
+
});
|
|
3721
|
+
roleCmd.command("delete <id>").description("Delete a role definition from workforce.json").option("-y, --yes", "Skip confirmation prompt").action(async (id, opts) => {
|
|
3722
|
+
try {
|
|
3723
|
+
if (!opts.yes) {
|
|
3724
|
+
const readline7 = await import("readline");
|
|
3725
|
+
const rl = readline7.createInterface({
|
|
3726
|
+
input: process.stdin,
|
|
3727
|
+
output: process.stdout
|
|
3728
|
+
});
|
|
3729
|
+
const answer = await new Promise((resolve) => {
|
|
3730
|
+
rl.question(
|
|
3731
|
+
`Delete role "${id}" from workforce.json? [y/N] `,
|
|
3732
|
+
resolve
|
|
3733
|
+
);
|
|
3734
|
+
});
|
|
3735
|
+
rl.close();
|
|
3736
|
+
if (answer.toLowerCase() !== "y") {
|
|
3737
|
+
console.log("Aborted.");
|
|
3738
|
+
return;
|
|
3739
|
+
}
|
|
3740
|
+
}
|
|
3741
|
+
runRoleDelete(id);
|
|
3742
|
+
printSuccess(`Deleted role "${id}" from workforce.json`);
|
|
3743
|
+
printInfo("Next", "Run `npx zooid deploy` to sync to server");
|
|
3744
|
+
} catch (err) {
|
|
3745
|
+
handleError("role delete", err);
|
|
3746
|
+
}
|
|
3747
|
+
});
|
|
3748
|
+
var credentialsCmd = program.command("credentials").description("Manage M2M agent credentials");
|
|
3749
|
+
credentialsCmd.command("create <name>").option("--role <role...>", "Role names to assign").description("Create a new credential (outputs .env to stdout)").action(async (name, opts) => {
|
|
3750
|
+
try {
|
|
3751
|
+
const env = await runCredentialsCreate(name, opts);
|
|
3752
|
+
process.stdout.write(env + "\n");
|
|
3753
|
+
} catch (err) {
|
|
3754
|
+
handleError("credentials create", err);
|
|
3755
|
+
}
|
|
3756
|
+
});
|
|
3757
|
+
credentialsCmd.command("list").description("List all credentials for the current server").action(async () => {
|
|
3758
|
+
try {
|
|
3759
|
+
const creds = await runCredentialsList();
|
|
3760
|
+
if (creds.length === 0) {
|
|
3761
|
+
printInfo("No credentials", "found for this server");
|
|
3762
|
+
return;
|
|
3763
|
+
}
|
|
3764
|
+
for (const c of creds) {
|
|
3765
|
+
const roleNames = c.roles.map((r) => r.name ?? r).join(", ");
|
|
3766
|
+
console.log(
|
|
3767
|
+
` ${c.name.padEnd(20)} ${c.client_id.padEnd(35)} roles: ${roleNames}`
|
|
3768
|
+
);
|
|
3769
|
+
}
|
|
3770
|
+
} catch (err) {
|
|
3771
|
+
handleError("credentials list", err);
|
|
3772
|
+
}
|
|
3773
|
+
});
|
|
3774
|
+
credentialsCmd.command("rotate <name>").description("Rotate credential secret (outputs .env to stdout)").action(async (name) => {
|
|
3775
|
+
try {
|
|
3776
|
+
const env = await runCredentialsRotate(name);
|
|
3777
|
+
process.stdout.write(env + "\n");
|
|
3778
|
+
} catch (err) {
|
|
3779
|
+
handleError("credentials rotate", err);
|
|
3780
|
+
}
|
|
3781
|
+
});
|
|
3782
|
+
credentialsCmd.command("revoke <name>").description("Revoke (delete) a credential").action(async (name) => {
|
|
3783
|
+
try {
|
|
3784
|
+
await runCredentialsRevoke(name);
|
|
3785
|
+
} catch (err) {
|
|
3786
|
+
handleError("credentials revoke", err);
|
|
3787
|
+
}
|
|
3788
|
+
});
|
|
1968
3789
|
program.parse();
|
|
1969
3790
|
export {
|
|
1970
3791
|
setTelemetryChannel
|