@playcraft/cli 0.1.2 → 0.1.3

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.
@@ -2,12 +2,12 @@ import { withObservedFile } from '../workspaces/revisions.js';
2
2
  import { revisionScope, workspaceRoot, workspaceSessions } from '../workspaces/registry.js';
3
3
  import { join } from 'node:path';
4
4
  import { randomUUID } from 'node:crypto';
5
- import { checkpointRecordSchema, fileWritePayloadSchema, gitCommitPayloadSchema, gitPushPayloadSchema, parameterPatchPayloadSchema, terminalCreatePayloadSchema, terminalProcessIdentity, transitionOperation, } from '@playcraft/common/cloud-workspace/runtime';
5
+ import { checkpointRecordSchema, fileWritePayloadSchema, gitCommitPayloadSchema, gitPathsPayloadSchema, gitPushPayloadSchema, parameterPatchPayloadSchema, terminalCreatePayloadSchema, terminalProcessIdentity, transitionOperation, } from '@playcraft/common/cloud-workspace/runtime';
6
6
  import { NativePreparationError } from '../codex/native-adapter.js';
7
7
  import { disposeTerminalHost } from './terminal-host.js';
8
8
  import { captureWorkspaceTree, materializeSnapshot, publishFileSnapshot, } from '../workspaces/file-snapshot.js';
9
9
  import { InvalidWorkspacePathError, resolveWorkspaceFile, restoreWorkspaceFiles, snapshotWorkspaceFiles, writeWorkspaceFile, } from '../workspaces/files.js';
10
- import { GitWorkspaceError, commitWorkspaceGit, pushWorkspaceGit } from '../workspaces/git.js';
10
+ import { GitWorkspaceError, commitWorkspaceGit, discardWorkspaceGit, pushWorkspaceGit, stageWorkspaceGit, unstageWorkspaceGit, } from '../workspaces/git.js';
11
11
  import { ParameterEditError } from '../workspaces/json5-edit.js';
12
12
  import { patchWorkspaceParameters, readWorkspaceParameters } from '../workspaces/parameters.js';
13
13
  import { executionGroupRestarted } from './execution-group.js';
@@ -26,6 +26,9 @@ const LOCAL_KINDS = new Set([
26
26
  'file.write',
27
27
  'git.commit',
28
28
  'git.push',
29
+ 'git.stage',
30
+ 'git.unstage',
31
+ 'git.discard',
29
32
  'terminal.create',
30
33
  'parameter.patch',
31
34
  ]);
@@ -88,6 +91,9 @@ async function executeScopedOperation(state, scopeKey, operation, payload) {
88
91
  if (operation.kind === 'git.push') {
89
92
  return executeGitPush(state, scopeKey, current, payload);
90
93
  }
94
+ if (operation.kind === 'git.stage' || operation.kind === 'git.unstage' || operation.kind === 'git.discard') {
95
+ return executeGitIndex(state, scopeKey, current, payload);
96
+ }
91
97
  if (operation.kind === 'terminal.create') {
92
98
  return executeTerminalCreate(state, scopeKey, current, payload);
93
99
  }
@@ -620,6 +626,30 @@ async function executeGitPush(state, scopeKey, operation, payload) {
620
626
  return persist(state, scopeKey, transitionOperation(operation, 'failed', { error: gitErrorCode(error) }));
621
627
  }
622
628
  }
