@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.
Files changed (139) hide show
  1. package/README.md +21 -8
  2. package/instructions/skills/lanes-link/SKILL.md +59 -14
  3. package/package.json +1 -1
  4. package/src/auth/index.ts +3 -1
  5. package/src/auth/oauth/metadata.ts +83 -9
  6. package/src/auth/oauth/redirects.ts +70 -0
  7. package/src/auth/oauth/server.ts +49 -69
  8. package/src/auth/oauth/store.ts +19 -5
  9. package/src/cli/argv.ts +50 -0
  10. package/src/cli/brand.ts +178 -0
  11. package/src/cli/callback-page.ts +108 -128
  12. package/src/cli/commands/connect/accounts.ts +5 -0
  13. package/src/cli/commands/connect/assertion.ts +187 -0
  14. package/src/cli/commands/connect/authorise.ts +61 -17
  15. package/src/cli/commands/connect/client.ts +37 -9
  16. package/src/cli/commands/connect/discover.ts +94 -0
  17. package/src/cli/commands/connect/family.ts +72 -0
  18. package/src/cli/commands/connect/index.ts +113 -115
  19. package/src/cli/commands/connect/method.ts +237 -0
  20. package/src/cli/commands/connect/outcome.ts +42 -1
  21. package/src/cli/commands/connect/pasted-token.ts +66 -0
  22. package/src/cli/commands/connect/requirements.ts +60 -8
  23. package/src/cli/commands/connect/setup.ts +16 -5
  24. package/src/cli/commands/connect/target-note.ts +34 -0
  25. package/src/cli/commands/identity.ts +258 -0
  26. package/src/cli/commands/knowledge/index.ts +390 -0
  27. package/src/cli/commands/knowledge/migrate.ts +180 -0
  28. package/src/cli/commands/knowledge/setup.ts +144 -0
  29. package/src/cli/commands/knowledge.ts +10 -0
  30. package/src/cli/commands/mcp/harnesses.ts +16 -2
  31. package/src/cli/commands/mcp/register.ts +9 -1
  32. package/src/cli/commands/mcp/stdio.ts +21 -0
  33. package/src/cli/commands/operate/dashboard.ts +107 -0
  34. package/src/cli/commands/operate/findings.ts +151 -0
  35. package/src/cli/commands/operate/inspect.ts +56 -158
  36. package/src/cli/commands/operate/outputs.ts +38 -11
  37. package/src/cli/commands/operate/serve.ts +3 -0
  38. package/src/cli/commands/operate/token.ts +1 -1
  39. package/src/cli/commands/operate.ts +2 -0
  40. package/src/cli/commands/profile/declare.ts +154 -0
  41. package/src/cli/commands/profile/removal.ts +17 -0
  42. package/src/cli/commands/profile.ts +83 -35
  43. package/src/cli/commands/setup.ts +22 -6
  44. package/src/cli/commands/target.ts +65 -83
  45. package/src/cli/config-edit.ts +48 -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/identity.ts +12 -1
  50. package/src/cli/main.ts +68 -4
  51. package/src/cli/oauth-callback.ts +187 -0
  52. package/src/cli/oauth-exchange.ts +57 -15
  53. package/src/cli/oauth.ts +67 -177
  54. package/src/cli/output.ts +21 -5
  55. package/src/cli/provider-marks.ts +45 -0
  56. package/src/cli/runtime/open.ts +67 -49
  57. package/src/cli/runtime/registry.ts +60 -2
  58. package/src/cli/runtime/select.ts +26 -13
  59. package/src/cli/runtime/vault.ts +61 -0
  60. package/src/cli/runtime.ts +2 -1
  61. package/src/cli/selection.ts +357 -0
  62. package/src/cli/usage.ts +32 -9
  63. package/src/connectivity/auth/README.md +7 -1
  64. package/src/connectivity/auth/basic/index.ts +1 -1
  65. package/src/connectivity/auth/index.ts +14 -0
  66. package/src/connectivity/auth/oauth-authcode/broker.ts +26 -0
  67. package/src/connectivity/auth/oauth-authcode/index.ts +16 -2
  68. package/src/connectivity/auth/oauth-authcode/provider.ts +1 -1
  69. package/src/connectivity/auth/oauth-authcode/refresh.ts +3 -3
  70. package/src/connectivity/auth/oauth-jwt/README.md +33 -0
  71. package/src/connectivity/auth/oauth-jwt/index.ts +237 -0
  72. package/src/connectivity/auth/oauth-jwt/key.ts +148 -0
  73. package/src/connectivity/auth/resolve.ts +1 -1
  74. package/src/connectivity/auth/token.ts +11 -0
  75. package/src/connectivity/index.ts +2 -0
  76. package/src/connectivity/manifest/auth.ts +99 -2
  77. package/src/connectivity/manifest/identity.ts +12 -0
  78. package/src/connectivity/manifest/index.ts +3 -1
  79. package/src/connectivity/manifest/provider.ts +37 -8
  80. package/src/connectivity/manifest/requirements.ts +109 -6
  81. package/src/deployments/adapters/filesystem.ts +10 -1
  82. package/src/deployments/adapters/github-api.ts +106 -0
  83. package/src/deployments/adapters/github-commit.ts +103 -0
  84. package/src/deployments/adapters/github-repo.ts +356 -0
  85. package/src/deployments/adapters/github-testing.ts +258 -0
  86. package/src/deployments/adapters/github.ts +125 -0
  87. package/src/deployments/deploy.ts +48 -19
  88. package/src/deployments/driver.ts +8 -1
  89. package/src/deployments/gcp/driver.ts +9 -1
  90. package/src/deployments/gcp/survey.ts +3 -0
  91. package/src/deployments/knowledge.ts +119 -0
  92. package/src/deployments/prepare.ts +2 -2
  93. package/src/deployments/servable.ts +81 -0
  94. package/src/deployments/target.ts +3 -2
  95. package/src/deployments/upload.ts +2 -1
  96. package/src/dispatch/dispatch.ts +1 -1
  97. package/src/profile/authorization.ts +13 -4
  98. package/src/profile/identity.ts +60 -0
  99. package/src/profile/index.ts +15 -5
  100. package/src/profile/knowledge.ts +124 -0
  101. package/src/profile/load.ts +17 -5
  102. package/src/profile/primitives.ts +24 -1
  103. package/src/profile/schema.ts +72 -4
  104. package/src/profile/targets.ts +74 -114
  105. package/src/profile/workspace.ts +71 -84
  106. package/src/providers/google/calendar/index.ts +2 -0
  107. package/src/providers/google/contacts/index.ts +2 -0
  108. package/src/providers/google/docs/index.ts +2 -0
  109. package/src/providers/google/drive/index.ts +2 -0
  110. package/src/providers/google/gmail/index.ts +2 -0
  111. package/src/providers/google/gmail-imap/index.ts +125 -0
  112. package/src/providers/google/index.ts +2 -1
  113. package/src/providers/google/shared/oauth.ts +18 -6
  114. package/src/providers/google/shared/service-account.ts +110 -0
  115. package/src/providers/google/shared/setup.ts +5 -2
  116. package/src/providers/google/sheets/index.ts +2 -0
  117. package/src/providers/google/tasks/index.ts +2 -0
  118. package/src/providers/identity/provider.ts +166 -0
  119. package/src/providers/index.ts +3 -0
  120. package/src/providers/owner.ts +10 -2
  121. package/src/providers/scopes.ts +2 -0
  122. package/src/providers/setup/plan.ts +31 -9
  123. package/src/providers/setup/provider.ts +23 -0
  124. package/src/providers/slack/index.ts +81 -33
  125. package/src/providers/slack/oauth.ts +103 -0
  126. package/src/providers/slack/scopes.ts +37 -0
  127. package/src/server/container.ts +18 -1
  128. package/src/server/cors.ts +252 -0
  129. package/src/server/dashboard.ts +208 -0
  130. package/src/server/endpoint.ts +57 -4
  131. package/src/server/generation.ts +1 -0
  132. package/src/server/generations.ts +13 -2
  133. package/src/server/harness.ts +20 -3
  134. package/src/server/index.ts +76 -12
  135. package/src/server/mcp/build.ts +1 -1
  136. package/src/server/mcp/index.ts +1 -0
  137. package/src/server/mcp/instructions.ts +62 -5
  138. package/src/server/mcp/visibility.ts +42 -0
  139. package/src/stores/blobs/route.ts +123 -0
