@xmemo/client 0.4.128 → 0.4.131

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.
Files changed (3) hide show
  1. package/README.md +16 -7
  2. package/package.json +1 -1
  3. package/src/cli.js +158 -37
package/README.md CHANGED
@@ -20,12 +20,10 @@ Upgrade an existing global install:
20
20
 
21
21
  ```bash
22
22
  xmemo update
23
- # or
24
- xmemo --update
25
23
  ```
26
24
 
27
- Both commands run `npm install -g @xmemo/client@latest`. Use
28
- `xmemo update --dry-run` to print the exact command without changing anything.
25
+ This runs `npm install -g @xmemo/client@latest`. Use `xmemo update --dry-run`
26
+ to print the exact command without changing anything.
29
27
 
30
28
  ## Commands
31
29
 
@@ -38,6 +36,7 @@ xmemo doctor
38
36
  xmemo discovery show
39
37
  xmemo setup
40
38
  xmemo login
39
+ xmemo auth status
41
40
  xmemo status
42
41
  xmemo token status
43
42
  xmemo token add --from-stdin
@@ -57,8 +56,11 @@ xmemo privacy
57
56
  token values into project files.
58
57
  - The CLI generates one stable non-secret `XMEMO_AGENT_INSTANCE_ID` per local
59
58
  client profile and stores it in user-scoped config outside git.
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.
59
+ - `xmemo login` stores the issued credential in the user-scoped XMemo CLI
60
+ config directory, shows the approved account when the server provides it,
61
+ and does not require extra token configuration afterward.
62
+ - `xmemo token add` remains available for existing tokens and still avoids
63
+ project files, shell history, and printed token values.
62
64
  - Legacy `xmemo token set` refuses plaintext credential storage unless
63
65
  `--allow-plaintext` is explicitly provided.
64
66
  - The npm package uses a `files` whitelist so only `bin`, `src`, `README.md`,
@@ -70,12 +72,19 @@ Recommended personal-user flow:
70
72
 
71
73
  ```bash
72
74
  xmemo login
75
+ xmemo auth status
73
76
  xmemo token status --verify
74
77
  ```
75
78
 
76
79
  `xmemo login` uses the hosted device-login flow when the service advertises it:
77
80
  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.
81
+ the CLI stores the issued MCP token in the user-scoped credential file. When the
82
+ service returns approved account metadata, the CLI prints the account label so
83
+ users can confirm which XMemo account was connected. No manual token setup is
84
+ needed after a successful `xmemo login`; `xmemo token status --verify` is only
85
+ an optional connectivity check. The CLI waits for the full browser authorization
86
+ window by default; use `--timeout-ms` only to shorten or extend that approval
87
+ window, and `--http-timeout-ms` only for individual service requests.
79
88
 
80
89
  Users who already have a token can configure it directly without shell profiles:
81
90
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xmemo/client",
3
- "version": "0.4.128",
3
+ "version": "0.4.131",
4
4
  "description": "Privacy-first CLI and MCP setup helper for XMemo.",
5
5
  "type": "module",
