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.
- package/README.md +121 -0
- package/dist/writbase.js +707 -0
- package/migrations/00001_enums.sql +7 -0
- package/migrations/00002_core_tables.sql +105 -0
- package/migrations/00003_constraints_indexes.sql +69 -0
- package/migrations/00004_rls_policies.sql +169 -0
- package/migrations/00005_seed_app_settings.sql +2 -0
- package/migrations/00006_rate_limit_rpc.sql +14 -0
- package/migrations/00007_rate_limit_rpc_search_path.sql +14 -0
- package/migrations/00008_indexes_and_cleanup.sql +22 -0
- package/migrations/00009_atomic_mutations.sql +175 -0
- package/migrations/00010_user_rate_limits.sql +52 -0
- package/migrations/00011_atomic_permission_update.sql +31 -0
- package/migrations/00012_pg_cron_cleanup.sql +27 -0
- package/migrations/00013_max_agent_keys.sql +4 -0
- package/migrations/00014_event_log_indexes.sql +2 -0
- package/migrations/00015_auth_rate_limits.sql +69 -0
- package/migrations/00016_request_log.sql +42 -0
- package/migrations/00017_task_search.sql +58 -0
- package/migrations/00018_inter_agent_exchange.sql +423 -0
- package/migrations/00019_workspaces.sql +782 -0
- package/migrations/00020_can_comment.sql +46 -0
- package/migrations/00021_webhook_delivery.sql +125 -0
- package/migrations/00022_task_archive.sql +380 -0
- package/migrations/00023_get_top_tasks.sql +21 -0
- package/migrations/00024_agent_key_defaults.sql +55 -0
- package/migrations/00025_production_automation.sql +353 -0
- package/migrations/00026_top_tasks_exclude_terminal.sql +19 -0
- package/migrations/00027_rename_agent_key_defaults.sql +51 -0
- package/package.json +34 -0
package/dist/writbase.js
ADDED
|
@@ -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();
|