@playcraft/cli 0.0.57 → 0.0.59

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 (225) hide show
  1. package/README.md +34 -16
  2. package/dist/cli-root-help.js +1 -0
  3. package/dist/commands/build-all.js +10 -9
  4. package/dist/commands/build.js +13 -12
  5. package/dist/commands/create.js +26 -33
  6. package/dist/commands/platform-skills.generated.js +19 -0
  7. package/dist/commands/skills.js +2 -0
  8. package/dist/commands/tools-generation.js +1 -1
  9. package/dist/commands/workspace-runtime.js +54 -0
  10. package/dist/index.js +3 -1
  11. package/dist/project-skills/commands.js +72 -0
  12. package/dist/project-skills/lifecycle.js +28 -0
  13. package/dist/project-skills/local-run.js +112 -0
  14. package/dist/project-skills/local-store.js +149 -0
  15. package/dist/project-skills/messages.js +72 -0
  16. package/dist/project-skills/reconcile.js +62 -0
  17. package/dist/project-skills/remote-cache.js +51 -0
  18. package/dist/project-skills/validation.js +19 -0
  19. package/dist/remix/clone.js +2 -0
  20. package/dist/remix/init-template.js +2 -0
  21. package/dist/remix/pull.js +2 -0
  22. package/dist/remix/push.js +21 -1
  23. package/dist/utils/agent-api-client.js +54 -17
  24. package/dist/utils/tool-operation-journal.js +54 -0
  25. package/dist/workspace-runtime/codex/app-server.js +395 -0
  26. package/dist/workspace-runtime/codex/config-toml.js +25 -0
  27. package/dist/workspace-runtime/codex/jsonrpc-stdio.js +106 -0
  28. package/dist/workspace-runtime/codex/loopback.js +59 -0
  29. package/dist/workspace-runtime/codex/native-adapter.js +3 -0
  30. package/dist/workspace-runtime/main.js +10 -0
  31. package/dist/workspace-runtime/persistence/journal.js +350 -0
  32. package/dist/workspace-runtime/processes/managed-writes.js +69 -0
  33. package/dist/workspace-runtime/serve.js +111 -0
  34. package/dist/workspace-runtime/server/auth.js +21 -0
  35. package/dist/workspace-runtime/server/dispatch.js +861 -0
  36. package/dist/workspace-runtime/server/execution-group.js +88 -0
  37. package/dist/workspace-runtime/server/http.js +561 -0
  38. package/dist/workspace-runtime/server/lock.js +53 -0
  39. package/dist/workspace-runtime/server/types.js +1 -0
  40. package/dist/workspace-runtime/workspaces/context-error.js +2 -0
  41. package/dist/workspace-runtime/workspaces/file-snapshot.js +213 -0
  42. package/dist/workspace-runtime/workspaces/files.js +143 -0
  43. package/dist/workspace-runtime/workspaces/git.js +242 -0
  44. package/dist/workspace-runtime/workspaces/json5-edit.js +170 -0
  45. package/dist/workspace-runtime/workspaces/parameters.js +272 -0
  46. package/dist/workspace-runtime/workspaces/prepare.js +364 -0
  47. package/dist/workspace-runtime/workspaces/registry.js +107 -0
  48. package/dist/workspace-runtime/workspaces/revisions.js +31 -0
  49. package/package.json +6 -3
  50. package/project-template-v2/.claude/agents/artist.md +82 -0
  51. package/project-template-v2/.claude/agents/developer.md +153 -0
  52. package/project-template-v2/.claude/agents/game-designer.md +264 -0
  53. package/project-template-v2/.claude/agents/refs/artist-art-style-catalog.md +533 -0
  54. package/project-template-v2/.claude/agents/refs/artist-color-audio-recipes.md +153 -0
  55. package/project-template-v2/.claude/agents/refs/artist-dimension-axis.md +27 -0
  56. package/project-template-v2/.claude/agents/refs/artist-master-composite-recipes.md +208 -0
  57. package/project-template-v2/.claude/agents/refs/atom-skill-library.md +81 -0
  58. package/project-template-v2/.claude/agents/refs/developer-impl-cookbook.md +432 -0
  59. package/project-template-v2/.claude/agents/refs/framework-5-component-filter.md +252 -0
  60. package/project-template-v2/.claude/agents/refs/framework-game-feel-juice.md +266 -0
  61. package/project-template-v2/.claude/agents/refs/framework-mda.md +147 -0
  62. package/project-template-v2/.claude/agents/refs/game-designer-gameplay-sufficiency.md +123 -0
  63. package/project-template-v2/.claude/agents/refs/ta-3d-flip-recipe.md +88 -0
  64. package/project-template-v2/.claude/agents/refs/ta-atlas-deliverable-standard.md +67 -0
  65. package/project-template-v2/.claude/agents/refs/ta-batch-pipeline-recipes.md +120 -0
  66. package/project-template-v2/.claude/agents/refs/ta-image-generation-detail.md +300 -0
  67. package/project-template-v2/.claude/agents/refs/ta-image-ops-reference.md +495 -0
  68. package/project-template-v2/.claude/agents/refs/ta-pipeline-cookbook.md +1141 -0
  69. package/project-template-v2/.claude/agents/refs/ta-tools-reference.md +111 -0
  70. package/project-template-v2/.claude/agents/refs/ta-vfx-preset-catalog.md +365 -0
  71. package/project-template-v2/.claude/agents/refs/threejs-cannon-pitfalls.md +412 -0
  72. package/project-template-v2/.claude/agents/reviewer.md +75 -0
  73. package/project-template-v2/.claude/agents/technical-artist.md +86 -0
  74. package/project-template-v2/.claude/hooks/snapshot-milestone.mjs +243 -0
  75. package/project-template-v2/.claude/hooks/user-prompt.mjs +133 -0
  76. package/project-template-v2/.claude/settings.json +33 -0
  77. package/project-template-v2/.claude/skills/brainstorming/SKILL.md +161 -0
  78. package/project-template-v2/.claude/skills/brainstorming/scripts/frame-template.html +270 -0
  79. package/project-template-v2/.claude/skills/brainstorming/scripts/helper.js +177 -0
  80. package/project-template-v2/.claude/skills/brainstorming/scripts/server.cjs +354 -0
  81. package/project-template-v2/.claude/skills/brainstorming/scripts/start-server.sh +148 -0
  82. package/project-template-v2/.claude/skills/brainstorming/scripts/stop-server.sh +56 -0
  83. package/project-template-v2/.claude/skills/brainstorming/scripts/wait-for-selection.sh +62 -0
  84. package/project-template-v2/.claude/skills/brainstorming/spec-document-reviewer-prompt.md +49 -0
  85. package/project-template-v2/.claude/skills/brainstorming/visual-companion.md +309 -0
  86. package/project-template-v2/.claude/skills/playcraft-ad-psychology/SKILL.md +182 -0
  87. package/project-template-v2/.claude/skills/playcraft-art-style-guide/SKILL.md +123 -0
  88. package/project-template-v2/.claude/skills/playcraft-asset-state-sheet/SKILL.md +205 -0
  89. package/project-template-v2/.claude/skills/playcraft-audio-generation/SKILL.md +280 -0
  90. package/project-template-v2/.claude/skills/playcraft-batch-pipeline/SKILL.md +184 -0
  91. package/project-template-v2/.claude/skills/playcraft-build-optimizer/SKILL.md +306 -0
  92. package/project-template-v2/.claude/skills/playcraft-image-generation/SKILL.md +298 -0
  93. package/project-template-v2/.claude/skills/playcraft-image-generation/reference/build-sprite-sheet.template.mjs +123 -0
  94. package/project-template-v2/.claude/skills/playcraft-image-generation/reference/compare-style.template.mjs +254 -0
  95. package/project-template-v2/.claude/skills/playcraft-image-generation/reference/gen-batch-sprite.template.mjs +324 -0
  96. package/project-template-v2/.claude/skills/playcraft-image-generation/reference/gen-batch.template.mjs +97 -0
  97. package/project-template-v2/.claude/skills/playcraft-image-generation/reference/gen-edit-variants.template.mjs +118 -0
  98. package/project-template-v2/.claude/skills/playcraft-image-generation/reference/process-batch.template.mjs +137 -0
  99. package/project-template-v2/.claude/skills/playcraft-image-generation/reference/prompt-cookbook.md +397 -0
  100. package/project-template-v2/.claude/skills/playcraft-image-generation/reference/validate-sprite-sheet.template.mjs +296 -0
  101. package/project-template-v2/.claude/skills/playcraft-image-ops/SKILL.md +122 -0
  102. package/project-template-v2/.claude/skills/playcraft-image-processing/SKILL.md +219 -0
  103. package/project-template-v2/.claude/skills/playcraft-masking/SKILL.md +373 -0
  104. package/project-template-v2/.claude/skills/playcraft-playable-optimization/SKILL.md +161 -0
  105. package/project-template-v2/.claude/skills/playcraft-research/SKILL.md +215 -0
  106. package/project-template-v2/.claude/skills/playcraft-skill-recommender/SKILL.md +382 -0
  107. package/project-template-v2/.claude/skills/playcraft-sprite-generation/SKILL.md +423 -0
  108. package/project-template-v2/.claude/skills/playcraft-sprite-remix/SKILL.md +158 -0
  109. package/project-template-v2/.claude/skills/playcraft-sprite-sheet/SKILL.md +100 -0
  110. package/project-template-v2/.claude/skills/playcraft-storyboard/SKILL.md +167 -0
  111. package/project-template-v2/.claude/skills/playcraft-style-qa/SKILL.md +270 -0
  112. package/project-template-v2/.claude/skills/playcraft-text-rendering/SKILL.md +236 -0
  113. package/project-template-v2/.claude/skills/playcraft-vfx-animation/SKILL.md +130 -0
  114. package/project-template-v2/.claude/skills/playwright-cli/SKILL.md +390 -0
  115. package/project-template-v2/.claude/skills/playwright-cli/references/element-attributes.md +23 -0
  116. package/project-template-v2/.claude/skills/playwright-cli/references/playwright-tests.md +39 -0
  117. package/project-template-v2/.claude/skills/playwright-cli/references/request-mocking.md +87 -0
  118. package/project-template-v2/.claude/skills/playwright-cli/references/running-code.md +240 -0
  119. package/project-template-v2/.claude/skills/playwright-cli/references/session-management.md +226 -0
  120. package/project-template-v2/.claude/skills/playwright-cli/references/spec-driven-testing.md +308 -0
  121. package/project-template-v2/.claude/skills/playwright-cli/references/storage-state.md +275 -0
  122. package/project-template-v2/.claude/skills/playwright-cli/references/test-generation.md +134 -0
  123. package/project-template-v2/.claude/skills/playwright-cli/references/tracing.md +142 -0
  124. package/project-template-v2/.claude/skills/playwright-cli/references/video-recording.md +153 -0
  125. package/project-template-v2/.claude/skills/session-analyzer/SKILL.md +386 -0
  126. package/project-template-v2/.claude/skills/session-analyzer/scripts/execution-breakdown.mjs +182 -0
  127. package/project-template-v2/.claude/skills/session-analyzer/scripts/find-turns.mjs +72 -0
  128. package/project-template-v2/.claude/skills/session-analyzer/scripts/heavy-output.mjs +121 -0
  129. package/project-template-v2/.claude/skills/session-analyzer/scripts/resolve-session.mjs +102 -0
  130. package/project-template-v2/.claude/skills/session-analyzer/scripts/subagent-stats.mjs +127 -0
  131. package/project-template-v2/.claude/skills/session-analyzer/scripts/subagent-tool-timeline.mjs +106 -0
  132. package/project-template-v2/.claude/skills/session-analyzer/scripts/time-gaps.mjs +128 -0
  133. package/project-template-v2/.claude/skills/session-analyzer/scripts/turn-timeline.mjs +67 -0
  134. package/project-template-v2/.claude/snapshot.mjs +263 -0
  135. package/project-template-v2/.playcraft/skills.lock.json +152 -0
  136. package/project-template-v2/CLAUDE.md +146 -0
  137. package/project-template-v2/assets/audio/bgm/.gitkeep +0 -0
  138. package/project-template-v2/assets/audio/sfx/.gitkeep +0 -0
  139. package/project-template-v2/assets/bundles/.gitkeep +0 -0
  140. package/project-template-v2/assets/images/bg/.gitkeep +0 -0
  141. package/project-template-v2/assets/images/reference/.gitkeep +0 -0
  142. package/project-template-v2/assets/images/storyboard/.gitkeep +0 -0
  143. package/project-template-v2/assets/images/tiles/.gitkeep +0 -0
  144. package/project-template-v2/assets/images/ui/.gitkeep +0 -0
  145. package/project-template-v2/assets/images/vfx/.gitkeep +0 -0
  146. package/project-template-v2/assets/models/.gitkeep +0 -0
  147. package/project-template-v2/docs/harness/iteration-1/context-flow.md +254 -0
  148. package/project-template-v2/docs/harness/iteration-1/generate-flow.md +91 -0
  149. package/project-template-v2/docs/harness/iteration-1/ideate-flow.md +214 -0
  150. package/project-template-v2/docs/harness/iteration-1/optimize-flow.md +75 -0
  151. package/project-template-v2/docs/harness/iteration-1/wrapup-flow.md +63 -0
  152. package/project-template-v2/docs/harness/iteration-2/context-flow.md +223 -0
  153. package/project-template-v2/docs/harness/iteration-2/generate-flow.md +129 -0
  154. package/project-template-v2/docs/harness/iteration-2/ideate-flow.md +267 -0
  155. package/project-template-v2/docs/harness/iteration-2/optimize-flow.md +164 -0
  156. package/project-template-v2/docs/harness/iteration-2/wrapup-flow.md +115 -0
  157. package/project-template-v2/docs/harness/orchestrator-flow.md +364 -0
  158. package/project-template-v2/docs/project-state.json +60 -0
  159. package/project-template-v2/docs/project-state.md +72 -0
  160. package/project-template-v2/docs/standards/README.md +225 -0
  161. package/project-template-v2/docs/standards/agent-behavior-standards.md +174 -0
  162. package/project-template-v2/docs/standards/artifacts/design-brief.md +19 -0
  163. package/project-template-v2/docs/standards/artifacts/design.md +22 -0
  164. package/project-template-v2/docs/standards/artifacts/game-code.md +41 -0
  165. package/project-template-v2/docs/standards/artifacts/todo-list.md +41 -0
  166. package/project-template-v2/docs/standards/iter1-agent-behavior-standards.md +343 -0
  167. package/project-template-v2/game/index.ts +18 -0
  168. package/project-template-v2/globals.d.ts +51 -0
  169. package/project-template-v2/index.css +34 -0
  170. package/project-template-v2/index.html +18 -0
  171. package/project-template-v2/main.ts +9 -0
  172. package/project-template-v2/package.json +46 -0
  173. package/project-template-v2/skills/_shared/scripts/dispatch-clear.mjs +31 -0
  174. package/project-template-v2/skills/_shared/scripts/dispatch-set.mjs +86 -0
  175. package/project-template-v2/skills/_shared/scripts/dod-check.mjs +153 -0
  176. package/project-template-v2/skills/_shared/scripts/handoff-append.mjs +70 -0
  177. package/project-template-v2/skills/_shared/scripts/lib/validator-artifacts.mjs +91 -0
  178. package/project-template-v2/skills/_shared/scripts/pipeline/dod-config.mjs +131 -0
  179. package/project-template-v2/skills/_shared/scripts/pipeline/index.mjs +80 -0
  180. package/project-template-v2/skills/_shared/scripts/pipeline/iteration-1-core.mjs +90 -0
  181. package/project-template-v2/skills/_shared/scripts/pipeline/iteration-2-wrap.mjs +93 -0
  182. package/project-template-v2/skills/_shared/scripts/pipeline/iteration-3-visual.mjs +23 -0
  183. package/project-template-v2/skills/_shared/scripts/render-project-state.mjs +230 -0
  184. package/project-template-v2/skills/_shared/scripts/state-advance-stage.mjs +58 -0
  185. package/project-template-v2/skills/_shared/scripts/state-get.mjs +339 -0
  186. package/project-template-v2/skills/_shared/scripts/state-handoff.mjs +39 -0
  187. package/project-template-v2/skills/_shared/scripts/state-set.mjs +94 -0
  188. package/project-template-v2/skills/_shared/scripts/state-store.mjs +783 -0
  189. package/project-template-v2/skills/_shared/scripts/todo-add.mjs +88 -0
  190. package/project-template-v2/skills/_shared/scripts/todo-get.mjs +130 -0
  191. package/project-template-v2/skills/_shared/scripts/todo-remove.mjs +33 -0
  192. package/project-template-v2/skills/_shared/scripts/todo-set.mjs +47 -0
  193. package/project-template-v2/skills/_shared/scripts/verify-asset-code-sync.mjs +268 -0
  194. package/project-template-v2/skills/_shared/scripts/verify-env.mjs +285 -0
  195. package/project-template-v2/skills/_shared/scripts/verify-placeholders.mjs +161 -0
  196. package/project-template-v2/skills/playable-autoplay/SKILL.md +176 -0
  197. package/project-template-v2/skills/playable-autoplay/agents/openai.yaml +4 -0
  198. package/project-template-v2/skills/playable-debug/SKILL.md +116 -0
  199. package/project-template-v2/skills/playable-debug/agents/openai.yaml +4 -0
  200. package/project-template-v2/skills/playable-debug/references/debug-config.md +40 -0
  201. package/project-template-v2/skills/playable-record/SKILL.md +140 -0
  202. package/project-template-v2/skills/playable-record/scripts/lib/dev-server.mjs +104 -0
  203. package/project-template-v2/skills/playable-record/scripts/lib/record-audio-bridge.js +141 -0
  204. package/project-template-v2/skills/playable-record/scripts/record-playable.mjs +425 -0
  205. package/project-template-v2/skills/playable-record/scripts/verify-contract.mjs +261 -0
  206. package/project-template-v2/skills/playable-record/scripts/verify-firstwin.mjs +398 -0
  207. package/project-template-v2/skills/playable-record/scripts/verify-fusion.mjs +322 -0
  208. package/project-template-v2/skills/playable-record/scripts/verify-lifecycle.mjs +105 -0
  209. package/project-template-v2/skills/playable-record/scripts/verify-vlm-video.mjs +230 -0
  210. package/project-template-v2/skills/playable-validate/SKILL.md +61 -0
  211. package/project-template-v2/skills/playable-validate/validation-rules.md +14 -0
  212. package/project-template-v2/skills/playable-verify-ui/SKILL.md +63 -0
  213. package/project-template-v2/skills/playable-verify-ui/scripts/lib/__init__.py +1 -0
  214. package/project-template-v2/skills/playable-verify-ui/scripts/lib/config_expr.js +218 -0
  215. package/project-template-v2/skills/playable-verify-ui/scripts/lib/node_utils.js +51 -0
  216. package/project-template-v2/skills/playable-verify-ui/scripts/lib/profile_loader.py +59 -0
  217. package/project-template-v2/skills/playable-verify-ui/scripts/lib/report.py +106 -0
  218. package/project-template-v2/skills/playable-verify-ui/scripts/lib/runtime_contract.js +116 -0
  219. package/project-template-v2/skills/playable-verify-ui/scripts/verify_fetch_antipatterns.py +59 -0
  220. package/project-template-v2/skills/playable-verify-ui/scripts/verify_hardcoded_layout.js +64 -0
  221. package/project-template-v2/skills/playable-verify-ui/scripts/verify_runtime_contract.js +58 -0
  222. package/project-template-v2/skills/playable-verify-ui/scripts/verify_text_overlap.py +333 -0
  223. package/project-template-v2/ta-workspace/scripts/.gitkeep +0 -0
  224. package/project-template-v2/tsconfig.json +20 -0
  225. package/project-template-v2/vite.config.ts +27 -0
