@corvio/cli 0.1.0-beta.4 → 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/README.md CHANGED
@@ -15,3 +15,5 @@ corvio update check --json
15
15
  ```
16
16
 
17
17
  When no environment key is configured, run `corvio auth login`. Login creates one user-level `cvu_` credential; it does not freeze the browser's current Workspace. Select each operation's Workspace with `corvio workspaces use`, `--workspace`, or `CORVIO_WORKSPACE_ID`. Legacy Workspace-bound `cvk_` keys remain supported and cannot be widened to another Workspace. The environment key always takes precedence. Local sync stores only credential-free handles and revision/hash baselines in `.corvio/state.json`; Markdown lives below `corvio_docs/`, and conflicts require explicit resolution. Update checks compare npm's published `beta` tag with the API compatibility policy but never self-install. See [the Corvio CLI guide](https://corvio.ai/developers/cli) for the full command and receipt contract.
18
+
19
+ The CLI rejects unknown/duplicate options and malformed integer bounds before network access. Normal API calls have a bounded timeout (`CORVIO_REQUEST_TIMEOUT_MS`, maximum ten minutes); only reads, explicit idempotency contracts, revision guards, and other owner-declared safe operations retry automatically. Document updates preflight the current revision when `--expected-revision` is omitted. Downloads, uploads, and Markdown pulls verify SHA-256 receipts before replacing local files; local writes are atomic, remote default filenames cannot escape the current directory, and Sync refuses symbolic-link traversal while checkpointing each successful push.
package/dist/cli.js CHANGED
@@ -9,6 +9,8 @@ import {
9
9
  ApiClient,
10
10
  CliError,
11
11
  EXIT,
12
+ assertKnownOptions,
13
+ assertPositionalCount,
12
14
  clearWorkspaceContext,
13
15
  clearWorkspaceCredential,
14
16
  idempotencyKey,
@@ -31,22 +33,75 @@ import {
31
33
  writeTextFile,
32
34
  } from "./core.js";
33
35
 
34
- const VERSION = "0.1.0-beta.4";
36
+ const VERSION = "0.1.0-beta.5";
35
37
  const DEFAULT_API_BASE = "https://api.corvio.ai/v1";
36
38
  const DEFAULT_APP_BASE = "https://corvio.ai";
37
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
+ });
38
92
 
39
93
  const HELP = `Corvio Workspace CLI ${VERSION}
40
94
 
41
95
  Usage:
42
96
  corvio auth login|status|logout
43
- corvio workspaces list|use|current [id]
97
+ corvio workspaces list|use|current|create [id]
44
98
  corvio capabilities
45
99
  corvio ask --prompt <text> [--conversation-id <id>] [--allow-actions]
46
100
  corvio questions list|get [id]
47
101
  corvio conversations list|get [id]
48
102
  corvio search <query>
49
- 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>
50
105
  corvio files list|get|download|upload|organize|open [id]
51
106
  corvio sync init|status|plan|pull|push|resolve
52
107
  corvio update check
@@ -61,10 +116,101 @@ Authentication:
61
116
  Run 'corvio auth login' when no environment key is configured.
