@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
@@ -1,10 +1,12 @@
1
1
  import { describeKnowledge } from '#deployments/knowledge.ts';
2
2
  import {
3
- loadProfileConfig,
4
- resolveSelection,
5
- undeclaredTarget,
6
- type Config,
7
- type ProfileSelection,
3
+ isPointer,
4
+ listProfiles,
5
+ notInRegistry,
6
+ openTarget,
7
+ readRegistry,
8
+ resolveWorkspaceRoot,
9
+ type WorkspaceTarget,
8
10
  } from '#profile';
9
11
  import { ConfigError } from '#profile';
10
12
  import { deployedUrl, deploymentIdentity, type DeploymentIdentity } from '../endpoint-url.ts';
@@ -13,18 +15,23 @@ import { announce, emit, heading, ok, print, style, table, waiting, warn } from
13
15
  import type { GlobalFlags } from '../runtime.ts';
14
16
 
15
17
  /**
16
- * `lanes link target` — the adapter sets a profile declares, and which one is in play.
18
+ * `lanes link target` — the targets this workspace knows, and where each lives.
17
19
  *
18
- * A target names *where a profile runs*: a credential store and a blob store,
19
- * and optionally a deployment. Connections, providers, policy and limits are
20
- * declared once and apply to every target, so changing one changes where the
21
- * bytes go and nothing above them.
20
+ * A target names *a workspace*: a credential store, a blob store, optionally a
21
+ * deployment, and the profiles that live in it. Under ADR-052 it is declared
22
+ * once, by the workspace that is it not once per profile, which is what let
23
+ * two profiles disagree about whether a running deployment existed.
22
24
  *
23
- * Two answers are worth telling apart and this is the only place that does.
24
- * `instance.default_target` is what commands run against when nobody says, and
25
- * it lives in the profile. `LANES_LINK_TARGET` is what *this shell* says, and it
26
- * wins. When they disagree — the case that sends someone hunting a bug — `list`
27
- * marks both and names the variable.
25
+ * So this command no longer takes `--profile`. It never really wanted one: the
26
+ * question is "what can I pass to --target", and that had the same answer for
27
+ * every profile in all but the broken cases.
28
+ *
29
+ * **`list` does not follow pointers.** A registry entry either declares its
30
+ * adapters here or names the workspace that does, and following the second kind
31
+ * is a network read per entry. `list` prints what the registry says, so it stays
32
+ * instant and works offline; `show` follows one target and reports what is
33
+ * really there. That split is why a listing can be trusted when the bucket is
34
+ * unreachable — which is exactly when someone is running it.
28
35
  *
29
36
  * Each command is a data function plus a printing wrapper, the split
30
37
  * `profile.ts` uses and for the same reason: `--json` wants the facts without
@@ -35,9 +42,18 @@ export interface TargetSummary {
35
42
  readonly name: string;
36
43
  /** Whether this is the one `--target` named, when it named any. */
37
44
  readonly isSelected: boolean;
38
- readonly credentials: string;
39
- readonly storage: string;
40
- readonly vault: string;
45
+ /**
46
+ * Where the declaration lives: `null` for one this workspace makes itself,
47
+ * otherwise the workspace it points at.
48
+ *
49
+ * The whole listing hangs off this. A pointer's adapters are not read by
50
+ * `list`, so every field below it is null for one — which is honest rather
51
+ * than lossy: they are somewhere else, and `show` is the command that goes.
52
+ */
53
+ readonly pointsAt: string | null;
54
+ readonly credentials: string | null;
55
+ readonly storage: string | null;
56
+ readonly vault: string | null;
41
57
  /**
42
58
  * Where memory and skills are kept, when that is not `storage` above.
43
59
  *
@@ -47,7 +63,7 @@ export interface TargetSummary {
47
63
  * the log and the state and false of the owner's own notes.
48
64
  */
49
65
  readonly knowledge: string | null;
