@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,86 @@
|
|
|
1
|
+
import { print } from '../../output.ts';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* `lanes link mcp install-instructions` — the block a client should be told once.
|
|
5
|
+
*
|
|
6
|
+
* A registration points a client at this endpoint. It does not tell the *model*
|
|
7
|
+
* anything, and the model is what decides whether to look here at all. The
|
|
8
|
+
* common failure is not a client that cannot reach the endpoint; it is one that
|
|
9
|
+
* can and answers from nothing anyway, because reaching for a tool is a habit
|
|
10
|
+
* and no habit is installed by adding a server to a config file.
|
|
11
|
+
*
|
|
12
|
+
* So this prints a short block for a person to paste into whatever their client
|
|
13
|
+
* treats as durable instruction — a project file, a custom instruction, a
|
|
14
|
+
* memory. It says where things are and what to read first, and nothing else:
|
|
15
|
+
* the full account lives at `lanes://instructions` and is served by the
|
|
16
|
+
* endpoint, so it can be corrected without anybody pasting anything again.
|
|
17
|
+
*
|
|
18
|
+
* **Printed rather than written.** Every client keeps this somewhere different
|
|
19
|
+
* and several keep it somewhere a person curates by hand. Writing into one of
|
|
20
|
+
* those is the class of thing ADR-016 puts on the other side of the line from
|
|
21
|
+
* `mcp add`: delegate where the harness owns a command, write the file only
|
|
22
|
+
* where it does not, and where neither is true, hand over the text.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
/** What each client calls the place this belongs, so the hint is actionable. */
|
|
26
|
+
const WHERE: Record<string, string> = {
|
|
27
|
+
claude: 'CLAUDE.md in your project, or ~/.claude/CLAUDE.md for every project',
|
|
28
|
+
chatgpt: "Settings → Personalization → Custom instructions, under 'anything else'",
|
|
29
|
+
codex: 'AGENTS.md in your project, or ~/.codex/AGENTS.md',
|
|
30
|
+
cursor: '.cursor/rules/ in your project',
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
export interface InstallInstructionsFlags {
|
|
34
|
+
readonly client?: string | undefined;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function installInstructions(flags: InstallInstructionsFlags = {}): void {
|
|
38
|
+
const client = flags.client?.toLowerCase();
|
|
39
|
+
|
|
40
|
+
if (client !== undefined && !(client in WHERE)) {
|
|
41
|
+
throw new Error(
|
|
42
|
+
`Unknown client "${client}". Known: ${Object.keys(WHERE).join(', ')}.\n` +
|
|
43
|
+
' Omit --client for the block on its own.',
|
|
44
|
+
);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
if (client !== undefined) {
|
|
48
|
+
// To stderr, so the block on stdout stays pasteable. Somebody piping this
|
|
49
|
+
// into a file wants the text and not the advice about where to put it.
|
|
50
|
+
process.stderr.write(`Paste this into ${WHERE[client]}:\n\n`);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
print(BLOCK);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* The block itself.
|
|
58
|
+
*
|
|
59
|
+
* Deliberately short and deliberately not a copy of the endpoint's own
|
|
60
|
+
* instructions. It exists to establish one habit — look here before answering
|
|
61
|
+
* from nothing — and to name the document that carries the rest. A longer block
|
|
62
|
+
* pasted into a project file is one that goes stale in a place nobody will
|
|
63
|
+
* think to update.
|
|
64
|
+
*
|
|
65
|
+
* No vendor names and no account details, for the same reason nothing under
|
|
66
|
+
* `server/` may carry one: what the owner connected is theirs.
|
|
67
|
+
*/
|
|
68
|
+
const BLOCK = `## Lanes Link
|
|
69
|
+
|
|
70
|
+
Lanes Link is the route to my accounts, my memory, my notes, my saved procedures
|
|
71
|
+
and my contacts. It is an MCP endpoint, and it is already connected.
|
|
72
|
+
|
|
73
|
+
- Before saying you do not know something about me or my work, search memory.
|
|
74
|
+
- Before using anyone's address or handle, look them up in entities. It returns
|
|
75
|
+
every match and never picks one; if there is more than one, ask me.
|
|
76
|
+
- A thing for me to do is a task, not a memory. Tasks have a status.
|
|
77
|
+
- If I have a saved procedure for something, tell me it exists and let me run it
|
|
78
|
+
rather than improvising your own version.
|
|
79
|
+
- Before telling me something cannot be reached or that an account is missing,
|
|
80
|
+
ask the setup surface. It reports what exists and gives the exact command for
|
|
81
|
+
what does not. Run nothing yourself: every change here is mine to make.
|
|
82
|
+
- Read \`lanes://instructions\` before your first call for the full account of
|
|
83
|
+
how routing, profiles and refusals work.
|
|
84
|
+
|
|
85
|
+
Every tool takes a profile. A profile is how I keep things apart, so when it is
|
|
86
|
+
ambiguous which one I mean, ask rather than picking the first.`;
|
|
@@ -32,6 +32,17 @@ export interface McpAddOptions extends GlobalFlags {
|
|
|
32
32
|
readonly force?: boolean | undefined;
|
|
33
33
|
/** Register only; leave the skill and the agent alone. */
|
|
34
34
|
readonly noSkill?: boolean | undefined;
|
|
35
|
+
/**
|
|
36
|
+
* Write the endpoint token into the registration, for a machine with no browser.
|
|
37
|
+
*
|
|
38
|
+
* The ordinary path registers a bare URL and the client authorises itself
|
|
39
|
+
* against lanes.sh (ADR-062), which is both safer and less to get wrong. This
|
|
40
|
+
* is the CI escape hatch, and it is a flag rather than a fallback because the
|
|
41
|
+
* difference has to be a decision somebody made: a registration that quietly
|
|
42
|
+
* embedded a credential when a browser was unavailable is one nobody would
|
|
43
|
+
* notice had done so.
|
|
44
|
+
*/
|
|
45
|
+
readonly headless?: boolean | undefined;
|
|
35
46
|
}
|
|
36
47
|
|
|
37
48
|
export async function mcpAdd(target: string | undefined, options: McpAddOptions): Promise<void> {
|
|
@@ -71,17 +82,20 @@ export async function mcpAdd(target: string | undefined, options: McpAddOptions)
|
|
|
71
82
|
const runtime = await openRuntime(options);
|
|
72
83
|
|
|
73
84
|
try {
|
|
85
|
+
// Minted either way. The endpoint needs one to serve at all, and `outputs`
|
|
86
|
+
// prints it; what `--headless` decides is whether it is written into
|
|
87
|
+
// somebody's agent config.
|
|
74
88
|
const { token } = await ensureProfileToken(runtime.credentials, runtime.config.auth.token_ref);
|
|
75
89
|
|
|
76
90
|
// The target's own address, not the local one. This built
|
|
77
|
-
// `http://<host>:<port>/mcp` unconditionally, so `mcp add --
|
|
91
|
+
// `http://<host>:<port>/mcp` unconditionally, so `mcp add --workspace cloud`
|
|
78
92
|
// registered loopback with the agent: a registration that reports success,
|
|
79
93
|
// names the right server, and points at a port with nothing behind it.
|
|
80
94
|
const url = await endpointUrl(runtime.config, runtime.declared);
|
|
81
95
|
const input: AddInput = {
|
|
82
96
|
name,
|
|
83
97
|
url,
|
|
84
|
-
token,
|
|
98
|
+
...(options.headless === true ? { token } : {}),
|
|
85
99
|
tokenEnv,
|
|
86
100
|
scope,
|
|
87
101
|
profile: runtime.resolution.profile,
|
package/src/cli/commands/mcp.ts
CHANGED
|
@@ -20,3 +20,4 @@ export { harnessCommands } from './mcp/harnesses.ts';
|
|
|
20
20
|
export { mcpList } from './mcp/list.ts';
|
|
21
21
|
export { mcpAdd, type McpAddOptions } from './mcp/register.ts';
|
|
22
22
|
export { mcpStdio } from './mcp/stdio.ts';
|
|
23
|
+
export { installInstructions, type InstallInstructionsFlags } from './mcp/onboarding.ts';
|
|
@@ -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);
|