@playcraft/cli 0.1.33 → 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,6 +1,7 @@
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
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';
@@ -789,7 +790,7 @@ async function executeGitCommit(state, scopeKey, operation, payload) {
789
790
  return persist(state, scopeKey, transitionOperation(operation, 'failed', { error: 'invalid_git_commit' }));
790
791
  }
791
792
  try {
792
- 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));
793
794
  const accepted = await persist(state, scopeKey, transitionOperation(operation, 'accepted'));
794
795
  await recordNativeEvents(state, accepted, [
795
796
  {
@@ -826,7 +827,7 @@ async function executeWorkspaceSave(state, scopeKey, operation, payload) {
826
827
  }
827
828
  const binding = operation.workspaceId ? state.journal.getWorkspace(operation.workspaceId) : undefined;
828
829
  try {
829
- const saved = await saveWorkspaceGit(await workspaceRoot(state, operation.workspaceId, operation.directoryName), {
830
+ const saved = await saveWorkspaceGit(await gitWorkspaceRoot(state, operation), {
830
831
  message: parsed.data.trigger === 'auto' ? 'Autosave workspace' : 'Save workspace',
831
832
  push: savePushesBranch(binding?.targetKind, operation.directoryName),
832
833
  boundBranch: boundBranch(state, operation),
@@ -851,7 +852,7 @@ async function executeGitPush(state, scopeKey, operation, payload) {
851
852
  return persist(state, scopeKey, transitionOperation(operation, 'failed', { error: 'remote_not_configurable' }));
852
853
  }
853
854
  try {
854
- await pushWorkspaceGit(await workspaceRoot(state, operation.workspaceId, operation.directoryName), boundBranch(state, operation));
855
+ await pushWorkspaceGit(await gitWorkspaceRoot(state, operation), boundBranch(state, operation));
855
856
  const accepted = await persist(state, scopeKey, transitionOperation(operation, 'accepted'));
856
857
  await recordNativeEvents(state, accepted, [
857
858
  {
@@ -872,7 +873,7 @@ async function executeGitPull(state, scopeKey, operation, payload) {
872
873
  return persist(state, scopeKey, transitionOperation(operation, 'failed', { error: 'remote_not_configurable' }));
873
874
  }
874
875
  try {
875
- const pulled = await pullWorkspaceGit(await workspaceRoot(state, operation.workspaceId, operation.directoryName), boundBranch(state, operation));
876
+ const pulled = await pullWorkspaceGit(await gitWorkspaceRoot(state, operation), boundBranch(state, operation));
876
877
  const accepted = await persist(state, scopeKey, transitionOperation(operation, 'accepted'));
877
878
  await recordNativeEvents(state, accepted, [{
878
879
  type: 'git.pulled',
@@ -890,7 +891,7 @@ async function executeGitMergeAbort(state, scopeKey, operation, payload) {
890
891
  return persist(state, scopeKey, transitionOperation(operation, 'failed', { error: 'invalid_git_merge_abort' }));
891
892
  }
892
893
  try {
893
- await abortWorkspaceMerge(await workspaceRoot(state, operation.workspaceId, operation.directoryName));
894
+ await abortWorkspaceMerge(await gitWorkspaceRoot(state, operation));
894
895
  const accepted = await persist(state, scopeKey, transitionOperation(operation, 'accepted'));
895
896
  await recordNativeEvents(state, accepted, [{ type: 'git.merge.aborted', payload: {}, occurredAt: new Date().toISOString() }]);
896
897
  return persist(state, scopeKey, transitionOperation(accepted, 'completed'));
@@ -912,7 +913,7 @@ async function executeGitIndex(state, scopeKey, operation, payload) {
912
913
  return persist(state, scopeKey, transitionOperation(operation, 'failed', { error: 'invalid_git_paths' }));
913
914
  }
914
915
  try {
915
- await change.run(await workspaceRoot(state, operation.workspaceId, operation.directoryName), parsed.data);
916
+ await change.run(await gitWorkspaceRoot(state, operation), parsed.data);
916
917
  const accepted = await persist(state, scopeKey, transitionOperation(operation, 'accepted'));
917
918
  await recordNativeEvents(state, accepted, [
918
919
  { type: change.event, payload: { paths: parsed.data.paths }, occurredAt: new Date().toISOString() },
@@ -1043,8 +1044,16 @@ function boundBranch(state, operation) {
1043
1044
  : binding.branchName ?? undefined;
1044
1045
  }
1045
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);
1046
1049
  return error instanceof GitWorkspaceError ? error.code : 'git_command_failed';
1047
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
+ }
1048
1057
  function parameterPatchError(error) {
1049
1058
  if (error instanceof ParameterEditError)
1050
1059
  return error.code;
@@ -515,6 +515,18 @@ export async function ensureWorkspaceGit(root) {
515
515
  await excludeFromGit(rootReal, '/.tmp/user_uploads/');
516
516
  return rootReal;
517
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
+ }
518
530
  /** Turn snapshots never create a repository; a directory without one simply has no turn changes. */
519
531
  async function existingWorkspaceGit(root) {
520
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.33",
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.33",
54
- "@playcraft/common": "^0.1.33",
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",