@lolkda/dsh-prompt-manager 3.0.0
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/LICENSE +21 -0
- package/README.md +606 -0
- package/client/client.js +2320 -0
- package/cordis.patch.yml +20 -0
- package/environment.md +24 -0
- package/lib/entries.js +303 -0
- package/lib/entries.js.map +1 -0
- package/lib/guard.js +134 -0
- package/lib/guard.js.map +1 -0
- package/lib/index.js +959 -0
- package/lib/index.js.map +1 -0
- package/lib/net.js +179 -0
- package/lib/net.js.map +1 -0
- package/lib/pack.js +327 -0
- package/lib/pack.js.map +1 -0
- package/lib/probe.js +251 -0
- package/lib/probe.js.map +1 -0
- package/lib/routes.js +718 -0
- package/lib/routes.js.map +1 -0
- package/lib/scripts.js +803 -0
- package/lib/scripts.js.map +1 -0
- package/lib/source.js +308 -0
- package/lib/source.js.map +1 -0
- package/lib/store.js +223 -0
- package/lib/store.js.map +1 -0
- package/lib/subscriptions.js +269 -0
- package/lib/subscriptions.js.map +1 -0
- package/lib/sync.js +646 -0
- package/lib/sync.js.map +1 -0
- package/lib/types/entries.d.ts +194 -0
- package/lib/types/entries.d.ts.map +1 -0
- package/lib/types/guard.d.ts +63 -0
- package/lib/types/guard.d.ts.map +1 -0
- package/lib/types/index.d.ts +176 -0
- package/lib/types/index.d.ts.map +1 -0
- package/lib/types/net.d.ts +81 -0
- package/lib/types/net.d.ts.map +1 -0
- package/lib/types/pack.d.ts +298 -0
- package/lib/types/pack.d.ts.map +1 -0
- package/lib/types/probe.d.ts +150 -0
- package/lib/types/probe.d.ts.map +1 -0
- package/lib/types/routes.d.ts +85 -0
- package/lib/types/routes.d.ts.map +1 -0
- package/lib/types/scripts.d.ts +455 -0
- package/lib/types/scripts.d.ts.map +1 -0
- package/lib/types/source.d.ts +194 -0
- package/lib/types/source.d.ts.map +1 -0
- package/lib/types/store.d.ts +140 -0
- package/lib/types/store.d.ts.map +1 -0
- package/lib/types/subscriptions.d.ts +204 -0
- package/lib/types/subscriptions.d.ts.map +1 -0
- package/lib/types/sync.d.ts +248 -0
- package/lib/types/sync.d.ts.map +1 -0
- package/package.json +100 -0
package/lib/scripts.js
ADDED
|
@@ -0,0 +1,803 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* User-authored scripts that supply prompt variables.
|
|
3
|
+
*
|
|
4
|
+
* One script is one file under `scripts/`, run as a child process, and one JSON
|
|
5
|
+
* object printed on stdout: its keys become the `{{name}}` references the
|
|
6
|
+
* prompt can use. A script therefore declares its own variables — there is no
|
|
7
|
+
* second place to register a name — and one script can supply many.
|
|
8
|
+
*
|
|
9
|
+
* Execution is deliberately out of process. A provider is evaluated
|
|
10
|
+
* synchronously for every assembly, so nothing that costs a subprocess can live
|
|
11
|
+
* there; more to the point, user code that hangs or crashes must not be able to
|
|
12
|
+
* take the host down with it. A run is bounded by a timeout, its output is
|
|
13
|
+
* bounded by a size cap, and every failure is a report rather than a throw.
|
|
14
|
+
*
|
|
15
|
+
* A script's values are cached after every successful run, so a profile start
|
|
16
|
+
* never has to execute anything to keep the prompt rendering the values it saw
|
|
17
|
+
* last: the cache is read synchronously at mount, and changed scripts are
|
|
18
|
+
* picked up by a refresh that runs behind the mount.
|
|
19
|
+
*
|
|
20
|
+
* @module @lolkda/dsh-prompt-manager/scripts
|
|
21
|
+
*/
|
|
22
|
+
import { spawn } from 'node:child_process';
|
|
23
|
+
import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs';
|
|
24
|
+
import { join } from 'node:path';
|
|
25
|
+
import { Script as CompiledScript } from 'node:vm';
|
|
26
|
+
import { isEntryId, MAX_ID_LENGTH } from './entries.js';
|
|
27
|
+
import { DEFAULT_PROBE_TEXTS, MAX_PROBE_VALUE } from './probe.js';
|
|
28
|
+
import { bodyHash, PromptStore, PromptStoreError } from './store.js';
|
|
29
|
+
/** Directory name, under the plugin's storage root, holding the scripts. */
|
|
30
|
+
export const SCRIPTS_DIR_NAME = 'scripts';
|
|
31
|
+
/** Extension every script file carries. */
|
|
32
|
+
export const SCRIPT_EXTENSION = '.js';
|
|
33
|
+
/** Largest accepted script, in bytes. */
|
|
34
|
+
export const MAX_SCRIPT_BYTES = 64 * 1024;
|
|
35
|
+
/** At most this many scripts may live in the directory. */
|
|
36
|
+
export const MAX_SCRIPTS = 20;
|
|
37
|
+
/** At most this many variables may come from one script. */
|
|
38
|
+
export const MAX_SCRIPT_VARIABLES = 64;
|
|
39
|
+
/** Per-run timeout, used when a script's override sets none. */
|
|
40
|
+
export const DEFAULT_SCRIPT_TIMEOUT_MS = 3000;
|
|
41
|
+
/** Largest amount of output read back from one run, in bytes. */
|
|
42
|
+
export const MAX_SCRIPT_OUTPUT = 64 * 1024;
|
|
43
|
+
/** Default interpreter. An override replaces it, e.g. `python`. */
|
|
44
|
+
export const DEFAULT_SCRIPT_COMMAND = 'node';
|
|
45
|
+
/** Placeholder an override's `args` may carry for the script's own path. */
|
|
46
|
+
export const SCRIPT_PLACEHOLDER = '{script}';
|
|
47
|
+
/** Bookkeeping file beside the scripts; dot-prefixed so no scan ever sees it. */
|
|
48
|
+
export const SCRIPT_STATE_FILE = '.state.json';
|
|
49
|
+
/** Prefix of the throwaway file a draft run is executed from. */
|
|
50
|
+
const DRAFT_PREFIX = '.draft-';
|
|
51
|
+
/** Longest output tail the settings page is handed for one stream. */
|
|
52
|
+
const REPORT_OUTPUT = 2000;
|
|
53
|
+
/** Valid prompt-variable names, mirroring the registry's own rule. */
|
|
54
|
+
const VARIABLE_NAME = /^[a-z][a-z0-9_]*$/;
|
|
55
|
+
/** A draft file name this module wrote, as opposed to anything a person left. */
|
|
56
|
+
const DRAFT_FILE = new RegExp(`^\\${DRAFT_PREFIX}\\d+-\\d+\\${SCRIPT_EXTENSION}$`);
|
|
57
|
+
/** Monotonic suffix keeping concurrent draft names distinct within one process. */
|
|
58
|
+
let draftCounter = 0;
|
|
59
|
+
/** A script operation the caller should report, not retry blindly. */
|
|
60
|
+
export class ScriptError extends Error {
|
|
61
|
+
/** Machine-readable reason. */
|
|
62
|
+
reason;
|
|
63
|
+
/** The run report, when the refusal came from running something. */
|
|
64
|
+
report;
|
|
65
|
+
/**
|
|
66
|
+
* @param reason - machine-readable reason.
|
|
67
|
+
* @param message - human-facing detail.
|
|
68
|
+
* @param report - the run that produced the refusal, when there was one.
|
|
69
|
+
*/
|
|
70
|
+
constructor(reason, message, report) {
|
|
71
|
+
super(message);
|
|
72
|
+
this.name = 'ScriptError';
|
|
73
|
+
this.reason = reason;
|
|
74
|
+
this.report = report;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
/** Message text of an unknown thrown value. */
|
|
78
|
+
function messageOf(error) {
|
|
79
|
+
return error instanceof Error ? error.message : String(error);
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* Read one stream back, bounded.
|
|
83
|
+
* @param text - accumulated output.
|
|
84
|
+
* @param tail - keep the end rather than the start, where errors usually are.
|
|
85
|
+
* @returns the text, cut to the size cap and then to a display-friendly tail.
|
|
86
|
+
*/
|
|
87
|
+
function clipStream(text, tail) {
|
|
88
|
+
const clipped = text.length > MAX_SCRIPT_OUTPUT ? (tail ? text.slice(-MAX_SCRIPT_OUTPUT) : text.slice(0, MAX_SCRIPT_OUTPUT)) : text;
|
|
89
|
+
return clipped.length > REPORT_OUTPUT
|
|
90
|
+
? (tail ? `…${clipped.slice(-REPORT_OUTPUT)}` : `${clipped.slice(0, REPORT_OUTPUT)}…`)
|
|
91
|
+
: clipped;
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* Run one script and collect both streams.
|
|
95
|
+
*
|
|
96
|
+
* `spawn` rather than a synchronous runner, because a save or a test run is
|
|
97
|
+
* driven by a click and must not block the host's event loop for the length of
|
|
98
|
+
* the timeout. The timeout is enforced here, so a script that never exits is
|
|
99
|
+
* killed and reported as a timeout rather than held open.
|
|
100
|
+
*
|
|
101
|
+
* @param spec - resolved interpreter, arguments, and timeout.
|
|
102
|
+
* @param options - working directory, which is the script directory.
|
|
103
|
+
* @returns the normalized run.
|
|
104
|
+
*/
|
|
105
|
+
export const defaultScriptRunner = (spec, options) => {
|
|
106
|
+
return new Promise((resolve) => {
|
|
107
|
+
let stdout = '';
|
|
108
|
+
let stderr = '';
|
|
109
|
+
let settled = false;
|
|
110
|
+
let timedOut = false;
|
|
111
|
+
const finish = (run) => {
|
|
112
|
+
if (settled)
|
|
113
|
+
return;
|
|
114
|
+
settled = true;
|
|
115
|
+
clearTimeout(timer);
|
|
116
|
+
resolve(run);
|
|
117
|
+
};
|
|
118
|
+
const child = spawn(spec.command, spec.args, {
|
|
119
|
+
cwd: options.cwd,
|
|
120
|
+
shell: false,
|
|
121
|
+
windowsHide: true,
|
|
122
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
123
|
+
// The host's environment comes through, so PATH and proxies work as they
|
|
124
|
+
// do anywhere else; the two markers let a script tell what is running it.
|
|
125
|
+
env: { ...process.env, DSH_PROMPT_MANAGER: '1', DSH_PROMPT_MANAGER_SCRIPT: options.name },
|
|
126
|
+
});
|
|
127
|
+
const timer = setTimeout(() => {
|
|
128
|
+
timedOut = true;
|
|
129
|
+
child.kill();
|
|
130
|
+
}, spec.timeoutMs);
|
|
131
|
+
child.stdout?.on('data', (chunk) => {
|
|
132
|
+
if (stdout.length < MAX_SCRIPT_OUTPUT)
|
|
133
|
+
stdout += chunk.toString();
|
|
134
|
+
});
|
|
135
|
+
child.stderr?.on('data', (chunk) => {
|
|
136
|
+
if (stderr.length < MAX_SCRIPT_OUTPUT)
|
|
137
|
+
stderr += chunk.toString();
|
|
138
|
+
});
|
|
139
|
+
child.on('error', (error) => {
|
|
140
|
+
finish({ spawnError: error.code ?? 'EINVAL', status: undefined, stdout, stderr, timedOut: false });
|
|
141
|
+
});
|
|
142
|
+
child.on('close', (code) => {
|
|
143
|
+
finish({
|
|
144
|
+
spawnError: undefined,
|
|
145
|
+
status: code === null ? undefined : code,
|
|
146
|
+
stdout,
|
|
147
|
+
stderr,
|
|
148
|
+
timedOut,
|
|
149
|
+
});
|
|
150
|
+
});
|
|
151
|
+
});
|
|
152
|
+
};
|
|
153
|
+
/**
|
|
154
|
+
* Resolve one script's execution from its override.
|
|
155
|
+
*
|
|
156
|
+
* An override with no `command` keeps the default interpreter; one that names a
|
|
157
|
+
* command replaces both the command and the arguments, with `{script}`
|
|
158
|
+
* substituted for the script's path — appended when the override does not
|
|
159
|
+
* mention the placeholder at all.
|
|
160
|
+
*
|
|
161
|
+
* @param scriptPath - absolute path of the script file.
|
|
162
|
+
* @param override - the script's configured override, when it has one.
|
|
163
|
+
* @returns the resolved spec.
|
|
164
|
+
*/
|
|
165
|
+
export function resolveSpec(scriptPath, override) {
|
|
166
|
+
const timeoutMs = override?.timeoutMs ?? DEFAULT_SCRIPT_TIMEOUT_MS;
|
|
167
|
+
const command = override?.command?.trim() ?? '';
|
|
168
|
+
if (command.length === 0)
|
|
169
|
+
return { command: DEFAULT_SCRIPT_COMMAND, args: [scriptPath], timeoutMs };
|
|
170
|
+
const args = override?.args;
|
|
171
|
+
if (args === undefined)
|
|
172
|
+
return { command, args: [scriptPath], timeoutMs };
|
|
173
|
+
let substituted = false;
|
|
174
|
+
const resolved = args.map((argument) => {
|
|
175
|
+
if (!argument.includes(SCRIPT_PLACEHOLDER))
|
|
176
|
+
return argument;
|
|
177
|
+
substituted = true;
|
|
178
|
+
return argument.split(SCRIPT_PLACEHOLDER).join(scriptPath);
|
|
179
|
+
});
|
|
180
|
+
return { command, args: substituted ? resolved : [...resolved, scriptPath], timeoutMs };
|
|
181
|
+
}
|
|
182
|
+
/**
|
|
183
|
+
* Accept the `scripts` config value in whatever shape a composition delivers.
|
|
184
|
+
*
|
|
185
|
+
* Reports rather than throws: a malformed override is a composition mistake the
|
|
186
|
+
* caller fails the mount over, but the caller is the one that knows the mount is
|
|
187
|
+
* still happening.
|
|
188
|
+
*
|
|
189
|
+
* @param raw - the config value.
|
|
190
|
+
* @returns the usable overrides, plus one problem message per dropped entry.
|
|
191
|
+
*/
|
|
192
|
+
export function normalizeScriptOverrides(raw) {
|
|
193
|
+
const overrides = {};
|
|
194
|
+
const problems = [];
|
|
195
|
+
if (raw === undefined || raw === null)
|
|
196
|
+
return { overrides, problems };
|
|
197
|
+
if (typeof raw !== 'object' || Array.isArray(raw)) {
|
|
198
|
+
return { overrides, problems: ['scripts must be a mapping of script name to an override'] };
|
|
199
|
+
}
|
|
200
|
+
for (const [name, value] of Object.entries(raw)) {
|
|
201
|
+
if (!isEntryId(name)) {
|
|
202
|
+
problems.push(`script override name ${JSON.stringify(name)} is not a usable script name`);
|
|
203
|
+
continue;
|
|
204
|
+
}
|
|
205
|
+
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
|
|
206
|
+
problems.push(`override for ${name} must be a mapping`);
|
|
207
|
+
continue;
|
|
208
|
+
}
|
|
209
|
+
const record = value;
|
|
210
|
+
const override = {};
|
|
211
|
+
const command = record['command'];
|
|
212
|
+
if (command !== undefined) {
|
|
213
|
+
if (typeof command !== 'string' || command.trim().length === 0) {
|
|
214
|
+
problems.push(`override for ${name} has a command that is not a non-empty string`);
|
|
215
|
+
continue;
|
|
216
|
+
}
|
|
217
|
+
override.command = command.trim();
|
|
218
|
+
}
|
|
219
|
+
const args = record['args'];
|
|
220
|
+
if (args !== undefined) {
|
|
221
|
+
if (!Array.isArray(args) || args.some((entry) => typeof entry !== 'string')) {
|
|
222
|
+
problems.push(`override for ${name} has args that are not a list of strings`);
|
|
223
|
+
continue;
|
|
224
|
+
}
|
|
225
|
+
override.args = args;
|
|
226
|
+
}
|
|
227
|
+
const timeoutMs = record['timeoutMs'];
|
|
228
|
+
if (timeoutMs !== undefined) {
|
|
229
|
+
if (typeof timeoutMs !== 'number' || !Number.isFinite(timeoutMs) || timeoutMs <= 0) {
|
|
230
|
+
problems.push(`override for ${name} has a timeoutMs that is not a positive number`);
|
|
231
|
+
continue;
|
|
232
|
+
}
|
|
233
|
+
override.timeoutMs = Math.trunc(timeoutMs);
|
|
234
|
+
}
|
|
235
|
+
overrides[name] = override;
|
|
236
|
+
}
|
|
237
|
+
return { overrides, problems };
|
|
238
|
+
}
|
|
239
|
+
/**
|
|
240
|
+
* Whether a script's source parses.
|
|
241
|
+
*
|
|
242
|
+
* Compiling is not running: `new vm.Script()` reports a syntax error and
|
|
243
|
+
* executes nothing, which is what makes it safe to call on a draft.
|
|
244
|
+
*
|
|
245
|
+
* @param source - the script text.
|
|
246
|
+
* @returns the parser's complaint, or `undefined` when it is fine.
|
|
247
|
+
*/
|
|
248
|
+
export function checkSyntax(source) {
|
|
249
|
+
try {
|
|
250
|
+
new CompiledScript(source, { filename: 'script.js' });
|
|
251
|
+
return undefined;
|
|
252
|
+
}
|
|
253
|
+
catch (error) {
|
|
254
|
+
return messageOf(error);
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
/**
|
|
258
|
+
* Turn one script's output into the variables it supplies.
|
|
259
|
+
*
|
|
260
|
+
* The output must be a flat JSON object; its keys are the variable names. A
|
|
261
|
+
* string is used as it stands, a finite number is stringified, and anything
|
|
262
|
+
* else is refused — a value that is not text cannot be interpolated into a
|
|
263
|
+
* prompt, and refusing it here is better than discovering it at assembly.
|
|
264
|
+
*
|
|
265
|
+
* @param raw - the script's standard output.
|
|
266
|
+
* @param texts - placeholder used for a value that came out empty.
|
|
267
|
+
* @returns the variables, plus every reason the output is unusable.
|
|
268
|
+
*/
|
|
269
|
+
export function parseScriptOutput(raw, texts = DEFAULT_PROBE_TEXTS) {
|
|
270
|
+
const truncated = [];
|
|
271
|
+
const text = raw.trim();
|
|
272
|
+
if (text.length === 0)
|
|
273
|
+
return { variables: {}, truncated, problems: ['脚本没有输出任何内容'] };
|
|
274
|
+
let parsed;
|
|
275
|
+
try {
|
|
276
|
+
parsed = JSON.parse(text);
|
|
277
|
+
}
|
|
278
|
+
catch (error) {
|
|
279
|
+
return { variables: {}, truncated, problems: [`输出不是合法 JSON:${messageOf(error)}`] };
|
|
280
|
+
}
|
|
281
|
+
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
|
|
282
|
+
return { variables: {}, truncated, problems: ['输出必须是 JSON 对象,键就是变量名'] };
|
|
283
|
+
}
|
|
284
|
+
const record = parsed;
|
|
285
|
+
const keys = Object.keys(record);
|
|
286
|
+
if (keys.length === 0)
|
|
287
|
+
return { variables: {}, truncated, problems: ['对象里没有任何键'] };
|
|
288
|
+
if (keys.length > MAX_SCRIPT_VARIABLES) {
|
|
289
|
+
return {
|
|
290
|
+
variables: {},
|
|
291
|
+
truncated,
|
|
292
|
+
problems: [`一个脚本最多提供 ${String(MAX_SCRIPT_VARIABLES)} 个变量,这次给了 ${String(keys.length)} 个`],
|
|
293
|
+
};
|
|
294
|
+
}
|
|
295
|
+
const variables = {};
|
|
296
|
+
const problems = [];
|
|
297
|
+
for (const key of keys) {
|
|
298
|
+
if (!VARIABLE_NAME.test(key) || key.length > MAX_ID_LENGTH) {
|
|
299
|
+
problems.push(`变量名 ${JSON.stringify(key)} 不合法(要匹配 ${String(VARIABLE_NAME)})`);
|
|
300
|
+
continue;
|
|
301
|
+
}
|
|
302
|
+
const value = record[key];
|
|
303
|
+
if (typeof value === 'string') {
|
|
304
|
+
const clipped = value.slice(0, MAX_PROBE_VALUE);
|
|
305
|
+
if (value.length > clipped.length)
|
|
306
|
+
truncated.push(key);
|
|
307
|
+
variables[key] = clipped.length > 0 ? clipped : texts.empty;
|
|
308
|
+
continue;
|
|
309
|
+
}
|
|
310
|
+
if (typeof value === 'number' && Number.isFinite(value)) {
|
|
311
|
+
const rendered = String(value);
|
|
312
|
+
if (rendered.length > MAX_PROBE_VALUE)
|
|
313
|
+
truncated.push(key);
|
|
314
|
+
variables[key] = rendered.slice(0, MAX_PROBE_VALUE);
|
|
315
|
+
continue;
|
|
316
|
+
}
|
|
317
|
+
problems.push(`变量 ${key} 的值必须是字符串或数字`);
|
|
318
|
+
}
|
|
319
|
+
if (problems.length > 0)
|
|
320
|
+
return { variables: {}, truncated, problems };
|
|
321
|
+
return { variables, truncated, problems };
|
|
322
|
+
}
|
|
323
|
+
/**
|
|
324
|
+
* Read the bookkeeping beside the scripts.
|
|
325
|
+
* @param dir - the script directory.
|
|
326
|
+
* @returns one record per script; an unreadable or malformed file reads as empty.
|
|
327
|
+
*/
|
|
328
|
+
export function readScriptState(dir) {
|
|
329
|
+
const file = join(dir, SCRIPT_STATE_FILE);
|
|
330
|
+
if (!existsSync(file))
|
|
331
|
+
return {};
|
|
332
|
+
try {
|
|
333
|
+
const parsed = JSON.parse(readFileSync(file, 'utf8'));
|
|
334
|
+
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed))
|
|
335
|
+
return {};
|
|
336
|
+
const state = {};
|
|
337
|
+
for (const [name, value] of Object.entries(parsed)) {
|
|
338
|
+
if (!isEntryId(name))
|
|
339
|
+
continue;
|
|
340
|
+
if (typeof value !== 'object' || value === null || Array.isArray(value))
|
|
341
|
+
continue;
|
|
342
|
+
const record = value;
|
|
343
|
+
const sha1 = record['sha1'];
|
|
344
|
+
const variables = record['variables'];
|
|
345
|
+
// An empty hash is kept: it is what a script that has never succeeded
|
|
346
|
+
// writes, and the page should still be able to show why it failed.
|
|
347
|
+
if (typeof sha1 !== 'string')
|
|
348
|
+
continue;
|
|
349
|
+
if (typeof variables !== 'object' || variables === null || Array.isArray(variables))
|
|
350
|
+
continue;
|
|
351
|
+
const kept = {};
|
|
352
|
+
for (const [variable, text] of Object.entries(variables)) {
|
|
353
|
+
if (VARIABLE_NAME.test(variable) && typeof text === 'string' && text.length > 0)
|
|
354
|
+
kept[variable] = text;
|
|
355
|
+
}
|
|
356
|
+
const entry = {
|
|
357
|
+
sha1,
|
|
358
|
+
variables: kept,
|
|
359
|
+
ranAt: typeof record['ranAt'] === 'string' ? record['ranAt'] : '',
|
|
360
|
+
ms: typeof record['ms'] === 'number' && Number.isFinite(record['ms']) ? record['ms'] : 0,
|
|
361
|
+
};
|
|
362
|
+
if (typeof record['exitCode'] === 'number' && Number.isFinite(record['exitCode']))
|
|
363
|
+
entry.exitCode = record['exitCode'];
|
|
364
|
+
if (typeof record['error'] === 'string' && record['error'].length > 0)
|
|
365
|
+
entry.error = record['error'];
|
|
366
|
+
state[name] = entry;
|
|
367
|
+
}
|
|
368
|
+
return state;
|
|
369
|
+
}
|
|
370
|
+
catch {
|
|
371
|
+
return {};
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
/**
|
|
375
|
+
* Persist the bookkeeping.
|
|
376
|
+
* @param dir - the script directory.
|
|
377
|
+
* @param state - the state to write.
|
|
378
|
+
*/
|
|
379
|
+
export function writeScriptState(dir, state) {
|
|
380
|
+
mkdirSync(dir, { recursive: true });
|
|
381
|
+
writeFileSync(join(dir, SCRIPT_STATE_FILE), `${JSON.stringify(state, null, 2)}\n`, 'utf8');
|
|
382
|
+
}
|
|
383
|
+
/**
|
|
384
|
+
* Remove draft files this module left behind, so an interrupted run cannot
|
|
385
|
+
* accumulate files in a directory a person reads.
|
|
386
|
+
* @param dir - the script directory.
|
|
387
|
+
*/
|
|
388
|
+
export function cleanDrafts(dir) {
|
|
389
|
+
if (!existsSync(dir))
|
|
390
|
+
return;
|
|
391
|
+
try {
|
|
392
|
+
for (const name of readdirSync(dir)) {
|
|
393
|
+
if (DRAFT_FILE.test(name))
|
|
394
|
+
rmSync(join(dir, name), { force: true });
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
catch {
|
|
398
|
+
/* a directory that cannot be listed has nothing this pass can clean */
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
/** The script engine: on-disk scripts, their runs, and their values. */
|
|
402
|
+
export class PromptScripts {
|
|
403
|
+
host;
|
|
404
|
+
/** `<root>/scripts`, from the plugin's resolved storage root. */
|
|
405
|
+
store;
|
|
406
|
+
/** How one script is executed; replaced in tests. */
|
|
407
|
+
runner;
|
|
408
|
+
/** Keep a run from writing its values into a prompt that has been torn down. */
|
|
409
|
+
disposed = false;
|
|
410
|
+
/**
|
|
411
|
+
* @param host - the plugin side of the engine.
|
|
412
|
+
* @param options - the process runner; the real one by default.
|
|
413
|
+
*/
|
|
414
|
+
constructor(host, options = {}) {
|
|
415
|
+
this.host = host;
|
|
416
|
+
this.store = new PromptStore(join(host.dir()), { extension: SCRIPT_EXTENSION, maxBytes: MAX_SCRIPT_BYTES });
|
|
417
|
+
this.runner = options.run ?? defaultScriptRunner;
|
|
418
|
+
}
|
|
419
|
+
/** Absolute directory holding the scripts. */
|
|
420
|
+
get dir() {
|
|
421
|
+
return this.store.dir;
|
|
422
|
+
}
|
|
423
|
+
/** Stop accepting run results. */
|
|
424
|
+
dispose() {
|
|
425
|
+
this.disposed = true;
|
|
426
|
+
}
|
|
427
|
+
/**
|
|
428
|
+
* Script names present on disk, in directory order.
|
|
429
|
+
* @returns the names, capped at {@link MAX_SCRIPTS}.
|
|
430
|
+
*/
|
|
431
|
+
names() {
|
|
432
|
+
return this.store.ids().slice(0, MAX_SCRIPTS);
|
|
433
|
+
}
|
|
434
|
+
/**
|
|
435
|
+
* Read one script's source.
|
|
436
|
+
*
|
|
437
|
+
* A lookup answers `undefined` rather than throwing: an unusable name is a
|
|
438
|
+
* script that is not there, and a file that cannot be read is reported and
|
|
439
|
+
* skipped, so neither can turn a page refresh into a failed request.
|
|
440
|
+
*
|
|
441
|
+
* @param name - script name.
|
|
442
|
+
* @returns the source and its hash, or `undefined` when there is no readable file.
|
|
443
|
+
*/
|
|
444
|
+
read(name) {
|
|
445
|
+
if (!isEntryId(name))
|
|
446
|
+
return undefined;
|
|
447
|
+
let stored;
|
|
448
|
+
try {
|
|
449
|
+
stored = this.store.read(name);
|
|
450
|
+
}
|
|
451
|
+
catch (error) {
|
|
452
|
+
this.host.warn(`cannot read the script ${name}: ${messageOf(error)}`);
|
|
453
|
+
return undefined;
|
|
454
|
+
}
|
|
455
|
+
return stored === undefined ? undefined : { source: stored.body, sha1: stored.sha1 };
|
|
456
|
+
}
|
|
457
|
+
/**
|
|
458
|
+
* The scripts as the settings page sees them.
|
|
459
|
+
* @returns one summary per script on disk.
|
|
460
|
+
*/
|
|
461
|
+
list() {
|
|
462
|
+
const state = readScriptState(this.dir);
|
|
463
|
+
return this.names().map((name) => {
|
|
464
|
+
const record = state[name];
|
|
465
|
+
const stored = this.read(name);
|
|
466
|
+
const summary = {
|
|
467
|
+
name,
|
|
468
|
+
sha1: stored?.sha1 ?? null,
|
|
469
|
+
variables: record === undefined ? [] : Object.keys(record.variables),
|
|
470
|
+
pending: record === undefined || stored === undefined || record.sha1 !== stored.sha1,
|
|
471
|
+
};
|
|
472
|
+
if (record !== undefined) {
|
|
473
|
+
if (record.ranAt.length > 0)
|
|
474
|
+
summary.ranAt = record.ranAt;
|
|
475
|
+
if (record.exitCode !== undefined)
|
|
476
|
+
summary.exitCode = record.exitCode;
|
|
477
|
+
if (record.ms > 0)
|
|
478
|
+
summary.ms = record.ms;
|
|
479
|
+
if (record.error !== undefined)
|
|
480
|
+
summary.error = record.error;
|
|
481
|
+
}
|
|
482
|
+
return summary;
|
|
483
|
+
});
|
|
484
|
+
}
|
|
485
|
+
/**
|
|
486
|
+
* Declare the values cached for every script whose file has not changed.
|
|
487
|
+
*
|
|
488
|
+
* Called at mount, synchronously, so a profile start serves the values it saw
|
|
489
|
+
* last without executing anything. A script with no cache, or one whose file
|
|
490
|
+
* changed while the profile was down, is left to {@link refresh}.
|
|
491
|
+
*
|
|
492
|
+
* @returns the names that still need a run.
|
|
493
|
+
*/
|
|
494
|
+
mountDeclare() {
|
|
495
|
+
const onDisk = this.names();
|
|
496
|
+
// A record whose script is gone is housekeeping: nothing has been declared
|
|
497
|
+
// yet at mount, so only the cache entry can be let go of here.
|
|
498
|
+
this.reconcile(onDisk);
|
|
499
|
+
const state = readScriptState(this.dir);
|
|
500
|
+
const pending = [];
|
|
501
|
+
for (const name of onDisk) {
|
|
502
|
+
const record = state[name];
|
|
503
|
+
const stored = this.read(name);
|
|
504
|
+
if (record === undefined || stored === undefined || record.sha1 !== stored.sha1) {
|
|
505
|
+
pending.push(name);
|
|
506
|
+
continue;
|
|
507
|
+
}
|
|
508
|
+
for (const [variable, value] of Object.entries(record.variables)) {
|
|
509
|
+
this.apply(name, variable, value);
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
return pending;
|
|
513
|
+
}
|
|
514
|
+
/**
|
|
515
|
+
* Run every named script, or every script on disk, and publish the values.
|
|
516
|
+
* @param names - scripts to run; all of them when omitted.
|
|
517
|
+
* @returns one report per script, in the order they were run.
|
|
518
|
+
*/
|
|
519
|
+
async refresh(names) {
|
|
520
|
+
const onDisk = this.names();
|
|
521
|
+
// A script deleted by hand (or by another profile sharing the store) leaves
|
|
522
|
+
// its record and its variables behind; this is the pass that notices.
|
|
523
|
+
this.reconcile(onDisk);
|
|
524
|
+
const wanted = names === undefined ? onDisk : names.filter((name) => onDisk.includes(name));
|
|
525
|
+
const reports = [];
|
|
526
|
+
for (const name of wanted)
|
|
527
|
+
reports.push(await this.run(name));
|
|
528
|
+
return reports;
|
|
529
|
+
}
|
|
530
|
+
/**
|
|
531
|
+
* Run one script from disk and publish whatever it supplies.
|
|
532
|
+
* @param name - script name.
|
|
533
|
+
* @returns the run report.
|
|
534
|
+
* @throws {ScriptError} when the script does not exist.
|
|
535
|
+
*/
|
|
536
|
+
async run(name) {
|
|
537
|
+
const stored = this.read(name);
|
|
538
|
+
if (stored === undefined)
|
|
539
|
+
throw new ScriptError('unknown-script', `没有这个脚本:${name}`);
|
|
540
|
+
const report = await this.execute(name, stored.source, false);
|
|
541
|
+
if (report.ok)
|
|
542
|
+
this.recordSuccess(name, stored.sha1, report);
|
|
543
|
+
else
|
|
544
|
+
this.recordFailure(name, report.problems.join(';'));
|
|
545
|
+
return report;
|
|
546
|
+
}
|
|
547
|
+
/**
|
|
548
|
+
* Run a draft without touching the disk, the cache, or the prompt.
|
|
549
|
+
*
|
|
550
|
+
* This is the test button: the run is the same code path a saved script takes,
|
|
551
|
+
* so what it reports is what saving would produce — but nothing is registered,
|
|
552
|
+
* which is what makes it safe to try things out.
|
|
553
|
+
*
|
|
554
|
+
* @param name - the name the draft is being written under.
|
|
555
|
+
* @param source - the draft's source.
|
|
556
|
+
* @returns the run report.
|
|
557
|
+
* @throws {ScriptError} when the name or the source is unusable.
|
|
558
|
+
*/
|
|
559
|
+
async runSource(name, source) {
|
|
560
|
+
if (!isEntryId(name))
|
|
561
|
+
throw new ScriptError('invalid-name', `${JSON.stringify(name)} 不是一个可用的脚本名`);
|
|
562
|
+
return this.execute(name, source, true);
|
|
563
|
+
}
|
|
564
|
+
/**
|
|
565
|
+
* Validate a draft, run it, and only then put it in place.
|
|
566
|
+
*
|
|
567
|
+
* Nothing is written until the script has produced a usable variable set and
|
|
568
|
+
* no name it wants is taken by something else, so a broken or conflicting
|
|
569
|
+
* script never becomes a file.
|
|
570
|
+
*
|
|
571
|
+
* @param name - script name.
|
|
572
|
+
* @param source - the source to save.
|
|
573
|
+
* @param fence - what the caller expects to find on disk.
|
|
574
|
+
* @returns the variables that are now in force, and the run that produced them.
|
|
575
|
+
* @throws {ScriptError} on any refusal, carrying the run report when there was one.
|
|
576
|
+
*/
|
|
577
|
+
async save(name, source, fence) {
|
|
578
|
+
if (!isEntryId(name))
|
|
579
|
+
throw new ScriptError('invalid-name', `${JSON.stringify(name)} 不是一个可用的脚本名`);
|
|
580
|
+
const existing = this.names();
|
|
581
|
+
if (!existing.includes(name) && existing.length >= MAX_SCRIPTS) {
|
|
582
|
+
throw new ScriptError('too-many', `最多 ${String(MAX_SCRIPTS)} 个脚本,先删掉一个`);
|
|
583
|
+
}
|
|
584
|
+
if (Buffer.byteLength(source, 'utf8') > MAX_SCRIPT_BYTES) {
|
|
585
|
+
throw new ScriptError('invalid-source', `脚本超过 ${String(MAX_SCRIPT_BYTES)} 字节`);
|
|
586
|
+
}
|
|
587
|
+
const syntax = checkSyntax(source);
|
|
588
|
+
if (syntax !== undefined)
|
|
589
|
+
throw new ScriptError('invalid-source', `脚本无法解析:${syntax}`);
|
|
590
|
+
const report = await this.execute(name, source, true);
|
|
591
|
+
if (!report.ok) {
|
|
592
|
+
throw new ScriptError('invalid-output', report.problems.join(';'), report);
|
|
593
|
+
}
|
|
594
|
+
const conflicts = [];
|
|
595
|
+
for (const variable of Object.keys(report.variables)) {
|
|
596
|
+
const owner = this.host.owner(variable);
|
|
597
|
+
if (owner !== undefined && owner !== name)
|
|
598
|
+
conflicts.push(`${variable}(属于 ${owner})`);
|
|
599
|
+
}
|
|
600
|
+
if (conflicts.length > 0) {
|
|
601
|
+
throw new ScriptError('conflict', `变量名已被占用:${conflicts.join('、')}`, report);
|
|
602
|
+
}
|
|
603
|
+
try {
|
|
604
|
+
this.store.write(name, source, fence);
|
|
605
|
+
}
|
|
606
|
+
catch (error) {
|
|
607
|
+
if (error instanceof PromptStoreError) {
|
|
608
|
+
throw new ScriptError(error.code === 'conflict' ? 'conflict' : 'invalid-source', error.message, report);
|
|
609
|
+
}
|
|
610
|
+
throw error;
|
|
611
|
+
}
|
|
612
|
+
const sha1 = bodyHash(source);
|
|
613
|
+
this.recordSuccess(name, sha1, report);
|
|
614
|
+
return { name, sha1, variables: report.variables, report };
|
|
615
|
+
}
|
|
616
|
+
/**
|
|
617
|
+
* Forget one script: its file and its cache entry.
|
|
618
|
+
*
|
|
619
|
+
* A variable an entry still references stays declared, frozen at the value it
|
|
620
|
+
* last held: a missing value would make every section that references it fail
|
|
621
|
+
* to assemble. One that nothing references is dropped outright — keeping it
|
|
622
|
+
* would leave the variables page showing a value for a script that is gone,
|
|
623
|
+
* which is what made a deleted script look undeletable.
|
|
624
|
+
*
|
|
625
|
+
* @param name - script name.
|
|
626
|
+
* @returns `true` when a file was removed.
|
|
627
|
+
*/
|
|
628
|
+
remove(name) {
|
|
629
|
+
const state = readScriptState(this.dir);
|
|
630
|
+
const record = state[name];
|
|
631
|
+
const removed = this.store.remove(name);
|
|
632
|
+
if (record !== undefined) {
|
|
633
|
+
delete state[name];
|
|
634
|
+
writeScriptState(this.dir, state);
|
|
635
|
+
}
|
|
636
|
+
this.release(name, record);
|
|
637
|
+
return removed;
|
|
638
|
+
}
|
|
639
|
+
/**
|
|
640
|
+
* Drop the cache entries and variables of scripts that are no longer on disk.
|
|
641
|
+
* @param onDisk - script names currently on disk.
|
|
642
|
+
*/
|
|
643
|
+
reconcile(onDisk) {
|
|
644
|
+
const state = readScriptState(this.dir);
|
|
645
|
+
let changed = false;
|
|
646
|
+
for (const name of Object.keys(state)) {
|
|
647
|
+
if (onDisk.includes(name))
|
|
648
|
+
continue;
|
|
649
|
+
const record = state[name];
|
|
650
|
+
delete state[name];
|
|
651
|
+
changed = true;
|
|
652
|
+
this.release(name, record);
|
|
653
|
+
}
|
|
654
|
+
if (changed)
|
|
655
|
+
writeScriptState(this.dir, state);
|
|
656
|
+
}
|
|
657
|
+
/**
|
|
658
|
+
* Let go of what one script supplied, keeping whatever is still referenced.
|
|
659
|
+
* @param name - the script that is gone.
|
|
660
|
+
* @param record - its cache entry, when it had one.
|
|
661
|
+
*/
|
|
662
|
+
release(name, record) {
|
|
663
|
+
if (record === undefined)
|
|
664
|
+
return;
|
|
665
|
+
for (const variable of Object.keys(record.variables)) {
|
|
666
|
+
if (this.host.referenced(variable))
|
|
667
|
+
continue;
|
|
668
|
+
this.host.forget(variable, name);
|
|
669
|
+
}
|
|
670
|
+
}
|
|
671
|
+
/**
|
|
672
|
+
* Record a successful run: its values reach the registry, its summary reaches
|
|
673
|
+
* the cache.
|
|
674
|
+
* @param name - owning script.
|
|
675
|
+
* @param sha1 - hash of the source that produced the values.
|
|
676
|
+
* @param report - the run that succeeded.
|
|
677
|
+
*/
|
|
678
|
+
recordSuccess(name, sha1, report) {
|
|
679
|
+
const state = readScriptState(this.dir);
|
|
680
|
+
const entry = {
|
|
681
|
+
sha1,
|
|
682
|
+
ranAt: new Date().toISOString(),
|
|
683
|
+
ms: report.ms,
|
|
684
|
+
variables: { ...report.variables },
|
|
685
|
+
};
|
|
686
|
+
if (report.exitCode !== undefined)
|
|
687
|
+
entry.exitCode = report.exitCode;
|
|
688
|
+
state[name] = entry;
|
|
689
|
+
writeScriptState(this.dir, state);
|
|
690
|
+
for (const [variable, value] of Object.entries(report.variables))
|
|
691
|
+
this.apply(name, variable, value);
|
|
692
|
+
}
|
|
693
|
+
/**
|
|
694
|
+
* Record a failed run.
|
|
695
|
+
*
|
|
696
|
+
* The values from the last success stay in force: a variable with no value
|
|
697
|
+
* would make every section referencing it fail to assemble, so a broken run
|
|
698
|
+
* costs the error message and nothing else. The hash of the success those
|
|
699
|
+
* values belong to is kept too, so the script still reads as needing a run.
|
|
700
|
+
*
|
|
701
|
+
* @param name - owning script.
|
|
702
|
+
* @param error - why the run failed.
|
|
703
|
+
*/
|
|
704
|
+
recordFailure(name, error) {
|
|
705
|
+
const state = readScriptState(this.dir);
|
|
706
|
+
const previous = state[name];
|
|
707
|
+
const entry = {
|
|
708
|
+
sha1: previous?.sha1 ?? '',
|
|
709
|
+
ranAt: previous?.ranAt ?? '',
|
|
710
|
+
ms: previous?.ms ?? 0,
|
|
711
|
+
variables: previous?.variables ?? {},
|
|
712
|
+
error,
|
|
713
|
+
};
|
|
714
|
+
if (previous?.exitCode !== undefined)
|
|
715
|
+
entry.exitCode = previous.exitCode;
|
|
716
|
+
state[name] = entry;
|
|
717
|
+
writeScriptState(this.dir, state);
|
|
718
|
+
}
|
|
719
|
+
/**
|
|
720
|
+
* Publish one variable, reporting a name another owner already holds.
|
|
721
|
+
* @param name - owning script.
|
|
722
|
+
* @param variable - the `{{name}}` reference.
|
|
723
|
+
* @param value - the value to serve.
|
|
724
|
+
*/
|
|
725
|
+
apply(name, variable, value) {
|
|
726
|
+
if (this.disposed)
|
|
727
|
+
return;
|
|
728
|
+
if (this.host.declare(variable, value, name) === 'conflict') {
|
|
729
|
+
this.host.warn(`script ${name} wants the variable ${variable}, which another source already owns; it keeps its current value`);
|
|
730
|
+
}
|
|
731
|
+
}
|
|
732
|
+
/**
|
|
733
|
+
* Execute a source without writing it: a draft file is used so that the script
|
|
734
|
+
* sees a real path, a real `__dirname`, and a real working directory, which is
|
|
735
|
+
* what makes a test run the same thing as a saved run.
|
|
736
|
+
*
|
|
737
|
+
* @param name - script name, for the report.
|
|
738
|
+
* @param source - the source to run.
|
|
739
|
+
* @param draft - whether to run from a throwaway file.
|
|
740
|
+
* @returns the report, never a throw for a script-level failure.
|
|
741
|
+
*/
|
|
742
|
+
async execute(name, source, draft) {
|
|
743
|
+
mkdirSync(this.dir, { recursive: true });
|
|
744
|
+
let path = join(this.dir, `${name}${SCRIPT_EXTENSION}`);
|
|
745
|
+
if (draft) {
|
|
746
|
+
draftCounter += 1;
|
|
747
|
+
path = join(this.dir, `${DRAFT_PREFIX}${String(process.pid)}-${String(draftCounter)}${SCRIPT_EXTENSION}`);
|
|
748
|
+
writeFileSync(path, source, 'utf8');
|
|
749
|
+
}
|
|
750
|
+
const spec = resolveSpec(path, this.host.overrides()[name]);
|
|
751
|
+
const started = Date.now();
|
|
752
|
+
let run;
|
|
753
|
+
try {
|
|
754
|
+
run = await this.runner(spec, { cwd: this.dir, name });
|
|
755
|
+
}
|
|
756
|
+
catch (error) {
|
|
757
|
+
run = { spawnError: undefined, status: undefined, stdout: '', stderr: messageOf(error), timedOut: false };
|
|
758
|
+
}
|
|
759
|
+
finally {
|
|
760
|
+
if (draft)
|
|
761
|
+
rmSync(path, { force: true });
|
|
762
|
+
}
|
|
763
|
+
const ms = Date.now() - started;
|
|
764
|
+
const report = {
|
|
765
|
+
name,
|
|
766
|
+
ok: false,
|
|
767
|
+
exitCode: run.status,
|
|
768
|
+
ms,
|
|
769
|
+
variables: {},
|
|
770
|
+
truncated: [],
|
|
771
|
+
problems: [],
|
|
772
|
+
warnings: [],
|
|
773
|
+
stdout: clipStream(run.stdout, false),
|
|
774
|
+
stderr: clipStream(run.stderr, true),
|
|
775
|
+
};
|
|
776
|
+
if (run.timedOut) {
|
|
777
|
+
report.problems.push(`脚本超时(超过 ${String(spec.timeoutMs)}ms),已经把它杀掉`);
|
|
778
|
+
return report;
|
|
779
|
+
}
|
|
780
|
+
if (run.spawnError !== undefined) {
|
|
781
|
+
report.problems.push(`无法启动 ${spec.command}(${run.spawnError})`);
|
|
782
|
+
return report;
|
|
783
|
+
}
|
|
784
|
+
const parsed = parseScriptOutput(run.stdout, this.host.texts());
|
|
785
|
+
if (parsed.problems.length > 0) {
|
|
786
|
+
const prefix = run.status !== undefined && run.status !== 0 ? `退出码 ${String(run.status)};` : '';
|
|
787
|
+
for (const problem of parsed.problems)
|
|
788
|
+
report.problems.push(`${prefix}${problem}`);
|
|
789
|
+
return report;
|
|
790
|
+
}
|
|
791
|
+
// A tool that prints a usable version and still exits non-zero told us what
|
|
792
|
+
// we asked for, so the values are kept and the exit code is surfaced beside
|
|
793
|
+
// them rather than costing the run.
|
|
794
|
+
if (run.status !== undefined && run.status !== 0) {
|
|
795
|
+
report.warnings.push(`退出码 ${String(run.status)}(输出可用,值照常采用)`);
|
|
796
|
+
}
|
|
797
|
+
report.variables = parsed.variables;
|
|
798
|
+
report.truncated = parsed.truncated;
|
|
799
|
+
report.ok = true;
|
|
800
|
+
return report;
|
|
801
|
+
}
|
|
802
|
+
}
|
|
803
|
+
//# sourceMappingURL=scripts.js.map
|