@lanes-sh/link 0.7.2 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (190) hide show
  1. package/README.md +20 -10
  2. package/instructions/agents/lanes-link-scout.md +2 -2
  3. package/instructions/skills/lanes-link/SKILL.md +148 -73
  4. package/package.json +2 -1
  5. package/src/audit/index.ts +8 -1
  6. package/src/auth/index.ts +58 -2
  7. package/src/auth/lanes/assertion.ts +256 -0
  8. package/src/auth/lanes/callback.ts +135 -0
  9. package/src/auth/lanes/federation.ts +50 -0
  10. package/src/auth/lanes/login.ts +294 -0
  11. package/src/auth/lanes/members.ts +103 -0
  12. package/src/auth/lanes/session.ts +97 -0
  13. package/src/auth/oauth/grant.ts +183 -0
  14. package/src/auth/oauth/result.ts +27 -0
  15. package/src/auth/oauth/server.ts +176 -203
  16. package/src/auth/oauth/store.ts +85 -4
  17. package/src/auth/remote.ts +32 -9
  18. package/src/cli/accepts.ts +109 -0
  19. package/src/cli/argv.ts +57 -3
  20. package/src/cli/audit-change.ts +140 -0
  21. package/src/cli/callback-page.ts +36 -115
  22. package/src/cli/commands/auth-dispatch.ts +48 -0
  23. package/src/cli/commands/auth.ts +229 -0
  24. package/src/cli/commands/connect/accounts.ts +4 -4
  25. package/src/cli/commands/connect/authorise.ts +4 -4
  26. package/src/cli/commands/connect/bind-credential.ts +2 -1
  27. package/src/cli/commands/connect/custom/index.ts +1 -1
  28. package/src/cli/commands/connect/custom/write.ts +2 -2
  29. package/src/cli/commands/connect/grant.ts +29 -14
  30. package/src/cli/commands/connect/index.ts +90 -88
  31. package/src/cli/commands/connect/options.ts +83 -0
  32. package/src/cli/commands/connect/registration.ts +50 -0
  33. package/src/cli/commands/connect/requirements.ts +1 -1
  34. package/src/cli/commands/connect/settle.ts +16 -6
  35. package/src/cli/commands/connect/target-note.ts +7 -2
  36. package/src/cli/commands/connect/unknown.ts +1 -1
  37. package/src/cli/commands/connect/variables.ts +3 -2
  38. package/src/cli/commands/connection-list.ts +116 -0
  39. package/src/cli/commands/connection.ts +183 -165
  40. package/src/cli/commands/grant.ts +140 -0
  41. package/src/cli/commands/identity.ts +24 -12
  42. package/src/cli/commands/knowledge/index.ts +49 -89
  43. package/src/cli/commands/knowledge/migrate.ts +79 -13
  44. package/src/cli/commands/knowledge/show.ts +97 -0
  45. package/src/cli/commands/knowledge.ts +2 -1
  46. package/src/cli/commands/mcp/harnesses.ts +30 -8
  47. package/src/cli/commands/mcp/onboarding.ts +86 -0
  48. package/src/cli/commands/mcp/register.ts +16 -2
  49. package/src/cli/commands/mcp.ts +1 -0
  50. package/src/cli/commands/members.ts +288 -0
  51. package/src/cli/commands/operate/attach.ts +3 -3
  52. package/src/cli/commands/operate/audit.ts +11 -7
  53. package/src/cli/commands/operate/auth.ts +28 -11
  54. package/src/cli/commands/operate/findings.ts +2 -1
  55. package/src/cli/commands/operate/inspect.ts +37 -19
  56. package/src/cli/commands/operate/migrate.ts +33 -13
  57. package/src/cli/commands/operate/outputs.ts +3 -3
  58. package/src/cli/commands/operate/pair-certificate.ts +141 -0
  59. package/src/cli/commands/operate/pair.ts +324 -0
  60. package/src/cli/commands/operate/policy.ts +73 -22
  61. package/src/cli/commands/operate/serve.ts +53 -5
  62. package/src/cli/commands/operate/status.ts +18 -10
  63. package/src/cli/commands/operate/tools.ts +2 -2
  64. package/src/cli/commands/operate.ts +2 -0
  65. package/src/cli/commands/owner/assets.ts +2 -2
  66. package/src/cli/commands/owner/entities.ts +2 -2
  67. package/src/cli/commands/owner/memory.ts +2 -2
  68. package/src/cli/commands/owner/shared.ts +13 -2
  69. package/src/cli/commands/owner/skills.ts +28 -8
  70. package/src/cli/commands/owner/tasks.ts +2 -2
  71. package/src/cli/commands/owner/vault.ts +3 -3
  72. package/src/cli/commands/profile/disposition.ts +236 -0
  73. package/src/cli/commands/profile/removal.ts +154 -64
  74. package/src/cli/commands/profile/remove.ts +83 -7
  75. package/src/cli/commands/profile.ts +79 -16
  76. package/src/cli/commands/relabel.ts +112 -0
  77. package/src/cli/commands/secrets.ts +39 -17
  78. package/src/cli/commands/set-workspace.ts +96 -0
  79. package/src/cli/commands/setup.ts +2 -2
  80. package/src/cli/commands/sync.ts +8 -8
  81. package/src/cli/commands/target.ts +9 -7
  82. package/src/cli/commands/update-migration.ts +54 -0
  83. package/src/cli/commands/update.ts +78 -24
  84. package/src/cli/config-edit.ts +99 -143
  85. package/src/cli/config-migrate.ts +82 -64
  86. package/src/cli/config-repair-sweep.ts +119 -0
  87. package/src/cli/config-repair.ts +131 -125
  88. package/src/cli/config-templates.ts +200 -0
  89. package/src/cli/contract3-credentials.ts +294 -0
  90. package/src/cli/contract3-data.ts +262 -0
  91. package/src/cli/contract3-layout.ts +46 -0
  92. package/src/cli/contract3-shape.ts +212 -0
  93. package/src/cli/contract3.ts +399 -0
  94. package/src/cli/contract4-credentials.ts +207 -0
  95. package/src/cli/contract4-data.ts +399 -0
  96. package/src/cli/contract4-rename.ts +73 -0
  97. package/src/cli/contract4-yaml.ts +223 -0
  98. package/src/cli/contract4.ts +342 -0
  99. package/src/cli/endpoint-url.ts +1 -1
  100. package/src/cli/identity.ts +44 -26
  101. package/src/cli/lanes.ts +25 -1
  102. package/src/cli/main.ts +94 -14
  103. package/src/cli/migrate-move.ts +166 -0
  104. package/src/cli/migrate-plan.ts +12 -6
  105. package/src/cli/output.ts +34 -1
  106. package/src/cli/publish.ts +6 -7
  107. package/src/cli/runtime/open.ts +64 -99
  108. package/src/cli/runtime/registry.ts +6 -7
  109. package/src/cli/runtime/select.ts +2 -11
  110. package/src/cli/runtime/stores.ts +58 -0
  111. package/src/cli/runtime/types.ts +106 -0
  112. package/src/cli/runtime/vault.ts +19 -4
  113. package/src/cli/runtime/workspace.ts +60 -0
  114. package/src/cli/runtime.ts +2 -1
  115. package/src/cli/selection-require.ts +44 -13
  116. package/src/cli/selection.ts +127 -145
  117. package/src/cli/usage.ts +40 -20
  118. package/src/cli/workspace-migrate.ts +152 -22
  119. package/src/connectivity/manifest/provider.ts +34 -13
  120. package/src/connectivity/manifest/requirements.ts +1 -1
  121. package/src/connectivity/transports/imap/parser.ts +70 -9
  122. package/src/deployments/adapters/filesystem.ts +18 -3
  123. package/src/deployments/bind.ts +1 -1
  124. package/src/deployments/deploy.ts +38 -29
  125. package/src/deployments/gcp/bucket.ts +58 -11
  126. package/src/deployments/gcp/provision.ts +7 -7
  127. package/src/deployments/knowledge.ts +9 -4
  128. package/src/deployments/prepare.ts +72 -24
  129. package/src/deployments/record.ts +1 -1
  130. package/src/deployments/report.ts +2 -2
  131. package/src/deployments/serving.ts +15 -74
  132. package/src/deployments/target.ts +34 -13
  133. package/src/deployments/upload.ts +60 -27
  134. package/src/dispatch/deps.ts +88 -0
  135. package/src/dispatch/dispatch.ts +21 -62
  136. package/src/policy/index.ts +47 -15
  137. package/src/profile/connections.ts +195 -0
  138. package/src/profile/deployments.ts +86 -8
  139. package/src/profile/index.ts +35 -6
  140. package/src/profile/knowledge.ts +18 -5
  141. package/src/profile/layout.ts +163 -90
  142. package/src/profile/load.ts +133 -64
  143. package/src/profile/pairing.ts +32 -0
  144. package/src/profile/primitives.ts +35 -1
  145. package/src/profile/registry.ts +6 -6
  146. package/src/profile/schema.ts +181 -21
  147. package/src/profile/targets.ts +21 -9
  148. package/src/profile/testing.ts +104 -2
  149. package/src/profile/workspace.ts +124 -33
  150. package/src/providers/assets/provider.ts +6 -6
  151. package/src/providers/custom/index.ts +1 -1
  152. package/src/providers/custom/load.ts +2 -3
  153. package/src/providers/entities/provider.ts +6 -6
  154. package/src/providers/entities/writes.ts +1 -1
  155. package/src/providers/identity/provider.ts +2 -2
  156. package/src/providers/memory/provider.ts +26 -8
  157. package/src/providers/setup/plan.ts +1 -1
  158. package/src/providers/setup/provider.ts +3 -3
  159. package/src/providers/skills/provider.ts +2 -2
  160. package/src/providers/slack/index.ts +2 -2
  161. package/src/providers/tasks/provider.ts +6 -6
  162. package/src/providers/vault/provider.ts +1 -1
  163. package/src/registry/policy-bridge.ts +33 -11
  164. package/src/registry/reconcile.ts +4 -4
  165. package/src/registry/registry.ts +1 -1
  166. package/src/server/authorization.ts +94 -0
  167. package/src/server/edge.ts +14 -1
  168. package/src/server/endpoint.ts +89 -104
  169. package/src/server/generation.ts +10 -1
  170. package/src/server/harness.ts +71 -13
  171. package/src/server/index.ts +31 -0
  172. package/src/server/mcp/build.ts +20 -1
  173. package/src/server/mcp/client-info.ts +54 -0
  174. package/src/server/mcp/guide.ts +120 -0
  175. package/src/server/mcp/instructions.ts +22 -22
  176. package/src/server/mcp/prompts.ts +7 -3
  177. package/src/server/mcp/resources.ts +16 -8
  178. package/src/server/mcp/routing.ts +3 -3
  179. package/src/server/mcp/tools.ts +25 -6
  180. package/src/server/mcp/visibility.ts +74 -7
  181. package/src/server/oauth.ts +29 -109
  182. package/src/server/read/credential.ts +134 -0
  183. package/src/server/read/deployed.ts +56 -0
  184. package/src/server/read/listener.ts +54 -0
  185. package/src/server/read/open.ts +101 -0
  186. package/src/server/read/routes.ts +247 -0
  187. package/src/server/read/state.ts +171 -0
  188. package/src/stores/blobs/conformance.ts +19 -0
  189. package/src/stores/state/index.ts +76 -10
  190. package/src/stores/state/testing.ts +5 -1
