@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
@@ -1,76 +1,83 @@
1
1
  import { parseDocument } from 'yaml';
2
2
  import { readWorkspaceFile, workspaceFiles, writeWorkspaceFile } from './files.ts';
3
- import { workspaceSchema, type DeploymentRecord } from './schema.ts';
4
- import { WORKSPACE_FILE, readWorkspace } from './workspace.ts';
3
+ import { SUPPORTED_CONTRACT, workspaceSchema, type WorkspaceTarget } from './schema.ts';
4
+ import { WORKSPACE_FILE } from './workspace.ts';
5
5
 
6
6
  /**
7
- * The workspace's record of where its deployments live.
7
+ * Writing the target registry.
8
8
  *
9
- * A target is declared by a profile, and that declaration is what every command
10
- * resolves from. This is the index beside it, and the distinction is the whole
11
- * design: a profile file rewritten by hand or by a tool took a live Cloud Run
12
- * service, its bucket, and its credential store out of reach in one edit,
13
- * because the four lines naming them were the only copy. The service was still
14
- * running. Nothing could find it.
9
+ * ADR-044 kept a deployment *index* here and had to insist it was "an index, not
10
+ * configuration", because a target was declared by the profile and resolving
11
+ * from anything else would have been a second source of truth. ADR-052 removed
12
+ * the thing it was second to: the profile declares no target now, so this file
13
+ * writes the only declaration there is.
15
14
  *
16
- * So the record is kept where the thing it describes is not: one level up, in
17
- * `lanes-link.yaml`. `sync targets` reads it to know which bucket to open, and
18
- * nothing else reads it at all an index that starts being resolved from is a
19
- * second source of truth, which is the failure ADR-037 spent a release
20
- * removing (ADR-044).
15
+ * Which is why the entry shape matters. A workspace that *is* the target writes
16
+ * its adapters; a machine reaching it writes a pointer. `registry.ts` follows
17
+ * one to the other, and the schema refuses an entry trying to be both.
18
+ *
19
+ * Everything goes through the YAML document API, so the comments in a workspace
20
+ * file an operator has annotated survive being written to.
21
21
  */
22
22
 
23
- /** Every deployment the workspace has recorded. Empty for a workspace with none. */
24
- export async function readDeployments(workspaceRoot: string): Promise<DeploymentRecord[]> {
25
- // A workspace file that will not parse is not a reason to fail a recovery:
26
- // the caller has other ways to find a target, and this is the cheapest.
27
- try {
28
- return (await readWorkspace(workspaceRoot))?.deployments ?? [];
29
- } catch {
30
- return [];
31
- }
32
- }
33
-
34
- /** What the workspace knows about one target, if anything. */
35
- export async function findDeployment(
36
- workspaceRoot: string,
37
- target: string,
38
- ): Promise<DeploymentRecord | undefined> {
39
- return (await readDeployments(workspaceRoot)).find((entry) => entry.target === target);
40
- }
41
-
42
23
  /**
43
- * Record a deployment, replacing any earlier entry for the same target.
24
+ * Record a target, replacing any earlier entry of the same name.
44
25
  *
45
- * Keyed by target rather than appended, because a target has one deployment by
46
- * definition — a second entry would be a history, and a history is a thing to
47
- * read wrong. Redeploying the same target to a new bucket should leave one
48
- * record naming the new one.
26
+ * Keyed by name rather than appended, because a workspace has one answer per
27
+ * target by definition — a second entry would be a history, and a history is a
28
+ * thing to read wrong.
49
29
  *
50
- * Merged into the existing entry so a field this caller does not know about —
30
+ * Merged over the existing entry so a field this caller does not know about —
51
31
  * `primary`, on a redeploy that did not ask — is carried forward rather than
52
- * dropped.
53
- *
54
- * Written through the YAML document API, so the comments in a workspace file an
55
- * operator has annotated survive being indexed.
32
+ * dropped. That merge is why `deploy` can record a new `last_deploy` without
33
+ * having to re-answer whose token opens the endpoint.
56
34
  */