62
117
  `;
63
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
+
64
200
  function baseUrl(options) {
65
201
  return normalizeApiBase(options["api-base"] || process.env.CORVIO_API_BASE, DEFAULT_API_BASE);
66
202
  }
67
203
 
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
+
68
214
  async function resolveWorkspaceSelection(options, credential) {
69
215
  const explicit = String(options.workspace || options["workspace-id"] || "").trim();
70
216
  if (explicit) return { workspaceId: explicit, workspaceName: null, source: "option" };
@@ -136,6 +282,7 @@ async function authLogin(options) {
136
282
  "assets:write",
137
283
  "sync:read",
138
284
  "sync:write",
285
+ "workspaces:write",
139
286
  ],
140
287
  },
141
288
  });
@@ -296,7 +443,31 @@ async function workspaces(options, action, id) {
296
443
  await storeWorkspaceContext({ workspaceId: identity.workspace.id, workspaceName: identity.workspace.name });
297
444
  return writeResult({ selected: true, workspace: identity.workspace }, { json: options.json });
298
445
  }
299
- throw new CliError("workspaces action must be list, use, or current.");
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.");
300
471
  }
301
472
 
302
473
  async function ask(options, positionals) {
@@ -312,13 +483,14 @@ async function ask(options, positionals) {
312
483
  mode: options["allow-actions"] ? "allow_actions" : "answer_only",
313
484
  sources: listOption(options, "sources", ["workspace", "web", "memory"]),
314
485
  },
486
+ timeoutMs: 330_000,
315
487
  });
316
488
  writeResult(result, { json: options.json });
317
489
  }
318
490
 
319
491
  function pageQuery(options) {
320
492
  return {
321
- limit: numberOption(options, "limit", 20),
493
+ limit: numberOption(options, "limit", 20, { maximum: 100 }),
322
494
  cursor: options.cursor,
323
495
  conversation_id: options["conversation-id"],
324
496
  };
@@ -328,7 +500,7 @@ async function questions(options, action, id) {
328
500
  const { api } = await client(options);
329
501
  const result = action === "list"
330
502
  ? await api.request("/questions", { query: pageQuery(options) })
331
- : await api.request(`/questions/${id || option(options, "id", { required: true })}`);
503
+ : await api.request(`/questions/${resourcePath(id || option(options, "id", { required: true }))}`);
332
504
  writeResult(result, { json: options.json });
333
505
  }
334
506
 
@@ -336,7 +508,7 @@ async function conversations(options, action, id) {
336
508
  const { api } = await client(options);
337
509
  const result = action === "list"
338
510
  ? await api.request("/conversations", { query: pageQuery(options) })
339
- : await api.request(`/conversations/${id || option(options, "id", { required: true })}`);
511
+ : await api.request(`/conversations/${resourcePath(id || option(options, "id", { required: true }))}`);
340
512
  writeResult(result, { json: options.json });
341
513
  }
342
514
 
@@ -346,9 +518,10 @@ async function search(options, positionals) {
346
518
  const { api } = await client(options);
347
519
  const result = await api.request("/search", {
348
520
  method: "POST",
521
+ retrySafe: true,
349
522
  body: {
350
523
  query,
351
- limit: numberOption(options, "limit", 10),
524
+ limit: numberOption(options, "limit", 10, { maximum: 50 }),
352
525
  page_id: options["page-id"] || null,
353
526
  node_id: options["node-id"] || null,
354
527
  },
@@ -363,12 +536,13 @@ async function docs(options, action, id) {
363
536
  if (action === "list") {
364
537
  result = await api.request("/documents", { query: pageQuery(options) });
365
538
  } else if (action === "get") {
366
- result = await api.request(`/documents/${documentId || option(options, "id", { required: true })}`);
539
+ result = await api.request(`/documents/${resourcePath(documentId || option(options, "id", { required: true }))}`);
367
540
  if (options.output) {
368
541
  await writeTextFile(options.output, String(result.markdown || ""));
369
542
  result = { ...result, output: options.output };
370
543
  }
371
544
  } else if (action === "create") {
545
+ rejectTogether(options, "file", "markdown");
372
546
  const markdown = options.file ? await readTextFile(options.file) : String(options.markdown || "");
373
547
  result = await api.request("/documents", {
374
548
  method: "POST",
@@ -381,33 +555,78 @@ async function docs(options, action, id) {
381
555
  },
382
556
  });
383
557
  } else if (action === "update") {
558
+ rejectTogether(options, "file", "markdown");
384
559
  const markdown = options.file ? await readTextFile(options.file) : options.markdown;
385
- 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)}`, {
386
580
  method: "PUT",
387
581
  body: {
388
- ...(options.title ? { title: options.title } : {}),
582
+ ...(options.title !== undefined ? { title: options.title } : {}),
389
583
  ...(markdown !== undefined ? { markdown } : {}),
390
- ...(options["expected-revision"] !== undefined
391
- ? { expected_content_revision: Number.parseInt(options["expected-revision"], 10) }
392
- : {}),
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,
393
597
  },
394
598
  });
395
599
  } else if (action === "archive") {
396
600
  requireConfirmation(options, "Archiving a document");
397
- 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" });
398
602
  } else if (action === "share") {
399
603
  requireConfirmation(options, "Creating a document share link");
400
- 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`, {
401
605
  method: "POST",
606
+ retrySafe: true,
402
607
  body: { public_display_name: options["display-name"] || null },
403
608
  });
404
609
  } else if (action === "open") {
405
- 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)}`;
406
612
  const opened = options["no-input"] ? false : await openBrowser(target);
407
- result = { document_id: documentId, url: target, opened };
613
+ result = { document_id: targetId, url: target, opened };
408
614
  } else {
409
- throw new CliError("docs action must be list, get, create, update, archive, share, or open.");
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.");
410
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
+ });
411
630
  writeResult(result, { json: options.json });
412
631
  }
413
632
 
