@lanes-sh/link 0.8.0 → 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 (87) hide show
  1. package/instructions/skills/lanes-link/SKILL.md +12 -12
  2. package/package.json +1 -1
  3. package/src/auth/oauth/store.ts +21 -5
  4. package/src/cli/accepts.ts +6 -5
  5. package/src/cli/commands/connect/index.ts +2 -1
  6. package/src/cli/commands/connect/settle.ts +12 -4
  7. package/src/cli/commands/connection.ts +2 -1
  8. package/src/cli/commands/identity.ts +4 -4
  9. package/src/cli/commands/knowledge/index.ts +9 -16
  10. package/src/cli/commands/knowledge/migrate.ts +9 -4
  11. package/src/cli/commands/knowledge/show.ts +14 -9
  12. package/src/cli/commands/operate/migrate.ts +5 -2
  13. package/src/cli/commands/operate/pair.ts +1 -1
  14. package/src/cli/commands/operate/serve.ts +1 -1
  15. package/src/cli/commands/owner/assets.ts +2 -2
  16. package/src/cli/commands/owner/entities.ts +2 -2
  17. package/src/cli/commands/owner/memory.ts +2 -2
  18. package/src/cli/commands/owner/tasks.ts +2 -2
  19. package/src/cli/commands/owner/vault.ts +3 -3
  20. package/src/cli/commands/profile/disposition.ts +236 -0
  21. package/src/cli/commands/profile/removal.ts +100 -13
  22. package/src/cli/commands/profile/remove.ts +67 -6
  23. package/src/cli/commands/profile.ts +33 -6
  24. package/src/cli/commands/secrets.ts +4 -4
  25. package/src/cli/commands/update-migration.ts +54 -0
  26. package/src/cli/commands/update.ts +36 -23
  27. package/src/cli/config-edit.ts +29 -8
  28. package/src/cli/config-repair-sweep.ts +119 -0
  29. package/src/cli/config-repair.ts +90 -141
  30. package/src/cli/config-templates.ts +26 -24
  31. package/src/cli/contract3-credentials.ts +294 -0
  32. package/src/cli/contract3-data.ts +143 -209
  33. package/src/cli/contract3-layout.ts +46 -0
  34. package/src/cli/contract3-shape.ts +34 -8
  35. package/src/cli/contract3.ts +141 -24
  36. package/src/cli/contract4-credentials.ts +207 -0
  37. package/src/cli/contract4-data.ts +399 -0
  38. package/src/cli/contract4-rename.ts +73 -0
  39. package/src/cli/contract4-yaml.ts +223 -0
  40. package/src/cli/contract4.ts +342 -0
  41. package/src/cli/identity.ts +44 -26
  42. package/src/cli/main.ts +6 -1
  43. package/src/cli/migrate-move.ts +166 -0
  44. package/src/cli/migrate-plan.ts +3 -3
  45. package/src/cli/publish.ts +1 -5
  46. package/src/cli/runtime/open.ts +5 -5
  47. package/src/cli/runtime/select.ts +2 -11
  48. package/src/cli/runtime/stores.ts +16 -11
  49. package/src/cli/runtime/vault.ts +2 -2
  50. package/src/cli/usage.ts +5 -1
  51. package/src/cli/workspace-migrate.ts +32 -11
  52. package/src/connectivity/manifest/provider.ts +31 -12
  53. package/src/connectivity/transports/imap/parser.ts +70 -9
  54. package/src/deployments/adapters/filesystem.ts +18 -3
  55. package/src/deployments/deploy.ts +5 -5
  56. package/src/deployments/gcp/bucket.ts +42 -6
  57. package/src/deployments/knowledge.ts +9 -4
  58. package/src/deployments/target.ts +28 -7
  59. package/src/deployments/upload.ts +39 -30
  60. package/src/profile/connections.ts +13 -1
  61. package/src/profile/deployments.ts +86 -8
  62. package/src/profile/index.ts +5 -1
  63. package/src/profile/knowledge.ts +18 -5
  64. package/src/profile/layout.ts +147 -71
  65. package/src/profile/load.ts +53 -17
  66. package/src/profile/schema.ts +11 -2
  67. package/src/profile/testing.ts +45 -10
  68. package/src/profile/workspace.ts +66 -30
  69. package/src/providers/assets/provider.ts +6 -6
  70. package/src/providers/entities/provider.ts +6 -6
  71. package/src/providers/entities/writes.ts +1 -1
  72. package/src/providers/identity/provider.ts +1 -1
  73. package/src/providers/memory/provider.ts +6 -6
  74. package/src/providers/setup/provider.ts +3 -3
  75. package/src/providers/skills/provider.ts +2 -2
  76. package/src/providers/tasks/provider.ts +6 -6
  77. package/src/providers/vault/provider.ts +1 -1
  78. package/src/registry/registry.ts +1 -1
  79. package/src/server/endpoint.ts +4 -0
  80. package/src/server/harness.ts +1 -1
  81. package/src/server/mcp/instructions.ts +21 -21
  82. package/src/server/mcp/routing.ts +3 -3
  83. package/src/server/mcp/tools.ts +16 -3
  84. package/src/server/mcp/visibility.ts +56 -4
  85. package/src/stores/blobs/conformance.ts +19 -0
  86. package/src/stores/state/index.ts +76 -10
  87. package/src/stores/state/testing.ts +5 -1