50
- /** Whether a deployment is declared. Free, and always present. */
66
+ /** Whether a deployment is declared. Unknown, and false, for a pointer. */
51
67
  readonly deployed: boolean;
52
68
  readonly deployment: DeploymentIdentity | null;
53
69
  /** Only when asked for; absent is "not asked", null is "asked, no answer". */
@@ -56,8 +72,6 @@ export interface TargetSummary {
56
72
 
57
73
  export interface TargetListing {
58
74
  readonly root: string;
59
- readonly profile: string;
60
- readonly path: string;
61
75
  /** What `--target` named, when it named anything. */
62
76
  readonly selected: string | null;
63
77
  /**
@@ -76,64 +90,79 @@ export interface TargetFlags extends GlobalFlags {
76
90
  }
77
91
 
78
92
  /**
79
- * The profile, its config, and what it declares — in one pass.
93
+ * The registry, and what each entry says about itself — in one pass.
80
94
  *
81
95
  * **Deliberately not `resolveProfile`.** That helper resolves the target the way
82
96
  * every other command needs it resolved: by refusing a name that is not
83
- * declared. Here that is exactly backwards. `LANES_LINK_TARGET=clod` is the
84
- * state in which every other command has just started failing, and this is the
85
- * command someone runs to find out why — so it has to survive the condition it
86
- * exists to diagnose. It resolves the name with `askedTarget` and reports
87
- * whether it landed on anything, rather than throwing. `readProfiles` in
88
- * `profile.ts` declines to parse configs for the same reason.
97
+ * declared. Here that is exactly backwards. `--target clod` is the state in
98
+ * which every other command has just started failing, and this is the command
99
+ * someone runs to find out why — so it has to survive the condition it exists to
100
+ * diagnose. It reports whether the name landed on anything rather than throwing.
101
+ * `readProfiles` in `profile.ts` declines to parse configs for the same reason.
102
+ *
103
+ * It reads no profile at all now. The registry is the answer, and it is one file
104
+ * (ADR-052).
89
105
  */
90
106
  async function survey(
91
107
  flags: TargetFlags,
92
108
  options: { urls?: boolean; env?: Record<string, string | undefined> } = {},
93
- ): Promise<{ selection: ProfileSelection; config: Config; listing: TargetListing }> {
94
- const selection = await resolveSelection({
95
- ...(flags.profile !== undefined ? { profileFlag: flags.profile } : {}),
96
- ...(options.env !== undefined ? { env: options.env } : {}),
97
- });
98
-
99
- const { config } = await loadProfileConfig(selection.workspaceRoot, selection.profile);
109
+ ): Promise<{ root: string; listing: TargetListing }> {
110
+ const root = resolveWorkspaceRoot(options.env !== undefined ? { env: options.env } : {});
111
+ const registry = await readRegistry(root);
100
112
 
101
113
  const selected = flags.target ?? null;
102
- const names = Object.keys(config.targets);
114
+ const names = Object.keys(registry).sort();
103
115
 
104
116
  const summaries: TargetSummary[] = names.map((name) => {
105
- const declared = config.targets[name]!;
106
- return {
107
- name,
108
- isSelected: name === selected,
109
- credentials: declared.credentials.adapter,
110
- storage: declared.storage.adapter,
111
- vault: declared.vault?.adapter ?? 'file',
112
- knowledge: declared.knowledge ? describeKnowledge(declared.knowledge) : null,
113
- deployed: declared.deploy !== undefined,
114
- deployment: deploymentIdentity(declared.deploy),
115
- };
117
+ const entry = registry[name]!;
118
+ return summarise(name, entry, name === selected);
116
119
  });
117
120
 
118
121
  return {
119
- selection,
120
- config,
122
+ root,
121
123
  listing: {
122
- root: selection.workspaceRoot,
123
- profile: selection.profile,
124
- path: selection.profilePath,
124
+ root,
125
125
  selected,
126
126
  selectedDeclared: selected === null || names.includes(selected),
127
127
  // Asking the platform is opt-in: one `gcloud` subprocess per deployable
128
- // target, and the profiles that make this command worth running are
128
+ // target, and the workspaces that make this command worth running are
129
129
  // exactly the ones with several. A discovery command that takes ten
130
130
  // seconds and needs a cloud CLI installed is one nobody runs twice —
131
131
  // `outputs --target X` is already the command that asks.
132
- targets: options.urls === true ? await withUrls(config, summaries) : summaries,
132
+ targets: options.urls === true ? await withUrls(registry, summaries) : summaries,
133
133
  },
134
134
  };
135
135
  }
136
136
 
137
+ /** One entry, rendered without following it. */
138
+ function summarise(name: string, entry: WorkspaceTarget, isSelected: boolean): TargetSummary {
139
+ if (isPointer(entry)) {
140
+ return {
141
+ name,
142
+ isSelected,
143
+ pointsAt: entry.workspace,
144
+ credentials: null,
145
+ storage: null,
146
+ vault: null,
147
+ knowledge: null,
148
+ deployed: false,
149
+ deployment: null,
150
+ };
151
+ }
152
+
153
+ return {
154
+ name,
155
+ isSelected,
156
+ pointsAt: null,
157
+ credentials: entry.credentials?.adapter ?? null,
158
+ storage: entry.storage?.adapter ?? null,
159
+ vault: entry.vault?.adapter ?? 'file',
160
+ knowledge: null,
161
+ deployed: entry.deploy !== undefined,
162
+ deployment: deploymentIdentity(entry.deploy),
163
+ };
164
+ }
165
+
137
166
  /** What `--json` and the tests want, without the rendering. */
138
167
  export async function readTargets(
139
168
  flags: TargetFlags,
@@ -144,7 +173,7 @@ export async function readTargets(
144
173
 
145
174
  /** Every deployable target's address, asked for at once rather than in turn. */
146
175
  async function withUrls(
147
- config: Config,
176
+ registry: Record<string, WorkspaceTarget>,
148
177
  summaries: readonly TargetSummary[],
149
178
  ): Promise<TargetSummary[]> {
150
179
  return waiting('asking the platform for addresses', () =>
@@ -154,7 +183,7 @@ async function withUrls(
154
183
  // Resolves to null immediately for a target with no deployment, and
155
184
  // swallows a missing or unauthenticated `gcloud` — neither is a reason
156
185
  // for a listing to fail.
157
- url: await deployedUrl(config.targets[summary.name]?.deploy),
186
+ url: await deployedUrl(registry[summary.name]?.deploy),
158
187
  })),
159
188
  ),
160
189
  );
@@ -164,16 +193,23 @@ export async function targetList(flags: TargetFlags): Promise<void> {
164
193
  const { listing } = await survey(flags, { urls: flags.urls === true });
165
194
 
166
195
  return emit(flags.json, listing, () => {
167
- print(style.dim(`${listing.profile} ${listing.path}`));
196
+ print(style.dim(listing.root));
168
197
  print();
169
198
 
170
199
  table(
171
200
  listing.targets.map((target) => [
172
201
  ` ${target.isSelected ? style.cyan('→') : ' '}`,
173
202
  style.bold(target.name),
174
- style.dim(target.credentials),
175
- style.dim(target.storage),
176
- deploymentCell(target),
203
+ // A pointer says where it lives instead of what it is made of. Its
204
+ // adapters are declared in that workspace and reading them is a network
205
+ // call `list` deliberately does not make.
206
+ ...(target.pointsAt !== null
207
+ ? [style.dim(target.pointsAt), '', '']
208
+ : [
209
+ style.dim(target.credentials ?? ''),
210
+ style.dim(target.storage ?? ''),
211
+ deploymentCell(target),
212
+ ]),
177
213
  ]),
178
214
  );
179
215
 
@@ -233,13 +269,15 @@ export function targetUse(name: string | undefined): never {
233
269
  /**
234
270
  * `lanes link target show [name]` — one target's adapters, and where it answers.
235
271
  *
236
- * The deep counterpart to `list`, and the one that *does* ask the platform: one
237
- * target, one subprocess, and you named it. Nothing else prints a target's
238
- * adapter set `config show` dumps the whole file as JSON which is what earns
239
- * this its place beside `outputs`.
272
+ * The deep counterpart to `list`, and the one that *does* go and look: it
273
+ * follows a pointer to the workspace that declares the target, then asks the
274
+ * platform for the address. One target, one hop, one subprocess, and you named
275
+ * it. Nothing else prints a target's adapter set — `config show` dumps a
276
+ * profile, which no longer carries one — which is what earns this its place
277
+ * beside `outputs`.
240
278
  */
241
279
  export async function targetShow(name: string | undefined, flags: TargetFlags): Promise<void> {
242
- const { config, listing } = await survey(flags);
280
+ const { root, listing } = await survey(flags);
243
281
 
244
282
  // Positionally or by flag, but one of them: this command's whole subject is a
245
283
  // single target, and there is no default left to mean "the one you would have
@@ -247,28 +285,31 @@ export async function targetShow(name: string | undefined, flags: TargetFlags):
247
285
  const wanted = name ?? listing.selected;
248
286
  if (!wanted) {
249
287
  throw new ConfigError(
250
- 'Usage: lanes link target show <name> --profile <name>\n' +
288
+ 'Usage: lanes link target show <name>\n' +
251
289
  ` Declared here: ${listing.targets.map((one) => one.name).join(', ') || 'none'}`,
252
290
  );
253
291
  }
254
292
 
255
- const summary = listing.targets.find((candidate) => candidate.name === wanted);
256
- if (!summary) throw undeclaredTarget(wanted, config, listing.profile);
293
+ const resolved = await openTarget(root, wanted);
294
+ const summary = summarise(wanted, { ...resolved.declared, ...resolved.entry }, true);
295
+ const profiles = await listProfiles(resolved.workspaceRoot);
257
296
 
258
297
  const url = summary.deployment
259
298
  ? await waiting('asking the platform for an address', () =>
260
- deployedUrl(config.targets[wanted]?.deploy),
299
+ deployedUrl(resolved.declared.deploy),
261
300
  )
262
301
  : null;
263
302
 
264
- return emit(flags.json, { ...summary, url }, () => {
265
- print(style.dim(`${listing.profile} ${listing.path}`));
303
+ return emit(flags.json, { ...summary, workspace: resolved.workspaceRoot, profiles, url }, () => {
304
+ print(style.dim(resolved.workspaceRoot));
266
305
 
267
306
  heading(summary.name);
268
307
  table([
269
- [' credentials', summary.credentials],
270
- [' storage', summary.storage],
271
- [' vault', summary.vault],
308
+ [' workspace', resolved.workspaceRoot],
309
+ [' profiles', profiles.join(', ') || style.dim('none yet')],
310
+ [' credentials', summary.credentials ?? ''],
311
+ [' storage', summary.storage ?? ''],
312
+ [' vault', summary.vault ?? ''],
272
313
  ...(summary.knowledge
273
314
  ? [[' knowledge', summary.knowledge, style.dim('memory and skills')]]
274
315
  : []),
@@ -2,6 +2,7 @@ import { homedir } from 'node:os';
2
2
  import { join, sep } from 'node:path';
3
3
  import { installRoot, resolveWorkspaceRoot } from '#profile';
4
4
  import { repairOwnerLayer } from '../config-repair.ts';
5
+ import { migrateWorkspace, needsMigration } from '../workspace-migrate.ts';
5
6
  import { emit, fail, ok, print, printErr, progress, style, warn } from '../output.ts';
6
7
  import { PACKAGE, release, type ReleaseState } from '../release.ts';
7
8
  import { version } from '../version.ts';
@@ -169,7 +170,21 @@ export async function update(flags: UpdateFlags): Promise<void> {
169
170
  // profile of someone already on the latest version is exactly the one this was
170
171
  // reported against.
171
172
  if (flags.check !== true) {
172
- await repairOwnerLayer(resolveWorkspaceRoot(), undefined, {
173
+ const root = resolveWorkspaceRoot();
174
+
175
+ // The contract migration before the owner-layer repair, and the order is not
176
+ // cosmetic: the repair opens profiles through the ordinary loader, and the
177
+ // loader refuses contract 1 outright (ADR-052). On a workspace that has not
178
+ // been migrated the repair has nothing it can read.
179
+ //
180
+ // Local workspace only. A remote one is a bucket whose endpoint is running a
181
+ // pinned image, and migrating it from here would leave that revision reading
182
+ // a contract it does not implement until someone redeploys. `deploy` is what
183
+ // migrates a bucket, because it is the command that ships the image in the
184
+ // same breath.
185
+ await migrateLocal(root, flags.json === true ? progress : print);
186
+
187
+ await repairOwnerLayer(root, undefined, {
173
188
  ...(flags.json === true ? { report: progress } : {}),
174
189
  });
175
190
  }
@@ -275,3 +290,43 @@ async function runInstall(argv: readonly string[], json: boolean): Promise<boole
275
290
  return false;
276
291
  }
277
292
  }
293
+
294
+ /**
295
+ * Bring the local workspace to the current contract, saying so if it did.
296
+ *
297
+ * Narrated rather than silent: this rewrites every profile in the workspace, and
298
+ * a command that reshapes somebody's config without a word is one they cannot
299
+ * audit afterwards. Routed to `report` for `--json`, where a line of prose in
300
+ * front of the document corrupts whatever is parsing it — the same routing
301
+ * `repairOwnerLayer` takes, for the same reason.
302
+ *
303
+ * A failure here is reported and swallowed. `update`'s job is to install a
304
+ * version, and a workspace that cannot migrate — a custom path that will not
305
+ * hoist, two profiles disagreeing about one target — is a thing to be told
306
+ * about, in a sentence naming the fix, rather than a reason for the upgrade to
307
+ * fail. `check` and `doctor` both refuse loudly on the next run.
308
+ */
309
+ async function migrateLocal(root: string, say: (line: string) => void): Promise<void> {
310
+ try {
311
+ if (!(await needsMigration(root))) return;
312
+
313
+ const migration = await migrateWorkspace(root);
314
+ if (migration.alreadyCurrent) return;
315
+
316
+ say(
317
+ `migrated ${migration.profiles.length} profile(s) to contract 2 — a target is declared by ` +
318
+ 'the workspace now, not by each profile',
319
+ );
320
+ for (const change of migration.changes) say(` ${change}`);
321
+
322
+ const pointers = migration.targets.filter((one) => one.kind === 'pointer');
323
+ for (const pointer of pointers) {
324
+ say(
325
+ ` "${pointer.name}" points at ${pointer.where} — run ` +
326
+ `lanes link deploy --target ${pointer.name} to migrate what is there`,
327
+ );
328
+ }
329
+ } catch (error) {
330
+ say(`could not migrate this workspace: ${error instanceof Error ? error.message : String(error)}`);
331
+ }
332
+ }
@@ -5,6 +5,7 @@ import {
5
5
  isRemoteWorkspace,
6
6
  readWorkspaceFile,
7
7
  validateConfig,
8
+ validateConfigShape,
8
9
  workspaceFiles,
9
10
  writeWorkspaceFile,
10
11
  } from '#profile';
@@ -179,12 +180,28 @@ export class ConfigDocument {
179
180
  * Validation happens on the rendered text rather than the in-memory tree so
180
181
  * that what is checked is exactly what would land on disk.
181
182
  */
182
- async save(): Promise<void> {
183
+ /**
184
+ * `shapeOnly` validates the schema and the secret scan, and skips the
185
+ * referential checks.
186
+ *
187
+ * For the contract 1 → 2 migration, and nothing else. That migration rewrites
188
+ * the structure of a file which may *also* carry an unrelated problem the
189
+ * loader refuses — a connection row still spelling a renamed provider is the
190
+ * one that actually happens. Blocking the structural fix on the unrelated one
191
+ * would leave the file stuck at a contract nothing reads, which is a worse
192
+ * place to be than contract 2 with a stale row that `doctor --fix` names and
193
+ * repairs.
194
+ *
195
+ * The half it keeps is the half that matters for a write: the schema, and the
196
+ * scan that stops a credential value being written into config.
197
+ */
198
+ async save(options: { shapeOnly?: boolean } = {}): Promise<void> {
183
199
  const rendered = this.toString();
184
200
 
185
201
  // Throws on any validation failure, including a credential value that has
186
202
  // crept in — so a CLI edit can never introduce one.
187
- validateConfig(this.#document.toJSON(), this.#path);
203
+ if (options.shapeOnly === true) validateConfigShape(this.#document.toJSON(), this.#path);
204
+ else validateConfig(this.#document.toJSON(), this.#path);
188
205
 
189
206
  if (!this.#location) {
190
207
  throw new ConfigError(`${this.#path}: opened from text, so there is nowhere to save it`);
@@ -217,7 +234,7 @@ export class ConfigDocument {
217
234
  * Written with comments, because this is the file an operator will read first
218
235
  * and most of what it needs to say is *why*, not *what*.
219
236
  */
220
- export function newProfileTemplate(profile: string, port: number, targets: string): string {
237
+ export function newProfileTemplate(profile: string, port: number): string {
221
238
  return `# Lanes Link profile: ${profile}
222
239
  #
223
240
  # This file is the source of truth for what exists. It never contains a
@@ -226,23 +243,24 @@ export function newProfileTemplate(profile: string, port: number, targets: strin
226
243
  #
227
244
  # Edit it by hand or through the CLI; both are supported, and CLI edits
228
245
  # preserve your comments and ordering.
229
- contract: 1
246
+ contract: 2
230
247
 
231
248
  instance:
232
249
  profile: ${profile}
233
250
  port: ${port}
234
251
  host: 127.0.0.1
235
252
 
236
- # Adapter selection is per target, and every command names the one it means:
253
+ # This file says nothing about where it runs, and that is the point.
254
+ #
255
+ # A profile lives in exactly one target, and the target is the workspace holding
256
+ # this file — which declares its own adapters, once, in lanes-link.yaml beside
257
+ # the profiles/ directory (ADR-052). Moving this profile somewhere else is
258
+ # copying the file there; there is no block in it to edit.
259
+ #
260
+ # Every command still names both, because neither is inferred (ADR-037):
237
261
  #
238
262
  # lanes link status --profile ${profile} --target <name>
239
263
  #
240
- # There is no default. A target is chosen on the command line or not at all,
241
- # so a flag that goes missing fails here rather than quietly running somewhere
242
- # else (ADR-037). Everything below "targets" is target-independent and declared
243
- # exactly once.
244
- targets:
245
- ${targets}
246
264
  # The bearer token for the endpoint this profile serves.
247
265
  #
248
266
  # "lanes link start" serves every profile in the workspace from one URL, and this
@@ -323,10 +341,23 @@ export function newWorkspaceTemplate(): string {
323
341
  # every call names the profile it means, with --profile. Profiles never share a
324
342
  # database or a credential store, so what one holds is invisible to another.
325
343
  #
326
- # "deploy" adds a "deployments:" list here. It is an index, not configuration —
327
- # nothing resolves from it. It records where a deployment lives so that losing
328
- # the target block out of a profile does not lose the service, the bucket, and
329
- # the credential store along with it. "lanes link sync targets" reads it.
330
- contract: 1
344
+ # This workspace IS a target. "targets:" below says where its bytes go, once,
345
+ # for every profile in it a profile says nothing about where it runs, so
346
+ # there is one copy of it and nothing to keep in step (ADR-052).
347
+ #
348
+ # A target somewhere else is a pointer, and "deploy" writes one:
349
+ #
350
+ # targets:
351
+ # cloud:
352
+ # workspace: gs://your-bucket
353
+ #
354
+ # The workspace at that address declares its own adapters, and is the only
355
+ # thing that does. Reading it is a network call, which is why "--target cloud"
356
+ # needs that bucket reachable.
357
+ contract: 2
358
+ targets:
359
+ local:
360
+ credentials: { adapter: file }
361
+ storage: { adapter: filesystem }
331
362
  `;
332
363
  }
@@ -1,4 +1,4 @@
1
- import type { Config, DeployConfig } from '#profile';
1
+ import type { Config, DeployConfig, TargetConfig } from '#profile';
2
2
 
3
3
  /**
4
4
  * Where this profile's endpoint answers, for the target in play.
@@ -15,8 +15,8 @@ import type { Config, DeployConfig } from '#profile';
15
15
  * answer and the other could not see it, which is what a copied line does
16
16
  * eventually.
17
17
  */
18
- export async function endpointUrl(config: Config, target: string): Promise<string> {
19
- const deployed = await deployedUrl(config.targets[target]?.deploy);
18
+ export async function endpointUrl(config: Config, declared: TargetConfig): Promise<string> {
19
+ const deployed = await deployedUrl(declared.deploy);
20
20
  return deployed ?? localUrl(config);
21
21
  }
22
22
 
package/src/cli/main.ts CHANGED
@@ -32,7 +32,8 @@ import { knowledgeShow, knowledgeUse } from './commands/knowledge.ts';
32
32
  import { dispatchOwner } from './dispatch-owner.ts';
33
33
  import { update } from './commands/update.ts';
34
34
  import { all, customFlags, globalFlags, knowledgeFlags, ownerFlags, parseArgv, text } from './argv.ts';
35
- import { assertKnownFlags, requireSelection } from './selection.ts';
35
+ import { assertKnownFlags } from './selection.ts';
36
+ import { requireSelection } from './selection-require.ts';
36
37
  import { PROGRAM, USAGE } from './usage.ts';
37
38
  import { version } from './version.ts';
38
39
  import { print } from './output.ts';
@@ -125,19 +126,20 @@ export async function run(argv: readonly string[]): Promise<void> {
125
126
  switch (second) {
126
127
  case 'add':
127
128
  if (!rest[0]) {
128
- throw new Error(`Usage: ${PROGRAM} profile add <name> --target <name> [--target <name>]`);
129
+ throw new Error(`Usage: ${PROGRAM} profile add <name> --target <name>`);
129
130
  }
130
131
  return profileAdd(rest[0], {
131
- // Read from argv rather than from `flags`, because this is the one
132
- // place a flag is a list: a profile declares every target it can run
133
- // on, and the parser keeps only the last value of a repeated flag.
134
- targets: all(argv, 'target'),
132
+ // One target, not a list. A profile declared every target it could
133
+ // run on under contract 1, which is why this read the repeated flag
134
+ // out of argv; it lives in exactly one now (ADR-052), so a second
135
+ // --target would be naming a second place to put the same file.
136
+ targets: [text(flags, 'target')!],
135
137
  nonInteractive: flags['non-interactive'] === true,
136
138
  json,
137
139
  });
138
140
  case 'list':
139
141
  case undefined:
140
- return profileList({ json });
142
+ return profileList(text(flags, 'target')!, { json });
141
143
  case 'default':
142
144
  if (!rest[0]) throw new Error(`Usage: ${PROGRAM} profile default <name>`);
143
145
  return profileDefault(rest[0]);
@@ -1,5 +1,5 @@
1
1
  import type { SecretStore } from '#secrets';
2
- import type { Config } from '#profile';
2
+ import { openTarget, type Config } from '#profile';
3
3
  import { publishWorkspace } from '#deployments/upload.ts';
4
4
  import { openSecretStoreFor, type Runtime } from './runtime.ts';
5
5
  import { endpointUrl } from './endpoint-url.ts';
@@ -117,6 +117,7 @@ export async function publishProfileEdit(input: {
117
117
  /** Ask a running endpoint to re-read its config. Never throws. */
118
118
  async function notifyReload(input: {
119
119
  readonly config: Config;
120
+ readonly workspaceRoot: string;
120
121
  readonly target: string;
121
122
  readonly credentials: SecretStore;
122
123
  }): Promise<PublishOutcome> {
@@ -125,7 +126,8 @@ async function notifyReload(input: {
125
126
  // Answers for a deployed target as well as a local one — a loopback URL
126
127
  // sent to a deployment reaches a port with nothing behind it, which is the
127
128
  // bug this function's own doc comment records.
128
- url = (await endpointUrl(input.config, input.target)).replace(/\/mcp$/, '/reload');
129
+ const { declared } = await openTarget(input.workspaceRoot, input.target);
130
+ url = (await endpointUrl(input.config, declared)).replace(/\/mcp$/, '/reload');
129
131
  } catch (error) {
130
132
  return { served: false, reason: `could not work out where the endpoint is: ${message(error)}` };
131
133
  }
@@ -9,10 +9,10 @@ import {
9
9
  KNOWLEDGE_LAYOUT,
10
10
  layout,
11
11
  listProfiles,
12
- undeclaredTarget,
13
12
  workspacePath,
14
13
  type Config,
15
14
  type Resolution,
15
+ type TargetConfig,
16
16
  } from '#profile';
17
17
  import { ProviderRegistry, toPolicyDocument } from '#registry';
18
18
  import { Dispatcher, createConsoleLogger } from '#dispatch';
@@ -48,6 +48,15 @@ export interface Runtime {
48
48
  readonly resolution: Resolution;
49
49
  readonly config: Config;
50
50
  readonly target: string;
51
+ /**
52
+ * The target's adapter set, from the workspace that declares it.
53
+ *
54
+ * Here because the config no longer carries it. Every caller that used to
55
+ * reach `config.targets[target]` — for a `deploy` block, a storage adapter, a
56
+ * knowledge repository — reads this instead, and reads it without following
57
+ * the pointer a second time (ADR-052).
58
+ */
59
+ readonly declared: TargetConfig;
51
60
  readonly state: RuntimeState;
52
61
  /** The durable log, for reading. Copies, if any, are write-only and not here. */
53
62
  readonly audit: AuditReader;
@@ -133,16 +142,15 @@ export async function openRuntime(
133
142
  flags: GlobalFlags,
134
143
  options: OpenOptions = {},
135
144
  ): Promise<Runtime> {
136
- const { resolution, config, target } = await resolveProfile(flags);
137
- const declared = config.targets[target];
138
- // `resolveProfile` has already checked this, so reaching it means a caller
139
- // passed `allowUndeclaredTarget`. Through the shared refusal all the same:
140
- // this was a fourth spelling of that sentence with the profile name and the
141
- // list of what exists both missing, and the comment on `undeclaredTarget`
142
- // says why one copy is the most that survives.
143
- if (!declared) throw undeclaredTarget(target, config, resolution.profile);
145
+ const { resolution, config, target, resolved } = await resolveProfile(flags);
146
+ // `resolveProfile` returns this for every caller that did not ask to create
147
+ // the target, and `openRuntime` never does a runtime for a target that does
148
+ // not exist yet has nothing to open. The check is what makes that readable at
149
+ // the type level rather than a comment.
150
+ if (!resolved) throw new Error(`Target "${target}" has nothing to open yet`);
144
151
 
145
- const root = resolution.workspaceRoot;
152
+ const declared = resolved.declared;
153
+ const root = resolved.workspaceRoot;
146
154
  const adapters: TargetInput = { declared, config, root, target };
147
155
 
148
156
  // Credentials first: an S3 key pair is itself a credential reference, so the
@@ -341,6 +349,7 @@ export async function openRuntime(
341
349
  resolution,
342
350
  config,
343
351
  target,
352
+ declared,
344
353
  state,
345
354
  audit,
346
355
  credentials,