@lanes-sh/link 0.6.8 → 0.6.9
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +5 -0
- package/package.json +1 -1
- package/src/cli/brand.ts +10 -9
- package/src/cli/commands/operate/auth.ts +333 -0
- package/src/cli/commands/operate/desktop.ts +220 -0
- package/src/cli/commands/operate/findings.ts +9 -38
- package/src/cli/commands/operate/inspect.ts +59 -40
- package/src/cli/commands/operate/serve.ts +0 -3
- package/src/cli/commands/operate.ts +5 -3
- package/src/cli/main.ts +19 -4
- package/src/cli/selection.ts +15 -4
- package/src/cli/usage.ts +5 -1
- package/src/connectivity/auth/index.ts +1 -0
- package/src/connectivity/auth/oauth-authcode/provider.ts +17 -1
- package/src/connectivity/auth/oauth-authcode/refresh.ts +43 -11
- package/src/connectivity/auth/oauth-jwt/index.ts +12 -1
- package/src/connectivity/auth/reauth.ts +48 -0
- package/src/server/cors.ts +3 -3
- package/src/server/endpoint.ts +0 -17
- package/src/server/harness.ts +0 -7
- package/src/server/index.ts +3 -33
- package/src/server/mcp/index.ts +0 -1
- package/src/server/mcp/visibility.ts +0 -33
- package/src/cli/commands/operate/dashboard.ts +0 -107
- package/src/cli/dashboard-page.ts +0 -293
- package/src/cli/dashboard-shell.ts +0 -125
- package/src/cli/provider-marks.ts +0 -45
- package/src/server/dashboard.ts +0 -212
package/README.md
CHANGED
|
@@ -81,6 +81,11 @@ with Claude Code or Codex.
|
|
|
81
81
|
It runs the commands above rather than reimplementing them, so consent and the token stay here where
|
|
82
82
|
they belong, and an endpoint set up in the app is the same one you get from a shell. Available from
|
|
83
83
|
Lanes v0.47.0, as a research preview.
|
|
84
|
+
|
|
85
|
+
```console
|
|
86
|
+
$ lanes link desktop # opens the app on that page, installing it if it is not there
|
|
87
|
+
```
|
|
88
|
+
|
|
84
89
|
**[How to use it →](https://lanes.sh/docs/desktop/lanes-link)**
|
|
85
90
|
|
|
86
91
|
## What your agent gets
|
package/package.json
CHANGED
package/src/cli/brand.ts
CHANGED
|
@@ -2,9 +2,10 @@
|
|
|
2
2
|
* The Lanes design tokens, and the pieces every page this repository serves is
|
|
3
3
|
* built from.
|
|
4
4
|
*
|
|
5
|
-
*
|
|
6
|
-
* connect flow lands on
|
|
7
|
-
* carried its own approximation of the brand.
|
|
5
|
+
* Two surfaces render HTML — the authorization consent screen and the page a
|
|
6
|
+
* connect flow lands on. There were three until ADR-053 retired the dashboard,
|
|
7
|
+
* and until this file existed each carried its own approximation of the brand.
|
|
8
|
+
* They had drifted: two greens and
|
|
8
9
|
* two ambers that are not tokens at all, nine different alphas standing in for
|
|
9
10
|
* one border colour, a destructive red with a dark variant the token does not
|
|
10
11
|
* have, and the system font stack where Geist belongs.
|
|
@@ -154,8 +155,8 @@ a { color: inherit; }
|
|
|
154
155
|
*
|
|
155
156
|
* The font stylesheet and the faces it names are the only two origins any of
|
|
156
157
|
* them reaches; `default-src 'none'` closes the rest. No `script-src`, because
|
|
157
|
-
*
|
|
158
|
-
* this rather than replacing it.
|
|
158
|
+
* a page here has no script by default — the consent screen, which has one
|
|
159
|
+
* listener for its submit spinner, extends this rather than replacing it.
|
|
159
160
|
*/
|
|
160
161
|
export const PAGE_CSP =
|
|
161
162
|
"frame-ancestors 'none'; default-src 'none'; " +
|
|
@@ -165,10 +166,10 @@ export const PAGE_CSP =
|
|
|
165
166
|
/**
|
|
166
167
|
* Headers every page here answers with.
|
|
167
168
|
*
|
|
168
|
-
* `frame-ancestors` in both spellings: one of these pages
|
|
169
|
-
*
|
|
170
|
-
*
|
|
171
|
-
*
|
|
169
|
+
* `frame-ancestors` in both spellings: one of these pages is a consent screen
|
|
170
|
+
* with a submit button, and framing it is the cheap half of a UI-redress
|
|
171
|
+
* attack. The URLs carry a `client_id` and a `redirect_uri`, neither of which
|
|
172
|
+
* belongs in a Referer.
|
|
172
173
|
*/
|
|
173
174
|
export const PAGE_HEADERS: Readonly<Record<string, string>> = {
|
|
174
175
|
'content-type': 'text/html; charset=utf-8',
|
|
@@ -0,0 +1,333 @@
|
|
|
1
|
+
import { credentialResolver, ReauthRequired } from '#connectivity/auth/index.ts';
|
|
2
|
+
import type { ResolvedCredential } from '#connectivity/auth/credential.ts';
|
|
3
|
+
import { credentialRefFor } from '#registry';
|
|
4
|
+
import { announce, emit, fail, ok, print, warn } from '../../output.ts';
|
|
5
|
+
import { openRuntime, type GlobalFlags, type Runtime } from '../../runtime.ts';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Whether each connection could still authenticate, asked rather than guessed.
|
|
9
|
+
*
|
|
10
|
+
* `doctor` used to answer a version of this from the *age* of a stored
|
|
11
|
+
* credential, which is wrong in both directions: it dates a credential from
|
|
12
|
+
* when its access token was last refreshed, so a healthy connection nobody has
|
|
13
|
+
* called in a fortnight reads as stale, and a grant revoked an hour ago reads
|
|
14
|
+
* as fresh because nothing has tried to use it since.
|
|
15
|
+
*
|
|
16
|
+
* So this attempts the renewal instead. That is the only thing that actually
|
|
17
|
+
* knows, and it is cheap in the case that matters: resolving an OAuth
|
|
18
|
+
* credential short-circuits on the stored `expires_at` (`oauth-authcode/provider.ts`),
|
|
19
|
+
* so a connection whose access token is still live costs no network at all. The
|
|
20
|
+
* cost is one token-endpoint round trip per connection that has genuinely
|
|
21
|
+
* lapsed — which is exactly the set worth asking about.
|
|
22
|
+
*
|
|
23
|
+
* **This command writes.** A successful refresh persists the new token, which on
|
|
24
|
+
* a deployed target is a secret-store version per refreshed connection. That is
|
|
25
|
+
* deliberate — it is the same write the serve path makes, and it warms the token
|
|
26
|
+
* for the next real call — but it is why the read-only wording `check`, `plan`
|
|
27
|
+
* and `doctor` carry does not appear here.
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
/** What can be said about one connection's ability to authenticate. */
|
|
31
|
+
export type AuthVerdict =
|
|
32
|
+
/** Resolved. The vendor accepted it just now, or its access token is still live. */
|
|
33
|
+
| 'ok'
|
|
34
|
+
/** Stored, and cannot be renewed without a person. The signal this exists for. */
|
|
35
|
+
| 'reauth'
|
|
36
|
+
/** Nothing at the credential ref. */
|
|
37
|
+
| 'missing'
|
|
38
|
+
/** A static secret is present, and nothing here can exercise it. */
|
|
39
|
+
| 'stored'
|
|
40
|
+
/** `auth.kind: none` — the owner layer. Can never need signing in. */
|
|
41
|
+
| 'none'
|
|
42
|
+
/** The probe could not complete: a timeout, a network fault, an unexpected throw. */
|
|
43
|
+
| 'unknown';
|
|
44
|
+
|
|
45
|
+
export interface ConnectionAuth {
|
|
46
|
+
readonly key: string;
|
|
47
|
+
readonly provider: string;
|
|
48
|
+
readonly id: string;
|
|
49
|
+
/** The manifest's `auth.kind`, so a reader can tell why a verdict is what it is. */
|
|
50
|
+
readonly method: string;
|
|
51
|
+
readonly verdict: AuthVerdict;
|
|
52
|
+
/** Whether answering cost a token-endpoint round trip. */
|
|
53
|
+
readonly refreshed: boolean;
|
|
54
|
+
readonly detail?: string;
|
|
55
|
+
readonly fix?: string;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* What the probe observed, before it means anything.
|
|
60
|
+
*
|
|
61
|
+
* Separated from the verdict so the classification can be tested without a
|
|
62
|
+
* network, a credential store, or a runtime — every interesting case here is a
|
|
63
|
+
* question about *mapping*, and the mapping is where the mistakes are.
|
|
64
|
+
*/
|
|
65
|
+
export type ProbeResult =
|
|
66
|
+
/** A credential came back. `staleAccessToken` is the silent-failure case below. */
|
|
67
|
+
| { readonly outcome: 'resolved'; readonly staleAccessToken: boolean }
|
|
68
|
+
/** The resolver returned `resolveNone()` — there was nothing to resolve. */
|
|
69
|
+
| { readonly outcome: 'none' }
|
|
70
|
+
| { readonly outcome: 'timeout' }
|
|
71
|
+
| { readonly outcome: 'threw'; readonly error: unknown };
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* One OAuth probe result, as a verdict.
|
|
75
|
+
*
|
|
76
|
+
* The two rules worth stating out loud, because both are ways this feature
|
|
77
|
+
* could lie:
|
|
78
|
+
*
|
|
79
|
+
* **An unexpected throw is `unknown`, never `reauth`.** Only `ReauthRequired`
|
|
80
|
+
* means a person is needed. Anything else — DNS, a 500, a bug in here — must
|
|
81
|
+
* not send someone through a consent screen, and a warning that is wrong once
|
|
82
|
+
* is a warning that gets scrolled past every time after.
|
|
83
|
+
*
|
|
84
|
+
* **A resolved token is not automatically `ok`.** `upstreamAccessToken` hands
|
|
85
|
+
* back the *stale* access token when there is no refresh token to renew with,
|
|
86
|
+
* and again when a refresh returns no `access_token`. Both are deliberate on
|
|
87
|
+
* the serve path, where letting the vendor's own 401 surface is the truthful
|
|
88
|
+
* instruction — but a health check that reported them as working would be
|
|
89
|
+
* saying the opposite of what the next real call will find. So the stored
|
|
90
|
+
* expiry is checked here rather than that behaviour being changed.
|
|
91
|
+
*/
|
|
92
|
+
export function classifyOAuth(result: ProbeResult): AuthVerdict {
|
|
93
|
+
switch (result.outcome) {
|
|
94
|
+
case 'resolved':
|
|
95
|
+
return result.staleAccessToken ? 'reauth' : 'ok';
|
|
96
|
+
case 'none':
|
|
97
|
+
return 'missing';
|
|
98
|
+
case 'timeout':
|
|
99
|
+
return 'unknown';
|
|
100
|
+
case 'threw':
|
|
101
|
+
return result.error instanceof ReauthRequired ? 'reauth' : 'unknown';
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* How many connections to probe at once.
|
|
107
|
+
*
|
|
108
|
+
* Small on purpose. The work is mostly waiting on token endpoints, so some
|
|
109
|
+
* concurrency is free, but a profile with twenty Google connections hitting one
|
|
110
|
+
* endpoint at once is a rate limit rather than a speed-up.
|
|
111
|
+
*/
|
|
112
|
+
const CONCURRENCY = 6;
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* How long one connection may take before it is reported as `unknown`.
|
|
116
|
+
*
|
|
117
|
+
* `refreshDirectly` takes no `AbortSignal`, so this races rather than cancels —
|
|
118
|
+
* the request finishes into nothing. That is acceptable for a read-shaped
|
|
119
|
+
* command and avoids threading a signal through the refresh path for the
|
|
120
|
+
* benefit of one caller.
|
|
121
|
+
*/
|
|
122
|
+
const PER_CONNECTION_TIMEOUT_MS = 8_000;
|
|
123
|
+
|
|
124
|
+
/** `expires_at` from a stored OAuth blob, or null when it is not one. */
|
|
125
|
+
function storedExpiry(raw: string | null): number | null {
|
|
126
|
+
if (!raw) return null;
|
|
127
|
+
try {
|
|
128
|
+
const parsed = JSON.parse(raw) as { expires_at?: number };
|
|
129
|
+
return typeof parsed.expires_at === 'number' ? parsed.expires_at : null;
|
|
130
|
+
} catch {
|
|
131
|
+
return null;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export interface AuthFlags extends GlobalFlags {
|
|
136
|
+
readonly json?: boolean | undefined;
|
|
137
|
+
/** Narrow to one connection, by `provider.id`. A filter, not a second subject. */
|
|
138
|
+
readonly connection?: string | undefined;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Every connection's verdict, probed concurrently.
|
|
143
|
+
*
|
|
144
|
+
* Exported because `doctor` asks the same question and must not answer it a
|
|
145
|
+
* second, differently-wrong way — that divergence is the bug this replaced.
|
|
146
|
+
*/
|
|
147
|
+
export async function probeConnections(
|
|
148
|
+
runtime: Runtime,
|
|
149
|
+
connections: readonly Runtime['config']['connections'][number][],
|
|
150
|
+
forSelection: (command: string) => string,
|
|
151
|
+
): Promise<ConnectionAuth[]> {
|
|
152
|
+
const resolve = credentialResolver(runtime.registry, runtime.credentials);
|
|
153
|
+
|
|
154
|
+
const probe = async (
|
|
155
|
+
connection: Runtime['config']['connections'][number],
|
|
156
|
+
): Promise<ConnectionAuth> => {
|
|
157
|
+
const key = `${connection.provider}.${connection.id}`;
|
|
158
|
+
const manifest = runtime.manifestFor(connection.provider);
|
|
159
|
+
const method = manifest?.auth.kind ?? 'unknown';
|
|
160
|
+
const base = { key, provider: connection.provider, id: connection.id, method };
|
|
161
|
+
|
|
162
|
+
// A provider holding nothing to authenticate with can never need signing
|
|
163
|
+
// in, and saying so is more useful than saying nothing: it is the whole
|
|
164
|
+
// owner layer, and "why is memory not checked" is a real question.
|
|
165
|
+
if (!manifest || manifest.auth.kind === 'none') {
|
|
166
|
+
return { ...base, method: 'none', verdict: 'none', refreshed: false };
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
const ref = credentialRefFor(connection, manifest);
|
|
170
|
+
if (!ref) return { ...base, verdict: 'none', refreshed: false };
|
|
171
|
+
|
|
172
|
+
const missing = (): ConnectionAuth => ({
|
|
173
|
+
...base,
|
|
174
|
+
verdict: 'missing',
|
|
175
|
+
refreshed: false,
|
|
176
|
+
detail: `Nothing stored at ${ref}.`,
|
|
177
|
+
fix: forSelection(`lanes link connect ${key}`),
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
if (!(await runtime.credentials.has(ref))) return missing();
|
|
181
|
+
|
|
182
|
+
// Everything that is not authcode OAuth is a secret sitting in the store.
|
|
183
|
+
// There is nothing to renew and no cheap way to exercise it, so presence is
|
|
184
|
+
// the whole of what can be said — and `strategy` in particular *must* take
|
|
185
|
+
// this path, because `credentialResolver` refuses it unconditionally (it
|
|
186
|
+
// signs its own requests) and routing it through would manufacture a
|
|
187
|
+
// failure that is not there.
|
|
188
|
+
if (manifest.auth.kind !== 'oauth') {
|
|
189
|
+
return { ...base, verdict: 'stored', refreshed: false };
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
const before = storedExpiry(await runtime.credentials.get(ref));
|
|
193
|
+
const lapsed = before !== null && before <= Date.now();
|
|
194
|
+
|
|
195
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
196
|
+
const result = await Promise.race([
|
|
197
|
+
resolve(connection.provider, connection.id).then(
|
|
198
|
+
(credential: ResolvedCredential): ProbeResult =>
|
|
199
|
+
credential.kind === 'none'
|
|
200
|
+
? { outcome: 'none' }
|
|
201
|
+
: { outcome: 'resolved', staleAccessToken: false },
|
|
202
|
+
(error: unknown): ProbeResult => ({ outcome: 'threw', error }),
|
|
203
|
+
),
|
|
204
|
+
new Promise<ProbeResult>((settle) => {
|
|
205
|
+
timer = setTimeout(() => settle({ outcome: 'timeout' }), PER_CONNECTION_TIMEOUT_MS);
|
|
206
|
+
}),
|
|
207
|
+
]);
|
|
208
|
+
if (timer) clearTimeout(timer);
|
|
209
|
+
|
|
210
|
+
// The silent-failure check. An expiry that is *still* in the past after
|
|
211
|
+
// resolving means the resolver went out and came back with nothing better,
|
|
212
|
+
// which is the case `classifyOAuth` documents.
|
|
213
|
+
const after = storedExpiry(await runtime.credentials.get(ref));
|
|
214
|
+
const stillLapsed = after !== null && after <= Date.now();
|
|
215
|
+
|
|
216
|
+
const settled: ProbeResult =
|
|
217
|
+
result.outcome === 'resolved'
|
|
218
|
+
? { outcome: 'resolved', staleAccessToken: stillLapsed }
|
|
219
|
+
: result;
|
|
220
|
+
|
|
221
|
+
const verdict = classifyOAuth(settled);
|
|
222
|
+
const detail =
|
|
223
|
+
result.outcome === 'threw' && result.error instanceof Error
|
|
224
|
+
? result.error.message
|
|
225
|
+
: result.outcome === 'timeout'
|
|
226
|
+
? `Timed out after ${PER_CONNECTION_TIMEOUT_MS / 1000}s.`
|
|
227
|
+
: settled.outcome === 'resolved' && settled.staleAccessToken
|
|
228
|
+
? 'The stored access token is expired and could not be renewed.'
|
|
229
|
+
: undefined;
|
|
230
|
+
|
|
231
|
+
return {
|
|
232
|
+
...base,
|
|
233
|
+
verdict,
|
|
234
|
+
// Whether answering cost a round trip: a live token means the resolver
|
|
235
|
+
// short-circuited and never went out.
|
|
236
|
+
refreshed: lapsed,
|
|
237
|
+
...(detail ? { detail } : {}),
|
|
238
|
+
...(verdict === 'reauth' || verdict === 'missing'
|
|
239
|
+
? { fix: forSelection(`lanes link connect ${key}`) }
|
|
240
|
+
: {}),
|
|
241
|
+
};
|
|
242
|
+
};
|
|
243
|
+
|
|
244
|
+
return mapWithLimit(connections, CONCURRENCY, probe);
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
export async function auth(flags: AuthFlags): Promise<void> {
|
|
248
|
+
const runtime = await openRuntime(flags);
|
|
249
|
+
|
|
250
|
+
try {
|
|
251
|
+
const { profile, target } = runtime.resolution;
|
|
252
|
+
const forSelection = (command: string) => `${command} --profile ${profile} --target ${target}`;
|
|
253
|
+
|
|
254
|
+
const wanted = flags.connection;
|
|
255
|
+
const connections = wanted
|
|
256
|
+
? runtime.config.connections.filter((c) => `${c.provider}.${c.id}` === wanted)
|
|
257
|
+
: runtime.config.connections;
|
|
258
|
+
|
|
259
|
+
if (wanted && connections.length === 0) {
|
|
260
|
+
throw new Error(
|
|
261
|
+
`No connection "${wanted}" in profile ${profile}. Run: ${forSelection('lanes link status')}`,
|
|
262
|
+
);
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
const results = await probeConnections(runtime, connections, forSelection);
|
|
266
|
+
const needsSomeone = results.filter((r) => r.verdict === 'reauth' || r.verdict === 'missing');
|
|
267
|
+
|
|
268
|
+
// Always zero, and this is load-bearing rather than an oversight: the
|
|
269
|
+
// desktop app discards stdout when the CLI exits non-zero, which is exactly
|
|
270
|
+
// why it cannot read `doctor`. A connection needing a person is the answer
|
|
271
|
+
// this command was asked for, not a failure to produce one.
|
|
272
|
+
return emit(flags.json, { profile, target, ok: needsSomeone.length === 0, connections: results }, () => {
|
|
273
|
+
announce(runtime.resolution);
|
|
274
|
+
|
|
275
|
+
for (const result of results) {
|
|
276
|
+
const line = `${result.key} — ${describe(result.verdict)}`;
|
|
277
|
+
if (result.verdict === 'reauth') print(fail(`${line}\n ${result.fix}`));
|
|
278
|
+
else if (result.verdict === 'missing') print(warn(`${line}\n ${result.fix}`));
|
|
279
|
+
else print(ok(line));
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
if (needsSomeone.length > 0) {
|
|
283
|
+
print();
|
|
284
|
+
print(fail(`${needsSomeone.length} connection(s) need you to sign in again`));
|
|
285
|
+
}
|
|
286
|
+
});
|
|
287
|
+
} finally {
|
|
288
|
+
await runtime.close();
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
function describe(verdict: AuthVerdict): string {
|
|
293
|
+
switch (verdict) {
|
|
294
|
+
case 'ok':
|
|
295
|
+
return 'authenticated';
|
|
296
|
+
case 'reauth':
|
|
297
|
+
return 'signed out, and cannot renew itself';
|
|
298
|
+
case 'missing':
|
|
299
|
+
return 'no credential stored';
|
|
300
|
+
case 'stored':
|
|
301
|
+
return 'credential stored, not exercised';
|
|
302
|
+
case 'none':
|
|
303
|
+
return 'needs no credential';
|
|
304
|
+
case 'unknown':
|
|
305
|
+
return 'could not be checked';
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
/**
|
|
310
|
+
* `Promise.all` with a ceiling, in the ten lines it takes.
|
|
311
|
+
*
|
|
312
|
+
* A pool rather than chunks: chunking would make every batch wait for its
|
|
313
|
+
* slowest member, which is the shape this command is trying to avoid.
|
|
314
|
+
*/
|
|
315
|
+
async function mapWithLimit<T, R>(
|
|
316
|
+
items: readonly T[],
|
|
317
|
+
limit: number,
|
|
318
|
+
run: (item: T) => Promise<R>,
|
|
319
|
+
): Promise<R[]> {
|
|
320
|
+
const results = new Array<R>(items.length);
|
|
321
|
+
let next = 0;
|
|
322
|
+
|
|
323
|
+
const worker = async (): Promise<void> => {
|
|
324
|
+
for (;;) {
|
|
325
|
+
const index = next++;
|
|
326
|
+
if (index >= items.length) return;
|
|
327
|
+
results[index] = await run(items[index]!);
|
|
328
|
+
}
|
|
329
|
+
};
|
|
330
|
+
|
|
331
|
+
await Promise.all(Array.from({ length: Math.min(limit, items.length) }, worker));
|
|
332
|
+
return results;
|
|
333
|
+
}
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
import { ok, print, progress, style, warn } from '../../output.ts';
|
|
2
|
+
import { confirm, isInteractive } from '../../prompt.ts';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* `lanes link desktop` — open the Lanes app, on its Lanes Link page.
|
|
6
|
+
*
|
|
7
|
+
* `lanes link dashboard` is the same command under its older name. That name
|
|
8
|
+
* used to mean a page this endpoint served at `/dashboard`, and ADR-053 retired
|
|
9
|
+
* it: the desktop app has a Lanes Link settings page that runs these same
|
|
10
|
+
* commands, so the served page was the second, weaker copy of one surface.
|
|
11
|
+
*
|
|
12
|
+
* It resolves no profile and opens no runtime, which is what lets it be `'none'`
|
|
13
|
+
* in `selection.ts` — nothing here depends on which profile or target you meant.
|
|
14
|
+
* The app holds its own selection, and a command that demanded two values it
|
|
15
|
+
* then discarded would only be asking out of habit.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* The scheme the released Lanes app registers with LaunchServices.
|
|
20
|
+
*
|
|
21
|
+
* `lanes-dev` and `lanes-stage` are separate builds registering separate
|
|
22
|
+
* schemes, so testing against either means naming it — see
|
|
23
|
+
* `LANES_LINK_APP_SCHEME` below.
|
|
24
|
+
*/
|
|
25
|
+
const SCHEME = 'lanes';
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* The settings page id.
|
|
29
|
+
*
|
|
30
|
+
* A contract with another repository: it is `SETTINGS_PAGE_IDS` in the app's
|
|
31
|
+
* `src/settings/nav.ts`, and a test there pins this spelling precisely because
|
|
32
|
+
* renaming it would break this command with nothing on either side reporting a
|
|
33
|
+
* failure. The app opens Settings on its current page rather than refusing an
|
|
34
|
+
* id it does not have, so a mismatch degrades instead of dying — which also
|
|
35
|
+
* means it is invisible.
|
|
36
|
+
*/
|
|
37
|
+
const PAGE = 'integrations-link';
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* How the app is installed, in the tap's own spelling.
|
|
41
|
+
*
|
|
42
|
+
* Homebrew rather than a download, for the same reason the app installs this
|
|
43
|
+
* CLI with `bun install -g` rather than sending someone to npm: an install a
|
|
44
|
+
* command performs has to be one an upgrade later finds. It is only ever
|
|
45
|
+
* reached on darwin, since the platform check turns everything else away first.
|
|
46
|
+
*/
|
|
47
|
+
const INSTALL = ['brew', 'install', '--cask', 'lanes-sh/lanes/lanes'] as const;
|
|
48
|
+
|
|
49
|
+
/** Enough of the tail to explain an exit code, and no more. */
|
|
50
|
+
const KEPT_STDERR = 4096;
|
|
51
|
+
|
|
52
|
+
export interface DesktopFlags {
|
|
53
|
+
/** Print the URL instead of opening it. */
|
|
54
|
+
readonly print?: boolean | undefined;
|
|
55
|
+
/** Install without asking first. */
|
|
56
|
+
readonly yes?: boolean | undefined;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Injected in tests. Every field is the real thing when absent. */
|
|
60
|
+
export interface DesktopDeps {
|
|
61
|
+
readonly env?: Record<string, string | undefined> | undefined;
|
|
62
|
+
readonly platform?: NodeJS.Platform | undefined;
|
|
63
|
+
/** Hand the URL to the OS. Resolves false when nothing claimed the scheme. */
|
|
64
|
+
readonly open?: ((url: string) => Promise<boolean>) | undefined;
|
|
65
|
+
/** Where `brew` is, or null. */
|
|
66
|
+
readonly brew?: (() => string | null) | undefined;
|
|
67
|
+
/** Run the install. Resolves an error tail, or null on success. */
|
|
68
|
+
readonly install?: ((brew: string) => Promise<string | null>) | undefined;
|
|
69
|
+
readonly interactive?: boolean | undefined;
|
|
70
|
+
readonly confirm?: ((question: string) => Promise<boolean>) | undefined;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** The deep link, and the one env var that points it at another build. */
|
|
74
|
+
export function settingsUrl(env: Record<string, string | undefined> = process.env): string {
|
|
75
|
+
// `||`, not `??`: an empty `LANES_LINK_APP_SCHEME` is someone unsetting it in
|
|
76
|
+
// a shell, and `://settings` is not a URL anything can open.
|
|
77
|
+
return `${env['LANES_LINK_APP_SCHEME'] || SCHEME}://settings?page=${PAGE}`;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Awaited, unlike `defaultOpenBrowser` in `oauth.ts`, and not the same function.
|
|
82
|
+
*
|
|
83
|
+
* That one is fire-and-forget because an OAuth consent has to outlive the
|
|
84
|
+
* command and `xdg-open` can block for as long as the browser it exec'd. This
|
|
85
|
+
* one runs on darwin only, where `open` hands off to LaunchServices and returns
|
|
86
|
+
* immediately — so the exit code is free, and it is the only thing that
|
|
87
|
+
* separates "the app is installed" from "nothing on this machine answers
|
|
88
|
+
* `lanes://`". Merging the two would give one of them the wrong behaviour.
|
|
89
|
+
*/
|
|
90
|
+
async function openUrl(url: string): Promise<boolean> {
|
|
91
|
+
try {
|
|
92
|
+
// An argument array, never a shell string: the URL carries a query.
|
|
93
|
+
const child = Bun.spawn(['open', url], { stdout: 'ignore', stderr: 'ignore' });
|
|
94
|
+
return (await child.exited) === 0;
|
|
95
|
+
} catch {
|
|
96
|
+
return false;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Run the cask install, streaming it.
|
|
102
|
+
*
|
|
103
|
+
* Streamed rather than captured for the reason `runGcloud` is: a download of
|
|
104
|
+
* this size takes long enough that silence is indistinguishable from a hang.
|
|
105
|
+
* stderr is kept as well as shown, because "no such cask" and "the tap is
|
|
106
|
+
* unreachable" both exit 1 and need different answers.
|
|
107
|
+
*/
|
|
108
|
+
async function runInstall(brew: string): Promise<string | null> {
|
|
109
|
+
const child = Bun.spawn([brew, ...INSTALL.slice(1)], { stdout: 'inherit', stderr: 'pipe' });
|
|
110
|
+
|
|
111
|
+
let captured = '';
|
|
112
|
+
const decoder = new TextDecoder();
|
|
113
|
+
const reader = (child.stderr as ReadableStream<Uint8Array>).getReader();
|
|
114
|
+
for (;;) {
|
|
115
|
+
const { done, value } = await reader.read();
|
|
116
|
+
if (done) break;
|
|
117
|
+
const text = decoder.decode(value, { stream: true });
|
|
118
|
+
process.stderr.write(text);
|
|
119
|
+
captured = (captured + text).slice(-KEPT_STDERR);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
return (await child.exited) === 0 ? null : captured.trim();
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Install the app, having established that nothing answers the scheme.
|
|
127
|
+
*
|
|
128
|
+
* It asks first. Everything else this CLI installs is a file in the workspace
|
|
129
|
+
* it already owns; this puts an application on the machine, which is the
|
|
130
|
+
* largest side effect any command here has and the one a person is most
|
|
131
|
+
* entitled to decline. `--yes` is for the run that already decided, and a
|
|
132
|
+
* pipe with no terminal is refused rather than assumed — the same split
|
|
133
|
+
* `knowledge use` makes (ADR-041).
|
|
134
|
+
*/
|
|
135
|
+
async function install(flags: DesktopFlags, deps: DesktopDeps): Promise<void> {
|
|
136
|
+
const line = INSTALL.join(' ');
|
|
137
|
+
const brew = (deps.brew ?? (() => Bun.which('brew')))();
|
|
138
|
+
|
|
139
|
+
if (!brew) {
|
|
140
|
+
// Nothing to offer: this is the one path where the command cannot finish
|
|
141
|
+
// the job, so it hands over the whole of it.
|
|
142
|
+
throw new Error(
|
|
143
|
+
'The Lanes app is not installed, and neither is Homebrew.\n' +
|
|
144
|
+
` With Homebrew: ${line}\n` +
|
|
145
|
+
' Without it, download the app: https://lanes.sh/desktop',
|
|
146
|
+
);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
print(warn('the Lanes app is not installed'));
|
|
150
|
+
print(style.dim(` ${line}`));
|
|
151
|
+
|
|
152
|
+
const interactive = deps.interactive ?? isInteractive();
|
|
153
|
+
if (!flags.yes) {
|
|
154
|
+
if (!interactive) {
|
|
155
|
+
// A script cannot answer, and installing an application because nobody
|
|
156
|
+
// was there to say no is the wrong way to resolve that.
|
|
157
|
+
throw new Error(
|
|
158
|
+
`Nothing here can answer a prompt. Run the line above, or pass --yes to install it.`,
|
|
159
|
+
);
|
|
160
|
+
}
|
|
161
|
+
if (!(await (deps.confirm ?? ((q: string) => confirm(q)))('Install it now?'))) {
|
|
162
|
+
throw new Error(`Not installed. When you want it: ${line}`);
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
progress(style.dim(' installing, which takes a minute the first time…'));
|
|
167
|
+
const failed = await (deps.install ?? runInstall)(brew);
|
|
168
|
+
if (failed !== null) {
|
|
169
|
+
throw new Error(`${line} failed.\n${failed || ' It printed nothing that explains why.'}`);
|
|
170
|
+
}
|
|
171
|
+
print(ok('installed Lanes'));
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
export async function desktop(flags: DesktopFlags, deps: DesktopDeps = {}): Promise<void> {
|
|
175
|
+
const url = settingsUrl(deps.env ?? process.env);
|
|
176
|
+
|
|
177
|
+
if (flags.print) {
|
|
178
|
+
// Alone on stdout, so `open "$(lanes link desktop --print)"` works. Unlike
|
|
179
|
+
// the URL this printed when it opened a served page, it carries no token
|
|
180
|
+
// and is the same string every time.
|
|
181
|
+
print(url);
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
const platform = deps.platform ?? process.platform;
|
|
186
|
+
if (platform !== 'darwin') {
|
|
187
|
+
// The app is macOS-only; this CLI is not. Saying so beats spawning
|
|
188
|
+
// `xdg-open` at a scheme no Linux machine has ever registered.
|
|
189
|
+
throw new Error(
|
|
190
|
+
`The Lanes desktop app is macOS-only, and this is ${platform}.\n` +
|
|
191
|
+
` What it would have opened: ${url}\n` +
|
|
192
|
+
' Everything that page does is a command here — start with: lanes link status',
|
|
193
|
+
);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
const open = deps.open ?? openUrl;
|
|
197
|
+
if (await open(url)) {
|
|
198
|
+
print(ok(`opened ${style.bold('Lanes')} → Settings → Integrations → Lanes Link`));
|
|
199
|
+
// The one failure this command cannot see. An older Lanes is still
|
|
200
|
+
// registered for `lanes://`, so `open` exits 0, and it then ignores an
|
|
201
|
+
// action it does not know — the app comes forward on whatever page it was
|
|
202
|
+
// already on. Not printed after an install below, where the version is
|
|
203
|
+
// whatever the tap just handed over.
|
|
204
|
+
print(style.dim(' needs Lanes 0.48.0 or newer; an older app ignores the link.'));
|
|
205
|
+
return;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
await install(flags, deps);
|
|
209
|
+
|
|
210
|
+
if (!(await open(url))) {
|
|
211
|
+
// Installed, and the scheme still unclaimed. LaunchServices registers a
|
|
212
|
+
// cask's bundle as it lands, but not always before the next process asks.
|
|
213
|
+
throw new Error(
|
|
214
|
+
'Lanes is installed, but nothing answers a lanes:// link yet.\n' +
|
|
215
|
+
' macOS registers the scheme a moment after the app lands. Try again: lanes link desktop',
|
|
216
|
+
);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
print(ok(`opened ${style.bold('Lanes')} → Settings → Integrations → Lanes Link`));
|
|
220
|
+
}
|
|
@@ -1,53 +1,24 @@
|
|
|
1
1
|
import { ownerPrincipal } from '#auth';
|
|
2
2
|
import type { DiscoveredCapability } from '#connectivity';
|
|
3
|
-
import { BROKERED } from '#connectivity/auth/index.ts';
|
|
4
3
|
import { allowedConnections } from '#policy';
|
|
5
4
|
import { toPolicyDocument } from '#registry';
|
|
6
5
|
import { capabilityDiff, discoveryProbe } from '../../runtime/discovery.ts';
|
|
7
6
|
import type { openRuntime } from '../../runtime.ts';
|
|
8
7
|
|
|
9
8
|
/**
|
|
10
|
-
* The
|
|
9
|
+
* The one thing `doctor` has to work out rather than simply read.
|
|
11
10
|
*
|
|
12
11
|
* Everything else in `inspect.ts` is a lookup — is the token there, does the
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
* part that changes for reasons the gate order has nothing to do with.
|
|
18
|
-
*/
|
|
19
|
-
|
|
20
|
-
/**
|
|
21
|
-
* How old a stored OAuth credential is.
|
|
12
|
+
* connection name a provider that exists — and this is an analysis: it diffs
|
|
13
|
+
* what an upstream now offers against what the endpoint is serving. It is the
|
|
14
|
+
* length in that file, and it is the part that changes for reasons the gate
|
|
15
|
+
* order has nothing to do with.
|
|
22
16
|
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
17
|
+
* `credentialAge` used to sit beside it and date a credential from the OAuth
|
|
18
|
+
* provider's stamp. It is gone: dating a credential answers "when did this last
|
|
19
|
+
* refresh", which is not the question anyone was asking. `operate/auth.ts`
|
|
20
|
+
* asks the real one by attempting the renewal.
|
|
26
21
|
*/
|
|
27
|
-
export async function credentialAge(
|
|
28
|
-
credentials: { get(ref: string): Promise<string | null> },
|
|
29
|
-
ref: string,
|
|
30
|
-
): Promise<{ days: number; brokered: boolean } | null> {
|
|
31
|
-
const raw = await credentials.get(ref);
|
|
32
|
-
if (!raw) return null;
|
|
33
|
-
|
|
34
|
-
try {
|
|
35
|
-
const parsed = JSON.parse(raw) as {
|
|
36
|
-
expires_at?: number;
|
|
37
|
-
expires_in?: number;
|
|
38
|
-
authorized_via?: string;
|
|
39
|
-
};
|
|
40
|
-
if (typeof parsed.expires_at !== 'number') return null;
|
|
41
|
-
|
|
42
|
-
const issued = parsed.expires_at - (parsed.expires_in ?? 3600) * 1000;
|
|
43
|
-
return {
|
|
44
|
-
days: Math.floor((Date.now() - issued) / 86_400_000),
|
|
45
|
-
brokered: parsed.authorized_via === BROKERED,
|
|
46
|
-
};
|
|
47
|
-
} catch {
|
|
48
|
-
return null;
|
|
49
|
-
}
|
|
50
|
-
}
|
|
51
22
|
|
|
52
23
|
/**
|
|
53
24
|
* Capabilities the upstream has grown since you connected.
|