@playcraft/cli 0.1.57-beta → 0.1.59-beta

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 (89) hide show
  1. package/dist/commands/workspace.js +43 -0
  2. package/dist/utils/agent-generation-provenance.js +1 -1
  3. package/dist/workspace/messages.js +10 -0
  4. package/dist/workspace-runtime/batch/deliver.js +140 -80
  5. package/dist/workspace-runtime/batch/freeze.js +18 -6
  6. package/dist/workspace-runtime/batch/generate.js +68 -64
  7. package/dist/workspace-runtime/batch/policy.js +71 -22
  8. package/dist/workspace-runtime/batch/prepare.js +108 -93
  9. package/dist/workspace-runtime/batch/records.js +4 -0
  10. package/dist/workspace-runtime/batch/routes.js +10 -2
  11. package/dist/workspace-runtime/batch/source-candidate.js +2 -0
  12. package/dist/workspace-runtime/batch/source-capture.js +1 -0
  13. package/dist/workspace-runtime/batch/source-failure.js +2 -0
  14. package/dist/workspace-runtime/batch/source-publish.js +3 -0
  15. package/dist/workspace-runtime/batch/writer-proof.js +37 -0
  16. package/dist/workspace-runtime/codex/app-server.js +278 -20
  17. package/dist/workspace-runtime/codex/config-toml.js +4 -0
  18. package/dist/workspace-runtime/codex/delivery-tools.js +1 -1
  19. package/dist/workspace-runtime/codex/generation-provenance-broker.js +17 -8
  20. package/dist/workspace-runtime/codex/loopback.js +2 -0
  21. package/dist/workspace-runtime/codex/model-broker.js +7 -4
  22. package/dist/workspace-runtime/persistence/journal.js +34 -0
  23. package/dist/workspace-runtime/persistence/sqlite-worker.js +68 -6
  24. package/dist/workspace-runtime/previews/output.js +2 -0
  25. package/dist/workspace-runtime/previews/publish.js +20 -5
  26. package/dist/workspace-runtime/previews/runtime-preview.js +4 -0
  27. package/dist/workspace-runtime/processes/managed-writes.js +47 -5
  28. package/dist/workspace-runtime/serve.js +13 -3
  29. package/dist/workspace-runtime/server/accept-operation.js +22 -2
  30. package/dist/workspace-runtime/server/directory-access.js +414 -0
  31. package/dist/workspace-runtime/server/dispatch.js +74 -29
  32. package/dist/workspace-runtime/server/endpoints.js +1 -0
  33. package/dist/workspace-runtime/server/http.js +80 -10
  34. package/dist/workspace-runtime/server/operation-record.js +2 -0
  35. package/dist/workspace-runtime/server/terminal-host.js +124 -8
  36. package/dist/workspace-runtime/server/workspace-storage.js +193 -0
  37. package/dist/workspace-runtime/server/workspace-transfer.js +3 -1
  38. package/dist/workspace-runtime/workspaces/append-only-log.js +3 -0
  39. package/dist/workspace-runtime/workspaces/artifact-events.js +5 -2
  40. package/dist/workspace-runtime/workspaces/artifact-files.js +14 -8
  41. package/dist/workspace-runtime/workspaces/artifact-receipts.js +7 -25
  42. package/dist/workspace-runtime/workspaces/artifacts.js +4 -1
  43. package/dist/workspace-runtime/workspaces/asset-library-import.js +9 -4
  44. package/dist/workspace-runtime/workspaces/asset-publication-access.js +7 -9
  45. package/dist/workspace-runtime/workspaces/asset-transfer-publish.js +3 -0
  46. package/dist/workspace-runtime/workspaces/asset-transfer-storage.js +17 -2
  47. package/dist/workspace-runtime/workspaces/attachments.js +9 -0
  48. package/dist/workspace-runtime/workspaces/batch-commit.js +5 -3
  49. package/dist/workspace-runtime/workspaces/binary-write.js +12 -7
  50. package/dist/workspace-runtime/workspaces/cli-context.js +2 -0
  51. package/dist/workspace-runtime/workspaces/cli-credentials.js +1 -0
  52. package/dist/workspace-runtime/workspaces/delivery-build.js +30 -11
  53. package/dist/workspace-runtime/workspaces/delivery-store.js +2 -0
  54. package/dist/workspace-runtime/workspaces/dependencies.js +90 -70
  55. package/dist/workspace-runtime/workspaces/developer-command.js +12 -1
  56. package/dist/workspace-runtime/workspaces/directory-access.js +13 -18
  57. package/dist/workspace-runtime/workspaces/directory-attachments.js +8 -0
  58. package/dist/workspace-runtime/workspaces/directory-create.js +18 -5
  59. package/dist/workspace-runtime/workspaces/directory-maintenance.js +46 -35
  60. package/dist/workspace-runtime/workspaces/directory-mutations.js +8 -3
  61. package/dist/workspace-runtime/workspaces/files.js +4 -0
  62. package/dist/workspace-runtime/workspaces/git-credential-broker.js +3 -0
  63. package/dist/workspace-runtime/workspaces/git-direct-store.js +2 -0
  64. package/dist/workspace-runtime/workspaces/json5-edit.js +36 -0
  65. package/dist/workspace-runtime/workspaces/library-attachments.js +4 -0
  66. package/dist/workspace-runtime/workspaces/parameter-agent-protocol.js +2 -0
  67. package/dist/workspace-runtime/workspaces/parameter-agent-store.js +2 -0
  68. package/dist/workspace-runtime/workspaces/parameter-agent.js +40 -11
  69. package/dist/workspace-runtime/workspaces/parameter-assets.js +1 -0
  70. package/dist/workspace-runtime/workspaces/parameter-initialization-skill-transaction.js +141 -0
  71. package/dist/workspace-runtime/workspaces/parameter-initialization-skill.js +329 -0
  72. package/dist/workspace-runtime/workspaces/parameter-initialization.js +63 -0
  73. package/dist/workspace-runtime/workspaces/parameter-production.js +124 -0
  74. package/dist/workspace-runtime/workspaces/parameters.js +95 -3
  75. package/dist/workspace-runtime/workspaces/platform-export.js +5 -0
  76. package/dist/workspace-runtime/workspaces/prepare.js +4 -0
  77. package/dist/workspace-runtime/workspaces/registry.js +55 -3
  78. package/dist/workspace-runtime/workspaces/remix-references.js +5 -0
  79. package/dist/workspace-runtime/workspaces/shared-write.js +187 -0
  80. package/dist/workspace-runtime/workspaces/skills.js +14 -15
  81. package/dist/workspace-runtime/workspaces/storage-snapshot.js +235 -0
  82. package/dist/workspace-runtime/workspaces/transfer.js +4 -1
  83. package/dist/workspace-runtime/workspaces/workspace-shares.js +235 -0
  84. package/dist/workspace-runtime/workspaces/worktree-attachments.js +2 -0
  85. package/dist/workspace-runtime/workspaces/worktree-checkout.js +11 -6
  86. package/package.json +5 -4
  87. package/project-template/.claude/skills/playcraft-runtime-parameters/SKILL.md +32 -0
  88. package/project-template/.claude/skills/playcraft-runtime-parameters/references/platform-protocol.md +21 -0
  89. package/project-template/.claude/skills/playcraft-runtime-parameters/references/preview-integration.md +57 -0
