@tryaura/aura-testkit 0.1.1 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -13,14 +13,20 @@ run spawns.
13
13
  ```ts
14
14
  import { createSeedBuilder, runCheck } from "@tryaura/aura-testkit";
15
15
 
16
- it("reports a legacy config", async () => {
16
+ it("runs a distribution against a fake machine", async () => {
17
17
  await using seed = await createSeedBuilder()
18
18
  .homeFile(".fixture/config.txt", "legacy=true\n")
19
19
  .workspaceFile("AGENTS.md", "workspace instructions\n")
20
20
  .shim("fixture-agent", [{ args: ["--version"], stdout: "fixture-agent 1.2.3\n" }])
21
21
  .build();
22
22
 
23
- const result = await runCheck({ distro, seed });
23
+ const result = await runCheck({
24
+ distro: {
25
+ branding: { command: "fixture", displayName: "Fixture Doctor" },
26
+ plugins: [],
27
+ },
28
+ seed,
29
+ });
24
30
 
25
31
  expect(result.findings).toMatchInlineSnapshot();
26
32
  });
@@ -59,6 +65,26 @@ Every invocation is recorded, matched or not:
59
65
  await expect(seed.invocations("fixture-agent")).resolves.toEqual([["--version"]]);
60
66
  ```
61
67
 
68
+ Asking for a command that was not seeded rejects and lists the known shims. A seeded command that
69
+ has not run resolves to an empty list.
70
+
71
+ ### Shim record contract
72
+
73
+ The invocation log is a public, versioned format. Each invocation is one ASCII line:
74
+
75
+ ```text
76
+ aura-testkit-v1<TAB><argument-count><TAB><base64-UTF-8-argument>...<LF>
77
+ ```
78
+
79
+ Empty and multiline arguments round-trip because every argument is encoded separately. A shim
80
+ builds the complete record in memory and appends it with one `printf`, so parallel invocations do
81
+ not interleave fields. Records are capped at 2,048 bytes; an invocation over that limit writes a
82
+ versioned truncation marker, and `seed.invocations(command)` rejects with the record size and limit
83
+ instead of returning incomplete arguments.
84
+
85
+ Generated shims require a POSIX shell and the standard `base64` and `tr` utilities available at
86
+ `/usr/bin`. Seed building fails immediately on Windows with an explicit platform error.
87
+
62
88
  Versioned official-app fixtures are also exported for adapter and binary integration tests:
63
89
 
