@lanes-sh/link 0.8.0 → 0.9.1

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 (90) hide show
  1. package/README.md +1 -25
  2. package/instructions/skills/lanes-link/SKILL.md +12 -12
  3. package/package.json +1 -1
  4. package/src/auth/oauth/store.ts +21 -5
  5. package/src/cli/accepts.ts +6 -5
  6. package/src/cli/brand.ts +20 -15
  7. package/src/cli/callback-page.ts +8 -7
  8. package/src/cli/commands/connect/index.ts +2 -1
  9. package/src/cli/commands/connect/settle.ts +12 -4
  10. package/src/cli/commands/connection.ts +2 -1
  11. package/src/cli/commands/identity.ts +4 -4
  12. package/src/cli/commands/knowledge/index.ts +9 -16
  13. package/src/cli/commands/knowledge/migrate.ts +9 -4
  14. package/src/cli/commands/knowledge/show.ts +14 -9
  15. package/src/cli/commands/operate/migrate.ts +5 -2
  16. package/src/cli/commands/operate/pair.ts +1 -1
  17. package/src/cli/commands/operate/serve.ts +1 -1
  18. package/src/cli/commands/owner/assets.ts +2 -2
  19. package/src/cli/commands/owner/entities.ts +2 -2
  20. package/src/cli/commands/owner/memory.ts +2 -2
  21. package/src/cli/commands/owner/tasks.ts +2 -2
  22. package/src/cli/commands/owner/vault.ts +3 -3
  23. package/src/cli/commands/profile/disposition.ts +236 -0
  24. package/src/cli/commands/profile/removal.ts +100 -13
  25. package/src/cli/commands/profile/remove.ts +67 -6
  26. package/src/cli/commands/profile.ts +33 -6
  27. package/src/cli/commands/secrets.ts +4 -4
  28. package/src/cli/commands/update-migration.ts +54 -0
  29. package/src/cli/commands/update.ts +36 -23
  30. package/src/cli/config-edit.ts +29 -8
  31. package/src/cli/config-repair-sweep.ts +183 -0
  32. package/src/cli/config-repair.ts +90 -141
  33. package/src/cli/config-templates.ts +26 -24
  34. package/src/cli/contract3-credentials.ts +294 -0
  35. package/src/cli/contract3-data.ts +143 -209
  36. package/src/cli/contract3-layout.ts +46 -0
  37. package/src/cli/contract3-shape.ts +34 -8
  38. package/src/cli/contract3.ts +141 -24
  39. package/src/cli/contract4-credentials.ts +207 -0
  40. package/src/cli/contract4-data.ts +399 -0
  41. package/src/cli/contract4-rename.ts +73 -0
  42. package/src/cli/contract4-yaml.ts +223 -0
  43. package/src/cli/contract4.ts +349 -0
  44. package/src/cli/identity.ts +44 -26
  45. package/src/cli/main.ts +6 -1
  46. package/src/cli/migrate-move.ts +166 -0
  47. package/src/cli/migrate-plan.ts +3 -3
  48. package/src/cli/publish.ts +1 -5
  49. package/src/cli/runtime/open.ts +5 -5
  50. package/src/cli/runtime/select.ts +2 -11
  51. package/src/cli/runtime/stores.ts +16 -11
  52. package/src/cli/runtime/vault.ts +2 -2
  53. package/src/cli/usage.ts +5 -1
  54. package/src/cli/workspace-migrate.ts +32 -11
  55. package/src/connectivity/manifest/provider.ts +31 -12
  56. package/src/connectivity/transports/imap/parser.ts +70 -9
  57. package/src/deployments/adapters/filesystem.ts +18 -3
  58. package/src/deployments/deploy.ts +5 -5
  59. package/src/deployments/gcp/bucket.ts +42 -6
  60. package/src/deployments/knowledge.ts +9 -4
  61. package/src/deployments/target.ts +28 -7
  62. package/src/deployments/upload.ts +39 -30
  63. package/src/profile/connections.ts +13 -1
  64. package/src/profile/deployments.ts +86 -8
  65. package/src/profile/index.ts +5 -1
  66. package/src/profile/knowledge.ts +18 -5
  67. package/src/profile/layout.ts +147 -71
  68. package/src/profile/load.ts +53 -17
  69. package/src/profile/schema.ts +11 -2
  70. package/src/profile/testing.ts +45 -10
  71. package/src/profile/workspace.ts +66 -30
  72. package/src/providers/assets/provider.ts +6 -6
  73. package/src/providers/entities/provider.ts +6 -6
  74. package/src/providers/entities/writes.ts +1 -1
  75. package/src/providers/identity/provider.ts +1 -1
  76. package/src/providers/memory/provider.ts +6 -6
  77. package/src/providers/setup/provider.ts +3 -3
  78. package/src/providers/skills/provider.ts +2 -2
  79. package/src/providers/tasks/provider.ts +6 -6
  80. package/src/providers/vault/provider.ts +1 -1
  81. package/src/registry/registry.ts +1 -1
  82. package/src/server/endpoint.ts +4 -0
  83. package/src/server/harness.ts +1 -1
  84. package/src/server/mcp/instructions.ts +21 -21
  85. package/src/server/mcp/routing.ts +3 -3
  86. package/src/server/mcp/tools.ts +16 -3
  87. package/src/server/mcp/visibility.ts +56 -4
  88. package/src/stores/blobs/conformance.ts +19 -0
  89. package/src/stores/state/index.ts +76 -10
  90. package/src/stores/state/testing.ts +5 -1
