@fungi.computer/bonsai 0.1.0 → 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +73 -0
- package/README.md +66 -12
- package/backend/build-workflow.ts +137 -0
- package/backend/index.ts +13 -7
- package/backend/methods.ts +209 -19
- package/package.json +54 -18
- package/scripts/release.mjs +12 -1
- package/scripts/shed-terminal-proof.mjs +10 -8
- package/shared/contract.ts +41 -0
- package/test/apps-build-install.test.ts +399 -26
- package/test/browser/build-workflow.browser.mjs +104 -0
- package/test/browser/repository-browser.fixture.mjs +84 -0
- package/test/browser/repository-settings-fixture.tsx +198 -2
- package/test/browser/repository-settings.browser.mjs +188 -26
- package/test/checks-workflow-template.test.mjs +12 -10
- package/test/favicon.test.mjs +25 -0
- package/test/git-push.native.mjs +30 -0
- package/test/git-push.worker.ts +51 -3
- package/test/merge-template-build.test.mjs +44 -201
- package/test/storyboard-save-https-git.fixture.mjs +35 -29
- package/test/storyboard-save-template.test.mjs +61 -12
- package/vite.config.ts +19 -1
- package/web/app-build-panel.tsx +183 -95
- package/web/bonsai.css +1 -0
- package/web/index.html +0 -1
- package/web/repo-settings.tsx +1 -1
- package/web/public/favicon.svg +0 -1
package/test/git-push.worker.ts
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import * as S from "effect/Schema";
|
|
2
|
+
import { repositoryWorkflowSourceSchema } from "@fungi.computer/workflows-wire";
|
|
1
3
|
import {
|
|
2
4
|
ReadCopyBudgetExceeded,
|
|
3
5
|
countingGit,
|
|
@@ -8,7 +10,7 @@ import {
|
|
|
8
10
|
type AppGitPushFact,
|
|
9
11
|
} from "@fungi.computer/agar/git";
|
|
10
12
|
import type { StandardWebhookPayload } from "@fungi.computer/oyster/git";
|
|
11
|
-
import { BonsaiApp } from "../backend/index.js";
|
|
13
|
+
import { BonsaiApp, type BonsaiEnv } from "../backend/index.js";
|
|
12
14
|
import type { GitBinding } from "../backend/git.js";
|
|
13
15
|
import type { HookEvent } from "../shared/hooks.js";
|
|
14
16
|
|
|
@@ -128,6 +130,7 @@ function growingGit(large: () => boolean): GitBinding {
|
|
|
128
130
|
}
|
|
129
131
|
|
|
130
132
|
export class BonsaiPushProbe extends BonsaiApp {
|
|
133
|
+
private readonly workflowRequests: unknown[];
|
|
131
134
|
private readonly providerCalls: () => number;
|
|
132
135
|
private readonly delivery: { failNext: boolean };
|
|
133
136
|
private readonly emitted: {
|
|
@@ -162,8 +165,44 @@ export class BonsaiPushProbe extends BonsaiApp {
|
|
|
162
165
|
limit: 10_000,
|
|
163
166
|
signal: lifetime.signal,
|
|
164
167
|
});
|
|
165
|
-
|
|
168
|
+
const workflowRequests: unknown[] = [];
|
|
169
|
+
const unsupported = () =>
|
|
170
|
+
Promise.reject(
|
|
171
|
+
new Error("Unexpected APPS operation in lifecycle handoff proof"),
|
|
172
|
+
);
|
|
173
|
+
const bindings: BonsaiEnv = {
|
|
166
174
|
GIT: counted.git,
|
|
175
|
+
APPS: {
|
|
176
|
+
register: unsupported,
|
|
177
|
+
fork: unsupported,
|
|
178
|
+
build: unsupported,
|
|
179
|
+
buildStatus: unsupported,
|
|
180
|
+
select: unsupported,
|
|
181
|
+
// Only pond is already registered. Reading this source must not build.
|
|
182
|
+
repository: ({ repo }: { repo: string }) =>
|
|
183
|
+
Promise.resolve(
|
|
184
|
+
repo === "pond"
|
|
185
|
+
? S.decodeUnknownSync(repositoryWorkflowSourceSchema)({
|
|
186
|
+
appId: "app_pond",
|
|
187
|
+
installationId: "bonsai",
|
|
188
|
+
callerAppId: "app_bonsai",
|
|
189
|
+
callerSourceRevision: "git:" + "a".repeat(40),
|
|
190
|
+
})
|
|
191
|
+
: null,
|
|
192
|
+
),
|
|
193
|
+
},
|
|
194
|
+
WORKFLOWS: {
|
|
195
|
+
runs: unused,
|
|
196
|
+
graphStatus: unused,
|
|
197
|
+
runGraph: (input) => {
|
|
198
|
+
workflowRequests.push(input);
|
|
199
|
+
return Promise.resolve({
|
|
200
|
+
kind: "started",
|
|
201
|
+
runId: "run_main",
|
|
202
|
+
planDigest: "sha256:" + "d".repeat(64),
|
|
203
|
+
});
|
|
204
|
+
},
|
|
205
|
+
},
|
|
167
206
|
EVENTS: {
|
|
168
207
|
emit: (type, payload, eventId) => {
|
|
169
208
|
if (delivery.failNext) {
|
|
@@ -174,7 +213,9 @@ export class BonsaiPushProbe extends BonsaiApp {
|
|
|
174
213
|
return Promise.resolve();
|
|
175
214
|
},
|
|
176
215
|
},
|
|
177
|
-
}
|
|
216
|
+
};
|
|
217
|
+
super(ctx, bindings);
|
|
218
|
+
this.workflowRequests = workflowRequests;
|
|
178
219
|
this.providerCalls = counted.total;
|
|
179
220
|
this.emitted = emitted;
|
|
180
221
|
this.delivery = delivery;
|
|
@@ -224,6 +265,9 @@ export class BonsaiPushProbe extends BonsaiApp {
|
|
|
224
265
|
};
|
|
225
266
|
}
|
|
226
267
|
}
|
|
268
|
+
workflowStarts(): Promise<string> {
|
|
269
|
+
return Promise.resolve(JSON.stringify(this.workflowRequests));
|
|
270
|
+
}
|
|
227
271
|
calls(): Promise<number> {
|
|
228
272
|
return Promise.resolve(this.providerCalls());
|
|
229
273
|
}
|
|
@@ -252,6 +296,10 @@ export default {
|
|
|
252
296
|
): Promise<Response> {
|
|
253
297
|
const url = new URL(request.url);
|
|
254
298
|
const probe = env.PROBE.getByName(url.searchParams.get("probe") ?? "push");
|
|
299
|
+
if (url.pathname === "/workflow-starts")
|
|
300
|
+
return new Response(await probe.workflowStarts(), {
|
|
301
|
+
headers: { "Content-Type": "application/json" },
|
|
302
|
+
});
|
|
255
303
|
if (url.pathname === "/grow") return Response.json(await probe.grow());
|
|
256
304
|
if (url.pathname === "/calls")
|
|
257
305
|
return Response.json({ calls: await probe.calls() });
|
|
@@ -1,234 +1,77 @@
|
|
|
1
|
+
import { URL } from "node:url";
|
|
1
2
|
import assert from "node:assert/strict";
|
|
2
3
|
import { readFile } from "node:fs/promises";
|
|
3
4
|
import { test } from "node:test";
|
|
4
|
-
import { URL } from "node:url";
|
|
5
|
-
import { Script } from "node:vm";
|
|
6
5
|
import * as S from "effect/Schema";
|
|
7
|
-
import * as z from "zod";
|
|
8
6
|
import {
|
|
9
|
-
catalogEvents,
|
|
10
7
|
catalogMethods,
|
|
11
8
|
compile,
|
|
12
|
-
triggerRun,
|
|
13
9
|
validate,
|
|
14
10
|
workflowDefinitionSchema,
|
|
15
11
|
} from "@fungi.computer/braid";
|
|
16
12
|
import { buildAppApi } from "@fungi.computer/agar/backend-declaration";
|
|
17
|
-
import {
|
|
18
|
-
import { appBackend as bonsaiBackend } from "../backend/methods.ts";
|
|
19
|
-
const bonsaiApi = buildAppApi(bonsaiBackend);
|
|
13
|
+
import { appBackend } from "../backend/methods.ts";
|
|
20
14
|
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
await readFile(
|
|
28
|
-
new URL(
|
|
29
|
-
"../../trellis/templates/bonsai-build-on-merge.json",
|
|
30
|
-
import.meta.url,
|
|
31
|
-
),
|
|
32
|
-
"utf8",
|
|
33
|
-
),
|
|
15
|
+
async function template() {
|
|
16
|
+
const file = JSON.parse(
|
|
17
|
+
await readFile(
|
|
18
|
+
new URL(
|
|
19
|
+
"../../trellis/templates/bonsai-build-on-merge.json",
|
|
20
|
+
import.meta.url,
|
|
34
21
|
),
|
|
35
|
-
|
|
36
|
-
|
|
22
|
+
"utf8",
|
|
23
|
+
),
|
|
24
|
+
);
|
|
25
|
+
return S.decodeUnknownSync(workflowDefinitionSchema)(file.definition);
|
|
37
26
|
}
|
|
38
27
|
|
|
39
|
-
async
|
|
40
|
-
|
|
28
|
+
test("the lifecycle build template compiles against Bonsai's maintained method declarations", async () => {
|
|
29
|
+
const definition = await template();
|
|
30
|
+
const catalog = {
|
|
41
31
|
app_bonsai: {
|
|
42
|
-
methods: (await catalogMethods(
|
|
43
|
-
events: (await catalogEvents(bonsaiApi.events)).events,
|
|
32
|
+
methods: (await catalogMethods(buildAppApi(appBackend).methods)).methods,
|
|
44
33
|
},
|
|
45
34
|
};
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
function findNode(definition, id) {
|
|
49
|
-
const node = definition.nodes.find((candidate) => candidate.id === id);
|
|
50
|
-
assert.ok(node, `missing node ${id}`);
|
|
51
|
-
return node;
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
// Only the external Code execution boundary is replaced by Node VM here.
|
|
55
|
-
const evaluate = async (code, input) =>
|
|
56
|
-
z.json().parse(
|
|
57
|
-
await new Script(`(async () => { ${code} })()`).runInNewContext({
|
|
58
|
-
env: { INPUT: { read: () => Promise.resolve(input) } },
|
|
59
|
-
}),
|
|
60
|
-
);
|
|
61
|
-
|
|
62
|
-
const after = "c".repeat(40);
|
|
63
|
-
// The envelope Bonsai's outbox emits when a Change merges into `repo`.
|
|
64
|
-
function mergedEnvelope({ repo = "my-app", ref = "main" } = {}) {
|
|
65
|
-
return activityEnvelope(
|
|
66
|
-
{
|
|
67
|
-
id: 41,
|
|
68
|
-
at: Date.UTC(2026, 9, 15, 4),
|
|
69
|
-
type: "merge",
|
|
70
|
-
repo,
|
|
71
|
-
actor: "morel",
|
|
72
|
-
data: {
|
|
73
|
-
change: {
|
|
74
|
-
repo,
|
|
75
|
-
number: 3,
|
|
76
|
-
sourceRepo: `${repo}--morel`,
|
|
77
|
-
sourceRef: "edit",
|
|
78
|
-
targetRef: ref,
|
|
79
|
-
head: "b".repeat(40),
|
|
80
|
-
},
|
|
81
|
-
from: `${repo}--morel`,
|
|
82
|
-
ref,
|
|
83
|
-
before: "a".repeat(40),
|
|
84
|
-
after,
|
|
85
|
-
},
|
|
86
|
-
},
|
|
87
|
-
{
|
|
88
|
-
name: `${repo}--morel`,
|
|
89
|
-
canonical: repo,
|
|
90
|
-
cloneUrl: `https://bonsai.example/git/${repo}--morel.git`,
|
|
91
|
-
},
|
|
92
|
-
);
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
test("pins the build-on-merge template to Bonsai's maintained declarations", async () => {
|
|
96
|
-
const definition = await buildOnMergeTemplate();
|
|
97
|
-
const catalog = await installedCatalog();
|
|
98
35
|
assert.deepEqual(validate(definition, catalog), []);
|
|
99
36
|
const compiled = await compile(definition, catalog, {
|
|
100
37
|
bonsai: { appId: "app_bonsai", installationId: "bonsai" },
|
|
101
38
|
});
|
|
102
39
|
assert.ok(compiled.ok, JSON.stringify(compiled));
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
assert.doesNotMatch(text, /gitAccess|OYSTER_OWNER_KEY|Authorization|token/iu);
|
|
107
|
-
// The run starts the build of the merged commit, then waits on that job.
|
|
108
|
-
const build = findNode(definition, "build");
|
|
109
|
-
assert.ok(build.kind === "app");
|
|
110
|
-
assert.equal(build.method, "apps.build");
|
|
111
|
-
assert.deepEqual(build.values.sourceRevision, {
|
|
112
|
-
from: { node: "merged", port: "sourceRevision" },
|
|
113
|
-
});
|
|
114
|
-
const status = findNode(definition, "build-status");
|
|
115
|
-
assert.ok(status.kind === "app");
|
|
116
|
-
assert.equal(status.method, "apps.buildStatus");
|
|
117
|
-
assert.deepEqual(status.values.jobId, {
|
|
118
|
-
from: { node: "build", port: "jobId" },
|
|
119
|
-
});
|
|
120
|
-
assert.equal(
|
|
121
|
-
catalog.app_bonsai.methods["apps.buildStatus"].pending,
|
|
122
|
-
true,
|
|
123
|
-
"the Workflow waits on the job through a pending method",
|
|
40
|
+
assert.doesNotMatch(
|
|
41
|
+
JSON.stringify(definition),
|
|
42
|
+
/gitAccess|OYSTER_OWNER_KEY|Authorization|token/iu,
|
|
124
43
|
);
|
|
125
|
-
});
|
|
126
|
-
|
|
127
|
-
test("starts on a merge into the App's source main, at the merged commit", async () => {
|
|
128
|
-
const definition = await buildOnMergeTemplate();
|
|
129
|
-
const trigger = definition.triggers?.find(
|
|
130
|
-
(item) => item.on === "event" && item.event === "change.merged",
|
|
131
|
-
);
|
|
132
|
-
assert.ok(trigger);
|
|
133
|
-
const payload = mergedEnvelope();
|
|
134
|
-
const outcome = triggerRun(definition, trigger.id, {
|
|
135
|
-
eventId: payload.data.eventId,
|
|
136
|
-
payload,
|
|
137
|
-
});
|
|
138
|
-
assert.equal(outcome.kind, "start", JSON.stringify(outcome));
|
|
139
|
-
// The owner setting defaults to showing the Workflows review UI.
|
|
140
|
-
assert.equal(outcome.input.reviewBeforeInstall, true);
|
|
141
|
-
const merged = findNode(definition, "merged");
|
|
142
|
-
assert.ok(merged.kind === "code");
|
|
143
44
|
assert.deepEqual(
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
{ appId: outcome.input.appId, sourceRevision: `git:${after}` },
|
|
45
|
+
definition.nodes.map((node) =>
|
|
46
|
+
node.kind === "app" ? node.method : node.kind,
|
|
47
|
+
),
|
|
48
|
+
["apps.build", "apps.verifyBuild", "apps.install"],
|
|
149
49
|
);
|
|
150
|
-
|
|
151
|
-
const skipped = mergedEnvelope(other);
|
|
152
|
-
assert.deepEqual(
|
|
153
|
-
triggerRun(definition, trigger.id, {
|
|
154
|
-
eventId: skipped.data.eventId,
|
|
155
|
-
payload: skipped,
|
|
156
|
-
}),
|
|
157
|
-
{ kind: "ignored" },
|
|
158
|
-
JSON.stringify(other),
|
|
159
|
-
);
|
|
160
|
-
}
|
|
50
|
+
assert.equal(catalog.app_bonsai.methods["apps.verifyBuild"].pending, true);
|
|
161
51
|
});
|
|
162
52
|
|
|
163
|
-
test("
|
|
164
|
-
const definition = await
|
|
165
|
-
const
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
artifact: { provenance: { sourceRevision } },
|
|
172
|
-
};
|
|
173
|
-
assert.deepEqual(
|
|
174
|
-
await evaluate(built.source, {
|
|
175
|
-
status: "succeeded",
|
|
176
|
-
detail: null,
|
|
177
|
-
release,
|
|
178
|
-
sourceRevision,
|
|
179
|
-
}),
|
|
180
|
-
{
|
|
181
|
-
release,
|
|
182
|
-
summary: `Built rel_built of app_myapp from ${sourceRevision}`,
|
|
183
|
-
},
|
|
184
|
-
);
|
|
185
|
-
await assert.rejects(
|
|
186
|
-
evaluate(built.source, {
|
|
187
|
-
status: "failed",
|
|
188
|
-
detail: "tsc exited 2",
|
|
189
|
-
release: null,
|
|
190
|
-
sourceRevision,
|
|
191
|
-
}),
|
|
192
|
-
/The build did not pass: tsc exited 2/u,
|
|
193
|
-
);
|
|
194
|
-
await assert.rejects(
|
|
195
|
-
evaluate(built.source, {
|
|
196
|
-
status: "succeeded",
|
|
197
|
-
detail: null,
|
|
198
|
-
release,
|
|
199
|
-
sourceRevision: `git:${"d".repeat(40)}`,
|
|
200
|
-
}),
|
|
201
|
-
/not the merged commit/u,
|
|
202
|
-
);
|
|
203
|
-
});
|
|
204
|
-
|
|
205
|
-
test("routes the Release through review only when Review before install is on", async () => {
|
|
206
|
-
const definition = await buildOnMergeTemplate();
|
|
207
|
-
const gate = findNode(definition, "review-gate");
|
|
208
|
-
assert.ok(gate.kind === "condition");
|
|
209
|
-
assert.deepEqual(gate.test, {
|
|
210
|
-
op: "eq",
|
|
211
|
-
left: { from: { node: "$input", port: "reviewBeforeInstall" } },
|
|
212
|
-
right: { literal: true },
|
|
213
|
-
});
|
|
214
|
-
// The review is reached only from the gate's true outlet, automatic
|
|
215
|
-
// install only from its false one; install takes whichever ran.
|
|
216
|
-
const review = findNode(definition, "review");
|
|
217
|
-
assert.ok(review.kind === "review");
|
|
218
|
-
assert.deepEqual(review.show[0], { node: "to-review", port: "release" });
|
|
219
|
-
assert.deepEqual(findNode(definition, "to-review").fields.review, {
|
|
220
|
-
from: { node: "review-gate", port: "true" },
|
|
53
|
+
test("verification gates installation and the template neither configures nor promises review", async () => {
|
|
54
|
+
const definition = await template();
|
|
55
|
+
const verify = definition.nodes.find((node) => node.id === "verify");
|
|
56
|
+
const install = definition.nodes.find((node) => node.id === "install");
|
|
57
|
+
assert.ok(verify?.kind === "app");
|
|
58
|
+
assert.ok(install?.kind === "app");
|
|
59
|
+
assert.deepEqual(verify.values.jobId, {
|
|
60
|
+
from: { node: "build", port: "jobId" },
|
|
221
61
|
});
|
|
222
|
-
assert.deepEqual(
|
|
223
|
-
from: { node: "
|
|
62
|
+
assert.deepEqual(verify.values.sourceRevision, {
|
|
63
|
+
from: { node: "$input", port: "sourceRevision" },
|
|
224
64
|
});
|
|
225
|
-
const install = findNode(definition, "install");
|
|
226
|
-
assert.ok(install.kind === "app");
|
|
227
|
-
assert.equal(install.method, "apps.install");
|
|
228
65
|
assert.deepEqual(install.values.release, {
|
|
229
|
-
|
|
230
|
-
{ from: { node: "review", port: "approved" } },
|
|
231
|
-
{ from: { node: "automatic", port: "release" } },
|
|
232
|
-
],
|
|
66
|
+
from: { node: "verify", port: "release" },
|
|
233
67
|
});
|
|
68
|
+
assert.doesNotMatch(
|
|
69
|
+
JSON.stringify(definition),
|
|
70
|
+
/reviewBeforeInstall|review-gate|review before install|"kind":"review"/iu,
|
|
71
|
+
);
|
|
72
|
+
assert.equal(
|
|
73
|
+
definition.triggers,
|
|
74
|
+
undefined,
|
|
75
|
+
"the host lifecycle starts this template through WORKFLOWS, not a guest merge event",
|
|
76
|
+
);
|
|
234
77
|
});
|
|
@@ -2,45 +2,51 @@ import { Buffer } from "node:buffer";
|
|
|
2
2
|
import { execFileSync, spawn } from "node:child_process";
|
|
3
3
|
import { once } from "node:events";
|
|
4
4
|
import { createServer } from "node:https";
|
|
5
|
-
import { mkdtemp, readFile } from "node:fs/promises";
|
|
5
|
+
import { mkdtemp, readFile, rm } from "node:fs/promises";
|
|
6
6
|
import { tmpdir } from "node:os";
|
|
7
7
|
import { join } from "node:path";
|
|
8
8
|
import process from "node:process";
|
|
9
9
|
import { URL } from "node:url";
|
|
10
10
|
|
|
11
|
+
async function createCertificate() {
|
|
12
|
+
const certDir = await mkdtemp(join(tmpdir(), "storyboard-save-tls-"));
|
|
13
|
+
try {
|
|
14
|
+
const keyPath = join(certDir, "key.pem");
|
|
15
|
+
const certPath = join(certDir, "cert.pem");
|
|
16
|
+
execFileSync(
|
|
17
|
+
"openssl",
|
|
18
|
+
[
|
|
19
|
+
"req",
|
|
20
|
+
"-x509",
|
|
21
|
+
"-newkey",
|
|
22
|
+
"rsa:2048",
|
|
23
|
+
"-keyout",
|
|
24
|
+
keyPath,
|
|
25
|
+
"-out",
|
|
26
|
+
certPath,
|
|
27
|
+
"-days",
|
|
28
|
+
"1",
|
|
29
|
+
"-nodes",
|
|
30
|
+
"-subj",
|
|
31
|
+
"/CN=127.0.0.1",
|
|
32
|
+
],
|
|
33
|
+
{ stdio: ["ignore", "pipe", "pipe"] },
|
|
34
|
+
);
|
|
35
|
+
return { key: await readFile(keyPath), cert: await readFile(certPath) };
|
|
36
|
+
} finally {
|
|
37
|
+
await rm(certDir, { recursive: true, force: true });
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
11
41
|
/**
|
|
12
42
|
* A real git smart-HTTP server over real TLS (self-signed), proxying to the
|
|
13
43
|
* maintained `git http-backend` CGI program against a directory of real bare
|
|
14
|
-
* repositories.
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
* rather than a stand-in local path.
|
|
44
|
+
* repositories. The host-supplied $FUNGI_GIT_REMOTE is an `https:` remote;
|
|
45
|
+
* this gives Shed's real `run` handler and git's real client something
|
|
46
|
+
* genuine to fetch and push against, rather than a stand-in local path.
|
|
18
47
|
*/
|
|
19
48
|
export async function startHttpsGitServer(reposRoot) {
|
|
20
|
-
const
|
|
21
|
-
const keyPath = join(certDir, "key.pem");
|
|
22
|
-
const certPath = join(certDir, "cert.pem");
|
|
23
|
-
execFileSync(
|
|
24
|
-
"openssl",
|
|
25
|
-
[
|
|
26
|
-
"req",
|
|
27
|
-
"-x509",
|
|
28
|
-
"-newkey",
|
|
29
|
-
"rsa:2048",
|
|
30
|
-
"-keyout",
|
|
31
|
-
keyPath,
|
|
32
|
-
"-out",
|
|
33
|
-
certPath,
|
|
34
|
-
"-days",
|
|
35
|
-
"1",
|
|
36
|
-
"-nodes",
|
|
37
|
-
"-subj",
|
|
38
|
-
"/CN=127.0.0.1",
|
|
39
|
-
],
|
|
40
|
-
{ stdio: ["ignore", "pipe", "pipe"] },
|
|
41
|
-
);
|
|
42
|
-
const key = await readFile(keyPath);
|
|
43
|
-
const cert = await readFile(certPath);
|
|
49
|
+
const { key, cert } = await createCertificate();
|
|
44
50
|
function cgi(request, response) {
|
|
45
51
|
const url = new URL(request.url, "https://localhost");
|
|
46
52
|
const child = spawn("git", ["http-backend"], {
|
|
@@ -1,10 +1,16 @@
|
|
|
1
1
|
import assert from "node:assert/strict";
|
|
2
2
|
import { Buffer } from "node:buffer";
|
|
3
|
-
import { spawn } from "node:child_process";
|
|
4
|
-
import { execFile } from "node:child_process";
|
|
3
|
+
import { execFile, spawn } from "node:child_process";
|
|
5
4
|
import { dirname, join } from "node:path";
|
|
6
5
|
import { DatabaseSync } from "node:sqlite";
|
|
7
|
-
import {
|
|
6
|
+
import {
|
|
7
|
+
mkdir,
|
|
8
|
+
mkdtemp,
|
|
9
|
+
readFile,
|
|
10
|
+
readdir,
|
|
11
|
+
rm,
|
|
12
|
+
writeFile,
|
|
13
|
+
} from "node:fs/promises";
|
|
8
14
|
import { tmpdir } from "node:os";
|
|
9
15
|
import process from "node:process";
|
|
10
16
|
import { test } from "node:test";
|
|
@@ -24,7 +30,7 @@ import {
|
|
|
24
30
|
// Shed's, Prints', Inkcap's and Bonsai's sources are pinned at runtime only;
|
|
25
31
|
// Bonsai's compiler options do not own them.
|
|
26
32
|
import { buildAppApi } from "@fungi.computer/agar/backend-declaration";
|
|
27
|
-
import { mintComputerProcessId } from "@fungi.computer/computer
|
|
33
|
+
import { mintComputerProcessId } from "@fungi.computer/computer-contract";
|
|
28
34
|
import { appBackend as bonsaiBackend } from "../backend/methods.ts";
|
|
29
35
|
import { appBackend as shedBackend } from "../../shed/backend/methods.ts";
|
|
30
36
|
import { createShedRuns } from "../../shed/backend/runs.ts";
|
|
@@ -37,6 +43,50 @@ import {
|
|
|
37
43
|
|
|
38
44
|
const execFileAsync = promisify(execFile);
|
|
39
45
|
|
|
46
|
+
for (const startupFails of [false, true]) {
|
|
47
|
+
test(`HTTPS Git fixture removes certificate files after ${startupFails ? "startup failure" : "server close"}`, async (t) => {
|
|
48
|
+
const work = await mkdtemp(
|
|
49
|
+
join(tmpdir(), "storyboard-save-fixture-check-"),
|
|
50
|
+
);
|
|
51
|
+
t.after(() => rm(work, { recursive: true, force: true }));
|
|
52
|
+
const scratch = join(work, "tmp");
|
|
53
|
+
await mkdir(scratch);
|
|
54
|
+
const fixture = new URL(
|
|
55
|
+
"./storyboard-save-https-git.fixture.mjs",
|
|
56
|
+
import.meta.url,
|
|
57
|
+
);
|
|
58
|
+
const env = {
|
|
59
|
+
...process.env,
|
|
60
|
+
HOME: work,
|
|
61
|
+
TMPDIR: scratch,
|
|
62
|
+
TMP: scratch,
|
|
63
|
+
TEMP: scratch,
|
|
64
|
+
};
|
|
65
|
+
if (startupFails) env.OPENSSL_CONF = join(work, "missing-openssl.cnf");
|
|
66
|
+
// tmpdir() is process-local. Give the real producer its own environment,
|
|
67
|
+
// rather than changing the test runner's environment or mocking openssl.
|
|
68
|
+
await execFileAsync(
|
|
69
|
+
process.execPath,
|
|
70
|
+
[
|
|
71
|
+
"--input-type=module",
|
|
72
|
+
"--eval",
|
|
73
|
+
`
|
|
74
|
+
import assert from "node:assert/strict";
|
|
75
|
+
import { startHttpsGitServer } from ${JSON.stringify(fixture.href)};
|
|
76
|
+
const startup = startHttpsGitServer(${JSON.stringify(work)});
|
|
77
|
+
${
|
|
78
|
+
startupFails
|
|
79
|
+
? "await assert.rejects(startup, { status: 1 });"
|
|
80
|
+
: "const server = await startup; await server.close();"
|
|
81
|
+
}
|
|
82
|
+
`,
|
|
83
|
+
],
|
|
84
|
+
{ env },
|
|
85
|
+
);
|
|
86
|
+
assert.deepEqual(await readdir(scratch), []);
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
|
|
40
90
|
async function readTemplate(name) {
|
|
41
91
|
const template = z
|
|
42
92
|
.object({ definition: z.json() })
|
|
@@ -192,7 +242,13 @@ test("the Save to git flow runs through Shed's real run handler and Inkcap's rea
|
|
|
192
242
|
procs.set(id, record);
|
|
193
243
|
const child = spawn("bash", ["-c", localCommand], {
|
|
194
244
|
cwd: cwd ? localize(cwd) : undefined,
|
|
195
|
-
|
|
245
|
+
// The trusted host supplies the Git remote after admission; Shed
|
|
246
|
+
// and its command carry no credential.
|
|
247
|
+
env: {
|
|
248
|
+
...process.env,
|
|
249
|
+
GIT_SSL_NO_VERIFY: "true",
|
|
250
|
+
FUNGI_GIT_REMOTE: remote,
|
|
251
|
+
},
|
|
196
252
|
});
|
|
197
253
|
let stderr = "";
|
|
198
254
|
child.stderr.on("data", (chunk) => {
|
|
@@ -209,13 +265,6 @@ test("the Save to git flow runs through Shed's real run handler and Inkcap's rea
|
|
|
209
265
|
processes() {
|
|
210
266
|
return Promise.resolve({ processes: [...procs.values()] });
|
|
211
267
|
},
|
|
212
|
-
workflowGitPass() {
|
|
213
|
-
return Promise.resolve({
|
|
214
|
-
passId: "pass_test",
|
|
215
|
-
remote,
|
|
216
|
-
expiresAt: Date.now() + 300000,
|
|
217
|
-
});
|
|
218
|
-
},
|
|
219
268
|
async write({ path, content }) {
|
|
220
269
|
const target = localize(path);
|
|
221
270
|
await mkdir(dirname(target), { recursive: true });
|
package/vite.config.ts
CHANGED
|
@@ -2,12 +2,30 @@ import { fileURLToPath } from "node:url";
|
|
|
2
2
|
import tailwindcss from "@tailwindcss/vite";
|
|
3
3
|
import react from "@vitejs/plugin-react";
|
|
4
4
|
import { defineConfig } from "vite";
|
|
5
|
+
import { fungiFaviconHref } from "@fungi.computer/sigils/favicon";
|
|
5
6
|
|
|
6
7
|
export default defineConfig({
|
|
7
8
|
envDir: false,
|
|
8
9
|
base: "./",
|
|
9
10
|
root: fileURLToPath(new URL("./web", import.meta.url)),
|
|
10
|
-
plugins: [
|
|
11
|
+
plugins: [
|
|
12
|
+
react(),
|
|
13
|
+
tailwindcss(),
|
|
14
|
+
{
|
|
15
|
+
name: "fungi-favicon",
|
|
16
|
+
transformIndexHtml: () => [
|
|
17
|
+
{
|
|
18
|
+
tag: "link",
|
|
19
|
+
attrs: {
|
|
20
|
+
rel: "icon",
|
|
21
|
+
type: "image/svg+xml",
|
|
22
|
+
href: fungiFaviconHref,
|
|
23
|
+
},
|
|
24
|
+
injectTo: "head",
|
|
25
|
+
},
|
|
26
|
+
],
|
|
27
|
+
},
|
|
28
|
+
],
|
|
11
29
|
build: {
|
|
12
30
|
target: "es2022",
|
|
13
31
|
outDir: fileURLToPath(new URL("./.botanical/web", import.meta.url)),
|