@playcraft/cli 0.1.32 → 0.1.34

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.
@@ -1,9 +1,10 @@
1
1
  import { withObservedFile } from '../workspaces/revisions.js';
2
2
  import { revisionScope, sessionWorkspaceRoot, workspaceRoot, workspaceSessions } from '../workspaces/registry.js';
3
3
  import { abandonTurnChanges, beginTurnChanges, finishTurnChanges } from '../workspaces/turn-changes.js';
4
+ import { shareWorkspaceGit } from '../workspaces/shared-git.js';
4
5
  import { join } from 'node:path';
5
6
  import { z } from 'zod';
6
- import { PLATFORM_ROUTES, platformEndpoint, fileWritePayloadSchema, gitCommitPayloadSchema, gitPathsPayloadSchema, gitPushPayloadSchema, parameterPatchPayloadSchema, productionSessionAttemptId, reasoningEffortSchema, workspacePermissionModeSchema, workspaceComposerSelectionSchema, workspaceReviewPayloadSchema, workspaceCompactPayloadSchema, workspaceSteerPayloadSchema, workspaceApprovalResponseSchema, terminalCreatePayloadSchema, terminalProcessIdentity, transitionOperation, recoverUncertainOperation, workspaceSavePayloadSchema, workspaceSkillsUpdatePayloadSchema, } from '@playcraft/common/cloud-workspace/runtime';
7
+ import { PLATFORM_ROUTES, platformEndpoint, fileWritePayloadSchema, gitCommitPayloadSchema, gitPathsPayloadSchema, gitPushPayloadSchema, gitPullPayloadSchema, gitMergeAbortPayloadSchema, parameterPatchPayloadSchema, productionSessionAttemptId, reasoningEffortSchema, workspacePermissionModeSchema, workspaceComposerSelectionSchema, workspaceReviewPayloadSchema, workspaceCompactPayloadSchema, workspaceSteerPayloadSchema, workspaceApprovalResponseSchema, terminalCreatePayloadSchema, terminalProcessIdentity, transitionOperation, recoverUncertainOperation, workspaceSavePayloadSchema, workspaceSkillsUpdatePayloadSchema, } from '@playcraft/common/cloud-workspace/runtime';
7
8
  import { closeCliCredentials } from '../workspaces/cli-credentials.js';
8
9
  import { artifactFormat } from '../workspaces/artifact-files.js';
9
10
  import { refreshWorkspaceArtifacts } from '../workspaces/artifact-events.js';
@@ -17,7 +18,7 @@ import { NativePreparationError } from '../codex/native-adapter.js';
17
18
  import { disposeTerminalHost } from './terminal-host.js';
18
19
  import { disposeDesktopHost } from './desktop-host.js';
19
20
  import { InvalidWorkspacePathError, resolveWorkspaceFile, writeWorkspaceFile, } from '../workspaces/files.js';
20
- import { GitWorkspaceError, commitWorkspaceGit, discardWorkspaceGit, pushWorkspaceGit, saveWorkspaceGit, stageWorkspaceGit, unstageWorkspaceGit, } from '../workspaces/git.js';
21
+ import { GitWorkspaceError, commitWorkspaceGit, discardWorkspaceGit, pushWorkspaceGit, pullWorkspaceGit, abortWorkspaceMerge, saveWorkspaceGit, stageWorkspaceGit, unstageWorkspaceGit, } from '../workspaces/git.js';
21
22
  import { ParameterEditError } from '../workspaces/json5-edit.js';
22
23
  import { patchWorkspaceParameters, readWorkspaceParameters } from '../workspaces/parameters.js';
23
24
  import { previewFailureCode, previewWorkspaceRoot, runPreviewPublish, trackPreviewPublish, } from '../previews/publish.js';
