@lanes-sh/link 0.2.2 → 0.3.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 (143) hide show
  1. package/README.md +22 -8
  2. package/instructions/skills/lanes-link/SKILL.md +42 -14
  3. package/package.json +1 -1
  4. package/src/cli/argv.ts +50 -0
  5. package/src/cli/brand.ts +178 -0
  6. package/src/cli/callback-page.ts +108 -128
  7. package/src/cli/commands/connect/accounts.ts +5 -0
  8. package/src/cli/commands/connect/assertion.ts +187 -0
  9. package/src/cli/commands/connect/authorise.ts +61 -17
  10. package/src/cli/commands/connect/client.ts +37 -9
  11. package/src/cli/commands/connect/discover.ts +94 -0
  12. package/src/cli/commands/connect/family.ts +72 -0
  13. package/src/cli/commands/connect/index.ts +113 -115
  14. package/src/cli/commands/connect/method.ts +237 -0
  15. package/src/cli/commands/connect/outcome.ts +42 -1
  16. package/src/cli/commands/connect/pasted-token.ts +66 -0
  17. package/src/cli/commands/connect/requirements.ts +60 -8
  18. package/src/cli/commands/connect/setup.ts +16 -5
  19. package/src/cli/commands/connect/target-note.ts +34 -0
  20. package/src/cli/commands/identity.ts +258 -0
  21. package/src/cli/commands/knowledge/index.ts +390 -0
  22. package/src/cli/commands/knowledge/migrate.ts +180 -0
  23. package/src/cli/commands/knowledge/setup.ts +144 -0
  24. package/src/cli/commands/knowledge.ts +10 -0
  25. package/src/cli/commands/mcp/harnesses.ts +16 -2
  26. package/src/cli/commands/mcp/register.ts +9 -1
  27. package/src/cli/commands/mcp/stdio.ts +21 -0
  28. package/src/cli/commands/operate/dashboard.ts +107 -0
  29. package/src/cli/commands/operate/findings.ts +151 -0
  30. package/src/cli/commands/operate/inspect.ts +63 -164
  31. package/src/cli/commands/operate/outputs.ts +38 -11
  32. package/src/cli/commands/operate/policy.ts +7 -7
  33. package/src/cli/commands/operate/serve.ts +3 -0
  34. package/src/cli/commands/operate/status.ts +108 -1
  35. package/src/cli/commands/operate/token.ts +1 -1
  36. package/src/cli/commands/operate.ts +2 -0
  37. package/src/cli/commands/profile/declare.ts +154 -0
  38. package/src/cli/commands/profile/removal.ts +17 -0
  39. package/src/cli/commands/profile/remove.ts +5 -5
  40. package/src/cli/commands/profile.ts +83 -35
  41. package/src/cli/commands/secrets.ts +6 -6
  42. package/src/cli/commands/setup.ts +22 -6
  43. package/src/cli/commands/sync.ts +262 -0
  44. package/src/cli/commands/target.ts +65 -83
  45. package/src/cli/config-edit.ts +53 -144
  46. package/src/cli/config-repair.ts +186 -0
  47. package/src/cli/dashboard-page.ts +284 -0
  48. package/src/cli/dashboard-shell.ts +125 -0
  49. package/src/cli/dispatch-owner.ts +93 -0
  50. package/src/cli/identity.ts +12 -1
  51. package/src/cli/main.ts +90 -61
  52. package/src/cli/nearest.ts +45 -0
  53. package/src/cli/oauth-callback.ts +187 -0
  54. package/src/cli/oauth-exchange.ts +57 -15
  55. package/src/cli/oauth.ts +67 -177
  56. package/src/cli/output.ts +21 -5
  57. package/src/cli/provider-marks.ts +45 -0
  58. package/src/cli/runtime/open.ts +74 -51
  59. package/src/cli/runtime/registry.ts +60 -2
  60. package/src/cli/runtime/select.ts +26 -13
  61. package/src/cli/runtime/vault.ts +61 -0
  62. package/src/cli/runtime.ts +2 -1
  63. package/src/cli/selection.ts +365 -0
  64. package/src/cli/usage.ts +42 -11
  65. package/src/connectivity/auth/README.md +7 -1
  66. package/src/connectivity/auth/basic/index.ts +1 -1
  67. package/src/connectivity/auth/index.ts +14 -0
  68. package/src/connectivity/auth/oauth-authcode/broker.ts +26 -0
  69. package/src/connectivity/auth/oauth-authcode/index.ts +16 -2
  70. package/src/connectivity/auth/oauth-authcode/provider.ts +1 -1
  71. package/src/connectivity/auth/oauth-authcode/refresh.ts +3 -3
  72. package/src/connectivity/auth/oauth-jwt/README.md +33 -0
  73. package/src/connectivity/auth/oauth-jwt/index.ts +237 -0
  74. package/src/connectivity/auth/oauth-jwt/key.ts +148 -0
  75. package/src/connectivity/auth/resolve.ts +1 -1
  76. package/src/connectivity/auth/token.ts +11 -0
  77. package/src/connectivity/index.ts +2 -0
  78. package/src/connectivity/manifest/auth.ts +99 -2
  79. package/src/connectivity/manifest/identity.ts +12 -0
  80. package/src/connectivity/manifest/index.ts +3 -1
  81. package/src/connectivity/manifest/provider.ts +37 -8
  82. package/src/connectivity/manifest/requirements.ts +109 -6
  83. package/src/deployments/adapters/filesystem.ts +10 -1
  84. package/src/deployments/adapters/github-api.ts +106 -0
  85. package/src/deployments/adapters/github-commit.ts +103 -0
  86. package/src/deployments/adapters/github-repo.ts +356 -0
  87. package/src/deployments/adapters/github-testing.ts +258 -0
  88. package/src/deployments/adapters/github.ts +125 -0
  89. package/src/deployments/deploy.ts +94 -114
  90. package/src/deployments/discover.ts +103 -0
  91. package/src/deployments/driver.ts +8 -1
  92. package/src/deployments/gcp/driver.ts +3 -1
  93. package/src/deployments/knowledge.ts +119 -0
  94. package/src/deployments/prepare.ts +12 -6
  95. package/src/deployments/report.ts +117 -0
  96. package/src/deployments/servable.ts +82 -0
  97. package/src/deployments/serving.ts +165 -0
  98. package/src/deployments/sync-apply.ts +276 -0
  99. package/src/deployments/sync.ts +136 -0
  100. package/src/deployments/target.ts +3 -2
  101. package/src/deployments/upload.ts +19 -12
  102. package/src/dispatch/dispatch.ts +1 -1
  103. package/src/profile/deployments.ts +80 -0
  104. package/src/profile/identity.ts +60 -0
  105. package/src/profile/index.ts +23 -5
  106. package/src/profile/knowledge.ts +124 -0
  107. package/src/profile/load.ts +17 -5
  108. package/src/profile/primitives.ts +24 -1
  109. package/src/profile/schema.ts +81 -3
  110. package/src/profile/targets.ts +122 -109
  111. package/src/profile/workspace.ts +139 -79
  112. package/src/providers/google/calendar/index.ts +2 -0
  113. package/src/providers/google/contacts/index.ts +2 -0
  114. package/src/providers/google/docs/index.ts +2 -0
  115. package/src/providers/google/drive/index.ts +2 -0
  116. package/src/providers/google/gmail/index.ts +2 -0
  117. package/src/providers/google/gmail-imap/index.ts +125 -0
  118. package/src/providers/google/index.ts +2 -1
  119. package/src/providers/google/shared/oauth.ts +18 -6
  120. package/src/providers/google/shared/service-account.ts +110 -0
  121. package/src/providers/google/shared/setup.ts +21 -3
  122. package/src/providers/google/sheets/index.ts +2 -0
  123. package/src/providers/google/tasks/index.ts +2 -0
  124. package/src/providers/identity/provider.ts +166 -0
  125. package/src/providers/index.ts +3 -0
  126. package/src/providers/owner.ts +10 -2
  127. package/src/providers/scopes.ts +2 -0
  128. package/src/providers/setup/plan.ts +31 -9
  129. package/src/providers/setup/provider.ts +23 -0
  130. package/src/providers/slack/index.ts +81 -33
  131. package/src/providers/slack/oauth.ts +103 -0
  132. package/src/providers/slack/scopes.ts +37 -0
  133. package/src/server/container.ts +18 -1
  134. package/src/server/cors.ts +252 -0
  135. package/src/server/dashboard.ts +208 -0
  136. package/src/server/endpoint.ts +45 -1
  137. package/src/server/generations.ts +11 -2
  138. package/src/server/harness.ts +7 -0
  139. package/src/server/index.ts +45 -6
  140. package/src/server/mcp/index.ts +1 -0
  141. package/src/server/mcp/instructions.ts +28 -1
  142. package/src/server/mcp/visibility.ts +33 -0
  143. package/src/stores/blobs/route.ts +123 -0
