@vigilbase/cli 0.2.3 → 0.2.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (75) hide show
  1. package/CLOUDFLARE.md +2 -2
  2. package/COMMANDS.md +111 -44
  3. package/LOCAL.md +99 -38
  4. package/ONE.md +2 -2
  5. package/README.md +215 -67
  6. package/dist/auth/acl.d.ts +6 -0
  7. package/dist/auth/acl.js +45 -0
  8. package/dist/auth/device-auth.js +1 -0
  9. package/dist/auth/provider.js +4 -4
  10. package/dist/auth/refresh.d.ts +5 -4
  11. package/dist/auth/refresh.js +82 -41
  12. package/dist/auth/store.d.ts +25 -0
  13. package/dist/auth/store.js +91 -11
  14. package/dist/bin.js +13 -1
  15. package/dist/cli.js +205 -81
  16. package/dist/commands/account.js +11 -7
  17. package/dist/commands/auth.d.ts +6 -0
  18. package/dist/commands/auth.js +75 -24
  19. package/dist/commands/billing.js +2 -2
  20. package/dist/commands/cloudflare.d.ts +1 -1
  21. package/dist/commands/cloudflare.js +31 -17
  22. package/dist/commands/helpers.js +3 -3
  23. package/dist/commands/index.d.ts +1 -0
  24. package/dist/commands/index.js +2 -1
  25. package/dist/commands/legacy-validation.d.ts +20 -0
  26. package/dist/commands/legacy-validation.js +77 -3
  27. package/dist/commands/local.js +186 -65
  28. package/dist/commands/one.js +16 -13
  29. package/dist/commands/public-tools.js +52 -27
  30. package/dist/commands/runs.js +5 -5
  31. package/dist/commands/security.d.ts +1 -1
  32. package/dist/commands/security.js +41 -37
  33. package/dist/commands/watchdog.js +5 -5
  34. package/dist/lib/banner.d.ts +5 -11
  35. package/dist/lib/banner.js +11 -31
  36. package/dist/lib/client.js +19 -1
  37. package/dist/lib/cloudflare-client.d.ts +2 -2
  38. package/dist/lib/config.d.ts +16 -0
  39. package/dist/lib/config.js +77 -19
  40. package/dist/lib/format.d.ts +1 -1
  41. package/dist/lib/format.js +75 -24
  42. package/dist/lib/help.js +57 -6
  43. package/dist/lib/network.d.ts +12 -3
  44. package/dist/lib/network.js +73 -9
  45. package/dist/lib/one-client.js +9 -2
  46. package/dist/lib/one-error-hints.d.ts +1 -0
  47. package/dist/lib/one-error-hints.js +10 -0
  48. package/dist/lib/open-url.js +2 -1
  49. package/dist/lib/platform.d.ts +2 -0
  50. package/dist/lib/platform.js +5 -0
  51. package/dist/lib/progress.d.ts +18 -3
  52. package/dist/lib/progress.js +72 -10
  53. package/dist/lib/system-trust.d.ts +2 -0
  54. package/dist/lib/system-trust.js +6 -0
  55. package/dist/lib/tui.d.ts +1 -1
  56. package/dist/lib/tui.js +5 -7
  57. package/dist/local/cloudflare-checkup/cf/child-env.js +2 -4
  58. package/dist/local/cloudflare-checkup/cf/install.d.ts +4 -2
  59. package/dist/local/cloudflare-checkup/cf/install.js +55 -24
  60. package/dist/local/cloudflare-checkup/cf/locate.d.ts +2 -0
  61. package/dist/local/cloudflare-checkup/cf/locate.js +10 -1
  62. package/dist/local/cloudflare-checkup/cf/run-cf.d.ts +18 -3
  63. package/dist/local/cloudflare-checkup/cf/run-cf.js +75 -17
  64. package/dist/local/cloudflare-checkup/contract.d.ts +16 -9
  65. package/dist/local/cloudflare-checkup/contract.js +80 -14
  66. package/dist/local/cloudflare-checkup/errors.d.ts +34 -1
  67. package/dist/local/cloudflare-checkup/errors.js +36 -5
  68. package/dist/local/cloudflare-checkup/output.d.ts +6 -0
  69. package/dist/local/cloudflare-checkup/output.js +9 -0
  70. package/dist/local/cloudflare-checkup/snapshot-file.d.ts +6 -2
  71. package/dist/local/cloudflare-checkup/snapshot-file.js +25 -11
  72. package/dist/local/cloudflare-checkup/submit-client.js +7 -1
  73. package/dist/types.d.ts +2 -1
  74. package/esm-only.cjs +3 -0
  75. package/package.json +7 -4
