@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
@@ -0,0 +1,125 @@
1
+ import { containedKey, type BlobKey, type BlobMetadata, type BlobStore } from '#stores/blobs';
2
+ import { inferContentType } from './filesystem.ts';
3
+ import type { GithubRepository } from './github-repo.ts';
4
+
5
+ /**
6
+ * A GitHub repository as a `BlobStore`.
7
+ *
8
+ * The one thing a profile can put here is what the owner wrote — memory entries
9
+ * and skills. Not state, not the audit log, not the credential store, and never
10
+ * the vault; `src/profile/knowledge.ts` says why, and says it as a schema with
11
+ * no field to set rather than as a default.
12
+ *
13
+ * **The API, not a clone.** A clone would be faster and would work offline, and
14
+ * it fails on the property this interface exists to provide: a container
15
+ * filesystem is discarded on every revision, so a deployed endpoint's clone
16
+ * would be re-fetched at best and silently empty at worst. That is precisely
17
+ * the bug ADR-014 §2 fixed for the vault, and re-introducing it for memory is
18
+ * not worth the latency. It also needs a `git` binary, which the image does not
19
+ * carry. ADR-041.
20
+ *
21
+ * Vendor-named, like `gcs.ts` and `gcp-secret-manager.ts` beside it: ADR-013's
22
+ * rule is that an adapter for a *protocol* takes the protocol's name, and a
23
+ * client for one vendor's API cannot honestly claim one.
24
+ *
25
+ * **Several of these share one `GithubRepository`.** Memory and skills are two
26
+ * roots in one repository, and pointing both at one client means one head, one
27
+ * tree, and one blob cache between them.
28
+ */
29
+
30
+ export interface GithubBlobStoreOptions {
31
+ readonly repository: GithubRepository;
32
+ /**
33
+ * A directory inside the repository that this store is rooted at — `memory`,
34
+ * `skills`, or those under a profile's own `path` prefix. Keys never see it.
35
+ */
36
+ readonly root?: string | undefined;
37
+ /** What a write commit says it did. `%s` is the key. */
38
+ readonly message?: ((operation: 'store' | 'remove', key: string) => string) | undefined;
39
+ }
40
+
41
+ const DEFAULT_MESSAGE = (operation: 'store' | 'remove', key: string): string =>
42
+ `${operation === 'store' ? 'Store' : 'Remove'} ${key}`;
43
+
44
+ export function createGithubBlobStore(options: GithubBlobStoreOptions): BlobStore {
45
+ const repository = options.repository;
46
+ const message = options.message ?? DEFAULT_MESSAGE;
47
+ const root = (options.root ?? '').replace(/^\/+/, '').replace(/\/+$/, '');
48
+ const prefix = root === '' ? '' : `${root}/`;
49
+
50
+ /**
51
+ * A key becomes a path in the repository.
52
+ *
53
+ * `containedKey` is `#stores/blobs`', not a second rule written here.
54
+ * `conformance.ts` asserts that a key one target refuses is not one another
55
+ * accepts, and that can only hold while there is exactly one answer to what
56
+ * a key resolves to.
57
+ */
58
+ const pathFor = (key: BlobKey): string => `${prefix}${containedKey(key)}`;
59
+
60
+ /** The reverse, for a listing. Null when the path is outside this root. */
61
+ const keyFor = (path: string): BlobKey | null =>
62
+ path.startsWith(prefix) ? path.slice(prefix.length) : null;
63
+
64
+ return {
65
+ async put(key, data, putOptions) {
66
+ // `putOptions.contentType` is accepted and not stored, which is the one
67
+ // place this adapter cannot match the others. There is nowhere to put it:
68
+ // a sidecar would be a file in the owner's own repository, listed beside
69
+ // their entries and loaded as one. `list` infers from the extension
70
+ // instead, using the same map the filesystem adapter infers from — which
71
+ // covers every type anything here writes.
72
+ void putOptions;
73
+ await repository.writeFile(pathFor(key), data, message('store', key));
74
+ },
75
+
76
+ async get(key) {
77
+ const path = pathFor(key);
78
+ const { entries } = await repository.entries();
79
+ const entry = entries.get(path);
80
+ // Absence is a value, not an error — every caller is written against null.
81
+ if (!entry) return null;
82
+
83
+ // An empty file has a real blob and zero bytes; `blob` returns the empty
84
+ // array rather than null, which is the case `conformance.ts` pins.
85
+ return repository.blob(entry.sha);
86
+ },
87
+
88
+ async has(key) {
89
+ const path = pathFor(key);
90
+ return (await repository.entries()).entries.has(path);
91
+ },
92
+
93
+ async delete(key) {
94
+ // Deleting what is not there is not a failure: callers use this to make
95
+ // absence true, and a sweep racing another sweep must not throw.
96
+ await repository.deleteFile(pathFor(key), message('remove', key));
97
+ },
98
+
99
+ async list(innerPrefix) {
100
+ const { entries, committedAt } = await repository.entries();
101
+ const wanted = innerPrefix ?? '';
102
+ const found: BlobMetadata[] = [];
103
+
104
+ for (const entry of entries.values()) {
105
+ const key = keyFor(entry.path);
106
+ if (key === null || !key.startsWith(wanted)) continue;
107
+
108
+ const contentType = inferContentType(key);
109
+ found.push({
110
+ key,
111
+ size: entry.size,
112
+ ...(contentType ? { contentType } : {}),
113
+ // The branch tip's date, the same for every entry in one listing.
114
+ // Per-file timestamps would be one `GET /commits?path=…` each, and
115
+ // nothing needs them: `skillFingerprint` only needs this to change
116
+ // when the tree does, and memory uses it as a fallback for a
117
+ // hand-written file with no `updated_at` in its frontmatter.
118
+ modifiedAt: committedAt,
119
+ });
120
+ }
121
+
122
+ return found.sort((a, b) => a.key.localeCompare(b.key));
123
+ },
124
+ };
125
+ }
@@ -1,4 +1,4 @@
1
- import { ConfigError, resolveDeployTarget, type DeployConfig } from '#profile';
1
+ import { ConfigError, type DeployConfig } from '#profile';
2
2
  import { announce, fail, heading, ok, print, style, warn } from '#cli/output.ts';
