@playcraft/cli 0.1.21 → 0.1.22

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.
Files changed (35) hide show
  1. package/dist/commands/tools-batch.js +59 -0
  2. package/dist/commands/tools.js +2 -0
  3. package/dist/workspace-runtime/codex/app-server.js +16 -5
  4. package/dist/workspace-runtime/codex/model-broker.js +5 -5
  5. package/dist/workspace-runtime/previews/publish.js +7 -4
  6. package/dist/workspace-runtime/production/contracts.js +17 -0
  7. package/dist/workspace-runtime/production/controller.js +207 -0
  8. package/dist/workspace-runtime/production/dependencies.js +60 -0
  9. package/dist/workspace-runtime/production/native.js +70 -0
  10. package/dist/workspace-runtime/production/processes.js +86 -0
  11. package/dist/workspace-runtime/production/save-command.js +20 -0
  12. package/dist/workspace-runtime/production/stages.js +175 -0
  13. package/dist/workspace-runtime/production/storage.js +32 -0
  14. package/dist/workspace-runtime/serve.js +3 -6
  15. package/dist/workspace-runtime/server/desktop-host.js +1 -1
  16. package/dist/workspace-runtime/server/desktop-preview.js +5 -5
  17. package/dist/workspace-runtime/server/dispatch.js +34 -24
  18. package/dist/workspace-runtime/server/http.js +110 -31
  19. package/dist/workspace-runtime/server/terminal-host.js +6 -1
  20. package/dist/workspace-runtime/workspaces/batch-commit.js +142 -0
  21. package/dist/workspace-runtime/workspaces/dependencies.js +5 -3
  22. package/dist/workspace-runtime/workspaces/directories.js +106 -0
  23. package/dist/workspace-runtime/workspaces/directory-access.js +20 -0
  24. package/dist/workspace-runtime/workspaces/directory-maintenance.js +111 -0
  25. package/dist/workspace-runtime/workspaces/directory-stats.js +49 -0
  26. package/dist/workspace-runtime/workspaces/git-credential-broker.js +63 -30
  27. package/dist/workspace-runtime/workspaces/git-credential-helper.js +4 -2
  28. package/dist/workspace-runtime/workspaces/git-direct-store.js +12 -11
  29. package/dist/workspace-runtime/workspaces/prepare.js +20 -5
  30. package/dist/workspace-runtime/workspaces/registry.js +63 -12
  31. package/dist/workspace-runtime/workspaces/revisions.js +5 -5
  32. package/dist/workspace-runtime/workspaces/skill-artifact-loader.js +2 -2
  33. package/package.json +3 -3
  34. package/project-templates/batch-locks/phaser.yaml +922 -0
  35. package/project-templates/batch-locks/threejs.yaml +978 -0