64
90
  ```ts
@@ -88,7 +114,8 @@ await using cursorSeed = await createCursorSeed({
88
114
  needs to prove a check ran: a check that threw reports no findings and is explained only by
89
115
  `report.diagnostics`.
90
116
  - `findings` — shorthand for `report.findings`.
91
- - `exitCode` — `0`, `1`, or `2`.
117
+ - `exitCode` — `0` for a completed check, `2` for usage/state conflicts, or `3` for operational
118
+ failures. Inspect report status or severity counts to assert finding health.
92
119
  - `diffs` — every change under the fake HOME and workspace, as unified patches whose first line is
93
120
  the entry's permission bits. Permission-only changes, empty directories, and binary files are all
94
121
  visible. The seeded `PATH` directory is not diffed; use `seed.invocations` for what a shim did.
package/dist/index.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { CheckReport, CheckReport as CheckReport$1, CliDistro, CliExitCode, ReportFinding } from "@tryaura/aura-cli";
1
+ import { CheckReport, CheckReport as CheckReport$1, CliDistro, CliDistro as CliDistro$1, CliExitCode, ReportFinding } from "@tryaura/aura-cli";
2
2
  import { Environment } from "@tryaura/aura-sdk";
3
3
  //#region src/types.d.ts
4
4
  /**
@@ -36,8 +36,8 @@ interface TestSeed {
36
36
  * Every invocation of one seeded shim, in the order it happened.
37
37
  *
38
38
  * Records invocations no response matched too, which is what turns "the shim answered `exit 2`"
39
- * into "the adapter asked for arguments no response declares". Empty for a command that was never
40
- * run, or was never seeded.
39
+ * into "the adapter asked for arguments no response declares". Empty when the seeded command was
40
+ * never run. Asking for a command that was not seeded throws and names the known shims.
41
41
  */
42
42
  readonly invocations: (command: string) => Promise<readonly (readonly string[])[]>;
43
43
  }
@@ -59,13 +59,20 @@ type SeedContent = string | ((roots: SeedRoots) => string);
59
59
  interface TestSeedBuilder {
60
60
  homeFile(path: string, content: SeedContent): TestSeedBuilder;
61
61
  shim(command: string, responses: readonly ShimResponse[]): TestSeedBuilder;
62
+ /**
63
+ * Records the seeded workspace preset as trusted in the seed's `agents/aura.json`.
64
+ *
65
+ * A repository preset applies only after a person accepted it, and `--yes` runs never accept
66
+ * one; a seed that wants the preset in effect declares the acceptance that already happened.
67
+ */
68
+ trustWorkspacePreset(path?: string): TestSeedBuilder;
62
69
  workspaceFile(path: string, content: SeedContent): TestSeedBuilder;
63
70
  build(): Promise<TestSeed>;
64
71
  }
65
72
  interface RunCheckOptions {
66
73
  /** Check-command flags other than `--json`, which the runner always supplies. */
67
74
  readonly args?: readonly string[] | undefined;
68
- readonly distro: CliDistro;
75
+ readonly distro: CliDistro$1;
69
76
  readonly seed: TestSeed;
70
77
  }
71
78
  /** Options for running a compiled Aura distribution against a deterministic seed. */
@@ -131,7 +138,7 @@ declare function runCheck(options: RunCheckOptions): Promise<TestRunResult>;
131
138
  interface RunSetupOptions {
132
139
  /** Setup-command flags other than `--yes`, which the runner always supplies. */
133
140
  readonly args?: readonly string[] | undefined;
134
- readonly distro: CliDistro;
141
+ readonly distro: CliDistro$1;
135
142
  /** Extra variables visible to the run, such as a skill-directory token. `PATH` stays seeded. */
136
143
  readonly environmentVariables?: Readonly<Record<string, string>> | undefined;
137
144
  readonly seed: TestSeed;
@@ -196,7 +203,7 @@ type TreeEntry = BinaryEntry | DirectoryEntry | FileEntry | SymlinkEntry;
196
203
  type FilesystemSnapshot = ReadonlyMap<string, TreeEntry>;
197
204
  declare function captureFilesystem(seed: TestSeed): Promise<FilesystemSnapshot>;
198
205
  //#endregion
199
- //#region src/seed.d.ts
206
+ //#region src/seed.boundary.d.ts
200
207
  /** Starts a fluent description of one isolated fake machine. */
201
208
  declare function createSeedBuilder(): TestSeedBuilder;
202
209
  //#endregion
@@ -303,4 +310,4 @@ declare function cursorShimResponses(options: Pick<CursorSeedOptions, "version">
303
310
  /** Builds documented Cursor rule and MCP configuration against an exact editor version. */
304
311
  declare function createCursorSeed(options: CursorSeedOptions): Promise<TestSeed>;
305
312
  //#endregion
306
- export { ANY_ARGUMENT, CODEX_NESTED_PACKAGE, type CheckReport, type ClaudeCodeFixtureVersion, type ClaudeCodeSeedOptions, type CodexFixtureVersion, type CodexProjectInstructions, type CodexSeedOptions, type ConvergedTwiceResult, type ConvergenceRunResult, type CursorFixtureVersion, type CursorRulesFixture, type CursorSeedOptions, type FilesystemSnapshot, type MockDirectory, type MockDirectoryBuilder, type MockDirectoryFile, type MockDirectoryListing, type MockDirectoryRequest, type RunBinaryCheckOptions, type RunCheckOptions, type RunSetupOptions, type SetupRunResult, type ShimArgument, type ShimResponse, type TestFileDiff, type TestFileDiffStatus, type TestRunResult, type TestSeed, type TestSeedBuilder, captureFilesystem, claudeCodeShimResponses, codexShimResponses, createClaudeCodeSeed, createCodexSeed, createCursorSeed, createMockDirectoryBuilder, createSeedBuilder, cursorShimResponses, expectConvergedTwice, loopbackOnlyHttpGet, runBinaryCheck, runCheck, runSetup };
313
+ export { ANY_ARGUMENT, CODEX_NESTED_PACKAGE, type CheckReport, type ClaudeCodeFixtureVersion, type ClaudeCodeSeedOptions, type CliDistro, type CodexFixtureVersion, type CodexProjectInstructions, type CodexSeedOptions, type ConvergedTwiceResult, type ConvergenceRunResult, type CursorFixtureVersion, type CursorRulesFixture, type CursorSeedOptions, type FilesystemSnapshot, type MockDirectory, type MockDirectoryBuilder, type MockDirectoryFile, type MockDirectoryListing, type MockDirectoryRequest, type RunBinaryCheckOptions, type RunCheckOptions, type RunSetupOptions, type SetupRunResult, type ShimArgument, type ShimResponse, type TestFileDiff, type TestFileDiffStatus, type TestRunResult, type TestSeed, type TestSeedBuilder, captureFilesystem, claudeCodeShimResponses, codexShimResponses, createClaudeCodeSeed, createCodexSeed, createCursorSeed, createMockDirectoryBuilder, createSeedBuilder, cursorShimResponses, expectConvergedTwice, loopbackOnlyHttpGet, runBinaryCheck, runCheck, runSetup };
package/dist/index.js CHANGED
@@ -4,7 +4,7 @@ import { createHash } from "node:crypto";
4
4
  import { chmod, lstat, mkdir, mkdtemp, readFile, readdir, readlink, realpath, rm, writeFile } from "node:fs/promises";
5
5
  import { PassThrough, Readable } from "node:stream";
6
6
  import { runCli } from "@tryaura/aura-cli";
7
- import { tmpdir } from "node:os";
7
+ import { platform, tmpdir } from "node:os";
8
8
  import { DEFAULT_HTTP_TIMEOUT_MS, MAX_HTTP_RESPONSE_BYTES, MAX_HTTP_TIMEOUT_MS } from "@tryaura/aura-sdk";
9
9
  import { createServer } from "node:http";
10
10
  //#region ../../node_modules/.pnpm/diff@9.0.0/node_modules/diff/libesm/diff/base.js
@@ -850,12 +850,12 @@ function toPortablePath(path) {
850
850
  //#endregion
851
851
  //#region src/guards.ts
852
852
  /** Narrows to a plain JSON-shaped object, excluding arrays and null. */
853
- function isRecord(value) {
853
+ function isRecord$1(value) {
854
854
  return typeof value === "object" && value !== null && !Array.isArray(value);
855
855
  }
856
856
  /** Freezes a JSON-shaped value all the way down. */
857
857
  function deepFreeze(value) {
858
- if (!isRecord(value) && !Array.isArray(value)) return value;
858
+ if (!isRecord$1(value) && !Array.isArray(value)) return value;
859
859
  for (const property of Object.values(value)) deepFreeze(property);
860
860
  return Object.freeze(value);
861
861
  }
@@ -886,7 +886,7 @@ function reject(path, expectation, problems) {
886
886
  /** Validates the declared keys and ignores every other one, which is what lets the report grow. */
887
887
  function shape(fields) {
888
888
  return (value, path, problems) => {
889
- if (!isRecord(value)) return reject(path, "an object", problems);
889
+ if (!isRecord$1(value)) return reject(path, "an object", problems);
890
890
  let valid = true;
891
891
  for (const [key, check] of Object.entries(fields)) valid = check(value[key], `${path}.${key}`, problems) && valid;
892
892
  return valid;
@@ -902,7 +902,7 @@ function arrayOf(check) {
902
902
  }
903
903
  function recordOf(check) {
904
904
  return (value, path, problems) => {
905
- if (!isRecord(value)) return reject(path, "an object", problems);
905
+ if (!isRecord$1(value)) return reject(path, "an object", problems);
906
906
  let valid = true;
907
907
  for (const [key, entry] of Object.entries(value)) valid = check(entry, `${path}.${key}`, problems) && valid;
908
908
  return valid;
@@ -959,15 +959,15 @@ const findingFields = shape({
959
959
  */
960
960
  const finding = (value, path, problems) => {
961
961
  const valid = findingFields(value, path, problems);
962
- return isRecord(value) ? tableRowsAreReadable(value, path, problems) && valid : valid;
962
+ return isRecord$1(value) ? tableRowsAreReadable(value, path, problems) && valid : valid;
963
963
  };
964
964
  function tableRowsAreReadable(value, path, problems) {
965
965
  const presentation = value["presentation"];
966
- if (!isRecord(presentation) || presentation["kind"] !== "metadata-table") return true;
966
+ if (!isRecord$1(presentation) || presentation["kind"] !== "metadata-table") return true;
967
967
  const rowsKey = presentation["rowsKey"];
968
968
  const metadata = value["metadata"];
969
- const rows = typeof rowsKey === "string" && isRecord(metadata) ? metadata[rowsKey] : void 0;
970
- if (!Array.isArray(rows) || !rows.every(isRecord)) return reject(`${path}.metadata.${typeof rowsKey === "string" ? rowsKey : "?"}`, "an array of objects, because presentation.rowsKey names it", problems);
969
+ const rows = typeof rowsKey === "string" && isRecord$1(metadata) ? metadata[rowsKey] : void 0;
970
+ if (!Array.isArray(rows) || !rows.every(isRecord$1)) return reject(`${path}.metadata.${typeof rowsKey === "string" ? rowsKey : "?"}`, "an array of objects, because presentation.rowsKey names it", problems);
971
971
  const keys = columnKeys(presentation["columns"]);
972
972
  if (keys.length === 0) return true;
973
973
  return rows.every((row, index) => keys.some((key) => row[key] !== void 0) ? true : reject(`${path}.metadata.${String(rowsKey)}[${String(index)}]`, `a row carrying at least one column key (${keys.join(", ")})`, problems));
@@ -975,7 +975,7 @@ function tableRowsAreReadable(value, path, problems) {
975
975
  function columnKeys(columns) {
976
976
  if (!Array.isArray(columns)) return [];
977
977
  return columns.flatMap((column) => {
978
- const key = isRecord(column) ? column["key"] : void 0;
978
+ const key = isRecord$1(column) ? column["key"] : void 0;
979
979
  return typeof key === "string" ? [key] : [];
980
980
  });
981
981
  }
@@ -1039,7 +1039,7 @@ const REPORT = {
1039
1039
  })
1040
1040
  };
1041
1041
  function isJsonObject(value) {
1042
- return isRecord(value) && Object.values(value).every(isJsonValue);
1042
+ return isRecord$1(value) && Object.values(value).every(isJsonValue);
1043
1043
  }
1044
1044
  function isJsonValue(value) {
1045
1045
  return value === null || typeof value === "boolean" || typeof value === "number" || typeof value === "string" || Array.isArray(value) && value.every(isJsonValue) || isJsonObject(value);
@@ -1218,7 +1218,7 @@ function executeBinary(options) {
1218
1218
  });
1219
1219
  }
1220
1220
  //#endregion
1221
- //#region ../core/src/http.ts
1221
+ //#region ../core/src/http.boundary.ts
1222
1222
  /**
1223
1223
  * Hosts allowed to serve plain `http:`.
1224
1224
  *
@@ -1345,6 +1345,39 @@ function failureReason(error) {
1345
1345
  return name === "TimeoutError" || name === "AbortError" ? "timeout" : "network";
1346
1346
  }
1347
1347
  //#endregion
1348
+ //#region ../core/src/managed-block/protocol.ts
1349
+ /**
1350
+ * Normalizes snippet text to the bytes covered by the managed-block hash protocol.
1351
+ *
1352
+ * Trailing newlines are trimmed by scanning rather than with `/\n+$/`, whose backtracking is
1353
+ * quadratic when a long newline run does not reach the end of the string.
1354
+ */
1355
+ function canonicalizeManagedSnippet(content) {
1356
+ const normalized = content.replace(/\r\n?/g, "\n");
1357
+ let end = normalized.length;
1358
+ while (end > 0 && normalized[end - 1] === "\n") end -= 1;
1359
+ return `${normalized.slice(0, end)}\n`;
1360
+ }
1361
+ /** Computes the protocol hash for text already in {@link canonicalizeManagedSnippet} form. */
1362
+ function hashCanonicalManagedSnippet(canonical) {
1363
+ return createHash("sha256").update(canonical, "utf8").digest("hex");
1364
+ }
1365
+ /** Computes the protocol hash for a snippet's canonical UTF-8 contents. */
1366
+ function hashManagedSnippet(content) {
1367
+ return hashCanonicalManagedSnippet(canonicalizeManagedSnippet(content));
1368
+ }
1369
+ //#endregion
1370
+ //#region ../core/src/preset/repo-trust.ts
1371
+ /**
1372
+ * Hashes repository preset contents for the trust record.
1373
+ *
1374
+ * Uses the managed-snippet canonicalization so a checkout that rewrites line endings does not
1375
+ * invalidate a trust the user already granted to the same bytes-as-authored.
1376
+ */
1377
+ function hashRepoPreset(content) {
1378
+ return hashManagedSnippet(content);
1379
+ }
1380
+ //#endregion
1348
1381
  //#region src/http.ts
1349
1382
  const LOOPBACK_HOSTS = /* @__PURE__ */ new Set(["127.0.0.1", "[::1]"]);
1350
1383
  const realHttpGet = createHttpGet();
@@ -1540,6 +1573,8 @@ const ANY_ARGUMENT = Symbol("aura-testkit.anyArgument");
1540
1573
  //#region src/shims.ts
1541
1574
  const COMMAND_NAME = /^[A-Za-z0-9._+-]+$/u;
1542
1575
  const RESERVED_NAMES = /* @__PURE__ */ new Set([".", ".."]);
1576
+ const RECORD_FORMAT = "aura-testkit-v1";
1577
+ const RECORD_LIMIT_BYTES = 2048;
1543
1578
  async function writeShim(options) {
1544
1579
  const { command, logDir, pathDir, responses } = options;
1545
1580
  validateShim(command, responses);
@@ -1550,12 +1585,15 @@ async function writeShim(options) {
1550
1585
  /**
1551
1586
  * Reads back every invocation one shim recorded.
1552
1587
  *
1553
- * The log frames each record as its argument count followed by that many arguments, all
1554
- * NUL-terminated. Arguments cannot contain NUL {@link validateShim} rejects that so the framing
1555
- * stays unambiguous for empty arguments and for arguments holding newlines.
1588
+ * Each invocation is one LF-terminated ASCII record containing the format version, argument count,
1589
+ * and one base64-encoded UTF-8 field per argument, separated by tabs. The shim appends the complete
1590
+ * record with one `printf`, so concurrent invocations cannot interleave individual fields.
1591
+ *
1592
+ * A missing log means the seeded shim has not run. Invalid command names and malformed or truncated
1593
+ * records throw instead of being conflated with that valid empty state.
1556
1594
  */
1557
1595
  async function readInvocations(logDir, command) {
1558
- if (!isPortableCommandName(command)) return [];
1596
+ if (!isPortableCommandName(command)) throw new Error(`Shim command must be a portable executable name. Received: ${command}`);
1559
1597
  let log;
1560
1598
  try {
1561
1599
  log = await readFile(join(logDir, command), "utf8");
@@ -1563,16 +1601,10 @@ async function readInvocations(logDir, command) {
1563
1601
  if (isNotFound(error)) return [];
1564
1602
  throw error;
1565
1603
  }
1566
- const fields = log.split("\0");
1567
- fields.pop();
1568
1604
  const invocations = [];
1569
- let cursor = 0;
1570
- while (cursor < fields.length) {
1571
- const count = Number(fields[cursor]);
1572
- if (!Number.isInteger(count) || count < 0 || cursor + 1 + count > fields.length) throw new Error(`Shim ${command} wrote an invocation log this runner cannot read.`);
1573
- invocations.push(Object.freeze(fields.slice(cursor + 1, cursor + 1 + count)));
1574
- cursor += 1 + count;
1575
- }
1605
+ const records = log.split("\n");
1606
+ if (records.pop() !== "") throw unreadableInvocationLog(command);
1607
+ for (const record of records) invocations.push(parseInvocationRecord(command, record));
1576
1608
  return Object.freeze(invocations);
1577
1609
  }
1578
1610
  function validateShim(command, responses) {
@@ -1610,12 +1642,17 @@ function validateResponse(command, response) {
1610
1642
  function renderShim(command, logPath, responses) {
1611
1643
  return [
1612
1644
  "#!/bin/sh",
1613
- "{",
1614
- ` printf '%s\\0' "$#"`,
1615
- " for aura_recorded in \"$@\"; do",
1616
- ` printf '%s\\0' "$aura_recorded"`,
1617
- " done",
1618
- `} >> ${shellQuote(logPath)}`,
1645
+ `aura_record="${RECORD_FORMAT}\t$#"`,
1646
+ "for aura_recorded in \"$@\"; do",
1647
+ ` aura_encoded=$(printf '%s' "$aura_recorded" | /usr/bin/base64 | /usr/bin/tr -d '\\r\\n')`,
1648
+ " aura_record=\"${aura_record} ${aura_encoded}\"",
1649
+ "done",
1650
+ "aura_record=\"${aura_record}\n\"",
1651
+ "aura_record_length=${#aura_record}",
1652
+ `if [ "$aura_record_length" -gt ${String(RECORD_LIMIT_BYTES)} ]; then`,
1653
+ ` aura_record="${RECORD_FORMAT}\ttruncated\t\${aura_record_length}\n"`,
1654
+ "fi",
1655
+ `printf '%s' "$aura_record" >> ${shellQuote(logPath)}`,
1619
1656
  ...responses.map(renderResponse),
1620
1657
  `printf '%s' ${shellQuote(`aura-testkit: unmatched invocation: ${command}`)} >&2`,
1621
1658
  "for aura_reported in \"$@\"; do",
@@ -1639,13 +1676,47 @@ function shellQuote(value) {
1639
1676
  return `'${value.replaceAll("'", `'"'"'`)}'`;
