@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
@@ -44,8 +44,8 @@ export async function secretsPush(flags: SecretsFlags): Promise<void> {
44
44
  const { selection, config } = await resolveProfileOnly(flags);
45
45
  announceProfile(selection);
46
46
 
47
- const source = await openSecretStoreFor(config, selection.workspaceRoot, flags.from);
48
- const destination = await openSecretStoreFor(config, selection.workspaceRoot, flags.to);
47
+ const source = await openSecretStoreFor(selection.workspaceRoot, flags.from);
48
+ const destination = await openSecretStoreFor(selection.workspaceRoot, flags.to);
49
49
 
50
50
  const refs = await source.list();
51
51
  if (refs.length === 0) {
@@ -143,7 +143,7 @@ export async function secretsSet(ref: string | undefined, flags: GlobalFlags): P
143
143
  );
144
144
  }
145
145
 
146
- const credentials = await openSecretStoreFor(config, resolution.workspaceRoot, target);
146
+ const credentials = await openSecretStoreFor(resolution.workspaceRoot, target);
147
147
  const replacing = await credentials.has(ref);
148
148
  await credentials.set(ref, value);
149
149
 
@@ -165,7 +165,7 @@ export async function secretsList(flags: GlobalFlags): Promise<void> {
165
165
  });
166
166
  announceWorkspace(resolution);
167
167
 
168
- const credentials = await openSecretStoreFor(config, resolution.workspaceRoot, target);
168
+ const credentials = await openSecretStoreFor(resolution.workspaceRoot, target);
169
169
  const refs = await credentials.list();
170
170
 
171
171
  heading(`Credential references in workspace ${target} (${refs.length})`);
@@ -0,0 +1,54 @@
1
+ import type { Contract3Migration } from '../contract3.ts';
2
+ import type { Contract4Migration } from '../contract4.ts';
3
+
4
+ /**
5
+ * What each contract migration did, as `update` reports it.
6
+ *
7
+ * Split from `update.ts` so that file stays inside the size budget, and on the
8
+ * seam it already had: that file decides *whether* to migrate and this says
9
+ * *what happened*. Kept beside it rather than beside the migrations, because
10
+ * `doctor --fix` renders the same facts its own way and the two are allowed to
11
+ * read differently — what they may not do is disagree about which of them is
12
+ * worth mentioning.
13
+ */
14
+
15
+ /**
16
+ * What contract 4 did, and the one part of it that is the operator's.
17
+ *
18
+ * `update` reported nothing, so the shared-store duplication happened in
19
+ * silence — a decision the migration calls the owner's, and which `doctor --fix`
20
+ * prints. Two paths disagreeing on that is how one becomes the wrong one to run.
21
+ */
22
+ export function sayContract4(migration: Contract4Migration, say: (line: string) => void): void {
23
+ say(
24
+ `migrated ${migration.profiles.length} profile(s) to contract 4 — a profile owns its data ` +
25
+ 'again, and Lanes\u2019 own surfaces are lanes_memory, lanes_tasks and the rest',
26
+ );
27
+ for (const change of migration.changes) say(` ${change}`);
28
+
29
+ if (migration.shared.length > 0) {
30
+ say(' A store more than one profile granted was copied into each, and the original kept.');
31
+ say(' Deleting what you do not want is the one step here that is yours.');
32
+ }
33
+
34
+ if (migration.orphaned.length > 0) {
35
+ say(' What no profile grants was left exactly where it is, and named above.');
36
+ }
37
+
38
+ say(' Every registered client caches its tool list, so re-add them: lanes link mcp add');
39
+ }
40
+
41
+ export function sayContract3(migration: Contract3Migration, say: (line: string) => void): void {
42
+ say(
43
+ `migrated ${migration.profiles.length} profile(s) to contract 3 — connections belong to the ` +
44
+ 'workspace now, and a profile grants them one by one',
45
+ );
46
+ for (const change of migration.changes) say(` ${change}`);
47
+
48
+ if (migration.renames.length > 0) {
49
+ say(' Two profiles named different accounts with the same id, so one was renamed.');
50
+ say(' Check the grants in each profile before running an agent against them.');
51
+ }
52
+
53
+ say(` The old per-profile credential stores are left in place; remove them once this works.`);
54
+ }
@@ -1,9 +1,11 @@
1
1
  import { homedir } from 'node:os';
2
2
  import { join, sep } from 'node:path';
3
3
  import { installRoot, resolveWorkspaceRoot } from '#profile';
