@lanes-sh/link 0.6.8 → 0.6.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. package/.gcloudignore +50 -0
  2. package/README.md +5 -0
  3. package/package.json +4 -3
  4. package/src/auth/oidc.ts +65 -12
  5. package/src/cli/brand.ts +16 -9
  6. package/src/cli/commands/operate/auth.ts +333 -0
  7. package/src/cli/commands/operate/desktop.ts +220 -0
  8. package/src/cli/commands/operate/findings.ts +9 -38
  9. package/src/cli/commands/operate/inspect.ts +59 -40
  10. package/src/cli/commands/operate/serve.ts +0 -3
  11. package/src/cli/commands/operate.ts +5 -3
  12. package/src/cli/main.ts +19 -4
  13. package/src/cli/selection.ts +15 -4
  14. package/src/cli/usage.ts +5 -1
  15. package/src/connectivity/auth/index.ts +1 -0
  16. package/src/connectivity/auth/oauth-authcode/provider.ts +17 -1
  17. package/src/connectivity/auth/oauth-authcode/refresh.ts +43 -11
  18. package/src/connectivity/auth/oauth-jwt/index.ts +12 -1
  19. package/src/connectivity/auth/reauth.ts +48 -0
  20. package/src/deployments/gcp/Dockerfile +27 -2
  21. package/src/deployments/gcp/bucket.ts +82 -9
  22. package/src/deployments/gcp/driver.ts +43 -5
  23. package/src/deployments/gcp/lifecycle.json +12 -0
  24. package/src/deployments/gcp/survey.ts +12 -1
  25. package/src/policy/limits.ts +76 -3
  26. package/src/profile/index.ts +1 -0
  27. package/src/profile/legacy.ts +8 -3
  28. package/src/profile/schema.ts +65 -0
  29. package/src/server/cors.ts +3 -3
  30. package/src/server/edge.ts +188 -1
  31. package/src/server/endpoint.ts +0 -17
  32. package/src/server/harness.ts +10 -7
  33. package/src/server/index.ts +62 -90
  34. package/src/server/mcp/index.ts +0 -1
  35. package/src/server/mcp/visibility.ts +0 -33
  36. package/src/server/oauth.ts +21 -2
  37. package/src/cli/commands/operate/dashboard.ts +0 -107
  38. package/src/cli/dashboard-page.ts +0 -293
  39. package/src/cli/dashboard-shell.ts +0 -125
  40. package/src/cli/provider-marks.ts +0 -45
  41. package/src/server/dashboard.ts +0 -212
@@ -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.
@@ -1,11 +1,12 @@
1
- import { credentialRefFor, formatPlan, planIsNoop, planReconcile } from '#registry';
1
+ import { formatPlan, planIsNoop, planReconcile } from '#registry';
2
2
  import { DEFAULT_SURFACES } from '../../config-repair.ts';
3
3
  import { announce, announceProfile, emit, fail, ok, print, warn } from '../../output.ts';
4
4
  import { staleNudge } from '../../release.ts';
5
5
  import { openRuntime, resolveProfileOnly, type GlobalFlags, type Runtime } from '../../runtime.ts';
6
6
  import type { FetchLike } from '#deployments/knowledge.ts';
7
7
  import { unboundRotatableRefs } from '#deployments/bind.ts';
8
- import { credentialAge, reportCapabilityDrift } from './findings.ts';
8
+ import { reportCapabilityDrift } from './findings.ts';
9
+ import { probeConnections } from './auth.ts';
9
10
  import { migratedContract, migratedRenamedProviders } from './migrate.ts';
10
11
 
