@lanes-sh/link 0.2.2 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (143) hide show
  1. package/README.md +22 -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 +63 -164
  31. package/src/cli/commands/operate/outputs.ts +38 -11
  32. package/src/cli/commands/operate/policy.ts +7 -7
  33. package/src/cli/commands/operate/serve.ts +3 -0
  34. package/src/cli/commands/operate/status.ts +108 -1
  35. package/src/cli/commands/operate/token.ts +1 -1
  36. package/src/cli/commands/operate.ts +2 -0
  37. package/src/cli/commands/profile/declare.ts +154 -0
  38. package/src/cli/commands/profile/removal.ts +17 -0
  39. package/src/cli/commands/profile/remove.ts +5 -5
  40. package/src/cli/commands/profile.ts +83 -35
  41. package/src/cli/commands/secrets.ts +6 -6
  42. package/src/cli/commands/setup.ts +22 -6
  43. package/src/cli/commands/sync.ts +262 -0
  44. package/src/cli/commands/target.ts +65 -83
  45. package/src/cli/config-edit.ts +53 -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/dispatch-owner.ts +93 -0
  50. package/src/cli/identity.ts +12 -1
  51. package/src/cli/main.ts +90 -61
  52. package/src/cli/nearest.ts +45 -0
  53. package/src/cli/oauth-callback.ts +187 -0
  54. package/src/cli/oauth-exchange.ts +57 -15
  55. package/src/cli/oauth.ts +67 -177
  56. package/src/cli/output.ts +21 -5
  57. package/src/cli/provider-marks.ts +45 -0
  58. package/src/cli/runtime/open.ts +74 -51
  59. package/src/cli/runtime/registry.ts +60 -2
  60. package/src/cli/runtime/select.ts +26 -13
  61. package/src/cli/runtime/vault.ts +61 -0
  62. package/src/cli/runtime.ts +2 -1
  63. package/src/cli/selection.ts +365 -0
  64. package/src/cli/usage.ts +42 -11
  65. package/src/connectivity/auth/README.md +7 -1
  66. package/src/connectivity/auth/basic/index.ts +1 -1
  67. package/src/connectivity/auth/index.ts +14 -0
  68. package/src/connectivity/auth/oauth-authcode/broker.ts +26 -0
  69. package/src/connectivity/auth/oauth-authcode/index.ts +16 -2
  70. package/src/connectivity/auth/oauth-authcode/provider.ts +1 -1
  71. package/src/connectivity/auth/oauth-authcode/refresh.ts +3 -3
  72. package/src/connectivity/auth/oauth-jwt/README.md +33 -0
  73. package/src/connectivity/auth/oauth-jwt/index.ts +237 -0
  74. package/src/connectivity/auth/oauth-jwt/key.ts +148 -0
  75. package/src/connectivity/auth/resolve.ts +1 -1
  76. package/src/connectivity/auth/token.ts +11 -0
  77. package/src/connectivity/index.ts +2 -0
  78. package/src/connectivity/manifest/auth.ts +99 -2
  79. package/src/connectivity/manifest/identity.ts +12 -0
  80. package/src/connectivity/manifest/index.ts +3 -1
  81. package/src/connectivity/manifest/provider.ts +37 -8
  82. package/src/connectivity/manifest/requirements.ts +109 -6
  83. package/src/deployments/adapters/filesystem.ts +10 -1
  84. package/src/deployments/adapters/github-api.ts +106 -0
  85. package/src/deployments/adapters/github-commit.ts +103 -0
  86. package/src/deployments/adapters/github-repo.ts +356 -0
  87. package/src/deployments/adapters/github-testing.ts +258 -0
  88. package/src/deployments/adapters/github.ts +125 -0
  89. package/src/deployments/deploy.ts +94 -114
  90. package/src/deployments/discover.ts +103 -0
  91. package/src/deployments/driver.ts +8 -1
  92. package/src/deployments/gcp/driver.ts +3 -1
  93. package/src/deployments/knowledge.ts +119 -0
  94. package/src/deployments/prepare.ts +12 -6
  95. package/src/deployments/report.ts +117 -0
  96. package/src/deployments/servable.ts +82 -0
  97. package/src/deployments/serving.ts +165 -0
  98. package/src/deployments/sync-apply.ts +276 -0
  99. package/src/deployments/sync.ts +136 -0
  100. package/src/deployments/target.ts +3 -2
  101. package/src/deployments/upload.ts +19 -12
  102. package/src/dispatch/dispatch.ts +1 -1
  103. package/src/profile/deployments.ts +80 -0
  104. package/src/profile/identity.ts +60 -0
  105. package/src/profile/index.ts +23 -5
  106. package/src/profile/knowledge.ts +124 -0
  107. package/src/profile/load.ts +17 -5
  108. package/src/profile/primitives.ts +24 -1
  109. package/src/profile/schema.ts +81 -3
  110. package/src/profile/targets.ts +122 -109
  111. package/src/profile/workspace.ts +139 -79
  112. package/src/providers/google/calendar/index.ts +2 -0
  113. package/src/providers/google/contacts/index.ts +2 -0
  114. package/src/providers/google/docs/index.ts +2 -0
  115. package/src/providers/google/drive/index.ts +2 -0
  116. package/src/providers/google/gmail/index.ts +2 -0
  117. package/src/providers/google/gmail-imap/index.ts +125 -0
  118. package/src/providers/google/index.ts +2 -1
  119. package/src/providers/google/shared/oauth.ts +18 -6
  120. package/src/providers/google/shared/service-account.ts +110 -0
  121. package/src/providers/google/shared/setup.ts +21 -3
  122. package/src/providers/google/sheets/index.ts +2 -0
  123. package/src/providers/google/tasks/index.ts +2 -0
  124. package/src/providers/identity/provider.ts +166 -0
  125. package/src/providers/index.ts +3 -0
  126. package/src/providers/owner.ts +10 -2
  127. package/src/providers/scopes.ts +2 -0
  128. package/src/providers/setup/plan.ts +31 -9
  129. package/src/providers/setup/provider.ts +23 -0
  130. package/src/providers/slack/index.ts +81 -33
  131. package/src/providers/slack/oauth.ts +103 -0
  132. package/src/providers/slack/scopes.ts +37 -0
  133. package/src/server/container.ts +18 -1
  134. package/src/server/cors.ts +252 -0
  135. package/src/server/dashboard.ts +208 -0
  136. package/src/server/endpoint.ts +45 -1
  137. package/src/server/generations.ts +11 -2
  138. package/src/server/harness.ts +7 -0
  139. package/src/server/index.ts +45 -6
  140. package/src/server/mcp/index.ts +1 -0
  141. package/src/server/mcp/instructions.ts +28 -1
  142. package/src/server/mcp/visibility.ts +33 -0
  143. package/src/stores/blobs/route.ts +123 -0
