@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.
- package/README.md +22 -8
- package/instructions/skills/lanes-link/SKILL.md +42 -14
- package/package.json +1 -1
- package/src/cli/argv.ts +50 -0
- package/src/cli/brand.ts +178 -0
- package/src/cli/callback-page.ts +108 -128
- package/src/cli/commands/connect/accounts.ts +5 -0
- package/src/cli/commands/connect/assertion.ts +187 -0
- package/src/cli/commands/connect/authorise.ts +61 -17
- package/src/cli/commands/connect/client.ts +37 -9
- package/src/cli/commands/connect/discover.ts +94 -0
- package/src/cli/commands/connect/family.ts +72 -0
- package/src/cli/commands/connect/index.ts +113 -115
- package/src/cli/commands/connect/method.ts +237 -0
- package/src/cli/commands/connect/outcome.ts +42 -1
- package/src/cli/commands/connect/pasted-token.ts +66 -0
- package/src/cli/commands/connect/requirements.ts +60 -8
- package/src/cli/commands/connect/setup.ts +16 -5
- package/src/cli/commands/connect/target-note.ts +34 -0
- package/src/cli/commands/identity.ts +258 -0
- package/src/cli/commands/knowledge/index.ts +390 -0
- package/src/cli/commands/knowledge/migrate.ts +180 -0
- package/src/cli/commands/knowledge/setup.ts +144 -0
- package/src/cli/commands/knowledge.ts +10 -0
- package/src/cli/commands/mcp/harnesses.ts +16 -2
- package/src/cli/commands/mcp/register.ts +9 -1
- package/src/cli/commands/mcp/stdio.ts +21 -0
- package/src/cli/commands/operate/dashboard.ts +107 -0
- package/src/cli/commands/operate/findings.ts +151 -0
- package/src/cli/commands/operate/inspect.ts +63 -164
- package/src/cli/commands/operate/outputs.ts +38 -11
- package/src/cli/commands/operate/policy.ts +7 -7
- package/src/cli/commands/operate/serve.ts +3 -0
- package/src/cli/commands/operate/status.ts +108 -1
- package/src/cli/commands/operate/token.ts +1 -1
- package/src/cli/commands/operate.ts +2 -0
- package/src/cli/commands/profile/declare.ts +154 -0
- package/src/cli/commands/profile/removal.ts +17 -0
- package/src/cli/commands/profile/remove.ts +5 -5
- package/src/cli/commands/profile.ts +83 -35
- package/src/cli/commands/secrets.ts +6 -6
- package/src/cli/commands/setup.ts +22 -6
- package/src/cli/commands/sync.ts +262 -0
- package/src/cli/commands/target.ts +65 -83
- package/src/cli/config-edit.ts +53 -144
- package/src/cli/config-repair.ts +186 -0
- package/src/cli/dashboard-page.ts +284 -0
- package/src/cli/dashboard-shell.ts +125 -0
- package/src/cli/dispatch-owner.ts +93 -0
- package/src/cli/identity.ts +12 -1
- package/src/cli/main.ts +90 -61
- package/src/cli/nearest.ts +45 -0
- package/src/cli/oauth-callback.ts +187 -0
- package/src/cli/oauth-exchange.ts +57 -15
- package/src/cli/oauth.ts +67 -177
- package/src/cli/output.ts +21 -5
- package/src/cli/provider-marks.ts +45 -0
- package/src/cli/runtime/open.ts +74 -51
- package/src/cli/runtime/registry.ts +60 -2
- package/src/cli/runtime/select.ts +26 -13
- package/src/cli/runtime/vault.ts +61 -0
- package/src/cli/runtime.ts +2 -1
- package/src/cli/selection.ts +365 -0
- package/src/cli/usage.ts +42 -11
- package/src/connectivity/auth/README.md +7 -1
- package/src/connectivity/auth/basic/index.ts +1 -1
- package/src/connectivity/auth/index.ts +14 -0
- package/src/connectivity/auth/oauth-authcode/broker.ts +26 -0
- package/src/connectivity/auth/oauth-authcode/index.ts +16 -2
- package/src/connectivity/auth/oauth-authcode/provider.ts +1 -1
- package/src/connectivity/auth/oauth-authcode/refresh.ts +3 -3
- package/src/connectivity/auth/oauth-jwt/README.md +33 -0
- package/src/connectivity/auth/oauth-jwt/index.ts +237 -0
- package/src/connectivity/auth/oauth-jwt/key.ts +148 -0
- package/src/connectivity/auth/resolve.ts +1 -1
- package/src/connectivity/auth/token.ts +11 -0
- package/src/connectivity/index.ts +2 -0
- package/src/connectivity/manifest/auth.ts +99 -2
- package/src/connectivity/manifest/identity.ts +12 -0
- package/src/connectivity/manifest/index.ts +3 -1
- package/src/connectivity/manifest/provider.ts +37 -8
- package/src/connectivity/manifest/requirements.ts +109 -6
- package/src/deployments/adapters/filesystem.ts +10 -1
- package/src/deployments/adapters/github-api.ts +106 -0
- package/src/deployments/adapters/github-commit.ts +103 -0
- package/src/deployments/adapters/github-repo.ts +356 -0
- package/src/deployments/adapters/github-testing.ts +258 -0
- package/src/deployments/adapters/github.ts +125 -0
- package/src/deployments/deploy.ts +94 -114
- package/src/deployments/discover.ts +103 -0
- package/src/deployments/driver.ts +8 -1
- package/src/deployments/gcp/driver.ts +3 -1
- package/src/deployments/knowledge.ts +119 -0
- package/src/deployments/prepare.ts +12 -6
- package/src/deployments/report.ts +117 -0
- package/src/deployments/servable.ts +82 -0
- package/src/deployments/serving.ts +165 -0
- package/src/deployments/sync-apply.ts +276 -0
- package/src/deployments/sync.ts +136 -0
- package/src/deployments/target.ts +3 -2
- package/src/deployments/upload.ts +19 -12
- package/src/dispatch/dispatch.ts +1 -1
- package/src/profile/deployments.ts +80 -0
- package/src/profile/identity.ts +60 -0
- package/src/profile/index.ts +23 -5
- package/src/profile/knowledge.ts +124 -0
- package/src/profile/load.ts +17 -5
- package/src/profile/primitives.ts +24 -1
- package/src/profile/schema.ts +81 -3
- package/src/profile/targets.ts +122 -109
- package/src/profile/workspace.ts +139 -79
- package/src/providers/google/calendar/index.ts +2 -0
- package/src/providers/google/contacts/index.ts +2 -0
- package/src/providers/google/docs/index.ts +2 -0
- package/src/providers/google/drive/index.ts +2 -0
- package/src/providers/google/gmail/index.ts +2 -0
- package/src/providers/google/gmail-imap/index.ts +125 -0
- package/src/providers/google/index.ts +2 -1
- package/src/providers/google/shared/oauth.ts +18 -6
- package/src/providers/google/shared/service-account.ts +110 -0
- package/src/providers/google/shared/setup.ts +21 -3
- package/src/providers/google/sheets/index.ts +2 -0
- package/src/providers/google/tasks/index.ts +2 -0
- package/src/providers/identity/provider.ts +166 -0
- package/src/providers/index.ts +3 -0
- package/src/providers/owner.ts +10 -2
- package/src/providers/scopes.ts +2 -0
- package/src/providers/setup/plan.ts +31 -9
- package/src/providers/setup/provider.ts +23 -0
- package/src/providers/slack/index.ts +81 -33
- package/src/providers/slack/oauth.ts +103 -0
- package/src/providers/slack/scopes.ts +37 -0
- package/src/server/container.ts +18 -1
- package/src/server/cors.ts +252 -0
- package/src/server/dashboard.ts +208 -0
- package/src/server/endpoint.ts +45 -1
- package/src/server/generations.ts +11 -2
- package/src/server/harness.ts +7 -0
- package/src/server/index.ts +45 -6
- package/src/server/mcp/index.ts +1 -0
- package/src/server/mcp/instructions.ts +28 -1
- package/src/server/mcp/visibility.ts +33 -0
- 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,
|
|
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
|
-
|
|
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
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
announce(
|
|
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,
|
|
87
|
-
const readable = await readableRefs(resolution.workspaceRoot,
|
|
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
|
-
|
|
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
|
|
172
|
-
`lanes link secrets push --
|
|
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
|
-
|
|
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,
|
|
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
|
-
/**
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
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
|
}
|