carveout 0.3.1 → 0.4.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
@@ -270,9 +270,17 @@ agent once per stop:
270
270
  | `carveout whoami` | Print the account, workspace and token expiry signed in with |
271
271
  | `carveout logout` | Delete the saved platform token |
272
272
 
273
- Every option: [reference](docs/REFERENCE.md#commands). Only `login`, `whoami`
274
- and `logout` talk to the Lunox platform; every other command is local and needs
275
- no account.
273
+ Every option: [reference](docs/REFERENCE.md#commands). Only `login`, `whoami`,
274
+ `logout`, `handoff --platform` and `patch --from` talk to the Lunox platform;
275
+ every other command is local and needs no account.
276
+
277
+ For a repository you will not connect to the platform, `carveout handoff
278
+ <entry>... --accept <test>... --platform --bounty <id>` uploads the ready
279
+ folder, public and in pseudonyms, with your acceptance tests renamed into its
280
+ names, as a version of that bounty; the receipt stays on your machine. The
281
+ platform grades contractors' submissions against it, and `carveout patch
282
+ --from <submission id>` brings a passing one back through the same local
283
+ `patch`. Publishing the version is a person's act in the platform's web app.
276
284
 
277
285
  ## Limits
278
286
 
@@ -15,7 +15,7 @@ import { type SandboxSpec } from "./sandbox.js";
15
15
  import { type Readiness } from "./readiness.js";
16
16
  import { type AliasRule, type FolderPseudonyms } from "./pseudonyms/index.js";
17
17
  import { type Receipt } from "./receipt.js";
18
- export declare const CARVEOUT_VERSION = "0.3.1";
18
+ export declare const CARVEOUT_VERSION = "0.4.0";
19
19
  export declare const RECORD_PATH = "carveout.json";
20
20
  export declare const RECORD_SCHEMA_VERSION = 1;
21
21
  export declare const BOUNDARY_PATH = "BOUNDARY.md";
package/dist/src/carve.js CHANGED
@@ -35,7 +35,7 @@ import { sandboxRun } from "./sandbox.js";
35
35
  import { configuredFolders, liveCandidates } from "./readiness.js";
36
36
  import { exactToFolder, fromFolder, specifierPackages, toFolder, keptNames, looseLeaks, maskKept, pseudonymize, vocabularyBlockers, vocabularyLeaks, workspaceNames, ownedNames, workspaceVocabulary, } from "./pseudonyms/index.js";
37
37
  import { RECEIPT_SCHEMA_VERSION, folderRecordFor, folderView, newCutId, receiptPathFor, trustedBaseline, trustedCut, trustedCutWith, writeReceipt, } from "./receipt.js";
38
- export const CARVEOUT_VERSION = "0.3.1";
38
+ export const CARVEOUT_VERSION = "0.4.0";
39
39
  export const RECORD_PATH = "carveout.json";
40
40
  export const RECORD_SCHEMA_VERSION = 1;
41
41
  /** Blockers that stop a cut before it writes anything. */
package/dist/src/cli.js CHANGED
@@ -20,18 +20,24 @@ import { statusProject } from "./status.js";
20
20
  import { hasAdvice, renderReadiness } from "./readiness.js";
21
21
  import { trustedCut, writeReceipt } from "./receipt.js";
22
22
  import { runAccount } from "./account.js";
23
+ import { handoffPlatform, patchFrom } from "./hosted.js";
24
+ import { PlatformError } from "./platform.js";
23
25
  const USAGE = `carveout ${CARVEOUT_VERSION}: carve a slice out of a TypeScript repository.
24
26
 
25
27
  carveout handoff <entry>... --out <dir> [--task <file|text>] [--depth N] [--max-files N]
26
28
  [--private <path>]... [--name <word>]... [--copy <dir>]... [--live <pkg>]...
27
29
  [--accept <test>...] [--no-whole-package] [--no-copy-pure] [--receipt <file>]
28
- [--max-exposure-lines N] [--max-exposure-percent P] [--json]
30
+ [--max-exposure-lines N] [--max-exposure-percent P]
31
+ [--platform [<url>] --bounty <id> [--title <text>]] [--json]
29
32
  The hand-off in one step: a cut with the tests that run the code, its
30
33
  whole package and the pure code it runs, for someone outside (public,
31
34
  in pseudonyms), and its check. Prints whether the folder is ready (its
32
35
  tests pass before any edit), what it shows, the receipt to keep, and
33
36
  what to send. --task writes TASK.md: the work, and when it is accepted.
34
- Exits 0 only when the folder is ready to hand over.
37
+ Exits 0 only when the folder is ready to hand over. --platform (after
38
+ carveout login) uploads the ready folder and the --accept tests, in the
39
+ folder's names, as a draft version of the bounty --bounty names; the
40
+ receipt stays here. Publishing it is a person's act in the web app.
35
41
 
36
42
  carveout plan <entry>... [--repo <dir>] [--max-files N] [--depth N]
37
43
  [--with-tests] [--whole-package] [--follow-workspaces]
@@ -82,10 +88,15 @@ const USAGE = `carveout ${CARVEOUT_VERSION}: carve a slice out of a TypeScript r
82
88
 
83
89
  carveout patch <dir> [--repo <checkout>] [--allow <path>]... [--receipt <file>]
84
90
  [--output <file>] [--json]
91
+ carveout patch --from <submission-id> [--platform [<url>]] [--repo <checkout>]
92
+ [--allow <path>]... [--output <file>] [--json]
85
93
  The edits made in <dir> as a patch for the repository (git apply). Runs
86
94
  none of the folder's code. Keeps out, and names, new files outside the
87
95
  slice's packages, protected paths (CI, hooks, manifests, lockfiles,
88
96
  environment) and deleted tests; --allow <path|glob> lets one through.
97
+ --from takes a passing submission's files from the platform, on a folder
98
+ handoff --platform uploaded from here, and judges them the same way,
99
+ by the local receipt.
89
100
 
90
101
  carveout apply <dir> <patch-file> [--repo <checkout>] [--stubs] [--receipt <file>] [--json]
91
102
  Brings a patch for the repository at the cut's commit into <dir>, with its
@@ -144,7 +155,7 @@ const USAGE = `carveout ${CARVEOUT_VERSION}: carve a slice out of a TypeScript r
144
155
  workspace token in ~/.lunox/credentials (LUNOX_CREDENTIALS), shared with
145
156
  duediligence; whoami prints the account, workspace and token expiry;
146
157
  logout deletes the token here (the platform cannot revoke it from the
147
- CLI). No other command talks to the platform.
158
+ CLI). Only handoff --platform and patch --from talk to it besides.
148
159
 
149
160
  An entry point is a file or a directory, relative to --repo (default: the
150
161
  current directory, which should be the repository's root). Defaults:
@@ -252,8 +263,10 @@ async function main(argv) {
252
263
  process.exitCode = await runAccount(commandName, rest, { client: "carveout", version: CARVEOUT_VERSION });
253
264
  return;
254
265
  }
266
+ // `--platform` with no URL: the default platform (LUNOX_PLATFORM_URL, else production).
267
+ const args = rest.map((arg, i) => arg === "--platform" && (rest[i + 1] === undefined || rest[i + 1]?.startsWith("-")) ? "--platform=" : arg);
255
268
  const { values, positionals } = parseArgs({
256
- args: [...rest],
269
+ args,
257
270
  allowPositionals: true,
258
271
  // `--no-whole-package`, `--no-copy-pure`, `--no-public`: handoff's
259
272
  // defaults turned off by name.
@@ -301,6 +314,10 @@ async function main(argv) {
301
314
  "keep-names": { type: "boolean" },
302
315
  task: { type: "string" },
303
316
  entry: { type: "string", multiple: true },
317
+ platform: { type: "string" },
318
+ bounty: { type: "string" },
319
+ title: { type: "string" },
320
+ from: { type: "string" },
304
321
  },
305
322
  });
306
323
  // With negated flags read, `--no-receipt` comes back as `receipt: false`
@@ -383,6 +400,9 @@ async function main(argv) {
383
400
  ...(values.private === undefined ? {} : { private: values.private }),
384
401
  ...maxExposure,
385
402
  };
403
+ /** `--platform`'s URL; undefined for the default. */
404
+ const platformGiven = values.platform === undefined || values.platform === "" ? undefined : values.platform;
405
+ const hostedDeps = () => ({ env: process.env, fetchImpl: fetch, now: Date.now });
386
406
  switch (commandName) {
387
407
  case "where":
388
408
  process.stdout.write(`${carveoutHome()}\n`);
@@ -519,6 +539,15 @@ async function main(argv) {
519
539
  fail("handoff needs at least one entry point.");
520
540
  if (values.out === undefined)
521
541
  fail("handoff needs --out <dir>.");
542
+ const toPlatform = values.platform !== undefined;
543
+ if (toPlatform) {
544
+ if (values.bounty === undefined)
545
+ fail("handoff --platform needs --bounty <id>: the bounty the folder is a version of.");
546
+ if (values["keep-names"] === true || values.public === false || values["no-receipt"] === true)
547
+ fail("handoff --platform uploads a public folder in pseudonyms, with its receipt kept here: drop --keep-names, --no-public and --no-receipt.");
548
+ if ((values.accept ?? []).length === 0)
549
+ fail("handoff --platform needs --accept <test>...: the platform grades a submission by the tests that fail before the work.");
550
+ }
522
551
  // The hand-off's own defaults, unless a flag or the owner's
523
552
  // configuration says otherwise: the tests that run the code, its
524
553
  // whole package, the pure code it runs, and a folder for someone
@@ -580,6 +609,38 @@ async function main(argv) {
580
609
  const record = result.record;
581
610
  // The step that failed, and what it printed last: what to fix first.
582
611
  const failedStep = steps.find((step) => !step.ok);
612
+ // To the platform: only a folder ready to hand over, public and in
613
+ // pseudonyms, whose acceptance tests fail before the work.
614
+ let uploaded = null;
615
+ let notUploaded = null;
616
+ if (toPlatform) {
617
+ if (!record.ready || (verdict.handoff !== "ready" && verdict.handoff !== "no-tests"))
618
+ notUploaded = "the folder is not ready to hand over (above); fix it and hand off again.";
619
+ else if (vacuous !== false)
620
+ notUploaded =
621
+ vacuous === true
622
+ ? "the acceptance tests already pass before the work, so they would accept anything."
623
+ : "the acceptance tests did not run on the folder (above).";
624
+ else if (result.receipt === null || result.pseudonyms === null)
625
+ notUploaded = "the folder is not in pseudonyms with a receipt kept here.";
626
+ else
627
+ try {
628
+ uploaded = await handoffPlatform({
629
+ platform: platformGiven,
630
+ bountyId: values.bounty ?? "",
631
+ folder: result.out,
632
+ receiptPath: result.receipt,
633
+ accepted,
634
+ title: values.title,
635
+ deps: hostedDeps(),
636
+ });
637
+ }
638
+ catch (error) {
639
+ if (!(error instanceof PlatformError))
640
+ throw error;
641
+ notUploaded = error.message;
642
+ }
643
+ }
583
644
  if (values.json === true)
584
645
  print({
585
646
  out: result.out,
@@ -595,6 +656,21 @@ async function main(argv) {
595
656
  // The owner's acceptance tests on the folder as it goes out.
596
657
  acceptance: vacuous === null ? null : vacuous ? "pass-before-the-work" : "fail-before-the-work",
597
658
  steps,
659
+ ...(toPlatform
660
+ ? {
661
+ platform: uploaded === null
662
+ ? { uploaded: false, reason: notUploaded }
663
+ : {
664
+ uploaded: true,
665
+ base: uploaded.base,
666
+ bountyId: uploaded.bountyId,
667
+ sandboxId: uploaded.sandboxId,
668
+ versionId: uploaded.versionId,
669
+ runId: uploaded.runId,
670
+ record: uploaded.recordPath,
671
+ },
672
+ }
673
+ : {}),
598
674
  });
599
675
  else {
600
676
  const lines = [
@@ -642,15 +718,29 @@ async function main(argv) {
642
718
  result.receipt === null
643
719
  ? "No receipt: the folder that comes back will be judged by its own record (--no-receipt on patch)."
644
720
  : `Receipt: ${result.receipt}\n Keep it, and never send it: the folder that comes back is judged by it.`,
645
- `Send the folder without node_modules: (cd ${dirname(result.out)} && zip -qr ${basename(result.out)}.zip ${basename(result.out)} -x '*/node_modules/*')`,
646
- "When it comes back, from the repository's root: carveout patch <returned folder> --output change.patch; read all of it; then git apply --3way change.patch on a branch, and let CI decide.",
647
- "",
721
+ ...(uploaded !== null
722
+ ? [
723
+ `Uploaded to ${uploaded.base}: version ${uploaded.versionId} of bounty ${uploaded.bountyId} (sandbox ${uploaded.sandboxId}, import run ${uploaded.runId}), ${uploaded.files} file(s) and ${accepted.length} acceptance test(s) in the folder's names. The receipt stayed here; the upload is recorded in ${uploaded.recordPath}.`,
724
+ " The platform runs the folder's baseline (each acceptance test must fail on it). Publishing the version is a person's act in the platform's web app.",
725
+ "When a contributor's submission passes, from the repository's root: carveout patch --from <submission id> --output change.patch; read all of it; then git apply --3way change.patch on a branch, and let CI decide.",
726
+ "",
727
+ ]
728
+ : [
729
+ ...(notUploaded === null ? [] : [`Not uploaded to the platform: ${notUploaded}`, ""]),
730
+ `Send the folder without node_modules: (cd ${dirname(result.out)} && zip -qr ${basename(result.out)}.zip ${basename(result.out)} -x '*/node_modules/*')`,
731
+ "When it comes back, from the repository's root: carveout patch <returned folder> --output change.patch; read all of it; then git apply --3way change.patch on a branch, and let CI decide.",
732
+ "",
733
+ ]),
648
734
  ];
649
735
  process.stdout.write(lines.join("\n"));
650
736
  }
651
737
  // Acceptance tests that pass before the work would accept anything.
652
738
  if (!record.ready || verdict.handoff !== "ready" || vacuous === true)
653
739
  process.exitCode = 2;
740
+ if (toPlatform && uploaded === null)
741
+ process.exitCode = 1;
742
+ else if (uploaded !== null)
743
+ process.exitCode = 0;
654
744
  return;
655
745
  }
656
746
  case "cut": {
@@ -965,14 +1055,39 @@ async function main(argv) {
965
1055
  }
966
1056
  case "patch": {
967
1057
  const [dir] = positionals;
968
- if (dir === undefined)
969
- fail("patch needs the folder a cut wrote.");
970
- const given = repoFor(dir);
971
- const patch = await patchProject(dir, {
972
- ...(given === undefined ? {} : { repo: given }),
973
- ...(values.allow === undefined ? {} : { allow: values.allow }),
974
- ...receiptOption,
975
- });
1058
+ let patch;
1059
+ if (values.from !== undefined) {
1060
+ if (dir !== undefined)
1061
+ fail("patch --from takes a submission id, not a folder.");
1062
+ let from;
1063
+ try {
1064
+ from = await patchFrom({
1065
+ platform: platformGiven,
1066
+ submissionId: values.from,
1067
+ repo: values.repo,
1068
+ allow: values.allow,
1069
+ deps: hostedDeps(),
1070
+ });
1071
+ }
1072
+ catch (error) {
1073
+ if (!(error instanceof PlatformError))
1074
+ throw error;
1075
+ fail(`patch --from: ${error.message}`);
1076
+ }
1077
+ patch = from.patch;
1078
+ if (values.json !== true)
1079
+ process.stderr.write(`carveout: submission ${values.from}: ${from.files} file(s) from ${from.record.base}, on the folder uploaded as version ${from.record.versionId}${from.cutAgain ? ", cut again from the receipt (the folder was gone or edited)" : ""}; judged by the receipt here.\n`);
1080
+ }
1081
+ else {
1082
+ if (dir === undefined)
1083
+ fail("patch needs the folder a cut wrote, or --from <submission id>.");
1084
+ const given = repoFor(dir);
1085
+ patch = await patchProject(dir, {
1086
+ ...(given === undefined ? {} : { repo: given }),
1087
+ ...(values.allow === undefined ? {} : { allow: values.allow }),
1088
+ ...receiptOption,
1089
+ });
1090
+ }
976
1091
  const boundary = new Set(patch.boundary.map((edit) => edit.path));
977
1092
  const notes = [
978
1093
  ...(patch.recordFrom === "folder"
@@ -0,0 +1,108 @@
1
+ /**
2
+ * The hosted round trip, on the platform (sandbox-factory) a workspace
3
+ * signed in to with `carveout login` (`platform.ts`):
4
+ *
5
+ * handoff --platform --bounty <id>
6
+ * After the local cut and check, the folder, which must be ready,
7
+ * public and in pseudonyms, is uploaded with the owner's acceptance
8
+ * tests renamed into the folder's names by the receipt's table:
9
+ * POST /api/v1/orgs/:org/sandboxes/uploads, under an Idempotency-Key.
10
+ * The platform hosts it as a draft version of the bounty, its baseline
11
+ * run with every acceptance test failing, and grades contributors'
12
+ * submissions against it. What was uploaded is recorded in
13
+ * `CARVEOUT_HOME/platform/<cut id>.json`. Publishing the version is a
14
+ * person's act in the platform's web app.
15
+ *
16
+ * patch --from <submission id>
17
+ * GET /api/v1/orgs/:org/sandboxes/submissions/:sid/files: a passing
18
+ * submission's changed files. They are laid over a scratch copy of the
19
+ * folder (cut again from the receipt at the cut's commit when the
20
+ * folder is gone or edited), and `patchProject` judges that copy with
21
+ * the local receipt exactly as `patch <dir>` judges a folder that came
22
+ * back by hand.
23
+ *
24
+ * What leaves this machine is the folder and the renamed acceptance tests:
25
+ * never the receipt, its table, or a file outside the folder. The platform
26
+ * refuses a folder that holds the workspace's names or a credential.
27
+ * `carveout/api` never imports this module.
28
+ */
29
+ import { type Patch } from "./carve.js";
30
+ /** An upload is large and is checked before it is answered. */
31
+ export declare const UPLOAD_TIMEOUT_MS = 180000;
32
+ export declare const PLATFORM_RECORD_SCHEMA_VERSION = 1;
33
+ /** The outside world the two commands use; tests pass their own. */
34
+ export interface HostedDeps {
35
+ readonly env: NodeJS.ProcessEnv;
36
+ readonly fetchImpl: typeof fetch;
37
+ readonly now: () => number;
38
+ }
39
+ /** What `handoff --platform` uploaded, kept beside the receipts: never sent. */
40
+ export interface PlatformRecord {
41
+ readonly schemaVersion: typeof PLATFORM_RECORD_SCHEMA_VERSION;
42
+ readonly base: string;
43
+ readonly org: string;
44
+ readonly bountyId: string;
45
+ readonly sandboxId: string;
46
+ readonly versionId: string;
47
+ readonly runId: string;
48
+ /** The receipt file, absolute. */
49
+ readonly receipt: string;
50
+ /** The folder uploaded, absolute. */
51
+ readonly folder: string;
52
+ /** The hash of the folder's carveout.json as uploaded: how a submission is matched to it. */
53
+ readonly recordSha256: string;
54
+ readonly cutId: string;
55
+ readonly uploadedAt: string;
56
+ }
57
+ /** Where uploads are recorded: `CARVEOUT_HOME/platform`. */
58
+ export declare const platformRecordDir: () => string;
59
+ /** The upload whose folder record hashes to `recordSha256`, or null. */
60
+ export declare function findPlatformRecord(recordSha256: string): PlatformRecord | null;
61
+ /**
62
+ * Every file of a folder as text, at its folder path: none under
63
+ * node_modules or .git, nor a tool's folder at the root (`TOOL_DIRS`). A
64
+ * binary file or a link is refused, never sent as something else.
65
+ */
66
+ export declare function folderFiles(folder: string): {
67
+ path: string;
68
+ text: string;
69
+ }[];
70
+ /**
71
+ * Uploads a folder `handoff` cut and checked: refused unless it is public
72
+ * and in pseudonyms, with at least one acceptance test, each of which maps
73
+ * into the folder's names byte for byte. Answers what it recorded.
74
+ */
75
+ export declare function handoffPlatform(input: {
76
+ /** `--platform`'s value; else LUNOX_PLATFORM_URL, else production. */
77
+ readonly platform?: string | undefined;
78
+ readonly bountyId: string;
79
+ readonly folder: string;
80
+ readonly receiptPath: string;
81
+ /** The owner's acceptance tests, at their repository paths. */
82
+ readonly accepted: readonly {
83
+ readonly path: string;
84
+ readonly text: string;
85
+ }[];
86
+ readonly title?: string | undefined;
87
+ readonly deps: HostedDeps;
88
+ }): Promise<PlatformRecord & {
89
+ readonly recordPath: string;
90
+ readonly files: number;
91
+ }>;
92
+ /**
93
+ * `patch --from`: a passing submission's files, from the platform, laid
94
+ * over a copy of the folder that was uploaded and judged by the local
95
+ * receipt (`patchProject`). The copy is removed after.
96
+ */
97
+ export declare function patchFrom(input: {
98
+ readonly platform?: string | undefined;
99
+ readonly submissionId: string;
100
+ readonly repo?: string | undefined;
101
+ readonly allow?: readonly string[] | undefined;
102
+ readonly deps: HostedDeps;
103
+ }): Promise<{
104
+ readonly patch: Patch;
105
+ readonly record: PlatformRecord;
106
+ readonly files: number;
107
+ readonly cutAgain: boolean;
108
+ }>;
@@ -0,0 +1,302 @@
1
+ /**
2
+ * The hosted round trip, on the platform (sandbox-factory) a workspace
3
+ * signed in to with `carveout login` (`platform.ts`):
4
+ *
5
+ * handoff --platform --bounty <id>
6
+ * After the local cut and check, the folder, which must be ready,
7
+ * public and in pseudonyms, is uploaded with the owner's acceptance
8
+ * tests renamed into the folder's names by the receipt's table:
9
+ * POST /api/v1/orgs/:org/sandboxes/uploads, under an Idempotency-Key.
10
+ * The platform hosts it as a draft version of the bounty, its baseline
11
+ * run with every acceptance test failing, and grades contributors'
12
+ * submissions against it. What was uploaded is recorded in
13
+ * `CARVEOUT_HOME/platform/<cut id>.json`. Publishing the version is a
14
+ * person's act in the platform's web app.
15
+ *
16
+ * patch --from <submission id>
17
+ * GET /api/v1/orgs/:org/sandboxes/submissions/:sid/files: a passing
18
+ * submission's changed files. They are laid over a scratch copy of the
19
+ * folder (cut again from the receipt at the cut's commit when the
20
+ * folder is gone or edited), and `patchProject` judges that copy with
21
+ * the local receipt exactly as `patch <dir>` judges a folder that came
22
+ * back by hand.
23
+ *
24
+ * What leaves this machine is the folder and the renamed acceptance tests:
25
+ * never the receipt, its table, or a file outside the folder. The platform
26
+ * refuses a folder that holds the workspace's names or a credential.
27
+ * `carveout/api` never imports this module.
28
+ */
29
+ import { execFileSync } from "node:child_process";
30
+ import { createHash, randomUUID } from "node:crypto";
31
+ import { chmodSync, cpSync, existsSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, renameSync, rmSync, writeFileSync, } from "node:fs";
32
+ import { tmpdir } from "node:os";
33
+ import { dirname, join, resolve } from "node:path";
34
+ import { CARVEOUT_VERSION, RECORD_PATH, carveoutHome, cutSlice, isTextContent, patchProject } from "./carve.js";
35
+ import { TOOL_DIRS } from "./core.js";
36
+ import { assertSafePath } from "./policy.js";
37
+ import { exactToFolder } from "./pseudonyms/index.js";
38
+ import { folderView, readReceipt } from "./receipt.js";
39
+ import { credentialFor, credentialsPath, platformUrl, PlatformError, request } from "./platform.js";
40
+ const CLIENT = "carveout";
41
+ /** An upload is large and is checked before it is answered. */
42
+ export const UPLOAD_TIMEOUT_MS = 180_000;
43
+ export const PLATFORM_RECORD_SCHEMA_VERSION = 1;
44
+ const sha256 = (text) => createHash("sha256").update(text).digest("hex");
45
+ /** Where uploads are recorded: `CARVEOUT_HOME/platform`. */
46
+ export const platformRecordDir = () => join(carveoutHome(), "platform");
47
+ /** Writes a platform record where only its owner reads it, whole. */
48
+ function writePlatformRecord(record) {
49
+ const dir = platformRecordDir();
50
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
51
+ const target = join(dir, `${record.cutId}.json`);
52
+ const temporary = `${target}.${randomUUID()}.tmp`;
53
+ writeFileSync(temporary, `${JSON.stringify(record, null, 2)}\n`, { mode: 0o600, flag: "wx" });
54
+ chmodSync(temporary, 0o600);
55
+ renameSync(temporary, target);
56
+ return target;
57
+ }
58
+ /** The upload whose folder record hashes to `recordSha256`, or null. */
59
+ export function findPlatformRecord(recordSha256) {
60
+ const dir = platformRecordDir();
61
+ if (!existsSync(dir))
62
+ return null;
63
+ for (const name of readdirSync(dir).filter((entry) => entry.endsWith(".json")).sort()) {
64
+ try {
65
+ const record = JSON.parse(readFileSync(join(dir, name), "utf8"));
66
+ if (record.schemaVersion === PLATFORM_RECORD_SCHEMA_VERSION && record.recordSha256 === recordSha256)
67
+ return record;
68
+ }
69
+ catch {
70
+ // Not one of ours; another is looked at.
71
+ }
72
+ }
73
+ return null;
74
+ }
75
+ /**
76
+ * Every file of a folder as text, at its folder path: none under
77
+ * node_modules or .git, nor a tool's folder at the root (`TOOL_DIRS`). A
78
+ * binary file or a link is refused, never sent as something else.
79
+ */
80
+ export function folderFiles(folder) {
81
+ const root = resolve(folder);
82
+ const files = [];
83
+ const walk = (dir) => {
84
+ for (const entry of readdirSync(join(root, dir), { withFileTypes: true }).sort((a, b) => (a.name < b.name ? -1 : 1))) {
85
+ const path = dir === "" ? entry.name : `${dir}/${entry.name}`;
86
+ if (entry.name === "node_modules" || entry.name === ".git" || (dir === "" && TOOL_DIRS.includes(entry.name)))
87
+ continue;
88
+ const stat = lstatSync(join(root, path));
89
+ if (stat.isSymbolicLink())
90
+ throw new PlatformError(`${path} is a link; a folder uploaded holds files only.`);
91
+ if (stat.isDirectory())
92
+ walk(path);
93
+ else if (stat.isFile()) {
94
+ const bytes = readFileSync(join(root, path));
95
+ if (!isTextContent(bytes))
96
+ throw new PlatformError(`${path} is not UTF-8 text; the platform takes a folder of text files only.`);
97
+ files.push({ path, text: bytes.toString("utf8") });
98
+ }
99
+ }
100
+ };
101
+ walk("");
102
+ return files;
103
+ }
104
+ /** This CLI's saved sign-in for `base`, or a PlatformError saying how to get one. */
105
+ function signedIn(base, env) {
106
+ const saved = credentialFor(credentialsPath(env), base, CLIENT);
107
+ if (saved === undefined)
108
+ throw new PlatformError(`Not signed in to ${base}. Run \`carveout login\`.`);
109
+ return saved;
110
+ }
111
+ /**
112
+ * Uploads a folder `handoff` cut and checked: refused unless it is public
113
+ * and in pseudonyms, with at least one acceptance test, each of which maps
114
+ * into the folder's names byte for byte. Answers what it recorded.
115
+ */
116
+ export async function handoffPlatform(input) {
117
+ const base = platformUrl(input.platform, input.deps.env);
118
+ const saved = signedIn(base, input.deps.env);
119
+ const receipt = readReceipt(input.receiptPath);
120
+ const table = receipt.pseudonyms;
121
+ const view = folderView(receipt);
122
+ if (table === undefined || view.publicScrubbed !== true || view.request.public !== true)
123
+ throw new PlatformError("Only a public folder in pseudonyms is uploaded: hand off without --keep-names or --no-public, so the platform never has the repository's names.");
124
+ if (input.accepted.length === 0)
125
+ throw new PlatformError("The platform grades a submission by the owner's acceptance tests: hand off with --accept <test>..., tests that fail before the work.");
126
+ const acceptanceTests = input.accepted.map((test) => {
127
+ const folder = exactToFolder(table, test.path, test.text);
128
+ if (folder === null)
129
+ throw new PlatformError(`${test.path} would not map into the folder's names byte for byte; rename what it names, and hand off again.`);
130
+ return { path: folder.path, text: folder.text, expectedBaseline: "fail" };
131
+ });
132
+ const files = folderFiles(input.folder);
133
+ const recordText = files.find((file) => file.path === RECORD_PATH)?.text;
134
+ if (recordText === undefined)
135
+ throw new PlatformError(`${input.folder} has no ${RECORD_PATH}; it was not cut by carveout.`);
136
+ const own = JSON.parse(recordText);
137
+ if (own.cutId !== receipt.cutId)
138
+ throw new PlatformError(`${input.folder} is not the folder of cut ${receipt.cutId.slice(0, 8)}, whose receipt was given.`);
139
+ const recordSha256 = sha256(recordText);
140
+ const answer = await request({
141
+ base,
142
+ method: "POST",
143
+ path: `/api/v1/orgs/${encodeURIComponent(saved.organizationId)}/sandboxes/uploads`,
144
+ token: saved.token,
145
+ client: CLIENT,
146
+ version: CARVEOUT_VERSION,
147
+ fetchImpl: input.deps.fetchImpl,
148
+ idempotencyKey: randomUUID(),
149
+ timeoutMs: UPLOAD_TIMEOUT_MS,
150
+ body: {
151
+ bountyId: input.bountyId,
152
+ carveout: { version: CARVEOUT_VERSION, recordSha256 },
153
+ files,
154
+ acceptanceTests,
155
+ ...(input.title === undefined ? {} : { title: input.title }),
156
+ },
157
+ });
158
+ const id = (value) => (typeof value?.id === "string" ? value.id : null);
159
+ const [sandboxId, versionId, runId] = [id(answer.sandbox), id(answer.version), id(answer.run)];
160
+ if (sandboxId === null || versionId === null || runId === null)
161
+ throw new PlatformError(`${base} took the upload but answered without its sandbox, version and run.`);
162
+ const record = {
163
+ schemaVersion: PLATFORM_RECORD_SCHEMA_VERSION,
164
+ base,
165
+ org: saved.organizationId,
166
+ bountyId: input.bountyId,
167
+ sandboxId,
168
+ versionId,
169
+ runId,
170
+ receipt: resolve(input.receiptPath),
171
+ folder: resolve(input.folder),
172
+ recordSha256,
173
+ cutId: receipt.cutId,
174
+ uploadedAt: new Date(input.deps.now()).toISOString(),
175
+ };
176
+ return { ...record, recordPath: writePlatformRecord(record), files: files.length };
177
+ }
178
+ /** Whether the folder on disk is the one uploaded, every file the cut wrote unchanged. */
179
+ function folderIntact(folder, receipt, recordSha256) {
180
+ const at = (path) => join(folder, path);
181
+ if (!existsSync(at(RECORD_PATH)) || sha256(readFileSync(at(RECORD_PATH))) !== recordSha256)
182
+ return false;
183
+ const view = folderView(receipt);
184
+ return [...(view.copied ?? []), ...view.generated].every((file) => existsSync(at(file.path)) && sha256(readFileSync(at(file.path))) === file.sha256);
185
+ }
186
+ /** The files of a folder, copied to `to`, without what `folderFiles` leaves out. */
187
+ function copyFolder(from, to) {
188
+ const root = resolve(from);
189
+ cpSync(root, to, {
190
+ recursive: true,
191
+ filter: (source) => {
192
+ const rel = source === root ? "" : source.slice(root.length + 1).split("\\").join("/");
193
+ const parts = rel.split("/");
194
+ return !parts.includes("node_modules") && !parts.includes(".git") && !(parts.length >= 1 && TOOL_DIRS.includes(parts[0] ?? ""));
195
+ },
196
+ });
197
+ }
198
+ /**
199
+ * The folder cut again from the receipt, at the cut's commit, in a clone
200
+ * of the repository: the copied files as the cut wrote them, the generated
201
+ * ones and the record as the receipt keeps them. Refused unless every file
202
+ * is the one uploaded.
203
+ */
204
+ async function cutAgain(receipt, scratch, out, recordSha256) {
205
+ const record = receipt.record;
206
+ const commit = record.manifest.sourceCommitSha;
207
+ if (commit === null || !existsSync(receipt.repo))
208
+ throw new PlatformError(`The folder ${receipt.out} is gone or edited, and the cut cannot be made again (${commit === null ? "it was not cut at a commit" : `${receipt.repo} is gone`}): restore the folder as it was uploaded.`);
209
+ const repo = join(scratch, "repo");
210
+ const git = (cwd, args) => execFileSync("git", args, { cwd, stdio: ["ignore", "pipe", "pipe"] });
211
+ git(scratch, ["clone", "-q", "--shared", "--no-checkout", receipt.repo, repo]);
212
+ git(repo, ["checkout", "-q", "--detach", commit]);
213
+ const request = record.request;
214
+ await cutSlice({
215
+ repo,
216
+ out,
217
+ entryPoints: request.entryPoints,
218
+ budget: request.budget,
219
+ choices: request.choices,
220
+ offline: true,
221
+ widenLiterals: request.widenLiterals,
222
+ tests: request.tests === true,
223
+ ...(request.live === undefined ? {} : { live: request.live }),
224
+ wholePackage: request.wholePackage === true,
225
+ followWorkspaces: request.followWorkspaces === true,
226
+ copyPure: request.copyPure === true,
227
+ private: request.private ?? [],
228
+ copy: request.copy ?? [],
229
+ public: true,
230
+ ...(request.task === undefined ? {} : { task: request.task }),
231
+ ...(receipt.pseudonyms === undefined ? {} : { earlierPseudonyms: receipt.pseudonyms, names: receipt.pseudonyms.vocabulary }),
232
+ ...(receipt.unseen === undefined ? {} : { unseen: receipt.unseen }),
233
+ receipt: join(scratch, "receipt.json"),
234
+ log: () => { },
235
+ });
236
+ // What the cut generated, and its record, as the folder held them.
237
+ for (const [path, text] of Object.entries(receipt.generatedText ?? {})) {
238
+ assertSafePath(path, "a generated file");
239
+ mkdirSync(dirname(join(out, path)), { recursive: true });
240
+ writeFileSync(join(out, path), text);
241
+ }
242
+ writeFileSync(join(out, RECORD_PATH), `${JSON.stringify(folderView(receipt), null, 2)}\n`);
243
+ if (!folderIntact(out, receipt, recordSha256))
244
+ throw new PlatformError(`The folder ${receipt.out} is gone or edited, and cut again from the receipt it is not the folder uploaded: restore the folder as it was uploaded.`);
245
+ }
246
+ /**
247
+ * `patch --from`: a passing submission's files, from the platform, laid
248
+ * over a copy of the folder that was uploaded and judged by the local
249
+ * receipt (`patchProject`). The copy is removed after.
250
+ */
251
+ export async function patchFrom(input) {
252
+ const base = platformUrl(input.platform, input.deps.env);
253
+ const saved = signedIn(base, input.deps.env);
254
+ const answer = await request({
255
+ base,
256
+ path: `/api/v1/orgs/${encodeURIComponent(saved.organizationId)}/sandboxes/submissions/${encodeURIComponent(input.submissionId)}/files`,
257
+ token: saved.token,
258
+ client: CLIENT,
259
+ version: CARVEOUT_VERSION,
260
+ fetchImpl: input.deps.fetchImpl,
261
+ });
262
+ const recordSha256 = answer.upload?.recordSha256;
263
+ const files = answer.files;
264
+ if (typeof recordSha256 !== "string" ||
265
+ !Array.isArray(files) ||
266
+ !files.every((file) => typeof file.path === "string" && typeof file.text === "string"))
267
+ throw new PlatformError(`${base} answered submission ${input.submissionId} without its files.`);
268
+ const record = findPlatformRecord(recordSha256);
269
+ if (record === null)
270
+ throw new PlatformError(`Submission ${input.submissionId} is on a folder this machine did not upload (no record under ${platformRecordDir()}): run patch --from where handoff --platform ran.`);
271
+ if (!existsSync(record.receipt))
272
+ throw new PlatformError(`${record.receipt}: the receipt of that hand-off is gone.`);
273
+ const receipt = readReceipt(record.receipt);
274
+ const scratch = mkdtempSync(join(tmpdir(), "carveout-from-"));
275
+ try {
276
+ const dir = join(scratch, "folder");
277
+ const intact = folderIntact(record.folder, receipt, recordSha256);
278
+ if (intact)
279
+ copyFolder(record.folder, dir);
280
+ else
281
+ await cutAgain(receipt, scratch, dir, recordSha256);
282
+ for (const file of files) {
283
+ try {
284
+ assertSafePath(file.path, "a submitted file");
285
+ }
286
+ catch {
287
+ throw new PlatformError(`${base} answered a file at ${JSON.stringify(file.path)}, which is not a plain path in the folder.`);
288
+ }
289
+ mkdirSync(dirname(join(dir, file.path)), { recursive: true });
290
+ writeFileSync(join(dir, file.path), file.text);
291
+ }
292
+ const patch = await patchProject(dir, {
293
+ receipt: record.receipt,
294
+ ...(input.repo === undefined ? {} : { repo: input.repo }),
295
+ ...(input.allow === undefined ? {} : { allow: input.allow }),
296
+ });
297
+ return { patch, record, files: files.length, cutAgain: !intact };
298
+ }
299
+ finally {
300
+ rmSync(scratch, { recursive: true, force: true });
301
+ }
302
+ }
@@ -3,7 +3,8 @@
3
3
  * talks to it: the base URL, the shared credentials file, and one request
4
4
  * function on `fetch`, so carveout keeps `typescript` as its only
5
5
  * dependency. Nothing here runs unless a platform command (`login`,
6
- * `whoami`, `logout`, in `account.ts`) is called; every other command stays
6
+ * `whoami`, `logout`, in `account.ts`; `handoff --platform` and
7
+ * `patch --from`, in `hosted.ts`) is called; every other command stays
7
8
  * local, and `carveout/api` never imports it.
8
9
  *
9
10
  * Credentials: `~/.lunox/credentials` (LUNOX_CREDENTIALS moves it), JSON,
@@ -57,6 +58,10 @@ export interface RequestOptions {
57
58
  readonly session?: boolean;
58
59
  readonly body?: unknown;
59
60
  readonly fetchImpl?: typeof fetch;
61
+ /** Sent as `Idempotency-Key`: a retried create gets its first answer back. */
62
+ readonly idempotencyKey?: string;
63
+ /** How long the call may take; 30 seconds unless given (an upload takes longer). */
64
+ readonly timeoutMs?: number;
60
65
  }
61
66
  /**
62
67
  * One call to the platform's API. Sends the token as a bearer, the client
@@ -3,7 +3,8 @@
3
3
  * talks to it: the base URL, the shared credentials file, and one request
4
4
  * function on `fetch`, so carveout keeps `typescript` as its only
5
5
  * dependency. Nothing here runs unless a platform command (`login`,
6
- * `whoami`, `logout`, in `account.ts`) is called; every other command stays
6
+ * `whoami`, `logout`, in `account.ts`; `handoff --platform` and
7
+ * `patch --from`, in `hosted.ts`) is called; every other command stays
7
8
  * local, and `carveout/api` never imports it.
8
9
  *
9
10
  * Credentials: `~/.lunox/credentials` (LUNOX_CREDENTIALS moves it), JSON,
@@ -126,7 +127,7 @@ function minimumOf(body) {
126
127
  * upgrade, otherwise the API's own `{ error, code }`.
127
128
  */
128
129
  export async function request(options) {
129
- const { base, path, client, version, method = "GET", token, session = false, body, fetchImpl = fetch } = options;
130
+ const { base, path, client, version, method = "GET", token, session = false, body, fetchImpl = fetch, idempotencyKey, timeoutMs = 30_000, } = options;
130
131
  const headers = {
131
132
  Accept: "application/json",
132
133
  "User-Agent": `${client}/${version}`,
@@ -136,6 +137,8 @@ export async function request(options) {
136
137
  headers["Authorization"] = `Bearer ${token}`;
137
138
  if (body !== undefined)
138
139
  headers["Content-Type"] = "application/json";
140
+ if (idempotencyKey !== undefined)
141
+ headers["Idempotency-Key"] = idempotencyKey;
139
142
  let response;
140
143
  try {
141
144
  response = await fetchImpl(`${base}${path}`, {
@@ -143,7 +146,7 @@ export async function request(options) {
143
146
  headers,
144
147
  ...(body === undefined ? {} : { body: JSON.stringify(body) }),
145
148
  redirect: "error",
146
- signal: AbortSignal.timeout(30_000),
149
+ signal: AbortSignal.timeout(timeoutMs),
147
150
  });
148
151
  }
149
152
  catch (error) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "carveout",
3
- "version": "0.3.1",
3
+ "version": "0.4.0",
4
4
  "description": "Hand one part of a closed TypeScript repository to an outside contractor: a slice, stubbed and mocked, that installs, builds and tests on its own, and a checked patch back. Also checks a change made in the repository: what it reaches, the tests to run, and weakened tests or config edits.",
5
5
  "keywords": [
6
6
  "carveout",
@@ -17,14 +17,14 @@ description: >-
17
17
  license: MIT
18
18
  compatibility: >-
19
19
  Node 22.12 or later, git and npm; Docker to run a returned folder's code. Runs
20
- the carveout@0.3.1 CLI from npm; online cuts read the npm registry.
20
+ the carveout@0.4.0 CLI from npm; online cuts read the npm registry.
21
21
  ---
22
22
 
23
23
  # carveout
24
24
 
25
- `carveout <command>` is the CLI, `carveout@0.3.1`. When `carveout --version`
25
+ `carveout <command>` is the CLI, `carveout@0.4.0`. When `carveout --version`
26
26
  prints another version or nothing, ask the person before installing it:
27
- `npm i -g carveout@0.3.1`, or put `npx carveout@0.3.1` in place of `carveout` in
27
+ `npm i -g carveout@0.4.0`, or put `npx carveout@0.4.0` in place of `carveout` in
28
28
  each command (in a clone of the repository, `node dist/src/cli.js` after
29
29
  `npm install && npm run build`). Node 22.12 or later, and `git` and `npm` on
30
30
  PATH.
@@ -71,16 +71,18 @@ kept. The sections below are each step in full.
71
71
 
72
72
  With the `lunox` MCP tools signed in (bundled; `/mcp` signs in), read with the
73
73
  `list_*`/`get_*` tools. Write only when asked: a bounty (`post_bounty` or
74
- `import_github_issue`, `size_bounty` is a paid run, `get_decisions` and
75
- `confirm_link`), a slice (`plan_slice`, `start_sandbox`, `cut_slice`), its
76
- build (`prepare`, then `get_handout`: public files and the hidden suite's hash
77
- only), a contractor's patch (`submit_patch`, `get_verdict`, one `retry`).
78
- Approvals, decisions, publishing and merging back happen in the web app. Model
79
- runs (`size_bounty`, `prepare`, `retry`) count against a monthly token limit
80
- (`get_usage`); on `quota_exceeded`, tell the person when it resets and do not
81
- retry. `insufficient_scope` means signing in again through `/mcp`. Never ask
82
- for a token; the CLI signs in with `carveout login`. Local mode, below, is the
83
- default.
74
+ `import_github_issue`; `size_bounty` is a paid run; `get_decisions`,
75
+ `confirm_link`), a slice (`plan_slice`, `start_sandbox`, `cut_slice`), its build
76
+ (`prepare`, then `get_handout`: public files and the hidden suite's hash only),
77
+ a contractor's patch (`submit_patch`, `get_verdict`, one `retry`). Approvals,
78
+ decisions, publishing and merging back happen in the web app. Model runs
79
+ (`size_bounty`, `prepare`, `retry`) count against a monthly token limit
80
+ (`get_usage`); on `quota_exceeded`, say when it resets and do not retry. On
81
+ `insufficient_scope`, sign in again through `/mcp`. Never ask for a token; the
82
+ CLI signs in with `carveout login`. For an unconnected repository, `handoff ...
83
+ --accept <test>... --platform --bounty <id>` uploads the ready folder and the
84
+ tests in its names, never the receipt; `patch --from <submission id>` judges a
85
+ passing submission by the local receipt. Local mode, below, is the default.
84
86
 
85
87
  ## 1. Plan
86
88
 
@@ -121,12 +123,10 @@ The defaults are depth 3 and 40 files. Choose the budget with the person:
121
123
 
122
124
  Depth counts imports of code. Going through a barrel's re-export does not add
123
125
  depth, and only the module that declares an imported name comes along, not the
124
- barrel's other modules. A module the slice imports only types from is always cut
125
- to a stub.
126
+ barrel's other modules. A module the slice takes only types from is a stub.
126
127
 
127
- When the file limit is reached, the plan lists the files it left out. Raise
128
- `--max-files`, or narrow the entry points. `--json` gives the same report as
129
- data.
128
+ At the file limit, the plan lists the files it left out: raise `--max-files`,
129
+ or narrow the entry points. `--json` gives the same report as data.
130
130
 
131
131
  `--with-tests` also brings the test files outside the slice that import it
132
132
  (`*.test.*`, `*.spec.*`, `__tests__/`), when they run on `node:test` or Vitest
@@ -14,7 +14,7 @@ description: >-
14
14
  test to make it pass.
15
15
  license: MIT
16
16
  compatibility: >-
17
- Node 22.12 or later, git and npm. Runs the carveout@0.3.1 CLI from npm.
17
+ Node 22.12 or later, git and npm. Runs the carveout@0.4.0 CLI from npm.
18
18
  ---
19
19
 
20
20
  # carveout-gate
@@ -23,9 +23,9 @@ What a change made in the repository reaches, and whether it weakened the checks
23
23
  that would catch it. To hand a part of the repository to someone outside it
24
24
  instead, use the `carveout` skill.
25
25
 
26
- `carveout <command>` is the CLI, `carveout@0.3.1`. When `carveout --version`
26
+ `carveout <command>` is the CLI, `carveout@0.4.0`. When `carveout --version`
27
27
  prints another version or nothing, ask the person before installing it:
28
- `npm i -g carveout@0.3.1`, or put `npx carveout@0.3.1` in place of `carveout` in
28
+ `npm i -g carveout@0.4.0`, or put `npx carveout@0.4.0` in place of `carveout` in
29
29
  each command (in a clone of the repository, `node dist/src/cli.js` after
30
30
  `npm install && npm run build`). Node 22.12 or later, and `git` and `npm` on
31
31
  PATH.