@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,263 @@
1
+ import { parseDocument } from 'yaml';
2
+ import {
3
+ ConfigError,
4
+ SUPPORTED_CONTRACT,
5
+ WORKSPACE_FILE,
6
+ layout,
7
+ listProfiles,
8
+ readWorkspaceFile,
9
+ isRemoteWorkspace,
10
+ workspaceFiles,
11
+ isLegacyProfile,
12
+ legacyConfigSchema,
13
+ writeWorkspaceFile,
14
+ type LegacyTarget,
15
+ type WorkspaceTarget,
16
+ } from '#profile';
17
+ import { ConfigDocument } from './config-edit.ts';
18
+ import { hoist, summarise } from './migrate-plan.ts';
19
+
20
+ /**
21
+ * Contract 1 → 2: the target moves out of the profile and into the workspace.
22
+ *
23
+ * Under contract 1 every profile carried a `targets:` block naming the adapter
24
+ * sets it could be opened against. That is what made a deploy leave two copies
25
+ * of each profile — one in `~/.lanes-link`, one in the bucket the endpoint reads
26
+ * — and gave them nothing to keep them honest. It failed the way it was always
27
+ * going to: a local file was rewritten, lost its cloud target and eight
28
+ * connections, and `status --target cloud` reported seven where the endpoint was
29
+ * serving fifteen. `sync targets` and ADR-044's deployment index were both
30
+ * written in response to earlier rounds of the same thing.
31
+ *
32
+ * ADR-052 removes the second copy. A workspace *is* a target: it declares its
33
+ * adapters once, in its own `lanes-link.yaml`, and holds the profiles that live
34
+ * in it. This is the one-way trip that gets an existing workspace there.
35
+ *
36
+ * **What it does, per workspace:** hoists each profile's target blocks into the
37
+ * workspace registry, strips `targets:` from the profile, and sets `contract: 2`.
38
+ * A block whose storage names a bucket becomes a *pointer* locally — the bucket
39
+ * declares it — and the adapters travel to that workspace when `deploy` next
40
+ * runs, which is the only command that can put them there safely.
41
+ *
42
+ * **Where it runs** follows `commands/operate/migrate.ts`'s reasoning: an
43
+ * operator should not have to know a migration command exists before their
44
+ * config breaks. So `update` runs it on the local workspace automatically,
45
+ * `doctor --fix` runs it on demand, and `deploy` runs it on the target workspace
46
+ * as its first step — which is what keeps a bucket and the image reading it in
47
+ * step, given contract 1 is not read at all.
48
+ */
49
+
50
+ export interface WorkspaceMigration {
51
+ readonly workspaceRoot: string;
52
+ /** Profiles rewritten to contract 2. */
53
+ readonly profiles: readonly string[];
54
+ /** Targets written into the registry, and how each was recorded. */
55
+ readonly targets: readonly { name: string; kind: 'declared' | 'pointer'; where?: string }[];
56
+ /** Human-readable lines, in the order they happened. */
57
+ readonly changes: readonly string[];
58
+ /** Nothing to do: every profile was already contract 2. */
59
+ readonly alreadyCurrent: boolean;
60
+ }
61
+
62
+ /** Whether this workspace still holds anything at contract 1. */
63
+ export async function needsMigration(workspaceRoot: string): Promise<boolean> {
64
+ for (const profile of await listProfiles(workspaceRoot)) {
65
+ const text = await readWorkspaceFile(workspaceFiles(workspaceRoot), `profiles/${profile}.yaml`);
66
+ if (text === null) continue;
67
+ try {
68
+ if (isLegacyProfile(parseDocument(text).toJSON())) return true;
69
+ } catch {
70
+ // A file that will not parse is not this function's problem to report.
71
+ // `check` gives it a better sentence than "needs migrating" would.
72
+ }
73
+ }
74
+ return false;
75
+ }
76
+
77
+ /**
78
+ * Migrate one workspace. Safe to run on an already-migrated one.
79
+ *
80
+ * **Everything that can fail happens before the first write.** A refusal leaves
81
+ * the workspace exactly as it was rather than half-migrated, which matters more
82
+ * here than usual: the thing being rewritten is the only remaining description
83
+ * of where somebody's accounts live.
84
+ */
85
+ export async function migrateWorkspace(
86
+ workspaceRoot: string,
87
+ options: { apply: boolean } = { apply: true },
88
+ ): Promise<WorkspaceMigration> {
89
+ const names = await listProfiles(workspaceRoot);
90
+ const legacy: { profile: string; document: ConfigDocument; targets: Record<string, LegacyTarget> }[] =
91
+ [];
92
+
93
+ for (const profile of names) {
94
+ const document = await ConfigDocument.open(workspaceRoot, profile);
95
+ const raw = document.toJSON();
96
+ if (!isLegacyProfile(raw)) continue;
97
+
98
+ const parsed = legacyConfigSchema.safeParse(raw);
99
+ if (!parsed.success) {
100
+ throw new ConfigError(
101
+ `${document.path} is contract 1 but its targets: block could not be read, so it cannot ` +
102
+ `be migrated:\n` +
103
+ parsed.error.issues.map((issue) => ` ${issue.path.join('.')}: ${issue.message}`).join('\n'),
104
+ );
105
+ }
106
+
107
+ legacy.push({ profile, document, targets: parsed.data.targets });
108
+ }
109
+
110
+ if (legacy.length === 0) {
111
+ return {
112
+ workspaceRoot,
113
+ profiles: [],
114
+ targets: [],
115
+ changes: [],
116
+ alreadyCurrent: true,
117
+ };
118
+ }
119
+
120
+ const registry = await hoist(legacy, workspaceRoot);
121
+
122
+ const changes: string[] = [];
123
+ for (const [name, entry] of Object.entries(registry)) {
124
+ changes.push(
125
+ entry.workspace !== undefined
126
+ ? `targets.${name}: pointer to ${entry.workspace}`
127
+ : `targets.${name}: declared in ${WORKSPACE_FILE}`,
128
+ );
129
+ }
130
+ for (const { profile } of legacy) changes.push(`profiles/${profile}.yaml: targets: removed, contract: 2`);
131
+
132
+ if (!options.apply) {
133
+ return {
134
+ workspaceRoot,
135
+ profiles: legacy.map((one) => one.profile),
136
+ targets: describe(registry),
137
+ changes,
138
+ alreadyCurrent: false,
139
+ };
140
+ }
141
+
142
+ // The registry first. A profile stripped of its targets while the workspace
143
+ // file has not learnt them yet is a profile nothing can open — and that is the
144
+ // window a crash would leave behind. This way round, the worst interruption
145
+ // leaves a workspace that declares its targets *and* profiles that still
146
+ // declare them too, which the next run reconciles by doing the same thing
147
+ // again.
148
+ await writeRegistry(workspaceRoot, registry);
149
+
150
+ for (const { document } of legacy) {
151
+ document.removeIn(['targets']);
152
+ document.setIn(['contract'], SUPPORTED_CONTRACT);
153
+ // `instance.default_target` went with the block it selected from. It has
154
+ // been inert since ADR-037 and there is now nothing for it to name.
155
+ document.removeIn(['instance', 'default_target']);
156
+ // Shape-only: a profile may carry an unrelated problem the full loader
157
+ // refuses — a connection row still spelling a renamed provider is the one
158
+ // that happens — and blocking the structural fix on it would strand the file
159
+ // at a contract nothing reads. `doctor --fix` names and repairs that one.
160
+ await document.save({ shapeOnly: true });
161
+ }
162
+
163
+ return {
164
+ workspaceRoot,
165
+ profiles: legacy.map((one) => one.profile),
166
+ targets: describe(registry),
167
+ changes,
168
+ alreadyCurrent: false,
169
+ };
170
+ }
171
+
172
+ /**
173
+ * Write the registry into the workspace file, creating it when absent.
174
+ *
175
+ * The whole object is assembled in plain JS and set once. Setting `targets` and
176
+ * then reaching back into it with `setIn(['targets', name, 'primary'])` does not
177
+ * work — the node written from a plain object is not one the document API will
178
+ * traverse — and the failure is a runtime "Expected YAML collection at targets"
179
+ * rather than anything a type would have caught.
180
+ */
181
+ async function writeRegistry(
182
+ workspaceRoot: string,
183
+ registry: Record<string, WorkspaceTarget>,
184
+ ): Promise<void> {
185
+ const files = workspaceFiles(workspaceRoot);
186
+ const text = (await readWorkspaceFile(files, WORKSPACE_FILE)) ?? `contract: ${SUPPORTED_CONTRACT}\n`;
187
+ const document = parseDocument(text);
188
+ const current = (document.toJSON() ?? {}) as {
189
+ targets?: Record<string, WorkspaceTarget>;
190
+ deployments?: {
191
+ target?: string;
192
+ workspace?: string;
193
+ primary?: string;
194
+ last_deploy?: string;
195
+ }[];
196
+ };
197
+
198
+ // Anything already in the file wins over what was hoisted: a workspace part
199
+ // way through this has entries that are already right, and re-deriving them
200
+ // from a profile that still carries a stale block would undo a correction.
201
+ const merged: Record<string, WorkspaceTarget> = { ...registry, ...(current.targets ?? {}) };
202
+
203
+ // ADR-044's index, folded into the entries it described. `primary` and
204
+ // `last_deploy` were kept beside the declaration precisely because the
205
+ // declaration could be lost; they belong on it now that it cannot be.
206
+ for (const record of current.deployments ?? []) {
207
+ if (!record.target) continue;
208
+ const entry = merged[record.target];
209
+ if (!entry) continue;
210
+ merged[record.target] = {
211
+ ...entry,
212
+ ...(record.primary ? { primary: record.primary } : {}),
213
+ ...(record.last_deploy ? { last_deploy: record.last_deploy } : {}),
214
+ };
215
+ }
216
+
217
+ document.setIn(['contract'], SUPPORTED_CONTRACT);
218
+ document.setIn(
219
+ ['targets'],
220
+ Object.fromEntries(Object.entries(merged).sort(([a], [b]) => a.localeCompare(b))),
221
+ );
222
+ document.deleteIn(['deployments']);
223
+ document.deleteIn(['default_target']);
224
+
225
+ await writeWorkspaceFile(files, WORKSPACE_FILE, String(document));
226
+ }
227
+
228
+ function describe(
229
+ registry: Record<string, WorkspaceTarget>,
230
+ ): { name: string; kind: 'declared' | 'pointer'; where?: string }[] {
231
+ return Object.entries(registry).map(([name, entry]) =>
232
+ entry.workspace !== undefined
233
+ ? { name, kind: 'pointer' as const, where: entry.workspace }
234
+ : { name, kind: 'declared' as const },
235
+ );
236
+ }
237
+
238
+ /**
239
+ * Refuse a contract-1 workspace with the command that fixes it.
240
+ *
241
+ * `readRegistry` answers `{}` for one, because the `targets:` block it reads did
242
+ * not exist until contract 2 — so without this every command refuses with "this
243
+ * workspace declares no targets", which is a true sentence pointing in exactly
244
+ * the wrong direction.
245
+ *
246
+ * `update` is named rather than `doctor --fix`, and that is not arbitrary:
247
+ * `doctor` needs a `--target`, and on an unmigrated workspace there is no target
248
+ * to give it. `update` takes neither flag and migrates the local workspace as
249
+ * part of what it already means (ADR-052).
250
+ */
251
+ export async function refuseIfUnmigrated(root: string): Promise<void> {
252
+ if (!(await needsMigration(root))) return;
253
+
254
+ throw new ConfigError(
255
+ `${root} is a contract 1 workspace, and this version does not read one.\n\n` +
256
+ ' Under contract 1 each profile declared its own targets. They are declared\n' +
257
+ ' once by the workspace now, which is what stopped a deploy leaving two\n' +
258
+ ' copies of every profile that could drift apart (ADR-052).\n\n' +
259
+ ' Migrate it: lanes link update\n' +
260
+ ' A deployed target is migrated by the deploy that ships the image reading it:\n' +
261
+ ' lanes link deploy --target <name>',
262
+ );
263
+ }
@@ -1,4 +1,13 @@
1
- import { ConfigError, type Config, type DeployConfig, type TargetConfig } from '#profile';
1
+ import {
2
+ ConfigError,
3
+ WORKSPACE_FILE,
4
+ declaredTarget,
5
+ readRegistry,
6
+ recordTarget,
7
+ type Config,
8
+ type DeployConfig,
9
+ type TargetConfig,
10
+ } from '#profile';
2
11
  import { VAULT_KEY_ENV, VAULT_KEY_REF } from '#secrets';
