@playcraft/cli 0.1.4 → 0.1.6
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/build.js +9 -4
- package/dist/workspace-runtime/persistence/journal.js +3 -0
- package/dist/workspace-runtime/previews/publish.js +237 -0
- package/dist/workspace-runtime/previews/single-file.js +35 -0
- package/dist/workspace-runtime/serve.js +2 -0
- package/dist/workspace-runtime/server/dispatch.js +27 -1
- package/dist/workspace-runtime/server/http.js +1 -3
- package/dist/workspace-runtime/workspaces/dependencies.js +56 -68
- package/dist/workspace-runtime/workspaces/developer-command.js +64 -0
- package/dist/workspace-runtime/workspaces/registry.js +1 -1
- package/package.json +3 -3
package/dist/commands/build.js
CHANGED
|
@@ -450,6 +450,7 @@ export async function buildCommand(projectPath, options) {
|
|
|
450
450
|
const shouldRunPlayableOnly = options.mode === 'playable';
|
|
451
451
|
// 验证平台
|
|
452
452
|
const validPlatforms = [
|
|
453
|
+
'playcraft',
|
|
453
454
|
'facebook',
|
|
454
455
|
'snapchat',
|
|
455
456
|
'ironsource',
|
|
@@ -473,6 +474,7 @@ export async function buildCommand(projectPath, options) {
|
|
|
473
474
|
// 注:Facebook 在 Phaser(playable-scripts devkit)下的产物格式由其 devkit 输入配置决定(恒为 ZIP),
|
|
474
475
|
// 平台侧不在此处做引擎判断。
|
|
475
476
|
const platformFormatConfig = {
|
|
477
|
+
playcraft: { formats: ['html'], default: 'html' },
|
|
476
478
|
facebook: { formats: ['html', 'zip'], default: 'html' },
|
|
477
479
|
applovin: { formats: ['html'], default: 'html' },
|
|
478
480
|
ironsource: { formats: ['html'], default: 'html' },
|
|
@@ -584,8 +586,11 @@ export async function buildCommand(projectPath, options) {
|
|
|
584
586
|
console.log(pc.dim(' 无主题目录,跳过主题选择'));
|
|
585
587
|
}
|
|
586
588
|
}
|
|
587
|
-
// 输入商店跳转地址(CTA)-
|
|
588
|
-
if (!options.storeUrls) {
|
|
589
|
+
// 输入商店跳转地址(CTA)- 如果配置文件中没有则必填;通用预览(playcraft)不面向渠道,可不填
|
|
590
|
+
if (selectedPlatform === 'playcraft' && !options.storeUrls) {
|
|
591
|
+
console.log(pc.dim('\nℹ️ 通用预览不需要商店地址'));
|
|
592
|
+
}
|
|
593
|
+
else if (!options.storeUrls) {
|
|
589
594
|
console.log(pc.cyan('\n🔗 商店跳转地址(CTA 按钮目标)'));
|
|
590
595
|
console.log(pc.dim(' iOS 和 Android 地址均为必填'));
|
|
591
596
|
const storeUrlAnswer = await inquirer.prompt([
|
|
@@ -636,8 +641,8 @@ export async function buildCommand(projectPath, options) {
|
|
|
636
641
|
// 未指定格式,使用默认值
|
|
637
642
|
options.format = formatConfig.default;
|
|
638
643
|
}
|
|
639
|
-
// 如果命令行未传入 storeUrls
|
|
640
|
-
if (!options.storeUrls) {
|
|
644
|
+
// 如果命令行未传入 storeUrls,交互式输入(必填);通用预览(playcraft)不面向渠道,可不填
|
|
645
|
+
if (!options.storeUrls && options.platform !== 'playcraft') {
|
|
641
646
|
spinner.stop();
|
|
642
647
|
console.log(pc.cyan('\n🔗 商店跳转地址(CTA 按钮目标)'));
|
|
643
648
|
console.log(pc.dim(' iOS 和 Android 地址均为必填'));
|
|
@@ -98,6 +98,9 @@ export class RuntimeJournal {
|
|
|
98
98
|
getOperationById(operationId) {
|
|
99
99
|
return Object.values(this.snapshot.operations).find((operation) => operation.operationId === operationId);
|
|
100
100
|
}
|
|
101
|
+
listOperations() {
|
|
102
|
+
return Object.entries(this.snapshot.operations).map(([scopeKey, operation]) => ({ scopeKey, operation }));
|
|
103
|
+
}
|
|
101
104
|
getReceipt(key) {
|
|
102
105
|
return this.snapshot.receipts[key];
|
|
103
106
|
}
|
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { spawn } from 'node:child_process';
|
|
3
|
+
import { constants } from 'node:fs';
|
|
4
|
+
import { lstat, open, readdir, rm } from 'node:fs/promises';
|
|
5
|
+
import { join } from 'node:path';
|
|
6
|
+
import { PREVIEW_HTML_MAX_BYTES, PREVIEW_LOG_TAIL_MAX, previewPublishResultSchema, previewReservationSchema, transitionOperation, } from '@playcraft/common/cloud-workspace/runtime';
|
|
7
|
+
import { excludeFromGit, readDependencies, scheduleDependencyInstall, } from '../workspaces/dependencies.js';
|
|
8
|
+
import { runDeveloperCommand } from '../workspaces/developer-command.js';
|
|
9
|
+
import { workspaceRoot } from '../workspaces/registry.js';
|
|
10
|
+
import { classifyPreviewOutput, findExternalLocalReferences } from './single-file.js';
|
|
11
|
+
const BUILD_TIMEOUT_MS = 10 * 60_000;
|
|
12
|
+
/** Build output and the builder's own scratch folder live in the checkout, hidden from Git. */
|
|
13
|
+
const OUTPUT_FOLDER = '.playcraft-preview-out';
|
|
14
|
+
const BUILDER_TEMP_FOLDER = '.playcraft-temp';
|
|
15
|
+
/**
|
|
16
|
+
* The generic single-file build. `--mode full` keeps the builder from asking which stages to run.
|
|
17
|
+
* Group-writable output lets the control user, a member of the development group, remove it.
|
|
18
|
+
*/
|
|
19
|
+
const BUILD_COMMAND = 'umask 002 && playcraft build --mode full --platform playcraft --format html';
|
|
20
|
+
class PreviewFailure extends Error {
|
|
21
|
+
code;
|
|
22
|
+
constructor(code) {
|
|
23
|
+
super(code);
|
|
24
|
+
this.code = code;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
const running = new WeakMap();
|
|
28
|
+
/**
|
|
29
|
+
* Checks what can be checked before the operation is accepted. The build itself runs later in
|
|
30
|
+
* `runPreviewPublish`; the dispatch request returns as soon as the operation is accepted.
|
|
31
|
+
*/
|
|
32
|
+
export async function previewWorkspaceRoot(state, operation) {
|
|
33
|
+
if (!operation.workspaceId || !/^[A-Za-z0-9_-]+$/.test(operation.operationId)) {
|
|
34
|
+
throw new PreviewFailure('invalid_preview_publish');
|
|
35
|
+
}
|
|
36
|
+
return workspaceRoot(state, operation.workspaceId);
|
|
37
|
+
}
|
|
38
|
+
/** Tracks a background publish so tests and shutdown can wait for it. */
|
|
39
|
+
export function trackPreviewPublish(state, task) {
|
|
40
|
+
const tasks = running.get(state) ?? new Set();
|
|
41
|
+
running.set(state, tasks);
|
|
42
|
+
const tracked = task.finally(() => tasks.delete(tracked));
|
|
43
|
+
tasks.add(tracked);
|
|
44
|
+
}
|
|
45
|
+
export async function settlePreviewPublishes(state) {
|
|
46
|
+
await Promise.all([...(running.get(state) ?? [])]);
|
|
47
|
+
}
|
|
48
|
+
/** A publish cut off by a restart has no build left running; it is closed rather than left accepted. */
|
|
49
|
+
export async function recoverInterruptedPreviews(journal) {
|
|
50
|
+
for (const { scopeKey, operation } of journal.listOperations()) {
|
|
51
|
+
if (operation.kind !== 'preview.publish' || operation.status !== 'accepted')
|
|
52
|
+
continue;
|
|
53
|
+
await journal.putOperation(scopeKey, transitionOperation(operation, 'interrupted', { error: 'preview_interrupted' }));
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
export async function runPreviewPublish(state, operation, root) {
|
|
57
|
+
const workspaceId = operation.workspaceId;
|
|
58
|
+
const outputDir = join(root, OUTPUT_FOLDER, operation.operationId);
|
|
59
|
+
const output = { log: '' };
|
|
60
|
+
try {
|
|
61
|
+
await ensureDependencies(state, workspaceId, root);
|
|
62
|
+
await excludeFromGit(root, `/${OUTPUT_FOLDER}/`);
|
|
63
|
+
await excludeFromGit(root, `/${BUILDER_TEMP_FOLDER}/`);
|
|
64
|
+
await buildPreview(state, root, outputDir, operation.operationId, output);
|
|
65
|
+
const bytes = await readSingleHtml(outputDir);
|
|
66
|
+
if (bytes.byteLength > PREVIEW_HTML_MAX_BYTES)
|
|
67
|
+
throw new PreviewFailure('preview_not_single_file');
|
|
68
|
+
if (findExternalLocalReferences(bytes.toString('utf8')).length > 0) {
|
|
69
|
+
throw new PreviewFailure('preview_not_single_file');
|
|
70
|
+
}
|
|
71
|
+
const contentDigest = createHash('sha256').update(bytes).digest('hex');
|
|
72
|
+
const contentMd5 = createHash('md5').update(bytes).digest('base64');
|
|
73
|
+
const source = await sourceRevision(root);
|
|
74
|
+
const reserved = previewReservationSchema.parse(await postControl(state, '/runtime/internal/preview-reservations', {
|
|
75
|
+
workspaceId,
|
|
76
|
+
operationId: operation.operationId,
|
|
77
|
+
contentDigest,
|
|
78
|
+
contentMd5,
|
|
79
|
+
sizeBytes: bytes.byteLength,
|
|
80
|
+
gitHead: source.head,
|
|
81
|
+
dirty: source.dirty,
|
|
82
|
+
cliVersion: state.config.cliVersion,
|
|
83
|
+
}));
|
|
84
|
+
if (!reserved.alreadyUploaded) {
|
|
85
|
+
const uploaded = await fetch(reserved.uploadUrl, {
|
|
86
|
+
method: 'PUT',
|
|
87
|
+
redirect: 'error',
|
|
88
|
+
signal: AbortSignal.timeout(120_000),
|
|
89
|
+
headers: {
|
|
90
|
+
'content-type': 'text/html; charset=utf-8',
|
|
91
|
+
'content-md5': contentMd5,
|
|
92
|
+
'content-length': String(bytes.byteLength),
|
|
93
|
+
},
|
|
94
|
+
body: new Uint8Array(bytes),
|
|
95
|
+
});
|
|
96
|
+
await uploaded.body?.cancel();
|
|
97
|
+
if (!uploaded.ok)
|
|
98
|
+
throw new PreviewFailure('preview_upload_failed');
|
|
99
|
+
}
|
|
100
|
+
const published = previewPublishResultSchema.parse(await postControl(state, '/runtime/internal/preview-completions', {
|
|
101
|
+
workspaceId,
|
|
102
|
+
operationId: operation.operationId,
|
|
103
|
+
artifactId: reserved.artifactId,
|
|
104
|
+
expectedPublishRevision: reserved.expectedPublishRevision,
|
|
105
|
+
logTail: tail(output.log),
|
|
106
|
+
}));
|
|
107
|
+
if (published.status === 'conflict')
|
|
108
|
+
throw new PreviewFailure('publish_conflict');
|
|
109
|
+
return published;
|
|
110
|
+
}
|
|
111
|
+
catch (error) {
|
|
112
|
+
const code = error instanceof PreviewFailure ? error.code : 'preview_build_failed';
|
|
113
|
+
if (code !== 'publish_conflict') {
|
|
114
|
+
await postControl(state, '/runtime/internal/preview-failures', {
|
|
115
|
+
workspaceId,
|
|
116
|
+
operationId: operation.operationId,
|
|
117
|
+
error: code,
|
|
118
|
+
logTail: tail(output.log || (error instanceof Error ? error.message : '')),
|
|
119
|
+
}).catch(() => undefined);
|
|
120
|
+
}
|
|
121
|
+
throw new PreviewFailure(code);
|
|
122
|
+
}
|
|
123
|
+
finally {
|
|
124
|
+
await rm(outputDir, { recursive: true, force: true }).catch(() => undefined);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
export function previewFailureCode(error) {
|
|
128
|
+
const code = error instanceof PreviewFailure
|
|
129
|
+
? error.code
|
|
130
|
+
: error instanceof Error && /^[a-z_]+$/.test(error.message)
|
|
131
|
+
? error.message
|
|
132
|
+
: 'preview_build_failed';
|
|
133
|
+
return code.slice(0, 256);
|
|
134
|
+
}
|
|
135
|
+
/**
|
|
136
|
+
* Checks the install against the current manifest and lockfile before building, so a package
|
|
137
|
+
* Codex added since the workspace opened is installed first. A running install is joined rather
|
|
138
|
+
* than started twice; a failed one is retried once here.
|
|
139
|
+
*/
|
|
140
|
+
async function ensureDependencies(state, workspaceId, root) {
|
|
141
|
+
await scheduleDependencyInstall(state, workspaceId, root);
|
|
142
|
+
const status = readDependencies(state, workspaceId).status;
|
|
143
|
+
if (status !== 'ready' && status !== 'none')
|
|
144
|
+
throw new PreviewFailure('dependencies_not_ready');
|
|
145
|
+
}
|
|
146
|
+
async function buildPreview(state, root, outputDir, operationId, output) {
|
|
147
|
+
// Tests replace the build; the real one needs a project and the published CLI.
|
|
148
|
+
const planned = process.env.PLAYCRAFT_PREVIEW_BUILD_COMMAND || BUILD_COMMAND;
|
|
149
|
+
const outcome = await runDeveloperCommand(state, {
|
|
150
|
+
processId: `preview-${operationId}`,
|
|
151
|
+
cwd: root,
|
|
152
|
+
command: `${planned} --output ${shellQuote(outputDir)} .`,
|
|
153
|
+
timeoutMs: BUILD_TIMEOUT_MS,
|
|
154
|
+
onOutput: (chunk) => {
|
|
155
|
+
output.log = (output.log + String(chunk)).slice(-64 * 1024);
|
|
156
|
+
},
|
|
157
|
+
});
|
|
158
|
+
if (outcome.code !== 0)
|
|
159
|
+
throw new PreviewFailure('preview_build_failed');
|
|
160
|
+
}
|
|
161
|
+
/** Reads the one HTML file without following links, so the uploaded bytes are what was checked. */
|
|
162
|
+
async function readSingleHtml(outputDir) {
|
|
163
|
+
const listed = await readdir(outputDir, { withFileTypes: true }).catch(() => {
|
|
164
|
+
throw new PreviewFailure('preview_not_single_file');
|
|
165
|
+
});
|
|
166
|
+
const entries = listed.map((entry) => ({
|
|
167
|
+
name: entry.name,
|
|
168
|
+
kind: entry.isSymbolicLink() ? 'symlink' : entry.isFile() ? 'file' : 'dir',
|
|
169
|
+
}));
|
|
170
|
+
const classified = classifyPreviewOutput(entries);
|
|
171
|
+
if (!classified.ok)
|
|
172
|
+
throw new PreviewFailure('preview_not_single_file');
|
|
173
|
+
const source = join(outputDir, classified.entry);
|
|
174
|
+
const stat = await lstat(source);
|
|
175
|
+
if (!stat.isFile())
|
|
176
|
+
throw new PreviewFailure('preview_not_single_file');
|
|
177
|
+
const handle = await open(source, constants.O_RDONLY | constants.O_NOFOLLOW);
|
|
178
|
+
try {
|
|
179
|
+
return await handle.readFile();
|
|
180
|
+
}
|
|
181
|
+
finally {
|
|
182
|
+
await handle.close();
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
async function sourceRevision(root) {
|
|
186
|
+
const head = await git(root, ['rev-parse', 'HEAD']);
|
|
187
|
+
const status = await git(root, ['status', '--porcelain']);
|
|
188
|
+
const revision = head.stdout.trim();
|
|
189
|
+
if (head.code !== 0 || !/^[0-9a-f]{7,64}$/i.test(revision))
|
|
190
|
+
return { dirty: true };
|
|
191
|
+
return { head: revision, dirty: status.code !== 0 || status.stdout.trim().length > 0 };
|
|
192
|
+
}
|
|
193
|
+
function git(cwd, args) {
|
|
194
|
+
return new Promise((resolve) => {
|
|
195
|
+
const child = spawn('git', args, { cwd, stdio: ['ignore', 'pipe', 'ignore'] });
|
|
196
|
+
let stdout = '';
|
|
197
|
+
child.stdout.on('data', (chunk) => {
|
|
198
|
+
stdout = (stdout + String(chunk)).slice(-8_000);
|
|
199
|
+
});
|
|
200
|
+
child.once('error', () => resolve({ code: null, stdout }));
|
|
201
|
+
child.once('close', (code) => resolve({ code, stdout }));
|
|
202
|
+
});
|
|
203
|
+
}
|
|
204
|
+
async function postControl(state, path, body) {
|
|
205
|
+
const endpoint = new URL(path, state.config.callbackUrl);
|
|
206
|
+
const local = endpoint.protocol === 'http:' && ['127.0.0.1', 'localhost', '[::1]'].includes(endpoint.hostname);
|
|
207
|
+
if (endpoint.protocol !== 'https:' && !local)
|
|
208
|
+
throw new PreviewFailure('preview_upload_failed');
|
|
209
|
+
const response = await fetch(endpoint, {
|
|
210
|
+
method: 'POST',
|
|
211
|
+
redirect: 'error',
|
|
212
|
+
signal: AbortSignal.timeout(60_000),
|
|
213
|
+
headers: {
|
|
214
|
+
authorization: `Bearer ${state.config.controlToken}`,
|
|
215
|
+
'content-type': 'application/json',
|
|
216
|
+
},
|
|
217
|
+
body: JSON.stringify({
|
|
218
|
+
runtimeId: state.config.runtimeId,
|
|
219
|
+
generation: state.config.generation,
|
|
220
|
+
runtimeEpoch: state.runtimeEpoch,
|
|
221
|
+
...body,
|
|
222
|
+
}),
|
|
223
|
+
});
|
|
224
|
+
const payload = (await response.json().catch(() => ({})));
|
|
225
|
+
if (!response.ok) {
|
|
226
|
+
const record = (payload && typeof payload === 'object' ? payload : {});
|
|
227
|
+
const reason = typeof record.reason === 'string' ? record.reason : typeof record.error === 'string' ? record.error : '';
|
|
228
|
+
throw new PreviewFailure(/^[a-z_]{1,64}$/.test(reason) ? reason : 'preview_upload_failed');
|
|
229
|
+
}
|
|
230
|
+
return payload;
|
|
231
|
+
}
|
|
232
|
+
function shellQuote(value) {
|
|
233
|
+
return `'${value.replace(/'/g, `'\\''`)}'`;
|
|
234
|
+
}
|
|
235
|
+
function tail(value) {
|
|
236
|
+
return value.slice(-PREVIEW_LOG_TAIL_MAX);
|
|
237
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
const LOCAL_ATTR = /<(?:script|img|link|source|video|audio|iframe)\b[^>]*?\b(?:src|href)\s*=\s*["']([^"']+)["']/gi;
|
|
2
|
+
const CSS_URL = /url\(\s*["']?([^"')]+)["']?\s*\)/gi;
|
|
3
|
+
/** A reference the sealed document would load from the local build, not from the network or inline data. */
|
|
4
|
+
export function findExternalLocalReferences(html) {
|
|
5
|
+
const found = new Set();
|
|
6
|
+
for (const expression of [LOCAL_ATTR, CSS_URL]) {
|
|
7
|
+
expression.lastIndex = 0;
|
|
8
|
+
for (const match of html.matchAll(expression)) {
|
|
9
|
+
const value = (match[1] ?? '').trim();
|
|
10
|
+
if (isLocalReference(value))
|
|
11
|
+
found.add(value);
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
return [...found];
|
|
15
|
+
}
|
|
16
|
+
export function classifyPreviewOutput(entries) {
|
|
17
|
+
if (entries.length !== 1)
|
|
18
|
+
return { ok: false };
|
|
19
|
+
const only = entries[0];
|
|
20
|
+
if (!only || only.kind !== 'file' || !only.name.toLowerCase().endsWith('.html'))
|
|
21
|
+
return { ok: false };
|
|
22
|
+
if (only.name.includes('/') || only.name.includes('\\') || only.name === '.' || only.name === '..') {
|
|
23
|
+
return { ok: false };
|
|
24
|
+
}
|
|
25
|
+
return { ok: true, entry: only.name };
|
|
26
|
+
}
|
|
27
|
+
function isLocalReference(value) {
|
|
28
|
+
if (!value || value.startsWith('#') || value.startsWith('data:') || value.startsWith('blob:'))
|
|
29
|
+
return false;
|
|
30
|
+
if (/^(https?:|mailto:|javascript:)/i.test(value))
|
|
31
|
+
return false;
|
|
32
|
+
if (value.startsWith('//'))
|
|
33
|
+
return false;
|
|
34
|
+
return true;
|
|
35
|
+
}
|
|
@@ -14,6 +14,7 @@ import { RuntimeProcessLock } from './server/lock.js';
|
|
|
14
14
|
import { probeGitBinary } from './workspaces/git.js';
|
|
15
15
|
import { WorkspaceGitCredentialBroker } from './workspaces/git-credential-broker.js';
|
|
16
16
|
import { WorkspaceModelBroker } from './codex/model-broker.js';
|
|
17
|
+
import { recoverInterruptedPreviews } from './previews/publish.js';
|
|
17
18
|
import { recoverInterruptedInstalls } from './workspaces/dependencies.js';
|
|
18
19
|
export async function startWorkspaceRuntime(config, options = {}) {
|
|
19
20
|
process.env.PLAYCRAFT_SKIP_UPDATE_CHECK = '1';
|
|
@@ -33,6 +34,7 @@ export async function startWorkspaceRuntime(config, options = {}) {
|
|
|
33
34
|
journal = new RuntimeJournal(config.stateDir);
|
|
34
35
|
await journal.load();
|
|
35
36
|
await recoverInterruptedInstalls(journal);
|
|
37
|
+
await recoverInterruptedPreviews(journal);
|
|
36
38
|
await mkdir(config.workspaceRoot, { recursive: true, mode: 0o700 });
|
|
37
39
|
const state = {
|
|
38
40
|
config: { ...config, protocolMinor: negotiated.protocolMinor },
|
|
@@ -10,6 +10,7 @@ import { InvalidWorkspacePathError, resolveWorkspaceFile, restoreWorkspaceFiles,
|
|
|
10
10
|
import { GitWorkspaceError, commitWorkspaceGit, discardWorkspaceGit, pushWorkspaceGit, stageWorkspaceGit, unstageWorkspaceGit, } from '../workspaces/git.js';
|
|
11
11
|
import { ParameterEditError } from '../workspaces/json5-edit.js';
|
|
12
12
|
import { patchWorkspaceParameters, readWorkspaceParameters } from '../workspaces/parameters.js';
|
|
13
|
+
import { previewFailureCode, previewWorkspaceRoot, runPreviewPublish, trackPreviewPublish, } from '../previews/publish.js';
|
|
13
14
|
import { executionGroupRestarted } from './execution-group.js';
|
|
14
15
|
import { probeProcess, spawnBackgroundWriter, stopBackgroundWriter, } from '../processes/managed-writes.js';
|
|
15
16
|
const CONVERSATION_KINDS = new Set([
|
|
@@ -31,6 +32,7 @@ const LOCAL_KINDS = new Set([
|
|
|
31
32
|
'git.discard',
|
|
32
33
|
'terminal.create',
|
|
33
34
|
'parameter.patch',
|
|
35
|
+
'preview.publish',
|
|
34
36
|
]);
|
|
35
37
|
export function canExecuteKind(state, kind) {
|
|
36
38
|
if (LOCAL_KINDS.has(kind))
|
|
@@ -51,8 +53,9 @@ export async function executeOperation(state, scopeKey, operation, payload) {
|
|
|
51
53
|
const checkpointId = asRecord(payload).checkpointId;
|
|
52
54
|
const checkpointRun = () => operation.kind.startsWith('checkpoint.') && typeof checkpointId === 'string'
|
|
53
55
|
? state.journal.exclusive(`checkpoint:${checkpointId}`, run) : run();
|
|
54
|
-
if (['runtime.destroy', 'session.interrupt', 'session.approval.respond'].includes(operation.kind))
|
|
56
|
+
if (['runtime.destroy', 'session.interrupt', 'session.approval.respond', 'preview.publish'].includes(operation.kind)) {
|
|
55
57
|
return run();
|
|
58
|
+
}
|
|
56
59
|
return state.journal.exclusive(`workspace:${revisionScope(state, operation.workspaceId) ?? 'single'}`, checkpointRun);
|
|
57
60
|
}
|
|
58
61
|
function workspaceTurnAllowed(state) {
|
|
@@ -100,6 +103,9 @@ async function executeScopedOperation(state, scopeKey, operation, payload) {
|
|
|
100
103
|
if (operation.kind === 'parameter.patch') {
|
|
101
104
|
return executeParameterPatch(state, scopeKey, current, payload);
|
|
102
105
|
}
|
|
106
|
+
if (operation.kind === 'preview.publish') {
|
|
107
|
+
return executePreviewPublish(state, scopeKey, current);
|
|
108
|
+
}
|
|
103
109
|
if (!state.native?.ready()) {
|
|
104
110
|
return persist(state, scopeKey, transitionOperation(current, 'failed', { error: 'capability_unavailable' }));
|
|
105
111
|
}
|
|
@@ -694,6 +700,26 @@ async function executeParameterPatch(state, scopeKey, operation, payload) {
|
|
|
694
700
|
}
|
|
695
701
|
}).catch((error) => persist(state, scopeKey, transitionOperation(operation, 'failed', { error: parameterPatchError(error) })));
|
|
696
702
|
}
|
|
703
|
+
/**
|
|
704
|
+
* A preview build takes minutes, so the request returns once the operation is accepted; Runtime
|
|
705
|
+
* reads the final state back by operation id. A request the sandbox cannot serve fails at once.
|
|
706
|
+
*/
|
|
707
|
+
async function executePreviewPublish(state, scopeKey, operation) {
|
|
708
|
+
let root;
|
|
709
|
+
try {
|
|
710
|
+
root = await previewWorkspaceRoot(state, operation);
|
|
711
|
+
}
|
|
712
|
+
catch (error) {
|
|
713
|
+
return persist(state, scopeKey, transitionOperation(operation, 'failed', { error: previewFailureCode(error) }));
|
|
714
|
+
}
|
|
715
|
+
const accepted = await persist(state, scopeKey, transitionOperation(operation, 'accepted'));
|
|
716
|
+
trackPreviewPublish(state, runPreviewPublish(state, accepted, root).then(async () => {
|
|
717
|
+
await persist(state, scopeKey, transitionOperation(accepted, 'completed'));
|
|
718
|
+
}, async (error) => {
|
|
719
|
+
await persist(state, scopeKey, transitionOperation(accepted, 'failed', { error: previewFailureCode(error) }));
|
|
720
|
+
}).catch(() => undefined));
|
|
721
|
+
return accepted;
|
|
722
|
+
}
|
|
697
723
|
async function executeTerminalCreate(state, scopeKey, operation, payload) {
|
|
698
724
|
const parsed = terminalCreatePayloadSchema.safeParse(payload ?? {});
|
|
699
725
|
if (!parsed.success) {
|
|
@@ -264,7 +264,7 @@ function handshake(state) {
|
|
|
264
264
|
if (item.name === 'artifacts.singleFile') {
|
|
265
265
|
return destroyed
|
|
266
266
|
? capability('artifacts.singleFile', false, 'runtime_destroyed')
|
|
267
|
-
: capability('artifacts.singleFile',
|
|
267
|
+
: capability('artifacts.singleFile', true);
|
|
268
268
|
}
|
|
269
269
|
return item;
|
|
270
270
|
});
|
|
@@ -559,8 +559,6 @@ async function handleTerminalIdentity(state, request, response, remainder) {
|
|
|
559
559
|
function unavailableReason(state, kind) {
|
|
560
560
|
if (state.journal.destroyed())
|
|
561
561
|
return 'runtime_destroyed';
|
|
562
|
-
if (kind === 'preview.publish')
|
|
563
|
-
return 'capability_not_implemented';
|
|
564
562
|
return state.nativeError ?? 'adapter_not_started';
|
|
565
563
|
}
|
|
566
564
|
function sessionHistoryPath(pathname) {
|
|
@@ -1,13 +1,14 @@
|
|
|
1
|
-
import { spawn } from 'node:child_process';
|
|
2
1
|
import { createHash, randomUUID } from 'node:crypto';
|
|
3
|
-
import { appendFile, lstat, mkdir, readFile, writeFile } from 'node:fs/promises';
|
|
2
|
+
import { appendFile, lstat, mkdir, readFile, stat, writeFile } from 'node:fs/promises';
|
|
3
|
+
import { setTimeout as delay } from 'node:timers/promises';
|
|
4
4
|
import { join } from 'node:path';
|
|
5
|
-
import {
|
|
5
|
+
import { launcherSocket, runDeveloperCommand } from './developer-command.js';
|
|
6
6
|
/** Long enough for a cold store on a game template; a stuck registry must not hold the write lease. */
|
|
7
7
|
const INSTALL_TIMEOUT_MS = 10 * 60_000;
|
|
8
8
|
const LOG_TAIL_LINES = 50;
|
|
9
9
|
const LOG_TAIL_MAX_CHARS = 8_000;
|
|
10
10
|
const WRITE_PREFIX = 'dependencies-';
|
|
11
|
+
const SHARE_WAIT_MS = 60_000;
|
|
11
12
|
/**
|
|
12
13
|
* Projects are managed with pnpm. It follows pnpm-lock.yaml when the lockfile matches, never
|
|
13
14
|
* prompts, and writes plain lines to the log. Package scripts run: the workspace is the user's own
|
|
@@ -27,21 +28,24 @@ export async function planInstall(root) {
|
|
|
27
28
|
}
|
|
28
29
|
const running = new WeakMap();
|
|
29
30
|
/**
|
|
30
|
-
* Starts the install after a workspace is registered
|
|
31
|
-
*
|
|
32
|
-
*
|
|
31
|
+
* Starts the install after a workspace is registered; opening the workspace never waits for it.
|
|
32
|
+
* Registering again retries a failed install and reinstalls after the manifest or lockfile
|
|
33
|
+
* changed. One install runs per workspace at a time: a second call gets the running one. The
|
|
34
|
+
* returned task settles when that install has finished or been found current.
|
|
33
35
|
*/
|
|
34
36
|
export function scheduleDependencyInstall(state, workspaceId, root) {
|
|
35
37
|
const inFlight = running.get(state) ?? new Map();
|
|
36
38
|
running.set(state, inFlight);
|
|
37
|
-
|
|
38
|
-
|
|
39
|
+
const existing = inFlight.get(workspaceId);
|
|
40
|
+
if (existing)
|
|
41
|
+
return existing;
|
|
39
42
|
const task = installOnce(state, workspaceId, root)
|
|
40
43
|
.catch((error) => {
|
|
41
44
|
console.error(`[workspace-runtime] dependency install for ${workspaceId} failed to run`, error);
|
|
42
45
|
})
|
|
43
46
|
.finally(() => inFlight.delete(workspaceId));
|
|
44
47
|
inFlight.set(workspaceId, task);
|
|
48
|
+
return task;
|
|
45
49
|
}
|
|
46
50
|
/** Settles every install started in this process; used by tests and shutdown. */
|
|
47
51
|
export async function settleDependencyInstalls(state) {
|
|
@@ -102,7 +106,12 @@ async function installOnce(state, workspaceId, root) {
|
|
|
102
106
|
});
|
|
103
107
|
let outcome;
|
|
104
108
|
try {
|
|
105
|
-
|
|
109
|
+
// Through the launcher the install runs as the development group, which cannot enter the
|
|
110
|
+
// checkout until Runtime has opened it; a local install runs as the owner and need not wait.
|
|
111
|
+
const shared = !launcherSocket(state) || (await waitUntilShared(root));
|
|
112
|
+
outcome = shared
|
|
113
|
+
? await runInstall(state, workspaceId, root)
|
|
114
|
+
: { code: undefined, timedOut: false, log: '', error: 'workspace_not_shared' };
|
|
106
115
|
}
|
|
107
116
|
finally {
|
|
108
117
|
await state.journal.putManagedWrite({
|
|
@@ -119,7 +128,10 @@ async function installOnce(state, workspaceId, root) {
|
|
|
119
128
|
status: ok ? 'ready' : 'failed',
|
|
120
129
|
...(ok
|
|
121
130
|
? { lockHash: plan.lockHash }
|
|
122
|
-
: {
|
|
131
|
+
: {
|
|
132
|
+
error: outcome.error ??
|
|
133
|
+
(outcome.timedOut ? 'install_timeout' : `install_exit_${outcome.code ?? 'lost'}`),
|
|
134
|
+
}),
|
|
123
135
|
startedAt,
|
|
124
136
|
finishedAt: new Date().toISOString(),
|
|
125
137
|
logTail: tail(outcome.log),
|
|
@@ -132,81 +144,57 @@ async function installOnce(state, workspaceId, root) {
|
|
|
132
144
|
async function runInstall(state, workspaceId, root) {
|
|
133
145
|
// Tests replace the install; a real registry is never reached from them.
|
|
134
146
|
const planned = process.env.PLAYCRAFT_DEPENDENCY_INSTALL_COMMAND || INSTALL_COMMAND;
|
|
135
|
-
const command = `{ ${planned}; } </dev/null 2>&1`;
|
|
136
147
|
const logDir = join(state.config.stateDir, 'dependency-logs');
|
|
137
148
|
await mkdir(logDir, { recursive: true, mode: 0o700 });
|
|
138
149
|
const logPath = join(logDir, `${workspaceId}.log`);
|
|
139
150
|
await writeFile(logPath, `$ ${planned}\n`, { mode: 0o600 });
|
|
140
151
|
let log = '';
|
|
141
152
|
let pending = Promise.resolve();
|
|
142
|
-
const
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
runtimeEpoch: state.runtimeEpoch,
|
|
153
|
-
argv: ['/usr/bin/bash', '-lc', command],
|
|
154
|
-
cwd: root,
|
|
155
|
-
env: {},
|
|
156
|
-
})
|
|
157
|
-
: undefined;
|
|
158
|
-
launched?.stdin?.end();
|
|
159
|
-
const local = launched
|
|
160
|
-
? undefined
|
|
161
|
-
: spawn('bash', ['-lc', command], {
|
|
162
|
-
cwd: root,
|
|
163
|
-
detached: true,
|
|
164
|
-
stdio: ['ignore', 'pipe', 'pipe'],
|
|
165
|
-
});
|
|
166
|
-
const stdout = launched?.stdout ?? local.stdout;
|
|
167
|
-
const stderr = launched?.stderr ?? local.stderr;
|
|
168
|
-
stdout.on('data', record);
|
|
169
|
-
stderr.on('data', record);
|
|
170
|
-
let timedOut = false;
|
|
171
|
-
const code = await new Promise((resolve) => {
|
|
172
|
-
const timer = setTimeout(() => {
|
|
173
|
-
timedOut = true;
|
|
174
|
-
if (launched)
|
|
175
|
-
void launched.stop().catch(() => undefined);
|
|
176
|
-
else if (local?.pid) {
|
|
177
|
-
try {
|
|
178
|
-
process.kill(-local.pid, 'SIGKILL');
|
|
179
|
-
}
|
|
180
|
-
catch {
|
|
181
|
-
/* already gone */
|
|
182
|
-
}
|
|
183
|
-
}
|
|
184
|
-
}, INSTALL_TIMEOUT_MS);
|
|
185
|
-
const finish = (value) => {
|
|
186
|
-
clearTimeout(timer);
|
|
187
|
-
resolve(value);
|
|
188
|
-
};
|
|
189
|
-
if (launched)
|
|
190
|
-
launched.onExit(finish);
|
|
191
|
-
else {
|
|
192
|
-
local.once('error', () => finish(null));
|
|
193
|
-
local.once('close', (value) => finish(value));
|
|
194
|
-
}
|
|
153
|
+
const outcome = await runDeveloperCommand(state, {
|
|
154
|
+
processId: `${WRITE_PREFIX}${workspaceId}`,
|
|
155
|
+
cwd: root,
|
|
156
|
+
command: planned,
|
|
157
|
+
timeoutMs: INSTALL_TIMEOUT_MS,
|
|
158
|
+
onOutput: (chunk) => {
|
|
159
|
+
const text = String(chunk);
|
|
160
|
+
log = (log + text).slice(-64 * 1024);
|
|
161
|
+
pending = pending.then(() => appendFile(logPath, text)).catch(() => undefined);
|
|
162
|
+
},
|
|
195
163
|
});
|
|
196
164
|
await pending;
|
|
197
|
-
return {
|
|
165
|
+
return { ...outcome, log };
|
|
166
|
+
}
|
|
167
|
+
/**
|
|
168
|
+
* The checkout is cloned private to the control user and opened to the development group by
|
|
169
|
+
* Runtime after registration. Group read, write and search on the root is the sign it is open.
|
|
170
|
+
*/
|
|
171
|
+
export async function waitUntilShared(root, timeoutMs = SHARE_WAIT_MS, intervalMs = 500) {
|
|
172
|
+
const deadline = Date.now() + timeoutMs;
|
|
173
|
+
for (;;) {
|
|
174
|
+
try {
|
|
175
|
+
if (((await stat(root)).mode & 0o070) === 0o070)
|
|
176
|
+
return true;
|
|
177
|
+
}
|
|
178
|
+
catch {
|
|
179
|
+
/* not there yet */
|
|
180
|
+
}
|
|
181
|
+
if (Date.now() >= deadline)
|
|
182
|
+
return false;
|
|
183
|
+
await delay(intervalMs);
|
|
184
|
+
}
|
|
198
185
|
}
|
|
199
186
|
/** A local-only ignore, never committed: the change list must not fill up with installed packages. */
|
|
200
|
-
|
|
187
|
+
/** Keeps sandbox-managed folders out of the Git panel without touching the project's .gitignore. */
|
|
188
|
+
export async function excludeFromGit(root, entry = '/node_modules/') {
|
|
201
189
|
const info = join(root, '.git', 'info');
|
|
202
190
|
if (!(await isDirectory(join(root, '.git'))))
|
|
203
191
|
return;
|
|
204
192
|
await mkdir(info, { recursive: true });
|
|
205
193
|
const path = join(info, 'exclude');
|
|
206
194
|
const current = (await readOptional(path)) ?? '';
|
|
207
|
-
if (current.split('\n').some((line) => line.trim() ===
|
|
195
|
+
if (current.split('\n').some((line) => line.trim() === entry))
|
|
208
196
|
return;
|
|
209
|
-
await appendFile(path, `${current && !current.endsWith('\n') ? '\n' : ''}
|
|
197
|
+
await appendFile(path, `${current && !current.endsWith('\n') ? '\n' : ''}${entry}\n`);
|
|
210
198
|
}
|
|
211
199
|
function tail(log) {
|
|
212
200
|
return log.split('\n').slice(-LOG_TAIL_LINES).join('\n').slice(-LOG_TAIL_MAX_CHARS);
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import { startLauncherProcess } from '../codex/launcher-client.js';
|
|
3
|
+
export function launcherSocket(state) {
|
|
4
|
+
const socketPath = process.env.PLAYCRAFT_LAUNCHER_SOCKET;
|
|
5
|
+
return socketPath && state.config.workspaceMode === 'multi' ? socketPath : undefined;
|
|
6
|
+
}
|
|
7
|
+
/**
|
|
8
|
+
* Runs a shell command in the workspace as the development user through the launcher, so whatever
|
|
9
|
+
* it writes belongs to the same user as Codex. Without a launcher (local runs and tests) it runs
|
|
10
|
+
* directly as the owner. Output from both streams goes to `onOutput`.
|
|
11
|
+
*/
|
|
12
|
+
export async function runDeveloperCommand(state, input) {
|
|
13
|
+
const command = `{ ${input.command}; } </dev/null 2>&1`;
|
|
14
|
+
const socketPath = launcherSocket(state);
|
|
15
|
+
const launched = socketPath
|
|
16
|
+
? await startLauncherProcess({
|
|
17
|
+
socketPath,
|
|
18
|
+
processId: input.processId,
|
|
19
|
+
runtimeEpoch: state.runtimeEpoch,
|
|
20
|
+
argv: ['/usr/bin/bash', '-lc', command],
|
|
21
|
+
cwd: input.cwd,
|
|
22
|
+
env: {},
|
|
23
|
+
})
|
|
24
|
+
: undefined;
|
|
25
|
+
launched?.stdin?.end();
|
|
26
|
+
const local = launched
|
|
27
|
+
? undefined
|
|
28
|
+
: spawn('bash', ['-lc', command], {
|
|
29
|
+
cwd: input.cwd,
|
|
30
|
+
detached: true,
|
|
31
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
32
|
+
});
|
|
33
|
+
const stdout = launched?.stdout ?? local.stdout;
|
|
34
|
+
const stderr = launched?.stderr ?? local.stderr;
|
|
35
|
+
stdout.on('data', input.onOutput);
|
|
36
|
+
stderr.on('data', input.onOutput);
|
|
37
|
+
let timedOut = false;
|
|
38
|
+
const code = await new Promise((resolve) => {
|
|
39
|
+
const timer = setTimeout(() => {
|
|
40
|
+
timedOut = true;
|
|
41
|
+
if (launched)
|
|
42
|
+
void launched.stop().catch(() => undefined);
|
|
43
|
+
else if (local?.pid) {
|
|
44
|
+
try {
|
|
45
|
+
process.kill(-local.pid, 'SIGKILL');
|
|
46
|
+
}
|
|
47
|
+
catch {
|
|
48
|
+
/* already gone */
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
}, input.timeoutMs);
|
|
52
|
+
const finish = (value) => {
|
|
53
|
+
clearTimeout(timer);
|
|
54
|
+
resolve(value);
|
|
55
|
+
};
|
|
56
|
+
if (launched)
|
|
57
|
+
launched.onExit(finish);
|
|
58
|
+
else {
|
|
59
|
+
local.once('error', () => finish(null));
|
|
60
|
+
local.once('close', (value) => finish(value));
|
|
61
|
+
}
|
|
62
|
+
});
|
|
63
|
+
return { code, timedOut };
|
|
64
|
+
}
|
|
@@ -114,7 +114,7 @@ export async function registerWorkspace(state, input, leaseFence, preparation) {
|
|
|
114
114
|
}
|
|
115
115
|
await state.journal.putWorkspace(binding);
|
|
116
116
|
// Runs in the background; every registration rechecks it, so reopening retries a failed install.
|
|
117
|
-
scheduleDependencyInstall(state, binding.workspaceId, root);
|
|
117
|
+
void scheduleDependencyInstall(state, binding.workspaceId, root);
|
|
118
118
|
return binding;
|
|
119
119
|
});
|
|
120
120
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@playcraft/cli",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.6",
|
|
4
4
|
"private": false,
|
|
5
5
|
"type": "module",
|
|
6
6
|
"engines": {
|
|
@@ -48,8 +48,8 @@
|
|
|
48
48
|
"@gltf-transform/core": "^4.3.0",
|
|
49
49
|
"@gltf-transform/extensions": "^4.3.0",
|
|
50
50
|
"@gltf-transform/functions": "^4.3.0",
|
|
51
|
-
"@playcraft/build": "^0.1.
|
|
52
|
-
"@playcraft/common": "^0.1.
|
|
51
|
+
"@playcraft/build": "^0.1.6",
|
|
52
|
+
"@playcraft/common": "^0.1.6",
|
|
53
53
|
"better-sqlite3-pinned": "npm:better-sqlite3@13.0.3",
|
|
54
54
|
"chokidar": "^4.0.3",
|
|
55
55
|
"commander": "^13.1.0",
|