@playcraft/cli 0.1.69 → 0.1.70
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/workspace-runtime/batch/content-changes.js +3 -0
- package/dist/workspace-runtime/batch/generate.js +4 -1
- package/dist/workspace-runtime/batch/prepare.js +5 -1
- package/dist/workspace-runtime/batch/source-capture.js +42 -3
- package/dist/workspace-runtime/server/http.js +1 -0
- package/dist/workspace-runtime/verify-artifact.js +2 -1
- package/dist/workspace-runtime/workspaces/remix-references.js +119 -2
- package/package.json +3 -3
|
@@ -1,8 +1,11 @@
|
|
|
1
|
+
import { WORKSPACE_REMIX_REFERENCES_DIRECTORY } from '@playcraft/common/cloud-workspace/runtime';
|
|
1
2
|
import { gitRead } from './git.js';
|
|
2
3
|
const reference = /^(?:[a-f0-9]{40}|[a-f0-9]{64})$/;
|
|
3
4
|
const excludedDirectories = new Set(['node_modules', 'dist', 'build', 'coverage', 'docs', 'test', 'tests', '__tests__']);
|
|
4
5
|
const contentExtension = /\.(?:[cm]?[jt]sx?|vue|svelte|html?|css|s[ac]ss|less|json5?|ya?ml|xml|glsl|wgsl|vert|frag|lua|py|svg|png|jpe?g|webp|avif|gif|bmp|mp[34]|wav|ogg|m4a|webm|glb|gltf|fbx|obj|bin|atlas|woff2?|ttf|otf)$/i;
|
|
5
6
|
function contentPath(path) {
|
|
7
|
+
if (path.startsWith(`${WORKSPACE_REMIX_REFERENCES_DIRECTORY}/`))
|
|
8
|
+
return false;
|
|
6
9
|
const parts = path.split('/');
|
|
7
10
|
if (parts.some(part => part.startsWith('.') || excludedDirectories.has(part)))
|
|
8
11
|
return false;
|
|
@@ -100,7 +100,10 @@ export async function generateBatchMember(state, body) {
|
|
|
100
100
|
const policy = memberExecutionPolicy(root, tempDir);
|
|
101
101
|
await bindMemberPolicy(state, sessionId, policy, { runId: command.runId, ownerFence: command.ownerFence });
|
|
102
102
|
const modelConfigId = command.model && /^[0-9a-f-]{36}$/i.test(command.model) ? command.model : undefined;
|
|
103
|
-
const turnPayload = { text: command.prompt, permissionMode: 'auto', ...(modelConfigId ? { modelConfigId } : {})
|
|
103
|
+
const turnPayload = { text: command.prompt, permissionMode: 'auto', ...(modelConfigId ? { modelConfigId } : {}),
|
|
104
|
+
...(command.remixReferences?.length ? { context: { files: [], skills: [], remixReferences: command.remixReferences.map(reference => ({
|
|
105
|
+
...reference, directoryName: command.directoryName,
|
|
106
|
+
})) } } : {}) };
|
|
104
107
|
const turnKey = operation(state, { kind: 'session.turn', clientRequestId: `batch-turn:${command.runId}`, sessionId, workspaceId: command.workspaceId, directoryName: command.directoryName, payload: turnPayload });
|
|
105
108
|
const prior = state.journal.getOperation(turnKey.scopeKey);
|
|
106
109
|
if (prior && prior.payloadHash !== turnKey.record.payloadHash)
|
|
@@ -14,6 +14,7 @@ import { processHasExited, runProcessGroup } from './processes.js';
|
|
|
14
14
|
import { createInstallLog } from './install-log.js';
|
|
15
15
|
import { stopMemberWriters } from './writer-proof.js';
|
|
16
16
|
import { restoreBatchSkillArtifacts } from './skill-artifacts.js';
|
|
17
|
+
import { rebindBatchRemixReferences } from '../workspaces/remix-references.js';
|
|
17
18
|
import { filesystemObjectStore, memberStatus, readMember, readSnapshot, readStopMarker, serializeRun, sourceCacheDir, writeMember, } from './records.js';
|
|
18
19
|
const BRANCH = /^remix\/draft-[a-f0-9]{16}$/;
|
|
19
20
|
function hashCommand(value) {
|
|
@@ -191,11 +192,14 @@ export async function prepareBatchMember(state, body, options) {
|
|
|
191
192
|
const commit = command.resumeFromCommit ?? snapshot.commitHash;
|
|
192
193
|
await checkoutBranch(staging, command.branchName, commit, repositoryUrl);
|
|
193
194
|
await restoreBatchSkillArtifacts(sourceRoot, staging);
|
|
195
|
+
await rebindBatchRemixReferences(staging, snapshot.sourceDirectoryName, command.directoryName, command.remixReferences ?? []);
|
|
194
196
|
const { rename } = await import('node:fs/promises');
|
|
195
197
|
await rename(staging, root);
|
|
196
198
|
}
|
|
197
|
-
else
|
|
199
|
+
else {
|
|
198
200
|
await restoreBatchSkillArtifacts(sourceRoot, root);
|
|
201
|
+
await rebindBatchRemixReferences(root, snapshot.sourceDirectoryName, command.directoryName, command.remixReferences ?? []);
|
|
202
|
+
}
|
|
199
203
|
await registerWorkspaceDirectory(state, {
|
|
200
204
|
workspaceId: command.workspaceId,
|
|
201
205
|
directoryName: command.directoryName,
|
|
@@ -4,15 +4,19 @@ import { lstat, mkdir, open, readFile, realpath, rename, rm, writeFile } from 'n
|
|
|
4
4
|
import { dirname, isAbsolute, join, relative } from 'node:path';
|
|
5
5
|
import { gzip, gunzip } from 'node:zlib';
|
|
6
6
|
import { promisify } from 'node:util';
|
|
7
|
+
import { WORKSPACE_REMIX_REFERENCES_MANIFEST } from '@playcraft/common/cloud-workspace/runtime';
|
|
7
8
|
import { excludeFromGit } from '../workspaces/dependencies.js';
|
|
9
|
+
import { batchReferenceEnvExamples, batchReferenceMetadata } from '../workspaces/remix-references.js';
|
|
8
10
|
import { gitRead, gitWrite } from './git.js';
|
|
9
11
|
const compress = promisify(gzip);
|
|
10
12
|
const decompress = promisify(gunzip);
|
|
11
13
|
const privateNames = new Set(['.git', 'node_modules', '.ssh', '.aws', '.azure', '.gnupg', '.codex', '.npm', '.pnpm-store',
|
|
12
14
|
'.config', '.npmrc', '.yarnrc', '.yarnrc.yml', '.netrc', '.git-credentials', 'credentials.json', 'credentials', 'secrets',
|
|
13
15
|
'id_rsa', 'id_ed25519', 'settings.local.json', '.playcraft.json', 'playcraft.config.json', '.playcraft-temp', '.playcraft-preview-out']);
|
|
14
|
-
function included(path) {
|
|
15
|
-
|
|
16
|
+
function included(path, referenceExamples) {
|
|
17
|
+
const parts = path.split('/');
|
|
18
|
+
return !parts.some((name, index) => privateNames.has(name) || (/^\.env(?:$|\.)/.test(name) &&
|
|
19
|
+
!(index === parts.length - 1 && referenceExamples.has(path))) || /\.(?:pem|key|p12|pfx)$/.test(name))
|
|
16
20
|
&& !path.startsWith('.playcraft/skills/');
|
|
17
21
|
}
|
|
18
22
|
/** Git keeps only the executable bit, so the digest must not depend on umask or group-write bits. */
|
|
@@ -32,10 +36,12 @@ export function digestFiles(files) {
|
|
|
32
36
|
/** Files Git will accept. Credentials, caches and node_modules are left out. */
|
|
33
37
|
export async function readSourceFiles(root) {
|
|
34
38
|
const source = await realpath(root);
|
|
39
|
+
const referenceExamples = await batchReferenceEnvExamples(source);
|
|
40
|
+
const referenceRecords = await batchReferenceMetadata(source);
|
|
35
41
|
const paths = new Set((await gitRead(source, ['ls-files', '--cached', '--others', '--exclude-standard', '-z'])).split('\0').filter(Boolean));
|
|
36
42
|
const files = new Map();
|
|
37
43
|
for (const path of [...paths].sort()) {
|
|
38
|
-
if (!included(path))
|
|
44
|
+
if (!included(path, referenceExamples))
|
|
39
45
|
continue;
|
|
40
46
|
const full = join(source, path);
|
|
41
47
|
const stat = await lstat(full).catch(error => {
|
|
@@ -55,6 +61,33 @@ export async function readSourceFiles(root) {
|
|
|
55
61
|
await handle.close();
|
|
56
62
|
}
|
|
57
63
|
}
|
|
64
|
+
const manifestFile = files.get(WORKSPACE_REMIX_REFERENCES_MANIFEST);
|
|
65
|
+
if (manifestFile) {
|
|
66
|
+
try {
|
|
67
|
+
const manifest = JSON.parse(manifestFile.body.toString('utf8'));
|
|
68
|
+
if (manifest?.version === 1 && Array.isArray(manifest.items)) {
|
|
69
|
+
const withheld = [];
|
|
70
|
+
for (const record of referenceRecords) {
|
|
71
|
+
for (const file of record.files) {
|
|
72
|
+
const path = `${record.path}/${file.path}`;
|
|
73
|
+
// A damaged reference may make an otherwise allowed example fail verification. That
|
|
74
|
+
// is a reference-integrity error, not evidence that a protected path was withheld.
|
|
75
|
+
const isExample = /^\.env\.(?:example|sample|template)$/i.test(path.split('/').pop() ?? '');
|
|
76
|
+
if (paths.has(path) && !included(path, referenceExamples) &&
|
|
77
|
+
(!isExample || !included(path, new Set([path]))) && (await lstat(join(source, path)).catch(() => undefined))?.isFile()) {
|
|
78
|
+
withheld.push(record.referenceId);
|
|
79
|
+
break;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
delete manifest.batchPolicyWithheldReferenceIds;
|
|
84
|
+
if (withheld.length)
|
|
85
|
+
manifest.batchPolicyWithheldReferenceIds = withheld;
|
|
86
|
+
files.set(WORKSPACE_REMIX_REFERENCES_MANIFEST, { ...manifestFile, body: Buffer.from(JSON.stringify(manifest)) });
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
catch { /* Preserve malformed legacy metadata without letting it block an unrelated batch. */ }
|
|
90
|
+
}
|
|
58
91
|
return files;
|
|
59
92
|
}
|
|
60
93
|
/** A changing source must not silently lose files from the captured input. */
|
|
@@ -99,6 +132,12 @@ export async function captureIndependentSource(source, destination) {
|
|
|
99
132
|
for (const entry of ['/.playcraft.json', '/playcraft.config.json', '/.playcraft/skills/', '/.playcraft-temp/'])
|
|
100
133
|
await excludeFromGit(staging, entry);
|
|
101
134
|
await gitWrite(staging, ['add', '-A', '.']);
|
|
135
|
+
// Imported reference files can be tracked in the source despite .gitignore. Preserve only
|
|
136
|
+
// their filtered staging paths, without changing ignore handling for other source files.
|
|
137
|
+
for (const path of ['.playcraft/remix-references.json', 'references/remixes']) {
|
|
138
|
+
if (await lstat(join(staging, path)).catch(() => undefined))
|
|
139
|
+
await gitWrite(staging, ['add', '--force', '-A', '--', path]);
|
|
140
|
+
}
|
|
102
141
|
await gitWrite(staging, ['commit', '--allow-empty', '-m', 'Freeze batch source']);
|
|
103
142
|
const commitHash = (await gitWrite(staging, ['rev-parse', 'HEAD'])).trim();
|
|
104
143
|
await rename(staging, destination);
|
|
@@ -201,6 +201,7 @@ async function dispatchRuntimeRequest(state, request, response) {
|
|
|
201
201
|
remixReferencesVersion: 1,
|
|
202
202
|
batchSourceFreezeRevision: 2,
|
|
203
203
|
batchSourcePublicationVersion: 1,
|
|
204
|
+
batchReferenceContextVersion: 1,
|
|
204
205
|
...(state.native?.workspaceUi ? { workspaceUiVersion: 1 } : {}),
|
|
205
206
|
...(state.native?.startReview && state.native?.steer ? { turnControlVersion: 1 } : {}),
|
|
206
207
|
...(state.native?.startCompact ? { compactionVersion: 1 } : {}),
|
|
@@ -48,6 +48,7 @@ export async function verifyRuntimeArtifact(expectedVersion) {
|
|
|
48
48
|
assert.equal(identity.runtimeId, config.runtimeId);
|
|
49
49
|
assert.equal(identity.batchSourceFreezeRevision, 2, 'built runtime lacks the required batch source freeze implementation');
|
|
50
50
|
assert.equal(identity.batchSourcePublicationVersion, 1, 'built runtime lacks trusted batch source publication');
|
|
51
|
+
assert.equal(identity.batchReferenceContextVersion, 1, 'built runtime lacks batch reference context support');
|
|
51
52
|
}
|
|
52
53
|
finally {
|
|
53
54
|
await runtime?.close();
|
|
@@ -56,5 +57,5 @@ export async function verifyRuntimeArtifact(expectedVersion) {
|
|
|
56
57
|
}
|
|
57
58
|
if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
|
|
58
59
|
await verifyRuntimeArtifact(process.argv[2] ?? '');
|
|
59
|
-
console.log('Runtime artifact verified: batchSourceFreezeRevision=2, batchSourcePublicationVersion=1');
|
|
60
|
+
console.log('Runtime artifact verified: batchSourceFreezeRevision=2, batchSourcePublicationVersion=1, batchReferenceContextVersion=1');
|
|
60
61
|
}
|
|
@@ -115,12 +115,18 @@ function validateRecord(record, directoryName) {
|
|
|
115
115
|
originalFiles.reduce((sum, file) => sum + file.size, 0) > WORKSPACE_REMIX_REFERENCE_MAX_BYTES)
|
|
116
116
|
throw new RemixReferenceError('remix_reference_manifest_invalid');
|
|
117
117
|
}
|
|
118
|
-
async function
|
|
118
|
+
async function verifyReferenceMetadata(root, record, directoryName, alternateDirectory) {
|
|
119
119
|
validateRecord(record, directoryName);
|
|
120
120
|
const target = await safeDirectory(root, record.path);
|
|
121
121
|
const receipt = JSON.parse((await readFileSafely(target, RECEIPT, MANIFEST_MAX_BYTES)).toString('utf8'));
|
|
122
|
-
|
|
122
|
+
const parsedReceipt = workspaceRemixReferenceSchema.parse(receipt);
|
|
123
|
+
if (JSON.stringify(parsedReceipt) !== JSON.stringify(record)
|
|
124
|
+
&& (!alternateDirectory || JSON.stringify(parsedReceipt) !== JSON.stringify({ ...record, directoryName: alternateDirectory })))
|
|
123
125
|
throw new RemixReferenceError('remix_reference_changed');
|
|
126
|
+
return target;
|
|
127
|
+
}
|
|
128
|
+
async function verifyReference(root, record, directoryName, alternateDirectory) {
|
|
129
|
+
const target = await verifyReferenceMetadata(root, record, directoryName, alternateDirectory);
|
|
124
130
|
if ((await readFileSafely(target, ATTRIBUTES, 256)).toString('utf8') !== SNAPSHOT_ATTRIBUTES)
|
|
125
131
|
throw new RemixReferenceError('remix_reference_changed');
|
|
126
132
|
const expected = new Set([RECEIPT, ATTRIBUTES, ...record.files.map(file => file.path)]);
|
|
@@ -144,6 +150,117 @@ async function verifyReference(root, record, directoryName) {
|
|
|
144
150
|
throw new RemixReferenceError('remix_reference_changed');
|
|
145
151
|
}
|
|
146
152
|
}
|
|
153
|
+
/** Metadata-verified records identify files excluded by the batch source safety policy. */
|
|
154
|
+
export async function batchReferenceMetadata(root) {
|
|
155
|
+
let items;
|
|
156
|
+
try {
|
|
157
|
+
items = await readManifest(root);
|
|
158
|
+
}
|
|
159
|
+
catch {
|
|
160
|
+
return [];
|
|
161
|
+
}
|
|
162
|
+
const verified = [];
|
|
163
|
+
for (const record of items) {
|
|
164
|
+
try {
|
|
165
|
+
await verifyReferenceMetadata(root, record, record.directoryName);
|
|
166
|
+
verified.push(record);
|
|
167
|
+
}
|
|
168
|
+
catch { /* An unrelated invalid reference cannot block source capture. */ }
|
|
169
|
+
}
|
|
170
|
+
return verified;
|
|
171
|
+
}
|
|
172
|
+
/** Only verified imported snapshots can retain credential-free .env examples in a batch freeze. */
|
|
173
|
+
export async function batchReferenceEnvExamples(root) {
|
|
174
|
+
const examples = new Set();
|
|
175
|
+
let items;
|
|
176
|
+
try {
|
|
177
|
+
items = await readManifest(root);
|
|
178
|
+
}
|
|
179
|
+
catch {
|
|
180
|
+
return examples;
|
|
181
|
+
}
|
|
182
|
+
for (const record of items) {
|
|
183
|
+
const files = record.files.filter(file => /^\.env\.(?:example|sample|template)$/i.test(file.path.split('/').pop() ?? '')
|
|
184
|
+
&& !isCredentialFile(file.sourcePath));
|
|
185
|
+
if (!files.length)
|
|
186
|
+
continue;
|
|
187
|
+
try {
|
|
188
|
+
await verifyReference(root, record, record.directoryName);
|
|
189
|
+
for (const file of files)
|
|
190
|
+
examples.add(`${record.path}/${file.path}`);
|
|
191
|
+
}
|
|
192
|
+
catch { /* An unselected damaged reference must not stop freezing the project. */ }
|
|
193
|
+
}
|
|
194
|
+
return examples;
|
|
195
|
+
}
|
|
196
|
+
/** Rebind frozen source references inside a member checkout; content and source identities remain immutable. */
|
|
197
|
+
export async function rebindBatchRemixReferences(root, sourceDirectory, memberDirectory, selected) {
|
|
198
|
+
workspaceDirectoryNameSchema.parse(sourceDirectory);
|
|
199
|
+
workspaceDirectoryNameSchema.parse(memberDirectory);
|
|
200
|
+
const rootReal = await realpath(root);
|
|
201
|
+
let items;
|
|
202
|
+
try {
|
|
203
|
+
items = await readManifest(rootReal);
|
|
204
|
+
}
|
|
205
|
+
catch (error) {
|
|
206
|
+
if (!selected.length)
|
|
207
|
+
return;
|
|
208
|
+
throw error;
|
|
209
|
+
}
|
|
210
|
+
for (const reference of selected) {
|
|
211
|
+
const record = items.find(item => item.referenceId === reference.referenceId);
|
|
212
|
+
if (reference.directoryName !== sourceDirectory || !record || record.contentHash !== reference.snapshotHash)
|
|
213
|
+
throw new RemixReferenceError('context_remix_reference_changed');
|
|
214
|
+
}
|
|
215
|
+
if (selected.length) {
|
|
216
|
+
const manifest = JSON.parse((await readFileSafely(rootReal, WORKSPACE_REMIX_REFERENCES_MANIFEST, MANIFEST_MAX_BYTES)).toString('utf8'));
|
|
217
|
+
if (Array.isArray(manifest.batchPolicyWithheldReferenceIds) && selected.some(reference => manifest.batchPolicyWithheldReferenceIds.includes(reference.referenceId)))
|
|
218
|
+
throw new RemixReferenceError('batch_reference_files_withheld');
|
|
219
|
+
}
|
|
220
|
+
// Selected references must be complete. Other records need only valid metadata to be rebound;
|
|
221
|
+
// their contents remain subject to the normal checks when the user later lists or selects them.
|
|
222
|
+
const rebound = [];
|
|
223
|
+
const writable = [];
|
|
224
|
+
for (const record of items) {
|
|
225
|
+
const isSelected = selected.some(reference => reference.referenceId === record.referenceId);
|
|
226
|
+
try {
|
|
227
|
+
if (record.directoryName !== sourceDirectory && record.directoryName !== memberDirectory)
|
|
228
|
+
throw new RemixReferenceError('remix_reference_manifest_invalid');
|
|
229
|
+
const alternate = record.directoryName === sourceDirectory ? memberDirectory : sourceDirectory;
|
|
230
|
+
if (isSelected)
|
|
231
|
+
await verifyReference(rootReal, record, record.directoryName, alternate);
|
|
232
|
+
else
|
|
233
|
+
await verifyReferenceMetadata(rootReal, record, record.directoryName, alternate);
|
|
234
|
+
const next = { ...record, directoryName: memberDirectory };
|
|
235
|
+
rebound.push(next);
|
|
236
|
+
writable.push(next);
|
|
237
|
+
}
|
|
238
|
+
catch (error) {
|
|
239
|
+
if (isSelected)
|
|
240
|
+
throw error;
|
|
241
|
+
rebound.push(record);
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
if (!items.length)
|
|
245
|
+
return;
|
|
246
|
+
const metadata = await safeDirectory(rootReal, '.playcraft');
|
|
247
|
+
const atomicWrite = async (destination, body) => {
|
|
248
|
+
const temporary = join(metadata, `.batch-reference-${randomUUID()}.tmp`);
|
|
249
|
+
try {
|
|
250
|
+
// shared
|
|
251
|
+
await writeFile(temporary, body, { flag: 'wx', mode: 0o644 });
|
|
252
|
+
await rename(temporary, destination);
|
|
253
|
+
}
|
|
254
|
+
finally {
|
|
255
|
+
await rm(temporary, { force: true });
|
|
256
|
+
}
|
|
257
|
+
};
|
|
258
|
+
for (const record of writable) {
|
|
259
|
+
const target = await safeDirectory(rootReal, record.path);
|
|
260
|
+
await atomicWrite(join(target, RECEIPT), JSON.stringify(record));
|
|
261
|
+
}
|
|
262
|
+
await atomicWrite(join(metadata, 'remix-references.json'), JSON.stringify({ version: 1, items: rebound }));
|
|
263
|
+
}
|
|
147
264
|
/** Reads revalidate the snapshot, so changed/deleted files cannot silently remain attached to a turn. */
|
|
148
265
|
export async function listWorkspaceRemixReferences(root, directoryName = 'basic') {
|
|
149
266
|
workspaceDirectoryNameSchema.parse(directoryName);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@playcraft/cli",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.70",
|
|
4
4
|
"private": false,
|
|
5
5
|
"type": "module",
|
|
6
6
|
"engines": {
|
|
@@ -56,8 +56,8 @@
|
|
|
56
56
|
"@gltf-transform/core": "^4.3.0",
|
|
57
57
|
"@gltf-transform/extensions": "^4.3.0",
|
|
58
58
|
"@gltf-transform/functions": "^4.3.0",
|
|
59
|
-
"@playcraft/build": "0.1.
|
|
60
|
-
"@playcraft/common": "0.1.
|
|
59
|
+
"@playcraft/build": "0.1.70",
|
|
60
|
+
"@playcraft/common": "0.1.70",
|
|
61
61
|
"better-sqlite3-pinned": "npm:better-sqlite3@13.0.3",
|
|
62
62
|
"chokidar": "^4.0.3",
|
|
63
63
|
"commander": "^13.1.0",
|