@@ -0,0 +1,187 @@
1
+ import { readFile } from 'node:fs/promises';
2
+ import { homedir } from 'node:os';
3
+ import { isAbsolute, resolve } from 'node:path';
4
+ import { ASSERTION_GRANT, parseAssertionKey } from '#connectivity/auth/index.ts';
5
+ import { credentialRefForConnection, type AuthAssertion, type ProviderManifest } from '#connectivity';
6
+ import type { SecretStore } from '#secrets';
7
+ import { ok, progress, style } from '../../output.ts';
8
+ import { terminalPrompter, type Prompter } from '../../prompt.ts';
9
+ import { askForSetup, printSetup } from './setup.ts';
10
+
11
+ /**
12
+ * The counterpart to `authorise.ts`, for a provider authenticated by a key.
13
+ *
14
+ * The same conversation, minus the browser: show the console work, take what it
15
+ * produced, put it in the store. What differs is that there are two halves and
16
+ * they have different lifetimes — the key is one file covering every provider
17
+ * of a vendor, and the account it acts as is per connection. So the key is
18
+ * asked for once per profile and the subject once per account, and re-running
19
+ * `connect` for a second provider asks for neither.
20
+ */
21
+
22
+ /**
23
+ * Take a path, or the file's contents.
24
+ *
25
+ * A key arrives as a downloaded file, and a downloaded file is a path — asking
26
+ * someone to open it and paste several hundred characters of PEM into a
27
+ * terminal is asking for a truncated key and an error two steps later. Pasting
28
+ * still works for anyone who would rather, which is why this looks at the shape
29
+ * of the answer rather than at a flag.
30
+ *
31
+ * What is *stored* is always the contents. A path is a fact about one machine
32
+ * and this credential outlives it: the same profile is read by a deployed
33
+ * revision that has no such file, and a store holding a path would fail there
34
+ * with an error about the filesystem rather than about the credential.
35
+ */
36
+ async function contentsOf(answer: string): Promise<string> {
37
+ if (answer.startsWith('{')) return answer;
38
+
39
+ const expanded = answer.startsWith('~/') ? resolve(homedir(), answer.slice(2)) : answer;
40
+ const path = isAbsolute(expanded) ? expanded : resolve(process.cwd(), expanded);
41
+
42
+ try {
43
+ return await readFile(path, 'utf8');
44
+ } catch {
45
+ throw new Error(
46
+ `No file at ${path}. Give the path to the key the console downloaded, or paste its contents.`,
47
+ );
48
+ }
49
+ }
50
+
51
+ /**
52
+ * Ensure the profile holds the key, and this connection holds a pointer to it.
53
+ *
54
+ * Idempotent on the key for the same reason `ensureStaticCredential` is: seven
55
+ * providers share one file, and the second `connect` must find it already there
56
+ * and say so rather than asking again. The subject is not idempotent in the
57
+ * same way — it is per connection, and every connection is a first one.
58
+ */
59
+ export async function authoriseWithKey(input: {
60
+ readonly manifest: ProviderManifest;
61
+ readonly assertion: AuthAssertion;
62
+ readonly connectionId: string;
63
+ readonly credentials: SecretStore;
64
+ readonly changes: string[];
65
+ /** The operator asked to be asked again — a rotated key, or the wrong account. */
66
+ readonly replace: boolean;
67
+ readonly prompter?: Prompter;
68
+ }): Promise<void> {
69
+ const { manifest, assertion, connectionId, credentials, changes, replace } = input;
70
+ const prompter = input.prompter ?? terminalPrompter;
71
+
72
+ const keyPrompt = assertion.setup.prompts.find((prompt) => prompt.scope === 'shared');
73
+ if (!keyPrompt) {
74
+ throw new Error(
75
+ `Provider "${manifest.id}" declares auth.assertion with no shared prompt, so there is no key to ask for.`,
76
+ );
77
+ }
78
+
79
+ const stored = await credentials.has(assertion.key_ref);
80
+
81
+ // `stored && !replace`, and deliberately not `reuseStoredCredential` — which
82
+ // also asks whether the connection id is still provisional. That question is
83
+ // right for a per-connection credential, where a provisional id means an
84
+ // earlier connect stored something no server ever accepted. It is wrong here:
85
+ // this key is shared across the whole profile, so a *first* connect of a
86
+ // second provider is provisional by definition while the key it finds was
87
+ // stored deliberately and may already be in use. Asking again there made a
88
+ // stored key unusable — the preflight said it had everything it needed, and
89
+ // the run that followed immediately asked for it.
90
+ if (stored && !replace) {
91
+ // The walkthrough still prints. The key is shared and already held, but the
92
+ // *sharing* is per resource and per product: whoever connects Sheets after
93
+ // Drive has a key that works and a spreadsheet nobody has shared with it
94
+ // yet, and that failure looks exactly like a broken credential.
95
+ printSetup(
96
+ manifest,
97
+ `The key is already stored at ${assertion.key_ref}, so it is not asked for again — but what ` +
98
+ 'it can reach is granted per resource, and this is the first connection of this one.',
99
+ assertion.setup,
100
+ );
101
+ progress(ok(`key already stored (${assertion.key_ref})`));
102
+ // Named because this is the only way past a key that is stored and wrong,
103
+ // and a well-formed key for the wrong project is refused by the token
104
+ // endpoint rather than here.
105
+ progress(style.dim(` To replace it: lanes link connect ${manifest.id} --replace`));
106
+ } else {
107
+ if (stored) {
108
+ progress(
109
+ style.dim(
110
+ `Replacing ${assertion.key_ref} — what is stored is overwritten only once you have entered a new one.`,
111
+ ),
112
+ );
113
+ }
114
+
115
+ const answers = await askForSetup(
116
+ manifest,
117
+ [keyPrompt],
118
+ `Stored at ${assertion.key_ref}, in the credential store — never in config.`,
119
+ prompter,
120
+ assertion.setup,
121
+ );
122
+
123
+ const contents = await contentsOf(answers.get(keyPrompt.key)!);
124
+
125
+ // Parsed before it is written, so the wrong file is caught here rather than
126
+ // at the token endpoint. The two candidates live on adjacent pages of the
127
+ // same console and `parseAssertionKey` knows how to tell them apart.
128
+ const key = parseAssertionKey(contents, assertion.key_ref);
129
+
130
+ await credentials.set(assertion.key_ref, contents);
131
+ changes.push(`${assertion.key_ref} stored`);
132
+ progress(ok(`key stored — it acts as ${key.client_email}`));
133
+ }
134
+
135
+ const subject = await askForSubject(assertion, prompter);
136
+
137
+ await credentials.set(
138
+ credentialRefForConnection(manifest, connectionId)!,
139
+ JSON.stringify({
140
+ grant: ASSERTION_GRANT,
141
+ key_ref: assertion.key_ref,
142
+ ...(subject ? { subject } : {}),
143
+ }),
144
+ );
145
+
146
+ progress(ok(subject ? `authenticated as ${subject}` : 'authenticated'));
147
+ }
148
+
149
+ /**
150
+ * Who the key acts as, where it acts as anyone.
151
+ *
152
+ * Blank is a real answer when delegation is `optional` — the key is then an
153
+ * identity in its own right. It is refused when delegation is `required`,
154
+ * because the alternative is a credential that authenticates perfectly and
155
+ * finds nothing: there is no mailbox or contact list belonging to a key, so
156
+ * every call would return an empty result rather than an error, which is the
157
+ * worst way for this to be wrong.
158
+ */
159
+ async function askForSubject(
160
+ assertion: AuthAssertion,
161
+ prompter: Prompter,
162
+ ): Promise<string | undefined> {
163
+ const optional = assertion.delegation === 'optional';
164
+
165
+ if (!prompter.interactive) {
166
+ if (optional) return undefined;
167
+ throw new Error(
168
+ `This provider can only reach an account by acting as someone, and this run is ` +
169
+ `non-interactive so there is nobody to ask who. Re-run in a terminal.`,
170
+ );
171
+ }
172
+
173
+ progress();
174
+ const answer = await prompter.ask(
175
+ ` ${assertion.subject_label}${optional ? style.dim(' [none]') : ''}`,
176
+ );
177
+
178
+ if (answer.length > 0) return answer;
179
+
180
+ if (optional) return undefined;
181
+
182
+ throw new Error(
183
+ `${assertion.subject_label} is required here: this provider has nothing that belongs to a ` +
184
+ 'key, so a connection that acts as nobody would authenticate and then find every ' +
185
+ 'mailbox, list and calendar empty.',
186
+ );
187
+ }
@@ -16,9 +16,14 @@ import { ensureOAuthApp } from './setup.ts';
16
16
  /**
17
17
  * Getting a token, and saying what it will be able to do first.
18
18
  *
19
- * Two paths, because two kinds of upstream: an MCP server publishes metadata
20
- * worth discovering and the SDK drives it, while a plain REST API announces
21
- * nothing and the manifest has to name its endpoints.
19
+ * Two paths, and what chooses between them is not the kind of upstream but
20
+ * whether the manifest names its own endpoints. Without them there is nothing
21
+ * to authorise against but what the server advertises, so the SDK discovers it
22
+ * and drives the flow. With them there is nothing left to discover, and the
23
+ * flow is ours — which is the only arrangement in which a client somebody else
24
+ * holds can redeem the code. A REST API never announces an authorization
25
+ * server, so it is always on the second path; an MCP server may be on either.
26
+ * See ADR-040.
22
27
  */