@@ -0,0 +1,112 @@
1
+ import { CONNECTIONS_FILE, readConnections, type Resolution } from '#profile';
2
+ import { ConfigDocument } from '../config-edit.ts';
3
+ import { announce, announceWorkspace, emit, ok, print, style } from '../output.ts';
4
+ import { recordConfigChange } from '../audit-change.ts';
5
+ import { openWorkspaceRuntime, type GlobalFlags } from '../runtime.ts';
6
+ import { nextAfterEdit, publishProfileEdit } from '../publish.ts';
7
+ import { locate } from './connection.ts';
8
+
9
+ /**
10
+ * `lanes link relabel` — the operator's own word for an account.
11
+ *
12
+ * Split from `disconnect` so `connection.ts` stays inside the size budget, and
13
+ * on a real seam rather than a line count: `disconnect` reaches two files, every
14
+ * profile that granted the row, and the credential store, while this writes one
15
+ * field in one document.
16
+ *
17
+ * The label is a separate field from `account` for the reason the bug below
18
+ * gives, and the two must not be merged back.
19
+ */
20
+
21
+ export interface Relabelled {
22
+ readonly profile: string;
23
+ readonly target: string;
24
+ readonly key: string;
25
+ readonly from: string;
26
+ readonly to: string;
27
+ readonly published: string;
28
+ }
29
+
30
+ export interface RelabelFlags extends GlobalFlags {
31
+ readonly json?: boolean | undefined;
32
+ }
33
+
34
+ /**
35
+ * Rename a connection, writing `label` and never `account`.
36
+ *
37
+ * It wrote `account` until it was noticed that `account` is not a display name:
38
+ * `settleIdentity` matches on it to tell a repair from a new account,
39
+ * `idFromAccount` derives the id from it, and `gmail.send_message` writes it
40
+ * into a `From` header. Renaming through it therefore un-recognised the account
41
+ * it renamed — the next `connect` added a second row beside it.
42
+ */
43
+ export async function renameConnection(
44
+ key: string,
45
+ label: string,
46
+ flags: RelabelFlags,
47
+ ): Promise<{ resolution: Resolution; relabelled: Relabelled }> {
48
+ const runtime = await openWorkspaceRuntime(flags);
49
+
50
+ try {
51
+ const { resolution, config, target } = runtime;
52
+ const root = resolution.workspaceRoot;
53
+
54
+ // The workspace's file, not the profile's. A label is the operator's own
55
+ // word for an account (ADR-057 moved the row that carries it), so renaming
56
+ // it once renames it everywhere — which is what someone renaming "the work
57
+ // mailbox" means, and what two copies could never keep true.
58
+ const connectionsFile = await readConnections(root);
59
+ const located = locate(connectionsFile.connections, key, target);
60
+
61
+ const document = await ConfigDocument.openKey(root, CONNECTIONS_FILE);
62
+ document.setIn(['connections', located.index, 'label'], label);
63
+ await document.save();
64
+
65
+ await recordConfigChange(config, root, target, {
66
+ capability: 'config.connection.relabel',
67
+ scope: target,
68
+ connection: key,
69
+ arguments: { from: located.connection.label ?? located.connection.account, to: label },
70
+ });
71
+
72
+ return {
73
+ resolution,
74
+ relabelled: {
75
+ profile: resolution.profile,
76
+ target,
77
+ key,
78
+ // What it was called a moment ago, which is the account only until the
79
+ // first rename.
80
+ from: located.connection.label ?? located.connection.account,
81
+ to: label,
82
+ published: nextAfterEdit(await publishProfileEdit({ resolution, config, target })),
83
+ },
84
+ };
85
+ } finally {
86
+ await runtime.close();
87
+ }
88
+ }
89
+
90
+ export async function relabel(
91
+ key: string | undefined,
92
+ label: string | undefined,
93
+ flags: RelabelFlags,
94
+ ): Promise<void> {
95
+ if (!key) throw new Error('Which connection? Run: lanes link status');
96
+ if (!label) throw new Error(`What should ${key} be called? Run: lanes link relabel ${key} "New name"`);
97
+
98
+ const { resolution, relabelled: result } = await renameConnection(key, label, flags);
99
+
100
+ return emit(flags.json, result, () => {
101
+ announceWorkspace(resolution);
102
+ print(ok(`${style.bold(result.key)} is now ${style.bold(result.to)}`));
103
+ if (result.from) print(` was ${style.dim(result.from)}`);
104
+ // The label is a display name in two places, and only one of them changed.
105
+ print(
106
+ style.dim(
107
+ ' The state store keeps the old name until the next reconcile, which updates it.',
108
+ ),
109
+ );
110
+ if (result.published) print(style.dim(` ${result.published}`));
111
+ })
112
+ }
@@ -1,14 +1,20 @@
1
1
  import { ConfigError } from '#profile';