@@ -1,18 +1,9 @@
1
- import { ownerPrincipal } from '#auth';
2
- import type { DiscoveredCapability } from '#connectivity';
3
- import { BROKERED } from '#connectivity/auth/index.ts';
4
- import { allowedConnections } from '#policy';
5
- import {
6
- credentialRefFor,
7
- formatPlan,
8
- planIsNoop,
9
- planReconcile,
10
- toPolicyDocument,
11
- } from '#registry';
12
- import { announce, emit, fail, ok, print, warn } from '../../output.ts';
1
+ import { credentialRefFor, formatPlan, planIsNoop, planReconcile } from '#registry';
2
+ import { announce, announceProfile, emit, fail, ok, print, warn } from '../../output.ts';
13
3
  import { staleNudge } from '../../release.ts';
14
- import { capabilityDiff, discoveryProbe } from '../../runtime/discovery.ts';
15
- import { openRuntime, resolveProfile, type GlobalFlags } from '../../runtime.ts';
4
+ import { openRuntime, resolveProfileOnly, type GlobalFlags } from '../../runtime.ts';
5
+ import type { FetchLike } from '#deployments/knowledge.ts';
6
+ import { credentialAge, reportCapabilityDrift } from './findings.ts';
16
7
 
17
8
  /**
18
9
  * The gate order — check, doctor, plan, start — exists so failures surface in
@@ -22,12 +13,13 @@ import { openRuntime, resolveProfile, type GlobalFlags } from '../../runtime.ts'
22
13
 
23
14
  /** Static validation only. No external calls, no database, no credentials. */
