@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 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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lanes-sh/link",
3
- "version": "0.6.8",
3
+ "version": "0.6.9",
4
4
  "description": "A self-hostable MCP gateway for all your connections, memory, tasks, files, and secrets",
5
5
  "license": "Apache-2.0",
6
6
  "homepage": "https://lanes.sh/link",
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
- * Three surfaces render HTML — the authorization consent screen, the page a
6
- * connect flow lands on, and the dashboard — and until this file existed each
7
- * carried its own approximation of the brand. They had drifted: two greens and
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
- * these pages have no script — the dashboard, which has one listener, extends
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 asks for the endpoint
169
- * token and another lists the owner's accounts, and framing either is the cheap
170
- * half of a UI-redress attack. The URLs carry a `client_id`, a `redirect_uri`,
171
- * or a one-time key, none of which belongs in a Referer.
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 two things `doctor` has to work out rather than simply read.
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
- * credential resolve, does the connection name a provider that exists — and
14
- * these two are analyses: one dates a credential from what the OAuth provider
15
- * stamped on it, and the other diffs what an upstream now offers against what
16
- * the endpoint is serving. They are the length in that file, and they are the
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
- * Derived from the `expires_at` the OAuth provider stamps when saving tokens.
24
- * Returns null for anything that is not a token blob an app password has no
25
- * meaningful age, and guessing one would produce a confusing warning.
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.