@playcraft/cli 0.1.56-beta → 0.1.58-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/assets/client.js +6 -4
- package/dist/commands/tools-3d.js +15 -0
- package/dist/commands/tools-generation.js +3 -2
- package/dist/commands/workspace.js +43 -0
- package/dist/utils/agent-api-client.js +31 -8
- package/dist/utils/agent-generation-provenance.js +45 -0
- package/dist/utils/generation-workspace-scope.js +50 -0
- package/dist/utils/image-generation-retry.js +7 -0
- package/dist/utils/tool-operation-journal.js +34 -4
- 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/prepare.js +109 -137
- 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 +1 -0
- package/dist/workspace-runtime/codex/config-toml.js +4 -0
- package/dist/workspace-runtime/codex/delivery-tools.js +7 -5
- package/dist/workspace-runtime/codex/generation-provenance-broker.js +80 -0
- package/dist/workspace-runtime/codex/launcher-client.js +18 -0
- package/dist/workspace-runtime/codex/model-broker.js +23 -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 +19 -32
- 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 +61 -23
- package/dist/workspace-runtime/server/endpoints.js +1 -0
- package/dist/workspace-runtime/server/http.js +52 -10
- package/dist/workspace-runtime/server/operation-record.js +2 -0
- package/dist/workspace-runtime/server/terminal-host.js +43 -8
- 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 +105 -71
- 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 +190 -0
- package/dist/workspace-runtime/workspaces/prepare.js +4 -0
- package/dist/workspace-runtime/workspaces/registry.js +11 -0
- 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/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
- package/project-template-v2/.playcraft/skills.lock.json +15 -8
- package/templates/preview-runtime/v1/README.md +4 -0
- package/templates/preview-runtime/v1/manifest.json +6 -6
- package/templates/preview-runtime/v1/runtime.mjs +368 -22
- package/templates/preview-runtime/v1/types/adapters.d.ts +4 -1
- package/templates/preview-runtime/v1/types/preview.d.ts +1 -0
- package/templates/preview-runtime/v1/types/protocol.d.ts +19 -0
- package/templates/preview-runtime/v1/types/runtime.d.ts +2 -1
|
@@ -2,9 +2,9 @@ import { readdir, readFile, realpath } from 'node:fs/promises';
|
|
|
2
2
|
import { createHash } from 'node:crypto';
|
|
3
3
|
import { posix, relative, resolve, sep } from 'node:path';
|
|
4
4
|
import JSON5 from 'json5';
|
|
5
|
-
import { parameterDocumentSchema, parameterPatchPayloadSchema, parameterCatalog, validateParameterChange, workspaceRelativePathSchema, WORKSPACE_FILE_CONTENT_MAX, } from '@playcraft/common/cloud-workspace/runtime';
|
|
5
|
+
import { parameterDocumentSchema, parameterPatchPayloadSchema, parameterCatalog, parameterI18nCatalog, parameterRuntimeTranslations, PARAMETER_RUNTIME_LOCALES, validateParameterChange, validateParameterI18nChange, workspaceRelativePathSchema, WORKSPACE_FILE_CONTENT_MAX, } from '@playcraft/common/cloud-workspace/runtime';
|
|
6
6
|
import { InvalidWorkspacePathError, writeWorkspaceFile } from './files.js';
|
|
7
|
-
import { editJson5Paths, ParameterEditError } from './json5-edit.js';
|
|
7
|
+
import { editJson5Paths, editJson5StringRecords, ParameterEditError } from './json5-edit.js';
|
|
8
8
|
import { prepareParameterAssetRegistration, writeParameterAssetRegistration, } from './parameter-assets.js';
|
|
9
9
|
import { themeAssetIndexFingerprint } from './theme-asset-index.js';
|
|
10
10
|
const MANIFEST_PATH = '.playcraft/workspace.json';
|
|
@@ -32,6 +32,7 @@ async function sourceFingerprint(root, located, ownIndexWrite) {
|
|
|
32
32
|
readOptionalText(root, MANIFEST_PATH),
|
|
33
33
|
readOptionalText(root, THEME_INDEX_PATH),
|
|
34
34
|
selectedIndex ? readOptionalText(root, selectedIndex) : undefined,
|
|
35
|
+
located.schemaPath ? readOptionalText(root, runtimeI18nGlobalPath(located.schemaPath)) : undefined,
|
|
35
36
|
]);
|
|
36
37
|
const hashes = sources.map((text) => text === undefined ? null : createHash('sha256').update(text).digest('hex'));
|
|
37
38
|
const rawIndexHash = hashes[4];
|
|
@@ -106,6 +107,10 @@ async function readParameterDocuments(root, revisions, located) {
|
|
|
106
107
|
? { platformI18n: platformFile.value, platformI18nPath: i18nPath }
|
|
107
108
|
: {}),
|
|
108
109
|
};
|
|
110
|
+
const runtimeI18n = await readRuntimeI18n(root, revisions, located);
|
|
111
|
+
if (runtimeI18n.errors.length)
|
|
112
|
+
return parameterDocumentSchema.parse({ ...schemaDocument, status: 'invalid', errors: runtimeI18n.errors });
|
|
113
|
+
Object.assign(schemaDocument, runtimeI18n.document ? { runtimeI18n: runtimeI18n.document } : {});
|
|
109
114
|
if (platformFile.status === 'invalid') {
|
|
110
115
|
return parameterDocumentSchema.parse({
|
|
111
116
|
...schemaDocument,
|
|
@@ -148,6 +153,8 @@ async function readParameterDocuments(root, revisions, located) {
|
|
|
148
153
|
export async function patchWorkspaceParameters(root, revisions, payload, observed, options = {}) {
|
|
149
154
|
const parsed = parameterPatchPayloadSchema.parse(payload);
|
|
150
155
|
const document = await readWorkspaceParameters(root, revisions);
|
|
156
|
+
if ('documentId' in parsed)
|
|
157
|
+
return patchWorkspaceParameterI18n(root, document, parsed, observed);
|
|
151
158
|
if (document.status === 'invalid')
|
|
152
159
|
throw new ParameterEditError('parameter_document_invalid');
|
|
153
160
|
if (document.status === 'missing' || document.status === 'schema_only' || !document.dataPath) {
|
|
@@ -169,7 +176,7 @@ export async function patchWorkspaceParameters(root, revisions, payload, observe
|
|
|
169
176
|
if (observed && createHash('sha256').update(source).digest('hex') !== observed.contentHash) {
|
|
170
177
|
throw new ParameterEditError('revision_conflict');
|
|
171
178
|
}
|
|
172
|
-
const next = editJson5Paths(source, changes);
|
|
179
|
+
const next = formatParameterData(document.dataPath, source, editJson5Paths(source, changes));
|
|
173
180
|
JSON5.parse(next);
|
|
174
181
|
if (Buffer.byteLength(next, 'utf8') > WORKSPACE_FILE_CONTENT_MAX) {
|
|
175
182
|
throw new ParameterEditError('parameter_document_invalid');
|
|
@@ -209,6 +216,91 @@ export async function patchWorkspaceParameters(root, revisions, payload, observe
|
|
|
209
216
|
const written = await writeWorkspaceFile(root, document.dataPath, next);
|
|
210
217
|
return { ...written, ...(additionalWrites.length ? { additionalWrites } : {}) };
|
|
211
218
|
}
|
|
219
|
+
/** Strict data files must remain consumable by the project's original production importer. */
|
|
220
|
+
function formatParameterData(path, source, edited) {
|
|
221
|
+
if (!/\.json$/i.test(path))
|
|
222
|
+
return edited;
|
|
223
|
+
const indentation = source.match(/^[\t ]+(?=")/m)?.[0] ?? (/[\r\n]/.test(source.trim()) ? ' ' : undefined);
|
|
224
|
+
const next = JSON.stringify(JSON5.parse(edited), (_key, value) => {
|
|
225
|
+
// Unknown authored values must not silently change from non-finite numbers into null.
|
|
226
|
+
if (typeof value === 'number' && !Number.isFinite(value))
|
|
227
|
+
throw new ParameterEditError('parameter_document_invalid');
|
|
228
|
+
return value;
|
|
229
|
+
}, indentation);
|
|
230
|
+
const eol = source.includes('\r\n') ? '\r\n' : '\n';
|
|
231
|
+
return next.replaceAll('\n', eol) + (source.endsWith('\n') ? eol : '');
|
|
232
|
+
}
|
|
233
|
+
async function readRuntimeI18n(root, revisions, located) {
|
|
234
|
+
if (!located.schemaPath || !located.dataPath)
|
|
235
|
+
return { errors: [] };
|
|
236
|
+
const globalPath = runtimeI18nGlobalPath(located.schemaPath);
|
|
237
|
+
const themePath = runtimeI18nThemePath(located.dataPath);
|
|
238
|
+
const [globalFile, themeFile] = await Promise.all([readOptionalJson5(root, globalPath), readOptionalJson5(root, themePath)]);
|
|
239
|
+
const errors = [];
|
|
240
|
+
if (globalFile.status === 'invalid')
|
|
241
|
+
errors.push({ path: globalPath, reason: globalFile.reason });
|
|
242
|
+
if (themeFile.status === 'invalid')
|
|
243
|
+
errors.push({ path: themePath, reason: themeFile.reason });
|
|
244
|
+
if (errors.length || globalFile.status === 'missing' && themeFile.status === 'missing')
|
|
245
|
+
return { errors };
|
|
246
|
+
return { errors, document: {
|
|
247
|
+
globalPath, themePath,
|
|
248
|
+
global: globalFile.status === 'ok' ? globalFile.value : {},
|
|
249
|
+
theme: themeFile.status === 'ok' ? themeFile.value : {},
|
|
250
|
+
...(globalFile.status === 'ok' ? { globalHash: globalFile.contentHash } : {}),
|
|
251
|
+
...(themeFile.status === 'ok' ? { themeHash: themeFile.contentHash } : {}),
|
|
252
|
+
globalRevision: revisions[globalPath]?.revision ?? 0,
|
|
253
|
+
themeRevision: revisions[themePath]?.revision ?? 0,
|
|
254
|
+
locales: [...PARAMETER_RUNTIME_LOCALES],
|
|
255
|
+
} };
|
|
256
|
+
}
|
|
257
|
+
async function patchWorkspaceParameterI18n(root, document, payload, observed) {
|
|
258
|
+
if (document.status !== 'ready' || !document.runtimeI18n)
|
|
259
|
+
throw new ParameterEditError('parameter_i18n_global_invalid');
|
|
260
|
+
const runtime = document.runtimeI18n;
|
|
261
|
+
if (payload.sourceFingerprint !== document.sourceFingerprint || observed && observed.dataPath !== runtime.themePath)
|
|
262
|
+
throw new ParameterEditError('parameter_source_conflict');
|
|
263
|
+
for (const change of payload.changes) {
|
|
264
|
+
const error = validateParameterI18nChange(document, change);
|
|
265
|
+
if (error)
|
|
266
|
+
throw new ParameterEditError(error);
|
|
267
|
+
}
|
|
268
|
+
const source = await readOptionalText(root, runtime.themePath);
|
|
269
|
+
if (observed && (source === undefined ? '' : createHash('sha256').update(source).digest('hex')) !== observed.contentHash)
|
|
270
|
+
throw new ParameterEditError('revision_conflict');
|
|
271
|
+
const globals = runtime.global;
|
|
272
|
+
const themes = runtime.theme;
|
|
273
|
+
const nextEntries = new Map();
|
|
274
|
+
const changedLocales = new Set();
|
|
275
|
+
for (const change of payload.changes) {
|
|
276
|
+
const field = parameterI18nCatalog(document, change.locale).find(entry => entry.key === change.fieldPath.join('.'));
|
|
277
|
+
const key = field.runtimeI18nKey;
|
|
278
|
+
const identity = JSON.stringify([key, change.locale]);
|
|
279
|
+
if (changedLocales.has(identity))
|
|
280
|
+
throw new ParameterEditError('parameter_changes_overlap');
|
|
281
|
+
changedLocales.add(identity);
|
|
282
|
+
const defaults = parameterRuntimeTranslations(globals[key]);
|
|
283
|
+
const previous = parameterRuntimeTranslations(themes[key]) ?? defaults;
|
|
284
|
+
const entry = nextEntries.get(key) ?? { ...previous };
|
|
285
|
+
entry[change.locale] = change.op === 'remove' ? defaults[change.locale] : change.value;
|
|
286
|
+
nextEntries.set(key, entry);
|
|
287
|
+
}
|
|
288
|
+
const edits = [...nextEntries].map(([key, value]) => PARAMETER_RUNTIME_LOCALES.every(locale => value[locale] === globals[key][locale]) ? { key, op: 'remove' } : { key, value });
|
|
289
|
+
const next = editJson5StringRecords(source ?? '{}\n', edits);
|
|
290
|
+
if (Buffer.byteLength(next, 'utf8') > WORKSPACE_FILE_CONTENT_MAX)
|
|
291
|
+
throw new ParameterEditError('parameter_document_invalid');
|
|
292
|
+
if (await sourceFingerprint(root, await locateParameterFiles(root)) !== document.sourceFingerprint)
|
|
293
|
+
throw new ParameterEditError('parameter_source_conflict');
|
|
294
|
+
if (await readOptionalText(root, runtime.themePath) !== source)
|
|
295
|
+
throw new ParameterEditError('revision_conflict');
|
|
296
|
+
return writeWorkspaceFile(root, runtime.themePath, next);
|
|
297
|
+
}
|
|
298
|
+
function runtimeI18nGlobalPath(schemaPath) {
|
|
299
|
+
return posix.join(posix.dirname(schemaPath), 'i18n.runtime.json5');
|
|
300
|
+
}
|
|
301
|
+
function runtimeI18nThemePath(dataPath) {
|
|
302
|
+
return posix.join(posix.dirname(dataPath), 'theme.i18n.runtime.json5');
|
|
303
|
+
}
|
|
212
304
|
async function locateParameterFiles(root) {
|
|
213
305
|
const manifest = await readOptionalJson5(root, MANIFEST_PATH);
|
|
214
306
|
if (manifest.status === 'invalid') {
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
import { execFile } from 'node:child_process';
|
|
2
|
+
import { readFile } from 'node:fs/promises';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import { promisify } from 'node:util';
|
|
5
|
+
import { z } from 'zod';
|
|
6
|
+
import { buildStageAt } from '@playcraft/common/build-progress';
|
|
7
|
+
import { PLATFORM_ROUTES, platformEndpoint } from '@playcraft/common/cloud-workspace/runtime';
|
|
8
|
+
import { submitTrustedOperation } from '../server/submit-operation.js';
|
|
9
|
+
import { businessEndpoint } from '../../utils/environment-endpoints.js';
|
|
10
|
+
import { ensureCliCredentials } from './cli-credentials.js';
|
|
11
|
+
import { readWorkspaceGitStatus } from './git.js';
|
|
12
|
+
import { sessionWorkspaceRoot } from './registry.js';
|
|
13
|
+
const run = promisify(execFile);
|
|
14
|
+
const REQUEST_TIMEOUT_MS = 60_000;
|
|
15
|
+
/** Validation findings beyond this are summarized by count; the Agent fixes the first ones first. */
|
|
16
|
+
const MAX_FINDINGS = 30;
|
|
17
|
+
/** A tool error the Agent sees as its reason; `code` is stable, the detail explains it. */
|
|
18
|
+
export class PlatformExportError extends Error {
|
|
19
|
+
code;
|
|
20
|
+
constructor(code, detail) {
|
|
21
|
+
super(detail ? `${code}: ${detail}` : code);
|
|
22
|
+
this.code = code;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
/** The project and branch a workspace directory exports, or undefined when it has no project. */
|
|
26
|
+
export async function platformExportTarget(state, scope) {
|
|
27
|
+
const binding = state.journal.getWorkspace(scope.workspaceId);
|
|
28
|
+
if (!binding?.projectId)
|
|
29
|
+
return undefined;
|
|
30
|
+
const root = await sessionWorkspaceRoot(state, scope.workspaceId, scope.directoryName);
|
|
31
|
+
return { projectId: binding.projectId, root };
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* A standalone workspace has no remote to export from. The platform gives it a project (as its
|
|
35
|
+
* first save does) and registers it again; the registration reaches this runtime before the reply.
|
|
36
|
+
*/
|
|
37
|
+
async function promoteStandalone(state, scope) {
|
|
38
|
+
if (state.journal.getWorkspace(scope.workspaceId)?.targetKind !== 'standalone' || scope.directoryName !== 'basic')
|
|
39
|
+
return;
|
|
40
|
+
const config = state.config;
|
|
41
|
+
const response = await fetch(platformEndpoint(config.publicBaseUrl, PLATFORM_ROUTES.workspacePromotions), {
|
|
42
|
+
method: 'POST',
|
|
43
|
+
redirect: 'error',
|
|
44
|
+
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
|
45
|
+
headers: { authorization: `Bearer ${config.controlToken}`, 'content-type': 'application/json', 'playcraft-env': config.environment },
|
|
46
|
+
body: JSON.stringify({ runtimeId: config.runtimeId, generation: config.generation, runtimeEpoch: state.runtimeEpoch,
|
|
47
|
+
workspaceId: scope.workspaceId }),
|
|
48
|
+
});
|
|
49
|
+
if (!response.ok) {
|
|
50
|
+
const body = await response.json().catch(() => undefined);
|
|
51
|
+
throw new PlatformExportError('workspace_promotion_failed', typeof body?.reason === 'string' ? body.reason : String(response.status));
|
|
52
|
+
}
|
|
53
|
+
await response.body?.cancel().catch(() => undefined);
|
|
54
|
+
}
|
|
55
|
+
const credentialSchema = z.object({ apiUrl: z.string().url(), token: z.string().min(1) });
|
|
56
|
+
async function request(state, path, init) {
|
|
57
|
+
await ensureCliCredentials(state).catch(() => { throw new PlatformExportError('cli_credentials_unavailable'); });
|
|
58
|
+
const credential = credentialSchema.parse(JSON.parse(await readFile(join(state.config.workspaceRoot, '.playcraft.json'), 'utf8')));
|
|
59
|
+
const response = await fetch(businessEndpoint(credential.apiUrl, path), {
|
|
60
|
+
method: init.method,
|
|
61
|
+
redirect: 'error',
|
|
62
|
+
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
|
63
|
+
headers: {
|
|
64
|
+
'X-Sandbox-Token': credential.token,
|
|
65
|
+
'playcraft-env': state.config.environment,
|
|
66
|
+
...(init.body !== undefined ? { 'content-type': 'application/json' } : {}),
|
|
67
|
+
},
|
|
68
|
+
...(init.body !== undefined ? { body: JSON.stringify(init.body) } : {}),
|
|
69
|
+
});
|
|
70
|
+
const text = await response.text();
|
|
71
|
+
let body;
|
|
72
|
+
try {
|
|
73
|
+
body = JSON.parse(text);
|
|
74
|
+
}
|
|
75
|
+
catch {
|
|
76
|
+
body = undefined;
|
|
77
|
+
}
|
|
78
|
+
const error = body && typeof body === 'object' && 'error' in body ? body.error : undefined;
|
|
79
|
+
if (!response.ok || (typeof error === 'string' && !('id' in body))) {
|
|
80
|
+
const message = body && typeof body === 'object' && 'message' in body ? body.message : error;
|
|
81
|
+
throw new PlatformExportError('platform_export_rejected', `${response.status} ${typeof message === 'string' ? message : Array.isArray(message) ? message.join('; ') : text.slice(0, 300)}`);
|
|
82
|
+
}
|
|
83
|
+
return body;
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* Saves the directory (commit + push), then exports that commit for the channels through the
|
|
87
|
+
* platform, as the delivery panel's Export does. Each channel repeats its latest export of the
|
|
88
|
+
* branch, or uses the defaults without one. A standalone workspace first becomes a project.
|
|
89
|
+
*/
|
|
90
|
+
export async function startPlatformExport(state, input) {
|
|
91
|
+
await promoteStandalone(state, input);
|
|
92
|
+
const target = await platformExportTarget(state, input);
|
|
93
|
+
if (!target)
|
|
94
|
+
throw new PlatformExportError('platform_export_requires_project');
|
|
95
|
+
const saved = await submitTrustedOperation(state, {
|
|
96
|
+
kind: 'workspace.save',
|
|
97
|
+
workspaceId: input.workspaceId,
|
|
98
|
+
directoryName: input.directoryName,
|
|
99
|
+
...(input.sessionId ? { sessionId: input.sessionId } : {}),
|
|
100
|
+
clientRequestId: `${input.clientRequestId.slice(0, 80)}:export-save`,
|
|
101
|
+
payload: {},
|
|
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
|
+
};
|
|
108
|
+
if (saved.status !== 'completed')
|
|
109
|
+
throw new PlatformExportError('workspace_save_failed', saved.error);
|
|
110
|
+
const status = await readWorkspaceGitStatus(target.root);
|
|
111
|
+
// A save skips the push during a merge; with an upstream, `ahead` says the push did not land.
|
|
112
|
+
if (status.merging || (status.hasUpstream && status.ahead > 0))
|
|
113
|
+
throw new PlatformExportError('workspace_not_pushed');
|
|
114
|
+
if (status.branch === 'HEAD')
|
|
115
|
+
throw new PlatformExportError('workspace_detached_head');
|
|
116
|
+
const commit = (await run('git', ['-C', target.root, 'rev-parse', 'HEAD'])).stdout.trim();
|
|
117
|
+
const result = await request(state, '/api/builds/agent-exports', {
|
|
118
|
+
method: 'POST',
|
|
119
|
+
body: {
|
|
120
|
+
projectId: target.projectId,
|
|
121
|
+
branchName: status.branch,
|
|
122
|
+
commitHash: commit,
|
|
123
|
+
platforms: input.channels,
|
|
124
|
+
...(input.format ? { format: input.format } : {}),
|
|
125
|
+
clientRequestId: input.clientRequestId,
|
|
126
|
+
},
|
|
127
|
+
});
|
|
128
|
+
return {
|
|
129
|
+
commit: commit.slice(0, 7),
|
|
130
|
+
branch: status.branch,
|
|
131
|
+
builds: result.builds.map((build) => ({
|
|
132
|
+
buildId: build.id,
|
|
133
|
+
channel: build.platform,
|
|
134
|
+
status: build.status,
|
|
135
|
+
...(build.format ? { format: build.format } : {}),
|
|
136
|
+
...(build.error ? { error: build.error } : {}),
|
|
137
|
+
...(result.options?.[build.platform] ? { options: result.options[build.platform] } : {}),
|
|
138
|
+
})),
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
/** One platform export as the Agent reads it: status, the recorded failure and the checks. */
|
|
142
|
+
export async function readPlatformExport(state, input) {
|
|
143
|
+
const target = await platformExportTarget(state, input);
|
|
144
|
+
if (!target)
|
|
145
|
+
throw new PlatformExportError('platform_export_requires_project');
|
|
146
|
+
let buildId = input.buildId;
|
|
147
|
+
if (!buildId) {
|
|
148
|
+
const { branch } = await readWorkspaceGitStatus(target.root);
|
|
149
|
+
const query = new URLSearchParams({ projectId: target.projectId, branchName: branch, limit: '1',
|
|
150
|
+
...(input.channel ? { platform: input.channel } : {}) });
|
|
151
|
+
const list = await request(state, `/api/agent/tools/list-builds?${query}`, { method: 'GET' });
|
|
152
|
+
buildId = list.items[0]?.id;
|
|
153
|
+
if (!buildId)
|
|
154
|
+
throw new PlatformExportError('delivery_build_not_found');
|
|
155
|
+
}
|
|
156
|
+
const build = await request(state, `/api/agent/tools/get-build?${new URLSearchParams({ id: buildId })}`, { method: 'GET' });
|
|
157
|
+
const running = build.status === 'queued' || build.status === 'running';
|
|
158
|
+
const findings = (build.validationWarnings ?? []).map((warning) => ({
|
|
159
|
+
code: warning.code,
|
|
160
|
+
severity: warning.severity,
|
|
161
|
+
message: warning.message,
|
|
162
|
+
...(typeof warning.details?.status === 'string' ? { status: warning.details.status } : {}),
|
|
163
|
+
...(typeof warning.details?.recommendation === 'string' ? { recommendation: warning.details.recommendation } : {}),
|
|
164
|
+
...(typeof warning.details?.file === 'string' ? { file: warning.details.file } : {}),
|
|
165
|
+
...(typeof warning.details?.line === 'number' ? { line: warning.details.line } : {}),
|
|
166
|
+
}));
|
|
167
|
+
return {
|
|
168
|
+
build: {
|
|
169
|
+
buildId: build.id,
|
|
170
|
+
channel: build.platform,
|
|
171
|
+
format: build.format,
|
|
172
|
+
status: build.status,
|
|
173
|
+
...(running ? { stage: buildStageAt(build.progress).stage } : {}),
|
|
174
|
+
...(build.commitHash ? { commit: build.commitHash } : {}),
|
|
175
|
+
...(build.error ? { error: build.error } : {}),
|
|
176
|
+
...(build.previewUrl ? { previewUrl: build.previewUrl } : {}),
|
|
177
|
+
...(build.downloadUrl ? { downloadUrl: build.downloadUrl } : {}),
|
|
178
|
+
...(typeof build.outputSize === 'number' ? { sizeBytes: build.outputSize } : {}),
|
|
179
|
+
...(typeof build.sizeLimit === 'number' ? { sizeLimitBytes: build.sizeLimit } : {}),
|
|
180
|
+
},
|
|
181
|
+
...(build.artifacts?.failure ? { failure: build.artifacts.failure } : {}),
|
|
182
|
+
...(!running && build.validationPassed !== null && build.validationPassed !== undefined ? {
|
|
183
|
+
validation: {
|
|
184
|
+
passed: build.validationPassed,
|
|
185
|
+
findings: findings.slice(0, MAX_FINDINGS),
|
|
186
|
+
...(findings.length > MAX_FINDINGS ? { omitted: findings.length - MAX_FINDINGS } : {}),
|
|
187
|
+
},
|
|
188
|
+
} : {}),
|
|
189
|
+
};
|
|
190
|
+
}
|
|
@@ -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
|
}
|
|
@@ -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 };
|
|
@@ -174,7 +177,9 @@ export async function registerWorkspace(state, input, leaseFence, preparation, r
|
|
|
174
177
|
if (binding.targetKind === 'main' && !preservedLocalBasic && (remoteUpgrade || await lstat(attachMarker).then(() => true, () => false))) {
|
|
175
178
|
if (preparation?.mode !== 'clone' || !state.gitCredentials)
|
|
176
179
|
throw new WorkspaceContextError('workspace_git_gateway_unavailable');
|
|
180
|
+
// private
|
|
177
181
|
await mkdir(join(state.config.stateDir, 'workspace-remote-attach'), { recursive: true, mode: 0o700 });
|
|
182
|
+
// private
|
|
178
183
|
await writeFile(attachMarker, JSON.stringify({ repositoryUrl: binding.repositoryUrl, branchName: binding.branchName }), { mode: 0o600 });
|
|
179
184
|
// The credential helper only serves a workspace the journal knows to be bound.
|
|
180
185
|
await state.journal.putWorkspace({ ...binding, directories });
|
|
@@ -240,6 +245,12 @@ export async function registerWorkspace(state, input, leaseFence, preparation, r
|
|
|
240
245
|
await writeWorkspaceCliContext(root, binding.workspaceId);
|
|
241
246
|
await ensureWorkspaceArtifactInstructions(root);
|
|
242
247
|
await state.journal.putWorkspace({ ...binding, directories });
|
|
248
|
+
// Registration is the one point every reused container reaches: the root repair is skipped
|
|
249
|
+
// when the binding did not change, so the shared subtrees repair themselves here (idempotent).
|
|
250
|
+
// Keep registration latency independent of workspace size; build and scan paths also repair.
|
|
251
|
+
void repairWorkspaceShares(state, root).catch(error => {
|
|
252
|
+
console.warn('[workspace-runtime] share repair failed:', error instanceof Error ? error.message : error);
|
|
253
|
+
});
|
|
243
254
|
try {
|
|
244
255
|
await readWorkspaceSkills(root, join(state.config.stateDir, 'skill-artifacts'), entry => loadWorkspaceSkillArtifact(state, binding.workspaceId, entry));
|
|
245
256
|
}
|
|
@@ -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
|
+
}
|