@lanes-sh/link 0.8.0 → 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/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/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 +119 -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 +342 -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,54 @@
|
|
|
1
|
+
import type { Contract3Migration } from '../contract3.ts';
|
|
2
|
+
import type { Contract4Migration } from '../contract4.ts';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* What each contract migration did, as `update` reports it.
|
|
6
|
+
*
|
|
7
|
+
* Split from `update.ts` so that file stays inside the size budget, and on the
|
|
8
|
+
* seam it already had: that file decides *whether* to migrate and this says
|
|
9
|
+
* *what happened*. Kept beside it rather than beside the migrations, because
|
|
10
|
+
* `doctor --fix` renders the same facts its own way and the two are allowed to
|
|
11
|
+
* read differently — what they may not do is disagree about which of them is
|
|
12
|
+
* worth mentioning.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* What contract 4 did, and the one part of it that is the operator's.
|
|
17
|
+
*
|
|
18
|
+
* `update` reported nothing, so the shared-store duplication happened in
|
|
19
|
+
* silence — a decision the migration calls the owner's, and which `doctor --fix`
|
|
20
|
+
* prints. Two paths disagreeing on that is how one becomes the wrong one to run.
|
|
21
|
+
*/
|
|
22
|
+
export function sayContract4(migration: Contract4Migration, say: (line: string) => void): void {
|
|
23
|
+
say(
|
|
24
|
+
`migrated ${migration.profiles.length} profile(s) to contract 4 — a profile owns its data ` +
|
|
25
|
+
'again, and Lanes\u2019 own surfaces are lanes_memory, lanes_tasks and the rest',
|
|
26
|
+
);
|
|
27
|
+
for (const change of migration.changes) say(` ${change}`);
|
|
28
|
+
|
|
29
|
+
if (migration.shared.length > 0) {
|
|
30
|
+
say(' A store more than one profile granted was copied into each, and the original kept.');
|
|
31
|
+
say(' Deleting what you do not want is the one step here that is yours.');
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
if (migration.orphaned.length > 0) {
|
|
35
|
+
say(' What no profile grants was left exactly where it is, and named above.');
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
say(' Every registered client caches its tool list, so re-add them: lanes link mcp add');
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function sayContract3(migration: Contract3Migration, say: (line: string) => void): void {
|
|
42
|
+
say(
|
|
43
|
+
`migrated ${migration.profiles.length} profile(s) to contract 3 — connections belong to the ` +
|
|
44
|
+
'workspace now, and a profile grants them one by one',
|
|
45
|
+
);
|
|
46
|
+
for (const change of migration.changes) say(` ${change}`);
|
|
47
|
+
|
|
48
|
+
if (migration.renames.length > 0) {
|
|
49
|
+
say(' Two profiles named different accounts with the same id, so one was renamed.');
|
|
50
|
+
say(' Check the grants in each profile before running an agent against them.');
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
say(` The old per-profile credential stores are left in place; remove them once this works.`);
|
|
54
|
+
}
|
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
import { homedir } from 'node:os';
|
|
2
2
|
import { join, sep } from 'node:path';
|
|
3
3
|
import { installRoot, resolveWorkspaceRoot } from '#profile';
|
|
4
|
-
import { repairOwnerLayer } from '../config-repair.ts';
|
|
5
|
-
import { migrateToCurrentContract, type ContractMigration } from '../workspace-migrate.ts';
|
|
6
|
-
import type
|
|
4
|
+
import { repairOwnerLayer } from '../config-repair-sweep.ts';
|
|
5
|
+
import { migrateToCurrentContract, needsMigration, type ContractMigration } from '../workspace-migrate.ts';
|
|
6
|
+
import { needsContract3, type Contract3Migration } from '../contract3.ts';
|
|
7
|
+
import { needsContract4 } from '../contract4.ts';
|
|
8
|
+
import { sayContract3, sayContract4 } from './update-migration.ts';
|
|
7
9
|
import { emit, fail, ok, print, printErr, progress, style, warn } from '../output.ts';
|
|
8
10
|
import { PACKAGE, release, type ReleaseState } from '../release.ts';
|
|
9
11
|
import { version } from '../version.ts';
|
|
@@ -184,11 +186,17 @@ export async function update(flags: UpdateFlags): Promise<void> {
|
|
|
184
186
|
// a contract it does not implement until someone redeploys. `deploy` is what
|
|
185
187
|
// migrates a bucket, because it is the command that ships the image in the
|
|
186
188
|
// same breath.
|
|
187
|
-
await migrateLocal(root, flags.json === true ? progress : print);
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
189
|
+
const migrated = await migrateLocal(root, flags.json === true ? progress : print);
|
|
190
|
+
|
|
191
|
+
// Only on a workspace that is actually at the current contract — see
|
|
192
|
+
// `migrateLocal`. The repair writes contract-3 shapes, and writing them
|
|
193
|
+
// after a refusal that said nothing had been written is what left a stray
|
|
194
|
+
// `connections.yaml` in a contract-2 workspace.
|
|
195
|
+
if (migrated) {
|
|
196
|
+
await repairOwnerLayer(root, undefined, {
|
|
197
|
+
...(flags.json === true ? { report: progress } : {}),
|
|
198
|
+
});
|
|
199
|
+
}
|
|
192
200
|
}
|
|
193
201
|
|
|
194
202
|
const report = {
|
|
@@ -315,22 +323,8 @@ async function runInstall(argv: readonly string[], json: boolean): Promise<boole
|
|
|
315
323
|
* fifteen accounts were merged into one store should see that happen rather than
|
|
316
324
|
* discover it from a directory listing.
|
|
317
325
|
*/
|
|
318
|
-
function sayContract3(migration: Contract3Migration, say: (line: string) => void): void {
|
|
319
|
-
say(
|
|
320
|
-
`migrated ${migration.profiles.length} profile(s) to contract 3 — connections belong to the ` +
|
|
321
|
-
'workspace now, and a profile grants them one by one',
|
|
322
|
-
);
|
|
323
|
-
for (const change of migration.changes) say(` ${change}`);
|
|
324
|
-
|
|
325
|
-
if (migration.renames.length > 0) {
|
|
326
|
-
say(' Two profiles named different accounts with the same id, so one was renamed.');
|
|
327
|
-
say(' Check the grants in each profile before running an agent against them.');
|
|
328
|
-
}
|
|
329
|
-
|
|
330
|
-
say(` The old per-profile credential stores are left in place; remove them once this works.`);
|
|
331
|
-
}
|
|
332
326
|
|
|
333
|
-
async function migrateLocal(root: string, say: (line: string) => void): Promise<
|
|
327
|
+
async function migrateLocal(root: string, say: (line: string) => void): Promise<boolean> {
|
|
334
328
|
try {
|
|
335
329
|
// One call, both steps, in order — a workspace that predates both walks
|
|
336
330
|
// through them rather than jumping, so each step's reasoning applies to the
|
|
@@ -347,11 +341,30 @@ async function migrateLocal(root: string, say: (line: string) => void): Promise<
|
|
|
347
341
|
|
|
348
342
|
if (migration.legacy) sayLegacyTargets(migration.legacy, say);
|
|
349
343
|
if (migration.contract3) sayContract3(migration.contract3, say);
|
|
344
|
+
if (migration.contract4) sayContract4(migration.contract4, say);
|
|
345
|
+
return true;
|
|
350
346
|
} catch (error) {
|
|
351
347
|
say(`could not migrate this workspace: ${error instanceof Error ? error.message : String(error)}`);
|
|
348
|
+
|
|
349
|
+
// **Still behind, not "something threw".** Keying the repair on the throw
|
|
350
|
+
// suppressed it for the whole workspace when one profile failed to
|
|
351
|
+
// migrate — so the others, already current, never received a newly shipped
|
|
352
|
+
// surface, which is the case `update` runs the repair for at all. One
|
|
353
|
+
// unreadable profile is a per-profile warning, which the repair already
|
|
354
|
+
// gives it.
|
|
355
|
+
const behind = await stillBehind(root);
|
|
356
|
+
if (behind) say(' the owner-layer repair is skipped until it does');
|
|
357
|
+
return !behind;
|
|
352
358
|
}
|
|
353
359
|
}
|
|
354
360
|
|
|
361
|
+
/** Anything still below the current contract. Unreadable counts as behind. */
|
|
362
|
+
function stillBehind(root: string): Promise<boolean> {
|
|
363
|
+
return needsMigration(root)
|
|
364
|
+
.then(async (one) => one || (await needsContract3(root)) || (await needsContract4(root)))
|
|
365
|
+
.catch(() => true);
|
|
366
|
+
}
|
|
367
|
+
|
|
355
368
|
/** Contract 1 to contract 2: targets move from the profile to the workspace. */
|
|
356
369
|
function sayLegacyTargets(
|
|
357
370
|
migration: NonNullable<ContractMigration['legacy']>,
|
package/src/cli/config-edit.ts
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
import { rename, writeFile } from 'node:fs/promises';
|
|
2
|
-
import { Document, parseDocument, type Node } from 'yaml';
|
|
2
|
+
import { Document, parseDocument, YAMLSeq, type Node } from 'yaml';
|
|
3
3
|
import {
|
|
4
4
|
CONNECTIONS_FILE,
|
|
5
5
|
ConfigError,
|
|
6
6
|
WORKSPACE_FILE,
|
|
7
|
+
LEGACY_WORKSPACE_FILE,
|
|
7
8
|
workspaceSchema,
|
|
8
9
|
assertConnectionsUnique,
|
|
9
10
|
connectionsFileSchema,
|
|
@@ -15,6 +16,7 @@ import {
|
|
|
15
16
|
validateConfigShape,
|
|
16
17
|
workspaceFiles,
|
|
17
18
|
writeWorkspaceFile,
|
|
19
|
+
layout,
|
|
18
20
|
} from '#profile';
|
|
19
21
|
|
|
20
22
|
/**
|
|
@@ -42,9 +44,13 @@ function validateDocument(
|
|
|
42
44
|
raw: unknown,
|
|
43
45
|
path: string,
|
|
44
46
|
key: string | undefined,
|
|
45
|
-
options: { shapeOnly?: boolean },
|
|
47
|
+
options: { shapeOnly?: boolean; contract?: number },
|
|
46
48
|
): void {
|
|
47
|
-
|
|
49
|
+
// Either name. The contract-3 migration rewrites the registry under the name
|
|
50
|
+
// it still has, and a document checked against the wrong schema fails with
|
|
51
|
+
// "instance: expected object" — which reads as a corrupt profile rather than
|
|
52
|
+
// as a registry being validated as one.
|
|
53
|
+
if (key === WORKSPACE_FILE || key === LEGACY_WORKSPACE_FILE) {
|
|
48
54
|
const parsed = workspaceSchema.safeParse(raw);
|
|
49
55
|
if (!parsed.success) {
|
|
50
56
|
throw new ConfigError(
|
|
@@ -74,8 +80,10 @@ function validateDocument(
|
|
|
74
80
|
return;
|
|
75
81
|
}
|
|
76
82
|
|
|
77
|
-
|
|
78
|
-
|
|
83
|
+
// `contract` is the one a migration is *producing*, and it is only ever
|
|
84
|
+
// passed by one: every other caller writes the newest.
|
|
85
|
+
if (options.shapeOnly === true) validateConfigShape(raw, path, options.contract);
|
|
86
|
+
else validateConfig(raw, path, options.contract);
|
|
79
87
|
}
|
|
80
88
|
|
|
81
89
|
export class ConfigDocument {
|
|
@@ -101,7 +109,7 @@ export class ConfigDocument {
|
|
|
101
109
|
* `gs://` URL produces something that addresses nothing.
|
|
102
110
|
*/
|
|
103
111
|
static async open(workspaceRoot: string, profile: string): Promise<ConfigDocument> {
|
|
104
|
-
return ConfigDocument.openKey(workspaceRoot,
|
|
112
|
+
return ConfigDocument.openKey(workspaceRoot, layout.profileConfig(profile));
|
|
105
113
|
}
|
|
106
114
|
|
|
107
115
|
/**
|
|
@@ -199,7 +207,20 @@ export class ConfigDocument {
|
|
|
199
207
|
// difference decided whether a command explained itself or crashed.
|
|
200
208
|
const existing = this.#document.getIn(path as (string | number)[]);
|
|
201
209
|
if (existing === undefined || existing === null) {
|
|
202
|
-
|
|
210
|
+
// A `YAMLSeq` rather than the plain `[node]` this used to set. The array
|
|
211
|
+
// is not a collection the document API will traverse — the same hazard
|
|
212
|
+
// `setIn` above documents — so the *first* append landed and the second
|
|
213
|
+
// found a value with no `.add` and threw `existing.add is not a
|
|
214
|
+
// function`. `#expand` below reads `.items` and was silently a no-op for
|
|
215
|
+
// the same reason, leaving the sequence in flow style.
|
|
216
|
+
//
|
|
217
|
+
// Latent until contract 3: every path this was called with
|
|
218
|
+
// (`connections`, `policy.allow`) already existed, so the branch ran at
|
|
219
|
+
// most once per document. `grants:` is genuinely absent on a profile
|
|
220
|
+
// being repaired, which is what made the second append reachable.
|
|
221
|
+
const created = new YAMLSeq();
|
|
222
|
+
created.add(node);
|
|
223
|
+
this.#document.setIn(path as (string | number)[], created);
|
|
203
224
|
} else {
|
|
204
225
|
(existing as { add(item: unknown): void }).add(node);
|
|
205
226
|
}
|
|
@@ -262,7 +283,7 @@ export class ConfigDocument {
|
|
|
262
283
|
* The half it keeps is the half that matters for a write: the schema, and the
|
|
263
284
|
* scan that stops a credential value being written into config.
|
|
264
285
|
*/
|
|
265
|
-
async save(options: { shapeOnly?: boolean } = {}): Promise<void> {
|
|
286
|
+
async save(options: { shapeOnly?: boolean; contract?: number } = {}): Promise<void> {
|
|
266
287
|
const rendered = this.toString();
|
|
267
288
|
|
|
268
289
|
// Throws on any validation failure, including a credential value that has
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import { CONNECTIONS_FILE, listProfiles, workspaceFiles, writeWorkspaceFile } from '#profile';
|
|
2
|
+
import { newConnectionsTemplate } from './config-templates.ts';
|
|
3
|
+
import { ConfigDocument } from './config-edit.ts';
|
|
4
|
+
import { ok, print, style, warn } from './output.ts';
|
|
5
|
+
import { DEFAULT_SURFACES, ensureOwnerLayer, repairLines, repaired } from './config-repair.ts';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Applying the owner-layer repair across a whole workspace.
|
|
9
|
+
*
|
|
10
|
+
* Split from `config-repair.ts` when that file outgrew the budget, on the seam
|
|
11
|
+
* it already had: that file decides what one profile is missing, and this one
|
|
12
|
+
* walks the workspace applying it. The decision is a pure function of two
|
|
13
|
+
* documents and is tested as one; the sweep is all filesystem, ordering and
|
|
14
|
+
* what to say when a profile will not open.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
/** `memory, tasks, assets, skills, vault, setup and entities`, in repair order. */
|
|
18
|
+
function listSurfaces(): string {
|
|
19
|
+
const names = [...DEFAULT_SURFACES];
|
|
20
|
+
const last = names.pop();
|
|
21
|
+
return names.length === 0 ? String(last) : `${names.join(', ')} and ${last}`;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** The workspace's connections document, written from the template if missing. */
|
|
25
|
+
async function openOrCreateConnections(workspaceRoot: string): Promise<ConfigDocument> {
|
|
26
|
+
try {
|
|
27
|
+
return await ConfigDocument.openKey(workspaceRoot, CONNECTIONS_FILE);
|
|
28
|
+
} catch {
|
|
29
|
+
await writeWorkspaceFile(
|
|
30
|
+
workspaceFiles(workspaceRoot),
|
|
31
|
+
CONNECTIONS_FILE,
|
|
32
|
+
newConnectionsTemplate(),
|
|
33
|
+
);
|
|
34
|
+
return ConfigDocument.openKey(workspaceRoot, CONNECTIONS_FILE);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Apply that repair across a workspace, saving and reporting what changed.
|
|
40
|
+
*
|
|
41
|
+
* Here rather than in `#deployments`, where it was, because `start` needs it as
|
|
42
|
+
* much as `deploy` does — more, in fact: `start` is the one command an existing
|
|
43
|
+
* install runs without being asked to, so it is the path by which a profile
|
|
44
|
+
* written before ADR-050 gets the layer at all. Two copies of a function that
|
|
45
|
+
* widens a policy is not a thing to have.
|
|
46
|
+
*
|
|
47
|
+
* **The caller scopes it**, and for `deploy` that is exactly the set being
|
|
48
|
+
* uploaded: a profile it sends is a profile the endpoint will serve, so
|
|
49
|
+
* repairing a narrower set would leave a served profile without the surfaces.
|
|
50
|
+
* Note what a `--profile` flag does not mean — it is the flag alone, so a
|
|
51
|
+
* profile resolved from the environment leaves it undefined and that reads as
|
|
52
|
+
* the whole workspace.
|
|
53
|
+
*
|
|
54
|
+
* *Which files are profiles* comes from `listProfiles`, never from an allowlist
|
|
55
|
+
* of what is safe to copy: that would happily hand over a committed
|
|
56
|
+
* `personal.example.yaml` or a nested `profiles/archive/old.yaml`, and this
|
|
57
|
+
* opens and validates what it is given — which once turned a template into a
|
|
58
|
+
* `ConfigError` aborting a deploy after provisioning had made cloud resources.
|
|
59
|
+
*
|
|
60
|
+
* A profile that cannot be read is warned about rather than fatal: the repair is
|
|
61
|
+
* a courtesy on the way past, and the caller's real work should still happen.
|
|
62
|
+
* Not silent, though — nothing else widens a policy without being asked.
|
|
63
|
+
*
|
|
64
|
+
* CLI-side by construction, like everything else in this file: a deployed
|
|
65
|
+
* revision holds `objectViewer` on `profiles/` (ADR-023) and could not write
|
|
66
|
+
* this even if the code let it.
|
|
67
|
+
*/
|
|
68
|
+
export async function repairOwnerLayer(
|
|
69
|
+
workspaceRoot: string,
|
|
70
|
+
profiles: readonly string[] | undefined,
|
|
71
|
+
options: { report?: (line: string) => void } = {},
|
|
72
|
+
): Promise<void> {
|
|
73
|
+
// stdout by default, because every caller but one is printing a report a
|
|
74
|
+
// person reads. `update --json` passes `progress` instead: what it produces is
|
|
75
|
+
// a document, and a line of prose in front of it corrupts whatever is parsing.
|
|
76
|
+
// Routed rather than silenced — nothing else here widens a policy without
|
|
77
|
+
// saying so, and this must not be the exception.
|
|
78
|
+
const say = options.report ?? print;
|
|
79
|
+
const wanted = profiles === undefined ? undefined : new Set(profiles);
|
|
80
|
+
|
|
81
|
+
// One connections document for the whole sweep. The owner layer is the
|
|
82
|
+
// workspace's now (ADR-059), so repairing three profiles must not add three
|
|
83
|
+
// `memory.main` rows — opening it once and saving it once is what makes the
|
|
84
|
+
// second profile see what the first one created.
|
|
85
|
+
// Created if absent rather than refused. A contract-3 workspace always has
|
|
86
|
+
// one, but a hand-made or half-migrated one may not — and this is the repair,
|
|
87
|
+
// so the file it needs is a thing to write rather than a reason to stop.
|
|
88
|
+
const connections = await openOrCreateConnections(workspaceRoot);
|
|
89
|
+
let connectionsChanged = false;
|
|
90
|
+
|
|
91
|
+
for (const name of await listProfiles(workspaceRoot)) {
|
|
92
|
+
if (wanted !== undefined && !wanted.has(name)) continue;
|
|
93
|
+
|
|
94
|
+
try {
|
|
95
|
+
const document = await ConfigDocument.open(workspaceRoot, name);
|
|
96
|
+
const repair = ensureOwnerLayer(connections, document);
|
|
97
|
+
if (!repaired(repair)) continue;
|
|
98
|
+
|
|
99
|
+
connectionsChanged = true;
|
|
100
|
+
await document.save();
|
|
101
|
+
|
|
102
|
+
say(ok(`gave ${style.bold(name)} its own owner layer`));
|
|
103
|
+
for (const change of repairLines(repair)) say(` ${style.dim(change)}`);
|
|
104
|
+
// Built from `DEFAULT_SURFACES` rather than typed out. The typed-out
|
|
105
|
+
// version still named six after a seventh had been added, so a person
|
|
106
|
+
// watching a deploy was told entities had arrived on the line above and
|
|
107
|
+
// that the layer was six things on the line below.
|
|
108
|
+
say(` ${style.dim(`${listSurfaces()} — your own material, no account behind any of them`)}`);
|
|
109
|
+
} catch (error) {
|
|
110
|
+
say(
|
|
111
|
+
warn(
|
|
112
|
+
`could not give ${name} its owner layer: ${error instanceof Error ? error.message.split('\n')[0] : String(error)}`,
|
|
113
|
+
),
|
|
114
|
+
);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
if (connectionsChanged) await connections.save();
|
|
119
|
+
}
|
package/src/cli/config-repair.ts
CHANGED
|
@@ -1,7 +1,5 @@
|
|
|
1
|
-
import { newConnectionsTemplate } from './config-templates.ts';
|
|
2
|
-
import { CONNECTIONS_FILE, listProfiles, workspaceFiles, writeWorkspaceFile } from '#profile';
|
|
3
1
|
import { ConfigDocument } from './config-edit.ts';
|
|
4
|
-
import {
|
|
2
|
+
import { nextConnectionId } from './identity.ts';
|
|
5
3
|
|
|
6
4
|
/**
|
|
7
5
|
* Giving a profile a reserved provider it is missing, without undoing a choice.
|
|
@@ -19,16 +17,16 @@ import { ok, print, style, warn } from './output.ts';
|
|
|
19
17
|
* than several that look alike.
|
|
20
18
|
*/
|
|
21
19
|
|
|
22
|
-
/**
|
|
20
|
+
/** Lanes' own provider ids, and the label each row carries. */
|
|
23
21
|
const RESERVED_SURFACES = {
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
22
|
+
lanes_memory: 'Memory',
|
|
23
|
+
lanes_tasks: 'Tasks',
|
|
24
|
+
lanes_assets: 'Assets',
|
|
25
|
+
lanes_skills: 'Skills',
|
|
26
|
+
lanes_vault: 'Vault',
|
|
27
|
+
lanes_setup: 'Setup',
|
|
28
|
+
lanes_identity: 'Identity',
|
|
29
|
+
lanes_entities: 'Entities',
|
|
32
30
|
} as const;
|
|
33
31
|
|
|
34
32
|
type ReservedSurface = keyof typeof RESERVED_SURFACES;
|
|
@@ -56,13 +54,13 @@ type ReservedSurface = keyof typeof RESERVED_SURFACES;
|
|
|
56
54
|
* template writes and a diff between the two reads as a diff.
|
|
57
55
|
*/
|
|
58
56
|
export const DEFAULT_SURFACES: readonly ReservedSurface[] = [
|
|
59
|
-
'
|
|
60
|
-
'
|
|
61
|
-
'
|
|
62
|
-
'
|
|
63
|
-
'
|
|
64
|
-
'
|
|
65
|
-
'
|
|
57
|
+
'lanes_memory',
|
|
58
|
+
'lanes_tasks',
|
|
59
|
+
'lanes_assets',
|
|
60
|
+
'lanes_skills',
|
|
61
|
+
'lanes_vault',
|
|
62
|
+
'lanes_setup',
|
|
63
|
+
'lanes_entities',
|
|
66
64
|
];
|
|
67
65
|
|
|
68
66
|
/**
|
|
@@ -121,18 +119,37 @@ export function ensureReservedConnection(
|
|
|
121
119
|
const rows = Array.isArray(workspace?.connections) ? workspace.connections : [];
|
|
122
120
|
const held_grants = Array.isArray(config?.grants) ? config.grants : [];
|
|
123
121
|
|
|
124
|
-
//
|
|
125
|
-
// the *first* one is taken rather than `main` specifically: an
|
|
126
|
-
// renamed theirs should not get a second one bolted on beside
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
122
|
+
// **This profile's own grant comes first.** Any instance of the provider will
|
|
123
|
+
// do, and the *first* one is taken rather than `main` specifically: an
|
|
124
|
+
// operator who renamed theirs should not get a second one bolted on beside
|
|
125
|
+
// it. That reasoning was right and the lookup implementing it was not — it
|
|
126
|
+
// read the workspace's first row, which under contract 2 was this profile's
|
|
127
|
+
// because a profile carried its own connections, and under contract 3 is
|
|
128
|
+
// whichever profile sorts first.
|
|
129
|
+
//
|
|
130
|
+
// So the repair asked whether `personal` granted `memory.main` — demo's — saw
|
|
131
|
+
// that it did not, and set about adding it. For `vault` and `skills` the
|
|
132
|
+
// schema refuses a second grant and the save failed loudly, which is how this
|
|
133
|
+
// was found. For `memory`, `tasks`, `assets`, `setup` and `entities` nothing
|
|
134
|
+
// refuses it, and the profile would quietly have been granted another
|
|
135
|
+
// profile's notes and another profile's task list — the outcome ADR-059
|
|
136
|
+
// exists to prevent, arriving from the repair rather than the migration.
|
|
137
|
+
//
|
|
138
|
+
// **Every** instance, not the first one matched. A profile may grant two —
|
|
139
|
+
// `memory.team` and `memory.personal` — and reading only the first let a deny
|
|
140
|
+
// on the other be stepped around: the repair widened the undenied row and
|
|
141
|
+
// reported the surface granted, which is the one thing the paragraph below
|
|
142
|
+
// says it must never do.
|
|
143
|
+
//
|
|
144
|
+
// `startsWith` rather than slicing at the dot, because these rows are raw
|
|
145
|
+
// unvalidated YAML: `indexOf` answers -1 for a value with no dot at all, and
|
|
146
|
+
// `'vaults'.slice(0, -1)` is `'vault'` — so a typo'd `connection: vaults`
|
|
147
|
+
// matched the vault surface and was widened while the real one stayed
|
|
148
|
+
// unreachable.
|
|
149
|
+
const mine = held_grants.filter((row) => {
|
|
150
|
+
const connection = (row as { connection?: unknown } | null)?.connection;
|
|
151
|
+
return typeof connection === 'string' && connection.startsWith(`${provider}.`);
|
|
152
|
+
}) as { connection?: string; allow?: unknown; deny?: unknown }[];
|
|
136
153
|
|
|
137
154
|
// Denied on purpose, and a deny beats an allow — so writing the rule would
|
|
138
155
|
// widen nothing while announcing that an agent can now read the surface,
|
|
@@ -143,18 +160,53 @@ export function ensureReservedConnection(
|
|
|
143
160
|
// Only a rule covering the whole surface counts. `deny: [setup.provider]` is
|
|
144
161
|
// an operator narrowing it, not switching it off, and that narrowing survives
|
|
145
162
|
// the repair untouched — which is the point of denying one capability.
|
|
146
|
-
if (patternsIn(
|
|
163
|
+
if (mine.some((row) => patternsIn(row.deny).some(covers))) return { changes: [], granted: [] };
|
|
164
|
+
|
|
165
|
+
// The instance already carrying the rule, if any. Not an early return: the
|
|
166
|
+
// workspace row can be missing while the grant is present — a profile written
|
|
167
|
+
// before the surface existed, repaired once against a `connections.yaml` that
|
|
168
|
+
// has since been hand-edited — and repairing one half only is what this
|
|
169
|
+
// function exists to prevent.
|
|
170
|
+
const allowed = mine.find((row) => patternsIn(row.allow).some(covers));
|
|
171
|
+
|
|
172
|
+
// Otherwise the first row that exists and grants nothing is the one to widen.
|
|
173
|
+
const owned = allowed ?? mine[0];
|
|
174
|
+
|
|
175
|
+
// Only when this profile grants no instance of the provider at all is the
|
|
176
|
+
// workspace consulted, which is the case this was written for: a surface that
|
|
177
|
+
// did not exist when the profile was written.
|
|
178
|
+
const declared = rows.find(
|
|
179
|
+
(row) => (row as { provider?: unknown } | null)?.provider === provider,
|
|
180
|
+
) as { id?: unknown } | undefined;
|
|
181
|
+
|
|
182
|
+
// The id an existing row already has, else the next free one. Opaque and
|
|
183
|
+
// allocated across the whole workspace, so `lan3` names exactly one row
|
|
184
|
+
// however many surfaces a repair adds in one pass.
|
|
185
|
+
const taken = rows.flatMap((row) => {
|
|
186
|
+
const id = (row as { id?: unknown } | null)?.id;
|
|
187
|
+
return typeof id === 'string' ? [id] : [];
|
|
188
|
+
});
|
|
189
|
+
const id = typeof declared?.id === 'string' ? declared.id : nextConnectionId(taken, true);
|
|
190
|
+
|
|
191
|
+
const key = owned?.connection ?? `${provider}.${id}`;
|
|
192
|
+
|
|
193
|
+
// Whether the *workspace* needs a row, which is a separate question from
|
|
194
|
+
// which one this profile grants: a profile cannot grant a connection that is
|
|
195
|
+
// not declared, so `owned` implies `declared`.
|
|
196
|
+
const existing = declared;
|
|
197
|
+
const held = owned;
|
|
147
198
|
|
|
148
199
|
const changes: string[] = [];
|
|
149
200
|
const granted: string[] = [];
|
|
150
201
|
|
|
151
202
|
if (existing === undefined) {
|
|
152
|
-
// Inline, and
|
|
153
|
-
// like `newConnectionsTemplate` writes a fresh one
|
|
154
|
-
//
|
|
203
|
+
// Inline, and the allocated id, so a repaired workspace is spelled exactly
|
|
204
|
+
// like `newConnectionsTemplate` writes a fresh one — which is what
|
|
205
|
+
// `config-edit.test.ts` asserts by checking a fresh profile needs no repair.
|
|
206
|
+
// Two spellings of one row is how a template and its repair drift apart.
|
|
155
207
|
connections.addTo(
|
|
156
208
|
['connections'],
|
|
157
|
-
{ id
|
|
209
|
+
{ id, provider, account: RESERVED_SURFACES[provider] },
|
|
158
210
|
{ inline: true },
|
|
159
211
|
);
|
|
160
212
|
changes.push(`connections.yaml += ${key}`);
|
|
@@ -166,7 +218,7 @@ export function ensureReservedConnection(
|
|
|
166
218
|
profile.addTo(['grants'], { connection: key, allow: [rule], deny: [] }, { inline: true });
|
|
167
219
|
changes.push(`grants += ${key}`);
|
|
168
220
|
granted.push(rule);
|
|
169
|
-
} else if (
|
|
221
|
+
} else if (allowed === undefined) {
|
|
170
222
|
// A row that exists and grants nothing is a surface that is present and
|
|
171
223
|
// silent. Widening it back is the repair; the deny check above is what stops
|
|
172
224
|
// this undoing a deliberate narrowing.
|
|
@@ -178,28 +230,7 @@ export function ensureReservedConnection(
|
|
|
178
230
|
return { changes, granted };
|
|
179
231
|
}
|
|
180
232
|
|
|
181
|
-
/** The workspace's connections document, written from the template if missing. */
|
|
182
|
-
async function openOrCreateConnections(workspaceRoot: string): Promise<ConfigDocument> {
|
|
183
|
-
try {
|
|
184
|
-
return await ConfigDocument.openKey(workspaceRoot, CONNECTIONS_FILE);
|
|
185
|
-
} catch {
|
|
186
|
-
await writeWorkspaceFile(
|
|
187
|
-
workspaceFiles(workspaceRoot),
|
|
188
|
-
CONNECTIONS_FILE,
|
|
189
|
-
newConnectionsTemplate(),
|
|
190
|
-
);
|
|
191
|
-
return ConfigDocument.openKey(workspaceRoot, CONNECTIONS_FILE);
|
|
192
|
-
}
|
|
193
|
-
}
|
|
194
|
-
|
|
195
233
|
/** Whether a repair did anything, without a caller adding up two lists. */
|
|
196
|
-
/** `memory, tasks, assets, skills, vault, setup and entities`, in repair order. */
|
|
197
|
-
function listSurfaces(): string {
|
|
198
|
-
const names = [...DEFAULT_SURFACES];
|
|
199
|
-
const last = names.pop();
|
|
200
|
-
return names.length === 0 ? String(last) : `${names.join(', ')} and ${last}`;
|
|
201
|
-
}
|
|
202
|
-
|
|
203
234
|
export function repaired(repair: SurfaceRepair): boolean {
|
|
204
235
|
return repair.changes.length > 0 || repair.granted.length > 0;
|
|
205
236
|
}
|
|
@@ -286,88 +317,6 @@ export function ensureIdentityConnection(
|
|
|
286
317
|
connections: ConfigDocument,
|
|
287
318
|
profile: ConfigDocument,
|
|
288
319
|
): SurfaceRepair {
|
|
289
|
-
return ensureReservedConnection(connections, profile, '
|
|
320
|
+
return ensureReservedConnection(connections, profile, 'lanes_identity');
|
|
290
321
|
}
|
|
291
322
|
|
|
292
|
-
/**
|
|
293
|
-
* Apply that repair across a workspace, saving and reporting what changed.
|
|
294
|
-
*
|
|
295
|
-
* Here rather than in `#deployments`, where it was, because `start` needs it as
|
|
296
|
-
* much as `deploy` does — more, in fact: `start` is the one command an existing
|
|
297
|
-
* install runs without being asked to, so it is the path by which a profile
|
|
298
|
-
* written before ADR-050 gets the layer at all. Two copies of a function that
|
|
299
|
-
* widens a policy is not a thing to have.
|
|
300
|
-
*
|
|
301
|
-
* **The caller scopes it**, and for `deploy` that is exactly the set being
|
|
302
|
-
* uploaded: a profile it sends is a profile the endpoint will serve, so
|
|
303
|
-
* repairing a narrower set would leave a served profile without the surfaces.
|
|
304
|
-
* Note what a `--profile` flag does not mean — it is the flag alone, so a
|
|
305
|
-
* profile resolved from the environment leaves it undefined and that reads as
|
|
306
|
-
* the whole workspace.
|
|
307
|
-
*
|
|
308
|
-
* *Which files are profiles* comes from `listProfiles`, never from an allowlist
|
|
309
|
-
* of what is safe to copy: that would happily hand over a committed
|
|
310
|
-
* `personal.example.yaml` or a nested `profiles/archive/old.yaml`, and this
|
|
311
|
-
* opens and validates what it is given — which once turned a template into a
|
|
312
|
-
* `ConfigError` aborting a deploy after provisioning had made cloud resources.
|
|
313
|
-
*
|
|
314
|
-
* A profile that cannot be read is warned about rather than fatal: the repair is
|
|
315
|
-
* a courtesy on the way past, and the caller's real work should still happen.
|
|
316
|
-
* Not silent, though — nothing else widens a policy without being asked.
|
|
317
|
-
*
|
|
318
|
-
* CLI-side by construction, like everything else in this file: a deployed
|
|
319
|
-
* revision holds `objectViewer` on `profiles/` (ADR-023) and could not write
|
|
320
|
-
* this even if the code let it.
|
|
321
|
-
*/
|
|
322
|
-
export async function repairOwnerLayer(
|
|
323
|
-
workspaceRoot: string,
|
|
324
|
-
profiles: readonly string[] | undefined,
|
|
325
|
-
options: { report?: (line: string) => void } = {},
|
|
326
|
-
): Promise<void> {
|
|
327
|
-
// stdout by default, because every caller but one is printing a report a
|
|
328
|
-
// person reads. `update --json` passes `progress` instead: what it produces is
|
|
329
|
-
// a document, and a line of prose in front of it corrupts whatever is parsing.
|
|
330
|
-
// Routed rather than silenced — nothing else here widens a policy without
|
|
331
|
-
// saying so, and this must not be the exception.
|
|
332
|
-
const say = options.report ?? print;
|
|
333
|
-
const wanted = profiles === undefined ? undefined : new Set(profiles);
|
|
334
|
-
|
|
335
|
-
// One connections document for the whole sweep. The owner layer is the
|
|
336
|
-
// workspace's now (ADR-059), so repairing three profiles must not add three
|
|
337
|
-
// `memory.main` rows — opening it once and saving it once is what makes the
|
|
338
|
-
// second profile see what the first one created.
|
|
339
|
-
// Created if absent rather than refused. A contract-3 workspace always has
|
|
340
|
-
// one, but a hand-made or half-migrated one may not — and this is the repair,
|
|
341
|
-
// so the file it needs is a thing to write rather than a reason to stop.
|
|
342
|
-
const connections = await openOrCreateConnections(workspaceRoot);
|
|
343
|
-
let connectionsChanged = false;
|
|
344
|
-
|
|
345
|
-
for (const name of await listProfiles(workspaceRoot)) {
|
|
346
|
-
if (wanted !== undefined && !wanted.has(name)) continue;
|
|
347
|
-
|
|
348
|
-
try {
|
|
349
|
-
const document = await ConfigDocument.open(workspaceRoot, name);
|
|
350
|
-
const repair = ensureOwnerLayer(connections, document);
|
|
351
|
-
if (!repaired(repair)) continue;
|
|
352
|
-
|
|
353
|
-
connectionsChanged = true;
|
|
354
|
-
await document.save();
|
|
355
|
-
|
|
356
|
-
say(ok(`gave ${style.bold(name)} its own owner layer`));
|
|
357
|
-
for (const change of repairLines(repair)) say(` ${style.dim(change)}`);
|
|
358
|
-
// Built from `DEFAULT_SURFACES` rather than typed out. The typed-out
|
|
359
|
-
// version still named six after a seventh had been added, so a person
|
|
360
|
-
// watching a deploy was told entities had arrived on the line above and
|
|
361
|
-
// that the layer was six things on the line below.
|
|
362
|
-
say(` ${style.dim(`${listSurfaces()} — your own material, no account behind any of them`)}`);
|
|
363
|
-
} catch (error) {
|
|
364
|
-
say(
|
|
365
|
-
warn(
|
|
366
|
-
`could not give ${name} its owner layer: ${error instanceof Error ? error.message.split('\n')[0] : String(error)}`,
|
|
367
|
-
),
|
|
368
|
-
);
|
|
369
|
-
}
|
|
370
|
-
}
|
|
371
|
-
|
|
372
|
-
if (connectionsChanged) await connections.save();
|
|
373
|
-
}
|