@foldrun/core 0.2.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.
Files changed (166) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +239 -0
  3. package/dist/index.d.ts +32 -0
  4. package/dist/index.js +43 -0
  5. package/dist/src/agent-tools.d.ts +31 -0
  6. package/dist/src/agent-tools.js +99 -0
  7. package/dist/src/agents-md.d.ts +9 -0
  8. package/dist/src/agents-md.js +83 -0
  9. package/dist/src/api-tools.d.ts +38 -0
  10. package/dist/src/api-tools.js +313 -0
  11. package/dist/src/approvals.d.ts +39 -0
  12. package/dist/src/approvals.js +103 -0
  13. package/dist/src/arrange.d.ts +8 -0
  14. package/dist/src/arrange.js +40 -0
  15. package/dist/src/catalog.d.ts +108 -0
  16. package/dist/src/catalog.js +255 -0
  17. package/dist/src/completions.d.ts +61 -0
  18. package/dist/src/completions.js +578 -0
  19. package/dist/src/confine.d.ts +31 -0
  20. package/dist/src/confine.js +298 -0
  21. package/dist/src/container.d.ts +30 -0
  22. package/dist/src/container.js +172 -0
  23. package/dist/src/context-tools.d.ts +81 -0
  24. package/dist/src/context-tools.js +347 -0
  25. package/dist/src/deploy.d.ts +100 -0
  26. package/dist/src/deploy.js +377 -0
  27. package/dist/src/egress.d.ts +75 -0
  28. package/dist/src/egress.js +133 -0
  29. package/dist/src/evals.d.ts +150 -0
  30. package/dist/src/evals.js +651 -0
  31. package/dist/src/flow-lint.d.ts +16 -0
  32. package/dist/src/flow-lint.js +177 -0
  33. package/dist/src/git-hook.d.ts +1 -0
  34. package/dist/src/git-hook.js +70 -0
  35. package/dist/src/git.d.ts +37 -0
  36. package/dist/src/git.js +101 -0
  37. package/dist/src/gitrepo.d.ts +95 -0
  38. package/dist/src/gitrepo.js +355 -0
  39. package/dist/src/history.d.ts +82 -0
  40. package/dist/src/history.js +259 -0
  41. package/dist/src/kinds.d.ts +95 -0
  42. package/dist/src/kinds.js +421 -0
  43. package/dist/src/library.d.ts +94 -0
  44. package/dist/src/library.js +341 -0
  45. package/dist/src/linkable.d.ts +20 -0
  46. package/dist/src/linkable.js +113 -0
  47. package/dist/src/model-probe.d.ts +21 -0
  48. package/dist/src/model-probe.js +81 -0
  49. package/dist/src/notify.d.ts +61 -0
  50. package/dist/src/notify.js +252 -0
  51. package/dist/src/oauth-presets.d.ts +13 -0
  52. package/dist/src/oauth-presets.js +39 -0
  53. package/dist/src/okf.d.ts +220 -0
  54. package/dist/src/okf.js +656 -0
  55. package/dist/src/openapi.d.ts +66 -0
  56. package/dist/src/openapi.js +342 -0
  57. package/dist/src/paths.d.ts +33 -0
  58. package/dist/src/paths.js +78 -0
  59. package/dist/src/platform.d.ts +32 -0
  60. package/dist/src/platform.js +48 -0
  61. package/dist/src/providers.d.ts +47 -0
  62. package/dist/src/providers.js +114 -0
  63. package/dist/src/refs.d.ts +20 -0
  64. package/dist/src/refs.js +78 -0
  65. package/dist/src/run-container.d.ts +227 -0
  66. package/dist/src/run-container.js +760 -0
  67. package/dist/src/runner.d.ts +241 -0
  68. package/dist/src/runner.js +3215 -0
  69. package/dist/src/runtime.d.ts +41 -0
  70. package/dist/src/runtime.js +357 -0
  71. package/dist/src/script-tools.d.ts +62 -0
  72. package/dist/src/script-tools.js +296 -0
  73. package/dist/src/secret-files.d.ts +12 -0
  74. package/dist/src/secret-files.js +92 -0
  75. package/dist/src/secrets.d.ts +147 -0
  76. package/dist/src/secrets.js +626 -0
  77. package/dist/src/starter.d.ts +21 -0
  78. package/dist/src/starter.js +399 -0
  79. package/dist/src/step-exec.d.ts +136 -0
  80. package/dist/src/step-exec.js +391 -0
  81. package/dist/src/storage.d.ts +123 -0
  82. package/dist/src/storage.js +640 -0
  83. package/dist/src/store.d.ts +1052 -0
  84. package/dist/src/store.js +2378 -0
  85. package/dist/src/tar.d.ts +30 -0
  86. package/dist/src/tar.js +111 -0
  87. package/dist/src/tool-names.d.ts +39 -0
  88. package/dist/src/tool-names.js +79 -0
  89. package/dist/src/tool-programs.d.ts +18 -0
  90. package/dist/src/tool-programs.js +52 -0
  91. package/dist/src/tool-test.d.ts +19 -0
  92. package/dist/src/tool-test.js +335 -0
  93. package/dist/src/translator.d.ts +159 -0
  94. package/dist/src/translator.js +986 -0
  95. package/dist/src/triggers.d.ts +60 -0
  96. package/dist/src/triggers.js +270 -0
  97. package/dist/src/webhook.d.ts +47 -0
  98. package/dist/src/webhook.js +148 -0
  99. package/index.ts +44 -0
  100. package/package.json +64 -0
  101. package/src/agent-tools.ts +138 -0
  102. package/src/agents-md.ts +81 -0
  103. package/src/api-tools.ts +389 -0
  104. package/src/approvals.ts +129 -0
  105. package/src/arrange.ts +42 -0
  106. package/src/catalog.ts +321 -0
  107. package/src/completions.ts +652 -0
  108. package/src/confine.ts +350 -0
  109. package/src/container.ts +220 -0
  110. package/src/context-tools.ts +435 -0
  111. package/src/deploy.ts +458 -0
  112. package/src/egress.ts +176 -0
  113. package/src/evals.ts +812 -0
  114. package/src/flow-lint.ts +215 -0
  115. package/src/git-hook.ts +73 -0
  116. package/src/git.ts +117 -0
  117. package/src/gitrepo.ts +391 -0
  118. package/src/history.ts +329 -0
  119. package/src/kinds.ts +507 -0
  120. package/src/library.ts +419 -0
  121. package/src/linkable.ts +123 -0
  122. package/src/model-probe.ts +112 -0
  123. package/src/notify.ts +281 -0
  124. package/src/oauth-presets.ts +54 -0
  125. package/src/okf.ts +768 -0
  126. package/src/openapi.ts +383 -0
  127. package/src/paths.ts +79 -0
  128. package/src/platform.ts +87 -0
  129. package/src/providers.ts +147 -0
  130. package/src/refs.ts +83 -0
  131. package/src/run-container.ts +883 -0
  132. package/src/runner.ts +3446 -0
  133. package/src/runtime.ts +404 -0
  134. package/src/script-tools.ts +383 -0
  135. package/src/secret-files.ts +101 -0
  136. package/src/secrets.ts +799 -0
  137. package/src/starter.ts +415 -0
  138. package/src/step-exec.ts +498 -0
  139. package/src/storage.ts +771 -0
  140. package/src/store.ts +2985 -0
  141. package/src/tar.ts +124 -0
  142. package/src/tool-names.ts +91 -0
  143. package/src/tool-programs.ts +65 -0
  144. package/src/tool-test.ts +401 -0
  145. package/src/translator.ts +1029 -0
  146. package/src/triggers.ts +287 -0
  147. package/src/webhook.ts +183 -0
  148. package/templates/blog-desk/AGENTS.md +13 -0
  149. package/templates/blog-desk/agents/researcher/agent.md +11 -0
  150. package/templates/blog-desk/agents/writer/agent.md +8 -0
  151. package/templates/blog-desk/evals/writer-quality.md +10 -0
  152. package/templates/blog-desk/flows/publish.md +10 -0
  153. package/templates/blog-desk/knowledge/house-style.md +9 -0
  154. package/templates/blog-desk/knowledge/index.md +9 -0
  155. package/templates/hello/AGENTS.md +18 -0
  156. package/templates/hello/agents/curator/agent.md +32 -0
  157. package/templates/hello/agents/notetaker/agent.md +24 -0
  158. package/templates/hello/evals/house-style.md +16 -0
  159. package/templates/hello/flows/memory-review.md +7 -0
  160. package/templates/hello/flows/note.md +7 -0
  161. package/templates/hello/knowledge/house-style.md +15 -0
  162. package/templates/hello/knowledge/index.md +9 -0
  163. package/templates/hello/skills/plain-english/SKILL.md +11 -0
  164. package/templates/hello/state/publishing.md +7 -0
  165. package/templates/hello/tools/wordcount/run.py +13 -0
  166. package/templates/hello/tools/wordcount/tool.md +22 -0