3
3
  import { staleNudge } from '#cli/release.ts';
4
4
  import { confirm, isInteractive } from '#cli/prompt.ts';
@@ -8,6 +8,7 @@ import { printSteps, runSteps } from './steps.ts';
8
8
  import { driverFor } from './drivers.ts';
9
9
  import { prepareSecrets, readableRefs, rotatableRefs } from './prepare.ts';
10
10
  import { deployedWorkspace, repairSetupSurface, uploadWorkspace } from './upload.ts';
11
+ import { unservableProfiles, unservableRefusal } from './servable.ts';
11
12
 
12
13
  /**
13
14
  * `lanes link deploy` — set up what is missing, build the image, roll a revision,
@@ -46,13 +47,20 @@ export interface DeployFlags extends GlobalFlags {
46
47
  export async function deploy(flags: DeployFlags): Promise<void> {
47
48
  // The one command allowed to name a target that does not exist yet: creating
48
49
  // it is what a first deploy is for.
49
- const { resolution, config } = await resolveProfile(flags, { allowUndeclaredTarget: true });
50
-
51
- // And the one command that can work out which target it meant. `resolveProfile`
52
- // falls back to `instance.default_target`, which is the target commands *run*
53
- // against `local`, and never the answer to "deploy what".
54
- const { target, source } = resolveDeployTarget(config, flags.target);
55
- announce({ ...resolution, target, targetSource: source });
50
+ //
51
+ // It used to work out its own target too — the one declaring a `deploy` block,
52
+ // guessing when there was one, refusing when there were two, and inventing
53
+ // `cloud` when there were none. That inference was a defence against
54
+ // `instance.default_target`, which is `local` on a scaffolded profile and
55
+ // never the answer to "deploy what". With the fallback gone (ADR-037) the
56
+ // defence has nothing to defend against, and what is left is three behaviours
57
+ // from one command line on the command that creates cloud resources and rolls
58
+ // a public URL — where `allowUndeclaredTarget` means a mistake is not refused
59
+ // but surveyed, written into the profile, and deployed as a new service.
60
+ const { resolution, config, target } = await resolveProfile(flags, {
61
+ allowUndeclaredTarget: true,
62
+ });
63
+ announce(resolution);
56
64
 
57
65
  // `check` before anything external, per the gate order: a config that will be
58
66
  // rejected on boot should be rejected here, not after a five-minute build.
@@ -103,7 +111,7 @@ export async function deploy(flags: DeployFlags): Promise<void> {
103
111
  deploy: deployConfig,
104
112
  tag,
105
113
  target,
106
- ...(flags.profile !== undefined ? { profile: flags.profile } : {}),
114
+ profile: resolution.profile,
107
115
  ...(workspace !== undefined ? { workspace } : {}),
108
116
  ...(secretEnv ? { secretEnv } : {}),
109
117
  });
@@ -168,8 +176,9 @@ export async function deploy(flags: DeployFlags): Promise<void> {
168
176
  for (const problem of prepared.blocking) print(fail(problem));
169
177
  throw new ConfigError(
170
178
  'The deployed instance cannot start without these. Store them with ' +
171
- `lanes link secrets set <ref> --target ${target}, or copy a local setup with ` +
172
- `lanes link secrets push --from local --to ${target}.`,
179
+ `lanes link secrets set <ref> --profile ${resolution.profile} --target ${target}, or ` +
180
+ `copy a local setup with lanes link secrets push --profile ${resolution.profile} ` +
181
+ `--from local --to ${target}.`,
173
182
  );
174
183
  }
175
184
 
@@ -193,6 +202,25 @@ export async function deploy(flags: DeployFlags): Promise<void> {
193
202
  // the upload sends would leave a served profile without the surface — this
194
203
  // bug again, one profile over.
195
204
  if (workspace) {
205
+ // Before anything is copied, and before the rollout: the endpoint opens
206
+ // every profile in the bucket against this one target, so a profile that
207
+ // does not declare it is not a profile that gets skipped — it is a revision
208
+ // that never goes healthy. `servable.ts` has the whole failure.
209
+ //
210
+ // Here rather than at the top of the command because it reads the same
211
+ // scope the upload does, and that scope is not settled until `workspace`
212
+ // says there is a bucket to send to at all.
213
+ const unservable = await unservableProfiles({
214
+ workspaceRoot: resolution.workspaceRoot,
215
+ profile: flags.profile,
216
+ target,
217
+ });
218
+
219
+ if (unservable.length > 0) {
220
+ heading('Cannot be served');
221
+ throw new ConfigError(unservableRefusal(unservable, target));
222
+ }
223
+
196
224
  await repairSetupSurface(resolution.workspaceRoot, flags.profile);
197
225
 
198
226
  // Before the rollout, so the revision that comes up finds a config to read.
@@ -208,7 +236,7 @@ export async function deploy(flags: DeployFlags): Promise<void> {
208
236
  if (!url) {
209
237
  print(
210
238
  warn(
211
- `deployed, but the platform reported no URL yet — run: lanes link outputs --target ${target}`,
239
+ `deployed, but the platform reported no URL yet — run: lanes link outputs --profile ${resolution.profile} --target ${target}`,
212
240
  ),
213
241
  );
214
242
  return;
@@ -218,9 +246,9 @@ export async function deploy(flags: DeployFlags): Promise<void> {
218
246
  print(` ${url}/mcp`);
219
247
  print(await healthLine(url));
220
248
  print('');
221
- print(registerLine(target));
249
+ print(registerLine(resolution.profile, target));
222
250
 
223
- reportUnauthorised(prepared.warnings, target);
251
+ reportUnauthorised(prepared.warnings, resolution.profile, target);
224
252
  }
225
253
 
226
254
  /**
@@ -242,9 +270,10 @@ export async function deploy(flags: DeployFlags): Promise<void> {
242
270
  * appeared only on a later re-deploy, by which point the connector is usually
243
271
  * registered and the ordering is no longer available to get right.
244
272
  */
