@lanes-sh/link 0.5.3 → 0.6.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 (59) hide show
  1. package/instructions/agents/lanes-link-scout.md +1 -1
  2. package/instructions/skills/lanes-link/SKILL.md +33 -29
  3. package/package.json +2 -1
  4. package/src/cli/commands/connect/custom/index.ts +2 -0
  5. package/src/cli/commands/connect/custom/spec.ts +1 -0
  6. package/src/cli/commands/connect/declare.ts +76 -0
  7. package/src/cli/commands/connect/family.ts +19 -3
  8. package/src/cli/commands/connect/index.ts +23 -24
  9. package/src/cli/commands/connect/outcome.ts +8 -0
  10. package/src/cli/commands/connect/settle.ts +94 -22
  11. package/src/cli/commands/connect/target-note.ts +2 -2
  12. package/src/cli/commands/connection.ts +18 -7
  13. package/src/cli/commands/knowledge/index.ts +14 -34
  14. package/src/cli/commands/mcp/register.ts +1 -1
  15. package/src/cli/commands/operate/dashboard.ts +2 -2
  16. package/src/cli/commands/operate/inspect.ts +5 -1
  17. package/src/cli/commands/operate/migrate.ts +85 -1
  18. package/src/cli/commands/operate/outputs.ts +7 -22
  19. package/src/cli/commands/operate/status.ts +95 -70
  20. package/src/cli/commands/operate/tools.ts +1 -1
  21. package/src/cli/commands/profile/removal.ts +36 -25
  22. package/src/cli/commands/profile/remove.ts +5 -2
  23. package/src/cli/commands/profile.ts +56 -40
  24. package/src/cli/commands/sync.ts +94 -162
  25. package/src/cli/commands/target.ts +115 -74
  26. package/src/cli/commands/update.ts +56 -1
  27. package/src/cli/config-edit.ts +47 -16
  28. package/src/cli/dashboard-page.ts +10 -1
  29. package/src/cli/endpoint-url.ts +3 -3
  30. package/src/cli/main.ts +11 -7
  31. package/src/cli/publish.ts +4 -2
  32. package/src/cli/runtime/open.ts +20 -10
  33. package/src/cli/runtime/select.ts +69 -22
  34. package/src/cli/selection-require.ts +79 -0
  35. package/src/cli/selection.ts +45 -92
  36. package/src/cli/usage.ts +3 -0
  37. package/src/cli/workspace-migrate.ts +385 -0
  38. package/src/deployments/bootstrap.ts +31 -11
  39. package/src/deployments/deploy.ts +54 -27
  40. package/src/deployments/knowledge.ts +5 -2
  41. package/src/deployments/prepare.ts +3 -1
  42. package/src/deployments/serving.ts +19 -15
  43. package/src/deployments/upload.ts +10 -1
  44. package/src/profile/deployments.ts +64 -53
  45. package/src/profile/index.ts +22 -6
  46. package/src/profile/legacy.ts +92 -0
  47. package/src/profile/load.ts +12 -28
  48. package/src/profile/registry.ts +182 -0
  49. package/src/profile/schema.ts +171 -110
  50. package/src/profile/targets.ts +62 -91
  51. package/src/profile/testing.ts +78 -0
  52. package/src/profile/workspace.ts +11 -25
  53. package/src/providers/setup/provider.ts +10 -2
  54. package/src/server/dashboard.ts +5 -1
  55. package/src/server/harness.ts +1 -6
  56. package/src/cli/commands/profile/declare.ts +0 -154
  57. package/src/deployments/servable.ts +0 -82
  58. package/src/deployments/sync-apply.ts +0 -330
  59. package/src/deployments/sync.ts +0 -164
@@ -230,7 +230,6 @@ async function useGithub(flags: KnowledgeFlags): Promise<void> {
230
230
 
231
231
  print('');
232
232
  print(ok(`memory and skills are now kept in ${facts.fullName}`));
233
- reportOtherTargets(runtime.config, runtime.target, knowledge.token_ref);
234
233
  } finally {
235
234
  await runtime.close();
236
235
  }