2
2
  import { assertValidSecretRef } from '#secrets';
3
- import { announce, announceProfile, heading, ok, print, style } from '../output.ts';
4
- import { openSecretStoreFor, resolveProfile, resolveProfileOnly, type GlobalFlags } from '../runtime.ts';
3
+ import { announceWorkspace, announceProfile, heading, ok, print, style } from '../output.ts';
4
+ import {
5
+ openSecretStoreFor,
6
+ primaryProfile,
7
+ resolveProfile,
8
+ resolveProfileOnly,
9
+ type GlobalFlags,
10
+ } from '../runtime.ts';
5
11
 
6
12
  /**
7
13
  * `lanes link secrets` — moving credential values between a profile's targets.
8
14
  *
9
15
  * Credentials follow the target, because each target has its own credential
10
- * store: `lanes link connect gmail.side --target cloud` writes the refresh token into
11
- * Secret Manager, and the same command with `--target local` writes it into
16
+ * store: `lanes link connect gmail.side --workspace cloud` writes the refresh token into
17
+ * Secret Manager, and the same command with `--workspace local` writes it into
12
18
  * the encrypted file. That is the right default and it leaves one gap, which
13
19
  * this command fills — a setup built locally first, and now wanted in the
14
20
  * cloud, without re-running every OAuth flow.
@@ -38,12 +44,12 @@ export async function secretsPush(flags: SecretsFlags): Promise<void> {
38
44
  const { selection, config } = await resolveProfileOnly(flags);
39
45
  announceProfile(selection);
40
46
 
41
- const source = await openSecretStoreFor(config, selection.workspaceRoot, flags.from);
42
- 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);
43
49
 
44
50
  const refs = await source.list();
45
51
  if (refs.length === 0) {
46
- print(style.dim(`No credentials in target "${flags.from}".`));
52
+ print(style.dim(`No credentials in workspace "${flags.from}".`));
47
53
  return;
48
54
  }
49
55
 
@@ -104,7 +110,7 @@ export async function secretsSet(ref: string | undefined, flags: GlobalFlags): P
104
110
  if (!ref) {
105
111
  throw new ConfigError(
106
112
  'Usage: lanes link secrets set <ref> (the value is read from stdin)\n' +
107
- ' e.g. printf %s "$DATABASE_URL" | lanes link secrets set cloud/database_url --target cloud',
113
+ ' e.g. printf %s "$DATABASE_URL" | lanes link secrets set cloud/database_url --workspace cloud',
108
114
  );
109
115
  }
110
116
  assertValidSecretRef(ref);
@@ -119,8 +125,16 @@ export async function secretsSet(ref: string | undefined, flags: GlobalFlags): P
119
125
  );
120
126
  }
121
127
 
122
- const { resolution, config, target } = await resolveProfile(flags);
123
- announce(resolution);
128
+ // The credential store belongs to the workspace since contract 3, so every
129
+ // profile opened the same one and naming one chose nothing. A profile is
130
+ // still resolved, because `openSecretStoreFor` reads the adapter set through
131
+ // one — so the banner names the workspace rather than reporting whichever
132
+ // profile happened to supply it.
133
+ const { resolution, config, target } = await resolveProfile({
134
+ ...flags,
135
+ profile: await primaryProfile(flags),
136
+ });
137
+ announceWorkspace(resolution);
124
138
 
125
139
  const value = (await Bun.stdin.text()).replace(/\n$/, '');
126
140
  if (!value) {
@@ -129,24 +143,32 @@ export async function secretsSet(ref: string | undefined, flags: GlobalFlags): P
129
143
  );
130
144
  }
131
145
 
132
- const credentials = await openSecretStoreFor(config, resolution.workspaceRoot, target);
146
+ const credentials = await openSecretStoreFor(resolution.workspaceRoot, target);
133
147
  const replacing = await credentials.has(ref);
134
148
  await credentials.set(ref, value);
135
149
 
136
- print(ok(`${replacing ? 'replaced' : 'stored'} ${style.bold(ref)} in target ${target}`));
150
+ print(ok(`${replacing ? 'replaced' : 'stored'} ${style.bold(ref)} in workspace ${target}`));
137
151
  if (replacing) {
138
152
  print(style.dim(' Anything still using the old value will start failing.'));
139
153
  }
140
154
  }
141
155
 
142
156
  export async function secretsList(flags: GlobalFlags): Promise<void> {
143
- const { resolution, config, target } = await resolveProfile(flags);
144
- announce(resolution);
145
-
146
- const credentials = await openSecretStoreFor(config, resolution.workspaceRoot, target);
157
+ // The credential store belongs to the workspace since contract 3, so every
158
+ // profile opened the same one and naming one chose nothing. A profile is
159
+ // still resolved, because `openSecretStoreFor` reads the adapter set through
160
+ // one so the banner names the workspace rather than reporting whichever
161
+ // profile happened to supply it.
162
+ const { resolution, config, target } = await resolveProfile({
163
+ ...flags,
164
+ profile: await primaryProfile(flags),
165
+ });
166
+ announceWorkspace(resolution);
167
+
168
+ const credentials = await openSecretStoreFor(resolution.workspaceRoot, target);
147
169
  const refs = await credentials.list();
148
170
 
149
- heading(`Credential references in target ${target} (${refs.length})`);
171
+ heading(`Credential references in workspace ${target} (${refs.length})`);
150
172
  if (refs.length === 0) {
151
173
  print(style.dim(' none'));
152
174
  } else {
@@ -0,0 +1,96 @@
1
+ import {
2
+ ConfigError,
3
+ WORKSPACE_FILE,
4
+ readRegistry,
5
+ readWorkspace,
6
+ resolveWorkspaceRoot,
7
+ } from '#profile';
8
+ import { ConfigDocument } from '../config-edit.ts';
9
+ import { ok, print, style } from '../output.ts';
10
+
11
+ /**
12
+ * `lanes set-workspace <name>` — which workspace a command means when it does
13
+ * not say.
14
+ *
15
+ * ADR-037 deleted the command this replaces, and its reasoning is the thing to
16
+ * read before changing anything here:
17
+ *
18
+ * > Persisted context state is the standard way operators run destructive
19
+ * > commands against the wrong target, and the version of it that bites is the
20
+ * > dotfile nothing prints.
21
+ *
22
+ * Both halves of that sentence are answered rather than argued with (ADR-061).
23
+ * The default is **printed on every command that uses it**, so it is not the
24
+ * dotfile nothing prints; and it is **refused by every command that publishes or
25
+ * destroys**, so it is not what runs a destructive command against the wrong
26
+ * thing. Take either half away and the objection stands again.
27
+ *
28
+ * Top level rather than under `lanes link`, because it selects the workspace
29
+ * *the CLI* acts in and a second area added to `lanes` will want the same
30
+ * answer.
31
+ */
32
+
33
+ export interface SetWorkspaceResult {
34
+ readonly workspace: string;
35
+ readonly previous: string | null;
36
+ readonly path: string;
37
+ }
38
+
39
+ export async function setWorkspace(
40
+ name: string | undefined,
41
+ options: { json?: boolean } = {},
42
+ ): Promise<void> {
43
+ const root = resolveWorkspaceRoot();
44
+
45
+ if (name === undefined) {
46
+ const current = (await readWorkspace(root))?.default_workspace;
47
+ throw new ConfigError(
48
+ `Usage: lanes set-workspace <name>\n` +
49
+ (current ? ` The default is currently "${current}".\n` : ' No default is set.\n') +
50
+ ` Run "lanes link workspace list" to see what there is.`,
51
+ );
52
+ }
53
+
54
+ // Checked against the registry, not accepted on trust. A default naming a
55
+ // workspace that does not exist would turn every later command's refusal into
56
+ // one about the wrong thing — and the operator would have been told "ok" by
57
+ // the command that caused it.
58
+ const registry = await readRegistry(root);
59
+ if (!(name in registry)) {
60
+ const names = Object.keys(registry).sort();
61
+ throw new ConfigError(
62
+ `${root} declares no workspace "${name}".\n` +
63
+ (names.length > 0
64
+ ? ` It has: ${names.join(', ')}\n`
65
+ : ' It has none yet. Create one with: lanes link profile add <name> --workspace local\n') +
66
+ ` Run "lanes link workspace list" to see them.`,
67
+ );
68
+ }
69
+
70
+ const document = await ConfigDocument.openKey(root, WORKSPACE_FILE);
71
+ const previous = document.getIn(['default_workspace']);
72
+ document.setIn(['default_workspace'], name);
73
+ await document.save();
74
+
75
+ const result: SetWorkspaceResult = {
76
+ workspace: name,
77
+ previous: typeof previous === 'string' ? previous : null,
78
+ path: `${root}/${WORKSPACE_FILE}`,
79
+ };
80
+
81
+ if (options.json === true) {
82
+ print(JSON.stringify(result, null, 2));
83
+ return;
84
+ }
85
+
86
+ print(ok(`default workspace: ${style.bold(name)}`));
87
+ print(style.dim(` ${result.path}`));
88
+ print('');
89
+ print(
90
+ style.dim(
91
+ ' Every command that uses it prints it. Commands that publish or destroy —\n' +
92
+ ' deploy, sync, secrets push, profile remove, disconnect, token rotate —\n' +
93
+ ' still want --workspace typed out.',
94
+ ),
95
+ );
96
+ }
@@ -25,11 +25,11 @@ export async function setupPlan(provider: string | undefined, flags: SetupFlags)
25
25
  const context = {
26
26
  profile: runtime.resolution.profile,
27
27
  target: runtime.resolution.target,
28
- connections: runtime.config.connections.map((c) => `${c.provider}.${c.id}`),
28
+ connections: runtime.connections.map(({ ref }) => ref),
29
29
  // Declaring an `oauth_apps` entry is how a profile says "use my own
30
30
  // client". Without this the plan would tell someone who has already
31
31
  // registered one that they need nothing.
32
- ownClients: Object.keys(runtime.config.oauth_apps),
32
+ ownClients: runtime.ownClients,
33
33
  };
