@lanes-sh/link 0.5.4 → 0.6.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) 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/target-note.ts +2 -2
  5. package/src/cli/commands/knowledge/index.ts +14 -34
  6. package/src/cli/commands/mcp/register.ts +1 -1
  7. package/src/cli/commands/operate/dashboard.ts +2 -2
  8. package/src/cli/commands/operate/inspect.ts +5 -1
  9. package/src/cli/commands/operate/migrate.ts +85 -1
  10. package/src/cli/commands/operate/outputs.ts +7 -22
  11. package/src/cli/commands/operate/status.ts +84 -69
  12. package/src/cli/commands/operate/tools.ts +1 -1
  13. package/src/cli/commands/profile/removal.ts +36 -25
  14. package/src/cli/commands/profile/remove.ts +5 -2
  15. package/src/cli/commands/profile.ts +56 -40
  16. package/src/cli/commands/sync.ts +94 -162
  17. package/src/cli/commands/target.ts +115 -74
  18. package/src/cli/commands/update.ts +56 -1
  19. package/src/cli/config-edit.ts +47 -16
  20. package/src/cli/endpoint-url.ts +3 -3
  21. package/src/cli/main.ts +9 -7
  22. package/src/cli/migrate-plan.ts +160 -0
  23. package/src/cli/publish.ts +4 -2
  24. package/src/cli/runtime/open.ts +19 -10
  25. package/src/cli/runtime/select.ts +69 -22
  26. package/src/cli/selection-require.ts +79 -0
  27. package/src/cli/selection.ts +44 -92
  28. package/src/cli/workspace-migrate.ts +263 -0
  29. package/src/deployments/bootstrap.ts +31 -11
  30. package/src/deployments/deploy.ts +103 -27
  31. package/src/deployments/knowledge.ts +5 -2
  32. package/src/deployments/prepare.ts +3 -1
  33. package/src/deployments/serving.ts +19 -15
  34. package/src/deployments/upload.ts +10 -1
  35. package/src/profile/deployments.ts +64 -53
  36. package/src/profile/index.ts +22 -6
  37. package/src/profile/legacy.ts +92 -0
  38. package/src/profile/load.ts +12 -28
  39. package/src/profile/registry.ts +182 -0
  40. package/src/profile/schema.ts +162 -110
  41. package/src/profile/targets.ts +62 -91
  42. package/src/profile/testing.ts +78 -0
  43. package/src/profile/workspace.ts +11 -25
  44. package/src/server/dashboard.ts +4 -1
  45. package/src/server/harness.ts +1 -6
  46. package/src/cli/commands/profile/declare.ts +0 -154
  47. package/src/deployments/servable.ts +0 -82
  48. package/src/deployments/sync-apply.ts +0 -330
  49. package/src/deployments/sync.ts +0 -164
@@ -68,7 +68,7 @@ stops a write is policy on the endpoint:
68
68
 
69
69
  ```console
70
70
  $ lanes link policy deny memory.write --profile <name> --target <name>
71
- $ lanes link policy list --profile <name>
71
+ $ lanes link policy list --profile <name> --target <name>
72
72
  ```
73
73
 
74
74
  If you are running against a profile that grants writes, that is the owner's
@@ -22,7 +22,9 @@ and personal apart; they share no database and no credential store.
22
22
  **Ask which profile is meant when it is ambiguous. Do not default to whichever
23
23
  is listed first.** Quietly picking one crosses the line the profile exists to
24
24
  draw. There is no "current profile" to switch — the choice is made per call, and
25
- `lanes link profile list` shows what exists.
25
+ `lanes link profile list --target <name>` shows what exists *in that target*. A
26
+ profile lives in exactly one, so `personal` on `local` and `personal` on `cloud`
27
+ are two profiles that share a name rather than one profile in two places.
26
28
 
27
29
  **What a command must be told is never inferred — but it is not always both.**
28
30
  Nothing resolves from an environment variable or a config default, so a command
@@ -30,12 +32,11 @@ missing what it needs refuses rather than acting somewhere else. Passing a flag
30
32
  command does not read is refused too, which makes "add both to be safe" its own
31
33
  failure. Four levels:
32
34
 