@@ -242,7 +241,7 @@ async function useLocal(flags: KnowledgeFlags): Promise<void> {
242
241
  announce(runtime.resolution);
243
242
  const selection = ` --profile ${runtime.resolution.profile} --target ${runtime.target}`;
244
243
 
245
- const knowledge = runtime.config.targets[runtime.target]?.knowledge;
244
+ const knowledge = runtime.config.knowledge;
246
245
  if (!knowledge) {
247
246
  print(style.dim(' This profile already keeps memory and skills on its own storage.'));
248
247
  return;
@@ -299,8 +298,7 @@ async function openLocalStores(
299
298
  runtime: Runtime,
300
299
  ): Promise<{ storage: BlobStore; skills: BlobStore }> {
301
300
  const { openStorage } = await import('#deployments/target.ts');
302
- const declared = runtime.config.targets[runtime.target];
303
- if (!declared) throw new ConfigError(`Target "${runtime.target}" is not declared`);
301
+ const declared = runtime.declared;
304
302
 
305
303
  const factory = await openStorage(
306
304
  {
@@ -314,7 +312,14 @@ async function openLocalStores(
314
312
  return { storage: factory(), skills: factory(layout.skills(runtime.config.instance.profile)) };
315
313
  }
316
314
 
317
- /** Write, or remove, the block on every target this profile declares. */
315
+ /**
316
+ * Write, or remove, this profile's knowledge block.
317
+ *
318
+ * One place, not one per target. It used to loop over every target the profile
319
+ * declared and write the same block into each — which is what a per-target key
320
+ * holding a per-profile fact costs. The block is on the profile now (ADR-052),
321
+ * so there is one of it.
322
+ */
318
323
  async function writeBlock(
319
324
  config: Config,
320
325
  root: string,
@@ -322,12 +327,10 @@ async function writeBlock(
322
327
  ): Promise<void> {
323
328
  const document = await ConfigDocument.open(root, config.instance.profile);
324
329
 
325
- for (const target of Object.keys(config.targets)) {
326
- if (knowledge === undefined) {
327
- document.removeIn(['targets', target, 'knowledge']);
328
- continue;
329
- }
330
- document.setIn(['targets', target, 'knowledge'], {
330
+ if (knowledge === undefined) {
331
+ document.removeIn(['knowledge']);
332
+ } else {
333
+ document.setIn(['knowledge'], {
331
334
  adapter: knowledge.adapter,
332
335
  repo: knowledge.repo,
333
336
  ...(knowledge.branch ? { branch: knowledge.branch } : {}),
@@ -339,29 +342,6 @@ async function writeBlock(
339
342
  await document.save();
340
343
  }
341
344
 
342
- /**
343
- * The other targets need the same token in their own credential stores.
344
- *
345
- * Not written for them: a second target's store is Secret Manager, which needs
346
- * cloud credentials this command has no business assuming. `secrets push` is
347
- * the command that already does exactly this, so this points at it.
348
- */
349
- function reportOtherTargets(config: Config, current: string, ref: string): void {
350
- const others = Object.keys(config.targets).filter((target) => target !== current);
351
- if (others.length === 0) return;
352
-
353
- const profile = config.instance.profile;
354
-
355
- print(
356
- style.dim(
357
- ` Written into every target (${others.join(', ')} as well). Each reads "${ref}" from its own credential store:`,
358
- ),
359
- );
360
- for (const target of others) {
361
- print(style.dim(` lanes link secrets push --from ${current} --to ${target} --profile ${profile}`));
362
- }
363
- }
364
-
365
345
  async function decideMigration(hasContent: boolean, flags: KnowledgeFlags): Promise<boolean> {
366
346
  if (flags.migrate !== undefined) return flags.migrate;
367
347
  if (!hasContent) return false;
@@ -77,7 +77,7 @@ export async function mcpAdd(target: string | undefined, options: McpAddOptions)
77
77
  // `http://<host>:<port>/mcp` unconditionally, so `mcp add --target cloud`
78
78
  // registered loopback with the agent: a registration that reports success,
79
79
  // names the right server, and points at a port with nothing behind it.
80
- const url = await endpointUrl(runtime.config, runtime.target);
80
+ const url = await endpointUrl(runtime.config, runtime.declared);
81
81
  const input: AddInput = {
82
82
  name,
83
83
  url,
@@ -36,10 +36,10 @@ export async function dashboard(flags: DashboardFlags): Promise<void> {
36
36
  // still be correct and nobody would ever read it, because a storage 403
37
37
  // arrives first. This is the seam `runtime.ts` documents `deploy` and
38
38
  // `secrets push` stopping at, for the same reason.
39
- const { resolution, config, target } = await resolveProfile(flags);
39
+ const { resolution, config, target, resolved } = await resolveProfile(flags);
40
40
  announce(resolution);
41
41
 
42
- const deployed = deploymentIdentity(config.targets[target]?.deploy);
42
+ const deployed = deploymentIdentity(resolved?.declared.deploy);
43
43
  if (deployed) {
44
44
  throw new Error(
45
45
  `Target "${target}" is deployed to ${deployed.platform}, and the dashboard is served only ` +
@@ -5,7 +5,7 @@ import { staleNudge } from '../../release.ts';
5
5
  import { openRuntime, resolveProfileOnly, type GlobalFlags, type Runtime } from '../../runtime.ts';
6
6
  import type { FetchLike } from '#deployments/knowledge.ts';
7
7
  import { credentialAge, reportCapabilityDrift } from './findings.ts';
8
- import { migratedRenamedProviders } from './migrate.ts';
8
+ import { migratedContract, migratedRenamedProviders } from './migrate.ts';
9
9
 
10
10
  /**
11
11
  * The gate order — check, doctor, plan, start — exists so failures surface in
@@ -75,6 +75,10 @@ export async function doctor(flags: DoctorFlags): Promise<void> {
75
75
  try {
76
76
  runtime = await openRuntime(flags, { fetch: flags.fetch });
77
77
  } catch (refusal) {
78
+ // Contract first: it is checked before the schema, so a profile carrying
79
+ // both problems reports the contract one and the rename cannot be seen until
80
+ // that is settled.
81
+ if (await migratedContract(flags, refusal)) return;
78
82
  if (await migratedRenamedProviders(flags, refusal)) return;
79
83
  throw refusal;
80
84
  }
@@ -1,6 +1,7 @@
1
- import { ConfigError, resolveSelection } from '#profile';
1
+ import { ConfigError, resolveSelection, resolveTargetWorkspace, resolveWorkspaceRoot } from '#profile';
2
2
  import { ConfigDocument } from '../../config-edit.ts';
3
3
  import { migrateRenamedProviders, pendingRenames, shapeOf } from '../../config-migrate.ts';
4
+ import { migrateWorkspace, needsMigration } from '../../workspace-migrate.ts';
4
5
  import { emit, fail, ok, print, style, warn } from '../../output.ts';
5
6
  import { openSecretStoreFor, type GlobalFlags } from '../../runtime.ts';
6
7
 
@@ -98,3 +99,86 @@ export async function migratedRenamedProviders(
98
99
 
99
100
  return true;
100
101
  }
102
+
103
+
104
+ /**
105
+ * Whether a refusal to load was a stale contract, and — with `--fix` — raise it.
106
+ *
107
+ * The sibling of `migratedRenamedProviders` above, and asked *before* it: a
108
+ * contract-1 profile is refused by `assertSupportedContract`, which runs before
109
+ * the schema, so a file with both problems reports this one and the rename is
110
+ * invisible until it is fixed.
111
+ *
112
+ * Here for the reason this file's header already gives — `doctor` is what
113
+ * someone runs when something is broken, so it has to be the command that works
114
+ * when everything else refuses. `update` migrates the local workspace on its own
115
+ * and most people will never reach this; the ones who do are the ones whose
116
+ * `--target` is a bucket, which `update` deliberately leaves alone.
117
+ *
118
+ * **It will migrate a remote workspace, and says what that costs.** The endpoint
119
+ * in front of a bucket runs a pinned image, and one built before contract 2
120
+ * cannot read what this writes. That is a real consequence and it is the
121
+ * operator's to accept, so the line naming the redeploy is printed whether or
122
+ * not `--fix` was passed.
123
+ */
124
+ export async function migratedContract(flags: RenameFlags, refusal: unknown): Promise<boolean> {
125
+ if (!(refusal instanceof ConfigError)) return false;
126
+
127
+ const root = resolveWorkspaceRoot();
128
+ const target = flags.target ?? '';
129
+ // Where the profiles actually are. For a pointer that is the bucket, which is
130
+ // the case this exists for; a target the registry cannot resolve is not a
131
+ // migration problem and falls through to the original refusal.
132
+ const where = await resolveTargetWorkspace(root, target).catch(() => null);
133
+ if (where === null) return false;
134
+
135
+ if (!(await needsMigration(where))) return false;
136
+
137
+ const migration = await migrateWorkspace(where, { apply: flags.fix === true });
138
+ const applied = flags.fix === true;
139
+ const remote = where !== root;
140
+
141
+ if (!applied) process.exitCode = 1;
142
+
143
+ await emit(
144
+ flags.json,
145
+ {
146
+ ok: applied,
147
+ workspace: where,
148
+ target,
149
+ applied,
150
+ remote,
151
+ profiles: migration.profiles,
152
+ targets: migration.targets,
153
+ changes: migration.changes,
154
+ },
155
+ () => {
156
+ print(`workspace ${style.bold(where)} target ${style.bold(target)}`);
157
+ print();
158
+ print(
159
+ applied
160
+ ? ok('migrated to contract 2 — a target is declared by the workspace, not by each profile')
161
+ : warn('this workspace is contract 1, and nothing here reads that any more'),
162
+ );
163
+ for (const change of migration.changes) print(` ${change}`);
164
+
165
+ if (remote) {
166
+ print();
167
+ print(
168
+ style.dim(
169
+ applied
170
+ ? ' The endpoint serving this bucket is running an older image and cannot read\n' +
171
+ ` what was just written. Roll a new one: lanes link deploy --target ${target}`
172
+ : ` lanes link deploy --target ${target} migrates it and ships the image that\n` +
173
+ ' understands it, in one step. Prefer that to --fix here.',
174
+ ),
175
+ );
176
+ return;
177
+ }
178
+
179
+ if (!applied) print(style.dim(` Fix it with: lanes link doctor --fix --target ${target}`));
180
+ },
181
+ );
182
+
183
+ return true;
184
+ }
@@ -1,5 +1,4 @@
1
1
  import { listProfiles } from '#profile';
2
- import { unservableProfiles } from '#deployments/servable.ts';
3
2
  import { fileURLToPath } from 'node:url';
4
3
  import { deployedUrl, endpointHealth, localUrl } from '../../endpoint-url.ts';
5
4
  import { announce, heading, print, style, warn } from '../../output.ts';
@@ -28,7 +27,7 @@ export async function outputs(flags: OutputsFlags): Promise<void> {
28
27
 
29
28
  try {
30
29
  const { token } = await ensureProfileToken(runtime.credentials, runtime.config.auth.token_ref);
31
- const declared = runtime.config.targets[runtime.target]?.deploy;
30
+ const declared = runtime.declared.deploy;
32
31
  const deployed = await deployedUrl(declared);
33
32
  // Not `endpointUrl`, which would ask the platform a second time for an
34
33
  // answer this line already has.
@@ -37,14 +36,12 @@ export async function outputs(flags: OutputsFlags): Promise<void> {
37
36
  const live = await endpointHealth(url, token);
38
37
  const mine = live?.profile === runtime.resolution.profile;
39
38
 
40
- // Live if it is up, otherwise what `start` would serve which is every
41
- // profile in the workspace *that declares this target*, not every profile
42
- // full stop. `start` opens them all against one target and skips the ones
43
- // that cannot run on it, so listing those here would promise a reach the
44
- // endpoint will not have.
45
- const profiles = mine
46
- ? live.profiles
47
- : await servable(runtime.resolution.workspaceRoot, runtime.target);
39
+ // Live if it is up, otherwise every profile in this target's workspace.
40
+ // Those are the same set now: a profile lives in exactly one target
41
+ // (ADR-052), so every profile here is one this target can open. The filter
42
+ // that used to sit between them answered "which of these declare it", a
43
+ // question no profile has an opinion on any more.
44
+ const profiles = mine ? live.profiles : await listProfiles(runtime.resolution.workspaceRoot);
48
45
 
49
46
  if (flags.json) {
50
47
  print(
@@ -138,18 +135,6 @@ export async function outputs(flags: OutputsFlags): Promise<void> {
138
135
  }
139
136
  }
140
137
 
141
- /** The profiles a `start` on this target would actually open. */
142
- async function servable(workspaceRoot: string, target: string): Promise<string[]> {
143
- const all = await listProfiles(workspaceRoot);
144
- const cannot = new Set(
145
- (await unservableProfiles({ workspaceRoot, profiles: undefined, target })).map(
146
- (one) => one.profile,
147
- ),
148
- );
149
-
150
- return all.filter((name) => !cannot.has(name));
151
- }
152
-
153
138
  /**
154
139
  * The command that prints this endpoint's token, verified to actually work.
155
140
  *
@@ -1,4 +1,13 @@
1
- import { listProfiles, loadWorkspaceProfiles, resolveWorkspaceRoot } from '#profile';
1
+ import {
2
+ isPointer,
3
+ listProfiles,
4
+ loadWorkspaceProfiles,
5
+ notInRegistry,
6
+ openTarget,
7
+ readRegistry,
8
+ resolveWorkspaceRoot,
9
+ type ResolvedTarget,
10
+ } from '#profile';
2
11
  import { oneProfile, visibleCapabilities } from '#server/mcp';
3
12
  import { toPolicyDocument } from '#registry';
4
13
  import { announce, emit, heading, print, style, table } from '../../output.ts';
@@ -46,6 +55,9 @@ export async function status(flags: StatusFlags): Promise<void> {
46
55
  provider: connection.provider,
47
56
  id: connection.id,
48
57
  account: connection.account,
58
+ // Null rather than absent: a caller reading this to prefill a rename box
59
+ // needs to tell "called nothing in particular" from a field it forgot.
60
+ label: connection.label ?? null,
49
61
  state: byKey.get(key)?.status ?? 'not reconciled',
50
62
  };
51
63
  });
@@ -74,7 +86,7 @@ export async function status(flags: StatusFlags): Promise<void> {
74
86
  // replaces it. Silently turning a string into an object would be a cost
75
87
  // paid by every reader for the benefit of none.
76
88
  const local = `http://${runtime.config.instance.host}:${runtime.config.instance.port}/mcp`;
77
- const deployment = deploymentIdentity(runtime.config.targets[runtime.target]?.deploy);
89
+ const deployment = deploymentIdentity(runtime.declared.deploy);
78
90
  const endpoint = deployment ? null : local;
79
91
 
80
92
  return emit(
@@ -102,7 +114,14 @@ export async function status(flags: StatusFlags): Promise<void> {
102
114
  connection.state === 'active'
103
115
  ? style.green(connection.state)
104
116
  : style.yellow(connection.state),
105
- style.dim(connection.account),
117
+ // Both, where they differ. The label is what the operator called
118
+ // it and the account is which mailbox it is; a row answering only
119
+ // one of those is the row this column already was.
120
+ style.dim(
121
+ connection.label && connection.label !== connection.account
122
+ ? `${connection.label} — ${connection.account}`
123
+ : connection.account,
124
+ ),
106
125
  ]),
107
126
  );
108
127
  }
@@ -142,97 +161,103 @@ export async function status(flags: StatusFlags): Promise<void> {
142
161
  * profile narrows it to the detailed view above; naming none is not a missing
143
162
  * answer, it is the wider one.
144
163
  *
145
- * **Opens no store and makes no network call.** The view above already declines
146
- * to probe, for the reasons in this file's header; this one additionally
147
- * declines to open a target's adapters, so it stays instant and — more to the
148
- * point — still answers for a target whose stores are unreachable, misdeclared,
149
- * or gone. A summary that needs the deployment to be healthy cannot report that
150
- * it is not.
164
+ * **Opens no profile store.** The view above already declines to probe, for the
165
+ * reasons in this file's header; this one additionally declines to open a
166
+ * target's adapters, so it stays fast and — more to the point — still answers
167
+ * for a target whose stores are unreachable or gone. A summary that needs the
168
+ * deployment to be healthy cannot report that it is not.
151
169
  *
152
- * That is what makes the row it exists for legible: a target declared by one
153
- * profile and not its sibling. From inside either profile that state is
154
- * invisible, and it is what a vanished deployment looks like from the outside.
170
+ * It does read the target's workspace, which for a pointer is a bucket. That is
171
+ * the one call it cannot avoid: the profiles it is reporting on live there
172
+ * (ADR-052). When that read fails it says so under the target's own heading
173
+ * rather than printing an empty profile list, because "no profiles" and "could
174
+ * not look" are the two answers this command must never blur.
175
+ *
176
+ * The row it used to exist for — a target declared by one profile and not its
177
+ * sibling — cannot happen now. A target is declared once, by its workspace, so
178
+ * there is no per-profile disagreement left to surface.
155
179
  */
156
180
  async function workspaceStatus(flags: StatusFlags): Promise<void> {
157
181
  const target = flags.target!;
158
- const root = resolveWorkspaceRoot();
159
- const workspace = await loadWorkspaceProfiles(root);
160
-
161
- const profiles = workspace.loaded.map((entry) => ({
162
- name: entry.profile,
163
- declares: target in entry.config.targets,
164
- connections: entry.config.connections.length,
165
- deployment: deploymentIdentity(entry.config.targets[target]?.deploy),
166
- }));
182
+ const localRoot = resolveWorkspaceRoot();
183
+ const registry = await readRegistry(localRoot);
184
+ const entry = registry[target];
167
185
 
168
- const declaring = profiles.filter((entry) => entry.declares);
186
+ if (!entry) throw notInRegistry(target, registry, localRoot);
169
187
 
170
- // Distinct deployments, not the first one found. Two profiles declaring the
171
- // same target name against different services is drift worth printing rather
172
- // than a tie to break silently — and picking one would make the wrong half of
173
- // the workspace look correctly configured.
174
- const deployments = [
175
- ...new Map(
176
- declaring
177
- .filter((entry) => entry.deployment !== null)
178
- .map((entry) => [JSON.stringify(entry.deployment), entry.deployment!] as const),
179
- ).values(),
180
- ];
188
+ let resolved: ResolvedTarget | undefined;
189
+ let unreachable: string | undefined;
190
+ try {
191
+ resolved = await openTarget(localRoot, target);
192
+ } catch (error) {
193
+ // The whole message, not its first line. Truncating to `split('\n')[0]` is
194
+ // exactly what the sync command used to do, and it is why a bucket refusing
195
+ // for a nameable reason reported an empty one instead — a `ConfigError`
196
+ // carries its fix on the lines after the first.
197
+ unreachable = error instanceof Error ? error.message : String(error);
198
+ }
199
+
200
+ const root = resolved?.workspaceRoot ?? (isPointer(entry) ? entry.workspace : localRoot);
201
+ const workspace = resolved ? await loadWorkspaceProfiles(root) : undefined;
202
+
203
+ const profiles = (workspace?.loaded ?? []).map((loaded) => ({
204
+ name: loaded.profile,
205
+ connections: loaded.config.connections.length,
206
+ }));
207
+
208
+ const deployment = deploymentIdentity(resolved?.declared.deploy);
181
209
 
182
210
  return emit(
183
211
  flags.json,
184
- { workspace: root, target, profiles, deployments, unreadable: workspace.unreadable },
212
+ {
213
+ workspace: root,
214
+ target,
215
+ remote: resolved?.remote ?? isPointer(entry),
216
+ reachable: unreachable === undefined,
217
+ ...(unreachable ? { error: unreachable } : {}),
218
+ profiles,
219
+ deployment,
220
+ unreadable: workspace?.unreadable ?? [],
221
+ },
185
222
  () => {
186
223
  print(style.dim(`workspace ${style.bold(root)} target ${style.bold(target)}`));
187
224
 
188
- heading('Profiles');
189
- table(
190
- profiles.map((entry) => [
191
- ` ${style.bold(entry.name)}`,
192
- entry.declares ? style.green(`${target} declared`) : style.yellow('not declared'),
193
- style.dim(entry.declares ? `${entry.connections} connection(s)` : '—'),
194
- ]),
195
- );
196
- for (const bad of workspace.unreadable) {
197
- print(` ${style.bold(bad.profile)} ${style.yellow(bad.reason)}`);
198
- }
199
-
200
- heading('Endpoint');
201
- if (declaring.length === 0) {
202
- print(style.yellow(` No profile declares "${target}".`));
203
- print(
204
- style.dim(
205
- ' If it was deployed once, the deployment still exists and only the\n' +
206
- ` declaration is gone — recover it with: lanes link sync targets --target ${target}`,
207
- ),
208
- );
225
+ if (unreachable !== undefined) {
226
+ heading('Unreachable');
227
+ // The refusal's own wording, and nothing after it. A generic trailer
228
+ // here read as a second, vaguer diagnosis of a problem the message above
229
+ // had already named precisely.
230
+ for (const line of unreachable.split('\n')) print(` ${style.yellow(line)}`);
209
231
  return;
210
232
  }
211
233
 
212
- if (deployments.length === 0) {
213
- print(style.dim(' No deployment this target runs wherever the CLI does.'));
214
- return;
234
+ heading('Profiles');
235
+ if (profiles.length === 0) {
236
+ print(style.dim(' None yet.'));
237
+ print(style.dim(` Create one with: lanes link profile add <name> --target ${target}`));
238
+ } else {
239
+ table(
240
+ profiles.map((profile) => [
241
+ ` ${style.bold(profile.name)}`,
242
+ style.dim(`${profile.connections} connection(s)`),
243
+ ]),
244
+ );
215
245
  }
216
-
217
- for (const deployment of deployments) {
218
- const { platform, service, region } = deployment;
219
- print(` ${platform} service ${style.bold(service)} in ${region}`);
246
+ for (const bad of workspace?.unreadable ?? []) {
247
+ print(` ${style.bold(bad.profile)} ${style.yellow(bad.reason)}`);
220
248
  }
221
249
 
222
- if (deployments.length > 1) {
223
- print(
224
- style.yellow(
225
- ` ${deployments.length} profiles declare "${target}" against different services.`,
226
- ),
227
- );
228
- print(style.dim(' They are separate endpoints sharing a name. Check each profile.'));
250
+ heading('Endpoint');
251
+ if (!deployment) {
252
+ print(style.dim(' No deployment — this target runs wherever the CLI does.'));
229
253
  return;
230
254
  }
231
255
 
256
+ print(` ${deployment.platform} service ${style.bold(deployment.service)} in ${deployment.region}`);
232
257
  print(
233
258
  style.dim(
234
259
  " the address is the platform's to assign — run: " +
235
- `lanes link outputs --target ${target} --profile ${declaring[0]!.name}`,
260
+ `lanes link outputs --target ${target} --profile ${profiles[0]?.name ?? '<name>'}`,
236
261
  ),
237
262
  );
238
263
  },
@@ -28,7 +28,7 @@ export async function tools(flags: ToolsFlags): Promise<void> {
28
28
 
29
29
  try {
30
30
  const { token } = await ensureProfileToken(runtime.credentials, runtime.config.auth.token_ref);
31
- const declared = runtime.config.targets[runtime.target]?.deploy;
31
+ const declared = runtime.declared.deploy;
32
32
  const deployed = await deployedUrl(declared);
33
33
  // Not `endpointUrl`, which asks the platform a second time for an answer
34
34
  // this line already has.
@@ -82,8 +82,17 @@ export interface RemovalPlan {
82
82
  }
83
83
 
84
84
  export interface PlanOptions {
85
- /** Restrict to one target. The profile itself then survives. */
86
- readonly target?: string | undefined;
85
+ /**
86
+ * The target whose stores this plans against.
87
+ *
88
+ * Required now, and not a restriction: a profile lives in exactly one target
89
+ * (ADR-052), so there is no "all of them" left for this to mean. It used to be
90
+ * optional because a profile could declare several and removing it meant
91
+ * emptying each.
92
+ */
93
+ readonly target: string;
94
+ /** That target's adapter set, from the workspace declaring it (ADR-052). */
95
+ readonly declared: TargetConfig;
87
96
  readonly openSecrets: (target: string) => Promise<SecretStore>;
88
97
  readonly openBlobs: (target: string, area?: string) => Promise<BlobStore>;
89
98
  readonly readDefaultProfile?: (() => Promise<string | undefined>) | undefined;
@@ -110,14 +119,13 @@ export async function removalPlan(
110
119
  const untouched: { target: string; refs: string[] }[] = [];
111
120
  const warnings: string[] = [];
112
121
 
113
- const names = options.target ? [options.target] : Object.keys(config.targets);
122
+ // One target, always. A profile lived in as many as it declared and this
123
+ // planned across all of them; it lives in exactly one now (ADR-052), and that
124
+ // one is whichever workspace the caller resolved to reach this file.
125
+ const names: string[] = [options.target];
126
+ const declared = options.declared;
114
127
 
115
128
  for (const name of names) {
116
- const declared = config.targets[name];
117
- if (!declared) {
118
- warnings.push(`Target "${name}" is not declared by this profile, so nothing was planned.`);
119
- continue;
120
- }
121
129
 
122
130
  // A repository this profile keeps memory and skills in is not this
123
131
  // command's to empty, and it is not reachable from here either: the routing
@@ -128,9 +136,9 @@ export async function removalPlan(
128
136
  // `rm -r data/<profile>` used to be the whole of "what could this profile
129
137
  // reach". So it is said before the operator confirms rather than discovered
130
138
  // afterwards.
131
- if (declared.knowledge) {
139
+ if (config.knowledge) {
132
140
  warnings.push(
133
- `Target "${name}" keeps this profile's memory and skills in ${declared.knowledge.repo}. ` +
141
+ `This profile keeps its memory and skills in ${config.knowledge.repo}. ` +
134
142
  'Nothing here touches a repository, so they survive this removal — delete them there ' +
135
143
  'if you want them gone.',
136
144
  );
@@ -226,23 +234,26 @@ export async function removalPlan(
226
234
  }
227
235
  }
228
236
 
229
- // Only when the whole profile is going. With `--target` it still exists.
230
- if (!options.target) {
231
- const defaultProfile = await options.readDefaultProfile?.();
232
- if (defaultProfile === profile) {
233
- items.push({
234
- target: null,
235
- kind: 'workspace-key',
236
- id: 'default_profile',
237
- note: 'cleared, not repointed at whatever remains',
238
- });
239
- }
240
-
241
- // Last. It is the only record of where everything else lives, so a failure
242
- // before this point leaves data a later run can still find.
243
- items.push({ target: null, kind: 'config', id: profilePath(root, profile) });
237
+ // Always, now. `--target` used to mean "decommission this one target and leave
238
+ // the profile behind", which was a coherent thing to want while a profile
239
+ // could be declared against several. It lives in exactly one (ADR-052), and
240
+ // the file itself is *in* that target's workspace — so emptying the target and
241
+ // keeping the profile would leave a config nothing can open, in a workspace it
242
+ // no longer belongs to.
243
+ const defaultProfile = await options.readDefaultProfile?.();
244
+ if (defaultProfile === profile) {
245
+ items.push({
246
+ target: null,
247
+ kind: 'workspace-key',
248
+ id: 'default_profile',
249
+ note: 'cleared, not repointed at whatever remains',
250
+ });
244
251
  }
245
252
 
253
+ // Last. It is the only record of where everything else lives, so a failure
254
+ // before this point leaves data a later run can still find.
255
+ items.push({ target: null, kind: 'config', id: profilePath(root, profile) });
256
+
246
257
  return { profile, items, untouched, warnings };
247
258
  }
248
259
 
@@ -3,6 +3,7 @@ import type { BlobStore } from '#stores/blobs';
3
3
  import {
4
4
  ConfigError,
5
5
  WORKSPACE_FILE,
6
+ openTarget,
6
7
  readWorkspace,
7
8
  readWorkspaceFile,
8
9
  workspaceFiles,
@@ -250,15 +251,17 @@ export interface RemoveFlags extends GlobalFlags {
250
251
  * in the tool.
251
252
  */
252
253
  export async function removeProfile(name: string, flags: RemoveFlags): Promise<void> {
253
- const { selection, config } = await resolveProfileOnly({ ...flags, profile: name });
254
+ const { selection, config, target } = await resolveProfileOnly({ ...flags, profile: name });
254
255
  announceProfile(selection);
255
256
 
256
257
  const root = selection.workspaceRoot;
257
258
  const registry = await buildRegistryWithWorkspace(root, name);
258
259
  const files = workspaceFiles(root);
260
+ const { declared } = await openTarget(root, target);
259
261
 
260
262
  const plan = await removalPlan(config, root, name, registry, {
261
- target: flags.target,
263
+ target,
264
+ declared,
262
265
  openSecrets: (target) => openSecretStoreFor(config, root, target),
263
266
  openBlobs: (target, area) => openBlobStoreFor(config, root, target, area),
264
267
  readDefaultProfile: async () => (await readWorkspace(root))?.default_profile,