@foldrun/core 1.4.1 → 2.0.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/dist/index.d.ts +0 -3
- package/dist/index.js +0 -3
- package/dist/src/deploy.d.ts +0 -26
- package/dist/src/deploy.js +10 -53
- package/dist/src/history.d.ts +30 -10
- package/dist/src/history.js +232 -99
- package/dist/src/library.d.ts +0 -13
- package/dist/src/library.js +0 -50
- package/dist/src/platform.d.ts +1 -3
- package/dist/src/platform.js +0 -1
- package/dist/src/run-container.d.ts +29 -0
- package/dist/src/run-container.js +74 -2
- package/dist/src/runner.js +10 -2
- package/dist/src/secrets.js +1 -12
- package/dist/src/starter.js +2 -2
- package/dist/src/store.d.ts +15 -10
- package/dist/src/store.js +45 -21
- package/index.ts +0 -3
- package/package.json +1 -3
- package/src/deploy.ts +5 -71
- package/src/history.ts +230 -108
- package/src/library.ts +0 -44
- package/src/platform.ts +1 -4
- package/src/run-container.ts +78 -2
- package/src/runner.ts +10 -1
- package/src/secrets.ts +1 -11
- package/src/starter.ts +2 -2
- package/src/store.ts +55 -27
- package/dist/src/git-hook.d.ts +0 -1
- package/dist/src/git-hook.js +0 -70
- package/dist/src/git.d.ts +0 -37
- package/dist/src/git.js +0 -101
- package/dist/src/gitrepo.d.ts +0 -113
- package/dist/src/gitrepo.js +0 -404
- package/dist/src/tar.d.ts +0 -30
- package/dist/src/tar.js +0 -112
- package/src/git-hook.ts +0 -73
- package/src/git.ts +0 -117
- package/src/gitrepo.ts +0 -439
- package/src/tar.ts +0 -125
package/src/git-hook.ts
DELETED
|
@@ -1,73 +0,0 @@
|
|
|
1
|
-
// The pre-receive check, run by git inside a push.
|
|
2
|
-
//
|
|
3
|
-
// git feeds "<old> <new> <ref>" lines on stdin. For a push to main, read the
|
|
4
|
-
// tree at the new commit straight from the object store and run the deploy
|
|
5
|
-
// checks on it. Any issue: print them and exit non-zero — the push is refused
|
|
6
|
-
// and the person sees exactly what a dashboard deploy would have told them,
|
|
7
|
-
// in their terminal, before anything changed.
|
|
8
|
-
//
|
|
9
|
-
// Runs as its own process under git's environment. GIT_DIR is set by git.
|
|
10
|
-
|
|
11
|
-
import fs from "node:fs";
|
|
12
|
-
import path from "node:path";
|
|
13
|
-
import { spawnSync } from "node:child_process";
|
|
14
|
-
import { deployIssues, runsInFlight } from "./deploy.ts";
|
|
15
|
-
|
|
16
|
-
const dir = process.env.GIT_DIR;
|
|
17
|
-
if (!dir) process.exit(0);
|
|
18
|
-
|
|
19
|
-
// `_library.git` is the account shelf, not a workspace: it holds tools,
|
|
20
|
-
// skills and knowledge and has no agents by definition. The workspace rules
|
|
21
|
-
// refused every push to it with "no agents". Its sync (syncLibraryFromTree)
|
|
22
|
-
// validates what it accepts on receive; nothing here to add yet.
|
|
23
|
-
// Inside a hook git sets GIT_DIR relative to the repo ("."), so resolve it
|
|
24
|
-
// against the cwd before asking for its name.
|
|
25
|
-
if (path.basename(path.resolve(process.cwd(), dir)) === "_library.git") process.exit(0);
|
|
26
|
-
|
|
27
|
-
const input = fs.readFileSync(0, "utf8");
|
|
28
|
-
const git = (args: string[]) => {
|
|
29
|
-
const r = spawnSync("git", ["--git-dir", dir, ...args], { maxBuffer: 64 * 1024 * 1024 });
|
|
30
|
-
return r.status === 0 ? r.stdout.toString() : null;
|
|
31
|
-
};
|
|
32
|
-
|
|
33
|
-
// The client is gone by the time the deploy runs, so this is the only moment
|
|
34
|
-
// a person can be told their push will not take effect yet. A warning, not a
|
|
35
|
-
// refusal: the commit is fine and the scheduler applies it when the runs end.
|
|
36
|
-
const here = path.basename(path.resolve(process.cwd(), dir));
|
|
37
|
-
if (here.endsWith(".git")) {
|
|
38
|
-
const workspace = here.slice(0, -4);
|
|
39
|
-
const tenant = path.basename(path.resolve(process.cwd(), dir, "..", ".."));
|
|
40
|
-
try {
|
|
41
|
-
const live = runsInFlight(tenant, workspace);
|
|
42
|
-
if (live.length) {
|
|
43
|
-
process.stderr.write(
|
|
44
|
-
`\nfoldrun: ${live.length} run${live.length === 1 ? " is" : "s are"} in flight, so this push is not live yet.\n` +
|
|
45
|
-
` It is on main and will be applied automatically when they finish.\n\n`,
|
|
46
|
-
);
|
|
47
|
-
}
|
|
48
|
-
} catch {
|
|
49
|
-
// A warning that cannot be computed must never fail a push.
|
|
50
|
-
}
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
let refused = false;
|
|
54
|
-
for (const line of input.split("\n").filter(Boolean)) {
|
|
55
|
-
const [, newSha, ref] = line.split(" ");
|
|
56
|
-
if (ref !== "refs/heads/main" || /^0+$/.test(newSha)) continue;
|
|
57
|
-
const tree = git(["ls-tree", "-r", "--name-only", newSha]) ?? "";
|
|
58
|
-
const files = tree
|
|
59
|
-
.split("\n")
|
|
60
|
-
.filter(Boolean)
|
|
61
|
-
.flatMap((p) => {
|
|
62
|
-
const content = git(["show", `${newSha}:${p}`]);
|
|
63
|
-
return content === null ? [] : [{ path: p, content }];
|
|
64
|
-
});
|
|
65
|
-
const issues = deployIssues(files);
|
|
66
|
-
if (issues.length) {
|
|
67
|
-
refused = true;
|
|
68
|
-
process.stderr.write(`\nfoldrun: refusing this push — ${issues.length} problem${issues.length === 1 ? "" : "s"} the checks would have stopped at deploy:\n`);
|
|
69
|
-
for (const i of issues) process.stderr.write(` ${i.where}: ${i.message}\n`);
|
|
70
|
-
process.stderr.write(`\nFix and push again. (foldrun check runs the same rules locally.)\n\n`);
|
|
71
|
-
}
|
|
72
|
-
}
|
|
73
|
-
process.exit(refused ? 1 : 0);
|
package/src/git.ts
DELETED
|
@@ -1,117 +0,0 @@
|
|
|
1
|
-
// git push → deployed.
|
|
2
|
-
//
|
|
3
|
-
// A workspace is markdown, so there is no build to run: a push is a fetch of
|
|
4
|
-
// the tree at that commit, a check, and a swap. This module is the GitHub half
|
|
5
|
-
// — the secret a repository signs with, the payload shape, and pulling the
|
|
6
|
-
// tarball. `deploy.ts` decides whether the result is allowed to go live.
|
|
7
|
-
//
|
|
8
|
-
// The tarball, not a clone: one HTTP call, no git binary, no working copy kept
|
|
9
|
-
// on the server. A markdown workspace is a few hundred kilobytes.
|
|
10
|
-
|
|
11
|
-
import crypto from "node:crypto";
|
|
12
|
-
import { installKey } from "./webhook.ts";
|
|
13
|
-
|
|
14
|
-
/** GitHub refuses to fetch anything larger, and neither should we. */
|
|
15
|
-
const MAX_TARBALL = 25 * 1024 * 1024;
|
|
16
|
-
|
|
17
|
-
/**
|
|
18
|
-
* The secret a repository signs its pushes with.
|
|
19
|
-
*
|
|
20
|
-
* Derived rather than stored, exactly like a flow's webhook token: HMAC of the
|
|
21
|
-
* workspace's identity under the install key. Nothing new to persist, unique
|
|
22
|
-
* per workspace, and rotating FOLDRUN_SECRET_KEY invalidates every connection
|
|
23
|
-
* at once.
|
|
24
|
-
*/
|
|
25
|
-
export function gitSecret(tenant: string, workspace: string): string {
|
|
26
|
-
return crypto
|
|
27
|
-
.createHmac("sha256", installKey())
|
|
28
|
-
.update(`git/${tenant}/${workspace}`)
|
|
29
|
-
.digest("hex");
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
export function gitHookPath(tenant: string, workspace: string): string {
|
|
33
|
-
return `/api/git/${tenant}/${workspace}`;
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
/**
|
|
37
|
-
* Whether a request really came from the repository it claims to.
|
|
38
|
-
*
|
|
39
|
-
* Compared in constant time: a byte-by-byte early return leaks the expected
|
|
40
|
-
* signature to anyone willing to time enough requests. The raw body is
|
|
41
|
-
* required — re-serializing parsed JSON changes bytes GitHub signed.
|
|
42
|
-
*/
|
|
43
|
-
export function verifySignature(secret: string, rawBody: string, header: string | null): boolean {
|
|
44
|
-
if (!header) return false;
|
|
45
|
-
const expected = `sha256=${crypto.createHmac("sha256", secret).update(rawBody).digest("hex")}`;
|
|
46
|
-
const a = Buffer.from(expected);
|
|
47
|
-
const b = Buffer.from(header);
|
|
48
|
-
return a.length === b.length && crypto.timingSafeEqual(a, b);
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
export interface PushEvent {
|
|
52
|
-
repo: string;
|
|
53
|
-
/** Full ref, e.g. refs/heads/main. */
|
|
54
|
-
ref: string;
|
|
55
|
-
branch: string;
|
|
56
|
-
commit: string;
|
|
57
|
-
message: string | null;
|
|
58
|
-
pusher: string | null;
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
/** What we need out of a GitHub push payload, or null if it isn't one. */
|
|
62
|
-
export function parsePush(payload: unknown): PushEvent | null {
|
|
63
|
-
if (!payload || typeof payload !== "object") return null;
|
|
64
|
-
const p = payload as Record<string, any>;
|
|
65
|
-
const repo = p.repository?.full_name;
|
|
66
|
-
const ref = p.ref;
|
|
67
|
-
const commit = p.after;
|
|
68
|
-
if (typeof repo !== "string" || typeof ref !== "string" || typeof commit !== "string") {
|
|
69
|
-
return null;
|
|
70
|
-
}
|
|
71
|
-
// A deleted branch reports all zeros and ships no tree.
|
|
72
|
-
if (/^0+$/.test(commit)) return null;
|
|
73
|
-
return {
|
|
74
|
-
repo,
|
|
75
|
-
ref,
|
|
76
|
-
branch: ref.replace(/^refs\/heads\//, ""),
|
|
77
|
-
commit,
|
|
78
|
-
message: typeof p.head_commit?.message === "string" ? p.head_commit.message : null,
|
|
79
|
-
pusher: typeof p.pusher?.name === "string" ? p.pusher.name : null,
|
|
80
|
-
};
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
/**
|
|
84
|
-
* The repository at one commit, as a gzipped tarball.
|
|
85
|
-
*
|
|
86
|
-
* A token is only needed for a private repository. It is read from the
|
|
87
|
-
* environment rather than taken as an argument so a caller cannot be tricked
|
|
88
|
-
* into forwarding one somewhere else.
|
|
89
|
-
*/
|
|
90
|
-
export async function fetchTarball(repo: string, commit: string): Promise<Buffer> {
|
|
91
|
-
if (!/^[\w.-]+\/[\w.-]+$/.test(repo)) throw new Error(`refusing to fetch "${repo}"`);
|
|
92
|
-
if (!/^[0-9a-f]{7,40}$/i.test(commit)) throw new Error(`refusing to fetch commit "${commit}"`);
|
|
93
|
-
|
|
94
|
-
const headers: Record<string, string> = {
|
|
95
|
-
accept: "application/vnd.github+json",
|
|
96
|
-
"user-agent": "foldrun",
|
|
97
|
-
"x-github-api-version": "2022-11-28",
|
|
98
|
-
};
|
|
99
|
-
const token = process.env.GITHUB_TOKEN;
|
|
100
|
-
if (token) headers.authorization = `Bearer ${token}`;
|
|
101
|
-
|
|
102
|
-
const res = await fetch(`https://api.github.com/repos/${repo}/tarball/${commit}`, { headers });
|
|
103
|
-
if (!res.ok) {
|
|
104
|
-
throw new Error(
|
|
105
|
-
`GitHub returned ${res.status} for ${repo}@${commit.slice(0, 8)}` +
|
|
106
|
-
(res.status === 404 && !token
|
|
107
|
-
? " — a private repository needs GITHUB_TOKEN set on the server"
|
|
108
|
-
: ""),
|
|
109
|
-
);
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
const buf = Buffer.from(await res.arrayBuffer());
|
|
113
|
-
if (buf.length > MAX_TARBALL) {
|
|
114
|
-
throw new Error(`${repo} is ${Math.round(buf.length / 1e6)}MB — too large to deploy`);
|
|
115
|
-
}
|
|
116
|
-
return buf;
|
|
117
|
-
}
|
package/src/gitrepo.ts
DELETED
|
@@ -1,439 +0,0 @@
|
|
|
1
|
-
// A git repository per workspace — the platform as the remote.
|
|
2
|
-
//
|
|
3
|
-
// git remote add foldrun https://app.foldrun.io/git/<tenant>/<workspace>.git
|
|
4
|
-
// git push foldrun main → deploys, commit on record
|
|
5
|
-
// git clone …/<workspace>.git → the workspace, with its whole history
|
|
6
|
-
//
|
|
7
|
-
// One bare repository per workspace on the data volume. Every change the
|
|
8
|
-
// platform makes — a dashboard save, a deploy, a deletion — is a commit into
|
|
9
|
-
// it, made with git's own plumbing against a scratch index so no working copy
|
|
10
|
-
// is ever kept. Every change a person makes is a push into it. So there is
|
|
11
|
-
// one history, it is real git, and anyone can take it away with `git clone`.
|
|
12
|
-
//
|
|
13
|
-
// This deliberately stops at being a remote. Pull requests, reviews and
|
|
14
|
-
// issues are a forge, which is a product; GitHub and GitLab remain a fine
|
|
15
|
-
// mirror for anyone who wants that on top.
|
|
16
|
-
//
|
|
17
|
-
// history.ts delegates here when a repository exists, so the History page,
|
|
18
|
-
// the per-file panel and Observe's eval attribution all read git without
|
|
19
|
-
// knowing it. The journal it kept before stays as the fallback for an install
|
|
20
|
-
// without a git binary.
|
|
21
|
-
|
|
22
|
-
import fs from "node:fs";
|
|
23
|
-
import os from "node:os";
|
|
24
|
-
import path from "node:path";
|
|
25
|
-
import { spawnSync } from "node:child_process";
|
|
26
|
-
import { dataRoot } from "./paths.ts";
|
|
27
|
-
import type { DiffOp, Revision, RevisionFile, RevisionSummary } from "./history.ts";
|
|
28
|
-
|
|
29
|
-
const SAFE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,120}$/;
|
|
30
|
-
const safe = (v: string, what: string) => {
|
|
31
|
-
if (!SAFE.test(v)) throw new Error(`illegal ${what}: ${v}`);
|
|
32
|
-
return v;
|
|
33
|
-
};
|
|
34
|
-
|
|
35
|
-
/** Where a workspace's bare repository lives. `@library` is the account shelf. */
|
|
36
|
-
export function repoDir(tenant: string, scope: string): string {
|
|
37
|
-
safe(tenant, "tenant");
|
|
38
|
-
const name = scope === "@library" ? "_library" : safe(scope, "workspace");
|
|
39
|
-
return path.join(dataRoot(), tenant, ".git", `${name}.git`);
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
let gitChecked: boolean | null = null;
|
|
43
|
-
/** Is there a git binary? Checked once; an install without one keeps the journal. */
|
|
44
|
-
export function gitAvailable(): boolean {
|
|
45
|
-
if (gitChecked === null) {
|
|
46
|
-
gitChecked = spawnSync("git", ["--version"], { stdio: "ignore" }).status === 0;
|
|
47
|
-
}
|
|
48
|
-
return gitChecked;
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
function git(dir: string, args: string[], opts: { input?: string | Buffer; env?: Record<string, string> } = {}) {
|
|
52
|
-
const r = spawnSync("git", ["--git-dir", dir, ...args], {
|
|
53
|
-
input: opts.input,
|
|
54
|
-
env: { ...process.env, GIT_TERMINAL_PROMPT: "0", ...opts.env },
|
|
55
|
-
maxBuffer: 64 * 1024 * 1024,
|
|
56
|
-
});
|
|
57
|
-
if (r.status !== 0) {
|
|
58
|
-
throw new Error(`git ${args[0]} failed: ${(r.stderr?.toString() ?? "").trim().slice(0, 500)}`);
|
|
59
|
-
}
|
|
60
|
-
return r.stdout.toString();
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
export function repoExists(tenant: string, scope: string): boolean {
|
|
64
|
-
return fs.existsSync(path.join(repoDir(tenant, scope), "HEAD"));
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
/** Create the repository if it is not there. Idempotent. */
|
|
68
|
-
export function ensureRepo(tenant: string, scope: string): string {
|
|
69
|
-
const dir = repoDir(tenant, scope);
|
|
70
|
-
if (!fs.existsSync(path.join(dir, "HEAD"))) {
|
|
71
|
-
fs.mkdirSync(path.dirname(dir), { recursive: true });
|
|
72
|
-
spawnSync("git", ["init", "--bare", "--quiet", "--initial-branch=main", dir], { stdio: "ignore" });
|
|
73
|
-
// Pushes over HTTP need this; it is the one config a bare remote wants.
|
|
74
|
-
git(dir, ["config", "http.receivepack", "true"]);
|
|
75
|
-
}
|
|
76
|
-
installHooks(dir);
|
|
77
|
-
return dir;
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
export function headSha(tenant: string, scope: string, ref = "refs/heads/main"): string | null {
|
|
81
|
-
if (!repoExists(tenant, scope)) return null;
|
|
82
|
-
const r = spawnSync("git", ["--git-dir", repoDir(tenant, scope), "rev-parse", "--verify", "--quiet", ref]);
|
|
83
|
-
return r.status === 0 ? r.stdout.toString().trim() : null;
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
/**
|
|
87
|
-
* Apply a change set as one commit on main, using plumbing against a scratch
|
|
88
|
-
* index seeded from HEAD. No working copy, no checkout: each changed path is
|
|
89
|
-
* hashed straight from its content into the object store.
|
|
90
|
-
*
|
|
91
|
-
* Returns the new commit, or null when the tree did not change.
|
|
92
|
-
*/
|
|
93
|
-
export function commitChanges(
|
|
94
|
-
tenant: string,
|
|
95
|
-
scope: string,
|
|
96
|
-
files: RevisionFile[],
|
|
97
|
-
meta: { by?: string; message?: string; at?: string } = {},
|
|
98
|
-
): string | null {
|
|
99
|
-
const dir = ensureRepo(tenant, scope);
|
|
100
|
-
const parent = headSha(tenant, scope);
|
|
101
|
-
const scratch = fs.mkdtempSync(path.join(os.tmpdir(), "foldrun-index-"));
|
|
102
|
-
const index = path.join(scratch, "index");
|
|
103
|
-
// update-index insists on a work tree even when every entry arrives as
|
|
104
|
-
// --cacheinfo and nothing on disk is read. The scratch directory is one.
|
|
105
|
-
const env = { GIT_INDEX_FILE: index, GIT_WORK_TREE: scratch };
|
|
106
|
-
try {
|
|
107
|
-
if (parent) git(dir, ["read-tree", parent], { env });
|
|
108
|
-
for (const f of files) {
|
|
109
|
-
if (f.after === null) {
|
|
110
|
-
if (parent) git(dir, ["update-index", "--force-remove", "--", f.path], { env });
|
|
111
|
-
continue;
|
|
112
|
-
}
|
|
113
|
-
const blob = git(dir, ["hash-object", "-w", "--stdin"], { input: f.after }).trim();
|
|
114
|
-
// Executable where code lives, matching what the writers chmod on disk.
|
|
115
|
-
const mode = /\.(py|sh|mjs|js|rb)$/.test(f.path) && /(^|\/)(scripts|tools)\//.test(f.path) ? "100755" : "100644";
|
|
116
|
-
git(dir, ["update-index", "--add", "--cacheinfo", `${mode},${blob},${f.path}`], { env });
|
|
117
|
-
}
|
|
118
|
-
const tree = git(dir, ["write-tree"], { env }).trim();
|
|
119
|
-
if (parent && git(dir, ["rev-parse", `${parent}^{tree}`]).trim() === tree) return null;
|
|
120
|
-
|
|
121
|
-
const who = meta.by ?? "system";
|
|
122
|
-
const email = who.includes("@") ? who : `${who}@foldrun`;
|
|
123
|
-
const name = who.includes("@") ? who.split("@")[0] : who;
|
|
124
|
-
const date = meta.at ? new Date(meta.at).toISOString() : new Date().toISOString();
|
|
125
|
-
const commit = git(
|
|
126
|
-
dir,
|
|
127
|
-
["commit-tree", tree, ...(parent ? ["-p", parent] : []), "-m", meta.message ?? describe(files)],
|
|
128
|
-
{
|
|
129
|
-
env: {
|
|
130
|
-
GIT_AUTHOR_NAME: name, GIT_AUTHOR_EMAIL: email, GIT_AUTHOR_DATE: date,
|
|
131
|
-
GIT_COMMITTER_NAME: "foldrun", GIT_COMMITTER_EMAIL: "platform@foldrun", GIT_COMMITTER_DATE: date,
|
|
132
|
-
},
|
|
133
|
-
},
|
|
134
|
-
).trim();
|
|
135
|
-
git(dir, ["update-ref", "refs/heads/main", commit, ...(parent ? [parent] : [])]);
|
|
136
|
-
return commit;
|
|
137
|
-
} finally {
|
|
138
|
-
fs.rmSync(scratch, { recursive: true, force: true });
|
|
139
|
-
}
|
|
140
|
-
}
|
|
141
|
-
|
|
142
|
-
function describe(files: RevisionFile[]): string {
|
|
143
|
-
if (files.length === 1) {
|
|
144
|
-
const f = files[0];
|
|
145
|
-
return `${f.before === null ? "created" : f.after === null ? "deleted" : "edited"} ${f.path}`;
|
|
146
|
-
}
|
|
147
|
-
return `changed ${files.length} files`;
|
|
148
|
-
}
|
|
149
|
-
|
|
150
|
-
// ------------------------------------------------------------------ reading
|
|
151
|
-
|
|
152
|
-
const SEP = "";
|
|
153
|
-
|
|
154
|
-
/** Commits on main, newest first, as the same summary the journal gave. */
|
|
155
|
-
export function listCommits(
|
|
156
|
-
tenant: string,
|
|
157
|
-
scope: string,
|
|
158
|
-
opts: { forPath?: string; limit?: number; ref?: string } = {},
|
|
159
|
-
): RevisionSummary[] {
|
|
160
|
-
if (!headSha(tenant, scope, opts.ref)) return [];
|
|
161
|
-
const dir = repoDir(tenant, scope);
|
|
162
|
-
const out = git(dir, [
|
|
163
|
-
"log",
|
|
164
|
-
opts.ref ?? "refs/heads/main",
|
|
165
|
-
`--max-count=${opts.limit ?? 200}`,
|
|
166
|
-
`--format=%H${SEP}%aI${SEP}%ae${SEP}%s`,
|
|
167
|
-
"--name-only",
|
|
168
|
-
...(opts.forPath ? ["--", opts.forPath] : []),
|
|
169
|
-
]);
|
|
170
|
-
const rows: RevisionSummary[] = [];
|
|
171
|
-
let current: RevisionSummary | null = null;
|
|
172
|
-
for (const line of out.split("\n")) {
|
|
173
|
-
if (line.includes(SEP)) {
|
|
174
|
-
const [id, at, by, message] = line.split(SEP);
|
|
175
|
-
current = { id, at, by: by.endsWith("@foldrun") ? by.replace(/@foldrun$/, "") : by, message, commit: id, paths: [] };
|
|
176
|
-
rows.push(current);
|
|
177
|
-
} else if (line.trim() && current) {
|
|
178
|
-
current.paths.push(line.trim());
|
|
179
|
-
}
|
|
180
|
-
}
|
|
181
|
-
return rows;
|
|
182
|
-
}
|
|
183
|
-
|
|
184
|
-
/** One commit in full: every changed file with its before and after. */
|
|
185
|
-
export function readCommit(tenant: string, scope: string, sha: string, forPath?: string): Revision | null {
|
|
186
|
-
if (!repoExists(tenant, scope) || !/^[0-9a-f]{4,40}$/.test(sha)) return null;
|
|
187
|
-
const dir = repoDir(tenant, scope);
|
|
188
|
-
const head = spawnSync("git", ["--git-dir", dir, "rev-parse", "--verify", "--quiet", `${sha}^{commit}`]);
|
|
189
|
-
if (head.status !== 0) return null;
|
|
190
|
-
const full = head.stdout.toString().trim();
|
|
191
|
-
const [at, by, ...msg] = git(dir, ["log", "-1", `--format=%aI${SEP}%ae${SEP}%s`, full]).trim().split(SEP);
|
|
192
|
-
const hasParent = spawnSync("git", ["--git-dir", dir, "rev-parse", "--verify", "--quiet", `${full}^`]).status === 0;
|
|
193
|
-
const names = git(dir, [
|
|
194
|
-
"diff-tree", "--no-commit-id", "--name-status", "-r", "--root", full,
|
|
195
|
-
...(forPath ? ["--", forPath] : []),
|
|
196
|
-
]);
|
|
197
|
-
const files: RevisionFile[] = [];
|
|
198
|
-
for (const line of names.split("\n").filter(Boolean)) {
|
|
199
|
-
const [status, p] = line.split("\t");
|
|
200
|
-
const before = status === "A" || !hasParent ? null : blob(dir, `${full}^`, p);
|
|
201
|
-
const after = status === "D" ? null : blob(dir, full, p);
|
|
202
|
-
files.push({ path: p, before, after });
|
|
203
|
-
}
|
|
204
|
-
return {
|
|
205
|
-
id: full,
|
|
206
|
-
at,
|
|
207
|
-
by: by.endsWith("@foldrun") ? by.replace(/@foldrun$/, "") : by,
|
|
208
|
-
message: msg.join(SEP),
|
|
209
|
-
commit: full,
|
|
210
|
-
files,
|
|
211
|
-
};
|
|
212
|
-
}
|
|
213
|
-
|
|
214
|
-
function blob(dir: string, rev: string, p: string): string | null {
|
|
215
|
-
const r = spawnSync("git", ["--git-dir", dir, "show", `${rev}:${p}`], { maxBuffer: 64 * 1024 * 1024 });
|
|
216
|
-
return r.status === 0 ? r.stdout.toString() : null;
|
|
217
|
-
}
|
|
218
|
-
|
|
219
|
-
export interface RefInfo {
|
|
220
|
-
name: string;
|
|
221
|
-
sha: string;
|
|
222
|
-
/** Commit subject and time, for a branch list that says what each is. */
|
|
223
|
-
subject: string;
|
|
224
|
-
at: string;
|
|
225
|
-
}
|
|
226
|
-
|
|
227
|
-
export function listRefs(tenant: string, scope: string, kind: "heads" | "tags"): RefInfo[] {
|
|
228
|
-
if (!repoExists(tenant, scope)) return [];
|
|
229
|
-
const out = git(repoDir(tenant, scope), [
|
|
230
|
-
"for-each-ref", `refs/${kind}`, "--sort=-committerdate",
|
|
231
|
-
`--format=%(refname:short)${SEP}%(objectname)${SEP}%(subject)${SEP}%(committerdate:iso-strict)`,
|
|
232
|
-
]);
|
|
233
|
-
return out.split("\n").filter(Boolean).map((l) => {
|
|
234
|
-
const [name, sha, subject, at] = l.split(SEP);
|
|
235
|
-
return { name, sha, subject, at };
|
|
236
|
-
});
|
|
237
|
-
}
|
|
238
|
-
|
|
239
|
-
/**
|
|
240
|
-
* The commit a restore means by `to`, on main's history:
|
|
241
|
-
*
|
|
242
|
-
* a sha or a prefix of one that commit
|
|
243
|
-
* a branch or tag name where it points
|
|
244
|
-
* an ISO date or time the last commit on main at or before it
|
|
245
|
-
* "3d", "12h", "90m", "2w" the same, that long ago
|
|
246
|
-
*
|
|
247
|
-
* Null when nothing matches — before the first commit, or a name that is
|
|
248
|
-
* not there. Throws on a `to` that cannot be any of these, so a typo is a
|
|
249
|
-
* 400 rather than a silent "nothing found".
|
|
250
|
-
*/
|
|
251
|
-
export function resolveRestorePoint(
|
|
252
|
-
tenant: string,
|
|
253
|
-
scope: string,
|
|
254
|
-
to: string,
|
|
255
|
-
now = Date.now(),
|
|
256
|
-
): { sha: string; at: string; by: string; message: string } | null {
|
|
257
|
-
const t = to.trim();
|
|
258
|
-
if (!t || t.startsWith("-")) throw Object.assign(new Error(`not a revision or a time: ${to}`), { status: 400 });
|
|
259
|
-
if (!headSha(tenant, scope)) return null;
|
|
260
|
-
const dir = repoDir(tenant, scope);
|
|
261
|
-
let sha: string | null = null;
|
|
262
|
-
const rel = /^(\d+)\s*(m|h|d|w)$/.exec(t);
|
|
263
|
-
const moment = rel
|
|
264
|
-
? now - Number(rel[1]) * { m: 60_000, h: 3_600_000, d: 86_400_000, w: 604_800_000 }[rel[2] as "m" | "h" | "d" | "w"]
|
|
265
|
-
: /^\d{4}-\d{2}-\d{2}/.test(t)
|
|
266
|
-
? Date.parse(t)
|
|
267
|
-
: null;
|
|
268
|
-
if (moment !== null) {
|
|
269
|
-
if (Number.isNaN(moment)) throw Object.assign(new Error(`not a date: ${to}`), { status: 400 });
|
|
270
|
-
sha = git(dir, ["log", "-1", "--format=%H", `--until=@${Math.floor(moment / 1000)}`, "refs/heads/main"]).trim() || null;
|
|
271
|
-
} else {
|
|
272
|
-
if (!/^[A-Za-z0-9][A-Za-z0-9._/-]{0,120}$/.test(t)) throw Object.assign(new Error(`not a revision or a time: ${to}`), { status: 400 });
|
|
273
|
-
for (const ref of [/^[0-9a-f]{4,40}$/.test(t) ? `${t}^{commit}` : null, `refs/heads/${t}^{commit}`, `refs/tags/${t}^{commit}`]) {
|
|
274
|
-
if (!ref) continue;
|
|
275
|
-
const r = spawnSync("git", ["--git-dir", dir, "rev-parse", "--verify", "--quiet", ref]);
|
|
276
|
-
if (r.status === 0) {
|
|
277
|
-
sha = r.stdout.toString().trim();
|
|
278
|
-
break;
|
|
279
|
-
}
|
|
280
|
-
}
|
|
281
|
-
}
|
|
282
|
-
if (!sha) return null;
|
|
283
|
-
const [at, by, ...msg] = git(dir, ["log", "-1", `--format=%cI${SEP}%ae${SEP}%s`, sha]).trim().split(SEP);
|
|
284
|
-
return { sha, at, by: by.endsWith("@foldrun") ? by.replace(/@foldrun$/, "") : by, message: msg.join(SEP) };
|
|
285
|
-
}
|
|
286
|
-
|
|
287
|
-
/** The files at a commit, for a browser or a deploy. */
|
|
288
|
-
export function listTree(tenant: string, scope: string, ref = "refs/heads/main"): { path: string; mode: string }[] {
|
|
289
|
-
if (!headSha(tenant, scope, ref)) return [];
|
|
290
|
-
return git(repoDir(tenant, scope), ["ls-tree", "-r", "--format=%(objectmode) %(path)", ref])
|
|
291
|
-
.split("\n")
|
|
292
|
-
.filter(Boolean)
|
|
293
|
-
.map((l) => {
|
|
294
|
-
const [mode, ...rest] = l.split(" ");
|
|
295
|
-
return { mode, path: rest.join(" ") };
|
|
296
|
-
});
|
|
297
|
-
}
|
|
298
|
-
|
|
299
|
-
export function readBlob(tenant: string, scope: string, ref: string, p: string): string | null {
|
|
300
|
-
if (!repoExists(tenant, scope)) return null;
|
|
301
|
-
return blob(repoDir(tenant, scope), ref, p);
|
|
302
|
-
}
|
|
303
|
-
|
|
304
|
-
/** The whole tree at a ref as deploy files — what a push turns into a workspace. */
|
|
305
|
-
export function filesAt(tenant: string, scope: string, ref: string): { path: string; content: string }[] {
|
|
306
|
-
const dir = repoDir(tenant, scope);
|
|
307
|
-
return listTree(tenant, scope, ref).flatMap((t) => {
|
|
308
|
-
const content = blob(dir, ref, t.path);
|
|
309
|
-
return content === null ? [] : [{ path: t.path, content }];
|
|
310
|
-
});
|
|
311
|
-
}
|
|
312
|
-
|
|
313
|
-
/**
|
|
314
|
-
* What a ref would change on main: every file that differs, with both sides.
|
|
315
|
-
* The branch's own commits are `listCommits({ ref })`; this is the net
|
|
316
|
-
* effect, which is what a reviewer reads.
|
|
317
|
-
*/
|
|
318
|
-
export function diffRefs(tenant: string, scope: string, base: string, head: string): RevisionFile[] {
|
|
319
|
-
if (!headSha(tenant, scope, base) || !headSha(tenant, scope, head)) return [];
|
|
320
|
-
const dir = repoDir(tenant, scope);
|
|
321
|
-
const out = git(dir, ["diff", "--name-status", `${base}...${head}`]);
|
|
322
|
-
return out.split("\n").filter(Boolean).map((line) => {
|
|
323
|
-
const [status, p] = line.split("\t");
|
|
324
|
-
return {
|
|
325
|
-
path: p,
|
|
326
|
-
before: status === "A" ? null : blob(dir, base, p),
|
|
327
|
-
after: status === "D" ? null : blob(dir, head, p),
|
|
328
|
-
};
|
|
329
|
-
});
|
|
330
|
-
}
|
|
331
|
-
|
|
332
|
-
/**
|
|
333
|
-
* Merge a branch into main and return the new main. A fast-forward when it
|
|
334
|
-
* is one; otherwise a merge commit whose tree is the BRANCH's — the branch
|
|
335
|
-
* is what the person reviewed and chose, and a three-way textual merge of
|
|
336
|
-
* markdown with no one to resolve conflicts would deploy text nobody wrote.
|
|
337
|
-
* Both parents are recorded, so history shows the merge for what it was.
|
|
338
|
-
*/
|
|
339
|
-
export function mergeBranch(
|
|
340
|
-
tenant: string,
|
|
341
|
-
scope: string,
|
|
342
|
-
branch: string,
|
|
343
|
-
meta: { by?: string } = {},
|
|
344
|
-
): { sha: string; fastForward: boolean } {
|
|
345
|
-
const dir = repoDir(tenant, scope);
|
|
346
|
-
const main = headSha(tenant, scope);
|
|
347
|
-
const head = headSha(tenant, scope, `refs/heads/${safe(branch, "branch")}`);
|
|
348
|
-
if (!head) throw new Error(`no branch "${branch}"`);
|
|
349
|
-
if (!main) {
|
|
350
|
-
git(dir, ["update-ref", "refs/heads/main", head]);
|
|
351
|
-
return { sha: head, fastForward: true };
|
|
352
|
-
}
|
|
353
|
-
const isAncestor = spawnSync("git", ["--git-dir", dir, "merge-base", "--is-ancestor", main, head]).status === 0;
|
|
354
|
-
if (isAncestor) {
|
|
355
|
-
git(dir, ["update-ref", "refs/heads/main", head, main]);
|
|
356
|
-
return { sha: head, fastForward: true };
|
|
357
|
-
}
|
|
358
|
-
const tree = git(dir, ["rev-parse", `${head}^{tree}`]).trim();
|
|
359
|
-
const who = meta.by ?? "foldrun";
|
|
360
|
-
const email = who.includes("@") ? who : `${who}@foldrun`;
|
|
361
|
-
const name = who.includes("@") ? who.split("@")[0] : who;
|
|
362
|
-
const date = new Date().toISOString();
|
|
363
|
-
const sha = git(dir, ["commit-tree", tree, "-p", main, "-p", head, "-m", `merge ${branch} into main`], {
|
|
364
|
-
env: {
|
|
365
|
-
GIT_AUTHOR_NAME: name, GIT_AUTHOR_EMAIL: email, GIT_AUTHOR_DATE: date,
|
|
366
|
-
GIT_COMMITTER_NAME: "foldrun", GIT_COMMITTER_EMAIL: "platform@foldrun", GIT_COMMITTER_DATE: date,
|
|
367
|
-
},
|
|
368
|
-
}).trim();
|
|
369
|
-
git(dir, ["update-ref", "refs/heads/main", sha, main]);
|
|
370
|
-
return { sha, fastForward: false };
|
|
371
|
-
}
|
|
372
|
-
|
|
373
|
-
export function deleteBranch(tenant: string, scope: string, branch: string) {
|
|
374
|
-
if (branch === "main") throw new Error("main is the default branch");
|
|
375
|
-
git(repoDir(tenant, scope), ["update-ref", "-d", `refs/heads/${safe(branch, "branch")}`]);
|
|
376
|
-
}
|
|
377
|
-
|
|
378
|
-
/** Push main to another remote — a GitHub or GitLab mirror. Blocking; callers
|
|
379
|
-
* run it off the request path. The token never touches disk: it is in the
|
|
380
|
-
* URL for this one process and nowhere else. */
|
|
381
|
-
export function pushMirror(tenant: string, scope: string, url: string): { ok: boolean; detail: string } {
|
|
382
|
-
if (!headSha(tenant, scope)) return { ok: false, detail: "nothing to push yet" };
|
|
383
|
-
const r = spawnSync("git", ["--git-dir", repoDir(tenant, scope), "push", "--quiet", url, "+refs/heads/main:refs/heads/main"], {
|
|
384
|
-
env: { ...process.env, GIT_TERMINAL_PROMPT: "0" },
|
|
385
|
-
timeout: 60_000,
|
|
386
|
-
});
|
|
387
|
-
const detail = (r.stderr?.toString() ?? "").trim()
|
|
388
|
-
// Never echo the URL back: it carries the token.
|
|
389
|
-
.replaceAll(url, "<mirror>")
|
|
390
|
-
.slice(0, 500);
|
|
391
|
-
return { ok: r.status === 0, detail: r.status === 0 ? "pushed" : detail || "push failed" };
|
|
392
|
-
}
|
|
393
|
-
|
|
394
|
-
/**
|
|
395
|
-
* Where the compiled pre-receive check is, found on disk rather than through
|
|
396
|
-
* require.resolve — a bundler rewrites require.resolve("pkg") into a module
|
|
397
|
-
* id, and the hook was once told to run a script called "51113". Searched
|
|
398
|
-
* from the working directory upward: the monorepo layout, then an installed
|
|
399
|
-
* package. Empty when there is no compiled check (a dev server on the source
|
|
400
|
-
* tree), in which case a push is checked at deploy instead of refused — the
|
|
401
|
-
* same rules, one step later.
|
|
402
|
-
*/
|
|
403
|
-
export function hookScriptPath(): string {
|
|
404
|
-
let dir = process.cwd();
|
|
405
|
-
for (let i = 0; i < 6; i++) {
|
|
406
|
-
for (const rel of ["packages/core/dist/src/git-hook.js", "node_modules/@foldrun/core/dist/src/git-hook.js"]) {
|
|
407
|
-
const candidate = path.join(dir, rel);
|
|
408
|
-
if (fs.existsSync(candidate)) return candidate;
|
|
409
|
-
}
|
|
410
|
-
const parent = path.dirname(dir);
|
|
411
|
-
if (parent === dir) break;
|
|
412
|
-
dir = parent;
|
|
413
|
-
}
|
|
414
|
-
return "";
|
|
415
|
-
}
|
|
416
|
-
|
|
417
|
-
/**
|
|
418
|
-
* The pre-receive hook: a push that fails the deploy checks is refused with
|
|
419
|
-
* the errors in the pusher's terminal, instead of accepted and then not
|
|
420
|
-
* deployed. The hook shells out to node with the compiled check, so it runs
|
|
421
|
-
* the same rules as a dashboard deploy; which node and which script are
|
|
422
|
-
* passed in the environment by the HTTP route, because a hook inherits git's
|
|
423
|
-
* environment and nothing else.
|
|
424
|
-
*/
|
|
425
|
-
export function installHooks(dir: string) {
|
|
426
|
-
const hooks = path.join(dir, "hooks");
|
|
427
|
-
fs.mkdirSync(hooks, { recursive: true });
|
|
428
|
-
const hook = path.join(hooks, "pre-receive");
|
|
429
|
-
const script = `#!/bin/sh
|
|
430
|
-
# Installed by foldrun. Refuses a push to main whose tree fails the checks.
|
|
431
|
-
[ -n "$FOLDRUN_HOOK_NODE" ] && [ -n "$FOLDRUN_HOOK_SCRIPT" ] || exit 0
|
|
432
|
-
exec "$FOLDRUN_HOOK_NODE" "$FOLDRUN_HOOK_SCRIPT"
|
|
433
|
-
`;
|
|
434
|
-
if (!fs.existsSync(hook) || fs.readFileSync(hook, "utf8") !== script) {
|
|
435
|
-
fs.writeFileSync(hook, script, { mode: 0o755 });
|
|
436
|
-
}
|
|
437
|
-
}
|
|
438
|
-
|
|
439
|
-
export { type DiffOp };
|