@corvio/cli 0.1.0-beta.3 → 0.1.0-beta.5

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/cli.js CHANGED
@@ -1,9 +1,17 @@
1
1
  #!/usr/bin/env node
2
2
 
3
+ import { createHash, randomUUID } from "node:crypto";
4
+ import { lstat, mkdir, readFile, readdir, rename, stat, unlink, writeFile } from "node:fs/promises";
5
+ import { homedir } from "node:os";
6
+ import { basename, dirname, extname, join, relative, resolve, sep } from "node:path";
7
+
3
8
  import {
4
9
  ApiClient,
5
10
  CliError,
6
11
  EXIT,
12
+ assertKnownOptions,
13
+ assertPositionalCount,
14
+ clearWorkspaceContext,
7
15
  clearWorkspaceCredential,
8
16
  idempotencyKey,
9
17
  listOption,
@@ -12,44 +20,214 @@ import {
12
20
  openBrowser,
13
21
  option,
14
22
  readTextFile,
23
+ readWorkspaceContext,
15
24
  readWorkspaceCredential,
16
25
  requireConfirmation,
26
+ resolveNpmUpdateStatus,
17
27
  runMain,
18
28
  sleep,
29
+ storeWorkspaceContext,
19
30
  storeWorkspaceCredential,
20
31
  writeResult,
32
+ writeBinaryFile,
21
33
  writeTextFile,
22
34
  } from "./core.js";
23
35
 
24
- const VERSION = "0.1.0-beta.3";
36
+ const VERSION = "0.1.0-beta.5";
25
37
  const DEFAULT_API_BASE = "https://api.corvio.ai/v1";
26
38
  const DEFAULT_APP_BASE = "https://corvio.ai";
27
39
  const USER_AGENT = `corvio-cli/${VERSION}`;
40
+ const MAX_UPLOAD_BYTES = 100 * 1024 * 1024;
41
+ const MAX_SYNC_FILES = 2_000;
42
+ const MAX_MARKDOWN_CHARACTERS = 120_000;
43
+ const GLOBAL_OPTIONS = Object.freeze([
44
+ "api-base",
45
+ "workspace",
46
+ "workspace-id",
47
+ "json",
48
+ "no-input",
49
+ "yes",
50
+ "help",
51
+ "version",
52
+ ]);
53
+
54
+ const COMMAND_OPTIONS = Object.freeze({
55
+ "auth:login": ["force"],
56
+ "auth:status": [],
57
+ "auth:logout": [],
58
+ "workspaces:list": [],
59
+ "workspaces:current": [],
60
+ "workspaces:use": ["id"],
61
+ "workspaces:create": ["name", "kind", "description", "idempotency-key"],
62
+ "capabilities:": [],
63
+ "ask:": ["prompt", "conversation-id", "sources", "allow-actions", "idempotency-key"],
64
+ "questions:list": ["limit", "cursor", "conversation-id"],
65
+ "questions:get": ["id"],
66
+ "conversations:list": ["limit", "cursor"],
67
+ "conversations:get": ["id"],
68
+ "search:": ["query", "limit", "page-id", "node-id"],
69
+ "docs:list": ["limit", "cursor"],
70
+ "docs:get": ["id", "output"],
71
+ "docs:create": ["title", "file", "markdown", "parent-node-id", "idempotency-key"],
72
+ "docs:update": ["id", "title", "file", "markdown", "expected-revision"],
73
+ "docs:move": ["id", "parent-node-id", "before-node-id", "after-node-id", "idempotency-key"],
74
+ "docs:archive": ["id"],
75
+ "docs:share": ["id", "display-name"],
76
+ "docs:open": ["id"],
77
+ "projects:create": ["title", "idempotency-key"],
78
+ "files:list": ["limit"],
79
+ "files:get": ["id"],
80
+ "files:download": ["id", "output"],
81
+ "files:upload": ["file", "mime-type"],
82
+ "files:organize": ["id", "instruction", "target-root-node-id"],
83
+ "files:open": ["id"],
84
+ "sync:init": ["root-node-id", "dir", "name", "force"],
85
+ "sync:status": ["dir"],
86
+ "sync:plan": ["dir"],
87
+ "sync:pull": ["dir"],
88
+ "sync:push": ["dir"],
89
+ "sync:resolve": ["dir", "conflict-id", "strategy"],
90
+ "update:check": [],
91
+ });
28
92
 
29
93
  const HELP = `Corvio Workspace CLI ${VERSION}
30
94
 
31
95
  Usage:
32
96
  corvio auth login|status|logout
97
+ corvio workspaces list|use|current|create [id]
33
98
  corvio capabilities
34
99
  corvio ask --prompt <text> [--conversation-id <id>] [--allow-actions]
35
100
  corvio questions list|get [id]
36
101
  corvio conversations list|get [id]
37
102
  corvio search <query>
38
- corvio docs list|get|create|update|archive|share|open [id]
103
+ corvio docs list|get|create|update|move|archive|share|open [id]
104
+ corvio projects create --title <title>
105
+ corvio files list|get|download|upload|organize|open [id]
106
+ corvio sync init|status|plan|pull|push|resolve
107
+ corvio update check
39
108
 
40
109
  Global options:
41
- --api-base <url> --json --no-input --yes --help --version
110
+ --api-base <url> --workspace <id> --json --no-input --yes --help --version
42
111
 
43
112
  Authentication:
44
- CORVIO_API_KEY=cvk_... takes precedence over the stored device-link login.
113
+ CORVIO_API_KEY=cvu_... takes precedence over the stored device-link login.
114
+ Legacy Workspace-bound cvk_ keys remain supported.
115
+ Select the target with 'corvio workspaces use <id>', --workspace, or CORVIO_WORKSPACE_ID.
45
116
  Run 'corvio auth login' when no environment key is configured.
46
117
  `;
47
118
 
119
+ const COMMAND_HELP = Object.freeze({
120
+ auth: `Usage: corvio auth login|status|logout\n\nAuthenticate with a user-level device link, inspect the current principal, or revoke the stored credential.`,
121
+ workspaces: `Usage:\n corvio workspaces list\n corvio workspaces use <workspace-id>\n corvio workspaces current\n corvio workspaces create --name <name> [--kind personal|team]\n\nWorkspace status and blocked capability reasons are returned with each item.`,
122
+ capabilities: `Usage: corvio capabilities [--json]\n\nShow live API resources, principal scopes, selected Workspace status, and CLI compatibility.`,
123
+ ask: `Usage: corvio ask --prompt <text> [--conversation-id <id>] [--sources workspace,web,memory] [--allow-actions] [--idempotency-key <key>]`,
124
+ questions: `Usage:\n corvio questions list [--limit <n>] [--cursor <cursor>] [--conversation-id <id>]\n corvio questions get <question-id>`,
125
+ conversations: `Usage:\n corvio conversations list [--limit <n>] [--cursor <cursor>]\n corvio conversations get <conversation-id>`,
126
+ search: `Usage: corvio search <query> [--limit <n>] [--page-id <id>] [--node-id <id>]`,
127
+ docs: `Usage:\n corvio docs list [--limit <n>] [--cursor <cursor>]\n corvio docs get <id> [--output <path>]\n corvio docs create --title <title> [--file <path>|--markdown <text>] [--parent-node-id <id>] [--idempotency-key <key>]\n corvio docs update <id> [--title <title>] [--file <path>|--markdown <text>] [--expected-revision <n>]\n corvio docs move <id> --parent-node-id <id>\n corvio docs archive <id> --yes\n corvio docs share <id> --yes [--display-name <name>]\n corvio docs open <id>`,
128
+ projects: `Usage: corvio projects create --title <title> [--idempotency-key <key>]\n\nProjects are the user-visible folder/container owner in the Corvio Docs tree. 'corvio folders' is an alias.`,
129
+ files: `Usage:\n corvio files list [--limit <n>]\n corvio files get <id>\n corvio files download <id> [--output <path>]\n corvio files upload --file <path> [--mime-type <type>]\n corvio files organize <id> --instruction <text> --yes [--target-root-node-id <id>]\n corvio files open <id>`,
130
+ sync: `Usage:\n corvio sync init --root-node-id <id> [--dir <path>] [--name <name>]\n corvio sync status|plan|pull [--dir <path>]\n corvio sync push --yes [--dir <path>]\n corvio sync resolve --conflict-id <id> --strategy use-remote|keep-local --yes [--dir <path>]`,
131
+ update: `Usage: corvio update check [--json]\n\nCompare this executable with the reviewed API policy and the npm beta dist-tag.`,
132
+ });
133
+
134
+ function contextualHelp(command, action) {
135
+ const normalizedCommand = command === "workspace" ? "workspaces" : command === "folders" ? "projects" : command;
136
+ const commandHelp = COMMAND_HELP[normalizedCommand];
137
+ if (!commandHelp) return HELP.trimEnd();
138
+ if (!action) return commandHelp;
139
+ const matchingLine = commandHelp
140
+ .split("\n")
141
+ .find((line) => line.trim().startsWith(`corvio ${normalizedCommand} ${action}`));
142
+ return matchingLine ? `Usage:\n ${matchingLine.trim()}\n\n${commandHelp}` : commandHelp;
143
+ }
144
+
145
+ function normalizeInvocation(command, action) {
146
+ const normalizedCommand = command === "workspace" ? "workspaces" : command === "folders" ? "projects" : command;
147
+ const defaultActions = {
148
+ workspaces: "list",
149
+ questions: "list",
150
+ conversations: "list",
151
+ docs: "list",
152
+ projects: "create",
153
+ files: "list",
154
+ sync: "status",
155
+ update: "check",
156
+ };
157
+ return {
158
+ command: normalizedCommand,
159
+ action: ["ask", "search", "capabilities"].includes(normalizedCommand)
160
+ ? ""
161
+ : action || defaultActions[normalizedCommand] || "",
162
+ };
163
+ }
164
+
165
+ function validateInvocation(options, positionals) {
166
+ const [rawCommand, rawAction] = positionals;
167
+ if (!rawCommand || rawCommand === "help" || rawCommand === "version" || options.help || options.version) return;
168
+ const { command, action } = normalizeInvocation(rawCommand, rawAction);
169
+ const schema = COMMAND_OPTIONS[`${command}:${action}`];
170
+ if (!schema) throw new CliError(`Unknown command: ${positionals.join(" ")}`, { code: "unknown_command" });
171
+ assertKnownOptions(options, [...GLOBAL_OPTIONS, ...schema]);
172
+ if (command !== "ask" && command !== "search") {
173
+ const actionsWithId = new Set([
174
+ "workspaces:use",
175
+ "questions:get",
176
+ "conversations:get",
177
+ "docs:get",
178
+ "docs:update",
179
+ "docs:move",
180
+ "docs:archive",
181
+ "docs:share",
182
+ "docs:open",
183
+ "files:get",
184
+ "files:download",
185
+ "files:organize",
186
+ "files:open",
187
+ ]);
188
+ assertPositionalCount(positionals, actionsWithId.has(`${command}:${action}`) ? 3 : command === "capabilities" ? 1 : 2);
189
+ }
190
+ }
191
+
192
+ function rejectTogether(options, left, right) {
193
+ if (options[left] !== undefined && options[right] !== undefined) {
194
+ throw new CliError(`Options --${left} and --${right} cannot be used together.`, {
195
+ code: "mutually_exclusive_options",
196
+ });
197
+ }
198
+ }
199
+
48
200
  function baseUrl(options) {
49
201
  return normalizeApiBase(options["api-base"] || process.env.CORVIO_API_BASE, DEFAULT_API_BASE);
50
202
  }
51
203
 
52
- async function client(options, { auth = true } = {}) {
204
+ function resourcePath(value) {
205
+ return encodeURIComponent(String(value));
206
+ }
207
+
208
+ function safeRemoteFileName(value, fallback) {
209
+ const normalized = String(value || "").replaceAll("\\", "/");
210
+ const name = basename(normalized).trim();
211
+ return name && name !== "." && name !== ".." ? name : fallback;
212
+ }
213
+
214
+ async function resolveWorkspaceSelection(options, credential) {
215
+ const explicit = String(options.workspace || options["workspace-id"] || "").trim();
216
+ if (explicit) return { workspaceId: explicit, workspaceName: null, source: "option" };
217
+ const legacyWorkspaceId = String(credential?.metadata?.workspace_id || "").trim();
218
+ if (legacyWorkspaceId) {
219
+ return {
220
+ workspaceId: legacyWorkspaceId,
221
+ workspaceName: String(credential?.metadata?.workspace_name || "").trim() || null,
222
+ source: "legacy_credential",
223
+ };
224
+ }
225
+ const context = await readWorkspaceContext();
226
+ if (context?.workspaceId) return context;
227
+ return null;
228
+ }
229
+
230
+ async function client(options, { auth = true, workspace = "required" } = {}) {
53
231
  const credential = auth ? await readWorkspaceCredential() : null;
54
232
  if (auth && !credential) {
55
233
  throw new CliError("No Corvio credential found. Set CORVIO_API_KEY or run `corvio auth login`.", {
@@ -57,16 +235,31 @@ async function client(options, { auth = true } = {}) {
57
235
  exitCode: EXIT.AUTH,
58
236
  });
59
237
  }
238
+ const selection = auth && workspace !== "none"
239
+ ? await resolveWorkspaceSelection(options, credential)
240
+ : null;
241
+ if (auth && workspace === "required" && credential?.key?.startsWith("cvu_") && !selection?.workspaceId) {
242
+ throw new CliError(
243
+ "No Corvio Workspace selected. Run `corvio workspaces list` then `corvio workspaces use <id>`.",
244
+ { code: "workspace_selection_required", exitCode: EXIT.AUTH },
245
+ );
246
+ }
60
247
  return {
61
- api: new ApiClient({ baseUrl: baseUrl(options), credential: credential?.key, userAgent: USER_AGENT }),
248
+ api: new ApiClient({
249
+ baseUrl: baseUrl(options),
250
+ credential: credential?.key,
251
+ workspaceId: selection?.workspaceId,
252
+ userAgent: USER_AGENT,
253
+ }),
62
254
  credential,
255
+ selection,
63
256
  };
64
257
  }
65
258
 
66
259
  async function authLogin(options) {
67
260
  const current = await readWorkspaceCredential();
68
261
  if (current?.source === "environment" && !options.force) {
69
- const { api } = await client(options);
262
+ const api = new ApiClient({ baseUrl: baseUrl(options), credential: current.key, userAgent: USER_AGENT });
70
263
  const identity = await api.request("/me");
71
264
  writeResult({ authenticated: true, source: "environment", identity }, { json: options.json });
72
265
  return;
@@ -85,6 +278,11 @@ async function authLogin(options) {
85
278
  "documents:read",
86
279
  "documents:write",
87
280
  "documents:share",
281
+ "assets:read",
282
+ "assets:write",
283
+ "sync:read",
284
+ "sync:write",
285
+ "workspaces:write",
88
286
  ],
89
287
  },
90
288
  });
@@ -127,7 +325,9 @@ async function authLogin(options) {
127
325
  authenticated: true,
128
326
  source: "device_link",
129
327
  profile: stored.profile,
328
+ user: identity.user,
130
329
  workspace: identity.workspace,
330
+ workspaces: identity.workspaces || [],
131
331
  scopes: identity.api_key?.scopes || [],
132
332
  }, { json: options.json });
133
333
  }