@@ -0,0 +1,258 @@
1
+ import type { FetchLike } from './github-api.ts';
2
+
3
+ /**
4
+ * A GitHub repository, in memory, for the adapter's tests.
5
+ *
6
+ * Test-only, behind the `-testing` suffix the blob and state stores already use
7
+ * for the same purpose, so application code does not reach it by habit. It is not
8
+ * a general GitHub simulator: it implements exactly the eight requests
9
+ * `github-repo.ts` and `github-commit.ts` make, and answers anything else with
10
+ * a 501 so an untested code path fails loudly rather than silently.
11
+ *
12
+ * What it does model faithfully, because the adapter's correctness rests on it:
13
+ * blob shas are content hashes (so "same sha, same bytes" is true, which is
14
+ * what makes the blob cache sound), commits snapshot the tree, the branch read
15
+ * answers 304 to a matching `If-None-Match`, and a Contents write with a stale
16
+ * `sha` answers 409.
17
+ */
18
+
19
+ export interface FakeGithubOptions {
20
+ readonly repo?: string;
21
+ readonly private?: boolean;
22
+ readonly defaultBranch?: string;
23
+ readonly canPush?: boolean;
24
+ /** Start with commits already in place, rather than an empty repository. */
25
+ readonly files?: Readonly<Record<string, string>>;
26
+ }
27
+
28
+ export interface FakeGithub {
29
+ readonly fetch: FetchLike;
30
+ /** Every request, as `METHOD /path`, in order. */
31
+ readonly calls: string[];
32
+ /** The files at the branch tip. */
33
+ files(): Record<string, string>;
34
+ commitCount(): number;
35
+ /** Answer the next matching request with this instead. */
36
+ failNext(match: string, response: Response): void;
37
+ /**
38
+ * Answer every matching request this way, from now on.
39
+ *
40
+ * The shape a token that has expired actually has: not one bad response, but
41
+ * every response, until somebody replaces it. `failNext` models a race;
42
+ * this models a state.
43
+ */
44
+ failEvery(match: string, make: () => Response): void;
45
+ /** Change a file behind the adapter's back, as another writer would. */
46
+ writeBehind(path: string, text: string): void;
47
+ }
48
+
49
+ const encoder = new TextEncoder();
50
+ const decoder = new TextDecoder();
51
+
52
+ /** Content-addressed, so the adapter's blob cache is exercised honestly. */
53
+ function blobSha(data: Uint8Array): string {
54
+ const hasher = new Bun.CryptoHasher('sha1');
55
+ hasher.update(data);
56
+ return hasher.digest('hex');
57
+ }
58
+
59
+ function decodeBase64(value: string): Uint8Array {
60
+ const binary = atob(value);
61
+ const bytes = new Uint8Array(binary.length);
62
+ for (let index = 0; index < binary.length; index += 1) bytes[index] = binary.charCodeAt(index);
63
+ return bytes;
64
+ }
65
+
66
+ function json(body: unknown, init: ResponseInit = {}): Response {
67
+ return new Response(JSON.stringify(body), {
68
+ ...init,
69
+ headers: { 'content-type': 'application/json', ...(init.headers ?? {}) },
70
+ });
71
+ }
72
+
73
+ export function createFakeGithub(options: FakeGithubOptions = {}): FakeGithub {
74
+ const repo = options.repo ?? 'my-org/my-notes';
75
+ const branch = options.defaultBranch ?? 'main';
76
+ const calls: string[] = [];
77
+ const overrides: Array<{ match: string; response: Response }> = [];
78
+ const standing: Array<{ match: string; make: () => Response }> = [];
79
+
80
+ /** Path → bytes, at the tip. */
81
+ let files = new Map<string, Uint8Array>();
82
+ /** Commit sha → the tree it points at. Trees are addressed by commit here. */
83
+ const commits = new Map<string, Map<string, Uint8Array>>();
84
+ /** Loose objects created by the Git Data API before a commit references them. */
85
+ const loose = new Map<string, Uint8Array>();
86
+ const trees = new Map<string, Map<string, Uint8Array>>();
87
+ let head: string | null = null;
88
+ let counter = 0;
89
+
90
+ const snapshot = (): string => {
91
+ counter += 1;
92
+ const sha = `commit${counter}`;
93
+ commits.set(sha, new Map(files));
94
+ head = sha;
95
+ return sha;
96
+ };
97
+
98
+ if (options.files) {
99
+ for (const [path, text] of Object.entries(options.files)) {
100
+ files.set(path, encoder.encode(text));
101
+ }
102
+ snapshot();
103
+ }
104
+
105
+ const treeOf = (sha: string): Map<string, Uint8Array> => commits.get(sha) ?? new Map();
106
+
107
+ const fetch: FetchLike = async (input, init = {}) => {
108
+ const url = new URL(String(input));
109
+ const method = init.method ?? 'GET';
110
+ const path = url.pathname;
111
+ calls.push(`${method} ${path}`);
112
+
113
+ const override = overrides.findIndex((entry) => `${method} ${path}`.includes(entry.match));
114
+ if (override !== -1) return overrides.splice(override, 1)[0]!.response;
115
+
116
+ const always = standing.find((entry) => `${method} ${path}`.includes(entry.match));
117
+ if (always) return always.make();
118
+
119
+ const body = init.body === undefined ? undefined : (JSON.parse(String(init.body)) as Record<string, unknown>);
120
+ const headers = (init.headers ?? {}) as Record<string, string>;
121
+
122
+ if (method === 'GET' && path === `/repos/${repo}`) {
123
+ return json({
124
+ full_name: repo,
125
+ private: options.private ?? true,
126
+ default_branch: branch,
127
+ permissions: { push: options.canPush ?? true },
128
+ });
129
+ }
130
+
131
+ if (method === 'GET' && path === '/user') return json({ login: 'an-owner' });
132
+
133
+ if (method === 'GET' && path === `/repos/${repo}/branches/${branch}`) {
134
+ if (head === null) return json({ message: 'Branch not found' }, { status: 404 });
135
+ const etag = `"${head}"`;
136
+ if (headers['if-none-match'] === etag) return new Response(null, { status: 304 });
137
+
138
+ return json(
139
+ { commit: { sha: head, commit: { committer: { date: '2026-01-02T03:04:05Z' } } } },
140
+ { headers: { etag } },
141
+ );
142
+ }
143
+
144
+ if (method === 'GET' && path.startsWith(`/repos/${repo}/git/trees/`)) {
145
+ const sha = path.slice(`/repos/${repo}/git/trees/`.length);
146
+ const tree = commits.has(sha) ? treeOf(sha) : (trees.get(sha) ?? new Map());
147
+ return json({
148
+ sha,
149
+ tree: [...tree].map(([entry, data]) => ({
150
+ path: entry,
151
+ mode: '100644',
152
+ type: 'blob',
153
+ sha: blobSha(data),
154
+ size: data.byteLength,
155
+ })),
156
+ truncated: false,
157
+ });
158
+ }
159
+
160
+ if (method === 'GET' && path.startsWith(`/repos/${repo}/git/blobs/`)) {
161
+ const sha = path.slice(`/repos/${repo}/git/blobs/`.length);
162
+ for (const data of [...files.values(), ...loose.values()]) {
163
+ if (blobSha(data) === sha) return new Response(data);
164
+ }
165
+ for (const tree of commits.values()) {
166
+ for (const data of tree.values()) if (blobSha(data) === sha) return new Response(data);
167
+ }
168
+ return json({ message: 'Not Found' }, { status: 404 });
169
+ }
170
+
171
+ if (path.startsWith(`/repos/${repo}/contents/`)) {
172
+ const target = decodeURIComponent(path.slice(`/repos/${repo}/contents/`.length));
173
+ const existing = files.get(target);
174
+ const given = body?.['sha'] as string | undefined;
175
+
176
+ if (existing && given !== blobSha(existing)) {
177
+ return json({ message: 'does not match' }, { status: 409 });
178
+ }
179
+ if (!existing && given) return json({ message: 'not found' }, { status: 404 });
180
+
181
+ if (method === 'PUT') {
182
+ const data = decodeBase64(String(body?.['content'] ?? ''));
183
+ files.set(target, data);
184
+ const sha = snapshot();
185
+ return json({
186
+ content: { sha: blobSha(data), size: data.byteLength },
187
+ commit: { sha, committer: { date: '2026-01-02T03:04:05Z' } },
188
+ });
189
+ }
190
+
191
+ if (method === 'DELETE') {
192
+ if (!existing) return json({ message: 'Not Found' }, { status: 404 });
193
+ files.delete(target);
194
+ const sha = snapshot();
195
+ return json({ commit: { sha, committer: { date: '2026-01-02T03:04:05Z' } } });
196
+ }
197
+ }
198
+
199
+ if (method === 'POST' && path === `/repos/${repo}/git/blobs`) {
200
+ const data = decodeBase64(String(body?.['content'] ?? ''));
201
+ const sha = blobSha(data);
202
+ loose.set(sha, data);
203
+ return json({ sha });
204
+ }
205
+
206
+ if (method === 'POST' && path === `/repos/${repo}/git/trees`) {
207
+ const base = body?.['base_tree'] as string | undefined;
208
+ const tree = new Map(base ? treeOf(base) : []);
209
+
210
+ for (const entry of (body?.['tree'] ?? []) as Array<{ path: string; sha: string | null }>) {
211
+ if (entry.sha === null) tree.delete(entry.path);
212
+ else {
213
+ const data = loose.get(entry.sha);
214
+ if (data) tree.set(entry.path, data);
215
+ }
216
+ }
217
+
218
+ counter += 1;
219
+ const sha = `tree${counter}`;
220
+ trees.set(sha, tree);
221
+ return json({ sha });
222
+ }
223
+
224
+ if (method === 'POST' && path === `/repos/${repo}/git/commits`) {
225
+ counter += 1;
226
+ const sha = `commit${counter}`;
227
+ commits.set(sha, new Map(trees.get(String(body?.['tree'])) ?? new Map()));
228
+ return json({ sha });
229
+ }
230
+
231
+ // Creating the first ref, or moving an existing one. Both land the commit.
232
+ if (
233
+ (method === 'POST' && path === `/repos/${repo}/git/refs`) ||
234
+ (method === 'PATCH' && path === `/repos/${repo}/git/refs/heads/${branch}`)
235
+ ) {
236
+ const sha = String(body?.['sha']);
237
+ if (!commits.has(sha)) return json({ message: 'no such commit' }, { status: 422 });
238
+ head = sha;
239
+ files = new Map(treeOf(sha));
240
+ return json({ ref: `refs/heads/${branch}`, object: { sha } });
241
+ }
242
+
243
+ return json({ message: `the fake does not implement ${method} ${path}` }, { status: 501 });
244
+ };
245
+
246
+ return {
247
+ fetch,
248
+ calls,
249
+ files: () => Object.fromEntries([...files].map(([path, data]) => [path, decoder.decode(data)])),
250
+ commitCount: () => commits.size,
251
+ failNext: (match, response) => overrides.push({ match, response }),
252
+ failEvery: (match, make) => standing.push({ match, make }),
253
+ writeBehind: (path, text) => {
254
+ files.set(path, encoder.encode(text));
255
+ snapshot();
256
+ },
257
+ };
258
+ }
@@ -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, recordDeployment, resolveWorkspaceRoot, 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,9 @@ 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';
12
+ import { collidingRefs, collisionRefusal, servingProfiles } from './serving.ts';
13
+ import { healthLine, reachability, registerLine, reportUnauthorised } from './report.ts';
11
14
 