@@ -38,6 +39,8 @@ const LOCAL_KINDS = new Set([
38
39
  'file.write',
39
40
  'git.commit',
40
41
  'git.push',
42
+ 'git.pull',
43
+ 'git.merge.abort',
41
44
  'git.stage',
42
45
  'git.unstage',
43
46
  'git.discard',
@@ -93,6 +96,12 @@ async function executeScopedOperation(state, scopeKey, operation, payload, recov
93
96
  if (operation.kind === 'git.push') {
94
97
  return executeGitPush(state, scopeKey, current, payload);
95
98
  }
99
+ if (operation.kind === 'git.pull') {
100
+ return executeGitPull(state, scopeKey, current, payload);
101
+ }
102
+ if (operation.kind === 'git.merge.abort') {
103
+ return executeGitMergeAbort(state, scopeKey, current, payload);
104
+ }
96
105
  if (operation.kind === 'git.stage' || operation.kind === 'git.unstage' || operation.kind === 'git.discard') {
97
106
  return executeGitIndex(state, scopeKey, current, payload);
98
107
  }
@@ -781,7 +790,7 @@ async function executeGitCommit(state, scopeKey, operation, payload) {
781
790
  return persist(state, scopeKey, transitionOperation(operation, 'failed', { error: 'invalid_git_commit' }));
782
791
  }
783
792
  try {
784
- const sha = await commitWorkspaceGit(await workspaceRoot(state, operation.workspaceId, operation.directoryName), parsed.data, boundBranch(state, operation));
793
+ const sha = await commitWorkspaceGit(await gitWorkspaceRoot(state, operation), parsed.data, boundBranch(state, operation));
785
794
  const accepted = await persist(state, scopeKey, transitionOperation(operation, 'accepted'));
786
795
  await recordNativeEvents(state, accepted, [
787
796
  {
@@ -818,7 +827,7 @@ async function executeWorkspaceSave(state, scopeKey, operation, payload) {
818
827
  }
819
828
  const binding = operation.workspaceId ? state.journal.getWorkspace(operation.workspaceId) : undefined;
820
829
  try {
821
- const saved = await saveWorkspaceGit(await workspaceRoot(state, operation.workspaceId, operation.directoryName), {
830
+ const saved = await saveWorkspaceGit(await gitWorkspaceRoot(state, operation), {
822
831
  message: parsed.data.trigger === 'auto' ? 'Autosave workspace' : 'Save workspace',
823
832
  push: savePushesBranch(binding?.targetKind, operation.directoryName),
824
833
  boundBranch: boundBranch(state, operation),
@@ -843,7 +852,7 @@ async function executeGitPush(state, scopeKey, operation, payload) {
843
852
  return persist(state, scopeKey, transitionOperation(operation, 'failed', { error: 'remote_not_configurable' }));
844
853
  }
845
854
  try {
846
- await pushWorkspaceGit(await workspaceRoot(state, operation.workspaceId, operation.directoryName), boundBranch(state, operation));
855
+ await pushWorkspaceGit(await gitWorkspaceRoot(state, operation), boundBranch(state, operation));
847
856
  const accepted = await persist(state, scopeKey, transitionOperation(operation, 'accepted'));
848
857
  await recordNativeEvents(state, accepted, [
849
858
  {
@@ -858,6 +867,39 @@ async function executeGitPush(state, scopeKey, operation, payload) {
858
867
  return persist(state, scopeKey, transitionOperation(operation, 'failed', { error: gitErrorCode(error) }));
859
868
  }
860
869
  }
870
+ /** Conflicts complete the pull: the merge stays in progress for the user to resolve. */
871
+ async function executeGitPull(state, scopeKey, operation, payload) {
872
+ if (!gitPullPayloadSchema.safeParse(payload ?? {}).success) {
873
+ return persist(state, scopeKey, transitionOperation(operation, 'failed', { error: 'remote_not_configurable' }));
874
+ }
875
+ try {
876
+ const pulled = await pullWorkspaceGit(await gitWorkspaceRoot(state, operation), boundBranch(state, operation));
877
+ const accepted = await persist(state, scopeKey, transitionOperation(operation, 'accepted'));
878
+ await recordNativeEvents(state, accepted, [{
879
+ type: 'git.pulled',
880
+ payload: { remote: 'origin', outcome: pulled.outcome, conflicts: pulled.conflicts.slice(0, 100) },
881
+ occurredAt: new Date().toISOString(),
882
+ }]);
883
+ return persist(state, scopeKey, transitionOperation(accepted, 'completed'));
884
+ }
885
+ catch (error) {
886
+ return persist(state, scopeKey, transitionOperation(operation, 'failed', { error: gitErrorCode(error) }));
887
+ }
888
+ }
889
+ async function executeGitMergeAbort(state, scopeKey, operation, payload) {
890
+ if (!gitMergeAbortPayloadSchema.safeParse(payload ?? {}).success) {
891
+ return persist(state, scopeKey, transitionOperation(operation, 'failed', { error: 'invalid_git_merge_abort' }));
892
+ }
893
+ try {
894
+ await abortWorkspaceMerge(await gitWorkspaceRoot(state, operation));
895
+ const accepted = await persist(state, scopeKey, transitionOperation(operation, 'accepted'));
896
+ await recordNativeEvents(state, accepted, [{ type: 'git.merge.aborted', payload: {}, occurredAt: new Date().toISOString() }]);
897
+ return persist(state, scopeKey, transitionOperation(accepted, 'completed'));
898
+ }
899
+ catch (error) {
900
+ return persist(state, scopeKey, transitionOperation(operation, 'failed', { error: gitErrorCode(error) }));
901
+ }
902
+ }
861
903
  const GIT_INDEX_CHANGES = {
862
904
  'git.stage': { run: stageWorkspaceGit, event: 'git.staged' },
863
905
  'git.unstage': { run: unstageWorkspaceGit, event: 'git.unstaged' },
@@ -871,7 +913,7 @@ async function executeGitIndex(state, scopeKey, operation, payload) {
871
913
  return persist(state, scopeKey, transitionOperation(operation, 'failed', { error: 'invalid_git_paths' }));
872
914
  }
873
915
  try {
874
- await change.run(await workspaceRoot(state, operation.workspaceId, operation.directoryName), parsed.data);
916
+ await change.run(await gitWorkspaceRoot(state, operation), parsed.data);
875
917
  const accepted = await persist(state, scopeKey, transitionOperation(operation, 'accepted'));
876
918
  await recordNativeEvents(state, accepted, [
877
919
  { type: change.event, payload: { paths: parsed.data.paths }, occurredAt: new Date().toISOString() },
@@ -1002,8 +1044,16 @@ function boundBranch(state, operation) {
1002
1044
  : binding.branchName ?? undefined;
1003
1045
  }
1004
1046
  function gitErrorCode(error) {
1047
+ // The code is all an operation records; Git's own message stays in the runtime log.
1048
+ console.warn('[workspace-runtime] git operation failed:', error instanceof Error ? error.message : error);
1005
1049
  return error instanceof GitWorkspaceError ? error.code : 'git_command_failed';
1006
1050
  }
1051
+ /** The workspace checkout, prepared so this process and the development user can both write it. */
1052
+ async function gitWorkspaceRoot(state, operation) {
1053
+ const root = await workspaceRoot(state, operation.workspaceId, operation.directoryName);
1054
+ await shareWorkspaceGit(state, root);
1055
+ return root;
1056
+ }
1007
1057
  function parameterPatchError(error) {
1008
1058
  if (error instanceof ParameterEditError)
1009
1059
  return error.code;
@@ -44,6 +44,7 @@ export async function readWorkspaceGitStatus(root) {
44
44
  ahead = Number.parseInt(right ?? '0', 10) || 0;
45
45
  }
46
46
  const porcelain = await git(rootReal, ['status', '--porcelain=v1', '--untracked-files=all', '-z']);
47
+ const merge = await mergeState(rootReal);
47
48
  // Line counts are a convenience; a repository without commits still lists its files.
48
49
  const [staged, unstaged] = await Promise.all([
49
50
  gitAllowFail(rootReal, ['diff', '--cached', '--numstat', '-z']),
@@ -57,6 +58,7 @@ export async function readWorkspaceGitStatus(root) {
57
58
  ahead,
58
59
  behind,
59
60
  hasUpstream: upstream.code === 0,
61
+ ...merge,
60
62
  files: parsePorcelain(porcelain).map((entry) => ({
61
63
  ...entry,
62
64
  ...(stagedCounts.has(entry.path) ? { staged: stagedCounts.get(entry.path) } : {}),
@@ -131,7 +133,8 @@ export async function diffWorkspaceTrees(root, before, after) {
131
133
  export async function readWorkspaceGitFile(root, path, ref) {
132
134
  const rootReal = await ensureWorkspaceGit(root);
133
135
  assertWorkspacePaths([path]);
134
- const object = ref === 'INDEX' ? `:${path}` : `${ref}:${path}`;
136
+ const stage = { INDEX: 0, MERGE_BASE: 1, MERGE_OURS: 2, MERGE_THEIRS: 3 }[ref];
137
+ const object = stage === undefined ? `${ref}:${path}` : `:${stage}:${path}`;
135
138
  const absent = gitFileContentSchema.parse({ path, ref, exists: false, binary: false, content: '' });
136
139
  const size = await gitAllowFail(rootReal, ['cat-file', '-s', object]);
137
140
  if (size.code !== 0)
@@ -271,6 +274,9 @@ function pushRejection(stdout) {
271
274
  export async function commitWorkspaceGit(root, payload, boundBranch) {
272
275
  const rootReal = await ensureWorkspaceGit(root);
273
276
  await assertBoundBranch(rootReal, boundBranch);
277
+ // Adding a conflicted file would mark it resolved with its conflict markers in it.
278
+ if ((await unmergedPaths(rootReal)).length)
279
+ throw new GitWorkspaceError('git_conflicts_unresolved', 'unmerged');
274
280
  if (payload.stagedOnly) {
275
281
  // The index is the commit; nothing is added.
276
282
  }
@@ -334,6 +340,10 @@ export function isCredentialFile(path) {
334
340
  export async function saveWorkspaceGit(root, input) {
335
341
  const rootReal = await ensureWorkspaceGit(root);
336
342
  await assertBoundBranch(rootReal, input.boundBranch);
343
+ // A save during a merge would stage conflict markers as resolutions and conclude the merge. The
344
+ // user (or an agent) finishes the merge with an explicit commit; saves resume after it.
345
+ if ((await mergeState(rootReal)).merging)
346
+ return { pushed: false, withheld: [] };
337
347
  await git(rootReal, ['add', '-A'], 60000);
338
348
  const staged = (await git(rootReal, ['diff', '--cached', '--name-only', '-z'], 60000)).split('\0').filter(Boolean);
339
349
  const withheld = staged.filter(isCredentialFile);
@@ -353,6 +363,68 @@ export async function saveWorkspaceGit(root, input) {
353
363
  await recordPushedHead(rootReal);
354
364
  return { ...(commit ? { commit } : {}), pushed: true, withheld };
355
365
  }
366
+ /**
367
+ * `git pull` with a merge, as VS Code runs it: fetch the bound branch from origin, then merge it.
368
+ * Uncommitted work is kept unless the incoming commits change the same files, in which case Git
369
+ * refuses before touching anything. Conflicts leave the merge in progress for the user to resolve.
370
+ */
371
+ export async function pullWorkspaceGit(root, boundBranch) {
372
+ const rootReal = await ensureWorkspaceGit(root);
373
+ await assertBoundBranch(rootReal, boundBranch);
374
+ if ((await mergeState(rootReal)).merging)
375
+ throw new GitWorkspaceError('git_merge_in_progress', 'merge_in_progress');
376
+ if ((await gitAllowFail(rootReal, ['remote', 'get-url', 'origin'])).code !== 0) {
377
+ throw new GitWorkspaceError('remote_not_configured', 'origin');
378
+ }
379
+ const branch = (await gitAllowFail(rootReal, ['symbolic-ref', '--quiet', '--short', 'HEAD'])).stdout.trim();
380
+ const born = (await gitAllowFail(rootReal, ['rev-parse', '-q', '--verify', 'HEAD^{commit}'])).code === 0;
381
+ if (!branch || !born)
382
+ throw new GitWorkspaceError('git_command_failed', 'git_branch_required');
383
+ // A fork clone has no fetch refspec for its own branch, so the tracking ref is named here.
384
+ const remoteRef = `refs/remotes/origin/${branch}`;
385
+ const fetched = await gitAllowFail(rootReal, ['fetch', '--no-tags', 'origin', `+refs/heads/${branch}:${remoteRef}`], 90000);
386
+ if (fetched.code !== 0)
387
+ throw new GitWorkspaceError(httpRefusal(fetched.stderr) ?? 'git_fetch_failed', 'git_fetch_failed');
388
+ if ((await gitAllowFail(rootReal, ['merge-base', '--is-ancestor', remoteRef, 'HEAD'])).code === 0) {
389
+ return { outcome: 'up_to_date', conflicts: [] };
390
+ }
391
+ // The short name gives the merge commit the message `git pull` would write.
392
+ const merged = await gitAllowFail(rootReal, [...(await commitIdentity(rootReal)), 'merge', '--no-edit', `origin/${branch}`], 60000);
393
+ if (merged.code === 0)
394
+ return { outcome: 'merged', conflicts: [] };
395
+ const conflicts = await unmergedPaths(rootReal);
396
+ if (conflicts.length)
397
+ return { outcome: 'conflicts', conflicts };
398
+ if (/would be overwritten by merge|untracked working tree files would be (?:overwritten|removed)/i.test(`${merged.stdout}\n${merged.stderr}`)) {
399
+ throw new GitWorkspaceError('git_pull_local_changes', 'local_changes');
400
+ }
401
+ throw new GitWorkspaceError('git_command_failed', merged.stderr.trim() || 'git_merge_failed');
402
+ }
403
+ /** `git merge --abort`: the work tree and index return to how they were before the merge began. */
404
+ export async function abortWorkspaceMerge(root) {
405
+ const rootReal = await ensureWorkspaceGit(root);
406
+ if (!(await mergeState(rootReal)).merging)
407
+ throw new GitWorkspaceError('git_merge_not_in_progress', 'no_merge');
408
+ await git(rootReal, ['merge', '--abort'], 60000);
409
+ }
410
+ /** Paths with unmerged index entries, that is, conflicts not yet marked resolved. */
411
+ async function unmergedPaths(rootReal) {
412
+ const listed = await gitAllowFail(rootReal, ['diff', '--name-only', '--diff-filter=U', '-z']);
413
+ return listed.code === 0 ? listed.stdout.split('\0').filter(Boolean) : [];
414
+ }
415
+ async function mergeState(rootReal) {
416
+ try {
417
+ await stat(join(rootReal, '.git', 'MERGE_HEAD'));
418
+ }
419
+ catch (error) {
420
+ if (error.code === 'ENOENT')
421
+ return {};
422
+ throw error;
423
+ }
424
+ const message = await readFile(join(rootReal, '.git', 'MERGE_MSG'), 'utf8').catch(() => '');
425
+ const text = message.split('\n').filter((line) => !line.startsWith('#')).join('\n').trim().slice(0, 4096);
426
+ return { merging: true, ...(text ? { mergeMessage: text } : {}) };
427
+ }
356
428
  /**
357
429
  * Connects a standalone checkout to the repository it was just given, keeping all its history and
358
430
  * uncommitted work. The new repository holds one empty commit on `branch`; it becomes the base of an
@@ -443,6 +515,18 @@ export async function ensureWorkspaceGit(root) {
443
515
  await excludeFromGit(rootReal, '/.tmp/user_uploads/');
444
516
  return rootReal;
445
517
  }
518
+ /**
519
+ * The control user and the development user (terminal, Codex) both write this repository. Git
520
+ * creates directories with the writer's umask, so a directory one user made can refuse the
521
+ * other's objects. With `core.sharedRepository=group` Git makes new directories group-writable for
522
+ * both; the image puts the control user in the development group.
523
+ */
524
+ export async function shareWorkspaceRepository(root) {
525
+ const rootReal = await existingWorkspaceGit(root);
526
+ const current = await gitAllowFail(rootReal, ['config', '--get', 'core.sharedRepository']);
527
+ if (current.stdout.trim() !== 'group')
528
+ await git(rootReal, ['config', 'core.sharedRepository', 'group']);
529
+ }
446
530
  /** Turn snapshots never create a repository; a directory without one simply has no turn changes. */
447
531
  async function existingWorkspaceGit(root) {
448
532
  const rootReal = await realpath(root);
@@ -0,0 +1,40 @@
1
+ import { lstat, readdir } from 'node:fs/promises';
2
+ import { join } from 'node:path';
3
+ import { launcherSocket, runDeveloperCommand } from './developer-command.js';
4
+ import { shareWorkspaceRepository } from './git.js';
5
+ /**
6
+ * Directories under `.git` this process cannot write: another user created them without group
7
+ * write. Only their owner can change that, so they are reported rather than fixed here.
8
+ */
9
+ export async function foreignUnwritableGitDirectories(root) {
10
+ const uid = typeof process.getuid === 'function' ? process.getuid() : undefined;
11
+ const found = [];
12
+ const visit = async (path) => {
13
+ const stat = await lstat(path).catch(() => undefined);
14
+ if (!stat?.isDirectory())
15
+ return;
16
+ if (stat.uid !== uid && (stat.mode & 0o020) === 0)
17
+ found.push(path);
18
+ for (const entry of await readdir(path).catch(() => []))
19
+ await visit(join(path, entry));
20
+ };
21
+ await visit(join(root, '.git'));
22
+ return found;
23
+ }
24
+ /**
25
+ * Prepares a workspace repository for a Git write by this process: new directories become group
26
+ * writable, and directories the development user already made without group write are opened by
27
+ * that user through the launcher. Without a launcher (local runs) both users are the same.
28
+ */
29
+ export async function shareWorkspaceGit(state, root) {
30
+ await shareWorkspaceRepository(root).catch(() => undefined);
31
+ if (!launcherSocket(state) || !(await foreignUnwritableGitDirectories(root)).length)
32
+ return;
33
+ await runDeveloperCommand(state, {
34
+ processId: `git-share-${Date.now().toString(36)}`,
35
+ cwd: root,
36
+ command: 'find .git -type d -uid "$(id -u)" ! -perm -g+w -exec chmod g+ws {} +',
37
+ timeoutMs: 30_000,
38
+ onOutput: () => undefined,
39
+ }).catch(() => undefined);
40
+ }
@@ -1,5 +1,6 @@
1
1
  import { TURN_FILES_CHANGED_EVENT, } from '@playcraft/common/cloud-workspace/runtime';
2
2
  import { diffWorkspaceTrees, retainTurnSnapshots, snapshotWorkspaceTree } from './git.js';
3
+ import { shareWorkspaceGit } from './shared-git.js';
3
4
  const TURN_ENDS = new Set(['turn/completed', 'turn.completed', 'turn.interrupted', 'turn.failed']);
4
5
  /**
5
6
  * Snapshots taken when a session's turn starts, until that turn ends. Kept in memory only: a
@@ -20,6 +21,7 @@ export async function beginTurnChanges(state, sessionId, input) {
20
21
  turnsOf(state).delete(sessionId);
21
22
  if (input.directoryName === 'root')
22
23
  return;
24
+ await shareWorkspaceGit(state, input.root).catch(() => undefined);
23
25
  const before = await snapshotWorkspaceTree(input.root).catch(() => undefined);
24
26
  if (before)
25
27
  turnsOf(state).set(sessionId, { ...input, before });
@@ -42,6 +44,7 @@ export async function finishTurnChanges(state, event) {
42
44
  return [];
43
45
  turnsOf(state).delete(session.sessionId);
44
46
  try {
47
+ await shareWorkspaceGit(state, start.root);
45
48
  const after = await snapshotWorkspaceTree(start.root);
46
49
  const { files, truncated } = await diffWorkspaceTrees(start.root, start.before, after);
47
50
  if (!files.length)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@playcraft/cli",
3
- "version": "0.1.32",
3
+ "version": "0.1.34",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "engines": {
@@ -50,8 +50,8 @@
50
50
  "@gltf-transform/core": "^4.3.0",
51
51
  "@gltf-transform/extensions": "^4.3.0",
52
52
  "@gltf-transform/functions": "^4.3.0",
53
- "@playcraft/build": "^0.1.32",
54
- "@playcraft/common": "^0.1.32",
53
+ "@playcraft/build": "^0.1.34",
54
+ "@playcraft/common": "^0.1.34",
55
55
  "better-sqlite3-pinned": "npm:better-sqlite3@13.0.3",
56
56
  "chokidar": "^4.0.3",
57
57
  "commander": "^13.1.0",