@lanes-sh/link 0.3.2 → 0.4.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.
Files changed (68) hide show
  1. package/README.md +11 -3
  2. package/instructions/agents/lanes-link-scout.md +2 -2
  3. package/instructions/skills/lanes-link/SKILL.md +135 -11
  4. package/package.json +3 -1
  5. package/src/cli/argv.ts +52 -0
  6. package/src/cli/commands/connect/authorise.ts +5 -0
  7. package/src/cli/commands/connect/custom/ask.ts +167 -0
  8. package/src/cli/commands/connect/custom/credential.ts +143 -0
  9. package/src/cli/commands/connect/custom/derive.ts +229 -0
  10. package/src/cli/commands/connect/custom/index.ts +285 -0
  11. package/src/cli/commands/connect/custom/prompts.ts +160 -0
  12. package/src/cli/commands/connect/custom/spec.ts +293 -0
  13. package/src/cli/commands/connect/custom/values.ts +53 -0
  14. package/src/cli/commands/connect/custom/write.ts +166 -0
  15. package/src/cli/commands/connect/grant.ts +27 -0
  16. package/src/cli/commands/connect/index.ts +24 -27
  17. package/src/cli/commands/connect/outcome.ts +3 -1
  18. package/src/cli/commands/connect/requirements.ts +11 -1
  19. package/src/cli/commands/connect/settle.ts +17 -0
  20. package/src/cli/commands/connect/setup.ts +9 -1
  21. package/src/cli/commands/connect/strategy.ts +87 -0
  22. package/src/cli/commands/connect/unknown.ts +41 -0
  23. package/src/cli/identity.ts +29 -5
  24. package/src/cli/main.ts +37 -13
  25. package/src/cli/oauth.ts +89 -36
  26. package/src/cli/runtime/open.ts +13 -1
  27. package/src/cli/runtime/registry.ts +12 -0
  28. package/src/cli/selection.ts +12 -0
  29. package/src/cli/usage.ts +9 -1
  30. package/src/connectivity/auth/README.md +8 -1
  31. package/src/connectivity/auth/strategy/index.ts +128 -4
  32. package/src/connectivity/connector.ts +11 -0
  33. package/src/connectivity/index.ts +11 -1
  34. package/src/connectivity/manifest/auth.ts +19 -0
  35. package/src/connectivity/manifest/connector.ts +21 -0
  36. package/src/connectivity/manifest/primitives.ts +5 -1
  37. package/src/connectivity/manifest/provider.ts +30 -12
  38. package/src/connectivity/provider.ts +55 -0
  39. package/src/connectivity/transports/factory.ts +1 -0
  40. package/src/connectivity/transports/http/index.ts +73 -2
  41. package/src/dispatch/dispatch.ts +44 -5
  42. package/src/providers/bunq/hints.ts +43 -0
  43. package/src/providers/bunq/index.ts +87 -0
  44. package/src/providers/bunq/redact.ts +64 -0
  45. package/src/providers/bunq/specs/bunq.v1.json +864 -0
  46. package/src/providers/bunq/specs/vendor.ts +338 -0
  47. package/src/providers/bunq/strategy/handshake.ts +211 -0
  48. package/src/providers/bunq/strategy/index.ts +298 -0
  49. package/src/providers/bunq/strategy/keys.ts +72 -0
  50. package/src/providers/custom/index.ts +1 -6
  51. package/src/providers/custom/load.ts +56 -14
  52. package/src/providers/custom/template.ts +1 -1
  53. package/src/providers/discord/hints.ts +195 -0
  54. package/src/providers/discord/index.ts +121 -0
  55. package/src/providers/discord/redact.ts +99 -0
  56. package/src/providers/discord/specs/discord.v10.json +2333 -0
  57. package/src/providers/discord/specs/vendor.ts +164 -0
  58. package/src/providers/google/specs/vendor.ts +32 -317
  59. package/src/providers/index.ts +9 -0
  60. package/src/providers/reddit/index.ts +113 -0
  61. package/src/providers/reddit/oauth.ts +77 -0
  62. package/src/providers/reddit/redact.ts +33 -0
  63. package/src/providers/reddit/scopes.ts +27 -0
  64. package/src/providers/reddit/specs/reddit.v1.json +700 -0
  65. package/src/providers/scopes.ts +2 -0
  66. package/src/providers/shared/openapi.ts +155 -0
  67. package/src/providers/shared/vendor-operations.ts +98 -0
  68. package/src/providers/shared/vendor-spec.ts +309 -0
