writbase 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (30) hide show
  1. package/README.md +121 -0
  2. package/dist/writbase.js +707 -0
  3. package/migrations/00001_enums.sql +7 -0
  4. package/migrations/00002_core_tables.sql +105 -0
  5. package/migrations/00003_constraints_indexes.sql +69 -0
  6. package/migrations/00004_rls_policies.sql +169 -0
  7. package/migrations/00005_seed_app_settings.sql +2 -0
  8. package/migrations/00006_rate_limit_rpc.sql +14 -0
  9. package/migrations/00007_rate_limit_rpc_search_path.sql +14 -0
  10. package/migrations/00008_indexes_and_cleanup.sql +22 -0
  11. package/migrations/00009_atomic_mutations.sql +175 -0
  12. package/migrations/00010_user_rate_limits.sql +52 -0
  13. package/migrations/00011_atomic_permission_update.sql +31 -0
  14. package/migrations/00012_pg_cron_cleanup.sql +27 -0
  15. package/migrations/00013_max_agent_keys.sql +4 -0
  16. package/migrations/00014_event_log_indexes.sql +2 -0
  17. package/migrations/00015_auth_rate_limits.sql +69 -0
  18. package/migrations/00016_request_log.sql +42 -0
  19. package/migrations/00017_task_search.sql +58 -0
  20. package/migrations/00018_inter_agent_exchange.sql +423 -0
  21. package/migrations/00019_workspaces.sql +782 -0
  22. package/migrations/00020_can_comment.sql +46 -0
  23. package/migrations/00021_webhook_delivery.sql +125 -0
  24. package/migrations/00022_task_archive.sql +380 -0
  25. package/migrations/00023_get_top_tasks.sql +21 -0
  26. package/migrations/00024_agent_key_defaults.sql +55 -0
  27. package/migrations/00025_production_automation.sql +353 -0
  28. package/migrations/00026_top_tasks_exclude_terminal.sql +19 -0
  29. package/migrations/00027_rename_agent_key_defaults.sql +51 -0
  30. package/package.json +34 -0
