@lanes-sh/link 0.5.3 → 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 (59) 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/custom/index.ts +2 -0
  5. package/src/cli/commands/connect/custom/spec.ts +1 -0
  6. package/src/cli/commands/connect/declare.ts +76 -0
  7. package/src/cli/commands/connect/family.ts +19 -3
  8. package/src/cli/commands/connect/index.ts +23 -24
  9. package/src/cli/commands/connect/outcome.ts +8 -0
  10. package/src/cli/commands/connect/settle.ts +94 -22
  11. package/src/cli/commands/connect/target-note.ts +2 -2
  12. package/src/cli/commands/connection.ts +18 -7
  13. package/src/cli/commands/knowledge/index.ts +14 -34
  14. package/src/cli/commands/mcp/register.ts +1 -1
  15. package/src/cli/commands/operate/dashboard.ts +2 -2
  16. package/src/cli/commands/operate/inspect.ts +5 -1
  17. package/src/cli/commands/operate/migrate.ts +85 -1
  18. package/src/cli/commands/operate/outputs.ts +7 -22
  19. package/src/cli/commands/operate/status.ts +95 -70
  20. package/src/cli/commands/operate/tools.ts +1 -1
  21. package/src/cli/commands/profile/removal.ts +36 -25
  22. package/src/cli/commands/profile/remove.ts +5 -2
  23. package/src/cli/commands/profile.ts +56 -40
  24. package/src/cli/commands/sync.ts +94 -162
  25. package/src/cli/commands/target.ts +115 -74
  26. package/src/cli/commands/update.ts +56 -1
  27. package/src/cli/config-edit.ts +47 -16
  28. package/src/cli/dashboard-page.ts +10 -1
  29. package/src/cli/endpoint-url.ts +3 -3
  30. package/src/cli/main.ts +11 -7
  31. package/src/cli/publish.ts +4 -2
  32. package/src/cli/runtime/open.ts +20 -10
  33. package/src/cli/runtime/select.ts +69 -22
  34. package/src/cli/selection-require.ts +79 -0
  35. package/src/cli/selection.ts +45 -92
  36. package/src/cli/usage.ts +3 -0
  37. package/src/cli/workspace-migrate.ts +385 -0
  38. package/src/deployments/bootstrap.ts +31 -11
  39. package/src/deployments/deploy.ts +54 -27
  40. package/src/deployments/knowledge.ts +5 -2
  41. package/src/deployments/prepare.ts +3 -1
  42. package/src/deployments/serving.ts +19 -15
  43. package/src/deployments/upload.ts +10 -1
  44. package/src/profile/deployments.ts +64 -53
  45. package/src/profile/index.ts +22 -6
  46. package/src/profile/legacy.ts +92 -0
  47. package/src/profile/load.ts +12 -28
  48. package/src/profile/registry.ts +182 -0
  49. package/src/profile/schema.ts +171 -110
  50. package/src/profile/targets.ts +62 -91
  51. package/src/profile/testing.ts +78 -0
  52. package/src/profile/workspace.ts +11 -25
  53. package/src/providers/setup/provider.ts +10 -2
  54. package/src/server/dashboard.ts +5 -1
  55. package/src/server/harness.ts +1 -6
  56. package/src/cli/commands/profile/declare.ts +0 -154
  57. package/src/deployments/servable.ts +0 -82
  58. package/src/deployments/sync-apply.ts +0 -330
  59. package/src/deployments/sync.ts +0 -164
@@ -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
  }