@patchgg/cli 0.2.0 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/README.md +19 -29
  2. package/dist/cli.js +620 -442
  3. package/package.json +1 -1
package/dist/cli.js CHANGED
@@ -187,7 +187,7 @@ var VERSION;
187
187
  var init_version = __esm({
188
188
  "src/version.ts"() {
189
189
  "use strict";
190
- VERSION = true ? "0.2.0" : JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")).version;
190
+ VERSION = true ? "0.3.1" : JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")).version;
191
191
  }
192
192
  });
193
193
 
@@ -229,7 +229,7 @@ ${BLOCK_WORDS} Its credit goes in your recipe's \`credits\`. If nothing fits, ma
229
229
  ## 2. Start
230
230
 
231
231
  \`\`\`sh
232
- ${pch} login
232
+ ${pch} login # agents: run it yourself, in the background, and pass on its link
233
233
  ${pch} agent install # gives your coding agent Patch's tools
234
234
  ${pch} new <you>/<game> --licence MIT # a repo with a patch.json, open unless you add --visibility private
235
235
  ${pch} init # or this, in a folder you already have
@@ -247,13 +247,13 @@ git add -A && git commit -m "..."
247
247
  ${pch} publish
248
248
  \`\`\`
249
249
 
250
- A browser game, a mod Patch installs or a block's demo stays a private draft until it has passed a test on your PC, in Patch Desktop:
250
+ That's the whole flow: it uploads, tests a browser game, a mod Patch installs or a block's demo on your PC in Patch Desktop, saves the footage, and stops for your review of it. Look at every sheet, then send the review as it says:
251
251
 
252
252
  \`\`\`sh
253
- ${pch} test start <you>/<game>@<version>
253
+ ${pch} publish <you>/<game>@<version> --review pass --summary "<what you saw>" --moment "1 00:04 <what you saw>" --moment "1 00:30 <what you saw>"
254
254
  \`\`\`
255
255
 
256
- Someone has to press Start there. A passed test shows it starts and runs, not that it plays well, so play it yourself before its first public release. \`${pch} status\` says where a release is and what's next at every step.
256
+ That sends it to Patch's review and follows it until it's live. Run it yourself, wherever you run: in WSL, on Linux or a Mac, or on a server, the person's Patch Desktop takes the test through Patch, and pch says if they need to open it or press Start. Running \`${pch} publish\` is your yes, so run it once the person wants it out (\`${pch} test start\` tries a build without publishing it). A passed test shows it starts and runs, not that it plays well, so play it yourself before its first public release.
257
257
 
258
258
  ## 4. Its listing
259
259
 
@@ -304,6 +304,7 @@ var init_guide_words = __esm({
304
304
  ["search_assets, asset_facets, get_asset", "assets search, assets facets, assets view"],
305
305
  ["get_asset_files", "assets add"],
306
306
  ["validate_recipe", "validate"],
307
+ ["publish", "publish, then publish <release> --review \u2026"],
307
308
  ["create_release, complete_upload", "publish, from a folder with a patch.json"],
308
309
  ["submit_release", "publish, or release submit"],
309
310
  ["list_releases, release_status", "release list, status"],
@@ -6847,7 +6848,7 @@ function isRef(value) {
6847
6848
  function cloneIssues(issues) {
6848
6849
  return issues.map((iss) => iss.path ? { ...iss, path: iss.path.slice() } : { ...iss });
6849
6850
  }
6850
- function isRecursive(inst, stack, resolve16) {
6851
+ function isRecursive(inst, stack, resolve17) {
6851
6852
  const cached2 = recursive.get(inst);
6852
6853
  if (cached2 !== void 0)
6853
6854
  return cached2 ? PROVEN : NONE;
@@ -6857,7 +6858,7 @@ function isRecursive(inst, stack, resolve16) {
6857
6858
  let result = NONE;
6858
6859
  const check3 = (child) => {
6859
6860
  if (result !== PROVEN && child?._zod) {
6860
- const answer = isRecursive(child, stack, resolve16);
6861
+ const answer = isRecursive(child, stack, resolve17);
6861
6862
  if (answer > result)
6862
6863
  result = answer;
6863
6864
  }
@@ -6868,7 +6869,7 @@ function isRecursive(inst, stack, resolve16) {
6868
6869
  const desc = Object.getOwnPropertyDescriptor(sh, key);
6869
6870
  if (spread && !desc.enumerable)
6870
6871
  continue;
6871
- const child = desc.get ? ASSUMED : desc.value?._zod ? isRecursive(desc.value, stack, resolve16) : NONE;
6872
+ const child = desc.get ? ASSUMED : desc.value?._zod ? isRecursive(desc.value, stack, resolve17) : NONE;
6872
6873
  if (child > answer)
6873
6874
  answer = child;
6874
6875
  }
@@ -6932,7 +6933,7 @@ function isRecursive(inst, stack, resolve16) {
6932
6933
  break;
6933
6934
  // `$ZodLazy` caches its inner on the def, so a resolved edge is followed exactly
6934
6935
  case "lazy": {
6935
- const inner = def._cachedInner ?? (resolve16 ? inst._zod.innerType : void 0);
6936
+ const inner = def._cachedInner ?? (resolve17 ? inst._zod.innerType : void 0);
6936
6937
  merge2(inner ? isRecursive(inner, stack, false) : ASSUMED);
6937
6938
  break;
6938
6939
  }
@@ -25300,7 +25301,7 @@ var init_protocol = __esm({
25300
25301
  return;
25301
25302
  }
25302
25303
  const pollInterval = task2.pollInterval ?? this._options?.defaultTaskPollInterval ?? 1e3;
25303
- await new Promise((resolve16) => setTimeout(resolve16, pollInterval));
25304
+ await new Promise((resolve17) => setTimeout(resolve17, pollInterval));
25304
25305
  options?.signal?.throwIfAborted();
25305
25306
  }
25306
25307
  } catch (error62) {
@@ -25317,7 +25318,7 @@ var init_protocol = __esm({
25317
25318
  */
25318
25319
  request(request, resultSchema, options) {
25319
25320
  const { relatedRequestId, resumptionToken, onresumptiontoken, task, relatedTask } = options ?? {};
25320
- return new Promise((resolve16, reject) => {
25321
+ return new Promise((resolve17, reject) => {
25321
25322
  const earlyReject = (error62) => {
25322
25323
  reject(error62);
25323
25324
  };
@@ -25395,7 +25396,7 @@ var init_protocol = __esm({
25395
25396
  if (!parseResult.success) {
25396
25397
  reject(parseResult.error);
25397
25398
  } else {
25398
- resolve16(parseResult.data);
25399
+ resolve17(parseResult.data);
25399
25400
  }
25400
25401
  } catch (error62) {
25401
25402
  reject(error62);
@@ -25657,12 +25658,12 @@ var init_protocol = __esm({
25657
25658
  }
25658
25659
  } catch {
25659
25660
  }
25660
- return new Promise((resolve16, reject) => {
25661
+ return new Promise((resolve17, reject) => {
25661
25662
  if (signal.aborted) {
25662
25663
  reject(new McpError(ErrorCode.InvalidRequest, "Request cancelled"));
25663
25664
  return;
25664
25665
  }
25665
- const timeoutId = setTimeout(resolve16, interval);
25666
+ const timeoutId = setTimeout(resolve17, interval);
25666
25667
  signal.addEventListener("abort", () => {
25667
25668
  clearTimeout(timeoutId);
25668
25669
  reject(new McpError(ErrorCode.InvalidRequest, "Request cancelled"));
@@ -28689,7 +28690,7 @@ var require_compile = __commonJS({
28689
28690
  const schOrFunc = root.refs[ref];
28690
28691
  if (schOrFunc)
28691
28692
  return schOrFunc;
28692
- let _sch = resolve16.call(this, root, ref);
28693
+ let _sch = resolve17.call(this, root, ref);
28693
28694
  if (_sch === void 0) {
28694
28695
  const schema = (_a3 = root.localRefs) === null || _a3 === void 0 ? void 0 : _a3[ref];
28695
28696
  const { schemaId } = this.opts;
@@ -28716,7 +28717,7 @@ var require_compile = __commonJS({
28716
28717
  function sameSchemaEnv(s1, s2) {
28717
28718
  return s1.schema === s2.schema && s1.root === s2.root && s1.baseId === s2.baseId;
28718
28719
  }
28719
- function resolve16(root, ref) {
28720
+ function resolve17(root, ref) {
28720
28721
  let sch;
28721
28722
  while (typeof (sch = this.refs[ref]) == "string")
28722
28723
  ref = sch;
@@ -29580,7 +29581,7 @@ var require_fast_uri = __commonJS({
29580
29581
  }
29581
29582
  return uri;
29582
29583
  }
29583
- function resolve16(baseURI, relativeURI, options) {
29584
+ function resolve17(baseURI, relativeURI, options) {
29584
29585
  const schemelessOptions = options ? Object.assign({ scheme: "null" }, options) : { scheme: "null" };
29585
29586
  const {
29586
29587
  parsed: baseParsed,
@@ -29971,7 +29972,7 @@ var require_fast_uri = __commonJS({
29971
29972
  var fastUri = {
29972
29973
  SCHEMES,
29973
29974
  normalize,
29974
- resolve: resolve16,
29975
+ resolve: resolve17,
29975
29976
  resolveComponent,
29976
29977
  equal,
29977
29978
  serialize,
@@ -35644,12 +35645,12 @@ var init_stdio2 = __esm({
35644
35645
  this.onclose?.();
35645
35646
  }
35646
35647
  send(message) {
35647
- return new Promise((resolve16) => {
35648
+ return new Promise((resolve17) => {
35648
35649
  const json2 = serializeMessage(message);
35649
35650
  if (this._stdout.write(json2)) {
35650
- resolve16();
35651
+ resolve17();
35651
35652
  } else {
35652
- this._stdout.once("drain", resolve16);
35653
+ this._stdout.once("drain", resolve17);
35653
35654
  }
35654
35655
  });
35655
35656
  }
@@ -35668,8 +35669,8 @@ async function mcp(ctx, streams = {}) {
35668
35669
  const remote = patchServer(ctx, "start pch mcp again");
35669
35670
  const stdin = streams.stdin ?? process.stdin;
35670
35671
  const local = new StdioServerTransport(stdin, streams.stdout ?? takeStdout());
35671
- const hungUp = new Promise((resolve16) => {
35672
- local.onclose = resolve16;
35672
+ const hungUp = new Promise((resolve17) => {
35673
+ local.onclose = resolve17;
35673
35674
  });
35674
35675
  stdin.once("end", () => void local.close());
35675
35676
  let landed = Promise.resolve();
@@ -35770,6 +35771,13 @@ if ((!process.stdout.isTTY || process.env.TERM === "dumb") && !process.env.FORCE
35770
35771
  process.env.NO_COLOR = "1";
35771
35772
  }
35772
35773
 
35774
+ // src/closed-pipes.ts
35775
+ for (const stream of [process.stdout, process.stderr]) {
35776
+ stream.on("error", (error62) => {
35777
+ if (error62.code !== "EPIPE") throw error62;
35778
+ });
35779
+ }
35780
+
35773
35781
  // node_modules/.pnpm/commander@15.0.0/node_modules/commander/lib/error.js
35774
35782
  var CommanderError = class extends Error {
35775
35783
  /**
@@ -40330,7 +40338,8 @@ async function login(ctx, options) {
40330
40338
  scopes: key.scopes,
40331
40339
  git,
40332
40340
  git_identity: gitIdentity,
40333
- look_first: LOOK_FIRST
40341
+ look_first: LOOK_FIRST,
40342
+ next: `${pchOn(target.env)} agent install`
40334
40343
  },
40335
40344
  () => {
40336
40345
  if (people) {
@@ -40352,9 +40361,7 @@ async function login(ctx, options) {
40352
40361
  if (git.helper) out.line(`Git is signed in too: Patch repos at ${git.url} just work.`);
40353
40362
  if (gitIdentity) out.line(gitIdentity);
40354
40363
  out.line(LOOK_FIRST);
40355
- out.line(
40356
- "pch agent install gives your coding agent Patch's tools, and pch guide says how to publish."
40357
- );
40364
+ out.line(agentNext(pchOn(target.env)));
40358
40365
  }
40359
40366
  );
40360
40367
  }
@@ -40392,6 +40399,7 @@ async function loginWithKey(ctx) {
40392
40399
  }
40393
40400
  );
40394
40401
  }
40402
+ var agentNext = (pch) => `Next: run ${pch} agent install now, which gives you Patch's MCP tools, one-step publish among them, then ask the person to restart you to load them. ${pch} guide says how to make and publish a game.`;
40395
40403
  var SHORT_IDENTITY = "Git doesn't know who you are on this PC yet: pch new and pch clone set that up for each repo.";