@@ -0,0 +1,293 @@
1
+ /**
2
+ * The two fixed lists, as data, and what each member needs typed.
3
+ *
4
+ * `connect custom` exists to compose them: a connectivity type from
5
+ * `connectivity/manifest/connector.ts` and a credential type from
6
+ * `connectivity/manifest/auth.ts`, which are closed discriminated unions. So
7
+ * this file is a projection of those two schemas onto flags, and nothing here
8
+ * decides anything — `derive.ts` builds the manifest and `defineProvider` has
9
+ * the final word, exactly as it does for a hand-written file.
10
+ *
11
+ * One member is deliberately absent. `local` means the capability code is
12
+ * *ours*, compiled into this build, so there is nothing for a manifest to point
13
+ * at — refused by name rather than omitted in silence, because an operator
14
+ * reading the schema will find it and ask.
15
+ *
16
+ * `strategy` *is* offered, and naming one is a large part of what a declaration
17
+ * is for. A strategy travels on a provider's definition rather than in a global
18
+ * registry, so a YAML manifest reaches one by name — which is the only way to
19
+ * point a connection at a vendor's sandbox, since a built-in manifest's
20
+ * `options` are not the operator's to edit. See ADR-046.
21
+ */
22
+
23
+ /** Reachable by declaring one. `local` is ours; see the note above. */
24
+ export const CONNECTOR_KINDS = ['mcp', 'http', 'imap', 'dav', 'fs'] as const;
25
+ export type ConnectorKind = (typeof CONNECTOR_KINDS)[number];
26
+
27
+ /**
28
+ * Typed with a hyphen, stored with an underscore.
29
+ *
30
+ * The manifest spells it `api_key` because `identifier` does, and a flag spells
31
+ * it `api-key` because every other flag in this CLI is kebab-case. One place
32
+ * knows both.
33
+ */
34
+ export const AUTH_METHODS = [
35
+ 'none',
36
+ 'bearer',
37
+ 'api-key',
38
+ 'header',
39
+ 'basic',
40
+ 'oauth',
41
+ 'strategy',
42
+ ] as const;
43
+ export type AuthMethod = (typeof AUTH_METHODS)[number];
44
+
45
+ export const AUTH_KIND: Record<AuthMethod, string> = {
46
+ none: 'none',
47
+ bearer: 'bearer',
48
+ 'api-key': 'api_key',
49
+ header: 'header',
50
+ basic: 'basic',
51
+ oauth: 'oauth',
52
+ strategy: 'strategy',
53
+ };
54
+
55
+ /**
56
+ * Provider ids this command's own grammar has taken.
57
+ *
58
+ * `custom` is the second word of `lanes link connect custom`, so a provider
59
+ * called `custom` could be declared, registered, and then never connected —
60
+ * that command would always mean this one. Refused in two places: here, so it
61
+ * cannot be created, and in `buildRegistryWithWorkspace`, so a file written by
62
+ * hand before this existed says why rather than being quietly unreachable.
63
+ *
64
+ * Not `RESERVED_PROVIDER_IDS`: that list is the owner layer's, it is surfaced to
65
+ * an agent as "the owner providers present in this profile", and the CLI
66
+ * registry passes `allowReserved: true` so it would not have fired here anyway.
67
+ */
68
+ export const RESERVED_BY_GRAMMAR = ['custom'] as const;
69
+
70
+ /** One value the operator supplies, and what to call it when asking. */
71
+ export interface FieldSpec {
72
+ /** The kebab-case flag, which is also the key in `CustomFlags` once camelised. */
73
+ readonly flag: string;
74
+ readonly label: string;
75
+ readonly required: boolean;
76
+ /** One line under the prompt, where the answer is not obvious from the label. */
77
+ readonly hint?: string;
78
+ /** A closed set, offered as a choice rather than a free string. */
79
+ readonly choices?: readonly string[];
80
+ }
81
+
82
+ /**
83
+ * Per connectivity type: what must be said, and what may be.
84
+ *
85
+ * Only fields with no honest default appear. `port`, `max_body_bytes`,
86
+ * `max_range_days`, `max_file_bytes` and the rest are schema defaults and stay
87
+ * out of the written file, so a later change to a default reaches manifests
88
+ * already on disk instead of being frozen into each one.
89
+ */
90
+ /**
91
+ * The one field `mcp` and `http` share, and the only one worth a flag.
92
+ *
93
+ * Repeatable. What the *vendor* requires of a client rather than what a caller
94
+ * wants from it: a `User-Agent` on a host that throttles the default one
95
+ * hardest, or the header an mcp server offers for asking it to expose fewer
96
+ * tools. `Authorization` is refused — that one belongs to `auth`.
97
+ */
98
+ const HEADER: FieldSpec = {
99
+ flag: 'header',
100
+ label: 'Header sent on every request',
101
+ required: false,
102
+ hint: 'Name: value, repeatable. For what the vendor requires of a client, e.g. a User-Agent',
103
+ };
104
+
105
+ export const CONNECTOR_FIELDS: Record<ConnectorKind, readonly FieldSpec[]> = {
106
+ mcp: [
107
+ {
108
+ flag: 'endpoint',
109
+ label: 'MCP endpoint',
110
+ required: true,
111
+ hint: 'The URL the server speaks Streamable HTTP on, e.g. https://mcp.example.com/mcp',
112
+ },
113
+ HEADER,
114
+ ],
115
+ http: [
116
+ { flag: 'base-url', label: 'Base URL', required: true, hint: 'e.g. https://api.example.com/v1' },
117
+ {
118
+ flag: 'openapi',
119
+ label: 'OpenAPI document',
120
+ required: true,
121
+ hint: 'A URL, or a path to a file beside the manifest',
122
+ },
123
+ {
124
+ flag: 'operations',
125
+ label: 'Operations to expose',
126
+ required: false,
127
+ hint: 'Globs on operationId, path or tag. A large spec is a tool list no agent can reason over',
128
+ },
129
+ HEADER,
130
+ ],
131
+ imap: [
132
+ { flag: 'host', label: 'IMAP host', required: true, hint: 'e.g. imap.example.com' },
133
+ { flag: 'port', label: 'IMAP port', required: false, hint: 'Defaults to 993' },
134
+ {
135
+ flag: 'smtp-host',
136
+ label: 'SMTP host',
137
+ required: false,
138
+ hint: 'Leave empty for a read-only mailbox — without it there is no send capability',
139
+ },
140
+ { flag: 'smtp-port', label: 'SMTP port', required: false, hint: '465 for implicit TLS, 587 to upgrade in-band' },
141
+ ],
142
+ dav: [
143
+ { flag: 'base-url', label: 'Base URL', required: true, hint: 'Where discovery begins, e.g. https://dav.example.com' },
144
+ { flag: 'service', label: 'Service', required: true, choices: ['caldav', 'carddav'] },
145
+ ],
146
+ fs: [
147
+ { flag: 'root', label: 'Folder', required: true, hint: 'Everything under it is reachable and nothing above it. May start with ~' },
148
+ { flag: 'exclude', label: 'Names to exclude', required: false, hint: 'On top of .git, .ssh and node_modules, which are always refused' },
149
+ ],
150
+ };
151
+
152
+ /**
153
+ * Per credential type.
154
+ *
155
+ * The credential itself is never here, and never a flag. A flag value is in
156
+ * shell history, in `ps` output while the command runs, and in any transcript of
157
+ * the session — which is why `secrets set` reads from stdin and why this command
158
+ * writes `setup.prompts` into the manifest and lets the ordinary connect path
159
+ * ask. The upshot is that this change handles no secrets at all.
160
+ */
161
+ export const AUTH_FIELDS: Record<AuthMethod, readonly FieldSpec[]> = {
162
+ none: [],
163
+ bearer: [
164
+ { flag: 'auth-header', label: 'Header name', required: false, hint: 'Defaults to Authorization' },
165
+ ],
166
+ 'api-key': [
167
+ { flag: 'auth-header', label: 'Header name', required: false, hint: 'Defaults to X-API-Key' },
168
+ { flag: 'auth-query', label: 'Query parameter', required: false, hint: 'Instead of a header' },
169
+ ],
170
+ header: [
171
+ { flag: 'auth-header', label: 'Header name', required: true, hint: 'The header the value is sent in, verbatim' },
172
+ ],
173
+ basic: [],
174
+ strategy: [
175
+ {
176
+ flag: 'strategy',
177
+ label: 'Strategy name',
178
+ required: true,
179
+ hint: 'The name a provider in this build supplies, e.g. bunq',
180
+ },
181
+ {
182
+ flag: 'strategy-option',
183
+ label: 'Strategy option',
184
+ required: false,
185
+ hint: 'key=value, repeatable. Read by the strategy itself',
186
+ },
187
+ ],
188
+ oauth: [
189
+ { flag: 'scopes', label: 'Scopes', required: true, hint: 'What to ask the authorization server for' },
190
+ { flag: 'authorize-url', label: 'Authorize URL', required: false, hint: 'Required for an http connector; discovered for mcp' },
191
+ { flag: 'token-url', label: 'Token URL', required: false, hint: 'Declared with authorize-url or not at all' },
192
+ { flag: 'client-app', label: 'OAuth app name', required: false, hint: 'Which oauth_apps entry holds the client. Defaults to the provider id' },
193
+ { flag: 'registration', label: 'Registration', required: false, choices: ['dynamic', 'manual'] },
194
+ {
195
+ flag: 'authorize-param',
196
+ label: 'Extra authorization parameter',
197
+ required: false,
198
+ hint: 'key=value, repeatable. Some vendors need one to issue a refresh token at all',
199
+ },
200
+ {
201
+ flag: 'redirect-uri',
202
+ label: 'Redirect URI',
203
+ required: false,
204
+ hint: 'Only for a vendor that matches the whole URL — connect otherwise uses a port the kernel picks',
205
+ },
206
+ ],
207
+ };
208
+
209
+ /** Everything `connect custom` accepts, including what it forwards to `connect`. */
210
+ export const CONNECT_CUSTOM_FLAGS: readonly string[] = [
211
+ 'connector',
212
+ 'auth',
213
+ 'name',
214
+ 'description',
215
+ ...new Set(
216
+ [...Object.values(CONNECTOR_FIELDS), ...Object.values(AUTH_FIELDS)]
217
+ .flat()
218
+ .map((field) => field.flag),
219
+ ),
220
+ 'identity-url',
221
+ 'identity-field',
222
+ 'setup-docs',
223
+ 'replace-manifest',
224
+ 'yes',
225
+ // Forwarded to `connect` untouched, so declaring and connecting is one line.
226
+ // `--own-client` is not among them: it selects between an operator's client
227
+ // and a broker's, and a synthesized manifest never declares a broker.
228
+ 'id',
229
+ 'display-name',
230
+ 'replace',
231
+ 'non-interactive',
232
+ 'accept-broad-scopes',
233
+ ];
234
+
235
+ /** What the operator typed, before any of it has been judged. */
236
+ export interface CustomFlags {
237
+ readonly connector?: string | undefined;
238
+ readonly auth?: string | undefined;
239
+ readonly name?: string | undefined;
240
+ readonly description?: string | undefined;
241
+ readonly endpoint?: string | undefined;
242
+ readonly baseUrl?: string | undefined;
243
+ readonly openapi?: string | undefined;
244
+ readonly operations?: readonly string[] | undefined;
245
+ readonly service?: string | undefined;
246
+ readonly host?: string | undefined;
247
+ readonly port?: string | undefined;
248
+ readonly smtpHost?: string | undefined;
249
+ readonly smtpPort?: string | undefined;
250
+ readonly root?: string | undefined;
251
+ readonly exclude?: readonly string[] | undefined;
252
+ readonly header?: readonly string[] | undefined;
253
+ readonly authHeader?: string | undefined;
254
+ readonly authQuery?: string | undefined;
255
+ readonly scopes?: readonly string[] | undefined;
256
+ readonly authorizeUrl?: string | undefined;
257
+ readonly tokenUrl?: string | undefined;
258
+ readonly clientApp?: string | undefined;
259
+ readonly registration?: string | undefined;
260
+ readonly redirectUri?: string | undefined;
261
+ readonly authorizeParam?: readonly string[] | undefined;
262
+ readonly strategy?: string | undefined;
263
+ readonly strategyOption?: readonly string[] | undefined;
264
+ readonly identityUrl?: string | undefined;
265
+ readonly identityField?: string | undefined;
266
+ readonly setupDocs?: string | undefined;
267
+ readonly replaceManifest?: boolean | undefined;
268
+ readonly yes?: boolean | undefined;
269
+ }
270
+
271
+ /** Everything settled, ready for `deriveManifest`. */
272
+ export interface CustomAnswers {
273
+ readonly id: string;
274
+ readonly name: string;
275
+ readonly description?: string | undefined;
276
+ readonly connector: ConnectorKind;
277
+ readonly auth: AuthMethod;
278
+ readonly values: Readonly<Record<string, string | readonly string[]>>;
279
+ }
280
+
281
+ /** `acme_billing` reads as `Acme Billing` until somebody says otherwise. */
282
+ export function titleCase(id: string): string {
283
+ return id
284
+ .split('_')
285
+ .filter((part) => part.length > 0)
286
+ .map((part) => part.charAt(0).toUpperCase() + part.slice(1))
287
+ .join(' ');
288
+ }
289
+
290
+ /** camelCase, so a flag name indexes `CustomFlags` without a second table. */
291
+ export function camel(flag: string): string {
292
+ return flag.replace(/-([a-z])/g, (_, letter: string) => letter.toUpperCase());
293
+ }
@@ -0,0 +1,53 @@
1
+ import type { CustomAnswers } from './spec.ts';
2
+
3
+ /**
4
+ * Reading what the operator said, in the shape the schema wants it.
5
+ *
6
+ * `values` is flag-keyed and every value arrived as a string or a list of them,
7
+ * because argv has nothing else to offer. These four are the only places that
8
+ * changes, so a field is read the same way wherever it is read.
9
+ */
10
+
11
+ export const one = (answers: CustomAnswers, flag: string): string | undefined => {
12
+ const value = answers.values[flag];
13
+ return typeof value === 'string' && value.length > 0 ? value : undefined;
14
+ };
15
+
16
+ export const many = (answers: CustomAnswers, flag: string): readonly string[] => {
17
+ const value = answers.values[flag];
18
+ return Array.isArray(value) ? value.filter((entry) => entry.length > 0) : [];
19
+ };
20
+
21
+ /**
22
+ * `--strategy-option key=value` and `--authorize-param key=value`, repeated.
23
+ *
24
+ * Both untyped on purpose. A strategy's `options` are validated by the strategy
25
+ * itself, which is the only thing that knows what it takes; an authorization
26
+ * request's extra parameters are the vendor's vocabulary and not ours.
27
+ */
28
+ export function pairs(answers: CustomAnswers, flag: string): Record<string, string> | undefined {
29
+ const declared = many(answers, flag);
30
+ if (declared.length === 0) return undefined;
31
+
32
+ const parsed: Record<string, string> = {};
33
+
34
+ for (const entry of declared) {
35
+ const split = entry.indexOf('=');
36
+ if (split < 1) {
37
+ throw new Error(`--${flag} "${entry}" is not a setting. Write it as "key=value".`);
38
+ }
39
+ parsed[entry.slice(0, split).trim()] = entry.slice(split + 1).trim();
40
+ }
41
+
42
+ return parsed;
43
+ }
44
+
45
+ export function port(value: string | undefined, flag: string): number | undefined {
46
+ if (value === undefined) return undefined;
47
+
48
+ const parsed = Number(value);
49
+ if (!Number.isInteger(parsed) || parsed < 1 || parsed > 65535) {
50
+ throw new Error(`--${flag} must be a port between 1 and 65535, not "${value}".`);
51
+ }
52
+ return parsed;
53
+ }
@@ -0,0 +1,166 @@
1
+ import { mkdir, readFile, rename, stat, writeFile } from 'node:fs/promises';
2
+ import { dirname, isAbsolute, join, resolve } from 'node:path';
3
+ import { stringify } from 'yaml';
4
+ import type { ProviderManifest } from '#connectivity';
5
+ import { layout } from '#profile';
6
+ import { parseManifest } from '#providers/custom/index.ts';
7
+
8
+ /**
9
+ * Putting the manifest where the loader will find it.
10
+ *
11
+ * The only filesystem code in this command, and the only place that decides what
12
+ * the file looks like. Writing is local: a deployed revision reads its
13
+ * manifests from a bucket but never writes its own config (ADR-007), so the
14
+ * operator's workspace is where a declaration is authored and `deploy` or the
15
+ * publish that follows a connect is what carries it.
16
+ */
17
+
18
+ /** Where this profile keeps its own declarations. */
19
+ export function manifestPath(workspaceRoot: string, profile: string, id: string): string {
20
+ return join(workspaceRoot, layout.providers(profile), `${id}.yaml`);
21
+ }
22
+
23
+ /**
24
+ * Fields this command never writes, so a hand-added one is not a difference.
25
+ *
26
+ * The file belongs to the operator the moment it exists. `redact` and `hints`
27
+ * are exactly what somebody adds after seeing the tool list — per-capability
28
+ * argument keys worth recording, and prose the vendor's own description leaves
29
+ * out — and a re-run must not read them as drift.
30
+ */
31
+ const THEIRS = ['redact', 'hints', 'bundles'];
32
+
33
+ /**
34
+ * One header line, about the file rather than about the fields.
35
+ *
36
+ * `manifestTemplate` is the other way round and stays that way: it is teaching
37
+ * material whose values are deliberately wrong and whose comments explain each
38
+ * field. This is a record of what somebody just said, where every value is
39
+ * theirs — so a comment describing a field would end up describing a different
40
+ * provider than the value beside it, and every optional field they declined
41
+ * would sit in the file as a value that now *is* declared.
42
+ */
43
+ const HEADER =
44
+ '# Written by `lanes link connect custom`. Yours to edit from here —\n' +
45
+ '# `lanes link connect <id>` re-reads this file every time.\n';
46
+
47
+ export function renderManifest(declaration: Record<string, unknown>): string {
48
+ return HEADER + stringify(declaration, { lineWidth: 0 });
49
+ }
50
+
51
+ /** The manifest already at this path, or null. Parsed, so defaults match. */
52
+ export async function readExistingManifest(path: string): Promise<ProviderManifest | null> {
53
+ let text: string;
54
+ try {
55
+ text = await readFile(path, 'utf8');
56
+ } catch {
57
+ return null;
58
+ }
59
+
60
+ // Deliberately `parseManifest` and not the loader's file variant: a relative
61
+ // `openapi` must compare as it was written, not as it resolves, or every
62
+ // re-run reads as a change.
63
+ return parseManifest(text, path);
64
+ }
65
+
66
+ /**
67
+ * Where a re-run would differ from what is on disk, as dotted paths.
68
+ *
69
+ * Compared as parsed manifests rather than as bytes, so both sides have the same
70
+ * defaults applied and neither key order nor a reflowed line reads as drift.
71
+ * Both directions, minus `THEIRS`: dropping `--operations` from a second run is
72
+ * a real difference, and answering "unchanged" there would leave a filter in
73
+ * place that the operator has just stopped asking for.
74
+ */
75
+ export function manifestDiff(derived: ProviderManifest, existing: ProviderManifest): string[] {
76
+ const differences: string[] = [];
77
+
78
+ const walk = (a: unknown, b: unknown, path: string): void => {
79
+ if (path.length > 0 && THEIRS.includes(path.split('.')[0]!)) return;
80
+
81
+ if (Array.isArray(a) || Array.isArray(b)) {
82
+ if (JSON.stringify(a) !== JSON.stringify(b)) differences.push(describe(path, a, b));
83
+ return;
84
+ }
85
+
86
+ if (isRecord(a) && isRecord(b)) {
87
+ for (const key of new Set([...Object.keys(a), ...Object.keys(b)])) {
88
+ walk(a[key], b[key], path.length > 0 ? `${path}.${key}` : key);
89
+ }
90
+ return;
91
+ }
92
+
93
+ if (a !== b) differences.push(describe(path, a, b));
94
+ };
95
+
96
+ walk(derived, existing, '');
97
+ return differences.sort();
98
+ }
99
+
100
+ const isRecord = (value: unknown): value is Record<string, unknown> =>
101
+ typeof value === 'object' && value !== null;
102
+
103
+ function describe(path: string, derived: unknown, existing: unknown): string {
104
+ return `${path}: ${show(existing)} → ${show(derived)}`;
105
+ }
106
+
107
+ const show = (value: unknown): string =>
108
+ value === undefined ? '(absent)' : typeof value === 'string' ? value : JSON.stringify(value);
109
+
110
+ /** Write through a temp file, so a crash cannot leave half a declaration. */
111
+ export async function writeManifest(path: string, text: string): Promise<void> {
112
+ await mkdir(dirname(path), { recursive: true });
113
+
114
+ // `.tmp` because the filesystem blob store skips that suffix when it lists —
115
+ // so a crash between write and rename leaves a file the loader will not try to
116
+ // parse, rather than one that breaks every command for this profile.
117
+ const temporary = `${path}.tmp`;
118
+ await writeFile(temporary, text, { mode: 0o600 });
119
+ await rename(temporary, path);
120
+ }
121
+
122
+ /**
123
+ * That an OpenAPI document named as a path is actually there.
124
+ *
125
+ * Checked before the manifest is written, because the alternative is bleak:
126
+ * discovery is the only thing that reads the spec, `openRuntime` swallows a
127
+ * discovery failure so startup survives a provider that is merely unreachable,
128
+ * and the result is a provider registered with zero capabilities and nothing
129
+ * anywhere saying why.
130
+ *
131
+ * A relative path resolves against `providers.d/`, which is what
132
+ * `resolveSpecPath` does when the loader reads it — and is almost never what
133
+ * somebody typing `./spec.json` at a shell prompt means. So when it is missing
134
+ * there and present in the working directory, say both.
135
+ */
136
+ export async function checkOpenapiReachable(
137
+ value: string,
138
+ workspaceRoot: string,
139
+ profile: string,
140
+ ): Promise<void> {
141
+ if (/^https?:/i.test(value)) return;
142
+
143
+ const beside = isAbsolute(value)
144
+ ? value
145
+ : resolve(join(workspaceRoot, layout.providers(profile)), value);
146
+
147
+ if (await exists(beside)) return;
148
+
149
+ const here = isAbsolute(value) ? undefined : resolve(process.cwd(), value);
150
+ const alsoLookedAt =
151
+ here && here !== beside && (await exists(here))
152
+ ? `\n It does exist at ${here}. A relative path in a manifest resolves against the manifest, ` +
153
+ 'not against wherever you ran this from — copy it beside the manifest, or give an absolute ' +
154
+ 'path or a URL.'
155
+ : '';
156
+
157
+ throw new Error(`No OpenAPI document at ${beside}.${alsoLookedAt}`);
158
+ }
159
+
160
+ const exists = async (path: string): Promise<boolean> => {
161
+ try {
162
+ return (await stat(path)).isFile();
163
+ } catch {
164
+ return false;
165
+ }
166
+ };
@@ -0,0 +1,27 @@
1
+ import type { ConfigDocument } from '../../config-edit.ts';
2
+ import { matchesRule } from './accounts.ts';
3
+
4
+ /**
5
+ * What connecting grants.
6
+ *
7
+ * One rule per provider, not one per capability. The pinned-per-tool form this
8
+ * replaced was 85 lines for four providers and unreadable, and what it bought —
9
+ * a vendor cannot widen your policy by shipping a new tool — is preserved
10
+ * instead by `doctor`, which reports capabilities that appeared after you
11
+ * connected.
12
+ *
13
+ * Idempotent, and by matching rather than by equality: a profile already holding
14
+ * a broader rule that covers this one is not widened, and a second connect to
15
+ * the same provider adds nothing.
16
+ */
17
+ export function grantProvider(
18
+ document: ConfigDocument,
19
+ allow: readonly { readonly capability: string }[],
20
+ providerId: string,
21
+ ): string[] {
22
+ const rule = `${providerId}.*`;
23
+ if (allow.some((existing) => matchesRule(existing.capability, rule))) return [];
24
+
25
+ document.addTo(['policy', 'allow'], rule, { inline: true });
26
+ return [rule];
27
+ }
@@ -4,7 +4,8 @@ import { ensureSetupConnection, repaired } from '../../config-repair.ts';
4
4
  import { emit, print } from '../../output.ts';
