@playcraft/cli 0.1.68 → 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/asset-library/provenance.js +169 -0
- package/dist/commands/library-messages.js +27 -9
- package/dist/commands/library.js +62 -10
- package/dist/model-adaptation/project-template.js +56 -3
- package/dist/model-adaptation/project.js +15 -1
- package/dist/workspace-runtime/batch/content-changes.js +3 -0
- package/dist/workspace-runtime/batch/freeze.js +15 -2
- package/dist/workspace-runtime/batch/generate.js +4 -1
- package/dist/workspace-runtime/batch/git.js +16 -4
- package/dist/workspace-runtime/batch/prepare.js +5 -1
- package/dist/workspace-runtime/batch/publication.js +96 -0
- package/dist/workspace-runtime/batch/source-candidate.js +2 -1
- package/dist/workspace-runtime/batch/source-capture.js +42 -3
- package/dist/workspace-runtime/batch/source-publish.js +26 -15
- package/dist/workspace-runtime/persistence/sqlite-worker.js +2 -1
- package/dist/workspace-runtime/serve.js +50 -18
- package/dist/workspace-runtime/server/dispatch.js +42 -9
- package/dist/workspace-runtime/server/http.js +13 -2
- package/dist/workspace-runtime/verify-artifact.js +3 -1
- package/dist/workspace-runtime/workspaces/archive.js +28 -3
- package/dist/workspace-runtime/workspaces/asset-library-import.js +33 -2
- package/dist/workspace-runtime/workspaces/asset-provenance-verifier.js +36 -0
- package/dist/workspace-runtime/workspaces/registry.js +3 -5
- package/dist/workspace-runtime/workspaces/remix-references.js +119 -2
- package/dist/workspace-runtime/workspaces/remote-attach-marker.js +55 -0
- package/package.json +3 -3
- package/project-template/.claude/skills/playcraft-workspace-assets/SKILL.md +15 -4
- package/project-template-v2/.claude/skills/playcraft-workspace-assets/SKILL.md +15 -4
- package/project-template-v2/.playcraft/skills.lock.json +2 -2
- package/project-templates/phaser/.claude/skills/playcraft-workspace-assets/SKILL.md +15 -4
- package/project-templates/threejs/.claude/skills/playcraft-workspace-assets/SKILL.md +15 -4
|
@@ -1,13 +1,21 @@
|
|
|
1
1
|
import { constants } from 'node:fs';
|
|
2
|
+
import { createHash } from 'node:crypto';
|
|
2
3
|
import { lstat, open, opendir, realpath } from 'node:fs/promises';
|
|
3
4
|
import { join, relative, sep } from 'node:path';
|
|
4
5
|
import { Readable, Transform } from 'node:stream';
|
|
5
6
|
import { pipeline } from 'node:stream/promises';
|
|
6
7
|
import { createDeflateRaw } from 'node:zlib';
|
|
7
8
|
import { isCredentialFile } from './git.js';
|
|
8
|
-
|
|
9
|
+
import { verifyWorkspaceAssetProvenance, WORKSPACE_ASSET_PROVENANCE_PATH } from '../../asset-library/provenance.js';
|
|
10
|
+
import { workspaceAssetProvenanceDocumentSchema } from '@playcraft/common/cloud-workspace/runtime';
|
|
11
|
+
const EXCLUDED_NAMES = new Set(['.git', 'node_modules', '.playcraft-temp', '.playcraft.json', '.playcraft-workspace.json']);
|
|
9
12
|
const MAX_SOURCE_BYTES = 2 * 1024 * 1024 * 1024;
|
|
10
13
|
const MAX_ENTRIES = 50_000;
|
|
14
|
+
const PUBLIC_ARCHIVE_ERRORS = new Set(['workspace_archive_changed', 'workspace_archive_too_large', 'workspace_archive_invalid_path',
|
|
15
|
+
'workspace_archive_failed', 'asset_provenance_authority_required', 'asset_provenance_authority_unavailable',
|
|
16
|
+
'asset_provenance_authority_mismatch', 'asset_provenance_authority_response_bound', 'asset_provenance_model_changed',
|
|
17
|
+
'asset_provenance_license_changed', 'asset_provenance_record_missing', 'asset_provenance_license_path_mismatch',
|
|
18
|
+
'asset_provenance_too_large', 'asset_provenance_changed', 'asset_provenance_notice_conflict', 'asset_provenance_expected_path_invalid']);
|
|
11
19
|
const UTF8_DESCRIPTOR_FLAGS = 0x0808;
|
|
12
20
|
const CRC_TABLE = Uint32Array.from({ length: 256 }, (_, value) => {
|
|
13
21
|
let crc = value;
|
|
@@ -18,6 +26,13 @@ const CRC_TABLE = Uint32Array.from({ length: 256 }, (_, value) => {
|
|
|
18
26
|
/** Exports working files, including ignored deliverables, without following symbolic links. */
|
|
19
27
|
export async function createWorkspaceArchive(root, options = {}) {
|
|
20
28
|
const rootReal = await realpath(root);
|
|
29
|
+
const provenance = await verifyWorkspaceAssetProvenance(rootReal, { verify: options.verifyProvenance,
|
|
30
|
+
expectedModelPaths: options.expectedModelPaths });
|
|
31
|
+
const expectedHashes = new Map();
|
|
32
|
+
for (const asset of provenance?.assets ?? []) {
|
|
33
|
+
expectedHashes.set(asset.path, asset.provenance.modelSha256);
|
|
34
|
+
expectedHashes.set(asset.licensePath, createHash('sha256').update(asset.provenance.licenseText).digest('hex'));
|
|
35
|
+
}
|
|
21
36
|
const excludedRoots = await Promise.all((options.excludedRoots ?? []).map(path => realpath(path).catch(() => path)));
|
|
22
37
|
const entries = [];
|
|
23
38
|
let bytes = 0;
|
|
@@ -52,7 +67,8 @@ export async function createWorkspaceArchive(root, options = {}) {
|
|
|
52
67
|
bytes += info.size;
|
|
53
68
|
if (bytes > (options.maxSourceBytes ?? MAX_SOURCE_BYTES) || entries.length >= (options.maxEntries ?? MAX_ENTRIES))
|
|
54
69
|
throw new Error('workspace_archive_too_large');
|
|
55
|
-
entries.push({ path, target, info
|
|
70
|
+
entries.push({ path, target, info, expectedHash: expectedHashes.get(path),
|
|
71
|
+
...(path === WORKSPACE_ASSET_PROVENANCE_PATH && provenance ? { expectedProvenance: JSON.stringify(provenance) } : {}) });
|
|
56
72
|
}
|
|
57
73
|
};
|
|
58
74
|
await walk(rootReal);
|
|
@@ -89,6 +105,8 @@ async function* zipFiles(root, entries, signal) {
|
|
|
89
105
|
let crc = 0xffffffff;
|
|
90
106
|
let size = 0;
|
|
91
107
|
let compressedSize = 0;
|
|
108
|
+
const hash = entry.expectedHash ? createHash('sha256') : undefined;
|
|
109
|
+
const sourceChunks = [];
|
|
92
110
|
const checksum = new Transform({ transform(chunk, _encoding, done) {
|
|
93
111
|
size += chunk.length;
|
|
94
112
|
if (size > info.size) {
|
|
@@ -97,6 +115,9 @@ async function* zipFiles(root, entries, signal) {
|
|
|
97
115
|
}
|
|
98
116
|
for (const byte of chunk)
|
|
99
117
|
crc = CRC_TABLE[(crc ^ byte) & 0xff] ^ (crc >>> 8);
|
|
118
|
+
hash?.update(chunk);
|
|
119
|
+
if (entry.expectedProvenance)
|
|
120
|
+
sourceChunks.push(chunk);
|
|
100
121
|
done(null, chunk);
|
|
101
122
|
} });
|
|
102
123
|
const compressed = createDeflateRaw();
|
|
@@ -118,6 +139,10 @@ async function* zipFiles(root, entries, signal) {
|
|
|
118
139
|
}
|
|
119
140
|
if (size !== info.size)
|
|
120
141
|
throw new Error('workspace_archive_changed');
|
|
142
|
+
if (hash && hash.digest('hex') !== entry.expectedHash)
|
|
143
|
+
throw new Error('asset_provenance_model_changed');
|
|
144
|
+
if (entry.expectedProvenance && JSON.stringify(workspaceAssetProvenanceDocumentSchema.parse(JSON.parse(Buffer.concat(sourceChunks).toString('utf8')))) !== entry.expectedProvenance)
|
|
145
|
+
throw new Error('asset_provenance_changed');
|
|
121
146
|
const after = await handle.stat();
|
|
122
147
|
if (after.size !== info.size || after.mtimeMs !== info.mtimeMs || after.ctimeMs !== info.ctimeMs)
|
|
123
148
|
throw new Error('workspace_archive_changed');
|
|
@@ -188,7 +213,7 @@ export async function serveWorkspaceArchive(root, request, response, options = {
|
|
|
188
213
|
else if (!controller.signal.aborted) {
|
|
189
214
|
const reason = error instanceof Error ? error.message : 'workspace_archive_failed';
|
|
190
215
|
response.writeHead(reason === 'workspace_archive_too_large' ? 413 : 409, { 'content-type': 'application/json', 'cache-control': 'private, no-store' });
|
|
191
|
-
response.end(JSON.stringify({ error:
|
|
216
|
+
response.end(JSON.stringify({ error: PUBLIC_ARCHIVE_ERRORS.has(reason) ? reason : 'workspace_archive_failed' }));
|
|
192
217
|
}
|
|
193
218
|
}
|
|
194
219
|
finally {
|
|
@@ -6,6 +6,7 @@ import { BINARY_WORKSPACE_FILE_MAX_BYTES, WORKSPACE_LIBRARY_ATTACHMENT_MAX_BYTES
|
|
|
6
6
|
import { observeWorkspaceBinaryFile, writeWorkspaceBinaryFile } from './binary-write.js';
|
|
7
7
|
import { shareWorkspaceAssetDirectory } from './asset-publication-access.js';
|
|
8
8
|
import { ensureSharedDirectory } from './shared-write.js';
|
|
9
|
+
import { publishLibraryModels } from '../../asset-library/provenance.js';
|
|
9
10
|
import { isEditableAudioLibraryAsset, prepareAudioLibraryImport } from './audio-assets/library-import.js';
|
|
10
11
|
import { readAudioArtifactPackage } from './audio-assets/artifact-package.js';
|
|
11
12
|
const RESERVED = new Set(['.git', 'node_modules', '.playcraft-temp', '.tmp']);
|
|
@@ -341,6 +342,8 @@ export async function importWorkspaceLibraryAssets(root, selection, snapshot, as
|
|
|
341
342
|
await createPublicDirectory(rootReal, payload.targetDirectory);
|
|
342
343
|
const result = { paths: [], skippedPaths: [], failures: [...transport.failures] };
|
|
343
344
|
const processed = new Set(transport.failures.map(failure => failure.assetId));
|
|
345
|
+
const curated = [];
|
|
346
|
+
const plannedTargets = new Set();
|
|
344
347
|
const progress = () => options.onProgress?.({ ...result, failures: [...result.failures,
|
|
345
348
|
...payload.assetIds.filter(id => !processed.has(id)).map(assetId => ({ assetId, error: 'asset_library_import_interrupted' }))] });
|
|
346
349
|
await progress();
|
|
@@ -361,7 +364,7 @@ export async function importWorkspaceLibraryAssets(root, selection, snapshot, as
|
|
|
361
364
|
let name = originalName;
|
|
362
365
|
let target = validPath([payload.targetDirectory, name].filter(Boolean).join('/'));
|
|
363
366
|
let existing = await statTarget(rootReal, target);
|
|
364
|
-
if (existing && payload.collision === 'skip') {
|
|
367
|
+
if ((existing || plannedTargets.has(target)) && payload.collision === 'skip') {
|
|
365
368
|
result.skippedPaths.push(target);
|
|
366
369
|
processed.add(assetId);
|
|
367
370
|
await progress();
|
|
@@ -370,7 +373,7 @@ export async function importWorkspaceLibraryAssets(root, selection, snapshot, as
|
|
|
370
373
|
if (payload.collision === 'rename') {
|
|
371
374
|
const extension = asset.kind === 'bundle' ? '' : extname(originalName);
|
|
372
375
|
const stem = originalName.slice(0, originalName.length - extension.length);
|
|
373
|
-
for (let suffix = 1; existing; suffix++) {
|
|
376
|
+
for (let suffix = 1; existing || plannedTargets.has(target); suffix++) {
|
|
374
377
|
if (suffix > 10_000)
|
|
375
378
|
throw new AssetLibraryImportError('content_conflict');
|
|
376
379
|
name = `${stem} (${suffix})${extension}`;
|
|
@@ -378,6 +381,14 @@ export async function importWorkspaceLibraryAssets(root, selection, snapshot, as
|
|
|
378
381
|
existing = await statTarget(rootReal, target);
|
|
379
382
|
}
|
|
380
383
|
}
|
|
384
|
+
if (asset.provenance) {
|
|
385
|
+
const observed = existing ? await observeWorkspaceBinaryFile(rootReal, target) : undefined;
|
|
386
|
+
curated.push({ assetId, path: target, bytes: Buffer.from(asset.files[0].content, 'base64'),
|
|
387
|
+
provenance: asset.provenance, expectedContentHash: observed?.contentHash ?? null });
|
|
388
|
+
plannedTargets.add(target);
|
|
389
|
+
await progress();
|
|
390
|
+
continue;
|
|
391
|
+
}
|
|
381
392
|
if (asset.kind === 'bundle') {
|
|
382
393
|
const audio = isEditableAudioLibraryAsset(asset) ? await prepareAudioLibraryImport(rootReal, target, asset, assertCurrent) : undefined;
|
|
383
394
|
const incoming = audio?.asset ?? asset;
|
|
@@ -396,6 +407,8 @@ export async function importWorkspaceLibraryAssets(root, selection, snapshot, as
|
|
|
396
407
|
}
|
|
397
408
|
}
|
|
398
409
|
catch (error) {
|
|
410
|
+
if (error instanceof Error && ['stale_lease', 'batch_workspace_readonly'].includes(error.message))
|
|
411
|
+
throw error;
|
|
399
412
|
const code = error instanceof AssetLibraryImportError ? error.message
|
|
400
413
|
: error instanceof Error && ['invalid_audio_library_package', 'audio_library_engine_unavailable', 'audio_library_source_unverified', 'audio_representation_conflict'].includes(error.message) ? error.message
|
|
401
414
|
: error?.code === 'content_conflict' ? 'content_conflict'
|
|
@@ -408,5 +421,23 @@ export async function importWorkspaceLibraryAssets(root, selection, snapshot, as
|
|
|
408
421
|
if (publishedTransaction)
|
|
409
422
|
await removeTransaction(rootReal, publishedTransaction).catch(() => undefined);
|
|
410
423
|
}
|
|
424
|
+
if (curated.length) {
|
|
425
|
+
try {
|
|
426
|
+
const receipt = await publishLibraryModels(rootReal, curated, { operationId: options.operationId,
|
|
427
|
+
scope: options.scope, assertCurrent });
|
|
428
|
+
result.bundleReceipt = receipt;
|
|
429
|
+
result.paths.push(...receipt.files.map(file => file.path));
|
|
430
|
+
}
|
|
431
|
+
catch (error) {
|
|
432
|
+
if (error instanceof Error && ['stale_lease', 'batch_workspace_readonly'].includes(error.message))
|
|
433
|
+
throw error;
|
|
434
|
+
const code = error.code ?? (error instanceof Error ? error.message : 'asset_library_import_failed');
|
|
435
|
+
for (const model of curated)
|
|
436
|
+
result.failures.push({ assetId: model.assetId, error: code.slice(0, 256) });
|
|
437
|
+
}
|
|
438
|
+
for (const model of curated)
|
|
439
|
+
processed.add(model.assetId);
|
|
440
|
+
await progress();
|
|
441
|
+
}
|
|
411
442
|
return workspaceAssetLibraryImportResultSchema.parse(result);
|
|
412
443
|
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { readFile } from 'node:fs/promises';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { assetLibraryModelProvenanceSchema, canonicalAssetLibraryModelProvenance } from '@playcraft/common/cloud-workspace/runtime';
|
|
4
|
+
import { businessEndpoint } from '../../utils/environment-endpoints.js';
|
|
5
|
+
import { ensureCliCredentials } from './cli-credentials.js';
|
|
6
|
+
/** Compare the portable source record with the current user's immutable server revision. */
|
|
7
|
+
export async function verifyRuntimeAssetProvenance(state, provenance) {
|
|
8
|
+
await ensureCliCredentials(state);
|
|
9
|
+
const credential = JSON.parse(await readFile(join(state.config.workspaceRoot, '.playcraft.json'), 'utf8'));
|
|
10
|
+
if (typeof credential.apiUrl !== 'string' || typeof credential.token !== 'string' || !credential.token)
|
|
11
|
+
throw new Error('asset_provenance_authority_unavailable');
|
|
12
|
+
const url = businessEndpoint(credential.apiUrl, `/api/agent/tools/asset-library/${encodeURIComponent(provenance.assetId)}`);
|
|
13
|
+
url.searchParams.set('revisionId', provenance.revisionId);
|
|
14
|
+
const response = await fetch(url, { redirect: 'error', signal: AbortSignal.timeout(15_000),
|
|
15
|
+
headers: { 'X-Sandbox-Token': credential.token, 'playcraft-env': state.config.environment } });
|
|
16
|
+
if (!response.ok) {
|
|
17
|
+
await response.body?.cancel();
|
|
18
|
+
throw new Error('asset_provenance_authority_unavailable');
|
|
19
|
+
}
|
|
20
|
+
const maximum = 256 * 1024, chunks = [];
|
|
21
|
+
let length = 0;
|
|
22
|
+
if (!response.body)
|
|
23
|
+
throw new Error('asset_provenance_authority_unavailable');
|
|
24
|
+
for await (const chunk of response.body) {
|
|
25
|
+
length += chunk.byteLength;
|
|
26
|
+
if (length > maximum) {
|
|
27
|
+
await response.body.cancel().catch(() => undefined);
|
|
28
|
+
throw new Error('asset_provenance_authority_response_bound');
|
|
29
|
+
}
|
|
30
|
+
chunks.push(chunk);
|
|
31
|
+
}
|
|
32
|
+
const result = JSON.parse(Buffer.concat(chunks).toString('utf8'));
|
|
33
|
+
const authoritative = assetLibraryModelProvenanceSchema.parse(result.provenance);
|
|
34
|
+
if (canonicalAssetLibraryModelProvenance(authoritative) !== canonicalAssetLibraryModelProvenance(provenance))
|
|
35
|
+
throw new Error('asset_provenance_authority_mismatch');
|
|
36
|
+
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { execFile } from 'node:child_process';
|
|
2
|
-
import { lstat, mkdir, realpath, rm
|
|
2
|
+
import { lstat, mkdir, realpath, rm } from 'node:fs/promises';
|
|
3
3
|
import { dirname, join, relative } from 'node:path';
|
|
4
4
|
import { promisify } from 'node:util';
|
|
5
5
|
import { isLocalWorkspaceGitAuthMode, sameWorkspaceRegistration, workspaceRegistrationSchema, batchSourceSnapshotSchema, } from '@playcraft/common/cloud-workspace/runtime';
|
|
@@ -10,6 +10,7 @@ import { ensureWorkspaceSkills, readWorkspaceSkills } from './skills.js';
|
|
|
10
10
|
import { loadWorkspaceSkillArtifact, workspacePlatformSkillAccess } from './skill-artifact-loader.js';
|
|
11
11
|
import { writeWorkspaceCliContext } from './cli-context.js';
|
|
12
12
|
import { attachWorkspaceRemote } from './git.js';
|
|
13
|
+
import { markRemoteAttachPending } from './remote-attach-marker.js';
|
|
13
14
|
import { ensureWorkspaceArtifactInstructions } from './instructions.js';
|
|
14
15
|
import { repairWorkspaceShares } from './workspace-shares.js';
|
|
15
16
|
export { WorkspaceContextError } from './context-error.js';
|
|
@@ -183,10 +184,7 @@ export async function registerWorkspace(state, input, leaseFence, preparation, r
|
|
|
183
184
|
&& (needsRemote || remoteUpgrade || await lstat(attachMarker).then(() => true, () => false))) {
|
|
184
185
|
if (preparation?.mode !== 'clone' || !state.gitCredentials)
|
|
185
186
|
throw new WorkspaceContextError('workspace_git_gateway_unavailable');
|
|
186
|
-
|
|
187
|
-
await mkdir(join(state.config.stateDir, 'workspace-remote-attach'), { recursive: true, mode: 0o700 });
|
|
188
|
-
// private
|
|
189
|
-
await writeFile(attachMarker, JSON.stringify({ repositoryUrl: binding.repositoryUrl, branchName: binding.branchName }), { mode: 0o600 });
|
|
187
|
+
await markRemoteAttachPending(state, binding.workspaceId, binding.repositoryUrl, binding.branchName);
|
|
190
188
|
// The credential helper only serves a workspace the journal knows to be bound.
|
|
191
189
|
await state.journal.putWorkspace({ ...binding, directories });
|
|
192
190
|
try {
|
|
@@ -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);
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import { link, mkdir, open, readFile, unlink } from 'node:fs/promises';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
function markerPath(state, workspaceId) {
|
|
5
|
+
return join(state.config.stateDir, 'workspace-remote-attach', `${workspaceId}.json`);
|
|
6
|
+
}
|
|
7
|
+
/** Caller holds the workspace journal lock. Never replace a different unfinished attach. */
|
|
8
|
+
export async function markRemoteAttachPending(state, workspaceId, repositoryUrl, branchName) {
|
|
9
|
+
const path = markerPath(state, workspaceId);
|
|
10
|
+
const directory = join(state.config.stateDir, 'workspace-remote-attach');
|
|
11
|
+
// private: the attach marker is controller state outside the shared workspace tree.
|
|
12
|
+
await mkdir(directory, { recursive: true, mode: 0o700 });
|
|
13
|
+
const expected = { repositoryUrl, branchName };
|
|
14
|
+
const temporary = `${path}.${randomUUID()}.tmp`;
|
|
15
|
+
// private: only the controller reads an unfinished attach back.
|
|
16
|
+
const handle = await open(temporary, 'wx', 0o600);
|
|
17
|
+
try {
|
|
18
|
+
await handle.writeFile(JSON.stringify(expected));
|
|
19
|
+
await handle.sync();
|
|
20
|
+
await handle.close();
|
|
21
|
+
try {
|
|
22
|
+
await link(temporary, path);
|
|
23
|
+
}
|
|
24
|
+
catch (error) {
|
|
25
|
+
if (error.code !== 'EEXIST')
|
|
26
|
+
throw error;
|
|
27
|
+
const previous = JSON.parse(await readFile(path, 'utf8'));
|
|
28
|
+
if (previous.repositoryUrl !== repositoryUrl || previous.branchName !== branchName)
|
|
29
|
+
throw new Error('batch_source_identity_mismatch');
|
|
30
|
+
}
|
|
31
|
+
const folder = await open(directory, 'r');
|
|
32
|
+
try {
|
|
33
|
+
await folder.sync();
|
|
34
|
+
}
|
|
35
|
+
finally {
|
|
36
|
+
await folder.close();
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
finally {
|
|
40
|
+
await handle.close();
|
|
41
|
+
await unlink(temporary).catch(() => undefined);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
/** Caller holds the workspace journal lock to serialize with attach and marker removal. */
|
|
45
|
+
export async function remoteAttachPending(state, workspaceId) {
|
|
46
|
+
try {
|
|
47
|
+
await readFile(markerPath(state, workspaceId));
|
|
48
|
+
return true;
|
|
49
|
+
}
|
|
50
|
+
catch (error) {
|
|
51
|
+
if (error.code === 'ENOENT')
|
|
52
|
+
return false;
|
|
53
|
+
throw error;
|
|
54
|
+
}
|
|
55
|
+
}
|
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",
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: playcraft-workspace-assets
|
|
3
|
-
description: Use when finding or downloading shared library assets, or
|
|
3
|
+
description: Use when selecting 3D asset kits, finding or downloading shared library assets, or managing durable media generation tasks in a PlayCraft cloud workspace.
|
|
4
4
|
compatibility: agent,opencode
|
|
5
|
-
version: 1.
|
|
5
|
+
version: 1.1.0
|
|
6
6
|
surfaces:
|
|
7
7
|
- sandbox-agent
|
|
8
8
|
---
|
|
@@ -13,6 +13,17 @@ surfaces:
|
|
|
13
13
|
|
|
14
14
|
## 查找并复用素材
|
|
15
15
|
|
|
16
|
+
一组三维物件的检索入口为素材包;包内覆盖结果给出逐项可用模型及缺口:
|
|
17
|
+
|
|
18
|
+
```bash
|
|
19
|
+
playcraft library kits --search "家具" --json
|
|
20
|
+
playcraft library kit kenney-furniture-kit --needs "椅子,桌子,打印机" --json
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
素材包是主题与风格的逻辑集合,区别于一起下载的文件集合及版权来源归档。`coverageScope: partial` 表示仅部分收录;`members` 才是当前可用模型及确切版本,`needCoverage` 返回同包每项需求的候选或缺口。`inventoryRevision` 只标识当前库存,不是持久保存的历史整包版本。`styleGuidance` 提供设计参照,`compatibility` 未验证的单位、朝向和连接规则不能用于兼容性承诺;生成供应商不会自动匹配该风格。
|
|
24
|
+
|
|
25
|
+
包内模型和独立素材都使用以下单素材入口。模型下载加 `--revision-id <revisionId>`,保留命令生成的 `.playcraft/asset-provenance.json`、许可和来源说明;工程文件路径、模型版本与来源记录共同标识实际使用实例。
|
|
26
|
+
|
|
16
27
|
```bash
|
|
17
28
|
playcraft library search "角色" --type texture --tags character --limit 20 --json
|
|
18
29
|
playcraft library facets --json
|
|
@@ -21,9 +32,9 @@ playcraft library files <assetId> --json
|
|
|
21
32
|
playcraft library download <assetId> --output ./assets/character
|
|
22
33
|
```
|
|
23
34
|
|
|
24
|
-
|
|
35
|
+
检索字段是名称、素材库路径和标签,已审核模型还支持别名、用途、许可及能力筛选;也可按分类、子分类、团队和分页筛选。使用检索结果返回的素材库编号,与 `tools list-assets` 的项目资产编号分别处理。预览图不能代替模型或其依赖。
|
|
25
36
|
|
|
26
|
-
`--output`
|
|
37
|
+
`--output` 对单文件表示文件路径,对文件集合表示目录;`--output-dir` 始终表示目录。目标路径必须尚不存在。文件集合包含清单、嵌套目录和模型贴图等依赖,下载后保留其相对路径。用户要求修改已有素材时,先下载到新路径并核对,再按用户要求替换。
|
|
27
38
|
|
|
28
39
|
## 创建持久化生成任务
|
|
29
40
|
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: playcraft-workspace-assets
|
|
3
|
-
description: Use when finding or downloading shared library assets, or
|
|
3
|
+
description: Use when selecting 3D asset kits, finding or downloading shared library assets, or managing durable media generation tasks in a PlayCraft cloud workspace.
|
|
4
4
|
compatibility: agent,opencode
|
|
5
|
-
version: 1.
|
|
5
|
+
version: 1.1.0
|
|
6
6
|
surfaces:
|
|
7
7
|
- sandbox-agent
|
|
8
8
|
---
|
|
@@ -13,6 +13,17 @@ surfaces:
|
|
|
13
13
|
|
|
14
14
|
## 查找并复用素材
|
|
15
15
|
|
|
16
|
+
一组三维物件的检索入口为素材包;包内覆盖结果给出逐项可用模型及缺口:
|
|
17
|
+
|
|
18
|
+
```bash
|
|
19
|
+
playcraft library kits --search "家具" --json
|
|
20
|
+
playcraft library kit kenney-furniture-kit --needs "椅子,桌子,打印机" --json
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
素材包是主题与风格的逻辑集合,区别于一起下载的文件集合及版权来源归档。`coverageScope: partial` 表示仅部分收录;`members` 才是当前可用模型及确切版本,`needCoverage` 返回同包每项需求的候选或缺口。`inventoryRevision` 只标识当前库存,不是持久保存的历史整包版本。`styleGuidance` 提供设计参照,`compatibility` 未验证的单位、朝向和连接规则不能用于兼容性承诺;生成供应商不会自动匹配该风格。
|
|
24
|
+
|
|
25
|
+
包内模型和独立素材都使用以下单素材入口。模型下载加 `--revision-id <revisionId>`,保留命令生成的 `.playcraft/asset-provenance.json`、许可和来源说明;工程文件路径、模型版本与来源记录共同标识实际使用实例。
|
|
26
|
+
|
|
16
27
|
```bash
|
|
17
28
|
playcraft library search "角色" --type texture --tags character --limit 20 --json
|
|
18
29
|
playcraft library facets --json
|
|
@@ -21,9 +32,9 @@ playcraft library files <assetId> --json
|
|
|
21
32
|
playcraft library download <assetId> --output ./assets/character
|
|
22
33
|
```
|
|
23
34
|
|
|
24
|
-
|
|
35
|
+
检索字段是名称、素材库路径和标签,已审核模型还支持别名、用途、许可及能力筛选;也可按分类、子分类、团队和分页筛选。使用检索结果返回的素材库编号,与 `tools list-assets` 的项目资产编号分别处理。预览图不能代替模型或其依赖。
|
|
25
36
|
|
|
26
|
-
`--output`
|
|
37
|
+
`--output` 对单文件表示文件路径,对文件集合表示目录;`--output-dir` 始终表示目录。目标路径必须尚不存在。文件集合包含清单、嵌套目录和模型贴图等依赖,下载后保留其相对路径。用户要求修改已有素材时,先下载到新路径并核对,再按用户要求替换。
|
|
27
38
|
|
|
28
39
|
## 创建持久化生成任务
|
|
29
40
|
|
|
@@ -171,8 +171,8 @@
|
|
|
171
171
|
},
|
|
172
172
|
{
|
|
173
173
|
"skillRef": "remix/playcraft-workspace-assets",
|
|
174
|
-
"version": "1.
|
|
175
|
-
"artifactHash": "
|
|
174
|
+
"version": "1.1.0",
|
|
175
|
+
"artifactHash": "bb8d3c17953e85e006f5de75bd267d29ea9cef329298d169ba57e11f59f4f21e",
|
|
176
176
|
"surface": "sandbox-agent",
|
|
177
177
|
"mountPath": ".playcraft/skills/remix__playcraft-workspace-assets"
|
|
178
178
|
}
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: playcraft-workspace-assets
|
|
3
|
-
description: Use when finding or downloading shared library assets, or
|
|
3
|
+
description: Use when selecting 3D asset kits, finding or downloading shared library assets, or managing durable media generation tasks in a PlayCraft cloud workspace.
|
|
4
4
|
compatibility: agent,opencode
|
|
5
|
-
version: 1.
|
|
5
|
+
version: 1.1.0
|
|
6
6
|
surfaces:
|
|
7
7
|
- sandbox-agent
|
|
8
8
|
---
|
|
@@ -13,6 +13,17 @@ surfaces:
|
|
|
13
13
|
|
|
14
14
|
## 查找并复用素材
|
|
15
15
|
|
|
16
|
+
一组三维物件的检索入口为素材包;包内覆盖结果给出逐项可用模型及缺口:
|
|
17
|
+
|
|
18
|
+
```bash
|
|
19
|
+
playcraft library kits --search "家具" --json
|
|
20
|
+
playcraft library kit kenney-furniture-kit --needs "椅子,桌子,打印机" --json
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
素材包是主题与风格的逻辑集合,区别于一起下载的文件集合及版权来源归档。`coverageScope: partial` 表示仅部分收录;`members` 才是当前可用模型及确切版本,`needCoverage` 返回同包每项需求的候选或缺口。`inventoryRevision` 只标识当前库存,不是持久保存的历史整包版本。`styleGuidance` 提供设计参照,`compatibility` 未验证的单位、朝向和连接规则不能用于兼容性承诺;生成供应商不会自动匹配该风格。
|
|
24
|
+
|
|
25
|
+
包内模型和独立素材都使用以下单素材入口。模型下载加 `--revision-id <revisionId>`,保留命令生成的 `.playcraft/asset-provenance.json`、许可和来源说明;工程文件路径、模型版本与来源记录共同标识实际使用实例。
|
|
26
|
+
|
|
16
27
|
```bash
|
|
17
28
|
playcraft library search "角色" --type texture --tags character --limit 20 --json
|
|
18
29
|
playcraft library facets --json
|
|
@@ -21,9 +32,9 @@ playcraft library files <assetId> --json
|
|
|
21
32
|
playcraft library download <assetId> --output ./assets/character
|
|
22
33
|
```
|
|
23
34
|
|
|
24
|
-
|
|
35
|
+
检索字段是名称、素材库路径和标签,已审核模型还支持别名、用途、许可及能力筛选;也可按分类、子分类、团队和分页筛选。使用检索结果返回的素材库编号,与 `tools list-assets` 的项目资产编号分别处理。预览图不能代替模型或其依赖。
|
|
25
36
|
|
|
26
|
-
`--output`
|
|
37
|
+
`--output` 对单文件表示文件路径,对文件集合表示目录;`--output-dir` 始终表示目录。目标路径必须尚不存在。文件集合包含清单、嵌套目录和模型贴图等依赖,下载后保留其相对路径。用户要求修改已有素材时,先下载到新路径并核对,再按用户要求替换。
|
|
27
38
|
|
|
28
39
|
## 创建持久化生成任务
|
|
29
40
|
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: playcraft-workspace-assets
|
|
3
|
-
description: Use when finding or downloading shared library assets, or
|
|
3
|
+
description: Use when selecting 3D asset kits, finding or downloading shared library assets, or managing durable media generation tasks in a PlayCraft cloud workspace.
|
|
4
4
|
compatibility: agent,opencode
|
|
5
|
-
version: 1.
|
|
5
|
+
version: 1.1.0
|
|
6
6
|
surfaces:
|
|
7
7
|
- sandbox-agent
|
|
8
8
|
---
|
|
@@ -13,6 +13,17 @@ surfaces:
|
|
|
13
13
|
|
|
14
14
|
## 查找并复用素材
|
|
15
15
|
|
|
16
|
+
一组三维物件的检索入口为素材包;包内覆盖结果给出逐项可用模型及缺口:
|
|
17
|
+
|
|
18
|
+
```bash
|
|
19
|
+
playcraft library kits --search "家具" --json
|
|
20
|
+
playcraft library kit kenney-furniture-kit --needs "椅子,桌子,打印机" --json
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
素材包是主题与风格的逻辑集合,区别于一起下载的文件集合及版权来源归档。`coverageScope: partial` 表示仅部分收录;`members` 才是当前可用模型及确切版本,`needCoverage` 返回同包每项需求的候选或缺口。`inventoryRevision` 只标识当前库存,不是持久保存的历史整包版本。`styleGuidance` 提供设计参照,`compatibility` 未验证的单位、朝向和连接规则不能用于兼容性承诺;生成供应商不会自动匹配该风格。
|
|
24
|
+
|
|
25
|
+
包内模型和独立素材都使用以下单素材入口。模型下载加 `--revision-id <revisionId>`,保留命令生成的 `.playcraft/asset-provenance.json`、许可和来源说明;工程文件路径、模型版本与来源记录共同标识实际使用实例。
|
|
26
|
+
|
|
16
27
|
```bash
|
|
17
28
|
playcraft library search "角色" --type texture --tags character --limit 20 --json
|
|
18
29
|
playcraft library facets --json
|
|
@@ -21,9 +32,9 @@ playcraft library files <assetId> --json
|
|
|
21
32
|
playcraft library download <assetId> --output ./assets/character
|
|
22
33
|
```
|
|
23
34
|
|
|
24
|
-
|
|
35
|
+
检索字段是名称、素材库路径和标签,已审核模型还支持别名、用途、许可及能力筛选;也可按分类、子分类、团队和分页筛选。使用检索结果返回的素材库编号,与 `tools list-assets` 的项目资产编号分别处理。预览图不能代替模型或其依赖。
|
|
25
36
|
|
|
26
|
-
`--output`
|
|
37
|
+
`--output` 对单文件表示文件路径,对文件集合表示目录;`--output-dir` 始终表示目录。目标路径必须尚不存在。文件集合包含清单、嵌套目录和模型贴图等依赖,下载后保留其相对路径。用户要求修改已有素材时,先下载到新路径并核对,再按用户要求替换。
|
|
27
38
|
|
|
28
39
|
## 创建持久化生成任务
|
|
29
40
|
|