@@ -0,0 +1,355 @@
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
+ import fs from "node:fs";
22
+ import os from "node:os";
23
+ import path from "node:path";
24
+ import { spawnSync } from "node:child_process";
25
+ import { dataRoot } from "./paths.js";
26
+ const SAFE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,120}$/;
27
+ const safe = (v, what) => {
28
+ if (!SAFE.test(v))
29
+ throw new Error(`illegal ${what}: ${v}`);
30
+ return v;
31
+ };
32
+ /** Where a workspace's bare repository lives. `@library` is the account shelf. */
33
+ export function repoDir(tenant, scope) {
34
+ safe(tenant, "tenant");
35
+ const name = scope === "@library" ? "_library" : safe(scope, "workspace");
36
+ return path.join(dataRoot(), tenant, ".git", `${name}.git`);
37
+ }
38
+ let gitChecked = null;
39
+ /** Is there a git binary? Checked once; an install without one keeps the journal. */
40
+ export function gitAvailable() {
41
+ if (gitChecked === null) {
42
+ gitChecked = spawnSync("git", ["--version"], { stdio: "ignore" }).status === 0;
43
+ }
44
+ return gitChecked;
45
+ }
46
+ function git(dir, args, opts = {}) {
47
+ const r = spawnSync("git", ["--git-dir", dir, ...args], {
48
+ input: opts.input,
49
+ env: { ...process.env, GIT_TERMINAL_PROMPT: "0", ...opts.env },
50
+ maxBuffer: 64 * 1024 * 1024,
51
+ });
52
+ if (r.status !== 0) {
53
+ throw new Error(`git ${args[0]} failed: ${(r.stderr?.toString() ?? "").trim().slice(0, 500)}`);
54
+ }
55
+ return r.stdout.toString();
56
+ }
57
+ export function repoExists(tenant, scope) {
58
+ return fs.existsSync(path.join(repoDir(tenant, scope), "HEAD"));
59
+ }
60
+ /** Create the repository if it is not there. Idempotent. */
61
+ export function ensureRepo(tenant, scope) {
62
+ const dir = repoDir(tenant, scope);
63
+ if (!fs.existsSync(path.join(dir, "HEAD"))) {
64
+ fs.mkdirSync(path.dirname(dir), { recursive: true });
65
+ spawnSync("git", ["init", "--bare", "--quiet", "--initial-branch=main", dir], { stdio: "ignore" });
66
+ // Pushes over HTTP need this; it is the one config a bare remote wants.
67
+ git(dir, ["config", "http.receivepack", "true"]);
68
+ }
69
+ installHooks(dir);
70
+ return dir;
71
+ }
72
+ export function headSha(tenant, scope, ref = "refs/heads/main") {
73
+ if (!repoExists(tenant, scope))
74
+ return null;
75
+ const r = spawnSync("git", ["--git-dir", repoDir(tenant, scope), "rev-parse", "--verify", "--quiet", ref]);
76
+ return r.status === 0 ? r.stdout.toString().trim() : null;
77
+ }
78
+ /**
79
+ * Apply a change set as one commit on main, using plumbing against a scratch
80
+ * index seeded from HEAD. No working copy, no checkout: each changed path is
81
+ * hashed straight from its content into the object store.
82
+ *
83
+ * Returns the new commit, or null when the tree did not change.
84
+ */
85
+ export function commitChanges(tenant, scope, files, meta = {}) {
86
+ const dir = ensureRepo(tenant, scope);
87
+ const parent = headSha(tenant, scope);
88
+ const scratch = fs.mkdtempSync(path.join(os.tmpdir(), "foldrun-index-"));
89
+ const index = path.join(scratch, "index");
90
+ // update-index insists on a work tree even when every entry arrives as
91
+ // --cacheinfo and nothing on disk is read. The scratch directory is one.
92
+ const env = { GIT_INDEX_FILE: index, GIT_WORK_TREE: scratch };
93
+ try {
94
+ if (parent)
95
+ git(dir, ["read-tree", parent], { env });
96
+ for (const f of files) {
97
+ if (f.after === null) {
98
+ if (parent)
99
+ git(dir, ["update-index", "--force-remove", "--", f.path], { env });
100
+ continue;
101
+ }
102
+ const blob = git(dir, ["hash-object", "-w", "--stdin"], { input: f.after }).trim();
103
+ // Executable where code lives, matching what the writers chmod on disk.
104
+ const mode = /\.(py|sh|mjs|js|rb)$/.test(f.path) && /(^|\/)(scripts|tools)\//.test(f.path) ? "100755" : "100644";
105
+ git(dir, ["update-index", "--add", "--cacheinfo", `${mode},${blob},${f.path}`], { env });
106
+ }
107
+ const tree = git(dir, ["write-tree"], { env }).trim();
108
+ if (parent && git(dir, ["rev-parse", `${parent}^{tree}`]).trim() === tree)
109
+ return null;
110
+ const who = meta.by ?? "system";
111
+ const email = who.includes("@") ? who : `${who}@foldrun`;
112
+ const name = who.includes("@") ? who.split("@")[0] : who;
113
+ const date = meta.at ? new Date(meta.at).toISOString() : new Date().toISOString();
114
+ const commit = git(dir, ["commit-tree", tree, ...(parent ? ["-p", parent] : []), "-m", meta.message ?? describe(files)], {
115
+ env: {
116
+ GIT_AUTHOR_NAME: name, GIT_AUTHOR_EMAIL: email, GIT_AUTHOR_DATE: date,
117
+ GIT_COMMITTER_NAME: "foldrun", GIT_COMMITTER_EMAIL: "platform@foldrun", GIT_COMMITTER_DATE: date,
118
+ },
119
+ }).trim();
120
+ git(dir, ["update-ref", "refs/heads/main", commit, ...(parent ? [parent] : [])]);
121
+ return commit;
122
+ }
123
+ finally {
124
+ fs.rmSync(scratch, { recursive: true, force: true });
125
+ }
126
+ }
127
+ function describe(files) {
128
+ if (files.length === 1) {
129
+ const f = files[0];
130
+ return `${f.before === null ? "created" : f.after === null ? "deleted" : "edited"} ${f.path}`;
131
+ }
132
+ return `changed ${files.length} files`;
133
+ }
134
+ // ------------------------------------------------------------------ reading
135
+ const SEP = "";
136
+ /** Commits on main, newest first, as the same summary the journal gave. */
137
+ export function listCommits(tenant, scope, opts = {}) {
138
+ if (!headSha(tenant, scope, opts.ref))
139
+ return [];
140
+ const dir = repoDir(tenant, scope);
141
+ const out = git(dir, [
142
+ "log",
143
+ opts.ref ?? "refs/heads/main",
144
+ `--max-count=${opts.limit ?? 200}`,
145
+ `--format=%H${SEP}%aI${SEP}%ae${SEP}%s`,
146
+ "--name-only",
147
+ ...(opts.forPath ? ["--", opts.forPath] : []),
148
+ ]);
149
+ const rows = [];
150
+ let current = null;
151
+ for (const line of out.split("\n")) {
152
+ if (line.includes(SEP)) {
153
+ const [id, at, by, message] = line.split(SEP);
154
+ current = { id, at, by: by.endsWith("@foldrun") ? by.replace(/@foldrun$/, "") : by, message, commit: id, paths: [] };
155
+ rows.push(current);
156
+ }
157
+ else if (line.trim() && current) {
158
+ current.paths.push(line.trim());
159
+ }
160
+ }
161
+ return rows;
162
+ }
163
+ /** One commit in full: every changed file with its before and after. */
164
+ export function readCommit(tenant, scope, sha, forPath) {
165
+ if (!repoExists(tenant, scope) || !/^[0-9a-f]{4,40}$/.test(sha))
166
+ return null;
167
+ const dir = repoDir(tenant, scope);
168
+ const head = spawnSync("git", ["--git-dir", dir, "rev-parse", "--verify", "--quiet", `${sha}^{commit}`]);
169
+ if (head.status !== 0)
170
+ return null;
171
+ const full = head.stdout.toString().trim();
172
+ const [at, by, ...msg] = git(dir, ["log", "-1", `--format=%aI${SEP}%ae${SEP}%s`, full]).trim().split(SEP);
173
+ const hasParent = spawnSync("git", ["--git-dir", dir, "rev-parse", "--verify", "--quiet", `${full}^`]).status === 0;
174
+ const names = git(dir, [
175
+ "diff-tree", "--no-commit-id", "--name-status", "-r", "--root", full,
176
+ ...(forPath ? ["--", forPath] : []),
177
+ ]);
178
+ const files = [];
179
+ for (const line of names.split("\n").filter(Boolean)) {
180
+ const [status, p] = line.split("\t");
181
+ const before = status === "A" || !hasParent ? null : blob(dir, `${full}^`, p);
182
+ const after = status === "D" ? null : blob(dir, full, p);
183
+ files.push({ path: p, before, after });
184
+ }
185
+ return {
186
+ id: full,
187
+ at,
188
+ by: by.endsWith("@foldrun") ? by.replace(/@foldrun$/, "") : by,
189
+ message: msg.join(SEP),
190
+ commit: full,
191
+ files,
192
+ };
193
+ }
194
+ function blob(dir, rev, p) {
195
+ const r = spawnSync("git", ["--git-dir", dir, "show", `${rev}:${p}`], { maxBuffer: 64 * 1024 * 1024 });
196
+ return r.status === 0 ? r.stdout.toString() : null;
197
+ }
198
+ export function listRefs(tenant, scope, kind) {
199
+ if (!repoExists(tenant, scope))
200
+ return [];
201
+ const out = git(repoDir(tenant, scope), [
202
+ "for-each-ref", `refs/${kind}`, "--sort=-committerdate",
203
+ `--format=%(refname:short)${SEP}%(objectname)${SEP}%(subject)${SEP}%(committerdate:iso-strict)`,
204
+ ]);
205
+ return out.split("\n").filter(Boolean).map((l) => {
206
+ const [name, sha, subject, at] = l.split(SEP);
207
+ return { name, sha, subject, at };
208
+ });
209
+ }
210
+ /** The files at a commit, for a browser or a deploy. */
211
+ export function listTree(tenant, scope, ref = "refs/heads/main") {
212
+ if (!headSha(tenant, scope, ref))
213
+ return [];
214
+ return git(repoDir(tenant, scope), ["ls-tree", "-r", "--format=%(objectmode) %(path)", ref])
215
+ .split("\n")
216
+ .filter(Boolean)
217
+ .map((l) => {
218
+ const [mode, ...rest] = l.split(" ");
219
+ return { mode, path: rest.join(" ") };
220
+ });
221
+ }
222
+ export function readBlob(tenant, scope, ref, p) {
223
+ if (!repoExists(tenant, scope))
224
+ return null;
225
+ return blob(repoDir(tenant, scope), ref, p);
226
+ }
227
+ /** The whole tree at a ref as deploy files — what a push turns into a workspace. */
228
+ export function filesAt(tenant, scope, ref) {
229
+ const dir = repoDir(tenant, scope);
230
+ return listTree(tenant, scope, ref).flatMap((t) => {
231
+ const content = blob(dir, ref, t.path);
232
+ return content === null ? [] : [{ path: t.path, content }];
233
+ });
234
+ }
235
+ /**
236
+ * What a ref would change on main: every file that differs, with both sides.
237
+ * The branch's own commits are `listCommits({ ref })`; this is the net
238
+ * effect, which is what a reviewer reads.
239
+ */
240
+ export function diffRefs(tenant, scope, base, head) {
241
+ if (!headSha(tenant, scope, base) || !headSha(tenant, scope, head))
242
+ return [];
243
+ const dir = repoDir(tenant, scope);
244
+ const out = git(dir, ["diff", "--name-status", `${base}...${head}`]);
245
+ return out.split("\n").filter(Boolean).map((line) => {
246
+ const [status, p] = line.split("\t");
247
+ return {
248
+ path: p,
249
+ before: status === "A" ? null : blob(dir, base, p),
250
+ after: status === "D" ? null : blob(dir, head, p),
251
+ };
252
+ });
253
+ }
254
+ /**
255
+ * Merge a branch into main and return the new main. A fast-forward when it
256
+ * is one; otherwise a merge commit whose tree is the BRANCH's — the branch
257
+ * is what the person reviewed and chose, and a three-way textual merge of
258
+ * markdown with no one to resolve conflicts would deploy text nobody wrote.
259
+ * Both parents are recorded, so history shows the merge for what it was.
260
+ */
261
+ export function mergeBranch(tenant, scope, branch, meta = {}) {
262
+ const dir = repoDir(tenant, scope);
263
+ const main = headSha(tenant, scope);
264
+ const head = headSha(tenant, scope, `refs/heads/${safe(branch, "branch")}`);
265
+ if (!head)
266
+ throw new Error(`no branch "${branch}"`);
267
+ if (!main) {
268
+ git(dir, ["update-ref", "refs/heads/main", head]);
269
+ return { sha: head, fastForward: true };
270
+ }
271
+ const isAncestor = spawnSync("git", ["--git-dir", dir, "merge-base", "--is-ancestor", main, head]).status === 0;
272
+ if (isAncestor) {
273
+ git(dir, ["update-ref", "refs/heads/main", head, main]);
274
+ return { sha: head, fastForward: true };
275
+ }
276
+ const tree = git(dir, ["rev-parse", `${head}^{tree}`]).trim();
277
+ const who = meta.by ?? "foldrun";
278
+ const email = who.includes("@") ? who : `${who}@foldrun`;
279
+ const name = who.includes("@") ? who.split("@")[0] : who;
280
+ const date = new Date().toISOString();
281
+ const sha = git(dir, ["commit-tree", tree, "-p", main, "-p", head, "-m", `merge ${branch} into main`], {
282
+ env: {
283
+ GIT_AUTHOR_NAME: name, GIT_AUTHOR_EMAIL: email, GIT_AUTHOR_DATE: date,
284
+ GIT_COMMITTER_NAME: "foldrun", GIT_COMMITTER_EMAIL: "platform@foldrun", GIT_COMMITTER_DATE: date,
285
+ },
286
+ }).trim();
287
+ git(dir, ["update-ref", "refs/heads/main", sha, main]);
288
+ return { sha, fastForward: false };
289
+ }
290
+ export function deleteBranch(tenant, scope, branch) {
291
+ if (branch === "main")
292
+ throw new Error("main is the default branch");
293
+ git(repoDir(tenant, scope), ["update-ref", "-d", `refs/heads/${safe(branch, "branch")}`]);
294
+ }
295
+ /** Push main to another remote — a GitHub or GitLab mirror. Blocking; callers
296
+ * run it off the request path. The token never touches disk: it is in the
297
+ * URL for this one process and nowhere else. */
298
+ export function pushMirror(tenant, scope, url) {
299
+ if (!headSha(tenant, scope))
300
+ return { ok: false, detail: "nothing to push yet" };
301
+ const r = spawnSync("git", ["--git-dir", repoDir(tenant, scope), "push", "--quiet", url, "+refs/heads/main:refs/heads/main"], {
302
+ env: { ...process.env, GIT_TERMINAL_PROMPT: "0" },
303
+ timeout: 60_000,
304
+ });
305
+ const detail = (r.stderr?.toString() ?? "").trim()
306
+ // Never echo the URL back: it carries the token.
307
+ .replaceAll(url, "<mirror>")
308
+ .slice(0, 500);
309
+ return { ok: r.status === 0, detail: r.status === 0 ? "pushed" : detail || "push failed" };
310
+ }
311
+ /**
312
+ * Where the compiled pre-receive check is, found on disk rather than through
313
+ * require.resolve — a bundler rewrites require.resolve("pkg") into a module
314
+ * id, and the hook was once told to run a script called "51113". Searched
315
+ * from the working directory upward: the monorepo layout, then an installed
316
+ * package. Empty when there is no compiled check (a dev server on the source
317
+ * tree), in which case a push is checked at deploy instead of refused — the
318
+ * same rules, one step later.
319
+ */
320
+ export function hookScriptPath() {
321
+ let dir = process.cwd();
322
+ for (let i = 0; i < 6; i++) {
323
+ for (const rel of ["packages/core/dist/src/git-hook.js", "node_modules/@foldrun/core/dist/src/git-hook.js"]) {
324
+ const candidate = path.join(dir, rel);
325
+ if (fs.existsSync(candidate))
326
+ return candidate;
327
+ }
328
+ const parent = path.dirname(dir);
329
+ if (parent === dir)
330
+ break;
331
+ dir = parent;
332
+ }
333
+ return "";
334
+ }
335
+ /**
336
+ * The pre-receive hook: a push that fails the deploy checks is refused with
337
+ * the errors in the pusher's terminal, instead of accepted and then not
338
+ * deployed. The hook shells out to node with the compiled check, so it runs
339
+ * the same rules as a dashboard deploy; which node and which script are
340
+ * passed in the environment by the HTTP route, because a hook inherits git's
341
+ * environment and nothing else.
342
+ */
343
+ export function installHooks(dir) {
344
+ const hooks = path.join(dir, "hooks");
345
+ fs.mkdirSync(hooks, { recursive: true });
346
+ const hook = path.join(hooks, "pre-receive");
347
+ const script = `#!/bin/sh
348
+ # Installed by foldrun. Refuses a push to main whose tree fails the checks.
349
+ [ -n "$FOLDRUN_HOOK_NODE" ] && [ -n "$FOLDRUN_HOOK_SCRIPT" ] || exit 0
350
+ exec "$FOLDRUN_HOOK_NODE" "$FOLDRUN_HOOK_SCRIPT"
351
+ `;
352
+ if (!fs.existsSync(hook) || fs.readFileSync(hook, "utf8") !== script) {
353
+ fs.writeFileSync(hook, script, { mode: 0o755 });
354
+ }
355
+ }
@@ -0,0 +1,82 @@
1
+ /**
2
+ * The current tree of a scope, so the first commit can be a full import.
3
+ *
4
+ * Registered by the modules that know how to list a workspace or the library
5
+ * (store.ts, library.ts), because this module cannot import them — they
6
+ * import it. A revision recorded before any commit exists imports the whole
7
+ * tree rather than committing one file into an empty repository, so a clone
8
+ * made a minute later has everything.
9
+ */
10
+ type TreeReader = (tenant: string, scope: string) => RevisionFile[] | null;
11
+ export declare function registerTreeReader(fn: TreeReader): void;
12
+ /** Something to do after a commit lands on main — the mirror push. Registered
13
+ * by mirror.ts; this module cannot import it (secrets, settings). */
14
+ type AfterCommit = (tenant: string, scope: string, sha: string) => void;
15
+ export declare function registerAfterCommit(fn: AfterCommit): void;
16
+ export declare function notifyCommit(tenant: string, scope: string, sha: string): void;
17
+ export interface RevisionFile {
18
+ path: string;
19
+ /** Null when the file did not exist before. */
20
+ before: string | null;
21
+ /** Null when the change deleted it. */
22
+ after: string | null;
23
+ }
24
+ export interface Revision {
25
+ id: string;
26
+ at: string;
27
+ /** Who: a signed-in email, "api-key", "agent:<name>", "deploy", "system". */
28
+ by: string;
29
+ message: string;
30
+ /** Set when this revision IS a git commit — a deploy from a push. */
31
+ commit: string | null;
32
+ files: RevisionFile[];
33
+ }
34
+ /** The index line: everything but the content. */
35
+ export interface RevisionSummary {
36
+ id: string;
37
+ at: string;
38
+ by: string;
39
+ message: string;
40
+ commit: string | null;
41
+ paths: string[];
42
+ }
43
+ /** A local revision id: sortable by time, unique enough, visibly not a sha. */
44
+ export declare function newRevisionId(now?: Date): string;
45
+ /**
46
+ * Record one change set. Called by the writers, never by hand.
47
+ *
48
+ * Returns null and records nothing when nothing actually changed — a save
49
+ * of identical content is not a revision, and a history full of "no change"
50
+ * entries is a history nobody reads.
51
+ */
52
+ export declare function recordRevision(tenant: string, scope: string, files: RevisionFile[], meta?: {
53
+ by?: string;
54
+ message?: string;
55
+ id?: string;
56
+ commit?: string | null;
57
+ }): Revision | null;
58
+ /** Newest first. `forPath` narrows to revisions that touched one file. */
59
+ export declare function listRevisions(tenant: string, scope: string, opts?: {
60
+ forPath?: string;
61
+ limit?: number;
62
+ }): RevisionSummary[];
63
+ export declare function readRevision(tenant: string, scope: string, id: string, forPath?: string): Revision | null;
64
+ /**
65
+ * Make sure a scope's repository has its initial import, so a clone made
66
+ * right now is complete. A no-op once any commit exists.
67
+ */
68
+ export declare function ensureImported(tenant: string, scope: string): string | null;
69
+ /** The most recent revision id for a scope — what a change is attributed
70
+ * to when nothing more specific (a deployed commit) is on record. */
71
+ export declare function latestRevisionId(tenant: string, scope: string): string | null;
72
+ export type DiffOp = {
73
+ kind: "same" | "add" | "del";
74
+ text: string;
75
+ };
76
+ /**
77
+ * A line diff, for showing a revision. Plain LCS — quadratic, and fine: the
78
+ * files are documents, not generated code, and a diff of two 300-line files
79
+ * is ninety thousand cells, which is nothing.
80
+ */
81
+ export declare function diffLines(before: string, after: string): DiffOp[];
82
+ export {};