@lanes-sh/link 0.2.2 → 0.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +22 -8
- package/instructions/skills/lanes-link/SKILL.md +42 -14
- package/package.json +1 -1
- package/src/cli/argv.ts +50 -0
- package/src/cli/brand.ts +178 -0
- package/src/cli/callback-page.ts +108 -128
- package/src/cli/commands/connect/accounts.ts +5 -0
- package/src/cli/commands/connect/assertion.ts +187 -0
- package/src/cli/commands/connect/authorise.ts +61 -17
- package/src/cli/commands/connect/client.ts +37 -9
- package/src/cli/commands/connect/discover.ts +94 -0
- package/src/cli/commands/connect/family.ts +72 -0
- package/src/cli/commands/connect/index.ts +113 -115
- package/src/cli/commands/connect/method.ts +237 -0
- package/src/cli/commands/connect/outcome.ts +42 -1
- package/src/cli/commands/connect/pasted-token.ts +66 -0
- package/src/cli/commands/connect/requirements.ts +60 -8
- package/src/cli/commands/connect/setup.ts +16 -5
- package/src/cli/commands/connect/target-note.ts +34 -0
- package/src/cli/commands/identity.ts +258 -0
- package/src/cli/commands/knowledge/index.ts +390 -0
- package/src/cli/commands/knowledge/migrate.ts +180 -0
- package/src/cli/commands/knowledge/setup.ts +144 -0
- package/src/cli/commands/knowledge.ts +10 -0
- package/src/cli/commands/mcp/harnesses.ts +16 -2
- package/src/cli/commands/mcp/register.ts +9 -1
- package/src/cli/commands/mcp/stdio.ts +21 -0
- package/src/cli/commands/operate/dashboard.ts +107 -0
- package/src/cli/commands/operate/findings.ts +151 -0
- package/src/cli/commands/operate/inspect.ts +63 -164
- package/src/cli/commands/operate/outputs.ts +38 -11
- package/src/cli/commands/operate/policy.ts +7 -7
- package/src/cli/commands/operate/serve.ts +3 -0
- package/src/cli/commands/operate/status.ts +108 -1
- package/src/cli/commands/operate/token.ts +1 -1
- package/src/cli/commands/operate.ts +2 -0
- package/src/cli/commands/profile/declare.ts +154 -0
- package/src/cli/commands/profile/removal.ts +17 -0
- package/src/cli/commands/profile/remove.ts +5 -5
- package/src/cli/commands/profile.ts +83 -35
- package/src/cli/commands/secrets.ts +6 -6
- package/src/cli/commands/setup.ts +22 -6
- package/src/cli/commands/sync.ts +262 -0
- package/src/cli/commands/target.ts +65 -83
- package/src/cli/config-edit.ts +53 -144
- package/src/cli/config-repair.ts +186 -0
- package/src/cli/dashboard-page.ts +284 -0
- package/src/cli/dashboard-shell.ts +125 -0
- package/src/cli/dispatch-owner.ts +93 -0
- package/src/cli/identity.ts +12 -1
- package/src/cli/main.ts +90 -61
- package/src/cli/nearest.ts +45 -0
- package/src/cli/oauth-callback.ts +187 -0
- package/src/cli/oauth-exchange.ts +57 -15
- package/src/cli/oauth.ts +67 -177
- package/src/cli/output.ts +21 -5
- package/src/cli/provider-marks.ts +45 -0
- package/src/cli/runtime/open.ts +74 -51
- package/src/cli/runtime/registry.ts +60 -2
- package/src/cli/runtime/select.ts +26 -13
- package/src/cli/runtime/vault.ts +61 -0
- package/src/cli/runtime.ts +2 -1
- package/src/cli/selection.ts +365 -0
- package/src/cli/usage.ts +42 -11
- package/src/connectivity/auth/README.md +7 -1
- package/src/connectivity/auth/basic/index.ts +1 -1
- package/src/connectivity/auth/index.ts +14 -0
- package/src/connectivity/auth/oauth-authcode/broker.ts +26 -0
- package/src/connectivity/auth/oauth-authcode/index.ts +16 -2
- package/src/connectivity/auth/oauth-authcode/provider.ts +1 -1
- package/src/connectivity/auth/oauth-authcode/refresh.ts +3 -3
- package/src/connectivity/auth/oauth-jwt/README.md +33 -0
- package/src/connectivity/auth/oauth-jwt/index.ts +237 -0
- package/src/connectivity/auth/oauth-jwt/key.ts +148 -0
- package/src/connectivity/auth/resolve.ts +1 -1
- package/src/connectivity/auth/token.ts +11 -0
- package/src/connectivity/index.ts +2 -0
- package/src/connectivity/manifest/auth.ts +99 -2
- package/src/connectivity/manifest/identity.ts +12 -0
- package/src/connectivity/manifest/index.ts +3 -1
- package/src/connectivity/manifest/provider.ts +37 -8
- package/src/connectivity/manifest/requirements.ts +109 -6
- package/src/deployments/adapters/filesystem.ts +10 -1
- package/src/deployments/adapters/github-api.ts +106 -0
- package/src/deployments/adapters/github-commit.ts +103 -0
- package/src/deployments/adapters/github-repo.ts +356 -0
- package/src/deployments/adapters/github-testing.ts +258 -0
- package/src/deployments/adapters/github.ts +125 -0
- package/src/deployments/deploy.ts +94 -114
- package/src/deployments/discover.ts +103 -0
- package/src/deployments/driver.ts +8 -1
- package/src/deployments/gcp/driver.ts +3 -1
- package/src/deployments/knowledge.ts +119 -0
- package/src/deployments/prepare.ts +12 -6
- package/src/deployments/report.ts +117 -0
- package/src/deployments/servable.ts +82 -0
- package/src/deployments/serving.ts +165 -0
- package/src/deployments/sync-apply.ts +276 -0
- package/src/deployments/sync.ts +136 -0
- package/src/deployments/target.ts +3 -2
- package/src/deployments/upload.ts +19 -12
- package/src/dispatch/dispatch.ts +1 -1
- package/src/profile/deployments.ts +80 -0
- package/src/profile/identity.ts +60 -0
- package/src/profile/index.ts +23 -5
- package/src/profile/knowledge.ts +124 -0
- package/src/profile/load.ts +17 -5
- package/src/profile/primitives.ts +24 -1
- package/src/profile/schema.ts +81 -3
- package/src/profile/targets.ts +122 -109
- package/src/profile/workspace.ts +139 -79
- package/src/providers/google/calendar/index.ts +2 -0
- package/src/providers/google/contacts/index.ts +2 -0
- package/src/providers/google/docs/index.ts +2 -0
- package/src/providers/google/drive/index.ts +2 -0
- package/src/providers/google/gmail/index.ts +2 -0
- package/src/providers/google/gmail-imap/index.ts +125 -0
- package/src/providers/google/index.ts +2 -1
- package/src/providers/google/shared/oauth.ts +18 -6
- package/src/providers/google/shared/service-account.ts +110 -0
- package/src/providers/google/shared/setup.ts +21 -3
- package/src/providers/google/sheets/index.ts +2 -0
- package/src/providers/google/tasks/index.ts +2 -0
- package/src/providers/identity/provider.ts +166 -0
- package/src/providers/index.ts +3 -0
- package/src/providers/owner.ts +10 -2
- package/src/providers/scopes.ts +2 -0
- package/src/providers/setup/plan.ts +31 -9
- package/src/providers/setup/provider.ts +23 -0
- package/src/providers/slack/index.ts +81 -33
- package/src/providers/slack/oauth.ts +103 -0
- package/src/providers/slack/scopes.ts +37 -0
- package/src/server/container.ts +18 -1
- package/src/server/cors.ts +252 -0
- package/src/server/dashboard.ts +208 -0
- package/src/server/endpoint.ts +45 -1
- package/src/server/generations.ts +11 -2
- package/src/server/harness.ts +7 -0
- package/src/server/index.ts +45 -6
- package/src/server/mcp/index.ts +1 -0
- package/src/server/mcp/instructions.ts +28 -1
- package/src/server/mcp/visibility.ts +33 -0
- package/src/stores/blobs/route.ts +123 -0
|
@@ -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(
|
|
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 =
|
|
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
|
-
|
|
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
|
|
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 (
|
|
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(
|
|
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:
|
|
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(
|
|
31
|
-
|
|
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 =
|
|
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
|
+
}
|