245
- function registerLine(target: string): string {
273
+ function registerLine(profile: string, target: string): string {
246
274
  return style.dim(
247
- ` Connect your accounts first, then register with: lanes link outputs --target ${target}\n` +
275
+ ` Connect your accounts first, then register with:\n` +
276
+ ` lanes link outputs --profile ${profile} --target ${target}\n` +
248
277
  ' A client keeps the tool list it fetched when it connected, so one registered\n' +
249
278
  ' before the accounts holds a surface without them until it is re-added.',
250
279
  );
@@ -264,7 +293,7 @@ function registerLine(target: string): string {
264
293
  * refusing it, naming the connection rather than the staleness. Reconcile now
265
294
  * runs again on every reload, and `connect` asks for one (ADR-029).
266
295
  */
267
- function reportUnauthorised(warnings: readonly string[], target: string): void {
296
+ function reportUnauthorised(warnings: readonly string[], profile: string, target: string): void {
268
297
  if (warnings.length === 0) return;
269
298
 
270
299
  heading('Not authorised yet');
@@ -273,7 +302,7 @@ function reportUnauthorised(warnings: readonly string[], target: string): void {
273
302
  print(
274
303
  style.dim(
275
304
  ' A browser consent per account is the one step this cannot take for you:\n' +
276
- ` lanes link connect <provider> --target ${target}\n` +
305
+ ` lanes link connect <provider> --profile ${profile} --target ${target}\n` +
277
306
  ' Each is served as soon as it is authorised. There is no second deploy —\n' +
278
307
  ' deploying is how code gets here, and authorising an account changes none.',
279
308
  ),
@@ -313,7 +342,7 @@ async function healthLine(url: string): Promise<string> {
313
342
  return ok(
314
343
  body.profiles
315
344
  ? `healthy — serving ${body.profiles.join(', ')}`
316
- : 'healthy — run `lanes link outputs` for what it serves',
345
+ : 'healthy — run `lanes link outputs` with this profile and target for what it serves',
317
346
  );
318
347
  } catch {
319
348
  // A cold start plus a database connect can outrun a short probe, and
@@ -39,7 +39,14 @@ export interface PlanInput {
39
39
  readonly deploy: DeployConfig;
40
40
  readonly tag: string;
41
41
  readonly target: string;
42
- readonly profile?: string | undefined;
42
+ /**
43
+ * The primary profile this revision serves.
44
+ *
45
+ * Required rather than optional: `deploy` names it (ADR-037), and a revision
46
+ * that boots without `LANES_LINK_PROFILE` refuses. Leaving it optional here is
47
+ * what let deployments ship without the variable at all.
48
+ */
49
+ readonly profile: string;
43
50
  /**
44
51
  * Where the running instance reads its config, as a store URL.
45
52
  *
@@ -103,7 +103,9 @@ export function deployPlan(input: PlanInput): DeployStep[] {
103
103
  '--set-env-vars',
104
104
  [
105
105
  `LANES_LINK_TARGET=${input.target}`,
106
- ...(input.profile ? [`LANES_LINK_PROFILE=${input.profile}`] : []),
106
+ // Unconditional: `deploy` requires --profile (ADR-037), so this is
107
+ // always known, and a revision without it refuses at boot.
108
+ `LANES_LINK_PROFILE=${input.profile}`,
107
109
  ...(input.workspace ? [`LANES_LINK_HOME=${input.workspace}`] : []),
108
110
  ].join(','),
109
111
  ...secretMounts(input.secretEnv),
@@ -0,0 +1,119 @@
1
+ import type { SecretStore } from '#secrets';
2
+ import type { BlobStore } from '#stores/blobs';
3
+ import { knowledgeRoot, type KnowledgeConfig } from '#profile';
4
+ import { requireSecret, type TargetInput } from './target.ts';
5
+ import type { FetchLike } from './adapters/github-api.ts';
6
+ // Type-only, so a target with no `knowledge` block never loads the adapter.
7
+ // The same rule `target.ts` follows for the cloud adapters, kept here by the
8
+ // fact that a type import is erased entirely.
9
+ import type { GithubRepository } from './adapters/github-repo.ts';
10
+ // Re-exported so a caller that wants to replace the transport does not have to
11
+ // reach past this module into an adapter. It is narrower than
12
+ // `typeof globalThis.fetch`, which under Bun's types carries a `preconnect` a
13
+ // test double would otherwise have to stub.
14
+ export type { FetchLike } from './adapters/github-api.ts';
15
+
16
+ /**
17
+ * Opening the place a profile keeps its memory and its skills, when that is not
18
+ * the place it keeps everything else.
19
+ *
20
+ * Its own file rather than a fifth case in `target.ts`, because it is not the
21
+ * same kind of decision. `target.ts` answers "where does this target run" —
22
+ * credentials here, bytes there — and every consumer of a `BlobStore` rides
23
+ * that one answer. This answers a narrower one: two directories of documents
24
+ * the owner wrote have somewhere else to be, and nothing else moves with them.
25
+ *
26
+ * ADR-041 has the argument. `src/profile/knowledge.ts` has the contract, and
27
+ * the reason there is no field here that could name the credential store or the
28
+ * vault.
29
+ */
30
+
31
+ /**
32
+ * One repository client, two stores.
33
+ *
34
+ * Memory and skills are two directories in one repository, and pointing both at
35
+ * one client means one branch head, one tree, and one blob cache between them —
36
+ * so the endpoint's two-second skill poll keeps memory's view current for free.
37
+ * Two clients would each poll, each cache, and could disagree about which
38
+ * commit is current.
39
+ */
40
+ export interface KnowledgeStores {
41
+ readonly repository: GithubRepository;
42
+ readonly skills: BlobStore;
43
+ /** Rooted at the repository's memory directory; keys are `<connection>/<id>.md`. */
44
+ readonly memory: BlobStore;
45
+ /** One line for `target show` and `doctor`, so the config file is not the only witness. */
46
+ readonly describe: string;
47
+ }
48
+
49
+ /**
50
+ * `undefined` when the target declares no `knowledge` block — which is every
51
+ * profile written before it existed, so the caller's fallback is the path that
52
+ * has always run rather than a special case.
53
+ */
54
+ export async function openKnowledge(
55
+ input: TargetInput,
56
+ secrets: SecretStore,
57
+ /** Injected for tests. The repository is the only thing these stores reach. */
58
+ call?: FetchLike,
59
+ ): Promise<KnowledgeStores | undefined> {
60
+ const { declared, target } = input;
61
+ const knowledge = declared.knowledge;
62
+ if (!knowledge) return undefined;
63
+
64
+ const token = await requireSecret(
65
+ secrets,
66
+ knowledge.token_ref,
67
+ `targets.${target}.knowledge.token_ref`,
68
+ target,
69
+ 'the github adapter',
70
+ );
71
+
72
+ const { GithubRepository } = await import('./adapters/github-repo.ts');
73
+ const repository = new GithubRepository({
74
+ repo: knowledge.repo,
75
+ token,
76
+ ...(knowledge.branch !== undefined ? { branch: knowledge.branch } : {}),
77
+ ...(call ? { fetch: call } : {}),
78
+ });
79
+
80
+ return {
81
+ repository,
82
+ ...(await knowledgeStores(repository, knowledge)),
83
+ describe: describeKnowledge(knowledge),
84
+ };
85
+ }
86
+
87
+ /**
88
+ * The two stores a knowledge repository holds.
89
+ *
90
+ * Separate from `openKnowledge` because `lanes link knowledge use` builds these
91
+ * against a repository it is still probing — before any of it has been written
92
+ * into the config `openKnowledge` reads. Two spellings of one layout is how a
93
+ * migration comes to write where nothing later looks.
94
+ */
95
+ export async function knowledgeStores(
96
+ repository: GithubRepository,
97
+ knowledge: KnowledgeConfig,
98
+ ): Promise<{ skills: BlobStore; memory: BlobStore }> {
99
+ const { createGithubBlobStore } = await import('./adapters/github.ts');
100
+
101
+ const build = (area: 'memory' | 'skills'): BlobStore =>
102
+ createGithubBlobStore({
103
+ repository,
104
+ root: knowledgeRoot(knowledge, area),
105
+ // What the commit says it did. Named per area rather than left to the
106
+ // adapter's default, because "Store main/note.md" in a repository holding
107
+ // both is a line that does not say which of them changed.
108
+ message: (operation, key) =>
109
+ `${operation === 'store' ? 'Store' : 'Remove'} ${area === 'skills' ? 'skill' : 'memory'} ${key}`,
110
+ });
111
+
112
+ return { skills: build('skills'), memory: build('memory') };
113
+ }
114
+
115
+ /** `github:owner/name#branch/path`, in one place so every reader agrees. */
116
+ export function describeKnowledge(knowledge: KnowledgeConfig): string {
117
+ const branch = knowledge.branch ? `#${knowledge.branch}` : '';
118
+ return `github:${knowledge.repo}${branch}${knowledge.path ? `/${knowledge.path}` : ''}`;
119
+ }
@@ -188,7 +188,7 @@ export async function prepareSecrets(input: PrepareInput): Promise<PrepareResult
188
188
  if (!ref || (await credentials.has(ref))) continue;
189
189
  warnings.push(
190
190
  `${connection.provider}.${connection.id} is not authorised yet — no credential at "${ref}"\n` +
191
- ` lanes link connect ${connection.provider} --target ${target} --id ${connection.id}`,
191
+ ` lanes link connect ${connection.provider} --profile ${input.config.instance.profile} --target ${target} --id ${connection.id}`,
192
192
  );
193
193
  }
194
194
 
@@ -252,6 +252,6 @@ async function seedProfileToken(input: {
252
252
  }
253
253
 
254
254
  const { created } = await ensureProfileToken(input.credentials, ref);
255
- if (created) print(ok(`minted an endpoint token at "${ref}" — read it with: lanes link token show`));
255
+ if (created) print(ok(`minted an endpoint token at "${ref}" — read it with lanes link token show`));
256
256
  }
257
257
 
@@ -0,0 +1,81 @@
1
+ import { listProfiles, loadProfileConfig } from '#profile';
2
+
3
+ /**
4
+ * Whether every profile a deploy sends can actually run on the target it sends them to.
5
+ *
6
+ * A deploy uploads config for a set of profiles and rolls a revision baked with
7
+ * one `LANES_LINK_TARGET`. The endpoint then opens *every* profile it finds in
8
+ * the bucket against that one target — see `openReconciled` — and a profile that
9
+ * does not declare it throws on the way up. The catch there closes the runtimes
10
+ * and rethrows, so the container exits and the revision never goes healthy.
11
+ *
12
+ * That is the whole failure, and its shape is what makes it worth a pre-flight:
13
+ * the profile at fault is usually the one just scaffolded, the operator was not
14
+ * thinking about the deployment when they made it, and the symptom is a revision
15
+ * that will not start — which reads as a problem with the deploy, the image, or
16
+ * the platform. Nothing points at the new profile. Refusing here costs one file
17
+ * read per profile and names it.
18
+ *
19
+ * Its own file, not a private function in `deploy.ts`, for the reason
20
+ * `handoff.ts` gives: `deploy.test.ts` does not import `deploy.ts`, and making
21
+ * it do so would pull the whole CLI runtime into a unit test.
22
+ */
23
+
24
+ export interface Unservable {
25
+ readonly profile: string;
26
+ /** What it does declare, so the refusal says how far off it is. */
27
+ readonly declares: readonly string[];
28
+ }
29
+
30
+ /**
31
+ * The profiles this deploy would send that the revision could not open.
32
+ *
33
+ * Scoped exactly as `uploadWorkspace` and `repairSetupSurface` are — by the
34
+ * `--profile` flag, absent meaning the whole workspace — because the set that
35
+ * gets uploaded is the set that gets served, and checking a different one would
36
+ * be checking the wrong question.
37
+ *
38
+ * A profile that cannot be parsed is not reported here. It is already fatal
39
+ * further along, with a better message than this could give, and a YAML error
40
+ * dressed up as "cannot run on cloud" would send someone looking at their
41
+ * targets instead of their syntax.
42
+ */
43
+ export async function unservableProfiles(input: {
44
+ readonly workspaceRoot: string;
45
+ readonly profile: string | undefined;
46
+ readonly target: string;
47
+ }): Promise<Unservable[]> {
48
+ const found: Unservable[] = [];
49
+
50
+ for (const name of await listProfiles(input.workspaceRoot)) {
51
+ if (input.profile !== undefined && name !== input.profile) continue;
52
+
53
+ let declared: string[];
54
+ try {
55
+ const { config } = await loadProfileConfig(input.workspaceRoot, name);
56
+ declared = Object.keys(config.targets);
57
+ } catch {
58
+ continue;
59
+ }
60
+
61
+ if (!declared.includes(input.target)) found.push({ profile: name, declares: declared });
62
+ }
63
+
64
+ return found;
65
+ }
66
+
67
+ /** The refusal, as a block, so the wording is testable without a deploy. */
68
+ export function unservableRefusal(found: readonly Unservable[], target: string): string {
69
+ const rows = found
70
+ .map((one) => ` ${one.profile} declares: ${one.declares.join(', ') || 'nothing'}`)
71
+ .join('\n');
72
+
73
+ return (
74
+ `${found.length} profile${found.length === 1 ? '' : 's'} would be uploaded that cannot run on "${target}":\n` +
75
+ `${rows}\n\n` +
76
+ ' The endpoint opens every profile in the bucket with this target, so the\n' +
77
+ ' revision would come up and refuse to start.\n' +
78
+ ` lanes link profile add <name> --target ${target} declares it on a new one\n` +
79
+ ' --profile <name> deploys just the one'
80
+ );
81
+ }
@@ -271,15 +271,16 @@ export async function openStorage(
271
271
  * Two refs make the same two mistakes available twice over, and "undefined is
272
272
  * not a string" from inside an S3 client is not a diagnosis.
273
273
  */
274
- async function requireSecret(
274
+ export async function requireSecret(
275
275
  secrets: SecretStore,
276
276
  ref: string | undefined,
277
277
  field: string,
278
278
  target: string,
279
+ requiredBy = 'the s3 adapter',
279
280
  ): Promise<string> {
280
281
  if (!ref) {
281
282
  throw new ConfigError(
282
- `${field} is required for the s3 adapter — the key is a credential, so config names it ` +
283
+ `${field} is required for ${requiredBy} — the value is a credential, so config names it ` +
283
284
  'rather than carrying it.',
284
285
  );
285
286
  }
@@ -7,7 +7,8 @@ import {
7
7
  type Config,
8
8
  type TargetConfig,
9
9
  } from '#profile';
10
- import { ConfigDocument, ensureSetupConnection, repairLines, repaired } from '#cli/config-edit.ts';
10
+ import { ConfigDocument } from '#cli/config-edit.ts';
11
+ import { ensureSetupConnection, repairLines, repaired } from '#cli/config-repair.ts';
11
12
  import { ok, print, style, warn } from '#cli/output.ts';
12
13
 
13
14
  /**
@@ -241,7 +241,7 @@ export class Dispatcher {
241
241
  return (outcome = deny(
242
242
  'denied_connection_unauthorized',
243
243
  record.status === 'unauthorized'
244
- ? `Connection ${request.connectionKey} has no valid credential. Run: lanes link connect ${request.connectionKey}`
244
+ ? `Connection ${request.connectionKey} has no valid credential. Connecting it again for this profile and target would store one.`
245
245
  : `Connection ${request.connectionKey} is disabled.`,
246
246
  ));
247
247
  }
@@ -0,0 +1,60 @@
1
+ import { z } from 'zod';
2
+ import { identifier } from './primitives.ts';
3
+
4
+ /**
5
+ * Who the owner is, in this profile — the names, addresses, and handles they
6
+ * want used when something is written as them.
7
+ *
8
+ * A profile already says what is *reachable*: connections, credentials, what
9
+ * policy permits. It said nothing about *whose* they are. So an agent composing
10
+ * a message, opening a pull request, or signing off had to infer a name from
11
+ * whatever happened to be in the conversation, and it mixed them across
12
+ * profiles — the work signature on a personal mailbox, the wrong handle in a
13
+ * commit trailer. The information was knowable and simply not written down.
14
+ *
15
+ * Three shapes were possible and only this one is right:
16
+ *
17
+ * - **A list, not a map.** `names: [John, Semin]` cannot say *when* to use
18
+ * which, and the note is the whole point — an owner with two names has them
19
+ * for a reason. A flat list of `{kind, value, note}` mirrors `connections`,
20
+ * which is the other list in this file whose order a reader relies on.
21
+ * - **`kind` is free-form**, any `identifier`. Shipping an enum would mean a
22
+ * release every time someone wants `linkedin`, `pronouns`, or `signature`,
23
+ * and there is nothing this file could do with the knowledge that a value is
24
+ * an email that would be worth that. Rendering is generic and stays generic.
25
+ * - **`note` is prose, not a reference.** Binding an entry to a connection was
26
+ * the obvious alternative and is a trap: it puts a cross-reference into
27
+ * `assertReferentialIntegrity`, and then renaming a connection breaks config
28
+ * *load* — the profile stops opening because a name in a signature moved.
29
+ * The agent reading "use with the personal mailbox" gets it right without
30
+ * costing anything the day that mailbox is renamed.
31
+ *
32
+ * Declaration order is meaningful and preserved: the first entry of a kind is
33
+ * the one to reach for absent a reason, and the reader of the YAML sees the same
34
+ * order the agent is told.
35
+ *
36
+ * Not a secret, and worth being explicit about why, because this is the first
37
+ * block in a profile that holds the owner's own data rather than a pointer to
38
+ * it. A name and an address are disclosed by the first message of any mailbox
39
+ * this endpoint serves; withholding them here while serving the mailbox would
40
+ * be theatre. What is refused is a *credential* pasted into a value — a token
41
+ * beginning `ghp_` in a `github` entry trips `secret-detection.ts` like anything
42
+ * else in this file, which is that check working rather than getting in the way.
43
+ */
44
+ export const identityEntrySchema = z.object({
45
+ /** `name`, `email`, `github` — or anything else the owner finds useful. */
46
+ kind: identifier,
47
+ value: z.string().min(1),
48
+ /**
49
+ * When this one applies, in the owner's words.
50
+ *
51
+ * Optional, and usually absent on the only entry of its kind: a profile with
52
+ * one address needs no note explaining which address to use. It earns its
53
+ * place the moment there are two.
54
+ */
55
+ note: z.string().min(1).optional(),
56
+ });
57
+
58
+ export const identitySchema = z.array(identityEntrySchema);
59
+
60
+ export type IdentityEntry = z.infer<typeof identityEntrySchema>;