@@ -105,6 +105,6 @@ export async function memoryForget(id: string | undefined, flags: OwnerFlags): P
105
105
  * again, so the CLI cannot address a different directory from the provider.
106
106
  */
107
107
  export function memoryStore(runtime: Runtime, flags: OwnerFlags): BlobStore {
108
- const connection = ownerConnection(runtime.config, 'memory', flags);
109
- return scopeBlobStore(runtime.storage, scopeNamespace('memory', connection));
108
+ const connection = ownerConnection(runtime.config, 'lanes_memory', flags);
109
+ return scopeBlobStore(runtime.storage, scopeNamespace('lanes_memory', connection));
110
110
  }
@@ -189,6 +189,6 @@ function assertStatus(raw: string | undefined): TaskStatus | undefined {
189
189
  * CLI cannot address a different directory from the provider.
190
190
  */
191
191
  export function tasksStore(runtime: Runtime, flags: OwnerFlags): BlobStore {
192
- const connection = ownerConnection(runtime.config, 'tasks', flags);
193
- return scopeBlobStore(runtime.storage, scopeNamespace('tasks', connection));
192
+ const connection = ownerConnection(runtime.config, 'lanes_tasks', flags);
193
+ return scopeBlobStore(runtime.storage, scopeNamespace('lanes_tasks', connection));
194
194
  }
@@ -50,7 +50,7 @@ export async function vaultGet(id: string | undefined, flags: OwnerFlags): Promi
50
50
  const itemId = required(id, 'lanes link vault get <id> [--show]');
51
51
 
52
52
  await withRuntime(flags, async (runtime) => {
53
- const connection = ownerConnection(runtime.config, 'vault', flags);
53
+ const connection = ownerConnection(runtime.config, 'lanes_vault', flags);
54
54
  const item = await runtime.vault.get(connection, itemId);
55
55
  if (!item) throw new ConfigError(`No vault item "${itemId}" in this profile.`);
56
56
 
@@ -77,7 +77,7 @@ export async function vaultSet(id: string | undefined, flags: OwnerFlags): Promi
77
77
  const value = await readStdin(`lanes link vault set ${itemId}`, 'the secret');
78
78
 
79
79
  await withRuntime(flags, async (runtime) => {
80
- const connection = ownerConnection(runtime.config, 'vault', flags);
80
+ const connection = ownerConnection(runtime.config, 'lanes_vault', flags);
81
81
  const replacing = (await runtime.vault.get(connection, itemId)) !== null;
82
82
 
83
83
  await runtime.vault.put(connection, {
@@ -104,7 +104,7 @@ export async function vaultRemove(id: string | undefined, flags: OwnerFlags): Pr
104
104
  const itemId = required(id, 'lanes link vault remove <id>');
105
105
 
106
106
  await withRuntime(flags, async (runtime) => {
107
- const connection = ownerConnection(runtime.config, 'vault', flags);
107
+ const connection = ownerConnection(runtime.config, 'lanes_vault', flags);
108
108
  const item = await runtime.vault.get(connection, itemId);
109
109
  if (!item) throw new ConfigError(`No vault item "${itemId}" in this profile.`);
110
110
 
@@ -0,0 +1,236 @@
1
+ import { ConfigError } from '#profile';
2
+ import type { Prompter } from '../../prompt.ts';
3
+
4
+ /**
5
+ * What becomes of the bytes a removed profile owns.
6
+ *
7
+ * Split from `removal.ts` and `remove.ts` on the seam the question already has:
8
+ * both files grew past the size budget when this arrived, and it is one
9
+ * decision — asked once, answered once, and then applied by the plan and the
10
+ * execution without either re-deciding anything.
11
+ */
12
+
13
+ /**
14
+ * What to do with the bytes a profile owns.
15
+ *
16
+ * There has to be an answer and there is no safe default, which is why this is
17
+ * a discriminated choice rather than a flag with a fallback. Deleting by
18
+ * default destroys the material somebody kept; migrating by default puts one
19
+ * profile's notes into another without being asked, which is the thing ADR-066
20
+ * exists to stop happening by accident.
21
+ */
22
+ export type Disposition =
23
+ | { readonly kind: 'delete' }
24
+ | { readonly kind: 'migrate'; readonly into: string };
25
+
26
+ /**
27
+ * `note.md` becomes `note-2.md`, before the extension rather than after it.
28
+ *
29
+ * An asset's key is whatever the file was called, and `note.md-2` is a file
30
+ * nothing will open. Memory entries and tasks are `<id>.md`, so the same rule
31
+ * keeps the id readable.
32
+ */
33
+ export function suffixed(key: string, taken: (candidate: string) => boolean): string {
34
+ const dot = key.lastIndexOf('.');
35
+ const cut = dot <= key.lastIndexOf('/') ? key.length : dot;
36
+ const [stem, extension] = [key.slice(0, cut), key.slice(cut)];
37
+
38
+ for (let n = 2; ; n += 1) {
39
+ const candidate = `${stem}-${n}${extension}`;
40
+ if (!taken(candidate)) return candidate;
41
+ }
42
+ }
43
+
44
+ /**
45
+ * What to do with the bytes, decided before anything is planned.
46
+ *
47
+ * **`--yes` does not imply a disposition.** Everything else `--yes` skips is a
48
+ * confirmation of something the command was already told to do; this is a
49
+ * question it has not been asked, and picking either answer silently is a way
50
+ * to lose somebody's notes — or to put them somewhere they did not ask for
51
+ * them, which under ADR-066 is the failure the whole change exists to prevent.
52
+ * So a non-interactive caller must say, and one that does not is refused with
53
+ * both spellings named.
54
+ */
55
+ export async function settleDisposition(
56
+ profile: string,
57
+ flags: { readonly deleteData?: boolean | undefined; readonly migrateTo?: string | undefined },
58
+ prompter: Prompter,
59
+ someoneToAsk: boolean,
60
+ ): Promise<Disposition | null> {
61
+ if (flags.migrateTo !== undefined && flags.deleteData === true) {
62
+ throw new ConfigError(
63
+ '--delete-data and --migrate-to say opposite things about the same bytes. Pass one.',
64
+ );
65
+ }
66
+
67
+ if (flags.migrateTo !== undefined) return { kind: 'migrate', into: flags.migrateTo };
68
+ if (flags.deleteData === true) return { kind: 'delete' };
69
+
70
+ if (!someoneToAsk) {
71
+ throw new ConfigError(
72
+ `"${profile}" owns memory, tasks, assets and skills, and this does not guess at what ` +
73
+ 'becomes of them.\n' +
74
+ ' Say which: --delete-data, or --migrate-to <profile>',
75
+ );
76
+ }
77
+
78
+ const answer = (
79
+ await prompter.ask(
80
+ `What becomes of ${profile}'s memory, tasks, assets and skills?\n` +
81
+ ` [d] delete them [m] move them into another profile [anything else] stop: `,
82
+ )
83
+ ).trim().toLowerCase();
84
+
85
+ if (answer === 'd') return { kind: 'delete' };
86
+ if (answer !== 'm') return null;
87
+
88
+ const into = (await prompter.ask(' Move them into which profile? ')).trim();
89
+ return into.length === 0 ? null : { kind: 'migrate', into };
90
+ }
91
+
92
+
93
+ /**
94
+ * The skill a key belongs to, or null when the key is not a skill's.
95
+ *
96
+ * Both layouts: `<name>/SKILL.md` and whatever it ships beside it, and the flat
97
+ * `<name>.md`.
98
+ */
99
+ export function skillNameIn(key: string): string | null {
100
+ const parts = key.split('/');
101
+ if (parts[0] !== 'skills.d' || parts.length < 3) return null;
102
+
103
+ const third = parts[2]!;
104
+ return parts.length > 3 ? third : third.replace(/\.md$/, '');
105
+ }
106
+
107
+ /**
108
+ * Two skills of one name have no union either, so this refuses like the vault.
109
+ *
110
+ * A skill's name is not a filename — it becomes the capability id
111
+ * `skills.<name>`, which is what a policy rule grants and what an MCP prompt is
112
+ * called. So the suffix that resolves a colliding note resolves nothing here: a
113
+ * skill arriving as `proc-b-2` is granted by no rule the destination holds and
114
+ * offered to no client, which is `refuseSealedVault`'s argument — a copy under a
115
+ * name no command opens — reached by a different route. Renaming only the
116
+ * directory is worse still, because the frontmatter keeps declaring the old
117
+ * name: two skills then claim one capability id and `skills list` refuses for
118
+ * the whole profile rather than for the one skill.
119
+ *
120
+ * Which of the two to keep is a decision about the operator's procedures, and
121
+ * they are the only one who can make it.
122
+ */
123
+ export function refuseCollidingSkill(into: string, name: string): never {
124
+ throw new ConfigError(
125
+ `"${into}" already has a skill named "${name}", and the one arriving cannot take another ` +
126
+ `name: it becomes the capability id "skills.${name}", which policy rules grant and MCP ` +
127
+ 'prompts are called by.\n' +
128
+ ` Rename one of the two first — lanes link skills show ${name} --profile ${into} — then ` +
129
+ 'run this again, or remove the profile with --delete-data.',
130
+ );
131
+ }
132
+
133
+ /**
134
+ * Rename anything the destination already holds, before a byte moves.
135
+ *
136
+ * Resolved while this is still a plan, so the operator sees every rename in the
137
+ * preview they confirm from and the execution has no decision left to make. A
138
+ * destination that is occupied holds the *destination profile's* own note, task
139
+ * or asset — overwriting one would be the quiet half of a migration nobody
140
+ * asked for, and merging two is not a thing bytes can do.
141
+ *
142
+ * Mutates `items` in place because the caller is still assembling the plan;
143
+ * the warnings come back for it to attach.
144
+ */
145
+ export async function resolveCollisions(
146
+ items: { kind: string; movedTo?: readonly [string, string]; note?: string }[],
147
+ profile: string,
148
+ into: string,
149
+ open: (area: string) => Promise<{ list(): Promise<{ key: string }[]> }>,
150
+ ): Promise<string[]> {
151
+ const warnings: string[] = [];
152
+
153
+ // **Throws rather than warning.** It returned a warning saying "nothing has
154
+ // run" and then left every `movedTo` in place — and `executeRemoval` writes
155
+ // each one without checking, on the stated ground that "a collision was
156
+ // resolved while this was still a plan". With `--yes` there is no prompt to
157
+ // stop at, so an unreadable destination meant the destination profile's own
158
+ // notes were overwritten by the removed profile's files of the same name, and
159
+ // the command reported success. A plan that cannot be made safe must not
160
+ // become one.
161
+ let held: Set<string>;
162
+ try {
163
+ held = new Set((await (await open(into)).list()).map((blob) => blob.key));
164
+ } catch (cause) {
165
+ throw new ConfigError(
166
+ `"${profile}"'s storage could not be read (${
167
+ cause instanceof Error ? cause.message : String(cause)
168
+ }), so a name it already holds cannot be found — and migrating into it ` +
169
+ 'would overwrite one.\n Nothing has run. Fix the store and try again, or pass ' +
170
+ '--delete-data instead.',
171
+ );
172
+ }
173
+
174
+ for (let index = 0; index < items.length; index += 1) {
175
+ const item = items[index]!;
176
+ if (item.kind !== 'blob' || item.movedTo === undefined) continue;
177
+
178
+ const key = item.movedTo[1];
179
+ if (!held.has(key)) {
180
+ held.add(key);
181
+ continue;
182
+ }
183
+
184
+ // Before the suffix, because for one kind of key there is no suffix that
185
+ // works and the answer is to stop rather than to invent one.
186
+ const skill = skillNameIn(key);
187
+ if (skill !== null) refuseCollidingSkill(profile, skill);
188
+
189
+ const renamed = suffixed(key, (candidate) => held.has(candidate));
190
+ held.add(renamed);
191
+ items[index] = {
192
+ ...item,
193
+ movedTo: [into, renamed] as const,
194
+ note: `renamed — ${profile} already holds ${key}`,
195
+ };
196
+ warnings.push(`${key} is already in "${profile}", so it arrives as ${renamed}.`);
197
+ }
198
+
199
+ return warnings;
200
+ }
201
+
202
+ /**
203
+ * Whether one key inside a profile's directory can cross into another profile.
204
+ *
205
+ * Only the owner's material can. The prompt asks about memory, tasks, assets
206
+ * and skills; the directory holds two more things and neither may travel.
207
+ *
208
+ * `state.kv/` is derived and disposable, and adopting it would hand the
209
+ * destination another profile's cursor — resuming a mailbox from a position it
210
+ * never read and never seeing the messages in between, which is the
211
+ * cross-profile cursor sharing ADR-066 exists to remove.
212
+ *
213
+ * `vault.d/` is refused by `refuseSealedVault` rather than copied. Because the
214
+ * owner layer merges to one row per surface, both profiles hold
215
+ * `vault.d/lan5.enc` — so a copy always collided, landed as `lan5-2.enc`, and
216
+ * had its source deleted, leaving every item in it unreachable by any command.
217
+ */
218
+ export function migratesAcross(key: string): boolean {
219
+ return !key.startsWith('state.kv/') && !key.startsWith('vault.d/');
220
+ }
221
+
222
+ /**
223
+ * Two sealed documents have no union, so the command says so and stops.
224
+ *
225
+ * Deleting one silently is the outcome nobody would accept, and a copy under
226
+ * another name is one no command opens — so neither is offered. The refusal
227
+ * names the two ways forward, which is the rule every refusal here follows.
228
+ */
229
+ export function refuseSealedVault(profile: string, into: string): never {
230
+ throw new ConfigError(
231
+ `"${profile}" holds a sealed vault, and a vault cannot be merged into "${into}"'s: two ` +
232
+ 'encrypted documents have no union, and a copy under another name is one no command opens.\n' +
233
+ ` Move what you need first — lanes link vault list --profile ${profile} — then remove it ` +
234
+ 'with --delete-data.',
235
+ );
236
+ }
@@ -1,8 +1,22 @@
1
- import { layout, profilePath, vaultRef, 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
@@ -103,8 +117,21 @@ export interface RemovalItem {
103
117
  readonly kind: 'secret' | 'blob' | 'file' | 'config' | 'workspace-key';
104
118
  readonly id: string;
105
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];
106
132
  }
107
133
 
134
+
108
135
  export interface RemovalPlan {
109
136
  readonly profile: string;
110
137
  readonly items: readonly RemovalItem[];
@@ -128,6 +155,8 @@ export interface PlanOptions {
128
155
  readonly openSecrets: (target: string) => Promise<SecretStore>;
129
156
  readonly openBlobs: (target: string, area?: string) => Promise<BlobStore>;
130
157
  readonly readDefaultProfile?: (() => Promise<string | undefined>) | undefined;
158
+ /** What becomes of this profile's own bytes. */
159
+ readonly disposition: Disposition;
131
160
  /**
132
161
  * The profiles that are staying.
133
162
  *
@@ -140,6 +169,7 @@ export interface PlanOptions {
140
169
 
141
170
  const reason = (cause: unknown): string => (cause instanceof Error ? cause.message : String(cause));
142
171
 
172
+
143
173
  /**
144
174
  * Everything removing this profile would delete, before any of it is deleted.
145
175
  *
@@ -156,6 +186,9 @@ export async function removalPlan(
156
186
  options: PlanOptions,
157
187
  ): Promise<RemovalPlan> {
158
188
  const items: RemovalItem[] = [];
189
+ const migrateInto =
190
+ options.disposition.kind === 'migrate' ? options.disposition.into : undefined;
191
+ const sealed: string[] = [];
159
192
  const untouched: { target: string; refs: string[] }[] = [];
160
193
  const warnings: string[] = [];
161
194
 
@@ -205,16 +238,58 @@ export async function removalPlan(
205
238
  );
206
239
  }
207
240
 
208
- // **No blob sweep, and no profile directory.** Both existed because a
209
- // profile owned `data/<profile>/`, and `rm -r` on it was exactly "what could
210
- // this profile reach". It owns nothing now (ADR-057, ADR-059): the blob root
211
- // is the workspace's, and the stores under it belong to connections other
212
- // profiles may grant. Listing it here would queue every byte in the
213
- // workspace for deletion because one profile is going.
214
- //
215
- // What replaces it is `lanes link disconnect`, which takes one connection,
216
- // its grants, and its credential — after checking whether anything else
217
- // still needs them. That check is the whole reason this cannot happen here.
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.
248
+ try {
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
+ }
275
+ } catch (cause) {
276
+ warnings.push(
277
+ `Target "${name}": its storage could not be opened (${reason(cause)}), so nothing in it will be removed.`,
278
+ );
279
+ }
280
+
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.
285
+ if (declared.storage.adapter === 'filesystem') {
286
+ items.push({
287
+ target: name,
288
+ kind: 'file',
289
+ id: layout.profileDir(profile),
290
+ note: 'the profile directory, once emptied',
291
+ });
292
+ }
218
293
 
219
294
  // A deployed revision reads its config from the bucket rather than the
220
295
  // image (ADR-023), so that copy is the profile too — and it is outside the
@@ -223,7 +298,7 @@ export async function removalPlan(
223
298
  items.push({
224
299
  target: name,
225
300
  kind: 'config',
226
- id: `profiles/${profile}.yaml`,
301
+ id: layout.profileConfig(profile),
227
302
  note: 'the copy a deployed revision reads',
228
303
  });
229
304
  }
@@ -253,6 +328,16 @@ export async function removalPlan(
253
328
  });
254
329
  }
255
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
+
256
341
  // Last. It is the only record of where everything else lives, so a failure
257
342
  // before this point leaves data a later run can still find.
258
343
  items.push({ target: null, kind: 'config', id: profilePath(root, profile) });
@@ -290,7 +375,9 @@ export function renderPlan(plan: RemovalPlan): void {
290
375
  print(` ${style.bold(target ?? 'workspace')}`);
291
376
  for (const item of items) {
292
377
  const note = item.note ? style.dim(` — ${item.note}`) : '';
293
- 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}`);
294
381
  }
295
382
  print();
296
383
  }
@@ -24,6 +24,7 @@ import {
24
24
  } from '../../runtime.ts';
25
25
  import { loadWorkspaceProfiles } from '#profile';
26
26
  import { removalPlan, renderPlan, type RemovalItem, type RemovalPlan } from './removal.ts';
27
+ import { settleDisposition, type Disposition } from './disposition.ts';
27
28
 
28
29
  /**
29
30
  * Performing a removal, and being honest about the parts that did not happen.
@@ -62,9 +63,21 @@ export interface RunDeps {
62
63
 
63
64
  const reason = (cause: unknown): string => (cause instanceof Error ? cause.message : String(cause));
64
65
 
65
- /** 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
+ */
66
78
  const isRecordOfWhereThingsAre = (item: RemovalItem): boolean =>
67
- 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'));
68
81
 
69
82
  export async function executeRemoval(
70
83
  plan: RemovalPlan,
@@ -107,9 +120,30 @@ export async function executeRemoval(
107
120
  await (await secretStore(item.target!)).delete(item.id);
108
121
  break;
109
122
 
110
- case 'blob':
111
- 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);
112
145
  break;
146
+ }
113
147
 
114
148
  case 'config':
115
149
  if (item.target === null) await deps.removeConfig(item.id);
@@ -241,10 +275,15 @@ export interface RemoveFlags extends GlobalFlags {
241
275
  readonly dryRun?: boolean | undefined;
242
276
  readonly yes?: boolean | undefined;
243
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;
244
282
  /** Injected by a caller that has already asked — the console, and tests. */
245
283
  readonly prompter?: Prompter | undefined;
246
284
  }
247
285
 
286
+
248
287
  /**
249
288
  * `lanes link profile remove <name>` — the profile, and everything it owns.
250
289
  *
@@ -261,10 +300,32 @@ export async function removeProfile(name: string, flags: RemoveFlags): Promise<v
261
300
  const files = workspaceFiles(root);
262
301
  const { declared } = await openTarget(root, target);
263
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
+
264
324
  const plan = await removalPlan(config, root, name, registry, {
265
325
  target,
266
326
  declared,
267
- openSecrets: (target) => openSecretStoreFor(config, root, target),
327
+ disposition,
328
+ openSecrets: (target) => openSecretStoreFor(root, target),
268
329
  openBlobs: (target, area) => openBlobStoreFor(config, root, target, area),
269
330
  readDefaultProfile: async () => (await readWorkspace(root))?.default_profile,
270
331
  // What the workspace keeps. The credential store is one file for all of
@@ -285,7 +346,7 @@ export async function removeProfile(name: string, flags: RemoveFlags): Promise<v
285
346
  if (!(await confirmedByName(name, { yes: flags.yes, prompter: flags.prompter }))) return;
286
347
 
287
348
  const outcome = await executeRemoval(plan, {
288
- openSecrets: (target) => openSecretStoreFor(config, root, target),
349
+ openSecrets: (target) => openSecretStoreFor(root, target),
289
350
  openBlobs: (target, area) => openBlobStoreFor(config, root, target, area),
290
351
  removeConfig: async (path) => await files.delete(relativeToRoot(root, path)),
291
352
  removeDirectory: async (path) => await rm(workspacePath(root, path), { recursive: true, force: true }),
@@ -6,6 +6,8 @@ import {
6
6
  CONNECTIONS_FILE,
7
7
  ConfigError,
8
8
  WORKSPACE_FILE,
9
+ LEGACY_WORKSPACE_FILE,
10
+ legacyProfileConfig,
9
11
  listProfiles,
10
12
  profilePath,
11
13
  readWorkspace,
@@ -14,6 +16,7 @@ import {
14
16
  writeWorkspaceFile,
15
17
  resolveTargetWorkspace,
16
18
  resolveWorkspaceRoot,
19
+ layout,
17
20
  } from '#profile';
18
21
 
19
22
  import { recordConfigChange } from '../audit-change.ts';
@@ -77,9 +80,26 @@ export async function createProfile(
77
80
  // <name> --workspace local` on an empty directory is how a workspace comes into
78
81
  // existence, and the target it names is declared *by* that file — so writing
79
82
  // it second means resolving a target nothing has declared yet.
80
- if (!isRemoteWorkspace(local) && !existsSync(join(local, WORKSPACE_FILE))) {
81
- await mkdir(local, { recursive: true });
82
- 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
+ }
83
103
  }
84
104
 
85
105
  const root = await resolveTargetWorkspace(local, target);
@@ -93,8 +113,15 @@ export async function createProfile(
93
113
  await writeWorkspaceFile(workspaceFiles(root), CONNECTIONS_FILE, newConnectionsTemplate());
94
114
  }
95
115
 
96
- if (await workspaceFiles(root).has(`profiles/${name}.yaml`)) {
97
- throw new Error(`Profile "${name}" already exists at ${path}`);
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
+ }
98
125
  }
99
126
 
100
127
  // Only a directory needs making. A bucket has no directories, and the write
@@ -127,7 +154,7 @@ export async function createProfile(
127
154
 
128
155
  await writeWorkspaceFile(
129
156
  workspaceFiles(root),
130
- `profiles/${name}.yaml`,
157
+ layout.profileConfig(name),
131
158
  newProfileTemplate(name, port, session?.subject),
132
159
  );
133
160