629
+ const GIT_INDEX_CHANGES = {
630
+ 'git.stage': { run: stageWorkspaceGit, event: 'git.staged' },
631
+ 'git.unstage': { run: unstageWorkspaceGit, event: 'git.unstaged' },
632
+ 'git.discard': { run: discardWorkspaceGit, event: 'git.discarded' },
633
+ };
634
+ /** Stage, unstage and discard share a payload; each records its own event so the browser refreshes. */
635
+ async function executeGitIndex(state, scopeKey, operation, payload) {
636
+ const change = GIT_INDEX_CHANGES[operation.kind];
637
+ const parsed = gitPathsPayloadSchema.safeParse(payload);
638
+ if (!parsed.success) {
639
+ return persist(state, scopeKey, transitionOperation(operation, 'failed', { error: 'invalid_git_paths' }));
640
+ }
641
+ try {
642
+ await change.run(await workspaceRoot(state, operation.workspaceId), parsed.data);
643
+ const accepted = await persist(state, scopeKey, transitionOperation(operation, 'accepted'));
644
+ await recordNativeEvents(state, accepted, [
645
+ { type: change.event, payload: { paths: parsed.data.paths }, occurredAt: new Date().toISOString() },
646
+ ]);
647
+ return persist(state, scopeKey, transitionOperation(accepted, 'completed'));
648
+ }
649
+ catch (error) {
650
+ return persist(state, scopeKey, transitionOperation(operation, 'failed', { error: gitErrorCode(error) }));
651
+ }
652
+ }
623
653
  async function executeParameterPatch(state, scopeKey, operation, payload) {
624
654
  const parsed = parameterPatchPayloadSchema.safeParse(payload);
625
655
  if (!parsed.success) {
@@ -1,12 +1,12 @@
1
1
  import { readObservedFile, withObservedFile } from '../workspaces/revisions.js';
2
2
  import { registerWorkspace, revisionScope, workspaceRoot, WorkspaceContextError } from '../workspaces/registry.js';
3
3
  import { randomUUID } from 'node:crypto';
4
- import { workspaceRegistrationRequestSchema, HTTP_STATUS_BY_ERROR, assertLeaseFence, buildHandshakeDocument, capability, findUntrustedRuntimeFields, hashOperationPayload, instanceRebuildRequestSchema, sourceAckSchema, operationAcceptRequestSchema, operationKindSchema, operationScopeKey, resolveIdempotentAccept, nativeHistoryReadSchema, runtimeError, unavailableRuntimeCapabilities, } from '@playcraft/common/cloud-workspace/runtime';
4
+ import { workspaceRegistrationRequestSchema, HTTP_STATUS_BY_ERROR, assertLeaseFence, buildHandshakeDocument, capability, findUntrustedRuntimeFields, gitFileRefSchema, hashOperationPayload, instanceRebuildRequestSchema, sourceAckSchema, operationAcceptRequestSchema, operationKindSchema, operationScopeKey, resolveIdempotentAccept, nativeHistoryReadSchema, runtimeError, unavailableRuntimeCapabilities, } from '@playcraft/common/cloud-workspace/runtime';
5
5
  import { controlTokenMatches, extractControlToken } from './auth.js';
6
6
  import { canExecuteKind, executeOperation } from './dispatch.js';
7
7
  import { InvalidWorkspacePathError, listWorkspaceFiles, } from '../workspaces/files.js';
8
8
  import { materializeRemoteSnapshot } from '../workspaces/file-snapshot.js';
9
- import { GitWorkspaceError, readWorkspaceGitDiff, readWorkspaceGitGraph, readWorkspaceGitStatus, } from '../workspaces/git.js';
9
+ import { GitWorkspaceError, readWorkspaceGitDiff, readWorkspaceGitFile, readWorkspaceGitGraph, readWorkspaceGitStatus, } from '../workspaces/git.js';
10
10
  import { readWorkspaceParameters } from '../workspaces/parameters.js';
11
11
  import { desktopHost, desktopRoute } from './desktop-host.js';
12
12
  import { terminalHost } from './terminal-host.js';
@@ -131,6 +131,15 @@ export async function handleRuntimeRequest(state, request, response) {
131
131
  await writeGitRead(state, response, async () => readWorkspaceGitDiff(await workspaceRoot(state, url.searchParams.get('workspaceId') ?? undefined)));
132
132
  return;
133
133
  }
134
+ if (request.method === 'GET' && url.pathname === '/runtime/git/file') {
135
+ const ref = gitFileRefSchema.safeParse(url.searchParams.get('ref'));
136
+ if (!ref.success) {
137
+ writeJson(response, 400, { error: 'invalid_git_ref' });
138
+ return;
139
+ }
140
+ await writeGitRead(state, response, async () => readWorkspaceGitFile(await workspaceRoot(state, url.searchParams.get('workspaceId') ?? undefined), url.searchParams.get('path') ?? '', ref.data));
141
+ return;
142
+ }
134
143
  if (request.method === 'GET' && url.pathname === '/runtime/git/graph') {
135
144
  await writeGitRead(state, response, async () => readWorkspaceGitGraph(await workspaceRoot(state, url.searchParams.get('workspaceId') ?? undefined)));
136
145
  return;
@@ -1,8 +1,8 @@
1
1
  import { spawn } from 'node:child_process';
2
- import { readFile, realpath, stat } from 'node:fs/promises';
2
+ import { lstat, readFile, realpath, stat, unlink } from 'node:fs/promises';
3
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';
4
+ import { basename, dirname, join, relative, resolve, sep } from 'node:path';
5
+ import { WORKSPACE_FILE_CONTENT_MAX, gitDiffSchema, gitFileContentSchema, gitGraphSchema, gitStatusSchema, workspaceRelativePathSchema, } from '@playcraft/common/cloud-workspace/runtime';
6
6
  const GIT_DIFF_MAX_BYTES = 1024 * 1024;
7
7
  const GIT_PREFIX = [
8
8
  '-c',
@@ -42,15 +42,96 @@ export async function readWorkspaceGitStatus(root) {
42
42
  ahead = Number.parseInt(right ?? '0', 10) || 0;
43
43
  }
44
44
  const porcelain = await git(rootReal, ['status', '--porcelain=v1', '--untracked-files=all', '-z']);
45
+ // Line counts are a convenience; a repository without commits still lists its files.
46
+ const [staged, unstaged] = await Promise.all([
47
+ gitAllowFail(rootReal, ['diff', '--cached', '--numstat', '-z']),
48
+ gitAllowFail(rootReal, ['diff', '--numstat', '-z']),
49
+ ]);
50
+ const stagedCounts = staged.code === 0 ? parseNumstat(staged.stdout) : new Map();
51
+ const unstagedCounts = unstaged.code === 0 ? parseNumstat(unstaged.stdout) : new Map();
45
52
  return gitStatusSchema.parse({
46
53
  initialized: true,
47
54
  branch,
48
55
  ahead,
49
56
  behind,
50
57
  hasUpstream: upstream.code === 0,
51
- files: parsePorcelain(porcelain),
58
+ files: parsePorcelain(porcelain).map((entry) => ({
59
+ ...entry,
60
+ ...(stagedCounts.has(entry.path) ? { staged: stagedCounts.get(entry.path) } : {}),
61
+ ...(unstagedCounts.has(entry.path) ? { unstaged: unstagedCounts.get(entry.path) } : {}),
62
+ })),
52
63
  });
53
64
  }
65
+ /**
66
+ * One file as stored in the last commit or in the index. Reads the blob as is, without filters,
67
+ * so the browser compares exactly what Git holds.
68
+ */
69
+ export async function readWorkspaceGitFile(root, path, ref) {
70
+ const rootReal = await ensureWorkspaceGit(root);
71
+ assertWorkspacePaths([path]);
72
+ const object = ref === 'HEAD' ? `HEAD:${path}` : `:${path}`;
73
+ const absent = gitFileContentSchema.parse({ path, ref, exists: false, binary: false, content: '' });
74
+ const size = await gitAllowFail(rootReal, ['cat-file', '-s', object]);
75
+ if (size.code !== 0)
76
+ return absent;
77
+ if (Number.parseInt(size.stdout.trim(), 10) > WORKSPACE_FILE_CONTENT_MAX) {
78
+ throw new GitWorkspaceError('git_file_too_large', path);
79
+ }
80
+ const blob = await gitAllowFail(rootReal, ['cat-file', 'blob', object]);
81
+ if (blob.code !== 0)
82
+ return absent;
83
+ // Git treats a NUL byte as the mark of binary content; so does the diff view.
84
+ const binary = blob.stdout.includes('\0');
85
+ return gitFileContentSchema.parse({ path, ref, exists: true, binary, content: binary ? '' : blob.stdout });
86
+ }
87
+ /** Adds the paths to the index, including deletions. */
88
+ export async function stageWorkspaceGit(root, payload) {
89
+ const rootReal = await ensureWorkspaceGit(root);
90
+ assertWorkspacePaths(payload.paths);
91
+ await git(rootReal, ['--literal-pathspecs', 'add', '-A', '--', ...payload.paths]);
92
+ }
93
+ /** Returns the paths' index entries to the last commit, leaving the work tree alone. */
94
+ export async function unstageWorkspaceGit(root, payload) {
95
+ const rootReal = await ensureWorkspaceGit(root);
96
+ assertWorkspacePaths(payload.paths);
97
+ const head = await gitAllowFail(rootReal, ['rev-parse', '--verify', '--quiet', 'HEAD']);
98
+ if (head.code === 0) {
99
+ await git(rootReal, ['--literal-pathspecs', 'restore', '--staged', '--', ...payload.paths]);
100
+ }
101
+ else {
102
+ // Before the first commit there is nothing to restore from; the entries are simply removed.
103
+ await git(rootReal, ['--literal-pathspecs', 'rm', '--cached', '-r', '-q', '--ignore-unmatch', '--', ...payload.paths]);
104
+ }
105
+ }
106
+ /**
107
+ * Throws away work-tree changes: tracked files return to their staged content, untracked files are
108
+ * deleted. Staged content is never touched, so a staged change survives a discard.
109
+ */
110
+ export async function discardWorkspaceGit(root, payload) {
111
+ const rootReal = await ensureWorkspaceGit(root);
112
+ assertWorkspacePaths(payload.paths);
113
+ const others = await git(rootReal, ['--literal-pathspecs', 'ls-files', '--others', '--exclude-standard', '-z', '--', ...payload.paths]);
114
+ const untracked = new Set(others.split('\0').filter((path) => path.length > 0));
115
+ const tracked = payload.paths.filter((path) => !untracked.has(path));
116
+ if (tracked.length > 0) {
117
+ await git(rootReal, ['--literal-pathspecs', 'restore', '--worktree', '--', ...tracked]);
118
+ }
119
+ for (const path of untracked) {
120
+ const target = resolve(rootReal, path);
121
+ // The parent must resolve inside the workspace, so a symlinked directory cannot redirect the delete.
122
+ assertInsideRoot(rootReal, join(await realpath(dirname(target)), basename(target)));
123
+ if ((await lstat(target)).isDirectory())
124
+ throw new GitWorkspaceError('invalid_workspace_path', path);
125
+ await unlink(target);
126
+ }
127
+ }
128
+ function assertWorkspacePaths(paths) {
129
+ for (const relativePath of paths) {
130
+ if (!workspaceRelativePathSchema.safeParse(relativePath).success) {
131
+ throw new GitWorkspaceError('invalid_workspace_path', relativePath);
132
+ }
133
+ }
134
+ }
54
135
  export async function readWorkspaceGitDiff(root) {
55
136
  const rootReal = await ensureWorkspaceGit(root);
56
137
  const vsHead = await gitAllowFail(rootReal, ['diff', '--no-color', 'HEAD']);
@@ -92,12 +173,11 @@ function splitLogLine(line) {
92
173
  }
93
174
  export async function commitWorkspaceGit(root, payload) {
94
175
  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
- }
176
+ if (payload.stagedOnly) {
177
+ // The index is the commit; nothing is added.
178
+ }
179
+ else if (payload.paths && payload.paths.length > 0) {
180
+ assertWorkspacePaths(payload.paths);
101
181
  await git(rootReal, ['add', '--', ...payload.paths]);
102
182
  }
103
183
  else {
@@ -202,6 +282,28 @@ function parsePorcelain(raw) {
202
282
  }
203
283
  return files;
204
284
  }
285
+ /**
286
+ * `diff --numstat -z`: `added<TAB>removed<TAB>path<NUL>`, or for a rename an empty path followed by
287
+ * `old<NUL>new<NUL>`. Binary files report `-` and are left out.
288
+ */
289
+ function parseNumstat(raw) {
290
+ const counts = new Map();
291
+ const parts = raw.split('\0');
292
+ for (let index = 0; index < parts.length; index += 1) {
293
+ const match = /^(\d+|-)\t(\d+|-)\t(.*)$/s.exec(parts[index] ?? '');
294
+ if (!match)
295
+ continue;
296
+ let path = match[3];
297
+ if (path === '') {
298
+ path = parts[index + 2] ?? '';
299
+ index += 2;
300
+ }
301
+ if (match[1] === '-' || !path)
302
+ continue;
303
+ counts.set(path, { added: Number(match[1]), removed: Number(match[2]) });
304
+ }
305
+ return counts;
306
+ }
205
307
  async function git(root, args, timeoutMs = 15000) {
206
308
  const result = await gitAllowFail(root, args, timeoutMs);
207
309
  if (result.code !== 0) {
@@ -227,17 +329,18 @@ function gitEnv() {
227
329
  function runGitProcess(cwd, args, timeoutMs) {
228
330
  return new Promise((resolve, reject) => {
229
331
  const child = spawn('git', args, { cwd, env: gitEnv(), windowsHide: true });
230
- let stdout = '';
231
- let stderr = '';
332
+ // Chunks are decoded together; a character split across two chunks would otherwise be corrupted.
333
+ const stdout = [];
334
+ const stderr = [];
232
335
  const timer = setTimeout(() => {
233
336
  child.kill('SIGKILL');
234
337
  reject(new GitWorkspaceError('git_command_failed', 'timeout'));
235
338
  }, timeoutMs);
236
339
  child.stdout.on('data', (chunk) => {
237
- stdout += String(chunk);
340
+ stdout.push(Buffer.from(chunk));
238
341
  });
239
342
  child.stderr.on('data', (chunk) => {
240
- stderr += String(chunk);
343
+ stderr.push(Buffer.from(chunk));
241
344
  });
242
345
  child.on('error', (error) => {
243
346
  clearTimeout(timer);
@@ -249,7 +352,11 @@ function runGitProcess(cwd, args, timeoutMs) {
249
352
  });
250
353
  child.on('close', (code) => {
251
354
  clearTimeout(timer);
252
- resolve({ stdout, stderr, code: code ?? 1 });
355
+ resolve({
356
+ stdout: Buffer.concat(stdout).toString('utf8'),
357
+ stderr: Buffer.concat(stderr).toString('utf8'),
358
+ code: code ?? 1,
359
+ });
253
360
  });
254
361
  });
255
362
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@playcraft/cli",
3
- "version": "0.1.2",
3
+ "version": "0.1.3",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "engines": {
@@ -48,8 +48,8 @@
48
48
  "@gltf-transform/core": "^4.3.0",
49
49
  "@gltf-transform/extensions": "^4.3.0",
50
50
  "@gltf-transform/functions": "^4.3.0",
51
- "@playcraft/build": "^0.1.2",
52
- "@playcraft/common": "^0.1.2",
51
+ "@playcraft/build": "^0.1.3",
52
+ "@playcraft/common": "^0.1.3",
53
53
  "better-sqlite3-pinned": "npm:better-sqlite3@13.0.3",
54
54
  "chokidar": "^4.0.3",
55
55
  "commander": "^13.1.0",