24
15
  export async function check(flags: GlobalFlags): Promise<void> {
25
- const { resolution, config } = await resolveProfile(flags);
26
- announce(resolution);
16
+ const { selection, config } = await resolveProfileOnly(flags);
17
+ announceProfile(selection);
27
18
 
28
19
  // Reaching here means the loader accepted it: contract major, no credential
29
- // values, referential integrity, and target resolution all passed.
30
- print(ok(`${resolution.profilePath} is valid`));
20
+ // values, and referential integrity all passed. Not target resolution this
21
+ // validates a file, and the file is the same whichever target reads it.
22
+ print(ok(`${selection.profilePath} is valid`));
31
23
  print(
32
24
  ` ${config.connections.length} connection(s), ` +
33
25
  `${config.policy.allow.length} allow rule(s), ${config.policy.deny.length} deny rule(s)`,
@@ -48,16 +40,18 @@ export async function plan(flags: GlobalFlags): Promise<void> {
48
40
 
49
41
  export interface DoctorFlags extends GlobalFlags {
50
42
  readonly json?: boolean | undefined;
43
+ /** Injected for tests. A knowledge repository is the only thing doctor fetches. */
44
+ readonly fetch?: FetchLike | undefined;
51
45
  }
52
46
 
53
47
  /**
54
48
  * One thing doctor found: what is wrong, and the command that fixes it.
55
49
  *
56
- * `fix` carries `--profile` while the printed line does not. A person reading
57
- * the terminal just resolved a profile to get here and their next command
58
- * resolves the same one; something parsing this JSON has no shell context at
59
- * all, so leaving it off would hand an agent a command that acts on whichever
60
- * profile happens to be the default.
50
+ * `fix` carries both flags. That used to be an argument about JSON callers —
51
+ * a person reading the terminal had just resolved a profile and their next
52
+ * command would resolve the same one, while a parser had no shell context. The
53
+ * argument is moot now: nothing resolves on its own (ADR-037), so a command
54
+ * without them refuses whoever runs it. What survives is the shape.
61
55
  */
62
56
  export interface DoctorFinding {
63
57
  readonly kind: string;
@@ -68,15 +62,15 @@ export interface DoctorFinding {
68
62
 
69
63
  /** Read-only external checks: credentials resolve, stores reachable. */
70
64
  export async function doctor(flags: DoctorFlags): Promise<void> {
71
- const runtime = await openRuntime(flags);
65
+ const runtime = await openRuntime(flags, { fetch: flags.fetch });
72
66
 
73
67
  const checks: string[] = [];
74
68
  const warnings: DoctorFinding[] = [];
75
69
  const problems: DoctorFinding[] = [];
76
70
 
77
71
  try {
78
- const forProfile = (command: string) =>
79
- `${command} --profile ${runtime.resolution.profile}`;
72
+ const forSelection = (command: string) =>
73
+ `${command} --profile ${runtime.resolution.profile} --target ${runtime.resolution.target}`;
80
74
 
81
75
  checks.push('config is valid');
82
76
  checks.push('state store is reachable');
@@ -90,7 +84,7 @@ export async function doctor(flags: DoctorFlags): Promise<void> {
90
84
  warnings.push({
91
85
  kind: 'no_profile_token',
92
86
  message: 'no profile token yet — lanes link start will mint one, or run: lanes link token rotate',
93
- fix: forProfile('lanes link token rotate'),
87
+ fix: forSelection('lanes link token rotate'),
94
88
  });
95
89
  }
96
90
 
@@ -120,7 +114,7 @@ export async function doctor(flags: DoctorFlags): Promise<void> {
120
114
  message:
121
115
  `${key} credential is ${staleness.days} days old — a Google app in "Testing" expires at 7. ` +
122
116
  `Run: lanes link connect ${key}`,
123
- fix: forProfile(`lanes link connect ${key}`),
117
+ fix: forSelection(`lanes link connect ${key}`),
124
118
  });
125
119
  } else {
126
120
  const age = staleness
@@ -133,7 +127,45 @@ export async function doctor(flags: DoctorFlags): Promise<void> {
133
127
  kind: 'missing_credential',
134
128
  key,
135
129
  message: `${key} has no stored credential — run: lanes link connect ${key}`,
136
- fix: forProfile(`lanes link connect ${key}`),
130
+ fix: forSelection(`lanes link connect ${key}`),
131
+ });
132
+ }
133
+ }
134
+
135
+ // A repository this profile stores memory and skills in is an external
136
+ // dependency the other checks here do not cover, and it fails in the way
137
+ // doctor exists for: a token expires, and memory stops working with nothing
138
+ // said until the next read. One request answers all of it.
139
+ if (runtime.knowledge) {
140
+ try {
141
+ const facts = await runtime.knowledge.repository.facts();
142
+
143
+ if (!facts.canPush) {
144
+ problems.push({
145
+ kind: 'knowledge_read_only',
146
+ key: facts.fullName,
147
+ message:
148
+ `memory and skills are kept in ${facts.fullName}, and the token can no longer write ` +
149
+ 'to it — writes will fail. Regenerate it with Contents: read and write, then run: ' +
150
+ 'lanes link knowledge use github --repo ' + facts.fullName + ' --replace',
151
+ fix: forSelection(`lanes link knowledge use github --repo ${facts.fullName} --replace`),
152
+ });
153
+ } else if (!facts.private) {
154
+ warnings.push({
155
+ kind: 'knowledge_public',
156
+ key: facts.fullName,
157
+ message: `${facts.fullName} is public — every memory entry and skill in it is world-readable`,
158
+ });
159
+ } else {
160
+ checks.push(`memory and skills reachable in ${facts.fullName}`);
161
+ }
162
+ } catch (error) {
163
+ problems.push({
164
+ kind: 'knowledge_unreachable',
165
+ key: runtime.knowledge.describe,
166
+ message:
167
+ `memory and skills are kept in ${runtime.knowledge.describe}, which could not be ` +
168
+ `reached: ${(error as Error).message}`,
137
169
  });
138
170
  }
139
171
  }
@@ -174,7 +206,7 @@ export async function doctor(flags: DoctorFlags): Promise<void> {
174
206
  message:
175
207
  `this profile ${hasSetupRow ? 'does not grant "setup.*"' : 'has no "setup" connection'}` +
176
208
  ', so an agent cannot see what is configured — run: lanes link connect setup',
177
- fix: forProfile('lanes link connect setup'),
209
+ fix: forSelection('lanes link connect setup'),
178
210
  });
179
211
  }
180
212
 
@@ -205,7 +237,7 @@ export async function doctor(flags: DoctorFlags): Promise<void> {
205
237
  warnings.push({
206
238
  kind: 'reconcile_drift',
207
239
  message: 'runtime state differs from config — run: lanes link plan',
208
- fix: forProfile('lanes link plan'),
240
+ fix: forSelection('lanes link plan'),
209
241
  });
210
242
  }
211
243
 
@@ -227,136 +259,3 @@ export async function doctor(flags: DoctorFlags): Promise<void> {
227
259
  await runtime.close();
228
260
  }
229
261
  }