@@ -138,7 +338,12 @@ async function authStatus(options) {
138
338
  writeResult({ authenticated: false, sources: ["CORVIO_API_KEY", "device_link"] }, { json: options.json });
139
339
  return;
140
340
  }
141
- const api = new ApiClient({ baseUrl: baseUrl(options), credential: credential.key, userAgent: USER_AGENT });
341
+ const selection = await resolveWorkspaceSelection(options, credential);
342
+ const api = new ApiClient({
343
+ baseUrl: baseUrl(options),
344
+ credential: credential.key,
345
+ userAgent: USER_AGENT,
346
+ });
142
347
  const identity = await api.request("/me");
143
348
  writeResult({
144
349
  authenticated: true,
@@ -146,6 +351,8 @@ async function authStatus(options) {
146
351
  profile: credential.profile,
147
352
  user: identity.user,
148
353
  workspace: identity.workspace,
354
+ workspaces: identity.workspaces || [],
355
+ selection,
149
356
  api_key: identity.api_key,
150
357
  }, { json: options.json });
151
358
  }
@@ -165,24 +372,104 @@ async function authLogout(options) {
165
372
  const api = new ApiClient({ baseUrl: baseUrl(options), credential: credential.key, userAgent: USER_AGENT });
166
373
  await api.request("/auth/credentials/current:revoke", { method: "POST" });
167
374
  await clearWorkspaceCredential();
375
+ await clearWorkspaceContext();
168
376
  writeResult({ authenticated: false, revoked: true, profile: credential.profile }, { json: options.json });
169
377
  }
