@lanes-sh/link 0.5.4 → 0.6.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.
Files changed (48) 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/publish.ts +4 -2
  23. package/src/cli/runtime/open.ts +19 -10
  24. package/src/cli/runtime/select.ts +69 -22
  25. package/src/cli/selection-require.ts +79 -0
  26. package/src/cli/selection.ts +44 -92
  27. package/src/cli/workspace-migrate.ts +385 -0
  28. package/src/deployments/bootstrap.ts +31 -11
  29. package/src/deployments/deploy.ts +54 -27
  30. package/src/deployments/knowledge.ts +5 -2
  31. package/src/deployments/prepare.ts +3 -1
  32. package/src/deployments/serving.ts +19 -15
  33. package/src/deployments/upload.ts +10 -1
  34. package/src/profile/deployments.ts +64 -53
  35. package/src/profile/index.ts +22 -6
  36. package/src/profile/legacy.ts +92 -0
  37. package/src/profile/load.ts +12 -28
  38. package/src/profile/registry.ts +182 -0
  39. package/src/profile/schema.ts +162 -110
  40. package/src/profile/targets.ts +62 -91
  41. package/src/profile/testing.ts +78 -0
  42. package/src/profile/workspace.ts +11 -25
  43. package/src/server/dashboard.ts +4 -1
  44. package/src/server/harness.ts +1 -6
  45. package/src/cli/commands/profile/declare.ts +0 -154
  46. package/src/deployments/servable.ts +0 -82
  47. package/src/deployments/sync-apply.ts +0 -330
  48. package/src/deployments/sync.ts +0 -164
@@ -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