@@ -0,0 +1,111 @@
1
+ import { execFile } from 'node:child_process';
2
+ import { mkdir, readFile, rename, rm, writeFile } from 'node:fs/promises';
3
+ import { join } from 'node:path';
4
+ import { randomUUID } from 'node:crypto';
5
+ import { promisify } from 'node:util';
6
+ import { workspaceDirectoryNameSchema } from '@playcraft/common/cloud-workspace/runtime';
7
+ import { productionDirectoryBusy } from '../production/controller.js';
8
+ import { workspaceRoot, workspaceSessions } from './registry.js';
9
+ const execute = promisify(execFile);
10
+ const git = async (state, root, ...args) => (await execute('git', ['-c', 'core.hooksPath=/dev/null', '-c', 'core.fsmonitor=false', '-C', root, ...args], {
11
+ timeout: 30_000, maxBuffer: 1024 * 1024, env: { PATH: process.env.PATH, HOME: state.config.stateDir, GIT_CONFIG_GLOBAL: '/dev/null', GIT_CONFIG_NOSYSTEM: '1', GIT_TERMINAL_PROMPT: '0' },
12
+ })).stdout.trim();
13
+ export async function workspaceDirectoryOccupied(state, workspaceId, directoryName) {
14
+ if (await productionDirectoryBusy(state, workspaceId, directoryName))
15
+ return true;
16
+ if (state.journal.listOperations().some(({ operation }) => operation.workspaceId === workspaceId
17
+ && (operation.directoryName ?? 'basic') === directoryName && ['queued', 'dispatched', 'accepted', 'uncertain'].includes(operation.status)))
18
+ return true;
19
+ if (workspaceSessions(state, workspaceId, directoryName).some(session => session.turnBlock === 'uncertain' || ['starting', 'running', 'waiting_for_user'].includes(session.turnStatus ?? '')))
20
+ return true;
21
+ if (state.journal.listTerminals().some(terminal => terminal.workspaceId === workspaceId && (terminal.directoryName ?? 'basic') === directoryName && terminal.status !== 'destroyed'))
22
+ return true;
23
+ return [workspaceId, `${workspaceId}:${directoryName}`].some(scope => state.journal.listManagedWrites(scope).some(write => !write.stoppedAt));
24
+ }
25
+ async function assertCleanAndPushed(state, root, allowedHead) {
26
+ if (await git(state, root, 'status', '--porcelain', '--untracked-files=all'))
27
+ throw new Error('workspace_directory_uncommitted');
28
+ const upstream = await git(state, root, 'rev-parse', '--abbrev-ref', '--symbolic-full-name', '@{upstream}').catch(() => '');
29
+ if (!upstream || (Number(await git(state, root, 'rev-list', '--count', `${upstream}..HEAD`)) > 0 && await git(state, root, 'rev-parse', 'HEAD') !== allowedHead))
30
+ throw new Error('workspace_directory_unpushed');
31
+ }
32
+ /** Keep remote branches, published previews and all journal identities; only local checkouts are reclaimed. */
33
+ export async function cleanupWorkspaceDirectories(state, workspaceId, directoryNames) {
34
+ if (!Array.isArray(directoryNames) || directoryNames.length === 0 || directoryNames.length > 64)
35
+ throw new Error('workspace_directories_invalid');
36
+ const names = [...new Set(directoryNames.map(name => workspaceDirectoryNameSchema.parse(name)))];
37
+ return state.journal.exclusive(`maintenance:${workspaceId}`, async () => {
38
+ const cleaned = [];
39
+ const blocked = [];
40
+ for (const directoryName of names) {
41
+ try {
42
+ await state.journal.exclusive(`directory:${workspaceId}:${directoryName}`, async () => {
43
+ if (await workspaceDirectoryOccupied(state, workspaceId, directoryName))
44
+ throw new Error('workspace_directory_occupied');
45
+ const root = await workspaceRoot(state, workspaceId, directoryName);
46
+ await assertCleanAndPushed(state, root);
47
+ const binding = state.journal.getWorkspace(workspaceId);
48
+ // Persist recoverable identity before removing the checkout; a retry cannot silently adopt another branch.
49
+ const existing = binding.directories?.find(item => item.directoryName === directoryName);
50
+ const directory = existing ?? { workspaceId, directoryName, remixId: binding.remixId, branchName: binding.branchName };
51
+ await state.journal.putWorkspace({ ...binding, directories: [...(binding.directories ?? []).filter(item => item.directoryName !== directoryName), { ...directory, status: 'reclaimed' }] });
52
+ await rm(root, { recursive: true });
53
+ cleaned.push(directoryName);
54
+ });
55
+ }
56
+ catch (error) {
57
+ blocked.push({ directoryName, reason: error instanceof Error && /^[a-z0-9_]+$/.test(error.message) ? error.message : 'workspace_cleanup_failed' });
58
+ }
59
+ }
60
+ return { cleaned, blocked };
61
+ });
62
+ }
63
+ /** Conflicts remain isolated to their own engineering directory. Busy directories are skipped without deferred work. */
64
+ export async function syncWorkspaceDirectories(state, workspaceId, commitHash, targetDirectoryNames) {
65
+ if (typeof commitHash !== 'string' || !/^[a-f0-9]{40,64}$/.test(commitHash) || !Array.isArray(targetDirectoryNames) || targetDirectoryNames.length === 0 || targetDirectoryNames.length > 64)
66
+ throw new Error('workspace_sync_invalid');
67
+ const basic = await workspaceRoot(state, workspaceId, 'basic');
68
+ await git(state, basic, 'merge-base', '--is-ancestor', commitHash, 'HEAD');
69
+ const synced = [], skipped = [], conflicts = [];
70
+ for (const directoryName of [...new Set(targetDirectoryNames.map(name => workspaceDirectoryNameSchema.parse(name)))]) {
71
+ if (directoryName === 'basic')
72
+ throw new Error('workspace_sync_target_invalid');
73
+ await state.journal.exclusive(`directory:${workspaceId}:${directoryName}`, async () => {
74
+ if (await workspaceDirectoryOccupied(state, workspaceId, directoryName)) {
75
+ skipped.push({ directoryName, reason: 'directory_busy' });
76
+ return;
77
+ }
78
+ const root = await workspaceRoot(state, workspaceId, directoryName);
79
+ const ledger = join(state.config.stateDir, 'directory-sync', workspaceId, `${directoryName}.json`);
80
+ const applied = await readFile(ledger, 'utf8').then(value => JSON.parse(value), () => undefined);
81
+ await assertCleanAndPushed(state, root, applied?.sourceCommit === commitHash ? applied.targetCommit : undefined);
82
+ if (applied?.sourceCommit === commitHash && await git(state, root, 'rev-parse', 'HEAD') === applied.targetCommit) {
83
+ synced.push(directoryName);
84
+ return;
85
+ }
86
+ const containing = await git(state, root, 'merge-base', '--is-ancestor', commitHash, 'HEAD').then(() => true, () => false);
87
+ if (containing) {
88
+ synced.push(directoryName);
89
+ return;
90
+ }
91
+ // Import the exact preserved basic commit, without fetching a moving remote branch.
92
+ await execute('git', ['-c', 'core.hooksPath=/dev/null', '-C', root, 'fetch', '--no-tags', '--', basic, commitHash], { timeout: 30_000, maxBuffer: 1024 * 1024 });
93
+ const equivalent = (await git(state, root, 'cherry', 'HEAD', commitHash)).split('\n').some(line => line === `- ${commitHash}`);
94
+ if (!equivalent) {
95
+ try {
96
+ await git(state, root, '-c', 'user.name=PlayCraft', '-c', 'user.email=playcraft@localhost', 'cherry-pick', commitHash);
97
+ }
98
+ catch {
99
+ conflicts.push(directoryName);
100
+ return;
101
+ }
102
+ }
103
+ await mkdir(join(state.config.stateDir, 'directory-sync', workspaceId), { recursive: true, mode: 0o700 });
104
+ const temporary = `${ledger}.${randomUUID()}.tmp`;
105
+ await writeFile(temporary, JSON.stringify({ sourceCommit: commitHash, targetCommit: await git(state, root, 'rev-parse', 'HEAD') }), { mode: 0o600 });
106
+ await rename(temporary, ledger);
107
+ synced.push(directoryName);
108
+ });
109
+ }
110
+ return { synced, skipped, conflicts, previewStale: true };
111
+ }
@@ -0,0 +1,49 @@
1
+ import { execFile } from 'node:child_process';
2
+ import { promisify } from 'node:util';
3
+ import { workspaceRoot, WorkspaceContextError } from './registry.js';
4
+ const execute = promisify(execFile);
5
+ /** Measures registered checkouts only when requested; journal activity never scans private neighboring workspaces. */
6
+ export async function readWorkspaceDirectoryStats(state, workspaceId) {
7
+ const binding = state.journal.getWorkspace(workspaceId);
8
+ if (!binding)
9
+ throw new WorkspaceContextError('workspace_not_registered');
10
+ const names = [...new Set(['basic', ...(binding.directories ?? []).map(directory => directory.directoryName)])];
11
+ const operations = state.journal.listOperations().map(item => item.operation).filter(operation => operation.workspaceId === workspaceId);
12
+ const events = state.journal.listEvents().flatMap(event => {
13
+ const payload = event.payload && typeof event.payload === 'object' ? event.payload : {};
14
+ const session = typeof payload.sessionId === 'string' ? state.journal.getSession(payload.sessionId) : undefined;
15
+ if ((payload.workspaceId ?? session?.workspaceId) !== workspaceId)
16
+ return [];
17
+ return [{ directoryName: String(payload.directoryName ?? session?.directoryName ?? 'basic'), occurredAt: event.occurredAt }];
18
+ });
19
+ const latest = (dates) => {
20
+ const timestamps = dates.map(value => Date.parse(value)).filter(Number.isFinite);
21
+ return timestamps.length ? new Date(Math.max(...timestamps)).toISOString() : null;
22
+ };
23
+ const directories = [];
24
+ for (const directoryName of names) {
25
+ const lastUsedAt = latest([...operations.filter(operation => (operation.directoryName ?? 'basic') === directoryName).map(operation => operation.updatedAt),
26
+ ...events.filter(event => event.directoryName === directoryName).map(event => event.occurredAt)]);
27
+ if (binding.directories?.some(directory => directory.directoryName === directoryName && directory.status === 'reclaimed')) {
28
+ directories.push({ directoryName, sizeBytes: 0, lastUsedAt, status: 'reclaimed' });
29
+ continue;
30
+ }
31
+ let root;
32
+ try {
33
+ root = await workspaceRoot(state, workspaceId, directoryName);
34
+ }
35
+ catch (error) {
36
+ if (!(error instanceof WorkspaceContextError) || error.message !== 'workspace_not_prepared')
37
+ throw error;
38
+ directories.push({ directoryName, sizeBytes: 0, lastUsedAt, status: 'missing' });
39
+ continue;
40
+ }
41
+ const output = await execute('du', ['-sk', root], { timeout: 30_000, maxBuffer: 4096 });
42
+ const blocks = Number(output.stdout.trim().split(/\s+/)[0]);
43
+ if (!Number.isSafeInteger(blocks) || blocks < 0 || !Number.isSafeInteger(blocks * 1024))
44
+ throw new Error('workspace_directory_stats_invalid');
45
+ directories.push({ directoryName, sizeBytes: blocks * 1024, lastUsedAt, status: 'present' });
46
+ }
47
+ return { workspaceId, sizeBytes: directories.reduce((sum, directory) => sum + directory.sizeBytes, 0),
48
+ lastUsedAt: latest([...operations.map(operation => operation.updatedAt), ...events.map(event => event.occurredAt)]), directories };
49
+ }
@@ -6,7 +6,7 @@ import { createServer } from 'node:http';
6
6
  import { join } from 'node:path';
