@worca/app 1.3.0 → 1.4.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/README.md +85 -6
- package/agents/clarify.meta.json +1 -0
- package/agents/memoryDefragmenter.meta.json +2 -1
- package/agents/reviewer.meta.json +60 -0
- package/agents/worca-cc-code-reviewer.md +33 -0
- package/agents/worca-cc-memory-defragmenter.md +5 -3
- package/agents/workspaceScanner.meta.json +1 -0
- package/package.json +14 -10
- package/scripts/git-diff.mjs +25 -0
- package/scripts/gitDiff.meta.json +18 -0
- package/scripts/js-inline.mjs +11 -0
- package/scripts/js.meta.json +22 -0
- package/scripts/py-inline.py +27 -0
- package/scripts/py.meta.json +22 -0
- package/scripts/shell.meta.json +24 -0
- package/skills/worca/SKILL.md +3 -2
- package/src/cli/models.mjs +247 -0
- package/src/cli/render.mjs +72 -4
- package/src/cli/schedule.mjs +494 -0
- package/src/cli/worca-cc.mjs +1001 -22
- package/src/core/agent-registry.mjs +75 -23
- package/src/core/agent-store.mjs +51 -2
- package/src/core/artifacts.mjs +73 -10
- package/src/core/ask/events.mjs +119 -1
- package/src/core/ask/limits.mjs +32 -4
- package/src/core/ask/mcp-stdio.mjs +12 -0
- package/src/core/ask/model-deps.mjs +126 -0
- package/src/core/ask/model-proposal.mjs +370 -0
- package/src/core/ask/models.mjs +12 -0
- package/src/core/ask/policy-deps.mjs +124 -0
- package/src/core/ask/policy-proposal.mjs +363 -0
- package/src/core/ask/prompt.mjs +74 -9
- package/src/core/ask/proposal.mjs +54 -5
- package/src/core/ask/schedule-deps.mjs +83 -0
- package/src/core/ask/schedule-spec.mjs +310 -0
- package/src/core/ask/script-deps.mjs +357 -0
- package/src/core/ask/source-deps.mjs +52 -0
- package/src/core/ask/source-spec.mjs +157 -0
- package/src/core/ask/spawn.mjs +1 -0
- package/src/core/ask/store.mjs +6 -3
- package/src/core/ask/tool-deps.mjs +4 -0
- package/src/core/ask/tools.mjs +657 -37
- package/src/core/ask/turn.mjs +109 -2
- package/src/core/ask-files.mjs +406 -0
- package/src/core/ask-forms.mjs +195 -0
- package/src/core/ask-projection.mjs +72 -0
- package/src/core/bridge/errors.mjs +84 -0
- package/src/core/bridge/provider-ops.mjs +281 -0
- package/src/core/bridge/providers/copilot.mjs +269 -0
- package/src/core/bridge/providers/endpoint.mjs +257 -0
- package/src/core/bridge/registry.mjs +88 -0
- package/src/core/bridge/semaphore.mjs +73 -0
- package/src/core/bridge/server.mjs +184 -0
- package/src/core/bridge/telemetry.mjs +53 -0
- package/src/core/bridge/translate/request.mjs +252 -0
- package/src/core/bridge/translate/response.mjs +82 -0
- package/src/core/bridge/translate/stream.mjs +242 -0
- package/src/core/bridge/upstream.mjs +209 -0
- package/src/core/chat/command-router.mjs +58 -4
- package/src/core/chat/notifier.mjs +14 -1
- package/src/core/chat/renderers.mjs +35 -0
- package/src/core/claude-runner.mjs +126 -19
- package/src/core/config.mjs +212 -31
- package/src/core/cost-budget.mjs +3 -2
- package/src/core/db.mjs +169 -15
- package/src/core/failure-policy.mjs +10 -0
- package/src/core/fs-browse.mjs +16 -4
- package/src/core/git-info.mjs +22 -0
- package/src/core/graph/builtin-workflows.mjs +3 -1
- package/src/core/graph/exec-io.mjs +71 -0
- package/src/core/graph/executor.mjs +139 -70
- package/src/core/graph/human-evidence.mjs +131 -0
- package/src/core/graph/python-probe.mjs +172 -0
- package/src/core/graph/registry-ports.mjs +10 -6
- package/src/core/graph/scheduler.mjs +39 -24
- package/src/core/graph/script-child.mjs +81 -0
- package/src/core/graph/script-runner.mjs +597 -0
- package/src/core/graph/worca_script.py +207 -0
- package/src/core/guardrail-store.mjs +16 -0
- package/src/core/human-backfill.mjs +108 -0
- package/src/core/human-rate.mjs +17 -0
- package/src/core/index-html.mjs +6 -2
- package/src/core/memory-defrag-model.mjs +112 -0
- package/src/core/memory-store.mjs +70 -18
- package/src/core/memory-sync.mjs +22 -11
- package/src/core/metrics/read.mjs +4 -1
- package/src/core/metrics/record.mjs +50 -2
- package/src/core/metrics/sync.mjs +6 -4
- package/src/core/model-env.mjs +149 -0
- package/src/core/model-test.mjs +14 -1
- package/src/core/notifications.mjs +128 -0
- package/src/core/onboarding.mjs +8 -2
- package/src/core/orchestrator.mjs +357 -26
- package/src/core/phases.mjs +95 -6
- package/src/core/plugin-api.mjs +24 -7
- package/src/core/plugin-manifest.mjs +184 -18
- package/src/core/plugin-models.mjs +1 -0
- package/src/core/plugin-script-cases.mjs +118 -0
- package/src/core/plugin-store.mjs +163 -17
- package/src/core/plugin-workflows.mjs +71 -17
- package/src/core/policy/cache.mjs +116 -0
- package/src/core/policy/effective.mjs +175 -0
- package/src/core/policy/gate.mjs +91 -0
- package/src/core/policy/local.mjs +145 -0
- package/src/core/policy/registry.mjs +330 -0
- package/src/core/policy/scope.mjs +61 -0
- package/src/core/policy/state.mjs +79 -0
- package/src/core/policy/sync.mjs +513 -0
- package/src/core/protocol.mjs +43 -0
- package/src/core/run-harness.mjs +421 -72
- package/src/core/scheduler.mjs +980 -0
- package/src/core/script-bench.mjs +628 -0
- package/src/core/script-registry.mjs +116 -0
- package/src/core/script-store.mjs +563 -0
- package/src/core/settings.mjs +489 -14
- package/src/core/stats.mjs +33 -2
- package/src/core/workflow-export.mjs +94 -3
- package/src/core/workflow-share.mjs +67 -18
- package/src/core/workflows.mjs +47 -11
- package/src/core/workspaces.mjs +18 -12
- package/src/shared/forms/answer.mjs +164 -0
- package/src/shared/forms/catalog.mjs +91 -0
- package/src/shared/forms/form-def.mjs +290 -0
- package/src/shared/forms/layout.mjs +67 -0
- package/src/shared/forms/paths.mjs +47 -0
- package/src/shared/forms/project.mjs +309 -0
- package/src/shared/forms/schema.mjs +205 -0
- package/src/shared/graph/agent-meta.mjs +55 -5
- package/src/shared/graph/constants.mjs +14 -2
- package/src/shared/graph/flow-layout.mjs +2 -1
- package/src/shared/graph/isomorphic.mjs +5 -3
- package/src/shared/graph/manifest.mjs +22 -13
- package/src/shared/graph/ports.mjs +45 -19
- package/src/shared/graph/script-cases.mjs +257 -0
- package/src/shared/graph/script-icons.mjs +46 -0
- package/src/shared/graph/script-infer.mjs +259 -0
- package/src/shared/graph/script-meta.mjs +408 -0
- package/src/shared/graph/script-templates.mjs +201 -0
- package/src/shared/graph/template.mjs +4 -4
- package/src/shared/graph/validate.mjs +89 -16
- package/src/shared/human-estimate.mjs +100 -0
- package/src/shared/schedule/recurrence.mjs +353 -0
- package/src/shared/team-metrics/aggregate.mjs +51 -11
- package/{scripts → tools}/install.mjs +3 -3
- package/ui/public/app.js +4390 -683
- package/ui/public/artifact-picker.mjs +189 -0
- package/ui/public/ask/dom.mjs +121 -0
- package/ui/public/ask/form-preview.mjs +55 -0
- package/ui/public/ask/form-renderer.mjs +250 -0
- package/ui/public/ask/registry.mjs +53 -0
- package/ui/public/ask/widgets-display.mjs +370 -0
- package/ui/public/ask/widgets-input.mjs +624 -0
- package/ui/public/ask/widgets-layout.mjs +90 -0
- package/ui/public/ask-panel.mjs +401 -27
- package/ui/public/ask-run-card.mjs +1 -1
- package/ui/public/bridge-view.mjs +694 -0
- package/ui/public/chat-settings-view.mjs +24 -0
- package/ui/public/code-editor.mjs +181 -0
- package/ui/public/getting-started.mjs +34 -7
- package/ui/public/graph/composer.mjs +138 -10
- package/ui/public/graph/inspector.mjs +61 -58
- package/ui/public/graph/palette.mjs +27 -9
- package/ui/public/graph/run-decor.mjs +34 -17
- package/ui/public/graph/run-hosts.mjs +7 -1
- package/ui/public/graph/save-dialog.mjs +3 -0
- package/ui/public/graph/view.mjs +23 -7
- package/ui/public/guardrails-view.mjs +15 -3
- package/ui/public/guide-spot.mjs +87 -9
- package/ui/public/index.html +480 -141
- package/ui/public/memory-view.mjs +22 -4
- package/ui/public/models-view.mjs +162 -17
- package/ui/public/node-tunables.mjs +33 -4
- package/ui/public/plugins-view.mjs +23 -1
- package/ui/public/results-view.mjs +4 -2
- package/ui/public/schedule-sheet.mjs +430 -0
- package/ui/public/schedules-view.mjs +432 -0
- package/ui/public/script-bench-view.mjs +1154 -0
- package/ui/public/script-forms.mjs +282 -0
- package/ui/public/script-wizard.mjs +529 -0
- package/ui/public/scripts-view.mjs +868 -0
- package/ui/public/stats-view.mjs +159 -52
- package/ui/public/style.css +1461 -44
- package/ui/public/team-metrics-surfaces.mjs +77 -16
- package/ui/public/team-metrics-view.mjs +68 -5
- package/ui/public/team-policy-view.mjs +1402 -0
- package/ui/public/ui-level.mjs +237 -0
- package/ui/server.mjs +1827 -73
|
@@ -0,0 +1,563 @@
|
|
|
1
|
+
// src/core/script-store.mjs
|
|
2
|
+
// CRUD for USER scripts: <worcaHome()>/scripts/<key>.meta.json + the program
|
|
3
|
+
// file the sidecar names (+ an optional <key>.tests.json), layered under the
|
|
4
|
+
// read-only built-in scripts/ dir and the plugin layers by script-registry.mjs.
|
|
5
|
+
// The sibling of agent-store.mjs: same err.code vocabulary, so ui/server.mjs
|
|
6
|
+
// reuses agentErrorStatus. Validation + persistence live here (thin-core
|
|
7
|
+
// pattern); HTTP mapping is the route's.
|
|
8
|
+
//
|
|
9
|
+
// Two rules are this file's own:
|
|
10
|
+
// - `meta.file` is STORE-OWNED. A client-sent `file` is ignored and recomputed
|
|
11
|
+
// from key + runtime, so a sidecar can never name a file the store did not
|
|
12
|
+
// write, and a runtime change renames the program.
|
|
13
|
+
// - Writes are atomic per file and ORDERED source -> cases -> meta (delete
|
|
14
|
+
// runs meta -> cases -> source), so a crash mid-save never leaves a loadable
|
|
15
|
+
// meta pointing at a program that is not there.
|
|
16
|
+
|
|
17
|
+
import { mkdir, open, readFile, writeFile, rm, rename } from 'node:fs/promises';
|
|
18
|
+
import { join } from 'node:path';
|
|
19
|
+
import { loadScriptRegistry, userScriptsDir } from './script-registry.mjs';
|
|
20
|
+
import { loadAgentRegistry } from './agent-registry.mjs';
|
|
21
|
+
import { AGENT_KEY_RE } from './agent-store.mjs';
|
|
22
|
+
import { listWorkflows } from './workflows.mjs';
|
|
23
|
+
import { normalizeScriptMeta, validateScriptMetaV2, RESERVED_SCRIPT_KEYS, hasParamsPort } from '../shared/graph/script-meta.mjs';
|
|
24
|
+
import { normalizeCases } from '../shared/graph/script-cases.mjs';
|
|
25
|
+
import { AWAIT_PORT, PARAMS_PORT } from '../shared/graph/constants.mjs'; // the synthesized gate port and the opt-in params port are wirable
|
|
26
|
+
|
|
27
|
+
export { userScriptsDir }; // single source: the registry's layer resolver
|
|
28
|
+
|
|
29
|
+
/** Keys are ONE namespace with agents (base D16), so the regex is the agents'. */
|
|
30
|
+
export const SCRIPT_KEY_RE = AGENT_KEY_RE;
|
|
31
|
+
/** The reserved keys live in the shared module so the page refuses exactly what the store does. */
|
|
32
|
+
export { RESERVED_SCRIPT_KEYS };
|
|
33
|
+
/** Windows resolves these stems as devices even with an extension (`con.mjs` IS
|
|
34
|
+
* the console), so a script keyed like one writes to the device on a Windows
|
|
35
|
+
* host and can never be read back. */
|
|
36
|
+
const WIN_DEVICE_RE = /^(con|prn|aux|nul|com[1-9]|lpt[1-9])$/i;
|
|
37
|
+
export const MAX_SOURCE_BYTES = 262144;
|
|
38
|
+
|
|
39
|
+
const RENAME_RETRIES = 3;
|
|
40
|
+
const RENAME_DELAY_MS = 50;
|
|
41
|
+
|
|
42
|
+
function err(message, code) { return Object.assign(new Error(message), { code }); }
|
|
43
|
+
const sourceTooBig = (key) => `the program of "${key}" is over ${MAX_SOURCE_BYTES} bytes and was read short `
|
|
44
|
+
+ '— shrink it on disk before saving it from worca';
|
|
45
|
+
const sourceNotRead = (key) => `the program of "${key}" could not be read `
|
|
46
|
+
+ '— fix it on disk before saving it from worca';
|
|
47
|
+
const isObject = (v) => !!v && typeof v === 'object' && !Array.isArray(v);
|
|
48
|
+
const byteLength = (s) => Buffer.byteLength(String(s ?? ''), 'utf8');
|
|
49
|
+
const delay = (ms) => new Promise((r) => { setTimeout(r, ms); });
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* An explicit `null` in a sent meta means "remove this key". The update path
|
|
53
|
+
* merges the sent meta OVER the stored one, so without this a form could edit
|
|
54
|
+
* `verdict`, `exitCodes`, `command` or the two port shapes but never clear
|
|
55
|
+
* them — and a shell script switched to node would keep a `command` the
|
|
56
|
+
* validator refuses. The sidecar validator itself reads null as a bad value, so
|
|
57
|
+
* the keys are dropped BEFORE it runs. Mutates and returns `raw`. The bench
|
|
58
|
+
* uses it on a draft meta, which comes off the very same form.
|
|
59
|
+
*/
|
|
60
|
+
export function stripNullKeys(raw) {
|
|
61
|
+
for (const k of Object.keys(raw)) if (raw[k] === null) delete raw[k];
|
|
62
|
+
return raw;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Line endings are the STORE's business, not the editor's: a <textarea> hands
|
|
66
|
+
// back LF whatever it was given (the HTML value API normalises CRLF away), so
|
|
67
|
+
// the page can never send a CR. cmd.exe wants CRLF in a .cmd (a lone LF breaks
|
|
68
|
+
// multi-line constructs on older Windows), and a CR inside a .sh is a syntax
|
|
69
|
+
// error to /bin/sh — each file gets its own platform's ending on every write.
|
|
70
|
+
const toCrlf = (s) => String(s).replace(/\r\n|\r|\n/g, '\r\n');
|
|
71
|
+
const toLf = (s) => String(s).replace(/\r\n|\r/g, '\n');
|
|
72
|
+
/** A program's text as it must sit ON DISK: CRLF for a shell script's win32
|
|
73
|
+
* variant, LF for its default one, untouched for every other runtime. The
|
|
74
|
+
* bench writes its draft files through this too. */
|
|
75
|
+
export function programText(runtime, text, { win32 = false } = {}) {
|
|
76
|
+
if (runtime !== 'shell') return String(text);
|
|
77
|
+
return win32 ? toCrlf(text) : toLf(text);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** The writable user layer. null only when the home cannot be resolved. */
|
|
81
|
+
function requireUserDir() {
|
|
82
|
+
const dir = userScriptsDir();
|
|
83
|
+
if (!dir) throw err('cannot resolve the user scripts directory (WORCA_HOME unset?)', 'BAD_REQUEST');
|
|
84
|
+
return dir;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** The merged registry as the engine sees it (agent keys already dropped, D16). */
|
|
88
|
+
// A registry is a PLAIN object, so `reg[key]` reaches Object.prototype: `constructor`,
|
|
89
|
+
// `toString`, `valueOf` and `hasOwnProperty` all pass SCRIPT_KEY_RE (it is the agent
|
|
90
|
+
// key shape, D16) and answer with a Function every guard below then reads as an
|
|
91
|
+
// existing script — a 200 where the contract says 404, and a write on the PUT paths.
|
|
92
|
+
const pick = (reg, key) => (Object.hasOwn(reg, key) ? reg[key] : null);
|
|
93
|
+
function registryNow() {
|
|
94
|
+
return loadScriptRegistry({ agentKeys: Object.keys(loadAgentRegistry()) });
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** Every script key ON DISK, including one an agent key would shadow — the
|
|
98
|
+
* DUPLICATE guard must see it, or "create" would silently write a dead file. */
|
|
99
|
+
function everyScriptKey() {
|
|
100
|
+
return loadScriptRegistry({ agentKeys: null });
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** The program filename the store owns for a runtime. */
|
|
104
|
+
export function sourceFileFor(key, runtime, { win32 = false } = {}) {
|
|
105
|
+
if (runtime === 'node') return `${key}.mjs`;
|
|
106
|
+
if (runtime === 'python') return `${key}.py`; // P2 ships the runtime; the name is fixed now
|
|
107
|
+
if (runtime === 'shell') return win32 ? `${key}.cmd` : `${key}.sh`;
|
|
108
|
+
return null;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const casesFileFor = (key) => `${key}.tests.json`;
|
|
112
|
+
|
|
113
|
+
/** The `file` field's two platform entries, whatever shape it has. */
|
|
114
|
+
function platformNames(file) {
|
|
115
|
+
if (typeof file === 'string') return { def: file, win32: null };
|
|
116
|
+
if (isObject(file)) return { def: file.default ?? null, win32: file.win32 ?? null };
|
|
117
|
+
return { def: null, win32: null };
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Write one file atomically: a sibling temp + rename. On Windows `rename` is
|
|
122
|
+
* MoveFileEx(REPLACE_EXISTING), which an antivirus scanner or a just-closed
|
|
123
|
+
* handle can answer with EPERM/EBUSY for a few milliseconds — retry before
|
|
124
|
+
* surfacing it (§10).
|
|
125
|
+
*/
|
|
126
|
+
let tmpSeq = 0;
|
|
127
|
+
async function writeAtomic(path, text) {
|
|
128
|
+
// The counter, not the pid alone: two writes of ONE file inside this process
|
|
129
|
+
// would otherwise share the temp path, and the slower rename lands on a name
|
|
130
|
+
// the faster one already moved (ENOENT).
|
|
131
|
+
tmpSeq += 1;
|
|
132
|
+
const tmp = `${path}.tmp-${process.pid}-${tmpSeq}`;
|
|
133
|
+
await writeFile(tmp, text, 'utf8');
|
|
134
|
+
for (let attempt = 0; ; attempt += 1) {
|
|
135
|
+
try {
|
|
136
|
+
await rename(tmp, path);
|
|
137
|
+
return;
|
|
138
|
+
} catch (e) {
|
|
139
|
+
if ((e?.code === 'EPERM' || e?.code === 'EBUSY') && attempt < RENAME_RETRIES) {
|
|
140
|
+
await delay(RENAME_DELAY_MS);
|
|
141
|
+
continue;
|
|
142
|
+
}
|
|
143
|
+
await rm(tmp, { force: true }).catch(() => {});
|
|
144
|
+
throw e;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// `recursive` so a DIRECTORY sitting where a program (or the cases file) belongs
|
|
150
|
+
// cannot throw here: deleteScript removes the meta first, so a throw on the
|
|
151
|
+
// program left the script gone from worca, the junk on disk, and the retry a 404.
|
|
152
|
+
const removeFile = (path) => rm(path, { force: true, recursive: true, maxRetries: RENAME_RETRIES });
|
|
153
|
+
// ...but ONLY for a name the store mints itself. A name READ OFF the sidecar on disk
|
|
154
|
+
// is user-editable: the basename rule admits ".", and the registry's containment
|
|
155
|
+
// check covers the host platform's entry alone, so `{ default: "x.sh", win32: "." }`
|
|
156
|
+
// loads on macOS — and a recursive forced rm of join(dir, ".") deletes EVERY user
|
|
157
|
+
// script while answering { ok: true }. Such a name is removed as a plain file; a
|
|
158
|
+
// directory in its place is left where it is.
|
|
159
|
+
const removeNamedFile = async (path) => {
|
|
160
|
+
try { await rm(path, { force: true, maxRetries: RENAME_RETRIES }); }
|
|
161
|
+
catch (e) { if (e?.code !== 'ERR_FS_EISDIR' && e?.code !== 'EISDIR') throw e; }
|
|
162
|
+
};
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* One script's files are written (or removed) one save at a time. Two saves of
|
|
166
|
+
* the SAME key in flight at once — a double-clicked Save, a save landing on a
|
|
167
|
+
* delete — otherwise interleave their source/cases/meta writes and leave one
|
|
168
|
+
* writer's program under the other's meta, while both responses report their own
|
|
169
|
+
* bytes. Keyed by `<dir>/<key>`, so different scripts still save in parallel.
|
|
170
|
+
*/
|
|
171
|
+
const writing = new Map();
|
|
172
|
+
function underKeyLock(lockKey, fn) {
|
|
173
|
+
const done = (writing.get(lockKey) || Promise.resolve()).then(fn, fn);
|
|
174
|
+
const settled = done.then(() => {}, () => {});
|
|
175
|
+
writing.set(lockKey, settled);
|
|
176
|
+
settled.then(() => { if (writing.get(lockKey) === settled) writing.delete(lockKey); });
|
|
177
|
+
return done;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/** 'ui' | 'cli' | 'ask:<threadId>' (W19). Absent -> 'ui'. */
|
|
181
|
+
function byStamp(by) {
|
|
182
|
+
const v = typeof by === 'string' && by.trim() ? by.trim() : 'ui';
|
|
183
|
+
if (v === 'ui' || v === 'cli' || /^ask:[A-Za-z0-9_-]{1,64}$/.test(v)) return v;
|
|
184
|
+
throw err('by must be "ui", "cli" or "ask:<threadId>"', 'BAD_REQUEST');
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/** Read + normalize one `<key>.tests.json`. A missing or unreadable file is no
|
|
188
|
+
* cases; an invalid ROW is dropped by the normalizer (the store's write path is
|
|
189
|
+
* the hard gate, this one only reads). */
|
|
190
|
+
async function readCasesAt(dir, key, meta, shipped) {
|
|
191
|
+
if (!dir) return [];
|
|
192
|
+
let raw;
|
|
193
|
+
try { raw = JSON.parse(await readFile(join(dir, casesFileFor(key)), 'utf8')); } catch { return []; }
|
|
194
|
+
// lenient: a case must survive an edit to its script's ports and params (see normalizeCases).
|
|
195
|
+
return normalizeCases(raw, meta, { shipped, lenient: true }).cases;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/** The script's own cases + the W18 user overlay (empty on the user layer). */
|
|
199
|
+
async function bothCaseLists(meta) {
|
|
200
|
+
const shipped = meta.origin !== 'user';
|
|
201
|
+
const own = await readCasesAt(meta.scriptsDir, meta.key, meta, shipped);
|
|
202
|
+
const overlay = shipped ? await readCasesAt(userScriptsDir(), meta.key, meta, false) : [];
|
|
203
|
+
return { own, overlay };
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/** Merged registry list, each meta stamped with how many cases it has. */
|
|
207
|
+
export async function listScripts() {
|
|
208
|
+
const out = [];
|
|
209
|
+
for (const meta of Object.values(registryNow())) {
|
|
210
|
+
const { own, overlay } = await bothCaseLists(meta);
|
|
211
|
+
out.push({ ...meta, caseCount: own.length + overlay.length });
|
|
212
|
+
}
|
|
213
|
+
return out;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* One program file, read at most MAX_SOURCE_BYTES + 1 bytes deep. `readFile`
|
|
218
|
+
* pulled a hand-placed 600 MB program into the process whole — and past ~512 MiB
|
|
219
|
+
* it throws "Invalid string length", which the catch then read as "no program",
|
|
220
|
+
* after which a meta-only save of a shell script DROPPED the user's file. The cut
|
|
221
|
+
* is in BYTES: `String.slice` counts UTF-16 units, so a multi-byte program came
|
|
222
|
+
* back whole and over the cap. TextDecoder in stream mode keeps a partial
|
|
223
|
+
* trailing sequence to itself instead of turning it into U+FFFD, and `ignoreBOM`
|
|
224
|
+
* leaves a byte-order mark in the text the page saves back.
|
|
225
|
+
* A read that FAILED is not "no program": ENOENT is (the file is gone), but a
|
|
226
|
+
* permission accident, EMFILE under load or a directory in the program's place
|
|
227
|
+
* would otherwise hand the page an empty Source tab, and the next save drops
|
|
228
|
+
* `file` and deletes the user's program — `unreadable` blocks that save.
|
|
229
|
+
* @returns {Promise<{text: string, truncated: boolean, unreadable: boolean}>}
|
|
230
|
+
*/
|
|
231
|
+
async function readProgram(file) {
|
|
232
|
+
let fh = null;
|
|
233
|
+
try {
|
|
234
|
+
fh = await open(file, 'r');
|
|
235
|
+
const buf = Buffer.allocUnsafe(MAX_SOURCE_BYTES + 1);
|
|
236
|
+
let filled = 0;
|
|
237
|
+
while (filled < buf.length) {
|
|
238
|
+
const { bytesRead } = await fh.read(buf, filled, buf.length - filled, filled);
|
|
239
|
+
if (!bytesRead) break;
|
|
240
|
+
filled += bytesRead;
|
|
241
|
+
}
|
|
242
|
+
const decoder = new TextDecoder('utf-8', { ignoreBOM: true });
|
|
243
|
+
const truncated = filled > MAX_SOURCE_BYTES;
|
|
244
|
+
// Stream mode ONLY when the bytes were cut: it holds a partial trailing
|
|
245
|
+
// sequence back instead of emitting U+FFFD. On a whole file that same hold
|
|
246
|
+
// would DELETE the last character of a program that is not valid UTF-8 (a
|
|
247
|
+
// latin-1 `.sh`), silently, with `truncated` false — and the page saves back
|
|
248
|
+
// the text it was handed.
|
|
249
|
+
const text = decoder.decode(buf.subarray(0, Math.min(filled, MAX_SOURCE_BYTES)), { stream: truncated });
|
|
250
|
+
return { text, truncated, unreadable: false };
|
|
251
|
+
} catch (e) {
|
|
252
|
+
return { text: '', truncated: false, unreadable: e?.code !== 'ENOENT' };
|
|
253
|
+
} finally {
|
|
254
|
+
if (fh) await fh.close().catch(() => {});
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/** Full read: meta + both program files + both case lists, or null. */
|
|
259
|
+
export async function readScript(key) {
|
|
260
|
+
if (!SCRIPT_KEY_RE.test(String(key || ''))) return null;
|
|
261
|
+
const meta = pick(registryNow(), key);
|
|
262
|
+
if (!meta) return null;
|
|
263
|
+
const { def, win32 } = platformNames(meta.file);
|
|
264
|
+
const dir = meta.scriptsDir;
|
|
265
|
+
// Read by NAME, not by the host-resolved scriptPath: `source` must be the
|
|
266
|
+
// DEFAULT entry and `sourceWin32` the win32 one on every host, or the Source
|
|
267
|
+
// tab would show the .cmd to a Windows user and call it the .sh.
|
|
268
|
+
let source = '';
|
|
269
|
+
let sourceTruncated = false;
|
|
270
|
+
let sourceUnreadable = false;
|
|
271
|
+
if (def) {
|
|
272
|
+
const read = await readProgram(join(dir, def));
|
|
273
|
+
source = read.text;
|
|
274
|
+
sourceTruncated = read.truncated;
|
|
275
|
+
sourceUnreadable = read.unreadable;
|
|
276
|
+
}
|
|
277
|
+
let sourceWin32 = '';
|
|
278
|
+
if (win32 && win32 !== def) {
|
|
279
|
+
const read = await readProgram(join(dir, win32));
|
|
280
|
+
sourceWin32 = read.text;
|
|
281
|
+
// The .cmd rides the same cap, and the same flags: the page saves back the pair
|
|
282
|
+
// it was handed, so a short win32 variant must block the save just as loudly.
|
|
283
|
+
if (read.truncated) sourceTruncated = true;
|
|
284
|
+
if (read.unreadable) sourceUnreadable = true;
|
|
285
|
+
}
|
|
286
|
+
const { own, overlay } = await bothCaseLists(meta);
|
|
287
|
+
return {
|
|
288
|
+
meta,
|
|
289
|
+
source,
|
|
290
|
+
sourceWin32,
|
|
291
|
+
sourcePath: meta.scriptPath || null,
|
|
292
|
+
sourceTruncated,
|
|
293
|
+
sourceUnreadable,
|
|
294
|
+
cases: own,
|
|
295
|
+
userCases: overlay,
|
|
296
|
+
casesWritable: true,
|
|
297
|
+
};
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/** The key shapes no script may take, whatever layer it would land in: the
|
|
301
|
+
* reserved route segments in ANY case, and the Windows device stems. Exported
|
|
302
|
+
* because the bench's draft guard must refuse exactly what a Save refuses (C29)
|
|
303
|
+
* — a second copy of the list is how the two drifted apart in the first place. */
|
|
304
|
+
export function assertKeyAllowed(key) {
|
|
305
|
+
if (RESERVED_SCRIPT_KEYS.includes(key.toLowerCase())) {
|
|
306
|
+
throw err(`"${key}" is a reserved script key — pick another name`, 'BAD_REQUEST');
|
|
307
|
+
}
|
|
308
|
+
if (WIN_DEVICE_RE.test(key)) {
|
|
309
|
+
throw err(`"${key}" is a reserved device name on Windows — pick another key`, 'BAD_REQUEST');
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
/** BUILTIN / DUPLICATE / PLUGIN for a key that must be FREE (create, duplicate). */
|
|
314
|
+
function assertKeyFree(key) {
|
|
315
|
+
const all = everyScriptKey();
|
|
316
|
+
// macOS and Windows filesystems are case-INSENSITIVE (§10): `lint` and `Lint`
|
|
317
|
+
// are two registry keys but ONE `<key>.meta.json` and ONE program file, so a
|
|
318
|
+
// key differing only in case would overwrite the other script without a word.
|
|
319
|
+
const twin = Object.keys(all).find((k) => k !== key && k.toLowerCase() === key.toLowerCase());
|
|
320
|
+
if (twin) {
|
|
321
|
+
throw err(`a script "${twin}" already exists — script keys differ only in case, and one file holds both `
|
|
322
|
+
+ 'on macOS and Windows', 'DUPLICATE');
|
|
323
|
+
}
|
|
324
|
+
const existing = pick(all, key);
|
|
325
|
+
if (existing) {
|
|
326
|
+
if (existing.origin === 'builtin') throw err(`"${key}" is a built-in script — duplicate it under a new name instead`, 'BUILTIN');
|
|
327
|
+
const origin = String(existing.origin || '');
|
|
328
|
+
if (origin.startsWith('plugin:')) {
|
|
329
|
+
throw err(`script "${key}" is shipped by plugin "${origin.slice('plugin:'.length)}" — pick another key`, 'DUPLICATE');
|
|
330
|
+
}
|
|
331
|
+
throw err(`a user script "${key}" already exists`, 'DUPLICATE');
|
|
332
|
+
}
|
|
333
|
+
if (pick(loadAgentRegistry(), key)) {
|
|
334
|
+
throw err(`"${key}" is an agent key — scripts and agents share one namespace, so pick another key`, 'DUPLICATE');
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
/** NOT_FOUND / BUILTIN / PLUGIN for a key that must be WRITABLE (update, delete). */
|
|
339
|
+
function requireUserScript(key, verb) {
|
|
340
|
+
if (!SCRIPT_KEY_RE.test(String(key || ''))) throw err(`script not found: ${key}`, 'NOT_FOUND');
|
|
341
|
+
const existing = pick(registryNow(), key);
|
|
342
|
+
if (existing && existing.origin === 'builtin') {
|
|
343
|
+
throw err(verb === 'delete'
|
|
344
|
+
? `"${key}" is a built-in script and cannot be deleted — duplicate it under a new name instead`
|
|
345
|
+
: `"${key}" is a built-in script — duplicate it instead of editing`, 'BUILTIN');
|
|
346
|
+
}
|
|
347
|
+
const origin = String(existing?.origin || '');
|
|
348
|
+
if (origin.startsWith('plugin:')) {
|
|
349
|
+
throw err(`script "${key}" is managed by plugin "${origin.slice('plugin:'.length)}" — disable or uninstall the plugin instead`, 'PLUGIN');
|
|
350
|
+
}
|
|
351
|
+
if (!existing) throw err(`script not found: ${key}`, 'NOT_FOUND');
|
|
352
|
+
return existing;
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
/**
|
|
356
|
+
* The store-owned `file` + the source rules (spec §3.1). Returns the names to
|
|
357
|
+
* write; `raw.file` is REPLACED here on every path.
|
|
358
|
+
* @returns {{def: string|null, win32: string|null, source: string, sourceWin32: string}}
|
|
359
|
+
*/
|
|
360
|
+
function applyFileRules(raw, key, source, sourceWin32) {
|
|
361
|
+
const src = typeof source === 'string' ? source : '';
|
|
362
|
+
const win = typeof sourceWin32 === 'string' ? sourceWin32 : '';
|
|
363
|
+
if (byteLength(src) > MAX_SOURCE_BYTES) throw err(`source is over ${MAX_SOURCE_BYTES} bytes`, 'BAD_REQUEST');
|
|
364
|
+
if (byteLength(win) > MAX_SOURCE_BYTES) throw err(`sourceWin32 is over ${MAX_SOURCE_BYTES} bytes`, 'BAD_REQUEST');
|
|
365
|
+
const runtime = raw.runtime;
|
|
366
|
+
if (win && runtime !== 'shell') throw err('sourceWin32 is only legal on the shell runtime', 'BAD_REQUEST');
|
|
367
|
+
if (runtime === 'node' || runtime === 'python') {
|
|
368
|
+
if (!src.trim()) throw err(`runtime "${runtime}" needs a program — the source cannot be empty`, 'BAD_REQUEST');
|
|
369
|
+
raw.file = sourceFileFor(key, runtime);
|
|
370
|
+
return { def: raw.file, win32: null, source: src, sourceWin32: '' };
|
|
371
|
+
}
|
|
372
|
+
if (runtime === 'shell') {
|
|
373
|
+
if (win.trim() && !src.trim()) throw err('sourceWin32 needs a shell file — add the default source too', 'BAD_REQUEST');
|
|
374
|
+
if (!src.trim()) { raw.file = null; return { def: null, win32: null, source: '', sourceWin32: '' }; }
|
|
375
|
+
const def = sourceFileFor(key, 'shell');
|
|
376
|
+
const w = win.trim() ? sourceFileFor(key, 'shell', { win32: true }) : null;
|
|
377
|
+
raw.file = w ? { default: def, win32: w } : def;
|
|
378
|
+
return { def, win32: w, source: programText('shell', src), sourceWin32: w ? programText('shell', win, { win32: true }) : '' };
|
|
379
|
+
}
|
|
380
|
+
raw.file = null; // unknown runtime: the validator names it
|
|
381
|
+
return { def: null, win32: null, source: src, sourceWin32: '' };
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
/** Validate a prepared raw sidecar exactly as the plugin validator does. */
|
|
385
|
+
function gate(raw) {
|
|
386
|
+
const issues = validateScriptMetaV2(raw).errors;
|
|
387
|
+
if (issues.length) throw err(issues.join('; '), 'BAD_REQUEST');
|
|
388
|
+
const { meta } = normalizeScriptMeta(raw, { warn: () => {} });
|
|
389
|
+
if (!meta) throw err('invalid script metadata', 'BAD_REQUEST');
|
|
390
|
+
return meta;
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
/**
|
|
394
|
+
* Saved-template wires that the NEW port set of `key` no longer satisfies —
|
|
395
|
+
* `["<workflow name> (<nodeId>.<portId>)", …]`. NOT refused (no saved template
|
|
396
|
+
* can reference a port before it exists); the RUN refuses, this is the editor's
|
|
397
|
+
* heads-up. Agent parity (agent-store.mjs#stalePortRefs), with `kind:'script'`
|
|
398
|
+
* and the `ports: "config"` scripts skipped: their ports live on the NODE, so a
|
|
399
|
+
* sidecar edit cannot stale them.
|
|
400
|
+
*/
|
|
401
|
+
function staleScriptRefs(workflows, key, meta) {
|
|
402
|
+
if (meta.ports === 'config') return [];
|
|
403
|
+
const outs = new Set((meta.outputs || []).map((p) => p.id));
|
|
404
|
+
const ins = new Set([...(meta.inputs || []).map((p) => p.id), AWAIT_PORT.id]);
|
|
405
|
+
const hits = [];
|
|
406
|
+
for (const wf of workflows) {
|
|
407
|
+
const mine = new Map((wf.nodes || []).filter((n) => n && n.kind === 'script' && n.key === key).map((n) => [n.id, n]));
|
|
408
|
+
if (!mine.size) continue;
|
|
409
|
+
const label = wf.name || wf.id;
|
|
410
|
+
// The engine `params` port lives on the NODE (config.paramsPort) and on the meta (a wirable param) at once.
|
|
411
|
+
const hasIn = (nodeId, port) => ins.has(port) || (port === PARAMS_PORT.id && hasParamsPort(meta, mine.get(nodeId)?.config));
|
|
412
|
+
for (const w of wf.wires || []) {
|
|
413
|
+
if (mine.has(w?.from?.node) && !outs.has(w.from.port)) hits.push(`${label} (${w.from.node}.${w.from.port})`);
|
|
414
|
+
if (mine.has(w?.to?.node) && !hasIn(w.to.node, w.to.port)) hits.push(`${label} (${w.to.node}.${w.to.port})`);
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
return hits;
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
/** Write one script's files in the crash-safe order: source -> cases -> meta. */
|
|
421
|
+
async function persist(dir, key, meta, files, { cases = null, drop = [] } = {}) {
|
|
422
|
+
await mkdir(dir, { recursive: true });
|
|
423
|
+
return underKeyLock(join(dir, key), async () => {
|
|
424
|
+
if (files.def) await writeAtomic(join(dir, files.def), files.source);
|
|
425
|
+
if (files.win32) await writeAtomic(join(dir, files.win32), files.sourceWin32);
|
|
426
|
+
if (cases && cases.length) await writeAtomic(join(dir, casesFileFor(key)), JSON.stringify({ version: 1, cases }, null, 2) + '\n');
|
|
427
|
+
await writeAtomic(join(dir, `${key}.meta.json`), JSON.stringify(meta, null, 2) + '\n');
|
|
428
|
+
// Only AFTER the meta names the new program: a file the sidecar no longer uses
|
|
429
|
+
// (a runtime change, a dropped win32 variant) is dead weight, never a gap.
|
|
430
|
+
for (const name of drop) if (name) await removeFile(join(dir, name));
|
|
431
|
+
});
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
/** Create a user script. `meta.key` is required and must be free in EVERY layer. */
|
|
435
|
+
export async function createScript({ meta: rawMeta, source, sourceWin32, by } = {}) {
|
|
436
|
+
const raw = stripNullKeys(isObject(rawMeta) ? { ...rawMeta } : {});
|
|
437
|
+
const key = typeof raw.key === 'string' ? raw.key.trim() : '';
|
|
438
|
+
if (!SCRIPT_KEY_RE.test(key)) throw err('script key must be alphanumeric (letters, digits, - or _)', 'BAD_REQUEST');
|
|
439
|
+
assertKeyAllowed(key);
|
|
440
|
+
const stamp = byStamp(by);
|
|
441
|
+
assertKeyFree(key);
|
|
442
|
+
raw.key = key;
|
|
443
|
+
if (!Number.isFinite(Number(raw.order))) raw.order = 99; // sort after the built-ins
|
|
444
|
+
raw.createdBy = stamp;
|
|
445
|
+
raw.updatedBy = stamp;
|
|
446
|
+
const files = applyFileRules(raw, key, source, sourceWin32);
|
|
447
|
+
const meta = gate(raw);
|
|
448
|
+
await persist(requireUserDir(), key, meta, files);
|
|
449
|
+
return { meta: { ...meta, origin: 'user' }, source: files.source, sourceWin32: files.sourceWin32 };
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
/** Update a user script (meta and/or either source). Built-ins -> BUILTIN (409). */
|
|
453
|
+
export async function updateScript(key, { meta: rawMeta, source, sourceWin32, by } = {}) {
|
|
454
|
+
const existing = requireUserScript(key, 'update');
|
|
455
|
+
const stamp = byStamp(by);
|
|
456
|
+
const current = await readScript(key);
|
|
457
|
+
// A program the READ had to cut at the cap (only a hand-edited file can be over
|
|
458
|
+
// it) must never be written back: the page saves the source it was handed, so
|
|
459
|
+
// even renaming the script would drop everything past MAX_SOURCE_BYTES.
|
|
460
|
+
if (current.sourceTruncated) throw err(sourceTooBig(key), 'BAD_REQUEST');
|
|
461
|
+
if (current.sourceUnreadable) throw err(sourceNotRead(key), 'BAD_REQUEST');
|
|
462
|
+
// The COMPUTED fields the registry stamps are not part of a sidecar;
|
|
463
|
+
// normalizeScriptMeta's fixed key set drops them, but spreading them into the
|
|
464
|
+
// raw would let a client's `scriptPath` reach the validator.
|
|
465
|
+
const base = { ...existing };
|
|
466
|
+
for (const f of ['origin', 'scriptPath', 'scriptsDir', 'commandResolved', 'frontmatter']) delete base[f];
|
|
467
|
+
// Sent keys win; a sent `null` REMOVES the stored key (see stripNullKeys).
|
|
468
|
+
const raw = stripNullKeys({ ...base, ...(isObject(rawMeta) ? rawMeta : {}) });
|
|
469
|
+
raw.key = key; // key immutable on update
|
|
470
|
+
raw.createdBy = existing.createdBy || stamp;
|
|
471
|
+
raw.updatedBy = stamp;
|
|
472
|
+
if (!Number.isFinite(Number(raw.order))) raw.order = existing.order;
|
|
473
|
+
// `undefined` keeps what is on disk; '' is a deliberate "no file" (an inline
|
|
474
|
+
// shell command, or dropping the Windows variant).
|
|
475
|
+
const nextSource = typeof source === 'string' ? source : current.source;
|
|
476
|
+
const nextWin = typeof sourceWin32 === 'string' ? sourceWin32 : current.sourceWin32;
|
|
477
|
+
const files = applyFileRules(raw, key, nextSource, nextWin);
|
|
478
|
+
const meta = gate(raw);
|
|
479
|
+
const was = platformNames(existing.file);
|
|
480
|
+
const drop = [was.def, was.win32].filter((n) => n && n !== files.def && n !== files.win32);
|
|
481
|
+
await persist(requireUserDir(), key, meta, files, { drop });
|
|
482
|
+
const stale = staleScriptRefs(await listWorkflows({ includeArchived: true }), key, meta);
|
|
483
|
+
const warnings = stale.length ? [`saved pipelines reference a removed port: ${stale.join(', ')}`] : [];
|
|
484
|
+
return { meta: { ...meta, origin: 'user' }, source: files.source, sourceWin32: files.sourceWin32, warnings };
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
/** Delete a user script; REFERENCED (409) while a saved workflow places the key. */
|
|
488
|
+
export async function deleteScript(key) {
|
|
489
|
+
const existing = requireUserScript(key, 'delete');
|
|
490
|
+
const refs = (await listWorkflows({ includeArchived: true }))
|
|
491
|
+
.filter((wf) => (wf.nodes || []).some((n) => n && n.kind === 'script' && n.key === key))
|
|
492
|
+
.map((wf) => wf.name || wf.id);
|
|
493
|
+
if (refs.length) {
|
|
494
|
+
throw err(`script "${key}" is used by saved workflow(s): ${refs.join(', ')} `
|
|
495
|
+
+ '— delete or edit those first (archived rows count)', 'REFERENCED');
|
|
496
|
+
}
|
|
497
|
+
const dir = requireUserDir();
|
|
498
|
+
const { def, win32 } = platformNames(existing.file);
|
|
499
|
+
await underKeyLock(join(dir, key), async () => {
|
|
500
|
+
// meta FIRST: after this line the registry no longer lists the script, so a
|
|
501
|
+
// crash leaves orphan files, never a sidecar naming a program that is gone.
|
|
502
|
+
await removeFile(join(dir, `${key}.meta.json`));
|
|
503
|
+
await removeFile(join(dir, casesFileFor(key)));
|
|
504
|
+
// Every entry the sidecar names (not just default + win32), each by the rule above.
|
|
505
|
+
const minted = new Set(['node', 'python', 'shell'].map((rt) => sourceFileFor(key, rt))
|
|
506
|
+
.concat(sourceFileFor(key, 'shell', { win32: true })));
|
|
507
|
+
const named = new Set([def, win32, ...(isObject(existing.file) ? Object.values(existing.file) : [])]
|
|
508
|
+
.filter((n) => typeof n === 'string' && n));
|
|
509
|
+
for (const name of named) await (minted.has(name) ? removeFile : removeNamedFile)(join(dir, name));
|
|
510
|
+
});
|
|
511
|
+
return { ok: true };
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
/** Copy ANY layer's script into the user layer under `newKey`, cases included. */
|
|
515
|
+
export async function duplicateScript(key, newKey, by) {
|
|
516
|
+
const target = typeof newKey === 'string' ? newKey.trim() : '';
|
|
517
|
+
if (!SCRIPT_KEY_RE.test(target)) throw err('script key must be alphanumeric (letters, digits, - or _)', 'BAD_REQUEST');
|
|
518
|
+
assertKeyAllowed(target);
|
|
519
|
+
const stamp = byStamp(by);
|
|
520
|
+
const src = await readScript(key);
|
|
521
|
+
if (!src) throw err(`script not found: ${key}`, 'NOT_FOUND');
|
|
522
|
+
if (src.sourceTruncated) throw err(sourceTooBig(key), 'BAD_REQUEST');
|
|
523
|
+
if (src.sourceUnreadable) throw err(sourceNotRead(key), 'BAD_REQUEST');
|
|
524
|
+
assertKeyFree(target);
|
|
525
|
+
const raw = { ...src.meta };
|
|
526
|
+
for (const f of ['origin', 'scriptPath', 'scriptsDir', 'commandResolved', 'portSummary']) delete raw[f];
|
|
527
|
+
raw.key = target;
|
|
528
|
+
raw.createdBy = stamp;
|
|
529
|
+
raw.updatedBy = stamp;
|
|
530
|
+
const files = applyFileRules(raw, target, src.source, src.sourceWin32);
|
|
531
|
+
const meta = gate(raw);
|
|
532
|
+
// The script's own cases plus the user's overlay for it; an overlay id that
|
|
533
|
+
// collides with a shipped one loses (the shipped case is the original).
|
|
534
|
+
const taken = new Set(src.cases.map((c) => c.id));
|
|
535
|
+
const cases = [...src.cases, ...src.userCases.filter((c) => !taken.has(c.id))];
|
|
536
|
+
await persist(requireUserDir(), target, meta, files, { cases });
|
|
537
|
+
return { meta: { ...meta, origin: 'user' }, source: files.source, sourceWin32: files.sourceWin32 };
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
/**
|
|
541
|
+
* Write a script's cases. ALWAYS the user layer: for a user script that is its
|
|
542
|
+
* own file, for a built-in or plugin key it is the W18 overlay — a
|
|
543
|
+
* `<key>.tests.json` with no meta beside it, which the registry's *.meta.json
|
|
544
|
+
* scan ignores by construction.
|
|
545
|
+
* @param {string} key @param {Array|object} rawCases the array, or a { version, cases } document
|
|
546
|
+
*/
|
|
547
|
+
export async function writeCases(key, rawCases) {
|
|
548
|
+
if (!SCRIPT_KEY_RE.test(String(key || ''))) throw err(`script not found: ${key}`, 'NOT_FOUND');
|
|
549
|
+
const meta = pick(registryNow(), key);
|
|
550
|
+
if (!meta) throw err(`script not found: ${key}`, 'NOT_FOUND');
|
|
551
|
+
const doc = Array.isArray(rawCases) ? { version: 1, cases: rawCases } : rawCases;
|
|
552
|
+
if (!isObject(doc) || !Array.isArray(doc.cases)) throw err('cases must be an array', 'BAD_REQUEST');
|
|
553
|
+
const { cases, errors } = normalizeCases({ version: 1, cases: doc.cases }, meta, { shipped: false });
|
|
554
|
+
if (errors.length) throw err(errors.join('; '), 'BAD_REQUEST');
|
|
555
|
+
const dir = requireUserDir();
|
|
556
|
+
await mkdir(dir, { recursive: true });
|
|
557
|
+
const path = join(dir, casesFileFor(key));
|
|
558
|
+
await underKeyLock(join(dir, key), async () => {
|
|
559
|
+
if (!cases.length) await removeFile(path);
|
|
560
|
+
else await writeAtomic(path, JSON.stringify({ version: 1, cases }, null, 2) + '\n');
|
|
561
|
+
});
|
|
562
|
+
return { cases };
|
|
563
|
+
}
|