@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.
Files changed (143) hide show
  1. package/README.md +22 -8
  2. package/instructions/skills/lanes-link/SKILL.md +42 -14
  3. package/package.json +1 -1
  4. package/src/cli/argv.ts +50 -0
  5. package/src/cli/brand.ts +178 -0
  6. package/src/cli/callback-page.ts +108 -128
  7. package/src/cli/commands/connect/accounts.ts +5 -0
  8. package/src/cli/commands/connect/assertion.ts +187 -0
  9. package/src/cli/commands/connect/authorise.ts +61 -17
  10. package/src/cli/commands/connect/client.ts +37 -9
  11. package/src/cli/commands/connect/discover.ts +94 -0
  12. package/src/cli/commands/connect/family.ts +72 -0
  13. package/src/cli/commands/connect/index.ts +113 -115
  14. package/src/cli/commands/connect/method.ts +237 -0
  15. package/src/cli/commands/connect/outcome.ts +42 -1
  16. package/src/cli/commands/connect/pasted-token.ts +66 -0
  17. package/src/cli/commands/connect/requirements.ts +60 -8
  18. package/src/cli/commands/connect/setup.ts +16 -5
  19. package/src/cli/commands/connect/target-note.ts +34 -0
  20. package/src/cli/commands/identity.ts +258 -0
  21. package/src/cli/commands/knowledge/index.ts +390 -0
  22. package/src/cli/commands/knowledge/migrate.ts +180 -0
  23. package/src/cli/commands/knowledge/setup.ts +144 -0
  24. package/src/cli/commands/knowledge.ts +10 -0
  25. package/src/cli/commands/mcp/harnesses.ts +16 -2
  26. package/src/cli/commands/mcp/register.ts +9 -1
  27. package/src/cli/commands/mcp/stdio.ts +21 -0
  28. package/src/cli/commands/operate/dashboard.ts +107 -0
  29. package/src/cli/commands/operate/findings.ts +151 -0
  30. package/src/cli/commands/operate/inspect.ts +63 -164
  31. package/src/cli/commands/operate/outputs.ts +38 -11
  32. package/src/cli/commands/operate/policy.ts +7 -7
  33. package/src/cli/commands/operate/serve.ts +3 -0
  34. package/src/cli/commands/operate/status.ts +108 -1
  35. package/src/cli/commands/operate/token.ts +1 -1
  36. package/src/cli/commands/operate.ts +2 -0
  37. package/src/cli/commands/profile/declare.ts +154 -0
  38. package/src/cli/commands/profile/removal.ts +17 -0
  39. package/src/cli/commands/profile/remove.ts +5 -5
  40. package/src/cli/commands/profile.ts +83 -35
  41. package/src/cli/commands/secrets.ts +6 -6
  42. package/src/cli/commands/setup.ts +22 -6
  43. package/src/cli/commands/sync.ts +262 -0
  44. package/src/cli/commands/target.ts +65 -83
  45. package/src/cli/config-edit.ts +53 -144
  46. package/src/cli/config-repair.ts +186 -0
  47. package/src/cli/dashboard-page.ts +284 -0
  48. package/src/cli/dashboard-shell.ts +125 -0
  49. package/src/cli/dispatch-owner.ts +93 -0
  50. package/src/cli/identity.ts +12 -1
  51. package/src/cli/main.ts +90 -61
  52. package/src/cli/nearest.ts +45 -0
  53. package/src/cli/oauth-callback.ts +187 -0
  54. package/src/cli/oauth-exchange.ts +57 -15
  55. package/src/cli/oauth.ts +67 -177
  56. package/src/cli/output.ts +21 -5
  57. package/src/cli/provider-marks.ts +45 -0
  58. package/src/cli/runtime/open.ts +74 -51
  59. package/src/cli/runtime/registry.ts +60 -2
  60. package/src/cli/runtime/select.ts +26 -13
  61. package/src/cli/runtime/vault.ts +61 -0
  62. package/src/cli/runtime.ts +2 -1
  63. package/src/cli/selection.ts +365 -0
  64. package/src/cli/usage.ts +42 -11
  65. package/src/connectivity/auth/README.md +7 -1
  66. package/src/connectivity/auth/basic/index.ts +1 -1
  67. package/src/connectivity/auth/index.ts +14 -0
  68. package/src/connectivity/auth/oauth-authcode/broker.ts +26 -0
  69. package/src/connectivity/auth/oauth-authcode/index.ts +16 -2
  70. package/src/connectivity/auth/oauth-authcode/provider.ts +1 -1
  71. package/src/connectivity/auth/oauth-authcode/refresh.ts +3 -3
  72. package/src/connectivity/auth/oauth-jwt/README.md +33 -0
  73. package/src/connectivity/auth/oauth-jwt/index.ts +237 -0
  74. package/src/connectivity/auth/oauth-jwt/key.ts +148 -0
  75. package/src/connectivity/auth/resolve.ts +1 -1
  76. package/src/connectivity/auth/token.ts +11 -0
  77. package/src/connectivity/index.ts +2 -0
  78. package/src/connectivity/manifest/auth.ts +99 -2
  79. package/src/connectivity/manifest/identity.ts +12 -0
  80. package/src/connectivity/manifest/index.ts +3 -1
  81. package/src/connectivity/manifest/provider.ts +37 -8
  82. package/src/connectivity/manifest/requirements.ts +109 -6
  83. package/src/deployments/adapters/filesystem.ts +10 -1
  84. package/src/deployments/adapters/github-api.ts +106 -0
  85. package/src/deployments/adapters/github-commit.ts +103 -0
  86. package/src/deployments/adapters/github-repo.ts +356 -0
  87. package/src/deployments/adapters/github-testing.ts +258 -0
  88. package/src/deployments/adapters/github.ts +125 -0
  89. package/src/deployments/deploy.ts +94 -114
  90. package/src/deployments/discover.ts +103 -0
  91. package/src/deployments/driver.ts +8 -1
  92. package/src/deployments/gcp/driver.ts +3 -1
  93. package/src/deployments/knowledge.ts +119 -0
  94. package/src/deployments/prepare.ts +12 -6
  95. package/src/deployments/report.ts +117 -0
  96. package/src/deployments/servable.ts +82 -0
  97. package/src/deployments/serving.ts +165 -0
  98. package/src/deployments/sync-apply.ts +276 -0
  99. package/src/deployments/sync.ts +136 -0
  100. package/src/deployments/target.ts +3 -2
  101. package/src/deployments/upload.ts +19 -12
  102. package/src/dispatch/dispatch.ts +1 -1
  103. package/src/profile/deployments.ts +80 -0
  104. package/src/profile/identity.ts +60 -0
  105. package/src/profile/index.ts +23 -5
  106. package/src/profile/knowledge.ts +124 -0
  107. package/src/profile/load.ts +17 -5
  108. package/src/profile/primitives.ts +24 -1
  109. package/src/profile/schema.ts +81 -3
  110. package/src/profile/targets.ts +122 -109
  111. package/src/profile/workspace.ts +139 -79
  112. package/src/providers/google/calendar/index.ts +2 -0
  113. package/src/providers/google/contacts/index.ts +2 -0
  114. package/src/providers/google/docs/index.ts +2 -0
  115. package/src/providers/google/drive/index.ts +2 -0
  116. package/src/providers/google/gmail/index.ts +2 -0
  117. package/src/providers/google/gmail-imap/index.ts +125 -0
  118. package/src/providers/google/index.ts +2 -1
  119. package/src/providers/google/shared/oauth.ts +18 -6
  120. package/src/providers/google/shared/service-account.ts +110 -0
  121. package/src/providers/google/shared/setup.ts +21 -3
  122. package/src/providers/google/sheets/index.ts +2 -0
  123. package/src/providers/google/tasks/index.ts +2 -0
  124. package/src/providers/identity/provider.ts +166 -0
  125. package/src/providers/index.ts +3 -0
  126. package/src/providers/owner.ts +10 -2
  127. package/src/providers/scopes.ts +2 -0
  128. package/src/providers/setup/plan.ts +31 -9
  129. package/src/providers/setup/provider.ts +23 -0
  130. package/src/providers/slack/index.ts +81 -33
  131. package/src/providers/slack/oauth.ts +103 -0
  132. package/src/providers/slack/scopes.ts +37 -0
  133. package/src/server/container.ts +18 -1
  134. package/src/server/cors.ts +252 -0
  135. package/src/server/dashboard.ts +208 -0
  136. package/src/server/endpoint.ts +45 -1
  137. package/src/server/generations.ts +11 -2
  138. package/src/server/harness.ts +7 -0
  139. package/src/server/index.ts +45 -6
  140. package/src/server/mcp/index.ts +1 -0
  141. package/src/server/mcp/instructions.ts +28 -1
  142. package/src/server/mcp/visibility.ts +33 -0
  143. 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 { ProviderManifest } from '#connectivity';
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
- /** `--own-client`: register one rather than using the client the broker runs. */
42
- readonly ownClient: boolean;
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 || (await profileHasOwnClient(app, document, credentials)) || input.ownClient) {
59
- if (input.ownClient && broker && !hasClientPrompts(manifest)) {
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 (input.ownClient) declareOwnClient(document, manifest, input.changes);
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, ensureSetupConnection, repaired } from '../../config-edit.ts';
6
- import { emit, print, progress, style } from '../../output.ts';
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 { credentialApp, matchesRule, moveCredential, siblingAccountId } from './accounts.ts';
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(target: string, options: ConnectOptions): Promise<ConnectOutcome> {
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
- // `lanes link connect icloud` an account rather than a provider.
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 family = registry
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 (family.length > 1) {
123
+ if (members.length > 1) {
114
124
  await runtime.close();
115
- progress(
116
- style.dim(`${providerId} is ${family.length} services on one account: ${family.join(', ')}`),
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
- ...(members.find((outcome) => !outcome.ok)?.reason
139
- ? { reason: members.find((outcome) => !outcome.ok)!.reason }
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
- // 0. With nobody to ask, resolve everything up front or refuse saying why.
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
- const prompter: Prompter =
196
- options.nonInteractive === true
197
- ? nonInteractivePrompter(`lanes link setup plan ${providerId} --profile ${profile}`)
198
- : terminalPrompter;
199
-
200
- if (manifest.auth.kind === 'oauth') {
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
- ownClient: options.ownClient === true,
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. A manifest never declares
261
- // capabilities for a proxied server — the server is the source of truth,
262
- // and a declared list would go stale the moment the vendor ships.
263
- let discovered: DiscoveredCapability[] = [];
264
-
265
- if (manifest.connector.kind !== 'local') {
266
- progress(style.dim(DISCOVERY_NOTE[manifest.connector.kind] ?? 'Discovering capabilities…'));
267
-
268
- // MCP is the one kind that does not use the runtime's connector here: it
269
- // wants the token exactly as just written, without the refresh machinery
270
- // that `bearerToken` wraps around it. Every other kind carries whatever
271
- // credential it needs from the factory.
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
- changes.push(`re-authorised ${connectionKey}`);
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 } : {}),