4
- import { repairOwnerLayer } from '../config-repair.ts';
5
- import { migrateToCurrentContract, type ContractMigration } from '../workspace-migrate.ts';
6
- import type { Contract3Migration } from '../contract3.ts';
4
+ import { repairOwnerLayer } from '../config-repair-sweep.ts';
5
+ import { migrateToCurrentContract, needsMigration, type ContractMigration } from '../workspace-migrate.ts';
6
+ import { needsContract3, type Contract3Migration } from '../contract3.ts';
7
+ import { needsContract4 } from '../contract4.ts';
8
+ import { sayContract3, sayContract4 } from './update-migration.ts';
7
9
  import { emit, fail, ok, print, printErr, progress, style, warn } from '../output.ts';
8
10
  import { PACKAGE, release, type ReleaseState } from '../release.ts';
9
11
  import { version } from '../version.ts';
@@ -184,11 +186,17 @@ export async function update(flags: UpdateFlags): Promise<void> {
184
186
  // a contract it does not implement until someone redeploys. `deploy` is what
185
187
  // migrates a bucket, because it is the command that ships the image in the
186
188
  // same breath.
187
- await migrateLocal(root, flags.json === true ? progress : print);
188
-
189
- await repairOwnerLayer(root, undefined, {
190
- ...(flags.json === true ? { report: progress } : {}),
191
- });
189
+ const migrated = await migrateLocal(root, flags.json === true ? progress : print);
190
+
191
+ // Only on a workspace that is actually at the current contract — see
192
+ // `migrateLocal`. The repair writes contract-3 shapes, and writing them
193
+ // after a refusal that said nothing had been written is what left a stray
194
+ // `connections.yaml` in a contract-2 workspace.
195
+ if (migrated) {
196
+ await repairOwnerLayer(root, undefined, {
197
+ ...(flags.json === true ? { report: progress } : {}),
198
+ });
199
+ }
192
200
  }
193
201
 
