@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,8 +1,22 @@
1
- import { layout, profilePath, workspacePath, type Config, type TargetConfig } from '#profile';
1
+ import {
2
+ layout,
3
+ PROFILE_FILE,
4
+ profilePath,
5
+ vaultRef,
6
+ workspacePath,
7
+ type Config,
8
+ type TargetConfig,
9
+ } from '#profile';
2
10
  import type { SecretStore } from '#secrets';
3
11
  import type { BlobStore } from '#stores/blobs';
4
12
  import { credentialRefFor, ownClientRefsFor, type ProviderRegistry } from '#registry';
5
13
  import { print, style, warn } from '../../output.ts';
14
+ import {
15
+ migratesAcross,
16
+ refuseSealedVault,
17
+ resolveCollisions,
18
+ type Disposition,
19
+ } from './disposition.ts';
6
20
 
7
21
  /**
8
22
  * What a profile's removal is allowed to delete, worked out before any of it
@@ -32,37 +46,69 @@ import { print, style, warn } from '../../output.ts';
32
46
  */
33
47
  export function declaredRefs(
34
48
  config: Config,
35
- registry: ProviderRegistry,
36
49
  declared: TargetConfig,
50
+ /**
51
+ * What the profiles that are staying declare.
52
+ *
53
+ * Nothing in here is deleted, however plainly the profile being removed also
54
+ * declares it. The credential store is one file per *workspace* since
55
+ * contract 3, and every profile takes the template default
56
+ * `token_ref: profile/token` — so removing one profile deleted the endpoint
57
+ * token the others are served by, and the deployed revision then refused every
58
+ * request with "No profile token in this target's credential store". The vault
59
+ * ref is read off the target and is identical for every profile there, which
60
+ * made the sibling's sealed items unrecoverable in the same command.
61
+ */
62
+ survivors: readonly Config[] = [],
37
63
  ): string[] {
38
64
  const refs = new Set<string>([config.auth.token_ref]);
39
65
 
40
66
  // Read off the *target*, not the profile. `vaultTargetSchema` sits inside
41
- // `targetSchema`, so two targets may seal the same profile's items in
42
- // different places; taking it from the profile would attach one target's
43
- // vault to another target's removal.
67
+ // `targetSchema`, so two targets may seal the same items in different places;
68
+ // taking it from the profile would attach one target's vault to another
69
+ // target's removal.
70
+ //
71
+ // **Through `vaultRef`, because the name carries the connection.** This said
72
+ // `vault/document`, the contract-2 constant, while `openVault` seals under
73
+ // `vault/<connection>` (ADR-059) — so removing a profile queued a ref nothing
74
+ // had ever written and left the real document behind. Under-deletion rather
75
+ // than over, since the survivor set was wrong the same way and they cancelled,
76
+ // but what stayed behind is sealed credential material belonging to a profile
77
+ // the operator asked to be gone. Per connection also makes the survivor check
78
+ // mean something: two profiles granting different vaults no longer look like
79
+ // one document to it.
44
80
  if (declared.vault?.adapter === 'secret') {
45
- refs.add(declared.vault.ref ?? 'vault/document');
81
+ refs.add(vaultRef(declared, config));
46
82
  }
47
83
 
48
- for (const connection of config.connections) {
49
- const manifest = registry.manifest(connection.provider);
50
-
51
- // `credentialRefFor` rather than `credentialRefForConnection`: it is the
52
- // single authority on where a connection's credential lives, and it exists
53
- // because two callers once derived the answer differently and disagreed.
54
- // Asking the lower-level one here would open that gap again from a third
55
- // side and it would miss a `local` provider, whose only ref is the one
56
- // the connection declares itself.
57
- const ref = credentialRefFor(connection, manifest);
58
- if (ref) refs.add(ref);
59
-
60
- if (manifest) {
61
- for (const own of ownClientRefsFor(manifest, config.oauth_apps)) refs.add(own);
62
- }
84
+ // **No connection credentials.** They belong to the workspace now (ADR-057),
85
+ // and every one of them may be granted by a profile that is staying. Removing
86
+ // a profile therefore removes no account and no credential — `lanes link
87
+ // disconnect` is the command that does that, and it is the one that knows how
88
+ // to check whether anybody else still needs the credential first.
89
+ //
90
+ // This is the sharpest edge in the whole decoupling, so `renderPlan` says it
91
+ // out loud rather than leaving an operator to infer it from a short list:
92
+ // "remove the work profile" used to mean "revoke what work could reach", and
93
+ // it does not any more.
94
+ if (config.auth.authorization?.mode === 'oidc') {
95
+ refs.add(config.auth.authorization.client_id_ref);
63
96
  }
64
97
 
65
- return [...refs];
98
+ // Shared with a profile that is staying, so not ours to delete. Computed the
99
+ // same way for the survivors as for this one, because "what does a profile
100
+ // declare" has to have one answer.
101
+ const kept = new Set(
102
+ survivors.flatMap((other) => [
103
+ other.auth.token_ref,
104
+ ...(declared.vault?.adapter === 'secret' ? [vaultRef(declared, other)] : []),
105
+ ...(other.auth.authorization?.mode === 'oidc'
106
+ ? [other.auth.authorization.client_id_ref]
107
+ : []),
108
+ ]),
109
+ );
110
+
111
+ return [...refs].filter((ref) => !kept.has(ref));
66
112
  }
67
113
 
68
114
  export interface RemovalItem {
@@ -71,8 +117,21 @@ export interface RemovalItem {
71
117
  readonly kind: 'secret' | 'blob' | 'file' | 'config' | 'workspace-key';
72
118
  readonly id: string;
73
119
  readonly note?: string;
120
+ /**
121
+ * The area `id` is a key within, for a blob.
122
+ *
123
+ * Carried rather than derived: the two ends of a migration are two areas and
124
+ * the executor opens both. Derived once, the plan prefixed the profile
125
+ * directory onto `id` while the executor opened its default area — the copy
126
+ * found nothing at the doubled path, wrote nothing, and the directory removal
127
+ * took the bytes. A `--migrate-to` reported success and lost the data.
128
+ */
129
+ readonly area?: string;
130
+ /** Where this object goes instead of being deleted, as `[area, key]`. */
131
+ readonly movedTo?: readonly [string, string];
74
132
  }
75
133
 
134
+
76
135
  export interface RemovalPlan {
77
136
  readonly profile: string;
78
137
  readonly items: readonly RemovalItem[];
@@ -96,10 +155,21 @@ export interface PlanOptions {
96
155
  readonly openSecrets: (target: string) => Promise<SecretStore>;
97
156
  readonly openBlobs: (target: string, area?: string) => Promise<BlobStore>;
98
157
  readonly readDefaultProfile?: (() => Promise<string | undefined>) | undefined;
158
+ /** What becomes of this profile's own bytes. */
159
+ readonly disposition: Disposition;
160
+ /**
161
+ * The profiles that are staying.
162
+ *
163
+ * So a credential both this one and a survivor declares is left alone — see
164
+ * `declaredRefs`. Defaulted to empty, which is the single-profile workspace
165
+ * and the shape every existing caller had.
166
+ */
167
+ readonly survivors?: readonly Config[] | undefined;
99
168
  }
100
169
 
101
170
  const reason = (cause: unknown): string => (cause instanceof Error ? cause.message : String(cause));
102
171
 
172
+
103
173
  /**
104
174
  * Everything removing this profile would delete, before any of it is deleted.
105
175
  *
@@ -116,6 +186,9 @@ export async function removalPlan(
116
186
  options: PlanOptions,
117
187
  ): Promise<RemovalPlan> {
118
188
  const items: RemovalItem[] = [];
189
+ const migrateInto =
190
+ options.disposition.kind === 'migrate' ? options.disposition.into : undefined;
191
+ const sealed: string[] = [];
119
192
  const untouched: { target: string; refs: string[] }[] = [];
120
193
  const warnings: string[] = [];
121
194
 
@@ -144,15 +217,16 @@ export async function removalPlan(
144
217
  );
145
218
  }
146
219
 
147
- // Secrets first, and this ordering is load-bearing rather than tidy:
148
- // `layout.credentials(p)` is `data/<p>/credentials.enc`, *inside* the blob
149
- // root `data/<p>`. For the file adapter the credential store is itself a
150
- // blob, so blobs-first would delete the store the secret deletions read
151
- // through and turn every one of them into a failure.
220
+ // Secrets, and there is nothing left to order them against. This used to
221
+ // run before a blob sweep because `layout.credentials(p)` was
222
+ // `data/<p>/credentials.enc`, *inside* the blob root `data/<p>`, so
223
+ // blobs-first deleted the store the secret deletions read through. Both the
224
+ // sweep and the per-profile root are gone (ADR-057, ADR-059) — see the note
225
+ // below — and the credential store is the workspace's now.
152
226
  try {
153
227
  const secrets = await options.openSecrets(name);
154
228
  const present = await secrets.list();
155
- const mine = new Set(declaredRefs(config, registry, declared));
229
+ const mine = new Set(declaredRefs(config, declared, options.survivors ?? []));
156
230
 
157
231
  for (const ref of present) if (mine.has(ref)) items.push({ target: name, kind: 'secret', id: ref });
158
232
 
@@ -164,53 +238,57 @@ export async function removalPlan(
164
238
  );
165
239
  }
166
240
 
241
+ // **The sweep is back, bounded by the profile's own directory.** It went
242
+ // away under ADR-059, when a profile owned no bytes and the blob root was
243
+ // the whole workspace — listing it then queued every byte in the workspace
244
+ // for deletion because one profile was going. ADR-066 gives the directory
245
+ // back, so it means what it used to: what this profile owns, and nothing an
246
+ // account owns. `lanes link disconnect` is still the command for an
247
+ // account, and nothing here touches one.
167
248
  try {
168
- const blobs = await options.openBlobs(name);
169
- for (const blob of await blobs.list()) items.push({ target: name, kind: 'blob', id: blob.key });
249
+ const blobs = await options.openBlobs(name, layout.profileDir(profile));
250
+ for (const blob of await blobs.list()) {
251
+ // **Not the declaration.** It is config rather than data, deleted below
252
+ // as its own item after everything it is the record of. Swept here it
253
+ // would be counted twice, and on a `--migrate-to` copied into the
254
+ // destination as a second `profile.yaml` — one profile's grants and
255
+ // members landing inside another's directory.
256
+ if (blob.key === PROFILE_FILE) continue;
257
+
258
+ const migratable = migrateInto !== undefined && migratesAcross(blob.key);
259
+
260
+ items.push({
261
+ target: name,
262
+ kind: 'blob',
263
+ id: blob.key,
264
+ area: layout.profileDir(profile),
265
+ ...(migratable
266
+ ? { movedTo: [layout.profileDir(migrateInto), blob.key] as const }
267
+ : {}),
268
+ ...(migrateInto !== undefined && !migratable
269
+ ? { note: 'not migrated — deleted with the profile' }
270
+ : {}),
271
+ });
272
+
273
+ if (migrateInto !== undefined && blob.key.startsWith('vault.d/')) sealed.push(blob.key);
274
+ }
170
275
  } catch (cause) {
171
276
  warnings.push(
172
277
  `Target "${name}": its storage could not be opened (${reason(cause)}), so nothing in it will be removed.`,
173
278
  );
174
279
  }
175
280
 
176
- // The blob store's root is the profile's own directory, and an adapter must
177
- // never delete the root it was configured with — so emptying it leaves the
178
- // directory. `layout.ts` frames that directory as the unit ("rm -r
179
- // data/work is exactly remove the work profile's data"), and one left
180
- // behind is silently reused by a later `profile add` of the same name.
281
+ // The store's root is the profile's own directory, and an adapter must never
282
+ // delete the root it was configured with — so emptying it leaves the
283
+ // directory, and one left behind is silently reused by a later `profile add`
284
+ // of the same name.
181
285
  if (declared.storage.adapter === 'filesystem') {
182
- const blobRoot = declared.storage.path ?? layout.blobs(profile);
183
286
  items.push({
184
287
  target: name,
185
288
  kind: 'file',
186
- id: blobRoot,
289
+ id: layout.profileDir(profile),
187
290
  note: 'the profile directory, once emptied',
188
291
  });
189
-
190
- // Skills and manifests sit at fixed areas under `data/<profile>/`, the
191
- // same way state and the log do — an explicit area, so a profile that
192
- // declares its own `storage.path` moves its provider blobs and leaves
193
- // these where `layout` says they are. The sweep above walks the blob root
194
- // and would then walk straight past them, so they are named. Ordinary
195
- // case: both are already inside `blobRoot` and the sweep has them, which
196
- // is why this only fires when the two have been pointed apart.
197
- //
198
- // Resolved before comparing, because they are not compared as paths
199
- // otherwise: `newProfileTemplate` writes `./data/<profile>` and
200
- // `layout.blobs` returns `data/<profile>`, which is one directory spelled
201
- // two ways — the same leading `./` that `profile/layout.ts` has a
202
- // paragraph about. Comparing the strings names every default profile's
203
- // skills twice in the preview an operator confirms.
204
- if (workspacePath(root, blobRoot) !== workspacePath(root, layout.blobs(profile))) {
205
- for (const area of [layout.skills(profile), layout.providers(profile)]) {
206
- items.push({
207
- target: name,
208
- kind: 'file',
209
- id: area,
210
- note: 'outside the declared storage path, so not swept with it',
211
- });
212
- }
213
- }
214
292
  }
215
293
 
216
294
  // A deployed revision reads its config from the bucket rather than the
@@ -220,7 +298,7 @@ export async function removalPlan(
220
298
  items.push({
221
299
  target: name,
222
300
  kind: 'config',
223
- id: `profiles/${profile}.yaml`,
301
+ id: layout.profileConfig(profile),
224
302
  note: 'the copy a deployed revision reads',
225
303
  });
226
304
  }
@@ -250,6 +328,16 @@ export async function removalPlan(
250
328
  });
251
329
  }
252
330
 
331
+ if (sealed.length > 0) refuseSealedVault(profile, migrateInto!);
332
+
333
+ if (migrateInto !== undefined) {
334
+ warnings.push(
335
+ ...(await resolveCollisions(items, migrateInto, layout.profileDir(migrateInto), (area) =>
336
+ options.openBlobs(options.target, area),
337
+ )),
338
+ );
339
+ }
340
+
253
341
  // Last. It is the only record of where everything else lives, so a failure
254
342
  // before this point leaves data a later run can still find.
255
343
  items.push({ target: null, kind: 'config', id: profilePath(root, profile) });
@@ -287,7 +375,9 @@ export function renderPlan(plan: RemovalPlan): void {
287
375
  print(` ${style.bold(target ?? 'workspace')}`);
288
376
  for (const item of items) {
289
377
  const note = item.note ? style.dim(` — ${item.note}`) : '';
290
- print(` ${KIND_LABEL[item.kind].padEnd(10)} ${item.id}${note}`);
378
+ const shown = item.area === undefined ? item.id : `${item.area}/${item.id}`;
379
+ const into = item.movedTo ? style.dim(` → ${item.movedTo[0]}/${item.movedTo[1]}`) : '';
380
+ print(` ${KIND_LABEL[item.kind].padEnd(10)} ${shown}${into}${note}`);
291
381
  }
292
382
  print();
293
383
  }
@@ -13,6 +13,7 @@ import {
13
13
  import { parseDocument } from 'yaml';
14
14
  import { rm } from 'node:fs/promises';
15
15
  import { terminalPrompter, type Prompter } from '../../prompt.ts';
16
+ import { recordConfigChange } from '../../audit-change.ts';
16
17
  import { announceProfile, emit, fail, ok, print, style } from '../../output.ts';
17
18
  import {
18
19
  buildRegistryWithWorkspace,
@@ -21,7 +22,9 @@ import {
21
22
  resolveProfileOnly,
22
23
  type GlobalFlags,
23
24
  } from '../../runtime.ts';
25
+ import { loadWorkspaceProfiles } from '#profile';
24
26
  import { removalPlan, renderPlan, type RemovalItem, type RemovalPlan } from './removal.ts';
27
+ import { settleDisposition, type Disposition } from './disposition.ts';
25
28
 
26
29
  /**
27
30
  * Performing a removal, and being honest about the parts that did not happen.
@@ -60,9 +63,21 @@ export interface RunDeps {
60
63
 
61
64
  const reason = (cause: unknown): string => (cause instanceof Error ? cause.message : String(cause));
62
65
 
63
- /** The items that only make sense once everything else is actually gone. */
66
+ /**
67
+ * The items that only make sense once everything else is actually gone.
68
+ *
69
+ * **`file` is in here, and that is the whole of its reason.** It is
70
+ * `rm -rf profiles/<profile>`, and it carries a target rather than `null`, so
71
+ * the original `target === null` test let it run after a failed object — taking
72
+ * the bytes a `--migrate-to` had not managed to copy, and `profile.yaml`, which
73
+ * the sweep deliberately leaves for last. `renderOutcome` then printed "the
74
+ * profile's config was kept, so nothing is stranded" about a directory that no
75
+ * longer existed. Same shape as the defect this file records having shipped
76
+ * once already.
77
+ */
64
78
  const isRecordOfWhereThingsAre = (item: RemovalItem): boolean =>
65
- item.target === null && (item.kind === 'config' || item.kind === 'workspace-key');
79
+ item.kind === 'file' ||
80
+ (item.target === null && (item.kind === 'config' || item.kind === 'workspace-key'));
66
81
 
67
82
  export async function executeRemoval(
68
83
  plan: RemovalPlan,
@@ -105,9 +120,30 @@ export async function executeRemoval(
105
120
  await (await secretStore(item.target!)).delete(item.id);
106
121
  break;
107
122
 
108
- case 'blob':
109
- await (await blobStore(item.target!)).delete(item.id);
123
+ case 'blob': {
124
+ const store = await blobStore(item.target!, item.area);
125
+
126
+ // Read across *before* deleting, and verify it landed — the same rule
127
+ // the contract migrations follow, for the same reason: a copy that
128
+ // half happened and a source that is already gone is the one state
129
+ // with nothing to retry from. A collision was resolved while this was
130
+ // still a plan, so the destination is free.
131
+ if (item.movedTo !== undefined) {
132
+ const [area, key] = item.movedTo;
133
+ const bytes = await store.get(item.id);
134
+
135
+ if (bytes !== null) {
136
+ const into = await blobStore(item.target!, area);
137
+ await into.put(key, bytes);
138
+ if ((await into.get(key)) === null) {
139
+ throw new Error(`${area}/${key} did not read back after being written`);
140
+ }
141
+ }
142
+ }
143
+
144
+ await store.delete(item.id);
110
145
  break;
146
+ }
111
147
 
112
148
  case 'config':
113
149
  if (item.target === null) await deps.removeConfig(item.id);
@@ -239,10 +275,15 @@ export interface RemoveFlags extends GlobalFlags {
239
275
  readonly dryRun?: boolean | undefined;
240
276
  readonly yes?: boolean | undefined;
241
277
  readonly json?: boolean | undefined;
278
+ /** Delete this profile's memory, tasks, assets, entities, vault and skills. */
279
+ readonly deleteData?: boolean | undefined;
280
+ /** Move them into this profile instead. */
281
+ readonly migrateTo?: string | undefined;
242
282
  /** Injected by a caller that has already asked — the console, and tests. */
243
283
  readonly prompter?: Prompter | undefined;
244
284
  }
245
285
 
286
+
246
287
  /**
247
288
  * `lanes link profile remove <name>` — the profile, and everything it owns.
248
289
  *
@@ -255,16 +296,43 @@ export async function removeProfile(name: string, flags: RemoveFlags): Promise<v
255
296
  announceProfile(selection);
256
297
 
257
298
  const root = selection.workspaceRoot;
258
- const registry = await buildRegistryWithWorkspace(root, name);
299
+ const registry = await buildRegistryWithWorkspace(root);
259
300
  const files = workspaceFiles(root);
260
301
  const { declared } = await openTarget(root, target);
261
302
 
303
+ const prompter = flags.prompter ?? terminalPrompter;
304
+ const someoneToAsk = flags.prompter ? prompter.interactive : process.stdin.isTTY;
305
+ const disposition = await settleDisposition(name, flags, prompter, someoneToAsk);
306
+
307
+ if (disposition === null) {
308
+ print(style.dim(' cancelled — nothing was removed'));
309
+ return;
310
+ }
311
+
312
+ if (disposition.kind === 'migrate') {
313
+ // Before the plan, because a plan against a profile that does not exist
314
+ // would name destinations nothing will ever read.
315
+ const staying = (await loadWorkspaceProfiles(root)).loaded.map((one) => one.profile);
316
+ if (disposition.into === name || !staying.includes(disposition.into)) {
317
+ throw new ConfigError(
318
+ `Cannot migrate "${name}" into "${disposition.into}".\n` +
319
+ ` Staying: ${staying.filter((one) => one !== name).join(', ') || 'nothing'}`,
320
+ );
321
+ }
322
+ }
323
+
262
324
  const plan = await removalPlan(config, root, name, registry, {
263
325
  target,
264
326
  declared,
265
- openSecrets: (target) => openSecretStoreFor(config, root, target),
327
+ disposition,
328
+ openSecrets: (target) => openSecretStoreFor(root, target),
266
329
  openBlobs: (target, area) => openBlobStoreFor(config, root, target, area),
267
330
  readDefaultProfile: async () => (await readWorkspace(root))?.default_profile,
331
+ // What the workspace keeps. The credential store is one file for all of
332
+ // them now, so anything a survivor declares is not this removal's to delete.
333
+ survivors: (await loadWorkspaceProfiles(root)).loaded
334
+ .filter((one) => one.profile !== name)
335
+ .map((one) => one.config),
268
336
  });
269
337
 
270
338
  renderPlan(plan);
@@ -278,7 +346,7 @@ export async function removeProfile(name: string, flags: RemoveFlags): Promise<v
278
346
  if (!(await confirmedByName(name, { yes: flags.yes, prompter: flags.prompter }))) return;
279
347
 
280
348
  const outcome = await executeRemoval(plan, {
281
- openSecrets: (target) => openSecretStoreFor(config, root, target),
349
+ openSecrets: (target) => openSecretStoreFor(root, target),
282
350
  openBlobs: (target, area) => openBlobStoreFor(config, root, target, area),
283
351
  removeConfig: async (path) => await files.delete(relativeToRoot(root, path)),
284
352
  removeDirectory: async (path) => await rm(workspacePath(root, path), { recursive: true, force: true }),
@@ -286,6 +354,14 @@ export async function removeProfile(name: string, flags: RemoveFlags): Promise<v
286
354
  retry: retryCommand,
287
355
  });
288
356
 
357
+ // Before the render, and against the config loaded above — the profile's file
358
+ // is gone by now, so a helper that re-read it would find nothing.
359
+ await recordConfigChange(config, root, target, {
360
+ capability: 'config.profile.remove',
361
+ scope: name,
362
+ arguments: { connections: config.grants.map((grant) => grant.connection) },
363
+ });
364
+
289
365
  return emit(flags.json, outcome, () => renderOutcome(outcome));
290
366
  }
291
367
 
@@ -1,9 +1,13 @@
1
+ import { newConnectionsTemplate, newProfileTemplate, newWorkspaceTemplate } from '../config-templates.ts';
1
2
  import { mkdir, writeFile } from 'node:fs/promises';
2
3
  import { existsSync } from 'node:fs';
3
4
  import { join } from 'node:path';
4
5
  import {
6
+ CONNECTIONS_FILE,
5
7
  ConfigError,
6
8
  WORKSPACE_FILE,
9
+ LEGACY_WORKSPACE_FILE,
10
+ legacyProfileConfig,
7
11
  listProfiles,
8
12
  profilePath,
9
13
  readWorkspace,
@@ -12,9 +16,13 @@ import {
12
16
  writeWorkspaceFile,
13
17
  resolveTargetWorkspace,
14
18
  resolveWorkspaceRoot,
19
+ layout,
15
20
  } from '#profile';
16
- import { newProfileTemplate, newWorkspaceTemplate } from '../config-edit.ts';
21
+
22
+ import { recordConfigChange } from '../audit-change.ts';
23
+ import { resolveProfile } from '../runtime.ts';
17
24
  import { emit, ok, print, style, table } from '../output.ts';
25
+ import { readSession } from '#auth/lanes/session.ts';
18
26
 
19
27
  /**
20
28
  * Profile management.
@@ -58,7 +66,7 @@ export interface ProfileListing {
58
66
  *
59
67
  * It now decides *where the file goes* rather than what is written in it: a
60
68
  * profile lives in one target's workspace and declares nothing about it
61
- * (ADR-052), so `--target cloud` writes into the bucket and the endpoint there
69
+ * (ADR-052), so `--workspace cloud` writes into the bucket and the endpoint there
62
70
  * serves it on its next reconcile.
63
71
  */
64
72
  export async function createProfile(
@@ -69,19 +77,51 @@ export async function createProfile(
69
77
  const target = options.targets[0]!;
70
78
 
71
79
  // The workspace file before the target is resolved, not after. `profile add
72
- // <name> --target local` on an empty directory is how a workspace comes into
80
+ // <name> --workspace local` on an empty directory is how a workspace comes into
73
81
  // existence, and the target it names is declared *by* that file — so writing
74
82
  // it second means resolving a target nothing has declared yet.
75
- if (!isRemoteWorkspace(local) && !existsSync(join(local, WORKSPACE_FILE))) {
76
- await mkdir(local, { recursive: true });
77
- await writeFile(join(local, WORKSPACE_FILE), newWorkspaceTemplate(), { mode: 0o600 });
83
+ // **Either name, and an unmigrated workspace is refused rather than
84
+ // shadowed.** `resolveWorkspaceRoot` accepts both markers, so this ran
85
+ // against a contract-3 workspace, found no `workspaces.yaml`, and wrote a
86
+ // fresh template beside `lanes-link.yaml` — `readWorkspace` prefers the new
87
+ // name, so every declared target and deployment record vanished, and
88
+ // `renameRegistry` returns early once the new file exists, so no migration
89
+ // could put them back.
90
+ if (!isRemoteWorkspace(local)) {
91
+ if (existsSync(join(local, LEGACY_WORKSPACE_FILE)) && !existsSync(join(local, WORKSPACE_FILE))) {
92
+ throw new ConfigError(
93
+ `${local} is still laid out the way contract 3 kept it, and adding a profile here would ` +
94
+ 'write a second registry beside the one it already has.\n' +
95
+ ' Migrate it first: lanes link doctor --fix --profile <name> --workspace <name>',
96
+ );
97
+ }
98
+
99
+ if (!existsSync(join(local, WORKSPACE_FILE))) {
100
+ await mkdir(local, { recursive: true });
101
+ await writeFile(join(local, WORKSPACE_FILE), newWorkspaceTemplate(), { mode: 0o600 });
102
+ }
78
103
  }
79
104
 
80
105
  const root = await resolveTargetWorkspace(local, target);
81
106
  const path = profilePath(root, name);
82
107
 
83
- if (await workspaceFiles(root).has(`profiles/${name}.yaml`)) {
84
- throw new Error(`Profile "${name}" already exists at ${path}`);
108
+ // The connections file comes into existence with the workspace, carrying the
109
+ // owner layer. It is written before the profile because the profile's grants
110
+ // name rows in it, and `assertGrantsResolve` refuses a grant with nothing
111
+ // behind it — so a profile written first would not load until this existed.
112
+ if (!(await workspaceFiles(root).has(CONNECTIONS_FILE))) {
113
+ await writeWorkspaceFile(workspaceFiles(root), CONNECTIONS_FILE, newConnectionsTemplate());
114
+ }
115
+
116
+ // Both shapes, for the same reason: `listProfiles` sees a contract-3
117
+ // `profiles/<name>.yaml` and this did not, so `profile add personal` wrote a
118
+ // fresh template alongside the operator's own — one name, two files, and the
119
+ // empty one opened. `doctor --fix` then planned a move onto an occupied
120
+ // destination and threw on every rerun.
121
+ for (const key of [layout.profileConfig(name), legacyProfileConfig(name)]) {
122
+ if (await workspaceFiles(root).has(key)) {
123
+ throw new Error(`Profile "${name}" already exists at ${root}/${key}`);
124
+ }
85
125
  }
86
126
 
87
127
  // Only a directory needs making. A bucket has no directories, and the write
@@ -99,10 +139,23 @@ export async function createProfile(
99
139
  // sibling's, or asked. It declares no target now (ADR-052): it is written into
100
140
  // the workspace of the target it was named with, and that workspace already
101
141
  // says where its bytes go.
142
+ // The signed-in subject, so the profile reaches somebody the moment it
143
+ // exists. Without it every new profile shipped `members: []` while the
144
+ // template writes `authorization: mode: self` — an endpoint that advertises
145
+ // OAuth and lists nobody, where the owner signs in at lanes.sh and is told no
146
+ // profile lists them. A local stdio or CI caller still worked, which is why a
147
+ // smoke test passed: those carry `profiles: undefined` and `mayReach` admits
148
+ // everything.
149
+ //
150
+ // Null when nobody is signed in, which is a real state — `lanes auth login`
151
+ // has not been run yet — and the template then says how to fix it rather than
152
+ // inventing a subject.
153
+ const session = await readSession();
154
+
102
155
  await writeWorkspaceFile(
103
156
  workspaceFiles(root),
104
- `profiles/${name}.yaml`,
105
- newProfileTemplate(name, port),
157
+ layout.profileConfig(name),
158
+ newProfileTemplate(name, port, session?.subject),
106
159
  );
107
160
 
108
161
  return { name, path, port, targets: options.targets, copiedFrom: {} };
@@ -142,8 +195,8 @@ export async function profileAdd(
142
195
  if (options.targets.length === 0) {
143
196
  throw new ConfigError(
144
197
  'Say which targets this profile declares:\n' +
145
- ` lanes link profile add ${name} --target local\n` +
146
- ` lanes link profile add ${name} --target local --target cloud\n` +
198
+ ` lanes link profile add ${name} --workspace local\n` +
199
+ ` lanes link profile add ${name} --workspace local --workspace cloud\n` +
147
200
  '\n' +
148
201
  ' A target is where a profile runs — a credential store and a blob store.\n' +
149
202
  ' There is no default to inherit before the profile exists.',
@@ -152,11 +205,21 @@ export async function profileAdd(
152
205
 
153
206
  const created = await createProfile(name, options);
154
207
 
208
+ // Re-read rather than assembled from `created`, because the file on disk is
209
+ // what a workspace with a remote credential store needs to open one.
210
+ const primary = created.targets[0]!;
211
+ const { resolution, config } = await resolveProfile({ profile: created.name, target: primary });
212
+ await recordConfigChange(config, resolution.workspaceRoot, primary, {
213
+ capability: 'config.profile.add',
214
+ scope: created.name,
215
+ arguments: { port: created.port, workspace: primary },
216
+ });
217
+
155
218
  return emit(options.json, created, () => {
156
219
  print(ok(`created profile ${style.bold(created.name)}`));
157
220
  print(` config ${created.path}`);
158
221
  print(` port ${created.port}`);
159
- print(` targets ${created.targets.join(', ')}`);
222
+ print(` workspace ${created.targets.join(', ')}`);
160
223
 
161
224
  for (const [target, from] of Object.entries(created.copiedFrom)) {
162
225
  print(` ${style.dim(`${target} adapters copied from profile "${from}"`)}`);
@@ -165,7 +228,7 @@ export async function profileAdd(
165
228
  print();
166
229
  print(
167
230
  style.dim(
168
- `Next: lanes link connect example --profile ${created.name} --target ${created.targets[0]}`,
231
+ `Next: lanes link connect example --profile ${created.name} --workspace ${created.targets[0]}`,
169
232
  ),
170
233
  );
171
234
  });
@@ -180,7 +243,7 @@ export async function profileList(
180
243
  return emit(options.json, listing, () => {
181
244
  if (listing.profiles.length === 0) {
182
245
  print(style.dim(`No profiles in ${listing.root}.`));
183
- print(style.dim('Create one with: lanes link profile add personal --target local'));
246
+ print(style.dim('Create one with: lanes link profile add personal --workspace local'));
184
247
  return;
185
248
  }
186
249
 
@@ -210,7 +273,7 @@ export function profileDefault(name: string | undefined): never {
210
273
  throw new ConfigError(
211
274
  'lanes link profile default was removed.\n' +
212
275
  ' Nothing reads default_profile any more — pass --profile on every command:\n' +
213
- ` lanes link status --profile ${name ?? '<name>'} --target <target>\n` +
276
+ ` lanes link status --profile ${name ?? '<name>'} --workspace <name>\n` +
214
277
  ' If the key is still in lanes-link.yaml it is inert, and safe to delete.',
215
278
  );
216
279
  }