@lanes-sh/link 0.2.2 → 0.3.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 (130) hide show
  1. package/README.md +21 -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 +56 -158
  31. package/src/cli/commands/operate/outputs.ts +38 -11
  32. package/src/cli/commands/operate/serve.ts +3 -0
  33. package/src/cli/commands/operate/token.ts +1 -1
  34. package/src/cli/commands/operate.ts +2 -0
  35. package/src/cli/commands/profile/declare.ts +154 -0
  36. package/src/cli/commands/profile/removal.ts +17 -0
  37. package/src/cli/commands/profile.ts +83 -35
  38. package/src/cli/commands/setup.ts +22 -6
  39. package/src/cli/commands/target.ts +65 -83
  40. package/src/cli/config-edit.ts +48 -144
  41. package/src/cli/config-repair.ts +186 -0
  42. package/src/cli/dashboard-page.ts +284 -0
  43. package/src/cli/dashboard-shell.ts +125 -0
  44. package/src/cli/identity.ts +12 -1
  45. package/src/cli/main.ts +68 -4
  46. package/src/cli/oauth-callback.ts +187 -0
  47. package/src/cli/oauth-exchange.ts +57 -15
  48. package/src/cli/oauth.ts +67 -177
  49. package/src/cli/output.ts +21 -5
  50. package/src/cli/provider-marks.ts +45 -0
  51. package/src/cli/runtime/open.ts +67 -49
  52. package/src/cli/runtime/registry.ts +60 -2
  53. package/src/cli/runtime/select.ts +26 -13
  54. package/src/cli/runtime/vault.ts +61 -0
  55. package/src/cli/runtime.ts +2 -1
  56. package/src/cli/selection.ts +357 -0
  57. package/src/cli/usage.ts +32 -9
  58. package/src/connectivity/auth/README.md +7 -1
  59. package/src/connectivity/auth/basic/index.ts +1 -1
  60. package/src/connectivity/auth/index.ts +14 -0
  61. package/src/connectivity/auth/oauth-authcode/broker.ts +26 -0
  62. package/src/connectivity/auth/oauth-authcode/index.ts +16 -2
  63. package/src/connectivity/auth/oauth-authcode/provider.ts +1 -1
  64. package/src/connectivity/auth/oauth-authcode/refresh.ts +3 -3
  65. package/src/connectivity/auth/oauth-jwt/README.md +33 -0
  66. package/src/connectivity/auth/oauth-jwt/index.ts +237 -0
  67. package/src/connectivity/auth/oauth-jwt/key.ts +148 -0
  68. package/src/connectivity/auth/resolve.ts +1 -1
  69. package/src/connectivity/auth/token.ts +11 -0
  70. package/src/connectivity/index.ts +2 -0
  71. package/src/connectivity/manifest/auth.ts +99 -2
  72. package/src/connectivity/manifest/identity.ts +12 -0
  73. package/src/connectivity/manifest/index.ts +3 -1
  74. package/src/connectivity/manifest/provider.ts +37 -8
  75. package/src/connectivity/manifest/requirements.ts +109 -6
  76. package/src/deployments/adapters/filesystem.ts +10 -1
  77. package/src/deployments/adapters/github-api.ts +106 -0
  78. package/src/deployments/adapters/github-commit.ts +103 -0
  79. package/src/deployments/adapters/github-repo.ts +356 -0
  80. package/src/deployments/adapters/github-testing.ts +258 -0
  81. package/src/deployments/adapters/github.ts +125 -0
  82. package/src/deployments/deploy.ts +48 -19
  83. package/src/deployments/driver.ts +8 -1
  84. package/src/deployments/gcp/driver.ts +3 -1
  85. package/src/deployments/knowledge.ts +119 -0
  86. package/src/deployments/prepare.ts +2 -2
  87. package/src/deployments/servable.ts +81 -0
  88. package/src/deployments/target.ts +3 -2
  89. package/src/deployments/upload.ts +2 -1
  90. package/src/dispatch/dispatch.ts +1 -1
  91. package/src/profile/identity.ts +60 -0
  92. package/src/profile/index.ts +15 -5
  93. package/src/profile/knowledge.ts +124 -0
  94. package/src/profile/load.ts +17 -5
  95. package/src/profile/primitives.ts +24 -1
  96. package/src/profile/schema.ts +46 -3
  97. package/src/profile/targets.ts +74 -114
  98. package/src/profile/workspace.ts +71 -84
  99. package/src/providers/google/calendar/index.ts +2 -0
  100. package/src/providers/google/contacts/index.ts +2 -0
  101. package/src/providers/google/docs/index.ts +2 -0
  102. package/src/providers/google/drive/index.ts +2 -0
  103. package/src/providers/google/gmail/index.ts +2 -0
  104. package/src/providers/google/gmail-imap/index.ts +125 -0
  105. package/src/providers/google/index.ts +2 -1
  106. package/src/providers/google/shared/oauth.ts +18 -6
  107. package/src/providers/google/shared/service-account.ts +110 -0
  108. package/src/providers/google/shared/setup.ts +5 -2
  109. package/src/providers/google/sheets/index.ts +2 -0
  110. package/src/providers/google/tasks/index.ts +2 -0
  111. package/src/providers/identity/provider.ts +166 -0
  112. package/src/providers/index.ts +3 -0
  113. package/src/providers/owner.ts +10 -2
  114. package/src/providers/scopes.ts +2 -0
  115. package/src/providers/setup/plan.ts +31 -9
  116. package/src/providers/setup/provider.ts +23 -0
  117. package/src/providers/slack/index.ts +81 -33
  118. package/src/providers/slack/oauth.ts +103 -0
  119. package/src/providers/slack/scopes.ts +37 -0
  120. package/src/server/container.ts +18 -1
  121. package/src/server/cors.ts +252 -0
  122. package/src/server/dashboard.ts +208 -0
  123. package/src/server/endpoint.ts +45 -1
  124. package/src/server/generations.ts +11 -2
  125. package/src/server/harness.ts +7 -0
  126. package/src/server/index.ts +45 -6
  127. package/src/server/mcp/index.ts +1 -0
  128. package/src/server/mcp/instructions.ts +28 -1
  129. package/src/server/mcp/visibility.ts +33 -0
  130. 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';