23
28
 
24
29
  /**
@@ -65,8 +70,8 @@ export async function authorise(input: {
65
70
  /** How the operator spelled the target, so a refusal names a command they typed. */
66
71
  target?: string;
67
72
  profile: string;
68
- /** `--own-client`: register a client rather than using the one a broker runs. */
69
- ownClient?: boolean;
73
+ /** Which OAuth client, when the operator chose one. `undefined` keeps today's precedence. */
74
+ client?: 'own' | 'hosted' | undefined;
70
75
  prompter?: Prompter;
71
76
  /** The operator has already said yes to scopes broader than the provider needs. */
72
77
  acceptBroadScopes?: boolean;
@@ -86,13 +91,24 @@ export async function authorise(input: {
86
91
  // announces. So the manifest names the endpoints and we run the flow
87
92
  // directly — the same loopback listener, PKCE, and exchange, minus the
88
93
  // discovery the SDK would otherwise do for us.
89
- if (manifest.connector.kind !== 'mcp') {
94
+ //
95
+ // An MCP connector takes that path too when it names both endpoints, and that
96
+ // is the *only* thing declaring them means. The SDK's flow ends by posting to
97
+ // the token endpoint with whatever `clientInformation()` returned, which is
98
+ // fine for a client the operator holds and impossible for one held by a
99
+ // broker — so a provider whose client lives somewhere else opts out here
100
+ // rather than discovering it after consent. Nothing is lost by opting out:
101
+ // discovery is all the SDK was doing that this does not, and a manifest that
102
+ // names its endpoints has nothing left to discover. Notion, Linear, and
103
+ // Google's two MCP servers name neither and are untouched. See ADR-040.
104
+ if (
105
+ manifest.connector.kind !== 'mcp' ||
106
+ (manifest.auth.authorize_url !== undefined && manifest.auth.token_url !== undefined)
107
+ ) {
90
108
  await authoriseDirect(input);
91
109
  return;
92
110
  }
93
111
 
94
- // An MCP provider is always bring-your-own: `defineProvider` refuses a broker
95
- // on one, because the SDK owns the exchange and there is no seam to route it.
96
112
  if (manifest.auth.registration === 'manual') {
97
113
  await ensureOAuthApp(input);
98
114
  }
@@ -187,7 +203,7 @@ async function authoriseDirect(input: {
187
203
  firstForProvider: boolean;
188
204
  target?: string;
189
205
  profile: string;
190
- ownClient?: boolean;
206
+ client?: 'own' | 'hosted' | undefined;
191
207
  prompter?: Prompter;
192
208
  acceptBroadScopes?: boolean;
193
209
  fetch?: typeof globalThis.fetch;
@@ -210,7 +226,7 @@ async function authoriseDirect(input: {
210
226
  document: input.document,
211
227
  changes: input.changes,
212
228
  firstForProvider: input.firstForProvider,
213
- ownClient: input.ownClient === true,
229
+ client: input.client,
214
230
  target: input.target ?? manifest.id,
215
231
  profile: input.profile,
216
232
  ...(input.prompter ? { prompter: input.prompter } : {}),
@@ -234,19 +250,39 @@ async function authoriseDirect(input: {
234
250
  throw new Error('Cancelled — nothing was authorised.');
235
251
  }
236
252
 
253
+ // What a response carrying no refresh token means, which only the manifest
254
+ // knows: Google omitting one is a failure worth stopping for, Slack omitting
255
+ // one is the ordinary success. See `RefreshTokenPolicy`.
256
+ const refreshToken = {
257
+ required: manifest.auth.refresh_token === 'required',
258
+ vendor: manifest.name,
259
+ ...(manifest.auth.revoke_url ? { revokeUrl: manifest.auth.revoke_url } : {}),
260
+ };
261
+
237
262
  let tokens;
238
263
  try {
239
264
  tokens = await runOAuthFlow({
240
265
  authorizeUrl,
241
- clientId: client.kind === 'own' ? client.clientId : client.config.clientId,
242
- ...(client.kind === 'own'
243
- ? { tokenUrl, clientSecret: client.clientSecret }
244
- : {
266
+ clientId: client.kind === 'brokered' ? client.config.clientId : client.clientId,
267
+ ...(client.kind === 'brokered'
268
+ ? {
245
269
  exchange: brokerExchangeVia({
246
270
  url: client.url,
271
+ refreshToken,
247
272
  ...(input.fetch ? { fetch: input.fetch } : {}),
248
273
  }),
274
+ }
275
+ : {
276
+ tokenUrl,
277
+ clientSecret: client.clientSecret,
249
278
  }),
279
+ refreshToken,
280
+ // Only where the broker published one, which means only where the vendor
281
+ // refuses a loopback redirect. The broker owns the URL because the
282
+ // correct value depends on which deployment answered `/config`.
283
+ ...(client.kind === 'brokered' && client.config.redirectUri
284
+ ? { relayRedirect: client.config.redirectUri }
285
+ : {}),
250
286
  scopes,
251
287
  connectionLabel: manifest.name,
252
288
  ...(manifest.auth.authorize_params
@@ -283,10 +319,18 @@ async function authoriseDirect(input: {
283
319
  `${manifest.id}/${connectionId}`,
284
320
  JSON.stringify({
285
321
  access_token: tokens.accessToken,
286
- refresh_token: tokens.refreshToken,
322
+ // Both omitted rather than defaulted where the vendor issues neither.
323
+ //
324
+ // A long-lived token has no refresh token and states no lifetime, and
325
+ // inventing an hour for it would have `doctor` reporting a healthy
326
+ // connection as stale forever while telling the operator to re-run a
327
+ // command that changes nothing. Absent is what `upstreamAccessToken`
328
+ // already reads as "hand back what is stored", which is correct here.
329
+ ...(tokens.refreshToken ? { refresh_token: tokens.refreshToken } : {}),
287
330
  token_type: 'Bearer',
288
- expires_in: tokens.expiresIn,
289
- expires_at: Date.now() + tokens.expiresIn * 1000,
331
+ ...(tokens.expiresIn !== undefined
332
+ ? { expires_in: tokens.expiresIn, expires_at: Date.now() + tokens.expiresIn * 1000 }
333
+ : {}),
290
334
  scope: tokens.scope,
291
335
  issuer: new URL(authorizeUrl).origin,
292
336
  ...(tokens.idToken ? { id_token: tokens.idToken } : {}),
@@ -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
+ }