@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.
- package/.gcloudignore +50 -0
- package/README.md +5 -0
- package/package.json +4 -3
- package/src/auth/oidc.ts +65 -12
- package/src/cli/brand.ts +16 -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/deployments/gcp/Dockerfile +27 -2
- package/src/deployments/gcp/bucket.ts +82 -9
- package/src/deployments/gcp/driver.ts +43 -5
- package/src/deployments/gcp/lifecycle.json +12 -0
- package/src/deployments/gcp/survey.ts +12 -1
- package/src/policy/limits.ts +76 -3
- package/src/profile/index.ts +1 -0
- package/src/profile/legacy.ts +8 -3
- package/src/profile/schema.ts +65 -0
- package/src/server/cors.ts +3 -3
- package/src/server/edge.ts +188 -1
- package/src/server/endpoint.ts +0 -17
- package/src/server/harness.ts +10 -7
- package/src/server/index.ts +62 -90
- package/src/server/mcp/index.ts +0 -1
- package/src/server/mcp/visibility.ts +0 -33
- package/src/server/oauth.ts +21 -2
- 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
|
@@ -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.
|
|
@@ -1,11 +1,12 @@
|
|
|
1
|
-
import {
|
|
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 {
|
|
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
|
-
/**
|
|
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
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
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
|
-
|
|
122
|
-
|
|
123
|
-
|
|
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: '
|
|
133
|
-
key,
|
|
143
|
+
kind: 'needs_reauth',
|
|
144
|
+
key: result.key,
|
|
134
145
|
message:
|
|
135
|
-
`${key}
|
|
136
|
-
|
|
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
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
: ''
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
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
|
-
*
|
|
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
|
-
*
|
|
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 {
|
|
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
|
-
|
|
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
|
|
293
|
-
//
|
|
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
|
-
|
|
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
|
package/src/cli/selection.ts
CHANGED
|
@@ -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
|
|
137
|
-
//
|
|
138
|
-
|
|
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
|
-
|
|
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}
|
|
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
|
|
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
|
);
|