@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.
Files changed (123) hide show
  1. package/README.md +147 -2
  2. package/dist/catalog.d.ts +19 -0
  3. package/dist/catalog.d.ts.map +1 -0
  4. package/dist/catalog.js +58 -0
  5. package/dist/codegen/docs-cli.d.ts +3 -0
  6. package/dist/codegen/docs-cli.d.ts.map +1 -0
  7. package/dist/codegen/docs-cli.js +6 -0
  8. package/dist/codegen/docs.d.ts +15 -0
  9. package/dist/codegen/docs.d.ts.map +1 -0
  10. package/dist/codegen/docs.js +123 -0
  11. package/dist/codegen/generate-cli.d.ts +3 -0
  12. package/dist/codegen/generate-cli.d.ts.map +1 -0
  13. package/dist/codegen/generate-cli.js +4 -0
  14. package/dist/codegen/generate.d.ts +11 -0
  15. package/dist/codegen/generate.d.ts.map +1 -0
  16. package/dist/codegen/generate.js +20 -0
  17. package/dist/codegen/package.d.ts +10 -0
  18. package/dist/codegen/package.d.ts.map +1 -0
  19. package/dist/codegen/package.js +34 -0
  20. package/dist/config.d.ts +113 -0
  21. package/dist/config.d.ts.map +1 -0
  22. package/dist/config.js +82 -0
  23. package/dist/index.d.ts +7 -0
  24. package/dist/index.d.ts.map +1 -0
  25. package/dist/index.js +6 -0
  26. package/dist/integrity.json +15 -0
  27. package/dist/lint/audit-catalog.d.ts +11 -0
  28. package/dist/lint/audit-catalog.d.ts.map +1 -0
  29. package/dist/lint/audit-catalog.js +15 -0
  30. package/dist/lint/post-synth/e2b011-template-name.d.ts +12 -0
  31. package/dist/lint/post-synth/e2b011-template-name.d.ts.map +1 -0
  32. package/dist/lint/post-synth/e2b011-template-name.js +48 -0
  33. package/dist/lint/post-synth/e2b012-steward-tools.d.ts +20 -0
  34. package/dist/lint/post-synth/e2b012-steward-tools.d.ts.map +1 -0
  35. package/dist/lint/post-synth/e2b012-steward-tools.js +53 -0
  36. package/dist/lint/post-synth/index.d.ts +3 -0
  37. package/dist/lint/post-synth/index.d.ts.map +1 -0
  38. package/dist/lint/post-synth/index.js +6 -0
  39. package/dist/lint/rules/index.d.ts +6 -0
  40. package/dist/lint/rules/index.d.ts.map +1 -0
  41. package/dist/lint/rules/index.js +6 -0
  42. package/dist/lint/rules/pinned-image.d.ts +14 -0
  43. package/dist/lint/rules/pinned-image.d.ts.map +1 -0
  44. package/dist/lint/rules/pinned-image.js +36 -0
  45. package/dist/lint/rules/template-literal.d.ts +11 -0
  46. package/dist/lint/rules/template-literal.d.ts.map +1 -0
  47. package/dist/lint/rules/template-literal.js +39 -0
  48. package/dist/lint/rules/template-size.d.ts +10 -0
  49. package/dist/lint/rules/template-size.d.ts.map +1 -0
  50. package/dist/lint/rules/template-size.js +31 -0
  51. package/dist/lsp/completions.d.ts +7 -0
  52. package/dist/lsp/completions.d.ts.map +1 -0
  53. package/dist/lsp/completions.js +10 -0
  54. package/dist/lsp/hover.d.ts +7 -0
  55. package/dist/lsp/hover.d.ts.map +1 -0
  56. package/dist/lsp/hover.js +30 -0
  57. package/dist/manifest.json +6 -0
  58. package/dist/meta.json +45 -0
  59. package/dist/okf/index.md +14 -0
  60. package/dist/okf/rules/E2B001.md +15 -0
  61. package/dist/okf/rules/E2B002.md +15 -0
  62. package/dist/okf/rules/E2B011.md +15 -0
  63. package/dist/okf/rules/E2B012.md +15 -0
  64. package/dist/okf/types/Template.md +16 -0
  65. package/dist/op/runtime.d.ts +119 -0
  66. package/dist/op/runtime.d.ts.map +1 -0
  67. package/dist/op/runtime.js +522 -0
  68. package/dist/op/sandbox.d.ts +67 -0
  69. package/dist/op/sandbox.d.ts.map +1 -0
  70. package/dist/op/sandbox.js +87 -0
  71. package/dist/package-cli.d.ts +3 -0
  72. package/dist/package-cli.d.ts.map +1 -0
  73. package/dist/package-cli.js +14 -0
  74. package/dist/plugin.d.ts +12 -0
  75. package/dist/plugin.d.ts.map +1 -0
  76. package/dist/plugin.js +66 -0
  77. package/dist/resources.d.ts +38 -0
  78. package/dist/resources.d.ts.map +1 -0
  79. package/dist/resources.js +21 -0
  80. package/dist/rules/e2b011-template-name.ts +51 -0
  81. package/dist/rules/e2b012-steward-tools.ts +58 -0
  82. package/dist/rules/pinned-image.ts +41 -0
  83. package/dist/rules/template-literal.ts +42 -0
  84. package/dist/rules/template-size.ts +36 -0
  85. package/dist/serializer.d.ts +18 -0
  86. package/dist/serializer.d.ts.map +1 -0
  87. package/dist/serializer.js +70 -0
  88. package/dist/skills/chant-e2b-ops.md +69 -0
  89. package/dist/types/index.d.ts +1 -0
  90. package/package.json +67 -4
  91. package/src/catalog.ts +72 -0
  92. package/src/codegen/docs-cli.ts +7 -0
  93. package/src/codegen/docs.ts +130 -0
  94. package/src/codegen/generate-cli.ts +5 -0
  95. package/src/codegen/generate.ts +22 -0
  96. package/src/codegen/package.ts +39 -0
  97. package/src/config.ts +102 -0
  98. package/src/index.ts +6 -0
  99. package/src/lint/audit-catalog.ts +29 -0
  100. package/src/lint/post-synth/e2b011-template-name.ts +51 -0
  101. package/src/lint/post-synth/e2b012-steward-tools.ts +58 -0
  102. package/src/lint/post-synth/index.ts +9 -0
  103. package/src/lint/post-synth/post-synth.test.ts +59 -0
  104. package/src/lint/rules/index.ts +9 -0
  105. package/src/lint/rules/pinned-image.ts +41 -0
  106. package/src/lint/rules/rules.test.ts +46 -0
  107. package/src/lint/rules/template-literal.ts +42 -0
  108. package/src/lint/rules/template-size.ts +36 -0
  109. package/src/lsp/completions.test.ts +23 -0
  110. package/src/lsp/completions.ts +13 -0
  111. package/src/lsp/hover.test.ts +29 -0
  112. package/src/lsp/hover.ts +35 -0
  113. package/src/op/ledger-sync.test.ts +136 -0
  114. package/src/op/runtime.test.ts +394 -0
  115. package/src/op/runtime.ts +573 -0
  116. package/src/op/sandbox.ts +138 -0
  117. package/src/package-cli.ts +16 -0
  118. package/src/plugin.test.ts +39 -0
  119. package/src/plugin.ts +79 -0
  120. package/src/resources.ts +50 -0
  121. package/src/serializer.test.ts +88 -0
  122. package/src/serializer.ts +73 -0
  123. package/src/skills/chant-e2b-ops.md +69 -0
