@playcraft/cli 0.1.57-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.
- package/dist/commands/workspace.js +43 -0
- package/dist/utils/agent-generation-provenance.js +1 -1
- package/dist/workspace/messages.js +10 -0
- package/dist/workspace-runtime/batch/deliver.js +140 -80
- package/dist/workspace-runtime/batch/freeze.js +18 -6
- package/dist/workspace-runtime/batch/generate.js +68 -64
- package/dist/workspace-runtime/batch/policy.js +71 -22
- package/dist/workspace-runtime/batch/prepare.js +108 -93
- package/dist/workspace-runtime/batch/records.js +4 -0
- package/dist/workspace-runtime/batch/routes.js +10 -2
- package/dist/workspace-runtime/batch/source-candidate.js +2 -0
- package/dist/workspace-runtime/batch/source-capture.js +1 -0
- package/dist/workspace-runtime/batch/source-failure.js +2 -0
- package/dist/workspace-runtime/batch/source-publish.js +3 -0
- package/dist/workspace-runtime/batch/writer-proof.js +37 -0
- package/dist/workspace-runtime/codex/app-server.js +278 -20
- package/dist/workspace-runtime/codex/config-toml.js +4 -0
- package/dist/workspace-runtime/codex/delivery-tools.js +1 -1
- package/dist/workspace-runtime/codex/generation-provenance-broker.js +17 -8
- package/dist/workspace-runtime/codex/loopback.js +2 -0
- package/dist/workspace-runtime/codex/model-broker.js +7 -4
- package/dist/workspace-runtime/persistence/journal.js +34 -0
- package/dist/workspace-runtime/persistence/sqlite-worker.js +68 -6
- package/dist/workspace-runtime/previews/output.js +2 -0
- package/dist/workspace-runtime/previews/publish.js +20 -5
- package/dist/workspace-runtime/previews/runtime-preview.js +4 -0
- package/dist/workspace-runtime/processes/managed-writes.js +47 -5
- package/dist/workspace-runtime/serve.js +13 -3
- package/dist/workspace-runtime/server/accept-operation.js +22 -2
- package/dist/workspace-runtime/server/directory-access.js +414 -0
- package/dist/workspace-runtime/server/dispatch.js +74 -29
- package/dist/workspace-runtime/server/endpoints.js +1 -0
- package/dist/workspace-runtime/server/http.js +80 -10
- package/dist/workspace-runtime/server/operation-record.js +2 -0
- package/dist/workspace-runtime/server/terminal-host.js +124 -8
- package/dist/workspace-runtime/server/workspace-storage.js +193 -0
- package/dist/workspace-runtime/server/workspace-transfer.js +3 -1
- package/dist/workspace-runtime/workspaces/append-only-log.js +3 -0
- package/dist/workspace-runtime/workspaces/artifact-events.js +5 -2
- package/dist/workspace-runtime/workspaces/artifact-files.js +14 -8
- package/dist/workspace-runtime/workspaces/artifact-receipts.js +7 -25
- package/dist/workspace-runtime/workspaces/artifacts.js +4 -1
- package/dist/workspace-runtime/workspaces/asset-library-import.js +9 -4
- package/dist/workspace-runtime/workspaces/asset-publication-access.js +7 -9
- package/dist/workspace-runtime/workspaces/asset-transfer-publish.js +3 -0
- package/dist/workspace-runtime/workspaces/asset-transfer-storage.js +17 -2
- package/dist/workspace-runtime/workspaces/attachments.js +9 -0
- package/dist/workspace-runtime/workspaces/batch-commit.js +5 -3
- package/dist/workspace-runtime/workspaces/binary-write.js +12 -7
- package/dist/workspace-runtime/workspaces/cli-context.js +2 -0
- package/dist/workspace-runtime/workspaces/cli-credentials.js +1 -0
- package/dist/workspace-runtime/workspaces/delivery-build.js +30 -11
- package/dist/workspace-runtime/workspaces/delivery-store.js +2 -0
- package/dist/workspace-runtime/workspaces/dependencies.js +90 -70
- package/dist/workspace-runtime/workspaces/developer-command.js +12 -1
- package/dist/workspace-runtime/workspaces/directory-access.js +13 -18
- package/dist/workspace-runtime/workspaces/directory-attachments.js +8 -0
- package/dist/workspace-runtime/workspaces/directory-create.js +18 -5
- package/dist/workspace-runtime/workspaces/directory-maintenance.js +46 -35
- package/dist/workspace-runtime/workspaces/directory-mutations.js +8 -3
- package/dist/workspace-runtime/workspaces/files.js +4 -0
- package/dist/workspace-runtime/workspaces/git-credential-broker.js +3 -0
- package/dist/workspace-runtime/workspaces/git-direct-store.js +2 -0
- package/dist/workspace-runtime/workspaces/json5-edit.js +36 -0
- package/dist/workspace-runtime/workspaces/library-attachments.js +4 -0
- package/dist/workspace-runtime/workspaces/parameter-agent-protocol.js +2 -0
- package/dist/workspace-runtime/workspaces/parameter-agent-store.js +2 -0
- package/dist/workspace-runtime/workspaces/parameter-agent.js +40 -11
- package/dist/workspace-runtime/workspaces/parameter-assets.js +1 -0
- package/dist/workspace-runtime/workspaces/parameter-initialization-skill-transaction.js +141 -0
- package/dist/workspace-runtime/workspaces/parameter-initialization-skill.js +329 -0
- package/dist/workspace-runtime/workspaces/parameter-initialization.js +63 -0
- package/dist/workspace-runtime/workspaces/parameter-production.js +124 -0
- package/dist/workspace-runtime/workspaces/parameters.js +95 -3
- package/dist/workspace-runtime/workspaces/platform-export.js +5 -0
- package/dist/workspace-runtime/workspaces/prepare.js +4 -0
- package/dist/workspace-runtime/workspaces/registry.js +55 -3
- package/dist/workspace-runtime/workspaces/remix-references.js +5 -0
- package/dist/workspace-runtime/workspaces/shared-write.js +187 -0
- package/dist/workspace-runtime/workspaces/skills.js +14 -15
- package/dist/workspace-runtime/workspaces/storage-snapshot.js +235 -0
- package/dist/workspace-runtime/workspaces/transfer.js +4 -1
- package/dist/workspace-runtime/workspaces/workspace-shares.js +235 -0
- package/dist/workspace-runtime/workspaces/worktree-attachments.js +2 -0
- package/dist/workspace-runtime/workspaces/worktree-checkout.js +11 -6
- package/package.json +5 -4
- package/project-template/.claude/skills/playcraft-runtime-parameters/SKILL.md +32 -0
- package/project-template/.claude/skills/playcraft-runtime-parameters/references/platform-protocol.md +21 -0
- package/project-template/.claude/skills/playcraft-runtime-parameters/references/preview-integration.md +57 -0
|
@@ -100,6 +100,11 @@ export async function startPlatformExport(state, input) {
|
|
|
100
100
|
clientRequestId: `${input.clientRequestId.slice(0, 80)}:export-save`,
|
|
101
101
|
payload: {},
|
|
102
102
|
});
|
|
103
|
+
if (['queued', 'dispatched', 'accepted'].includes(saved.status))
|
|
104
|
+
return {
|
|
105
|
+
status: 'pending', operationId: saved.operationId, stage: 'workspace.save', builds: [],
|
|
106
|
+
nextAction: 'Finish the current turn so the queued save can run. Do not poll or wait for it within this turn. In a later turn, call the delivery tool again to continue the export.',
|
|
107
|
+
};
|
|
103
108
|
if (saved.status !== 'completed')
|
|
104
109
|
throw new PlatformExportError('workspace_save_failed', saved.error);
|
|
105
110
|
const status = await readWorkspaceGitStatus(target.root);
|
|
@@ -38,6 +38,7 @@ export async function prepareWorkspaceCheckout(state, binding, input, leaseFence
|
|
|
38
38
|
workspaceDirectoryNameSchema.parse(directoryName);
|
|
39
39
|
const target = join(workspace, directoryName);
|
|
40
40
|
const receiptDir = join(state.config.stateDir, 'workspace-preparations');
|
|
41
|
+
// private
|
|
41
42
|
await mkdir(receiptDir, { recursive: true, mode: 0o700 });
|
|
42
43
|
const receiptPath = join(receiptDir, `${binding.workspaceId}${directoryName === 'basic' ? '' : `--${directoryName}`}.json`);
|
|
43
44
|
let previous = await readReceipt(receiptPath, binding);
|
|
@@ -73,6 +74,7 @@ export async function prepareWorkspaceCheckout(state, binding, input, leaseFence
|
|
|
73
74
|
// Persist ownership before creating/removing a staging directory, so a crash can be retried.
|
|
74
75
|
await writeReceipt(receiptPath, receipt);
|
|
75
76
|
await rm(staging, { recursive: true, force: true });
|
|
77
|
+
// private
|
|
76
78
|
await mkdir(staging, { mode: 0o700 });
|
|
77
79
|
const access = clone?.gitAccess;
|
|
78
80
|
const credential = clone?.directAccess ?? access;
|
|
@@ -163,6 +165,7 @@ export async function prepareWorkspaceCheckout(state, binding, input, leaseFence
|
|
|
163
165
|
if (!stat.isDirectory() || stat.isSymbolicLink() || await realpath(workspace) !== workspace)
|
|
164
166
|
throw new WorkspaceContextError('invalid_workspace_directory');
|
|
165
167
|
}
|
|
168
|
+
// shared
|
|
166
169
|
await mkdir(workspace, { recursive: true, mode: 0o700 });
|
|
167
170
|
await rename(staging, target);
|
|
168
171
|
// The terminal and Codex run as the development user, so a checkout only the control user can
|
|
@@ -234,6 +237,7 @@ async function readReceipt(path, binding) {
|
|
|
234
237
|
}
|
|
235
238
|
async function writeReceipt(path, value) {
|
|
236
239
|
const temp = `${path}.tmp`;
|
|
240
|
+
// private
|
|
237
241
|
await writeFile(temp, `${JSON.stringify(value)}\n`, { mode: 0o600 });
|
|
238
242
|
await rename(temp, path);
|
|
239
243
|
}
|
|
@@ -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';
|
|
@@ -11,6 +11,7 @@ import { loadWorkspaceSkillArtifact, workspacePlatformSkillAccess } from './skil
|
|
|
11
11
|
import { writeWorkspaceCliContext } from './cli-context.js';
|
|
12
12
|
import { attachWorkspaceRemote } from './git.js';
|
|
13
13
|
import { ensureWorkspaceArtifactInstructions } from './instructions.js';
|
|
14
|
+
import { repairWorkspaceShares } from './workspace-shares.js';
|
|
14
15
|
export { WorkspaceContextError } from './context-error.js';
|
|
15
16
|
const exec = promisify(execFile);
|
|
16
17
|
export function revisionScope(state, workspaceId, directoryName = 'basic') {
|
|
@@ -128,6 +129,7 @@ export async function registerWorkspace(state, input, leaseFence, preparation, r
|
|
|
128
129
|
throw new WorkspaceContextError('invalid_workspace_registration');
|
|
129
130
|
// Production restores its assigned result from durable bytes, without touching basic.
|
|
130
131
|
const parent = await realpath(state.config.workspaceRoot);
|
|
132
|
+
// shared
|
|
131
133
|
await mkdir(join(parent, binding.workspaceId), { mode: 0o2770 }).catch(error => {
|
|
132
134
|
if (error.code !== 'EEXIST')
|
|
133
135
|
throw error;
|
|
@@ -141,6 +143,7 @@ export async function registerWorkspace(state, input, leaseFence, preparation, r
|
|
|
141
143
|
// Registration publishes trusted identity. Only an explicit basic ensure restores the checkout.
|
|
142
144
|
const parent = await realpath(state.config.workspaceRoot);
|
|
143
145
|
const container = join(parent, binding.workspaceId);
|
|
146
|
+
// shared
|
|
144
147
|
await mkdir(container, { mode: 0o2770 }).catch(error => { if (error.code !== 'EEXIST')
|
|
145
148
|
throw error; });
|
|
146
149
|
const registered = { ...binding, directories };
|
|
@@ -160,7 +163,7 @@ export async function registerWorkspace(state, input, leaseFence, preparation, r
|
|
|
160
163
|
await prepareWorkspaceCheckout(state, binding, preparation, leaseFence);
|
|
161
164
|
root = await checkedDirectory(state, binding.workspaceId);
|
|
162
165
|
}
|
|
163
|
-
const
|
|
166
|
+
const gitIn = (cwd) => async (...args) => (await exec('git', ['-C', cwd, ...args], {
|
|
164
167
|
timeout: 5000,
|
|
165
168
|
maxBuffer: 64 * 1024,
|
|
166
169
|
env: {
|
|
@@ -170,11 +173,19 @@ export async function registerWorkspace(state, input, leaseFence, preparation, r
|
|
|
170
173
|
GIT_CONFIG_GLOBAL: '/dev/null',
|
|
171
174
|
},
|
|
172
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)) === '';
|
|
173
181
|
// Unfinished if it failed part way: the marker outlives the journal entry written below.
|
|
174
|
-
if (binding.targetKind === 'main' && !preservedLocalBasic
|
|
182
|
+
if (binding.targetKind === 'main' && !preservedLocalBasic
|
|
183
|
+
&& (needsRemote || remoteUpgrade || await lstat(attachMarker).then(() => true, () => false))) {
|
|
175
184
|
if (preparation?.mode !== 'clone' || !state.gitCredentials)
|
|
176
185
|
throw new WorkspaceContextError('workspace_git_gateway_unavailable');
|
|
186
|
+
// private
|
|
177
187
|
await mkdir(join(state.config.stateDir, 'workspace-remote-attach'), { recursive: true, mode: 0o700 });
|
|
188
|
+
// private
|
|
178
189
|
await writeFile(attachMarker, JSON.stringify({ repositoryUrl: binding.repositoryUrl, branchName: binding.branchName }), { mode: 0o600 });
|
|
179
190
|
// The credential helper only serves a workspace the journal knows to be bound.
|
|
180
191
|
await state.journal.putWorkspace({ ...binding, directories });
|
|
@@ -213,6 +224,10 @@ export async function registerWorkspace(state, input, leaseFence, preparation, r
|
|
|
213
224
|
throw error;
|
|
214
225
|
throw new WorkspaceContextError('workspace_not_prepared');
|
|
215
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);
|
|
216
231
|
if (state.journal.destroyed())
|
|
217
232
|
throw new WorkspaceContextError('runtime_destroyed');
|
|
218
233
|
if (leaseFence < state.acceptedLeaseFence)
|
|
@@ -240,6 +255,12 @@ export async function registerWorkspace(state, input, leaseFence, preparation, r
|
|
|
240
255
|
await writeWorkspaceCliContext(root, binding.workspaceId);
|
|
241
256
|
await ensureWorkspaceArtifactInstructions(root);
|
|
242
257
|
await state.journal.putWorkspace({ ...binding, directories });
|
|
258
|
+
// Registration is the one point every reused container reaches: the root repair is skipped
|
|
259
|
+
// when the binding did not change, so the shared subtrees repair themselves here (idempotent).
|
|
260
|
+
// Keep registration latency independent of workspace size; build and scan paths also repair.
|
|
261
|
+
void repairWorkspaceShares(state, root).catch(error => {
|
|
262
|
+
console.warn('[workspace-runtime] share repair failed:', error instanceof Error ? error.message : error);
|
|
263
|
+
});
|
|
243
264
|
try {
|
|
244
265
|
await readWorkspaceSkills(root, join(state.config.stateDir, 'skill-artifacts'), entry => loadWorkspaceSkillArtifact(state, binding.workspaceId, entry));
|
|
245
266
|
}
|
|
@@ -249,3 +270,34 @@ export async function registerWorkspace(state, input, leaseFence, preparation, r
|
|
|
249
270
|
return binding;
|
|
250
271
|
});
|
|
251
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
|
+
}
|
|
@@ -24,6 +24,7 @@ async function safeDirectory(root, relative = '', create = false) {
|
|
|
24
24
|
let current = root;
|
|
25
25
|
for (const segment of relative.split('/').filter(Boolean)) {
|
|
26
26
|
current = join(current, segment);
|
|
27
|
+
// shared
|
|
27
28
|
if (create)
|
|
28
29
|
await mkdir(current, { mode: 0o755 }).catch((error) => {
|
|
29
30
|
if (error.code !== 'EEXIST')
|
|
@@ -255,9 +256,12 @@ async function importReference(root, directoryName, input, assertCurrent) {
|
|
|
255
256
|
for (const file of files) {
|
|
256
257
|
assertCurrent();
|
|
257
258
|
await safeDirectory(stage, dirname(file.targetPath) === '.' ? '' : dirname(file.targetPath), true);
|
|
259
|
+
// shared
|
|
258
260
|
await writeFile(join(stage, file.targetPath), file.bytes, { flag: 'wx', mode: 0o644 });
|
|
259
261
|
}
|
|
262
|
+
// shared
|
|
260
263
|
await writeFile(join(stage, RECEIPT), JSON.stringify(record), { flag: 'wx', mode: 0o644 });
|
|
264
|
+
// shared
|
|
261
265
|
await writeFile(join(stage, ATTRIBUTES), SNAPSHOT_ATTRIBUTES, { flag: 'wx', mode: 0o644 });
|
|
262
266
|
await chmod(stage, 0o755);
|
|
263
267
|
assertCurrent();
|
|
@@ -276,6 +280,7 @@ async function importReference(root, directoryName, input, assertCurrent) {
|
|
|
276
280
|
await safeDirectory(rootReal, '.playcraft', true);
|
|
277
281
|
const temporary = join(rootReal, '.playcraft', `.remix-references-${randomUUID()}.tmp`);
|
|
278
282
|
try {
|
|
283
|
+
// shared
|
|
279
284
|
await writeFile(temporary, serialized, { flag: 'wx', mode: 0o644 });
|
|
280
285
|
assertCurrent();
|
|
281
286
|
await safeDirectory(rootReal, '.playcraft');
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import { constants } from 'node:fs';
|
|
3
|
+
import { chmod, chown, lstat, mkdir, open, rename, rm, } from 'node:fs/promises';
|
|
4
|
+
import { basename, dirname, join, relative, resolve, sep } from 'node:path';
|
|
5
|
+
/**
|
|
6
|
+
* Cloud workspaces are shared by two Linux identities: the controller (`playcraft-control`) and the
|
|
7
|
+
* development user that runs agents and builds (`playcraft-dev`). Every inode either of them must
|
|
8
|
+
* read or write has to carry the development group plus group read/write (and setgid on
|
|
9
|
+
* directories, so new entries inherit that group).
|
|
10
|
+
*
|
|
11
|
+
* Only the owner of an inode can chmod/chown it, so a call that finds a foreign owner leaves the
|
|
12
|
+
* entry alone instead of failing the operation; `repairWorkspaceShares` re-runs the same shaping as
|
|
13
|
+
* the owning identity.
|
|
14
|
+
*/
|
|
15
|
+
export const SHARED_DIRECTORY_MODE = 0o2770;
|
|
16
|
+
export const SHARED_FILE_MODE = 0o660;
|
|
17
|
+
const PERMISSION_BITS = 0o7777;
|
|
18
|
+
const OWNER_ONLY_ERRORS = new Set(['EPERM', 'EACCES']);
|
|
19
|
+
/** The development group, read from the workspace container that owns the tree. */
|
|
20
|
+
export async function workspaceGroup(workspaceRoot) {
|
|
21
|
+
const stat = await lstat(workspaceRoot);
|
|
22
|
+
if (!stat.isDirectory() || stat.isSymbolicLink()) {
|
|
23
|
+
throw new Error('invalid_workspace_directory');
|
|
24
|
+
}
|
|
25
|
+
return stat.gid;
|
|
26
|
+
}
|
|
27
|
+
function currentUid() {
|
|
28
|
+
return typeof process.getuid === 'function' ? process.getuid() : undefined;
|
|
29
|
+
}
|
|
30
|
+
/** Another identity owns this inode; only its owner can re-shape it. */
|
|
31
|
+
function canShape(owner) {
|
|
32
|
+
const current = currentUid();
|
|
33
|
+
return current === undefined || current === 0 || owner === current;
|
|
34
|
+
}
|
|
35
|
+
function isOwnerOnly(error) {
|
|
36
|
+
const code = error?.code;
|
|
37
|
+
return typeof code === 'string' && OWNER_ONLY_ERRORS.has(code);
|
|
38
|
+
}
|
|
39
|
+
async function shapePath(path, groupId, mode, union = false) {
|
|
40
|
+
const stat = await lstat(path);
|
|
41
|
+
if (stat.isSymbolicLink())
|
|
42
|
+
throw new Error('invalid_shared_path');
|
|
43
|
+
const desired = union ? (stat.mode & 0o777) | mode : mode;
|
|
44
|
+
if (stat.gid === groupId && (stat.mode & PERMISSION_BITS) === desired)
|
|
45
|
+
return false;
|
|
46
|
+
if (!canShape(stat.uid))
|
|
47
|
+
return false;
|
|
48
|
+
try {
|
|
49
|
+
if (stat.gid !== groupId)
|
|
50
|
+
await chown(path, stat.uid, groupId);
|
|
51
|
+
await chmod(path, desired);
|
|
52
|
+
return true;
|
|
53
|
+
}
|
|
54
|
+
catch (error) {
|
|
55
|
+
if (isOwnerOnly(error))
|
|
56
|
+
return false;
|
|
57
|
+
throw error;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
async function shapeHandle(handle, groupId, mode) {
|
|
61
|
+
const stat = await handle.stat();
|
|
62
|
+
if (stat.gid === groupId && (stat.mode & PERMISSION_BITS) === mode)
|
|
63
|
+
return false;
|
|
64
|
+
if (!canShape(stat.uid))
|
|
65
|
+
return false;
|
|
66
|
+
try {
|
|
67
|
+
if (stat.gid !== groupId)
|
|
68
|
+
await handle.chown(stat.uid, groupId);
|
|
69
|
+
await handle.chmod(mode);
|
|
70
|
+
return true;
|
|
71
|
+
}
|
|
72
|
+
catch (error) {
|
|
73
|
+
if (isOwnerOnly(error))
|
|
74
|
+
return false;
|
|
75
|
+
throw error;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
/** True when `target` is the root itself or lives below it. */
|
|
79
|
+
export function isInsideRoot(root, target) {
|
|
80
|
+
const base = resolve(root), path = resolve(target);
|
|
81
|
+
return path === base || path.startsWith(`${base}${sep}`);
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* Chain of directories below `root` down to `target`; empty when `target` is the root itself or
|
|
85
|
+
* outside it. The root is shared by checkout preparation, never as a side effect of a write.
|
|
86
|
+
*/
|
|
87
|
+
function directoryChain(root, target) {
|
|
88
|
+
if (!isInsideRoot(root, target))
|
|
89
|
+
return [];
|
|
90
|
+
const parts = relative(resolve(root), resolve(target)).split(sep).filter(Boolean);
|
|
91
|
+
return parts.map((_, index) => resolve(root, ...parts.slice(0, index + 1)));
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* Shares one existing path with the development group. Returns whether the mode changed; an entry
|
|
95
|
+
* owned by the other identity is reported as unchanged because this process cannot re-shape it.
|
|
96
|
+
*/
|
|
97
|
+
export async function shareIfNeeded(workspaceRoot, path) {
|
|
98
|
+
const groupId = await workspaceGroup(workspaceRoot);
|
|
99
|
+
const stat = await lstat(path);
|
|
100
|
+
if (stat.isSymbolicLink())
|
|
101
|
+
throw new Error('invalid_shared_path');
|
|
102
|
+
return shapePath(path, groupId, stat.isDirectory() ? SHARED_DIRECTORY_MODE : SHARED_FILE_MODE);
|
|
103
|
+
}
|
|
104
|
+
/**
|
|
105
|
+
* Shapes an existing path with an explicit group and mode. Used by the legacy helpers that receive
|
|
106
|
+
* the group from their caller (scratch trees and checkout preparation) so there is one implementation.
|
|
107
|
+
*/
|
|
108
|
+
export async function sharePathWithGroup(path, groupId, mode, union = false) {
|
|
109
|
+
return shapePath(path, groupId, mode, union);
|
|
110
|
+
}
|
|
111
|
+
/** Shapes an already open file. Cross-owner failures are tolerated like `shareIfNeeded`. */
|
|
112
|
+
export async function shareHandleWithGroup(handle, groupId, mode) {
|
|
113
|
+
return shapeHandle(handle, groupId, mode);
|
|
114
|
+
}
|
|
115
|
+
/**
|
|
116
|
+
* Creates a directory tree both identities can use. Missing ancestors are created with the shared
|
|
117
|
+
* mode and every segment below `workspaceRoot` is re-checked, so a pre-existing private parent is
|
|
118
|
+
* repaired when this process owns it.
|
|
119
|
+
*/
|
|
120
|
+
export async function ensureSharedDirectory(workspaceRoot, path) {
|
|
121
|
+
const root = resolve(workspaceRoot), target = resolve(path);
|
|
122
|
+
const groupId = await workspaceGroup(root);
|
|
123
|
+
const chain = directoryChain(root, target);
|
|
124
|
+
const shapes = isInsideRoot(root, target) ? chain : [target];
|
|
125
|
+
// Reject redirected ancestors before mkdir follows them.
|
|
126
|
+
for (const directory of chain) {
|
|
127
|
+
const stat = await lstat(directory).catch((error) => {
|
|
128
|
+
if (error.code === 'ENOENT')
|
|
129
|
+
return undefined;
|
|
130
|
+
throw error;
|
|
131
|
+
});
|
|
132
|
+
if (stat && (!stat.isDirectory() || stat.isSymbolicLink())) {
|
|
133
|
+
throw new Error('invalid_shared_directory');
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
await mkdir(target, { recursive: true, mode: SHARED_DIRECTORY_MODE });
|
|
137
|
+
for (const directory of shapes) {
|
|
138
|
+
const stat = await lstat(directory);
|
|
139
|
+
if (!stat.isDirectory() || stat.isSymbolicLink()) {
|
|
140
|
+
throw new Error('invalid_shared_directory');
|
|
141
|
+
}
|
|
142
|
+
await shapePath(directory, groupId, SHARED_DIRECTORY_MODE);
|
|
143
|
+
}
|
|
144
|
+
return target;
|
|
145
|
+
}
|
|
146
|
+
/**
|
|
147
|
+
* Opens (optionally creating) a file the development group can read and write. The handle keeps
|
|
148
|
+
* the descriptor, so a concurrent `chmod` cannot redirect what is written.
|
|
149
|
+
*/
|
|
150
|
+
export async function openSharedFile(workspaceRoot, path, flags) {
|
|
151
|
+
const groupId = await workspaceGroup(workspaceRoot);
|
|
152
|
+
const handle = await open(path, flags, SHARED_FILE_MODE);
|
|
153
|
+
try {
|
|
154
|
+
const stat = await handle.stat();
|
|
155
|
+
if (!stat.isFile())
|
|
156
|
+
throw new Error('invalid_shared_file');
|
|
157
|
+
await shapeHandle(handle, groupId, SHARED_FILE_MODE);
|
|
158
|
+
return handle;
|
|
159
|
+
}
|
|
160
|
+
catch (error) {
|
|
161
|
+
await handle.close().catch(() => undefined);
|
|
162
|
+
throw error;
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
/** Writes through a temporary file in the same directory, then renames, so readers never see a partial file. */
|
|
166
|
+
export async function writeSharedFile(workspaceRoot, path, data) {
|
|
167
|
+
const root = resolve(workspaceRoot), target = resolve(path);
|
|
168
|
+
if (isInsideRoot(root, target))
|
|
169
|
+
await ensureSharedDirectory(root, dirname(target));
|
|
170
|
+
const groupId = await workspaceGroup(root);
|
|
171
|
+
const temporary = join(dirname(target), `.${basename(target)}.${randomUUID()}.tmp`);
|
|
172
|
+
try {
|
|
173
|
+
const handle = await open(temporary, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, SHARED_FILE_MODE);
|
|
174
|
+
try {
|
|
175
|
+
await shapeHandle(handle, groupId, SHARED_FILE_MODE);
|
|
176
|
+
await handle.writeFile(data);
|
|
177
|
+
await handle.sync();
|
|
178
|
+
}
|
|
179
|
+
finally {
|
|
180
|
+
await handle.close();
|
|
181
|
+
}
|
|
182
|
+
await rename(temporary, target);
|
|
183
|
+
}
|
|
184
|
+
finally {
|
|
185
|
+
await rm(temporary, { force: true });
|
|
186
|
+
}
|
|
187
|
+
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { createHash
|
|
2
|
-
import { cp, lstat,
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { cp, lstat, readFile, readdir, rm, writeFile } from 'node:fs/promises';
|
|
3
3
|
import { basename, dirname, join } from 'node:path';
|
|
4
4
|
import { load } from 'js-yaml';
|
|
5
5
|
import { canonicalProjectSkillsJson, createCanonicalBundleArchive, effectiveProjectSkills, extractBundleArchive, inspectProjectSkills, readProjectSkillSource, readProjectSkillsLockFile, sourceTreeDigest, validateProjectSkillsSnapshot, } from '@playcraft/common/project-skills';
|
|
@@ -9,6 +9,7 @@ import { selectPlatformSkillSnapshot } from '../../project-skills/builtin-import
|
|
|
9
9
|
import { EMPTY_WORKSPACE_REPOSITORY_SKILLS, EMPTY_WORKSPACE_PLATFORM_SKILLS } from './empty-template.js';
|
|
10
10
|
import { excludeFromGit } from './dependencies.js';
|
|
11
11
|
import { selectOptionalPlatformSkills } from './optional-platform-skills.js';
|
|
12
|
+
import { ensureSharedDirectory, writeSharedFile } from './shared-write.js';
|
|
12
13
|
/** Runtime availability is determined by the locked metadata, independent of unsynced source edits. */
|
|
13
14
|
export async function readWorkspaceSkillSnapshot(root) {
|
|
14
15
|
const management = await lstat(join(root, '.playcraft'));
|
|
@@ -42,7 +43,7 @@ export async function ensureWorkspaceSkills(root, cache, access, options = {}) {
|
|
|
42
43
|
continue;
|
|
43
44
|
await safeDirectory(root, '.agents/skills');
|
|
44
45
|
const source = `.agents/skills/${name}`;
|
|
45
|
-
await
|
|
46
|
+
await ensureSharedDirectory(root, join(root, source));
|
|
46
47
|
added.add(source);
|
|
47
48
|
const template = join(resolveCliPackageRoot(), 'project-template/.claude/skills', name);
|
|
48
49
|
for (const child of await readdir(template))
|
|
@@ -61,12 +62,16 @@ async function safeDirectory(root, path) {
|
|
|
61
62
|
let current = root;
|
|
62
63
|
for (const segment of path.split('/')) {
|
|
63
64
|
current = join(current, segment);
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
65
|
+
// Validate every existing segment before creating anything, so a link never redirects the tree.
|
|
66
|
+
const stat = await lstat(current).catch((error) => {
|
|
67
|
+
if (error.code === 'ENOENT')
|
|
68
|
+
return undefined;
|
|
69
|
+
throw error;
|
|
70
|
+
});
|
|
71
|
+
if (stat && (!stat.isDirectory() || stat.isSymbolicLink()))
|
|
68
72
|
throw new Error('workspace_skills_invalid');
|
|
69
73
|
}
|
|
74
|
+
await ensureSharedDirectory(root, current);
|
|
70
75
|
return current;
|
|
71
76
|
}
|
|
72
77
|
/** Cache misses may reconstruct only the exact locked repository bytes, never a newer source. */
|
|
@@ -111,14 +116,7 @@ async function lockedFiles(root, cache, entry, view, loadArtifact) {
|
|
|
111
116
|
throw new Error('workspace_skills_invalid_bundle');
|
|
112
117
|
if (entry.revision.kind === 'source' && sourceTreeDigest(extracted.files.map(file => ({ path: file.path, content: file.data }))) !== entry.sourceTreeHash)
|
|
113
118
|
throw new Error('workspace_skills_digest_mismatch');
|
|
114
|
-
|
|
115
|
-
try {
|
|
116
|
-
await writeFile(temporary, archive, { flag: 'wx', mode: 0o644 });
|
|
117
|
-
await rename(temporary, saved);
|
|
118
|
-
}
|
|
119
|
-
finally {
|
|
120
|
-
await rm(temporary, { force: true });
|
|
121
|
-
}
|
|
119
|
+
await writeSharedFile(root, saved, archive);
|
|
122
120
|
return extracted.files;
|
|
123
121
|
}
|
|
124
122
|
function metadata(entryKey, content) {
|
|
@@ -155,6 +153,7 @@ export async function freezeWorkspaceSkills(root, cache, loadArtifact) {
|
|
|
155
153
|
const relative = join(entry.entryKey, file.path);
|
|
156
154
|
expectedFiles.add(relative);
|
|
157
155
|
await safeDirectory(generation, dirname(relative));
|
|
156
|
+
// shared
|
|
158
157
|
await writeFile(target, file.data, { flag: 'wx', mode: 0o444 }).catch(async (error) => {
|
|
159
158
|
if (error.code !== 'EEXIST')
|
|
160
159
|
throw error;
|