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