offwatch 0.5.9 → 0.5.10

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 (94) hide show
  1. package/bin/offwatch.js +7 -6
  2. package/package.json +4 -3
  3. package/src/__tests__/agent-jwt-env.test.ts +79 -0
  4. package/src/__tests__/allowed-hostname.test.ts +80 -0
  5. package/src/__tests__/auth-command-registration.test.ts +16 -0
  6. package/src/__tests__/board-auth.test.ts +53 -0
  7. package/src/__tests__/common.test.ts +98 -0
  8. package/src/__tests__/company-delete.test.ts +95 -0
  9. package/src/__tests__/company-import-export-e2e.test.ts +502 -0
  10. package/src/__tests__/company-import-url.test.ts +74 -0
  11. package/src/__tests__/company-import-zip.test.ts +44 -0
  12. package/src/__tests__/company.test.ts +599 -0
  13. package/src/__tests__/context.test.ts +70 -0
  14. package/src/__tests__/data-dir.test.ts +79 -0
  15. package/src/__tests__/doctor.test.ts +102 -0
  16. package/src/__tests__/feedback.test.ts +177 -0
  17. package/src/__tests__/helpers/embedded-postgres.ts +6 -0
  18. package/src/__tests__/helpers/zip.ts +87 -0
  19. package/src/__tests__/home-paths.test.ts +44 -0
  20. package/src/__tests__/http.test.ts +106 -0
  21. package/src/__tests__/network-bind.test.ts +62 -0
  22. package/src/__tests__/onboard.test.ts +166 -0
  23. package/src/__tests__/routines.test.ts +249 -0
  24. package/src/__tests__/telemetry.test.ts +117 -0
  25. package/src/__tests__/worktree-merge-history.test.ts +492 -0
  26. package/src/__tests__/worktree.test.ts +982 -0
  27. package/src/adapters/http/format-event.ts +4 -0
  28. package/src/adapters/http/index.ts +7 -0
  29. package/src/adapters/index.ts +2 -0
  30. package/src/adapters/process/format-event.ts +4 -0
  31. package/src/adapters/process/index.ts +7 -0
  32. package/src/adapters/registry.ts +63 -0
  33. package/src/checks/agent-jwt-secret-check.ts +40 -0
  34. package/src/checks/config-check.ts +33 -0
  35. package/src/checks/database-check.ts +59 -0
  36. package/src/checks/deployment-auth-check.ts +88 -0
  37. package/src/checks/index.ts +18 -0
  38. package/src/checks/llm-check.ts +82 -0
  39. package/src/checks/log-check.ts +30 -0
  40. package/src/checks/path-resolver.ts +1 -0
  41. package/src/checks/port-check.ts +24 -0
  42. package/src/checks/secrets-check.ts +146 -0
  43. package/src/checks/storage-check.ts +51 -0
  44. package/src/client/board-auth.ts +282 -0
  45. package/src/client/command-label.ts +4 -0
  46. package/src/client/context.ts +175 -0
  47. package/src/client/http.ts +255 -0
  48. package/src/commands/allowed-hostname.ts +40 -0
  49. package/src/commands/auth-bootstrap-ceo.ts +138 -0
  50. package/src/commands/client/activity.ts +71 -0
  51. package/src/commands/client/agent.ts +315 -0
  52. package/src/commands/client/approval.ts +259 -0
  53. package/src/commands/client/auth.ts +113 -0
  54. package/src/commands/client/common.ts +221 -0
  55. package/src/commands/client/company.ts +1578 -0
  56. package/src/commands/client/context.ts +125 -0
  57. package/src/commands/client/dashboard.ts +34 -0
  58. package/src/commands/client/feedback.ts +645 -0
  59. package/src/commands/client/issue.ts +411 -0
  60. package/src/commands/client/plugin.ts +374 -0
  61. package/src/commands/client/zip.ts +129 -0
  62. package/src/commands/configure.ts +201 -0
  63. package/src/commands/db-backup.ts +102 -0
  64. package/src/commands/doctor.ts +203 -0
  65. package/src/commands/env.ts +411 -0
  66. package/src/commands/heartbeat-run.ts +344 -0
  67. package/src/commands/onboard.ts +692 -0
  68. package/src/commands/routines.ts +352 -0
  69. package/src/commands/run.ts +216 -0
  70. package/src/commands/worktree-lib.ts +279 -0
  71. package/src/commands/worktree-merge-history-lib.ts +764 -0
  72. package/src/commands/worktree.ts +2876 -0
  73. package/src/config/data-dir.ts +48 -0
  74. package/src/config/env.ts +125 -0
  75. package/src/config/home.ts +80 -0
  76. package/src/config/hostnames.ts +26 -0
  77. package/src/config/schema.ts +30 -0
  78. package/src/config/secrets-key.ts +48 -0
  79. package/src/config/server-bind.ts +183 -0
  80. package/src/config/store.ts +120 -0
  81. package/src/index.ts +182 -0
  82. package/src/prompts/database.ts +157 -0
  83. package/src/prompts/llm.ts +43 -0
  84. package/src/prompts/logging.ts +37 -0
  85. package/src/prompts/secrets.ts +99 -0
  86. package/src/prompts/server.ts +221 -0
  87. package/src/prompts/storage.ts +146 -0
  88. package/src/telemetry.ts +49 -0
  89. package/src/utils/banner.ts +24 -0
  90. package/src/utils/net.ts +18 -0
  91. package/src/utils/path-resolver.ts +25 -0
  92. package/src/version.ts +10 -0
  93. package/lib/downloader.js +0 -112
  94. package/postinstall.js +0 -23