@@ -0,0 +1,53 @@
1
+ import { closeSync, constants, mkdirSync, openSync, readFileSync, unlinkSync, writeFileSync } from 'node:fs';
2
+ import { dirname, join } from 'node:path';
3
+ export class RuntimeProcessLock {
4
+ filePath;
5
+ fd = null;
6
+ constructor(stateDir) {
7
+ this.filePath = join(stateDir, 'workspace-runtime.lock');
8
+ }
9
+ acquire() {
10
+ mkdirSync(dirname(this.filePath), { recursive: true, mode: 0o700 });
11
+ try {
12
+ this.fd = openSync(this.filePath, constants.O_CREAT | constants.O_EXCL | constants.O_WRONLY, 0o600);
13
+ writeFileSync(this.fd, String(process.pid));
14
+ return;
15
+ }
16
+ catch (error) {
17
+ if (error.code !== 'EEXIST')
18
+ throw error;
19
+ }
20
+ const existing = Number.parseInt(readFileSync(this.filePath, 'utf8'), 10);
21
+ if (Number.isInteger(existing) && processAlive(existing)) {
22
+ throw Object.assign(new Error(`workspace-runtime already running as pid ${existing}`), {
23
+ code: 'duplicate_instance',
24
+ });
25
+ }
26
+ unlinkSync(this.filePath);
27
+ this.acquire();
28
+ }
29
+ release() {
30
+ if (this.fd !== null) {
31
+ closeSync(this.fd);
32
+ this.fd = null;
33
+ }
34
+ try {
35
+ if (readFileSync(this.filePath, 'utf8').trim() === String(process.pid)) {
36
+ unlinkSync(this.filePath);
37
+ }
38
+ }
39
+ catch (error) {
40
+ if (error.code !== 'ENOENT')
41
+ throw error;
42
+ }
43
+ }
44
+ }
45
+ function processAlive(pid) {
46
+ try {
47
+ process.kill(pid, 0);
48
+ return true;
49
+ }
50
+ catch {
51
+ return false;
52
+ }
53
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,2 @@
1
+ export class WorkspaceContextError extends Error {
2
+ }
@@ -0,0 +1,213 @@
1
+ import { chmod, lstat, mkdir, readdir, readFile, readlink, realpath, rm, symlink, writeFile } from 'node:fs/promises';
2
+ import { dirname, join, relative, resolve, sep } from 'node:path';
3
+ import { fileSnapshotDigest, fileSnapshotManifestKey, fileSnapshotManifestSchema, fileSnapshotObjectKey, hashSnapshotBytes, isDisposableBackupPath, putObjectBytes, workspaceRelativePathSchema, } from '@playcraft/common/cloud-workspace/runtime';
4
+ const SKIP_DIRECTORY_NAMES = new Set(['.git', 'node_modules']);
5
+ const READ_ATTEMPTS = 3;
6
+ const realIo = {
7
+ async readdir(path) {
8
+ return readdir(path, { withFileTypes: true });
9
+ },
10
+ async lstat(path) {
11
+ return lstat(path);
12
+ },
13
+ readFile,
14
+ readlink,
15
+ };
16
+ export async function captureWorkspaceTree(root, previousPaths, revisions, io = realIo) {
17
+ const entries = [];
18
+ const files = [];
19
+ const bodies = new Map();
20
+ const seen = new Set();
21
+ let status = 'complete';
22
+ await walk(root, root, io, revisions, entries, files, bodies, seen, () => {
23
+ status = 'incomplete';
24
+ });
25
+ for (const path of [...previousPaths].sort((left, right) => left.localeCompare(right))) {
26
+ if (seen.has(path) || !workspaceRelativePathSchema.safeParse(path).success)
27
+ continue;
28
+ entries.push({ path, kind: 'deleted' });
29
+ }
30
+ entries.sort((left, right) => left.path.localeCompare(right.path) || left.kind.localeCompare(right.kind));
31
+ return { status, entries, files, bodies };
32
+ }
33
+ export async function materializeSnapshot(snapshotDir, tree) {
34
+ await rm(snapshotDir, { recursive: true, force: true });
35
+ for (const file of tree.files) {
36
+ const body = tree.bodies.get(file.path);
37
+ if (!body || hashSnapshotBytes(body) !== file.contentHash)
38
+ throw new Error('snapshot_body_missing');
39
+ const target = join(snapshotDir, ...file.path.split('/'));
40
+ await mkdir(dirname(target), { recursive: true, mode: 0o700 });
41
+ await writeFile(target, body, { mode: 0o600 });
42
+ }
43
+ }
44
+ export async function materializeRemoteSnapshot(root, entries) {
45
+ await mkdir(root, { recursive: true, mode: 0o700 });
46
+ const rootReal = await realpath(root);
47
+ const skipped = [];
48
+ const applicable = [];
49
+ for (const entry of entries) {
50
+ if (isDisposableBackupPath(entry.path)) {
51
+ skipped.push(entry.path);
52
+ continue;
53
+ }
54
+ applicable.push(entry);
55
+ }
56
+ for (const entry of applicable) {
57
+ if (entry.kind !== 'file')
58
+ continue;
59
+ if (!entry.contentBase64 || !entry.contentHash)
60
+ throw new Error('snapshot_mismatch');
61
+ const body = Buffer.from(entry.contentBase64, 'base64');
62
+ if (hashSnapshotBytes(body) !== entry.contentHash)
63
+ throw new Error('snapshot_mismatch');
64
+ }
65
+ for (const entry of applicable) {
66
+ const target = resolve(rootReal, ...entry.path.split('/'));
67
+ const rel = relative(rootReal, target);
68
+ if (rel === '' || rel.startsWith(`..${sep}`) || rel === '..' || rel.startsWith('..')) {
69
+ throw new Error('snapshot_mismatch');
70
+ }
71
+ if (entry.kind === 'deleted') {
72
+ await rm(target, { force: true });
73
+ continue;
74
+ }
75
+ await mkdir(dirname(target), { recursive: true, mode: 0o700 });
76
+ if (entry.kind === 'symlink') {
77
+ if (!entry.target)
78
+ throw new Error('snapshot_mismatch');
79
+ await rm(target, { force: true });
80
+ await symlink(entry.target, target);
81
+ continue;
82
+ }
83
+ const body = Buffer.from(entry.contentBase64 ?? '', 'base64');
84
+ await writeFile(target, body, { mode: entry.mode ?? 0o600 });
85
+ if (entry.mode !== undefined)
86
+ await chmod(target, entry.mode);
87
+ }
88
+ let repositoryReady = false;
89
+ try {
90
+ const git = await lstat(join(rootReal, '.git'));
91
+ repositoryReady = git.isDirectory() && !git.isSymbolicLink();
92
+ }
93
+ catch {
94
+ repositoryReady = false;
95
+ }
96
+ return {
97
+ skipped,
98
+ directoryReady: true,
99
+ repositoryReady,
100
+ environmentReady: false,
101
+ };
102
+ }
103
+ export async function publishFileSnapshot(input) {
104
+ for (const entry of input.tree.entries) {
105
+ if (entry.kind !== 'file' || !entry.contentHash)
106
+ continue;
107
+ const body = input.tree.bodies.get(entry.path);
108
+ if (!body || hashSnapshotBytes(body) !== entry.contentHash)
109
+ throw new Error('snapshot_body_missing');
110
+ await putObjectBytes(input.baseUrl, fileSnapshotObjectKey(input.workspaceId, input.generation, input.snapshotId, entry.contentHash), body, 'cli');
111
+ }
112
+ const digest = fileSnapshotDigest(input.tree.entries);
113
+ const manifest = fileSnapshotManifestSchema.parse({
114
+ snapshotId: input.snapshotId,
115
+ workspaceId: input.workspaceId,
116
+ generation: input.generation,
117
+ status: input.tree.status,
118
+ entries: input.tree.entries,
119
+ digest,
120
+ });
121
+ const manifestKey = fileSnapshotManifestKey(input.workspaceId, input.generation, input.snapshotId);
122
+ await putObjectBytes(input.baseUrl, manifestKey, Buffer.from(JSON.stringify(manifest)), 'cli');
123
+ return { manifestKey, checksum: digest, status: input.tree.status };
124
+ }
125
+ async function walk(rootReal, current, io, revisions, entries, files, bodies, seen, markIncomplete) {
126
+ const listed = await io.readdir(current);
127
+ for (const entry of listed) {
128
+ if (entry.name === '.' || entry.name === '..' || SKIP_DIRECTORY_NAMES.has(entry.name))
129
+ continue;
130
+ const child = join(current, entry.name);
131
+ const relativePath = toPosixRelative(rootReal, child);
132
+ if (!workspaceRelativePathSchema.safeParse(relativePath).success) {
133
+ markIncomplete();
134
+ continue;
135
+ }
136
+ if (entry.isSymbolicLink()) {
137
+ const sampled = await sampleLink(child, io);
138
+ seen.add(relativePath);
139
+ if (!sampled) {
140
+ markIncomplete();
141
+ entries.push({ path: relativePath, kind: 'symlink' });
142
+ continue;
143
+ }
144
+ entries.push({ path: relativePath, kind: 'symlink', mode: sampled.mode, target: sampled.target });
145
+ continue;
146
+ }
147
+ if (entry.isDirectory()) {
148
+ await walk(rootReal, child, io, revisions, entries, files, bodies, seen, markIncomplete);
149
+ continue;
150
+ }
151
+ if (!entry.isFile())
152
+ continue;
153
+ const sampled = await sampleFile(child, io);
154
+ seen.add(relativePath);
155
+ if (!sampled) {
156
+ markIncomplete();
157
+ entries.push({ path: relativePath, kind: 'file' });
158
+ continue;
159
+ }
160
+ const record = {
161
+ path: relativePath,
162
+ contentHash: sampled.hash,
163
+ revision: revisions[relativePath]?.revision ?? 0,
164
+ };
165
+ entries.push({
166
+ path: relativePath,
167
+ kind: 'file',
168
+ mode: sampled.mode,
169
+ contentHash: sampled.hash,
170
+ size: sampled.body.byteLength,
171
+ });
172
+ files.push(record);
173
+ bodies.set(relativePath, sampled.body);
174
+ }
175
+ }
176
+ async function sampleFile(path, io) {
177
+ let previous;
178
+ for (let attempt = 0; attempt < READ_ATTEMPTS; attempt += 1) {
179
+ try {
180
+ const body = await io.readFile(path);
181
+ const mode = (await io.lstat(path)).mode & 0o777;
182
+ const hash = hashSnapshotBytes(body);
183
+ if (previous && previous.hash === hash && previous.mode === mode)
184
+ return { body, mode, hash };
185
+ previous = { hash, mode, body };
186
+ }
187
+ catch {
188
+ return undefined;
189
+ }
190
+ }
191
+ return undefined;
192
+ }
193
+ async function sampleLink(path, io) {
194
+ let previous;
195
+ for (let attempt = 0; attempt < READ_ATTEMPTS; attempt += 1) {
196
+ try {
197
+ const target = await io.readlink(path);
198
+ const mode = (await io.lstat(path)).mode & 0o777;
199
+ if (target.includes('\0') || target.length === 0 || target.length > 512)
200
+ return undefined;
201
+ if (previous && previous.target === target && previous.mode === mode)
202
+ return { mode, target };
203
+ previous = { mode, target };
204
+ }
205
+ catch {
206
+ return undefined;
207
+ }
208
+ }
209
+ return undefined;
210
+ }
211
+ function toPosixRelative(rootReal, target) {
212
+ return relative(rootReal, target).split(sep).join('/');
213
+ }
@@ -0,0 +1,143 @@
1
+ import { mkdir, readdir, readFile, realpath, rm, writeFile } from 'node:fs/promises';
2
+ import { dirname, join, relative, resolve, sep } from 'node:path';
3
+ import { hashWorkspaceFileContent, workspaceFileContentSchema, workspaceFileListSchema, workspaceRelativePathSchema, } from '@playcraft/common/cloud-workspace/runtime';
4
+ const SKIP_DIRECTORY_NAMES = new Set(['.git', 'node_modules']);
5
+ export class InvalidWorkspacePathError extends Error {
6
+ code = 'invalid_workspace_path';
7
+ constructor(path) {
8
+ super(path);
9
+ this.name = 'InvalidWorkspacePathError';
10
+ }
11
+ }
12
+ export async function ensureWorkspaceRoot(root) {
13
+ await mkdir(root, { recursive: true, mode: 0o700 });
14
+ return realpath(root);
15
+ }
16
+ export async function resolveWorkspaceFile(root, relativePath) {
17
+ const parsed = workspaceRelativePathSchema.safeParse(relativePath);
18
+ if (!parsed.success)
19
+ throw new InvalidWorkspacePathError(relativePath);
20
+ const rootReal = await ensureWorkspaceRoot(root);
21
+ const target = resolve(rootReal, ...parsed.data.split('/'));
22
+ assertInsideRoot(rootReal, target);
23
+ try {
24
+ const existing = await realpath(target);
25
+ assertInsideRoot(rootReal, existing);
26
+ return existing;
27
+ }
28
+ catch (error) {
29
+ if (error.code !== 'ENOENT')
30
+ throw error;
31
+ await assertParentInsideRoot(rootReal, dirname(target));
32
+ return target;
33
+ }
34
+ }
35
+ export async function writeWorkspaceFile(root, relativePath, content) {
36
+ const target = await resolveWorkspaceFile(root, relativePath);
37
+ await mkdir(dirname(target), { recursive: true, mode: 0o700 });
38
+ const bytes = Buffer.from(content, 'utf8');
39
+ await writeFile(target, bytes, { mode: 0o600 });
40
+ return { path: relativePath, contentHash: hashWorkspaceFileContent(content), size: bytes.byteLength };
41
+ }
42
+ export async function readWorkspaceFile(root, relativePath, revisions) {
43
+ const target = await resolveWorkspaceFile(root, relativePath);
44
+ const content = await readFile(target, 'utf8');
45
+ const contentHash = hashWorkspaceFileContent(content);
46
+ return workspaceFileContentSchema.parse({
47
+ path: relativePath,
48
+ revision: revisions[relativePath]?.revision ?? 0,
49
+ contentHash,
50
+ content,
51
+ });
52
+ }
53
+ export async function listWorkspaceFiles(root, revisions) {
54
+ const rootReal = await ensureWorkspaceRoot(root);
55
+ const files = [];
56
+ for (const relativePath of await walkWorkspaceFiles(rootReal, rootReal)) {
57
+ const target = join(rootReal, ...relativePath.split('/'));
58
+ const content = await readFile(target, 'utf8');
59
+ files.push({
60
+ path: relativePath,
61
+ revision: revisions[relativePath]?.revision ?? 0,
62
+ contentHash: hashWorkspaceFileContent(content),
63
+ size: Buffer.byteLength(content, 'utf8'),
64
+ });
65
+ }
66
+ files.sort((left, right) => left.path.localeCompare(right.path));
67
+ return workspaceFileListSchema.parse({ files });
68
+ }
69
+ export async function snapshotWorkspaceFiles(root, snapshotDir, revisions) {
70
+ const listed = await listWorkspaceFiles(root, revisions);
71
+ await rm(snapshotDir, { recursive: true, force: true });
72
+ const records = [];
73
+ for (const file of listed.files) {
74
+ const content = await readWorkspaceFile(root, file.path, revisions);
75
+ const target = join(snapshotDir, ...file.path.split('/'));
76
+ await mkdir(dirname(target), { recursive: true, mode: 0o700 });
77
+ await writeFile(target, content.content, { mode: 0o600 });
78
+ records.push({ path: file.path, contentHash: file.contentHash, revision: file.revision });
79
+ }
80
+ return records;
81
+ }
82
+ export async function restoreWorkspaceFiles(root, snapshotDir, files) {
83
+ const rootReal = await ensureWorkspaceRoot(root);
84
+ const keep = new Set(files.map((item) => item.path));
85
+ for (const relativePath of await walkWorkspaceFiles(rootReal, rootReal)) {
86
+ if (!keep.has(relativePath)) {
87
+ await rm(join(rootReal, ...relativePath.split('/')), { force: true });
88
+ }
89
+ }
90
+ const next = {};
91
+ for (const file of files) {
92
+ const source = join(snapshotDir, ...file.path.split('/'));
93
+ const content = await readFile(source, 'utf8');
94
+ await writeWorkspaceFile(root, file.path, content);
95
+ next[file.path] = { revision: file.revision, contentHash: hashWorkspaceFileContent(content) };
96
+ }
97
+ return next;
98
+ }
99
+ async function walkWorkspaceFiles(rootReal, current) {
100
+ const entries = await readdir(current, { withFileTypes: true });
101
+ const paths = [];
102
+ for (const entry of entries) {
103
+ if (entry.isDirectory()) {
104
+ if (SKIP_DIRECTORY_NAMES.has(entry.name) || entry.name === '.' || entry.name === '..')
105
+ continue;
106
+ const child = join(current, entry.name);
107
+ const childReal = await realpath(child);
108
+ assertInsideRoot(rootReal, childReal);
109
+ paths.push(...(await walkWorkspaceFiles(rootReal, childReal)));
110
+ continue;
111
+ }
112
+ if (!entry.isFile())
113
+ continue;
114
+ const childReal = await realpath(join(current, entry.name));
115
+ assertInsideRoot(rootReal, childReal);
116
+ paths.push(toPosixRelative(rootReal, childReal));
117
+ }
118
+ return paths;
119
+ }
120
+ async function assertParentInsideRoot(rootReal, parent) {
121
+ if (parent === rootReal)
122
+ return;
123
+ try {
124
+ const parentReal = await realpath(parent);
125
+ assertInsideRoot(rootReal, parentReal);
126
+ }
127
+ catch (error) {
128
+ if (error.code !== 'ENOENT')
129
+ throw error;
130
+ await mkdir(parent, { recursive: true, mode: 0o700 });
131
+ const parentReal = await realpath(parent);
132
+ assertInsideRoot(rootReal, parentReal);
133
+ }
134
+ }
135
+ function assertInsideRoot(rootReal, target) {
136
+ const rel = relative(rootReal, target);
137
+ if (rel === '' || rel.startsWith(`..${sep}`) || rel === '..' || rel.startsWith('..')) {
138
+ throw new InvalidWorkspacePathError(target);
139
+ }
140
+ }
141
+ function toPosixRelative(rootReal, target) {
142
+ return relative(rootReal, target).split(sep).join('/');
143
+ }
@@ -0,0 +1,242 @@
1
+ import { spawn } from 'node:child_process';
2
+ import { readFile, realpath, stat } from 'node:fs/promises';
3
+ import { tmpdir } from 'node:os';
4
+ import { join, relative, resolve, sep } from 'node:path';
5
+ import { gitDiffSchema, gitGraphSchema, gitStatusSchema, workspaceRelativePathSchema, } from '@playcraft/common/cloud-workspace/runtime';
6
+ const GIT_DIFF_MAX_BYTES = 1024 * 1024;
7
+ const GIT_PREFIX = [
8
+ '-c',
9
+ 'core.hooksPath=/dev/null',
10
+ '-c',
11
+ 'commit.gpgsign=false',
12
+ '-c',
13
+ 'safe.directory=*',
14
+ ];
15
+ export class GitWorkspaceError extends Error {
16
+ code;
17
+ constructor(code, message) {
18
+ super(message);
19
+ this.code = code;
20
+ this.name = 'GitWorkspaceError';
21
+ }
22
+ }
23
+ export async function probeGitBinary() {
24
+ try {
25
+ const result = await runGitProcess(tmpdir(), ['--version'], 4000);
26
+ return result.code === 0;
27
+ }
28
+ catch (error) {
29
+ return error instanceof GitWorkspaceError && error.code === 'git_binary_missing' ? false : false;
30
+ }
31
+ }
32
+ export async function readWorkspaceGitStatus(root) {
33
+ const rootReal = await ensureWorkspaceGit(root);
34
+ const branchResult = await gitAllowFail(rootReal, ['symbolic-ref', '--short', 'HEAD']);
35
+ const branch = branchResult.code === 0 ? branchResult.stdout.trim() || 'HEAD' : 'HEAD';
36
+ const upstream = await gitAllowFail(rootReal, ['rev-list', '--left-right', '--count', '@{upstream}...HEAD']);
37
+ let ahead = 0;
38
+ let behind = 0;
39
+ if (upstream.code === 0) {
40
+ const [left, right] = upstream.stdout.trim().split(/\s+/);
41
+ behind = Number.parseInt(left ?? '0', 10) || 0;
42
+ ahead = Number.parseInt(right ?? '0', 10) || 0;
43
+ }
44
+ const porcelain = await git(rootReal, ['status', '--porcelain=v1', '--untracked-files=all', '-z']);
45
+ return gitStatusSchema.parse({
46
+ initialized: true,
47
+ branch,
48
+ ahead,
49
+ behind,
50
+ hasUpstream: upstream.code === 0,
51
+ files: parsePorcelain(porcelain),
52
+ });
53
+ }
54
+ export async function readWorkspaceGitDiff(root) {
55
+ const rootReal = await ensureWorkspaceGit(root);
56
+ const vsHead = await gitAllowFail(rootReal, ['diff', '--no-color', 'HEAD']);
57
+ const patch = vsHead.code === 0
58
+ ? vsHead.stdout
59
+ : `${(await gitAllowFail(rootReal, ['diff', '--no-color'])).stdout}${(await gitAllowFail(rootReal, ['diff', '--no-color', '--cached'])).stdout}`;
60
+ if (Buffer.byteLength(patch, 'utf8') > GIT_DIFF_MAX_BYTES) {
61
+ throw new GitWorkspaceError('git_diff_too_large', '1MiB');
62
+ }
63
+ return gitDiffSchema.parse({ patch });
64
+ }
65
+ export async function readWorkspaceGitGraph(root) {
66
+ const rootReal = await ensureWorkspaceGit(root);
67
+ const log = await gitAllowFail(rootReal, ['log', '-n', '20', '--format=%H%x09%s%x09%cI']);
68
+ if (log.code !== 0)
69
+ return gitGraphSchema.parse({ commits: [] });
70
+ const commits = log.stdout
71
+ .split('\n')
72
+ .map((line) => line.trimEnd())
73
+ .filter((line) => line.length > 0)
74
+ .map((line) => {
75
+ const [sha, subject, committedAt] = splitLogLine(line);
76
+ return {
77
+ sha,
78
+ subject: subject.length > 0 ? subject : '(empty)',
79
+ committedAt: committedAt.length > 0 ? committedAt : new Date(0).toISOString(),
80
+ };
81
+ });
82
+ return gitGraphSchema.parse({ commits });
83
+ }
84
+ function splitLogLine(line) {
85
+ const first = line.indexOf('\t');
86
+ if (first < 0)
87
+ return [line, '', ''];
88
+ const second = line.indexOf('\t', first + 1);
89
+ if (second < 0)
90
+ return [line.slice(0, first), line.slice(first + 1), ''];
91
+ return [line.slice(0, first), line.slice(first + 1, second), line.slice(second + 1)];
92
+ }
93
+ export async function commitWorkspaceGit(root, payload) {
94
+ const rootReal = await ensureWorkspaceGit(root);
95
+ if (payload.paths && payload.paths.length > 0) {
96
+ for (const relativePath of payload.paths) {
97
+ if (!workspaceRelativePathSchema.safeParse(relativePath).success) {
98
+ throw new GitWorkspaceError('invalid_workspace_path', relativePath);
99
+ }
100
+ }
101
+ await git(rootReal, ['add', '--', ...payload.paths]);
102
+ }
103
+ else {
104
+ await git(rootReal, ['add', '-A']);
105
+ }
106
+ const cached = await gitAllowFail(rootReal, ['diff', '--cached', '--quiet']);
107
+ if (cached.code === 0) {
108
+ throw new GitWorkspaceError('nothing_to_commit', payload.message);
109
+ }
110
+ await git(rootReal, ['commit', '-m', payload.message], 20000);
111
+ return (await git(rootReal, ['rev-parse', 'HEAD'])).trim();
112
+ }
113
+ export async function pushWorkspaceGit(root) {
114
+ const rootReal = await ensureWorkspaceGit(root);
115
+ const origin = await gitAllowFail(rootReal, ['remote', 'get-url', 'origin']);
116
+ if (origin.code !== 0) {
117
+ throw new GitWorkspaceError('remote_not_configured', 'origin');
118
+ }
119
+ const pushed = await gitAllowFail(rootReal, ['push', 'origin', 'HEAD'], 20000);
120
+ if (pushed.code !== 0) {
121
+ throw new GitWorkspaceError('git_command_failed', pushed.stderr.trim() || 'git_push_failed');
122
+ }
123
+ }
124
+ export async function ensureWorkspaceGit(root) {
125
+ const rootReal = await realpath(root);
126
+ if (!(await gitMarkerExists(rootReal))) {
127
+ const init = await gitAllowFail(rootReal, ['init']);
128
+ if (init.code !== 0) {
129
+ throw new GitWorkspaceError('git_command_failed', init.stderr.trim() || 'git_init_failed');
130
+ }
131
+ await git(rootReal, ['config', 'user.name', 'PlayCraft Workspace']);
132
+ await git(rootReal, ['config', 'user.email', 'workspace@playcraft.local']);
133
+ }
134
+ await assertWorkspaceGitRoot(rootReal);
135
+ return rootReal;
136
+ }
137
+ async function gitMarkerExists(rootReal) {
138
+ try {
139
+ await stat(join(rootReal, '.git'));
140
+ return true;
141
+ }
142
+ catch (error) {
143
+ if (error.code === 'ENOENT')
144
+ return false;
145
+ throw error;
146
+ }
147
+ }
148
+ async function assertWorkspaceGitRoot(rootReal) {
149
+ const marker = join(rootReal, '.git');
150
+ const info = await stat(marker);
151
+ if (info.isDirectory()) {
152
+ assertInsideRoot(rootReal, await realpath(marker));
153
+ return;
154
+ }
155
+ if (info.isFile()) {
156
+ const text = await readFile(marker, 'utf8');
157
+ const match = /^gitdir:\s*(.+)$/m.exec(text);
158
+ if (!match)
159
+ throw new GitWorkspaceError('git_root_escape', 'invalid_gitdir');
160
+ const target = resolve(rootReal, match[1].trim());
161
+ assertInsideRoot(rootReal, await realpath(target));
162
+ return;
163
+ }
164
+ throw new GitWorkspaceError('git_root_escape', marker);
165
+ }
166
+ function assertInsideRoot(rootReal, target) {
167
+ const rel = relative(rootReal, target);
168
+ if (rel === '' || rel.startsWith(`..${sep}`) || rel === '..' || rel.startsWith('..')) {
169
+ throw new GitWorkspaceError('git_root_escape', target);
170
+ }
171
+ }
172
+ function parsePorcelain(raw) {
173
+ const parts = raw.split('\0').filter((part) => part.length > 0);
174
+ const files = [];
175
+ for (let i = 0; i < parts.length; i += 1) {
176
+ const entry = parts[i] ?? '';
177
+ if (entry.length < 3)
178
+ continue;
179
+ const indexStatus = entry[0] ?? ' ';
180
+ const worktreeStatus = entry[1] ?? ' ';
181
+ const pathPart = entry.slice(3);
182
+ if (indexStatus === 'R' || indexStatus === 'C' || worktreeStatus === 'R' || worktreeStatus === 'C') {
183
+ const dest = parts[i + 1] ?? pathPart;
184
+ i += 1;
185
+ files.push({ path: dest, indexStatus, worktreeStatus });
186
+ continue;
187
+ }
188
+ files.push({ path: pathPart, indexStatus, worktreeStatus });
189
+ }
190
+ return files;
191
+ }
192
+ async function git(root, args, timeoutMs = 15000) {
193
+ const result = await gitAllowFail(root, args, timeoutMs);
194
+ if (result.code !== 0) {
195
+ throw new GitWorkspaceError('git_command_failed', result.stderr.trim() || `git_exit_${result.code}`);
196
+ }
197
+ return result.stdout;
198
+ }
199
+ async function gitAllowFail(root, args, timeoutMs = 15000) {
200
+ return runGitProcess(root, [...GIT_PREFIX, `--git-dir=${join(root, '.git')}`, `--work-tree=${root}`, ...args], timeoutMs);
201
+ }
202
+ function gitEnv() {
203
+ const env = { ...process.env };
204
+ delete env.GIT_DIR;
205
+ delete env.GIT_WORK_TREE;
206
+ delete env.GIT_COMMON_DIR;
207
+ delete env.GIT_INDEX_FILE;
208
+ delete env.GIT_OBJECT_DIRECTORY;
209
+ delete env.GIT_ALTERNATE_OBJECT_DIRECTORIES;
210
+ env.GIT_OPTIONAL_LOCKS = '0';
211
+ env.GIT_TERMINAL_PROMPT = '0';
212
+ return env;
213
+ }
214
+ function runGitProcess(cwd, args, timeoutMs) {
215
+ return new Promise((resolve, reject) => {
216
+ const child = spawn('git', args, { cwd, env: gitEnv(), windowsHide: true });
217
+ let stdout = '';
218
+ let stderr = '';
219
+ const timer = setTimeout(() => {
220
+ child.kill('SIGKILL');
221
+ reject(new GitWorkspaceError('git_command_failed', 'timeout'));
222
+ }, timeoutMs);
223
+ child.stdout.on('data', (chunk) => {
224
+ stdout += String(chunk);
225
+ });
226
+ child.stderr.on('data', (chunk) => {
227
+ stderr += String(chunk);
228
+ });
229
+ child.on('error', (error) => {
230
+ clearTimeout(timer);
231
+ if (error.code === 'ENOENT') {
232
+ reject(new GitWorkspaceError('git_binary_missing', 'git'));
233
+ return;
234
+ }
235
+ reject(error);
236
+ });
237
+ child.on('close', (code) => {
238
+ clearTimeout(timer);
239
+ resolve({ stdout, stderr, code: code ?? 1 });
240
+ });
241
+ });
242
+ }