@lanes-sh/link 0.2.1 → 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 (139) hide show
  1. package/README.md +21 -8
  2. package/instructions/skills/lanes-link/SKILL.md +59 -14
  3. package/package.json +1 -1
  4. package/src/auth/index.ts +3 -1
  5. package/src/auth/oauth/metadata.ts +83 -9
  6. package/src/auth/oauth/redirects.ts +70 -0
  7. package/src/auth/oauth/server.ts +49 -69
  8. package/src/auth/oauth/store.ts +19 -5
  9. package/src/cli/argv.ts +50 -0
  10. package/src/cli/brand.ts +178 -0
  11. package/src/cli/callback-page.ts +108 -128
  12. package/src/cli/commands/connect/accounts.ts +5 -0
  13. package/src/cli/commands/connect/assertion.ts +187 -0
  14. package/src/cli/commands/connect/authorise.ts +61 -17
  15. package/src/cli/commands/connect/client.ts +37 -9
  16. package/src/cli/commands/connect/discover.ts +94 -0
  17. package/src/cli/commands/connect/family.ts +72 -0
  18. package/src/cli/commands/connect/index.ts +113 -115
  19. package/src/cli/commands/connect/method.ts +237 -0
  20. package/src/cli/commands/connect/outcome.ts +42 -1
  21. package/src/cli/commands/connect/pasted-token.ts +66 -0
  22. package/src/cli/commands/connect/requirements.ts +60 -8
  23. package/src/cli/commands/connect/setup.ts +16 -5
  24. package/src/cli/commands/connect/target-note.ts +34 -0
  25. package/src/cli/commands/identity.ts +258 -0
  26. package/src/cli/commands/knowledge/index.ts +390 -0
  27. package/src/cli/commands/knowledge/migrate.ts +180 -0
  28. package/src/cli/commands/knowledge/setup.ts +144 -0
  29. package/src/cli/commands/knowledge.ts +10 -0
  30. package/src/cli/commands/mcp/harnesses.ts +16 -2
  31. package/src/cli/commands/mcp/register.ts +9 -1
  32. package/src/cli/commands/mcp/stdio.ts +21 -0
  33. package/src/cli/commands/operate/dashboard.ts +107 -0
  34. package/src/cli/commands/operate/findings.ts +151 -0
  35. package/src/cli/commands/operate/inspect.ts +56 -158
  36. package/src/cli/commands/operate/outputs.ts +38 -11
  37. package/src/cli/commands/operate/serve.ts +3 -0
  38. package/src/cli/commands/operate/token.ts +1 -1
  39. package/src/cli/commands/operate.ts +2 -0
  40. package/src/cli/commands/profile/declare.ts +154 -0
  41. package/src/cli/commands/profile/removal.ts +17 -0
  42. package/src/cli/commands/profile.ts +83 -35
  43. package/src/cli/commands/setup.ts +22 -6
  44. package/src/cli/commands/target.ts +65 -83
  45. package/src/cli/config-edit.ts +48 -144
  46. package/src/cli/config-repair.ts +186 -0
  47. package/src/cli/dashboard-page.ts +284 -0
  48. package/src/cli/dashboard-shell.ts +125 -0
  49. package/src/cli/identity.ts +12 -1
  50. package/src/cli/main.ts +68 -4
  51. package/src/cli/oauth-callback.ts +187 -0
  52. package/src/cli/oauth-exchange.ts +57 -15
  53. package/src/cli/oauth.ts +67 -177
  54. package/src/cli/output.ts +21 -5
  55. package/src/cli/provider-marks.ts +45 -0
  56. package/src/cli/runtime/open.ts +67 -49
  57. package/src/cli/runtime/registry.ts +60 -2
  58. package/src/cli/runtime/select.ts +26 -13
  59. package/src/cli/runtime/vault.ts +61 -0
  60. package/src/cli/runtime.ts +2 -1
  61. package/src/cli/selection.ts +357 -0
  62. package/src/cli/usage.ts +32 -9
  63. package/src/connectivity/auth/README.md +7 -1
  64. package/src/connectivity/auth/basic/index.ts +1 -1
  65. package/src/connectivity/auth/index.ts +14 -0
  66. package/src/connectivity/auth/oauth-authcode/broker.ts +26 -0
  67. package/src/connectivity/auth/oauth-authcode/index.ts +16 -2
  68. package/src/connectivity/auth/oauth-authcode/provider.ts +1 -1
  69. package/src/connectivity/auth/oauth-authcode/refresh.ts +3 -3
  70. package/src/connectivity/auth/oauth-jwt/README.md +33 -0
  71. package/src/connectivity/auth/oauth-jwt/index.ts +237 -0
  72. package/src/connectivity/auth/oauth-jwt/key.ts +148 -0
  73. package/src/connectivity/auth/resolve.ts +1 -1
  74. package/src/connectivity/auth/token.ts +11 -0
  75. package/src/connectivity/index.ts +2 -0
  76. package/src/connectivity/manifest/auth.ts +99 -2
  77. package/src/connectivity/manifest/identity.ts +12 -0
  78. package/src/connectivity/manifest/index.ts +3 -1
  79. package/src/connectivity/manifest/provider.ts +37 -8
  80. package/src/connectivity/manifest/requirements.ts +109 -6
  81. package/src/deployments/adapters/filesystem.ts +10 -1
  82. package/src/deployments/adapters/github-api.ts +106 -0
  83. package/src/deployments/adapters/github-commit.ts +103 -0
  84. package/src/deployments/adapters/github-repo.ts +356 -0
  85. package/src/deployments/adapters/github-testing.ts +258 -0
  86. package/src/deployments/adapters/github.ts +125 -0
  87. package/src/deployments/deploy.ts +48 -19
  88. package/src/deployments/driver.ts +8 -1
  89. package/src/deployments/gcp/driver.ts +9 -1
  90. package/src/deployments/gcp/survey.ts +3 -0
  91. package/src/deployments/knowledge.ts +119 -0
  92. package/src/deployments/prepare.ts +2 -2
  93. package/src/deployments/servable.ts +81 -0
  94. package/src/deployments/target.ts +3 -2
  95. package/src/deployments/upload.ts +2 -1
  96. package/src/dispatch/dispatch.ts +1 -1
  97. package/src/profile/authorization.ts +13 -4
  98. package/src/profile/identity.ts +60 -0
  99. package/src/profile/index.ts +15 -5
  100. package/src/profile/knowledge.ts +124 -0
  101. package/src/profile/load.ts +17 -5
  102. package/src/profile/primitives.ts +24 -1
  103. package/src/profile/schema.ts +72 -4
  104. package/src/profile/targets.ts +74 -114
  105. package/src/profile/workspace.ts +71 -84
  106. package/src/providers/google/calendar/index.ts +2 -0
  107. package/src/providers/google/contacts/index.ts +2 -0
  108. package/src/providers/google/docs/index.ts +2 -0
  109. package/src/providers/google/drive/index.ts +2 -0
  110. package/src/providers/google/gmail/index.ts +2 -0
  111. package/src/providers/google/gmail-imap/index.ts +125 -0
  112. package/src/providers/google/index.ts +2 -1
  113. package/src/providers/google/shared/oauth.ts +18 -6
  114. package/src/providers/google/shared/service-account.ts +110 -0
  115. package/src/providers/google/shared/setup.ts +5 -2
  116. package/src/providers/google/sheets/index.ts +2 -0
  117. package/src/providers/google/tasks/index.ts +2 -0
  118. package/src/providers/identity/provider.ts +166 -0
  119. package/src/providers/index.ts +3 -0
  120. package/src/providers/owner.ts +10 -2
  121. package/src/providers/scopes.ts +2 -0
  122. package/src/providers/setup/plan.ts +31 -9
  123. package/src/providers/setup/provider.ts +23 -0
  124. package/src/providers/slack/index.ts +81 -33
  125. package/src/providers/slack/oauth.ts +103 -0
  126. package/src/providers/slack/scopes.ts +37 -0
  127. package/src/server/container.ts +18 -1
  128. package/src/server/cors.ts +252 -0
  129. package/src/server/dashboard.ts +208 -0
  130. package/src/server/endpoint.ts +57 -4
  131. package/src/server/generation.ts +1 -0
  132. package/src/server/generations.ts +13 -2
  133. package/src/server/harness.ts +20 -3
  134. package/src/server/index.ts +76 -12
  135. package/src/server/mcp/build.ts +1 -1
  136. package/src/server/mcp/index.ts +1 -0
  137. package/src/server/mcp/instructions.ts +62 -5
  138. package/src/server/mcp/visibility.ts +42 -0
  139. 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),
