@xmemo/client 0.4.126 → 0.4.128
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 +64 -3
- package/package.json +1 -1
- package/src/cli.js +491 -22
package/README.md
CHANGED
|
@@ -16,17 +16,31 @@ internal scripts are not part of this npm package.
|
|
|
16
16
|
npm install -g @xmemo/client
|
|
17
17
|
```
|
|
18
18
|
|
|
19
|
+
Upgrade an existing global install:
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
xmemo update
|
|
23
|
+
# or
|
|
24
|
+
xmemo --update
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
Both commands run `npm install -g @xmemo/client@latest`. Use
|
|
28
|
+
`xmemo update --dry-run` to print the exact command without changing anything.
|
|
29
|
+
|
|
19
30
|
## Commands
|
|
20
31
|
|
|
21
32
|
```bash
|
|
33
|
+
xmemo update
|
|
22
34
|
xmemo setup codex
|
|
23
35
|
xmemo setup codex --yes
|
|
24
36
|
xmemo smoke --client codex
|
|
25
37
|
xmemo doctor
|
|
26
38
|
xmemo discovery show
|
|
27
39
|
xmemo setup
|
|
40
|
+
xmemo login
|
|
28
41
|
xmemo status
|
|
29
42
|
xmemo token status
|
|
43
|
+
xmemo token add --from-stdin
|
|
30
44
|
xmemo env example --shell bash
|
|
31
45
|
xmemo mcp list
|
|
32
46
|
xmemo mcp config --client generic
|
|
@@ -43,15 +57,40 @@ xmemo privacy
|
|
|
43
57
|
token values into project files.
|
|
44
58
|
- The CLI generates one stable non-secret `XMEMO_AGENT_INSTANCE_ID` per local
|
|
45
59
|
client profile and stores it in user-scoped config outside git.
|
|
46
|
-
- `xmemo token
|
|
60
|
+
- `xmemo login` and `xmemo token add` store tokens only in the user-scoped
|
|
61
|
+
XMemo CLI credential file outside git; token values are never printed.
|
|
62
|
+
- Legacy `xmemo token set` refuses plaintext credential storage unless
|
|
47
63
|
`--allow-plaintext` is explicitly provided.
|
|
48
64
|
- The npm package uses a `files` whitelist so only `bin`, `src`, `README.md`,
|
|
49
65
|
and `LICENSE` are published.
|
|
50
66
|
|
|
51
67
|
## Token flow
|
|
52
68
|
|
|
69
|
+
Recommended personal-user flow:
|
|
70
|
+
|
|
71
|
+
```bash
|
|
72
|
+
xmemo login
|
|
73
|
+
xmemo token status --verify
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
`xmemo login` uses the hosted device-login flow when the service advertises it:
|
|
77
|
+
the CLI shows a browser URL and one-time code, the user authorizes in XMemo, and
|
|
78
|
+
the CLI stores the issued MCP token in the user-scoped credential file.
|
|
79
|
+
|
|
80
|
+
Users who already have a token can configure it directly without shell profiles:
|
|
81
|
+
|
|
82
|
+
```bash
|
|
83
|
+
printf '%s\n' 'your-token' | xmemo token add --from-stdin
|
|
84
|
+
xmemo token status --verify
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
This is the preferred fallback while a hosted service is rolling out device
|
|
88
|
+
login. It still avoids project files, MCP config files, logs, and chat
|
|
89
|
+
transcripts.
|
|
90
|
+
|
|
53
91
|
Tokens should be created by the XMemo website or enterprise console, then
|
|
54
|
-
stored
|
|
92
|
+
stored with `xmemo login`, `xmemo token add`, a user environment variable, or an
|
|
93
|
+
enterprise secret manager:
|
|
55
94
|
|
|
56
95
|
```bash
|
|
57
96
|
export XMEMO_KEY="your-token"
|
|
@@ -129,13 +168,35 @@ For clients without a verified user-scoped write path, generate a read-only
|
|
|
129
168
|
template and apply it manually after review:
|
|
130
169
|
|
|
131
170
|
```bash
|
|
132
|
-
xmemo mcp config --client copilot-cli
|
|
171
|
+
xmemo mcp config --client copilot-cli
|
|
133
172
|
xmemo mcp config --client generic --base-url "https://your-private-service.example" --json
|
|
134
173
|
```
|
|
135
174
|
|
|
136
175
|
Only Codex and Cursor currently have write-capable helpers. Other client writes
|
|
137
176
|
should only be added after their official user-scoped config format is verified.
|
|
138
177
|
|
|
178
|
+
### Copilot CLI
|
|
179
|
+
|
|
180
|
+
Copilot CLI has `/mcp` management, but it does not currently document a stable
|
|
181
|
+
cross-platform user config file path/format for third-party tools to edit
|
|
182
|
+
directly. The recommended personal-user path is therefore local proxy mode:
|
|
183
|
+
|
|
184
|
+
```bash
|
|
185
|
+
xmemo login
|
|
186
|
+
xmemo mcp config --client copilot-cli
|
|
187
|
+
xmemo mcp proxy
|
|
188
|
+
```
|
|
189
|
+
|
|
190
|
+
The generated Copilot CLI template points at `http://127.0.0.1:8765/mcp` and
|
|
191
|
+
does not include token or identity headers. `xmemo mcp proxy` reads the token
|
|
192
|
+
saved by `xmemo login` or `xmemo token add --from-stdin`, adds the XMemo bearer
|
|
193
|
+
token and local agent identity, then forwards requests to `https://xmemo.dev/mcp`.
|
|
194
|
+
If you specifically want the older environment-variable template, run:
|
|
195
|
+
|
|
196
|
+
```bash
|
|
197
|
+
xmemo mcp config --client copilot-cli --remote-env
|
|
198
|
+
```
|
|
199
|
+
|
|
139
200
|
### Codex
|
|
140
201
|
|
|
141
202
|
Recommended Codex setup:
|
package/package.json
CHANGED
package/src/cli.js
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import fs from 'node:fs/promises';
|
|
2
|
+
import http from 'node:http';
|
|
2
3
|
import os from 'node:os';
|
|
3
4
|
import path from 'node:path';
|
|
5
|
+
import { spawn } from 'node:child_process';
|
|
4
6
|
import { randomUUID } from 'node:crypto';
|
|
5
7
|
|
|
6
8
|
const PRODUCT_NAME = 'XMemo';
|
|
@@ -8,7 +10,7 @@ const PACKAGE_NAME = '@xmemo/client';
|
|
|
8
10
|
const FALLBACK_PACKAGE_NAME = '@yonro/xmemo-client';
|
|
9
11
|
const COMMAND_NAME = 'xmemo';
|
|
10
12
|
const LEGACY_COMMAND_NAME = 'memory-os';
|
|
11
|
-
const CLI_VERSION = '0.4.
|
|
13
|
+
const CLI_VERSION = '0.4.128';
|
|
12
14
|
const DEFAULT_SERVICE_URL = 'https://xmemo.dev';
|
|
13
15
|
const TOKEN_ENV_VAR = 'XMEMO_KEY';
|
|
14
16
|
const LEGACY_TOKEN_ENV_VAR = 'MEMORY_OS_MCP_TOKEN';
|
|
@@ -20,6 +22,10 @@ const MCP_SERVER_NAME = 'memory_os';
|
|
|
20
22
|
const CODEX_PROFILE_TARGET = 'AGENTS.md';
|
|
21
23
|
const CODEX_PROFILE_MARKER_START = '<!-- memory-os:codex-profile:start -->';
|
|
22
24
|
const CODEX_PROFILE_MARKER_END = '<!-- memory-os:codex-profile:end -->';
|
|
25
|
+
const DEVICE_LOGIN_START_PATH = '/api/v1/auth/device/start';
|
|
26
|
+
const DEVICE_LOGIN_TOKEN_PATH = '/api/v1/auth/device/token';
|
|
27
|
+
const DEFAULT_PROXY_HOST = '127.0.0.1';
|
|
28
|
+
const DEFAULT_PROXY_PORT = 8765;
|
|
23
29
|
|
|
24
30
|
const MCP_CLIENTS = new Map([
|
|
25
31
|
['codex', {
|
|
@@ -59,6 +65,10 @@ export async function run(args, io = defaultIo()) {
|
|
|
59
65
|
return 0;
|
|
60
66
|
}
|
|
61
67
|
|
|
68
|
+
if (command === 'update' || command === '--update') {
|
|
69
|
+
return await updateCommand(args.slice(1), io);
|
|
70
|
+
}
|
|
71
|
+
|
|
62
72
|
if (command === 'doctor') {
|
|
63
73
|
return await doctorCommand(args.slice(1), io);
|
|
64
74
|
}
|
|
@@ -75,6 +85,10 @@ export async function run(args, io = defaultIo()) {
|
|
|
75
85
|
return await setupCommand(args.slice(1), io);
|
|
76
86
|
}
|
|
77
87
|
|
|
88
|
+
if (command === 'login') {
|
|
89
|
+
return await loginCommand(args.slice(1), io);
|
|
90
|
+
}
|
|
91
|
+
|
|
78
92
|
if (command === 'token') {
|
|
79
93
|
return await tokenCommand(args.slice(1), io);
|
|
80
94
|
}
|
|
@@ -119,7 +133,8 @@ function defaultIo() {
|
|
|
119
133
|
stdin: process.stdin,
|
|
120
134
|
stdout: process.stdout,
|
|
121
135
|
stderr: process.stderr,
|
|
122
|
-
fetch: globalThis.fetch
|
|
136
|
+
fetch: globalThis.fetch,
|
|
137
|
+
spawn
|
|
123
138
|
};
|
|
124
139
|
}
|
|
125
140
|
|
|
@@ -128,14 +143,19 @@ function writeHelp(io) {
|
|
|
128
143
|
writeLine(io.stdout, `Fallback npm package: ${FALLBACK_PACKAGE_NAME}; legacy command alias: ${LEGACY_COMMAND_NAME}`);
|
|
129
144
|
writeLine(io.stdout, '');
|
|
130
145
|
writeLine(io.stdout, 'Usage:');
|
|
146
|
+
writeLine(io.stdout, ` ${COMMAND_NAME} update [--dry-run] [--json]`);
|
|
147
|
+
writeLine(io.stdout, ` ${COMMAND_NAME} --update [--dry-run] [--json]`);
|
|
131
148
|
writeLine(io.stdout, ` ${COMMAND_NAME} doctor [--base-url <https://api.example.com>] [--json]`);
|
|
132
149
|
writeLine(io.stdout, ` ${COMMAND_NAME} discovery show [--base-url <https://api.example.com>] [--json]`);
|
|
133
150
|
writeLine(io.stdout, ` ${COMMAND_NAME} setup [codex|cursor] [--url <https://api.example.com>] [--write|--yes] [--json]`);
|
|
151
|
+
writeLine(io.stdout, ` ${COMMAND_NAME} login [--from-stdin] [--base-url <url>] [--json]`);
|
|
134
152
|
writeLine(io.stdout, ` ${COMMAND_NAME} status [--url <https://api.example.com>] [--json]`);
|
|
135
|
-
writeLine(io.stdout, ` ${COMMAND_NAME} token status`);
|
|
153
|
+
writeLine(io.stdout, ` ${COMMAND_NAME} token status [--verify]`);
|
|
154
|
+
writeLine(io.stdout, ` ${COMMAND_NAME} token add --from-stdin`);
|
|
136
155
|
writeLine(io.stdout, ` ${COMMAND_NAME} token set --from-stdin [--allow-plaintext]`);
|
|
137
156
|
writeLine(io.stdout, ` ${COMMAND_NAME} mcp list`);
|
|
138
|
-
writeLine(io.stdout, ` ${COMMAND_NAME} mcp config --client <codex|cursor|generic> [--base-url <url>] [--json]`);
|
|
157
|
+
writeLine(io.stdout, ` ${COMMAND_NAME} mcp config --client <codex|cursor|copilot-cli|generic> [--base-url <url>] [--json]`);
|
|
158
|
+
writeLine(io.stdout, ` ${COMMAND_NAME} mcp proxy [--port ${DEFAULT_PROXY_PORT}]`);
|
|
139
159
|
writeLine(io.stdout, ` ${COMMAND_NAME} mcp profile codex [--json]`);
|
|
140
160
|
writeLine(io.stdout, ` ${COMMAND_NAME} profile install codex [--target AGENTS.md] [--dry-run|--json]`);
|
|
141
161
|
writeLine(io.stdout, ` ${COMMAND_NAME} profile uninstall codex [--target AGENTS.md] [--json]`);
|
|
@@ -147,6 +167,50 @@ function writeHelp(io) {
|
|
|
147
167
|
writeLine(io.stdout, `Default service URL: ${DEFAULT_SERVICE_URL}; use --url or XMEMO_URL for private deployments.`);
|
|
148
168
|
writeLine(io.stdout, '');
|
|
149
169
|
writeLine(io.stdout, 'Privacy defaults: no telemetry, no token in project files, and no token is sent by `status`, `doctor`, or `discovery`.');
|
|
170
|
+
writeLine(io.stdout, '`login` and `token add` store credentials only in the user-scoped XMemo CLI config directory.');
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
async function updateCommand(args, io) {
|
|
174
|
+
const outputJson = hasFlag(args, '--json');
|
|
175
|
+
const dryRun = hasFlag(args, '--dry-run');
|
|
176
|
+
const npmCommand = npmExecutable();
|
|
177
|
+
const npmArgs = ['install', '-g', `${PACKAGE_NAME}@latest`];
|
|
178
|
+
const report = {
|
|
179
|
+
package: PACKAGE_NAME,
|
|
180
|
+
command: [npmCommand, ...npmArgs],
|
|
181
|
+
dryRun,
|
|
182
|
+
tokenSent: false,
|
|
183
|
+
projectFilesModified: false
|
|
184
|
+
};
|
|
185
|
+
|
|
186
|
+
if (dryRun) {
|
|
187
|
+
if (outputJson) {
|
|
188
|
+
writeLine(io.stdout, JSON.stringify(report, null, 2));
|
|
189
|
+
} else {
|
|
190
|
+
writeLine(io.stdout, `Update command: ${report.command.join(' ')}`);
|
|
191
|
+
writeLine(io.stdout, 'Dry run only; no changes made.');
|
|
192
|
+
}
|
|
193
|
+
return 0;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
if (!outputJson) {
|
|
197
|
+
writeLine(io.stdout, `Updating ${PACKAGE_NAME} with: ${report.command.join(' ')}`);
|
|
198
|
+
}
|
|
199
|
+
const result = await runProcess(npmCommand, npmArgs, io, { stream: !outputJson });
|
|
200
|
+
report.exitCode = result.code;
|
|
201
|
+
report.completed = result.code === 0;
|
|
202
|
+
|
|
203
|
+
if (outputJson) {
|
|
204
|
+
writeLine(io.stdout, JSON.stringify(report, null, 2));
|
|
205
|
+
}
|
|
206
|
+
if (result.code !== 0) {
|
|
207
|
+
const detail = result.stderr.trim() || result.stdout.trim() || `exit code ${result.code}`;
|
|
208
|
+
throw new UsageError(`Update failed: ${detail}`);
|
|
209
|
+
}
|
|
210
|
+
if (!outputJson) {
|
|
211
|
+
writeLine(io.stdout, `Update complete. Run \`${COMMAND_NAME} --version\` to confirm.`);
|
|
212
|
+
}
|
|
213
|
+
return 0;
|
|
150
214
|
}
|
|
151
215
|
|
|
152
216
|
async function doctorCommand(args, io) {
|
|
@@ -385,46 +449,119 @@ async function profileCommand(args, io) {
|
|
|
385
449
|
return 0;
|
|
386
450
|
}
|
|
387
451
|
|
|
452
|
+
async function loginCommand(args, io) {
|
|
453
|
+
const outputJson = hasFlag(args, '--json');
|
|
454
|
+
const fromStdin = hasFlag(args, '--from-stdin') || hasFlag(args, '--token-stdin');
|
|
455
|
+
const baseUrl = normalizeBaseUrl(baseUrlOption(args, io.env));
|
|
456
|
+
const timeoutMs = parsePositiveInteger(optionValue(args, '--timeout-ms') ?? '30000', '--timeout-ms');
|
|
457
|
+
const pollOnce = hasFlag(args, '--poll-once');
|
|
458
|
+
|
|
459
|
+
if (fromStdin) {
|
|
460
|
+
const result = await storeTokenFromStdin(io, { source: 'stdin' });
|
|
461
|
+
if (outputJson) {
|
|
462
|
+
writeLine(io.stdout, JSON.stringify(result, null, 2));
|
|
463
|
+
} else {
|
|
464
|
+
writeLine(io.stdout, `${PRODUCT_NAME} login complete.`);
|
|
465
|
+
writeLine(io.stdout, `Stored token in user-scoped credential file: ${result.credentialPath}`);
|
|
466
|
+
writeLine(io.stdout, 'Token value was not printed. Project files were not modified.');
|
|
467
|
+
}
|
|
468
|
+
return 0;
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
const start = await startDeviceLogin(baseUrl, timeoutMs, io);
|
|
472
|
+
if (!outputJson) {
|
|
473
|
+
writeLine(io.stdout, `${PRODUCT_NAME} device login`);
|
|
474
|
+
writeLine(io.stdout, `Open: ${start.verificationUriComplete ?? start.verificationUri}`);
|
|
475
|
+
if (start.userCode) {
|
|
476
|
+
writeLine(io.stdout, `Code: ${start.userCode}`);
|
|
477
|
+
}
|
|
478
|
+
writeLine(io.stdout, 'Waiting for authorization...');
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
const token = await pollDeviceLogin(baseUrl, start, timeoutMs, io, { pollOnce });
|
|
482
|
+
const result = await storeTokenValue(token, { source: 'device-login' }, io.env);
|
|
483
|
+
const payload = {
|
|
484
|
+
...result,
|
|
485
|
+
baseUrl,
|
|
486
|
+
verificationUri: start.verificationUri,
|
|
487
|
+
deviceLogin: true
|
|
488
|
+
};
|
|
489
|
+
|
|
490
|
+
if (outputJson) {
|
|
491
|
+
writeLine(io.stdout, JSON.stringify(payload, null, 2));
|
|
492
|
+
} else {
|
|
493
|
+
writeLine(io.stdout, 'Login complete. Token stored securely in the user-scoped XMemo CLI config directory.');
|
|
494
|
+
writeLine(io.stdout, `Credential path: ${result.credentialPath}`);
|
|
495
|
+
writeLine(io.stdout, `Verify with: ${COMMAND_NAME} token status --verify`);
|
|
496
|
+
}
|
|
497
|
+
return 0;
|
|
498
|
+
}
|
|
499
|
+
|
|
388
500
|
async function tokenCommand(args, io) {
|
|
389
501
|
const subcommand = args[0] ?? 'help';
|
|
390
502
|
|
|
391
503
|
if (subcommand === 'help' || subcommand === '--help' || subcommand === '-h') {
|
|
392
504
|
writeLine(io.stdout, 'Token commands:');
|
|
393
|
-
writeLine(io.stdout, ` ${COMMAND_NAME} token status`);
|
|
505
|
+
writeLine(io.stdout, ` ${COMMAND_NAME} token status [--verify]`);
|
|
506
|
+
writeLine(io.stdout, ` ${COMMAND_NAME} token add --from-stdin`);
|
|
394
507
|
writeLine(io.stdout, ` ${COMMAND_NAME} token set --from-stdin [--allow-plaintext]`);
|
|
395
508
|
writeLine(io.stdout, '');
|
|
396
|
-
writeLine(io.stdout,
|
|
509
|
+
writeLine(io.stdout, `${COMMAND_NAME} login is the recommended personal-user path.`);
|
|
510
|
+
writeLine(io.stdout, `${COMMAND_NAME} token add --from-stdin stores a token in the user-scoped XMemo CLI config directory.`);
|
|
397
511
|
return 0;
|
|
398
512
|
}
|
|
399
513
|
|
|
400
514
|
if (subcommand === 'status') {
|
|
401
|
-
const
|
|
515
|
+
const credential = await readStoredCredential(io.env);
|
|
402
516
|
const hasEnvironmentToken = Boolean(io.env[TOKEN_ENV_VAR] ?? io.env[LEGACY_TOKEN_ENV_VAR]);
|
|
403
|
-
const
|
|
517
|
+
const hasUserCredential = Boolean(credential.token);
|
|
404
518
|
writeLine(io.stdout, `Environment token: ${hasEnvironmentToken ? 'present' : 'missing'} (${TOKEN_ENV_VAR})`);
|
|
405
|
-
writeLine(io.stdout, `User credential file: ${
|
|
519
|
+
writeLine(io.stdout, `User credential file: ${hasUserCredential ? 'present' : 'missing'} (${credential.path})`);
|
|
406
520
|
writeLine(io.stdout, 'Token values are never printed.');
|
|
407
|
-
|
|
521
|
+
if (hasFlag(args, '--verify')) {
|
|
522
|
+
const token = await resolveCredentialToken(io.env);
|
|
523
|
+
if (!token) {
|
|
524
|
+
writeLine(io.stderr, `No token found. Run \`${COMMAND_NAME} login\` or \`${COMMAND_NAME} token add --from-stdin\`.`);
|
|
525
|
+
return 1;
|
|
526
|
+
}
|
|
527
|
+
const baseUrl = normalizeBaseUrl(baseUrlOption(args, io.env));
|
|
528
|
+
const timeoutMs = parsePositiveInteger(optionValue(args, '--timeout-ms') ?? '10000', '--timeout-ms');
|
|
529
|
+
const verification = await verifyTokenWithMcp(baseUrl, token, timeoutMs, io);
|
|
530
|
+
writeLine(io.stdout, `Remote token verification: ${verification.ok ? 'ok' : 'failed'} (${verification.detail})`);
|
|
531
|
+
return verification.ok ? 0 : 1;
|
|
532
|
+
}
|
|
533
|
+
return hasEnvironmentToken || hasUserCredential ? 0 : 1;
|
|
408
534
|
}
|
|
409
535
|
|
|
410
|
-
if (subcommand === '
|
|
536
|
+
if (subcommand === 'add') {
|
|
411
537
|
if (!hasFlag(args, '--from-stdin')) {
|
|
412
538
|
throw new UsageError('Refusing command-line token input. Pipe the token through stdin with --from-stdin.');
|
|
413
539
|
}
|
|
540
|
+
const result = await storeTokenFromStdin(io, { source: 'token-add' });
|
|
541
|
+
if (hasFlag(args, '--json')) {
|
|
542
|
+
writeLine(io.stdout, JSON.stringify(result, null, 2));
|
|
543
|
+
} else {
|
|
544
|
+
writeLine(io.stdout, `Stored token in user-scoped credential file: ${result.credentialPath}`);
|
|
545
|
+
writeLine(io.stdout, 'Token value was not printed. Project files were not modified.');
|
|
546
|
+
}
|
|
547
|
+
return 0;
|
|
548
|
+
}
|
|
414
549
|
|
|
550
|
+
if (subcommand === 'set') {
|
|
551
|
+
if (!hasFlag(args, '--from-stdin')) {
|
|
552
|
+
throw new UsageError('Refusing command-line token input. Pipe the token through stdin with --from-stdin.');
|
|
553
|
+
}
|
|
415
554
|
const token = (await readAll(io.stdin)).trim();
|
|
416
555
|
validateToken(token);
|
|
417
|
-
|
|
418
556
|
if (!hasFlag(args, '--allow-plaintext')) {
|
|
419
557
|
writeLine(io.stderr, 'Token was read from stdin but was not stored.');
|
|
420
558
|
writeLine(io.stderr, 'Enterprise default refuses plaintext token storage without --allow-plaintext.');
|
|
421
|
-
writeLine(io.stderr, `Preferred
|
|
559
|
+
writeLine(io.stderr, `Preferred personal-user path: ${COMMAND_NAME} login or ${COMMAND_NAME} token add --from-stdin.`);
|
|
422
560
|
return 2;
|
|
423
561
|
}
|
|
424
562
|
|
|
425
|
-
const
|
|
426
|
-
|
|
427
|
-
writeLine(io.stdout, `Stored token in user-scoped credential file: ${credentialPath}`);
|
|
563
|
+
const result = await storeTokenValue(token, { source: 'token-set' }, io.env);
|
|
564
|
+
writeLine(io.stdout, `Stored token in user-scoped credential file: ${result.credentialPath}`);
|
|
428
565
|
writeLine(io.stdout, 'Token value was not printed. Do not commit this file.');
|
|
429
566
|
return 0;
|
|
430
567
|
}
|
|
@@ -438,7 +575,8 @@ async function mcpCommand(args, io) {
|
|
|
438
575
|
if (subcommand === 'help' || subcommand === '--help' || subcommand === '-h') {
|
|
439
576
|
writeLine(io.stdout, 'MCP commands:');
|
|
440
577
|
writeLine(io.stdout, ` ${COMMAND_NAME} mcp list`);
|
|
441
|
-
writeLine(io.stdout, ` ${COMMAND_NAME} mcp config --client <codex|cursor|generic> [--base-url <url>] [--json]`);
|
|
578
|
+
writeLine(io.stdout, ` ${COMMAND_NAME} mcp config --client <codex|cursor|copilot-cli|generic> [--base-url <url>] [--json]`);
|
|
579
|
+
writeLine(io.stdout, ` ${COMMAND_NAME} mcp proxy [--port ${DEFAULT_PROXY_PORT}] [--base-url <url>]`);
|
|
442
580
|
writeLine(io.stdout, ` ${COMMAND_NAME} mcp profile codex [--json]`);
|
|
443
581
|
writeLine(io.stdout, ` ${COMMAND_NAME} mcp add <codex|cursor> [--url <https://api.example.com>]`);
|
|
444
582
|
writeLine(io.stdout, ` ${COMMAND_NAME} mcp add <codex|cursor> [--url <https://api.example.com>] --write [--config <path>]`);
|
|
@@ -463,7 +601,12 @@ async function mcpCommand(args, io) {
|
|
|
463
601
|
const clientId = optionValue(args, '--client') ?? args[1] ?? 'generic';
|
|
464
602
|
const baseUrl = normalizeBaseUrl(baseUrlOption(args, io.env));
|
|
465
603
|
const mcpUrl = endpointUrl(baseUrl, '/mcp');
|
|
466
|
-
const
|
|
604
|
+
const useLocalProxy = clientId === 'copilot-cli' && !hasFlag(args, '--remote-env');
|
|
605
|
+
const proxyPort = parsePositiveInteger(optionValue(args, '--port') ?? String(DEFAULT_PROXY_PORT), '--port');
|
|
606
|
+
const proxyUrl = `http://${DEFAULT_PROXY_HOST}:${proxyPort}/mcp`;
|
|
607
|
+
const template = useLocalProxy
|
|
608
|
+
? mcpLocalProxyTemplate(clientId, proxyUrl)
|
|
609
|
+
: mcpConfigTemplate(clientId, mcpUrl);
|
|
467
610
|
|
|
468
611
|
if (hasFlag(args, '--json')) {
|
|
469
612
|
writeLine(io.stdout, JSON.stringify(template, null, 2));
|
|
@@ -471,7 +614,12 @@ async function mcpCommand(args, io) {
|
|
|
471
614
|
}
|
|
472
615
|
|
|
473
616
|
writeLine(io.stdout, `${PRODUCT_NAME} MCP config template for ${clientId}`);
|
|
474
|
-
|
|
617
|
+
if (useLocalProxy) {
|
|
618
|
+
writeLine(io.stdout, `Requires credential: ${COMMAND_NAME} login or ${COMMAND_NAME} token add --from-stdin`);
|
|
619
|
+
writeLine(io.stdout, `Run local proxy: ${template.requiresLocalCommand}`);
|
|
620
|
+
} else {
|
|
621
|
+
writeLine(io.stdout, `Requires env: ${TOKEN_ENV_VAR}`);
|
|
622
|
+
}
|
|
475
623
|
if (typeof template.snippet === 'string') {
|
|
476
624
|
writeLine(io.stdout, template.snippet.trimEnd());
|
|
477
625
|
} else {
|
|
@@ -481,6 +629,10 @@ async function mcpCommand(args, io) {
|
|
|
481
629
|
return 0;
|
|
482
630
|
}
|
|
483
631
|
|
|
632
|
+
if (subcommand === 'proxy') {
|
|
633
|
+
return await mcpProxyCommand(args.slice(1), io);
|
|
634
|
+
}
|
|
635
|
+
|
|
484
636
|
if (subcommand === 'profile') {
|
|
485
637
|
const clientId = args[1] ?? 'codex';
|
|
486
638
|
if (clientId !== 'codex') {
|
|
@@ -544,6 +696,82 @@ async function mcpCommand(args, io) {
|
|
|
544
696
|
return 0;
|
|
545
697
|
}
|
|
546
698
|
|
|
699
|
+
async function mcpProxyCommand(args, io) {
|
|
700
|
+
const baseUrl = normalizeBaseUrl(baseUrlOption(args, io.env));
|
|
701
|
+
const mcpUrl = endpointUrl(baseUrl, '/mcp');
|
|
702
|
+
const host = optionValue(args, '--host') ?? DEFAULT_PROXY_HOST;
|
|
703
|
+
const port = parsePositiveInteger(optionValue(args, '--port') ?? String(DEFAULT_PROXY_PORT), '--port');
|
|
704
|
+
const token = await resolveCredentialToken(io.env);
|
|
705
|
+
if (!token) {
|
|
706
|
+
throw new UsageError(`No token found. Run \`${COMMAND_NAME} login\` or \`${COMMAND_NAME} token add --from-stdin\` first.`);
|
|
707
|
+
}
|
|
708
|
+
validateToken(token);
|
|
709
|
+
const identity = await agentIdentity('copilot-cli', io.env);
|
|
710
|
+
|
|
711
|
+
const server = http.createServer(async (request, response) => {
|
|
712
|
+
try {
|
|
713
|
+
await handleMcpProxyRequest({ request, response, mcpUrl, token, identity, io });
|
|
714
|
+
} catch (error) {
|
|
715
|
+
response.statusCode = 502;
|
|
716
|
+
response.setHeader('content-type', 'application/json');
|
|
717
|
+
response.end(JSON.stringify({ error: 'mcp_proxy_error', message: error.message }));
|
|
718
|
+
}
|
|
719
|
+
});
|
|
720
|
+
|
|
721
|
+
await new Promise((resolve, reject) => {
|
|
722
|
+
server.once('error', reject);
|
|
723
|
+
server.listen(port, host, () => {
|
|
724
|
+
server.off('error', reject);
|
|
725
|
+
resolve();
|
|
726
|
+
});
|
|
727
|
+
});
|
|
728
|
+
|
|
729
|
+
writeLine(io.stdout, `${PRODUCT_NAME} MCP proxy listening on http://${host}:${port}/mcp`);
|
|
730
|
+
writeLine(io.stdout, `Forwarding to ${mcpUrl}`);
|
|
731
|
+
writeLine(io.stdout, `Credential source: ${TOKEN_ENV_VAR} or ${credentialsPath(io.env)}`);
|
|
732
|
+
return 0;
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
async function handleMcpProxyRequest({ request, response, mcpUrl, token, identity, io }) {
|
|
736
|
+
const requestUrl = new URL(request.url ?? '/', `http://${request.headers.host ?? `${DEFAULT_PROXY_HOST}:${DEFAULT_PROXY_PORT}`}`);
|
|
737
|
+
if (request.method !== 'POST' || requestUrl.pathname !== '/mcp') {
|
|
738
|
+
response.statusCode = 404;
|
|
739
|
+
response.setHeader('content-type', 'application/json');
|
|
740
|
+
response.end(JSON.stringify({ error: 'not_found' }));
|
|
741
|
+
return;
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
const body = await readAll(request);
|
|
745
|
+
const upstreamHeaders = {
|
|
746
|
+
accept: String(request.headers.accept || 'application/json, text/event-stream'),
|
|
747
|
+
'content-type': String(request.headers['content-type'] || 'application/json'),
|
|
748
|
+
authorization: `Bearer ${token}`,
|
|
749
|
+
[AGENT_ID_HEADER]: identity.agentId,
|
|
750
|
+
[AGENT_INSTANCE_HEADER]: identity.agentInstanceId,
|
|
751
|
+
'user-agent': `XMemo-CLI-Proxy/${CLI_VERSION} (+https://github.com/yonro/memory-os-cli)`
|
|
752
|
+
};
|
|
753
|
+
const sessionId = request.headers['mcp-session-id'];
|
|
754
|
+
if (sessionId) {
|
|
755
|
+
upstreamHeaders['mcp-session-id'] = Array.isArray(sessionId) ? sessionId[0] : sessionId;
|
|
756
|
+
}
|
|
757
|
+
|
|
758
|
+
const upstream = await io.fetch(mcpUrl, {
|
|
759
|
+
method: 'POST',
|
|
760
|
+
headers: upstreamHeaders,
|
|
761
|
+
body
|
|
762
|
+
});
|
|
763
|
+
|
|
764
|
+
response.statusCode = upstream.status;
|
|
765
|
+
for (const header of ['content-type', 'mcp-session-id']) {
|
|
766
|
+
const value = upstream.headers.get(header);
|
|
767
|
+
if (value) {
|
|
768
|
+
response.setHeader(header, value);
|
|
769
|
+
}
|
|
770
|
+
}
|
|
771
|
+
const buffer = Buffer.from(await upstream.arrayBuffer());
|
|
772
|
+
response.end(buffer);
|
|
773
|
+
}
|
|
774
|
+
|
|
547
775
|
async function smokeCommand(args, io) {
|
|
548
776
|
const clientId = optionValue(args, '--client');
|
|
549
777
|
const outputJson = hasFlag(args, '--json');
|
|
@@ -636,10 +864,145 @@ function writePrivacy(io) {
|
|
|
636
864
|
writeLine(io.stdout, '- `status` does not send tokens.');
|
|
637
865
|
writeLine(io.stdout, `- MCP configs reference ${TOKEN_ENV_VAR}; token values are not embedded.`);
|
|
638
866
|
writeLine(io.stdout, `- Agent instance IDs are non-secret and stored in user-scoped config outside git.`);
|
|
639
|
-
writeLine(io.stdout, '-
|
|
867
|
+
writeLine(io.stdout, '- `login` and `token add` store credentials in the user-scoped XMemo CLI config directory.');
|
|
868
|
+
writeLine(io.stdout, '- Legacy `token set` plaintext storage requires explicit --allow-plaintext.');
|
|
640
869
|
writeLine(io.stdout, '- npm publishing is restricted by package.json files whitelist.');
|
|
641
870
|
}
|
|
642
871
|
|
|
872
|
+
async function startDeviceLogin(baseUrl, timeoutMs, io) {
|
|
873
|
+
const payload = await postJson(endpointUrl(baseUrl, DEVICE_LOGIN_START_PATH), {
|
|
874
|
+
client_id: PACKAGE_NAME,
|
|
875
|
+
cli_version: CLI_VERSION,
|
|
876
|
+
token_type: 'mcp_token',
|
|
877
|
+
scopes: ['memory:read', 'memory:write']
|
|
878
|
+
}, timeoutMs, io);
|
|
879
|
+
|
|
880
|
+
const deviceCode = stringValue(payload, ['device_code']);
|
|
881
|
+
const verificationUri = stringValue(payload, ['verification_uri']);
|
|
882
|
+
if (!deviceCode || !verificationUri) {
|
|
883
|
+
throw new UsageError(`Device login did not return device_code and verification_uri from ${baseUrl}.`);
|
|
884
|
+
}
|
|
885
|
+
|
|
886
|
+
return {
|
|
887
|
+
deviceCode,
|
|
888
|
+
userCode: stringValue(payload, ['user_code']),
|
|
889
|
+
verificationUri,
|
|
890
|
+
verificationUriComplete: stringValue(payload, ['verification_uri_complete']),
|
|
891
|
+
expiresIn: Number.isFinite(Number(payload.expires_in)) ? Number(payload.expires_in) : 600,
|
|
892
|
+
interval: Number.isFinite(Number(payload.interval)) ? Math.max(1, Number(payload.interval)) : 5
|
|
893
|
+
};
|
|
894
|
+
}
|
|
895
|
+
|
|
896
|
+
async function pollDeviceLogin(baseUrl, start, timeoutMs, io, options = {}) {
|
|
897
|
+
const deadline = Date.now() + Math.min(start.expiresIn * 1000, timeoutMs);
|
|
898
|
+
while (Date.now() <= deadline) {
|
|
899
|
+
const payload = await postJson(endpointUrl(baseUrl, DEVICE_LOGIN_TOKEN_PATH), {
|
|
900
|
+
device_code: start.deviceCode,
|
|
901
|
+
grant_type: 'urn:ietf:params:oauth:grant-type:device_code'
|
|
902
|
+
}, timeoutMs, io, { allowDevicePending: true });
|
|
903
|
+
|
|
904
|
+
const token = stringValue(payload, ['access_token']) ?? stringValue(payload, ['token']);
|
|
905
|
+
if (token) {
|
|
906
|
+
validateToken(token);
|
|
907
|
+
return token;
|
|
908
|
+
}
|
|
909
|
+
|
|
910
|
+
const error = stringValue(payload, ['error']);
|
|
911
|
+
if (error && error !== 'authorization_pending' && error !== 'slow_down') {
|
|
912
|
+
throw new UsageError(`Device login failed: ${error}`);
|
|
913
|
+
}
|
|
914
|
+
if (options.pollOnce) {
|
|
915
|
+
throw new UsageError('Device login is still pending.');
|
|
916
|
+
}
|
|
917
|
+
await sleep((error === 'slow_down' ? start.interval + 5 : start.interval) * 1000);
|
|
918
|
+
}
|
|
919
|
+
|
|
920
|
+
throw new UsageError('Device login expired before authorization completed.');
|
|
921
|
+
}
|
|
922
|
+
|
|
923
|
+
async function storeTokenFromStdin(io, metadata = {}) {
|
|
924
|
+
const token = (await readAll(io.stdin)).trim();
|
|
925
|
+
validateToken(token);
|
|
926
|
+
return await storeTokenValue(token, metadata, io.env);
|
|
927
|
+
}
|
|
928
|
+
|
|
929
|
+
async function storeTokenValue(token, metadata, env) {
|
|
930
|
+
validateToken(token);
|
|
931
|
+
const credentialPath = credentialsPath(env);
|
|
932
|
+
await writePlaintextCredential(credentialPath, token, metadata);
|
|
933
|
+
return {
|
|
934
|
+
ok: true,
|
|
935
|
+
credentialPath,
|
|
936
|
+
tokenPresent: true,
|
|
937
|
+
tokenPrinted: false,
|
|
938
|
+
projectFilesModified: false,
|
|
939
|
+
storage: 'user-scoped-credential-file'
|
|
940
|
+
};
|
|
941
|
+
}
|
|
942
|
+
|
|
943
|
+
async function readStoredCredential(env) {
|
|
944
|
+
const credentialPath = credentialsPath(env);
|
|
945
|
+
const content = await readTextIfExists(credentialPath);
|
|
946
|
+
if (!content.trim()) {
|
|
947
|
+
return { path: credentialPath, token: null };
|
|
948
|
+
}
|
|
949
|
+
|
|
950
|
+
const parsed = parseJsonConfig(content, credentialPath);
|
|
951
|
+
return {
|
|
952
|
+
path: credentialPath,
|
|
953
|
+
token: stringValue(parsed, ['token']),
|
|
954
|
+
storage: stringValue(parsed, ['storage'])
|
|
955
|
+
};
|
|
956
|
+
}
|
|
957
|
+
|
|
958
|
+
async function resolveCredentialToken(env) {
|
|
959
|
+
const environmentToken = env[TOKEN_ENV_VAR] ?? env[LEGACY_TOKEN_ENV_VAR];
|
|
960
|
+
if (environmentToken) {
|
|
961
|
+
return environmentToken;
|
|
962
|
+
}
|
|
963
|
+
const credential = await readStoredCredential(env);
|
|
964
|
+
return credential.token;
|
|
965
|
+
}
|
|
966
|
+
|
|
967
|
+
async function verifyTokenWithMcp(baseUrl, token, timeoutMs, io) {
|
|
968
|
+
const url = endpointUrl(baseUrl, '/mcp');
|
|
969
|
+
const controller = new AbortController();
|
|
970
|
+
const timeout = setTimeout(() => controller.abort(), timeoutMs);
|
|
971
|
+
try {
|
|
972
|
+
const response = await io.fetch(url, {
|
|
973
|
+
method: 'POST',
|
|
974
|
+
headers: {
|
|
975
|
+
accept: 'application/json, text/event-stream',
|
|
976
|
+
'content-type': 'application/json',
|
|
977
|
+
authorization: `Bearer ${token}`,
|
|
978
|
+
'user-agent': `XMemo-CLI/${CLI_VERSION} (+https://github.com/yonro/memory-os-cli)`
|
|
979
|
+
},
|
|
980
|
+
body: JSON.stringify({
|
|
981
|
+
jsonrpc: '2.0',
|
|
982
|
+
id: 1,
|
|
983
|
+
method: 'initialize',
|
|
984
|
+
params: {
|
|
985
|
+
protocolVersion: '2024-11-05',
|
|
986
|
+
capabilities: {},
|
|
987
|
+
clientInfo: { name: COMMAND_NAME, version: CLI_VERSION }
|
|
988
|
+
}
|
|
989
|
+
}),
|
|
990
|
+
signal: controller.signal
|
|
991
|
+
});
|
|
992
|
+
return {
|
|
993
|
+
ok: response.ok,
|
|
994
|
+
detail: response.ok ? `HTTP ${response.status}` : `HTTP ${response.status}`
|
|
995
|
+
};
|
|
996
|
+
} catch (error) {
|
|
997
|
+
return {
|
|
998
|
+
ok: false,
|
|
999
|
+
detail: error.name === 'AbortError' ? `timeout after ${timeoutMs}ms` : error.message
|
|
1000
|
+
};
|
|
1001
|
+
} finally {
|
|
1002
|
+
clearTimeout(timeout);
|
|
1003
|
+
}
|
|
1004
|
+
}
|
|
1005
|
+
|
|
643
1006
|
async function probe(url, timeoutMs, io) {
|
|
644
1007
|
if (typeof io.fetch !== 'function') {
|
|
645
1008
|
return { url, ok: false, error: 'fetch unavailable in this Node runtime' };
|
|
@@ -693,6 +1056,44 @@ async function fetchJson(url, timeoutMs, io) {
|
|
|
693
1056
|
}
|
|
694
1057
|
}
|
|
695
1058
|
|
|
1059
|
+
async function postJson(url, payload, timeoutMs, io, options = {}) {
|
|
1060
|
+
if (typeof io.fetch !== 'function') {
|
|
1061
|
+
throw new UsageError('This Node runtime does not provide fetch; use Node.js 20 or newer.');
|
|
1062
|
+
}
|
|
1063
|
+
|
|
1064
|
+
const controller = new AbortController();
|
|
1065
|
+
const timeout = setTimeout(() => controller.abort(), timeoutMs);
|
|
1066
|
+
|
|
1067
|
+
try {
|
|
1068
|
+
const response = await io.fetch(url, {
|
|
1069
|
+
method: 'POST',
|
|
1070
|
+
headers: {
|
|
1071
|
+
accept: 'application/json',
|
|
1072
|
+
'content-type': 'application/json'
|
|
1073
|
+
},
|
|
1074
|
+
body: JSON.stringify(payload),
|
|
1075
|
+
signal: controller.signal
|
|
1076
|
+
});
|
|
1077
|
+
const responsePayload = await response.json();
|
|
1078
|
+
if (!response.ok) {
|
|
1079
|
+
const error = stringValue(responsePayload, ['error']) ?? stringValue(responsePayload, ['detail']) ?? `HTTP ${response.status}`;
|
|
1080
|
+
if (options.allowDevicePending && (error === 'authorization_pending' || error === 'slow_down')) {
|
|
1081
|
+
return { error };
|
|
1082
|
+
}
|
|
1083
|
+
throw new UsageError(`Request failed with HTTP ${response.status}: ${url} (${error})`);
|
|
1084
|
+
}
|
|
1085
|
+
return responsePayload;
|
|
1086
|
+
} catch (error) {
|
|
1087
|
+
if (error instanceof UsageError) {
|
|
1088
|
+
throw error;
|
|
1089
|
+
}
|
|
1090
|
+
const reason = error.name === 'AbortError' ? `timeout after ${timeoutMs}ms` : error.message;
|
|
1091
|
+
throw new UsageError(`Request failed: ${url} (${reason})`);
|
|
1092
|
+
} finally {
|
|
1093
|
+
clearTimeout(timeout);
|
|
1094
|
+
}
|
|
1095
|
+
}
|
|
1096
|
+
|
|
696
1097
|
function ensureDiscoveryService(discovery, discoveryUrl) {
|
|
697
1098
|
const service = stringValue(discovery, ['service']);
|
|
698
1099
|
if (service && service !== 'memory-os') {
|
|
@@ -824,6 +1225,32 @@ function mcpConfigTemplate(clientId, mcpUrl) {
|
|
|
824
1225
|
};
|
|
825
1226
|
}
|
|
826
1227
|
|
|
1228
|
+
function mcpLocalProxyTemplate(clientId, proxyUrl) {
|
|
1229
|
+
const serverName = clientId === 'cursor' || clientId === 'gemini-cli' ? 'memory_os' : 'memory-os';
|
|
1230
|
+
return {
|
|
1231
|
+
client: clientId,
|
|
1232
|
+
serverName,
|
|
1233
|
+
snippetFormat: 'json',
|
|
1234
|
+
snippet: {
|
|
1235
|
+
mcpServers: {
|
|
1236
|
+
[serverName]: {
|
|
1237
|
+
type: 'http',
|
|
1238
|
+
url: proxyUrl
|
|
1239
|
+
}
|
|
1240
|
+
}
|
|
1241
|
+
},
|
|
1242
|
+
requiresCredential: [`${COMMAND_NAME} login`, `${COMMAND_NAME} token add --from-stdin`],
|
|
1243
|
+
requiresLocalCommand: `${COMMAND_NAME} mcp proxy --port ${new URL(proxyUrl).port || DEFAULT_PROXY_PORT}`,
|
|
1244
|
+
agentIdentity: {
|
|
1245
|
+
agentId: clientId,
|
|
1246
|
+
agentIdHeader: AGENT_ID_HEADER,
|
|
1247
|
+
agentInstanceEnvVar: AGENT_INSTANCE_ENV_VAR,
|
|
1248
|
+
agentInstanceHeader: AGENT_INSTANCE_HEADER
|
|
1249
|
+
},
|
|
1250
|
+
writesTokenValue: false
|
|
1251
|
+
};
|
|
1252
|
+
}
|
|
1253
|
+
|
|
827
1254
|
function sameMajorMinor(left, right) {
|
|
828
1255
|
const leftParts = left.split('.');
|
|
829
1256
|
const rightParts = right.split('.');
|
|
@@ -1386,13 +1813,15 @@ function defaultCursorConfigPath(env) {
|
|
|
1386
1813
|
return path.join(home, '.cursor', 'mcp.json');
|
|
1387
1814
|
}
|
|
1388
1815
|
|
|
1389
|
-
async function writePlaintextCredential(credentialPath, token) {
|
|
1816
|
+
async function writePlaintextCredential(credentialPath, token, metadata = {}) {
|
|
1390
1817
|
await fs.mkdir(path.dirname(credentialPath), { recursive: true, mode: 0o700 });
|
|
1391
1818
|
await bestEffortChmod(path.dirname(credentialPath), 0o700);
|
|
1392
1819
|
const payload = {
|
|
1393
1820
|
version: 1,
|
|
1394
1821
|
tokenEnvVar: TOKEN_ENV_VAR,
|
|
1395
|
-
storage: '
|
|
1822
|
+
storage: 'user-scoped-credential-file',
|
|
1823
|
+
createdAt: new Date().toISOString(),
|
|
1824
|
+
metadata,
|
|
1396
1825
|
token
|
|
1397
1826
|
};
|
|
1398
1827
|
await fs.writeFile(credentialPath, `${JSON.stringify(payload, null, 2)}\n`, { mode: 0o600 });
|
|
@@ -1494,6 +1923,46 @@ function parsePositiveInteger(value, name) {
|
|
|
1494
1923
|
return parsed;
|
|
1495
1924
|
}
|
|
1496
1925
|
|
|
1926
|
+
async function sleep(ms) {
|
|
1927
|
+
await new Promise((resolve) => setTimeout(resolve, ms));
|
|
1928
|
+
}
|
|
1929
|
+
|
|
1930
|
+
|
|
1931
|
+
function npmExecutable() {
|
|
1932
|
+
return os.platform() === 'win32' ? 'npm.cmd' : 'npm';
|
|
1933
|
+
}
|
|
1934
|
+
|
|
1935
|
+
|
|
1936
|
+
async function runProcess(command, args, io, { stream = true } = {}) {
|
|
1937
|
+
const spawnFn = io.spawn ?? spawn;
|
|
1938
|
+
return await new Promise((resolve, reject) => {
|
|
1939
|
+
const child = spawnFn(command, args, {
|
|
1940
|
+
stdio: ['ignore', 'pipe', 'pipe']
|
|
1941
|
+
});
|
|
1942
|
+
let stdout = '';
|
|
1943
|
+
let stderr = '';
|
|
1944
|
+
child.stdout?.on('data', (chunk) => {
|
|
1945
|
+
const text = String(chunk);
|
|
1946
|
+
stdout += text;
|
|
1947
|
+
if (stream) {
|
|
1948
|
+
io.stdout.write(text);
|
|
1949
|
+
}
|
|
1950
|
+
});
|
|
1951
|
+
child.stderr?.on('data', (chunk) => {
|
|
1952
|
+
const text = String(chunk);
|
|
1953
|
+
stderr += text;
|
|
1954
|
+
if (stream) {
|
|
1955
|
+
io.stderr.write(text);
|
|
1956
|
+
}
|
|
1957
|
+
});
|
|
1958
|
+
child.on('error', reject);
|
|
1959
|
+
child.on('close', (code) => {
|
|
1960
|
+
resolve({ code: code ?? 0, stdout, stderr });
|
|
1961
|
+
});
|
|
1962
|
+
});
|
|
1963
|
+
}
|
|
1964
|
+
|
|
1965
|
+
|
|
1497
1966
|
async function readAll(stream) {
|
|
1498
1967
|
let content = '';
|
|
1499
1968
|
for await (const chunk of stream) {
|