@lanes-sh/link 0.2.2 → 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 (130) hide show
  1. package/README.md +21 -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 +56 -158
  31. package/src/cli/commands/operate/outputs.ts +38 -11
  32. package/src/cli/commands/operate/serve.ts +3 -0
  33. package/src/cli/commands/operate/token.ts +1 -1
  34. package/src/cli/commands/operate.ts +2 -0
  35. package/src/cli/commands/profile/declare.ts +154 -0
  36. package/src/cli/commands/profile/removal.ts +17 -0
  37. package/src/cli/commands/profile.ts +83 -35
  38. package/src/cli/commands/setup.ts +22 -6
  39. package/src/cli/commands/target.ts +65 -83
  40. package/src/cli/config-edit.ts +48 -144
  41. package/src/cli/config-repair.ts +186 -0
  42. package/src/cli/dashboard-page.ts +284 -0
  43. package/src/cli/dashboard-shell.ts +125 -0
  44. package/src/cli/identity.ts +12 -1
  45. package/src/cli/main.ts +68 -4
  46. package/src/cli/oauth-callback.ts +187 -0
  47. package/src/cli/oauth-exchange.ts +57 -15
  48. package/src/cli/oauth.ts +67 -177
  49. package/src/cli/output.ts +21 -5
  50. package/src/cli/provider-marks.ts +45 -0
  51. package/src/cli/runtime/open.ts +67 -49
  52. package/src/cli/runtime/registry.ts +60 -2
  53. package/src/cli/runtime/select.ts +26 -13
  54. package/src/cli/runtime/vault.ts +61 -0
  55. package/src/cli/runtime.ts +2 -1
  56. package/src/cli/selection.ts +357 -0
  57. package/src/cli/usage.ts +32 -9
  58. package/src/connectivity/auth/README.md +7 -1
  59. package/src/connectivity/auth/basic/index.ts +1 -1
  60. package/src/connectivity/auth/index.ts +14 -0
  61. package/src/connectivity/auth/oauth-authcode/broker.ts +26 -0
  62. package/src/connectivity/auth/oauth-authcode/index.ts +16 -2
  63. package/src/connectivity/auth/oauth-authcode/provider.ts +1 -1
  64. package/src/connectivity/auth/oauth-authcode/refresh.ts +3 -3
  65. package/src/connectivity/auth/oauth-jwt/README.md +33 -0
  66. package/src/connectivity/auth/oauth-jwt/index.ts +237 -0
  67. package/src/connectivity/auth/oauth-jwt/key.ts +148 -0
  68. package/src/connectivity/auth/resolve.ts +1 -1
  69. package/src/connectivity/auth/token.ts +11 -0
  70. package/src/connectivity/index.ts +2 -0
  71. package/src/connectivity/manifest/auth.ts +99 -2
  72. package/src/connectivity/manifest/identity.ts +12 -0
  73. package/src/connectivity/manifest/index.ts +3 -1
  74. package/src/connectivity/manifest/provider.ts +37 -8
  75. package/src/connectivity/manifest/requirements.ts +109 -6
  76. package/src/deployments/adapters/filesystem.ts +10 -1
  77. package/src/deployments/adapters/github-api.ts +106 -0
  78. package/src/deployments/adapters/github-commit.ts +103 -0
  79. package/src/deployments/adapters/github-repo.ts +356 -0
  80. package/src/deployments/adapters/github-testing.ts +258 -0
  81. package/src/deployments/adapters/github.ts +125 -0
  82. package/src/deployments/deploy.ts +48 -19
  83. package/src/deployments/driver.ts +8 -1
  84. package/src/deployments/gcp/driver.ts +3 -1
  85. package/src/deployments/knowledge.ts +119 -0
  86. package/src/deployments/prepare.ts +2 -2
  87. package/src/deployments/servable.ts +81 -0
  88. package/src/deployments/target.ts +3 -2
  89. package/src/deployments/upload.ts +2 -1
  90. package/src/dispatch/dispatch.ts +1 -1
  91. package/src/profile/identity.ts +60 -0
  92. package/src/profile/index.ts +15 -5
  93. package/src/profile/knowledge.ts +124 -0
  94. package/src/profile/load.ts +17 -5
  95. package/src/profile/primitives.ts +24 -1
  96. package/src/profile/schema.ts +46 -3
  97. package/src/profile/targets.ts +74 -114
  98. package/src/profile/workspace.ts +71 -84
  99. package/src/providers/google/calendar/index.ts +2 -0
  100. package/src/providers/google/contacts/index.ts +2 -0
  101. package/src/providers/google/docs/index.ts +2 -0
  102. package/src/providers/google/drive/index.ts +2 -0
  103. package/src/providers/google/gmail/index.ts +2 -0
  104. package/src/providers/google/gmail-imap/index.ts +125 -0
  105. package/src/providers/google/index.ts +2 -1
  106. package/src/providers/google/shared/oauth.ts +18 -6
  107. package/src/providers/google/shared/service-account.ts +110 -0
  108. package/src/providers/google/shared/setup.ts +5 -2
  109. package/src/providers/google/sheets/index.ts +2 -0
  110. package/src/providers/google/tasks/index.ts +2 -0
  111. package/src/providers/identity/provider.ts +166 -0
  112. package/src/providers/index.ts +3 -0
  113. package/src/providers/owner.ts +10 -2
  114. package/src/providers/scopes.ts +2 -0
  115. package/src/providers/setup/plan.ts +31 -9
  116. package/src/providers/setup/provider.ts +23 -0
  117. package/src/providers/slack/index.ts +81 -33
  118. package/src/providers/slack/oauth.ts +103 -0
  119. package/src/providers/slack/scopes.ts +37 -0
  120. package/src/server/container.ts +18 -1
  121. package/src/server/cors.ts +252 -0
  122. package/src/server/dashboard.ts +208 -0
  123. package/src/server/endpoint.ts +45 -1
  124. package/src/server/generations.ts +11 -2
  125. package/src/server/harness.ts +7 -0
  126. package/src/server/index.ts +45 -6
  127. package/src/server/mcp/index.ts +1 -0
  128. package/src/server/mcp/instructions.ts +28 -1
  129. package/src/server/mcp/visibility.ts +33 -0
  130. package/src/stores/blobs/route.ts +123 -0
