@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
|
@@ -0,0 +1,349 @@
|
|
|
1
|
+
import {
|
|
2
|
+
ConfigError,
|
|
3
|
+
layout,
|
|
4
|
+
listProfiles,
|
|
5
|
+
readWorkspaceFile,
|
|
6
|
+
workspaceFiles,
|
|
7
|
+
writeWorkspaceFile,
|
|
8
|
+
WORKSPACE_FILE,
|
|
9
|
+
CONNECTIONS_FILE,
|
|
10
|
+
openTarget,
|
|
11
|
+
} from '#profile';
|
|
12
|
+
import { parseDocument } from 'yaml';
|
|
13
|
+
import { ConfigDocument } from './config-edit.ts';
|
|
14
|
+
import { C3 } from './contract3-layout.ts';
|
|
15
|
+
import { grantingProfiles, planMoves, type DataPlan, type Renames } from './contract4-data.ts';
|
|
16
|
+
import { planRenames } from './contract4-rename.ts';
|
|
17
|
+
import { ensureRegistryContract } from './config-repair-sweep.ts';
|
|
18
|
+
import {
|
|
19
|
+
assertConnectionsSavable,
|
|
20
|
+
readConnectionRows,
|
|
21
|
+
renameConnections,
|
|
22
|
+
rewriteGrants,
|
|
23
|
+
} from './contract4-yaml.ts';
|
|
24
|
+
import { applyMoves, assertOneObjectPerDestination } from './migrate-move.ts';
|
|
25
|
+
import {
|
|
26
|
+
applyCredentialMoves,
|
|
27
|
+
planCredentialMoves,
|
|
28
|
+
planVaultMoves,
|
|
29
|
+
} from './contract4-credentials.ts';
|
|
30
|
+
import { openSecretStoreFor } from './runtime/select.ts';
|
|
31
|
+
import { buildRegistryWithWorkspace } from './runtime/registry.ts';
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Contract 3 to contract 4: a profile owns its data again.
|
|
35
|
+
*
|
|
36
|
+
* Contract 3 moved every account up to the workspace and, in the same sweep,
|
|
37
|
+
* moved the owner layer's bytes beside the *connection* — so a profile owned
|
|
38
|
+
* nothing and the shipped default had every profile granting one memory.
|
|
39
|
+
* ADR-066 reverses that half; ADR-067 collapses a profile into one directory
|
|
40
|
+
* and retires `data/`, which had stopped meaning anything once the declaration
|
|
41
|
+
* moved in beside the bytes.
|
|
42
|
+
*
|
|
43
|
+
* Four steps, ordered so a crash between any two leaves a workspace that still
|
|
44
|
+
* opens:
|
|
45
|
+
*
|
|
46
|
+
* 1. The registry is renamed. One document, cannot half-apply.
|
|
47
|
+
* 2. Bytes move — the workspace's up out of `data/`, a profile's into its
|
|
48
|
+
* directory.
|
|
49
|
+
* 3. Declarations move into the directories they now name.
|
|
50
|
+
* 4. Each profile is stamped `contract: 4`.
|
|
51
|
+
*
|
|
52
|
+
* **The stamp is last, and it is the record that this finished** rather than a
|
|
53
|
+
* step among steps. Contract 3 shipped with it written first, which left
|
|
54
|
+
* profiles claiming the new contract with every byte still at the old path and
|
|
55
|
+
* a rerun that read the stamp and found nothing to do.
|
|
56
|
+
*
|
|
57
|
+
* Every read here goes through `C3`, and every write through `layout`. A
|
|
58
|
+
* migration that asks the live layout for its *source* paths finds nothing to
|
|
59
|
+
* move the moment the next contract lands.
|
|
60
|
+
*/
|
|
61
|
+
|
|
62
|
+
export interface Contract4Migration {
|
|
63
|
+
readonly workspaceRoot: string;
|
|
64
|
+
readonly profiles: readonly string[];
|
|
65
|
+
readonly changes: readonly string[];
|
|
66
|
+
/** Copied into more than one profile, original left. The operator decides. */
|
|
67
|
+
readonly shared: DataPlan['shared'];
|
|
68
|
+
/** Granted by no profile. Left where it is. */
|
|
69
|
+
readonly orphaned: readonly string[];
|
|
70
|
+
readonly alreadyCurrent: boolean;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Whether this workspace still holds anything at contract 3. */
|
|
74
|
+
export async function needsContract4(workspaceRoot: string): Promise<boolean> {
|
|
75
|
+
for (const profile of await listProfiles(workspaceRoot)) {
|
|
76
|
+
const raw = await readProfile(workspaceRoot, profile);
|
|
77
|
+
if (raw !== null && (raw.contract ?? 0) === 3) return true;
|
|
78
|
+
}
|
|
79
|
+
return false;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
async function readProfile(
|
|
83
|
+
root: string,
|
|
84
|
+
profile: string,
|
|
85
|
+
): Promise<{ contract?: number; grants?: { connection?: unknown }[] } | null> {
|
|
86
|
+
const files = workspaceFiles(root);
|
|
87
|
+
// The contract-3 path first: a profile still to be migrated is there, and one
|
|
88
|
+
// already migrated is at the new path. Reading only one shape makes a rerun
|
|
89
|
+
// after an interruption see half a workspace.
|
|
90
|
+
const text =
|
|
91
|
+
(await readWorkspaceFile(files, C3.profile(profile))) ??
|
|
92
|
+
(await readWorkspaceFile(files, layout.profileConfig(profile)));
|
|
93
|
+
if (text === null) return null;
|
|
94
|
+
try {
|
|
95
|
+
return parseDocument(text).toJSON() as { contract?: number };
|
|
96
|
+
} catch {
|
|
97
|
+
return null;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export async function migrateToContract4(
|
|
102
|
+
workspaceRoot: string,
|
|
103
|
+
options: { apply: boolean; target?: string } = { apply: true },
|
|
104
|
+
): Promise<Contract4Migration> {
|
|
105
|
+
const target = options.target ?? 'local';
|
|
106
|
+
const names = await listProfiles(workspaceRoot);
|
|
107
|
+
const configs = new Map<string, { grants?: { connection?: unknown }[] }>();
|
|
108
|
+
|
|
109
|
+
for (const profile of names) {
|
|
110
|
+
const raw = await readProfile(workspaceRoot, profile);
|
|
111
|
+
if (raw !== null && (raw.contract ?? 0) === 3) configs.set(profile, raw);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
if (configs.size === 0) {
|
|
115
|
+
return {
|
|
116
|
+
workspaceRoot,
|
|
117
|
+
profiles: [],
|
|
118
|
+
changes: [],
|
|
119
|
+
shared: [],
|
|
120
|
+
orphaned: [],
|
|
121
|
+
alreadyCurrent: true,
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const profiles = [...configs.keys()];
|
|
126
|
+
const files = workspaceFiles(workspaceRoot);
|
|
127
|
+
|
|
128
|
+
// Every connection the workspace holds, and the id each ends up with. Built
|
|
129
|
+
// once and read by every rewrite below — the contract-3 mover's own bug was a
|
|
130
|
+
// map keyed one way and queried the other, which made the resolution a silent
|
|
131
|
+
// no-op and sent two profiles' blobs into one namespace.
|
|
132
|
+
await assertConnectionsSavable(workspaceRoot);
|
|
133
|
+
|
|
134
|
+
const rows = await readConnectionRows(workspaceRoot);
|
|
135
|
+
const renames = planRenames(rows);
|
|
136
|
+
|
|
137
|
+
const plan = await planMoves(files, grantingProfiles(configs), profiles, renames);
|
|
138
|
+
|
|
139
|
+
// Everything that can refuse, before the first byte moves. A `keep` move is
|
|
140
|
+
// exempt: two profiles granting one store are *meant* to write one source to
|
|
141
|
+
// two destinations, which is the shape this check exists to catch elsewhere.
|
|
142
|
+
assertOneObjectPerDestination(plan.moves.filter((move) => move.keep !== true));
|
|
143
|
+
|
|
144
|
+
const changes = [...describe(plan, profiles), ...repositoryNotes(configs, renames)];
|
|
145
|
+
if (!options.apply) {
|
|
146
|
+
return {
|
|
147
|
+
workspaceRoot,
|
|
148
|
+
profiles,
|
|
149
|
+
changes,
|
|
150
|
+
shared: plan.shared,
|
|
151
|
+
orphaned: plan.orphaned,
|
|
152
|
+
alreadyCurrent: false,
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
await renameRegistry(workspaceRoot);
|
|
157
|
+
|
|
158
|
+
// The registry's own stamp, which the byte-for-byte rename carries across
|
|
159
|
+
// unchanged. Here rather than inside `renameRegistry` because that function
|
|
160
|
+
// returns early on a workspace already holding `workspaces.yaml`, which is
|
|
161
|
+
// exactly the workspace whose stamp is stale.
|
|
162
|
+
await ensureRegistryContract(workspaceRoot);
|
|
163
|
+
await applyMoves(files, plan.moves);
|
|
164
|
+
|
|
165
|
+
// Credentials before the rows: a ref is derived from the id, so the rows must
|
|
166
|
+
// still name the old one for `planCredentialMoves` to compute the same pair
|
|
167
|
+
// on a rerun.
|
|
168
|
+
const credentials = await moveCredentials(workspaceRoot, target, rows, renames, configs);
|
|
169
|
+
|
|
170
|
+
// **The rows are renamed after the grants and before the stamp**, and every
|
|
171
|
+
// window that leaves is one a rerun closes. `connections.yaml` is the only
|
|
172
|
+
// source `planRenames` has, so renaming it before the grants destroyed the
|
|
173
|
+
// map mid-flight: the rerun read the new rows, computed `lan1 → lan1`, found
|
|
174
|
+
// no mapping for `memory.main`, and stamped a profile whose grants named a
|
|
175
|
+
// connection nothing declared — refused at load, and `needsContract4` false,
|
|
176
|
+
// so no migration would ever run again.
|
|
177
|
+
//
|
|
178
|
+
// Both rewrites are idempotent, which is what makes the order safe rather
|
|
179
|
+
// than merely better: a grant already naming the new ref is not in the map
|
|
180
|
+
// and is left alone, and so is a row.
|
|
181
|
+
await rewriteGrants(workspaceRoot, profiles, renames);
|
|
182
|
+
await renameConnections(workspaceRoot, renames);
|
|
183
|
+
|
|
184
|
+
// Last. The stamp is the record that the migration finished.
|
|
185
|
+
for (const profile of profiles) {
|
|
186
|
+
const document = await ConfigDocument.openKey(workspaceRoot, layout.profileConfig(profile));
|
|
187
|
+
document.setIn(['contract'], 4);
|
|
188
|
+
await document.save();
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
return {
|
|
192
|
+
workspaceRoot,
|
|
193
|
+
profiles,
|
|
194
|
+
changes: [...changes, ...credentials],
|
|
195
|
+
shared: plan.shared,
|
|
196
|
+
orphaned: plan.orphaned,
|
|
197
|
+
alreadyCurrent: false,
|
|
198
|
+
};
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* Move each stored credential to the ref its renamed connection now derives.
|
|
203
|
+
*
|
|
204
|
+
* **Throws rather than warns**, and that is the whole of its error handling. It
|
|
205
|
+
* warned once, and the rehearsal that found it showed why it must not: the
|
|
206
|
+
* warning was printed, `renameConnections` ran anyway, and the workspace came
|
|
207
|
+
* out with rows naming `gmail.con1` while the secret sat at
|
|
208
|
+
* `gmail/wjj_andrews`. A rerun cannot repair that — the rows are renamed, so
|
|
209
|
+
* the second run computes no rename for them and the old ref is orphaned with
|
|
210
|
+
* nothing left that knows what it belonged to.
|
|
211
|
+
*
|
|
212
|
+
* Failing here leaves the rows untouched, which is the state a rerun *can*
|
|
213
|
+
* finish from. Bytes that already moved are found in place and skipped.
|
|
214
|
+
*/
|
|
215
|
+
async function moveCredentials(
|
|
216
|
+
root: string,
|
|
217
|
+
target: string,
|
|
218
|
+
rows: readonly { id: string; provider: string }[],
|
|
219
|
+
renames: Renames,
|
|
220
|
+
profiles: ReadonlyMap<string, { grants?: { connection?: unknown }[] }>,
|
|
221
|
+
): Promise<string[]> {
|
|
222
|
+
if (rows.length === 0) return [];
|
|
223
|
+
|
|
224
|
+
const [store, registry] = await Promise.all([
|
|
225
|
+
openSecretStoreFor(root, target),
|
|
226
|
+
buildRegistryWithWorkspace(root),
|
|
227
|
+
]);
|
|
228
|
+
|
|
229
|
+
// `readConnectionRows`, not `readConnections`. The latter runs
|
|
230
|
+
// `assertNoRenamedProviders`, which refuses a `tasks` row — and a workspace
|
|
231
|
+
// holding one is exactly the workspace being migrated, so reading through the
|
|
232
|
+
// guard makes the refusal block its own fix. ADR-051's rule, met twice now: a
|
|
233
|
+
// refusal has to name a command, and the command has to be able to run.
|
|
234
|
+
// The vault's document is planned separately because `credentialRefFor`
|
|
235
|
+
// cannot name it — see `planVaultMoves`. One apply for both, so a source
|
|
236
|
+
// feeding several destinations is deleted once rather than per plan.
|
|
237
|
+
const declared = (await openTarget(root, target)).declared;
|
|
238
|
+
|
|
239
|
+
return await applyCredentialMoves(store, [
|
|
240
|
+
...planCredentialMoves(await readConnectionRows(root, true), registry, renames),
|
|
241
|
+
...(declared.vault?.adapter === 'secret'
|
|
242
|
+
? planVaultMoves(profiles, renames, declared.vault.ref)
|
|
243
|
+
: []),
|
|
244
|
+
]);
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
* `lanes-link.yaml` becomes `workspaces.yaml`.
|
|
249
|
+
*
|
|
250
|
+
* Written then deleted rather than moved, so an interruption leaves both and
|
|
251
|
+
* `readWorkspace` — which prefers the new name — still opens the workspace.
|
|
252
|
+
* Losing this file is losing the address of every target.
|
|
253
|
+
*/
|
|
254
|
+
async function renameRegistry(root: string): Promise<void> {
|
|
255
|
+
const files = workspaceFiles(root);
|
|
256
|
+
if (await files.has(WORKSPACE_FILE)) return;
|
|
257
|
+
|
|
258
|
+
const text = await readWorkspaceFile(files, C3.workspace);
|
|
259
|
+
if (text === null) return;
|
|
260
|
+
|
|
261
|
+
await writeWorkspaceFile(files, WORKSPACE_FILE, text);
|
|
262
|
+
if ((await readWorkspaceFile(files, WORKSPACE_FILE)) === null) {
|
|
263
|
+
throw new ConfigError(
|
|
264
|
+
`${WORKSPACE_FILE} did not read back after being written. Nothing has been deleted; ` +
|
|
265
|
+
'fix the store and run this again.',
|
|
266
|
+
);
|
|
267
|
+
}
|
|
268
|
+
await files.delete(C3.workspace);
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/**
|
|
272
|
+
* What a `knowledge:` repository needs done by hand, named rather than left.
|
|
273
|
+
*
|
|
274
|
+
* A profile keeping its memory and entities in GitHub addresses them by the
|
|
275
|
+
* connection id — an entry reaches the repository as `memory/<id>/<entry>.md`.
|
|
276
|
+
* Contract 4 renames the id, so the provider starts reading `memory/lan1/`
|
|
277
|
+
* while the repository still holds `memory/main/`: `memory_search` returns
|
|
278
|
+
* nothing and `entities_find` finds nobody, with the data sitting intact under
|
|
279
|
+
* the old name and nothing having failed.
|
|
280
|
+
*
|
|
281
|
+
* Not repaired here on purpose. Renaming directories in somebody's repository
|
|
282
|
+
* is a network write to a thing this migration does not own, and `applyMoves`
|
|
283
|
+
* cannot reach a GitHub store at all — so the honest move is to say exactly
|
|
284
|
+
* what to rename, which is one `git mv` per area. `removalPlan` warns about the
|
|
285
|
+
* same class for the same reason.
|
|
286
|
+
*/
|
|
287
|
+
function repositoryNotes(
|
|
288
|
+
configs: ReadonlyMap<string, { knowledge?: unknown; grants?: { connection?: unknown }[] }>,
|
|
289
|
+
renames: Renames,
|
|
290
|
+
): string[] {
|
|
291
|
+
const notes: string[] = [];
|
|
292
|
+
|
|
293
|
+
for (const [profile, config] of configs) {
|
|
294
|
+
const repo = (config.knowledge as { repo?: unknown } | undefined)?.repo;
|
|
295
|
+
if (typeof repo !== 'string') continue;
|
|
296
|
+
|
|
297
|
+
for (const surface of ['memory', 'entities'] as const) {
|
|
298
|
+
const granted = (config.grants ?? [])
|
|
299
|
+
.map((grant) => grant.connection)
|
|
300
|
+
.find((ref): ref is string => typeof ref === 'string' && ref.startsWith(`${surface}.`));
|
|
301
|
+
if (granted === undefined) continue;
|
|
302
|
+
|
|
303
|
+
const to = renames.get(granted);
|
|
304
|
+
if (to === undefined) continue;
|
|
305
|
+
|
|
306
|
+
const was = granted.slice(granted.indexOf('.') + 1);
|
|
307
|
+
const now = to.slice(to.indexOf('.') + 1);
|
|
308
|
+
if (was === now) continue;
|
|
309
|
+
|
|
310
|
+
notes.push(
|
|
311
|
+
`${repo}: rename ${surface}/${was}/ to ${surface}/${now}/ — "${profile}" reads it by the ` +
|
|
312
|
+
'connection id, and nothing here can write to your repository',
|
|
313
|
+
);
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
return notes;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
function describe(plan: DataPlan, profiles: readonly string[]): string[] {
|
|
321
|
+
const changes = [`${C3.workspace} → ${WORKSPACE_FILE}`];
|
|
322
|
+
|
|
323
|
+
for (const profile of profiles) {
|
|
324
|
+
changes.push(`${C3.profile(profile)} → ${layout.profileConfig(profile)}: contract 4`);
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
const moved = plan.moves.filter((move) => move.keep !== true).length;
|
|
328
|
+
if (moved > 0) changes.push(`${moved} object(s) moved out of data/`);
|
|
329
|
+
|
|
330
|
+
for (const { key, profiles: owners } of plan.shared) {
|
|
331
|
+
changes.push(`${key}: copied into ${owners.join(' and ')} — the original is left for you`);
|
|
332
|
+
}
|
|
333
|
+
for (const key of plan.orphaned) {
|
|
334
|
+
changes.push(`${key}: no profile grants it, so it stays where it is`);
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
// Said as what it is. Contract 3 merged these and deliberately did not delete
|
|
338
|
+
// them, so a workspace that came through it still holds a decryptable
|
|
339
|
+
// credential document per profile — and reporting that as an ungranted store
|
|
340
|
+
// reads as tidy-up rather than as a credential left on disk.
|
|
341
|
+
for (const key of plan.leftover) {
|
|
342
|
+
changes.push(
|
|
343
|
+
`${key}: a credential store contract 3 merged and left behind — its contents are in ` +
|
|
344
|
+
`${layout.credentials()} now, so delete it`,
|
|
345
|
+
);
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
return changes;
|
|
349
|
+
}
|
package/src/cli/identity.ts
CHANGED
|
@@ -88,10 +88,10 @@ export async function resolveAccount(
|
|
|
88
88
|
if (!primary || !identity.qualifier) return primary;
|
|
89
89
|
|
|
90
90
|
// `alice (Acme)` rather than `alice`. The bracketed half is what makes
|
|
91
|
-
// two workspaces two accounts instead of one overwritten twice
|
|
92
|
-
//
|
|
93
|
-
//
|
|
94
|
-
//
|
|
91
|
+
// two workspaces two accounts instead of one overwritten twice: the
|
|
92
|
+
// reconnect match below is on the account, so without it the second
|
|
93
|
+
// connect repairs the first row rather than adding one. It used to reach
|
|
94
|
+
// the id as well, back when the id was slugified from this.
|
|
95
95
|
const qualifier = pluck(body, identity.qualifier);
|
|
96
96
|
return qualifier ? `${primary} (${qualifier})` : primary;
|
|
97
97
|
}
|
|
@@ -116,29 +116,47 @@ export async function resolveAccount(
|
|
|
116
116
|
}
|
|
117
117
|
|
|
118
118
|
/**
|
|
119
|
-
*
|
|
119
|
+
* `lan` for a surface built into Lanes, `con` for somebody's account.
|
|
120
120
|
*
|
|
121
|
-
*
|
|
122
|
-
*
|
|
123
|
-
*
|
|
124
|
-
*
|
|
121
|
+
* The prefix is the only thing an id says, and it says the one thing that is
|
|
122
|
+
* true forever: whether there is a vendor behind this row. Everything else a
|
|
123
|
+
* reader wants — whose mailbox, what the operator calls it — is `account` and
|
|
124
|
+
* `label`, one field each, both changeable without moving a reference.
|
|
125
125
|
*/
|
|
126
|
-
export
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
126
|
+
export const OWNER_ID_PREFIX = 'lan';
|
|
127
|
+
export const ACCOUNT_ID_PREFIX = 'con';
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* The next free connection id.
|
|
131
|
+
*
|
|
132
|
+
* **Opaque, where this used to derive the id from the account.**
|
|
133
|
+
* `ada.lovelace@example.com` became `ada_lovelace`, on the reasoning that the
|
|
134
|
+
* local part tells accounts apart in practice. It does not: the same name at
|
|
135
|
+
* two domains produced `ada_lovelace` and `ada_lovelace2`, and an id that half
|
|
136
|
+
* describes its account is worse than one that does not, because it invites
|
|
137
|
+
* being trusted. What made that concrete is that the id is the whole of the
|
|
138
|
+
* `connection` enum a model chooses from.
|
|
139
|
+
*
|
|
140
|
+
* So the id is a key and nothing else. `account` carries the identity the
|
|
141
|
+
* provider reports and `label` the operator's own word, which means a `relabel`
|
|
142
|
+
* never moves a `credential_ref`, a blob path, or a grant.
|
|
143
|
+
*
|
|
144
|
+
* **A leading letter, not a bare number.** `id: 001` parses as the integer `1`
|
|
145
|
+
* in YAML, so `gmail.001` in a grant would match nothing and every id would need
|
|
146
|
+
* quoting forever. `con1` is a string unconditionally.
|
|
147
|
+
*
|
|
148
|
+
* Numbers are never reused: the highest taken plus one, so an id that appears
|
|
149
|
+
* in an audit log years later still means the row it meant then.
|
|
150
|
+
*/
|
|
151
|
+
export function nextConnectionId(taken: readonly string[], owner: boolean): string {
|
|
152
|
+
const prefix = owner ? OWNER_ID_PREFIX : ACCOUNT_ID_PREFIX;
|
|
153
|
+
const pattern = new RegExp(`^${prefix}([0-9]+)$`);
|
|
154
|
+
|
|
155
|
+
let highest = 0;
|
|
156
|
+
for (const id of taken) {
|
|
157
|
+
const match = pattern.exec(id);
|
|
158
|
+
if (match) highest = Math.max(highest, Number(match[1]));
|
|
143
159
|
}
|
|
160
|
+
|
|
161
|
+
return `${prefix}${highest + 1}`;
|
|
144
162
|
}
|
package/src/cli/main.ts
CHANGED
|
@@ -202,7 +202,8 @@ export async function run(argv: readonly string[]): Promise<void> {
|
|
|
202
202
|
case 'remove':
|
|
203
203
|
if (!rest[0]) {
|
|
204
204
|
throw new Error(
|
|
205
|
-
`Usage: ${PROGRAM} profile remove <name> [--workspace <name>] [--dry-run] [--yes]
|
|
205
|
+
`Usage: ${PROGRAM} profile remove <name> [--workspace <name>] [--dry-run] [--yes] ` +
|
|
206
|
+
'[--delete-data | --migrate-to <profile>]',
|
|
206
207
|
);
|
|
207
208
|
}
|
|
208
209
|
return profileRemove(rest[0], {
|
|
@@ -210,6 +211,10 @@ export async function run(argv: readonly string[]): Promise<void> {
|
|
|
210
211
|
json,
|
|
211
212
|
dryRun: flags['dry-run'] === true,
|
|
212
213
|
yes: flags['yes'] === true,
|
|
214
|
+
deleteData: flags['delete-data'] === true,
|
|
215
|
+
...(typeof flags['migrate-to'] === 'string'
|
|
216
|
+
? { migrateTo: flags['migrate-to'] }
|
|
217
|
+
: {}),
|
|
213
218
|
});
|
|
214
219
|
default:
|
|
215
220
|
throw new Error(`Unknown: ${PROGRAM} profile ${second}`);
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
import { ConfigError } from '#profile';
|
|
2
|
+
import type { BlobStore } from '#stores/blobs';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Moving objects between two layouts, without losing one.
|
|
6
|
+
*
|
|
7
|
+
* Shared by every contract migration that relocates bytes rather than YAML —
|
|
8
|
+
* contract 3 hoisting a profile's data to the workspace, contract 4 carrying it
|
|
9
|
+
* back into the profile. Each learned the same rules the same hard way, so they
|
|
10
|
+
* live once: nothing is deleted until what replaced it has been read back, every
|
|
11
|
+
* destination is checked before the first byte moves, and a rerun after an
|
|
12
|
+
* interruption finishes rather than refusing.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
export interface Move {
|
|
16
|
+
readonly from: string;
|
|
17
|
+
readonly to: string;
|
|
18
|
+
/**
|
|
19
|
+
* Rewrite the object's bytes on the way across.
|
|
20
|
+
*
|
|
21
|
+
* Only a connection record needs this, and it needs it for a reason a plain
|
|
22
|
+
* copy cannot serve: `ConnectionRepository.list` reads `provider` and `id`
|
|
23
|
+
* out of the record *body*, not out of the key it was stored under. A renamed
|
|
24
|
+
* connection whose bytes were copied verbatim would sit at
|
|
25
|
+
* `connections.v1/vault.work` still calling itself `vault.main`, and the next
|
|
26
|
+
* `upsert` would write a second record beside it.
|
|
27
|
+
*/
|
|
28
|
+
readonly rewrite?: (data: Uint8Array) => Uint8Array;
|
|
29
|
+
/**
|
|
30
|
+
* Write only where the destination is empty; leave the source alone if not.
|
|
31
|
+
*
|
|
32
|
+
* For the objects two profiles can legitimately both hold — a connection they
|
|
33
|
+
* share, a provider-keyed cache, one custom manifest — where a second copy is
|
|
34
|
+
* a duplicate rather than a clash. `claim` drops the loser within one run, but
|
|
35
|
+
* deletes the winner's source and leaves the loser's: the rerun then saw a
|
|
36
|
+
* different first claimant, found foreign bytes at the destination, and threw,
|
|
37
|
+
* permanently. `rewriteProfiles` stamps the contract *after* the moves, so an
|
|
38
|
+
* interruption during them forces exactly that rerun.
|
|
39
|
+
*/
|
|
40
|
+
readonly whenAbsent?: boolean;
|
|
41
|
+
/**
|
|
42
|
+
* Copy, and leave the source where it is.
|
|
43
|
+
*
|
|
44
|
+
* For the one thing contract 4 cannot decide: a store two profiles both grant
|
|
45
|
+
* has to land in both, and neither copy may delete what the other still needs
|
|
46
|
+
* to read. The original is reported rather than removed — merging two sets of
|
|
47
|
+
* notes is not reversible, and picking one profile's would take the other's
|
|
48
|
+
* away silently.
|
|
49
|
+
*/
|
|
50
|
+
readonly keep?: boolean;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Two objects aimed at one key, caught while this is still a plan.
|
|
55
|
+
*
|
|
56
|
+
* `applyMoves` checks the destination per object as well, but that check cannot
|
|
57
|
+
* see a collision between two objects *in this run* once the moves are applied
|
|
58
|
+
* concurrently — both would look at an absent destination and both would write.
|
|
59
|
+
* Hoisting it here also puts it where this file says it belongs: everything that
|
|
60
|
+
* can fail happens before the first byte moves, so a refusal leaves the
|
|
61
|
+
* workspace exactly as it was.
|
|
62
|
+
*/
|
|
63
|
+
export function assertOneObjectPerDestination(moves: readonly Move[]): void {
|
|
64
|
+
const seen = new Map<string, string>();
|
|
65
|
+
|
|
66
|
+
for (const move of moves) {
|
|
67
|
+
const first = seen.get(move.to);
|
|
68
|
+
if (first !== undefined) {
|
|
69
|
+
throw new ConfigError(
|
|
70
|
+
`Two objects want to be at ${move.to}, and this migration cannot merge them.\n` +
|
|
71
|
+
` ${first} and ${move.from}. Nothing has been written.\n` +
|
|
72
|
+
' Which one to keep is yours to decide, because both are your data: move or\n' +
|
|
73
|
+
' delete one of the two, then run this again.',
|
|
74
|
+
);
|
|
75
|
+
}
|
|
76
|
+
seen.set(move.to, move.from);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** First claim on a destination wins; a later one is left where it is. */
|
|
81
|
+
export function claim(claimed: Set<string>, move: Move): Move | null {
|
|
82
|
+
if (claimed.has(move.to)) return null;
|
|
83
|
+
claimed.add(move.to);
|
|
84
|
+
return { ...move, whenAbsent: true };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* How many objects are in flight at once.
|
|
89
|
+
*
|
|
90
|
+
* Serial was fine while this only ever ran against a local disk. A deployed
|
|
91
|
+
* workspace's audit log is one object per event, so the first real bucket this
|
|
92
|
+
* migrated held 1,906 of them — three round trips each, in series, is minutes of
|
|
93
|
+
* a deploy spent with nothing on screen. The same 16 the read paths in
|
|
94
|
+
* `#providers/memory` and `#providers/tasks` settled on, and for the same
|
|
95
|
+
* reason: enough to hide the latency, not enough to look like an incident to the
|
|
96
|
+
* other end.
|
|
97
|
+
*
|
|
98
|
+
* Safe to widen only while each move stays independent, which is what
|
|
99
|
+
* `assertOneObjectPerDestination` guarantees.
|
|
100
|
+
*/
|
|
101
|
+
const MOVE_CONCURRENCY = 16;
|
|
102
|
+
|
|
103
|
+
export async function applyMoves(files: BlobStore, moves: readonly Move[]): Promise<void> {
|
|
104
|
+
const pending = moves.filter((move) => move.from !== move.to);
|
|
105
|
+
|
|
106
|
+
for (let start = 0; start < pending.length; start += MOVE_CONCURRENCY) {
|
|
107
|
+
await Promise.all(
|
|
108
|
+
pending.slice(start, start + MOVE_CONCURRENCY).map((move) => applyMove(files, move)),
|
|
109
|
+
);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** One object, moved or finished. Never deletes before the copy reads back. */
|
|
114
|
+
async function applyMove(files: BlobStore, move: Move): Promise<void> {
|
|
115
|
+
const source = await files.get(move.from);
|
|
116
|
+
if (source === null) return;
|
|
117
|
+
|
|
118
|
+
// Before the comparison below as well as before the write. Comparing the
|
|
119
|
+
// *source* bytes against a destination that holds the rewritten ones would
|
|
120
|
+
// read an already-finished move as a collision, and refuse the one rerun this
|
|
121
|
+
// file promises.
|
|
122
|
+
const data = move.rewrite === undefined ? source : move.rewrite(source);
|
|
123
|
+
|
|
124
|
+
if (await files.has(move.to)) {
|
|
125
|
+
const held = await files.get(move.to);
|
|
126
|
+
|
|
127
|
+
// Raced away between the two calls, so there is nothing there after all and
|
|
128
|
+
// the ordinary path below is still the right one.
|
|
129
|
+
if (held !== null) {
|
|
130
|
+
// Another instance of the same connection, or another profile's copy of
|
|
131
|
+
// one shared cache, got there first. Left where it is rather than
|
|
132
|
+
// refused — and rerunnable, which is the whole point.
|
|
133
|
+
if (!sameBytes(held, data)) {
|
|
134
|
+
if (move.whenAbsent === true) return;
|
|
135
|
+
throw new ConfigError(
|
|
136
|
+
`Two objects want to be at ${move.to}, and this migration cannot merge them.\n` +
|
|
137
|
+
` ${move.from} is the second, and what is already there is not a copy of it.\n` +
|
|
138
|
+
' Nothing has been deleted. Please report it with the layout of your data ' +
|
|
139
|
+
'directory.',
|
|
140
|
+
);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// Already copied, by a run that did not get to the delete.
|
|
144
|
+
if (move.keep !== true) await files.delete(move.from);
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
await files.put(move.to, data);
|
|
150
|
+
if ((await files.get(move.to)) === null) {
|
|
151
|
+
throw new ConfigError(
|
|
152
|
+
`${move.to} did not read back after being written. Nothing has been deleted; ` +
|
|
153
|
+
`fix the store and run this again.`,
|
|
154
|
+
);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
if (move.keep !== true) await files.delete(move.from);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function sameBytes(left: Uint8Array, right: Uint8Array): boolean {
|
|
161
|
+
if (left.length !== right.length) return false;
|
|
162
|
+
for (let index = 0; index < left.length; index += 1) {
|
|
163
|
+
if (left[index] !== right[index]) return false;
|
|
164
|
+
}
|
|
165
|
+
return true;
|
|
166
|
+
}
|
package/src/cli/migrate-plan.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import {
|
|
2
|
-
|
|
2
|
+
LEGACY_DATA_DIR, ConfigError, layout, isRemoteWorkspace, type LegacyTarget, type WorkspaceTarget } from '#profile';
|
|
3
3
|
import { deployedWorkspace } from '#deployments/upload.ts';
|
|
4
4
|
|
|
5
5
|
/**
|
|
@@ -111,8 +111,8 @@ export function toEntry(
|
|
|
111
111
|
// and `layout` describes where things live *now* — workspace-level since
|
|
112
112
|
// ADR-057. Asking it would compare a contract-1 path against a contract-3
|
|
113
113
|
// default and refuse every profile that had written the ordinary one.
|
|
114
|
-
const defaultStorage = `./${
|
|
115
|
-
const defaultCredentials = `./${
|
|
114
|
+
const defaultStorage = `./${LEGACY_DATA_DIR}/${profile}`;
|
|
115
|
+
const defaultCredentials = `./${LEGACY_DATA_DIR}/${profile}/credentials.enc`;
|
|
116
116
|
|
|
117
117
|
refuseCustomPath(name, profile, workspaceRoot, 'storage.path', storagePath, defaultStorage);
|
|
118
118
|
refuseCustomPath(
|
package/src/cli/publish.ts
CHANGED
|
@@ -102,11 +102,7 @@ export async function publishProfileEdit(input: {
|
|
|
102
102
|
/** Every profile the edit touched, where it reached more than the one named. */
|
|
103
103
|
readonly touched?: readonly string[] | undefined;
|
|
104
104
|
}): Promise<PublishOutcome> {
|
|
105
|
-
const credentials = await openSecretStoreFor(
|
|
106
|
-
input.config,
|
|
107
|
-
input.resolution.workspaceRoot,
|
|
108
|
-
input.target,
|
|
109
|
-
);
|
|
105
|
+
const credentials = await openSecretStoreFor(input.resolution.workspaceRoot, input.target);
|
|
110
106
|
|
|
111
107
|
return publishAndNotify({
|
|
112
108
|
config: input.config,
|