@corvio/cli 0.1.0-beta.3 → 0.1.0-beta.4
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 +7 -2
- package/dist/cli.js +636 -18
- package/dist/core.js +203 -27
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -4,9 +4,14 @@ Official CLI for a user's Corvio Workspace. It supports an existing `CORVIO_API_
|
|
|
4
4
|
|
|
5
5
|
```bash
|
|
6
6
|
npm install --global @corvio/cli@beta
|
|
7
|
-
export CORVIO_API_KEY='
|
|
7
|
+
export CORVIO_API_KEY='cvu_...'
|
|
8
8
|
corvio auth status
|
|
9
|
+
corvio workspaces list
|
|
10
|
+
corvio workspaces use <workspace_id>
|
|
9
11
|
corvio ask --prompt 'Summarize recurring launch risks' --json
|
|
12
|
+
corvio files upload --file ./research.pdf --json
|
|
13
|
+
corvio sync init --dir ./knowledge --root-node-id <node_id> --json
|
|
14
|
+
corvio update check --json
|
|
10
15
|
```
|
|
11
16
|
|
|
12
|
-
When no environment key is configured, run `corvio auth login`. The environment key always takes precedence. See [the Corvio CLI guide](https://corvio.ai/developers/cli) for the
|
|
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.
|
package/dist/cli.js
CHANGED
|
@@ -1,9 +1,15 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
4
|
+
import { lstat, mkdir, readFile, readdir, rename, stat, unlink, writeFile } from "node:fs/promises";
|
|
5
|
+
import { homedir } from "node:os";
|
|
6
|
+
import { basename, dirname, extname, join, relative, resolve, sep } from "node:path";
|
|
7
|
+
|
|
3
8
|
import {
|
|
4
9
|
ApiClient,
|
|
5
10
|
CliError,
|
|
6
11
|
EXIT,
|
|
12
|
+
clearWorkspaceContext,
|
|
7
13
|
clearWorkspaceCredential,
|
|
8
14
|
idempotencyKey,
|
|
9
15
|
listOption,
|
|
@@ -12,16 +18,20 @@ import {
|
|
|
12
18
|
openBrowser,
|
|
13
19
|
option,
|
|
14
20
|
readTextFile,
|
|
21
|
+
readWorkspaceContext,
|
|
15
22
|
readWorkspaceCredential,
|
|
16
23
|
requireConfirmation,
|
|
24
|
+
resolveNpmUpdateStatus,
|
|
17
25
|
runMain,
|
|
18
26
|
sleep,
|
|
27
|
+
storeWorkspaceContext,
|
|
19
28
|
storeWorkspaceCredential,
|
|
20
29
|
writeResult,
|
|
30
|
+
writeBinaryFile,
|
|
21
31
|
writeTextFile,
|
|
22
32
|
} from "./core.js";
|
|
23
33
|
|
|
24
|
-
const VERSION = "0.1.0-beta.
|
|
34
|
+
const VERSION = "0.1.0-beta.4";
|
|
25
35
|
const DEFAULT_API_BASE = "https://api.corvio.ai/v1";
|
|
26
36
|
const DEFAULT_APP_BASE = "https://corvio.ai";
|
|
27
37
|
const USER_AGENT = `corvio-cli/${VERSION}`;
|
|
@@ -30,18 +40,24 @@ const HELP = `Corvio Workspace CLI ${VERSION}
|
|
|
30
40
|
|
|
31
41
|
Usage:
|
|
32
42
|
corvio auth login|status|logout
|
|
43
|
+
corvio workspaces list|use|current [id]
|
|
33
44
|
corvio capabilities
|
|
34
45
|
corvio ask --prompt <text> [--conversation-id <id>] [--allow-actions]
|
|
35
46
|
corvio questions list|get [id]
|
|
36
47
|
corvio conversations list|get [id]
|
|
37
48
|
corvio search <query>
|
|
38
49
|
corvio docs list|get|create|update|archive|share|open [id]
|
|
50
|
+
corvio files list|get|download|upload|organize|open [id]
|
|
51
|
+
corvio sync init|status|plan|pull|push|resolve
|
|
52
|
+
corvio update check
|
|
39
53
|
|
|
40
54
|
Global options:
|
|
41
|
-
--api-base <url> --json --no-input --yes --help --version
|
|
55
|
+
--api-base <url> --workspace <id> --json --no-input --yes --help --version
|
|
42
56
|
|
|
43
57
|
Authentication:
|
|
44
|
-
CORVIO_API_KEY=
|
|
58
|
+
CORVIO_API_KEY=cvu_... takes precedence over the stored device-link login.
|
|
59
|
+
Legacy Workspace-bound cvk_ keys remain supported.
|
|
60
|
+
Select the target with 'corvio workspaces use <id>', --workspace, or CORVIO_WORKSPACE_ID.
|
|
45
61
|
Run 'corvio auth login' when no environment key is configured.
|
|
46
62
|
`;
|
|
47
63
|
|
|
@@ -49,7 +65,23 @@ function baseUrl(options) {
|
|
|
49
65
|
return normalizeApiBase(options["api-base"] || process.env.CORVIO_API_BASE, DEFAULT_API_BASE);
|
|
50
66
|
}
|
|
51
67
|
|
|
52
|
-
async function
|
|
68
|
+
async function resolveWorkspaceSelection(options, credential) {
|
|
69
|
+
const explicit = String(options.workspace || options["workspace-id"] || "").trim();
|
|
70
|
+
if (explicit) return { workspaceId: explicit, workspaceName: null, source: "option" };
|
|
71
|
+
const legacyWorkspaceId = String(credential?.metadata?.workspace_id || "").trim();
|
|
72
|
+
if (legacyWorkspaceId) {
|
|
73
|
+
return {
|
|
74
|
+
workspaceId: legacyWorkspaceId,
|
|
75
|
+
workspaceName: String(credential?.metadata?.workspace_name || "").trim() || null,
|
|
76
|
+
source: "legacy_credential",
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
const context = await readWorkspaceContext();
|
|
80
|
+
if (context?.workspaceId) return context;
|
|
81
|
+
return null;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
async function client(options, { auth = true, workspace = "required" } = {}) {
|
|
53
85
|
const credential = auth ? await readWorkspaceCredential() : null;
|
|
54
86
|
if (auth && !credential) {
|
|
55
87
|
throw new CliError("No Corvio credential found. Set CORVIO_API_KEY or run `corvio auth login`.", {
|
|
@@ -57,16 +89,31 @@ async function client(options, { auth = true } = {}) {
|
|
|
57
89
|
exitCode: EXIT.AUTH,
|
|
58
90
|
});
|
|
59
91
|
}
|
|
92
|
+
const selection = auth && workspace !== "none"
|
|
93
|
+
? await resolveWorkspaceSelection(options, credential)
|
|
94
|
+
: null;
|
|
95
|
+
if (auth && workspace === "required" && credential?.key?.startsWith("cvu_") && !selection?.workspaceId) {
|
|
96
|
+
throw new CliError(
|
|
97
|
+
"No Corvio Workspace selected. Run `corvio workspaces list` then `corvio workspaces use <id>`.",
|
|
98
|
+
{ code: "workspace_selection_required", exitCode: EXIT.AUTH },
|
|
99
|
+
);
|
|
100
|
+
}
|
|
60
101
|
return {
|
|
61
|
-
api: new ApiClient({
|
|
102
|
+
api: new ApiClient({
|
|
103
|
+
baseUrl: baseUrl(options),
|
|
104
|
+
credential: credential?.key,
|
|
105
|
+
workspaceId: selection?.workspaceId,
|
|
106
|
+
userAgent: USER_AGENT,
|
|
107
|
+
}),
|
|
62
108
|
credential,
|
|
109
|
+
selection,
|
|
63
110
|
};
|
|
64
111
|
}
|
|
65
112
|
|
|
66
113
|
async function authLogin(options) {
|
|
67
114
|
const current = await readWorkspaceCredential();
|
|
68
115
|
if (current?.source === "environment" && !options.force) {
|
|
69
|
-
const
|
|
116
|
+
const api = new ApiClient({ baseUrl: baseUrl(options), credential: current.key, userAgent: USER_AGENT });
|
|
70
117
|
const identity = await api.request("/me");
|
|
71
118
|
writeResult({ authenticated: true, source: "environment", identity }, { json: options.json });
|
|
72
119
|
return;
|
|
@@ -85,6 +132,10 @@ async function authLogin(options) {
|
|
|
85
132
|
"documents:read",
|
|
86
133
|
"documents:write",
|
|
87
134
|
"documents:share",
|
|
135
|
+
"assets:read",
|
|
136
|
+
"assets:write",
|
|
137
|
+
"sync:read",
|
|
138
|
+
"sync:write",
|
|
88
139
|
],
|
|
89
140
|
},
|
|
90
141
|
});
|
|
@@ -127,7 +178,9 @@ async function authLogin(options) {
|
|
|
127
178
|
authenticated: true,
|
|
128
179
|
source: "device_link",
|
|
129
180
|
profile: stored.profile,
|
|
181
|
+
user: identity.user,
|
|
130
182
|
workspace: identity.workspace,
|
|
183
|
+
workspaces: identity.workspaces || [],
|
|
131
184
|
scopes: identity.api_key?.scopes || [],
|
|
132
185
|
}, { json: options.json });
|
|
133
186
|
}
|
|
@@ -138,7 +191,12 @@ async function authStatus(options) {
|
|
|
138
191
|
writeResult({ authenticated: false, sources: ["CORVIO_API_KEY", "device_link"] }, { json: options.json });
|
|
139
192
|
return;
|
|
140
193
|
}
|
|
141
|
-
const
|
|
194
|
+
const selection = await resolveWorkspaceSelection(options, credential);
|
|
195
|
+
const api = new ApiClient({
|
|
196
|
+
baseUrl: baseUrl(options),
|
|
197
|
+
credential: credential.key,
|
|
198
|
+
userAgent: USER_AGENT,
|
|
199
|
+
});
|
|
142
200
|
const identity = await api.request("/me");
|
|
143
201
|
writeResult({
|
|
144
202
|
authenticated: true,
|
|
@@ -146,6 +204,8 @@ async function authStatus(options) {
|
|
|
146
204
|
profile: credential.profile,
|
|
147
205
|
user: identity.user,
|
|
148
206
|
workspace: identity.workspace,
|
|
207
|
+
workspaces: identity.workspaces || [],
|
|
208
|
+
selection,
|
|
149
209
|
api_key: identity.api_key,
|
|
150
210
|
}, { json: options.json });
|
|
151
211
|
}
|
|
@@ -165,24 +225,80 @@ async function authLogout(options) {
|
|
|
165
225
|
const api = new ApiClient({ baseUrl: baseUrl(options), credential: credential.key, userAgent: USER_AGENT });
|
|
166
226
|
await api.request("/auth/credentials/current:revoke", { method: "POST" });
|
|
167
227
|
await clearWorkspaceCredential();
|
|
228
|
+
await clearWorkspaceContext();
|
|
168
229
|
writeResult({ authenticated: false, revoked: true, profile: credential.profile }, { json: options.json });
|
|
169
230
|
}
|
|
170
231
|
|
|
171
232
|
async function capabilities(options) {
|
|
172
|
-
const { api, credential } = await client(options);
|
|
233
|
+
const { api, credential, selection } = await client(options, { workspace: "optional" });
|
|
173
234
|
const [index, identity] = await Promise.all([api.request("", { auth: false }), api.request("/me")]);
|
|
174
235
|
writeResult({
|
|
175
236
|
cli_version: VERSION,
|
|
176
237
|
auth_source: credential.source,
|
|
238
|
+
workspace_selection: selection,
|
|
177
239
|
api: index,
|
|
178
240
|
principal: identity,
|
|
179
|
-
|
|
180
|
-
files: "Public Workspace Asset upload/organization is not advertised by /v1 yet.",
|
|
181
|
-
sync: "Public Local Sync adapters are not advertised by /v1 yet.",
|
|
182
|
-
},
|
|
241
|
+
update: await buildUpdateStatus(index, { allowRegistryFailure: true }),
|
|
183
242
|
}, { json: options.json });
|
|
184
243
|
}
|
|
185
244
|
|
|
245
|
+
async function workspaces(options, action, id) {
|
|
246
|
+
const credential = await readWorkspaceCredential();
|
|
247
|
+
if (!credential) {
|
|
248
|
+
throw new CliError("No Corvio credential found. Run `corvio auth login`.", {
|
|
249
|
+
code: "auth_required",
|
|
250
|
+
exitCode: EXIT.AUTH,
|
|
251
|
+
});
|
|
252
|
+
}
|
|
253
|
+
if (action === "list") {
|
|
254
|
+
const api = new ApiClient({ baseUrl: baseUrl(options), credential: credential.key, userAgent: USER_AGENT });
|
|
255
|
+
if (credential.key.startsWith("cvk_")) {
|
|
256
|
+
const identity = await api.request("/me");
|
|
257
|
+
return writeResult({ data: identity.workspace ? [identity.workspace] : [], legacy_bound: true }, { json: options.json });
|
|
258
|
+
}
|
|
259
|
+
return writeResult(await api.request("/workspaces"), { json: options.json });
|
|
260
|
+
}
|
|
261
|
+
const selection = await resolveWorkspaceSelection(options, credential);
|
|
262
|
+
if (action === "current") {
|
|
263
|
+
if (!selection?.workspaceId) {
|
|
264
|
+
if (credential.key.startsWith("cvk_")) {
|
|
265
|
+
const api = new ApiClient({ baseUrl: baseUrl(options), credential: credential.key, userAgent: USER_AGENT });
|
|
266
|
+
const identity = await api.request("/me");
|
|
267
|
+
return writeResult(
|
|
268
|
+
{ selected: Boolean(identity.workspace), workspace: identity.workspace, source: "legacy_credential" },
|
|
269
|
+
{ json: options.json },
|
|
270
|
+
);
|
|
271
|
+
}
|
|
272
|
+
return writeResult({ selected: false, workspace: null }, { json: options.json });
|
|
273
|
+
}
|
|
274
|
+
const api = new ApiClient({
|
|
275
|
+
baseUrl: baseUrl(options),
|
|
276
|
+
credential: credential.key,
|
|
277
|
+
workspaceId: selection.workspaceId,
|
|
278
|
+
userAgent: USER_AGENT,
|
|
279
|
+
});
|
|
280
|
+
const identity = await api.request("/me");
|
|
281
|
+
return writeResult({ selected: true, workspace: identity.workspace, source: selection.source }, { json: options.json });
|
|
282
|
+
}
|
|
283
|
+
if (action === "use") {
|
|
284
|
+
const workspaceId = String(id || options.id || options.workspace || options["workspace-id"] || "").trim();
|
|
285
|
+
if (!workspaceId) throw new CliError("Provide a Workspace ID.", { code: "workspace_selection_required" });
|
|
286
|
+
const api = new ApiClient({
|
|
287
|
+
baseUrl: baseUrl(options),
|
|
288
|
+
credential: credential.key,
|
|
289
|
+
workspaceId,
|
|
290
|
+
userAgent: USER_AGENT,
|
|
291
|
+
});
|
|
292
|
+
const identity = await api.request("/me");
|
|
293
|
+
if (!identity.workspace?.id) {
|
|
294
|
+
throw new CliError("Corvio did not resolve the selected Workspace.", { code: "workspace_selection_failed" });
|
|
295
|
+
}
|
|
296
|
+
await storeWorkspaceContext({ workspaceId: identity.workspace.id, workspaceName: identity.workspace.name });
|
|
297
|
+
return writeResult({ selected: true, workspace: identity.workspace }, { json: options.json });
|
|
298
|
+
}
|
|
299
|
+
throw new CliError("workspaces action must be list, use, or current.");
|
|
300
|
+
}
|
|
301
|
+
|
|
186
302
|
async function ask(options, positionals) {
|
|
187
303
|
const prompt = String(options.prompt || positionals.join(" ")).trim();
|
|
188
304
|
if (!prompt) throw new CliError("Provide --prompt <text> or a positional prompt.");
|
|
@@ -295,28 +411,530 @@ async function docs(options, action, id) {
|
|
|
295
411
|
writeResult(result, { json: options.json });
|
|
296
412
|
}
|
|
297
413
|
|
|
414
|
+
async function buildUpdateStatus(index, { allowRegistryFailure = false } = {}) {
|
|
415
|
+
return resolveNpmUpdateStatus({
|
|
416
|
+
currentVersion: VERSION,
|
|
417
|
+
policy: index?.distribution?.workspace_cli || {
|
|
418
|
+
package: "@corvio/cli",
|
|
419
|
+
dist_tag: "beta",
|
|
420
|
+
},
|
|
421
|
+
userAgent: USER_AGENT,
|
|
422
|
+
allowRegistryFailure,
|
|
423
|
+
});
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
function updateCachePath() {
|
|
427
|
+
const root = process.env.CORVIO_CONFIG_DIR
|
|
428
|
+
? resolve(process.env.CORVIO_CONFIG_DIR)
|
|
429
|
+
: process.env.XDG_CACHE_HOME
|
|
430
|
+
? join(resolve(process.env.XDG_CACHE_HOME), "corvio")
|
|
431
|
+
: join(homedir(), ".cache", "corvio");
|
|
432
|
+
return join(root, "update-check.json");
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
async function readUpdateCache() {
|
|
436
|
+
try {
|
|
437
|
+
return JSON.parse(await readFile(updateCachePath(), "utf8"));
|
|
438
|
+
} catch {
|
|
439
|
+
return null;
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
async function writeUpdateCache(payload) {
|
|
444
|
+
const target = updateCachePath();
|
|
445
|
+
await mkdir(dirname(target), { recursive: true, mode: 0o700 });
|
|
446
|
+
const temporary = `${target}.${randomUUID()}.tmp`;
|
|
447
|
+
await writeFile(temporary, `${JSON.stringify(payload, null, 2)}\n`, { mode: 0o600 });
|
|
448
|
+
await rename(temporary, target);
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
async function updateCheck(options) {
|
|
452
|
+
const { api } = await client(options, { auth: false });
|
|
453
|
+
const index = await api.request("", { auth: false });
|
|
454
|
+
const result = await buildUpdateStatus(index);
|
|
455
|
+
await writeUpdateCache({ checked_at: new Date().toISOString(), result }).catch(() => {});
|
|
456
|
+
writeResult(result, { json: options.json });
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
async function maybeNotifyUpdate(options) {
|
|
460
|
+
if (
|
|
461
|
+
options.json
|
|
462
|
+
|| options["no-input"]
|
|
463
|
+
|| process.env.CI
|
|
464
|
+
|| process.env.CORVIO_DISABLE_UPDATE_CHECK === "1"
|
|
465
|
+
) return;
|
|
466
|
+
const cache = await readUpdateCache();
|
|
467
|
+
const checkedAt = Date.parse(String(cache?.checked_at || ""));
|
|
468
|
+
let result = cache?.result;
|
|
469
|
+
if (!Number.isFinite(checkedAt) || Date.now() - checkedAt > 24 * 60 * 60 * 1000) {
|
|
470
|
+
try {
|
|
471
|
+
const { api } = await client(options, { auth: false });
|
|
472
|
+
const index = await api.request("", { auth: false });
|
|
473
|
+
result = await buildUpdateStatus(index, { allowRegistryFailure: true });
|
|
474
|
+
await writeUpdateCache({ checked_at: new Date().toISOString(), result }).catch(() => {});
|
|
475
|
+
} catch {
|
|
476
|
+
return;
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
if (result?.update_available) {
|
|
480
|
+
process.stderr.write(
|
|
481
|
+
`Update available: @corvio/cli ${result.current_version} → ${result.latest_published_version}. `
|
|
482
|
+
+ `Run: ${result.install_command}\n`,
|
|
483
|
+
);
|
|
484
|
+
} else if (result?.supported === false) {
|
|
485
|
+
process.stderr.write(
|
|
486
|
+
`This Corvio CLI (${VERSION}) is no longer supported. Run: ${result.install_command}\n`,
|
|
487
|
+
);
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
function mimeTypeFor(path) {
|
|
492
|
+
const extension = extname(String(path)).toLowerCase();
|
|
493
|
+
return ({
|
|
494
|
+
".md": "text/markdown",
|
|
495
|
+
".txt": "text/plain",
|
|
496
|
+
".csv": "text/csv",
|
|
497
|
+
".json": "application/json",
|
|
498
|
+
".pdf": "application/pdf",
|
|
499
|
+
".png": "image/png",
|
|
500
|
+
".jpg": "image/jpeg",
|
|
501
|
+
".jpeg": "image/jpeg",
|
|
502
|
+
".gif": "image/gif",
|
|
503
|
+
".docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
|
504
|
+
".xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
|
505
|
+
".pptx": "application/vnd.openxmlformats-officedocument.presentationml.presentation",
|
|
506
|
+
})[extension] || "application/octet-stream";
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
async function files(options, action, id) {
|
|
510
|
+
const { api } = await client(options);
|
|
511
|
+
const assetId = id || options.id;
|
|
512
|
+
let result;
|
|
513
|
+
if (action === "list") {
|
|
514
|
+
result = await api.request("/files", { query: { limit: numberOption(options, "limit", 50) } });
|
|
515
|
+
} else if (action === "get") {
|
|
516
|
+
result = await api.request(`/files/${assetId || option(options, "id", { required: true })}`);
|
|
517
|
+
} else if (action === "download") {
|
|
518
|
+
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`;
|
|
522
|
+
const written = await writeBinaryFile(output, bytes);
|
|
523
|
+
result = { ...metadata, output: written };
|
|
524
|
+
} else if (action === "upload") {
|
|
525
|
+
const input = resolve(String(option(options, "file", { required: true })));
|
|
526
|
+
const info = await stat(input);
|
|
527
|
+
if (!info.isFile()) throw new CliError("--file must point to a regular file.", { code: "upload_file_invalid" });
|
|
528
|
+
const bytes = await readFile(input);
|
|
529
|
+
const fileName = basename(input);
|
|
530
|
+
const mimeType = String(options["mime-type"] || mimeTypeFor(input));
|
|
531
|
+
const initialized = await api.request("/files/uploads", {
|
|
532
|
+
method: "POST",
|
|
533
|
+
body: { file_name: fileName, mime_type: mimeType, size_bytes: bytes.length },
|
|
534
|
+
});
|
|
535
|
+
await api.putUrl(initialized.upload_url, bytes, { headers: initialized.upload_headers || {} });
|
|
536
|
+
result = await api.request("/files/uploads:finalize", {
|
|
537
|
+
method: "POST",
|
|
538
|
+
body: {
|
|
539
|
+
upload_id: initialized.upload_id,
|
|
540
|
+
blob_name: initialized.blob_name,
|
|
541
|
+
file_name: fileName,
|
|
542
|
+
mime_type: mimeType,
|
|
543
|
+
size_bytes: bytes.length,
|
|
544
|
+
},
|
|
545
|
+
});
|
|
546
|
+
} else if (action === "organize") {
|
|
547
|
+
requireConfirmation(options, "Starting a file organization run");
|
|
548
|
+
result = await api.request(`/files/${assetId || option(options, "id", { required: true })}:organize`, {
|
|
549
|
+
method: "POST",
|
|
550
|
+
body: {
|
|
551
|
+
instruction: option(options, "instruction", { required: true }),
|
|
552
|
+
target_root_node_id: options["target-root-node-id"] || null,
|
|
553
|
+
},
|
|
554
|
+
});
|
|
555
|
+
} else if (action === "open") {
|
|
556
|
+
const targetId = assetId || option(options, "id", { required: true });
|
|
557
|
+
const target = `${String(process.env.CORVIO_APP_BASE || DEFAULT_APP_BASE).replace(/\/+$/, "")}/app/a/${targetId}`;
|
|
558
|
+
result = { file_id: targetId, url: target, opened: options["no-input"] ? false : await openBrowser(target) };
|
|
559
|
+
} else {
|
|
560
|
+
throw new CliError("files action must be list, get, download, upload, organize, or open.");
|
|
561
|
+
}
|
|
562
|
+
writeResult(result, { json: options.json });
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
function sha256(content) {
|
|
566
|
+
return createHash("sha256").update(content).digest("hex");
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
function syncRoot(options) {
|
|
570
|
+
return resolve(String(options.dir || process.cwd()));
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
function syncStatePath(root) {
|
|
574
|
+
return join(root, ".corvio", "state.json");
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
async function readSyncState(options) {
|
|
578
|
+
const root = syncRoot(options);
|
|
579
|
+
let state;
|
|
580
|
+
try {
|
|
581
|
+
state = JSON.parse(await readFile(syncStatePath(root), "utf8"));
|
|
582
|
+
} catch (cause) {
|
|
583
|
+
if (cause?.code === "ENOENT") {
|
|
584
|
+
throw new CliError("This directory is not initialized. Run `corvio sync init --root-node-id <id>`.", {
|
|
585
|
+
code: "sync_not_initialized",
|
|
586
|
+
});
|
|
587
|
+
}
|
|
588
|
+
throw new CliError(".corvio/state.json is invalid.", { code: "sync_state_invalid" });
|
|
589
|
+
}
|
|
590
|
+
if (state.version !== 1 || !state.source_id) {
|
|
591
|
+
throw new CliError(".corvio/state.json has an unsupported format.", { code: "sync_state_invalid" });
|
|
592
|
+
}
|
|
593
|
+
const requestedBase = baseUrl(options);
|
|
594
|
+
if (state.api_base && state.api_base !== requestedBase) {
|
|
595
|
+
throw new CliError(
|
|
596
|
+
`This directory belongs to ${state.api_base}; use the matching --api-base or reinitialize it.`,
|
|
597
|
+
{ code: "sync_api_base_mismatch" },
|
|
598
|
+
);
|
|
599
|
+
}
|
|
600
|
+
return { root, state };
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
async function writeSyncState(root, state) {
|
|
604
|
+
const target = syncStatePath(root);
|
|
605
|
+
await mkdir(dirname(target), { recursive: true, mode: 0o700 });
|
|
606
|
+
const temporary = `${target}.${randomUUID()}.tmp`;
|
|
607
|
+
await writeFile(temporary, `${JSON.stringify(state, null, 2)}\n`, { mode: 0o600 });
|
|
608
|
+
await rename(temporary, target);
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
function localSyncPath(root, relativePath) {
|
|
612
|
+
const parts = String(relativePath).replaceAll("\\", "/").split("/").filter(Boolean);
|
|
613
|
+
if (parts[0] !== "corvio_docs" || parts.some((part) => part === "." || part === "..")) {
|
|
614
|
+
throw new CliError(`Unsafe sync path returned by Corvio: ${relativePath}`, { code: "sync_path_unsafe" });
|
|
615
|
+
}
|
|
616
|
+
const target = resolve(root, ...parts);
|
|
617
|
+
if (target !== root && !target.startsWith(`${root}${sep}`)) {
|
|
618
|
+
throw new CliError(`Unsafe sync path returned by Corvio: ${relativePath}`, { code: "sync_path_unsafe" });
|
|
619
|
+
}
|
|
620
|
+
return target;
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
async function scanMarkdown(root) {
|
|
624
|
+
const mirror = join(root, "corvio_docs");
|
|
625
|
+
await mkdir(mirror, { recursive: true });
|
|
626
|
+
const manifest = [];
|
|
627
|
+
async function walk(directory) {
|
|
628
|
+
for (const entry of await readdir(directory, { withFileTypes: true })) {
|
|
629
|
+
const path = join(directory, entry.name);
|
|
630
|
+
if (entry.isSymbolicLink()) continue;
|
|
631
|
+
if (entry.isDirectory()) {
|
|
632
|
+
await walk(path);
|
|
633
|
+
} else if (entry.isFile() && entry.name.toLowerCase().endsWith(".md")) {
|
|
634
|
+
const bytes = await readFile(path);
|
|
635
|
+
const info = await lstat(path);
|
|
636
|
+
manifest.push({
|
|
637
|
+
relative_path: relative(root, path).split(sep).join("/"),
|
|
638
|
+
content_sha256: sha256(bytes),
|
|
639
|
+
size_bytes: bytes.length,
|
|
640
|
+
mtime_ms: Math.trunc(info.mtimeMs),
|
|
641
|
+
});
|
|
642
|
+
}
|
|
643
|
+
}
|
|
644
|
+
}
|
|
645
|
+
await walk(mirror);
|
|
646
|
+
manifest.sort((left, right) => left.relative_path.localeCompare(right.relative_path));
|
|
647
|
+
return manifest;
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
async function syncManifest(root, state) {
|
|
651
|
+
const manifest = await scanMarkdown(root);
|
|
652
|
+
const mappings = Object.entries(state.mappings || {}).map(([pageId, mapping]) => ({ pageId, ...mapping }));
|
|
653
|
+
const pageByPath = new Map(mappings.map((mapping) => [mapping.relative_path, mapping.pageId]));
|
|
654
|
+
const presentPaths = new Set(manifest.map((item) => item.relative_path));
|
|
655
|
+
for (const item of manifest) {
|
|
656
|
+
const pageId = pageByPath.get(item.relative_path);
|
|
657
|
+
if (pageId) item.page_id = pageId;
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
// Treat a local move as the same Page only when the last-common hash gives
|
|
661
|
+
// one unambiguous missing-old-path -> new-path match. Duplicate content
|
|
662
|
+
// stays unbound and the server reports separate missing/new actions.
|
|
663
|
+
const unboundByHash = new Map();
|
|
664
|
+
for (const item of manifest.filter((candidate) => !candidate.page_id)) {
|
|
665
|
+
const values = unboundByHash.get(item.content_sha256) || [];
|
|
666
|
+
values.push(item);
|
|
667
|
+
unboundByHash.set(item.content_sha256, values);
|
|
668
|
+
}
|
|
669
|
+
const missingByHash = new Map();
|
|
670
|
+
for (const mapping of mappings.filter((candidate) => !presentPaths.has(candidate.relative_path))) {
|
|
671
|
+
const values = missingByHash.get(mapping.content_sha256) || [];
|
|
672
|
+
values.push(mapping);
|
|
673
|
+
missingByHash.set(mapping.content_sha256, values);
|
|
674
|
+
}
|
|
675
|
+
for (const [hash, items] of unboundByHash.entries()) {
|
|
676
|
+
const candidates = missingByHash.get(hash) || [];
|
|
677
|
+
if (items.length === 1 && candidates.length === 1) items[0].page_id = candidates[0].pageId;
|
|
678
|
+
}
|
|
679
|
+
return manifest;
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
async function syncPlan(api, state, root) {
|
|
683
|
+
return api.request("/sync/plan", {
|
|
684
|
+
method: "POST",
|
|
685
|
+
body: { source_id: state.source_id, local: await syncManifest(root, state) },
|
|
686
|
+
});
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
async function syncCommand(options, action) {
|
|
690
|
+
const { api, selection } = await client(options);
|
|
691
|
+
if (action === "init") {
|
|
692
|
+
const root = syncRoot(options);
|
|
693
|
+
await mkdir(root, { recursive: true });
|
|
694
|
+
if (!options.force) {
|
|
695
|
+
try {
|
|
696
|
+
await readFile(syncStatePath(root), "utf8");
|
|
697
|
+
throw new CliError("This directory is already initialized; pass --force to bind it again.", {
|
|
698
|
+
code: "sync_already_initialized",
|
|
699
|
+
});
|
|
700
|
+
} catch (cause) {
|
|
701
|
+
if (cause instanceof CliError) throw cause;
|
|
702
|
+
if (cause?.code !== "ENOENT") throw cause;
|
|
703
|
+
}
|
|
704
|
+
}
|
|
705
|
+
const clientId = `corvio-cli-${randomUUID()}`;
|
|
706
|
+
const source = await api.request("/sync/sources", {
|
|
707
|
+
method: "POST",
|
|
708
|
+
body: {
|
|
709
|
+
root_node_id: option(options, "root-node-id", { required: true }),
|
|
710
|
+
folder_name: String(options.name || basename(root)),
|
|
711
|
+
client_id: clientId,
|
|
712
|
+
},
|
|
713
|
+
});
|
|
714
|
+
const state = {
|
|
715
|
+
version: 1,
|
|
716
|
+
source_id: source.id,
|
|
717
|
+
root_node_id: source.root_node_id,
|
|
718
|
+
workspace_id: source.workspace_id || selection?.workspaceId || null,
|
|
719
|
+
client_id: clientId,
|
|
720
|
+
api_base: baseUrl(options),
|
|
721
|
+
mirror_root_dir: source.mirror_root_dir || "corvio_docs",
|
|
722
|
+
mappings: {},
|
|
723
|
+
initialized_at: new Date().toISOString(),
|
|
724
|
+
};
|
|
725
|
+
await mkdir(join(root, state.mirror_root_dir), { recursive: true });
|
|
726
|
+
await writeSyncState(root, state);
|
|
727
|
+
return writeResult({ source, directory: root, state_file: syncStatePath(root) }, { json: options.json });
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
const { root, state } = await readSyncState(options);
|
|
731
|
+
if (state.workspace_id && selection?.workspaceId && state.workspace_id !== selection.workspaceId) {
|
|
732
|
+
throw new CliError(
|
|
733
|
+
`This sync directory belongs to Workspace ${state.workspace_id}; select it before syncing.`,
|
|
734
|
+
{ code: "sync_workspace_mismatch" },
|
|
735
|
+
);
|
|
736
|
+
}
|
|
737
|
+
if (action === "status") {
|
|
738
|
+
const remote = await api.request(`/sync/sources/${state.source_id}`);
|
|
739
|
+
return writeResult({ ...remote, directory: root, state_file: syncStatePath(root) }, { json: options.json });
|
|
740
|
+
}
|
|
741
|
+
if (action === "plan") {
|
|
742
|
+
return writeResult(await syncPlan(api, state, root), { json: options.json });
|
|
743
|
+
}
|
|
744
|
+
if (action === "pull") {
|
|
745
|
+
const plan = await syncPlan(api, state, root);
|
|
746
|
+
const conflicts = plan.actions.filter((item) => item.action === "conflict");
|
|
747
|
+
if (conflicts.length) {
|
|
748
|
+
throw new CliError("Sync has conflicts. Run `corvio sync plan --json` and resolve each conflict.", {
|
|
749
|
+
code: "sync_conflict",
|
|
750
|
+
exitCode: EXIT.CONFLICT,
|
|
751
|
+
details: { conflicts },
|
|
752
|
+
});
|
|
753
|
+
}
|
|
754
|
+
const committed = [];
|
|
755
|
+
const mappings = { ...(state.mappings || {}) };
|
|
756
|
+
for (const actionItem of plan.actions.filter((item) => item.action === "pull")) {
|
|
757
|
+
const item = actionItem.remote;
|
|
758
|
+
if (!item) continue;
|
|
759
|
+
const target = localSyncPath(root, item.relative_path);
|
|
760
|
+
const previous = mappings[item.page_id];
|
|
761
|
+
if (previous?.relative_path && previous.relative_path !== item.relative_path) {
|
|
762
|
+
const oldTarget = localSyncPath(root, previous.relative_path);
|
|
763
|
+
try {
|
|
764
|
+
const oldBytes = await readFile(oldTarget);
|
|
765
|
+
if (sha256(oldBytes) === previous.content_sha256) await unlink(oldTarget);
|
|
766
|
+
} catch (cause) {
|
|
767
|
+
if (cause?.code !== "ENOENT") throw cause;
|
|
768
|
+
}
|
|
769
|
+
}
|
|
770
|
+
await writeTextFile(target, item.markdown || "");
|
|
771
|
+
const info = await stat(target);
|
|
772
|
+
committed.push({
|
|
773
|
+
page_id: item.page_id,
|
|
774
|
+
node_id: item.node_id,
|
|
775
|
+
relative_path: item.relative_path,
|
|
776
|
+
content_sha256: item.content_sha256,
|
|
777
|
+
mtime_ms: Math.trunc(info.mtimeMs),
|
|
778
|
+
});
|
|
779
|
+
mappings[item.page_id] = {
|
|
780
|
+
node_id: item.node_id,
|
|
781
|
+
relative_path: item.relative_path,
|
|
782
|
+
content_sha256: item.content_sha256,
|
|
783
|
+
content_revision: item.content_revision,
|
|
784
|
+
};
|
|
785
|
+
}
|
|
786
|
+
if (committed.length) {
|
|
787
|
+
await api.request("/sync/commit", {
|
|
788
|
+
method: "POST",
|
|
789
|
+
body: { source_id: state.source_id, items: committed },
|
|
790
|
+
});
|
|
791
|
+
}
|
|
792
|
+
state.mappings = mappings;
|
|
793
|
+
state.last_pull_at = new Date().toISOString();
|
|
794
|
+
await writeSyncState(root, state);
|
|
795
|
+
return writeResult({
|
|
796
|
+
pulled: committed.length,
|
|
797
|
+
unresolved_local_missing: plan.actions.filter((item) => item.action === "local_missing"),
|
|
798
|
+
directory: root,
|
|
799
|
+
}, { json: options.json });
|
|
800
|
+
}
|
|
801
|
+
if (action === "push") {
|
|
802
|
+
requireConfirmation(options, "Pushing local Markdown changes to Corvio");
|
|
803
|
+
const plan = await syncPlan(api, state, root);
|
|
804
|
+
const conflicts = plan.actions.filter((item) => item.action === "conflict");
|
|
805
|
+
if (conflicts.length) {
|
|
806
|
+
throw new CliError("Sync has conflicts. Resolve them before pushing.", {
|
|
807
|
+
code: "sync_conflict",
|
|
808
|
+
exitCode: EXIT.CONFLICT,
|
|
809
|
+
details: { conflicts },
|
|
810
|
+
});
|
|
811
|
+
}
|
|
812
|
+
const results = [];
|
|
813
|
+
const mappings = { ...(state.mappings || {}) };
|
|
814
|
+
for (const actionItem of plan.actions.filter((item) => item.action === "push")) {
|
|
815
|
+
const path = localSyncPath(root, actionItem.relative_path);
|
|
816
|
+
const bytes = await readFile(path);
|
|
817
|
+
const info = await stat(path);
|
|
818
|
+
const previous = actionItem.page_id ? mappings[actionItem.page_id] : null;
|
|
819
|
+
const result = await api.request("/sync/push", {
|
|
820
|
+
method: "POST",
|
|
821
|
+
body: {
|
|
822
|
+
source_id: state.source_id,
|
|
823
|
+
relative_path: actionItem.relative_path,
|
|
824
|
+
markdown: bytes.toString("utf8"),
|
|
825
|
+
content_sha256: sha256(bytes),
|
|
826
|
+
mtime_ms: Math.trunc(info.mtimeMs),
|
|
827
|
+
page_id: actionItem.page_id || null,
|
|
828
|
+
expected_content_revision: previous?.content_revision ?? null,
|
|
829
|
+
},
|
|
830
|
+
});
|
|
831
|
+
results.push(result);
|
|
832
|
+
mappings[result.item.page_id] = {
|
|
833
|
+
node_id: result.item.node_id,
|
|
834
|
+
relative_path: result.item.relative_path,
|
|
835
|
+
content_sha256: result.item.content_sha256,
|
|
836
|
+
content_revision: result.item.content_revision,
|
|
837
|
+
};
|
|
838
|
+
}
|
|
839
|
+
state.mappings = mappings;
|
|
840
|
+
state.last_push_at = new Date().toISOString();
|
|
841
|
+
await writeSyncState(root, state);
|
|
842
|
+
return writeResult({
|
|
843
|
+
pushed: results.length,
|
|
844
|
+
results,
|
|
845
|
+
unresolved_local_missing: plan.actions.filter((item) => item.action === "local_missing"),
|
|
846
|
+
}, { json: options.json });
|
|
847
|
+
}
|
|
848
|
+
if (action === "resolve") {
|
|
849
|
+
requireConfirmation(options, "Resolving a sync conflict");
|
|
850
|
+
const conflictId = option(options, "conflict-id", { required: true });
|
|
851
|
+
const strategy = option(options, "strategy", { required: true });
|
|
852
|
+
const conflict = await api.request(`/sync/conflicts/${conflictId}`);
|
|
853
|
+
if (strategy === "use-remote") {
|
|
854
|
+
if (!conflict.remote) throw new CliError("The remote document is no longer available.", { code: "sync_remote_missing" });
|
|
855
|
+
const item = conflict.remote;
|
|
856
|
+
const target = localSyncPath(root, item.relative_path);
|
|
857
|
+
await writeTextFile(target, item.markdown || "");
|
|
858
|
+
const info = await stat(target);
|
|
859
|
+
await api.request("/sync/commit", {
|
|
860
|
+
method: "POST",
|
|
861
|
+
body: {
|
|
862
|
+
source_id: state.source_id,
|
|
863
|
+
items: [{
|
|
864
|
+
page_id: item.page_id,
|
|
865
|
+
node_id: item.node_id,
|
|
866
|
+
relative_path: item.relative_path,
|
|
867
|
+
content_sha256: item.content_sha256,
|
|
868
|
+
mtime_ms: Math.trunc(info.mtimeMs),
|
|
869
|
+
}],
|
|
870
|
+
},
|
|
871
|
+
});
|
|
872
|
+
const result = await api.request(`/sync/conflicts/${conflictId}:resolve`, {
|
|
873
|
+
method: "POST",
|
|
874
|
+
body: { strategy: "use_remote" },
|
|
875
|
+
});
|
|
876
|
+
state.mappings[item.page_id] = {
|
|
877
|
+
node_id: item.node_id,
|
|
878
|
+
relative_path: item.relative_path,
|
|
879
|
+
content_sha256: item.content_sha256,
|
|
880
|
+
content_revision: item.content_revision,
|
|
881
|
+
};
|
|
882
|
+
await writeSyncState(root, state);
|
|
883
|
+
return writeResult(result, { json: options.json });
|
|
884
|
+
}
|
|
885
|
+
if (strategy === "keep-local") {
|
|
886
|
+
const target = localSyncPath(root, conflict.relative_path);
|
|
887
|
+
const bytes = await readFile(target);
|
|
888
|
+
const info = await stat(target);
|
|
889
|
+
const result = await api.request("/sync/push", {
|
|
890
|
+
method: "POST",
|
|
891
|
+
body: {
|
|
892
|
+
source_id: state.source_id,
|
|
893
|
+
relative_path: conflict.relative_path,
|
|
894
|
+
markdown: bytes.toString("utf8"),
|
|
895
|
+
content_sha256: sha256(bytes),
|
|
896
|
+
mtime_ms: Math.trunc(info.mtimeMs),
|
|
897
|
+
page_id: conflict.remote?.page_id || null,
|
|
898
|
+
conflict_id: conflictId,
|
|
899
|
+
},
|
|
900
|
+
});
|
|
901
|
+
state.mappings[result.item.page_id] = {
|
|
902
|
+
node_id: result.item.node_id,
|
|
903
|
+
relative_path: result.item.relative_path,
|
|
904
|
+
content_sha256: result.item.content_sha256,
|
|
905
|
+
content_revision: result.item.content_revision,
|
|
906
|
+
};
|
|
907
|
+
await writeSyncState(root, state);
|
|
908
|
+
return writeResult(result, { json: options.json });
|
|
909
|
+
}
|
|
910
|
+
throw new CliError("--strategy must be use-remote or keep-local.", { code: "sync_strategy_invalid" });
|
|
911
|
+
}
|
|
912
|
+
throw new CliError("sync action must be init, status, plan, pull, push, or resolve.");
|
|
913
|
+
}
|
|
914
|
+
|
|
298
915
|
async function main({ options, positionals }) {
|
|
299
916
|
const [command, action, id, ...rest] = positionals;
|
|
300
917
|
if (options.version || command === "version") return writeResult(VERSION, { json: options.json });
|
|
301
918
|
if (options.help || !command || command === "help") return writeResult(HELP.trimEnd(), { json: false });
|
|
919
|
+
if (command !== "update") await maybeNotifyUpdate(options);
|
|
302
920
|
if (command === "auth") {
|
|
303
921
|
if (action === "login") return authLogin(options);
|
|
304
922
|
if (action === "status") return authStatus(options);
|
|
305
923
|
if (action === "logout") return authLogout(options);
|
|
306
924
|
throw new CliError("auth action must be login, status, or logout.");
|
|
307
925
|
}
|
|
926
|
+
if (command === "workspaces" || command === "workspace") {
|
|
927
|
+
return workspaces(options, action || "list", id);
|
|
928
|
+
}
|
|
308
929
|
if (command === "capabilities") return capabilities(options);
|
|
309
930
|
if (command === "ask") return ask(options, [action, id, ...rest].filter(Boolean));
|
|
310
931
|
if (command === "questions") return questions(options, action || "list", id);
|
|
311
932
|
if (command === "conversations") return conversations(options, action || "list", id);
|
|
312
933
|
if (command === "search") return search(options, [action, id, ...rest].filter(Boolean));
|
|
313
934
|
if (command === "docs") return docs(options, action || "list", id);
|
|
314
|
-
if (command === "files"
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
exitCode: EXIT.UNAVAILABLE,
|
|
318
|
-
});
|
|
319
|
-
}
|
|
935
|
+
if (command === "files") return files(options, action || "list", id);
|
|
936
|
+
if (command === "sync") return syncCommand(options, action || "status");
|
|
937
|
+
if (command === "update" && (action || "check") === "check") return updateCheck(options);
|
|
320
938
|
throw new CliError(`Unknown command: ${command}`);
|
|
321
939
|
}
|
|
322
940
|
|
package/dist/core.js
CHANGED
|
@@ -105,10 +105,92 @@ export function normalizeApiBase(value, fallback) {
|
|
|
105
105
|
return raw.endsWith("/v1") ? raw : `${raw}/v1`;
|
|
106
106
|
}
|
|
107
107
|
|
|
108
|
+
function parseSemver(value) {
|
|
109
|
+
const match = String(value || "").trim().match(/^(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?/);
|
|
110
|
+
if (!match) return null;
|
|
111
|
+
return {
|
|
112
|
+
core: match.slice(1, 4).map((item) => Number.parseInt(item, 10)),
|
|
113
|
+
pre: match[4] ? match[4].split(".") : [],
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export function compareVersions(left, right) {
|
|
118
|
+
const a = parseSemver(left);
|
|
119
|
+
const b = parseSemver(right);
|
|
120
|
+
if (!a || !b) return 0;
|
|
121
|
+
for (let index = 0; index < 3; index += 1) {
|
|
122
|
+
if (a.core[index] !== b.core[index]) return a.core[index] > b.core[index] ? 1 : -1;
|
|
123
|
+
}
|
|
124
|
+
if (a.pre.length === 0 && b.pre.length > 0) return 1;
|
|
125
|
+
if (b.pre.length === 0 && a.pre.length > 0) return -1;
|
|
126
|
+
for (let index = 0; index < Math.max(a.pre.length, b.pre.length); index += 1) {
|
|
127
|
+
if (a.pre[index] === undefined) return -1;
|
|
128
|
+
if (b.pre[index] === undefined) return 1;
|
|
129
|
+
if (a.pre[index] === b.pre[index]) continue;
|
|
130
|
+
const aNumber = /^\d+$/.test(a.pre[index]) ? Number.parseInt(a.pre[index], 10) : null;
|
|
131
|
+
const bNumber = /^\d+$/.test(b.pre[index]) ? Number.parseInt(b.pre[index], 10) : null;
|
|
132
|
+
if (aNumber !== null && bNumber !== null) return aNumber > bNumber ? 1 : -1;
|
|
133
|
+
if (aNumber !== null) return -1;
|
|
134
|
+
if (bNumber !== null) return 1;
|
|
135
|
+
return a.pre[index] > b.pre[index] ? 1 : -1;
|
|
136
|
+
}
|
|
137
|
+
return 0;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export async function resolveNpmUpdateStatus({
|
|
141
|
+
currentVersion,
|
|
142
|
+
policy,
|
|
143
|
+
userAgent,
|
|
144
|
+
allowRegistryFailure = false,
|
|
145
|
+
fetchImpl = globalThis.fetch,
|
|
146
|
+
registryBase = process.env.CORVIO_NPM_REGISTRY_BASE || "https://registry.npmjs.org",
|
|
147
|
+
}) {
|
|
148
|
+
const packageName = String(policy?.package || "").trim();
|
|
149
|
+
const distTag = String(policy?.dist_tag || "beta").trim();
|
|
150
|
+
if (!packageName) throw new CliError("The API did not advertise an npm package.", { code: "update_policy_invalid" });
|
|
151
|
+
const encoded = encodeURIComponent(packageName).replace("%40", "@");
|
|
152
|
+
let latestVersion = null;
|
|
153
|
+
let registryError = null;
|
|
154
|
+
try {
|
|
155
|
+
const registry = String(registryBase).replace(/\/+$/, "");
|
|
156
|
+
const response = await fetchImpl(`${registry}/${encoded}`, {
|
|
157
|
+
headers: { accept: "application/json", "user-agent": userAgent },
|
|
158
|
+
signal: AbortSignal.timeout(2500),
|
|
159
|
+
});
|
|
160
|
+
if (!response.ok) throw new Error(`npm registry returned ${response.status}`);
|
|
161
|
+
const metadata = await response.json();
|
|
162
|
+
latestVersion = String(metadata?.["dist-tags"]?.[distTag] || "").trim() || null;
|
|
163
|
+
} catch (error) {
|
|
164
|
+
registryError = error instanceof Error ? error.message : String(error);
|
|
165
|
+
if (!allowRegistryFailure) {
|
|
166
|
+
throw new CliError(`Unable to check npm for updates: ${registryError}`, {
|
|
167
|
+
code: "update_check_failed",
|
|
168
|
+
exitCode: EXIT.REMOTE,
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
const minimum = String(policy.minimum_supported_version || "").trim() || null;
|
|
173
|
+
return {
|
|
174
|
+
package: packageName,
|
|
175
|
+
channel: String(policy.channel || distTag),
|
|
176
|
+
dist_tag: distTag,
|
|
177
|
+
current_version: currentVersion,
|
|
178
|
+
latest_published_version: latestVersion,
|
|
179
|
+
reviewed_version: String(policy.reviewed_version || "").trim() || null,
|
|
180
|
+
minimum_supported_version: minimum,
|
|
181
|
+
update_available: Boolean(latestVersion && compareVersions(latestVersion, currentVersion) > 0),
|
|
182
|
+
supported: minimum ? compareVersions(currentVersion, minimum) >= 0 : true,
|
|
183
|
+
install_command: String(policy.install_command || `npm install --global ${packageName}@${distTag}`),
|
|
184
|
+
automatic_install: false,
|
|
185
|
+
...(registryError ? { registry_error: registryError } : {}),
|
|
186
|
+
};
|
|
187
|
+
}
|
|
188
|
+
|
|
108
189
|
export class ApiClient {
|
|
109
|
-
constructor({ baseUrl, credential = null, userAgent, fetchImpl = globalThis.fetch }) {
|
|
190
|
+
constructor({ baseUrl, credential = null, workspaceId = null, userAgent, fetchImpl = globalThis.fetch }) {
|
|
110
191
|
this.baseUrl = baseUrl.replace(/\/+$/, "");
|
|
111
192
|
this.credential = credential;
|
|
193
|
+
this.workspaceId = String(workspaceId || "").trim() || null;
|
|
112
194
|
this.userAgent = userAgent;
|
|
113
195
|
this.fetchImpl = fetchImpl;
|
|
114
196
|
}
|
|
@@ -129,6 +211,10 @@ export class ApiClient {
|
|
|
129
211
|
throw new CliError("Corvio authentication is required.", { code: "auth_required", exitCode: EXIT.AUTH });
|
|
130
212
|
}
|
|
131
213
|
requestHeaders.authorization = `Bearer ${this.credential}`;
|
|
214
|
+
if (this.workspaceId) {
|
|
215
|
+
requestHeaders["X-Corvio-Workspace-Id"] = this.workspaceId;
|
|
216
|
+
requestHeaders["X-Corvio-Workspace-Context-Contract"] = "required-v1";
|
|
217
|
+
}
|
|
132
218
|
}
|
|
133
219
|
let response;
|
|
134
220
|
try {
|
|
@@ -179,6 +265,10 @@ export class ApiClient {
|
|
|
179
265
|
throw new CliError("Corvio authentication is required.", { code: "auth_required", exitCode: EXIT.AUTH });
|
|
180
266
|
}
|
|
181
267
|
requestHeaders.authorization = `Bearer ${this.credential}`;
|
|
268
|
+
if (this.workspaceId) {
|
|
269
|
+
requestHeaders["X-Corvio-Workspace-Id"] = this.workspaceId;
|
|
270
|
+
requestHeaders["X-Corvio-Workspace-Context-Contract"] = "required-v1";
|
|
271
|
+
}
|
|
182
272
|
}
|
|
183
273
|
let response;
|
|
184
274
|
try {
|
|
@@ -208,6 +298,31 @@ export class ApiClient {
|
|
|
208
298
|
}
|
|
209
299
|
return bytes;
|
|
210
300
|
}
|
|
301
|
+
|
|
302
|
+
async putUrl(url, content, { headers = {} } = {}) {
|
|
303
|
+
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
|
+
});
|
|
315
|
+
}
|
|
316
|
+
if (!response.ok) {
|
|
317
|
+
const message = (await response.text()).slice(0, 500);
|
|
318
|
+
throw new CliError(message || `File upload failed (${response.status}).`, {
|
|
319
|
+
code: "upload_failed",
|
|
320
|
+
status: response.status,
|
|
321
|
+
exitCode: EXIT.REMOTE,
|
|
322
|
+
});
|
|
323
|
+
}
|
|
324
|
+
return { ok: true, status: response.status };
|
|
325
|
+
}
|
|
211
326
|
}
|
|
212
327
|
|
|
213
328
|
function configRoot(env = process.env) {
|
|
@@ -220,6 +335,10 @@ export function workspaceCredentialPath(env = process.env) {
|
|
|
220
335
|
return join(configRoot(env), "credentials.json");
|
|
221
336
|
}
|
|
222
337
|
|
|
338
|
+
export function workspaceContextPath(env = process.env) {
|
|
339
|
+
return join(configRoot(env), "workspace-context.json");
|
|
340
|
+
}
|
|
341
|
+
|
|
223
342
|
export function adminCredentialPath(env = process.env) {
|
|
224
343
|
return join(configRoot(env), "admin-credentials.json");
|
|
225
344
|
}
|
|
@@ -227,8 +346,8 @@ export function adminCredentialPath(env = process.env) {
|
|
|
227
346
|
export async function readWorkspaceCredential(env = process.env) {
|
|
228
347
|
const environmentKey = String(env.CORVIO_API_KEY || "").trim();
|
|
229
348
|
if (environmentKey) {
|
|
230
|
-
if (!environmentKey.startsWith("cvk_")) {
|
|
231
|
-
throw new CliError("CORVIO_API_KEY must be a Workspace cvk_ key.", {
|
|
349
|
+
if (!environmentKey.startsWith("cvu_") && !environmentKey.startsWith("cvk_")) {
|
|
350
|
+
throw new CliError("CORVIO_API_KEY must be a user cvu_ key or legacy Workspace cvk_ key.", {
|
|
232
351
|
code: "invalid_workspace_key",
|
|
233
352
|
exitCode: EXIT.AUTH,
|
|
234
353
|
});
|
|
@@ -245,14 +364,15 @@ export async function readWorkspaceCredential(env = process.env) {
|
|
|
245
364
|
}
|
|
246
365
|
const profileName = String(parsed.active_profile || "default");
|
|
247
366
|
const profile = parsed.profiles?.[profileName];
|
|
248
|
-
const
|
|
249
|
-
|
|
367
|
+
const metadata = String(parsed.api_key || "").trim() ? parsed : profile;
|
|
368
|
+
const key = String(metadata?.api_key || "").trim();
|
|
369
|
+
if (!key.startsWith("cvu_") && !key.startsWith("cvk_")) {
|
|
250
370
|
throw new CliError("Stored Corvio credentials are invalid; run `corvio auth login` again.", {
|
|
251
371
|
code: "credential_store_invalid",
|
|
252
372
|
exitCode: EXIT.AUTH,
|
|
253
373
|
});
|
|
254
374
|
}
|
|
255
|
-
return { key, source: "device_link", profile: profileName, metadata
|
|
375
|
+
return { key, source: "device_link", profile: profileName, metadata };
|
|
256
376
|
}
|
|
257
377
|
|
|
258
378
|
export async function storeWorkspaceCredential(payload, env = process.env) {
|
|
@@ -260,28 +380,73 @@ export async function storeWorkspaceCredential(payload, env = process.env) {
|
|
|
260
380
|
const root = dirname(path);
|
|
261
381
|
await mkdir(root, { recursive: true, mode: 0o700 });
|
|
262
382
|
await chmod(root, 0o700).catch(() => {});
|
|
263
|
-
const
|
|
383
|
+
const document = {
|
|
384
|
+
version: 2,
|
|
385
|
+
api_key: payload.secret,
|
|
386
|
+
key_id: payload.key_id,
|
|
387
|
+
audience: payload.audience || "user",
|
|
388
|
+
scopes: payload.scopes || [],
|
|
389
|
+
expires_at: payload.expires_at || null,
|
|
390
|
+
created_at: payload.created_at || new Date().toISOString(),
|
|
391
|
+
source: "device_link",
|
|
392
|
+
};
|
|
393
|
+
const temporary = `${path}.${randomUUID()}.tmp`;
|
|
394
|
+
await writeFile(temporary, `${JSON.stringify(document, null, 2)}\n`, { mode: 0o600, flag: "wx" });
|
|
395
|
+
await chmod(temporary, 0o600);
|
|
396
|
+
await rename(temporary, path);
|
|
397
|
+
await chmod(path, 0o600);
|
|
398
|
+
return { path, profile: "default" };
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
export async function readWorkspaceContext(env = process.env) {
|
|
402
|
+
const environmentWorkspaceId = String(env.CORVIO_WORKSPACE_ID || "").trim();
|
|
403
|
+
if (environmentWorkspaceId) {
|
|
404
|
+
return { workspaceId: environmentWorkspaceId, workspaceName: null, source: "environment" };
|
|
405
|
+
}
|
|
406
|
+
const path = workspaceContextPath(env);
|
|
407
|
+
try {
|
|
408
|
+
const parsed = JSON.parse(await readFile(path, "utf8"));
|
|
409
|
+
const workspaceId = String(parsed.active_workspace_id || "").trim();
|
|
410
|
+
if (!workspaceId) return null;
|
|
411
|
+
return {
|
|
412
|
+
workspaceId,
|
|
413
|
+
workspaceName: String(parsed.active_workspace_name || "").trim() || null,
|
|
414
|
+
source: "stored",
|
|
415
|
+
};
|
|
416
|
+
} catch (cause) {
|
|
417
|
+
if (cause?.code === "ENOENT") return null;
|
|
418
|
+
throw new CliError("Stored Corvio Workspace selection is unreadable.", {
|
|
419
|
+
code: "workspace_context_store_invalid",
|
|
420
|
+
exitCode: EXIT.AUTH,
|
|
421
|
+
});
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
export async function storeWorkspaceContext({ workspaceId, workspaceName = null }, env = process.env) {
|
|
426
|
+
const normalizedId = String(workspaceId || "").trim();
|
|
427
|
+
if (!normalizedId) throw new CliError("Workspace ID is required.", { code: "workspace_selection_required" });
|
|
428
|
+
const path = workspaceContextPath(env);
|
|
429
|
+
const root = dirname(path);
|
|
430
|
+
await mkdir(root, { recursive: true, mode: 0o700 });
|
|
431
|
+
await chmod(root, 0o700).catch(() => {});
|
|
264
432
|
const document = {
|
|
265
433
|
version: 1,
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
api_key: payload.secret,
|
|
270
|
-
key_id: payload.key_id,
|
|
271
|
-
workspace_id: payload.workspace_id,
|
|
272
|
-
workspace_name: payload.workspace_name,
|
|
273
|
-
scopes: payload.scopes || [],
|
|
274
|
-
created_at: payload.created_at || new Date().toISOString(),
|
|
275
|
-
source: "device_link",
|
|
276
|
-
},
|
|
277
|
-
},
|
|
434
|
+
active_workspace_id: normalizedId,
|
|
435
|
+
active_workspace_name: String(workspaceName || "").trim() || null,
|
|
436
|
+
updated_at: new Date().toISOString(),
|
|
278
437
|
};
|
|
279
438
|
const temporary = `${path}.${randomUUID()}.tmp`;
|
|
280
439
|
await writeFile(temporary, `${JSON.stringify(document, null, 2)}\n`, { mode: 0o600, flag: "wx" });
|
|
281
440
|
await chmod(temporary, 0o600);
|
|
282
441
|
await rename(temporary, path);
|
|
283
442
|
await chmod(path, 0o600);
|
|
284
|
-
return { path,
|
|
443
|
+
return { path, workspaceId: normalizedId };
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
export async function clearWorkspaceContext(env = process.env) {
|
|
447
|
+
const path = workspaceContextPath(env);
|
|
448
|
+
await rm(path, { force: true });
|
|
449
|
+
return path;
|
|
285
450
|
}
|
|
286
451
|
|
|
287
452
|
export async function clearWorkspaceCredential(env = process.env) {
|
|
@@ -388,13 +553,24 @@ export async function openBrowser(url) {
|
|
|
388
553
|
const target = String(url);
|
|
389
554
|
const command = platform() === "darwin" ? "open" : platform() === "win32" ? "cmd" : "xdg-open";
|
|
390
555
|
const args = platform() === "win32" ? ["/c", "start", "", target] : [target];
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
556
|
+
return new Promise((resolvePromise) => {
|
|
557
|
+
let settled = false;
|
|
558
|
+
const finish = (opened) => {
|
|
559
|
+
if (settled) return;
|
|
560
|
+
settled = true;
|
|
561
|
+
resolvePromise(opened);
|
|
562
|
+
};
|
|
563
|
+
try {
|
|
564
|
+
const child = spawn(command, args, { detached: true, stdio: "ignore" });
|
|
565
|
+
child.once("error", () => finish(false));
|
|
566
|
+
child.once("spawn", () => {
|
|
567
|
+
child.unref();
|
|
568
|
+
finish(true);
|
|
569
|
+
});
|
|
570
|
+
} catch {
|
|
571
|
+
finish(false);
|
|
572
|
+
}
|
|
573
|
+
});
|
|
398
574
|
}
|
|
399
575
|
|
|
400
576
|
export async function sleep(milliseconds) {
|