1640
1677
  }
1641
1678
  function isNotFound(error) {
1642
- return isRecord(error) && error["code"] === "ENOENT";
1679
+ return isRecord$1(error) && error["code"] === "ENOENT";
1680
+ }
1681
+ function isUnsignedInteger(value) {
1682
+ return /^(?:0|[1-9][0-9]*)$/u.test(value);
1683
+ }
1684
+ function parseInvocationRecord(command, record) {
1685
+ const fields = record.split(" ");
1686
+ if (fields[0] !== RECORD_FORMAT) throw unreadableInvocationLog(command);
1687
+ if (fields[1] === "truncated") throwTruncatedInvocation(command, fields);
1688
+ return parseInvocationFields(command, fields);
1689
+ }
1690
+ function throwTruncatedInvocation(command, fields) {
1691
+ const recordLength = fields[2];
1692
+ if (fields.length !== 3 || recordLength === void 0 || !isUnsignedInteger(recordLength)) throw unreadableInvocationLog(command);
1693
+ throw new Error(`Shim ${command} invocation produced a ${recordLength}-byte log record, exceeding the ${String(RECORD_LIMIT_BYTES)}-byte atomic limit. Reduce the invocation's arguments.`);
1694
+ }
1695
+ function parseInvocationFields(command, fields) {
1696
+ const countField = fields[1];
1697
+ if (countField === void 0 || !isUnsignedInteger(countField)) throw unreadableInvocationLog(command);
1698
+ const count = Number(countField);
1699
+ if (!Number.isSafeInteger(count) || fields.length !== count + 2) throw unreadableInvocationLog(command);
1700
+ return Object.freeze(fields.slice(2).map((field) => decodeArgument(command, field)));
1701
+ }
1702
+ function decodeArgument(command, value) {
1703
+ const bytes = Buffer.from(value, "base64");
1704
+ if (bytes.toString("base64") !== value) throw unreadableInvocationLog(command);
1705
+ try {
1706
+ return new TextDecoder("utf-8", { fatal: true }).decode(bytes);
1707
+ } catch {
1708
+ throw unreadableInvocationLog(command);
1709
+ }
1710
+ }
1711
+ function unreadableInvocationLog(command) {
1712
+ return /* @__PURE__ */ new Error(`Shim ${command} wrote an invocation log this runner cannot read.`);
1643
1713
  }
