pi-studio 0.9.53 → 0.9.55

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-studio",
3
- "version": "0.9.53",
3
+ "version": "0.9.55",
4
4
  "description": "Two-pane browser workspace for pi with prompt/response editing, annotations, critiques, active quiz, prompt/response history, live previews, and tmux-backed REPL/literate REPL workflows",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -0,0 +1,558 @@
1
+ import { createHash, randomUUID } from "node:crypto";
2
+ import {
3
+ closeSync,
4
+ constants,
5
+ existsSync,
6
+ fchmodSync,
7
+ fchownSync,
8
+ fsyncSync,
9
+ fstatSync,
10
+ linkSync,
11
+ lstatSync,
12
+ openSync,
13
+ readFileSync,
14
+ realpathSync,
15
+ renameSync,
16
+ unlinkSync,
17
+ writeFileSync,
18
+ } from "node:fs";
19
+ import { basename, dirname, isAbsolute, join, resolve } from "node:path";
20
+
21
+ export const STUDIO_DISK_REVISION_PATTERN = /^sha256:[a-f0-9]{64}$/;
22
+
23
+ function toBuffer(content) {
24
+ return Buffer.isBuffer(content) ? content : Buffer.from(String(content ?? ""), "utf8");
25
+ }
26
+
27
+ function normalizeComparablePath(filePath) {
28
+ const normalized = resolve(filePath);
29
+ return process.platform === "win32" ? normalized.toLowerCase() : normalized;
30
+ }
31
+
32
+ function pathsMatch(left, right) {
33
+ return normalizeComparablePath(left) === normalizeComparablePath(right);
34
+ }
35
+
36
+ function errorMessage(error) {
37
+ return error instanceof Error ? error.message : String(error);
38
+ }
39
+
40
+ function isMissingFileError(error) {
41
+ return Boolean(error && typeof error === "object" && error.code === "ENOENT");
42
+ }
43
+
44
+ export function createStudioDiskRevision(content) {
45
+ return `sha256:${createHash("sha256").update(toBuffer(content)).digest("hex")}`;
46
+ }
47
+
48
+ export function normalizeStudioDiskRevision(value) {
49
+ const revision = typeof value === "string" ? value.trim().toLowerCase() : "";
50
+ return STUDIO_DISK_REVISION_PATTERN.test(revision) ? revision : null;
51
+ }
52
+
53
+ export function studioDiskRevisionsMatch(left, right) {
54
+ const normalizedLeft = normalizeStudioDiskRevision(left);
55
+ const normalizedRight = normalizeStudioDiskRevision(right);
56
+ return Boolean(normalizedLeft && normalizedRight && normalizedLeft === normalizedRight);
57
+ }
58
+
59
+ export function readStudioDiskFileSnapshot(filePath) {
60
+ if (typeof filePath !== "string" || !filePath.trim()) {
61
+ throw new Error("Missing file path.");
62
+ }
63
+ const requestedPath = resolve(filePath);
64
+ const requestedStats = lstatSync(requestedPath);
65
+ if (!requestedStats.isFile() && !requestedStats.isSymbolicLink()) {
66
+ throw new Error(`Path is not a file: ${requestedPath}`);
67
+ }
68
+ const canonicalPath = realpathSync(requestedPath);
69
+ const snapshot = inspectStableCanonicalTarget(canonicalPath);
70
+ if (
71
+ snapshot.exists !== true
72
+ || snapshot.unsafe === true
73
+ || !Buffer.isBuffer(snapshot.buffer)
74
+ || typeof snapshot.revision !== "string"
75
+ ) {
76
+ throw new Error(snapshot.message || `Path is not a file: ${canonicalPath}`);
77
+ }
78
+ return Object.freeze({
79
+ path: snapshot.path,
80
+ buffer: snapshot.buffer,
81
+ revision: snapshot.revision,
82
+ size: snapshot.buffer.length,
83
+ mtimeMs: snapshot.mtimeMs,
84
+ mode: snapshot.mode,
85
+ });
86
+ }
87
+
88
+ function inspectStableCanonicalTarget(targetPath, options = {}) {
89
+ const absolutePath = resolve(targetPath);
90
+ let stats;
91
+ try {
92
+ stats = lstatSync(absolutePath);
93
+ } catch (error) {
94
+ if (isMissingFileError(error)) return { exists: false, path: absolutePath };
95
+ throw error;
96
+ }
97
+ if (stats.isSymbolicLink()) {
98
+ return {
99
+ exists: true,
100
+ unsafe: true,
101
+ path: absolutePath,
102
+ message: "The file location is now a symbolic link.",
103
+ };
104
+ }
105
+ if (!stats.isFile()) {
106
+ return {
107
+ exists: true,
108
+ unsafe: true,
109
+ path: absolutePath,
110
+ message: "The file location is no longer a regular file.",
111
+ };
112
+ }
113
+ const canonicalPath = realpathSync(absolutePath);
114
+ if (options.requireCanonical !== false && !pathsMatch(canonicalPath, absolutePath)) {
115
+ return {
116
+ exists: true,
117
+ unsafe: true,
118
+ path: absolutePath,
119
+ message: "The file location now resolves somewhere else.",
120
+ };
121
+ }
122
+ const noFollow = typeof constants.O_NOFOLLOW === "number" ? constants.O_NOFOLLOW : 0;
123
+ let fd = null;
124
+ try {
125
+ fd = openSync(canonicalPath, constants.O_RDONLY | noFollow);
126
+ const before = fstatSync(fd);
127
+ if (!before.isFile()) throw new Error(`Path is not a file: ${canonicalPath}`);
128
+ const buffer = readFileSync(fd);
129
+ const after = fstatSync(fd);
130
+ const latestPathStats = lstatSync(canonicalPath);
131
+ const latestRequestedStats = pathsMatch(absolutePath, canonicalPath)
132
+ ? latestPathStats
133
+ : lstatSync(absolutePath);
134
+ if (
135
+ latestPathStats.isSymbolicLink()
136
+ || !latestPathStats.isFile()
137
+ || latestRequestedStats.isSymbolicLink()
138
+ || !latestRequestedStats.isFile()
139
+ || before.dev !== after.dev
140
+ || before.ino !== after.ino
141
+ || after.dev !== latestPathStats.dev
142
+ || after.ino !== latestPathStats.ino
143
+ || after.dev !== latestRequestedStats.dev
144
+ || after.ino !== latestRequestedStats.ino
145
+ || before.size !== after.size
146
+ || before.mtimeMs !== after.mtimeMs
147
+ || before.ctimeMs !== after.ctimeMs
148
+ ) {
149
+ throw new Error(`File changed while Studio was inspecting it: ${canonicalPath}`);
150
+ }
151
+ return {
152
+ exists: true,
153
+ unsafe: false,
154
+ path: canonicalPath,
155
+ buffer,
156
+ revision: createStudioDiskRevision(buffer),
157
+ mode: after.mode,
158
+ uid: after.uid,
159
+ gid: after.gid,
160
+ nlink: after.nlink,
161
+ dev: after.dev,
162
+ ino: after.ino,
163
+ mtimeMs: after.mtimeMs,
164
+ };
165
+ } finally {
166
+ if (fd !== null) {
167
+ try { closeSync(fd); } catch {}
168
+ }
169
+ }
170
+ }
171
+
172
+ function resolveStableNewTarget(targetPath) {
173
+ const absolutePath = resolve(targetPath);
174
+ const parentPath = dirname(absolutePath);
175
+ const canonicalParent = realpathSync(parentPath);
176
+ const canonicalTarget = join(canonicalParent, basename(absolutePath));
177
+ return { path: canonicalTarget, parentPath: canonicalParent };
178
+ }
179
+
180
+ function getStudioPathIdentity(path) {
181
+ const stats = lstatSync(path);
182
+ return { dev: stats.dev, ino: stats.ino };
183
+ }
184
+
185
+ function studioPathIdentitiesMatch(left, right) {
186
+ return Boolean(left && right && left.dev === right.dev && left.ino === right.ino);
187
+ }
188
+
189
+ function fsyncStudioDirectory(path) {
190
+ let fd = null;
191
+ try {
192
+ const directoryFlag = typeof constants.O_DIRECTORY === "number" ? constants.O_DIRECTORY : 0;
193
+ fd = openSync(path, constants.O_RDONLY | directoryFlag);
194
+ fsyncSync(fd);
195
+ } catch {
196
+ // Some supported platforms/filesystems do not permit directory fsync.
197
+ } finally {
198
+ if (fd !== null) {
199
+ try { closeSync(fd); } catch {}
200
+ }
201
+ }
202
+ }
203
+
204
+ function applyStudioFileMetadata(fd, metadata) {
205
+ if (typeof metadata === "number") {
206
+ fchmodSync(fd, metadata & 0o7777);
207
+ return;
208
+ }
209
+ if (!metadata || typeof metadata !== "object") return;
210
+ if (
211
+ process.platform !== "win32"
212
+ && Number.isInteger(metadata.uid)
213
+ && Number.isInteger(metadata.gid)
214
+ ) {
215
+ fchownSync(fd, metadata.uid, metadata.gid);
216
+ }
217
+ const mode = Number.isInteger(metadata.mode)
218
+ ? metadata.mode & 0o7777
219
+ : (0o666 & ~process.umask());
220
+ fchmodSync(fd, mode);
221
+ }
222
+
223
+ function writeStudioDiskFileAtomically(targetPath, content, metadata, options = {}) {
224
+ const buffer = toBuffer(content);
225
+ const parentPath = dirname(targetPath);
226
+ const canonicalParent = realpathSync(parentPath);
227
+ if (!pathsMatch(parentPath, canonicalParent)) {
228
+ throw new Error("The save directory now resolves somewhere else.");
229
+ }
230
+ const parentIdentity = getStudioPathIdentity(canonicalParent);
231
+ const tempPath = join(parentPath, `.pi-studio-${randomUUID()}.tmp`);
232
+ let fd = null;
233
+ try {
234
+ const noFollow = typeof constants.O_NOFOLLOW === "number" ? constants.O_NOFOLLOW : 0;
235
+ fd = openSync(tempPath, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | noFollow, 0o600);
236
+ const tempIdentity = fstatSync(fd);
237
+ writeFileSync(fd, buffer);
238
+ applyStudioFileMetadata(fd, metadata);
239
+ fsyncSync(fd);
240
+ closeSync(fd);
241
+ fd = null;
242
+ const assertStagingIntegrity = () => {
243
+ const latestCanonicalParent = realpathSync(parentPath);
244
+ if (
245
+ !pathsMatch(parentPath, latestCanonicalParent)
246
+ || !studioPathIdentitiesMatch(parentIdentity, getStudioPathIdentity(latestCanonicalParent))
247
+ ) {
248
+ throw new Error("The save directory changed before the write could be committed.");
249
+ }
250
+ const latestTemp = lstatSync(tempPath);
251
+ if (latestTemp.isSymbolicLink() || !latestTemp.isFile() || latestTemp.dev !== tempIdentity.dev || latestTemp.ino !== tempIdentity.ino) {
252
+ throw new Error("The temporary save file changed before it could be committed.");
253
+ }
254
+ };
255
+ assertStagingIntegrity();
256
+ if (typeof options.beforeCommit === "function") options.beforeCommit();
257
+ // Keep the final compare-to-commit window to two local identity checks. Filesystem
258
+ // writers do not share an atomic CAS primitive with Studio, so this remains best-effort.
259
+ assertStagingIntegrity();
260
+ if (options.replace === false) {
261
+ linkSync(tempPath, targetPath);
262
+ try {
263
+ unlinkSync(tempPath);
264
+ } catch {
265
+ // The no-clobber target is already committed. A same-directory orphaned
266
+ // staging link is safer than reporting failure after changing the target.
267
+ }
268
+ } else {
269
+ renameSync(tempPath, targetPath);
270
+ }
271
+ fsyncStudioDirectory(parentPath);
272
+ } catch (error) {
273
+ if (fd !== null) {
274
+ try { closeSync(fd); } catch {}
275
+ }
276
+ try {
277
+ if (existsSync(tempPath)) unlinkSync(tempPath);
278
+ } catch {}
279
+ throw error;
280
+ }
281
+ return {
282
+ path: resolve(targetPath),
283
+ revision: createStudioDiskRevision(buffer),
284
+ size: buffer.length,
285
+ };
286
+ }
287
+
288
+ function createStudioDiskConflict(reason, path, currentRevision, message) {
289
+ return { ok: false, conflict: true, reason, path, currentRevision, message };
290
+ }
291
+
292
+ function throwStudioDiskConflict(conflict) {
293
+ const error = new Error(conflict.message);
294
+ error.studioConflict = conflict;
295
+ throw error;
296
+ }
297
+
298
+ function createStudioNewFileMetadata() {
299
+ return { mode: 0o666 & ~process.umask() };
300
+ }
301
+
302
+ export function saveStudioDiskFileIfRevision(options) {
303
+ const filePath = typeof options?.path === "string" ? options.path.trim() : "";
304
+ if (!filePath || !isAbsolute(filePath)) {
305
+ return { ok: false, reason: "invalid-path", message: "Safe save needs an absolute canonical file path." };
306
+ }
307
+ const expectedRevision = normalizeStudioDiskRevision(options?.expectedRevision);
308
+ const allowMissingRecreation = options?.force === true;
309
+ let target;
310
+ try {
311
+ target = inspectStableCanonicalTarget(filePath);
312
+ } catch (error) {
313
+ return { ok: false, reason: "read-failed", message: `Could not inspect the file before saving: ${errorMessage(error)}` };
314
+ }
315
+
316
+ if (target.unsafe) {
317
+ return createStudioDiskConflict(
318
+ "location-changed",
319
+ target.path,
320
+ null,
321
+ `${target.message} Studio will not follow a replaced location while saving.`,
322
+ );
323
+ }
324
+ if (!target.exists && !allowMissingRecreation) {
325
+ return createStudioDiskConflict(
326
+ "file-missing",
327
+ target.path,
328
+ null,
329
+ "The file was removed or moved after Studio loaded it.",
330
+ );
331
+ }
332
+ if (target.exists && target.nlink > 1) {
333
+ return createStudioDiskConflict(
334
+ "hard-linked-file",
335
+ target.path,
336
+ target.revision,
337
+ "The file has multiple hard links. Studio will not silently split those links with an atomic replacement; use Save As instead.",
338
+ );
339
+ }
340
+ if (target.exists && !expectedRevision) {
341
+ return createStudioDiskConflict(
342
+ "revision-required",
343
+ target.path,
344
+ target.revision,
345
+ "Studio no longer has the disk revision this editor was based on.",
346
+ );
347
+ }
348
+ if (target.exists && !studioDiskRevisionsMatch(expectedRevision, target.revision)) {
349
+ return createStudioDiskConflict(
350
+ "disk-changed",
351
+ target.path,
352
+ target.revision,
353
+ "The file changed again after Studio reported the previous conflict.",
354
+ );
355
+ }
356
+
357
+ let stableTargetPath = target.path;
358
+ if (!target.exists) {
359
+ try {
360
+ const resolvedTarget = resolveStableNewTarget(filePath);
361
+ if (!pathsMatch(resolvedTarget.path, filePath)) {
362
+ return createStudioDiskConflict(
363
+ "location-changed",
364
+ filePath,
365
+ null,
366
+ "The file's parent directory now resolves somewhere else. Studio will not recreate it there.",
367
+ );
368
+ }
369
+ stableTargetPath = resolvedTarget.path;
370
+ } catch (error) {
371
+ return { ok: false, reason: "write-failed", message: `Could not resolve the file location before saving: ${errorMessage(error)}` };
372
+ }
373
+ }
374
+
375
+ const targetExisted = target.exists;
376
+ const metadata = targetExisted ? target : createStudioNewFileMetadata();
377
+ try {
378
+ const written = writeStudioDiskFileAtomically(stableTargetPath, options?.content, metadata, {
379
+ replace: targetExisted,
380
+ beforeCommit: () => {
381
+ const latest = inspectStableCanonicalTarget(stableTargetPath);
382
+ if (latest.unsafe) {
383
+ throwStudioDiskConflict(createStudioDiskConflict(
384
+ "location-changed",
385
+ stableTargetPath,
386
+ null,
387
+ `${latest.message} Studio will not follow a replaced location while saving.`,
388
+ ));
389
+ }
390
+ if (targetExisted) {
391
+ if (!latest.exists || !studioDiskRevisionsMatch(expectedRevision, latest.revision)) {
392
+ throwStudioDiskConflict(createStudioDiskConflict(
393
+ latest.exists ? "disk-changed" : "file-missing",
394
+ stableTargetPath,
395
+ latest.exists ? latest.revision : null,
396
+ latest.exists
397
+ ? "The file changed again while Studio was preparing the save."
398
+ : "The file was removed while Studio was preparing the save.",
399
+ ));
400
+ }
401
+ if (latest.nlink > 1) {
402
+ throwStudioDiskConflict(createStudioDiskConflict(
403
+ "hard-linked-file",
404
+ stableTargetPath,
405
+ latest.revision,
406
+ "The file gained another hard link while Studio was preparing the save; use Save As instead.",
407
+ ));
408
+ }
409
+ } else if (latest.exists) {
410
+ throwStudioDiskConflict(createStudioDiskConflict(
411
+ "disk-changed",
412
+ stableTargetPath,
413
+ latest.revision,
414
+ "A new file appeared at this path while Studio was preparing to recreate it.",
415
+ ));
416
+ }
417
+ },
418
+ });
419
+ return { ok: true, ...written };
420
+ } catch (error) {
421
+ if (error && typeof error === "object" && error.studioConflict) return error.studioConflict;
422
+ if (!targetExisted && error && typeof error === "object" && error.code === "EEXIST") {
423
+ let currentRevision = null;
424
+ try {
425
+ const current = inspectStableCanonicalTarget(stableTargetPath);
426
+ if (current.exists && !current.unsafe) currentRevision = current.revision;
427
+ } catch {}
428
+ return createStudioDiskConflict(
429
+ "disk-changed",
430
+ stableTargetPath,
431
+ currentRevision,
432
+ "A new file appeared at this path before Studio could recreate it.",
433
+ );
434
+ }
435
+ return { ok: false, reason: "write-failed", message: `Failed to save file: ${errorMessage(error)}` };
436
+ }
437
+ }
438
+
439
+ export function saveStudioDiskFileAs(options) {
440
+ const pathInput = typeof options?.path === "string" ? options.path.trim() : "";
441
+ if (!pathInput) return { ok: false, reason: "invalid-path", message: "Missing file path." };
442
+ const cwd = typeof options?.cwd === "string" && options.cwd.trim() ? options.cwd : process.cwd();
443
+ const requestedPath = isAbsolute(pathInput) ? resolve(pathInput) : resolve(cwd, pathInput);
444
+ const overwrite = options?.overwrite === true;
445
+ const expectedRevision = normalizeStudioDiskRevision(options?.expectedRevision);
446
+ let stableTargetPath = requestedPath;
447
+ let target;
448
+ try {
449
+ target = inspectStableCanonicalTarget(requestedPath, { requireCanonical: false });
450
+ if (!target.exists) {
451
+ stableTargetPath = resolveStableNewTarget(requestedPath).path;
452
+ target = inspectStableCanonicalTarget(stableTargetPath);
453
+ } else {
454
+ stableTargetPath = target.path;
455
+ }
456
+ } catch (error) {
457
+ return { ok: false, reason: "read-failed", message: `Could not inspect the save location: ${errorMessage(error)}` };
458
+ }
459
+ if (target.unsafe) {
460
+ return { ok: false, reason: "unsafe-path", message: `${target.message} Choose another path.` };
461
+ }
462
+ if (target.exists && target.nlink > 1) {
463
+ return createStudioDiskConflict(
464
+ "hard-linked-file",
465
+ target.path,
466
+ target.revision,
467
+ "Studio will not atomically replace a file with multiple hard links. Choose another path or update the linked file outside Studio.",
468
+ );
469
+ }
470
+ if (target.exists && !overwrite) {
471
+ return createStudioDiskConflict(
472
+ "target-exists",
473
+ target.path,
474
+ target.revision,
475
+ `A file already exists at ${target.path}.`,
476
+ );
477
+ }
478
+ if (target.exists && overwrite && !studioDiskRevisionsMatch(expectedRevision, target.revision)) {
479
+ return createStudioDiskConflict(
480
+ "target-exists",
481
+ target.path,
482
+ target.revision,
483
+ expectedRevision
484
+ ? `The file at ${target.path} changed again after Studio asked for replacement confirmation.`
485
+ : `Confirm replacement of the existing file at ${target.path}.`,
486
+ );
487
+ }
488
+ if (!target.exists && overwrite && expectedRevision) {
489
+ return createStudioDiskConflict(
490
+ "target-missing",
491
+ stableTargetPath,
492
+ null,
493
+ "The file was removed after Studio asked for replacement confirmation. Confirm again to create it at this path.",
494
+ );
495
+ }
496
+
497
+ const targetExisted = target.exists;
498
+ const metadata = targetExisted ? target : createStudioNewFileMetadata();
499
+ try {
500
+ const written = writeStudioDiskFileAtomically(stableTargetPath, options?.content, metadata, {
501
+ replace: targetExisted,
502
+ beforeCommit: () => {
503
+ const latest = inspectStableCanonicalTarget(stableTargetPath);
504
+ if (latest.unsafe) {
505
+ throwStudioDiskConflict({
506
+ ok: false,
507
+ reason: "unsafe-path",
508
+ message: `${latest.message} Choose another path.`,
509
+ });
510
+ }
511
+ if (targetExisted) {
512
+ if (!latest.exists || !studioDiskRevisionsMatch(expectedRevision, latest.revision)) {
513
+ throwStudioDiskConflict(createStudioDiskConflict(
514
+ latest.exists ? "target-exists" : "target-missing",
515
+ stableTargetPath,
516
+ latest.exists ? latest.revision : null,
517
+ latest.exists
518
+ ? "The replacement target changed again while Studio was preparing the save."
519
+ : "The replacement target was removed while Studio was preparing the save.",
520
+ ));
521
+ }
522
+ if (latest.nlink > 1) {
523
+ throwStudioDiskConflict(createStudioDiskConflict(
524
+ "hard-linked-file",
525
+ stableTargetPath,
526
+ latest.revision,
527
+ "The replacement target gained another hard link while Studio was preparing the save; choose another path.",
528
+ ));
529
+ }
530
+ } else if (latest.exists) {
531
+ throwStudioDiskConflict(createStudioDiskConflict(
532
+ "target-exists",
533
+ stableTargetPath,
534
+ latest.revision,
535
+ "A file appeared at this path while Studio was preparing the save.",
536
+ ));
537
+ }
538
+ },
539
+ });
540
+ return { ok: true, ...written };
541
+ } catch (error) {
542
+ if (error && typeof error === "object" && error.studioConflict) return error.studioConflict;
543
+ if (!targetExisted && error && typeof error === "object" && error.code === "EEXIST") {
544
+ let currentRevision = null;
545
+ try {
546
+ const current = inspectStableCanonicalTarget(stableTargetPath);
547
+ if (current.exists && !current.unsafe) currentRevision = current.revision;
548
+ } catch {}
549
+ return createStudioDiskConflict(
550
+ "target-exists",
551
+ stableTargetPath,
552
+ currentRevision,
553
+ `A file already exists at ${stableTargetPath}.`,
554
+ );
555
+ }
556
+ return { ok: false, reason: "write-failed", message: `Failed to save file: ${errorMessage(error)}` };
557
+ }
558
+ }