@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 +2 -0
- package/dist/cli.js +366 -37
- package/dist/core.js +286 -48
- package/package.json +1 -1
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
391
|
-
|
|
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
|
|
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:
|
|
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
|
-
|
|
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, {
|
|
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,
|
|
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:
|
|
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
|
|
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
|
|
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:
|
|
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
|
|
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")
|
|
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
|
-
|
|
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
|
|
92
|
-
|
|
93
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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({
|
|
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, {
|
|
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
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
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:
|
|
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
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
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:
|
|
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
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
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
|
-
|
|
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" ? "
|
|
555
|
-
const args = platform() === "win32" ? ["
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|