@lanes-sh/link 0.5.4 → 0.6.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/instructions/agents/lanes-link-scout.md +1 -1
- package/instructions/skills/lanes-link/SKILL.md +33 -29
- package/package.json +2 -1
- package/src/cli/commands/connect/target-note.ts +2 -2
- package/src/cli/commands/knowledge/index.ts +14 -34
- package/src/cli/commands/mcp/register.ts +1 -1
- package/src/cli/commands/operate/dashboard.ts +2 -2
- package/src/cli/commands/operate/inspect.ts +5 -1
- package/src/cli/commands/operate/migrate.ts +85 -1
- package/src/cli/commands/operate/outputs.ts +7 -22
- package/src/cli/commands/operate/status.ts +84 -69
- package/src/cli/commands/operate/tools.ts +1 -1
- package/src/cli/commands/profile/removal.ts +36 -25
- package/src/cli/commands/profile/remove.ts +5 -2
- package/src/cli/commands/profile.ts +56 -40
- package/src/cli/commands/sync.ts +94 -162
- package/src/cli/commands/target.ts +115 -74
- package/src/cli/commands/update.ts +56 -1
- package/src/cli/config-edit.ts +47 -16
- package/src/cli/endpoint-url.ts +3 -3
- package/src/cli/main.ts +9 -7
- package/src/cli/migrate-plan.ts +160 -0
- package/src/cli/publish.ts +4 -2
- package/src/cli/runtime/open.ts +19 -10
- package/src/cli/runtime/select.ts +69 -22
- package/src/cli/selection-require.ts +79 -0
- package/src/cli/selection.ts +44 -92
- package/src/cli/workspace-migrate.ts +263 -0
- package/src/deployments/bootstrap.ts +31 -11
- package/src/deployments/deploy.ts +103 -27
- package/src/deployments/knowledge.ts +5 -2
- package/src/deployments/prepare.ts +3 -1
- package/src/deployments/serving.ts +19 -15
- package/src/deployments/upload.ts +10 -1
- package/src/profile/deployments.ts +64 -53
- package/src/profile/index.ts +22 -6
- package/src/profile/legacy.ts +92 -0
- package/src/profile/load.ts +12 -28
- package/src/profile/registry.ts +182 -0
- package/src/profile/schema.ts +162 -110
- package/src/profile/targets.ts +62 -91
- package/src/profile/testing.ts +78 -0
- package/src/profile/workspace.ts +11 -25
- package/src/server/dashboard.ts +4 -1
- package/src/server/harness.ts +1 -6
- package/src/cli/commands/profile/declare.ts +0 -154
- package/src/deployments/servable.ts +0 -82
- package/src/deployments/sync-apply.ts +0 -330
- package/src/deployments/sync.ts +0 -164
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
import { ConfigError, layout, isRemoteWorkspace, type LegacyTarget, type WorkspaceTarget } from '#profile';
|
|
2
|
+
import { deployedWorkspace } from '#deployments/upload.ts';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* What one contract-1 target block becomes, and where.
|
|
6
|
+
*
|
|
7
|
+
* Apart from `workspace-migrate.ts`, which reads and writes: this is the
|
|
8
|
+
* decision, and it is the half that has been wrong twice. Both times the shape
|
|
9
|
+
* was right and the *perspective* was not — the same `cloud:` block is a pointer
|
|
10
|
+
* seen from a laptop and a declaration seen from inside the bucket, and a
|
|
11
|
+
* migration that cannot tell which end it is on writes `gs://b` pointing at
|
|
12
|
+
* `gs://b`.
|
|
13
|
+
*
|
|
14
|
+
* So the workspace being migrated is an argument to every function here, and the
|
|
15
|
+
* tests drive them directly.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Every profile's target blocks, folded into one registry.
|
|
20
|
+
*
|
|
21
|
+
* Two refusals rather than guesses, because both are cases where one target
|
|
22
|
+
* would need two different answers and picking either silently is the class of
|
|
23
|
+
* bug this whole change is about.
|
|
24
|
+
*/
|
|
25
|
+
export async function hoist(
|
|
26
|
+
legacy: readonly { profile: string; targets: Record<string, LegacyTarget> }[],
|
|
27
|
+
workspaceRoot: string,
|
|
28
|
+
): Promise<Record<string, WorkspaceTarget>> {
|
|
29
|
+
const registry: Record<string, WorkspaceTarget> = {};
|
|
30
|
+
const seenFrom: Record<string, string> = {};
|
|
31
|
+
|
|
32
|
+
for (const { profile, targets } of legacy) {
|
|
33
|
+
for (const [name, declared] of Object.entries(targets)) {
|
|
34
|
+
const entry = toEntry(name, declared, profile, workspaceRoot);
|
|
35
|
+
if (entry === null) continue;
|
|
36
|
+
const previous = registry[name];
|
|
37
|
+
|
|
38
|
+
if (previous === undefined) {
|
|
39
|
+
registry[name] = entry;
|
|
40
|
+
seenFrom[name] = profile;
|
|
41
|
+
continue;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
if (JSON.stringify(previous) !== JSON.stringify(entry)) {
|
|
45
|
+
throw new ConfigError(
|
|
46
|
+
`Profiles "${seenFrom[name]}" and "${profile}" both declare target "${name}", and they ` +
|
|
47
|
+
`do not agree.\n` +
|
|
48
|
+
` A target is declared once by the workspace it lives in (ADR-052), so one of these\n` +
|
|
49
|
+
` has to win and this cannot pick.\n\n` +
|
|
50
|
+
` ${seenFrom[name]}: ${summarise(previous)}\n` +
|
|
51
|
+
` ${profile}: ${summarise(entry)}\n\n` +
|
|
52
|
+
` Edit one of them to match the other, then run this again.`,
|
|
53
|
+
);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
return registry;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* One contract-1 target block as a registry entry.
|
|
63
|
+
*
|
|
64
|
+
* A block whose storage names a bucket becomes a pointer: that bucket is a
|
|
65
|
+
* workspace, it holds the profiles served there, and under ADR-052 it is the
|
|
66
|
+
* thing that declares the target. The adapters are not copied into the pointer —
|
|
67
|
+
* they travel to the bucket on the next `deploy`, which is the only command that
|
|
68
|
+
* can write there and roll an image that understands what it wrote.
|
|
69
|
+
*
|
|
70
|
+
* Per-profile paths are dropped when they are the layout defaults, which is the
|
|
71
|
+
* ordinary case: `./data/<profile>` and `./data/<profile>/credentials.enc` are
|
|
72
|
+
* exactly what `layout.ts` derives, so a workspace-level target that omits them
|
|
73
|
+
* addresses the same bytes. A genuinely custom path cannot be hoisted — one
|
|
74
|
+
* target cannot hold a different path per profile — and is refused by name.
|
|
75
|
+
*/
|
|
76
|
+
export function toEntry(
|
|
77
|
+
name: string,
|
|
78
|
+
declared: LegacyTarget,
|
|
79
|
+
profile: string,
|
|
80
|
+
workspaceRoot: string,
|
|
81
|
+
): WorkspaceTarget | null {
|
|
82
|
+
const remote = deployedWorkspace(declared);
|
|
83
|
+
|
|
84
|
+
// **A target whose bucket is the workspace being migrated declares itself.**
|
|
85
|
+
//
|
|
86
|
+
// Migrating happens on both ends, and the second one is inside the bucket: the
|
|
87
|
+
// profile there carries the same `cloud` block, and deriving a pointer from it
|
|
88
|
+
// produces `gs://b` pointing at `gs://b`. That is a loop, and `openTarget`
|
|
89
|
+
// refuses it — which made `deploy` unable to run against the bucket it had
|
|
90
|
+
// just migrated, on the one command the refusal names as the fix.
|
|
91
|
+
if (remote && remote !== workspaceRoot) return { workspace: remote };
|
|
92
|
+
|
|
93
|
+
// **A filesystem target is dropped from a remote workspace.**
|
|
94
|
+
//
|
|
95
|
+
// The bucket's copy of a profile was uploaded from a laptop, so it carries
|
|
96
|
+
// that laptop's `local:` block — paths under `./data/` that address a disk the
|
|
97
|
+
// endpoint has never seen. Hoisting it would leave the bucket declaring a
|
|
98
|
+
// target it can never open, and `workspacePath` refuses that combination the
|
|
99
|
+
// moment anything tries.
|
|
100
|
+
//
|
|
101
|
+
// Nothing is lost: the machine that owns `local` has its own copy, and that is
|
|
102
|
+
// the one that was ever real.
|
|
103
|
+
if (isRemoteWorkspace(workspaceRoot) && declared.storage.adapter === 'filesystem') return null;
|
|
104
|
+
|
|
105
|
+
const storagePath = declared.storage.path;
|
|
106
|
+
const credentialsPath = declared.credentials.path;
|
|
107
|
+
|
|
108
|
+
const defaultStorage = `./${layout.blobs(profile)}`;
|
|
109
|
+
const defaultCredentials = `./${layout.credentials(profile)}`;
|
|
110
|
+
|
|
111
|
+
refuseCustomPath(name, profile, workspaceRoot, 'storage.path', storagePath, defaultStorage);
|
|
112
|
+
refuseCustomPath(
|
|
113
|
+
name,
|
|
114
|
+
profile,
|
|
115
|
+
workspaceRoot,
|
|
116
|
+
'credentials.path',
|
|
117
|
+
credentialsPath,
|
|
118
|
+
defaultCredentials,
|
|
119
|
+
);
|
|
120
|
+
|
|
121
|
+
const { path: _storage, ...storage } = declared.storage;
|
|
122
|
+
const { path: _credentials, ...credentials } = declared.credentials;
|
|
123
|
+
|
|
124
|
+
return {
|
|
125
|
+
credentials,
|
|
126
|
+
storage,
|
|
127
|
+
...(declared.audit ? { audit: declared.audit } : {}),
|
|
128
|
+
...(declared.vault ? { vault: declared.vault } : {}),
|
|
129
|
+
...(declared.deploy ? { deploy: declared.deploy } : {}),
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function refuseCustomPath(
|
|
134
|
+
target: string,
|
|
135
|
+
profile: string,
|
|
136
|
+
workspaceRoot: string,
|
|
137
|
+
field: string,
|
|
138
|
+
value: string | undefined,
|
|
139
|
+
expected: string,
|
|
140
|
+
): void {
|
|
141
|
+
if (value === undefined) return;
|
|
142
|
+
// Both spellings of the same directory: `./data/personal` and `data/personal`
|
|
143
|
+
// resolve identically and the template has written each at different times.
|
|
144
|
+
if (value === expected || value === expected.replace(/^\.\//, '')) return;
|
|
145
|
+
|
|
146
|
+
throw new ConfigError(
|
|
147
|
+
`Profile "${profile}" declares target "${target}" with a custom ${field}:\n` +
|
|
148
|
+
` ${value}\n` +
|
|
149
|
+
` A target is declared once for the whole workspace now (ADR-052), so it cannot hold a\n` +
|
|
150
|
+
` different path per profile. The default it would get is ${expected}.\n\n` +
|
|
151
|
+
` Move the data there and delete the line, or keep this profile in a workspace of its\n` +
|
|
152
|
+
` own: LANES_LINK_HOME=${workspaceRoot}/<somewhere> lanes link profile add ${profile} --target ${target}`,
|
|
153
|
+
);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
export function summarise(entry: WorkspaceTarget): string {
|
|
157
|
+
if (entry.workspace !== undefined) return `points at ${entry.workspace}`;
|
|
158
|
+
const parts = [entry.credentials?.adapter, entry.storage?.adapter].filter(Boolean);
|
|
159
|
+
return `${parts.join(' + ')}${entry.deploy ? `, deploys ${entry.deploy.service}` : ''}`;
|
|
160
|
+
}
|
package/src/cli/publish.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { SecretStore } from '#secrets';
|
|
2
|
-
import type
|
|
2
|
+
import { openTarget, type Config } from '#profile';
|
|
3
3
|
import { publishWorkspace } from '#deployments/upload.ts';
|
|
4
4
|
import { openSecretStoreFor, type Runtime } from './runtime.ts';
|
|
5
5
|
import { endpointUrl } from './endpoint-url.ts';
|
|
@@ -117,6 +117,7 @@ export async function publishProfileEdit(input: {
|
|
|
117
117
|
/** Ask a running endpoint to re-read its config. Never throws. */
|
|
118
118
|
async function notifyReload(input: {
|
|
119
119
|
readonly config: Config;
|
|
120
|
+
readonly workspaceRoot: string;
|
|
120
121
|
readonly target: string;
|
|
121
122
|
readonly credentials: SecretStore;
|
|
122
123
|
}): Promise<PublishOutcome> {
|
|
@@ -125,7 +126,8 @@ async function notifyReload(input: {
|
|
|
125
126
|
// Answers for a deployed target as well as a local one — a loopback URL
|
|
126
127
|
// sent to a deployment reaches a port with nothing behind it, which is the
|
|
127
128
|
// bug this function's own doc comment records.
|
|
128
|
-
|
|
129
|
+
const { declared } = await openTarget(input.workspaceRoot, input.target);
|
|
130
|
+
url = (await endpointUrl(input.config, declared)).replace(/\/mcp$/, '/reload');
|
|
129
131
|
} catch (error) {
|
|
130
132
|
return { served: false, reason: `could not work out where the endpoint is: ${message(error)}` };
|
|
131
133
|
}
|
package/src/cli/runtime/open.ts
CHANGED
|
@@ -9,10 +9,10 @@ import {
|
|
|
9
9
|
KNOWLEDGE_LAYOUT,
|
|
10
10
|
layout,
|
|
11
11
|
listProfiles,
|
|
12
|
-
undeclaredTarget,
|
|
13
12
|
workspacePath,
|
|
14
13
|
type Config,
|
|
15
14
|
type Resolution,
|
|
15
|
+
type TargetConfig,
|
|
16
16
|
} from '#profile';
|
|
17
17
|
import { ProviderRegistry, toPolicyDocument } from '#registry';
|
|
18
18
|
import { Dispatcher, createConsoleLogger } from '#dispatch';
|
|
@@ -48,6 +48,15 @@ export interface Runtime {
|
|
|
48
48
|
readonly resolution: Resolution;
|
|
49
49
|
readonly config: Config;
|
|
50
50
|
readonly target: string;
|
|
51
|
+
/**
|
|
52
|
+
* The target's adapter set, from the workspace that declares it.
|
|
53
|
+
*
|
|
54
|
+
* Here because the config no longer carries it. Every caller that used to
|
|
55
|
+
* reach `config.targets[target]` — for a `deploy` block, a storage adapter, a
|
|
56
|
+
* knowledge repository — reads this instead, and reads it without following
|
|
57
|
+
* the pointer a second time (ADR-052).
|
|
58
|
+
*/
|
|
59
|
+
readonly declared: TargetConfig;
|
|
51
60
|
readonly state: RuntimeState;
|
|
52
61
|
/** The durable log, for reading. Copies, if any, are write-only and not here. */
|
|
53
62
|
readonly audit: AuditReader;
|
|
@@ -133,16 +142,15 @@ export async function openRuntime(
|
|
|
133
142
|
flags: GlobalFlags,
|
|
134
143
|
options: OpenOptions = {},
|
|
135
144
|
): Promise<Runtime> {
|
|
136
|
-
const { resolution, config, target } = await resolveProfile(flags);
|
|
137
|
-
|
|
138
|
-
// `
|
|
139
|
-
//
|
|
140
|
-
//
|
|
141
|
-
|
|
142
|
-
// says why one copy is the most that survives.
|
|
143
|
-
if (!declared) throw undeclaredTarget(target, config, resolution.profile);
|
|
145
|
+
const { resolution, config, target, resolved } = await resolveProfile(flags);
|
|
146
|
+
// `resolveProfile` returns this for every caller that did not ask to create
|
|
147
|
+
// the target, and `openRuntime` never does — a runtime for a target that does
|
|
148
|
+
// not exist yet has nothing to open. The check is what makes that readable at
|
|
149
|
+
// the type level rather than a comment.
|
|
150
|
+
if (!resolved) throw new Error(`Target "${target}" has nothing to open yet`);
|
|
144
151
|
|
|
145
|
-
const
|
|
152
|
+
const declared = resolved.declared;
|
|
153
|
+
const root = resolved.workspaceRoot;
|
|
146
154
|
const adapters: TargetInput = { declared, config, root, target };
|
|
147
155
|
|
|
148
156
|
// Credentials first: an S3 key pair is itself a credential reference, so the
|
|
@@ -341,6 +349,7 @@ export async function openRuntime(
|
|
|
341
349
|
resolution,
|
|
342
350
|
config,
|
|
343
351
|
target,
|
|
352
|
+
declared,
|
|
344
353
|
state,
|
|
345
354
|
audit,
|
|
346
355
|
credentials,
|
|
@@ -3,11 +3,15 @@ import type { SecretStore } from '#secrets';
|
|
|
3
3
|
import type { BlobStore } from '#stores/blobs';
|
|
4
4
|
import {
|
|
5
5
|
loadProfileConfig,
|
|
6
|
+
openTarget,
|
|
7
|
+
readRegistry,
|
|
6
8
|
resolveSelection,
|
|
9
|
+
resolveTargetWorkspace,
|
|
10
|
+
resolveWorkspaceRoot,
|
|
7
11
|
requireTarget,
|
|
8
|
-
undeclaredTarget,
|
|
9
12
|
type Config,
|
|
10
13
|
type ProfileSelection,
|
|
14
|
+
type ResolvedTarget,
|
|
11
15
|
type Resolution,
|
|
12
16
|
} from '#profile';
|
|
13
17
|
import { openSecrets, openStorage } from '#deployments/target.ts';
|
|
@@ -40,41 +44,78 @@ export async function resolveProfile(
|
|
|
40
44
|
resolution: Resolution;
|
|
41
45
|
config: Config;
|
|
42
46
|
target: string;
|
|
47
|
+
/**
|
|
48
|
+
* The adapter set, already followed to whichever workspace declares it.
|
|
49
|
+
*
|
|
50
|
+
* `undefined` only under `allowUndeclaredTarget`, which is `deploy` on a first
|
|
51
|
+
* run: the target does not exist yet, so there is nothing to follow and
|
|
52
|
+
* nothing to open. Every other caller can rely on it.
|
|
53
|
+
*/
|
|
54
|
+
resolved: ResolvedTarget | undefined;
|
|
43
55
|
}> {
|
|
44
56
|
// Spread rather than assigned: `exactOptionalPropertyTypes` makes an explicit
|
|
45
57
|
// `env: undefined` a different type from an absent one, and the absent one is
|
|
46
58
|
// what means "read the real environment".
|
|
47
59
|
const env = options.env !== undefined ? { env: options.env } : {};
|
|
48
60
|
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
61
|
+
// **Target first, and the order is the change.** It used to find the profile,
|
|
62
|
+
// read its config, and ask that config which targets existed — which is why
|
|
63
|
+
// "is `cloud` declared" had a different answer per profile, and why a profile
|
|
64
|
+
// rewritten without its cloud block reported a running deployment as gone.
|
|
65
|
+
//
|
|
66
|
+
// A target is a workspace now (ADR-052), so it has to be resolved before there
|
|
67
|
+
// is anywhere to look for a profile: `personal` on `local` and `personal` on
|
|
68
|
+
// `cloud` are two files, in two workspaces, and only the target says which one
|
|
69
|
+
// this command means.
|
|
70
|
+
const localRoot = resolveWorkspaceRoot(env);
|
|
71
|
+
const registry = await readRegistry(localRoot);
|
|
72
|
+
const target = requireTarget(registry, flags.target, {
|
|
53
73
|
allowUndeclared: options.allowUndeclaredTarget === true,
|
|
54
|
-
|
|
74
|
+
root: localRoot,
|
|
55
75
|
});
|
|
56
76
|
|
|
57
|
-
|
|
77
|
+
// `deploy` on a first run names a target nothing declares yet, and there is no
|
|
78
|
+
// workspace to follow. It resolves its own adapters from the flags it was
|
|
79
|
+
// given; everything else follows the pointer here, once.
|
|
80
|
+
const resolved = options.allowUndeclaredTarget === true && !(target in registry)
|
|
81
|
+
? undefined
|
|
82
|
+
: await openTarget(localRoot, target);
|
|
83
|
+
|
|
84
|
+
const root = resolved?.workspaceRoot ?? localRoot;
|
|
85
|
+
const selection = await resolveSelection({ profileFlag: flags.profile, root, ...env });
|
|
86
|
+
const { config } = await loadProfileConfig(root, selection.profile);
|
|
87
|
+
|
|
88
|
+
return { resolution: { ...selection, target }, config, target, resolved };
|
|
58
89
|
}
|
|
59
90
|
|
|
60
91
|
/**
|
|
61
|
-
* A profile without
|
|
92
|
+
* A profile, without opening any of its stores.
|
|
62
93
|
*
|
|
63
94
|
* `check` validates a YAML file, `config show` prints the whole of it, and
|
|
64
|
-
* `policy list` reads a block that is
|
|
65
|
-
*
|
|
66
|
-
*
|
|
67
|
-
*
|
|
95
|
+
* `policy list` reads a block that is the same wherever the profile runs. None
|
|
96
|
+
* of them needs a credential store or a bucket, and opening one would make all
|
|
97
|
+
* three fail on a target that is merely unreachable.
|
|
98
|
+
*
|
|
99
|
+
* It still needs `--target`, which it did not before. That is not ceremony: a
|
|
100
|
+
* profile lives in exactly one target's workspace now (ADR-052), so without one
|
|
101
|
+
* there is no file to validate — `personal` on `local` and `personal` on `cloud`
|
|
102
|
+
* are different documents. What the flag buys here is finding the file; what it
|
|
103
|
+
* still does not buy is opening anything.
|
|
68
104
|
*/
|
|
69
105
|
export async function resolveProfileOnly(
|
|
70
106
|
flags: GlobalFlags,
|
|
71
107
|
options: { env?: Record<string, string | undefined> } = {},
|
|
72
|
-
): Promise<{ selection: ProfileSelection; config: Config }> {
|
|
108
|
+
): Promise<{ selection: ProfileSelection; config: Config; target: string }> {
|
|
73
109
|
const env = options.env !== undefined ? { env: options.env } : {};
|
|
74
|
-
const
|
|
75
|
-
const
|
|
110
|
+
const localRoot = resolveWorkspaceRoot(env);
|
|
111
|
+
const registry = await readRegistry(localRoot);
|
|
112
|
+
const target = requireTarget(registry, flags.target, { root: localRoot });
|
|
113
|
+
const root = await resolveTargetWorkspace(localRoot, target);
|
|
114
|
+
|
|
115
|
+
const selection = await resolveSelection({ profileFlag: flags.profile, root, ...env });
|
|
116
|
+
const { config } = await loadProfileConfig(root, selection.profile);
|
|
76
117
|
|
|
77
|
-
return { selection, config };
|
|
118
|
+
return { selection, config, target };
|
|
78
119
|
}
|
|
79
120
|
|
|
80
121
|
/**
|
|
@@ -90,9 +131,16 @@ export async function openSecretStoreFor(
|
|
|
90
131
|
root: string,
|
|
91
132
|
target: string,
|
|
92
133
|
): Promise<SecretStore> {
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
134
|
+
// Resolved here rather than taken from the caller, so `secrets push --from
|
|
135
|
+
// local --to cloud` can hold two targets that live in two different workspaces
|
|
136
|
+
// without the caller having to follow either pointer itself.
|
|
137
|
+
const resolved = await openTarget(root, target);
|
|
138
|
+
return openSecrets({
|
|
139
|
+
declared: resolved.declared,
|
|
140
|
+
config,
|
|
141
|
+
root: resolved.workspaceRoot,
|
|
142
|
+
target,
|
|
143
|
+
});
|
|
96
144
|
}
|
|
97
145
|
|
|
98
146
|
/**
|
|
@@ -113,10 +161,9 @@ export async function openBlobStoreFor(
|
|
|
113
161
|
target: string,
|
|
114
162
|
area?: string,
|
|
115
163
|
): Promise<BlobStore> {
|
|
116
|
-
const
|
|
117
|
-
if (!declared) throw undeclaredTarget(target, config);
|
|
164
|
+
const resolved = await openTarget(root, target);
|
|
118
165
|
|
|
119
|
-
const input = { declared, config, root, target };
|
|
166
|
+
const input = { declared: resolved.declared, config, root: resolved.workspaceRoot, target };
|
|
120
167
|
const storage = await openStorage(input, await openSecrets(input));
|
|
121
168
|
return area === undefined ? storage() : storage(area);
|
|
122
169
|
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import {
|
|
2
|
+
listProfiles,
|
|
3
|
+
noProfileNamed,
|
|
4
|
+
noTargetNamed,
|
|
5
|
+
readRegistry,
|
|
6
|
+
resolveTargetWorkspace,
|
|
7
|
+
resolveWorkspaceRoot,
|
|
8
|
+
} from '#profile';
|
|
9
|
+
import type { Flags } from './argv.ts';
|
|
10
|
+
import { dispatchWillRefuse, requirementFor } from './selection.ts';
|
|
11
|
+
import { refuseIfUnmigrated } from './workspace-migrate.ts';
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Refusing a command that has not said what it acts on.
|
|
15
|
+
*
|
|
16
|
+
* Apart from `selection.ts`, which holds the two tables — what each command
|
|
17
|
+
* requires, and which flags it accepts — and argues for keeping *those two*
|
|
18
|
+
* together. This is the third thing: actually resolving a selection, which under
|
|
19
|
+
* ADR-052 means reading the registry, following a pointer to the workspace that
|
|
20
|
+
* declares the target, and listing the profiles that live there. Table lookups
|
|
21
|
+
* and network reads are not the same job, and the file-size budget noticed
|
|
22
|
+
* before anyone else did.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Refuse before the command runs, naming what it wants and what there is.
|
|
27
|
+
*
|
|
28
|
+
* Async, and it reads the workspace — but only on the way to throwing. The
|
|
29
|
+
* useful half of "which profile did you mean" is the list of them, and the same
|
|
30
|
+
* for targets; a refusal that only restates the flag name leaves someone to go
|
|
31
|
+
* and look it up. Both messages come from `#profile` so this file and the
|
|
32
|
+
* resolver cannot describe the same refusal differently, and both name an
|
|
33
|
+
* exported variable that no longer counts — the shell still configured for the
|
|
34
|
+
* old world is the state hardest to diagnose from the inside.
|
|
35
|
+
*/
|
|
36
|
+
export async function requireSelection(
|
|
37
|
+
first: string,
|
|
38
|
+
second: string | undefined,
|
|
39
|
+
flags: Flags,
|
|
40
|
+
env?: Record<string, string | undefined>,
|
|
41
|
+
): Promise<void> {
|
|
42
|
+
if (dispatchWillRefuse(first, second)) return;
|
|
43
|
+
|
|
44
|
+
const needs = requirementFor(first, second);
|
|
45
|
+
if (needs === 'none') return;
|
|
46
|
+
|
|
47
|
+
const root = resolveWorkspaceRoot(env ? { env } : {});
|
|
48
|
+
|
|
49
|
+
// **Target before profile, for both levels.** It used to ask for the profile
|
|
50
|
+
// and read that profile's own target list — the ordering ADR-052 inverted,
|
|
51
|
+
// since the profile lives inside the target and there is nowhere to look for
|
|
52
|
+
// one until the target is known. `refuseIfUnmigrated` is on the refusal path
|
|
53
|
+
// only, so it costs nothing when the flag is present.
|
|
54
|
+
if (typeof flags['target'] !== 'string') {
|
|
55
|
+
await refuseIfUnmigrated(root);
|
|
56
|
+
throw noTargetNamed(await readRegistry(root), root, env);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// A named target the registry cannot know, because it does not exist yet.
|
|
60
|
+
if (!(flags['target'] in (await readRegistry(root)))) await refuseIfUnmigrated(root);
|
|
61
|
+
|
|
62
|
+
if (needs === 'target') return;
|
|
63
|
+
|
|
64
|
+
if (typeof flags['profile'] !== 'string') {
|
|
65
|
+
// Listed from the target's own workspace, so the names offered are ones that
|
|
66
|
+
// command could act on. An unreachable pointer degrades to the empty list
|
|
67
|
+
// rather than failing — "which profile" is still the question being asked.
|
|
68
|
+
const where = await resolveTargetWorkspace(root, flags['target']).catch(() => root);
|
|
69
|
+
throw noProfileNamed(where, await listProfiles(where).catch(() => []), env);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Flags every command accepts, whatever it does.
|
|
75
|
+
*
|
|
76
|
+
* `--help` short-circuits before dispatch, and `--json` is offered widely enough
|
|
77
|
+
* that listing it per command would be noise. `--quiet` is read by `announce`
|
|
78
|
+
* rather than by any one command.
|
|
79
|
+
*/
|
package/src/cli/selection.ts
CHANGED
|
@@ -1,14 +1,4 @@
|
|
|
1
|
-
import {
|
|
2
|
-
ConfigError,
|
|
3
|
-
listProfiles,
|
|
4
|
-
loadProfileConfig,
|
|
5
|
-
loadWorkspaceProfiles,
|
|
6
|
-
noProfileNamed,
|
|
7
|
-
noTargetInWorkspace,
|
|
8
|
-
noTargetNamed,
|
|
9
|
-
resolveWorkspaceRoot,
|
|
10
|
-
targetsByName,
|
|
11
|
-
} from '#profile';
|
|
1
|
+
import { ConfigError } from '#profile';
|
|
12
2
|
import type { Flags } from './argv.ts';
|
|
13
3
|
import { CONNECT_CUSTOM_FLAGS } from './commands/connect/custom/spec.ts';
|
|
14
4
|
import { nearest } from './nearest.ts';
|
|
@@ -39,11 +29,17 @@ import { nearest } from './nearest.ts';
|
|
|
39
29
|
* What a command must be told before it can act.
|
|
40
30
|
*
|
|
41
31
|
* `target` is not a weaker `profile+target`. It says the command's subject *is*
|
|
42
|
-
* the target, and that the profiles behind it are every profile
|
|
43
|
-
*
|
|
32
|
+
* the target, and that the profiles behind it are every profile *in* it rather
|
|
33
|
+
* than one the operator picks (ADR-043, ADR-052). `--profile` stays accepted
|
|
44
34
|
* there, as a filter.
|
|
35
|
+
*
|
|
36
|
+
* There is no `profile`-alone level any more. Five commands sat there — the ones
|
|
37
|
+
* that read a profile's file and open nothing — and it stopped being reachable
|
|
38
|
+
* when a profile came to live inside one target's workspace: without a target
|
|
39
|
+
* there is no file to read. The level is gone rather than left empty, so nobody
|
|
40
|
+
* adds a sixth command to a level that cannot resolve.
|
|
45
41
|
*/
|
|
46
|
-
export type Requires = 'none' | '
|
|
42
|
+
export type Requires = 'none' | 'target' | 'profile+target';
|
|
47
43
|
|
|
48
44
|
/**
|
|
49
45
|
* The rule, per command path.
|
|
@@ -87,28 +83,33 @@ export const SELECTION: Record<string, Requires> = {
|
|
|
87
83
|
'mcp list': 'none',
|
|
88
84
|
// The bare forms, which each dispatch to a `case undefined` in `main.ts`.
|
|
89
85
|
// `lanes link profile` is `profile list`, and needs the same as it.
|
|
90
|
-
profile: 'none',
|
|
91
86
|
mcp: 'none',
|
|
92
|
-
'profile list': 'none',
|
|
93
|
-
'profile add': 'none',
|
|
94
87
|
'profile default': 'none',
|
|
95
88
|
'target use': 'none',
|
|
96
89
|
'vault key': 'none',
|
|
90
|
+
// Listing the registry is what you run to find out what `--target` accepts, so
|
|
91
|
+
// requiring the answer as input would be circular (ADR-052).
|
|
92
|
+
target: 'none',
|
|
93
|
+
'target list': 'none',
|
|
94
|
+
|
|
95
|
+
// A profile lives in one target's workspace, so listing or creating one names
|
|
96
|
+
// which workspace. `target show` follows the pointer, which `list` does not.
|
|
97
|
+
profile: 'target',
|
|
98
|
+
'profile list': 'target',
|
|
99
|
+
'profile add': 'target',
|
|
100
|
+
'profile remove': 'target',
|
|
101
|
+
'target show': 'target',
|
|
97
102
|
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
'config show': 'profile',
|
|
103
|
-
|
|
104
|
-
'
|
|
105
|
-
|
|
106
|
-
'
|
|
107
|
-
'
|
|
108
|
-
// Target-independent for the same reason `policy list` is: the block is
|
|
109
|
-
// declared once in the YAML and applies to every target the profile has.
|
|
110
|
-
identity: 'profile',
|
|
111
|
-
'identity list': 'profile',
|
|
103
|
+
// These read one profile's file and open nothing. The target is what says
|
|
104
|
+
// which workspace holds it — required to *locate* the profile, not to open it.
|
|
105
|
+
check: 'profile+target',
|
|
106
|
+
config: 'profile+target',
|
|
107
|
+
'config show': 'profile+target',
|
|
108
|
+
policy: 'profile+target',
|
|
109
|
+
'policy list': 'profile+target',
|
|
110
|
+
identity: 'profile+target',
|
|
111
|
+
'identity list': 'profile+target',
|
|
112
|
+
'secrets push': 'profile+target',
|
|
112
113
|
|
|
113
114
|
connect: 'profile+target',
|
|
114
115
|
// Both edit the profile config, and `disconnect` also opens the target's
|
|
@@ -205,7 +206,7 @@ const SUBCOMMANDS: Record<string, readonly string[]> = {
|
|
|
205
206
|
* bogus" is the useful sentence, and a complaint about `--profile` on a command
|
|
206
207
|
* that does not exist sends someone off to fix the wrong thing.
|
|
207
208
|
*/
|
|
208
|
-
function dispatchWillRefuse(first: string, second: string | undefined): boolean {
|
|
209
|
+
export function dispatchWillRefuse(first: string, second: string | undefined): boolean {
|
|
209
210
|
const known = SUBCOMMANDS[first];
|
|
210
211
|
if (!known || second === undefined) return false;
|
|
211
212
|
return !known.includes(second);
|
|
@@ -228,65 +229,6 @@ export function requirementFor(first: string, second: string | undefined): Requi
|
|
|
228
229
|
return SELECTION[selectionKey(first, second)] ?? 'profile+target';
|
|
229
230
|
}
|
|
230
231
|
|
|
231
|
-
/**
|
|
232
|
-
* Refuse before the command runs, naming what it wants and what there is.
|
|
233
|
-
*
|
|
234
|
-
* Async, and it reads the workspace — but only on the way to throwing. The
|
|
235
|
-
* useful half of "which profile did you mean" is the list of them, and the same
|
|
236
|
-
* for targets; a refusal that only restates the flag name leaves someone to go
|
|
237
|
-
* and look it up. Both messages come from `#profile` so this file and the
|
|
238
|
-
* resolver cannot describe the same refusal differently, and both name an
|
|
239
|
-
* exported variable that no longer counts — the shell still configured for the
|
|
240
|
-
* old world is the state hardest to diagnose from the inside.
|
|
241
|
-
*/
|
|
242
|
-
export async function requireSelection(
|
|
243
|
-
first: string,
|
|
244
|
-
second: string | undefined,
|
|
245
|
-
flags: Flags,
|
|
246
|
-
env?: Record<string, string | undefined>,
|
|
247
|
-
): Promise<void> {
|
|
248
|
-
if (dispatchWillRefuse(first, second)) return;
|
|
249
|
-
|
|
250
|
-
const needs = requirementFor(first, second);
|
|
251
|
-
if (needs === 'none') return;
|
|
252
|
-
|
|
253
|
-
// Asked before the profile requirement, because for these there is none. The
|
|
254
|
-
// refusal has to describe the workspace rather than one profile's targets,
|
|
255
|
-
// since the command was never going to act on only one.
|
|
256
|
-
if (needs === 'target') {
|
|
257
|
-
if (typeof flags['target'] === 'string') return;
|
|
258
|
-
const root = resolveWorkspaceRoot(env ? { env } : {});
|
|
259
|
-
throw noTargetInWorkspace(targetsByName(await loadWorkspaceProfiles(root)), root, env);
|
|
260
|
-
}
|
|
261
|
-
|
|
262
|
-
const profile = flags['profile'];
|
|
263
|
-
if (typeof profile !== 'string') {
|
|
264
|
-
const root = resolveWorkspaceRoot(env ? { env } : {});
|
|
265
|
-
throw noProfileNamed(root, await listProfiles(root), env);
|
|
266
|
-
}
|
|
267
|
-
|
|
268
|
-
if (needs !== 'profile+target' || typeof flags['target'] === 'string') return;
|
|
269
|
-
|
|
270
|
-
// The profile is known by here, so the target list is the one belonging to it
|
|
271
|
-
// rather than a guess. A profile that does not exist is a different refusal,
|
|
272
|
-
// and `resolveSelection` gives it a better one a moment later.
|
|
273
|
-
const root = resolveWorkspaceRoot(env ? { env } : {});
|
|
274
|
-
try {
|
|
275
|
-
const { config } = await loadProfileConfig(root, profile);
|
|
276
|
-
throw noTargetNamed(config, profile, env);
|
|
277
|
-
} catch (error) {
|
|
278
|
-
if (error instanceof ConfigError) throw error;
|
|
279
|
-
throw new ConfigError(`--target is required for "${[first, second].filter(Boolean).join(' ')}".`);
|
|
280
|
-
}
|
|
281
|
-
}
|
|
282
|
-
|
|
283
|
-
/**
|
|
284
|
-
* Flags every command accepts, whatever it does.
|
|
285
|
-
*
|
|
286
|
-
* `--help` short-circuits before dispatch, and `--json` is offered widely enough
|
|
287
|
-
* that listing it per command would be noise. `--quiet` is read by `announce`
|
|
288
|
-
* rather than by any one command.
|
|
289
|
-
*/
|
|
290
232
|
const UNIVERSAL = ['help', 'json', 'quiet'];
|
|
291
233
|
|
|
292
234
|
/**
|
|
@@ -367,6 +309,16 @@ const ACCEPTS: Record<string, readonly string[]> = {
|
|
|
367
309
|
* `parseArgv` returns every `--anything` it sees and no command ever inspected
|
|
368
310
|
* the leftovers. A typo was swallowed the same way on every command in the CLI.
|
|
369
311
|
*/
|
|
312
|
+
/**
|
|
313
|
+
* Commands that name their profile as an argument, and so refuse the flag.
|
|
314
|
+
*
|
|
315
|
+
* A `--profile` here could only name a *second* profile and disagree with the
|
|
316
|
+
* positional one. They needed no exception while both sat at `none`; ADR-052
|
|
317
|
+
* moved them to `target`, which made them inherit `--profile` from the rule
|
|
318
|
+
* below.
|
|
319
|
+
*/
|
|
320
|
+
const POSITIONAL_PROFILE = new Set(['profile add', 'profile remove']);
|
|
321
|
+
|
|
370
322
|
export function assertKnownFlags(first: string, second: string | undefined, flags: Flags): void {
|
|
371
323
|
if (dispatchWillRefuse(first, second)) return;
|
|
372
324
|
|
|
@@ -378,7 +330,7 @@ export function assertKnownFlags(first: string, second: string | undefined, flag
|
|
|
378
330
|
...(ACCEPTS[key] ?? []),
|
|
379
331
|
// `target` accepts both: the target is what it acts on, and `--profile`
|
|
380
332
|
// narrows it to one of the profiles behind it.
|
|
381
|
-
...(needs !== 'none' ? ['profile'] : []),
|
|
333
|
+
...(needs !== 'none' && !POSITIONAL_PROFILE.has(key) ? ['profile'] : []),
|
|
382
334
|
...(needs === 'target' || needs === 'profile+target' ? ['target'] : []),
|
|
383
335
|
]);
|
|
384
336
|
|