@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
@@ -27,8 +27,19 @@ import { knowledgeTargetSchema } from './knowledge.ts';
27
27
  * outright — never a best-effort load. Guessing at an unknown schema on a file
28
28
  * that governs authorization is how you end up granting something the operator
29
29
  * did not write.
30
+ *
31
+ * **2 moved `targets:` out of the profile and into the workspace** (ADR-052). A
32
+ * profile used to declare the adapter sets it could be opened against, which
33
+ * meant a deploy left two copies of every profile — one in the workspace and one
34
+ * in the bucket the endpoint reads — with nothing keeping them honest. Now a
35
+ * workspace *is* a target: it declares its own adapters once, holds the profiles
36
+ * that live in it, and a profile is one copy in one place.
37
+ *
38
+ * A hard cut, and contract 1 is not read here. `./legacy.ts` understands it, and
39
+ * only the migration uses that — a runtime that loaded either shape would be the
40
+ * two-sources-of-truth problem again, one level up.
30
41
  */
31
- export const SUPPORTED_CONTRACT = 1;
42
+ export const SUPPORTED_CONTRACT = 2;
32
43
 
33
44
  /**
34
45
  * There is no `database:` block any more.
@@ -149,8 +160,13 @@ export const vaultTargetSchema = z.object({
149
160
  */