11
12
  /**
@@ -65,7 +66,16 @@ export interface DoctorFinding {
65
66
  readonly fix?: string;
66
67
  }
67
68
 
68
- /** Read-only external checks: credentials resolve, stores reachable. */
69
+ /**
70
+ * External checks: credentials still authenticate, stores reachable.
71
+ *
72
+ * Not read-only, and that changed when the credential check stopped guessing
73
+ * from a stored date and started attempting the renewal. A refresh that
74
+ * succeeds persists the new token, which on a deployed target is a secret-store
75
+ * write. It is the same write serving a request makes, and it warms the token
76
+ * for the next real call — but `check` and `plan` above are still the two that
77
+ * touch nothing.
78
+ */
69
79
  export async function doctor(flags: DoctorFlags): Promise<void> {
70
80
  // The one check that cannot use a runtime, because it answers for the profiles
71
81
  // that cannot open one. A provider rename left in the config refuses at load,
@@ -108,47 +118,56 @@ export async function doctor(flags: DoctorFlags): Promise<void> {
108
118
  });
109
119
  }
110
120
 
111
- for (const connection of runtime.config.connections) {
112
- const key = `${connection.provider}.${connection.id}`;
113
- const ref = credentialRefFor(connection, runtime.manifestFor(connection.provider));
114
- if (!ref) {
115
- checks.push(`${key} needs no credential`);
116
- continue;
117
- }
118
- if (await runtime.credentials.has(ref)) {
119
- const staleness = await credentialAge(runtime.credentials, ref);
121
+ // Whether each credential still works, asked rather than dated.
122
+ //
123
+ // This used to warn from the *age* of a stored credential, on the theory
124
+ // that a Google app left in "Testing" expires refresh tokens at seven days.
125
+ // The heuristic was wrong in both directions — it dated a credential from
126
+ // its last refresh, so an untouched healthy connection read as stale and a
127
+ // grant revoked an hour ago read as fresh — and its own guard made it worse:
128
+ // it skipped brokered credentials because "the hosted client is in
129
+ // production", which `providers/google/shared/oauth.ts` now says outright is
130
+ // not so. The hosted client is under review and carries the same weekly
131
+ // expiry, so the warning was silenced for exactly the population that has
132
+ // the problem.
133
+ //
134
+ // `probeConnections` answers it by attempting the renewal, which is the only
135
+ // thing that actually knows. Same classifier as `lanes link auth`, so the two
136
+ // cannot drift apart again.
137
+ const probed = await probeConnections(runtime, runtime.config.connections, forSelection);
120
138
 
121
- // A Google project left in "Testing" expires refresh tokens after seven
122
- // days. That is a policy setting rather than a fault, but it presents
123
- // as an authentication failure mid-task — so say it before the call
124
- // fails rather than after.
125
- //
126
- // Only for a client the operator registered. The hosted one is in
127
- // production and does not expire refresh tokens weekly, so this warning
128
- // would simply be false there — and a warning that is wrong once is a
129
- // warning that gets scrolled past every time after.
130
- if (staleness !== null && !staleness.brokered && staleness.days >= 7) {
139
+ for (const result of probed) {
140
+ switch (result.verdict) {
141
+ case 'reauth':
131
142
  warnings.push({
132
- kind: 'stale_credential',
133
- key,
143
+ kind: 'needs_reauth',
144
+ key: result.key,
134
145
  message:
135
- `${key} credential is ${staleness.days} days old a Google app in "Testing" expires at 7. ` +
136
- `Run: lanes link connect ${key}`,
137
- fix: forSelection(`lanes link connect ${key}`),
146
+ `${result.key} is signed out and cannot renew itself run: lanes link connect ${result.key}` +
147
+ (result.detail ? `\n ${result.detail}` : ''),
148
+ fix: forSelection(`lanes link connect ${result.key}`),
138
149
  });
139
- } else {
140
- const age = staleness
141
- ? ` (${staleness.days}d old${staleness.brokered ? ', hosted client' : ''})`
142
- : '';
143
- checks.push(`${key} credential resolves${age}`);
144
- }
145
- } else {
146
- problems.push({
147
- kind: 'missing_credential',
148
- key,
149
- message: `${key} has no stored credential — run: lanes link connect ${key}`,
150
- fix: forSelection(`lanes link connect ${key}`),
151
- });
150
+ break;
151
+ case 'missing':
152
+ problems.push({
153
+ kind: 'missing_credential',
154
+ key: result.key,
155
+ message: `${result.key} has no stored credential — run: lanes link connect ${result.key}`,
156
+ fix: forSelection(`lanes link connect ${result.key}`),
157
+ });
158
+ break;
159
+ case 'none':
160
+ checks.push(`${result.key} needs no credential`);
161
+ break;
162
+ case 'unknown':
163
+ warnings.push({
164
+ kind: 'auth_uncheckable',
165
+ key: result.key,
166
+ message: `${result.key} could not be checked${result.detail ? `: ${result.detail}` : ''}`,
167
+ });
168
+ break;
169
+ default:
170
+ checks.push(`${result.key} credential resolves`);
152
171
  }
153
172
  }
154
173
 
@@ -45,9 +45,6 @@ export async function start(
45
45
  port: flags.port,
46
46
  only: flags.only,
47
47
  mintToken: true,
48
- // Local, so there is a browser and a person at it. `container.ts` does not
49
- // pass this — see `#server/dashboard.ts`.
50
- dashboard: true,
51
48
  // Stderr, not stdout: `--json` and `--raw` callers parse the other stream.
52
49
  // A refused credential is the event worth seeing while this runs in the
53
50
  // foreground, and until now nothing printed it.
@@ -2,13 +2,14 @@
2
2
  * Running an instance and looking at it — everything that is neither
3
3
  * `connect` nor the owner layer.
4
4
  *
5
- * Seven files, one per verb group, because every private helper here served
5
+ * Eight files, one per verb group, because every private helper here served
6
6
  * exactly one command and nothing crossed between them:
7
7
  *
8
8
  * inspect.ts check, plan, doctor — the gate order, cheapest failure first
9
+ * auth.ts whether each connection can still authenticate, by asking
9
10
  * status.ts connections, reachable capabilities, endpoint
10
11
  * outputs.ts what an agent harness needs, and proving the short form works
11
- * dashboard.ts opening the page a local endpoint serves, with the key it needs
12
+ * desktop.ts opening the Lanes app, on the page that drives this CLI
12
13
  * serve.ts start
13
14
  * audit.ts audit tail and verify, and the Markdown rendering of tail
14
15
  * token.ts token show, token rotate
@@ -19,11 +20,12 @@
19
20
  */
20
21
 
21
22
  export { check, doctor, plan } from './operate/inspect.ts';
23
+ export { auth, classifyOAuth, type AuthFlags, type AuthVerdict, type ConnectionAuth } from './operate/auth.ts';
22
24
  export { status } from './operate/status.ts';
23
25
  export { outputs, type OutputsFlags } from './operate/outputs.ts';
24
26
  export { tools, type ToolsFlags } from './operate/tools.ts';
25
27
  export { start } from './operate/serve.ts';
26
- export { dashboard, type DashboardFlags } from './operate/dashboard.ts';
28
+ export { desktop, settingsUrl, type DesktopFlags } from './operate/desktop.ts';
27
29
  export { auditTail, auditVerify, markdownCell } from './operate/audit.ts';
28
30
  export { attachFile } from './operate/attach.ts';
29
31
  export { tokenRotate, tokenShow } from './operate/token.ts';
package/src/cli/main.ts CHANGED
@@ -5,9 +5,10 @@ import {
5
5
  attachFile,
6
6
  auditTail,
7
7
  auditVerify,
8
+ auth,
8
9
  check,
9
10
  configShow,
10
- dashboard,
11
+ desktop,
11
12
  doctor,
12
13
  outputs,
13
14
  plan,
@@ -282,6 +283,11 @@ export async function run(argv: readonly string[]): Promise<void> {
282
283
  return plan(global);
283
284
  case 'doctor':
284
285
  return doctor({ ...global, json, fix: flags['fix'] === true });
286
+
287
+ // Beside `doctor` because it answers half of what `doctor` used to guess at,
288
+ // and answers it by asking rather than by dating a credential.
289
+ case 'auth':
290
+ return auth({ ...global, json, connection: text(flags, 'connection') });
285
291
  case 'status':
286
292
  return status({ ...global, json });
287
293
  case 'outputs':
@@ -289,10 +295,19 @@ export async function run(argv: readonly string[]): Promise<void> {
289
295
 
290
296
  // Beside `outputs` for the same reason `tools` is: it answers the next
291
297
  // question a person has rather than the next one an agent has. `outputs`
292
- // hands a harness a URL and a token; this opens the one page a person can
293
- // read, and only a local endpoint serves it.
298
+ // hands a harness a URL and a token; this opens the app a person drives all
299
+ // of this from.
300
+ //
301
+ // Two spellings, one behaviour, as `skill` is for `mcp skill`. `dashboard`
302
+ // is what this was called when it opened a page the endpoint served, and
303
+ // that name is in a year of notes; `desktop` is what it does now (ADR-053).
304
+ // Both are in `USAGE`, unlike the `skill` alias, because nobody has learned
305
+ // the new one yet.
306
+ //
307
+ // No `...global`: this resolves nothing, so there is nothing to select.
294
308
  case 'dashboard':
295
- return dashboard({ ...global, print: flags['print'] === true });
309
+ case 'desktop':
310
+ return desktop({ print: flags['print'] === true, yes: flags['yes'] === true });
296
311
 
297
312
  // Beside `outputs` because it answers the next question. `outputs` says
298
313
  // where the endpoint is; this says what it would hand a client that asked
@@ -129,13 +129,18 @@ export const SELECTION: Record<string, Requires> = {
129
129
  secrets: 'profile+target',
130
130
  plan: 'profile+target',
131
131
  doctor: 'profile+target',
132
+ auth: 'profile+target',
132
133
  // Target-scoped: see the note above. `--profile` narrows each to one profile.
133
134
  status: 'target',
134
135
  outputs: 'profile+target',
135
136
  tools: 'profile+target',
136
- // It reads which target it is rendering for before it decides anything: a
137
- // deployed one has no page to open, and the refusal has to name it.
138
- dashboard: 'profile+target',
137
+ // It resolves nothing and opens nothing it hands macOS a URL (ADR-053).
138
+ // `target list` is the precedent for a `'none'` command that still takes a
139
+ // flag of its own. Both spellings need a row: `selection.test.ts` reads
140
+ // `main.ts` for `case` labels, and a label with no row here falls through to
141
+ // the `profile+target` default.
142
+ dashboard: 'none',
143
+ desktop: 'none',
139
144
  attach: 'profile+target',
140
145
  start: 'profile+target',
141
146
  deploy: 'target',
@@ -267,6 +272,9 @@ const ACCEPTS: Record<string, readonly string[]> = {
267
272
  // it undoes a provider rename this project shipped, and every other finding
268
273
  // there is something only the operator can decide.
269
274
  doctor: ['fix'],
275
+ // A filter, not a second subject: it narrows the answer to one connection so
276
+ // a caller can re-ask about the row it just repaired. Same shape as `attach`.
277
+ auth: ['connection'],
270
278
  relabel: [],
271
279
  'target list': ['urls', 'target'],
272
280
  'target show': ['target'],
@@ -281,7 +289,10 @@ const ACCEPTS: Record<string, readonly string[]> = {
281
289
  'mcp add': ['name', 'scope', 'token-env', 'dry-run', 'force', 'no-skill'],
282
290
  'mcp skill': ['print', 'force'],
283
291
  'mcp list': ['name', 'scope'],
284
- dashboard: ['print'],
292
+ // `--yes` because it installs the app when nothing answers the scheme, and
293
+ // that is the one prompt in this CLI that puts an application on the machine.
294
+ dashboard: ['print', 'yes'],
295
+ desktop: ['print', 'yes'],
285
296
  skill: ['print', 'force'],
286
297
  deploy: ['dry-run', 'iam', 'access', 'service-account', 'tag', 'yes', 'non-interactive'],
287
298
  'secrets push': ['from', 'to', 'overwrite', 'dry-run'],
package/src/cli/usage.ts CHANGED
@@ -37,7 +37,9 @@ ${style.bold('Everyday')}
37
37
  from the credential store, or say what is missing
38
38
  ${PROGRAM} start [--only] reconcile and serve every profile on one endpoint
39
39
  ${PROGRAM} outputs [--show] [--json] the endpoint an agent needs
40
- ${PROGRAM} dashboard [--print] open the local endpoint's page in a browser
40
+ ${PROGRAM} desktop [--print] [--yes] open the Lanes app on its Lanes Link page,
41
+ installing it first if it is not there
42
+ ${PROGRAM} dashboard the older spelling of the line above
41
43
  ${PROGRAM} mcp add [claude|codex] register this endpoint, and install the agent skill
42
44
  ${PROGRAM} mcp add --no-skill register only, leaving the agent's own files alone
43
45
  ${PROGRAM} mcp list where it is registered, and whether the skill is current
@@ -129,6 +131,8 @@ ${style.bold('Inspection')}
129
131
  ${PROGRAM} doctor [--json] credentials resolve, stores reachable
130
132
  ${PROGRAM} doctor --fix apply a repair it can make itself, such as
131
133
  a provider this project renamed under you
134
+ ${PROGRAM} auth [--json] whether each connection can still sign in
135
+ ${PROGRAM} auth --connection <key> just this one
132
136
  ${PROGRAM} tools [--json] what the endpoint advertises to a client
133
137
  ${PROGRAM} plan what reconcile would change
134
138
  ${PROGRAM} audit tail [--limit N] [--denied-only] [--format md]
@@ -19,6 +19,7 @@
19
19
 
20
20
  export { credentialResolver, type ResolvedCredential } from './resolve.ts';
21
21
  export { requestAuthorizer } from './authorize.ts';
22
+ export { ReauthRequired, statusMeansGrantIsDead } from './reauth.ts';
22
23
  export { basicCredential } from './basic/index.ts';
23
24
  export { bearerToken, bearerTokenAsStored } from './token.ts';
24
25
  export {
@@ -1,5 +1,6 @@
1
1
  import type { SecretStore } from '#secrets';
2
2
  import type { ProviderManifest } from '#connectivity';
3
+ import { ReauthRequired } from '../reauth.ts';
3
4
 
4
5
  /**
5
6
  * The SDK's `OAuthClientProvider`, backed by our `SecretStore`.
@@ -69,6 +70,20 @@ export class CredentialOAuthProvider {
69
70
  return `${this.#options.manifest.id}/${this.#options.connectionId}`;
70
71
  }
71
72
 
73
+ /**
74
+ * Which connection this provider speaks for, as `provider.id`.
75
+ *
76
+ * Public because a refusal has to name it: `refresh.ts` builds the same key
77
+ * for a `ReauthRequired`, and a caller holding several connections needs to
78
+ * know which one to send someone to rather than parsing it back out of a
79
+ * sentence. Note the separator differs from `#tokensRef` on purpose — that
80
+ * one is a credential ref (`gmail/main`), this one is the addressing form
81
+ * (`gmail.main`).
82
+ */
83
+ get connectionId(): string {
84
+ return this.#options.connectionId;
85
+ }
86
+
72
87
  // --- OAuthClientProvider ----------------------------------------------
73
88
 
74
89
  get redirectUrl(): string | undefined {
@@ -131,7 +146,8 @@ export class CredentialOAuthProvider {
131
146
 
132
147
  async redirectToAuthorization(authorizationUrl: URL): Promise<void> {
133
148
  if (!this.#options.openBrowser) {
134
- throw new Error(
149
+ throw new ReauthRequired(
150
+ `${this.#options.manifest.id}.${this.#options.connectionId}`,
135
151
  `Connection ${this.#options.manifest.id}.${this.#options.connectionId} needs re-authorisation, ` +
136
152
  `which requires a browser. Connect ${this.#options.manifest.id}.${this.#options.connectionId} again for this profile and target.`,
137
153
  );