@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,106 @@
1
+ /**
2
+ * The wire, for the two files above it.
3
+ *
4
+ * `github-repo.ts` holds the state — which commit the branch is on, what is in
5
+ * its tree, which blobs have been read — and `github-commit.ts` holds the one
6
+ * operation that assembles several files into a single commit. What is left
7
+ * here is everything neither of them should have to know twice: the host, the
8
+ * headers, base64, path encoding, and what a failure means.
9
+ *
10
+ * `fetch` and no client library, the same argument `gcs.ts` and
11
+ * `gcp-secret-manager.ts` make: a repository holding live refresh tokens does
12
+ * not take on a transitive dependency tree to save some URL building.
13
+ */
14
+
15
+ /**
16
+ * Declared here rather than imported, for the reason `gcs.ts` gives: this
17
+ * component may not import `#auth`, and `typeof globalThis.fetch` under Bun's
18
+ * types carries a `preconnect` a test double would have to stub.
19
+ */
20
+ export type FetchLike = (input: string | URL, init?: RequestInit) => Promise<Response>;
21
+
22
+ export const API = 'https://api.github.com';
23
+
24
+ /** Every request declares the version, as GitHub's own documentation does. */
25
+ const VERSION = '2022-11-28';
26
+
27
+ export function githubHeaders(token: string, init: RequestInit): Record<string, string> {
28
+ const headers: Record<string, string> = {
29
+ accept: 'application/vnd.github+json',
30
+ authorization: `Bearer ${token}`,
31
+ 'x-github-api-version': VERSION,
32
+ ...((init.headers ?? {}) as Record<string, string>),
33
+ };
34
+ if (init.body !== undefined) headers['content-type'] = 'application/json';
35
+ return headers;
36
+ }
37
+
38
+ /** Percent-encode each segment, leaving the slashes that address a directory. */
39
+ export function encodePath(path: string): string {
40
+ return path.split('/').map(encodeURIComponent).join('/');
41
+ }
42
+
43
+ export function base64(data: Uint8Array): string {
44
+ let binary = '';
45
+ for (const byte of data) binary += String.fromCharCode(byte);
46
+ return btoa(binary);
47
+ }
48
+
49
+ export class GithubApiError extends Error {
50
+ constructor(
51
+ message: string,
52
+ readonly status: number,
53
+ ) {
54
+ super(message);
55
+ this.name = 'GithubApiError';
56
+ }
57
+ }
58
+
59
+ /**
60
+ * A failure that says what was being attempted and, where GitHub's own message
61
+ * does not, what to do about it.
62
+ *
63
+ * Three are worth naming because they are the three an operator actually hits:
64
+ * a token that has expired or was never granted this repository, the rate
65
+ * limit, and a repository whose name is right and whose visibility is not.
66
+ * GitHub answers the last two of those with a 404 by design — it does not
67
+ * confirm that a repository you cannot see exists — so the message has to offer
68
+ * the causes rather than assert one.
69
+ */
70
+ export async function failure(what: string, response: Response): Promise<GithubApiError> {
71
+ const body = await response.text().catch(() => '');
72
+ const detail = body.slice(0, 300);
73
+
74
+ if (response.status === 401) {
75
+ return new GithubApiError(
76
+ `GitHub refused the token while trying to ${what} (401). It has expired, been revoked, or ` +
77
+ 'was never valid. Generate another and run: lanes link knowledge use github --repo <owner/name>',
78
+ 401,
79
+ );
80
+ }
81
+
82
+ if (response.status === 403 && response.headers.get('x-ratelimit-remaining') === '0') {
83
+ const reset = Number(response.headers.get('x-ratelimit-reset') ?? 0);
84
+ const when = reset > 0 ? new Date(reset * 1000).toISOString() : 'shortly';
85
+ return new GithubApiError(
86
+ `GitHub's rate limit is spent, so this could not ${what}. It resets at ${when}. ` +
87
+ "Keeping memory and skills in a repository makes that limit one of this endpoint's own — " +
88
+ 'ADR-041 says so plainly, and it is the cost of the arrangement rather than a fault.',
89
+ 403,
90
+ );
91
+ }
92
+
93
+ if (response.status === 403 || response.status === 404) {
94
+ return new GithubApiError(
95
+ `GitHub would not let this token ${what} (${response.status}). The usual causes are a token ` +
96
+ 'that was not granted this repository, a fine-grained token still waiting on an ' +
97
+ `organisation owner's approval, or Contents permission set to read rather than write. ${detail}`,
98
+ response.status,
99
+ );
100
+ }
101
+
102
+ return new GithubApiError(
103
+ `GitHub failed to ${what} (${response.status}). ${detail}`,
104
+ response.status,
105
+ );
106
+ }
@@ -0,0 +1,103 @@
1
+ import { base64 } from './github-api.ts';
2
+ import type { GithubRepository } from './github-repo.ts';
3
+
4
+ /**
5
+ * Many files, and many deletions, as **one** commit.
6
+ *
7
+ * `writeFile` is one commit per file, which is right for a single write and
8
+ * wrong for a migration: two hundred commits each reading "store memory entry"
9
+ * is not a history anybody opens, and it is two hundred round trips against a
10
+ * limit this endpoint now shares. This goes through the Git Data API instead —
11
+ * blobs, a tree, a commit, then move the ref — which is what `git push` does
12
+ * and produces the same object graph.
13
+ *
14
+ * Its own file because it is the one operation that does not fit the
15
+ * one-request-one-answer shape of everything in `github-repo.ts`, and because
16
+ * it needs none of that file's private state: it works entirely through the
17
+ * repository's public surface, so the split costs nothing.
18
+ *
19
+ * **The empty repository is the ordinary case here, not an edge one.** Somebody
20
+ * following the setup steps creates a repository for this and migrates into it
21
+ * immediately, so there is no parent commit and no base tree, and the ref has
22
+ * to be created rather than moved.
23
+ */
24
+
25
+ export interface CommitFile {
26
+ readonly path: string;
27
+ readonly data: Uint8Array;
28
+ }
29
+
30
+ /** The new commit sha, or null when there was nothing to commit. */
31
+ export async function commitFiles(
32
+ repository: GithubRepository,
33
+ files: readonly CommitFile[],
34
+ deletes: readonly string[],
35
+ message: string,
36
+ ): Promise<string | null> {
37
+ if (files.length === 0 && deletes.length === 0) return null;
38
+
39
+ const branch = await repository.branch();
40
+ // Forced: a migration is the one place where reading a two-second-old head
41
+ // could mean committing against a parent that has already moved.
42
+ const head = await repository.head(true);
43
+
44
+ const blobs = await Promise.all(
45
+ files.map(async (file) => ({
46
+ path: file.path,
47
+ sha: (
48
+ await repository.json<{ sha: string }>(
49
+ `/repos/${repository.repo}/git/blobs`,
50
+ {
51
+ method: 'POST',
52
+ body: JSON.stringify({ content: base64(file.data), encoding: 'base64' }),
53
+ },
54
+ `upload "${file.path}" to ${repository.repo}`,
55
+ )
56
+ ).sha,
57
+ })),
58
+ );
59
+
60
+ const tree = await repository.json<{ sha: string }>(
61
+ `/repos/${repository.repo}/git/trees`,
62
+ {
63
+ method: 'POST',
64
+ body: JSON.stringify({
65
+ ...(head ? { base_tree: head.commit } : {}),
66
+ tree: [
67
+ ...blobs.map((blob) => ({ path: blob.path, mode: '100644', type: 'blob', sha: blob.sha })),
68
+ // A null sha is how the Git Data API spells "not in this tree".
69
+ ...deletes.map((path) => ({ path, mode: '100644', type: 'blob', sha: null })),
70
+ ],
71
+ }),
72
+ },
73
+ `build a tree for ${repository.repo}`,
74
+ );
75
+
76
+ const commit = await repository.json<{ sha: string }>(
77
+ `/repos/${repository.repo}/git/commits`,
78
+ {
79
+ method: 'POST',
80
+ body: JSON.stringify({ message, tree: tree.sha, parents: head ? [head.commit] : [] }),
81
+ },
82
+ `commit to ${repository.repo}`,
83
+ );
84
+
85
+ // Create when the branch has no commits, move when it does. A `PATCH` on a
86
+ // ref that does not exist answers 422, which reads as a permissions problem
87
+ // and is not one.
88
+ await repository.json(
89
+ head
90
+ ? `/repos/${repository.repo}/git/refs/heads/${encodeURIComponent(branch)}`
91
+ : `/repos/${repository.repo}/git/refs`,
92
+ {
93
+ method: head ? 'PATCH' : 'POST',
94
+ body: JSON.stringify(
95
+ head ? { sha: commit.sha } : { ref: `refs/heads/${branch}`, sha: commit.sha },
96
+ ),
97
+ },
98
+ `move "${branch}" in ${repository.repo}`,
99
+ );
100
+
101
+ repository.invalidate();
102
+ return commit.sha;
103
+ }
@@ -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
+ }