170
378
 
171
379
  async function capabilities(options) {
172
- const { api, credential } = await client(options);
380
+ const { api, credential, selection } = await client(options, { workspace: "optional" });
173
381
  const [index, identity] = await Promise.all([api.request("", { auth: false }), api.request("/me")]);
174
382
  writeResult({
175
383
  cli_version: VERSION,
176
384
  auth_source: credential.source,
385
+ workspace_selection: selection,
177
386
  api: index,
178
387
  principal: identity,
179
- unavailable: {
180
- files: "Public Workspace Asset upload/organization is not advertised by /v1 yet.",
181
- sync: "Public Local Sync adapters are not advertised by /v1 yet.",
182
- },
388
+ update: await buildUpdateStatus(index, { allowRegistryFailure: true }),
183
389
  }, { json: options.json });
184
390
  }
185
391
 
392
+ async function workspaces(options, action, id) {
393
+ const credential = await readWorkspaceCredential();
394
+ if (!credential) {
395
+ throw new CliError("No Corvio credential found. Run `corvio auth login`.", {
396
+ code: "auth_required",
397
+ exitCode: EXIT.AUTH,
398
+ });
399
+ }
400
+ if (action === "list") {
401
+ const api = new ApiClient({ baseUrl: baseUrl(options), credential: credential.key, userAgent: USER_AGENT });
402
+ if (credential.key.startsWith("cvk_")) {
403
+ const identity = await api.request("/me");
404
+ return writeResult({ data: identity.workspace ? [identity.workspace] : [], legacy_bound: true }, { json: options.json });
405
+ }
406
+ return writeResult(await api.request("/workspaces"), { json: options.json });
407
+ }
408
+ const selection = await resolveWorkspaceSelection(options, credential);
409
+ if (action === "current") {
410
+ if (!selection?.workspaceId) {
411
+ if (credential.key.startsWith("cvk_")) {
412
+ const api = new ApiClient({ baseUrl: baseUrl(options), credential: credential.key, userAgent: USER_AGENT });
413
+ const identity = await api.request("/me");
414
+ return writeResult(
415
+ { selected: Boolean(identity.workspace), workspace: identity.workspace, source: "legacy_credential" },
416
+ { json: options.json },
417
+ );
418
+ }
419
+ return writeResult({ selected: false, workspace: null }, { json: options.json });
420
+ }
421
+ const api = new ApiClient({
422
+ baseUrl: baseUrl(options),
423
+ credential: credential.key,
424
+ workspaceId: selection.workspaceId,
425
+ userAgent: USER_AGENT,
426
+ });
427
+ const identity = await api.request("/me");
428
+ return writeResult({ selected: true, workspace: identity.workspace, source: selection.source }, { json: options.json });
429
+ }
430
+ if (action === "use") {
431
+ const workspaceId = String(id || options.id || options.workspace || options["workspace-id"] || "").trim();
432
+ if (!workspaceId) throw new CliError("Provide a Workspace ID.", { code: "workspace_selection_required" });
433
+ const api = new ApiClient({
434
+ baseUrl: baseUrl(options),
435
+ credential: credential.key,
436
+ workspaceId,
437
+ userAgent: USER_AGENT,
438
+ });
439
+ const identity = await api.request("/me");
440
+ if (!identity.workspace?.id) {
441
+ throw new CliError("Corvio did not resolve the selected Workspace.", { code: "workspace_selection_failed" });
442
+ }
443
+ await storeWorkspaceContext({ workspaceId: identity.workspace.id, workspaceName: identity.workspace.name });
444
+ return writeResult({ selected: true, workspace: identity.workspace }, { json: options.json });
445
+ }
446
+ if (action === "create") {
447
+ if (!credential.key.startsWith("cvu_")) {
448
+ throw new CliError("Creating a Workspace requires a user-level cvu_ credential.", {
449
+ code: "user_api_key_required",
450
+ exitCode: EXIT.AUTH,
451
+ });
452
+ }
453
+ const api = new ApiClient({ baseUrl: baseUrl(options), credential: credential.key, userAgent: USER_AGENT });
454
+ const kind = String(options.kind || "personal");
455
+ if (!new Set(["personal", "team"]).has(kind)) {
456
+ throw new CliError("--kind must be personal or team.", { code: "workspace_kind_invalid" });
457
+ }
458
+ const result = await api.request("/workspaces", {
459
+ method: "POST",
460
+ headers: { "idempotency-key": idempotencyKey(options) },
461
+ body: {
462
+ name: option(options, "name", { required: true }),
463
+ kind,
464
+ description: options.description || null,
465
+ },
466
+ });
467
+ await storeWorkspaceContext({ workspaceId: result.id, workspaceName: result.name });
468
+ return writeResult({ created: true, selected: true, workspace: result }, { json: options.json });
469
+ }
470
+ throw new CliError("workspaces action must be list, use, current, or create.");
471
+ }
472
+
186
473
  async function ask(options, positionals) {
187
474
  const prompt = String(options.prompt || positionals.join(" ")).trim();
188
475
  if (!prompt) throw new CliError("Provide --prompt <text> or a positional prompt.");
@@ -196,13 +483,14 @@ async function ask(options, positionals) {
196
483
  mode: options["allow-actions"] ? "allow_actions" : "answer_only",
197
484
  sources: listOption(options, "sources", ["workspace", "web", "memory"]),
198
485
  },
486
+ timeoutMs: 330_000,
199
487
  });
200
488
  writeResult(result, { json: options.json });
201
489
  }
202
490
 
