@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.
- 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/commands/mcp/list.ts +123 -29
- package/src/cli/identity.ts +29 -5
- package/src/cli/main.ts +42 -14
- 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 +45 -0
- package/src/providers/bunq/index.ts +87 -0
- package/src/providers/bunq/redact.ts +75 -0
- package/src/providers/bunq/specs/bunq.v1.json +883 -0
- package/src/providers/bunq/specs/vendor.ts +396 -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 +179 -0
- 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
|
+
}
|