34
34
 
35
35
  const manifests = runtime.registry.list().map((entry) => entry.manifest);
@@ -64,15 +64,15 @@ async function locateRemote(
64
64
  }
65
65
 
66
66
  const entry = (await readRegistry(root))[target];
67
- if (entry?.workspace) return { workspace: entry.workspace, how: 'already recorded' };
67
+ if (entry?.at) return { workspace: entry.at, how: 'already recorded' };
68
68
 
69
69
  if (flags.discover !== true) {
70
70
  throw new ConfigError(
71
71
  `Nothing here says where "${target}" lives.\n` +
72
72
  ' No pointer to it in lanes-link.yaml, and nothing else records one.\n\n' +
73
- ' If you know the bucket: lanes link sync targets --target ' +
73
+ ' If you know the bucket: lanes link sync workspaces --workspace ' +
74
74
  `${target} --from gs://<bucket>\n` +
75
- ` If you do not: lanes link sync targets --target ${target} --discover`,
75
+ ` If you do not: lanes link sync targets --workspace ${target} --discover`,
76
76
  );
77
77
  }
78
78
 
@@ -99,7 +99,7 @@ async function locateRemote(
99
99
  if (candidates.length > 1 || !isInteractive()) {
100
100
  throw new ConfigError(
101
101
  `Found ${candidates.length} deployment(s). Name the one you mean:\n` +
102
- ` lanes link sync targets --target ${target} --from ${first.workspace}`,
102
+ ` lanes link sync targets --workspace ${target} --from ${first.workspace}`,
103
103
  );
104
104
  }
105
105
 
@@ -132,7 +132,7 @@ export async function syncTargets(flags: SyncFlags): Promise<void> {
132
132
  const remoteRegistry = await readRegistry(workspace);
133
133
  const declaresIt = remoteRegistry[target] !== undefined;
134
134
  const existing = (await readRegistry(root))[target];
135
- const already = existing?.workspace === workspace;
135
+ const already = existing?.at === workspace;
136
136
 
137
137
  const payload = { workspace: root, remote: workspace, target, how, declaresIt, applied: false };
138
138
 
@@ -163,7 +163,7 @@ export async function syncTargets(flags: SyncFlags): Promise<void> {
163
163
  }
164
164
 
165
165
  heading('Would write');
166
- print(` targets.${target}.workspace: ${workspace}`);
166
+ print(` workspaces.${target}.at: ${workspace}`);
167
167
  print(style.dim(' The bucket keeps everything else; this records where it is.'));
168
168
  };
169
169
 
@@ -184,11 +184,11 @@ export async function syncTargets(flags: SyncFlags): Promise<void> {
184
184
  // deploy block, whose token opens it — is declared where it lives, and reading
185
185
  // it from there is what makes this safe to run on a workspace that has lost
186
186
  // its own copy of anything.
187
- await recordTarget(root, target, { workspace });
187
+ await recordTarget(root, target, { at: workspace });
188
188
 
189
189
  print('');
190
190
  print(ok(`"${target}" now points at ${workspace}`));
191
- print(style.dim(` lanes link status --target ${target} reads it from there`));
191
+ print(style.dim(` lanes link status --workspace ${target} reads it from there`));
192
192
 
193
193
  return emit(flags.json, { ...payload, applied: true }, () => {});
194
194
  }
@@ -158,7 +158,7 @@ function summarise(name: string, entry: WorkspaceTarget, isSelected: boolean): T
158
158
  return {
159
159
  name,
160
160
  isSelected,
161
- pointsAt: entry.workspace,
161
+ pointsAt: entry.at,
162
162
  credentials: null,
163
163
  storage: null,
164
164
  vault: null,
@@ -200,7 +200,7 @@ function summarise(name: string, entry: WorkspaceTarget, isSelected: boolean): T
200
200
  * line above, from the same object.
201
201
  */
202
202
  export function resolvedEntry(resolved: ResolvedTarget): WorkspaceTarget {
203
- const { workspace: _followed, ...record } = resolved.entry;
203
+ const { at: _followed, ...record } = resolved.entry;
204
204
  return { ...resolved.declared, ...record };
205
205
  }
206
206
 
@@ -261,7 +261,7 @@ export async function targetList(flags: TargetFlags): Promise<void> {
261
261
  // as input would be circular, and it has to keep working in the state every
262
262
  // other command fails in — which is what `selectedDeclared` reports.
263
263
  if (listing.selected === null) {
264
- print(style.dim(' Every other command names one of these with --target.'));
264
+ print(style.dim(' Every other command names one of these with --workspace.'));
265
265
  return;
266
266
  }
267
267
 
@@ -299,10 +299,12 @@ function deploymentCell(target: TargetSummary): string {
299
299
  */
300
300
  export function targetUse(name: string | undefined): never {
301
301
  throw new ConfigError(
302
- 'lanes link target use was removed.\n' +
303
- ' Nothing reads instance.default_target any morepass --target on every\n' +
304
- ' command instead:\n' +
305
- ` lanes link status --profile <name> --target ${name ?? '<target>'}\n` +
302
+ 'lanes link workspace use was removed, and came back under a new name.\n' +
303
+ ' `instance.default_target` is still inert nothing reads it. What does\n' +
304
+ ' read a default is `default_workspace` in lanes-link.yaml (ADR-061):\n' +
305
+ ` lanes set-workspace ${name ?? '<name>'}\n` +
306
+ ' Or name it per command:\n' +
307
+ ` lanes link status --profile <name> --workspace ${name ?? '<name>'}\n` +
306
308
  ' If the key is still in your profile it is inert, and safe to delete.',
307
309
  );
308
310
  }
@@ -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,11 +1,15 @@
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 { migrateWorkspace, needsMigration } from '../workspace-migrate.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';
6
9
  import { emit, fail, ok, print, printErr, progress, style, warn } from '../output.ts';
7
10
  import { PACKAGE, release, type ReleaseState } from '../release.ts';
8
11
  import { version } from '../version.ts';
12
+ import { readSession } from '#auth/lanes/session.ts';
9
13
 
10
14
  /**
11
15
  * `lanes link update` — install the newer release, or say why it will not.
@@ -182,11 +186,17 @@ export async function update(flags: UpdateFlags): Promise<void> {
182
186
  // a contract it does not implement until someone redeploys. `deploy` is what
183
187
  // migrates a bucket, because it is the command that ships the image in the
184
188
  // same breath.
185
- await migrateLocal(root, flags.json === true ? progress : print);
186
-
187
- await repairOwnerLayer(root, undefined, {
188
- ...(flags.json === true ? { report: progress } : {}),
189
- });
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
+ }
190
200
  }
191
201
 
192
202
  const report = {
@@ -306,27 +316,71 @@ async function runInstall(argv: readonly string[], json: boolean): Promise<boole
306
316
  * about, in a sentence naming the fix, rather than a reason for the upgrade to
307
317
  * fail. `check` and `doctor` both refuse loudly on the next run.
308
318
  */
309
- async function migrateLocal(root: string, say: (line: string) => void): Promise<void> {
319
+ /**
320
+ * Contract 2 to contract 3, reported the same way its predecessor is.
321
+ *
322
+ * Loud about what moved, because this one moves credentials. An operator whose
323
+ * fifteen accounts were merged into one store should see that happen rather than
324
+ * discover it from a directory listing.
325
+ */
326
+
327
+ async function migrateLocal(root: string, say: (line: string) => void): Promise<boolean> {
310
328
  try {
311
- if (!(await needsMigration(root))) return;
329
+ // One call, both steps, in order — a workspace that predates both walks
330
+ // through them rather than jumping, so each step's reasoning applies to the
331
+ // shape it was written for. `update` used to spell that walk itself, which
332
+ // is how `deploy` and `doctor --fix` came to spell only half of it.
333
+ //
334
+ // The subject is passed rather than left to be defaulted because this is
335
+ // the one caller that already had it in hand.
336
+ const session = await readSession();
337
+ const migration = await migrateToCurrentContract(root, {
338
+ apply: true,
339
+ ...(session ? { subject: session.subject } : {}),
340
+ });
341
+
342
+ if (migration.legacy) sayLegacyTargets(migration.legacy, say);
343
+ if (migration.contract3) sayContract3(migration.contract3, say);
344
+ if (migration.contract4) sayContract4(migration.contract4, say);
345
+ return true;
346
+ } catch (error) {
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;
358
+ }
359
+ }
312
360
 
313
- const migration = await migrateWorkspace(root);
314
- if (migration.alreadyCurrent) return;
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
+ }
315
367
 
368
+ /** Contract 1 to contract 2: targets move from the profile to the workspace. */
369
+ function sayLegacyTargets(
370
+ migration: NonNullable<ContractMigration['legacy']>,
371
+ say: (line: string) => void,
372
+ ): void {
373
+ say(
374
+ `migrated ${migration.profiles.length} profile(s) to contract 2 — a target is declared by ` +
375
+ 'the workspace now, not by each profile',
376
+ );
377
+ for (const change of migration.changes) say(` ${change}`);
378
+
379
+ for (const pointer of migration.targets.filter((one) => one.kind === 'pointer')) {
316
380
  say(
317
- `migrated ${migration.profiles.length} profile(s) to contract 2 a target is declared by ` +
318
- 'the workspace now, not by each profile',
381
+ ` "${pointer.name}" points at ${pointer.where}run ` +
382
+ `lanes link deploy --workspace ${pointer.name} to migrate what is there`,
319
383
  );
320
- for (const change of migration.changes) say(` ${change}`);
321
-
322
- const pointers = migration.targets.filter((one) => one.kind === 'pointer');
323
- for (const pointer of pointers) {
324
- say(
325
- ` "${pointer.name}" points at ${pointer.where} — run ` +
326
- `lanes link deploy --target ${pointer.name} to migrate what is there`,
327
- );
328
- }
329
- } catch (error) {
330
- say(`could not migrate this workspace: ${error instanceof Error ? error.message : String(error)}`);
331
384
  }
332
385
  }
386
+