@intentius/chant-lexicon-e2b 0.0.0-stage → 0.124.32
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/README.md +147 -2
- package/dist/catalog.d.ts +19 -0
- package/dist/catalog.d.ts.map +1 -0
- package/dist/catalog.js +58 -0
- package/dist/codegen/docs-cli.d.ts +3 -0
- package/dist/codegen/docs-cli.d.ts.map +1 -0
- package/dist/codegen/docs-cli.js +6 -0
- package/dist/codegen/docs.d.ts +15 -0
- package/dist/codegen/docs.d.ts.map +1 -0
- package/dist/codegen/docs.js +123 -0
- package/dist/codegen/generate-cli.d.ts +3 -0
- package/dist/codegen/generate-cli.d.ts.map +1 -0
- package/dist/codegen/generate-cli.js +4 -0
- package/dist/codegen/generate.d.ts +11 -0
- package/dist/codegen/generate.d.ts.map +1 -0
- package/dist/codegen/generate.js +20 -0
- package/dist/codegen/package.d.ts +10 -0
- package/dist/codegen/package.d.ts.map +1 -0
- package/dist/codegen/package.js +34 -0
- package/dist/config.d.ts +113 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/config.js +82 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +6 -0
- package/dist/integrity.json +15 -0
- package/dist/lint/audit-catalog.d.ts +11 -0
- package/dist/lint/audit-catalog.d.ts.map +1 -0
- package/dist/lint/audit-catalog.js +15 -0
- package/dist/lint/post-synth/e2b011-template-name.d.ts +12 -0
- package/dist/lint/post-synth/e2b011-template-name.d.ts.map +1 -0
- package/dist/lint/post-synth/e2b011-template-name.js +48 -0
- package/dist/lint/post-synth/e2b012-steward-tools.d.ts +20 -0
- package/dist/lint/post-synth/e2b012-steward-tools.d.ts.map +1 -0
- package/dist/lint/post-synth/e2b012-steward-tools.js +53 -0
- package/dist/lint/post-synth/index.d.ts +3 -0
- package/dist/lint/post-synth/index.d.ts.map +1 -0
- package/dist/lint/post-synth/index.js +6 -0
- package/dist/lint/rules/index.d.ts +6 -0
- package/dist/lint/rules/index.d.ts.map +1 -0
- package/dist/lint/rules/index.js +6 -0
- package/dist/lint/rules/pinned-image.d.ts +14 -0
- package/dist/lint/rules/pinned-image.d.ts.map +1 -0
- package/dist/lint/rules/pinned-image.js +36 -0
- package/dist/lint/rules/template-literal.d.ts +11 -0
- package/dist/lint/rules/template-literal.d.ts.map +1 -0
- package/dist/lint/rules/template-literal.js +39 -0
- package/dist/lint/rules/template-size.d.ts +10 -0
- package/dist/lint/rules/template-size.d.ts.map +1 -0
- package/dist/lint/rules/template-size.js +31 -0
- package/dist/lsp/completions.d.ts +7 -0
- package/dist/lsp/completions.d.ts.map +1 -0
- package/dist/lsp/completions.js +10 -0
- package/dist/lsp/hover.d.ts +7 -0
- package/dist/lsp/hover.d.ts.map +1 -0
- package/dist/lsp/hover.js +30 -0
- package/dist/manifest.json +6 -0
- package/dist/meta.json +45 -0
- package/dist/okf/index.md +14 -0
- package/dist/okf/rules/E2B001.md +15 -0
- package/dist/okf/rules/E2B002.md +15 -0
- package/dist/okf/rules/E2B011.md +15 -0
- package/dist/okf/rules/E2B012.md +15 -0
- package/dist/okf/types/Template.md +16 -0
- package/dist/op/runtime.d.ts +119 -0
- package/dist/op/runtime.d.ts.map +1 -0
- package/dist/op/runtime.js +522 -0
- package/dist/op/sandbox.d.ts +67 -0
- package/dist/op/sandbox.d.ts.map +1 -0
- package/dist/op/sandbox.js +87 -0
- package/dist/package-cli.d.ts +3 -0
- package/dist/package-cli.d.ts.map +1 -0
- package/dist/package-cli.js +14 -0
- package/dist/plugin.d.ts +12 -0
- package/dist/plugin.d.ts.map +1 -0
- package/dist/plugin.js +66 -0
- package/dist/resources.d.ts +38 -0
- package/dist/resources.d.ts.map +1 -0
- package/dist/resources.js +21 -0
- package/dist/rules/e2b011-template-name.ts +51 -0
- package/dist/rules/e2b012-steward-tools.ts +58 -0
- package/dist/rules/pinned-image.ts +41 -0
- package/dist/rules/template-literal.ts +42 -0
- package/dist/rules/template-size.ts +36 -0
- package/dist/serializer.d.ts +18 -0
- package/dist/serializer.d.ts.map +1 -0
- package/dist/serializer.js +70 -0
- package/dist/skills/chant-e2b-ops.md +69 -0
- package/dist/types/index.d.ts +1 -0
- package/package.json +67 -4
- package/src/catalog.ts +72 -0
- package/src/codegen/docs-cli.ts +7 -0
- package/src/codegen/docs.ts +130 -0
- package/src/codegen/generate-cli.ts +5 -0
- package/src/codegen/generate.ts +22 -0
- package/src/codegen/package.ts +39 -0
- package/src/config.ts +102 -0
- package/src/index.ts +6 -0
- package/src/lint/audit-catalog.ts +29 -0
- package/src/lint/post-synth/e2b011-template-name.ts +51 -0
- package/src/lint/post-synth/e2b012-steward-tools.ts +58 -0
- package/src/lint/post-synth/index.ts +9 -0
- package/src/lint/post-synth/post-synth.test.ts +59 -0
- package/src/lint/rules/index.ts +9 -0
- package/src/lint/rules/pinned-image.ts +41 -0
- package/src/lint/rules/rules.test.ts +46 -0
- package/src/lint/rules/template-literal.ts +42 -0
- package/src/lint/rules/template-size.ts +36 -0
- package/src/lsp/completions.test.ts +23 -0
- package/src/lsp/completions.ts +13 -0
- package/src/lsp/hover.test.ts +29 -0
- package/src/lsp/hover.ts +35 -0
- package/src/op/ledger-sync.test.ts +136 -0
- package/src/op/runtime.test.ts +394 -0
- package/src/op/runtime.ts +573 -0
- package/src/op/sandbox.ts +138 -0
- package/src/package-cli.ts +16 -0
- package/src/plugin.test.ts +39 -0
- package/src/plugin.ts +79 -0
- package/src/resources.ts +50 -0
- package/src/serializer.test.ts +88 -0
- package/src/serializer.ts +73 -0
- package/src/skills/chant-e2b-ops.md +69 -0
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The seam between the e2b runtime and an E2B-compatible API.
|
|
3
|
+
*
|
|
4
|
+
* {@link StewardSandboxes} is all the runtime asks of a sandbox provider: find
|
|
5
|
+
* the steward's sandbox by its metadata, make one, wake one, run a command in
|
|
6
|
+
* it, list and kill its processes, keep it awake and pause it. The production
|
|
7
|
+
* implementation below is a thin wrapper over the `e2b` SDK, so the runtime
|
|
8
|
+
* works against hosted E2B and anything that serves E2B's API (loam, for one);
|
|
9
|
+
* a test hands the runtime a fake and drives the whole path with no network.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
/** The metadata key a steward's sandbox is found by. */
|
|
13
|
+
export const STEWARD_METADATA_KEY = "chant/steward";
|
|
14
|
+
|
|
15
|
+
/** The environment variables that mark a process in the sandbox as a chant run. */
|
|
16
|
+
export const RUN_OP_ENV = "CHANT_E2B_OP";
|
|
17
|
+
export const RUN_ID_ENV = "CHANT_E2B_RUN";
|
|
18
|
+
export const RUN_STARTED_ENV = "CHANT_E2B_STARTED";
|
|
19
|
+
|
|
20
|
+
export interface CommandResult {
|
|
21
|
+
exitCode: number;
|
|
22
|
+
stdout: string;
|
|
23
|
+
stderr: string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface RunCommandOptions {
|
|
27
|
+
envs?: Record<string, string>;
|
|
28
|
+
onStdout?: (chunk: string) => void;
|
|
29
|
+
onStderr?: (chunk: string) => void;
|
|
30
|
+
/** Called with the process id once the command has started. */
|
|
31
|
+
onStart?: (pid: number) => void;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface SandboxProcess {
|
|
35
|
+
pid: number;
|
|
36
|
+
envs: Record<string, string>;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** One steward's sandbox, awake. */
|
|
40
|
+
export interface StewardSandbox {
|
|
41
|
+
readonly id: string;
|
|
42
|
+
/** Run `bash -c <script>` to completion, however long it takes. A non-zero exit is a result, not a throw. */
|
|
43
|
+
run(script: string, opts?: RunCommandOptions): Promise<CommandResult>;
|
|
44
|
+
processes(): Promise<SandboxProcess[]>;
|
|
45
|
+
kill(pid: number): Promise<void>;
|
|
46
|
+
/** Hold the sandbox awake for `ms` from now. When that runs out it pauses rather than dying. */
|
|
47
|
+
keepAwake(ms: number): Promise<void>;
|
|
48
|
+
pause(): Promise<void>;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export interface StewardSandboxes {
|
|
52
|
+
/** The steward's sandbox, running or paused, or `undefined` when it has none. */
|
|
53
|
+
find(steward: string): Promise<{ id: string; state: string } | undefined>;
|
|
54
|
+
/** Make the steward's sandbox. It pauses, rather than dies, when it is left alone. */
|
|
55
|
+
create(steward: string, opts: { template: string; awakeMs: number }): Promise<StewardSandbox>;
|
|
56
|
+
/** Wake (or reach) a sandbox by id. */
|
|
57
|
+
connect(id: string, opts: { awakeMs: number }): Promise<StewardSandbox>;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export interface E2bConnection {
|
|
61
|
+
apiKey: string;
|
|
62
|
+
/** Omitted: the SDK's own default. */
|
|
63
|
+
domain?: string;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** The production seam, over the `e2b` SDK. The SDK is imported on first use, so loading the plugin costs nothing. */
|
|
67
|
+
export function e2bSandboxes(conn: E2bConnection): StewardSandboxes {
|
|
68
|
+
const base = { apiKey: conn.apiKey, ...(conn.domain ? { domain: conn.domain } : {}) };
|
|
69
|
+
const sdk = () => import("e2b");
|
|
70
|
+
|
|
71
|
+
const wrap = (sb: import("e2b").Sandbox): StewardSandbox => ({
|
|
72
|
+
id: sb.sandboxId,
|
|
73
|
+
async run(script, opts = {}) {
|
|
74
|
+
const { CommandExitError } = await sdk();
|
|
75
|
+
// `timeoutMs: 0` lifts the SDK's one-minute default: an op runs as long as it runs.
|
|
76
|
+
const handle = await sb.commands.run(script, {
|
|
77
|
+
background: true,
|
|
78
|
+
timeoutMs: 0,
|
|
79
|
+
...(opts.envs ? { envs: opts.envs } : {}),
|
|
80
|
+
...(opts.onStdout ? { onStdout: opts.onStdout } : {}),
|
|
81
|
+
...(opts.onStderr ? { onStderr: opts.onStderr } : {}),
|
|
82
|
+
});
|
|
83
|
+
opts.onStart?.(handle.pid);
|
|
84
|
+
try {
|
|
85
|
+
const done = await handle.wait();
|
|
86
|
+
return { exitCode: done.exitCode, stdout: done.stdout, stderr: done.stderr };
|
|
87
|
+
} catch (err) {
|
|
88
|
+
if (err instanceof CommandExitError) return { exitCode: err.exitCode, stdout: err.stdout, stderr: err.stderr };
|
|
89
|
+
throw err;
|
|
90
|
+
}
|
|
91
|
+
},
|
|
92
|
+
async processes() {
|
|
93
|
+
return (await sb.commands.list()).map((p) => ({ pid: p.pid, envs: p.envs ?? {} }));
|
|
94
|
+
},
|
|
95
|
+
async kill(pid) {
|
|
96
|
+
await sb.commands.kill(pid);
|
|
97
|
+
},
|
|
98
|
+
async keepAwake(ms) {
|
|
99
|
+
await sb.setTimeout(ms);
|
|
100
|
+
},
|
|
101
|
+
async pause() {
|
|
102
|
+
await sb.pause();
|
|
103
|
+
},
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
return {
|
|
107
|
+
async find(steward) {
|
|
108
|
+
const { Sandbox } = await sdk();
|
|
109
|
+
const page = await Sandbox.list({
|
|
110
|
+
...base,
|
|
111
|
+
query: { metadata: { [STEWARD_METADATA_KEY]: steward }, state: ["running", "paused"] },
|
|
112
|
+
limit: 2,
|
|
113
|
+
}).nextItems();
|
|
114
|
+
if (page.length > 1) {
|
|
115
|
+
throw new Error(
|
|
116
|
+
`e2b runtime: ${page.length} sandboxes carry ${STEWARD_METADATA_KEY}=${steward}. ` +
|
|
117
|
+
`A steward is one machine; kill all but one (${page.map((s) => s.sandboxId).join(", ")}).`,
|
|
118
|
+
);
|
|
119
|
+
}
|
|
120
|
+
return page[0] ? { id: page[0].sandboxId, state: page[0].state } : undefined;
|
|
121
|
+
},
|
|
122
|
+
async create(steward, opts) {
|
|
123
|
+
const { Sandbox } = await sdk();
|
|
124
|
+
const sb = await Sandbox.create(opts.template, {
|
|
125
|
+
...base,
|
|
126
|
+
timeoutMs: opts.awakeMs,
|
|
127
|
+
metadata: { [STEWARD_METADATA_KEY]: steward },
|
|
128
|
+
// Run out of time paused, not killed: the disk, and the memory, are the steward.
|
|
129
|
+
lifecycle: { onTimeout: "pause" },
|
|
130
|
+
});
|
|
131
|
+
return wrap(sb);
|
|
132
|
+
},
|
|
133
|
+
async connect(id, opts) {
|
|
134
|
+
const { Sandbox } = await sdk();
|
|
135
|
+
return wrap(await Sandbox.connect(id, { ...base, timeoutMs: opts.awakeMs }));
|
|
136
|
+
},
|
|
137
|
+
};
|
|
138
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
#!/usr/bin/env tsx
|
|
2
|
+
/**
|
|
3
|
+
* `npm run bundle` for the e2b lexicon: writes dist/manifest.json, dist/meta.json,
|
|
4
|
+
* the rules and the skills.
|
|
5
|
+
*/
|
|
6
|
+
import { packageLexicon } from "./codegen/package";
|
|
7
|
+
import { writeBundleSpec } from "@intentius/chant/codegen/package";
|
|
8
|
+
import { dirname, join } from "path";
|
|
9
|
+
import { fileURLToPath } from "url";
|
|
10
|
+
|
|
11
|
+
const distDir = join(dirname(fileURLToPath(import.meta.url)), "..", "dist");
|
|
12
|
+
const verbose = process.argv.includes("--verbose");
|
|
13
|
+
|
|
14
|
+
const { spec, stats } = await packageLexicon({ verbose });
|
|
15
|
+
writeBundleSpec(spec, distDir);
|
|
16
|
+
console.error(`Packaged ${stats.resources} resources, ${stats.ruleCount} rules, ${stats.skillCount} skills into ${distDir}`);
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { isLexiconPlugin } from "@intentius/chant/lexicon";
|
|
3
|
+
import { e2bPlugin } from "./plugin";
|
|
4
|
+
|
|
5
|
+
describe("e2b plugin", () => {
|
|
6
|
+
it("is a valid LexiconPlugin", () => {
|
|
7
|
+
expect(isLexiconPlugin(e2bPlugin)).toBe(true);
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
it("has the e2b name and serializer", () => {
|
|
11
|
+
expect(e2bPlugin.name).toBe("e2b");
|
|
12
|
+
expect(e2bPlugin.serializer.name).toBe("e2b");
|
|
13
|
+
expect(e2bPlugin.serializer.rulePrefix).toBe("E2B");
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
it("ships lint rules and post-synth checks under the E2B prefix, every check catalogued", () => {
|
|
17
|
+
expect((e2bPlugin.lintRules?.() ?? []).map((r) => r.id)).toEqual(["E2B001", "E2B002"]);
|
|
18
|
+
const checks = (e2bPlugin.postSynthChecks?.() ?? []).map((c) => c.id);
|
|
19
|
+
expect(checks).toEqual(["E2B011", "E2B012"]);
|
|
20
|
+
for (const id of checks) expect(e2bPlugin.auditCatalog?.()[id]).toBeDefined();
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it("keeps its op runtime and config namespace", () => {
|
|
24
|
+
expect(e2bPlugin.opRuntime).toBeDefined();
|
|
25
|
+
expect(e2bPlugin.configSchema?.safeParse({ profiles: { prod: { apiKey: { env: "E2B_API_KEY" }, template: "chant-steward" } }, defaultProfile: "prod" }).success).toBe(true);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it("registers completion, hover and docs", () => {
|
|
29
|
+
expect(typeof e2bPlugin.completionProvider).toBe("function");
|
|
30
|
+
expect(typeof e2bPlugin.hoverProvider).toBe("function");
|
|
31
|
+
expect(typeof e2bPlugin.docs).toBe("function");
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it("loads its skill with content", () => {
|
|
35
|
+
const skills = e2bPlugin.skills?.() ?? [];
|
|
36
|
+
expect(skills.map((s) => s.name)).toEqual(["chant-e2b-ops"]);
|
|
37
|
+
expect(skills[0].content.length).toBeGreaterThan(500);
|
|
38
|
+
});
|
|
39
|
+
});
|
package/src/plugin.ts
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import type { LexiconPlugin } from "@intentius/chant/lexicon";
|
|
2
|
+
import type { CompletionContext, HoverContext } from "@intentius/chant/lsp/types";
|
|
3
|
+
import { createSkillsLoader } from "@intentius/chant/lexicon-plugin-helpers";
|
|
4
|
+
import { e2bSerializer } from "./serializer";
|
|
5
|
+
import { e2bConfigSchema } from "./config";
|
|
6
|
+
import { createE2bOpRuntime } from "./op/runtime";
|
|
7
|
+
import { rules } from "./lint/rules";
|
|
8
|
+
import { postSynthChecks } from "./lint/post-synth";
|
|
9
|
+
import { e2bAuditCatalog } from "./lint/audit-catalog";
|
|
10
|
+
import { completions } from "./lsp/completions";
|
|
11
|
+
import { hover } from "./lsp/hover";
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* e2b lexicon plugin: `chant run <op> --on e2b`, and the `Template` a
|
|
15
|
+
* steward's sandbox is made from.
|
|
16
|
+
*
|
|
17
|
+
* The op runtime is most of the lexicon. Its one declarable kind is written
|
|
18
|
+
* by hand, so `generate` only writes the post-synth barrel, there is no
|
|
19
|
+
* upstream spec to validate against or measure coverage of, and `package`
|
|
20
|
+
* bundles the hand-written catalog.
|
|
21
|
+
*/
|
|
22
|
+
export const e2bPlugin: LexiconPlugin = {
|
|
23
|
+
name: "e2b",
|
|
24
|
+
serializer: e2bSerializer,
|
|
25
|
+
configSchema: e2bConfigSchema,
|
|
26
|
+
auditCatalog: () => e2bAuditCatalog,
|
|
27
|
+
|
|
28
|
+
// Construction is pure: the key, the steward and the repository are
|
|
29
|
+
// resolved on the first call, so loading the plugin costs nothing.
|
|
30
|
+
opRuntime: createE2bOpRuntime(),
|
|
31
|
+
|
|
32
|
+
skills: createSkillsLoader(import.meta.url, [
|
|
33
|
+
{
|
|
34
|
+
file: "chant-e2b-ops.md",
|
|
35
|
+
name: "chant-e2b-ops",
|
|
36
|
+
description: "Run chant ops on a steward sandbox with `--on e2b`, on hosted E2B or any API that speaks E2B's",
|
|
37
|
+
triggers: [{ type: "context" as const, value: "e2b" }],
|
|
38
|
+
},
|
|
39
|
+
]),
|
|
40
|
+
|
|
41
|
+
async generate(): Promise<void> {
|
|
42
|
+
const { generate } = await import("./codegen/generate");
|
|
43
|
+
generate();
|
|
44
|
+
},
|
|
45
|
+
|
|
46
|
+
// Nothing upstream to check the hand-written kind against.
|
|
47
|
+
async validate(): Promise<void> {},
|
|
48
|
+
async coverage(): Promise<void> {},
|
|
49
|
+
|
|
50
|
+
async package(options?: { verbose?: boolean; force?: boolean }): Promise<void> {
|
|
51
|
+
const { packageLexicon } = await import("./codegen/package");
|
|
52
|
+
const { writeBundleSpec } = await import("@intentius/chant/codegen/package");
|
|
53
|
+
const { join, dirname } = await import("path");
|
|
54
|
+
const { fileURLToPath } = await import("url");
|
|
55
|
+
const { spec } = await packageLexicon(options);
|
|
56
|
+
writeBundleSpec(spec, join(dirname(dirname(fileURLToPath(import.meta.url))), "dist"));
|
|
57
|
+
},
|
|
58
|
+
|
|
59
|
+
lintRules() {
|
|
60
|
+
return rules;
|
|
61
|
+
},
|
|
62
|
+
|
|
63
|
+
postSynthChecks() {
|
|
64
|
+
return postSynthChecks;
|
|
65
|
+
},
|
|
66
|
+
|
|
67
|
+
completionProvider(ctx: CompletionContext) {
|
|
68
|
+
return completions(ctx);
|
|
69
|
+
},
|
|
70
|
+
|
|
71
|
+
hoverProvider(ctx: HoverContext) {
|
|
72
|
+
return hover(ctx);
|
|
73
|
+
},
|
|
74
|
+
|
|
75
|
+
async docs(options?) {
|
|
76
|
+
const { generateDocs } = await import("./codegen/docs");
|
|
77
|
+
return generateDocs(options);
|
|
78
|
+
},
|
|
79
|
+
};
|
package/src/resources.ts
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The e2b lexicon's one declarable kind: a sandbox template.
|
|
3
|
+
*
|
|
4
|
+
* A steward's sandbox is made from a template (`template` in the profile,
|
|
5
|
+
* default `base`). The runtime needs git and Node in it, and a project usually
|
|
6
|
+
* wants its own tools there too, installed once rather than on every run. A
|
|
7
|
+
* `Template` declares that image; `chant build` writes it out as a Dockerfile
|
|
8
|
+
* and the `e2b template create` command line that builds it.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import type { Declarable } from "@intentius/chant/declarable";
|
|
12
|
+
import { createResource } from "@intentius/chant/runtime";
|
|
13
|
+
import { TEMPLATE_TYPE } from "./catalog";
|
|
14
|
+
|
|
15
|
+
export interface TemplateProps {
|
|
16
|
+
/** The template's name on E2B: lowercase letters, digits, dashes and underscores. A profile's `template` names it. */
|
|
17
|
+
name: string;
|
|
18
|
+
/** The image the template starts from, such as `node:22-bookworm`. Pin a tag. */
|
|
19
|
+
fromImage: string;
|
|
20
|
+
/** Shell commands run in order while the template builds, one `RUN` line each. */
|
|
21
|
+
run?: string[];
|
|
22
|
+
/** Environment variables set in every sandbox made from the template. */
|
|
23
|
+
env?: Record<string, string>;
|
|
24
|
+
/** The working directory for the `run` steps and the start command. */
|
|
25
|
+
workdir?: string;
|
|
26
|
+
/** The start command (`--cmd`): E2B runs it when the template is snapshotted, so a sandbox starts with it running. */
|
|
27
|
+
startCmd?: string;
|
|
28
|
+
/** The ready command (`--ready-cmd`): E2B polls it until it exits 0 before it snapshots the template. */
|
|
29
|
+
readyCmd?: string;
|
|
30
|
+
/** vCPUs per sandbox. A positive integer; E2B's default is 2. */
|
|
31
|
+
cpuCount?: number;
|
|
32
|
+
/** Memory per sandbox in MiB. A positive even integer; E2B's default is 1024. */
|
|
33
|
+
memoryMB?: number;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface Template extends Declarable {
|
|
37
|
+
readonly props: TemplateProps;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** A sandbox template, serialized to `e2b.<name>.Dockerfile` and an `e2b template create` command. */
|
|
41
|
+
export const Template = /* @__PURE__ */ createResource(TEMPLATE_TYPE, "e2b", {}) as unknown as new (
|
|
42
|
+
props: TemplateProps,
|
|
43
|
+
) => Template;
|
|
44
|
+
|
|
45
|
+
/** The props of a declared `Template`, or `undefined` for anything else. */
|
|
46
|
+
export function templateProps(entity: unknown): TemplateProps | undefined {
|
|
47
|
+
if (!entity || typeof entity !== "object") return undefined;
|
|
48
|
+
if ((entity as { entityType?: unknown }).entityType !== TEMPLATE_TYPE) return undefined;
|
|
49
|
+
return ((entity as { props?: unknown }).props ?? {}) as TemplateProps;
|
|
50
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import type { Declarable } from "@intentius/chant/declarable";
|
|
3
|
+
import type { SerializerResult } from "@intentius/chant/serializer";
|
|
4
|
+
import { e2bSerializer } from "./serializer";
|
|
5
|
+
import { Template } from "./resources";
|
|
6
|
+
|
|
7
|
+
function serialize(entries: Array<[string, unknown]>): SerializerResult {
|
|
8
|
+
return e2bSerializer.serialize(new Map(entries as Array<[string, Declarable]>)) as SerializerResult;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
describe("e2b serializer", () => {
|
|
12
|
+
it("writes a Dockerfile per template, named after it, and the command that builds it", () => {
|
|
13
|
+
const out = serialize([
|
|
14
|
+
[
|
|
15
|
+
"steward",
|
|
16
|
+
new Template({
|
|
17
|
+
name: "chant-steward",
|
|
18
|
+
fromImage: "node:22-bookworm",
|
|
19
|
+
env: { CI: "1", GREETING: 'say "hi"' },
|
|
20
|
+
workdir: "/home/user",
|
|
21
|
+
run: ["apt-get update", "apt-get install -y jq"],
|
|
22
|
+
startCmd: "node server.js",
|
|
23
|
+
readyCmd: "curl -sf localhost:3000",
|
|
24
|
+
cpuCount: 2,
|
|
25
|
+
memoryMB: 2048,
|
|
26
|
+
}),
|
|
27
|
+
],
|
|
28
|
+
]);
|
|
29
|
+
|
|
30
|
+
expect(Object.keys(out.files ?? {})).toEqual(["e2b.chant-steward.Dockerfile"]);
|
|
31
|
+
expect(out.verbatimFiles).toEqual(["e2b.chant-steward.Dockerfile"]);
|
|
32
|
+
expect(out.files?.["e2b.chant-steward.Dockerfile"]).toBe(
|
|
33
|
+
[
|
|
34
|
+
"FROM node:22-bookworm",
|
|
35
|
+
'ENV CI="1"',
|
|
36
|
+
'ENV GREETING="say \\"hi\\""',
|
|
37
|
+
"WORKDIR /home/user",
|
|
38
|
+
"RUN apt-get update",
|
|
39
|
+
"RUN apt-get install -y jq",
|
|
40
|
+
"",
|
|
41
|
+
].join("\n"),
|
|
42
|
+
);
|
|
43
|
+
expect(JSON.parse(out.primary)).toEqual({
|
|
44
|
+
templates: {
|
|
45
|
+
steward: {
|
|
46
|
+
name: "chant-steward",
|
|
47
|
+
dockerfile: "e2b.chant-steward.Dockerfile",
|
|
48
|
+
build:
|
|
49
|
+
"e2b template create chant-steward --dockerfile e2b.chant-steward.Dockerfile " +
|
|
50
|
+
"--cmd 'node server.js' --ready-cmd 'curl -sf localhost:3000' --cpu-count 2 --memory-mb 2048",
|
|
51
|
+
cpuCount: 2,
|
|
52
|
+
memoryMB: 2048,
|
|
53
|
+
},
|
|
54
|
+
},
|
|
55
|
+
});
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it("leaves out what a template does not set", () => {
|
|
59
|
+
const out = serialize([["base", new Template({ name: "slim", fromImage: "debian:12" })]]);
|
|
60
|
+
expect(out.files?.["e2b.slim.Dockerfile"]).toBe("FROM debian:12\n");
|
|
61
|
+
expect(JSON.parse(out.primary).templates.base).toEqual({
|
|
62
|
+
name: "slim",
|
|
63
|
+
dockerfile: "e2b.slim.Dockerfile",
|
|
64
|
+
build: "e2b template create slim --dockerfile e2b.slim.Dockerfile",
|
|
65
|
+
});
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it("quotes a start command with a single quote in it as one shell word", () => {
|
|
69
|
+
const out = serialize([["s", new Template({ name: "s", fromImage: "node:22-bookworm", startCmd: "echo 'hi'" })]]);
|
|
70
|
+
expect(JSON.parse(out.primary).templates.s.build).toContain(`--cmd 'echo '\\''hi'\\'''`);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it("orders templates by export name and ignores anything that is not one", () => {
|
|
74
|
+
const out = serialize([
|
|
75
|
+
["zeta", new Template({ name: "zeta", fromImage: "node:22-bookworm" })],
|
|
76
|
+
["alpha", new Template({ name: "alpha", fromImage: "node:22-bookworm" })],
|
|
77
|
+
["other", { entityType: "Other::Thing", props: {} }],
|
|
78
|
+
]);
|
|
79
|
+
expect(Object.keys(JSON.parse(out.primary).templates)).toEqual(["alpha", "zeta"]);
|
|
80
|
+
expect(Object.keys(out.files ?? {})).toHaveLength(2);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it("gives a build with no templates an empty index", () => {
|
|
84
|
+
const out = serialize([]);
|
|
85
|
+
expect(JSON.parse(out.primary)).toEqual({ templates: {} });
|
|
86
|
+
expect(out.files).toEqual({});
|
|
87
|
+
});
|
|
88
|
+
});
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import type { Declarable } from "@intentius/chant/declarable";
|
|
2
|
+
import type { Serializer, SerializerResult } from "@intentius/chant/serializer";
|
|
3
|
+
import { templateProps, type TemplateProps } from "./resources";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The e2b serializer: each declared `Template` becomes `e2b.<name>.Dockerfile`
|
|
7
|
+
* (`FROM`, then `ENV`, `WORKDIR` and one `RUN` per step), named after the
|
|
8
|
+
* template so several can sit in one directory.
|
|
9
|
+
*
|
|
10
|
+
* The primary output is a JSON index of the templates, keyed by the name each
|
|
11
|
+
* was exported as. Each entry carries the Dockerfile's name and `build`, the
|
|
12
|
+
* `e2b template create` command line that builds the template from it, run in
|
|
13
|
+
* the output directory. A build with no templates (a project that only runs
|
|
14
|
+
* Ops here) gets an empty index.
|
|
15
|
+
*/
|
|
16
|
+
export const e2bSerializer: Serializer = {
|
|
17
|
+
name: "e2b",
|
|
18
|
+
rulePrefix: "E2B",
|
|
19
|
+
serialize(entities: Map<string, Declarable>): SerializerResult {
|
|
20
|
+
const index: Record<string, unknown> = {};
|
|
21
|
+
const files: Record<string, string> = {};
|
|
22
|
+
|
|
23
|
+
const templates = [...entities]
|
|
24
|
+
.map(([exportName, entity]) => [exportName, templateProps(entity)] as const)
|
|
25
|
+
.filter((e): e is readonly [string, TemplateProps] => e[1] !== undefined)
|
|
26
|
+
.sort(([a], [b]) => a.localeCompare(b));
|
|
27
|
+
|
|
28
|
+
for (const [exportName, props] of templates) {
|
|
29
|
+
const name = props.name || exportName;
|
|
30
|
+
const dockerfile = `e2b.${name}.Dockerfile`;
|
|
31
|
+
files[dockerfile] = renderDockerfile(props);
|
|
32
|
+
index[exportName] = {
|
|
33
|
+
name,
|
|
34
|
+
dockerfile,
|
|
35
|
+
build: createCommand(name, dockerfile, props),
|
|
36
|
+
...(props.cpuCount !== undefined ? { cpuCount: props.cpuCount } : {}),
|
|
37
|
+
...(props.memoryMB !== undefined ? { memoryMB: props.memoryMB } : {}),
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
return {
|
|
42
|
+
primary: JSON.stringify({ templates: index }, null, 2) + "\n",
|
|
43
|
+
files,
|
|
44
|
+
verbatimFiles: Object.keys(files),
|
|
45
|
+
};
|
|
46
|
+
},
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
/** A Dockerfile string literal: Docker reads double-quoted values with JSON's escapes. */
|
|
50
|
+
const quote = (value: string): string => JSON.stringify(value);
|
|
51
|
+
|
|
52
|
+
/** A word as one shell argument. */
|
|
53
|
+
function shellWord(value: string): string {
|
|
54
|
+
return /^[A-Za-z0-9._:@+=/,-]+$/.test(value) ? value : `'${value.replace(/'/g, `'\\''`)}'`;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function renderDockerfile(props: TemplateProps): string {
|
|
58
|
+
const lines = [`FROM ${props.fromImage}`];
|
|
59
|
+
for (const [key, value] of Object.entries(props.env ?? {})) lines.push(`ENV ${key}=${quote(value)}`);
|
|
60
|
+
if (props.workdir) lines.push(`WORKDIR ${props.workdir}`);
|
|
61
|
+
for (const step of props.run ?? []) lines.push(`RUN ${step}`);
|
|
62
|
+
return lines.join("\n") + "\n";
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** The `e2b template create` command line that builds the template, run where the Dockerfile is. */
|
|
66
|
+
export function createCommand(name: string, dockerfile: string, props: TemplateProps): string {
|
|
67
|
+
const args = ["e2b", "template", "create", name, "--dockerfile", dockerfile];
|
|
68
|
+
if (props.startCmd) args.push("--cmd", props.startCmd);
|
|
69
|
+
if (props.readyCmd) args.push("--ready-cmd", props.readyCmd);
|
|
70
|
+
if (props.cpuCount !== undefined) args.push("--cpu-count", String(props.cpuCount));
|
|
71
|
+
if (props.memoryMB !== undefined) args.push("--memory-mb", String(props.memoryMB));
|
|
72
|
+
return args.map(shellWord).join(" ");
|
|
73
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
---
|
|
2
|
+
skill: chant-e2b-ops
|
|
3
|
+
description: Run chant ops on a steward sandbox with `--on e2b`, on hosted E2B or any API that speaks E2B's
|
|
4
|
+
user-invocable: true
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
# Running chant ops on an e2b steward
|
|
8
|
+
|
|
9
|
+
`chant run <op> --on e2b` runs the op on a steward: one sandbox that outlives
|
|
10
|
+
every run. It keeps a clone of the project and its installed tools on its disk,
|
|
11
|
+
runs one op at a time, and sleeps paused between runs. Any API that speaks
|
|
12
|
+
E2B's hosts one: hosted E2B, or a platform that serves the same API.
|
|
13
|
+
|
|
14
|
+
## Set it up
|
|
15
|
+
|
|
16
|
+
```ts
|
|
17
|
+
// chant.config.ts
|
|
18
|
+
import type { ChantConfig } from "@intentius/chant/config";
|
|
19
|
+
import "@intentius/chant-lexicon-e2b";
|
|
20
|
+
|
|
21
|
+
export default {
|
|
22
|
+
lexicons: ["e2b"],
|
|
23
|
+
e2b: {
|
|
24
|
+
profiles: {
|
|
25
|
+
prod: {
|
|
26
|
+
apiKey: { env: "E2B_API_KEY" },
|
|
27
|
+
domain: "e2b.example.com", // omit for hosted E2B
|
|
28
|
+
steward: "prod-steward", // default: the repository's name
|
|
29
|
+
gitToken: { env: "GITHUB_TOKEN" }, // fetch a private repo, push the ledger
|
|
30
|
+
},
|
|
31
|
+
},
|
|
32
|
+
defaultProfile: "prod",
|
|
33
|
+
},
|
|
34
|
+
} satisfies ChantConfig;
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
The project must depend on `@intentius/chant`, because the steward runs the
|
|
38
|
+
chant the project installs. The template (default `base`) needs git and Node.
|
|
39
|
+
|
|
40
|
+
## Run, read, cancel
|
|
41
|
+
|
|
42
|
+
```bash
|
|
43
|
+
chant run prod-apply --on e2b # streams the steps, then pauses the steward
|
|
44
|
+
chant run status prod-apply --on e2b # a run in progress, or the last one recorded
|
|
45
|
+
chant run log prod-apply --on e2b
|
|
46
|
+
chant run cancel prod-apply --on e2b --force
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
The steward runs the commit this checkout is at, fetched from the repository,
|
|
50
|
+
so **push first**. Uncommitted changes are not sent; the run warns about them.
|
|
51
|
+
|
|
52
|
+
One run at a time: a run while the steward is busy is refused, naming the run
|
|
53
|
+
in progress. A client that dies mid-run (Ctrl-C excepted) leaves the run going;
|
|
54
|
+
`status` reports it, and the steward pauses itself when it is done.
|
|
55
|
+
|
|
56
|
+
## Gates
|
|
57
|
+
|
|
58
|
+
A gate works as it does locally. The run reaches it in the sandbox, pushes the
|
|
59
|
+
pending fact to the repository's `chant/lifecycle` branch and ends `gated`
|
|
60
|
+
(exit 3). `chant approve <op> <gate>` from any checkout of the repository
|
|
61
|
+
records the answer, and the next `--on e2b` run walks through. The steward also
|
|
62
|
+
pushes its run records, so `chant run log` on any checkout sees its runs.
|
|
63
|
+
|
|
64
|
+
## Running on a schedule
|
|
65
|
+
|
|
66
|
+
Nothing in E2B fires on a clock. Run `chant run <op> --on e2b` from whatever
|
|
67
|
+
already does: a CI `schedule:` trigger (the github lexicon renders an op's
|
|
68
|
+
`schedule` as one), a CronJob, or `chant operator` on a machine that stays up.
|
|
69
|
+
The job is short, because the steward does the work.
|