@lanes-sh/link 0.3.2 → 0.4.1

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 (69) 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/commands/mcp/list.ts +123 -29
  24. package/src/cli/identity.ts +29 -5
  25. package/src/cli/main.ts +42 -14
  26. package/src/cli/oauth.ts +89 -36
  27. package/src/cli/runtime/open.ts +13 -1
  28. package/src/cli/runtime/registry.ts +12 -0
  29. package/src/cli/selection.ts +12 -0
  30. package/src/cli/usage.ts +9 -1
  31. package/src/connectivity/auth/README.md +8 -1
  32. package/src/connectivity/auth/strategy/index.ts +128 -4
  33. package/src/connectivity/connector.ts +11 -0
  34. package/src/connectivity/index.ts +11 -1
  35. package/src/connectivity/manifest/auth.ts +19 -0
  36. package/src/connectivity/manifest/connector.ts +21 -0
  37. package/src/connectivity/manifest/primitives.ts +5 -1
  38. package/src/connectivity/manifest/provider.ts +30 -12
  39. package/src/connectivity/provider.ts +55 -0
  40. package/src/connectivity/transports/factory.ts +1 -0
  41. package/src/connectivity/transports/http/index.ts +73 -2
  42. package/src/dispatch/dispatch.ts +44 -5
  43. package/src/providers/bunq/hints.ts +45 -0
  44. package/src/providers/bunq/index.ts +87 -0
  45. package/src/providers/bunq/redact.ts +75 -0
  46. package/src/providers/bunq/specs/bunq.v1.json +883 -0
  47. package/src/providers/bunq/specs/vendor.ts +396 -0
  48. package/src/providers/bunq/strategy/handshake.ts +211 -0
  49. package/src/providers/bunq/strategy/index.ts +298 -0
  50. package/src/providers/bunq/strategy/keys.ts +72 -0
  51. package/src/providers/custom/index.ts +1 -6
  52. package/src/providers/custom/load.ts +56 -14
  53. package/src/providers/custom/template.ts +1 -1
  54. package/src/providers/discord/hints.ts +195 -0
  55. package/src/providers/discord/index.ts +121 -0
  56. package/src/providers/discord/redact.ts +99 -0
  57. package/src/providers/discord/specs/discord.v10.json +2333 -0
  58. package/src/providers/discord/specs/vendor.ts +164 -0
  59. package/src/providers/google/specs/vendor.ts +32 -317
  60. package/src/providers/index.ts +9 -0
  61. package/src/providers/reddit/index.ts +113 -0
  62. package/src/providers/reddit/oauth.ts +77 -0
  63. package/src/providers/reddit/redact.ts +33 -0
  64. package/src/providers/reddit/scopes.ts +27 -0
  65. package/src/providers/reddit/specs/reddit.v1.json +700 -0
  66. package/src/providers/scopes.ts +2 -0
  67. package/src/providers/shared/openapi.ts +155 -0
  68. package/src/providers/shared/vendor-operations.ts +179 -0
  69. package/src/providers/shared/vendor-spec.ts +309 -0