@@ -0,0 +1,342 @@
1
+ import {
2
+ ConfigError,
3
+ layout,
4
+ listProfiles,
5
+ readWorkspaceFile,
6
+ workspaceFiles,
7
+ writeWorkspaceFile,
8
+ WORKSPACE_FILE,
9
+ CONNECTIONS_FILE,
10
+ openTarget,
11
+ } from '#profile';
12
+ import { parseDocument } from 'yaml';
13
+ import { ConfigDocument } from './config-edit.ts';
14
+ import { C3 } from './contract3-layout.ts';
15
+ import { grantingProfiles, planMoves, type DataPlan, type Renames } from './contract4-data.ts';
16
+ import { planRenames } from './contract4-rename.ts';
17
+ import {
18
+ assertConnectionsSavable,
19
+ readConnectionRows,
20
+ renameConnections,
21
+ rewriteGrants,
22
+ } from './contract4-yaml.ts';
23
+ import { applyMoves, assertOneObjectPerDestination } from './migrate-move.ts';
24
+ import {
25
+ applyCredentialMoves,
26
+ planCredentialMoves,
27
+ planVaultMoves,
28
+ } from './contract4-credentials.ts';
29
+ import { openSecretStoreFor } from './runtime/select.ts';
30
+ import { buildRegistryWithWorkspace } from './runtime/registry.ts';
31
+
32
+ /**
33
+ * Contract 3 to contract 4: a profile owns its data again.
34
+ *
35
+ * Contract 3 moved every account up to the workspace and, in the same sweep,
36
+ * moved the owner layer's bytes beside the *connection* — so a profile owned
37
+ * nothing and the shipped default had every profile granting one memory.
38
+ * ADR-066 reverses that half; ADR-067 collapses a profile into one directory
39
+ * and retires `data/`, which had stopped meaning anything once the declaration
40
+ * moved in beside the bytes.
41
+ *
42
+ * Four steps, ordered so a crash between any two leaves a workspace that still
43
+ * opens:
44
+ *
45
+ * 1. The registry is renamed. One document, cannot half-apply.
46
+ * 2. Bytes move — the workspace's up out of `data/`, a profile's into its
47
+ * directory.
48
+ * 3. Declarations move into the directories they now name.
49
+ * 4. Each profile is stamped `contract: 4`.
50
+ *
51
+ * **The stamp is last, and it is the record that this finished** rather than a
52
+ * step among steps. Contract 3 shipped with it written first, which left
53
+ * profiles claiming the new contract with every byte still at the old path and
54
+ * a rerun that read the stamp and found nothing to do.
55
+ *
56
+ * Every read here goes through `C3`, and every write through `layout`. A
57
+ * migration that asks the live layout for its *source* paths finds nothing to
58
+ * move the moment the next contract lands.
59
+ */
60
+
61
+ export interface Contract4Migration {
62
+ readonly workspaceRoot: string;
63
+ readonly profiles: readonly string[];
64
+ readonly changes: readonly string[];
65
+ /** Copied into more than one profile, original left. The operator decides. */
66
+ readonly shared: DataPlan['shared'];
67
+ /** Granted by no profile. Left where it is. */
68
+ readonly orphaned: readonly string[];
69
+ readonly alreadyCurrent: boolean;
70
+ }
71
+
72
+ /** Whether this workspace still holds anything at contract 3. */
73
+ export async function needsContract4(workspaceRoot: string): Promise<boolean> {
74
+ for (const profile of await listProfiles(workspaceRoot)) {
75
+ const raw = await readProfile(workspaceRoot, profile);
76
+ if (raw !== null && (raw.contract ?? 0) === 3) return true;
77
+ }
78
+ return false;
79
+ }
80
+
81
+ async function readProfile(
82
+ root: string,
83
+ profile: string,
84
+ ): Promise<{ contract?: number; grants?: { connection?: unknown }[] } | null> {
85
+ const files = workspaceFiles(root);
86
+ // The contract-3 path first: a profile still to be migrated is there, and one
87
+ // already migrated is at the new path. Reading only one shape makes a rerun
88
+ // after an interruption see half a workspace.
89
+ const text =
90
+ (await readWorkspaceFile(files, C3.profile(profile))) ??
91
+ (await readWorkspaceFile(files, layout.profileConfig(profile)));
92
+ if (text === null) return null;
93
+ try {
94
+ return parseDocument(text).toJSON() as { contract?: number };
95
+ } catch {
96
+ return null;
97
+ }
98
+ }
99
+
100
+ export async function migrateToContract4(
101
+ workspaceRoot: string,
102
+ options: { apply: boolean; target?: string } = { apply: true },
103
+ ): Promise<Contract4Migration> {
104
+ const target = options.target ?? 'local';
105
+ const names = await listProfiles(workspaceRoot);
106
+ const configs = new Map<string, { grants?: { connection?: unknown }[] }>();
107
+
108
+ for (const profile of names) {
109
+ const raw = await readProfile(workspaceRoot, profile);
110
+ if (raw !== null && (raw.contract ?? 0) === 3) configs.set(profile, raw);
111
+ }
112
+
113
+ if (configs.size === 0) {
114
+ return {
115
+ workspaceRoot,
116
+ profiles: [],
117
+ changes: [],
118
+ shared: [],
119
+ orphaned: [],
120
+ alreadyCurrent: true,
121
+ };
122
+ }
123
+
124
+ const profiles = [...configs.keys()];
125
+ const files = workspaceFiles(workspaceRoot);
126
+
127
+ // Every connection the workspace holds, and the id each ends up with. Built
128
+ // once and read by every rewrite below — the contract-3 mover's own bug was a
129
+ // map keyed one way and queried the other, which made the resolution a silent
130
+ // no-op and sent two profiles' blobs into one namespace.
131
+ await assertConnectionsSavable(workspaceRoot);
132
+
133
+ const rows = await readConnectionRows(workspaceRoot);
134
+ const renames = planRenames(rows);
135
+
136
+ const plan = await planMoves(files, grantingProfiles(configs), profiles, renames);
137
+
138
+ // Everything that can refuse, before the first byte moves. A `keep` move is
139
+ // exempt: two profiles granting one store are *meant* to write one source to
140
+ // two destinations, which is the shape this check exists to catch elsewhere.
141
+ assertOneObjectPerDestination(plan.moves.filter((move) => move.keep !== true));
142
+
143
+ const changes = [...describe(plan, profiles), ...repositoryNotes(configs, renames)];
144
+ if (!options.apply) {
145
+ return {
146
+ workspaceRoot,
147
+ profiles,
148
+ changes,
149
+ shared: plan.shared,
150
+ orphaned: plan.orphaned,
151
+ alreadyCurrent: false,
152
+ };
153
+ }
154
+
155
+ await renameRegistry(workspaceRoot);
156
+ await applyMoves(files, plan.moves);
157
+
158
+ // Credentials before the rows: a ref is derived from the id, so the rows must
159
+ // still name the old one for `planCredentialMoves` to compute the same pair
160
+ // on a rerun.
161
+ const credentials = await moveCredentials(workspaceRoot, target, rows, renames, configs);
162
+
163
+ // **The rows are renamed after the grants and before the stamp**, and every
164
+ // window that leaves is one a rerun closes. `connections.yaml` is the only
165
+ // source `planRenames` has, so renaming it before the grants destroyed the
166
+ // map mid-flight: the rerun read the new rows, computed `lan1 → lan1`, found
167
+ // no mapping for `memory.main`, and stamped a profile whose grants named a
168
+ // connection nothing declared — refused at load, and `needsContract4` false,
169
+ // so no migration would ever run again.
170
+ //
171
+ // Both rewrites are idempotent, which is what makes the order safe rather
172
+ // than merely better: a grant already naming the new ref is not in the map
173
+ // and is left alone, and so is a row.
174
+ await rewriteGrants(workspaceRoot, profiles, renames);
175
+ await renameConnections(workspaceRoot, renames);
176
+
177
+ // Last. The stamp is the record that the migration finished.
178
+ for (const profile of profiles) {
179
+ const document = await ConfigDocument.openKey(workspaceRoot, layout.profileConfig(profile));
180
+ document.setIn(['contract'], 4);
181
+ await document.save();
182
+ }
183
+
184
+ return {
185
+ workspaceRoot,
186
+ profiles,
187
+ changes: [...changes, ...credentials],
188
+ shared: plan.shared,
189
+ orphaned: plan.orphaned,
190
+ alreadyCurrent: false,
191
+ };
192
+ }
193
+
194
+ /**
195
+ * Move each stored credential to the ref its renamed connection now derives.
196
+ *
197
+ * **Throws rather than warns**, and that is the whole of its error handling. It
198
+ * warned once, and the rehearsal that found it showed why it must not: the
199
+ * warning was printed, `renameConnections` ran anyway, and the workspace came
200
+ * out with rows naming `gmail.con1` while the secret sat at
201
+ * `gmail/wjj_andrews`. A rerun cannot repair that — the rows are renamed, so
202
+ * the second run computes no rename for them and the old ref is orphaned with
203
+ * nothing left that knows what it belonged to.
204
+ *
205
+ * Failing here leaves the rows untouched, which is the state a rerun *can*
206
+ * finish from. Bytes that already moved are found in place and skipped.
207
+ */
208
+ async function moveCredentials(
209
+ root: string,
210
+ target: string,
211
+ rows: readonly { id: string; provider: string }[],
212
+ renames: Renames,
213
+ profiles: ReadonlyMap<string, { grants?: { connection?: unknown }[] }>,
214
+ ): Promise<string[]> {
215
+ if (rows.length === 0) return [];
216
+
217
+ const [store, registry] = await Promise.all([
218
+ openSecretStoreFor(root, target),
219
+ buildRegistryWithWorkspace(root),
220
+ ]);
221
+
222
+ // `readConnectionRows`, not `readConnections`. The latter runs
223
+ // `assertNoRenamedProviders`, which refuses a `tasks` row — and a workspace
224
+ // holding one is exactly the workspace being migrated, so reading through the
225
+ // guard makes the refusal block its own fix. ADR-051's rule, met twice now: a
226
+ // refusal has to name a command, and the command has to be able to run.
227
+ // The vault's document is planned separately because `credentialRefFor`
228
+ // cannot name it — see `planVaultMoves`. One apply for both, so a source
229
+ // feeding several destinations is deleted once rather than per plan.
230
+ const declared = (await openTarget(root, target)).declared;
231
+
232
+ return await applyCredentialMoves(store, [
233
+ ...planCredentialMoves(await readConnectionRows(root, true), registry, renames),
234
+ ...(declared.vault?.adapter === 'secret'
235
+ ? planVaultMoves(profiles, renames, declared.vault.ref)
236
+ : []),
237
+ ]);
238
+ }
239
+
240
+ /**
241
+ * `lanes-link.yaml` becomes `workspaces.yaml`.
242
+ *
243
+ * Written then deleted rather than moved, so an interruption leaves both and
244
+ * `readWorkspace` — which prefers the new name — still opens the workspace.
245
+ * Losing this file is losing the address of every target.
246
+ */
247
+ async function renameRegistry(root: string): Promise<void> {
248
+ const files = workspaceFiles(root);
249
+ if (await files.has(WORKSPACE_FILE)) return;
250
+
251
+ const text = await readWorkspaceFile(files, C3.workspace);
252
+ if (text === null) return;
253
+
254
+ await writeWorkspaceFile(files, WORKSPACE_FILE, text);
255
+ if ((await readWorkspaceFile(files, WORKSPACE_FILE)) === null) {
256
+ throw new ConfigError(
257
+ `${WORKSPACE_FILE} did not read back after being written. Nothing has been deleted; ` +
258
+ 'fix the store and run this again.',
259
+ );
260
+ }
261
+ await files.delete(C3.workspace);
262
+ }
263
+
264
+ /**
265
+ * What a `knowledge:` repository needs done by hand, named rather than left.
266
+ *
267
+ * A profile keeping its memory and entities in GitHub addresses them by the
268
+ * connection id — an entry reaches the repository as `memory/<id>/<entry>.md`.
269
+ * Contract 4 renames the id, so the provider starts reading `memory/lan1/`
270
+ * while the repository still holds `memory/main/`: `memory_search` returns
271
+ * nothing and `entities_find` finds nobody, with the data sitting intact under
272
+ * the old name and nothing having failed.
273
+ *
274
+ * Not repaired here on purpose. Renaming directories in somebody's repository
275
+ * is a network write to a thing this migration does not own, and `applyMoves`
276
+ * cannot reach a GitHub store at all — so the honest move is to say exactly
277
+ * what to rename, which is one `git mv` per area. `removalPlan` warns about the
278
+ * same class for the same reason.
279
+ */
280
+ function repositoryNotes(
281
+ configs: ReadonlyMap<string, { knowledge?: unknown; grants?: { connection?: unknown }[] }>,
282
+ renames: Renames,
283
+ ): string[] {
284
+ const notes: string[] = [];
285
+
286
+ for (const [profile, config] of configs) {
287
+ const repo = (config.knowledge as { repo?: unknown } | undefined)?.repo;
288
+ if (typeof repo !== 'string') continue;
289
+
290
+ for (const surface of ['memory', 'entities'] as const) {
291
+ const granted = (config.grants ?? [])
292
+ .map((grant) => grant.connection)
293
+ .find((ref): ref is string => typeof ref === 'string' && ref.startsWith(`${surface}.`));
294
+ if (granted === undefined) continue;
295
+
296
+ const to = renames.get(granted);
297
+ if (to === undefined) continue;
298
+
299
+ const was = granted.slice(granted.indexOf('.') + 1);
300
+ const now = to.slice(to.indexOf('.') + 1);
301
+ if (was === now) continue;
302
+
303
+ notes.push(
304
+ `${repo}: rename ${surface}/${was}/ to ${surface}/${now}/ — "${profile}" reads it by the ` +
305
+ 'connection id, and nothing here can write to your repository',
306
+ );
307
+ }
308
+ }
309
+
310
+ return notes;
311
+ }
312
+
313
+ function describe(plan: DataPlan, profiles: readonly string[]): string[] {
314
+ const changes = [`${C3.workspace} → ${WORKSPACE_FILE}`];
315
+
316
+ for (const profile of profiles) {
317
+ changes.push(`${C3.profile(profile)} → ${layout.profileConfig(profile)}: contract 4`);
318
+ }
319
+
320
+ const moved = plan.moves.filter((move) => move.keep !== true).length;
321
+ if (moved > 0) changes.push(`${moved} object(s) moved out of data/`);
322
+
323
+ for (const { key, profiles: owners } of plan.shared) {
324
+ changes.push(`${key}: copied into ${owners.join(' and ')} — the original is left for you`);
325
+ }
326
+ for (const key of plan.orphaned) {
327
+ changes.push(`${key}: no profile grants it, so it stays where it is`);
328
+ }
329
+
330
+ // Said as what it is. Contract 3 merged these and deliberately did not delete
331
+ // them, so a workspace that came through it still holds a decryptable
332
+ // credential document per profile — and reporting that as an ungranted store
333
+ // reads as tidy-up rather than as a credential left on disk.
334
+ for (const key of plan.leftover) {
335
+ changes.push(
336
+ `${key}: a credential store contract 3 merged and left behind — its contents are in ` +
337
+ `${layout.credentials()} now, so delete it`,
338
+ );
339
+ }
340
+
341
+ return changes;
342
+ }
@@ -88,10 +88,10 @@ export async function resolveAccount(
88
88
  if (!primary || !identity.qualifier) return primary;
89
89
 
90
90
  // `alice (Acme)` rather than `alice`. The bracketed half is what makes
91
- // two workspaces two accounts instead of one overwritten twice, and it
92
- // survives into the connection id because `idFromAccount` slugifies the
93
- // whole string when there is no `@` in it `alice_acme`, which is a row
94
- // somebody can read in `status`.
91
+ // two workspaces two accounts instead of one overwritten twice: the
92
+ // reconnect match below is on the account, so without it the second
93
+ // connect repairs the first row rather than adding one. It used to reach
94
+ // the id as well, back when the id was slugified from this.
95
95
  const qualifier = pluck(body, identity.qualifier);
96
96
  return qualifier ? `${primary} (${qualifier})` : primary;
97
97
  }
