@lanes-sh/link 0.8.0 → 0.9.1
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 +1 -25
- package/instructions/skills/lanes-link/SKILL.md +12 -12
- package/package.json +1 -1
- package/src/auth/oauth/store.ts +21 -5
- package/src/cli/accepts.ts +6 -5
- package/src/cli/brand.ts +20 -15
- package/src/cli/callback-page.ts +8 -7
- package/src/cli/commands/connect/index.ts +2 -1
- package/src/cli/commands/connect/settle.ts +12 -4
- package/src/cli/commands/connection.ts +2 -1
- package/src/cli/commands/identity.ts +4 -4
- package/src/cli/commands/knowledge/index.ts +9 -16
- package/src/cli/commands/knowledge/migrate.ts +9 -4
- package/src/cli/commands/knowledge/show.ts +14 -9
- package/src/cli/commands/operate/migrate.ts +5 -2
- package/src/cli/commands/operate/pair.ts +1 -1
- package/src/cli/commands/operate/serve.ts +1 -1
- 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/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 +100 -13
- package/src/cli/commands/profile/remove.ts +67 -6
- package/src/cli/commands/profile.ts +33 -6
- package/src/cli/commands/secrets.ts +4 -4
- package/src/cli/commands/update-migration.ts +54 -0
- package/src/cli/commands/update.ts +36 -23
- package/src/cli/config-edit.ts +29 -8
- package/src/cli/config-repair-sweep.ts +183 -0
- package/src/cli/config-repair.ts +90 -141
- package/src/cli/config-templates.ts +26 -24
- package/src/cli/contract3-credentials.ts +294 -0
- package/src/cli/contract3-data.ts +143 -209
- package/src/cli/contract3-layout.ts +46 -0
- package/src/cli/contract3-shape.ts +34 -8
- package/src/cli/contract3.ts +141 -24
- 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 +349 -0
- package/src/cli/identity.ts +44 -26
- package/src/cli/main.ts +6 -1
- package/src/cli/migrate-move.ts +166 -0
- package/src/cli/migrate-plan.ts +3 -3
- package/src/cli/publish.ts +1 -5
- package/src/cli/runtime/open.ts +5 -5
- package/src/cli/runtime/select.ts +2 -11
- package/src/cli/runtime/stores.ts +16 -11
- package/src/cli/runtime/vault.ts +2 -2
- package/src/cli/usage.ts +5 -1
- package/src/cli/workspace-migrate.ts +32 -11
- package/src/connectivity/manifest/provider.ts +31 -12
- package/src/connectivity/transports/imap/parser.ts +70 -9
- package/src/deployments/adapters/filesystem.ts +18 -3
- package/src/deployments/deploy.ts +5 -5
- package/src/deployments/gcp/bucket.ts +42 -6
- package/src/deployments/knowledge.ts +9 -4
- package/src/deployments/target.ts +28 -7
- package/src/deployments/upload.ts +39 -30
- package/src/profile/connections.ts +13 -1
- package/src/profile/deployments.ts +86 -8
- package/src/profile/index.ts +5 -1
- package/src/profile/knowledge.ts +18 -5
- package/src/profile/layout.ts +147 -71
- package/src/profile/load.ts +53 -17
- package/src/profile/schema.ts +11 -2
- package/src/profile/testing.ts +45 -10
- package/src/profile/workspace.ts +66 -30
- package/src/providers/assets/provider.ts +6 -6
- package/src/providers/entities/provider.ts +6 -6
- package/src/providers/entities/writes.ts +1 -1
- package/src/providers/identity/provider.ts +1 -1
- package/src/providers/memory/provider.ts +6 -6
- package/src/providers/setup/provider.ts +3 -3
- package/src/providers/skills/provider.ts +2 -2
- package/src/providers/tasks/provider.ts +6 -6
- package/src/providers/vault/provider.ts +1 -1
- package/src/registry/registry.ts +1 -1
- package/src/server/endpoint.ts +4 -0
- package/src/server/harness.ts +1 -1
- package/src/server/mcp/instructions.ts +21 -21
- package/src/server/mcp/routing.ts +3 -3
- package/src/server/mcp/tools.ts +16 -3
- package/src/server/mcp/visibility.ts +56 -4
- package/src/stores/blobs/conformance.ts +19 -0
- package/src/stores/state/index.ts +76 -10
- package/src/stores/state/testing.ts +5 -1
|
@@ -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
|
}
|
|
@@ -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
|
+
}
|
|
@@ -1,8 +1,22 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
layout,
|
|
3
|
+
PROFILE_FILE,
|
|
4
|
+
profilePath,
|
|
5
|
+
vaultRef,
|
|
6
|
+
workspacePath,
|
|
7
|
+
type Config,
|
|
8
|
+
type TargetConfig,
|
|
9
|
+
} from '#profile';
|
|
2
10
|
import type { SecretStore } from '#secrets';
|
|
3
11
|
import type { BlobStore } from '#stores/blobs';
|
|
4
12
|
import { credentialRefFor, ownClientRefsFor, type ProviderRegistry } from '#registry';
|
|
5
13
|
import { print, style, warn } from '../../output.ts';
|
|
14
|
+
import {
|
|
15
|
+
migratesAcross,
|
|
16
|
+
refuseSealedVault,
|
|
17
|
+
resolveCollisions,
|
|
18
|
+
type Disposition,
|
|
19
|
+
} from './disposition.ts';
|
|
6
20
|
|
|
7
21
|
/**
|
|
8
22
|
* What a profile's removal is allowed to delete, worked out before any of it
|
|
@@ -103,8 +117,21 @@ export interface RemovalItem {
|
|
|
103
117
|
readonly kind: 'secret' | 'blob' | 'file' | 'config' | 'workspace-key';
|
|
104
118
|
readonly id: string;
|
|
105
119
|
readonly note?: string;
|
|
120
|
+
/**
|
|
121
|
+
* The area `id` is a key within, for a blob.
|
|
122
|
+
*
|
|
123
|
+
* Carried rather than derived: the two ends of a migration are two areas and
|
|
124
|
+
* the executor opens both. Derived once, the plan prefixed the profile
|
|
125
|
+
* directory onto `id` while the executor opened its default area — the copy
|
|
126
|
+
* found nothing at the doubled path, wrote nothing, and the directory removal
|
|
127
|
+
* took the bytes. A `--migrate-to` reported success and lost the data.
|
|
128
|
+
*/
|
|
129
|
+
readonly area?: string;
|
|
130
|
+
/** Where this object goes instead of being deleted, as `[area, key]`. */
|
|
131
|
+
readonly movedTo?: readonly [string, string];
|
|
106
132
|
}
|
|
107
133
|
|
|
134
|
+
|
|
108
135
|
export interface RemovalPlan {
|
|
109
136
|
readonly profile: string;
|
|
110
137
|
readonly items: readonly RemovalItem[];
|
|
@@ -128,6 +155,8 @@ export interface PlanOptions {
|
|
|
128
155
|
readonly openSecrets: (target: string) => Promise<SecretStore>;
|
|
129
156
|
readonly openBlobs: (target: string, area?: string) => Promise<BlobStore>;
|
|
130
157
|
readonly readDefaultProfile?: (() => Promise<string | undefined>) | undefined;
|
|
158
|
+
/** What becomes of this profile's own bytes. */
|
|
159
|
+
readonly disposition: Disposition;
|
|
131
160
|
/**
|
|
132
161
|
* The profiles that are staying.
|
|
133
162
|
*
|
|
@@ -140,6 +169,7 @@ export interface PlanOptions {
|
|
|
140
169
|
|
|
141
170
|
const reason = (cause: unknown): string => (cause instanceof Error ? cause.message : String(cause));
|
|
142
171
|
|
|
172
|
+
|
|
143
173
|
/**
|
|
144
174
|
* Everything removing this profile would delete, before any of it is deleted.
|
|
145
175
|
*
|
|
@@ -156,6 +186,9 @@ export async function removalPlan(
|
|
|
156
186
|
options: PlanOptions,
|
|
157
187
|
): Promise<RemovalPlan> {
|
|
158
188
|
const items: RemovalItem[] = [];
|
|
189
|
+
const migrateInto =
|
|
190
|
+
options.disposition.kind === 'migrate' ? options.disposition.into : undefined;
|
|
191
|
+
const sealed: string[] = [];
|
|
159
192
|
const untouched: { target: string; refs: string[] }[] = [];
|
|
160
193
|
const warnings: string[] = [];
|
|
161
194
|
|
|
@@ -205,16 +238,58 @@ export async function removalPlan(
|
|
|
205
238
|
);
|
|
206
239
|
}
|
|
207
240
|
|
|
208
|
-
// **
|
|
209
|
-
// profile owned
|
|
210
|
-
//
|
|
211
|
-
//
|
|
212
|
-
//
|
|
213
|
-
//
|
|
214
|
-
//
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
241
|
+
// **The sweep is back, bounded by the profile's own directory.** It went
|
|
242
|
+
// away under ADR-059, when a profile owned no bytes and the blob root was
|
|
243
|
+
// the whole workspace — listing it then queued every byte in the workspace
|
|
244
|
+
// for deletion because one profile was going. ADR-066 gives the directory
|
|
245
|
+
// back, so it means what it used to: what this profile owns, and nothing an
|
|
246
|
+
// account owns. `lanes link disconnect` is still the command for an
|
|
247
|
+
// account, and nothing here touches one.
|
|
248
|
+
try {
|
|
249
|
+
const blobs = await options.openBlobs(name, layout.profileDir(profile));
|
|
250
|
+
for (const blob of await blobs.list()) {
|
|
251
|
+
// **Not the declaration.** It is config rather than data, deleted below
|
|
252
|
+
// as its own item after everything it is the record of. Swept here it
|
|
253
|
+
// would be counted twice, and on a `--migrate-to` copied into the
|
|
254
|
+
// destination as a second `profile.yaml` — one profile's grants and
|
|
255
|
+
// members landing inside another's directory.
|
|
256
|
+
if (blob.key === PROFILE_FILE) continue;
|
|
257
|
+
|
|
258
|
+
const migratable = migrateInto !== undefined && migratesAcross(blob.key);
|
|
259
|
+
|
|
260
|
+
items.push({
|
|
261
|
+
target: name,
|
|
262
|
+
kind: 'blob',
|
|
263
|
+
id: blob.key,
|
|
264
|
+
area: layout.profileDir(profile),
|
|
265
|
+
...(migratable
|
|
266
|
+
? { movedTo: [layout.profileDir(migrateInto), blob.key] as const }
|
|
267
|
+
: {}),
|
|
268
|
+
...(migrateInto !== undefined && !migratable
|
|
269
|
+
? { note: 'not migrated — deleted with the profile' }
|
|
270
|
+
: {}),
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
if (migrateInto !== undefined && blob.key.startsWith('vault.d/')) sealed.push(blob.key);
|
|
274
|
+
}
|
|
275
|
+
} catch (cause) {
|
|
276
|
+
warnings.push(
|
|
277
|
+
`Target "${name}": its storage could not be opened (${reason(cause)}), so nothing in it will be removed.`,
|
|
278
|
+
);
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
// The store's root is the profile's own directory, and an adapter must never
|
|
282
|
+
// delete the root it was configured with — so emptying it leaves the
|
|
283
|
+
// directory, and one left behind is silently reused by a later `profile add`
|
|
284
|
+
// of the same name.
|
|
285
|
+
if (declared.storage.adapter === 'filesystem') {
|
|
286
|
+
items.push({
|
|
287
|
+
target: name,
|
|
288
|
+
kind: 'file',
|
|
289
|
+
id: layout.profileDir(profile),
|
|
290
|
+
note: 'the profile directory, once emptied',
|
|
291
|
+
});
|
|
292
|
+
}
|
|
218
293
|
|
|
219
294
|
// A deployed revision reads its config from the bucket rather than the
|
|
220
295
|
// image (ADR-023), so that copy is the profile too — and it is outside the
|
|
@@ -223,7 +298,7 @@ export async function removalPlan(
|
|
|
223
298
|
items.push({
|
|
224
299
|
target: name,
|
|
225
300
|
kind: 'config',
|
|
226
|
-
id:
|
|
301
|
+
id: layout.profileConfig(profile),
|
|
227
302
|
note: 'the copy a deployed revision reads',
|
|
228
303
|
});
|
|
229
304
|
}
|
|
@@ -253,6 +328,16 @@ export async function removalPlan(
|
|
|
253
328
|
});
|
|
254
329
|
}
|
|
255
330
|
|
|
331
|
+
if (sealed.length > 0) refuseSealedVault(profile, migrateInto!);
|
|
332
|
+
|
|
333
|
+
if (migrateInto !== undefined) {
|
|
334
|
+
warnings.push(
|
|
335
|
+
...(await resolveCollisions(items, migrateInto, layout.profileDir(migrateInto), (area) =>
|
|
336
|
+
options.openBlobs(options.target, area),
|
|
337
|
+
)),
|
|
338
|
+
);
|
|
339
|
+
}
|
|
340
|
+
|
|
256
341
|
// Last. It is the only record of where everything else lives, so a failure
|
|
257
342
|
// before this point leaves data a later run can still find.
|
|
258
343
|
items.push({ target: null, kind: 'config', id: profilePath(root, profile) });
|
|
@@ -290,7 +375,9 @@ export function renderPlan(plan: RemovalPlan): void {
|
|
|
290
375
|
print(` ${style.bold(target ?? 'workspace')}`);
|
|
291
376
|
for (const item of items) {
|
|
292
377
|
const note = item.note ? style.dim(` — ${item.note}`) : '';
|
|
293
|
-
|
|
378
|
+
const shown = item.area === undefined ? item.id : `${item.area}/${item.id}`;
|
|
379
|
+
const into = item.movedTo ? style.dim(` → ${item.movedTo[0]}/${item.movedTo[1]}`) : '';
|
|
380
|
+
print(` ${KIND_LABEL[item.kind].padEnd(10)} ${shown}${into}${note}`);
|
|
294
381
|
}
|
|
295
382
|
print();
|
|
296
383
|
}
|
|
@@ -24,6 +24,7 @@ import {
|
|
|
24
24
|
} from '../../runtime.ts';
|
|
25
25
|
import { loadWorkspaceProfiles } from '#profile';
|
|
26
26
|
import { removalPlan, renderPlan, type RemovalItem, type RemovalPlan } from './removal.ts';
|
|
27
|
+
import { settleDisposition, type Disposition } from './disposition.ts';
|
|
27
28
|
|
|
28
29
|
/**
|
|
29
30
|
* Performing a removal, and being honest about the parts that did not happen.
|
|
@@ -62,9 +63,21 @@ export interface RunDeps {
|
|
|
62
63
|
|
|
63
64
|
const reason = (cause: unknown): string => (cause instanceof Error ? cause.message : String(cause));
|
|
64
65
|
|
|
65
|
-
/**
|
|
66
|
+
/**
|
|
67
|
+
* The items that only make sense once everything else is actually gone.
|
|
68
|
+
*
|
|
69
|
+
* **`file` is in here, and that is the whole of its reason.** It is
|
|
70
|
+
* `rm -rf profiles/<profile>`, and it carries a target rather than `null`, so
|
|
71
|
+
* the original `target === null` test let it run after a failed object — taking
|
|
72
|
+
* the bytes a `--migrate-to` had not managed to copy, and `profile.yaml`, which
|
|
73
|
+
* the sweep deliberately leaves for last. `renderOutcome` then printed "the
|
|
74
|
+
* profile's config was kept, so nothing is stranded" about a directory that no
|
|
75
|
+
* longer existed. Same shape as the defect this file records having shipped
|
|
76
|
+
* once already.
|
|
77
|
+
*/
|
|
66
78
|
const isRecordOfWhereThingsAre = (item: RemovalItem): boolean =>
|
|
67
|
-
item.
|
|
79
|
+
item.kind === 'file' ||
|
|
80
|
+
(item.target === null && (item.kind === 'config' || item.kind === 'workspace-key'));
|
|
68
81
|
|
|
69
82
|
export async function executeRemoval(
|
|
70
83
|
plan: RemovalPlan,
|
|
@@ -107,9 +120,30 @@ export async function executeRemoval(
|
|
|
107
120
|
await (await secretStore(item.target!)).delete(item.id);
|
|
108
121
|
break;
|
|
109
122
|
|
|
110
|
-
case 'blob':
|
|
111
|
-
|
|
123
|
+
case 'blob': {
|
|
124
|
+
const store = await blobStore(item.target!, item.area);
|
|
125
|
+
|
|
126
|
+
// Read across *before* deleting, and verify it landed — the same rule
|
|
127
|
+
// the contract migrations follow, for the same reason: a copy that
|
|
128
|
+
// half happened and a source that is already gone is the one state
|
|
129
|
+
// with nothing to retry from. A collision was resolved while this was
|
|
130
|
+
// still a plan, so the destination is free.
|
|
131
|
+
if (item.movedTo !== undefined) {
|
|
132
|
+
const [area, key] = item.movedTo;
|
|
133
|
+
const bytes = await store.get(item.id);
|
|
134
|
+
|
|
135
|
+
if (bytes !== null) {
|
|
136
|
+
const into = await blobStore(item.target!, area);
|
|
137
|
+
await into.put(key, bytes);
|
|
138
|
+
if ((await into.get(key)) === null) {
|
|
139
|
+
throw new Error(`${area}/${key} did not read back after being written`);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
await store.delete(item.id);
|
|
112
145
|
break;
|
|
146
|
+
}
|
|
113
147
|
|
|
114
148
|
case 'config':
|
|
115
149
|
if (item.target === null) await deps.removeConfig(item.id);
|
|
@@ -241,10 +275,15 @@ export interface RemoveFlags extends GlobalFlags {
|
|
|
241
275
|
readonly dryRun?: boolean | undefined;
|
|
242
276
|
readonly yes?: boolean | undefined;
|
|
243
277
|
readonly json?: boolean | undefined;
|
|
278
|
+
/** Delete this profile's memory, tasks, assets, entities, vault and skills. */
|
|
279
|
+
readonly deleteData?: boolean | undefined;
|
|
280
|
+
/** Move them into this profile instead. */
|
|
281
|
+
readonly migrateTo?: string | undefined;
|
|
244
282
|
/** Injected by a caller that has already asked — the console, and tests. */
|
|
245
283
|
readonly prompter?: Prompter | undefined;
|
|
246
284
|
}
|
|
247
285
|
|
|
286
|
+
|
|
248
287
|
/**
|
|
249
288
|
* `lanes link profile remove <name>` — the profile, and everything it owns.
|
|
250
289
|
*
|
|
@@ -261,10 +300,32 @@ export async function removeProfile(name: string, flags: RemoveFlags): Promise<v
|
|
|
261
300
|
const files = workspaceFiles(root);
|
|
262
301
|
const { declared } = await openTarget(root, target);
|
|
263
302
|
|
|
303
|
+
const prompter = flags.prompter ?? terminalPrompter;
|
|
304
|
+
const someoneToAsk = flags.prompter ? prompter.interactive : process.stdin.isTTY;
|
|
305
|
+
const disposition = await settleDisposition(name, flags, prompter, someoneToAsk);
|
|
306
|
+
|
|
307
|
+
if (disposition === null) {
|
|
308
|
+
print(style.dim(' cancelled — nothing was removed'));
|
|
309
|
+
return;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
if (disposition.kind === 'migrate') {
|
|
313
|
+
// Before the plan, because a plan against a profile that does not exist
|
|
314
|
+
// would name destinations nothing will ever read.
|
|
315
|
+
const staying = (await loadWorkspaceProfiles(root)).loaded.map((one) => one.profile);
|
|
316
|
+
if (disposition.into === name || !staying.includes(disposition.into)) {
|
|
317
|
+
throw new ConfigError(
|
|
318
|
+
`Cannot migrate "${name}" into "${disposition.into}".\n` +
|
|
319
|
+
` Staying: ${staying.filter((one) => one !== name).join(', ') || 'nothing'}`,
|
|
320
|
+
);
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
|
|
264
324
|
const plan = await removalPlan(config, root, name, registry, {
|
|
265
325
|
target,
|
|
266
326
|
declared,
|
|
267
|
-
|
|
327
|
+
disposition,
|
|
328
|
+
openSecrets: (target) => openSecretStoreFor(root, target),
|
|
268
329
|
openBlobs: (target, area) => openBlobStoreFor(config, root, target, area),
|
|
269
330
|
readDefaultProfile: async () => (await readWorkspace(root))?.default_profile,
|
|
270
331
|
// What the workspace keeps. The credential store is one file for all of
|
|
@@ -285,7 +346,7 @@ export async function removeProfile(name: string, flags: RemoveFlags): Promise<v
|
|
|
285
346
|
if (!(await confirmedByName(name, { yes: flags.yes, prompter: flags.prompter }))) return;
|
|
286
347
|
|
|
287
348
|
const outcome = await executeRemoval(plan, {
|
|
288
|
-
openSecrets: (target) => openSecretStoreFor(
|
|
349
|
+
openSecrets: (target) => openSecretStoreFor(root, target),
|
|
289
350
|
openBlobs: (target, area) => openBlobStoreFor(config, root, target, area),
|
|
290
351
|
removeConfig: async (path) => await files.delete(relativeToRoot(root, path)),
|
|
291
352
|
removeDirectory: async (path) => await rm(workspacePath(root, path), { recursive: true, force: true }),
|
|
@@ -6,6 +6,8 @@ import {
|
|
|
6
6
|
CONNECTIONS_FILE,
|
|
7
7
|
ConfigError,
|
|
8
8
|
WORKSPACE_FILE,
|
|
9
|
+
LEGACY_WORKSPACE_FILE,
|
|
10
|
+
legacyProfileConfig,
|
|
9
11
|
listProfiles,
|
|
10
12
|
profilePath,
|
|
11
13
|
readWorkspace,
|
|
@@ -14,6 +16,7 @@ import {
|
|
|
14
16
|
writeWorkspaceFile,
|
|
15
17
|
resolveTargetWorkspace,
|
|
16
18
|
resolveWorkspaceRoot,
|
|
19
|
+
layout,
|
|
17
20
|
} from '#profile';
|
|
18
21
|
|
|
19
22
|
import { recordConfigChange } from '../audit-change.ts';
|
|
@@ -77,9 +80,26 @@ export async function createProfile(
|
|
|
77
80
|
// <name> --workspace local` on an empty directory is how a workspace comes into
|
|
78
81
|
// existence, and the target it names is declared *by* that file — so writing
|
|
79
82
|
// it second means resolving a target nothing has declared yet.
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
+
// **Either name, and an unmigrated workspace is refused rather than
|
|
84
|
+
// shadowed.** `resolveWorkspaceRoot` accepts both markers, so this ran
|
|
85
|
+
// against a contract-3 workspace, found no `workspaces.yaml`, and wrote a
|
|
86
|
+
// fresh template beside `lanes-link.yaml` — `readWorkspace` prefers the new
|
|
87
|
+
// name, so every declared target and deployment record vanished, and
|
|
88
|
+
// `renameRegistry` returns early once the new file exists, so no migration
|
|
89
|
+
// could put them back.
|
|
90
|
+
if (!isRemoteWorkspace(local)) {
|
|
91
|
+
if (existsSync(join(local, LEGACY_WORKSPACE_FILE)) && !existsSync(join(local, WORKSPACE_FILE))) {
|
|
92
|
+
throw new ConfigError(
|
|
93
|
+
`${local} is still laid out the way contract 3 kept it, and adding a profile here would ` +
|
|
94
|
+
'write a second registry beside the one it already has.\n' +
|
|
95
|
+
' Migrate it first: lanes link doctor --fix --profile <name> --workspace <name>',
|
|
96
|
+
);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
if (!existsSync(join(local, WORKSPACE_FILE))) {
|
|
100
|
+
await mkdir(local, { recursive: true });
|
|
101
|
+
await writeFile(join(local, WORKSPACE_FILE), newWorkspaceTemplate(), { mode: 0o600 });
|
|
102
|
+
}
|
|
83
103
|
}
|
|
84
104
|
|
|
85
105
|
const root = await resolveTargetWorkspace(local, target);
|
|
@@ -93,8 +113,15 @@ export async function createProfile(
|
|
|
93
113
|
await writeWorkspaceFile(workspaceFiles(root), CONNECTIONS_FILE, newConnectionsTemplate());
|
|
94
114
|
}
|
|
95
115
|
|
|
96
|
-
|
|
97
|
-
|
|
116
|
+
// Both shapes, for the same reason: `listProfiles` sees a contract-3
|
|
117
|
+
// `profiles/<name>.yaml` and this did not, so `profile add personal` wrote a
|
|
118
|
+
// fresh template alongside the operator's own — one name, two files, and the
|
|
119
|
+
// empty one opened. `doctor --fix` then planned a move onto an occupied
|
|
120
|
+
// destination and threw on every rerun.
|
|
121
|
+
for (const key of [layout.profileConfig(name), legacyProfileConfig(name)]) {
|
|
122
|
+
if (await workspaceFiles(root).has(key)) {
|
|
123
|
+
throw new Error(`Profile "${name}" already exists at ${root}/${key}`);
|
|
124
|
+
}
|
|
98
125
|
}
|
|
99
126
|
|
|
100
127
|
// Only a directory needs making. A bucket has no directories, and the write
|
|
@@ -127,7 +154,7 @@ export async function createProfile(
|
|
|
127
154
|
|
|
128
155
|
await writeWorkspaceFile(
|
|
129
156
|
workspaceFiles(root),
|
|
130
|
-
|
|
157
|
+
layout.profileConfig(name),
|
|
131
158
|
newProfileTemplate(name, port, session?.subject),
|
|
132
159
|
);
|
|
133
160
|
|