5
5
  import { nonInteractivePrompter, terminalPrompter, type Prompter } from '../../prompt.ts';
6
6
  import { openRuntime, type GlobalFlags } from '../../runtime.ts';
7
- import { matchesRule, moveCredential, siblingAccountId } from './accounts.ts';
7
+ import { moveCredential, siblingAccountId } from './accounts.ts';
8
+ import { grantProvider } from './grant.ts';
8
9
  import { discoverCapabilities } from './discover.ts';
9
10
  import { connectFamily, familyMembers } from './family.ts';
10
11
  import { authoriseWithKey } from './assertion.ts';
@@ -16,7 +17,9 @@ import { ALREADY, NOTHING, renderOutcome, where, type ConnectOutcome } from './o
16
17
  import { nextAfterEdit, publishRuntimeEdit } from '#cli/publish.ts';
17
18
  import { ensureStaticCredential } from './setup.ts';
18
19
  import { settleIdentity } from './settle.ts';
20
+ import { runStrategySetup } from './strategy.ts';
19
21
  import { announceConnectTarget } from './target-note.ts';
22
+ import { unknownProvider } from './unknown.ts';
20
23
 
21
24
  /**
22
25
  * `lanes link connect <provider>` — the one command that adds an account.
@@ -91,7 +94,14 @@ export async function connect(target: string, options: ConnectOptions): Promise<
91
94
  return emit(options.json, outcome, () => renderOutcome(outcome));
92
95
  }
93
96
 
94
- async function runConnect(
97
+ /**
98
+ * Exported for `connect custom`, which declares a provider and then connects it.
99
+ *
100
+ * Narrow on purpose — no extra parameters, no second entry point into the five
101
+ * steps. The manifest is written before this is called, because the registry
102
+ * that reads `providers.d/` is built by `openRuntime` on the first line.
103
+ */
104
+ export async function runConnect(
95
105
  target: string,
96
106
  options: ConnectOptions,
97
107
  /** A family member — the account this belongs to has already said where it goes. */
@@ -133,17 +143,13 @@ async function runConnect(
133
143
  }
134
144
 
135
145
  if (!entry) {
136
- const available = registry.list();
137
- const builtin = available.filter((c) => c.origin === 'builtin').map((c) => c.manifest.id);
138
- const custom = available.filter((c) => c.origin === 'workspace').map((c) => c.manifest.id);
139
-
140
- throw new Error(
141
- `Unknown provider "${providerId}".\n` +
142
- ` built in: ${builtin.join(', ')}\n` +
143
- (custom.length > 0
144
- ? ` yours: ${custom.join(', ')}\n`
145
- : ` add your own: a manifest in ${runtime.resolution.workspaceRoot}/providers/\n`),
146
- );
146
+ throw unknownProvider({
147
+ providerId,
148
+ registry,
149
+ workspaceRoot: runtime.resolution.workspaceRoot,
150
+ profile: runtime.resolution.profile,
151
+ target: runtime.target,
152
+ });
147
153
  }
148
154
 
149
155
  const manifest = entry.manifest;
@@ -257,6 +263,9 @@ async function runConnect(
257
263
  });
