@lanes-sh/link 0.2.2 → 0.3.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 +22 -8
- package/instructions/skills/lanes-link/SKILL.md +42 -14
- package/package.json +1 -1
- 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 +63 -164
- package/src/cli/commands/operate/outputs.ts +38 -11
- package/src/cli/commands/operate/policy.ts +7 -7
- package/src/cli/commands/operate/serve.ts +3 -0
- package/src/cli/commands/operate/status.ts +108 -1
- 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/remove.ts +5 -5
- package/src/cli/commands/profile.ts +83 -35
- package/src/cli/commands/secrets.ts +6 -6
- package/src/cli/commands/setup.ts +22 -6
- package/src/cli/commands/sync.ts +262 -0
- package/src/cli/commands/target.ts +65 -83
- package/src/cli/config-edit.ts +53 -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/dispatch-owner.ts +93 -0
- package/src/cli/identity.ts +12 -1
- package/src/cli/main.ts +90 -61
- package/src/cli/nearest.ts +45 -0
- 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 +74 -51
- 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 +365 -0
- package/src/cli/usage.ts +42 -11
- 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 +94 -114
- package/src/deployments/discover.ts +103 -0
- package/src/deployments/driver.ts +8 -1
- package/src/deployments/gcp/driver.ts +3 -1
- package/src/deployments/knowledge.ts +119 -0
- package/src/deployments/prepare.ts +12 -6
- package/src/deployments/report.ts +117 -0
- package/src/deployments/servable.ts +82 -0
- package/src/deployments/serving.ts +165 -0
- package/src/deployments/sync-apply.ts +276 -0
- package/src/deployments/sync.ts +136 -0
- package/src/deployments/target.ts +3 -2
- package/src/deployments/upload.ts +19 -12
- package/src/dispatch/dispatch.ts +1 -1
- package/src/profile/deployments.ts +80 -0
- package/src/profile/identity.ts +60 -0
- package/src/profile/index.ts +23 -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 +81 -3
- package/src/profile/targets.ts +122 -109
- package/src/profile/workspace.ts +139 -79
- 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 +21 -3
- 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 +45 -1
- package/src/server/generations.ts +11 -2
- package/src/server/harness.ts +7 -0
- package/src/server/index.ts +45 -6
- package/src/server/mcp/index.ts +1 -0
- package/src/server/mcp/instructions.ts +28 -1
- package/src/server/mcp/visibility.ts +33 -0
- package/src/stores/blobs/route.ts +123 -0
|
@@ -5,7 +5,7 @@ import {
|
|
|
5
5
|
brokerOriginOverride,
|
|
6
6
|
type BrokerConfig,
|
|
7
7
|
} from '#connectivity/auth/index.ts';
|
|
8
|
-
import type
|
|
8
|
+
import { hasOwnClientPath, type ProviderManifest } from '#connectivity';
|
|
9
9
|
import type { SecretStore } from '#secrets';
|
|
10
10
|
import { ConfigDocument } from '../../config-edit.ts';
|
|
11
11
|
import { progress, style, warn } from '../../output.ts';
|
|
@@ -38,8 +38,15 @@ export interface ClientChoice {
|
|
|
38
38
|
readonly changes: string[];
|
|
39
39
|
/** No connection of this provider exists yet, so its console setup is undone. */
|
|
40
40
|
readonly firstForProvider: boolean;
|
|
41
|
-
/**
|
|
42
|
-
|
|
41
|
+
/**
|
|
42
|
+
* Which client, when the operator said which.
|
|
43
|
+
*
|
|
44
|
+
* `undefined` is the precedence this file has always applied and is what a
|
|
45
|
+
* provider with one browser route resolves to: a declared `oauth_apps` entry
|
|
46
|
+
* wins, otherwise the broker. The two explicit values come from the choice
|
|
47
|
+
* `connect` now prints, and they override that precedence in both directions.
|
|
48
|
+
*/
|
|
49
|
+
readonly client: 'own' | 'hosted' | undefined;
|
|
43
50
|
/** How the operator spelled the target, so a refusal names a command they typed. */
|
|
44
51
|
readonly target: string;
|
|
45
52
|
readonly profile: string;
|
|
@@ -54,9 +61,31 @@ export async function resolveOAuthClient(input: ClientChoice): Promise<OAuthClie
|
|
|
54
61
|
}
|
|
55
62
|
|
|
56
63
|
const { app, broker } = manifest.auth;
|
|
64
|
+
const ownClient = input.client === 'own';
|
|
65
|
+
// Hoisted: four of the branches below need the answer, and it reads the store.
|
|
66
|
+
const hasOwnClient = await profileHasOwnClient(app, document, credentials);
|
|
67
|
+
|
|
68
|
+
// Asked for outright, on a profile that registered a client of its own.
|
|
69
|
+
//
|
|
70
|
+
// Honoured rather than overruled, which is a change: the entry used to be
|
|
71
|
+
// final. It can be honoured safely because which client minted a token is
|
|
72
|
+
// stamped on the token — so this connection refreshes against the broker
|
|
73
|
+
// while every existing one keeps refreshing where it was issued. What it must
|
|
74
|
+
// not be is silent, because the profile's other connections do not move.
|
|
75
|
+
const insteadOfOwn = input.client === 'hosted' && broker !== undefined && hasOwnClient;
|
|
76
|
+
|
|
77
|
+
if (insteadOfOwn) {
|
|
78
|
+
progress(
|
|
79
|
+
style.dim(
|
|
80
|
+
`This profile has an OAuth client of its own, and this connection is being authorised ` +
|
|
81
|
+
`against ${broker.operator}'s instead. Existing connections are unaffected — they keep ` +
|
|
82
|
+
`refreshing against the client that issued them.`,
|
|
83
|
+
),
|
|
84
|
+
);
|
|
85
|
+
}
|
|
57
86
|
|
|
58
|
-
if (!broker || (
|
|
59
|
-
if (
|
|
87
|
+
if (!broker || (!insteadOfOwn && (hasOwnClient || ownClient))) {
|
|
88
|
+
if (ownClient && broker && !hasOwnClientPath(manifest)) {
|
|
60
89
|
// `defineProvider` permits a broker with no prompts — a provider with no
|
|
61
90
|
// bring-your-own path is a legal thing to be. This is where that absence
|
|
62
91
|
// becomes a sentence rather than a prompt for a value nothing collects.
|
|
@@ -73,7 +102,7 @@ export async function resolveOAuthClient(input: ClientChoice): Promise<OAuthClie
|
|
|
73
102
|
firstForProvider: input.firstForProvider,
|
|
74
103
|
...(input.prompter ? { prompter: input.prompter } : {}),
|
|
75
104
|
});
|
|
76
|
-
if (
|
|
105
|
+
if (ownClient) declareOwnClient(document, manifest, input.changes);
|
|
77
106
|
|
|
78
107
|
const [clientId, clientSecret] = app
|
|
79
108
|
? await Promise.all([
|
|
@@ -116,6 +145,7 @@ export async function resolveOAuthClient(input: ClientChoice): Promise<OAuthClie
|
|
|
116
145
|
cause: cause instanceof Error ? cause.message : String(cause),
|
|
117
146
|
...(cause instanceof BrokerError && cause.notice ? { notice: cause.notice } : {}),
|
|
118
147
|
docsUrl: broker.docs_url,
|
|
148
|
+
ownClient: hasOwnClientPath(manifest),
|
|
119
149
|
});
|
|
120
150
|
}
|
|
121
151
|
|
|
@@ -128,6 +158,7 @@ export async function resolveOAuthClient(input: ClientChoice): Promise<OAuthClie
|
|
|
128
158
|
cause: 'it is not accepting new connections.',
|
|
129
159
|
...(config.notice ? { notice: config.notice } : {}),
|
|
130
160
|
docsUrl: config.docsUrl ?? broker.docs_url,
|
|
161
|
+
ownClient: hasOwnClientPath(manifest),
|
|
131
162
|
});
|
|
132
163
|
}
|
|
133
164
|
|
|
@@ -193,9 +224,6 @@ async function profileHasOwnClient(
|
|
|
193
224
|
return Boolean(id && secret);
|
|
194
225
|
}
|
|
195
226
|
|
|
196
|
-
function hasClientPrompts(manifest: ProviderManifest): boolean {
|
|
197
|
-
return (manifest.setup?.prompts ?? []).some((prompt) => prompt.scope === 'shared');
|
|
198
|
-
}
|
|
199
227
|
|
|
200
228
|
/**
|
|
201
229
|
* What is actually asked for, and what the broker cannot grant.
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { bearerTokenAsStored } from '#connectivity/auth/index.ts';
|
|
2
|
+
import type { AnyConnector, DiscoveredCapability, ProviderManifest } from '#connectivity';
|
|
3
|
+
import { createMcpConnector } from '#connectivity/transports';
|
|
4
|
+
import type { RegisteredProvider } from '#registry';
|
|
5
|
+
import type { SecretStore } from '#secrets';
|
|
6
|
+
import { progress, style } from '../../output.ts';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Step three of `connect`: ask the upstream what it exposes.
|
|
10
|
+
*
|
|
11
|
+
* A manifest never declares capabilities for a proxied server — the server is
|
|
12
|
+
* the source of truth, and a declared list would go stale the moment the vendor
|
|
13
|
+
* ships. So this is the one part of connecting that talks to the thing being
|
|
14
|
+
* connected for a reason other than authentication.
|
|
15
|
+
*
|
|
16
|
+
* Its own file because the three cases have nothing to do with one another: an
|
|
17
|
+
* MCP server is asked over a session, an HTTP provider is read from a
|
|
18
|
+
* description on disk, and a local provider already knows. Interleaved with the
|
|
19
|
+
* config writing around them, that was three shapes wearing one `if`.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* What discovery is actually doing, per kind, so the wait is explained.
|
|
24
|
+
*
|
|
25
|
+
* They differ enough to be worth saying: reading a local OpenAPI file is
|
|
26
|
+
* instant, while signing in to an IMAP server is a TLS handshake and a LOGIN
|
|
27
|
+
* against a host that sometimes takes its time.
|
|
28
|
+
*/
|
|
29
|
+
const DISCOVERY_NOTE: Record<string, string> = {
|
|
30
|
+
mcp: 'Discovering capabilities…',
|
|
31
|
+
http: 'Reading the API description…',
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
export async function discoverCapabilities(input: {
|
|
35
|
+
readonly entry: RegisteredProvider;
|
|
36
|
+
readonly manifest: ProviderManifest;
|
|
37
|
+
readonly connectionId: string;
|
|
38
|
+
readonly credentials: SecretStore;
|
|
39
|
+
readonly connectorFor: (providerId: string, connectionId: string) => AnyConnector | undefined;
|
|
40
|
+
/** Called with what was found, so the caller can cache and register it. */
|
|
41
|
+
readonly remember: (discovered: DiscoveredCapability[]) => Promise<void>;
|
|
42
|
+
}): Promise<DiscoveredCapability[]> {
|
|
43
|
+
const { entry, manifest, connectionId, credentials } = input;
|
|
44
|
+
|
|
45
|
+
if (manifest.connector.kind === 'local') return localCapabilities(entry, manifest);
|
|
46
|
+
|
|
47
|
+
progress(style.dim(DISCOVERY_NOTE[manifest.connector.kind] ?? 'Discovering capabilities…'));
|
|
48
|
+
|
|
49
|
+
// MCP is the one kind that does not use the runtime's connector here: it
|
|
50
|
+
// wants the token exactly as just written, without the refresh machinery that
|
|
51
|
+
// `bearerToken` wraps around it. Every other kind carries whatever credential
|
|
52
|
+
// it needs from the factory.
|
|
53
|
+
const connector =
|
|
54
|
+
manifest.connector.kind === 'mcp'
|
|
55
|
+
? createMcpConnector({
|
|
56
|
+
endpoint: manifest.connector.endpoint,
|
|
57
|
+
...(manifest.connector.headers ? { headers: manifest.connector.headers } : {}),
|
|
58
|
+
accessToken: () => bearerTokenAsStored(manifest, connectionId, credentials),
|
|
59
|
+
})
|
|
60
|
+
: input.connectorFor(manifest.id, connectionId);
|
|
61
|
+
|
|
62
|
+
if (!connector) return [];
|
|
63
|
+
|
|
64
|
+
// Discovery takes the manifest and nothing else. What a provider exposes is a
|
|
65
|
+
// property of the provider, not of an account — which is just as well,
|
|
66
|
+
// because the connection being created does not exist in config until the
|
|
67
|
+
// step after this one.
|
|
68
|
+
const discovered = await connector.discover({ manifest });
|
|
69
|
+
await input.remember(discovered);
|
|
70
|
+
return discovered;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Local capabilities carry their bundle from the manifest.
|
|
75
|
+
*
|
|
76
|
+
* Resources are included alongside tools: they need a policy grant too, and
|
|
77
|
+
* leaving them out would register a resource nothing is allowed to read.
|
|
78
|
+
*/
|
|
79
|
+
function localCapabilities(
|
|
80
|
+
entry: RegisteredProvider,
|
|
81
|
+
manifest: ProviderManifest,
|
|
82
|
+
): DiscoveredCapability[] {
|
|
83
|
+
if (!entry.definition) return [];
|
|
84
|
+
|
|
85
|
+
const bundleOf = (name: string): string | undefined =>
|
|
86
|
+
manifest.bundles?.find((candidate) => candidate.capabilities.includes(name))?.name;
|
|
87
|
+
|
|
88
|
+
return entry.definition.capabilities.map((capability) => ({
|
|
89
|
+
name: capability.name,
|
|
90
|
+
description: capability.description,
|
|
91
|
+
inputSchema: {},
|
|
92
|
+
...(bundleOf(capability.name) ? { bundle: bundleOf(capability.name)! } : {}),
|
|
93
|
+
}));
|
|
94
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import type { ProviderRegistry } from '#registry';
|
|
2
|
+
import { progress, style } from '../../output.ts';
|
|
3
|
+
import { credentialApp, familyNote } from './accounts.ts';
|
|
4
|
+
import { familyOutcome, type ConnectOutcome } from './outcome.ts';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* `lanes link connect icloud` — an account rather than a provider.
|
|
8
|
+
*
|
|
9
|
+
* Everyone models iCloud this way: Apple's own Settings, macOS Internet
|
|
10
|
+
* Accounts, Thunderbird, DAVx⁵. One authorisation, three services. It is three
|
|
11
|
+
* *providers* underneath because mail and calendars are different protocols,
|
|
12
|
+
* and because a policy line per provider is what lets someone allow
|
|
13
|
+
* `icloud_calendar.*` while never granting mail — but nobody should have to
|
|
14
|
+
* know that to connect their account.
|
|
15
|
+
*
|
|
16
|
+
* Its own file because it is its own subject: `runConnect` is five numbered
|
|
17
|
+
* steps that add one account, and this is the fan-out that turns one name into
|
|
18
|
+
* several of those. Keeping them together made the interesting half — how a
|
|
19
|
+
* partial failure is reported — read as a preamble to be scrolled past.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Which providers answer to this name as a shared account.
|
|
24
|
+
*
|
|
25
|
+
* Fewer than two is not a family. Asked of the registry rather than matched on
|
|
26
|
+
* the id, for the reason `siblingAccountId` gives: `app` is a manifest field,
|
|
27
|
+
* and a provider is free to declare `app: icloud` under any name it likes.
|
|
28
|
+
*/
|
|
29
|
+
export function familyMembers(registry: ProviderRegistry, name: string): readonly string[] {
|
|
30
|
+
return registry
|
|
31
|
+
.list()
|
|
32
|
+
.filter((candidate) => credentialApp(candidate.manifest) === name)
|
|
33
|
+
.map((candidate) => candidate.manifest.id);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Connect each member in turn, and report the account rather than the services.
|
|
38
|
+
*
|
|
39
|
+
* In sequence, and the order matters: the first settles the account id and
|
|
40
|
+
* stores the credential, and the rest find both already there.
|
|
41
|
+
*
|
|
42
|
+
* The id travels as a flag because the family members are addressed by their
|
|
43
|
+
* own names — `connect icloud.will` parses `will` off a target that is then
|
|
44
|
+
* thrown away, and recursing without it meant the command named an account and
|
|
45
|
+
* each member silently invented its own.
|
|
46
|
+
*
|
|
47
|
+
* `announced` is passed for the same reason and one axis over: the line naming
|
|
48
|
+
* the target belongs to the account, not to each service under it, so it is
|
|
49
|
+
* printed once here and suppressed in every member. Three copies of it is three
|
|
50
|
+
* times nothing new.
|
|
51
|
+
*/
|
|
52
|
+
export async function connectFamily<Options extends { readonly id?: string | undefined }>(input: {
|
|
53
|
+
readonly name: string;
|
|
54
|
+
readonly members: readonly string[];
|
|
55
|
+
readonly options: Options;
|
|
56
|
+
readonly namedId: string | undefined;
|
|
57
|
+
readonly connect: (
|
|
58
|
+
provider: string,
|
|
59
|
+
options: Options,
|
|
60
|
+
announced: boolean,
|
|
61
|
+
) => Promise<ConnectOutcome>;
|
|
62
|
+
}): Promise<ConnectOutcome> {
|
|
63
|
+
const { name, members, options, namedId, connect } = input;
|
|
64
|
+
|
|
65
|
+
progress(style.dim(familyNote(name, members)));
|
|
66
|
+
|
|
67
|
+
const inherited = { ...options, id: options.id ?? namedId };
|
|
68
|
+
const outcomes: ConnectOutcome[] = [];
|
|
69
|
+
for (const member of members) outcomes.push(await connect(member, inherited, true));
|
|
70
|
+
|
|
71
|
+
return familyOutcome(outcomes);
|
|
72
|
+
}
|
|
@@ -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 } : {}),
|