@@ -0,0 +1,229 @@
1
+ import { defineProvider, type ProviderManifest } from '#connectivity';
2
+ import {
3
+ AUTH_METHODS,
4
+ CONNECTOR_KINDS,
5
+ type AuthMethod,
6
+ type ConnectorKind,
7
+ type CustomAnswers,
8
+ } from './spec.ts';
9
+ import { identityBlock, setupBlock } from './prompts.ts';
10
+ import { authBlock } from './credential.ts';
11
+ import { many, one, port } from './values.ts';
12
+
13
+ /**
14
+ * Turning what the operator said into a manifest.
15
+ *
16
+ * The flag surface cannot mirror the schema, because `defineProvider` demands
17
+ * fields nobody should have to type: `setup.prompts` for every credential type
18
+ * that is asked for rather than granted, an `identity` block wherever the
19
+ * connector can answer, and — for a manual OAuth client — two prompt keys and
20
+ * two credential refs whose exact spelling is read back by literal string
21
+ * elsewhere. So this file derives them, and `defineProvider` still has the last
22
+ * word, exactly as it does for a hand-written file.
23
+ *
24
+ * It also refuses three things the schema accepts and `authorise` cannot run.
25
+ * That is the failure class worth the most care here: the manifest validates,
26
+ * the connection is declared, and the flow has nowhere to go — which is
27
+ * indistinguishable from a working setup right up until somebody uses it.
28
+ */
29
+
30
+ export function parseConnectorKind(value: string): ConnectorKind {
31
+ if ((CONNECTOR_KINDS as readonly string[]).includes(value)) return value as ConnectorKind;
32
+
33
+ if (value === 'local') {
34
+ throw new Error(
35
+ '"local" is not a connectivity type you can declare: it means the capability code is ours, ' +
36
+ 'compiled into this build — the example provider and the owner layer. There is nothing for a ' +
37
+ `manifest to point at. Pick one of: ${CONNECTOR_KINDS.join(', ')}.`,
38
+ );
39
+ }
40
+
41
+ throw new Error(`Unknown connectivity type "${value}". Pick one of: ${CONNECTOR_KINDS.join(', ')}.`);
42
+ }
43
+
44
+ export function parseAuthMethod(value: string): AuthMethod {
45
+ if ((AUTH_METHODS as readonly string[]).includes(value)) return value as AuthMethod;
46
+
47
+ throw new Error(`Unknown credential type "${value}". Pick one of: ${AUTH_METHODS.join(', ')}.`);
48
+ }
49
+
50
+ /**
51
+ * Which pairs `defineProvider` would refuse, refused here instead.
52
+ *
53
+ * Same rules, earlier, and naming the alternative. The point is not to
54
+ * duplicate the check — it runs regardless a moment later — but that its
55
+ * message states a rule where this one states what to do about it.
56
+ */
57
+ export function refuseIllegalPair(connector: ConnectorKind, auth: AuthMethod): void {
58
+ if (connector === 'mcp' && auth !== 'none' && auth !== 'oauth' && auth !== 'bearer') {
59
+ throw new Error(
60
+ `An mcp connector sends exactly one header, "Authorization: Bearer", because that is what the ` +
61
+ `MCP specification says a client sends. There is nowhere else on the request for a "${auth}" ` +
62
+ 'credential to go, so this would connect unauthenticated: no error, an empty tool list, and ' +
63
+ 'nothing to read that says why.\n' +
64
+ ' Reach the same service over its REST API with --connector http, or use --auth bearer.',
65
+ );
66
+ }
67
+
68
+ if ((connector === 'imap' || connector === 'dav') && auth !== 'basic') {
69
+ throw new Error(
70
+ `An ${connector} connector authenticates with a username and password. Every mail and DAV host ` +
71
+ 'that matters issues an app password and expects it over Basic; OAuth for these exists but is ' +
72
+ 'partner-gated with no published scopes, so declaring it would validate and then fail to ' +
73
+ 'authenticate.\n Use --auth basic.',
74
+ );
75
+ }
76
+
77
+ if (auth === 'strategy' && connector !== 'http') {
78
+ throw new Error(
79
+ `A strategy signs or negotiates an HTTP request, and a ${connector} connector does not make ` +
80
+ 'one it could sign.\n Use --connector http.',
81
+ );
82
+ }
83
+
84
+ if (connector === 'fs' && auth !== 'none') {
85
+ throw new Error(
86
+ 'An fs connector reads a folder on this machine and holds no account. The permission is the ' +
87
+ 'operating system\'s, held against this process — there is nothing to store and nothing that ' +
88
+ 'could be carried to another machine (ADR-011).\n Use --auth none.',
89
+ );
90
+ }
91
+ }
92
+
93
+ /**
94
+ * What gets written: only the fields the operator's answers actually settle.
95
+ *
96
+ * Deliberately *not* a `ProviderManifest`, and the distinction is load-bearing
97
+ * twice over. `defineProvider` fills in every schema default, and writing those
98
+ * back out would freeze them — a manifest saying `port: 993` no longer follows
99
+ * the default if it ever changes, and a re-run diffs against a file full of
100
+ * values nobody chose.
101
+ *
102
+ * Worse, it would not even load. Defaulting `auth.refresh_token` to `required`
103
+ * puts a key ending in `_token` into the document, and the entropy check that
104
+ * guards a manifest refuses any such key that is not a `_ref` — so the file this
105
+ * command wrote would be rejected the next time anything read it. Rendering the
106
+ * declaration rather than the validated manifest is what keeps the two honest.
107
+ */
108
+ export function deriveDeclaration(answers: CustomAnswers): Record<string, unknown> {
109
+ refuseIllegalPair(answers.connector, answers.auth);
110
+
111
+ const setup = setupBlock(answers);
112
+ const identity = identityBlock(answers);
113
+
114
+ return {
115
+ id: answers.id,
116
+ name: answers.name,
117
+ ...(answers.description ? { description: answers.description } : {}),
118
+ connector: connectorBlock(answers),
119
+ auth: authBlock(answers),
120
+ ...(identity ? { identity } : {}),
121
+ ...(setup ? { setup } : {}),
122
+ };
123
+ }
124
+
125
+ /** The same declaration, validated — which is what a re-run compares against. */
126
+ export function deriveManifest(answers: CustomAnswers): ProviderManifest {
127
+ return defineProvider(deriveDeclaration(answers));
128
+ }
129
+
130
+ /**
131
+ * `--header 'Name: value'`, repeated.
132
+ *
133
+ * `Authorization` is refused here as well as by `defineProvider`, because this
134
+ * is where somebody is choosing: the credential comes from `auth`, and a
135
+ * manifest setting both would leave which one is sent up to merge order.
136
+ */
137
+ function headers(answers: CustomAnswers): Record<string, string> | undefined {
138
+ const declared = many(answers, 'header');
139
+ if (declared.length === 0) return undefined;
140
+
141
+ const parsed: Record<string, string> = {};
142
+
143
+ for (const entry of declared) {
144
+ const split = entry.indexOf(':');
145
+ if (split < 1) {
146
+ throw new Error(`--header "${entry}" is not a header. Write it as "Name: value".`);
147
+ }
148
+
149
+ const name = entry.slice(0, split).trim();
150
+ if (name.toLowerCase() === 'authorization') {
151
+ throw new Error(
152
+ 'The credential is the auth block\'s, so --header cannot set Authorization — setting both ' +
153
+ 'would leave which one is sent up to merge order.\n' +
154
+ ' Use --auth bearer (or api-key, or header with --auth-header) instead.',
155
+ );
156
+ }
157
+
158
+ parsed[name] = entry.slice(split + 1).trim();
159
+ }
160
+
161
+ return parsed;
162
+ }
163
+
164
+ /**
165
+ * Only what was said, plus what cannot be defaulted.
166
+ *
167
+ * A schema default is deliberately not written: leaving `port` out means a
168
+ * manifest follows the default if it ever changes, and it keeps the diff on a
169
+ * re-run down to what actually differs.
170
+ */
171
+ function connectorBlock(answers: CustomAnswers): Record<string, unknown> {
172
+ const kind = answers.connector;
173
+
174
+ const sent = headers(answers);
175
+
176
+ if (kind === 'mcp') {
177
+ return { kind, endpoint: one(answers, 'endpoint'), ...(sent ? { headers: sent } : {}) };
178
+ }
179
+
180
+ if (kind === 'http') {
181
+ const include = many(answers, 'operations');
182
+ return {
183
+ kind,
184
+ base_url: one(answers, 'base-url'),
185
+ openapi: one(answers, 'openapi'),
186
+ ...(include.length > 0 ? { operations: { include } } : {}),
187
+ ...(sent ? { headers: sent } : {}),
188
+ };
189
+ }
190
+
191
+ if (kind === 'imap') {
192
+ const smtpHost = one(answers, 'smtp-host');
193
+ const smtpPort = port(one(answers, 'smtp-port'), 'smtp-port');
194
+
195
+ return {
196
+ kind,
197
+ host: one(answers, 'host'),
198
+ ...(port(one(answers, 'port'), 'port') !== undefined
199
+ ? { port: port(one(answers, 'port'), 'port') }
200
+ : {}),
201
+ // No SMTP host, no send capability — `imap/index.ts` reads the absence of
202
+ // the block, so a mailbox declared without one is read-only by
203
+ // construction rather than by policy.
204
+ ...(smtpHost
205
+ ? {
206
+ smtp: {
207
+ host: smtpHost,
208
+ ...(smtpPort !== undefined ? { port: smtpPort } : {}),
209
+ // A consequence of the port rather than a separate question: 465
210
+ // is implicit TLS, 587 upgrades in-band, and the default is the
211
+ // latter. Written only when it is not the default.
212
+ ...(smtpPort === 465 ? { starttls: false } : {}),
213
+ },
214
+ }
215
+ : {}),
216
+ };
217
+ }
218
+
219
+ if (kind === 'dav') {
220
+ return { kind, base_url: one(answers, 'base-url'), service: one(answers, 'service') };
221
+ }
222
+
223
+ const exclude = many(answers, 'exclude');
224
+ return {
225
+ kind,
226
+ root: one(answers, 'root'),
227
+ ...(exclude.length > 0 ? { exclude } : {}),
228
+ };
229
+ }
@@ -0,0 +1,285 @@
1
+ import { relative } from 'node:path';
2
+ import { RESERVED_PROVIDER_IDS } from '#connectivity';
3
+ import { isRemoteWorkspace, resolveWorkspaceRoot } from '#profile';
4
+ import { parseManifest } from '#providers/custom/index.ts';
5
+ import { PROVIDER_MANIFESTS } from '#providers/index.ts';
6
+ import { announce, emit, ok, progress } from '../../../output.ts';
7
+ import { nonInteractivePrompter, terminalPrompter, type Prompter } from '../../../prompt.ts';
8
+ import { resolveProfile, type GlobalFlags } from '../../../runtime.ts';
9
+ import { PROGRAM } from '../../../usage.ts';
10
+ import { runConnect, type ConnectOptions } from '../index.ts';
11
+ import { renderOutcome, type ConnectOutcome } from '../outcome.ts';
12
+ import { collect, type Blocked } from './ask.ts';
13
+ import { deriveDeclaration, deriveManifest } from './derive.ts';
14
+ import { RESERVED_BY_GRAMMAR, type CustomFlags } from './spec.ts';
15
+ import {
16
+ checkOpenapiReachable,
17
+ manifestDiff,
18
+ manifestPath,
19
+ readExistingManifest,
20
+ renderManifest,
21
+ writeManifest,
22
+ } from './write.ts';
23
+
24
+ /**
25
+ * `lanes link connect custom <id>` — declare a provider, then connect it.
26
+ *
27
+ * The built-in list has never been the boundary: a manifest in the profile's
28
+ * `providers.d/` is validated by the same schema a built-in is and registered
29
+ * into the same registry, which is the scalability claim of the whole manifest
30
+ * design. What was missing was the way in. An operator had to find an
31
+ * undocumented directory, write YAML, and satisfy cross-field rules they could
32
+ * not see — and the one message that would have told them where to put the file
33
+ * named a directory nothing reads.
34
+ *
35
+ * So this composes the two closed unions: a connectivity type and a credential
36
+ * type. Anything not covered by a pair of them is not something to bolt on here
37
+ * — it is a member missing from one of those lists, which is a folder and a
38
+ * schema entry away. `docs/detailed/connectivity-coverage.md` is the standing
39
+ * account of which pairs work, which are closed on purpose, and which are not
40
+ * built yet.
41
+ *
42
+ * The manifest is written *before* `runConnect`, because the registry that reads
43
+ * `providers.d/` is built by `openRuntime` on its first line. Everything that
44
+ * can be refused is refused before the write.
45
+ */
46
+
47
+ export interface ConnectCustomOptions extends GlobalFlags, CustomFlags {
48
+ readonly json?: boolean | undefined;
49
+ /** Forwarded to `connect` untouched. */
50
+ readonly id?: string | undefined;
51
+ readonly displayName?: string | undefined;
52
+ readonly replace?: boolean | undefined;
53
+ readonly nonInteractive?: boolean | undefined;
54
+ readonly acceptBroadScopes?: boolean | undefined;
55
+ /** Injected by tests, so the declaration can be checked without a runtime. */
56
+ readonly prompter?: Prompter | undefined;
57
+ readonly connectWith?:
58
+ | ((target: string, options: ConnectOptions, announced?: boolean) => Promise<ConnectOutcome>)
59
+ | undefined;
60
+ }
61
+
62
+ export async function connectCustom(
63
+ providerId: string | undefined,
64
+ options: ConnectCustomOptions,
65
+ ): Promise<void> {
66
+ if (!providerId) throw new Error(`Usage: ${PROGRAM} connect custom <provider-id>`);
67
+
68
+ // Both of these are answerable without reading anything, so they come before
69
+ // the profile is resolved: loading a config from a bucket is a network call,
70
+ // and refusing afterwards means having made it to say no.
71
+ refuseUnusableId(providerId);
72
+
73
+ // A manifest is read from the workspace and written to the filesystem, and a
74
+ // bucket only does the first. Not a limitation of this command: a deployed
75
+ // revision never rewrites its own config (ADR-007), so a declaration is
76
+ // authored where the operator is and carried up by the publish that follows.
77
+ const root = resolveWorkspaceRoot();
78
+ if (isRemoteWorkspace(root)) {
79
+ throw new Error(
80
+ `This workspace is ${root}, and a manifest is written to a local filesystem.\n` +
81
+ ` Declare it in the workspace you deploy from, and \`${PROGRAM} deploy\` carries it up.`,
82
+ );
83
+ }
84
+
85
+ const { resolution, target } = await resolveProfile(options);
86
+ const { workspaceRoot, profile } = resolution;
87
+
88
+ // Before it acts, because writing the manifest *is* acting and `usage.ts`
89
+ // promises every command says where. `runConnect` is told it has been said, so
90
+ // the line does not appear twice.
91
+ if (options.json !== true) announce(resolution);
92
+
93
+ const path = manifestPath(workspaceRoot, profile, providerId);
94
+ const rerun = `${PROGRAM} connect custom ${providerId} --profile ${profile} --target ${target}`;
95
+
96
+ const prompter =
97
+ options.prompter ??
98
+ (options.nonInteractive ? nonInteractivePrompter(rerun) : terminalPrompter);
99
+
100
+ const collected = await collect(providerId, options, prompter, (missing) =>
101
+ [rerun, ...missing.map((flag) => `--${flag} <value>`)].join(' '),
102
+ );
103
+
104
+ if ('missing' in collected) {
105
+ return refuse(blockedOn(collected, providerId), options.json);
106
+ }
107
+
108
+ const declaration = deriveDeclaration(collected);
109
+ const text = renderManifest(declaration);
110
+
111
+ // Through the loader's own gate, not a copy of it: the entropy check that
112
+ // refuses a pasted credential, then every cross-field rule. A second
113
+ // implementation of "is this a secret" is how the two come to disagree.
114
+ const derived = parseManifest(text, path);
115
+
116
+ if (derived.connector.kind === 'http') {
117
+ await checkOpenapiReachable(derived.connector.openapi, workspaceRoot, profile);
118
+ }
119
+
120
+ // A connection is labelled with the account it belongs to, and a provider that
121
+ // cannot report one has to be told. Interactively `settleIdentity` asks; with
122
+ // nobody to ask it throws — but only after the credential has been stored, so
123
+ // the operator gets two failed runs instead of one refusal. Checked here
124
+ // because this command is the one that knows no identity block was derived.
125
+ if (options.nonInteractive && !derived.identity && derived.auth.kind !== 'none' && !options.displayName) {
126
+ throw new Error(
127
+ `${derived.name} has no way to report whose account a connection is, so on a run with nobody ` +
128
+ 'to ask it has to be named.\n Nothing was written. Add --display-name "<label>"' +
129
+ (derived.connector.kind === 'http'
130
+ ? ', or --identity-url and --identity-field if the API can answer for itself.'
131
+ : '.'),
132
+ );
133
+ }
134
+
135
+ const shown = relative(workspaceRoot, path);
136
+ const existing = await readExistingManifest(path);
137
+ const differences = existing ? manifestDiff(deriveManifest(collected), existing) : [];
138
+
139
+ if (existing && differences.length > 0 && !options.replaceManifest) {
140
+ return refuse(alreadyDescribed(shown, differences, rerun, options.replace), options.json);
141
+ }
142
+
143
+ const changes: string[] = [];
144
+ if (!existing) {
145
+ await writeManifest(path, text);
146
+ changes.push(`wrote ${shown}`);
147
+ } else if (differences.length > 0) {
148
+ await writeManifest(path, text);
149
+ changes.push(`rewrote ${shown}`);
150
+ } else {
151
+ changes.push(`${shown} unchanged`);
152
+ }
153
+
154
+ // Said now rather than only in the outcome: the connect that follows may open
155
+ // a browser, and knowing the file landed is worth having before that.
156
+ progress(ok(changes[0]!));
157
+
158
+ // Named field by field rather than spread, because the two commands share a
159
+ // flag name that means different things. `--auth` here is the credential type
160
+ // in the manifest — `none`, `basic`, `api-key` — and on `connect` it names
161
+ // which *route* in, for a provider offering two. Spreading sent `--auth none`
162
+ // straight through, and `connect` refused a provider it had just been handed
163
+ // with "--auth accepts: oauth".
164
+ const handOff = options.connectWith ?? runConnect;
165
+ const outcome = await handOff(
166
+ providerId,
167
+ {
168
+ profile: options.profile,
169
+ target: options.target,
170
+ quiet: options.quiet ?? false,
171
+ ...(options.id ? { id: options.id } : {}),
172
+ ...(options.displayName ? { displayName: options.displayName } : {}),
173
+ ...(options.replace ? { replace: options.replace } : {}),
174
+ ...(options.nonInteractive ? { nonInteractive: options.nonInteractive } : {}),
175
+ ...(options.acceptBroadScopes ? { acceptBroadScopes: options.acceptBroadScopes } : {}),
176
+ ...(options.json ? { json: options.json } : {}),
177
+ },
178
+ true,
179
+ );
180
+
181
+ // The manifest first, because it is the change this command made and the rest
182
+ // is what `connect` made. It stays in the list on a failure too: the file is
183
+ // real, it is the operator's now, and the retry is plain `connect` — which is
184
+ // what `then` already says.
185
+ const merged: ConnectOutcome = { ...outcome, changes: [...changes, ...outcome.changes] };
186
+
187
+ if (!merged.ok) process.exitCode = 1;
188
+
189
+ // `--json` gets the manifest in `changes`, because that list is what a caller
190
+ // counts and matches on. The human rendering does not, because they were told
191
+ // above — before the connect, which is where it mattered — and the same
192
+ // sentence twice reads as two things having happened.
193
+ return emit(options.json, merged, () => renderOutcome(outcome));
194
+ }
195
+
196
+ /**
197
+ * Ids this command cannot create.
198
+ *
199
+ * `custom` is the second word of its own grammar, so such a provider could be
200
+ * declared, registered, and then never connected. The reserved owner ids are
201
+ * refused by `defineProvider` a moment later, but with a rule rather than with
202
+ * the reason — and this is the one place somebody is choosing a name.
203
+ */
204
+ function refuseUnusableId(id: string): void {
205
+ if (!/^[a-z][a-z0-9_]*$/.test(id)) {
206
+ throw new Error(
207
+ `"${id}" cannot be a provider id: they are lowercase, start with a letter, and use ` +
208
+ 'underscores rather than hyphens — it becomes part of every capability name and every ' +
209
+ `policy rule. Try "${id.toLowerCase().replace(/[^a-z0-9]+/g, '_').replace(/^_+|_+$/g, '')}".`,
210
+ );
211
+ }
212
+
213
+ if ((RESERVED_BY_GRAMMAR as readonly string[]).includes(id)) {
214
+ throw new Error(
215
+ `"${id}" is the second word of this command, so a provider called "${id}" could be declared ` +
216
+ `and then never connected — \`${PROGRAM} connect ${id}\` would always mean this command. ` +
217
+ 'Pick another id.',
218
+ );
219
+ }
220
+
221
+ if (RESERVED_PROVIDER_IDS.includes(id)) {
222
+ throw new Error(
223
+ `"${id}" is reserved for what this endpoint provides itself — your memory, skills, vault, ` +
224
+ 'setup surface and identity. Pick another id.',
225
+ );
226
+ }
227
+
228
+ if (PROVIDER_MANIFESTS.some((manifest) => manifest.id === id)) {
229
+ throw new Error(
230
+ `"${id}" is already built in, so a manifest of yours would shadow it — and a provider ` +
231
+ 'silently answering differently from the one it is named after is very hard to diagnose ' +
232
+ `from the outside.\n Connect the built-in with \`${PROGRAM} connect ${id}\`, or pick ` +
233
+ 'another id.',
234
+ );
235
+ }
236
+ }
237
+
238
+ function blockedOn(blocked: Blocked, providerId: string): ConnectOutcome {
239
+ const plural = blocked.missing.length === 1 ? 'value' : 'values';
240
+
241
+ return {
242
+ ok: false,
243
+ changes: [],
244
+ granted: [],
245
+ discovered: 0,
246
+ reason: 'needs_declaration',
247
+ message:
248
+ `Declaring ${providerId} needs ${blocked.missing.length} more ${plural}, and this run is ` +
249
+ 'non-interactive.\n Nothing was written. Either run it in a terminal, or pass:',
250
+ needs: [],
251
+ then: blocked.command,
252
+ };
253
+ }
254
+
255
+ function alreadyDescribed(
256
+ shown: string,
257
+ differences: readonly string[],
258
+ rerun: string,
259
+ replacePassed: boolean | undefined,
260
+ ): ConnectOutcome {
261
+ // `--replace` and `--replace-manifest` are one word apart and do different
262
+ // things, so somebody who reached here with the wrong one is told which.
263
+ const confusion = replacePassed
264
+ ? '\n (--replace asks for the credential again; the manifest is a separate thing.)'
265
+ : '';
266
+
267
+ return {
268
+ ok: false,
269
+ changes: [],
270
+ granted: [],
271
+ discovered: 0,
272
+ reason: 'needs_declaration',
273
+ message:
274
+ `${shown} already describes this provider, differently. Nothing was written.${confusion}\n` +
275
+ `${differences.map((line) => ` ${line}`).join('\n')}\n\n` +
276
+ ' Edit the file and connect it, or replace it:',
277
+ needs: [],
278
+ then: `${rerun} --replace-manifest`,
279
+ };
280
+ }
281
+
282
+ function refuse(outcome: ConnectOutcome, json: boolean | undefined): void | Promise<void> {
283
+ process.exitCode = 1;
284
+ return emit(json, outcome, () => renderOutcome(outcome));
285
+ }
@@ -0,0 +1,160 @@
1
+ import type { CustomAnswers } from './spec.ts';
2
+
3
+ /**
4
+ * What the manifest says about being connected: what to ask for, and how to
5
+ * label the account it turns out to be.
6
+ *
7
+ * Neither is optional decoration. `ensureStaticCredential` throws for any
8
+ * credential type that is asked for rather than granted and finds no
9
+ * per-connection prompt, so a manifest without one is a provider that cannot be
10
+ * connected at all. And without an `identity` block a connection list reads
11
+ * `Thing main`, `Thing main2` — which cannot answer the only question anyone
12
+ * asks of it, and leaves `connect` unable to tell a reconnect from a new
13
+ * account.
14
+ */
15
+
16
+ /** Where a per-account prompt's ref comes from, so it must not be declared. */
17
+ const PER_ACCOUNT = { scope: 'connection' } as const;
18
+
19
+ /**
20
+ * The two prompt keys that are not ours to choose.
21
+ *
22
+ * `declareOwnClient` looks these up by literal key, and `resolveOAuthClient`
23
+ * reads `<app>/client_id` and `<app>/client_secret` out of the credential store
24
+ * by literal string. Spell either differently and the manifest validates,
25
+ * `connect` collects two secrets from the operator, stores them, and *then*
26
+ * refuses with "No OAuth client stored" — after they have been through a vendor
27
+ * console. There is no freedom here and the tests assert it against
28
+ * `declareOwnClient` rather than against a copy of these strings.
29
+ */
30
+ const OAUTH_CLIENT_KEYS = ['client_id', 'client_secret'] as const;
31
+
32
+ export function setupBlock(answers: CustomAnswers): Record<string, unknown> | undefined {
33
+ const prompts = promptsFor(answers);
34
+ const docs = documentation(answers);
35
+
36
+ if (prompts.length === 0 && Object.keys(docs).length === 0) return undefined;
37
+ return { ...docs, ...(prompts.length > 0 ? { prompts } : {}) };
38
+ }
39
+
40
+ function promptsFor(answers: CustomAnswers): Record<string, unknown>[] {
41
+ const { auth, name } = answers;
42
+
43
+ if (auth === 'none') return [];
44
+
45
+ if (auth === 'basic') {
46
+ // Exactly one of each, in this order: `basic` stores `username:password` —
47
+ // RFC 7617's own encoding — and cannot be assembled from anything else.
48
+ return [
49
+ { key: 'username', label: 'Username', ...PER_ACCOUNT, field: 'username' },
50
+ { key: 'password', label: 'Password', secret: true, ...PER_ACCOUNT, field: 'password' },
51
+ ];
52
+ }
53
+
54
+ if (auth === 'bearer') {
55
+ return [{ key: 'token', label: `${name} API token`, secret: true, ...PER_ACCOUNT }];
56
+ }
57
+
58
+ if (auth === 'api-key' || auth === 'header' || auth === 'strategy') {
59
+ // A strategy negotiates rather than just attaching, but what it starts from
60
+ // is still one secret the operator holds — bunq's handshake begins with an
61
+ // API key pasted from the app. If a strategy needs something else, the
62
+ // manifest is a file and this is one line of it.
63
+ return [{ key: 'api_key', label: `${name} API key`, secret: true, ...PER_ACCOUNT }];
64
+ }
65
+
66
+ // Dynamic registration asks for nothing: the authorization server hands out a
67
+ // client and the operator never sees one.
68
+ const app = clientApp(answers);
69
+ if (!app) return [];
70
+
71
+ return OAUTH_CLIENT_KEYS.map((key) => ({
72
+ key,
73
+ label: `${name} OAuth ${key.replace('_', ' ')}`,
74
+ ...(key === 'client_secret' ? { secret: true } : {}),
75
+ // Shared across every account of this profile, so the ref cannot derive
76
+ // from a connection and has to be named.
77
+ scope: 'shared',
78
+ credential_ref: `${app}/${key}`,
79
+ }));
80
+ }
81
+
82
+ /** The `oauth_apps` entry a manual client lands in, or nothing. */
83
+ function clientApp(answers: CustomAnswers): string | undefined {
84
+ if (answers.auth !== 'oauth') return undefined;
85
+
86
+ const declared = answers.values['registration'];
87
+ const explicit = answers.values['client-app'];
88
+ const endpoints = answers.values['authorize-url'];
89
+
90
+ const dynamic =
91
+ declared === 'dynamic' ||
92
+ (declared === undefined && answers.connector === 'mcp' && !explicit && !endpoints);
93
+
94
+ if (dynamic) return undefined;
95
+ return typeof explicit === 'string' && explicit.length > 0 ? explicit : answers.id;
96
+ }
97
+
98
+ /**
99
+ * `--setup-docs`, placed by shape.
100
+ *
101
+ * Never into `setup.docs`, which nothing reads — `printSetup` renders `summary`,
102
+ * `docs_url` and `steps`, and `planFor` reads `docs_url`. A manifest using
103
+ * `docs` validates and then shows the operator nothing, which is the worst of
104
+ * the three outcomes.
105
+ */
106
+ function documentation(answers: CustomAnswers): Record<string, unknown> {
107
+ const value = answers.values['setup-docs'];
108
+ if (typeof value !== 'string' || value.length === 0) return {};
109
+
110
+ if (!/^https?:\/\//i.test(value)) return { steps: [value] };
111
+
112
+ const app = clientApp(answers);
113
+ return {
114
+ docs_url: value,
115
+ // The one thing a vendor's console asks for that this command knows and the
116
+ // operator cannot guess: the redirect URI is a loopback address on a port
117
+ // chosen per run, so a fixed port registered there will not match.
118
+ ...(app
119
+ ? {
120
+ steps: [
121
+ `Register an OAuth client at ${value}. Its redirect URI is a loopback address on a port ` +
122
+ 'chosen per run, which most authorization servers accept. One that matches the whole ' +
123
+ 'URL will refuse the grant with redirect_uri_mismatch — declare the URL it was given ' +
124
+ 'with --redirect-uri if so.',
125
+ ],
126
+ }
127
+ : {}),
128
+ };
129
+ }
130
+
131
+ /**
132
+ * How this provider will say whose account was authorised.
133
+ *
134
+ * Three transports implement `identify()` — imap, dav and fs — and for the first
135
+ * two it is also the credential check, since the answer is the name the *server
136
+ * accepted* rather than the one the operator typed. An mcp connector gets no
137
+ * block: the identity would be a tool call, and a guessed tool name turns a
138
+ * working connect into a failed probe. An http one gets a block only when both
139
+ * halves were given, because a URL with no field to read is not an identity.
140
+ */
141
+ export function identityBlock(answers: CustomAnswers): Record<string, unknown> | undefined {
142
+ if (answers.connector === 'imap' || answers.connector === 'dav' || answers.connector === 'fs') {
143
+ return { kind: 'connector' };
144
+ }
145
+
146
+ if (answers.connector !== 'http') return undefined;
147
+
148
+ const url = answers.values['identity-url'];
149
+ const field = answers.values['identity-field'];
150
+ if (typeof url !== 'string' || url.length === 0) return undefined;
151
+
152
+ if (typeof field !== 'string' || field.length === 0) {
153
+ throw new Error(
154
+ '--identity-url needs --identity-field: the probe is one GET, and the field is which value in ' +
155
+ 'the response names the account. Without it there is nothing to read out of the body.',
156
+ );
157
+ }
158
+
159
+ return { kind: 'http', url, field };
160
+ }