1
+ import { credentialRefFor, formatPlan, planIsNoop, planReconcile } from '#registry';
12
2
  import { announce, emit, fail, ok, print, warn } from '../../output.ts';
13
3
  import { staleNudge } from '../../release.ts';
14
- import { capabilityDiff, discoveryProbe } from '../../runtime/discovery.ts';
15
4
  import { openRuntime, resolveProfile, 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
@@ -48,16 +39,18 @@ export async function plan(flags: GlobalFlags): Promise<void> {
48
39
 
49
40
  export interface DoctorFlags extends GlobalFlags {
50
41
  readonly json?: boolean | undefined;
42
+ /** Injected for tests. A knowledge repository is the only thing doctor fetches. */
43
+ readonly fetch?: FetchLike | undefined;
51
44
  }
52
45
 
53
46
  /**
54
47
  * One thing doctor found: what is wrong, and the command that fixes it.
55
48
  *
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.
49
+ * `fix` carries both flags. That used to be an argument about JSON callers —
50
+ * a person reading the terminal had just resolved a profile and their next
51
+ * command would resolve the same one, while a parser had no shell context. The
52
+ * argument is moot now: nothing resolves on its own (ADR-037), so a command
53
+ * without them refuses whoever runs it. What survives is the shape.
61
54
  */
62
55
  export interface DoctorFinding {
63
56
  readonly kind: string;
@@ -68,15 +61,15 @@ export interface DoctorFinding {
68
61
 
69
62
  /** Read-only external checks: credentials resolve, stores reachable. */
70
63
  export async function doctor(flags: DoctorFlags): Promise<void> {
71
- const runtime = await openRuntime(flags);
64
+ const runtime = await openRuntime(flags, { fetch: flags.fetch });
72
65
 
73
66
  const checks: string[] = [];
74
67
  const warnings: DoctorFinding[] = [];
75
68
  const problems: DoctorFinding[] = [];
76
69
 
77
70
  try {
78
- const forProfile = (command: string) =>
79
- `${command} --profile ${runtime.resolution.profile}`;
71
+ const forSelection = (command: string) =>
72
+ `${command} --profile ${runtime.resolution.profile} --target ${runtime.resolution.target}`;
80
73
 
81
74
  checks.push('config is valid');
82
75
  checks.push('state store is reachable');
@@ -90,7 +83,7 @@ export async function doctor(flags: DoctorFlags): Promise<void> {
90
83
  warnings.push({
91
84
  kind: 'no_profile_token',
92
85
  message: 'no profile token yet — lanes link start will mint one, or run: lanes link token rotate',
93
- fix: forProfile('lanes link token rotate'),
86
+ fix: forSelection('lanes link token rotate'),
94
87
  });
95
88
  }
96
89
 
@@ -120,7 +113,7 @@ export async function doctor(flags: DoctorFlags): Promise<void> {
120
113
  message:
121
114
  `${key} credential is ${staleness.days} days old — a Google app in "Testing" expires at 7. ` +
122
115
  `Run: lanes link connect ${key}`,
123
- fix: forProfile(`lanes link connect ${key}`),
116
+ fix: forSelection(`lanes link connect ${key}`),
124
117
  });
125
118
  } else {
126
119
  const age = staleness
@@ -133,7 +126,45 @@ export async function doctor(flags: DoctorFlags): Promise<void> {
133
126
  kind: 'missing_credential',
134
127
  key,
135
128
  message: `${key} has no stored credential — run: lanes link connect ${key}`,
136
- fix: forProfile(`lanes link connect ${key}`),
129
+ fix: forSelection(`lanes link connect ${key}`),
130
+ });
131
+ }
132
+ }
133
+
134
+ // A repository this profile stores memory and skills in is an external
135
+ // dependency the other checks here do not cover, and it fails in the way
136
+ // doctor exists for: a token expires, and memory stops working with nothing
137
+ // said until the next read. One request answers all of it.
138
+ if (runtime.knowledge) {
139
+ try {
140
+ const facts = await runtime.knowledge.repository.facts();
141
+
142
+ if (!facts.canPush) {
143
+ problems.push({
144
+ kind: 'knowledge_read_only',
145
+ key: facts.fullName,
146
+ message:
147
+ `memory and skills are kept in ${facts.fullName}, and the token can no longer write ` +
148
+ 'to it — writes will fail. Regenerate it with Contents: read and write, then run: ' +
149
+ 'lanes link knowledge use github --repo ' + facts.fullName + ' --replace',
150
+ fix: forSelection(`lanes link knowledge use github --repo ${facts.fullName} --replace`),
151
+ });
152
+ } else if (!facts.private) {
153
+ warnings.push({
154
+ kind: 'knowledge_public',
155
+ key: facts.fullName,
156
+ message: `${facts.fullName} is public — every memory entry and skill in it is world-readable`,
157
+ });
158
+ } else {
159
+ checks.push(`memory and skills reachable in ${facts.fullName}`);
160
+ }
161
+ } catch (error) {
162
+ problems.push({
163
+ kind: 'knowledge_unreachable',
164
+ key: runtime.knowledge.describe,
165
+ message:
166
+ `memory and skills are kept in ${runtime.knowledge.describe}, which could not be ` +
167
+ `reached: ${(error as Error).message}`,
137
168
  });
138
169
  }
139
170
  }
@@ -174,7 +205,7 @@ export async function doctor(flags: DoctorFlags): Promise<void> {
174
205
  message:
175
206
  `this profile ${hasSetupRow ? 'does not grant "setup.*"' : 'has no "setup" connection'}` +
176
207
  ', so an agent cannot see what is configured — run: lanes link connect setup',
177
- fix: forProfile('lanes link connect setup'),
208
+ fix: forSelection('lanes link connect setup'),
178
209
  });
179
210
  }
