@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.
Files changed (190) hide show
  1. package/README.md +20 -10
  2. package/instructions/agents/lanes-link-scout.md +2 -2
  3. package/instructions/skills/lanes-link/SKILL.md +148 -73
  4. package/package.json +2 -1
  5. package/src/audit/index.ts +8 -1
  6. package/src/auth/index.ts +58 -2
  7. package/src/auth/lanes/assertion.ts +256 -0
  8. package/src/auth/lanes/callback.ts +135 -0
  9. package/src/auth/lanes/federation.ts +50 -0
  10. package/src/auth/lanes/login.ts +294 -0
  11. package/src/auth/lanes/members.ts +103 -0
  12. package/src/auth/lanes/session.ts +97 -0
  13. package/src/auth/oauth/grant.ts +183 -0
  14. package/src/auth/oauth/result.ts +27 -0
  15. package/src/auth/oauth/server.ts +176 -203
  16. package/src/auth/oauth/store.ts +85 -4
  17. package/src/auth/remote.ts +32 -9
  18. package/src/cli/accepts.ts +109 -0
  19. package/src/cli/argv.ts +57 -3
  20. package/src/cli/audit-change.ts +140 -0
  21. package/src/cli/callback-page.ts +36 -115
  22. package/src/cli/commands/auth-dispatch.ts +48 -0
  23. package/src/cli/commands/auth.ts +229 -0
  24. package/src/cli/commands/connect/accounts.ts +4 -4
  25. package/src/cli/commands/connect/authorise.ts +4 -4
  26. package/src/cli/commands/connect/bind-credential.ts +2 -1
  27. package/src/cli/commands/connect/custom/index.ts +1 -1
  28. package/src/cli/commands/connect/custom/write.ts +2 -2
  29. package/src/cli/commands/connect/grant.ts +29 -14
  30. package/src/cli/commands/connect/index.ts +90 -88
  31. package/src/cli/commands/connect/options.ts +83 -0
  32. package/src/cli/commands/connect/registration.ts +50 -0
  33. package/src/cli/commands/connect/requirements.ts +1 -1
  34. package/src/cli/commands/connect/settle.ts +16 -6
  35. package/src/cli/commands/connect/target-note.ts +7 -2
  36. package/src/cli/commands/connect/unknown.ts +1 -1
  37. package/src/cli/commands/connect/variables.ts +3 -2
  38. package/src/cli/commands/connection-list.ts +116 -0
  39. package/src/cli/commands/connection.ts +183 -165
  40. package/src/cli/commands/grant.ts +140 -0
  41. package/src/cli/commands/identity.ts +24 -12
  42. package/src/cli/commands/knowledge/index.ts +49 -89
  43. package/src/cli/commands/knowledge/migrate.ts +79 -13
  44. package/src/cli/commands/knowledge/show.ts +97 -0
  45. package/src/cli/commands/knowledge.ts +2 -1
  46. package/src/cli/commands/mcp/harnesses.ts +30 -8
  47. package/src/cli/commands/mcp/onboarding.ts +86 -0
  48. package/src/cli/commands/mcp/register.ts +16 -2
  49. package/src/cli/commands/mcp.ts +1 -0
  50. package/src/cli/commands/members.ts +288 -0
  51. package/src/cli/commands/operate/attach.ts +3 -3
  52. package/src/cli/commands/operate/audit.ts +11 -7
  53. package/src/cli/commands/operate/auth.ts +28 -11
  54. package/src/cli/commands/operate/findings.ts +2 -1
  55. package/src/cli/commands/operate/inspect.ts +37 -19
  56. package/src/cli/commands/operate/migrate.ts +33 -13
  57. package/src/cli/commands/operate/outputs.ts +3 -3
  58. package/src/cli/commands/operate/pair-certificate.ts +141 -0
  59. package/src/cli/commands/operate/pair.ts +324 -0
  60. package/src/cli/commands/operate/policy.ts +73 -22
  61. package/src/cli/commands/operate/serve.ts +53 -5
  62. package/src/cli/commands/operate/status.ts +18 -10
  63. package/src/cli/commands/operate/tools.ts +2 -2
  64. package/src/cli/commands/operate.ts +2 -0
  65. package/src/cli/commands/owner/assets.ts +2 -2
  66. package/src/cli/commands/owner/entities.ts +2 -2
  67. package/src/cli/commands/owner/memory.ts +2 -2
  68. package/src/cli/commands/owner/shared.ts +13 -2
  69. package/src/cli/commands/owner/skills.ts +28 -8
  70. package/src/cli/commands/owner/tasks.ts +2 -2
  71. package/src/cli/commands/owner/vault.ts +3 -3
  72. package/src/cli/commands/profile/disposition.ts +236 -0
  73. package/src/cli/commands/profile/removal.ts +154 -64
  74. package/src/cli/commands/profile/remove.ts +83 -7
  75. package/src/cli/commands/profile.ts +79 -16
  76. package/src/cli/commands/relabel.ts +112 -0
  77. package/src/cli/commands/secrets.ts +39 -17
  78. package/src/cli/commands/set-workspace.ts +96 -0
  79. package/src/cli/commands/setup.ts +2 -2
  80. package/src/cli/commands/sync.ts +8 -8
  81. package/src/cli/commands/target.ts +9 -7
  82. package/src/cli/commands/update-migration.ts +54 -0
  83. package/src/cli/commands/update.ts +78 -24
  84. package/src/cli/config-edit.ts +99 -143
  85. package/src/cli/config-migrate.ts +82 -64
  86. package/src/cli/config-repair-sweep.ts +119 -0
  87. package/src/cli/config-repair.ts +131 -125
  88. package/src/cli/config-templates.ts +200 -0
  89. package/src/cli/contract3-credentials.ts +294 -0
  90. package/src/cli/contract3-data.ts +262 -0
  91. package/src/cli/contract3-layout.ts +46 -0
  92. package/src/cli/contract3-shape.ts +212 -0
  93. package/src/cli/contract3.ts +399 -0
  94. package/src/cli/contract4-credentials.ts +207 -0
  95. package/src/cli/contract4-data.ts +399 -0
  96. package/src/cli/contract4-rename.ts +73 -0
  97. package/src/cli/contract4-yaml.ts +223 -0
  98. package/src/cli/contract4.ts +342 -0
  99. package/src/cli/endpoint-url.ts +1 -1
  100. package/src/cli/identity.ts +44 -26
  101. package/src/cli/lanes.ts +25 -1
  102. package/src/cli/main.ts +94 -14
  103. package/src/cli/migrate-move.ts +166 -0
  104. package/src/cli/migrate-plan.ts +12 -6
  105. package/src/cli/output.ts +34 -1
  106. package/src/cli/publish.ts +6 -7
  107. package/src/cli/runtime/open.ts +64 -99
  108. package/src/cli/runtime/registry.ts +6 -7
  109. package/src/cli/runtime/select.ts +2 -11
  110. package/src/cli/runtime/stores.ts +58 -0
  111. package/src/cli/runtime/types.ts +106 -0
  112. package/src/cli/runtime/vault.ts +19 -4
  113. package/src/cli/runtime/workspace.ts +60 -0
  114. package/src/cli/runtime.ts +2 -1
  115. package/src/cli/selection-require.ts +44 -13
  116. package/src/cli/selection.ts +127 -145
  117. package/src/cli/usage.ts +40 -20
  118. package/src/cli/workspace-migrate.ts +152 -22
  119. package/src/connectivity/manifest/provider.ts +34 -13
  120. package/src/connectivity/manifest/requirements.ts +1 -1
  121. package/src/connectivity/transports/imap/parser.ts +70 -9
  122. package/src/deployments/adapters/filesystem.ts +18 -3
  123. package/src/deployments/bind.ts +1 -1
  124. package/src/deployments/deploy.ts +38 -29
  125. package/src/deployments/gcp/bucket.ts +58 -11
  126. package/src/deployments/gcp/provision.ts +7 -7
  127. package/src/deployments/knowledge.ts +9 -4
  128. package/src/deployments/prepare.ts +72 -24
  129. package/src/deployments/record.ts +1 -1
  130. package/src/deployments/report.ts +2 -2
  131. package/src/deployments/serving.ts +15 -74
  132. package/src/deployments/target.ts +34 -13
  133. package/src/deployments/upload.ts +60 -27
  134. package/src/dispatch/deps.ts +88 -0
  135. package/src/dispatch/dispatch.ts +21 -62
  136. package/src/policy/index.ts +47 -15
  137. package/src/profile/connections.ts +195 -0
  138. package/src/profile/deployments.ts +86 -8
  139. package/src/profile/index.ts +35 -6
  140. package/src/profile/knowledge.ts +18 -5
  141. package/src/profile/layout.ts +163 -90
  142. package/src/profile/load.ts +133 -64
  143. package/src/profile/pairing.ts +32 -0
  144. package/src/profile/primitives.ts +35 -1
  145. package/src/profile/registry.ts +6 -6
  146. package/src/profile/schema.ts +181 -21
  147. package/src/profile/targets.ts +21 -9
  148. package/src/profile/testing.ts +104 -2
  149. package/src/profile/workspace.ts +124 -33
  150. package/src/providers/assets/provider.ts +6 -6
  151. package/src/providers/custom/index.ts +1 -1
  152. package/src/providers/custom/load.ts +2 -3
  153. package/src/providers/entities/provider.ts +6 -6
  154. package/src/providers/entities/writes.ts +1 -1
  155. package/src/providers/identity/provider.ts +2 -2
  156. package/src/providers/memory/provider.ts +26 -8
  157. package/src/providers/setup/plan.ts +1 -1
  158. package/src/providers/setup/provider.ts +3 -3
  159. package/src/providers/skills/provider.ts +2 -2
  160. package/src/providers/slack/index.ts +2 -2
  161. package/src/providers/tasks/provider.ts +6 -6
  162. package/src/providers/vault/provider.ts +1 -1
  163. package/src/registry/policy-bridge.ts +33 -11
  164. package/src/registry/reconcile.ts +4 -4
  165. package/src/registry/registry.ts +1 -1
  166. package/src/server/authorization.ts +94 -0
  167. package/src/server/edge.ts +14 -1
  168. package/src/server/endpoint.ts +89 -104
  169. package/src/server/generation.ts +10 -1
  170. package/src/server/harness.ts +71 -13
  171. package/src/server/index.ts +31 -0
  172. package/src/server/mcp/build.ts +20 -1
  173. package/src/server/mcp/client-info.ts +54 -0
  174. package/src/server/mcp/guide.ts +120 -0
  175. package/src/server/mcp/instructions.ts +22 -22
  176. package/src/server/mcp/prompts.ts +7 -3
  177. package/src/server/mcp/resources.ts +16 -8
  178. package/src/server/mcp/routing.ts +3 -3
  179. package/src/server/mcp/tools.ts +25 -6
  180. package/src/server/mcp/visibility.ts +74 -7
  181. package/src/server/oauth.ts +29 -109
  182. package/src/server/read/credential.ts +134 -0
  183. package/src/server/read/deployed.ts +56 -0
  184. package/src/server/read/listener.ts +54 -0
  185. package/src/server/read/open.ts +101 -0
  186. package/src/server/read/routes.ts +247 -0
  187. package/src/server/read/state.ts +171 -0
  188. package/src/stores/blobs/conformance.ts +19 -0
  189. package/src/stores/state/index.ts +76 -10
  190. 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.connections.length} connection(s), ` +
29
- `${config.policy.allow.length} allow rule(s), ${config.policy.deny.length} deny rule(s)`,
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.config, runtime.state, runtime.credentials, runtime.manifestFor);
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} --target ${runtime.resolution.target}`;
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.config.connections, forSelection);
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.config.connections.map((c) => c.provider))) {
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
- const denied = (rule: string): boolean =>
240
- runtime.config.policy.deny.some(
241
- (entry) => entry.capability === '*' || entry.capability === rule,
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 rule = `${provider}.*`;
246
- if (denied(rule)) return false;
263
+ const grant = grantFor(provider);
264
+ if (grant === undefined) return true;
247
265
 
248
- const hasRow = runtime.config.connections.some(
249
- (connection) => connection.provider === provider,
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
- return !hasRow || !granted;
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.config, runtime.state, runtime.credentials, runtime.manifestFor);
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.config.connections,
330
+ connections: grantedConnections(runtime),
313
331
  manifestFor: runtime.manifestFor,
314
332
  });
315
333
  if (rotation.unbound.length > 0) {
@@ -1,7 +1,10 @@
1
- import { ConfigError, resolveSelection, resolveTargetWorkspace, resolveWorkspaceRoot } from '#profile';
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 { migrateWorkspace, needsMigration } from '../../workspace-migrate.ts';
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
- const document = await ConfigDocument.open(root, selection.profile);
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(document);
62
- const credentials = await openSecretStoreFor(config, root, target);
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
- if (!(await needsMigration(where))) return false;
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('migrated to contract 2 — a target is declared by the workspace, not by each profile')
170
- : warn('this workspace is contract 1, and nothing here reads that any more'),
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 --target ${target}`
181
- : ` lanes link deploy --target ${target} migrates it and ships the image that\n` +
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 --target ${target}`));
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 --target cloud` printing a bare
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} --target ${target}`;
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, '--target', target];
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
+ }