flipstream 0.6.0 → 0.7.0
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/dist/commands/auth/clear-all.d.ts +1 -0
- package/dist/commands/auth/clear-all.js +91 -24
- package/dist/commands/auth/login.js +1 -1
- package/dist/commands/auth/logout.js +2 -2
- package/dist/commands/auth/status.js +6 -5
- package/dist/commands/catalog.js +13 -2
- package/dist/commands/connections/list.js +1 -1
- package/dist/commands/log/add.js +1 -1
- package/dist/commands/log/list.js +1 -1
- package/dist/commands/query.js +4 -4
- package/dist/commands/workspaces/connections.js +1 -1
- package/dist/commands/workspaces/get.js +1 -1
- package/dist/commands/workspaces/list.js +1 -1
- package/dist/lib/api/retry.d.ts +1 -1
- package/dist/lib/api/retry.js +1 -1
- package/dist/lib/auth/claims.d.ts +1 -1
- package/dist/lib/auth/claims.js +2 -2
- package/dist/lib/auth/flow.js +2 -1
- package/dist/lib/auth/refresh.js +22 -12
- package/dist/lib/auth/session.js +1 -1
- package/dist/lib/command/base.js +5 -0
- package/dist/lib/output/interactivity.js +6 -0
- package/dist/lib/output/machine-mode.d.ts +2 -0
- package/dist/lib/output/machine-mode.js +25 -0
- package/dist/lib/planner/catalog.d.ts +2 -0
- package/dist/lib/planner/catalog.js +11 -1
- package/dist/lib/store/keychain-child.d.ts +11 -0
- package/dist/lib/store/keychain-child.js +135 -0
- package/dist/lib/store/keyring.d.ts +26 -12
- package/dist/lib/store/keyring.js +246 -18
- package/dist/lib/store/memory-store.d.ts +6 -6
- package/dist/lib/store/memory-store.js +10 -7
- package/oclif.manifest.json +1 -1
- package/package.json +1 -1
|
@@ -3,48 +3,115 @@ import { fetchMetadata } from '../../lib/auth/discovery.js';
|
|
|
3
3
|
import { revokeToken } from '../../lib/auth/revoke.js';
|
|
4
4
|
import { BaseCommand } from '../../lib/command/base.js';
|
|
5
5
|
import { knownHosts, removeConfigDir } from '../../lib/config/xdg.js';
|
|
6
|
+
import { CliError, TimeoutError } from '../../lib/errors.js';
|
|
7
|
+
import { ExitCode } from '../../lib/exit-codes.js';
|
|
6
8
|
import { createStore } from '../../lib/store/index.js';
|
|
7
9
|
export default class AuthClearAll extends BaseCommand {
|
|
8
10
|
static description = 'Purge ALL Flipstream credentials: every keychain entry and the local config directory.';
|
|
9
11
|
static examples = ['<%= config.bin %> auth clear-all', '<%= config.bin %> auth clear-all --no-revoke'];
|
|
10
12
|
static flags = {
|
|
11
|
-
'no-revoke': Flags.boolean({
|
|
13
|
+
'no-revoke': Flags.boolean({
|
|
14
|
+
description: 'Clear local credentials only; skip the per-host server revocation calls.',
|
|
15
|
+
}),
|
|
12
16
|
};
|
|
13
17
|
static summary = 'Purge ALL credentials: every keychain entry and the local config dir.';
|
|
14
18
|
async run() {
|
|
15
19
|
const store = createStore();
|
|
16
20
|
const hosts = knownHosts();
|
|
17
21
|
const timeoutMs = this.flags.timeout ?? 10_000;
|
|
18
|
-
//
|
|
19
|
-
//
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
22
|
+
// THE LOCAL PURGE COMES FIRST, AND ALONE. An earlier attempt at this gave the
|
|
23
|
+
// command one wall-clock budget covering both keychain and network work, and
|
|
24
|
+
// that budget then got spent by slow token revocations — so a later host's
|
|
25
|
+
// `clear` was skipped entirely and `clear-all` returned having left
|
|
26
|
+
// credentials in the keychain. A purge command must never be talked out of
|
|
27
|
+
// purging by a server that will not answer.
|
|
28
|
+
//
|
|
29
|
+
// So the two phases are separated: every host's entry is read and deleted
|
|
30
|
+
// here, one at a time (each keychain call raises its own OS dialog, and
|
|
31
|
+
// stacking them puts several prompts on one person), and the revocations run
|
|
32
|
+
// afterwards from tokens captured on the way through. Serial keychain work is
|
|
33
|
+
// bounded because each call carries its own deadline; nothing here inherits a
|
|
34
|
+
// network stall.
|
|
35
|
+
const revocations = [];
|
|
36
|
+
const failed = [];
|
|
37
|
+
let keychainTimedOut = false;
|
|
38
|
+
for (const host of hosts) {
|
|
39
|
+
let refreshToken;
|
|
40
|
+
let clientId;
|
|
41
|
+
try {
|
|
42
|
+
// eslint-disable-next-line no-await-in-loop -- serialized on purpose; see above
|
|
43
|
+
const creds = await store.load(host);
|
|
44
|
+
refreshToken = creds?.refreshToken;
|
|
45
|
+
clientId = creds?.clientId;
|
|
46
|
+
}
|
|
47
|
+
catch (error) {
|
|
48
|
+
// Unreadable is not fatal to the purge: the delete below still runs, and
|
|
49
|
+
// a credential we could not read is one we certainly cannot revoke.
|
|
50
|
+
if (error instanceof TimeoutError)
|
|
51
|
+
keychainTimedOut = true;
|
|
52
|
+
}
|
|
53
|
+
if (!this.flags['no-revoke'] && refreshToken && clientId) {
|
|
54
|
+
revocations.push({ clientId, host, token: refreshToken });
|
|
55
|
+
}
|
|
56
|
+
try {
|
|
57
|
+
// eslint-disable-next-line no-await-in-loop -- serialized on purpose; see above
|
|
58
|
+
await store.clear(host);
|
|
59
|
+
}
|
|
60
|
+
catch (error) {
|
|
61
|
+
// Keep purging. One unusable entry must not strand every other host's
|
|
62
|
+
// credentials, and the caller is told exactly which ones survived.
|
|
63
|
+
if (error instanceof TimeoutError)
|
|
64
|
+
keychainTimedOut = true;
|
|
65
|
+
failed.push(host);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
// Best effort, and deliberately AFTER the purge: a revocation that hangs or
|
|
69
|
+
// 500s can no longer cost anybody a deleted credential. These are network
|
|
70
|
+
// calls with their own timeout and are never reported as keychain failures.
|
|
71
|
+
await Promise.all(revocations.map(async (revocation) => this.revoke(revocation, timeoutMs)));
|
|
72
|
+
const cleared = hosts.filter((host) => !failed.includes(host));
|
|
73
|
+
if (failed.length > 0) {
|
|
74
|
+
// The config dir STAYS. knownHosts() is read from it, so removing it here
|
|
75
|
+
// would delete the only record of the hosts whose secrets are still in the
|
|
76
|
+
// keychain — the retry this error recommends would then find nothing to
|
|
77
|
+
// retry, and live credentials would be left with no way to locate them.
|
|
78
|
+
const summary = `Cleared ${cleared.length} host(s); ${failed.length} could not be cleared: ${failed.join(', ')}`;
|
|
79
|
+
const hint = 'Those keychain entries may still exist, so the local host list has been kept for the ' +
|
|
80
|
+
'retry. Re-run once the keychain can answer.';
|
|
81
|
+
const next = ['flipstream auth clear-all'];
|
|
82
|
+
// keychain_timeout means the KEYCHAIN timed out. It is not a label for a
|
|
83
|
+
// slow issuer, which is why revocation now runs outside this path entirely.
|
|
84
|
+
if (keychainTimedOut) {
|
|
85
|
+
throw new TimeoutError(`${summary} — the OS keychain did not respond in time.`, 'keychain_timeout').withDetails({ hint, next, retryable: true });
|
|
86
|
+
}
|
|
87
|
+
throw new CliError(summary, 'clear_incomplete', ExitCode.GENERIC).withDetails({ hint, next, retryable: true });
|
|
88
|
+
}
|
|
39
89
|
const configRemoved = removeConfigDir();
|
|
40
|
-
return this.respond({ clearedHosts:
|
|
90
|
+
return this.respond({ clearedHosts: cleared, configRemoved }, () => {
|
|
41
91
|
if (hosts.length === 0 && !configRemoved) {
|
|
42
92
|
this.log('Nothing to clear.');
|
|
43
93
|
}
|
|
44
94
|
else {
|
|
45
|
-
this.log(`Cleared ${
|
|
95
|
+
this.log(`Cleared ${cleared.length} host(s): ${cleared.join(', ') || '(none)'}`);
|
|
46
96
|
this.log(configRemoved ? 'Removed the local config directory.' : 'No local config directory to remove.');
|
|
47
97
|
}
|
|
48
98
|
});
|
|
49
99
|
}
|
|
100
|
+
// One host's revocation, swallowing everything: the local credential is
|
|
101
|
+
// already gone, so nothing here can improve or worsen the purge.
|
|
102
|
+
async revoke(revocation, timeoutMs) {
|
|
103
|
+
try {
|
|
104
|
+
const meta = await fetchMetadata(revocation.host, { timeoutMs });
|
|
105
|
+
await revokeToken({
|
|
106
|
+
clientId: revocation.clientId,
|
|
107
|
+
revocationEndpoint: meta.revocationEndpoint,
|
|
108
|
+
timeoutMs,
|
|
109
|
+
token: revocation.token,
|
|
110
|
+
tokenTypeHint: 'refresh_token',
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
catch {
|
|
114
|
+
// Best-effort by contract.
|
|
115
|
+
}
|
|
116
|
+
}
|
|
50
117
|
}
|
|
@@ -23,7 +23,7 @@ export default class AuthLogin extends BaseCommand {
|
|
|
23
23
|
});
|
|
24
24
|
const expiresAt = Math.floor(Date.now() / 1000) + result.expiresIn;
|
|
25
25
|
try {
|
|
26
|
-
createStore().save(result.host, {
|
|
26
|
+
await createStore().save(result.host, {
|
|
27
27
|
accessToken: result.accessToken,
|
|
28
28
|
clientId: result.clientId,
|
|
29
29
|
expiresAt,
|
|
@@ -14,7 +14,7 @@ export default class AuthLogout extends BaseCommand {
|
|
|
14
14
|
async run() {
|
|
15
15
|
const host = this.resolvedHost();
|
|
16
16
|
const store = createStore();
|
|
17
|
-
const creds = store.load(host);
|
|
17
|
+
const creds = await store.load(host);
|
|
18
18
|
if (!creds) {
|
|
19
19
|
return this.respond({ host, loggedOut: true, revoked: false }, () => this.log(`Not logged in to ${host}; nothing to do.`));
|
|
20
20
|
}
|
|
@@ -40,7 +40,7 @@ export default class AuthLogout extends BaseCommand {
|
|
|
40
40
|
}
|
|
41
41
|
}
|
|
42
42
|
// Always clear local credentials, even if revocation could not be confirmed.
|
|
43
|
-
store.clear(host);
|
|
43
|
+
await store.clear(host);
|
|
44
44
|
forgetHost(host);
|
|
45
45
|
return this.respond({ host, loggedOut: true, revoked }, () => {
|
|
46
46
|
this.log(`Logged out of ${host}.`);
|
|
@@ -18,16 +18,17 @@ export default class AuthStatus extends BaseCommand {
|
|
|
18
18
|
async run() {
|
|
19
19
|
const host = this.resolvedHost();
|
|
20
20
|
const store = createStore();
|
|
21
|
-
if (!store.available()) {
|
|
22
|
-
process.stderr.write('The OS keychain is unavailable (e.g. a headless/SSH session). Browser login needs it
|
|
23
|
-
'
|
|
21
|
+
if (!(await store.available())) {
|
|
22
|
+
process.stderr.write('The OS keychain is unavailable (e.g. a headless/SSH session). Browser login needs it. ' +
|
|
23
|
+
'Over SSH or any other remote access, use a Flipstream service account from your ' +
|
|
24
|
+
'Flipstream account instead — it needs no browser and no keychain.\n');
|
|
24
25
|
return this.exit(ExitCode.NETWORK);
|
|
25
26
|
}
|
|
26
|
-
const creds = store.load(host);
|
|
27
|
+
const creds = await store.load(host);
|
|
27
28
|
if (!creds) {
|
|
28
29
|
return this.respond({ host, loggedIn: false }, () => this.log(`Not logged in to ${host}. Run \`flipstream auth login\`.`));
|
|
29
30
|
}
|
|
30
|
-
const claims = tokenClaims(host, store) ?? {};
|
|
31
|
+
const claims = (await tokenClaims(host, store)) ?? {};
|
|
31
32
|
// JSON keeps the stable subject id (agent-friendly); the human line prefers
|
|
32
33
|
// the friendlier email when present.
|
|
33
34
|
const account = [claims.sub, claims.email].find((value) => typeof value === 'string') ?? null;
|
package/dist/commands/catalog.js
CHANGED
|
@@ -37,7 +37,7 @@ export default class Catalog extends BaseCommand {
|
|
|
37
37
|
const url = resolvePlannerUrl({ urlFlag: this.flags.url });
|
|
38
38
|
const authHost = resolveHost({ hostFlag: this.flags['auth-host'] ?? this.flags.host });
|
|
39
39
|
const store = createStore();
|
|
40
|
-
if (!store.load(authHost))
|
|
40
|
+
if (!(await store.load(authHost)))
|
|
41
41
|
throw AuthRequiredError.notLoggedIn();
|
|
42
42
|
const client = authedPlannerClient({ authHost, store, timeoutMs: this.flags.timeout, url });
|
|
43
43
|
const { source } = this.args;
|
|
@@ -71,7 +71,11 @@ export default class Catalog extends BaseCommand {
|
|
|
71
71
|
], 'No sources.');
|
|
72
72
|
// Footer to STDERR: it is guidance, not data, and stdout must stay clean.
|
|
73
73
|
const hint = index.defaultSource === undefined ? '' : `, default ${index.defaultSource}`;
|
|
74
|
-
this
|
|
74
|
+
// WHICH MODEL produced this (#125). Without it, a current catalog and a
|
|
75
|
+
// planner running a stale image read identically, and the only way to
|
|
76
|
+
// tell them apart is cloning two repos to compare pins.
|
|
77
|
+
const served = index.catalogVersion === undefined ? '' : `\ndata-model ${index.catalogVersion}`;
|
|
78
|
+
this.footer(`\n${index.sources.length} source(s)${hint}; \`${this.config.bin} catalog ${index.defaultSource ?? '<source>'}\` for its names${served}`);
|
|
75
79
|
});
|
|
76
80
|
}
|
|
77
81
|
const entry = parseCatalogSource(payload);
|
|
@@ -96,6 +100,13 @@ export default class Catalog extends BaseCommand {
|
|
|
96
100
|
if (entry.customDimensionsMax !== undefined) {
|
|
97
101
|
this.log(`\nCUSTOM DIMENSIONS up to ${entry.customDimensionsMax} per connection`);
|
|
98
102
|
}
|
|
103
|
+
// The same version line the index carries (#125), and UNCONDITIONAL — unlike
|
|
104
|
+
// the Try block below, which only appears when this source has both a
|
|
105
|
+
// dimension and a metric. This is the view people read while checking whether
|
|
106
|
+
// a name exists, which is exactly when "is this the model I think it is?"
|
|
107
|
+
// is the question.
|
|
108
|
+
if (entry.catalogVersion !== undefined)
|
|
109
|
+
this.footer(`\ndata-model ${entry.catalogVersion}`);
|
|
99
110
|
// A runnable next command built from THIS source's real names (E11-3, #102):
|
|
100
111
|
// the reader of a catalog is one edit away from a working query, so hand
|
|
101
112
|
// them that edit. stderr — guidance, not data.
|
|
@@ -27,7 +27,7 @@ export default class ConnectionsList extends BaseCommand {
|
|
|
27
27
|
assertUuid(workspace, 'workspace id'); // exit 2, no request
|
|
28
28
|
const host = this.resolvedHost();
|
|
29
29
|
const store = createStore();
|
|
30
|
-
if (!store.load(host))
|
|
30
|
+
if (!(await store.load(host)))
|
|
31
31
|
throw AuthRequiredError.notLoggedIn();
|
|
32
32
|
const client = authedAdminClient(host, store, this.flags.timeout);
|
|
33
33
|
const startedMs = Date.now();
|
package/dist/commands/log/add.js
CHANGED
|
@@ -31,7 +31,7 @@ export default class LogAdd extends BaseCommand {
|
|
|
31
31
|
assertUuid(this.flags.workspace, 'workspace id'); // exit 2, no request
|
|
32
32
|
const host = this.resolvedHost();
|
|
33
33
|
const store = createStore();
|
|
34
|
-
if (!store.load(host))
|
|
34
|
+
if (!(await store.load(host)))
|
|
35
35
|
throw AuthRequiredError.notLoggedIn();
|
|
36
36
|
const client = authedAdminClient(host, store, this.flags.timeout);
|
|
37
37
|
const organizationId = await resolveWorkspaceOrg(client, this.flags.workspace);
|
|
@@ -26,7 +26,7 @@ export default class LogList extends BaseCommand {
|
|
|
26
26
|
assertUuid(this.flags.workspace, 'workspace id'); // exit 2, no request
|
|
27
27
|
const host = this.resolvedHost();
|
|
28
28
|
const store = createStore();
|
|
29
|
-
if (!store.load(host))
|
|
29
|
+
if (!(await store.load(host)))
|
|
30
30
|
throw AuthRequiredError.notLoggedIn();
|
|
31
31
|
const client = authedAdminClient(host, store, this.flags.timeout);
|
|
32
32
|
const projected = await fetchLog(client, {
|
package/dist/commands/query.js
CHANGED
|
@@ -94,7 +94,7 @@ export default class Query extends BaseCommand {
|
|
|
94
94
|
return this.respond(body, (data) => this.log(JSON.stringify(data, null, 2)));
|
|
95
95
|
}
|
|
96
96
|
const store = createStore();
|
|
97
|
-
if (!store.load(authHost))
|
|
97
|
+
if (!(await store.load(authHost)))
|
|
98
98
|
throw AuthRequiredError.notLoggedIn();
|
|
99
99
|
const admin = authedAdminClient(authHost, store, this.flags.timeout);
|
|
100
100
|
// Resolve + check the connection for BOTH paths. Name resolution is only for
|
|
@@ -174,7 +174,7 @@ export default class Query extends BaseCommand {
|
|
|
174
174
|
if (error.details.upstreamCode?.startsWith('UNKNOWN_')) {
|
|
175
175
|
// Diagnosis never triggers a token refresh: an error-path bonus fetch
|
|
176
176
|
// must not be able to mutate persistent auth state (review, #110).
|
|
177
|
-
if (context.store.accessTokenIfFresh(context.authHost) === null) {
|
|
177
|
+
if ((await context.store.accessTokenIfFresh(context.authHost)) === null) {
|
|
178
178
|
this.verboseLog('vocabulary diagnosis skipped: no fresh access token (diagnosis never refreshes)');
|
|
179
179
|
}
|
|
180
180
|
else {
|
|
@@ -210,11 +210,11 @@ export default class Query extends BaseCommand {
|
|
|
210
210
|
// anything client-side) and are not secrets; both lists are bounded so a
|
|
211
211
|
// token from a hostile --auth-host issuer cannot balloon the envelope.
|
|
212
212
|
if (error.code === 'role_forbidden') {
|
|
213
|
-
const claims = tokenClaims(context.authHost, context.store) ?? {};
|
|
213
|
+
const claims = (await tokenClaims(context.authHost, context.store)) ?? {};
|
|
214
214
|
const roles = (Array.isArray(claims.roles) ? claims.roles.filter((r) => typeof r === 'string') : [])
|
|
215
215
|
.slice(0, 20)
|
|
216
216
|
.map((role) => role.slice(0, 64));
|
|
217
|
-
const scopes = (context.store.load(context.authHost)?.scopes ?? [])
|
|
217
|
+
const scopes = ((await context.store.load(context.authHost))?.scopes ?? [])
|
|
218
218
|
.slice(0, 20)
|
|
219
219
|
.map((scope) => scope.slice(0, 64));
|
|
220
220
|
error.withDetails({
|
|
@@ -25,7 +25,7 @@ export default class WorkspacesConnections extends BaseCommand {
|
|
|
25
25
|
assertUuid(this.args.id, 'workspace id'); // exit 2, no request
|
|
26
26
|
const host = this.resolvedHost();
|
|
27
27
|
const store = createStore();
|
|
28
|
-
if (!store.load(host))
|
|
28
|
+
if (!(await store.load(host)))
|
|
29
29
|
throw AuthRequiredError.notLoggedIn();
|
|
30
30
|
const client = authedAdminClient(host, store, this.flags.timeout);
|
|
31
31
|
const startedMs = Date.now();
|
|
@@ -31,7 +31,7 @@ export default class WorkspacesGet extends BaseCommand {
|
|
|
31
31
|
assertUuid(this.args.id, 'workspace id'); // exit 2, no request issued
|
|
32
32
|
const host = this.resolvedHost();
|
|
33
33
|
const store = createStore();
|
|
34
|
-
if (!store.load(host))
|
|
34
|
+
if (!(await store.load(host)))
|
|
35
35
|
throw AuthRequiredError.notLoggedIn();
|
|
36
36
|
const client = createAuthedAdminClient({
|
|
37
37
|
accessTokenIfFresh: (authHost) => store.accessTokenIfFresh(authHost),
|
|
@@ -32,7 +32,7 @@ export default class WorkspacesList extends BaseCommand {
|
|
|
32
32
|
// The admin host == the OAuth issuer (creds live here); --host overrides both.
|
|
33
33
|
const host = this.resolvedHost();
|
|
34
34
|
const store = createStore();
|
|
35
|
-
if (!store.load(host))
|
|
35
|
+
if (!(await store.load(host)))
|
|
36
36
|
throw AuthRequiredError.notLoggedIn();
|
|
37
37
|
const client = createAuthedAdminClient({
|
|
38
38
|
accessTokenIfFresh: (authHost) => store.accessTokenIfFresh(authHost),
|
package/dist/lib/api/retry.d.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { type DataClient } from './client.js';
|
|
2
2
|
export interface RefreshDeps {
|
|
3
|
-
accessTokenIfFresh: (authHost: string) => null | string
|
|
3
|
+
accessTokenIfFresh: (authHost: string) => Promise<null | string>;
|
|
4
4
|
authHost: string;
|
|
5
5
|
refresh: (authHost: string) => Promise<null | string>;
|
|
6
6
|
}
|
package/dist/lib/api/retry.js
CHANGED
|
@@ -8,7 +8,7 @@ import { requestJson } from './http.js';
|
|
|
8
8
|
// session_expired (exit 4). Non-401 errors (403/5xx/network/timeout) are never
|
|
9
9
|
// retried; a second consecutive 401 propagates. Refresh always targets authHost.
|
|
10
10
|
export async function withFreshToken(deps, fn) {
|
|
11
|
-
let token = deps.accessTokenIfFresh(deps.authHost) ?? (await deps.refresh(deps.authHost));
|
|
11
|
+
let token = (await deps.accessTokenIfFresh(deps.authHost)) ?? (await deps.refresh(deps.authHost));
|
|
12
12
|
if (!token)
|
|
13
13
|
throw AuthRequiredError.sessionExpired();
|
|
14
14
|
try {
|
|
@@ -1,3 +1,3 @@
|
|
|
1
1
|
import type { TokenStore } from '../store/index.js';
|
|
2
2
|
export declare function decodeJwtClaimsUnverified(accessToken: string): null | Record<string, unknown>;
|
|
3
|
-
export declare function tokenClaims(host: string, store: TokenStore): null | Record<string, unknown
|
|
3
|
+
export declare function tokenClaims(host: string, store: TokenStore): Promise<null | Record<string, unknown>>;
|
package/dist/lib/auth/claims.js
CHANGED
|
@@ -14,7 +14,7 @@ export function decodeJwtClaimsUnverified(accessToken) {
|
|
|
14
14
|
}
|
|
15
15
|
}
|
|
16
16
|
// Decoded claims of the stored access token for `host` (display only), or null.
|
|
17
|
-
export function tokenClaims(host, store) {
|
|
18
|
-
const creds = store.load(host);
|
|
17
|
+
export async function tokenClaims(host, store) {
|
|
18
|
+
const creds = await store.load(host);
|
|
19
19
|
return creds ? decodeJwtClaimsUnverified(creds.accessToken) : null;
|
|
20
20
|
}
|
package/dist/lib/auth/flow.js
CHANGED
|
@@ -108,7 +108,8 @@ export async function login(options = {}) {
|
|
|
108
108
|
const pasted = await (options.promptRedirect ?? defaultPromptRedirect)(authorizeUrl);
|
|
109
109
|
if (!pasted || !pasted.trim()) {
|
|
110
110
|
throw new AuthFailedError('No redirected URL was provided. Browser login is not possible in a non-interactive ' +
|
|
111
|
-
'environment; use a service
|
|
111
|
+
'environment; over SSH or any other remote access, use a Flipstream service account ' +
|
|
112
|
+
'(client_credentials) from your Flipstream account instead — it needs no browser.', 'headless_no_input');
|
|
112
113
|
}
|
|
113
114
|
return parseRedirect(pasted, state).code;
|
|
114
115
|
};
|
package/dist/lib/auth/refresh.js
CHANGED
|
@@ -11,7 +11,7 @@ import { fetchMetadata } from './discovery.js';
|
|
|
11
11
|
export async function refresh(host, options = {}) {
|
|
12
12
|
const store = options.store ?? createStore();
|
|
13
13
|
const timeoutMs = options.timeoutMs ?? 10_000;
|
|
14
|
-
const creds = store.load(host);
|
|
14
|
+
const creds = await store.load(host);
|
|
15
15
|
if (!creds || !creds.refreshToken)
|
|
16
16
|
return null;
|
|
17
17
|
let tokenEndpoint;
|
|
@@ -50,20 +50,30 @@ export async function refresh(host, options = {}) {
|
|
|
50
50
|
// token is dead — but the one now in the keychain is not, and clearing blindly
|
|
51
51
|
// would delete a working session and log the user out for no reason.
|
|
52
52
|
//
|
|
53
|
-
// Re-read past any cache
|
|
54
|
-
//
|
|
55
|
-
//
|
|
56
|
-
|
|
53
|
+
// Re-read past any cache. If somebody else's token is there now, their
|
|
54
|
+
// session is live: HAND OFF to it rather than reporting failure, because
|
|
55
|
+
// returning null would surface as "Session expired. Run `flipstream auth
|
|
56
|
+
// login`" while perfectly good credentials sit in the keychain.
|
|
57
|
+
const current = await store.reload(host);
|
|
57
58
|
if (current !== null && current.refreshToken !== creds.refreshToken) {
|
|
58
|
-
// Someone else won the race and their session is live. HAND OFF to it rather
|
|
59
|
-
// than reporting failure: returning null here would surface as "Session
|
|
60
|
-
// expired. Run `flipstream auth login`" while perfectly good credentials sit
|
|
61
|
-
// in the keychain — telling the user to fix something that is not broken.
|
|
62
59
|
// May still be null if their access token is also stale; either way we do
|
|
63
|
-
// not
|
|
60
|
+
// not touch a session we do not own.
|
|
64
61
|
return freshAccessToken(current);
|
|
65
62
|
}
|
|
66
|
-
|
|
63
|
+
// AND WE DO NOT DELETE, even though this token looks dead to us.
|
|
64
|
+
//
|
|
65
|
+
// The check above cannot be made atomic. Every store operation is its own
|
|
66
|
+
// child process now (~125ms), so between that reload and any delete there is
|
|
67
|
+
// a wide window in which the process that DID win the rotation completes its
|
|
68
|
+
// save — and we would then erase credentials that had just become valid. One
|
|
69
|
+
// command would return a token while every later command looked logged out.
|
|
70
|
+
//
|
|
71
|
+
// The two mistakes are not the same size. Deleting wrongly destroys a live
|
|
72
|
+
// session silently. Keeping a genuinely dead token costs one more failed
|
|
73
|
+
// refresh, which returns null here, surfaces as session_expired, and is
|
|
74
|
+
// overwritten by the next login anyway. Given a race we cannot close without
|
|
75
|
+
// a cross-process lock that every save would also have to respect, the
|
|
76
|
+
// cheaper mistake is the right default.
|
|
67
77
|
return null;
|
|
68
78
|
}
|
|
69
79
|
if (!response.ok)
|
|
@@ -84,6 +94,6 @@ export async function refresh(host, options = {}) {
|
|
|
84
94
|
// Rotate the refresh token ONLY when the server issued a new one.
|
|
85
95
|
refreshToken: token.refresh_token ?? creds.refreshToken,
|
|
86
96
|
};
|
|
87
|
-
store.save(host, updated);
|
|
97
|
+
await store.save(host, updated);
|
|
88
98
|
return token.access_token;
|
|
89
99
|
}
|
package/dist/lib/auth/session.js
CHANGED
|
@@ -6,7 +6,7 @@ import { refresh } from './refresh.js';
|
|
|
6
6
|
// client (E4) uses to obtain a bearer token.
|
|
7
7
|
export async function getFreshAccessToken(host, options = {}) {
|
|
8
8
|
const store = options.store ?? createStore();
|
|
9
|
-
const cached = store.accessTokenIfFresh(host);
|
|
9
|
+
const cached = await store.accessTokenIfFresh(host);
|
|
10
10
|
if (cached)
|
|
11
11
|
return cached;
|
|
12
12
|
return refresh(host, { store, timeoutMs: options.timeoutMs });
|
package/dist/lib/command/base.js
CHANGED
|
@@ -2,6 +2,7 @@ import { Command, Flags } from '@oclif/core';
|
|
|
2
2
|
import { CONTRACT_VERSION } from '../config/constants.js';
|
|
3
3
|
import { resolveHost } from '../config/xdg.js';
|
|
4
4
|
import { classifyError, renderError, UsageError } from '../errors.js';
|
|
5
|
+
import { setMachineMode } from '../output/machine-mode.js';
|
|
5
6
|
import { renderNdjson } from '../output/ndjson.js';
|
|
6
7
|
import { redact } from '../output/redact.js';
|
|
7
8
|
import { appendRunLog, currentRunLogPath } from '../output/runlog.js';
|
|
@@ -99,6 +100,10 @@ export class BaseCommand extends Command {
|
|
|
99
100
|
});
|
|
100
101
|
this.flags = flags;
|
|
101
102
|
this.args = args;
|
|
103
|
+
// Tell the rest of the process which stream discipline applies, so code far
|
|
104
|
+
// from here — the keychain's "still waiting" notice, for one — can honour
|
|
105
|
+
// the empty-stderr guarantee without being handed the command object.
|
|
106
|
+
setMachineMode(this.jsonEnabled() || Boolean(this.flags.ndjson));
|
|
102
107
|
// --json and --ndjson both claim stdout; refuse the ambiguous combination.
|
|
103
108
|
// UsageError carries exit 2 (oclif's {exit:2} would map to GENERIC instead).
|
|
104
109
|
if (this.jsonEnabled() && this.flags.ndjson) {
|
|
@@ -17,6 +17,12 @@
|
|
|
17
17
|
// allocate a pty can look interactive until then. The escape hatch below
|
|
18
18
|
// (FLIPSTREAM_NO_INPUT) exists for exactly that gap: any wrapper can force the
|
|
19
19
|
// deterministic no-prompt path regardless of what the TTY claims.
|
|
20
|
+
//
|
|
21
|
+
// NOTE for anyone tempted to reuse these for a GUI question: none of them
|
|
22
|
+
// answer it. macOS renders a keychain prompt through SecurityAgent, which a
|
|
23
|
+
// desktop-launched process with piped stdio can show and a PTY-holding SSH
|
|
24
|
+
// session cannot — see src/lib/store/keyring.ts, which deliberately asks a
|
|
25
|
+
// different question.
|
|
20
26
|
// stdin (can the user answer?) AND stderr (can they SEE the prompt?). stdout
|
|
21
27
|
// is deliberately not consulted: piping stdout is this CLI's DESIGNED usage
|
|
22
28
|
// (`query --json > out.json`), and a human at a terminal doing that can still
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
// Is this run speaking to a machine? One boolean, set once, read by the few
|
|
2
|
+
// places that would otherwise write prose into a stream an agent is parsing.
|
|
3
|
+
//
|
|
4
|
+
// It exists because progress notices are decided FAR from the command that
|
|
5
|
+
// knows the output mode: src/lib/store/keyring.ts wants to say "still waiting on
|
|
6
|
+
// the keychain" while a call is in flight, and it has no access to oclif's
|
|
7
|
+
// jsonEnabled(). Passing the mode down through every call site would thread a
|
|
8
|
+
// display concern through the store, the deadline and the spawn; a single
|
|
9
|
+
// process-wide fact is both smaller and truer — a process is in exactly one
|
|
10
|
+
// output mode for its whole life.
|
|
11
|
+
//
|
|
12
|
+
// The rule it protects is in docs/AGENT-CONTRACT.md and pinned by a test: under
|
|
13
|
+
// --json/--ndjson, stderr stays EMPTY. A notice that ignored that would put
|
|
14
|
+
// prose in front of an agent that is reading stderr for diagnostics.
|
|
15
|
+
const MACHINE_MODE = Symbol.for('io.flipstream.cli.machine-mode');
|
|
16
|
+
// Keyed on a registered Symbol rather than a module-local, so it survives this
|
|
17
|
+
// file being loaded as two module instances (the same path spelled two ways —
|
|
18
|
+
// see src/lib/store/index.ts, which keeps its singleton the same way).
|
|
19
|
+
export function setMachineMode(on) {
|
|
20
|
+
;
|
|
21
|
+
globalThis[MACHINE_MODE] = on;
|
|
22
|
+
}
|
|
23
|
+
export function isMachineMode() {
|
|
24
|
+
return globalThis[MACHINE_MODE] === true;
|
|
25
|
+
}
|
|
@@ -5,6 +5,7 @@ export interface CatalogItem {
|
|
|
5
5
|
type: string;
|
|
6
6
|
}
|
|
7
7
|
export interface CatalogSource {
|
|
8
|
+
catalogVersion?: string;
|
|
8
9
|
customDimensionsMax?: number;
|
|
9
10
|
description: string;
|
|
10
11
|
dimensions: CatalogItem[];
|
|
@@ -19,6 +20,7 @@ export interface CatalogSourceSummary {
|
|
|
19
20
|
name: string;
|
|
20
21
|
}
|
|
21
22
|
export interface CatalogIndex {
|
|
23
|
+
catalogVersion?: string;
|
|
22
24
|
defaultSource?: string;
|
|
23
25
|
sources: CatalogSourceSummary[];
|
|
24
26
|
}
|
|
@@ -18,6 +18,15 @@ function str(value, fallback = '') {
|
|
|
18
18
|
function arr(value) {
|
|
19
19
|
return Array.isArray(value) ? value : [];
|
|
20
20
|
}
|
|
21
|
+
// The data-model release that produced this document (#125). data-model stamps
|
|
22
|
+
// it on every catalog — `catalogVersion` is required by its own schema and reads
|
|
23
|
+
// the package version through importlib.metadata, so it cannot drift from the
|
|
24
|
+
// tag query-planner pins. OPTIONAL here regardless: a planner older than the
|
|
25
|
+
// field simply omits it, and a missing version must print nothing rather than
|
|
26
|
+
// the word "undefined".
|
|
27
|
+
function version(record) {
|
|
28
|
+
return typeof record.catalogVersion === 'string' ? record.catalogVersion : undefined;
|
|
29
|
+
}
|
|
21
30
|
function parseItem(value) {
|
|
22
31
|
const record = asRecord(value) ?? {};
|
|
23
32
|
// `format` may be absent or null; its `type` is what the human table shows.
|
|
@@ -42,7 +51,7 @@ export function parseCatalogIndex(payload) {
|
|
|
42
51
|
};
|
|
43
52
|
});
|
|
44
53
|
const defaultSource = typeof record.defaultSource === 'string' ? record.defaultSource : undefined;
|
|
45
|
-
return { defaultSource, sources };
|
|
54
|
+
return { catalogVersion: version(record), defaultSource, sources };
|
|
46
55
|
}
|
|
47
56
|
// GET /catalog/<source> — one source's full vocabulary.
|
|
48
57
|
export function parseCatalogSource(payload) {
|
|
@@ -50,6 +59,7 @@ export function parseCatalogSource(payload) {
|
|
|
50
59
|
const custom = asRecord(record.customDimensions);
|
|
51
60
|
const max = custom !== undefined && typeof custom.max === 'number' ? custom.max : undefined;
|
|
52
61
|
return {
|
|
62
|
+
catalogVersion: version(record),
|
|
53
63
|
customDimensionsMax: max,
|
|
54
64
|
description: str(record.description),
|
|
55
65
|
dimensions: arr(record.dimensions).map((item) => parseItem(item)),
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
// ONE keychain operation, in a process of its own, printed as JSON on stdout.
|
|
2
|
+
//
|
|
3
|
+
// This exists because a macOS keychain call can park in mach_msg forever (#119):
|
|
4
|
+
// when securityd wants an authorization prompt that no GUI session can show (an
|
|
5
|
+
// SSH shell, a locked console), the call never returns. Nothing inside a Node
|
|
6
|
+
// process can recover from that — an AbortSignal does not interrupt a syscall
|
|
7
|
+
// already in flight, worker.terminate() cannot unwind a thread blocked in the
|
|
8
|
+
// kernel, and even process.exit() and process.reallyExit() hang, because Node's
|
|
9
|
+
// shutdown joins its libuv workers and the wedged one never finishes. All three
|
|
10
|
+
// were measured doing exactly that.
|
|
11
|
+
//
|
|
12
|
+
// A separate PROCESS is the one thing that can be killed. So the parent spawns
|
|
13
|
+
// this, waits with a deadline, and SIGKILLs it if the deadline wins — the wedged
|
|
14
|
+
// thread dies with the child, and the CLI stays free to report the failure and
|
|
15
|
+
// exit with its own code.
|
|
16
|
+
//
|
|
17
|
+
// STDIN IS ALSO THE PARENT'S HEARTBEAT. The request arrives as one line and the
|
|
18
|
+
// parent then holds the pipe open rather than closing it, so losing that pipe
|
|
19
|
+
// means the parent is gone. A wedged helper whose CLI was killed would otherwise
|
|
20
|
+
// be reparented and block forever, and cancelling a slow command repeatedly
|
|
21
|
+
// would quietly accumulate immortal processes — the exact containment failure
|
|
22
|
+
// the subprocess was introduced to prevent. Self-terminating on disconnect
|
|
23
|
+
// covers every way a parent can die, including SIGKILL, which no parent-side
|
|
24
|
+
// signal handler could catch.
|
|
25
|
+
//
|
|
26
|
+
// Nothing is passed as a command-line argument: those are readable by any `ps`
|
|
27
|
+
// on the machine, which would expose the secret on a write and the account name
|
|
28
|
+
// on every read.
|
|
29
|
+
import { AsyncEntry } from '@napi-rs/keyring';
|
|
30
|
+
import { writeSync } from 'node:fs';
|
|
31
|
+
// fs.writeSync rather than process.stdout.write: this is the one place a store
|
|
32
|
+
// module writes to a pipe, and a synchronous write cannot be truncated by the
|
|
33
|
+
// process exiting underneath it.
|
|
34
|
+
function emit(result) {
|
|
35
|
+
writeSync(1, `${JSON.stringify(result)}\n`);
|
|
36
|
+
}
|
|
37
|
+
// SIGKILL on ourselves rather than process.exit(): if a keychain call is already
|
|
38
|
+
// wedged in the kernel, Node's own shutdown would join its libuv worker and hang
|
|
39
|
+
// exactly like the parent did. Only the signal is certain.
|
|
40
|
+
function dieWithParent() {
|
|
41
|
+
process.kill(process.pid, 'SIGKILL');
|
|
42
|
+
}
|
|
43
|
+
// ONE liveness handler, installed before anything else and never conditional.
|
|
44
|
+
//
|
|
45
|
+
// An earlier version had two sets — one that ignored EOF while reading the
|
|
46
|
+
// request, and an unconditional pair installed after the request resolved. The
|
|
47
|
+
// gap between them was a real hole: a parent that died immediately after writing
|
|
48
|
+
// the request could deliver EOF into the handoff, have it ignored by the first
|
|
49
|
+
// set and missed by the second, and leave a wedged helper with nothing left to
|
|
50
|
+
// kill it. That is precisely the orphan this design exists to prevent, so the
|
|
51
|
+
// handler is now unconditional for the whole life of the process.
|
|
52
|
+
process.stdin.on('end', dieWithParent);
|
|
53
|
+
process.stdin.on('close', dieWithParent);
|
|
54
|
+
// The first line on stdin. The pipe stays OPEN afterwards, still serving as the
|
|
55
|
+
// heartbeat above.
|
|
56
|
+
function readRequestLine() {
|
|
57
|
+
return new Promise((resolve, reject) => {
|
|
58
|
+
// Already gone before we got started — nothing will ever arrive.
|
|
59
|
+
if (process.stdin.readableEnded) {
|
|
60
|
+
dieWithParent();
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
let buffered = '';
|
|
64
|
+
process.stdin.setEncoding('utf8');
|
|
65
|
+
process.stdin.on('data', (chunk) => {
|
|
66
|
+
buffered += chunk;
|
|
67
|
+
const newline = buffered.indexOf('\n');
|
|
68
|
+
if (newline !== -1)
|
|
69
|
+
resolve(buffered.slice(0, newline));
|
|
70
|
+
});
|
|
71
|
+
process.stdin.on('error', reject);
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
// Narrow the request at the boundary — the repo's rule for anything parsed, even
|
|
75
|
+
// when the only writer is our own parent.
|
|
76
|
+
function parseRequest(raw) {
|
|
77
|
+
let parsed;
|
|
78
|
+
try {
|
|
79
|
+
parsed = JSON.parse(raw);
|
|
80
|
+
}
|
|
81
|
+
catch {
|
|
82
|
+
return undefined;
|
|
83
|
+
}
|
|
84
|
+
if (typeof parsed !== 'object' || parsed === null)
|
|
85
|
+
return undefined;
|
|
86
|
+
const record = parsed;
|
|
87
|
+
const { account, op, secret, service } = record;
|
|
88
|
+
if (op !== 'delete' && op !== 'get' && op !== 'set')
|
|
89
|
+
return undefined;
|
|
90
|
+
if (typeof service !== 'string' || typeof account !== 'string')
|
|
91
|
+
return undefined;
|
|
92
|
+
if (secret !== undefined && typeof secret !== 'string')
|
|
93
|
+
return undefined;
|
|
94
|
+
return { account, op, secret, service };
|
|
95
|
+
}
|
|
96
|
+
async function main() {
|
|
97
|
+
const request = parseRequest(await readRequestLine());
|
|
98
|
+
if (request === undefined) {
|
|
99
|
+
emit({ error: 'the keychain helper got no readable request', ok: false });
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
const entry = new AsyncEntry(request.service, request.account);
|
|
103
|
+
try {
|
|
104
|
+
switch (request.op) {
|
|
105
|
+
case 'delete': {
|
|
106
|
+
await entry.deletePassword();
|
|
107
|
+
emit({ ok: true, value: null });
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
case 'get': {
|
|
111
|
+
const value = await entry.getPassword();
|
|
112
|
+
emit({ ok: true, value: value ?? null });
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
case 'set': {
|
|
116
|
+
await entry.setPassword(request.secret ?? '');
|
|
117
|
+
emit({ ok: true, value: null });
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
catch (error) {
|
|
122
|
+
// The message is the keychain's own ("No matching entry found…"), which the
|
|
123
|
+
// parent needs verbatim to tell "empty" apart from "unusable". It never
|
|
124
|
+
// contains the secret — that is the value we did not get.
|
|
125
|
+
emit({ error: error instanceof Error ? error.message : String(error), ok: false });
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
await main();
|
|
129
|
+
// Explicit, because stdin is deliberately still open as the parent's heartbeat
|
|
130
|
+
// and its listeners would otherwise keep this process alive forever — the answer
|
|
131
|
+
// would be written and the parent would still wait for a close that never came.
|
|
132
|
+
// Safe to exit here: the result left through a synchronous write, and the
|
|
133
|
+
// keychain call is already finished, so there is no libuv worker left to join.
|
|
134
|
+
// eslint-disable-next-line n/no-process-exit, unicorn/no-process-exit -- the heartbeat pipe keeps the loop alive
|
|
135
|
+
process.exit(0);
|
|
@@ -1,19 +1,33 @@
|
|
|
1
|
+
import { TimeoutError } from '../errors.js';
|
|
1
2
|
import { type Credentials } from './credentials.js';
|
|
3
|
+
import { type KeychainChildRequest } from './keychain-child.js';
|
|
2
4
|
export declare const KEYRING_SERVICE = "io.flipstream.cli";
|
|
5
|
+
export declare const KEYCHAIN_TIMEOUT_MS = 5000;
|
|
6
|
+
export declare const KEYCHAIN_DEFAULT_TIMEOUT_MS = 30000;
|
|
7
|
+
export declare const KEYCHAIN_INTERACTIVE_TIMEOUT_MS = 120000;
|
|
8
|
+
export declare const KEYCHAIN_NOTICE_AFTER_MS = 10000;
|
|
9
|
+
export declare const KEYCHAIN_TIMEOUT_ENV = "FLIPSTREAM_KEYCHAIN_TIMEOUT_MS";
|
|
10
|
+
export declare const KEYCHAIN_INTERACTIVE_ENV = "FLIPSTREAM_KEYCHAIN_INTERACTIVE";
|
|
11
|
+
export declare const KEYCHAIN_MAX_TIMEOUT_MS = 2147483647;
|
|
3
12
|
export interface TokenStore {
|
|
4
|
-
accessTokenIfFresh(host: string): null | string
|
|
5
|
-
available(): boolean
|
|
6
|
-
clear(host: string): void
|
|
7
|
-
load(host: string): Credentials | null
|
|
8
|
-
reload(host: string): Credentials | null
|
|
9
|
-
save(host: string, creds: Credentials): void
|
|
13
|
+
accessTokenIfFresh(host: string): Promise<null | string>;
|
|
14
|
+
available(): Promise<boolean>;
|
|
15
|
+
clear(host: string): Promise<void>;
|
|
16
|
+
load(host: string): Promise<Credentials | null>;
|
|
17
|
+
reload(host: string): Promise<Credentials | null>;
|
|
18
|
+
save(host: string, creds: Credentials): Promise<void>;
|
|
10
19
|
}
|
|
20
|
+
export declare function keychainDeadlineMs(env?: NodeJS.ProcessEnv): number;
|
|
21
|
+
export declare function keychainTimeout(operation: string, timeoutMs?: number): TimeoutError;
|
|
22
|
+
export declare function withDeadline<T>(operation: string, run: (signal: AbortSignal) => Promise<T>, timeoutMs?: number): Promise<T>;
|
|
23
|
+
export declare function spawnKeychain(request: KeychainChildRequest, signal: AbortSignal, scriptPath?: string): Promise<null | string>;
|
|
11
24
|
export declare class KeyringStore implements TokenStore {
|
|
12
25
|
#private;
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
26
|
+
constructor(scriptPath?: string);
|
|
27
|
+
accessTokenIfFresh(host: string): Promise<null | string>;
|
|
28
|
+
available(): Promise<boolean>;
|
|
29
|
+
clear(host: string): Promise<void>;
|
|
30
|
+
load(host: string): Promise<Credentials | null>;
|
|
31
|
+
reload(host: string): Promise<Credentials | null>;
|
|
32
|
+
save(host: string, creds: Credentials): Promise<void>;
|
|
19
33
|
}
|
|
@@ -1,10 +1,191 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import { fileURLToPath } from 'node:url';
|
|
3
|
+
import { TimeoutError } from '../errors.js';
|
|
4
|
+
import { isCI } from '../output/interactivity.js';
|
|
5
|
+
import { isMachineMode } from '../output/machine-mode.js';
|
|
2
6
|
import { freshAccessToken, keyOf, parseCredentials } from './credentials.js';
|
|
3
7
|
// FROZEN. Tokens are stored in the OS keychain under this service id. Renaming
|
|
4
8
|
// it strands users' tokens (silent logout + orphaned secrets), so any change
|
|
5
9
|
// requires a one-time keyring-only migration (read old id -> write new id ->
|
|
6
10
|
// delete old) — never a plaintext intermediary.
|
|
7
11
|
export const KEYRING_SERVICE = 'io.flipstream.cli';
|
|
12
|
+
// Why a keychain call needs a deadline AT ALL: on macOS it can park in mach_msg
|
|
13
|
+
// forever, and nothing inside this process can recover from that. An AbortSignal
|
|
14
|
+
// does not interrupt a syscall already in flight, and process.exit() and
|
|
15
|
+
// process.reallyExit() both hang too, because Node's shutdown joins its libuv
|
|
16
|
+
// workers and the wedged one never finishes. Hence keychain-child.ts: the work
|
|
17
|
+
// runs in a process we can actually kill.
|
|
18
|
+
//
|
|
19
|
+
// How long to wait was argued over seven review rounds, and the useful outcome
|
|
20
|
+
// was learning that the question cannot be answered by detection. Every signal
|
|
21
|
+
// tried was wrong: stdio says nothing (SecurityAgent draws a GUI dialog, so a
|
|
22
|
+
// desktop-launched CLI has pipes and an answerable prompt), SSH_* says nothing
|
|
23
|
+
// (a tmux session started before an SSH login inherits none), and `launchctl
|
|
24
|
+
// managername` says nothing either — the machine this was found on reported Aqua
|
|
25
|
+
// with SSH_CONNECTION unset while its console sat unattended. Aqua proves a
|
|
26
|
+
// screen exists, not that a person is in front of it. There is no evidence
|
|
27
|
+
// available here that somebody is there to click.
|
|
28
|
+
//
|
|
29
|
+
// So the deadline is a judgement rather than a deduction, and it is split three
|
|
30
|
+
// ways. An unattended machine should not sit for minutes, so the DEFAULT is
|
|
31
|
+
// thirty seconds. Somebody who is there gets told at ten what is happening and
|
|
32
|
+
// has twenty more to answer — and if they want longer they can say so, which is
|
|
33
|
+
// the only statement about presence anybody can actually make. A caller that has
|
|
34
|
+
// declared nothing can answer fails fastest of all.
|
|
35
|
+
export const KEYCHAIN_TIMEOUT_MS = 5000;
|
|
36
|
+
export const KEYCHAIN_DEFAULT_TIMEOUT_MS = 30_000;
|
|
37
|
+
export const KEYCHAIN_INTERACTIVE_TIMEOUT_MS = 120_000;
|
|
38
|
+
// How long a keychain call may take before we explain the wait on stderr.
|
|
39
|
+
export const KEYCHAIN_NOTICE_AFTER_MS = 10_000;
|
|
40
|
+
export const KEYCHAIN_TIMEOUT_ENV = 'FLIPSTREAM_KEYCHAIN_TIMEOUT_MS';
|
|
41
|
+
export const KEYCHAIN_INTERACTIVE_ENV = 'FLIPSTREAM_KEYCHAIN_INTERACTIVE';
|
|
42
|
+
// setTimeout's ceiling. A larger delay is silently clamped to ~1ms, which would
|
|
43
|
+
// turn "wait longer" into "fail immediately" — the opposite of the request.
|
|
44
|
+
export const KEYCHAIN_MAX_TIMEOUT_MS = 2_147_483_647;
|
|
45
|
+
function isTruthy(value) {
|
|
46
|
+
return value !== undefined && value !== '' && value !== '0' && value !== 'false';
|
|
47
|
+
}
|
|
48
|
+
// How long to wait for the OS on THIS run. Every path is a stated fact — an
|
|
49
|
+
// exact figure, a declared willingness to wait, or a declared inability to
|
|
50
|
+
// answer — because nothing observable about the session is evidence either way.
|
|
51
|
+
export function keychainDeadlineMs(env = process.env) {
|
|
52
|
+
const override = Number(env[KEYCHAIN_TIMEOUT_ENV]);
|
|
53
|
+
if (Number.isInteger(override) && override > 0 && override <= KEYCHAIN_MAX_TIMEOUT_MS)
|
|
54
|
+
return override;
|
|
55
|
+
if (isTruthy(env[KEYCHAIN_INTERACTIVE_ENV]))
|
|
56
|
+
return KEYCHAIN_INTERACTIVE_TIMEOUT_MS;
|
|
57
|
+
if (isCI(env) || isTruthy(env.FLIPSTREAM_NO_INPUT))
|
|
58
|
+
return KEYCHAIN_TIMEOUT_MS;
|
|
59
|
+
return KEYCHAIN_DEFAULT_TIMEOUT_MS;
|
|
60
|
+
}
|
|
61
|
+
// A keychain call that ran out of time — exit 8, like any other timeout.
|
|
62
|
+
// DISTINCT from "no credentials" on purpose: reporting a stuck keychain as "not
|
|
63
|
+
// logged in" sends a logged-in user to `auth login`, which needs the same
|
|
64
|
+
// keychain and hangs in the same place.
|
|
65
|
+
export function keychainTimeout(operation, timeoutMs = KEYCHAIN_DEFAULT_TIMEOUT_MS) {
|
|
66
|
+
return new TimeoutError(`The OS keychain did not respond within ${timeoutMs} ms (${operation}).`, 'keychain_timeout').withDetails({
|
|
67
|
+
hint: 'A keychain prompt was probably waiting with nobody at that machine to approve it — or the ' +
|
|
68
|
+
'session cannot show one at all (SSH, a locked console, an unattended box). Over SSH or any ' +
|
|
69
|
+
'other remote access, use a Flipstream service account from your Flipstream account rather ' +
|
|
70
|
+
'than an interactive login: it needs no browser and no keychain. Otherwise approve the dialog ' +
|
|
71
|
+
`on that machine and set ${KEYCHAIN_INTERACTIVE_ENV}=1 (or ${KEYCHAIN_TIMEOUT_ENV}=<ms>) to ` +
|
|
72
|
+
'allow time for it, or set FLIPSTREAM_NO_KEYRING=1 for an in-memory session that keeps nothing ' +
|
|
73
|
+
'between commands.',
|
|
74
|
+
retryable: true,
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
// Say something, once, when a keychain call is taking a human amount of time.
|
|
78
|
+
// This is what stops a deadline long enough to be useful from looking like the
|
|
79
|
+
// original hang: the complaint was never the seconds, it was the silence.
|
|
80
|
+
//
|
|
81
|
+
// SILENT IN MACHINE MODE. Under --json/--ndjson stderr must stay empty (the
|
|
82
|
+
// agent contract, pinned by a test), and an agent has nobody to ask anyway.
|
|
83
|
+
function announceWait() {
|
|
84
|
+
const timer = setTimeout(() => {
|
|
85
|
+
if (isMachineMode())
|
|
86
|
+
return;
|
|
87
|
+
process.stderr.write("→ Still waiting on the OS keychain. Are you filling in the keychain prompt? If there's " +
|
|
88
|
+
'nobody at that machine — SSH, or any other remote access — use a Flipstream service ' +
|
|
89
|
+
'account instead; it needs no browser and no keychain. Ctrl-C to stop waiting.\n');
|
|
90
|
+
}, KEYCHAIN_NOTICE_AFTER_MS);
|
|
91
|
+
// Never hold the process open for a notice that may never be due.
|
|
92
|
+
timer.unref?.();
|
|
93
|
+
return () => clearTimeout(timer);
|
|
94
|
+
}
|
|
95
|
+
// Run something under a wall-clock deadline, aborting the signal when the
|
|
96
|
+
// deadline wins. Kept separate from the spawning below so the deadline itself is
|
|
97
|
+
// testable without touching a real keychain.
|
|
98
|
+
export async function withDeadline(operation, run, timeoutMs = keychainDeadlineMs()) {
|
|
99
|
+
const controller = new AbortController();
|
|
100
|
+
let timer;
|
|
101
|
+
const deadline = new Promise((_resolve, reject) => {
|
|
102
|
+
timer = setTimeout(() => {
|
|
103
|
+
controller.abort();
|
|
104
|
+
reject(keychainTimeout(operation, timeoutMs));
|
|
105
|
+
}, timeoutMs);
|
|
106
|
+
});
|
|
107
|
+
try {
|
|
108
|
+
return await Promise.race([run(controller.signal), deadline]);
|
|
109
|
+
}
|
|
110
|
+
finally {
|
|
111
|
+
clearTimeout(timer);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
// This module's sibling child script, in whichever form is on disk: dist/*.js
|
|
115
|
+
// for a published run, src/*.ts under the Bun dev entry. Derived from our OWN
|
|
116
|
+
// url rather than guessed, so it cannot point at a build that is not there.
|
|
117
|
+
function childScript() {
|
|
118
|
+
const self = import.meta.url;
|
|
119
|
+
return fileURLToPath(new URL(self.endsWith('.ts') ? './keychain-child.ts' : './keychain-child.js', self));
|
|
120
|
+
}
|
|
121
|
+
// Ask the child to do one operation, and KILL it if the deadline passes. SIGKILL
|
|
122
|
+
// rather than SIGTERM: a process wedged in the kernel will not run a handler.
|
|
123
|
+
//
|
|
124
|
+
// The runtime is process.execPath, never a node_modules/.bin shim — a shim
|
|
125
|
+
// encodes an assumption about the machine (a `node` on PATH), while this encodes
|
|
126
|
+
// only what the repo already guarantees. `scriptPath` is injectable so the kill
|
|
127
|
+
// path and the failure paths can be tested against helpers that misbehave on
|
|
128
|
+
// purpose.
|
|
129
|
+
export function spawnKeychain(request, signal, scriptPath = childScript()) {
|
|
130
|
+
return new Promise((resolve, reject) => {
|
|
131
|
+
const child = spawn(process.execPath, [scriptPath], { stdio: ['pipe', 'pipe', 'pipe'] });
|
|
132
|
+
signal.addEventListener('abort', () => child.kill('SIGKILL'), { once: true });
|
|
133
|
+
// One line, and then the pipe STAYS OPEN as the child's heartbeat: losing it
|
|
134
|
+
// is how the child learns this process died and kills itself, instead of
|
|
135
|
+
// being reparented and blocking forever. Closing it here would hand that
|
|
136
|
+
// guarantee away for nothing.
|
|
137
|
+
child.stdin.on('error', reject);
|
|
138
|
+
child.stdin.write(`${JSON.stringify(request)}\n`);
|
|
139
|
+
let out = '';
|
|
140
|
+
child.stdout.setEncoding('utf8');
|
|
141
|
+
child.stdout.on('data', (chunk) => {
|
|
142
|
+
out += chunk;
|
|
143
|
+
});
|
|
144
|
+
child.on('error', reject);
|
|
145
|
+
child.on('close', () => {
|
|
146
|
+
// Killed by our own deadline: withDeadline's rejection is already on its
|
|
147
|
+
// way, so settling here would only race it.
|
|
148
|
+
if (signal.aborted)
|
|
149
|
+
return;
|
|
150
|
+
let parsed;
|
|
151
|
+
try {
|
|
152
|
+
parsed = JSON.parse(out.trim());
|
|
153
|
+
}
|
|
154
|
+
catch {
|
|
155
|
+
reject(new Error('the keychain helper returned no readable answer'));
|
|
156
|
+
return;
|
|
157
|
+
}
|
|
158
|
+
if (parsed.ok)
|
|
159
|
+
resolve(parsed.value ?? null);
|
|
160
|
+
// The child's message is the keychain's own wording, which the callers
|
|
161
|
+
// below match on to tell "empty" apart from "unusable".
|
|
162
|
+
else
|
|
163
|
+
reject(new Error(parsed.error ?? 'the keychain helper failed'));
|
|
164
|
+
});
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
async function keychain(call) {
|
|
168
|
+
const request = {
|
|
169
|
+
account: call.account,
|
|
170
|
+
op: call.op,
|
|
171
|
+
secret: call.secret,
|
|
172
|
+
service: KEYRING_SERVICE,
|
|
173
|
+
};
|
|
174
|
+
const done = announceWait();
|
|
175
|
+
try {
|
|
176
|
+
return await withDeadline(call.operation, (signal) => spawnKeychain(request, signal, call.scriptPath));
|
|
177
|
+
}
|
|
178
|
+
finally {
|
|
179
|
+
done();
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
// "Not found" is the keychain working and holding nothing — the ONLY failure
|
|
183
|
+
// that means absence. A helper that could not spawn, crashed loading its native
|
|
184
|
+
// binding, or answered with something unreadable has told us nothing about
|
|
185
|
+
// whether a credential exists, and must never be read as "there isn't one".
|
|
186
|
+
function isNotFound(error) {
|
|
187
|
+
return /not found|no (matching )?(entry|password|item)|could not be found/i.test(error.message);
|
|
188
|
+
}
|
|
8
189
|
// TokenStore backed by the OS keychain. The whole Credentials JSON is the single
|
|
9
190
|
// secret per host (keyed by host).
|
|
10
191
|
//
|
|
@@ -13,7 +194,8 @@ export const KEYRING_SERVICE = 'io.flipstream.cli';
|
|
|
13
194
|
// re-reads it through withFreshToken, and a paginated call re-reads it per page.
|
|
14
195
|
// On macOS each read is a separate access, so a user who granted "Allow" rather
|
|
15
196
|
// than "Always Allow" gets prompted once per read: two dialogs for `catalog`,
|
|
16
|
-
// three for `query`, more for anything that paginates or resolves a name.
|
|
197
|
+
// three for `query`, more for anything that paginates or resolves a name. Each
|
|
198
|
+
// read is also a process now, so the cache saves spawns as well as dialogs.
|
|
17
199
|
//
|
|
18
200
|
// This costs nothing in EXPOSURE: the credentials are already in memory the moment
|
|
19
201
|
// they are read — they have to be, to go in an Authorization header — so holding
|
|
@@ -38,49 +220,95 @@ export class KeyringStore {
|
|
|
38
220
|
// `null` is cached too: "this host has no credentials" is an answer worth
|
|
39
221
|
// remembering, or a logged-out run re-reads on every check.
|
|
40
222
|
#cache = new Map();
|
|
41
|
-
|
|
42
|
-
|
|
223
|
+
// Injectable purely so the failure paths can be driven by a helper that
|
|
224
|
+
// misbehaves on purpose; production always uses the sibling script.
|
|
225
|
+
#scriptPath;
|
|
226
|
+
constructor(scriptPath) {
|
|
227
|
+
this.#scriptPath = scriptPath;
|
|
43
228
|
}
|
|
44
|
-
|
|
229
|
+
async accessTokenIfFresh(host) {
|
|
230
|
+
return freshAccessToken(await this.load(host));
|
|
231
|
+
}
|
|
232
|
+
async available() {
|
|
45
233
|
try {
|
|
46
|
-
|
|
234
|
+
await keychain({
|
|
235
|
+
account: '__probe__',
|
|
236
|
+
op: 'get',
|
|
237
|
+
operation: 'probing the keychain',
|
|
238
|
+
scriptPath: this.#scriptPath,
|
|
239
|
+
});
|
|
47
240
|
return true;
|
|
48
241
|
}
|
|
49
242
|
catch (error) {
|
|
243
|
+
// A wedged keychain is NOT an answer to "is one available" — it is the
|
|
244
|
+
// timeout this whole change exists to report. Returning false here sent
|
|
245
|
+
// `auth status` down its unavailable branch and out with exit 7 (network),
|
|
246
|
+
// for the one condition whose entire point is exit 8 and a message naming
|
|
247
|
+
// the prompt. It propagates.
|
|
248
|
+
if (error instanceof TimeoutError)
|
|
249
|
+
throw error;
|
|
50
250
|
// "not found" means the keychain works but is empty (available); any other
|
|
51
251
|
// failure (e.g. no Secret Service on headless Linux) means it is unavailable.
|
|
52
|
-
return
|
|
252
|
+
return isNotFound(error);
|
|
53
253
|
}
|
|
54
254
|
}
|
|
55
|
-
clear(host) {
|
|
56
|
-
this.#cache.set(host, null);
|
|
255
|
+
async clear(host) {
|
|
57
256
|
try {
|
|
58
|
-
|
|
257
|
+
await keychain({
|
|
258
|
+
account: keyOf(host),
|
|
259
|
+
op: 'delete',
|
|
260
|
+
operation: `clearing credentials for ${host}`,
|
|
261
|
+
scriptPath: this.#scriptPath,
|
|
262
|
+
});
|
|
59
263
|
}
|
|
60
|
-
catch {
|
|
61
|
-
//
|
|
264
|
+
catch (error) {
|
|
265
|
+
// Only a verified "there was nothing to delete" counts as cleared. Anything
|
|
266
|
+
// else — a timeout, a helper that could not start, an unreadable answer —
|
|
267
|
+
// leaves the credential possibly intact, and logout must not report success
|
|
268
|
+
// over it.
|
|
269
|
+
if (!isNotFound(error))
|
|
270
|
+
throw error;
|
|
62
271
|
}
|
|
272
|
+
// Cached only once the deletion is confirmed, so a failed clear cannot leave
|
|
273
|
+
// this process believing the session is gone.
|
|
274
|
+
this.#cache.set(host, null);
|
|
63
275
|
}
|
|
64
|
-
load(host) {
|
|
276
|
+
async load(host) {
|
|
65
277
|
const cached = this.#cache.get(host);
|
|
66
278
|
if (cached !== undefined)
|
|
67
279
|
return cached;
|
|
68
280
|
return this.reload(host);
|
|
69
281
|
}
|
|
70
|
-
reload(host) {
|
|
282
|
+
async reload(host) {
|
|
71
283
|
let creds = null;
|
|
72
284
|
try {
|
|
73
|
-
const secret =
|
|
285
|
+
const secret = await keychain({
|
|
286
|
+
account: keyOf(host),
|
|
287
|
+
op: 'get',
|
|
288
|
+
operation: `reading credentials for ${host}`,
|
|
289
|
+
scriptPath: this.#scriptPath,
|
|
290
|
+
});
|
|
74
291
|
creds = secret ? parseCredentials(secret) : null;
|
|
75
292
|
}
|
|
76
|
-
catch {
|
|
293
|
+
catch (error) {
|
|
294
|
+
// Absence is the only thing we may infer. Every other failure propagates:
|
|
295
|
+
// "we could not read" reported as "nothing stored" turns a spawn failure
|
|
296
|
+
// into not_logged_in and sends a logged-in user to re-authenticate.
|
|
297
|
+
if (!isNotFound(error))
|
|
298
|
+
throw error;
|
|
77
299
|
creds = null;
|
|
78
300
|
}
|
|
79
301
|
this.#cache.set(host, creds);
|
|
80
302
|
return creds;
|
|
81
303
|
}
|
|
82
|
-
save(host, creds) {
|
|
83
|
-
|
|
304
|
+
async save(host, creds) {
|
|
305
|
+
await keychain({
|
|
306
|
+
account: keyOf(host),
|
|
307
|
+
op: 'set',
|
|
308
|
+
operation: `saving credentials for ${host}`,
|
|
309
|
+
scriptPath: this.#scriptPath,
|
|
310
|
+
secret: JSON.stringify(creds),
|
|
311
|
+
});
|
|
84
312
|
// Keep the cache authoritative: a refresh saves rotated tokens mid-command,
|
|
85
313
|
// and a later read must see them rather than the ones it started with.
|
|
86
314
|
this.#cache.set(host, creds);
|
|
@@ -2,10 +2,10 @@ import type { TokenStore } from './keyring.js';
|
|
|
2
2
|
import { type Credentials } from './credentials.js';
|
|
3
3
|
export declare class MemoryStore implements TokenStore {
|
|
4
4
|
private readonly entries;
|
|
5
|
-
accessTokenIfFresh(host: string): null | string
|
|
6
|
-
available(): boolean
|
|
7
|
-
clear(host: string): void
|
|
8
|
-
load(host: string): Credentials | null
|
|
9
|
-
reload(host: string): Credentials | null
|
|
10
|
-
save(host: string, creds: Credentials): void
|
|
5
|
+
accessTokenIfFresh(host: string): Promise<null | string>;
|
|
6
|
+
available(): Promise<boolean>;
|
|
7
|
+
clear(host: string): Promise<void>;
|
|
8
|
+
load(host: string): Promise<Credentials | null>;
|
|
9
|
+
reload(host: string): Promise<Credentials | null>;
|
|
10
|
+
save(host: string, creds: Credentials): Promise<void>;
|
|
11
11
|
}
|
|
@@ -3,24 +3,27 @@ import { freshAccessToken, keyOf } from './credentials.js';
|
|
|
3
3
|
// Interchangeable with KeyringStore via the TokenStore interface.
|
|
4
4
|
export class MemoryStore {
|
|
5
5
|
entries = new Map();
|
|
6
|
-
accessTokenIfFresh(host) {
|
|
7
|
-
return freshAccessToken(this.load(host));
|
|
6
|
+
async accessTokenIfFresh(host) {
|
|
7
|
+
return freshAccessToken(await this.load(host));
|
|
8
8
|
}
|
|
9
|
-
|
|
9
|
+
// Async to match TokenStore, which is async because a REAL keychain call has
|
|
10
|
+
// to be cancellable (see keyring.ts). Nothing here can block; the signature
|
|
11
|
+
// exists so tests drive the same call shape production does.
|
|
12
|
+
async available() {
|
|
10
13
|
return true;
|
|
11
14
|
}
|
|
12
|
-
clear(host) {
|
|
15
|
+
async clear(host) {
|
|
13
16
|
this.entries.delete(keyOf(host));
|
|
14
17
|
}
|
|
15
|
-
load(host) {
|
|
18
|
+
async load(host) {
|
|
16
19
|
return this.entries.get(keyOf(host)) ?? null;
|
|
17
20
|
}
|
|
18
21
|
// No cache here, so a forced re-read is the same read. Present so the interface
|
|
19
22
|
// is honest and tests exercise the same call sites as the real store.
|
|
20
|
-
reload(host) {
|
|
23
|
+
async reload(host) {
|
|
21
24
|
return this.load(host);
|
|
22
25
|
}
|
|
23
|
-
save(host, creds) {
|
|
26
|
+
async save(host, creds) {
|
|
24
27
|
this.entries.set(keyOf(host), creds);
|
|
25
28
|
}
|
|
26
29
|
}
|
package/oclif.manifest.json
CHANGED