33
- - **Neither.** `lanes link profile list`, `lanes link mcp list`,
35
+ - **Neither.** `lanes link target list`, `lanes link mcp list`,
34
36
  `lanes link version`.
35
- - **`--profile` alone.** `lanes link check`, `lanes link config show`,
36
- `lanes link policy list`, `lanes link target list --profile <name>`,
37
- `lanes link identity list`. Each is target-independent one declaration in the
38
- YAML that applies wherever the profile runs.
37
+ - **`--target` alone.** `lanes link profile list`, `lanes link profile add`,
38
+ `lanes link profile remove`, `lanes link target show`. A profile lives inside
39
+ one target's workspace, so listing or creating one names which workspace.
39
40
  - **`--target`, with the profiles derived from it.** `lanes link status`,
40
41
  `lanes link deploy` and `lanes link sync targets` act on one endpoint serving
41
42
  every profile that declares that target. `--profile` is accepted and *narrows*
@@ -222,7 +223,7 @@ granted, and retrying will not reveal it. A call that *is* refused was refused b
222
223
  policy on purpose.
223
224
 
224
225
  Report it plainly and let the owner decide whether to widen the grant —
225
- `lanes link policy list --profile <name>` shows the rules, `lanes link policy allow <capability> --profile <name> --target <name>`
226
+ `lanes link policy list --profile <name> --target <name>` shows the rules, `lanes link policy allow <capability> --profile <name> --target <name>`
226
227
  changes them, and that is their call, not yours. **Do not look for another route
227
228
  to the same data.** Every call is audited either way; `lanes link audit tail --profile <name> --target <name>`
228
229
  shows what was attempted, refusals included.
@@ -299,10 +300,12 @@ key on — do not treat the absence of JSON as a failure. `status`, `doctor`,
299
300
  `config show`, `audit tail` and every `mcp` subcommand do not.
300
301
 
