@playcraft/cli 0.1.16 → 0.1.18

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,7 +1,7 @@
1
1
  import { readObservedFile, withObservedFile } from '../workspaces/revisions.js';
2
2
  import { registerWorkspace, revisionScope, workspaceRoot, WorkspaceContextError } from '../workspaces/registry.js';
3
3
  import { randomUUID } from 'node:crypto';
4
- import { workspaceRegistrationRequestSchema, HTTP_STATUS_BY_ERROR, assertLeaseFence, buildHandshakeDocument, capability, findUntrustedRuntimeFields, gitFileRefSchema, hashOperationPayload, instanceRebuildRequestSchema, sourceAckSchema, operationAcceptRequestSchema, operationKindSchema, operationScopeKey, resolveIdempotentAccept, nativeHistoryReadSchema, runtimeError, unavailableRuntimeCapabilities, } from '@playcraft/common/cloud-workspace/runtime';
4
+ import { workspaceRegistrationRequestSchema, HTTP_STATUS_BY_ERROR, assertLeaseFence, buildHandshakeDocument, capability, findUntrustedRuntimeFields, gitFileRefSchema, hashOperationPayload, instanceRebuildRequestSchema, sourceAckSchema, operationAcceptRequestSchema, operationKindSchema, operationScopeKey, resolveIdempotentAccept, nativeHistoryReadSchema, runtimeError, runtimeConnectivityDocumentSchema, unavailableRuntimeCapabilities, } from '@playcraft/common/cloud-workspace/runtime';
5
5
  import { controlTokenMatches, extractControlToken } from './auth.js';
6
6
  import { applyRuntimeEndpointPatch } from './endpoints.js';
7
7
  import { canExecuteKind, executeOperation } from './dispatch.js';
@@ -17,6 +17,7 @@ import { readWorkspaceSkills } from '../workspaces/skills.js';
17
17
  import { loadWorkspaceSkillArtifact } from '../workspaces/skill-artifact-loader.js';
18
18
  import { join } from 'node:path';
19
19
  import { terminalHost } from './terminal-host.js';