194
202
  const report = {
@@ -315,22 +323,8 @@ async function runInstall(argv: readonly string[], json: boolean): Promise<boole
315
323
  * fifteen accounts were merged into one store should see that happen rather than
316
324
  * discover it from a directory listing.
317
325
  */
318
- function sayContract3(migration: Contract3Migration, say: (line: string) => void): void {
319
- say(
320
- `migrated ${migration.profiles.length} profile(s) to contract 3 — connections belong to the ` +
321
- 'workspace now, and a profile grants them one by one',
322
- );
323
- for (const change of migration.changes) say(` ${change}`);
324
-
325
- if (migration.renames.length > 0) {
326
- say(' Two profiles named different accounts with the same id, so one was renamed.');
327
- say(' Check the grants in each profile before running an agent against them.');
328
- }
329
-
330
- say(` The old per-profile credential stores are left in place; remove them once this works.`);
331
- }
332
326
 
333
- async function migrateLocal(root: string, say: (line: string) => void): Promise<void> {
327
+ async function migrateLocal(root: string, say: (line: string) => void): Promise<boolean> {
334
328
  try {
335
329
  // One call, both steps, in order — a workspace that predates both walks
336
330
  // through them rather than jumping, so each step's reasoning applies to the
@@ -347,11 +341,30 @@ async function migrateLocal(root: string, say: (line: string) => void): Promise<
347
341
 
348
342
  if (migration.legacy) sayLegacyTargets(migration.legacy, say);
349
343
  if (migration.contract3) sayContract3(migration.contract3, say);
344
+ if (migration.contract4) sayContract4(migration.contract4, say);
345
+ return true;
350
346
  } catch (error) {
351
347
  say(`could not migrate this workspace: ${error instanceof Error ? error.message : String(error)}`);
348
+
349
+ // **Still behind, not "something threw".** Keying the repair on the throw
350
+ // suppressed it for the whole workspace when one profile failed to
351
+ // migrate — so the others, already current, never received a newly shipped
352
+ // surface, which is the case `update` runs the repair for at all. One
353
+ // unreadable profile is a per-profile warning, which the repair already
354
+ // gives it.
355
+ const behind = await stillBehind(root);
356
+ if (behind) say(' the owner-layer repair is skipped until it does');
357
+ return !behind;
352
358
  }
353
359
  }
354
360
 
361
+ /** Anything still below the current contract. Unreadable counts as behind. */
362
+ function stillBehind(root: string): Promise<boolean> {
363
+ return needsMigration(root)
364
+ .then(async (one) => one || (await needsContract3(root)) || (await needsContract4(root)))
365
+ .catch(() => true);
366
+ }
367
+
355
368
  /** Contract 1 to contract 2: targets move from the profile to the workspace. */
356
369
  function sayLegacyTargets(
357
370
  migration: NonNullable<ContractMigration['legacy']>,
@@ -1,9 +1,10 @@
1
1
  import { rename, writeFile } from 'node:fs/promises';
2
- import { Document, parseDocument, type Node } from 'yaml';
2
+ import { Document, parseDocument, YAMLSeq, type Node } from 'yaml';
3
3
  import {
4
4
  CONNECTIONS_FILE,
5
5
  ConfigError,
6
6
  WORKSPACE_FILE,
7
+ LEGACY_WORKSPACE_FILE,
7
8
  workspaceSchema,
8
9
  assertConnectionsUnique,
9
10
  connectionsFileSchema,
@@ -15,6 +16,7 @@ import {
15
16
  validateConfigShape,
16
17
  workspaceFiles,
17
18
  writeWorkspaceFile,
19
+ layout,
18
20
  } from '#profile';
19
21
 
20
22
  /**
@@ -42,9 +44,13 @@ function validateDocument(
42
44
  raw: unknown,
43
45
  path: string,
44
46
  key: string | undefined,
45
- options: { shapeOnly?: boolean },
47
+ options: { shapeOnly?: boolean; contract?: number },
46
48
  ): void {
47
- if (key === WORKSPACE_FILE) {
49
+ // Either name. The contract-3 migration rewrites the registry under the name
50
+ // it still has, and a document checked against the wrong schema fails with
51
+ // "instance: expected object" — which reads as a corrupt profile rather than
52
+ // as a registry being validated as one.
53
+ if (key === WORKSPACE_FILE || key === LEGACY_WORKSPACE_FILE) {
48
54
  const parsed = workspaceSchema.safeParse(raw);
49
55
  if (!parsed.success) {
50
56
  throw new ConfigError(
@@ -74,8 +80,10 @@ function validateDocument(
74
80
  return;
75
81
  }
76
82
 
77
- if (options.shapeOnly === true) validateConfigShape(raw, path);
78
- else validateConfig(raw, path);
83
+ // `contract` is the one a migration is *producing*, and it is only ever
84
+ // passed by one: every other caller writes the newest.
85
+ if (options.shapeOnly === true) validateConfigShape(raw, path, options.contract);
86
+ else validateConfig(raw, path, options.contract);
79
87
  }
80
88
 
81
89
  export class ConfigDocument {
@@ -101,7 +109,7 @@ export class ConfigDocument {
101
109
  * `gs://` URL produces something that addresses nothing.
102
110
  */
103
111
  static async open(workspaceRoot: string, profile: string): Promise<ConfigDocument> {
104
- return ConfigDocument.openKey(workspaceRoot, `profiles/${profile}.yaml`);
112
+ return ConfigDocument.openKey(workspaceRoot, layout.profileConfig(profile));
105
113
  }
106
114
 
107
115
  /**
@@ -199,7 +207,20 @@ export class ConfigDocument {
199
207
  // difference decided whether a command explained itself or crashed.
200
208
  const existing = this.#document.getIn(path as (string | number)[]);
201
209
  if (existing === undefined || existing === null) {
202
- this.#document.setIn(path as (string | number)[], [node]);
210
+ // A `YAMLSeq` rather than the plain `[node]` this used to set. The array
211
+ // is not a collection the document API will traverse — the same hazard
212
+ // `setIn` above documents — so the *first* append landed and the second
213
+ // found a value with no `.add` and threw `existing.add is not a
214
+ // function`. `#expand` below reads `.items` and was silently a no-op for
215
+ // the same reason, leaving the sequence in flow style.
216
+ //
217
+ // Latent until contract 3: every path this was called with
218
+ // (`connections`, `policy.allow`) already existed, so the branch ran at
219
+ // most once per document. `grants:` is genuinely absent on a profile
220
+ // being repaired, which is what made the second append reachable.
221
+ const created = new YAMLSeq();
222
+ created.add(node);
223
+ this.#document.setIn(path as (string | number)[], created);
203
224
  } else {
204
225
  (existing as { add(item: unknown): void }).add(node);
205
226
  }
@@ -262,7 +283,7 @@ export class ConfigDocument {
262
283
  * The half it keeps is the half that matters for a write: the schema, and the
263
284
  * scan that stops a credential value being written into config.
264
285
  */
265
- async save(options: { shapeOnly?: boolean } = {}): Promise<void> {
286
+ async save(options: { shapeOnly?: boolean; contract?: number } = {}): Promise<void> {
266
287
  const rendered = this.toString();
267
288
 
268
289
  // Throws on any validation failure, including a credential value that has
@@ -0,0 +1,183 @@
1
+ import {
2
+ CONNECTIONS_FILE,
3
+ listProfiles,
4
+ SUPPORTED_CONTRACT,
5
+ WORKSPACE_FILE,
6
+ workspaceFiles,
7
+ writeWorkspaceFile,
8
+ } from '#profile';
9
+ import { newConnectionsTemplate } from './config-templates.ts';
10
+ import { ConfigDocument } from './config-edit.ts';
11
+ import { ok, print, style, warn } from './output.ts';
12
+ import { DEFAULT_SURFACES, ensureOwnerLayer, repairLines, repaired } from './config-repair.ts';
13
+
14
+ /**
15
+ * Applying the owner-layer repair across a whole workspace.
16
+ *
17
+ * Split from `config-repair.ts` when that file outgrew the budget, on the seam
18
+ * it already had: that file decides what one profile is missing, and this one
19
+ * walks the workspace applying it. The decision is a pure function of two
20
+ * documents and is tested as one; the sweep is all filesystem, ordering and
21
+ * what to say when a profile will not open.
22
+ */
23
+
24
+ /**
25
+ * Stamp the registry with the contract the workspace is actually in.
26
+ *
27
+ * `renameRegistry` copies `lanes-link.yaml` to `workspaces.yaml` byte for byte,
28
+ * which is what makes an interruption survivable — but it carried the old
29
+ * `contract:` across with everything else, so a migrated workspace sat at 3
30
+ * while every profile in it said 4, and a workspace `profile add` created said
31
+ * 4 from the start. Two workspaces at the same contract disagreeing about which
32
+ * one they are in.
33
+ *
34
+ * Cosmetic in most commands, which read the profiles. Not in `isUnmigrated`
35
+ * (`src/profile/registry.ts`), the one place a registry's own contract is read:
36
+ * it compares against `SUPPORTED_CONTRACT` to tell a pointer at an out-of-date
37
+ * bucket from a pointer at the wrong target, so a stale stamp there answers a
38
+ * question about contract 4 with a refusal naming *contract 1* and sends the
39
+ * operator to a `deploy` that changes nothing.
40
+ *
41
+ * Called from the migration, so its output needs no repair, and from the sweep,
42
+ * for the workspaces 0.9.0 already migrated. One spelling, for the reason the
43
+ * template and `ensureOwnerLayer` share one: two would have to agree forever.
44
+ */
45
+ export async function ensureRegistryContract(workspaceRoot: string): Promise<boolean> {
46
+ const files = workspaceFiles(workspaceRoot);
47
+ if (!(await files.has(WORKSPACE_FILE))) return false;
48
+
49
+ const document = await ConfigDocument.openKey(workspaceRoot, WORKSPACE_FILE);
50
+ if (document.getIn(['contract']) === SUPPORTED_CONTRACT) return false;
51
+
52
+ document.setIn(['contract'], SUPPORTED_CONTRACT);
53
+ await document.save();
54
+ return true;
55
+ }
56
+
57
+ /**
58
+ * The first line of an error that actually says something.
59
+ *
60
+ * `message.split('\n')[0]` was the whole of this, and a `ConfigError` from a
61
+ * schema failure is `<path>:\n <field>: <reason>` — so the warning rendered as
62
+ * "could not give personal its owner layer: /…/personal.yaml:" and named no
63
+ * reason at all. Seen for real on an upgrade, twice, with nothing after the
64
+ * colon.
65
+ */
66
+ function reasonOf(error: unknown): string {
67
+ if (!(error instanceof Error)) return String(error);
68
+
69
+ const lines = error.message.split('\n').map((line) => line.trim());
70
+ const said = lines.find((line) => line !== '' && !line.endsWith(':'));
71
+ return said ?? lines.find((line) => line !== '') ?? error.message;
72
+ }
73
+
74
+ /** `memory, tasks, assets, skills, vault, setup and entities`, in repair order. */
75
+ function listSurfaces(): string {
76
+ const names = [...DEFAULT_SURFACES];
77
+ const last = names.pop();
78
+ return names.length === 0 ? String(last) : `${names.join(', ')} and ${last}`;
79
+ }
80
+
81
+ /** The workspace's connections document, written from the template if missing. */
82
+ async function openOrCreateConnections(workspaceRoot: string): Promise<ConfigDocument> {
83
+ try {
84
+ return await ConfigDocument.openKey(workspaceRoot, CONNECTIONS_FILE);
85
+ } catch {
86
+ await writeWorkspaceFile(
87
+ workspaceFiles(workspaceRoot),
88
+ CONNECTIONS_FILE,
89
+ newConnectionsTemplate(),
90
+ );
91
+ return ConfigDocument.openKey(workspaceRoot, CONNECTIONS_FILE);
92
+ }
93
+ }
94
+
95
+ /**
96
+ * Apply that repair across a workspace, saving and reporting what changed.
97
+ *
98
+ * Here rather than in `#deployments`, where it was, because `start` needs it as
99
+ * much as `deploy` does — more, in fact: `start` is the one command an existing
100
+ * install runs without being asked to, so it is the path by which a profile
101
+ * written before ADR-050 gets the layer at all. Two copies of a function that
102
+ * widens a policy is not a thing to have.
103
+ *
104
+ * **The caller scopes it**, and for `deploy` that is exactly the set being
105
+ * uploaded: a profile it sends is a profile the endpoint will serve, so
106
+ * repairing a narrower set would leave a served profile without the surfaces.
107
+ * Note what a `--profile` flag does not mean — it is the flag alone, so a
108
+ * profile resolved from the environment leaves it undefined and that reads as
109
+ * the whole workspace.
110
+ *
111
+ * *Which files are profiles* comes from `listProfiles`, never from an allowlist
112
+ * of what is safe to copy: that would happily hand over a committed
113
+ * `personal.example.yaml` or a nested `profiles/archive/old.yaml`, and this
114
+ * opens and validates what it is given — which once turned a template into a
115
+ * `ConfigError` aborting a deploy after provisioning had made cloud resources.
116
+ *
117
+ * A profile that cannot be read is warned about rather than fatal: the repair is
118
+ * a courtesy on the way past, and the caller's real work should still happen.
119
+ * Not silent, though — nothing else widens a policy without being asked.
120
+ *
121
+ * CLI-side by construction, like everything else in this file: a deployed
122
+ * revision holds `objectViewer` on `profiles/` (ADR-023) and could not write
123
+ * this even if the code let it.
124
+ */
125
+ export async function repairOwnerLayer(
126
+ workspaceRoot: string,
127
+ profiles: readonly string[] | undefined,
128
+ options: { report?: (line: string) => void } = {},
129
+ ): Promise<void> {
130
+ // stdout by default, because every caller but one is printing a report a
131
+ // person reads. `update --json` passes `progress` instead: what it produces is
132
+ // a document, and a line of prose in front of it corrupts whatever is parsing.
133
+ // Routed rather than silenced — nothing else here widens a policy without
134
+ // saying so, and this must not be the exception.
135
+ const say = options.report ?? print;
136
+ const wanted = profiles === undefined ? undefined : new Set(profiles);
137
+
138
+ // One connections document for the whole sweep. The owner layer is the
139
+ // workspace's now (ADR-059), so repairing three profiles must not add three
140
+ // `memory.main` rows — opening it once and saving it once is what makes the
141
+ // second profile see what the first one created.
142
+ // Created if absent rather than refused. A contract-3 workspace always has
143
+ // one, but a hand-made or half-migrated one may not — and this is the repair,
144
+ // so the file it needs is a thing to write rather than a reason to stop.
145
+ const connections = await openOrCreateConnections(workspaceRoot);
146
+ let connectionsChanged = false;
147
+
148
+ for (const name of await listProfiles(workspaceRoot)) {
149
+ if (wanted !== undefined && !wanted.has(name)) continue;
150
+
151
+ try {
152
+ const document = await ConfigDocument.open(workspaceRoot, name);
153
+ const repair = ensureOwnerLayer(connections, document);
154
+ if (!repaired(repair)) continue;
155
+
156
+ connectionsChanged = true;
157
+ await document.save();
158
+
159
+ say(ok(`gave ${style.bold(name)} its own owner layer`));
160
+ for (const change of repairLines(repair)) say(` ${style.dim(change)}`);
161
+ // Built from `DEFAULT_SURFACES` rather than typed out. The typed-out
162
+ // version still named six after a seventh had been added, so a person
163
+ // watching a deploy was told entities had arrived on the line above and
164
+ // that the layer was six things on the line below.
165
+ say(` ${style.dim(`${listSurfaces()} — your own material, no account behind any of them`)}`);
166
+ } catch (error) {
167
+ say(
168
+ warn(
169
+ `could not give ${name} its owner layer: ${reasonOf(error)}`,
170
+ ),
171
+ );
172
+ }
173
+ }
174
+
175
+ if (connectionsChanged) await connections.save();
176
+
177
+ // After the profiles, so a workspace that could not be repaired is not told
178
+ // it is current. The stamp says what the workspace is; the profiles are what
179
+ // makes it true.
180
+ if (await ensureRegistryContract(workspaceRoot)) {
181
+ say(ok(`stamped ${style.bold(WORKSPACE_FILE)} as contract ${SUPPORTED_CONTRACT}`));
182
+ }
183
+ }