@functional-systems/srcx 0.1.0-dev.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/CHANGELOG.md +20 -0
- package/CONTRIBUTING.md +54 -0
- package/DESIGN.md +1057 -0
- package/LICENSE +202 -0
- package/README.md +209 -0
- package/RELEASING.md +137 -0
- package/VALIDATION.md +181 -0
- package/dist/build.d.ts +94 -0
- package/dist/build.js +433 -0
- package/dist/build.js.map +1 -0
- package/dist/chunk.d.ts +30 -0
- package/dist/chunk.js +296 -0
- package/dist/chunk.js.map +1 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +276 -0
- package/dist/cli.js.map +1 -0
- package/dist/common.d.ts +17 -0
- package/dist/common.js +74 -0
- package/dist/common.js.map +1 -0
- package/dist/git.d.ts +38 -0
- package/dist/git.js +224 -0
- package/dist/git.js.map +1 -0
- package/dist/publish.d.ts +60 -0
- package/dist/publish.js +341 -0
- package/dist/publish.js.map +1 -0
- package/dist/releases.d.ts +8 -0
- package/dist/releases.js +79 -0
- package/dist/releases.js.map +1 -0
- package/dist/remote.d.ts +53 -0
- package/dist/remote.js +136 -0
- package/dist/remote.js.map +1 -0
- package/dist/repository.d.ts +29 -0
- package/dist/repository.js +180 -0
- package/dist/repository.js.map +1 -0
- package/dist/search.d.ts +46 -0
- package/dist/search.js +181 -0
- package/dist/search.js.map +1 -0
- package/dist/settings.d.ts +10 -0
- package/dist/settings.js +35 -0
- package/dist/settings.js.map +1 -0
- package/package.json +58 -0
- package/src/build.ts +587 -0
- package/src/chunk.ts +328 -0
- package/src/cli.ts +366 -0
- package/src/common.ts +71 -0
- package/src/git.ts +286 -0
- package/src/publish.ts +487 -0
- package/src/releases.ts +108 -0
- package/src/remote.ts +230 -0
- package/src/repository.ts +306 -0
- package/src/search.ts +282 -0
- package/src/settings.ts +59 -0
package/src/common.ts
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
2
|
+
import { mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
|
|
3
|
+
import { dirname } from "node:path";
|
|
4
|
+
export const APP = "srcx";
|
|
5
|
+
export const PURPOSE = "code-search-v1";
|
|
6
|
+
export type Doc = { id: string; kind: string; [key: string]: unknown };
|
|
7
|
+
export function canonical(value: unknown): string {
|
|
8
|
+
if (Array.isArray(value)) return `[${value.map(canonical).join(",")}]`;
|
|
9
|
+
if (value && typeof value === "object")
|
|
10
|
+
return `{${Object.entries(value)
|
|
11
|
+
.filter(([, v]) => v !== undefined)
|
|
12
|
+
.sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))
|
|
13
|
+
.map(([k, v]) => `${JSON.stringify(k)}:${canonical(v)}`)
|
|
14
|
+
.join(",")}}`;
|
|
15
|
+
return JSON.stringify(value);
|
|
16
|
+
}
|
|
17
|
+
export function hash(value: unknown): string {
|
|
18
|
+
return createHash("sha256")
|
|
19
|
+
.update(
|
|
20
|
+
Buffer.isBuffer(value)
|
|
21
|
+
? value
|
|
22
|
+
: typeof value === "string"
|
|
23
|
+
? value
|
|
24
|
+
: canonical(value),
|
|
25
|
+
)
|
|
26
|
+
.digest("hex");
|
|
27
|
+
}
|
|
28
|
+
export function invariant(value: unknown, message: string): asserts value {
|
|
29
|
+
if (!value) throw new Error(message);
|
|
30
|
+
}
|
|
31
|
+
export async function atomic(path: string, value: unknown): Promise<void> {
|
|
32
|
+
await mkdir(dirname(path), { recursive: true, mode: 0o700 });
|
|
33
|
+
const temp = `${path}.${randomUUID()}.tmp`;
|
|
34
|
+
try {
|
|
35
|
+
await writeFile(temp, `${JSON.stringify(value, null, 2)}\n`, {
|
|
36
|
+
flag: "wx",
|
|
37
|
+
mode: 0o600,
|
|
38
|
+
});
|
|
39
|
+
await rename(temp, path);
|
|
40
|
+
} finally {
|
|
41
|
+
await rm(temp, { force: true });
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
export async function json<T>(path: string): Promise<T> {
|
|
45
|
+
return JSON.parse(await readFile(path, "utf8")) as T;
|
|
46
|
+
}
|
|
47
|
+
export async function optionalJson<T>(path: string): Promise<T | undefined> {
|
|
48
|
+
try {
|
|
49
|
+
return await json<T>(path);
|
|
50
|
+
} catch (e) {
|
|
51
|
+
if ((e as NodeJS.ErrnoException).code === "ENOENT") return undefined;
|
|
52
|
+
throw e;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
export function utf8(bytes: Buffer): string | undefined {
|
|
56
|
+
const s = bytes.toString("utf8");
|
|
57
|
+
return Buffer.from(s).equals(bytes) ? s : undefined;
|
|
58
|
+
}
|
|
59
|
+
export function sortedHash(docs: Doc[]): string {
|
|
60
|
+
return hash(
|
|
61
|
+
[...docs].sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0)),
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
export function lineAt(source: Buffer, byte: number): number {
|
|
65
|
+
let n = 1;
|
|
66
|
+
for (let i = 0; i < byte; i++) if (source[i] === 10) n++;
|
|
67
|
+
return n;
|
|
68
|
+
}
|
|
69
|
+
export function sleep(ms: number): Promise<void> {
|
|
70
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
71
|
+
}
|
package/src/git.ts
ADDED
|
@@ -0,0 +1,286 @@
|
|
|
1
|
+
import { execFile, spawn } from "node:child_process";
|
|
2
|
+
import { promisify } from "node:util";
|
|
3
|
+
import { realpath } from "node:fs/promises";
|
|
4
|
+
import { basename, join } from "node:path";
|
|
5
|
+
import { randomUUID } from "node:crypto";
|
|
6
|
+
import { atomic, hash, invariant, optionalJson, utf8 } from "./common.js";
|
|
7
|
+
import { stateRoot } from "./settings.js";
|
|
8
|
+
const exec = promisify(execFile);
|
|
9
|
+
export async function git(path: string, args: string[]): Promise<Buffer> {
|
|
10
|
+
try {
|
|
11
|
+
const r = await exec("git", ["--no-replace-objects", "-C", path, ...args], {
|
|
12
|
+
encoding: "buffer",
|
|
13
|
+
maxBuffer: 64 * 1024 * 1024,
|
|
14
|
+
env: {
|
|
15
|
+
...process.env,
|
|
16
|
+
GIT_NO_REPLACE_OBJECTS: "1",
|
|
17
|
+
GIT_TERMINAL_PROMPT: "0",
|
|
18
|
+
},
|
|
19
|
+
});
|
|
20
|
+
return r.stdout;
|
|
21
|
+
} catch {
|
|
22
|
+
throw new Error(
|
|
23
|
+
`Git ${args[0]} failed. Verify the repository, ref, and local objects.`,
|
|
24
|
+
);
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
export async function resolveCommit(
|
|
28
|
+
path: string,
|
|
29
|
+
ref: string,
|
|
30
|
+
): Promise<{ oid: string; branch?: string; time: string }> {
|
|
31
|
+
invariant(
|
|
32
|
+
ref && !ref.startsWith("-") && !/[\s~^:@{\\]/.test(ref),
|
|
33
|
+
"Use a branch, Git tag, or commit OID; revision expressions are not supported.",
|
|
34
|
+
);
|
|
35
|
+
let qualified = ref;
|
|
36
|
+
let branch: string | undefined;
|
|
37
|
+
const exists = async (r: string) => {
|
|
38
|
+
try {
|
|
39
|
+
await git(path, ["show-ref", "--verify", "--hash", r]);
|
|
40
|
+
return true;
|
|
41
|
+
} catch {
|
|
42
|
+
return false;
|
|
43
|
+
}
|
|
44
|
+
};
|
|
45
|
+
if (ref.startsWith("refs/")) {
|
|
46
|
+
invariant(
|
|
47
|
+
ref.startsWith("refs/heads/") || ref.startsWith("refs/tags/"),
|
|
48
|
+
"Only heads/tags refs are supported.",
|
|
49
|
+
);
|
|
50
|
+
invariant(await exists(ref), "Ref not found.");
|
|
51
|
+
if (ref.startsWith("refs/heads/")) branch = ref;
|
|
52
|
+
} else {
|
|
53
|
+
const heads = await exists(`refs/heads/${ref}`),
|
|
54
|
+
tags = await exists(`refs/tags/${ref}`);
|
|
55
|
+
invariant(
|
|
56
|
+
!(heads && tags),
|
|
57
|
+
"Ambiguous branch/tag name: use refs/heads/... or refs/tags/...",
|
|
58
|
+
);
|
|
59
|
+
if (heads) {
|
|
60
|
+
qualified = `refs/heads/${ref}`;
|
|
61
|
+
branch = qualified;
|
|
62
|
+
} else if (tags) qualified = `refs/tags/${ref}`;
|
|
63
|
+
else
|
|
64
|
+
invariant(
|
|
65
|
+
/^[0-9a-fA-F]{4,64}$/.test(ref),
|
|
66
|
+
"Unknown ref; only local branch/tag names and commit OIDs are accepted.",
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
const oid = (
|
|
70
|
+
await git(path, [
|
|
71
|
+
"rev-parse",
|
|
72
|
+
"--verify",
|
|
73
|
+
"--end-of-options",
|
|
74
|
+
`${qualified}^{commit}`,
|
|
75
|
+
])
|
|
76
|
+
)
|
|
77
|
+
.toString()
|
|
78
|
+
.trim();
|
|
79
|
+
const time = (await git(path, ["show", "-s", "--format=%cI", oid]))
|
|
80
|
+
.toString()
|
|
81
|
+
.trim();
|
|
82
|
+
return { oid, branch, time };
|
|
83
|
+
}
|
|
84
|
+
export type Entry = {
|
|
85
|
+
path: string;
|
|
86
|
+
pathBase64: string;
|
|
87
|
+
validPath: boolean;
|
|
88
|
+
mode: string;
|
|
89
|
+
oid: string;
|
|
90
|
+
type: string;
|
|
91
|
+
bytes: number;
|
|
92
|
+
};
|
|
93
|
+
export async function inventory(path: string, oid: string): Promise<Entry[]> {
|
|
94
|
+
const raw = await git(path, ["ls-tree", "-r", "-z", "--full-tree", oid]);
|
|
95
|
+
const entries: Entry[] = [];
|
|
96
|
+
let start = 0;
|
|
97
|
+
for (let i = 0; i < raw.length; i++)
|
|
98
|
+
if (raw[i] === 0) {
|
|
99
|
+
const rec = raw.subarray(start, i);
|
|
100
|
+
start = i + 1;
|
|
101
|
+
const tab = rec.indexOf(9);
|
|
102
|
+
invariant(tab >= 0, "Malformed Git tree.");
|
|
103
|
+
const [mode, type, object] = rec.subarray(0, tab).toString().split(" ");
|
|
104
|
+
const name = rec.subarray(tab + 1);
|
|
105
|
+
const decoded = utf8(name);
|
|
106
|
+
invariant(mode && type && object, "Malformed tree metadata.");
|
|
107
|
+
entries.push({
|
|
108
|
+
path: decoded ?? `<invalid-utf8:${name.toString("base64")}>`,
|
|
109
|
+
pathBase64: name.toString("base64"),
|
|
110
|
+
validPath: decoded !== undefined,
|
|
111
|
+
mode,
|
|
112
|
+
oid: object,
|
|
113
|
+
type,
|
|
114
|
+
bytes: 0,
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
const blobs = entries.filter((e) => e.type === "blob");
|
|
118
|
+
if (blobs.length) {
|
|
119
|
+
const child = spawn(
|
|
120
|
+
"git",
|
|
121
|
+
[
|
|
122
|
+
"--no-replace-objects",
|
|
123
|
+
"-C",
|
|
124
|
+
path,
|
|
125
|
+
"cat-file",
|
|
126
|
+
"--batch-check=%(objectname) %(objecttype) %(objectsize)",
|
|
127
|
+
],
|
|
128
|
+
{ stdio: ["pipe", "pipe", "ignore"] },
|
|
129
|
+
);
|
|
130
|
+
const pieces: Buffer[] = [];
|
|
131
|
+
child.stdout.on("data", (b: Buffer) => pieces.push(b));
|
|
132
|
+
const ended = new Promise<void>((ok, no) => {
|
|
133
|
+
child.once("error", no);
|
|
134
|
+
child.once("close", (c) =>
|
|
135
|
+
c === 0 ? ok() : no(new Error("Git object size lookup failed.")),
|
|
136
|
+
);
|
|
137
|
+
});
|
|
138
|
+
child.stdin.on("error", () => {});
|
|
139
|
+
child.stdin.end(blobs.map((e) => e.oid + "\n").join(""));
|
|
140
|
+
await ended;
|
|
141
|
+
const lines = Buffer.concat(pieces).toString().trim().split("\n");
|
|
142
|
+
invariant(
|
|
143
|
+
lines.length === blobs.length,
|
|
144
|
+
"Incomplete Git object inventory.",
|
|
145
|
+
);
|
|
146
|
+
lines.forEach((line, i) => {
|
|
147
|
+
const [id, type, size] = line.split(" ");
|
|
148
|
+
const e = blobs[i]!;
|
|
149
|
+
invariant(
|
|
150
|
+
id === e.oid && type === "blob" && /^\d+$/.test(size ?? ""),
|
|
151
|
+
"Missing Git blob.",
|
|
152
|
+
);
|
|
153
|
+
e.bytes = Number(size);
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
return entries;
|
|
157
|
+
}
|
|
158
|
+
/** One sequential cat-file process; each response is bounded to one allowed blob. */
|
|
159
|
+
export class Blobs {
|
|
160
|
+
private child;
|
|
161
|
+
private iterator;
|
|
162
|
+
private buffer = Buffer.alloc(0);
|
|
163
|
+
constructor(path: string) {
|
|
164
|
+
this.child = spawn(
|
|
165
|
+
"git",
|
|
166
|
+
["--no-replace-objects", "-C", path, "cat-file", "--batch"],
|
|
167
|
+
{ stdio: ["pipe", "pipe", "ignore"] },
|
|
168
|
+
);
|
|
169
|
+
this.child.on("error", () => {});
|
|
170
|
+
this.child.stdin.on("error", () => {});
|
|
171
|
+
this.iterator = this.child.stdout[Symbol.asyncIterator]();
|
|
172
|
+
}
|
|
173
|
+
private async fill(n: number): Promise<void> {
|
|
174
|
+
while (this.buffer.length < n) {
|
|
175
|
+
const r = await this.iterator.next();
|
|
176
|
+
invariant(!r.done, "Git blob stream ended early.");
|
|
177
|
+
this.buffer = Buffer.concat([this.buffer, r.value as Buffer]);
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
async read(e: Entry): Promise<Buffer> {
|
|
181
|
+
this.child.stdin.write(e.oid + "\n");
|
|
182
|
+
let newline = this.buffer.indexOf(10);
|
|
183
|
+
while (newline < 0) {
|
|
184
|
+
await this.fill(this.buffer.length + 1);
|
|
185
|
+
newline = this.buffer.indexOf(10);
|
|
186
|
+
}
|
|
187
|
+
const header = this.buffer.subarray(0, newline).toString();
|
|
188
|
+
this.buffer = this.buffer.subarray(newline + 1);
|
|
189
|
+
invariant(
|
|
190
|
+
header === `${e.oid} blob ${e.bytes}`,
|
|
191
|
+
"Git blob metadata changed or object is missing.",
|
|
192
|
+
);
|
|
193
|
+
await this.fill(e.bytes + 1);
|
|
194
|
+
const data = Buffer.from(this.buffer.subarray(0, e.bytes));
|
|
195
|
+
invariant(this.buffer[e.bytes] === 10, "Malformed Git blob response.");
|
|
196
|
+
this.buffer = this.buffer.subarray(e.bytes + 1);
|
|
197
|
+
return data;
|
|
198
|
+
}
|
|
199
|
+
close() {
|
|
200
|
+
this.child.stdin.end();
|
|
201
|
+
this.child.kill();
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
export function normalizeRemote(raw: string): string {
|
|
205
|
+
const value = raw.trim();
|
|
206
|
+
let host: string, path: string;
|
|
207
|
+
if (value.includes("://")) {
|
|
208
|
+
const u = new URL(value);
|
|
209
|
+
invariant(
|
|
210
|
+
["ssh:", "https:", "http:", "git:"].includes(u.protocol) &&
|
|
211
|
+
!u.search &&
|
|
212
|
+
!u.hash,
|
|
213
|
+
"Unsupported remote URL.",
|
|
214
|
+
);
|
|
215
|
+
host = u.host.toLowerCase();
|
|
216
|
+
path = u.pathname;
|
|
217
|
+
} else {
|
|
218
|
+
const m = /^(?:[^@/:]+@)?([^/:]+):(.+)$/.exec(value);
|
|
219
|
+
invariant(
|
|
220
|
+
m,
|
|
221
|
+
"Remote must be a network Git URL; local-path remotes need explicit support.",
|
|
222
|
+
);
|
|
223
|
+
host = m[1]!.toLowerCase();
|
|
224
|
+
path = m[2]!;
|
|
225
|
+
}
|
|
226
|
+
path = path.replace(/^\/+|\/+$/g, "").replace(/\.git$/, "");
|
|
227
|
+
invariant(
|
|
228
|
+
host && path && !path.split("/").some((x) => !x || x === "." || x === ".."),
|
|
229
|
+
"Invalid remote identity.",
|
|
230
|
+
);
|
|
231
|
+
if (host === "github.com") path = path.toLowerCase();
|
|
232
|
+
return `${host}/${path}`;
|
|
233
|
+
}
|
|
234
|
+
export type Identity = {
|
|
235
|
+
key: string;
|
|
236
|
+
name: string;
|
|
237
|
+
path: string;
|
|
238
|
+
remote?: string;
|
|
239
|
+
};
|
|
240
|
+
export async function identity(
|
|
241
|
+
path: string,
|
|
242
|
+
remote?: string,
|
|
243
|
+
): Promise<Identity> {
|
|
244
|
+
path = await realpath(path);
|
|
245
|
+
await git(path, ["rev-parse", "--git-dir"]);
|
|
246
|
+
const names = (await git(path, ["remote"]))
|
|
247
|
+
.toString()
|
|
248
|
+
.trim()
|
|
249
|
+
.split("\n")
|
|
250
|
+
.filter(Boolean);
|
|
251
|
+
let selected = remote;
|
|
252
|
+
if (!selected && names.includes("origin")) selected = "origin";
|
|
253
|
+
if (!selected && names.length)
|
|
254
|
+
throw new Error("No origin remote; choose --remote explicitly.");
|
|
255
|
+
if (selected) {
|
|
256
|
+
invariant(names.includes(selected), "Selected remote does not exist.");
|
|
257
|
+
const key = normalizeRemote(
|
|
258
|
+
(await git(path, ["remote", "get-url", selected])).toString(),
|
|
259
|
+
);
|
|
260
|
+
return { key, name: key.split("/").at(-1)!, path, remote: selected };
|
|
261
|
+
}
|
|
262
|
+
const file = join(stateRoot(), "local-sources", hash(path) + ".json");
|
|
263
|
+
let saved = await optionalJson<{ key: string }>(file);
|
|
264
|
+
if (!saved) {
|
|
265
|
+
saved = { key: `local/${randomUUID()}` };
|
|
266
|
+
await atomic(file, saved);
|
|
267
|
+
}
|
|
268
|
+
return { key: saved.key, name: basename(path), path };
|
|
269
|
+
}
|
|
270
|
+
export async function gitTags(
|
|
271
|
+
path: string,
|
|
272
|
+
): Promise<{ ref: string; oid: string }[]> {
|
|
273
|
+
const refs = (
|
|
274
|
+
await git(path, ["for-each-ref", "--format=%(refname)", "refs/tags/"])
|
|
275
|
+
)
|
|
276
|
+
.toString()
|
|
277
|
+
.trim()
|
|
278
|
+
.split("\n")
|
|
279
|
+
.filter(Boolean);
|
|
280
|
+
const result = [];
|
|
281
|
+
for (const ref of refs) {
|
|
282
|
+
const { oid } = await resolveCommit(path, ref);
|
|
283
|
+
result.push({ ref, oid });
|
|
284
|
+
}
|
|
285
|
+
return result;
|
|
286
|
+
}
|