180
211
 
@@ -205,7 +236,7 @@ export async function doctor(flags: DoctorFlags): Promise<void> {
205
236
  warnings.push({
206
237
  kind: 'reconcile_drift',
207
238
  message: 'runtime state differs from config — run: lanes link plan',
208
- fix: forProfile('lanes link plan'),
239
+ fix: forSelection('lanes link plan'),
209
240
  });
210
241
  }
211
242
 
@@ -227,136 +258,3 @@ export async function doctor(flags: DoctorFlags): Promise<void> {
227
258
  await runtime.close();
228
259
  }
229
260
  }
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, profile: 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
 
@@ -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.
@@ -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';
@@ -0,0 +1,154 @@
1
+ import { listProfiles, loadProfileConfig, ConfigError, type TargetConfig } from '#profile';
2
+ import { ask, isInteractive } from '../../prompt.ts';
3
+ import { print, style } from '../../output.ts';
4
+
5
+ /**
6
+ * The targets a new profile declares, and where their values come from.
7
+ *
8
+ * `profile add` used to emit exactly one target — `local`, hard-coded in the
9
+ * template — while accepting a `--target` it dropped on the floor. So the
10
+ * command that creates a profile could not create the thing a profile is mostly
11
+ * *for*: somewhere to run.
12
+ *
13
+ * A `local` target is derivable and never asked about: a path under the
14
+ * workspace, an encrypted file beside it. Anything else names a cloud account,
15
+ * and this is the one place in the CLI where guessing those is actively harmful.
16
+ * `deploy`'s survey proposes a *fresh* project id (`lanes-link-<random>`),
17
+ * which is right for a first deploy and exactly wrong here — pressing return
18
+ * would build a second, separate deployment rather than adding this profile to
19
+ * the one the operator already has. So the defaults come from a sibling profile
20
+ * that already declares the same target name, and the service name is the only
21
+ * one derived, because that is the one field that has to differ per profile.
22
+ */
23
+
24
+ export interface DeclaredTarget {
25
+ readonly name: string;
26
+ /** Rendered YAML, indented two spaces, ready to sit under `targets:`. */
27
+ readonly block: string;
28
+ /** Which profile the values were taken from, when they were taken from one. */
29
+ readonly from?: string;
30
+ }
31
+
32
+ /** The `local` target, which is the same shape in every profile. */
33
+ export function localBlock(profile: string, name = 'local'): string {
34
+ return (
35
+ ` ${name}:\n` +
36
+ ` credentials: { adapter: file, path: ./data/${profile}/credentials.enc }\n` +
37
+ ` storage: { adapter: filesystem, path: ./data/${profile} }\n`
38
+ );
39
+ }
40
+
41
+ /**
42
+ * A sibling profile's declaration of the same target name, if there is one.
43
+ *
44
+ * The first match wins rather than the operator being asked which — two
45
+ * profiles declaring `cloud` against different projects is possible and rare,
46
+ * and the values are shown and editable at the prompt either way.
47
+ */
48
+ export async function siblingTarget(
49
+ workspaceRoot: string,
50
+ target: string,
51
+ exclude: string,
52
+ ): Promise<{ profile: string; declared: TargetConfig } | null> {
53
+ for (const name of await listProfiles(workspaceRoot)) {
54
+ if (name === exclude) continue;
55
+
56
+ try {
57
+ const { config } = await loadProfileConfig(workspaceRoot, name);
58
+ const declared = config.targets[target];
59
+ if (declared) return { profile: name, declared };
60
+ } catch {
61
+ // A profile that will not parse is not a source of defaults, and saying
62
+ // so here would report a broken sibling as a problem with this command.
63
+ }
64
+ }
65
+
66
+ return null;
67
+ }
68
+
69
+ /**
70
+ * Ask for what a non-local target needs, pre-filled from a sibling.
71
+ *
72
+ * Refuses rather than inventing when there is nobody to ask and nothing to copy
73
+ * — the same bargain `connect --non-interactive` makes. A profile written with
74
+ * a guessed project id parses, deploys, and points at the wrong account.
75
+ */
76
+ export async function askTarget(input: {
77
+ readonly target: string;
78
+ readonly profile: string;
79
+ readonly sibling: { profile: string; declared: TargetConfig } | null;
80
+ readonly nonInteractive?: boolean;
81
+ }): Promise<DeclaredTarget> {
82
+ const { target, profile, sibling } = input;
83
+
84
+ if (!sibling) {
85
+ throw new ConfigError(
86
+ `No profile in this workspace declares a target called "${target}", so there is\n` +
87
+ 'nothing to copy its adapters from.\n' +
88
+ ` lanes link profile add ${profile} --target local\n` +
89
+ ` lanes link deploy --profile ${profile} --target ${target}\n` +
90
+ ' The deploy surveys for what it does not know and writes the block.',
91
+ );
92
+ }
93
+
94
+ const declared = sibling.declared;
95
+ const project = declared.credentials.project ?? '';
96
+ const bucket = declared.storage.bucket ?? '';
97
+
98
+ // The one field that must differ: two profiles served by one project need two
99
+ // services, which is what makes them separately deployable.
100
+ const service = `lanes-link-${profile}-mcp`;
101
+
102
+ if (input.nonInteractive === true || !isInteractive()) {
103
+ return {
104
+ name: target,
105
+ block: cloudBlock({ target, project, bucket, service, declared }),
106
+ from: sibling.profile,
107
+ };
108
+ }
109
+
110
+ print(style.dim(` ${target} — defaults from profile "${sibling.profile}"`));
111
+
112
+ const answeredProject = (await ask(` Cloud project [${project}]: `)) || project;
113
+ const answeredBucket = (await ask(` Bucket [${bucket}]: `)) || bucket;
114
+ const answeredService = (await ask(` Service [${service}]: `)) || service;
115
+
116
+ return {
117
+ name: target,
118
+ block: cloudBlock({
119
+ target,
120
+ project: answeredProject,
121
+ bucket: answeredBucket,
122
+ service: answeredService,
123
+ declared,
124
+ }),
125
+ from: sibling.profile,
126
+ };
127
+ }
128
+
129
+ /** One target's YAML, carrying over the adapters the sibling chose. */
130
+ function cloudBlock(input: {
131
+ target: string;
132
+ project: string;
133
+ bucket: string;
134
+ service: string;
135
+ declared: TargetConfig;
136
+ }): string {
137
+ const { declared } = input;
138
+ const deploy = declared.deploy;
139
+
140
+ return (
141
+ ` ${input.target}:\n` +
142
+ ` credentials: { adapter: ${declared.credentials.adapter}, project: ${input.project} }\n` +
143
+ ` storage: { adapter: ${declared.storage.adapter}, bucket: ${input.bucket} }\n` +
144
+ ` vault: { adapter: ${declared.vault?.adapter ?? 'secret'} }\n` +
145
+ (deploy
146
+ ? ` deploy:\n` +
147
+ ` platform: ${deploy.platform}\n` +
148
+ ` project: ${input.project}\n` +
149
+ ` region: ${deploy.region}\n` +
150
+ ` service: ${input.service}\n` +
151
+ ` access: ${deploy.access}\n`
152
+ : '')
153
+ );
154
+ }
@@ -119,6 +119,23 @@ export async function removalPlan(
119
119
  continue;
120
120
  }
121
121
 
122
+ // A repository this profile keeps memory and skills in is not this
123
+ // command's to empty, and it is not reachable from here either: the routing
124
+ // that points memory at a repository is applied by `openRuntime`, and this
125
+ // plan is built on `openBlobStoreFor`, which opens the target's own declared
126
+ // storage. That is the right answer — deleting a profile must not delete
127
+ // somebody's repository — and it is a surprising one, because
128
+ // `rm -r data/<profile>` used to be the whole of "what could this profile
129
+ // reach". So it is said before the operator confirms rather than discovered
130
+ // afterwards.
131
+ if (declared.knowledge) {
132
+ warnings.push(
133
+ `Target "${name}" keeps this profile's memory and skills in ${declared.knowledge.repo}. ` +
134
+ 'Nothing here touches a repository, so they survive this removal — delete them there ' +
135
+ 'if you want them gone.',
136
+ );
137
+ }
138
+
122
139
  // Secrets first, and this ordering is load-bearing rather than tidy:
123
140
  // `layout.credentials(p)` is `data/<p>/credentials.enc`, *inside* the blob
124
141
  // root `data/<p>`. For the file adapter the credential store is itself a