@lanes-sh/link 0.7.2 → 0.8.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 +136 -61
- 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 +65 -0
- package/src/auth/remote.ts +32 -9
- package/src/cli/accepts.ts +108 -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 +88 -87
- 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 +4 -2
- 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 +182 -165
- package/src/cli/commands/grant.ts +140 -0
- package/src/cli/commands/identity.ts +21 -9
- package/src/cli/commands/knowledge/index.ts +46 -79
- package/src/cli/commands/knowledge/migrate.ts +74 -13
- package/src/cli/commands/knowledge/show.ts +92 -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 +29 -12
- 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 +52 -4
- 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/shared.ts +13 -2
- package/src/cli/commands/owner/skills.ts +28 -8
- package/src/cli/commands/profile/removal.ts +79 -76
- package/src/cli/commands/profile/remove.ts +16 -1
- package/src/cli/commands/profile.ts +46 -10
- package/src/cli/commands/relabel.ts +112 -0
- package/src/cli/commands/secrets.ts +34 -12
- 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.ts +58 -17
- package/src/cli/config-edit.ts +75 -140
- package/src/cli/config-migrate.ts +82 -64
- package/src/cli/config-repair.ts +89 -32
- package/src/cli/config-templates.ts +198 -0
- package/src/cli/contract3-data.ts +328 -0
- package/src/cli/contract3-shape.ts +186 -0
- package/src/cli/contract3.ts +282 -0
- package/src/cli/endpoint-url.ts +1 -1
- package/src/cli/lanes.ts +25 -1
- package/src/cli/main.ts +89 -14
- package/src/cli/migrate-plan.ts +12 -6
- package/src/cli/output.ts +34 -1
- package/src/cli/publish.ts +5 -2
- package/src/cli/runtime/open.ts +63 -98
- package/src/cli/runtime/registry.ts +6 -7
- package/src/cli/runtime/stores.ts +53 -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 +34 -18
- package/src/cli/workspace-migrate.ts +125 -16
- package/src/connectivity/manifest/provider.ts +3 -1
- package/src/connectivity/manifest/requirements.ts +1 -1
- package/src/deployments/bind.ts +1 -1
- package/src/deployments/deploy.ts +36 -27
- package/src/deployments/gcp/bucket.ts +18 -7
- package/src/deployments/gcp/provision.ts +7 -7
- 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 +15 -15
- package/src/deployments/upload.ts +46 -22
- 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 +183 -0
- package/src/profile/deployments.ts +3 -3
- package/src/profile/index.ts +30 -5
- package/src/profile/layout.ts +86 -89
- package/src/profile/load.ts +80 -47
- 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 +172 -21
- package/src/profile/targets.ts +21 -9
- package/src/profile/testing.ts +69 -2
- package/src/profile/workspace.ts +58 -3
- package/src/providers/custom/index.ts +1 -1
- package/src/providers/custom/load.ts +2 -3
- package/src/providers/identity/provider.ts +1 -1
- package/src/providers/memory/provider.ts +20 -2
- package/src/providers/setup/plan.ts +1 -1
- package/src/providers/slack/index.ts +2 -2
- package/src/registry/policy-bridge.ts +33 -11
- package/src/registry/reconcile.ts +4 -4
- package/src/server/authorization.ts +94 -0
- package/src/server/edge.ts +14 -1
- package/src/server/endpoint.ts +85 -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 +1 -1
- package/src/server/mcp/prompts.ts +7 -3
- package/src/server/mcp/resources.ts +16 -8
- package/src/server/mcp/tools.ts +9 -3
- package/src/server/mcp/visibility.ts +18 -3
- 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
|
@@ -0,0 +1,288 @@
|
|
|
1
|
+
import { readSession } from '#auth/lanes/session.ts';
|
|
2
|
+
import { recordConfigChange } from '../audit-change.ts';
|
|
3
|
+
import {
|
|
4
|
+
ConfigError,
|
|
5
|
+
readRegistry,
|
|
6
|
+
resolveTargetWorkspace,
|
|
7
|
+
resolveWorkspaceRoot,
|
|
8
|
+
} from '#profile';
|
|
9
|
+
import { ConfigDocument } from '../config-edit.ts';
|
|
10
|
+
import { describeMember, workspaceMembers } from '#auth/lanes/members.ts';
|
|
11
|
+
import { announce, emit, heading, ok, print, style, table, warn } from '../output.ts';
|
|
12
|
+
import { nextAfterEdit, publishProfileEdit } from '../publish.ts';
|
|
13
|
+
import { resolveProfile, type GlobalFlags } from '../runtime.ts';
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* `lanes link profile members` — who may consume a profile (ADR-060).
|
|
17
|
+
*
|
|
18
|
+
* CLI-only, like everything that authorises future agent behaviour (ADR-007),
|
|
19
|
+
* and for the sharpest version of that argument: an agent able to edit this
|
|
20
|
+
* could add itself.
|
|
21
|
+
*
|
|
22
|
+
* **This is a selection from the Lanes workspace, not a list beside it.**
|
|
23
|
+
* Membership is managed on the dashboard — invited, accepted, given a role,
|
|
24
|
+
* removed — and a workspace bound with `lanes_workspace:` is asked who it holds.
|
|
25
|
+
* Somebody added there shows up here the moment they accept, and granting them a
|
|
26
|
+
* profile is a local edit naming a subject the server already vouches for.
|
|
27
|
+
*
|
|
28
|
+
* For `local` there is no such list, so the only subject accepted is the
|
|
29
|
+
* signed-in one — a local workspace delegating to a stranger is a typo, not a
|
|
30
|
+
* use case.
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
export interface MemberRow {
|
|
34
|
+
readonly subject: string;
|
|
35
|
+
readonly role: 'owner' | 'member';
|
|
36
|
+
/** Whether this is the identity running the command. */
|
|
37
|
+
readonly you: boolean;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export async function membersList(flags: GlobalFlags & { json?: boolean }): Promise<void> {
|
|
41
|
+
const { resolution, config, target } = await resolveProfile(flags);
|
|
42
|
+
const session = await readSession();
|
|
43
|
+
|
|
44
|
+
const rows: MemberRow[] = config.members.map((member) => ({
|
|
45
|
+
subject: member.subject,
|
|
46
|
+
role: member.role,
|
|
47
|
+
you: member.subject === session?.subject,
|
|
48
|
+
}));
|
|
49
|
+
|
|
50
|
+
// The workspace's own people, so this listing answers both halves of the
|
|
51
|
+
// question: who may use this profile, and who *could* be given it. Without
|
|
52
|
+
// the second, adding somebody means going to the dashboard to copy a subject
|
|
53
|
+
// out of it.
|
|
54
|
+
const held = await workspaceMembers(await boundWorkspace(target));
|
|
55
|
+
const granted = new Set(rows.map((row) => row.subject));
|
|
56
|
+
const available = held.members.filter(
|
|
57
|
+
(member) => member.subject === null || !granted.has(member.subject),
|
|
58
|
+
);
|
|
59
|
+
|
|
60
|
+
return emit(
|
|
61
|
+
flags.json,
|
|
62
|
+
{ profile: resolution.profile, members: rows, available: available },
|
|
63
|
+
() => {
|
|
64
|
+
announce(resolution);
|
|
65
|
+
|
|
66
|
+
if (rows.length === 0) {
|
|
67
|
+
// Empty is nobody, and saying so matters: the natural reading of a blank
|
|
68
|
+
// list is "no restriction", which is the opposite of what it means.
|
|
69
|
+
print(style.dim('No members. Nobody may consume this profile.'));
|
|
70
|
+
print(style.dim(' Add yourself with: lanes link profile members add --me'));
|
|
71
|
+
} else {
|
|
72
|
+
heading(`May consume ${resolution.profile} (${rows.length})`);
|
|
73
|
+
table(
|
|
74
|
+
rows.map((row) => [
|
|
75
|
+
` ${style.bold(row.subject)}`,
|
|
76
|
+
row.role,
|
|
77
|
+
row.you ? style.dim('you') : '',
|
|
78
|
+
]),
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
if (available.length > 0) {
|
|
83
|
+
heading(`In the workspace, not granted (${available.length})`);
|
|
84
|
+
table(
|
|
85
|
+
available.map((member) => [
|
|
86
|
+
` ${style.bold(describeMember(member))}`,
|
|
87
|
+
member.role,
|
|
88
|
+
member.status === 'pending'
|
|
89
|
+
? style.dim('invitation not accepted')
|
|
90
|
+
: style.dim(member.subject ?? ''),
|
|
91
|
+
]),
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
if (held.unavailable !== null && held.unavailable !== 'this workspace is not bound to a Lanes workspace') {
|
|
96
|
+
print('');
|
|
97
|
+
print(style.dim(`Could not list the workspace's members: ${held.unavailable}`));
|
|
98
|
+
}
|
|
99
|
+
},
|
|
100
|
+
);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** The Lanes workspace this one is bound to, if any. */
|
|
104
|
+
async function boundWorkspace(target: string): Promise<string | undefined> {
|
|
105
|
+
// **The workspace that declares the target, which is not this machine once it
|
|
106
|
+
// is deployed.** `lanes_workspace` is a declaration field, and
|
|
107
|
+
// `recordDeployment` writes the declaration into the bucket while leaving
|
|
108
|
+
// this machine a pointer carrying only `at`, `primary` and the deploy stamps.
|
|
109
|
+
// So reading it here found nothing the moment a target was deployed: binding
|
|
110
|
+
// a workspace stopped taking effect, `members add` refused everyone but the
|
|
111
|
+
// person at the keyboard, and the remedy it printed — add `lanes_workspace:`
|
|
112
|
+
// under `workspaces.<target>` — named the pointer entry, which the next
|
|
113
|
+
// deploy overwrites. A declaration resolves back to this root unchanged.
|
|
114
|
+
const local = resolveWorkspaceRoot();
|
|
115
|
+
const registry = await readRegistry(await resolveTargetWorkspace(local, target).catch(() => local));
|
|
116
|
+
return registry[target]?.lanes_workspace;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export interface MembersFlags extends GlobalFlags {
|
|
120
|
+
readonly json?: boolean | undefined;
|
|
121
|
+
/** Add the signed-in subject, rather than one typed out. */
|
|
122
|
+
readonly me?: boolean | undefined;
|
|
123
|
+
readonly role?: string | undefined;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export async function membersAdd(
|
|
127
|
+
subject: string | undefined,
|
|
128
|
+
flags: MembersFlags,
|
|
129
|
+
): Promise<void> {
|
|
130
|
+
const { resolution, config, target } = await resolveProfile(flags);
|
|
131
|
+
const session = await readSession();
|
|
132
|
+
|
|
133
|
+
const wanted = flags.me === true ? session?.subject : subject;
|
|
134
|
+
|
|
135
|
+
if (wanted === undefined) {
|
|
136
|
+
throw new ConfigError(
|
|
137
|
+
flags.me === true
|
|
138
|
+
? 'Not signed in, so there is no "me" to add. Run: lanes auth login'
|
|
139
|
+
: 'Which subject? Run: lanes link profile members add <subject> --profile <name>\n' +
|
|
140
|
+
' Or add yourself: lanes link profile members add --me --profile <name>',
|
|
141
|
+
);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const role = flags.role === 'owner' ? 'owner' : 'member';
|
|
145
|
+
|
|
146
|
+
if (config.members.some((member) => member.subject === wanted)) {
|
|
147
|
+
print(style.dim(`${resolution.profile} already lists ${wanted}.`));
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
await assertDelegatable(wanted, target, session?.subject);
|
|
152
|
+
|
|
153
|
+
const document = await ConfigDocument.open(resolution.workspaceRoot, resolution.profile);
|
|
154
|
+
document.addTo(['members'], { subject: wanted, role }, { inline: true });
|
|
155
|
+
await document.save();
|
|
156
|
+
|
|
157
|
+
await recordConfigChange(
|
|
158
|
+
config,
|
|
159
|
+
resolution.workspaceRoot,
|
|
160
|
+
target,
|
|
161
|
+
{
|
|
162
|
+
capability: 'config.member.add',
|
|
163
|
+
scope: resolution.profile,
|
|
164
|
+
arguments: { subject: wanted, role },
|
|
165
|
+
},
|
|
166
|
+
(note) => print(warn(note)),
|
|
167
|
+
);
|
|
168
|
+
|
|
169
|
+
const published = nextAfterEdit(await publishProfileEdit({ resolution, config, target }));
|
|
170
|
+
|
|
171
|
+
return emit(flags.json, { profile: resolution.profile, subject: wanted, role }, () => {
|
|
172
|
+
announce(resolution);
|
|
173
|
+
print(ok(`${wanted} may now consume ${style.bold(resolution.profile)} as ${role}`));
|
|
174
|
+
print(style.dim(' They reach exactly what this profile grants, and nothing else.'));
|
|
175
|
+
if (published) print(style.dim(` ${published}`));
|
|
176
|
+
});
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
export async function membersRemove(
|
|
180
|
+
subject: string | undefined,
|
|
181
|
+
flags: MembersFlags,
|
|
182
|
+
): Promise<void> {
|
|
183
|
+
const { resolution, config, target } = await resolveProfile(flags);
|
|
184
|
+
if (!subject) throw new ConfigError('Which subject? Run: lanes link profile members list');
|
|
185
|
+
|
|
186
|
+
const at = config.members.findIndex((member) => member.subject === subject);
|
|
187
|
+
if (at === -1) {
|
|
188
|
+
print(style.dim(`${resolution.profile} does not list ${subject}.`));
|
|
189
|
+
return;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
const document = await ConfigDocument.open(resolution.workspaceRoot, resolution.profile);
|
|
193
|
+
document.removeFrom(['members'], at);
|
|
194
|
+
await document.save();
|
|
195
|
+
|
|
196
|
+
await recordConfigChange(
|
|
197
|
+
config,
|
|
198
|
+
resolution.workspaceRoot,
|
|
199
|
+
target,
|
|
200
|
+
{ capability: 'config.member.remove', scope: resolution.profile, arguments: { subject } },
|
|
201
|
+
(note) => print(warn(note)),
|
|
202
|
+
);
|
|
203
|
+
|
|
204
|
+
const published = nextAfterEdit(await publishProfileEdit({ resolution, config, target }));
|
|
205
|
+
|
|
206
|
+
return emit(flags.json, { profile: resolution.profile, subject }, () => {
|
|
207
|
+
announce(resolution);
|
|
208
|
+
print(ok(`${subject} may no longer consume ${style.bold(resolution.profile)}`));
|
|
209
|
+
|
|
210
|
+
// The half that is not obvious, and is the difference between this and a
|
|
211
|
+
// session manager. A token already issued keeps working until it expires:
|
|
212
|
+
// membership is read when one is minted, not on every call (ADR-060).
|
|
213
|
+
print(
|
|
214
|
+
style.dim(
|
|
215
|
+
' A token they already hold keeps working until it expires.\n' +
|
|
216
|
+
` To close that window now: lanes link token rotate --workspace ${target}`,
|
|
217
|
+
),
|
|
218
|
+
);
|
|
219
|
+
if (published) print(style.dim(` ${published}`));
|
|
220
|
+
});
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* Whether this workspace may delegate to that subject.
|
|
225
|
+
*
|
|
226
|
+
* The check exists because the alternative is a profile listing a subject
|
|
227
|
+
* nobody can produce a token for — which reads exactly like a working
|
|
228
|
+
* delegation until the person tries to use it.
|
|
229
|
+
*
|
|
230
|
+
* Bound to a Lanes workspace, the answer is the workspace's own member list:
|
|
231
|
+
* somebody added on the dashboard is delegatable here, and nobody else is.
|
|
232
|
+
* Unbound, there is no list to ask, so the only verifiable subject is the one at
|
|
233
|
+
* the keyboard.
|
|
234
|
+
*/
|
|
235
|
+
async function assertDelegatable(
|
|
236
|
+
subject: string,
|
|
237
|
+
target: string,
|
|
238
|
+
signedIn: string | undefined,
|
|
239
|
+
): Promise<void> {
|
|
240
|
+
const bound = await boundWorkspace(target);
|
|
241
|
+
|
|
242
|
+
if (bound === undefined) {
|
|
243
|
+
if (subject === signedIn) return;
|
|
244
|
+
|
|
245
|
+
throw new ConfigError(
|
|
246
|
+
`Workspace "${target}" is not bound to a Lanes workspace, so it can only delegate to you.\n` +
|
|
247
|
+
' Add yourself with: lanes link profile members add --me\n' +
|
|
248
|
+
' To delegate to somebody else, bind the workspace:\n' +
|
|
249
|
+
` lanes_workspace: <id> # in lanes-link.yaml, under workspaces.${target}\n` +
|
|
250
|
+
' Run "lanes auth workspaces" for the ids you belong to.',
|
|
251
|
+
);
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
const held = await workspaceMembers(bound);
|
|
255
|
+
|
|
256
|
+
// Unreachable rather than empty. Refusing on a network failure would make a
|
|
257
|
+
// local edit depend on our uptime, and the endpoint verifies the subject when
|
|
258
|
+
// it mints a token regardless — so this warns and proceeds.
|
|
259
|
+
if (held.unavailable !== null) {
|
|
260
|
+
print(
|
|
261
|
+
style.dim(
|
|
262
|
+
` Could not check the workspace's members (${held.unavailable}), so this was not verified.`,
|
|
263
|
+
),
|
|
264
|
+
);
|
|
265
|
+
return;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
const match = held.members.find((member) => member.subject === subject);
|
|
269
|
+
if (match !== undefined) return;
|
|
270
|
+
|
|
271
|
+
// A pending invitation is the interesting refusal: the person exists, the
|
|
272
|
+
// operator can see them on the dashboard, and there is still no subject to
|
|
273
|
+
// write down. Saying "not a member" would send them to add somebody who is
|
|
274
|
+
// already there.
|
|
275
|
+
const pending = held.members.filter((member) => member.status === 'pending');
|
|
276
|
+
|
|
277
|
+
throw new ConfigError(
|
|
278
|
+
`${subject} is not a member of the Lanes workspace behind "${target}".\n` +
|
|
279
|
+
(held.members.length > 0
|
|
280
|
+
? ` It holds: ${held.members.map(describeMember).join(', ')}\n`
|
|
281
|
+
: ' It holds nobody yet.\n') +
|
|
282
|
+
(pending.length > 0
|
|
283
|
+
? ` ${pending.map(describeMember).join(', ')} ${pending.length === 1 ? 'has' : 'have'} not ` +
|
|
284
|
+
'accepted the invitation yet, so there is no subject for them to be granted.\n'
|
|
285
|
+
: '') +
|
|
286
|
+
' Invite them on the dashboard first: https://lanes.sh/dashboard/settings/members',
|
|
287
|
+
);
|
|
288
|
+
}
|
|
@@ -2,7 +2,7 @@ import { readFile } from 'node:fs/promises';
|
|
|
2
2
|
import { basename } from 'node:path';
|
|
3
3
|
import { ownerPrincipal } from '#auth';
|
|
4
4
|
import { announce, print, style } from '../../output.ts';
|
|
5
|
-
import { openRuntime, type GlobalFlags } from '../../runtime.ts';
|
|
5
|
+
import { grantedConnections, openRuntime, type GlobalFlags } from '../../runtime.ts';
|
|
6
6
|
|
|
7
7
|
/**
|
|
8
8
|
* `lanes link attach <file> --connection <provider>.<account>`
|
|
@@ -44,11 +44,11 @@ export async function attachFile(
|
|
|
44
44
|
|
|
45
45
|
// Checked against config rather than trusted, so a typo names the mistake
|
|
46
46
|
// instead of staging into a namespace nothing will ever read.
|
|
47
|
-
const known = runtime.
|
|
47
|
+
const known = grantedConnections(runtime).some(
|
|
48
48
|
(connection) => connection.provider === providerId && connection.id === connectionId,
|
|
49
49
|
);
|
|
50
50
|
if (!known) {
|
|
51
|
-
const have = runtime.
|
|
51
|
+
const have = grantedConnections(runtime).map((c) => `${c.provider}.${c.id}`);
|
|
52
52
|
throw new Error(
|
|
53
53
|
`No connection "${flags.connection}" in this profile. Configured: ${have.join(', ') || 'none'}.`,
|
|
54
54
|
);
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
1
|
+
import { announceWorkspace, print, style, table } from '../../output.ts';
|
|
2
|
+
import { openWorkspaceRuntime, type GlobalFlags } from '../../runtime.ts';
|
|
3
3
|
|
|
4
4
|
/** `lanes link audit tail` — the log the dispatcher writes, rendered for a terminal. */
|
|
5
5
|
|
|
@@ -15,11 +15,11 @@ export async function auditTail(
|
|
|
15
15
|
}
|
|
16
16
|
const markdown = flags.format === 'md';
|
|
17
17
|
|
|
18
|
-
const runtime = await
|
|
18
|
+
const runtime = await openWorkspaceRuntime(flags);
|
|
19
19
|
try {
|
|
20
20
|
// The banner is chrome around a table; in Markdown it is a stray line in
|
|
21
21
|
// the middle of a document someone is pasting somewhere.
|
|
22
|
-
if (!markdown)
|
|
22
|
+
if (!markdown) announceWorkspace(runtime.resolution);
|
|
23
23
|
|
|
24
24
|
const events = await runtime.audit.tail({
|
|
25
25
|
limit: flags.limit ?? 25,
|
|
@@ -35,7 +35,11 @@ export async function auditTail(
|
|
|
35
35
|
// The log lives in the database; this is a rendering of it, not a second
|
|
36
36
|
// copy of it. See ADR-013 — storage format and display format are not the
|
|
37
37
|
// same decision.
|
|
38
|
-
|
|
38
|
+
// The workspace, not the profile. Contract 3 gave the workspace one
|
|
39
|
+
// chain covering every profile in it, and a heading naming whichever
|
|
40
|
+
// profile happened to open the store described a log that does not
|
|
41
|
+
// exist. Each row carries its own profile.
|
|
42
|
+
print(`# Audit — ${runtime.resolution.target}\n`);
|
|
39
43
|
print('| Time | Result | Capability | Connection | Duration | Arguments |');
|
|
40
44
|
print('|---|---|---|---|---|---|');
|
|
41
45
|
for (const event of events) {
|
|
@@ -81,9 +85,9 @@ export async function auditTail(
|
|
|
81
85
|
* can see how much was looked at.
|
|
82
86
|
*/
|
|
83
87
|
export async function auditVerify(flags: GlobalFlags): Promise<void> {
|
|
84
|
-
const runtime = await
|
|
88
|
+
const runtime = await openWorkspaceRuntime(flags);
|
|
85
89
|
try {
|
|
86
|
-
|
|
90
|
+
announceWorkspace(runtime.resolution);
|
|
87
91
|
const report = await runtime.audit.verify();
|
|
88
92
|
|
|
89
93
|
const scope = `${report.events} event${report.events === 1 ? '' : 's'} across ${report.runs} run${report.runs === 1 ? '' : 's'}`;
|
|
@@ -1,8 +1,15 @@
|
|
|
1
|
+
import type { ConnectionConfig } from '#profile';
|
|
1
2
|
import { credentialResolver, ReauthRequired } from '#connectivity/auth/index.ts';
|
|
2
3
|
import type { ResolvedCredential } from '#connectivity/auth/credential.ts';
|
|
3
4
|
import { credentialRefFor } from '#registry';
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
5
|
+
import { announceWorkspace, emit, fail, ok, print, warn } from '../../output.ts';
|
|
6
|
+
import { readConnections } from '#profile';
|
|
7
|
+
import {
|
|
8
|
+
grantedConnections,
|
|
9
|
+
openWorkspaceRuntime,
|
|
10
|
+
type GlobalFlags,
|
|
11
|
+
type Runtime,
|
|
12
|
+
} from '../../runtime.ts';
|
|
6
13
|
|
|
7
14
|
/**
|
|
8
15
|
* Whether each connection could still authenticate, asked rather than guessed.
|
|
@@ -146,13 +153,13 @@ export interface AuthFlags extends GlobalFlags {
|
|
|
146
153
|
*/
|
|
147
154
|
export async function probeConnections(
|
|
148
155
|
runtime: Runtime,
|
|
149
|
-
connections: readonly
|
|
156
|
+
connections: readonly ConnectionConfig[],
|
|
150
157
|
forSelection: (command: string) => string,
|
|
151
158
|
): Promise<ConnectionAuth[]> {
|
|
152
159
|
const resolve = credentialResolver(runtime.registry, runtime.credentials);
|
|
153
160
|
|
|
154
161
|
const probe = async (
|
|
155
|
-
connection:
|
|
162
|
+
connection: ConnectionConfig,
|
|
156
163
|
): Promise<ConnectionAuth> => {
|
|
157
164
|
const key = `${connection.provider}.${connection.id}`;
|
|
158
165
|
const manifest = runtime.manifestFor(connection.provider);
|
|
@@ -245,20 +252,30 @@ export async function probeConnections(
|
|
|
245
252
|
}
|
|
246
253
|
|
|
247
254
|
export async function auth(flags: AuthFlags): Promise<void> {
|
|
248
|
-
const runtime = await
|
|
255
|
+
const runtime = await openWorkspaceRuntime(flags);
|
|
249
256
|
|
|
250
257
|
try {
|
|
251
258
|
const { profile, target } = runtime.resolution;
|
|
252
|
-
const forSelection = (command: string) => `${command} --
|
|
259
|
+
const forSelection = (command: string) => `${command} --workspace ${target}`;
|
|
260
|
+
|
|
261
|
+
// Every connection the workspace holds, not the ones one profile grants.
|
|
262
|
+
// Whether an account can still authenticate is a fact about the account
|
|
263
|
+
// (ADR-057), and scoping it to a profile's grants meant an account that had
|
|
264
|
+
// just been connected could not be checked until somebody granted it —
|
|
265
|
+
// which is exactly the moment you want to check.
|
|
266
|
+
//
|
|
267
|
+
// `--profile` narrows to what that profile can reach, because "can the
|
|
268
|
+
// things this profile uses still sign in" is also a real question.
|
|
269
|
+
const all = flags.profile === undefined
|
|
270
|
+
? (await readConnections(runtime.resolution.workspaceRoot)).connections
|
|
271
|
+
: grantedConnections(runtime);
|
|
253
272
|
|
|
254
273
|
const wanted = flags.connection;
|
|
255
|
-
const connections = wanted
|
|
256
|
-
? runtime.config.connections.filter((c) => `${c.provider}.${c.id}` === wanted)
|
|
257
|
-
: runtime.config.connections;
|
|
274
|
+
const connections = wanted ? all.filter((c) => `${c.provider}.${c.id}` === wanted) : all;
|
|
258
275
|
|
|
259
276
|
if (wanted && connections.length === 0) {
|
|
260
277
|
throw new Error(
|
|
261
|
-
`No connection "${wanted}" in
|
|
278
|
+
`No connection "${wanted}" in workspace ${target}. Run: ${forSelection('lanes link connection list')}`,
|
|
262
279
|
);
|
|
263
280
|
}
|
|
264
281
|
|
|
@@ -270,7 +287,7 @@ export async function auth(flags: AuthFlags): Promise<void> {
|
|
|
270
287
|
// why it cannot read `doctor`. A connection needing a person is the answer
|
|
271
288
|
// this command was asked for, not a failure to produce one.
|
|
272
289
|
return emit(flags.json, { profile, target, ok: needsSomeone.length === 0, connections: results }, () => {
|
|
273
|
-
|
|
290
|
+
announceWorkspace(runtime.resolution);
|
|
274
291
|
|
|
275
292
|
for (const result of results) {
|
|
276
293
|
const line = `${result.key} — ${describe(result.verdict)}`;
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { grantedConnections } from '../../runtime.ts';
|
|
1
2
|
import { ownerPrincipal } from '#auth';
|
|
2
3
|
import type { DiscoveredCapability } from '#connectivity';
|
|
3
4
|
import { allowedConnections } from '#policy';
|
|
@@ -49,7 +50,7 @@ export async function reportCapabilityDrift(
|
|
|
49
50
|
const principal = ownerPrincipal(runtime.config.instance.profile).id;
|
|
50
51
|
|
|
51
52
|
for (const entry of runtime.registry.list()) {
|
|
52
|
-
const connection = runtime.
|
|
53
|
+
const connection = grantedConnections(runtime).find((c) => c.provider === entry.manifest.id);
|
|
53
54
|
if (!connection) continue;
|
|
54
55
|
|
|
55
56
|
const connector = runtime.connectorFor(entry.manifest.id, connection.id);
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { formatPlan, planIsNoop, planReconcile } from '#registry';
|
|
2
|
+
import type { ConnectionConfig, SelectedConnection } from '#profile';
|
|
2
3
|
import { DEFAULT_SURFACES } from '../../config-repair.ts';
|
|
3
4
|
import { announce, announceProfile, emit, fail, ok, print, warn } from '../../output.ts';
|
|
4
5
|
import { staleNudge } from '../../release.ts';
|
|
@@ -16,6 +17,18 @@ import { migratedContract, migratedRenamedProviders } from './migrate.ts';
|
|
|
16
17
|
*/
|
|
17
18
|
|
|
18
19
|
/** Static validation only. No external calls, no database, no credentials. */
|
|
20
|
+
/**
|
|
21
|
+
* The accounts this profile actually reaches, as reconcile wants them.
|
|
22
|
+
*
|
|
23
|
+
* A profile grants a subset of the workspace's connections (ADR-057), and
|
|
24
|
+
* reconcile is about the ones it reaches: an account granted to nobody has no
|
|
25
|
+
* state to keep in step, and marking it `disabled` here would fight with the
|
|
26
|
+
* profile next door that does grant it.
|
|
27
|
+
*/
|
|
28
|
+
function grantedConnections(runtime: { connections: readonly SelectedConnection[] }): ConnectionConfig[] {
|
|
29
|
+
return runtime.connections.map(({ connection }) => connection);
|
|
30
|
+
}
|
|
31
|
+
|
|
19
32
|
export async function check(flags: GlobalFlags): Promise<void> {
|
|
20
33
|
const { selection, config } = await resolveProfileOnly(flags);
|
|
21
34
|
announceProfile(selection);
|
|
@@ -25,8 +38,10 @@ export async function check(flags: GlobalFlags): Promise<void> {
|
|
|
25
38
|
// validates a file, and the file is the same whichever target reads it.
|
|
26
39
|
print(ok(`${selection.profilePath} is valid`));
|
|
27
40
|
print(
|
|
28
|
-
` ${config.
|
|
29
|
-
`${config.
|
|
41
|
+
` ${config.grants.length} grant(s), ` +
|
|
42
|
+
`${config.grants.reduce((n, g) => n + g.allow.length, 0)} allow rule(s), ` +
|
|
43
|
+
`${config.grants.reduce((n, g) => n + g.deny.length, 0)} deny rule(s), ` +
|
|
44
|
+
`${config.members.length} member(s)`,
|
|
30
45
|
);
|
|
31
46
|
}
|
|
32
47
|
|
|
@@ -35,7 +50,7 @@ export async function plan(flags: GlobalFlags): Promise<void> {
|
|
|
35
50
|
const runtime = await openRuntime(flags);
|
|
36
51
|
try {
|
|
37
52
|
announce(runtime.resolution);
|
|
38
|
-
const result = await planReconcile(runtime
|
|
53
|
+
const result = await planReconcile(grantedConnections(runtime), runtime.state, runtime.credentials, runtime.manifestFor);
|
|
39
54
|
print(formatPlan(result));
|
|
40
55
|
} finally {
|
|
41
56
|
await runtime.close();
|
|
@@ -100,7 +115,7 @@ export async function doctor(flags: DoctorFlags): Promise<void> {
|
|
|
100
115
|
|
|
101
116
|
try {
|
|
102
117
|
const forSelection = (command: string) =>
|
|
103
|
-
`${command} --profile ${runtime.resolution.profile} --
|
|
118
|
+
`${command} --profile ${runtime.resolution.profile} --workspace ${runtime.resolution.target}`;
|
|
104
119
|
|
|
105
120
|
checks.push('config is valid');
|
|
106
121
|
checks.push('state store is reachable');
|
|
@@ -134,7 +149,7 @@ export async function doctor(flags: DoctorFlags): Promise<void> {
|
|
|
134
149
|
// `probeConnections` answers it by attempting the renewal, which is the only
|
|
135
150
|
// thing that actually knows. Same classifier as `lanes link auth`, so the two
|
|
136
151
|
// cannot drift apart again.
|
|
137
|
-
const probed = await probeConnections(runtime, runtime
|
|
152
|
+
const probed = await probeConnections(runtime, grantedConnections(runtime), forSelection);
|
|
138
153
|
|
|
139
154
|
for (const result of probed) {
|
|
140
155
|
switch (result.verdict) {
|
|
@@ -209,7 +224,7 @@ export async function doctor(flags: DoctorFlags): Promise<void> {
|
|
|
209
224
|
}
|
|
210
225
|
}
|
|
211
226
|
|
|
212
|
-
for (const name of new Set(runtime.
|
|
227
|
+
for (const name of new Set(grantedConnections(runtime).map((one) => one.provider))) {
|
|
213
228
|
if (!runtime.registry.has(name)) {
|
|
214
229
|
problems.push({
|
|
215
230
|
kind: 'unknown_provider',
|
|
@@ -236,22 +251,25 @@ export async function doctor(flags: DoctorFlags): Promise<void> {
|
|
|
236
251
|
// covering it is not reported. That is the same rule the repair follows, and
|
|
237
252
|
// reading it here from `policy.deny` rather than asking the repair keeps
|
|
238
253
|
// `doctor` a read.
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
254
|
+
// A grant row *is* the connection and the rule together now (ADR-058), so
|
|
255
|
+
// "has a row but no rule" is a state that stopped existing — which removes
|
|
256
|
+
// the half-repaired case this check was originally written for. What is left
|
|
257
|
+
// is one question per surface: does this profile grant one, and is the grant
|
|
258
|
+
// more than a deny.
|
|
259
|
+
const grantFor = (provider: string) =>
|
|
260
|
+
runtime.config.grants.find((grant) => grant.connection.startsWith(`${provider}.`));
|
|
243
261
|
|
|
244
262
|
const missing = DEFAULT_SURFACES.filter((provider) => {
|
|
245
|
-
const
|
|
246
|
-
if (
|
|
263
|
+
const grant = grantFor(provider);
|
|
264
|
+
if (grant === undefined) return true;
|
|
247
265
|
|
|
248
|
-
const
|
|
249
|
-
|
|
250
|
-
);
|
|
251
|
-
const granted = runtime.config.policy.allow.some(
|
|
266
|
+
const rule = `${provider}.*`;
|
|
267
|
+
const denied = grant.deny.some(
|
|
252
268
|
(entry) => entry.capability === '*' || entry.capability === rule,
|
|
253
269
|
);
|
|
254
|
-
|
|
270
|
+
if (denied) return false;
|
|
271
|
+
|
|
272
|
+
return !grant.allow.some((entry) => entry.capability === '*' || entry.capability === rule);
|
|
255
273
|
});
|
|
256
274
|
|
|
257
275
|
if (missing.length > 0) {
|
|
@@ -287,7 +305,7 @@ export async function doctor(flags: DoctorFlags): Promise<void> {
|
|
|
287
305
|
warnings.push({ kind: 'capability_drift', message }),
|
|
288
306
|
);
|
|
289
307
|
|
|
290
|
-
const drift = await planReconcile(runtime
|
|
308
|
+
const drift = await planReconcile(grantedConnections(runtime), runtime.state, runtime.credentials, runtime.manifestFor);
|
|
291
309
|
if (!planIsNoop(drift)) {
|
|
292
310
|
warnings.push({
|
|
293
311
|
kind: 'reconcile_drift',
|
|
@@ -309,7 +327,7 @@ export async function doctor(flags: DoctorFlags): Promise<void> {
|
|
|
309
327
|
const rotation = await unboundRotatableRefs({
|
|
310
328
|
deploy: runtime.declared.deploy,
|
|
311
329
|
target: runtime.target,
|
|
312
|
-
connections: runtime
|
|
330
|
+
connections: grantedConnections(runtime),
|
|
313
331
|
manifestFor: runtime.manifestFor,
|
|
314
332
|
});
|
|
315
333
|
if (rotation.unbound.length > 0) {
|
|
@@ -1,7 +1,10 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
CONNECTIONS_FILE,
|
|
3
|
+
listProfiles, ConfigError, resolveSelection, resolveTargetWorkspace, resolveWorkspaceRoot,
|
|
4
|
+
SUPPORTED_CONTRACT } from '#profile';
|
|
2
5
|
import { ConfigDocument } from '../../config-edit.ts';
|
|
3
6
|
import { migrateRenamedProviders, pendingRenames, shapeOf } from '../../config-migrate.ts';
|
|
4
|
-
import {
|
|
7
|
+
import { migrateToCurrentContract } from '../../workspace-migrate.ts';
|
|
5
8
|
import { emit, fail, ok, print, style, warn } from '../../output.ts';
|
|
6
9
|
import { openSecretStoreFor, type GlobalFlags } from '../../runtime.ts';
|
|
7
10
|
|
|
@@ -52,16 +55,25 @@ export async function migratedRenamedProviders(
|
|
|
52
55
|
const root = await resolveTargetWorkspace(localRoot, target).catch(() => localRoot);
|
|
53
56
|
|
|
54
57
|
const selection = await resolveSelection({ profileFlag: flags.profile, root });
|
|
55
|
-
|
|
58
|
+
|
|
59
|
+
// The rename is a property of the *connection*, so it is found in the
|
|
60
|
+
// workspace's file (ADR-057) — and applied across every profile, because any
|
|
61
|
+
// of them may grant the row being renamed.
|
|
62
|
+
const document = await ConfigDocument.openKey(root, CONNECTIONS_FILE);
|
|
56
63
|
if (pendingRenames(document).length === 0) return false;
|
|
57
64
|
|
|
65
|
+
const profiles: ConfigDocument[] = [];
|
|
66
|
+
for (const name of await listProfiles(root)) {
|
|
67
|
+
profiles.push(await ConfigDocument.open(root, name));
|
|
68
|
+
}
|
|
69
|
+
|
|
58
70
|
// Shape-only, because the check this document fails runs after the schema.
|
|
59
71
|
// Throws when it is malformed beyond a rename, which is a better sentence
|
|
60
72
|
// than the referential one it would otherwise be reported under.
|
|
61
|
-
const config = shapeOf(
|
|
73
|
+
const config = shapeOf(await ConfigDocument.open(root, selection.profile));
|
|
62
74
|
const credentials = await openSecretStoreFor(config, root, target);
|
|
63
75
|
|
|
64
|
-
const migration = await migrateRenamedProviders(document, credentials, {
|
|
76
|
+
const migration = await migrateRenamedProviders(document, profiles, credentials, {
|
|
65
77
|
apply: flags.fix === true,
|
|
66
78
|
});
|
|
67
79
|
|
|
@@ -141,9 +153,14 @@ export async function migratedContract(flags: RenameFlags, refusal: unknown): Pr
|
|
|
141
153
|
const where = await resolveTargetWorkspace(root, target).catch(() => null);
|
|
142
154
|
if (where === null) return false;
|
|
143
155
|
|
|
144
|
-
|
|
156
|
+
// Running the migration is how "is there anything to do" is answered, rather
|
|
157
|
+
// than a predicate beside it. The predicate was `needsMigration`, which asks
|
|
158
|
+
// only about contract 1 — so a contract-2 bucket, the exact thing this
|
|
159
|
+
// function exists to rescue, returned false here and fell through to the
|
|
160
|
+
// refusal it was supposed to fix. With `--fix` absent this writes nothing.
|
|
161
|
+
const migration = await migrateToCurrentContract(where, { apply: flags.fix === true });
|
|
162
|
+
if (migration.alreadyCurrent) return false;
|
|
145
163
|
|
|
146
|
-
const migration = await migrateWorkspace(where, { apply: flags.fix === true });
|
|
147
164
|
const applied = flags.fix === true;
|
|
148
165
|
const remote = where !== root;
|
|
149
166
|
|
|
@@ -166,8 +183,8 @@ export async function migratedContract(flags: RenameFlags, refusal: unknown): Pr
|
|
|
166
183
|
print();
|
|
167
184
|
print(
|
|
168
185
|
applied
|
|
169
|
-
? ok(
|
|
170
|
-
: warn(
|
|
186
|
+
? ok(`migrated to contract ${SUPPORTED_CONTRACT} — a workspace declares its own adapters, and holds the connections a profile grants`)
|
|
187
|
+
: warn(`this workspace is behind contract ${SUPPORTED_CONTRACT}, and nothing here reads that any more`),
|
|
171
188
|
);
|
|
172
189
|
for (const change of migration.changes) print(` ${change}`);
|
|
173
190
|
|
|
@@ -177,15 +194,15 @@ export async function migratedContract(flags: RenameFlags, refusal: unknown): Pr
|
|
|
177
194
|
style.dim(
|
|
178
195
|
applied
|
|
179
196
|
? ' The endpoint serving this bucket is running an older image and cannot read\n' +
|
|
180
|
-
` what was just written. Roll a new one: lanes link deploy --
|
|
181
|
-
: ` lanes link deploy --
|
|
197
|
+
` what was just written. Roll a new one: lanes link deploy --workspace ${target}`
|
|
198
|
+
: ` lanes link deploy --workspace ${target} migrates it and ships the image that\n` +
|
|
182
199
|
' understands it, in one step. Prefer that to --fix here.',
|
|
183
200
|
),
|
|
184
201
|
);
|
|
185
202
|
return;
|
|
186
203
|
}
|
|
187
204
|
|
|
188
|
-
if (!applied) print(style.dim(` Fix it with: lanes link doctor --fix --
|
|
205
|
+
if (!applied) print(style.dim(` Fix it with: lanes link doctor --fix --workspace ${target}`));
|
|
189
206
|
},
|
|
190
207
|
);
|
|
191
208
|
|