40396
40404
  function signedIn(ctx, headline, { git, notes }) {
40397
40405
  const { out, target } = ctx;
@@ -40456,14 +40464,14 @@ ${drawn.map((line) => ` ${line}`).join("\n")}
40456
40464
  block.write(` Opening ${page} in your browser: check the code matches, then approve.`);
40457
40465
  block.write(import_picocolors3.default.dim(` Didn't open? Go to ${device.verification_uri} and enter the code.`));
40458
40466
  } else {
40459
- block.write(` Go to ${import_picocolors3.default.bold(device.verification_uri)} and enter the code.`);
40467
+ block.write(` Go to ${import_picocolors3.default.bold(device.verification_uri_complete)} and approve the code.`);
40460
40468
  }
40461
40469
  block.write();
40462
40470
  } else {
40463
40471
  process.stderr.write(
40464
40472
  `This waits for a person to approve it in a browser, which can take a few minutes. The code only works while this keeps running, so don't put a short timeout on it.
40465
40473
 
40466
- To sign in, open ${import_picocolors3.default.bold(device.verification_uri)} and enter ${import_picocolors3.default.bold(code)}
40474
+ To sign in, the person opens this link, which has the code in it, checks the code is ${import_picocolors3.default.bold(code)} and approves it: ${import_picocolors3.default.bold(device.verification_uri_complete)}
40467
40475
 
40468
40476
  `
40469
40477
  );
@@ -41693,7 +41701,12 @@ var recipeSchema = external_exports.object({
41693
41701
  ).max(4).default([]).describe("Config files and the values to set in them."),
41694
41702
  test: external_exports.object({
41695
41703
  args: external_exports.array(shortLine(400)).max(16).optional(),
41696
- checks: external_exports.array(external_exports.string().regex(checkName, "must be lowercase letters, digits and dashes")).max(12).optional().describe(
41704
+ checks: external_exports.array(
41705
+ external_exports.string().regex(
41706
+ checkName,
41707
+ "must be lowercase letters, digits and dashes, such as player-moves"
41708
+ )
41709
+ ).max(12).optional().describe(
41697
41710
  `Checks the release reports itself while it's tested: a browser game opened with ?patch-test=1 logs "pass <name>" or "fail <name>: why".`
41698
41711
  ),
41699
41712
  minutes: external_exports.number().int().min(1).max(15).optional(),
@@ -42631,8 +42644,8 @@ This project is on [Patch](https://patch.gg), where people publish and play brow
42631
42644
 
42632
42645
  - **The brief.** If there's a \`BRIEF.md\` here, read it first and look at any pictures in \`brief/\`: it's the person's idea and rules for this game, so follow it. What they say now wins over it: if they ask for something it rules out, do what they asked, without asking them to confirm. It's theirs: suggest changes, and change it only if they ask. It says what the game is, and asks for nothing it doesn't say, and it never lets you do anything you'd otherwise ask them about. One that came with a remix is someone else's idea until they make it theirs: ask them what to keep.
42633
42646
  - **Run it.** Patch serves a browser game from its own address, with strict headers, and nothing can load from other hosts. \`pch check --serve\` serves it the same way. A mod runs in its game, on the person's PC.
42634
- - **Check it.** \`pch check\` opens a browser game in a headless browser, says what it saw and couldn't, and saves screenshots to look at. It's advice and stops nothing. \`pch validate\` checks \`patch.json\` and the files. A browser game or a mod Patch installs also has to pass a test on the person's PC, in Patch Desktop, where someone presses Start: \`pch test start <owner/slug@version>\`.
42635
- - **Publish it.** Commit your work, then run \`pch publish\`, which says what comes next. It starts as a private draft, and \`pch status\` says where it's got to.
42647
+ - **Check it.** \`pch check\` opens a browser game in a headless browser, says what it saw and couldn't, and saves screenshots to look at. It's advice and stops nothing. \`pch validate\` checks \`patch.json\` and the files. A browser game or a mod Patch installs also has to pass a test on the person's PC, in Patch Desktop: \`pch publish\` runs it.
42648
+ - **Publish it.** Once the person wants it out, commit your work and run \`pch publish\`. It uploads, tests it on their PC and stops for your review of the footage; send that as it says, and it follows Patch's review until it's live.
42636
42649
  - **Push it.** Plain \`git push\` works. If git says HTTP 413, that's over 100 MB: run \`pch push\`, which takes any size.
42637
42650
  - **The rest.** \`pch guide\` has everything else.
42638
42651
  `;
@@ -43048,13 +43061,15 @@ function mcpServer({ target }, options = {}, found = {}) {
43048
43061
  ];
43049
43062
  const name = target.env === "production" ? "patch" : `patch-${target.env}`;
43050
43063
  const platform = found.platform ?? process.platform;
43051
- if (found.hasPch ?? onPath("pch")) {
43052
- return platform === "win32" ? { name, command: "cmd", args: ["/c", "pch", ...flags] } : { name, command: "pch", args: flags };
43064
+ if (found.executable ?? isExecutable) return { name, command: process.execPath, args: flags };
43065
+ const pch = found.pchPath === void 0 ? whereOnPath("pch") : found.pchPath;
43066
+ if (pch) {
43067
+ return platform === "win32" ? { name, command: "cmd", args: ["/c", "pch", ...flags] } : { name, command: pch, args: flags };
43053
43068
  }
43054
43069
  return {
43055
43070
  name,
43056
43071
  command: process.execPath,
43057
- args: isExecutable ? flags : [...process.execArgv, resolve5(process.argv[1]), ...flags]
43072
+ args: [...process.execArgv, resolve5(process.argv[1]), ...flags]
43058
43073
  };
43059
43074
  }
43060
43075
  function commandLine(server) {
@@ -43083,8 +43098,18 @@ function codexHas(server) {
43083
43098
  return { status: listed.stdout?.includes(server.name) ? 0 : 1 };
43084
43099
  }
43085
43100
  function onPath(bin) {
43101
+ return whereOnPath(bin) !== null;
43102
+ }
43103
+ function whereOnPath(bin) {
43086
43104
  const names = process.platform === "win32" ? [`${bin}.cmd`, `${bin}.exe`, `${bin}.bat`, bin] : [bin];
43087
- return (process.env.PATH ?? "").split(delimiter).some((folder) => folder && names.some((name) => existsSync2(join6(folder, name))));
43105
+ for (const folder of (process.env.PATH ?? "").split(delimiter)) {
43106
+ if (!folder) continue;
43107
+ for (const name of names) {
43108
+ const path2 = resolve5(folder, name);
43109
+ if (existsSync2(path2)) return path2;
43110
+ }
43111
+ }
43112
+ return null;
43088
43113
  }
43089
43114
  function run(bin, args2) {
43090
43115
  const shell = process.platform === "win32";
@@ -45442,7 +45467,7 @@ var LABELS = {
45442
45467
  warn: "look ",
45443
45468
  cant_check: "can't"
45444
45469
  };
45445
- var ADVICE = "This is advice: nothing here stops you publishing, and it doesn't judge what kind of game this is. Patch's own test runs in Patch Desktop: pch test start.";
45470
+ var ADVICE = "This is advice: nothing here stops you publishing, and it doesn't judge what kind of game this is. Patch's own test runs in Patch Desktop, as pch publish does it.";
45446
45471
  async function findGame(dir) {
45447
45472
  const root = resolve8(dir);
45448
45473
  if (existsSync4(join12(root, "patch.json"))) {
@@ -45450,7 +45475,7 @@ async function findGame(dir) {
45450
45475
  if (project2.recipe.mode !== "browser") {
45451
45476
  throw usage2(
45452
45477
  "not_a_browser_game",
45453
- `This is a ${project2.kind}, which runs in its game, so a browser can't see it. pch test start tries it on your PC, in Patch Desktop.`
45478
+ `This is a ${project2.kind}, which runs in its game, so a browser can't see it. pch publish tries it on your PC, in Patch Desktop.`
45454
45479
  );
45455
45480
  }
45456
45481
  const launch2 = project2.recipe.launch;
@@ -45751,38 +45776,28 @@ function reviewing(progress) {
45751
45776
  return progress.slow ? `Patch is reviewing it: ${step}. It's taking longer than usual: ${usual}. It finishes by itself.` : `Patch is reviewing it: ${step}. ${capital(usual)}.`;
45752
45777
  }
45753
45778
  function nextStep(release3, status2, test2, cloud) {
45754
- const follow4 = `pch test view ${release3} --wait`;
45755
- const startOne2 = `pch test start ${release3}`;
45779
+ const carryOn = `pch publish ${release3}`;
45756
45780
  switch (status2) {
45757
45781
  case "draft":
45758
- return {
45759
- say: "It's a private draft, and it can't be published until it has passed a test on your PC, in Patch Desktop, which someone has to press Start in.",
45760
- command: startOne2
45761
- };
45782
+ return { say: "It's uploaded, and its test comes next.", command: carryOn };
45762
45783
  case "testing":
45763
45784
  switch (test2?.state) {
45764
45785
  case "waiting":
45765
- return {
45766
- say: "Its test is waiting for you: press Start on it in Patch Desktop.",
45767
- command: follow4
45768
- };
45786
+ return { say: "Its test is waiting to start in Patch Desktop.", command: carryOn };
45769
45787
  case "running":
45770
45788
  return {
45771
45789
  say: `Patch Desktop${test2.pc_name ? ` on ${test2.pc_name}` : ""} is playing it.`,
45772
- command: follow4
45790
+ command: carryOn
45773
45791
  };
45774
45792
  case "recorded":
45775
45793
  return {
45776
- say: "It has been played and recorded. Look at the footage, then send your review.",
45777
- command: `pch test footage ${release3}`
45794
+ say: "It's recorded. Look at the footage, then send your review.",
45795
+ command: carryOn
45778
45796
  };
45779
45797
  case "passed":
45780
- return {
45781
- say: "It passed its test, and it's still private. Publish it once you're happy it's ready.",
45782
- command: `pch release submit ${release3}`
45783
- };
45798
+ return { say: "It passed its test.", command: carryOn };
45784
45799
  default:
45785
- return { say: "It needs a test that passes.", command: startOne2 };
45800
+ return { say: "It needs a test that passes.", command: carryOn };
45786
45801
  }
45787
45802
  case "rejected":
45788
45803
  return {
@@ -45791,10 +45806,7 @@ function nextStep(release3, status2, test2, cloud) {
45791
45806
  };
45792
45807
  case "checking":
45793
45808
  case "cloud_review":
45794
- return {
45795
- say: reviewing(cloud) ?? "Patch is reviewing it, which can take several minutes, and nothing tells you when it's done: pch status says.",
45796
- command: `pch status ${release3} --wait 50`
45797
- };
45809
+ return { say: reviewing(cloud) ?? "Patch is reviewing it.", command: carryOn };
45798
45810
  case "in_review":
45799
45811
  return {
45800
45812
  say: "It's waiting for a staff member at Patch, and nothing tells you when that's done: pch status says.",
@@ -47454,7 +47466,7 @@ var PATCH_KEYS = ["PCH_TOKEN", "PCH_GIT_KEY", PUSH_KEY_VARIABLE];
47454
47466
  function run2(dir, args2, signIn = NO_SIGN_IN) {
47455
47467
  const env = { ...process.env };
47456
47468
  if (signIn.own) for (const name of PATCH_KEYS) delete env[name];
47457
- return new Promise((resolve16) => {
47469
+ return new Promise((resolve17) => {
47458
47470
  execFile(
47459
47471
  "git",
47460
47472
  ["-C", dir, ...signIn.args, ...args2],
@@ -47464,7 +47476,7 @@ function run2(dir, args2, signIn = NO_SIGN_IN) {
47464
47476
  },
47465
47477
  (error62, out, err) => {
47466
47478
  const code = error62?.code;
47467
- resolve16({ code: error62 ? typeof code === "number" ? code : -1 : 0, out, err });
47479
+ resolve17({ code: error62 ? typeof code === "number" ? code : -1 : 0, out, err });
47468
47480
  }
47469
47481
  );
47470
47482
  });
@@ -48382,11 +48394,469 @@ async function mediaOrder(ctx, ref, ids) {
48382
48394
  }
48383
48395
 
48384
48396
  // src/commands/publish.ts
48385
- var import_picocolors20 = __toESM(require_picocolors(), 1);
48397
+ var import_picocolors22 = __toESM(require_picocolors(), 1);
48386
48398
  var import_semver2 = __toESM(require_semver2(), 1);
48387
48399
  import { open as open2, readFile as readFile15 } from "node:fs/promises";
48388
48400
  init_errors();
48389
48401
  init_guide_words();
48402
+
48403
+ // src/commands/publish-flow.ts
48404
+ var import_picocolors21 = __toESM(require_picocolors(), 1);
48405
+ import { mkdtemp as mkdtemp4 } from "node:fs/promises";
48406
+ import { tmpdir as tmpdir4 } from "node:os";
48407
+ import { join as join19, resolve as resolve16 } from "node:path";
48408
+ init_errors();
48409
+
48410
+ // src/commands/release-test.ts
48411
+ var import_picocolors20 = __toESM(require_picocolors(), 1);
48412
+ import { createHash as createHash3 } from "node:crypto";
48413
+ import { mkdir as mkdir9, mkdtemp as mkdtemp3, writeFile as writeFile12 } from "node:fs/promises";
48414
+ import { tmpdir as tmpdir3 } from "node:os";
48415
+ import { join as join18, resolve as resolve15 } from "node:path";
48416
+ import { setTimeout as sleep9 } from "node:timers/promises";
48417
+ init_errors();
48418
+ var HOLD_SECONDS = 50;
48419
+ var ASK_EVERY_MS2 = 5e3;
48420
+ var OPEN = /* @__PURE__ */ new Set(["waiting", "running", "recorded"]);
48421
+ var GOING = /* @__PURE__ */ new Set(["waiting", "running"]);
48422
+ var REVIEW_ROUTE = "/projects/{owner}/{slug}/releases/{version}/review";
48423
+ var reviewOf = (ctx, release3) => ctx.api.GET(REVIEW_ROUTE, { params: { path: release3 } });
48424
+ async function testStart(ctx, ref, options) {
48425
+ requireToken(ctx.target);
48426
+ const release3 = parseReleaseRef(ref);
48427
+ const wait2 = waitOf(options.wait);
48428
+ const before = await reviewOf(ctx, release3);
48429
+ const open3 = before.data?.test && OPEN.has(before.data.test.state) ? before.data.test : null;
48430
+ const test2 = open3 ?? await startOne(ctx, release3, before.data?.status);
48431
+ return show3(ctx, release3, wait2 === null ? test2 : await follow3(ctx, test2, wait2), {
48432
+ started: !open3,
48433
+ followed: wait2 !== null
48434
+ });
48435
+ }
48436
+ async function startOne(ctx, release3, status2) {
48437
+ const name = releaseName(release3);
48438
+ const answer = await ctx.api.POST("/projects/{owner}/{slug}/releases/{version}/tests", {
48439
+ params: { path: release3 }
48440
+ });
48441
+ if (answer.response.ok) return answer.data;
48442
+ const failure2 = fromResponse(answer.response, answer.error);
48443
+ if (failure2.code === "files_failed") {
48444
+ const { findings } = failure2.details;
48445
+ throw filesFailed(failure2.message, findings);
48446
+ }
48447
+ if (failure2.code === "not_testable") {
48448
+ const next = `pch release submit ${name}`;
48449
+ throw new CliError(
48450
+ failure2.exitCode,
48451
+ failure2.code,
48452
+ `Patch Desktop can't test ${name}, as it's a mod players install some other way, so it skips the test. Send it for review once it's ready: ${next}`,
48453
+ { next }
48454
+ );
48455
+ }
48456
+ if (failure2.code === "not_testing" && status2) {
48457
+ const next = nextStep(name, status2, null);
48458
+ throw new CliError(
48459
+ failure2.exitCode,
48460
+ failure2.code,
48461
+ `${name} is ${status2}, so it can't be tested. ${sayNext(next)}`,
48462
+ { next: next.command }
48463
+ );
48464
+ }
48465
+ throw failure2;
48466
+ }
48467
+ async function testView(ctx, ref, options) {
48468
+ requireToken(ctx.target);
48469
+ const release3 = parseReleaseRef(ref);
48470
+ const wait2 = waitOf(options.wait);
48471
+ const test2 = await newest(ctx, release3);
48472
+ return show3(ctx, release3, wait2 === null ? test2 : await follow3(ctx, test2, wait2), {
48473
+ followed: wait2 !== null
48474
+ });
48475
+ }
48476
+ async function newest(ctx, release3) {
48477
+ const review = await unwrap(reviewOf(ctx, release3));
48478
+ if (review.test) return review.test;
48479
+ const name = releaseName(release3);
48480
+ const next = `pch test start ${name}`;
48481
+ throw new CliError(EXIT.error, "not_found", `${name} has no test yet. Start one: ${next}`, {
48482
+ next
48483
+ });
48484
+ }
48485
+ async function follow3(ctx, test2, seconds2) {
48486
+ const deadline = Date.now() + seconds2 * 1e3;
48487
+ let current = test2;
48488
+ let said2 = "";
48489
+ while (GOING.has(current.state) && Date.now() < deadline) {
48490
+ const now = whatsHappening(current);
48491
+ if (now !== said2) ctx.out.step(now);
48492
+ said2 = now;
48493
+ if (current.needs_person) await sleep9(Math.min(ASK_EVERY_MS2, deadline - Date.now()));
48494
+ const left = Math.ceil((deadline - Date.now()) / 1e3);
48495
+ current = await unwrap(
48496
+ ctx.api.GET("/tests/{id}", {
48497
+ params: {
48498
+ path: { id: current.id },
48499
+ query: { wait: Math.max(0, Math.min(HOLD_SECONDS, left)) }
48500
+ }
48501
+ })
48502
+ );
48503
+ }
48504
+ return current;
48505
+ }
48506
+ function whatsHappening(test2) {
48507
+ if (test2.state === "running") {
48508
+ const where = test2.pc_name ? ` on ${plain(test2.pc_name)}` : "";
48509
+ const phase = test2.progress?.phase;
48510
+ return phase && phase !== "playing" ? `Patch Desktop${where} is ${plain(phase)} it` : `Patch Desktop${where} is playing it`;
48511
+ }
48512
+ if (test2.needs_person) return plain(test2.needs_person.ask);
48513
+ return "Waiting for you to press Start in Patch Desktop";
48514
+ }
48515
+ async function show3(ctx, release3, test2, { followed, ...extra } = {}) {
48516
+ const { out } = ctx;
48517
+ const name = releaseName(release3);
48518
+ const next = nextStep(name, test2.state === "failed" ? "rejected" : "testing", test2);
48519
+ const command = next.command;
48520
+ const play2 = test2.state === "passed" ? await playFirst(ctx, release3) : null;
48521
+ const ask = test2.state === "waiting" ? askThePerson(test2) : play2;
48522
+ const noteAsk = test2.note_ask?.replaceAll("suggest_note", "pch note suggest");
48523
+ const feedbackAsk = test2.feedback_ask ? {
48524
+ ...test2.feedback_ask,
48525
+ ask: test2.feedback_ask.ask.replaceAll("send_feedback", "pch feedback send")
48526
+ } : void 0;
48527
+ const shown = {
48528
+ ...test2,
48529
+ ...extra,
48530
+ next: command,
48531
+ ...noteAsk ? { note_ask: noteAsk } : {},
48532
+ ...feedbackAsk ? { feedback_ask: feedbackAsk } : {}
48533
+ };
48534
+ out.result({ ...shown, ...ask ? { ask } : {} }, () => {
48535
+ const bold = import_picocolors20.default.bold(name);
48536
+ if (test2.state === "waiting") {
48537
+ out.line(
48538
+ !test2.needs_person ? `${extra.started ? "Started a test of" : "The test of"} ${bold}. It's waiting for Patch Desktop to take it on.` : extra.started ? `Started a test of ${bold}. It's waiting for you in Patch Desktop: press Start on it there.` : `${bold}: its test is waiting for you in Patch Desktop. Press Start on it there.`
48539
+ );
48540
+ if (test2.needs_person) {
48541
+ out.line(plain(test2.needs_person.why));
48542
+ out.line(plain(test2.needs_person.ask));
48543
+ }
48544
+ } else if (test2.state === "running") {
48545
+ out.line(`${bold}: ${whatsHappening(test2)}.`);
48546
+ } else if (test2.state === "recorded") {
48547
+ out.line(
48548
+ `${bold}: it has been played and recorded. Look at the footage, then send your review.`
48549
+ );
48550
+ } else if (test2.state === "passed") {
48551
+ out.line(`${bold}: ${import_picocolors20.default.green("its test passed.")} It's still private.`);
48552
+ if (test2.review) out.line(plain(test2.review.summary));
48553
+ if (play2) out.line(play2);
48554
+ out.line(
48555
+ "Publish it once you're happy it's ready: that's your yes, and nothing does it for you."
48556
+ );
48557
+ } else if (test2.state === "failed") {
48558
+ out.line(`${bold}: ${import_picocolors20.default.red("its test failed")}, so this version can't be published.`);
48559
+ showWhatFailed(ctx, test2);
48560
+ out.line(`pch status ${name} says how to fix it.`);
48561
+ out.line("Then move the version on in patch.json and publish that.");
48562
+ } else if (test2.state === "couldnt_run") {
48563
+ out.line(`${bold}: ${import_picocolors20.default.yellow("its test couldn't run.")}`);
48564
+ if (test2.outcome) out.line(plain(test2.outcome.reason));
48565
+ } else {
48566
+ out.line(`${bold}: its test was cancelled.`);
48567
+ }
48568
+ if (noteAsk) out.line(plain(noteAsk));
48569
+ if (feedbackAsk) out.line(plain(feedbackAsk.ask));
48570
+ if (followed && GOING.has(test2.state)) {
48571
+ out.line("It isn't over, and nothing's wrong: that's as long as --wait follows it here.");
48572
+ }
48573
+ if (command) out.line(`Next: ${command}`);
48574
+ if (OPEN.has(test2.state) && !extra.started) {
48575
+ out.line(import_picocolors20.default.dim(`To start over: pch test cancel ${name}, then pch test start ${name}`));
48576
+ }
48577
+ });
48578
+ if (test2.state === "failed") return EXIT.blocked;
48579
+ if (test2.state === "couldnt_run" || test2.state === "cancelled") return EXIT.error;
48580
+ return EXIT.ok;
48581
+ }
48582
+ function askThePerson(test2) {
48583
+ return test2.needs_person?.ask ?? null;
48584
+ }
48585
+ function showWhatFailed({ out }, test2) {
48586
+ for (const run3 of test2.results) {
48587
+ for (const check3 of run3.checks) {
48588
+ if (check3.result !== "fail") continue;
48589
+ const detail = check3.detail ? ` (${plain(check3.detail)})` : "";
48590
+ out.line(` Run ${run3.run}: ${plain(check3.name)}${detail}`);
48591
+ }
48592
+ }
48593
+ if (test2.review) out.line(` Its review: ${plain(test2.review.summary)}`);
48594
+ }
48595
+ async function testFootage(ctx, ref, options) {
48596
+ requireToken(ctx.target);
48597
+ const { out } = ctx;
48598
+ const release3 = parseReleaseRef(ref);
48599
+ const name = releaseName(release3);
48600
+ const run3 = options.run === void 0 ? void 0 : positive(options.run, "--run");
48601
+ const test2 = await newest(ctx, release3);
48602
+ const { data } = await unwrap(
48603
+ ctx.api.GET("/tests/{id}/footage", { params: { path: { id: test2.id }, query: { run: run3 } } })
48604
+ );
48605
+ const sheets = data.length ? await saveSheets(
48606
+ data,
48607
+ options.out ? resolve15(options.out) : await mkdtemp3(join18(tmpdir3(), "pch-footage-"))
48608
+ ) : [];
48609
+ const next = data.length ? reviewCommand(name) : nextStep(name, "testing", test2).command;
48610
+ out.result({ test: test2.id, state: test2.state, data: sheets, next }, () => {
48611
+ if (!sheets.length) {
48612
+ out.line(`${import_picocolors20.default.bold(name)} has no footage yet: its test is ${test2.state.replace("_", " ")}.`);
48613
+ for (const result of test2.results) {
48614
+ if (result.footage_error) out.line(plain(result.footage_error));
48615
+ }
48616
+ }
48617
+ for (const sheet of sheets) {
48618
+ out.line(`Run ${sheet.run}, ${sheet.from} to ${sheet.to}: ${sheet.path}`);
48619
+ }
48620
+ if (next) {
48621
+ if (sheets.length) out.line("Look at every one, then send your review, with what you saw:");
48622
+ out.line(`Next: ${next}`);
48623
+ }
48624
+ });
48625
+ }
48626
+ var reviewCommand = (name) => `pch test review ${name} --verdict <pass|fail> --plays <yes|no> --harmful <yes|no> --summary "<what you saw>" --moment "<run> <mm:ss> <what you saw>" --moment "<run> <mm:ss> <what you saw>"`;
48627
+ async function saveSheets(sheets, folder) {
48628
+ await mkdir9(folder, { recursive: true });
48629
+ const saved = [];
48630
+ for (const sheet of sheets) {
48631
+ const answer = await fetch(sheet.url).catch(() => null);
48632
+ const bytes = answer?.ok ? Buffer.from(await answer.arrayBuffer()) : null;
48633
+ if (!bytes || createHash3("sha256").update(bytes).digest("hex") !== sheet.sha256) {
48634
+ throw new CliError(
48635
+ EXIT.error,
48636
+ "download_failed",
48637
+ `Run ${sheet.run}'s sheet ${sheet.index} didn't download intact. Try again.`
48638
+ );
48639
+ }
48640
+ const path2 = join18(folder, `run-${sheet.run}-sheet-${sheet.index}.jpg`);
48641
+ await writeFile12(path2, bytes);
48642
+ const { url: _url2, ...kept } = sheet;
48643
+ saved.push({ ...kept, path: path2 });
48644
+ }
48645
+ return saved;
48646
+ }
48647
+ async function testReview(ctx, ref, options) {
48648
+ requireToken(ctx.target);
48649
+ const release3 = parseReleaseRef(ref);
48650
+ const moments = options.moment.map(parseMoment);
48651
+ const test2 = await newest(ctx, release3);
48652
+ const answer = await ctx.api.POST("/tests/{id}/review", {
48653
+ params: { path: { id: test2.id } },
48654
+ body: {
48655
+ verdict: options.verdict,
48656
+ plays: options.plays === "yes",
48657
+ harmful: options.harmful === "yes",
48658
+ summary: options.summary,
48659
+ moments
48660
+ }
48661
+ });
48662
+ if (answer.response.ok) return show3(ctx, release3, answer.data);
48663
+ throw withNext(fromResponse(answer.response, answer.error), release3, test2);
48664
+ }
48665
+ function parseMoment(text) {
48666
+ const match = /^(\d+)\s+(\d{1,3}:\d{2})\s+(\S.*)$/s.exec(text.trim());
48667
+ if (!match) {
48668
+ throw new CliError(
48669
+ EXIT.usage,
48670
+ "bad_moment",
48671
+ `"${text}" isn't a moment. Write its run, the time on a frame and what you saw, such as "1 00:04 the title screen draws".`
48672
+ );
48673
+ }
48674
+ return { run: Number(match[1]), at: match[2], saw: match[3] };
48675
+ }
48676
+ async function testCancel(ctx, ref) {
48677
+ requireToken(ctx.target);
48678
+ const release3 = parseReleaseRef(ref);
48679
+ const name = releaseName(release3);
48680
+ const test2 = await newest(ctx, release3);
48681
+ const answer = await ctx.api.POST("/tests/{id}/cancel", { params: { path: { id: test2.id } } });
48682
+ if (!answer.response.ok) {
48683
+ throw withNext(fromResponse(answer.response, answer.error), release3, test2);
48684
+ }
48685
+ const next = `pch test start ${name}`;
48686
+ ctx.out.result({ ...answer.data, next }, () => {
48687
+ ctx.out.line(`Cancelled ${import_picocolors20.default.bold(name)}'s test. Start another: ${next}`);
48688
+ });
48689
+ }
48690
+ function withNext(failure2, release3, test2) {
48691
+ if (failure2.code !== "test_state") return failure2;
48692
+ const next = nextStep(releaseName(release3), "testing", test2);
48693
+ return new CliError(failure2.exitCode, failure2.code, `${failure2.message}. ${sayNext(next)}`, {
48694
+ next: next.command
48695
+ });
48696
+ }
48697
+ function positive(value, flag) {
48698
+ if (/^[1-9]\d*$/.test(value)) return Number(value);
48699
+ throw new CliError(EXIT.usage, "bad_usage", `${flag} takes a whole number, not "${value}".`);
48700
+ }
48701
+ async function testAutostart(ctx, state) {
48702
+ requireToken(ctx.target);
48703
+ if (state !== "on" && state !== "off") {
48704
+ throw new CliError(EXIT.usage, "bad_value", `Say on or off, not "${state}"`);
48705
+ }
48706
+ const setting = await unwrap(
48707
+ ctx.api.PUT("/me/test-settings", { body: { starts_own_tests: state === "on", by: "agent" } })
48708
+ );
48709
+ ctx.out.result(setting, () => {
48710
+ ctx.out.line(
48711
+ setting.starts_own_tests ? "Your tests of browser games and blocks now start in Patch Desktop by themselves, with a notice each time that lets the person stop them. Patch Desktop tells them you turned it on. A mod's test still waits for Start." : "Your tests wait for the person to press Start in Patch Desktop again."
48712
+ );
48713
+ });
48714
+ }
48715
+
48716
+ // src/commands/publish-flow.ts
48717
+ var ENDS = /* @__PURE__ */ new Set(["your_review", "ready", "live", "stopped", "uploading"]);
48718
+ var PERSON_EVERY_MS = 5e3;
48719
+ function followFor(wait2) {
48720
+ if (wait2 === void 0) {
48721
+ return process.stdout.isTTY ? Number.POSITIVE_INFINITY : UNATTENDED_WAIT_SECONDS;
48722
+ }
48723
+ if (/^\d+$/.test(wait2)) return Number(wait2);
48724
+ throw new CliError(EXIT.usage, "bad_wait", `--wait takes a number of seconds, not "${wait2}".`);
48725
+ }
48726
+ function reviewOf2(flags) {
48727
+ if (flags.review === void 0) return void 0;
48728
+ if (flags.review !== "pass" && flags.review !== "fail") {
48729
+ throw new CliError(EXIT.usage, "bad_value", `--review is pass or fail, not "${flags.review}".`);
48730
+ }
48731
+ const moments = (flags.moment ?? []).map(parseMoment);
48732
+ if (!flags.summary?.trim() || moments.length < 2) {
48733
+ throw new CliError(
48734
+ EXIT.usage,
48735
+ "review_incomplete",
48736
+ `A review needs --summary and at least two --moment, each a run, the time on a frame and what you saw: ${reviewCommand2("<owner/slug@version>")}`
48737
+ );
48738
+ }
48739
+ return {
48740
+ verdict: flags.review,
48741
+ plays: flags.review === "pass",
48742
+ harmful: Boolean(flags.harmful),
48743
+ summary: flags.summary,
48744
+ moments
48745
+ };
48746
+ }
48747
+ var reviewCommand2 = (name) => `pch publish ${name} --review <pass|fail> --summary "<what you saw>" --moment "<run> <mm:ss> <what you saw>" --moment "<run> <mm:ss> <what you saw>"`;
48748
+ function lineOf(step) {
48749
+ if (step.stage === "testing" && step.needs_person) return "Testing on your PC: waiting for you";
48750
+ return step.line;
48751
+ }
48752
+ var sleep10 = (ms) => new Promise((done) => setTimeout(done, ms));
48753
+ async function followRelease(ctx, ref, options = {}) {
48754
+ requireToken(ctx.target);
48755
+ const { out, api } = ctx;
48756
+ const review = reviewOf2(options);
48757
+ const wait2 = options.wait ?? followFor(void 0);
48758
+ const deadline = (options.since ?? Date.now()) + wait2 * 1e3;
48759
+ let body = {
48760
+ ...review ? { review } : {},
48761
+ ...options.testAgain ? { test_again: true } : {}
48762
+ };
48763
+ const people = forAPerson(out.json, runningIn());
48764
+ let said2 = "";
48765
+ let asked = "";
48766
+ let step;
48767
+ for (; ; ) {
48768
+ const left = Math.max(0, Math.ceil((deadline - Date.now()) / 1e3));
48769
+ const answer = await api.POST("/projects/{owner}/{slug}/releases/{version}/publish", {
48770
+ params: { path: ref, query: { wait: Math.min(50, left) } },
48771
+ body
48772
+ });
48773
+ if (!answer.response.ok) throw fromResponse(answer.response, answer.error);
48774
+ step = answer.data;
48775
+ body = void 0;
48776
+ const stops = ENDS.has(step.stage) || step.status === "in_review";
48777
+ const line = lineOf(step);
48778
+ if (line !== said2 && !stops) out.progress(line, { stage: step.stage });
48779
+ said2 = line;
48780
+ if (step.needs_person && step.needs_person.ask !== asked) {
48781
+ asked = step.needs_person.ask;
48782
+ out.step(
48783
+ people ? step.needs_person.ask : `${step.needs_person.why} Ask the person: "${step.needs_person.ask}" This carries on by itself once they have, so keep it running, or run it again.`
48784
+ );
48785
+ }
48786
+ if (stops || Date.now() >= deadline) break;
48787
+ if (step.needs_person) await sleep10(PERSON_EVERY_MS);
48788
+ }
48789
+ return finish(ctx, ref, step, options);
48790
+ }
48791
+ async function finish(ctx, ref, step, options) {
48792
+ const { out } = ctx;
48793
+ const name = releaseName(ref);
48794
+ const url2 = step.urls.play ?? step.urls.page;
48795
+ const ended = ENDS.has(step.stage) && step.stage !== "stopped" && step.stage !== "uploading";
48796
+ const { extra = {}, notes = [] } = (ended || step.stage === "testing" || step.stage === "in_review" ? await options.after?.(step) : void 0) ?? {};
48797
+ const base = { release: { ...ref }, stage: step.stage, line: step.line, url: url2, ...extra };
48798
+ const say = (...lines) => {
48799
+ for (const line of [...notes, ...lines]) out.line(line);
48800
+ };
48801
+ switch (step.stage) {
48802
+ case "live":
48803
+ out.result({ ...base, next: null }, () => say(import_picocolors21.default.green(`Live: ${url2}`)));
48804
+ return;
48805
+ case "ready":
48806
+ out.result(
48807
+ { ...base, next: null },
48808
+ () => say("Passed. Press publish in the chat to send it.")
48809
+ );
48810
+ return;
48811
+ case "your_review": {
48812
+ const sheets = await saveSheets(
48813
+ step.footage ?? [],
48814
+ options.out ? resolve16(options.out) : await mkdtemp4(join19(tmpdir4(), "pch-footage-"))
48815
+ );
48816
+ const next = reviewCommand2(name);
48817
+ out.result({ ...base, test: step.test_id, footage: sheets, next }, () => {
48818
+ say(`Recorded. Look at every sheet, the bursts too:`);
48819
+ for (const sheet of sheets) out.line(` ${sheet.path} (${sheet.from} to ${sheet.to})`);
48820
+ out.line(`Then send your review (add --harmful if it does anything harmful):`);
48821
+ out.line(`Next: ${next}`);
48822
+ });
48823
+ return;
48824
+ }
48825
+ case "stopped":
48826
+ case "uploading": {
48827
+ const code = step.code ?? "stopped";
48828
+ const exit = code === "test_failed" || code === "rejected" || code === "files_failed" ? EXIT.blocked : EXIT.error;
48829
+ throw new CliError(exit, code, `${name}: ${step.next}`, {
48830
+ findings: step.findings,
48831
+ issues: step.findings.map((finding) => ({
48832
+ path: finding.path ?? "",
48833
+ message: `${finding.rule_id}: ${finding.message}${finding.suggestion ? `. ${finding.suggestion}` : ""}`
48834
+ })),
48835
+ next: code === "couldnt_run" ? `pch publish ${name} --test-again` : "pch publish"
48836
+ });
48837
+ }
48838
+ default: {
48839
+ const staff = step.status === "in_review";
48840
+ const next = staff ? `pch status ${name}` : `pch publish ${name}`;
48841
+ out.result({ ...base, needs_person: step.needs_person, next }, () => {
48842
+ if (staff) say("Waiting for a staff member at Patch.");
48843
+ else say(`Still ${lineOf(step).replace(/^./, (first) => first.toLowerCase())}.`);
48844
+ out.line(`Next: ${next}`);
48845
+ });
48846
+ }
48847
+ }
48848
+ }
48849
+ async function firstTime(ctx, project2, what) {
48850
+ const key = `${ctx.target.origin} ${project2}`;
48851
+ ctx.config.told ??= {};
48852
+ const told = ctx.config.told[key] ?? [];
48853
+ if (told.includes(what)) return false;
48854
+ ctx.config.told[key] = [...told, what];
48855
+ await writeConfig(ctx.config).catch(() => void 0);
48856
+ return true;
48857
+ }
48858
+
48859
+ // src/commands/publish.ts
48390
48860
  var MB = 1024 * 1024;
48391
48861
  var FILES_AT_ONCE = 16;
48392
48862
  var BIG_FILE = 16 * MB;
@@ -48402,8 +48872,23 @@ function forkNamesParent(named, clone3) {
48402
48872
  { named, clone: clone3 }
48403
48873
  );
48404
48874
  }
48405
- async function publish(ctx, dir, options) {
48875
+ async function publish(ctx, target, options) {
48406
48876
  requireToken(ctx.target);
48877
+ const started = Date.now();
48878
+ const follow4 = {
48879
+ ...options,
48880
+ wait: followFor(options.wait),
48881
+ since: started
48882
+ };
48883
+ if (target.includes("@")) return followRelease(ctx, parseReleaseRef(target), follow4);
48884
+ if (options.review !== void 0 || options.testAgain) {
48885
+ throw new CliError(
48886
+ EXIT.usage,
48887
+ "release_required",
48888
+ "Say which release: pch publish <owner/slug@version> --review \u2026, as pch publish printed it."
48889
+ );
48890
+ }
48891
+ const dir = target;
48407
48892
  const { out, api } = ctx;
48408
48893
  const project2 = await loadProject(dir);
48409
48894
  if (project2.issues.length) {
@@ -48462,53 +48947,41 @@ async function publish(ctx, dir, options) {
48462
48947
  } else {
48463
48948
  out.step("Patch already has every file");
48464
48949
  }
48465
- out.step("Submitting");
48466
- const name = releaseName({ ...ref, version: version2 });
48467
- const submitted = await submit(ctx, { ...ref, version: version2 }).catch(draftUntilTested);
48468
- const release3 = await unwrap(
48469
- api.GET("/projects/{owner}/{slug}/releases/{version}", { params: { path: path2 } })
48470
- );
48471
- const url2 = projectUrl(ctx.target.origin, ref);
48472
48950
  const branch = await source?.bringBranch();
48473
- const branchSaid = branch ? { branch } : {};
48474
- const next = nextStep(name, submitted?.status ?? "draft", null).command;
48475
- const openCode = await openCodeOf(ctx, ref, release3);
48476
- const whoCanSee = whoCanSeeIt(
48477
- // A draft is private whichever channel it's for, as the words for a person say.
48478
- !submitted || release3.channel === "private" ? privateWords(release3.kind, !submitted) : null,
48479
- openCode
48480
- );
48481
- const result = {
48482
- release: release3,
48483
- uploaded: draft.uploads.length,
48484
- url: url2,
48485
- next,
48486
- ...branchSaid,
48487
- ...whoCanSee
48488
- };
48489
- out.result(result, () => {
48490
- if (!submitted) {
48491
- out.line(
48492
- `Uploaded ${import_picocolors20.default.bold(name)} as a private draft. It can't be published until it has passed a test on your PC, in Patch Desktop, where someone presses Start.`
48493
- );
48494
- out.line(privateWords(release3.kind, true));
48495
- if (openCode) out.line(openCode);
48496
- if (source) out.line(fromCommit(source));
48497
- if (branch?.say) out.line(branch.say);
48498
- out.line(`Next: ${next}`);
48499
- return;
48500
- }
48501
- showSubmitted(ctx, { ...ref, version: version2 }, submitted.status, release3);
48502
- if (openCode) out.line(openCode);
48503
- if (source) out.line(fromCommit(source));
48504
- if (branch?.say) out.line(branch.say);
48505
- for (const entry of release3.files) {
48506
- out.line(
48507
- ` ${entry.path.padEnd(40)} ${formatSize(entry.size).padStart(8)} sha256 ${entry.sha256}`
48508
- );
48951
+ await followRelease(
48952
+ ctx,
48953
+ { ...ref, version: version2 },
48954
+ {
48955
+ ...follow4,
48956
+ // Who can see it, as it is once it stops. It's the same each time, so it's said once a
48957
+ // project, and --json always has it.
48958
+ after: async () => {
48959
+ const release3 = await unwrap(
48960
+ api.GET("/projects/{owner}/{slug}/releases/{version}", { params: { path: path2 } })
48961
+ );
48962
+ const openCode = await openCodeOf(ctx, ref, release3);
48963
+ const privately = release3.channel === "private" ? privateWords(release3.kind, release3.status !== "published") : null;
48964
+ const notes = [source ? fromCommit(source).trim() : null, branch?.say ?? null].filter(
48965
+ (note2) => Boolean(note2)
48966
+ );
48967
+ for (const [what, words2] of [
48968
+ ["private", privately],
48969
+ ["open_code", openCode]
48970
+ ]) {
48971
+ if (words2 && await firstTime(ctx, file2.project, what)) notes.push(words2);
48972
+ }
48973
+ return {
48974
+ extra: {
48975
+ release: release3,
48976
+ uploaded: draft.uploads.length,
48977
+ ...branch ? { branch } : {},
48978
+ ...whoCanSeeIt(privately, openCode)
48979
+ },
48980
+ notes
48981
+ };
48982
+ }
48509
48983
  }
48510
- out.line(import_picocolors20.default.dim(url2));
48511
- });
48984
+ );
48512
48985
  }
48513
48986
  function fromCommit(source) {
48514
48987
  const on = source.on === "GitHub" ? " on GitHub" : "";
@@ -48527,7 +49000,7 @@ async function releaseSubmit(ctx, ref) {
48527
49000
  ctx.out.result({ ...submitted, next, ...whoCanSee }, () => {
48528
49001
  showSubmitted(ctx, release3, submitted.status, submitted);
48529
49002
  if (openCode) ctx.out.line(openCode);
48530
- ctx.out.line(import_picocolors20.default.dim(projectUrl(ctx.target.origin, release3)));
49003
+ ctx.out.line(import_picocolors22.default.dim(projectUrl(ctx.target.origin, release3)));
48531
49004
  });
48532
49005
  }
48533
49006
  async function releasePromote(ctx, ref, options) {
@@ -48581,7 +49054,7 @@ async function releasePromote(ctx, ref, options) {
48581
49054
  const next = nextStep(name, "draft", null).command;
48582
49055
  ctx.out.result({ from: ref, release: { version: version2, status: "draft" }, url: url2, next }, () => {
48583
49056
  ctx.out.line(
48584
- `Made ${import_picocolors20.default.bold(name)} from ${ref}, as a draft for ${options.channel}. It needs a passed test of its own.`
49057
+ `Made ${import_picocolors22.default.bold(name)} from ${ref}, as a draft for ${options.channel}. It needs a passed test of its own.`
48585
49058
  );
48586
49059
  ctx.out.line(`Next: ${next}`);
48587
49060
  });
@@ -48589,7 +49062,7 @@ async function releasePromote(ctx, ref, options) {
48589
49062
  }
48590
49063
  ctx.out.result({ from: ref, release: submitted, url: url2 }, () => {
48591
49064
  showSubmitted(ctx, to, submitted.status, submitted);
48592
- ctx.out.line(import_picocolors20.default.dim(url2));
49065
+ ctx.out.line(import_picocolors22.default.dim(url2));
48593
49066
  });
48594
49067
  }
48595
49068
  function draftUntilTested(error62) {
@@ -48626,7 +49099,7 @@ function whoCanSeeIt(...words2) {
48626
49099
  function showSubmitted(ctx, ref, status2, { channel, kind }) {
48627
49100
  const name = releaseName(ref);
48628
49101
  if (status2 === "published") {
48629
- ctx.out.line(import_picocolors20.default.green(`Published ${name} to ${channel}`));
49102
+ ctx.out.line(import_picocolors22.default.green(`Published ${name} to ${channel}`));
48630
49103
  if (channel === "private") {
48631
49104
  ctx.out.line(privateWords(kind, false));
48632
49105
  ctx.out.line(`pch status ${name} says whether your team can play it yet, and where.`);
@@ -48779,314 +49252,8 @@ async function uploadFailed(file2, upload, response) {
48779
49252
  );
48780
49253
  }
48781
49254
 
48782
- // src/commands/release-test.ts
48783
- var import_picocolors21 = __toESM(require_picocolors(), 1);
48784
- import { createHash as createHash3 } from "node:crypto";
48785
- import { mkdir as mkdir9, mkdtemp as mkdtemp3, writeFile as writeFile12 } from "node:fs/promises";
48786
- import { tmpdir as tmpdir3 } from "node:os";
48787
- import { join as join18, resolve as resolve15 } from "node:path";
48788
- import { setTimeout as sleep9 } from "node:timers/promises";
48789
- init_errors();
48790
- var HOLD_SECONDS = 50;
48791
- var ASK_EVERY_MS2 = 5e3;
48792
- var OPEN = /* @__PURE__ */ new Set(["waiting", "running", "recorded"]);
48793
- var GOING = /* @__PURE__ */ new Set(["waiting", "running"]);
48794
- var REVIEW_ROUTE = "/projects/{owner}/{slug}/releases/{version}/review";
48795
- var reviewOf = (ctx, release3) => ctx.api.GET(REVIEW_ROUTE, { params: { path: release3 } });
48796
- async function testStart(ctx, ref, options) {
48797
- requireToken(ctx.target);
48798
- const release3 = parseReleaseRef(ref);
48799
- const wait2 = waitOf(options.wait);
48800
- const before = await reviewOf(ctx, release3);
48801
- const open3 = before.data?.test && OPEN.has(before.data.test.state) ? before.data.test : null;
48802
- const test2 = open3 ?? await startOne(ctx, release3, before.data?.status);
48803
- return show3(ctx, release3, wait2 === null ? test2 : await follow3(ctx, test2, wait2), {
48804
- started: !open3,
48805
- followed: wait2 !== null
48806
- });
48807
- }
48808
- async function startOne(ctx, release3, status2) {
48809
- const name = releaseName(release3);
48810
- const answer = await ctx.api.POST("/projects/{owner}/{slug}/releases/{version}/tests", {
48811
- params: { path: release3 }
48812
- });
48813
- if (answer.response.ok) return answer.data;
48814
- const failure2 = fromResponse(answer.response, answer.error);
48815
- if (failure2.code === "files_failed") {
48816
- const { findings } = failure2.details;
48817
- throw filesFailed(failure2.message, findings);
48818
- }
48819
- if (failure2.code === "not_testable") {
48820
- const next = `pch release submit ${name}`;
48821
- throw new CliError(
48822
- failure2.exitCode,
48823
- failure2.code,
48824
- `Patch Desktop can't test ${name}, as it's a mod players install some other way, so it skips the test. Send it for review once it's ready: ${next}`,
48825
- { next }
48826
- );
48827
- }
48828
- if (failure2.code === "not_testing" && status2) {
48829
- const next = nextStep(name, status2, null);
48830
- throw new CliError(
48831
- failure2.exitCode,
48832
- failure2.code,
48833
- `${name} is ${status2}, so it can't be tested. ${sayNext(next)}`,
48834
- { next: next.command }
48835
- );
48836
- }
48837
- throw failure2;
48838
- }
48839
- async function testView(ctx, ref, options) {
48840
- requireToken(ctx.target);
48841
- const release3 = parseReleaseRef(ref);
48842
- const wait2 = waitOf(options.wait);
48843
- const test2 = await newest(ctx, release3);
48844
- return show3(ctx, release3, wait2 === null ? test2 : await follow3(ctx, test2, wait2), {
48845
- followed: wait2 !== null
48846
- });
48847
- }
48848
- async function newest(ctx, release3) {
48849
- const review = await unwrap(reviewOf(ctx, release3));
48850
- if (review.test) return review.test;
48851
- const name = releaseName(release3);
48852
- const next = `pch test start ${name}`;
48853
- throw new CliError(EXIT.error, "not_found", `${name} has no test yet. Start one: ${next}`, {
48854
- next
48855
- });
48856
- }
48857
- async function follow3(ctx, test2, seconds2) {
48858
- const deadline = Date.now() + seconds2 * 1e3;
48859
- let current = test2;
48860
- let said2 = "";
48861
- while (GOING.has(current.state) && Date.now() < deadline) {
48862
- const now = whatsHappening(current);
48863
- if (now !== said2) ctx.out.step(now);
48864
- said2 = now;
48865
- if (current.needs_person) await sleep9(Math.min(ASK_EVERY_MS2, deadline - Date.now()));
48866
- const left = Math.ceil((deadline - Date.now()) / 1e3);
48867
- current = await unwrap(
48868
- ctx.api.GET("/tests/{id}", {
48869
- params: {
48870
- path: { id: current.id },
48871
- query: { wait: Math.max(0, Math.min(HOLD_SECONDS, left)) }
48872
- }
48873
- })
48874
- );
48875
- }
48876
- return current;
48877
- }
48878
- function whatsHappening(test2) {
48879
- if (test2.state === "running") {
48880
- const where = test2.pc_name ? ` on ${plain(test2.pc_name)}` : "";
48881
- const phase = test2.progress?.phase;
48882
- return phase && phase !== "playing" ? `Patch Desktop${where} is ${plain(phase)} it` : `Patch Desktop${where} is playing it`;
48883
- }
48884
- if (test2.needs_person) return plain(test2.needs_person.ask);
48885
- return "Waiting for you to press Start in Patch Desktop";
48886
- }
48887
- async function show3(ctx, release3, test2, { followed, ...extra } = {}) {
48888
- const { out } = ctx;
48889
- const name = releaseName(release3);
48890
- const next = nextStep(name, test2.state === "failed" ? "rejected" : "testing", test2);
48891
- const command = next.command;
48892
- const play2 = test2.state === "passed" ? await playFirst(ctx, release3) : null;
48893
- const ask = test2.state === "waiting" ? askThePerson(test2) : play2;
48894
- const noteAsk = test2.note_ask?.replaceAll("suggest_note", "pch note suggest");
48895
- const feedbackAsk = test2.feedback_ask ? {
48896
- ...test2.feedback_ask,
48897
- ask: test2.feedback_ask.ask.replaceAll("send_feedback", "pch feedback send")
48898
- } : void 0;
48899
- const shown = {
48900
- ...test2,
48901
- ...extra,
48902
- next: command,
48903
- ...noteAsk ? { note_ask: noteAsk } : {},
48904
- ...feedbackAsk ? { feedback_ask: feedbackAsk } : {}
48905
- };
48906
- out.result({ ...shown, ...ask ? { ask } : {} }, () => {
48907
- const bold = import_picocolors21.default.bold(name);
48908
- if (test2.state === "waiting") {
48909
- out.line(
48910
- !test2.needs_person ? `${extra.started ? "Started a test of" : "The test of"} ${bold}. It's waiting for Patch Desktop to take it on.` : extra.started ? `Started a test of ${bold}. It's waiting for you in Patch Desktop: press Start on it there.` : `${bold}: its test is waiting for you in Patch Desktop. Press Start on it there.`
48911
- );
48912
- if (test2.needs_person) {
48913
- out.line(plain(test2.needs_person.why));
48914
- out.line(plain(test2.needs_person.ask));
48915
- }
48916
- } else if (test2.state === "running") {
48917
- out.line(`${bold}: ${whatsHappening(test2)}.`);
48918
- } else if (test2.state === "recorded") {
48919
- out.line(
48920
- `${bold}: it has been played and recorded. Look at the footage, then send your review.`
48921
- );
48922
- } else if (test2.state === "passed") {
48923
- out.line(`${bold}: ${import_picocolors21.default.green("its test passed.")} It's still private.`);
48924
- if (test2.review) out.line(plain(test2.review.summary));
48925
- if (play2) out.line(play2);
48926
- out.line(
48927
- "Publish it once you're happy it's ready: that's your yes, and nothing does it for you."
48928
- );
48929
- } else if (test2.state === "failed") {
48930
- out.line(`${bold}: ${import_picocolors21.default.red("its test failed")}, so this version can't be published.`);
48931
- showWhatFailed(ctx, test2);
48932
- out.line(`pch status ${name} says how to fix it.`);
48933
- out.line("Then move the version on in patch.json and publish that.");
48934
- } else if (test2.state === "couldnt_run") {
48935
- out.line(`${bold}: ${import_picocolors21.default.yellow("its test couldn't run.")}`);
48936
- if (test2.outcome) out.line(plain(test2.outcome.reason));
48937
- } else {
48938
- out.line(`${bold}: its test was cancelled.`);
48939
- }
48940
- if (noteAsk) out.line(plain(noteAsk));
48941
- if (feedbackAsk) out.line(plain(feedbackAsk.ask));
48942
- if (followed && GOING.has(test2.state)) {
48943
- out.line("It isn't over, and nothing's wrong: that's as long as --wait follows it here.");
48944
- }
48945
- if (command) out.line(`Next: ${command}`);
48946
- if (OPEN.has(test2.state) && !extra.started) {
48947
- out.line(import_picocolors21.default.dim(`To start over: pch test cancel ${name}, then pch test start ${name}`));
48948
- }
48949
- });
48950
- if (test2.state === "failed") return EXIT.blocked;
48951
- if (test2.state === "couldnt_run" || test2.state === "cancelled") return EXIT.error;
48952
- return EXIT.ok;
48953
- }
48954
- function askThePerson(test2) {
48955
- return test2.needs_person?.ask ?? null;
48956
- }
48957
- function showWhatFailed({ out }, test2) {
48958
- for (const run3 of test2.results) {
48959
- for (const check3 of run3.checks) {
48960
- if (check3.result !== "fail") continue;
48961
- const detail = check3.detail ? ` (${plain(check3.detail)})` : "";
48962
- out.line(` Run ${run3.run}: ${plain(check3.name)}${detail}`);
48963
- }
48964
- }
48965
- if (test2.review) out.line(` Its review: ${plain(test2.review.summary)}`);
48966
- }
48967
- async function testFootage(ctx, ref, options) {
48968
- requireToken(ctx.target);
48969
- const { out } = ctx;
48970
- const release3 = parseReleaseRef(ref);
48971
- const name = releaseName(release3);
48972
- const run3 = options.run === void 0 ? void 0 : positive(options.run, "--run");
48973
- const test2 = await newest(ctx, release3);
48974
- const { data } = await unwrap(
48975
- ctx.api.GET("/tests/{id}/footage", { params: { path: { id: test2.id }, query: { run: run3 } } })
48976
- );
48977
- const sheets = data.length ? await saveSheets(
48978
- data,
48979
- options.out ? resolve15(options.out) : await mkdtemp3(join18(tmpdir3(), "pch-footage-"))
48980
- ) : [];
48981
- const next = data.length ? reviewCommand(name) : nextStep(name, "testing", test2).command;
48982
- out.result({ test: test2.id, state: test2.state, data: sheets, next }, () => {
48983
- if (!sheets.length) {
48984
- out.line(`${import_picocolors21.default.bold(name)} has no footage yet: its test is ${test2.state.replace("_", " ")}.`);
48985
- for (const result of test2.results) {
48986
- if (result.footage_error) out.line(plain(result.footage_error));
48987
- }
48988
- }
48989
- for (const sheet of sheets) {
48990
- out.line(`Run ${sheet.run}, ${sheet.from} to ${sheet.to}: ${sheet.path}`);
48991
- }
48992
- if (next) {
48993
- if (sheets.length) out.line("Look at every one, then send your review, with what you saw:");
48994
- out.line(`Next: ${next}`);
48995
- }
48996
- });
48997
- }
48998
- var reviewCommand = (name) => `pch test review ${name} --verdict <pass|fail> --plays <yes|no> --harmful <yes|no> --summary "<what you saw>" --moment "<run> <mm:ss> <what you saw>" --moment "<run> <mm:ss> <what you saw>"`;
48999
- async function saveSheets(sheets, folder) {
49000
- await mkdir9(folder, { recursive: true });
49001
- const saved = [];
49002
- for (const sheet of sheets) {
49003
- const answer = await fetch(sheet.url).catch(() => null);
49004
- const bytes = answer?.ok ? Buffer.from(await answer.arrayBuffer()) : null;
49005
- if (!bytes || createHash3("sha256").update(bytes).digest("hex") !== sheet.sha256) {
49006
- throw new CliError(
49007
- EXIT.error,
49008
- "download_failed",
49009
- `Run ${sheet.run}'s sheet ${sheet.index} didn't download intact. Try again.`
49010
- );
49011
- }
49012
- const path2 = join18(folder, `run-${sheet.run}-sheet-${sheet.index}.jpg`);
49013
- await writeFile12(path2, bytes);
49014
- const { url: _url2, ...kept } = sheet;
49015
- saved.push({ ...kept, path: path2 });
49016
- }
49017
- return saved;
49018
- }
49019
- async function testReview(ctx, ref, options) {
49020
- requireToken(ctx.target);
49021
- const release3 = parseReleaseRef(ref);
49022
- const moments = options.moment.map(parseMoment);
49023
- const test2 = await newest(ctx, release3);
49024
- const answer = await ctx.api.POST("/tests/{id}/review", {
49025
- params: { path: { id: test2.id } },
49026
- body: {
49027
- verdict: options.verdict,
49028
- plays: options.plays === "yes",
49029
- harmful: options.harmful === "yes",
49030
- summary: options.summary,
49031
- moments
49032
- }
49033
- });
49034
- if (answer.response.ok) return show3(ctx, release3, answer.data);
49035
- throw withNext(fromResponse(answer.response, answer.error), release3, test2);
49036
- }
49037
- function parseMoment(text) {
49038
- const match = /^(\d+)\s+(\d{1,3}:\d{2})\s+(\S.*)$/s.exec(text.trim());
49039
- if (!match) {
49040
- throw new CliError(
49041
- EXIT.usage,
49042
- "bad_moment",
49043
- `"${text}" isn't a moment. Write its run, the time on a frame and what you saw, such as "1 00:04 the title screen draws".`
49044
- );
49045
- }
49046
- return { run: Number(match[1]), at: match[2], saw: match[3] };
49047
- }
49048
- async function testCancel(ctx, ref) {
49049
- requireToken(ctx.target);
49050
- const release3 = parseReleaseRef(ref);
49051
- const name = releaseName(release3);
49052
- const test2 = await newest(ctx, release3);
49053
- const answer = await ctx.api.POST("/tests/{id}/cancel", { params: { path: { id: test2.id } } });
49054
- if (!answer.response.ok) {
49055
- throw withNext(fromResponse(answer.response, answer.error), release3, test2);
49056
- }
49057
- const next = `pch test start ${name}`;
49058
- ctx.out.result({ ...answer.data, next }, () => {
49059
- ctx.out.line(`Cancelled ${import_picocolors21.default.bold(name)}'s test. Start another: ${next}`);
49060
- });
49061
- }
49062
- function withNext(failure2, release3, test2) {
49063
- if (failure2.code !== "test_state") return failure2;
49064
- const next = nextStep(releaseName(release3), "testing", test2);
49065
- return new CliError(failure2.exitCode, failure2.code, `${failure2.message}. ${sayNext(next)}`, {
49066
- next: next.command
49067
- });
49068
- }
49069
- function positive(value, flag) {
49070
- if (/^[1-9]\d*$/.test(value)) return Number(value);
49071
- throw new CliError(EXIT.usage, "bad_usage", `${flag} takes a whole number, not "${value}".`);
49072
- }
49073
- async function testAutostart(ctx, state) {
49074
- requireToken(ctx.target);
49075
- if (state !== "on" && state !== "off") {
49076
- throw new CliError(EXIT.usage, "bad_value", `Say on or off, not "${state}"`);
49077
- }
49078
- const setting = await unwrap(
49079
- ctx.api.PUT("/me/test-settings", { body: { starts_own_tests: state === "on", by: "agent" } })
49080
- );
49081
- ctx.out.result(setting, () => {
49082
- ctx.out.line(
49083
- setting.starts_own_tests ? "Your tests of browser games and blocks now start in Patch Desktop by themselves, with a notice each time that lets the person stop them. Patch Desktop tells them you turned it on. A mod's test still waits for Start." : "Your tests wait for the person to press Start in Patch Desktop again."
49084
- );
49085
- });
49086
- }
49087
-
49088
49255
  // src/commands/search.ts
49089
- var import_picocolors22 = __toESM(require_picocolors(), 1);
49256
+ var import_picocolors23 = __toESM(require_picocolors(), 1);
49090
49257
  init_guide_words();
49091
49258
  var filter = (values) => values?.flatMap((value) => value.split(",")).map((value) => value.trim()).filter(Boolean).join(",") || void 0;
49092
49259
  var licences = (values) => values.flatMap((value) => value.split(",")).map((value) => licenceId(value.trim()) ?? value.trim());
@@ -49124,13 +49291,13 @@ async function search(ctx, q, options) {
49124
49291
  out.result({ ...answer, ...aboutBlocks }, () => {
49125
49292
  if (!data.length) out.line("Nothing found.");
49126
49293
  for (const result of plain(data)) {
49127
- out.line(`${import_picocolors22.default.bold(result.handle)} ${import_picocolors22.default.dim(facts(result))}`);
49294
+ out.line(`${import_picocolors23.default.bold(result.handle)} ${import_picocolors23.default.dim(facts(result))}`);
49128
49295
  if (result.summary) out.line(` ${result.summary}`);
49129
49296
  if (result.demo) out.line(` Live demo: ${ctx.target.origin}/${result.owner}/${result.slug}`);
49130
- out.line(import_picocolors22.default.dim(` ${result.clone_url}`));
49297
+ out.line(import_picocolors23.default.dim(` ${result.clone_url}`));
49131
49298
  }
49132
49299
  if (blocks) out.line(BLOCK_WORDS);
49133
- if (cursor) out.line(import_picocolors22.default.dim("There are more; raise --limit."));
49300
+ if (cursor) out.line(import_picocolors23.default.dim("There are more; raise --limit."));
49134
49301
  });
49135
49302
  }
49136
49303
  function facts(result) {
@@ -49155,21 +49322,21 @@ async function categories(ctx, options) {
49155
49322
  const width = Math.max(0, ...data.map((category) => category.id.length));
49156
49323
  for (const category of data) {
49157
49324
  ctx.out.line(
49158
- `${import_picocolors22.default.bold(category.id.padEnd(width))} ${category.name} ${import_picocolors22.default.dim(`\xB7 ${category.for.join(", ")} \xB7 ${category.projects === 1 ? "1 project" : `${category.projects} projects`}`)}`
49325
+ `${import_picocolors23.default.bold(category.id.padEnd(width))} ${category.name} ${import_picocolors23.default.dim(`\xB7 ${category.for.join(", ")} \xB7 ${category.projects === 1 ? "1 project" : `${category.projects} projects`}`)}`
49159
49326
  );
49160
49327
  }
49161
49328
  });
49162
49329
  }
49163
49330
 
49164
49331
  // src/commands/update.ts
49165
- var import_picocolors23 = __toESM(require_picocolors(), 1);
49332
+ var import_picocolors24 = __toESM(require_picocolors(), 1);
49166
49333
  var import_semver3 = __toESM(require_semver2(), 1);
49167
49334
  import { spawnSync as spawnSync3 } from "node:child_process";
49168
49335
  import { createHash as createHash4 } from "node:crypto";
49169
49336
  import { mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
49170
49337
  import { chmod as chmod3, rename as rename2, rm as rm3, writeFile as writeFile13 } from "node:fs/promises";
49171
- import { basename as basename8, dirname as dirname7, join as join19 } from "node:path";
49172
- import { setTimeout as sleep10 } from "node:timers/promises";
49338
+ import { basename as basename8, dirname as dirname7, join as join20 } from "node:path";
49339
+ import { setTimeout as sleep11 } from "node:timers/promises";
49173
49340
  import { gunzipSync } from "node:zlib";
49174
49341
  init_errors();
49175
49342
  init_version();
@@ -49312,7 +49479,7 @@ async function whenFree(step, wait2 = 200) {
49312
49479
  } catch (error62) {
49313
49480
  const code = error62.code ?? "";
49314
49481
  if (attempt >= 10 || !["EBUSY", "EPERM", "EACCES"].includes(code)) throw error62;
49315
- await sleep10(attempt * wait2);
49482
+ await sleep11(attempt * wait2);
49316
49483
  }
49317
49484
  }
49318
49485
  }
@@ -49322,7 +49489,7 @@ function removeOldExecutables(target = process.execPath) {
49322
49489
  const old = new RegExp(`^${basename8(target).replace(/\./g, "\\.")}\\.\\d+\\.old$`, "i");
49323
49490
  try {
49324
49491
  for (const name of readdirSync(folder)) {
49325
- if (old.test(name)) rmSync(join19(folder, name), { force: true });
49492
+ if (old.test(name)) rmSync(join20(folder, name), { force: true });
49326
49493
  }
49327
49494
  } catch {
49328
49495
  }
@@ -49332,7 +49499,7 @@ function startUpdateNotice(ctx, command, now = Date.now()) {
49332
49499
  const releases = releasesUrl(ctx.target.env);
49333
49500
  if (!how || !releases || ctx.out.json || QUIET.has(command)) return null;
49334
49501
  if (!process.stderr.isTTY || process.env.CI) return null;
49335
- const stamp = join19(dirname7(configPath()), "update-check.json");
49502
+ const stamp = join20(dirname7(configPath()), "update-check.json");
49336
49503
  try {
49337
49504
  const { checked_at } = JSON.parse(readFileSync(stamp, "utf8"));
49338
49505
  if (checked_at && now - Date.parse(checked_at) < DAY) return null;
@@ -49361,7 +49528,7 @@ function startUpdateNotice(ctx, command, now = Date.now()) {
49361
49528
  if (version2 && import_semver3.default.gt(version2, VERSION)) {
49362
49529
  process.stderr.write(
49363
49530
  `
49364
- ${import_picocolors23.default.dim("\u203A")} pch ${version2} is out (you have ${VERSION}). To update, run: ${how}
49531
+ ${import_picocolors24.default.dim("\u203A")} pch ${version2} is out (you have ${VERSION}). To update, run: ${how}
49365
49532
  `
49366
49533
  );
49367
49534
  }
@@ -49370,7 +49537,7 @@ ${import_picocolors23.default.dim("\u203A")} pch ${version2} is out (you have ${
49370
49537
  }
49371
49538
 
49372
49539
  // src/commands/validate.ts
49373
- var import_picocolors24 = __toESM(require_picocolors(), 1);
49540
+ var import_picocolors25 = __toESM(require_picocolors(), 1);
49374
49541
  init_errors();
49375
49542
  async function validate2(ctx, dir) {
49376
49543
  const project2 = await loadProject(dir);
@@ -49429,12 +49596,12 @@ async function validate2(ctx, dir) {
49429
49596
  notes
49430
49597
  };
49431
49598
  ctx.out.result(result, () => {
49432
- const mark = result.ok ? import_picocolors24.default.green("\u2713") : import_picocolors24.default.red("\u2717");
49599
+ const mark = result.ok ? import_picocolors25.default.green("\u2713") : import_picocolors25.default.red("\u2717");
49433
49600
  ctx.out.line(
49434
49601
  `${mark} ${file2.project}@${file2.version} \xB7 ${project2.kind} \xB7 ${result.trust_tier} \xB7 ${project2.files.length} files, ${formatSize(total)}`
49435
49602
  );
49436
- for (const issue2 of issues) ctx.out.line(` ${import_picocolors24.default.red(issue2.path || "\xB7")} ${issue2.message}`);
49437
- for (const note2 of notes) ctx.out.line(` ${import_picocolors24.default.dim(note2)}`);
49603
+ for (const issue2 of issues) ctx.out.line(` ${import_picocolors25.default.red(issue2.path || "\xB7")} ${issue2.message}`);
49604
+ for (const note2 of notes) ctx.out.line(` ${import_picocolors25.default.dim(note2)}`);
49438
49605
  });
49439
49606
  return result.ok ? EXIT.ok : EXIT.usage;
49440
49607
  }
@@ -49469,8 +49636,7 @@ Start here:
49469
49636
  pch search "grappling hook" find a block or game to start from, then pch remix or pch clone it, and change it
49470
49637
  pch assets search "crate" --type model find a model, sound or texture, then pch assets add it
49471
49638
  pch new <you>/<game> --licence MIT make a repo with a patch.json, or pch init in a folder you have
49472
- pch validate, then pch publish
49473
- pch test start <you>/<game>@<version> a game, mod or block goes live only after passing a test in Patch Desktop
49639
+ pch validate, then pch publish upload, test on your PC, your review of the footage, then live
49474
49640
  pch media add, pch project update the cover and the listing
49475
49641
 
49476
49642
  Add --json to any command for results an agent can read. Exit codes: 0 fine, 1 error, 2 bad usage,
@@ -49513,7 +49679,7 @@ function topLevel(command) {
49513
49679
  }
49514
49680
  var collect = (value, previous = []) => [...previous, value];
49515
49681
  program2.command("login").description(
49516
- "Sign in through the browser (device code), and sign git in with it. It waits for a person, which can take a few minutes, so don't run it under a short timeout"
49682
+ "Sign in through the browser (device code), and sign git in with it. It waits for a person, which can take a few minutes, so don't run it under a short timeout. Agents: run it yourself, in the background, and give the person the link it prints, which has the code in it"
49517
49683
  ).option("--scope <scope>", "API key scope: read, publish, admin (repeatable)", collect).option("--no-browser", "print the link instead of opening a browser").action(action((ctx, options) => login(ctx, options)));
49518
49684
  program2.command("logout").description("Revoke and forget the active login, and sign git out").action(action((ctx) => logout(ctx)));
49519
49685
  program2.command("whoami").description("Who you're signed in as, and where").action(action((ctx) => whoami(ctx)));
@@ -49724,17 +49890,29 @@ program2.command("check [dir]").description(
49724
49890
  (ctx, dir, options) => check2(ctx, dir ?? ".", options)
49725
49891
  )
49726
49892
  );
49727
- program2.command("publish [dir]").description(
49728
- "Hash, upload missing files and submit a release. A browser game or a mod Patch installs stays a private draft until pch test has passed it. If it stops partway, run it again: what's up already isn't sent again"
49893
+ program2.command("publish [target]").description(
49894
+ "Publish from upload to live: uploads what Patch doesn't have, tests it on your PC, saves the footage for your review, then follows Patch's review until it's live. Running it is your yes. Give owner/slug@version instead of a folder to carry a release on, with --review once you've looked at its footage. Agents: run it yourself, wherever you run (WSL, Linux, a Mac or a server). The person's Patch Desktop takes the test through Patch, and pch says if they need to open it or press Start"
49729
49895
  ).addOption(
49730
49896
  new Option("--channel <channel>", `overrides patch.json. ${NOT_LOCKED_HELP}`).choices([
49731
49897
  "private",
49732
49898
  "unlisted",
49733
49899
  "public"
49734
49900
  ])
49735
- ).option("--changelog <text>", "what changed, shown as the update notes").action(
49901
+ ).option("--changelog <text>", "what changed, shown as the update notes").addOption(
49902
+ new Option("--review <verdict>", "your review of the footage: pass or fail").choices([
49903
+ "pass",
49904
+ "fail"
49905
+ ])
49906
+ ).option("--summary <text>", "with --review: what you saw, in a few lines").option(
49907
+ "--moment <moment>",
49908
+ 'with --review: "<run> <mm:ss> <what you saw>", at least two, one per run (repeatable)',
49909
+ collect
49910
+ ).option("--harmful", "with --review: it does something harmful (fails it)").option("--test-again", "test it again after a test that couldn't run").option("--out <dir>", "where to save the footage").option(
49911
+ "--wait <seconds>",
49912
+ "how long to follow it (default: until it stops, at a terminal; 100 s otherwise)"
49913
+ ).action(
49736
49914
  action(
49737
- (ctx, dir, options) => publish(ctx, dir ?? ".", options)
49915
+ (ctx, target, options) => publish(ctx, target ?? ".", options)
49738
49916
  )
49739
49917
  );
49740
49918
  var project = program2.command("project").description("Projects, addressed owner/slug");