@@ -6,6 +6,7 @@ import sharp from 'sharp';
6
6
  import { WORKSPACE_ARTIFACT_OUTPUT_DIRECTORY, isWorkspaceArtifactOutputPath, isWorkspaceArtifactOutputVersion, workspaceArtifactContentQuerySchema, workspaceArtifactListSchema, } from '@playcraft/common/cloud-workspace/runtime';
7
7
  import { artifactBundleDigest, artifactDirectory, artifactFormat, artifactPath, artifactResources, snapshotArtifactBundle, snapshotArtifactFile } from './artifact-files.js';
8
8
  import { ARTIFACT_RECEIPTS_DIRECTORY, readArtifactReceipt } from './artifact-receipts.js';
9
+ import { ensureSharedDirectory } from './shared-write.js';
9
10
  import { validateWorkspacePlayable } from '../previews/finalize.js';
10
11
  import { PREVIEW_HTML_MAX_BYTES, WORKSPACE_PLAYABLE_OUTPUT_PATH } from '@playcraft/common/cloud-workspace/runtime';
11
12
  const serial = new Map();
@@ -35,6 +36,7 @@ export class WorkspaceArtifactStore {
35
36
  }
36
37
  get scopeId() { return this.directoryName === 'basic' ? this.workspaceId : `${this.workspaceId}:${this.directoryName}`; }
37
38
  async directory() {
39
+ // private
38
40
  await mkdir(this.stateDir, { recursive: true, mode: 0o700 });
39
41
  return artifactDirectory(this.stateDir, `artifacts/${digest(this.scopeId)}`);
40
42
  }
@@ -64,6 +66,7 @@ export class WorkspaceArtifactStore {
64
66
  }
