@lanes-sh/link 0.7.2 → 0.8.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 (156) 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 +136 -61
  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 +65 -0
  17. package/src/auth/remote.ts +32 -9
  18. package/src/cli/accepts.ts +108 -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 +88 -87
  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 +4 -2
  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 +182 -165
  40. package/src/cli/commands/grant.ts +140 -0
  41. package/src/cli/commands/identity.ts +21 -9
  42. package/src/cli/commands/knowledge/index.ts +46 -79
  43. package/src/cli/commands/knowledge/migrate.ts +74 -13
  44. package/src/cli/commands/knowledge/show.ts +92 -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 +29 -12
  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 +52 -4
  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/shared.ts +13 -2
  66. package/src/cli/commands/owner/skills.ts +28 -8
  67. package/src/cli/commands/profile/removal.ts +79 -76
  68. package/src/cli/commands/profile/remove.ts +16 -1
  69. package/src/cli/commands/profile.ts +46 -10
  70. package/src/cli/commands/relabel.ts +112 -0
  71. package/src/cli/commands/secrets.ts +34 -12
  72. package/src/cli/commands/set-workspace.ts +96 -0
  73. package/src/cli/commands/setup.ts +2 -2
  74. package/src/cli/commands/sync.ts +8 -8
  75. package/src/cli/commands/target.ts +9 -7
  76. package/src/cli/commands/update.ts +58 -17
  77. package/src/cli/config-edit.ts +75 -140
  78. package/src/cli/config-migrate.ts +82 -64
  79. package/src/cli/config-repair.ts +89 -32
  80. package/src/cli/config-templates.ts +198 -0
  81. package/src/cli/contract3-data.ts +328 -0
  82. package/src/cli/contract3-shape.ts +186 -0
  83. package/src/cli/contract3.ts +282 -0
  84. package/src/cli/endpoint-url.ts +1 -1
  85. package/src/cli/lanes.ts +25 -1
  86. package/src/cli/main.ts +89 -14
  87. package/src/cli/migrate-plan.ts +12 -6
  88. package/src/cli/output.ts +34 -1
  89. package/src/cli/publish.ts +5 -2
  90. package/src/cli/runtime/open.ts +63 -98
  91. package/src/cli/runtime/registry.ts +6 -7
  92. package/src/cli/runtime/stores.ts +53 -0
  93. package/src/cli/runtime/types.ts +106 -0
  94. package/src/cli/runtime/vault.ts +19 -4
  95. package/src/cli/runtime/workspace.ts +60 -0
  96. package/src/cli/runtime.ts +2 -1
  97. package/src/cli/selection-require.ts +44 -13
  98. package/src/cli/selection.ts +127 -145
  99. package/src/cli/usage.ts +34 -18
  100. package/src/cli/workspace-migrate.ts +125 -16
  101. package/src/connectivity/manifest/provider.ts +3 -1
  102. package/src/connectivity/manifest/requirements.ts +1 -1
  103. package/src/deployments/bind.ts +1 -1
  104. package/src/deployments/deploy.ts +36 -27
  105. package/src/deployments/gcp/bucket.ts +18 -7
  106. package/src/deployments/gcp/provision.ts +7 -7
  107. package/src/deployments/prepare.ts +72 -24
  108. package/src/deployments/record.ts +1 -1
  109. package/src/deployments/report.ts +2 -2
  110. package/src/deployments/serving.ts +15 -74
  111. package/src/deployments/target.ts +15 -15
  112. package/src/deployments/upload.ts +46 -22
  113. package/src/dispatch/deps.ts +88 -0
  114. package/src/dispatch/dispatch.ts +21 -62
  115. package/src/policy/index.ts +47 -15
  116. package/src/profile/connections.ts +183 -0
  117. package/src/profile/deployments.ts +3 -3
  118. package/src/profile/index.ts +30 -5
  119. package/src/profile/layout.ts +86 -89
  120. package/src/profile/load.ts +80 -47
  121. package/src/profile/pairing.ts +32 -0
  122. package/src/profile/primitives.ts +35 -1
  123. package/src/profile/registry.ts +6 -6
  124. package/src/profile/schema.ts +172 -21
  125. package/src/profile/targets.ts +21 -9
  126. package/src/profile/testing.ts +69 -2
  127. package/src/profile/workspace.ts +58 -3
  128. package/src/providers/custom/index.ts +1 -1
  129. package/src/providers/custom/load.ts +2 -3
  130. package/src/providers/identity/provider.ts +1 -1
  131. package/src/providers/memory/provider.ts +20 -2
  132. package/src/providers/setup/plan.ts +1 -1
  133. package/src/providers/slack/index.ts +2 -2
  134. package/src/registry/policy-bridge.ts +33 -11
  135. package/src/registry/reconcile.ts +4 -4
  136. package/src/server/authorization.ts +94 -0
  137. package/src/server/edge.ts +14 -1
  138. package/src/server/endpoint.ts +85 -104
  139. package/src/server/generation.ts +10 -1
  140. package/src/server/harness.ts +71 -13
  141. package/src/server/index.ts +31 -0
  142. package/src/server/mcp/build.ts +20 -1
  143. package/src/server/mcp/client-info.ts +54 -0
  144. package/src/server/mcp/guide.ts +120 -0
  145. package/src/server/mcp/instructions.ts +1 -1
  146. package/src/server/mcp/prompts.ts +7 -3
  147. package/src/server/mcp/resources.ts +16 -8
  148. package/src/server/mcp/tools.ts +9 -3
  149. package/src/server/mcp/visibility.ts +18 -3
  150. package/src/server/oauth.ts +29 -109
  151. package/src/server/read/credential.ts +134 -0
  152. package/src/server/read/deployed.ts +56 -0
  153. package/src/server/read/listener.ts +54 -0
  154. package/src/server/read/open.ts +101 -0
  155. package/src/server/read/routes.ts +247 -0
  156. package/src/server/read/state.ts +171 -0