150
161
  export const deployTargetSchema = z.object({
151
162
  platform: z.enum(['cloudrun']),
152
- region: z.string(),
153
- service: z.string(),
163
+ // Non-empty here rather than in a referential check further down. Under
164
+ // contract 1 `assertReferentialIntegrity` walked the profile's `targets:`
165
+ // block for these; the block is the workspace's now (ADR-052), and a
166
+ // constraint the schema can express belongs in the schema — an empty service
167
+ // name should fail at load, not minutes into a build.
168
+ region: z.string().min(1),
169
+ service: z.string().min(1),
154
170
  /**
155
171
  * `iam` puts the platform's own identity check in front of the service;
156
172
  * `public` leaves the door open and relies on this application's gate.
@@ -197,69 +213,21 @@ export const deployTargetSchema = z.object({
197
213
  });
198
214
 
199
215
  /**
200
- * The pre-`deploy` spelling, still accepted.
216
+ * One adapter set — where credentials are kept, where bytes go, where it rolls.
201
217
  *
202
- * Normalised into `deploy` below rather than read anywhere, so exactly one shape
203
- * reaches the rest of the codebase. It gains `access: iam` in the process, which
204
- * is a deliberate change of default for a config that predates the field — a
205
- * deploy that was open stays open only by saying so.
218
+ * Declared by the **workspace** that is this target, not by a profile in it
219
+ * (ADR-052). The `cloudrun:` spelling `deploy:` replaced is gone with contract
220
+ * 1; `./legacy.ts` still normalises it, on the one path that reads a contract-1
221
+ * file at all.
206
222
  */
207
- const legacyCloudRunSchema = z.object({
208
- project: z.string(),
209
- region: z.string(),
210
- service: z.string(),
223
+ export const targetSchema = z.object({
224
+ credentials: credentialsTargetSchema,
225
+ audit: auditTargetSchema.optional(),
226
+ storage: storageTargetSchema,
227
+ vault: vaultTargetSchema.optional(),
228
+ deploy: deployTargetSchema.optional(),
211
229
  });
212
230
 
213
- export const targetSchema = z
214
- .object({
215
- credentials: credentialsTargetSchema,
216
- audit: auditTargetSchema.optional(),
217
- storage: storageTargetSchema,
218
- vault: vaultTargetSchema.optional(),
219
- /**
220
- * Memory and skills, somewhere other than `storage` above.
221
- *
222
- * Optional and absent by default, so every profile written before it keeps
223
- * storing both exactly where it did. See `knowledge.ts` for why these two
224
- * are separable from the rest and why the credential store and the vault
225
- * are not.
226
- */
227
- knowledge: knowledgeTargetSchema.optional(),
228
- deploy: deployTargetSchema.optional(),
229
- /** @deprecated Write `deploy` with `platform: cloudrun`. */
230
- cloudrun: legacyCloudRunSchema.optional(),
231
- })
232
- .superRefine((target, ctx) => {
233
- // Both present is refused rather than resolved by precedence: a second place
234
- // to say where this deploys could only ever disagree with the first, and
235
- // silently preferring one would roll a revision to the project the operator
236
- // was not reading.
237
- if (target.deploy && target.cloudrun) {
238
- ctx.addIssue({
239
- code: 'custom',
240
- path: ['cloudrun'],
241
- message:
242
- 'both "deploy" and "cloudrun" are declared — remove "cloudrun", which "deploy" replaces',
243
- });
244
- }
245
- })
246
- .transform(({ cloudrun, ...target }) =>
247
- target.deploy || !cloudrun
248
- ? target
249
- : {
250
- ...target,
251
- // The pre-`deploy` spelling predates both of these, so it gets the
252
- // same defaults the current one would: the closed door, and no
253
- // instance kept warm.
254
- deploy: {
255
- ...cloudrun,
256
- platform: 'cloudrun' as const,
257
- access: 'iam' as const,
258
- min_instances: 0,
259
- },
260
- },
261
- );
262
-
263
231
  /**
264
232
  * A rule is a pattern, and usually just a string.
265
233
  *
@@ -303,11 +271,20 @@ export const policySchema = z.object({
303
271
  * is *whose mailbox is this*. `id` is the stable key that `credential_ref` and
304
272
  * the agent's `connection` argument point at, and it is derived from `account`
305
273
  * so it means something too.
274
+ *
275
+ * `label` is the operator's own word for the same row, and it exists because
276
+ * `account` cannot be. Three things read `account` as an identity — the
277
+ * reconnect match in `settleIdentity`, the id derived from it, and the `From`
278
+ * header `gmail.send_message` writes — so a `relabel` that wrote "Work mail"
279
+ * there stopped the next `connect` recognising the account it had renamed.
280
+ * Nothing addresses a connection by its label; it is only ever displayed.
306
281
  */
307
282
  export const connectionSchema = z.object({
308
283
  id: z.string().regex(/^[a-z0-9][a-z0-9_]*$/, 'must be lowercase alphanumeric with underscores'),
309
284
  provider: identifier,
310
285
  account: z.string().min(1),
286
+ /** Absent means the row is shown as its account, which is the usual case. */
287
+ label: z.string().min(1).optional(),
311
288
  credential_ref: credentialRef.optional(),
312
289
  /** Provider-specific, validated later against that provider's own schema. */
313
290
  config: z.record(z.string(), z.unknown()).optional(),
@@ -319,27 +296,20 @@ export const oauthAppSchema = z.object({
319
296
  });
320
297
 
321
298
 
299
+ /**
300
+ * One profile: who it is, what it reaches, and what it may do.
301
+ *
302
+ * **It does not say where it runs.** A profile lives in exactly one workspace
303
+ * and that workspace is a target (ADR-052), so the adapter set is a property of
304
+ * where the file is, not of what is in it. `instance.default_target` went with
305
+ * `targets:` — it was already inert under ADR-037, and contract 2 is the release
306
+ * that stops carrying it.
307
+ */
322
308
  export const configSchema = z.object({
323
309
  contract: z.number().int().positive(),
324
310
 
325
311
  instance: z.object({
326
312
  profile: identifier,
327
- /**
328
- * @deprecated Parsed, never read. See ADR-037.
329
- *
330
- * Every command names its target on the command line now, so nothing
331
- * consults this. It stays *declared* rather than being dropped, and that is
332
- * the whole point: an undeclared key is stripped silently by the schema,
333
- * which would leave `check` and `doctor` with nothing to report and an
334
- * operator staring at a line they reasonably believe still selects
335
- * something. Declaring it is what lets them be told it is inert.
336
- *
337
- * Optional, so a profile written today needs no such line, and unvalidated,
338
- * so a stale value naming a target that no longer exists is harmless rather
339
- * than a failure on a key nothing reads. The `database:` note above records
340
- * the same decision for the same reason.
341
- */
342
- default_target: z.string().min(1).optional(),
343
313
  port: z.number().int().min(1).max(65535).default(7337),
344
314
  /**
345
315
  * Loopback by default. Binding elsewhere is possible but the server
@@ -348,8 +318,6 @@ export const configSchema = z.object({
348
318
  host: z.string().default('127.0.0.1'),
349
319
  }),
350
320
 
351
- targets: z.record(z.string(), targetSchema),
352
-
353
321
  auth: z
354
322
  .object({
355
323
  mode: z.literal('bearer').default('bearer'),
@@ -389,6 +357,22 @@ export const configSchema = z.object({
389
357
  connections: z.array(connectionSchema).default([]),
390
358
  policy: policySchema.default({ allow: [], deny: [] }),
391
359
 
360
+ /**
361
+ * Memory and skills, somewhere other than the target's own storage (ADR-041).
362
+ *
363
+ * On the profile rather than the target, which is where contract 1 kept it.
364
+ * That was never really a property of the adapter set — it says where *this
365
+ * profile's* notes live — and it had to be per-target only because a profile
366
+ * could be declared against several. It lives in exactly one now (ADR-052), so
367
+ * per-profile and per-profile-per-target are the same thing, and this is the
368
+ * one of the two that matches what ADR-030 says a profile owns.
369
+ *
370
+ * Optional and absent by default, so a profile that says nothing keeps both
371
+ * where it keeps everything else. See `knowledge.ts` for why these two are
372
+ * separable from the rest and why the credential store and the vault are not.
373
+ */
374
+ knowledge: knowledgeTargetSchema.optional(),
375
+
392
376
  /**
393
377
  * Who the owner is, for anything written as them. Optional and additive, so
394
378
  * every profile written before it keeps loading unchanged — the same reasoning
@@ -407,44 +391,121 @@ export { authorizationSchema, identitySchema };
407
391
  export type { IdentityEntry } from './identity.ts';
408
392
 
409
393
  /**
410
- * Where a deployment lives an index, not configuration.
394
+ * One entry in the workspace's target registry.
411
395
  *
412
- * **Nothing resolves from this.** A target is still declared by the profile,
413
- * and a command still acts on what the profile says. This exists because the
414
- * profile file was the *only* record that a deployment existed, so rewriting
415
- * one erased the sole pointer to a live service, a bucket holding every byte
416
- * the endpoint remembered, and a credential store — none of which had gone
417
- * anywhere. There was no command that could find them again.
396
+ * Two shapes, and which one a workspace writes says who owns the target:
418
397
  *
419
- * It lives in `lanes-link.yaml`, outside any profile, for exactly that reason:
420
- * a record kept inside the thing it describes cannot survive the thing being
421
- * lost. `lanes link sync targets` reads it, and reads it *only* to know where
422
- * to look (ADR-044).
398
+ * - **A declaration** `credentials` and `storage`, and whatever else the
399
+ * adapter set needs. This workspace *is* that target. `~/.lanes-link` writes
400
+ * one of these for `local`.
401
+ * - **A pointer** — `workspace: gs://bucket[/prefix]`, and nothing else. The
402
+ * target lives elsewhere and the workspace at that URI declares it. This is
403
+ * what a machine holds for `cloud`.
423
404
  *
424
- * Written by `deploy`, after an upload has succeeded so it never names a
425
- * workspace nothing was ever put in.
405
+ * The pointer is why there is nothing to sync. ADR-044 added an index beside the
406
+ * profile's own `targets:` block and had to insist it was "an index, not
407
+ * configuration", because resolving from it would have made it a second source
408
+ * of truth. Here it is the *only* source: the profile declares no target at all,
409
+ * so a pointer and a declaration cannot disagree — there is one of them per
410
+ * target, in one file (ADR-052).
411
+ *
412
+ * A declaration for a target that is not deployed yet is the bootstrap case and
413
+ * is allowed: `deploy` reads it, creates the remote workspace, writes the
414
+ * declaration *there*, and replaces this one with a pointer. That is the only
415
+ * moment a target is described in two places, and it does not outlive the
416
+ * command.
426
417
  */
427
- export const deploymentRecordSchema = z.object({
428
- target: identifier,
429
- /** Where the deployed endpoint reads its config: `gs://bucket[/prefix]`. */
430
- workspace: z.string().min(1),
431
- /**
432
- * Whose bearer token opens this endpoint (ADR-009).
433
- *
434
- * Recorded rather than inferred. One endpoint serves every profile in the
435
- * bucket under one token, and which profile's token that is decides who gets
436
- * in — the one question about a deployment that must not be guessed at.
437
- */
438
- primary: identifier.optional(),
439
- last_deploy: z.string().optional(),
440
- });
418
+ export const workspaceTargetSchema = z
419
+ .object({
420
+ /** A pointer: where the workspace declaring this target lives. */
421
+ workspace: z.string().min(1).optional(),
422
+ credentials: credentialsTargetSchema.optional(),
423
+ audit: auditTargetSchema.optional(),
424
+ storage: storageTargetSchema.optional(),
425
+ vault: vaultTargetSchema.optional(),
426
+ deploy: deployTargetSchema.optional(),
427
+ /**
428
+ * Whose bearer token opens this endpoint (ADR-009).
429
+ *
430
+ * Recorded rather than inferred. One endpoint serves every profile in the
431
+ * workspace under one token, and which profile's token that is decides who
432
+ * gets in — the one question about a deployment that must not be guessed at.
433
+ */
434
+ primary: identifier.optional(),
435
+ last_deploy: z.string().optional(),
436
+ })
437
+ .superRefine((entry, ctx) => {
438
+ const declares = entry.credentials !== undefined || entry.storage !== undefined;
439
+
440
+ // Both is the state ADR-052 exists to prevent, and it is worth refusing
441
+ // rather than preferring one: a pointer beside a declaration is two answers
442
+ // to "where are this target's bytes", and picking either silently is how the
443
+ // fifteen-connection bucket got reported as seven.
444
+ if (declares && entry.workspace !== undefined) {
445
+ ctx.addIssue({
446
+ code: 'custom',
447
+ message:
448
+ 'names a "workspace" and also declares adapters — a target is declared by exactly ' +
449
+ 'one workspace. Keep the adapters here, or keep the pointer and declare them there.',
450
+ });
451
+ return;
452
+ }
453
+
454
+ if (!declares && entry.workspace === undefined) {
455
+ ctx.addIssue({
456
+ code: 'custom',
457
+ message:
458
+ 'declares neither "workspace" nor "credentials" and "storage" — a target either ' +
459
+ 'lives here or points at where it does.',
460
+ });
461
+ return;
462
+ }
463
+
464
+ if (declares && (entry.credentials === undefined || entry.storage === undefined)) {
465
+ ctx.addIssue({
466
+ code: 'custom',
467
+ path: [entry.credentials === undefined ? 'credentials' : 'storage'],
468
+ message: 'a target declared here needs both "credentials" and "storage"',
469
+ });
470
+ }
471
+ });
441
472
 
442
- /** The workspace file: `lanes-link.yaml`, alongside a `profiles/` directory. */
473
+ /**
474
+ * The workspace file: `lanes-link.yaml`, alongside a `profiles/` directory.
475
+ *
476
+ * `default_profile` is parsed and ignored, as it has been since ADR-037 — it
477
+ * survives so `check` can say it is inert rather than the schema stripping it
478
+ * silently. `deployments:` is gone; `targets` above is what it became.
479
+ */
443
480
  export const workspaceSchema = z.object({
444
481
  contract: z.number().int().positive(),
445
482
  default_profile: identifier.optional(),
446
- deployments: z.array(deploymentRecordSchema).default([]),
483
+ targets: z.record(z.string(), workspaceTargetSchema).default({}),
447
484
  });
448
485
 
449
486
  export type WorkspaceConfig = z.infer<typeof workspaceSchema>;
450
- export type DeploymentRecord = z.infer<typeof deploymentRecordSchema>;
487
+ export type WorkspaceTarget = z.infer<typeof workspaceTargetSchema>;
488
+
489
+ /** Whether a registry entry points elsewhere rather than declaring the target. */
490
+ export function isPointer(
491
+ entry: WorkspaceTarget,
492
+ ): entry is WorkspaceTarget & { workspace: string } {
493
+ return entry.workspace !== undefined;
494
+ }
495
+
496
+ /**
497
+ * The adapter set a registry entry declares, or undefined for a pointer.
498
+ *
499
+ * A pointer has to be followed before there is anything to open, which is
500
+ * `resolveTargetWorkspace`'s job — this only narrows the shape once it has been.
501
+ */
502
+ export function declaredTarget(entry: WorkspaceTarget): TargetConfig | undefined {
503
+ if (entry.credentials === undefined || entry.storage === undefined) return undefined;
504
+ return {
505
+ credentials: entry.credentials,
506
+ storage: entry.storage,
507
+ ...(entry.audit ? { audit: entry.audit } : {}),
508
+ ...(entry.vault ? { vault: entry.vault } : {}),
509
+ ...(entry.deploy ? { deploy: entry.deploy } : {}),
510
+ };
511
+ }
@@ -1,5 +1,5 @@
1
1
  import { ConfigError } from './load.ts';
2
- import type { Config } from './schema.ts';
2
+ import { isPointer, type WorkspaceTarget } from './schema.ts';
3
3
 
4
4
  /**
5
5
  * Which target a command acts on.
@@ -9,8 +9,9 @@ import type { Config } from './schema.ts';
9
9
  * the profile, which is why it is a different file.
10
10
  *
11
11
  * **`--target`, or the command does not run** (ADR-037). There is no fallback:
12
- * not `LANES_LINK_TARGET`, not `instance.default_target`. Both are still
13
- * parsed, so no existing config file has to change, and neither is read.
12
+ * not `LANES_LINK_TARGET`, not `instance.default_target`. The variable is still
13
+ * named in a refusal so a shell configured for the old world can be told it is
14
+ * ignored; the key went with contract 1.
14
15
  *
15
16
  * The chain this replaces resolved `--target`, then the variable, then the key,
16
17
  * and printed which of the three it landed on. What that bought was one flag
@@ -19,10 +20,18 @@ import type { Config } from './schema.ts';
19
20
  * and the next command carried on from a different source, so the mistake
20
21
  * surfaced one command later with nothing connecting it to its cause. A
21
22
  * resolver with nowhere to fall back to cannot fail that way.
23
+ *
24
+ * **What changed under ADR-052** is where the list of targets comes from. It was
25
+ * one profile's `targets:` block, which meant "is this target declared" had a
26
+ * different answer per profile and a deployment could look vanished from inside
27
+ * one of them. It is now the workspace registry — one list, before any profile
28
+ * is read, which is what lets `--target` be chosen first.
22
29
  */
23
30
 
31
+ export type Registry = Record<string, WorkspaceTarget>;
32
+
24
33
  /**
25
- * The target this command named, checked against what the profile declares.
34
+ * The target this command named, checked against the workspace registry.
26
35
  *
27
36
  * `allowUndeclared` is for the one command whose job is to create the target it
28
37
  * was given — `deploy`, on a first run. Every other command naming a target that
@@ -30,14 +39,14 @@ import type { Config } from './schema.ts';
30
39
  * answer.
31
40
  */
32
41
  export function requireTarget(
33
- config: Config,
42
+ registry: Registry,
34
43
  targetFlag: string | undefined,
35
- options: { allowUndeclared?: boolean; profile?: string } = {},
44
+ options: { allowUndeclared?: boolean; root?: string } = {},
36
45
  ): string {
37
- if (!targetFlag) throw noTargetNamed(config, options.profile);
46
+ if (!targetFlag) throw noTargetNamed(registry, options.root);
38
47
 
39
- if (options.allowUndeclared !== true && !(targetFlag in config.targets)) {
40
- throw undeclaredTarget(targetFlag, config, options.profile);
48
+ if (options.allowUndeclared !== true && !(targetFlag in registry)) {
49
+ throw notInRegistry(targetFlag, registry, options.root);
41
50
  }
42
51
 
43
52
  return targetFlag;
@@ -46,43 +55,41 @@ export function requireTarget(
46
55
  /**
47
56
  * The refusal for a command that named no target.
48
57
  *
49
- * It lists the targets with their adapters, because "which one" is the question
50
- * being asked and the adapter set is what distinguishes them. It also reports
51
- * the two things that used to answer this and no longer do an exported
52
- * variable and the key still sitting in the file since an operator looking at
53
- * either has every reason to believe it is still working.
58
+ * It lists the targets and where each lives, because "which one" is the question
59
+ * being asked and here-versus-elsewhere is what distinguishes them. It also
60
+ * reports the variable that used to answer this and no longer does, since an
61
+ * operator with it exported has every reason to believe it still works.
62
+ *
63
+ * One refusal rather than the two this replaced. `noTargetNamed` used to list
64
+ * one profile's adapters and `noTargetInWorkspace` listed which profiles
65
+ * declared each name — a distinction that existed only because a target was
66
+ * declared per profile. There is one list now, so there is one sentence.
54
67
  */
55
68
  export function noTargetNamed(
56
- config: Config,
57
- profile?: string,
69
+ registry: Registry,
70
+ root?: string,
58
71
  env: Record<string, string | undefined> = process.env as Record<string, string | undefined>,
59
72
  ): ConfigError {
60
- const names = Object.keys(config.targets);
61
- const whose = profile ? ` by profile "${profile}"` : '';
73
+ const names = Object.keys(registry).sort();
74
+ const where = root ?? 'this workspace';
62
75
 
63
- const rows = names
64
- .map((name) => {
65
- const declared = config.targets[name]!;
66
- const deployed = declared.deploy ? ' deployed' : '';
67
- return ` ${name} ${declared.credentials.adapter} ${declared.storage.adapter}${deployed}`;
68
- })
69
- .join('\n');
76
+ if (names.length === 0) {
77
+ return new ConfigError(
78
+ `--target is required, and ${where} declares none.\n` +
79
+ ' Create one with: lanes link profile add <name> --target local',
80
+ );
81
+ }
70
82
 
71
83
  const stale = env[LEGACY_TARGET_ENV];
72
- const inert = config.instance.default_target;
73
84
 
74
85
  return new ConfigError(
75
86
  '--target is required. This command opens a target\'s stores, and nothing\n' +
76
87
  'else selects one.\n\n' +
77
- ` Targets declared${whose}\n${rows}\n` +
78
- `\n e.g. lanes link status --profile ${profile ?? '<name>'} --target ${names[0] ?? '<target>'}` +
88
+ ` Targets in ${where}\n${rows(registry, names)}\n` +
89
+ `\n e.g. lanes link status --target ${names[0]}` +
79
90
  (stale
80
91
  ? `\n\n ${LEGACY_TARGET_ENV}=${stale} is set in this shell and is no longer read.\n` +
81
92
  ' Unset it, or pass --target.'
82
- : '') +
83
- (inert
84
- ? `\n\n instance.default_target: ${inert} is still in this profile. It is no longer\n` +
85
- ' read either, and is safe to delete.'
86
93
  : ''),
87
94
  );
88
95
  }
@@ -94,72 +101,36 @@ export function noTargetNamed(
94
101
  * an operator is looking at and reasonably believes is still working — which is
95
102
  * the whole difference between "this stopped working" and "this stopped working
96
103
  * and here is why".
104
+ *
105
+ * The deployed image still *sets* it, and that is not a contradiction: a
106
+ * revision serves exactly one target and passes it to `serve` on the command
107
+ * line. Nothing resolves from the variable on either side.
97
108
  */
98
109
  export const LEGACY_TARGET_ENV = 'LANES_LINK_TARGET';
99
110
 
100
- /**
101
- * The refusal for a target that is not in the file, in one spelling.
102
- *
103
- * It was two here and in the CLI's `openSecretStoreFor` — which is one more
104
- * than a sentence naming the available targets survives: the copies drift the
105
- * moment either learns something the other does not.
106
- */
107
- export function undeclaredTarget(target: string, config: Config, profile?: string): ConfigError {
108
- const have = Object.keys(config.targets).join(', ') || 'none';
109
- const whose = profile ? `profile "${profile}"` : 'this profile';
111
+ /** The refusal for a target that is not in the registry, in one spelling. */
112
+ export function notInRegistry(target: string, registry: Registry, root?: string): ConfigError {
113
+ const names = Object.keys(registry).sort();
114
+ const where = root ?? 'this workspace';
110
115
 
111
- return new ConfigError(`Target "${target}" is not declared by ${whose} (have: ${have})`);
112
- }
113
-
114
- /**
115
- * The refusal for a target-scoped command that named no target.
116
- *
117
- * The twin of `noTargetNamed`, for the commands whose subject is the target
118
- * rather than one profile's view of it (ADR-043). It lists the target names the
119
- * *workspace* declares and who declares each, because the question those
120
- * commands are asking is "which endpoint", and a target only one profile knows
121
- * about is the answer to a different question than one they all share.
122
- *
123
- * Naming the profiles is not decoration. A target declared by one profile and
124
- * not its sibling is precisely the state that reads as a deployment having
125
- * disappeared, and it is invisible from inside either profile alone.
126
- */
127
- export function noTargetInWorkspace(
128
- declared: ReadonlyMap<string, readonly string[]>,
129
- workspaceRoot: string,
130
- env: Record<string, string | undefined> = process.env as Record<string, string | undefined>,
131
- ): ConfigError {
132
- if (declared.size === 0) {
133
- return new ConfigError(
134
- `--target is required, and no profile in ${workspaceRoot} declares one.\n` +
135
- ' Create a profile with: lanes link profile add <name> --target local',
136
- );
116
+ if (names.length === 0) {
117
+ return new ConfigError(`Target "${target}" is not declared, and ${where} declares none.`);
137
118
  }
138
119
 
139
- const total = new Set(
140
- [...declared.values()].flatMap((profiles) => profiles as readonly string[]),
141
- ).size;
120
+ return new ConfigError(
121
+ `Target "${target}" is not declared in ${where}.\n\n Targets\n${rows(registry, names)}`,
122
+ );
123
+ }
142
124
 
143
- const rows = [...declared.entries()]
144
- .map(([name, profiles]) => {
145
- // "every profile" rather than the list once it is all of them: the list is
146
- // there to show a gap, and a complete one shows none.
147
- const whose = profiles.length === total ? 'every profile' : profiles.join(', ');
148
- return ` ${name} ${whose}`;
125
+ function rows(registry: Registry, names: readonly string[]): string {
126
+ return names
127
+ .map((name) => {
128
+ const entry = registry[name]!;
129
+ if (isPointer(entry)) return ` ${name} ${entry.workspace}`;
130
+ const adapters = [entry.credentials?.adapter, entry.storage?.adapter]
131
+ .filter(Boolean)
132
+ .join(' ');
133
+ return ` ${name} here ${adapters}${entry.deploy ? ' deployable' : ''}`;
149
134
  })
150
135
  .join('\n');
151
-
152
- const first = [...declared.keys()][0]!;
153
- const stale = env[LEGACY_TARGET_ENV];
154
-
155
- return new ConfigError(
156
- '--target is required. This command acts on a target, and every profile\n' +
157
- 'that declares it.\n\n' +
158
- ` Targets in ${workspaceRoot}\n${rows}\n` +
159
- `\n e.g. lanes link status --target ${first}` +
160
- (stale
161
- ? `\n\n ${LEGACY_TARGET_ENV}=${stale} is set in this shell and is no longer read.\n` +
162
- ' Unset it, or pass --target.'
163
- : ''),
164
- );
165
136
  }
@@ -0,0 +1,78 @@
1
+ import { SUPPORTED_CONTRACT } from './schema.ts';
2
+
3
+ /**
4
+ * Workspace files for tests, so a fixture says what it is testing.
5
+ *
6
+ * Under contract 1 a test workspace needed only `contract` and a profile, because
7
+ * the profile carried its own `targets:` block. The target is declared by the
8
+ * workspace now (ADR-052), so every fixture that opens a store needs one here —
9
+ * and thirty of them writing the same eight lines by hand is how a fixture ends
10
+ * up subtly different from the thing it is standing in for.
11
+ *
12
+ * Kept beside the schema rather than under a test directory, following
13
+ * `stores/blobs/testing.ts` and `stores/state/testing.ts`.
14
+ */
15
+
16
+ /** The adapters each well-known test target gets. */
17
+ const ADAPTERS: Record<string, string> = {
18
+ local: ` credentials: { adapter: file }
19
+ storage: { adapter: filesystem }`,
20
+ // Deployable, because that is what `cloud` means everywhere it is used as a
21
+ // fixture: the tests that name it are about a target with a service in front
22
+ // of it, and one without a `deploy:` block reads as an ordinary bucket.
23
+ cloud: ` credentials: { adapter: gcp-secret-manager, project: my-project }
24
+ storage: { adapter: gcs, bucket: your-bucket }
25
+ vault: { adapter: secret }
26
+ deploy:
27
+ platform: cloudrun
28
+ project: my-project
29
+ region: europe-west1
30
+ service: my-service`,
31
+ staging: ` credentials: { adapter: gcp-secret-manager, project: staging-project }
32
+ storage: { adapter: gcs, bucket: staging-bucket }`,
33
+ s3: ` credentials: { adapter: file }
34
+ storage:
35
+ adapter: s3
36
+ bucket: your-bucket
37
+ endpoint: https://example.storage.example.com/storage/v1/s3
38
+ region: eu-central-1
39
+ access_key_id_ref: s3/access_key_id
40
+ secret_access_key_ref: s3/secret_access_key`,
41
+ };
42
+
43
+ /**
44
+ * A `lanes-link.yaml` declaring the named targets.
45
+ *
46
+ * Anything not in the table above is given the local adapters, which is what a
47
+ * test naming an arbitrary target is nearly always after — it wants the name to
48
+ * resolve, not the bytes to go anywhere in particular.
49
+ */
50
+ export function workspaceYaml(
51
+ targets: readonly string[] = ['local'],
52
+ options: { defaultProfile?: string } = {},
53
+ ): string {
54
+ const blocks = targets
55
+ .map((name) => ` ${name}:\n${ADAPTERS[name] ?? ADAPTERS['local']}`)
56
+ .join('\n');
57
+
58
+ return (
59
+ `contract: ${SUPPORTED_CONTRACT}\n` +
60
+ (options.defaultProfile ? `default_profile: ${options.defaultProfile}\n` : '') +
61
+ `targets:\n${blocks}\n`
62
+ );
63
+ }
64
+
65
+ /** A registry entry pointing at another workspace, for the pointer paths. */
66
+ export function pointerYaml(
67
+ target: string,
68
+ workspace: string,
69
+ options: { defaultProfile?: string; alsoLocal?: boolean } = {},
70
+ ): string {
71
+ const local = options.alsoLocal === false ? '' : ` local:\n${ADAPTERS['local']}\n`;
72
+
73
+ return (
74
+ `contract: ${SUPPORTED_CONTRACT}\n` +
75
+ (options.defaultProfile ? `default_profile: ${options.defaultProfile}\n` : '') +
76
+ `targets:\n${local} ${target}:\n workspace: ${workspace}\n`
77
+ );
78
+ }