@@ -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
 
@@ -0,0 +1,66 @@
1
+ import { PASTED } from '#connectivity/auth/index.ts';
2
+ import type { ProviderManifest } from '#connectivity';
3
+ import type { SecretStore } from '#secrets';
4
+ import { ok, progress } from '../../output.ts';
5
+ import { terminalPrompter, type Prompter } from '../../prompt.ts';
6
+ import { askForSetup } from './setup.ts';
7
+
8
+ /**
9
+ * `--auth pasted_token`: a credential the operator already holds.
10
+ *
11
+ * The escape hatch, and it exists because the flow above can be refused by
12
+ * somebody who is not in the room. A Slack workspace on Enterprise Grid
13
+ * requires an admin to approve an app before it can authenticate anyone, so an
14
+ * operator whose admin has not approved the Lanes app cannot connect at all —
15
+ * while a user token from an app their workspace already trusts works
16
+ * perfectly. Removing the paste would take Slack away from exactly the people
17
+ * who have the least ability to do anything about it.
18
+ *
19
+ * Written in the blob shape the OAuth path writes, into the same ref, which is
20
+ * what makes it cost nothing downstream: no refresh token means
21
+ * `upstreamAccessToken` hands the stored value back untouched, and no
22
+ * `expires_at` means nothing calls it stale. `auth.kind` stays `oauth` because
23
+ * it describes what the vendor offers, not how this one connection was filled.
24
+ */
25
+ export async function authorisePastedToken(input: {
26
+ manifest: ProviderManifest;
27
+ connectionId: string;
28
+ credentials: SecretStore;
29
+ prompter?: Prompter;
30
+ }): Promise<void> {
31
+ const { manifest, connectionId, credentials } = input;
32
+ const prompter = input.prompter ?? terminalPrompter;
33
+ if (manifest.auth.kind !== 'oauth') return;
34
+
35
+ const prompts = (manifest.setup?.prompts ?? []).filter((prompt) => prompt.scope === 'connection');
36
+ if (prompts.length === 0) {
37
+ throw new Error(
38
+ `${manifest.name} has no pasted-credential path: it does not describe a token to ask you ` +
39
+ 'for. Authorise in a browser instead — drop --auth, or pass --auth oauth.',
40
+ );
41
+ }
42
+
43
+ const ref = `${manifest.id}/${connectionId}`;
44
+ const answers = await askForSetup(
45
+ manifest,
46
+ prompts,
47
+ `Stored at ${ref}, in the credential store — never in config.`,
48
+ prompter,
49
+ );
50
+
51
+ await credentials.set(
52
+ ref,
53
+ JSON.stringify({
54
+ access_token: answers.get(prompts[0]!.key)!,
55
+ token_type: 'Bearer',
56
+ // What the token can do was decided wherever it was minted and cannot be
57
+ // read back, so this records what was asked for and no more. The scope
58
+ // gate the browser path runs has nothing to show here — recorded as a
59
+ // weaker guarantee in security.md rather than papered over.
60
+ scope: manifest.auth.scopes.join(' '),
61
+ authorized_via: PASTED,
62
+ }),
63
+ );
64
+
65
+ progress(ok('token stored'));
66
+ }
@@ -32,7 +32,12 @@ export async function missingRequirements(
32
32
  return missing;
33
33
  }
