@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
@@ -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 } : {}),
@@ -0,0 +1,237 @@
1
+ import { hasOwnClientPath, type AuthAssertion, type ProviderManifest } from '#connectivity';
2
+ import { progress, style } from '../../output.ts';
3
+ import { terminalPrompter, type Prompter } from '../../prompt.ts';
4
+
5
+ /**
6
+ * Which way in, where a provider offers more than one.
7
+ *
8
+ * Two questions that used to be asked in two different places and are one
9
+ * question to the person answering: what kind of credential, and — for the
10
+ * browser — whose OAuth client. The second was a flag, `--own-client`, which is
11
+ * to say it was a choice nobody discovered unless they already knew it existed.
12
+ *
13
+ * Most providers offer exactly one route and this file is inert for them:
14
+ * `options` returns a single entry, nothing is printed, and nothing is asked.
15
+ * That is the property worth protecting — adding routes to Google must not put
16
+ * a question, or a warning, in front of somebody connecting GitHub.
17
+ */
18
+
19
+ export type ChosenMethod =
20
+ | {
21
+ readonly kind: 'assertion';
22
+ /** How `--auth` spells this route, for reporting what the connection became. */
23
+ readonly id: string;
24
+ readonly assertion: AuthAssertion;
25
+ }
26
+ /**
27
+ * The browser, and which client the exchange runs through.
28
+ *
29
+ * `undefined` means "whatever this profile already decided", which is the
30
+ * precedence `resolveOAuthClient` has always applied: a declared `oauth_apps`
31
+ * entry wins, otherwise the broker. It is what a provider with only one
32
+ * browser route resolves to, so nothing about those changes.
33
+ *
34
+ * `id` is unset for exactly those synthesised cases — `--auth oauth`,
35
+ * `--own-client`, and a provider with one route — because there was no choice
36
+ * to report. A provider that never offered two reads as it always did.
37
+ */
38
+ | { readonly kind: 'oauth'; readonly id?: string; readonly client: 'own' | 'hosted' | undefined }
39
+ /**
40
+ * A credential the operator already holds, for a provider that does OAuth.
41
+ *
42
+ * Offered where an OAuth manifest still declares a per-connection prompt,
43
+ * which is a thing to be only where the browser route can be closed by
44
+ * somebody who is not in the room: a Slack workspace on Enterprise Grid needs
45
+ * an admin to approve an app before it can authenticate anyone, and the
46
+ * person running `connect` may not be that admin.
47
+ */
48
+ | { readonly kind: 'pasted'; readonly id?: string };
49
+
50
+ interface Option {
51
+ /** What `--auth` accepts, and how a chosen route is named back. */
52
+ readonly id: string;
53
+ readonly label: string;
54
+ readonly detail: string;
55
+ readonly chosen: ChosenMethod;
56
+ }
57
+
58
+
59
+ /**
60
+ * Every route this provider actually has, in the order they are offered.
61
+ *
62
+ * The key first, because it is the one that removes a recurring chore and the
63
+ * one nobody would guess at. Then the hosted client, which is the default and
64
+ * the thing "as it works today" means. Then a client of your own, which is
65
+ * twenty minutes in a console and is what an organisation forbidding
66
+ * third-party clients needs.
67
+ */
68
+ export function options(manifest: ProviderManifest): readonly Option[] {
69
+ if (manifest.auth.kind !== 'oauth') return [];
70
+
71
+ const { assertion, broker } = manifest.auth;
72
+ const found: Option[] = [];
73
+
74
+ if (assertion) {
75
+ found.push({
76
+ id: assertion.method,
77
+ label: assertion.label,
78
+ detail: assertion.reach,
79
+ chosen: { kind: 'assertion', id: assertion.method, assertion },
80
+ });
81
+ }
82
+
83
+ if (broker) {
84
+ found.push({
85
+ id: 'hosted_client',
86
+ label: `Sign in through a browser, using the OAuth client ${broker.operator} operates`,
87
+ detail:
88
+ 'nothing to register and no client secret on this machine. The exchange is performed by ' +
89
+ `${broker.operator}, and the connection is re-authorised whenever its token expires.`,
90
+ chosen: { kind: 'oauth', id: 'hosted_client', client: 'hosted' },
91
+ });
92
+ }
93
+
94
+ if (!broker || hasOwnClientPath(manifest)) {
95
+ found.push({
96
+ id: 'own_client',
97
+ label: 'Sign in through a browser, using an OAuth client you register',
98
+ detail: broker
99
+ ? 'a console walkthrough once per profile, after which nothing leaves this machine but ' +
100
+ 'the browser. What an organisation that forbids third-party clients needs.'
101
+ : 'the whole account, and the connection is re-authorised whenever its token expires.',
102
+ // Undefined `client` rather than 'own' where it is the only browser route:
103
+ // there is nothing to override, and forcing it would write an `oauth_apps`
104
+ // entry for a provider whose manifest already says it is the only way.
105
+ chosen: { kind: 'oauth', id: 'own_client', client: broker ? 'own' : undefined },
106
+ });
107
+ }
108
+
109
+ // Last, always. It is the way in when the others are refused, not one anyone
110
+ // should be reaching for first: what it stores is the credential itself
111
+ // rather than a means of obtaining one, so rotating it is manual, and nothing
112
+ // can show what it is allowed to do.
113
+ const pasted = (manifest.setup?.prompts ?? []).filter((prompt) => prompt.scope === 'connection');
114
+ if (pasted.length > 0) {
115
+ found.push({
116
+ id: 'pasted_token',
117
+ label: `Paste a ${pasted.map((prompt) => prompt.label).join(', then ')} you already hold`,
118
+ detail:
119
+ 'no browser, for a workspace that has not approved this app — which an admin decides, ' +
120
+ 'not you. The credential is stored as given, so rotating it is manual and nothing can ' +
121
+ 'say what it is allowed to do.',
122
+ chosen: { kind: 'pasted', id: 'pasted_token' },
123
+ });
124
+ }
125
+
126
+ return found;
127
+ }
128
+
129
+ /** What `--auth` will accept for this provider, for a message that lists them. */
130
+ export function methodsFor(manifest: ProviderManifest): readonly string[] {
131
+ return options(manifest).map((option) => option.id);
132
+ }
133
+
134
+ /**
135
+ * Choose, from the flag or from the operator — and from nothing else.
136
+ *
137
+ * Nothing is inferred from what this account authenticates with today, which is
138
+ * a decision rather than an omission. A connection authenticates one way at a
139
+ * time: whichever route this run picks replaces the credential the account has
140
+ * now, and re-running `connect` is how somebody switches. Defaulting to the
141
+ * stored route would mean reading a credential to answer a question that is the
142
+ * operator's on every run, and would hide the replacement behind a default that
143
+ * reads as a no-op.
144
+ *
145
+ * It used to try. The route was read from the *provisional* connection id,
146
+ * which is `pending` until identity is settled — so it found nothing every
147
+ * time, fell back to the browser, and performed the silent swap it existed to
148
+ * prevent on anyone who pressed Enter. Saying what the choice does is the part
149
+ * that was actually missing.
150
+ */
151
+ export async function chooseAuthMethod(input: {
152
+ readonly manifest: ProviderManifest;
153
+ /** `--auth <method>`, if it was given. */
154
+ readonly requested: string | undefined;
155
+ /** `--own-client`, which is the older spelling of one of these. */
156
+ readonly ownClient?: boolean;
157
+ readonly prompter?: Prompter;
158
+ }): Promise<ChosenMethod> {
159
+ const { manifest, requested } = input;
160
+ const prompter = input.prompter ?? terminalPrompter;
161
+ const available = options(manifest);
162
+
163
+ if (requested !== undefined) {
164
+ // `oauth` is not an option id. It is the older, coarser spelling — "the
165
+ // browser, however this profile already resolves it" — and dropping it
166
+ // would break a scripted `--auth oauth` for no gain.
167
+ if (requested === 'oauth') return { kind: 'oauth', client: undefined };
168
+
169
+ const picked = available.find((option) => option.id === requested);
170
+ if (picked) return picked.chosen;
171
+
172
+ throw new Error(
173
+ `${manifest.name} cannot authenticate with "${requested}". ` +
174
+ `--auth accepts: ${['oauth', ...methodsFor(manifest)].join(', ')}.`,
175
+ );
176
+ }
177
+
178
+ if (input.ownClient === true) return { kind: 'oauth', client: 'own' };
179
+
180
+ // One route, or none this file knows about: decide nothing and say nothing.
181
+ if (available.length < 2) return { kind: 'oauth', client: undefined };
182
+
183
+ // Nobody to ask. The flag above is the non-interactive answer, deliberately —
184
+ // guessing picks which credential gets overwritten (ADR-038).
185
+ if (!prompter.interactive) return { kind: 'oauth', client: undefined };
186
+
187
+ return ask(manifest, available, prompter);
188
+ }
189
+
190
+ async function ask(
191
+ manifest: ProviderManifest,
192
+ available: readonly Option[],
193
+ prompter: Prompter,
194
+ ): Promise<ChosenMethod> {
195
+ // "As it works today" for anyone who has not chosen otherwise: the hosted
196
+ // client where there is one, and otherwise the browser. Never the key — it is
197
+ // listed first because it is the one worth knowing about, and defaulting to
198
+ // the first entry would make Enter mean "the route with a console visit in
199
+ // it" for someone who was not reading.
200
+ const hosted = available.findIndex((option) => option.id === 'hosted_client');
201
+ const fallback =
202
+ hosted !== -1 ? hosted : Math.max(available.findIndex((option) => option.chosen.kind === 'oauth'), 0);
203
+ const preferred = String(fallback + 1);
204
+
205
+ progress();
206
+ progress(style.bold(`${manifest.name} can authenticate ${count(available.length)} ways`));
207
+ progress();
208
+ for (const [index, option] of available.entries()) {
209
+ progress(` ${index + 1}. ${option.label}`);
210
+ progress(style.dim(` ${option.detail}`));
211
+ }
212
+ progress();
213
+ // Printed whether or not this account is already connected, because it is a
214
+ // statement about what the command does rather than a reading of what is
215
+ // stored — and on a first connect it is true with nothing to replace.
216
+ progress(style.dim(' Whichever you pick becomes the only way in for this account. It replaces'));
217
+ progress(
218
+ style.dim(' whatever is stored for it now — a connection authenticates one way at a time.'),
219
+ );
220
+ progress();
221
+
222
+ const answer = await prompter.ask(` Which ${style.dim(`[${preferred}]`)}`);
223
+ const picked = answer.length === 0 ? preferred : answer;
224
+
225
+ const byNumber = available[Number(picked) - 1];
226
+ if (/^\d+$/.test(picked) && byNumber) return byNumber.chosen;
227
+
228
+ const byName = available.find((option) => option.id === picked);
229
+ if (byName) return byName.chosen;
230
+
231
+ throw new Error(
232
+ `"${picked}" is not one of the choices. Answer 1 to ${available.length}.`,
233
+ );
234
+ }
235
+
236
+ const WORDS = ['no', 'one', 'two', 'three', 'four', 'five'];
237
+ const count = (total: number): string => WORDS[total] ?? String(total);
@@ -17,6 +17,10 @@ export interface ConnectOutcome {
17
17
  readonly ok: boolean;
18
18
  readonly key?: string;
19
19
  readonly account?: string;
20
+ /** The profile written to, for a caller that cannot see the announce line. */
21
+ readonly profile?: string;
22
+ /** The target written to — which credential store now holds this account. */
23
+ readonly target?: string;
20
24
  readonly changes: readonly string[];
21
25
  readonly granted: readonly string[];
22
26
  /**
@@ -45,6 +49,35 @@ export const NOTHING = { changes: [], granted: [], discovered: 0 } as const;
45
49
 
46
50
  export const ALREADY = 'Already connected — nothing changed.';
47
51
 
52
+ /**
53
+ * One result for an account that turned out to be several services.
54
+ *
55
+ * The whole account succeeded only if every service did. A partial result is the
56
+ * case worth surfacing: one member blocked on a value leaves an account half
57
+ * connected, which `status` shows and prose does not.
58
+ */
59
+ export function familyOutcome(members: readonly ConnectOutcome[]): ConnectOutcome {
60
+ const blocked = members.find((outcome) => !outcome.ok);
61
+
62
+ return {
63
+ ...NOTHING,
64
+ ok: members.every((outcome) => outcome.ok),
65
+ members,
66
+ ...(blocked?.reason ? { reason: blocked.reason } : {}),
67
+ };
68
+ }
69
+
70
+ /**
71
+ * Which store an account landed in, for the caller not reading the terminal.
72
+ *
73
+ * `announceConnectTarget` fixes the human channel. A `--json` caller had the
74
+ * same blindness the operator did — and an agent is exactly the reader who
75
+ * cannot see the line printed above it.
76
+ */
77
+ export function where(runtime: { resolution: { profile: string; target: string } }) {
78
+ return { profile: runtime.resolution.profile, target: runtime.resolution.target };
79
+ }
80
+
48
81
  /**
49
82
  * What `connect` says last.
50
83
  *
@@ -101,7 +134,15 @@ export function renderOutcome(outcome: ConnectOutcome): void {
101
134
  */
102
135
  function renderBlocked(outcome: ConnectOutcome): void {
103
136
  progress();
104
- print(fail(outcome.reason === 'needs_browser' ? 'a browser is needed' : 'more is needed first'));
137
+ print(
138
+ fail(
139
+ outcome.reason === 'needs_browser'
140
+ ? 'a browser is needed'
141
+ : outcome.reason === 'needs_terminal'
142
+ ? 'a terminal is needed'
143
+ : 'more is needed first',
144
+ ),
145
+ );
105
146
 
106
147
  for (const line of (outcome.message ?? '').split('\n')) print(` ${line}`);
107
148