@@ -4,9 +4,10 @@ import { open, rm, lstat } from 'node:fs/promises';
4
4
  import { dirname } from 'node:path';
5
5
  import { z } from 'zod';
6
6
  import { DEFAULT_TIMEOUT_MS, validateOneOrigin } from '../lib/config.js';
7
- import { requestText, parseJsonResponse } from '../lib/network.js';
8
- import { assertFile, checkDirectory, credentialsPath, readCredentials, readLockedCredentials, readStoredCredentials, writeOneCredentials, clearOneCredentials, clearIdentityCredentials, DEFAULT_ONE_ORIGIN, UNCERTAIN_REFRESH } from './store.js';
7
+ import { requestText, parseJsonResponse, NetworkError } from '../lib/network.js';
8
+ import { assertFile, checkDirectory, credentialsPath, readCredentials, readLockedCredentials, readStoredCredentials, writeOneCredentials, clearOneCredentials, clearIdentityCredentials, shellQuote, DEFAULT_ONE_ORIGIN, SignInEndedError, UNCERTAIN_REFRESH } from './store.js';
9
9
  import { identityProvider, validateIdentityIssuer } from './provider.js';
10
+ import { stripAcl } from './acl.js';
10
11
  export function boundIdentityCredentials(config, one) {
11
12
  try {
12
13
  return one.clientId === config.clientId && one.resource === validateOneOrigin(config.oneBaseUrl) && one.oneBaseUrl === one.resource && validateIdentityIssuer(one.issuer, config) === one.issuer;
@@ -21,14 +22,51 @@ export function credentialsLockWaitMs(timeoutMs) {
21
22
  const requested = typeof timeoutMs === 'number' && Number.isFinite(timeoutMs) && timeoutMs > 0 ? timeoutMs : DEFAULT_TIMEOUT_MS;
22
23
  return Math.min(requested, MAX_LOCK_WAIT_MS);
23
24
  }
24
- /** Another command holds credentials.json.lock. */
25
+ /** credentials.json.lock is held: by a running command, by one that was interrupted, or it is damaged. */
25
26
  export class CredentialsLockedError extends Error {
26
- constructor() { super('Credentials are locked by another command. A crashed refresh requires a new login; do not reuse its refresh token.'); this.name = 'CredentialsLockedError'; }
27
+ constructor(message) { super(message); this.name = 'CredentialsLockedError'; }
28
+ }
29
+ /** An empty or unparseable lock this old is not one a command is still writing; login and logout replace it. */
30
+ const DAMAGED_LOCK_AGE_MS = 30_000;
31
+ function isMissing(error) { return error instanceof Error && 'code' in error && error.code === 'ENOENT'; }
32
+ /** Who holds the lock, or null when it was released in between. `pid` is null for an empty or unparseable lock. */
33
+ async function readLockOwner(path) {
34
+ let info, handle;
35
+ try {
36
+ info = await lstat(path);
37
+ assertFile(info, path);
38
+ handle = await open(path, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
39
+ }
40
+ catch (error) {
41
+ if (isMissing(error))
42
+ return null;
43
+ throw error;
44
+ }
45
+ let owner = '';
46
+ try {
47
+ assertFile(await handle.stat(), path);
48
+ owner = await handle.readFile('utf8');
49
+ }
50
+ finally {
51
+ await handle.close();
52
+ }
53
+ if (!/^\s*\d+\s*$/.test(owner) || !Number.isSafeInteger(Number(owner)) || Number(owner) <= 0)
54
+ return { info, pid: null, isDead: false };
55
+ const pid = Number(owner);
56
+ let isDead = false;
57
+ try {
58
+ process.kill(pid, 0);
59
+ }
60
+ catch (error) {
61
+ isDead = error instanceof Error && 'code' in error && error.code === 'ESRCH';
62
+ }
63
+ return { info, pid, isDead };
27
64
  }
28
65
  /**
29
66
  * credentials.json.lock guards both credentials files; CLI 0.2.0 takes the same lock.
30
- * Never steal a stale lock: a crashed refresh may have consumed the rotating token.
31
- * With `wait` false, a held lock fails at once instead of after the lock wait.
67
+ * Only login and logout (`replaceDeadLock`) replace a lock whose owner has exited, or an old damaged one: a crashed
68
+ * refresh may have consumed the rotating token, and both start over without it. Other commands fail at once on a
69
+ * dead owner. With `wait` false, a held lock fails at once instead of after the lock wait.
32
70
  */
33
71
  export async function withCredentialsLock(config, callback, replaceDeadLock = false, wait = true) {
34
72
  const path = `${credentialsPath(config)}.lock`;
@@ -43,42 +81,29 @@ export async function withCredentialsLock(config, callback, replaceDeadLock = fa
43
81
  catch (error) {
44
82
  if (!(error instanceof Error && 'code' in error && error.code === 'EEXIST'))
45
83
  throw error;
46
- const existing = await lstat(path);
47
- assertFile(existing, path);
48
- if (replaceDeadLock) {
49
- const existingHandle = await open(path, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
50
- let owner = '';
51
- try {
52
- assertFile(await existingHandle.stat(), path);
53
- owner = await existingHandle.readFile('utf8');
54
- }
55
- finally {
56
- await existingHandle.close();
57
- }
58
- const pid = Number(owner);
59
- let dead = false;
60
- if (Number.isSafeInteger(pid) && pid > 0) {
61
- try {
62
- process.kill(pid, 0);
63
- }
64
- catch (error) {
65
- dead = error instanceof Error && 'code' in error && error.code === 'ESRCH';
66
- }
67
- }
68
- if (dead) {
69
- const current = await lstat(path);
70
- if (current.ino === existing.ino && current.dev === existing.dev) {
71
- await rm(path);
72
- continue;
73
- }
74
- }
84
+ const owner = await readLockOwner(path);
85
+ if (!owner)
86
+ continue;
87
+ const isOldDamaged = owner.pid === null && Date.now() - owner.info.mtimeMs > DAMAGED_LOCK_AGE_MS;
88
+ if (replaceDeadLock && (owner.isDead || isOldDamaged)) {
89
+ const current = await lstat(path).catch(() => null);
90
+ if (current?.ino === owner.info.ino && current.dev === owner.info.dev)
91
+ await rm(path);
92
+ continue;
93
+ }
94
+ if (owner.isDead)
95
+ throw new CredentialsLockedError(`A previous vigilbase command was interrupted while it held ${shellQuote(path)}. Run \`vigilbase login\` to recover.`);
96
+ if (!wait || Date.now() >= deadline) {
97
+ throw new CredentialsLockedError(owner.pid === null
98
+ ? `${path} is damaged. Stop every vigilbase command, delete it, and run \`vigilbase login\`.`
99
+ : `Credentials are locked by another vigilbase command (process ${owner.pid}). If none is running, delete ${shellQuote(path)} and run \`vigilbase login\`.`);
75
100
  }
76
- if (!wait || Date.now() >= deadline)
77
- throw new CredentialsLockedError();
78
101
  await new Promise(resolve => setTimeout(resolve, 100));
79
102
  }
80
103
  }
81
104
  await handle.chmod(0o600);
105
+ // Best effort: the lock holds only a PID, and a failure here must not leave it behind.
106
+ await stripAcl(path).catch(() => { });
82
107
  const info = await handle.stat();
83
108
  assertFile(info, path);
84
109
  try {
@@ -117,11 +142,24 @@ export async function managedAccessToken(config, failedToken) {
117
142
  throw new Error(UNCERTAIN_REFRESH);
118
143
  const provider = await identityProvider(config, one.issuer);
119
144
  await writeOneCredentials(config, { ...one, refreshPending: true });
120
- const { response, text } = await requestText(config, provider.tokenEndpoint, { method: 'POST', headers: { Accept: 'application/json', 'Content-Type': 'application/x-www-form-urlencoded', 'User-Agent': config.userAgent }, body: new URLSearchParams({ grant_type: 'refresh_token', refresh_token: one.refreshToken, client_id: one.clientId, resource: one.resource }) });
145
+ let response, text;
146
+ try {
147
+ ({ response, text } = await requestText(config, provider.tokenEndpoint, { option: '--auth-url', method: 'POST', headers: { Accept: 'application/json', 'Content-Type': 'application/x-www-form-urlencoded', 'User-Agent': config.userAgent }, body: new URLSearchParams({ grant_type: 'refresh_token', refresh_token: one.refreshToken, client_id: one.clientId, resource: one.resource }) }));
148
+ }
149
+ catch (error) {
150
+ // The request may have reached Identity and rotated the token, so it stays pending and can't be retried.
151
+ const reason = error instanceof NetworkError ? ` (${error.message.split(/\.(?:\s|$)/)[0]})` : '';
152
+ throw new Error(`The Identity refresh may not have completed${reason}. Run \`vigilbase login\` again; its token cannot be retried safely.`, { cause: error });
153
+ }
121
154
  const body = parseJsonResponse(response, text);
122
155
  if (!response.ok) {
123
- if (z.object({ error: z.literal('invalid_grant') }).safeParse(body).success)
156
+ if (z.object({ error: z.literal('invalid_grant') }).safeParse(body).success) {
124
157
  await clearIdentityCredentials(config);
158
+ throw new SignInEndedError();
159
+ }
160
+ // A definite client error means the token was not used. A server error may come after it rotated.
161
+ if (response.status < 500)
162
+ await writeOneCredentials(config, { ...one, refreshPending: false });
125
163
  throw new Error('Identity refresh failed. Run `vigilbase login` again.');
126
164
  }
127
165
  const token = z.object({ access_token: z.string().min(1), refresh_token: z.string().min(1), expires_in: z.number().int().positive().max(300), scope: z.string().optional() }).safeParse(body);
@@ -144,7 +182,7 @@ export async function revokeIdentityGrant(config, one) {
144
182
  if (!boundIdentityCredentials(config, one))
145
183
  throw new Error(SAVED_FOR_OTHER_CONFIGURATION);
146
184
  const provider = await identityProvider(config, one.issuer);
147
- const { response, text } = await requestText(config, provider.revocationEndpoint, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded', 'User-Agent': config.userAgent }, body: new URLSearchParams({ token: one.refreshToken, token_type_hint: 'refresh_token', client_id: one.clientId }) });
185
+ const { response, text } = await requestText(config, provider.revocationEndpoint, { option: '--auth-url', method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded', 'User-Agent': config.userAgent }, body: new URLSearchParams({ token: one.refreshToken, token_type_hint: 'refresh_token', client_id: one.clientId }) });
148
186
  if (response.ok)
149
187
  return;
150
188
  if (response.status === 400 && z.object({ error: z.enum(['invalid_request', 'invalid_grant']) }).safeParse(parseJsonResponse(response, text)).success)
@@ -172,12 +210,15 @@ export async function revokeIdentityCredentials(config) {
172
210
  /** Best effort: legacy One auth retires on 2026-10-31, and its session expires on its own. */
173
211
  async function signOutLegacySession(config, one) {
174
212
  const origin = validateOneOrigin(one.oneBaseUrl ?? DEFAULT_ONE_ORIGIN);
175
- const { response } = await requestText(config, `${origin}/api/auth/sign-out`, { method: 'POST', headers: { Accept: 'application/json', Authorization: `Bearer ${one.accessToken}`, 'User-Agent': config.userAgent }, redirect: 'manual', credentials: 'omit' });
213
+ const { response } = await requestText(config, `${origin}/api/auth/sign-out`, { option: '--one-url', method: 'POST', headers: { Accept: 'application/json', Authorization: `Bearer ${one.accessToken}`, 'User-Agent': config.userAgent }, redirect: 'manual', credentials: 'omit' });
176
214
  if (!response.ok)
177
215
  throw new Error('Legacy One sign-out failed.');
178
216
  }
179
217
  /** Revoke and remove every credentials file. A failed Identity revocation keeps them all. */
180
218
  export async function revokeSavedCredentials(config, onWarning) {
219
+ // Nothing is saved without a credentials directory; don't create one to take the lock.
220
+ if (!await checkDirectory(dirname(credentialsPath(config))))
221
+ return false;
181
222
  return withCredentialsLock(config, async () => {
182
223
  // A safe file that cannot be parsed cannot be revoked either; removing it keeps logout from dead-ending.
183
224
  const unreadable = [];
@@ -33,6 +33,18 @@ export declare function identityCredentialsPath(config: Pick<CliConfig, 'configD
33
33
  export declare class InvalidCredentialsError extends Error {
34
34
  constructor(message: string);
35
35
  }
36
+ /** A command that needs One found no usable sign-in. */
37
+ export declare class NotSignedInError extends Error {
38
+ readonly code = "NOT_SIGNED_IN";
39
+ constructor();
40
+ }
41
+ /** Identity answered a refresh with invalid_grant: the grant is gone and identity-credentials.json was removed. */
42
+ export declare class SignInEndedError extends Error {
43
+ readonly code = "SIGN_IN_ENDED";
44
+ constructor();
45
+ }
46
+ /** A path as one shell word for a remedy: as is when plain, otherwise in single quotes. */
47
+ export declare function shellQuote(path: string): string;
36
48
  /** Credentials cannot be protected by modes and owners on Windows yet. */
37
49
  export declare class SignInUnsupportedError extends Error {
38
50
  readonly code = "SIGN_IN_UNSUPPORTED";
@@ -85,6 +97,14 @@ export declare function clearOneCredentials(config: Pick<CliConfig, 'configDir'>
85
97
  /** Remove identity-credentials.json only; credentials.json stays for CLI 0.1.x. */
86
98
  export declare function clearIdentityCredentials(config: Pick<CliConfig, 'configDir'>): Promise<boolean>;
87
99
  export declare function getOneAccessToken(config: CredentialsConfig): Promise<string | null>;
100
+ /** Where a saved sign-in this configuration can't use belongs. `issuer` and `clientId` are null for a legacy session. */
101
+ export type SavedFor = {
102
+ oneBaseUrl: string;
103
+ issuer: string | null;
104
+ clientId: string | null;
105
+ };
106
+ /** The configuration the saved sign-in belongs to when it isn't this one; null when it is, or nothing is saved. */
107
+ export declare function savedForAnotherConfiguration(config: CredentialsConfig, { version, one }: CredentialsFile): Promise<SavedFor | null>;
88
108
  /** Saved sign-in for doctor, which reports problems instead of failing on them. */
89
109
  export type OptionalAccessToken = {
90
110
  accessToken: string | null;
@@ -97,6 +117,11 @@ export type OptionalAccessToken = {
97
117
  * With no saved sign-in there is no problem, even under a config path that fails the directory checks.
98
118
  */
99
119
  export declare function getOptionalOneAccessToken(config: CredentialsConfig): Promise<OptionalAccessToken>;
120
+ /**
121
+ * whoami's directory check, which agrees with list and doctor: false when the credentials directory is missing,
122
+ * or fails the checks with no credentials saved there. The strict check's error when credentials may be there.
123
+ */
124
+ export declare function hasCredentialsDirectory(config: Pick<CliConfig, 'configDir'>): Promise<boolean>;
100
125
  /**
101
126
  * Whether list can show the saved sign-in as usable, judged from the files alone: no refresh, no network
102
127
  * and no wait for the credentials lock. A legacy token must be unexpired; an Identity grant may hold an
@@ -3,8 +3,9 @@ import { constants } from 'node:fs';
3
3
  import { lstat, mkdir, open, rename, rm } from 'node:fs/promises';
4
4
  import { basename, dirname, isAbsolute, join, parse, resolve } from 'node:path';
5
5
  import { z } from 'zod';
6
- import { DEFAULT_TIMEOUT_MS } from '../lib/config.js';
6
+ import { DEFAULT_TIMEOUT_MS, relativeConfigDirProblem } from '../lib/config.js';
7
7
  import { platform } from '../lib/platform.js';
8
+ import { aclEntries, letsOthersChangeEntries, stripAcl } from './acl.js';
8
9
  const FILE_NAME = 'credentials.json';
9
10
  /** CLI 0.1.x rejects a version 2 credentials.json and deletes it unrevoked, so Identity grants live apart. */
10
11
  const IDENTITY_FILE_NAME = 'identity-credentials.json';
@@ -14,7 +15,7 @@ export const DEFAULT_ONE_ORIGIN = 'https://one.vigilbase.com';
14
15
  const INVALID_ONE_CREDENTIALS = 'Saved One credentials are invalid. Run `vigilbase logout`, then `vigilbase login` to replace them.';
15
16
  const INVALID_IDENTITY_CREDENTIALS = 'Saved Identity credentials are invalid. Run `vigilbase login` to replace them.';
16
17
  export const UNCERTAIN_REFRESH = 'A previous refresh has an uncertain result. Run `vigilbase login` again; its token cannot be retried safely.';
17
- const SAVED_FOR_ANOTHER_ORIGIN = 'Saved for a different One origin, Identity issuer or client ID.';
18
+ const SAVED_FOR_ANOTHER_ORIGIN = 'Saved for a different One origin, Identity issuer or client ID. Pass the matching --one-url, --auth-url and --client-id, or run `vigilbase login`.';
18
19
  export const OneCredentialsSchema = z.object({
19
20
  issuer: z.string().optional(), clientId: z.string().optional(), resource: z.string().optional(), refreshToken: z.string().min(1).optional(), refreshTokenExpiresAt: z.string().optional(), refreshPending: z.boolean().optional(), orgId: z.string().optional(),
20
21
  oneBaseUrl: z.string().optional(), accessToken: z.string().min(1), tokenType: z.string().optional(),
@@ -48,6 +49,26 @@ export class InvalidCredentialsError extends Error {
48
49
  this.name = 'InvalidCredentialsError';
49
50
  }
50
51
  }
52
+ /** A command that needs One found no usable sign-in. */
53
+ export class NotSignedInError extends Error {
54
+ code = 'NOT_SIGNED_IN';
55
+ constructor() {
56
+ super('This command needs a Vigilbase One sign-in: run `vigilbase login`.');
57
+ this.name = 'NotSignedInError';
58
+ }
59
+ }
60
+ /** Identity answered a refresh with invalid_grant: the grant is gone and identity-credentials.json was removed. */
61
+ export class SignInEndedError extends Error {
62
+ code = 'SIGN_IN_ENDED';
63
+ constructor() {
64
+ super('Your saved sign-in was ended by Vigilbase Identity and has been removed. Run `vigilbase login` again.');
65
+ this.name = 'SignInEndedError';
66
+ }
67
+ }
68
+ /** A path as one shell word for a remedy: as is when plain, otherwise in single quotes. */
69
+ export function shellQuote(path) {
70
+ return /^[\w@%+=:,./-]+$/.test(path) ? path : `'${path.replaceAll("'", `'\\''`)}'`;
71
+ }
51
72
  /** Credentials cannot be protected by modes and owners on Windows yet. */
52
73
  export class SignInUnsupportedError extends Error {
53
74
  code = 'SIGN_IN_UNSUPPORTED';
@@ -81,11 +102,16 @@ export function assertFile(info, path) {
81
102
  if (isOwnedByOtherUser(info))
82
103
  throw new CredentialsPathError(`${path} is owned by another user`, REMOVE_FILE);
83
104
  if ((info.mode & 0o777) !== MODE_FILE)
84
- throw new CredentialsPathError(`${path} must have mode 0600`, `Run chmod 600 ${path}.`);
105
+ throw new CredentialsPathError(`${path} must have mode 0600`, `Run chmod 600 ${shellQuote(path)}.`);
106
+ }
107
+ function aclError(path) {
108
+ return new CredentialsPathError(`${path} has an access control list`, `Run chmod -N ${shellQuote(path)}, or set VIGILBASE_CONFIG_DIR to another directory.`);
85
109
  }
86
110
  const USE_ANOTHER_DIRECTORY = 'Set VIGILBASE_CONFIG_DIR to a private directory without symbolic links in its path.';
111
+ /** Names the variable the path came from: VIGILBASE_CONFIG_DIR, or HOME for the default directory. */
87
112
  function relativeDirectoryError(configDir) {
88
- return new CredentialsPathError(`${configDir} is not an absolute path`, 'Set VIGILBASE_CONFIG_DIR to an absolute path.');
113
+ const { reason, remedy } = relativeConfigDirProblem(configDir);
114
+ return new CredentialsPathError(reason, remedy);
89
115
  }
90
116
  /**
91
117
  * Check every ancestor before traversing it; a writable non-sticky parent permits replacement.
@@ -117,6 +143,7 @@ export async function checkDirectory(configDir, isCreate = false) {
117
143
  if (!isCreate)
118
144
  return false;
119
145
  await mkdir(path, { mode: MODE_DIR });
146
+ await stripAcl(path);
120
147
  info = await lstat(path);
121
148
  }
122
149
  if (info.isSymbolicLink())
@@ -127,7 +154,7 @@ export async function checkDirectory(configDir, isCreate = false) {
127
154
  if (isOwnedByOtherUser(info))
128
155
  throw new CredentialsPathError(`${path} is owned by another user`, USE_ANOTHER_DIRECTORY);
129
156
  if ((info.mode & 0o777) !== MODE_DIR) {
130
- throw new CredentialsPathError(`${path} must have mode 0700`, `Run chmod 700 ${path}, or set VIGILBASE_CONFIG_DIR to another directory.`);
157
+ throw new CredentialsPathError(`${path} must have mode 0700`, `Run chmod 700 ${shellQuote(path)}, or set VIGILBASE_CONFIG_DIR to another directory.`);
131
158
  }
132
159
  }
133
160
  else {
@@ -137,18 +164,47 @@ export async function checkDirectory(configDir, isCreate = false) {
137
164
  throw new CredentialsPathError(`${path} is owned by another user`, USE_ANOTHER_DIRECTORY);
138
165
  }
139
166
  }
167
+ // Mode bits can't show a macOS access control list, and its allow entries override them.
168
+ let acls;
169
+ try {
170
+ acls = await aclEntries(paths);
171
+ }
172
+ catch (error) {
173
+ throw new CredentialsPathError(`${absolute} cannot be checked for an access control list`, USE_ANOTHER_DIRECTORY, { cause: error });
174
+ }
175
+ for (const [index, path] of paths.entries()) {
176
+ if (index === paths.length - 1) {
177
+ if (acls[index].length)
178
+ throw aclError(path);
179
+ }
180
+ else if (acls[index].some(letsOthersChangeEntries)) {
181
+ throw new CredentialsPathError(`${path} is writable by other users through its access control list`, USE_ANOTHER_DIRECTORY);
182
+ }
183
+ }
140
184
  return true;
141
185
  }
142
186
  async function checkExistingFile(path) {
143
187
  try {
144
188
  assertFile(await lstat(path), path);
145
- return true;
146
189
  }
147
190
  catch (error) {
148
191
  if (isMissing(error))
149
192
  return false;
150
193
  throw error;
151
194
  }
195
+ let acl;
196
+ try {
197
+ [acl = []] = await aclEntries([path]);
198
+ }
199
+ catch (error) {
200
+ // Another command may have removed it in between.
201
+ if (await lstat(path).then(() => false, isMissing))
202
+ return false;
203
+ throw new CredentialsPathError(`${path} cannot be checked for an access control list`, REMOVE_FILE, { cause: error });
204
+ }
205
+ if (acl.length)
206
+ throw aclError(path);
207
+ return true;
152
208
  }
153
209
  /** Parse one credentials file; the Identity file must hold a complete version 2 grant. */
154
210
  async function readCredentialsFile(path) {
@@ -321,6 +377,7 @@ export async function writeOneCredentials(config, one) {
321
377
  const handle = await open(temporaryPath, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, MODE_FILE);
322
378
  try {
323
379
  await handle.chmod(MODE_FILE);
380
+ await stripAcl(temporaryPath);
324
381
  await handle.writeFile(`${JSON.stringify(next, null, 2)}\n`, 'utf8');
325
382
  await handle.sync();
326
383
  await handle.close();
@@ -382,6 +439,18 @@ export async function getOneAccessToken(config) {
382
439
  }
383
440
  return hasExpired(one) ? null : one.accessToken;
384
441
  }
442
+ /** The configuration the saved sign-in belongs to when it isn't this one; null when it is, or nothing is saved. */
443
+ export async function savedForAnotherConfiguration(config, { version, one }) {
444
+ if (!one?.accessToken.trim())
445
+ return null;
446
+ if (version === 2) {
447
+ const { boundIdentityCredentials } = await import('./refresh.js');
448
+ if (boundIdentityCredentials(config, one))
449
+ return null;
450
+ return { oneBaseUrl: one.oneBaseUrl ?? one.resource ?? DEFAULT_ONE_ORIGIN, issuer: one.issuer ?? null, clientId: one.clientId ?? null };
451
+ }
452
+ return isForOneOrigin(one, config) ? null : { oneBaseUrl: one.oneBaseUrl ?? DEFAULT_ONE_ORIGIN, issuer: null, clientId: null };
453
+ }
385
454
  let hasWarnedAboutSignIn = false;
386
455
  function isAbsent(error) {
387
456
  return error instanceof Error && 'code' in error && (error.code === 'ENOENT' || error.code === 'ENOTDIR');
@@ -447,12 +516,26 @@ export async function getOptionalOneAccessToken(config) {
447
516
  if (!directory.isUsable)
448
517
  return { accessToken: null, problem: directory.problem === null ? null : messageOf(directory.problem) };
449
518
  try {
519
+ if (await savedForAnotherConfiguration(config, await readCredentials(config)))
520
+ return { accessToken: null, problem: SAVED_FOR_ANOTHER_ORIGIN };
450
521
  return { accessToken: await getOneAccessToken(config), problem: null };
451
522
  }
452
523
  catch (error) {
453
524
  return { accessToken: null, problem: messageOf(error) };
454
525
  }
455
526
  }
527
+ /**
528
+ * whoami's directory check, which agrees with list and doctor: false when the credentials directory is missing,
529
+ * or fails the checks with no credentials saved there. The strict check's error when credentials may be there.
530
+ */
531
+ export async function hasCredentialsDirectory(config) {
532
+ if (platform() === 'win32')
533
+ throw new SignInUnsupportedError();
534
+ const directory = await checkDirectoryQuietly(config);
535
+ if (directory.problem !== null)
536
+ throw directory.problem;
537
+ return directory.isUsable;
538
+ }
456
539
  /**
457
540
  * Whether list can show the saved sign-in as usable, judged from the files alone: no refresh, no network
458
541
  * and no wait for the credentials lock. A legacy token must be unexpired; an Identity grant may hold an
@@ -474,16 +557,13 @@ export async function hasUsableSignIn(config, warn) {
474
557
  const { version, one } = await readCredentialsWithoutWaiting(config);
475
558
  if (!one?.accessToken.trim())
476
559
  return false;
560
+ if (await savedForAnotherConfiguration(config, { version, one }))
561
+ throw new Error(SAVED_FOR_ANOTHER_ORIGIN);
477
562
  if (version === 2) {
478
- const { boundIdentityCredentials } = await import('./refresh.js');
479
- if (!boundIdentityCredentials(config, one))
480
- throw new Error(SAVED_FOR_ANOTHER_ORIGIN);
481
563
  if (one.refreshPending)
482
564
  throw new Error(UNCERTAIN_REFRESH);
483
565
  return true;
484
566
  }
485
- if (!isForOneOrigin(one, config))
486
- throw new Error(SAVED_FOR_ANOTHER_ORIGIN);
487
567
  return !hasExpired(one);
488
568
  }
489
569
  catch (error) {
package/dist/bin.js CHANGED
@@ -1,6 +1,18 @@
1
1
  #!/usr/bin/env node
2
2
  import { runCli } from './cli.js';
3
3
  import { configureSystemTrust } from './lib/system-trust.js';
4
+ // A reader that stops early (`| head`) closes the pipe, which is fine; any other write error loses output.
5
+ let isOutputLost = false;
6
+ for (const stream of [process.stdout, process.stderr]) {
7
+ stream.on('error', (error) => {
8
+ if (error.code !== 'EPIPE')
9
+ isOutputLost = true;
10
+ });
11
+ }
4
12
  configureSystemTrust();
5
13
  const code = await runCli();
6
- process.exit(code);
14
+ // Writes to a pipe are asynchronous: wait until both streams have drained, or process.exit drops the rest.
15
+ await Promise.all([process.stdout, process.stderr].map((stream) => new Promise((resolve) => {
16
+ stream.write('', () => resolve());
17
+ })));
18
+ process.exit(isOutputLost || typeof code !== 'number' || !Number.isInteger(code) ? 1 : code);