@@ -511,30 +730,50 @@ async function files(options, action, id) {
511
730
  const assetId = id || options.id;
512
731
  let result;
513
732
  if (action === "list") {
514
- result = await api.request("/files", { query: { limit: numberOption(options, "limit", 50) } });
733
+ result = await api.request("/files", { query: { limit: numberOption(options, "limit", 50, { maximum: 100 }) } });
515
734
  } else if (action === "get") {
516
- result = await api.request(`/files/${assetId || option(options, "id", { required: true })}`);
735
+ result = await api.request(`/files/${resourcePath(assetId || option(options, "id", { required: true }))}`);
517
736
  } else if (action === "download") {
518
737
  const targetId = assetId || option(options, "id", { required: true });
519
- const metadata = await api.request(`/files/${targetId}`);
520
- const bytes = await api.requestBytes(`/files/${targetId}/content`);
521
- const output = options.output || metadata.original_file_name || `${targetId}.bin`;
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`);
522
747
  const written = await writeBinaryFile(output, bytes);
523
748
  result = { ...metadata, output: written };
524
749
  } else if (action === "upload") {
525
750
  const input = resolve(String(option(options, "file", { required: true })));
526
751
  const info = await stat(input);
527
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
+ }
528
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
+ }
529
764
  const fileName = basename(input);
530
765
  const mimeType = String(options["mime-type"] || mimeTypeFor(input));
531
766
  const initialized = await api.request("/files/uploads", {
532
767
  method: "POST",
533
768
  body: { file_name: fileName, mime_type: mimeType, size_bytes: bytes.length },
534
769
  });
535
- await api.putUrl(initialized.upload_url, bytes, { headers: initialized.upload_headers || {} });
770
+ await api.putUrl(initialized.upload_url, bytes, {
771
+ headers: initialized.upload_headers || {},
772
+ timeoutMs: 5 * 60_000,
773
+ });
536
774
  result = await api.request("/files/uploads:finalize", {
537
775
  method: "POST",
776
+ retrySafe: true,
538
777
  body: {
539
778
  upload_id: initialized.upload_id,
540
779
  blob_name: initialized.blob_name,
@@ -543,9 +782,15 @@ async function files(options, action, id) {
543
782
  size_bytes: bytes.length,
544
783
  },
545
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
+ }
546
791
  } else if (action === "organize") {
547
792
  requireConfirmation(options, "Starting a file organization run");
548
- result = await api.request(`/files/${assetId || option(options, "id", { required: true })}:organize`, {
793
+ result = await api.request(`/files/${resourcePath(assetId || option(options, "id", { required: true }))}:organize`, {
549
794
  method: "POST",
550
795
  body: {
551
796
  instruction: option(options, "instruction", { required: true }),
@@ -554,7 +799,7 @@ async function files(options, action, id) {
554
799
  });
555
800
  } else if (action === "open") {
556
801
  const targetId = assetId || option(options, "id", { required: true });
557
- const target = `${String(process.env.CORVIO_APP_BASE || DEFAULT_APP_BASE).replace(/\/+$/, "")}/app/a/${targetId}`;
802
+ const target = `${String(process.env.CORVIO_APP_BASE || DEFAULT_APP_BASE).replace(/\/+$/, "")}/app/a/${resourcePath(targetId)}`;
558
803
  result = { file_id: targetId, url: target, opened: options["no-input"] ? false : await openBrowser(target) };
559
804
  } else {
560
805
  throw new CliError("files action must be list, get, download, upload, organize, or open.");
@@ -609,6 +854,9 @@ async function writeSyncState(root, state) {
609
854
  }
610
855
 
611
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
+ }
612
860
  const parts = String(relativePath).replaceAll("\\", "/").split("/").filter(Boolean);
613
861
  if (parts[0] !== "corvio_docs" || parts.some((part) => part === "." || part === "..")) {
614
862
  throw new CliError(`Unsafe sync path returned by Corvio: ${relativePath}`, { code: "sync_path_unsafe" });
@@ -620,9 +868,56 @@ function localSyncPath(root, relativePath) {
620
868
  return target;
621
869
  }
622
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
+
623
917
  async function scanMarkdown(root) {
624
918
  const mirror = join(root, "corvio_docs");
625
919
  await mkdir(mirror, { recursive: true });
920
+ await assertNoSyncSymlink(root, mirror);
626
921
  const manifest = [];
627
922
  async function walk(directory) {
628
923
  for (const entry of await readdir(directory, { withFileTypes: true })) {
@@ -639,6 +934,11 @@ async function scanMarkdown(root) {
639
934
  size_bytes: bytes.length,
640
935
  mtime_ms: Math.trunc(info.mtimeMs),
641
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
+ }
642
942
  }
643
943
  }
644
944
  }
@@ -682,6 +982,7 @@ async function syncManifest(root, state) {
682
982
  async function syncPlan(api, state, root) {
683
983
  return api.request("/sync/plan", {
684
984
  method: "POST",
985
+ retrySafe: true,
685
986
  body: { source_id: state.source_id, local: await syncManifest(root, state) },
686
987
  });
687
988
  }
@@ -735,7 +1036,7 @@ async function syncCommand(options, action) {
735
1036
  );
736
1037
  }
737
1038
  if (action === "status") {
738
- const remote = await api.request(`/sync/sources/${state.source_id}`);
1039
+ const remote = await api.request(`/sync/sources/${resourcePath(state.source_id)}`);
739
1040
  return writeResult({ ...remote, directory: root, state_file: syncStatePath(root) }, { json: options.json });
740
1041
  }
741
1042
  if (action === "plan") {
@@ -757,9 +1058,12 @@ async function syncCommand(options, action) {
757
1058
  const item = actionItem.remote;
758
1059
  if (!item) continue;
759
1060
  const target = localSyncPath(root, item.relative_path);
1061
+ await assertNoSyncSymlink(root, target);
1062
+ const verified = verifiedRemoteMarkdown(item);
760
1063
  const previous = mappings[item.page_id];
761
1064
  if (previous?.relative_path && previous.relative_path !== item.relative_path) {
762
1065
  const oldTarget = localSyncPath(root, previous.relative_path);
1066
+ await assertNoSyncSymlink(root, oldTarget);
763
1067
  try {
764
1068
  const oldBytes = await readFile(oldTarget);
765
1069
  if (sha256(oldBytes) === previous.content_sha256) await unlink(oldTarget);
@@ -767,13 +1071,13 @@ async function syncCommand(options, action) {
767
1071
  if (cause?.code !== "ENOENT") throw cause;
768
1072
  }
769
1073
  }
770
- await writeTextFile(target, item.markdown || "");
1074
+ await writeTextFile(target, verified.markdown);
771
1075
  const info = await stat(target);
772
1076
  committed.push({
773
1077
  page_id: item.page_id,
774
1078
  node_id: item.node_id,
775
1079
  relative_path: item.relative_path,
776
- content_sha256: item.content_sha256,
1080
+ content_sha256: verified.contentHash,
777
1081
  mtime_ms: Math.trunc(info.mtimeMs),
778
1082
  });
779
1083
  mappings[item.page_id] = {
@@ -813,7 +1117,14 @@ async function syncCommand(options, action) {
813
1117
  const mappings = { ...(state.mappings || {}) };
814
1118
  for (const actionItem of plan.actions.filter((item) => item.action === "push")) {
815
1119
  const path = localSyncPath(root, actionItem.relative_path);
1120
+ await assertNoSyncSymlink(root, path);
816
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
+ }
817
1128
  const info = await stat(path);
818
1129
  const previous = actionItem.page_id ? mappings[actionItem.page_id] : null;
819
1130
  const result = await api.request("/sync/push", {
@@ -821,7 +1132,7 @@ async function syncCommand(options, action) {
821
1132
  body: {
822
1133
  source_id: state.source_id,
823
1134
  relative_path: actionItem.relative_path,
824
- markdown: bytes.toString("utf8"),
1135
+ markdown,
825
1136
  content_sha256: sha256(bytes),
826
1137
  mtime_ms: Math.trunc(info.mtimeMs),
827
1138
  page_id: actionItem.page_id || null,
@@ -835,6 +1146,9 @@ async function syncCommand(options, action) {
835
1146
  content_sha256: result.item.content_sha256,
836
1147
  content_revision: result.item.content_revision,
837
1148
  };
1149
+ state.mappings = mappings;
1150
+ state.last_push_at = new Date().toISOString();
1151
+ await writeSyncState(root, state);
838
1152
  }
839
1153
  state.mappings = mappings;
840
1154
  state.last_push_at = new Date().toISOString();
@@ -849,12 +1163,14 @@ async function syncCommand(options, action) {
849
1163
  requireConfirmation(options, "Resolving a sync conflict");
850
1164
  const conflictId = option(options, "conflict-id", { required: true });
851
1165
  const strategy = option(options, "strategy", { required: true });
852
- const conflict = await api.request(`/sync/conflicts/${conflictId}`);
1166
+ const conflict = await api.request(`/sync/conflicts/${resourcePath(conflictId)}`);
853
1167
  if (strategy === "use-remote") {
854
1168
  if (!conflict.remote) throw new CliError("The remote document is no longer available.", { code: "sync_remote_missing" });
855
1169
  const item = conflict.remote;
856
1170
  const target = localSyncPath(root, item.relative_path);
857
- await writeTextFile(target, item.markdown || "");
1171
+ await assertNoSyncSymlink(root, target);
1172
+ const verified = verifiedRemoteMarkdown(item);
1173
+ await writeTextFile(target, verified.markdown);
858
1174
  const info = await stat(target);
859
1175
  await api.request("/sync/commit", {
860
1176
  method: "POST",
@@ -864,12 +1180,12 @@ async function syncCommand(options, action) {
864
1180
  page_id: item.page_id,
865
1181
  node_id: item.node_id,
866
1182
  relative_path: item.relative_path,
867
- content_sha256: item.content_sha256,
1183
+ content_sha256: verified.contentHash,
868
1184
  mtime_ms: Math.trunc(info.mtimeMs),
869
1185
  }],
870
1186
  },
871
1187
  });
872
- const result = await api.request(`/sync/conflicts/${conflictId}:resolve`, {
1188
+ const result = await api.request(`/sync/conflicts/${resourcePath(conflictId)}:resolve`, {
873
1189
  method: "POST",
874
1190
  body: { strategy: "use_remote" },
875
1191
  });
@@ -884,14 +1200,21 @@ async function syncCommand(options, action) {
884
1200
  }
885
1201
  if (strategy === "keep-local") {
886
1202
  const target = localSyncPath(root, conflict.relative_path);
1203
+ await assertNoSyncSymlink(root, target);
887
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
+ }
888
1211
  const info = await stat(target);
889
1212
  const result = await api.request("/sync/push", {
890
1213
  method: "POST",
891
1214
  body: {
892
1215
  source_id: state.source_id,
893
1216
  relative_path: conflict.relative_path,
894
- markdown: bytes.toString("utf8"),
1217
+ markdown,
895
1218
  content_sha256: sha256(bytes),
896
1219
  mtime_ms: Math.trunc(info.mtimeMs),
897
1220
  page_id: conflict.remote?.page_id || null,
@@ -915,7 +1238,12 @@ async function syncCommand(options, action) {
915
1238
  async function main({ options, positionals }) {
916
1239
  const [command, action, id, ...rest] = positionals;
917
1240
  if (options.version || command === "version") return writeResult(VERSION, { json: options.json });
918
- 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);
919
1247
  if (command !== "update") await maybeNotifyUpdate(options);
920
1248
  if (command === "auth") {
921
1249
  if (action === "login") return authLogin(options);
@@ -932,6 +1260,7 @@ async function main({ options, positionals }) {
932
1260
  if (command === "conversations") return conversations(options, action || "list", id);
933
1261
  if (command === "search") return search(options, [action, id, ...rest].filter(Boolean));
934
1262
  if (command === "docs") return docs(options, action || "list", id);
1263
+ if (command === "projects" || command === "folders") return projects(options, action || "create");
935
1264
  if (command === "files") return files(options, action || "list", id);
936
1265
  if (command === "sync") return syncCommand(options, action || "status");
937
1266
  if (command === "update" && (action || "check") === "check") return updateCheck(options);
package/dist/core.js CHANGED
@@ -32,6 +32,59 @@ function exitCodeForStatus(status) {
32
32
  return EXIT.REMOTE;
33
33
  }
34
34
 
35
+ const DEFAULT_REQUEST_TIMEOUT_MS = 60_000;
36
+ const MAX_REQUEST_TIMEOUT_MS = 10 * 60_000;
37
+ const RETRYABLE_HTTP_STATUSES = new Set([408, 429, 502, 503, 504]);
38
+
39
+ function normalizeRequestTimeout(value, fallback = DEFAULT_REQUEST_TIMEOUT_MS) {
40
+ const raw = value === undefined || value === null || value === "" ? fallback : Number(value);
41
+ if (!Number.isSafeInteger(raw) || raw < 1 || raw > MAX_REQUEST_TIMEOUT_MS) {
42
+ throw new CliError(
43
+ `Request timeout must be an integer between 1 and ${MAX_REQUEST_TIMEOUT_MS} milliseconds.`,
44
+ { code: "invalid_request_timeout" },
45
+ );
46
+ }
47
+ return raw;
48
+ }
49
+
50
+ function networkFailureDetails({
51
+ cause,
52
+ url,
53
+ method,
54
+ requestHeaders = {},
55
+ body = undefined,
56
+ retrySafe = false,
57
+ }) {
58
+ const nested = cause && typeof cause === "object" ? cause.cause : null;
59
+ const causeCode = String(nested?.code || cause?.code || "").trim() || null;
60
+ const syscall = String(nested?.syscall || cause?.syscall || "").trim() || null;
61
+ const normalizedMethod = String(method || "GET").toUpperCase();
62
+ const operationKey = String(requestHeaders["idempotency-key"] || requestHeaders["Idempotency-Key"] || "").trim();
63
+ const revision = body?.expected_content_revision;
64
+ const revisionGuarded = normalizedMethod === "PUT" && Number.isSafeInteger(revision) && revision >= 0;
65
+ const safeToRetry = retrySafe || ["GET", "HEAD"].includes(normalizedMethod) || Boolean(operationKey) || revisionGuarded;
66
+ const timedOut = cause?.name === "TimeoutError" || cause?.name === "AbortError";
67
+ return {
68
+ method: normalizedMethod,
69
+ origin: new URL(String(url)).origin,
70
+ response_received: false,
71
+ timed_out: timedOut,
72
+ safe_to_retry: safeToRetry,
73
+ retry_safety: operationKey
74
+ ? "idempotency_key"
75
+ : revisionGuarded
76
+ ? "revision_guard"
77
+ : retrySafe
78
+ ? "operation_contract"
79
+ : safeToRetry
80
+ ? "read_only"
81
+ : "unknown_commit_state",
82
+ ...(operationKey ? { idempotency_key: operationKey } : {}),
83
+ ...(causeCode ? { cause_code: causeCode } : {}),
84
+ ...(syscall ? { syscall } : {}),
85
+ };
86
+ }
87
+
35
88
  export function parseArgv(argv) {
36
89
  const options = Object.create(null);
37
90
  const positionals = [];
@@ -47,14 +100,26 @@ export function parseArgv(argv) {
47
100
  ]);
48
101
  for (let index = 0; index < argv.length; index += 1) {
49
102
  const value = argv[index];
103
+ if (value === "--") {
104
+ positionals.push(...argv.slice(index + 1));
105
+ break;
106
+ }
50
107
  if (!value.startsWith("--")) {
51
108
  positionals.push(value);
52
109
  continue;
53
110
  }
54
111
  const separator = value.indexOf("=");
55
112
  const name = value.slice(2, separator === -1 ? undefined : separator);
113
+ if (!name) throw new CliError("Option name must not be empty.", { code: "invalid_option" });
114
+ if (Object.hasOwn(options, name)) {
115
+ throw new CliError(`Option --${name} was provided more than once.`, { code: "duplicate_option" });
116
+ }
56
117
  if (booleanOptions.has(name)) {
57
- options[name] = separator === -1 ? true : value.slice(separator + 1) !== "false";
118
+ const booleanValue = separator === -1 ? "true" : value.slice(separator + 1);
119
+ if (booleanValue !== "true" && booleanValue !== "false") {
120
+ throw new CliError(`Option --${name} must be true or false.`, { code: "invalid_boolean_option" });
121
+ }
122
+ options[name] = booleanValue === "true";
58
123
  continue;
59
124
  }
60
125
  if (separator !== -1) {
@@ -71,6 +136,21 @@ export function parseArgv(argv) {
71
136
  return { options, positionals };
72
137
  }
73
138
 
139
+ export function assertKnownOptions(options, allowed) {
140
+ const allowedSet = new Set(allowed);
141
+ for (const name of Object.keys(options)) {
142
+ if (!allowedSet.has(name)) {
143
+ throw new CliError(`Unknown option --${name}.`, { code: "unknown_option" });
144
+ }
145
+ }
146
+ }
147
+
148
+ export function assertPositionalCount(positionals, maximum) {
149
+ if (positionals.length > maximum) {
150
+ throw new CliError(`Unexpected argument: ${positionals[maximum]}`, { code: "unexpected_argument" });
151
+ }
152
+ }
153
+
74
154
  export function option(options, name, { required = false, fallback = undefined } = {}) {
75
155
  const value = options[name] ?? fallback;
76
156
  if (required && (value === undefined || String(value).trim() === "")) {
@@ -85,28 +165,43 @@ export function listOption(options, name, fallback = []) {
85
165
  return String(value).split(",").map((item) => item.trim()).filter(Boolean);
86
166
  }
87
167
 
88
- export function numberOption(options, name, fallback) {
168
+ export function numberOption(options, name, fallback, { minimum = 1, maximum = Number.MAX_SAFE_INTEGER } = {}) {
89
169
  const value = options[name];
90
170
  if (value === undefined) return fallback;
91
- const parsed = Number.parseInt(String(value), 10);
92
- if (!Number.isFinite(parsed) || parsed < 1) {
93
- throw new CliError(`Option --${name} must be a positive integer.`, { code: "invalid_number_option" });
171
+ const normalized = String(value).trim();
172
+ const parsed = /^\d+$/.test(normalized) ? Number(normalized) : Number.NaN;
173
+ if (!Number.isSafeInteger(parsed) || parsed < minimum || parsed > maximum) {
174
+ throw new CliError(
175
+ `Option --${name} must be an integer between ${minimum} and ${maximum}.`,
176
+ { code: "invalid_number_option" },
177
+ );
94
178
  }
95
179
  return parsed;
96
180
  }
97
181
 
98
182
  export function normalizeApiBase(value, fallback) {
99
183
  const raw = String(value || fallback).trim().replace(/\/+$/, "");
100
- if (!/^https:\/\//i.test(raw) && !/^http:\/\/(127\.0\.0\.1|localhost)(:\d+)?/i.test(raw)) {
184
+ let parsed;
185
+ try {
186
+ parsed = new URL(raw);
187
+ } catch {
188
+ throw new CliError("Corvio API base must be a valid URL.", { code: "invalid_api_base" });
189
+ }
190
+ const localhost = parsed.hostname === "localhost" || parsed.hostname === "127.0.0.1" || parsed.hostname === "[::1]";
191
+ if (parsed.username || parsed.password || (parsed.protocol !== "https:" && !(parsed.protocol === "http:" && localhost))) {
101
192
  throw new CliError("Corvio API base must use HTTPS (localhost is allowed for development).", {
102
193
  code: "insecure_api_base",
103
194
  });
104
195
  }
105
- return raw.endsWith("/v1") ? raw : `${raw}/v1`;
196
+ if (parsed.search || parsed.hash) {
197
+ throw new CliError("Corvio API base must not include a query string or fragment.", { code: "invalid_api_base" });
198
+ }
199
+ const normalized = parsed.toString().replace(/\/+$/, "");
200
+ return normalized.endsWith("/v1") ? normalized : `${normalized}/v1`;
106
201
  }
107
202
 
108
203
  function parseSemver(value) {
109
- const match = String(value || "").trim().match(/^(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?/);
204
+ const match = String(value || "").trim().match(/^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/);
110
205
  if (!match) return null;
111
206
  return {
112
207
  core: match.slice(1, 4).map((item) => Number.parseInt(item, 10)),
@@ -187,15 +282,31 @@ export async function resolveNpmUpdateStatus({
187
282
  }
188
283
 
189
284
  export class ApiClient {
190
- constructor({ baseUrl, credential = null, workspaceId = null, userAgent, fetchImpl = globalThis.fetch }) {
285
+ constructor({
286
+ baseUrl,
287
+ credential = null,
288
+ workspaceId = null,
289
+ userAgent,
290
+ fetchImpl = globalThis.fetch,
291
+ timeoutMs = process.env.CORVIO_REQUEST_TIMEOUT_MS,
292
+ }) {
191
293
  this.baseUrl = baseUrl.replace(/\/+$/, "");
192
294
  this.credential = credential;
193
295
  this.workspaceId = String(workspaceId || "").trim() || null;
194
296
  this.userAgent = userAgent;
195
297
  this.fetchImpl = fetchImpl;
298
+ this.timeoutMs = normalizeRequestTimeout(timeoutMs);
196
299
  }
197
300
 
198
- async request(path, { method = "GET", body, query, headers = {}, auth = true } = {}) {
301
+ async request(path, {
302
+ method = "GET",
303
+ body,
304
+ query,
305
+ headers = {},
306
+ auth = true,
307
+ retrySafe = false,
308
+ timeoutMs = this.timeoutMs,
309
+ } = {}) {
199
310
  const url = new URL(`${this.baseUrl}${path.startsWith("/") ? path : `/${path}`}`);
200
311
  for (const [key, value] of Object.entries(query || {})) {
201
312
  if (value !== undefined && value !== null && value !== "") url.searchParams.set(key, String(value));
@@ -216,18 +327,56 @@ export class ApiClient {
216
327
  requestHeaders["X-Corvio-Workspace-Context-Contract"] = "required-v1";
217
328
  }
218
329
  }
330
+ const normalizedTimeout = normalizeRequestTimeout(timeoutMs, this.timeoutMs);
219
331
  let response;
220
- try {
221
- response = await this.fetchImpl(url, {
222
- method,
223
- headers: requestHeaders,
224
- body: body === undefined ? undefined : JSON.stringify(body),
225
- });
226
- } catch (cause) {
227
- throw new CliError(`Unable to reach Corvio: ${cause instanceof Error ? cause.message : String(cause)}`, {
228
- code: "network_error",
229
- exitCode: EXIT.REMOTE,
230
- });
332
+ for (let attempt = 1; attempt <= 2; attempt += 1) {
333
+ try {
334
+ response = await this.fetchImpl(url, {
335
+ method,
336
+ headers: requestHeaders,
337
+ body: body === undefined ? undefined : JSON.stringify(body),
338
+ signal: AbortSignal.timeout(normalizedTimeout),
339
+ });
340
+ const safety = networkFailureDetails({
341
+ cause: null,
342
+ url,
343
+ method,
344
+ requestHeaders,
345
+ body,
346
+ retrySafe,
347
+ });
348
+ if (attempt === 1 && safety.safe_to_retry && RETRYABLE_HTTP_STATUSES.has(response.status)) {
349
+ await response.body?.cancel().catch(() => {});
350
+ const retryAfterValue = response.headers.get("retry-after");
351
+ const retryAfterSeconds = retryAfterValue === null ? Number.NaN : Number(retryAfterValue);
352
+ const delayMs = Number.isFinite(retryAfterSeconds)
353
+ ? Math.min(2_000, Math.max(0, retryAfterSeconds * 1000))
354
+ : 150;
355
+ await sleep(delayMs);
356
+ continue;
357
+ }
358
+ break;
359
+ } catch (cause) {
360
+ const details = networkFailureDetails({ cause, url, method, requestHeaders, body, retrySafe });
361
+ if (attempt === 1 && details.safe_to_retry) {
362
+ await sleep(150);
363
+ continue;
364
+ }
365
+ throw new CliError(
366
+ details.timed_out
367
+ ? `Corvio did not respond within ${normalizedTimeout} ms.`
368
+ : `Unable to reach Corvio: ${cause instanceof Error ? cause.message : String(cause)}`,
369
+ {
370
+ code: details.timed_out ? "request_timeout" : "network_error",
371
+ exitCode: EXIT.REMOTE,
372
+ details: {
373
+ ...details,
374
+ attempts: attempt,
375
+ automatic_retry_attempted: attempt > 1,
376
+ },
377
+ },
378
+ );
379
+ }
231
380
  }
232
381
  const text = await response.text();
233
382
  let payload = null;
@@ -244,13 +393,18 @@ export class ApiClient {
244
393
  code: String(envelope.code || `http_${response.status}`),
245
394
  status: response.status,
246
395
  exitCode: exitCodeForStatus(response.status),
247
- details: envelope.details || null,
396
+ details: {
397
+ ...(envelope.details || {}),
398
+ ...(envelope.request_id || response.headers.get("x-request-id")
399
+ ? { request_id: envelope.request_id || response.headers.get("x-request-id") }
400
+ : {}),
401
+ },
248
402
  });
249
403
  }
250
404
  return payload;
251
405
  }
252
406
 
253
- async requestBytes(path, { query, headers = {}, auth = true } = {}) {
407
+ async requestBytes(path, { query, headers = {}, auth = true, timeoutMs = this.timeoutMs } = {}) {
254
408
  const url = new URL(`${this.baseUrl}${path.startsWith("/") ? path : `/${path}`}`);
255
409
  for (const [key, value] of Object.entries(query || {})) {
256
410
  if (value !== undefined && value !== null && value !== "") url.searchParams.set(key, String(value));
@@ -270,14 +424,38 @@ export class ApiClient {
270
424
  requestHeaders["X-Corvio-Workspace-Context-Contract"] = "required-v1";
271
425
  }
272
426
  }
427
+ const normalizedTimeout = normalizeRequestTimeout(timeoutMs, this.timeoutMs);
273
428
  let response;
274
- try {
275
- response = await this.fetchImpl(url, { method: "GET", headers: requestHeaders });
276
- } catch (cause) {
277
- throw new CliError(`Unable to reach Corvio: ${cause instanceof Error ? cause.message : String(cause)}`, {
278
- code: "network_error",
279
- exitCode: EXIT.REMOTE,
280
- });
429
+ for (let attempt = 1; attempt <= 2; attempt += 1) {
430
+ try {
431
+ response = await this.fetchImpl(url, {
432
+ method: "GET",
433
+ headers: requestHeaders,
434
+ signal: AbortSignal.timeout(normalizedTimeout),
435
+ });
436
+ if (attempt === 1 && RETRYABLE_HTTP_STATUSES.has(response.status)) {
437
+ await response.body?.cancel().catch(() => {});
438
+ await sleep(150);
439
+ continue;
440
+ }
441
+ break;
442
+ } catch (cause) {
443
+ const details = networkFailureDetails({ cause, url, method: "GET", requestHeaders });
444
+ if (attempt === 1) {
445
+ await sleep(150);
446
+ continue;
447
+ }
448
+ throw new CliError(
449
+ details.timed_out
450
+ ? `Corvio did not respond within ${normalizedTimeout} ms.`
451
+ : `Unable to reach Corvio: ${cause instanceof Error ? cause.message : String(cause)}`,
452
+ {
453
+ code: details.timed_out ? "request_timeout" : "network_error",
454
+ exitCode: EXIT.REMOTE,
455
+ details: { ...details, attempts: attempt, automatic_retry_attempted: true },
456
+ },
457
+ );
458
+ }
281
459
  }
282
460
  const bytes = new Uint8Array(await response.arrayBuffer());
283
461
  if (!response.ok) {
@@ -293,25 +471,57 @@ export class ApiClient {
293
471
  code: String(envelope.code || `http_${response.status}`),
294
472
  status: response.status,
295
473
  exitCode: exitCodeForStatus(response.status),
296
- details: envelope.details || null,
474
+ details: {
475
+ ...(envelope.details || {}),
476
+ ...(envelope.request_id || response.headers.get("x-request-id")
477
+ ? { request_id: envelope.request_id || response.headers.get("x-request-id") }
478
+ : {}),
479
+ },
297
480
  });
298
481
  }
299
482
  return bytes;
300
483
  }
301
484
 
302
- async putUrl(url, content, { headers = {} } = {}) {
485
+ async putUrl(url, content, { headers = {}, timeoutMs = this.timeoutMs } = {}) {
486
+ const normalizedTimeout = normalizeRequestTimeout(timeoutMs, this.timeoutMs);
303
487
  let response;
304
- try {
305
- response = await this.fetchImpl(String(url), {
306
- method: "PUT",
307
- headers,
308
- body: content,
309
- });
310
- } catch (cause) {
311
- throw new CliError(`Unable to upload the file: ${cause instanceof Error ? cause.message : String(cause)}`, {
312
- code: "upload_network_error",
313
- exitCode: EXIT.REMOTE,
314
- });
488
+ for (let attempt = 1; attempt <= 2; attempt += 1) {
489
+ try {
490
+ response = await this.fetchImpl(String(url), {
491
+ method: "PUT",
492
+ headers,
493
+ body: content,
494
+ signal: AbortSignal.timeout(normalizedTimeout),
495
+ });
496
+ if (attempt === 1 && RETRYABLE_HTTP_STATUSES.has(response.status)) {
497
+ await response.body?.cancel().catch(() => {});
498
+ await sleep(150);
499
+ continue;
500
+ }
501
+ break;
502
+ } catch (cause) {
503
+ const details = networkFailureDetails({
504
+ cause,
505
+ url,
506
+ method: "PUT",
507
+ requestHeaders: headers,
508
+ retrySafe: true,
509
+ });
510
+ if (attempt === 1) {
511
+ await sleep(150);
512
+ continue;
513
+ }
514
+ throw new CliError(
515
+ details.timed_out
516
+ ? `The file upload did not respond within ${normalizedTimeout} ms.`
517
+ : `Unable to upload the file: ${cause instanceof Error ? cause.message : String(cause)}`,
518
+ {
519
+ code: details.timed_out ? "upload_timeout" : "upload_network_error",
520
+ exitCode: EXIT.REMOTE,
521
+ details: { ...details, attempts: attempt, automatic_retry_attempted: true },
522
+ },
523
+ );
524
+ }
315
525
  }
316
526
  if (!response.ok) {
317
527
  const message = (await response.text()).slice(0, 500);
@@ -534,7 +744,21 @@ export function writeError(error, { json = false, stderr = process.stderr } = {}
534
744
  ...(error instanceof CliError && error.details ? { details: error.details } : {}),
535
745
  },
536
746
  };
537
- stderr.write(json ? `${JSON.stringify(safe)}\n` : `Error: ${safe.error.message}\n`);
747
+ if (json) {
748
+ stderr.write(`${JSON.stringify(safe)}\n`);
749
+ return;
750
+ }
751
+ stderr.write(`Error: ${safe.error.message}\n`);
752
+ if (safe.error.details?.request_id) stderr.write(`Request ID: ${safe.error.details.request_id}\n`);
753
+ if (safe.error.details?.safe_to_retry && safe.error.details?.idempotency_key) {
754
+ stderr.write(
755
+ `Safe retry: rerun the command with --idempotency-key ${safe.error.details.idempotency_key}\n`,
756
+ );
757
+ } else if (safe.error.details?.retry_safety === "revision_guard") {
758
+ stderr.write("Safe retry: rerun with the same --expected-revision; Corvio will reject an already-committed replacement.\n");
759
+ } else if (safe.error.details?.retry_safety === "unknown_commit_state") {
760
+ stderr.write("Retry state is unknown; read the remote object before repeating this mutation.\n");
761
+ }
538
762
  }
539
763
 
540
764
  export async function runMain(main, argv = process.argv.slice(2)) {
@@ -551,8 +775,8 @@ export async function runMain(main, argv = process.argv.slice(2)) {
551
775
 
552
776
  export async function openBrowser(url) {
553
777
  const target = String(url);
554
- const command = platform() === "darwin" ? "open" : platform() === "win32" ? "cmd" : "xdg-open";
555
- const args = platform() === "win32" ? ["/c", "start", "", target] : [target];
778
+ const command = platform() === "darwin" ? "open" : platform() === "win32" ? "rundll32.exe" : "xdg-open";
779
+ const args = platform() === "win32" ? ["url.dll,FileProtocolHandler", target] : [target];
556
780
  return new Promise((resolvePromise) => {
557
781
  let settled = false;
558
782
  const finish = (opened) => {
@@ -584,14 +808,28 @@ export async function readTextFile(path) {
584
808
  export async function writeTextFile(path, content) {
585
809
  const target = resolve(String(path));
586
810
  await mkdir(dirname(target), { recursive: true });
587
- await writeFile(target, content, "utf8");
811
+ const temporary = `${target}.${randomUUID()}.tmp`;
812
+ try {
813
+ await writeFile(temporary, content, { encoding: "utf8", flag: "wx" });
814
+ await rename(temporary, target);
815
+ } catch (error) {
816
+ await rm(temporary, { force: true }).catch(() => {});
817
+ throw error;
818
+ }
588
819
  return target;
589
820
  }
590
821
 
591
822
  export async function writeBinaryFile(path, content) {
592
823
  const target = resolve(String(path));
593
824
  await mkdir(dirname(target), { recursive: true });
594
- await writeFile(target, content);
825
+ const temporary = `${target}.${randomUUID()}.tmp`;
826
+ try {
827
+ await writeFile(temporary, content, { flag: "wx" });
828
+ await rename(temporary, target);
829
+ } catch (error) {
830
+ await rm(temporary, { force: true }).catch(() => {});
831
+ throw error;
832
+ }
595
833
  return target;
596
834
  }
597
835
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@corvio/cli",
3
- "version": "0.1.0-beta.4",
3
+ "version": "0.1.0-beta.5",
4
4
  "description": "Official Corvio Workspace CLI for questions, knowledge search, documents, and Agent workflows.",
5
5
  "type": "module",
6
6
  "bin": {