258
264
  }
259
265
 
266
+ // 1b. The vendor's own handshake — see `./strategy.ts`, including why here.
267
+ await runStrategySetup(manifest, provisionalId, runtime);
268
+
260
269
  // 2. Ask the provider whose account that was.
261
270
  //
262
271
  // This is what distinguishes reconnecting an existing account from
@@ -322,20 +331,8 @@ async function runConnect(
322
331
  changes.push(`re-authorised ${connectionKey}${method.id ? ` with ${method.id}` : ''}`);
323
332
  }
324
333
 
325
- // 5. Grant it.
326
- //
327
- // One rule per provider, not one per capability. The pinned-per-tool
328
- // form this replaced was 85 lines for four providers and unreadable, and
329
- // what it bought — a vendor cannot widen your policy by shipping a new
330
- // tool — is preserved instead by `doctor`, which reports capabilities
331
- // that appeared after you connected.
332
- const granted: string[] = [];
333
- const rule = `${providerId}.*`;
334
-
335
- if (!runtime.config.policy.allow.some((existing) => matchesRule(existing.capability, rule))) {
336
- document.addTo(['policy', 'allow'], rule, { inline: true });
337
- granted.push(rule);
338
- }
334
+ // 5. Grant it — one rule per provider; `grant.ts` says why not per capability.
335
+ const granted = grantProvider(document, runtime.config.policy.allow, providerId);
339
336
 
