@lanes-sh/link 0.2.2 → 0.3.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 (130) hide show
  1. package/README.md +21 -8
  2. package/instructions/skills/lanes-link/SKILL.md +42 -14
  3. package/package.json +1 -1
  4. package/src/cli/argv.ts +50 -0
  5. package/src/cli/brand.ts +178 -0
  6. package/src/cli/callback-page.ts +108 -128
  7. package/src/cli/commands/connect/accounts.ts +5 -0
  8. package/src/cli/commands/connect/assertion.ts +187 -0
  9. package/src/cli/commands/connect/authorise.ts +61 -17
  10. package/src/cli/commands/connect/client.ts +37 -9
  11. package/src/cli/commands/connect/discover.ts +94 -0
  12. package/src/cli/commands/connect/family.ts +72 -0
  13. package/src/cli/commands/connect/index.ts +113 -115
  14. package/src/cli/commands/connect/method.ts +237 -0
  15. package/src/cli/commands/connect/outcome.ts +42 -1
  16. package/src/cli/commands/connect/pasted-token.ts +66 -0
  17. package/src/cli/commands/connect/requirements.ts +60 -8
  18. package/src/cli/commands/connect/setup.ts +16 -5
  19. package/src/cli/commands/connect/target-note.ts +34 -0
  20. package/src/cli/commands/identity.ts +258 -0
  21. package/src/cli/commands/knowledge/index.ts +390 -0
  22. package/src/cli/commands/knowledge/migrate.ts +180 -0
  23. package/src/cli/commands/knowledge/setup.ts +144 -0
  24. package/src/cli/commands/knowledge.ts +10 -0
  25. package/src/cli/commands/mcp/harnesses.ts +16 -2
  26. package/src/cli/commands/mcp/register.ts +9 -1
  27. package/src/cli/commands/mcp/stdio.ts +21 -0
  28. package/src/cli/commands/operate/dashboard.ts +107 -0
  29. package/src/cli/commands/operate/findings.ts +151 -0
  30. package/src/cli/commands/operate/inspect.ts +56 -158
  31. package/src/cli/commands/operate/outputs.ts +38 -11
  32. package/src/cli/commands/operate/serve.ts +3 -0
  33. package/src/cli/commands/operate/token.ts +1 -1
  34. package/src/cli/commands/operate.ts +2 -0
  35. package/src/cli/commands/profile/declare.ts +154 -0
  36. package/src/cli/commands/profile/removal.ts +17 -0
  37. package/src/cli/commands/profile.ts +83 -35
  38. package/src/cli/commands/setup.ts +22 -6
  39. package/src/cli/commands/target.ts +65 -83
  40. package/src/cli/config-edit.ts +48 -144
  41. package/src/cli/config-repair.ts +186 -0
  42. package/src/cli/dashboard-page.ts +284 -0
  43. package/src/cli/dashboard-shell.ts +125 -0
  44. package/src/cli/identity.ts +12 -1
  45. package/src/cli/main.ts +68 -4
  46. package/src/cli/oauth-callback.ts +187 -0
  47. package/src/cli/oauth-exchange.ts +57 -15
  48. package/src/cli/oauth.ts +67 -177
  49. package/src/cli/output.ts +21 -5
  50. package/src/cli/provider-marks.ts +45 -0
  51. package/src/cli/runtime/open.ts +67 -49
  52. package/src/cli/runtime/registry.ts +60 -2
  53. package/src/cli/runtime/select.ts +26 -13
  54. package/src/cli/runtime/vault.ts +61 -0
  55. package/src/cli/runtime.ts +2 -1
  56. package/src/cli/selection.ts +357 -0
  57. package/src/cli/usage.ts +32 -9
  58. package/src/connectivity/auth/README.md +7 -1
  59. package/src/connectivity/auth/basic/index.ts +1 -1
  60. package/src/connectivity/auth/index.ts +14 -0
  61. package/src/connectivity/auth/oauth-authcode/broker.ts +26 -0
  62. package/src/connectivity/auth/oauth-authcode/index.ts +16 -2
  63. package/src/connectivity/auth/oauth-authcode/provider.ts +1 -1
  64. package/src/connectivity/auth/oauth-authcode/refresh.ts +3 -3
  65. package/src/connectivity/auth/oauth-jwt/README.md +33 -0
  66. package/src/connectivity/auth/oauth-jwt/index.ts +237 -0
  67. package/src/connectivity/auth/oauth-jwt/key.ts +148 -0
  68. package/src/connectivity/auth/resolve.ts +1 -1
  69. package/src/connectivity/auth/token.ts +11 -0
  70. package/src/connectivity/index.ts +2 -0
  71. package/src/connectivity/manifest/auth.ts +99 -2
  72. package/src/connectivity/manifest/identity.ts +12 -0
  73. package/src/connectivity/manifest/index.ts +3 -1
  74. package/src/connectivity/manifest/provider.ts +37 -8
  75. package/src/connectivity/manifest/requirements.ts +109 -6
  76. package/src/deployments/adapters/filesystem.ts +10 -1
  77. package/src/deployments/adapters/github-api.ts +106 -0
  78. package/src/deployments/adapters/github-commit.ts +103 -0
  79. package/src/deployments/adapters/github-repo.ts +356 -0
  80. package/src/deployments/adapters/github-testing.ts +258 -0
  81. package/src/deployments/adapters/github.ts +125 -0
  82. package/src/deployments/deploy.ts +48 -19
  83. package/src/deployments/driver.ts +8 -1
  84. package/src/deployments/gcp/driver.ts +3 -1
  85. package/src/deployments/knowledge.ts +119 -0
  86. package/src/deployments/prepare.ts +2 -2
  87. package/src/deployments/servable.ts +81 -0
  88. package/src/deployments/target.ts +3 -2
  89. package/src/deployments/upload.ts +2 -1
  90. package/src/dispatch/dispatch.ts +1 -1
  91. package/src/profile/identity.ts +60 -0
  92. package/src/profile/index.ts +15 -5
  93. package/src/profile/knowledge.ts +124 -0
  94. package/src/profile/load.ts +17 -5
  95. package/src/profile/primitives.ts +24 -1
  96. package/src/profile/schema.ts +46 -3
  97. package/src/profile/targets.ts +74 -114
  98. package/src/profile/workspace.ts +71 -84
  99. package/src/providers/google/calendar/index.ts +2 -0
  100. package/src/providers/google/contacts/index.ts +2 -0
  101. package/src/providers/google/docs/index.ts +2 -0
  102. package/src/providers/google/drive/index.ts +2 -0
  103. package/src/providers/google/gmail/index.ts +2 -0
  104. package/src/providers/google/gmail-imap/index.ts +125 -0
  105. package/src/providers/google/index.ts +2 -1
  106. package/src/providers/google/shared/oauth.ts +18 -6
  107. package/src/providers/google/shared/service-account.ts +110 -0
  108. package/src/providers/google/shared/setup.ts +5 -2
  109. package/src/providers/google/sheets/index.ts +2 -0
  110. package/src/providers/google/tasks/index.ts +2 -0
  111. package/src/providers/identity/provider.ts +166 -0
  112. package/src/providers/index.ts +3 -0
  113. package/src/providers/owner.ts +10 -2
  114. package/src/providers/scopes.ts +2 -0
  115. package/src/providers/setup/plan.ts +31 -9
  116. package/src/providers/setup/provider.ts +23 -0
  117. package/src/providers/slack/index.ts +81 -33
  118. package/src/providers/slack/oauth.ts +103 -0
  119. package/src/providers/slack/scopes.ts +37 -0
  120. package/src/server/container.ts +18 -1
  121. package/src/server/cors.ts +252 -0
  122. package/src/server/dashboard.ts +208 -0
  123. package/src/server/endpoint.ts +45 -1
  124. package/src/server/generations.ts +11 -2
  125. package/src/server/harness.ts +7 -0
  126. package/src/server/index.ts +45 -6
  127. package/src/server/mcp/index.ts +1 -0
  128. package/src/server/mcp/instructions.ts +28 -1
  129. package/src/server/mcp/visibility.ts +33 -0
  130. package/src/stores/blobs/route.ts +123 -0
