@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.
- package/LICENSE +202 -0
- package/README.md +239 -0
- package/dist/index.d.ts +32 -0
- package/dist/index.js +43 -0
- package/dist/src/agent-tools.d.ts +31 -0
- package/dist/src/agent-tools.js +99 -0
- package/dist/src/agents-md.d.ts +9 -0
- package/dist/src/agents-md.js +83 -0
- package/dist/src/api-tools.d.ts +38 -0
- package/dist/src/api-tools.js +313 -0
- package/dist/src/approvals.d.ts +39 -0
- package/dist/src/approvals.js +103 -0
- package/dist/src/arrange.d.ts +8 -0
- package/dist/src/arrange.js +40 -0
- package/dist/src/catalog.d.ts +108 -0
- package/dist/src/catalog.js +255 -0
- package/dist/src/completions.d.ts +61 -0
- package/dist/src/completions.js +578 -0
- package/dist/src/confine.d.ts +31 -0
- package/dist/src/confine.js +298 -0
- package/dist/src/container.d.ts +30 -0
- package/dist/src/container.js +172 -0
- package/dist/src/context-tools.d.ts +81 -0
- package/dist/src/context-tools.js +347 -0
- package/dist/src/deploy.d.ts +100 -0
- package/dist/src/deploy.js +377 -0
- package/dist/src/egress.d.ts +75 -0
- package/dist/src/egress.js +133 -0
- package/dist/src/evals.d.ts +150 -0
- package/dist/src/evals.js +651 -0
- package/dist/src/flow-lint.d.ts +16 -0
- package/dist/src/flow-lint.js +177 -0
- package/dist/src/git-hook.d.ts +1 -0
- package/dist/src/git-hook.js +70 -0
- package/dist/src/git.d.ts +37 -0
- package/dist/src/git.js +101 -0
- package/dist/src/gitrepo.d.ts +95 -0
- package/dist/src/gitrepo.js +355 -0
- package/dist/src/history.d.ts +82 -0
- package/dist/src/history.js +259 -0
- package/dist/src/kinds.d.ts +95 -0
- package/dist/src/kinds.js +421 -0
- package/dist/src/library.d.ts +94 -0
- package/dist/src/library.js +341 -0
- package/dist/src/linkable.d.ts +20 -0
- package/dist/src/linkable.js +113 -0
- package/dist/src/model-probe.d.ts +21 -0
- package/dist/src/model-probe.js +81 -0
- package/dist/src/notify.d.ts +61 -0
- package/dist/src/notify.js +252 -0
- package/dist/src/oauth-presets.d.ts +13 -0
- package/dist/src/oauth-presets.js +39 -0
- package/dist/src/okf.d.ts +220 -0
- package/dist/src/okf.js +656 -0
- package/dist/src/openapi.d.ts +66 -0
- package/dist/src/openapi.js +342 -0
- package/dist/src/paths.d.ts +33 -0
- package/dist/src/paths.js +78 -0
- package/dist/src/platform.d.ts +32 -0
- package/dist/src/platform.js +48 -0
- package/dist/src/providers.d.ts +47 -0
- package/dist/src/providers.js +114 -0
- package/dist/src/refs.d.ts +20 -0
- package/dist/src/refs.js +78 -0
- package/dist/src/run-container.d.ts +227 -0
- package/dist/src/run-container.js +760 -0
- package/dist/src/runner.d.ts +241 -0
- package/dist/src/runner.js +3215 -0
- package/dist/src/runtime.d.ts +41 -0
- package/dist/src/runtime.js +357 -0
- package/dist/src/script-tools.d.ts +62 -0
- package/dist/src/script-tools.js +296 -0
- package/dist/src/secret-files.d.ts +12 -0
- package/dist/src/secret-files.js +92 -0
- package/dist/src/secrets.d.ts +147 -0
- package/dist/src/secrets.js +626 -0
- package/dist/src/starter.d.ts +21 -0
- package/dist/src/starter.js +399 -0
- package/dist/src/step-exec.d.ts +136 -0
- package/dist/src/step-exec.js +391 -0
- package/dist/src/storage.d.ts +123 -0
- package/dist/src/storage.js +640 -0
- package/dist/src/store.d.ts +1052 -0
- package/dist/src/store.js +2378 -0
- package/dist/src/tar.d.ts +30 -0
- package/dist/src/tar.js +111 -0
- package/dist/src/tool-names.d.ts +39 -0
- package/dist/src/tool-names.js +79 -0
- package/dist/src/tool-programs.d.ts +18 -0
- package/dist/src/tool-programs.js +52 -0
- package/dist/src/tool-test.d.ts +19 -0
- package/dist/src/tool-test.js +335 -0
- package/dist/src/translator.d.ts +159 -0
- package/dist/src/translator.js +986 -0
- package/dist/src/triggers.d.ts +60 -0
- package/dist/src/triggers.js +270 -0
- package/dist/src/webhook.d.ts +47 -0
- package/dist/src/webhook.js +148 -0
- package/index.ts +44 -0
- package/package.json +64 -0
- package/src/agent-tools.ts +138 -0
- package/src/agents-md.ts +81 -0
- package/src/api-tools.ts +389 -0
- package/src/approvals.ts +129 -0
- package/src/arrange.ts +42 -0
- package/src/catalog.ts +321 -0
- package/src/completions.ts +652 -0
- package/src/confine.ts +350 -0
- package/src/container.ts +220 -0
- package/src/context-tools.ts +435 -0
- package/src/deploy.ts +458 -0
- package/src/egress.ts +176 -0
- package/src/evals.ts +812 -0
- package/src/flow-lint.ts +215 -0
- package/src/git-hook.ts +73 -0
- package/src/git.ts +117 -0
- package/src/gitrepo.ts +391 -0
- package/src/history.ts +329 -0
- package/src/kinds.ts +507 -0
- package/src/library.ts +419 -0
- package/src/linkable.ts +123 -0
- package/src/model-probe.ts +112 -0
- package/src/notify.ts +281 -0
- package/src/oauth-presets.ts +54 -0
- package/src/okf.ts +768 -0
- package/src/openapi.ts +383 -0
- package/src/paths.ts +79 -0
- package/src/platform.ts +87 -0
- package/src/providers.ts +147 -0
- package/src/refs.ts +83 -0
- package/src/run-container.ts +883 -0
- package/src/runner.ts +3446 -0
- package/src/runtime.ts +404 -0
- package/src/script-tools.ts +383 -0
- package/src/secret-files.ts +101 -0
- package/src/secrets.ts +799 -0
- package/src/starter.ts +415 -0
- package/src/step-exec.ts +498 -0
- package/src/storage.ts +771 -0
- package/src/store.ts +2985 -0
- package/src/tar.ts +124 -0
- package/src/tool-names.ts +91 -0
- package/src/tool-programs.ts +65 -0
- package/src/tool-test.ts +401 -0
- package/src/translator.ts +1029 -0
- package/src/triggers.ts +287 -0
- package/src/webhook.ts +183 -0
- package/templates/blog-desk/AGENTS.md +13 -0
- package/templates/blog-desk/agents/researcher/agent.md +11 -0
- package/templates/blog-desk/agents/writer/agent.md +8 -0
- package/templates/blog-desk/evals/writer-quality.md +10 -0
- package/templates/blog-desk/flows/publish.md +10 -0
- package/templates/blog-desk/knowledge/house-style.md +9 -0
- package/templates/blog-desk/knowledge/index.md +9 -0
- package/templates/hello/AGENTS.md +18 -0
- package/templates/hello/agents/curator/agent.md +32 -0
- package/templates/hello/agents/notetaker/agent.md +24 -0
- package/templates/hello/evals/house-style.md +16 -0
- package/templates/hello/flows/memory-review.md +7 -0
- package/templates/hello/flows/note.md +7 -0
- package/templates/hello/knowledge/house-style.md +15 -0
- package/templates/hello/knowledge/index.md +9 -0
- package/templates/hello/skills/plain-english/SKILL.md +11 -0
- package/templates/hello/state/publishing.md +7 -0
- package/templates/hello/tools/wordcount/run.py +13 -0
- package/templates/hello/tools/wordcount/tool.md +22 -0
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
// Structural checks on a flow's arrangement.
|
|
2
|
+
//
|
|
3
|
+
// A flow can be perfectly valid markdown and still be guaranteed to produce
|
|
4
|
+
// fiction — the clearest case being a reviewer scheduled in group 1, whose
|
|
5
|
+
// instruction says "review both changes" while its context is empty because
|
|
6
|
+
// nothing has run yet. The model won't fail; it will invent a review and the
|
|
7
|
+
// run goes green. Work that quietly stops happening is exactly what these
|
|
8
|
+
// checks exist to make loud.
|
|
9
|
+
//
|
|
10
|
+
// Every check here is *structural*: it reasons about what a step can possibly
|
|
11
|
+
// have in context, never about whether the model did a good job. Nothing is
|
|
12
|
+
// blocked — flows still run, warnings are advisory.
|
|
13
|
+
// Anaphora: phrases that only mean something if earlier output exists.
|
|
14
|
+
// Deliberately conservative — a false warning is worse than a missed one,
|
|
15
|
+
// because noisy warnings get ignored and then the real one is invisible too.
|
|
16
|
+
const REFERS_BACK = /\b(both|above|earlier|previous(ly)?|prior|the (changes|draft|research|results|findings|report|article|fixes|work)|what (they|the others?) (found|shipped|wrote|said))\b/i;
|
|
17
|
+
export function lintFlow(flow, known) {
|
|
18
|
+
const warnings = [];
|
|
19
|
+
const agentNames = known ? new Set(known.agents) : null;
|
|
20
|
+
// Cron's oldest trap, and an expensive one: when day-of-month AND
|
|
21
|
+
// day-of-week are both restricted they are OR'd, not AND'd — the scheduler
|
|
22
|
+
// does it too, correctly (`domRestricted && dowRestricted` → `domOk ||
|
|
23
|
+
// dowOk`). So `0 5 1-7 * 5`, which every reader parses as "first Friday of
|
|
24
|
+
// the month", fires every Friday AND every day of the first week: about
|
|
25
|
+
// eleven runs where one was meant. Nothing about the expression looks wrong,
|
|
26
|
+
// which is exactly why it needs saying here rather than being discovered on
|
|
27
|
+
// the bill.
|
|
28
|
+
if (flow.schedule) {
|
|
29
|
+
const [, , dom, , dow] = flow.schedule.trim().split(/\s+/);
|
|
30
|
+
if (dom && dow && dom !== "*" && dow !== "*") {
|
|
31
|
+
warnings.push({
|
|
32
|
+
step: null,
|
|
33
|
+
message: `schedule "${flow.schedule}" restricts both day fields — cron runs it on EITHER, not both`,
|
|
34
|
+
detail: "Day-of-month and day-of-week are OR'd when both are set, so this fires far more often " +
|
|
35
|
+
"than it reads. For \"the Nth weekday of the month\" there is no cron form; use a plain " +
|
|
36
|
+
"day-of-month (`0 5 3 * *`) for monthly, or a plain weekday (`0 5 * * 1`) for weekly.",
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
// Trigger shapes that can never fire, said at check time rather than
|
|
41
|
+
// discovered as a flow that "never runs".
|
|
42
|
+
if (flow.trigger === "once" && !flow.at) {
|
|
43
|
+
warnings.push({ step: null, message: "trigger: once needs an `at:` instant", detail: "Write `at: 2026-09-05T09:00:00+10:00` (ISO 8601). Without a readable instant this flow never fires." });
|
|
44
|
+
}
|
|
45
|
+
if (flow.trigger === "watch" && !/^https?:\/\//.test(flow.url ?? "")) {
|
|
46
|
+
warnings.push({ step: null, message: "trigger: watch needs an http(s) `url:`", detail: "The scheduler polls that URL and starts the flow when its content changes." });
|
|
47
|
+
}
|
|
48
|
+
if (flow.trigger === "flow") {
|
|
49
|
+
if (!flow.after)
|
|
50
|
+
warnings.push({ step: null, message: "trigger: flow needs `after: <flow>`", detail: "Name the flow of this workspace whose finishing starts this one." });
|
|
51
|
+
else if (flow.after === flow.name)
|
|
52
|
+
warnings.push({ step: null, message: "trigger: flow — a flow cannot chain on itself", detail: "That is a loop with no bound. Chain on the flow that produces what this one needs." });
|
|
53
|
+
}
|
|
54
|
+
if (flow.signature && !flow.signingSecret) {
|
|
55
|
+
warnings.push({ step: null, message: `signature: ${flow.signature} needs \`signing_secret: \${NAME}\``, detail: "Every delivery will be refused until the secret is named and set in Settings → Secrets." });
|
|
56
|
+
}
|
|
57
|
+
if (flow.signature && flow.trigger !== "webhook") {
|
|
58
|
+
warnings.push({ step: null, message: "signature: only applies to trigger: webhook", detail: "Inbox and other triggers do not carry a provider signature; the line does nothing here." });
|
|
59
|
+
}
|
|
60
|
+
if (flow.steps.length === 0)
|
|
61
|
+
return warnings;
|
|
62
|
+
const firstGroup = Math.min(...flow.steps.map((s) => s.group));
|
|
63
|
+
flow.steps.forEach((step, i) => {
|
|
64
|
+
const inFirstGroup = step.group === firstGroup;
|
|
65
|
+
// The first group starts with context = null. An instruction that points
|
|
66
|
+
// at earlier output therefore points at nothing.
|
|
67
|
+
if (inFirstGroup && step.instruction && REFERS_BACK.test(step.instruction)) {
|
|
68
|
+
warnings.push({
|
|
69
|
+
step: i,
|
|
70
|
+
line: step.line,
|
|
71
|
+
message: `"${step.subflow ?? step.agent}" refers to earlier work but runs first`,
|
|
72
|
+
detail: "Steps in the first group start with no context — nothing has run yet. This agent will " +
|
|
73
|
+
"invent what it was asked to review rather than fail. Move it to a later group.",
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
// A gate with no `preview:` shows only the previous step's reply and
|
|
77
|
+
// whatever files that reply happens to name. The person deciding sees a
|
|
78
|
+
// summary, not the thing — unless the agent remembered.
|
|
79
|
+
if ((step.approve || step.ask) && !step.preview?.length) {
|
|
80
|
+
warnings.push({
|
|
81
|
+
step: i,
|
|
82
|
+
line: step.line,
|
|
83
|
+
message: `"${step.subflow ?? step.agent}" is a gate with no preview:`,
|
|
84
|
+
detail: "The approval box shows the previous step's reply and any storage/ file it names. " +
|
|
85
|
+
"Declare what to show — `preview: draft/*.mdx, draft/images/*.webp` — so the approver " +
|
|
86
|
+
"always sees the document, not a pointer, even when the agent forgets to name it.",
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
// `each: items` fans out over DATA, and only an `output: json` step in
|
|
90
|
+
// an earlier group produces any. Without one the step expands to
|
|
91
|
+
// nothing every time, quietly — the exact failure this file exists for.
|
|
92
|
+
if (step.each === "items") {
|
|
93
|
+
const feeder = flow.steps.some((s) => s.group < step.group && s.output === "json");
|
|
94
|
+
if (!feeder) {
|
|
95
|
+
warnings.push({
|
|
96
|
+
step: i,
|
|
97
|
+
line: step.line,
|
|
98
|
+
message: `"${step.subflow ?? step.agent}" fans out with each: items but no earlier step returns data`,
|
|
99
|
+
detail: "each: items reads the JSON array an `output: json` step returned. No step before this " +
|
|
100
|
+
"one declares output: json, so there are never any items and this step is always skipped. " +
|
|
101
|
+
"Add `output: json` to the step that produces the list.",
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
// A step's OTHER agent references — the ones no parser validates,
|
|
106
|
+
// because they are options rather than the step's target. Both fail
|
|
107
|
+
// silently and at the worst possible moment: a misspelled `delegate:`
|
|
108
|
+
// name is simply never chosen (the runner filters picks to the declared
|
|
109
|
+
// set), and a misspelled `on-fail:` is discovered only once something
|
|
110
|
+
// has already gone wrong.
|
|
111
|
+
if (agentNames) {
|
|
112
|
+
for (const name of step.delegate ?? []) {
|
|
113
|
+
if (!agentNames.has(name)) {
|
|
114
|
+
warnings.push({
|
|
115
|
+
step: i,
|
|
116
|
+
line: step.line,
|
|
117
|
+
message: `delegate: "${name}" is not an agent in this workspace`,
|
|
118
|
+
detail: "The runner only accepts picks from the declared set, so this name can never be " +
|
|
119
|
+
"chosen — the step silently has one fewer colleague to call on. Fix the spelling, " +
|
|
120
|
+
"or drop the name.",
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
if (step.onFail && !agentNames.has(step.onFail)) {
|
|
125
|
+
warnings.push({
|
|
126
|
+
step: i,
|
|
127
|
+
line: step.line,
|
|
128
|
+
message: `on-fail: "${step.onFail}" is not an agent in this workspace`,
|
|
129
|
+
detail: "The recovery path is the one you cannot test by running the flow successfully. As " +
|
|
130
|
+
"written, a failure here has nowhere to go and the step just fails.",
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
if (step.onFail && step.onFail === step.agent && !step.subflow) {
|
|
135
|
+
warnings.push({
|
|
136
|
+
step: i,
|
|
137
|
+
line: step.line,
|
|
138
|
+
message: `on-fail: "${step.onFail}" is the agent that just failed`,
|
|
139
|
+
detail: "The same agent is handed the same instruction with its own failure as context. That is " +
|
|
140
|
+
"`retry:` with extra steps — name a different agent, or use retry:.",
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
// `when:` tests the previous results, which the first group doesn't have.
|
|
144
|
+
if (inFirstGroup && step.when) {
|
|
145
|
+
warnings.push({
|
|
146
|
+
step: i,
|
|
147
|
+
line: step.line,
|
|
148
|
+
message: `"${step.subflow ?? step.agent}" has a when: condition but runs first`,
|
|
149
|
+
detail: `Nothing has produced output yet, so "${step.when}" can never match and this step will ` +
|
|
150
|
+
"always be skipped. Move it after the step whose output it depends on.",
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
});
|
|
154
|
+
// The same agent twice in one parallel group gets two identical contexts and
|
|
155
|
+
// no way to divide the work — almost always a drag that landed wrong.
|
|
156
|
+
const byGroup = new Map();
|
|
157
|
+
for (const s of flow.steps)
|
|
158
|
+
byGroup.set(s.group, [...(byGroup.get(s.group) ?? []), s]);
|
|
159
|
+
for (const [group, steps] of byGroup) {
|
|
160
|
+
const counts = new Map();
|
|
161
|
+
for (const s of steps) {
|
|
162
|
+
const key = s.subflow ? `flow:${s.subflow}` : s.agent;
|
|
163
|
+
counts.set(key, (counts.get(key) ?? 0) + 1);
|
|
164
|
+
}
|
|
165
|
+
for (const [agent, n] of counts) {
|
|
166
|
+
if (n > 1) {
|
|
167
|
+
warnings.push({
|
|
168
|
+
step: null,
|
|
169
|
+
message: `"${agent}" appears ${n} times in group ${group}`,
|
|
170
|
+
detail: "Both copies run at the same time with identical context, so they'll do the same work " +
|
|
171
|
+
"twice. Put them in different groups, or give each a distinct instruction.",
|
|
172
|
+
});
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
return warnings;
|
|
177
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,70 @@
|
|
|
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
|
+
import fs from "node:fs";
|
|
11
|
+
import path from "node:path";
|
|
12
|
+
import { spawnSync } from "node:child_process";
|
|
13
|
+
import { deployIssues, runsInFlight } from "./deploy.js";
|
|
14
|
+
const dir = process.env.GIT_DIR;
|
|
15
|
+
if (!dir)
|
|
16
|
+
process.exit(0);
|
|
17
|
+
// `_library.git` is the account shelf, not a workspace: it holds tools,
|
|
18
|
+
// skills and knowledge and has no agents by definition. The workspace rules
|
|
19
|
+
// refused every push to it with "no agents". Its sync (syncLibraryFromTree)
|
|
20
|
+
// validates what it accepts on receive; nothing here to add yet.
|
|
21
|
+
// Inside a hook git sets GIT_DIR relative to the repo ("."), so resolve it
|
|
22
|
+
// against the cwd before asking for its name.
|
|
23
|
+
if (path.basename(path.resolve(process.cwd(), dir)) === "_library.git")
|
|
24
|
+
process.exit(0);
|
|
25
|
+
const input = fs.readFileSync(0, "utf8");
|
|
26
|
+
const git = (args) => {
|
|
27
|
+
const r = spawnSync("git", ["--git-dir", dir, ...args], { maxBuffer: 64 * 1024 * 1024 });
|
|
28
|
+
return r.status === 0 ? r.stdout.toString() : null;
|
|
29
|
+
};
|
|
30
|
+
// The client is gone by the time the deploy runs, so this is the only moment
|
|
31
|
+
// a person can be told their push will not take effect yet. A warning, not a
|
|
32
|
+
// refusal: the commit is fine and the scheduler applies it when the runs end.
|
|
33
|
+
const here = path.basename(path.resolve(process.cwd(), dir));
|
|
34
|
+
if (here.endsWith(".git")) {
|
|
35
|
+
const workspace = here.slice(0, -4);
|
|
36
|
+
const tenant = path.basename(path.resolve(process.cwd(), dir, "..", ".."));
|
|
37
|
+
try {
|
|
38
|
+
const live = runsInFlight(tenant, workspace);
|
|
39
|
+
if (live.length) {
|
|
40
|
+
process.stderr.write(`\nfoldrun: ${live.length} run${live.length === 1 ? " is" : "s are"} in flight, so this push is not live yet.\n` +
|
|
41
|
+
` It is on main and will be applied automatically when they finish.\n\n`);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
catch {
|
|
45
|
+
// A warning that cannot be computed must never fail a push.
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
let refused = false;
|
|
49
|
+
for (const line of input.split("\n").filter(Boolean)) {
|
|
50
|
+
const [, newSha, ref] = line.split(" ");
|
|
51
|
+
if (ref !== "refs/heads/main" || /^0+$/.test(newSha))
|
|
52
|
+
continue;
|
|
53
|
+
const tree = git(["ls-tree", "-r", "--name-only", newSha]) ?? "";
|
|
54
|
+
const files = tree
|
|
55
|
+
.split("\n")
|
|
56
|
+
.filter(Boolean)
|
|
57
|
+
.flatMap((p) => {
|
|
58
|
+
const content = git(["show", `${newSha}:${p}`]);
|
|
59
|
+
return content === null ? [] : [{ path: p, content }];
|
|
60
|
+
});
|
|
61
|
+
const issues = deployIssues(files);
|
|
62
|
+
if (issues.length) {
|
|
63
|
+
refused = true;
|
|
64
|
+
process.stderr.write(`\nfoldrun: refusing this push — ${issues.length} problem${issues.length === 1 ? "" : "s"} the checks would have stopped at deploy:\n`);
|
|
65
|
+
for (const i of issues)
|
|
66
|
+
process.stderr.write(` ${i.where}: ${i.message}\n`);
|
|
67
|
+
process.stderr.write(`\nFix and push again. (foldrun check runs the same rules locally.)\n\n`);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
process.exit(refused ? 1 : 0);
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The secret a repository signs its pushes with.
|
|
3
|
+
*
|
|
4
|
+
* Derived rather than stored, exactly like a flow's webhook token: HMAC of the
|
|
5
|
+
* workspace's identity under the install key. Nothing new to persist, unique
|
|
6
|
+
* per workspace, and rotating FOLDRUN_SECRET_KEY invalidates every connection
|
|
7
|
+
* at once.
|
|
8
|
+
*/
|
|
9
|
+
export declare function gitSecret(tenant: string, workspace: string): string;
|
|
10
|
+
export declare function gitHookPath(tenant: string, workspace: string): string;
|
|
11
|
+
/**
|
|
12
|
+
* Whether a request really came from the repository it claims to.
|
|
13
|
+
*
|
|
14
|
+
* Compared in constant time: a byte-by-byte early return leaks the expected
|
|
15
|
+
* signature to anyone willing to time enough requests. The raw body is
|
|
16
|
+
* required — re-serializing parsed JSON changes bytes GitHub signed.
|
|
17
|
+
*/
|
|
18
|
+
export declare function verifySignature(secret: string, rawBody: string, header: string | null): boolean;
|
|
19
|
+
export interface PushEvent {
|
|
20
|
+
repo: string;
|
|
21
|
+
/** Full ref, e.g. refs/heads/main. */
|
|
22
|
+
ref: string;
|
|
23
|
+
branch: string;
|
|
24
|
+
commit: string;
|
|
25
|
+
message: string | null;
|
|
26
|
+
pusher: string | null;
|
|
27
|
+
}
|
|
28
|
+
/** What we need out of a GitHub push payload, or null if it isn't one. */
|
|
29
|
+
export declare function parsePush(payload: unknown): PushEvent | null;
|
|
30
|
+
/**
|
|
31
|
+
* The repository at one commit, as a gzipped tarball.
|
|
32
|
+
*
|
|
33
|
+
* A token is only needed for a private repository. It is read from the
|
|
34
|
+
* environment rather than taken as an argument so a caller cannot be tricked
|
|
35
|
+
* into forwarding one somewhere else.
|
|
36
|
+
*/
|
|
37
|
+
export declare function fetchTarball(repo: string, commit: string): Promise<Buffer>;
|
package/dist/src/git.js
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
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
|
+
import crypto from "node:crypto";
|
|
11
|
+
import { installKey } from "./webhook.js";
|
|
12
|
+
/** GitHub refuses to fetch anything larger, and neither should we. */
|
|
13
|
+
const MAX_TARBALL = 25 * 1024 * 1024;
|
|
14
|
+
/**
|
|
15
|
+
* The secret a repository signs its pushes with.
|
|
16
|
+
*
|
|
17
|
+
* Derived rather than stored, exactly like a flow's webhook token: HMAC of the
|
|
18
|
+
* workspace's identity under the install key. Nothing new to persist, unique
|
|
19
|
+
* per workspace, and rotating FOLDRUN_SECRET_KEY invalidates every connection
|
|
20
|
+
* at once.
|
|
21
|
+
*/
|
|
22
|
+
export function gitSecret(tenant, workspace) {
|
|
23
|
+
return crypto
|
|
24
|
+
.createHmac("sha256", installKey())
|
|
25
|
+
.update(`git/${tenant}/${workspace}`)
|
|
26
|
+
.digest("hex");
|
|
27
|
+
}
|
|
28
|
+
export function gitHookPath(tenant, workspace) {
|
|
29
|
+
return `/api/git/${tenant}/${workspace}`;
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Whether a request really came from the repository it claims to.
|
|
33
|
+
*
|
|
34
|
+
* Compared in constant time: a byte-by-byte early return leaks the expected
|
|
35
|
+
* signature to anyone willing to time enough requests. The raw body is
|
|
36
|
+
* required — re-serializing parsed JSON changes bytes GitHub signed.
|
|
37
|
+
*/
|
|
38
|
+
export function verifySignature(secret, rawBody, header) {
|
|
39
|
+
if (!header)
|
|
40
|
+
return false;
|
|
41
|
+
const expected = `sha256=${crypto.createHmac("sha256", secret).update(rawBody).digest("hex")}`;
|
|
42
|
+
const a = Buffer.from(expected);
|
|
43
|
+
const b = Buffer.from(header);
|
|
44
|
+
return a.length === b.length && crypto.timingSafeEqual(a, b);
|
|
45
|
+
}
|
|
46
|
+
/** What we need out of a GitHub push payload, or null if it isn't one. */
|
|
47
|
+
export function parsePush(payload) {
|
|
48
|
+
if (!payload || typeof payload !== "object")
|
|
49
|
+
return null;
|
|
50
|
+
const p = payload;
|
|
51
|
+
const repo = p.repository?.full_name;
|
|
52
|
+
const ref = p.ref;
|
|
53
|
+
const commit = p.after;
|
|
54
|
+
if (typeof repo !== "string" || typeof ref !== "string" || typeof commit !== "string") {
|
|
55
|
+
return null;
|
|
56
|
+
}
|
|
57
|
+
// A deleted branch reports all zeros and ships no tree.
|
|
58
|
+
if (/^0+$/.test(commit))
|
|
59
|
+
return null;
|
|
60
|
+
return {
|
|
61
|
+
repo,
|
|
62
|
+
ref,
|
|
63
|
+
branch: ref.replace(/^refs\/heads\//, ""),
|
|
64
|
+
commit,
|
|
65
|
+
message: typeof p.head_commit?.message === "string" ? p.head_commit.message : null,
|
|
66
|
+
pusher: typeof p.pusher?.name === "string" ? p.pusher.name : null,
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* The repository at one commit, as a gzipped tarball.
|
|
71
|
+
*
|
|
72
|
+
* A token is only needed for a private repository. It is read from the
|
|
73
|
+
* environment rather than taken as an argument so a caller cannot be tricked
|
|
74
|
+
* into forwarding one somewhere else.
|
|
75
|
+
*/
|
|
76
|
+
export async function fetchTarball(repo, commit) {
|
|
77
|
+
if (!/^[\w.-]+\/[\w.-]+$/.test(repo))
|
|
78
|
+
throw new Error(`refusing to fetch "${repo}"`);
|
|
79
|
+
if (!/^[0-9a-f]{7,40}$/i.test(commit))
|
|
80
|
+
throw new Error(`refusing to fetch commit "${commit}"`);
|
|
81
|
+
const headers = {
|
|
82
|
+
accept: "application/vnd.github+json",
|
|
83
|
+
"user-agent": "foldrun",
|
|
84
|
+
"x-github-api-version": "2022-11-28",
|
|
85
|
+
};
|
|
86
|
+
const token = process.env.GITHUB_TOKEN;
|
|
87
|
+
if (token)
|
|
88
|
+
headers.authorization = `Bearer ${token}`;
|
|
89
|
+
const res = await fetch(`https://api.github.com/repos/${repo}/tarball/${commit}`, { headers });
|
|
90
|
+
if (!res.ok) {
|
|
91
|
+
throw new Error(`GitHub returned ${res.status} for ${repo}@${commit.slice(0, 8)}` +
|
|
92
|
+
(res.status === 404 && !token
|
|
93
|
+
? " — a private repository needs GITHUB_TOKEN set on the server"
|
|
94
|
+
: ""));
|
|
95
|
+
}
|
|
96
|
+
const buf = Buffer.from(await res.arrayBuffer());
|
|
97
|
+
if (buf.length > MAX_TARBALL) {
|
|
98
|
+
throw new Error(`${repo} is ${Math.round(buf.length / 1e6)}MB — too large to deploy`);
|
|
99
|
+
}
|
|
100
|
+
return buf;
|
|
101
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import type { DiffOp, Revision, RevisionFile, RevisionSummary } from "./history.ts";
|
|
2
|
+
/** Where a workspace's bare repository lives. `@library` is the account shelf. */
|
|
3
|
+
export declare function repoDir(tenant: string, scope: string): string;
|
|
4
|
+
/** Is there a git binary? Checked once; an install without one keeps the journal. */
|
|
5
|
+
export declare function gitAvailable(): boolean;
|
|
6
|
+
export declare function repoExists(tenant: string, scope: string): boolean;
|
|
7
|
+
/** Create the repository if it is not there. Idempotent. */
|
|
8
|
+
export declare function ensureRepo(tenant: string, scope: string): string;
|
|
9
|
+
export declare function headSha(tenant: string, scope: string, ref?: string): string | null;
|
|
10
|
+
/**
|
|
11
|
+
* Apply a change set as one commit on main, using plumbing against a scratch
|
|
12
|
+
* index seeded from HEAD. No working copy, no checkout: each changed path is
|
|
13
|
+
* hashed straight from its content into the object store.
|
|
14
|
+
*
|
|
15
|
+
* Returns the new commit, or null when the tree did not change.
|
|
16
|
+
*/
|
|
17
|
+
export declare function commitChanges(tenant: string, scope: string, files: RevisionFile[], meta?: {
|
|
18
|
+
by?: string;
|
|
19
|
+
message?: string;
|
|
20
|
+
at?: string;
|
|
21
|
+
}): string | null;
|
|
22
|
+
/** Commits on main, newest first, as the same summary the journal gave. */
|
|
23
|
+
export declare function listCommits(tenant: string, scope: string, opts?: {
|
|
24
|
+
forPath?: string;
|
|
25
|
+
limit?: number;
|
|
26
|
+
ref?: string;
|
|
27
|
+
}): RevisionSummary[];
|
|
28
|
+
/** One commit in full: every changed file with its before and after. */
|
|
29
|
+
export declare function readCommit(tenant: string, scope: string, sha: string, forPath?: string): Revision | null;
|
|
30
|
+
export interface RefInfo {
|
|
31
|
+
name: string;
|
|
32
|
+
sha: string;
|
|
33
|
+
/** Commit subject and time, for a branch list that says what each is. */
|
|
34
|
+
subject: string;
|
|
35
|
+
at: string;
|
|
36
|
+
}
|
|
37
|
+
export declare function listRefs(tenant: string, scope: string, kind: "heads" | "tags"): RefInfo[];
|
|
38
|
+
/** The files at a commit, for a browser or a deploy. */
|
|
39
|
+
export declare function listTree(tenant: string, scope: string, ref?: string): {
|
|
40
|
+
path: string;
|
|
41
|
+
mode: string;
|
|
42
|
+
}[];
|
|
43
|
+
export declare function readBlob(tenant: string, scope: string, ref: string, p: string): string | null;
|
|
44
|
+
/** The whole tree at a ref as deploy files — what a push turns into a workspace. */
|
|
45
|
+
export declare function filesAt(tenant: string, scope: string, ref: string): {
|
|
46
|
+
path: string;
|
|
47
|
+
content: string;
|
|
48
|
+
}[];
|
|
49
|
+
/**
|
|
50
|
+
* What a ref would change on main: every file that differs, with both sides.
|
|
51
|
+
* The branch's own commits are `listCommits({ ref })`; this is the net
|
|
52
|
+
* effect, which is what a reviewer reads.
|
|
53
|
+
*/
|
|
54
|
+
export declare function diffRefs(tenant: string, scope: string, base: string, head: string): RevisionFile[];
|
|
55
|
+
/**
|
|
56
|
+
* Merge a branch into main and return the new main. A fast-forward when it
|
|
57
|
+
* is one; otherwise a merge commit whose tree is the BRANCH's — the branch
|
|
58
|
+
* is what the person reviewed and chose, and a three-way textual merge of
|
|
59
|
+
* markdown with no one to resolve conflicts would deploy text nobody wrote.
|
|
60
|
+
* Both parents are recorded, so history shows the merge for what it was.
|
|
61
|
+
*/
|
|
62
|
+
export declare function mergeBranch(tenant: string, scope: string, branch: string, meta?: {
|
|
63
|
+
by?: string;
|
|
64
|
+
}): {
|
|
65
|
+
sha: string;
|
|
66
|
+
fastForward: boolean;
|
|
67
|
+
};
|
|
68
|
+
export declare function deleteBranch(tenant: string, scope: string, branch: string): void;
|
|
69
|
+
/** Push main to another remote — a GitHub or GitLab mirror. Blocking; callers
|
|
70
|
+
* run it off the request path. The token never touches disk: it is in the
|
|
71
|
+
* URL for this one process and nowhere else. */
|
|
72
|
+
export declare function pushMirror(tenant: string, scope: string, url: string): {
|
|
73
|
+
ok: boolean;
|
|
74
|
+
detail: string;
|
|
75
|
+
};
|
|
76
|
+
/**
|
|
77
|
+
* Where the compiled pre-receive check is, found on disk rather than through
|
|
78
|
+
* require.resolve — a bundler rewrites require.resolve("pkg") into a module
|
|
79
|
+
* id, and the hook was once told to run a script called "51113". Searched
|
|
80
|
+
* from the working directory upward: the monorepo layout, then an installed
|
|
81
|
+
* package. Empty when there is no compiled check (a dev server on the source
|
|
82
|
+
* tree), in which case a push is checked at deploy instead of refused — the
|
|
83
|
+
* same rules, one step later.
|
|
84
|
+
*/
|
|
85
|
+
export declare function hookScriptPath(): string;
|
|
86
|
+
/**
|
|
87
|
+
* The pre-receive hook: a push that fails the deploy checks is refused with
|
|
88
|
+
* the errors in the pusher's terminal, instead of accepted and then not
|
|
89
|
+
* deployed. The hook shells out to node with the compiled check, so it runs
|
|
90
|
+
* the same rules as a dashboard deploy; which node and which script are
|
|
91
|
+
* passed in the environment by the HTTP route, because a hook inherits git's
|
|
92
|
+
* environment and nothing else.
|
|
93
|
+
*/
|
|
94
|
+
export declare function installHooks(dir: string): void;
|
|
95
|
+
export { type DiffOp };
|