@playcraft/cli 0.1.58-beta → 0.1.59-beta

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,6 @@
1
1
  import { execFile } from 'node:child_process';
2
2
  import { lstat, mkdir, realpath, rm, writeFile } from 'node:fs/promises';
3
- import { join, relative } from 'node:path';
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';
6
6
  import { prepareWorkspaceCheckout } from './prepare.js';
@@ -163,7 +163,7 @@ export async function registerWorkspace(state, input, leaseFence, preparation, r
163
163
  await prepareWorkspaceCheckout(state, binding, preparation, leaseFence);
164
164
  root = await checkedDirectory(state, binding.workspaceId);
165
165
  }
166
- const git = async (...args) => (await exec('git', ['-C', root, ...args], {
166
+ const gitIn = (cwd) => async (...args) => (await exec('git', ['-C', cwd, ...args], {
167
167
  timeout: 5000,
168
168
  maxBuffer: 64 * 1024,
169
169
  env: {
@@ -173,8 +173,14 @@ export async function registerWorkspace(state, input, leaseFence, preparation, r
173
173
  GIT_CONFIG_GLOBAL: '/dev/null',
174
174
  },
175
175
  })).stdout.trim();
176
+ const git = gitIn(root);
177
+ // The checkout, not the journal, decides whether the remote is still missing: the state volume is
178
+ // per generation and forgets a standalone→main promotion, while the checkout outlives it.
179
+ const needsRemote = binding.targetKind === 'main' && !preservedLocalBasic
180
+ && (await git('remote').catch(() => undefined)) === '';
176
181
  // Unfinished if it failed part way: the marker outlives the journal entry written below.
177
- if (binding.targetKind === 'main' && !preservedLocalBasic && (remoteUpgrade || await lstat(attachMarker).then(() => true, () => false))) {
182
+ if (binding.targetKind === 'main' && !preservedLocalBasic
183
+ && (needsRemote || remoteUpgrade || await lstat(attachMarker).then(() => true, () => false))) {
178
184
  if (preparation?.mode !== 'clone' || !state.gitCredentials)
179
185
  throw new WorkspaceContextError('workspace_git_gateway_unavailable');
180
186
  // private
@@ -218,6 +224,10 @@ export async function registerWorkspace(state, input, leaseFence, preparation, r
218
224
  throw error;
219
225
  throw new WorkspaceContextError('workspace_not_prepared');
220
226
  }
227
+ // Variants share basic's volume but have no registration step of their own, so a swap can leave
228
+ // them checked out, on the right branch, and without the remote their saves need.
229
+ if (!readOnlyBasic && !preservedLocalBasic)
230
+ await restoreDirectoryRemotes(binding, root, gitIn);
221
231
  if (state.journal.destroyed())
222
232
  throw new WorkspaceContextError('runtime_destroyed');
223
233
  if (leaseFence < state.acceptedLeaseFence)
@@ -260,3 +270,34 @@ export async function registerWorkspace(state, input, leaseFence, preparation, r
260
270
  return binding;
261
271
  });
262
272
  }
273
+ /**
274
+ * Variant checkouts outlive the journal that registered them: a sandbox swap keeps the files and the
275
+ * branch but drops the remote. Only an already-correct branch is repaired; rebuilding another one
276
+ * would discard the variant's own commits, so that case stays a checkout mismatch.
277
+ */
278
+ async function restoreDirectoryRemotes(binding, root, gitIn) {
279
+ const repositoryUrl = binding.repositoryUrl;
280
+ if (!repositoryUrl)
281
+ return;
282
+ for (const directory of binding.directories ?? []) {
283
+ if (directory.directoryName === 'basic')
284
+ continue;
285
+ const checkout = join(dirname(root), directory.directoryName);
286
+ const stat = await lstat(checkout).catch(() => undefined);
287
+ if (!stat?.isDirectory() || stat.isSymbolicLink())
288
+ continue;
289
+ const git = gitIn(checkout);
290
+ if ((await git('symbolic-ref', '--short', 'HEAD').catch(() => undefined)) !== directory.branchName)
291
+ continue;
292
+ const origin = await git('config', '--get', 'remote.origin.url').catch(() => undefined);
293
+ if (origin === repositoryUrl)
294
+ continue;
295
+ try {
296
+ if (origin === undefined)
297
+ await git('remote', 'add', 'origin', repositoryUrl);
298
+ else
299
+ await git('remote', 'set-url', 'origin', repositoryUrl);
300
+ }
301
+ catch { /* The variant still opens; its next save reports the missing remote. */ }
302
+ }
303
+ }
@@ -0,0 +1,235 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { constants, createReadStream, createWriteStream } from 'node:fs';
3
+ import { chmod, lstat, mkdir, mkdtemp, open, readlink, readdir, realpath, rm, symlink, utimes } from 'node:fs/promises';
4
+ import { tmpdir } from 'node:os';
5
+ import { dirname, isAbsolute, join, relative, resolve, sep } from 'node:path';
6
+ import { Readable, Transform } from 'node:stream';
7
+ import { pipeline } from 'node:stream/promises';
8
+ import { createGunzip, createGzip } from 'node:zlib';
9
+ const MAGIC = Buffer.from('PLAYCRAFT-STORAGE-1\n');
10
+ export const STORAGE_SNAPSHOT_MAX_BYTES = 16 * 1024 * 1024 * 1024;
11
+ const MAX_ENTRIES = 250_000, MAX_MANIFEST_BYTES = 32 * 1024 * 1024;
12
+ const EXCLUDED = new Set(['node_modules', '.next', '.cache', '.turbo', '.vite', '.pnpm-store', '.playcraft.json']);
13
+ function inside(root, path) {
14
+ const part = relative(root, path);
15
+ return part === '' || (!isAbsolute(part) && part !== '..' && !part.startsWith(`..${sep}`));
16
+ }
17
+ function excluded(path) {
18
+ const parts = path.split('/');
19
+ return parts.some(name => EXCLUDED.has(name.toLowerCase()) || /^\.playcraft-credential-/.test(name));
20
+ }
21
+ function safeLink(root, parent, link) {
22
+ // Parent traversals may precede a target, but cannot follow a symlinkable path component.
23
+ // For example, `link/../outside` can escape even though its lexical path looks internal.
24
+ return Boolean(link) && !isAbsolute(link) && !/[\0\\]/.test(link) && /^(?:\.\.\/)*(?!.*(?:^|\/)\.\.(?:\/|$)).+$/.test(link) &&
25
+ inside(root, resolve(parent, link));
26
+ }
27
+ function same(before, after) {
28
+ return before.dev === after.dev && before.ino === after.ino && before.mode === after.mode && before.size === after.size &&
29
+ before.mtimeMs === after.mtimeMs && before.ctimeMs === after.ctimeMs;
30
+ }
31
+ async function scan(root, options) {
32
+ const entries = [], excludedRoots = await Promise.all((options.excludedRoots ?? []).map(path => realpath(path).catch(() => resolve(path))));
33
+ let bytes = 0;
34
+ async function walk(directory) {
35
+ options.signal?.throwIfAborted();
36
+ const before = await lstat(directory);
37
+ if (!before.isDirectory() || before.isSymbolicLink() || !inside(root, await realpath(directory)))
38
+ throw new Error('storage_snapshot_changed');
39
+ for (const name of (await readdir(directory)).sort()) {
40
+ const source = join(directory, name), path = relative(root, source).split(sep).join('/');
41
+ if (excluded(path) || excludedRoots.some(item => inside(item, source)))
42
+ continue;
43
+ if (Buffer.byteLength(path) > 65_535 || /[\0\\]/.test(path))
44
+ throw new Error('storage_snapshot_invalid_path');
45
+ const info = await lstat(source);
46
+ if (/(?:^|\/)\.git\/objects\/info\/alternates$/.test(path))
47
+ throw new Error('storage_snapshot_unsupported');
48
+ const type = info.isSymbolicLink() ? 'symlink' : info.isDirectory() ? 'directory' : info.isFile() ? 'file' : undefined;
49
+ if (!type || entries.length >= MAX_ENTRIES)
50
+ throw new Error('storage_snapshot_unsupported');
51
+ const entry = { path, source, info, type, mode: info.mode & 0o777, mtime: info.mtimeMs, size: type === 'file' ? info.size : 0 };
52
+ if (type === 'symlink') {
53
+ entry.link = await readlink(source);
54
+ if (!safeLink(root, dirname(source), entry.link))
55
+ throw new Error('storage_snapshot_unsafe_link');
56
+ }
57
+ else if (type === 'file' && /(?:^|\/)\.git$/.test(path)) {
58
+ if (info.size > 4096)
59
+ throw new Error('storage_snapshot_unsupported');
60
+ const handle = await open(source, constants.O_RDONLY | constants.O_NOFOLLOW);
61
+ try {
62
+ const pointer = /^gitdir: ([^\r\n]+)\n?$/.exec(await handle.readFile('utf8'));
63
+ if (!pointer || !inside(root, resolve(directory, pointer[1])))
64
+ throw new Error('storage_snapshot_unsupported');
65
+ }
66
+ finally {
67
+ await handle.close();
68
+ }
69
+ }
70
+ bytes += entry.size;
71
+ if (bytes > STORAGE_SNAPSHOT_MAX_BYTES)
72
+ throw new Error('storage_snapshot_too_large');
73
+ entries.push(entry);
74
+ if (type === 'directory')
75
+ await walk(source);
76
+ }
77
+ if (!same(before, await lstat(directory)))
78
+ throw new Error('storage_snapshot_changed');
79
+ }
80
+ await walk(root);
81
+ return entries;
82
+ }
83
+ export async function createStorageSnapshot(root, options = {}) {
84
+ const temporary = await mkdtemp(join(tmpdir(), 'playcraft-storage-'));
85
+ const cleanup = () => rm(temporary, { recursive: true, force: true });
86
+ try {
87
+ const canonical = await realpath(root), rootInfo = await lstat(canonical), entries = await scan(canonical, options);
88
+ const manifest = Buffer.from(JSON.stringify(entries.map(({ path, type, mode, mtime, size, link }) => ({ path, type, mode, mtime, size, ...(link === undefined ? {} : { link }) }))));
89
+ if (manifest.length > MAX_MANIFEST_BYTES)
90
+ throw new Error('storage_snapshot_too_large');
91
+ const length = Buffer.alloc(4);
92
+ length.writeUInt32BE(manifest.length);
93
+ async function* contents() {
94
+ yield MAGIC;
95
+ yield length;
96
+ yield manifest;
97
+ for (const entry of entries) {
98
+ options.signal?.throwIfAborted();
99
+ if (!same(entry.info, await lstat(entry.source)))
100
+ throw new Error('storage_snapshot_changed');
101
+ if (entry.type !== 'file')
102
+ continue;
103
+ const file = await open(entry.source, constants.O_RDONLY | constants.O_NOFOLLOW);
104
+ try {
105
+ if (!same(entry.info, await file.stat()))
106
+ throw new Error('storage_snapshot_changed');
107
+ let bytes = 0;
108
+ for await (const chunk of file.createReadStream({ autoClose: false })) {
109
+ bytes += chunk.length;
110
+ if (bytes > entry.size)
111
+ throw new Error('storage_snapshot_changed');
112
+ yield chunk;
113
+ }
114
+ if (bytes !== entry.size)
115
+ throw new Error('storage_snapshot_changed');
116
+ if (!same(entry.info, await file.stat()))
117
+ throw new Error('storage_snapshot_changed');
118
+ }
119
+ finally {
120
+ await file.close();
121
+ }
122
+ }
123
+ for (const entry of entries)
124
+ if (!same(entry.info, await lstat(entry.source)))
125
+ throw new Error('storage_snapshot_changed');
126
+ if (!same(rootInfo, await lstat(canonical)))
127
+ throw new Error('storage_snapshot_changed');
128
+ }
129
+ const path = join(temporary, 'snapshot.gz'), hash = createHash('sha256');
130
+ let bytes = 0;
131
+ await pipeline(Readable.from(contents()), createGzip(), new Transform({ transform(chunk, _encoding, done) { bytes += chunk.length; hash.update(chunk); done(bytes > STORAGE_SNAPSHOT_MAX_BYTES ? new Error('storage_snapshot_too_large') : null, chunk); } }),
132
+ // private
133
+ createWriteStream(path, { flags: 'wx', mode: 0o600 }), { signal: options.signal });
134
+ return { path, bytes, sha256: hash.digest('hex'), cleanup };
135
+ }
136
+ catch (error) {
137
+ await cleanup();
138
+ throw error;
139
+ }
140
+ }
141
+ /** Extract only a validated manifest into a newly-created private directory; create links last. */
142
+ export async function extractStorageSnapshot(path, target, options = {}) {
143
+ const input = createReadStream(path), stream = input.pipe(createGunzip());
144
+ input.on('error', error => stream.destroy(error));
145
+ const iterator = stream[Symbol.asyncIterator]();
146
+ let chunk = Buffer.alloc(0), offset = 0, total = 0;
147
+ const read = async (length) => {
148
+ const result = Buffer.allocUnsafe(length);
149
+ let written = 0;
150
+ while (written < length) {
151
+ options.signal?.throwIfAborted();
152
+ if (offset === chunk.length) {
153
+ const next = await iterator.next();
154
+ if (next.done)
155
+ throw new Error('storage_snapshot_invalid');
156
+ chunk = next.value;
157
+ offset = 0;
158
+ total += chunk.length;
159
+ if (total > STORAGE_SNAPSHOT_MAX_BYTES + MAX_MANIFEST_BYTES)
160
+ throw new Error('storage_snapshot_too_large');
161
+ }
162
+ const count = Math.min(length - written, chunk.length - offset);
163
+ chunk.copy(result, written, offset, offset + count);
164
+ written += count;
165
+ offset += count;
166
+ }
167
+ return result;
168
+ };
169
+ try {
170
+ if (!(await read(MAGIC.length)).equals(MAGIC))
171
+ throw new Error('storage_snapshot_invalid');
172
+ const size = (await read(4)).readUInt32BE();
173
+ if (size > MAX_MANIFEST_BYTES)
174
+ throw new Error('storage_snapshot_too_large');
175
+ const entries = JSON.parse((await read(size)).toString('utf8'));
176
+ if (!Array.isArray(entries) || entries.length > MAX_ENTRIES)
177
+ throw new Error('storage_snapshot_invalid');
178
+ const known = new Map();
179
+ let bytes = 0;
180
+ for (const raw of entries) {
181
+ const entry = raw;
182
+ if (!entry || typeof entry.path !== 'string' || !entry.path || Buffer.byteLength(entry.path) > 65_535 || /[\0\\]/.test(entry.path) || entry.path.split('/').some(part => !part || part === '.' || part === '..') || isAbsolute(entry.path) || known.has(entry.path) ||
183
+ !['file', 'directory', 'symlink'].includes(entry.type) || !Number.isSafeInteger(entry.mode) || entry.mode < 0 || entry.mode > 0o777 || !Number.isFinite(entry.mtime) ||
184
+ !Number.isSafeInteger(entry.size) || entry.size < 0 || (entry.type !== 'file' && entry.size !== 0))
185
+ throw new Error('storage_snapshot_invalid');
186
+ const parent = dirname(entry.path);
187
+ if (parent !== '.' && known.get(parent)?.type !== 'directory')
188
+ throw new Error('storage_snapshot_invalid');
189
+ if (entry.type === 'symlink' && (typeof entry.link !== 'string' || !safeLink(target, join(target, parent), entry.link)))
190
+ throw new Error('storage_snapshot_unsafe_link');
191
+ bytes += entry.size;
192
+ if (bytes > STORAGE_SNAPSHOT_MAX_BYTES)
193
+ throw new Error('storage_snapshot_too_large');
194
+ known.set(entry.path, entry);
195
+ }
196
+ for (const entry of known.values()) {
197
+ const destination = join(target, entry.path);
198
+ // private
199
+ if (entry.type === 'directory')
200
+ await mkdir(destination, { mode: 0o700 });
201
+ if (entry.type !== 'file')
202
+ continue;
203
+ // private
204
+ const handle = await open(destination, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, 0o600);
205
+ try {
206
+ let remaining = entry.size;
207
+ while (remaining > 0) {
208
+ const value = await read(Math.min(64 * 1024, remaining));
209
+ await handle.writeFile(value);
210
+ remaining -= value.length;
211
+ }
212
+ }
213
+ finally {
214
+ await handle.close();
215
+ }
216
+ await chmod(destination, entry.mode);
217
+ await utimes(destination, entry.mtime / 1000, entry.mtime / 1000);
218
+ }
219
+ if (offset !== chunk.length || !(await iterator.next()).done)
220
+ throw new Error('storage_snapshot_invalid');
221
+ for (const entry of known.values())
222
+ if (entry.type === 'symlink')
223
+ await symlink(entry.link, join(target, entry.path));
224
+ for (const entry of [...known.values()].reverse())
225
+ if (entry.type === 'directory') {
226
+ await chmod(join(target, entry.path), entry.mode);
227
+ await utimes(join(target, entry.path), entry.mtime / 1000, entry.mtime / 1000);
228
+ }
229
+ }
230
+ finally {
231
+ input.destroy();
232
+ stream.destroy();
233
+ await iterator.return?.();
234
+ }
235
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@playcraft/cli",
3
- "version": "0.1.58-beta",
3
+ "version": "0.1.59-beta",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "engines": {
@@ -53,8 +53,8 @@
53
53
  "@gltf-transform/core": "^4.3.0",
54
54
  "@gltf-transform/extensions": "^4.3.0",
55
55
  "@gltf-transform/functions": "^4.3.0",
56
- "@playcraft/build": "0.1.58-beta",
57
- "@playcraft/common": "0.1.58-beta",
56
+ "@playcraft/build": "0.1.59-beta",
57
+ "@playcraft/common": "0.1.59-beta",
58
58
  "better-sqlite3-pinned": "npm:better-sqlite3@13.0.3",
59
59
  "chokidar": "^4.0.3",
60
60
  "commander": "^13.1.0",