@playcraft/cli 0.1.57-beta → 0.1.58-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.
- package/dist/commands/workspace.js +43 -0
- package/dist/workspace/messages.js +10 -0
- package/dist/workspace-runtime/batch/deliver.js +140 -80
- package/dist/workspace-runtime/batch/freeze.js +18 -6
- package/dist/workspace-runtime/batch/generate.js +68 -64
- package/dist/workspace-runtime/batch/prepare.js +108 -93
- package/dist/workspace-runtime/batch/records.js +4 -0
- package/dist/workspace-runtime/batch/routes.js +10 -2
- package/dist/workspace-runtime/batch/source-candidate.js +2 -0
- package/dist/workspace-runtime/batch/source-capture.js +1 -0
- package/dist/workspace-runtime/batch/source-failure.js +2 -0
- package/dist/workspace-runtime/batch/source-publish.js +3 -0
- package/dist/workspace-runtime/batch/writer-proof.js +37 -0
- package/dist/workspace-runtime/codex/app-server.js +1 -0
- package/dist/workspace-runtime/codex/config-toml.js +4 -0
- package/dist/workspace-runtime/codex/delivery-tools.js +1 -1
- package/dist/workspace-runtime/persistence/journal.js +34 -0
- package/dist/workspace-runtime/persistence/sqlite-worker.js +68 -6
- package/dist/workspace-runtime/previews/output.js +2 -0
- package/dist/workspace-runtime/previews/publish.js +20 -5
- package/dist/workspace-runtime/previews/runtime-preview.js +4 -0
- package/dist/workspace-runtime/processes/managed-writes.js +47 -5
- package/dist/workspace-runtime/serve.js +11 -2
- package/dist/workspace-runtime/server/accept-operation.js +22 -2
- package/dist/workspace-runtime/server/directory-access.js +414 -0
- package/dist/workspace-runtime/server/dispatch.js +61 -23
- package/dist/workspace-runtime/server/endpoints.js +1 -0
- package/dist/workspace-runtime/server/http.js +52 -10
- package/dist/workspace-runtime/server/operation-record.js +2 -0
- package/dist/workspace-runtime/server/terminal-host.js +43 -8
- package/dist/workspace-runtime/server/workspace-transfer.js +3 -1
- package/dist/workspace-runtime/workspaces/append-only-log.js +3 -0
- package/dist/workspace-runtime/workspaces/artifact-events.js +5 -2
- package/dist/workspace-runtime/workspaces/artifact-files.js +14 -8
- package/dist/workspace-runtime/workspaces/artifact-receipts.js +7 -25
- package/dist/workspace-runtime/workspaces/artifacts.js +4 -1
- package/dist/workspace-runtime/workspaces/asset-library-import.js +9 -4
- package/dist/workspace-runtime/workspaces/asset-publication-access.js +7 -9
- package/dist/workspace-runtime/workspaces/asset-transfer-publish.js +3 -0
- package/dist/workspace-runtime/workspaces/asset-transfer-storage.js +17 -2
- package/dist/workspace-runtime/workspaces/attachments.js +9 -0
- package/dist/workspace-runtime/workspaces/batch-commit.js +5 -3
- package/dist/workspace-runtime/workspaces/binary-write.js +12 -7
- package/dist/workspace-runtime/workspaces/cli-context.js +2 -0
- package/dist/workspace-runtime/workspaces/cli-credentials.js +1 -0
- package/dist/workspace-runtime/workspaces/delivery-build.js +30 -11
- package/dist/workspace-runtime/workspaces/delivery-store.js +2 -0
- package/dist/workspace-runtime/workspaces/dependencies.js +90 -70
- package/dist/workspace-runtime/workspaces/developer-command.js +12 -1
- package/dist/workspace-runtime/workspaces/directory-access.js +13 -18
- package/dist/workspace-runtime/workspaces/directory-attachments.js +8 -0
- package/dist/workspace-runtime/workspaces/directory-create.js +18 -5
- package/dist/workspace-runtime/workspaces/directory-maintenance.js +46 -35
- package/dist/workspace-runtime/workspaces/directory-mutations.js +8 -3
- package/dist/workspace-runtime/workspaces/files.js +4 -0
- package/dist/workspace-runtime/workspaces/git-credential-broker.js +3 -0
- package/dist/workspace-runtime/workspaces/git-direct-store.js +2 -0
- package/dist/workspace-runtime/workspaces/json5-edit.js +36 -0
- package/dist/workspace-runtime/workspaces/library-attachments.js +4 -0
- package/dist/workspace-runtime/workspaces/parameter-agent-protocol.js +2 -0
- package/dist/workspace-runtime/workspaces/parameter-agent-store.js +2 -0
- package/dist/workspace-runtime/workspaces/parameter-agent.js +40 -11
- package/dist/workspace-runtime/workspaces/parameter-assets.js +1 -0
- package/dist/workspace-runtime/workspaces/parameter-initialization-skill-transaction.js +141 -0
- package/dist/workspace-runtime/workspaces/parameter-initialization-skill.js +329 -0
- package/dist/workspace-runtime/workspaces/parameter-initialization.js +63 -0
- package/dist/workspace-runtime/workspaces/parameter-production.js +124 -0
- package/dist/workspace-runtime/workspaces/parameters.js +95 -3
- package/dist/workspace-runtime/workspaces/platform-export.js +5 -0
- package/dist/workspace-runtime/workspaces/prepare.js +4 -0
- package/dist/workspace-runtime/workspaces/registry.js +11 -0
- package/dist/workspace-runtime/workspaces/remix-references.js +5 -0
- package/dist/workspace-runtime/workspaces/shared-write.js +187 -0
- package/dist/workspace-runtime/workspaces/skills.js +14 -15
- package/dist/workspace-runtime/workspaces/transfer.js +4 -1
- package/dist/workspace-runtime/workspaces/workspace-shares.js +235 -0
- package/dist/workspace-runtime/workspaces/worktree-attachments.js +2 -0
- package/dist/workspace-runtime/workspaces/worktree-checkout.js +11 -6
- package/package.json +5 -4
- package/project-template/.claude/skills/playcraft-runtime-parameters/SKILL.md +32 -0
- package/project-template/.claude/skills/playcraft-runtime-parameters/references/platform-protocol.md +21 -0
- package/project-template/.claude/skills/playcraft-runtime-parameters/references/preview-integration.md +57 -0
|
@@ -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
|
-
|
|
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
|
|
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
|
-
|
|
135
|
-
await
|
|
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,
|
|
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 {
|
|
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
|
-
|
|
49
|
-
|
|
50
|
-
|
|
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,
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}
|
|
@@ -4,6 +4,7 @@ import { constants } from 'node:fs';
|
|
|
4
4
|
import { setTimeout as delay } from 'node:timers/promises';
|
|
5
5
|
import { join } from 'node:path';
|
|
6
6
|
import { launcherSocket, runDeveloperCommand } from './developer-command.js';
|
|
7
|
+
import { adoptDirectoryAccess, holdDirectoryAccess, managedDirectoryScope } from '../server/directory-access.js';
|
|
7
8
|
/** Long enough for a cold store on a game template; a stuck registry must not hold the write lease. */
|
|
8
9
|
const INSTALL_TIMEOUT_MS = 10 * 60_000;
|
|
9
10
|
const LOG_TAIL_LINES = 50;
|
|
@@ -44,17 +45,18 @@ export function scheduleDependencyInstall(state, workspaceId, root, shouldStart)
|
|
|
44
45
|
running.set(state, inFlight);
|
|
45
46
|
const existing = inFlight.get(workspaceId);
|
|
46
47
|
if (existing?.root === root) {
|
|
48
|
+
void adoptDirectoryAccess(state, `managed:${existing.writeId}`).catch(() => undefined);
|
|
47
49
|
if (!shouldStart)
|
|
48
50
|
existing.guards = undefined;
|
|
49
51
|
else
|
|
50
52
|
existing.guards?.push(shouldStart);
|
|
51
53
|
return existing.task;
|
|
52
54
|
}
|
|
53
|
-
const scheduled = { root, task: Promise.resolve(), guards: shouldStart ? [shouldStart] : undefined };
|
|
55
|
+
const scheduled = { root, writeId: `${WRITE_PREFIX}${workspaceId}-${randomUUID()}`, task: Promise.resolve(), guards: shouldStart ? [shouldStart] : undefined };
|
|
54
56
|
const begin = () => {
|
|
55
57
|
if (state.journal.destroyed() || (scheduled.guards && !scheduled.guards.some((guard) => guard())))
|
|
56
58
|
return;
|
|
57
|
-
return installOnce(state, workspaceId, root);
|
|
59
|
+
return installOnce(state, workspaceId, root, scheduled.writeId);
|
|
58
60
|
};
|
|
59
61
|
const task = (existing ? existing.task.then(begin) : begin() ?? Promise.resolve())
|
|
60
62
|
.catch((error) => {
|
|
@@ -91,8 +93,8 @@ export async function ensureWorkspaceDependencies(state, workspaceId, root) {
|
|
|
91
93
|
};
|
|
92
94
|
}
|
|
93
95
|
/**
|
|
94
|
-
*
|
|
95
|
-
*
|
|
96
|
+
* A lost controller makes the install result unknown. Keep its writer claim until a supervisor
|
|
97
|
+
* confirms that the process group stopped; a closed connection alone cannot prove that.
|
|
96
98
|
*/
|
|
97
99
|
export async function recoverInterruptedInstalls(journal) {
|
|
98
100
|
const now = new Date().toISOString();
|
|
@@ -106,81 +108,96 @@ export async function recoverInterruptedInstalls(journal) {
|
|
|
106
108
|
finishedAt: now,
|
|
107
109
|
});
|
|
108
110
|
}
|
|
109
|
-
for (const write of journal.listManagedWrites()) {
|
|
110
|
-
if (write.writeId.startsWith(WRITE_PREFIX) && !write.stoppedAt) {
|
|
111
|
-
await journal.putManagedWrite({ ...write, stoppedAt: now });
|
|
112
|
-
}
|
|
113
|
-
}
|
|
114
111
|
}
|
|
115
|
-
async function installOnce(state, workspaceId, root) {
|
|
116
|
-
|
|
112
|
+
async function installOnce(state, workspaceId, root, writeId) {
|
|
113
|
+
const startedAt = new Date().toISOString();
|
|
114
|
+
const release = await holdDirectoryAccess(state, managedDirectoryScope(state, workspaceId), `managed:${writeId}`, 'shared');
|
|
115
|
+
let processMayRun = false;
|
|
116
|
+
let processStopped = false;
|
|
117
117
|
try {
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
catch {
|
|
121
|
-
await state.journal.putWorkspaceDependencies({
|
|
122
|
-
workspaceId, status: 'failed', error: 'dependency_inputs_unreadable', finishedAt: new Date().toISOString(),
|
|
123
|
-
});
|
|
124
|
-
return;
|
|
125
|
-
}
|
|
126
|
-
const current = state.journal.getWorkspaceDependencies(workspaceId);
|
|
127
|
-
if (plan.kind === 'skip') {
|
|
128
|
-
if (current?.status !== 'none') {
|
|
118
|
+
if (state.journal.listManagedWrites().some(writer => writer.workspaceId === workspaceId &&
|
|
119
|
+
writer.writeId !== writeId && writer.writeId.startsWith(WRITE_PREFIX) && !writer.stoppedAt)) {
|
|
129
120
|
await state.journal.putWorkspaceDependencies({
|
|
130
|
-
workspaceId,
|
|
131
|
-
status: 'none',
|
|
132
|
-
finishedAt: new Date().toISOString(),
|
|
121
|
+
workspaceId, status: 'failed', error: 'dependency_run_unconfirmed', finishedAt: new Date().toISOString(),
|
|
133
122
|
});
|
|
123
|
+
return;
|
|
134
124
|
}
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
125
|
+
let plan;
|
|
126
|
+
try {
|
|
127
|
+
plan = await planInstall(root);
|
|
128
|
+
}
|
|
129
|
+
catch {
|
|
130
|
+
await state.journal.putWorkspaceDependencies({
|
|
131
|
+
workspaceId, status: 'failed', error: 'dependency_inputs_unreadable', finishedAt: new Date().toISOString(),
|
|
132
|
+
});
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
const current = state.journal.getWorkspaceDependencies(workspaceId);
|
|
136
|
+
if (plan.kind === 'skip') {
|
|
137
|
+
if (current?.status !== 'none') {
|
|
138
|
+
await state.journal.putWorkspaceDependencies({
|
|
139
|
+
workspaceId,
|
|
140
|
+
status: 'none',
|
|
141
|
+
finishedAt: new Date().toISOString(),
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
if (current?.status === 'ready' &&
|
|
147
|
+
current.lockHash === plan.lockHash &&
|
|
148
|
+
(await isDirectory(join(root, 'node_modules')))) {
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
await excludeFromGit(root);
|
|
152
|
+
// A checkpoint waits for this write: node_modules is half written until the install ends.
|
|
153
|
+
await state.journal.putManagedWrite({ writeId, workspaceId, source: 'background', startedAt });
|
|
154
|
+
await state.journal.putWorkspaceDependencies({
|
|
164
155
|
workspaceId,
|
|
165
|
-
|
|
156
|
+
status: 'installing',
|
|
166
157
|
startedAt,
|
|
167
|
-
|
|
158
|
+
});
|
|
159
|
+
let outcome;
|
|
160
|
+
try {
|
|
161
|
+
// Through the launcher the install runs as the development group, which cannot enter the
|
|
162
|
+
// checkout until Runtime has opened it; a local install runs as the owner and need not wait.
|
|
163
|
+
const shared = !launcherSocket(state) || (await waitUntilShared(root));
|
|
164
|
+
if (shared) {
|
|
165
|
+
processMayRun = true;
|
|
166
|
+
outcome = await runInstall(state, workspaceId, root);
|
|
167
|
+
processStopped = outcome.code !== undefined;
|
|
168
|
+
}
|
|
169
|
+
else
|
|
170
|
+
outcome = { code: undefined, timedOut: false, log: '', error: 'workspace_not_shared' };
|
|
171
|
+
}
|
|
172
|
+
finally {
|
|
173
|
+
if (!processMayRun || processStopped)
|
|
174
|
+
await state.journal.putManagedWrite({
|
|
175
|
+
writeId,
|
|
176
|
+
workspaceId,
|
|
177
|
+
source: 'background',
|
|
178
|
+
startedAt,
|
|
179
|
+
stoppedAt: new Date().toISOString(),
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
const ok = outcome.code === 0 && !outcome.timedOut;
|
|
183
|
+
await state.journal.putWorkspaceDependencies({
|
|
184
|
+
workspaceId,
|
|
185
|
+
status: ok ? 'ready' : 'failed',
|
|
186
|
+
...(ok
|
|
187
|
+
? { lockHash: plan.lockHash }
|
|
188
|
+
: {
|
|
189
|
+
error: outcome.error ??
|
|
190
|
+
(outcome.timedOut ? 'install_timeout' : `install_exit_${outcome.code ?? 'lost'}`),
|
|
191
|
+
}),
|
|
192
|
+
startedAt,
|
|
193
|
+
finishedAt: new Date().toISOString(),
|
|
194
|
+
logTail: tail(outcome.log),
|
|
168
195
|
});
|
|
169
196
|
}
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
...(ok
|
|
175
|
-
? { lockHash: plan.lockHash }
|
|
176
|
-
: {
|
|
177
|
-
error: outcome.error ??
|
|
178
|
-
(outcome.timedOut ? 'install_timeout' : `install_exit_${outcome.code ?? 'lost'}`),
|
|
179
|
-
}),
|
|
180
|
-
startedAt,
|
|
181
|
-
finishedAt: new Date().toISOString(),
|
|
182
|
-
logTail: tail(outcome.log),
|
|
183
|
-
});
|
|
197
|
+
finally {
|
|
198
|
+
if (!processMayRun || processStopped)
|
|
199
|
+
release();
|
|
200
|
+
}
|
|
184
201
|
}
|
|
185
202
|
/**
|
|
186
203
|
* Runs as the development user through the launcher, so the packages belong to the same user as
|
|
@@ -190,8 +207,10 @@ async function runInstall(state, workspaceId, root) {
|
|
|
190
207
|
// Tests replace the install; a real registry is never reached from them.
|
|
191
208
|
const planned = process.env.PLAYCRAFT_DEPENDENCY_INSTALL_COMMAND || dependencyInstallCommand(state);
|
|
192
209
|
const logDir = join(state.config.stateDir, 'dependency-logs');
|
|
210
|
+
// private
|
|
193
211
|
await mkdir(logDir, { recursive: true, mode: 0o700 });
|
|
194
212
|
const logPath = join(logDir, `${workspaceId}.log`);
|
|
213
|
+
// private
|
|
195
214
|
await writeFile(logPath, `$ ${planned}\n`, { mode: 0o600 });
|
|
196
215
|
let log = '';
|
|
197
216
|
let pending = Promise.resolve();
|
|
@@ -254,6 +273,7 @@ export async function excludeFromGit(root, entry = '/node_modules/') {
|
|
|
254
273
|
});
|
|
255
274
|
const info = await open(join(gitPath, 'info'), flags);
|
|
256
275
|
handles.push(info);
|
|
276
|
+
// shared
|
|
257
277
|
file = await open(join(pinned(info, join(root, '.git/info')), 'exclude'), constants.O_RDWR | constants.O_CREAT | constants.O_APPEND | constants.O_NOFOLLOW | constants.O_NONBLOCK, 0o666);
|
|
258
278
|
const metadata = await file.stat();
|
|
259
279
|
if (!metadata.isFile() || metadata.nlink !== 1 || metadata.size > 1024 * 1024) {
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { spawn } from 'node:child_process';
|
|
2
2
|
import { startLauncherProcess } from '../codex/launcher-client.js';
|
|
3
|
+
import { stopBackgroundWriter } from '../processes/managed-writes.js';
|
|
3
4
|
export function launcherSocket(state) {
|
|
4
5
|
const socketPath = process.env.PLAYCRAFT_LAUNCHER_SOCKET;
|
|
5
6
|
return socketPath && state.config.workspaceMode === 'multi' ? socketPath : undefined;
|
|
@@ -94,5 +95,15 @@ export async function runDeveloperCommand(state, input) {
|
|
|
94
95
|
local.once('close', (value) => finish(value));
|
|
95
96
|
}
|
|
96
97
|
});
|
|
97
|
-
|
|
98
|
+
let stopped = !launched && !local?.pid;
|
|
99
|
+
try {
|
|
100
|
+
if (launched) {
|
|
101
|
+
await launched.stop();
|
|
102
|
+
stopped = true;
|
|
103
|
+
}
|
|
104
|
+
else if (local?.pid)
|
|
105
|
+
stopped = await stopBackgroundWriter(local.pid);
|
|
106
|
+
}
|
|
107
|
+
catch { /* Transport loss cannot prove the process group has stopped. */ }
|
|
108
|
+
return { code: stopped ? code : undefined, timedOut, ...(canceled ? { canceled: true } : {}) };
|
|
98
109
|
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { chmod, chown, lstat, readdir } from 'node:fs/promises';
|
|
2
2
|
import { join } from 'node:path';
|
|
3
|
+
import { SHARED_DIRECTORY_MODE, SHARED_FILE_MODE, sharePathWithGroup, } from './shared-write.js';
|
|
3
4
|
/** Grant development-group traversal of the workspace container without touching source checkouts. */
|
|
4
5
|
export async function grantWorkspaceTraversal(root, groupId) {
|
|
5
6
|
const stat = await lstat(root);
|
|
@@ -18,9 +19,7 @@ export async function openScratchToDevelopment(path, groupId) {
|
|
|
18
19
|
const stat = await lstat(path);
|
|
19
20
|
if (!stat.isDirectory() || stat.isSymbolicLink())
|
|
20
21
|
throw new Error('invalid_scratch_directory');
|
|
21
|
-
|
|
22
|
-
await chown(path, stat.uid, groupId);
|
|
23
|
-
await chmod(path, 0o2770);
|
|
22
|
+
await sharePathWithGroup(path, groupId, SHARED_DIRECTORY_MODE);
|
|
24
23
|
}
|
|
25
24
|
/** The sandbox development group can edit engine directories. Symlinks never grant access outside the tree. */
|
|
26
25
|
export async function openDirectoryToDevelopment(root, groupId) {
|
|
@@ -29,22 +28,18 @@ export async function openDirectoryToDevelopment(root, groupId) {
|
|
|
29
28
|
const stat = await lstat(path);
|
|
30
29
|
if (stat.isSymbolicLink())
|
|
31
30
|
return;
|
|
31
|
+
// Entries owned by the other identity cannot be re-shaped from here; the owner repairs them.
|
|
32
32
|
const canModify = currentUid === undefined || currentUid === 0 || stat.uid === currentUid;
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
for (const entry of entries)
|
|
44
|
-
await visit(join(path, entry));
|
|
45
|
-
}
|
|
46
|
-
else if (stat.isFile() && canModify)
|
|
47
|
-
await chmod(path, (stat.mode & 0o777) | 0o660);
|
|
33
|
+
await sharePathWithGroup(path, groupId, stat.isDirectory() ? SHARED_DIRECTORY_MODE : SHARED_FILE_MODE, stat.isFile());
|
|
34
|
+
if (!stat.isDirectory())
|
|
35
|
+
return;
|
|
36
|
+
const entries = await readdir(path).catch(error => {
|
|
37
|
+
if (!canModify && error.code === 'EACCES')
|
|
38
|
+
return [];
|
|
39
|
+
throw error;
|
|
40
|
+
});
|
|
41
|
+
for (const entry of entries)
|
|
42
|
+
await visit(join(path, entry));
|
|
48
43
|
};
|
|
49
44
|
await visit(root);
|
|
50
45
|
}
|