@lanes-sh/link 0.9.2 → 0.9.3
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 +10 -4
- package/instructions/skills/lanes-link/SKILL.md +73 -48
- package/package.json +1 -1
- package/src/auth/index.ts +127 -26
- package/src/cli/accepts.ts +13 -3
- package/src/cli/commands/connect/declare.ts +16 -7
- package/src/cli/commands/connect/index.ts +4 -4
- package/src/cli/commands/connect/settle.ts +33 -7
- package/src/cli/commands/connection-list.ts +25 -1
- package/src/cli/commands/mcp/harnesses.ts +36 -22
- package/src/cli/commands/mcp/register.ts +27 -8
- package/src/cli/commands/mcp/stdio.ts +0 -1
- package/src/cli/commands/operate/inspect.ts +21 -10
- package/src/cli/commands/operate/outputs.ts +94 -61
- package/src/cli/commands/operate/serve.ts +0 -4
- package/src/cli/commands/operate/token.ts +305 -35
- package/src/cli/commands/operate/tools.ts +28 -5
- package/src/cli/commands/operate.ts +7 -1
- package/src/cli/commands/profile/removal.ts +10 -9
- package/src/cli/config-repair-sweep.ts +18 -4
- package/src/cli/config-repair.ts +1 -1
- package/src/cli/config-templates.ts +14 -7
- package/src/cli/contract3-credentials.ts +8 -8
- package/src/cli/contract4.ts +7 -2
- package/src/cli/contract5.ts +234 -0
- package/src/cli/endpoint-url.ts +17 -3
- package/src/cli/main.ts +28 -4
- package/src/cli/publish.ts +13 -4
- package/src/cli/runtime/open.ts +19 -2
- package/src/cli/runtime/select.ts +0 -12
- package/src/cli/runtime.ts +0 -1
- package/src/cli/selection.ts +40 -10
- package/src/cli/usage.ts +12 -7
- package/src/cli/workspace-migrate.ts +18 -12
- package/src/connectivity/context.ts +17 -0
- package/src/connectivity/manifest/provider.ts +9 -1
- package/src/deployments/prepare.ts +8 -33
- package/src/deployments/report.ts +6 -3
- package/src/dispatch/context.ts +3 -0
- package/src/dispatch/dispatch.ts +5 -0
- package/src/profile/connections.ts +32 -0
- package/src/profile/index.ts +9 -0
- package/src/profile/schema.ts +47 -3
- package/src/profile/tokens.ts +137 -0
- package/src/profile/workspace.ts +1 -1
- package/src/providers/harness.ts +1 -0
- package/src/providers/setup/plan.ts +16 -0
- package/src/providers/setup/provider.ts +39 -12
- package/src/server/container.ts +3 -3
- package/src/server/endpoint.ts +17 -29
- package/src/server/harness.ts +28 -3
- package/src/server/index.ts +8 -8
- package/src/server/mcp/visibility.ts +11 -3
- package/src/server/read/deployed.ts +4 -0
- package/src/server/read/open.ts +4 -0
- package/src/server/read/routes.ts +15 -2
- package/src/server/read/state.ts +30 -2
|
@@ -1,12 +1,15 @@
|
|
|
1
1
|
import { RESERVED_PROVIDER_IDS } from '#connectivity';
|
|
2
|
+
import { PROVIDER_MANIFESTS } from '#providers/index.ts';
|
|
2
3
|
import {
|
|
3
4
|
connectionRefOf,
|
|
5
|
+
defaultConnectionLabel,
|
|
4
6
|
listProfiles,
|
|
5
7
|
loadProfileConfig,
|
|
6
8
|
readConnections,
|
|
7
9
|
resolveTargetWorkspace,
|
|
8
10
|
resolveWorkspaceRoot,
|
|
9
11
|
} from '#profile';
|
|
12
|
+
import { RESERVED_SURFACES } from '../config-repair.ts';
|
|
10
13
|
import { emit, heading, print, style, table } from '../output.ts';
|
|
11
14
|
import type { GlobalFlags } from '../runtime.ts';
|
|
12
15
|
|
|
@@ -104,6 +107,20 @@ export async function connectionList(flags: GlobalFlags & { json?: boolean }): P
|
|
|
104
107
|
});
|
|
105
108
|
}
|
|
106
109
|
|
|
110
|
+
/**
|
|
111
|
+
* What each provider is called, by id.
|
|
112
|
+
*
|
|
113
|
+
* The catalogue's manifests plus the owner layer, which is registered separately
|
|
114
|
+
* and is deliberately absent from `PROVIDERS`. A workspace-local manifest is not
|
|
115
|
+
* here: this command reads files rather than building a registry, and one custom
|
|
116
|
+
* provider falling back to its account is a smaller price than a registry build
|
|
117
|
+
* on a listing.
|
|
118
|
+
*/
|
|
119
|
+
const PROVIDER_NAMES = new Map<string, string>([
|
|
120
|
+
...PROVIDER_MANIFESTS.map((manifest): [string, string] => [manifest.id, manifest.name]),
|
|
121
|
+
...Object.entries(RESERVED_SURFACES),
|
|
122
|
+
]);
|
|
123
|
+
|
|
107
124
|
function row(one: ConnectionSummary): string[] {
|
|
108
125
|
// "granted to nobody" is said rather than left blank, because a blank column
|
|
109
126
|
// reads as "not loaded yet" and this is a fact about the config.
|
|
@@ -112,5 +129,12 @@ function row(one: ConnectionSummary): string[] {
|
|
|
112
129
|
? style.dim('no profile grants it')
|
|
113
130
|
: one.grantedTo.join(', ');
|
|
114
131
|
|
|
115
|
-
|
|
132
|
+
// The account was this column's fallback, which meant an unlabelled row read
|
|
133
|
+
// as its address and said nothing about which service the address is at. The
|
|
134
|
+
// derived name says both, and is the one the dashboard and the connect prompt
|
|
135
|
+
// show for the same row.
|
|
136
|
+
const named = PROVIDER_NAMES.get(one.provider);
|
|
137
|
+
const label = one.label ?? (named ? defaultConnectionLabel(named, one.account) : one.account);
|
|
138
|
+
|
|
139
|
+
return [` ${style.bold(one.key)}`, label, reach];
|
|
116
140
|
}
|
|
@@ -45,14 +45,17 @@ export interface AddInput {
|
|
|
45
45
|
readonly tokenEnv: string;
|
|
46
46
|
readonly scope: string;
|
|
47
47
|
/**
|
|
48
|
-
* Which
|
|
48
|
+
* Which workspace this registration is for.
|
|
49
49
|
*
|
|
50
|
-
* Not part of the URL —
|
|
51
|
-
*
|
|
52
|
-
* told to run afterwards do need it, and a `lanes link
|
|
53
|
-
* substitutes to nothing.
|
|
50
|
+
* Not part of the URL — one endpoint serves every profile in the workspace and
|
|
51
|
+
* each call names one in its `profile` argument. It is here because the shell
|
|
52
|
+
* commands a harness is told to run afterwards do need it, and a `lanes link
|
|
53
|
+
* token show` without it substitutes to nothing.
|
|
54
|
+
*
|
|
55
|
+
* There is no `profile` beside it any more (ADR-068). It was here for exactly
|
|
56
|
+
* one reader — the `export` line below — and what that line names is a token,
|
|
57
|
+
* which is the workspace's.
|
|
54
58
|
*/
|
|
55
|
-
readonly profile: string;
|
|
56
59
|
readonly target: string;
|
|
57
60
|
}
|
|
58
61
|
|
|
@@ -142,14 +145,18 @@ export const HARNESSES: readonly Harness[] = [
|
|
|
142
145
|
scoped: false,
|
|
143
146
|
storesToken: false,
|
|
144
147
|
home: CODEX_HOME,
|
|
145
|
-
|
|
148
|
+
// The env var only under `--headless`, which is what the header comment
|
|
149
|
+
// above claims and this did not do: it was passed unconditionally, so every
|
|
150
|
+
// Codex registration was a token registration while `storesToken: false`
|
|
151
|
+
// said otherwise and `afterAdd` told the operator to export a variable an
|
|
152
|
+
// OAuth-capable client would never read.
|
|
153
|
+
add: ({ name, url, tokenEnv, token }) => [
|
|
146
154
|
'mcp',
|
|
147
155
|
'add',
|
|
148
156
|
name,
|
|
149
157
|
'--url',
|
|
150
158
|
url,
|
|
151
|
-
'--bearer-token-env-var',
|
|
152
|
-
tokenEnv,
|
|
159
|
+
...(token ? ['--bearer-token-env-var', tokenEnv] : []),
|
|
153
160
|
],
|
|
154
161
|
get: (name) => ['mcp', 'get', name],
|
|
155
162
|
remove: (name) => ['mcp', 'remove', name],
|
|
@@ -157,19 +164,26 @@ export const HARNESSES: readonly Harness[] = [
|
|
|
157
164
|
// installs to both unchanged. Codex has no subagent directory, so it gets
|
|
158
165
|
// the skill and not the scout — and `mcp add` says which it did.
|
|
159
166
|
skills: () => join(CODEX_HOME(), 'skills'),
|
|
160
|
-
afterAdd: ({ tokenEnv,
|
|
161
|
-
|
|
162
|
-
'
|
|
163
|
-
//
|
|
164
|
-
//
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
167
|
+
afterAdd: ({ tokenEnv, target, token }) =>
|
|
168
|
+
// Nothing to say unless a token is actually in play. Without `--headless`
|
|
169
|
+
// this registration is a bare URL like Claude Code's, and telling somebody
|
|
170
|
+
// to export a variable nothing reads was the instruction that made
|
|
171
|
+
// `storesToken: false` read as a contradiction.
|
|
172
|
+
token === undefined
|
|
173
|
+
? []
|
|
174
|
+
: [
|
|
175
|
+
`Codex reads the token from $${tokenEnv} when it starts, so set it where Codex will see it:`,
|
|
176
|
+
'',
|
|
177
|
+
// `--workspace`, and this line is the reason it matters more here
|
|
178
|
+
// than anywhere else: an unresolvable substitution yields the empty
|
|
179
|
+
// string, the header becomes "Bearer ", and the only symptom is a
|
|
180
|
+
// 401 that reads as a bad token rather than a command that refused.
|
|
181
|
+
` export ${tokenEnv}="$(lanes link token show --raw --workspace ${target})"`,
|
|
182
|
+
'',
|
|
183
|
+
'Add that to your shell profile. This is the better half of the bargain: the token never',
|
|
184
|
+
'reaches ~/.codex/config.toml, and a "lanes link token rotate" is picked up on next launch',
|
|
185
|
+
'with no re-registration.',
|
|
186
|
+
],
|
|
173
187
|
},
|
|
174
188
|
];
|
|
175
189
|
|
|
@@ -1,6 +1,7 @@
|
|
|
1
|
+
import { anyIssuedToken } from '#profile';
|
|
1
2
|
import { endpointUrl } from '../../endpoint-url.ts';
|
|
2
3
|
import { fail, ok, print, style, warn } from '../../output.ts';
|
|
3
|
-
import {
|
|
4
|
+
import { openWorkspaceRuntime, type GlobalFlags } from '../../runtime.ts';
|
|
4
5
|
import { installFor } from './assets.ts';
|
|
5
6
|
import { HARNESSES, type AddInput, type Harness } from './harnesses.ts';
|
|
6
7
|
|
|
@@ -114,13 +115,32 @@ export async function mcpAdd(target: string | undefined, options: McpAddOptions)
|
|
|
114
115
|
const scope = options.scope ?? 'user';
|
|
115
116
|
const tokenEnv = options.tokenEnv ?? 'LANES_LINK_TOKEN';
|
|
116
117
|
|
|
117
|
-
|
|
118
|
+
// **The workspace, not a profile** (ADR-068). One endpoint serves every
|
|
119
|
+
// profile in the workspace and each call names one in its `profile` argument,
|
|
120
|
+
// so a registration was never per-profile: two different `--profile` values
|
|
121
|
+
// produced byte-identical harness commands. What kept the flag required was
|
|
122
|
+
// that the endpoint's token lived at `auth.token_ref` on a profile — a ref
|
|
123
|
+
// whose default was the same constant for all of them — and reading it meant
|
|
124
|
+
// resolving one. The token is the workspace's now, so there is nothing left
|
|
125
|
+
// to ask. `--profile` is still accepted, and narrows nothing here.
|
|
126
|
+
const runtime = await openWorkspaceRuntime(options);
|
|
118
127
|
|
|
119
128
|
try {
|
|
120
|
-
//
|
|
121
|
-
//
|
|
122
|
-
//
|
|
123
|
-
|
|
129
|
+
// **Only for `--headless`, and only if one has been issued.** The ordinary
|
|
130
|
+
// path registers a bare URL and the client authorises itself (ADR-062).
|
|
131
|
+
// Nothing is minted here: a token names the person it was issued to, and
|
|
132
|
+
// `mcp add` does not know who — which is what `token issue` is for.
|
|
133
|
+
const token = options.headless === true
|
|
134
|
+
? (await anyIssuedToken(runtime.resolution.workspaceRoot, runtime.credentials))?.value
|
|
135
|
+
: undefined;
|
|
136
|
+
|
|
137
|
+
if (options.headless === true && token === undefined) {
|
|
138
|
+
throw new Error(
|
|
139
|
+
'No static token is issued in this workspace, so --headless has nothing to write.\n' +
|
|
140
|
+
` Issue one: lanes link token issue --me --workspace ${runtime.target}\n` +
|
|
141
|
+
' Without --headless the client signs in for itself and needs none.',
|
|
142
|
+
);
|
|
143
|
+
}
|
|
124
144
|
|
|
125
145
|
// The target's own address, not the local one. This built
|
|
126
146
|
// `http://<host>:<port>/mcp` unconditionally, so `mcp add --workspace cloud`
|
|
@@ -130,10 +150,9 @@ export async function mcpAdd(target: string | undefined, options: McpAddOptions)
|
|
|
130
150
|
const input: AddInput = {
|
|
131
151
|
name,
|
|
132
152
|
url,
|
|
133
|
-
...(
|
|
153
|
+
...(token === undefined ? {} : { token }),
|
|
134
154
|
tokenEnv,
|
|
135
155
|
scope,
|
|
136
|
-
profile: runtime.resolution.profile,
|
|
137
156
|
target: runtime.resolution.target,
|
|
138
157
|
};
|
|
139
158
|
|
|
@@ -48,7 +48,6 @@ export async function mcpStdio(
|
|
|
48
48
|
reconciled: ({ profile, plan, ofMany }) =>
|
|
49
49
|
printErr(`${ofMany ? `${profile}\n` : ''}${plan}`),
|
|
50
50
|
// Unreachable: this path never mints a token, because it never needs one.
|
|
51
|
-
tokenMinted: () => {},
|
|
52
51
|
},
|
|
53
52
|
log: {
|
|
54
53
|
debug() {},
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { formatPlan, planIsNoop, planReconcile } from '#registry';
|
|
2
|
-
import type
|
|
2
|
+
import { readEndpointTokens, type ConnectionConfig, type SelectedConnection } from '#profile';
|
|
3
3
|
import { DEFAULT_SURFACES } from '../../config-repair.ts';
|
|
4
4
|
import { announce, announceProfile, emit, fail, ok, print, warn } from '../../output.ts';
|
|
5
5
|
import { staleNudge } from '../../release.ts';
|
|
@@ -120,16 +120,27 @@ export async function doctor(flags: DoctorFlags): Promise<void> {
|
|
|
120
120
|
checks.push('config is valid');
|
|
121
121
|
checks.push('state store is reachable');
|
|
122
122
|
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
123
|
+
// **A row whose value is missing, not a missing token** (ADR-068). Having
|
|
124
|
+
// issued none is the healthy default — a client signs in for itself — so
|
|
125
|
+
// reporting that as a problem would make every working workspace fail
|
|
126
|
+
// doctor. What is genuinely broken is a row pointing at nothing: it matches
|
|
127
|
+
// no credential, and from the client's side reads exactly like a wrong
|
|
128
|
+
// token. That is what a half-finished `secrets push` leaves behind.
|
|
129
|
+
const issued = await readEndpointTokens(runtime.resolution.workspaceRoot);
|
|
130
|
+
const orphaned: string[] = [];
|
|
131
|
+
for (const row of issued) {
|
|
132
|
+
if ((await runtime.credentials.get(row.ref)) === null) orphaned.push(row.id);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
if (issued.length > 0 && orphaned.length === 0) {
|
|
136
|
+
checks.push(`${issued.length} endpoint token(s) present`);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
for (const id of orphaned) {
|
|
129
140
|
warnings.push({
|
|
130
|
-
kind: '
|
|
131
|
-
message:
|
|
132
|
-
fix:
|
|
141
|
+
kind: 'orphaned_endpoint_token',
|
|
142
|
+
message: `token "${id}" has a row but no value in this workspace's store — it matches nothing`,
|
|
143
|
+
fix: `lanes link token rotate --id ${id} --workspace ${runtime.resolution.target}`,
|
|
133
144
|
});
|
|
134
145
|
}
|
|
135
146
|
|
|
@@ -1,8 +1,8 @@
|
|
|
1
|
-
import { listProfiles } from '#profile';
|
|
1
|
+
import { anyIssuedToken, listProfiles } from '#profile';
|
|
2
2
|
import { fileURLToPath } from 'node:url';
|
|
3
3
|
import { deployedUrl, endpointHealth, localUrl } from '../../endpoint-url.ts';
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
4
|
+
import { announceWorkspace, heading, print, style, warn } from '../../output.ts';
|
|
5
|
+
import { openWorkspaceRuntime, type GlobalFlags } from '../../runtime.ts';
|
|
6
6
|
|
|
7
7
|
export interface OutputsFlags extends GlobalFlags {
|
|
8
8
|
readonly show?: boolean | undefined;
|
|
@@ -23,18 +23,27 @@ export interface OutputsFlags extends GlobalFlags {
|
|
|
23
23
|
* the harness's business.
|
|
24
24
|
*/
|
|
25
25
|
export async function outputs(flags: OutputsFlags): Promise<void> {
|
|
26
|
-
|
|
26
|
+
// The workspace, matching what this command's own subject has always been
|
|
27
|
+
// (ADR-068). It asked for a profile only to find the endpoint's token, and
|
|
28
|
+
// that token is the workspace's.
|
|
29
|
+
const runtime = await openWorkspaceRuntime(flags);
|
|
27
30
|
|
|
28
31
|
try {
|
|
29
|
-
|
|
32
|
+
// Whatever has been issued, or nothing — which is the ordinary state.
|
|
33
|
+
// Nothing is minted: `outputs` reports, and a token names a person.
|
|
34
|
+
const held = await anyIssuedToken(runtime.resolution.workspaceRoot, runtime.credentials);
|
|
35
|
+
const token = held?.value;
|
|
30
36
|
const declared = runtime.declared.deploy;
|
|
31
37
|
const deployed = await deployedUrl(declared);
|
|
32
38
|
// Not `endpointUrl`, which would ask the platform a second time for an
|
|
33
39
|
// answer this line already has.
|
|
34
40
|
const url = deployed ?? localUrl(runtime.config);
|
|
35
41
|
|
|
42
|
+
// Unauthenticated when nothing is issued. `/health` answers either way; what
|
|
43
|
+
// it withholds without a credential is the profile list, so `mine` below is
|
|
44
|
+
// then decided by the endpoint answering at all.
|
|
36
45
|
const live = await endpointHealth(url, token);
|
|
37
|
-
const mine = live
|
|
46
|
+
const mine = live !== null;
|
|
38
47
|
|
|
39
48
|
// Live if it is up, otherwise every profile in this target's workspace.
|
|
40
49
|
// Those are the same set now: a profile lives in exactly one target
|
|
@@ -51,9 +60,8 @@ export async function outputs(flags: OutputsFlags): Promise<void> {
|
|
|
51
60
|
running: mine,
|
|
52
61
|
deployed: deployed !== null,
|
|
53
62
|
target: runtime.target,
|
|
54
|
-
primary: runtime.resolution.profile,
|
|
55
63
|
profiles,
|
|
56
|
-
...(flags.show ? { token } : {}),
|
|
64
|
+
...(flags.show && token !== undefined ? { token } : {}),
|
|
57
65
|
},
|
|
58
66
|
null,
|
|
59
67
|
2,
|
|
@@ -62,7 +70,7 @@ export async function outputs(flags: OutputsFlags): Promise<void> {
|
|
|
62
70
|
return;
|
|
63
71
|
}
|
|
64
72
|
|
|
65
|
-
|
|
73
|
+
announceWorkspace(runtime.resolution);
|
|
66
74
|
|
|
67
75
|
heading('Endpoint');
|
|
68
76
|
print(
|
|
@@ -75,21 +83,19 @@ export async function outputs(flags: OutputsFlags): Promise<void> {
|
|
|
75
83
|
print(style.dim(` ${declared.platform} service "${declared.service}" for target "${runtime.target}".`));
|
|
76
84
|
}
|
|
77
85
|
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
}
|
|
88
|
-
print(style.dim(' Each call names one, in its `profile` argument.'));
|
|
86
|
+
heading(`Profiles served by it (${profiles.length})`);
|
|
87
|
+
for (const profile of profiles) print(` ${profile}`);
|
|
88
|
+
print(
|
|
89
|
+
style.dim(
|
|
90
|
+
' Each call names one, in its `profile` argument. Which of these a client\n' +
|
|
91
|
+
' actually reaches is decided by who signs in — every profile whose members\n' +
|
|
92
|
+
' list them, and no others.',
|
|
93
|
+
),
|
|
94
|
+
);
|
|
89
95
|
|
|
90
|
-
if (flags.show) {
|
|
96
|
+
if (flags.show && token !== undefined) {
|
|
91
97
|
heading('Token');
|
|
92
|
-
print(` ${token}`);
|
|
98
|
+
print(` ${token} ${style.dim(`(${held?.id})`)}`);
|
|
93
99
|
}
|
|
94
100
|
|
|
95
101
|
heading('Register with your agent');
|
|
@@ -100,36 +106,59 @@ export async function outputs(flags: OutputsFlags): Promise<void> {
|
|
|
100
106
|
);
|
|
101
107
|
print('');
|
|
102
108
|
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
` --header "Authorization: Bearer $(${invocation.command})"`,
|
|
112
|
-
);
|
|
109
|
+
// **The bare URL, and no header** (ADR-062). This printed the
|
|
110
|
+
// `Authorization: Bearer $(…)` form unconditionally, which was the shape
|
|
111
|
+
// before every endpoint ran the authorization flow — a registration that
|
|
112
|
+
// carries a credential, bypasses consent and expiry, and leaves a long-lived
|
|
113
|
+
// token in a harness config. The client discovers
|
|
114
|
+
// `/.well-known/oauth-protected-resource` from the 401 and signs its owner
|
|
115
|
+
// in instead.
|
|
116
|
+
print(` claude mcp add --transport http lanes-link ${url}`);
|
|
113
117
|
print('');
|
|
114
|
-
|
|
115
|
-
if (!invocation.onPath) {
|
|
116
|
-
// The failure this prevents is nasty: an unresolvable command substitutes
|
|
117
|
-
// to the empty string, the header becomes "Bearer ", and the only symptom
|
|
118
|
-
// is a 401 that looks like a bad token rather than a missing binary.
|
|
119
|
-
print(warn('lanes is not on your PATH, so the short form would substitute to nothing.'));
|
|
120
|
-
print(style.dim(' The command above uses this checkout instead. To shorten it permanently:'));
|
|
121
|
-
print(` cd ${process.cwd()} && bun link`);
|
|
122
|
-
print('');
|
|
123
|
-
}
|
|
124
|
-
|
|
125
118
|
print(
|
|
126
119
|
style.dim(
|
|
127
|
-
'
|
|
128
|
-
'
|
|
129
|
-
'
|
|
130
|
-
'
|
|
120
|
+
' No credential goes into that command. The client reads this endpoint\'s\n' +
|
|
121
|
+
' protected-resource document, sends its owner to sign in, and comes back\n' +
|
|
122
|
+
' holding a token of its own — so a config file synced to a dotfiles repo\n' +
|
|
123
|
+
' is not a leak, and rotating a static token does not invalidate it.',
|
|
131
124
|
),
|
|
132
125
|
);
|
|
126
|
+
|
|
127
|
+
heading('For a machine with no browser');
|
|
128
|
+
if (token === undefined) {
|
|
129
|
+
print(style.dim(' No static token is issued in this workspace.'));
|
|
130
|
+
print(
|
|
131
|
+
style.dim(
|
|
132
|
+
` lanes link token issue --me --workspace ${runtime.target}\n` +
|
|
133
|
+
' It reaches the profiles that list your subject as a member, and nothing else.',
|
|
134
|
+
),
|
|
135
|
+
);
|
|
136
|
+
} else {
|
|
137
|
+
const invocation = await tokenInvocation(runtime.resolution.target);
|
|
138
|
+
print(
|
|
139
|
+
` claude mcp add --transport http lanes-link ${url} \\\n` +
|
|
140
|
+
` --header "Authorization: Bearer $(${invocation.command})"`,
|
|
141
|
+
);
|
|
142
|
+
print('');
|
|
143
|
+
if (!invocation.onPath) {
|
|
144
|
+
// The failure this prevents is nasty: an unresolvable command
|
|
145
|
+
// substitutes to the empty string, the header becomes "Bearer ", and
|
|
146
|
+
// the only symptom is a 401 that looks like a bad token rather than a
|
|
147
|
+
// missing binary.
|
|
148
|
+
print(warn('lanes is not on your PATH, so the short form would substitute to nothing.'));
|
|
149
|
+
print(style.dim(' The command above uses this checkout instead. To shorten it permanently:'));
|
|
150
|
+
print(` cd ${process.cwd()} && bun link`);
|
|
151
|
+
print('');
|
|
152
|
+
}
|
|
153
|
+
print(
|
|
154
|
+
style.dim(
|
|
155
|
+
' CI only, and it is narrower than it looks: the token reaches the profiles\n' +
|
|
156
|
+
' its subject is a member of. The $(…) keeps it out of your agent\'s context\n' +
|
|
157
|
+
' and out of the transcript, but it resolves once and is stored as a literal —\n' +
|
|
158
|
+
' so a rotate means registering again.',
|
|
159
|
+
),
|
|
160
|
+
);
|
|
161
|
+
}
|
|
133
162
|
} finally {
|
|
134
163
|
await runtime.close();
|
|
135
164
|
}
|
|
@@ -146,28 +175,32 @@ export async function outputs(flags: OutputsFlags): Promise<void> {
|
|
|
146
175
|
* checkout, which always works.
|
|
147
176
|
*/
|
|
148
177
|
export async function tokenInvocation(
|
|
149
|
-
expected: string,
|
|
150
|
-
profile: string,
|
|
151
178
|
target: string,
|
|
152
179
|
): Promise<{ command: string; onPath: boolean }> {
|
|
153
|
-
//
|
|
154
|
-
// token is per-
|
|
155
|
-
// `token show --raw` hands over the local one beside a deployed URL — a
|
|
156
|
-
// credential that looks like an answer and fails as a wrong password.
|
|
157
|
-
//
|
|
158
|
-
//
|
|
159
|
-
|
|
180
|
+
// `--workspace`, always, and from the *resolved* selection rather than the
|
|
181
|
+
// flags. A token is per-workspace, so `outputs --workspace cloud` printing a
|
|
182
|
+
// bare `token show --raw` hands over the local one beside a deployed URL — a
|
|
183
|
+
// credential that looks like an answer and fails as a wrong password.
|
|
184
|
+
//
|
|
185
|
+
// No `--profile` any more (ADR-068): `token show` refuses one, so printing it
|
|
186
|
+
// here would emit a line that cannot be pasted.
|
|
187
|
+
const selection = ` --workspace ${target}`;
|
|
160
188
|
const short = `lanes link token show --raw${selection}`;
|
|
161
|
-
const argv = ['link', 'token', 'show', '--raw', '--
|
|
189
|
+
const argv = ['link', 'token', 'show', '--raw', '--workspace', target];
|
|
162
190
|
|
|
163
191
|
const resolved = Bun.which('lanes');
|
|
164
192
|
if (resolved) {
|
|
165
193
|
try {
|
|
166
194
|
const result = Bun.spawnSync([resolved, ...argv]);
|
|
167
|
-
//
|
|
168
|
-
//
|
|
169
|
-
//
|
|
170
|
-
|
|
195
|
+
// Exit status and a plausible token, rather than a comparison against a
|
|
196
|
+
// known value. This used to be handed the expected token and check for
|
|
197
|
+
// equality, which caught a `lanes` on PATH belonging to a different
|
|
198
|
+
// workspace. It cannot now: with several rows issued, `token show`
|
|
199
|
+
// refuses without `--id` — so demanding one value back would reject a
|
|
200
|
+
// correctly-installed binary. What survives is the check that matters for
|
|
201
|
+
// the failure this function exists to prevent, which is a substitution
|
|
202
|
+
// that yields nothing at all.
|
|
203
|
+
if (result.success && new TextDecoder().decode(result.stdout).trim().startsWith('llk_')) {
|
|
171
204
|
return { command: short, onPath: true };
|
|
172
205
|
}
|
|
173
206
|
} catch {
|
|
@@ -61,7 +61,6 @@ export async function start(
|
|
|
61
61
|
flags: resolved,
|
|
62
62
|
port: flags.port,
|
|
63
63
|
only: flags.only,
|
|
64
|
-
mintToken: true,
|
|
65
64
|
// Stderr, not stdout: `--json` and `--raw` callers parse the other stream.
|
|
66
65
|
// A refused credential is the event worth seeing while this runs in the
|
|
67
66
|
// foreground, and until now nothing printed it.
|
|
@@ -72,9 +71,6 @@ export async function start(
|
|
|
72
71
|
print(plan);
|
|
73
72
|
print(ok(`reconciled ${ofMany ? profile : ''}`.trim()));
|
|
74
73
|
},
|
|
75
|
-
tokenMinted({ target }) {
|
|
76
|
-
print(warn(`minted a token — run: lanes link outputs --show --workspace ${target}`));
|
|
77
|
-
},
|
|
78
74
|
},
|
|
79
75
|
});
|
|
80
76
|
|