@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
@@ -82,8 +82,17 @@ export interface RemovalPlan {
82
82
  }
83
83
 
84
84
  export interface PlanOptions {
85
- /** Restrict to one target. The profile itself then survives. */
86
- readonly target?: string | undefined;
85
+ /**
86
+ * The target whose stores this plans against.
87
+ *
88
+ * Required now, and not a restriction: a profile lives in exactly one target
89
+ * (ADR-052), so there is no "all of them" left for this to mean. It used to be
90
+ * optional because a profile could declare several and removing it meant
91
+ * emptying each.
92
+ */
93
+ readonly target: string;
94
+ /** That target's adapter set, from the workspace declaring it (ADR-052). */
95
+ readonly declared: TargetConfig;
87
96
  readonly openSecrets: (target: string) => Promise<SecretStore>;
88
97
  readonly openBlobs: (target: string, area?: string) => Promise<BlobStore>;
89
98
  readonly readDefaultProfile?: (() => Promise<string | undefined>) | undefined;
@@ -110,14 +119,13 @@ export async function removalPlan(
110
119
  const untouched: { target: string; refs: string[] }[] = [];
111
120
  const warnings: string[] = [];
112
121
 
113
- const names = options.target ? [options.target] : Object.keys(config.targets);
122
+ // One target, always. A profile lived in as many as it declared and this
123
+ // planned across all of them; it lives in exactly one now (ADR-052), and that
124
+ // one is whichever workspace the caller resolved to reach this file.
125
+ const names: string[] = [options.target];
126
+ const declared = options.declared;
114
127
 
115
128
  for (const name of names) {
116
- const declared = config.targets[name];
117
- if (!declared) {
118
- warnings.push(`Target "${name}" is not declared by this profile, so nothing was planned.`);
119
- continue;
120
- }
121
129
 
122
130
  // A repository this profile keeps memory and skills in is not this
123
131
  // command's to empty, and it is not reachable from here either: the routing
@@ -128,9 +136,9 @@ export async function removalPlan(
128
136
  // `rm -r data/<profile>` used to be the whole of "what could this profile
129
137
  // reach". So it is said before the operator confirms rather than discovered
130
138
  // afterwards.
131
- if (declared.knowledge) {
139
+ if (config.knowledge) {
132
140
  warnings.push(
133
- `Target "${name}" keeps this profile's memory and skills in ${declared.knowledge.repo}. ` +
141
+ `This profile keeps its memory and skills in ${config.knowledge.repo}. ` +
134
142
  'Nothing here touches a repository, so they survive this removal — delete them there ' +
135
143
  'if you want them gone.',
136
144
  );
@@ -226,23 +234,26 @@ export async function removalPlan(
226
234
  }
227
235
  }
228
236
 
229
- // Only when the whole profile is going. With `--target` it still exists.
230
- if (!options.target) {
231
- const defaultProfile = await options.readDefaultProfile?.();
232
- if (defaultProfile === profile) {
233
- items.push({
234
- target: null,
235
- kind: 'workspace-key',
236
- id: 'default_profile',
237
- note: 'cleared, not repointed at whatever remains',
238
- });
239
- }
240
-
241
- // Last. It is the only record of where everything else lives, so a failure
242
- // before this point leaves data a later run can still find.
243
- items.push({ target: null, kind: 'config', id: profilePath(root, profile) });
237
+ // Always, now. `--target` used to mean "decommission this one target and leave
238
+ // the profile behind", which was a coherent thing to want while a profile
239
+ // could be declared against several. It lives in exactly one (ADR-052), and
240
+ // the file itself is *in* that target's workspace — so emptying the target and
241
+ // keeping the profile would leave a config nothing can open, in a workspace it
242
+ // no longer belongs to.
243
+ const defaultProfile = await options.readDefaultProfile?.();
244
+ if (defaultProfile === profile) {
245
+ items.push({
246
+ target: null,
247
+ kind: 'workspace-key',
248
+ id: 'default_profile',
249
+ note: 'cleared, not repointed at whatever remains',
250
+ });
244
251
  }
245
252
 
253
+ // Last. It is the only record of where everything else lives, so a failure
254
+ // before this point leaves data a later run can still find.
255
+ items.push({ target: null, kind: 'config', id: profilePath(root, profile) });
256
+
246
257
  return { profile, items, untouched, warnings };
247
258
  }
248
259
 
@@ -3,6 +3,7 @@ import type { BlobStore } from '#stores/blobs';
3
3
  import {
4
4
  ConfigError,
5
5
  WORKSPACE_FILE,
6
+ openTarget,
6
7
  readWorkspace,
7
8
  readWorkspaceFile,
8
9
  workspaceFiles,
@@ -250,15 +251,17 @@ export interface RemoveFlags extends GlobalFlags {
250
251
  * in the tool.
251
252
  */
252
253
  export async function removeProfile(name: string, flags: RemoveFlags): Promise<void> {
253
- const { selection, config } = await resolveProfileOnly({ ...flags, profile: name });
254
+ const { selection, config, target } = await resolveProfileOnly({ ...flags, profile: name });
254
255
  announceProfile(selection);
255
256
 
256
257
  const root = selection.workspaceRoot;
257
258
  const registry = await buildRegistryWithWorkspace(root, name);
258
259
  const files = workspaceFiles(root);
260
+ const { declared } = await openTarget(root, target);
259
261
 
260
262
  const plan = await removalPlan(config, root, name, registry, {
261
- target: flags.target,
263
+ target,
264
+ declared,
262
265
  openSecrets: (target) => openSecretStoreFor(config, root, target),
263
266
  openBlobs: (target, area) => openBlobStoreFor(config, root, target, area),
264
267
  readDefaultProfile: async () => (await readWorkspace(root))?.default_profile,
@@ -7,10 +7,13 @@ import {
7
7
  listProfiles,
8
8
  profilePath,
9
9
  readWorkspace,
10
+ isRemoteWorkspace,
11
+ workspaceFiles,
12
+ writeWorkspaceFile,
13
+ resolveTargetWorkspace,
10
14
  resolveWorkspaceRoot,
11
15
  } from '#profile';
12
16
  import { newProfileTemplate, newWorkspaceTemplate } from '../config-edit.ts';
13
- import { askTarget, localBlock, siblingTarget } from './profile/declare.ts';
14
17
  import { emit, ok, print, style, table } from '../output.ts';
15
18
 
16
19
  /**
@@ -48,70 +51,80 @@ export interface ProfileListing {
48
51
  /**
49
52
  * Write a new profile, and the workspace file if this is the first one.
50
53
  *
51
- * The targets are the argument that used to be missing. `--target` was accepted
54
+ * The target is the argument that used to be missing. `--target` was accepted
52
55
  * and dropped here, and the template could only ever emit `local` — so the
53
56
  * command reported success and produced a profile that could not reach the
54
57
  * deployment the operator had just told it about.
58
+ *
59
+ * It now decides *where the file goes* rather than what is written in it: a
60
+ * profile lives in one target's workspace and declares nothing about it
61
+ * (ADR-052), so `--target cloud` writes into the bucket and the endpoint there
62
+ * serves it on its next reconcile.
55
63
  */
56
64
  export async function createProfile(
57
65
  name: string,
58
66
  options: { targets: readonly string[]; nonInteractive?: boolean },
59
67
  ): Promise<ProfileCreated> {
60
- const root = resolveWorkspaceRoot();
68
+ const local = resolveWorkspaceRoot();
69
+ const target = options.targets[0]!;
70
+
71
+ // The workspace file before the target is resolved, not after. `profile add
72
+ // <name> --target local` on an empty directory is how a workspace comes into
73
+ // existence, and the target it names is declared *by* that file — so writing
74
+ // it second means resolving a target nothing has declared yet.
75
+ if (!isRemoteWorkspace(local) && !existsSync(join(local, WORKSPACE_FILE))) {
76
+ await mkdir(local, { recursive: true });
77
+ await writeFile(join(local, WORKSPACE_FILE), newWorkspaceTemplate(), { mode: 0o600 });
78
+ }
79
+
80
+ const root = await resolveTargetWorkspace(local, target);
61
81
  const path = profilePath(root, name);
62
82
 
63
- if (existsSync(path)) throw new Error(`Profile "${name}" already exists at ${path}`);
83
+ if (await workspaceFiles(root).has(`profiles/${name}.yaml`)) {
84
+ throw new Error(`Profile "${name}" already exists at ${path}`);
85
+ }
64
86
 
65
- await mkdir(join(root, 'profiles'), { recursive: true });
87
+ // Only a directory needs making. A bucket has no directories, and the write
88
+ // that follows creates the key outright.
89
+ if (!isRemoteWorkspace(root)) await mkdir(join(root, 'profiles'), { recursive: true });
66
90
 
67
91
  // Each profile gets its own port so two can serve at once without an
68
92
  // operator having to think about it.
69
93
  const existing = await listProfiles(root);
70
94
  const port = FIRST_PORT + existing.length;
71
95
 
72
- // Everything that can fail a target nobody declares, a prompt nobody can
73
- // answer happens before the first write, so a refusal leaves the workspace
74
- // exactly as it was rather than half a profile behind.
75
- const blocks: string[] = [];
76
- const copiedFrom: Record<string, string> = {};
77
-
78
- for (const target of options.targets) {
79
- if (target === 'local') {
80
- blocks.push(localBlock(name));
81
- continue;
82
- }
83
-
84
- const declared = await askTarget({
85
- target,
86
- profile: name,
87
- sibling: await siblingTarget(root, target, name),
88
- ...(options.nonInteractive === true ? { nonInteractive: true } : {}),
89
- });
90
-
91
- blocks.push(declared.block);
92
- if (declared.from) copiedFrom[target] = declared.from;
93
- }
94
-
95
- const workspaceFile = join(root, WORKSPACE_FILE);
96
- if (!existsSync(workspaceFile)) {
97
- await writeFile(workspaceFile, newWorkspaceTemplate(), { mode: 0o600 });
98
- }
99
-
100
- await writeFile(path, newProfileTemplate(name, port, blocks.join('')), { mode: 0o600 });
96
+ // The prompting that used to happen here is gone. A new profile had to be
97
+ // given an adapter block per target it declared, and for anything but `local`
98
+ // there was nothing safe to derive one from so the command copied a
99
+ // sibling's, or asked. It declares no target now (ADR-052): it is written into
100
+ // the workspace of the target it was named with, and that workspace already
101
+ // says where its bytes go.
102
+ await writeWorkspaceFile(
103
+ workspaceFiles(root),
104
+ `profiles/${name}.yaml`,
105
+ newProfileTemplate(name, port),
106
+ );
101
107
 
102
- return { name, path, port, targets: options.targets, copiedFrom };
108
+ return { name, path, port, targets: options.targets, copiedFrom: {} };
103
109
  }
104
110
 
105
111
  /**
106
- * Every profile in the workspace, and which one is the default.
112
+ * Every profile in one target's workspace, and which one is the default.
113
+ *
114
+ * **It takes a target, and that is the whole point of ADR-052.** A profile lives
115
+ * in exactly one target's workspace, so "which profiles exist" is a question
116
+ * about a target rather than about this machine — `personal` on `local` and
117
+ * `personal` on `cloud` are two files in two places, and listing the local
118
+ * directory for both is precisely the confusion this change removes.
107
119
  *
108
120
  * Names and paths only — deliberately not each profile's port, which would mean
109
121
  * parsing every config. One unparseable profile would then fail the command
110
122
  * that tells you which profiles exist, and that is exactly when you need it.
111
123
  * `status --json` reports the endpoint for a profile you have named.
112
124
  */
113
- export async function readProfiles(): Promise<ProfileListing> {
114
- const root = resolveWorkspaceRoot();
125
+ export async function readProfiles(target: string): Promise<ProfileListing> {
126
+ const local = resolveWorkspaceRoot();
127
+ const root = await resolveTargetWorkspace(local, target);
115
128
  const profiles = await listProfiles(root);
116
129
  const workspace = await readWorkspace(root);
117
130
 
@@ -158,8 +171,11 @@ export async function profileAdd(
158
171
  });
159
172
  }
160
173
 
161
- export async function profileList(options: { json?: boolean } = {}): Promise<void> {
162
- const listing = await readProfiles();
174
+ export async function profileList(
175
+ target: string,
176
+ options: { json?: boolean } = {},
177
+ ): Promise<void> {
178
+ const listing = await readProfiles(target);
163
179
 
164
180
  return emit(options.json, listing, () => {
165
181
  if (listing.profiles.length === 0) {
@@ -1,42 +1,35 @@
1
- import {
2
- ConfigError,
3
- findDeployment,
4
- loadWorkspaceProfiles,
5
- recordDeployment,
6
- resolveWorkspaceRoot,
7
- } from '#profile';
8
- import {
9
- applyBlobs,
10
- applyPulls,
11
- applyPushes,
12
- planBlobs,
13
- planProfile,
14
- profilesInEither,
15
- resolved,
16
- type Prefer,
17
- type ProfileSync,
18
- } from '#deployments/sync-apply.ts';
19
- import { conflictsIn, type Change } from '#deployments/sync.ts';
20
- import { deployedWorkspace } from '#deployments/upload.ts';
1
+ import { ConfigError, readRegistry, recordTarget, resolveWorkspaceRoot } from '#profile';
21
2
  import { discoverDeployments, holdsWorkspace } from '#deployments/discover.ts';
22
- import { emit, heading, ok, print, style, waiting, warn } from '../output.ts';
3
+ import { emit, heading, ok, print, style, waiting } from '../output.ts';
23
4
  import { confirm, isInteractive } from '../prompt.ts';
24
5
  import type { GlobalFlags } from '../runtime.ts';
25
6
 
26
7
  /**
27
- * `lanes link sync targets` — the workspace and a target's copy of it, reconciled.
8
+ * `lanes link sync targets` — adopt a deployment this workspace has lost track of.
28
9
  *
29
- * A deploy leaves two copies of every profile: the one in the workspace and the
30
- * one in the bucket the endpoint reads. They are meant to agree, and when they
31
- * stop there was no way to find out, let alone to say which side had lost
32
- * something. The reported case: a local profile was rewritten and lost its
33
- * cloud target, `auth.authorization`, and six connections, while the bucket
34
- * still held every one of them and the service went on answering.
10
+ * **This used to reconcile two copies of every profile, and there is only one
11
+ * now.** A deploy left the workspace holding one copy and the bucket another,
12
+ * they were meant to agree, and when they stopped there was no way to say which
13
+ * side had lost something. The reported case, twice: a local profile was
14
+ * rewritten and lost its cloud target, `auth.authorization`, and six
15
+ * connections, while the bucket still held every one of them and the service
16
+ * went on answering.
35
17
  *
36
- * Union, and refuse where a union is impossible (ADR-044). Nothing here
37
- * overwrites a value that exists on both sides that is `--prefer`, asked for
38
- * explicitly, because silently choosing one copy over the other is the failure
39
- * this command was written in response to.
18
+ * ADR-052 removed the second copy rather than the disagreement. A profile lives
19
+ * in exactly one target's workspace, so the diff engine this command was built
20
+ * around `sync-apply.ts`, `sync.ts`, `--prefer local|remote` had nothing
21
+ * left to compare and is gone with contract 1.
22
+ *
23
+ * What survives is the half that was never about merging: **finding a
24
+ * deployment the local registry has no pointer to**, and writing that pointer.
25
+ * A new machine, a reinstall, a workspace file restored from something older —
26
+ * the endpoint is still serving, and what went missing is the line saying where
27
+ * it lives. That is one write, and it cannot lose anything, because the bucket
28
+ * is authoritative for everything except its own address.
29
+ *
30
+ * `--prefer` is gone and is refused by name rather than ignored: it decided
31
+ * which side won a merge, and someone typing it is asking for behaviour this
32
+ * command no longer has.
40
33
  */
41
34
 
42
35
  export interface SyncFlags extends GlobalFlags {
@@ -47,22 +40,13 @@ export interface SyncFlags extends GlobalFlags {
47
40
  readonly prefer?: string | undefined;
48
41
  }
49
42
 
50
- function parsePrefer(value: string | undefined): Prefer | undefined {
51
- if (value === undefined) return undefined;
52
- if (value !== 'local' && value !== 'remote') {
53
- throw new ConfigError(`--prefer must be "local" or "remote", not "${value}"`);
54
- }
55
- return value;
56
- }
57
-
58
43
  /**
59
- * Where the target's copy lives, tried cheapest first.
44
+ * Where the target's workspace is, tried cheapest first.
60
45
  *
61
- * The order is the point. A declared target answers instantly and is right
62
- * whenever anything is; the index answers instantly and is right when the
63
- * declaration is what went missing; `--from` is for when the operator knows and
64
- * the workspace does not; and discovery is the one that works from nothing, at
65
- * the cost of a `gcloud` call per project.
46
+ * The order is the point. An existing pointer answers instantly and is right
47
+ * whenever anything is; `--from` is for when the operator knows and the
48
+ * workspace does not; and discovery is the one that works from nothing, at the
49
+ * cost of a `gcloud` call per project.
66
50
  */
67
51
  async function locateRemote(
68
52
  root: string,
@@ -72,26 +56,20 @@ async function locateRemote(
72
56
  if (flags.from) {
73
57
  if (!(await holdsWorkspace(flags.from))) {
74
58
  throw new ConfigError(
75
- `${flags.from} does not hold a workspace — no ${'lanes-link.yaml'} in it.\n` +
59
+ `${flags.from} does not hold a workspace — no lanes-link.yaml in it.\n` +
76
60
  ' Check the bucket name, or run with --discover to search for one.',
77
61
  );
78
62
  }
79
63
  return { workspace: flags.from.replace(/\/$/, ''), how: '--from' };
80
64
  }
81
65
 
82
- for (const entry of (await loadWorkspaceProfiles(root)).loaded) {
83
- const declared = entry.config.targets[target];
84
- const workspace = declared ? deployedWorkspace(declared) : undefined;
85
- if (workspace) return { workspace, how: `declared by ${entry.profile}` };
86
- }
87
-
88
- const recorded = await findDeployment(root, target);
89
- if (recorded) return { workspace: recorded.workspace, how: 'recorded by a previous deploy' };
66
+ const entry = (await readRegistry(root))[target];
67
+ if (entry?.workspace) return { workspace: entry.workspace, how: 'already recorded' };
90
68
 
91
69
  if (flags.discover !== true) {
92
70
  throw new ConfigError(
93
71
  `Nothing here says where "${target}" lives.\n` +
94
- ' No profile declares it, and no deploy recorded it in lanes-link.yaml.\n\n' +
72
+ ' No pointer to it in lanes-link.yaml, and nothing else records one.\n\n' +
95
73
  ' If you know the bucket: lanes link sync targets --target ' +
96
74
  `${target} --from gs://<bucket>\n` +
97
75
  ` If you do not: lanes link sync targets --target ${target} --discover`,
@@ -99,9 +77,7 @@ async function locateRemote(
99
77
  }
100
78
 
101
79
  const candidates = (
102
- await waiting('searching your projects for a deployment', () =>
103
- discoverDeployments(),
104
- )
80
+ await waiting('searching your projects for a deployment', () => discoverDeployments())
105
81
  ).filter((candidate) => candidate.workspace !== undefined);
106
82
 
107
83
  if (candidates.length === 0) {
@@ -117,8 +93,8 @@ async function locateRemote(
117
93
  print(style.dim(` workspace ${candidate.workspace}`));
118
94
  }
119
95
 
120
- // One is offered rather than chosen: adopting the wrong deployment would
121
- // merge a stranger's config into this workspace.
96
+ // One is offered rather than chosen: adopting the wrong deployment would point
97
+ // this workspace at a stranger's accounts.
122
98
  const first = candidates[0]!;
123
99
  if (candidates.length > 1 || !isInteractive()) {
124
100
  throw new ConfigError(
@@ -127,136 +103,92 @@ async function locateRemote(
127
103
  );
128
104
  }
129
105
 
130
- if (!(await confirm(` Sync "${target}" against ${first.workspace}?`))) {
106
+ if (!(await confirm(` Point "${target}" at ${first.workspace}?`))) {
131
107
  throw new ConfigError('Nothing was read or written.');
132
108
  }
133
109
 
134
110
  return { workspace: first.workspace!, how: 'discovered' };
135
111
  }
136
112
 
137
- const arrow = (direction: Change['direction']): string =>
138
- direction === 'pull' ? style.green('←') : direction === 'push' ? style.cyan('→') : style.yellow('!');
139
-
140
- const describe = (change: Change): string => {
141
- const path = change.path.length === 0 ? 'the whole profile' : change.path.join('.');
142
- if (change.direction === 'pull') return `${path} ${style.dim('missing locally')}`;
143
- if (change.direction === 'push') return `${path} ${style.dim('missing remotely')}`;
144
- return `${path} ${style.dim(`local ${short(change.local)} ≠ remote ${short(change.remote)}`)}`;
145
- };
146
-
147
- const short = (value: unknown): string => {
148
- const text = typeof value === 'string' ? value : JSON.stringify(value);
149
- return (text ?? 'nothing').length > 40 ? `${(text ?? '').slice(0, 37)}…` : (text ?? 'nothing');
150
- };
151
-
152
113
  export async function syncTargets(flags: SyncFlags): Promise<void> {
153
114
  const target = flags.target!;
154
- const prefer = parsePrefer(flags.prefer);
155
115
  const root = resolveWorkspaceRoot();
156
116
 
157
- const { workspace: remote, how } = await locateRemote(root, target, flags);
117
+ if (flags.prefer !== undefined) {
118
+ throw new ConfigError(
119
+ '--prefer decided which of two copies of a profile won a merge, and there is\n' +
120
+ 'only one copy now: the one in the target\'s own workspace (ADR-052).\n' +
121
+ ' This command adopts a deployment, and adopting cannot overwrite anything.\n' +
122
+ ' Drop the flag and run it again.',
123
+ );
124
+ }
158
125
 
159
- const names = flags.profile ? [flags.profile] : await profilesInEither(root, remote);
160
- const plans: ProfileSync[] = [];
161
- for (const profile of names) plans.push(await planProfile(root, remote, profile, prefer));
126
+ const { workspace, how } = await locateRemote(root, target, flags);
162
127
 
163
- const blobs = await planBlobs(root, remote);
164
- const conflicts = [
165
- ...plans.flatMap((plan) => conflictsIn(plan.changes)),
166
- ...blobs.filter((blob) => blob.direction === 'conflict'),
167
- ];
128
+ // What is actually there, so an adoption cannot point at an empty bucket and
129
+ // report success. This is the one read the command makes, and it is also the
130
+ // check: a workspace that declares this target is a workspace that can serve
131
+ // it.
132
+ const remoteRegistry = await readRegistry(workspace);
133
+ const declaresIt = remoteRegistry[target] !== undefined;
134
+ const existing = (await readRegistry(root))[target];
135
+ const already = existing?.workspace === workspace;
168
136
 
169
- const payload = {
170
- workspace: root,
171
- remote,
172
- target,
173
- profiles: plans.map((plan) => ({ profile: plan.profile, changes: plan.changes })),
174
- blobs,
175
- conflicts: conflicts.length,
176
- applied: false,
177
- };
137
+ const payload = { workspace: root, remote: workspace, target, how, declaresIt, applied: false };
178
138
 
179
139
  const render = (): void => {
180
140
  print(style.dim(`workspace ${style.bold(root)} target ${style.bold(target)}`));
181
- print(style.dim(`remote ${remote} (${how})`));
182
-
183
- let anything = false;
184
- for (const plan of plans) {
185
- if (plan.changes.length === 0) continue;
186
- anything = true;
141
+ print(style.dim(`remote ${workspace} (${how})`));
142
+ print('');
187
143
 
188
- heading(plan.profile);
189
- for (const change of plan.changes) print(` ${arrow(change.direction)} ${describe(change)}`);
144
+ if (!declaresIt) {
145
+ const there = Object.keys(remoteRegistry).sort().join(', ') || 'none';
146
+ print(
147
+ style.yellow(
148
+ `${workspace} does not declare a target called "${target}" (it declares: ${there}).`,
149
+ ),
150
+ );
151
+ print(
152
+ style.dim(
153
+ ' Adopting it would write a pointer to a workspace that cannot answer for\n' +
154
+ ' this target. Check the name, or deploy it there first.',
155
+ ),
156
+ );
157
+ return;
190
158
  }
191
159
 
192
- if (blobs.length > 0) {
193
- anything = true;
194
- heading('Skills and manifests');
195
- for (const blob of blobs) print(` ${arrow(blob.direction)} ${blob.key}`);
160
+ if (already) {
161
+ print(ok('already pointed there — nothing to change'));
162
+ return;
196
163
  }
197
164
 
198
- if (!anything) print(ok('already in step — nothing to copy in either direction'));
165
+ heading('Would write');
166
+ print(` targets.${target}.workspace: ${workspace}`);
167
+ print(style.dim(' The bucket keeps everything else; this records where it is.'));
199
168
  };
200
169
 
201
- if (conflicts.length > 0 && prefer === undefined) {
170
+ if (!declaresIt) {
202
171
  render();
203
- print('');
204
- throw new ConfigError(
205
- `${conflicts.length} conflict(s): both copies hold these, and they disagree.\n` +
206
- ' Nothing was written. Re-run with --prefer local or --prefer remote,\n' +
207
- ' or edit one side so they agree.',
208
- );
209
- }
210
-
211
- if (flags.dryRun === true) {
212
- return emit(flags.json, payload, () => {
213
- render();
214
- print('');
215
- print(style.dim(' --dry-run: nothing was written on either side.'));
216
- });
172
+ throw new ConfigError(`"${target}" is not declared at ${workspace}.`);
217
173
  }
218
174
 
219
- if (flags.json !== true) render();
220
-
221
- let pulled = 0;
222
- let pushed = 0;
223
- for (const plan of plans) {
224
- const changes = resolved(plan.changes, prefer);
225
- pulled += await applyPulls(root, remote, plan.profile, changes);
226
- if (await applyPushes(root, remote, plan.profile, changes)) pushed += 1;
175
+ if (flags.dryRun || already) {
176
+ render();
177
+ if (flags.dryRun) print(style.dim(' --dry-run: nothing was written.'));
178
+ return emit(flags.json, payload, () => {});
227
179
  }
228
180
 
229
- const copied = await applyBlobs(
230
- root,
231
- remote,
232
- resolvedBlobs(blobs, prefer),
233
- );
181
+ render();
234
182
 
235
- // Recorded now that a bucket has been confirmed to hold this target's
236
- // workspace: the next recovery does not have to discover it again.
237
- await recordDeployment(root, { target, workspace: remote });
238
-
239
- return emit(flags.json, { ...payload, applied: true, pulled, pushed, copied }, () => {
240
- print('');
241
- if (pulled === 0 && pushed === 0 && copied === 0) return;
183
+ // Only the pointer. Everything that describes the target its adapters, its
184
+ // deploy block, whose token opens it is declared where it lives, and reading
185
+ // it from there is what makes this safe to run on a workspace that has lost
186
+ // its own copy of anything.
187
+ await recordTarget(root, target, { workspace });
242
188
 
243
- print(ok(`${pulled} key(s) pulled, ${pushed} profile(s) pushed, ${copied} file(s) copied`));
244
- print(style.dim(` recorded ${remote} in lanes-link.yaml, so this is findable next time`));
245
- if (pushed > 0) {
246
- print(style.dim(` the endpoint re-reads its config on the next call — or run:`));
247
- print(style.dim(` lanes link status --target ${target}`));
248
- }
249
- });
250
- }
189
+ print('');
190
+ print(ok(`"${target}" now points at ${workspace}`));
191
+ print(style.dim(` lanes link status --target ${target} reads it from there`));
251
192
 
252
- /** A conflicting file follows `--prefer` like a conflicting key does. */
253
- function resolvedBlobs(
254
- blobs: readonly { key: string; direction: 'pull' | 'push' | 'conflict' }[],
255
- prefer: Prefer | undefined,
256
- ): { key: string; direction: 'pull' | 'push' }[] {
257
- return blobs.flatMap((blob) => {
258
- if (blob.direction !== 'conflict') return [{ key: blob.key, direction: blob.direction }];
259
- if (prefer === undefined) return [];
260
- return [{ key: blob.key, direction: prefer === 'remote' ? ('pull' as const) : ('push' as const) }];
261
- });
193
+ return emit(flags.json, { ...payload, applied: true }, () => {});
262
194
  }