@lanes-sh/link 0.2.2 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +21 -8
- package/instructions/skills/lanes-link/SKILL.md +42 -14
- package/package.json +1 -1
- package/src/cli/argv.ts +50 -0
- package/src/cli/brand.ts +178 -0
- package/src/cli/callback-page.ts +108 -128
- package/src/cli/commands/connect/accounts.ts +5 -0
- package/src/cli/commands/connect/assertion.ts +187 -0
- package/src/cli/commands/connect/authorise.ts +61 -17
- package/src/cli/commands/connect/client.ts +37 -9
- package/src/cli/commands/connect/discover.ts +94 -0
- package/src/cli/commands/connect/family.ts +72 -0
- package/src/cli/commands/connect/index.ts +113 -115
- package/src/cli/commands/connect/method.ts +237 -0
- package/src/cli/commands/connect/outcome.ts +42 -1
- package/src/cli/commands/connect/pasted-token.ts +66 -0
- package/src/cli/commands/connect/requirements.ts +60 -8
- package/src/cli/commands/connect/setup.ts +16 -5
- package/src/cli/commands/connect/target-note.ts +34 -0
- package/src/cli/commands/identity.ts +258 -0
- package/src/cli/commands/knowledge/index.ts +390 -0
- package/src/cli/commands/knowledge/migrate.ts +180 -0
- package/src/cli/commands/knowledge/setup.ts +144 -0
- package/src/cli/commands/knowledge.ts +10 -0
- package/src/cli/commands/mcp/harnesses.ts +16 -2
- package/src/cli/commands/mcp/register.ts +9 -1
- package/src/cli/commands/mcp/stdio.ts +21 -0
- package/src/cli/commands/operate/dashboard.ts +107 -0
- package/src/cli/commands/operate/findings.ts +151 -0
- package/src/cli/commands/operate/inspect.ts +56 -158
- package/src/cli/commands/operate/outputs.ts +38 -11
- package/src/cli/commands/operate/serve.ts +3 -0
- package/src/cli/commands/operate/token.ts +1 -1
- package/src/cli/commands/operate.ts +2 -0
- package/src/cli/commands/profile/declare.ts +154 -0
- package/src/cli/commands/profile/removal.ts +17 -0
- package/src/cli/commands/profile.ts +83 -35
- package/src/cli/commands/setup.ts +22 -6
- package/src/cli/commands/target.ts +65 -83
- package/src/cli/config-edit.ts +48 -144
- package/src/cli/config-repair.ts +186 -0
- package/src/cli/dashboard-page.ts +284 -0
- package/src/cli/dashboard-shell.ts +125 -0
- package/src/cli/identity.ts +12 -1
- package/src/cli/main.ts +68 -4
- package/src/cli/oauth-callback.ts +187 -0
- package/src/cli/oauth-exchange.ts +57 -15
- package/src/cli/oauth.ts +67 -177
- package/src/cli/output.ts +21 -5
- package/src/cli/provider-marks.ts +45 -0
- package/src/cli/runtime/open.ts +67 -49
- package/src/cli/runtime/registry.ts +60 -2
- package/src/cli/runtime/select.ts +26 -13
- package/src/cli/runtime/vault.ts +61 -0
- package/src/cli/runtime.ts +2 -1
- package/src/cli/selection.ts +357 -0
- package/src/cli/usage.ts +32 -9
- package/src/connectivity/auth/README.md +7 -1
- package/src/connectivity/auth/basic/index.ts +1 -1
- package/src/connectivity/auth/index.ts +14 -0
- package/src/connectivity/auth/oauth-authcode/broker.ts +26 -0
- package/src/connectivity/auth/oauth-authcode/index.ts +16 -2
- package/src/connectivity/auth/oauth-authcode/provider.ts +1 -1
- package/src/connectivity/auth/oauth-authcode/refresh.ts +3 -3
- package/src/connectivity/auth/oauth-jwt/README.md +33 -0
- package/src/connectivity/auth/oauth-jwt/index.ts +237 -0
- package/src/connectivity/auth/oauth-jwt/key.ts +148 -0
- package/src/connectivity/auth/resolve.ts +1 -1
- package/src/connectivity/auth/token.ts +11 -0
- package/src/connectivity/index.ts +2 -0
- package/src/connectivity/manifest/auth.ts +99 -2
- package/src/connectivity/manifest/identity.ts +12 -0
- package/src/connectivity/manifest/index.ts +3 -1
- package/src/connectivity/manifest/provider.ts +37 -8
- package/src/connectivity/manifest/requirements.ts +109 -6
- package/src/deployments/adapters/filesystem.ts +10 -1
- package/src/deployments/adapters/github-api.ts +106 -0
- package/src/deployments/adapters/github-commit.ts +103 -0
- package/src/deployments/adapters/github-repo.ts +356 -0
- package/src/deployments/adapters/github-testing.ts +258 -0
- package/src/deployments/adapters/github.ts +125 -0
- package/src/deployments/deploy.ts +48 -19
- package/src/deployments/driver.ts +8 -1
- package/src/deployments/gcp/driver.ts +3 -1
- package/src/deployments/knowledge.ts +119 -0
- package/src/deployments/prepare.ts +2 -2
- package/src/deployments/servable.ts +81 -0
- package/src/deployments/target.ts +3 -2
- package/src/deployments/upload.ts +2 -1
- package/src/dispatch/dispatch.ts +1 -1
- package/src/profile/identity.ts +60 -0
- package/src/profile/index.ts +15 -5
- package/src/profile/knowledge.ts +124 -0
- package/src/profile/load.ts +17 -5
- package/src/profile/primitives.ts +24 -1
- package/src/profile/schema.ts +46 -3
- package/src/profile/targets.ts +74 -114
- package/src/profile/workspace.ts +71 -84
- package/src/providers/google/calendar/index.ts +2 -0
- package/src/providers/google/contacts/index.ts +2 -0
- package/src/providers/google/docs/index.ts +2 -0
- package/src/providers/google/drive/index.ts +2 -0
- package/src/providers/google/gmail/index.ts +2 -0
- package/src/providers/google/gmail-imap/index.ts +125 -0
- package/src/providers/google/index.ts +2 -1
- package/src/providers/google/shared/oauth.ts +18 -6
- package/src/providers/google/shared/service-account.ts +110 -0
- package/src/providers/google/shared/setup.ts +5 -2
- package/src/providers/google/sheets/index.ts +2 -0
- package/src/providers/google/tasks/index.ts +2 -0
- package/src/providers/identity/provider.ts +166 -0
- package/src/providers/index.ts +3 -0
- package/src/providers/owner.ts +10 -2
- package/src/providers/scopes.ts +2 -0
- package/src/providers/setup/plan.ts +31 -9
- package/src/providers/setup/provider.ts +23 -0
- package/src/providers/slack/index.ts +81 -33
- package/src/providers/slack/oauth.ts +103 -0
- package/src/providers/slack/scopes.ts +37 -0
- package/src/server/container.ts +18 -1
- package/src/server/cors.ts +252 -0
- package/src/server/dashboard.ts +208 -0
- package/src/server/endpoint.ts +45 -1
- package/src/server/generations.ts +11 -2
- package/src/server/harness.ts +7 -0
- package/src/server/index.ts +45 -6
- package/src/server/mcp/index.ts +1 -0
- package/src/server/mcp/instructions.ts +28 -1
- package/src/server/mcp/visibility.ts +33 -0
- package/src/stores/blobs/route.ts +123 -0
|
@@ -0,0 +1,357 @@
|
|
|
1
|
+
import {
|
|
2
|
+
ConfigError,
|
|
3
|
+
listProfiles,
|
|
4
|
+
loadProfileConfig,
|
|
5
|
+
noProfileNamed,
|
|
6
|
+
noTargetNamed,
|
|
7
|
+
resolveWorkspaceRoot,
|
|
8
|
+
} from '#profile';
|
|
9
|
+
import type { Flags } from './argv.ts';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Which commands must name a profile and a target, and which flags each accepts.
|
|
13
|
+
*
|
|
14
|
+
* Two rules, in one file because they fail for the same reason and the fix for
|
|
15
|
+
* one makes the other legible.
|
|
16
|
+
*
|
|
17
|
+
* **A flag that is silently ignored is the defect.** `lanes link profile add
|
|
18
|
+
* work --target cloud` printed `ok` and dropped the flag: `main.ts` built a
|
|
19
|
+
* literal for that command and never spread the global flags into it. Nothing
|
|
20
|
+
* refused, because nothing had a list of what the command accepts. That is what
|
|
21
|
+
* `assertKnownFlags` is — and it matters more than the requirement, because
|
|
22
|
+
* required flags make a typo *worse* on their own. `--porfile work` used to
|
|
23
|
+
* fall through to a workspace default and mostly work; with a requirement and
|
|
24
|
+
* no allowlist it produces "--profile is required", naming a flag the operator
|
|
25
|
+
* believes they just passed.
|
|
26
|
+
*
|
|
27
|
+
* **A selection is named or the command does not run** (ADR-037). The table
|
|
28
|
+
* below is the whole rule, and `selection.test.ts` reads `main.ts` to check that
|
|
29
|
+
* every dispatched command appears in it — so a new command cannot quietly
|
|
30
|
+
* default to requiring nothing.
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
/** What a command must be told before it can act. */
|
|
34
|
+
export type Requires = 'none' | 'profile' | 'profile+target';
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* The rule, per command path.
|
|
38
|
+
*
|
|
39
|
+
* `--profile` for anything that reads or writes a profile's config or stores.
|
|
40
|
+
* `--target` for anything that opens a target's adapters or acts against a
|
|
41
|
+
* target's endpoint. A command that names a target positionally or through
|
|
42
|
+
* `--from`/`--to` supplies it that way and is not asked twice.
|
|
43
|
+
*
|
|
44
|
+
* Three entries are worth defending, because uniformity would be wrong:
|
|
45
|
+
*
|
|
46
|
+
* `check`, `config show` and `policy list` take no `--target`. All three are
|
|
47
|
+
* target-independent — a YAML file, the whole of it, and a policy block that is
|
|
48
|
+
* declared once and applies everywhere. Demanding a target would be the
|
|
49
|
+
* ceremony that teaches people to type `--target local` without reading it,
|
|
50
|
+
* which is how a required flag stops being a guard.
|
|
51
|
+
*
|
|
52
|
+
* `target list` takes no required `--target` either, and that is not an
|
|
53
|
+
* oversight: it is the command you run to find out what to pass. Requiring the
|
|
54
|
+
* answer as input is circular, and it has to keep working in the state every
|
|
55
|
+
* other command fails in.
|
|
56
|
+
*
|
|
57
|
+
* `profile add` **rejects** both. The name is positional, and there is no
|
|
58
|
+
* profile to select before it exists.
|
|
59
|
+
*/
|
|
60
|
+
export const SELECTION: Record<string, Requires> = {
|
|
61
|
+
help: 'none',
|
|
62
|
+
version: 'none',
|
|
63
|
+
update: 'none',
|
|
64
|
+
skill: 'none',
|
|
65
|
+
'mcp skill': 'none',
|
|
66
|
+
'mcp list': 'none',
|
|
67
|
+
// The bare forms, which each dispatch to a `case undefined` in `main.ts`.
|
|
68
|
+
// `lanes link profile` is `profile list`, and needs the same as it.
|
|
69
|
+
profile: 'none',
|
|
70
|
+
mcp: 'none',
|
|
71
|
+
'profile list': 'none',
|
|
72
|
+
'profile add': 'none',
|
|
73
|
+
'profile default': 'none',
|
|
74
|
+
'target use': 'none',
|
|
75
|
+
'vault key': 'none',
|
|
76
|
+
|
|
77
|
+
check: 'profile',
|
|
78
|
+
config: 'profile',
|
|
79
|
+
policy: 'profile',
|
|
80
|
+
target: 'profile',
|
|
81
|
+
'config show': 'profile',
|
|
82
|
+
'policy list': 'profile',
|
|
83
|
+
'target list': 'profile',
|
|
84
|
+
'target show': 'profile',
|
|
85
|
+
'secrets push': 'profile',
|
|
86
|
+
'profile remove': 'profile',
|
|
87
|
+
// Target-independent for the same reason `policy list` is: the block is
|
|
88
|
+
// declared once in the YAML and applies to every target the profile has.
|
|
89
|
+
identity: 'profile',
|
|
90
|
+
'identity list': 'profile',
|
|
91
|
+
|
|
92
|
+
connect: 'profile+target',
|
|
93
|
+
setup: 'profile+target',
|
|
94
|
+
token: 'profile+target',
|
|
95
|
+
audit: 'profile+target',
|
|
96
|
+
secrets: 'profile+target',
|
|
97
|
+
plan: 'profile+target',
|
|
98
|
+
doctor: 'profile+target',
|
|
99
|
+
status: 'profile+target',
|
|
100
|
+
outputs: 'profile+target',
|
|
101
|
+
tools: 'profile+target',
|
|
102
|
+
// It reads which target it is rendering for before it decides anything: a
|
|
103
|
+
// deployed one has no page to open, and the refusal has to name it.
|
|
104
|
+
dashboard: 'profile+target',
|
|
105
|
+
attach: 'profile+target',
|
|
106
|
+
start: 'profile+target',
|
|
107
|
+
deploy: 'profile+target',
|
|
108
|
+
'policy allow': 'profile+target',
|
|
109
|
+
'policy deny': 'profile+target',
|
|
110
|
+
// Both, unlike `identity list`, and for the same reason the policy edits are:
|
|
111
|
+
// each publishes the edit, which opens the target's credential store and
|
|
112
|
+
// reaches that target's endpoint.
|
|
113
|
+
'identity add': 'profile+target',
|
|
114
|
+
'identity remove': 'profile+target',
|
|
115
|
+
'token show': 'profile+target',
|
|
116
|
+
'token rotate': 'profile+target',
|
|
117
|
+
'audit tail': 'profile+target',
|
|
118
|
+
'audit verify': 'profile+target',
|
|
119
|
+
'secrets set': 'profile+target',
|
|
120
|
+
'secrets list': 'profile+target',
|
|
121
|
+
'mcp add': 'profile+target',
|
|
122
|
+
'mcp stdio': 'profile+target',
|
|
123
|
+
memory: 'profile+target',
|
|
124
|
+
skills: 'profile+target',
|
|
125
|
+
vault: 'profile+target',
|
|
126
|
+
// Both halves open the target's adapters — `show` counts what is in the
|
|
127
|
+
// stores, and `use` migrates between them — and both edit the profile's
|
|
128
|
+
// config. Neither can be answered without being told which.
|
|
129
|
+
knowledge: 'profile+target',
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* The second words each command accepts.
|
|
134
|
+
*
|
|
135
|
+
* Only the commands that have subcommands appear. This exists for one reason:
|
|
136
|
+
* the checks below run before the switch, so without it `lanes link vault bogus`
|
|
137
|
+
* is refused for a missing `--profile` rather than for the subcommand that does
|
|
138
|
+
* not exist — a usage error reported as the wrong usage error, which is its own
|
|
139
|
+
* small version of the bug being fixed. `selection.test.ts` reads `main.ts` and
|
|
140
|
+
* asserts this stays true.
|
|
141
|
+
*/
|
|
142
|
+
const SUBCOMMANDS: Record<string, readonly string[]> = {
|
|
143
|
+
profile: ['add', 'list', 'default', 'remove'],
|
|
144
|
+
target: ['list', 'use', 'show'],
|
|
145
|
+
policy: ['list', 'allow', 'deny'],
|
|
146
|
+
identity: ['add', 'list', 'remove'],
|
|
147
|
+
token: ['show', 'rotate'],
|
|
148
|
+
audit: ['tail', 'verify'],
|
|
149
|
+
config: ['show'],
|
|
150
|
+
setup: ['plan'],
|
|
151
|
+
memory: ['list', 'get', 'write', 'forget'],
|
|
152
|
+
skills: ['list', 'show', 'add', 'remove'],
|
|
153
|
+
vault: ['list', 'get', 'set', 'remove', 'key'],
|
|
154
|
+
mcp: ['skill', 'add', 'stdio', 'list'],
|
|
155
|
+
secrets: ['push', 'set', 'list'],
|
|
156
|
+
knowledge: ['show', 'use'],
|
|
157
|
+
};
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Whether the switch is going to refuse this command path anyway.
|
|
161
|
+
*
|
|
162
|
+
* When it is, these checks stay quiet and let it: "Unknown: lanes link vault
|
|
163
|
+
* bogus" is the useful sentence, and a complaint about `--profile` on a command
|
|
164
|
+
* that does not exist sends someone off to fix the wrong thing.
|
|
165
|
+
*/
|
|
166
|
+
function dispatchWillRefuse(first: string, second: string | undefined): boolean {
|
|
167
|
+
const known = SUBCOMMANDS[first];
|
|
168
|
+
if (!known || second === undefined) return false;
|
|
169
|
+
return !known.includes(second);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* The key for a command, longest match first.
|
|
174
|
+
*
|
|
175
|
+
* `token show` before `token`, so a two-word command can differ from its
|
|
176
|
+
* siblings without every sibling having to be listed.
|
|
177
|
+
*/
|
|
178
|
+
export function selectionKey(first: string, second: string | undefined): string {
|
|
179
|
+
const pair = second ? `${first} ${second}` : first;
|
|
180
|
+
if (pair in SELECTION) return pair;
|
|
181
|
+
return first;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/** Whether this command needs a profile, a target, both, or neither. */
|
|
185
|
+
export function requirementFor(first: string, second: string | undefined): Requires {
|
|
186
|
+
return SELECTION[selectionKey(first, second)] ?? 'profile+target';
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* Refuse before the command runs, naming what it wants and what there is.
|
|
191
|
+
*
|
|
192
|
+
* Async, and it reads the workspace — but only on the way to throwing. The
|
|
193
|
+
* useful half of "which profile did you mean" is the list of them, and the same
|
|
194
|
+
* for targets; a refusal that only restates the flag name leaves someone to go
|
|
195
|
+
* and look it up. Both messages come from `#profile` so this file and the
|
|
196
|
+
* resolver cannot describe the same refusal differently, and both name an
|
|
197
|
+
* exported variable that no longer counts — the shell still configured for the
|
|
198
|
+
* old world is the state hardest to diagnose from the inside.
|
|
199
|
+
*/
|
|
200
|
+
export async function requireSelection(
|
|
201
|
+
first: string,
|
|
202
|
+
second: string | undefined,
|
|
203
|
+
flags: Flags,
|
|
204
|
+
env?: Record<string, string | undefined>,
|
|
205
|
+
): Promise<void> {
|
|
206
|
+
if (dispatchWillRefuse(first, second)) return;
|
|
207
|
+
|
|
208
|
+
const needs = requirementFor(first, second);
|
|
209
|
+
if (needs === 'none') return;
|
|
210
|
+
|
|
211
|
+
const profile = flags['profile'];
|
|
212
|
+
if (typeof profile !== 'string') {
|
|
213
|
+
const root = resolveWorkspaceRoot(env ? { env } : {});
|
|
214
|
+
throw noProfileNamed(root, await listProfiles(root), env);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
if (needs !== 'profile+target' || typeof flags['target'] === 'string') return;
|
|
218
|
+
|
|
219
|
+
// The profile is known by here, so the target list is the one belonging to it
|
|
220
|
+
// rather than a guess. A profile that does not exist is a different refusal,
|
|
221
|
+
// and `resolveSelection` gives it a better one a moment later.
|
|
222
|
+
const root = resolveWorkspaceRoot(env ? { env } : {});
|
|
223
|
+
try {
|
|
224
|
+
const { config } = await loadProfileConfig(root, profile);
|
|
225
|
+
throw noTargetNamed(config, profile, env);
|
|
226
|
+
} catch (error) {
|
|
227
|
+
if (error instanceof ConfigError) throw error;
|
|
228
|
+
throw new ConfigError(`--target is required for "${[first, second].filter(Boolean).join(' ')}".`);
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* Flags every command accepts, whatever it does.
|
|
234
|
+
*
|
|
235
|
+
* `--help` short-circuits before dispatch, and `--json` is offered widely enough
|
|
236
|
+
* that listing it per command would be noise. `--quiet` is read by `announce`
|
|
237
|
+
* rather than by any one command.
|
|
238
|
+
*/
|
|
239
|
+
const UNIVERSAL = ['help', 'json', 'quiet'];
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* What each command accepts beyond the universal set and its own selection.
|
|
243
|
+
*
|
|
244
|
+
* Only commands with flags of their own appear. Anything absent accepts the
|
|
245
|
+
* universal set plus whatever `SELECTION` says it must be told.
|
|
246
|
+
*/
|
|
247
|
+
const ACCEPTS: Record<string, readonly string[]> = {
|
|
248
|
+
// `own-client` is the older spelling of one of the routes `auth` names, kept
|
|
249
|
+
// because it is in scripts and a year of documentation (ADR-038).
|
|
250
|
+
connect: [
|
|
251
|
+
'id',
|
|
252
|
+
'display-name',
|
|
253
|
+
'replace',
|
|
254
|
+
'non-interactive',
|
|
255
|
+
'accept-broad-scopes',
|
|
256
|
+
'own-client',
|
|
257
|
+
'auth',
|
|
258
|
+
],
|
|
259
|
+
setup: ['id'],
|
|
260
|
+
'profile add': ['target', 'non-interactive'],
|
|
261
|
+
'profile remove': ['dry-run', 'yes'],
|
|
262
|
+
'target list': ['urls', 'target'],
|
|
263
|
+
'target show': ['target'],
|
|
264
|
+
'token show': ['show', 'raw'],
|
|
265
|
+
'token rotate': ['show', 'raw', 'yes'],
|
|
266
|
+
'audit tail': ['limit', 'denied-only', 'format'],
|
|
267
|
+
'audit verify': ['limit', 'format'],
|
|
268
|
+
attach: ['connection'],
|
|
269
|
+
outputs: ['show'],
|
|
270
|
+
start: ['port', 'only'],
|
|
271
|
+
'mcp stdio': ['only'],
|
|
272
|
+
'mcp add': ['name', 'scope', 'token-env', 'dry-run', 'force', 'no-skill'],
|
|
273
|
+
'mcp skill': ['print', 'force'],
|
|
274
|
+
dashboard: ['print'],
|
|
275
|
+
skill: ['print', 'force'],
|
|
276
|
+
deploy: ['dry-run', 'iam', 'access', 'service-account', 'tag', 'yes', 'non-interactive'],
|
|
277
|
+
'secrets push': ['from', 'to', 'overwrite', 'dry-run'],
|
|
278
|
+
update: ['check'],
|
|
279
|
+
'identity add': ['note'],
|
|
280
|
+
memory: ['connection', 'title', 'description', 'file'],
|
|
281
|
+
skills: ['connection', 'title', 'description', 'file'],
|
|
282
|
+
vault: ['connection'],
|
|
283
|
+
// `no-migrate` is listed beside `migrate` because they are three states
|
|
284
|
+
// rather than two: neither one asks, and a run with no terminal has to be
|
|
285
|
+
// able to say which it meant (ADR-041).
|
|
286
|
+
knowledge: ['repo', 'branch', 'path', 'migrate', 'no-migrate', 'keep', 'allow-public', 'replace', 'yes'],
|
|
287
|
+
};
|
|
288
|
+
|
|
289
|
+
/**
|
|
290
|
+
* Refuse a flag this command does not read, and guess what was meant.
|
|
291
|
+
*
|
|
292
|
+
* This is the fix for the reported bug rather than a nicety. `profile add
|
|
293
|
+
* --target cloud` was accepted and dropped, and nothing could refuse it because
|
|
294
|
+
* `parseArgv` returns every `--anything` it sees and no command ever inspected
|
|
295
|
+
* the leftovers. A typo was swallowed the same way on every command in the CLI.
|
|
296
|
+
*/
|
|
297
|
+
export function assertKnownFlags(first: string, second: string | undefined, flags: Flags): void {
|
|
298
|
+
if (dispatchWillRefuse(first, second)) return;
|
|
299
|
+
|
|
300
|
+
const key = selectionKey(first, second);
|
|
301
|
+
const needs = SELECTION[key] ?? 'profile+target';
|
|
302
|
+
|
|
303
|
+
const allowed = new Set<string>([
|
|
304
|
+
...UNIVERSAL,
|
|
305
|
+
...(ACCEPTS[key] ?? []),
|
|
306
|
+
...(needs === 'profile' || needs === 'profile+target' ? ['profile'] : []),
|
|
307
|
+
...(needs === 'profile+target' ? ['target'] : []),
|
|
308
|
+
]);
|
|
309
|
+
|
|
310
|
+
const named = [first, second].filter(Boolean).join(' ');
|
|
311
|
+
|
|
312
|
+
for (const given of Object.keys(flags)) {
|
|
313
|
+
if (allowed.has(given)) continue;
|
|
314
|
+
|
|
315
|
+
throw new ConfigError(
|
|
316
|
+
`Unknown flag "--${given}" for "lanes link ${named}".` +
|
|
317
|
+
(nearest(given, allowed) ? `\n Did you mean --${nearest(given, allowed)}?` : '') +
|
|
318
|
+
`\n Accepts: ${[...allowed].sort().map((name) => `--${name}`).join(' ')}`,
|
|
319
|
+
);
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
/**
|
|
324
|
+
* The closest accepted flag, when there is an obviously close one.
|
|
325
|
+
*
|
|
326
|
+
* One edit away, or one transposition — enough for `--porfile` and `--taget`,
|
|
327
|
+
* and short of guessing at something the operator did not mean. A wrong guess
|
|
328
|
+
* here costs more than no guess: it sends them to a flag that is not the answer.
|
|
329
|
+
*/
|
|
330
|
+
function nearest(given: string, allowed: ReadonlySet<string>): string | undefined {
|
|
331
|
+
for (const candidate of allowed) {
|
|
332
|
+
if (Math.abs(candidate.length - given.length) > 1) continue;
|
|
333
|
+
if (distance(given, candidate) <= 1) return candidate;
|
|
334
|
+
if (sorted(given) === sorted(candidate)) return candidate;
|
|
335
|
+
}
|
|
336
|
+
return undefined;
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
const sorted = (text: string): string => [...text].sort().join('');
|
|
340
|
+
|
|
341
|
+
function distance(a: string, b: string): number {
|
|
342
|
+
let row = Array.from({ length: b.length + 1 }, (_, index) => index);
|
|
343
|
+
|
|
344
|
+
for (let i = 1; i <= a.length; i++) {
|
|
345
|
+
const next = [i];
|
|
346
|
+
for (let j = 1; j <= b.length; j++) {
|
|
347
|
+
next[j] = Math.min(
|
|
348
|
+
row[j]! + 1,
|
|
349
|
+
next[j - 1]! + 1,
|
|
350
|
+
row[j - 1]! + (a[i - 1] === b[j - 1] ? 0 : 1),
|
|
351
|
+
);
|
|
352
|
+
}
|
|
353
|
+
row = next;
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
return row[b.length]!;
|
|
357
|
+
}
|
package/src/cli/usage.ts
CHANGED
|
@@ -24,11 +24,13 @@ ${style.bold('Everyday')}
|
|
|
24
24
|
${PROGRAM} connect <provider> add an account (run once per account)
|
|
25
25
|
${PROGRAM} connect <provider>.<id> re-authorise one existing account
|
|
26
26
|
${PROGRAM} connect <...> --replace ask for the stored password or key again
|
|
27
|
+
${PROGRAM} connect <...> --auth <method> pick how, where there is a choice
|
|
27
28
|
${PROGRAM} connect <...> --non-interactive [--json]
|
|
28
29
|
answer nothing from a terminal: take every value
|
|
29
30
|
from the credential store, or say what is missing
|
|
30
31
|
${PROGRAM} start [--only] reconcile and serve every profile on one endpoint
|
|
31
32
|
${PROGRAM} outputs [--show] [--json] the endpoint an agent needs
|
|
33
|
+
${PROGRAM} dashboard [--print] open the local endpoint's page in a browser
|
|
32
34
|
${PROGRAM} mcp add [claude|codex] register this endpoint, and install the agent skill
|
|
33
35
|
${PROGRAM} mcp add --no-skill register only, leaving the agent's own files alone
|
|
34
36
|
${PROGRAM} mcp list where it is registered, and whether the skill is current
|
|
@@ -37,16 +39,22 @@ ${style.bold('Everyday')}
|
|
|
37
39
|
${PROGRAM} status [--json] connections, reachable capabilities, endpoint
|
|
38
40
|
|
|
39
41
|
${style.bold('Profiles')}
|
|
40
|
-
${PROGRAM} profile add <name> [--
|
|
42
|
+
${PROGRAM} profile add <name> --target <name> [--target <name>] [--json]
|
|
43
|
+
a target per place it runs; local is derived, the
|
|
44
|
+
rest are copied from a sibling profile
|
|
41
45
|
${PROGRAM} profile list [--json]
|
|
42
|
-
${PROGRAM} profile default <name>
|
|
43
46
|
${PROGRAM} profile remove <name> [--target t] [--dry-run] [--yes] [--json]
|
|
44
47
|
the profile, its credentials, and its data
|
|
45
48
|
|
|
46
49
|
${style.bold('Targets')}
|
|
47
|
-
${PROGRAM} target list [--urls] where this profile can run
|
|
48
|
-
${PROGRAM} target show
|
|
49
|
-
|
|
50
|
+
${PROGRAM} target list [--urls] where this profile can run
|
|
51
|
+
${PROGRAM} target show <name> one target's adapters, and the address it answers on
|
|
52
|
+
|
|
53
|
+
${style.bold('Who you are')}
|
|
54
|
+
${PROGRAM} identity add <kind> <value> [--note text] [--json]
|
|
55
|
+
e.g. name, email, github — any kind you like
|
|
56
|
+
${PROGRAM} identity list [--json]
|
|
57
|
+
${PROGRAM} identity remove <kind> <value> [--json]
|
|
50
58
|
|
|
51
59
|
${style.bold('Permissions')}
|
|
52
60
|
${PROGRAM} policy list
|
|
@@ -66,6 +74,14 @@ ${style.bold('Your own context')}
|
|
|
66
74
|
${PROGRAM} skills add <name> [--file f] document on stdin
|
|
67
75
|
${PROGRAM} skills remove <name>
|
|
68
76
|
|
|
77
|
+
${PROGRAM} knowledge show where memory and skills are kept, and how many
|
|
78
|
+
${PROGRAM} knowledge use github --repo <owner/name> [--branch b] [--path p]
|
|
79
|
+
keep both in a private repository, over the GitHub API
|
|
80
|
+
[--migrate] moves what is already stored, in one commit
|
|
81
|
+
[--no-migrate] switches and leaves it where it is
|
|
82
|
+
[--keep] moves it, and leaves the local copies unread
|
|
83
|
+
${PROGRAM} knowledge use local [--migrate] bring them back onto this target
|
|
84
|
+
|
|
69
85
|
${PROGRAM} vault list names only, never values
|
|
70
86
|
${PROGRAM} vault get <id> [--show|--raw]
|
|
71
87
|
${PROGRAM} vault set <id> [--description d] value on stdin
|
|
@@ -96,9 +112,13 @@ ${style.bold('Attachments')}
|
|
|
96
112
|
${PROGRAM} attach <file> --connection <provider>.<account>
|
|
97
113
|
stage a file, print a handle to send it by
|
|
98
114
|
|
|
99
|
-
${style.bold('
|
|
100
|
-
--profile <name>
|
|
101
|
-
--target <name>
|
|
115
|
+
${style.bold('Naming what a command acts on')}
|
|
116
|
+
--profile <name> required by every command that reads or writes a profile
|
|
117
|
+
--target <name> required by every command that opens a target's stores.
|
|
118
|
+
There is no default and no environment variable: a
|
|
119
|
+
command that names neither refuses and lists what exists.
|
|
120
|
+
|
|
121
|
+
${style.bold('Other flags')}
|
|
102
122
|
--connection <id> which memory/skills/vault connection, if a profile has several
|
|
103
123
|
--yes skip the confirmation a destructive command would ask for
|
|
104
124
|
--json machine-readable output, where a command offers it
|
|
@@ -107,7 +127,10 @@ ${style.bold('Global flags')}
|
|
|
107
127
|
--accept-broad-scopes agree in advance to scopes broader than a provider needs
|
|
108
128
|
--own-client register your own OAuth client instead of using the
|
|
109
129
|
one this project operates (connect only)
|
|
130
|
+
--auth <method> which way in, where a provider offers two (connect
|
|
131
|
+
only). "oauth" is the browser; the other is named
|
|
132
|
+
in the choice connect prints
|
|
110
133
|
--port <n> override the configured port (start only)
|
|
111
134
|
|
|
112
|
-
Every command prints the
|
|
135
|
+
Every command prints the profile and target it is acting on, before it acts.
|
|
113
136
|
`;
|
|
@@ -14,6 +14,7 @@ transport asks when it has a token to send and no request to attach it to.
|
|
|
14
14
|
| `api-key/` | `api_key` | a key, in a header or the query string |
|
|
15
15
|
| `basic/` | `basic` | `username:password`, RFC 7617's own encoding |
|
|
16
16
|
| `oauth-authcode/` | `oauth` | a refresh token, exchanged on every use |
|
|
17
|
+
| `oauth-jwt/` | `oauth` + `assertion` | a private key, signed into an assertion per exchange |
|
|
17
18
|
| `strategy/` | `strategy` | the escape hatch — per-vendor code, none registered |
|
|
18
19
|
|
|
19
20
|
## Adding one
|
|
@@ -22,13 +23,18 @@ A folder, a member of `authSchema` in `../manifest/auth.ts`, and a case in
|
|
|
22
23
|
`resolve.ts` (plus `authorize.ts` if it touches the request). Nothing else in
|
|
23
24
|
the codebase learns about it — that is the point of the split.
|
|
24
25
|
|
|
26
|
+
`oauth-jwt/` is the exception that proves the shape rather than breaking it. It
|
|
27
|
+
is not a `kind`, because it is a second way into a provider that already has
|
|
28
|
+
one, so it hangs off the OAuth block as `auth.assertion` and is selected by the
|
|
29
|
+
shape of the stored credential. Everything else about it is an ordinary folder
|
|
30
|
+
here.
|
|
31
|
+
|
|
25
32
|
These are named in the credential-type list this design is measured against and
|
|
26
33
|
are **not built**:
|
|
27
34
|
|
|
28
35
|
- `sigv4/` — AWS SigV4 request signing
|
|
29
36
|
- `gcp-token/` — service account → GCP access token
|
|
30
37
|
- `gcp-iap/` — service account → an IAP-signed JWT
|
|
31
|
-
- `oauth-jwt/` — OAuth 2.0 JWT bearer (RFC 7523)
|
|
32
38
|
- `oauth-client-creds/` — OAuth 2.0 client credentials
|
|
33
39
|
- `body-param/` — the credential as a request body parameter
|
|
34
40
|
|
|
@@ -15,7 +15,7 @@ export function resolveBasic(value: string, ref: string, providerId: string): Re
|
|
|
15
15
|
if (colon === -1) {
|
|
16
16
|
throw new Error(
|
|
17
17
|
`The credential at ${ref} is not a "username:password" pair, which basic auth requires. ` +
|
|
18
|
-
`
|
|
18
|
+
`Connect "${providerId}" again for this profile and target.`,
|
|
19
19
|
);
|
|
20
20
|
}
|
|
21
21
|
|
|
@@ -7,6 +7,10 @@
|
|
|
7
7
|
* know the whole set: `resolve.ts` and `authorize.ts` for anything HTTP-shaped,
|
|
8
8
|
* and `token.ts` for a transport that takes a bare token instead of a request.
|
|
9
9
|
*
|
|
10
|
+
* `oauth-jwt/` is the one folder that is not a `kind`: it is a second way into
|
|
11
|
+
* a provider that already declares `oauth`, selected by the shape of what is
|
|
12
|
+
* stored rather than by the manifest. Its own README says why.
|
|
13
|
+
*
|
|
10
14
|
* This is the axis the manifest's `auth:` block selects, and it is deliberately
|
|
11
15
|
* independent of `../transports/` — which is why iCloud can speak IMAP with a
|
|
12
16
|
* password while Gmail speaks HTTP with OAuth, and neither costs the other any
|
|
@@ -24,9 +28,19 @@ export {
|
|
|
24
28
|
type OAuthProviderOptions,
|
|
25
29
|
} from './oauth-authcode/provider.ts';
|
|
26
30
|
export { resolveUpstreamToken } from './oauth-authcode/index.ts';
|
|
31
|
+
export {
|
|
32
|
+
ASSERTION_GRANT,
|
|
33
|
+
clearMintedTokens,
|
|
34
|
+
isStoredAssertion,
|
|
35
|
+
resolveAssertionToken,
|
|
36
|
+
storedAssertionFor,
|
|
37
|
+
type StoredAssertion,
|
|
38
|
+
} from './oauth-jwt/index.ts';
|
|
39
|
+
export { assertionKeySchema, parseAssertionKey, signAssertion, type AssertionKey } from './oauth-jwt/key.ts';
|
|
27
40
|
export {
|
|
28
41
|
BROKER_ORIGIN_ENV,
|
|
29
42
|
BROKERED,
|
|
43
|
+
PASTED,
|
|
30
44
|
BrokerError,
|
|
31
45
|
brokerConfig,
|
|
32
46
|
brokerExchange,
|
|
@@ -20,6 +20,17 @@
|
|
|
20
20
|
*/
|
|
21
21
|
export const BROKERED = 'broker';
|
|
22
22
|
|
|
23
|
+
/**
|
|
24
|
+
* Stamped on a credential nobody here minted — the operator pasted it.
|
|
25
|
+
*
|
|
26
|
+
* The third answer, and the one that is not a client at all. It matters for the
|
|
27
|
+
* same reason as the other two and one more: a pasted token cannot be
|
|
28
|
+
* refreshed, cannot be attributed to a registration, and cannot be re-obtained
|
|
29
|
+
* by re-running a flow. `doctor` reads it to say so rather than offering a
|
|
30
|
+
* re-authorisation that would not apply.
|
|
31
|
+
*/
|
|
32
|
+
export const PASTED = 'pasted';
|
|
33
|
+
|
|
23
34
|
/** What the broker will authorise, and whether it is currently doing so. */
|
|
24
35
|
export interface BrokerConfig {
|
|
25
36
|
readonly clientId: string;
|
|
@@ -27,6 +38,20 @@ export interface BrokerConfig {
|
|
|
27
38
|
readonly scopesSupported: readonly string[];
|
|
28
39
|
/** Added to every request so the exchange returns an identity assertion. */
|
|
29
40
|
readonly identityScopes: readonly string[];
|
|
41
|
+
/**
|
|
42
|
+
* Where the vendor sends the browser back, when it will not send it here.
|
|
43
|
+
*
|
|
44
|
+
* Absent for a vendor that accepts a loopback redirect, which is all of them
|
|
45
|
+
* but Slack: the listener names itself and the broker is only asked to
|
|
46
|
+
* redeem. Present where the vendor demands HTTPS — then the redirect lands on
|
|
47
|
+
* the broker's own origin and is bounced down to the listener, and this is
|
|
48
|
+
* the URL both legs of the flow have to agree on.
|
|
49
|
+
*
|
|
50
|
+
* Published rather than derived, because which URL is correct depends on
|
|
51
|
+
* which deployment answered `/config` — and a broker running on loopback for
|
|
52
|
+
* a test would otherwise need a flag of its own.
|
|
53
|
+
*/
|
|
54
|
+
readonly redirectUri: string | undefined;
|
|
30
55
|
readonly open: boolean;
|
|
31
56
|
/** Why it is closed, or near capacity. The broker's words, printed verbatim. */
|
|
32
57
|
readonly notice: string | undefined;
|
|
@@ -217,6 +242,7 @@ export async function brokerConfig(
|
|
|
217
242
|
clientId,
|
|
218
243
|
scopesSupported: strings(data['scopes_supported']),
|
|
219
244
|
identityScopes: strings(data['identity_scopes']),
|
|
245
|
+
redirectUri: str(data['redirect_uri']),
|
|
220
246
|
open: data['status'] !== 'closed',
|
|
221
247
|
notice: str(data['notice']),
|
|
222
248
|
docsUrl: str(data['docs_url']),
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { auth } from '@modelcontextprotocol/client';
|
|
2
2
|
import type { ProviderManifest } from '#connectivity';
|
|
3
3
|
import type { SecretStore } from '#secrets';
|
|
4
|
+
import { resolveAssertionToken, storedAssertionFor } from '../oauth-jwt/index.ts';
|
|
4
5
|
import { CredentialOAuthProvider, upstreamAccessToken } from './provider.ts';
|
|
5
6
|
import { refreshDirectly } from './refresh.ts';
|
|
6
7
|
|
|
@@ -12,8 +13,11 @@ import { refreshDirectly } from './refresh.ts';
|
|
|
12
13
|
* trip at connect time, a refresh on every use, and two different ways to run
|
|
13
14
|
* that refresh depending on whether the provider has a metadata document.
|
|
14
15
|
*
|
|
15
|
-
* The other flows the credential-type list names —
|
|
16
|
-
*
|
|
16
|
+
* The other flows the credential-type list names — client credentials, SigV4 —
|
|
17
|
+
* are sibling folders that do not exist yet. See ../README.md. JWT bearer now
|
|
18
|
+
* does exist, in `../oauth-jwt/`, and is reached from here rather than from
|
|
19
|
+
* `resolve.ts`: a provider offering both declares one `auth.kind`, so the fork
|
|
20
|
+
* belongs at the point where the stored credential is first read.
|
|
17
21
|
*/
|
|
18
22
|
|
|
19
23
|
export async function resolveUpstreamToken(
|
|
@@ -23,6 +27,16 @@ export async function resolveUpstreamToken(
|
|
|
23
27
|
): Promise<string | null> {
|
|
24
28
|
if (manifest.auth.kind !== 'oauth') return null;
|
|
25
29
|
|
|
30
|
+
// Before anything is built, because the two arrangements share a ref and only
|
|
31
|
+
// what is stored there tells them apart. A `CredentialOAuthProvider` over an
|
|
32
|
+
// assertion pointer would find no `access_token`, conclude the connection was
|
|
33
|
+
// never authorised, and advise a browser flow the operator deliberately
|
|
34
|
+
// declined.
|
|
35
|
+
const assertion = await storedAssertionFor(manifest, connectionId, credentials);
|
|
36
|
+
if (assertion) {
|
|
37
|
+
return resolveAssertionToken({ manifest, connectionId, stored: assertion, credentials });
|
|
38
|
+
}
|
|
39
|
+
|
|
26
40
|
const provider = new CredentialOAuthProvider({
|
|
27
41
|
manifest,
|
|
28
42
|
connectionId,
|
|
@@ -133,7 +133,7 @@ export class CredentialOAuthProvider {
|
|
|
133
133
|
if (!this.#options.openBrowser) {
|
|
134
134
|
throw new Error(
|
|
135
135
|
`Connection ${this.#options.manifest.id}.${this.#options.connectionId} needs re-authorisation, ` +
|
|
136
|
-
`which requires a browser.
|
|
136
|
+
`which requires a browser. Connect ${this.#options.manifest.id}.${this.#options.connectionId} again for this profile and target.`,
|
|
137
137
|
);
|
|
138
138
|
}
|
|
139
139
|
this.#options.openBrowser(authorizationUrl);
|
|
@@ -24,7 +24,7 @@ export async function refreshDirectly(
|
|
|
24
24
|
|
|
25
25
|
if (!refreshToken) {
|
|
26
26
|
throw new Error(
|
|
27
|
-
`No refresh token stored for ${manifest.id}.
|
|
27
|
+
`No refresh token stored for ${manifest.id}. Connecting it again for this profile and target would store one.`,
|
|
28
28
|
);
|
|
29
29
|
}
|
|
30
30
|
|
|
@@ -72,7 +72,7 @@ async function viaBroker(
|
|
|
72
72
|
const notice = cause instanceof BrokerError && cause.notice ? `\n${cause.notice}` : '';
|
|
73
73
|
throw new Error(
|
|
74
74
|
`The credential for ${manifest.id} could not be refreshed. ` +
|
|
75
|
-
`Re-authorise
|
|
75
|
+
`Re-authorise ${manifest.id} for this profile and target.\n${String(
|
|
76
76
|
cause instanceof Error ? cause.message : cause,
|
|
77
77
|
).slice(0, 200)}${notice}`,
|
|
78
78
|
);
|
|
@@ -110,7 +110,7 @@ async function viaStoredClient(
|
|
|
110
110
|
// is always the same, so say it rather than surfacing the raw grant error.
|
|
111
111
|
throw new Error(
|
|
112
112
|
`The credential for ${manifest.id} could not be refreshed (${response.status}). ` +
|
|
113
|
-
`Re-authorise
|
|
113
|
+
`Re-authorise ${manifest.id} for this profile and target.\n${text.slice(0, 200)}`,
|
|
114
114
|
);
|
|
115
115
|
}
|
|
116
116
|
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# OAuth 2.0 JWT bearer (RFC 7523)
|
|
2
|
+
|
|
3
|
+
A key the operator holds, signed into a short-lived assertion and exchanged for
|
|
4
|
+
an access token. No browser, and **no refresh token** — there is nothing to
|
|
5
|
+
refresh, because a new assertion is signed whenever the last token ages out.
|
|
6
|
+
That is the point: an authorization-code refresh token lives or dies by the
|
|
7
|
+
issuer's policy, and a key does not.
|
|
8
|
+
|
|
9
|
+
Not a `kind` of its own. It is declared as `auth.assertion` on an existing
|
|
10
|
+
`oauth` block, because it is a second arrangement for the same provider rather
|
|
11
|
+
than a different provider — so `credentialRefForConnection`, `setupRequirements`
|
|
12
|
+
and the deploy grants all stay as they were. Which arrangement a connection uses
|
|
13
|
+
is decided by the *shape* of what is stored at `<provider>/<connection>`:
|
|
14
|
+
`isStoredAssertion` is that test, and `resolve.ts` asks it before building an
|
|
15
|
+
authorization-code provider.
|
|
16
|
+
|
|
17
|
+
| File | What it owns |
|
|
18
|
+
|---|---|
|
|
19
|
+
| `key.ts` | the key file's layout, PEM to DER, and the signed claim set — no I/O |
|
|
20
|
+
| `index.ts` | reading the pointer, the exchange, the process-lifetime token cache |
|
|
21
|
+
|
|
22
|
+
The endpoint comes from `token_uri` **inside the key file**, never from a
|
|
23
|
+
constant here, which is what keeps this folder free of any vendor. A second
|
|
24
|
+
vendor offering the same grant is a manifest and no code.
|
|
25
|
+
|
|
26
|
+
## What it cannot do
|
|
27
|
+
|
|
28
|
+
An assertion authenticates the key, and a key is not a person. It reaches only
|
|
29
|
+
what has been shared with its address — unless the identity provider is
|
|
30
|
+
configured to let it act as someone, which is an administrator's grant and not
|
|
31
|
+
the operator's. `auth.assertion.delegation` says which of the two a provider is,
|
|
32
|
+
and `cli/commands/connect/method.ts` is where that becomes a sentence someone
|
|
33
|
+
reads before choosing.
|