@arrhq/crate-cli 0.1.0 → 0.2.1
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 +21 -10
- package/package.json +5 -5
- package/src/cli.mjs +936 -26
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# @arrhq/crate-cli
|
|
2
2
|
|
|
3
|
-
Crate
|
|
3
|
+
Crate をCLIで操作するための公開向けクライアントです。
|
|
4
4
|
|
|
5
5
|
## Install
|
|
6
6
|
|
|
@@ -10,14 +10,31 @@ npm i -g @arrhq/crate-cli
|
|
|
10
10
|
|
|
11
11
|
## Configure
|
|
12
12
|
|
|
13
|
-
|
|
13
|
+
推奨(一般ユーザー):
|
|
14
14
|
|
|
15
|
-
|
|
16
|
-
|
|
15
|
+
```bash
|
|
16
|
+
crate auth login --crate-url https://crateio.com
|
|
17
|
+
crate auth status
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
認証トークン保存:
|
|
21
|
+
|
|
22
|
+
- macOS: Keychain(`security` コマンド)を優先
|
|
23
|
+
- 上記が使えない環境: `~/.config/crate-cli/auth.json`(`0600`)へフォールバック
|
|
24
|
+
|
|
25
|
+
CI/ヘッドレス向け(補助導線):
|
|
26
|
+
|
|
27
|
+
```bash
|
|
28
|
+
export CRATE_URL="https://crateio.com"
|
|
29
|
+
export CRATE_TOKEN="<crate_mt_...>"
|
|
30
|
+
export CRATE_PROJECT_ID="<project_uuid>" # optional
|
|
31
|
+
```
|
|
17
32
|
|
|
18
33
|
## Usage
|
|
19
34
|
|
|
20
35
|
```bash
|
|
36
|
+
crate auth login
|
|
37
|
+
crate auth status
|
|
21
38
|
crate tool list
|
|
22
39
|
crate tool call --name list_project_tasks --args-json '{"status":"open","limit":20}'
|
|
23
40
|
crate tasks list --status in_progress --limit 50
|
|
@@ -26,9 +43,3 @@ crate tasks update --task-id <task_uuid> --status in_progress
|
|
|
26
43
|
crate checkpoint status --client-event-id <checkpoint_uuid>
|
|
27
44
|
crate tasks close --task-id <task_uuid> --client-event-id <checkpoint_uuid>
|
|
28
45
|
```
|
|
29
|
-
|
|
30
|
-
## Release flow
|
|
31
|
-
|
|
32
|
-
1. CLIの変更PRで `.changeset/*.md` を追加する(`pnpm changeset`)。
|
|
33
|
-
2. `main` へのマージ後、GitHub Actions `Crate CLI Release` が Release PR を作成する。
|
|
34
|
-
3. Release PR をマージすると npm publish が実行される(Trusted Publisher/OIDC 前提)。
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@arrhq/crate-cli",
|
|
3
|
-
"version": "0.1
|
|
3
|
+
"version": "0.2.1",
|
|
4
4
|
"description": "Crate MCP CLI for fine-grained task/checkpoint/tool operations",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -12,11 +12,11 @@
|
|
|
12
12
|
"src",
|
|
13
13
|
"README.md"
|
|
14
14
|
],
|
|
15
|
+
"engines": {
|
|
16
|
+
"node": ">=20"
|
|
17
|
+
},
|
|
15
18
|
"scripts": {
|
|
16
19
|
"crate": "node ./bin/crate.mjs",
|
|
17
20
|
"test": "node --test ../../scripts/__tests__/crate-cli.test.mjs"
|
|
18
|
-
},
|
|
19
|
-
"engines": {
|
|
20
|
-
"node": ">=20"
|
|
21
21
|
}
|
|
22
|
-
}
|
|
22
|
+
}
|
package/src/cli.mjs
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
3
|
import { execFile } from "node:child_process";
|
|
4
|
-
import { randomUUID } from "node:crypto";
|
|
5
|
-
import { readFile } from "node:fs/promises";
|
|
4
|
+
import { createHash, randomBytes, randomUUID } from "node:crypto";
|
|
5
|
+
import { mkdir, readFile, unlink, writeFile } from "node:fs/promises";
|
|
6
|
+
import { createServer } from "node:http";
|
|
6
7
|
import { homedir } from "node:os";
|
|
7
8
|
import { join } from "node:path";
|
|
8
9
|
import { pathToFileURL } from "node:url";
|
|
@@ -11,6 +12,15 @@ import { promisify } from "node:util";
|
|
|
11
12
|
const DEFAULT_TIMEOUT_MS = 20_000;
|
|
12
13
|
const DEFAULT_MAX_WAIT_SECONDS = 180;
|
|
13
14
|
const DEFAULT_POLL_INTERVAL_SECONDS = 3;
|
|
15
|
+
const DEFAULT_CRATE_URL = "https://crateio.com";
|
|
16
|
+
const DEFAULT_OAUTH_CLIENT_ID = "crate-cli-public";
|
|
17
|
+
const DEFAULT_OAUTH_SCOPES = "snapshot:write tasks:write tasks:read";
|
|
18
|
+
const DEFAULT_AUTH_CALLBACK_TIMEOUT_SECONDS = 180;
|
|
19
|
+
const AUTH_STORE_PATH = join(homedir(), ".config", "crate-cli", "auth.json");
|
|
20
|
+
const AUTH_STORE_VERSION = 2;
|
|
21
|
+
const AUTH_KEYCHAIN_SERVICE = "arrhq.crate-cli";
|
|
22
|
+
const AUTH_KEYCHAIN_ACCOUNT_PREFIX = "oauth-session:";
|
|
23
|
+
const AUTH_CALLBACK_PATH = "/oauth/callback";
|
|
14
24
|
|
|
15
25
|
const UUID_PATTERN =
|
|
16
26
|
/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
|
@@ -27,6 +37,9 @@ const HELP_TEXT = `Usage:
|
|
|
27
37
|
crate <command> [options]
|
|
28
38
|
|
|
29
39
|
Commands:
|
|
40
|
+
auth login
|
|
41
|
+
auth status
|
|
42
|
+
auth logout
|
|
30
43
|
tool list
|
|
31
44
|
tool call
|
|
32
45
|
tasks list
|
|
@@ -36,6 +49,8 @@ Commands:
|
|
|
36
49
|
checkpoint status
|
|
37
50
|
|
|
38
51
|
Examples:
|
|
52
|
+
crate auth login
|
|
53
|
+
crate auth status
|
|
39
54
|
crate tool list
|
|
40
55
|
crate tool call --name list_project_tasks --args-json '{"status":"open","limit":20}'
|
|
41
56
|
crate tasks list --status in_progress --limit 50
|
|
@@ -45,29 +60,53 @@ Examples:
|
|
|
45
60
|
crate checkpoint status --client-event-id <checkpoint_uuid>
|
|
46
61
|
|
|
47
62
|
Global options:
|
|
48
|
-
--
|
|
49
|
-
--
|
|
50
|
-
--
|
|
63
|
+
--crate-url <url> Crate URL (e.g. https://crateio.com)
|
|
64
|
+
--token <token> Crate API token
|
|
65
|
+
--project-id <uuid> Default project_id for endpoint resolution
|
|
66
|
+
--server-name <name> mcp server key in ~/.codex/config.toml (optional)
|
|
67
|
+
--mcp-url <url> Legacy: MCP endpoint URL
|
|
68
|
+
--mcp-ingest-token <token> Legacy: MCP token (x-crate-ingest-token)
|
|
51
69
|
--timeout-ms <ms> RPC timeout (default: 20000)
|
|
52
70
|
--json JSON output
|
|
53
71
|
--help Show help`;
|
|
54
72
|
|
|
55
73
|
const COMMAND_HELP = {
|
|
74
|
+
"auth login": `Usage: crate auth login [options]
|
|
75
|
+
Options:
|
|
76
|
+
--crate-url <url> default: https://crateio.com
|
|
77
|
+
--project-id <uuid> optional project hint
|
|
78
|
+
--scope "<scopes>" default: "snapshot:write tasks:write tasks:read"
|
|
79
|
+
--client-id <id> default: crate-cli-public
|
|
80
|
+
--callback-timeout-seconds <n> default: 180
|
|
81
|
+
--no-browser print URL without launching browser
|
|
82
|
+
--json`,
|
|
83
|
+
"auth status": `Usage: crate auth status [options]
|
|
84
|
+
Options:
|
|
85
|
+
--json`,
|
|
86
|
+
"auth logout": `Usage: crate auth logout [options]
|
|
87
|
+
Options:
|
|
88
|
+
--json`,
|
|
56
89
|
"tool list": `Usage: crate tool list [options]
|
|
57
90
|
Options:
|
|
91
|
+
--crate-url <url>
|
|
92
|
+
--token <token>
|
|
93
|
+
--project-id <uuid>
|
|
94
|
+
--server-name <name>
|
|
58
95
|
--mcp-url <url>
|
|
59
96
|
--mcp-ingest-token <token>
|
|
60
|
-
--server-name <name>
|
|
61
97
|
--timeout-ms <ms>
|
|
62
98
|
--json`,
|
|
63
99
|
"tool call": `Usage: crate tool call --name <tool> [options]
|
|
64
100
|
Options:
|
|
65
|
-
--name <tool_name>
|
|
101
|
+
--name <tool_name> Crate tool name (required)
|
|
66
102
|
--args-json <json> Tool arguments as JSON object (default: {})
|
|
67
103
|
--args-file <path> Path to JSON file for tool arguments
|
|
104
|
+
--crate-url <url>
|
|
105
|
+
--token <token>
|
|
106
|
+
--project-id <uuid>
|
|
107
|
+
--server-name <name>
|
|
68
108
|
--mcp-url <url>
|
|
69
109
|
--mcp-ingest-token <token>
|
|
70
|
-
--server-name <name>
|
|
71
110
|
--timeout-ms <ms>
|
|
72
111
|
--json`,
|
|
73
112
|
"tasks list": `Usage: crate tasks list [options]
|
|
@@ -75,9 +114,11 @@ Options:
|
|
|
75
114
|
--project-id <uuid>
|
|
76
115
|
--status <open|in_progress|close|canceled>
|
|
77
116
|
--limit <n>
|
|
117
|
+
--crate-url <url>
|
|
118
|
+
--token <token>
|
|
119
|
+
--server-name <name>
|
|
78
120
|
--mcp-url <url>
|
|
79
121
|
--mcp-ingest-token <token>
|
|
80
|
-
--server-name <name>
|
|
81
122
|
--timeout-ms <ms>
|
|
82
123
|
--json`,
|
|
83
124
|
"tasks create": `Usage: crate tasks create --title <text> [options]
|
|
@@ -93,9 +134,11 @@ Options:
|
|
|
93
134
|
--context-label <label>
|
|
94
135
|
--actor-context <text>
|
|
95
136
|
--source-context <text>
|
|
137
|
+
--crate-url <url>
|
|
138
|
+
--token <token>
|
|
139
|
+
--server-name <name>
|
|
96
140
|
--mcp-url <url>
|
|
97
141
|
--mcp-ingest-token <token>
|
|
98
|
-
--server-name <name>
|
|
99
142
|
--timeout-ms <ms>
|
|
100
143
|
--json`,
|
|
101
144
|
"tasks update": `Usage: crate tasks update --task-id <uuid> [options]
|
|
@@ -113,9 +156,11 @@ Options:
|
|
|
113
156
|
--context-label <label> required fallback when status=close and client_event_id omitted
|
|
114
157
|
--actor-context <text>
|
|
115
158
|
--source-context <text>
|
|
159
|
+
--crate-url <url>
|
|
160
|
+
--token <token>
|
|
161
|
+
--server-name <name>
|
|
116
162
|
--mcp-url <url>
|
|
117
163
|
--mcp-ingest-token <token>
|
|
118
|
-
--server-name <name>
|
|
119
164
|
--timeout-ms <ms>
|
|
120
165
|
--json`,
|
|
121
166
|
"tasks close": `Usage: crate tasks close --task-id <uuid> [options]
|
|
@@ -130,9 +175,11 @@ Options:
|
|
|
130
175
|
--github-repo-full-name <owner/repo>
|
|
131
176
|
--max-wait-seconds <n> default: 180
|
|
132
177
|
--poll-interval-seconds <n> default: 3
|
|
178
|
+
--crate-url <url>
|
|
179
|
+
--token <token>
|
|
180
|
+
--server-name <name>
|
|
133
181
|
--mcp-url <url>
|
|
134
182
|
--mcp-ingest-token <token>
|
|
135
|
-
--server-name <name>
|
|
136
183
|
--timeout-ms <ms>
|
|
137
184
|
--json`,
|
|
138
185
|
"checkpoint status": `Usage: crate checkpoint status [options]
|
|
@@ -141,9 +188,11 @@ Options:
|
|
|
141
188
|
--session-id <id> Required when client_event_id omitted
|
|
142
189
|
--context-label <label> Required when client_event_id omitted
|
|
143
190
|
--project-id <uuid>
|
|
191
|
+
--crate-url <url>
|
|
192
|
+
--token <token>
|
|
193
|
+
--server-name <name>
|
|
144
194
|
--mcp-url <url>
|
|
145
195
|
--mcp-ingest-token <token>
|
|
146
|
-
--server-name <name>
|
|
147
196
|
--timeout-ms <ms>
|
|
148
197
|
--json`,
|
|
149
198
|
};
|
|
@@ -289,7 +338,540 @@ const parseFlags = (argv, schema, defaults = {}) => {
|
|
|
289
338
|
return { options, seenFlags };
|
|
290
339
|
};
|
|
291
340
|
|
|
341
|
+
const parseAuthStore = (raw) => {
|
|
342
|
+
if (typeof raw !== "string" || !raw.trim()) return null;
|
|
343
|
+
try {
|
|
344
|
+
const parsed = JSON.parse(raw);
|
|
345
|
+
if (!parsed || typeof parsed !== "object") return null;
|
|
346
|
+
const current = parsed.current;
|
|
347
|
+
if (!current || typeof current !== "object") return null;
|
|
348
|
+
return {
|
|
349
|
+
current: {
|
|
350
|
+
crate_url: toTrimmed(current.crate_url),
|
|
351
|
+
project_id: toTrimmed(current.project_id),
|
|
352
|
+
client_id: toTrimmed(current.client_id),
|
|
353
|
+
token_endpoint: toTrimmed(current.token_endpoint),
|
|
354
|
+
authorization_endpoint: toTrimmed(current.authorization_endpoint),
|
|
355
|
+
access_token: toTrimmed(current.access_token),
|
|
356
|
+
refresh_token: toTrimmed(current.refresh_token),
|
|
357
|
+
token_type: toTrimmed(current.token_type),
|
|
358
|
+
scope: toTrimmed(current.scope),
|
|
359
|
+
expires_at: toTrimmed(current.expires_at),
|
|
360
|
+
created_at: toTrimmed(current.created_at),
|
|
361
|
+
updated_at: toTrimmed(current.updated_at),
|
|
362
|
+
credential_key: toTrimmed(current.credential_key),
|
|
363
|
+
storage: toTrimmed(current.storage),
|
|
364
|
+
access_token: toTrimmed(current.access_token),
|
|
365
|
+
refresh_token: toTrimmed(current.refresh_token),
|
|
366
|
+
},
|
|
367
|
+
};
|
|
368
|
+
} catch {
|
|
369
|
+
return null;
|
|
370
|
+
}
|
|
371
|
+
};
|
|
372
|
+
|
|
373
|
+
const buildAuthCredentialKey = (session) => {
|
|
374
|
+
const crateUrl = normalizeCrateUrl(session?.crate_url);
|
|
375
|
+
const origin = toOriginFromUrl(crateUrl || session?.crate_url || "");
|
|
376
|
+
const projectId = toTrimmed(session?.project_id) || "-";
|
|
377
|
+
const clientId = toTrimmed(session?.client_id) || DEFAULT_OAUTH_CLIENT_ID;
|
|
378
|
+
if (!origin) return "";
|
|
379
|
+
return `${origin}|${projectId}|${clientId}`;
|
|
380
|
+
};
|
|
381
|
+
|
|
382
|
+
const buildAuthKeychainAccount = (credentialKey) => {
|
|
383
|
+
const normalized = toTrimmed(credentialKey);
|
|
384
|
+
if (!normalized) return "";
|
|
385
|
+
const digest = createHash("sha256").update(normalized).digest("hex");
|
|
386
|
+
return `${AUTH_KEYCHAIN_ACCOUNT_PREFIX}${digest}`;
|
|
387
|
+
};
|
|
388
|
+
|
|
389
|
+
const parseKeychainSecret = (raw) => {
|
|
390
|
+
if (typeof raw !== "string" || raw.trim() === "") return null;
|
|
391
|
+
try {
|
|
392
|
+
const parsed = JSON.parse(raw);
|
|
393
|
+
if (!parsed || typeof parsed !== "object") return null;
|
|
394
|
+
const accessToken = toTrimmed(parsed.access_token);
|
|
395
|
+
const refreshToken = toTrimmed(parsed.refresh_token);
|
|
396
|
+
if (!accessToken) return null;
|
|
397
|
+
return {
|
|
398
|
+
access_token: accessToken,
|
|
399
|
+
refresh_token: refreshToken,
|
|
400
|
+
};
|
|
401
|
+
} catch {
|
|
402
|
+
return null;
|
|
403
|
+
}
|
|
404
|
+
};
|
|
405
|
+
|
|
406
|
+
const isDarwinKeychainAvailable = async () => {
|
|
407
|
+
if (process.platform !== "darwin") return false;
|
|
408
|
+
try {
|
|
409
|
+
await execFileAsync("security", ["help"], { timeout: 5_000, maxBuffer: 64 * 1024 });
|
|
410
|
+
return true;
|
|
411
|
+
} catch {
|
|
412
|
+
return false;
|
|
413
|
+
}
|
|
414
|
+
};
|
|
415
|
+
|
|
416
|
+
const readDarwinKeychainSecret = async (account) => {
|
|
417
|
+
if (!account) return null;
|
|
418
|
+
try {
|
|
419
|
+
const { stdout } = await execFileAsync(
|
|
420
|
+
"security",
|
|
421
|
+
["find-generic-password", "-s", AUTH_KEYCHAIN_SERVICE, "-a", account, "-w"],
|
|
422
|
+
{ timeout: 10_000, maxBuffer: 128 * 1024 },
|
|
423
|
+
);
|
|
424
|
+
return parseKeychainSecret(stdout);
|
|
425
|
+
} catch {
|
|
426
|
+
return null;
|
|
427
|
+
}
|
|
428
|
+
};
|
|
429
|
+
|
|
430
|
+
const writeDarwinKeychainSecret = async (account, secretText) => {
|
|
431
|
+
if (!account || !secretText) return false;
|
|
432
|
+
try {
|
|
433
|
+
await execFileAsync(
|
|
434
|
+
"security",
|
|
435
|
+
[
|
|
436
|
+
"add-generic-password",
|
|
437
|
+
"-U",
|
|
438
|
+
"-s",
|
|
439
|
+
AUTH_KEYCHAIN_SERVICE,
|
|
440
|
+
"-a",
|
|
441
|
+
account,
|
|
442
|
+
"-w",
|
|
443
|
+
secretText,
|
|
444
|
+
],
|
|
445
|
+
{ timeout: 10_000, maxBuffer: 64 * 1024 },
|
|
446
|
+
);
|
|
447
|
+
return true;
|
|
448
|
+
} catch {
|
|
449
|
+
return false;
|
|
450
|
+
}
|
|
451
|
+
};
|
|
452
|
+
|
|
453
|
+
const deleteDarwinKeychainSecret = async (account) => {
|
|
454
|
+
if (!account) return;
|
|
455
|
+
try {
|
|
456
|
+
await execFileAsync(
|
|
457
|
+
"security",
|
|
458
|
+
["delete-generic-password", "-s", AUTH_KEYCHAIN_SERVICE, "-a", account],
|
|
459
|
+
{ timeout: 10_000, maxBuffer: 64 * 1024 },
|
|
460
|
+
);
|
|
461
|
+
} catch {
|
|
462
|
+
// Ignore missing keychain entry errors.
|
|
463
|
+
}
|
|
464
|
+
};
|
|
465
|
+
|
|
466
|
+
const readAuthStore = async () => {
|
|
467
|
+
try {
|
|
468
|
+
const raw = await readFile(AUTH_STORE_PATH, "utf8");
|
|
469
|
+
const parsed = parseAuthStore(raw);
|
|
470
|
+
if (!parsed?.current) return null;
|
|
471
|
+
|
|
472
|
+
const current = parsed.current;
|
|
473
|
+
const credentialKey = current.credential_key || buildAuthCredentialKey(current);
|
|
474
|
+
const account = buildAuthKeychainAccount(credentialKey);
|
|
475
|
+
const keychainEnabled = await isDarwinKeychainAvailable();
|
|
476
|
+
|
|
477
|
+
let tokens = null;
|
|
478
|
+
if (keychainEnabled) {
|
|
479
|
+
tokens = await readDarwinKeychainSecret(account);
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
// Backward-compatibility for previously file-stored secrets.
|
|
483
|
+
const legacyAccessToken = toTrimmed(current.access_token);
|
|
484
|
+
const legacyRefreshToken = toTrimmed(current.refresh_token);
|
|
485
|
+
if (!tokens && legacyAccessToken) {
|
|
486
|
+
tokens = {
|
|
487
|
+
access_token: legacyAccessToken,
|
|
488
|
+
refresh_token: legacyRefreshToken,
|
|
489
|
+
};
|
|
490
|
+
|
|
491
|
+
if (keychainEnabled) {
|
|
492
|
+
const migrated = await writeDarwinKeychainSecret(
|
|
493
|
+
account,
|
|
494
|
+
JSON.stringify(tokens),
|
|
495
|
+
);
|
|
496
|
+
if (migrated) {
|
|
497
|
+
await writeAuthStore({
|
|
498
|
+
current: {
|
|
499
|
+
...current,
|
|
500
|
+
credential_key: credentialKey,
|
|
501
|
+
access_token: tokens.access_token,
|
|
502
|
+
refresh_token: tokens.refresh_token,
|
|
503
|
+
},
|
|
504
|
+
});
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
return {
|
|
510
|
+
current: {
|
|
511
|
+
crate_url: current.crate_url,
|
|
512
|
+
project_id: current.project_id,
|
|
513
|
+
client_id: current.client_id,
|
|
514
|
+
token_endpoint: current.token_endpoint,
|
|
515
|
+
authorization_endpoint: current.authorization_endpoint,
|
|
516
|
+
token_type: current.token_type,
|
|
517
|
+
scope: current.scope,
|
|
518
|
+
expires_at: current.expires_at,
|
|
519
|
+
created_at: current.created_at,
|
|
520
|
+
updated_at: current.updated_at,
|
|
521
|
+
credential_key: credentialKey,
|
|
522
|
+
storage: keychainEnabled ? "keychain" : "file",
|
|
523
|
+
access_token: tokens?.access_token ?? "",
|
|
524
|
+
refresh_token: tokens?.refresh_token ?? "",
|
|
525
|
+
},
|
|
526
|
+
};
|
|
527
|
+
} catch {
|
|
528
|
+
return null;
|
|
529
|
+
}
|
|
530
|
+
};
|
|
531
|
+
|
|
532
|
+
const writeAuthStore = async (store) => {
|
|
533
|
+
const current = store?.current;
|
|
534
|
+
if (!current || typeof current !== "object") {
|
|
535
|
+
throw new Error("Invalid auth store payload.");
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
const accessToken = toTrimmed(current.access_token);
|
|
539
|
+
const refreshToken = toTrimmed(current.refresh_token);
|
|
540
|
+
const credentialKey = current.credential_key || buildAuthCredentialKey(current);
|
|
541
|
+
const account = buildAuthKeychainAccount(credentialKey);
|
|
542
|
+
const keychainEnabled = await isDarwinKeychainAvailable();
|
|
543
|
+
|
|
544
|
+
let storage = "file";
|
|
545
|
+
if (keychainEnabled && accessToken) {
|
|
546
|
+
const saved = await writeDarwinKeychainSecret(
|
|
547
|
+
account,
|
|
548
|
+
JSON.stringify({
|
|
549
|
+
access_token: accessToken,
|
|
550
|
+
refresh_token: refreshToken,
|
|
551
|
+
}),
|
|
552
|
+
);
|
|
553
|
+
if (saved) {
|
|
554
|
+
storage = "keychain";
|
|
555
|
+
}
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
const dir = join(homedir(), ".config", "crate-cli");
|
|
559
|
+
await mkdir(dir, { recursive: true });
|
|
560
|
+
|
|
561
|
+
const sanitized = {
|
|
562
|
+
version: AUTH_STORE_VERSION,
|
|
563
|
+
current: {
|
|
564
|
+
crate_url: toTrimmed(current.crate_url),
|
|
565
|
+
project_id: toTrimmed(current.project_id),
|
|
566
|
+
client_id: toTrimmed(current.client_id),
|
|
567
|
+
token_endpoint: toTrimmed(current.token_endpoint),
|
|
568
|
+
authorization_endpoint: toTrimmed(current.authorization_endpoint),
|
|
569
|
+
token_type: toTrimmed(current.token_type),
|
|
570
|
+
scope: toTrimmed(current.scope),
|
|
571
|
+
expires_at: toTrimmed(current.expires_at),
|
|
572
|
+
created_at: toTrimmed(current.created_at),
|
|
573
|
+
updated_at: toTrimmed(current.updated_at),
|
|
574
|
+
credential_key: credentialKey,
|
|
575
|
+
storage,
|
|
576
|
+
},
|
|
577
|
+
};
|
|
578
|
+
|
|
579
|
+
if (storage !== "keychain") {
|
|
580
|
+
sanitized.current.access_token = accessToken;
|
|
581
|
+
sanitized.current.refresh_token = refreshToken;
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
const body = `${JSON.stringify(sanitized, null, 2)}\n`;
|
|
585
|
+
await writeFile(AUTH_STORE_PATH, body, { mode: 0o600 });
|
|
586
|
+
return { storage };
|
|
587
|
+
};
|
|
588
|
+
|
|
589
|
+
const clearAuthStore = async () => {
|
|
590
|
+
try {
|
|
591
|
+
const raw = await readFile(AUTH_STORE_PATH, "utf8");
|
|
592
|
+
const parsed = parseAuthStore(raw);
|
|
593
|
+
const current = parsed?.current;
|
|
594
|
+
if (current) {
|
|
595
|
+
const credentialKey = current.credential_key || buildAuthCredentialKey(current);
|
|
596
|
+
const account = buildAuthKeychainAccount(credentialKey);
|
|
597
|
+
if (await isDarwinKeychainAvailable()) {
|
|
598
|
+
await deleteDarwinKeychainSecret(account);
|
|
599
|
+
}
|
|
600
|
+
}
|
|
601
|
+
} catch {
|
|
602
|
+
// Ignore parse/read errors and continue clearing local file.
|
|
603
|
+
}
|
|
604
|
+
try {
|
|
605
|
+
await unlink(AUTH_STORE_PATH);
|
|
606
|
+
} catch {
|
|
607
|
+
// Ignore missing file errors.
|
|
608
|
+
}
|
|
609
|
+
};
|
|
610
|
+
|
|
611
|
+
const normalizeCrateUrl = (value) => {
|
|
612
|
+
const raw = toTrimmed(value);
|
|
613
|
+
if (!raw) return "";
|
|
614
|
+
let parsed;
|
|
615
|
+
try {
|
|
616
|
+
parsed = new URL(raw);
|
|
617
|
+
} catch {
|
|
618
|
+
return "";
|
|
619
|
+
}
|
|
620
|
+
parsed.hash = "";
|
|
621
|
+
const path = (parsed.pathname || "/").replace(/\/+$/g, "") || "/";
|
|
622
|
+
parsed.pathname = path;
|
|
623
|
+
return parsed.toString();
|
|
624
|
+
};
|
|
625
|
+
|
|
626
|
+
const toOriginFromUrl = (value) => {
|
|
627
|
+
try {
|
|
628
|
+
return new URL(value).origin;
|
|
629
|
+
} catch {
|
|
630
|
+
return "";
|
|
631
|
+
}
|
|
632
|
+
};
|
|
633
|
+
|
|
634
|
+
const toIsoTimestampFromExpiresIn = (value) => {
|
|
635
|
+
const seconds = Number.parseInt(String(value), 10);
|
|
636
|
+
if (!Number.isFinite(seconds) || seconds <= 0) return "";
|
|
637
|
+
return new Date(Date.now() + seconds * 1000).toISOString();
|
|
638
|
+
};
|
|
639
|
+
|
|
640
|
+
const isExpiredOrNearExpiry = (iso, leewaySeconds = 60) => {
|
|
641
|
+
const ts = Date.parse(toTrimmed(iso));
|
|
642
|
+
if (Number.isNaN(ts)) return true;
|
|
643
|
+
return ts - Date.now() <= leewaySeconds * 1000;
|
|
644
|
+
};
|
|
645
|
+
|
|
646
|
+
const generatePkceCodeVerifier = () => randomBytes(64).toString("base64url");
|
|
647
|
+
|
|
648
|
+
const createPkceS256Challenge = (verifier) =>
|
|
649
|
+
createHash("sha256").update(verifier).digest("base64url");
|
|
650
|
+
|
|
651
|
+
const createAuthStateToken = () => randomBytes(24).toString("base64url");
|
|
652
|
+
|
|
653
|
+
const buildAuthorizationEndpoint = (crateUrl, projectId) => {
|
|
654
|
+
const endpoint = new URL("/oauth/authorize", crateUrl);
|
|
655
|
+
if (projectId) endpoint.searchParams.set("project_id", projectId);
|
|
656
|
+
return endpoint.toString();
|
|
657
|
+
};
|
|
658
|
+
|
|
659
|
+
const buildTokenEndpoint = (crateUrl) => new URL("/api/oauth/token", crateUrl).toString();
|
|
660
|
+
|
|
661
|
+
const fetchJson = async (url, timeoutMs = DEFAULT_TIMEOUT_MS) => {
|
|
662
|
+
const controller = new AbortController();
|
|
663
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
664
|
+
try {
|
|
665
|
+
const response = await fetch(url, {
|
|
666
|
+
method: "GET",
|
|
667
|
+
headers: {
|
|
668
|
+
Accept: "application/json",
|
|
669
|
+
},
|
|
670
|
+
signal: controller.signal,
|
|
671
|
+
});
|
|
672
|
+
const payload = await response.json().catch(() => null);
|
|
673
|
+
return { ok: response.ok, status: response.status, payload };
|
|
674
|
+
} finally {
|
|
675
|
+
clearTimeout(timer);
|
|
676
|
+
}
|
|
677
|
+
};
|
|
678
|
+
|
|
679
|
+
const postForm = async (url, body, timeoutMs = DEFAULT_TIMEOUT_MS) => {
|
|
680
|
+
const controller = new AbortController();
|
|
681
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
682
|
+
try {
|
|
683
|
+
const response = await fetch(url, {
|
|
684
|
+
method: "POST",
|
|
685
|
+
headers: {
|
|
686
|
+
"Content-Type": "application/x-www-form-urlencoded",
|
|
687
|
+
Accept: "application/json",
|
|
688
|
+
},
|
|
689
|
+
body: new URLSearchParams(body).toString(),
|
|
690
|
+
signal: controller.signal,
|
|
691
|
+
});
|
|
692
|
+
const payload = await response.json().catch(() => null);
|
|
693
|
+
return { ok: response.ok, status: response.status, payload };
|
|
694
|
+
} finally {
|
|
695
|
+
clearTimeout(timer);
|
|
696
|
+
}
|
|
697
|
+
};
|
|
698
|
+
|
|
699
|
+
const resolveOAuthEndpoints = async (crateUrl, projectId, timeoutMs) => {
|
|
700
|
+
const metadataUrl = new URL("/.well-known/oauth-authorization-server", crateUrl);
|
|
701
|
+
if (projectId) metadataUrl.searchParams.set("project_id", projectId);
|
|
702
|
+
|
|
703
|
+
const fallback = {
|
|
704
|
+
authorization_endpoint: buildAuthorizationEndpoint(crateUrl, projectId),
|
|
705
|
+
token_endpoint: buildTokenEndpoint(crateUrl),
|
|
706
|
+
};
|
|
707
|
+
|
|
708
|
+
const metadata = await fetchJson(metadataUrl.toString(), timeoutMs);
|
|
709
|
+
if (!metadata.ok || !metadata.payload || typeof metadata.payload !== "object") {
|
|
710
|
+
return fallback;
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
const authorizationEndpoint = toTrimmed(metadata.payload.authorization_endpoint);
|
|
714
|
+
const tokenEndpoint = toTrimmed(metadata.payload.token_endpoint);
|
|
715
|
+
return {
|
|
716
|
+
authorization_endpoint: authorizationEndpoint || fallback.authorization_endpoint,
|
|
717
|
+
token_endpoint: tokenEndpoint || fallback.token_endpoint,
|
|
718
|
+
};
|
|
719
|
+
};
|
|
720
|
+
|
|
721
|
+
const openExternalBrowser = async (targetUrl) => {
|
|
722
|
+
const platform = process.platform;
|
|
723
|
+
if (platform === "darwin") {
|
|
724
|
+
await execFileAsync("open", [targetUrl], { timeout: 10_000, maxBuffer: 64 * 1024 });
|
|
725
|
+
return;
|
|
726
|
+
}
|
|
727
|
+
if (platform === "win32") {
|
|
728
|
+
await execFileAsync("cmd", ["/c", "start", "", targetUrl], {
|
|
729
|
+
timeout: 10_000,
|
|
730
|
+
maxBuffer: 64 * 1024,
|
|
731
|
+
});
|
|
732
|
+
return;
|
|
733
|
+
}
|
|
734
|
+
await execFileAsync("xdg-open", [targetUrl], { timeout: 10_000, maxBuffer: 64 * 1024 });
|
|
735
|
+
};
|
|
736
|
+
|
|
737
|
+
const startLoopbackCallbackServer = async ({
|
|
738
|
+
callbackPath,
|
|
739
|
+
timeoutSeconds,
|
|
740
|
+
}) => {
|
|
741
|
+
const normalizedPath = callbackPath.startsWith("/") ? callbackPath : `/${callbackPath}`;
|
|
742
|
+
|
|
743
|
+
let resolved = false;
|
|
744
|
+
let resolveResult;
|
|
745
|
+
let rejectResult;
|
|
746
|
+
const resultPromise = new Promise((resolve, reject) => {
|
|
747
|
+
resolveResult = resolve;
|
|
748
|
+
rejectResult = reject;
|
|
749
|
+
});
|
|
750
|
+
|
|
751
|
+
const server = createServer((request, response) => {
|
|
752
|
+
let requestUrl;
|
|
753
|
+
try {
|
|
754
|
+
requestUrl = new URL(request.url || "/", `http://${request.headers.host || "127.0.0.1"}`);
|
|
755
|
+
} catch {
|
|
756
|
+
response.statusCode = 400;
|
|
757
|
+
response.end("Invalid callback URL");
|
|
758
|
+
return;
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
if (requestUrl.pathname !== normalizedPath) {
|
|
762
|
+
response.statusCode = 404;
|
|
763
|
+
response.end("Not found");
|
|
764
|
+
return;
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
const code = toTrimmed(requestUrl.searchParams.get("code"));
|
|
768
|
+
const state = toTrimmed(requestUrl.searchParams.get("state"));
|
|
769
|
+
const error = toTrimmed(requestUrl.searchParams.get("error"));
|
|
770
|
+
const errorDescription = toTrimmed(requestUrl.searchParams.get("error_description"));
|
|
771
|
+
|
|
772
|
+
response.statusCode = 200;
|
|
773
|
+
response.setHeader("Content-Type", "text/html; charset=utf-8");
|
|
774
|
+
response.end(
|
|
775
|
+
"<!doctype html><html><body><h1>Crate CLI</h1><p>認証が完了しました。このタブを閉じてください。</p></body></html>",
|
|
776
|
+
);
|
|
777
|
+
|
|
778
|
+
if (resolved) return;
|
|
779
|
+
resolved = true;
|
|
780
|
+
resolveResult({ code, state, error, error_description: errorDescription });
|
|
781
|
+
});
|
|
782
|
+
|
|
783
|
+
const timeoutMs = timeoutSeconds * 1000;
|
|
784
|
+
const timeout = setTimeout(() => {
|
|
785
|
+
if (resolved) return;
|
|
786
|
+
resolved = true;
|
|
787
|
+
rejectResult(new Error("OAuth callback timed out. Please run `crate auth login` again."));
|
|
788
|
+
server.close();
|
|
789
|
+
}, timeoutMs);
|
|
790
|
+
|
|
791
|
+
await new Promise((resolve, reject) => {
|
|
792
|
+
server.once("error", reject);
|
|
793
|
+
server.listen(0, "127.0.0.1", () => {
|
|
794
|
+
server.off("error", reject);
|
|
795
|
+
resolve();
|
|
796
|
+
});
|
|
797
|
+
});
|
|
798
|
+
|
|
799
|
+
const address = server.address();
|
|
800
|
+
if (!address || typeof address === "string") {
|
|
801
|
+
clearTimeout(timeout);
|
|
802
|
+
server.close();
|
|
803
|
+
throw new Error("Failed to bind local callback server.");
|
|
804
|
+
}
|
|
805
|
+
|
|
806
|
+
const redirectUri = new URL(`http://127.0.0.1:${address.port}${normalizedPath}`).toString();
|
|
807
|
+
|
|
808
|
+
const close = async () =>
|
|
809
|
+
new Promise((resolve) => {
|
|
810
|
+
clearTimeout(timeout);
|
|
811
|
+
server.close(() => resolve());
|
|
812
|
+
});
|
|
813
|
+
|
|
814
|
+
return {
|
|
815
|
+
redirectUri,
|
|
816
|
+
waitForCallback: () => resultPromise,
|
|
817
|
+
close,
|
|
818
|
+
};
|
|
819
|
+
};
|
|
820
|
+
|
|
821
|
+
const buildMcpResourceUrl = (crateUrl, projectId) => {
|
|
822
|
+
const url = new URL("/api/mcp", crateUrl);
|
|
823
|
+
if (projectId) url.searchParams.set("project_id", projectId);
|
|
824
|
+
return url.toString();
|
|
825
|
+
};
|
|
826
|
+
|
|
827
|
+
const normalizeAuthSession = (store) => {
|
|
828
|
+
if (!store?.current) return null;
|
|
829
|
+
const current = store.current;
|
|
830
|
+
if (!current.crate_url || !current.access_token) return null;
|
|
831
|
+
return current;
|
|
832
|
+
};
|
|
833
|
+
|
|
834
|
+
const refreshOAuthSession = async (session, timeoutMs) => {
|
|
835
|
+
if (!session.refresh_token || !session.token_endpoint || !session.client_id) {
|
|
836
|
+
return null;
|
|
837
|
+
}
|
|
838
|
+
|
|
839
|
+
const response = await postForm(
|
|
840
|
+
session.token_endpoint,
|
|
841
|
+
{
|
|
842
|
+
grant_type: "refresh_token",
|
|
843
|
+
refresh_token: session.refresh_token,
|
|
844
|
+
client_id: session.client_id,
|
|
845
|
+
},
|
|
846
|
+
timeoutMs,
|
|
847
|
+
);
|
|
848
|
+
if (!response.ok || !response.payload || typeof response.payload !== "object") {
|
|
849
|
+
return null;
|
|
850
|
+
}
|
|
851
|
+
|
|
852
|
+
const accessToken = toTrimmed(response.payload.access_token);
|
|
853
|
+
const refreshToken = toTrimmed(response.payload.refresh_token) || session.refresh_token;
|
|
854
|
+
const expiresAt = toIsoTimestampFromExpiresIn(response.payload.expires_in);
|
|
855
|
+
if (!accessToken || !refreshToken || !expiresAt) {
|
|
856
|
+
return null;
|
|
857
|
+
}
|
|
858
|
+
|
|
859
|
+
const now = new Date().toISOString();
|
|
860
|
+
return {
|
|
861
|
+
...session,
|
|
862
|
+
access_token: accessToken,
|
|
863
|
+
refresh_token: refreshToken,
|
|
864
|
+
token_type: toTrimmed(response.payload.token_type) || session.token_type,
|
|
865
|
+
scope: toTrimmed(response.payload.scope) || session.scope,
|
|
866
|
+
expires_at: expiresAt,
|
|
867
|
+
updated_at: now,
|
|
868
|
+
};
|
|
869
|
+
};
|
|
870
|
+
|
|
292
871
|
const getCommonDefaults = () => ({
|
|
872
|
+
crateUrl: process.env.CRATE_URL?.trim() ?? "",
|
|
873
|
+
apiToken: process.env.CRATE_TOKEN?.trim() ?? "",
|
|
874
|
+
projectId: process.env.CRATE_PROJECT_ID?.trim() ?? "",
|
|
293
875
|
mcpUrl: process.env.CRATE_MCP_URL?.trim() ?? "",
|
|
294
876
|
mcpIngestToken: process.env.CRATE_MCP_INGEST_TOKEN?.trim() ?? "",
|
|
295
877
|
serverName: process.env.CRATE_MCP_SERVER_NAME?.trim() ?? "",
|
|
@@ -307,6 +889,10 @@ const getWriteDefaults = () => ({
|
|
|
307
889
|
});
|
|
308
890
|
|
|
309
891
|
const COMMON_SCHEMA = {
|
|
892
|
+
"--crate-url": { key: "crateUrl", type: "string" },
|
|
893
|
+
"--api-url": { key: "crateUrl", type: "string" },
|
|
894
|
+
"--token": { key: "apiToken", type: "string" },
|
|
895
|
+
"--project-id": { key: "projectId", type: "string" },
|
|
310
896
|
"--mcp-url": { key: "mcpUrl", type: "string" },
|
|
311
897
|
"--mcp-ingest-token": { key: "mcpIngestToken", type: "string" },
|
|
312
898
|
"--server-name": { key: "serverName", type: "string" },
|
|
@@ -328,6 +914,9 @@ const WRITE_SCHEMA = {
|
|
|
328
914
|
};
|
|
329
915
|
|
|
330
916
|
const normalizeCommonOptions = (options) => {
|
|
917
|
+
options.crateUrl = toTrimmed(options.crateUrl);
|
|
918
|
+
options.apiToken = toTrimmed(options.apiToken);
|
|
919
|
+
options.projectId = toTrimmed(options.projectId);
|
|
331
920
|
options.mcpUrl = toTrimmed(options.mcpUrl);
|
|
332
921
|
options.mcpIngestToken = toTrimmed(options.mcpIngestToken);
|
|
333
922
|
options.serverName = toTrimmed(options.serverName);
|
|
@@ -470,6 +1059,33 @@ const parseGithubRepoFromRemoteUrl = (remoteUrl) => {
|
|
|
470
1059
|
return normalizeGithubRepoFullName(raw);
|
|
471
1060
|
};
|
|
472
1061
|
|
|
1062
|
+
const buildMcpUrlFromCrateUrl = (crateUrl, projectId = "") => {
|
|
1063
|
+
const normalizedBaseUrl = toTrimmed(crateUrl);
|
|
1064
|
+
if (!normalizedBaseUrl) return "";
|
|
1065
|
+
|
|
1066
|
+
let parsed;
|
|
1067
|
+
try {
|
|
1068
|
+
parsed = new URL(normalizedBaseUrl);
|
|
1069
|
+
} catch {
|
|
1070
|
+
throw new Error("--crate-url must be a valid URL.");
|
|
1071
|
+
}
|
|
1072
|
+
|
|
1073
|
+
const pathname = (parsed.pathname || "/").replace(/\/+$/g, "") || "/";
|
|
1074
|
+
if (!pathname.endsWith("/api/mcp")) {
|
|
1075
|
+
const prefix = pathname === "/" ? "" : pathname;
|
|
1076
|
+
parsed.pathname = `${prefix}/api/mcp`;
|
|
1077
|
+
} else {
|
|
1078
|
+
parsed.pathname = pathname;
|
|
1079
|
+
}
|
|
1080
|
+
|
|
1081
|
+
const normalizedProjectId = toTrimmed(projectId);
|
|
1082
|
+
if (normalizedProjectId && !parsed.searchParams.get("project_id")) {
|
|
1083
|
+
parsed.searchParams.set("project_id", normalizedProjectId);
|
|
1084
|
+
}
|
|
1085
|
+
|
|
1086
|
+
return parsed.toString();
|
|
1087
|
+
};
|
|
1088
|
+
|
|
473
1089
|
const inferGithubRepoFromGitRemote = async () => {
|
|
474
1090
|
try {
|
|
475
1091
|
const { stdout } = await execFileAsync("git", ["remote", "get-url", "origin"], {
|
|
@@ -483,18 +1099,28 @@ const inferGithubRepoFromGitRemote = async () => {
|
|
|
483
1099
|
};
|
|
484
1100
|
|
|
485
1101
|
const resolveMcpConnection = async (options) => {
|
|
486
|
-
|
|
1102
|
+
const hasCrateInput = Boolean(options.crateUrl || options.apiToken);
|
|
1103
|
+
const hasLegacyInput = Boolean(options.mcpUrl || options.mcpIngestToken);
|
|
1104
|
+
const hasCrateComplete = Boolean(options.crateUrl && options.apiToken);
|
|
1105
|
+
const hasLegacyComplete = Boolean(options.mcpUrl && options.mcpIngestToken);
|
|
1106
|
+
if (hasCrateComplete && hasLegacyComplete) {
|
|
1107
|
+
throw new Error(
|
|
1108
|
+
"Do not provide both --crate-url/--token and --mcp-url/--mcp-ingest-token.",
|
|
1109
|
+
);
|
|
1110
|
+
}
|
|
1111
|
+
|
|
1112
|
+
if (hasCrateComplete) {
|
|
487
1113
|
return {
|
|
488
|
-
mcpUrl: options.
|
|
489
|
-
mcpIngestToken: options.
|
|
1114
|
+
mcpUrl: buildMcpUrlFromCrateUrl(options.crateUrl, options.projectId),
|
|
1115
|
+
mcpIngestToken: options.apiToken,
|
|
490
1116
|
};
|
|
491
1117
|
}
|
|
492
1118
|
|
|
493
|
-
if (
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
1119
|
+
if (hasLegacyComplete) {
|
|
1120
|
+
return {
|
|
1121
|
+
mcpUrl: options.mcpUrl,
|
|
1122
|
+
mcpIngestToken: options.mcpIngestToken,
|
|
1123
|
+
};
|
|
498
1124
|
}
|
|
499
1125
|
|
|
500
1126
|
const servers = await readCodexServerConfigs();
|
|
@@ -522,8 +1148,44 @@ const resolveMcpConnection = async (options) => {
|
|
|
522
1148
|
};
|
|
523
1149
|
}
|
|
524
1150
|
|
|
1151
|
+
const authStore = await readAuthStore();
|
|
1152
|
+
let authSession = normalizeAuthSession(authStore);
|
|
1153
|
+
if (authSession && isExpiredOrNearExpiry(authSession.expires_at)) {
|
|
1154
|
+
const refreshed = await refreshOAuthSession(authSession, options.timeoutMs);
|
|
1155
|
+
if (refreshed) {
|
|
1156
|
+
authSession = refreshed;
|
|
1157
|
+
await writeAuthStore({ current: refreshed });
|
|
1158
|
+
}
|
|
1159
|
+
}
|
|
1160
|
+
if (authSession) {
|
|
1161
|
+
const effectiveCrateUrl = normalizeCrateUrl(options.crateUrl) || authSession.crate_url;
|
|
1162
|
+
const effectiveProjectId = toTrimmed(options.projectId) || authSession.project_id || "";
|
|
1163
|
+
const effectiveToken = options.apiToken || authSession.access_token;
|
|
1164
|
+
if (effectiveCrateUrl && effectiveToken) {
|
|
1165
|
+
return {
|
|
1166
|
+
mcpUrl: buildMcpUrlFromCrateUrl(effectiveCrateUrl, effectiveProjectId),
|
|
1167
|
+
mcpIngestToken: effectiveToken,
|
|
1168
|
+
};
|
|
1169
|
+
}
|
|
1170
|
+
}
|
|
1171
|
+
|
|
1172
|
+
if (options.crateUrl && !options.apiToken) {
|
|
1173
|
+
throw new Error("Crate API token is missing. Provide --token or run `crate auth login`.");
|
|
1174
|
+
}
|
|
1175
|
+
if (!options.crateUrl && options.apiToken) {
|
|
1176
|
+
throw new Error("Crate URL is missing. Provide --crate-url.");
|
|
1177
|
+
}
|
|
1178
|
+
if (options.mcpUrl && !options.mcpIngestToken) {
|
|
1179
|
+
throw new Error("MCP token is missing. Provide --mcp-ingest-token.");
|
|
1180
|
+
}
|
|
1181
|
+
if (!options.mcpUrl && options.mcpIngestToken) {
|
|
1182
|
+
throw new Error("MCP URL is missing. Provide --mcp-url.");
|
|
1183
|
+
}
|
|
1184
|
+
|
|
525
1185
|
throw new Error(
|
|
526
|
-
|
|
1186
|
+
hasCrateInput || hasLegacyInput
|
|
1187
|
+
? "Unable to resolve connection with current options."
|
|
1188
|
+
: "Unable to resolve connection automatically. Run `crate auth login`, or provide --crate-url and --token, --server-name, or --mcp-url and --mcp-ingest-token.",
|
|
527
1189
|
);
|
|
528
1190
|
};
|
|
529
1191
|
|
|
@@ -846,6 +1508,9 @@ const parseCommand = (argv) => {
|
|
|
846
1508
|
|
|
847
1509
|
const compound = `${first} ${second}`;
|
|
848
1510
|
const knownCompound = new Set([
|
|
1511
|
+
"auth login",
|
|
1512
|
+
"auth status",
|
|
1513
|
+
"auth logout",
|
|
849
1514
|
"tool list",
|
|
850
1515
|
"tool call",
|
|
851
1516
|
"tasks list",
|
|
@@ -1002,7 +1667,6 @@ const handleTasksList = async (argv, runtime) => {
|
|
|
1002
1667
|
|
|
1003
1668
|
const { options } = parseFlags(argv, schema, {
|
|
1004
1669
|
...getCommonDefaults(),
|
|
1005
|
-
projectId: "",
|
|
1006
1670
|
status: "",
|
|
1007
1671
|
limit: 50,
|
|
1008
1672
|
});
|
|
@@ -1054,7 +1718,6 @@ const handleTasksCreate = async (argv, runtime) => {
|
|
|
1054
1718
|
description: "",
|
|
1055
1719
|
actorContext: "agent",
|
|
1056
1720
|
sourceContext: "crate-cli",
|
|
1057
|
-
projectId: "",
|
|
1058
1721
|
githubRepoFullName: process.env.CRATE_GITHUB_REPO_FULL_NAME?.trim() ?? "",
|
|
1059
1722
|
});
|
|
1060
1723
|
|
|
@@ -1129,7 +1792,6 @@ const handleTasksUpdate = async (argv, runtime) => {
|
|
|
1129
1792
|
status: "",
|
|
1130
1793
|
actorContext: "agent",
|
|
1131
1794
|
sourceContext: "crate-cli",
|
|
1132
|
-
projectId: "",
|
|
1133
1795
|
githubRepoFullName: process.env.CRATE_GITHUB_REPO_FULL_NAME?.trim() ?? "",
|
|
1134
1796
|
});
|
|
1135
1797
|
|
|
@@ -1210,7 +1872,6 @@ const handleCheckpointStatus = async (argv, runtime) => {
|
|
|
1210
1872
|
|
|
1211
1873
|
const { options, seenFlags } = parseFlags(argv, schema, {
|
|
1212
1874
|
...getCommonDefaults(),
|
|
1213
|
-
projectId: "",
|
|
1214
1875
|
clientEventId: process.env.CRATE_EVENT_CLIENT_EVENT_ID?.trim() ?? "",
|
|
1215
1876
|
sessionId: process.env.CODEX_THREAD_ID?.trim() ?? "",
|
|
1216
1877
|
contextLabel: process.env.CRATE_EVENT_CONTEXT_LABEL?.trim() ?? "",
|
|
@@ -1395,8 +2056,256 @@ const handleTasksClose = async (argv, runtime) => {
|
|
|
1395
2056
|
return 0;
|
|
1396
2057
|
};
|
|
1397
2058
|
|
|
2059
|
+
const parseAuthLoginOptions = (argv) => {
|
|
2060
|
+
const schema = {
|
|
2061
|
+
...COMMON_SCHEMA,
|
|
2062
|
+
"--scope": { key: "scope", type: "string" },
|
|
2063
|
+
"--client-id": { key: "clientId", type: "string" },
|
|
2064
|
+
"--callback-timeout-seconds": { key: "callbackTimeoutSeconds", type: "integer" },
|
|
2065
|
+
"--no-browser": { key: "noBrowser", type: "boolean" },
|
|
2066
|
+
};
|
|
2067
|
+
|
|
2068
|
+
const { options } = parseFlags(argv, schema, {
|
|
2069
|
+
...getCommonDefaults(),
|
|
2070
|
+
scope: DEFAULT_OAUTH_SCOPES,
|
|
2071
|
+
clientId: DEFAULT_OAUTH_CLIENT_ID,
|
|
2072
|
+
callbackTimeoutSeconds: DEFAULT_AUTH_CALLBACK_TIMEOUT_SECONDS,
|
|
2073
|
+
noBrowser: false,
|
|
2074
|
+
});
|
|
2075
|
+
normalizeCommonOptions(options);
|
|
2076
|
+
options.scope = toTrimmed(options.scope);
|
|
2077
|
+
options.clientId = toTrimmed(options.clientId);
|
|
2078
|
+
options.crateUrl = normalizeCrateUrl(options.crateUrl || DEFAULT_CRATE_URL);
|
|
2079
|
+
options.projectId = toTrimmed(options.projectId);
|
|
2080
|
+
options.callbackTimeoutSeconds = parsePositiveInteger(
|
|
2081
|
+
options.callbackTimeoutSeconds,
|
|
2082
|
+
"--callback-timeout-seconds",
|
|
2083
|
+
);
|
|
2084
|
+
if (!options.crateUrl) {
|
|
2085
|
+
throw new Error("--crate-url must be a valid URL.");
|
|
2086
|
+
}
|
|
2087
|
+
if (!options.clientId) {
|
|
2088
|
+
throw new Error("--client-id is required.");
|
|
2089
|
+
}
|
|
2090
|
+
return options;
|
|
2091
|
+
};
|
|
2092
|
+
|
|
2093
|
+
const handleAuthLogin = async (argv) => {
|
|
2094
|
+
const options = parseAuthLoginOptions(argv);
|
|
2095
|
+
if (options.help) {
|
|
2096
|
+
process.stdout.write(`${COMMAND_HELP["auth login"]}\n`);
|
|
2097
|
+
return 0;
|
|
2098
|
+
}
|
|
2099
|
+
|
|
2100
|
+
const endpoints = await resolveOAuthEndpoints(
|
|
2101
|
+
options.crateUrl,
|
|
2102
|
+
options.projectId,
|
|
2103
|
+
options.timeoutMs,
|
|
2104
|
+
);
|
|
2105
|
+
const callback = await startLoopbackCallbackServer({
|
|
2106
|
+
callbackPath: AUTH_CALLBACK_PATH,
|
|
2107
|
+
timeoutSeconds: options.callbackTimeoutSeconds,
|
|
2108
|
+
});
|
|
2109
|
+
|
|
2110
|
+
const state = createAuthStateToken();
|
|
2111
|
+
const codeVerifier = generatePkceCodeVerifier();
|
|
2112
|
+
const codeChallenge = createPkceS256Challenge(codeVerifier);
|
|
2113
|
+
const resourceUrl = buildMcpResourceUrl(options.crateUrl, options.projectId);
|
|
2114
|
+
const authorizeUrl = new URL(endpoints.authorization_endpoint);
|
|
2115
|
+
authorizeUrl.searchParams.set("response_type", "code");
|
|
2116
|
+
authorizeUrl.searchParams.set("client_id", options.clientId);
|
|
2117
|
+
authorizeUrl.searchParams.set("redirect_uri", callback.redirectUri);
|
|
2118
|
+
authorizeUrl.searchParams.set("state", state);
|
|
2119
|
+
authorizeUrl.searchParams.set("code_challenge", codeChallenge);
|
|
2120
|
+
authorizeUrl.searchParams.set("code_challenge_method", "S256");
|
|
2121
|
+
authorizeUrl.searchParams.set("resource", resourceUrl);
|
|
2122
|
+
authorizeUrl.searchParams.set("mcp_url", resourceUrl);
|
|
2123
|
+
if (options.projectId) {
|
|
2124
|
+
authorizeUrl.searchParams.set("project_id", options.projectId);
|
|
2125
|
+
}
|
|
2126
|
+
if (options.scope) {
|
|
2127
|
+
authorizeUrl.searchParams.set("scope", options.scope);
|
|
2128
|
+
}
|
|
2129
|
+
|
|
2130
|
+
let browserOpened = false;
|
|
2131
|
+
if (!options.noBrowser) {
|
|
2132
|
+
try {
|
|
2133
|
+
await openExternalBrowser(authorizeUrl.toString());
|
|
2134
|
+
browserOpened = true;
|
|
2135
|
+
} catch {
|
|
2136
|
+
browserOpened = false;
|
|
2137
|
+
}
|
|
2138
|
+
}
|
|
2139
|
+
|
|
2140
|
+
if (!options.outputJson) {
|
|
2141
|
+
process.stdout.write(`OAuth authorize URL:\n${authorizeUrl.toString()}\n`);
|
|
2142
|
+
if (browserOpened) {
|
|
2143
|
+
process.stdout.write("ブラウザを開きました。認証完了を待機します。\n");
|
|
2144
|
+
} else {
|
|
2145
|
+
process.stdout.write("上記URLをブラウザで開いて認証してください。\n");
|
|
2146
|
+
}
|
|
2147
|
+
}
|
|
2148
|
+
|
|
2149
|
+
let callbackResult;
|
|
2150
|
+
try {
|
|
2151
|
+
callbackResult = await callback.waitForCallback();
|
|
2152
|
+
} finally {
|
|
2153
|
+
await callback.close();
|
|
2154
|
+
}
|
|
2155
|
+
|
|
2156
|
+
if (callbackResult.error) {
|
|
2157
|
+
const description = toTrimmed(callbackResult.error_description);
|
|
2158
|
+
throw new Error(
|
|
2159
|
+
description
|
|
2160
|
+
? `OAuth authorization failed: ${callbackResult.error} (${description})`
|
|
2161
|
+
: `OAuth authorization failed: ${callbackResult.error}`,
|
|
2162
|
+
);
|
|
2163
|
+
}
|
|
2164
|
+
if (!callbackResult.code) {
|
|
2165
|
+
throw new Error("OAuth authorization code was not returned.");
|
|
2166
|
+
}
|
|
2167
|
+
if (callbackResult.state !== state) {
|
|
2168
|
+
throw new Error("OAuth state mismatch. Please retry `crate auth login`.");
|
|
2169
|
+
}
|
|
2170
|
+
|
|
2171
|
+
const tokenResponse = await postForm(
|
|
2172
|
+
endpoints.token_endpoint,
|
|
2173
|
+
{
|
|
2174
|
+
grant_type: "authorization_code",
|
|
2175
|
+
code: callbackResult.code,
|
|
2176
|
+
client_id: options.clientId,
|
|
2177
|
+
redirect_uri: callback.redirectUri,
|
|
2178
|
+
code_verifier: codeVerifier,
|
|
2179
|
+
},
|
|
2180
|
+
options.timeoutMs,
|
|
2181
|
+
);
|
|
2182
|
+
if (!tokenResponse.ok || !tokenResponse.payload || typeof tokenResponse.payload !== "object") {
|
|
2183
|
+
const error = toTrimmed(tokenResponse.payload?.error) || `status=${tokenResponse.status}`;
|
|
2184
|
+
const desc = toTrimmed(tokenResponse.payload?.error_description);
|
|
2185
|
+
throw new Error(desc ? `OAuth token exchange failed: ${error} (${desc})` : `OAuth token exchange failed: ${error}`);
|
|
2186
|
+
}
|
|
2187
|
+
|
|
2188
|
+
const accessToken = toTrimmed(tokenResponse.payload.access_token);
|
|
2189
|
+
const refreshToken = toTrimmed(tokenResponse.payload.refresh_token);
|
|
2190
|
+
const tokenType = toTrimmed(tokenResponse.payload.token_type) || "Bearer";
|
|
2191
|
+
const scope = toTrimmed(tokenResponse.payload.scope) || options.scope;
|
|
2192
|
+
const expiresAt = toIsoTimestampFromExpiresIn(tokenResponse.payload.expires_in);
|
|
2193
|
+
|
|
2194
|
+
if (!accessToken || !refreshToken || !expiresAt) {
|
|
2195
|
+
throw new Error("OAuth token response is missing required fields.");
|
|
2196
|
+
}
|
|
2197
|
+
|
|
2198
|
+
const now = new Date().toISOString();
|
|
2199
|
+
const store = {
|
|
2200
|
+
current: {
|
|
2201
|
+
crate_url: options.crateUrl,
|
|
2202
|
+
project_id: options.projectId,
|
|
2203
|
+
client_id: options.clientId,
|
|
2204
|
+
token_endpoint: endpoints.token_endpoint,
|
|
2205
|
+
authorization_endpoint: endpoints.authorization_endpoint,
|
|
2206
|
+
access_token: accessToken,
|
|
2207
|
+
refresh_token: refreshToken,
|
|
2208
|
+
token_type: tokenType,
|
|
2209
|
+
scope,
|
|
2210
|
+
expires_at: expiresAt,
|
|
2211
|
+
created_at: now,
|
|
2212
|
+
updated_at: now,
|
|
2213
|
+
},
|
|
2214
|
+
};
|
|
2215
|
+
await writeAuthStore(store);
|
|
2216
|
+
|
|
2217
|
+
outputMaybeJson(
|
|
2218
|
+
options,
|
|
2219
|
+
{
|
|
2220
|
+
status: "authenticated",
|
|
2221
|
+
crate_url: options.crateUrl,
|
|
2222
|
+
project_id: options.projectId || null,
|
|
2223
|
+
expires_at: expiresAt,
|
|
2224
|
+
scope,
|
|
2225
|
+
browser_opened: browserOpened,
|
|
2226
|
+
},
|
|
2227
|
+
(payload) => {
|
|
2228
|
+
const lines = ["authentication succeeded"];
|
|
2229
|
+
lines.push(`- crate_url: ${payload.crate_url}`);
|
|
2230
|
+
lines.push(`- project_id: ${payload.project_id ?? "(auto)"}`);
|
|
2231
|
+
lines.push(`- expires_at: ${payload.expires_at}`);
|
|
2232
|
+
lines.push(`- scope: ${payload.scope}`);
|
|
2233
|
+
return `${lines.join("\n")}\n`;
|
|
2234
|
+
},
|
|
2235
|
+
);
|
|
2236
|
+
return 0;
|
|
2237
|
+
};
|
|
2238
|
+
|
|
2239
|
+
const handleAuthStatus = async (argv) => {
|
|
2240
|
+
const { options } = parseFlags(argv, COMMON_SCHEMA, getCommonDefaults());
|
|
2241
|
+
normalizeCommonOptions(options);
|
|
2242
|
+
if (options.help) {
|
|
2243
|
+
process.stdout.write(`${COMMAND_HELP["auth status"]}\n`);
|
|
2244
|
+
return 0;
|
|
2245
|
+
}
|
|
2246
|
+
|
|
2247
|
+
const store = await readAuthStore();
|
|
2248
|
+
let session = normalizeAuthSession(store);
|
|
2249
|
+
if (session && isExpiredOrNearExpiry(session.expires_at)) {
|
|
2250
|
+
const refreshed = await refreshOAuthSession(session, options.timeoutMs);
|
|
2251
|
+
if (refreshed) {
|
|
2252
|
+
session = refreshed;
|
|
2253
|
+
await writeAuthStore({ current: refreshed });
|
|
2254
|
+
}
|
|
2255
|
+
}
|
|
2256
|
+
|
|
2257
|
+
if (!session) {
|
|
2258
|
+
outputMaybeJson(
|
|
2259
|
+
options,
|
|
2260
|
+
{ authenticated: false },
|
|
2261
|
+
() => "not authenticated. run `crate auth login`.\n",
|
|
2262
|
+
);
|
|
2263
|
+
return 0;
|
|
2264
|
+
}
|
|
2265
|
+
|
|
2266
|
+
outputMaybeJson(
|
|
2267
|
+
options,
|
|
2268
|
+
{
|
|
2269
|
+
authenticated: true,
|
|
2270
|
+
crate_url: session.crate_url,
|
|
2271
|
+
project_id: session.project_id || null,
|
|
2272
|
+
scope: session.scope,
|
|
2273
|
+
expires_at: session.expires_at,
|
|
2274
|
+
token_type: session.token_type,
|
|
2275
|
+
},
|
|
2276
|
+
(payload) => {
|
|
2277
|
+
const lines = ["authenticated"];
|
|
2278
|
+
lines.push(`- crate_url: ${payload.crate_url}`);
|
|
2279
|
+
lines.push(`- project_id: ${payload.project_id ?? "(auto)"}`);
|
|
2280
|
+
lines.push(`- expires_at: ${payload.expires_at}`);
|
|
2281
|
+
lines.push(`- scope: ${payload.scope || "(default)"}`);
|
|
2282
|
+
return `${lines.join("\n")}\n`;
|
|
2283
|
+
},
|
|
2284
|
+
);
|
|
2285
|
+
return 0;
|
|
2286
|
+
};
|
|
2287
|
+
|
|
2288
|
+
const handleAuthLogout = async (argv) => {
|
|
2289
|
+
const { options } = parseFlags(argv, COMMON_SCHEMA, getCommonDefaults());
|
|
2290
|
+
normalizeCommonOptions(options);
|
|
2291
|
+
if (options.help) {
|
|
2292
|
+
process.stdout.write(`${COMMAND_HELP["auth logout"]}\n`);
|
|
2293
|
+
return 0;
|
|
2294
|
+
}
|
|
2295
|
+
|
|
2296
|
+
await clearAuthStore();
|
|
2297
|
+
outputMaybeJson(options, { status: "logged_out" }, () => "logged out.\n");
|
|
2298
|
+
return 0;
|
|
2299
|
+
};
|
|
2300
|
+
|
|
1398
2301
|
const runWithCommand = async (command, args, runtime) => {
|
|
1399
2302
|
switch (command) {
|
|
2303
|
+
case "auth login":
|
|
2304
|
+
return handleAuthLogin(args, runtime);
|
|
2305
|
+
case "auth status":
|
|
2306
|
+
return handleAuthStatus(args, runtime);
|
|
2307
|
+
case "auth logout":
|
|
2308
|
+
return handleAuthLogout(args, runtime);
|
|
1400
2309
|
case "tool list":
|
|
1401
2310
|
return handleToolList(args, runtime);
|
|
1402
2311
|
case "tool call":
|
|
@@ -1441,6 +2350,7 @@ export const runCrateCli = async (argv = process.argv.slice(2), runtime = {}) =>
|
|
|
1441
2350
|
};
|
|
1442
2351
|
|
|
1443
2352
|
export const __testables = {
|
|
2353
|
+
buildMcpUrlFromCrateUrl,
|
|
1444
2354
|
classifyTaskCloseError,
|
|
1445
2355
|
normalizeContextLabel,
|
|
1446
2356
|
normalizeClientEventId,
|