@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.
- package/README.md +21 -8
- package/instructions/skills/lanes-link/SKILL.md +59 -14
- package/package.json +1 -1
- package/src/auth/index.ts +3 -1
- package/src/auth/oauth/metadata.ts +83 -9
- package/src/auth/oauth/redirects.ts +70 -0
- package/src/auth/oauth/server.ts +49 -69
- package/src/auth/oauth/store.ts +19 -5
- 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 +56 -158
- package/src/cli/commands/operate/outputs.ts +38 -11
- package/src/cli/commands/operate/serve.ts +3 -0
- 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.ts +83 -35
- package/src/cli/commands/setup.ts +22 -6
- package/src/cli/commands/target.ts +65 -83
- package/src/cli/config-edit.ts +48 -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/identity.ts +12 -1
- package/src/cli/main.ts +68 -4
- 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 +67 -49
- 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 +357 -0
- package/src/cli/usage.ts +32 -9
- 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 +48 -19
- package/src/deployments/driver.ts +8 -1
- package/src/deployments/gcp/driver.ts +9 -1
- package/src/deployments/gcp/survey.ts +3 -0
- package/src/deployments/knowledge.ts +119 -0
- package/src/deployments/prepare.ts +2 -2
- package/src/deployments/servable.ts +81 -0
- package/src/deployments/target.ts +3 -2
- package/src/deployments/upload.ts +2 -1
- package/src/dispatch/dispatch.ts +1 -1
- package/src/profile/authorization.ts +13 -4
- package/src/profile/identity.ts +60 -0
- package/src/profile/index.ts +15 -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 +72 -4
- package/src/profile/targets.ts +74 -114
- package/src/profile/workspace.ts +71 -84
- 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 +5 -2
- 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 +57 -4
- package/src/server/generation.ts +1 -0
- package/src/server/generations.ts +13 -2
- package/src/server/harness.ts +20 -3
- package/src/server/index.ts +76 -12
- package/src/server/mcp/build.ts +1 -1
- package/src/server/mcp/index.ts +1 -0
- package/src/server/mcp/instructions.ts +62 -5
- package/src/server/mcp/visibility.ts +42 -0
- package/src/stores/blobs/route.ts +123 -0
|
@@ -0,0 +1,356 @@
|
|
|
1
|
+
import {
|
|
2
|
+
API,
|
|
3
|
+
GithubApiError,
|
|
4
|
+
base64,
|
|
5
|
+
encodePath,
|
|
6
|
+
failure,
|
|
7
|
+
githubHeaders,
|
|
8
|
+
type FetchLike,
|
|
9
|
+
} from './github-api.ts';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* A GitHub repository, as something a `BlobStore` can be built on.
|
|
13
|
+
*
|
|
14
|
+
* This file knows GitHub — how a branch is read conditionally, what a tree
|
|
15
|
+
* looks like, how a write is a commit. `github.ts` knows `BlobStore`, and
|
|
16
|
+
* reaches all of it through here.
|
|
17
|
+
*
|
|
18
|
+
* **One of these per target, shared by every store built on it.** Memory and
|
|
19
|
+
* skills are two roots in one repository, so they share a head, a tree, and a
|
|
20
|
+
* blob cache — which means the endpoint's two-second skill poll also keeps
|
|
21
|
+
* memory's view current, at no additional cost. Two clients would each poll,
|
|
22
|
+
* each cache, and could disagree about which commit is current.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* How long a validated head is trusted before the next read re-checks it.
|
|
27
|
+
*
|
|
28
|
+
* Not a cache of content: blobs are content-addressed and a sha never means
|
|
29
|
+
* different bytes, so those are held for the life of the process without a
|
|
30
|
+
* freshness question. This bounds one thing only — how long we go on believing
|
|
31
|
+
* the branch still points where it did.
|
|
32
|
+
*
|
|
33
|
+
* Two seconds because that is already the staleness this system accepts.
|
|
34
|
+
* `Generation.SKILL_POLL_MS` is 2,000ms, so a skill added elsewhere is invisible
|
|
35
|
+
* to a running endpoint for up to two seconds wherever it is stored. Matching it
|
|
36
|
+
* means a repository is no staler than the filesystem was.
|
|
37
|
+
*
|
|
38
|
+
* The check is a conditional request and GitHub answers 304, which **does not
|
|
39
|
+
* count against the rate limit** — so what this bounds is round trips, not
|
|
40
|
+
* quota. Zero validates on every read.
|
|
41
|
+
*/
|
|
42
|
+
const DEFAULT_FRESHNESS_MS = 2_000;
|
|
43
|
+
|
|
44
|
+
export interface GithubRepositoryOptions {
|
|
45
|
+
/** `owner/name`. */
|
|
46
|
+
readonly repo: string;
|
|
47
|
+
readonly token: string;
|
|
48
|
+
/** Defaults to the repository's own default branch, resolved on first use. */
|
|
49
|
+
readonly branch?: string | undefined;
|
|
50
|
+
readonly fetch?: FetchLike | undefined;
|
|
51
|
+
readonly now?: (() => number) | undefined;
|
|
52
|
+
readonly freshnessMs?: number | undefined;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** One file in the tree, as a listing needs it. */
|
|
56
|
+
export interface RepositoryEntry {
|
|
57
|
+
readonly path: string;
|
|
58
|
+
readonly sha: string;
|
|
59
|
+
readonly size: number;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** What a repository looks like to somebody deciding whether to store here. */
|
|
63
|
+
export interface RepositoryFacts {
|
|
64
|
+
readonly fullName: string;
|
|
65
|
+
readonly private: boolean;
|
|
66
|
+
readonly defaultBranch: string;
|
|
67
|
+
readonly canPush: boolean;
|
|
68
|
+
readonly empty: boolean;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export interface Head {
|
|
72
|
+
readonly commit: string;
|
|
73
|
+
readonly committedAt: Date;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export class GithubRepository {
|
|
77
|
+
readonly repo: string;
|
|
78
|
+
readonly #token: string;
|
|
79
|
+
readonly #fetch: FetchLike;
|
|
80
|
+
readonly #now: () => number;
|
|
81
|
+
readonly #freshnessMs: number;
|
|
82
|
+
#branch: string | undefined;
|
|
83
|
+
|
|
84
|
+
/** The branch tip, plus what GitHub gave us to re-ask about it cheaply. */
|
|
85
|
+
#head: Head | null = null;
|
|
86
|
+
#headEtag: string | undefined;
|
|
87
|
+
#headCheckedAt = 0;
|
|
88
|
+
#inFlight: Promise<Head | null> | undefined;
|
|
89
|
+
|
|
90
|
+
/** Keyed by the commit sha it was read at, so a stale one is never used. */
|
|
91
|
+
#tree: { commit: string; entries: Map<string, RepositoryEntry> } | undefined;
|
|
92
|
+
|
|
93
|
+
/** Content-addressed, so this needs no expiry. */
|
|
94
|
+
readonly #blobs = new Map<string, Uint8Array>();
|
|
95
|
+
|
|
96
|
+
constructor(options: GithubRepositoryOptions) {
|
|
97
|
+
this.repo = options.repo;
|
|
98
|
+
this.#token = options.token;
|
|
99
|
+
this.#fetch = options.fetch ?? globalThis.fetch;
|
|
100
|
+
this.#now = options.now ?? Date.now;
|
|
101
|
+
this.#freshnessMs = options.freshnessMs ?? DEFAULT_FRESHNESS_MS;
|
|
102
|
+
this.#branch = options.branch;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
request(path: string, init: RequestInit = {}): Promise<Response> {
|
|
106
|
+
return this.#fetch(`${API}${path}`, { ...init, headers: githubHeaders(this.#token, init) });
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
async json<T>(path: string, init: RequestInit = {}, what = path): Promise<T> {
|
|
110
|
+
const response = await this.request(path, init);
|
|
111
|
+
if (!response.ok) throw await failure(what, response);
|
|
112
|
+
return (await response.json()) as T;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** The account the token belongs to. */
|
|
116
|
+
async viewer(): Promise<string> {
|
|
117
|
+
return (await this.json<{ login: string }>('/user', {}, 'identify the token')).login;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** What this token can see and do here — the probe `knowledge use` runs. */
|
|
121
|
+
async facts(): Promise<RepositoryFacts> {
|
|
122
|
+
const repository = await this.#repository();
|
|
123
|
+
this.#branch ??= repository.default_branch;
|
|
124
|
+
|
|
125
|
+
return {
|
|
126
|
+
fullName: repository.full_name,
|
|
127
|
+
private: repository.private,
|
|
128
|
+
defaultBranch: repository.default_branch,
|
|
129
|
+
canPush: repository.permissions?.push === true,
|
|
130
|
+
empty: (await this.head(true)) === null,
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
async branch(): Promise<string> {
|
|
135
|
+
this.#branch ??= (await this.#repository()).default_branch;
|
|
136
|
+
return this.#branch;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
#repository(): Promise<{
|
|
140
|
+
full_name: string;
|
|
141
|
+
private: boolean;
|
|
142
|
+
default_branch: string;
|
|
143
|
+
permissions?: { push?: boolean };
|
|
144
|
+
}> {
|
|
145
|
+
return this.json(`/repos/${this.repo}`, {}, `read ${this.repo}`);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* The branch tip, or null when the branch has no commits yet.
|
|
150
|
+
*
|
|
151
|
+
* Null is an ordinary answer rather than an error: a repository created for
|
|
152
|
+
* this and not yet written to has no commits, which is exactly the state the
|
|
153
|
+
* first `lanes link knowledge use github` finds. Everything downstream reads
|
|
154
|
+
* it as "empty".
|
|
155
|
+
*
|
|
156
|
+
* Concurrent callers share one request. `allEntries` reads sixteen entries at
|
|
157
|
+
* a time and each of them asks; without this, one search would open sixteen
|
|
158
|
+
* connections to re-learn the same sha.
|
|
159
|
+
*/
|
|
160
|
+
async head(force = false): Promise<Head | null> {
|
|
161
|
+
if (!force && this.#head && this.#now() - this.#headCheckedAt < this.#freshnessMs) {
|
|
162
|
+
return this.#head;
|
|
163
|
+
}
|
|
164
|
+
this.#inFlight ??= this.#readHead().finally(() => {
|
|
165
|
+
this.#inFlight = undefined;
|
|
166
|
+
});
|
|
167
|
+
return this.#inFlight;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
async #readHead(): Promise<Head | null> {
|
|
171
|
+
const branch = await this.branch();
|
|
172
|
+
const response = await this.request(
|
|
173
|
+
`/repos/${this.repo}/branches/${encodeURIComponent(branch)}`,
|
|
174
|
+
this.#headEtag ? { headers: { 'if-none-match': this.#headEtag } } : {},
|
|
175
|
+
);
|
|
176
|
+
|
|
177
|
+
this.#headCheckedAt = this.#now();
|
|
178
|
+
|
|
179
|
+
// 304 is the answer this exists for: unchanged, free of rate limit, and the
|
|
180
|
+
// tree cached below is still current because it is keyed by the same sha.
|
|
181
|
+
if (response.status === 304) return this.#head;
|
|
182
|
+
|
|
183
|
+
if (response.status === 404) {
|
|
184
|
+
this.#head = null;
|
|
185
|
+
this.#headEtag = undefined;
|
|
186
|
+
return null;
|
|
187
|
+
}
|
|
188
|
+
if (!response.ok) throw await failure(`read branch "${branch}" of ${this.repo}`, response);
|
|
189
|
+
|
|
190
|
+
const body = (await response.json()) as {
|
|
191
|
+
commit: { sha: string; commit?: { committer?: { date?: string } } };
|
|
192
|
+
};
|
|
193
|
+
this.#headEtag = response.headers.get('etag') ?? undefined;
|
|
194
|
+
this.#head = {
|
|
195
|
+
commit: body.commit.sha,
|
|
196
|
+
committedAt: new Date(body.commit.commit?.committer?.date ?? 0),
|
|
197
|
+
};
|
|
198
|
+
return this.#head;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* Every blob in the branch, by path.
|
|
203
|
+
*
|
|
204
|
+
* Keyed by the commit it was read at, so an unchanged head reuses it and a
|
|
205
|
+
* moved one refetches. One request per commit rather than one per listing is
|
|
206
|
+
* the whole reason `list` is affordable here.
|
|
207
|
+
*/
|
|
208
|
+
async entries(): Promise<{ entries: Map<string, RepositoryEntry>; committedAt: Date }> {
|
|
209
|
+
const head = await this.head();
|
|
210
|
+
if (head === null) return { entries: new Map(), committedAt: new Date(0) };
|
|
211
|
+
|
|
212
|
+
if (this.#tree?.commit !== head.commit) {
|
|
213
|
+
const body = await this.json<{
|
|
214
|
+
tree: Array<{ path: string; type: string; sha: string; size?: number }>;
|
|
215
|
+
truncated?: boolean;
|
|
216
|
+
}>(`/repos/${this.repo}/git/trees/${head.commit}?recursive=1`, {}, `list ${this.repo}`);
|
|
217
|
+
|
|
218
|
+
// Refused rather than served short. GitHub truncates a recursive tree
|
|
219
|
+
// past roughly 100,000 entries, and a listing missing an arbitrary tail
|
|
220
|
+
// reads as "those entries do not exist" — which for memory is a search
|
|
221
|
+
// that quietly stops finding things.
|
|
222
|
+
if (body.truncated === true) {
|
|
223
|
+
throw new GithubApiError(
|
|
224
|
+
`${this.repo} is too large to list in one request — GitHub truncated the tree. Keep ` +
|
|
225
|
+
'memory and skills in a repository of their own, or under a "path" prefix in a smaller one.',
|
|
226
|
+
200,
|
|
227
|
+
);
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
const entries = new Map<string, RepositoryEntry>();
|
|
231
|
+
for (const item of body.tree) {
|
|
232
|
+
if (item.type !== 'blob') continue;
|
|
233
|
+
entries.set(item.path, { path: item.path, sha: item.sha, size: item.size ?? 0 });
|
|
234
|
+
}
|
|
235
|
+
this.#tree = { commit: head.commit, entries };
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
return { entries: this.#tree.entries, committedAt: head.committedAt };
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/** One blob's bytes. Cached by sha, which is what a sha is for. */
|
|
242
|
+
async blob(sha: string): Promise<Uint8Array> {
|
|
243
|
+
const cached = this.#blobs.get(sha);
|
|
244
|
+
if (cached) return cached;
|
|
245
|
+
|
|
246
|
+
const response = await this.request(`/repos/${this.repo}/git/blobs/${sha}`, {
|
|
247
|
+
headers: { accept: 'application/vnd.github.raw' },
|
|
248
|
+
});
|
|
249
|
+
if (!response.ok) throw await failure(`read blob ${sha} of ${this.repo}`, response);
|
|
250
|
+
|
|
251
|
+
const bytes = new Uint8Array(await response.arrayBuffer());
|
|
252
|
+
this.#blobs.set(sha, bytes);
|
|
253
|
+
return bytes;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* Create or replace one file, as one commit.
|
|
258
|
+
*
|
|
259
|
+
* The Contents API refuses a write whose `sha` is not the one currently
|
|
260
|
+
* stored, which is how two writers on one branch are kept from silently
|
|
261
|
+
* overwriting each other. One retry against a re-read sha covers the ordinary
|
|
262
|
+
* race — a local command and a deployed endpoint touching the same entry —
|
|
263
|
+
* and a second conflict is a real fight, worth reporting rather than looping on.
|
|
264
|
+
*/
|
|
265
|
+
async writeFile(path: string, data: Uint8Array, message: string): Promise<void> {
|
|
266
|
+
for (let attempt = 0; attempt < 2; attempt += 1) {
|
|
267
|
+
const current = await this.shaOf(path, attempt > 0);
|
|
268
|
+
const response = await this.request(`/repos/${this.repo}/contents/${encodePath(path)}`, {
|
|
269
|
+
method: 'PUT',
|
|
270
|
+
body: JSON.stringify({
|
|
271
|
+
message,
|
|
272
|
+
content: base64(data),
|
|
273
|
+
branch: await this.branch(),
|
|
274
|
+
...(current ? { sha: current } : {}),
|
|
275
|
+
}),
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
// 422 is what GitHub answers when a `sha` is required and was not sent —
|
|
279
|
+
// the same staleness as a 409, reached from the other direction.
|
|
280
|
+
if (response.status === 409 || response.status === 422) continue;
|
|
281
|
+
if (!response.ok) throw await failure(`write "${path}" to ${this.repo}`, response);
|
|
282
|
+
|
|
283
|
+
this.absorb(await response.json(), path, data.byteLength);
|
|
284
|
+
return;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
throw new GithubApiError(
|
|
288
|
+
`"${path}" in ${this.repo} changed while it was being written, twice. Something else is ` +
|
|
289
|
+
'writing this branch — check for a second endpoint on the same repository.',
|
|
290
|
+
409,
|
|
291
|
+
);
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/** Remove one file. False when there was nothing to remove. */
|
|
295
|
+
async deleteFile(path: string, message: string): Promise<boolean> {
|
|
296
|
+
const sha = await this.shaOf(path, false);
|
|
297
|
+
if (!sha) return false;
|
|
298
|
+
|
|
299
|
+
const response = await this.request(`/repos/${this.repo}/contents/${encodePath(path)}`, {
|
|
300
|
+
method: 'DELETE',
|
|
301
|
+
body: JSON.stringify({ message, sha, branch: await this.branch() }),
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
// Gone already, or gone since we looked: absence is what the caller wanted.
|
|
305
|
+
if (response.status === 404 || response.status === 409) return false;
|
|
306
|
+
if (!response.ok) throw await failure(`delete "${path}" from ${this.repo}`, response);
|
|
307
|
+
|
|
308
|
+
this.absorb(await response.json(), path, null);
|
|
309
|
+
return true;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
/** Forget the head and the tree. Blobs stay: a sha is still that content. */
|
|
313
|
+
invalidate(): void {
|
|
314
|
+
this.#head = null;
|
|
315
|
+
this.#headEtag = undefined;
|
|
316
|
+
this.#headCheckedAt = 0;
|
|
317
|
+
this.#tree = undefined;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/** The blob sha a path currently has, or undefined when it has none. */
|
|
321
|
+
async shaOf(path: string, force: boolean): Promise<string | undefined> {
|
|
322
|
+
if (force) this.invalidate();
|
|
323
|
+
return (await this.entries()).entries.get(path)?.sha;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
/**
|
|
327
|
+
* Fold a Contents API response back into the cache.
|
|
328
|
+
*
|
|
329
|
+
* It carries both new shas — the commit's and the file's — so the next read
|
|
330
|
+
* needs no round trip to learn what the write just did. A response that is
|
|
331
|
+
* not the documented shape drops the cache instead of being guessed at; the
|
|
332
|
+
* cost of being wrong there is one refetch.
|
|
333
|
+
*/
|
|
334
|
+
absorb(body: unknown, path: string, size: number | null): void {
|
|
335
|
+
const response = body as {
|
|
336
|
+
commit?: { sha?: string; committer?: { date?: string } };
|
|
337
|
+
content?: { sha?: string; size?: number };
|
|
338
|
+
};
|
|
339
|
+
const commit = response.commit?.sha;
|
|
340
|
+
if (!commit || !this.#tree) {
|
|
341
|
+
this.invalidate();
|
|
342
|
+
return;
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
const entries = new Map(this.#tree.entries);
|
|
346
|
+
if (size === null || !response.content?.sha) entries.delete(path);
|
|
347
|
+
else entries.set(path, { path, sha: response.content.sha, size: response.content.size ?? size });
|
|
348
|
+
|
|
349
|
+
this.#tree = { commit, entries };
|
|
350
|
+
this.#head = { commit, committedAt: new Date(response.commit?.committer?.date ?? this.#now()) };
|
|
351
|
+
// The ETag belonged to the previous tip, so the next conditional request
|
|
352
|
+
// must not present it — it would 304 against a branch that has moved.
|
|
353
|
+
this.#headEtag = undefined;
|
|
354
|
+
this.#headCheckedAt = this.#now();
|
|
355
|
+
}
|
|
356
|
+
}
|
|
@@ -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
|
+
}
|