230
-
231
- /**
232
- * How old a stored OAuth credential is.
233
- *
234
- * Derived from the `expires_at` the OAuth provider stamps when saving tokens.
235
- * Returns null for anything that is not a token blob — an app password has no
236
- * meaningful age, and guessing one would produce a confusing warning.
237
- */
238
- async function credentialAge(
239
- credentials: { get(ref: string): Promise<string | null> },
240
- ref: string,
241
- ): Promise<{ days: number; brokered: boolean } | null> {
242
- const raw = await credentials.get(ref);
243
- if (!raw) return null;
244
-
245
- try {
246
- const parsed = JSON.parse(raw) as {
247
- expires_at?: number;
248
- expires_in?: number;
249
- authorized_via?: string;
250
- };
251
- if (typeof parsed.expires_at !== 'number') return null;
252
-
253
- const issued = parsed.expires_at - (parsed.expires_in ?? 3600) * 1000;
254
- return {
255
- days: Math.floor((Date.now() - issued) / 86_400_000),
256
- brokered: parsed.authorized_via === BROKERED,
257
- };
258
- } catch {
259
- return null;
260
- }
261
- }
262
-
263
- /**
264
- * Capabilities the upstream has grown since you connected.
265
- *
266
- * This is what replaced pinning an allow line per tool. `connect` writes
267
- * `provider.*`, which is readable and which a vendor can quietly widen by
268
- * shipping a new tool — so the widening has to be *visible* somewhere, and
269
- * doctor is where you look when you want to know what changed.
270
- *
271
- * Compared against the discovery cache rather than against config, because the
272
- * cache is what the running server actually serves. Failures are reported and
273
- * skipped: an upstream being down is not a reason for doctor to fail, and it is
274
- * already obvious from every other check.
275
- *
276
- * This used to open `if (kind !== 'mcp') continue`, which meant it watched the
277
- * one connector kind whose capabilities this repository does not ship and
278
- * ignored the two it does. Drive gained three operations in a commit and the
279
- * endpoint served six for as long as the operator did not re-authorise; the
280
- * check written to make exactly that visible never ran. `http` is no longer
281
- * listed here because it no longer can drift — `runtime/discovery.ts` re-derives
282
- * it at startup — so what is left is the kinds that genuinely still cache.
283
- */
284
- async function reportCapabilityDrift(
285
- runtime: Awaited<ReturnType<typeof openRuntime>>,
286
- say: (message: string) => void,
287
- ): Promise<void> {
288
- const policy = toPolicyDocument(runtime.config);
289
- const principal = ownerPrincipal(runtime.config.instance.profile).id;
290
-
291
- for (const entry of runtime.registry.list()) {
292
- const connection = runtime.config.connections.find((c) => c.provider === entry.manifest.id);
293
- if (!connection) continue;
294
-
295
- const connector = runtime.connectorFor(entry.manifest.id, connection.id);
296
- const probe = discoveryProbe(entry.manifest, connector);
297
- // Re-derived every startup, so what is served is what is shipped. Probing
298
- // it here would only ever compare a value against itself.
299
- if (!probe || probe.cost === 'offline') continue;
300
-
301
- const cached = runtime.registry.discovered(entry.manifest.id) ?? [];
302
-
303
- let live: readonly DiscoveredCapability[];
304
- try {
305
- live = await probe.run();
306
- } catch {
307
- continue;
308
- }
309
-
310
- // A provider that was never discovered serves nothing at all, which reads
311
- // from the outside like the provider being broken. Skipping it silently is
312
- // how that stays a mystery.
313
- if (cached.length === 0) {
314
- say(
315
- `${entry.manifest.name} has never been discovered, so none of its ${live.length} ` +
316
- `capability(ies) are served — run: lanes link connect ${entry.manifest.id}`,
317
- );
318
- continue;
319
- }
320
-
321
- const diff = capabilityDiff(cached, live);
322
-
323
- if (diff.added.length > 0) {
324
- // Through the same `allowedConnections` the dispatcher enforces with,
325
- // rather than reading `policy.allow` directly. The predicate this replaced
326
- // ignored its own `name` argument, so the answer was all-or-nothing, and
327
- // it never consulted `deny` — telling an operator their policy covered a
328
- // capability they had explicitly withheld.
329
- const reachable = diff.added.filter(
330
- (name) =>
331
- allowedConnections(
332
- `${entry.manifest.id}.${name}`,
333
- [`${entry.manifest.id}.${connection.id}`],
334
- principal,
335
- policy,
336
- ).length > 0,
337
- );
338
-
339
- say(
340
- `${entry.manifest.name} has ${diff.added.length} new capability(ies) since you connected: ` +
341
- `${diff.added.slice(0, 5).join(', ')}${diff.added.length > 5 ? ', …' : ''}` +
342
- (reachable.length > 0
343
- ? `\n Your policy covers ${reachable.length} of them — lanes link policy deny ${entry.manifest.id}.<name> to withhold one.`
344
- : ''),
345
- );
346
- }
347
-
348
- if (diff.removed.length > 0) {
349
- say(`${entry.manifest.name} no longer offers: ${diff.removed.slice(0, 5).join(', ')}`);
350
- }
351
-
352
- // The case ADR-017 said `plan` structurally could not report: a schema or a
353
- // description moved, the name did not, and the endpoint is serving the old
354
- // shape until it restarts.
355
- if (diff.changed.length > 0) {
356
- say(
357
- `${entry.manifest.name} changed the shape of: ${diff.changed.slice(0, 5).join(', ')}` +
358
- `\n Run: lanes link connect ${entry.manifest.id}, then restart the endpoint.`,
359
- );
360
- }
361
- }
362
- }
@@ -1,4 +1,5 @@
1
1
  import { listProfiles } from '#profile';
