@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.
Files changed (49) hide show
  1. package/instructions/agents/lanes-link-scout.md +1 -1
  2. package/instructions/skills/lanes-link/SKILL.md +33 -29
  3. package/package.json +2 -1
  4. package/src/cli/commands/connect/target-note.ts +2 -2
  5. package/src/cli/commands/knowledge/index.ts +14 -34
  6. package/src/cli/commands/mcp/register.ts +1 -1
  7. package/src/cli/commands/operate/dashboard.ts +2 -2
  8. package/src/cli/commands/operate/inspect.ts +5 -1
  9. package/src/cli/commands/operate/migrate.ts +85 -1
  10. package/src/cli/commands/operate/outputs.ts +7 -22
  11. package/src/cli/commands/operate/status.ts +84 -69
  12. package/src/cli/commands/operate/tools.ts +1 -1
  13. package/src/cli/commands/profile/removal.ts +36 -25
  14. package/src/cli/commands/profile/remove.ts +5 -2
  15. package/src/cli/commands/profile.ts +56 -40
  16. package/src/cli/commands/sync.ts +94 -162
  17. package/src/cli/commands/target.ts +115 -74
  18. package/src/cli/commands/update.ts +56 -1
  19. package/src/cli/config-edit.ts +47 -16
  20. package/src/cli/endpoint-url.ts +3 -3
  21. package/src/cli/main.ts +9 -7
  22. package/src/cli/migrate-plan.ts +160 -0
  23. package/src/cli/publish.ts +4 -2
  24. package/src/cli/runtime/open.ts +19 -10
  25. package/src/cli/runtime/select.ts +69 -22
  26. package/src/cli/selection-require.ts +79 -0
  27. package/src/cli/selection.ts +44 -92
  28. package/src/cli/workspace-migrate.ts +263 -0
  29. package/src/deployments/bootstrap.ts +31 -11
  30. package/src/deployments/deploy.ts +103 -27
  31. package/src/deployments/knowledge.ts +5 -2
  32. package/src/deployments/prepare.ts +3 -1
  33. package/src/deployments/serving.ts +19 -15
  34. package/src/deployments/upload.ts +10 -1
  35. package/src/profile/deployments.ts +64 -53
  36. package/src/profile/index.ts +22 -6
  37. package/src/profile/legacy.ts +92 -0
  38. package/src/profile/load.ts +12 -28
  39. package/src/profile/registry.ts +182 -0
  40. package/src/profile/schema.ts +162 -110
  41. package/src/profile/targets.ts +62 -91
  42. package/src/profile/testing.ts +78 -0
  43. package/src/profile/workspace.ts +11 -25
  44. package/src/server/dashboard.ts +4 -1
  45. package/src/server/harness.ts +1 -6
  46. package/src/cli/commands/profile/declare.ts +0 -154
  47. package/src/deployments/servable.ts +0 -82
  48. package/src/deployments/sync-apply.ts +0 -330
  49. 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
+ }
@@ -1,5 +1,5 @@
1
1
  import type { SecretStore } from '#secrets';
2
- import type { Config } from '#profile';
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
- url = (await endpointUrl(input.config, input.target)).replace(/\/mcp$/, '/reload');
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
  }
@@ -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
- const declared = config.targets[target];
138
- // `resolveProfile` has already checked this, so reaching it means a caller
139
- // passed `allowUndeclaredTarget`. Through the shared refusal all the same:
140
- // this was a fourth spelling of that sentence with the profile name and the
141
- // list of what exists both missing, and the comment on `undeclaredTarget`
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 root = resolution.workspaceRoot;
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
- const selection = await resolveSelection({ profileFlag: flags.profile, ...env });
50
-
51
- const { config } = await loadProfileConfig(selection.workspaceRoot, selection.profile);
52
- const target = requireTarget(config, flags.target, {
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
- profile: selection.profile,
74
+ root: localRoot,
55
75
  });
56
76
 
57
- return { resolution: { ...selection, target }, config, target };
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 a target, for the commands that do not open one.
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 target-independent by construction. Making
65
- * those three demand a `--target` would be the ceremony that teaches people to
66
- * type `--target local` without reading it, which is how a required flag stops
67
- * being a guard.
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 selection = await resolveSelection({ profileFlag: flags.profile, ...env });
75
- const { config } = await loadProfileConfig(selection.workspaceRoot, selection.profile);
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
- const declared = config.targets[target];
94
- if (!declared) throw undeclaredTarget(target, config);
95
- return openSecrets({ declared, config, root, target });
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 declared = config.targets[target];
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
+ */
@@ -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 declaring it
43
- * rather than one the operator picks (ADR-043). `--profile` stays accepted
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' | 'profile' | 'target' | 'profile+target';
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
- check: 'profile',
99
- config: 'profile',
100
- policy: 'profile',
101
- target: 'profile',
102
- 'config show': 'profile',
103
- 'policy list': 'profile',
104
- 'target list': 'profile',
105
- 'target show': 'profile',
106
- 'secrets push': 'profile',
107
- 'profile remove': 'none',
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