@lanes-sh/link 0.7.2 → 0.9.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 +20 -10
- package/instructions/agents/lanes-link-scout.md +2 -2
- package/instructions/skills/lanes-link/SKILL.md +148 -73
- package/package.json +2 -1
- package/src/audit/index.ts +8 -1
- package/src/auth/index.ts +58 -2
- package/src/auth/lanes/assertion.ts +256 -0
- package/src/auth/lanes/callback.ts +135 -0
- package/src/auth/lanes/federation.ts +50 -0
- package/src/auth/lanes/login.ts +294 -0
- package/src/auth/lanes/members.ts +103 -0
- package/src/auth/lanes/session.ts +97 -0
- package/src/auth/oauth/grant.ts +183 -0
- package/src/auth/oauth/result.ts +27 -0
- package/src/auth/oauth/server.ts +176 -203
- package/src/auth/oauth/store.ts +85 -4
- package/src/auth/remote.ts +32 -9
- package/src/cli/accepts.ts +109 -0
- package/src/cli/argv.ts +57 -3
- package/src/cli/audit-change.ts +140 -0
- package/src/cli/callback-page.ts +36 -115
- package/src/cli/commands/auth-dispatch.ts +48 -0
- package/src/cli/commands/auth.ts +229 -0
- package/src/cli/commands/connect/accounts.ts +4 -4
- package/src/cli/commands/connect/authorise.ts +4 -4
- package/src/cli/commands/connect/bind-credential.ts +2 -1
- package/src/cli/commands/connect/custom/index.ts +1 -1
- package/src/cli/commands/connect/custom/write.ts +2 -2
- package/src/cli/commands/connect/grant.ts +29 -14
- package/src/cli/commands/connect/index.ts +90 -88
- package/src/cli/commands/connect/options.ts +83 -0
- package/src/cli/commands/connect/registration.ts +50 -0
- package/src/cli/commands/connect/requirements.ts +1 -1
- package/src/cli/commands/connect/settle.ts +16 -6
- package/src/cli/commands/connect/target-note.ts +7 -2
- package/src/cli/commands/connect/unknown.ts +1 -1
- package/src/cli/commands/connect/variables.ts +3 -2
- package/src/cli/commands/connection-list.ts +116 -0
- package/src/cli/commands/connection.ts +183 -165
- package/src/cli/commands/grant.ts +140 -0
- package/src/cli/commands/identity.ts +24 -12
- package/src/cli/commands/knowledge/index.ts +49 -89
- package/src/cli/commands/knowledge/migrate.ts +79 -13
- package/src/cli/commands/knowledge/show.ts +97 -0
- package/src/cli/commands/knowledge.ts +2 -1
- package/src/cli/commands/mcp/harnesses.ts +30 -8
- package/src/cli/commands/mcp/onboarding.ts +86 -0
- package/src/cli/commands/mcp/register.ts +16 -2
- package/src/cli/commands/mcp.ts +1 -0
- package/src/cli/commands/members.ts +288 -0
- package/src/cli/commands/operate/attach.ts +3 -3
- package/src/cli/commands/operate/audit.ts +11 -7
- package/src/cli/commands/operate/auth.ts +28 -11
- package/src/cli/commands/operate/findings.ts +2 -1
- package/src/cli/commands/operate/inspect.ts +37 -19
- package/src/cli/commands/operate/migrate.ts +33 -13
- package/src/cli/commands/operate/outputs.ts +3 -3
- package/src/cli/commands/operate/pair-certificate.ts +141 -0
- package/src/cli/commands/operate/pair.ts +324 -0
- package/src/cli/commands/operate/policy.ts +73 -22
- package/src/cli/commands/operate/serve.ts +53 -5
- package/src/cli/commands/operate/status.ts +18 -10
- package/src/cli/commands/operate/tools.ts +2 -2
- package/src/cli/commands/operate.ts +2 -0
- package/src/cli/commands/owner/assets.ts +2 -2
- package/src/cli/commands/owner/entities.ts +2 -2
- package/src/cli/commands/owner/memory.ts +2 -2
- package/src/cli/commands/owner/shared.ts +13 -2
- package/src/cli/commands/owner/skills.ts +28 -8
- package/src/cli/commands/owner/tasks.ts +2 -2
- package/src/cli/commands/owner/vault.ts +3 -3
- package/src/cli/commands/profile/disposition.ts +236 -0
- package/src/cli/commands/profile/removal.ts +154 -64
- package/src/cli/commands/profile/remove.ts +83 -7
- package/src/cli/commands/profile.ts +79 -16
- package/src/cli/commands/relabel.ts +112 -0
- package/src/cli/commands/secrets.ts +39 -17
- package/src/cli/commands/set-workspace.ts +96 -0
- package/src/cli/commands/setup.ts +2 -2
- package/src/cli/commands/sync.ts +8 -8
- package/src/cli/commands/target.ts +9 -7
- package/src/cli/commands/update-migration.ts +54 -0
- package/src/cli/commands/update.ts +78 -24
- package/src/cli/config-edit.ts +99 -143
- package/src/cli/config-migrate.ts +82 -64
- package/src/cli/config-repair-sweep.ts +119 -0
- package/src/cli/config-repair.ts +131 -125
- package/src/cli/config-templates.ts +200 -0
- package/src/cli/contract3-credentials.ts +294 -0
- package/src/cli/contract3-data.ts +262 -0
- package/src/cli/contract3-layout.ts +46 -0
- package/src/cli/contract3-shape.ts +212 -0
- package/src/cli/contract3.ts +399 -0
- package/src/cli/contract4-credentials.ts +207 -0
- package/src/cli/contract4-data.ts +399 -0
- package/src/cli/contract4-rename.ts +73 -0
- package/src/cli/contract4-yaml.ts +223 -0
- package/src/cli/contract4.ts +342 -0
- package/src/cli/endpoint-url.ts +1 -1
- package/src/cli/identity.ts +44 -26
- package/src/cli/lanes.ts +25 -1
- package/src/cli/main.ts +94 -14
- package/src/cli/migrate-move.ts +166 -0
- package/src/cli/migrate-plan.ts +12 -6
- package/src/cli/output.ts +34 -1
- package/src/cli/publish.ts +6 -7
- package/src/cli/runtime/open.ts +64 -99
- package/src/cli/runtime/registry.ts +6 -7
- package/src/cli/runtime/select.ts +2 -11
- package/src/cli/runtime/stores.ts +58 -0
- package/src/cli/runtime/types.ts +106 -0
- package/src/cli/runtime/vault.ts +19 -4
- package/src/cli/runtime/workspace.ts +60 -0
- package/src/cli/runtime.ts +2 -1
- package/src/cli/selection-require.ts +44 -13
- package/src/cli/selection.ts +127 -145
- package/src/cli/usage.ts +40 -20
- package/src/cli/workspace-migrate.ts +152 -22
- package/src/connectivity/manifest/provider.ts +34 -13
- package/src/connectivity/manifest/requirements.ts +1 -1
- package/src/connectivity/transports/imap/parser.ts +70 -9
- package/src/deployments/adapters/filesystem.ts +18 -3
- package/src/deployments/bind.ts +1 -1
- package/src/deployments/deploy.ts +38 -29
- package/src/deployments/gcp/bucket.ts +58 -11
- package/src/deployments/gcp/provision.ts +7 -7
- package/src/deployments/knowledge.ts +9 -4
- package/src/deployments/prepare.ts +72 -24
- package/src/deployments/record.ts +1 -1
- package/src/deployments/report.ts +2 -2
- package/src/deployments/serving.ts +15 -74
- package/src/deployments/target.ts +34 -13
- package/src/deployments/upload.ts +60 -27
- package/src/dispatch/deps.ts +88 -0
- package/src/dispatch/dispatch.ts +21 -62
- package/src/policy/index.ts +47 -15
- package/src/profile/connections.ts +195 -0
- package/src/profile/deployments.ts +86 -8
- package/src/profile/index.ts +35 -6
- package/src/profile/knowledge.ts +18 -5
- package/src/profile/layout.ts +163 -90
- package/src/profile/load.ts +133 -64
- package/src/profile/pairing.ts +32 -0
- package/src/profile/primitives.ts +35 -1
- package/src/profile/registry.ts +6 -6
- package/src/profile/schema.ts +181 -21
- package/src/profile/targets.ts +21 -9
- package/src/profile/testing.ts +104 -2
- package/src/profile/workspace.ts +124 -33
- package/src/providers/assets/provider.ts +6 -6
- package/src/providers/custom/index.ts +1 -1
- package/src/providers/custom/load.ts +2 -3
- package/src/providers/entities/provider.ts +6 -6
- package/src/providers/entities/writes.ts +1 -1
- package/src/providers/identity/provider.ts +2 -2
- package/src/providers/memory/provider.ts +26 -8
- package/src/providers/setup/plan.ts +1 -1
- package/src/providers/setup/provider.ts +3 -3
- package/src/providers/skills/provider.ts +2 -2
- package/src/providers/slack/index.ts +2 -2
- package/src/providers/tasks/provider.ts +6 -6
- package/src/providers/vault/provider.ts +1 -1
- package/src/registry/policy-bridge.ts +33 -11
- package/src/registry/reconcile.ts +4 -4
- package/src/registry/registry.ts +1 -1
- package/src/server/authorization.ts +94 -0
- package/src/server/edge.ts +14 -1
- package/src/server/endpoint.ts +89 -104
- package/src/server/generation.ts +10 -1
- package/src/server/harness.ts +71 -13
- package/src/server/index.ts +31 -0
- package/src/server/mcp/build.ts +20 -1
- package/src/server/mcp/client-info.ts +54 -0
- package/src/server/mcp/guide.ts +120 -0
- package/src/server/mcp/instructions.ts +22 -22
- package/src/server/mcp/prompts.ts +7 -3
- package/src/server/mcp/resources.ts +16 -8
- package/src/server/mcp/routing.ts +3 -3
- package/src/server/mcp/tools.ts +25 -6
- package/src/server/mcp/visibility.ts +74 -7
- package/src/server/oauth.ts +29 -109
- package/src/server/read/credential.ts +134 -0
- package/src/server/read/deployed.ts +56 -0
- package/src/server/read/listener.ts +54 -0
- package/src/server/read/open.ts +101 -0
- package/src/server/read/routes.ts +247 -0
- package/src/server/read/state.ts +171 -0
- package/src/stores/blobs/conformance.ts +19 -0
- package/src/stores/state/index.ts +76 -10
- package/src/stores/state/testing.ts +5 -1
|
@@ -0,0 +1,324 @@
|
|
|
1
|
+
import { randomBytes } from 'node:crypto';
|
|
2
|
+
import {
|
|
3
|
+
ConfigError,
|
|
4
|
+
PAIR_CERT_REF,
|
|
5
|
+
PAIR_KEY_REF,
|
|
6
|
+
PAIR_TOKEN_REF,
|
|
7
|
+
loadWorkspaceProfiles,
|
|
8
|
+
openTarget,
|
|
9
|
+
type LoadedProfile,
|
|
10
|
+
resolveWorkspaceRoot,
|
|
11
|
+
} from '#profile';
|
|
12
|
+
import type { ResolvedTarget } from '#profile';
|
|
13
|
+
import { deployedUrl } from '../../endpoint-url.ts';
|
|
14
|
+
import { ensureCertificate } from './pair-certificate.ts';
|
|
15
|
+
import { recordConfigChange } from '../../audit-change.ts';
|
|
16
|
+
import { ok, print, style } from '../../output.ts';
|
|
17
|
+
import type { SecretStore } from '#secrets';
|
|
18
|
+
import { openSecretStoreFor, type GlobalFlags } from '../../runtime.ts';
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* `lanes link pair` — let the Lanes dashboard read this machine (ADR-063).
|
|
22
|
+
*
|
|
23
|
+
* Three things, and each is the operator's to decline. It installs a locally
|
|
24
|
+
* trusted certificate, mints a credential that reads the whole workspace, and
|
|
25
|
+
* hands the browser a link carrying it. None of that happens without being
|
|
26
|
+
* asked for, and none of it is implied by `start`.
|
|
27
|
+
*
|
|
28
|
+
* **The certificate is the largest side effect any command in this CLI has.**
|
|
29
|
+
* It is a persistent change to the machine's trust store, made by a CLI, and
|
|
30
|
+
* ADR-053's precedent applies unchanged: offer it, name what it is, and stop if
|
|
31
|
+
* declined. A run with nobody at the terminal is refused rather than assumed.
|
|
32
|
+
*
|
|
33
|
+
* **The token travels in a fragment.** `#pair=` is never sent to a server, so a
|
|
34
|
+
* credential for a surface whose entire point is that Lanes cannot see it does
|
|
35
|
+
* not end up in a Lanes access log, a proxy, or a referrer header.
|
|
36
|
+
*
|
|
37
|
+
* **A deployed workspace pairs too, and skips all of that** (ADR-064). The
|
|
38
|
+
* certificate was the whole of the old refusal — installing one for an address
|
|
39
|
+
* this machine does not answer on is meaningless — and it is the one piece a
|
|
40
|
+
* deployed endpoint does not need, because the platform terminates TLS with a
|
|
41
|
+
* certificate a browser already trusts. What remains is a token and an address.
|
|
42
|
+
*
|
|
43
|
+
* **It names a workspace, not a profile**, because that is what it pairs. The
|
|
44
|
+
* surface it opens lists every connection and every profile the workspace holds,
|
|
45
|
+
* and the credential it mints reads all of them — so asking which profile was
|
|
46
|
+
* asking a question with no answer, and implying a per-profile pairing that does
|
|
47
|
+
* not exist. `--profile` is still accepted, and picks the port when profiles
|
|
48
|
+
* disagree about one.
|
|
49
|
+
*/
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Where the three pieces live in the credential store.
|
|
53
|
+
*
|
|
54
|
+
* Declared in `#profile` rather than here, because three components read these
|
|
55
|
+
* names now and only one of them is the CLI — the server opens the read surface
|
|
56
|
+
* with them and a deploy binds the token so the revision may read it. Importing
|
|
57
|
+
* a *command* module for a string constant pulled the CLI's output and prompt
|
|
58
|
+
* handling into the container's runtime graph. Re-exported because a year of
|
|
59
|
+
* callers spell them from here.
|
|
60
|
+
*/
|
|
61
|
+
export { PAIR_CERT_REF, PAIR_KEY_REF, PAIR_TOKEN_REF };
|
|
62
|
+
|
|
63
|
+
/** Where the dashboard lives, overridable so `lanes dev` can pair against it. */
|
|
64
|
+
const DASHBOARD_URL = process.env['LANES_WEB_URL'] ?? 'https://lanes.sh';
|
|
65
|
+
|
|
66
|
+
export interface PairFlags extends GlobalFlags {
|
|
67
|
+
/** Print the link for an existing pairing and change nothing. */
|
|
68
|
+
readonly print?: boolean | undefined;
|
|
69
|
+
/** Mint a fresh token, invalidating whatever a browser already holds. */
|
|
70
|
+
readonly rotate?: boolean | undefined;
|
|
71
|
+
/** Do not ask before installing mkcert. */
|
|
72
|
+
readonly yes?: boolean | undefined;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export interface PairDeps {
|
|
76
|
+
readonly which?: (binary: string) => string | null;
|
|
77
|
+
readonly run?: (command: readonly string[]) => Promise<string | null>;
|
|
78
|
+
readonly confirm?: (question: string) => Promise<boolean>;
|
|
79
|
+
readonly interactive?: boolean;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export async function pair(flags: PairFlags, deps: PairDeps = {}): Promise<void> {
|
|
83
|
+
const target = flags.target!;
|
|
84
|
+
const root = resolveWorkspaceRoot();
|
|
85
|
+
const resolved = await openTarget(root, target);
|
|
86
|
+
const { loaded: profiles } = await loadWorkspaceProfiles(resolved.workspaceRoot);
|
|
87
|
+
|
|
88
|
+
if (profiles.length === 0) {
|
|
89
|
+
throw new ConfigError(
|
|
90
|
+
`Workspace "${target}" holds no profiles, so there is no endpoint to pair.\n` +
|
|
91
|
+
` Create one with: lanes link profile add <name> --workspace ${target}`,
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// One endpoint serves every profile in a workspace, so they normally agree on
|
|
96
|
+
// a port and the choice is not a choice. Where they do not, the ambiguity is
|
|
97
|
+
// real and `--profile` is how it is settled — refused rather than guessed,
|
|
98
|
+
// because pairing the wrong port produces a dashboard that says "not
|
|
99
|
+
// connected" with everything working.
|
|
100
|
+
const named = flags.profile
|
|
101
|
+
? profiles.find((one: LoadedProfile) => one.profile === flags.profile)
|
|
102
|
+
: undefined;
|
|
103
|
+
|
|
104
|
+
if (flags.profile && !named) {
|
|
105
|
+
throw new ConfigError(
|
|
106
|
+
`Workspace "${target}" has no profile "${flags.profile}".\n` +
|
|
107
|
+
` It holds: ${profiles.map((one: LoadedProfile) => one.profile).join(', ')}`,
|
|
108
|
+
);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const ports = new Set(profiles.map((one: LoadedProfile) => one.config.instance.port));
|
|
112
|
+
if (!named && ports.size > 1) {
|
|
113
|
+
throw new ConfigError(
|
|
114
|
+
`The profiles in "${target}" do not agree on a port, so it is not clear which endpoint to pair.\n` +
|
|
115
|
+
profiles
|
|
116
|
+
.map((one: LoadedProfile) => ` ${one.profile} ${one.config.instance.port}`)
|
|
117
|
+
.join('\n') +
|
|
118
|
+
'\n Name one: lanes link pair --profile <name>',
|
|
119
|
+
);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const chosen = named ?? profiles[0]!;
|
|
123
|
+
const host = chosen.config.instance.host;
|
|
124
|
+
const credentials = await openSecretStoreFor(root, target);
|
|
125
|
+
|
|
126
|
+
// A workspace that declares a deployment is paired over the address the
|
|
127
|
+
// platform gave it, not over loopback — which is what `declared.deploy`
|
|
128
|
+
// answers and what `instance.host` does not: a deployed revision takes its
|
|
129
|
+
// host from the container's environment, so a profile bound to `127.0.0.1`
|
|
130
|
+
// in config is still serving `0.0.0.0` on Cloud Run.
|
|
131
|
+
if (resolved.declared.deploy) {
|
|
132
|
+
await pairDeployed({ flags, target, chosen, root, credentials, deploy: resolved.declared.deploy });
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
if (!isLoopback(host)) {
|
|
137
|
+
// Not deployed, and not on this machine either. There is no certificate
|
|
138
|
+
// this command could install for an address this machine does not answer
|
|
139
|
+
// on, and no platform URL to hand the browser instead.
|
|
140
|
+
throw new ConfigError(
|
|
141
|
+
`"${target}" is bound to ${host}, which is neither loopback nor a deployment.\n` +
|
|
142
|
+
' Pairing reaches an endpoint on *this* machine, or one `lanes link deploy` put\n' +
|
|
143
|
+
' somewhere with an address of its own.',
|
|
144
|
+
);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const readPort = chosen.config.instance.port + 1;
|
|
148
|
+
|
|
149
|
+
// `127.0.0.1` rather than `instance.host`, even though `localhost` and `::1`
|
|
150
|
+
// are equally loopback and equally covered by the certificate. It is one
|
|
151
|
+
// address for one machine, and the browser has to agree with `open.ts` about
|
|
152
|
+
// which spelling it is: two pairings of the same endpoint under two names
|
|
153
|
+
// would be two entries in the switcher, both working, neither wrong.
|
|
154
|
+
const address = `https://127.0.0.1:${readPort}`;
|
|
155
|
+
|
|
156
|
+
if (flags.print === true) {
|
|
157
|
+
const existing = await credentials.get(PAIR_TOKEN_REF);
|
|
158
|
+
if (existing === null) throw new ConfigError('Not paired yet. Run: lanes link pair');
|
|
159
|
+
print(pairingLink(existing, address));
|
|
160
|
+
return;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const certificate = await ensureCertificate(credentials, flags, deps);
|
|
164
|
+
|
|
165
|
+
const rotating = flags.rotate === true;
|
|
166
|
+
const existing = rotating ? null : await credentials.get(PAIR_TOKEN_REF);
|
|
167
|
+
const token = existing ?? `llp_${randomBytes(32).toString('base64url')}`;
|
|
168
|
+
if (existing === null) await credentials.set(PAIR_TOKEN_REF, token);
|
|
169
|
+
|
|
170
|
+
// The token itself never goes in, obviously. What is worth recording is that
|
|
171
|
+
// a credential reading the whole workspace now exists, and when — and for a
|
|
172
|
+
// rotation, that whatever a browser was holding stopped working at that
|
|
173
|
+
// moment.
|
|
174
|
+
if (existing === null) {
|
|
175
|
+
await recordConfigChange(chosen.config, root, target, {
|
|
176
|
+
capability: rotating ? 'config.pair.rotate' : 'config.pair.mint',
|
|
177
|
+
scope: target,
|
|
178
|
+
arguments: { readPort, certificate },
|
|
179
|
+
});
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
print(ok(certificate === 'reused' ? 'certificate already installed' : 'certificate installed'));
|
|
183
|
+
if (rotating) {
|
|
184
|
+
print(style.dim(' The previous pairing link no longer works. Re-open the new one.'));
|
|
185
|
+
}
|
|
186
|
+
print('');
|
|
187
|
+
print(ok(`the dashboard may now read ${style.bold(address)}`));
|
|
188
|
+
print('');
|
|
189
|
+
print(pairingLink(token, address));
|
|
190
|
+
print('');
|
|
191
|
+
print(
|
|
192
|
+
style.dim(
|
|
193
|
+
' Open that in a browser on this machine. The token is in the URL fragment,\n' +
|
|
194
|
+
' so it never reaches a Lanes server.\n' +
|
|
195
|
+
' It reads every connection, profile and audit entry in this workspace, and\n' +
|
|
196
|
+
' can change nothing. Take it back with: lanes link pair --rotate\n' +
|
|
197
|
+
'\n' +
|
|
198
|
+
` The endpoint has to be running: lanes link start --workspace ${target}`,
|
|
199
|
+
),
|
|
200
|
+
);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* Pairing a workspace that lives somewhere with an address of its own (ADR-064).
|
|
205
|
+
*
|
|
206
|
+
* The half of `pair` that is *not* shared with loopback is the certificate, and
|
|
207
|
+
* that was always the whole of the old refusal: installing one for an address
|
|
208
|
+
* this machine does not answer on is meaningless, and it is still meaningless.
|
|
209
|
+
* What was never the thing being refused is the credential and the address —
|
|
210
|
+
* the endpoint terminates TLS with a certificate a browser already trusts, so
|
|
211
|
+
* the two pieces that remain are a token and a URL.
|
|
212
|
+
*
|
|
213
|
+
* So there is no `mkcert` here, nothing is installed, and nothing is asked. The
|
|
214
|
+
* command writes one secret and prints a link.
|
|
215
|
+
*/
|
|
216
|
+
async function pairDeployed(input: {
|
|
217
|
+
flags: PairFlags;
|
|
218
|
+
target: string;
|
|
219
|
+
chosen: LoadedProfile;
|
|
220
|
+
root: string;
|
|
221
|
+
credentials: SecretStore;
|
|
222
|
+
deploy: NonNullable<ResolvedTarget['declared']['deploy']>;
|
|
223
|
+
}): Promise<void> {
|
|
224
|
+
const { flags, target, chosen, root, credentials } = input;
|
|
225
|
+
|
|
226
|
+
// `deployedUrl` asks the platform where the service ended up and degrades to
|
|
227
|
+
// null for every reason that is not this command's business — no driver, not
|
|
228
|
+
// deployed yet, no credentials for the project. A link with no address in it
|
|
229
|
+
// reads nothing, so this refuses rather than printing half of one.
|
|
230
|
+
const mcpUrl = await deployedUrl(input.deploy);
|
|
231
|
+
if (mcpUrl === null) {
|
|
232
|
+
throw new ConfigError(
|
|
233
|
+
`Could not find the address of "${target}".\n` +
|
|
234
|
+
' The service may not be deployed yet, or the platform CLI may not be signed in.\n' +
|
|
235
|
+
` Check it with: lanes link outputs --workspace ${target}`,
|
|
236
|
+
);
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// The read surface answers on the endpoint's own origin, beside `/mcp` rather
|
|
240
|
+
// than on a port of its own — Cloud Run routes exactly one.
|
|
241
|
+
const endpoint = mcpUrl.replace(/\/mcp$/, '');
|
|
242
|
+
|
|
243
|
+
if (flags.print === true) {
|
|
244
|
+
const existing = await credentials.get(PAIR_TOKEN_REF);
|
|
245
|
+
if (existing === null || existing === '') {
|
|
246
|
+
throw new ConfigError(`Not paired yet. Run: lanes link pair --workspace ${target}`);
|
|
247
|
+
}
|
|
248
|
+
print(pairingLink(existing, endpoint));
|
|
249
|
+
return;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
const rotating = flags.rotate === true;
|
|
253
|
+
const held = rotating ? null : await credentials.get(PAIR_TOKEN_REF);
|
|
254
|
+
|
|
255
|
+
// An empty string, not just a missing ref: `lanes link deploy` creates this
|
|
256
|
+
// secret with no version so the revision's IAM binding has something to
|
|
257
|
+
// attach to, and a secret that exists with no version reads back as null
|
|
258
|
+
// here and as `unpaired` there. Either shape means nobody has paired yet.
|
|
259
|
+
const existing = held === '' ? null : held;
|
|
260
|
+
const token = existing ?? `llp_${randomBytes(32).toString('base64url')}`;
|
|
261
|
+
if (existing === null) await credentials.set(PAIR_TOKEN_REF, token);
|
|
262
|
+
|
|
263
|
+
if (existing === null) {
|
|
264
|
+
await recordConfigChange(chosen.config, root, target, {
|
|
265
|
+
capability: rotating ? 'config.pair.rotate' : 'config.pair.mint',
|
|
266
|
+
scope: target,
|
|
267
|
+
arguments: { endpoint },
|
|
268
|
+
});
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
print(ok('no certificate needed — this endpoint already has one a browser trusts'));
|
|
272
|
+
if (rotating) {
|
|
273
|
+
print(style.dim(' The previous pairing link no longer works. Re-open the new one.'));
|
|
274
|
+
}
|
|
275
|
+
print('');
|
|
276
|
+
print(ok(`the dashboard may now read ${style.bold(endpoint)}`));
|
|
277
|
+
print('');
|
|
278
|
+
print(pairingLink(token, endpoint));
|
|
279
|
+
print('');
|
|
280
|
+
print(
|
|
281
|
+
style.dim(
|
|
282
|
+
' Open that in any browser, on any machine. The token is in the URL fragment,\n' +
|
|
283
|
+
' so it never reaches a Lanes server.\n' +
|
|
284
|
+
' It reads every connection, profile and audit entry in this workspace, and\n' +
|
|
285
|
+
' can change nothing. Take it back with:\n' +
|
|
286
|
+
` lanes link pair --workspace ${target} --rotate\n` +
|
|
287
|
+
'\n' +
|
|
288
|
+
' A rotation takes up to five seconds to be refused, because the endpoint\n' +
|
|
289
|
+
' caches what it read rather than calling Secret Manager per request.',
|
|
290
|
+
),
|
|
291
|
+
);
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/**
|
|
295
|
+
* The link the browser opens.
|
|
296
|
+
*
|
|
297
|
+
* The token rides in the fragment, which is never sent to a server — so a
|
|
298
|
+
* credential for a surface whose entire point is that Lanes cannot see this
|
|
299
|
+
* data does not land in a Lanes access log, a proxy, or a referrer header. The
|
|
300
|
+
* address rides beside it for the same reason and one more: it is the only
|
|
301
|
+
* thing telling the page which of several paired endpoints this link is for,
|
|
302
|
+
* and a query parameter would put a workspace's public address in that log.
|
|
303
|
+
*
|
|
304
|
+
* **A loopback link carries its address too**, and the parameter is required so
|
|
305
|
+
* that it cannot quietly stop. It used to be omitted here on the reasoning that
|
|
306
|
+
* loopback is derivable — and it is not: the read listener sits one port above
|
|
307
|
+
* whatever `instance.port` says, so an endpoint on any port but the default
|
|
308
|
+
* printed a link the dashboard then read at `7338`, reported unreachable, and
|
|
309
|
+
* gave no way to correct. The page still treats a link with no `at=` as
|
|
310
|
+
* loopback on the default port, because every link minted before this is that
|
|
311
|
+
* shape.
|
|
312
|
+
*
|
|
313
|
+
* Exported for `pair.test.ts` and for nothing else. The whole of the defect
|
|
314
|
+
* above was a shape nothing asserted on, in a command whose output no test
|
|
315
|
+
* reads, so the fix is not worth much without something that fails when the
|
|
316
|
+
* address goes missing again.
|
|
317
|
+
*/
|
|
318
|
+
export function pairingLink(token: string, endpoint: string): string {
|
|
319
|
+
return `${DASHBOARD_URL}/dashboard/link#pair=${token}&at=${encodeURIComponent(endpoint)}`;
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
function isLoopback(host: string): boolean {
|
|
323
|
+
return host === '127.0.0.1' || host === 'localhost' || host === '::1';
|
|
324
|
+
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { loadConfigFile } from '#profile';
|
|
2
|
+
import { recordConfigChange } from '../../audit-change.ts';
|
|
2
3
|
import { ConfigDocument } from '../../config-edit.ts';
|
|
3
|
-
import { announce, announceProfile, heading, ok, print, style, table } from '../../output.ts';
|
|
4
|
+
import { announce, announceProfile, heading, ok, print, style, table, warn } from '../../output.ts';
|
|
4
5
|
import { resolveProfile, resolveProfileOnly, type GlobalFlags } from '../../runtime.ts';
|
|
5
6
|
import { nextAfterEdit, publishProfileEdit } from '../../publish.ts';
|
|
6
7
|
|
|
@@ -15,56 +16,106 @@ export async function policyList(flags: GlobalFlags): Promise<void> {
|
|
|
15
16
|
const { selection, config } = await resolveProfileOnly(flags);
|
|
16
17
|
announceProfile(selection);
|
|
17
18
|
|
|
18
|
-
if (config.
|
|
19
|
-
print(style.dim('No
|
|
19
|
+
if (config.grants.length === 0) {
|
|
20
|
+
print(style.dim('No grants. Default deny is in effect: nothing is reachable.'));
|
|
20
21
|
return;
|
|
21
22
|
}
|
|
22
23
|
|
|
23
24
|
const expiry = (rule: { capability: string; expires_at?: string | undefined }) =>
|
|
24
25
|
rule.expires_at ? style.dim(`until ${rule.expires_at}`) : '';
|
|
25
26
|
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
27
|
+
// Grouped by connection rather than by effect, which reverses how this used to
|
|
28
|
+
// read. A rule governs one account now (ADR-058), so "what may be done with
|
|
29
|
+
// the work mailbox" is the question the listing should answer in one place —
|
|
30
|
+
// and the old shape, two lists of capabilities with the account nowhere in
|
|
31
|
+
// them, could not answer it at all.
|
|
32
|
+
for (const grant of config.grants) {
|
|
33
|
+
heading(grant.connection);
|
|
34
|
+
|
|
35
|
+
if (grant.allow.length === 0 && grant.deny.length === 0) {
|
|
36
|
+
print(style.dim(' Granted nothing. The connection is reachable and no capability is.'));
|
|
37
|
+
continue;
|
|
38
|
+
}
|
|
30
39
|
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
40
|
+
table([
|
|
41
|
+
...grant.allow.map((rule) => [` ${style.green('+')}`, rule.capability, expiry(rule)]),
|
|
42
|
+
...grant.deny.map((rule) => [` ${style.red('-')}`, rule.capability, expiry(rule)]),
|
|
43
|
+
]);
|
|
35
44
|
}
|
|
36
45
|
|
|
37
46
|
print('');
|
|
47
|
+
print(style.dim('A deny beats any allow on the same connection, whatever the order in the file.'));
|
|
38
48
|
print(
|
|
39
|
-
style.dim('
|
|
49
|
+
style.dim('A connection with no row above is not reachable at all, and is never advertised.'),
|
|
40
50
|
);
|
|
41
51
|
}
|
|
42
52
|
|
|
53
|
+
export interface PolicyFlags extends GlobalFlags {
|
|
54
|
+
readonly connection?: string | undefined;
|
|
55
|
+
}
|
|
56
|
+
|
|
43
57
|
export async function policyRule(
|
|
44
58
|
effect: 'allow' | 'deny',
|
|
45
59
|
capability: string,
|
|
46
|
-
flags:
|
|
60
|
+
flags: PolicyFlags,
|
|
47
61
|
): Promise<void> {
|
|
48
62
|
const { resolution, config, target } = await resolveProfile(flags);
|
|
49
|
-
const document = await ConfigDocument.open(resolution.workspaceRoot, resolution.profile);
|
|
50
63
|
|
|
51
|
-
|
|
52
|
-
|
|
64
|
+
// Required, and refused rather than guessed. A rule has to land in a row, and
|
|
65
|
+
// with two mailboxes granted there is no answer to "which one" that is not a
|
|
66
|
+
// guess about which account the operator meant to widen.
|
|
67
|
+
const key = flags.connection;
|
|
68
|
+
if (key === undefined) {
|
|
69
|
+
throw new Error(
|
|
70
|
+
`--connection is required. A rule governs one connection (ADR-058).\n` +
|
|
71
|
+
(config.grants.length > 0
|
|
72
|
+
? ` This profile grants: ${config.grants.map((grant) => grant.connection).join(', ')}`
|
|
73
|
+
: ` This profile grants nothing yet. Run: lanes link status --profile ${resolution.profile}`),
|
|
74
|
+
);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const index = config.grants.findIndex((grant) => grant.connection === key);
|
|
78
|
+
if (index === -1) {
|
|
79
|
+
throw new Error(
|
|
80
|
+
`Profile "${resolution.profile}" does not grant "${key}".\n` +
|
|
81
|
+
` Grant it first, then narrow it:\n` +
|
|
82
|
+
` lanes link grant ${key} --profile ${resolution.profile}`,
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const grant = config.grants[index]!;
|
|
87
|
+
if (grant[effect].some((rule) => rule.capability === capability)) {
|
|
88
|
+
print(style.dim(`${effect} ${capability} on ${key} is already declared.`));
|
|
53
89
|
return;
|
|
54
90
|
}
|
|
55
91
|
|
|
92
|
+
const document = await ConfigDocument.open(resolution.workspaceRoot, resolution.profile);
|
|
93
|
+
|
|
56
94
|
// A bare string, because that is what the file should read like. The object
|
|
57
95
|
// form exists for `expires_at` and is not worth writing by default.
|
|
58
|
-
document.addTo(['
|
|
59
|
-
// Validation runs before the write, so a rule naming a provider
|
|
60
|
-
//
|
|
96
|
+
document.addTo(['grants', index, effect], capability, { inline: true });
|
|
97
|
+
// Validation runs before the write, so a rule naming a provider other than the
|
|
98
|
+
// row's own fails here rather than matching nothing at runtime.
|
|
61
99
|
await document.save();
|
|
62
100
|
|
|
101
|
+
await recordConfigChange(
|
|
102
|
+
config,
|
|
103
|
+
resolution.workspaceRoot,
|
|
104
|
+
target,
|
|
105
|
+
{
|
|
106
|
+
capability: effect === 'allow' ? 'config.policy.allow' : 'config.policy.deny',
|
|
107
|
+
scope: resolution.profile,
|
|
108
|
+
connection: key,
|
|
109
|
+
arguments: { capability },
|
|
110
|
+
},
|
|
111
|
+
(note) => print(warn(note)),
|
|
112
|
+
);
|
|
113
|
+
|
|
63
114
|
announce(resolution);
|
|
64
|
-
print(ok(`${effect} ${style.bold(capability)}`));
|
|
115
|
+
print(ok(`${effect} ${style.bold(capability)} on ${style.bold(key)}`));
|
|
65
116
|
|
|
66
|
-
if (effect === 'deny' &&
|
|
67
|
-
print(style.dim(
|
|
117
|
+
if (effect === 'deny' && grant.allow.some((rule) => rule.capability === '*')) {
|
|
118
|
+
print(style.dim(" This narrows that row's catch-all allow; a deny always wins."));
|
|
68
119
|
}
|
|
69
120
|
|
|
70
121
|
// A deny the endpoint has not heard about is still granting what it names, so
|
|
@@ -1,9 +1,11 @@
|
|
|
1
|
+
import { readSession } from '#auth/lanes/session.ts';
|
|
2
|
+
import { ConfigError } from '#profile';
|
|
1
3
|
import { startEndpoint } from '#server/endpoint.ts';
|
|
2
4
|
import { streamLogger } from '#server/logging.ts';
|
|
3
|
-
import { repairOwnerLayer } from '../../config-repair.ts';
|
|
5
|
+
import { repairOwnerLayer } from '../../config-repair-sweep.ts';
|
|
4
6
|
import { announce, ok, print, style, warn } from '../../output.ts';
|
|
5
7
|
import { staleNudge } from '../../release.ts';
|
|
6
|
-
import { resolveProfile, type GlobalFlags } from '../../runtime.ts';
|
|
8
|
+
import { primaryProfile, resolveProfile, type GlobalFlags } from '../../runtime.ts';
|
|
7
9
|
|
|
8
10
|
/** `lanes link start` — reconcile, then serve every profile on one endpoint. */
|
|
9
11
|
|
|
@@ -14,9 +16,24 @@ export async function start(
|
|
|
14
16
|
only?: boolean | undefined;
|
|
15
17
|
},
|
|
16
18
|
): Promise<void> {
|
|
17
|
-
|
|
19
|
+
// One endpoint serves every profile in the workspace, so the workspace is the
|
|
20
|
+
// subject and a profile is not required. What `--profile` picks is the
|
|
21
|
+
// *primary*: whose token opens the endpoint and whose port it binds (ADR-009).
|
|
22
|
+
// `--only` is the flag that narrows what is served, and it has nothing to
|
|
23
|
+
// narrow unless a profile was named.
|
|
24
|
+
if (flags.only === true && flags.profile === undefined) {
|
|
25
|
+
throw new ConfigError(
|
|
26
|
+
'--only serves one profile, so it needs to be told which.\n' +
|
|
27
|
+
' Add --profile <name>, or drop --only to serve every profile in the workspace.',
|
|
28
|
+
);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const resolved = { ...flags, profile: await primaryProfile(flags) };
|
|
32
|
+
const { resolution } = await resolveProfile(resolved);
|
|
18
33
|
announce(resolution);
|
|
19
34
|
|
|
35
|
+
await requireSignIn();
|
|
36
|
+
|
|
20
37
|
// Before the bootstrap, and only here.
|
|
21
38
|
//
|
|
22
39
|
// This is the one command an existing install runs without being told to, so
|
|
@@ -41,7 +58,7 @@ export async function start(
|
|
|
41
58
|
// entrypoint. What stays here is what a terminal wants: the plan, printed as
|
|
42
59
|
// it is applied, and the endpoint at the end.
|
|
43
60
|
const endpoint = await startEndpoint({
|
|
44
|
-
flags,
|
|
61
|
+
flags: resolved,
|
|
45
62
|
port: flags.port,
|
|
46
63
|
only: flags.only,
|
|
47
64
|
mintToken: true,
|
|
@@ -56,7 +73,7 @@ export async function start(
|
|
|
56
73
|
print(ok(`reconciled ${ofMany ? profile : ''}`.trim()));
|
|
57
74
|
},
|
|
58
75
|
tokenMinted({ target }) {
|
|
59
|
-
print(warn(`minted a token — run: lanes link outputs --show --
|
|
76
|
+
print(warn(`minted a token — run: lanes link outputs --show --workspace ${target}`));
|
|
60
77
|
},
|
|
61
78
|
},
|
|
62
79
|
});
|
|
@@ -81,3 +98,34 @@ export async function start(
|
|
|
81
98
|
|
|
82
99
|
await new Promise(() => {});
|
|
83
100
|
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Refuse to serve without a Lanes session.
|
|
104
|
+
*
|
|
105
|
+
* A local endpoint requires this as much as a deployed one does, which is the
|
|
106
|
+
* uncomfortable half of ADR-060 and worth stating plainly: **a self-hostable
|
|
107
|
+
* tool now needs an account to start.** The alternative was worse. A profile
|
|
108
|
+
* declares who may consume it, and there is no way to check that against
|
|
109
|
+
* anybody if the endpoint has no idea who is asking — so "local is exempt"
|
|
110
|
+
* means local profiles cannot express delegation at all, and the model would be
|
|
111
|
+
* two models.
|
|
112
|
+
*
|
|
113
|
+
* What is *not* required is the network per request. A machine offline for a
|
|
114
|
+
* day keeps serving; the session is needed to sign in and to refresh, and
|
|
115
|
+
* `lanes auth status` says how long that has left to run.
|
|
116
|
+
*
|
|
117
|
+
* The CI token is the exception and stays one (ADR-009): a headless runner has
|
|
118
|
+
* no browser, presents `llk_…`, and reaches the whole workspace.
|
|
119
|
+
*/
|
|
120
|
+
async function requireSignIn(): Promise<void> {
|
|
121
|
+
if ((await readSession()) !== null) return;
|
|
122
|
+
|
|
123
|
+
throw new ConfigError(
|
|
124
|
+
'Not signed in, so there is nobody for this endpoint to serve.\n' +
|
|
125
|
+
' Run: lanes auth login\n' +
|
|
126
|
+
'\n' +
|
|
127
|
+
' A profile reaches you only where its members list your subject, which is why\n' +
|
|
128
|
+
' this is required for a local endpoint too. See:\n' +
|
|
129
|
+
' lanes link profile members add --me --profile <name>',
|
|
130
|
+
);
|
|
131
|
+
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { wasDefaulted } from '../../selection-require.ts';
|
|
1
2
|
import {
|
|
2
3
|
isPointer,
|
|
3
4
|
listProfiles,
|
|
@@ -11,7 +12,7 @@ import {
|
|
|
11
12
|
import { oneProfile, visibleCapabilities } from '#server/mcp';
|
|
12
13
|
import { toPolicyDocument } from '#registry';
|
|
13
14
|
import { announce, emit, heading, print, style, table } from '../../output.ts';
|
|
14
|
-
import { openRuntime, ownerPrincipal, type GlobalFlags } from '../../runtime.ts';
|
|
15
|
+
import { grantedConnections, openRuntime, ownerPrincipal, type GlobalFlags } from '../../runtime.ts';
|
|
15
16
|
import { deploymentIdentity } from '../../endpoint-url.ts';
|
|
16
17
|
|
|
17
18
|
/** `lanes link status` — connections, what is reachable through them, and where. */
|
|
@@ -29,7 +30,7 @@ export interface StatusFlags extends GlobalFlags {
|
|
|
29
30
|
*
|
|
30
31
|
* Which is why a deployed target prints its *identity* rather than its address.
|
|
31
32
|
* This used to print `http://<host>:<port>/mcp` unconditionally, so
|
|
32
|
-
* `status --
|
|
33
|
+
* `status --workspace cloud` named a loopback port with nothing behind it — the
|
|
33
34
|
* bug `endpoint-url.ts` records having already fixed in `mcp add`. Reaching for
|
|
34
35
|
* `endpointUrl` here would fix the lie by surrendering the property above: it
|
|
35
36
|
* shells out to `gcloud`, costs seconds, needs the CLI installed and
|
|
@@ -48,7 +49,7 @@ export async function status(flags: StatusFlags): Promise<void> {
|
|
|
48
49
|
const records = await runtime.state.connections.list();
|
|
49
50
|
const byKey = new Map(records.map((record) => [`${record.provider}.${record.id}`, record]));
|
|
50
51
|
|
|
51
|
-
const connections = runtime.
|
|
52
|
+
const connections = grantedConnections(runtime).map((connection) => {
|
|
52
53
|
const key = `${connection.provider}.${connection.id}`;
|
|
53
54
|
return {
|
|
54
55
|
key,
|
|
@@ -142,7 +143,7 @@ export async function status(flags: StatusFlags): Promise<void> {
|
|
|
142
143
|
print(
|
|
143
144
|
style.dim(
|
|
144
145
|
' the address is the platform\'s to assign — run: ' +
|
|
145
|
-
`lanes link outputs --
|
|
146
|
+
`lanes link outputs --workspace ${runtime.target}`,
|
|
146
147
|
),
|
|
147
148
|
);
|
|
148
149
|
}
|
|
@@ -197,12 +198,12 @@ async function workspaceStatus(flags: StatusFlags): Promise<void> {
|
|
|
197
198
|
unreachable = error instanceof Error ? error.message : String(error);
|
|
198
199
|
}
|
|
199
200
|
|
|
200
|
-
const root = resolved?.workspaceRoot ?? (isPointer(entry) ? entry.
|
|
201
|
+
const root = resolved?.workspaceRoot ?? (isPointer(entry) ? entry.at : localRoot);
|
|
201
202
|
const workspace = resolved ? await loadWorkspaceProfiles(root) : undefined;
|
|
202
203
|
|
|
203
204
|
const profiles = (workspace?.loaded ?? []).map((loaded) => ({
|
|
204
205
|
name: loaded.profile,
|
|
205
|
-
|
|
206
|
+
grants: loaded.config.grants.length,
|
|
206
207
|
}));
|
|
207
208
|
|
|
208
209
|
const deployment = deploymentIdentity(resolved?.declared.deploy);
|
|
@@ -220,7 +221,14 @@ async function workspaceStatus(flags: StatusFlags): Promise<void> {
|
|
|
220
221
|
unreadable: workspace?.unreadable ?? [],
|
|
221
222
|
},
|
|
222
223
|
() => {
|
|
223
|
-
|
|
224
|
+
// The same shape `announce` prints, including whether the workspace was
|
|
225
|
+
// typed or defaulted — a command that reported it differently would make
|
|
226
|
+
// ADR-061's echo a thing an operator has to learn twice.
|
|
227
|
+
print(
|
|
228
|
+
style.dim(
|
|
229
|
+
`workspace ${style.bold(target)}${wasDefaulted(target) ? ' (default)' : ''} ${root}`,
|
|
230
|
+
),
|
|
231
|
+
);
|
|
224
232
|
|
|
225
233
|
if (unreachable !== undefined) {
|
|
226
234
|
heading('Unreachable');
|
|
@@ -234,12 +242,12 @@ async function workspaceStatus(flags: StatusFlags): Promise<void> {
|
|
|
234
242
|
heading('Profiles');
|
|
235
243
|
if (profiles.length === 0) {
|
|
236
244
|
print(style.dim(' None yet.'));
|
|
237
|
-
print(style.dim(` Create one with: lanes link profile add <name> --
|
|
245
|
+
print(style.dim(` Create one with: lanes link profile add <name> --workspace ${target}`));
|
|
238
246
|
} else {
|
|
239
247
|
table(
|
|
240
248
|
profiles.map((profile) => [
|
|
241
249
|
` ${style.bold(profile.name)}`,
|
|
242
|
-
style.dim(`${profile.
|
|
250
|
+
style.dim(`${profile.grants} grant(s)`),
|
|
243
251
|
]),
|
|
244
252
|
);
|
|
245
253
|
}
|
|
@@ -257,7 +265,7 @@ async function workspaceStatus(flags: StatusFlags): Promise<void> {
|
|
|
257
265
|
print(
|
|
258
266
|
style.dim(
|
|
259
267
|
" the address is the platform's to assign — run: " +
|
|
260
|
-
`lanes link outputs --
|
|
268
|
+
`lanes link outputs --workspace ${target} --profile ${profiles[0]?.name ?? '<name>'}`,
|
|
261
269
|
),
|
|
262
270
|
);
|
|
263
271
|
},
|
|
@@ -50,7 +50,7 @@ export async function tools(flags: ToolsFlags): Promise<void> {
|
|
|
50
50
|
{
|
|
51
51
|
url,
|
|
52
52
|
target: runtime.target,
|
|
53
|
-
// Both, because `--
|
|
53
|
+
// Both, because `--workspace cloud` reaching loopback is indistinguishable
|
|
54
54
|
// from success without them.
|
|
55
55
|
deployed: deployed !== null,
|
|
56
56
|
answering: mine,
|
|
@@ -297,7 +297,7 @@ export function parse(text: string): Record<string, unknown> {
|
|
|
297
297
|
* `capabilityIdForToolName` answers it exactly against a set of known ids, and
|
|
298
298
|
* falls back to that first-underscore split when it recognises none — which is
|
|
299
299
|
* reachable here, because the registry is the *invoking profile's* while the
|
|
300
|
-
* endpoint may serve several, and under `--
|
|
300
|
+
* endpoint may serve several, and under `--workspace cloud` may run an image this
|
|
301
301
|
* checkout does not have. So the fallback is detected rather than trusted: a
|
|
302
302
|
* name that resolves to nothing known is grouped as unattributed, because a
|
|
303
303
|
* guessed heading with a confident count is worse than an honest "these did not
|