@lanes-sh/link 0.2.1 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +21 -8
- package/instructions/skills/lanes-link/SKILL.md +59 -14
- package/package.json +1 -1
- package/src/auth/index.ts +3 -1
- package/src/auth/oauth/metadata.ts +83 -9
- package/src/auth/oauth/redirects.ts +70 -0
- package/src/auth/oauth/server.ts +49 -69
- package/src/auth/oauth/store.ts +19 -5
- package/src/cli/argv.ts +50 -0
- package/src/cli/brand.ts +178 -0
- package/src/cli/callback-page.ts +108 -128
- package/src/cli/commands/connect/accounts.ts +5 -0
- package/src/cli/commands/connect/assertion.ts +187 -0
- package/src/cli/commands/connect/authorise.ts +61 -17
- package/src/cli/commands/connect/client.ts +37 -9
- package/src/cli/commands/connect/discover.ts +94 -0
- package/src/cli/commands/connect/family.ts +72 -0
- package/src/cli/commands/connect/index.ts +113 -115
- package/src/cli/commands/connect/method.ts +237 -0
- package/src/cli/commands/connect/outcome.ts +42 -1
- package/src/cli/commands/connect/pasted-token.ts +66 -0
- package/src/cli/commands/connect/requirements.ts +60 -8
- package/src/cli/commands/connect/setup.ts +16 -5
- package/src/cli/commands/connect/target-note.ts +34 -0
- package/src/cli/commands/identity.ts +258 -0
- package/src/cli/commands/knowledge/index.ts +390 -0
- package/src/cli/commands/knowledge/migrate.ts +180 -0
- package/src/cli/commands/knowledge/setup.ts +144 -0
- package/src/cli/commands/knowledge.ts +10 -0
- package/src/cli/commands/mcp/harnesses.ts +16 -2
- package/src/cli/commands/mcp/register.ts +9 -1
- package/src/cli/commands/mcp/stdio.ts +21 -0
- package/src/cli/commands/operate/dashboard.ts +107 -0
- package/src/cli/commands/operate/findings.ts +151 -0
- package/src/cli/commands/operate/inspect.ts +56 -158
- package/src/cli/commands/operate/outputs.ts +38 -11
- package/src/cli/commands/operate/serve.ts +3 -0
- package/src/cli/commands/operate/token.ts +1 -1
- package/src/cli/commands/operate.ts +2 -0
- package/src/cli/commands/profile/declare.ts +154 -0
- package/src/cli/commands/profile/removal.ts +17 -0
- package/src/cli/commands/profile.ts +83 -35
- package/src/cli/commands/setup.ts +22 -6
- package/src/cli/commands/target.ts +65 -83
- package/src/cli/config-edit.ts +48 -144
- package/src/cli/config-repair.ts +186 -0
- package/src/cli/dashboard-page.ts +284 -0
- package/src/cli/dashboard-shell.ts +125 -0
- package/src/cli/identity.ts +12 -1
- package/src/cli/main.ts +68 -4
- package/src/cli/oauth-callback.ts +187 -0
- package/src/cli/oauth-exchange.ts +57 -15
- package/src/cli/oauth.ts +67 -177
- package/src/cli/output.ts +21 -5
- package/src/cli/provider-marks.ts +45 -0
- package/src/cli/runtime/open.ts +67 -49
- package/src/cli/runtime/registry.ts +60 -2
- package/src/cli/runtime/select.ts +26 -13
- package/src/cli/runtime/vault.ts +61 -0
- package/src/cli/runtime.ts +2 -1
- package/src/cli/selection.ts +357 -0
- package/src/cli/usage.ts +32 -9
- package/src/connectivity/auth/README.md +7 -1
- package/src/connectivity/auth/basic/index.ts +1 -1
- package/src/connectivity/auth/index.ts +14 -0
- package/src/connectivity/auth/oauth-authcode/broker.ts +26 -0
- package/src/connectivity/auth/oauth-authcode/index.ts +16 -2
- package/src/connectivity/auth/oauth-authcode/provider.ts +1 -1
- package/src/connectivity/auth/oauth-authcode/refresh.ts +3 -3
- package/src/connectivity/auth/oauth-jwt/README.md +33 -0
- package/src/connectivity/auth/oauth-jwt/index.ts +237 -0
- package/src/connectivity/auth/oauth-jwt/key.ts +148 -0
- package/src/connectivity/auth/resolve.ts +1 -1
- package/src/connectivity/auth/token.ts +11 -0
- package/src/connectivity/index.ts +2 -0
- package/src/connectivity/manifest/auth.ts +99 -2
- package/src/connectivity/manifest/identity.ts +12 -0
- package/src/connectivity/manifest/index.ts +3 -1
- package/src/connectivity/manifest/provider.ts +37 -8
- package/src/connectivity/manifest/requirements.ts +109 -6
- package/src/deployments/adapters/filesystem.ts +10 -1
- package/src/deployments/adapters/github-api.ts +106 -0
- package/src/deployments/adapters/github-commit.ts +103 -0
- package/src/deployments/adapters/github-repo.ts +356 -0
- package/src/deployments/adapters/github-testing.ts +258 -0
- package/src/deployments/adapters/github.ts +125 -0
- package/src/deployments/deploy.ts +48 -19
- package/src/deployments/driver.ts +8 -1
- package/src/deployments/gcp/driver.ts +9 -1
- package/src/deployments/gcp/survey.ts +3 -0
- package/src/deployments/knowledge.ts +119 -0
- package/src/deployments/prepare.ts +2 -2
- package/src/deployments/servable.ts +81 -0
- package/src/deployments/target.ts +3 -2
- package/src/deployments/upload.ts +2 -1
- package/src/dispatch/dispatch.ts +1 -1
- package/src/profile/authorization.ts +13 -4
- package/src/profile/identity.ts +60 -0
- package/src/profile/index.ts +15 -5
- package/src/profile/knowledge.ts +124 -0
- package/src/profile/load.ts +17 -5
- package/src/profile/primitives.ts +24 -1
- package/src/profile/schema.ts +72 -4
- package/src/profile/targets.ts +74 -114
- package/src/profile/workspace.ts +71 -84
- package/src/providers/google/calendar/index.ts +2 -0
- package/src/providers/google/contacts/index.ts +2 -0
- package/src/providers/google/docs/index.ts +2 -0
- package/src/providers/google/drive/index.ts +2 -0
- package/src/providers/google/gmail/index.ts +2 -0
- package/src/providers/google/gmail-imap/index.ts +125 -0
- package/src/providers/google/index.ts +2 -1
- package/src/providers/google/shared/oauth.ts +18 -6
- package/src/providers/google/shared/service-account.ts +110 -0
- package/src/providers/google/shared/setup.ts +5 -2
- package/src/providers/google/sheets/index.ts +2 -0
- package/src/providers/google/tasks/index.ts +2 -0
- package/src/providers/identity/provider.ts +166 -0
- package/src/providers/index.ts +3 -0
- package/src/providers/owner.ts +10 -2
- package/src/providers/scopes.ts +2 -0
- package/src/providers/setup/plan.ts +31 -9
- package/src/providers/setup/provider.ts +23 -0
- package/src/providers/slack/index.ts +81 -33
- package/src/providers/slack/oauth.ts +103 -0
- package/src/providers/slack/scopes.ts +37 -0
- package/src/server/container.ts +18 -1
- package/src/server/cors.ts +252 -0
- package/src/server/dashboard.ts +208 -0
- package/src/server/endpoint.ts +57 -4
- package/src/server/generation.ts +1 -0
- package/src/server/generations.ts +13 -2
- package/src/server/harness.ts +20 -3
- package/src/server/index.ts +76 -12
- package/src/server/mcp/build.ts +1 -1
- package/src/server/mcp/index.ts +1 -0
- package/src/server/mcp/instructions.ts +62 -5
- package/src/server/mcp/visibility.ts +42 -0
- package/src/stores/blobs/route.ts +123 -0
|
@@ -1,18 +1,22 @@
|
|
|
1
|
-
import { createMcpConnector } from '#connectivity/transports';
|
|
2
|
-
import { bearerTokenAsStored } from '#connectivity/auth/index.ts';
|
|
3
|
-
import type { DiscoveredCapability } from '#connectivity';
|
|
4
1
|
import { credentialRefForConnection, WRITE_BUNDLE } from '#connectivity';
|
|
5
|
-
import { ConfigDocument
|
|
6
|
-
import {
|
|
2
|
+
import { ConfigDocument } from '../../config-edit.ts';
|
|
3
|
+
import { ensureSetupConnection, repaired } from '../../config-repair.ts';
|
|
4
|
+
import { emit, print } from '../../output.ts';
|
|
7
5
|
import { nonInteractivePrompter, terminalPrompter, type Prompter } from '../../prompt.ts';
|
|
8
6
|
import { openRuntime, type GlobalFlags } from '../../runtime.ts';
|
|
9
|
-
import {
|
|
7
|
+
import { matchesRule, moveCredential, siblingAccountId } from './accounts.ts';
|
|
8
|
+
import { discoverCapabilities } from './discover.ts';
|
|
9
|
+
import { connectFamily, familyMembers } from './family.ts';
|
|
10
|
+
import { authoriseWithKey } from './assertion.ts';
|
|
10
11
|
import { authorise } from './authorise.ts';
|
|
12
|
+
import { authorisePastedToken } from './pasted-token.ts';
|
|
13
|
+
import { chooseAuthMethod } from './method.ts';
|
|
11
14
|
import { preflight } from './requirements.ts';
|
|
12
|
-
import { ALREADY, NOTHING, renderOutcome, type ConnectOutcome } from './outcome.ts';
|
|
15
|
+
import { ALREADY, NOTHING, renderOutcome, where, type ConnectOutcome } from './outcome.ts';
|
|
13
16
|
import { nextAfterEdit, publishRuntimeEdit } from '#cli/publish.ts';
|
|
14
17
|
import { ensureStaticCredential } from './setup.ts';
|
|
15
18
|
import { settleIdentity } from './settle.ts';
|
|
19
|
+
import { announceConnectTarget } from './target-note.ts';
|
|
16
20
|
|
|
17
21
|
/**
|
|
18
22
|
* `lanes link connect <provider>` — the one command that adds an account.
|
|
@@ -48,7 +52,23 @@ export interface ConnectOptions extends GlobalFlags {
|
|
|
48
52
|
* and then forgotten, which is the right shape for a decision about a client
|
|
49
53
|
* that is shared by every connection of that vendor.
|
|
50
54
|
*/
|
|
55
|
+
/**
|
|
56
|
+
* `--own-client`, the older spelling of one of the routes `--auth` now names.
|
|
57
|
+
*
|
|
58
|
+
* Kept because it is in scripts and in a year of documentation, and because
|
|
59
|
+
* it still says something true. It resolves to `--auth own_client`.
|
|
60
|
+
*/
|
|
51
61
|
readonly ownClient?: boolean | undefined;
|
|
62
|
+
/**
|
|
63
|
+
* `--auth <method>`: which way in, for a provider that offers more than one.
|
|
64
|
+
*
|
|
65
|
+
* Unset means ask, where there is somebody to ask and something to ask
|
|
66
|
+
* about. It is not sticky the way `--own-client` is: `--own-client` writes an
|
|
67
|
+
* `oauth_apps` entry that every connection of the vendor then reads, whereas
|
|
68
|
+
* this decides one connection's credential and is recorded by that credential
|
|
69
|
+
* existing. Two accounts on the same profile may honestly differ.
|
|
70
|
+
*/
|
|
71
|
+
readonly auth?: string | undefined;
|
|
52
72
|
/** Injected for tests. The broker is the only thing `connect` fetches. */
|
|
53
73
|
readonly fetch?: typeof globalThis.fetch | undefined;
|
|
54
74
|
readonly json?: boolean | undefined;
|
|
@@ -63,18 +83,6 @@ export interface ConnectOptions extends GlobalFlags {
|
|
|
63
83
|
*/
|
|
64
84
|
const PROVISIONAL_ID = 'pending';
|
|
65
85
|
|
|
66
|
-
/**
|
|
67
|
-
* What discovery is actually doing, per kind, so the wait is explained.
|
|
68
|
-
*
|
|
69
|
-
* They differ enough to be worth saying: reading a local OpenAPI file is
|
|
70
|
-
* instant, while signing in to an IMAP server is a TLS handshake and a LOGIN
|
|
71
|
-
* against a host that sometimes takes its time.
|
|
72
|
-
*/
|
|
73
|
-
const DISCOVERY_NOTE: Record<string, string> = {
|
|
74
|
-
mcp: 'Discovering capabilities…',
|
|
75
|
-
http: 'Reading the API description…',
|
|
76
|
-
};
|
|
77
|
-
|
|
78
86
|
export async function connect(target: string, options: ConnectOptions): Promise<void> {
|
|
79
87
|
const outcome = await runConnect(target, options);
|
|
80
88
|
|
|
@@ -83,7 +91,12 @@ export async function connect(target: string, options: ConnectOptions): Promise<
|
|
|
83
91
|
return emit(options.json, outcome, () => renderOutcome(outcome));
|
|
84
92
|
}
|
|
85
93
|
|
|
86
|
-
async function runConnect(
|
|
94
|
+
async function runConnect(
|
|
95
|
+
target: string,
|
|
96
|
+
options: ConnectOptions,
|
|
97
|
+
/** A family member — the account this belongs to has already said where it goes. */
|
|
98
|
+
announced = false,
|
|
99
|
+
): Promise<ConnectOutcome> {
|
|
87
100
|
const separator = target.indexOf('.');
|
|
88
101
|
const providerId = separator === -1 ? target : target.slice(0, separator);
|
|
89
102
|
const namedId = separator === -1 ? undefined : target.slice(separator + 1);
|
|
@@ -93,52 +106,29 @@ async function runConnect(target: string, options: ConnectOptions): Promise<Conn
|
|
|
93
106
|
const runtime = await openRuntime(options);
|
|
94
107
|
|
|
95
108
|
try {
|
|
109
|
+
// After the runtime rather than before, like every other command that
|
|
110
|
+
// announces: the alternative is resolving the profile twice, which for a
|
|
111
|
+
// `gs://` workspace is a second network read of the same YAML. Still long
|
|
112
|
+
// before the browser opens, which is the part that matters. Inside the
|
|
113
|
+
// `try` so the `finally` closes the runtime if the rendering throws.
|
|
114
|
+
if (!announced) announceConnectTarget(runtime, options.json);
|
|
115
|
+
|
|
96
116
|
const registry = runtime.registry;
|
|
97
117
|
const entry = registry.get(providerId);
|
|
98
118
|
|
|
99
|
-
//
|
|
100
|
-
//
|
|
101
|
-
// Everyone models iCloud this way: Apple's own Settings, macOS Internet
|
|
102
|
-
// Accounts, Thunderbird, DAVx⁵. One authorisation, three services. It is
|
|
103
|
-
// three *providers* underneath because mail and calendars are different
|
|
104
|
-
// protocols, and because a policy line per provider is what lets someone
|
|
105
|
-
// allow `icloud_calendar.*` while never granting mail — but nobody should
|
|
106
|
-
// have to know that to connect their account.
|
|
119
|
+
// One name, several providers. `family.ts` says why iCloud is three.
|
|
107
120
|
if (!entry) {
|
|
108
|
-
const
|
|
109
|
-
.list()
|
|
110
|
-
.filter((candidate) => credentialApp(candidate.manifest) === providerId)
|
|
111
|
-
.map((candidate) => candidate.manifest.id);
|
|
121
|
+
const members = familyMembers(registry, providerId);
|
|
112
122
|
|
|
113
|
-
if (
|
|
123
|
+
if (members.length > 1) {
|
|
114
124
|
await runtime.close();
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
);
|
|
118
|
-
// In sequence, and the order matters: the first settles the account id
|
|
119
|
-
// and stores the credential, and the rest find both already there.
|
|
120
|
-
//
|
|
121
|
-
// The id travels as a flag because the family members are addressed by
|
|
122
|
-
// their own names: `connect icloud.will` parses `will` off a target
|
|
123
|
-
// that is then thrown away, and recursing with `options` alone dropped
|
|
124
|
-
// it — the command named an account and each member silently invented
|
|
125
|
-
// its own.
|
|
126
|
-
const inherited = { ...options, id: options.id ?? namedId };
|
|
127
|
-
const members: ConnectOutcome[] = [];
|
|
128
|
-
for (const member of family) members.push(await runConnect(member, inherited));
|
|
129
|
-
|
|
130
|
-
// The whole account succeeded only if every service did. A partial
|
|
131
|
-
// result is the case worth surfacing: one member blocked on a value
|
|
132
|
-
// leaves an account half connected, which `status` shows and prose does
|
|
133
|
-
// not.
|
|
134
|
-
return {
|
|
135
|
-
...NOTHING,
|
|
136
|
-
ok: members.every((outcome) => outcome.ok),
|
|
125
|
+
return connectFamily({
|
|
126
|
+
name: providerId,
|
|
137
127
|
members,
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
};
|
|
128
|
+
options,
|
|
129
|
+
namedId,
|
|
130
|
+
connect: runConnect,
|
|
131
|
+
});
|
|
142
132
|
}
|
|
143
133
|
}
|
|
144
134
|
|
|
@@ -175,7 +165,26 @@ async function runConnect(target: string, options: ConnectOptions): Promise<Conn
|
|
|
175
165
|
|
|
176
166
|
const profile = runtime.resolution.profile;
|
|
177
167
|
|
|
178
|
-
|
|
168
|
+
const prompter: Prompter =
|
|
169
|
+
options.nonInteractive === true
|
|
170
|
+
? nonInteractivePrompter(`lanes link setup plan ${providerId} --profile ${profile}`)
|
|
171
|
+
: terminalPrompter;
|
|
172
|
+
|
|
173
|
+
// 0a. Which way in, before anything is resolved or written.
|
|
174
|
+
//
|
|
175
|
+
// Ahead of the preflight because it changes the answer: one route needs
|
|
176
|
+
// a browser and the other needs a key, and refusing a scripted run for
|
|
177
|
+
// want of a browser it was never going to open is a refusal about the
|
|
178
|
+
// wrong thing. Inert for every provider declaring one method, which is
|
|
179
|
+
// all of them but one vendor's.
|
|
180
|
+
const method = await chooseAuthMethod({
|
|
181
|
+
manifest,
|
|
182
|
+
requested: options.auth,
|
|
183
|
+
ownClient: options.ownClient === true,
|
|
184
|
+
prompter,
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
// 0b. With nobody to ask, resolve everything up front or refuse saying why.
|
|
179
188
|
//
|
|
180
189
|
// Before any write, so a refusal leaves the profile exactly as it was.
|
|
181
190
|
// The whole list comes back at once: discovering a missing value a
|
|
@@ -185,19 +194,36 @@ async function runConnect(target: string, options: ConnectOptions): Promise<Conn
|
|
|
185
194
|
manifest,
|
|
186
195
|
connectionId: named ?? adopted,
|
|
187
196
|
profile,
|
|
197
|
+
target: runtime.target,
|
|
188
198
|
credentials: runtime.credentials,
|
|
189
|
-
target,
|
|
199
|
+
spec: target,
|
|
200
|
+
method: method.kind,
|
|
190
201
|
});
|
|
191
202
|
|
|
192
203
|
if (blocked) return { ...NOTHING, ok: false, ...blocked };
|
|
193
204
|
}
|
|
194
205
|
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
:
|
|
199
|
-
|
|
200
|
-
|
|
206
|
+
if (method.kind === 'assertion') {
|
|
207
|
+
await authoriseWithKey({
|
|
208
|
+
manifest,
|
|
209
|
+
assertion: method.assertion,
|
|
210
|
+
connectionId: provisionalId,
|
|
211
|
+
credentials: runtime.credentials,
|
|
212
|
+
changes,
|
|
213
|
+
// Same reading as the static-credential arm below: naming a connection,
|
|
214
|
+
// or asking outright, is how someone says "that one again" — which is
|
|
215
|
+
// what a rotated key calls for.
|
|
216
|
+
replace: options.nonInteractive !== true && (options.replace === true || named !== undefined),
|
|
217
|
+
prompter,
|
|
218
|
+
});
|
|
219
|
+
} else if (method.kind === 'pasted') {
|
|
220
|
+
await authorisePastedToken({
|
|
221
|
+
manifest,
|
|
222
|
+
connectionId: provisionalId,
|
|
223
|
+
credentials: runtime.credentials,
|
|
224
|
+
prompter,
|
|
225
|
+
});
|
|
226
|
+
} else if (manifest.auth.kind === 'oauth') {
|
|
201
227
|
await authorise({
|
|
202
228
|
manifest,
|
|
203
229
|
connectionId: provisionalId,
|
|
@@ -207,7 +233,7 @@ async function runConnect(target: string, options: ConnectOptions): Promise<Conn
|
|
|
207
233
|
firstForProvider: !runtime.config.connections.some((c) => c.provider === providerId),
|
|
208
234
|
target,
|
|
209
235
|
profile,
|
|
210
|
-
|
|
236
|
+
client: method.client,
|
|
211
237
|
prompter,
|
|
212
238
|
acceptBroadScopes: options.acceptBroadScopes === true,
|
|
213
239
|
...(options.fetch ? { fetch: options.fetch } : {}),
|
|
@@ -257,52 +283,18 @@ async function runConnect(target: string, options: ConnectOptions): Promise<Conn
|
|
|
257
283
|
if (from && to && from !== to) await moveCredential(runtime.credentials, from, to);
|
|
258
284
|
}
|
|
259
285
|
|
|
260
|
-
// 3. Ask the upstream what it exposes.
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
const connector =
|
|
273
|
-
manifest.connector.kind === 'mcp'
|
|
274
|
-
? createMcpConnector({
|
|
275
|
-
endpoint: manifest.connector.endpoint,
|
|
276
|
-
...(manifest.connector.headers ? { headers: manifest.connector.headers } : {}),
|
|
277
|
-
accessToken: () =>
|
|
278
|
-
bearerTokenAsStored(manifest, connectionId, runtime.credentials),
|
|
279
|
-
})
|
|
280
|
-
: runtime.connectorFor(providerId, connectionId);
|
|
281
|
-
|
|
282
|
-
if (connector) {
|
|
283
|
-
// Discovery takes the manifest and nothing else. What a provider exposes
|
|
284
|
-
// is a property of the provider, not of an account — which is just as
|
|
285
|
-
// well, because the connection being created does not exist in config
|
|
286
|
-
// until step 4 below.
|
|
287
|
-
discovered = await connector.discover({ manifest });
|
|
288
|
-
|
|
289
|
-
await runtime.state.kv.set('discovery', providerId, JSON.stringify(discovered));
|
|
290
|
-
registry.setDiscovered(providerId, discovered);
|
|
291
|
-
}
|
|
292
|
-
} else if (entry.definition) {
|
|
293
|
-
// Local capabilities carry their bundle from the manifest. Resources are
|
|
294
|
-
// included alongside tools: they need a policy grant too, and leaving
|
|
295
|
-
// them out would register a resource nothing is allowed to read.
|
|
296
|
-
const bundleOf = (name: string): string | undefined =>
|
|
297
|
-
manifest.bundles?.find((candidate) => candidate.capabilities.includes(name))?.name;
|
|
298
|
-
|
|
299
|
-
discovered = entry.definition.capabilities.map((capability) => ({
|
|
300
|
-
name: capability.name,
|
|
301
|
-
description: capability.description,
|
|
302
|
-
inputSchema: {},
|
|
303
|
-
...(bundleOf(capability.name) ? { bundle: bundleOf(capability.name)! } : {}),
|
|
304
|
-
}));
|
|
305
|
-
}
|
|
286
|
+
// 3. Ask the upstream what it exposes.
|
|
287
|
+
const discovered = await discoverCapabilities({
|
|
288
|
+
entry,
|
|
289
|
+
manifest,
|
|
290
|
+
connectionId,
|
|
291
|
+
credentials: runtime.credentials,
|
|
292
|
+
connectorFor: runtime.connectorFor.bind(runtime),
|
|
293
|
+
remember: async (found) => {
|
|
294
|
+
await runtime.state.kv.set('discovery', providerId, JSON.stringify(found));
|
|
295
|
+
registry.setDiscovered(providerId, found);
|
|
296
|
+
},
|
|
297
|
+
});
|
|
306
298
|
|
|
307
299
|
// 4. Declare the connection, or update the one this account already has.
|
|
308
300
|
const existingIndex = runtime.config.connections.findIndex(
|
|
@@ -323,7 +315,11 @@ async function runConnect(target: string, options: ConnectOptions): Promise<Conn
|
|
|
323
315
|
document.setIn(['connections', existingIndex, 'account'], account);
|
|
324
316
|
changes.push(`connections.${connectionKey}.account = ${account}`);
|
|
325
317
|
}
|
|
326
|
-
|
|
318
|
+
// Named where the provider offered a choice, because this is the line an
|
|
319
|
+
// operator reads to see that a re-connect swapped the route rather than
|
|
320
|
+
// refreshed it — and `--auth` reaches here having asked nothing. Unnamed
|
|
321
|
+
// for a provider with one way in, whose output is unchanged.
|
|
322
|
+
changes.push(`re-authorised ${connectionKey}${method.id ? ` with ${method.id}` : ''}`);
|
|
327
323
|
}
|
|
328
324
|
|
|
329
325
|
// 5. Grant it.
|
|
@@ -369,6 +365,7 @@ async function runConnect(target: string, options: ConnectOptions): Promise<Conn
|
|
|
369
365
|
ok: true,
|
|
370
366
|
key: connectionKey,
|
|
371
367
|
account,
|
|
368
|
+
...where(runtime),
|
|
372
369
|
discovered: discovered.length,
|
|
373
370
|
next: ALREADY,
|
|
374
371
|
};
|
|
@@ -384,6 +381,7 @@ async function runConnect(target: string, options: ConnectOptions): Promise<Conn
|
|
|
384
381
|
ok: true,
|
|
385
382
|
key: connectionKey,
|
|
386
383
|
account,
|
|
384
|
+
...where(runtime),
|
|
387
385
|
changes,
|
|
388
386
|
granted,
|
|
389
387
|
...(notes.length > 0 ? { notes } : {}),
|
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
import { hasOwnClientPath, type AuthAssertion, type ProviderManifest } from '#connectivity';
|
|
2
|
+
import { progress, style } from '../../output.ts';
|
|
3
|
+
import { terminalPrompter, type Prompter } from '../../prompt.ts';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Which way in, where a provider offers more than one.
|
|
7
|
+
*
|
|
8
|
+
* Two questions that used to be asked in two different places and are one
|
|
9
|
+
* question to the person answering: what kind of credential, and — for the
|
|
10
|
+
* browser — whose OAuth client. The second was a flag, `--own-client`, which is
|
|
11
|
+
* to say it was a choice nobody discovered unless they already knew it existed.
|
|
12
|
+
*
|
|
13
|
+
* Most providers offer exactly one route and this file is inert for them:
|
|
14
|
+
* `options` returns a single entry, nothing is printed, and nothing is asked.
|
|
15
|
+
* That is the property worth protecting — adding routes to Google must not put
|
|
16
|
+
* a question, or a warning, in front of somebody connecting GitHub.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
export type ChosenMethod =
|
|
20
|
+
| {
|
|
21
|
+
readonly kind: 'assertion';
|
|
22
|
+
/** How `--auth` spells this route, for reporting what the connection became. */
|
|
23
|
+
readonly id: string;
|
|
24
|
+
readonly assertion: AuthAssertion;
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* The browser, and which client the exchange runs through.
|
|
28
|
+
*
|
|
29
|
+
* `undefined` means "whatever this profile already decided", which is the
|
|
30
|
+
* precedence `resolveOAuthClient` has always applied: a declared `oauth_apps`
|
|
31
|
+
* entry wins, otherwise the broker. It is what a provider with only one
|
|
32
|
+
* browser route resolves to, so nothing about those changes.
|
|
33
|
+
*
|
|
34
|
+
* `id` is unset for exactly those synthesised cases — `--auth oauth`,
|
|
35
|
+
* `--own-client`, and a provider with one route — because there was no choice
|
|
36
|
+
* to report. A provider that never offered two reads as it always did.
|
|
37
|
+
*/
|
|
38
|
+
| { readonly kind: 'oauth'; readonly id?: string; readonly client: 'own' | 'hosted' | undefined }
|
|
39
|
+
/**
|
|
40
|
+
* A credential the operator already holds, for a provider that does OAuth.
|
|
41
|
+
*
|
|
42
|
+
* Offered where an OAuth manifest still declares a per-connection prompt,
|
|
43
|
+
* which is a thing to be only where the browser route can be closed by
|
|
44
|
+
* somebody who is not in the room: a Slack workspace on Enterprise Grid needs
|
|
45
|
+
* an admin to approve an app before it can authenticate anyone, and the
|
|
46
|
+
* person running `connect` may not be that admin.
|
|
47
|
+
*/
|
|
48
|
+
| { readonly kind: 'pasted'; readonly id?: string };
|
|
49
|
+
|
|
50
|
+
interface Option {
|
|
51
|
+
/** What `--auth` accepts, and how a chosen route is named back. */
|
|
52
|
+
readonly id: string;
|
|
53
|
+
readonly label: string;
|
|
54
|
+
readonly detail: string;
|
|
55
|
+
readonly chosen: ChosenMethod;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Every route this provider actually has, in the order they are offered.
|
|
61
|
+
*
|
|
62
|
+
* The key first, because it is the one that removes a recurring chore and the
|
|
63
|
+
* one nobody would guess at. Then the hosted client, which is the default and
|
|
64
|
+
* the thing "as it works today" means. Then a client of your own, which is
|
|
65
|
+
* twenty minutes in a console and is what an organisation forbidding
|
|
66
|
+
* third-party clients needs.
|
|
67
|
+
*/
|
|
68
|
+
export function options(manifest: ProviderManifest): readonly Option[] {
|
|
69
|
+
if (manifest.auth.kind !== 'oauth') return [];
|
|
70
|
+
|
|
71
|
+
const { assertion, broker } = manifest.auth;
|
|
72
|
+
const found: Option[] = [];
|
|
73
|
+
|
|
74
|
+
if (assertion) {
|
|
75
|
+
found.push({
|
|
76
|
+
id: assertion.method,
|
|
77
|
+
label: assertion.label,
|
|
78
|
+
detail: assertion.reach,
|
|
79
|
+
chosen: { kind: 'assertion', id: assertion.method, assertion },
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
if (broker) {
|
|
84
|
+
found.push({
|
|
85
|
+
id: 'hosted_client',
|
|
86
|
+
label: `Sign in through a browser, using the OAuth client ${broker.operator} operates`,
|
|
87
|
+
detail:
|
|
88
|
+
'nothing to register and no client secret on this machine. The exchange is performed by ' +
|
|
89
|
+
`${broker.operator}, and the connection is re-authorised whenever its token expires.`,
|
|
90
|
+
chosen: { kind: 'oauth', id: 'hosted_client', client: 'hosted' },
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
if (!broker || hasOwnClientPath(manifest)) {
|
|
95
|
+
found.push({
|
|
96
|
+
id: 'own_client',
|
|
97
|
+
label: 'Sign in through a browser, using an OAuth client you register',
|
|
98
|
+
detail: broker
|
|
99
|
+
? 'a console walkthrough once per profile, after which nothing leaves this machine but ' +
|
|
100
|
+
'the browser. What an organisation that forbids third-party clients needs.'
|
|
101
|
+
: 'the whole account, and the connection is re-authorised whenever its token expires.',
|
|
102
|
+
// Undefined `client` rather than 'own' where it is the only browser route:
|
|
103
|
+
// there is nothing to override, and forcing it would write an `oauth_apps`
|
|
104
|
+
// entry for a provider whose manifest already says it is the only way.
|
|
105
|
+
chosen: { kind: 'oauth', id: 'own_client', client: broker ? 'own' : undefined },
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// Last, always. It is the way in when the others are refused, not one anyone
|
|
110
|
+
// should be reaching for first: what it stores is the credential itself
|
|
111
|
+
// rather than a means of obtaining one, so rotating it is manual, and nothing
|
|
112
|
+
// can show what it is allowed to do.
|
|
113
|
+
const pasted = (manifest.setup?.prompts ?? []).filter((prompt) => prompt.scope === 'connection');
|
|
114
|
+
if (pasted.length > 0) {
|
|
115
|
+
found.push({
|
|
116
|
+
id: 'pasted_token',
|
|
117
|
+
label: `Paste a ${pasted.map((prompt) => prompt.label).join(', then ')} you already hold`,
|
|
118
|
+
detail:
|
|
119
|
+
'no browser, for a workspace that has not approved this app — which an admin decides, ' +
|
|
120
|
+
'not you. The credential is stored as given, so rotating it is manual and nothing can ' +
|
|
121
|
+
'say what it is allowed to do.',
|
|
122
|
+
chosen: { kind: 'pasted', id: 'pasted_token' },
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
return found;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** What `--auth` will accept for this provider, for a message that lists them. */
|
|
130
|
+
export function methodsFor(manifest: ProviderManifest): readonly string[] {
|
|
131
|
+
return options(manifest).map((option) => option.id);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Choose, from the flag or from the operator — and from nothing else.
|
|
136
|
+
*
|
|
137
|
+
* Nothing is inferred from what this account authenticates with today, which is
|
|
138
|
+
* a decision rather than an omission. A connection authenticates one way at a
|
|
139
|
+
* time: whichever route this run picks replaces the credential the account has
|
|
140
|
+
* now, and re-running `connect` is how somebody switches. Defaulting to the
|
|
141
|
+
* stored route would mean reading a credential to answer a question that is the
|
|
142
|
+
* operator's on every run, and would hide the replacement behind a default that
|
|
143
|
+
* reads as a no-op.
|
|
144
|
+
*
|
|
145
|
+
* It used to try. The route was read from the *provisional* connection id,
|
|
146
|
+
* which is `pending` until identity is settled — so it found nothing every
|
|
147
|
+
* time, fell back to the browser, and performed the silent swap it existed to
|
|
148
|
+
* prevent on anyone who pressed Enter. Saying what the choice does is the part
|
|
149
|
+
* that was actually missing.
|
|
150
|
+
*/
|
|
151
|
+
export async function chooseAuthMethod(input: {
|
|
152
|
+
readonly manifest: ProviderManifest;
|
|
153
|
+
/** `--auth <method>`, if it was given. */
|
|
154
|
+
readonly requested: string | undefined;
|
|
155
|
+
/** `--own-client`, which is the older spelling of one of these. */
|
|
156
|
+
readonly ownClient?: boolean;
|
|
157
|
+
readonly prompter?: Prompter;
|
|
158
|
+
}): Promise<ChosenMethod> {
|
|
159
|
+
const { manifest, requested } = input;
|
|
160
|
+
const prompter = input.prompter ?? terminalPrompter;
|
|
161
|
+
const available = options(manifest);
|
|
162
|
+
|
|
163
|
+
if (requested !== undefined) {
|
|
164
|
+
// `oauth` is not an option id. It is the older, coarser spelling — "the
|
|
165
|
+
// browser, however this profile already resolves it" — and dropping it
|
|
166
|
+
// would break a scripted `--auth oauth` for no gain.
|
|
167
|
+
if (requested === 'oauth') return { kind: 'oauth', client: undefined };
|
|
168
|
+
|
|
169
|
+
const picked = available.find((option) => option.id === requested);
|
|
170
|
+
if (picked) return picked.chosen;
|
|
171
|
+
|
|
172
|
+
throw new Error(
|
|
173
|
+
`${manifest.name} cannot authenticate with "${requested}". ` +
|
|
174
|
+
`--auth accepts: ${['oauth', ...methodsFor(manifest)].join(', ')}.`,
|
|
175
|
+
);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
if (input.ownClient === true) return { kind: 'oauth', client: 'own' };
|
|
179
|
+
|
|
180
|
+
// One route, or none this file knows about: decide nothing and say nothing.
|
|
181
|
+
if (available.length < 2) return { kind: 'oauth', client: undefined };
|
|
182
|
+
|
|
183
|
+
// Nobody to ask. The flag above is the non-interactive answer, deliberately —
|
|
184
|
+
// guessing picks which credential gets overwritten (ADR-038).
|
|
185
|
+
if (!prompter.interactive) return { kind: 'oauth', client: undefined };
|
|
186
|
+
|
|
187
|
+
return ask(manifest, available, prompter);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
async function ask(
|
|
191
|
+
manifest: ProviderManifest,
|
|
192
|
+
available: readonly Option[],
|
|
193
|
+
prompter: Prompter,
|
|
194
|
+
): Promise<ChosenMethod> {
|
|
195
|
+
// "As it works today" for anyone who has not chosen otherwise: the hosted
|
|
196
|
+
// client where there is one, and otherwise the browser. Never the key — it is
|
|
197
|
+
// listed first because it is the one worth knowing about, and defaulting to
|
|
198
|
+
// the first entry would make Enter mean "the route with a console visit in
|
|
199
|
+
// it" for someone who was not reading.
|
|
200
|
+
const hosted = available.findIndex((option) => option.id === 'hosted_client');
|
|
201
|
+
const fallback =
|
|
202
|
+
hosted !== -1 ? hosted : Math.max(available.findIndex((option) => option.chosen.kind === 'oauth'), 0);
|
|
203
|
+
const preferred = String(fallback + 1);
|
|
204
|
+
|
|
205
|
+
progress();
|
|
206
|
+
progress(style.bold(`${manifest.name} can authenticate ${count(available.length)} ways`));
|
|
207
|
+
progress();
|
|
208
|
+
for (const [index, option] of available.entries()) {
|
|
209
|
+
progress(` ${index + 1}. ${option.label}`);
|
|
210
|
+
progress(style.dim(` ${option.detail}`));
|
|
211
|
+
}
|
|
212
|
+
progress();
|
|
213
|
+
// Printed whether or not this account is already connected, because it is a
|
|
214
|
+
// statement about what the command does rather than a reading of what is
|
|
215
|
+
// stored — and on a first connect it is true with nothing to replace.
|
|
216
|
+
progress(style.dim(' Whichever you pick becomes the only way in for this account. It replaces'));
|
|
217
|
+
progress(
|
|
218
|
+
style.dim(' whatever is stored for it now — a connection authenticates one way at a time.'),
|
|
219
|
+
);
|
|
220
|
+
progress();
|
|
221
|
+
|
|
222
|
+
const answer = await prompter.ask(` Which ${style.dim(`[${preferred}]`)}`);
|
|
223
|
+
const picked = answer.length === 0 ? preferred : answer;
|
|
224
|
+
|
|
225
|
+
const byNumber = available[Number(picked) - 1];
|
|
226
|
+
if (/^\d+$/.test(picked) && byNumber) return byNumber.chosen;
|
|
227
|
+
|
|
228
|
+
const byName = available.find((option) => option.id === picked);
|
|
229
|
+
if (byName) return byName.chosen;
|
|
230
|
+
|
|
231
|
+
throw new Error(
|
|
232
|
+
`"${picked}" is not one of the choices. Answer 1 to ${available.length}.`,
|
|
233
|
+
);
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
const WORDS = ['no', 'one', 'two', 'three', 'four', 'five'];
|
|
237
|
+
const count = (total: number): string => WORDS[total] ?? String(total);
|
|
@@ -17,6 +17,10 @@ export interface ConnectOutcome {
|
|
|
17
17
|
readonly ok: boolean;
|
|
18
18
|
readonly key?: string;
|
|
19
19
|
readonly account?: string;
|
|
20
|
+
/** The profile written to, for a caller that cannot see the announce line. */
|
|
21
|
+
readonly profile?: string;
|
|
22
|
+
/** The target written to — which credential store now holds this account. */
|
|
23
|
+
readonly target?: string;
|
|
20
24
|
readonly changes: readonly string[];
|
|
21
25
|
readonly granted: readonly string[];
|
|
22
26
|
/**
|
|
@@ -45,6 +49,35 @@ export const NOTHING = { changes: [], granted: [], discovered: 0 } as const;
|
|
|
45
49
|
|
|
46
50
|
export const ALREADY = 'Already connected — nothing changed.';
|
|
47
51
|
|
|
52
|
+
/**
|
|
53
|
+
* One result for an account that turned out to be several services.
|
|
54
|
+
*
|
|
55
|
+
* The whole account succeeded only if every service did. A partial result is the
|
|
56
|
+
* case worth surfacing: one member blocked on a value leaves an account half
|
|
57
|
+
* connected, which `status` shows and prose does not.
|
|
58
|
+
*/
|
|
59
|
+
export function familyOutcome(members: readonly ConnectOutcome[]): ConnectOutcome {
|
|
60
|
+
const blocked = members.find((outcome) => !outcome.ok);
|
|
61
|
+
|
|
62
|
+
return {
|
|
63
|
+
...NOTHING,
|
|
64
|
+
ok: members.every((outcome) => outcome.ok),
|
|
65
|
+
members,
|
|
66
|
+
...(blocked?.reason ? { reason: blocked.reason } : {}),
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Which store an account landed in, for the caller not reading the terminal.
|
|
72
|
+
*
|
|
73
|
+
* `announceConnectTarget` fixes the human channel. A `--json` caller had the
|
|
74
|
+
* same blindness the operator did — and an agent is exactly the reader who
|
|
75
|
+
* cannot see the line printed above it.
|
|
76
|
+
*/
|
|
77
|
+
export function where(runtime: { resolution: { profile: string; target: string } }) {
|
|
78
|
+
return { profile: runtime.resolution.profile, target: runtime.resolution.target };
|
|
79
|
+
}
|
|
80
|
+
|
|
48
81
|
/**
|
|
49
82
|
* What `connect` says last.
|
|
50
83
|
*
|
|
@@ -101,7 +134,15 @@ export function renderOutcome(outcome: ConnectOutcome): void {
|
|
|
101
134
|
*/
|
|
102
135
|
function renderBlocked(outcome: ConnectOutcome): void {
|
|
103
136
|
progress();
|
|
104
|
-
print(
|
|
137
|
+
print(
|
|
138
|
+
fail(
|
|
139
|
+
outcome.reason === 'needs_browser'
|
|
140
|
+
? 'a browser is needed'
|
|
141
|
+
: outcome.reason === 'needs_terminal'
|
|
142
|
+
? 'a terminal is needed'
|
|
143
|
+
: 'more is needed first',
|
|
144
|
+
),
|
|
145
|
+
);
|
|
105
146
|
|
|
106
147
|
for (const line of (outcome.message ?? '').split('\n')) print(` ${line}`);
|
|
107
148
|
|