@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.
@@ -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
- return !path.split('/').some(name => privateNames.has(name) || /^\.env(?:$|\.)/.test(name) || /\.(?:pem|key|p12|pfx)$/.test(name))
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 verifyReference(root, record, directoryName) {
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
- if (JSON.stringify(workspaceRemixReferenceSchema.parse(receipt)) !== JSON.stringify(record))
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.69",
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.69",
60
- "@playcraft/common": "0.1.69",
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",