3
12
  import { ok, print, style } from '#cli/output.ts';
4
13
  import { isInteractive } from '#cli/prompt.ts';
@@ -75,7 +84,11 @@ export async function resolveTarget(input: {
75
84
  flags: TargetBootstrap;
76
85
  }): Promise<TargetConfig> {
77
86
  const { config, flags, target } = input;
78
- const declared = config.targets[target];
87
+ // From the workspace registry, not the profile. A profile declares no target
88
+ // (ADR-052), so the question "what does this workspace already know about
89
+ // `cloud`" is asked of the one file that answers it.
90
+ const entry = (await readRegistry(input.workspaceRoot))[target];
91
+ const declared = entry ? declaredTarget(entry) : undefined;
79
92
  const access = parseAccess(flags.access);
80
93
 
81
94
  const overrides = {
@@ -102,28 +115,35 @@ export async function resolveTarget(input: {
102
115
  adapters: declared === undefined,
103
116
  });
104
117
 
105
- const document = await ConfigDocument.open(input.workspaceRoot, input.profile);
118
+ // The target goes to the workspace registry; the authorization block goes to
119
+ // the profile. Two files, because they are two different kinds of fact: where
120
+ // this target's bytes live is true of every profile here, and whether *this*
121
+ // profile issues its own tokens is true of one.
122
+ // `withoutUndefined` and its deep sibling type their result `Partial<T>`,
123
+ // which is stricter than what they do: they drop keys whose value is
124
+ // `undefined`, and a key that was required was never undefined. The casts say
125
+ // that, rather than the shape being genuinely unknown here.
126
+ const entryToWrite = declared
127
+ ? { ...declared, deploy: withoutUndefined(surveyed.target.deploy!) as DeployConfig }
128
+ : (deepWithoutUndefined(surveyed.target) as unknown as TargetConfig);
106
129
 
107
- if (declared) {
108
- document.setIn(['targets', target, 'deploy'], withoutUndefined(surveyed.target.deploy!));
109
- } else {
110
- document.setIn(['targets', target], deepWithoutUndefined(surveyed.target));
111
- }
130
+ await recordTarget(input.workspaceRoot, target, entryToWrite);
112
131
 
113
132
  // `auth.authorization` is not part of the target and is written all the same:
114
133
  // the question that decides it is "will a remote client reach this", which
115
134
  // only a deploy is in a position to ask. See `SurveyResult`.
116
135
  if (surveyed.authorization) {
136
+ const document = await ConfigDocument.open(input.workspaceRoot, input.profile);
117
137
  document.setIn(['auth', 'authorization'], surveyed.authorization);
138
+ await document.save();
118
139
  }
119
- await document.save();
120
140
 
121
141
  print('');
122
142
  print(
123
143
  ok(
124
144
  declared
125
- ? `written to targets.${target}.deploy in ${input.profilePath}`
126
- : `written to targets.${target} in ${input.profilePath}`,
145
+ ? `written to targets.${target}.deploy in ${WORKSPACE_FILE}`
146
+ : `written to targets.${target} in ${WORKSPACE_FILE}`,
127
147
  ),
128
148
  );
129
149
  if (surveyed.authorization) {
@@ -1,4 +1,10 @@
1
- import { ConfigError, recordDeployment, resolveWorkspaceRoot, type DeployConfig } from '#profile';
1
+ import {
2
+ ConfigError,
3
+ recordTarget,
4
+ resolveTargetWorkspace,
5
+ resolveWorkspaceRoot,
6
+ type DeployConfig,
7
+ } from '#profile';
2
8
  import { announce, fail, heading, ok, print, style, warn } from '#cli/output.ts';
3
9
  import { staleNudge } from '#cli/release.ts';
4
10
  import { confirm, isInteractive } from '#cli/prompt.ts';
@@ -8,8 +14,8 @@ import { printSteps, runSteps } from './steps.ts';
8
14
  import { driverFor } from './drivers.ts';
9
15
  import { prepareSecrets, readableRefs, rotatableRefs } from './prepare.ts';
10
16
  import { repairOwnerLayer } from '#cli/config-repair.ts';
17
+ import { migrateWorkspace } from '#cli/workspace-migrate.ts';
11
18
  import { deployedWorkspace, uploadWorkspace } from './upload.ts';
12
- import { unservableProfiles, unservableRefusal } from './servable.ts';
13
19
  import { collidingRefs, collisionRefusal, servingProfiles } from './serving.ts';
14
20
  import { healthLine, reachability, registerLine, reportUnauthorised } from './report.ts';
15
21
 
@@ -56,6 +62,23 @@ export interface DeployFlags extends GlobalFlags {
56
62
  }
57
63
 
58
64
  export async function deploy(flags: DeployFlags): Promise<void> {
65
+ // **The target's own workspace is migrated before anything resolves it.**
66
+ //
67
+ // This is the first thing the command does, and it has to be. `deploy` is what
68
+ // the refusal on a contract-1 bucket tells you to run — and every other read of
69
+ // that bucket goes through `openTarget`, which refuses it for the same reason.
70
+ // Migrating after resolution made the instruction circular: the command named
71
+ // as the fix could not get past the problem it fixes.
72
+ //
73
+ // `resolveTargetWorkspace` is the one lookup that does not need the far end to
74
+ // declare anything: it reads this machine's pointer and stops. So the bucket is
75
+ // located, migrated, and only then opened.
76
+ //
77
+ // Idempotent, and silent on a workspace already at contract 2 — a listing and
78
+ // no writes. `--dry-run` reports what it would do and writes nothing, like
79
+ // every other step of this command.
80
+ if (!(await migrateTargetWorkspace(requireTargetFlag(flags), flags.dryRun !== true))) return;
81
+
59
82
  // The one command allowed to name a target that does not exist yet: creating
60
83
  // it is what a first deploy is for.
61
84
  //
@@ -200,6 +223,7 @@ export async function deploy(flags: DeployFlags): Promise<void> {
200
223
  const credentials = await openSecretStoreFor(config, resolution.workspaceRoot, target);
201
224
  const prepared = await prepareSecrets({
202
225
  config,
226
+ declared,
203
227
  credentials,
204
228
  root: resolution.workspaceRoot,
205
229
  target,
@@ -237,25 +261,12 @@ export async function deploy(flags: DeployFlags): Promise<void> {
237
261
  // the upload sends would leave a served profile without the surface — this
238
262
  // bug again, one profile over.
239
263
  if (workspace) {
240
- // Before anything is copied, and before the rollout: the endpoint opens
241
- // every profile in the bucket against this one target, so a profile that
242
- // does not declare it is not a profile that gets skipped it is a revision
243
- // that never goes healthy. `servable.ts` has the whole failure.
244
- //
245
- // Here rather than at the top of the command because it reads the same
246
- // scope the upload does, and that scope is not settled until `workspace`
247
- // says there is a bucket to send to at all.
248
- const unservable = await unservableProfiles({
249
- workspaceRoot: resolution.workspaceRoot,
250
- profiles: serving,
251
- target,
252
- });
253
-
254
- if (unservable.length > 0) {
255
- heading('Cannot be served');
256
- throw new ConfigError(unservableRefusal(unservable, target));
257
- }
258
-
264
+ // The pre-flight that used to sit here is gone with contract 1. It refused
265
+ // a deploy carrying a profile that did not declare the target, because the
266
+ // endpoint opens every profile in the bucket against one target and one
267
+ // that could not run on it took the whole revision down. A profile declares
268
+ // no target now (ADR-052) — it lives in one — so there is no profile in this
269
+ // workspace that this target cannot open, and nothing left to check.
259
270
  await repairOwnerLayer(resolution.workspaceRoot, serving);
260
271
 
261
272
  // Before the rollout, so the revision that comes up finds a config to read.
@@ -263,14 +274,40 @@ export async function deploy(flags: DeployFlags): Promise<void> {
263
274
  // workspace it was told to read is not there yet.
264
275
  await uploadWorkspace(resolution.workspaceRoot, workspace, serving);
265
276
 
266
- // Recorded once the bucket is known to hold this target's config. It is an
267
- // index, not configuration (ADR-044) — the next recovery reads it instead
268
- // of asking the platform.
269
- await recordDeployment(resolution.workspaceRoot, {
270
- target,
277
+ // **The bucket's own migration, here and nowhere else.**
278
+ //
279
+ // A second pass, and it is not redundant. The one at the top of the command
280
+ // migrated whatever the bucket already held; this catches what the upload
281
+ // just put there — profiles from a workspace that is itself at contract 2
282
+ // arrive migrated, but a first deploy of a *newly created* bucket writes
283
+ // them here for the first time. Idempotent, so the ordinary case is one
284
+ // listing and no writes.
285
+ await migrateWorkspace(workspace, { apply: true });
286
+
287
+ // **The target hands itself over to the workspace it now lives in.**
288
+ //
289
+ // Two writes, and the order matters. The declaration goes into the bucket's
290
+ // own `lanes-link.yaml` first, because that is the file the revision coming
291
+ // up will read to learn where its credentials and bytes are — and a
292
+ // revision that boots before it lands has nothing to open.
293
+ //
294
+ // The local entry then becomes a *pointer*. That is the whole of ADR-052 in
295
+ // two lines: after this, exactly one file declares this target, and this
296
+ // machine holds a reference to it rather than a copy. ADR-044's index
297
+ // existed because the profile's own block was the only record and could be
298
+ // lost in one edit; there is no second copy left to lose.
299
+ const stamped = new Date().toISOString();
300
+
301
+ await recordTarget(workspace, target, {
302
+ ...declared,
303
+ primary: resolution.profile,
304
+ last_deploy: stamped,
305
+ });
306
+
307
+ await recordTarget(resolution.workspaceRoot, target, {
271
308
  workspace,
272
309
  primary: resolution.profile,
273
- last_deploy: new Date().toISOString(),
310
+ last_deploy: stamped,
274
311
  });
275
312
  }
276
313
 
@@ -303,3 +340,42 @@ function requireTargetFlag(flags: DeployFlags): string {
303
340
  }
304
341
  return flags.target;
305
342
  }
343
+
344
+ /**
345
+ * Bring a target's own workspace to the current contract, before it is opened.
346
+ *
347
+ * Deliberately tolerant of a target this workspace has no pointer for: that is a
348
+ * first deploy, where there is nothing to migrate and `deploy` is about to create
349
+ * the workspace itself.
350
+ *
351
+ * Narrated when it does something. This rewrites every profile in somebody's
352
+ * bucket, and a command that reshapes that silently is one they cannot audit
353
+ * afterwards.
354
+ */
355
+ async function migrateTargetWorkspace(target: string, apply: boolean): Promise<boolean> {
356
+ const root = resolveWorkspaceRoot();
357
+
358
+ const workspace = await resolveTargetWorkspace(root, target).catch(() => null);
359
+ if (workspace === null || workspace === root) return true;
360
+
361
+ const migrated = await migrateWorkspace(workspace, { apply });
362
+ if (migrated.alreadyCurrent) return true;
363
+
364
+ heading(apply ? 'Migrated' : 'Would migrate');
365
+ print(style.dim(` ${workspace}`));
366
+ for (const change of migrated.changes) print(` ${change}`);
367
+ if (apply) {
368
+ print(style.dim(' The revision this deploy rolls is the first that can read it.'));
369
+ return true;
370
+ }
371
+
372
+ // A dry run stops here rather than pressing on to survey and plan. Everything
373
+ // past this point opens the target, and the target is not readable until the
374
+ // migration above has actually happened — so continuing would report a second,
375
+ // confusing refusal about the thing the first paragraph just offered to fix.
376
+ print(style.dim(' Nothing was written, and nothing else was checked: the rest of this'));
377
+ print(style.dim(' command opens the target, which is not readable until this has run.'));
378
+ print('');
379
+ print(style.dim(` Run it for real: lanes link deploy --target ${target}`));
380
+ return false;
381
+ }
@@ -57,8 +57,11 @@ export async function openKnowledge(
57
57
  /** Injected for tests. The repository is the only thing these stores reach. */
58
58
  call?: FetchLike,
59
59
  ): Promise<KnowledgeStores | undefined> {
60
- const { declared, target } = input;
61
- const knowledge = declared.knowledge;
60
+ const { config, target } = input;
61
+ // On the profile since contract 2: it says where *this profile's* memory and
62
+ // skills live, and a profile lives in exactly one target (ADR-052), so the
63
+ // per-target spelling it replaced could no longer say anything extra.
64
+ const knowledge = config.knowledge;
62
65
  if (!knowledge) return undefined;
63
66
 
64
67
  const token = await requireSecret(
@@ -22,6 +22,8 @@ import { buildRegistryWithWorkspace, ensureProfileToken } from '#cli/runtime.ts'
22
22
 
23
23
  export interface PrepareInput {
24
24
  readonly config: Config;
25
+ /** The target's adapter set, from the workspace that declares it (ADR-052). */
26
+ readonly declared: TargetConfig;
25
27
  readonly credentials: SecretStore;
26
28
  readonly root: string;
27
29
  readonly target: string;
@@ -178,7 +180,7 @@ export async function prepareSecrets(input: PrepareInput): Promise<PrepareResult
178
180
  const warnings: string[] = [];
179
181
 
180
182
  await seedProfileToken({ config, credentials, readOnly, blocking });
181
- await seedVaultKey({ declared: config.targets[target], credentials, readOnly });
183
+ await seedVaultKey({ declared: input.declared, credentials, readOnly });
182
184
 
183
185
  // Connection credentials are written by `connect`, against a real account, in
184
186
  // a browser. Nothing here can produce one, and a deploy that stopped for one
@@ -1,7 +1,8 @@
1
1
  import {
2
2
  ConfigError,
3
- findDeployment,
3
+ listProfiles,
4
4
  loadWorkspaceProfiles,
5
+ readRegistry,
5
6
  type WorkspaceProfiles,
6
7
  } from '#profile';
7
8
  import { rotatableCredentialRefsFor } from '#registry';
@@ -17,10 +18,16 @@ import { buildRegistryWithWorkspace } from '#cli/runtime.ts';
17
18
  * profile did not declare yet — which the survey then offered to create
18
19
  * somewhere new. The way to deploy both was to know that already.
19
20
  *
20
- * The set is derived rather than guessed: it is every profile declaring the
21
- * target, which is exactly the set the endpoint will try to open. `--profile`
22
- * narrows it, and naming one is still how a first deploy works, because a
23
- * target nothing declares has no set to derive (ADR-043).
21
+ * The set is derived rather than guessed: it is every profile *in* the target's
22
+ * workspace, which is exactly the set the endpoint will try to open. `--profile`
23
+ * narrows it, and naming one is still how a first deploy works, because a target
24
+ * that does not exist yet has no workspace to derive from (ADR-043, ADR-052).
25
+ *
26
+ * It used to be "every profile declaring the target", read out of each profile's
27
+ * own file. That is the shape ADR-052 removed: the same question had a different
28
+ * answer per profile, so a rewritten file could drop a profile out of the set
29
+ * silently and the deploy would quietly send fewer profiles than the endpoint
30
+ * was serving.
24
31
  */
25
32
 
26
33
  export interface Serving {
@@ -42,23 +49,20 @@ export async function servingProfiles(input: {
42
49
  return { profiles: [...named], primary: named[0]! };
43
50
  }
44
51
 
45
- const workspace = await loadWorkspaceProfiles(workspaceRoot);
46
- const declaring = workspace.loaded
47
- .filter((entry) => target in entry.config.targets)
48
- .map((entry) => entry.profile);
52
+ const living = await listProfiles(workspaceRoot);
49
53
 
50
- if (declaring.length === 0) {
54
+ if (living.length === 0) {
51
55
  throw new ConfigError(
52
- `No profile declares "${target}", so there is no set to deploy.\n` +
56
+ `No profile lives in "${target}", so there is no set to deploy.\n` +
53
57
  ' A first deploy creates the target, and has to be told which profile\n' +
54
58
  ' it belongs to:\n' +
55
59
  ` lanes link deploy --target ${target} --profile <name>\n\n` +
56
- ` If "${target}" was deployed before and the declaration was lost:\n` +
60
+ ` If "${target}" was deployed before and the pointer to it was lost:\n` +
57
61
  ` lanes link sync targets --target ${target} --discover`,
58
62
  );
59
63
  }
60
64
 
61
- return { profiles: declaring, primary: await choosePrimary(workspaceRoot, target, declaring) };
65
+ return { profiles: living, primary: await choosePrimary(workspaceRoot, target, living) };
62
66
  }
63
67
 
64
68
  /**
@@ -75,13 +79,13 @@ async function choosePrimary(
75
79
  target: string,
76
80
  declaring: readonly string[],
77
81
  ): Promise<string> {
78
- const recorded = (await findDeployment(workspaceRoot, target))?.primary;
82
+ const recorded = (await readRegistry(workspaceRoot))[target]?.primary;
79
83
  if (recorded !== undefined && declaring.includes(recorded)) return recorded;
80
84
 
81
85
  if (declaring.length === 1) return declaring[0]!;
82
86
 
83
87
  throw new ConfigError(
84
- `${declaring.length} profiles declare "${target}", and nothing records which\n` +
88
+ `${declaring.length} profiles live in "${target}", and nothing records which\n` +
85
89
  "of them owns the endpoint's token. One token opens the endpoint and\n" +
86
90
  'reaches every profile behind it, so this cannot be picked for you.\n\n' +
87
91
  ` Name it once and it is remembered:\n` +
@@ -1,4 +1,5 @@
1
1
  import {
2
+ openTarget,
2
3
  DATA_DIR,
3
4
  WORKSPACE_FILE,
4
5
  layout,
@@ -150,7 +151,15 @@ export async function publishWorkspace(input: {
150
151
  readonly target: string;
151
152
  readonly profile: string;
152
153
  }): Promise<string | null> {
153
- const declared = input.config.targets[input.target];
154
+ // Resolution failures are swallowed rather than thrown. The config edit that
155
+ // called this has already succeeded and is on disk; a target that cannot be
156
+ // resolved — undeclared, or a pointer to a bucket that is not answering — is
157
+ // something for `check` and `status` to report, not a reason to fail an edit
158
+ // that is done. Returning null is "published nowhere", which is exactly what
159
+ // happened.
160
+ const declared = await openTarget(input.workspaceRoot, input.target)
161
+ .then((resolved) => resolved.declared)
162
+ .catch(() => undefined);
154
163
  if (!declared) return null;
155
164
 
156
165
  const destination = deployedWorkspace(declared);