@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.
- package/README.md +11 -3
- package/instructions/agents/lanes-link-scout.md +2 -2
- package/instructions/skills/lanes-link/SKILL.md +135 -11
- package/package.json +3 -1
- package/src/cli/argv.ts +52 -0
- package/src/cli/commands/connect/authorise.ts +5 -0
- package/src/cli/commands/connect/custom/ask.ts +167 -0
- package/src/cli/commands/connect/custom/credential.ts +143 -0
- package/src/cli/commands/connect/custom/derive.ts +229 -0
- package/src/cli/commands/connect/custom/index.ts +285 -0
- package/src/cli/commands/connect/custom/prompts.ts +160 -0
- package/src/cli/commands/connect/custom/spec.ts +293 -0
- package/src/cli/commands/connect/custom/values.ts +53 -0
- package/src/cli/commands/connect/custom/write.ts +166 -0
- package/src/cli/commands/connect/grant.ts +27 -0
- package/src/cli/commands/connect/index.ts +24 -27
- package/src/cli/commands/connect/outcome.ts +3 -1
- package/src/cli/commands/connect/requirements.ts +11 -1
- package/src/cli/commands/connect/settle.ts +17 -0
- package/src/cli/commands/connect/setup.ts +9 -1
- package/src/cli/commands/connect/strategy.ts +87 -0
- package/src/cli/commands/connect/unknown.ts +41 -0
- package/src/cli/identity.ts +29 -5
- package/src/cli/main.ts +37 -13
- package/src/cli/oauth.ts +89 -36
- package/src/cli/runtime/open.ts +13 -1
- package/src/cli/runtime/registry.ts +12 -0
- package/src/cli/selection.ts +12 -0
- package/src/cli/usage.ts +9 -1
- package/src/connectivity/auth/README.md +8 -1
- package/src/connectivity/auth/strategy/index.ts +128 -4
- package/src/connectivity/connector.ts +11 -0
- package/src/connectivity/index.ts +11 -1
- package/src/connectivity/manifest/auth.ts +19 -0
- package/src/connectivity/manifest/connector.ts +21 -0
- package/src/connectivity/manifest/primitives.ts +5 -1
- package/src/connectivity/manifest/provider.ts +30 -12
- package/src/connectivity/provider.ts +55 -0
- package/src/connectivity/transports/factory.ts +1 -0
- package/src/connectivity/transports/http/index.ts +73 -2
- package/src/dispatch/dispatch.ts +44 -5
- package/src/providers/bunq/hints.ts +43 -0
- package/src/providers/bunq/index.ts +87 -0
- package/src/providers/bunq/redact.ts +64 -0
- package/src/providers/bunq/specs/bunq.v1.json +864 -0
- package/src/providers/bunq/specs/vendor.ts +338 -0
- package/src/providers/bunq/strategy/handshake.ts +211 -0
- package/src/providers/bunq/strategy/index.ts +298 -0
- package/src/providers/bunq/strategy/keys.ts +72 -0
- package/src/providers/custom/index.ts +1 -6
- package/src/providers/custom/load.ts +56 -14
- package/src/providers/custom/template.ts +1 -1
- package/src/providers/discord/hints.ts +195 -0
- package/src/providers/discord/index.ts +121 -0
- package/src/providers/discord/redact.ts +99 -0
- package/src/providers/discord/specs/discord.v10.json +2333 -0
- package/src/providers/discord/specs/vendor.ts +164 -0
- package/src/providers/google/specs/vendor.ts +32 -317
- package/src/providers/index.ts +9 -0
- package/src/providers/reddit/index.ts +113 -0
- package/src/providers/reddit/oauth.ts +77 -0
- package/src/providers/reddit/redact.ts +33 -0
- package/src/providers/reddit/scopes.ts +27 -0
- package/src/providers/reddit/specs/reddit.v1.json +700 -0
- package/src/providers/scopes.ts +2 -0
- package/src/providers/shared/openapi.ts +155 -0
- package/src/providers/shared/vendor-operations.ts +98 -0
- 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 {
|
|
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
|
-
|
|
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
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
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
|
-
:
|
|
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;
|