301
302
  **A profile is created and removed, never switched.** `lanes link profile add
302
- <name>` writes a new one and takes `--target <name>`, repeated once per target it
303
- should declare. `lanes link profile remove <name>` takes `--dry-run` and then
304
- `--yes`; given a `--target` it decommissions that one target's stores and leaves
305
- the profile file in place. Neither reads `--profile`.
303
+ <name> --target <name>` writes a new one *into that target's workspace* one
304
+ target, not a list, because a profile lives in exactly one.
305
+ `lanes link profile remove <name> --target <name>` takes `--dry-run` and then
306
+ `--yes`, and removes the profile itself along with its stores: the file is in
307
+ that workspace, so there is nowhere left for it to survive. Neither reads
308
+ `--profile`; both name the profile positionally.
306
309
 
307
310
  There is no current profile and no default target. `lanes link profile default`
308
311
  and `lanes link target use` are gone and now refuse with an explanation — if you
@@ -312,9 +315,9 @@ replacement to find. The choice is made per command, on purpose.
312
315
  ## Deploying, and what it decides
313
316
 
314
317
  `lanes link deploy` builds an image and rolls a revision. Its subject is a
315
- **target**, and the profiles behind it are every profile declaring that target,
316
- so one deploy serves all of them — there is no per-profile deploy to run and no
317
- reason to loop over them.
318
+ **target**, and the profiles behind it are every profile *in* that target's
319
+ workspace, so one deploy serves all of them — there is no per-profile deploy to
320
+ run and no reason to loop over them.
318
321
 
319
322
  **Always `--dry-run` first, and show what it printed.** It creates cloud
320
323
  resources that cost money, it implements no `--json` to inspect instead, and that
@@ -326,9 +329,9 @@ Two things it refuses to guess, and both are the owner's to answer:
326
329
  - **Whose bearer token opens the endpoint.** One token reaches every profile
327
330
  behind that target, so this decides who gets in. With several candidates and
328
331
  nothing recorded, it refuses and prints the command that names one.
329
- - **A first deploy.** A target no profile declares yet has no set to derive from,
330
- so `--profile` is required there. It may be repeated, and the first one named
331
- is the primary.
332
+ - **A first deploy.** A target that does not exist yet has no workspace to derive
333
+ a set from, so `--profile` is required there. It may be repeated, and the first
334
+ one named is the primary.
332
335
 
333
336
  **Never pass `--yes`, `--non-interactive`, `--access public` or
334
337
  `--service-account` yourself.** Each settles a question about who can reach their
@@ -338,22 +341,23 @@ this file already applies to `--accept-broad-scopes`.
338
341
  Deploying is how new code reaches the endpoint. It is not how an account gets
339
342
  connected and not how a config change lands; both of those publish themselves.
340
343
 
341
- ## When the workspace and the endpoint disagree
344
+ ## When a machine has lost track of a deployment
342
345
 
343
- A deployment records where it lives, and a workspace can lose that record a new
344
- machine, a reinstall, a profile file restored from something older. The endpoint
345
- is still serving; what went missing is the config that describes it. The symptom
346
- is a `lanes link status` that reports nothing deployed for a target you know is
347
- up.
346
+ A workspace holds a *pointer* to each target it does not itself hold, and a
347
+ machine can lose one — a new laptop, a reinstall, a workspace file restored from
348
+ something older. The endpoint is still serving; what went missing is the line
349
+ saying where it lives. The symptom is a `lanes link status` that reports nothing
350
+ for a target you know is up.
348
351
 
349
- `lanes link sync targets` reconciles the two. `--discover` looks for a deployment
350
- the workspace has no record of, `--from <location>` names one directly, and
351
- `--dry-run` reports what it would merge without merging it. Run the dry run and
352
+ `lanes link sync targets` adopts it. `--discover` looks for a deployment the
353
+ workspace has no pointer to, `--from <location>` names one directly, and
354
+ `--dry-run` reports what it would write without writing it. Run the dry run and
352
355
  show it.
353
356
 
354
- **`--prefer local` or `--prefer remote` is their answer, not yours.** It decides
355
- which side wins where the two disagree, and the losing value is the one nobody
356
- was asked about. Report what differs and let them pick.
357
+ **It cannot lose anything.** Adopting writes one line the pointer — and the
358
+ workspace at the other end stays authoritative for everything else. There is no
359
+ `--prefer` any more and passing one is refused: it chose a winner when a profile
360
+ existed in two copies that could disagree, and there is one copy now.
357
361
 
358
362
  ## Registering it, and re-registering it
359
363
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lanes-sh/link",
3
- "version": "0.5.4",
3
+ "version": "0.6.1",
4
4
  "description": "A self-hostable MCP gateway for all your connections, memory, tasks, files, and secrets",
5
5
  "license": "Apache-2.0",
6
6
  "homepage": "https://lanes.sh/link",
@@ -66,6 +66,7 @@
66
66
  "#dispatch": "./src/dispatch/index.ts",
67
67
  "#policy": "./src/policy/index.ts",
68
68
  "#profile": "./src/profile/index.ts",
69
+ "#profile/*": "./src/profile/*",
69
70
  "#providers/*": "./src/providers/*",
70
71
  "#registry": "./src/registry/index.ts",
71
72
  "#secrets": "./src/secrets/index.ts",
@@ -1,4 +1,4 @@
1
- import type { Config, Resolution } from '#profile';
1
+ import type { Resolution } from '#profile';
2
2
  import { announce } from '../../output.ts';
3
3
 
4
4
  /**
@@ -21,7 +21,7 @@ import { announce } from '../../output.ts';
21
21
  * is the same mistake caught where it is still cheap.
22
22
  */
23
23
  export function announceConnectTarget(
24
- runtime: { readonly resolution: Resolution; readonly config: Pick<Config, 'targets'> },
24
+ runtime: { readonly resolution: Resolution },
25
25
  json?: boolean | undefined,
26
26
  ): void {
27
27
  // `emit`'s early return only protects lines printed *at* the emit, and this
@@ -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';
@@ -77,7 +86,7 @@ export async function status(flags: StatusFlags): Promise<void> {
77
86
  // replaces it. Silently turning a string into an object would be a cost
78
87
  // paid by every reader for the benefit of none.
79
88
  const local = `http://${runtime.config.instance.host}:${runtime.config.instance.port}/mcp`;
80
- const deployment = deploymentIdentity(runtime.config.targets[runtime.target]?.deploy);
89
+ const deployment = deploymentIdentity(runtime.declared.deploy);
81
90
  const endpoint = deployment ? null : local;
82
91
 