65
67
  async write(directory, catalog) {
66
68
  const temporary = join(directory, `${randomUUID()}.tmp`);
69
+ // private
67
70
  const handle = await open(temporary, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, 0o600);
68
71
  try {
69
72
  await handle.writeFile(JSON.stringify(catalog));
@@ -281,7 +284,7 @@ export class WorkspaceArtifactStore {
281
284
  const target = join(root, ARTIFACT_RECEIPTS_DIRECTORY);
282
285
  if (!(await lstat(target)).isDirectory())
283
286
  return [];
284
- directory = await artifactDirectory(root, ARTIFACT_RECEIPTS_DIRECTORY);
287
+ directory = await ensureSharedDirectory(root, target);
285
288
  }
286
289
  catch {
287
290
  return [];
@@ -5,6 +5,7 @@ import { basename, dirname, extname, join, relative } from 'node:path';
5
5
  import { BINARY_WORKSPACE_FILE_MAX_BYTES, WORKSPACE_LIBRARY_ATTACHMENT_MAX_BYTES, WORKSPACE_LIBRARY_ATTACHMENT_MAX_FILES, workspaceAssetLibraryImportPayloadSchema, workspaceAssetLibraryImportResultSchema, workspaceAssetLibraryImportTransportSchema, workspaceRelativePathSchema, } from '@playcraft/common/cloud-workspace/runtime';
6
6
  import { observeWorkspaceBinaryFile, writeWorkspaceBinaryFile } from './binary-write.js';
7
7
  import { shareWorkspaceAssetDirectory } from './asset-publication-access.js';
8
+ import { ensureSharedDirectory } from './shared-write.js';
8
9
  const RESERVED = new Set(['.git', 'node_modules', '.playcraft-temp', '.tmp']);
9
10
  const digest = (value) => createHash('sha256').update(value).digest('hex');
10
11
  const missing = (error) => error?.code === 'ENOENT';
@@ -23,6 +24,7 @@ async function safeDirectory(root, path, create = false) {
23
24
  let current = root;
24
25
  for (const part of path.split('/').filter(Boolean)) {
25
26
  current = join(current, part);
27
+ // private
26
28
  if (create)
27
29
  await mkdir(current, { mode: 0o700 }).catch((error) => {
28
30
  if (error.code !== 'EEXIST')
@@ -47,8 +49,7 @@ async function createPublicDirectory(root, path) {
47
49
  if (!missing(error))
48
50
  throw error;
49
51
  await safeDirectory(root, parent);
50
- await mkdir(join(root, current), { mode: 0o700 });
51
- await shareWorkspaceAssetDirectory(root, join(root, current));
52
+ await ensureSharedDirectory(root, join(root, current));
52
53
  await safeDirectory(root, current);
53
54
  }
54
55
  }
@@ -127,6 +128,7 @@ async function syncDirectory(path) {
127
128
  async function saveTransaction(root, transaction, value) {
128
129
  await safeDirectory(root, relative(root, transaction));
129
130
  const temporary = join(transaction, 'manifest.next');
131
+ // private
130
132
  const handle = await open(temporary, constants.O_WRONLY | constants.O_CREAT | constants.O_TRUNC | constants.O_NOFOLLOW, 0o600);
131
133
  try {
132
134
  await handle.writeFile(JSON.stringify(value));
@@ -181,6 +183,8 @@ export async function recoverInterruptedLibraryImports(root) {
181
183
  return result;
182
184
  throw error;
183
185
  }
186
+ // The shared scratch root is where both identities create their own private transactions.
187
+ await ensureSharedDirectory(rootReal, scratch);
184
188
  for (const entry of await readdir(scratch, { withFileTypes: true })) {
185
189
  if (!entry.isDirectory() || !entry.name.startsWith('library-import-'))
186
190
  continue;
@@ -239,9 +243,10 @@ async function publishBundle(root, target, asset, replace, assertCurrent, operat
239
243
  if (previous && (!replace || !previous.isDirectory() || previous.isSymbolicLink()))
240
244
  throw new AssetLibraryImportError('content_conflict');
241
245
  const originalHash = previous ? await directorySnapshot(join(root, target)) : undefined;
242
- const scratch = await safeDirectory(root, '.playcraft-temp', true);
246
+ const scratch = await ensureSharedDirectory(root, join(root, '.playcraft-temp'));
243
247
  const transaction = await mkdtemp(join(scratch, 'library-import-'));
244
248
  const staged = join(transaction, 'incoming'), backup = join(transaction, 'previous');
249
+ // private
245
250
  await mkdir(staged, { mode: 0o700 });
246
251
  let moved = false, published = false, preserve = false;
247
252
  let reservation;
@@ -271,7 +276,7 @@ async function publishBundle(root, target, asset, replace, assertCurrent, operat
271
276
  }
272
277
  else if (current)
273
278
  throw new AssetLibraryImportError('content_conflict');
274
- await mkdir(join(root, target), { mode: 0o700 });
279
+ await ensureSharedDirectory(root, join(root, target));
275
280
  reservation = await lstat(join(root, target));
276
281
  await saveTransaction(root, transaction, { ...manifest, phase: 'reserved', reservation: { dev: reservation.dev, ino: reservation.ino } });
277
282
  await assertCurrent();
@@ -1,19 +1,17 @@
1
1
  import { constants } from 'node:fs';
2
2
  import { lstat, open } from 'node:fs/promises';
3
+ import { SHARED_DIRECTORY_MODE, SHARED_FILE_MODE, shareHandleWithGroup, } from './shared-write.js';
4
+ /**
5
+ * Published workspace assets belong to the development group so both identities can read and edit
6
+ * them. Private transfers, chunks, transaction records and backups never use these helpers.
7
+ */
3
8
  async function shareWithWorkspaceGroup(root, handle, directory) {
4
9
  const workspace = await lstat(root), target = await handle.stat();
5
10
  if (!workspace.isDirectory() || workspace.isSymbolicLink()
6
11
  || (directory ? !target.isDirectory() : !target.isFile() || target.nlink !== 1))
7
12
  throw new Error('invalid_workspace_asset');
8
- const shared = directory ? 0o2770 : 0o660;
9
- // Already shared: the other identity owns this entry and cannot re-shape it from here.
10
- if (target.gid === workspace.gid && (target.mode & 0o7777) === shared)
11
- return;
12
- if (target.gid !== workspace.gid)
13
- await handle.chown(target.uid, workspace.gid);
14
- // Match directory-access.ts: the development group can read and edit public project files.
15
- // Private transfers, chunks, transaction records and backups never use these helpers.
16
- await handle.chmod(shared);
13
+ // Already shared or owned by the other identity: shaping is a no-op, never a cross-owner EPERM.
14
+ await shareHandleWithGroup(handle, workspace.gid, directory ? SHARED_DIRECTORY_MODE : SHARED_FILE_MODE);
17
15
  }
18
16
  export async function shareWorkspaceAssetFile(root, handle) {
19
17
  await shareWithWorkspaceGroup(root, handle, false);
@@ -40,6 +40,7 @@ async function assemble(transfer) {
40
40
  const staged = [];
41
41
  for (const [index, file] of transfer.record.manifest.files.entries()) {
42
42
  const destination = join(transfer.directory, 'incoming', String(index));
43
+ // private
43
44
  const output = await open(destination, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, 0o600);
44
45
  const hash = createHash('sha256');
45
46
  try {
@@ -120,6 +121,7 @@ async function createDirectories(transfer, path) {
120
121
  if (!assetTransferMissing(error))
121
122
  throw error;
122
123
  await safeAssetTransferDirectory(transfer.root, dirname(directory) === '.' ? '' : dirname(directory));
124
+ // private
123
125
  await mkdir(join(transfer.root, directory), { mode: 0o700 });
124
126
  await shareWorkspaceAssetDirectory(transfer.root, join(transfer.root, directory));
125
127
  const stat = await lstat(await safeAssetTransferDirectory(transfer.root, directory));
@@ -146,6 +148,7 @@ async function ensureDirectoryMarkers(transfer) {
146
148
  const parent = await safeAssetTransferDirectory(transfer.root, path);
147
149
  const marker = join(parent, '.gitkeep');
148
150
  try {
151
+ // private
149
152
  const handle = await open(marker, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, 0o600);
150
153
  try {
151
154
  await shareWorkspaceAssetFile(transfer.root, handle);
@@ -1,8 +1,9 @@
1
1
  import { createHash, randomUUID } from 'node:crypto';
2
2
  import { constants } from 'node:fs';
3
3
  import { link, lstat, mkdir, open, readdir, realpath, rename, rm, unlink } from 'node:fs/promises';
4
- import { join } from 'node:path';
4
+ import { join, relative } from 'node:path';
5
5
  import { ASSET_TRANSFER_BODY_MAX_BYTES, ASSET_TRANSFER_CHUNK_BYTES, ASSET_TRANSFER_FILE_MAX_BYTES, assetBundleReceiptSchema, assetTransferChunkSchema, assetTransferManifestSchema, assetTransferStatusSchema, hashAssetTransferManifest, isAssetBundleReceiptForManifest, } from '@playcraft/common/cloud-workspace/runtime';
6
+ import { ensureSharedDirectory } from './shared-write.js';
6
7
  export class AssetTransferError extends Error {
7
8
  code;
8
9
  status;
@@ -27,9 +28,19 @@ export async function safeAssetTransferDirectory(root, path = '', create = false
27
28
  if (part === '.' || part === '..' || /[\\:\x00-\x1f\x7f]/.test(part))
28
29
  failPath();
29
30
  current = join(current, part);
30
- if (create)
31
+ // The scratch root is shared by both identities; each transaction below it stays private.
32
+ if (create && relative(root, current) === '.playcraft-temp') {
33
+ await ensureSharedDirectory(root, current).catch(error => {
34
+ if (error.message === 'invalid_shared_directory')
35
+ failPath();
36
+ throw error;
37
+ });
38
+ }
39
+ else if (create) {
40
+ // private
31
41
  await mkdir(current, { mode: 0o700 }).catch(error => { if (error.code !== 'EEXIST')
32
42
  throw error; });
43
+ }
33
44
  const stat = await lstat(current);
34
45
  if (!stat.isDirectory() || stat.isSymbolicLink() || await realpath(current) !== current)
35
46
  failPath();
@@ -93,6 +104,7 @@ export async function hashAssetTransferFile(path, limit = ASSET_TRANSFER_FILE_MA
93
104
  export async function saveAssetTransfer(transfer) {
94
105
  await safeAssetTransferDirectory(transfer.directory);
95
106
  const temporary = join(transfer.directory, `record-${randomUUID()}.tmp`);
107
+ // private
96
108
  const handle = await open(temporary, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, 0o600);
97
109
  try {
98
110
  await handle.writeFile(JSON.stringify(transfer.record));
@@ -194,6 +206,7 @@ export async function withAssetTransfer(root, scope, id, action) {
194
206
  }
195
207
  async function saveProgress(transfer, files) {
196
208
  const temporary = join(transfer.directory, `progress-${randomUUID()}.tmp`);
209
+ // private
197
210
  const handle = await open(temporary, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, 0o600);
198
211
  try {
199
212
  await handle.writeFile(JSON.stringify(files));
@@ -292,6 +305,7 @@ async function createAssetTransferUnlocked(root, scope, input) {
292
305
  const directory = await safeAssetTransferDirectory(rootReal, `.playcraft-temp/asset-transfers/${manifest.transferId}`, true);
293
306
  const record = { version: 1, createdAt: Date.now(), scope, manifest, manifestHash: hashAssetTransferManifest(manifest), status: 'uploading' };
294
307
  const temporary = join(directory, `create-${randomUUID()}.tmp`);
308
+ // private
295
309
  const handle = await open(temporary, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, 0o600);
296
310
  try {
297
311
  await handle.writeFile(JSON.stringify(record));
@@ -366,6 +380,7 @@ export async function putAssetTransferChunk(root, scope, id, input) {
366
380
  throw error;
367
381
  }
368
382
  const temporary = join(directory, `chunk-${randomUUID()}.tmp`);
383
+ // private
369
384
  const handle = await open(temporary, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, 0o600);
370
385
  try {
371
386
  await handle.writeFile(bytes);
@@ -13,6 +13,7 @@ async function attachmentRoot(root) {
13
13
  let directory = root;
14
14
  for (const part of WORKSPACE_USER_UPLOADS_DIRECTORY.split('/')) {
15
15
  directory = join(directory, part);
16
+ // shared
16
17
  await mkdir(directory, { mode: 0o755 }).catch((error) => {
17
18
  if (error.code !== 'EEXIST')
18
19
  throw error;
@@ -62,6 +63,7 @@ export async function uploadWorkspaceAttachment(root, body) {
62
63
  const uploads = await attachmentRoot(root);
63
64
  const directory = join(uploads, attachment.id);
64
65
  const metadataDirectory = join(uploads, '.metadata');
66
+ // shared
65
67
  await mkdir(metadataDirectory, { mode: 0o755 }).catch((error) => {
66
68
  if (error.code !== 'EEXIST')
67
69
  throw error;
@@ -69,8 +71,11 @@ export async function uploadWorkspaceAttachment(root, body) {
69
71
  if (!(await lstat(metadataDirectory)).isDirectory())
70
72
  throw new Error('invalid_attachment');
71
73
  // Codex can run as a different user from the sandbox control process.
74
+ // shared
72
75
  await mkdir(directory, { mode: 0o755 });
76
+ // shared
73
77
  await writeFile(join(directory, attachment.name), bytes, { flag: 'wx', mode: 0o644 });
78
+ // shared
74
79
  await writeFile(join(metadataDirectory, `${attachment.id}.json`), JSON.stringify(attachment), {
75
80
  flag: 'wx',
76
81
  mode: 0o644,
@@ -88,11 +93,14 @@ export async function uploadWorkspaceVideoAttachment(root, request, query) {
88
93
  await excludeFromGit(root, `/${WORKSPACE_USER_UPLOADS_DIRECTORY}/`);
89
94
  const uploads = await attachmentRoot(root);
90
95
  const id = randomUUID(), directory = join(uploads, id), metadataDirectory = join(uploads, '.metadata');
96
+ // shared
91
97
  await mkdir(metadataDirectory, { mode: 0o755 }).catch((error) => { if (error.code !== 'EEXIST')
92
98
  throw error; });
93
99
  if (!(await lstat(metadataDirectory)).isDirectory())
94
100
  throw new Error('invalid_attachment');
101
+ // shared
95
102
  await mkdir(directory, { mode: 0o755 });
103
+ // shared
96
104
  const handle = await open(join(directory, name), constants.O_CREAT | constants.O_EXCL | constants.O_RDWR | constants.O_NOFOLLOW, 0o644);
97
105
  try {
98
106
  let size = 0;
@@ -112,6 +120,7 @@ export async function uploadWorkspaceVideoAttachment(root, request, query) {
112
120
  if (contentType !== media.mimeType && !(contentType === 'video/quicktime' && media.mimeType === 'video/mp4'))
113
121
  throw new Error('invalid_attachment');
114
122
  const attachment = { id, name, size, ...media, sha256: await hashAttachment(handle) };
123
+ // shared
115
124
  await writeFile(join(metadataDirectory, `${id}.json`), JSON.stringify(attachment), { flag: 'wx', mode: 0o644 });
116
125
  return attachment;
117
126
  }
@@ -1,11 +1,12 @@
1
1
  import { execFile } from 'node:child_process';
2
2
  import { constants } from 'node:fs';
3
3
  import { createHash } from 'node:crypto';
4
- import { lstat, mkdir, open, realpath, rm, writeFile } from 'node:fs/promises';
4
+ import { lstat, mkdir, open, realpath, rm } from 'node:fs/promises';
5
5
  import { dirname, isAbsolute, join, relative, sep } from 'node:path';
6
6
  import { promisify } from 'node:util';
7
7
  import { batchCommitSourceVersion } from '@playcraft/common/cloud-workspace/runtime';
8
8
  import { validateProjectSkillsSnapshot } from '@playcraft/common/project-skills';
9
+ import { ensureSharedDirectory, writeSharedFile } from './shared-write.js';
9
10
  const execute = promisify(execFile);
10
11
  const ID = /^[A-Za-z0-9_-]{1,128}$/;
11
12
  const COMMIT = /^(?:[a-f0-9]{40}|[a-f0-9]{64})$/;
@@ -131,8 +132,9 @@ export async function checkoutBatchCommit(input) {
131
132
  if (await lstat(target).catch(() => undefined))
132
133
  continue;
133
134
  const body = await readArtifact(root, path);
134
- await mkdir(dirname(target), { recursive: true });
135
- await writeFile(target, body, { flag: 'wx', mode: 0o600 });
135
+ // The checkout is handed to the development identity, so its archives must be group readable.
136
+ await ensureSharedDirectory(input.destination, dirname(target));
137
+ await writeSharedFile(input.destination, target, body);
136
138
  }
137
139
  }
138
140
  catch (cause) {
@@ -1,12 +1,13 @@
1
1
  import { createHash, randomUUID } from 'node:crypto';
2
2
  import { constants } from 'node:fs';
3
- import { link, lstat, mkdir, open, realpath, rename, unlink } from 'node:fs/promises';
3
+ import { link, lstat, open, realpath, rename, unlink } from 'node:fs/promises';
4
4
  import { basename, join } from 'node:path';
5
5
  import { fileBinaryWritePayloadSchema } from '@playcraft/common/cloud-workspace/runtime';
6
6
  import { artifactPath } from './artifact-files.js';
7
7
  import { sampleDiskFile } from './disk-snapshot.js';
8
8
  import { InvalidWorkspacePathError } from './files.js';
9
- import { shareWorkspaceAssetDirectory, shareWorkspaceAssetFile } from './asset-publication-access.js';
9
+ import { shareWorkspaceAssetFile } from './asset-publication-access.js';
10
+ import { ensureSharedDirectory } from './shared-write.js';
10
11
  export class BinaryWorkspaceWriteError extends Error {
11
12
  code;
12
13
  constructor(code) {
@@ -45,14 +46,17 @@ export async function writeWorkspaceBinaryFile(root, input) {
45
46
  throw new InvalidWorkspacePathError(payload.path);
46
47
  for (const part of parts.slice(0, -1)) {
47
48
  parent = join(parent, part);
48
- let created = false;
49
- await mkdir(parent, { mode: 0o700 }).then(() => { created = true; }).catch(error => { if (error.code !== 'EEXIST')
50
- throw error; });
49
+ const existing = await lstat(parent).catch((error) => {
50
+ if (error.code === 'ENOENT')
51
+ return undefined;
52
+ throw error;
53
+ });
54
+ // Only new parents are shared; an existing directory keeps whatever access its owner chose.
55
+ if (!existing)
56
+ await ensureSharedDirectory(rootReal, parent);
51
57
  const stat = await lstat(parent);
52
58
  if (!stat.isDirectory() || stat.isSymbolicLink())
53
59
  throw new InvalidWorkspacePathError(payload.path);
54
- if (created)
55
- await shareWorkspaceAssetDirectory(rootReal, parent);
56
60
  }
57
61
  const parentStat = await lstat(parent);
58
62
  const target = join(parent, basename(payload.path));
@@ -61,6 +65,7 @@ export async function writeWorkspaceBinaryFile(root, input) {
61
65
  let staged = false;
62
66
  try {
63
67
  await verifyPreconditions(rootReal, payload);
68
+ // shared
64
69
  const handle = await open(temporary, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, 0o600);
65
70
  staged = true;
66
71
  try {
@@ -5,6 +5,7 @@ import { excludeFromGit } from './dependencies.js';
5
5
  /** Non-secret routing hint; the business service still authorizes the selected workspace. */
6
6
  export async function writeWorkspaceCliContext(root, workspaceId) {
7
7
  const management = join(root, '.playcraft');
8
+ // shared
8
9
  await mkdir(management, { mode: 0o755 }).catch(error => { if (error.code !== 'EEXIST')
9
10
  throw error; });
10
11
  const stat = await lstat(management);
@@ -12,6 +13,7 @@ export async function writeWorkspaceCliContext(root, workspaceId) {
12
13
  throw new Error('workspace_skills_invalid');
13
14
  const temporary = join(management, `workspace-context.${randomUUID()}.tmp`);
14
15
  try {
16
+ // shared
15
17
  await writeFile(temporary, `${JSON.stringify({ schemaVersion: 'playcraft.workspace-context.v1', workspaceId })}\n`, { flag: 'wx', mode: 0o644 });
16
18
  await rename(temporary, join(management, 'workspace-context.json'));
17
19
  await excludeFromGit(root, '/.playcraft/workspace-context.json');
@@ -76,6 +76,7 @@ async function renew(state, entry) {
76
76
  await mkdir(config.workspaceRoot, { recursive: true });
77
77
  const temporary = join(config.workspaceRoot, `.playcraft-credential-${randomUUID()}.tmp`);
78
78
  try {
79
+ // private
79
80
  await writeFile(temporary, JSON.stringify({
80
81
  apiUrl: credential.apiUrl,
81
82
  token: credential.token,
@@ -1,15 +1,17 @@
1
1
  import { randomUUID } from 'node:crypto';
2
2
  import { constants } from 'node:fs';
3
- import { copyFile, cp, chmod, lstat, mkdir, mkdtemp, readdir, readlink, rename, rm, stat, symlink } from 'node:fs/promises';
3
+ import { copyFile, cp, lstat, mkdir, mkdtemp, readdir, readlink, rename, rm, stat, symlink } from 'node:fs/promises';
4
4
  import { tmpdir } from 'node:os';
5
5
  import { basename, dirname, join, relative, sep } from 'node:path';
6
6
  import { createPlatformAdapter, getChannelRuleProfile, } from '@playcraft/build';
7
7
  import { DELIVERY_QUEUE_LIMIT, isWorkspaceArtifactOutputPath, } from '@playcraft/common/cloud-workspace/runtime';
8
- import { workspaceRoot } from './registry.js';
8
+ import { revisionScope, workspaceRoot } from './registry.js';
9
9
  import { excludeFromGit, ensureWorkspaceDependencies } from './dependencies.js';
10
10
  import { listWorkspaceBuildInputs } from './git.js';
11
11
  import { runDeveloperCommand } from './developer-command.js';
12
12
  import { openScratchToDevelopment } from './directory-access.js';
13
+ import { ensureSharedDirectory } from './shared-write.js';
14
+ import { driftScope, permissionDriftFromOutput, repairWorkspaceShares } from './workspace-shares.js';
13
15
  import { DeliveryStore } from './delivery-store.js';
14
16
  import { DeliveryValidationError, validateDeliveryArtifact } from './delivery.js';
15
17
  export const DELIVERY_BUILD_TIMEOUT_MS = 10 * 60_000;
@@ -196,7 +198,7 @@ async function copyToDist(root, staging, channel, format) {
196
198
  throw new DeliveryBuildError('delivery_output_missing');
197
199
  const target = join(root, relative);
198
200
  const directory = dirname(target);
199
- await mkdir(directory, { recursive: true });
201
+ await ensureSharedDirectory(root, directory);
200
202
  const temporary = join(directory, `.${basename(file)}.${randomUUID()}.tmp`);
201
203
  try {
202
204
  await copyFile(join(staging, file), temporary);
@@ -249,9 +251,8 @@ async function copyInput(root, destination, path) {
249
251
  * caches, logs and local outputs stay behind. Without a repository the whole tree is copied.
250
252
  */
251
253
  export async function isolateWorkspaceForBuild(root, destination) {
252
- await mkdir(destination, { recursive: true });
253
254
  // The build runs as the development identity and writes its scratch folder inside this copy.
254
- await chmod(destination, 0o2770);
255
+ await ensureSharedDirectory(root, destination);
255
256
  const modules = join(root, 'node_modules');
256
257
  const modulesEntry = await lstat(modules).catch(() => undefined);
257
258
  if (modulesEntry?.isDirectory() || modulesEntry?.isSymbolicLink()) {
@@ -319,11 +320,10 @@ async function executeBuild(state, input, controller) {
319
320
  await openScratchToDevelopment(scratch, (await lstat(root)).gid);
320
321
  staging = join(scratch, 'output');
321
322
  const project = join(scratch, 'project');
322
- await mkdir(staging, { recursive: true });
323
- await chmod(staging, 0o2770);
323
+ await ensureSharedDirectory(root, staging);
324
324
  await excludeFromGit(root, `/${STAGING_DIRECTORY}/`);
325
325
  await excludeFromGit(root, '/dist/');
326
- const dependencies = await ensureWorkspaceDependencies(state, input.workspaceId, root);
326
+ const dependencies = await ensureWorkspaceDependencies(state, revisionScope(state, input.workspaceId, input.directoryName) ?? input.workspaceId, root);
327
327
  if (!dependencies.ok) {
328
328
  if (dependencies.logTail) {
329
329
  await store.appendLog(input.buildId, `\n${dependencies.logTail}\n`);
@@ -338,7 +338,8 @@ async function executeBuild(state, input, controller) {
338
338
  }
339
339
  const planned = process.env.PLAYCRAFT_DELIVERY_BUILD_COMMAND || 'playcraft build';
340
340
  const command = `umask 002 && NO_COLOR=1 CI=1 ${planned} . --platform ${input.payload.channel} --format ${input.payload.format} --output ${shellQuote(staging)} --mode full`;
341
- const outcome = await runDeveloperCommand(state, {
341
+ let outputTail = '';
342
+ const runBuild = () => runDeveloperCommand(state, {
342
343
  processId: `delivery-${input.buildId}`,
343
344
  cwd: project,
344
345
  command,
@@ -348,10 +349,28 @@ async function executeBuild(state, input, controller) {
348
349
  const text = String(chunk);
349
350
  if (!text)
350
351
  return;
352
+ outputTail = (outputTail + text).slice(-64 * 1024);
351
353
  logChain = logChain.then(() => store.appendLog(input.buildId, text)).catch(() => undefined);
352
354
  },
353
355
  });
354
- if (outcome.canceled) {
356
+ let outcome = await runBuild();
357
+ let permissionDrift = false;
358
+ if (outcome.code !== 0 && !outcome.canceled && !outcome.timedOut) {
359
+ // The build runs as the development identity; an entry only that identity owns repairs its
360
+ // own shared subtree, then the build is retried exactly once.
361
+ const drift = permissionDriftFromOutput(outputTail);
362
+ if (drift) {
363
+ permissionDrift = true;
364
+ await repairWorkspaceShares(state, root, await driftScope(root, drift.path)).catch(() => undefined);
365
+ outcome = await runBuild();
366
+ permissionDrift = outcome.code !== 0;
367
+ }
368
+ }
369
+ if (outcome.code === undefined) {
370
+ status = 'failed';
371
+ error = 'delivery_process_unknown';
372
+ }
373
+ else if (outcome.canceled) {
355
374
  status = 'canceled';
356
375
  error = 'delivery_canceled';
357
376
  }
@@ -361,7 +380,7 @@ async function executeBuild(state, input, controller) {
361
380
  }
362
381
  else if (outcome.code !== 0) {
363
382
  status = 'failed';
364
- error = 'delivery_build_failed';
383
+ error = permissionDrift ? 'workspace_permission_drift' : 'delivery_build_failed';
365
384
  }
366
385
  else {
367
386
  const output = await copyToDist(root, staging, input.payload.channel, input.payload.format);
@@ -24,6 +24,7 @@ export class DeliveryStore {
24
24
  this.directory = workspaceDirectory(stateDir, workspaceId);
25
25
  }
26
26
  async ready() {
27
+ // private
27
28
  await mkdir(this.directory, { recursive: true, mode: 0o700 });
28
29
  return this.directory;
29
30
  }
@@ -42,6 +43,7 @@ export class DeliveryStore {
42
43
  async writeJson(path, value) {
43
44
  const temporary = `${path}.${randomUUID()}.tmp`;
44
45
  try {
46
+ // private
45
47
  await writeFile(temporary, JSON.stringify(value), { mode: 0o600 });
46
48
  await rename(temporary, path);
47
49
  }