@@ -0,0 +1,707 @@
1
+ #!/usr/bin/env node
2
+
3
+ // src/bin/writbase.ts
4
+ import { Command } from "commander";
5
+
6
+ // src/commands/init.ts
7
+ import { webcrypto } from "crypto";
8
+ import { execSync } from "child_process";
9
+ import { existsSync } from "fs";
10
+ import { join as join2 } from "path";
11
+ import { confirm, input, select } from "@inquirer/prompts";
12
+ import { createSpinner } from "nanospinner";
13
+
14
+ // src/lib/config.ts
15
+ import dotenv from "dotenv";
16
+ import { writeFileSync, renameSync } from "fs";
17
+ import { join } from "path";
18
+
19
+ // src/lib/output.ts
20
+ import pc from "picocolors";
21
+ function success(msg) {
22
+ console.log(pc.green("\u2713 ") + msg);
23
+ }
24
+ function error(msg) {
25
+ console.error(pc.red("\u2717 ") + msg);
26
+ }
27
+ function warn(msg) {
28
+ console.log(pc.yellow("\u26A0 ") + msg);
29
+ }
30
+ function info(msg) {
31
+ console.log(pc.cyan("\u2139 ") + msg);
32
+ }
33
+ function table(headers, rows) {
34
+ const colWidths = headers.map(
35
+ (h, i) => Math.max(h.length, ...rows.map((r) => (r[i] ?? "").length))
36
+ );
37
+ const sep = colWidths.map((w) => "\u2500".repeat(w + 2)).join("\u253C");
38
+ const formatRow = (row) => row.map((cell, i) => ` ${(cell ?? "").padEnd(colWidths[i])} `).join("\u2502");
39
+ console.log(pc.bold(formatRow(headers)));
40
+ console.log(sep);
41
+ for (const row of rows) {
42
+ console.log(formatRow(row));
43
+ }
44
+ }
45
+
46
+ // src/lib/config.ts
47
+ function loadConfigPartial() {
48
+ dotenv.config();
49
+ return {
50
+ supabaseUrl: process.env.SUPABASE_URL,
51
+ supabaseServiceRoleKey: process.env.SUPABASE_SERVICE_ROLE_KEY,
52
+ databaseUrl: process.env.DATABASE_URL,
53
+ workspaceId: process.env.WRITBASE_WORKSPACE_ID
54
+ };
55
+ }
56
+ function loadConfig() {
57
+ const partial = loadConfigPartial();
58
+ const missing = [];
59
+ if (!partial.supabaseUrl) missing.push("SUPABASE_URL");
60
+ if (!partial.supabaseServiceRoleKey) missing.push("SUPABASE_SERVICE_ROLE_KEY");
61
+ if (!partial.databaseUrl) missing.push("DATABASE_URL");
62
+ if (!partial.workspaceId) missing.push("WRITBASE_WORKSPACE_ID");
63
+ if (missing.length > 0) {
64
+ error(`Missing required environment variables: ${missing.join(", ")}`);
65
+ console.log("Run `writbase init` to configure.");
66
+ process.exit(1);
67
+ }
68
+ return partial;
69
+ }
70
+ function writeEnv(vars) {
71
+ const envPath = join(process.cwd(), ".env");
72
+ const tmpPath = join(process.cwd(), ".env.tmp");
73
+ const content = Object.entries(vars).map(([key2, value]) => `${key2}=${value}`).join("\n") + "\n";
74
+ writeFileSync(tmpPath, content, { mode: 384 });
75
+ renameSync(tmpPath, envPath);
76
+ }
77
+
78
+ // src/lib/supabase.ts
79
+ import { createClient } from "@supabase/supabase-js";
80
+ function createAdminClient(url, key2) {
81
+ return createClient(url, key2, {
82
+ auth: {
83
+ autoRefreshToken: false,
84
+ persistSession: false
85
+ }
86
+ });
87
+ }
88
+
89
+ // src/commands/init.ts
90
+ async function initCommand() {
91
+ console.log();
92
+ info("WritBase \u2014 Interactive Setup");
93
+ console.log();
94
+ const envPath = join2(process.cwd(), ".env");
95
+ if (existsSync(envPath)) {
96
+ const overwrite = await confirm({
97
+ message: ".env file already exists. Reconfigure?",
98
+ default: false
99
+ });
100
+ if (!overwrite) return;
101
+ }
102
+ const existing = loadConfigPartial();
103
+ let supabaseUrl = existing.supabaseUrl ?? "";
104
+ let serviceRoleKey = existing.supabaseServiceRoleKey ?? "";
105
+ let databaseUrl = existing.databaseUrl ?? "";
106
+ const hasExisting = await confirm({
107
+ message: "Do you have an existing Supabase project?",
108
+ default: true
109
+ });
110
+ if (hasExisting) {
111
+ const hosting = await select({
112
+ message: "Where is your Supabase instance?",
113
+ choices: [
114
+ { name: "Hosted (supabase.co)", value: "hosted" },
115
+ { name: "Local (supabase start)", value: "local" }
116
+ ]
117
+ });
118
+ if (hosting === "local") {
119
+ try {
120
+ const statusJson = execSync("supabase status --output json", {
121
+ encoding: "utf-8",
122
+ stdio: ["ignore", "pipe", "pipe"]
123
+ });
124
+ const status = JSON.parse(statusJson);
125
+ supabaseUrl = status.API_URL ?? supabaseUrl;
126
+ serviceRoleKey = status.SERVICE_ROLE_KEY ?? serviceRoleKey;
127
+ databaseUrl = status.DB_URL ?? databaseUrl;
128
+ success("Auto-detected local Supabase credentials");
129
+ } catch {
130
+ warn("Could not auto-detect. Please enter credentials manually.");
131
+ }
132
+ }
133
+ if (!supabaseUrl) {
134
+ supabaseUrl = await input({
135
+ message: "Supabase URL:",
136
+ default: existing.supabaseUrl
137
+ });
138
+ }
139
+ if (!serviceRoleKey) {
140
+ serviceRoleKey = await input({
141
+ message: "Service Role Key:",
142
+ default: existing.supabaseServiceRoleKey
143
+ });
144
+ }
145
+ if (!databaseUrl) {
146
+ databaseUrl = await input({
147
+ message: "Database URL (postgresql://...):",
148
+ default: existing.databaseUrl
149
+ });
150
+ }
151
+ } else {
152
+ try {
153
+ execSync("which supabase", { stdio: "ignore" });
154
+ } catch {
155
+ error("Supabase CLI not found.");
156
+ info("Install: https://supabase.com/docs/guides/cli");
157
+ process.exit(1);
158
+ }
159
+ const startLocal = await confirm({
160
+ message: "Start a new local Supabase instance? (runs `supabase init` + `supabase start`)",
161
+ default: true
162
+ });
163
+ if (!startLocal) {
164
+ info("Set up a Supabase project first, then run `writbase init` again.");
165
+ return;
166
+ }
167
+ const spinner2 = createSpinner("Starting local Supabase...").start();
168
+ try {
169
+ execSync("supabase init --force", { stdio: "ignore" });
170
+ execSync("supabase start", { stdio: "ignore", timeout: 12e4 });
171
+ const statusJson = execSync("supabase status --output json", {
172
+ encoding: "utf-8",
173
+ stdio: ["ignore", "pipe", "pipe"]
174
+ });
175
+ const status = JSON.parse(statusJson);
176
+ supabaseUrl = status.API_URL;
177
+ serviceRoleKey = status.SERVICE_ROLE_KEY;
178
+ databaseUrl = status.DB_URL;
179
+ spinner2.success({ text: "Local Supabase started" });
180
+ } catch (err) {
181
+ spinner2.error({ text: "Failed to start Supabase" });
182
+ error(err instanceof Error ? err.message : String(err));
183
+ process.exit(1);
184
+ }
185
+ }
186
+ const spinner = createSpinner("Validating connection...").start();
187
+ const supabase = createAdminClient(supabaseUrl, serviceRoleKey);
188
+ try {
189
+ const { error: connError } = await supabase.from("_does_not_exist").select("*").limit(1);
190
+ if (connError && !connError.message.includes("does not exist")) {
191
+ spinner.error({ text: "Connection failed" });
192
+ error(connError.message);
193
+ process.exit(1);
194
+ }
195
+ spinner.success({ text: "Connection validated" });
196
+ } catch (err) {
197
+ spinner.error({ text: "Connection failed" });
198
+ error(err instanceof Error ? err.message : String(err));
199
+ process.exit(1);
200
+ }
201
+ const { error: schemaError } = await supabase.from("workspaces").select("id").limit(1);
202
+ const schemaExists = !schemaError;
203
+ if (!schemaExists) {
204
+ warn("Schema not initialized. Run `writbase migrate` first.");
205
+ writeEnv({
206
+ SUPABASE_URL: supabaseUrl,
207
+ SUPABASE_SERVICE_ROLE_KEY: serviceRoleKey,
208
+ DATABASE_URL: databaseUrl
209
+ });
210
+ success(".env written (partial \u2014 no workspace yet)");
211
+ console.log();
212
+ info("Next steps:");
213
+ console.log(" 1. writbase migrate");
214
+ console.log(" 2. writbase init (re-run to complete setup)");
215
+ console.log();
216
+ return;
217
+ }
218
+ const { data: workspaces } = await supabase.from("workspaces").select("id, name, slug");
219
+ let workspaceId;
220
+ if (workspaces && workspaces.length > 0) {
221
+ if (workspaces.length === 1) {
222
+ workspaceId = workspaces[0].id;
223
+ info(`Using workspace: ${workspaces[0].name} (${workspaces[0].slug})`);
224
+ } else {
225
+ workspaceId = await select({
226
+ message: "Select workspace:",
227
+ choices: workspaces.map((w) => ({
228
+ name: `${w.name} (${w.slug})`,
229
+ value: w.id
230
+ }))
231
+ });
232
+ }
233
+ } else {
234
+ info("No workspaces found. Creating one...");
235
+ const spinner2 = createSpinner("Creating system user and workspace...").start();
236
+ try {
237
+ const { data: user, error: userError } = await supabase.auth.admin.createUser({
238
+ email: "system@writbase.local",
239
+ password: webcrypto.randomUUID(),
240
+ email_confirm: true
241
+ });
242
+ let userId;
243
+ if (userError) {
244
+ if (userError.message?.includes("already") || userError.status === 422) {
245
+ const { data: existingUsers } = await supabase.auth.admin.listUsers();
246
+ const existing2 = existingUsers?.users?.find(
247
+ (u) => u.email === "system@writbase.local"
248
+ );
249
+ if (!existing2) {
250
+ spinner2.error({ text: "Could not find existing system user" });
251
+ process.exit(1);
252
+ }
253
+ userId = existing2.id;
254
+ } else {
255
+ throw userError;
256
+ }
257
+ } else {
258
+ userId = user.user.id;
259
+ }
260
+ await new Promise((resolve) => setTimeout(resolve, 1e3));
261
+ const { data: ws } = await supabase.from("workspaces").select("id, name, slug").eq("owner_id", userId).single();
262
+ if (!ws) {
263
+ spinner2.error({ text: "Workspace not created by trigger" });
264
+ error("The handle_new_user() trigger may not be set up. Run `writbase migrate` first.");
265
+ process.exit(1);
266
+ }
267
+ workspaceId = ws.id;
268
+ spinner2.success({ text: `Workspace created: ${ws.name}` });
269
+ } catch (err) {
270
+ spinner2.error({ text: "Failed to create workspace" });
271
+ error(err instanceof Error ? err.message : String(err));
272
+ process.exit(1);
273
+ }
274
+ }
275
+ writeEnv({
276
+ SUPABASE_URL: supabaseUrl,
277
+ SUPABASE_SERVICE_ROLE_KEY: serviceRoleKey,
278
+ DATABASE_URL: databaseUrl,
279
+ WRITBASE_WORKSPACE_ID: workspaceId
280
+ });
281
+ console.log();
282
+ success(".env written");
283
+ console.log();
284
+ info("Next steps:");
285
+ if (!schemaExists) {
286
+ console.log(" 1. writbase migrate");
287
+ console.log(" 2. writbase key create");
288
+ } else {
289
+ console.log(" 1. writbase key create");
290
+ }
291
+ console.log(" Then: writbase status");
292
+ console.log();
293
+ }
294
+
295
+ // src/commands/migrate.ts
296
+ import { execSync as execSync2 } from "child_process";
297
+ import { mkdtempSync, mkdirSync, cpSync, writeFileSync as writeFileSync2, rmSync } from "fs";
298
+ import { join as join3 } from "path";
299
+ import { tmpdir } from "os";
300
+ import { fileURLToPath } from "url";
301
+ import { createSpinner as createSpinner2 } from "nanospinner";
302
+ var CONFIG_TOML = `project_id = "writbase"
303
+
304
+ [api]
305
+ enabled = false
306
+
307
+ [db]
308
+ port = 54322
309
+ major_version = 15
310
+
311
+ [auth]
312
+ enabled = false
313
+ `;
314
+ async function migrateCommand(opts) {
315
+ const config = loadConfig();
316
+ try {
317
+ execSync2("which supabase", { stdio: "ignore" });
318
+ } catch {
319
+ error("Supabase CLI not found. Install it: https://supabase.com/docs/guides/cli");
320
+ process.exit(1);
321
+ }
322
+ const packageRoot = fileURLToPath(new URL("..", import.meta.url));
323
+ const migrationsSource = join3(packageRoot, "migrations");
324
+ const spinner = createSpinner2("Running migrations...").start();
325
+ const tmpDir = mkdtempSync(join3(tmpdir(), "writbase-migrate-"));
326
+ try {
327
+ const supabaseDir = join3(tmpDir, "supabase");
328
+ mkdirSync(supabaseDir);
329
+ writeFileSync2(join3(supabaseDir, "config.toml"), CONFIG_TOML);
330
+ cpSync(migrationsSource, join3(supabaseDir, "migrations"), { recursive: true });
331
+ const args = [`--db-url "${config.databaseUrl}"`, `--workdir ${tmpDir}`];
332
+ if (opts.dryRun) args.push("--dry-run");
333
+ const cmd = `supabase migration up ${args.join(" ")}`;
334
+ const output = execSync2(cmd, {
335
+ encoding: "utf-8",
336
+ stdio: ["ignore", "pipe", "pipe"]
337
+ });
338
+ spinner.success({ text: "Migrations applied" });
339
+ if (output.trim()) {
340
+ console.log(output.trim());
341
+ }
342
+ if (opts.dryRun) {
343
+ warn("Dry run \u2014 no changes applied");
344
+ } else {
345
+ success("Database schema is up to date");
346
+ }
347
+ } catch (err) {
348
+ spinner.error({ text: "Migration failed" });
349
+ if (err instanceof Error && "stderr" in err) {
350
+ error(String(err.stderr));
351
+ } else {
352
+ error(err instanceof Error ? err.message : String(err));
353
+ }
354
+ process.exit(1);
355
+ } finally {
356
+ rmSync(tmpDir, { recursive: true, force: true });
357
+ }
358
+ }
359
+
360
+ // src/commands/key.ts
361
+ import { confirm as confirm2, input as input2, select as select2 } from "@inquirer/prompts";
362
+ import { createSpinner as createSpinner3 } from "nanospinner";
363
+
364
+ // src/lib/agent-keys.ts
365
+ import { webcrypto as webcrypto2 } from "crypto";
366
+
367
+ // src/lib/event-log.ts
368
+ async function logEvent(supabase, params) {
369
+ const { error: error2 } = await supabase.from("event_log").insert({
370
+ event_category: params.eventCategory,
371
+ target_type: params.targetType,
372
+ target_id: params.targetId,
373
+ event_type: params.eventType,
374
+ field_name: params.fieldName ?? null,
375
+ old_value: params.oldValue ?? null,
376
+ new_value: params.newValue ?? null,
377
+ actor_type: params.actorType,
378
+ actor_id: params.actorId,
379
+ actor_label: params.actorLabel,
380
+ source: params.source,
381
+ workspace_id: params.workspaceId
382
+ });
383
+ if (error2) {
384
+ console.error(
385
+ "Failed to log event:",
386
+ JSON.stringify({
387
+ event_type: params.eventType,
388
+ target_id: params.targetId,
389
+ error: error2.message
390
+ })
391
+ );
392
+ }
393
+ }
394
+
395
+ // src/lib/agent-keys.ts
396
+ async function hashSecret(secret) {
397
+ const data = new TextEncoder().encode(secret);
398
+ const hashBuffer = await webcrypto2.subtle.digest("SHA-256", data);
399
+ return Array.from(new Uint8Array(hashBuffer)).map((b) => b.toString(16).padStart(2, "0")).join("");
400
+ }
401
+ async function generateKey(keyId) {
402
+ const randomBytes = new Uint8Array(32);
403
+ webcrypto2.getRandomValues(randomBytes);
404
+ const secret = Array.from(randomBytes).map((b) => b.toString(16).padStart(2, "0")).join("");
405
+ const hash = await hashSecret(secret);
406
+ const prefix = secret.slice(0, 8);
407
+ const fullKey = `wb_${keyId}_${secret}`;
408
+ return { fullKey, prefix, hash };
409
+ }
410
+ var KEY_COLUMNS = "id, name, role, key_prefix, is_active, special_prompt, created_at, last_used_at, created_by, project_id, department_id";
411
+ async function listAgentKeys(supabase, workspaceId) {
412
+ const { data, error: error2 } = await supabase.from("agent_keys").select(KEY_COLUMNS).eq("workspace_id", workspaceId).order("created_at", { ascending: false });
413
+ if (error2) throw error2;
414
+ return data;
415
+ }
416
+ async function createAgentKey(supabase, params) {
417
+ const keyId = webcrypto2.randomUUID();
418
+ const { fullKey, prefix, hash } = await generateKey(keyId);
419
+ const { data, error: error2 } = await supabase.from("agent_keys").insert({
420
+ id: keyId,
421
+ name: params.name,
422
+ role: params.role ?? "worker",
423
+ key_hash: hash,
424
+ key_prefix: prefix,
425
+ special_prompt: null,
426
+ created_by: "writbase-cli",
427
+ workspace_id: params.workspaceId,
428
+ project_id: params.projectId ?? null,
429
+ department_id: params.departmentId ?? null
430
+ }).select(KEY_COLUMNS).single();
431
+ if (error2) throw error2;
432
+ await logEvent(supabase, {
433
+ eventCategory: "admin",
434
+ targetType: "agent_key",
435
+ targetId: data.id,
436
+ eventType: "agent_key.created",
437
+ actorType: "system",
438
+ actorId: "writbase-cli",
439
+ actorLabel: "writbase-cli",
440
+ source: "system",
441
+ workspaceId: params.workspaceId
442
+ });
443
+ return { key: data, fullKey };
444
+ }
445
+ async function rotateAgentKey(supabase, params) {
446
+ const { fullKey, prefix, hash } = await generateKey(params.id);
447
+ const { data, error: error2 } = await supabase.from("agent_keys").update({ key_hash: hash, key_prefix: prefix }).eq("id", params.id).eq("workspace_id", params.workspaceId).select(KEY_COLUMNS).single();
448
+ if (error2) throw error2;
449
+ await logEvent(supabase, {
450
+ eventCategory: "admin",
451
+ targetType: "agent_key",
452
+ targetId: params.id,
453
+ eventType: "agent_key.rotated",
454
+ actorType: "system",
455
+ actorId: "writbase-cli",
456
+ actorLabel: "writbase-cli",
457
+ source: "system",
458
+ workspaceId: params.workspaceId
459
+ });
460
+ return { key: data, fullKey };
461
+ }
462
+ async function deactivateAgentKey(supabase, params) {
463
+ const { data, error: error2 } = await supabase.from("agent_keys").update({ is_active: false }).eq("id", params.id).eq("workspace_id", params.workspaceId).select(KEY_COLUMNS).single();
464
+ if (error2) throw error2;
465
+ await logEvent(supabase, {
466
+ eventCategory: "admin",
467
+ targetType: "agent_key",
468
+ targetId: params.id,
469
+ eventType: "agent_key.deactivated",
470
+ actorType: "system",
471
+ actorId: "writbase-cli",
472
+ actorLabel: "writbase-cli",
473
+ source: "system",
474
+ workspaceId: params.workspaceId
475
+ });
476
+ return data;
477
+ }
478
+
479
+ // src/commands/key.ts
480
+ async function resolveKeyByNameOrId(supabase, workspaceId, nameOrId) {
481
+ const keys = await listAgentKeys(supabase, workspaceId);
482
+ const byName = keys.find(
483
+ (k) => k.name.toLowerCase() === nameOrId.toLowerCase()
484
+ );
485
+ if (byName) return byName;
486
+ const byId = keys.filter((k) => k.id.startsWith(nameOrId));
487
+ if (byId.length === 1) return byId[0];
488
+ if (byId.length > 1) {
489
+ error(`Ambiguous ID prefix "${nameOrId}" \u2014 matches ${byId.length} keys`);
490
+ process.exit(1);
491
+ }
492
+ error(`No agent key found matching "${nameOrId}"`);
493
+ process.exit(1);
494
+ }
495
+ async function keyCreateCommand() {
496
+ const config = loadConfig();
497
+ const supabase = createAdminClient(config.supabaseUrl, config.supabaseServiceRoleKey);
498
+ const name = await input2({ message: "Key name:" });
499
+ if (!name.trim()) {
500
+ error("Name is required");
501
+ process.exit(1);
502
+ }
503
+ const role = await select2({
504
+ message: "Role:",
505
+ choices: [
506
+ { name: "worker", value: "worker" },
507
+ { name: "manager", value: "manager" }
508
+ ],
509
+ default: "worker"
510
+ });
511
+ let projectId = null;
512
+ let departmentId = null;
513
+ const { data: projects } = await supabase.from("projects").select("id, name").eq("workspace_id", config.workspaceId).order("name");
514
+ if (projects && projects.length > 0) {
515
+ const projectChoice = await select2({
516
+ message: "Default project (optional):",
517
+ choices: [
518
+ { name: "(none)", value: "" },
519
+ ...projects.map((p) => ({
520
+ name: p.name,
521
+ value: p.id
522
+ }))
523
+ ]
524
+ });
525
+ if (projectChoice) {
526
+ projectId = projectChoice;
527
+ const { data: departments } = await supabase.from("departments").select("id, name").eq("project_id", projectId).order("name");
528
+ if (departments && departments.length > 0) {
529
+ const deptChoice = await select2({
530
+ message: "Default department (optional):",
531
+ choices: [
532
+ { name: "(none)", value: "" },
533
+ ...departments.map((d) => ({
534
+ name: d.name,
535
+ value: d.id
536
+ }))
537
+ ]
538
+ });
539
+ if (deptChoice) departmentId = deptChoice;
540
+ }
541
+ }
542
+ }
543
+ const spinner = createSpinner3("Creating agent key...").start();
544
+ try {
545
+ const { key: key2, fullKey } = await createAgentKey(supabase, {
546
+ name: name.trim(),
547
+ role,
548
+ workspaceId: config.workspaceId,
549
+ projectId,
550
+ departmentId
551
+ });
552
+ spinner.success({ text: "Agent key created" });
553
+ console.log();
554
+ table(
555
+ ["Field", "Value"],
556
+ [
557
+ ["Name", key2.name],
558
+ ["Role", key2.role],
559
+ ["ID", key2.id],
560
+ ["Prefix", key2.key_prefix],
561
+ ["Active", String(key2.is_active)]
562
+ ]
563
+ );
564
+ console.log();
565
+ warn("Save this key now \u2014 it cannot be retrieved later:");
566
+ console.log();
567
+ console.log(` ${fullKey}`);
568
+ console.log();
569
+ } catch (err) {
570
+ spinner.error({ text: "Failed to create key" });
571
+ error(err instanceof Error ? err.message : String(err));
572
+ process.exit(1);
573
+ }
574
+ }
575
+ async function keyListCommand() {
576
+ const config = loadConfig();
577
+ const supabase = createAdminClient(config.supabaseUrl, config.supabaseServiceRoleKey);
578
+ const spinner = createSpinner3("Fetching agent keys...").start();
579
+ try {
580
+ const keys = await listAgentKeys(supabase, config.workspaceId);
581
+ spinner.success({ text: `Found ${keys.length} key(s)` });
582
+ if (keys.length === 0) return;
583
+ console.log();
584
+ table(
585
+ ["Name", "Role", "Prefix", "Active", "Created"],
586
+ keys.map((k) => [
587
+ k.name,
588
+ k.role,
589
+ k.key_prefix,
590
+ k.is_active ? "yes" : "no",
591
+ new Date(k.created_at).toLocaleDateString()
592
+ ])
593
+ );
594
+ } catch (err) {
595
+ spinner.error({ text: "Failed to list keys" });
596
+ error(err instanceof Error ? err.message : String(err));
597
+ process.exit(1);
598
+ }
599
+ }
600
+ async function keyRotateCommand(nameOrId) {
601
+ const config = loadConfig();
602
+ const supabase = createAdminClient(config.supabaseUrl, config.supabaseServiceRoleKey);
603
+ const key2 = await resolveKeyByNameOrId(supabase, config.workspaceId, nameOrId);
604
+ const confirmed = await confirm2({
605
+ message: `Rotate key "${key2.name}" (${key2.key_prefix}...)? The old key will stop working immediately.`,
606
+ default: false
607
+ });
608
+ if (!confirmed) return;
609
+ const spinner = createSpinner3("Rotating key...").start();
610
+ try {
611
+ const { fullKey } = await rotateAgentKey(supabase, {
612
+ id: key2.id,
613
+ workspaceId: config.workspaceId
614
+ });
615
+ spinner.success({ text: "Key rotated" });
616
+ console.log();
617
+ warn("Save this new key \u2014 it cannot be retrieved later:");
618
+ console.log();
619
+ console.log(` ${fullKey}`);
620
+ console.log();
621
+ } catch (err) {
622
+ spinner.error({ text: "Failed to rotate key" });
623
+ error(err instanceof Error ? err.message : String(err));
624
+ process.exit(1);
625
+ }
626
+ }
627
+ async function keyDeactivateCommand(nameOrId) {
628
+ const config = loadConfig();
629
+ const supabase = createAdminClient(config.supabaseUrl, config.supabaseServiceRoleKey);
630
+ const key2 = await resolveKeyByNameOrId(supabase, config.workspaceId, nameOrId);
631
+ if (!key2.is_active) {
632
+ warn(`Key "${key2.name}" is already inactive`);
633
+ return;
634
+ }
635
+ const confirmed = await confirm2({
636
+ message: `Deactivate key "${key2.name}" (${key2.key_prefix}...)? This key will stop working immediately.`,
637
+ default: false
638
+ });
639
+ if (!confirmed) return;
640
+ const spinner = createSpinner3("Deactivating key...").start();
641
+ try {
642
+ await deactivateAgentKey(supabase, {
643
+ id: key2.id,
644
+ workspaceId: config.workspaceId
645
+ });
646
+ spinner.success({ text: `Key "${key2.name}" deactivated` });
647
+ } catch (err) {
648
+ spinner.error({ text: "Failed to deactivate key" });
649
+ error(err instanceof Error ? err.message : String(err));
650
+ process.exit(1);
651
+ }
652
+ }
653
+
654
+ // src/commands/status.ts
655
+ import { createSpinner as createSpinner4 } from "nanospinner";
656
+ async function statusCommand() {
657
+ const config = loadConfig();
658
+ const supabase = createAdminClient(config.supabaseUrl, config.supabaseServiceRoleKey);
659
+ const spinner = createSpinner4("Checking connection...").start();
660
+ try {
661
+ const { error: connError } = await supabase.from("app_settings").select("*").limit(1);
662
+ if (connError) {
663
+ spinner.error({ text: "Connection failed" });
664
+ error(connError.message);
665
+ process.exit(1);
666
+ }
667
+ spinner.success({ text: "Connected to Supabase" });
668
+ const wsId = config.workspaceId;
669
+ const [workspaces, keys, tasks, projects] = await Promise.all([
670
+ supabase.from("workspaces").select("*", { count: "exact", head: true }).eq("id", wsId),
671
+ supabase.from("agent_keys").select("*", { count: "exact", head: true }).eq("workspace_id", wsId),
672
+ supabase.from("tasks").select("*", { count: "exact", head: true }).eq("workspace_id", wsId),
673
+ supabase.from("projects").select("*", { count: "exact", head: true }).eq("workspace_id", wsId)
674
+ ]);
675
+ console.log();
676
+ info(`Workspace: ${wsId}`);
677
+ console.log();
678
+ table(
679
+ ["Resource", "Count"],
680
+ [
681
+ ["Workspaces", String(workspaces.count ?? 0)],
682
+ ["Agent Keys", String(keys.count ?? 0)],
683
+ ["Tasks", String(tasks.count ?? 0)],
684
+ ["Projects", String(projects.count ?? 0)]
685
+ ]
686
+ );
687
+ console.log();
688
+ success("WritBase is healthy");
689
+ } catch (err) {
690
+ spinner.error({ text: "Health check failed" });
691
+ error(err instanceof Error ? err.message : String(err));
692
+ process.exit(1);
693
+ }
694
+ }
695
+
696
+ // src/bin/writbase.ts
697
+ var program = new Command();
698
+ program.name("writbase").description("WritBase CLI \u2014 agent-first task management").version("0.1.0");
699
+ program.command("init").description("Interactive setup \u2014 configure credentials and workspace").action(initCommand);
700
+ program.command("migrate").description("Apply database migrations via supabase migration up").option("--dry-run", "Show what would be applied without making changes").action(migrateCommand);
701
+ var key = program.command("key").description("Manage agent keys");
702
+ key.command("create").description("Create a new agent key").action(keyCreateCommand);
703
+ key.command("list").description("List all agent keys").action(keyListCommand);
704
+ key.command("rotate <name-or-id>").description("Rotate an agent key (generates new secret)").action(keyRotateCommand);
705
+ key.command("deactivate <name-or-id>").description("Deactivate an agent key").action(keyDeactivateCommand);
706
+ program.command("status").description("Health check \u2014 verify connection and show counts").action(statusCommand);
707
+ program.parse();