@@ -116,29 +116,47 @@ export async function resolveAccount(
116
116
  }
117
117
 
118
118
  /**
119
- * Turn an account into a connection id.
119
+ * `lan` for a surface built into Lanes, `con` for somebody's account.
120
120
  *
121
- * `ada.lovelace@example.com` becomes `ada_lovelace`, which is what appears in
122
- * `credential_ref` and in the agent's `connection` argument. The local part is
123
- * enough to tell accounts apart in practice, and the full address is still
124
- * right there in `account` when it is not.
121
+ * The prefix is the only thing an id says, and it says the one thing that is
122
+ * true forever: whether there is a vendor behind this row. Everything else a
123
+ * reader wants whose mailbox, what the operator calls it — is `account` and
124
+ * `label`, one field each, both changeable without moving a reference.
125
125
  */
126
- export function idFromAccount(account: string, taken: readonly string[] = []): string {
127
- const local = account.includes('@') ? (account.split('@')[0] ?? account) : account;
128
-
129
- const base =
130
- local
131
- .toLowerCase()
132
- .replace(/[^a-z0-9]+/g, '_')
133
- .replace(/^_+|_+$/g, '')
134
- .slice(0, 40) || 'main';
135
-
136
- if (!taken.includes(base)) return base;
137
-
138
- // A genuine collision — two accounts sharing a local part, e.g. the same
139
- // name at two domains. Suffixing beats overwriting someone else's credential.
140
- for (let n = 2; ; n++) {
141
- const candidate = `${base}${n}`;
142
- if (!taken.includes(candidate)) return candidate;
126
+ export const OWNER_ID_PREFIX = 'lan';
127
+ export const ACCOUNT_ID_PREFIX = 'con';
128
+
129
+ /**
130
+ * The next free connection id.
131
+ *
132
+ * **Opaque, where this used to derive the id from the account.**
133
+ * `ada.lovelace@example.com` became `ada_lovelace`, on the reasoning that the
134
+ * local part tells accounts apart in practice. It does not: the same name at
135
+ * two domains produced `ada_lovelace` and `ada_lovelace2`, and an id that half
136
+ * describes its account is worse than one that does not, because it invites
137
+ * being trusted. What made that concrete is that the id is the whole of the
138
+ * `connection` enum a model chooses from.
139
+ *
140
+ * So the id is a key and nothing else. `account` carries the identity the
141
+ * provider reports and `label` the operator's own word, which means a `relabel`
142
+ * never moves a `credential_ref`, a blob path, or a grant.
143
+ *
144
+ * **A leading letter, not a bare number.** `id: 001` parses as the integer `1`
145
+ * in YAML, so `gmail.001` in a grant would match nothing and every id would need
146
+ * quoting forever. `con1` is a string unconditionally.
147
+ *
148
+ * Numbers are never reused: the highest taken plus one, so an id that appears
149
+ * in an audit log years later still means the row it meant then.
150
+ */
151
+ export function nextConnectionId(taken: readonly string[], owner: boolean): string {
152
+ const prefix = owner ? OWNER_ID_PREFIX : ACCOUNT_ID_PREFIX;
153
+ const pattern = new RegExp(`^${prefix}([0-9]+)$`);
154
+
155
+ let highest = 0;
156
+ for (const id of taken) {
157
+ const match = pattern.exec(id);
158
+ if (match) highest = Math.max(highest, Number(match[1]));
143
159
  }
160
+
161
+ return `${prefix}${highest + 1}`;
144
162
  }
package/src/cli/main.ts CHANGED
@@ -202,7 +202,8 @@ export async function run(argv: readonly string[]): Promise<void> {
202
202
  case 'remove':
203
203
  if (!rest[0]) {
204
204
  throw new Error(
205
- `Usage: ${PROGRAM} profile remove <name> [--workspace <name>] [--dry-run] [--yes]`,
205
+ `Usage: ${PROGRAM} profile remove <name> [--workspace <name>] [--dry-run] [--yes] ` +
206
+ '[--delete-data | --migrate-to <profile>]',
206
207
  );
207
208
  }
208
209
  return profileRemove(rest[0], {
@@ -210,6 +211,10 @@ export async function run(argv: readonly string[]): Promise<void> {
210
211
  json,
211
212
  dryRun: flags['dry-run'] === true,
212
213
  yes: flags['yes'] === true,
214
+ deleteData: flags['delete-data'] === true,
215
+ ...(typeof flags['migrate-to'] === 'string'
216
+ ? { migrateTo: flags['migrate-to'] }
217
+ : {}),
213
218
  });
214
219
  default:
215
220
  throw new Error(`Unknown: ${PROGRAM} profile ${second}`);
@@ -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,5 +1,5 @@
1
1
  import {
2
- DATA_DIR, ConfigError, layout, isRemoteWorkspace, type LegacyTarget, type WorkspaceTarget } from '#profile';
2
+ LEGACY_DATA_DIR, ConfigError, layout, isRemoteWorkspace, type LegacyTarget, type WorkspaceTarget } from '#profile';
3
3
  import { deployedWorkspace } from '#deployments/upload.ts';
4
4
 
5
5
  /**
@@ -111,8 +111,8 @@ export function toEntry(
111
111
  // and `layout` describes where things live *now* — workspace-level since
112
112
  // ADR-057. Asking it would compare a contract-1 path against a contract-3
113
113
  // default and refuse every profile that had written the ordinary one.
114
- const defaultStorage = `./${DATA_DIR}/${profile}`;
115
- const defaultCredentials = `./${DATA_DIR}/${profile}/credentials.enc`;
114
+ const defaultStorage = `./${LEGACY_DATA_DIR}/${profile}`;
115
+ const defaultCredentials = `./${LEGACY_DATA_DIR}/${profile}/credentials.enc`;
116
116
 
117
117
  refuseCustomPath(name, profile, workspaceRoot, 'storage.path', storagePath, defaultStorage);
118
118
  refuseCustomPath(
@@ -102,11 +102,7 @@ export async function publishProfileEdit(input: {
102
102
  /** Every profile the edit touched, where it reached more than the one named. */
103
103
  readonly touched?: readonly string[] | undefined;
104
104
  }): Promise<PublishOutcome> {
105
- const credentials = await openSecretStoreFor(
106
- input.config,
107
- input.resolution.workspaceRoot,
108
- input.target,
109
- );
105
+ const credentials = await openSecretStoreFor(input.resolution.workspaceRoot, input.target);
110
106
 
111
107
  return publishAndNotify({
112
108
  config: input.config,