@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
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
* outputs.ts what an agent harness needs, and proving the short form works
|
|
12
12
|
* desktop.ts opening the Lanes app, on the page that drives this CLI
|
|
13
13
|
* serve.ts start
|
|
14
|
+
* pair.ts pair — the dashboard's read surface (ADR-063)
|
|
14
15
|
* audit.ts audit tail and verify, and the Markdown rendering of tail
|
|
15
16
|
* token.ts token show, token rotate
|
|
16
17
|
* policy.ts policy list/allow/deny, config show
|
|
@@ -25,6 +26,7 @@ export { status } from './operate/status.ts';
|
|
|
25
26
|
export { outputs, type OutputsFlags } from './operate/outputs.ts';
|
|
26
27
|
export { tools, type ToolsFlags } from './operate/tools.ts';
|
|
27
28
|
export { start } from './operate/serve.ts';
|
|
29
|
+
export { pair, PAIR_CERT_REF, PAIR_KEY_REF, PAIR_TOKEN_REF, type PairFlags } from './operate/pair.ts';
|
|
28
30
|
export { desktop, settingsUrl, type DesktopFlags } from './operate/desktop.ts';
|
|
29
31
|
export { auditTail, auditVerify, markdownCell } from './operate/audit.ts';
|
|
30
32
|
export { attachFile } from './operate/attach.ts';
|
|
@@ -127,6 +127,6 @@ export async function assetsRemove(name: string | undefined, flags: OwnerFlags):
|
|
|
127
127
|
* it is built from the same two functions rather than spelled as a path.
|
|
128
128
|
*/
|
|
129
129
|
export function assetsStore(runtime: Runtime, flags: OwnerFlags): BlobStore {
|
|
130
|
-
const connection = ownerConnection(runtime.config, '
|
|
131
|
-
return scopeBlobStore(runtime.storage, scopeNamespace('
|
|
130
|
+
const connection = ownerConnection(runtime.config, 'lanes_assets', flags);
|
|
131
|
+
return scopeBlobStore(runtime.storage, scopeNamespace('lanes_assets', connection));
|
|
132
132
|
}
|
|
@@ -239,6 +239,6 @@ export async function entitiesReindex(flags: OwnerFlags): Promise<void> {
|
|
|
239
239
|
* `memoryStore` gives: a path spelled out again is a path that can differ.
|
|
240
240
|
*/
|
|
241
241
|
export function entitiesStore(runtime: Runtime, flags: OwnerFlags): BlobStore {
|
|
242
|
-
const connection = ownerConnection(runtime.config, '
|
|
243
|
-
return scopeBlobStore(runtime.storage, scopeNamespace('
|
|
242
|
+
const connection = ownerConnection(runtime.config, 'lanes_entities', flags);
|
|
243
|
+
return scopeBlobStore(runtime.storage, scopeNamespace('lanes_entities', connection));
|
|
244
244
|
}
|
|
@@ -105,6 +105,6 @@ export async function memoryForget(id: string | undefined, flags: OwnerFlags): P
|
|
|
105
105
|
* again, so the CLI cannot address a different directory from the provider.
|
|
106
106
|
*/
|
|
107
107
|
export function memoryStore(runtime: Runtime, flags: OwnerFlags): BlobStore {
|
|
108
|
-
const connection = ownerConnection(runtime.config, '
|
|
109
|
-
return scopeBlobStore(runtime.storage, scopeNamespace('
|
|
108
|
+
const connection = ownerConnection(runtime.config, 'lanes_memory', flags);
|
|
109
|
+
return scopeBlobStore(runtime.storage, scopeNamespace('lanes_memory', connection));
|
|
110
110
|
}
|
|
@@ -73,7 +73,15 @@ export async function withRuntime(
|
|
|
73
73
|
* the candidates rather than picking.
|
|
74
74
|
*/
|
|
75
75
|
export function ownerConnection(config: Config, provider: string, flags: OwnerFlags): string {
|
|
76
|
-
|
|
76
|
+
// Derived from the grants, not from the workspace's connections. The question
|
|
77
|
+
// is which store *this profile* may reach, and a workspace holding
|
|
78
|
+
// `memory.acme` that this profile does not grant is not a candidate — offering
|
|
79
|
+
// it would let the CLI write somewhere the endpoint would refuse to read
|
|
80
|
+
// (ADR-057, ADR-058).
|
|
81
|
+
const prefix = `${provider}.`;
|
|
82
|
+
const candidates = config.grants
|
|
83
|
+
.filter((grant) => grant.connection.startsWith(prefix))
|
|
84
|
+
.map((grant) => ({ id: grant.connection.slice(prefix.length) }));
|
|
77
85
|
|
|
78
86
|
if (flags.connection) {
|
|
79
87
|
const match = candidates.find((connection) => connection.id === flags.connection);
|
|
@@ -90,7 +98,10 @@ export function ownerConnection(config: Config, provider: string, flags: OwnerFl
|
|
|
90
98
|
|
|
91
99
|
if (candidates.length === 0) {
|
|
92
100
|
throw new ConfigError(
|
|
93
|
-
`This profile
|
|
101
|
+
`This profile grants no ${provider} connection.\n` +
|
|
102
|
+
` Connect one to the workspace, then grant it:\n` +
|
|
103
|
+
` lanes link connect ${provider}\n` +
|
|
104
|
+
` lanes link grant ${provider}.<id> --profile ${config.instance.profile}`,
|
|
94
105
|
);
|
|
95
106
|
}
|
|
96
107
|
if (candidates.length > 1) {
|
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import { ConfigError } from '#profile';
|
|
1
|
+
import { ConfigError, type Config } from '#profile';
|
|
2
|
+
import type { BlobStore } from '#stores/blobs';
|
|
2
3
|
import {
|
|
3
4
|
loadProfileSkills,
|
|
4
5
|
readSkill,
|
|
@@ -10,9 +11,28 @@ import { agreed, readStdin, required, withRuntime, type OwnerFlags } from './sha
|
|
|
10
11
|
|
|
11
12
|
/** `lanes link skills` — the reusable procedures agents can invoke. */
|
|
12
13
|
|
|
14
|
+
/**
|
|
15
|
+
* The store, or a refusal naming why there is none.
|
|
16
|
+
*
|
|
17
|
+
* A profile grants at most one `skills` connection and may grant none
|
|
18
|
+
* (ADR-059), so `store(runtime)` is genuinely absent rather than empty. The two
|
|
19
|
+
* are worth telling apart: "you have no skills" invites writing one, and
|
|
20
|
+
* "this profile is not granted skills" is a config fact the operator has to fix
|
|
21
|
+
* before writing one would do anything.
|
|
22
|
+
*/
|
|
23
|
+
function store(runtime: { skills: BlobStore | undefined; config: Config }): BlobStore {
|
|
24
|
+
if (runtime.skills) return runtime.skills;
|
|
25
|
+
throw new ConfigError(
|
|
26
|
+
`Profile "${runtime.config.instance.profile}" grants no skills connection.\n` +
|
|
27
|
+
` Grant one, then try again:\n` +
|
|
28
|
+
` lanes link grant skills.main --profile ${runtime.config.instance.profile}`,
|
|
29
|
+
);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
|
|
13
33
|
export async function skillsList(flags: OwnerFlags): Promise<void> {
|
|
14
34
|
await withRuntime(flags, async (runtime) => {
|
|
15
|
-
const skills = await loadProfileSkills(runtime
|
|
35
|
+
const skills = await loadProfileSkills(store(runtime));
|
|
16
36
|
|
|
17
37
|
heading(`Skills (${skills.length})`);
|
|
18
38
|
if (skills.length === 0) {
|
|
@@ -43,10 +63,10 @@ export async function skillsShow(name: string | undefined, flags: OwnerFlags): P
|
|
|
43
63
|
const skillName = required(name, 'lanes link skills show <name>');
|
|
44
64
|
|
|
45
65
|
await withRuntime(flags, async (runtime) => {
|
|
46
|
-
const skill = await readSkill(runtime
|
|
66
|
+
const skill = await readSkill(store(runtime), skillName);
|
|
47
67
|
if (!skill) throw new ConfigError(`No skill "${skillName}" in this workspace.`);
|
|
48
68
|
|
|
49
|
-
const bytes = await runtime.
|
|
69
|
+
const bytes = await store(runtime).get(skill.path);
|
|
50
70
|
print('');
|
|
51
71
|
print(bytes ? new TextDecoder().decode(bytes) : skill.body);
|
|
52
72
|
});
|
|
@@ -63,8 +83,8 @@ export async function skillsAdd(name: string | undefined, flags: OwnerFlags): Pr
|
|
|
63
83
|
);
|
|
64
84
|
|
|
65
85
|
await withRuntime(flags, async (runtime) => {
|
|
66
|
-
const existing = await readSkill(runtime
|
|
67
|
-
const skill = await writeSkill(runtime
|
|
86
|
+
const existing = await readSkill(store(runtime), skillName);
|
|
87
|
+
const skill = await writeSkill(store(runtime), skillName, text);
|
|
68
88
|
|
|
69
89
|
print(ok(`${existing ? 'replaced' : 'added'} skill ${style.bold(skill.name)}`));
|
|
70
90
|
print(
|
|
@@ -79,14 +99,14 @@ export async function skillsRemove(name: string | undefined, flags: OwnerFlags):
|
|
|
79
99
|
const skillName = required(name, 'lanes link skills remove <name>');
|
|
80
100
|
|
|
81
101
|
await withRuntime(flags, async (runtime) => {
|
|
82
|
-
const skill = await readSkill(runtime
|
|
102
|
+
const skill = await readSkill(store(runtime), skillName);
|
|
83
103
|
if (!skill) throw new ConfigError(`No skill "${skillName}" in this workspace.`);
|
|
84
104
|
|
|
85
105
|
print(` ${style.bold(skill.name)} ${skill.description}`);
|
|
86
106
|
print(style.dim(` ${skill.path}`));
|
|
87
107
|
if (!(await agreed(flags, 'Remove this skill?'))) return;
|
|
88
108
|
|
|
89
|
-
await removeSkill(runtime
|
|
109
|
+
await removeSkill(store(runtime), skillName);
|
|
90
110
|
print(ok(`removed skill ${style.bold(skillName)}`));
|
|
91
111
|
});
|
|
92
112
|
}
|
|
@@ -189,6 +189,6 @@ function assertStatus(raw: string | undefined): TaskStatus | undefined {
|
|
|
189
189
|
* CLI cannot address a different directory from the provider.
|
|
190
190
|
*/
|
|
191
191
|
export function tasksStore(runtime: Runtime, flags: OwnerFlags): BlobStore {
|
|
192
|
-
const connection = ownerConnection(runtime.config, '
|
|
193
|
-
return scopeBlobStore(runtime.storage, scopeNamespace('
|
|
192
|
+
const connection = ownerConnection(runtime.config, 'lanes_tasks', flags);
|
|
193
|
+
return scopeBlobStore(runtime.storage, scopeNamespace('lanes_tasks', connection));
|
|
194
194
|
}
|
|
@@ -50,7 +50,7 @@ export async function vaultGet(id: string | undefined, flags: OwnerFlags): Promi
|
|
|
50
50
|
const itemId = required(id, 'lanes link vault get <id> [--show]');
|
|
51
51
|
|
|
52
52
|
await withRuntime(flags, async (runtime) => {
|
|
53
|
-
const connection = ownerConnection(runtime.config, '
|
|
53
|
+
const connection = ownerConnection(runtime.config, 'lanes_vault', flags);
|
|
54
54
|
const item = await runtime.vault.get(connection, itemId);
|
|
55
55
|
if (!item) throw new ConfigError(`No vault item "${itemId}" in this profile.`);
|
|
56
56
|
|
|
@@ -77,7 +77,7 @@ export async function vaultSet(id: string | undefined, flags: OwnerFlags): Promi
|
|
|
77
77
|
const value = await readStdin(`lanes link vault set ${itemId}`, 'the secret');
|
|
78
78
|
|
|
79
79
|
await withRuntime(flags, async (runtime) => {
|
|
80
|
-
const connection = ownerConnection(runtime.config, '
|
|
80
|
+
const connection = ownerConnection(runtime.config, 'lanes_vault', flags);
|
|
81
81
|
const replacing = (await runtime.vault.get(connection, itemId)) !== null;
|
|
82
82
|
|
|
83
83
|
await runtime.vault.put(connection, {
|
|
@@ -104,7 +104,7 @@ export async function vaultRemove(id: string | undefined, flags: OwnerFlags): Pr
|
|
|
104
104
|
const itemId = required(id, 'lanes link vault remove <id>');
|
|
105
105
|
|
|
106
106
|
await withRuntime(flags, async (runtime) => {
|
|
107
|
-
const connection = ownerConnection(runtime.config, '
|
|
107
|
+
const connection = ownerConnection(runtime.config, 'lanes_vault', flags);
|
|
108
108
|
const item = await runtime.vault.get(connection, itemId);
|
|
109
109
|
if (!item) throw new ConfigError(`No vault item "${itemId}" in this profile.`);
|
|
110
110
|
|
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
import { ConfigError } from '#profile';
|
|
2
|
+
import type { Prompter } from '../../prompt.ts';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* What becomes of the bytes a removed profile owns.
|
|
6
|
+
*
|
|
7
|
+
* Split from `removal.ts` and `remove.ts` on the seam the question already has:
|
|
8
|
+
* both files grew past the size budget when this arrived, and it is one
|
|
9
|
+
* decision — asked once, answered once, and then applied by the plan and the
|
|
10
|
+
* execution without either re-deciding anything.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* What to do with the bytes a profile owns.
|
|
15
|
+
*
|
|
16
|
+
* There has to be an answer and there is no safe default, which is why this is
|
|
17
|
+
* a discriminated choice rather than a flag with a fallback. Deleting by
|
|
18
|
+
* default destroys the material somebody kept; migrating by default puts one
|
|
19
|
+
* profile's notes into another without being asked, which is the thing ADR-066
|
|
20
|
+
* exists to stop happening by accident.
|
|
21
|
+
*/
|
|
22
|
+
export type Disposition =
|
|
23
|
+
| { readonly kind: 'delete' }
|
|
24
|
+
| { readonly kind: 'migrate'; readonly into: string };
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* `note.md` becomes `note-2.md`, before the extension rather than after it.
|
|
28
|
+
*
|
|
29
|
+
* An asset's key is whatever the file was called, and `note.md-2` is a file
|
|
30
|
+
* nothing will open. Memory entries and tasks are `<id>.md`, so the same rule
|
|
31
|
+
* keeps the id readable.
|
|
32
|
+
*/
|
|
33
|
+
export function suffixed(key: string, taken: (candidate: string) => boolean): string {
|
|
34
|
+
const dot = key.lastIndexOf('.');
|
|
35
|
+
const cut = dot <= key.lastIndexOf('/') ? key.length : dot;
|
|
36
|
+
const [stem, extension] = [key.slice(0, cut), key.slice(cut)];
|
|
37
|
+
|
|
38
|
+
for (let n = 2; ; n += 1) {
|
|
39
|
+
const candidate = `${stem}-${n}${extension}`;
|
|
40
|
+
if (!taken(candidate)) return candidate;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* What to do with the bytes, decided before anything is planned.
|
|
46
|
+
*
|
|
47
|
+
* **`--yes` does not imply a disposition.** Everything else `--yes` skips is a
|
|
48
|
+
* confirmation of something the command was already told to do; this is a
|
|
49
|
+
* question it has not been asked, and picking either answer silently is a way
|
|
50
|
+
* to lose somebody's notes — or to put them somewhere they did not ask for
|
|
51
|
+
* them, which under ADR-066 is the failure the whole change exists to prevent.
|
|
52
|
+
* So a non-interactive caller must say, and one that does not is refused with
|
|
53
|
+
* both spellings named.
|
|
54
|
+
*/
|
|
55
|
+
export async function settleDisposition(
|
|
56
|
+
profile: string,
|
|
57
|
+
flags: { readonly deleteData?: boolean | undefined; readonly migrateTo?: string | undefined },
|
|
58
|
+
prompter: Prompter,
|
|
59
|
+
someoneToAsk: boolean,
|
|
60
|
+
): Promise<Disposition | null> {
|
|
61
|
+
if (flags.migrateTo !== undefined && flags.deleteData === true) {
|
|
62
|
+
throw new ConfigError(
|
|
63
|
+
'--delete-data and --migrate-to say opposite things about the same bytes. Pass one.',
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
if (flags.migrateTo !== undefined) return { kind: 'migrate', into: flags.migrateTo };
|
|
68
|
+
if (flags.deleteData === true) return { kind: 'delete' };
|
|
69
|
+
|
|
70
|
+
if (!someoneToAsk) {
|
|
71
|
+
throw new ConfigError(
|
|
72
|
+
`"${profile}" owns memory, tasks, assets and skills, and this does not guess at what ` +
|
|
73
|
+
'becomes of them.\n' +
|
|
74
|
+
' Say which: --delete-data, or --migrate-to <profile>',
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const answer = (
|
|
79
|
+
await prompter.ask(
|
|
80
|
+
`What becomes of ${profile}'s memory, tasks, assets and skills?\n` +
|
|
81
|
+
` [d] delete them [m] move them into another profile [anything else] stop: `,
|
|
82
|
+
)
|
|
83
|
+
).trim().toLowerCase();
|
|
84
|
+
|
|
85
|
+
if (answer === 'd') return { kind: 'delete' };
|
|
86
|
+
if (answer !== 'm') return null;
|
|
87
|
+
|
|
88
|
+
const into = (await prompter.ask(' Move them into which profile? ')).trim();
|
|
89
|
+
return into.length === 0 ? null : { kind: 'migrate', into };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* The skill a key belongs to, or null when the key is not a skill's.
|
|
95
|
+
*
|
|
96
|
+
* Both layouts: `<name>/SKILL.md` and whatever it ships beside it, and the flat
|
|
97
|
+
* `<name>.md`.
|
|
98
|
+
*/
|
|
99
|
+
export function skillNameIn(key: string): string | null {
|
|
100
|
+
const parts = key.split('/');
|
|
101
|
+
if (parts[0] !== 'skills.d' || parts.length < 3) return null;
|
|
102
|
+
|
|
103
|
+
const third = parts[2]!;
|
|
104
|
+
return parts.length > 3 ? third : third.replace(/\.md$/, '');
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Two skills of one name have no union either, so this refuses like the vault.
|
|
109
|
+
*
|
|
110
|
+
* A skill's name is not a filename — it becomes the capability id
|
|
111
|
+
* `skills.<name>`, which is what a policy rule grants and what an MCP prompt is
|
|
112
|
+
* called. So the suffix that resolves a colliding note resolves nothing here: a
|
|
113
|
+
* skill arriving as `proc-b-2` is granted by no rule the destination holds and
|
|
114
|
+
* offered to no client, which is `refuseSealedVault`'s argument — a copy under a
|
|
115
|
+
* name no command opens — reached by a different route. Renaming only the
|
|
116
|
+
* directory is worse still, because the frontmatter keeps declaring the old
|
|
117
|
+
* name: two skills then claim one capability id and `skills list` refuses for
|
|
118
|
+
* the whole profile rather than for the one skill.
|
|
119
|
+
*
|
|
120
|
+
* Which of the two to keep is a decision about the operator's procedures, and
|
|
121
|
+
* they are the only one who can make it.
|
|
122
|
+
*/
|
|
123
|
+
export function refuseCollidingSkill(into: string, name: string): never {
|
|
124
|
+
throw new ConfigError(
|
|
125
|
+
`"${into}" already has a skill named "${name}", and the one arriving cannot take another ` +
|
|
126
|
+
`name: it becomes the capability id "skills.${name}", which policy rules grant and MCP ` +
|
|
127
|
+
'prompts are called by.\n' +
|
|
128
|
+
` Rename one of the two first — lanes link skills show ${name} --profile ${into} — then ` +
|
|
129
|
+
'run this again, or remove the profile with --delete-data.',
|
|
130
|
+
);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Rename anything the destination already holds, before a byte moves.
|
|
135
|
+
*
|
|
136
|
+
* Resolved while this is still a plan, so the operator sees every rename in the
|
|
137
|
+
* preview they confirm from and the execution has no decision left to make. A
|
|
138
|
+
* destination that is occupied holds the *destination profile's* own note, task
|
|
139
|
+
* or asset — overwriting one would be the quiet half of a migration nobody
|
|
140
|
+
* asked for, and merging two is not a thing bytes can do.
|
|
141
|
+
*
|
|
142
|
+
* Mutates `items` in place because the caller is still assembling the plan;
|
|
143
|
+
* the warnings come back for it to attach.
|
|
144
|
+
*/
|
|
145
|
+
export async function resolveCollisions(
|
|
146
|
+
items: { kind: string; movedTo?: readonly [string, string]; note?: string }[],
|
|
147
|
+
profile: string,
|
|
148
|
+
into: string,
|
|
149
|
+
open: (area: string) => Promise<{ list(): Promise<{ key: string }[]> }>,
|
|
150
|
+
): Promise<string[]> {
|
|
151
|
+
const warnings: string[] = [];
|
|
152
|
+
|
|
153
|
+
// **Throws rather than warning.** It returned a warning saying "nothing has
|
|
154
|
+
// run" and then left every `movedTo` in place — and `executeRemoval` writes
|
|
155
|
+
// each one without checking, on the stated ground that "a collision was
|
|
156
|
+
// resolved while this was still a plan". With `--yes` there is no prompt to
|
|
157
|
+
// stop at, so an unreadable destination meant the destination profile's own
|
|
158
|
+
// notes were overwritten by the removed profile's files of the same name, and
|
|
159
|
+
// the command reported success. A plan that cannot be made safe must not
|
|
160
|
+
// become one.
|
|
161
|
+
let held: Set<string>;
|
|
162
|
+
try {
|
|
163
|
+
held = new Set((await (await open(into)).list()).map((blob) => blob.key));
|
|
164
|
+
} catch (cause) {
|
|
165
|
+
throw new ConfigError(
|
|
166
|
+
`"${profile}"'s storage could not be read (${
|
|
167
|
+
cause instanceof Error ? cause.message : String(cause)
|
|
168
|
+
}), so a name it already holds cannot be found — and migrating into it ` +
|
|
169
|
+
'would overwrite one.\n Nothing has run. Fix the store and try again, or pass ' +
|
|
170
|
+
'--delete-data instead.',
|
|
171
|
+
);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
for (let index = 0; index < items.length; index += 1) {
|
|
175
|
+
const item = items[index]!;
|
|
176
|
+
if (item.kind !== 'blob' || item.movedTo === undefined) continue;
|
|
177
|
+
|
|
178
|
+
const key = item.movedTo[1];
|
|
179
|
+
if (!held.has(key)) {
|
|
180
|
+
held.add(key);
|
|
181
|
+
continue;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// Before the suffix, because for one kind of key there is no suffix that
|
|
185
|
+
// works and the answer is to stop rather than to invent one.
|
|
186
|
+
const skill = skillNameIn(key);
|
|
187
|
+
if (skill !== null) refuseCollidingSkill(profile, skill);
|
|
188
|
+
|
|
189
|
+
const renamed = suffixed(key, (candidate) => held.has(candidate));
|
|
190
|
+
held.add(renamed);
|
|
191
|
+
items[index] = {
|
|
192
|
+
...item,
|
|
193
|
+
movedTo: [into, renamed] as const,
|
|
194
|
+
note: `renamed — ${profile} already holds ${key}`,
|
|
195
|
+
};
|
|
196
|
+
warnings.push(`${key} is already in "${profile}", so it arrives as ${renamed}.`);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
return warnings;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* Whether one key inside a profile's directory can cross into another profile.
|
|
204
|
+
*
|
|
205
|
+
* Only the owner's material can. The prompt asks about memory, tasks, assets
|
|
206
|
+
* and skills; the directory holds two more things and neither may travel.
|
|
207
|
+
*
|
|
208
|
+
* `state.kv/` is derived and disposable, and adopting it would hand the
|
|
209
|
+
* destination another profile's cursor — resuming a mailbox from a position it
|
|
210
|
+
* never read and never seeing the messages in between, which is the
|
|
211
|
+
* cross-profile cursor sharing ADR-066 exists to remove.
|
|
212
|
+
*
|
|
213
|
+
* `vault.d/` is refused by `refuseSealedVault` rather than copied. Because the
|
|
214
|
+
* owner layer merges to one row per surface, both profiles hold
|
|
215
|
+
* `vault.d/lan5.enc` — so a copy always collided, landed as `lan5-2.enc`, and
|
|
216
|
+
* had its source deleted, leaving every item in it unreachable by any command.
|
|
217
|
+
*/
|
|
218
|
+
export function migratesAcross(key: string): boolean {
|
|
219
|
+
return !key.startsWith('state.kv/') && !key.startsWith('vault.d/');
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* Two sealed documents have no union, so the command says so and stops.
|
|
224
|
+
*
|
|
225
|
+
* Deleting one silently is the outcome nobody would accept, and a copy under
|
|
226
|
+
* another name is one no command opens — so neither is offered. The refusal
|
|
227
|
+
* names the two ways forward, which is the rule every refusal here follows.
|
|
228
|
+
*/
|
|
229
|
+
export function refuseSealedVault(profile: string, into: string): never {
|
|
230
|
+
throw new ConfigError(
|
|
231
|
+
`"${profile}" holds a sealed vault, and a vault cannot be merged into "${into}"'s: two ` +
|
|
232
|
+
'encrypted documents have no union, and a copy under another name is one no command opens.\n' +
|
|
233
|
+
` Move what you need first — lanes link vault list --profile ${profile} — then remove it ` +
|
|
234
|
+
'with --delete-data.',
|
|
235
|
+
);
|
|
236
|
+
}
|