@@ -0,0 +1,9 @@
1
+ import type { LintRule } from "@intentius/chant/lint/rule";
2
+ import { templateSizeRule } from "./template-size";
3
+ import { pinnedImageRule } from "./pinned-image";
4
+
5
+ export { templateSizeRule } from "./template-size";
6
+ export { pinnedImageRule, isFloatingImage } from "./pinned-image";
7
+
8
+ /** All lint rules the e2b lexicon provides. */
9
+ export const rules: LintRule[] = [templateSizeRule, pinnedImageRule];
@@ -0,0 +1,41 @@
1
+ import type { LintRule, LintDiagnostic, LintContext } from "@intentius/chant/lint/rule";
2
+ import { ts } from "@intentius/chant/lint/typescript";
3
+ import { at, property, templateLiterals } from "./template-literal";
4
+
5
+ /** An image reference with no tag or digest, or the `latest` tag. */
6
+ export function isFloatingImage(image: string): boolean {
7
+ if (image.includes("@")) return false;
8
+ const lastSegment = image.slice(image.lastIndexOf("/") + 1);
9
+ const colon = lastSegment.indexOf(":");
10
+ return colon === -1 || lastSegment.slice(colon + 1) === "latest";
11
+ }
12
+
13
+ /**
14
+ * E2B002: A template's base image is pinned.
15
+ *
16
+ * A steward keeps one sandbox for months, but a template is rebuilt whenever
17
+ * someone runs `e2b template create`. With `fromImage` untagged or on `latest`,
18
+ * two builds of the same declaration can carry different Node or git
19
+ * versions, and a steward made from the second behaves differently with no
20
+ * change in the repository.
21
+ */
22
+ export const pinnedImageRule: LintRule = {
23
+ id: "E2B002",
24
+ severity: "warning",
25
+ category: "correctness",
26
+ description: "A Template's fromImage should name a tag other than latest, or a digest",
27
+
28
+ check(context: LintContext): LintDiagnostic[] {
29
+ const { sourceFile } = context;
30
+ const diagnostics: LintDiagnostic[] = [];
31
+ for (const obj of templateLiterals(sourceFile)) {
32
+ const image = property(obj, "fromImage", sourceFile);
33
+ if (image && ts.isStringLiteralLike(image) && image.text.length > 0 && isFloatingImage(image.text)) {
34
+ diagnostics.push(
35
+ at(sourceFile, image, "E2B002", "warning", `fromImage "${image.text}" is not pinned; name a version tag such as node:22-bookworm, or a digest.`),
36
+ );
37
+ }
38
+ }
39
+ return diagnostics;
40
+ },
41
+ };
@@ -0,0 +1,46 @@
1
+ import { describe, test, expect } from "vitest";
2
+ import * as ts from "typescript";
3
+ import type { LintContext } from "@intentius/chant/lint/rule";
4
+ import { templateSizeRule, pinnedImageRule, isFloatingImage } from "./index";
5
+
6
+ function ctx(code: string): LintContext {
7
+ return { sourceFile: ts.createSourceFile("steward.ts", code, ts.ScriptTarget.Latest, true), entities: [], filePath: "steward.ts" };
8
+ }
9
+
10
+ describe("e2b lint rules", () => {
11
+ describe("E2B001: template size", () => {
12
+ test("flags a fractional or zero cpuCount and an odd or negative memoryMB", () => {
13
+ const diags = templateSizeRule.check(ctx(`new Template({ name: "a", fromImage: "node:22", cpuCount: 1.5, memoryMB: 1023 });`));
14
+ expect(diags.map((d) => d.ruleId)).toEqual(["E2B001", "E2B001"]);
15
+ expect(diags[0].message).toContain("cpuCount 1.5");
16
+ expect(diags[1].message).toContain("memoryMB 1023");
17
+ expect(templateSizeRule.check(ctx(`new Template({ cpuCount: 0, memoryMB: -512 });`))).toHaveLength(2);
18
+ });
19
+
20
+ test("passes whole sizes, computed values and other constructors", () => {
21
+ expect(templateSizeRule.check(ctx(`new Template({ cpuCount: 2, memoryMB: 2048 });`))).toHaveLength(0);
22
+ expect(templateSizeRule.check(ctx(`new Template({ cpuCount: n, memoryMB: 1024 * 2 });`))).toHaveLength(0);
23
+ expect(templateSizeRule.check(ctx(`new Machine({ cpuCount: 1.5, memoryMB: 3 });`))).toHaveLength(0);
24
+ });
25
+
26
+ test("reports the line of the offending value", () => {
27
+ const [d] = templateSizeRule.check(ctx(`new Template({\n name: "a",\n memoryMB: 511,\n});`));
28
+ expect(d.line).toBe(3);
29
+ });
30
+ });
31
+
32
+ describe("E2B002: pinned base image", () => {
33
+ test("isFloatingImage spots no tag and latest, and accepts tags and digests", () => {
34
+ for (const floating of ["node", "node:latest", "ghcr.io/acme/base", "registry:5000/acme/base"]) expect(isFloatingImage(floating), floating).toBe(true);
35
+ for (const pinned of ["node:22-bookworm", "registry:5000/acme/base:1.2", "node@sha256:abc"]) expect(isFloatingImage(pinned), pinned).toBe(false);
36
+ });
37
+
38
+ test("warns on an unpinned fromImage literal", () => {
39
+ const diags = pinnedImageRule.check(ctx(`new Template({ name: "a", fromImage: "node:latest" });`));
40
+ expect(diags).toHaveLength(1);
41
+ expect(diags[0]).toMatchObject({ ruleId: "E2B002", severity: "warning" });
42
+ expect(pinnedImageRule.check(ctx(`new Template({ name: "a", fromImage: "node:22-bookworm" });`))).toHaveLength(0);
43
+ expect(pinnedImageRule.check(ctx(`new Template({ name: "a", fromImage: base });`))).toHaveLength(0);
44
+ });
45
+ });
46
+ });
@@ -0,0 +1,42 @@
1
+ /** Helpers the e2b lint rules share: finding `new Template({...})` literals and reporting on them. */
2
+
3
+ import type { LintDiagnostic } from "@intentius/chant/lint/rule";
4
+ import { ts } from "@intentius/chant/lint/typescript";
5
+
6
+ /** The object literal passed to each `new Template({...})` in the file. */
7
+ export function templateLiterals(sourceFile: ts.SourceFile): ts.ObjectLiteralExpression[] {
8
+ const found: ts.ObjectLiteralExpression[] = [];
9
+ function visit(node: ts.Node): void {
10
+ if (ts.isNewExpression(node)) {
11
+ const callee = node.expression;
12
+ const name = ts.isIdentifier(callee) ? callee.text : ts.isPropertyAccessExpression(callee) ? callee.name.text : "";
13
+ const arg = node.arguments?.[0];
14
+ if (name === "Template" && arg && ts.isObjectLiteralExpression(arg)) found.push(arg);
15
+ }
16
+ ts.forEachChild(node, visit);
17
+ }
18
+ visit(sourceFile);
19
+ return found;
20
+ }
21
+
22
+ /** The initializer of `key` in an object literal, when it is written there. */
23
+ export function property(obj: ts.ObjectLiteralExpression, key: string, sourceFile: ts.SourceFile): ts.Expression | undefined {
24
+ for (const p of obj.properties) {
25
+ if (ts.isPropertyAssignment(p) && p.name.getText(sourceFile) === key) return p.initializer;
26
+ }
27
+ return undefined;
28
+ }
29
+
30
+ /** A numeric literal's value, a negated one included; `undefined` for anything computed. */
31
+ export function numberLiteral(expr: ts.Expression): number | undefined {
32
+ if (ts.isNumericLiteral(expr)) return Number(expr.text);
33
+ if (ts.isPrefixUnaryExpression(expr) && expr.operator === ts.SyntaxKind.MinusToken && ts.isNumericLiteral(expr.operand)) {
34
+ return -Number(expr.operand.text);
35
+ }
36
+ return undefined;
37
+ }
38
+
39
+ export function at(sourceFile: ts.SourceFile, node: ts.Node, ruleId: string, severity: LintDiagnostic["severity"], message: string): LintDiagnostic {
40
+ const { line, character } = sourceFile.getLineAndCharacterOfPosition(node.getStart(sourceFile));
41
+ return { file: sourceFile.fileName, line: line + 1, column: character + 1, ruleId, severity, message };
42
+ }
@@ -0,0 +1,36 @@
1
+ import type { LintRule, LintDiagnostic, LintContext } from "@intentius/chant/lint/rule";
2
+ import { at, numberLiteral, property, templateLiterals } from "./template-literal";
3
+
4
+ /**
5
+ * E2B001: A template's CPU and memory are sizes E2B accepts.
6
+ *
7
+ * `cpuCount` is a whole number of vCPUs, at least one. `memoryMB` is a whole
8
+ * number of MiB, at least one and even: `e2b template create` refuses an odd
9
+ * one. Both are only checked when written as literals.
10
+ */
11
+ export const templateSizeRule: LintRule = {
12
+ id: "E2B001",
13
+ severity: "error",
14
+ category: "correctness",
15
+ description: "A Template's cpuCount must be a positive integer and its memoryMB a positive even integer",
16
+
17
+ check(context: LintContext): LintDiagnostic[] {
18
+ const { sourceFile } = context;
19
+ const diagnostics: LintDiagnostic[] = [];
20
+ for (const obj of templateLiterals(sourceFile)) {
21
+ const cpu = property(obj, "cpuCount", sourceFile);
22
+ const cpuValue = cpu ? numberLiteral(cpu) : undefined;
23
+ if (cpu && cpuValue !== undefined && !(Number.isInteger(cpuValue) && cpuValue >= 1)) {
24
+ diagnostics.push(at(sourceFile, cpu, "E2B001", "error", `cpuCount ${cpuValue} is not a whole number of vCPUs; use 1 or more.`));
25
+ }
26
+ const mem = property(obj, "memoryMB", sourceFile);
27
+ const memValue = mem ? numberLiteral(mem) : undefined;
28
+ if (mem && memValue !== undefined && !(Number.isInteger(memValue) && memValue >= 2 && memValue % 2 === 0)) {
29
+ diagnostics.push(
30
+ at(sourceFile, mem, "E2B001", "error", `memoryMB ${memValue} is not a positive even number of MiB, which E2B requires.`),
31
+ );
32
+ }
33
+ }
34
+ return diagnostics;
35
+ },
36
+ };
@@ -0,0 +1,23 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { completions } from "./completions";
3
+
4
+ const at = (linePrefix: string, wordAtCursor = "") => ({
5
+ uri: "file:///steward.ts",
6
+ content: linePrefix,
7
+ position: { line: 0, character: linePrefix.length },
8
+ wordAtCursor,
9
+ linePrefix,
10
+ });
11
+
12
+ describe("e2b LSP completions", () => {
13
+ it("offers Template after new", () => {
14
+ const items = completions(at("const s = new T", "T"));
15
+ expect(items.map((i) => i.label)).toEqual(["Template"]);
16
+ expect(items[0].detail).toBe("E2B::Template");
17
+ });
18
+
19
+ it("offers Template's properties inside its object", () => {
20
+ const labels = completions(at("new Template({ ")).map((i) => i.label);
21
+ for (const p of ["name", "fromImage", "run", "cpuCount", "memoryMB"]) expect(labels).toContain(p);
22
+ });
23
+ });
@@ -0,0 +1,13 @@
1
+ import type { CompletionContext, CompletionItem } from "@intentius/chant/lsp/types";
2
+ import { LexiconIndex, lexiconCompletions } from "@intentius/chant/lsp/lexicon-providers";
3
+ import { LEXICON_ENTRIES } from "../catalog";
4
+
5
+ const index = new LexiconIndex(LEXICON_ENTRIES);
6
+
7
+ /**
8
+ * e2b completions: `Template` after `new`, and its properties inside the
9
+ * object passed to it.
10
+ */
11
+ export function completions(ctx: CompletionContext): CompletionItem[] {
12
+ return lexiconCompletions(ctx, index, "E2B sandbox template");
13
+ }
@@ -0,0 +1,29 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { hover } from "./hover";
3
+
4
+ const on = (word: string, lineText: string, content = lineText) => ({
5
+ uri: "file:///steward.ts",
6
+ content,
7
+ position: { line: 0, character: 0 },
8
+ word,
9
+ lineText,
10
+ });
11
+
12
+ describe("e2b LSP hover", () => {
13
+ it("describes Template with what it builds into and its properties", () => {
14
+ const info = hover(on("Template", "new Template({})"));
15
+ expect(info?.contents).toContain("E2B::Template");
16
+ expect(info?.contents).toContain("e2b template create");
17
+ expect(info?.contents).toContain("`fromImage: string`");
18
+ });
19
+
20
+ it("describes a Template property in a file that builds one", () => {
21
+ const info = hover(on("memoryMB", " memoryMB: 2048,", 'new Template({\n memoryMB: 2048,\n})'));
22
+ expect(info?.contents).toContain("even");
23
+ });
24
+
25
+ it("stays quiet on unrelated words", () => {
26
+ expect(hover(on("x", "const x = 42"))).toBeUndefined();
27
+ expect(hover(on("name", "const name = 'a'"))).toBeUndefined();
28
+ });
29
+ });
@@ -0,0 +1,35 @@
1
+ import type { HoverContext, HoverInfo } from "@intentius/chant/lsp/types";
2
+ import { LEXICON_ENTRIES, TEMPLATE_PROPERTIES } from "../catalog";
3
+
4
+ /**
5
+ * e2b hover: the `Template` class, with its properties and what it builds
6
+ * into, and each of its properties by name.
7
+ */
8
+ export function hover(ctx: HoverContext): HoverInfo | undefined {
9
+ const { word } = ctx;
10
+ if (!word) return undefined;
11
+
12
+ const entry = LEXICON_ENTRIES[word];
13
+ if (entry) {
14
+ const lines = [
15
+ `**${word}**`,
16
+ "",
17
+ `E2B type: \`${entry.resourceType}\``,
18
+ "",
19
+ "*A sandbox template, written out as `e2b.<name>.Dockerfile` and built with `e2b template create`.*",
20
+ "",
21
+ ...Object.entries(TEMPLATE_PROPERTIES).map(
22
+ ([name, p]) => `- \`${name}${p.required ? "" : "?"}: ${p.type}\`: ${p.description}`,
23
+ ),
24
+ ];
25
+ return { contents: lines.join("\n") };
26
+ }
27
+
28
+ // A property name only means a Template property on a line that builds one
29
+ // or inside its object, so the hover stays quiet on an unrelated `name`.
30
+ const prop = TEMPLATE_PROPERTIES[word];
31
+ if (prop && /\bTemplate\b|^\s*\w+\??\s*:/.test(ctx.lineText ?? "") && ctx.content.includes("Template")) {
32
+ return { contents: `**${word}**${prop.required ? "" : " (optional)"}: \`${prop.type}\`\n\n${prop.description}` };
33
+ }
34
+ return undefined;
35
+ }
@@ -0,0 +1,136 @@
1
+ /**
2
+ * `ledger_sync`, the steward's reconciliation of its `chant/lifecycle` branch
3
+ * with the repository's, run against real git: a bare repository, the
4
+ * steward's long-lived clone and a second checkout that approves gates.
5
+ *
6
+ * The case it exists for: the steward keeps run records on its local branch
7
+ * only (chant pushes facts, not runs), so an approval pushed from elsewhere
8
+ * leaves the two branches diverged, and chant refuses to read a diverged
9
+ * ledger.
10
+ */
11
+
12
+ import { afterEach, beforeEach, describe, expect, it } from "vitest";
13
+ import { execFileSync } from "node:child_process";
14
+ import { mkdtempSync, rmSync } from "node:fs";
15
+ import { tmpdir } from "node:os";
16
+ import { join } from "node:path";
17
+ import { LEDGER_SYNC_FN } from "./runtime";
18
+
19
+ const BRANCH = "refs/heads/chant/lifecycle";
20
+ const IDENTITY = ["-c", "user.name=test", "-c", "user.email=test@example.com"];
21
+
22
+ let root: string;
23
+
24
+ function git(cwd: string, ...args: string[]): string {
25
+ return execFileSync("git", [...IDENTITY, ...args], { cwd, encoding: "utf8", stdio: ["pipe", "pipe", "pipe"] }).trim();
26
+ }
27
+
28
+ /** Append `line` to `path` on the clone's ledger branch, as chant's plumbing does: no checkout. */
29
+ function append(cwd: string, path: string, line: string): void {
30
+ const parent = (() => {
31
+ try {
32
+ return git(cwd, "rev-parse", "--verify", "-q", BRANCH);
33
+ } catch {
34
+ return "";
35
+ }
36
+ })();
37
+ const existing = parent ? (() => { try { return git(cwd, "show", `${parent}:${path}`) + "\n"; } catch { return ""; } })() : "";
38
+ const blob = execFileSync("git", ["hash-object", "-w", "--stdin"], { cwd, input: `${existing}${line}\n`, encoding: "utf8" }).trim();
39
+ const env = { ...process.env, GIT_INDEX_FILE: join(cwd, ".git", "test-index") };
40
+ if (parent) execFileSync("git", ["read-tree", parent], { cwd, env });
41
+ else execFileSync("git", ["read-tree", "--empty"], { cwd, env });
42
+ execFileSync("git", ["update-index", "--add", "--cacheinfo", `100644,${blob},${path}`], { cwd, env });
43
+ const tree = execFileSync("git", ["write-tree"], { cwd, env, encoding: "utf8" }).trim();
44
+ const commit = execFileSync("git", [...IDENTITY, "commit-tree", tree, ...(parent ? ["-p", parent] : []), "-m", line], { cwd, encoding: "utf8" }).trim();
45
+ git(cwd, "update-ref", BRANCH, commit);
46
+ }
47
+
48
+ function sync(cwd: string): string {
49
+ git(cwd, "fetch", "--quiet", "origin", "+refs/heads/*:refs/remotes/origin/*");
50
+ return execFileSync("bash", ["-c", `${LEDGER_SYNC_FN}\nledger_sync`], { cwd, encoding: "utf8", stdio: ["pipe", "pipe", "pipe"] });
51
+ }
52
+
53
+ function remoteFile(path: string): string[] {
54
+ return git(join(root, "remote.git"), "show", `chant/lifecycle:${path}`).split("\n");
55
+ }
56
+
57
+ beforeEach(() => {
58
+ root = mkdtempSync(join(tmpdir(), "e2b-ledger-"));
59
+ execFileSync("git", ["init", "--quiet", "--bare", "remote.git"], { cwd: root });
60
+ for (const clone of ["steward", "laptop"]) {
61
+ execFileSync("git", ["clone", "--quiet", join(root, "remote.git"), clone], { cwd: root, stdio: "pipe" });
62
+ }
63
+ });
64
+
65
+ afterEach(() => {
66
+ rmSync(root, { recursive: true, force: true });
67
+ });
68
+
69
+ describe("ledger_sync", () => {
70
+ it("does nothing without a local ledger", () => {
71
+ sync(join(root, "steward"));
72
+ expect(() => git(join(root, "remote.git"), "rev-parse", "--verify", "chant/lifecycle")).toThrow();
73
+ });
74
+
75
+ it("pushes local-only run records when the steward is only ahead", () => {
76
+ const steward = join(root, "steward");
77
+ append(steward, "local/runs__deploy.jsonl", '{"id":"r1"}');
78
+ sync(steward);
79
+ expect(remoteFile("local/runs__deploy.jsonl")).toEqual(['{"id":"r1"}']);
80
+ });
81
+
82
+ it("merges a diverged ledger, keeping both sides' lines, and pushes it", () => {
83
+ const steward = join(root, "steward");
84
+ const laptop = join(root, "laptop");
85
+
86
+ // The steward's gate: its pending fact is pushed, as chant pushes it.
87
+ append(steward, "_gates/deploy.jsonl", '{"kind":"pending"}');
88
+ git(steward, "push", "--quiet", "origin", `${BRANCH}:${BRANCH}`);
89
+ // Its run record stays local, as chant leaves it.
90
+ append(steward, "local/runs__deploy.jsonl", '{"id":"r1","status":"gated"}');
91
+
92
+ // An approval from another checkout.
93
+ git(laptop, "fetch", "--quiet", "origin", `+${BRANCH}:${BRANCH}`);
94
+ append(laptop, "_gates/deploy.jsonl", '{"kind":"resolution"}');
95
+ git(laptop, "push", "--quiet", "origin", `${BRANCH}:${BRANCH}`);
96
+
97
+ const stderr = sync(steward);
98
+ void stderr;
99
+
100
+ expect(remoteFile("_gates/deploy.jsonl")).toEqual(['{"kind":"pending"}', '{"kind":"resolution"}']);
101
+ expect(remoteFile("local/runs__deploy.jsonl")).toEqual(['{"id":"r1","status":"gated"}']);
102
+ // Both tips are ancestors of the merge, so neither side diverges from it again.
103
+ const tip = git(steward, "rev-parse", BRANCH);
104
+ expect(git(join(root, "remote.git"), "rev-parse", "chant/lifecycle")).toBe(tip);
105
+ git(laptop, "fetch", "--quiet", "origin", `${BRANCH}:${BRANCH}`);
106
+ expect(git(laptop, "rev-parse", BRANCH)).toBe(tip);
107
+ });
108
+
109
+ it("unions an append-only file both sides wrote to", () => {
110
+ const steward = join(root, "steward");
111
+ const laptop = join(root, "laptop");
112
+ append(steward, "local/runs__deploy.jsonl", '{"id":"r1"}');
113
+ git(steward, "push", "--quiet", "origin", `${BRANCH}:${BRANCH}`);
114
+ append(steward, "local/runs__deploy.jsonl", '{"id":"r2"}');
115
+ git(laptop, "fetch", "--quiet", "origin", `+${BRANCH}:${BRANCH}`);
116
+ append(laptop, "local/runs__deploy.jsonl", '{"id":"l1"}');
117
+ git(laptop, "push", "--quiet", "origin", `${BRANCH}:${BRANCH}`);
118
+
119
+ sync(steward);
120
+ expect(remoteFile("local/runs__deploy.jsonl")).toEqual(['{"id":"r1"}', '{"id":"l1"}', '{"id":"r2"}']);
121
+ });
122
+
123
+ it("leaves a steward that is behind for chant's own fetch", () => {
124
+ const steward = join(root, "steward");
125
+ const laptop = join(root, "laptop");
126
+ append(steward, "_gates/deploy.jsonl", '{"kind":"pending"}');
127
+ git(steward, "push", "--quiet", "origin", `${BRANCH}:${BRANCH}`);
128
+ git(laptop, "fetch", "--quiet", "origin", `+${BRANCH}:${BRANCH}`);
129
+ append(laptop, "_gates/deploy.jsonl", '{"kind":"resolution"}');
130
+ git(laptop, "push", "--quiet", "origin", `${BRANCH}:${BRANCH}`);
131
+
132
+ const before = git(steward, "rev-parse", BRANCH);
133
+ sync(steward);
134
+ expect(git(steward, "rev-parse", BRANCH)).toBe(before);
135
+ });
136
+ });