@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
|
@@ -1,9 +1,18 @@
|
|
|
1
1
|
import { RESERVED_PROVIDER_IDS } from '#connectivity';
|
|
2
2
|
import { credentialRefFor } from '#registry';
|
|
3
|
-
import
|
|
3
|
+
import {
|
|
4
|
+
CONNECTIONS_FILE,
|
|
5
|
+
listProfiles,
|
|
6
|
+
loadProfileConfig,
|
|
7
|
+
readConnections,
|
|
8
|
+
type ConnectionConfig,
|
|
9
|
+
type Resolution,
|
|
10
|
+
layout,
|
|
11
|
+
} from '#profile';
|
|
4
12
|
import { ConfigDocument } from '../config-edit.ts';
|
|
5
|
-
import { announce, emit, ok, print, style, warn } from '../output.ts';
|
|
6
|
-
import {
|
|
13
|
+
import { announce, announceWorkspace, emit, ok, print, style, warn } from '../output.ts';
|
|
14
|
+
import { recordConfigChange } from './../audit-change.ts';
|
|
15
|
+
import { openWorkspaceRuntime, type GlobalFlags } from '../runtime.ts';
|
|
7
16
|
import { nextAfterEdit, publishProfileEdit } from '../publish.ts';
|
|
8
17
|
import { confirm, isInteractive } from '../prompt.ts';
|
|
9
18
|
|
|
@@ -44,18 +53,20 @@ export interface Disconnected {
|
|
|
44
53
|
readonly credential: string | null;
|
|
45
54
|
/** Set when the credential was left because something else still needs it. */
|
|
46
55
|
readonly credentialSharedWith: readonly string[];
|
|
56
|
+
/**
|
|
57
|
+
* The profiles that lost a grant on this connection.
|
|
58
|
+
*
|
|
59
|
+
* Reported rather than counted, because a connection belongs to the workspace
|
|
60
|
+
* (ADR-057) and the profiles that lose one are not the profile the command was
|
|
61
|
+
* run against. Empty means it was granted by nobody, which is a legitimate
|
|
62
|
+
* state for an account connected and not yet used.
|
|
63
|
+
*/
|
|
64
|
+
readonly ungranted: readonly string[];
|
|
65
|
+
/** Connections left in the workspace, not in the profile. */
|
|
47
66
|
readonly remaining: number;
|
|
48
67
|
readonly published: string;
|
|
49
68
|
}
|
|
50
69
|
|
|
51
|
-
export interface Relabelled {
|
|
52
|
-
readonly profile: string;
|
|
53
|
-
readonly target: string;
|
|
54
|
-
readonly key: string;
|
|
55
|
-
readonly from: string;
|
|
56
|
-
readonly to: string;
|
|
57
|
-
readonly published: string;
|
|
58
|
-
}
|
|
59
70
|
|
|
60
71
|
export interface DisconnectFlags extends GlobalFlags {
|
|
61
72
|
readonly yes?: boolean | undefined;
|
|
@@ -63,9 +74,6 @@ export interface DisconnectFlags extends GlobalFlags {
|
|
|
63
74
|
readonly json?: boolean | undefined;
|
|
64
75
|
}
|
|
65
76
|
|
|
66
|
-
export interface RelabelFlags extends GlobalFlags {
|
|
67
|
-
readonly json?: boolean | undefined;
|
|
68
|
-
}
|
|
69
77
|
|
|
70
78
|
/**
|
|
71
79
|
* Find the one connection a key names.
|
|
@@ -75,45 +83,64 @@ export interface RelabelFlags extends GlobalFlags {
|
|
|
75
83
|
* is nothing to choose here, and a bare `gmail` with two accounts declared would
|
|
76
84
|
* be a command guessing which one to throw away.
|
|
77
85
|
*/
|
|
78
|
-
function locate(
|
|
86
|
+
export function locate(
|
|
87
|
+
connections: readonly ConnectionConfig[],
|
|
88
|
+
key: string,
|
|
89
|
+
workspace: string,
|
|
90
|
+
): Located {
|
|
79
91
|
if (!key.includes('.')) {
|
|
80
|
-
const matches =
|
|
92
|
+
const matches = connections.filter((one) => one.provider === key);
|
|
81
93
|
throw new Error(
|
|
82
94
|
`"${key}" names a provider, not a connection.\n` +
|
|
83
95
|
(matches.length > 0
|
|
84
|
-
? ` This
|
|
96
|
+
? ` This workspace holds ${matches.map((one) => `${one.provider}.${one.id}`).join(', ')}.\n`
|
|
85
97
|
: '') +
|
|
86
|
-
` Run: lanes link
|
|
98
|
+
` Run: lanes link connection list --workspace ${workspace}`,
|
|
87
99
|
);
|
|
88
100
|
}
|
|
89
101
|
|
|
90
|
-
const index =
|
|
102
|
+
const index = connections.findIndex((one) => `${one.provider}.${one.id}` === key);
|
|
91
103
|
if (index === -1) {
|
|
92
104
|
throw new Error(
|
|
93
|
-
`
|
|
94
|
-
` Run: lanes link
|
|
105
|
+
`Workspace "${workspace}" holds no connection "${key}".\n` +
|
|
106
|
+
` Run: lanes link connection list --workspace ${workspace}`,
|
|
95
107
|
);
|
|
96
108
|
}
|
|
97
109
|
|
|
98
|
-
return { index, connection:
|
|
110
|
+
return { index, connection: connections[index] as ConnectionConfig };
|
|
99
111
|
}
|
|
100
112
|
|
|
101
113
|
/**
|
|
102
|
-
* Refuse
|
|
114
|
+
* Refuse to remove the last instance of a built-in surface, and say why.
|
|
115
|
+
*
|
|
116
|
+
* A reserved provider is a connection like any other now (ADR-059), so
|
|
117
|
+
* `disconnect memory.acme` is a real thing to do and it works: the instance goes,
|
|
118
|
+
* every profile's grant on it goes, and the bytes stay where `rm -r` can find
|
|
119
|
+
* them.
|
|
120
|
+
*
|
|
121
|
+
* The *last* one is different, and refusing is honesty rather than caution. The
|
|
122
|
+
* owner layer arrives granted and is repaired back into place on the next
|
|
123
|
+
* `start`, `connect` or `deploy` (ADR-050), so removing the only `memory`
|
|
124
|
+
* connection succeeds, prints a success line, and is undone by the next command
|
|
125
|
+
* the operator runs. A command that reports a change the system immediately
|
|
126
|
+
* reverses is worse than one that refuses.
|
|
103
127
|
*
|
|
104
|
-
* `
|
|
105
|
-
* what the profile is *for*, they hold no credential, and each is granted by a
|
|
106
|
-
* policy line this command does not touch. Removing the connection alone would
|
|
107
|
-
* leave the policy granting `memory.*` against nothing: a config that is wrong
|
|
108
|
-
* rather than merely untidy, which is the same reason `knowledge use` takes its
|
|
109
|
-
* own block back. Hand-editing is the honest path and the file says so.
|
|
128
|
+
* `deny` is the way off, and it always was — the same line ADR-050 documents.
|
|
110
129
|
*/
|
|
111
|
-
function
|
|
130
|
+
function refuseLastReserved(
|
|
131
|
+
key: string,
|
|
132
|
+
provider: string,
|
|
133
|
+
connections: readonly ConnectionConfig[],
|
|
134
|
+
): void {
|
|
112
135
|
if (!RESERVED_PROVIDER_IDS.includes(provider)) return;
|
|
136
|
+
if (connections.filter((one) => one.provider === provider).length > 1) return;
|
|
137
|
+
|
|
113
138
|
throw new Error(
|
|
114
|
-
`"${key}" is
|
|
115
|
-
`
|
|
116
|
-
`
|
|
139
|
+
`"${key}" is the only ${provider} connection in this workspace.\n` +
|
|
140
|
+
` The owner layer is repaired back on the next start, connect or deploy, so removing it\n` +
|
|
141
|
+
` would report a change that the next command undoes.\n` +
|
|
142
|
+
` To take the surface away from a profile, deny it:\n` +
|
|
143
|
+
` lanes link policy deny "${provider}.*" --connection ${key} --profile <name>`,
|
|
117
144
|
);
|
|
118
145
|
}
|
|
119
146
|
|
|
@@ -128,97 +155,89 @@ function refuseReserved(key: string, provider: string, path: string): void {
|
|
|
128
155
|
* `unauthorized` for a `connect` nobody ran.
|
|
129
156
|
*/
|
|
130
157
|
export function connectionsSharingCredential(
|
|
131
|
-
|
|
158
|
+
connections: readonly ConnectionConfig[],
|
|
132
159
|
ref: string,
|
|
133
160
|
exceptIndex: number,
|
|
134
161
|
manifestFor: (provider: string) => Parameters<typeof credentialRefFor>[1],
|
|
135
162
|
): string[] {
|
|
136
|
-
return
|
|
163
|
+
return connections
|
|
137
164
|
.filter((one, i) => i !== exceptIndex && credentialRefFor(one, manifestFor(one.provider)) === ref)
|
|
138
165
|
.map((one) => `${one.provider}.${one.id}`);
|
|
139
166
|
}
|
|
140
167
|
|
|
141
168
|
/**
|
|
142
|
-
* Take
|
|
169
|
+
* Take the grant row back from a profile that named this connection.
|
|
143
170
|
*
|
|
144
|
-
*
|
|
145
|
-
*
|
|
146
|
-
*
|
|
147
|
-
*
|
|
148
|
-
* was
|
|
149
|
-
*
|
|
150
|
-
* disconnect one perfectly well.
|
|
171
|
+
* Exact, where this used to be a search. A rule named a *capability*, so
|
|
172
|
+
* disconnecting the last Gmail meant hunting every rule whose capability began
|
|
173
|
+
* `gmail.` and deciding whether it was now dangling — and getting that wrong
|
|
174
|
+
* either left a config the loader refuses or silently dropped a rule about an
|
|
175
|
+
* account that was still there. A grant names the connection (ADR-058), so
|
|
176
|
+
* removing it is finding one row.
|
|
151
177
|
*
|
|
152
|
-
* Symmetry is the argument
|
|
153
|
-
*
|
|
154
|
-
*
|
|
155
|
-
*
|
|
178
|
+
* Symmetry is still the argument. `connect` writes the connection and the
|
|
179
|
+
* profiles that asked for it get a grant; a connection that goes takes its
|
|
180
|
+
* grants with it, because a grant naming nothing is what
|
|
181
|
+
* `assertGrantsResolve` refuses at load.
|
|
156
182
|
*
|
|
157
|
-
*
|
|
158
|
-
*
|
|
159
|
-
* dangling as `gmail.*` once the last Gmail is gone, and the loader refuses it
|
|
160
|
-
* for the same reason.
|
|
161
|
-
*
|
|
162
|
-
* `deny` is left alone, deliberately. The loader permits a deny naming a
|
|
163
|
-
* provider with no connection, because denying something you have not connected
|
|
164
|
-
* yet is a reasonable thing to write ahead of time — and removing it here would
|
|
165
|
-
* silently re-permit whatever it covered if the account came back.
|
|
183
|
+
* Returns whether anything was removed, so the caller can report which profiles
|
|
184
|
+
* it touched rather than claiming all of them.
|
|
166
185
|
*/
|
|
167
|
-
function
|
|
168
|
-
const
|
|
169
|
-
if (!going) return;
|
|
170
|
-
|
|
171
|
-
const stillDeclared = config.connections.some(
|
|
172
|
-
(one, i) => i !== index && one.provider === going.provider,
|
|
173
|
-
);
|
|
174
|
-
if (stillDeclared) return;
|
|
175
|
-
|
|
176
|
-
const rules = document.getIn(['policy', 'allow']) as { items?: unknown[] } | null;
|
|
186
|
+
function dropGrantRow(document: ConfigDocument, key: string): boolean {
|
|
187
|
+
const rows = document.getIn(['grants']) as { items?: unknown[] } | null;
|
|
177
188
|
const doomed: number[] = [];
|
|
178
189
|
|
|
179
|
-
(
|
|
180
|
-
|
|
181
|
-
const capability =
|
|
182
|
-
typeof bare === 'string' ? bare : document.getIn(['policy', 'allow', at, 'capability']);
|
|
183
|
-
if (typeof capability !== 'string') return;
|
|
184
|
-
|
|
185
|
-
if (capability.split('.')[0] === going.provider) doomed.push(at);
|
|
190
|
+
(rows?.items ?? []).forEach((_row, at) => {
|
|
191
|
+
if (document.getIn(['grants', at, 'connection']) === key) doomed.push(at);
|
|
186
192
|
});
|
|
187
193
|
|
|
188
194
|
// Descending, so each removal cannot move the index of one still to come.
|
|
189
|
-
for (const at of doomed.reverse()) document.removeFrom(['
|
|
195
|
+
for (const at of doomed.reverse()) document.removeFrom(['grants'], at);
|
|
196
|
+
return doomed.length > 0;
|
|
190
197
|
}
|
|
191
198
|
|
|
192
199
|
export async function removeConnection(
|
|
193
200
|
key: string,
|
|
194
201
|
flags: DisconnectFlags,
|
|
195
202
|
): Promise<{ resolution: Resolution; disconnected: Disconnected } | null> {
|
|
196
|
-
const runtime = await
|
|
203
|
+
const runtime = await openWorkspaceRuntime(flags);
|
|
197
204
|
|
|
198
205
|
try {
|
|
199
|
-
const { resolution,
|
|
200
|
-
const
|
|
201
|
-
|
|
206
|
+
const { resolution, target } = runtime;
|
|
207
|
+
const root = resolution.workspaceRoot;
|
|
208
|
+
|
|
209
|
+
const connectionsFile = await readConnections(root);
|
|
210
|
+
const located = locate(connectionsFile.connections, key, target);
|
|
211
|
+
|
|
212
|
+
refuseLastReserved(key, located.connection.provider, connectionsFile.connections);
|
|
202
213
|
|
|
203
|
-
|
|
214
|
+
// Which profiles lose a grant, computed before anything is asked, because it
|
|
215
|
+
// is the fact that decides whether this is a small change or a large one. A
|
|
216
|
+
// connection belongs to the workspace now (ADR-057), so disconnecting it
|
|
217
|
+
// reaches every profile that named it — including ones the operator is not
|
|
218
|
+
// thinking about, which is exactly why the confirmation lists them.
|
|
219
|
+
const affected = await profilesGranting(root, key);
|
|
204
220
|
|
|
205
|
-
if (!(await confirmed(key, flags))) return null;
|
|
221
|
+
if (!(await confirmed(key, affected, flags))) return null;
|
|
206
222
|
|
|
207
223
|
const manifestFor = (provider: string) => runtime.registry.get(provider)?.manifest;
|
|
208
224
|
const ref = credentialRefFor(located.connection, manifestFor(located.connection.provider));
|
|
209
|
-
const shared = ref
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
//
|
|
214
|
-
//
|
|
215
|
-
//
|
|
216
|
-
//
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
225
|
+
const shared = ref
|
|
226
|
+
? connectionsSharingCredential(connectionsFile.connections, ref, located.index, manifestFor)
|
|
227
|
+
: [];
|
|
228
|
+
|
|
229
|
+
// Grants first, then the connection. That order is the one the loader can
|
|
230
|
+
// survive halfway through: a profile granting a connection that is gone is
|
|
231
|
+
// refused at load, while a connection nothing grants is merely unused. So if
|
|
232
|
+
// the process dies between the two writes, the workspace still opens.
|
|
233
|
+
for (const profile of affected) {
|
|
234
|
+
const document = await ConfigDocument.openKey(root, layout.profileConfig(profile));
|
|
235
|
+
if (dropGrantRow(document, key)) await document.save();
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
const connectionsDocument = await ConfigDocument.openKey(root, CONNECTIONS_FILE);
|
|
239
|
+
connectionsDocument.removeFrom(['connections'], located.index);
|
|
240
|
+
await connectionsDocument.save();
|
|
222
241
|
|
|
223
242
|
let removed: string | null = null;
|
|
224
243
|
if (ref && shared.length === 0 && flags.keepCredential !== true) {
|
|
@@ -226,6 +245,21 @@ export async function removeConnection(
|
|
|
226
245
|
removed = ref;
|
|
227
246
|
}
|
|
228
247
|
|
|
248
|
+
// `profiles` is the half of this that is easy to lose. A disconnect takes
|
|
249
|
+
// the account away from every profile that named it, and a row saying only
|
|
250
|
+
// "gmail.work removed" would leave a reader to work out which agents
|
|
251
|
+
// stopped being able to reach it.
|
|
252
|
+
await recordConfigChange(runtime.config, root, target, {
|
|
253
|
+
capability: 'config.connection.remove',
|
|
254
|
+
scope: target,
|
|
255
|
+
connection: key,
|
|
256
|
+
arguments: {
|
|
257
|
+
account: located.connection.account,
|
|
258
|
+
profiles: affected,
|
|
259
|
+
credentialRemoved: removed !== null,
|
|
260
|
+
},
|
|
261
|
+
});
|
|
262
|
+
|
|
229
263
|
return {
|
|
230
264
|
resolution,
|
|
231
265
|
disconnected: {
|
|
@@ -235,8 +269,21 @@ export async function removeConnection(
|
|
|
235
269
|
account: located.connection.account,
|
|
236
270
|
credential: removed,
|
|
237
271
|
credentialSharedWith: shared,
|
|
238
|
-
remaining:
|
|
239
|
-
|
|
272
|
+
remaining: connectionsFile.connections.length - 1,
|
|
273
|
+
ungranted: affected,
|
|
274
|
+
published: nextAfterEdit(
|
|
275
|
+
await publishProfileEdit({
|
|
276
|
+
resolution,
|
|
277
|
+
config: runtime.config,
|
|
278
|
+
target,
|
|
279
|
+
// Every profile that lost a grant, not just the one this ran under.
|
|
280
|
+
// Publishing one left the bucket with a `connections.yaml` missing
|
|
281
|
+
// the connection and a sibling still granting it — which
|
|
282
|
+
// `assertGrantsResolve` refuses at load, so `openReconciled` skipped
|
|
283
|
+
// that profile and it silently stopped being served.
|
|
284
|
+
touched: [...new Set([resolution.profile, ...affected])],
|
|
285
|
+
}),
|
|
286
|
+
),
|
|
240
287
|
},
|
|
241
288
|
};
|
|
242
289
|
} finally {
|
|
@@ -244,10 +291,34 @@ export async function removeConnection(
|
|
|
244
291
|
}
|
|
245
292
|
}
|
|
246
293
|
|
|
294
|
+
/**
|
|
295
|
+
* Every profile in the workspace whose grants name this connection.
|
|
296
|
+
*
|
|
297
|
+
* Read from the files rather than from the open runtime, which knows about one
|
|
298
|
+
* profile. Reading them all is the point: the operator named a connection, and
|
|
299
|
+
* the set of profiles that lose something is not visible from the one they
|
|
300
|
+
* happen to have selected.
|
|
301
|
+
*/
|
|
302
|
+
async function profilesGranting(root: string, key: string): Promise<string[]> {
|
|
303
|
+
const names = await listProfiles(root);
|
|
304
|
+
const granting: string[] = [];
|
|
305
|
+
|
|
306
|
+
for (const name of names) {
|
|
307
|
+
const { config } = await loadProfileConfig(root, name);
|
|
308
|
+
if (config.grants.some((grant) => grant.connection === key)) granting.push(name);
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
return granting;
|
|
312
|
+
}
|
|
313
|
+
|
|
247
314
|
/** A plain y/N. Not `profile remove`'s type-the-name, which guards the
|
|
248
315
|
* destruction of whole stores; this takes back one authorisation that `connect`
|
|
249
316
|
* can grant again. */
|
|
250
|
-
async function confirmed(
|
|
317
|
+
async function confirmed(
|
|
318
|
+
key: string,
|
|
319
|
+
affected: readonly string[],
|
|
320
|
+
flags: DisconnectFlags,
|
|
321
|
+
): Promise<boolean> {
|
|
251
322
|
if (flags.yes === true) return true;
|
|
252
323
|
if (!isInteractive()) {
|
|
253
324
|
throw new Error(
|
|
@@ -255,8 +326,18 @@ async function confirmed(key: string, flags: DisconnectFlags): Promise<boolean>
|
|
|
255
326
|
` Pass --yes to proceed.`,
|
|
256
327
|
);
|
|
257
328
|
}
|
|
329
|
+
|
|
330
|
+
// The profiles are named in the question rather than printed above it. A
|
|
331
|
+
// connection is the workspace's now, so "disconnect gmail.personal" can take
|
|
332
|
+
// the account away from a profile the operator is not looking at, and the one
|
|
333
|
+
// place that is certain to be read is the line they have to answer.
|
|
334
|
+
const reach =
|
|
335
|
+
affected.length === 0
|
|
336
|
+
? 'no profile grants it'
|
|
337
|
+
: `${affected.length} profile(s) lose it: ${affected.join(', ')}`;
|
|
338
|
+
|
|
258
339
|
// Defaults to no: this deletes a credential, and a stray return should not.
|
|
259
|
-
return confirm(`Disconnect ${key} and delete its credential
|
|
340
|
+
return confirm(`Disconnect ${key} and delete its credential? (${reach})`, false);
|
|
260
341
|
}
|
|
261
342
|
|
|
262
343
|
export async function disconnect(key: string | undefined, flags: DisconnectFlags): Promise<void> {
|
|
@@ -270,7 +351,7 @@ export async function disconnect(key: string | undefined, flags: DisconnectFlags
|
|
|
270
351
|
const { resolution, disconnected: result } = outcome;
|
|
271
352
|
|
|
272
353
|
return emit(flags.json, result, () => {
|
|
273
|
-
|
|
354
|
+
announceWorkspace(resolution);
|
|
274
355
|
print(ok(`disconnected ${style.bold(result.key)}${result.account ? ` (${result.account})` : ''}`));
|
|
275
356
|
|
|
276
357
|
if (result.credential) {
|
|
@@ -285,7 +366,10 @@ export async function disconnect(key: string | undefined, flags: DisconnectFlags
|
|
|
285
366
|
);
|
|
286
367
|
}
|
|
287
368
|
|
|
288
|
-
|
|
369
|
+
if (result.ungranted.length > 0) {
|
|
370
|
+
print(` ungranted ${result.ungranted.join(', ')}`);
|
|
371
|
+
}
|
|
372
|
+
print(style.dim(` ${result.remaining} connection(s) left in this workspace.`));
|
|
289
373
|
// The state record survives on purpose, and the next reconcile is what marks
|
|
290
374
|
// it disabled. Saying so stops "it is still in `status`" reading as a failure.
|
|
291
375
|
print(
|
|
@@ -296,69 +380,3 @@ export async function disconnect(key: string | undefined, flags: DisconnectFlags
|
|
|
296
380
|
if (result.published) print(style.dim(` ${result.published}`));
|
|
297
381
|
})
|
|
298
382
|
}
|
|
299
|
-
|
|
300
|
-
/**
|
|
301
|
-
* Rename a connection, writing `label` and never `account`.
|
|
302
|
-
*
|
|
303
|
-
* It wrote `account` until it was noticed that `account` is not a display name:
|
|
304
|
-
* `settleIdentity` matches on it to tell a repair from a new account,
|
|
305
|
-
* `idFromAccount` derives the id from it, and `gmail.send_message` writes it
|
|
306
|
-
* into a `From` header. Renaming through it therefore un-recognised the account
|
|
307
|
-
* it renamed — the next `connect` added a second row beside it.
|
|
308
|
-
*/
|
|
309
|
-
export async function renameConnection(
|
|
310
|
-
key: string,
|
|
311
|
-
label: string,
|
|
312
|
-
flags: RelabelFlags,
|
|
313
|
-
): Promise<{ resolution: Resolution; relabelled: Relabelled }> {
|
|
314
|
-
const runtime = await openRuntime(flags);
|
|
315
|
-
|
|
316
|
-
try {
|
|
317
|
-
const { resolution, config, target } = runtime;
|
|
318
|
-
const located = locate(config, key, resolution.profile);
|
|
319
|
-
const document = await ConfigDocument.open(resolution.workspaceRoot, resolution.profile);
|
|
320
|
-
|
|
321
|
-
document.setIn(['connections', located.index, 'label'], label);
|
|
322
|
-
await document.save();
|
|
323
|
-
|
|
324
|
-
return {
|
|
325
|
-
resolution,
|
|
326
|
-
relabelled: {
|
|
327
|
-
profile: resolution.profile,
|
|
328
|
-
target,
|
|
329
|
-
key,
|
|
330
|
-
// What it was called a moment ago, which is the account only until the
|
|
331
|
-
// first rename.
|
|
332
|
-
from: located.connection.label ?? located.connection.account,
|
|
333
|
-
to: label,
|
|
334
|
-
published: nextAfterEdit(await publishProfileEdit({ resolution, config, target })),
|
|
335
|
-
},
|
|
336
|
-
};
|
|
337
|
-
} finally {
|
|
338
|
-
await runtime.close();
|
|
339
|
-
}
|
|
340
|
-
}
|
|
341
|
-
|
|
342
|
-
export async function relabel(
|
|
343
|
-
key: string | undefined,
|
|
344
|
-
label: string | undefined,
|
|
345
|
-
flags: RelabelFlags,
|
|
346
|
-
): Promise<void> {
|
|
347
|
-
if (!key) throw new Error('Which connection? Run: lanes link status');
|
|
348
|
-
if (!label) throw new Error(`What should ${key} be called? Run: lanes link relabel ${key} "New name"`);
|
|
349
|
-
|
|
350
|
-
const { resolution, relabelled: result } = await renameConnection(key, label, flags);
|
|
351
|
-
|
|
352
|
-
return emit(flags.json, result, () => {
|
|
353
|
-
announce(resolution);
|
|
354
|
-
print(ok(`${style.bold(result.key)} is now ${style.bold(result.to)}`));
|
|
355
|
-
if (result.from) print(` was ${style.dim(result.from)}`);
|
|
356
|
-
// The label is a display name in two places, and only one of them changed.
|
|
357
|
-
print(
|
|
358
|
-
style.dim(
|
|
359
|
-
' The state store keeps the old name until the next reconcile, which updates it.',
|
|
360
|
-
),
|
|
361
|
-
);
|
|
362
|
-
if (result.published) print(style.dim(` ${result.published}`));
|
|
363
|
-
})
|
|
364
|
-
}
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import { ConfigError, connectionRefOf, readConnections, type Resolution } from '#profile';
|
|
2
|
+
import { ConfigDocument } from '../config-edit.ts';
|
|
3
|
+
import { announce, emit, ok, print, style } from '../output.ts';
|
|
4
|
+
import { nextAfterEdit, publishProfileEdit } from '../publish.ts';
|
|
5
|
+
import { resolveProfile, type GlobalFlags } from '../runtime.ts';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* `lanes link grant` and `lanes link revoke` — which of the workspace's accounts
|
|
9
|
+
* a profile may reach.
|
|
10
|
+
*
|
|
11
|
+
* The command that did not need to exist under contract 2, because connecting an
|
|
12
|
+
* account and granting it were the same act: `connect` wrote a row into the
|
|
13
|
+
* profile and a rule beside it, and a second profile wanting the same mailbox
|
|
14
|
+
* connected it again. A connection belongs to the workspace now (ADR-057), so
|
|
15
|
+
* authorising an account and deciding who may use it are two commands — and
|
|
16
|
+
* this is the second one.
|
|
17
|
+
*
|
|
18
|
+
* A grant row carries the provider wildcard, which is what `connect` writes for
|
|
19
|
+
* the profile that made the connection. Narrowing it afterwards is
|
|
20
|
+
* `lanes link policy deny <capability> --connection <ref>`; the two are separate
|
|
21
|
+
* because widening and narrowing are different decisions and reading them in one
|
|
22
|
+
* command's flags would hide which had happened.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
export interface Granted {
|
|
26
|
+
readonly profile: string;
|
|
27
|
+
readonly target: string;
|
|
28
|
+
readonly connection: string;
|
|
29
|
+
readonly account: string;
|
|
30
|
+
readonly allowed: readonly string[];
|
|
31
|
+
readonly published: string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export async function grantConnectionTo(
|
|
35
|
+
key: string | undefined,
|
|
36
|
+
flags: GlobalFlags & { json?: boolean },
|
|
37
|
+
): Promise<void> {
|
|
38
|
+
if (!key) {
|
|
39
|
+
throw new ConfigError(
|
|
40
|
+
'Which connection? Run: lanes link connection list\n' +
|
|
41
|
+
' e.g. lanes link grant gmail.personal --profile assistant',
|
|
42
|
+
);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const { resolution, config, target } = await resolveProfile(flags);
|
|
46
|
+
const root = resolution.workspaceRoot;
|
|
47
|
+
|
|
48
|
+
const held = (await readConnections(root)).connections;
|
|
49
|
+
const connection = held.find((one) => connectionRefOf(one) === key);
|
|
50
|
+
|
|
51
|
+
// Refused rather than written on trust. A grant naming a connection the
|
|
52
|
+
// workspace does not hold is refused at load by `assertGrantsResolve`, so
|
|
53
|
+
// writing one would leave a profile that no longer opens — and the operator
|
|
54
|
+
// would have been told "ok" by the command that broke it.
|
|
55
|
+
if (connection === undefined) {
|
|
56
|
+
throw new ConfigError(
|
|
57
|
+
`This workspace holds no connection "${key}".\n` +
|
|
58
|
+
(held.length > 0
|
|
59
|
+
? ` It holds: ${held.map(connectionRefOf).join(', ')}\n`
|
|
60
|
+
: ' It holds none yet. Connect one with: lanes link connect <provider>\n') +
|
|
61
|
+
` Run "lanes link connection list --workspace ${target}" to see them.`,
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
if (config.grants.some((grant) => grant.connection === key)) {
|
|
66
|
+
print(style.dim(`${resolution.profile} already grants ${key}.`));
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const rule = `${connection.provider}.*`;
|
|
71
|
+
const document = await ConfigDocument.open(root, resolution.profile);
|
|
72
|
+
document.addTo(['grants'], { connection: key, allow: [rule], deny: [] }, { inline: true });
|
|
73
|
+
await document.save();
|
|
74
|
+
|
|
75
|
+
const granted: Granted = {
|
|
76
|
+
profile: resolution.profile,
|
|
77
|
+
target,
|
|
78
|
+
connection: key,
|
|
79
|
+
account: connection.account,
|
|
80
|
+
allowed: [rule],
|
|
81
|
+
published: nextAfterEdit(await publishProfileEdit({ resolution, config, target })),
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
return emit(flags.json, granted, () => {
|
|
85
|
+
announce(resolution);
|
|
86
|
+
print(ok(`granted ${style.bold(key)} to ${style.bold(granted.profile)}`));
|
|
87
|
+
print(` account ${style.dim(granted.account)}`);
|
|
88
|
+
print(` allowed ${granted.allowed.join(', ')}`);
|
|
89
|
+
print(
|
|
90
|
+
style.dim(
|
|
91
|
+
` Narrow it with: lanes link policy deny <capability> --connection ${key} ` +
|
|
92
|
+
`--profile ${granted.profile}`,
|
|
93
|
+
),
|
|
94
|
+
);
|
|
95
|
+
if (granted.published) print(style.dim(` ${granted.published}`));
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Take a grant back, leaving the account where it is.
|
|
101
|
+
*
|
|
102
|
+
* Not `disconnect`, and the difference is the whole reason both exist: this
|
|
103
|
+
* removes one profile's permission and touches nothing else, while `disconnect`
|
|
104
|
+
* removes the account from the workspace and every profile that named it.
|
|
105
|
+
* Conflating them is how somebody loses a mailbox meaning to narrow an agent.
|
|
106
|
+
*/
|
|
107
|
+
export async function revokeConnectionFrom(
|
|
108
|
+
key: string | undefined,
|
|
109
|
+
flags: GlobalFlags & { json?: boolean },
|
|
110
|
+
): Promise<void> {
|
|
111
|
+
if (!key) {
|
|
112
|
+
throw new ConfigError(
|
|
113
|
+
'Which connection? Run: lanes link policy list\n' +
|
|
114
|
+
' e.g. lanes link revoke gmail.personal --profile assistant',
|
|
115
|
+
);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const { resolution, config, target } = await resolveProfile(flags);
|
|
119
|
+
const at = config.grants.findIndex((grant) => grant.connection === key);
|
|
120
|
+
|
|
121
|
+
if (at === -1) {
|
|
122
|
+
print(style.dim(`${resolution.profile} does not grant ${key}.`));
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const document = await ConfigDocument.open(resolution.workspaceRoot, resolution.profile);
|
|
127
|
+
document.removeFrom(['grants'], at);
|
|
128
|
+
await document.save();
|
|
129
|
+
|
|
130
|
+
return emit(flags.json, { profile: resolution.profile, target, connection: key }, () => {
|
|
131
|
+
announce(resolution);
|
|
132
|
+
print(ok(`revoked ${style.bold(key)} from ${style.bold(resolution.profile)}`));
|
|
133
|
+
print(
|
|
134
|
+
style.dim(
|
|
135
|
+
' The account is untouched, and every other profile granting it still reaches it.\n' +
|
|
136
|
+
` To remove the account itself: lanes link disconnect ${key}`,
|
|
137
|
+
),
|
|
138
|
+
);
|
|
139
|
+
});
|
|
140
|
+
}
|