@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
package/src/cli/lanes.ts CHANGED
@@ -21,13 +21,23 @@ const AREAS: Record<string, string> = {
21
21
  link: 'a self-hostable MCP gateway for all your connections, memory, tasks, files, and secrets',
22
22
  };
23
23
 
24
+ /** Commands that belong to the binary rather than to any one area. */
25
+ const TOP_LEVEL: Record<string, string> = {
26
+ auth: 'sign in to Lanes, so a profile can say who may use it',
27
+ 'set-workspace': 'which workspace a command means when it does not say',
28
+ };
29
+
24
30
  function areasUsage(): string {
25
31
  const rows = Object.entries(AREAS)
26
32
  .map(([name, blurb]) => ` ${style.bold(`lanes ${name}`)} ${blurb}`)
27
33
  .join('\n');
28
34
 
35
+ const top = Object.entries(TOP_LEVEL)
36
+ .map(([name, blurb]) => ` ${style.bold(`lanes ${name}`)} ${blurb}`)
37
+ .join('\n');
38
+
29
39
  return (
30
- `${style.bold('lanes')} — your own tools, wherever you work\n\n${rows}\n\n` +
40
+ `${style.bold('lanes')} — your own tools, wherever you work\n\n${rows}\n\n${top}\n\n` +
31
41
  `Run ${style.bold('lanes <area> help')} for what an area can do, ` +
32
42
  `or ${style.bold('lanes --version')} for which release this is.\n`
33
43
  );
@@ -51,6 +61,20 @@ async function main(argv: readonly string[]): Promise<void> {
51
61
  return;
52
62
  }
53
63
 
64
+ // A top-level command rather than an area, because it selects the workspace
65
+ // *the CLI* acts in — every area's commands read the same answer (ADR-061).
66
+ if (area === 'auth') {
67
+ const { runAuth } = await import('./commands/auth-dispatch.ts');
68
+ return await runAuth(rest);
69
+ }
70
+
71
+ if (area === 'set-workspace') {
72
+ const { setWorkspace } = await import('./commands/set-workspace.ts');
73
+ const { parseArgv } = await import('./argv.ts');
74
+ const { command, flags } = parseArgv(rest);
75
+ return await setWorkspace(command[0], { json: flags['json'] === true });
76
+ }
77
+
54
78
  if (area === 'link') {
55
79
  // Loaded here rather than at the top of the file so that a failure while
56
80
  // the area's module graph evaluates — a malformed environment variable read
package/src/cli/main.ts CHANGED
@@ -1,6 +1,10 @@
1
1
  import { connect } from './commands/connect/index.ts';
2
2
  import { connectCustom } from './commands/connect/custom/index.ts';
3
- import { disconnect, relabel } from './commands/connection.ts';
3
+ import { disconnect } from './commands/connection.ts';
4
+ import { grantConnectionTo, revokeConnectionFrom } from './commands/grant.ts';
5
+ import { connectionList } from './commands/connection-list.ts';
6
+ import { membersAdd, membersList, membersRemove } from './commands/members.ts';
7
+ import { relabel } from './commands/relabel.ts';
4
8
  import {
5
9
  attachFile,
6
10
  auditTail,
@@ -11,6 +15,7 @@ import {
11
15
  desktop,
12
16
  doctor,
13
17
  outputs,
18
+ pair,
14
19
  plan,
15
20
  policyList,
16
21
  policyRule,
@@ -26,13 +31,23 @@ import { syncTargets } from './commands/sync.ts';
26
31
  import { targetList, targetShow, targetUse } from './commands/target.ts';
27
32
  import { identityAdd, identityList, identityRemove } from './commands/identity.ts';
28
33
  import { setupPlan } from './commands/setup.ts';
29
- import { mcpAdd, mcpList, mcpStdio, skillDocument } from './commands/mcp.ts';
34
+ import { installInstructions, mcpAdd, mcpList, mcpStdio, skillDocument } from './commands/mcp.ts';
30
35
  import { deploy } from '#deployments/deploy.ts';
31
36
  import { secretsList, secretsPush, secretsSet } from './commands/secrets.ts';
32
37
  import { knowledgeShow, knowledgeUse } from './commands/knowledge.ts';
33
38
  import { dispatchOwner } from './dispatch-owner.ts';
34
39
  import { update } from './commands/update.ts';
35
- import { all, customFlags, globalFlags, knowledgeFlags, ownerFlags, parseArgv, text } from './argv.ts';
40
+ import {
41
+ all,
42
+ customFlags,
43
+ globalFlags,
44
+ knowledgeFlags,
45
+ ownerFlags,
46
+ parseArgv,
47
+ text,
48
+ } from './argv.ts';
49
+ import type { GlobalFlags } from './runtime.ts';
50
+ import type { OwnerFlags } from './commands/owner/shared.ts';
36
51
  import { assertKnownFlags } from './selection.ts';
37
52
  import { requireSelection } from './selection-require.ts';
38
53
  import { PROGRAM, USAGE } from './usage.ts';
@@ -57,8 +72,12 @@ export async function run(argv: readonly string[]): Promise<void> {
57
72
  const { command, flags } = parseArgv(argv);
58
73
  const [first, second, ...rest] = command;
59
74
 
60
- const global = globalFlags(flags);
61
- const owner = ownerFlags(flags, argv);
75
+ // Both are read *after* `requireSelection`, which is what resolves
76
+ // `default_workspace` onto `flags` (ADR-061). Capturing them before it would
77
+ // hand every command an undefined workspace on the path the default exists to
78
+ // serve — the flag would be resolved and nothing would be looking.
79
+ let global: GlobalFlags;
80
+ let owner: OwnerFlags;
62
81
 
63
82
  const show = flags['show'] === true;
64
83
  const raw = flags['raw'] === true;
@@ -77,6 +96,9 @@ export async function run(argv: readonly string[]): Promise<void> {
77
96
  assertKnownFlags(first, second, flags);
78
97
  await requireSelection(first, second, flags);
79
98
 
99
+ global = globalFlags(flags);
100
+ owner = ownerFlags(flags, argv);
101
+
80
102
  switch (first) {
81
103
  case 'connect':
82
104
  // A nested switch rather than an `if`, so `selection.test.ts` sees
@@ -117,6 +139,20 @@ export async function run(argv: readonly string[]): Promise<void> {
117
139
  });
118
140
  }
119
141
 
142
+ // The command connecting no longer implies. A connection belongs to the
143
+ // workspace (ADR-057), so which profiles may reach it is a separate say.
144
+ case 'connection':
145
+ if (second !== 'list' && second !== undefined) {
146
+ throw new Error(`Unknown: ${PROGRAM} connection ${second}`);
147
+ }
148
+ return connectionList({ ...global, json });
149
+
150
+ case 'grant':
151
+ return grantConnectionTo(second, { ...global, json });
152
+
153
+ case 'revoke':
154
+ return revokeConnectionFrom(second, { ...global, json });
155
+
120
156
  case 'setup':
121
157
  if (second !== 'plan' && second !== undefined) {
122
158
  throw new Error(`Unknown: ${PROGRAM} setup ${second}`);
@@ -127,27 +163,47 @@ export async function run(argv: readonly string[]): Promise<void> {
127
163
  switch (second) {
128
164
  case 'add':
129
165
  if (!rest[0]) {
130
- throw new Error(`Usage: ${PROGRAM} profile add <name> --target <name>`);
166
+ throw new Error(`Usage: ${PROGRAM} profile add <name> --workspace <name>`);
131
167
  }
132
168
  return profileAdd(rest[0], {
133
169
  // One target, not a list. A profile declared every target it could
134
170
  // run on under contract 1, which is why this read the repeated flag
135
171
  // out of argv; it lives in exactly one now (ADR-052), so a second
136
172
  // --target would be naming a second place to put the same file.
137
- targets: [text(flags, 'target')!],
173
+ targets: [text(flags, 'workspace')!],
138
174
  nonInteractive: flags['non-interactive'] === true,
139
175
  json,
140
176
  });
141
177
  case 'list':
142
178
  case undefined:
143
- return profileList(text(flags, 'target')!, { json });
179
+ return profileList(text(flags, 'workspace')!, { json });
144
180
  case 'default':
145
181
  if (!rest[0]) throw new Error(`Usage: ${PROGRAM} profile default <name>`);
146
182
  return profileDefault(rest[0]);
183
+ case 'members': {
184
+ const [action, subject] = rest;
185
+ switch (action) {
186
+ case 'add':
187
+ return membersAdd(subject, {
188
+ ...global,
189
+ json,
190
+ me: flags['me'] === true,
191
+ role: text(flags, 'role'),
192
+ });
193
+ case 'remove':
194
+ return membersRemove(subject, { ...global, json });
195
+ case 'list':
196
+ case undefined:
197
+ return membersList({ ...global, json });
198
+ default:
199
+ throw new Error(`Unknown: ${PROGRAM} profile members ${action}`);
200
+ }
201
+ }
147
202
  case 'remove':
148
203
  if (!rest[0]) {
149
204
  throw new Error(
150
- `Usage: ${PROGRAM} profile remove <name> [--target t] [--dry-run] [--yes]`,
205
+ `Usage: ${PROGRAM} profile remove <name> [--workspace <name>] [--dry-run] [--yes] ` +
206
+ '[--delete-data | --migrate-to <profile>]',
151
207
  );
152
208
  }
153
209
  return profileRemove(rest[0], {
@@ -155,23 +211,31 @@ export async function run(argv: readonly string[]): Promise<void> {
155
211
  json,
156
212
  dryRun: flags['dry-run'] === true,
157
213
  yes: flags['yes'] === true,
214
+ deleteData: flags['delete-data'] === true,
215
+ ...(typeof flags['migrate-to'] === 'string'
216
+ ? { migrateTo: flags['migrate-to'] }
217
+ : {}),
158
218
  });
159
219
  default:
160
220
  throw new Error(`Unknown: ${PROGRAM} profile ${second}`);
161
221
  }
162
222
 
223
+ // `target` was the old word for the same thing (ADR-061). Kept as its own
224
+ // case rather than aliased, so `selection.test.ts` sees both spellings and
225
+ // neither can default quietly.
163
226
  case 'target':
227
+ case 'workspace':
164
228
  switch (second) {
165
229
  case 'list':
166
230
  case undefined:
167
231
  return targetList({ ...global, json, urls: flags['urls'] === true });
168
232
  case 'use':
169
- if (!rest[0]) throw new Error(`Usage: ${PROGRAM} target use <name>`);
233
+ if (!rest[0]) throw new Error(`Usage: ${PROGRAM} workspace use <name>`);
170
234
  return targetUse(rest[0]);
171
235
  case 'show':
172
236
  return targetShow(rest[0], { ...global, json });
173
237
  default:
174
- throw new Error(`Unknown: ${PROGRAM} target ${second}`);
238
+ throw new Error(`Unknown: ${PROGRAM} workspace ${second}`);
175
239
  }
176
240
 
177
241
  case 'identity': {
@@ -206,11 +270,15 @@ export async function run(argv: readonly string[]): Promise<void> {
206
270
  const [capability] = rest;
207
271
  if (!capability) {
208
272
  throw new Error(
209
- `Usage: ${PROGRAM} policy ${second} <capability>\n` +
210
- ` e.g. ${PROGRAM} policy ${second} gmail.send_message, or gmail.* for the whole provider`,
273
+ `Usage: ${PROGRAM} policy ${second} <capability> --connection <provider>.<id>\n` +
274
+ ` e.g. ${PROGRAM} policy ${second} gmail.send_message --connection gmail.personal,\n` +
275
+ ` or gmail.* for everything that connection's provider offers`,
211
276
  );
212
277
  }
213
- return policyRule(second, capability, global);
278
+ return policyRule(second, capability, {
279
+ ...global,
280
+ ...(typeof flags.connection === 'string' ? { connection: flags.connection } : {}),
281
+ });
214
282
  }
215
283
  default:
216
284
  throw new Error(`Unknown: ${PROGRAM} policy ${second}`);
@@ -227,6 +295,14 @@ export async function run(argv: readonly string[]): Promise<void> {
227
295
  throw new Error(`Unknown: ${PROGRAM} token ${second}`);
228
296
  }
229
297
 
298
+ case 'pair':
299
+ return pair({
300
+ ...global,
301
+ print: flags['print'] === true,
302
+ rotate: flags['rotate'] === true,
303
+ yes: flags['yes'] === true,
304
+ });
305
+
230
306
  case 'audit':
231
307
  if (second === 'verify') return auditVerify(global);
232
308
  if (second !== 'tail' && second !== undefined) {
@@ -340,7 +416,10 @@ export async function run(argv: readonly string[]): Promise<void> {
340
416
  dryRun: flags['dry-run'] === true,
341
417
  force: flags['force'] === true,
342
418
  noSkill: flags['no-skill'] === true,
419
+ headless: flags['headless'] === true,
343
420
  });
421
+ case 'install-instructions':
422
+ return installInstructions({ ...(text(flags, 'client') ? { client: text(flags, 'client') } : {}) });
344
423
  case 'stdio':
345
424
  return mcpStdio({ ...global, ...(flags['only'] === true ? { only: true } : {}) });
346
425
  case 'list':
@@ -398,6 +477,7 @@ export async function run(argv: readonly string[]): Promise<void> {
398
477
  switch (second) {
399
478
  case undefined:
400
479
  case 'targets':
480
+ case 'workspaces':
401
481
  return syncTargets({
402
482
  ...global,
403
483
  dryRun: flags['dry-run'] === true,
@@ -0,0 +1,166 @@
1
+ import { ConfigError } from '#profile';
2
+ import type { BlobStore } from '#stores/blobs';
3
+
4
+ /**
5
+ * Moving objects between two layouts, without losing one.
6
+ *
7
+ * Shared by every contract migration that relocates bytes rather than YAML —
8
+ * contract 3 hoisting a profile's data to the workspace, contract 4 carrying it
9
+ * back into the profile. Each learned the same rules the same hard way, so they
10
+ * live once: nothing is deleted until what replaced it has been read back, every
11
+ * destination is checked before the first byte moves, and a rerun after an
12
+ * interruption finishes rather than refusing.
13
+ */
14
+
15
+ export interface Move {
16
+ readonly from: string;
17
+ readonly to: string;
18
+ /**
19
+ * Rewrite the object's bytes on the way across.
20
+ *
21
+ * Only a connection record needs this, and it needs it for a reason a plain
22
+ * copy cannot serve: `ConnectionRepository.list` reads `provider` and `id`
23
+ * out of the record *body*, not out of the key it was stored under. A renamed
24
+ * connection whose bytes were copied verbatim would sit at
25
+ * `connections.v1/vault.work` still calling itself `vault.main`, and the next
26
+ * `upsert` would write a second record beside it.
27
+ */
28
+ readonly rewrite?: (data: Uint8Array) => Uint8Array;
29
+ /**
30
+ * Write only where the destination is empty; leave the source alone if not.
31
+ *
32
+ * For the objects two profiles can legitimately both hold — a connection they
33
+ * share, a provider-keyed cache, one custom manifest — where a second copy is
34
+ * a duplicate rather than a clash. `claim` drops the loser within one run, but
35
+ * deletes the winner's source and leaves the loser's: the rerun then saw a
36
+ * different first claimant, found foreign bytes at the destination, and threw,
37
+ * permanently. `rewriteProfiles` stamps the contract *after* the moves, so an
38
+ * interruption during them forces exactly that rerun.
39
+ */
40
+ readonly whenAbsent?: boolean;
41
+ /**
42
+ * Copy, and leave the source where it is.
43
+ *
44
+ * For the one thing contract 4 cannot decide: a store two profiles both grant
45
+ * has to land in both, and neither copy may delete what the other still needs
46
+ * to read. The original is reported rather than removed — merging two sets of
47
+ * notes is not reversible, and picking one profile's would take the other's
48
+ * away silently.
49
+ */
50
+ readonly keep?: boolean;
51
+ }
52
+
53
+ /**
54
+ * Two objects aimed at one key, caught while this is still a plan.
55
+ *
56
+ * `applyMoves` checks the destination per object as well, but that check cannot
57
+ * see a collision between two objects *in this run* once the moves are applied
58
+ * concurrently — both would look at an absent destination and both would write.
59
+ * Hoisting it here also puts it where this file says it belongs: everything that
60
+ * can fail happens before the first byte moves, so a refusal leaves the
61
+ * workspace exactly as it was.
62
+ */
63
+ export function assertOneObjectPerDestination(moves: readonly Move[]): void {
64
+ const seen = new Map<string, string>();
65
+
66
+ for (const move of moves) {
67
+ const first = seen.get(move.to);
68
+ if (first !== undefined) {
69
+ throw new ConfigError(
70
+ `Two objects want to be at ${move.to}, and this migration cannot merge them.\n` +
71
+ ` ${first} and ${move.from}. Nothing has been written.\n` +
72
+ ' Which one to keep is yours to decide, because both are your data: move or\n' +
73
+ ' delete one of the two, then run this again.',
74
+ );
75
+ }
76
+ seen.set(move.to, move.from);
77
+ }
78
+ }
79
+
80
+ /** First claim on a destination wins; a later one is left where it is. */
81
+ export function claim(claimed: Set<string>, move: Move): Move | null {
82
+ if (claimed.has(move.to)) return null;
83
+ claimed.add(move.to);
84
+ return { ...move, whenAbsent: true };
85
+ }
86
+
87
+ /**
88
+ * How many objects are in flight at once.
89
+ *
90
+ * Serial was fine while this only ever ran against a local disk. A deployed
91
+ * workspace's audit log is one object per event, so the first real bucket this
92
+ * migrated held 1,906 of them — three round trips each, in series, is minutes of
93
+ * a deploy spent with nothing on screen. The same 16 the read paths in
94
+ * `#providers/memory` and `#providers/tasks` settled on, and for the same
95
+ * reason: enough to hide the latency, not enough to look like an incident to the
96
+ * other end.
97
+ *
98
+ * Safe to widen only while each move stays independent, which is what
99
+ * `assertOneObjectPerDestination` guarantees.
100
+ */
101
+ const MOVE_CONCURRENCY = 16;
102
+
103
+ export async function applyMoves(files: BlobStore, moves: readonly Move[]): Promise<void> {
104
+ const pending = moves.filter((move) => move.from !== move.to);
105
+
106
+ for (let start = 0; start < pending.length; start += MOVE_CONCURRENCY) {
107
+ await Promise.all(
108
+ pending.slice(start, start + MOVE_CONCURRENCY).map((move) => applyMove(files, move)),
109
+ );
110
+ }
111
+ }
112
+
113
+ /** One object, moved or finished. Never deletes before the copy reads back. */
114
+ async function applyMove(files: BlobStore, move: Move): Promise<void> {
115
+ const source = await files.get(move.from);
116
+ if (source === null) return;
117
+
118
+ // Before the comparison below as well as before the write. Comparing the
119
+ // *source* bytes against a destination that holds the rewritten ones would
120
+ // read an already-finished move as a collision, and refuse the one rerun this
121
+ // file promises.
122
+ const data = move.rewrite === undefined ? source : move.rewrite(source);
123
+
124
+ if (await files.has(move.to)) {
125
+ const held = await files.get(move.to);
126
+
127
+ // Raced away between the two calls, so there is nothing there after all and
128
+ // the ordinary path below is still the right one.
129
+ if (held !== null) {
130
+ // Another instance of the same connection, or another profile's copy of
131
+ // one shared cache, got there first. Left where it is rather than
132
+ // refused — and rerunnable, which is the whole point.
133
+ if (!sameBytes(held, data)) {
134
+ if (move.whenAbsent === true) return;
135
+ throw new ConfigError(
136
+ `Two objects want to be at ${move.to}, and this migration cannot merge them.\n` +
137
+ ` ${move.from} is the second, and what is already there is not a copy of it.\n` +
138
+ ' Nothing has been deleted. Please report it with the layout of your data ' +
139
+ 'directory.',
140
+ );
141
+ }
142
+
143
+ // Already copied, by a run that did not get to the delete.
144
+ if (move.keep !== true) await files.delete(move.from);
145
+ return;
146
+ }
147
+ }
148
+
149
+ await files.put(move.to, data);
150
+ if ((await files.get(move.to)) === null) {
151
+ throw new ConfigError(
152
+ `${move.to} did not read back after being written. Nothing has been deleted; ` +
153
+ `fix the store and run this again.`,
154
+ );
155
+ }
156
+
157
+ if (move.keep !== true) await files.delete(move.from);
158
+ }
159
+
160
+ function sameBytes(left: Uint8Array, right: Uint8Array): boolean {
161
+ if (left.length !== right.length) return false;
162
+ for (let index = 0; index < left.length; index += 1) {
163
+ if (left[index] !== right[index]) return false;
164
+ }
165
+ return true;
166
+ }
@@ -1,4 +1,5 @@
1
- import { ConfigError, layout, isRemoteWorkspace, type LegacyTarget, type WorkspaceTarget } from '#profile';
1
+ import {
2
+ LEGACY_DATA_DIR, ConfigError, layout, isRemoteWorkspace, type LegacyTarget, type WorkspaceTarget } from '#profile';
2
3
  import { deployedWorkspace } from '#deployments/upload.ts';
3
4
 
4
5
  /**
@@ -88,7 +89,7 @@ export function toEntry(
88
89
  // produces `gs://b` pointing at `gs://b`. That is a loop, and `openTarget`
89
90
  // refuses it — which made `deploy` unable to run against the bucket it had
90
91
  // just migrated, on the one command the refusal names as the fix.
91
- if (remote && remote !== workspaceRoot) return { workspace: remote };
92
+ if (remote && remote !== workspaceRoot) return { at: remote };
92
93
 
93
94
  // **A filesystem target is dropped from a remote workspace.**
94
95
  //
@@ -105,8 +106,13 @@ export function toEntry(
105
106
  const storagePath = declared.storage.path;
106
107
  const credentialsPath = declared.credentials.path;
107
108
 
108
- const defaultStorage = `./${layout.blobs(profile)}`;
109
- const defaultCredentials = `./${layout.credentials(profile)}`;
109
+ // The **contract-1** defaults, spelled out rather than taken from `layout`.
110
+ // This function reads a contract-1 profile, where every path was per profile,
111
+ // and `layout` describes where things live *now* — workspace-level since
112
+ // ADR-057. Asking it would compare a contract-1 path against a contract-3
113
+ // default and refuse every profile that had written the ordinary one.
114
+ const defaultStorage = `./${LEGACY_DATA_DIR}/${profile}`;
115
+ const defaultCredentials = `./${LEGACY_DATA_DIR}/${profile}/credentials.enc`;
110
116
 
111
117
  refuseCustomPath(name, profile, workspaceRoot, 'storage.path', storagePath, defaultStorage);
112
118
  refuseCustomPath(
@@ -149,12 +155,12 @@ function refuseCustomPath(
149
155
  ` A target is declared once for the whole workspace now (ADR-052), so it cannot hold a\n` +
150
156
  ` different path per profile. The default it would get is ${expected}.\n\n` +
151
157
  ` Move the data there and delete the line, or keep this profile in a workspace of its\n` +
152
- ` own: LANES_LINK_HOME=${workspaceRoot}/<somewhere> lanes link profile add ${profile} --target ${target}`,
158
+ ` own: LANES_LINK_HOME=${workspaceRoot}/<somewhere> lanes link profile add ${profile} --workspace ${target}`,
153
159
  );
154
160
  }
155
161
 
156
162
  export function summarise(entry: WorkspaceTarget): string {
157
- if (entry.workspace !== undefined) return `points at ${entry.workspace}`;
163
+ if (entry.at !== undefined) return `points at ${entry.at}`;
158
164
  const parts = [entry.credentials?.adapter, entry.storage?.adapter].filter(Boolean);
159
165
  return `${parts.join(' + ')}${entry.deploy ? `, deploys ${entry.deploy.service}` : ''}`;
160
166
  }
package/src/cli/output.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { wasDefaulted } from './selection-require.ts';
1
2
  import type { Resolution } from '#profile';
2
3
 
3
4
  /**
@@ -126,16 +127,48 @@ export function announceProfile(selection: {
126
127
  print(style.dim(`profile ${style.bold(selection.profile)} ${selection.workspaceRoot}`));
127
128
  }
128
129
 
130
+ /**
131
+ * The line every command prints before its output.
132
+ *
133
+ * It names the workspace, and whether that workspace was *typed* or came from
134
+ * `default_workspace`. The provenance is not decoration: ADR-037 removed sticky
135
+ * selection because "the dotfile nothing prints" is how an operator runs a
136
+ * command against the wrong thing, and ADR-061 gives the default back only on
137
+ * the condition that it announces itself. Dropping the `(default)` marker for
138
+ * tidiness would reverse that decision.
139
+ */
129
140
  export function announce(resolution: Resolution): void {
141
+ const provenance = wasDefaulted(resolution.target) ? ' (default)' : '';
142
+
130
143
  print(
131
144
  style.dim(
132
145
  `profile ${style.bold(resolution.profile)} ` +
133
- `target ${style.bold(resolution.target)} ` +
146
+ `workspace ${style.bold(resolution.target)}${provenance} ` +
134
147
  `${resolution.workspaceRoot}`,
135
148
  ),
136
149
  );
137
150
  }
138
151
 
152
+ /**
153
+ * The same line, for a command whose subject is the workspace.
154
+ *
155
+ * It names no profile, and that is the point rather than a saving. A
156
+ * workspace-level command still opens a runtime, and a runtime carries a
157
+ * profile — so `announce` would print whichever one happened to be picked, and
158
+ * a reader would take it for the thing being acted on. The audit log is one
159
+ * chain for the whole workspace; saying `profile personal` above it describes a
160
+ * log that does not exist.
161
+ */
162
+ export function announceWorkspace(resolution: Resolution): void {
163
+ const provenance = wasDefaulted(resolution.target) ? ' (default)' : '';
164
+
165
+ print(
166
+ style.dim(
167
+ `workspace ${style.bold(resolution.target)}${provenance} ${resolution.workspaceRoot}`,
168
+ ),
169
+ );
170
+ }
171
+
139
172
  /**
140
173
  * Print a machine-readable result, or fall through to the human rendering.
141
174
  *
@@ -53,7 +53,8 @@ export async function publishAndNotify(input: {
53
53
  readonly config: Config;
54
54
  readonly workspaceRoot: string;
55
55
  readonly target: string;
56
- readonly profile: string;
56
+ /** Every profile the edit touched. See `publishWorkspace`. */
57
+ readonly profile: string | readonly string[];
57
58
  readonly credentials: SecretStore;
58
59
  }): Promise<PublishOutcome> {
59
60
  let published: string | null = null;
@@ -98,18 +99,16 @@ export async function publishProfileEdit(input: {
98
99
  readonly resolution: { readonly workspaceRoot: string; readonly profile: string };
99
100
  readonly config: Config;
100
101
  readonly target: string;
102
+ /** Every profile the edit touched, where it reached more than the one named. */
103
+ readonly touched?: readonly string[] | undefined;
101
104
  }): Promise<PublishOutcome> {
102
- const credentials = await openSecretStoreFor(
103
- input.config,
104
- input.resolution.workspaceRoot,
105
- input.target,
106
- );
105
+ const credentials = await openSecretStoreFor(input.resolution.workspaceRoot, input.target);
107
106
 
108
107
  return publishAndNotify({
109
108
  config: input.config,
110
109
  workspaceRoot: input.resolution.workspaceRoot,
111
110
  target: input.target,
112
- profile: input.resolution.profile,
111
+ profile: input.touched ?? input.resolution.profile,
113
112
  credentials,
114
113
  });
115
114
  }