@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.
- package/README.md +21 -8
- package/instructions/skills/lanes-link/SKILL.md +42 -14
- package/package.json +1 -1
- package/src/cli/argv.ts +50 -0
- package/src/cli/brand.ts +178 -0
- package/src/cli/callback-page.ts +108 -128
- package/src/cli/commands/connect/accounts.ts +5 -0
- package/src/cli/commands/connect/assertion.ts +187 -0
- package/src/cli/commands/connect/authorise.ts +61 -17
- package/src/cli/commands/connect/client.ts +37 -9
- package/src/cli/commands/connect/discover.ts +94 -0
- package/src/cli/commands/connect/family.ts +72 -0
- package/src/cli/commands/connect/index.ts +113 -115
- package/src/cli/commands/connect/method.ts +237 -0
- package/src/cli/commands/connect/outcome.ts +42 -1
- package/src/cli/commands/connect/pasted-token.ts +66 -0
- package/src/cli/commands/connect/requirements.ts +60 -8
- package/src/cli/commands/connect/setup.ts +16 -5
- package/src/cli/commands/connect/target-note.ts +34 -0
- package/src/cli/commands/identity.ts +258 -0
- package/src/cli/commands/knowledge/index.ts +390 -0
- package/src/cli/commands/knowledge/migrate.ts +180 -0
- package/src/cli/commands/knowledge/setup.ts +144 -0
- package/src/cli/commands/knowledge.ts +10 -0
- package/src/cli/commands/mcp/harnesses.ts +16 -2
- package/src/cli/commands/mcp/register.ts +9 -1
- package/src/cli/commands/mcp/stdio.ts +21 -0
- package/src/cli/commands/operate/dashboard.ts +107 -0
- package/src/cli/commands/operate/findings.ts +151 -0
- package/src/cli/commands/operate/inspect.ts +56 -158
- package/src/cli/commands/operate/outputs.ts +38 -11
- package/src/cli/commands/operate/serve.ts +3 -0
- package/src/cli/commands/operate/token.ts +1 -1
- package/src/cli/commands/operate.ts +2 -0
- package/src/cli/commands/profile/declare.ts +154 -0
- package/src/cli/commands/profile/removal.ts +17 -0
- package/src/cli/commands/profile.ts +83 -35
- package/src/cli/commands/setup.ts +22 -6
- package/src/cli/commands/target.ts +65 -83
- package/src/cli/config-edit.ts +48 -144
- package/src/cli/config-repair.ts +186 -0
- package/src/cli/dashboard-page.ts +284 -0
- package/src/cli/dashboard-shell.ts +125 -0
- package/src/cli/identity.ts +12 -1
- package/src/cli/main.ts +68 -4
- package/src/cli/oauth-callback.ts +187 -0
- package/src/cli/oauth-exchange.ts +57 -15
- package/src/cli/oauth.ts +67 -177
- package/src/cli/output.ts +21 -5
- package/src/cli/provider-marks.ts +45 -0
- package/src/cli/runtime/open.ts +67 -49
- package/src/cli/runtime/registry.ts +60 -2
- package/src/cli/runtime/select.ts +26 -13
- package/src/cli/runtime/vault.ts +61 -0
- package/src/cli/runtime.ts +2 -1
- package/src/cli/selection.ts +357 -0
- package/src/cli/usage.ts +32 -9
- package/src/connectivity/auth/README.md +7 -1
- package/src/connectivity/auth/basic/index.ts +1 -1
- package/src/connectivity/auth/index.ts +14 -0
- package/src/connectivity/auth/oauth-authcode/broker.ts +26 -0
- package/src/connectivity/auth/oauth-authcode/index.ts +16 -2
- package/src/connectivity/auth/oauth-authcode/provider.ts +1 -1
- package/src/connectivity/auth/oauth-authcode/refresh.ts +3 -3
- package/src/connectivity/auth/oauth-jwt/README.md +33 -0
- package/src/connectivity/auth/oauth-jwt/index.ts +237 -0
- package/src/connectivity/auth/oauth-jwt/key.ts +148 -0
- package/src/connectivity/auth/resolve.ts +1 -1
- package/src/connectivity/auth/token.ts +11 -0
- package/src/connectivity/index.ts +2 -0
- package/src/connectivity/manifest/auth.ts +99 -2
- package/src/connectivity/manifest/identity.ts +12 -0
- package/src/connectivity/manifest/index.ts +3 -1
- package/src/connectivity/manifest/provider.ts +37 -8
- package/src/connectivity/manifest/requirements.ts +109 -6
- package/src/deployments/adapters/filesystem.ts +10 -1
- package/src/deployments/adapters/github-api.ts +106 -0
- package/src/deployments/adapters/github-commit.ts +103 -0
- package/src/deployments/adapters/github-repo.ts +356 -0
- package/src/deployments/adapters/github-testing.ts +258 -0
- package/src/deployments/adapters/github.ts +125 -0
- package/src/deployments/deploy.ts +48 -19
- package/src/deployments/driver.ts +8 -1
- package/src/deployments/gcp/driver.ts +3 -1
- package/src/deployments/knowledge.ts +119 -0
- package/src/deployments/prepare.ts +2 -2
- package/src/deployments/servable.ts +81 -0
- package/src/deployments/target.ts +3 -2
- package/src/deployments/upload.ts +2 -1
- package/src/dispatch/dispatch.ts +1 -1
- package/src/profile/identity.ts +60 -0
- package/src/profile/index.ts +15 -5
- package/src/profile/knowledge.ts +124 -0
- package/src/profile/load.ts +17 -5
- package/src/profile/primitives.ts +24 -1
- package/src/profile/schema.ts +46 -3
- package/src/profile/targets.ts +74 -114
- package/src/profile/workspace.ts +71 -84
- package/src/providers/google/calendar/index.ts +2 -0
- package/src/providers/google/contacts/index.ts +2 -0
- package/src/providers/google/docs/index.ts +2 -0
- package/src/providers/google/drive/index.ts +2 -0
- package/src/providers/google/gmail/index.ts +2 -0
- package/src/providers/google/gmail-imap/index.ts +125 -0
- package/src/providers/google/index.ts +2 -1
- package/src/providers/google/shared/oauth.ts +18 -6
- package/src/providers/google/shared/service-account.ts +110 -0
- package/src/providers/google/shared/setup.ts +5 -2
- package/src/providers/google/sheets/index.ts +2 -0
- package/src/providers/google/tasks/index.ts +2 -0
- package/src/providers/identity/provider.ts +166 -0
- package/src/providers/index.ts +3 -0
- package/src/providers/owner.ts +10 -2
- package/src/providers/scopes.ts +2 -0
- package/src/providers/setup/plan.ts +31 -9
- package/src/providers/setup/provider.ts +23 -0
- package/src/providers/slack/index.ts +81 -33
- package/src/providers/slack/oauth.ts +103 -0
- package/src/providers/slack/scopes.ts +37 -0
- package/src/server/container.ts +18 -1
- package/src/server/cors.ts +252 -0
- package/src/server/dashboard.ts +208 -0
- package/src/server/endpoint.ts +45 -1
- package/src/server/generations.ts +11 -2
- package/src/server/harness.ts +7 -0
- package/src/server/index.ts +45 -6
- package/src/server/mcp/index.ts +1 -0
- package/src/server/mcp/instructions.ts +28 -1
- package/src/server/mcp/visibility.ts +33 -0
- package/src/stores/blobs/route.ts +123 -0
|
@@ -1,18 +1,9 @@
|
|
|
1
|
-
import {
|
|
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
|
|
57
|
-
* the terminal just resolved a profile
|
|
58
|
-
*
|
|
59
|
-
*
|
|
60
|
-
*
|
|
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
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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
|
|
40
|
-
//
|
|
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
|
|
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(
|
|
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
|
|
165
|
+
profile: string,
|
|
166
|
+
target: string,
|
|
147
167
|
): Promise<{ command: string; onPath: boolean }> {
|
|
148
|
-
|
|
149
|
-
|
|
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,
|
|
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${
|
|
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
|
|
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
|