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/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 ?? true,
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
- return c.updateChannel(channelId, options);
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 fs6 = await import("fs");
244
- fs6.writeFileSync(getStatePath(), JSON.stringify(file, null, 2) + "\n");
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 fs2 from "fs";
251
- async function runPublish(channelId, options, client) {
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 = fs2.readFileSync(options.file, "utf-8");
257
- const parsed = JSON.parse(raw);
258
- type = parsed.type;
259
- data = parsed.data ?? parsed;
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 = JSON.parse(options.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
- throw new Error("Either --data or --file is required");
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 readline from "readline/promises";
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
- exec(`${cmd} "${verification_url}"`);
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(path5, options = {}) {
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}${path5}`, { ...options, headers });
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 = readline.createInterface({
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 fs3 from "fs";
709
- import path2 from "path";
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 = path2.dirname(fileURLToPath(import.meta.url));
776
- return path2.resolve(cliDir, "../../server");
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 (!fs3.existsSync(path2.join(serverDir, "wrangler.toml"))) {
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 = path2.join(serverDir, ".dev.vars");
787
- fs3.writeFileSync(devVarsPath, `ZOOID_JWT_SECRET=${jwtSecret}
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 = path2.join(serverDir, "src/db/schema.sql");
793
- if (fs3.existsSync(schemaPath)) {
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 fs4 from "fs";
834
- import path3 from "path";
835
- import readline2 from "readline/promises";
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 path3.join(process.cwd(), CONFIG_FILENAME);
1520
+ return path6.join(process.cwd(), CONFIG_FILENAME);
839
1521
  }
840
1522
  function loadServerConfig() {
841
1523
  const configPath = getConfigPath();
842
- if (!fs4.existsSync(configPath)) return null;
843
- const raw = fs4.readFileSync(configPath, "utf-8");
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
- fs4.writeFileSync(configPath, JSON.stringify(config, null, 2) + "\n");
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 = readline2.createInterface({
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
- fs4.writeFileSync(configPath, JSON.stringify(config, null, 2) + "\n");
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 fs5 from "fs";
1629
+ import fs9 from "fs";
918
1630
  import os from "os";
919
- import path4 from "path";
920
- import readline3 from "readline/promises";
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 path4.dirname(pkgJson);
1759
+ return path7.dirname(pkgJson);
927
1760
  }
928
- var USER_WRANGLER_TOML = path4.join(process.cwd(), "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 = fs5.readFileSync(path4.join(serverDir, "wrangler.toml"), "utf-8");
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
- fs5.writeFileSync(USER_WRANGLER_TOML, toml);
1779
+ fs9.writeFileSync(USER_WRANGLER_TOML, toml);
947
1780
  }
948
1781
  function prepareStagingDir() {
949
1782
  const serverDir = resolvePackageDir("@zooid/server");
950
- const serverRequire = createRequire(path4.join(serverDir, "package.json"));
951
- const webDir = path4.dirname(serverRequire.resolve("@zooid/web/package.json"));
952
- const webDistDir = path4.join(webDir, "dist");
953
- if (!fs5.existsSync(webDistDir)) {
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 = fs5.mkdtempSync(path4.join(os.tmpdir(), "zooid-deploy-"));
957
- copyDirSync(path4.join(serverDir, "src"), path4.join(tmpDir, "src"));
958
- copyDirSync(webDistDir, path4.join(tmpDir, "web-dist"));
959
- if (fs5.existsSync(USER_WRANGLER_TOML)) {
960
- fs5.copyFileSync(USER_WRANGLER_TOML, path4.join(tmpDir, "wrangler.toml"));
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 (!fs5.existsSync(path4.join(serverDir, "wrangler.toml"))) {
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 = fs5.readFileSync(path4.join(serverDir, "wrangler.toml"), "utf-8");
1798
+ let toml = fs9.readFileSync(path7.join(serverDir, "wrangler.toml"), "utf-8");
966
1799
  toml = toml.replace(/directory\s*=\s*"[^"]*"/, 'directory = "./web-dist/"');
967
- fs5.writeFileSync(path4.join(tmpDir, "wrangler.toml"), toml);
1800
+ fs9.writeFileSync(path7.join(tmpDir, "wrangler.toml"), toml);
968
1801
  }
969
1802
  const nodeModules = findServerNodeModules(serverDir);
970
1803
  if (nodeModules) {
971
- fs5.symlinkSync(nodeModules, path4.join(tmpDir, "node_modules"), "junction");
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 = path4.join(serverDir, "node_modules");
977
- if (fs5.existsSync(path4.join(local, "hono"))) return local;
978
- const storeNodeModules = path4.resolve(serverDir, "..", "..");
979
- if (fs5.existsSync(path4.join(storeNodeModules, "hono")))
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 !== path4.dirname(dir)) {
983
- dir = path4.dirname(dir);
984
- const nm = path4.join(dir, "node_modules");
985
- if (fs5.existsSync(path4.join(nm, "hono"))) return nm;
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
- fs5.mkdirSync(dest, { recursive: true });
991
- for (const entry of fs5.readdirSync(src, { withFileTypes: true })) {
992
- const srcPath = path4.join(src, entry.name);
993
- const destPath = path4.join(dest, entry.name);
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
- fs5.copyFileSync(srcPath, destPath);
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 = path4.join(process.cwd(), ".env");
1052
- if (!fs5.existsSync(envPath)) return {};
1053
- const content = fs5.readFileSync(envPath, "utf-8");
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 = readline3.createInterface({
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
- fs5.copyFileSync(USER_WRANGLER_TOML, path4.join(stagingDir, "wrangler.toml"));
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 = path4.join(stagingDir, "src/db/schema.sql");
1164
- if (fs5.existsSync(schemaPath)) {
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 (!fs5.existsSync(USER_WRANGLER_TOML)) {
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
- fs5.copyFileSync(USER_WRANGLER_TOML, path4.join(stagingDir, "wrangler.toml"));
1235
- const schemaPath = path4.join(stagingDir, "src/db/schema.sql");
1236
- if (fs5.existsSync(schemaPath)) {
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 = path4.join(stagingDir, "src/db/migrations");
1246
- if (fs5.existsSync(migrationsDir)) {
1247
- const migrationFiles = fs5.readdirSync(migrationsDir).filter((f) => f.endsWith(".sql")).sort();
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 = path4.join(migrationsDir, file);
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
- fs5.rmSync(dir, { recursive: true, force: true });
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.4.1");
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-server.json with server identity").action(async () => {
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("deploy").description("Deploy Zooid server to Cloudflare Workers").action(async () => {
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 (default)", true).option("--private", "Make channel private").option("--strict", "Enable strict schema validation on publish").option(
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 (display, types, storage)"
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 fs6 = await import("fs");
1552
- const raw = fs6.readFileSync(opts.config, "utf-8");
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 fs6 = await import("fs");
1556
- const raw = fs6.readFileSync(opts.schema, "utf-8");
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.private ? false : true,
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 (display, types, storage)"
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 fs6 = await import("fs");
1594
- const raw = fs6.readFileSync(opts.config, "utf-8");
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 fs6 = await import("fs");
1598
- const raw = fs6.readFileSync(opts.schema, "utf-8");
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 readline4 = await import("readline");
1645
- const rl = readline4.createInterface({
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("publish <channel>").description("Publish an event to a channel").option("--type <type>", "Event type").option("--data <json>", "Event data as JSON string").option("--file <path>", "Read event from JSON file").option("--token <token>", "Auth token (for remote/private channels)").action(async (channel, opts) => {
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
- const event = await runPublish(channelId, opts, client);
1681
- printSuccess(`Published event: ${event.id}`);
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 readline4 = await import("readline");
1693
- const rl = readline4.createInterface({
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
- "<scopes...>",
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
- for (const s of scopes) {
1881
- if (s !== "admin" && !s.startsWith("pub:") && !s.startsWith("sub:")) {
1882
- throw new Error(
1883
- `Invalid scope "${s}". Must be "admin", "pub:<channel>", or "sub:<channel>"`
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