20
+ import { probeRuntimeConnectivity } from './connectivity.js';
20
21
  export async function handleRuntimeRequest(state, request, response) {
21
22
  const url = new URL(request.url ?? '/', 'http://127.0.0.1');
22
23
  const access = authorizeRuntime(state, request);
@@ -33,6 +34,19 @@ export async function handleRuntimeRequest(state, request, response) {
33
34
  writeJson(response, 200, { status: 'ok', handshake: handshake(state) });
34
35
  return;
35
36
  }
37
+ if (request.method === 'GET' && url.pathname === '/runtime/connectivity') {
38
+ if (writeDestroyed(state, response))
39
+ return;
40
+ const parsed = runtimeConnectivityDocumentSchema.shape.workspaceId.safeParse(url.searchParams.get('workspaceId'));
41
+ if (!parsed.success) {
42
+ writeJson(response, 400, { error: 'invalid_workspace' });
43
+ return;
44
+ }
45
+ const workspaceId = parsed.data;
46
+ await workspaceRoot(state, workspaceId);
47
+ writeJson(response, 200, await probeRuntimeConnectivity(state, workspaceId));
48
+ return;
49
+ }
36
50
  if (request.method === 'GET' && url.pathname === '/runtime/identity') {
37
51
  const { runtimeId, userId, environment, generation } = state.config;
38
52
  writeJson(response, 200, {
@@ -2,7 +2,7 @@ import { randomUUID } from 'node:crypto';
2
2
  import { mkdir, rename, rm, writeFile } from 'node:fs/promises';
3
3
  import { join } from 'node:path';
4
4
  import { z } from 'zod';
5
- import { runtimeCallbackEndpoint } from '../../utils/environment-endpoints.js';
5
+ import { PLATFORM_ROUTES, platformEndpoint } from '@playcraft/common/cloud-workspace/runtime';
6
6
  const responseSchema = z.object({
7
7
  token: z.string().min(1),
8
8
  apiUrl: z.string().url(),
@@ -37,11 +37,7 @@ export async function ensureCliCredentials(state) {
37
37
  async function renew(state, entry) {
38
38
  const config = state.config;
39
39
  const epoch = state.runtimeEpoch;
40
- const endpoint = runtimeCallbackEndpoint(config.callbackUrl, '/runtime/internal/cli-credentials');
41
- if (endpoint.protocol !== 'https:' &&
42
- !(endpoint.protocol === 'http:' &&
43
- ['127.0.0.1', 'localhost', '[::1]'].includes(endpoint.hostname)))
44
- throw new Error('cli_callback_insecure');
40
+ const endpoint = platformEndpoint(config.publicBaseUrl, PLATFORM_ROUTES.cliCredentials);
45
41
  try {
46
42
  const response = await fetch(endpoint, {
47
43
  method: 'POST',
@@ -1,77 +1,7 @@
1
1
  import { chmod, lstat, mkdir, readdir, readFile, readlink, realpath, rm, symlink, writeFile } from 'node:fs/promises';
2
2
  import { dirname, join, relative, resolve, sep } from 'node:path';
3
- import { fileSnapshotDigest, fileSnapshotManifestKey, fileSnapshotContentKey, nativeTranscriptKey, fileSnapshotManifestSchema, hashSnapshotBytes, isDisposableBackupPath, isWorkspaceAttachmentPath, putObjectBytes, workspaceRelativePathSchema, } from '@playcraft/common/cloud-workspace/runtime';
4
- import { UNPUSHED_BUNDLE, addBundleObjects, repositoryResolves } from './git.js';
3
+ import { fileSnapshotDigest, fileSnapshotManifestKey, fileSnapshotManifestSchema, fileSnapshotObjectKey, hashSnapshotBytes, isDisposableBackupPath, isWorkspaceAttachmentPath, putObjectBytes, workspaceRelativePathSchema, } from '@playcraft/common/cloud-workspace/runtime';
5
4
  const SKIP_DIRECTORY_NAMES = new Set(['.git', 'node_modules']);
6
- /** Build output, caches and scratch folders, at any depth: regenerated, so not kept in storage. */
7
- const UNSTORED_DIRECTORY_NAMES = new Set([
8
- 'dist',
9
- 'dist-all',
10
- 'coverage',
11
- '.vite',
12
- '.cache',
13
- '.turbo',
14
- '.parcel-cache',
15
- '.sass-cache',
16
- '.next',
17
- '.nuxt',
18
- '.svelte-kit',
19
- 'tmp',
20
- '.tmp',
21
- '.temp',
22
- ]);
23
- /** Scratch and cache files, at any depth. */
24
- const UNSTORED_FILE = /(?:\.tmp|\.log|\.tsbuildinfo|^\.eslintcache)$/;
25
- /**
26
- * At the workspace root: the preview build's output, the builder's scratch folder, and skill
27
- * artifacts the sandbox pulls from the registry by `.playcraft/skills.lock.json`.
28
- */
29
- const UNSTORED_ROOT_DIRECTORIES = [
30
- '.playcraft-preview-out/',
31
- '.playcraft-temp/',
32
- '.playcraft/skills/',
33
- 'ta-workspace/logs/',
34
- ];
35
- /**
36
- * Recordings, frames and reports the template's verify and record scripts write again on each run.
37
- * The ring JSON evidence the build gate reads stays, as do the agent-edited profile and catalogs.
38
- */
39
- const UNSTORED_RUN_OUTPUT = [
40
- /^recordings\/(?:.+\.(?:mp4|webm)$|\.tmp-[^/]+\/|focus\/|clean\/)/,
41
- /^validate\/(?:[^/]+\.md$|(?:ring\d+\/)?frames\/|ring\d+\/[^/]+\.mp4$)/,
42
- ];
43
- /**
44
- * Whether a path goes to checkpoint storage. Credentials, lock files, build output, caches and
45
- * scratch files stay in the instance: its local copy keeps them, so restoring inside the instance
46
- * does not remove them.
47
- */
48
- export function keptInStorage(path) {
49
- if (isWorkspaceAttachmentPath(path))
50
- return true;
51
- if (isDisposableBackupPath(path))
52
- return false;
53
- if (path.startsWith('.git/'))
54
- return true;
55
- if (UNSTORED_ROOT_DIRECTORIES.some((prefix) => path.startsWith(prefix)))
56
- return false;
57
- if (UNSTORED_RUN_OUTPUT.some((pattern) => pattern.test(path)))
58
- return false;
59
- const segments = path.split('/');
60
- if (UNSTORED_FILE.test(segments[segments.length - 1]))
61
- return false;
62
- return !segments.slice(0, -1).some((segment) => UNSTORED_DIRECTORY_NAMES.has(segment));
63
- }
64
- /**
65
- * Under `.git/`, what a `local` capture leaves to the remote: packs and their indexes, submodule
66
- * repositories (`git submodule update` fetches them again), LFS objects, and template hooks.
67
- */
68
- const REMOTE_GIT_DIRECTORIES = new Set([
69
- '.git/objects/pack',
70
- '.git/objects/info',
71
- '.git/modules',
72
- '.git/lfs',
73
- '.git/hooks',
74
- ]);
75
5
  const READ_ATTEMPTS = 3;
76
6
  const realIo = {
77
7
  async readdir(path) {
@@ -83,28 +13,13 @@ const realIo = {
83
13
  readFile,
84
14
  readlink,
85
15
  };
86
- export async function captureWorkspaceTree(root, previousPaths, revisions, io = realIo, options = {}) {
16
+ export async function captureWorkspaceTree(root, previousPaths, revisions, io = realIo) {
87
17
  const entries = [];
88
18
  const files = [];
89
19
  const bodies = new Map();
90
20
  const seen = new Set();
91
21
  let status = 'complete';
92
- const skip = (relativePath, name, directory) => {
93
- if (name === '.' || name === '..')
94
- return true;
95
- if (directory && name === '.git')
96
- return !(options.git && relativePath === '.git');
97
- // A submodule's `.git` file points into `.git/modules`, which is not kept either.
98
- if (!directory && name === '.git' && relativePath !== '.git')
99
- return true;
100
- if (directory && SKIP_DIRECTORY_NAMES.has(name))
101
- return true;
102
- if (directory && options.git === 'local' && REMOTE_GIT_DIRECTORIES.has(relativePath))
103
- return true;
104
- // Git's own lock files only exist while a command runs and must not come back with a restore.
105
- return !directory && relativePath.startsWith('.git/') && name.endsWith('.lock');
106
- };
107
- await walk(root, root, io, revisions, entries, files, bodies, seen, skip, () => {
22
+ await walk(root, root, io, revisions, entries, files, bodies, seen, () => {
108
23
  status = 'incomplete';
109
24
  });
110
25
  for (const path of [...previousPaths].sort((left, right) => left.localeCompare(right))) {
@@ -147,12 +62,6 @@ export async function materializeRemoteSnapshot(root, entries) {
147
62
  if (hashSnapshotBytes(body) !== entry.contentHash)
148
63
  throw new Error('snapshot_mismatch');
149
64
  }
150
- if (applicable.some((entry) => entry.path === '.git/HEAD')) {
151
- const bundle = applicable.find((entry) => entry.path === UNPUSHED_BUNDLE && entry.kind === 'file');
152
- if (bundle)
153
- await addBundleObjects(rootReal, Buffer.from(bundle.contentBase64 ?? '', 'base64'));
154
- await clearClonedGitMetadata(rootReal);
155
- }
156
65
  for (const entry of applicable) {
157
66
  const target = resolve(rootReal, ...entry.path.split('/'));
158
67
  const rel = relative(rootReal, target);
@@ -176,100 +85,50 @@ export async function materializeRemoteSnapshot(root, entries) {
176
85
  if (entry.mode !== undefined)
177
86
  await chmod(target, entry.mode);
178
87
  }
88
+ let repositoryReady = false;
89
+ try {
90
+ const git = await lstat(join(rootReal, '.git'));
91
+ repositoryReady = git.isDirectory() && !git.isSymbolicLink();
92
+ }
93
+ catch {
94
+ repositoryReady = false;
95
+ }
179
96
  return {
180
97
  skipped,
181
98
  directoryReady: true,
182
- repositoryReady: await repositoryResolves(rootReal),
99
+ repositoryReady,
183
100
  environmentReady: false,
184
101
  };
185
102
  }
186
- /**
187
- * The snapshot's `.git` replaces the one a new clone made, objects aside: a ref the clone wrote
188
- * would otherwise outrank the snapshot's packed refs.
189
- */
190
- async function clearClonedGitMetadata(rootReal) {
191
- const gitDir = join(rootReal, '.git');
192
- const info = await lstat(gitDir).catch(() => undefined);
193
- if (!info?.isDirectory())
194
- return;
195
- for (const name of await readdir(gitDir)) {
196
- if (name !== 'objects')
197
- await rm(join(gitDir, name), { recursive: true, force: true });
198
- }
199
- }
200
- /**
201
- * Content goes to the workspace's shared objects, keyed by hash. A hash in `uploaded` was stored by
202
- * this process before and is not sent again; hashes stored now are added to it. The manifest still
203
- * lists every stored file, so each snapshot restores on its own. Paths not `keptInStorage` are left
204
- * out of both the objects and the manifest.
205
- */
206
103
  export async function publishFileSnapshot(input) {
207
- const stored = new Set();
208
- const entries = input.tree.entries.filter((entry) => keptInStorage(entry.path));
209
- for (const entry of entries) {
104
+ for (const entry of input.tree.entries) {
210
105
  if (entry.kind !== 'file' || !entry.contentHash)
211
106
  continue;
212
- if (input.uploaded?.has(entry.contentHash) || stored.has(entry.contentHash))
213
- continue;
214
107
  const body = input.tree.bodies.get(entry.path);
215
108
  if (!body || hashSnapshotBytes(body) !== entry.contentHash)
216
109
  throw new Error('snapshot_body_missing');
217
- await putObjectBytes(input.baseUrl, fileSnapshotContentKey(input.workspaceId, entry.contentHash), body, 'cli', input.headers);
218
- stored.add(entry.contentHash);
219
- }
220
- const native = [];
221
- for (const transcript of input.native ?? []) {
222
- const contentHash = hashSnapshotBytes(transcript.body);
223
- if (input.uploadedTranscripts?.get(transcript.path) !== contentHash) {
224
- await putObjectBytes(input.baseUrl, nativeTranscriptKey(input.workspaceId, transcript.path), transcript.body, 'cli', input.headers);
225
- input.uploadedTranscripts?.set(transcript.path, contentHash);
226
- }
227
- native.push({
228
- sessionId: transcript.sessionId,
229
- threadId: transcript.threadId,
230
- path: transcript.path,
231
- contentHash,
232
- size: transcript.body.byteLength,
233
- capturedAt: transcript.capturedAt,
234
- });
110
+ await putObjectBytes(input.baseUrl, fileSnapshotObjectKey(input.workspaceId, input.generation, input.snapshotId, entry.contentHash), body, 'cli', input.headers);
235
111
  }
236
- const digest = fileSnapshotDigest(entries);
112
+ const digest = fileSnapshotDigest(input.tree.entries);
237
113
  const manifest = fileSnapshotManifestSchema.parse({
238
114
  snapshotId: input.snapshotId,
239
115
  workspaceId: input.workspaceId,
240
116
  generation: input.generation,
241
117
  status: input.tree.status,
242
- entries,
118
+ entries: input.tree.entries,
243
119
  digest,
244
- objectLayout: 'workspace',
245
- ...(native.length > 0 ? { native } : {}),
246
120
  });
247
121
  const manifestKey = fileSnapshotManifestKey(input.workspaceId, input.generation, input.snapshotId);
248
122
  await putObjectBytes(input.baseUrl, manifestKey, Buffer.from(JSON.stringify(manifest)), 'cli', input.headers);
249
- for (const hash of stored)
250
- input.uploaded?.add(hash);
251
123
  return { manifestKey, checksum: digest, status: input.tree.status };
252
124
  }
253
- /**
254
- * Digest of what is on disk now, without the deletions recorded against the previous snapshot.
255
- * Transcripts count too: a turn that only talked still changes what a restore can resume.
256
- */
257
- export function liveTreeDigest(tree, native = []) {
258
- const files = fileSnapshotDigest(tree.entries.filter((entry) => entry.kind !== 'deleted'));
259
- if (native.length === 0)
260
- return files;
261
- const transcripts = native
262
- .map((item) => `${item.sessionId}:${item.threadId}:${item.path}:${hashSnapshotBytes(item.body)}`)
263
- .sort();
264
- return hashSnapshotBytes(Buffer.from([files, ...transcripts].join('\n')));
265
- }
266
- async function walk(rootReal, current, io, revisions, entries, files, bodies, seen, skip, markIncomplete) {
125
+ async function walk(rootReal, current, io, revisions, entries, files, bodies, seen, markIncomplete) {
267
126
  const listed = await io.readdir(current);
268
127
  for (const entry of listed) {
128
+ if (entry.name === '.' || entry.name === '..' || SKIP_DIRECTORY_NAMES.has(entry.name))
129
+ continue;
269
130
  const child = join(current, entry.name);
270
131
  const relativePath = toPosixRelative(rootReal, child);
271
- if (skip(relativePath, entry.name, entry.isDirectory()))
272
- continue;
273
132
  if (!workspaceRelativePathSchema.safeParse(relativePath).success) {
274
133
  markIncomplete();
275
134
  continue;
@@ -286,7 +145,7 @@ async function walk(rootReal, current, io, revisions, entries, files, bodies, se
286
145
  continue;
287
146
  }
288
147
  if (entry.isDirectory()) {
289
- await walk(rootReal, child, io, revisions, entries, files, bodies, seen, skip, markIncomplete);
148
+ await walk(rootReal, child, io, revisions, entries, files, bodies, seen, markIncomplete);
290
149
  continue;
291
150
  }
292
151
  if (!entry.isFile())
@@ -8,7 +8,7 @@ import { fileURLToPath } from 'node:url';
8
8
  import { promisify } from 'node:util';
9
9
  import { isLocalWorkspaceGitAuthMode, isLocalWorkspaceGitEnvironment, matchWorkspaceGitGatewayCloneUrl, workspacePreparationSchema } from '@playcraft/common/cloud-workspace/runtime';
10
10
  import { WorkspaceGitDirectStore } from './git-direct-store.js';
11
- import { runtimeCallbackEndpoint } from '../../utils/environment-endpoints.js';
11
+ import { PLATFORM_ROUTES, platformEndpoint } from '@playcraft/common/cloud-workspace/runtime';
12
12
  const exec = promisify(execFile);
13
13
  /** Only this control process sees the callback credential. Development processes use the local socket. */
14
14
  export class WorkspaceGitCredentialBroker {
@@ -165,9 +165,7 @@ export class WorkspaceGitCredentialBroker {
165
165
  await this.direct.sweep(true);
166
166
  }
167
167
  async refresh(binding, key) {
168
- const endpoint = runtimeCallbackEndpoint(this.state.config.callbackUrl, '/runtime/internal/git-credentials');
169
- if (endpoint.protocol !== 'https:' && !(endpoint.protocol === 'http:' && ['127.0.0.1', 'localhost', '[::1]'].includes(endpoint.hostname)))
170
- throw new Error('git_broker_unavailable');
168
+ const endpoint = platformEndpoint(this.state.config.publicBaseUrl, PLATFORM_ROUTES.gitCredentials);
171
169
  const epoch = this.state.runtimeEpoch;
172
170
  const response = await fetch(endpoint, {
173
171
  method: 'POST', redirect: 'error', signal: AbortSignal.timeout(10_000),
@@ -1,6 +1,6 @@
1
1
  import { spawn } from 'node:child_process';
2
2
  import { excludeFromGit } from './dependencies.js';
3
- import { lstat, mkdir, readFile, realpath, rename, rm, stat, unlink, writeFile } from 'node:fs/promises';
3
+ import { lstat, readFile, realpath, stat, unlink } from 'node:fs/promises';
4
4
  import { tmpdir } from 'node:os';
5
5
  import { basename, dirname, join, relative, resolve, sep } from 'node:path';
6
6
  import { WORKSPACE_FILE_CONTENT_MAX, gitDiffSchema, gitFileContentSchema, gitGraphSchema, gitStatusSchema, workspaceRelativePathSchema, } from '@playcraft/common/cloud-workspace/runtime';
@@ -246,95 +246,81 @@ export async function pushWorkspaceGit(root, boundBranch) {
246
246
  catch { /* Preserve uncertainty; never replay a write automatically. */ }
247
247
  throw new GitWorkspaceError('git_push_outcome_unknown', 'git_push_outcome_unknown');
248
248
  }
249
- const UNPUSHED_DIRECTORY = '.git/playcraft';
250
- export const UNPUSHED_BUNDLE = `${UNPUSHED_DIRECTORY}/unpushed.bundle`;
251
- const UNPUSHED_TIPS = `${UNPUSHED_DIRECTORY}/unpushed.tips`;
252
249
  /**
253
- * Keeps a bundle of the commits on local branches and the latest stash that no remote-tracking
254
- * branch holds, so a backup without the packs still has them. It is rewritten only when one of
255
- * those refs moves, and removed when nothing is unpushed. Without remote-tracking refs there is no
256
- * baseline, and the whole history is left out rather than bundled.
250
+ * Credential files a save never commits, even when `.gitignore` misses them: env files (their
251
+ * `.example` / `.sample` / `.template` stay), SSH keys, netrc and Git credential stores, and key
252
+ * or certificate bundles.
257
253
  */
258
- export async function bundleUnpushedCommits(root) {
259
- const rootReal = await realpath(root);
260
- const marker = await lstat(join(rootReal, '.git')).catch(() => undefined);
261
- if (!marker?.isDirectory())
262
- return;
263
- const tips = await git(rootReal, [
264
- 'for-each-ref', '--format=%(objectname) %(refname)', 'refs/heads', 'refs/remotes', 'refs/stash',
265
- ]);
266
- const tipsPath = join(rootReal, UNPUSHED_TIPS);
267
- if ((await readFile(tipsPath, 'utf8').catch(() => undefined)) === tips)
268
- return;
269
- const refs = tips.split('\n').map((line) => line.split(' ')[1]).filter((ref) => Boolean(ref));
270
- const local = ['--branches', ...(refs.includes('refs/stash') ? ['refs/stash'] : [])];
271
- const unpushed = refs.some((ref) => ref.startsWith('refs/remotes/'))
272
- ? Number.parseInt((await git(rootReal, ['rev-list', '--count', ...local, '--not', '--remotes'], 60000)).trim(), 10)
273
- : 0;
274
- const bundlePath = join(rootReal, UNPUSHED_BUNDLE);
275
- await mkdir(join(rootReal, UNPUSHED_DIRECTORY), { recursive: true, mode: 0o700 });
276
- if (unpushed > 0) {
277
- // The `.lock` name keeps a half-written bundle out of any snapshot.
278
- const temp = `${bundlePath}.lock`;
279
- try {
280
- await git(rootReal, ['bundle', 'create', temp, ...local, '--not', '--remotes'], 120000);
281
- await rename(temp, bundlePath);
282
- }
283
- finally {
284
- await rm(temp, { force: true });
285
- }
286
- }
287
- else {
288
- await rm(bundlePath, { force: true });
289
- }
290
- await writeFile(tipsPath, tips, { mode: 0o600 });
254
+ const CREDENTIAL_FILE = /^(?:\.env(?:\.(?!(?:example|sample|template)$)[^/]+)?|id_(?:rsa|dsa|ecdsa|ed25519)|\.netrc|\.git-credentials|[^/]*\.(?:pem|key|p12|pfx))$/i;
255
+ export function isCredentialFile(path) {
256
+ return CREDENTIAL_FILE.test(path.split('/').pop() ?? '');
291
257
  }
292
258
  /**
293
- * Adds a bundle's objects to the repository. `unbundle` walks history from the current refs, so this
294
- * runs on the fresh clone before the snapshot's refs, which point at these very commits, replace its
295
- * own. It fails when the remote lost the commits the bundle was built on; `repositoryResolves` then
296
- * reports what is missing.
259
+ * Commits every change in the work tree (`git add -A`, credential files aside) and, when asked,
260
+ * pushes the branch. A save with nothing new commits nothing and pushes only what the remote
261
+ * lacks, so a commit whose push failed goes out with the next save.
297
262
  */
298
- export async function addBundleObjects(root, body) {
299
- const rootReal = await realpath(root);
300
- const marker = await lstat(join(rootReal, '.git')).catch(() => undefined);
301
- if (!marker?.isDirectory())
263
+ export async function saveWorkspaceGit(root, input) {
264
+ const rootReal = await ensureWorkspaceGit(root);
265
+ await assertBoundBranch(rootReal, input.boundBranch);
266
+ await git(rootReal, ['add', '-A'], 60000);
267
+ const staged = (await git(rootReal, ['diff', '--cached', '--name-only', '-z'], 60000)).split('\0').filter(Boolean);
268
+ const withheld = staged.filter(isCredentialFile);
269
+ await unstage(rootReal, withheld);
270
+ const cached = await gitAllowFail(rootReal, ['diff', '--cached', '--quiet'], 60000);
271
+ if (cached.code !== 0 && cached.code !== 1) {
272
+ throw new GitWorkspaceError('git_command_failed', cached.stderr.trim() || 'git_diff_failed');
273
+ }
274
+ let commit;
275
+ if (cached.code === 1) {
276
+ await git(rootReal, [...(await commitIdentity(rootReal)), 'commit', '-q', '-m', input.message], 60000);
277
+ commit = (await git(rootReal, ['rev-parse', 'HEAD'])).trim();
278
+ }
279
+ if (!input.push || (await remoteHasHead(rootReal)))
280
+ return { ...(commit ? { commit } : {}), pushed: false, withheld };
281
+ await pushWorkspaceGit(rootReal, input.boundBranch);
282
+ await recordPushedHead(rootReal);
283
+ return { ...(commit ? { commit } : {}), pushed: true, withheld };
284
+ }
285
+ async function unstage(rootReal, paths) {
286
+ if (paths.length === 0)
302
287
  return;
303
- const temp = join(rootReal, '.git', 'playcraft-restore.bundle.lock');
304
- try {
305
- await writeFile(temp, body, { mode: 0o600 });
306
- await gitAllowFail(rootReal, ['bundle', 'unbundle', temp], 120000);
307
- }
308
- catch {
309
- /* Reported by repositoryResolves. */
310
- }
311
- finally {
312
- await rm(temp, { force: true });
288
+ const born = (await gitAllowFail(rootReal, ['rev-parse', '-q', '--verify', 'HEAD'])).code === 0;
289
+ for (let start = 0; start < paths.length; start += 100) {
290
+ const chunk = paths.slice(start, start + 100);
291
+ // Before the first commit there is no HEAD to reset to; dropping the entries does the same.
292
+ await git(rootReal, born ? ['reset', '-q', '--', ...chunk] : ['rm', '-q', '--cached', '--', ...chunk]);
313
293
  }
314
294
  }
315
- /** Whether HEAD and the index resolve to objects the repository holds. */
316
- export async function repositoryResolves(root) {
317
- const rootReal = await realpath(root);
318
- const marker = await lstat(join(rootReal, '.git')).catch(() => undefined);
319
- if (!marker?.isDirectory())
320
- return false;
321
- try {
322
- // Snapshots hold files only; Git does not recognise a repository whose refs folder is gone.
323
- for (const directory of ['refs/heads', 'refs/tags', 'objects']) {
324
- await mkdir(join(rootReal, '.git', directory), { recursive: true, mode: 0o755 });
325
- }
326
- const head = await gitAllowFail(rootReal, ['rev-parse', '-q', '--verify', 'HEAD^{tree}']);
327
- if (head.code === 0) {
328
- const index = await gitAllowFail(rootReal, ['diff-index', '--cached', '--quiet', 'HEAD'], 60000);
329
- return index.code === 0 || index.code === 1;
330
- }
331
- // Before the first commit there is no branch to resolve.
332
- const branches = await gitAllowFail(rootReal, ['for-each-ref', '--count=1', 'refs/heads']);
333
- return branches.code === 0 && branches.stdout.trim() === '';
334
- }
335
- catch {
295
+ /** A clone carries no committer; the sandbox image sets none either. */
296
+ async function commitIdentity(rootReal) {
297
+ const email = await gitAllowFail(rootReal, ['config', 'user.email']);
298
+ if (email.code === 0 && email.stdout.trim())
299
+ return [];
300
+ return ['-c', 'user.name=PlayCraft Workspace', '-c', 'user.email=workspace@playcraft.local'];
301
+ }
302
+ /**
303
+ * The remote-tracking ref of the current branch. A fork clone has no fetch refspec for its own
304
+ * branch, so the ref is written here after a confirmed push.
305
+ */
306
+ async function trackingRef(rootReal) {
307
+ const branch = (await gitAllowFail(rootReal, ['symbolic-ref', '--quiet', '--short', 'HEAD'])).stdout.trim();
308
+ return branch ? `refs/remotes/origin/${branch}` : undefined;
309
+ }
310
+ async function remoteHasHead(rootReal) {
311
+ const ref = await trackingRef(rootReal);
312
+ if (!ref)
336
313
  return false;
337
- }
314
+ const [head, tracked] = await Promise.all([
315
+ gitAllowFail(rootReal, ['rev-parse', '-q', '--verify', 'HEAD^{commit}']),
316
+ gitAllowFail(rootReal, ['rev-parse', '-q', '--verify', `${ref}^{commit}`]),
317
+ ]);
318
+ return head.code === 0 && tracked.code === 0 && head.stdout.trim() === tracked.stdout.trim();
319
+ }
320
+ async function recordPushedHead(rootReal) {
321
+ const ref = await trackingRef(rootReal);
322
+ if (ref)
323
+ await gitAllowFail(rootReal, ['update-ref', ref, 'HEAD']);
338
324
  }
339
325
  export async function ensureWorkspaceGit(root) {
340
326
  const rootReal = await realpath(root);
@@ -1,6 +1,6 @@
1
1
  import { createHash } from 'node:crypto';
2
2
  import { workspaceRoot } from './registry.js';
3
- import { runtimeCallbackEndpoint } from '../../utils/environment-endpoints.js';
3
+ import { PLATFORM_ROUTES, platformEndpoint } from '@playcraft/common/cloud-workspace/runtime';
4
4
  const MAX_ARTIFACT_BYTES = 80 * 1024 * 1024;
5
5
  /** Requests an exact locked artifact using control identity; no storage credentials reach Codex. */
6
6
  export async function loadWorkspaceSkillArtifact(state, workspaceId, entry) {
@@ -8,9 +8,7 @@ export async function loadWorkspaceSkillArtifact(state, workspaceId, entry) {
8
8
  const epoch = state.runtimeEpoch;
9
9
  if (state.journal.destroyed())
10
10
  throw new Error('workspace_skills_unavailable');
11
- const endpoint = runtimeCallbackEndpoint(state.config.callbackUrl, '/runtime/internal/skill-artifacts');
12
- if (endpoint.protocol !== 'https:' && !(endpoint.protocol === 'http:' && ['127.0.0.1', 'localhost', '[::1]'].includes(endpoint.hostname)))
13
- throw new Error('workspace_skills_callback_insecure');
11
+ const endpoint = platformEndpoint(state.config.publicBaseUrl, PLATFORM_ROUTES.skillArtifacts);
14
12
  const response = await fetch(endpoint, {
15
13
  method: 'POST', redirect: 'error', signal: AbortSignal.timeout(30_000),
16
14
  headers: { authorization: `Bearer ${state.config.controlToken}`, 'content-type': 'application/json', 'playcraft-env': state.config.environment },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@playcraft/cli",
3
- "version": "0.1.16",
3
+ "version": "0.1.18",
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.16",
52
- "@playcraft/common": "^0.1.16",
51
+ "@playcraft/build": "^0.1.18",
52
+ "@playcraft/common": "^0.1.18",
53
53
  "better-sqlite3-pinned": "npm:better-sqlite3@13.0.3",
54
54
  "chokidar": "^4.0.3",
55
55
  "commander": "^13.1.0",
@@ -1,94 +0,0 @@
1
- import { randomUUID } from 'node:crypto';
2
- import { readdir, readFile, stat } from 'node:fs/promises';
3
- import { join } from 'node:path';
4
- import { startLauncherProcess } from './launcher-client.js';
5
- export const TRANSCRIPT_MAX_BYTES = 64 * 1024 * 1024;
6
- const THREAD_ID = /^[A-Za-z0-9_-]{1,128}$/;
7
- const TRANSCRIPT_PATH = /^sessions\/\d{4}\/\d{2}\/\d{2}\/rollout-[A-Za-z0-9_.-]{1,200}\.jsonl$/;
8
- const COMMAND_TIMEOUT_MS = 60_000;
9
- /**
10
- * Reads the transcript Codex keeps for one thread. Codex appends to it while a turn runs, so a
11
- * trailing partial line is dropped; what is returned always parses line by line.
12
- */
13
- export async function exportTranscript(home, threadId) {
14
- if (!THREAD_ID.test(threadId))
15
- return undefined;
16
- const found = home.launcher
17
- ? await exportAsDeveloper(home.homeDir, home.launcher, threadId)
18
- : await exportDirect(home.homeDir, threadId);
19
- if (!found || !transcriptPathFor(found.path, threadId))
20
- return undefined;
21
- const end = found.body.lastIndexOf(0x0a);
22
- if (end < 0)
23
- return undefined;
24
- return { path: found.path, body: found.body.subarray(0, end + 1) };
25
- }
26
- function transcriptPathFor(path, threadId) {
27
- return TRANSCRIPT_PATH.test(path) && path.endsWith(`-${threadId}.jsonl`);
28
- }
29
- async function exportDirect(homeDir, threadId) {
30
- const suffix = `-${threadId}.jsonl`;
31
- for (const year of await names(join(homeDir, 'sessions'))) {
32
- for (const month of await names(join(homeDir, 'sessions', year))) {
33
- for (const day of await names(join(homeDir, 'sessions', year, month))) {
34
- const directory = join('sessions', year, month, day);
35
- const match = (await names(join(homeDir, directory))).find((name) => name.endsWith(suffix));
36
- if (!match)
37
- continue;
38
- const path = `${directory}/${match}`;
39
- const absolute = join(homeDir, path);
40
- if ((await stat(absolute)).size > TRANSCRIPT_MAX_BYTES)
41
- return undefined;
42
- return { path, body: await readFile(absolute) };
43
- }
44
- }
45
- }
46
- return undefined;
47
- }
48
- async function exportAsDeveloper(homeDir, launcher, threadId) {
49
- const limitKib = TRANSCRIPT_MAX_BYTES / 1024;
50
- const outcome = await runAsDeveloper(launcher, [
51
- `home=${quote(homeDir)}`,
52
- `f=$(find "$home/sessions" -mindepth 4 -maxdepth 4 -type f -name ${quote(`rollout-*-${threadId}.jsonl`)} -size -${limitKib}k 2>/dev/null | head -n 1)`,
53
- '[ -n "$f" ] || exit 3',
54
- 'printf \'%s\\n\' "${f#"$home"/}"',
55
- 'base64 -w 0 "$f"',
56
- ].join('; '));
57
- if (outcome.code !== 0)
58
- return undefined;
59
- const text = outcome.stdout.toString('utf8');
60
- const newline = text.indexOf('\n');
61
- if (newline < 0)
62
- return undefined;
63
- return { path: text.slice(0, newline), body: Buffer.from(text.slice(newline + 1).trim(), 'base64') };
64
- }
65
- async function runAsDeveloper(launcher, command) {
66
- const child = await startLauncherProcess({
67
- socketPath: launcher.socketPath,
68
- processId: `transcript-${randomUUID()}`,
69
- runtimeEpoch: launcher.runtimeEpoch,
70
- argv: ['/usr/bin/bash', '-c', `{ ${command}; } </dev/null 2>/dev/null`],
71
- cwd: launcher.cwd,
72
- env: {},
73
- });
74
- child.stdin?.end();
75
- const chunks = [];
76
- child.stdout?.on('data', (chunk) => chunks.push(Buffer.from(chunk)));
77
- const code = await new Promise((resolve) => {
78
- const timer = setTimeout(() => {
79
- void child.stop().catch(() => undefined);
80
- resolve(null);
81
- }, COMMAND_TIMEOUT_MS);
82
- child.onExit((value) => {
83
- clearTimeout(timer);
84
- resolve(value);
85
- });
86
- });
87
- return { code, stdout: Buffer.concat(chunks) };
88
- }
89
- async function names(directory) {
90
- return readdir(directory).then((entries) => entries.sort(), () => []);
91
- }
92
- function quote(value) {
93
- return `'${value.replaceAll("'", "'\\''")}'`;
94
- }