pi-studio 0.9.52 → 0.9.53
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/CHANGELOG.md +18 -0
- package/README.md +9 -7
- package/ROADMAP.md +14 -1
- package/client/studio-client.js +598 -106
- package/client/studio-preview-resource-helpers.js +61 -12
- package/client/studio.css +130 -3
- package/index.ts +547 -70
- package/package.json +1 -1
- package/shared/studio-resource-grants.js +157 -0
- package/shared/studio-side-question-context.js +17 -4
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-studio",
|
|
3
|
-
"version": "0.9.
|
|
3
|
+
"version": "0.9.53",
|
|
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,157 @@
|
|
|
1
|
+
import { realpathSync, statSync } from "node:fs";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { dirname, isAbsolute, relative, resolve, sep } from "node:path";
|
|
4
|
+
|
|
5
|
+
export const STUDIO_RESOURCE_GRANT_MAX_ENTRIES = 128;
|
|
6
|
+
|
|
7
|
+
function expandHome(pathInput) {
|
|
8
|
+
const value = String(pathInput || "").trim();
|
|
9
|
+
if (value === "~") return homedir();
|
|
10
|
+
if (value.startsWith("~/") || value.startsWith("~\\")) return resolve(homedir(), value.slice(2));
|
|
11
|
+
return value;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function resolveGrantInput(pathInput, fallbackCwd) {
|
|
15
|
+
const raw = expandHome(pathInput);
|
|
16
|
+
if (!raw) throw new Error("Missing Studio resource grant path.");
|
|
17
|
+
if (/\0/.test(raw)) throw new Error("Invalid Studio resource grant path.");
|
|
18
|
+
if (/^[a-z][a-z0-9+.-]*:/i.test(raw) && !/^[a-z]:[\\/]/i.test(raw)) {
|
|
19
|
+
throw new Error("Studio resource grants require a local path.");
|
|
20
|
+
}
|
|
21
|
+
return isAbsolute(raw) ? raw : resolve(fallbackCwd, raw);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function canonicalExistingPath(pathInput, fallbackCwd, expectedKind) {
|
|
25
|
+
const resolvedPath = resolveGrantInput(pathInput, fallbackCwd);
|
|
26
|
+
const canonicalPath = realpathSync(resolvedPath);
|
|
27
|
+
const stats = statSync(canonicalPath);
|
|
28
|
+
if (expectedKind === "directory" && !stats.isDirectory()) {
|
|
29
|
+
throw new Error("Studio resource directory grant does not refer to a directory.");
|
|
30
|
+
}
|
|
31
|
+
if (expectedKind === "file" && !stats.isFile()) {
|
|
32
|
+
throw new Error("Studio resource file grant does not refer to a file.");
|
|
33
|
+
}
|
|
34
|
+
if (!stats.isDirectory() && !stats.isFile()) {
|
|
35
|
+
throw new Error("Studio resource grant path must refer to a file or directory.");
|
|
36
|
+
}
|
|
37
|
+
return { canonicalPath, resolvedPath, kind: stats.isDirectory() ? "directory" : "file" };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function isPathInsideOrEqualDirectory(candidatePath, directoryPath) {
|
|
41
|
+
const rel = relative(directoryPath, candidatePath);
|
|
42
|
+
return rel === "" || (!isAbsolute(rel) && rel !== ".." && !rel.startsWith(`..${sep}`));
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function normalizeGrantSource(source, fallback) {
|
|
46
|
+
const value = String(source || "").trim();
|
|
47
|
+
return (value || fallback).slice(0, 100);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function copyGrant(entry) {
|
|
51
|
+
return Object.freeze({
|
|
52
|
+
kind: entry.kind,
|
|
53
|
+
path: entry.path,
|
|
54
|
+
sources: Object.freeze(Array.from(entry.sources)),
|
|
55
|
+
grantedAt: entry.grantedAt,
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function createStudioResourceGrantRegistry(options = {}) {
|
|
60
|
+
const requestedMaxEntries = Number(options.maxEntries);
|
|
61
|
+
const maxEntries = Math.max(1, Math.min(
|
|
62
|
+
STUDIO_RESOURCE_GRANT_MAX_ENTRIES,
|
|
63
|
+
Number.isFinite(requestedMaxEntries) && requestedMaxEntries > 0
|
|
64
|
+
? Math.floor(requestedMaxEntries)
|
|
65
|
+
: STUDIO_RESOURCE_GRANT_MAX_ENTRIES,
|
|
66
|
+
));
|
|
67
|
+
const now = typeof options.now === "function" ? options.now : Date.now;
|
|
68
|
+
const defaultCwd = typeof options.cwd === "string" && options.cwd.trim() ? options.cwd : process.cwd();
|
|
69
|
+
const entries = new Map();
|
|
70
|
+
|
|
71
|
+
function fallbackCwd(details) {
|
|
72
|
+
return typeof details?.cwd === "string" && details.cwd.trim() ? details.cwd : defaultCwd;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function addGrant(kind, canonicalPath, source) {
|
|
76
|
+
const key = `${kind}:${canonicalPath}`;
|
|
77
|
+
const existing = entries.get(key);
|
|
78
|
+
if (existing) {
|
|
79
|
+
existing.sources.add(source);
|
|
80
|
+
return copyGrant(existing);
|
|
81
|
+
}
|
|
82
|
+
if (entries.size >= maxEntries) {
|
|
83
|
+
throw new Error(`Studio resource grant limit reached (${maxEntries}).`);
|
|
84
|
+
}
|
|
85
|
+
const entry = {
|
|
86
|
+
kind,
|
|
87
|
+
path: canonicalPath,
|
|
88
|
+
sources: new Set([source]),
|
|
89
|
+
grantedAt: Math.max(0, Number(now()) || 0),
|
|
90
|
+
};
|
|
91
|
+
entries.set(key, entry);
|
|
92
|
+
return copyGrant(entry);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function grantDirectory(pathInput, details = {}) {
|
|
96
|
+
const resolved = canonicalExistingPath(pathInput, fallbackCwd(details), "directory");
|
|
97
|
+
return addGrant("directory", resolved.canonicalPath, normalizeGrantSource(details.source, "explicit-directory"));
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function grantFile(pathInput, details = {}) {
|
|
101
|
+
const resolved = canonicalExistingPath(pathInput, fallbackCwd(details), "file");
|
|
102
|
+
return addGrant("file", resolved.canonicalPath, normalizeGrantSource(details.source, "explicit-file"));
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function grantDocument(pathInput, details = {}) {
|
|
106
|
+
const cwd = fallbackCwd(details);
|
|
107
|
+
const document = canonicalExistingPath(pathInput, cwd, "file");
|
|
108
|
+
const documentDirectory = dirname(resolveGrantInput(pathInput, cwd));
|
|
109
|
+
const directoryGrant = grantDirectory(documentDirectory, {
|
|
110
|
+
cwd,
|
|
111
|
+
source: normalizeGrantSource(details.source, "document"),
|
|
112
|
+
});
|
|
113
|
+
if (!isPathInsideOrEqualDirectory(document.canonicalPath, directoryGrant.path)) {
|
|
114
|
+
grantFile(document.canonicalPath, { cwd, source: "document-file" });
|
|
115
|
+
}
|
|
116
|
+
return directoryGrant;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function findGrant(pathInput, details = {}) {
|
|
120
|
+
let candidate;
|
|
121
|
+
try {
|
|
122
|
+
candidate = canonicalExistingPath(pathInput, fallbackCwd(details));
|
|
123
|
+
} catch {
|
|
124
|
+
return null;
|
|
125
|
+
}
|
|
126
|
+
const exactFile = entries.get(`file:${candidate.canonicalPath}`);
|
|
127
|
+
if (exactFile) return copyGrant(exactFile);
|
|
128
|
+
|
|
129
|
+
let bestDirectory = null;
|
|
130
|
+
for (const entry of entries.values()) {
|
|
131
|
+
if (entry.kind !== "directory" || !isPathInsideOrEqualDirectory(candidate.canonicalPath, entry.path)) continue;
|
|
132
|
+
if (!bestDirectory || entry.path.length > bestDirectory.path.length) bestDirectory = entry;
|
|
133
|
+
}
|
|
134
|
+
return bestDirectory ? copyGrant(bestDirectory) : null;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
return Object.freeze({
|
|
138
|
+
grantDirectory,
|
|
139
|
+
grantFile,
|
|
140
|
+
grantDocument,
|
|
141
|
+
findGrant,
|
|
142
|
+
allows(pathInput, details = {}) {
|
|
143
|
+
return findGrant(pathInput, details) !== null;
|
|
144
|
+
},
|
|
145
|
+
snapshot() {
|
|
146
|
+
return Array.from(entries.values())
|
|
147
|
+
.sort((a, b) => a.path.localeCompare(b.path) || a.kind.localeCompare(b.kind))
|
|
148
|
+
.map(copyGrant);
|
|
149
|
+
},
|
|
150
|
+
clear() {
|
|
151
|
+
entries.clear();
|
|
152
|
+
},
|
|
153
|
+
get size() {
|
|
154
|
+
return entries.size;
|
|
155
|
+
},
|
|
156
|
+
});
|
|
157
|
+
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { readFileSync, readdirSync, realpathSync, statSync } from "node:fs";
|
|
1
|
+
import { lstatSync, readFileSync, readdirSync, realpathSync, statSync } from "node:fs";
|
|
2
2
|
import { basename, dirname, extname, isAbsolute, join, relative, resolve } from "node:path";
|
|
3
3
|
|
|
4
4
|
export const STUDIO_SIDE_CONTEXT_MAX_FILE_BYTES = 5_000_000;
|
|
@@ -53,8 +53,21 @@ export function resolveStudioSideQuestionRoot(pathInput, fallbackCwd) {
|
|
|
53
53
|
return stats.isDirectory() ? real : dirname(real);
|
|
54
54
|
}
|
|
55
55
|
|
|
56
|
+
export function assertStudioSideQuestionRootStable(rootPath) {
|
|
57
|
+
const requested = resolve(String(rootPath || ""));
|
|
58
|
+
if (lstatSync(requested).isSymbolicLink()) {
|
|
59
|
+
throw new Error("The selected side-question context root changed or now resolves to another folder.");
|
|
60
|
+
}
|
|
61
|
+
const currentReal = realpathSync(requested);
|
|
62
|
+
if (currentReal !== requested) {
|
|
63
|
+
throw new Error("The selected side-question context root changed or now resolves to another folder.");
|
|
64
|
+
}
|
|
65
|
+
if (!statSync(currentReal).isDirectory()) throw new Error("The selected side-question context root is no longer a folder.");
|
|
66
|
+
return currentReal;
|
|
67
|
+
}
|
|
68
|
+
|
|
56
69
|
export function resolveStudioSideQuestionPath(rootPath, pathInput, options = {}) {
|
|
57
|
-
const rootReal =
|
|
70
|
+
const rootReal = assertStudioSideQuestionRootStable(rootPath);
|
|
58
71
|
const raw = String(pathInput || "").trim().replace(/^@/, "");
|
|
59
72
|
if (!raw || /^[a-z][a-z0-9+.-]*:\/\//i.test(raw)) throw new Error("Use a local path inside the selected context root.");
|
|
60
73
|
const candidate = isAbsolute(raw) ? raw : resolve(rootReal, raw);
|
|
@@ -76,7 +89,7 @@ function classifyContextPath(filePath) {
|
|
|
76
89
|
}
|
|
77
90
|
|
|
78
91
|
export function listStudioSideQuestionContext(rootPath, options = {}) {
|
|
79
|
-
const root =
|
|
92
|
+
const root = assertStudioSideQuestionRootStable(rootPath);
|
|
80
93
|
const maxFiles = Math.max(1, Math.min(1_000, Math.floor(Number(options.maxFiles) || 400)));
|
|
81
94
|
const maxDirs = Math.max(1, Math.min(1_000, Math.floor(Number(options.maxDirs) || 500)));
|
|
82
95
|
const maxDepth = Math.max(0, Math.min(12, Math.floor(Number(options.maxDepth) || 8)));
|
|
@@ -177,7 +190,7 @@ export function sliceStudioSideQuestionExtractedText(text, options = {}) {
|
|
|
177
190
|
|
|
178
191
|
export function searchStudioSideQuestionContext(rootPath, queryInput, options = {}) {
|
|
179
192
|
if (options.signal?.aborted) throw new Error("Local context search was cancelled.");
|
|
180
|
-
const root =
|
|
193
|
+
const root = assertStudioSideQuestionRootStable(rootPath);
|
|
181
194
|
const query = String(queryInput || "").trim();
|
|
182
195
|
if (!query) throw new Error("Search query is empty.");
|
|
183
196
|
if (query.length > 500) throw new Error("Search query is too long.");
|