1644
1714
  //#endregion
1645
- //#region src/seed.ts
1715
+ //#region src/seed.boundary.ts
1646
1716
  var SeedBuilder = class {
1647
1717
  #homeFiles = /* @__PURE__ */ new Map();
1648
1718
  #shims = /* @__PURE__ */ new Map();
1719
+ #trustedPresets = /* @__PURE__ */ new Set();
1649
1720
  #workspaceFiles = /* @__PURE__ */ new Map();
1650
1721
  homeFile(path, content) {
1651
1722
  addFile(this.#homeFiles, "HOME", path, content);
@@ -1663,11 +1734,16 @@ var SeedBuilder = class {
1663
1734
  });
1664
1735
  return this;
1665
1736
  }
1737
+ trustWorkspacePreset(path = ".aura/preset.json") {
1738
+ this.#trustedPresets.add(normalizeSeedPath(path));
1739
+ return this;
1740
+ }
1666
1741
  workspaceFile(path, content) {
1667
1742
  addFile(this.#workspaceFiles, "workspace", path, content);
1668
1743
  return this;
1669
1744
  }
1670
1745
  async build() {
1746
+ assertSupportedPlatform(platform());
1671
1747
  const root = await realpath(await mkdtemp(join(tmpdir(), "aura-testkit-")));
1672
1748
  const homeDir = join(root, "home");
1673
1749
  const logDir = join(root, "invocations");
@@ -1692,6 +1768,7 @@ var SeedBuilder = class {
1692
1768
  pathDir,
1693
1769
  responses: shim.responses
1694
1770
  })));