@@ -0,0 +1,374 @@
1
+ import path from "node:path";
2
+ import { Command } from "commander";
3
+ import pc from "picocolors";
4
+ import {
5
+ addCommonClientOptions,
6
+ handleCommandError,
7
+ printOutput,
8
+ resolveCommandContext,
9
+ type BaseClientOptions,
10
+ } from "./common.js";
11
+
12
+ // ---------------------------------------------------------------------------
13
+ // Types mirroring server-side shapes
14
+ // ---------------------------------------------------------------------------
15
+
16
+ interface PluginRecord {
17
+ id: string;
18
+ pluginKey: string;
19
+ packageName: string;
20
+ version: string;
21
+ status: string;
22
+ displayName?: string;
23
+ lastError?: string | null;
24
+ installedAt: string;
25
+ updatedAt: string;
26
+ }
27
+
28
+
29
+ // ---------------------------------------------------------------------------
30
+ // Option types
31
+ // ---------------------------------------------------------------------------
32
+
33
+ interface PluginListOptions extends BaseClientOptions {
34
+ status?: string;
35
+ }
36
+
37
+ interface PluginInstallOptions extends BaseClientOptions {
38
+ local?: boolean;
39
+ version?: string;
40
+ }
41
+
42
+ interface PluginUninstallOptions extends BaseClientOptions {
43
+ force?: boolean;
44
+ }
45
+
46
+ // ---------------------------------------------------------------------------
47
+ // Helpers
48
+ // ---------------------------------------------------------------------------
49
+
50
+ /**
51
+ * Resolve a local path argument to an absolute path so the server can find the
52
+ * plugin on disk regardless of where the user ran the CLI.
53
+ */
54
+ function resolvePackageArg(packageArg: string, isLocal: boolean): string {
55
+ if (!isLocal) return packageArg;
56
+ // Already absolute
57
+ if (path.isAbsolute(packageArg)) return packageArg;
58
+ // Expand leading ~ to home directory
59
+ if (packageArg.startsWith("~")) {
60
+ const home = process.env.HOME ?? process.env.USERPROFILE ?? "";
61
+ return path.resolve(home, packageArg.slice(1).replace(/^[\\/]/, ""));
62
+ }
63
+ return path.resolve(process.cwd(), packageArg);
64
+ }
65
+
66
+ function formatPlugin(p: PluginRecord): string {
67
+ const statusColor =
68
+ p.status === "ready"
69
+ ? pc.green(p.status)
70
+ : p.status === "error"
71
+ ? pc.red(p.status)
72
+ : p.status === "disabled"
73
+ ? pc.dim(p.status)
74
+ : pc.yellow(p.status);
75
+
76
+ const parts = [
77
+ `key=${pc.bold(p.pluginKey)}`,
78
+ `status=${statusColor}`,
79
+ `version=${p.version}`,
80
+ `id=${pc.dim(p.id)}`,
81
+ ];
82
+
83
+ if (p.lastError) {
84
+ parts.push(`error=${pc.red(p.lastError.slice(0, 80))}`);
85
+ }
86
+
87
+ return parts.join(" ");
88
+ }
89
+
90
+ // ---------------------------------------------------------------------------
91
+ // Command registration
92
+ // ---------------------------------------------------------------------------
93
+
94
+ export function registerPluginCommands(program: Command): void {
95
+ const plugin = program.command("plugin").description("Plugin lifecycle management");
96
+
97
+ // -------------------------------------------------------------------------
98
+ // plugin list
99
+ // -------------------------------------------------------------------------
100
+ addCommonClientOptions(
101
+ plugin
102
+ .command("list")
103
+ .description("List installed plugins")
104
+ .option("--status <status>", "Filter by status (ready, error, disabled, installed, upgrade_pending)")
105
+ .action(async (opts: PluginListOptions) => {
106
+ try {
107
+ const ctx = resolveCommandContext(opts);
108
+ const qs = opts.status ? `?status=${encodeURIComponent(opts.status)}` : "";
109
+ const plugins = await ctx.api.get<PluginRecord[]>(`/api/plugins${qs}`);
110
+
111
+ if (ctx.json) {
112
+ printOutput(plugins, { json: true });
113
+ return;
114
+ }
115
+
116
+ const rows = plugins ?? [];
117
+ if (rows.length === 0) {
118
+ console.log(pc.dim("No plugins installed."));
119
+ return;
120
+ }
121
+
122
+ for (const p of rows) {
123
+ console.log(formatPlugin(p));
124
+ }
125
+ } catch (err) {
126
+ handleCommandError(err);
127
+ }
128
+ }),
129
+ );
130
+
131
+ // -------------------------------------------------------------------------
132
+ // plugin install <package-or-path>
133
+ // -------------------------------------------------------------------------
134
+ addCommonClientOptions(
135
+ plugin
136
+ .command("install <package>")
137
+ .description(
138
+ "Install a plugin from a local path or npm package.\n" +
139
+ " Examples:\n" +
140
+ " paperclipai plugin install ./my-plugin # local path\n" +
141
+ " paperclipai plugin install @acme/plugin-linear # npm package\n" +
142
+ " paperclipai plugin install @acme/plugin-linear@1.2 # pinned version",
143
+ )
144
+ .option("-l, --local", "Treat <package> as a local filesystem path", false)
145
+ .option("--version <version>", "Specific npm version to install (npm packages only)")
146
+ .action(async (packageArg: string, opts: PluginInstallOptions) => {
147
+ try {
148
+ const ctx = resolveCommandContext(opts);
149
+
150
+ // Auto-detect local paths: starts with . or / or ~ or is an absolute path
151
+ const isLocal =
152
+ opts.local ||
153
+ packageArg.startsWith("./") ||
154
+ packageArg.startsWith("../") ||
155
+ packageArg.startsWith("/") ||
156
+ packageArg.startsWith("~");
157
+
158
+ const resolvedPackage = resolvePackageArg(packageArg, isLocal);
159
+
160
+ if (!ctx.json) {
161
+ console.log(
162
+ pc.dim(
163
+ isLocal
164
+ ? `Installing plugin from local path: ${resolvedPackage}`
165
+ : `Installing plugin: ${resolvedPackage}${opts.version ? `@${opts.version}` : ""}`,
166
+ ),
167
+ );
168
+ }
169
+
170
+ const installedPlugin = await ctx.api.post<PluginRecord>("/api/plugins/install", {
171
+ packageName: resolvedPackage,
172
+ version: opts.version,
173
+ isLocalPath: isLocal,
174
+ });
175
+
176
+ if (ctx.json) {
177
+ printOutput(installedPlugin, { json: true });
178
+ return;
179
+ }
180
+
181
+ if (!installedPlugin) {
182
+ console.log(pc.dim("Install returned no plugin record."));
183
+ return;
184
+ }
185
+
186
+ console.log(
187
+ pc.green(
188
+ `✓ Installed ${pc.bold(installedPlugin.pluginKey)} v${installedPlugin.version} (${installedPlugin.status})`,
189
+ ),
190
+ );
191
+
192
+ if (installedPlugin.lastError) {
193
+ console.log(pc.red(` Warning: ${installedPlugin.lastError}`));
194
+ }
195
+ } catch (err) {
196
+ handleCommandError(err);
197
+ }
198
+ }),
199
+ );
200
+
201
+ // -------------------------------------------------------------------------
202
+ // plugin uninstall <plugin-key-or-id>
203
+ // -------------------------------------------------------------------------
204
+ addCommonClientOptions(
205
+ plugin
206
+ .command("uninstall <pluginKey>")
207
+ .description(
208
+ "Uninstall a plugin by its plugin key or database ID.\n" +
209
+ " Use --force to hard-purge all state and config.",
210
+ )
211
+ .option("--force", "Purge all plugin state and config (hard delete)", false)
212
+ .action(async (pluginKey: string, opts: PluginUninstallOptions) => {
213
+ try {
214
+ const ctx = resolveCommandContext(opts);
215
+ const purge = opts.force === true;
216
+ const qs = purge ? "?purge=true" : "";
217
+
218
+ if (!ctx.json) {
219
+ console.log(
220
+ pc.dim(
221
+ purge
222
+ ? `Uninstalling and purging plugin: ${pluginKey}`
223
+ : `Uninstalling plugin: ${pluginKey}`,
224
+ ),
225
+ );
226
+ }
227
+
228
+ const result = await ctx.api.delete<PluginRecord | null>(
229
+ `/api/plugins/${encodeURIComponent(pluginKey)}${qs}`,
230
+ );
231
+
232
+ if (ctx.json) {
233
+ printOutput(result, { json: true });
234
+ return;
235
+ }
236
+
237
+ console.log(pc.green(`✓ Uninstalled ${pc.bold(pluginKey)}${purge ? " (purged)" : ""}`));
238
+ } catch (err) {
239
+ handleCommandError(err);
240
+ }
241
+ }),
242
+ );
243
+
244
+ // -------------------------------------------------------------------------
245
+ // plugin enable <plugin-key-or-id>
246
+ // -------------------------------------------------------------------------
247
+ addCommonClientOptions(
248
+ plugin
249
+ .command("enable <pluginKey>")
250
+ .description("Enable a disabled or errored plugin")
251
+ .action(async (pluginKey: string, opts: BaseClientOptions) => {
252
+ try {
253
+ const ctx = resolveCommandContext(opts);
254
+ const result = await ctx.api.post<PluginRecord>(
255
+ `/api/plugins/${encodeURIComponent(pluginKey)}/enable`,
256
+ );
257
+
258
+ if (ctx.json) {
259
+ printOutput(result, { json: true });
260
+ return;
261
+ }
262
+
263
+ console.log(pc.green(`✓ Enabled ${pc.bold(pluginKey)} — status: ${result?.status ?? "unknown"}`));
264
+ } catch (err) {
265
+ handleCommandError(err);
266
+ }
267
+ }),
268
+ );
269
+
270
+ // -------------------------------------------------------------------------
271
+ // plugin disable <plugin-key-or-id>
272
+ // -------------------------------------------------------------------------
273
+ addCommonClientOptions(
274
+ plugin
275
+ .command("disable <pluginKey>")
276
+ .description("Disable a running plugin without uninstalling it")
277
+ .action(async (pluginKey: string, opts: BaseClientOptions) => {
278
+ try {
279
+ const ctx = resolveCommandContext(opts);
280
+ const result = await ctx.api.post<PluginRecord>(
281
+ `/api/plugins/${encodeURIComponent(pluginKey)}/disable`,
282
+ );
283
+
284
+ if (ctx.json) {
285
+ printOutput(result, { json: true });
286
+ return;
287
+ }
288
+
289
+ console.log(pc.dim(`Disabled ${pc.bold(pluginKey)} — status: ${result?.status ?? "unknown"}`));
290
+ } catch (err) {
291
+ handleCommandError(err);
292
+ }
293
+ }),
294
+ );
295
+
296
+ // -------------------------------------------------------------------------
297
+ // plugin inspect <plugin-key-or-id>
298
+ // -------------------------------------------------------------------------
299
+ addCommonClientOptions(
300
+ plugin
301
+ .command("inspect <pluginKey>")
302
+ .description("Show full details for an installed plugin")
303
+ .action(async (pluginKey: string, opts: BaseClientOptions) => {
304
+ try {
305
+ const ctx = resolveCommandContext(opts);
306
+ const result = await ctx.api.get<PluginRecord>(
307
+ `/api/plugins/${encodeURIComponent(pluginKey)}`,
308
+ );
309
+
310
+ if (ctx.json) {
311
+ printOutput(result, { json: true });
312
+ return;
313
+ }
314
+
315
+ if (!result) {
316
+ console.log(pc.red(`Plugin not found: ${pluginKey}`));
317
+ process.exit(1);
318
+ }
319
+
320
+ console.log(formatPlugin(result));
321
+ if (result.lastError) {
322
+ console.log(`\n${pc.red("Last error:")}\n${result.lastError}`);
323
+ }
324
+ } catch (err) {
325
+ handleCommandError(err);
326
+ }
327
+ }),
328
+ );
329
+
330
+ // -------------------------------------------------------------------------
331
+ // plugin examples
332
+ // -------------------------------------------------------------------------
333
+ addCommonClientOptions(
334
+ plugin
335
+ .command("examples")
336
+ .description("List bundled example plugins available for local install")
337
+ .action(async (opts: BaseClientOptions) => {
338
+ try {
339
+ const ctx = resolveCommandContext(opts);
340
+ const examples = await ctx.api.get<
341
+ Array<{
342
+ packageName: string;
343
+ pluginKey: string;
344
+ displayName: string;
345
+ description: string;
346
+ localPath: string;
347
+ tag: string;
348
+ }>
349
+ >("/api/plugins/examples");
350
+
351
+ if (ctx.json) {
352
+ printOutput(examples, { json: true });
353
+ return;
354
+ }
355
+
356
+ const rows = examples ?? [];
357
+ if (rows.length === 0) {
358
+ console.log(pc.dim("No bundled examples available."));
359
+ return;
360
+ }
361
+
362
+ for (const ex of rows) {
363
+ console.log(
364
+ `${pc.bold(ex.displayName)} ${pc.dim(ex.pluginKey)}\n` +
365
+ ` ${ex.description}\n` +
366
+ ` ${pc.cyan(`paperclipai plugin install ${ex.localPath}`)}`,
367
+ );
368
+ }
369
+ } catch (err) {
370
+ handleCommandError(err);
371
+ }
372
+ }),
373
+ );
374
+ }
@@ -0,0 +1,129 @@
1
+ import { inflateRawSync } from "node:zlib";
2
+ import path from "node:path";
3
+ import type { CompanyPortabilityFileEntry } from "@paperclipai/shared";
4
+
5
+ const textDecoder = new TextDecoder();
6
+
7
+ export const binaryContentTypeByExtension: Record<string, string> = {
8
+ ".gif": "image/gif",
9
+ ".jpeg": "image/jpeg",
10
+ ".jpg": "image/jpeg",
11
+ ".png": "image/png",
12
+ ".svg": "image/svg+xml",
13
+ ".webp": "image/webp",
14
+ };
15
+
16
+ function normalizeArchivePath(pathValue: string) {
17
+ return pathValue
18
+ .replace(/\\/g, "/")
19
+ .split("/")
20
+ .filter(Boolean)
21
+ .join("/");
22
+ }
23
+
24
+ function readUint16(source: Uint8Array, offset: number) {
25
+ return source[offset]! | (source[offset + 1]! << 8);
26
+ }
27
+
28
+ function readUint32(source: Uint8Array, offset: number) {
29
+ return (
30
+ source[offset]! |
31
+ (source[offset + 1]! << 8) |
32
+ (source[offset + 2]! << 16) |
33
+ (source[offset + 3]! << 24)
34
+ ) >>> 0;
35
+ }
36
+
37
+ function sharedArchiveRoot(paths: string[]) {
38
+ if (paths.length === 0) return null;
39
+ const firstSegments = paths
40
+ .map((entry) => normalizeArchivePath(entry).split("/").filter(Boolean))
41
+ .filter((parts) => parts.length > 0);
42
+ if (firstSegments.length === 0) return null;
43
+ const candidate = firstSegments[0]![0]!;
44
+ return firstSegments.every((parts) => parts.length > 1 && parts[0] === candidate)
45
+ ? candidate
46
+ : null;
47
+ }
48
+
49
+ function bytesToPortableFileEntry(pathValue: string, bytes: Uint8Array): CompanyPortabilityFileEntry {
50
+ const contentType = binaryContentTypeByExtension[path.extname(pathValue).toLowerCase()];
51
+ if (!contentType) return textDecoder.decode(bytes);
52
+ return {
53
+ encoding: "base64",
54
+ data: Buffer.from(bytes).toString("base64"),
55
+ contentType,
56
+ };
57
+ }
58
+
59
+ async function inflateZipEntry(compressionMethod: number, bytes: Uint8Array) {
60
+ if (compressionMethod === 0) return bytes;
61
+ if (compressionMethod !== 8) {
62
+ throw new Error("Unsupported zip archive: only STORE and DEFLATE entries are supported.");
63
+ }
64
+ return new Uint8Array(inflateRawSync(bytes));
65
+ }
66
+
67
+ export async function readZipArchive(source: ArrayBuffer | Uint8Array): Promise<{
68
+ rootPath: string | null;
69
+ files: Record<string, CompanyPortabilityFileEntry>;
70
+ }> {
71
+ const bytes = source instanceof Uint8Array ? source : new Uint8Array(source);
72
+ const entries: Array<{ path: string; body: CompanyPortabilityFileEntry }> = [];
73
+ let offset = 0;
74
+
75
+ while (offset + 4 <= bytes.length) {
76
+ const signature = readUint32(bytes, offset);
77
+ if (signature === 0x02014b50 || signature === 0x06054b50) break;
78
+ if (signature !== 0x04034b50) {
79
+ throw new Error("Invalid zip archive: unsupported local file header.");
80
+ }
81
+
82
+ if (offset + 30 > bytes.length) {
83
+ throw new Error("Invalid zip archive: truncated local file header.");
84
+ }
85
+
86
+ const generalPurposeFlag = readUint16(bytes, offset + 6);
87
+ const compressionMethod = readUint16(bytes, offset + 8);
88
+ const compressedSize = readUint32(bytes, offset + 18);
89
+ const fileNameLength = readUint16(bytes, offset + 26);
90
+ const extraFieldLength = readUint16(bytes, offset + 28);
91
+
92
+ if ((generalPurposeFlag & 0x0008) !== 0) {
93
+ throw new Error("Unsupported zip archive: data descriptors are not supported.");
94
+ }
95
+
96
+ const nameOffset = offset + 30;
97
+ const bodyOffset = nameOffset + fileNameLength + extraFieldLength;
98
+ const bodyEnd = bodyOffset + compressedSize;
99
+ if (bodyEnd > bytes.length) {
100
+ throw new Error("Invalid zip archive: truncated file contents.");
101
+ }
102
+
103
+ const rawArchivePath = textDecoder.decode(bytes.slice(nameOffset, nameOffset + fileNameLength));
104
+ const archivePath = normalizeArchivePath(rawArchivePath);
105
+ const isDirectoryEntry = /\/$/.test(rawArchivePath.replace(/\\/g, "/"));
106
+ if (archivePath && !isDirectoryEntry) {
107
+ const entryBytes = await inflateZipEntry(compressionMethod, bytes.slice(bodyOffset, bodyEnd));
108
+ entries.push({
109
+ path: archivePath,
110
+ body: bytesToPortableFileEntry(archivePath, entryBytes),
111
+ });
112
+ }
113
+
114
+ offset = bodyEnd;
115
+ }
116
+
117
+ const rootPath = sharedArchiveRoot(entries.map((entry) => entry.path));
118
+ const files: Record<string, CompanyPortabilityFileEntry> = {};
119
+ for (const entry of entries) {
120
+ const normalizedPath =
121
+ rootPath && entry.path.startsWith(`${rootPath}/`)
122
+ ? entry.path.slice(rootPath.length + 1)
123
+ : entry.path;
124
+ if (!normalizedPath) continue;
125
+ files[normalizedPath] = entry.body;
126
+ }
127
+
128
+ return { rootPath, files };
129
+ }
@@ -0,0 +1,201 @@
1
+ import * as p from "@clack/prompts";
2
+ import pc from "picocolors";
3
+ import { readConfig, writeConfig, configExists, resolveConfigPath } from "../config/store.js";
4
+ import type { PaperclipConfig } from "../config/schema.js";
5
+ import { ensureLocalSecretsKeyFile } from "../config/secrets-key.js";
6
+ import { promptDatabase } from "../prompts/database.js";
7
+ import { promptLlm } from "../prompts/llm.js";
8
+ import { promptLogging } from "../prompts/logging.js";
9
+ import { defaultSecretsConfig, promptSecrets } from "../prompts/secrets.js";
10
+ import { defaultStorageConfig, promptStorage } from "../prompts/storage.js";
11
+ import { promptServer } from "../prompts/server.js";
12
+ import {
13
+ resolveDefaultBackupDir,
14
+ resolveDefaultEmbeddedPostgresDir,
15
+ resolveDefaultLogsDir,
16
+ resolvePaperclipInstanceId,
17
+ } from "../config/home.js";
18
+ import { printPaperclipCliBanner } from "../utils/banner.js";
19
+
20
+ type Section = "llm" | "database" | "logging" | "server" | "storage" | "secrets";
21
+
22
+ const SECTION_LABELS: Record<Section, string> = {
23
+ llm: "LLM Provider",
24
+ database: "Database",
25
+ logging: "Logging",
26
+ server: "Server",
27
+ storage: "Storage",
28
+ secrets: "Secrets",
29
+ };
30
+
31
+ function defaultConfig(): PaperclipConfig {
32
+ const instanceId = resolvePaperclipInstanceId();
33
+ return {
34
+ $meta: {
35
+ version: 1,
36
+ updatedAt: new Date().toISOString(),
37
+ source: "configure",
38
+ },
39
+ database: {
40
+ mode: "embedded-postgres",
41
+ embeddedPostgresDataDir: resolveDefaultEmbeddedPostgresDir(instanceId),
42
+ embeddedPostgresPort: 54329,
43
+ backup: {
44
+ enabled: true,
45
+ intervalMinutes: 60,
46
+ retentionDays: 30,
47
+ dir: resolveDefaultBackupDir(instanceId),
48
+ },
49
+ },
50
+ logging: {
51
+ mode: "file",
52
+ logDir: resolveDefaultLogsDir(instanceId),
53
+ },
54
+ server: {
55
+ deploymentMode: "local_trusted",
56
+ exposure: "private",
57
+ bind: "loopback",
58
+ host: "127.0.0.1",
59
+ port: 3100,
60
+ allowedHostnames: [],
61
+ serveUi: true,
62
+ },
63
+ auth: {
64
+ baseUrlMode: "auto",
65
+ disableSignUp: false,
66
+ },
67
+ telemetry: {
68
+ enabled: true,
69
+ },
70
+ storage: defaultStorageConfig(),
71
+ secrets: defaultSecretsConfig(),
72
+ };
73
+ }
74
+
75
+ export async function configure(opts: {
76
+ config?: string;
77
+ section?: string;
78
+ }): Promise<void> {
79
+ printPaperclipCliBanner();
80
+ p.intro(pc.bgCyan(pc.black(" paperclip configure ")));
81
+ const configPath = resolveConfigPath(opts.config);
82
+
83
+ if (!configExists(opts.config)) {
84
+ p.log.error("No config file found. Run `paperclipai onboard` first.");
85
+ p.outro("");
86
+ return;
87
+ }
88
+
89
+ let config: PaperclipConfig;
90
+ try {
91
+ config = readConfig(opts.config) ?? defaultConfig();
92
+ } catch (err) {
93
+ p.log.message(
94
+ pc.yellow(
95
+ `Existing config is invalid. Loading defaults so you can repair it now.\n${err instanceof Error ? err.message : String(err)}`,
96
+ ),
97
+ );
98
+ config = defaultConfig();
99
+ }
100
+
101
+ let section: Section | undefined = opts.section as Section | undefined;
102
+
103
+ if (section && !SECTION_LABELS[section]) {
104
+ p.log.error(`Unknown section: ${section}. Choose from: ${Object.keys(SECTION_LABELS).join(", ")}`);
105
+ p.outro("");
106
+ return;
107
+ }
108
+
109
+ // Section selection loop
110
+ let continueLoop = true;
111
+ while (continueLoop) {
112
+ if (!section) {
113
+ const choice = await p.select({
114
+ message: "Which section do you want to configure?",
115
+ options: Object.entries(SECTION_LABELS).map(([value, label]) => ({
116
+ value: value as Section,
117
+ label,
118
+ })),
119
+ });
120
+
121
+ if (p.isCancel(choice)) {
122
+ p.cancel("Configuration cancelled.");
123
+ return;
124
+ }
125
+
126
+ section = choice;
127
+ }
128
+
129
+ p.log.step(pc.bold(SECTION_LABELS[section]));
130
+
131
+ switch (section) {
132
+ case "database":
133
+ config.database = await promptDatabase(config.database);
134
+ break;
135
+ case "llm": {
136
+ const llm = await promptLlm();
137
+ if (llm) {
138
+ config.llm = llm;
139
+ } else {
140
+ delete config.llm;
141
+ }
142
+ break;
143
+ }
144
+ case "logging":
145
+ config.logging = await promptLogging();
146
+ break;
147
+ case "server":
148
+ {
149
+ const { server, auth } = await promptServer({
150
+ currentServer: config.server,
151
+ currentAuth: config.auth,
152
+ });
153
+ config.server = server;
154
+ config.auth = auth;
155
+ }
156
+ break;
157
+ case "storage":
158
+ config.storage = await promptStorage(config.storage);
159
+ break;
160
+ case "secrets":
161
+ config.secrets = await promptSecrets(config.secrets);
162
+ {
163
+ const keyResult = ensureLocalSecretsKeyFile(config, configPath);
164
+ if (keyResult.status === "created") {
165
+ p.log.success(`Created local secrets key file at ${pc.dim(keyResult.path)}`);
166
+ } else if (keyResult.status === "existing") {
167
+ p.log.message(pc.dim(`Using existing local secrets key file at ${keyResult.path}`));
168
+ } else if (keyResult.status === "skipped_provider") {
169
+ p.log.message(pc.dim("Skipping local key file management for non-local provider"));
170
+ } else {
171
+ p.log.message(pc.dim("Skipping local key file management because PAPERCLIP_SECRETS_MASTER_KEY is set"));
172
+ }
173
+ }
174
+ break;
175
+ }
176
+
177
+ config.$meta.updatedAt = new Date().toISOString();
178
+ config.$meta.source = "configure";
179
+
180
+ writeConfig(config, opts.config);
181
+ p.log.success(`${SECTION_LABELS[section]} configuration updated.`);
182
+
183
+ // If section was provided via CLI flag, don't loop
184
+ if (opts.section) {
185
+ continueLoop = false;
186
+ } else {
187
+ const another = await p.confirm({
188
+ message: "Configure another section?",
189
+ initialValue: false,
190
+ });
191
+
192
+ if (p.isCancel(another) || !another) {
193
+ continueLoop = false;
194
+ } else {
195
+ section = undefined; // Reset to show picker again
196
+ }
197
+ }
198
+ }
199
+
200
+ p.outro("Configuration saved.");
201
+ }