@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.
- package/CLOUDFLARE.md +2 -2
- package/COMMANDS.md +111 -44
- package/LOCAL.md +99 -38
- package/ONE.md +2 -2
- package/README.md +215 -67
- package/dist/auth/acl.d.ts +6 -0
- package/dist/auth/acl.js +45 -0
- package/dist/auth/device-auth.js +1 -0
- package/dist/auth/provider.js +4 -4
- package/dist/auth/refresh.d.ts +5 -4
- package/dist/auth/refresh.js +82 -41
- package/dist/auth/store.d.ts +25 -0
- package/dist/auth/store.js +91 -11
- package/dist/bin.js +13 -1
- package/dist/cli.js +205 -81
- package/dist/commands/account.js +11 -7
- package/dist/commands/auth.d.ts +6 -0
- package/dist/commands/auth.js +75 -24
- package/dist/commands/billing.js +2 -2
- package/dist/commands/cloudflare.d.ts +1 -1
- package/dist/commands/cloudflare.js +31 -17
- package/dist/commands/helpers.js +3 -3
- package/dist/commands/index.d.ts +1 -0
- package/dist/commands/index.js +2 -1
- package/dist/commands/legacy-validation.d.ts +20 -0
- package/dist/commands/legacy-validation.js +77 -3
- package/dist/commands/local.js +186 -65
- package/dist/commands/one.js +16 -13
- package/dist/commands/public-tools.js +52 -27
- package/dist/commands/runs.js +5 -5
- package/dist/commands/security.d.ts +1 -1
- package/dist/commands/security.js +41 -37
- package/dist/commands/watchdog.js +5 -5
- package/dist/lib/banner.d.ts +5 -11
- package/dist/lib/banner.js +11 -31
- package/dist/lib/client.js +19 -1
- package/dist/lib/cloudflare-client.d.ts +2 -2
- package/dist/lib/config.d.ts +16 -0
- package/dist/lib/config.js +77 -19
- package/dist/lib/format.d.ts +1 -1
- package/dist/lib/format.js +75 -24
- package/dist/lib/help.js +57 -6
- package/dist/lib/network.d.ts +12 -3
- package/dist/lib/network.js +73 -9
- package/dist/lib/one-client.js +9 -2
- package/dist/lib/one-error-hints.d.ts +1 -0
- package/dist/lib/one-error-hints.js +10 -0
- package/dist/lib/open-url.js +2 -1
- package/dist/lib/platform.d.ts +2 -0
- package/dist/lib/platform.js +5 -0
- package/dist/lib/progress.d.ts +18 -3
- package/dist/lib/progress.js +72 -10
- package/dist/lib/system-trust.d.ts +2 -0
- package/dist/lib/system-trust.js +6 -0
- package/dist/lib/tui.d.ts +1 -1
- package/dist/lib/tui.js +5 -7
- package/dist/local/cloudflare-checkup/cf/child-env.js +2 -4
- package/dist/local/cloudflare-checkup/cf/install.d.ts +4 -2
- package/dist/local/cloudflare-checkup/cf/install.js +55 -24
- package/dist/local/cloudflare-checkup/cf/locate.d.ts +2 -0
- package/dist/local/cloudflare-checkup/cf/locate.js +10 -1
- package/dist/local/cloudflare-checkup/cf/run-cf.d.ts +18 -3
- package/dist/local/cloudflare-checkup/cf/run-cf.js +75 -17
- package/dist/local/cloudflare-checkup/contract.d.ts +16 -9
- package/dist/local/cloudflare-checkup/contract.js +80 -14
- package/dist/local/cloudflare-checkup/errors.d.ts +34 -1
- package/dist/local/cloudflare-checkup/errors.js +36 -5
- package/dist/local/cloudflare-checkup/output.d.ts +6 -0
- package/dist/local/cloudflare-checkup/output.js +9 -0
- package/dist/local/cloudflare-checkup/snapshot-file.d.ts +6 -2
- package/dist/local/cloudflare-checkup/snapshot-file.js +25 -11
- package/dist/local/cloudflare-checkup/submit-client.js +7 -1
- package/dist/types.d.ts +2 -1
- package/esm-only.cjs +3 -0
- package/package.json +7 -4
package/dist/auth/refresh.js
CHANGED
|
@@ -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
|
-
/**
|
|
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(
|
|
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
|
-
*
|
|
31
|
-
*
|
|
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
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
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
|
-
|
|
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 = [];
|
package/dist/auth/store.d.ts
CHANGED
|
@@ -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
|
package/dist/auth/store.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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);
|