12
15
  /**
13
16
  * `lanes link deploy` — set up what is missing, build the image, roll a revision,
@@ -41,18 +44,57 @@ export interface DeployFlags extends GlobalFlags {
41
44
  * this is for a terminal attached to a job nobody is watching.
42
45
  */
43
46
  readonly nonInteractive?: boolean | undefined;
47
+ /**
48
+ * Which profiles to send, narrowing the set the target implies.
49
+ *
50
+ * Repeatable, and absent means every profile declaring the target — the set
51
+ * the endpoint is going to open either way (ADR-043). The first one named is
52
+ * the primary.
53
+ */
54
+ readonly profiles?: readonly string[] | undefined;
44
55
  }
45
56
 
46
57
  export async function deploy(flags: DeployFlags): Promise<void> {
47
58
  // The one command allowed to name a target that does not exist yet: creating
48
59
  // it is what a first deploy is for.
49
- const { resolution, config } = await resolveProfile(flags, { allowUndeclaredTarget: true });
60
+ //
61
+ // It used to work out its own target too — the one declaring a `deploy` block,
62
+ // guessing when there was one, refusing when there were two, and inventing
63
+ // `cloud` when there were none. That inference was a defence against
64
+ // `instance.default_target`, which is `local` on a scaffolded profile and
65
+ // never the answer to "deploy what". With the fallback gone (ADR-037) the
66
+ // defence has nothing to defend against, and what is left is three behaviours
67
+ // from one command line on the command that creates cloud resources and rolls
68
+ // a public URL — where `allowUndeclaredTarget` means a mistake is not refused
69
+ // but surveyed, written into the profile, and deployed as a new service.
70
+ //
71
+ // Which profiles is derived rather than named: every profile declaring the
72
+ // target, because that is the set the revision will try to open (ADR-009).
73
+ // `--profile` narrows it, and a first deploy still has to name one — a target
74
+ // nothing declares has no set to derive from.
75
+ const { profiles: serving, primary } = await servingProfiles({
76
+ workspaceRoot: resolveWorkspaceRoot(),
77
+ target: requireTargetFlag(flags),
78
+ named: flags.profiles ?? [],
79
+ });
50
80
 
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 });
81
+ const { resolution, config, target } = await resolveProfile(
82
+ { ...flags, profile: primary },
83
+ { allowUndeclaredTarget: true },
84
+ );
85
+ announce(resolution);
86
+ if (serving.length > 1) {
87
+ print(style.dim(` serving ${serving.join(', ')} — ${primary} owns the token`));
88
+ }
89
+
90
+ // Before anything external, beside `check`, and for the same reason: two
91
+ // profiles writing one flat credential ref into one store is a failure with
92
+ // no later symptom worth having.
93
+ const colliding = await collidingRefs(resolution.workspaceRoot, serving);
94
+ if (colliding.length > 0) {
95
+ heading('Cannot share a credential store');
96
+ throw new ConfigError(collisionRefusal(colliding, target));
97
+ }
56
98
 
