@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
@@ -1,11 +1,17 @@
1
1
  import { newConnectionsTemplate } from './config-templates.ts';
2
- import { applyMoves, mergeCredentials, planCredentials, planMoves, type Move } from './contract3-data.ts';
2
+ import { planMoves } from './contract3-data.ts';
3
+ import { applyMoves } from './migrate-move.ts';
4
+ import {
5
+ mergeCredentials,
6
+ planCredentials,
7
+ connectionRefs,
8
+ type CredentialPlan,
9
+ } from './contract3-credentials.ts';
3
10
  import { parseDocument } from 'yaml';
4
11
  import {
5
12
  CONNECTIONS_FILE,
6
13
  ConfigError,
7
- DATA_DIR,
8
- WORKSPACE_FILE,
14
+ isRemoteWorkspace,
9
15
  layout,
10
16
  listProfiles,
11
17
  readWorkspaceFile,
@@ -15,7 +21,7 @@ import {
15
21
  import { RESERVED_PROVIDER_IDS } from '#connectivity';
16
22
  import { ConfigDocument } from './config-edit.ts';
17
23
  import { grantsFor, hoistConnections } from './contract3-shape.ts';
18
-
24
+ import { C3 } from './contract3-layout.ts';
19
25
  /**
20
26
  * Contract 2 to contract 3: connections move out of the profile.
21
27
  *
@@ -37,8 +43,8 @@ import { grantsFor, hoistConnections } from './contract3-shape.ts';
37
43
  * the old flat policy, which is exactly what contract 2 meant.
38
44
  * 4. Bytes move to their connection-keyed homes.
39
45
  *
40
- * The registry rename rides along at the end, because it is the one step that
41
- * cannot half-apply: it is a single document.
46
+ * The registry rename rides along at the end: it is one document and cannot
47
+ * half-apply.
42
48
  */
43
49
 
44
50
  /** What a divergent id was renamed to, and why. */
@@ -73,8 +79,18 @@ export interface LegacyProfile {
73
79
  readonly connections?: LegacyConnection[];
74
80
  readonly policy?: { allow?: unknown[]; deny?: unknown[] };
75
81
  readonly oauth_apps?: Record<string, unknown>;
82
+ /**
83
+ * Read for `token_ref` alone, and read raw rather than through `authSchema`:
84
+ * this walks profiles that have not been validated, and a profile that fails
85
+ * validation for an unrelated reason still has an endpoint token to leave
86
+ * behind.
87
+ */
88
+ readonly auth?: { token_ref?: string };
76
89
  }
77
90
 
91
+ /** The schema default, and what every profile written by the CLI carries. */
92
+ const DEFAULT_TOKEN_REF = 'profile/token';
93
+
78
94
  /** Whether this workspace still holds anything at contract 2. */
79
95
  export async function needsContract3(workspaceRoot: string): Promise<boolean> {
80
96
  for (const profile of await listProfiles(workspaceRoot)) {
@@ -85,7 +101,7 @@ export async function needsContract3(workspaceRoot: string): Promise<boolean> {
85
101
  }
86
102
 
87
103
  async function readProfile(root: string, profile: string): Promise<LegacyProfile | null> {
88
- const text = await readWorkspaceFile(workspaceFiles(root), `profiles/${profile}.yaml`);
104
+ const text = await readWorkspaceFile(workspaceFiles(root), C3.profile(profile));
89
105
  if (text === null) return null;
90
106
  try {
91
107
  return parseDocument(text).toJSON() as LegacyProfile;
@@ -129,7 +145,14 @@ export async function migrateToContract3(
129
145
 
130
146
  // Everything that can be computed is computed before the first write, so a
131
147
  // refusal leaves the workspace exactly as it was.
132
- const credentials = await planCredentials(workspaceRoot, [...legacy.keys()]);
148
+ const plans: CredentialPlan[] = [...legacy].map(([profile, config]) => ({
149
+ profile,
150
+ renames: connectionRefs(perProfile.get(profile) ?? new Map()),
151
+ tokenRef:
152
+ typeof config.auth?.token_ref === 'string' ? config.auth.token_ref : DEFAULT_TOKEN_REF,
153
+ }));
154
+
155
+ const credentials = await planCredentials(workspaceRoot, plans);
133
156
  const moves = await planMoves(files, [...legacy.keys()], perProfile);
134
157
 
135
158
  const changes: string[] = [
@@ -137,8 +160,14 @@ export async function migrateToContract3(
137
160
  ...renames.map((rename) => `renamed ${rename.from} to ${rename.to} (${rename.reason})`),
138
161
  ...[...legacy.keys()].map((profile) => `profiles/${profile}.yaml: contract 3, grants`),
139
162
  ];
140
- if (credentials.length > 0) {
141
- changes.push(`${layout.credentials()}: ${credentials.length} credential(s) merged`);
163
+ if (credentials.refs.length > 0) {
164
+ changes.push(`${C3.credentials()}: ${credentials.refs.length} credential(s) merged`);
165
+ }
166
+ if (credentials.tokens.length > 0) {
167
+ changes.push(
168
+ `${credentials.tokens.join(', ')}: left behind — one endpoint token per workspace now, ` +
169
+ 'and a fresh one is minted on the next command',
170
+ );
142
171
  }
143
172
  if (moves.length > 0) changes.push(`${moves.length} object(s) moved to their connection`);
144
173
 
@@ -147,7 +176,7 @@ export async function migrateToContract3(
147
176
  profiles: [...legacy.keys()],
148
177
  connections: rows.map(keyOf),
149
178
  renames,
150
- credentials,
179
+ credentials: credentials.refs,
151
180
  moved: moves.map((move) => move.to),
152
181
  changes,
153
182
  alreadyCurrent: false,
@@ -170,7 +199,7 @@ export async function migrateToContract3(
170
199
  // workspace" and there was no way back.
171
200
  await rewriteRegistry(workspaceRoot);
172
201
  await writeConnections(workspaceRoot, rows, legacy);
173
- await mergeCredentials(workspaceRoot, [...legacy.keys()]);
202
+ await mergeCredentials(workspaceRoot, plans);
174
203
 
175
204
  // **The bytes move before the profile says they have.**
176
205
  //
@@ -192,7 +221,25 @@ export async function migrateToContract3(
192
221
  return result;
193
222
  }
194
223
 
195
- /** The hoisted rows, plus every profile's `oauth_apps` merged into one block. */
224
+ /**
225
+ * The hoisted rows, plus every profile's `oauth_apps` merged into one block.
226
+ *
227
+ * **Anything already in the file survives**, which is the rule `writeRegistry`
228
+ * states for the same reason: a workspace part way through this has entries that
229
+ * are already right, and re-deriving them from what is left would undo a
230
+ * correction.
231
+ *
232
+ * Not a tidiness argument here. `rewriteProfiles` stamps profiles one at a time
233
+ * and `legacy` holds only those still at contract 2, so an interruption part way
234
+ * through it left a rerun hoisting a *subset* — and this overwrote the file with
235
+ * it, deleting the already-migrated profiles' rows. Their grants named those
236
+ * connections still. In the shape that actually bites, two profiles held
237
+ * `gmail.main` for different mailboxes and were hoisted to `gmail.main` and
238
+ * `gmail.main_2`; the rerun rebuilt the file from the second profile alone, so
239
+ * `gmail.main` came back naming the *other* person's mailbox and the first
240
+ * profile's surviving grant pointed at it. Nothing errored, and
241
+ * `loadProfileConfig` returned ok.
242
+ */
196
243
  async function writeConnections(
197
244
  root: string,
198
245
  rows: readonly LegacyConnection[],
@@ -201,9 +248,25 @@ async function writeConnections(
201
248
  const apps: Record<string, unknown> = {};
202
249
  for (const config of legacy.values()) Object.assign(apps, config.oauth_apps ?? {});
203
250
 
251
+ const held = await readWorkspaceFile(workspaceFiles(root), CONNECTIONS_FILE);
252
+ const current = held === null ? null : ConfigDocument.fromText(held, CONNECTIONS_FILE);
253
+ const previous = ((current?.toJSON() as { connections?: unknown } | null)?.connections ??
254
+ []) as LegacyConnection[];
255
+
256
+ const merged = new Map<string, LegacyConnection>();
257
+ for (const row of rows) merged.set(keyOf(row), row);
258
+ // Second, so a row this run re-derived does not displace the one already
259
+ // written for it.
260
+ for (const row of previous) {
261
+ if (typeof row?.provider === 'string' && typeof row?.id === 'string') merged.set(keyOf(row), row);
262
+ }
263
+
204
264
  const document = ConfigDocument.fromText(newConnectionsTemplate(), CONNECTIONS_FILE);
205
- document.setIn(['connections'], rows);
206
- document.setIn(['oauth_apps'], apps);
265
+ document.setIn(['connections'], [...merged.values()]);
266
+ document.setIn(['oauth_apps'], {
267
+ ...((current?.toJSON() as { oauth_apps?: Record<string, unknown> } | null)?.oauth_apps ?? {}),
268
+ ...apps,
269
+ });
207
270
 
208
271
  await writeWorkspaceFile(workspaceFiles(root), CONNECTIONS_FILE, document.toString());
209
272
  }
@@ -216,7 +279,9 @@ async function rewriteProfiles(
216
279
  subject: string | undefined,
217
280
  ): Promise<void> {
218
281
  for (const [profile, config] of legacy) {
219
- const document = await ConfigDocument.open(root, profile);
282
+ // `openKey` at the contract-3 path, not `open`, which resolves the live
283
+ // layout: this produces contract 3, and contract 4 moves what it produced.
284
+ const document = await ConfigDocument.openKey(root, C3.profile(profile));
220
285
 
221
286
  document.setIn(['contract'], 3);
222
287
  document.setIn(['grants'], grantsFor(config, perProfile.get(profile) ?? new Map()));
@@ -237,16 +302,17 @@ async function rewriteProfiles(
237
302
  document.removeIn(['policy']);
238
303
  document.removeIn(['oauth_apps']);
239
304
 
240
- await document.save();
305
+ await document.save({ contract: 3 });
241
306
  }
242
307
  }
243
308
 
244
309
  /** `targets:` becomes `workspaces:`, and a pointer's `workspace:` becomes `at:`. */
245
310
  async function rewriteRegistry(root: string): Promise<void> {
246
- const document = await ConfigDocument.openKey(root, WORKSPACE_FILE);
311
+ const document = await ConfigDocument.openKey(root, C3.workspace);
247
312
  const registry = document.toJSON() as {
248
313
  contract?: number;
249
314
  targets?: Record<string, { workspace?: string }>;
315
+ workspaces?: Record<string, unknown>;
250
316
  } | null;
251
317
 
252
318
  const targets = registry?.targets;
@@ -262,7 +328,33 @@ async function rewriteRegistry(root: string): Promise<void> {
262
328
  workspaces[name] = workspace === undefined ? rest : { at: workspace, ...rest };
263
329
  }
264
330
 
265
- document.setIn(['workspaces'], workspaces);
331
+ // **Anything already under `workspaces:` wins over what `targets:` says**,
332
+ // which is the same rule the 1-to-2 migration states for the same reason: a
333
+ // workspace part way through this has entries that are already right, and
334
+ // re-deriving them from a stale block would undo a correction.
335
+ //
336
+ // It is not hypothetical here. `editRegistry` used to write `workspaces:`
337
+ // into a contract-2 file without touching its `targets:`, so a deploy from
338
+ // an unmigrated laptop left two registries disagreeing — and this
339
+ // overwrote the newer one with the older, silently reverting a recorded
340
+ // deployment to whatever the last contract-2 command had written. That
341
+ // write is fixed at source, and this is what repairs a file already
342
+ // carrying both.
343
+ const merged: Record<string, unknown> = { ...workspaces };
344
+ for (const [name, entry] of Object.entries(registry?.workspaces ?? {})) {
345
+ const derived = merged[name];
346
+ // Per field, not per entry. The newer block is what `editRegistry` wrote
347
+ // when it could not see `targets:`, and `sync` writes only `{ at }` — so
348
+ // replacing the entry wholesale discarded `primary` (which schema.ts calls
349
+ // the one question about a deployment that must not be guessed at),
350
+ // `last_deploy`, and the whole `deploy:` block carrying project and
351
+ // region. Preserving the record was the entire point of the merge.
352
+ merged[name] =
353
+ derived !== null && typeof derived === 'object' && entry !== null && typeof entry === 'object'
354
+ ? { ...(derived as Record<string, unknown>), ...(entry as Record<string, unknown>) }
355
+ : entry;
356
+ }
357
+ document.setIn(['workspaces'], merged);
266
358
  document.removeIn(['targets']);
267
359
  }
268
360
 
@@ -270,12 +362,37 @@ async function rewriteRegistry(root: string): Promise<void> {
270
362
  const workspaces = (document.toJSON() as { workspaces?: Record<string, unknown> } | null)
271
363
  ?.workspaces ?? {};
272
364
 
273
- // The first workspace in the registry, which for every workspace this
274
- // migration will ever see is `local`. Written rather than left absent so the
275
- // sticky default is on from the first command after upgrading (ADR-061).
365
+ // The workspace on this machine, and only the *first* one when there is no
366
+ // such thing. Written rather than left absent so the sticky default is on
367
+ // from the first command after upgrading (ADR-061).
368
+ //
369
+ // This used to take the first key outright, on the stated grounds that "for
370
+ // every workspace this migration will ever see" that is `local`. It is not:
371
+ // the registry is written sorted, so a workspace that had ever deployed came
372
+ // out of the 1-to-2 migration with `cloud` ahead of `local`. Upgrading then
373
+ // pointed every subsequent command at a bucket — which is the one kind of
374
+ // workspace that can be unreachable, and was: the next `status` answered with
375
+ // a 403 from GCS rather than with the profiles sitting on the disk.
376
+ //
377
+ // A pointer carries `at:`; a workspace declaring its own adapters does not.
276
378
  if (document.getIn(['default_workspace']) === undefined) {
277
- const first = Object.keys(workspaces)[0];
278
- if (first !== undefined) document.setIn(['default_workspace'], first);
379
+ const names = Object.keys(workspaces);
380
+ const here = names.find((name) => {
381
+ const entry = workspaces[name] as
382
+ | { at?: unknown; storage?: { adapter?: unknown } }
383
+ | undefined;
384
+ // A pointer is here when it points at a path rather than a bucket:
385
+ // `resolveTargetWorkspace` follows a local one just as happily.
386
+ if (typeof entry?.at === 'string') return !isRemoteWorkspace(entry.at);
387
+ // Otherwise it declares its own adapters, and only a filesystem one is on
388
+ // this machine. Reading "no `at:`" as "local" missed that a cloud target
389
+ // surveyed by `bootstrap` but never rolled out is a *declaration* — so a
390
+ // deploy that failed at build or IAM left the same 403 default this was
391
+ // written to prevent.
392
+ return entry?.storage?.adapter === 'filesystem';
393
+ });
394
+ const chosen = here ?? names[0];
395
+ if (chosen !== undefined) document.setIn(['default_workspace'], chosen);
279
396
  }
280
397
 
281
398
  await document.save();
@@ -0,0 +1,207 @@
1
+ import { ConfigError } from '#profile';
2
+ import type { SecretStore } from '#secrets';
3
+ import { credentialRefFor } from '#registry';
4
+ import type { ProviderRegistry } from '#registry';
5
+ import type { ConnectionConfig } from '#profile';
6
+ import { rotatableCredentialRefs } from '#connectivity';
7
+ import type { Renames } from './contract4-data.ts';
8
+
9
+ /**
10
+ * Move each stored credential to the ref its renamed connection now derives.
11
+ *
12
+ * **The step without which the rename loses every account.** A `credential_ref`
13
+ * is almost never written down: `credentialRefFor` derives it as
14
+ * `<provider>/<connection>`, so renaming `gmail.main_2` to `gmail.con17`
15
+ * silently repoints the lookup at a key nothing holds. Config, blobs and state
16
+ * all move; the secret stays where it was, and the endpoint reports "no stored
17
+ * credential" for an account that is still perfectly well authorised.
18
+ *
19
+ * Found by rehearsing the migration against a real workspace, where it turned
20
+ * six live credentials into six re-authorisations. ADR-051 records why that is
21
+ * not an acceptable release note.
22
+ *
23
+ * Refs come from the same authority the runtime resolves them with, rather than
24
+ * from a pattern over the store's keys. A ref is not always
25
+ * `<provider>/<id>` — an `oauth_apps` entry puts the app's name in front
26
+ * instead — and matching on the last segment cannot tell `google/main` for a
27
+ * renamed Gmail connection from `google/main` for a renamed Drive one.
28
+ */
29
+ export interface CredentialMove {
30
+ readonly from: string;
31
+ readonly to: string;
32
+ }
33
+
34
+ export function planCredentialMoves(
35
+ connections: readonly ConnectionConfig[],
36
+ registry: ProviderRegistry,
37
+ renames: Renames,
38
+ ): CredentialMove[] {
39
+ const moves: CredentialMove[] = [];
40
+ const seen = new Set<string>();
41
+
42
+ for (const connection of connections) {
43
+ const to = renames.get(`${connection.provider}.${connection.id}`);
44
+ if (to === undefined) continue;
45
+
46
+ const id = to.slice(to.indexOf('.') + 1);
47
+ if (id === connection.id) continue;
48
+
49
+ const manifest = registry.manifest(connection.provider);
50
+ const after = { ...connection, id };
51
+
52
+ // A ref the row states outright does not move: it names a key the operator
53
+ // chose, and the id it happens to contain is not this migration's to read.
54
+ const refs: [string | undefined, string | undefined][] = [
55
+ [credentialRefFor(connection, manifest), credentialRefFor(after, manifest)],
56
+ ];
57
+
58
+ // An OAuth connection also holds the token blob `saveTokens` rewrites, and
59
+ // it is the one that matters — the hot path of an ordinary read.
60
+ if (manifest) {
61
+ const before = rotatableCredentialRefs(manifest, connection.id);
62
+ const now = rotatableCredentialRefs(manifest, id);
63
+ for (let index = 0; index < before.length; index += 1) {
64
+ refs.push([before[index], now[index]]);
65
+ }
66
+ }
67
+
68
+ for (const [from, into] of refs) {
69
+ if (from === undefined || into === undefined || from === into) continue;
70
+
71
+ // Keyed on the *pair*, not on the source. Several connections can derive
72
+ // one ref — three iCloud surfaces authorised as one account share
73
+ // `icloud/<id>` through `auth.app` — and giving distinct ids to each
74
+ // turns one credential into three. Deduping on the source sent it to
75
+ // whichever connection came first and orphaned the rest, which is what a
76
+ // real workspace showed on the first rehearsal.
77
+ const pair = `${from}\u0000${into}`;
78
+ if (seen.has(pair)) continue;
79
+ seen.add(pair);
80
+ moves.push({ from, to: into });
81
+ }
82
+ }
83
+
84
+ return moves;
85
+ }
86
+
87
+ /**
88
+ * Apply them, reading each value back before the old key is removed.
89
+ *
90
+ * The same rule the blob mover follows: a credential that half moved and a
91
+ * source already gone is the one state with nothing to retry from. A ref whose
92
+ * destination is already occupied is left alone rather than overwritten —
93
+ * that is a rerun finding its own work, and clobbering it would be the one
94
+ * irreversible thing here.
95
+ */
96
+ export async function applyCredentialMoves(
97
+ store: SecretStore,
98
+ moves: readonly CredentialMove[],
99
+ ): Promise<string[]> {
100
+ const applied: string[] = [];
101
+ const skipped: string[] = [];
102
+
103
+ // Grouped by source, because one credential may feed several destinations
104
+ // and the source may only go once every one of them is written. Deleting
105
+ // after the first would take it away from the connections still to come.
106
+ const bySource = new Map<string, string[]>();
107
+ for (const move of moves) {
108
+ const into = bySource.get(move.from) ?? [];
109
+ into.push(move.to);
110
+ bySource.set(move.from, into);
111
+ }
112
+
113
+ for (const [from, destinations] of bySource) {
114
+ const value = await store.get(from);
115
+ if (value === null) continue;
116
+
117
+ // **The source goes only once every destination holds this value.** The
118
+ // delete used to sit outside this loop while the loop skipped an occupied
119
+ // destination — so a ref already holding something (a stale credential a
120
+ // `disconnect --keep-credential` left behind, or an unrelated account's)
121
+ // meant nothing was written and the source was destroyed anyway, reported
122
+ // as a successful move. `migrate-move.ts` guards the same hazard with
123
+ // `sameBytes`, and this is that guard.
124
+ let carried = true;
125
+
126
+ for (const to of destinations) {
127
+ const held = await store.get(to);
128
+
129
+ if (held !== null) {
130
+ // Our own earlier work, so this destination is done. Anything else is
131
+ // somebody's credential and is not ours to overwrite or to delete the
132
+ // source out from under.
133
+ if (held !== value) {
134
+ carried = false;
135
+ skipped.push(
136
+ `${from} → ${to}: ${to} already holds a different value, so nothing was moved or ` +
137
+ 'deleted. Remove or rename it and run this again.',
138
+ );
139
+ }
140
+ continue;
141
+ }
142
+
143
+ await store.set(to, value);
144
+ if ((await store.get(to)) === null) {
145
+ throw new ConfigError(
146
+ `${to} did not read back after being written. Nothing has been deleted; ` +
147
+ 'fix the credential store and run this again.',
148
+ );
149
+ }
150
+ }
151
+
152
+ if (!carried) continue;
153
+
154
+ await store.delete(from);
155
+ applied.push(`${from} → ${destinations.join(', ')}`);
156
+ }
157
+
158
+ return [...applied, ...skipped];
159
+ }
160
+
161
+ /**
162
+ * The sealed vault document, which `credentialRefFor` cannot see.
163
+ *
164
+ * A vault connection's `auth.kind` is `none`, so `credentialRefForConnection`
165
+ * returns `undefined` for it and the loop above skips it entirely — while
166
+ * `vaultRef` names the document `vault/<profile>/<connection>` and a deployed
167
+ * target seals it in Secret Manager. Contract 4 renames the connection and
168
+ * gives the ref a profile, so without this the revision opens a name nothing
169
+ * created: every vault item reads as absent, with no error, and the ciphertext
170
+ * sits orphaned under the old ref.
171
+ *
172
+ * Invisible in a local rehearsal, because the `file` and `blob` adapters take
173
+ * their path from `layout` and were already correct. `secret` is the adapter
174
+ * every deployment uses.
175
+ *
176
+ * A `ref` the target states outright is left alone: it names a document the
177
+ * operator chose, and a deployment already sealing under it has to keep
178
+ * opening it.
179
+ */
180
+ export function planVaultMoves(
181
+ profiles: ReadonlyMap<string, { grants?: { connection?: unknown }[] }>,
182
+ renames: Renames,
183
+ declaredRef: string | undefined,
184
+ ): CredentialMove[] {
185
+ if (declaredRef !== undefined) return [];
186
+
187
+ const moves: CredentialMove[] = [];
188
+
189
+ for (const [profile, config] of profiles) {
190
+ const granted = (config.grants ?? [])
191
+ .map((grant) => grant.connection)
192
+ .find((ref): ref is string => typeof ref === 'string' && ref.startsWith('vault.'));
193
+
194
+ // `main` where the profile grants none, which is what `vaultRef` falls back
195
+ // to — so a profile that denied the vault still finds its own document
196
+ // rather than another profile's.
197
+ const was = granted === undefined ? 'main' : granted.slice('vault.'.length);
198
+ const to = granted === undefined ? undefined : renames.get(granted);
199
+ const now = to === undefined ? was : to.slice(to.indexOf('.') + 1);
200
+
201
+ const from = `vault/${was}`;
202
+ const into = `vault/${profile}/${now}`;
203
+ if (from !== into) moves.push({ from, to: into });
204
+ }
205
+
206
+ return moves;
207
+ }