2
+ import { unservableProfiles } from '#deployments/servable.ts';
2
3
  import { fileURLToPath } from 'node:url';
3
4
  import { deployedUrl, endpointHealth, localUrl } from '../../endpoint-url.ts';
4
5
  import { announce, heading, print, style, warn } from '../../output.ts';
@@ -36,11 +37,14 @@ export async function outputs(flags: OutputsFlags): Promise<void> {
36
37
  const live = await endpointHealth(url, token);
37
38
  const mine = live?.profile === runtime.resolution.profile;
38
39
 
39
- // Live if it is up, otherwise what `start` would serve: the whole
40
- // workspace, since `--only` is the exception rather than the default.
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.
41
45
  const profiles = mine
42
46
  ? live.profiles
43
- : await listProfiles(runtime.resolution.workspaceRoot);
47
+ : await servable(runtime.resolution.workspaceRoot, runtime.target);
44
48
 
45
49
  if (flags.json) {
46
50
  print(
@@ -99,7 +103,11 @@ export async function outputs(flags: OutputsFlags): Promise<void> {
99
103
  );
100
104
  print('');
101
105
 
102
- const invocation = await tokenInvocation(runtime.config.auth.token_ref, token, flags.profile);
106
+ const invocation = await tokenInvocation(
107
+ token,
108
+ runtime.resolution.profile,
109
+ runtime.resolution.target,
110
+ );
103
111
 
104
112
  print(
105
113
  ` claude mcp add --transport http lanes-link ${url} \\\n` +
@@ -130,6 +138,18 @@ export async function outputs(flags: OutputsFlags): Promise<void> {
130
138
  }
131
139
  }
132
140
 
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
+
133
153
  /**
134
154
  * The command that prints this endpoint's token, verified to actually work.
135
155
  *
@@ -140,18 +160,25 @@ export async function outputs(flags: OutputsFlags): Promise<void> {
140
160
  * checking it returns *this* token; otherwise the printed command names this
141
161
  * checkout, which always works.
142
162
  */
143
- async function tokenInvocation(
144
- tokenRef: string,
163
+ export async function tokenInvocation(
145
164
  expected: string,
146
- profile: string | undefined,
165
+ profile: string,
166
+ target: string,
147
167
  ): Promise<{ command: string; onPath: boolean }> {
148
- const suffix = profile ? ` --profile ${profile}` : '';
149
- const short = `lanes link token show --raw${suffix}`;
168
+ // Both, always, and from the *resolved* selection rather than the flags. A
169
+ // token is per-target, so `outputs --target cloud` printing a bare
170
+ // `token show --raw` hands over the local one beside a deployed URL — a
171
+ // credential that looks like an answer and fails as a wrong password. Naming
172
+ // the profile as well makes the line pasteable into any shell rather than
173
+ // only into one where the same default happens to resolve.
174
+ const selection = ` --profile ${profile} --target ${target}`;
175
+ const short = `lanes link token show --raw${selection}`;
176
+ const argv = ['link', 'token', 'show', '--raw', '--profile', profile, '--target', target];
150
177
 
151
178
  const resolved = Bun.which('lanes');
152
179
  if (resolved) {
153
180
  try {
154
- const result = Bun.spawnSync([resolved, 'link', 'token', 'show', '--raw', ...(profile ? ['--profile', profile] : [])]);
181
+ const result = Bun.spawnSync([resolved, ...argv]);
155
182
  // Compared against the token rather than merely checking it exited zero:
156
183
  // a `lanes` on PATH could belong to a different workspace entirely,
157
184
  // and would hand the harness a token this endpoint rejects.
@@ -168,6 +195,6 @@ async function tokenInvocation(
168
195
  // which is the empty-token failure this whole function exists to avoid,
169
196
  // reintroduced by the fallback meant to prevent it.
170
197
  const entry = fileURLToPath(new URL('../../lanes.ts', import.meta.url));
171
- return { command: `bun run ${entry} link token show --raw${suffix}`, onPath: false };
198
+ return { command: `bun run ${entry} link token show --raw${selection}`, onPath: false };
172
199
  }
173
200
 
@@ -1,7 +1,7 @@
1
1
  import { loadConfigFile } from '#profile';
2
2
  import { ConfigDocument } from '../../config-edit.ts';
3
- import { announce, heading, ok, print, style, table } from '../../output.ts';
4
- import { resolveProfile, type GlobalFlags } from '../../runtime.ts';
3
+ import { announce, announceProfile, heading, ok, print, style, table } from '../../output.ts';
4
+ import { resolveProfile, resolveProfileOnly, type GlobalFlags } from '../../runtime.ts';
5
5
  import { nextAfterEdit, publishProfileEdit } from '../../publish.ts';
6
6
 
7
7
  /**
@@ -12,8 +12,8 @@ import { nextAfterEdit, publishProfileEdit } from '../../publish.ts';
12
12
  */
13
13
 
14
14
  export async function policyList(flags: GlobalFlags): Promise<void> {
15
- const { resolution, config } = await resolveProfile(flags);
16
- announce(resolution);
15
+ const { selection, config } = await resolveProfileOnly(flags);
16
+ announceProfile(selection);
17
17
 
18
18
  if (config.policy.allow.length === 0 && config.policy.deny.length === 0) {
19
19
  print(style.dim('No rules. Default deny is in effect: nothing is reachable.'));
@@ -73,8 +73,8 @@ export async function policyRule(
73
73
  }
74
74
 
75
75
  export async function configShow(flags: GlobalFlags): Promise<void> {
76
- const { resolution } = await resolveProfile(flags);
77
- announce(resolution);
78
- const { config } = await loadConfigFile(resolution.profilePath);
76
+ const { selection } = await resolveProfileOnly(flags);
77
+ announceProfile(selection);
78
+ const { config } = await loadConfigFile(selection.profilePath);
79
79
  print(JSON.stringify(config, null, 2));
80
80
  }
@@ -24,6 +24,9 @@ export async function start(
24
24
  port: flags.port,
25
25
  only: flags.only,
26
26
  mintToken: true,
27
+ // Local, so there is a browser and a person at it. `container.ts` does not
28
+ // pass this — see `#server/dashboard.ts`.
29
+ dashboard: true,
27
30
  // Stderr, not stdout: `--json` and `--raw` callers parse the other stream.
28
31
  // A refused credential is the event worth seeing while this runs in the
29
32
  // foreground, and until now nothing printed it.
@@ -1,4 +1,4 @@
1
- import { listProfiles } from '#profile';
1
+ import { listProfiles, loadWorkspaceProfiles, resolveWorkspaceRoot } from '#profile';
2
2
  import { oneProfile, visibleCapabilities } from '#server/mcp';
3
3
  import { toPolicyDocument } from '#registry';
4
4
  import { announce, emit, heading, print, style, table } from '../../output.ts';
@@ -32,6 +32,8 @@ export interface StatusFlags extends GlobalFlags {
32
32
  * config-only command can say that much honestly.
33
33
  */
34
34
  export async function status(flags: StatusFlags): Promise<void> {
35
+ if (flags.profile === undefined) return workspaceStatus(flags);
36
+
35
37
  const runtime = await openRuntime(flags);
36
38
  try {
37
39
  const records = await runtime.state.connections.list();
@@ -131,3 +133,108 @@ export async function status(flags: StatusFlags): Promise<void> {
131
133
  await runtime.close();
132
134
  }
133
135
  }
136
+
137
+ /**
138
+ * `lanes link status --target <t>` — the whole workspace at one target.
139
+ *
140
+ * The default, because the subject of this command is the endpoint and one
141
+ * endpoint serves every profile in the workspace (ADR-009, ADR-043). Naming a
142
+ * profile narrows it to the detailed view above; naming none is not a missing
143
+ * answer, it is the wider one.
144
+ *
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.
151
+ *
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.
155
+ */
156
+ async function workspaceStatus(flags: StatusFlags): Promise<void> {
157
+ 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
+ }));
167
+
168
+ const declaring = profiles.filter((entry) => entry.declares);
169
+
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
+ ];
181
+
182
+ return emit(
183
+ flags.json,
184
+ { workspace: root, target, profiles, deployments, unreadable: workspace.unreadable },
185
+ () => {
186
+ print(style.dim(`workspace ${style.bold(root)} target ${style.bold(target)}`));
187
+
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
+ );
209
+ return;
210
+ }
211
+
212
+ if (deployments.length === 0) {
213
+ print(style.dim(' No deployment — this target runs wherever the CLI does.'));
214
+ return;
215
+ }
216
+
217
+ for (const deployment of deployments) {
218
+ const { platform, service, region } = deployment;
219
+ print(` ${platform} service ${style.bold(service)} in ${region}`);
220
+ }
221
+
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.'));
229
+ return;
230
+ }
231
+
232
+ print(
233
+ style.dim(
234
+ " the address is the platform's to assign — run: " +
235
+ `lanes link outputs --target ${target} --profile ${declaring[0]!.name}`,
236
+ ),
237
+ );
238
+ },
239
+ );
240
+ }
@@ -63,7 +63,7 @@ export async function tokenRotate(
63
63
  // than the command, so nothing re-reads this on its own.
64
64
  print(warn('every agent configured with the old token must be re-registered'));
65
65
  print(style.dim(' A harness stores the token it was given, not the command that produced it.'));
66
- print(style.dim(' Run: lanes link outputs for the command to re-run.'));
66
+ print(style.dim(' Run: lanes link outputs with this profile and target for the command to re-run.'));
67
67
  } finally {
68
68
  await runtime.close();
69
69
  }
@@ -8,6 +8,7 @@
8
8
  * inspect.ts check, plan, doctor — the gate order, cheapest failure first
9
9
  * status.ts connections, reachable capabilities, endpoint
10
10
  * outputs.ts what an agent harness needs, and proving the short form works
11
+ * dashboard.ts opening the page a local endpoint serves, with the key it needs
11
12
  * serve.ts start
12
13
  * audit.ts audit tail and verify, and the Markdown rendering of tail
13
14
  * token.ts token show, token rotate
@@ -22,6 +23,7 @@ export { status } from './operate/status.ts';
22
23
  export { outputs, type OutputsFlags } from './operate/outputs.ts';
23
24
  export { tools, type ToolsFlags } from './operate/tools.ts';
24
25
  export { start } from './operate/serve.ts';
26
+ export { dashboard, type DashboardFlags } from './operate/dashboard.ts';
25
27
  export { auditTail, auditVerify, markdownCell } from './operate/audit.ts';
26
28
  export { attachFile } from './operate/attach.ts';
27
29
  export { tokenRotate, tokenShow } from './operate/token.ts';