@@ -1,7 +1,9 @@
1
+ import { newConnectionsTemplate, newProfileTemplate, newWorkspaceTemplate } from '../config-templates.ts';
1
2
  import { mkdir, writeFile } from 'node:fs/promises';
2
3
  import { existsSync } from 'node:fs';
3
4
  import { join } from 'node:path';
4
5
  import {
6
+ CONNECTIONS_FILE,
5
7
  ConfigError,
6
8
  WORKSPACE_FILE,
7
9
  listProfiles,
@@ -13,8 +15,11 @@ import {
13
15
  resolveTargetWorkspace,
14
16
  resolveWorkspaceRoot,
15
17
  } from '#profile';
16
- import { newProfileTemplate, newWorkspaceTemplate } from '../config-edit.ts';
18
+
19
+ import { recordConfigChange } from '../audit-change.ts';
20
+ import { resolveProfile } from '../runtime.ts';
17
21
  import { emit, ok, print, style, table } from '../output.ts';
22
+ import { readSession } from '#auth/lanes/session.ts';
18
23
 
19
24
  /**
20
25
  * Profile management.
@@ -58,7 +63,7 @@ export interface ProfileListing {
58
63
  *
59
64
  * It now decides *where the file goes* rather than what is written in it: a
60
65
  * profile lives in one target's workspace and declares nothing about it
61
- * (ADR-052), so `--target cloud` writes into the bucket and the endpoint there
66
+ * (ADR-052), so `--workspace cloud` writes into the bucket and the endpoint there
62
67
  * serves it on its next reconcile.
63
68
  */
64
69
  export async function createProfile(
@@ -69,7 +74,7 @@ export async function createProfile(
69
74
  const target = options.targets[0]!;
70
75
 
71
76
  // The workspace file before the target is resolved, not after. `profile add
72
- // <name> --target local` on an empty directory is how a workspace comes into
77
+ // <name> --workspace local` on an empty directory is how a workspace comes into
73
78
  // existence, and the target it names is declared *by* that file — so writing
74
79
  // it second means resolving a target nothing has declared yet.
75
80
  if (!isRemoteWorkspace(local) && !existsSync(join(local, WORKSPACE_FILE))) {
@@ -80,6 +85,14 @@ export async function createProfile(
80
85
  const root = await resolveTargetWorkspace(local, target);
81
86
  const path = profilePath(root, name);
82
87
 
88
+ // The connections file comes into existence with the workspace, carrying the
89
+ // owner layer. It is written before the profile because the profile's grants
90
+ // name rows in it, and `assertGrantsResolve` refuses a grant with nothing
91
+ // behind it — so a profile written first would not load until this existed.
92
+ if (!(await workspaceFiles(root).has(CONNECTIONS_FILE))) {
93
+ await writeWorkspaceFile(workspaceFiles(root), CONNECTIONS_FILE, newConnectionsTemplate());
94
+ }
95
+
83
96
  if (await workspaceFiles(root).has(`profiles/${name}.yaml`)) {
84
97
  throw new Error(`Profile "${name}" already exists at ${path}`);
85
98
  }
@@ -99,10 +112,23 @@ export async function createProfile(
99
112
  // sibling's, or asked. It declares no target now (ADR-052): it is written into
100
113
  // the workspace of the target it was named with, and that workspace already
101
114
  // says where its bytes go.
115
+ // The signed-in subject, so the profile reaches somebody the moment it
116
+ // exists. Without it every new profile shipped `members: []` while the
117
+ // template writes `authorization: mode: self` — an endpoint that advertises
118
+ // OAuth and lists nobody, where the owner signs in at lanes.sh and is told no
119
+ // profile lists them. A local stdio or CI caller still worked, which is why a
120
+ // smoke test passed: those carry `profiles: undefined` and `mayReach` admits
121
+ // everything.
122
+ //
123
+ // Null when nobody is signed in, which is a real state — `lanes auth login`
124
+ // has not been run yet — and the template then says how to fix it rather than
125
+ // inventing a subject.
126
+ const session = await readSession();
127
+
102
128
  await writeWorkspaceFile(
103
129
  workspaceFiles(root),
104
130
  `profiles/${name}.yaml`,
105
- newProfileTemplate(name, port),
131
+ newProfileTemplate(name, port, session?.subject),
106
132
  );
107
133
 
108
134
  return { name, path, port, targets: options.targets, copiedFrom: {} };
@@ -142,8 +168,8 @@ export async function profileAdd(
142
168
  if (options.targets.length === 0) {
143
169
  throw new ConfigError(
144
170
  'Say which targets this profile declares:\n' +
145
- ` lanes link profile add ${name} --target local\n` +
146
- ` lanes link profile add ${name} --target local --target cloud\n` +
171
+ ` lanes link profile add ${name} --workspace local\n` +
172
+ ` lanes link profile add ${name} --workspace local --workspace cloud\n` +
147
173
  '\n' +
148
174
  ' A target is where a profile runs — a credential store and a blob store.\n' +
149
175
  ' There is no default to inherit before the profile exists.',
@@ -152,11 +178,21 @@ export async function profileAdd(
152
178
 
153
179
  const created = await createProfile(name, options);
154
180
 
181
+ // Re-read rather than assembled from `created`, because the file on disk is
182
+ // what a workspace with a remote credential store needs to open one.
183
+ const primary = created.targets[0]!;
184
+ const { resolution, config } = await resolveProfile({ profile: created.name, target: primary });
185
+ await recordConfigChange(config, resolution.workspaceRoot, primary, {
186
+ capability: 'config.profile.add',
187
+ scope: created.name,
188
+ arguments: { port: created.port, workspace: primary },
189
+ });
190
+
155
191
  return emit(options.json, created, () => {
156
192
  print(ok(`created profile ${style.bold(created.name)}`));
157
193
  print(` config ${created.path}`);
158
194
  print(` port ${created.port}`);
159
- print(` targets ${created.targets.join(', ')}`);
195
+ print(` workspace ${created.targets.join(', ')}`);
160
196
 
161
197
  for (const [target, from] of Object.entries(created.copiedFrom)) {
162
198
  print(` ${style.dim(`${target} adapters copied from profile "${from}"`)}`);
@@ -165,7 +201,7 @@ export async function profileAdd(
165
201
  print();
166
202
  print(
167
203
  style.dim(
168
- `Next: lanes link connect example --profile ${created.name} --target ${created.targets[0]}`,
204
+ `Next: lanes link connect example --profile ${created.name} --workspace ${created.targets[0]}`,
169
205
  ),
170
206
  );
171
207
  });
@@ -180,7 +216,7 @@ export async function profileList(
180
216
  return emit(options.json, listing, () => {
181
217
  if (listing.profiles.length === 0) {
182
218
  print(style.dim(`No profiles in ${listing.root}.`));
183
- print(style.dim('Create one with: lanes link profile add personal --target local'));
219
+ print(style.dim('Create one with: lanes link profile add personal --workspace local'));
184
220
  return;
185
221
  }
186
222
 
@@ -210,7 +246,7 @@ export function profileDefault(name: string | undefined): never {
210
246
  throw new ConfigError(
211
247
  'lanes link profile default was removed.\n' +
212
248
  ' Nothing reads default_profile any more — pass --profile on every command:\n' +
213
- ` lanes link status --profile ${name ?? '<name>'} --target <target>\n` +
249
+ ` lanes link status --profile ${name ?? '<name>'} --workspace <name>\n` +
214
250
  ' If the key is still in lanes-link.yaml it is inert, and safe to delete.',
215
251
  );
216
252
  }
@@ -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.
@@ -43,7 +49,7 @@ export async function secretsPush(flags: SecretsFlags): Promise<void> {
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) {
@@ -133,20 +147,28 @@ export async function secretsSet(ref: string | undefined, flags: GlobalFlags): P
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);
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);
145
167
 
146
168
  const credentials = await openSecretStoreFor(config, 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
  }
@@ -2,10 +2,12 @@ import { homedir } from 'node:os';
2
2
  import { join, sep } from 'node:path';
3
3
  import { installRoot, resolveWorkspaceRoot } from '#profile';
4
4
  import { repairOwnerLayer } from '../config-repair.ts';
5
- import { migrateWorkspace, needsMigration } from '../workspace-migrate.ts';
5
+ import { migrateToCurrentContract, type ContractMigration } from '../workspace-migrate.ts';
6
+ import type { Contract3Migration } from '../contract3.ts';
6
7
  import { emit, fail, ok, print, printErr, progress, style, warn } from '../output.ts';
7
8
  import { PACKAGE, release, type ReleaseState } from '../release.ts';
8
9
  import { version } from '../version.ts';
10
+ import { readSession } from '#auth/lanes/session.ts';
9
11
 
10
12
  /**
11
13
  * `lanes link update` — install the newer release, or say why it will not.
@@ -306,27 +308,66 @@ async function runInstall(argv: readonly string[], json: boolean): Promise<boole
306
308
  * about, in a sentence naming the fix, rather than a reason for the upgrade to
307
309
  * fail. `check` and `doctor` both refuse loudly on the next run.
308
310
  */
311
+ /**
312
+ * Contract 2 to contract 3, reported the same way its predecessor is.
313
+ *
314
+ * Loud about what moved, because this one moves credentials. An operator whose
315
+ * fifteen accounts were merged into one store should see that happen rather than
316
+ * discover it from a directory listing.
317
+ */
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
+
309
333
  async function migrateLocal(root: string, say: (line: string) => void): Promise<void> {
310
334
  try {
311
- if (!(await needsMigration(root))) return;
335
+ // One call, both steps, in order — a workspace that predates both walks
336
+ // through them rather than jumping, so each step's reasoning applies to the
337
+ // shape it was written for. `update` used to spell that walk itself, which
338
+ // is how `deploy` and `doctor --fix` came to spell only half of it.
339
+ //
340
+ // The subject is passed rather than left to be defaulted because this is
341
+ // the one caller that already had it in hand.
342
+ const session = await readSession();
343
+ const migration = await migrateToCurrentContract(root, {
344
+ apply: true,
345
+ ...(session ? { subject: session.subject } : {}),
346
+ });
347
+
348
+ if (migration.legacy) sayLegacyTargets(migration.legacy, say);
349
+ if (migration.contract3) sayContract3(migration.contract3, say);
350
+ } catch (error) {
351
+ say(`could not migrate this workspace: ${error instanceof Error ? error.message : String(error)}`);
352
+ }
353
+ }
312
354
 
313
- const migration = await migrateWorkspace(root);
314
- if (migration.alreadyCurrent) return;
355
+ /** Contract 1 to contract 2: targets move from the profile to the workspace. */
356
+ function sayLegacyTargets(
357
+ migration: NonNullable<ContractMigration['legacy']>,
358
+ say: (line: string) => void,
359
+ ): void {
360
+ say(
361
+ `migrated ${migration.profiles.length} profile(s) to contract 2 — a target is declared by ` +
362
+ 'the workspace now, not by each profile',
363
+ );
364
+ for (const change of migration.changes) say(` ${change}`);
315
365
 
366
+ for (const pointer of migration.targets.filter((one) => one.kind === 'pointer')) {
316
367
  say(
317
- `migrated ${migration.profiles.length} profile(s) to contract 2 a target is declared by ` +
318
- 'the workspace now, not by each profile',
368
+ ` "${pointer.name}" points at ${pointer.where}run ` +
369
+ `lanes link deploy --workspace ${pointer.name} to migrate what is there`,
319
370
  );
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
371
  }
332
372
  }
373
+