6
6
  "bin": {
package/src/cli.js CHANGED
@@ -10,7 +10,7 @@ const PACKAGE_NAME = '@xmemo/client';
10
10
  const FALLBACK_PACKAGE_NAME = '@yonro/xmemo-client';
11
11
  const COMMAND_NAME = 'xmemo';
12
12
  const LEGACY_COMMAND_NAME = 'memory-os';
13
- const CLI_VERSION = '0.4.128';
13
+ const CLI_VERSION = '0.4.131';
14
14
  const DEFAULT_SERVICE_URL = 'https://xmemo.dev';
15
15
  const TOKEN_ENV_VAR = 'XMEMO_KEY';
16
16
  const LEGACY_TOKEN_ENV_VAR = 'MEMORY_OS_MCP_TOKEN';
@@ -89,6 +89,10 @@ export async function run(args, io = defaultIo()) {
89
89
  return await loginCommand(args.slice(1), io);
90
90
  }
91
91
 
92
+ if (command === 'auth') {
93
+ return await authCommand(args.slice(1), io);
94
+ }
95
+
92
96
  if (command === 'token') {
93
97
  return await tokenCommand(args.slice(1), io);
94
98
  }
@@ -144,11 +148,11 @@ function writeHelp(io) {
144
148
  writeLine(io.stdout, '');
145
149
  writeLine(io.stdout, 'Usage:');
146
150
  writeLine(io.stdout, ` ${COMMAND_NAME} update [--dry-run] [--json]`);
147
- writeLine(io.stdout, ` ${COMMAND_NAME} --update [--dry-run] [--json]`);
148
151
  writeLine(io.stdout, ` ${COMMAND_NAME} doctor [--base-url <https://api.example.com>] [--json]`);
149
152
  writeLine(io.stdout, ` ${COMMAND_NAME} discovery show [--base-url <https://api.example.com>] [--json]`);
150
153
  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]`);
154
+ writeLine(io.stdout, ` ${COMMAND_NAME} login [--from-stdin] [--base-url <url>] [--timeout-ms <ms>] [--http-timeout-ms <ms>] [--json]`);
155
+ writeLine(io.stdout, ` ${COMMAND_NAME} auth status [--verify] [--base-url <url>] [--json]`);
152
156
  writeLine(io.stdout, ` ${COMMAND_NAME} status [--url <https://api.example.com>] [--json]`);
153
157
  writeLine(io.stdout, ` ${COMMAND_NAME} token status [--verify]`);
154
158
  writeLine(io.stdout, ` ${COMMAND_NAME} token add --from-stdin`);
@@ -165,6 +169,7 @@ function writeHelp(io) {
165
169
  writeLine(io.stdout, ` ${COMMAND_NAME} privacy`);
166
170
  writeLine(io.stdout, '');
167
171
  writeLine(io.stdout, `Default service URL: ${DEFAULT_SERVICE_URL}; use --url or XMEMO_URL for private deployments.`);
172
+ writeLine(io.stdout, '`login --timeout-ms` controls the full browser approval window; HTTP calls use `--http-timeout-ms`.');
168
173
  writeLine(io.stdout, '');
169
174
  writeLine(io.stdout, 'Privacy defaults: no telemetry, no token in project files, and no token is sent by `status`, `doctor`, or `discovery`.');
170
175
  writeLine(io.stdout, '`login` and `token add` store credentials only in the user-scoped XMemo CLI config directory.');
@@ -453,7 +458,8 @@ async function loginCommand(args, io) {
453
458
  const outputJson = hasFlag(args, '--json');
454
459
  const fromStdin = hasFlag(args, '--from-stdin') || hasFlag(args, '--token-stdin');
455
460
  const baseUrl = normalizeBaseUrl(baseUrlOption(args, io.env));
456
- const timeoutMs = parsePositiveInteger(optionValue(args, '--timeout-ms') ?? '30000', '--timeout-ms');
461
+ const httpTimeoutMs = parsePositiveInteger(optionValue(args, '--http-timeout-ms') ?? '30000', '--http-timeout-ms');
462
+ const loginTimeoutOption = optionValue(args, '--timeout-ms');
457
463
  const pollOnce = hasFlag(args, '--poll-once');
458
464
 
459
465
  if (fromStdin) {
@@ -468,7 +474,10 @@ async function loginCommand(args, io) {
468
474
  return 0;
469
475
  }
470
476
 
471
- const start = await startDeviceLogin(baseUrl, timeoutMs, io);
477
+ const start = await startDeviceLogin(baseUrl, httpTimeoutMs, io);
478
+ const loginTimeoutMs = loginTimeoutOption
479
+ ? parsePositiveInteger(loginTimeoutOption, '--timeout-ms')
480
+ : Math.max(1000, start.expiresIn * 1000);
472
481
  if (!outputJson) {
473
482
  writeLine(io.stdout, `${PRODUCT_NAME} device login`);
474
483
  writeLine(io.stdout, `Open: ${start.verificationUriComplete ?? start.verificationUri}`);
@@ -478,12 +487,13 @@ async function loginCommand(args, io) {
478
487
  writeLine(io.stdout, 'Waiting for authorization...');
479
488
  }
480
489
 
481
- const token = await pollDeviceLogin(baseUrl, start, timeoutMs, io, { pollOnce });
482
- const result = await storeTokenValue(token, { source: 'device-login' }, io.env);
490
+ const token = await pollDeviceLogin(baseUrl, start, loginTimeoutMs, httpTimeoutMs, io, { pollOnce });
491
+ const result = await storeTokenValue(token.accessToken, { source: 'device-login', account: token.account }, io.env);
483
492
  const payload = {
484
493
  ...result,
485
494
  baseUrl,
486
495
  verificationUri: start.verificationUri,
496
+ account: token.account,
487
497
  deviceLogin: true
488
498
  };
489
499
 
@@ -491,12 +501,34 @@ async function loginCommand(args, io) {
491
501
  writeLine(io.stdout, JSON.stringify(payload, null, 2));
492
502
  } else {
493
503
  writeLine(io.stdout, 'Login complete. Token stored securely in the user-scoped XMemo CLI config directory.');
504
+ if (token.account) {
505
+ writeLine(io.stdout, `Signed in as: ${formatAccount(token.account)}`);
506
+ }
494
507
  writeLine(io.stdout, `Credential path: ${result.credentialPath}`);
495
- writeLine(io.stdout, `Verify with: ${COMMAND_NAME} token status --verify`);
508
+ writeLine(io.stdout, 'No extra token configuration is required.');
509
+ writeLine(io.stdout, `Optional check: ${COMMAND_NAME} token status --verify`);
496
510
  }
497
511
  return 0;
498
512
  }
499
513
 
514
+ async function authCommand(args, io) {
515
+ const subcommand = args[0] ?? 'help';
516
+
517
+ if (subcommand === 'help' || subcommand === '--help' || subcommand === '-h') {
518
+ writeLine(io.stdout, 'Auth commands:');
519
+ writeLine(io.stdout, ` ${COMMAND_NAME} auth status [--verify] [--base-url <url>] [--json]`);
520
+ writeLine(io.stdout, '');
521
+ writeLine(io.stdout, `Use \`${COMMAND_NAME} login\` to sign in and \`${COMMAND_NAME} token add --from-stdin\` to store an existing token.`);
522
+ return 0;
523
+ }
524
+
525
+ if (subcommand === 'status') {
526
+ return await credentialStatusCommand(args.slice(1), io, { mode: 'auth' });
527
+ }
528
+
529
+ throw new UsageError(`Unknown auth command: ${subcommand}`);
530
+ }
531
+
500
532
  async function tokenCommand(args, io) {
501
533
  const subcommand = args[0] ?? 'help';
502
534
 
@@ -512,25 +544,7 @@ async function tokenCommand(args, io) {
512
544
  }
513
545
 
514
546
  if (subcommand === 'status') {
515
- const credential = await readStoredCredential(io.env);
516
- const hasEnvironmentToken = Boolean(io.env[TOKEN_ENV_VAR] ?? io.env[LEGACY_TOKEN_ENV_VAR]);
517
- const hasUserCredential = Boolean(credential.token);
518
- writeLine(io.stdout, `Environment token: ${hasEnvironmentToken ? 'present' : 'missing'} (${TOKEN_ENV_VAR})`);
519
- writeLine(io.stdout, `User credential file: ${hasUserCredential ? 'present' : 'missing'} (${credential.path})`);
520
- writeLine(io.stdout, 'Token values are never printed.');
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;
547
+ return await credentialStatusCommand(args.slice(1), io, { mode: 'token' });
534
548
  }
535
549
 
536
550
  if (subcommand === 'add') {
@@ -569,6 +583,79 @@ async function tokenCommand(args, io) {
569
583
  throw new UsageError(`Unknown token command: ${subcommand}`);
570
584
  }
571
585
 
586
+ async function credentialStatusCommand(args, io, { mode }) {
587
+ const outputJson = hasFlag(args, '--json');
588
+ const verify = hasFlag(args, '--verify');
589
+ const credential = await readStoredCredential(io.env);
590
+ const environmentToken = io.env[TOKEN_ENV_VAR] ?? io.env[LEGACY_TOKEN_ENV_VAR] ?? '';
591
+ const hasEnvironmentToken = Boolean(environmentToken);
592
+ const hasUserCredential = Boolean(credential.token);
593
+ const tokenSource = hasEnvironmentToken ? 'environment' : hasUserCredential ? 'user-credential-file' : 'missing';
594
+ const report = {
595
+ loggedIn: hasEnvironmentToken || hasUserCredential,
596
+ tokenSource,
597
+ environmentToken: {
598
+ present: hasEnvironmentToken,
599
+ variable: hasEnvironmentToken && io.env[TOKEN_ENV_VAR] ? TOKEN_ENV_VAR : hasEnvironmentToken ? LEGACY_TOKEN_ENV_VAR : TOKEN_ENV_VAR
600
+ },
601
+ userCredentialFile: {
602
+ present: hasUserCredential,
603
+ path: credential.path,
604
+ storage: credential.storage ?? null
605
+ },
606
+ account: credential.account ?? null,
607
+ privacy: {
608
+ tokenPrinted: false,
609
+ projectFilesModified: false
610
+ }
611
+ };
612
+
613
+ if (verify) {
614
+ const token = await resolveCredentialToken(io.env);
615
+ if (!token) {
616
+ if (outputJson) {
617
+ writeLine(io.stdout, JSON.stringify({ ...report, verification: { ok: false, detail: 'no token found' } }, null, 2));
618
+ } else {
619
+ writeCredentialStatus(report, io, { mode });
620
+ writeLine(io.stderr, `No token found. Run \`${COMMAND_NAME} login\` or \`${COMMAND_NAME} token add --from-stdin\`.`);
621
+ }
622
+ return 1;
623
+ }
624
+ const baseUrl = normalizeBaseUrl(baseUrlOption(args, io.env));
625
+ const timeoutMs = parsePositiveInteger(optionValue(args, '--timeout-ms') ?? '10000', '--timeout-ms');
626
+ const verification = await verifyTokenWithMcp(baseUrl, token, timeoutMs, io);
627
+ report.verification = verification;
628
+ if (outputJson) {
629
+ writeLine(io.stdout, JSON.stringify(report, null, 2));
630
+ return verification.ok ? 0 : 1;
631
+ }
632
+ writeCredentialStatus(report, io, { mode });
633
+ writeLine(io.stdout, `Remote token verification: ${verification.ok ? 'ok' : 'failed'} (${verification.detail})`);
634
+ return verification.ok ? 0 : 1;
635
+ }
636
+
637
+ if (outputJson) {
638
+ writeLine(io.stdout, JSON.stringify(report, null, 2));
639
+ } else {
640
+ writeCredentialStatus(report, io, { mode });
641
+ }
642
+ return report.loggedIn ? 0 : 1;
643
+ }
644
+
645
+ function writeCredentialStatus(report, io, { mode }) {
646
+ if (mode === 'auth') {
647
+ writeLine(io.stdout, `${PRODUCT_NAME} auth status`);
648
+ writeLine(io.stdout, `Logged in: ${report.loggedIn ? 'yes' : 'no'}`);
649
+ writeLine(io.stdout, `Credential source: ${report.tokenSource}`);
650
+ }
651
+ writeLine(io.stdout, `Environment token: ${report.environmentToken.present ? 'present' : 'missing'} (${report.environmentToken.variable})`);
652
+ writeLine(io.stdout, `User credential file: ${report.userCredentialFile.present ? 'present' : 'missing'} (${report.userCredentialFile.path})`);
653
+ if (report.account) {
654
+ writeLine(io.stdout, `Account: ${formatAccount(report.account)}`);
655
+ }
656
+ writeLine(io.stdout, report.loggedIn ? 'Credential is ready; token value remains hidden.' : `Run \`${COMMAND_NAME} login\` to sign in.`);
657
+ }
658
+
572
659
  async function mcpCommand(args, io) {
573
660
  const subcommand = args[0] ?? 'help';
574
661
 
@@ -893,18 +980,23 @@ async function startDeviceLogin(baseUrl, timeoutMs, io) {
893
980
  };
894
981
  }
895
982
 
896
- async function pollDeviceLogin(baseUrl, start, timeoutMs, io, options = {}) {
897
- const deadline = Date.now() + Math.min(start.expiresIn * 1000, timeoutMs);
983
+ async function pollDeviceLogin(baseUrl, start, loginTimeoutMs, httpTimeoutMs, io, options = {}) {
984
+ const deadline = Date.now() + Math.min(start.expiresIn * 1000, loginTimeoutMs);
985
+ const sleepFn = io.sleep ?? sleep;
986
+ let intervalSeconds = start.interval;
898
987
  while (Date.now() <= deadline) {
899
988
  const payload = await postJson(endpointUrl(baseUrl, DEVICE_LOGIN_TOKEN_PATH), {
900
989
  device_code: start.deviceCode,
901
990
  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;
991
+ }, httpTimeoutMs, io, { allowDevicePending: true });
992
+
993
+ const accessToken = stringValue(payload, ['access_token']) ?? stringValue(payload, ['token']);
994
+ if (accessToken) {
995
+ validateToken(accessToken);
996
+ return {
997
+ accessToken,
998
+ account: accountFromPayload(payload)
999
+ };
908
1000
  }
909
1001
 
910
1002
  const error = stringValue(payload, ['error']);
@@ -914,7 +1006,10 @@ async function pollDeviceLogin(baseUrl, start, timeoutMs, io, options = {}) {
914
1006
  if (options.pollOnce) {
915
1007
  throw new UsageError('Device login is still pending.');
916
1008
  }
917
- await sleep((error === 'slow_down' ? start.interval + 5 : start.interval) * 1000);
1009
+ if (error === 'slow_down') {
1010
+ intervalSeconds += 5;
1011
+ }
1012
+ await sleepFn(intervalSeconds * 1000);
918
1013
  }
919
1014
 
920
1015
  throw new UsageError('Device login expired before authorization completed.');
@@ -951,10 +1046,36 @@ async function readStoredCredential(env) {
951
1046
  return {
952
1047
  path: credentialPath,
953
1048
  token: stringValue(parsed, ['token']),
954
- storage: stringValue(parsed, ['storage'])
1049
+ storage: stringValue(parsed, ['storage']),
1050
+ account: accountFromPayload(parsed.metadata)
1051
+ };
1052
+ }
1053
+
1054
+ function accountFromPayload(payload) {
1055
+ const account = payload && typeof payload === 'object'
1056
+ ? (payload.user && typeof payload.user === 'object' ? payload.user : payload.account)
1057
+ : null;
1058
+ if (!account || typeof account !== 'object') {
1059
+ return null;
1060
+ }
1061
+ const userId = stringValue(account, ['user_id']) ?? stringValue(account, ['id']) ?? stringValue(account, ['userId']);
1062
+ const email = stringValue(account, ['email']);
1063
+ const displayName = stringValue(account, ['display_name']) ?? stringValue(account, ['name']) ?? stringValue(account, ['displayName']);
1064
+ if (!userId && !email && !displayName) {
1065
+ return null;
1066
+ }
1067
+ return {
1068
+ userId: userId ?? null,
1069
+ email: email ?? null,
1070
+ displayName: displayName ?? null
955
1071
  };
956
1072
  }
957
1073
 
1074
+ function formatAccount(account) {
1075
+ const label = account.displayName || account.email || account.userId || 'XMemo account';
1076
+ return account.email && account.displayName ? `${account.displayName} <${account.email}>` : label;
1077
+ }
1078
+
958
1079
  async function resolveCredentialToken(env) {
959
1080
  const environmentToken = env[TOKEN_ENV_VAR] ?? env[LEGACY_TOKEN_ENV_VAR];
960
1081
  if (environmentToken) {