zooid 0.5.1 → 0.6.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/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,14 +555,131 @@ 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 fs7 = await import("fs");
244
- fs7.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 {
245
573
  }
246
574
  }
247
575
  }
248
576
 
249
- // src/commands/push.ts
250
- import readline3 from "readline/promises";
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
+ }
251
683
 
252
684
  // src/lib/output.ts
253
685
  function printSuccess(message) {
@@ -273,110 +705,856 @@ function formatRelative(isoString) {
273
705
  return `${days}d ago`;
274
706
  }
275
707
 
276
- // src/lib/prompts.ts
277
- async function ask(rl, label, defaultValue) {
278
- const hint = defaultValue ? ` [${defaultValue}]` : "";
279
- const answer = await rl.question(` ${label}${hint}: `);
280
- return answer.trim() || defaultValue;
708
+ // src/commands/pull.ts
709
+ async function runPull(client) {
710
+ const c = client ?? createClient();
711
+ const wf = loadWorkforce();
712
+ const written = [];
713
+ let newChannelsAdded = false;
714
+ const channels = await c.listChannels();
715
+ if (channels.length > 0) {
716
+ printStep("Pulling channels...");
717
+ for (const ch of channels) {
718
+ const def = {
719
+ visibility: ch.is_public ? "public" : "private"
720
+ };
721
+ if (ch.name && ch.name !== ch.id) def.name = ch.name;
722
+ if (ch.description) def.description = ch.description;
723
+ if (ch.config) def.config = ch.config;
724
+ const targetFile = wf.provenance.channels[ch.id];
725
+ if (targetFile) {
726
+ updateInFile(targetFile, "channels", ch.id, def);
727
+ } else {
728
+ wf.channels[ch.id] = def;
729
+ newChannelsAdded = true;
730
+ }
731
+ written.push(ch.id);
732
+ }
733
+ }
734
+ let newRolesAdded = false;
735
+ try {
736
+ const server = resolveServer();
737
+ const file = loadConfigFile();
738
+ const entry = server ? file.servers?.[server] : void 0;
739
+ let roles;
740
+ if (server && isZoonHosted(server) && entry?.platform_token) {
741
+ roles = await listRolesFromZoon(server, entry.platform_token);
742
+ } else {
743
+ roles = await c.listRoles();
744
+ }
745
+ if (roles.length > 0) {
746
+ printStep("Pulling roles...");
747
+ for (const role of roles) {
748
+ const roleKey = role.slug ?? role.id;
749
+ const def = { scopes: role.scopes };
750
+ if (role.name) def.name = role.name;
751
+ if (role.description) def.description = role.description;
752
+ const targetFile = wf.provenance.roles[roleKey];
753
+ if (targetFile) {
754
+ updateInFile(targetFile, "roles", roleKey, def);
755
+ } else {
756
+ wf.roles[roleKey] = def;
757
+ newRolesAdded = true;
758
+ }
759
+ }
760
+ }
761
+ } catch {
762
+ }
763
+ if (newChannelsAdded || newRolesAdded) {
764
+ saveWorkforce(wf);
765
+ }
766
+ if (written.length > 0 || Object.keys(wf.roles).length > 0) {
767
+ printSuccess(
768
+ `Pulled ${written.length} channel(s) and ${Object.keys(wf.roles).length} role(s) into .zooid/workforce.json`
769
+ );
770
+ } else {
771
+ printInfo("Nothing to pull", "no channels or roles on server");
772
+ }
773
+ return written;
281
774
  }
282
775
 
283
- // src/commands/deploy.ts
284
- import { execSync, spawnSync } from "child_process";
285
- import crypto2 from "crypto";
286
- import fs3 from "fs";
287
- import os from "os";
288
- import path3 from "path";
289
- import readline2 from "readline/promises";
290
- import { createRequire } from "module";
291
- import { ZooidClient } from "@zooid/sdk";
292
-
293
- // src/lib/crypto.ts
294
- function base64url(buf) {
295
- return buf.toString("base64url");
296
- }
297
- async function createAdminToken(secret) {
298
- const header = base64url(
299
- Buffer.from(JSON.stringify({ alg: "HS256", typ: "JWT" }))
300
- );
301
- const payload = base64url(
302
- Buffer.from(
303
- JSON.stringify({
304
- scope: "admin",
305
- iat: Math.floor(Date.now() / 1e3)
306
- })
307
- )
308
- );
309
- const data = `${header}.${payload}`;
310
- const key = await crypto.subtle.importKey(
311
- "raw",
312
- new TextEncoder().encode(secret),
313
- { name: "HMAC", hash: "SHA-256" },
314
- false,
315
- ["sign"]
316
- );
317
- const sig = await crypto.subtle.sign(
318
- "HMAC",
319
- key,
320
- new TextEncoder().encode(data)
321
- );
322
- const signature = base64url(Buffer.from(sig));
323
- return `${data}.${signature}`;
324
- }
325
- async function createEdDSAAdminToken(privateKeyJwk, kid) {
326
- const header = base64url(
327
- Buffer.from(JSON.stringify({ alg: "EdDSA", typ: "JWT", kid }))
328
- );
329
- const payload = base64url(
330
- Buffer.from(
331
- JSON.stringify({
332
- scope: "admin",
333
- iat: Math.floor(Date.now() / 1e3)
334
- })
335
- )
336
- );
337
- const message = `${header}.${payload}`;
338
- const privateKey = await crypto.subtle.importKey(
339
- "jwk",
340
- privateKeyJwk,
341
- { name: "Ed25519" },
342
- false,
343
- ["sign"]
344
- );
345
- const sig = await crypto.subtle.sign(
346
- "Ed25519",
347
- privateKey,
348
- new TextEncoder().encode(message)
349
- );
350
- const signature = base64url(Buffer.from(sig));
351
- return `${message}.${signature}`;
776
+ // src/commands/publish.ts
777
+ import fs4 from "fs";
778
+ import readline from "readline";
779
+ function parseJSON(raw, source) {
780
+ try {
781
+ return JSON.parse(raw);
782
+ } catch {
783
+ throw new Error(`Invalid JSON from ${source}: ${raw.slice(0, 100)}`);
784
+ }
352
785
  }
353
-
354
- // src/commands/init.ts
355
- import fs2 from "fs";
356
- import path2 from "path";
357
- import readline from "readline/promises";
358
- var CONFIG_FILENAME = "zooid.json";
359
- function getConfigPath() {
360
- return path2.join(process.cwd(), CONFIG_FILENAME);
786
+ function readStdin() {
787
+ return new Promise((resolve, reject) => {
788
+ if (process.stdin.isTTY) {
789
+ reject(
790
+ new Error(
791
+ "No data provided. Usage: zooid publish <channel> <json> or pipe via stdin"
792
+ )
793
+ );
794
+ return;
795
+ }
796
+ let data = "";
797
+ process.stdin.setEncoding("utf-8");
798
+ process.stdin.on("data", (chunk) => data += chunk);
799
+ process.stdin.on("end", () => resolve(data.trim()));
800
+ process.stdin.on("error", reject);
801
+ });
802
+ }
803
+ async function runPublish(channelId, options, client, dataArg) {
804
+ const c = client ?? createPublishClient(channelId);
805
+ let type;
806
+ let data;
807
+ if (options.file) {
808
+ const raw = fs4.readFileSync(options.file, "utf-8");
809
+ const parsed = parseJSON(raw, options.file);
810
+ if (typeof parsed === "object" && parsed !== null && "type" in parsed) {
811
+ const obj = parsed;
812
+ type = obj.type;
813
+ data = obj.data ?? parsed;
814
+ } else {
815
+ data = parsed;
816
+ }
817
+ } else if (options.data) {
818
+ data = parseJSON(options.data, "--data");
819
+ type = options.type;
820
+ } else if (dataArg) {
821
+ data = parseJSON(dataArg, "argument");
822
+ type = options.type;
823
+ } else {
824
+ const raw = await readStdin();
825
+ data = parseJSON(raw, "stdin");
826
+ type = options.type;
827
+ }
828
+ const publishOpts = { data };
829
+ if (type) publishOpts.type = type;
830
+ return c.publish(channelId, publishOpts);
831
+ }
832
+ async function runPublishStream(channelId, options, client, onEvent) {
833
+ if (process.stdin.isTTY) {
834
+ throw new Error(
835
+ "--stream requires piped input (e.g. cat events.jsonl | zooid publish channel --stream)"
836
+ );
837
+ }
838
+ const c = client ?? createPublishClient(channelId);
839
+ const rl = readline.createInterface({ input: process.stdin });
840
+ let published = 0;
841
+ let errors = 0;
842
+ let lineNum = 0;
843
+ for await (const line of rl) {
844
+ lineNum++;
845
+ const trimmed = line.trim();
846
+ if (!trimmed) continue;
847
+ const data = parseJSON(trimmed, `stdin line ${lineNum}`);
848
+ const publishOpts = { data };
849
+ if (options.type) publishOpts.type = options.type;
850
+ try {
851
+ const event = await c.publish(channelId, publishOpts);
852
+ published++;
853
+ onEvent?.(event, lineNum);
854
+ } catch (err) {
855
+ errors++;
856
+ const msg = err instanceof Error ? err.message : String(err);
857
+ process.stderr.write(`Line ${lineNum}: ${msg}
858
+ `);
859
+ }
860
+ }
861
+ return { published, errors };
862
+ }
863
+
864
+ // src/commands/subscribe.ts
865
+ async function runSubscribePoll(channelId, options = {}, client) {
866
+ const c = client ?? createSubscribeClient(channelId);
867
+ const callback = options.callback ?? ((event) => {
868
+ console.log(JSON.stringify(event));
869
+ });
870
+ return c.subscribe(channelId, callback, {
871
+ interval: options.interval ?? 5e3,
872
+ mode: options.mode,
873
+ type: options.type
874
+ });
875
+ }
876
+ async function runSubscribeWebhook(channelId, url, client) {
877
+ const c = client ?? createSubscribeClient(channelId);
878
+ return c.registerWebhook(channelId, url);
879
+ }
880
+
881
+ // src/commands/tail.ts
882
+ async function runTail(channelId, options = {}, client) {
883
+ const c = client ?? createSubscribeClient(channelId);
884
+ if (options.follow) {
885
+ return runTailFollow(c, channelId, options);
886
+ }
887
+ const pollOpts = {};
888
+ if (options.limit !== void 0) pollOpts.limit = options.limit;
889
+ if (options.type) pollOpts.type = options.type;
890
+ if (options.since) pollOpts.since = options.since;
891
+ if (options.cursor) pollOpts.cursor = options.cursor;
892
+ return c.tail(channelId, pollOpts);
893
+ }
894
+ async function runTailFollow(client, channelId, options) {
895
+ const tailOpts = {
896
+ follow: true,
897
+ mode: options.mode,
898
+ interval: options.interval,
899
+ type: options.type
900
+ };
901
+ const stream = client.tail(channelId, tailOpts);
902
+ for await (const event of stream) {
903
+ console.log(JSON.stringify(event));
904
+ }
905
+ return void 0;
906
+ }
907
+
908
+ // src/commands/status.ts
909
+ async function runStatus(client) {
910
+ const c = client ?? createClient();
911
+ const [discovery, identity] = await Promise.all([
912
+ c.getMetadata(),
913
+ c.getServerMeta()
914
+ ]);
915
+ return { discovery, identity };
916
+ }
917
+
918
+ // src/commands/history.ts
919
+ function runHistory() {
920
+ const file = loadConfigFile();
921
+ const entries = [];
922
+ if (!file.servers) return entries;
923
+ const seen = /* @__PURE__ */ new Map();
924
+ for (const [serverUrl, serverConfig] of Object.entries(file.servers)) {
925
+ if (!serverConfig.channels) continue;
926
+ const normalizedServer = normalizeServerUrl(serverUrl);
927
+ for (const [channelId, channelData] of Object.entries(
928
+ serverConfig.channels
929
+ )) {
930
+ if (!channelData.stats) continue;
931
+ const key = `${normalizedServer}\0${channelId}`;
932
+ const existingIdx = seen.get(key);
933
+ if (existingIdx !== void 0) {
934
+ const existing = entries[existingIdx];
935
+ existing.num_tails += channelData.stats.num_tails;
936
+ if (channelData.stats.last_tailed_at > existing.last_tailed_at) {
937
+ existing.last_tailed_at = channelData.stats.last_tailed_at;
938
+ existing.name = channelData.name ?? existing.name;
939
+ }
940
+ if (channelData.stats.first_tailed_at < existing.first_tailed_at) {
941
+ existing.first_tailed_at = channelData.stats.first_tailed_at;
942
+ }
943
+ } else {
944
+ seen.set(key, entries.length);
945
+ entries.push({
946
+ server: normalizedServer,
947
+ channel_id: channelId,
948
+ name: channelData.name,
949
+ num_tails: channelData.stats.num_tails,
950
+ last_tailed_at: channelData.stats.last_tailed_at,
951
+ first_tailed_at: channelData.stats.first_tailed_at
952
+ });
953
+ }
954
+ }
955
+ }
956
+ entries.sort(
957
+ (a, b) => new Date(b.last_tailed_at).getTime() - new Date(a.last_tailed_at).getTime()
958
+ );
959
+ return entries;
960
+ }
961
+
962
+ // src/commands/share.ts
963
+ import readline2 from "readline/promises";
964
+
965
+ // src/lib/directory.ts
966
+ var DIRECTORY_BASE_URL = "https://directory.zooid.dev";
967
+ var TOKEN_PREFIX = "zd_";
968
+ var POLL_INTERVAL_MS = 3e3;
969
+ var POLL_TIMEOUT_MS = 2 * 60 * 1e3;
970
+ function getDirectoryToken() {
971
+ const token = loadDirectoryToken();
972
+ if (token && token.startsWith(TOKEN_PREFIX)) {
973
+ return token;
974
+ }
975
+ return void 0;
976
+ }
977
+ async function ensureDirectoryToken() {
978
+ const existing = getDirectoryToken();
979
+ if (existing) return existing;
980
+ return deviceAuth();
981
+ }
982
+ async function deviceAuth() {
983
+ const res = await fetch(`${DIRECTORY_BASE_URL}/api/auth/device`, {
984
+ method: "POST",
985
+ headers: { "Content-Type": "application/json" }
986
+ });
987
+ if (!res.ok) {
988
+ throw new Error(`Directory auth failed: ${res.status} ${res.statusText}`);
989
+ }
990
+ const data = await res.json();
991
+ const { device_code, verification_url } = data;
992
+ console.log("");
993
+ printInfo("Authorize", verification_url);
994
+ console.log(" Opening browser to complete GitHub sign-in...\n");
995
+ try {
996
+ const { exec: exec2 } = await import("child_process");
997
+ const platform = process.platform;
998
+ const cmd = platform === "darwin" ? "open" : platform === "win32" ? "start" : "xdg-open";
999
+ exec2(`${cmd} "${verification_url}"`);
1000
+ } catch {
1001
+ console.log(
1002
+ " Could not open browser automatically. Please visit the URL above.\n"
1003
+ );
1004
+ }
1005
+ const deadline = Date.now() + POLL_TIMEOUT_MS;
1006
+ while (Date.now() < deadline) {
1007
+ await sleep(POLL_INTERVAL_MS);
1008
+ const statusRes = await fetch(
1009
+ `${DIRECTORY_BASE_URL}/api/auth/status?code=${encodeURIComponent(device_code)}`
1010
+ );
1011
+ if (!statusRes.ok) continue;
1012
+ const status = await statusRes.json();
1013
+ if (status.status === "complete" && status.token) {
1014
+ saveDirectoryToken(status.token);
1015
+ console.log(" Authenticated with Zooid Directory.\n");
1016
+ return status.token;
1017
+ }
1018
+ if (status.status === "expired") {
1019
+ throw new Error("Device authorization expired. Please try again.");
1020
+ }
1021
+ }
1022
+ throw new Error(
1023
+ "Authorization timed out. You need your human to authorize you. Run `npx zooid share` again to retry."
1024
+ );
1025
+ }
1026
+ async function directoryFetch(path10, options = {}) {
1027
+ let token = await ensureDirectoryToken();
1028
+ const doFetch = (t) => {
1029
+ const headers = new Headers(options.headers);
1030
+ headers.set("Authorization", `Bearer ${t}`);
1031
+ headers.set("Content-Type", "application/json");
1032
+ return fetch(`${DIRECTORY_BASE_URL}${path10}`, { ...options, headers });
1033
+ };
1034
+ let res = await doFetch(token);
1035
+ if (res.status === 401) {
1036
+ console.log(" Directory token expired. Re-authenticating...\n");
1037
+ token = await deviceAuth();
1038
+ res = await doFetch(token);
1039
+ }
1040
+ return res;
1041
+ }
1042
+ function sleep(ms) {
1043
+ return new Promise((resolve) => setTimeout(resolve, ms));
1044
+ }
1045
+ async function formatDirectoryError(res) {
1046
+ let msg = `Directory returned ${res.status}`;
1047
+ try {
1048
+ const body = await res.json();
1049
+ const parts = [];
1050
+ if (body.error) parts.push(String(body.error));
1051
+ if (body.message) parts.push(String(body.message));
1052
+ if (parts.length > 0) msg = parts.join(": ");
1053
+ } catch {
1054
+ }
1055
+ return msg;
1056
+ }
1057
+
1058
+ // src/lib/prompts.ts
1059
+ async function ask(rl, label, defaultValue) {
1060
+ const hint = defaultValue ? ` [${defaultValue}]` : "";
1061
+ const answer = await rl.question(` ${label}${hint}: `);
1062
+ return answer.trim() || defaultValue;
1063
+ }
1064
+
1065
+ // src/commands/share.ts
1066
+ async function runShare(channelIds, options = {}) {
1067
+ const client = createClient();
1068
+ const config = loadConfig();
1069
+ const serverUrl = config.server;
1070
+ if (!serverUrl) {
1071
+ throw new Error(
1072
+ "No server configured. Run: npx zooid config set server <url>"
1073
+ );
1074
+ }
1075
+ const allChannels = await client.listChannels();
1076
+ const publicChannels = allChannels.filter((ch) => ch.is_public);
1077
+ if (publicChannels.length === 0) {
1078
+ throw new Error("No public channels found on this server.");
1079
+ }
1080
+ let selected;
1081
+ if (channelIds.length > 0) {
1082
+ const byId = new Map(allChannels.map((ch) => [ch.id, ch]));
1083
+ const missing = [];
1084
+ selected = [];
1085
+ for (const id of channelIds) {
1086
+ const ch = byId.get(id);
1087
+ if (!ch) {
1088
+ missing.push(id);
1089
+ } else {
1090
+ selected.push(ch);
1091
+ }
1092
+ }
1093
+ if (missing.length > 0) {
1094
+ throw new Error(`Channels not found: ${missing.join(", ")}`);
1095
+ }
1096
+ const privateChannels = selected.filter((ch) => !ch.is_public);
1097
+ if (privateChannels.length > 0) {
1098
+ throw new Error(
1099
+ `Cannot share private channels: ${privateChannels.map((ch) => ch.id).join(", ")}. Only public channels can be listed in the directory.`
1100
+ );
1101
+ }
1102
+ } else if (options.yes) {
1103
+ selected = publicChannels;
1104
+ } else {
1105
+ selected = await pickChannels(publicChannels);
1106
+ if (selected.length === 0) {
1107
+ throw new Error("No channels selected.");
1108
+ }
1109
+ }
1110
+ const channelDetails = await promptChannelDetails(selected, options.yes);
1111
+ const ids = selected.map((ch) => ch.id);
1112
+ const { claim, signature } = await client.getClaim(ids);
1113
+ const channels = selected.map((ch) => {
1114
+ const details = channelDetails.get(ch.id);
1115
+ const entry = {
1116
+ channel_id: ch.id,
1117
+ name: ch.name
1118
+ };
1119
+ if (details.description) entry.description = details.description;
1120
+ if (details.tags.length > 0) entry.tags = details.tags;
1121
+ return entry;
1122
+ });
1123
+ const res = await directoryFetch("/api/servers", {
1124
+ method: "POST",
1125
+ body: JSON.stringify({ server_url: serverUrl, claim, signature, channels })
1126
+ });
1127
+ if (!res.ok) {
1128
+ throw new Error(await formatDirectoryError(res));
1129
+ }
1130
+ await res.json();
1131
+ console.log("");
1132
+ for (const ch of selected) {
1133
+ console.log(` ${ch.id} \u2192 ${serverUrl}/${ch.id}`);
1134
+ }
1135
+ console.log("");
1136
+ console.log(` Any zooid can find your channel using:`);
1137
+ console.log(` npx zooid discover --query ${selected[0].id}`);
1138
+ const tags = [
1139
+ ...new Set(selected.flatMap((ch) => channelDetails.get(ch.id)?.tags ?? []))
1140
+ ];
1141
+ if (tags.length > 0) {
1142
+ console.log(` npx zooid discover --tag ${tags[0]}`);
1143
+ }
1144
+ console.log("");
1145
+ }
1146
+ async function pickChannels(channels) {
1147
+ const { default: checkbox } = await import("@inquirer/checkbox");
1148
+ const selected = await checkbox({
1149
+ message: "Select channels to share",
1150
+ choices: channels.map((ch) => ({
1151
+ name: ch.description ? `${ch.id} \u2014 ${ch.description}` : ch.id,
1152
+ value: ch.id,
1153
+ checked: true
1154
+ })),
1155
+ theme: {
1156
+ icon: { cursor: "> " },
1157
+ style: { highlight: (text) => text }
1158
+ }
1159
+ });
1160
+ const selectedSet = new Set(selected);
1161
+ return channels.filter((ch) => selectedSet.has(ch.id));
1162
+ }
1163
+ async function promptChannelDetails(channels, skipPrompt) {
1164
+ const result = /* @__PURE__ */ new Map();
1165
+ if (skipPrompt) {
1166
+ for (const ch of channels) {
1167
+ result.set(ch.id, {
1168
+ description: ch.description ?? "",
1169
+ tags: ch.tags
1170
+ });
1171
+ }
1172
+ return result;
1173
+ }
1174
+ const rl = readline2.createInterface({
1175
+ input: process.stdin,
1176
+ output: process.stdout
1177
+ });
1178
+ try {
1179
+ console.log("");
1180
+ console.log(" Set description and tags for each channel.");
1181
+ console.log(" Press Enter to accept defaults shown in [brackets].\n");
1182
+ for (const ch of channels) {
1183
+ console.log(` ${ch.id}:`);
1184
+ const desc = await ask(rl, "Description", ch.description ?? "");
1185
+ const tagsRaw = await ask(
1186
+ rl,
1187
+ "Tags (comma-separated)",
1188
+ ch.tags.join(", ")
1189
+ );
1190
+ const tags = tagsRaw.split(",").map((t) => t.trim()).filter(Boolean);
1191
+ result.set(ch.id, { description: desc, tags });
1192
+ console.log("");
1193
+ }
1194
+ } finally {
1195
+ rl.close();
1196
+ }
1197
+ return result;
1198
+ }
1199
+
1200
+ // src/commands/unshare.ts
1201
+ async function runUnshare(channelId) {
1202
+ const client = createClient();
1203
+ const config = loadConfig();
1204
+ const serverUrl = config.server;
1205
+ if (!serverUrl) {
1206
+ throw new Error(
1207
+ "No server configured. Run: npx zooid config set server <url>"
1208
+ );
1209
+ }
1210
+ const { claim, signature } = await client.getClaim([channelId], "delete");
1211
+ const res = await directoryFetch("/api/servers/channels", {
1212
+ method: "DELETE",
1213
+ body: JSON.stringify({
1214
+ server_url: serverUrl,
1215
+ channel_id: channelId,
1216
+ claim,
1217
+ signature
1218
+ })
1219
+ });
1220
+ if (!res.ok) {
1221
+ throw new Error(await formatDirectoryError(res));
1222
+ }
1223
+ }
1224
+
1225
+ // src/commands/discover.ts
1226
+ async function runDiscover(options) {
1227
+ const params = new URLSearchParams();
1228
+ if (options.query) params.set("q", options.query);
1229
+ if (options.tag) params.set("tag", options.tag);
1230
+ if (options.limit) params.set("limit", String(options.limit));
1231
+ const qs = params.toString();
1232
+ const url = `${DIRECTORY_BASE_URL}/api/discover${qs ? `?${qs}` : ""}`;
1233
+ const res = await fetch(url);
1234
+ if (!res.ok) {
1235
+ throw new Error(`Directory returned ${res.status}`);
1236
+ }
1237
+ const data = await res.json();
1238
+ if (data.channels.length === 0) {
1239
+ console.log("No channels found.");
1240
+ return;
1241
+ }
1242
+ console.log("");
1243
+ for (const ch of data.channels) {
1244
+ const host = new URL(ch.server_url).host;
1245
+ const tags = ch.tags.length > 0 ? ` [${ch.tags.join(", ")}]` : "";
1246
+ const desc = ch.description ? ` \u2014 ${ch.description}` : "";
1247
+ console.log(` ${host}/${ch.channel_id}${desc}${tags}`);
1248
+ }
1249
+ console.log(`
1250
+ ${data.total} channel${data.total === 1 ? "" : "s"} found.`);
1251
+ console.log("");
1252
+ }
1253
+
1254
+ // src/commands/server.ts
1255
+ async function runServerGet(client) {
1256
+ const c = client ?? createClient();
1257
+ return c.getServerMeta();
1258
+ }
1259
+ async function runServerSet(fields, client) {
1260
+ const c = client ?? createClient();
1261
+ return c.updateServerMeta(fields);
1262
+ }
1263
+
1264
+ // src/lib/roles.ts
1265
+ function loadRoleDefs() {
1266
+ const wf = loadWorkforce();
1267
+ return new Map(Object.entries(wf.roles));
1268
+ }
1269
+ function rolesToScopeMapping(roles) {
1270
+ const mapping = {};
1271
+ for (const [id, def] of roles) {
1272
+ mapping[id] = def.scopes;
1273
+ }
1274
+ return JSON.stringify(mapping);
1275
+ }
1276
+
1277
+ // src/lib/resolve-roles.ts
1278
+ function resolveRoleScopes(roleNames) {
1279
+ const roles = loadRoleDefs();
1280
+ const allScopes = /* @__PURE__ */ new Set();
1281
+ for (const name of roleNames) {
1282
+ const role = roles.get(name);
1283
+ if (!role) {
1284
+ throw new Error(
1285
+ `Role "${name}" not found in .zooid/workforce.json. Available: ${[...roles.keys()].join(", ") || "(none)"}`
1286
+ );
1287
+ }
1288
+ for (const scope of role.scopes) {
1289
+ allScopes.add(scope);
1290
+ }
1291
+ }
1292
+ return [...allScopes];
1293
+ }
1294
+
1295
+ // src/commands/token.ts
1296
+ async function runTokenMint(scopes, options) {
1297
+ const client = createClient();
1298
+ if (options.role?.length && scopes.length) {
1299
+ throw new Error("Cannot combine --role with explicit scopes");
1300
+ }
1301
+ if (options.role?.length) {
1302
+ scopes = resolveRoleScopes(options.role);
1303
+ }
1304
+ const body = { scopes };
1305
+ if (options.sub) body.sub = options.sub;
1306
+ if (options.name) body.name = options.name;
1307
+ if (options.expiresIn) body.expires_in = options.expiresIn;
1308
+ if (options.role?.length) body.groups = options.role;
1309
+ return client.mintToken(body);
1310
+ }
1311
+
1312
+ // src/commands/dev.ts
1313
+ import { execSync, spawn as spawn2 } from "child_process";
1314
+ import crypto2 from "crypto";
1315
+ import fs5 from "fs";
1316
+ import path4 from "path";
1317
+ import { fileURLToPath } from "url";
1318
+
1319
+ // src/lib/crypto.ts
1320
+ function base64url(buf) {
1321
+ return buf.toString("base64url");
1322
+ }
1323
+ async function createAdminToken(secret) {
1324
+ const header = base64url(
1325
+ Buffer.from(JSON.stringify({ alg: "HS256", typ: "JWT" }))
1326
+ );
1327
+ const payload = base64url(
1328
+ Buffer.from(
1329
+ JSON.stringify({
1330
+ scope: "admin",
1331
+ iat: Math.floor(Date.now() / 1e3)
1332
+ })
1333
+ )
1334
+ );
1335
+ const data = `${header}.${payload}`;
1336
+ const key = await crypto.subtle.importKey(
1337
+ "raw",
1338
+ new TextEncoder().encode(secret),
1339
+ { name: "HMAC", hash: "SHA-256" },
1340
+ false,
1341
+ ["sign"]
1342
+ );
1343
+ const sig = await crypto.subtle.sign(
1344
+ "HMAC",
1345
+ key,
1346
+ new TextEncoder().encode(data)
1347
+ );
1348
+ const signature = base64url(Buffer.from(sig));
1349
+ return `${data}.${signature}`;
1350
+ }
1351
+ async function createEdDSAAdminToken(privateKeyJwk, kid) {
1352
+ const header = base64url(
1353
+ Buffer.from(JSON.stringify({ alg: "EdDSA", typ: "JWT", kid }))
1354
+ );
1355
+ const payload = base64url(
1356
+ Buffer.from(
1357
+ JSON.stringify({
1358
+ scope: "admin",
1359
+ iat: Math.floor(Date.now() / 1e3)
1360
+ })
1361
+ )
1362
+ );
1363
+ const message = `${header}.${payload}`;
1364
+ const privateKey = await crypto.subtle.importKey(
1365
+ "jwk",
1366
+ privateKeyJwk,
1367
+ { name: "Ed25519" },
1368
+ false,
1369
+ ["sign"]
1370
+ );
1371
+ const sig = await crypto.subtle.sign(
1372
+ "Ed25519",
1373
+ privateKey,
1374
+ new TextEncoder().encode(message)
1375
+ );
1376
+ const signature = base64url(Buffer.from(sig));
1377
+ return `${message}.${signature}`;
1378
+ }
1379
+
1380
+ // src/commands/dev.ts
1381
+ function findServerDir() {
1382
+ const cliDir = path4.dirname(fileURLToPath(import.meta.url));
1383
+ return path4.resolve(cliDir, "../../server");
1384
+ }
1385
+ async function runDev(port = 8787) {
1386
+ const serverDir = findServerDir();
1387
+ if (!fs5.existsSync(path4.join(serverDir, "wrangler.toml"))) {
1388
+ throw new Error(
1389
+ `Server directory not found at ${serverDir}. Make sure you're running from the zooid monorepo.`
1390
+ );
1391
+ }
1392
+ const jwtSecret = crypto2.randomUUID();
1393
+ const devVarsPath = path4.join(serverDir, ".dev.vars");
1394
+ fs5.writeFileSync(devVarsPath, `ZOOID_JWT_SECRET=${jwtSecret}
1395
+ `);
1396
+ const adminToken = await createAdminToken(jwtSecret);
1397
+ const serverUrl = `http://localhost:${port}`;
1398
+ saveConfig({ admin_token: adminToken }, serverUrl);
1399
+ const schemaPath = path4.join(serverDir, "src/db/schema.sql");
1400
+ if (fs5.existsSync(schemaPath)) {
1401
+ try {
1402
+ execSync(
1403
+ `npx wrangler d1 execute zooid-db --local --file=${schemaPath}`,
1404
+ {
1405
+ cwd: serverDir,
1406
+ stdio: "pipe"
1407
+ }
1408
+ );
1409
+ printSuccess("Database schema initialized");
1410
+ } catch {
1411
+ printSuccess("Database ready (schema already exists)");
1412
+ }
1413
+ }
1414
+ printSuccess("Local server configured");
1415
+ printInfo("Server", serverUrl);
1416
+ printInfo("Admin token", adminToken.slice(0, 20) + "...");
1417
+ printInfo("Config saved to", "~/.zooid/state.json");
1418
+ console.log("");
1419
+ console.log("Starting wrangler dev...");
1420
+ console.log("");
1421
+ const child = spawn2(
1422
+ "npx",
1423
+ ["wrangler", "dev", "--local", "--port", String(port)],
1424
+ {
1425
+ cwd: serverDir,
1426
+ stdio: "inherit",
1427
+ shell: true
1428
+ }
1429
+ );
1430
+ child.on("error", (err) => {
1431
+ console.error(`Failed to start local server: ${err.message}`);
1432
+ process.exit(1);
1433
+ });
1434
+ child.on("exit", (code) => {
1435
+ process.exit(code ?? 0);
1436
+ });
1437
+ }
1438
+
1439
+ // src/commands/init.ts
1440
+ import fs7 from "fs";
1441
+ import path6 from "path";
1442
+ import readline3 from "readline/promises";
1443
+
1444
+ // src/commands/use.ts
1445
+ import fs6 from "fs";
1446
+ import path5 from "path";
1447
+ var SLUG_RE2 = /^[a-z0-9][a-z0-9-]{1,62}[a-z0-9]$/;
1448
+ function deriveTemplateName(url) {
1449
+ const parts = parseGitHubUrl(url);
1450
+ if (!parts) throw new Error("Cannot derive template name from URL");
1451
+ if (parts.path) {
1452
+ const segments = parts.path.split("/").filter(Boolean);
1453
+ return segments[segments.length - 1];
1454
+ }
1455
+ return parts.repo;
1456
+ }
1457
+ function resolveTemplateName(url, workforce) {
1458
+ const meta = workforce.meta;
1459
+ if (meta?.slug) {
1460
+ const slug = meta.slug;
1461
+ if (!SLUG_RE2.test(slug)) {
1462
+ throw new Error(
1463
+ `Invalid meta.slug "${slug}" \u2014 must be lowercase alphanumeric + hyphens, 3-64 chars`
1464
+ );
1465
+ }
1466
+ return slug;
1467
+ }
1468
+ return deriveTemplateName(url);
1469
+ }
1470
+ function addToInclude(relativePath) {
1471
+ const zooidDir = getZooidDir();
1472
+ const workforcePath = path5.join(zooidDir, "workforce.json");
1473
+ let raw;
1474
+ if (fs6.existsSync(workforcePath)) {
1475
+ raw = JSON.parse(fs6.readFileSync(workforcePath, "utf-8"));
1476
+ } else {
1477
+ raw = { channels: {}, roles: {} };
1478
+ }
1479
+ const include = raw.include ?? [];
1480
+ if (!include.includes(relativePath)) {
1481
+ include.push(relativePath);
1482
+ }
1483
+ raw.include = include;
1484
+ fs6.writeFileSync(workforcePath, JSON.stringify(raw, null, 2) + "\n");
1485
+ }
1486
+ async function runUse(url, options) {
1487
+ printStep("Fetching template...");
1488
+ const zooidDir = getZooidDir();
1489
+ const tmpDir = fs6.mkdtempSync(path5.join(zooidDir, ".tmp-template-"));
1490
+ try {
1491
+ const { fetchTemplate } = await import("./template-T5IB4YWC.js");
1492
+ const result = await fetchTemplate(url, tmpDir, {
1493
+ fetch: options?.fetch
1494
+ });
1495
+ const fetchedZooidDir = path5.join(tmpDir, ".zooid");
1496
+ const fetchedWorkforce = path5.join(fetchedZooidDir, "workforce.json");
1497
+ if (!fs6.existsSync(fetchedWorkforce)) {
1498
+ throw new Error("Template has no .zooid/workforce.json");
1499
+ }
1500
+ const wfRaw = JSON.parse(fs6.readFileSync(fetchedWorkforce, "utf-8"));
1501
+ const slug = resolveTemplateName(url, wfRaw);
1502
+ const targetDir = path5.join(zooidDir, slug);
1503
+ if (fs6.existsSync(targetDir)) {
1504
+ fs6.rmSync(targetDir, { recursive: true });
1505
+ }
1506
+ fs6.cpSync(fetchedZooidDir, targetDir, { recursive: true });
1507
+ addToInclude(`./${slug}/workforce.json`);
1508
+ printSuccess(
1509
+ `Saved .zooid/${slug}/ (${result.channelCount} channel(s), ${result.roleCount} role(s))`
1510
+ );
1511
+ printInfo("Added to include", "workforce.json");
1512
+ } finally {
1513
+ fs6.rmSync(tmpDir, { recursive: true, force: true });
1514
+ }
1515
+ }
1516
+
1517
+ // src/commands/init.ts
1518
+ var CONFIG_FILENAME = "zooid.json";
1519
+ function getConfigPath() {
1520
+ return path6.join(process.cwd(), CONFIG_FILENAME);
361
1521
  }
362
1522
  function loadServerConfig() {
363
1523
  const configPath = getConfigPath();
364
- if (!fs2.existsSync(configPath)) return null;
365
- const raw = fs2.readFileSync(configPath, "utf-8");
1524
+ if (!fs7.existsSync(configPath)) return null;
1525
+ const raw = fs7.readFileSync(configPath, "utf-8");
366
1526
  return JSON.parse(raw);
367
1527
  }
368
1528
  function saveServerConfig(config) {
369
1529
  const configPath = getConfigPath();
370
- fs2.writeFileSync(configPath, JSON.stringify(config, null, 2) + "\n");
1530
+ fs7.writeFileSync(configPath, JSON.stringify(config, null, 2) + "\n");
371
1531
  }
372
- async function runInit() {
1532
+ async function runInit(options) {
373
1533
  const configPath = getConfigPath();
374
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
+ }
375
1553
  if (existing) {
376
1554
  printInfo("Found existing", configPath);
377
1555
  console.log("");
378
1556
  }
379
- const rl = readline.createInterface({
1557
+ const rl = readline3.createInterface({
380
1558
  input: process.stdin,
381
1559
  output: process.stdout
382
1560
  });
@@ -413,14 +1591,14 @@ async function runInit() {
413
1591
  tags,
414
1592
  url
415
1593
  };
416
- fs2.writeFileSync(configPath, JSON.stringify(config, null, 2) + "\n");
417
- const channelsDir = path2.join(process.cwd(), "channels");
418
- const rolesDir = path2.join(process.cwd(), "roles");
419
- for (const dir of [channelsDir, rolesDir]) {
420
- if (!fs2.existsSync(dir)) {
421
- fs2.mkdirSync(dir, { recursive: true });
422
- fs2.writeFileSync(path2.join(dir, ".gitkeep"), "");
423
- }
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
+ );
424
1602
  }
425
1603
  console.log("");
426
1604
  printSuccess(`Saved ${CONFIG_FILENAME}`);
@@ -435,24 +1613,155 @@ async function runInit() {
435
1613
  config.tags.length > 0 ? config.tags.join(", ") : "(none)"
436
1614
  );
437
1615
  printInfo("URL", config.url || "(not set)");
438
- printInfo("Channels", "channels/");
439
- printInfo("Roles", "roles/");
1616
+ printInfo("Workforce", ".zooid/workforce.json");
440
1617
  console.log("");
441
1618
  } finally {
442
1619
  rl.close();
443
1620
  }
1621
+ if (options?.use) {
1622
+ await runUse(options.use);
1623
+ }
1624
+ }
1625
+
1626
+ // src/commands/deploy.ts
1627
+ import { execSync as execSync2, spawnSync } from "child_process";
1628
+ import crypto3 from "crypto";
1629
+ import fs9 from "fs";
1630
+ import os from "os";
1631
+ import path7 from "path";
1632
+ import readline5 from "readline/promises";
1633
+ import { createRequire } from "module";
1634
+ import { ZooidClient } from "@zooid/sdk";
1635
+
1636
+ // src/lib/channels.ts
1637
+ function loadChannelDefs() {
1638
+ const wf = loadWorkforce();
1639
+ return new Map(Object.entries(wf.channels));
1640
+ }
1641
+
1642
+ // src/lib/wrangler-vars.ts
1643
+ import fs8 from "fs";
1644
+ function setWranglerVar(tomlPath, key, value) {
1645
+ const content = fs8.readFileSync(tomlPath, "utf-8");
1646
+ const lines = content.split("\n");
1647
+ let varsStart = -1;
1648
+ let varsEnd = lines.length;
1649
+ for (let i = 0; i < lines.length; i++) {
1650
+ if (/^\[vars\]\s*$/.test(lines[i])) {
1651
+ varsStart = i;
1652
+ for (let j = i + 1; j < lines.length; j++) {
1653
+ if (/^\[/.test(lines[j]) && !/^\[vars\]/.test(lines[j])) {
1654
+ varsEnd = j;
1655
+ break;
1656
+ }
1657
+ }
1658
+ break;
1659
+ }
1660
+ }
1661
+ const keyPattern = new RegExp(`^${key}\\s*=`);
1662
+ let existingLine = -1;
1663
+ const searchStart = varsStart >= 0 ? varsStart + 1 : 0;
1664
+ const searchEnd = varsStart >= 0 ? varsEnd : lines.length;
1665
+ for (let i = searchStart; i < searchEnd; i++) {
1666
+ if (keyPattern.test(lines[i])) {
1667
+ existingLine = i;
1668
+ break;
1669
+ }
1670
+ }
1671
+ if (value === null) {
1672
+ if (existingLine >= 0) {
1673
+ lines.splice(existingLine, 1);
1674
+ }
1675
+ } else {
1676
+ const newLine = `${key} = '${value}'`;
1677
+ if (existingLine >= 0) {
1678
+ lines[existingLine] = newLine;
1679
+ } else if (varsStart >= 0) {
1680
+ lines.splice(varsStart + 1, 0, newLine);
1681
+ } else {
1682
+ lines.push("", "[vars]", newLine);
1683
+ }
1684
+ }
1685
+ fs8.writeFileSync(tomlPath, lines.join("\n"));
1686
+ }
1687
+
1688
+ // src/lib/channel-sync.ts
1689
+ import readline4 from "readline/promises";
1690
+ async function syncChannelsToServer(client, options = {}) {
1691
+ const localDefs = loadChannelDefs();
1692
+ const remoteChannels = await client.listChannels();
1693
+ const remoteIds = new Set(remoteChannels.map((ch) => ch.id));
1694
+ const localIds = new Set(localDefs.keys());
1695
+ let created = 0;
1696
+ let updated = 0;
1697
+ let deleted = 0;
1698
+ for (const [id, def] of localDefs) {
1699
+ if (!remoteIds.has(id)) {
1700
+ await client.createChannel({
1701
+ id,
1702
+ name: def.name ?? id,
1703
+ description: def.description,
1704
+ is_public: def.visibility === "public",
1705
+ config: def.config
1706
+ });
1707
+ printSuccess(`Channel created: ${id}`);
1708
+ created++;
1709
+ }
1710
+ }
1711
+ for (const [id, def] of localDefs) {
1712
+ if (remoteIds.has(id)) {
1713
+ await client.updateChannel(id, {
1714
+ name: def.name,
1715
+ description: def.description,
1716
+ is_public: def.visibility === "public",
1717
+ config: def.config ?? {}
1718
+ });
1719
+ printSuccess(`Channel updated: ${id}`);
1720
+ updated++;
1721
+ }
1722
+ }
1723
+ const orphaned = remoteChannels.filter((ch) => !localIds.has(ch.id));
1724
+ if (orphaned.length > 0) {
1725
+ if (options.prune) {
1726
+ for (const ch of orphaned) {
1727
+ await client.deleteChannel(ch.id);
1728
+ printSuccess(`Channel deleted: ${ch.id}`);
1729
+ deleted++;
1730
+ }
1731
+ } else if (options.confirmDelete) {
1732
+ const confirmed = await options.confirmDelete(orphaned);
1733
+ if (confirmed) {
1734
+ for (const ch of orphaned) {
1735
+ await client.deleteChannel(ch.id);
1736
+ printSuccess(`Channel deleted: ${ch.id}`);
1737
+ deleted++;
1738
+ }
1739
+ } else {
1740
+ printInfo("Skipped", "Remote-only channels left unchanged");
1741
+ }
1742
+ } else {
1743
+ printInfo(
1744
+ "Warning",
1745
+ `${orphaned.length} channel(s) on server not in workforce.json (use --prune to remove)`
1746
+ );
1747
+ for (const ch of orphaned) {
1748
+ printInfo(" -", `${ch.id}${ch.name ? ` (${ch.name})` : ""}`);
1749
+ }
1750
+ }
1751
+ }
1752
+ return { created, updated, deleted };
444
1753
  }
445
1754
 
446
1755
  // src/commands/deploy.ts
447
1756
  var require2 = createRequire(import.meta.url);
448
1757
  function resolvePackageDir(packageName) {
449
1758
  const pkgJson = require2.resolve(`${packageName}/package.json`);
450
- return path3.dirname(pkgJson);
1759
+ return path7.dirname(pkgJson);
451
1760
  }
452
- var USER_WRANGLER_TOML = path3.join(process.cwd(), "wrangler.toml");
1761
+ var USER_WRANGLER_TOML = path7.join(process.cwd(), "wrangler.toml");
453
1762
  function ejectWranglerToml(opts) {
454
1763
  const serverDir = resolvePackageDir("@zooid/server");
455
- let toml = fs3.readFileSync(path3.join(serverDir, "wrangler.toml"), "utf-8");
1764
+ let toml = fs9.readFileSync(path7.join(serverDir, "wrangler.toml"), "utf-8");
456
1765
  toml = toml.replace(/directory\s*=\s*"[^"]*"/, 'directory = "./web-dist/"');
457
1766
  toml = toml.replace(/name = "[^"]*"/, `name = "${opts.workerName}"`);
458
1767
  toml = toml.replace(
@@ -467,58 +1776,58 @@ function ejectWranglerToml(opts) {
467
1776
  /ZOOID_SERVER_ID = "[^"]*"/,
468
1777
  `ZOOID_SERVER_ID = "${opts.serverSlug}"`
469
1778
  );
470
- fs3.writeFileSync(USER_WRANGLER_TOML, toml);
1779
+ fs9.writeFileSync(USER_WRANGLER_TOML, toml);
471
1780
  }
472
1781
  function prepareStagingDir() {
473
1782
  const serverDir = resolvePackageDir("@zooid/server");
474
- const serverRequire = createRequire(path3.join(serverDir, "package.json"));
475
- const webDir = path3.dirname(serverRequire.resolve("@zooid/web/package.json"));
476
- const webDistDir = path3.join(webDir, "dist");
477
- if (!fs3.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)) {
478
1787
  throw new Error(`Web dashboard not built. Missing: ${webDistDir}`);
479
1788
  }
480
- const tmpDir = fs3.mkdtempSync(path3.join(os.tmpdir(), "zooid-deploy-"));
481
- copyDirSync(path3.join(serverDir, "src"), path3.join(tmpDir, "src"));
482
- copyDirSync(webDistDir, path3.join(tmpDir, "web-dist"));
483
- if (fs3.existsSync(USER_WRANGLER_TOML)) {
484
- fs3.copyFileSync(USER_WRANGLER_TOML, path3.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"));
485
1794
  } else {
486
- if (!fs3.existsSync(path3.join(serverDir, "wrangler.toml"))) {
1795
+ if (!fs9.existsSync(path7.join(serverDir, "wrangler.toml"))) {
487
1796
  throw new Error(`Server package missing wrangler.toml at ${serverDir}`);
488
1797
  }
489
- let toml = fs3.readFileSync(path3.join(serverDir, "wrangler.toml"), "utf-8");
1798
+ let toml = fs9.readFileSync(path7.join(serverDir, "wrangler.toml"), "utf-8");
490
1799
  toml = toml.replace(/directory\s*=\s*"[^"]*"/, 'directory = "./web-dist/"');
491
- fs3.writeFileSync(path3.join(tmpDir, "wrangler.toml"), toml);
1800
+ fs9.writeFileSync(path7.join(tmpDir, "wrangler.toml"), toml);
492
1801
  }
493
1802
  const nodeModules = findServerNodeModules(serverDir);
494
1803
  if (nodeModules) {
495
- fs3.symlinkSync(nodeModules, path3.join(tmpDir, "node_modules"), "junction");
1804
+ fs9.symlinkSync(nodeModules, path7.join(tmpDir, "node_modules"), "junction");
496
1805
  }
497
1806
  return tmpDir;
498
1807
  }
499
1808
  function findServerNodeModules(serverDir) {
500
- const local = path3.join(serverDir, "node_modules");
501
- if (fs3.existsSync(path3.join(local, "hono"))) return local;
502
- const storeNodeModules = path3.resolve(serverDir, "..", "..");
503
- if (fs3.existsSync(path3.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")))
504
1813
  return storeNodeModules;
505
1814
  let dir = serverDir;
506
- while (dir !== path3.dirname(dir)) {
507
- dir = path3.dirname(dir);
508
- const nm = path3.join(dir, "node_modules");
509
- if (fs3.existsSync(path3.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;
510
1819
  }
511
1820
  return null;
512
1821
  }
513
1822
  function copyDirSync(src, dest) {
514
- fs3.mkdirSync(dest, { recursive: true });
515
- for (const entry of fs3.readdirSync(src, { withFileTypes: true })) {
516
- const srcPath = path3.join(src, entry.name);
517
- const destPath = path3.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);
518
1827
  if (entry.isDirectory()) {
519
1828
  copyDirSync(srcPath, destPath);
520
1829
  } else {
521
- fs3.copyFileSync(srcPath, destPath);
1830
+ fs9.copyFileSync(srcPath, destPath);
522
1831
  }
523
1832
  }
524
1833
  }
@@ -533,7 +1842,7 @@ function wranglerEnv(creds) {
533
1842
  return env;
534
1843
  }
535
1844
  function wrangler(cmd, cwd, creds, opts) {
536
- return execSync(`npx wrangler ${cmd}`, {
1845
+ return execSync2(`npx wrangler ${cmd}`, {
537
1846
  cwd,
538
1847
  stdio: "pipe",
539
1848
  encoding: "utf-8",
@@ -572,9 +1881,9 @@ function parseDeployUrls(output) {
572
1881
  };
573
1882
  }
574
1883
  function loadDotEnv() {
575
- const envPath = path3.join(process.cwd(), ".env");
576
- if (!fs3.existsSync(envPath)) return {};
577
- const content = fs3.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");
578
1887
  const tokenMatch = content.match(/^CLOUDFLARE_API_TOKEN=(.+)$/m);
579
1888
  const accountMatch = content.match(/^CLOUDFLARE_ACCOUNT_ID=(.+)$/m);
580
1889
  return {
@@ -593,7 +1902,7 @@ async function getCfCredentials() {
593
1902
  printInfo("Using credentials from", ".env");
594
1903
  return { apiToken: dotEnv.apiToken, accountId: dotEnv.accountId };
595
1904
  }
596
- const rl = readline2.createInterface({
1905
+ const rl = readline5.createInterface({
597
1906
  input: process.stdin,
598
1907
  output: process.stdout
599
1908
  });
@@ -618,19 +1927,7 @@ async function getCfCredentials() {
618
1927
  rl.close();
619
1928
  }
620
1929
  }
621
- function loadChannelDefs() {
622
- const channelsDir = path3.join(process.cwd(), "channels");
623
- if (!fs3.existsSync(channelsDir)) return /* @__PURE__ */ new Map();
624
- const defs = /* @__PURE__ */ new Map();
625
- for (const file of fs3.readdirSync(channelsDir)) {
626
- if (!file.endsWith(".json")) continue;
627
- const id = file.replace(/\.json$/, "");
628
- const raw = fs3.readFileSync(path3.join(channelsDir, file), "utf-8");
629
- defs.set(id, JSON.parse(raw));
630
- }
631
- return defs;
632
- }
633
- async function runDeploy() {
1930
+ async function runDeploy(opts) {
634
1931
  let config = loadServerConfig();
635
1932
  if (!config) {
636
1933
  printInfo("No zooid.json found", "starting setup...");
@@ -642,6 +1939,11 @@ async function runDeploy() {
642
1939
  printError("Failed to load zooid.json after init");
643
1940
  process.exit(1);
644
1941
  }
1942
+ const serverUrl = config.url;
1943
+ if (serverUrl && isZoonHosted(serverUrl)) {
1944
+ await deployZoonHosted(config, serverUrl, opts);
1945
+ return;
1946
+ }
645
1947
  let stagingDir;
646
1948
  try {
647
1949
  stagingDir = prepareStagingDir();
@@ -653,7 +1955,7 @@ async function runDeploy() {
653
1955
  const dbName = `zooid-db-${serverSlug}`;
654
1956
  const workerName = `zooid-${serverSlug}`;
655
1957
  try {
656
- execSync("npx wrangler --version", { cwd: stagingDir, stdio: "pipe" });
1958
+ execSync2("npx wrangler --version", { cwd: stagingDir, stdio: "pipe" });
657
1959
  } catch {
658
1960
  printError("wrangler not found. Install with: npm install -g wrangler");
659
1961
  process.exit(1);
@@ -692,12 +1994,12 @@ async function runDeploy() {
692
1994
  const databaseId = dbIdMatch[1];
693
1995
  printSuccess(`D1 database created (${databaseId})`);
694
1996
  ejectWranglerToml({ workerName, dbName, databaseId, serverSlug });
695
- fs3.copyFileSync(USER_WRANGLER_TOML, path3.join(stagingDir, "wrangler.toml"));
1997
+ fs9.copyFileSync(USER_WRANGLER_TOML, path7.join(stagingDir, "wrangler.toml"));
696
1998
  printSuccess(
697
1999
  "Created wrangler.toml (edit to add vars, observability, etc.)"
698
2000
  );
699
- const schemaPath = path3.join(stagingDir, "src/db/schema.sql");
700
- if (fs3.existsSync(schemaPath)) {
2001
+ const schemaPath = path7.join(stagingDir, "src/db/schema.sql");
2002
+ if (fs9.existsSync(schemaPath)) {
701
2003
  printStep("Running database schema migration...");
702
2004
  wrangler(
703
2005
  `d1 execute ${dbName} --remote --file=${schemaPath}`,
@@ -707,23 +2009,23 @@ async function runDeploy() {
707
2009
  printSuccess("Database schema initialized");
708
2010
  }
709
2011
  printStep("Generating secrets...");
710
- const keyPair = await crypto2.subtle.generateKey("Ed25519", true, [
2012
+ const keyPair = await crypto3.subtle.generateKey("Ed25519", true, [
711
2013
  "sign",
712
2014
  "verify"
713
2015
  ]);
714
- const privateKeyRaw = await crypto2.subtle.exportKey(
2016
+ const privateKeyRaw = await crypto3.subtle.exportKey(
715
2017
  "pkcs8",
716
2018
  keyPair.privateKey
717
2019
  );
718
- const publicKeyRaw = await crypto2.subtle.exportKey(
2020
+ const publicKeyRaw = await crypto3.subtle.exportKey(
719
2021
  "raw",
720
2022
  keyPair.publicKey
721
2023
  );
722
- const privateKeyJwk = await crypto2.subtle.exportKey(
2024
+ const privateKeyJwk = await crypto3.subtle.exportKey(
723
2025
  "jwk",
724
2026
  keyPair.privateKey
725
2027
  );
726
- const publicKeyJwk = await crypto2.subtle.exportKey(
2028
+ const publicKeyJwk = await crypto3.subtle.exportKey(
727
2029
  "jwk",
728
2030
  keyPair.publicKey
729
2031
  );
@@ -752,7 +2054,7 @@ async function runDeploy() {
752
2054
  console.log("");
753
2055
  printInfo("Deploy type", "Redeploying existing server");
754
2056
  console.log("");
755
- if (!fs3.existsSync(USER_WRANGLER_TOML)) {
2057
+ if (!fs9.existsSync(USER_WRANGLER_TOML)) {
756
2058
  printStep("Ejecting wrangler.toml...");
757
2059
  let databaseId = "";
758
2060
  try {
@@ -767,9 +2069,9 @@ async function runDeploy() {
767
2069
  "Created wrangler.toml (edit to add vars, observability, etc.)"
768
2070
  );
769
2071
  }
770
- fs3.copyFileSync(USER_WRANGLER_TOML, path3.join(stagingDir, "wrangler.toml"));
771
- const schemaPath = path3.join(stagingDir, "src/db/schema.sql");
772
- if (fs3.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)) {
773
2075
  printStep("Running schema migration...");
774
2076
  wrangler(
775
2077
  `d1 execute ${dbName} --remote --file=${schemaPath}`,
@@ -778,11 +2080,11 @@ async function runDeploy() {
778
2080
  );
779
2081
  printSuccess("Schema up to date");
780
2082
  }
781
- const migrationsDir = path3.join(stagingDir, "src/db/migrations");
782
- if (fs3.existsSync(migrationsDir)) {
783
- const migrationFiles = fs3.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();
784
2086
  for (const file of migrationFiles) {
785
- const migrationPath = path3.join(migrationsDir, file);
2087
+ const migrationPath = path7.join(migrationsDir, file);
786
2088
  try {
787
2089
  wrangler(
788
2090
  `d1 execute ${dbName} --remote --file=${migrationPath}`,
@@ -803,23 +2105,23 @@ async function runDeploy() {
803
2105
  const hasLocalKey = keysResult?.[0]?.results?.length > 0;
804
2106
  if (!hasLocalKey) {
805
2107
  printStep("Upgrading to EdDSA auth...");
806
- const keyPair = await crypto2.subtle.generateKey("Ed25519", true, [
2108
+ const keyPair = await crypto3.subtle.generateKey("Ed25519", true, [
807
2109
  "sign",
808
2110
  "verify"
809
2111
  ]);
810
- const privateKeyRaw = await crypto2.subtle.exportKey(
2112
+ const privateKeyRaw = await crypto3.subtle.exportKey(
811
2113
  "pkcs8",
812
2114
  keyPair.privateKey
813
2115
  );
814
- const publicKeyRaw = await crypto2.subtle.exportKey(
2116
+ const publicKeyRaw = await crypto3.subtle.exportKey(
815
2117
  "raw",
816
2118
  keyPair.publicKey
817
2119
  );
818
- const privateKeyJwk = await crypto2.subtle.exportKey(
2120
+ const privateKeyJwk = await crypto3.subtle.exportKey(
819
2121
  "jwk",
820
2122
  keyPair.privateKey
821
2123
  );
822
- const publicKeyJwk = await crypto2.subtle.exportKey(
2124
+ const publicKeyJwk = await crypto3.subtle.exportKey(
823
2125
  "jwk",
824
2126
  keyPair.publicKey
825
2127
  );
@@ -862,6 +2164,17 @@ async function runDeploy() {
862
2164
  process.exit(1);
863
2165
  }
864
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
+ }
865
2178
  printStep("Deploying worker...");
866
2179
  const deployOutput = wranglerVerbose("deploy", stagingDir, creds);
867
2180
  const { workerUrl, customDomain } = parseDeployUrls(deployOutput);
@@ -924,14 +2237,33 @@ async function runDeploy() {
924
2237
  }
925
2238
  printInfo("Config", "~/.zooid/state.json");
926
2239
  console.log("");
927
- const channelDefs = loadChannelDefs();
928
- if (channelDefs.size > 0) {
929
- console.log(
930
- ` Found ${channelDefs.size} channel definition(s) in channels/`
931
- );
932
- console.log(" Run npx zooid push to sync them to the server.");
933
- console.log("");
934
- } else if (isFirstDeploy) {
2240
+ if (canonicalUrl && adminToken) {
2241
+ const channelDefs = loadChannelDefs();
2242
+ if (channelDefs.size > 0 || !isFirstDeploy) {
2243
+ printStep("Syncing channels to server...");
2244
+ try {
2245
+ const client = new ZooidClient({
2246
+ server: canonicalUrl,
2247
+ token: adminToken
2248
+ });
2249
+ const result = await syncChannelsToServer(client, {
2250
+ prune: opts?.prune
2251
+ });
2252
+ if (result.created || result.updated || result.deleted) {
2253
+ printSuccess(
2254
+ `Channels synced (${result.created} created, ${result.updated} updated, ${result.deleted} deleted)`
2255
+ );
2256
+ } else {
2257
+ printSuccess("Channels up to date");
2258
+ }
2259
+ } catch (err) {
2260
+ printError(
2261
+ `Failed to sync channels: ${err instanceof Error ? err.message : err}`
2262
+ );
2263
+ }
2264
+ }
2265
+ }
2266
+ if (isFirstDeploy) {
935
2267
  console.log(" Next steps:");
936
2268
  console.log(" Edit wrangler.toml to add env vars, observability, etc.");
937
2269
  console.log(" npx zooid channel create my-channel");
@@ -943,622 +2275,748 @@ async function runDeploy() {
943
2275
  if (canonicalUrl) {
944
2276
  try {
945
2277
  const openCmd = process.platform === "darwin" ? "open" : process.platform === "win32" ? "start" : "xdg-open";
946
- execSync(`${openCmd} ${canonicalUrl}`, { stdio: "ignore" });
2278
+ execSync2(`${openCmd} ${canonicalUrl}`, { stdio: "ignore" });
947
2279
  } catch {
948
2280
  }
949
2281
  }
950
2282
  }
951
2283
  function cleanup(dir) {
952
2284
  try {
953
- fs3.rmSync(dir, { recursive: true, force: true });
2285
+ fs9.rmSync(dir, { recursive: true, force: true });
954
2286
  } catch {
955
2287
  }
956
2288
  }
957
-
958
- // src/commands/push.ts
959
- async function defaultConfirmDelete(orphaned) {
960
- console.log("");
961
- printInfo(
962
- "Warning",
963
- `${orphaned.length} channel(s) on server not in channels/:`
964
- );
965
- for (const ch of orphaned) {
966
- printInfo(" -", `${ch.id}${ch.name ? ` (${ch.name})` : ""}`);
967
- }
968
- const rl = readline3.createInterface({
969
- input: process.stdin,
970
- output: process.stdout
971
- });
972
- try {
973
- const answer = await ask(
974
- rl,
975
- "Delete these channels from server? (y/N)",
976
- "N"
977
- );
978
- return answer.toLowerCase() === "y";
979
- } finally {
980
- rl.close();
981
- }
982
- }
983
- async function runPush(client, options = {}) {
984
- const localDefs = loadChannelDefs();
985
- if (localDefs.size === 0) {
986
- printInfo("Nothing to push", "no channel definitions in channels/");
987
- return;
988
- }
989
- const c = client ?? createClient();
990
- printStep("Syncing channels...");
991
- const remoteChannels = await c.listChannels();
992
- const remoteIds = new Set(remoteChannels.map((ch) => ch.id));
993
- const localIds = new Set(localDefs.keys());
994
- let created = 0;
995
- let updated = 0;
996
- let deleted = 0;
997
- for (const [id, def] of localDefs) {
998
- if (!remoteIds.has(id)) {
999
- await c.createChannel({
1000
- id,
1001
- name: def.name ?? id,
1002
- description: def.description,
1003
- is_public: def.visibility === "public",
1004
- config: def.config
1005
- });
1006
- printSuccess(`Channel created: ${id}`);
1007
- created++;
1008
- }
1009
- }
1010
- for (const [id, def] of localDefs) {
1011
- if (remoteIds.has(id)) {
1012
- await c.updateChannel(id, {
1013
- name: def.name,
1014
- description: def.description,
1015
- is_public: def.visibility === "public",
1016
- config: def.config
1017
- });
1018
- printSuccess(`Channel updated: ${id}`);
1019
- updated++;
1020
- }
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);
1021
2297
  }
1022
- const orphaned = remoteChannels.filter((ch) => !localIds.has(ch.id));
1023
- if (orphaned.length > 0) {
1024
- const confirmFn = options.confirmDelete ?? defaultConfirmDelete;
1025
- const confirmed = await confirmFn(orphaned);
1026
- if (confirmed) {
1027
- for (const ch of orphaned) {
1028
- await c.deleteChannel(ch.id);
1029
- printSuccess(`Channel deleted: ${ch.id}`);
1030
- deleted++;
1031
- }
1032
- } else {
1033
- printInfo("Skipped", "Remote-only channels left unchanged");
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
+ );
1034
2308
  }
1035
- }
1036
- if (created || updated || deleted) {
1037
- printSuccess(
1038
- `Channels synced (${created} created, ${updated} updated, ${deleted} deleted)`
1039
- );
1040
- } else {
1041
- printSuccess("Channels up to date");
1042
- }
1043
- }
1044
-
1045
- // src/commands/pull.ts
1046
- import fs4 from "fs";
1047
- import path4 from "path";
1048
- async function runPull(client) {
1049
- const c = client ?? createClient();
1050
- const channels = await c.listChannels();
1051
- if (channels.length === 0) {
1052
- printInfo("Nothing to pull", "no channels on server");
1053
- return [];
1054
- }
1055
- const channelsDir = path4.join(process.cwd(), "channels");
1056
- fs4.mkdirSync(channelsDir, { recursive: true });
1057
- printStep("Pulling channels...");
1058
- const written = [];
1059
- for (const ch of channels) {
1060
- const filePath = path4.join(channelsDir, `${ch.id}.json`);
1061
- const def = {
1062
- visibility: ch.is_public ? "public" : "private"
2309
+ return {
2310
+ slug: id === "public" ? "authenticated" : id,
2311
+ ...def.name ? { name: def.name } : {},
2312
+ scopes: def.scopes,
2313
+ ...def.description ? { description: def.description } : {}
1063
2314
  };
1064
- if (ch.name && ch.name !== ch.id) def.name = ch.name;
1065
- if (ch.description) def.description = ch.description;
1066
- if (ch.config) def.config = ch.config;
1067
- fs4.writeFileSync(filePath, JSON.stringify(def, null, 2) + "\n");
1068
- printSuccess(`channels/${ch.id}.json`);
1069
- written.push(ch.id);
1070
- }
1071
- printSuccess(`Pulled ${written.length} channel(s) into channels/`);
1072
- return written;
1073
- }
1074
-
1075
- // src/commands/publish.ts
1076
- import fs5 from "fs";
1077
- async function runPublish(channelId, options, client) {
1078
- const c = client ?? createPublishClient(channelId);
1079
- let type;
1080
- let data;
1081
- if (options.file) {
1082
- const raw = fs5.readFileSync(options.file, "utf-8");
1083
- const parsed = JSON.parse(raw);
1084
- type = parsed.type;
1085
- data = parsed.data ?? parsed;
1086
- } else if (options.data) {
1087
- data = JSON.parse(options.data);
1088
- type = options.type;
1089
- } else {
1090
- throw new Error("Either --data or --file is required");
1091
- }
1092
- const publishOpts = { data };
1093
- if (type) publishOpts.type = type;
1094
- return c.publish(channelId, publishOpts);
1095
- }
1096
-
1097
- // src/commands/subscribe.ts
1098
- async function runSubscribePoll(channelId, options = {}, client) {
1099
- const c = client ?? createSubscribeClient(channelId);
1100
- const callback = options.callback ?? ((event) => {
1101
- console.log(JSON.stringify(event));
1102
- });
1103
- return c.subscribe(channelId, callback, {
1104
- interval: options.interval ?? 5e3,
1105
- mode: options.mode,
1106
- type: options.type
1107
2315
  });
1108
- }
1109
- async function runSubscribeWebhook(channelId, url, client) {
1110
- const c = client ?? createSubscribeClient(channelId);
1111
- return c.registerWebhook(channelId, url);
1112
- }
1113
-
1114
- // src/commands/tail.ts
1115
- async function runTail(channelId, options = {}, client) {
1116
- const c = client ?? createSubscribeClient(channelId);
1117
- if (options.follow) {
1118
- return runTailFollow(c, channelId, options);
1119
- }
1120
- const pollOpts = {};
1121
- if (options.limit !== void 0) pollOpts.limit = options.limit;
1122
- if (options.type) pollOpts.type = options.type;
1123
- if (options.since) pollOpts.since = options.since;
1124
- if (options.cursor) pollOpts.cursor = options.cursor;
1125
- return c.tail(channelId, pollOpts);
1126
- }
1127
- async function runTailFollow(client, channelId, options) {
1128
- const tailOpts = {
1129
- follow: true,
1130
- mode: options.mode,
1131
- interval: options.interval,
1132
- type: options.type
1133
- };
1134
- const stream = client.tail(channelId, tailOpts);
1135
- for await (const event of stream) {
1136
- console.log(JSON.stringify(event));
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
+ }
1137
2328
  }
1138
- return void 0;
1139
- }
1140
-
1141
- // src/commands/status.ts
1142
- async function runStatus(client) {
1143
- const c = client ?? createClient();
1144
- const [discovery, identity] = await Promise.all([
1145
- c.getMetadata(),
1146
- c.getServerMeta()
1147
- ]);
1148
- return { discovery, identity };
1149
- }
1150
-
1151
- // src/commands/history.ts
1152
- function runHistory() {
1153
- const file = loadConfigFile();
1154
- const entries = [];
1155
- if (!file.servers) return entries;
1156
- const seen = /* @__PURE__ */ new Map();
1157
- for (const [serverUrl, serverConfig] of Object.entries(file.servers)) {
1158
- if (!serverConfig.channels) continue;
1159
- const normalizedServer = normalizeServerUrl(serverUrl);
1160
- for (const [channelId, channelData] of Object.entries(
1161
- serverConfig.channels
1162
- )) {
1163
- if (!channelData.stats) continue;
1164
- const key = `${normalizedServer}\0${channelId}`;
1165
- const existingIdx = seen.get(key);
1166
- if (existingIdx !== void 0) {
1167
- const existing = entries[existingIdx];
1168
- existing.num_tails += channelData.stats.num_tails;
1169
- if (channelData.stats.last_tailed_at > existing.last_tailed_at) {
1170
- existing.last_tailed_at = channelData.stats.last_tailed_at;
1171
- existing.name = channelData.name ?? existing.name;
1172
- }
1173
- if (channelData.stats.first_tailed_at < existing.first_tailed_at) {
1174
- existing.first_tailed_at = channelData.stats.first_tailed_at;
1175
- }
1176
- } else {
1177
- seen.set(key, entries.length);
1178
- entries.push({
1179
- server: normalizedServer,
1180
- channel_id: channelId,
1181
- name: channelData.name,
1182
- num_tails: channelData.stats.num_tails,
1183
- last_tailed_at: channelData.stats.last_tailed_at,
1184
- first_tailed_at: channelData.stats.first_tailed_at
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
1185
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
+ );
1186
2352
  }
1187
2353
  }
1188
2354
  }
1189
- entries.sort(
1190
- (a, b) => new Date(b.last_tailed_at).getTime() - new Date(a.last_tailed_at).getTime()
1191
- );
1192
- return entries;
2355
+ console.log("");
2356
+ printSuccess("Deploy complete (Zoon-hosted \u2014 no wrangler deploy needed)");
2357
+ console.log("");
1193
2358
  }
1194
2359
 
1195
- // src/commands/share.ts
1196
- import readline4 from "readline/promises";
1197
-
1198
- // src/lib/directory.ts
1199
- var DIRECTORY_BASE_URL = "https://directory.zooid.dev";
1200
- var TOKEN_PREFIX = "zd_";
1201
- var POLL_INTERVAL_MS = 3e3;
1202
- var POLL_TIMEOUT_MS = 2 * 60 * 1e3;
1203
- function getDirectoryToken() {
1204
- const token = loadDirectoryToken();
1205
- if (token && token.startsWith(TOKEN_PREFIX)) {
1206
- return token;
1207
- }
1208
- return void 0;
1209
- }
1210
- async function ensureDirectoryToken() {
1211
- const existing = getDirectoryToken();
1212
- if (existing) return existing;
1213
- return deviceAuth();
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
+ };
1214
2374
  }
1215
- async function deviceAuth() {
1216
- const res = await fetch(`${DIRECTORY_BASE_URL}/api/auth/device`, {
1217
- method: "POST",
1218
- headers: { "Content-Type": "application/json" }
1219
- });
1220
- if (!res.ok) {
1221
- throw new Error(`Directory auth failed: ${res.status} ${res.statusText}`);
1222
- }
1223
- const data = await res.json();
1224
- const { device_code, verification_url } = data;
1225
- console.log("");
1226
- printInfo("Authorize", verification_url);
1227
- console.log(" Opening browser to complete GitHub sign-in...\n");
1228
- try {
1229
- const { exec } = await import("child_process");
1230
- const platform = process.platform;
1231
- const cmd = platform === "darwin" ? "open" : platform === "win32" ? "start" : "xdg-open";
1232
- exec(`${cmd} "${verification_url}"`);
1233
- } catch {
1234
- console.log(
1235
- " Could not open browser automatically. Please visit the URL above.\n"
1236
- );
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];
1237
2381
  }
1238
- const deadline = Date.now() + POLL_TIMEOUT_MS;
1239
- while (Date.now() < deadline) {
1240
- await sleep(POLL_INTERVAL_MS);
1241
- const statusRes = await fetch(
1242
- `${DIRECTORY_BASE_URL}/api/auth/status?code=${encodeURIComponent(device_code)}`
1243
- );
1244
- if (!statusRes.ok) continue;
1245
- const status = await statusRes.json();
1246
- if (status.status === "complete" && status.token) {
1247
- saveDirectoryToken(status.token);
1248
- console.log(" Authenticated with Zooid Directory.\n");
1249
- return status.token;
1250
- }
1251
- if (status.status === "expired") {
1252
- throw new Error("Device authorization expired. Please try again.");
1253
- }
2382
+ if (file.current === serverUrl) {
2383
+ delete file.current;
1254
2384
  }
1255
- throw new Error(
1256
- "Authorization timed out. You need your human to authorize you. Run `npx zooid share` again to retry."
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" }
1257
2402
  );
2403
+ return res.ok;
1258
2404
  }
1259
- async function directoryFetch(path6, options = {}) {
1260
- let token = await ensureDirectoryToken();
1261
- const doFetch = (t) => {
1262
- const headers = new Headers(options.headers);
1263
- headers.set("Authorization", `Bearer ${t}`);
1264
- headers.set("Content-Type", "application/json");
1265
- return fetch(`${DIRECTORY_BASE_URL}${path6}`, { ...options, headers });
1266
- };
1267
- let res = await doFetch(token);
1268
- if (res.status === 401) {
1269
- console.log(" Directory token expired. Re-authenticating...\n");
1270
- token = await deviceAuth();
1271
- res = await doFetch(token);
1272
- }
1273
- return res;
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;
1274
2412
  }
1275
- function sleep(ms) {
1276
- return new Promise((resolve) => setTimeout(resolve, ms));
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();
1277
2419
  }
1278
- async function formatDirectoryError(res) {
1279
- let msg = `Directory returned ${res.status}`;
1280
- try {
1281
- const body = await res.json();
1282
- const parts = [];
1283
- if (body.error) parts.push(String(body.error));
1284
- if (body.message) parts.push(String(body.message));
1285
- if (parts.length > 0) msg = parts.join(": ");
1286
- } catch {
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);
1287
2427
  }
1288
- return msg;
1289
- }
1290
-
1291
- // src/commands/share.ts
1292
- async function runShare(channelIds, options = {}) {
1293
- const client = createClient();
1294
- const config = loadConfig();
1295
- const serverUrl = config.server;
1296
- if (!serverUrl) {
1297
- throw new Error(
1298
- "No server configured. Run: npx zooid config set server <url>"
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."
1299
2450
  );
2451
+ process.exit(1);
1300
2452
  }
1301
- const allChannels = await client.listChannels();
1302
- const publicChannels = allChannels.filter((ch) => ch.is_public);
1303
- if (publicChannels.length === 0) {
1304
- throw new Error("No public channels found on this server.");
2453
+ if (!accountId) {
2454
+ printError(
2455
+ "CLOUDFLARE_ACCOUNT_ID required. Set it in environment or .env file."
2456
+ );
2457
+ process.exit(1);
1305
2458
  }
1306
- let selected;
1307
- if (channelIds.length > 0) {
1308
- const byId = new Map(allChannels.map((ch) => [ch.id, ch]));
1309
- const missing = [];
1310
- selected = [];
1311
- for (const id of channelIds) {
1312
- const ch = byId.get(id);
1313
- if (!ch) {
1314
- missing.push(id);
1315
- } else {
1316
- selected.push(ch);
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);
1317
2498
  }
2499
+ } finally {
2500
+ rl.close();
1318
2501
  }
1319
- if (missing.length > 0) {
1320
- throw new Error(`Channels not found: ${missing.join(", ")}`);
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
+ );
1321
2522
  }
1322
- const privateChannels = selected.filter((ch) => !ch.is_public);
1323
- if (privateChannels.length > 0) {
1324
- throw new Error(
1325
- `Cannot share private channels: ${privateChannels.map((ch) => ch.id).join(", ")}. Only public channels can be listed in the directory.`
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)."
1326
2536
  );
1327
2537
  }
1328
- } else if (options.yes) {
1329
- selected = publicChannels;
2538
+ }
2539
+ printStep("Deleting Worker...");
2540
+ const workerOk = await deleteWorker(accountId, wrangler2.workerName, apiToken);
2541
+ if (workerOk) {
2542
+ printSuccess(`Deleted ${wrangler2.workerName}`);
1330
2543
  } else {
1331
- selected = await pickChannels(publicChannels);
1332
- if (selected.length === 0) {
1333
- throw new Error("No channels selected.");
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");
1334
2557
  }
1335
2558
  }
1336
- const channelDetails = await promptChannelDetails(selected, options.yes);
1337
- const ids = selected.map((ch) => ch.id);
1338
- const { claim, signature } = await client.getClaim(ids);
1339
- const channels = selected.map((ch) => {
1340
- const details = channelDetails.get(ch.id);
1341
- const entry = {
1342
- channel_id: ch.id,
1343
- name: ch.name
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);
1344
2646
  };
1345
- if (details.description) entry.description = details.description;
1346
- if (details.tags.length > 0) entry.tags = details.tags;
1347
- return entry;
2647
+ setTimeout(poll, interval);
1348
2648
  });
1349
- const res = await directoryFetch("/api/servers", {
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`, {
1350
2653
  method: "POST",
1351
- body: JSON.stringify({ server_url: serverUrl, claim, signature, channels })
2654
+ headers: {
2655
+ Authorization: `Bearer ${sessionToken}`,
2656
+ "Content-Type": "application/json"
2657
+ },
2658
+ body: JSON.stringify({ server_url: serverUrl })
1352
2659
  });
1353
2660
  if (!res.ok) {
1354
- throw new Error(await formatDirectoryError(res));
1355
- }
1356
- await res.json();
1357
- console.log("");
1358
- for (const ch of selected) {
1359
- console.log(` ${ch.id} \u2192 ${serverUrl}/${ch.id}`);
1360
- }
1361
- console.log("");
1362
- console.log(` Any zooid can find your channel using:`);
1363
- console.log(` npx zooid discover --query ${selected[0].id}`);
1364
- const tags = [
1365
- ...new Set(selected.flatMap((ch) => channelDetails.get(ch.id)?.tags ?? []))
1366
- ];
1367
- if (tags.length > 0) {
1368
- console.log(` npx zooid discover --tag ${tags[0]}`);
2661
+ const error = await res.json().catch(() => ({}));
2662
+ throw new Error(
2663
+ `Token exchange failed: ${error.message || error.error || res.status}`
2664
+ );
1369
2665
  }
1370
- console.log("");
2666
+ return await res.json();
1371
2667
  }
1372
- async function pickChannels(channels) {
1373
- const { default: checkbox } = await import("@inquirer/checkbox");
1374
- const selected = await checkbox({
1375
- message: "Select channels to share",
1376
- choices: channels.map((ch) => ({
1377
- name: ch.description ? `${ch.id} \u2014 ${ch.description}` : ch.id,
1378
- value: ch.id,
1379
- checked: true
1380
- })),
1381
- theme: {
1382
- icon: { cursor: "> " },
1383
- style: { highlight: (text) => text }
1384
- }
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}` }
1385
2672
  });
1386
- const selectedSet = new Set(selected);
1387
- return channels.filter((ch) => selectedSet.has(ch.id));
2673
+ if (!res.ok) return [];
2674
+ const data = await res.json();
2675
+ return data.servers;
1388
2676
  }
1389
- async function promptChannelDetails(channels, skipPrompt) {
1390
- const result = /* @__PURE__ */ new Map();
1391
- if (skipPrompt) {
1392
- for (const ch of channels) {
1393
- result.set(ch.id, {
1394
- description: ch.description ?? "",
1395
- tags: ch.tags
1396
- });
1397
- }
1398
- return result;
1399
- }
1400
- const rl = readline4.createInterface({
1401
- input: process.stdin,
1402
- output: process.stdout
1403
- });
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 = {};
1404
2683
  try {
1405
- console.log("");
1406
- console.log(" Set description and tags for each channel.");
1407
- console.log(" Press Enter to accept defaults shown in [brackets].\n");
1408
- for (const ch of channels) {
1409
- console.log(` ${ch.id}:`);
1410
- const desc = await ask(rl, "Description", ch.description ?? "");
1411
- const tagsRaw = await ask(
1412
- rl,
1413
- "Tags (comma-separated)",
1414
- ch.tags.join(", ")
1415
- );
1416
- const tags = tagsRaw.split(",").map((t) => t.trim()).filter(Boolean);
1417
- result.set(ch.id, { description: desc, tags });
1418
- console.log("");
1419
- }
1420
- } finally {
1421
- rl.close();
2684
+ existing = JSON.parse(fs11.readFileSync(configPath, "utf-8"));
2685
+ } catch {
1422
2686
  }
1423
- return result;
2687
+ existing.url = serverUrl;
2688
+ fs11.writeFileSync(configPath, JSON.stringify(existing, null, 2) + "\n");
1424
2689
  }
1425
-
1426
- // src/commands/unshare.ts
1427
- async function runUnshare(channelId) {
1428
- const client = createClient();
1429
- const config = loadConfig();
1430
- const serverUrl = config.server;
1431
- if (!serverUrl) {
1432
- throw new Error(
1433
- "No server configured. Run: npx zooid config set server <url>"
1434
- );
1435
- }
1436
- const { claim, signature } = await client.getClaim([channelId], "delete");
1437
- const res = await directoryFetch("/api/servers/channels", {
1438
- method: "DELETE",
1439
- body: JSON.stringify({
1440
- server_url: serverUrl,
1441
- channel_id: channelId,
1442
- claim,
1443
- signature
1444
- })
1445
- });
1446
- if (!res.ok) {
1447
- throw new Error(await formatDirectoryError(res));
2690
+ async function runLogin(url, options) {
2691
+ const _fetch = options?.fetch ?? globalThis.fetch;
2692
+ if (url) {
2693
+ return loginToServer(normalizeServerUrl(url), _fetch);
1448
2694
  }
2695
+ return loginToZoon(_fetch);
1449
2696
  }
1450
-
1451
- // src/commands/discover.ts
1452
- async function runDiscover(options) {
1453
- const params = new URLSearchParams();
1454
- if (options.query) params.set("q", options.query);
1455
- if (options.tag) params.set("tag", options.tag);
1456
- if (options.limit) params.set("limit", String(options.limit));
1457
- const qs = params.toString();
1458
- const url = `${DIRECTORY_BASE_URL}/api/discover${qs ? `?${qs}` : ""}`;
1459
- const res = await fetch(url);
1460
- if (!res.ok) {
1461
- throw new Error(`Directory returned ${res.status}`);
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 {
1462
2728
  }
1463
- const data = await res.json();
1464
- if (data.channels.length === 0) {
1465
- console.log("No channels found.");
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");
1466
2744
  return;
1467
2745
  }
1468
- console.log("");
1469
- for (const ch of data.channels) {
1470
- const host = new URL(ch.server_url).host;
1471
- const tags = ch.tags.length > 0 ? ` [${ch.tags.join(", ")}]` : "";
1472
- const desc = ch.description ? ` \u2014 ${ch.description}` : "";
1473
- console.log(` ${host}/${ch.channel_id}${desc}${tags}`);
2746
+ const exchangeResult = await exchangeToken(
2747
+ ACCOUNTS_URL,
2748
+ result.sessionToken,
2749
+ targetServer,
2750
+ { fetch: _fetch }
2751
+ );
2752
+ saveConfig(
2753
+ {
2754
+ admin_token: exchangeResult.token,
2755
+ platform_token: result.sessionToken,
2756
+ auth_method: "oidc"
2757
+ },
2758
+ targetServer,
2759
+ { setCurrent: true }
2760
+ );
2761
+ writeProjectConfig(targetServer);
2762
+ printSuccess(`Server: ${targetServer} (set as current)`);
2763
+ printInfo("Project", `zooid.json \u2192 ${targetServer}`);
2764
+ process.stderr.write("\n");
2765
+ }
2766
+ async function loginToServer(serverUrl, _fetch) {
2767
+ const url = new URL(serverUrl);
2768
+ if (url.hostname.endsWith(".zoon.eco")) {
2769
+ process.stderr.write("\nOpening browser to authenticate with Zoon...\n");
2770
+ const result = await pollDeviceAuth(ACCOUNTS_URL, { fetch: _fetch });
2771
+ process.stderr.write(
2772
+ `
2773
+ Logged in as ${result.user.name || result.user.email}
2774
+ `
2775
+ );
2776
+ const exchangeResult = await exchangeToken(
2777
+ ACCOUNTS_URL,
2778
+ result.sessionToken,
2779
+ serverUrl,
2780
+ { fetch: _fetch }
2781
+ );
2782
+ saveConfig(
2783
+ {
2784
+ admin_token: exchangeResult.token,
2785
+ platform_token: result.sessionToken,
2786
+ auth_method: "oidc"
2787
+ },
2788
+ serverUrl,
2789
+ { setCurrent: true }
2790
+ );
2791
+ writeProjectConfig(serverUrl);
2792
+ printSuccess(`Server: ${serverUrl} (set as current)`);
2793
+ printInfo("Project", `zooid.json \u2192 ${serverUrl}`);
2794
+ return;
1474
2795
  }
1475
- console.log(`
1476
- ${data.total} channel${data.total === 1 ? "" : "s"} found.`);
1477
- console.log("");
2796
+ throw new Error(
2797
+ "CLI login for self-hosted servers with external OIDC is coming soon.\nFor now, use `npx zooid token mint` to create a token manually."
2798
+ );
1478
2799
  }
1479
2800
 
1480
- // src/commands/server.ts
1481
- async function runServerGet(client) {
1482
- const c = client ?? createClient();
1483
- return c.getServerMeta();
2801
+ // src/commands/logout.ts
2802
+ import fs12 from "fs";
2803
+ async function runLogout(options) {
2804
+ const file = loadConfigFile();
2805
+ if (options.all) {
2806
+ for (const url of Object.keys(file.servers ?? {})) {
2807
+ clearServerAuth(file, url);
2808
+ }
2809
+ process.stderr.write("Logged out of all servers\n");
2810
+ } else {
2811
+ const server = resolveServer();
2812
+ if (!server) {
2813
+ throw new Error("No server configured.");
2814
+ }
2815
+ clearServerAuth(file, server);
2816
+ process.stderr.write(`Logged out of ${server}
2817
+ `);
2818
+ }
2819
+ const dir = getConfigDir();
2820
+ fs12.mkdirSync(dir, { recursive: true });
2821
+ fs12.writeFileSync(getStatePath(), JSON.stringify(file, null, 2) + "\n");
1484
2822
  }
1485
- async function runServerSet(fields, client) {
1486
- const c = client ?? createClient();
1487
- return c.updateServerMeta(fields);
2823
+ function clearServerAuth(file, url) {
2824
+ const entry = file.servers?.[url];
2825
+ if (!entry) return;
2826
+ delete entry.admin_token;
2827
+ delete entry.refresh_token;
2828
+ delete entry.auth_method;
1488
2829
  }
1489
2830
 
1490
- // src/commands/token.ts
1491
- async function runTokenMint(scopes, options) {
2831
+ // src/commands/whoami.ts
2832
+ async function runWhoami() {
2833
+ const config = loadConfig();
2834
+ const file = loadConfigFile();
2835
+ if (!config.server) {
2836
+ throw new Error("No server configured. Run: npx zooid login");
2837
+ }
1492
2838
  const client = createClient();
1493
- const body = { scopes };
1494
- if (options.sub) body.sub = options.sub;
1495
- if (options.name) body.name = options.name;
1496
- if (options.expiresIn) body.expires_in = options.expiresIn;
1497
- return client.mintToken(body);
2839
+ const claims = await client.getTokenClaims();
2840
+ const entry = file.servers?.[config.server];
2841
+ return {
2842
+ server: config.server,
2843
+ sub: claims.sub ?? "unknown",
2844
+ name: claims.name,
2845
+ scopes: claims.scopes ?? [],
2846
+ exp: claims.exp,
2847
+ authMethod: entry?.auth_method
2848
+ };
1498
2849
  }
1499
2850
 
1500
- // src/commands/dev.ts
1501
- import { execSync as execSync2, spawn as spawn2 } from "child_process";
1502
- import crypto3 from "crypto";
1503
- import fs6 from "fs";
1504
- import path5 from "path";
1505
- import { fileURLToPath } from "url";
1506
- function findServerDir() {
1507
- const cliDir = path5.dirname(fileURLToPath(import.meta.url));
1508
- return path5.resolve(cliDir, "../../server");
2851
+ // src/commands/credentials.ts
2852
+ function requireZoonServer() {
2853
+ const server = resolveServer();
2854
+ if (!server) {
2855
+ throw new Error("No server configured. Run: npx zooid login");
2856
+ }
2857
+ if (!isZoonHosted(server)) {
2858
+ throw new Error(
2859
+ "Credentials are only available for Zoon-hosted servers (*.zoon.eco)"
2860
+ );
2861
+ }
2862
+ const file = loadConfigFile();
2863
+ const entry = file.servers?.[server];
2864
+ if (!entry?.platform_token) {
2865
+ throw new Error(
2866
+ "Not authenticated with Zoon platform. Run: npx zooid login"
2867
+ );
2868
+ }
2869
+ return { server, platformToken: entry.platform_token };
2870
+ }
2871
+ function formatEnv(server, clientId, clientSecret) {
2872
+ return [
2873
+ `ZOOID_SERVER=${server}`,
2874
+ `ZOOID_CLIENT_ID=${clientId}`,
2875
+ `ZOOID_CLIENT_SECRET=${clientSecret}`
2876
+ ].join("\n");
2877
+ }
2878
+ async function runCredentialsCreate(name, options) {
2879
+ const { server, platformToken } = requireZoonServer();
2880
+ let roleNames = options.role;
2881
+ if (!roleNames || roleNames.length === 0) {
2882
+ const wf = loadWorkforce();
2883
+ if (wf.roles[name]) {
2884
+ roleNames = [name];
2885
+ } else {
2886
+ throw new Error(
2887
+ `No roles specified and no matching agent/role "${name}" found in workforce.json`
2888
+ );
2889
+ }
2890
+ }
2891
+ const result = await createCredential(server, platformToken, name, roleNames);
2892
+ process.stderr.write(
2893
+ `
2894
+ Created credential "${name}" on ${server} (roles: ${roleNames.join(", ")})
2895
+
2896
+ `
2897
+ );
2898
+ return formatEnv(server, result.client_id, result.client_secret);
1509
2899
  }
1510
- async function runDev(port = 8787) {
1511
- const serverDir = findServerDir();
1512
- if (!fs6.existsSync(path5.join(serverDir, "wrangler.toml"))) {
2900
+ async function runCredentialsList() {
2901
+ const { server, platformToken } = requireZoonServer();
2902
+ return listCredentials(server, platformToken);
2903
+ }
2904
+ async function resolveClientId(nameOrId) {
2905
+ const { server, platformToken } = requireZoonServer();
2906
+ const creds = await listCredentials(server, platformToken);
2907
+ const match = creds.find((c) => c.name === nameOrId) || creds.find((c) => c.client_id === nameOrId);
2908
+ if (!match) {
1513
2909
  throw new Error(
1514
- `Server directory not found at ${serverDir}. Make sure you're running from the zooid monorepo.`
2910
+ `Credential "${nameOrId}" not found. Run: npx zooid credentials list`
1515
2911
  );
1516
2912
  }
1517
- const jwtSecret = crypto3.randomUUID();
1518
- const devVarsPath = path5.join(serverDir, ".dev.vars");
1519
- fs6.writeFileSync(devVarsPath, `ZOOID_JWT_SECRET=${jwtSecret}
2913
+ return { clientId: match.client_id, name: match.name };
2914
+ }
2915
+ async function runCredentialsRotate(nameOrId) {
2916
+ const { server, platformToken } = requireZoonServer();
2917
+ const { clientId, name } = await resolveClientId(nameOrId);
2918
+ const result = await rotateCredential(server, platformToken, clientId);
2919
+ process.stderr.write(`
2920
+ Rotated credential "${name}" on ${server}
2921
+
1520
2922
  `);
1521
- const adminToken = await createAdminToken(jwtSecret);
1522
- const serverUrl = `http://localhost:${port}`;
1523
- saveConfig({ admin_token: adminToken }, serverUrl);
1524
- const schemaPath = path5.join(serverDir, "src/db/schema.sql");
1525
- if (fs6.existsSync(schemaPath)) {
1526
- try {
1527
- execSync2(
1528
- `npx wrangler d1 execute zooid-db --local --file=${schemaPath}`,
1529
- {
1530
- cwd: serverDir,
1531
- stdio: "pipe"
1532
- }
1533
- );
1534
- printSuccess("Database schema initialized");
1535
- } catch {
1536
- printSuccess("Database ready (schema already exists)");
2923
+ return formatEnv(server, result.client_id, result.client_secret);
2924
+ }
2925
+ async function runCredentialsRevoke(nameOrId) {
2926
+ const { server, platformToken } = requireZoonServer();
2927
+ const { clientId, name } = await resolveClientId(nameOrId);
2928
+ await revokeCredential(server, platformToken, clientId);
2929
+ process.stderr.write(`
2930
+ Revoked credential "${name}" on ${server}
2931
+ `);
2932
+ }
2933
+
2934
+ // src/lib/auto-refresh.ts
2935
+ function decodeJwtPayload(jwt) {
2936
+ try {
2937
+ const parts = jwt.split(".");
2938
+ if (parts.length !== 3) return null;
2939
+ const payload = atob(parts[1]);
2940
+ return JSON.parse(payload);
2941
+ } catch {
2942
+ return null;
2943
+ }
2944
+ }
2945
+ async function maybeRefreshToken(serverConfig, _serverUrl, _options) {
2946
+ if (serverConfig.auth_method !== "oidc") return;
2947
+ if (!serverConfig.admin_token) return;
2948
+ const payload = decodeJwtPayload(serverConfig.admin_token);
2949
+ if (!payload?.exp) return;
2950
+ const expiresAt = payload.exp * 1e3;
2951
+ const twoMinutes = 2 * 60 * 1e3;
2952
+ if (Date.now() < expiresAt - twoMinutes) return;
2953
+ process.stderr.write(
2954
+ "Session expired. Run `npx zooid login` to re-authenticate.\n"
2955
+ );
2956
+ }
2957
+
2958
+ // src/commands/role.ts
2959
+ function runRoleCreate(id, options) {
2960
+ const wf = loadWorkforce();
2961
+ if (id in wf.roles) {
2962
+ throw new Error(
2963
+ `Role "${id}" already exists. Use "zooid role update" to modify it.`
2964
+ );
2965
+ }
2966
+ const def = { scopes: options.scopes };
2967
+ if (options.name) def.name = options.name;
2968
+ if (options.description) def.description = options.description;
2969
+ wf.roles[id] = def;
2970
+ saveWorkforce(wf);
2971
+ }
2972
+ function runRoleList() {
2973
+ const wf = loadWorkforce();
2974
+ return Object.keys(wf.roles);
2975
+ }
2976
+ function runRoleUpdate(id, fields) {
2977
+ const wf = loadWorkforce();
2978
+ const existing = wf.roles[id];
2979
+ if (!existing) {
2980
+ throw new Error(`Role "${id}" not found. Use "zooid role create" first.`);
2981
+ }
2982
+ if (fields.name !== void 0) {
2983
+ if (fields.name === null) {
2984
+ delete existing.name;
2985
+ } else {
2986
+ existing.name = fields.name;
1537
2987
  }
1538
2988
  }
1539
- printSuccess("Local server configured");
1540
- printInfo("Server", serverUrl);
1541
- printInfo("Admin token", adminToken.slice(0, 20) + "...");
1542
- printInfo("Config saved to", "~/.zooid/state.json");
1543
- console.log("");
1544
- console.log("Starting wrangler dev...");
1545
- console.log("");
1546
- const child = spawn2(
1547
- "npx",
1548
- ["wrangler", "dev", "--local", "--port", String(port)],
1549
- {
1550
- cwd: serverDir,
1551
- stdio: "inherit",
1552
- shell: true
2989
+ if (fields.description !== void 0) {
2990
+ if (fields.description === null) {
2991
+ delete existing.description;
2992
+ } else {
2993
+ existing.description = fields.description;
1553
2994
  }
1554
- );
1555
- child.on("error", (err) => {
1556
- console.error(`Failed to start local server: ${err.message}`);
1557
- process.exit(1);
1558
- });
1559
- child.on("exit", (code) => {
1560
- process.exit(code ?? 0);
1561
- });
2995
+ }
2996
+ if (fields.scopes !== void 0) {
2997
+ existing.scopes = fields.scopes;
2998
+ }
2999
+ const targetFile = wf.provenance.roles[id];
3000
+ if (targetFile) {
3001
+ updateInFile(targetFile, "roles", id, existing);
3002
+ } else {
3003
+ wf.roles[id] = existing;
3004
+ saveWorkforce(wf);
3005
+ }
3006
+ return existing;
3007
+ }
3008
+ function runRoleDelete(id) {
3009
+ const wf = loadWorkforce();
3010
+ if (!(id in wf.roles)) {
3011
+ throw new Error(`Role "${id}" not found in .zooid/workforce.json`);
3012
+ }
3013
+ const targetFile = wf.provenance.roles[id];
3014
+ if (targetFile) {
3015
+ removeFromFile(targetFile, "roles", id);
3016
+ } else {
3017
+ delete wf.roles[id];
3018
+ saveWorkforce(wf);
3019
+ }
1562
3020
  }
1563
3021
 
1564
3022
  // src/index.ts
@@ -1584,7 +3042,7 @@ async function resolveAndRecord(channel, opts) {
1584
3042
  return result;
1585
3043
  }
1586
3044
  var program = new Command();
1587
- program.name("zooid").description("\u{1FAB8} Pub/sub for AI agents").version("0.5.1");
3045
+ program.name("zooid").description("\u{1FAB8} Pub/sub for AI agents").version("0.6.1");
1588
3046
  var telemetryCtx = { startTime: 0 };
1589
3047
  function setTelemetryChannel(channelId) {
1590
3048
  telemetryCtx.channelId = channelId;
@@ -1608,11 +3066,22 @@ function handleError(commandName, err) {
1608
3066
  printError(message);
1609
3067
  process.exit(1);
1610
3068
  }
1611
- program.hook("preAction", () => {
3069
+ program.hook("preAction", async () => {
1612
3070
  telemetryCtx.startTime = Date.now();
1613
3071
  if (isEnabled()) {
1614
3072
  showNoticeIfNeeded();
1615
3073
  }
3074
+ try {
3075
+ const file = loadConfigFile();
3076
+ const server = resolveServer();
3077
+ if (server && file.servers?.[server]) {
3078
+ const entry = file.servers[server];
3079
+ await maybeRefreshToken(entry, server, {
3080
+ save: (partial) => saveConfig(partial, server)
3081
+ });
3082
+ }
3083
+ } catch {
3084
+ }
1616
3085
  });
1617
3086
  function sendTelemetry(commandName, exitCode, error) {
1618
3087
  if (!isEnabled()) return;
@@ -1651,20 +3120,65 @@ program.command("dev").description("Start local development server").option("--p
1651
3120
  handleError("dev", err);
1652
3121
  }
1653
3122
  });
1654
- program.command("init").description("Create zooid-server.json with server identity").action(async () => {
3123
+ program.command("init").description("Create zooid.json with server identity").option("--use <url>", "Include a template from a GitHub URL").action(async (opts) => {
1655
3124
  try {
1656
- await runInit();
3125
+ await runInit({ use: opts.use });
1657
3126
  } catch (err) {
1658
3127
  handleError("init", err);
1659
3128
  }
1660
3129
  });
1661
- program.command("deploy").description("Deploy Zooid server to Cloudflare Workers").action(async () => {
3130
+ program.command("use <url>").description("Add a template to your workforce via include").action(async (url) => {
3131
+ try {
3132
+ await runUse(url);
3133
+ } catch (err) {
3134
+ handleError("use", err);
3135
+ }
3136
+ });
3137
+ program.command("deploy").description("Deploy Zooid server to Cloudflare Workers").option("--prune", "Delete server resources not in workforce.json").action(async (opts) => {
1662
3138
  try {
1663
- await runDeploy();
3139
+ await runDeploy(opts);
1664
3140
  } catch (err) {
1665
3141
  handleError("deploy", err);
1666
3142
  }
1667
3143
  });
3144
+ program.command("destroy").description("Destroy a deployed Zooid server and all its data").option("--force", "Skip confirmation prompt").option("--keep-local", "Keep wrangler.toml and state entries").action(async (options) => {
3145
+ try {
3146
+ await runDestroy({
3147
+ force: options.force,
3148
+ keepLocal: options.keepLocal
3149
+ });
3150
+ } catch (err) {
3151
+ handleError("destroy", err);
3152
+ }
3153
+ });
3154
+ program.command("login").argument("[url]", "Server URL (e.g., https://beno.zoon.eco)").description("Authenticate with Zoon or a specific server").action(async (url) => {
3155
+ try {
3156
+ await runLogin(url);
3157
+ } catch (err) {
3158
+ handleError("login", err);
3159
+ }
3160
+ });
3161
+ program.command("logout").option("--all", "Log out of all servers").description("Clear authentication for the current server").action(async (opts) => {
3162
+ try {
3163
+ await runLogout(opts);
3164
+ } catch (err) {
3165
+ handleError("logout", err);
3166
+ }
3167
+ });
3168
+ program.command("whoami").description("Show current identity and auth status").action(async () => {
3169
+ try {
3170
+ const result = await runWhoami();
3171
+ console.log(`Server: ${result.server}`);
3172
+ console.log(`User: ${result.name || result.sub}`);
3173
+ console.log(`Scopes: ${result.scopes.join(", ")}`);
3174
+ if (result.authMethod) {
3175
+ const expStr = result.exp ? ` (expires ${new Date(result.exp * 1e3).toISOString()})` : "";
3176
+ console.log(`Auth: ${result.authMethod}${expStr}`);
3177
+ }
3178
+ } catch (err) {
3179
+ handleError("whoami", err);
3180
+ }
3181
+ });
1668
3182
  var configCmd = program.command("config").description("Manage Zooid configuration");
1669
3183
  configCmd.command("set <key> <value>").description("Set a config value (server, admin-token, telemetry)").action((key, value) => {
1670
3184
  try {
@@ -1687,9 +3201,9 @@ configCmd.command("get <key>").description("Get a config value").action((key) =>
1687
3201
  }
1688
3202
  });
1689
3203
  var channelCmd = program.command("channel").description("Manage channels");
1690
- 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(
3204
+ channelCmd.command("create <id>").description("Create a new channel").option("--name <name>", "Display name (defaults to id)").option("--description <desc>", "Channel description").option("--public", "Make channel public").option("--private", "Make channel private (default)", true).option("--strict", "Enable strict schema validation on publish").option(
1691
3205
  "--config <file>",
1692
- "Path to channel config JSON file (display, types, storage)"
3206
+ "Path to channel config JSON file (types, policies, storage)"
1693
3207
  ).option(
1694
3208
  "--schema <file>",
1695
3209
  "Path to JSON schema file (map of event types to JSON schemas)"
@@ -1697,12 +3211,12 @@ channelCmd.command("create <id>").description("Create a new channel").option("--
1697
3211
  try {
1698
3212
  let config;
1699
3213
  if (opts.config) {
1700
- const fs7 = await import("fs");
1701
- const raw = fs7.readFileSync(opts.config, "utf-8");
3214
+ const fs13 = await import("fs");
3215
+ const raw = fs13.readFileSync(opts.config, "utf-8");
1702
3216
  config = JSON.parse(raw);
1703
3217
  } else if (opts.schema) {
1704
- const fs7 = await import("fs");
1705
- const raw = fs7.readFileSync(opts.schema, "utf-8");
3218
+ const fs13 = await import("fs");
3219
+ const raw = fs13.readFileSync(opts.schema, "utf-8");
1706
3220
  const parsed = JSON.parse(raw);
1707
3221
  const types = {};
1708
3222
  for (const [eventType, schemaDef] of Object.entries(parsed)) {
@@ -1713,7 +3227,7 @@ channelCmd.command("create <id>").description("Create a new channel").option("--
1713
3227
  const result = await runChannelCreate(id, {
1714
3228
  name: opts.name,
1715
3229
  description: opts.description,
1716
- public: opts.private ? false : true,
3230
+ public: opts.public ? true : false,
1717
3231
  strict: opts.strict,
1718
3232
  config
1719
3233
  });
@@ -1725,7 +3239,7 @@ channelCmd.command("create <id>").description("Create a new channel").option("--
1725
3239
  });
1726
3240
  channelCmd.command("update <id>").description("Update a channel").option("--name <name>", "Display name").option("--description <desc>", "Channel description").option("--tags <tags>", "Comma-separated tags").option("--public", "Make channel public").option("--private", "Make channel private").option("--strict", "Enable strict schema validation on publish").option("--no-strict", "Disable strict schema validation").option(
1727
3241
  "--config <file>",
1728
- "Path to channel config JSON file (display, types, storage)"
3242
+ "Path to channel config JSON file (types, policies, storage)"
1729
3243
  ).option(
1730
3244
  "--schema <file>",
1731
3245
  "Path to JSON schema file (map of event types to JSON schemas)"
@@ -1739,12 +3253,12 @@ channelCmd.command("update <id>").description("Update a channel").option("--name
1739
3253
  if (opts.public) fields.is_public = true;
1740
3254
  if (opts.private) fields.is_public = false;
1741
3255
  if (opts.config) {
1742
- const fs7 = await import("fs");
1743
- const raw = fs7.readFileSync(opts.config, "utf-8");
3256
+ const fs13 = await import("fs");
3257
+ const raw = fs13.readFileSync(opts.config, "utf-8");
1744
3258
  fields.config = JSON.parse(raw);
1745
3259
  } else if (opts.schema) {
1746
- const fs7 = await import("fs");
1747
- const raw = fs7.readFileSync(opts.schema, "utf-8");
3260
+ const fs13 = await import("fs");
3261
+ const raw = fs13.readFileSync(opts.schema, "utf-8");
1748
3262
  const parsed = JSON.parse(raw);
1749
3263
  const types = {};
1750
3264
  for (const [eventType, schemaDef] of Object.entries(parsed)) {
@@ -1790,8 +3304,8 @@ channelCmd.command("list").description("List all channels").action(async () => {
1790
3304
  channelCmd.command("delete <id>").description("Delete a channel and all its data").option("-y, --yes", "Skip confirmation prompt").action(async (id, opts) => {
1791
3305
  try {
1792
3306
  if (!opts.yes) {
1793
- const readline5 = await import("readline");
1794
- const rl = readline5.createInterface({
3307
+ const readline7 = await import("readline");
3308
+ const rl = readline7.createInterface({
1795
3309
  input: process.stdin,
1796
3310
  output: process.stdout
1797
3311
  });
@@ -1813,21 +3327,21 @@ channelCmd.command("delete <id>").description("Delete a channel and all its data
1813
3327
  handleError("channel delete", err);
1814
3328
  }
1815
3329
  });
1816
- program.command("push").description("Push local channel definitions (channels/) to server").action(async () => {
1817
- try {
1818
- await runPush();
1819
- } catch (err) {
1820
- handleError("push", err);
1821
- }
1822
- });
1823
- program.command("pull").description("Pull channel definitions from server into channels/").action(async () => {
3330
+ program.command("pull").description(
3331
+ "Pull channel and role definitions from server into workforce.json"
3332
+ ).action(async () => {
1824
3333
  try {
1825
3334
  await runPull();
1826
3335
  } catch (err) {
1827
3336
  handleError("pull", err);
1828
3337
  }
1829
3338
  });
1830
- 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) => {
3339
+ program.command("publish <channel> [data]").description(
3340
+ "Publish an event to a channel (accepts JSON arg, --data, --file, or stdin)"
3341
+ ).option("--type <type>", "Event type").option("--data <json>", "Event data as JSON string").option("--file <path>", "Read event from JSON file").option(
3342
+ "--stream",
3343
+ "Stream mode: read newline-delimited JSON from stdin, publish each line"
3344
+ ).option("--token <token>", "Auth token (for remote/private channels)").action(async (channel, dataArg, opts) => {
1831
3345
  try {
1832
3346
  const { client, channelId, tokenSaved } = resolveChannel(channel, {
1833
3347
  token: opts.token,
@@ -1840,8 +3354,19 @@ program.command("publish <channel>").description("Publish an event to a channel"
1840
3354
  `for ${channelId} \u2014 won't need --token next time`
1841
3355
  );
1842
3356
  }
1843
- const event = await runPublish(channelId, opts, client);
1844
- printSuccess(`Published event: ${event.id}`);
3357
+ if (opts.stream) {
3358
+ const { published, errors } = await runPublishStream(
3359
+ channelId,
3360
+ opts,
3361
+ client,
3362
+ (event) => printSuccess(`Published event: ${event.id}`)
3363
+ );
3364
+ const summary = `${published} published`;
3365
+ printSuccess(errors ? `${summary}, ${errors} failed` : summary);
3366
+ } else {
3367
+ const event = await runPublish(channelId, opts, client, dataArg);
3368
+ printSuccess(`Published event: ${event.id}`);
3369
+ }
1845
3370
  } catch (err) {
1846
3371
  handleError("publish", err);
1847
3372
  }
@@ -1852,8 +3377,8 @@ program.command("delete-event <channel> <event-id>").description("Delete a singl
1852
3377
  tokenType: "publish"
1853
3378
  });
1854
3379
  if (!opts.yes) {
1855
- const readline5 = await import("readline");
1856
- const rl = readline5.createInterface({
3380
+ const readline7 = await import("readline");
3381
+ const rl = readline7.createInterface({
1857
3382
  input: process.stdin,
1858
3383
  output: process.stdout
1859
3384
  });
@@ -2036,21 +3561,32 @@ var tokenCmd = program.command("token").description("Manage tokens");
2036
3561
  tokenCmd.command("mint").description(
2037
3562
  "Mint a new token. Scopes: admin, pub:<channel>, sub:<channel>. Wildcards: pub:*, sub:prefix-*"
2038
3563
  ).argument(
2039
- "<scopes...>",
3564
+ "[scopes...]",
2040
3565
  "Scopes to grant (e.g. admin, pub:my-channel, sub:*)"
3566
+ ).option(
3567
+ "--role <roles...>",
3568
+ "Mint with scopes from named roles (reads workforce.json)"
2041
3569
  ).option("--sub <sub>", "Subject identifier (e.g. publisher ID)").option("--name <name>", "Display name (used for publisher identity)").option("--expires-in <duration>", "Token expiry (e.g. 5m, 1h, 7d, 30d)").action(async (scopes, opts) => {
2042
3570
  try {
2043
- for (const s of scopes) {
2044
- if (s !== "admin" && !s.startsWith("pub:") && !s.startsWith("sub:")) {
2045
- throw new Error(
2046
- `Invalid scope "${s}". Must be "admin", "pub:<channel>", or "sub:<channel>"`
2047
- );
3571
+ if (!opts.role?.length && (!scopes || scopes.length === 0)) {
3572
+ printError("Provide scopes or --role");
3573
+ process.exit(1);
3574
+ }
3575
+ if (!opts.role?.length) {
3576
+ for (const s of scopes) {
3577
+ if (s !== "admin" && !s.startsWith("pub:") && !s.startsWith("sub:")) {
3578
+ printError(
3579
+ `Invalid scope "${s}". Must be "admin", "pub:<channel>", or "sub:<channel>"`
3580
+ );
3581
+ process.exit(1);
3582
+ }
2048
3583
  }
2049
3584
  }
2050
- const result = await runTokenMint(scopes, {
3585
+ const result = await runTokenMint(scopes ?? [], {
2051
3586
  sub: opts.sub,
2052
3587
  name: opts.name,
2053
- expiresIn: opts.expiresIn
3588
+ expiresIn: opts.expiresIn,
3589
+ role: opts.role
2054
3590
  });
2055
3591
  console.log(result.token);
2056
3592
  } catch (err) {
@@ -2128,6 +3664,130 @@ program.command("unshare <channel>").description("Remove a channel from the Zooi
2128
3664
  handleError("unshare", err);
2129
3665
  }
2130
3666
  });
3667
+ var roleCmd = program.command("role").description("Manage role definitions");
3668
+ roleCmd.command("create <id>").description("Create a role definition in workforce.json").option("--name <name>", "Display name").option("--description <desc>", "Role description").argument("<scopes...>", "Scopes to grant (e.g. pub:signals sub:market-data)").action((id, scopes, opts) => {
3669
+ try {
3670
+ for (const s of scopes) {
3671
+ if (s !== "admin" && !s.startsWith("pub:") && !s.startsWith("sub:")) {
3672
+ printError(
3673
+ `Invalid scope "${s}". Must be "admin", "pub:<channel>", or "sub:<channel>"`
3674
+ );
3675
+ process.exit(1);
3676
+ }
3677
+ }
3678
+ runRoleCreate(id, {
3679
+ name: opts.name,
3680
+ description: opts.description,
3681
+ scopes
3682
+ });
3683
+ printSuccess(`Created role "${id}" in workforce.json`);
3684
+ printInfo("Next", "Run `npx zooid deploy` to sync to server");
3685
+ } catch (err) {
3686
+ handleError("role create", err);
3687
+ }
3688
+ });
3689
+ roleCmd.command("list").description("List role definitions in workforce.json").action(() => {
3690
+ try {
3691
+ const ids = runRoleList();
3692
+ if (ids.length === 0) {
3693
+ console.log(
3694
+ "No roles defined. Create one with: npx zooid role create <id> <scopes...>"
3695
+ );
3696
+ } else {
3697
+ for (const id of ids) {
3698
+ console.log(` ${id}`);
3699
+ }
3700
+ }
3701
+ } catch (err) {
3702
+ handleError("role list", err);
3703
+ }
3704
+ });
3705
+ roleCmd.command("update <id>").description("Update a role definition in workforce.json").option("--name <name>", "Display name").option("--description <desc>", "Role description").option("--scopes <scopes...>", "Replace scopes").action((id, opts) => {
3706
+ try {
3707
+ const fields = {};
3708
+ if (opts.name !== void 0) fields.name = opts.name;
3709
+ if (opts.description !== void 0) fields.description = opts.description;
3710
+ if (opts.scopes !== void 0) fields.scopes = opts.scopes;
3711
+ if (Object.keys(fields).length === 0) {
3712
+ throw new Error(
3713
+ "No fields specified. Use --name, --description, or --scopes."
3714
+ );
3715
+ }
3716
+ runRoleUpdate(id, fields);
3717
+ printSuccess(`Updated role "${id}" in workforce.json`);
3718
+ printInfo("Next", "Run `npx zooid deploy` to sync to server");
3719
+ } catch (err) {
3720
+ handleError("role update", err);
3721
+ }
3722
+ });
3723
+ roleCmd.command("delete <id>").description("Delete a role definition from workforce.json").option("-y, --yes", "Skip confirmation prompt").action(async (id, opts) => {
3724
+ try {
3725
+ if (!opts.yes) {
3726
+ const readline7 = await import("readline");
3727
+ const rl = readline7.createInterface({
3728
+ input: process.stdin,
3729
+ output: process.stdout
3730
+ });
3731
+ const answer = await new Promise((resolve) => {
3732
+ rl.question(
3733
+ `Delete role "${id}" from workforce.json? [y/N] `,
3734
+ resolve
3735
+ );
3736
+ });
3737
+ rl.close();
3738
+ if (answer.toLowerCase() !== "y") {
3739
+ console.log("Aborted.");
3740
+ return;
3741
+ }
3742
+ }
3743
+ runRoleDelete(id);
3744
+ printSuccess(`Deleted role "${id}" from workforce.json`);
3745
+ printInfo("Next", "Run `npx zooid deploy` to sync to server");
3746
+ } catch (err) {
3747
+ handleError("role delete", err);
3748
+ }
3749
+ });
3750
+ var credentialsCmd = program.command("credentials").description("Manage M2M agent credentials");
3751
+ credentialsCmd.command("create <name>").option("--role <role...>", "Role names to assign").description("Create a new credential (outputs .env to stdout)").action(async (name, opts) => {
3752
+ try {
3753
+ const env = await runCredentialsCreate(name, opts);
3754
+ process.stdout.write(env + "\n");
3755
+ } catch (err) {
3756
+ handleError("credentials create", err);
3757
+ }
3758
+ });
3759
+ credentialsCmd.command("list").description("List all credentials for the current server").action(async () => {
3760
+ try {
3761
+ const creds = await runCredentialsList();
3762
+ if (creds.length === 0) {
3763
+ printInfo("No credentials", "found for this server");
3764
+ return;
3765
+ }
3766
+ for (const c of creds) {
3767
+ const roleNames = c.roles.map((r) => r.name ?? r).join(", ");
3768
+ console.log(
3769
+ ` ${c.name.padEnd(20)} ${c.client_id.padEnd(35)} roles: ${roleNames}`
3770
+ );
3771
+ }
3772
+ } catch (err) {
3773
+ handleError("credentials list", err);
3774
+ }
3775
+ });
3776
+ credentialsCmd.command("rotate <name>").description("Rotate credential secret (outputs .env to stdout)").action(async (name) => {
3777
+ try {
3778
+ const env = await runCredentialsRotate(name);
3779
+ process.stdout.write(env + "\n");
3780
+ } catch (err) {
3781
+ handleError("credentials rotate", err);
3782
+ }
3783
+ });
3784
+ credentialsCmd.command("revoke <name>").description("Revoke (delete) a credential").action(async (name) => {
3785
+ try {
3786
+ await runCredentialsRevoke(name);
3787
+ } catch (err) {
3788
+ handleError("credentials revoke", err);
3789
+ }
3790
+ });
2131
3791
  program.parse();
2132
3792
  export {
2133
3793
  setTelemetryChannel