83
92
  return emit(
@@ -152,97 +161,103 @@ export async function status(flags: StatusFlags): Promise<void> {
152
161
  * profile narrows it to the detailed view above; naming none is not a missing
153
162
  * answer, it is the wider one.
154
163
  *
155
- * **Opens no store and makes no network call.** The view above already declines
156
- * to probe, for the reasons in this file's header; this one additionally
157
- * declines to open a target's adapters, so it stays instant and — more to the
158
- * point — still answers for a target whose stores are unreachable, misdeclared,
159
- * or gone. A summary that needs the deployment to be healthy cannot report that
160
- * 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.
161
169
  *
162
- * That is what makes the row it exists for legible: a target declared by one
163
- * profile and not its sibling. From inside either profile that state is
164
- * 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.
165
179
  */
166
180
  async function workspaceStatus(flags: StatusFlags): Promise<void> {
167
181
  const target = flags.target!;
168
- const root = resolveWorkspaceRoot();
169
- const workspace = await loadWorkspaceProfiles(root);
170
-
171
- const profiles = workspace.loaded.map((entry) => ({
172
- name: entry.profile,
173
- declares: target in entry.config.targets,
174
- connections: entry.config.connections.length,
175
- deployment: deploymentIdentity(entry.config.targets[target]?.deploy),
176
- }));
182
+ const localRoot = resolveWorkspaceRoot();
183
+ const registry = await readRegistry(localRoot);
184
+ const entry = registry[target];
177
185
 
178
- const declaring = profiles.filter((entry) => entry.declares);
186
+ if (!entry) throw notInRegistry(target, registry, localRoot);
179
187
 
180
- // Distinct deployments, not the first one found. Two profiles declaring the
181
- // same target name against different services is drift worth printing rather
182
- // than a tie to break silently — and picking one would make the wrong half of
183
- // the workspace look correctly configured.
184
- const deployments = [
185
- ...new Map(
186
- declaring
187
- .filter((entry) => entry.deployment !== null)
188
- .map((entry) => [JSON.stringify(entry.deployment), entry.deployment!] as const),
189
- ).values(),
190
- ];
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);
191
209
 
192
210
  return emit(
193
211
  flags.json,
194
- { 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
+ },
195
222
  () => {
196
223
  print(style.dim(`workspace ${style.bold(root)} target ${style.bold(target)}`));
197
224
 
198
- heading('Profiles');
199
- table(
200
- profiles.map((entry) => [
201
- ` ${style.bold(entry.name)}`,
202
- entry.declares ? style.green(`${target} declared`) : style.yellow('not declared'),
203
- style.dim(entry.declares ? `${entry.connections} connection(s)` : '—'),
204
- ]),
205
- );
206
- for (const bad of workspace.unreadable) {
207
- print(` ${style.bold(bad.profile)} ${style.yellow(bad.reason)}`);
208
- }
209
-
210
- heading('Endpoint');
211
- if (declaring.length === 0) {
212
- print(style.yellow(` No profile declares "${target}".`));
213
- print(
214
- style.dim(
215
- ' If it was deployed once, the deployment still exists and only the\n' +
216
- ` declaration is gone — recover it with: lanes link sync targets --target ${target}`,
217
- ),
218
- );
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)}`);
219
231
  return;
220
232
  }
221
233
 
222
- if (deployments.length === 0) {
223
- print(style.dim(' No deployment this target runs wherever the CLI does.'));
224
- 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
+ );
225
245
  }
226
-
227
- for (const deployment of deployments) {
228
- const { platform, service, region } = deployment;
229
- 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)}`);
230
248
  }
231
249
 
232
- if (deployments.length > 1) {
233
- print(
234
- style.yellow(
235
- ` ${deployments.length} profiles declare "${target}" against different services.`,
236
- ),
237
- );
238
- 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.'));
239
253
  return;
240
254
  }
241
255
 
256
+ print(` ${deployment.platform} service ${style.bold(deployment.service)} in ${deployment.region}`);
242
257
  print(
243
258
  style.dim(
244
259
  " the address is the platform's to assign — run: " +
245
- `lanes link outputs --target ${target} --profile ${declaring[0]!.name}`,
260
+ `lanes link outputs --target ${target} --profile ${profiles[0]?.name ?? '<name>'}`,
246
261
  ),
247
262
  );
248
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.