@foldrun/core 2.11.0 → 2.12.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 +95 -29
- package/LICENSING.md +101 -0
- package/README.md +14 -6
- package/dist/src/runner.js +1 -1
- package/dist/src/scan.js +1 -1
- package/dist/src/test-mode.d.ts +1 -1
- package/dist/src/test-mode.js +1 -1
- package/package.json +5 -4
- package/src/runner.ts +1 -1
- package/src/scan.ts +1 -1
- package/src/test-mode.ts +1 -1
- 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/LICENSE
CHANGED
|
@@ -1,39 +1,105 @@
|
|
|
1
|
-
|
|
1
|
+
# Functional Source License, Version 1.1, ALv2 Future License
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
## Abbreviation
|
|
4
4
|
|
|
5
|
-
|
|
6
|
-
foldrun. They are licensed, not sold.
|
|
5
|
+
FSL-1.1-ALv2
|
|
7
6
|
|
|
8
|
-
|
|
9
|
-
and `@foldrun/core`) to write, check, run and deploy your own agents,
|
|
10
|
-
flows and workspaces, on your own machines and with the foldrun platform.
|
|
11
|
-
That includes the copies installing and running need, for your own use:
|
|
12
|
-
package caches and mirrors, CI and build machines, container images and
|
|
13
|
-
backups.
|
|
7
|
+
## Notice
|
|
14
8
|
|
|
15
|
-
|
|
16
|
-
copy, modify, merge, publish, distribute, sublicense, rent or sell the
|
|
17
|
-
software or any part of it; reverse engineer, decompile or disassemble it;
|
|
18
|
-
or use it to build a product or service that competes with foldrun.
|
|
9
|
+
Copyright 2026 foldrun
|
|
19
10
|
|
|
20
|
-
|
|
21
|
-
files) is yours.
|
|
11
|
+
## Terms and Conditions
|
|
22
12
|
|
|
23
|
-
|
|
24
|
-
dependencies) remain under their own licences.
|
|
13
|
+
### Licensor ("We")
|
|
25
14
|
|
|
26
|
-
|
|
27
|
-
stop using the software.
|
|
15
|
+
The party offering the Software under these Terms and Conditions.
|
|
28
16
|
|
|
29
|
-
|
|
30
|
-
remain available under that licence for those versions only.
|
|
17
|
+
### The Software
|
|
31
18
|
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
LAW ALLOWS, IN NO EVENT SHALL FOLDRUN BE LIABLE FOR ANY CLAIM, DAMAGES OR
|
|
36
|
-
OTHER LIABILITY ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR
|
|
37
|
-
ITS USE.
|
|
19
|
+
The "Software" is each version of the software that we make available under
|
|
20
|
+
these Terms and Conditions, as indicated by our inclusion of these Terms and
|
|
21
|
+
Conditions with the Software.
|
|
38
22
|
|
|
39
|
-
|
|
23
|
+
### License Grant
|
|
24
|
+
|
|
25
|
+
Subject to your compliance with this License Grant and the Patents,
|
|
26
|
+
Redistribution and Trademark clauses below, we hereby grant you the right to
|
|
27
|
+
use, copy, modify, create derivative works, publicly perform, publicly display
|
|
28
|
+
and redistribute the Software for any Permitted Purpose identified below.
|
|
29
|
+
|
|
30
|
+
### Permitted Purpose
|
|
31
|
+
|
|
32
|
+
A Permitted Purpose is any purpose other than a Competing Use. A Competing Use
|
|
33
|
+
means making the Software available to others in a commercial product or
|
|
34
|
+
service that:
|
|
35
|
+
|
|
36
|
+
1. substitutes for the Software;
|
|
37
|
+
|
|
38
|
+
2. substitutes for any other product or service we offer using the Software
|
|
39
|
+
that exists as of the date we make the Software available; or
|
|
40
|
+
|
|
41
|
+
3. offers the same or substantially similar functionality as the Software.
|
|
42
|
+
|
|
43
|
+
Permitted Purposes specifically include using the Software:
|
|
44
|
+
|
|
45
|
+
1. for your internal use and access;
|
|
46
|
+
|
|
47
|
+
2. for non-commercial education;
|
|
48
|
+
|
|
49
|
+
3. for non-commercial research; and
|
|
50
|
+
|
|
51
|
+
4. in connection with professional services that you provide to a licensee
|
|
52
|
+
using the Software in accordance with these Terms and Conditions.
|
|
53
|
+
|
|
54
|
+
### Patents
|
|
55
|
+
|
|
56
|
+
To the extent your use for a Permitted Purpose would necessarily infringe our
|
|
57
|
+
patents, the license grant above includes a license under our patents. If you
|
|
58
|
+
make a claim against any party that the Software infringes or contributes to
|
|
59
|
+
the infringement of any patent, then your patent license to the Software ends
|
|
60
|
+
immediately.
|
|
61
|
+
|
|
62
|
+
### Redistribution
|
|
63
|
+
|
|
64
|
+
The Terms and Conditions apply to all copies, modifications and derivatives of
|
|
65
|
+
the Software.
|
|
66
|
+
|
|
67
|
+
If you redistribute any copies, modifications or derivatives of the Software,
|
|
68
|
+
you must include a copy of or a link to these Terms and Conditions and not
|
|
69
|
+
remove any copyright notices provided in or with the Software.
|
|
70
|
+
|
|
71
|
+
### Disclaimer
|
|
72
|
+
|
|
73
|
+
THE SOFTWARE IS PROVIDED "AS IS" AND WITHOUT WARRANTIES OF ANY KIND, EXPRESS OR
|
|
74
|
+
IMPLIED, INCLUDING WITHOUT LIMITATION WARRANTIES OF FITNESS FOR A PARTICULAR
|
|
75
|
+
PURPOSE, MERCHANTABILITY, TITLE OR NON-INFRINGEMENT.
|
|
76
|
+
|
|
77
|
+
IN NO EVENT WILL WE HAVE ANY LIABILITY TO YOU ARISING OUT OF OR RELATED TO THE
|
|
78
|
+
SOFTWARE, INCLUDING INDIRECT, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES,
|
|
79
|
+
EVEN IF WE HAVE BEEN INFORMED OF THEIR POSSIBILITY IN ADVANCE.
|
|
80
|
+
|
|
81
|
+
### Trademarks
|
|
82
|
+
|
|
83
|
+
Except for displaying the License Details and identifying us as the origin of
|
|
84
|
+
the Software, you have no right under these Terms and Conditions to use our
|
|
85
|
+
trademarks, trade names, service marks or product names.
|
|
86
|
+
|
|
87
|
+
## Grant of Future License
|
|
88
|
+
|
|
89
|
+
We hereby irrevocably grant you an additional license to use the Software under
|
|
90
|
+
the Apache License, Version 2.0 that is effective on the second anniversary of
|
|
91
|
+
the date we make the Software available. On or after that date, you may use the
|
|
92
|
+
Software under the Apache License, Version 2.0, in which case the following
|
|
93
|
+
will apply:
|
|
94
|
+
|
|
95
|
+
Licensed under the Apache License, Version 2.0 (the "License"); you may not use
|
|
96
|
+
this file except in compliance with the License.
|
|
97
|
+
|
|
98
|
+
You may obtain a copy of the License at
|
|
99
|
+
|
|
100
|
+
http://www.apache.org/licenses/LICENSE-2.0
|
|
101
|
+
|
|
102
|
+
Unless required by applicable law or agreed to in writing, software distributed
|
|
103
|
+
under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR
|
|
104
|
+
CONDITIONS OF ANY KIND, either express or implied. See the License for the
|
|
105
|
+
specific language governing permissions and limitations under the License.
|
package/LICENSING.md
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
# Licensing
|
|
2
|
+
|
|
3
|
+
foldrun is **source-available** under the **Functional Source License,
|
|
4
|
+
Version 1.1, with an Apache 2.0 future licence** (`FSL-1.1-ALv2`). The full
|
|
5
|
+
terms are in [LICENSE](LICENSE); they are the standard FSL text, unchanged,
|
|
6
|
+
and they are what binds. This page explains them in plain English. If the
|
|
7
|
+
two ever disagree, LICENSE wins.
|
|
8
|
+
|
|
9
|
+
## In one paragraph
|
|
10
|
+
|
|
11
|
+
Use foldrun for anything except selling it, or offering it to others as a
|
|
12
|
+
product or service that competes with foldrun. Personal use and use inside
|
|
13
|
+
your own company are free, at any size. Every version becomes Apache 2.0,
|
|
14
|
+
with no restrictions, two years after it is released.
|
|
15
|
+
|
|
16
|
+
## What you may do, free
|
|
17
|
+
|
|
18
|
+
- **Use it yourself**: on your laptop, on your own server, for your own
|
|
19
|
+
agents and flows.
|
|
20
|
+
- **Use it inside your company or organisation**, of any size, for your own
|
|
21
|
+
work: your team's agents, your internal automations, your CI.
|
|
22
|
+
- **Read, change and fork the code**, and keep your changes private or
|
|
23
|
+
share them, as long as the result is used for one of the purposes on
|
|
24
|
+
this list.
|
|
25
|
+
- **Teach and research with it** (non-commercial education and research).
|
|
26
|
+
- **Set it up for a client as a consultant or agency**, as long as the
|
|
27
|
+
client is the one using it, for their own purposes, under these same
|
|
28
|
+
terms.
|
|
29
|
+
|
|
30
|
+
## What needs a commercial licence
|
|
31
|
+
|
|
32
|
+
The licence calls these a *Competing Use*: making foldrun available **to
|
|
33
|
+
others** in a **commercial** product or service that substitutes for
|
|
34
|
+
foldrun, substitutes for a foldrun product or service that existed when that
|
|
35
|
+
version was released, or offers the same or substantially similar
|
|
36
|
+
functionality. For example:
|
|
37
|
+
|
|
38
|
+
- running foldrun, or a modified copy, as a hosted agent platform that other
|
|
39
|
+
people or companies sign up to or pay for;
|
|
40
|
+
- reselling it, white-labelling it, or bundling it into a product you sell
|
|
41
|
+
whose value is what foldrun does;
|
|
42
|
+
- selling access to a managed foldrun instance.
|
|
43
|
+
|
|
44
|
+
If you want to do any of these, or are not sure whether your use counts,
|
|
45
|
+
write to **hello@foldrun.io**. Commercial licences are available.
|
|
46
|
+
|
|
47
|
+
## The two-year rule
|
|
48
|
+
|
|
49
|
+
Each version is offered under FSL from the day it is released. On the
|
|
50
|
+
second anniversary of that release, that version is **also** available under
|
|
51
|
+
the Apache License 2.0 — irrevocably, for everyone, for any purpose. Newer
|
|
52
|
+
versions are not affected until they reach their own second anniversary.
|
|
53
|
+
|
|
54
|
+
## Versions released under other terms
|
|
55
|
+
|
|
56
|
+
| Versions | Licence |
|
|
57
|
+
|---|---|
|
|
58
|
+
| released before 17 October 2026 | Apache License 2.0 |
|
|
59
|
+
| released 17 October 2026 up to the first FSL release | the foldrun proprietary licence that shipped in each package |
|
|
60
|
+
| the first FSL release and later | FSL-1.1-ALv2 (this page) |
|
|
61
|
+
|
|
62
|
+
A version keeps the licence it was released under.
|
|
63
|
+
|
|
64
|
+
## What stays yours
|
|
65
|
+
|
|
66
|
+
What you write with foldrun — your agents, flows, knowledge, skills, tools,
|
|
67
|
+
evals and the outputs of your runs — is yours. This licence covers foldrun's
|
|
68
|
+
code, not your work.
|
|
69
|
+
|
|
70
|
+
## Name and logo
|
|
71
|
+
|
|
72
|
+
The licence does not give you the foldrun name, logo or other trademarks,
|
|
73
|
+
except to say truthfully where the code came from. A fork you distribute
|
|
74
|
+
needs its own name.
|
|
75
|
+
|
|
76
|
+
## Third-party code
|
|
77
|
+
|
|
78
|
+
foldrun's dependencies, and anything it installs, stay under their own
|
|
79
|
+
licences.
|
|
80
|
+
|
|
81
|
+
## Patents
|
|
82
|
+
|
|
83
|
+
Using foldrun for a permitted purpose includes a licence to any foldrun
|
|
84
|
+
patents that use needs. That patent licence ends for anyone who sues claiming
|
|
85
|
+
the software infringes a patent.
|
|
86
|
+
|
|
87
|
+
## No warranty
|
|
88
|
+
|
|
89
|
+
foldrun is provided as is, with no warranty, and with no liability for
|
|
90
|
+
damages, as LICENSE sets out in full.
|
|
91
|
+
|
|
92
|
+
## Contributing
|
|
93
|
+
|
|
94
|
+
Contributions are welcome. Before we merge one, we ask you to agree that we
|
|
95
|
+
may release it under these terms and under commercial licences, so the
|
|
96
|
+
project can keep both. We will send the agreement with your first pull
|
|
97
|
+
request.
|
|
98
|
+
|
|
99
|
+
## Questions
|
|
100
|
+
|
|
101
|
+
hello@foldrun.io. This page is a summary to help you, not legal advice.
|
package/README.md
CHANGED
|
@@ -151,8 +151,8 @@ Most people want the CLI, not this package — `npm install -g foldrun`.
|
|
|
151
151
|
The command-line tool is its own package,
|
|
152
152
|
[`foldrun`](https://www.npmjs.com/package/foldrun), and depends on
|
|
153
153
|
this package. The reference documentation is at
|
|
154
|
-
[docs.foldrun.io](https://docs.foldrun.io).
|
|
155
|
-
|
|
154
|
+
[docs.foldrun.io](https://docs.foldrun.io). foldrun is source-available
|
|
155
|
+
under the Functional Source License (see "Licence" below).
|
|
156
156
|
|
|
157
157
|
### Templates and workspaces
|
|
158
158
|
|
|
@@ -189,8 +189,11 @@ This package is the framework: everything needed to read a workspace and
|
|
|
189
189
|
run it on the machine you are on. The platform — the dashboard, a queue and
|
|
190
190
|
its worker, one sandbox per step, a vault, metering, a team — is built on
|
|
191
191
|
it, in a separate package that plugs in through `src/platform.ts`. Nothing
|
|
192
|
-
in this repository imports it. The platform is
|
|
193
|
-
|
|
192
|
+
in this repository imports it. The platform is
|
|
193
|
+
[foldrun-platform](https://github.com/foldrun-io/foldrun-platform) and
|
|
194
|
+
[foldrun-web](https://github.com/foldrun-io/foldrun-web): run it on your own
|
|
195
|
+
server with Docker Compose (`foldrun-web/self-hosting`), or use foldrun's
|
|
196
|
+
hosted one; `foldrun deploy` moves a workspace from your machine to either
|
|
194
197
|
unchanged.
|
|
195
198
|
|
|
196
199
|
## Requirements
|
|
@@ -269,5 +272,10 @@ per request (fine to thousands of runs).
|
|
|
269
272
|
|
|
270
273
|
## Licence
|
|
271
274
|
|
|
272
|
-
|
|
273
|
-
|
|
275
|
+
Source-available under the [Functional Source License 1.1, Apache 2.0 future
|
|
276
|
+
licence](LICENSE) (`FSL-1.1-ALv2`). Use it for anything except offering it to
|
|
277
|
+
others as a competing commercial product or service: personal use and use
|
|
278
|
+
inside your own company are free, at any size. Each version becomes Apache 2.0
|
|
279
|
+
two years after its release. [LICENSING.md](LICENSING.md) explains what is
|
|
280
|
+
allowed and what needs a commercial licence (hello@foldrun.io), and which
|
|
281
|
+
licence earlier versions shipped under.
|
package/dist/src/runner.js
CHANGED
|
@@ -1016,7 +1016,7 @@ grantsOnly = false) {
|
|
|
1016
1016
|
// EMAIL_FROM]`): granting the tool grants them, the way an API tool's
|
|
1017
1017
|
// referenced credentials always travelled with it. Before, only the
|
|
1018
1018
|
// agent's own `secrets:` counted, so an agent that granted desk_email and
|
|
1019
|
-
// declared nothing failed with "RESEND_API_KEY is not set" (
|
|
1019
|
+
// declared nothing failed with "RESEND_API_KEY is not set" (a customer desk,
|
|
1020
1020
|
// 1 Oct). `proxied` / `materialised` say how, not what, and add nothing.
|
|
1021
1021
|
const toolSecrets = [];
|
|
1022
1022
|
// Does any granted tool act outside the workspace (`outward: true`)? The
|
package/dist/src/scan.js
CHANGED
|
@@ -811,7 +811,7 @@ export function scanFiles(files, options = {}) {
|
|
|
811
811
|
// Frontmatter YAML cannot read. Until 2026-10-21 nothing reported it for
|
|
812
812
|
// a skill or a library file: frontOf below reads it as "no frontmatter"
|
|
813
813
|
// and moves on, and the deploy's own refusal covers only the workspace's
|
|
814
|
-
// definition files. A
|
|
814
|
+
// definition files. A customer's skill with `description: … (Markdown,
|
|
815
815
|
// frontmatter, ::: directives) …` deployed clean, ran (the runtime quotes
|
|
816
816
|
// the value and retries), and was refused by the source API on its next
|
|
817
817
|
// save.
|
package/dist/src/test-mode.d.ts
CHANGED
|
@@ -113,7 +113,7 @@ export declare function restoreDivertedDirs(wsRoot: string, before: Map<string,
|
|
|
113
113
|
* are kept under runs/<id>/test-writes/, never on the workspace, so each
|
|
114
114
|
* step's copy of the workspace gets them laid over it — otherwise a step
|
|
115
115
|
* reads the real files, and a flow that hands work on through storage/
|
|
116
|
-
* tests something no live run does: on 2026-10-03 a
|
|
116
|
+
* tests something no live run does: on 2026-10-03 a customer desk's test run's
|
|
117
117
|
* compiler wrote today's PASS report, and the mailer was handed the
|
|
118
118
|
* morning's FAIL one and refused. Times are kept, so a verify that asks
|
|
119
119
|
* whether a file is fresh answers for the step that wrote it. Returns the
|
package/dist/src/test-mode.js
CHANGED
|
@@ -288,7 +288,7 @@ export function restoreDivertedDirs(wsRoot, before, to, note, handed = before) {
|
|
|
288
288
|
* are kept under runs/<id>/test-writes/, never on the workspace, so each
|
|
289
289
|
* step's copy of the workspace gets them laid over it — otherwise a step
|
|
290
290
|
* reads the real files, and a flow that hands work on through storage/
|
|
291
|
-
* tests something no live run does: on 2026-10-03 a
|
|
291
|
+
* tests something no live run does: on 2026-10-03 a customer desk's test run's
|
|
292
292
|
* compiler wrote today's PASS report, and the mailer was handed the
|
|
293
293
|
* morning's FAIL one and refused. Times are kept, so a verify that asks
|
|
294
294
|
* whether a file is fresh answers for the step that wrote it. Returns the
|
package/package.json
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@foldrun/core",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.12.0",
|
|
4
4
|
"description": "Agents are just folders. The format, the runner, the checks.",
|
|
5
|
-
"license": "
|
|
5
|
+
"license": "FSL-1.1-ALv2",
|
|
6
6
|
"type": "module",
|
|
7
7
|
"exports": {
|
|
8
8
|
".": {
|
|
@@ -39,10 +39,11 @@
|
|
|
39
39
|
"src",
|
|
40
40
|
"index.ts",
|
|
41
41
|
"templates",
|
|
42
|
-
"README.md"
|
|
42
|
+
"README.md",
|
|
43
|
+
"LICENSING.md"
|
|
43
44
|
],
|
|
44
45
|
"scripts": {
|
|
45
|
-
"build": "tsc -p tsconfig.json",
|
|
46
|
+
"build": "node -e \"require('node:fs').rmSync('dist', { recursive: true, force: true })\" && tsc -p tsconfig.json",
|
|
46
47
|
"prepare": "npm run build && (test ! -f .githooks/install.sh || sh .githooks/install.sh)",
|
|
47
48
|
"prepublishOnly": "npm run build",
|
|
48
49
|
"test": "FOLDRUN_DATA=$(mktemp -d /tmp/foldrun-test-XXXXXX) node --test *.test.ts $(ls tests/*.test.ts | grep -v e2e)",
|
package/src/runner.ts
CHANGED
|
@@ -1157,7 +1157,7 @@ export function agentContext(
|
|
|
1157
1157
|
// EMAIL_FROM]`): granting the tool grants them, the way an API tool's
|
|
1158
1158
|
// referenced credentials always travelled with it. Before, only the
|
|
1159
1159
|
// agent's own `secrets:` counted, so an agent that granted desk_email and
|
|
1160
|
-
// declared nothing failed with "RESEND_API_KEY is not set" (
|
|
1160
|
+
// declared nothing failed with "RESEND_API_KEY is not set" (a customer desk,
|
|
1161
1161
|
// 1 Oct). `proxied` / `materialised` say how, not what, and add nothing.
|
|
1162
1162
|
const toolSecrets: string[] = [];
|
|
1163
1163
|
// Does any granted tool act outside the workspace (`outward: true`)? The
|
package/src/scan.ts
CHANGED
|
@@ -907,7 +907,7 @@ export function scanFiles(files: ScanFile[], options: ScanOptions = {}): ScanFin
|
|
|
907
907
|
// Frontmatter YAML cannot read. Until 2026-10-21 nothing reported it for
|
|
908
908
|
// a skill or a library file: frontOf below reads it as "no frontmatter"
|
|
909
909
|
// and moves on, and the deploy's own refusal covers only the workspace's
|
|
910
|
-
// definition files. A
|
|
910
|
+
// definition files. A customer's skill with `description: … (Markdown,
|
|
911
911
|
// frontmatter, ::: directives) …` deployed clean, ran (the runtime quotes
|
|
912
912
|
// the value and retries), and was refused by the source API on its next
|
|
913
913
|
// save.
|
package/src/test-mode.ts
CHANGED
|
@@ -314,7 +314,7 @@ export function restoreDivertedDirs(
|
|
|
314
314
|
* are kept under runs/<id>/test-writes/, never on the workspace, so each
|
|
315
315
|
* step's copy of the workspace gets them laid over it — otherwise a step
|
|
316
316
|
* reads the real files, and a flow that hands work on through storage/
|
|
317
|
-
* tests something no live run does: on 2026-10-03 a
|
|
317
|
+
* tests something no live run does: on 2026-10-03 a customer desk's test run's
|
|
318
318
|
* compiler wrote today's PASS report, and the mailer was handed the
|
|
319
319
|
* morning's FAIL one and refused. Times are kept, so a verify that asks
|
|
320
320
|
* whether a file is fresh answers for the step that wrote it. Returns the
|
package/dist/src/git-hook.d.ts
DELETED
|
@@ -1 +0,0 @@
|
|
|
1
|
-
export {};
|
package/dist/src/git-hook.js
DELETED
|
@@ -1,70 +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
|
-
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);
|
package/dist/src/git.d.ts
DELETED
|
@@ -1,37 +0,0 @@
|
|
|
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
DELETED
|
@@ -1,101 +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
|
-
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
|
-
}
|
package/dist/src/gitrepo.d.ts
DELETED
|
@@ -1,113 +0,0 @@
|
|
|
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
|
-
/**
|
|
39
|
-
* The commit a restore means by `to`, on main's history:
|
|
40
|
-
*
|
|
41
|
-
* a sha or a prefix of one that commit
|
|
42
|
-
* a branch or tag name where it points
|
|
43
|
-
* an ISO date or time the last commit on main at or before it
|
|
44
|
-
* "3d", "12h", "90m", "2w" the same, that long ago
|
|
45
|
-
*
|
|
46
|
-
* Null when nothing matches — before the first commit, or a name that is
|
|
47
|
-
* not there. Throws on a `to` that cannot be any of these, so a typo is a
|
|
48
|
-
* 400 rather than a silent "nothing found".
|
|
49
|
-
*/
|
|
50
|
-
export declare function resolveRestorePoint(tenant: string, scope: string, to: string, now?: number): {
|
|
51
|
-
sha: string;
|
|
52
|
-
at: string;
|
|
53
|
-
by: string;
|
|
54
|
-
message: string;
|
|
55
|
-
} | null;
|
|
56
|
-
/** The files at a commit, for a browser or a deploy. */
|
|
57
|
-
export declare function listTree(tenant: string, scope: string, ref?: string): {
|
|
58
|
-
path: string;
|
|
59
|
-
mode: string;
|
|
60
|
-
}[];
|
|
61
|
-
export declare function readBlob(tenant: string, scope: string, ref: string, p: string): string | null;
|
|
62
|
-
/** The whole tree at a ref as deploy files — what a push turns into a workspace. */
|
|
63
|
-
export declare function filesAt(tenant: string, scope: string, ref: string): {
|
|
64
|
-
path: string;
|
|
65
|
-
content: string;
|
|
66
|
-
}[];
|
|
67
|
-
/**
|
|
68
|
-
* What a ref would change on main: every file that differs, with both sides.
|
|
69
|
-
* The branch's own commits are `listCommits({ ref })`; this is the net
|
|
70
|
-
* effect, which is what a reviewer reads.
|
|
71
|
-
*/
|
|
72
|
-
export declare function diffRefs(tenant: string, scope: string, base: string, head: string): RevisionFile[];
|
|
73
|
-
/**
|
|
74
|
-
* Merge a branch into main and return the new main. A fast-forward when it
|
|
75
|
-
* is one; otherwise a merge commit whose tree is the BRANCH's — the branch
|
|
76
|
-
* is what the person reviewed and chose, and a three-way textual merge of
|
|
77
|
-
* markdown with no one to resolve conflicts would deploy text nobody wrote.
|
|
78
|
-
* Both parents are recorded, so history shows the merge for what it was.
|
|
79
|
-
*/
|
|
80
|
-
export declare function mergeBranch(tenant: string, scope: string, branch: string, meta?: {
|
|
81
|
-
by?: string;
|
|
82
|
-
}): {
|
|
83
|
-
sha: string;
|
|
84
|
-
fastForward: boolean;
|
|
85
|
-
};
|
|
86
|
-
export declare function deleteBranch(tenant: string, scope: string, branch: string): void;
|
|
87
|
-
/** Push main to another remote — a GitHub or GitLab mirror. Blocking; callers
|
|
88
|
-
* run it off the request path. The token never touches disk: it is in the
|
|
89
|
-
* URL for this one process and nowhere else. */
|
|
90
|
-
export declare function pushMirror(tenant: string, scope: string, url: string): {
|
|
91
|
-
ok: boolean;
|
|
92
|
-
detail: string;
|
|
93
|
-
};
|
|
94
|
-
/**
|
|
95
|
-
* Where the compiled pre-receive check is, found on disk rather than through
|
|
96
|
-
* require.resolve — a bundler rewrites require.resolve("pkg") into a module
|
|
97
|
-
* id, and the hook was once told to run a script called "51113". Searched
|
|
98
|
-
* from the working directory upward: the monorepo layout, then an installed
|
|
99
|
-
* package. Empty when there is no compiled check (a dev server on the source
|
|
100
|
-
* tree), in which case a push is checked at deploy instead of refused — the
|
|
101
|
-
* same rules, one step later.
|
|
102
|
-
*/
|
|
103
|
-
export declare function hookScriptPath(): string;
|
|
104
|
-
/**
|
|
105
|
-
* The pre-receive hook: a push that fails the deploy checks is refused with
|
|
106
|
-
* the errors in the pusher's terminal, instead of accepted and then not
|
|
107
|
-
* deployed. The hook shells out to node with the compiled check, so it runs
|
|
108
|
-
* the same rules as a dashboard deploy; which node and which script are
|
|
109
|
-
* passed in the environment by the HTTP route, because a hook inherits git's
|
|
110
|
-
* environment and nothing else.
|
|
111
|
-
*/
|
|
112
|
-
export declare function installHooks(dir: string): void;
|
|
113
|
-
export { type DiffOp };
|
package/dist/src/gitrepo.js
DELETED
|
@@ -1,404 +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
|
-
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
|
-
/**
|
|
211
|
-
* The commit a restore means by `to`, on main's history:
|
|
212
|
-
*
|
|
213
|
-
* a sha or a prefix of one that commit
|
|
214
|
-
* a branch or tag name where it points
|
|
215
|
-
* an ISO date or time the last commit on main at or before it
|
|
216
|
-
* "3d", "12h", "90m", "2w" the same, that long ago
|
|
217
|
-
*
|
|
218
|
-
* Null when nothing matches — before the first commit, or a name that is
|
|
219
|
-
* not there. Throws on a `to` that cannot be any of these, so a typo is a
|
|
220
|
-
* 400 rather than a silent "nothing found".
|
|
221
|
-
*/
|
|
222
|
-
export function resolveRestorePoint(tenant, scope, to, now = Date.now()) {
|
|
223
|
-
const t = to.trim();
|
|
224
|
-
if (!t || t.startsWith("-"))
|
|
225
|
-
throw Object.assign(new Error(`not a revision or a time: ${to}`), { status: 400 });
|
|
226
|
-
if (!headSha(tenant, scope))
|
|
227
|
-
return null;
|
|
228
|
-
const dir = repoDir(tenant, scope);
|
|
229
|
-
let sha = null;
|
|
230
|
-
const rel = /^(\d+)\s*(m|h|d|w)$/.exec(t);
|
|
231
|
-
const moment = rel
|
|
232
|
-
? now - Number(rel[1]) * { m: 60_000, h: 3_600_000, d: 86_400_000, w: 604_800_000 }[rel[2]]
|
|
233
|
-
: /^\d{4}-\d{2}-\d{2}/.test(t)
|
|
234
|
-
? Date.parse(t)
|
|
235
|
-
: null;
|
|
236
|
-
if (moment !== null) {
|
|
237
|
-
if (Number.isNaN(moment))
|
|
238
|
-
throw Object.assign(new Error(`not a date: ${to}`), { status: 400 });
|
|
239
|
-
sha = git(dir, ["log", "-1", "--format=%H", `--until=@${Math.floor(moment / 1000)}`, "refs/heads/main"]).trim() || null;
|
|
240
|
-
}
|
|
241
|
-
else {
|
|
242
|
-
if (!/^[A-Za-z0-9][A-Za-z0-9._/-]{0,120}$/.test(t))
|
|
243
|
-
throw Object.assign(new Error(`not a revision or a time: ${to}`), { status: 400 });
|
|
244
|
-
for (const ref of [/^[0-9a-f]{4,40}$/.test(t) ? `${t}^{commit}` : null, `refs/heads/${t}^{commit}`, `refs/tags/${t}^{commit}`]) {
|
|
245
|
-
if (!ref)
|
|
246
|
-
continue;
|
|
247
|
-
const r = spawnSync("git", ["--git-dir", dir, "rev-parse", "--verify", "--quiet", ref]);
|
|
248
|
-
if (r.status === 0) {
|
|
249
|
-
sha = r.stdout.toString().trim();
|
|
250
|
-
break;
|
|
251
|
-
}
|
|
252
|
-
}
|
|
253
|
-
}
|
|
254
|
-
if (!sha)
|
|
255
|
-
return null;
|
|
256
|
-
const [at, by, ...msg] = git(dir, ["log", "-1", `--format=%cI${SEP}%ae${SEP}%s`, sha]).trim().split(SEP);
|
|
257
|
-
return { sha, at, by: by.endsWith("@foldrun") ? by.replace(/@foldrun$/, "") : by, message: msg.join(SEP) };
|
|
258
|
-
}
|
|
259
|
-
/** The files at a commit, for a browser or a deploy. */
|
|
260
|
-
export function listTree(tenant, scope, ref = "refs/heads/main") {
|
|
261
|
-
if (!headSha(tenant, scope, ref))
|
|
262
|
-
return [];
|
|
263
|
-
return git(repoDir(tenant, scope), ["ls-tree", "-r", "--format=%(objectmode) %(path)", ref])
|
|
264
|
-
.split("\n")
|
|
265
|
-
.filter(Boolean)
|
|
266
|
-
.map((l) => {
|
|
267
|
-
const [mode, ...rest] = l.split(" ");
|
|
268
|
-
return { mode, path: rest.join(" ") };
|
|
269
|
-
});
|
|
270
|
-
}
|
|
271
|
-
export function readBlob(tenant, scope, ref, p) {
|
|
272
|
-
if (!repoExists(tenant, scope))
|
|
273
|
-
return null;
|
|
274
|
-
return blob(repoDir(tenant, scope), ref, p);
|
|
275
|
-
}
|
|
276
|
-
/** The whole tree at a ref as deploy files — what a push turns into a workspace. */
|
|
277
|
-
export function filesAt(tenant, scope, ref) {
|
|
278
|
-
const dir = repoDir(tenant, scope);
|
|
279
|
-
return listTree(tenant, scope, ref).flatMap((t) => {
|
|
280
|
-
const content = blob(dir, ref, t.path);
|
|
281
|
-
return content === null ? [] : [{ path: t.path, content }];
|
|
282
|
-
});
|
|
283
|
-
}
|
|
284
|
-
/**
|
|
285
|
-
* What a ref would change on main: every file that differs, with both sides.
|
|
286
|
-
* The branch's own commits are `listCommits({ ref })`; this is the net
|
|
287
|
-
* effect, which is what a reviewer reads.
|
|
288
|
-
*/
|
|
289
|
-
export function diffRefs(tenant, scope, base, head) {
|
|
290
|
-
if (!headSha(tenant, scope, base) || !headSha(tenant, scope, head))
|
|
291
|
-
return [];
|
|
292
|
-
const dir = repoDir(tenant, scope);
|
|
293
|
-
const out = git(dir, ["diff", "--name-status", `${base}...${head}`]);
|
|
294
|
-
return out.split("\n").filter(Boolean).map((line) => {
|
|
295
|
-
const [status, p] = line.split("\t");
|
|
296
|
-
return {
|
|
297
|
-
path: p,
|
|
298
|
-
before: status === "A" ? null : blob(dir, base, p),
|
|
299
|
-
after: status === "D" ? null : blob(dir, head, p),
|
|
300
|
-
};
|
|
301
|
-
});
|
|
302
|
-
}
|
|
303
|
-
/**
|
|
304
|
-
* Merge a branch into main and return the new main. A fast-forward when it
|
|
305
|
-
* is one; otherwise a merge commit whose tree is the BRANCH's — the branch
|
|
306
|
-
* is what the person reviewed and chose, and a three-way textual merge of
|
|
307
|
-
* markdown with no one to resolve conflicts would deploy text nobody wrote.
|
|
308
|
-
* Both parents are recorded, so history shows the merge for what it was.
|
|
309
|
-
*/
|
|
310
|
-
export function mergeBranch(tenant, scope, branch, meta = {}) {
|
|
311
|
-
const dir = repoDir(tenant, scope);
|
|
312
|
-
const main = headSha(tenant, scope);
|
|
313
|
-
const head = headSha(tenant, scope, `refs/heads/${safe(branch, "branch")}`);
|
|
314
|
-
if (!head)
|
|
315
|
-
throw new Error(`no branch "${branch}"`);
|
|
316
|
-
if (!main) {
|
|
317
|
-
git(dir, ["update-ref", "refs/heads/main", head]);
|
|
318
|
-
return { sha: head, fastForward: true };
|
|
319
|
-
}
|
|
320
|
-
const isAncestor = spawnSync("git", ["--git-dir", dir, "merge-base", "--is-ancestor", main, head]).status === 0;
|
|
321
|
-
if (isAncestor) {
|
|
322
|
-
git(dir, ["update-ref", "refs/heads/main", head, main]);
|
|
323
|
-
return { sha: head, fastForward: true };
|
|
324
|
-
}
|
|
325
|
-
const tree = git(dir, ["rev-parse", `${head}^{tree}`]).trim();
|
|
326
|
-
const who = meta.by ?? "foldrun";
|
|
327
|
-
const email = who.includes("@") ? who : `${who}@foldrun`;
|
|
328
|
-
const name = who.includes("@") ? who.split("@")[0] : who;
|
|
329
|
-
const date = new Date().toISOString();
|
|
330
|
-
const sha = git(dir, ["commit-tree", tree, "-p", main, "-p", head, "-m", `merge ${branch} into main`], {
|
|
331
|
-
env: {
|
|
332
|
-
GIT_AUTHOR_NAME: name, GIT_AUTHOR_EMAIL: email, GIT_AUTHOR_DATE: date,
|
|
333
|
-
GIT_COMMITTER_NAME: "foldrun", GIT_COMMITTER_EMAIL: "platform@foldrun", GIT_COMMITTER_DATE: date,
|
|
334
|
-
},
|
|
335
|
-
}).trim();
|
|
336
|
-
git(dir, ["update-ref", "refs/heads/main", sha, main]);
|
|
337
|
-
return { sha, fastForward: false };
|
|
338
|
-
}
|
|
339
|
-
export function deleteBranch(tenant, scope, branch) {
|
|
340
|
-
if (branch === "main")
|
|
341
|
-
throw new Error("main is the default branch");
|
|
342
|
-
git(repoDir(tenant, scope), ["update-ref", "-d", `refs/heads/${safe(branch, "branch")}`]);
|
|
343
|
-
}
|
|
344
|
-
/** Push main to another remote — a GitHub or GitLab mirror. Blocking; callers
|
|
345
|
-
* run it off the request path. The token never touches disk: it is in the
|
|
346
|
-
* URL for this one process and nowhere else. */
|
|
347
|
-
export function pushMirror(tenant, scope, url) {
|
|
348
|
-
if (!headSha(tenant, scope))
|
|
349
|
-
return { ok: false, detail: "nothing to push yet" };
|
|
350
|
-
const r = spawnSync("git", ["--git-dir", repoDir(tenant, scope), "push", "--quiet", url, "+refs/heads/main:refs/heads/main"], {
|
|
351
|
-
env: { ...process.env, GIT_TERMINAL_PROMPT: "0" },
|
|
352
|
-
timeout: 60_000,
|
|
353
|
-
});
|
|
354
|
-
const detail = (r.stderr?.toString() ?? "").trim()
|
|
355
|
-
// Never echo the URL back: it carries the token.
|
|
356
|
-
.replaceAll(url, "<mirror>")
|
|
357
|
-
.slice(0, 500);
|
|
358
|
-
return { ok: r.status === 0, detail: r.status === 0 ? "pushed" : detail || "push failed" };
|
|
359
|
-
}
|
|
360
|
-
/**
|
|
361
|
-
* Where the compiled pre-receive check is, found on disk rather than through
|
|
362
|
-
* require.resolve — a bundler rewrites require.resolve("pkg") into a module
|
|
363
|
-
* id, and the hook was once told to run a script called "51113". Searched
|
|
364
|
-
* from the working directory upward: the monorepo layout, then an installed
|
|
365
|
-
* package. Empty when there is no compiled check (a dev server on the source
|
|
366
|
-
* tree), in which case a push is checked at deploy instead of refused — the
|
|
367
|
-
* same rules, one step later.
|
|
368
|
-
*/
|
|
369
|
-
export function hookScriptPath() {
|
|
370
|
-
let dir = process.cwd();
|
|
371
|
-
for (let i = 0; i < 6; i++) {
|
|
372
|
-
for (const rel of ["packages/core/dist/src/git-hook.js", "node_modules/@foldrun/core/dist/src/git-hook.js"]) {
|
|
373
|
-
const candidate = path.join(dir, rel);
|
|
374
|
-
if (fs.existsSync(candidate))
|
|
375
|
-
return candidate;
|
|
376
|
-
}
|
|
377
|
-
const parent = path.dirname(dir);
|
|
378
|
-
if (parent === dir)
|
|
379
|
-
break;
|
|
380
|
-
dir = parent;
|
|
381
|
-
}
|
|
382
|
-
return "";
|
|
383
|
-
}
|
|
384
|
-
/**
|
|
385
|
-
* The pre-receive hook: a push that fails the deploy checks is refused with
|
|
386
|
-
* the errors in the pusher's terminal, instead of accepted and then not
|
|
387
|
-
* deployed. The hook shells out to node with the compiled check, so it runs
|
|
388
|
-
* the same rules as a dashboard deploy; which node and which script are
|
|
389
|
-
* passed in the environment by the HTTP route, because a hook inherits git's
|
|
390
|
-
* environment and nothing else.
|
|
391
|
-
*/
|
|
392
|
-
export function installHooks(dir) {
|
|
393
|
-
const hooks = path.join(dir, "hooks");
|
|
394
|
-
fs.mkdirSync(hooks, { recursive: true });
|
|
395
|
-
const hook = path.join(hooks, "pre-receive");
|
|
396
|
-
const script = `#!/bin/sh
|
|
397
|
-
# Installed by foldrun. Refuses a push to main whose tree fails the checks.
|
|
398
|
-
[ -n "$FOLDRUN_HOOK_NODE" ] && [ -n "$FOLDRUN_HOOK_SCRIPT" ] || exit 0
|
|
399
|
-
exec "$FOLDRUN_HOOK_NODE" "$FOLDRUN_HOOK_SCRIPT"
|
|
400
|
-
`;
|
|
401
|
-
if (!fs.existsSync(hook) || fs.readFileSync(hook, "utf8") !== script) {
|
|
402
|
-
fs.writeFileSync(hook, script, { mode: 0o755 });
|
|
403
|
-
}
|
|
404
|
-
}
|
package/dist/src/tar.d.ts
DELETED
|
@@ -1,30 +0,0 @@
|
|
|
1
|
-
export interface TarEntry {
|
|
2
|
-
path: string;
|
|
3
|
-
content: Buffer;
|
|
4
|
-
}
|
|
5
|
-
/**
|
|
6
|
-
* Every regular file in a gzipped tarball.
|
|
7
|
-
*
|
|
8
|
-
* Directories, symlinks, and pax/global headers are skipped: a workspace is
|
|
9
|
-
* files, and following a link out of the archive is exactly the thing a deploy
|
|
10
|
-
* from an untrusted repository must not do.
|
|
11
|
-
*/
|
|
12
|
-
export declare function readTarGz(gz: Buffer): TarEntry[];
|
|
13
|
-
/**
|
|
14
|
-
* Drop the single wrapper directory an archive is rooted at.
|
|
15
|
-
*
|
|
16
|
-
* GitHub tarballs are rooted at `owner-repo-<sha>/`, which is not part of
|
|
17
|
-
* anyone's workspace. Stripped only when *every* entry shares one first
|
|
18
|
-
* segment, so an archive that is already flat is left alone.
|
|
19
|
-
*/
|
|
20
|
-
export declare function stripRoot(entries: TarEntry[]): TarEntry[];
|
|
21
|
-
/**
|
|
22
|
-
* Read a tarball as the file list a deploy ships.
|
|
23
|
-
*
|
|
24
|
-
* `subdir` picks a workspace living inside a larger repository — the common
|
|
25
|
-
* case, since a repo usually holds more than one thing.
|
|
26
|
-
*/
|
|
27
|
-
export declare function filesFromTarball(gz: Buffer, subdir?: string): {
|
|
28
|
-
path: string;
|
|
29
|
-
content: string;
|
|
30
|
-
}[];
|
package/dist/src/tar.js
DELETED
|
@@ -1,112 +0,0 @@
|
|
|
1
|
-
// Reading a .tar.gz, so a deploy can come from a GitHub tarball.
|
|
2
|
-
//
|
|
3
|
-
// `GET /repos/{owner}/{repo}/tarball/{sha}` is one HTTP call and needs no git
|
|
4
|
-
// binary, no clone, and no working copy on the server — which is the whole
|
|
5
|
-
// reason a markdown workspace can deploy from a webhook in a few hundred
|
|
6
|
-
// milliseconds. What it returns is a tar.gz, and Node has gzip but not tar.
|
|
7
|
-
//
|
|
8
|
-
// Deliberately dependency-free, for the same reason the cron parser is: tar is
|
|
9
|
-
// 512-byte blocks with an octal size field, and a reader is shorter than the
|
|
10
|
-
// argument about which library to add.
|
|
11
|
-
import zlib from "node:zlib";
|
|
12
|
-
import { trimSlashes } from "./paths.js";
|
|
13
|
-
const BLOCK = 512;
|
|
14
|
-
const str = (b, off, len) => {
|
|
15
|
-
const raw = b.subarray(off, off + len);
|
|
16
|
-
const end = raw.indexOf(0);
|
|
17
|
-
return raw.subarray(0, end === -1 ? raw.length : end).toString("utf8");
|
|
18
|
-
};
|
|
19
|
-
// Sizes are octal, space- or NUL-padded. GNU also writes base-256 for large
|
|
20
|
-
// files, flagged by the high bit — irrelevant for markdown, but a silent
|
|
21
|
-
// misparse would be worse than a clear refusal.
|
|
22
|
-
function size(b, off) {
|
|
23
|
-
if (b[off] & 0x80)
|
|
24
|
-
throw new Error("tar: base-256 size field is not supported");
|
|
25
|
-
const raw = str(b, off, 12).trim();
|
|
26
|
-
return raw ? parseInt(raw, 8) : 0;
|
|
27
|
-
}
|
|
28
|
-
/**
|
|
29
|
-
* Every regular file in a gzipped tarball.
|
|
30
|
-
*
|
|
31
|
-
* Directories, symlinks, and pax/global headers are skipped: a workspace is
|
|
32
|
-
* files, and following a link out of the archive is exactly the thing a deploy
|
|
33
|
-
* from an untrusted repository must not do.
|
|
34
|
-
*/
|
|
35
|
-
export function readTarGz(gz) {
|
|
36
|
-
const buf = zlib.gunzipSync(gz);
|
|
37
|
-
const out = [];
|
|
38
|
-
let longName = null;
|
|
39
|
-
for (let off = 0; off + BLOCK <= buf.length;) {
|
|
40
|
-
const header = buf.subarray(off, off + BLOCK);
|
|
41
|
-
// Two consecutive zero blocks end the archive; one is enough to stop.
|
|
42
|
-
if (header.every((b) => b === 0))
|
|
43
|
-
break;
|
|
44
|
-
const name = str(header, 0, 100);
|
|
45
|
-
const bytes = size(header, 124);
|
|
46
|
-
const type = String.fromCharCode(header[156]) || "0";
|
|
47
|
-
const prefix = str(header, 345, 155);
|
|
48
|
-
off += BLOCK;
|
|
49
|
-
const body = buf.subarray(off, off + bytes);
|
|
50
|
-
off += Math.ceil(bytes / BLOCK) * BLOCK;
|
|
51
|
-
// GNU long name: the next header's name lives in this entry's body.
|
|
52
|
-
if (type === "L") {
|
|
53
|
-
longName = body.toString("utf8").replace(/\0+$/, "");
|
|
54
|
-
continue;
|
|
55
|
-
}
|
|
56
|
-
if (type !== "0" && type !== "\0") {
|
|
57
|
-
longName = null;
|
|
58
|
-
continue;
|
|
59
|
-
}
|
|
60
|
-
const full = longName ?? (prefix ? `${prefix}/${name}` : name);
|
|
61
|
-
longName = null;
|
|
62
|
-
if (full)
|
|
63
|
-
out.push({ path: full, content: Buffer.from(body) });
|
|
64
|
-
}
|
|
65
|
-
return out;
|
|
66
|
-
}
|
|
67
|
-
/**
|
|
68
|
-
* Drop the single wrapper directory an archive is rooted at.
|
|
69
|
-
*
|
|
70
|
-
* GitHub tarballs are rooted at `owner-repo-<sha>/`, which is not part of
|
|
71
|
-
* anyone's workspace. Stripped only when *every* entry shares one first
|
|
72
|
-
* segment, so an archive that is already flat is left alone.
|
|
73
|
-
*/
|
|
74
|
-
export function stripRoot(entries) {
|
|
75
|
-
if (entries.length === 0)
|
|
76
|
-
return entries;
|
|
77
|
-
const first = entries[0].path.split("/")[0];
|
|
78
|
-
if (!entries.every((e) => e.path.split("/")[0] === first && e.path.includes("/"))) {
|
|
79
|
-
return entries;
|
|
80
|
-
}
|
|
81
|
-
return entries.map((e) => ({ ...e, path: e.path.slice(first.length + 1) }));
|
|
82
|
-
}
|
|
83
|
-
/**
|
|
84
|
-
* Read a tarball as the file list a deploy ships.
|
|
85
|
-
*
|
|
86
|
-
* `subdir` picks a workspace living inside a larger repository — the common
|
|
87
|
-
* case, since a repo usually holds more than one thing.
|
|
88
|
-
*/
|
|
89
|
-
export function filesFromTarball(gz, subdir = "") {
|
|
90
|
-
const prefix = trimSlashes(subdir, "both");
|
|
91
|
-
const out = [];
|
|
92
|
-
for (const entry of stripRoot(readTarGz(gz))) {
|
|
93
|
-
let rel = entry.path;
|
|
94
|
-
if (prefix) {
|
|
95
|
-
if (!rel.startsWith(`${prefix}/`))
|
|
96
|
-
continue;
|
|
97
|
-
rel = rel.slice(prefix.length + 1);
|
|
98
|
-
}
|
|
99
|
-
// A tarball is untrusted input: an entry can name anything at all, and
|
|
100
|
-
// `../` in an archive is the oldest way to write outside the destination.
|
|
101
|
-
if (rel.split("/").includes("..") || rel.startsWith("/"))
|
|
102
|
-
continue;
|
|
103
|
-
// AppleDouble sidecars. macOS `tar` writes a `._name` beside every file to
|
|
104
|
-
// carry metadata no other system wants; they are not part of anyone's
|
|
105
|
-
// workspace, and letting one through means a deploy rejected for shipping
|
|
106
|
-
// a file the author never wrote.
|
|
107
|
-
if ((rel.split("/").pop() ?? "").startsWith("._"))
|
|
108
|
-
continue;
|
|
109
|
-
out.push({ path: rel, content: entry.content.toString("utf8") });
|
|
110
|
-
}
|
|
111
|
-
return out;
|
|
112
|
-
}
|