@@ -113,6 +115,12 @@ export function deployPlan(input: PlanInput): DeployStep[] {
113
115
  // harness can mint one — so a target reached by a remote MCP client
114
116
  // declares `public` and gates the request in the application instead.
115
117
  cloudrun.access === 'iam' ? '--no-allow-unauthenticated' : '--allow-unauthenticated',
118
+ // Always passed, including the zero. Config is the source of truth here
119
+ // (ADR-004), and a flag sent only when non-zero would let a value be
120
+ // raised and never lowered — the revision would keep whatever the last
121
+ // deploy that bothered to mention it had set.
122
+ '--min-instances',
123
+ String(cloudrun.min_instances),
116
124
  ],
117
125
  },
118
126
  ];
@@ -127,6 +127,9 @@ export async function surveyCloudRun(input: SurveyInput): Promise<SurveyResult>
127
127
 
128
128
  const deploy: DeployConfig = {
129
129
  platform: 'cloudrun',
130
+ // Not asked about. Zero is right for almost every target and the question
131
+ // would cost every operator a decision to buy one of them a knob.
132
+ min_instances: current.min_instances ?? 0,
130
133
  project,
131
134
  region,
132
135
  service,
@@ -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
  }
@@ -34,11 +34,20 @@ const selfAuthorizationSchema = z.object({
34
34
  /**
35
35
  * How long an issued access token lives.
36
36
  *
37
- * Short by design and refreshed rather than lengthened: a client that holds a
38
- * long-lived token has something worth stealing, and the refresh path is the
39
- * one that can be revoked by dropping a row.
37
+ * Twelve hours, where this used to be one. The old reading short by design,
38
+ * refreshed rather than lengthened assumed the refresh happens. Expiry is
39
+ * in practice where a remote client loses its session: one observed against
40
+ * this endpoint let its access token lapse and reported needing authorization
41
+ * while the matching refresh token sat in the store unused, weeks from its
42
+ * own expiry. That is the client's bug and nothing here can fix it. What the
43
+ * endpoint can do is stop offering the chance twenty-four times a day.
44
+ *
45
+ * What is given up is real and bounded: a stolen access token is useful for
46
+ * longer. The revocable half is unchanged — the refresh path is still what a
47
+ * dropped row closes — and the refresh token was already the longer-lived of
48
+ * the pair at thirty days. `1440` is the ceiling, for one window a day.
40
49
  */
41
- access_token_ttl_minutes: z.number().int().positive().max(1440).default(60),
50
+ access_token_ttl_minutes: z.number().int().positive().max(1440).default(720),
42
51
  });
43
52
 
44
53
  const oidcAuthorizationSchema = z.object({