@@ -19,11 +19,21 @@ export {
19
19
  type Config,
20
20
  type ConnectionConfig,
21
21
  type DeployConfig,
22
+ type IdentityEntry,
22
23
  type PolicyRuleConfig,
23
24
  type TargetConfig,
24
25
  type WorkspaceConfig,
25
26
  } from './schema.ts';
26
27
 
28
+ export {
29
+ KNOWLEDGE_LAYOUT,
30
+ knowledgeRoot,
31
+ knowledgeTargetSchema,
32
+ parseRepository,
33
+ type KnowledgeArea,
34
+ type KnowledgeConfig,
35
+ } from './knowledge.ts';
36
+
27
37
  export {
28
38
  ConfigError,
29
39
  loadConfigFile,
@@ -41,10 +51,10 @@ export {
41
51
 
42
52
  export {
43
53
  WORKSPACE_FILE,
44
- describeSelection,
45
54
  installRoot,
46
55
  listProfiles,
47
56
  loadProfileConfig,
57
+ noProfileNamed,
48
58
  profilePath,
49
59
  readWorkspace,
50
60
  resolveSelection,
@@ -52,10 +62,9 @@ export {
52
62
  workspacePath,
53
63
  } from './workspace.ts';
54
64
  export {
55
- TARGET_ENV,
56
- askedTarget,
57
- resolveDeployTarget,
58
- resolveTarget,
65
+ LEGACY_TARGET_ENV,
66
+ noTargetNamed,
67
+ requireTarget,
59
68
  undeclaredTarget,
60
69
  } from './targets.ts';
61
70
  export {
@@ -65,6 +74,7 @@ export {
65
74
  writeWorkspaceFile,
66
75
  } from './files.ts';
67
76
  export {
77
+ type ProfileSelection,
68
78
  type Resolution,
69
79
  type ResolveOptions,
70
80
  } from './workspace.ts';
@@ -0,0 +1,124 @@
1
+ import { z } from 'zod';
2
+ import { credentialRef } from './primitives.ts';
3
+
4
+ /**
5
+ * Where a profile's memory and skills live, when that is not where everything
6
+ * else lives.
7
+ *
8
+ * A target's `storage:` block names one backend for everything a profile holds
9
+ * — runtime state, the audit log, memory entries, skills, cached attachments.
10
+ * That is right for most of it and wrong for two: state and the log are
11
+ * artefacts of one installation, and memory entries and skills are documents
12
+ * the owner wrote. Documents want history, review, and to be readable from more
13
+ * than one machine, and the backend that suits an object nobody reads is not
14
+ * the backend that suits those.
15
+ *
16
+ * So this block moves exactly those two, and it is the whole of what it can
17
+ * move. There is no `credentials` or `vault` value here and there will not be:
18
+ * a repository is a place to publish, and those two hold the material whose
19
+ * entire value is that it is not published (`docs/detailed/security.md`). The
20
+ * exclusion is structural — a field that does not exist cannot be set by
21
+ * accident, by a flag, or by an operator following an example.
22
+ *
23
+ * `adapter` is an enum of one, the same shape `vaultTargetSchema` uses, so a
24
+ * second host is a case rather than a schema rewrite.
25
+ *
26
+ * Absent means what has always happened, which is why every field that could
27
+ * carry a default does. A profile written before this block existed loads
28
+ * unchanged and stores its memory and skills exactly where it did.
29
+ *
30
+ * ADR-041.
31
+ */
32
+
33
+ /**
34
+ * `owner/name`, as GitHub spells it everywhere a person types one.
35
+ *
36
+ * A URL is deliberately not accepted. The browser URL, the SSH remote, and this
37
+ * are three spellings of one value, and a schema that takes all three has to
38
+ * normalise them somewhere — at which point the file no longer says what the
39
+ * operator wrote. The CLI accepts a pasted URL and reduces it to this before
40
+ * writing, which puts the leniency where a person is typing rather than in the
41
+ * contract.
42
+ */
43
+ const repository = z
44
+ .string()
45
+ .regex(
46
+ /^[A-Za-z0-9][A-Za-z0-9-]*\/[A-Za-z0-9._-]+$/,
47
+ 'must be "owner/name" — not a URL, and not a local path',
48
+ );
49
+
50
+ /**
51
+ * A directory inside the repository, for one that holds other things too.
52
+ *
53
+ * Normalised to no leading or trailing slash so `knowledge`, `/knowledge` and
54
+ * `knowledge/` are one prefix rather than three key spaces that look identical
55
+ * in a diff and address different objects.
56
+ */
57
+ const repositoryPath = z
58
+ .string()
59
+ .refine((value) => !value.split('/').includes('..'), {
60
+ message: 'must not contain ".." — it is a prefix inside the repository, not a path to walk',
61
+ })
62
+ .transform((value) => value.replace(/^\/+/, '').replace(/\/+$/, ''));
63
+
64
+ export const knowledgeTargetSchema = z.object({
65
+ adapter: z.literal('github'),
66
+ repo: repository,
67
+ /**
68
+ * Defaults at open time rather than here, to the repository's own default
69
+ * branch. Writing `main` into every generated config would be a guess, and a
70
+ * wrong one for anybody whose default is still `master` — a guess that then
71
+ * fails as "branch not found" rather than as "you did not say".
72
+ */
73
+ branch: z.string().min(1).optional(),
74
+ path: repositoryPath.optional(),
75
+ /**
76
+ * A reference, never the token.
77
+ *
78
+ * Its own ref rather than a reuse of the `github` provider connection's,
79
+ * which holds a token for GitHub's MCP server. That one needs Contents:
80
+ * *read*; this one needs Contents: *write*, has a different lifetime, and
81
+ * should be revocable on its own — revoking an MCP connection must not
82
+ * silently empty somebody's memory.
83
+ */
84
+ token_ref: credentialRef.default('knowledge/token'),
85
+ });
86
+
87
+ export type KnowledgeConfig = z.infer<typeof knowledgeTargetSchema>;
88
+
89
+ /**
90
+ * Where each area sits inside the repository.
91
+ *
92
+ * Two directories, named after the two things that move. `memory` is also the
93
+ * memory provider's own blob namespace — the prefix core scopes it into under
94
+ * the profile's blob root — which is why the same word does both jobs: the
95
+ * route that redirects it and the directory it lands in are the same fact, and
96
+ * spelling them separately is how they would come to disagree.
97
+ *
98
+ * Declared here, beside the schema, so the runtime that opens these stores and
99
+ * the command that migrates into them read one definition.
100
+ */
101
+ export const KNOWLEDGE_LAYOUT = {
102
+ memory: 'memory',
103
+ skills: 'skills',
104
+ } as const;
105
+
106
+ export type KnowledgeArea = keyof typeof KNOWLEDGE_LAYOUT;
107
+
108
+ /** The directory one area occupies, under the profile's optional path prefix. */
109
+ export function knowledgeRoot(knowledge: KnowledgeConfig, area: KnowledgeArea): string {
110
+ const directory = KNOWLEDGE_LAYOUT[area];
111
+ return knowledge.path ? `${knowledge.path}/${directory}` : directory;
112
+ }
113
+
114
+ /** `owner/name`, out of whatever a person pasted. Null when it is not one. */
115
+ export function parseRepository(given: string): string | null {
116
+ const trimmed = given
117
+ .trim()
118
+ .replace(/^https?:\/\/(?:www\.)?github\.com\//i, '')
119
+ .replace(/^(?:ssh:\/\/)?git@[^:]+[:/]/i, '')
120
+ .replace(/\.git$/i, '')
121
+ .replace(/\/+$/, '');
122
+
123
+ return repository.safeParse(trimmed).success ? trimmed : null;
124
+ }
@@ -114,11 +114,9 @@ function assertReferentialIntegrity(config: Config, source: string): void {
114
114
  if (targetNames.size === 0) {
115
115
  problems.push('targets: at least one target must be declared');
116
116
  }
117
- if (!targetNames.has(config.instance.default_target)) {
118
- problems.push(
119
- `instance.default_target: "${config.instance.default_target}" is not a declared target (have: ${[...targetNames].join(', ') || 'none'})`,
120
- );
121
- }
117
+ // `instance.default_target` is deliberately not checked. Nothing reads it
118
+ // (ADR-037), so validating it would be validating a comment — and failing
119
+ // `check` on a stale value would teach that the key still matters.
122
120
 
123
121
  // Only what holds for every platform. What one platform needs and the next
124
122
  // has no concept of — a GCP project, an AWS role ARN — is refused by the
@@ -146,6 +144,20 @@ function assertReferentialIntegrity(config: Config, source: string): void {
146
144
  connectionKeys.add(key);
147
145
  });
148
146
 
147
+ // Same reason as a duplicate connection: two entries with the same kind and
148
+ // value cannot both be meant, and the one that loses is invisible. It matters
149
+ // more here than it looks, because the two would usually differ only in their
150
+ // `note` — so the discarded one is precisely the guidance someone wrote down
151
+ // to stop an agent picking wrong.
152
+ const identityKeys = new Set<string>();
153
+ config.identity.forEach((entry, index) => {
154
+ const key = `${entry.kind}=${entry.value}`;
155
+ if (identityKeys.has(key)) {
156
+ problems.push(`identity[${index}]: duplicate entry "${entry.kind}: ${entry.value}"`);
157
+ }
158
+ identityKeys.add(key);
159
+ });
160
+
149
161
  const checkRules = (rules: readonly PolicyRuleConfig[], field: 'allow' | 'deny'): void => {
150
162
  rules.forEach((rule, index) => {
151
163
  const where = `policy.${field}[${index}]`;
@@ -1,7 +1,7 @@
1
1
  import { z } from 'zod';
2
2
 
3
3
  /**
4
- * The three string shapes the config contract is built out of.
4
+ * The four string shapes the config contract is built out of.
5
5
  *
6
6
  * Their own file because more than one schema module needs them, and the
7
7
  * alternative was either a circular import or a second copy of a regex that
@@ -43,3 +43,26 @@ export const capabilityPattern = z
43
43
  /^(?:\*|[a-z][a-z0-9_]*\.(?:\*|[A-Za-z0-9][A-Za-z0-9_-]*(?:\.[A-Za-z0-9][A-Za-z0-9_-]*)*(?:\.\*)?))$/,
44
44
  'must be "*", "gmail.*", "gmail.users.*", or "gmail.users.drafts.send"',
45
45
  );
46
+
47
+ /**
48
+ * A browser origin, exactly — scheme, host, and port, with nothing after it. Or
49
+ * `*`, which is what an absent list means anyway and is accepted so that saying
50
+ * it explicitly is not an error.
51
+ *
52
+ * Checked by round trip rather than by a regex, because `URL` already knows what
53
+ * an origin is and the failure this catches is a trailing slash or a path that
54
+ * looks harmless and matches nothing: a browser sends `Origin: https://x.example`
55
+ * and a configured `https://x.example/` would compare unequal, silently refusing
56
+ * the origin its owner believed they had allowed.
57
+ */
58
+ export const browserOrigin = z.string().refine(
59
+ (value) => {
60
+ if (value === '*') return true;
61
+ try {
62
+ return new URL(value).origin === value;
63
+ } catch {
64
+ return false;
65
+ }
66
+ },
67
+ 'must be "*" or an origin with no trailing slash or path, e.g. "https://chat.example"',
68
+ );
@@ -1,6 +1,8 @@
1
1
  import { z } from 'zod';
2
- import { capabilityPattern, credentialRef, identifier } from './primitives.ts';
2
+ import { browserOrigin, capabilityPattern, credentialRef, identifier } from './primitives.ts';
3
3
  import { authorizationSchema } from './authorization.ts';
4
+ import { identitySchema } from './identity.ts';
5
+ import { knowledgeTargetSchema } from './knowledge.ts';
4
6
 
5
7
  /**
6
8
  * Configuration is declarative desired state. This file is the source of truth
@@ -214,6 +216,15 @@ export const targetSchema = z
214
216
  audit: auditTargetSchema.optional(),
215
217
  storage: storageTargetSchema,
216
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(),
217
228
  deploy: deployTargetSchema.optional(),
218
229
  /** @deprecated Write `deploy` with `platform: cloudrun`. */
219
230
  cloudrun: legacyCloudRunSchema.optional(),
@@ -313,7 +324,22 @@ export const configSchema = z.object({
313
324
 
314
325
  instance: z.object({
315
326
  profile: identifier,
316
- default_target: z.string().min(1),
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(),
317
343
  port: z.number().int().min(1).max(65535).default(7337),
318
344
  /**
319
345
  * Loopback by default. Binding elsewhere is possible but the server
@@ -334,6 +360,15 @@ export const configSchema = z.object({
334
360
  * get one. Omitting it leaves every existing profile behaving identically.
335
361
  */
336
362
  authorization: authorizationSchema.optional(),
363
+ /**
364
+ * Browser origins that may call a *deployment's* MCP endpoint. Absent
365
+ * means `*`, so naming any is a narrowing and nothing needs setting.
366
+ *
367
+ * A deployment only, and this cannot widen that: a loopback endpoint
368
+ * refuses every cross-origin request and must keep doing so. Why the
369
+ * default is a wildcard is `src/server/cors.ts` and ADR-040.
370
+ */
371
+ allowed_origins: z.array(browserOrigin).optional(),
337
372
  })
338
373
  .default({ mode: 'bearer', token_ref: 'profile/token' }),
339
374
 
@@ -353,6 +388,13 @@ export const configSchema = z.object({
353
388
  */
354
389
  connections: z.array(connectionSchema).default([]),
355
390
  policy: policySchema.default({ allow: [], deny: [] }),
391
+
392
+ /**
393
+ * Who the owner is, for anything written as them. Optional and additive, so
394
+ * every profile written before it keeps loading unchanged — the same reasoning
395
+ * as `auth.authorization` above, and the reason `contract` does not move.
396
+ */
397
+ identity: identitySchema.default([]),
356
398
  });
357
399
 
358
400
  export type Config = z.infer<typeof configSchema>;
@@ -361,7 +403,8 @@ export type PolicyRuleConfig = z.infer<typeof policyRuleSchema>;
361
403
  export type TargetConfig = z.infer<typeof targetSchema>;
362
404
  export type DeployConfig = z.infer<typeof deployTargetSchema>;
363
405
  export type AuthorizationConfig = z.infer<typeof authorizationSchema>;
364
- export { authorizationSchema };
406
+ export { authorizationSchema, identitySchema };
407
+ export type { IdentityEntry } from './identity.ts';
365
408
 
366
409
  /** The workspace file: `lanes-link.yaml`, alongside a `profiles/` directory. */
367
410
  export const workspaceSchema = z.object({
@@ -6,147 +6,107 @@ import type { Config } from './schema.ts';
6
6
  *
7
7
  * A target names an adapter set — where credentials are kept and where bytes
8
8
  * go — and choosing one is a different subject from finding the workspace and
9
- * the profile, which is why it is a different file. `workspace.ts` reached the
10
- * size budget holding both, and the budget's job is to say which of the two
11
- * things a file is doing should leave.
9
+ * the profile, which is why it is a different file.
12
10
  *
13
- * The order is `--target`, then `LANES_LINK_TARGET`, then
14
- * `instance.default_target`. `deploy` resolves it differently and deliberately;
15
- * see `resolveDeployTarget`.
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.
14
+ *
15
+ * The chain this replaces resolved `--target`, then the variable, then the key,
16
+ * and printed which of the three it landed on. What that bought was one flag
17
+ * saved per command. What it cost was that an *ignored* flag still produced a
18
+ * working command — `profile add --target cloud` dropped the flag on the floor
19
+ * and the next command carried on from a different source, so the mistake
20
+ * surfaced one command later with nothing connecting it to its cause. A
21
+ * resolver with nowhere to fall back to cannot fail that way.
16
22
  */
17
23
 
18
- /** The variable that names a target for every command in a shell. */
19
- export const TARGET_ENV = 'LANES_LINK_TARGET';
20
-
21
24
  /**
22
- * The target somebody asked for, before the config gets a say.
23
- *
24
- * Split out because two callers need this precedence and must not disagree
25
- * about it: `resolveSelection` records a provisional answer for `announce`
26
- * before any config is loaded, and `resolveTarget` settles it afterwards. While
27
- * the flag was the only source, both could spell it `targetFlag ?? …` and stay
28
- * accidentally correct. With a second source, one of them learning about it and
29
- * the other not is a line reading `config-default` beside a target the
30
- * environment chose — which is the one line that exists to prevent exactly that.
25
+ * The target this command named, checked against what the profile declares.
31
26
  *
32
- * Returns no target rather than a default: what an unanswered question falls
33
- * back to is `instance.default_target`, and that is the config's to supply.
27
+ * `allowUndeclared` is for the one command whose job is to create the target it
28
+ * was given `deploy`, on a first run. Every other command naming a target that
29
+ * does not exist has made a typo, and the list of what does exist is the useful
30
+ * answer.
34
31
  */
35
- export function askedTarget(
32
+ export function requireTarget(
33
+ config: Config,
36
34
  targetFlag: string | undefined,
37
- env: Record<string, string | undefined>,
38
- ): { target: string | undefined; source: 'flag' | 'environment' | undefined } {
39
- if (targetFlag) return { target: targetFlag, source: 'flag' };
35
+ options: { allowUndeclared?: boolean; profile?: string } = {},
36
+ ): string {
37
+ if (!targetFlag) throw noTargetNamed(config, options.profile);
40
38
 
41
- const fromEnv = env[TARGET_ENV];
42
- if (fromEnv) return { target: fromEnv, source: 'environment' };
39
+ if (options.allowUndeclared !== true && !(targetFlag in config.targets)) {
40
+ throw undeclaredTarget(targetFlag, config, options.profile);
41
+ }
43
42
 
44
- return { target: undefined, source: undefined };
43
+ return targetFlag;
45
44
  }
46
45
 
47
46
  /**
48
- * The refusal for a target that is not in the file, in one spelling.
47
+ * The refusal for a command that named no target.
49
48
  *
50
- * It was two here and in the CLI's `openSecretStoreFor` — which is one more
51
- * than a sentence naming the available targets survives: the copies drift the
52
- * moment either learns something the other does not. This one knows about
53
- * `LANES_LINK_TARGET`, and that is precisely the knowledge a copy would lack
54
- * an exported typo otherwise fails every command in the shell with a message
55
- * that reads as a problem with the config file.
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.
56
54
  */
57
- export function undeclaredTarget(
58
- target: string,
55
+ export function noTargetNamed(
59
56
  config: Config,
60
- source?: 'flag' | 'environment' | 'config-default',
57
+ profile?: string,
58
+ env: Record<string, string | undefined> = process.env as Record<string, string | undefined>,
61
59
  ): ConfigError {
62
- const have = Object.keys(config.targets).join(', ') || 'none';
60
+ const names = Object.keys(config.targets);
61
+ const whose = profile ? ` by profile "${profile}"` : '';
62
+
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');
70
+
71
+ const stale = env[LEGACY_TARGET_ENV];
72
+ const inert = config.instance.default_target;
73
+
63
74
  return new ConfigError(
64
- `Target "${target}" is not declared in this profile (have: ${have})` +
65
- (source === 'environment'
66
- ? `\n${TARGET_ENV}=${target} is set in this shell — unset it, or pass --target.`
75
+ '--target is required. This command opens a target\'s stores, and nothing\n' +
76
+ '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>'}` +
79
+ (stale
80
+ ? `\n\n ${LEGACY_TARGET_ENV}=${stale} is set in this shell and is no longer read.\n` +
81
+ ' 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.'
67
86
  : ''),
68
87
  );
69
88
  }
70
89
 
71
90
  /**
72
- * Fill in the target once the profile's config has been loaded.
73
- *
74
- * Order: `--target`, then `LANES_LINK_TARGET`, then `instance.default_target`.
75
- * The middle one is the same bargain `LANES_LINK_PROFILE` already offers — a
76
- * shell's worth of commands without retyping a flag, somewhere `env` shows it.
91
+ * The variable that used to name a target, kept only to say it is ignored.
77
92
  *
78
- * `allowUndeclared` is for the one command whose job is to create the target it
79
- * was given `deploy`, on a first run. Every other command naming a target that
80
- * does not exist has made a typo, and the list of what does exist is the useful
81
- * answer; refusing there is what stops `--target clod` opening the default one.
93
+ * Not read by anything that resolves. It exists so a refusal can name the thing
94
+ * an operator is looking at and reasonably believes is still working which is
95
+ * the whole difference between "this stopped working" and "this stopped working
96
+ * and here is why".
82
97
  */
83
- export function resolveTarget(
84
- config: Config,
85
- targetFlag?: string,
86
- options: {
87
- allowUndeclared?: boolean;
88
- env?: Record<string, string | undefined>;
89
- } = {},
90
- ): { target: string; source: 'flag' | 'environment' | 'config-default' } {
91
- const env = options.env ?? (process.env as Record<string, string | undefined>);
92
- const asked = askedTarget(targetFlag, env);
93
-
94
- const target = asked.target ?? config.instance.default_target;
95
- const source = asked.source ?? 'config-default';
96
-
97
- if (options.allowUndeclared !== true && !(target in config.targets)) {
98
- throw undeclaredTarget(target, config, source);
99
- }
100
- return { target, source };
101
- }
98
+ export const LEGACY_TARGET_ENV = 'LANES_LINK_TARGET';
102
99
 
103
100
  /**
104
- * The target a deploy means, when nobody said.
105
- *
106
- * `--target cloud` was required on every deploy, and the reason was an accident:
107
- * an absent flag falls back to `instance.default_target`, which is `local` —
108
- * a target that by definition is not deployed anywhere. So the one command whose
109
- * subject is never ambiguous was the one command that made you say it, and the
110
- * default it would otherwise have taken was not merely unhelpful but wrong.
111
- *
112
- * The rule is what someone would say out loud: deploy the target that has a
113
- * deployment. One is the answer; none means the first run, which conventionally
114
- * creates `cloud` and is what every example in the docs names; several is a
115
- * genuine question, and asking beats rolling a revision to whichever came first
116
- * in a YAML mapping.
117
- *
118
- * `--target` still wins, which is how you deploy the second one.
101
+ * The refusal for a target that is not in the file, in one spelling.
119
102
  *
120
- * **`LANES_LINK_TARGET` is deliberately not read here.** It is the same kind of
121
- * answer as `instance.default_target` a shell-wide "where do my commands
122
- * run" and the paragraph above is why that kind of answer is the wrong one
123
- * for this question. It is also the only place where being wrong creates cloud
124
- * resources rather than an error: `deploy` is the one caller that passes
125
- * `allowUndeclared`, so an exported typo would not be refused, it would be
126
- * surveyed, written into the profile, and rolled out as a new service. An
127
- * environment variable must not be able to name a Cloud Run service into
128
- * existence. Say `--target`; `deploy` is rare enough to afford it.
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.
129
106
  */
130
- export const CONVENTIONAL_DEPLOY_TARGET = 'cloud';
131
-
132
- export function resolveDeployTarget(
133
- config: Config,
134
- targetFlag?: string,
135
- ): { target: string; source: 'flag' | 'deployable' } {
136
- if (targetFlag) return { target: targetFlag, source: 'flag' };
137
-
138
- // `deploy` alone: the legacy `cloudrun:` spelling is normalised into it by the
139
- // loader, so exactly one shape reaches here.
140
- const deployable = Object.entries(config.targets)
141
- .filter(([, declared]) => declared.deploy !== undefined)
142
- .map(([name]) => name);
143
-
144
- if (deployable.length > 1) {
145
- throw new ConfigError(
146
- `This profile declares ${deployable.length} deployable targets (${deployable.join(', ')}). ` +
147
- 'Name the one you mean with --target.',
148
- );
149
- }
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';
150
110
 
151
- return { target: deployable[0] ?? CONVENTIONAL_DEPLOY_TARGET, source: 'deployable' };
111
+ return new ConfigError(`Target "${target}" is not declared by ${whose} (have: ${have})`);
152
112
  }