1771
+ if (this.#trustedPresets.size > 0) await recordTrustedPresets(homeDir, workspaceDir, this.#trustedPresets);
1695
1772
  } catch (error) {
1696
1773
  await rm(root, {
1697
1774
  force: true,
@@ -1710,7 +1787,13 @@ var SeedBuilder = class {
1710
1787
  return Object.freeze({
1711
1788
  cleanup,
1712
1789
  homeDir,
1713
- invocations: (command) => commands.has(command) ? readInvocations(logDir, command) : Promise.resolve([]),
1790
+ invocations: async (command) => {
1791
+ if (!commands.has(command)) {
1792
+ const known = [...commands].sort().join(", ") || "(none)";
1793
+ throw new Error(`Unknown shim command: ${command}. Known shims: ${known}.`);
1794
+ }
1795
+ return readInvocations(logDir, command);
1796
+ },
1714
1797
  pathDir,
1715
1798
  workspaceDir,
1716
1799
  [Symbol.asyncDispose]: cleanup
@@ -1721,6 +1804,69 @@ var SeedBuilder = class {
1721
1804
  function createSeedBuilder() {
1722
1805
  return new SeedBuilder();
1723
1806
  }
1807
+ /**
1808
+ * Merges trust records for the seeded presets into the seed's manifest.
1809
+ *
1810
+ * A trust record binds an absolute preset path to a hash of its contents, and both exist only
1811
+ * once the seed is materialized, so this runs after the files are written and reads them back.
1812
+ */
1813
+ async function recordTrustedPresets(homeDir, workspaceDir, presets) {
1814
+ const manifestPath = join(homeDir, "agents", "aura.json");
1815
+ const existing = await readFile(manifestPath, "utf8").catch(() => void 0);
1816
+ const manifest = existing === void 0 ? {
1817
+ apps: {},
1818
+ mcpServers: [],
1819
+ ownership: {},
1820
+ schemaVersion: 1,
1821
+ skills: [],
1822
+ snippets: []
1823
+ } : parseSeedManifest(existing, manifestPath);
1824
+ const added = [];
1825
+ for (const relativePath of presets) {
1826
+ const path = join(workspaceDir, relativePath);
1827
+ const content = await readFile(path, "utf8").catch(() => {
1828
+ throw new Error(`trustWorkspacePreset needs the seeded workspace file: ${relativePath}`);
1829
+ });
1830
+ added.push({
1831
+ hash: hashRepoPreset(content),
1832
+ path
1833
+ });
1834
+ }
1835
+ const addedPaths = new Set(added.map((entry) => entry.path));
1836
+ const trustedRepoPresets = [...parseTrustedRepoPresets(manifest["trustedRepoPresets"], manifestPath).filter((entry) => !addedPaths.has(entry.path)), ...added];
1837
+ if (trustedRepoPresets.length > 64) throw new Error(`Seed manifest ${manifestPath} exceeds the ${String(64)} trusted repository preset limit`);
1838
+ await mkdir(dirname(manifestPath), { recursive: true });
1839
+ await writeFile(manifestPath, `${JSON.stringify({
1840
+ ...manifest,
1841
+ trustedRepoPresets
1842
+ }, void 0, 2)}\n`, {
1843
+ encoding: "utf8",
1844
+ mode: 384
1845
+ });
1846
+ }
1847
+ function parseSeedManifest(source, path) {
1848
+ const value = JSON.parse(source);
1849
+ if (!isRecord(value)) throw new Error(`Seed manifest ${path} must contain a JSON object`);
1850
+ return value;
1851
+ }
1852
+ function parseTrustedRepoPresets(value, manifestPath) {
1853
+ if (value === void 0) return [];
1854
+ if (!Array.isArray(value)) throw new Error(`Seed manifest ${manifestPath} trustedRepoPresets must be an array`);
1855
+ return value.map((candidate, index) => {
1856
+ if (!isRecord(candidate) || typeof candidate["hash"] !== "string" || typeof candidate["path"] !== "string") throw new Error(`Seed manifest ${manifestPath} trustedRepoPresets[${String(index)}] must contain string hash and path fields`);
1857
+ return {
1858
+ ...candidate,
1859
+ hash: candidate["hash"],
1860
+ path: candidate["path"]
1861
+ };
1862
+ });
1863
+ }
1864
+ function isRecord(value) {
1865
+ return typeof value === "object" && value !== null && !Array.isArray(value);
1866
+ }
1867
+ function assertSupportedPlatform(platform) {
1868
+ if (platform === "win32") throw new Error("Aura testkit requires a POSIX shell and cannot build seeds on Windows.");
1869
+ }
1724
1870
  function addFile(files, scope, path, content) {
1725
1871
  const normalized = normalizeSeedPath(path);
1726
1872
  if (files.has(normalized)) throw new Error(`${scope} file is already seeded: ${normalized}`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tryaura/aura-testkit",
3
- "version": "0.1.1",
3
+ "version": "0.2.0",
4
4
  "description": "Deterministic filesystem seeds and CLI harnesses for Aura plugins and distributions.",
5
5
  "keywords": [
6
6
  "aura",
@@ -30,17 +30,17 @@
30
30
  "access": "public"
31
31
  },
32
32
  "dependencies": {
33
- "@tryaura/aura-sdk": "0.1.1",
34
- "@tryaura/aura-cli": "0.1.1"
33
+ "@tryaura/aura-cli": "0.2.0",
34
+ "@tryaura/aura-sdk": "0.2.0"
35
35
  },
36
36
  "devDependencies": {
37
37
  "@types/node": "24.13.3",
38
38
  "diff": "9.0.0",
39
39
  "vitest": "4.1.10",
40
- "@tryaura/core": "0.0.0",
41
40
  "@tryaura/adapter-codex": "0.0.0",
41
+ "@tryaura/adapter-claude-code": "0.0.0",
42
42
  "@tryaura/adapter-cursor": "0.0.0",
43
- "@tryaura/adapter-claude-code": "0.0.0"
43
+ "@tryaura/core": "0.0.0"
44
44
  },
45
45
  "engines": {
46
46
  "node": ">=24"