340
337
  // 6. Repair the setup surface if this profile predates it.
341
338
  //
@@ -140,7 +140,9 @@ function renderBlocked(outcome: ConnectOutcome): void {
140
140
  ? 'a browser is needed'
141
141
  : outcome.reason === 'needs_terminal'
142
142
  ? 'a terminal is needed'
143
- : 'more is needed first',
143
+ : outcome.reason === 'needs_declaration'
144
+ ? 'this provider is not declared yet'
145
+ : 'more is needed first',
144
146
  ),
145
147
  );
146
148
 
@@ -37,7 +37,17 @@ export type BlockedReason =
37
37
  | 'needs_browser'
38
38
  /** A question only a person can answer, on a run with nobody to ask. */
39
39
  | 'needs_terminal'
40
- | 'missing_credentials';
40
+ | 'missing_credentials'
41
+ /**
42
+ * The manifest itself is not settled yet — `connect custom` only.
43
+ *
44
+ * Unlike the others this is not about a credential: nothing has been written
45
+ * and nothing needs storing first, the declaration is simply incomplete or
46
+ * disagrees with the file already on disk. It shares `Blocked` because it
47
+ * shares the thing that matters about one — every missing piece named at
48
+ * once, and the command that ends it.
49
+ */
50
+ | 'needs_declaration';
41
51
 
42
52
  export interface Blocked {
43
53
  readonly reason: BlockedReason;