57
99
  // `check` before anything external, per the gate order: a config that will be
58
100
  // rejected on boot should be rejected here, not after a five-minute build.
@@ -83,8 +125,8 @@ export async function deploy(flags: DeployFlags): Promise<void> {
83
125
  // below are, and read from config and manifests before anything opens a
84
126
  // store — `--dry-run` must reach the printed step list without touching a
85
127
  // credential.
86
- const rotatable = await rotatableRefs(resolution.workspaceRoot, flags.profile);
87
- const readable = await readableRefs(resolution.workspaceRoot, flags.profile, declared);
128
+ const rotatable = await rotatableRefs(resolution.workspaceRoot, serving);
129
+ const readable = await readableRefs(resolution.workspaceRoot, serving, declared);
88
130
  const provision = await driver.provision({
89
131
  deploy: deployConfig,
90
132
  declared,
@@ -103,7 +145,7 @@ export async function deploy(flags: DeployFlags): Promise<void> {
103
145
  deploy: deployConfig,
104
146
  tag,
105
147
  target,
106
- ...(flags.profile !== undefined ? { profile: flags.profile } : {}),
148
+ profile: resolution.profile,
107
149
  ...(workspace !== undefined ? { workspace } : {}),
108
150
  ...(secretEnv ? { secretEnv } : {}),
109
151
  });
@@ -168,8 +210,9 @@ export async function deploy(flags: DeployFlags): Promise<void> {
168
210
  for (const problem of prepared.blocking) print(fail(problem));
169
211
  throw new ConfigError(
170
212
  '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}.`,
213
+ `lanes link secrets set <ref> --profile ${resolution.profile} --target ${target}, or ` +
214
+ `copy a local setup with lanes link secrets push --profile ${resolution.profile} ` +
215
+ `--from local --to ${target}.`,
173
216
  );
174
217
  }
175
218
 
@@ -193,12 +236,41 @@ export async function deploy(flags: DeployFlags): Promise<void> {
193
236
  // the upload sends would leave a served profile without the surface — this
194
237
  // bug again, one profile over.
195
238
  if (workspace) {
196
- await repairSetupSurface(resolution.workspaceRoot, flags.profile);
239
+ // Before anything is copied, and before the rollout: the endpoint opens
240
+ // every profile in the bucket against this one target, so a profile that
241
+ // does not declare it is not a profile that gets skipped — it is a revision
242
+ // that never goes healthy. `servable.ts` has the whole failure.
243
+ //
244
+ // Here rather than at the top of the command because it reads the same
245
+ // scope the upload does, and that scope is not settled until `workspace`
246
+ // says there is a bucket to send to at all.
247
+ const unservable = await unservableProfiles({
248
+ workspaceRoot: resolution.workspaceRoot,
249
+ profiles: serving,
250
+ target,
251
+ });
252
+
253
+ if (unservable.length > 0) {
254
+ heading('Cannot be served');
255
+ throw new ConfigError(unservableRefusal(unservable, target));
256
+ }
257
+
258
+ await repairSetupSurface(resolution.workspaceRoot, serving);
197
259
 
198
260
  // Before the rollout, so the revision that comes up finds a config to read.
199
261
  // Uploading after would leave a window where the service is serving and the
200
262
  // workspace it was told to read is not there yet.
201
- await uploadWorkspace(resolution.workspaceRoot, workspace, flags.profile);
263
+ await uploadWorkspace(resolution.workspaceRoot, workspace, serving);
264
+
265
+ // Recorded once the bucket is known to hold this target's config. It is an
266
+ // index, not configuration (ADR-044) — the next recovery reads it instead
267
+ // of asking the platform.
268
+ await recordDeployment(resolution.workspaceRoot, {
269
+ target,
270
+ workspace,
271
+ primary: resolution.profile,
272
+ last_deploy: new Date().toISOString(),
273
+ });
202
274
  }
203
275
 
204
276
  heading('Rolling out');
@@ -208,7 +280,7 @@ export async function deploy(flags: DeployFlags): Promise<void> {
208
280
  if (!url) {
209
281
  print(
210
282
  warn(
211
- `deployed, but the platform reported no URL yet — run: lanes link outputs --target ${target}`,
283
+ `deployed, but the platform reported no URL yet — run: lanes link outputs --profile ${resolution.profile} --target ${target}`,
212
284
  ),
213
285
  );
214
286
  return;
@@ -218,107 +290,15 @@ export async function deploy(flags: DeployFlags): Promise<void> {
218
290
  print(` ${url}/mcp`);
219
291
  print(await healthLine(url));
220
292
  print('');
221
- print(registerLine(target));
222
-
223
- reportUnauthorised(prepared.warnings, target);
224
- }
225
-
226
- /**
227
- * How to register the endpoint, and when.
228
- *
229
- * The ordering is the whole point of the second half. A client captures
230
- * `tools/list` when it connects and keeps it: this endpoint is stateless, so
231
- * there is no stream on which to send `notifications/tools/list_changed`, and
232
- * `buildMcpServer` no longer pretends otherwise. A first deploy necessarily
233
- * publishes a profile whose only connection is `setup.main` — the accounts come
234
- * after — so a connector registered in that window captures a two-tool surface
235
- * and holds it. The endpoint is right, every reload lands, and the client shows
236
- * two tools until someone removes and re-adds it.
237
- *
238
- * Unconditional, and that is the correction that matters. This was gated on
239
- * `prepared.warnings.length`, which is zero in precisely the case it describes:
240
- * a fresh profile declares only `setup.main`, `setup` is a local provider with
241
- * no credential, so `prepareSecrets` has nothing to warn about. The advice
242
- * appeared only on a later re-deploy, by which point the connector is usually
243
- * registered and the ordering is no longer available to get right.
244
- */
245
- function registerLine(target: string): string {
246
- return style.dim(
247
- ` Connect your accounts first, then register with: lanes link outputs --target ${target}\n` +
248
- ' A client keeps the tool list it fetched when it connected, so one registered\n' +
249
- ' before the accounts holds a surface without them until it is re-added.',
250
- );
251
- }
252
-
253
- /**
254
- * The accounts a browser still has to authorise, and the step after them.
255
- *
256
- * Printed last rather than before the build, because this is the only thing
257
- * left to do and a list eight steps up the scrollback is a list nobody reads.
258
- *
259
- * There is no second deploy at the end of it any more, and the reason the old
260
- * one existed is worth keeping. Connection *credentials* are read live on every
261
- * call, so a fresh `connect` looked like it should be picked up — but whether a
262
- * connection was usable at all was decided by a reconcile that ran once per
263
- * process, so a revision that came up with an account unauthorised went on
264
- * refusing it, naming the connection rather than the staleness. Reconcile now
265
- * runs again on every reload, and `connect` asks for one (ADR-029).
266
- */
267
- function reportUnauthorised(warnings: readonly string[], target: string): void {
268
- if (warnings.length === 0) return;
269
-
270
- heading('Not authorised yet');
271
- for (const problem of warnings) print(warn(problem));
272
- print('');
273
- print(
274
- style.dim(
275
- ' A browser consent per account is the one step this cannot take for you:\n' +
276
- ` lanes link connect <provider> --target ${target}\n` +
277
- ' Each is served as soon as it is authorised. There is no second deploy —\n' +
278
- ' deploying is how code gets here, and authorising an account changes none.',
279
- ),
280
- );
281
- }
293
+ print(registerLine(resolution.profile, target));
282
294
 
283
- /**
284
- * Who can reach the service once this lands.
285
- *
286
- * Printed on every deploy rather than only when it changes, because it is the
287
- * one property of a deployment that is invisible from the outside until someone
288
- * either cannot get in or should not have been able to.
289
- */
290
- function reachability(access: DeployConfig['access']): string {
291
- return access === 'iam'
292
- ? style.dim(
293
- ' access: iam — the platform admits only callers holding its own identity\n' +
294
- ' token. No agent harness can mint one; use --access public with an\n' +
295
- ' authorization block if a remote MCP client has to reach this.',
296
- )
297
- : style.dim(
298
- ' access: public — the platform lets requests through and this endpoint\n' +
299
- ' authenticates them. The bearer token is what protects it.',
300
- );
295
+ reportUnauthorised(prepared.warnings, resolution.profile, target);
301
296
  }
302
297
 
303
- /** Ask the deployed endpoint whether it came up. */
304
- async function healthLine(url: string): Promise<string> {
305
- try {
306
- const response = await fetch(`${url}/health`, { signal: AbortSignal.timeout(10_000) });
307
- if (!response.ok) return warn(`the endpoint answered /health with ${response.status}`);
308
-
309
- // Asked anonymously, so it reports that the revision is up and nothing
310
- // about what it serves — the profile list is behind the token now.
311
- // `lanes link outputs` holds one and prints the rest.
312
- const body = (await response.json()) as { profiles?: string[] };
313
- return ok(
314
- body.profiles
315
- ? `healthy — serving ${body.profiles.join(', ')}`
316
- : 'healthy — run `lanes link outputs` for what it serves',
317
- );
318
- } catch {
319
- // A cold start plus a database connect can outrun a short probe, and
320
- // `access: iam` makes /health unreachable from here by design. Neither is a
321
- // failed deploy.
322
- return warn('could not reach /health from here — with access: iam that is expected');
298
+ /** `--target` is required by `SELECTION`; this is the type narrowing, not a check. */
299
+ function requireTargetFlag(flags: DeployFlags): string {
300
+ if (!flags.target) {
301
+ throw new ConfigError('--target is required. It names the deployment this acts on.');
323
302
  }
303
+ return flags.target;
324
304
  }