34
34
 
35
- export type BlockedReason = 'needs_id' | 'needs_browser' | 'missing_credentials';
35
+ export type BlockedReason =
36
+ | 'needs_id'
37
+ | 'needs_browser'
38
+ /** A question only a person can answer, on a run with nobody to ask. */
39
+ | 'needs_terminal'
40
+ | 'missing_credentials';
36
41
 
37
42
  export interface Blocked {
38
43
  readonly reason: BlockedReason;
@@ -60,14 +65,53 @@ export async function preflight(input: {
60
65
  readonly manifest: ProviderManifest;
61
66
  readonly connectionId: string | undefined;
62
67
  readonly profile: string;
63
- readonly credentials: { has(ref: string): Promise<boolean> };
64
- /** How the operator spelled the target — `icloud`, or `gmail.main`. */
68
+ /** Which target's credential store the values have to be in. */
65
69
  readonly target: string;
70
+ readonly credentials: { has(ref: string): Promise<boolean> };
71
+ /**
72
+ * How the operator spelled the provider — `icloud`, or `gmail.main`.
73
+ *
74
+ * Called `spec` and not `target`, which is what it was: this file holds the
75
+ * only two meanings of that word in one scope, and the Lanes target is the one
76
+ * that decides which credential store a suggested command writes into. A
77
+ * transposition here produces a command that runs and stores a credential
78
+ * somewhere nobody looks.
79
+ */
80
+ readonly spec: string;
81
+ /**
82
+ * Which way in the operator chose, for a provider offering two.
83
+ *
84
+ * The whole reason this parameter exists is that the OAuth refusal below is
85
+ * about a *browser*, and the key route opens none. Without it a scripted
86
+ * `connect --auth <key method>` would be turned away by a message describing
87
+ * a step it does not perform.
88
+ */
89
+ readonly method?: 'oauth' | 'assertion' | 'pasted';
66
90
  }): Promise<Blocked | null> {
67
- const { manifest, connectionId, profile, target } = input;
68
- const rerun = `lanes link connect ${target} --profile ${profile}`;
91
+ const { manifest, connectionId, profile, target, spec } = input;
92
+ const method = input.method ?? 'oauth';
93
+ const rerun = `lanes link connect ${spec} --profile ${profile} --target ${target}`;
94
+ const assertion = manifest.auth.kind === 'oauth' ? manifest.auth.assertion : undefined;
69
95
 
70
- if (manifest.auth.kind === 'oauth') {
96
+ if (method === 'assertion' && assertion) {
97
+ // The key can be placed ahead of time; who it acts as cannot. That value
98
+ // lives inside the pointer `connect` writes, so where it is mandatory this
99
+ // run has a question and nobody to ask — and refusing here is better than
100
+ // storing a credential that reads every mailbox as empty.
101
+ if (assertion.delegation === 'required') {
102
+ return {
103
+ reason: 'needs_terminal',
104
+ message:
105
+ `${manifest.name} can only reach an account by acting as someone, and who that is has ` +
106
+ `to be typed.\n Nothing was written. Run this in a terminal:`,
107
+ needs: [],
108
+ then: `${rerun} --auth ${assertion.method}`,
109
+ };
110
+ }
111
+ } else if (manifest.auth.kind === 'oauth' && method !== 'pasted') {
112
+ // `pasted` opens no browser, so the refusal below does not describe it. It
113
+ // needs a value instead, which *can* be placed ahead of time — so it falls
114
+ // through to the requirement check and gets the `secrets set` line.
71
115
  return {
72
116
  reason: 'needs_browser',
73
117
  message:
@@ -78,7 +122,12 @@ export async function preflight(input: {
78
122
  };
79
123
  }
80
124
 
81
- const { requirements, needsId } = setupRequirements(manifest, connectionId, profile);
125
+ const { requirements, needsId } = setupRequirements(
126
+ manifest,
127
+ connectionId,
128
+ { profile, target },
129
+ { method },
130
+ );
82
131
 
83
132
  if (needsId) {
84
133
  return {
@@ -98,6 +147,9 @@ export async function preflight(input: {
98
147
  reason: 'missing_credentials',
99
148
  message: `${manifest.name} needs ${missing.length} value(s) in the credential store first.`,
100
149
  needs: missing,
101
- then: `${rerun}${connectionId ? ` --id ${connectionId}` : ''} --non-interactive`,
150
+ then:
151
+ `${rerun}${connectionId ? ` --id ${connectionId}` : ''}` +
152
+ `${method === 'assertion' && assertion ? ` --auth ${assertion.method}` : ''}` +
153
+ `${method === 'pasted' ? ' --auth pasted_token' : ''} --non-interactive`,
102
154
  };
103
155
  }
@@ -1,5 +1,5 @@
1
1
  import type { SecretStore } from '#secrets';
2
- import type { ProviderManifest, SetupPrompt } from '#connectivity';
2
+ import type { ProviderManifest, SetupDeclaration, SetupPrompt } from '#connectivity';
3
3
  import { credentialRefForConnection } from '#connectivity';
4
4
  import { ConfigDocument } from '../../config-edit.ts';
5
5
  import { ok, progress, style } from '../../output.ts';
@@ -27,8 +27,18 @@ import { terminalPrompter, type Prompter } from '../../prompt.ts';
27
27
  * an unregistered scope is refused at consent, and a disabled API consents
28
28
  * cleanly and then 403s on every call.
29
29
  */
30
- export function printSetup(manifest: ProviderManifest, note: string): void {
31
- const setup = manifest.setup;
30
+ export function printSetup(
31
+ manifest: ProviderManifest,
32
+ note: string,
33
+ /**
34
+ * Which walkthrough. Defaults to the provider's own, and is passed explicitly
35
+ * by a provider that has more than one way in — those steps are a different
36
+ * console doing a different job, and printing the browser-flow instructions
37
+ * to someone who chose a key would be worse than printing nothing.
38
+ */
39
+ declaration: SetupDeclaration | undefined = manifest.setup,
40
+ ): void {
41
+ const setup = declaration;
32
42
  if (!setup) return;
33
43
 
34
44
  progress();
@@ -55,15 +65,16 @@ export async function askForSetup(
55
65
  prompts: readonly SetupPrompt[],
56
66
  note: string,
57
67
  prompter: Prompter = terminalPrompter,
68
+ declaration: SetupDeclaration | undefined = manifest.setup,
58
69
  ): Promise<Map<string, string>> {
59
- const setup = manifest.setup;
70
+ const setup = declaration;
60
71
  if (!setup) {
61
72
  throw new Error(
62
73
  `Provider "${manifest.id}" needs a credential but declares no setup, so there is no way to learn what to ask you for. Add a setup block to its manifest.`,
63
74
  );
64
75
  }
65
76
 
66
- printSetup(manifest, note);
77
+ printSetup(manifest, note, setup);
67
78
 
68
79
  const answers = new Map<string, string>();
69
80
  for (const prompt of prompts) {
@@ -0,0 +1,34 @@
1
+ import type { Config, Resolution } from '#profile';
2
+ import { announce } from '../../output.ts';
3
+
4
+ /**
5
+ * The line `connect` prints before it acts.
6
+ *
7
+ * Split from the five steps for the reason `outcome.ts` gives: the orchestration
8
+ * is about vendors and credentials, this is about what a caller is told. It buys
9
+ * the same thing too — no runtime, no config file and no credential store are
10
+ * needed to check that the line appears and that `--json` stays parseable.
11
+ *
12
+ * `connect` was the only mutating command that never said which target it wrote
13
+ * to, and the only one that writes a credential into a real store.
14
+ *
15
+ * This file also *held* a warning, for the case where a bare `connect` resolved
16
+ * to a local target while the profile declared a deployed one. ADR-037 removed
17
+ * the case rather than the warning: a target is named on the command line or the
18
+ * command does not run, so there is no longer a selection the operator did not
19
+ * make. What replaced it sits one step earlier — `deployments/servable.ts`
20
+ * refuses a *deploy* that would send a profile the revision cannot open, which
21
+ * is the same mistake caught where it is still cheap.
22
+ */
23
+ export function announceConnectTarget(
24
+ runtime: { readonly resolution: Resolution; readonly config: Pick<Config, 'targets'> },
25
+ json?: boolean | undefined,
26
+ ): void {
27
+ // `emit`'s early return only protects lines printed *at* the emit, and this
28
+ // one has to precede the browser — so it carries its own guard, the one
29
+ // `audit.ts` and `owner/shared.ts` already use. `output.ts` gives the reason
30
+ // beside `emit`: a line of prose in front of a JSON document corrupts it.
31
+ if (json === true) return;
32
+
33
+ announce(runtime.resolution);
34
+ }