@lanes-sh/link 0.7.2 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +20 -10
- package/instructions/agents/lanes-link-scout.md +2 -2
- package/instructions/skills/lanes-link/SKILL.md +136 -61
- package/package.json +2 -1
- package/src/audit/index.ts +8 -1
- package/src/auth/index.ts +58 -2
- package/src/auth/lanes/assertion.ts +256 -0
- package/src/auth/lanes/callback.ts +135 -0
- package/src/auth/lanes/federation.ts +50 -0
- package/src/auth/lanes/login.ts +294 -0
- package/src/auth/lanes/members.ts +103 -0
- package/src/auth/lanes/session.ts +97 -0
- package/src/auth/oauth/grant.ts +183 -0
- package/src/auth/oauth/result.ts +27 -0
- package/src/auth/oauth/server.ts +176 -203
- package/src/auth/oauth/store.ts +65 -0
- package/src/auth/remote.ts +32 -9
- package/src/cli/accepts.ts +108 -0
- package/src/cli/argv.ts +57 -3
- package/src/cli/audit-change.ts +140 -0
- package/src/cli/callback-page.ts +36 -115
- package/src/cli/commands/auth-dispatch.ts +48 -0
- package/src/cli/commands/auth.ts +229 -0
- package/src/cli/commands/connect/accounts.ts +4 -4
- package/src/cli/commands/connect/authorise.ts +4 -4
- package/src/cli/commands/connect/bind-credential.ts +2 -1
- package/src/cli/commands/connect/custom/index.ts +1 -1
- package/src/cli/commands/connect/custom/write.ts +2 -2
- package/src/cli/commands/connect/grant.ts +29 -14
- package/src/cli/commands/connect/index.ts +88 -87
- package/src/cli/commands/connect/options.ts +83 -0
- package/src/cli/commands/connect/registration.ts +50 -0
- package/src/cli/commands/connect/requirements.ts +1 -1
- package/src/cli/commands/connect/settle.ts +4 -2
- package/src/cli/commands/connect/target-note.ts +7 -2
- package/src/cli/commands/connect/unknown.ts +1 -1
- package/src/cli/commands/connect/variables.ts +3 -2
- package/src/cli/commands/connection-list.ts +116 -0
- package/src/cli/commands/connection.ts +182 -165
- package/src/cli/commands/grant.ts +140 -0
- package/src/cli/commands/identity.ts +21 -9
- package/src/cli/commands/knowledge/index.ts +46 -79
- package/src/cli/commands/knowledge/migrate.ts +74 -13
- package/src/cli/commands/knowledge/show.ts +92 -0
- package/src/cli/commands/knowledge.ts +2 -1
- package/src/cli/commands/mcp/harnesses.ts +30 -8
- package/src/cli/commands/mcp/onboarding.ts +86 -0
- package/src/cli/commands/mcp/register.ts +16 -2
- package/src/cli/commands/mcp.ts +1 -0
- package/src/cli/commands/members.ts +288 -0
- package/src/cli/commands/operate/attach.ts +3 -3
- package/src/cli/commands/operate/audit.ts +11 -7
- package/src/cli/commands/operate/auth.ts +28 -11
- package/src/cli/commands/operate/findings.ts +2 -1
- package/src/cli/commands/operate/inspect.ts +37 -19
- package/src/cli/commands/operate/migrate.ts +29 -12
- package/src/cli/commands/operate/outputs.ts +3 -3
- package/src/cli/commands/operate/pair-certificate.ts +141 -0
- package/src/cli/commands/operate/pair.ts +324 -0
- package/src/cli/commands/operate/policy.ts +73 -22
- package/src/cli/commands/operate/serve.ts +52 -4
- package/src/cli/commands/operate/status.ts +18 -10
- package/src/cli/commands/operate/tools.ts +2 -2
- package/src/cli/commands/operate.ts +2 -0
- package/src/cli/commands/owner/shared.ts +13 -2
- package/src/cli/commands/owner/skills.ts +28 -8
- package/src/cli/commands/profile/removal.ts +79 -76
- package/src/cli/commands/profile/remove.ts +16 -1
- package/src/cli/commands/profile.ts +46 -10
- package/src/cli/commands/relabel.ts +112 -0
- package/src/cli/commands/secrets.ts +34 -12
- package/src/cli/commands/set-workspace.ts +96 -0
- package/src/cli/commands/setup.ts +2 -2
- package/src/cli/commands/sync.ts +8 -8
- package/src/cli/commands/target.ts +9 -7
- package/src/cli/commands/update.ts +58 -17
- package/src/cli/config-edit.ts +75 -140
- package/src/cli/config-migrate.ts +82 -64
- package/src/cli/config-repair.ts +89 -32
- package/src/cli/config-templates.ts +198 -0
- package/src/cli/contract3-data.ts +328 -0
- package/src/cli/contract3-shape.ts +186 -0
- package/src/cli/contract3.ts +282 -0
- package/src/cli/endpoint-url.ts +1 -1
- package/src/cli/lanes.ts +25 -1
- package/src/cli/main.ts +89 -14
- package/src/cli/migrate-plan.ts +12 -6
- package/src/cli/output.ts +34 -1
- package/src/cli/publish.ts +5 -2
- package/src/cli/runtime/open.ts +63 -98
- package/src/cli/runtime/registry.ts +6 -7
- package/src/cli/runtime/stores.ts +53 -0
- package/src/cli/runtime/types.ts +106 -0
- package/src/cli/runtime/vault.ts +19 -4
- package/src/cli/runtime/workspace.ts +60 -0
- package/src/cli/runtime.ts +2 -1
- package/src/cli/selection-require.ts +44 -13
- package/src/cli/selection.ts +127 -145
- package/src/cli/usage.ts +34 -18
- package/src/cli/workspace-migrate.ts +125 -16
- package/src/connectivity/manifest/provider.ts +3 -1
- package/src/connectivity/manifest/requirements.ts +1 -1
- package/src/deployments/bind.ts +1 -1
- package/src/deployments/deploy.ts +36 -27
- package/src/deployments/gcp/bucket.ts +18 -7
- package/src/deployments/gcp/provision.ts +7 -7
- package/src/deployments/prepare.ts +72 -24
- package/src/deployments/record.ts +1 -1
- package/src/deployments/report.ts +2 -2
- package/src/deployments/serving.ts +15 -74
- package/src/deployments/target.ts +15 -15
- package/src/deployments/upload.ts +46 -22
- package/src/dispatch/deps.ts +88 -0
- package/src/dispatch/dispatch.ts +21 -62
- package/src/policy/index.ts +47 -15
- package/src/profile/connections.ts +183 -0
- package/src/profile/deployments.ts +3 -3
- package/src/profile/index.ts +30 -5
- package/src/profile/layout.ts +86 -89
- package/src/profile/load.ts +80 -47
- package/src/profile/pairing.ts +32 -0
- package/src/profile/primitives.ts +35 -1
- package/src/profile/registry.ts +6 -6
- package/src/profile/schema.ts +172 -21
- package/src/profile/targets.ts +21 -9
- package/src/profile/testing.ts +69 -2
- package/src/profile/workspace.ts +58 -3
- package/src/providers/custom/index.ts +1 -1
- package/src/providers/custom/load.ts +2 -3
- package/src/providers/identity/provider.ts +1 -1
- package/src/providers/memory/provider.ts +20 -2
- package/src/providers/setup/plan.ts +1 -1
- package/src/providers/slack/index.ts +2 -2
- package/src/registry/policy-bridge.ts +33 -11
- package/src/registry/reconcile.ts +4 -4
- package/src/server/authorization.ts +94 -0
- package/src/server/edge.ts +14 -1
- package/src/server/endpoint.ts +85 -104
- package/src/server/generation.ts +10 -1
- package/src/server/harness.ts +71 -13
- package/src/server/index.ts +31 -0
- package/src/server/mcp/build.ts +20 -1
- package/src/server/mcp/client-info.ts +54 -0
- package/src/server/mcp/guide.ts +120 -0
- package/src/server/mcp/instructions.ts +1 -1
- package/src/server/mcp/prompts.ts +7 -3
- package/src/server/mcp/resources.ts +16 -8
- package/src/server/mcp/tools.ts +9 -3
- package/src/server/mcp/visibility.ts +18 -3
- package/src/server/oauth.ts +29 -109
- package/src/server/read/credential.ts +134 -0
- package/src/server/read/deployed.ts +56 -0
- package/src/server/read/listener.ts +54 -0
- package/src/server/read/open.ts +101 -0
- package/src/server/read/routes.ts +247 -0
- package/src/server/read/state.ts +171 -0
|
@@ -1,7 +1,9 @@
|
|
|
1
|
+
import { newConnectionsTemplate, newProfileTemplate, newWorkspaceTemplate } from '../config-templates.ts';
|
|
1
2
|
import { mkdir, writeFile } from 'node:fs/promises';
|
|
2
3
|
import { existsSync } from 'node:fs';
|
|
3
4
|
import { join } from 'node:path';
|
|
4
5
|
import {
|
|
6
|
+
CONNECTIONS_FILE,
|
|
5
7
|
ConfigError,
|
|
6
8
|
WORKSPACE_FILE,
|
|
7
9
|
listProfiles,
|
|
@@ -13,8 +15,11 @@ import {
|
|
|
13
15
|
resolveTargetWorkspace,
|
|
14
16
|
resolveWorkspaceRoot,
|
|
15
17
|
} from '#profile';
|
|
16
|
-
|
|
18
|
+
|
|
19
|
+
import { recordConfigChange } from '../audit-change.ts';
|
|
20
|
+
import { resolveProfile } from '../runtime.ts';
|
|
17
21
|
import { emit, ok, print, style, table } from '../output.ts';
|
|
22
|
+
import { readSession } from '#auth/lanes/session.ts';
|
|
18
23
|
|
|
19
24
|
/**
|
|
20
25
|
* Profile management.
|
|
@@ -58,7 +63,7 @@ export interface ProfileListing {
|
|
|
58
63
|
*
|
|
59
64
|
* It now decides *where the file goes* rather than what is written in it: a
|
|
60
65
|
* profile lives in one target's workspace and declares nothing about it
|
|
61
|
-
* (ADR-052), so `--
|
|
66
|
+
* (ADR-052), so `--workspace cloud` writes into the bucket and the endpoint there
|
|
62
67
|
* serves it on its next reconcile.
|
|
63
68
|
*/
|
|
64
69
|
export async function createProfile(
|
|
@@ -69,7 +74,7 @@ export async function createProfile(
|
|
|
69
74
|
const target = options.targets[0]!;
|
|
70
75
|
|
|
71
76
|
// The workspace file before the target is resolved, not after. `profile add
|
|
72
|
-
// <name> --
|
|
77
|
+
// <name> --workspace local` on an empty directory is how a workspace comes into
|
|
73
78
|
// existence, and the target it names is declared *by* that file — so writing
|
|
74
79
|
// it second means resolving a target nothing has declared yet.
|
|
75
80
|
if (!isRemoteWorkspace(local) && !existsSync(join(local, WORKSPACE_FILE))) {
|
|
@@ -80,6 +85,14 @@ export async function createProfile(
|
|
|
80
85
|
const root = await resolveTargetWorkspace(local, target);
|
|
81
86
|
const path = profilePath(root, name);
|
|
82
87
|
|
|
88
|
+
// The connections file comes into existence with the workspace, carrying the
|
|
89
|
+
// owner layer. It is written before the profile because the profile's grants
|
|
90
|
+
// name rows in it, and `assertGrantsResolve` refuses a grant with nothing
|
|
91
|
+
// behind it — so a profile written first would not load until this existed.
|
|
92
|
+
if (!(await workspaceFiles(root).has(CONNECTIONS_FILE))) {
|
|
93
|
+
await writeWorkspaceFile(workspaceFiles(root), CONNECTIONS_FILE, newConnectionsTemplate());
|
|
94
|
+
}
|
|
95
|
+
|
|
83
96
|
if (await workspaceFiles(root).has(`profiles/${name}.yaml`)) {
|
|
84
97
|
throw new Error(`Profile "${name}" already exists at ${path}`);
|
|
85
98
|
}
|
|
@@ -99,10 +112,23 @@ export async function createProfile(
|
|
|
99
112
|
// sibling's, or asked. It declares no target now (ADR-052): it is written into
|
|
100
113
|
// the workspace of the target it was named with, and that workspace already
|
|
101
114
|
// says where its bytes go.
|
|
115
|
+
// The signed-in subject, so the profile reaches somebody the moment it
|
|
116
|
+
// exists. Without it every new profile shipped `members: []` while the
|
|
117
|
+
// template writes `authorization: mode: self` — an endpoint that advertises
|
|
118
|
+
// OAuth and lists nobody, where the owner signs in at lanes.sh and is told no
|
|
119
|
+
// profile lists them. A local stdio or CI caller still worked, which is why a
|
|
120
|
+
// smoke test passed: those carry `profiles: undefined` and `mayReach` admits
|
|
121
|
+
// everything.
|
|
122
|
+
//
|
|
123
|
+
// Null when nobody is signed in, which is a real state — `lanes auth login`
|
|
124
|
+
// has not been run yet — and the template then says how to fix it rather than
|
|
125
|
+
// inventing a subject.
|
|
126
|
+
const session = await readSession();
|
|
127
|
+
|
|
102
128
|
await writeWorkspaceFile(
|
|
103
129
|
workspaceFiles(root),
|
|
104
130
|
`profiles/${name}.yaml`,
|
|
105
|
-
newProfileTemplate(name, port),
|
|
131
|
+
newProfileTemplate(name, port, session?.subject),
|
|
106
132
|
);
|
|
107
133
|
|
|
108
134
|
return { name, path, port, targets: options.targets, copiedFrom: {} };
|
|
@@ -142,8 +168,8 @@ export async function profileAdd(
|
|
|
142
168
|
if (options.targets.length === 0) {
|
|
143
169
|
throw new ConfigError(
|
|
144
170
|
'Say which targets this profile declares:\n' +
|
|
145
|
-
` lanes link profile add ${name} --
|
|
146
|
-
` lanes link profile add ${name} --
|
|
171
|
+
` lanes link profile add ${name} --workspace local\n` +
|
|
172
|
+
` lanes link profile add ${name} --workspace local --workspace cloud\n` +
|
|
147
173
|
'\n' +
|
|
148
174
|
' A target is where a profile runs — a credential store and a blob store.\n' +
|
|
149
175
|
' There is no default to inherit before the profile exists.',
|
|
@@ -152,11 +178,21 @@ export async function profileAdd(
|
|
|
152
178
|
|
|
153
179
|
const created = await createProfile(name, options);
|
|
154
180
|
|
|
181
|
+
// Re-read rather than assembled from `created`, because the file on disk is
|
|
182
|
+
// what a workspace with a remote credential store needs to open one.
|
|
183
|
+
const primary = created.targets[0]!;
|
|
184
|
+
const { resolution, config } = await resolveProfile({ profile: created.name, target: primary });
|
|
185
|
+
await recordConfigChange(config, resolution.workspaceRoot, primary, {
|
|
186
|
+
capability: 'config.profile.add',
|
|
187
|
+
scope: created.name,
|
|
188
|
+
arguments: { port: created.port, workspace: primary },
|
|
189
|
+
});
|
|
190
|
+
|
|
155
191
|
return emit(options.json, created, () => {
|
|
156
192
|
print(ok(`created profile ${style.bold(created.name)}`));
|
|
157
193
|
print(` config ${created.path}`);
|
|
158
194
|
print(` port ${created.port}`);
|
|
159
|
-
print(`
|
|
195
|
+
print(` workspace ${created.targets.join(', ')}`);
|
|
160
196
|
|
|
161
197
|
for (const [target, from] of Object.entries(created.copiedFrom)) {
|
|
162
198
|
print(` ${style.dim(`${target} adapters copied from profile "${from}"`)}`);
|
|
@@ -165,7 +201,7 @@ export async function profileAdd(
|
|
|
165
201
|
print();
|
|
166
202
|
print(
|
|
167
203
|
style.dim(
|
|
168
|
-
`Next: lanes link connect example --profile ${created.name} --
|
|
204
|
+
`Next: lanes link connect example --profile ${created.name} --workspace ${created.targets[0]}`,
|
|
169
205
|
),
|
|
170
206
|
);
|
|
171
207
|
});
|
|
@@ -180,7 +216,7 @@ export async function profileList(
|
|
|
180
216
|
return emit(options.json, listing, () => {
|
|
181
217
|
if (listing.profiles.length === 0) {
|
|
182
218
|
print(style.dim(`No profiles in ${listing.root}.`));
|
|
183
|
-
print(style.dim('Create one with: lanes link profile add personal --
|
|
219
|
+
print(style.dim('Create one with: lanes link profile add personal --workspace local'));
|
|
184
220
|
return;
|
|
185
221
|
}
|
|
186
222
|
|
|
@@ -210,7 +246,7 @@ export function profileDefault(name: string | undefined): never {
|
|
|
210
246
|
throw new ConfigError(
|
|
211
247
|
'lanes link profile default was removed.\n' +
|
|
212
248
|
' Nothing reads default_profile any more — pass --profile on every command:\n' +
|
|
213
|
-
` lanes link status --profile ${name ?? '<name>'} --
|
|
249
|
+
` lanes link status --profile ${name ?? '<name>'} --workspace <name>\n` +
|
|
214
250
|
' If the key is still in lanes-link.yaml it is inert, and safe to delete.',
|
|
215
251
|
);
|
|
216
252
|
}
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import { CONNECTIONS_FILE, readConnections, type Resolution } from '#profile';
|
|
2
|
+
import { ConfigDocument } from '../config-edit.ts';
|
|
3
|
+
import { announce, announceWorkspace, emit, ok, print, style } from '../output.ts';
|
|
4
|
+
import { recordConfigChange } from '../audit-change.ts';
|
|
5
|
+
import { openWorkspaceRuntime, type GlobalFlags } from '../runtime.ts';
|
|
6
|
+
import { nextAfterEdit, publishProfileEdit } from '../publish.ts';
|
|
7
|
+
import { locate } from './connection.ts';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* `lanes link relabel` — the operator's own word for an account.
|
|
11
|
+
*
|
|
12
|
+
* Split from `disconnect` so `connection.ts` stays inside the size budget, and
|
|
13
|
+
* on a real seam rather than a line count: `disconnect` reaches two files, every
|
|
14
|
+
* profile that granted the row, and the credential store, while this writes one
|
|
15
|
+
* field in one document.
|
|
16
|
+
*
|
|
17
|
+
* The label is a separate field from `account` for the reason the bug below
|
|
18
|
+
* gives, and the two must not be merged back.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
export interface Relabelled {
|
|
22
|
+
readonly profile: string;
|
|
23
|
+
readonly target: string;
|
|
24
|
+
readonly key: string;
|
|
25
|
+
readonly from: string;
|
|
26
|
+
readonly to: string;
|
|
27
|
+
readonly published: string;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface RelabelFlags extends GlobalFlags {
|
|
31
|
+
readonly json?: boolean | undefined;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Rename a connection, writing `label` and never `account`.
|
|
36
|
+
*
|
|
37
|
+
* It wrote `account` until it was noticed that `account` is not a display name:
|
|
38
|
+
* `settleIdentity` matches on it to tell a repair from a new account,
|
|
39
|
+
* `idFromAccount` derives the id from it, and `gmail.send_message` writes it
|
|
40
|
+
* into a `From` header. Renaming through it therefore un-recognised the account
|
|
41
|
+
* it renamed — the next `connect` added a second row beside it.
|
|
42
|
+
*/
|
|
43
|
+
export async function renameConnection(
|
|
44
|
+
key: string,
|
|
45
|
+
label: string,
|
|
46
|
+
flags: RelabelFlags,
|
|
47
|
+
): Promise<{ resolution: Resolution; relabelled: Relabelled }> {
|
|
48
|
+
const runtime = await openWorkspaceRuntime(flags);
|
|
49
|
+
|
|
50
|
+
try {
|
|
51
|
+
const { resolution, config, target } = runtime;
|
|
52
|
+
const root = resolution.workspaceRoot;
|
|
53
|
+
|
|
54
|
+
// The workspace's file, not the profile's. A label is the operator's own
|
|
55
|
+
// word for an account (ADR-057 moved the row that carries it), so renaming
|
|
56
|
+
// it once renames it everywhere — which is what someone renaming "the work
|
|
57
|
+
// mailbox" means, and what two copies could never keep true.
|
|
58
|
+
const connectionsFile = await readConnections(root);
|
|
59
|
+
const located = locate(connectionsFile.connections, key, target);
|
|
60
|
+
|
|
61
|
+
const document = await ConfigDocument.openKey(root, CONNECTIONS_FILE);
|
|
62
|
+
document.setIn(['connections', located.index, 'label'], label);
|
|
63
|
+
await document.save();
|
|
64
|
+
|
|
65
|
+
await recordConfigChange(config, root, target, {
|
|
66
|
+
capability: 'config.connection.relabel',
|
|
67
|
+
scope: target,
|
|
68
|
+
connection: key,
|
|
69
|
+
arguments: { from: located.connection.label ?? located.connection.account, to: label },
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
return {
|
|
73
|
+
resolution,
|
|
74
|
+
relabelled: {
|
|
75
|
+
profile: resolution.profile,
|
|
76
|
+
target,
|
|
77
|
+
key,
|
|
78
|
+
// What it was called a moment ago, which is the account only until the
|
|
79
|
+
// first rename.
|
|
80
|
+
from: located.connection.label ?? located.connection.account,
|
|
81
|
+
to: label,
|
|
82
|
+
published: nextAfterEdit(await publishProfileEdit({ resolution, config, target })),
|
|
83
|
+
},
|
|
84
|
+
};
|
|
85
|
+
} finally {
|
|
86
|
+
await runtime.close();
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export async function relabel(
|
|
91
|
+
key: string | undefined,
|
|
92
|
+
label: string | undefined,
|
|
93
|
+
flags: RelabelFlags,
|
|
94
|
+
): Promise<void> {
|
|
95
|
+
if (!key) throw new Error('Which connection? Run: lanes link status');
|
|
96
|
+
if (!label) throw new Error(`What should ${key} be called? Run: lanes link relabel ${key} "New name"`);
|
|
97
|
+
|
|
98
|
+
const { resolution, relabelled: result } = await renameConnection(key, label, flags);
|
|
99
|
+
|
|
100
|
+
return emit(flags.json, result, () => {
|
|
101
|
+
announceWorkspace(resolution);
|
|
102
|
+
print(ok(`${style.bold(result.key)} is now ${style.bold(result.to)}`));
|
|
103
|
+
if (result.from) print(` was ${style.dim(result.from)}`);
|
|
104
|
+
// The label is a display name in two places, and only one of them changed.
|
|
105
|
+
print(
|
|
106
|
+
style.dim(
|
|
107
|
+
' The state store keeps the old name until the next reconcile, which updates it.',
|
|
108
|
+
),
|
|
109
|
+
);
|
|
110
|
+
if (result.published) print(style.dim(` ${result.published}`));
|
|
111
|
+
})
|
|
112
|
+
}
|
|
@@ -1,14 +1,20 @@
|
|
|
1
1
|
import { ConfigError } from '#profile';
|
|
2
2
|
import { assertValidSecretRef } from '#secrets';
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
3
|
+
import { announceWorkspace, announceProfile, heading, ok, print, style } from '../output.ts';
|
|
4
|
+
import {
|
|
5
|
+
openSecretStoreFor,
|
|
6
|
+
primaryProfile,
|
|
7
|
+
resolveProfile,
|
|
8
|
+
resolveProfileOnly,
|
|
9
|
+
type GlobalFlags,
|
|
10
|
+
} from '../runtime.ts';
|
|
5
11
|
|
|
6
12
|
/**
|
|
7
13
|
* `lanes link secrets` — moving credential values between a profile's targets.
|
|
8
14
|
*
|
|
9
15
|
* Credentials follow the target, because each target has its own credential
|
|
10
|
-
* store: `lanes link connect gmail.side --
|
|
11
|
-
* Secret Manager, and the same command with `--
|
|
16
|
+
* store: `lanes link connect gmail.side --workspace cloud` writes the refresh token into
|
|
17
|
+
* Secret Manager, and the same command with `--workspace local` writes it into
|
|
12
18
|
* the encrypted file. That is the right default and it leaves one gap, which
|
|
13
19
|
* this command fills — a setup built locally first, and now wanted in the
|
|
14
20
|
* cloud, without re-running every OAuth flow.
|
|
@@ -43,7 +49,7 @@ export async function secretsPush(flags: SecretsFlags): Promise<void> {
|
|
|
43
49
|
|
|
44
50
|
const refs = await source.list();
|
|
45
51
|
if (refs.length === 0) {
|
|
46
|
-
print(style.dim(`No credentials in
|
|
52
|
+
print(style.dim(`No credentials in workspace "${flags.from}".`));
|
|
47
53
|
return;
|
|
48
54
|
}
|
|
49
55
|
|
|
@@ -104,7 +110,7 @@ export async function secretsSet(ref: string | undefined, flags: GlobalFlags): P
|
|
|
104
110
|
if (!ref) {
|
|
105
111
|
throw new ConfigError(
|
|
106
112
|
'Usage: lanes link secrets set <ref> (the value is read from stdin)\n' +
|
|
107
|
-
' e.g. printf %s "$DATABASE_URL" | lanes link secrets set cloud/database_url --
|
|
113
|
+
' e.g. printf %s "$DATABASE_URL" | lanes link secrets set cloud/database_url --workspace cloud',
|
|
108
114
|
);
|
|
109
115
|
}
|
|
110
116
|
assertValidSecretRef(ref);
|
|
@@ -119,8 +125,16 @@ export async function secretsSet(ref: string | undefined, flags: GlobalFlags): P
|
|
|
119
125
|
);
|
|
120
126
|
}
|
|
121
127
|
|
|
122
|
-
|
|
123
|
-
|
|
128
|
+
// The credential store belongs to the workspace since contract 3, so every
|
|
129
|
+
// profile opened the same one and naming one chose nothing. A profile is
|
|
130
|
+
// still resolved, because `openSecretStoreFor` reads the adapter set through
|
|
131
|
+
// one — so the banner names the workspace rather than reporting whichever
|
|
132
|
+
// profile happened to supply it.
|
|
133
|
+
const { resolution, config, target } = await resolveProfile({
|
|
134
|
+
...flags,
|
|
135
|
+
profile: await primaryProfile(flags),
|
|
136
|
+
});
|
|
137
|
+
announceWorkspace(resolution);
|
|
124
138
|
|
|
125
139
|
const value = (await Bun.stdin.text()).replace(/\n$/, '');
|
|
126
140
|
if (!value) {
|
|
@@ -133,20 +147,28 @@ export async function secretsSet(ref: string | undefined, flags: GlobalFlags): P
|
|
|
133
147
|
const replacing = await credentials.has(ref);
|
|
134
148
|
await credentials.set(ref, value);
|
|
135
149
|
|
|
136
|
-
print(ok(`${replacing ? 'replaced' : 'stored'} ${style.bold(ref)} in
|
|
150
|
+
print(ok(`${replacing ? 'replaced' : 'stored'} ${style.bold(ref)} in workspace ${target}`));
|
|
137
151
|
if (replacing) {
|
|
138
152
|
print(style.dim(' Anything still using the old value will start failing.'));
|
|
139
153
|
}
|
|
140
154
|
}
|
|
141
155
|
|
|
142
156
|
export async function secretsList(flags: GlobalFlags): Promise<void> {
|
|
143
|
-
|
|
144
|
-
|
|
157
|
+
// The credential store belongs to the workspace since contract 3, so every
|
|
158
|
+
// profile opened the same one and naming one chose nothing. A profile is
|
|
159
|
+
// still resolved, because `openSecretStoreFor` reads the adapter set through
|
|
160
|
+
// one — so the banner names the workspace rather than reporting whichever
|
|
161
|
+
// profile happened to supply it.
|
|
162
|
+
const { resolution, config, target } = await resolveProfile({
|
|
163
|
+
...flags,
|
|
164
|
+
profile: await primaryProfile(flags),
|
|
165
|
+
});
|
|
166
|
+
announceWorkspace(resolution);
|
|
145
167
|
|
|
146
168
|
const credentials = await openSecretStoreFor(config, resolution.workspaceRoot, target);
|
|
147
169
|
const refs = await credentials.list();
|
|
148
170
|
|
|
149
|
-
heading(`Credential references in
|
|
171
|
+
heading(`Credential references in workspace ${target} (${refs.length})`);
|
|
150
172
|
if (refs.length === 0) {
|
|
151
173
|
print(style.dim(' none'));
|
|
152
174
|
} else {
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import {
|
|
2
|
+
ConfigError,
|
|
3
|
+
WORKSPACE_FILE,
|
|
4
|
+
readRegistry,
|
|
5
|
+
readWorkspace,
|
|
6
|
+
resolveWorkspaceRoot,
|
|
7
|
+
} from '#profile';
|
|
8
|
+
import { ConfigDocument } from '../config-edit.ts';
|
|
9
|
+
import { ok, print, style } from '../output.ts';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* `lanes set-workspace <name>` — which workspace a command means when it does
|
|
13
|
+
* not say.
|
|
14
|
+
*
|
|
15
|
+
* ADR-037 deleted the command this replaces, and its reasoning is the thing to
|
|
16
|
+
* read before changing anything here:
|
|
17
|
+
*
|
|
18
|
+
* > Persisted context state is the standard way operators run destructive
|
|
19
|
+
* > commands against the wrong target, and the version of it that bites is the
|
|
20
|
+
* > dotfile nothing prints.
|
|
21
|
+
*
|
|
22
|
+
* Both halves of that sentence are answered rather than argued with (ADR-061).
|
|
23
|
+
* The default is **printed on every command that uses it**, so it is not the
|
|
24
|
+
* dotfile nothing prints; and it is **refused by every command that publishes or
|
|
25
|
+
* destroys**, so it is not what runs a destructive command against the wrong
|
|
26
|
+
* thing. Take either half away and the objection stands again.
|
|
27
|
+
*
|
|
28
|
+
* Top level rather than under `lanes link`, because it selects the workspace
|
|
29
|
+
* *the CLI* acts in and a second area added to `lanes` will want the same
|
|
30
|
+
* answer.
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
export interface SetWorkspaceResult {
|
|
34
|
+
readonly workspace: string;
|
|
35
|
+
readonly previous: string | null;
|
|
36
|
+
readonly path: string;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export async function setWorkspace(
|
|
40
|
+
name: string | undefined,
|
|
41
|
+
options: { json?: boolean } = {},
|
|
42
|
+
): Promise<void> {
|
|
43
|
+
const root = resolveWorkspaceRoot();
|
|
44
|
+
|
|
45
|
+
if (name === undefined) {
|
|
46
|
+
const current = (await readWorkspace(root))?.default_workspace;
|
|
47
|
+
throw new ConfigError(
|
|
48
|
+
`Usage: lanes set-workspace <name>\n` +
|
|
49
|
+
(current ? ` The default is currently "${current}".\n` : ' No default is set.\n') +
|
|
50
|
+
` Run "lanes link workspace list" to see what there is.`,
|
|
51
|
+
);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Checked against the registry, not accepted on trust. A default naming a
|
|
55
|
+
// workspace that does not exist would turn every later command's refusal into
|
|
56
|
+
// one about the wrong thing — and the operator would have been told "ok" by
|
|
57
|
+
// the command that caused it.
|
|
58
|
+
const registry = await readRegistry(root);
|
|
59
|
+
if (!(name in registry)) {
|
|
60
|
+
const names = Object.keys(registry).sort();
|
|
61
|
+
throw new ConfigError(
|
|
62
|
+
`${root} declares no workspace "${name}".\n` +
|
|
63
|
+
(names.length > 0
|
|
64
|
+
? ` It has: ${names.join(', ')}\n`
|
|
65
|
+
: ' It has none yet. Create one with: lanes link profile add <name> --workspace local\n') +
|
|
66
|
+
` Run "lanes link workspace list" to see them.`,
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const document = await ConfigDocument.openKey(root, WORKSPACE_FILE);
|
|
71
|
+
const previous = document.getIn(['default_workspace']);
|
|
72
|
+
document.setIn(['default_workspace'], name);
|
|
73
|
+
await document.save();
|
|
74
|
+
|
|
75
|
+
const result: SetWorkspaceResult = {
|
|
76
|
+
workspace: name,
|
|
77
|
+
previous: typeof previous === 'string' ? previous : null,
|
|
78
|
+
path: `${root}/${WORKSPACE_FILE}`,
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
if (options.json === true) {
|
|
82
|
+
print(JSON.stringify(result, null, 2));
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
print(ok(`default workspace: ${style.bold(name)}`));
|
|
87
|
+
print(style.dim(` ${result.path}`));
|
|
88
|
+
print('');
|
|
89
|
+
print(
|
|
90
|
+
style.dim(
|
|
91
|
+
' Every command that uses it prints it. Commands that publish or destroy —\n' +
|
|
92
|
+
' deploy, sync, secrets push, profile remove, disconnect, token rotate —\n' +
|
|
93
|
+
' still want --workspace typed out.',
|
|
94
|
+
),
|
|
95
|
+
);
|
|
96
|
+
}
|
|
@@ -25,11 +25,11 @@ export async function setupPlan(provider: string | undefined, flags: SetupFlags)
|
|
|
25
25
|
const context = {
|
|
26
26
|
profile: runtime.resolution.profile,
|
|
27
27
|
target: runtime.resolution.target,
|
|
28
|
-
connections: runtime.
|
|
28
|
+
connections: runtime.connections.map(({ ref }) => ref),
|
|
29
29
|
// Declaring an `oauth_apps` entry is how a profile says "use my own
|
|
30
30
|
// client". Without this the plan would tell someone who has already
|
|
31
31
|
// registered one that they need nothing.
|
|
32
|
-
ownClients:
|
|
32
|
+
ownClients: runtime.ownClients,
|
|
33
33
|
};
|
|
34
34
|
|
|
35
35
|
const manifests = runtime.registry.list().map((entry) => entry.manifest);
|
package/src/cli/commands/sync.ts
CHANGED
|
@@ -64,15 +64,15 @@ async function locateRemote(
|
|
|
64
64
|
}
|
|
65
65
|
|
|
66
66
|
const entry = (await readRegistry(root))[target];
|
|
67
|
-
if (entry?.
|
|
67
|
+
if (entry?.at) return { workspace: entry.at, how: 'already recorded' };
|
|
68
68
|
|
|
69
69
|
if (flags.discover !== true) {
|
|
70
70
|
throw new ConfigError(
|
|
71
71
|
`Nothing here says where "${target}" lives.\n` +
|
|
72
72
|
' No pointer to it in lanes-link.yaml, and nothing else records one.\n\n' +
|
|
73
|
-
' If you know the bucket: lanes link sync
|
|
73
|
+
' If you know the bucket: lanes link sync workspaces --workspace ' +
|
|
74
74
|
`${target} --from gs://<bucket>\n` +
|
|
75
|
-
` If you do not: lanes link sync targets --
|
|
75
|
+
` If you do not: lanes link sync targets --workspace ${target} --discover`,
|
|
76
76
|
);
|
|
77
77
|
}
|
|
78
78
|
|
|
@@ -99,7 +99,7 @@ async function locateRemote(
|
|
|
99
99
|
if (candidates.length > 1 || !isInteractive()) {
|
|
100
100
|
throw new ConfigError(
|
|
101
101
|
`Found ${candidates.length} deployment(s). Name the one you mean:\n` +
|
|
102
|
-
` lanes link sync targets --
|
|
102
|
+
` lanes link sync targets --workspace ${target} --from ${first.workspace}`,
|
|
103
103
|
);
|
|
104
104
|
}
|
|
105
105
|
|
|
@@ -132,7 +132,7 @@ export async function syncTargets(flags: SyncFlags): Promise<void> {
|
|
|
132
132
|
const remoteRegistry = await readRegistry(workspace);
|
|
133
133
|
const declaresIt = remoteRegistry[target] !== undefined;
|
|
134
134
|
const existing = (await readRegistry(root))[target];
|
|
135
|
-
const already = existing?.
|
|
135
|
+
const already = existing?.at === workspace;
|
|
136
136
|
|
|
137
137
|
const payload = { workspace: root, remote: workspace, target, how, declaresIt, applied: false };
|
|
138
138
|
|
|
@@ -163,7 +163,7 @@ export async function syncTargets(flags: SyncFlags): Promise<void> {
|
|
|
163
163
|
}
|
|
164
164
|
|
|
165
165
|
heading('Would write');
|
|
166
|
-
print(`
|
|
166
|
+
print(` workspaces.${target}.at: ${workspace}`);
|
|
167
167
|
print(style.dim(' The bucket keeps everything else; this records where it is.'));
|
|
168
168
|
};
|
|
169
169
|
|
|
@@ -184,11 +184,11 @@ export async function syncTargets(flags: SyncFlags): Promise<void> {
|
|
|
184
184
|
// deploy block, whose token opens it — is declared where it lives, and reading
|
|
185
185
|
// it from there is what makes this safe to run on a workspace that has lost
|
|
186
186
|
// its own copy of anything.
|
|
187
|
-
await recordTarget(root, target, { workspace });
|
|
187
|
+
await recordTarget(root, target, { at: workspace });
|
|
188
188
|
|
|
189
189
|
print('');
|
|
190
190
|
print(ok(`"${target}" now points at ${workspace}`));
|
|
191
|
-
print(style.dim(` lanes link status --
|
|
191
|
+
print(style.dim(` lanes link status --workspace ${target} reads it from there`));
|
|
192
192
|
|
|
193
193
|
return emit(flags.json, { ...payload, applied: true }, () => {});
|
|
194
194
|
}
|
|
@@ -158,7 +158,7 @@ function summarise(name: string, entry: WorkspaceTarget, isSelected: boolean): T
|
|
|
158
158
|
return {
|
|
159
159
|
name,
|
|
160
160
|
isSelected,
|
|
161
|
-
pointsAt: entry.
|
|
161
|
+
pointsAt: entry.at,
|
|
162
162
|
credentials: null,
|
|
163
163
|
storage: null,
|
|
164
164
|
vault: null,
|
|
@@ -200,7 +200,7 @@ function summarise(name: string, entry: WorkspaceTarget, isSelected: boolean): T
|
|
|
200
200
|
* line above, from the same object.
|
|
201
201
|
*/
|
|
202
202
|
export function resolvedEntry(resolved: ResolvedTarget): WorkspaceTarget {
|
|
203
|
-
const {
|
|
203
|
+
const { at: _followed, ...record } = resolved.entry;
|
|
204
204
|
return { ...resolved.declared, ...record };
|
|
205
205
|
}
|
|
206
206
|
|
|
@@ -261,7 +261,7 @@ export async function targetList(flags: TargetFlags): Promise<void> {
|
|
|
261
261
|
// as input would be circular, and it has to keep working in the state every
|
|
262
262
|
// other command fails in — which is what `selectedDeclared` reports.
|
|
263
263
|
if (listing.selected === null) {
|
|
264
|
-
print(style.dim(' Every other command names one of these with --
|
|
264
|
+
print(style.dim(' Every other command names one of these with --workspace.'));
|
|
265
265
|
return;
|
|
266
266
|
}
|
|
267
267
|
|
|
@@ -299,10 +299,12 @@ function deploymentCell(target: TargetSummary): string {
|
|
|
299
299
|
*/
|
|
300
300
|
export function targetUse(name: string | undefined): never {
|
|
301
301
|
throw new ConfigError(
|
|
302
|
-
'lanes link
|
|
303
|
-
'
|
|
304
|
-
'
|
|
305
|
-
` lanes
|
|
302
|
+
'lanes link workspace use was removed, and came back under a new name.\n' +
|
|
303
|
+
' `instance.default_target` is still inert — nothing reads it. What does\n' +
|
|
304
|
+
' read a default is `default_workspace` in lanes-link.yaml (ADR-061):\n' +
|
|
305
|
+
` lanes set-workspace ${name ?? '<name>'}\n` +
|
|
306
|
+
' Or name it per command:\n' +
|
|
307
|
+
` lanes link status --profile <name> --workspace ${name ?? '<name>'}\n` +
|
|
306
308
|
' If the key is still in your profile it is inert, and safe to delete.',
|
|
307
309
|
);
|
|
308
310
|
}
|
|
@@ -2,10 +2,12 @@ import { homedir } from 'node:os';
|
|
|
2
2
|
import { join, sep } from 'node:path';
|
|
3
3
|
import { installRoot, resolveWorkspaceRoot } from '#profile';
|
|
4
4
|
import { repairOwnerLayer } from '../config-repair.ts';
|
|
5
|
-
import {
|
|
5
|
+
import { migrateToCurrentContract, type ContractMigration } from '../workspace-migrate.ts';
|
|
6
|
+
import type { Contract3Migration } from '../contract3.ts';
|
|
6
7
|
import { emit, fail, ok, print, printErr, progress, style, warn } from '../output.ts';
|
|
7
8
|
import { PACKAGE, release, type ReleaseState } from '../release.ts';
|
|
8
9
|
import { version } from '../version.ts';
|
|
10
|
+
import { readSession } from '#auth/lanes/session.ts';
|
|
9
11
|
|
|
10
12
|
/**
|
|
11
13
|
* `lanes link update` — install the newer release, or say why it will not.
|
|
@@ -306,27 +308,66 @@ async function runInstall(argv: readonly string[], json: boolean): Promise<boole
|
|
|
306
308
|
* about, in a sentence naming the fix, rather than a reason for the upgrade to
|
|
307
309
|
* fail. `check` and `doctor` both refuse loudly on the next run.
|
|
308
310
|
*/
|
|
311
|
+
/**
|
|
312
|
+
* Contract 2 to contract 3, reported the same way its predecessor is.
|
|
313
|
+
*
|
|
314
|
+
* Loud about what moved, because this one moves credentials. An operator whose
|
|
315
|
+
* fifteen accounts were merged into one store should see that happen rather than
|
|
316
|
+
* discover it from a directory listing.
|
|
317
|
+
*/
|
|
318
|
+
function sayContract3(migration: Contract3Migration, say: (line: string) => void): void {
|
|
319
|
+
say(
|
|
320
|
+
`migrated ${migration.profiles.length} profile(s) to contract 3 — connections belong to the ` +
|
|
321
|
+
'workspace now, and a profile grants them one by one',
|
|
322
|
+
);
|
|
323
|
+
for (const change of migration.changes) say(` ${change}`);
|
|
324
|
+
|
|
325
|
+
if (migration.renames.length > 0) {
|
|
326
|
+
say(' Two profiles named different accounts with the same id, so one was renamed.');
|
|
327
|
+
say(' Check the grants in each profile before running an agent against them.');
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
say(` The old per-profile credential stores are left in place; remove them once this works.`);
|
|
331
|
+
}
|
|
332
|
+
|
|
309
333
|
async function migrateLocal(root: string, say: (line: string) => void): Promise<void> {
|
|
310
334
|
try {
|
|
311
|
-
|
|
335
|
+
// One call, both steps, in order — a workspace that predates both walks
|
|
336
|
+
// through them rather than jumping, so each step's reasoning applies to the
|
|
337
|
+
// shape it was written for. `update` used to spell that walk itself, which
|
|
338
|
+
// is how `deploy` and `doctor --fix` came to spell only half of it.
|
|
339
|
+
//
|
|
340
|
+
// The subject is passed rather than left to be defaulted because this is
|
|
341
|
+
// the one caller that already had it in hand.
|
|
342
|
+
const session = await readSession();
|
|
343
|
+
const migration = await migrateToCurrentContract(root, {
|
|
344
|
+
apply: true,
|
|
345
|
+
...(session ? { subject: session.subject } : {}),
|
|
346
|
+
});
|
|
347
|
+
|
|
348
|
+
if (migration.legacy) sayLegacyTargets(migration.legacy, say);
|
|
349
|
+
if (migration.contract3) sayContract3(migration.contract3, say);
|
|
350
|
+
} catch (error) {
|
|
351
|
+
say(`could not migrate this workspace: ${error instanceof Error ? error.message : String(error)}`);
|
|
352
|
+
}
|
|
353
|
+
}
|
|
312
354
|
|
|
313
|
-
|
|
314
|
-
|
|
355
|
+
/** Contract 1 to contract 2: targets move from the profile to the workspace. */
|
|
356
|
+
function sayLegacyTargets(
|
|
357
|
+
migration: NonNullable<ContractMigration['legacy']>,
|
|
358
|
+
say: (line: string) => void,
|
|
359
|
+
): void {
|
|
360
|
+
say(
|
|
361
|
+
`migrated ${migration.profiles.length} profile(s) to contract 2 — a target is declared by ` +
|
|
362
|
+
'the workspace now, not by each profile',
|
|
363
|
+
);
|
|
364
|
+
for (const change of migration.changes) say(` ${change}`);
|
|
315
365
|
|
|
366
|
+
for (const pointer of migration.targets.filter((one) => one.kind === 'pointer')) {
|
|
316
367
|
say(
|
|
317
|
-
`
|
|
318
|
-
|
|
368
|
+
` "${pointer.name}" points at ${pointer.where} — run ` +
|
|
369
|
+
`lanes link deploy --workspace ${pointer.name} to migrate what is there`,
|
|
319
370
|
);
|
|
320
|
-
for (const change of migration.changes) say(` ${change}`);
|
|
321
|
-
|
|
322
|
-
const pointers = migration.targets.filter((one) => one.kind === 'pointer');
|
|
323
|
-
for (const pointer of pointers) {
|
|
324
|
-
say(
|
|
325
|
-
` "${pointer.name}" points at ${pointer.where} — run ` +
|
|
326
|
-
`lanes link deploy --target ${pointer.name} to migrate what is there`,
|
|
327
|
-
);
|
|
328
|
-
}
|
|
329
|
-
} catch (error) {
|
|
330
|
-
say(`could not migrate this workspace: ${error instanceof Error ? error.message : String(error)}`);
|
|
331
371
|
}
|
|
332
372
|
}
|
|
373
|
+
|