@lanes-sh/link 0.7.2 → 0.9.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.
- package/README.md +20 -10
- package/instructions/agents/lanes-link-scout.md +2 -2
- package/instructions/skills/lanes-link/SKILL.md +148 -73
- package/package.json +2 -1
- package/src/audit/index.ts +8 -1
- package/src/auth/index.ts +58 -2
- package/src/auth/lanes/assertion.ts +256 -0
- package/src/auth/lanes/callback.ts +135 -0
- package/src/auth/lanes/federation.ts +50 -0
- package/src/auth/lanes/login.ts +294 -0
- package/src/auth/lanes/members.ts +103 -0
- package/src/auth/lanes/session.ts +97 -0
- package/src/auth/oauth/grant.ts +183 -0
- package/src/auth/oauth/result.ts +27 -0
- package/src/auth/oauth/server.ts +176 -203
- package/src/auth/oauth/store.ts +85 -4
- package/src/auth/remote.ts +32 -9
- package/src/cli/accepts.ts +109 -0
- package/src/cli/argv.ts +57 -3
- package/src/cli/audit-change.ts +140 -0
- package/src/cli/callback-page.ts +36 -115
- package/src/cli/commands/auth-dispatch.ts +48 -0
- package/src/cli/commands/auth.ts +229 -0
- package/src/cli/commands/connect/accounts.ts +4 -4
- package/src/cli/commands/connect/authorise.ts +4 -4
- package/src/cli/commands/connect/bind-credential.ts +2 -1
- package/src/cli/commands/connect/custom/index.ts +1 -1
- package/src/cli/commands/connect/custom/write.ts +2 -2
- package/src/cli/commands/connect/grant.ts +29 -14
- package/src/cli/commands/connect/index.ts +90 -88
- package/src/cli/commands/connect/options.ts +83 -0
- package/src/cli/commands/connect/registration.ts +50 -0
- package/src/cli/commands/connect/requirements.ts +1 -1
- package/src/cli/commands/connect/settle.ts +16 -6
- package/src/cli/commands/connect/target-note.ts +7 -2
- package/src/cli/commands/connect/unknown.ts +1 -1
- package/src/cli/commands/connect/variables.ts +3 -2
- package/src/cli/commands/connection-list.ts +116 -0
- package/src/cli/commands/connection.ts +183 -165
- package/src/cli/commands/grant.ts +140 -0
- package/src/cli/commands/identity.ts +24 -12
- package/src/cli/commands/knowledge/index.ts +49 -89
- package/src/cli/commands/knowledge/migrate.ts +79 -13
- package/src/cli/commands/knowledge/show.ts +97 -0
- package/src/cli/commands/knowledge.ts +2 -1
- package/src/cli/commands/mcp/harnesses.ts +30 -8
- package/src/cli/commands/mcp/onboarding.ts +86 -0
- package/src/cli/commands/mcp/register.ts +16 -2
- package/src/cli/commands/mcp.ts +1 -0
- package/src/cli/commands/members.ts +288 -0
- package/src/cli/commands/operate/attach.ts +3 -3
- package/src/cli/commands/operate/audit.ts +11 -7
- package/src/cli/commands/operate/auth.ts +28 -11
- package/src/cli/commands/operate/findings.ts +2 -1
- package/src/cli/commands/operate/inspect.ts +37 -19
- package/src/cli/commands/operate/migrate.ts +33 -13
- package/src/cli/commands/operate/outputs.ts +3 -3
- package/src/cli/commands/operate/pair-certificate.ts +141 -0
- package/src/cli/commands/operate/pair.ts +324 -0
- package/src/cli/commands/operate/policy.ts +73 -22
- package/src/cli/commands/operate/serve.ts +53 -5
- package/src/cli/commands/operate/status.ts +18 -10
- package/src/cli/commands/operate/tools.ts +2 -2
- package/src/cli/commands/operate.ts +2 -0
- package/src/cli/commands/owner/assets.ts +2 -2
- package/src/cli/commands/owner/entities.ts +2 -2
- package/src/cli/commands/owner/memory.ts +2 -2
- package/src/cli/commands/owner/shared.ts +13 -2
- package/src/cli/commands/owner/skills.ts +28 -8
- package/src/cli/commands/owner/tasks.ts +2 -2
- package/src/cli/commands/owner/vault.ts +3 -3
- package/src/cli/commands/profile/disposition.ts +236 -0
- package/src/cli/commands/profile/removal.ts +154 -64
- package/src/cli/commands/profile/remove.ts +83 -7
- package/src/cli/commands/profile.ts +79 -16
- package/src/cli/commands/relabel.ts +112 -0
- package/src/cli/commands/secrets.ts +39 -17
- package/src/cli/commands/set-workspace.ts +96 -0
- package/src/cli/commands/setup.ts +2 -2
- package/src/cli/commands/sync.ts +8 -8
- package/src/cli/commands/target.ts +9 -7
- package/src/cli/commands/update-migration.ts +54 -0
- package/src/cli/commands/update.ts +78 -24
- package/src/cli/config-edit.ts +99 -143
- package/src/cli/config-migrate.ts +82 -64
- package/src/cli/config-repair-sweep.ts +119 -0
- package/src/cli/config-repair.ts +131 -125
- package/src/cli/config-templates.ts +200 -0
- package/src/cli/contract3-credentials.ts +294 -0
- package/src/cli/contract3-data.ts +262 -0
- package/src/cli/contract3-layout.ts +46 -0
- package/src/cli/contract3-shape.ts +212 -0
- package/src/cli/contract3.ts +399 -0
- package/src/cli/contract4-credentials.ts +207 -0
- package/src/cli/contract4-data.ts +399 -0
- package/src/cli/contract4-rename.ts +73 -0
- package/src/cli/contract4-yaml.ts +223 -0
- package/src/cli/contract4.ts +342 -0
- package/src/cli/endpoint-url.ts +1 -1
- package/src/cli/identity.ts +44 -26
- package/src/cli/lanes.ts +25 -1
- package/src/cli/main.ts +94 -14
- package/src/cli/migrate-move.ts +166 -0
- package/src/cli/migrate-plan.ts +12 -6
- package/src/cli/output.ts +34 -1
- package/src/cli/publish.ts +6 -7
- package/src/cli/runtime/open.ts +64 -99
- package/src/cli/runtime/registry.ts +6 -7
- package/src/cli/runtime/select.ts +2 -11
- package/src/cli/runtime/stores.ts +58 -0
- package/src/cli/runtime/types.ts +106 -0
- package/src/cli/runtime/vault.ts +19 -4
- package/src/cli/runtime/workspace.ts +60 -0
- package/src/cli/runtime.ts +2 -1
- package/src/cli/selection-require.ts +44 -13
- package/src/cli/selection.ts +127 -145
- package/src/cli/usage.ts +40 -20
- package/src/cli/workspace-migrate.ts +152 -22
- package/src/connectivity/manifest/provider.ts +34 -13
- package/src/connectivity/manifest/requirements.ts +1 -1
- package/src/connectivity/transports/imap/parser.ts +70 -9
- package/src/deployments/adapters/filesystem.ts +18 -3
- package/src/deployments/bind.ts +1 -1
- package/src/deployments/deploy.ts +38 -29
- package/src/deployments/gcp/bucket.ts +58 -11
- package/src/deployments/gcp/provision.ts +7 -7
- package/src/deployments/knowledge.ts +9 -4
- package/src/deployments/prepare.ts +72 -24
- package/src/deployments/record.ts +1 -1
- package/src/deployments/report.ts +2 -2
- package/src/deployments/serving.ts +15 -74
- package/src/deployments/target.ts +34 -13
- package/src/deployments/upload.ts +60 -27
- package/src/dispatch/deps.ts +88 -0
- package/src/dispatch/dispatch.ts +21 -62
- package/src/policy/index.ts +47 -15
- package/src/profile/connections.ts +195 -0
- package/src/profile/deployments.ts +86 -8
- package/src/profile/index.ts +35 -6
- package/src/profile/knowledge.ts +18 -5
- package/src/profile/layout.ts +163 -90
- package/src/profile/load.ts +133 -64
- package/src/profile/pairing.ts +32 -0
- package/src/profile/primitives.ts +35 -1
- package/src/profile/registry.ts +6 -6
- package/src/profile/schema.ts +181 -21
- package/src/profile/targets.ts +21 -9
- package/src/profile/testing.ts +104 -2
- package/src/profile/workspace.ts +124 -33
- package/src/providers/assets/provider.ts +6 -6
- package/src/providers/custom/index.ts +1 -1
- package/src/providers/custom/load.ts +2 -3
- package/src/providers/entities/provider.ts +6 -6
- package/src/providers/entities/writes.ts +1 -1
- package/src/providers/identity/provider.ts +2 -2
- package/src/providers/memory/provider.ts +26 -8
- package/src/providers/setup/plan.ts +1 -1
- package/src/providers/setup/provider.ts +3 -3
- package/src/providers/skills/provider.ts +2 -2
- package/src/providers/slack/index.ts +2 -2
- package/src/providers/tasks/provider.ts +6 -6
- package/src/providers/vault/provider.ts +1 -1
- package/src/registry/policy-bridge.ts +33 -11
- package/src/registry/reconcile.ts +4 -4
- package/src/registry/registry.ts +1 -1
- package/src/server/authorization.ts +94 -0
- package/src/server/edge.ts +14 -1
- package/src/server/endpoint.ts +89 -104
- package/src/server/generation.ts +10 -1
- package/src/server/harness.ts +71 -13
- package/src/server/index.ts +31 -0
- package/src/server/mcp/build.ts +20 -1
- package/src/server/mcp/client-info.ts +54 -0
- package/src/server/mcp/guide.ts +120 -0
- package/src/server/mcp/instructions.ts +22 -22
- package/src/server/mcp/prompts.ts +7 -3
- package/src/server/mcp/resources.ts +16 -8
- package/src/server/mcp/routing.ts +3 -3
- package/src/server/mcp/tools.ts +25 -6
- package/src/server/mcp/visibility.ts +74 -7
- package/src/server/oauth.ts +29 -109
- package/src/server/read/credential.ts +134 -0
- package/src/server/read/deployed.ts +56 -0
- package/src/server/read/listener.ts +54 -0
- package/src/server/read/open.ts +101 -0
- package/src/server/read/routes.ts +247 -0
- package/src/server/read/state.ts +171 -0
- package/src/stores/blobs/conformance.ts +19 -0
- package/src/stores/state/index.ts +76 -10
- package/src/stores/state/testing.ts +5 -1
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { formatPlan, planIsNoop, planReconcile } from '#registry';
|
|
2
|
+
import type { ConnectionConfig, SelectedConnection } from '#profile';
|
|
2
3
|
import { DEFAULT_SURFACES } from '../../config-repair.ts';
|
|
3
4
|
import { announce, announceProfile, emit, fail, ok, print, warn } from '../../output.ts';
|
|
4
5
|
import { staleNudge } from '../../release.ts';
|
|
@@ -16,6 +17,18 @@ import { migratedContract, migratedRenamedProviders } from './migrate.ts';
|
|
|
16
17
|
*/
|
|
17
18
|
|
|
18
19
|
/** Static validation only. No external calls, no database, no credentials. */
|
|
20
|
+
/**
|
|
21
|
+
* The accounts this profile actually reaches, as reconcile wants them.
|
|
22
|
+
*
|
|
23
|
+
* A profile grants a subset of the workspace's connections (ADR-057), and
|
|
24
|
+
* reconcile is about the ones it reaches: an account granted to nobody has no
|
|
25
|
+
* state to keep in step, and marking it `disabled` here would fight with the
|
|
26
|
+
* profile next door that does grant it.
|
|
27
|
+
*/
|
|
28
|
+
function grantedConnections(runtime: { connections: readonly SelectedConnection[] }): ConnectionConfig[] {
|
|
29
|
+
return runtime.connections.map(({ connection }) => connection);
|
|
30
|
+
}
|
|
31
|
+
|
|
19
32
|
export async function check(flags: GlobalFlags): Promise<void> {
|
|
20
33
|
const { selection, config } = await resolveProfileOnly(flags);
|
|
21
34
|
announceProfile(selection);
|
|
@@ -25,8 +38,10 @@ export async function check(flags: GlobalFlags): Promise<void> {
|
|
|
25
38
|
// validates a file, and the file is the same whichever target reads it.
|
|
26
39
|
print(ok(`${selection.profilePath} is valid`));
|
|
27
40
|
print(
|
|
28
|
-
` ${config.
|
|
29
|
-
`${config.
|
|
41
|
+
` ${config.grants.length} grant(s), ` +
|
|
42
|
+
`${config.grants.reduce((n, g) => n + g.allow.length, 0)} allow rule(s), ` +
|
|
43
|
+
`${config.grants.reduce((n, g) => n + g.deny.length, 0)} deny rule(s), ` +
|
|
44
|
+
`${config.members.length} member(s)`,
|
|
30
45
|
);
|
|
31
46
|
}
|
|
32
47
|
|
|
@@ -35,7 +50,7 @@ export async function plan(flags: GlobalFlags): Promise<void> {
|
|
|
35
50
|
const runtime = await openRuntime(flags);
|
|
36
51
|
try {
|
|
37
52
|
announce(runtime.resolution);
|
|
38
|
-
const result = await planReconcile(runtime
|
|
53
|
+
const result = await planReconcile(grantedConnections(runtime), runtime.state, runtime.credentials, runtime.manifestFor);
|
|
39
54
|
print(formatPlan(result));
|
|
40
55
|
} finally {
|
|
41
56
|
await runtime.close();
|
|
@@ -100,7 +115,7 @@ export async function doctor(flags: DoctorFlags): Promise<void> {
|
|
|
100
115
|
|
|
101
116
|
try {
|
|
102
117
|
const forSelection = (command: string) =>
|
|
103
|
-
`${command} --profile ${runtime.resolution.profile} --
|
|
118
|
+
`${command} --profile ${runtime.resolution.profile} --workspace ${runtime.resolution.target}`;
|
|
104
119
|
|
|
105
120
|
checks.push('config is valid');
|
|
106
121
|
checks.push('state store is reachable');
|
|
@@ -134,7 +149,7 @@ export async function doctor(flags: DoctorFlags): Promise<void> {
|
|
|
134
149
|
// `probeConnections` answers it by attempting the renewal, which is the only
|
|
135
150
|
// thing that actually knows. Same classifier as `lanes link auth`, so the two
|
|
136
151
|
// cannot drift apart again.
|
|
137
|
-
const probed = await probeConnections(runtime, runtime
|
|
152
|
+
const probed = await probeConnections(runtime, grantedConnections(runtime), forSelection);
|
|
138
153
|
|
|
139
154
|
for (const result of probed) {
|
|
140
155
|
switch (result.verdict) {
|
|
@@ -209,7 +224,7 @@ export async function doctor(flags: DoctorFlags): Promise<void> {
|
|
|
209
224
|
}
|
|
210
225
|
}
|
|
211
226
|
|
|
212
|
-
for (const name of new Set(runtime.
|
|
227
|
+
for (const name of new Set(grantedConnections(runtime).map((one) => one.provider))) {
|
|
213
228
|
if (!runtime.registry.has(name)) {
|
|
214
229
|
problems.push({
|
|
215
230
|
kind: 'unknown_provider',
|
|
@@ -236,22 +251,25 @@ export async function doctor(flags: DoctorFlags): Promise<void> {
|
|
|
236
251
|
// covering it is not reported. That is the same rule the repair follows, and
|
|
237
252
|
// reading it here from `policy.deny` rather than asking the repair keeps
|
|
238
253
|
// `doctor` a read.
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
254
|
+
// A grant row *is* the connection and the rule together now (ADR-058), so
|
|
255
|
+
// "has a row but no rule" is a state that stopped existing — which removes
|
|
256
|
+
// the half-repaired case this check was originally written for. What is left
|
|
257
|
+
// is one question per surface: does this profile grant one, and is the grant
|
|
258
|
+
// more than a deny.
|
|
259
|
+
const grantFor = (provider: string) =>
|
|
260
|
+
runtime.config.grants.find((grant) => grant.connection.startsWith(`${provider}.`));
|
|
243
261
|
|
|
244
262
|
const missing = DEFAULT_SURFACES.filter((provider) => {
|
|
245
|
-
const
|
|
246
|
-
if (
|
|
263
|
+
const grant = grantFor(provider);
|
|
264
|
+
if (grant === undefined) return true;
|
|
247
265
|
|
|
248
|
-
const
|
|
249
|
-
|
|
250
|
-
);
|
|
251
|
-
const granted = runtime.config.policy.allow.some(
|
|
266
|
+
const rule = `${provider}.*`;
|
|
267
|
+
const denied = grant.deny.some(
|
|
252
268
|
(entry) => entry.capability === '*' || entry.capability === rule,
|
|
253
269
|
);
|
|
254
|
-
|
|
270
|
+
if (denied) return false;
|
|
271
|
+
|
|
272
|
+
return !grant.allow.some((entry) => entry.capability === '*' || entry.capability === rule);
|
|
255
273
|
});
|
|
256
274
|
|
|
257
275
|
if (missing.length > 0) {
|
|
@@ -287,7 +305,7 @@ export async function doctor(flags: DoctorFlags): Promise<void> {
|
|
|
287
305
|
warnings.push({ kind: 'capability_drift', message }),
|
|
288
306
|
);
|
|
289
307
|
|
|
290
|
-
const drift = await planReconcile(runtime
|
|
308
|
+
const drift = await planReconcile(grantedConnections(runtime), runtime.state, runtime.credentials, runtime.manifestFor);
|
|
291
309
|
if (!planIsNoop(drift)) {
|
|
292
310
|
warnings.push({
|
|
293
311
|
kind: 'reconcile_drift',
|
|
@@ -309,7 +327,7 @@ export async function doctor(flags: DoctorFlags): Promise<void> {
|
|
|
309
327
|
const rotation = await unboundRotatableRefs({
|
|
310
328
|
deploy: runtime.declared.deploy,
|
|
311
329
|
target: runtime.target,
|
|
312
|
-
connections: runtime
|
|
330
|
+
connections: grantedConnections(runtime),
|
|
313
331
|
manifestFor: runtime.manifestFor,
|
|
314
332
|
});
|
|
315
333
|
if (rotation.unbound.length > 0) {
|
|
@@ -1,7 +1,10 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
CONNECTIONS_FILE,
|
|
3
|
+
listProfiles, ConfigError, resolveSelection, resolveTargetWorkspace, resolveWorkspaceRoot,
|
|
4
|
+
SUPPORTED_CONTRACT } from '#profile';
|
|
2
5
|
import { ConfigDocument } from '../../config-edit.ts';
|
|
3
6
|
import { migrateRenamedProviders, pendingRenames, shapeOf } from '../../config-migrate.ts';
|
|
4
|
-
import {
|
|
7
|
+
import { migrateToCurrentContract } from '../../workspace-migrate.ts';
|
|
5
8
|
import { emit, fail, ok, print, style, warn } from '../../output.ts';
|
|
6
9
|
import { openSecretStoreFor, type GlobalFlags } from '../../runtime.ts';
|
|
7
10
|
|
|
@@ -52,16 +55,25 @@ export async function migratedRenamedProviders(
|
|
|
52
55
|
const root = await resolveTargetWorkspace(localRoot, target).catch(() => localRoot);
|
|
53
56
|
|
|
54
57
|
const selection = await resolveSelection({ profileFlag: flags.profile, root });
|
|
55
|
-
|
|
58
|
+
|
|
59
|
+
// The rename is a property of the *connection*, so it is found in the
|
|
60
|
+
// workspace's file (ADR-057) — and applied across every profile, because any
|
|
61
|
+
// of them may grant the row being renamed.
|
|
62
|
+
const document = await ConfigDocument.openKey(root, CONNECTIONS_FILE);
|
|
56
63
|
if (pendingRenames(document).length === 0) return false;
|
|
57
64
|
|
|
65
|
+
const profiles: ConfigDocument[] = [];
|
|
66
|
+
for (const name of await listProfiles(root)) {
|
|
67
|
+
profiles.push(await ConfigDocument.open(root, name));
|
|
68
|
+
}
|
|
69
|
+
|
|
58
70
|
// Shape-only, because the check this document fails runs after the schema.
|
|
59
71
|
// Throws when it is malformed beyond a rename, which is a better sentence
|
|
60
72
|
// than the referential one it would otherwise be reported under.
|
|
61
|
-
const config = shapeOf(
|
|
62
|
-
const credentials = await openSecretStoreFor(
|
|
73
|
+
const config = shapeOf(await ConfigDocument.open(root, selection.profile));
|
|
74
|
+
const credentials = await openSecretStoreFor(root, target);
|
|
63
75
|
|
|
64
|
-
const migration = await migrateRenamedProviders(document, credentials, {
|
|
76
|
+
const migration = await migrateRenamedProviders(document, profiles, credentials, {
|
|
65
77
|
apply: flags.fix === true,
|
|
66
78
|
});
|
|
67
79
|
|
|
@@ -141,9 +153,17 @@ export async function migratedContract(flags: RenameFlags, refusal: unknown): Pr
|
|
|
141
153
|
const where = await resolveTargetWorkspace(root, target).catch(() => null);
|
|
142
154
|
if (where === null) return false;
|
|
143
155
|
|
|
144
|
-
|
|
156
|
+
// Running the migration is how "is there anything to do" is answered, rather
|
|
157
|
+
// than a predicate beside it. The predicate was `needsMigration`, which asks
|
|
158
|
+
// only about contract 1 — so a contract-2 bucket, the exact thing this
|
|
159
|
+
// function exists to rescue, returned false here and fell through to the
|
|
160
|
+
// refusal it was supposed to fix. With `--fix` absent this writes nothing.
|
|
161
|
+
const migration = await migrateToCurrentContract(where, {
|
|
162
|
+
apply: flags.fix === true,
|
|
163
|
+
target,
|
|
164
|
+
});
|
|
165
|
+
if (migration.alreadyCurrent) return false;
|
|
145
166
|
|
|
146
|
-
const migration = await migrateWorkspace(where, { apply: flags.fix === true });
|
|
147
167
|
const applied = flags.fix === true;
|
|
148
168
|
const remote = where !== root;
|
|
149
169
|
|
|
@@ -166,8 +186,8 @@ export async function migratedContract(flags: RenameFlags, refusal: unknown): Pr
|
|
|
166
186
|
print();
|
|
167
187
|
print(
|
|
168
188
|
applied
|
|
169
|
-
? ok(
|
|
170
|
-
: warn(
|
|
189
|
+
? ok(`migrated to contract ${SUPPORTED_CONTRACT} — a workspace declares its own adapters, and holds the connections a profile grants`)
|
|
190
|
+
: warn(`this workspace is behind contract ${SUPPORTED_CONTRACT}, and nothing here reads that any more`),
|
|
171
191
|
);
|
|
172
192
|
for (const change of migration.changes) print(` ${change}`);
|
|
173
193
|
|
|
@@ -177,15 +197,15 @@ export async function migratedContract(flags: RenameFlags, refusal: unknown): Pr
|
|
|
177
197
|
style.dim(
|
|
178
198
|
applied
|
|
179
199
|
? ' The endpoint serving this bucket is running an older image and cannot read\n' +
|
|
180
|
-
` what was just written. Roll a new one: lanes link deploy --
|
|
181
|
-
: ` lanes link deploy --
|
|
200
|
+
` what was just written. Roll a new one: lanes link deploy --workspace ${target}`
|
|
201
|
+
: ` lanes link deploy --workspace ${target} migrates it and ships the image that\n` +
|
|
182
202
|
' understands it, in one step. Prefer that to --fix here.',
|
|
183
203
|
),
|
|
184
204
|
);
|
|
185
205
|
return;
|
|
186
206
|
}
|
|
187
207
|
|
|
188
|
-
if (!applied) print(style.dim(` Fix it with: lanes link doctor --fix --
|
|
208
|
+
if (!applied) print(style.dim(` Fix it with: lanes link doctor --fix --workspace ${target}`));
|
|
189
209
|
},
|
|
190
210
|
);
|
|
191
211
|
|
|
@@ -151,14 +151,14 @@ export async function tokenInvocation(
|
|
|
151
151
|
target: string,
|
|
152
152
|
): Promise<{ command: string; onPath: boolean }> {
|
|
153
153
|
// Both, always, and from the *resolved* selection rather than the flags. A
|
|
154
|
-
// token is per-target, so `outputs --
|
|
154
|
+
// token is per-target, so `outputs --workspace cloud` printing a bare
|
|
155
155
|
// `token show --raw` hands over the local one beside a deployed URL — a
|
|
156
156
|
// credential that looks like an answer and fails as a wrong password. Naming
|
|
157
157
|
// the profile as well makes the line pasteable into any shell rather than
|
|
158
158
|
// only into one where the same default happens to resolve.
|
|
159
|
-
const selection = ` --profile ${profile} --
|
|
159
|
+
const selection = ` --profile ${profile} --workspace ${target}`;
|
|
160
160
|
const short = `lanes link token show --raw${selection}`;
|
|
161
|
-
const argv = ['link', 'token', 'show', '--raw', '--profile', profile, '--
|
|
161
|
+
const argv = ['link', 'token', 'show', '--raw', '--profile', profile, '--workspace', target];
|
|
162
162
|
|
|
163
163
|
const resolved = Bun.which('lanes');
|
|
164
164
|
if (resolved) {
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import { mkdtemp, readFile, rm } from 'node:fs/promises';
|
|
2
|
+
import { tmpdir } from 'node:os';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import { ConfigError, PAIR_CERT_REF, PAIR_KEY_REF } from '#profile';
|
|
5
|
+
import type { SecretStore } from '#secrets';
|
|
6
|
+
import { print, style, warn } from '../../output.ts';
|
|
7
|
+
import { confirm, isInteractive } from '../../prompt.ts';
|
|
8
|
+
import type { PairDeps, PairFlags } from './pair.ts';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Making a loopback address one this machine's browsers will trust.
|
|
12
|
+
*
|
|
13
|
+
* Split from `./pair.ts` because it is the half of pairing that only loopback
|
|
14
|
+
* has. A deployed endpoint terminates TLS with a certificate a browser already
|
|
15
|
+
* trusts, so none of this runs for one — and keeping it beside the command made
|
|
16
|
+
* a file most of which did not apply to half its callers.
|
|
17
|
+
*
|
|
18
|
+
* It is also the largest side effect any command in this CLI has: a persistent
|
|
19
|
+
* change to the machine's trust store, made by a CLI. That is why everything
|
|
20
|
+
* here asks first, and why a run with nobody at the terminal is refused rather
|
|
21
|
+
* than assumed.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
/** The names the certificate has to cover. All three are this machine. */
|
|
25
|
+
const HOSTS = ['127.0.0.1', 'localhost', '::1'];
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* A certificate a browser on this machine will accept.
|
|
29
|
+
*
|
|
30
|
+
* mkcert and nothing else, deliberately. It is the one tool that installs into
|
|
31
|
+
* the system trust store *and* Firefox's separate NSS store, across macOS,
|
|
32
|
+
* Linux and Windows — and a self-signed certificate this command generated
|
|
33
|
+
* itself would fail in the browser with an error the page cannot read, which is
|
|
34
|
+
* the worst of both: the work is done and the feature does not work.
|
|
35
|
+
*/
|
|
36
|
+
export async function ensureCertificate(
|
|
37
|
+
credentials: SecretStore,
|
|
38
|
+
flags: PairFlags,
|
|
39
|
+
deps: PairDeps,
|
|
40
|
+
): Promise<'reused' | 'installed'> {
|
|
41
|
+
const held =
|
|
42
|
+
(await credentials.get(PAIR_CERT_REF)) !== null &&
|
|
43
|
+
(await credentials.get(PAIR_KEY_REF)) !== null;
|
|
44
|
+
|
|
45
|
+
if (held && flags.rotate !== true) return 'reused';
|
|
46
|
+
|
|
47
|
+
const which = deps.which ?? ((binary: string) => Bun.which(binary));
|
|
48
|
+
const run = deps.run ?? runCommand;
|
|
49
|
+
|
|
50
|
+
let mkcert = which('mkcert');
|
|
51
|
+
if (!mkcert) mkcert = await installMkcert(flags, deps, which, run);
|
|
52
|
+
|
|
53
|
+
// Installs the local CA if it is not already there, and says nothing if it
|
|
54
|
+
// is. This is the step that touches the trust store, and it has already been
|
|
55
|
+
// consented to by the time it runs.
|
|
56
|
+
const installed = await run([mkcert, '-install']);
|
|
57
|
+
if (installed !== null) {
|
|
58
|
+
throw new ConfigError(`mkcert -install failed.\n${installed || ' It said nothing.'}`);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const scratch = await mkdtemp(join(tmpdir(), 'lanes-pair-'));
|
|
62
|
+
try {
|
|
63
|
+
const certPath = join(scratch, 'cert.pem');
|
|
64
|
+
const keyPath = join(scratch, 'key.pem');
|
|
65
|
+
|
|
66
|
+
const failed = await run([mkcert, '-cert-file', certPath, '-key-file', keyPath, ...HOSTS]);
|
|
67
|
+
if (failed !== null) {
|
|
68
|
+
throw new ConfigError(`mkcert failed to issue a certificate.\n${failed || ' It said nothing.'}`);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Into the credential store rather than left on disk. The private key is a
|
|
72
|
+
// secret by any reading, and the store is the one place in a workspace that
|
|
73
|
+
// is encrypted at rest.
|
|
74
|
+
await credentials.set(PAIR_CERT_REF, await readFile(certPath, 'utf8'));
|
|
75
|
+
await credentials.set(PAIR_KEY_REF, await readFile(keyPath, 'utf8'));
|
|
76
|
+
} finally {
|
|
77
|
+
await rm(scratch, { recursive: true, force: true });
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
return 'installed';
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
async function installMkcert(
|
|
84
|
+
flags: PairFlags,
|
|
85
|
+
deps: PairDeps,
|
|
86
|
+
which: (binary: string) => string | null,
|
|
87
|
+
run: (command: readonly string[]) => Promise<string | null>,
|
|
88
|
+
): Promise<string> {
|
|
89
|
+
const line = 'brew install mkcert';
|
|
90
|
+
const brew = which('brew');
|
|
91
|
+
|
|
92
|
+
if (!brew) {
|
|
93
|
+
// The one path where this command cannot finish the job, so it hands over
|
|
94
|
+
// the whole of it rather than half.
|
|
95
|
+
throw new ConfigError(
|
|
96
|
+
'Pairing needs mkcert, which issues a certificate this machine\'s browsers trust.\n' +
|
|
97
|
+
` With Homebrew: ${line}\n` +
|
|
98
|
+
' Otherwise: https://github.com/FiloSottile/mkcert#installation\n' +
|
|
99
|
+
' Then run "lanes link pair" again.',
|
|
100
|
+
);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
print(warn('pairing needs mkcert, and it is not installed'));
|
|
104
|
+
print(
|
|
105
|
+
style.dim(
|
|
106
|
+
' It issues a certificate for 127.0.0.1 that this machine trusts, which is what\n' +
|
|
107
|
+
' lets a page on lanes.sh read your endpoint at all. Safari will not fetch\n' +
|
|
108
|
+
' http://127.0.0.1 from an https page, and offers no way to allow it.\n' +
|
|
109
|
+
'\n' +
|
|
110
|
+
' This installs a local certificate authority into your system trust store.\n' +
|
|
111
|
+
` ${line}`,
|
|
112
|
+
),
|
|
113
|
+
);
|
|
114
|
+
|
|
115
|
+
if (flags.yes !== true) {
|
|
116
|
+
if (!(deps.interactive ?? isInteractive())) {
|
|
117
|
+
throw new ConfigError(
|
|
118
|
+
'Nothing here can answer a prompt, and installing a certificate authority because\n' +
|
|
119
|
+
' nobody was there to say no is the wrong way to resolve that.\n' +
|
|
120
|
+
` Run "${line}" yourself, or pass --yes.`,
|
|
121
|
+
);
|
|
122
|
+
}
|
|
123
|
+
if (!(await (deps.confirm ?? ((question: string) => confirm(question)))('Install it now?'))) {
|
|
124
|
+
throw new ConfigError(`Not paired. When you want it: ${line}`);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const failed = await run([brew, 'install', 'mkcert']);
|
|
129
|
+
if (failed !== null) throw new ConfigError(`${line} failed.\n${failed || ' It said nothing.'}`);
|
|
130
|
+
|
|
131
|
+
const found = which('mkcert');
|
|
132
|
+
if (!found) throw new ConfigError(`${line} reported success but mkcert is still not on PATH.`);
|
|
133
|
+
return found;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** Runs it. Null on success; whatever it said on failure. */
|
|
137
|
+
async function runCommand(command: readonly string[]): Promise<string | null> {
|
|
138
|
+
const spawned = Bun.spawn([...command], { stdout: 'pipe', stderr: 'pipe' });
|
|
139
|
+
const [code, stderr] = await Promise.all([spawned.exited, new Response(spawned.stderr).text()]);
|
|
140
|
+
return code === 0 ? null : stderr.trim();
|
|
141
|
+
}
|