203
491
  function pageQuery(options) {
204
492
  return {
205
- limit: numberOption(options, "limit", 20),
493
+ limit: numberOption(options, "limit", 20, { maximum: 100 }),
206
494
  cursor: options.cursor,
207
495
  conversation_id: options["conversation-id"],
208
496
  };
@@ -212,7 +500,7 @@ async function questions(options, action, id) {
212
500
  const { api } = await client(options);
213
501
  const result = action === "list"
214
502
  ? await api.request("/questions", { query: pageQuery(options) })
215
- : await api.request(`/questions/${id || option(options, "id", { required: true })}`);
503
+ : await api.request(`/questions/${resourcePath(id || option(options, "id", { required: true }))}`);
216
504
  writeResult(result, { json: options.json });
217
505
  }
218
506
 
@@ -220,7 +508,7 @@ async function conversations(options, action, id) {
220
508
  const { api } = await client(options);
221
509
  const result = action === "list"
222
510
  ? await api.request("/conversations", { query: pageQuery(options) })
223
- : await api.request(`/conversations/${id || option(options, "id", { required: true })}`);
511
+ : await api.request(`/conversations/${resourcePath(id || option(options, "id", { required: true }))}`);
224
512
  writeResult(result, { json: options.json });
225
513
  }
226
514
 
@@ -230,9 +518,10 @@ async function search(options, positionals) {
230
518
  const { api } = await client(options);
231
519
  const result = await api.request("/search", {
232
520
  method: "POST",
521
+ retrySafe: true,
233
522
  body: {
234
523
  query,
235
- limit: numberOption(options, "limit", 10),
524
+ limit: numberOption(options, "limit", 10, { maximum: 50 }),
236
525
  page_id: options["page-id"] || null,
237
526
  node_id: options["node-id"] || null,
238
527
  },
@@ -247,12 +536,13 @@ async function docs(options, action, id) {
247
536
  if (action === "list") {
248
537
  result = await api.request("/documents", { query: pageQuery(options) });
249
538
  } else if (action === "get") {
250
- result = await api.request(`/documents/${documentId || option(options, "id", { required: true })}`);
539
+ result = await api.request(`/documents/${resourcePath(documentId || option(options, "id", { required: true }))}`);
251
540
  if (options.output) {
252
541
  await writeTextFile(options.output, String(result.markdown || ""));
253
542
  result = { ...result, output: options.output };
254
543
  }
255
544
  } else if (action === "create") {
545
+ rejectTogether(options, "file", "markdown");
256
546
  const markdown = options.file ? await readTextFile(options.file) : String(options.markdown || "");
257
547
  result = await api.request("/documents", {
258
548
  method: "POST",
@@ -265,58 +555,715 @@ async function docs(options, action, id) {
265
555
  },
266
556
  });
267
557
  } else if (action === "update") {
558
+ rejectTogether(options, "file", "markdown");
268
559
  const markdown = options.file ? await readTextFile(options.file) : options.markdown;
269
- result = await api.request(`/documents/${documentId || option(options, "id", { required: true })}`, {
560
+ if (options.title === undefined && markdown === undefined) {
561
+ throw new CliError("Document update requires --title, --file, or --markdown.", {
562
+ code: "document_update_empty",
563
+ });
564
+ }
565
+ const targetId = documentId || option(options, "id", { required: true });
566
+ let expectedRevision;
567
+ if (options["expected-revision"] !== undefined) {
568
+ expectedRevision = numberOption(options, "expected-revision", undefined, { minimum: 0 });
569
+ } else {
570
+ const current = await api.request(`/documents/${resourcePath(targetId)}`);
571
+ if (!Number.isSafeInteger(current?.content_revision) || current.content_revision < 0) {
572
+ throw new CliError("Corvio did not return a valid document content revision.", {
573
+ code: "document_revision_missing",
574
+ exitCode: EXIT.REMOTE,
575
+ });
576
+ }
577
+ expectedRevision = current.content_revision;
578
+ }
579
+ result = await api.request(`/documents/${resourcePath(targetId)}`, {
270
580
  method: "PUT",
271
581
  body: {
272
- ...(options.title ? { title: options.title } : {}),
582
+ ...(options.title !== undefined ? { title: options.title } : {}),
273
583
  ...(markdown !== undefined ? { markdown } : {}),
274
- ...(options["expected-revision"] !== undefined
275
- ? { expected_content_revision: Number.parseInt(options["expected-revision"], 10) }
276
- : {}),
584
+ expected_content_revision: expectedRevision,
585
+ },
586
+ });
587
+ } else if (action === "move") {
588
+ rejectTogether(options, "before-node-id", "after-node-id");
589
+ const current = await api.request(`/documents/${resourcePath(documentId || option(options, "id", { required: true }))}`);
590
+ result = await api.request(`/nodes/${resourcePath(current.node_id)}:move`, {
591
+ method: "POST",
592
+ headers: { "idempotency-key": idempotencyKey(options) },
593
+ body: {
594
+ parent_node_id: option(options, "parent-node-id", { required: true }),
595
+ before_node_id: options["before-node-id"] || null,
596
+ after_node_id: options["after-node-id"] || null,
277
597
  },
278
598
  });
279
599
  } else if (action === "archive") {
280
600
  requireConfirmation(options, "Archiving a document");
281
- result = await api.request(`/documents/${documentId || option(options, "id", { required: true })}`, { method: "DELETE" });
601
+ result = await api.request(`/documents/${resourcePath(documentId || option(options, "id", { required: true }))}`, { method: "DELETE" });
282
602
  } else if (action === "share") {
283
603
  requireConfirmation(options, "Creating a document share link");
284
- result = await api.request(`/documents/${documentId || option(options, "id", { required: true })}/shares`, {
604
+ result = await api.request(`/documents/${resourcePath(documentId || option(options, "id", { required: true }))}/shares`, {
285
605
  method: "POST",
606
+ retrySafe: true,
286
607
  body: { public_display_name: options["display-name"] || null },
287
608
  });
288
609
  } else if (action === "open") {
289
- const target = `${String(process.env.CORVIO_APP_BASE || DEFAULT_APP_BASE).replace(/\/+$/, "")}/app/d/${documentId || option(options, "id", { required: true })}`;
610
+ const targetId = documentId || option(options, "id", { required: true });
611
+ const target = `${String(process.env.CORVIO_APP_BASE || DEFAULT_APP_BASE).replace(/\/+$/, "")}/app/d/${resourcePath(targetId)}`;
290
612
  const opened = options["no-input"] ? false : await openBrowser(target);
291
- result = { document_id: documentId, url: target, opened };
613
+ result = { document_id: targetId, url: target, opened };
614
+ } else {
615
+ throw new CliError("docs action must be list, get, create, update, move, archive, share, or open.");
616
+ }
617
+ writeResult(result, { json: options.json });
618
+ }
619
+
620
+ async function projects(options, action) {
621
+ if (action !== "create") {
622
+ throw new CliError("projects action must be create.");
623
+ }
624
+ const { api } = await client(options);
625
+ const result = await api.request("/projects", {
626
+ method: "POST",
627
+ headers: { "idempotency-key": idempotencyKey(options) },
628
+ body: { title: option(options, "title", { required: true }) },
629
+ });
630
+ writeResult(result, { json: options.json });
631
+ }
632
+
633
+ async function buildUpdateStatus(index, { allowRegistryFailure = false } = {}) {
634
+ return resolveNpmUpdateStatus({
635
+ currentVersion: VERSION,
636
+ policy: index?.distribution?.workspace_cli || {
637
+ package: "@corvio/cli",
638
+ dist_tag: "beta",
639
+ },
640
+ userAgent: USER_AGENT,
641
+ allowRegistryFailure,
642
+ });
643
+ }
644
+
645
+ function updateCachePath() {
646
+ const root = process.env.CORVIO_CONFIG_DIR
647
+ ? resolve(process.env.CORVIO_CONFIG_DIR)
648
+ : process.env.XDG_CACHE_HOME
649
+ ? join(resolve(process.env.XDG_CACHE_HOME), "corvio")
650
+ : join(homedir(), ".cache", "corvio");
651
+ return join(root, "update-check.json");
652
+ }
653
+
654
+ async function readUpdateCache() {
655
+ try {
656
+ return JSON.parse(await readFile(updateCachePath(), "utf8"));
657
+ } catch {
658
+ return null;
659
+ }
660
+ }
661
+
662
+ async function writeUpdateCache(payload) {
663
+ const target = updateCachePath();
664
+ await mkdir(dirname(target), { recursive: true, mode: 0o700 });
665
+ const temporary = `${target}.${randomUUID()}.tmp`;
666
+ await writeFile(temporary, `${JSON.stringify(payload, null, 2)}\n`, { mode: 0o600 });
667
+ await rename(temporary, target);
668
+ }
669
+
670
+ async function updateCheck(options) {
671
+ const { api } = await client(options, { auth: false });
672
+ const index = await api.request("", { auth: false });
673
+ const result = await buildUpdateStatus(index);
674
+ await writeUpdateCache({ checked_at: new Date().toISOString(), result }).catch(() => {});
675
+ writeResult(result, { json: options.json });
676
+ }
677
+
678
+ async function maybeNotifyUpdate(options) {
679
+ if (
680
+ options.json
681
+ || options["no-input"]
682
+ || process.env.CI
683
+ || process.env.CORVIO_DISABLE_UPDATE_CHECK === "1"
684
+ ) return;
685
+ const cache = await readUpdateCache();
686
+ const checkedAt = Date.parse(String(cache?.checked_at || ""));
687
+ let result = cache?.result;
688
+ if (!Number.isFinite(checkedAt) || Date.now() - checkedAt > 24 * 60 * 60 * 1000) {
689
+ try {
690
+ const { api } = await client(options, { auth: false });
691
+ const index = await api.request("", { auth: false });
692
+ result = await buildUpdateStatus(index, { allowRegistryFailure: true });
693
+ await writeUpdateCache({ checked_at: new Date().toISOString(), result }).catch(() => {});
694
+ } catch {
695
+ return;
696
+ }
697
+ }
698
+ if (result?.update_available) {
699
+ process.stderr.write(
700
+ `Update available: @corvio/cli ${result.current_version} → ${result.latest_published_version}. `
701
+ + `Run: ${result.install_command}\n`,
702
+ );
703
+ } else if (result?.supported === false) {
704
+ process.stderr.write(
705
+ `This Corvio CLI (${VERSION}) is no longer supported. Run: ${result.install_command}\n`,
706
+ );
707
+ }
708
+ }
709
+
710
+ function mimeTypeFor(path) {
711
+ const extension = extname(String(path)).toLowerCase();
712
+ return ({
713
+ ".md": "text/markdown",
714
+ ".txt": "text/plain",
715
+ ".csv": "text/csv",
716
+ ".json": "application/json",
717
+ ".pdf": "application/pdf",
718
+ ".png": "image/png",
719
+ ".jpg": "image/jpeg",
720
+ ".jpeg": "image/jpeg",
721
+ ".gif": "image/gif",
722
+ ".docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
723
+ ".xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
724
+ ".pptx": "application/vnd.openxmlformats-officedocument.presentationml.presentation",
725
+ })[extension] || "application/octet-stream";
726
+ }
727
+
728
+ async function files(options, action, id) {
729
+ const { api } = await client(options);
730
+ const assetId = id || options.id;
731
+ let result;
732
+ if (action === "list") {
733
+ result = await api.request("/files", { query: { limit: numberOption(options, "limit", 50, { maximum: 100 }) } });
734
+ } else if (action === "get") {
735
+ result = await api.request(`/files/${resourcePath(assetId || option(options, "id", { required: true }))}`);
736
+ } else if (action === "download") {
737
+ const targetId = assetId || option(options, "id", { required: true });
738
+ const metadata = await api.request(`/files/${resourcePath(targetId)}`);
739
+ const bytes = await api.requestBytes(`/files/${resourcePath(targetId)}/content`);
740
+ if (metadata.content_sha256 && sha256(bytes) !== metadata.content_sha256) {
741
+ throw new CliError("Downloaded file content did not match Corvio's SHA-256 receipt.", {
742
+ code: "download_integrity_mismatch",
743
+ exitCode: EXIT.REMOTE,
744
+ });
745
+ }
746
+ const output = options.output || safeRemoteFileName(metadata.original_file_name, `${targetId}.bin`);
747
+ const written = await writeBinaryFile(output, bytes);
748
+ result = { ...metadata, output: written };
749
+ } else if (action === "upload") {
750
+ const input = resolve(String(option(options, "file", { required: true })));
751
+ const info = await stat(input);
752
+ if (!info.isFile()) throw new CliError("--file must point to a regular file.", { code: "upload_file_invalid" });
753
+ if (info.size < 1 || info.size > MAX_UPLOAD_BYTES) {
754
+ throw new CliError(`--file must be between 1 byte and ${MAX_UPLOAD_BYTES} bytes.`, {
755
+ code: "upload_file_size_invalid",
756
+ });
757
+ }
758
+ const bytes = await readFile(input);
759
+ if (bytes.length < 1 || bytes.length > MAX_UPLOAD_BYTES) {
760
+ throw new CliError(`--file must be between 1 byte and ${MAX_UPLOAD_BYTES} bytes.`, {
761
+ code: "upload_file_size_invalid",
762
+ });
763
+ }
764
+ const fileName = basename(input);
765
+ const mimeType = String(options["mime-type"] || mimeTypeFor(input));
766
+ const initialized = await api.request("/files/uploads", {
767
+ method: "POST",
768
+ body: { file_name: fileName, mime_type: mimeType, size_bytes: bytes.length },
769
+ });
770
+ await api.putUrl(initialized.upload_url, bytes, {
771
+ headers: initialized.upload_headers || {},
772
+ timeoutMs: 5 * 60_000,
773
+ });
774
+ result = await api.request("/files/uploads:finalize", {
775
+ method: "POST",
776
+ retrySafe: true,
777
+ body: {
778
+ upload_id: initialized.upload_id,
779
+ blob_name: initialized.blob_name,
780
+ file_name: fileName,
781
+ mime_type: mimeType,
782
+ size_bytes: bytes.length,
783
+ },
784
+ });
785
+ if (result.content_sha256 && result.content_sha256 !== sha256(bytes)) {
786
+ throw new CliError("Uploaded file content did not match Corvio's SHA-256 receipt.", {
787
+ code: "upload_integrity_mismatch",
788
+ exitCode: EXIT.REMOTE,
789
+ });
790
+ }
791
+ } else if (action === "organize") {
792
+ requireConfirmation(options, "Starting a file organization run");
793
+ result = await api.request(`/files/${resourcePath(assetId || option(options, "id", { required: true }))}:organize`, {
794
+ method: "POST",
795
+ body: {
796
+ instruction: option(options, "instruction", { required: true }),
797
+ target_root_node_id: options["target-root-node-id"] || null,
798
+ },
799
+ });
800
+ } else if (action === "open") {
801
+ const targetId = assetId || option(options, "id", { required: true });
802
+ const target = `${String(process.env.CORVIO_APP_BASE || DEFAULT_APP_BASE).replace(/\/+$/, "")}/app/a/${resourcePath(targetId)}`;
803
+ result = { file_id: targetId, url: target, opened: options["no-input"] ? false : await openBrowser(target) };
292
804
  } else {
293
- throw new CliError("docs action must be list, get, create, update, archive, share, or open.");
805
+ throw new CliError("files action must be list, get, download, upload, organize, or open.");
294
806
  }
295
807
  writeResult(result, { json: options.json });
296
808
  }
297
809
 
810
+ function sha256(content) {
811
+ return createHash("sha256").update(content).digest("hex");
812
+ }
813
+
814
+ function syncRoot(options) {
815
+ return resolve(String(options.dir || process.cwd()));
816
+ }
817
+
818
+ function syncStatePath(root) {
819
+ return join(root, ".corvio", "state.json");
820
+ }
821
+
822
+ async function readSyncState(options) {
823
+ const root = syncRoot(options);
824
+ let state;
825
+ try {
826
+ state = JSON.parse(await readFile(syncStatePath(root), "utf8"));
827
+ } catch (cause) {
828
+ if (cause?.code === "ENOENT") {
829
+ throw new CliError("This directory is not initialized. Run `corvio sync init --root-node-id <id>`.", {
830
+ code: "sync_not_initialized",
831
+ });
832
+ }
833
+ throw new CliError(".corvio/state.json is invalid.", { code: "sync_state_invalid" });
834
+ }
835
+ if (state.version !== 1 || !state.source_id) {
836
+ throw new CliError(".corvio/state.json has an unsupported format.", { code: "sync_state_invalid" });
837
+ }
838
+ const requestedBase = baseUrl(options);
839
+ if (state.api_base && state.api_base !== requestedBase) {
840
+ throw new CliError(
841
+ `This directory belongs to ${state.api_base}; use the matching --api-base or reinitialize it.`,
842
+ { code: "sync_api_base_mismatch" },
843
+ );
844
+ }
845
+ return { root, state };
846
+ }
847
+
848
+ async function writeSyncState(root, state) {
849
+ const target = syncStatePath(root);
850
+ await mkdir(dirname(target), { recursive: true, mode: 0o700 });
851
+ const temporary = `${target}.${randomUUID()}.tmp`;
852
+ await writeFile(temporary, `${JSON.stringify(state, null, 2)}\n`, { mode: 0o600 });
853
+ await rename(temporary, target);
854
+ }
855
+
856
+ function localSyncPath(root, relativePath) {
857
+ if (String(relativePath).includes("\0")) {
858
+ throw new CliError("Unsafe sync path returned by Corvio.", { code: "sync_path_unsafe" });
859
+ }
860
+ const parts = String(relativePath).replaceAll("\\", "/").split("/").filter(Boolean);
861
+ if (parts[0] !== "corvio_docs" || parts.some((part) => part === "." || part === "..")) {
862
+ throw new CliError(`Unsafe sync path returned by Corvio: ${relativePath}`, { code: "sync_path_unsafe" });
863
+ }
864
+ const target = resolve(root, ...parts);
865
+ if (target !== root && !target.startsWith(`${root}${sep}`)) {
866
+ throw new CliError(`Unsafe sync path returned by Corvio: ${relativePath}`, { code: "sync_path_unsafe" });
867
+ }
868
+ return target;
869
+ }
870
+
871
+ async function assertNoSyncSymlink(root, target) {
872
+ const rootInfo = await lstat(root);
873
+ if (rootInfo.isSymbolicLink()) {
874
+ throw new CliError(`Sync root must not be a symbolic link: ${root}`, {
875
+ code: "sync_symlink_unsafe",
876
+ });
877
+ }
878
+ const relativeTarget = relative(root, target);
879
+ if (relativeTarget === "" || relativeTarget.startsWith(`..${sep}`) || relativeTarget === "..") {
880
+ throw new CliError(`Unsafe local sync target: ${target}`, { code: "sync_path_unsafe" });
881
+ }
882
+ let current = root;
883
+ for (const part of relativeTarget.split(sep).filter(Boolean)) {
884
+ current = join(current, part);
885
+ try {
886
+ const info = await lstat(current);
887
+ if (info.isSymbolicLink()) {
888
+ throw new CliError(`Sync path crosses a symbolic link: ${current}`, {
889
+ code: "sync_symlink_unsafe",
890
+ });
891
+ }
892
+ } catch (error) {
893
+ if (error?.code === "ENOENT") return;
894
+ throw error;
895
+ }
896
+ }
897
+ }
898
+
899
+ function verifiedRemoteMarkdown(item) {
900
+ const markdown = String(item?.markdown || "");
901
+ if (markdown.length > MAX_MARKDOWN_CHARACTERS) {
902
+ throw new CliError("Remote Markdown exceeds the supported sync size.", {
903
+ code: "sync_markdown_too_large",
904
+ exitCode: EXIT.REMOTE,
905
+ });
906
+ }
907
+ const contentHash = sha256(Buffer.from(markdown, "utf8"));
908
+ if (contentHash !== item?.content_sha256) {
909
+ throw new CliError("Remote Markdown did not match Corvio's SHA-256 receipt.", {
910
+ code: "sync_integrity_mismatch",
911
+ exitCode: EXIT.REMOTE,
912
+ });
913
+ }
914
+ return { markdown, contentHash };
915
+ }
916
+
917
+ async function scanMarkdown(root) {
918
+ const mirror = join(root, "corvio_docs");
919
+ await mkdir(mirror, { recursive: true });
920
+ await assertNoSyncSymlink(root, mirror);
921
+ const manifest = [];
922
+ async function walk(directory) {
923
+ for (const entry of await readdir(directory, { withFileTypes: true })) {
924
+ const path = join(directory, entry.name);
925
+ if (entry.isSymbolicLink()) continue;
926
+ if (entry.isDirectory()) {
927
+ await walk(path);
928
+ } else if (entry.isFile() && entry.name.toLowerCase().endsWith(".md")) {
929
+ const bytes = await readFile(path);
930
+ const info = await lstat(path);
931
+ manifest.push({
932
+ relative_path: relative(root, path).split(sep).join("/"),
933
+ content_sha256: sha256(bytes),
934
+ size_bytes: bytes.length,
935
+ mtime_ms: Math.trunc(info.mtimeMs),
936
+ });
937
+ if (manifest.length > MAX_SYNC_FILES) {
938
+ throw new CliError(`Sync supports at most ${MAX_SYNC_FILES} Markdown files per source.`, {
939
+ code: "sync_file_limit_exceeded",
940
+ });
941
+ }
942
+ }
943
+ }
944
+ }
945
+ await walk(mirror);
946
+ manifest.sort((left, right) => left.relative_path.localeCompare(right.relative_path));
947
+ return manifest;
948
+ }
949
+
950
+ async function syncManifest(root, state) {
951
+ const manifest = await scanMarkdown(root);
952
+ const mappings = Object.entries(state.mappings || {}).map(([pageId, mapping]) => ({ pageId, ...mapping }));
953
+ const pageByPath = new Map(mappings.map((mapping) => [mapping.relative_path, mapping.pageId]));
954
+ const presentPaths = new Set(manifest.map((item) => item.relative_path));
955
+ for (const item of manifest) {
956
+ const pageId = pageByPath.get(item.relative_path);
957
+ if (pageId) item.page_id = pageId;
958
+ }
959
+
960
+ // Treat a local move as the same Page only when the last-common hash gives
961
+ // one unambiguous missing-old-path -> new-path match. Duplicate content
962
+ // stays unbound and the server reports separate missing/new actions.
963
+ const unboundByHash = new Map();
964
+ for (const item of manifest.filter((candidate) => !candidate.page_id)) {
965
+ const values = unboundByHash.get(item.content_sha256) || [];
966
+ values.push(item);
967
+ unboundByHash.set(item.content_sha256, values);
968
+ }
969
+ const missingByHash = new Map();
970
+ for (const mapping of mappings.filter((candidate) => !presentPaths.has(candidate.relative_path))) {
971
+ const values = missingByHash.get(mapping.content_sha256) || [];
972
+ values.push(mapping);
973
+ missingByHash.set(mapping.content_sha256, values);
974
+ }
975
+ for (const [hash, items] of unboundByHash.entries()) {
976
+ const candidates = missingByHash.get(hash) || [];
977
+ if (items.length === 1 && candidates.length === 1) items[0].page_id = candidates[0].pageId;
978
+ }
979
+ return manifest;
980
+ }
981
+
982
+ async function syncPlan(api, state, root) {
983
+ return api.request("/sync/plan", {
984
+ method: "POST",
985
+ retrySafe: true,
986
+ body: { source_id: state.source_id, local: await syncManifest(root, state) },
987
+ });
988
+ }
989
+
990
+ async function syncCommand(options, action) {
991
+ const { api, selection } = await client(options);
992
+ if (action === "init") {
993
+ const root = syncRoot(options);
994
+ await mkdir(root, { recursive: true });
995
+ if (!options.force) {
996
+ try {
997
+ await readFile(syncStatePath(root), "utf8");
998
+ throw new CliError("This directory is already initialized; pass --force to bind it again.", {
999
+ code: "sync_already_initialized",
1000
+ });
1001
+ } catch (cause) {
1002
+ if (cause instanceof CliError) throw cause;
1003
+ if (cause?.code !== "ENOENT") throw cause;
1004
+ }
1005
+ }
1006
+ const clientId = `corvio-cli-${randomUUID()}`;
1007
+ const source = await api.request("/sync/sources", {
1008
+ method: "POST",
1009
+ body: {
1010
+ root_node_id: option(options, "root-node-id", { required: true }),
1011
+ folder_name: String(options.name || basename(root)),
1012
+ client_id: clientId,
1013
+ },
1014
+ });
1015
+ const state = {
1016
+ version: 1,
1017
+ source_id: source.id,
1018
+ root_node_id: source.root_node_id,
1019
+ workspace_id: source.workspace_id || selection?.workspaceId || null,
1020
+ client_id: clientId,
1021
+ api_base: baseUrl(options),
1022
+ mirror_root_dir: source.mirror_root_dir || "corvio_docs",
1023
+ mappings: {},
1024
+ initialized_at: new Date().toISOString(),
1025
+ };
1026
+ await mkdir(join(root, state.mirror_root_dir), { recursive: true });
1027
+ await writeSyncState(root, state);
1028
+ return writeResult({ source, directory: root, state_file: syncStatePath(root) }, { json: options.json });
1029
+ }
1030
+
1031
+ const { root, state } = await readSyncState(options);
1032
+ if (state.workspace_id && selection?.workspaceId && state.workspace_id !== selection.workspaceId) {
1033
+ throw new CliError(
1034
+ `This sync directory belongs to Workspace ${state.workspace_id}; select it before syncing.`,
1035
+ { code: "sync_workspace_mismatch" },
1036
+ );
1037
+ }
1038
+ if (action === "status") {
1039
+ const remote = await api.request(`/sync/sources/${resourcePath(state.source_id)}`);
1040
+ return writeResult({ ...remote, directory: root, state_file: syncStatePath(root) }, { json: options.json });
1041
+ }
1042
+ if (action === "plan") {
1043
+ return writeResult(await syncPlan(api, state, root), { json: options.json });
1044
+ }
1045
+ if (action === "pull") {
1046
+ const plan = await syncPlan(api, state, root);
1047
+ const conflicts = plan.actions.filter((item) => item.action === "conflict");
1048
+ if (conflicts.length) {
1049
+ throw new CliError("Sync has conflicts. Run `corvio sync plan --json` and resolve each conflict.", {
1050
+ code: "sync_conflict",
1051
+ exitCode: EXIT.CONFLICT,
1052
+ details: { conflicts },
1053
+ });
1054
+ }
1055
+ const committed = [];
1056
+ const mappings = { ...(state.mappings || {}) };
1057
+ for (const actionItem of plan.actions.filter((item) => item.action === "pull")) {
1058
+ const item = actionItem.remote;
1059
+ if (!item) continue;
1060
+ const target = localSyncPath(root, item.relative_path);
1061
+ await assertNoSyncSymlink(root, target);
1062
+ const verified = verifiedRemoteMarkdown(item);
1063
+ const previous = mappings[item.page_id];
1064
+ if (previous?.relative_path && previous.relative_path !== item.relative_path) {
1065
+ const oldTarget = localSyncPath(root, previous.relative_path);
1066
+ await assertNoSyncSymlink(root, oldTarget);
1067
+ try {
1068
+ const oldBytes = await readFile(oldTarget);
1069
+ if (sha256(oldBytes) === previous.content_sha256) await unlink(oldTarget);
1070
+ } catch (cause) {
1071
+ if (cause?.code !== "ENOENT") throw cause;
1072
+ }
1073
+ }
1074
+ await writeTextFile(target, verified.markdown);
1075
+ const info = await stat(target);
1076
+ committed.push({
1077
+ page_id: item.page_id,
1078
+ node_id: item.node_id,
1079
+ relative_path: item.relative_path,
1080
+ content_sha256: verified.contentHash,
1081
+ mtime_ms: Math.trunc(info.mtimeMs),
1082
+ });
1083
+ mappings[item.page_id] = {
1084
+ node_id: item.node_id,
1085
+ relative_path: item.relative_path,
1086
+ content_sha256: item.content_sha256,
1087
+ content_revision: item.content_revision,
1088
+ };
1089
+ }
1090
+ if (committed.length) {
1091
+ await api.request("/sync/commit", {
1092
+ method: "POST",
1093
+ body: { source_id: state.source_id, items: committed },
1094
+ });
1095
+ }
1096
+ state.mappings = mappings;
1097
+ state.last_pull_at = new Date().toISOString();
1098
+ await writeSyncState(root, state);
1099
+ return writeResult({
1100
+ pulled: committed.length,
1101
+ unresolved_local_missing: plan.actions.filter((item) => item.action === "local_missing"),
1102
+ directory: root,
1103
+ }, { json: options.json });
1104
+ }
1105
+ if (action === "push") {
1106
+ requireConfirmation(options, "Pushing local Markdown changes to Corvio");
1107
+ const plan = await syncPlan(api, state, root);
1108
+ const conflicts = plan.actions.filter((item) => item.action === "conflict");
1109
+ if (conflicts.length) {
1110
+ throw new CliError("Sync has conflicts. Resolve them before pushing.", {
1111
+ code: "sync_conflict",
1112
+ exitCode: EXIT.CONFLICT,
1113
+ details: { conflicts },
1114
+ });
1115
+ }
1116
+ const results = [];
1117
+ const mappings = { ...(state.mappings || {}) };
1118
+ for (const actionItem of plan.actions.filter((item) => item.action === "push")) {
1119
+ const path = localSyncPath(root, actionItem.relative_path);
1120
+ await assertNoSyncSymlink(root, path);
1121
+ const bytes = await readFile(path);
1122
+ const markdown = bytes.toString("utf8");
1123
+ if (markdown.length > MAX_MARKDOWN_CHARACTERS) {
1124
+ throw new CliError(`Markdown exceeds ${MAX_MARKDOWN_CHARACTERS} characters: ${actionItem.relative_path}`, {
1125
+ code: "sync_markdown_too_large",
1126
+ });
1127
+ }
1128
+ const info = await stat(path);
1129
+ const previous = actionItem.page_id ? mappings[actionItem.page_id] : null;
1130
+ const result = await api.request("/sync/push", {
1131
+ method: "POST",
1132
+ body: {
1133
+ source_id: state.source_id,
1134
+ relative_path: actionItem.relative_path,
1135
+ markdown,
1136
+ content_sha256: sha256(bytes),
1137
+ mtime_ms: Math.trunc(info.mtimeMs),
1138
+ page_id: actionItem.page_id || null,
1139
+ expected_content_revision: previous?.content_revision ?? null,
1140
+ },
1141
+ });
1142
+ results.push(result);
1143
+ mappings[result.item.page_id] = {
1144
+ node_id: result.item.node_id,
1145
+ relative_path: result.item.relative_path,
1146
+ content_sha256: result.item.content_sha256,
1147
+ content_revision: result.item.content_revision,
1148
+ };
1149
+ state.mappings = mappings;
1150
+ state.last_push_at = new Date().toISOString();
1151
+ await writeSyncState(root, state);
1152
+ }
1153
+ state.mappings = mappings;
1154
+ state.last_push_at = new Date().toISOString();
1155
+ await writeSyncState(root, state);
1156
+ return writeResult({
1157
+ pushed: results.length,
1158
+ results,
1159
+ unresolved_local_missing: plan.actions.filter((item) => item.action === "local_missing"),
1160
+ }, { json: options.json });
1161
+ }
1162
+ if (action === "resolve") {
1163
+ requireConfirmation(options, "Resolving a sync conflict");
1164
+ const conflictId = option(options, "conflict-id", { required: true });
1165
+ const strategy = option(options, "strategy", { required: true });
1166
+ const conflict = await api.request(`/sync/conflicts/${resourcePath(conflictId)}`);
1167
+ if (strategy === "use-remote") {
1168
+ if (!conflict.remote) throw new CliError("The remote document is no longer available.", { code: "sync_remote_missing" });
1169
+ const item = conflict.remote;
1170
+ const target = localSyncPath(root, item.relative_path);
1171
+ await assertNoSyncSymlink(root, target);
1172
+ const verified = verifiedRemoteMarkdown(item);
1173
+ await writeTextFile(target, verified.markdown);
1174
+ const info = await stat(target);
1175
+ await api.request("/sync/commit", {
1176
+ method: "POST",
1177
+ body: {
1178
+ source_id: state.source_id,
1179
+ items: [{
1180
+ page_id: item.page_id,
1181
+ node_id: item.node_id,
1182
+ relative_path: item.relative_path,
1183
+ content_sha256: verified.contentHash,
1184
+ mtime_ms: Math.trunc(info.mtimeMs),
1185
+ }],
1186
+ },
1187
+ });
1188
+ const result = await api.request(`/sync/conflicts/${resourcePath(conflictId)}:resolve`, {
1189
+ method: "POST",
1190
+ body: { strategy: "use_remote" },
1191
+ });
1192
+ state.mappings[item.page_id] = {
1193
+ node_id: item.node_id,
1194
+ relative_path: item.relative_path,
1195
+ content_sha256: item.content_sha256,
1196
+ content_revision: item.content_revision,
1197
+ };
1198
+ await writeSyncState(root, state);
1199
+ return writeResult(result, { json: options.json });
1200
+ }
1201
+ if (strategy === "keep-local") {
1202
+ const target = localSyncPath(root, conflict.relative_path);
1203
+ await assertNoSyncSymlink(root, target);
1204
+ const bytes = await readFile(target);
1205
+ const markdown = bytes.toString("utf8");
1206
+ if (markdown.length > MAX_MARKDOWN_CHARACTERS) {
1207
+ throw new CliError(`Markdown exceeds ${MAX_MARKDOWN_CHARACTERS} characters: ${conflict.relative_path}`, {
1208
+ code: "sync_markdown_too_large",
1209
+ });
1210
+ }
1211
+ const info = await stat(target);
1212
+ const result = await api.request("/sync/push", {
1213
+ method: "POST",
1214
+ body: {
1215
+ source_id: state.source_id,
1216
+ relative_path: conflict.relative_path,
1217
+ markdown,
1218
+ content_sha256: sha256(bytes),
1219
+ mtime_ms: Math.trunc(info.mtimeMs),
1220
+ page_id: conflict.remote?.page_id || null,
1221
+ conflict_id: conflictId,
1222
+ },
1223
+ });
1224
+ state.mappings[result.item.page_id] = {
1225
+ node_id: result.item.node_id,
1226
+ relative_path: result.item.relative_path,
1227
+ content_sha256: result.item.content_sha256,
1228
+ content_revision: result.item.content_revision,
1229
+ };
1230
+ await writeSyncState(root, state);
1231
+ return writeResult(result, { json: options.json });
1232
+ }
1233
+ throw new CliError("--strategy must be use-remote or keep-local.", { code: "sync_strategy_invalid" });
1234
+ }
1235
+ throw new CliError("sync action must be init, status, plan, pull, push, or resolve.");
1236
+ }
1237
+
298
1238
  async function main({ options, positionals }) {
299
1239
  const [command, action, id, ...rest] = positionals;
300
1240
  if (options.version || command === "version") return writeResult(VERSION, { json: options.json });
301
- if (options.help || !command || command === "help") return writeResult(HELP.trimEnd(), { json: false });
1241
+ if (options.help || !command || command === "help") {
1242
+ const helpCommand = command === "help" ? action : command;
1243
+ const helpAction = command === "help" ? id : action;
1244
+ return writeResult(contextualHelp(helpCommand, helpAction), { json: false });
1245
+ }
1246
+ validateInvocation(options, positionals);
1247
+ if (command !== "update") await maybeNotifyUpdate(options);
302
1248
  if (command === "auth") {
303
1249
  if (action === "login") return authLogin(options);
304
1250
  if (action === "status") return authStatus(options);
305
1251
  if (action === "logout") return authLogout(options);
306
1252
  throw new CliError("auth action must be login, status, or logout.");
307
1253
  }
1254
+ if (command === "workspaces" || command === "workspace") {
1255
+ return workspaces(options, action || "list", id);
1256
+ }
308
1257
  if (command === "capabilities") return capabilities(options);
309
1258
  if (command === "ask") return ask(options, [action, id, ...rest].filter(Boolean));
310
1259
  if (command === "questions") return questions(options, action || "list", id);
311
1260
  if (command === "conversations") return conversations(options, action || "list", id);
312
1261
  if (command === "search") return search(options, [action, id, ...rest].filter(Boolean));
313
1262
  if (command === "docs") return docs(options, action || "list", id);
314
- if (command === "files" || command === "sync") {
315
- throw new CliError(`${command} is not available until the public /v1 capability is released.`, {
316
- code: `${command}_capability_unavailable`,
317
- exitCode: EXIT.UNAVAILABLE,
318
- });
319
- }
1263
+ if (command === "projects" || command === "folders") return projects(options, action || "create");
1264
+ if (command === "files") return files(options, action || "list", id);
1265
+ if (command === "sync") return syncCommand(options, action || "status");
1266
+ if (command === "update" && (action || "check") === "check") return updateCheck(options);
320
1267
  throw new CliError(`Unknown command: ${command}`);
321
1268
  }
322
1269