57
- export async function recordDeployment(
35
+ export async function recordTarget(
58
36
  workspaceRoot: string,
59
- entry: DeploymentRecord,
37
+ target: string,
38
+ entry: WorkspaceTarget,
39
+ ): Promise<void> {
40
+ await editRegistry(workspaceRoot, (targets) => {
41
+ const previous = targets[target];
42
+ // A pointer replacing a declaration is `deploy` handing the target over to
43
+ // the workspace it just wrote, so the adapter keys have to go rather than
44
+ // merge — an entry carrying both is what the schema refuses.
45
+ targets[target] =
46
+ entry.workspace !== undefined ? { ...pick(previous), ...entry } : { ...previous, ...entry };
47
+ });
48
+ }
49
+
50
+ /** Forget a target. Used by `profile remove --target` once nothing is left in it. */
51
+ export async function removeTarget(workspaceRoot: string, target: string): Promise<void> {
52
+ await editRegistry(workspaceRoot, (targets) => {
53
+ delete targets[target];
54
+ });
55
+ }
56
+
57
+ /** The fields that survive a declaration becoming a pointer: the deploy record. */
58
+ function pick(previous: WorkspaceTarget | undefined): Partial<WorkspaceTarget> {
59
+ if (!previous) return {};
60
+ return {
61
+ ...(previous.primary ? { primary: previous.primary } : {}),
62
+ ...(previous.last_deploy ? { last_deploy: previous.last_deploy } : {}),
63
+ };
64
+ }
65
+
66
+ async function editRegistry(
67
+ workspaceRoot: string,
68
+ edit: (targets: Record<string, WorkspaceTarget>) => void,
60
69
  ): Promise<void> {
61
70
  const files = workspaceFiles(workspaceRoot);
62
- const text = (await readWorkspaceFile(files, WORKSPACE_FILE)) ?? 'contract: 1\n';
71
+ const text =
72
+ (await readWorkspaceFile(files, WORKSPACE_FILE)) ?? `contract: ${SUPPORTED_CONTRACT}\n`;
63
73
 
64
74
  const document = parseDocument(text);
65
- const existing = await readDeployments(workspaceRoot);
66
- const previous = existing.find((record) => record.target === entry.target);
75
+ const targets = (document.toJSON()?.targets ?? {}) as Record<string, WorkspaceTarget>;
67
76
 
68
- const merged = [
69
- ...existing.filter((record) => record.target !== entry.target),
70
- { ...previous, ...entry },
71
- ].sort((a, b) => a.target.localeCompare(b.target));
77
+ edit(targets);
72
78
 
73
- document.setIn(['deployments'], merged);
79
+ if (Object.keys(targets).length === 0) document.deleteIn(['targets']);
80
+ else document.setIn(['targets'], sorted(targets));
74
81
 
75
82
  // Validated before it lands, on the rendered tree rather than the input, so
76
83
  // what is checked is what would be read back.
@@ -78,3 +85,7 @@ export async function recordDeployment(
78
85
 
79
86
  await writeWorkspaceFile(files, WORKSPACE_FILE, String(document));
80
87
  }
88
+
89
+ function sorted(targets: Record<string, WorkspaceTarget>): Record<string, WorkspaceTarget> {
90
+ return Object.fromEntries(Object.entries(targets).sort(([a], [b]) => a.localeCompare(b)));
91
+ }
@@ -14,17 +14,19 @@
14
14
  export {
15
15
  SUPPORTED_CONTRACT,
16
16
  configSchema,
17
- deploymentRecordSchema,
17
+ declaredTarget,
18
+ isPointer,
18
19
  workspaceSchema,
20
+ workspaceTargetSchema,
19
21
  type AuthorizationConfig,
20
22
  type Config,
21
23
  type ConnectionConfig,
22
24
  type DeployConfig,
23
- type DeploymentRecord,
24
25
  type IdentityEntry,
25
26
  type PolicyRuleConfig,
26
27
  type TargetConfig,
27
28
  type WorkspaceConfig,
29
+ type WorkspaceTarget,
28
30
  } from './schema.ts';
29
31
 
30
32
  export {
@@ -66,19 +68,33 @@ export {
66
68
  readWorkspace,
67
69
  resolveSelection,
68
70
  resolveWorkspaceRoot,
69
- targetsByName,
70
71
  workspacePath,
71
72
  type LoadedProfile,
72
73
  type WorkspaceProfiles,
73
74
  } from './workspace.ts';
74
75
  export {
75
76
  LEGACY_TARGET_ENV,
76
- noTargetInWorkspace,
77
77
  noTargetNamed,
78
+ notInRegistry,
78
79
  requireTarget,
79
- undeclaredTarget,
80
+ type Registry,
80
81
  } from './targets.ts';
81
- export { findDeployment, readDeployments, recordDeployment } from './deployments.ts';
82
+ export {
83
+ isLegacyProfile,
84
+ isLegacyWorkspace,
85
+ legacyConfigSchema,
86
+ legacyTargetSchema,
87
+ type LegacyConfig,
88
+ type LegacyTarget,
89
+ } from './legacy.ts';
90
+ export {
91
+ declaredHere,
92
+ openTarget,
93
+ readRegistry,
94
+ resolveTargetWorkspace,
95
+ type ResolvedTarget,
96
+ } from './registry.ts';
97
+ export { recordTarget, removeTarget } from './deployments.ts';
82
98
  export {
83
99
  isRemoteWorkspace,
84
100
  readWorkspaceFile,
@@ -0,0 +1,92 @@
1
+ import { z } from 'zod';
2
+ import {
3
+ auditTargetSchema,
4
+ credentialsTargetSchema,
5
+ deployTargetSchema,
6
+ storageTargetSchema,
7
+ vaultTargetSchema,
8
+ } from './schema.ts';
9
+ import { knowledgeTargetSchema } from './knowledge.ts';
10
+
11
+ /**
12
+ * Contract 1, understood well enough to migrate away from.
13
+ *
14
+ * The runtime does not read this and must not start. A binary that loaded either
15
+ * shape would be the two-sources-of-truth problem ADR-052 removed, one level up:
16
+ * two spellings of "where does this target live", both valid, disagreeing
17
+ * silently. `SUPPORTED_CONTRACT` is 2 and a contract-1 file is refused
18
+ * everywhere except here.
19
+ *
20
+ * Deliberately loose. It parses only what the migration has to *move* — the
21
+ * `targets:` block and its adapters — and passes everything else through
22
+ * untouched, because the migration edits the YAML document rather than
23
+ * re-rendering it from a parsed shape. A file with a problem this schema cannot
24
+ * see is a file the contract-2 loader will report properly once the structure is
25
+ * right, and that order is on purpose: a stale connection row must not block the
26
+ * structural fix.
27
+ */
28
+
29
+ /**
30
+ * The `cloudrun:` block `deploy:` replaced.
31
+ *
32
+ * Normalised here rather than in `schema.ts`, which is where it used to live.
33
+ * Contract 2 has no reason to carry a spelling nothing has written for two
34
+ * releases, and the migration is the last thing that will ever read one.
35
+ */
36
+ const legacyCloudRunSchema = z.object({
37
+ project: z.string(),
38
+ region: z.string(),
39
+ service: z.string(),
40
+ });
41
+
42
+ export const legacyTargetSchema = z
43
+ .object({
44
+ credentials: credentialsTargetSchema,
45
+ audit: auditTargetSchema.optional(),
46
+ storage: storageTargetSchema,
47
+ vault: vaultTargetSchema.optional(),
48
+ knowledge: knowledgeTargetSchema.optional(),
49
+ deploy: deployTargetSchema.optional(),
50
+ cloudrun: legacyCloudRunSchema.optional(),
51
+ })
52
+ .transform(({ cloudrun, ...target }) =>
53
+ target.deploy || !cloudrun
54
+ ? target
55
+ : {
56
+ ...target,
57
+ // The pre-`deploy` spelling predates both of these, so it gets the
58
+ // same defaults the current one would: the closed door, and no
59
+ // instance kept warm.
60
+ deploy: {
61
+ ...cloudrun,
62
+ platform: 'cloudrun' as const,
63
+ access: 'iam' as const,
64
+ min_instances: 0,
65
+ },
66
+ },
67
+ );
68
+
69
+ /** A contract-1 profile, as far as the migration needs to understand one. */
70
+ export const legacyConfigSchema = z.object({
71
+ contract: z.literal(1),
72
+ instance: z.object({ profile: z.string() }).passthrough(),
73
+ targets: z.record(z.string(), legacyTargetSchema).default({}),
74
+ });
75
+
76
+ export type LegacyTarget = z.infer<typeof legacyTargetSchema>;
77
+ export type LegacyConfig = z.infer<typeof legacyConfigSchema>;
78
+
79
+ /** Whether a parsed document is a contract-1 profile, without throwing on one. */
80
+ export function isLegacyProfile(raw: unknown): boolean {
81
+ return (
82
+ raw !== null &&
83
+ typeof raw === 'object' &&
84
+ !Array.isArray(raw) &&
85
+ (raw as { contract?: unknown }).contract === 1
86
+ );
87
+ }
88
+
89
+ /** Whether a parsed workspace file is contract 1. Same test, different file. */
90
+ export function isLegacyWorkspace(raw: unknown): boolean {
91
+ return isLegacyProfile(raw);
92
+ }
@@ -129,7 +129,7 @@ function formatZodIssues(error: z.ZodError): string {
129
129
  * A connection naming a provider whose id has moved out from under it.
130
130
  *
131
131
  * There is exactly one, and it is the reason this function exists: `tasks` was
132
- * Google Tasks until the built-in task list took the plain noun (ADR-051). A row
132
+ * Google Tasks until the built-in task list took the plain noun (ADR-052). A row
133
133
  * left saying `provider: tasks` does not fail — it resolves to the *built-in*,
134
134
  * `reconcile` marks it active because a provider needing no credential is
135
135
  * authorized by construction, and the operator is left with their Google Tasks
@@ -200,15 +200,15 @@ export const RENAMED_PROVIDERS: Readonly<Record<string, ProviderRename>> = {
200
200
  /**
201
201
  * The repair, spelled with the selection it will refuse without.
202
202
  *
203
- * Both flags come off the document being validated rather than off the command
204
- * that is running: nothing has resolved anything yet, and the profile is written
205
- * in the file. A profile declaring one target names it; one declaring several
206
- * cannot be guessed at, and a placeholder is more honest than picking.
203
+ * The profile comes off the document being validated rather than off the command
204
+ * that is running, because nothing has resolved anything yet and the name is
205
+ * written in the file. The target cannot come from there any more a profile
206
+ * declares none (ADR-052) so it stays a placeholder. That is honest rather
207
+ * than lossy: the file being repaired lives in exactly one target's workspace,
208
+ * and whoever is reading this refusal just typed which one.
207
209
  */
208
210
  function repairCommand(config: Config): string {
209
- const targets = Object.keys(config.targets);
210
- const target = targets.length === 1 ? targets[0] : `<${targets.join('|') || 'target'}>`;
211
- return `lanes link doctor --fix --profile ${config.instance.profile} --target ${target}`;
211
+ return `lanes link doctor --fix --profile ${config.instance.profile} --target <target>`;
212
212
  }
213
213
 
214
214
  /** The rename a row is owed, or `null` when it is owed none. */
@@ -241,26 +241,10 @@ function renamedProvider(
241
241
  function assertReferentialIntegrity(config: Config, source: string): void {
242
242
  const problems: string[] = [];
243
243
 
244
- const targetNames = new Set(Object.keys(config.targets));
245
- if (targetNames.size === 0) {
246
- problems.push('targets: at least one target must be declared');
247
- }
248
- // `instance.default_target` is deliberately not checked. Nothing reads it
249
- // (ADR-037), so validating it would be validating a comment — and failing
250
- // `check` on a stale value would teach that the key still matters.
251
-
252
- // Only what holds for every platform. What one platform needs and the next
253
- // has no concept of — a GCP project, an AWS role ARN — is refused by the
254
- // driver that needs it, the way an adapter-specific field is refused by the
255
- // code that opens the adapter rather than by this file.
256
- for (const [name, target] of Object.entries(config.targets)) {
257
- if (!target.deploy) continue;
258
- for (const field of ['region', 'service'] as const) {
259
- if (!target.deploy[field]) {
260
- problems.push(`targets.${name}.deploy.${field}: required for a deployable target`);
261
- }
262
- }
263
- }
244
+ // There is nothing to check about targets here any more. A profile declares
245
+ // none (ADR-052): the workspace holding this file declares the one target it
246
+ // lives in, and `workspaceSchema` is what validates that. A profile is now
247
+ // portable between targets precisely because it says nothing about them.
264
248
 
265
249
  // Connection ids are unique per provider, so `gmail.main` and
266
250
  // `icloud_mail.main` can coexist.
@@ -0,0 +1,182 @@
1
+ import { ConfigError } from './load.ts';
2
+ import { notInRegistry } from './targets.ts';
3
+ import { readWorkspace } from './workspace.ts';
4
+ import {
5
+ SUPPORTED_CONTRACT,
6
+ declaredTarget,
7
+ isPointer,
8
+ type TargetConfig,
9
+ type WorkspaceTarget,
10
+ } from './schema.ts';
11
+
12
+ /**
13
+ * The target registry: which targets exist, and which workspace declares each.
14
+ *
15
+ * A target names an adapter set. Under contract 1 a *profile* declared one per
16
+ * target it could be opened against, which is what made a deploy leave two
17
+ * copies of every profile — one in `~/.lanes-link`, one in the bucket the
18
+ * endpoint reads — with nothing keeping them honest. The reported failure is in
19
+ * `sync-apply.ts`'s header and it happened again while this was being written:
20
+ * a rewritten local file reported seven connections for a target whose bucket
21
+ * held fifteen, and the endpoint went on serving all fifteen throughout.
22
+ *
23
+ * Now a workspace **is** a target (ADR-052). It declares its adapters once, in
24
+ * its own `lanes-link.yaml`, and holds the profiles that live in it. A profile
25
+ * is one copy in one place, so there is no reconciliation left to get wrong.
26
+ *
27
+ * A machine reaches a target it does not hold through a **pointer** — a registry
28
+ * entry carrying `workspace:` and nothing else. Following one is a read of that
29
+ * workspace's own file, which is why every function here is async and why
30
+ * `--target cloud` needs the bucket reachable. That is the trade ADR-052 takes
31
+ * deliberately: a cloud target that cannot be read says so, where the shape it
32
+ * replaces answered instantly from a copy that had been wrong for eight hours.
33
+ */
34
+
35
+ /** Everything a command needs once it knows which target it is acting on. */
36
+ export interface ResolvedTarget {
37
+ readonly target: string;
38
+ /**
39
+ * Where this target's profiles and workspace file live — a directory, or a
40
+ * bucket URL. Not necessarily the root the command was invoked from.
41
+ */
42
+ readonly workspaceRoot: string;
43
+ /** The adapter set, from whichever workspace declares it. */
44
+ readonly declared: TargetConfig;
45
+ /** The declaring entry, for `primary` and `last_deploy`. */
46
+ readonly entry: WorkspaceTarget;
47
+ /** Whether the local workspace reached this through a pointer. */
48
+ readonly remote: boolean;
49
+ }
50
+
51
+ /** Every target the workspace at `root` knows about. Empty when it has no file. */
52
+ export async function readRegistry(root: string): Promise<Record<string, WorkspaceTarget>> {
53
+ const workspace = await readWorkspace(root);
54
+ return workspace?.targets ?? {};
55
+ }
56
+
57
+ /**
58
+ * Follow a target to the workspace that declares it.
59
+ *
60
+ * Returns `root` itself for a target this workspace declares, and the pointer's
61
+ * URI for one it does not. One hop only: a pointer whose destination is itself a
62
+ * pointer is a loop, and a registry that can chain is one where "where does this
63
+ * live" stops having a short answer.
64
+ */
65
+ export async function resolveTargetWorkspace(root: string, target: string): Promise<string> {
66
+ const registry = await readRegistry(root);
67
+ const entry = registry[target];
68
+ if (!entry) throw notInRegistry(target, registry, root);
69
+ return isPointer(entry) ? entry.workspace.replace(/\/$/, '') : root;
70
+ }
71
+
72
+ /**
73
+ * A target, resolved to the adapter set a command can open.
74
+ *
75
+ * The pointer hop is the only thing here that touches the network, and it
76
+ * happens once per command rather than per store — `openSecretStoreFor` and
77
+ * `openBlobStoreFor` take what this returns rather than resolving again.
78
+ */
79
+ export async function openTarget(root: string, target: string): Promise<ResolvedTarget> {
80
+ const registry = await readRegistry(root);
81
+ const entry = registry[target];
82
+ if (!entry) throw notInRegistry(target, registry, root);
83
+
84
+ if (!isPointer(entry)) {
85
+ const declared = declaredTarget(entry);
86
+ // Unreachable through the schema, which refuses an entry that is neither a
87
+ // pointer nor a complete declaration. Kept because the alternative to a
88
+ // sentence here is a `TypeError` inside an adapter three frames down.
89
+ if (!declared) throw incompleteTarget(target, root);
90
+ return { target, workspaceRoot: root, declared, entry, remote: false };
91
+ }
92
+
93
+ const workspaceRoot = entry.workspace.replace(/\/$/, '');
94
+ const remoteRegistry = await readRegistry(workspaceRoot);
95
+ const remoteEntry = remoteRegistry[target];
96
+
97
+ if (!remoteEntry) {
98
+ // A contract-1 workspace has no `targets:` block at all, so it looks exactly
99
+ // like one that declares the wrong things. Told apart here because the two
100
+ // have completely different fixes, and "does not declare it" would send
101
+ // someone editing a bucket that is merely out of date.
102
+ if (await isUnmigrated(workspaceRoot)) throw remoteAtContractOne(target, workspaceRoot);
103
+ throw pointerMissesTarget(target, workspaceRoot, remoteRegistry, root);
104
+ }
105
+ if (isPointer(remoteEntry)) throw pointerChain(target, workspaceRoot, root);
106
+
107
+ const declared = declaredTarget(remoteEntry);
108
+ if (!declared) throw incompleteTarget(target, workspaceRoot);
109
+
110
+ // The local entry's `primary` and `last_deploy` are what `deploy` wrote on the
111
+ // machine that ran it; the declaring workspace is authoritative for everything
112
+ // else. Merged this way round so a redeploy from a second machine does not
113
+ // silently lose the first one's record of who opens the endpoint.
114
+ return {
115
+ target,
116
+ workspaceRoot,
117
+ declared,
118
+ entry: { ...entry, ...remoteEntry },
119
+ remote: true,
120
+ };
121
+ }
122
+
123
+ /** The targets a workspace declares itself, rather than pointing at. */
124
+ export function declaredHere(registry: Record<string, WorkspaceTarget>): string[] {
125
+ return Object.entries(registry)
126
+ .filter(([, entry]) => !isPointer(entry))
127
+ .map(([name]) => name)
128
+ .sort();
129
+ }
130
+
131
+ function incompleteTarget(target: string, root: string): ConfigError {
132
+ return new ConfigError(
133
+ `Target "${target}" in ${root} declares neither "credentials" nor "storage", ` +
134
+ 'so there is nothing to open.',
135
+ );
136
+ }
137
+
138
+ function pointerMissesTarget(
139
+ target: string,
140
+ workspaceRoot: string,
141
+ remote: Record<string, WorkspaceTarget>,
142
+ root: string,
143
+ ): ConfigError {
144
+ const there = Object.keys(remote).sort().join(', ') || 'none';
145
+ return new ConfigError(
146
+ `${root} says target "${target}" lives at ${workspaceRoot}, but that workspace does not ` +
147
+ `declare it (it declares: ${there}).\n` +
148
+ ` Adopt what is really there: lanes link sync targets --target ${target} --from ${workspaceRoot}`,
149
+ );
150
+ }
151
+
152
+ function pointerChain(target: string, workspaceRoot: string, root: string): ConfigError {
153
+ return new ConfigError(
154
+ `${root} points target "${target}" at ${workspaceRoot}, which points somewhere else again.\n` +
155
+ ' A target is declared by exactly one workspace. Follow it and declare it there.',
156
+ );
157
+ }
158
+
159
+
160
+ /**
161
+ * Whether a workspace still holds contract-1 profiles.
162
+ *
163
+ * Duplicated in spirit with `workspace-migrate.ts`'s `needsMigration`, and not
164
+ * imported from it: `#profile` is below `#cli` in the layering, and a refusal
165
+ * reaching upwards for a sentence is how a cycle gets introduced. This looks at
166
+ * one file rather than every profile, which is all a refusal needs.
167
+ */
168
+ async function isUnmigrated(root: string): Promise<boolean> {
169
+ const workspace = await readWorkspace(root).catch(() => null);
170
+ if (workspace === null) return false;
171
+ return workspace.contract < SUPPORTED_CONTRACT;
172
+ }
173
+
174
+ function remoteAtContractOne(target: string, workspaceRoot: string): ConfigError {
175
+ return new ConfigError(
176
+ `${workspaceRoot} is a contract 1 workspace, so it does not declare "${target}" yet.\n` +
177
+ ' Its profiles still carry their own targets: block, which this version does not read.\n\n' +
178
+ ` lanes link deploy --target ${target}\n` +
179
+ ' migrates it and rolls the image that can read it, in that order — which is what\n' +
180
+ ' keeps the endpoint in front of it serving throughout (ADR-052).',
181
+ );
182
+ }