7
7
  import { fileURLToPath } from 'node:url';
8
8
  import { promisify } from 'node:util';
9
- import { isLocalWorkspaceGitAuthMode, isLocalWorkspaceGitEnvironment, matchWorkspaceGitGatewayCloneUrl, workspacePreparationSchema } from '@playcraft/common/cloud-workspace/runtime';
9
+ import { isLocalWorkspaceGitAuthMode, isLocalWorkspaceGitEnvironment, matchWorkspaceGitGatewayCloneUrl, workspacePreparationSchema, workspaceDirectoryNameSchema } from '@playcraft/common/cloud-workspace/runtime';
10
10
  import { WorkspaceGitDirectStore } from './git-direct-store.js';
11
11
  import { PLATFORM_ROUTES, platformEndpoint } from '@playcraft/common/cloud-workspace/runtime';
12
12
  const exec = promisify(execFile);
@@ -76,9 +76,17 @@ export class WorkspaceGitCredentialBroker {
76
76
  await unlink(this.socketPath).catch(() => undefined);
77
77
  }
78
78
  }
79
- async configure(binding, preparation) {
79
+ async configure(binding, preparation, directoryName = 'basic') {
80
+ workspaceDirectoryNameSchema.parse(directoryName);
81
+ if (binding.targetKind === 'standalone')
82
+ throw new Error('git_access_denied');
83
+ if (directoryName !== 'basic')
84
+ binding = this.binding(binding.workspaceId, directoryName);
85
+ const { directories: _directories, ...stableBinding } = binding;
86
+ binding = stableBinding;
87
+ const scope = `${binding.workspaceId}:${directoryName}`;
80
88
  preparation = workspacePreparationSchema.parse(preparation);
81
- if (binding.targetKind === 'standalone' || preparation.mode !== 'clone')
89
+ if (preparation.mode !== 'clone')
82
90
  throw new Error('git_access_denied');
83
91
  const transport = preparation.transport;
84
92
  const isDirect = isLocalWorkspaceGitAuthMode(preparation.authMode);
@@ -95,13 +103,13 @@ export class WorkspaceGitCredentialBroker {
95
103
  }
96
104
  // Revocation and switching to gateway invalidate first; direct rotation keeps the old file until rename.
97
105
  if (!isDirect)
98
- await this.direct.remove(binding.workspaceId);
106
+ await this.direct.remove(binding.workspaceId, directoryName);
99
107
  else if (preparation.directAccess === null)
100
- await this.direct.put(binding, null);
101
- this.cache.delete(binding.workspaceId);
108
+ await this.direct.put(binding, null, undefined, directoryName);
109
+ this.cache.delete(scope);
102
110
  const cloneUrl = isDirect ? binding.repositoryUrl : transport.cloneUrl;
103
- const root = join(this.state.config.workspaceRoot, binding.workspaceId);
104
- const helper = `!${quote(process.execPath)} ${quote(fileURLToPath(new URL('./git-credential-helper.js', import.meta.url)))} ${quote(this.socketPath)} ${quote(binding.workspaceId)}`;
111
+ const root = join(this.state.config.workspaceRoot, binding.workspaceId, directoryName);
112
+ const helper = `!${quote(process.execPath)} ${quote(fileURLToPath(new URL('./git-credential-helper.js', import.meta.url)))} ${quote(this.socketPath)} ${quote(binding.workspaceId)} ${quote(directoryName)}`;
105
113
  await configureGitFile(root, this.state.config.stateDir, [
106
114
  ...(!isDirect ? [['--replace-all', `url.${cloneUrl}.insteadOf`, binding.repositoryUrl]] : []),
107
115
  ['--replace-all', `credential.${cloneUrl}.useHttpPath`, 'true'],
@@ -111,8 +119,8 @@ export class WorkspaceGitCredentialBroker {
111
119
  ['--replace-all', 'playcraft.gitAuthUrl', cloneUrl],
112
120
  ], binding.repositoryUrl);
113
121
  if (isDirect)
114
- await this.direct.put(binding, preparation.directAccess);
115
- this.cache.delete(binding.workspaceId);
122
+ await this.direct.put(binding, preparation.directAccess, undefined, directoryName);
123
+ this.cache.delete(scope);
116
124
  }
117
125
  async credential(input) {
118
126
  if (!this.ready || this.closed || this.state.journal.destroyed() || !input || !['get', 'erase'].includes(input.action)
@@ -120,17 +128,17 @@ export class WorkspaceGitCredentialBroker {
120
128
  || !['http', 'https'].includes(input.protocol) || typeof input.host !== 'string'
121
129
  || typeof input.path !== 'string')
122
130
  throw new Error('git_access_denied');
123
- const binding = this.state.journal.getWorkspace(input.workspaceId);
124
- if (!binding || binding.targetKind === 'standalone')
125
- throw new Error('git_access_denied');
131
+ const directoryName = workspaceDirectoryNameSchema.parse(input.directoryName ?? 'basic');
132
+ const binding = this.binding(input.workspaceId, directoryName);
133
+ const scope = `${input.workspaceId}:${directoryName}`;
126
134
  const cloneUrl = `${input.protocol}://${input.host}/${input.path}`;
127
- const direct = await this.direct.get(binding);
135
+ const direct = await this.direct.get(binding, directoryName);
128
136
  if (direct) {
129
137
  if (!direct.access || cloneUrl !== binding.repositoryUrl)
130
138
  throw new Error('git_access_denied');
131
139
  if (input.action === 'erase') {
132
140
  if (input.password === direct.access.password)
133
- await this.direct.put(binding, null, input.password);
141
+ await this.direct.put(binding, null, input.password, directoryName);
134
142
  return {};
135
143
  }
136
144
  return { username: direct.access.username, password: direct.access.password };
@@ -139,39 +147,57 @@ export class WorkspaceGitCredentialBroker {
139
147
  if (!matched.ok || matched.requestId !== `v2/${binding.workspaceId}`)
140
148
  throw new Error('git_access_denied');
141
149
  if (input.action === 'erase') {
142
- if (this.cache.get(input.workspaceId)?.preparation.gitAccess?.password === input.password)
143
- this.cache.delete(input.workspaceId);
150
+ if (this.cache.get(scope)?.preparation.gitAccess?.password === input.password)
151
+ this.cache.delete(scope);
144
152
  return {};
145
153
  }
146
154
  const key = JSON.stringify([binding, this.state.config.generation, this.state.runtimeEpoch]);
147
- const cached = this.cache.get(input.workspaceId);
155
+ const cached = this.cache.get(scope);
148
156
  let preparation = cached?.key === key && Date.parse(cached.preparation.gitAccess.expiresAt) > Date.now() + 60_000
149
157
  ? cached.preparation : undefined;
150
158
  if (!preparation) {
151
- const pendingKey = `${input.workspaceId}:${key}`;
159
+ const pendingKey = `${scope}:${key}`;
152
160
  let pending = this.pending.get(pendingKey);
153
161
  if (!pending) {
154
- pending = this.refresh(binding, key).finally(() => this.pending.delete(pendingKey));
162
+ pending = this.refresh(binding, key, directoryName).finally(() => this.pending.delete(pendingKey));
155
163
  this.pending.set(pendingKey, pending);
156
164
  }
157
165
  preparation = await pending;
158
166
  }
159
- if (await this.direct.get(binding) || preparation.transport?.cloneUrl !== cloneUrl || !preparation.gitAccess)
167
+ if (await this.direct.get(binding, directoryName) || preparation.transport?.cloneUrl !== cloneUrl || !preparation.gitAccess)
160
168
  throw new Error('git_access_denied');
161
169
  return { username: preparation.gitAccess.username, password: preparation.gitAccess.password };
162
170
  }
171
+ async prepareDirectory(workspaceId, directoryName = 'basic') {
172
+ const binding = this.binding(workspaceId, directoryName, true);
173
+ return this.refresh(binding, JSON.stringify([binding, this.state.config.generation, this.state.runtimeEpoch]), directoryName);
174
+ }
175
+ binding(workspaceId, directoryName, recovering = false) {
176
+ workspaceDirectoryNameSchema.parse(directoryName);
177
+ const binding = this.state.journal.getWorkspace(workspaceId);
178
+ if (!binding || binding.targetKind === 'standalone')
179
+ throw new Error('git_access_denied');
180
+ const { directories: _directories, ...base } = binding;
181
+ if (directoryName === 'basic')
182
+ return base;
183
+ const directory = binding.directories?.find(item => item.directoryName === directoryName && (recovering || item.status !== 'reclaimed'));
184
+ if (!directory?.remixId)
185
+ throw new Error('git_access_denied');
186
+ return { ...base, targetKind: 'remix', remixId: directory.remixId, branchName: directory.branchName };
187
+ }
163
188
  async destroy() {
164
189
  await this.close();
165
190
  await this.direct.sweep(true);
166
191
  }
167
- async refresh(binding, key) {
192
+ async refresh(binding, key, directoryName) {
193
+ const scope = `${binding.workspaceId}:${directoryName}`;
168
194
  const endpoint = platformEndpoint(this.state.config.publicBaseUrl, PLATFORM_ROUTES.gitCredentials);
169
195
  const epoch = this.state.runtimeEpoch;
170
196
  const response = await fetch(endpoint, {
171
197
  method: 'POST', redirect: 'error', signal: AbortSignal.timeout(10_000),
172
198
  headers: { authorization: `Bearer ${this.state.config.controlToken}`, 'content-type': 'application/json', 'playcraft-env': this.state.config.environment },
173
199
  body: JSON.stringify({ runtimeId: this.state.config.runtimeId, generation: this.state.config.generation,
174
- runtimeEpoch: epoch, workspaceId: binding.workspaceId }),
200
+ runtimeEpoch: epoch, workspaceId: binding.workspaceId, directoryName }),
175
201
  });
176
202
  if (!response.ok) {
177
203
  await response.body?.cancel();
@@ -182,14 +208,21 @@ export class WorkspaceGitCredentialBroker {
182
208
  throw new Error('git_access_denied');
183
209
  const matched = preparation.transport && matchWorkspaceGitGatewayCloneUrl(preparation.transport.cloneUrl, this.state.config.gitGatewayOrigins, binding.repositoryUrl);
184
210
  if (this.closed || this.state.journal.destroyed() || this.state.runtimeEpoch !== epoch
185
- || JSON.stringify(this.state.journal.getWorkspace(binding.workspaceId)) !== JSON.stringify(binding)
186
- || preparation.transport?.kind !== 'git-gateway-v2' || !matched?.ok || matched.requestId !== `v2/${binding.workspaceId}`
187
- || !preparation.gitAccess || Date.parse(preparation.gitAccess.issuedAt) > Date.now()
188
- || Date.parse(preparation.gitAccess.expiresAt) <= Date.now() + 10_000)
211
+ || JSON.stringify(this.binding(binding.workspaceId, directoryName, true)) !== JSON.stringify(binding))
189
212
  throw new Error('git_access_denied');
190
- if (this.cache.size >= 128)
191
- this.cache.clear();
192
- this.cache.set(binding.workspaceId, { key, preparation });
213
+ if (isLocalWorkspaceGitAuthMode(preparation.authMode)) {
214
+ if (!isLocalWorkspaceGitEnvironment(this.state.config.environment) || !preparation.directAccess || preparation.directAccess.repositoryUrl !== binding.repositoryUrl)
215
+ throw new Error('git_access_denied');
216
+ }
217
+ else {
218
+ if (preparation.transport?.kind !== 'git-gateway-v2' || !matched?.ok || matched.requestId !== `v2/${binding.workspaceId}`
219
+ || !preparation.gitAccess || Date.parse(preparation.gitAccess.issuedAt) > Date.now()
220
+ || Date.parse(preparation.gitAccess.expiresAt) <= Date.now() + 10_000)
221
+ throw new Error('git_access_denied');
222
+ if (this.cache.size >= 128)
223
+ this.cache.clear();
224
+ this.cache.set(scope, { key, preparation });
225
+ }
193
226
  return preparation;
194
227
  }
195
228
  }
@@ -1,6 +1,8 @@
1
1
  import { request } from 'node:http';
2
2
  // Git invokes this executable directly. Keep standard output exclusively for the credential protocol.
3
- const [socketPath, workspaceId, action] = process.argv.slice(2);
3
+ const [socketPath, workspaceId, third, fourth] = process.argv.slice(2);
4
+ const directoryName = fourth === undefined ? 'basic' : third;
5
+ const action = fourth ?? third;
4
6
  try {
5
7
  if (action === 'get' || action === 'erase') {
6
8
  const fields = {};
@@ -49,7 +51,7 @@ try {
49
51
  }
50
52
  });
51
53
  client.once('error', reject);
52
- client.end(JSON.stringify({ workspaceId, action, protocol: fields.protocol, host: fields.host,
54
+ client.end(JSON.stringify({ workspaceId, directoryName, action, protocol: fields.protocol, host: fields.host,
53
55
  path: fields.path, ...(action === 'erase' ? { password: fields.password } : {}) }));
54
56
  });
55
57
  if (action === 'get') {
@@ -2,7 +2,7 @@ import { constants } from 'node:fs';
2
2
  import { lstat, mkdir, open, realpath, readdir, rename, unlink } from 'node:fs/promises';
3
3
  import { randomUUID } from 'node:crypto';
4
4
  import { join, resolve, sep } from 'node:path';
5
- import { isLocalWorkspaceGitEnvironment, workspaceGitDirectAccessSchema } from '@playcraft/common/cloud-workspace/runtime';
5
+ import { isLocalWorkspaceGitEnvironment, workspaceGitDirectAccessSchema, workspaceDirectoryNameSchema, sameWorkspaceRegistration } from '@playcraft/common/cloud-workspace/runtime';
6
6
  /** Control-owned files only. Same-user processes are not an operating-system isolation boundary. */
7
7
  export class WorkspaceGitDirectStore {
8
8
  state;
@@ -19,10 +19,11 @@ export class WorkspaceGitDirectStore {
19
19
  const c = this.state.config;
20
20
  return JSON.stringify([c.environment, c.userId, c.runtimeId, c.generation]);
21
21
  }
22
- name(id) {
22
+ name(id, directoryName = 'basic') {
23
23
  if (!/^[a-f0-9-]{36}$/.test(id))
24
24
  throw new Error('git_access_denied');
25
- return `${id}.json`;
25
+ workspaceDirectoryNameSchema.parse(directoryName);
26
+ return `${id}${directoryName === 'basic' ? '' : `--${directoryName}`}.json`;
26
27
  }
27
28
  async directory(create, action) {
28
29
  const stateDir = resolve(this.state.config.stateDir);
@@ -76,12 +77,12 @@ export class WorkspaceGitDirectStore {
76
77
  await handle.close();
77
78
  }
78
79
  }
79
- async get(binding) {
80
+ async get(binding, directoryName = 'basic') {
80
81
  return this.directory(false, async (path) => {
81
- const stored = await this.read(join(path, this.name(binding.workspaceId)));
82
+ const stored = await this.read(join(path, this.name(binding.workspaceId, directoryName)));
82
83
  if (!stored)
83
84
  return undefined;
84
- if (stored.identity !== this.identity() || JSON.stringify(stored.binding) !== JSON.stringify(binding)
85
+ if (stored.identity !== this.identity() || !sameWorkspaceRegistration(stored.binding, binding)
85
86
  || !isLocalWorkspaceGitEnvironment(this.state.config.environment))
86
87
  throw new Error('git_access_denied');
87
88
  if (stored.access !== null) {
@@ -92,13 +93,13 @@ export class WorkspaceGitDirectStore {
92
93
  return stored;
93
94
  });
94
95
  }
95
- async put(binding, access, rejectedPassword) {
96
+ async put(binding, access, rejectedPassword, directoryName = 'basic') {
96
97
  if (!isLocalWorkspaceGitEnvironment(this.state.config.environment) || this.state.journal.destroyed())
97
98
  throw new Error('git_access_denied');
98
99
  if (access && (workspaceGitDirectAccessSchema.parse(access).repositoryUrl !== binding.repositoryUrl))
99
100
  throw new Error('git_access_denied');
100
101
  await this.exclusive(() => this.directory(true, async (path) => {
101
- const target = join(path, this.name(binding.workspaceId));
102
+ const target = join(path, this.name(binding.workspaceId, directoryName));
102
103
  // Refuse symlink/hardlink/permissive old files even though rename would replace them.
103
104
  const old = await this.read(target);
104
105
  if (rejectedPassword !== undefined && old?.access?.password !== rejectedPassword)
@@ -127,9 +128,9 @@ export class WorkspaceGitDirectStore {
127
128
  }
128
129
  }));
129
130
  }
130
- async remove(workspaceId) {
131
+ async remove(workspaceId, directoryName = 'basic') {
131
132
  await this.exclusive(() => this.directory(false, async (path) => {
132
- const target = join(path, this.name(workspaceId));
133
+ const target = join(path, this.name(workspaceId, directoryName));
133
134
  await this.read(target);
134
135
  await unlink(target).catch(error => { if (error.code !== 'ENOENT')
135
136
  throw error; });
@@ -138,7 +139,7 @@ export class WorkspaceGitDirectStore {
138
139
  async sweep(destroy = false) {
139
140
  await this.exclusive(() => this.directory(false, async (path) => {
140
141
  for (const name of await readdir(path)) {
141
- if (!/^[a-f0-9-]{36}\.(json|tmp)$/.test(name))
142
+ if (!/^[a-f0-9-]{36}(?:--remix-[a-z0-9][a-z0-9-]{3,63})?\.(json|tmp)$/.test(name))
142
143
  throw new Error('git_credential_file_rejected');
143
144
  const target = join(path, name);
144
145
  if (name.endsWith('.tmp')) {
@@ -2,11 +2,11 @@ import { spawn } from 'node:child_process';
2
2
  import { randomUUID } from 'node:crypto';
3
3
  import { lstat, mkdir, readFile, realpath, rename, rm, writeFile } from 'node:fs/promises';
4
4
  import { join } from 'node:path';
5
- import { isLocalWorkspaceGitAuthMode, isLocalWorkspaceGitEnvironment, matchWorkspaceGitGatewayCloneUrl, sameWorkspaceRegistration, workspacePreparationSchema, workspaceRegistrationSchema, } from '@playcraft/common/cloud-workspace/runtime';
5
+ import { isLocalWorkspaceGitAuthMode, isLocalWorkspaceGitEnvironment, matchWorkspaceGitGatewayCloneUrl, sameWorkspaceRegistration, workspacePreparationSchema, workspaceRegistrationSchema, workspaceDirectoryNameSchema, } from '@playcraft/common/cloud-workspace/runtime';
6
6
  import { WorkspaceContextError } from './context-error.js';
7
7
  import { scaffoldEmptyWorkspace } from './empty-template.js';
8
8
  /** The caller holds the Workspace lock. Only an absent, never-published directory may be prepared. */
9
- export async function prepareWorkspaceCheckout(state, binding, input, leaseFence) {
9
+ export async function prepareWorkspaceCheckout(state, binding, input, leaseFence, directoryName = 'basic', recovery = false) {
10
10
  const preparation = workspacePreparationSchema.safeParse(input);
11
11
  if (!preparation.success)
12
12
  throw new WorkspaceContextError('invalid_workspace_preparation');
@@ -20,15 +20,24 @@ export async function prepareWorkspaceCheckout(state, binding, input, leaseFence
20
20
  || clone.directAccess.repositoryUrl !== binding.repositoryUrl))
21
21
  throw new WorkspaceContextError('workspace_git_gateway_unavailable');
22
22
  const parent = await realpath(state.config.workspaceRoot);
23
- const target = join(parent, binding.workspaceId);
23
+ const workspace = join(parent, binding.workspaceId);
24
+ workspaceDirectoryNameSchema.parse(directoryName);
25
+ const target = join(workspace, directoryName);
24
26
  const receiptDir = join(state.config.stateDir, 'workspace-preparations');
25
27
  await mkdir(receiptDir, { recursive: true, mode: 0o700 });
26
- const receiptPath = join(receiptDir, `${binding.workspaceId}.json`);
27
- const previous = await readReceipt(receiptPath, binding);
28
+ const receiptPath = join(receiptDir, `${binding.workspaceId}${directoryName === 'basic' ? '' : `--${directoryName}`}.json`);
29
+ let previous = await readReceipt(receiptPath, binding);
30
+ if (recovery && previous?.status === 'published')
31
+ previous = undefined;
28
32
  if (previous?.status === 'published')
29
33
  throw new WorkspaceContextError('workspace_recovery_required');
30
34
  if (await exists(target))
31
35
  throw new WorkspaceContextError('workspace_checkout_conflict');
36
+ if (await exists(workspace)) {
37
+ const stat = await lstat(workspace);
38
+ if (!stat.isDirectory() || stat.isSymbolicLink() || await realpath(workspace) !== workspace)
39
+ throw new WorkspaceContextError('invalid_workspace_directory');
40
+ }
32
41
  // An unfinished checkout recorded before the fork instructions were known must be cloned again.
33
42
  const forkMatches = !previous || samePendingFork(previous.binding, binding);
34
43
  if (previous && !forkMatches)
@@ -133,6 +142,12 @@ export async function prepareWorkspaceCheckout(state, binding, input, leaseFence
133
142
  assertCurrent();
134
143
  if (await exists(target))
135
144
  throw new WorkspaceContextError('workspace_checkout_conflict');
145
+ if (await exists(workspace)) {
146
+ const stat = await lstat(workspace);
147
+ if (!stat.isDirectory() || stat.isSymbolicLink() || await realpath(workspace) !== workspace)
148
+ throw new WorkspaceContextError('invalid_workspace_directory');
149
+ }
150
+ await mkdir(workspace, { recursive: true, mode: 0o700 });
136
151
  await rename(staging, target);
137
152
  receipt.status = 'published';
138
153
  await writeReceipt(receiptPath, receipt);
@@ -1,5 +1,5 @@
1
1
  import { execFile } from 'node:child_process';
2
- import { lstat, realpath } from 'node:fs/promises';
2
+ import { lstat, mkdir, realpath } from 'node:fs/promises';
3
3
  import { join, relative } from 'node:path';
4
4
  import { promisify } from 'node:util';
5
5
  import { isLocalWorkspaceGitAuthMode, sameWorkspaceRegistration, workspaceRegistrationSchema, } from '@playcraft/common/cloud-workspace/runtime';
@@ -10,31 +10,39 @@ import { ensureWorkspaceSkills, readWorkspaceSkills } from './skills.js';
10
10
  import { loadWorkspaceSkillArtifact } from './skill-artifact-loader.js';
11
11
  export { WorkspaceContextError } from './context-error.js';
12
12
  const exec = promisify(execFile);
13
- export function revisionScope(state, workspaceId) {
14
- return state.config.workspaceMode === 'multi' ? workspaceId : undefined;
13
+ export function revisionScope(state, workspaceId, directoryName = 'basic') {
14
+ return state.config.workspaceMode === 'multi' ? `${workspaceId}:${directoryName}` : undefined;
15
15
  }
16
- export function workspaceSessions(state, workspaceId) {
16
+ export function workspaceSessions(state, workspaceId, directoryName = 'basic') {
17
17
  return state.journal
18
18
  .listSessions()
19
- .filter((item) => state.config.workspaceMode !== 'multi' || item.workspaceId === workspaceId);
19
+ .filter((item) => state.config.workspaceMode !== 'multi' || (item.workspaceId === workspaceId && (item.directoryName ?? 'basic') === directoryName));
20
20
  }
21
- export async function workspaceRoot(state, workspaceId) {
21
+ export async function workspaceRoot(state, workspaceId, directoryName = 'basic') {
22
22
  if (state.config.workspaceMode !== 'multi')
23
23
  return state.config.workspaceRoot;
24
24
  if (!workspaceId || !state.journal.getWorkspace(workspaceId))
25
25
  throw new WorkspaceContextError('workspace_not_registered');
26
- return checkedDirectory(state, workspaceId);
26
+ if (!/^(basic|remix-[a-z0-9][a-z0-9-]{3,63})$/.test(directoryName))
27
+ throw new WorkspaceContextError('invalid_workspace_directory');
28
+ if (directoryName !== 'basic' && !state.journal.getWorkspace(workspaceId)?.directories?.some(directory => directory.directoryName === directoryName))
29
+ throw new WorkspaceContextError('workspace_directory_not_registered');
30
+ return checkedDirectory(state, workspaceId, directoryName);
27
31
  }
28
- async function checkedDirectory(state, workspaceId) {
32
+ async function checkedDirectory(state, workspaceId, directoryName = 'basic') {
29
33
  if (!/^[0-9a-f-]{36}$/i.test(workspaceId))
30
34
  throw new WorkspaceContextError('invalid_workspace');
31
35
  const parent = await realpath(state.config.workspaceRoot);
32
- const target = join(parent, workspaceId);
36
+ const container = join(parent, workspaceId);
37
+ const containerStat = await lstat(container).catch(() => undefined);
38
+ if (!containerStat?.isDirectory() || containerStat.isSymbolicLink() || relative(parent, await realpath(container)) !== workspaceId)
39
+ throw new WorkspaceContextError('workspace_not_prepared');
40
+ const target = join(container, directoryName);
33
41
  try {
34
42
  const stat = await lstat(target);
35
43
  if (!stat.isDirectory() ||
36
44
  stat.isSymbolicLink() ||
37
- relative(parent, await realpath(target)) !== workspaceId) {
45
+ relative(container, await realpath(target)) !== directoryName) {
38
46
  throw new WorkspaceContextError('invalid_workspace_directory');
39
47
  }
40
48
  const stateDir = await realpath(state.config.stateDir);
@@ -49,6 +57,37 @@ async function checkedDirectory(state, workspaceId) {
49
57
  }
50
58
  }
51
59
  /** Registers a fixed directory; optional first creation is restricted to its trusted binding. */
60
+ export async function workspaceContainerRoot(state, workspaceId) {
61
+ if (state.config.workspaceMode !== 'multi')
62
+ return state.config.workspaceRoot;
63
+ if (!workspaceId || !state.journal.getWorkspace(workspaceId))
64
+ throw new WorkspaceContextError('workspace_not_registered');
65
+ if (!/^[0-9a-f-]{36}$/i.test(workspaceId))
66
+ throw new WorkspaceContextError('invalid_workspace');
67
+ const parent = await realpath(state.config.workspaceRoot);
68
+ const container = join(parent, workspaceId);
69
+ const stat = await lstat(container).catch(() => undefined);
70
+ if (!stat?.isDirectory() || stat.isSymbolicLink() || relative(parent, await realpath(container)) !== workspaceId)
71
+ throw new WorkspaceContextError('workspace_not_prepared');
72
+ const stateDir = await realpath(state.config.stateDir);
73
+ if (stateDir === container || stateDir.startsWith(`${container}/`))
74
+ throw new WorkspaceContextError('invalid_workspace_directory');
75
+ return container;
76
+ }
77
+ export async function sessionWorkspaceRoot(state, workspaceId, directoryName = 'basic') {
78
+ return directoryName === 'root' ? workspaceContainerRoot(state, workspaceId) : workspaceRoot(state, workspaceId, directoryName);
79
+ }
80
+ export async function workspaceContextFromRoot(state, cwd) {
81
+ const parent = await realpath(state.config.workspaceRoot);
82
+ const parts = relative(parent, cwd).split('/');
83
+ if (parts.length !== 1 && parts.length !== 2)
84
+ throw new WorkspaceContextError('workspace_not_registered');
85
+ const workspaceId = parts[0];
86
+ const directoryName = parts.length === 1 ? 'root' : parts[1];
87
+ if (await sessionWorkspaceRoot(state, workspaceId, directoryName) !== cwd)
88
+ throw new WorkspaceContextError('workspace_not_registered');
89
+ return { workspaceId, directoryName };
90
+ }
52
91
  export async function registerWorkspace(state, input, leaseFence, preparation) {
53
92
  if (state.config.workspaceMode !== 'multi')
54
93
  throw new WorkspaceContextError('multi_workspace_unavailable');
@@ -66,6 +105,18 @@ export async function registerWorkspace(state, input, leaseFence, preparation) {
66
105
  const existing = state.journal.getWorkspace(binding.workspaceId);
67
106
  if (existing && !sameWorkspaceRegistration(existing, binding))
68
107
  throw new WorkspaceContextError('workspace_binding_conflict');
108
+ const directories = binding.directories ?? existing?.directories;
109
+ if (directories?.some(directory => directory.directoryName === 'basic' && directory.status === 'reclaimed')) {
110
+ // Registration publishes trusted identity. Only an explicit basic ensure restores the checkout.
111
+ const parent = await realpath(state.config.workspaceRoot);
112
+ const container = join(parent, binding.workspaceId);
113
+ await mkdir(container, { mode: 0o2770 }).catch(error => { if (error.code !== 'EEXIST')
114
+ throw error; });
115
+ const registered = { ...binding, directories };
116
+ await state.journal.putWorkspace(registered);
117
+ await workspaceContainerRoot(state, binding.workspaceId);
118
+ return registered;
119
+ }
69
120
  let root;
70
121
  try {
71
122
  root = await checkedDirectory(state, binding.workspaceId);
@@ -129,13 +180,13 @@ export async function registerWorkspace(state, input, leaseFence, preparation) {
129
180
  catch {
130
181
  throw new WorkspaceContextError('workspace_skills_unavailable');
131
182
  }
132
- await state.journal.putWorkspace(binding);
183
+ await state.journal.putWorkspace({ ...binding, directories });
133
184
  try {
134
185
  await readWorkspaceSkills(root, join(state.config.stateDir, 'skill-artifacts'), entry => loadWorkspaceSkillArtifact(state, binding.workspaceId, entry));
135
186
  }
136
187
  catch { /* Skills reads and native turns diagnose missing artifacts; files and source repair remain available. */ }
137
188
  // Runs in the background; every registration rechecks it, so reopening retries a failed install.
138
- void scheduleDependencyInstall(state, binding.workspaceId, root);
189
+ void scheduleDependencyInstall(state, revisionScope(state, binding.workspaceId), root);
139
190
  return binding;
140
191
  });
141
192
  }