@mulmoclaude/core 1.0.1 → 1.2.1
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/assets/helps/collection-skills.md +28 -0
- package/dist/artifacts/paths.cjs +68 -0
- package/dist/artifacts/paths.cjs.map +1 -0
- package/dist/artifacts/paths.d.ts +45 -0
- package/dist/artifacts/paths.js +62 -0
- package/dist/artifacts/paths.js.map +1 -0
- package/dist/atomic-C_7YpMiM.cjs +102 -0
- package/dist/atomic-C_7YpMiM.cjs.map +1 -0
- package/dist/atomic-DPpdrJzO.js +89 -0
- package/dist/atomic-DPpdrJzO.js.map +1 -0
- package/dist/collection/core/chatSeed.d.ts +4 -0
- package/dist/collection/core/completion.d.ts +53 -0
- package/dist/collection/core/ids.d.ts +10 -0
- package/dist/collection/core/linkTargets.d.ts +14 -0
- package/dist/collection/core/ownProp.d.ts +9 -0
- package/dist/collection/core/recordKeys.d.ts +21 -0
- package/dist/collection/core/schemaRules.d.ts +167 -0
- package/dist/collection/core/schemaZ.d.ts +489 -9
- package/dist/collection/core/sortValueOf.d.ts +21 -0
- package/dist/collection/core/textSearch.d.ts +12 -0
- package/dist/collection/index.cjs +132 -29
- package/dist/collection/index.cjs.map +1 -1
- package/dist/collection/index.d.ts +5 -0
- package/dist/collection/index.js +115 -29
- package/dist/collection/index.js.map +1 -1
- package/dist/collection/registry/server/index.cjs +14 -42
- package/dist/collection/registry/server/index.cjs.map +1 -1
- package/dist/collection/registry/server/index.js +6 -34
- package/dist/collection/registry/server/index.js.map +1 -1
- package/dist/collection/server/discovery.d.ts +7 -0
- package/dist/collection/server/host.d.ts +3 -8
- package/dist/collection/server/index.cjs +10 -9
- package/dist/collection/server/index.d.ts +1 -0
- package/dist/collection/server/index.js +4 -4
- package/dist/collection/server/io.d.ts +0 -88
- package/dist/collection/server/skillAssets.d.ts +90 -0
- package/dist/collection-watchers/config.d.ts +2 -4
- package/dist/collection-watchers/index.cjs +5 -5
- package/dist/collection-watchers/index.cjs.map +1 -1
- package/dist/collection-watchers/index.js +5 -5
- package/dist/collection-watchers/index.js.map +1 -1
- package/dist/{discovery-Dpz6SWwe.cjs → discovery--kRUWed0.cjs} +444 -434
- package/dist/discovery--kRUWed0.cjs.map +1 -0
- package/dist/{discovery-BYIsLqag.js → discovery-CKR4TOX3.js} +426 -392
- package/dist/discovery-CKR4TOX3.js.map +1 -0
- package/dist/dist-Cwk0e12G.js +50 -0
- package/dist/dist-Cwk0e12G.js.map +1 -0
- package/dist/dist-pWpC-b04.cjs +61 -0
- package/dist/dist-pWpC-b04.cjs.map +1 -0
- package/dist/feeds/index.cjs +2 -2
- package/dist/feeds/index.js +2 -2
- package/dist/feeds/server/host.d.ts +5 -8
- package/dist/feeds/server/index.cjs +143 -61
- package/dist/feeds/server/index.cjs.map +1 -1
- package/dist/feeds/server/index.js +141 -59
- package/dist/feeds/server/index.js.map +1 -1
- package/dist/files/atomic.d.ts +34 -0
- package/dist/files/index.cjs +47 -0
- package/dist/files/index.cjs.map +1 -0
- package/dist/files/index.d.ts +3 -0
- package/dist/files/index.js +40 -0
- package/dist/files/index.js.map +1 -0
- package/dist/files/json.d.ts +4 -0
- package/dist/files/safe.d.ts +9 -0
- package/dist/google/auth.d.ts +1 -1
- package/dist/google/collectionDateTime.d.ts +5 -0
- package/dist/google/collectionSync.d.ts +2 -2
- package/dist/google/fsJson.d.ts +3 -2
- package/dist/google/host.d.ts +3 -6
- package/dist/google/index.cjs +64 -91
- package/dist/google/index.cjs.map +1 -1
- package/dist/google/index.d.ts +1 -0
- package/dist/google/index.js +57 -85
- package/dist/google/index.js.map +1 -1
- package/dist/{graph-o5FZZbXf.js → graph-BcwqiSpR.js} +2 -14
- package/dist/graph-BcwqiSpR.js.map +1 -0
- package/dist/{graph-2R4HbQAg.cjs → graph-DjE1Az1C.cjs} +2 -14
- package/dist/graph-DjE1Az1C.cjs.map +1 -0
- package/dist/host/hostSlot.d.ts +27 -0
- package/dist/{ids-DWmHjm17.cjs → ids-BnTh3Hl0.cjs} +29 -1
- package/dist/{ids-DWmHjm17.cjs.map → ids-BnTh3Hl0.cjs.map} +1 -1
- package/dist/{ids-D1M1T6KJ.js → ids-s4GfmE93.js} +18 -2
- package/dist/{ids-D1M1T6KJ.js.map → ids-s4GfmE93.js.map} +1 -1
- package/dist/{ingestTypes-Ci4eOgv-.js → ingestTypes-L59cIGgX.js} +2 -2
- package/dist/{ingestTypes-Ci4eOgv-.js.map → ingestTypes-L59cIGgX.js.map} +1 -1
- package/dist/{ingestTypes-DbuQNuK6.cjs → ingestTypes-xX8VpQqh.cjs} +2 -2
- package/dist/{ingestTypes-DbuQNuK6.cjs.map → ingestTypes-xX8VpQqh.cjs.map} +1 -1
- package/dist/notifier/engine.d.ts +2 -4
- package/dist/notifier/index.cjs +1 -1
- package/dist/notifier/index.js +1 -1
- package/dist/{notifier-ChpY0XrY.js → notifier-BdA5qzhe.js} +14 -16
- package/dist/notifier-BdA5qzhe.js.map +1 -0
- package/dist/{notifier-bS8IEeLA.cjs → notifier-tMsAXyXp.cjs} +14 -16
- package/dist/notifier-tMsAXyXp.cjs.map +1 -0
- package/dist/plugin-vue/fileWatch.d.ts +5 -0
- package/dist/plugin-vue/i18n.cjs +45 -0
- package/dist/plugin-vue/i18n.cjs.map +1 -0
- package/dist/plugin-vue/i18n.js +44 -0
- package/dist/plugin-vue/i18n.js.map +1 -0
- package/dist/plugin-vue/index.cjs +98 -0
- package/dist/plugin-vue/index.cjs.map +1 -0
- package/dist/plugin-vue/index.d.ts +5 -0
- package/dist/plugin-vue/index.js +91 -0
- package/dist/plugin-vue/index.js.map +1 -0
- package/dist/plugin-vue/markdownDoc.d.ts +10 -0
- package/dist/plugin-vue/pluginI18n.d.ts +22 -0
- package/dist/plugin-vue/useClipboardCopy.d.ts +6 -0
- package/dist/plugin-vue/useFileWatch.d.ts +8 -0
- package/dist/plugin-vue/useMarkdownDoc.d.ts +3 -0
- package/dist/{promptSafety-Bugq2kqL.js → promptSafety-CQ5Un4wi.js} +104 -7
- package/dist/promptSafety-CQ5Un4wi.js.map +1 -0
- package/dist/{promptSafety-DbE6eZmP.cjs → promptSafety-DfZYNtjK.cjs} +151 -6
- package/dist/promptSafety-DfZYNtjK.cjs.map +1 -0
- package/dist/remote-host/server/hostRunner.d.ts +5 -1
- package/dist/remote-host/server/index.cjs +75 -21
- package/dist/remote-host/server/index.cjs.map +1 -1
- package/dist/remote-host/server/index.js +72 -18
- package/dist/remote-host/server/index.js.map +1 -1
- package/dist/scheduler/index.cjs +3 -3
- package/dist/scheduler/index.cjs.map +1 -1
- package/dist/scheduler/index.js +2 -2
- package/dist/scheduler/index.js.map +1 -1
- package/dist/scheduler/task-manager.d.ts +2 -5
- package/dist/{server-CtCBLYgZ.js → server-BZJCw2HU.js} +209 -41
- package/dist/server-BZJCw2HU.js.map +1 -0
- package/dist/{server-BjWrcc5T.cjs → server-w5ZZRLju.cjs} +250 -46
- package/dist/server-w5ZZRLju.cjs.map +1 -0
- package/dist/skill-bridge/index.cjs +1 -1
- package/dist/skill-bridge/index.js +1 -1
- package/dist/translation/client.cjs +16 -0
- package/dist/translation/client.cjs.map +1 -1
- package/dist/translation/client.d.ts +6 -0
- package/dist/translation/client.js +16 -1
- package/dist/translation/client.js.map +1 -1
- package/dist/utils/errors.d.ts +2 -1
- package/dist/utils/fetch.cjs +36 -0
- package/dist/utils/fetch.cjs.map +1 -0
- package/dist/{collection/registry/server → utils}/fetch.d.ts +5 -3
- package/dist/utils/fetch.js +34 -0
- package/dist/utils/fetch.js.map +1 -0
- package/dist/utils/index.cjs +11 -3
- package/dist/utils/index.cjs.map +1 -0
- package/dist/utils/index.js +9 -1
- package/dist/utils/index.js.map +1 -0
- package/dist/whisper/index.cjs +9 -9
- package/dist/whisper/index.cjs.map +1 -1
- package/dist/whisper/index.js +3 -3
- package/dist/wiki/index.cjs +23 -28
- package/dist/wiki/index.cjs.map +1 -1
- package/dist/wiki/index.js +21 -26
- package/dist/wiki/index.js.map +1 -1
- package/dist/wiki/link.d.ts +7 -1
- package/dist/wiki/render.d.ts +5 -7
- package/dist/wiki/server/frontmatter.d.ts +2 -6
- package/dist/wiki/server/index.cjs +5 -41
- package/dist/wiki/server/index.cjs.map +1 -1
- package/dist/wiki/server/index.js +3 -39
- package/dist/wiki/server/index.js.map +1 -1
- package/dist/workspace-setup/index.js +22 -6
- package/dist/workspace-setup/index.js.map +1 -1
- package/package.json +54 -11
- package/dist/collection/server/atomic.d.ts +0 -1
- package/dist/discovery-BYIsLqag.js.map +0 -1
- package/dist/discovery-Dpz6SWwe.cjs.map +0 -1
- package/dist/errors-7P5eMOSX.cjs +0 -30
- package/dist/errors-7P5eMOSX.cjs.map +0 -1
- package/dist/errors-eid6Mes3.js +0 -19
- package/dist/errors-eid6Mes3.js.map +0 -1
- package/dist/google/fetch.d.ts +0 -8
- package/dist/graph-2R4HbQAg.cjs.map +0 -1
- package/dist/graph-o5FZZbXf.js.map +0 -1
- package/dist/notifier-ChpY0XrY.js.map +0 -1
- package/dist/notifier-bS8IEeLA.cjs.map +0 -1
- package/dist/promptSafety-Bugq2kqL.js.map +0 -1
- package/dist/promptSafety-DbE6eZmP.cjs.map +0 -1
- package/dist/server-BjWrcc5T.cjs.map +0 -1
- package/dist/server-CtCBLYgZ.js.map +0 -1
|
@@ -146,6 +146,7 @@ skipped, never crashes the host):
|
|
|
146
146
|
| `calendarTimeField` | Optional. Name of a string field holding a free-form time or time-range (`"14:00-17:00"`, `"17:00-"`, `"16:30"`) used to place records on the calendar's **day (time-allocation) view**. Consulted only when the date fields are date-only — a `datetime` anchor/end pair carries its own clock and takes precedence. Requires `calendarField`. See "Calendar view" below. |
|
|
147
147
|
| `kanbanField` | Optional. Name of an `enum` field that groups records into columns on the **Kanban board** (one column per declared value). When unset, the Kanban toggle still appears if the schema has any `enum` field — the first one is used, switchable in-view. Set this to pin a specific group field. Must name a real `enum` field. See "Kanban view" below. |
|
|
148
148
|
| `views` | Optional. Custom (LLM-authored) HTML views: `[{ id, label, icon?, file, capabilities?, target? }]`. Each renders an HTML file under `views/*.html` in a sandboxed iframe over the records, for layouts the built-ins don't cover (year/quarter overview, Gantt, report). `capabilities` is `["read"]` (default) or `["read","write"]`. `target: "mobile"` makes it a **remote view** for the phone app (different runtime contract — **`config/helps/custom-view-remote.md`**). See "Custom views" below and **`config/helps/custom-view.md`** for the desktop authoring contract. |
|
|
149
|
+
| `googleCalendar` | Optional. `{ "calendarId": "primary", "map": { "<field>": "summary" } }` — the host mirrors one of the user's **Google calendars** into this collection on a schedule, **LLM-free**: no tool call, no tokens per sync. Reach for it whenever the user asks for a collection that syncs with Google Calendar — never an `ingest.kind: "agent"` worker or the `google` MCP tools, which spend an LLM turn per refresh doing what the host does for free. Needs `dataPath`. See "Google Calendar sync" below and **`config/helps/google-calendar-collection.md`**. |
|
|
149
150
|
|
|
150
151
|
### Field types
|
|
151
152
|
|
|
@@ -710,6 +711,33 @@ Reach for `ingest.kind: "agent"` (not a `manageAutomations` task) whenever the
|
|
|
710
711
|
schedule belongs to one collection: it travels with the schema, dies with the
|
|
711
712
|
collection, and needs no separate setup.
|
|
712
713
|
|
|
714
|
+
### Google Calendar sync (`googleCalendar`)
|
|
715
|
+
|
|
716
|
+
A collection opts into **Google Calendar sync** the same way a feed opts into
|
|
717
|
+
retrieval — by declaring a block. Add `googleCalendar` and the host pulls the
|
|
718
|
+
user's changed events on a schedule and writes them as records **without calling
|
|
719
|
+
you**: no tool call, no tokens per sync.
|
|
720
|
+
|
|
721
|
+
```json
|
|
722
|
+
"googleCalendar": {
|
|
723
|
+
"calendarId": "primary",
|
|
724
|
+
"map": { "title": "summary", "on": "start", "until": "end" }
|
|
725
|
+
}
|
|
726
|
+
```
|
|
727
|
+
|
|
728
|
+
This is the answer whenever the user asks for a collection that syncs with
|
|
729
|
+
Google Calendar — **not** an `ingest.kind: "agent"` worker and **not** the
|
|
730
|
+
`google` MCP calendar tools, both of which spend an LLM turn on every refresh to
|
|
731
|
+
produce what the host produces for free. `map` reads _your_ field name → the
|
|
732
|
+
Google event field (`summary`, `start`, `end`, `htmlLink`, `colorId`, `status`);
|
|
733
|
+
at least one entry is required. Never map the `primaryKey` — it always holds the
|
|
734
|
+
Google event id, which is what makes a re-sync update a record instead of
|
|
735
|
+
duplicating it.
|
|
736
|
+
|
|
737
|
+
The rest of the contract — finding a non-primary `calendarId`, how deletions
|
|
738
|
+
propagate, and the first-run caveat — is
|
|
739
|
+
**`config/helps/google-calendar-collection.md`**. Read it before authoring one.
|
|
740
|
+
|
|
713
741
|
### Calendar view
|
|
714
742
|
|
|
715
743
|
Any collection that has at least one `date` (or `datetime`) field gains a
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
|
|
2
|
+
//#region src/artifacts/paths.ts
|
|
3
|
+
var MAX_SLUG_LEN = 120;
|
|
4
|
+
/** The workspace directory every artifact lives under (`<workspace>/artifacts`). */
|
|
5
|
+
var ARTIFACTS_ROOT = "artifacts";
|
|
6
|
+
/**
|
|
7
|
+
* Lowercase-ASCII slug for a throwaway, timestamped artifact filename. Empty,
|
|
8
|
+
* whitespace-only, and non-ASCII-only titles fall back to `fallback`. Capped at
|
|
9
|
+
* 120 chars so a long LLM title can't blow the filesystem's NAME_MAX.
|
|
10
|
+
*
|
|
11
|
+
* Leading/trailing hyphens are stripped with a linear scan rather than a regex
|
|
12
|
+
* like `/^-+|-+$/` — CodeQL flags the trailing-anchor form as polynomial
|
|
13
|
+
* backtracking on the attacker-influenced (LLM-provided) title. Strip → cap →
|
|
14
|
+
* strip so a cut at the 120-char boundary can't re-expose a trailing hyphen.
|
|
15
|
+
*/
|
|
16
|
+
function slugifyArtifact(title, fallback) {
|
|
17
|
+
if (!title) return fallback;
|
|
18
|
+
const collapsed = title.toLowerCase().replace(/[^a-z0-9]+/g, "-");
|
|
19
|
+
let start = 0;
|
|
20
|
+
let end = collapsed.length;
|
|
21
|
+
while (start < end && collapsed[start] === "-") start += 1;
|
|
22
|
+
while (end > start && collapsed[end - 1] === "-") end -= 1;
|
|
23
|
+
if (end - start > MAX_SLUG_LEN) end = start + MAX_SLUG_LEN;
|
|
24
|
+
while (end > start && collapsed[end - 1] === "-") end -= 1;
|
|
25
|
+
return collapsed.slice(start, end) || fallback;
|
|
26
|
+
}
|
|
27
|
+
/** UTC `YYYY/MM` partition (matches the host's #764 artifact sharding). UTC —
|
|
28
|
+
* not local — so a workspace synced across timezones stays in one bucket. */
|
|
29
|
+
function yearMonthUtc(now = /* @__PURE__ */ new Date()) {
|
|
30
|
+
return `${now.getUTCFullYear()}/${String(now.getUTCMonth() + 1).padStart(2, "0")}`;
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* FileOps-relative artifact path: `<dir>[/YYYY/MM]/<slug>-<epochMs><ext>`. The
|
|
34
|
+
* `<epochMs>` suffix keeps freshly-built filenames collision-free without a
|
|
35
|
+
* random component. This is what `files.artifacts.write` takes; prefix it with
|
|
36
|
+
* `toWorkspaceArtifactPath` for the workspace-relative form shown to the LLM.
|
|
37
|
+
*/
|
|
38
|
+
function buildArtifactRelPath(params) {
|
|
39
|
+
const { dir, title, ext, fallback, now = /* @__PURE__ */ new Date(), partitioned = true } = params;
|
|
40
|
+
const fileName = `${slugifyArtifact(title, fallback)}-${now.getTime()}${ext}`;
|
|
41
|
+
return (partitioned ? [
|
|
42
|
+
dir,
|
|
43
|
+
yearMonthUtc(now),
|
|
44
|
+
fileName
|
|
45
|
+
] : [dir, fileName]).join("/");
|
|
46
|
+
}
|
|
47
|
+
/** Prefix a FileOps-relative artifact path with the workspace `artifacts/` root. */
|
|
48
|
+
function toWorkspaceArtifactPath(relPath) {
|
|
49
|
+
return `${ARTIFACTS_ROOT}/${relPath}`;
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* True when any `/`-segment of `value` is empty (`//`, leading/trailing slash),
|
|
53
|
+
* `.`, or `..`. The lexical traversal / non-canonical guard every artifact path
|
|
54
|
+
* check shares — equivalent to `path.posix.normalize(v) === v && !v.includes("..")`
|
|
55
|
+
* — so a workspace-escape judgement can't drift between plugins.
|
|
56
|
+
*/
|
|
57
|
+
function hasUnsafePathSegment(value) {
|
|
58
|
+
return value.split("/").some((seg) => seg === "" || seg === "." || seg === "..");
|
|
59
|
+
}
|
|
60
|
+
//#endregion
|
|
61
|
+
exports.ARTIFACTS_ROOT = ARTIFACTS_ROOT;
|
|
62
|
+
exports.buildArtifactRelPath = buildArtifactRelPath;
|
|
63
|
+
exports.hasUnsafePathSegment = hasUnsafePathSegment;
|
|
64
|
+
exports.slugifyArtifact = slugifyArtifact;
|
|
65
|
+
exports.toWorkspaceArtifactPath = toWorkspaceArtifactPath;
|
|
66
|
+
exports.yearMonthUtc = yearMonthUtc;
|
|
67
|
+
|
|
68
|
+
//# sourceMappingURL=paths.cjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"paths.cjs","names":[],"sources":["../../src/artifacts/paths.ts"],"sourcesContent":["// Shared artifact-path builders for the presentation plugins (chart / html /\n// mulmoscript). Browser-safe by design: no node:path / no node:crypto, so it\n// bundles into both the server core and the browser (`./vue`) plugin entries.\n//\n// These are POSIX artifact *wire paths* — stored in JSON and used as the\n// generic `files.artifacts` FileOps keys — so they must ALWAYS join with `/`\n// regardless of host OS. `path.join` would emit `\\` on Windows and corrupt\n// them; `path.posix.join` would drag in node:path and break the browser\n// bundle. So already-sanitised segments are joined with `/` directly (same\n// choice the plugins made before this module existed).\n\nconst MAX_SLUG_LEN = 120;\n\n/** The workspace directory every artifact lives under (`<workspace>/artifacts`). */\nexport const ARTIFACTS_ROOT = \"artifacts\";\n\n/**\n * Lowercase-ASCII slug for a throwaway, timestamped artifact filename. Empty,\n * whitespace-only, and non-ASCII-only titles fall back to `fallback`. Capped at\n * 120 chars so a long LLM title can't blow the filesystem's NAME_MAX.\n *\n * Leading/trailing hyphens are stripped with a linear scan rather than a regex\n * like `/^-+|-+$/` — CodeQL flags the trailing-anchor form as polynomial\n * backtracking on the attacker-influenced (LLM-provided) title. Strip → cap →\n * strip so a cut at the 120-char boundary can't re-expose a trailing hyphen.\n */\nexport function slugifyArtifact(title: string | undefined, fallback: string): string {\n if (!title) return fallback;\n const collapsed = title.toLowerCase().replace(/[^a-z0-9]+/g, \"-\");\n let start = 0;\n let end = collapsed.length;\n while (start < end && collapsed[start] === \"-\") start += 1;\n while (end > start && collapsed[end - 1] === \"-\") end -= 1;\n if (end - start > MAX_SLUG_LEN) end = start + MAX_SLUG_LEN;\n while (end > start && collapsed[end - 1] === \"-\") end -= 1;\n return collapsed.slice(start, end) || fallback;\n}\n\n/** UTC `YYYY/MM` partition (matches the host's #764 artifact sharding). UTC —\n * not local — so a workspace synced across timezones stays in one bucket. */\nexport function yearMonthUtc(now: Date = new Date()): string {\n const year = now.getUTCFullYear();\n const month = String(now.getUTCMonth() + 1).padStart(2, \"0\");\n return `${year}/${month}`;\n}\n\nexport interface ArtifactRelPathParams {\n /** Artifact-kind directory under the artifacts root (e.g. `charts`, `html`, `stories`). */\n dir: string;\n /** Human title the slug is derived from; empty/non-ASCII falls back to `fallback`. */\n title: string | undefined;\n /** File extension INCLUDING the leading dot (e.g. `.html`, `.chart.json`). */\n ext: string;\n /** Slug used when `title` yields nothing (e.g. `chart`, `page`, `story`). */\n fallback: string;\n now?: Date;\n /** Include the `YYYY/MM` partition segment. Default true; stories opt out. */\n partitioned?: boolean;\n}\n\n/**\n * FileOps-relative artifact path: `<dir>[/YYYY/MM]/<slug>-<epochMs><ext>`. The\n * `<epochMs>` suffix keeps freshly-built filenames collision-free without a\n * random component. This is what `files.artifacts.write` takes; prefix it with\n * `toWorkspaceArtifactPath` for the workspace-relative form shown to the LLM.\n */\nexport function buildArtifactRelPath(params: ArtifactRelPathParams): string {\n const { dir, title, ext, fallback, now = new Date(), partitioned = true } = params;\n const fileName = `${slugifyArtifact(title, fallback)}-${now.getTime()}${ext}`;\n const segments = partitioned ? [dir, yearMonthUtc(now), fileName] : [dir, fileName];\n return segments.join(\"/\");\n}\n\n/** Prefix a FileOps-relative artifact path with the workspace `artifacts/` root. */\nexport function toWorkspaceArtifactPath(relPath: string): string {\n return `${ARTIFACTS_ROOT}/${relPath}`;\n}\n\n/**\n * True when any `/`-segment of `value` is empty (`//`, leading/trailing slash),\n * `.`, or `..`. The lexical traversal / non-canonical guard every artifact path\n * check shares — equivalent to `path.posix.normalize(v) === v && !v.includes(\"..\")`\n * — so a workspace-escape judgement can't drift between plugins.\n */\nexport function hasUnsafePathSegment(value: string): boolean {\n return value.split(\"/\").some((seg) => seg === \"\" || seg === \".\" || seg === \"..\");\n}\n"],"mappings":";;AAWA,IAAM,eAAe;;AAGrB,IAAa,iBAAiB;;;;;;;;;;;AAY9B,SAAgB,gBAAgB,OAA2B,UAA0B;CACnF,IAAI,CAAC,OAAO,OAAO;CACnB,MAAM,YAAY,MAAM,YAAY,CAAC,CAAC,QAAQ,eAAe,GAAG;CAChE,IAAI,QAAQ;CACZ,IAAI,MAAM,UAAU;CACpB,OAAO,QAAQ,OAAO,UAAU,WAAW,KAAK,SAAS;CACzD,OAAO,MAAM,SAAS,UAAU,MAAM,OAAO,KAAK,OAAO;CACzD,IAAI,MAAM,QAAQ,cAAc,MAAM,QAAQ;CAC9C,OAAO,MAAM,SAAS,UAAU,MAAM,OAAO,KAAK,OAAO;CACzD,OAAO,UAAU,MAAM,OAAO,GAAG,KAAK;AACxC;;;AAIA,SAAgB,aAAa,sBAAY,IAAI,KAAK,GAAW;CAG3D,OAAO,GAFM,IAAI,eAEP,EAAK,GADD,OAAO,IAAI,YAAY,IAAI,CAAC,CAAC,CAAC,SAAS,GAAG,GACtC;AACpB;;;;;;;AAsBA,SAAgB,qBAAqB,QAAuC;CAC1E,MAAM,EAAE,KAAK,OAAO,KAAK,UAAU,sBAAM,IAAI,KAAK,GAAG,cAAc,SAAS;CAC5E,MAAM,WAAW,GAAG,gBAAgB,OAAO,QAAQ,EAAE,GAAG,IAAI,QAAQ,IAAI;CAExE,QADiB,cAAc;EAAC;EAAK,aAAa,GAAG;EAAG;CAAQ,IAAI,CAAC,KAAK,QAAQ,EAAA,CAClE,KAAK,GAAG;AAC1B;;AAGA,SAAgB,wBAAwB,SAAyB;CAC/D,OAAO,GAAG,eAAe,GAAG;AAC9B;;;;;;;AAQA,SAAgB,qBAAqB,OAAwB;CAC3D,OAAO,MAAM,MAAM,GAAG,CAAC,CAAC,MAAM,QAAQ,QAAQ,MAAM,QAAQ,OAAO,QAAQ,IAAI;AACjF"}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/** The workspace directory every artifact lives under (`<workspace>/artifacts`). */
|
|
2
|
+
export declare const ARTIFACTS_ROOT = "artifacts";
|
|
3
|
+
/**
|
|
4
|
+
* Lowercase-ASCII slug for a throwaway, timestamped artifact filename. Empty,
|
|
5
|
+
* whitespace-only, and non-ASCII-only titles fall back to `fallback`. Capped at
|
|
6
|
+
* 120 chars so a long LLM title can't blow the filesystem's NAME_MAX.
|
|
7
|
+
*
|
|
8
|
+
* Leading/trailing hyphens are stripped with a linear scan rather than a regex
|
|
9
|
+
* like `/^-+|-+$/` — CodeQL flags the trailing-anchor form as polynomial
|
|
10
|
+
* backtracking on the attacker-influenced (LLM-provided) title. Strip → cap →
|
|
11
|
+
* strip so a cut at the 120-char boundary can't re-expose a trailing hyphen.
|
|
12
|
+
*/
|
|
13
|
+
export declare function slugifyArtifact(title: string | undefined, fallback: string): string;
|
|
14
|
+
/** UTC `YYYY/MM` partition (matches the host's #764 artifact sharding). UTC —
|
|
15
|
+
* not local — so a workspace synced across timezones stays in one bucket. */
|
|
16
|
+
export declare function yearMonthUtc(now?: Date): string;
|
|
17
|
+
export interface ArtifactRelPathParams {
|
|
18
|
+
/** Artifact-kind directory under the artifacts root (e.g. `charts`, `html`, `stories`). */
|
|
19
|
+
dir: string;
|
|
20
|
+
/** Human title the slug is derived from; empty/non-ASCII falls back to `fallback`. */
|
|
21
|
+
title: string | undefined;
|
|
22
|
+
/** File extension INCLUDING the leading dot (e.g. `.html`, `.chart.json`). */
|
|
23
|
+
ext: string;
|
|
24
|
+
/** Slug used when `title` yields nothing (e.g. `chart`, `page`, `story`). */
|
|
25
|
+
fallback: string;
|
|
26
|
+
now?: Date;
|
|
27
|
+
/** Include the `YYYY/MM` partition segment. Default true; stories opt out. */
|
|
28
|
+
partitioned?: boolean;
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* FileOps-relative artifact path: `<dir>[/YYYY/MM]/<slug>-<epochMs><ext>`. The
|
|
32
|
+
* `<epochMs>` suffix keeps freshly-built filenames collision-free without a
|
|
33
|
+
* random component. This is what `files.artifacts.write` takes; prefix it with
|
|
34
|
+
* `toWorkspaceArtifactPath` for the workspace-relative form shown to the LLM.
|
|
35
|
+
*/
|
|
36
|
+
export declare function buildArtifactRelPath(params: ArtifactRelPathParams): string;
|
|
37
|
+
/** Prefix a FileOps-relative artifact path with the workspace `artifacts/` root. */
|
|
38
|
+
export declare function toWorkspaceArtifactPath(relPath: string): string;
|
|
39
|
+
/**
|
|
40
|
+
* True when any `/`-segment of `value` is empty (`//`, leading/trailing slash),
|
|
41
|
+
* `.`, or `..`. The lexical traversal / non-canonical guard every artifact path
|
|
42
|
+
* check shares — equivalent to `path.posix.normalize(v) === v && !v.includes("..")`
|
|
43
|
+
* — so a workspace-escape judgement can't drift between plugins.
|
|
44
|
+
*/
|
|
45
|
+
export declare function hasUnsafePathSegment(value: string): boolean;
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
//#region src/artifacts/paths.ts
|
|
2
|
+
var MAX_SLUG_LEN = 120;
|
|
3
|
+
/** The workspace directory every artifact lives under (`<workspace>/artifacts`). */
|
|
4
|
+
var ARTIFACTS_ROOT = "artifacts";
|
|
5
|
+
/**
|
|
6
|
+
* Lowercase-ASCII slug for a throwaway, timestamped artifact filename. Empty,
|
|
7
|
+
* whitespace-only, and non-ASCII-only titles fall back to `fallback`. Capped at
|
|
8
|
+
* 120 chars so a long LLM title can't blow the filesystem's NAME_MAX.
|
|
9
|
+
*
|
|
10
|
+
* Leading/trailing hyphens are stripped with a linear scan rather than a regex
|
|
11
|
+
* like `/^-+|-+$/` — CodeQL flags the trailing-anchor form as polynomial
|
|
12
|
+
* backtracking on the attacker-influenced (LLM-provided) title. Strip → cap →
|
|
13
|
+
* strip so a cut at the 120-char boundary can't re-expose a trailing hyphen.
|
|
14
|
+
*/
|
|
15
|
+
function slugifyArtifact(title, fallback) {
|
|
16
|
+
if (!title) return fallback;
|
|
17
|
+
const collapsed = title.toLowerCase().replace(/[^a-z0-9]+/g, "-");
|
|
18
|
+
let start = 0;
|
|
19
|
+
let end = collapsed.length;
|
|
20
|
+
while (start < end && collapsed[start] === "-") start += 1;
|
|
21
|
+
while (end > start && collapsed[end - 1] === "-") end -= 1;
|
|
22
|
+
if (end - start > MAX_SLUG_LEN) end = start + MAX_SLUG_LEN;
|
|
23
|
+
while (end > start && collapsed[end - 1] === "-") end -= 1;
|
|
24
|
+
return collapsed.slice(start, end) || fallback;
|
|
25
|
+
}
|
|
26
|
+
/** UTC `YYYY/MM` partition (matches the host's #764 artifact sharding). UTC —
|
|
27
|
+
* not local — so a workspace synced across timezones stays in one bucket. */
|
|
28
|
+
function yearMonthUtc(now = /* @__PURE__ */ new Date()) {
|
|
29
|
+
return `${now.getUTCFullYear()}/${String(now.getUTCMonth() + 1).padStart(2, "0")}`;
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* FileOps-relative artifact path: `<dir>[/YYYY/MM]/<slug>-<epochMs><ext>`. The
|
|
33
|
+
* `<epochMs>` suffix keeps freshly-built filenames collision-free without a
|
|
34
|
+
* random component. This is what `files.artifacts.write` takes; prefix it with
|
|
35
|
+
* `toWorkspaceArtifactPath` for the workspace-relative form shown to the LLM.
|
|
36
|
+
*/
|
|
37
|
+
function buildArtifactRelPath(params) {
|
|
38
|
+
const { dir, title, ext, fallback, now = /* @__PURE__ */ new Date(), partitioned = true } = params;
|
|
39
|
+
const fileName = `${slugifyArtifact(title, fallback)}-${now.getTime()}${ext}`;
|
|
40
|
+
return (partitioned ? [
|
|
41
|
+
dir,
|
|
42
|
+
yearMonthUtc(now),
|
|
43
|
+
fileName
|
|
44
|
+
] : [dir, fileName]).join("/");
|
|
45
|
+
}
|
|
46
|
+
/** Prefix a FileOps-relative artifact path with the workspace `artifacts/` root. */
|
|
47
|
+
function toWorkspaceArtifactPath(relPath) {
|
|
48
|
+
return `${ARTIFACTS_ROOT}/${relPath}`;
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* True when any `/`-segment of `value` is empty (`//`, leading/trailing slash),
|
|
52
|
+
* `.`, or `..`. The lexical traversal / non-canonical guard every artifact path
|
|
53
|
+
* check shares — equivalent to `path.posix.normalize(v) === v && !v.includes("..")`
|
|
54
|
+
* — so a workspace-escape judgement can't drift between plugins.
|
|
55
|
+
*/
|
|
56
|
+
function hasUnsafePathSegment(value) {
|
|
57
|
+
return value.split("/").some((seg) => seg === "" || seg === "." || seg === "..");
|
|
58
|
+
}
|
|
59
|
+
//#endregion
|
|
60
|
+
export { ARTIFACTS_ROOT, buildArtifactRelPath, hasUnsafePathSegment, slugifyArtifact, toWorkspaceArtifactPath, yearMonthUtc };
|
|
61
|
+
|
|
62
|
+
//# sourceMappingURL=paths.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"paths.js","names":[],"sources":["../../src/artifacts/paths.ts"],"sourcesContent":["// Shared artifact-path builders for the presentation plugins (chart / html /\n// mulmoscript). Browser-safe by design: no node:path / no node:crypto, so it\n// bundles into both the server core and the browser (`./vue`) plugin entries.\n//\n// These are POSIX artifact *wire paths* — stored in JSON and used as the\n// generic `files.artifacts` FileOps keys — so they must ALWAYS join with `/`\n// regardless of host OS. `path.join` would emit `\\` on Windows and corrupt\n// them; `path.posix.join` would drag in node:path and break the browser\n// bundle. So already-sanitised segments are joined with `/` directly (same\n// choice the plugins made before this module existed).\n\nconst MAX_SLUG_LEN = 120;\n\n/** The workspace directory every artifact lives under (`<workspace>/artifacts`). */\nexport const ARTIFACTS_ROOT = \"artifacts\";\n\n/**\n * Lowercase-ASCII slug for a throwaway, timestamped artifact filename. Empty,\n * whitespace-only, and non-ASCII-only titles fall back to `fallback`. Capped at\n * 120 chars so a long LLM title can't blow the filesystem's NAME_MAX.\n *\n * Leading/trailing hyphens are stripped with a linear scan rather than a regex\n * like `/^-+|-+$/` — CodeQL flags the trailing-anchor form as polynomial\n * backtracking on the attacker-influenced (LLM-provided) title. Strip → cap →\n * strip so a cut at the 120-char boundary can't re-expose a trailing hyphen.\n */\nexport function slugifyArtifact(title: string | undefined, fallback: string): string {\n if (!title) return fallback;\n const collapsed = title.toLowerCase().replace(/[^a-z0-9]+/g, \"-\");\n let start = 0;\n let end = collapsed.length;\n while (start < end && collapsed[start] === \"-\") start += 1;\n while (end > start && collapsed[end - 1] === \"-\") end -= 1;\n if (end - start > MAX_SLUG_LEN) end = start + MAX_SLUG_LEN;\n while (end > start && collapsed[end - 1] === \"-\") end -= 1;\n return collapsed.slice(start, end) || fallback;\n}\n\n/** UTC `YYYY/MM` partition (matches the host's #764 artifact sharding). UTC —\n * not local — so a workspace synced across timezones stays in one bucket. */\nexport function yearMonthUtc(now: Date = new Date()): string {\n const year = now.getUTCFullYear();\n const month = String(now.getUTCMonth() + 1).padStart(2, \"0\");\n return `${year}/${month}`;\n}\n\nexport interface ArtifactRelPathParams {\n /** Artifact-kind directory under the artifacts root (e.g. `charts`, `html`, `stories`). */\n dir: string;\n /** Human title the slug is derived from; empty/non-ASCII falls back to `fallback`. */\n title: string | undefined;\n /** File extension INCLUDING the leading dot (e.g. `.html`, `.chart.json`). */\n ext: string;\n /** Slug used when `title` yields nothing (e.g. `chart`, `page`, `story`). */\n fallback: string;\n now?: Date;\n /** Include the `YYYY/MM` partition segment. Default true; stories opt out. */\n partitioned?: boolean;\n}\n\n/**\n * FileOps-relative artifact path: `<dir>[/YYYY/MM]/<slug>-<epochMs><ext>`. The\n * `<epochMs>` suffix keeps freshly-built filenames collision-free without a\n * random component. This is what `files.artifacts.write` takes; prefix it with\n * `toWorkspaceArtifactPath` for the workspace-relative form shown to the LLM.\n */\nexport function buildArtifactRelPath(params: ArtifactRelPathParams): string {\n const { dir, title, ext, fallback, now = new Date(), partitioned = true } = params;\n const fileName = `${slugifyArtifact(title, fallback)}-${now.getTime()}${ext}`;\n const segments = partitioned ? [dir, yearMonthUtc(now), fileName] : [dir, fileName];\n return segments.join(\"/\");\n}\n\n/** Prefix a FileOps-relative artifact path with the workspace `artifacts/` root. */\nexport function toWorkspaceArtifactPath(relPath: string): string {\n return `${ARTIFACTS_ROOT}/${relPath}`;\n}\n\n/**\n * True when any `/`-segment of `value` is empty (`//`, leading/trailing slash),\n * `.`, or `..`. The lexical traversal / non-canonical guard every artifact path\n * check shares — equivalent to `path.posix.normalize(v) === v && !v.includes(\"..\")`\n * — so a workspace-escape judgement can't drift between plugins.\n */\nexport function hasUnsafePathSegment(value: string): boolean {\n return value.split(\"/\").some((seg) => seg === \"\" || seg === \".\" || seg === \"..\");\n}\n"],"mappings":";AAWA,IAAM,eAAe;;AAGrB,IAAa,iBAAiB;;;;;;;;;;;AAY9B,SAAgB,gBAAgB,OAA2B,UAA0B;CACnF,IAAI,CAAC,OAAO,OAAO;CACnB,MAAM,YAAY,MAAM,YAAY,CAAC,CAAC,QAAQ,eAAe,GAAG;CAChE,IAAI,QAAQ;CACZ,IAAI,MAAM,UAAU;CACpB,OAAO,QAAQ,OAAO,UAAU,WAAW,KAAK,SAAS;CACzD,OAAO,MAAM,SAAS,UAAU,MAAM,OAAO,KAAK,OAAO;CACzD,IAAI,MAAM,QAAQ,cAAc,MAAM,QAAQ;CAC9C,OAAO,MAAM,SAAS,UAAU,MAAM,OAAO,KAAK,OAAO;CACzD,OAAO,UAAU,MAAM,OAAO,GAAG,KAAK;AACxC;;;AAIA,SAAgB,aAAa,sBAAY,IAAI,KAAK,GAAW;CAG3D,OAAO,GAFM,IAAI,eAEP,EAAK,GADD,OAAO,IAAI,YAAY,IAAI,CAAC,CAAC,CAAC,SAAS,GAAG,GACtC;AACpB;;;;;;;AAsBA,SAAgB,qBAAqB,QAAuC;CAC1E,MAAM,EAAE,KAAK,OAAO,KAAK,UAAU,sBAAM,IAAI,KAAK,GAAG,cAAc,SAAS;CAC5E,MAAM,WAAW,GAAG,gBAAgB,OAAO,QAAQ,EAAE,GAAG,IAAI,QAAQ,IAAI;CAExE,QADiB,cAAc;EAAC;EAAK,aAAa,GAAG;EAAG;CAAQ,IAAI,CAAC,KAAK,QAAQ,EAAA,CAClE,KAAK,GAAG;AAC1B;;AAGA,SAAgB,wBAAwB,SAAyB;CAC/D,OAAO,GAAG,eAAe,GAAG;AAC9B;;;;;;;AAQA,SAAgB,qBAAqB,OAAwB;CAC3D,OAAO,MAAM,MAAM,GAAG,CAAC,CAAC,MAAM,QAAQ,QAAQ,MAAM,QAAQ,OAAO,QAAQ,IAAI;AACjF"}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
const require_rolldown_runtime = require("./rolldown-runtime-D6vf50IK.cjs");
|
|
2
|
+
let node_fs = require("node:fs");
|
|
3
|
+
let node_path = require("node:path");
|
|
4
|
+
node_path = require_rolldown_runtime.__toESM(node_path, 1);
|
|
5
|
+
let node_crypto = require("node:crypto");
|
|
6
|
+
//#region src/files/atomic.ts
|
|
7
|
+
var DEFAULT_UNIQUE_TMP = true;
|
|
8
|
+
function tmpPathFor(filePath, uniqueTmp) {
|
|
9
|
+
return uniqueTmp ?? DEFAULT_UNIQUE_TMP ? `${filePath}.${(0, node_crypto.randomBytes)(6).toString("hex")}.tmp` : `${filePath}.tmp`;
|
|
10
|
+
}
|
|
11
|
+
var IS_WINDOWS = process.platform === "win32";
|
|
12
|
+
var RENAME_RETRY_DELAYS_MS = [
|
|
13
|
+
30,
|
|
14
|
+
100,
|
|
15
|
+
300
|
|
16
|
+
];
|
|
17
|
+
function hasErrnoCode(err) {
|
|
18
|
+
return typeof err === "object" && err !== null && "code" in err && typeof err.code === "string";
|
|
19
|
+
}
|
|
20
|
+
function isTransientRenameError(err, isWindows = IS_WINDOWS) {
|
|
21
|
+
if (!isWindows || !hasErrnoCode(err)) return false;
|
|
22
|
+
return err.code === "EPERM" || err.code === "EBUSY" || err.code === "EACCES";
|
|
23
|
+
}
|
|
24
|
+
var defaultRenameRetryDeps = {
|
|
25
|
+
rename: (fromPath, toPath) => node_fs.promises.rename(fromPath, toPath),
|
|
26
|
+
sleep: (millis) => new Promise((resolve) => setTimeout(resolve, millis)),
|
|
27
|
+
isWindows: IS_WINDOWS
|
|
28
|
+
};
|
|
29
|
+
async function renameWithWindowsRetry(fromPath, toPath, deps = defaultRenameRetryDeps) {
|
|
30
|
+
for (const delayMs of RENAME_RETRY_DELAYS_MS) try {
|
|
31
|
+
await deps.rename(fromPath, toPath);
|
|
32
|
+
return;
|
|
33
|
+
} catch (err) {
|
|
34
|
+
if (!isTransientRenameError(err, deps.isWindows)) throw err;
|
|
35
|
+
await deps.sleep(delayMs);
|
|
36
|
+
}
|
|
37
|
+
await deps.rename(fromPath, toPath);
|
|
38
|
+
}
|
|
39
|
+
var SYNC_SLEEP_BUF = new Int32Array(new SharedArrayBuffer(4));
|
|
40
|
+
function sleepSync(millis) {
|
|
41
|
+
Atomics.wait(SYNC_SLEEP_BUF, 0, 0, millis);
|
|
42
|
+
}
|
|
43
|
+
var defaultRenameRetryDepsSync = {
|
|
44
|
+
rename: (fromPath, toPath) => (0, node_fs.renameSync)(fromPath, toPath),
|
|
45
|
+
sleep: sleepSync,
|
|
46
|
+
isWindows: IS_WINDOWS
|
|
47
|
+
};
|
|
48
|
+
function renameSyncWithWindowsRetry(fromPath, toPath, deps = defaultRenameRetryDepsSync) {
|
|
49
|
+
for (const delayMs of RENAME_RETRY_DELAYS_MS) try {
|
|
50
|
+
deps.rename(fromPath, toPath);
|
|
51
|
+
return;
|
|
52
|
+
} catch (err) {
|
|
53
|
+
if (!isTransientRenameError(err, deps.isWindows)) throw err;
|
|
54
|
+
deps.sleep(delayMs);
|
|
55
|
+
}
|
|
56
|
+
deps.rename(fromPath, toPath);
|
|
57
|
+
}
|
|
58
|
+
function writeOptionsFor(content, mode) {
|
|
59
|
+
return typeof content === "string" ? {
|
|
60
|
+
encoding: "utf-8",
|
|
61
|
+
mode
|
|
62
|
+
} : { mode };
|
|
63
|
+
}
|
|
64
|
+
async function writeFileAtomic(filePath, content, opts = {}) {
|
|
65
|
+
const tmp = tmpPathFor(filePath, opts.uniqueTmp);
|
|
66
|
+
await node_fs.promises.mkdir(node_path.default.dirname(filePath), { recursive: true });
|
|
67
|
+
try {
|
|
68
|
+
await node_fs.promises.writeFile(tmp, content, writeOptionsFor(content, opts.mode));
|
|
69
|
+
await renameWithWindowsRetry(tmp, filePath);
|
|
70
|
+
} catch (err) {
|
|
71
|
+
await node_fs.promises.unlink(tmp).catch(() => {});
|
|
72
|
+
throw err;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
function writeFileAtomicSync(filePath, content, opts = {}) {
|
|
76
|
+
const tmp = tmpPathFor(filePath, opts.uniqueTmp);
|
|
77
|
+
(0, node_fs.mkdirSync)(node_path.default.dirname(filePath), { recursive: true });
|
|
78
|
+
try {
|
|
79
|
+
(0, node_fs.writeFileSync)(tmp, content, writeOptionsFor(content, opts.mode));
|
|
80
|
+
renameSyncWithWindowsRetry(tmp, filePath);
|
|
81
|
+
} catch (err) {
|
|
82
|
+
try {
|
|
83
|
+
(0, node_fs.unlinkSync)(tmp);
|
|
84
|
+
} catch {}
|
|
85
|
+
throw err;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
//#endregion
|
|
89
|
+
Object.defineProperty(exports, "writeFileAtomic", {
|
|
90
|
+
enumerable: true,
|
|
91
|
+
get: function() {
|
|
92
|
+
return writeFileAtomic;
|
|
93
|
+
}
|
|
94
|
+
});
|
|
95
|
+
Object.defineProperty(exports, "writeFileAtomicSync", {
|
|
96
|
+
enumerable: true,
|
|
97
|
+
get: function() {
|
|
98
|
+
return writeFileAtomicSync;
|
|
99
|
+
}
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
//# sourceMappingURL=atomic-C_7YpMiM.cjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"atomic-C_7YpMiM.cjs","names":[],"sources":["../src/files/atomic.ts"],"sourcesContent":["// rename(2) is atomic on POSIX; Node's Windows fallback (copy+unlink) is still safer than truncating in place.\n// Readers always see either the old file or the new — never a half-written one.\n//\n// Single source of truth for atomic file writes across the whole monorepo\n// (host, core, plugins). Previously copy-pasted four times, so a Windows retry\n// fix landed in only one (#2399, precedent #2222).\n\nimport { mkdirSync, promises, renameSync, unlinkSync, writeFileSync } from \"node:fs\";\nimport path from \"node:path\";\nimport { randomBytes } from \"node:crypto\";\n\nexport interface WriteAtomicOptions {\n mode?: number;\n /** Give the staging file a random suffix so concurrent writers to the same\n * destination can't collide at the OS layer.\n *\n * Defaults to `true`, and opting out is almost always wrong. A shared\n * `${filePath}.tmp` means two writers of one file race: the second write\n * overwrites the first's staging file, or one rename/unlink pulls it out\n * from under the other, surfacing as `ENOENT … rename '<file>.tmp'`. That is\n * not theoretical — it fired in production on session meta, where ten\n * distinct callers (`setClaudeSessionId`, `backfillOrigin`,\n * `incrementUserQueryCount`, …) all write the same `<sessionId>.json`\n * (#2222).\n *\n * Pass `false` only when you specifically need the staging path to be\n * predictable (e.g. a single-writer token file, or a test that pre-creates\n * it to force a write failure). */\n uniqueTmp?: boolean;\n}\n\n// Unique staging names are the safe default: the cost is a random suffix, the\n// cost of the alternative is a lost update under concurrency (#2222).\nconst DEFAULT_UNIQUE_TMP = true;\n\nfunction tmpPathFor(filePath: string, uniqueTmp: boolean | undefined): string {\n return (uniqueTmp ?? DEFAULT_UNIQUE_TMP) ? `${filePath}.${randomBytes(6).toString(\"hex\")}.tmp` : `${filePath}.tmp`;\n}\n\n// On Windows, AV / Search Indexer / Defender briefly hold handles and rename trips EPERM/EBUSY/EACCES. Retry loop is\n// gated to Windows because POSIX EPERM means a real perm problem (read-only fs, sticky, cross-device) — retrying\n// just adds latency before the inevitable throw.\nconst IS_WINDOWS = process.platform === \"win32\";\nconst RENAME_RETRY_DELAYS_MS = [30, 100, 300] as const;\n\nfunction hasErrnoCode(err: unknown): err is { code: string } {\n return typeof err === \"object\" && err !== null && \"code\" in err && typeof err.code === \"string\";\n}\n\n// `isWindows` is a parameter (defaulting to the real platform) so the safety-critical decision is testable on any OS.\nexport function isTransientRenameError(err: unknown, isWindows: boolean = IS_WINDOWS): boolean {\n if (!isWindows || !hasErrnoCode(err)) return false;\n return err.code === \"EPERM\" || err.code === \"EBUSY\" || err.code === \"EACCES\";\n}\n\n// Injectable so a test can drive the retry path (fail-then-succeed rename, no-op sleep, isWindows=true) on any OS.\nexport interface RenameRetryDeps {\n rename: (fromPath: string, toPath: string) => Promise<void>;\n sleep: (millis: number) => Promise<void>;\n isWindows: boolean;\n}\n\nconst defaultRenameRetryDeps: RenameRetryDeps = {\n rename: (fromPath, toPath) => promises.rename(fromPath, toPath),\n sleep: (millis) => new Promise((resolve) => setTimeout(resolve, millis)),\n isWindows: IS_WINDOWS,\n};\n\nexport async function renameWithWindowsRetry(fromPath: string, toPath: string, deps: RenameRetryDeps = defaultRenameRetryDeps): Promise<void> {\n for (const delayMs of RENAME_RETRY_DELAYS_MS) {\n try {\n await deps.rename(fromPath, toPath);\n return;\n } catch (err) {\n if (!isTransientRenameError(err, deps.isWindows)) throw err;\n await deps.sleep(delayMs);\n }\n }\n // Final attempt — let any error propagate.\n await deps.rename(fromPath, toPath);\n}\n\n// Atomics.wait parks the thread instead of busy-spinning. Only on the Windows-rename retry path, total ≤ ~430ms.\nconst SYNC_SLEEP_BUF = new Int32Array(new SharedArrayBuffer(4));\nfunction sleepSync(millis: number): void {\n Atomics.wait(SYNC_SLEEP_BUF, 0, 0, millis);\n}\n\n// Deliberate async/sync twin of RenameRetryDeps: `sleep` blocks the thread instead of returning a Promise.\nexport interface RenameRetryDepsSync {\n rename: (fromPath: string, toPath: string) => void;\n sleep: (millis: number) => void;\n isWindows: boolean;\n}\n\nconst defaultRenameRetryDepsSync: RenameRetryDepsSync = {\n rename: (fromPath, toPath) => renameSync(fromPath, toPath),\n sleep: sleepSync,\n isWindows: IS_WINDOWS,\n};\n\nexport function renameSyncWithWindowsRetry(fromPath: string, toPath: string, deps: RenameRetryDepsSync = defaultRenameRetryDepsSync): void {\n for (const delayMs of RENAME_RETRY_DELAYS_MS) {\n try {\n deps.rename(fromPath, toPath);\n return;\n } catch (err) {\n if (!isTransientRenameError(err, deps.isWindows)) throw err;\n deps.sleep(delayMs);\n }\n }\n deps.rename(fromPath, toPath);\n}\n\n// Forcing utf-8 on a Uint8Array would re-encode the bytes — wrong for PNGs and other binary blobs.\nfunction writeOptionsFor(content: string | Uint8Array, mode: number | undefined): { encoding?: \"utf-8\"; mode?: number } {\n return typeof content === \"string\" ? { encoding: \"utf-8\", mode } : { mode };\n}\n\nexport async function writeFileAtomic(filePath: string, content: string | Uint8Array, opts: WriteAtomicOptions = {}): Promise<void> {\n const tmp = tmpPathFor(filePath, opts.uniqueTmp);\n await promises.mkdir(path.dirname(filePath), { recursive: true });\n try {\n await promises.writeFile(tmp, content, writeOptionsFor(content, opts.mode));\n await renameWithWindowsRetry(tmp, filePath);\n } catch (err) {\n await promises.unlink(tmp).catch(() => {});\n throw err;\n }\n}\n\nexport function writeFileAtomicSync(filePath: string, content: string | Uint8Array, opts: WriteAtomicOptions = {}): void {\n const tmp = tmpPathFor(filePath, opts.uniqueTmp);\n mkdirSync(path.dirname(filePath), { recursive: true });\n try {\n writeFileSync(tmp, content, writeOptionsFor(content, opts.mode));\n renameSyncWithWindowsRetry(tmp, filePath);\n } catch (err) {\n try {\n unlinkSync(tmp);\n } catch {\n // best-effort cleanup\n }\n throw err;\n }\n}\n"],"mappings":";;;;;;AAiCA,IAAM,qBAAqB;AAE3B,SAAS,WAAW,UAAkB,WAAwC;CAC5E,OAAQ,aAAa,qBAAsB,GAAG,SAAS,IAAA,GAAA,YAAA,YAAA,CAAe,CAAC,CAAC,CAAC,SAAS,KAAK,EAAE,QAAQ,GAAG,SAAS;AAC/G;AAKA,IAAM,aAAa,QAAQ,aAAa;AACxC,IAAM,yBAAyB;CAAC;CAAI;CAAK;AAAG;AAE5C,SAAS,aAAa,KAAuC;CAC3D,OAAO,OAAO,QAAQ,YAAY,QAAQ,QAAQ,UAAU,OAAO,OAAO,IAAI,SAAS;AACzF;AAGA,SAAgB,uBAAuB,KAAc,YAAqB,YAAqB;CAC7F,IAAI,CAAC,aAAa,CAAC,aAAa,GAAG,GAAG,OAAO;CAC7C,OAAO,IAAI,SAAS,WAAW,IAAI,SAAS,WAAW,IAAI,SAAS;AACtE;AASA,IAAM,yBAA0C;CAC9C,SAAS,UAAU,WAAW,QAAA,SAAS,OAAO,UAAU,MAAM;CAC9D,QAAQ,WAAW,IAAI,SAAS,YAAY,WAAW,SAAS,MAAM,CAAC;CACvE,WAAW;AACb;AAEA,eAAsB,uBAAuB,UAAkB,QAAgB,OAAwB,wBAAuC;CAC5I,KAAK,MAAM,WAAW,wBACpB,IAAI;EACF,MAAM,KAAK,OAAO,UAAU,MAAM;EAClC;CACF,SAAS,KAAK;EACZ,IAAI,CAAC,uBAAuB,KAAK,KAAK,SAAS,GAAG,MAAM;EACxD,MAAM,KAAK,MAAM,OAAO;CAC1B;CAGF,MAAM,KAAK,OAAO,UAAU,MAAM;AACpC;AAGA,IAAM,iBAAiB,IAAI,WAAW,IAAI,kBAAkB,CAAC,CAAC;AAC9D,SAAS,UAAU,QAAsB;CACvC,QAAQ,KAAK,gBAAgB,GAAG,GAAG,MAAM;AAC3C;AASA,IAAM,6BAAkD;CACtD,SAAS,UAAU,YAAA,GAAA,QAAA,WAAA,CAAsB,UAAU,MAAM;CACzD,OAAO;CACP,WAAW;AACb;AAEA,SAAgB,2BAA2B,UAAkB,QAAgB,OAA4B,4BAAkC;CACzI,KAAK,MAAM,WAAW,wBACpB,IAAI;EACF,KAAK,OAAO,UAAU,MAAM;EAC5B;CACF,SAAS,KAAK;EACZ,IAAI,CAAC,uBAAuB,KAAK,KAAK,SAAS,GAAG,MAAM;EACxD,KAAK,MAAM,OAAO;CACpB;CAEF,KAAK,OAAO,UAAU,MAAM;AAC9B;AAGA,SAAS,gBAAgB,SAA8B,MAAiE;CACtH,OAAO,OAAO,YAAY,WAAW;EAAE,UAAU;EAAS;CAAK,IAAI,EAAE,KAAK;AAC5E;AAEA,eAAsB,gBAAgB,UAAkB,SAA8B,OAA2B,CAAC,GAAkB;CAClI,MAAM,MAAM,WAAW,UAAU,KAAK,SAAS;CAC/C,MAAM,QAAA,SAAS,MAAM,UAAA,QAAK,QAAQ,QAAQ,GAAG,EAAE,WAAW,KAAK,CAAC;CAChE,IAAI;EACF,MAAM,QAAA,SAAS,UAAU,KAAK,SAAS,gBAAgB,SAAS,KAAK,IAAI,CAAC;EAC1E,MAAM,uBAAuB,KAAK,QAAQ;CAC5C,SAAS,KAAK;EACZ,MAAM,QAAA,SAAS,OAAO,GAAG,CAAC,CAAC,YAAY,CAAC,CAAC;EACzC,MAAM;CACR;AACF;AAEA,SAAgB,oBAAoB,UAAkB,SAA8B,OAA2B,CAAC,GAAS;CACvH,MAAM,MAAM,WAAW,UAAU,KAAK,SAAS;CAC/C,CAAA,GAAA,QAAA,UAAA,CAAU,UAAA,QAAK,QAAQ,QAAQ,GAAG,EAAE,WAAW,KAAK,CAAC;CACrD,IAAI;EACF,CAAA,GAAA,QAAA,cAAA,CAAc,KAAK,SAAS,gBAAgB,SAAS,KAAK,IAAI,CAAC;EAC/D,2BAA2B,KAAK,QAAQ;CAC1C,SAAS,KAAK;EACZ,IAAI;GACF,CAAA,GAAA,QAAA,WAAA,CAAW,GAAG;EAChB,QAAQ,CAER;EACA,MAAM;CACR;AACF"}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { mkdirSync, promises, renameSync, unlinkSync, writeFileSync } from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { randomBytes } from "node:crypto";
|
|
4
|
+
//#region src/files/atomic.ts
|
|
5
|
+
var DEFAULT_UNIQUE_TMP = true;
|
|
6
|
+
function tmpPathFor(filePath, uniqueTmp) {
|
|
7
|
+
return uniqueTmp ?? DEFAULT_UNIQUE_TMP ? `${filePath}.${randomBytes(6).toString("hex")}.tmp` : `${filePath}.tmp`;
|
|
8
|
+
}
|
|
9
|
+
var IS_WINDOWS = process.platform === "win32";
|
|
10
|
+
var RENAME_RETRY_DELAYS_MS = [
|
|
11
|
+
30,
|
|
12
|
+
100,
|
|
13
|
+
300
|
|
14
|
+
];
|
|
15
|
+
function hasErrnoCode(err) {
|
|
16
|
+
return typeof err === "object" && err !== null && "code" in err && typeof err.code === "string";
|
|
17
|
+
}
|
|
18
|
+
function isTransientRenameError(err, isWindows = IS_WINDOWS) {
|
|
19
|
+
if (!isWindows || !hasErrnoCode(err)) return false;
|
|
20
|
+
return err.code === "EPERM" || err.code === "EBUSY" || err.code === "EACCES";
|
|
21
|
+
}
|
|
22
|
+
var defaultRenameRetryDeps = {
|
|
23
|
+
rename: (fromPath, toPath) => promises.rename(fromPath, toPath),
|
|
24
|
+
sleep: (millis) => new Promise((resolve) => setTimeout(resolve, millis)),
|
|
25
|
+
isWindows: IS_WINDOWS
|
|
26
|
+
};
|
|
27
|
+
async function renameWithWindowsRetry(fromPath, toPath, deps = defaultRenameRetryDeps) {
|
|
28
|
+
for (const delayMs of RENAME_RETRY_DELAYS_MS) try {
|
|
29
|
+
await deps.rename(fromPath, toPath);
|
|
30
|
+
return;
|
|
31
|
+
} catch (err) {
|
|
32
|
+
if (!isTransientRenameError(err, deps.isWindows)) throw err;
|
|
33
|
+
await deps.sleep(delayMs);
|
|
34
|
+
}
|
|
35
|
+
await deps.rename(fromPath, toPath);
|
|
36
|
+
}
|
|
37
|
+
var SYNC_SLEEP_BUF = new Int32Array(new SharedArrayBuffer(4));
|
|
38
|
+
function sleepSync(millis) {
|
|
39
|
+
Atomics.wait(SYNC_SLEEP_BUF, 0, 0, millis);
|
|
40
|
+
}
|
|
41
|
+
var defaultRenameRetryDepsSync = {
|
|
42
|
+
rename: (fromPath, toPath) => renameSync(fromPath, toPath),
|
|
43
|
+
sleep: sleepSync,
|
|
44
|
+
isWindows: IS_WINDOWS
|
|
45
|
+
};
|
|
46
|
+
function renameSyncWithWindowsRetry(fromPath, toPath, deps = defaultRenameRetryDepsSync) {
|
|
47
|
+
for (const delayMs of RENAME_RETRY_DELAYS_MS) try {
|
|
48
|
+
deps.rename(fromPath, toPath);
|
|
49
|
+
return;
|
|
50
|
+
} catch (err) {
|
|
51
|
+
if (!isTransientRenameError(err, deps.isWindows)) throw err;
|
|
52
|
+
deps.sleep(delayMs);
|
|
53
|
+
}
|
|
54
|
+
deps.rename(fromPath, toPath);
|
|
55
|
+
}
|
|
56
|
+
function writeOptionsFor(content, mode) {
|
|
57
|
+
return typeof content === "string" ? {
|
|
58
|
+
encoding: "utf-8",
|
|
59
|
+
mode
|
|
60
|
+
} : { mode };
|
|
61
|
+
}
|
|
62
|
+
async function writeFileAtomic(filePath, content, opts = {}) {
|
|
63
|
+
const tmp = tmpPathFor(filePath, opts.uniqueTmp);
|
|
64
|
+
await promises.mkdir(path.dirname(filePath), { recursive: true });
|
|
65
|
+
try {
|
|
66
|
+
await promises.writeFile(tmp, content, writeOptionsFor(content, opts.mode));
|
|
67
|
+
await renameWithWindowsRetry(tmp, filePath);
|
|
68
|
+
} catch (err) {
|
|
69
|
+
await promises.unlink(tmp).catch(() => {});
|
|
70
|
+
throw err;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
function writeFileAtomicSync(filePath, content, opts = {}) {
|
|
74
|
+
const tmp = tmpPathFor(filePath, opts.uniqueTmp);
|
|
75
|
+
mkdirSync(path.dirname(filePath), { recursive: true });
|
|
76
|
+
try {
|
|
77
|
+
writeFileSync(tmp, content, writeOptionsFor(content, opts.mode));
|
|
78
|
+
renameSyncWithWindowsRetry(tmp, filePath);
|
|
79
|
+
} catch (err) {
|
|
80
|
+
try {
|
|
81
|
+
unlinkSync(tmp);
|
|
82
|
+
} catch {}
|
|
83
|
+
throw err;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
//#endregion
|
|
87
|
+
export { writeFileAtomicSync as n, writeFileAtomic as t };
|
|
88
|
+
|
|
89
|
+
//# sourceMappingURL=atomic-DPpdrJzO.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"atomic-DPpdrJzO.js","names":[],"sources":["../src/files/atomic.ts"],"sourcesContent":["// rename(2) is atomic on POSIX; Node's Windows fallback (copy+unlink) is still safer than truncating in place.\n// Readers always see either the old file or the new — never a half-written one.\n//\n// Single source of truth for atomic file writes across the whole monorepo\n// (host, core, plugins). Previously copy-pasted four times, so a Windows retry\n// fix landed in only one (#2399, precedent #2222).\n\nimport { mkdirSync, promises, renameSync, unlinkSync, writeFileSync } from \"node:fs\";\nimport path from \"node:path\";\nimport { randomBytes } from \"node:crypto\";\n\nexport interface WriteAtomicOptions {\n mode?: number;\n /** Give the staging file a random suffix so concurrent writers to the same\n * destination can't collide at the OS layer.\n *\n * Defaults to `true`, and opting out is almost always wrong. A shared\n * `${filePath}.tmp` means two writers of one file race: the second write\n * overwrites the first's staging file, or one rename/unlink pulls it out\n * from under the other, surfacing as `ENOENT … rename '<file>.tmp'`. That is\n * not theoretical — it fired in production on session meta, where ten\n * distinct callers (`setClaudeSessionId`, `backfillOrigin`,\n * `incrementUserQueryCount`, …) all write the same `<sessionId>.json`\n * (#2222).\n *\n * Pass `false` only when you specifically need the staging path to be\n * predictable (e.g. a single-writer token file, or a test that pre-creates\n * it to force a write failure). */\n uniqueTmp?: boolean;\n}\n\n// Unique staging names are the safe default: the cost is a random suffix, the\n// cost of the alternative is a lost update under concurrency (#2222).\nconst DEFAULT_UNIQUE_TMP = true;\n\nfunction tmpPathFor(filePath: string, uniqueTmp: boolean | undefined): string {\n return (uniqueTmp ?? DEFAULT_UNIQUE_TMP) ? `${filePath}.${randomBytes(6).toString(\"hex\")}.tmp` : `${filePath}.tmp`;\n}\n\n// On Windows, AV / Search Indexer / Defender briefly hold handles and rename trips EPERM/EBUSY/EACCES. Retry loop is\n// gated to Windows because POSIX EPERM means a real perm problem (read-only fs, sticky, cross-device) — retrying\n// just adds latency before the inevitable throw.\nconst IS_WINDOWS = process.platform === \"win32\";\nconst RENAME_RETRY_DELAYS_MS = [30, 100, 300] as const;\n\nfunction hasErrnoCode(err: unknown): err is { code: string } {\n return typeof err === \"object\" && err !== null && \"code\" in err && typeof err.code === \"string\";\n}\n\n// `isWindows` is a parameter (defaulting to the real platform) so the safety-critical decision is testable on any OS.\nexport function isTransientRenameError(err: unknown, isWindows: boolean = IS_WINDOWS): boolean {\n if (!isWindows || !hasErrnoCode(err)) return false;\n return err.code === \"EPERM\" || err.code === \"EBUSY\" || err.code === \"EACCES\";\n}\n\n// Injectable so a test can drive the retry path (fail-then-succeed rename, no-op sleep, isWindows=true) on any OS.\nexport interface RenameRetryDeps {\n rename: (fromPath: string, toPath: string) => Promise<void>;\n sleep: (millis: number) => Promise<void>;\n isWindows: boolean;\n}\n\nconst defaultRenameRetryDeps: RenameRetryDeps = {\n rename: (fromPath, toPath) => promises.rename(fromPath, toPath),\n sleep: (millis) => new Promise((resolve) => setTimeout(resolve, millis)),\n isWindows: IS_WINDOWS,\n};\n\nexport async function renameWithWindowsRetry(fromPath: string, toPath: string, deps: RenameRetryDeps = defaultRenameRetryDeps): Promise<void> {\n for (const delayMs of RENAME_RETRY_DELAYS_MS) {\n try {\n await deps.rename(fromPath, toPath);\n return;\n } catch (err) {\n if (!isTransientRenameError(err, deps.isWindows)) throw err;\n await deps.sleep(delayMs);\n }\n }\n // Final attempt — let any error propagate.\n await deps.rename(fromPath, toPath);\n}\n\n// Atomics.wait parks the thread instead of busy-spinning. Only on the Windows-rename retry path, total ≤ ~430ms.\nconst SYNC_SLEEP_BUF = new Int32Array(new SharedArrayBuffer(4));\nfunction sleepSync(millis: number): void {\n Atomics.wait(SYNC_SLEEP_BUF, 0, 0, millis);\n}\n\n// Deliberate async/sync twin of RenameRetryDeps: `sleep` blocks the thread instead of returning a Promise.\nexport interface RenameRetryDepsSync {\n rename: (fromPath: string, toPath: string) => void;\n sleep: (millis: number) => void;\n isWindows: boolean;\n}\n\nconst defaultRenameRetryDepsSync: RenameRetryDepsSync = {\n rename: (fromPath, toPath) => renameSync(fromPath, toPath),\n sleep: sleepSync,\n isWindows: IS_WINDOWS,\n};\n\nexport function renameSyncWithWindowsRetry(fromPath: string, toPath: string, deps: RenameRetryDepsSync = defaultRenameRetryDepsSync): void {\n for (const delayMs of RENAME_RETRY_DELAYS_MS) {\n try {\n deps.rename(fromPath, toPath);\n return;\n } catch (err) {\n if (!isTransientRenameError(err, deps.isWindows)) throw err;\n deps.sleep(delayMs);\n }\n }\n deps.rename(fromPath, toPath);\n}\n\n// Forcing utf-8 on a Uint8Array would re-encode the bytes — wrong for PNGs and other binary blobs.\nfunction writeOptionsFor(content: string | Uint8Array, mode: number | undefined): { encoding?: \"utf-8\"; mode?: number } {\n return typeof content === \"string\" ? { encoding: \"utf-8\", mode } : { mode };\n}\n\nexport async function writeFileAtomic(filePath: string, content: string | Uint8Array, opts: WriteAtomicOptions = {}): Promise<void> {\n const tmp = tmpPathFor(filePath, opts.uniqueTmp);\n await promises.mkdir(path.dirname(filePath), { recursive: true });\n try {\n await promises.writeFile(tmp, content, writeOptionsFor(content, opts.mode));\n await renameWithWindowsRetry(tmp, filePath);\n } catch (err) {\n await promises.unlink(tmp).catch(() => {});\n throw err;\n }\n}\n\nexport function writeFileAtomicSync(filePath: string, content: string | Uint8Array, opts: WriteAtomicOptions = {}): void {\n const tmp = tmpPathFor(filePath, opts.uniqueTmp);\n mkdirSync(path.dirname(filePath), { recursive: true });\n try {\n writeFileSync(tmp, content, writeOptionsFor(content, opts.mode));\n renameSyncWithWindowsRetry(tmp, filePath);\n } catch (err) {\n try {\n unlinkSync(tmp);\n } catch {\n // best-effort cleanup\n }\n throw err;\n }\n}\n"],"mappings":";;;;AAiCA,IAAM,qBAAqB;AAE3B,SAAS,WAAW,UAAkB,WAAwC;CAC5E,OAAQ,aAAa,qBAAsB,GAAG,SAAS,GAAG,YAAY,CAAC,CAAC,CAAC,SAAS,KAAK,EAAE,QAAQ,GAAG,SAAS;AAC/G;AAKA,IAAM,aAAa,QAAQ,aAAa;AACxC,IAAM,yBAAyB;CAAC;CAAI;CAAK;AAAG;AAE5C,SAAS,aAAa,KAAuC;CAC3D,OAAO,OAAO,QAAQ,YAAY,QAAQ,QAAQ,UAAU,OAAO,OAAO,IAAI,SAAS;AACzF;AAGA,SAAgB,uBAAuB,KAAc,YAAqB,YAAqB;CAC7F,IAAI,CAAC,aAAa,CAAC,aAAa,GAAG,GAAG,OAAO;CAC7C,OAAO,IAAI,SAAS,WAAW,IAAI,SAAS,WAAW,IAAI,SAAS;AACtE;AASA,IAAM,yBAA0C;CAC9C,SAAS,UAAU,WAAW,SAAS,OAAO,UAAU,MAAM;CAC9D,QAAQ,WAAW,IAAI,SAAS,YAAY,WAAW,SAAS,MAAM,CAAC;CACvE,WAAW;AACb;AAEA,eAAsB,uBAAuB,UAAkB,QAAgB,OAAwB,wBAAuC;CAC5I,KAAK,MAAM,WAAW,wBACpB,IAAI;EACF,MAAM,KAAK,OAAO,UAAU,MAAM;EAClC;CACF,SAAS,KAAK;EACZ,IAAI,CAAC,uBAAuB,KAAK,KAAK,SAAS,GAAG,MAAM;EACxD,MAAM,KAAK,MAAM,OAAO;CAC1B;CAGF,MAAM,KAAK,OAAO,UAAU,MAAM;AACpC;AAGA,IAAM,iBAAiB,IAAI,WAAW,IAAI,kBAAkB,CAAC,CAAC;AAC9D,SAAS,UAAU,QAAsB;CACvC,QAAQ,KAAK,gBAAgB,GAAG,GAAG,MAAM;AAC3C;AASA,IAAM,6BAAkD;CACtD,SAAS,UAAU,WAAW,WAAW,UAAU,MAAM;CACzD,OAAO;CACP,WAAW;AACb;AAEA,SAAgB,2BAA2B,UAAkB,QAAgB,OAA4B,4BAAkC;CACzI,KAAK,MAAM,WAAW,wBACpB,IAAI;EACF,KAAK,OAAO,UAAU,MAAM;EAC5B;CACF,SAAS,KAAK;EACZ,IAAI,CAAC,uBAAuB,KAAK,KAAK,SAAS,GAAG,MAAM;EACxD,KAAK,MAAM,OAAO;CACpB;CAEF,KAAK,OAAO,UAAU,MAAM;AAC9B;AAGA,SAAS,gBAAgB,SAA8B,MAAiE;CACtH,OAAO,OAAO,YAAY,WAAW;EAAE,UAAU;EAAS;CAAK,IAAI,EAAE,KAAK;AAC5E;AAEA,eAAsB,gBAAgB,UAAkB,SAA8B,OAA2B,CAAC,GAAkB;CAClI,MAAM,MAAM,WAAW,UAAU,KAAK,SAAS;CAC/C,MAAM,SAAS,MAAM,KAAK,QAAQ,QAAQ,GAAG,EAAE,WAAW,KAAK,CAAC;CAChE,IAAI;EACF,MAAM,SAAS,UAAU,KAAK,SAAS,gBAAgB,SAAS,KAAK,IAAI,CAAC;EAC1E,MAAM,uBAAuB,KAAK,QAAQ;CAC5C,SAAS,KAAK;EACZ,MAAM,SAAS,OAAO,GAAG,CAAC,CAAC,YAAY,CAAC,CAAC;EACzC,MAAM;CACR;AACF;AAEA,SAAgB,oBAAoB,UAAkB,SAA8B,OAA2B,CAAC,GAAS;CACvH,MAAM,MAAM,WAAW,UAAU,KAAK,SAAS;CAC/C,UAAU,KAAK,QAAQ,QAAQ,GAAG,EAAE,WAAW,KAAK,CAAC;CACrD,IAAI;EACF,cAAc,KAAK,SAAS,gBAAgB,SAAS,KAAK,IAAI,CAAC;EAC/D,2BAA2B,KAAK,QAAQ;CAC1C,SAAS,KAAK;EACZ,IAAI;GACF,WAAW,GAAG;EAChB,QAAQ,CAER;EACA,MAAM;CACR;AACF"}
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
/** `/<slug> <message>`, or `/<slug> id=<itemId> <message>` when a specific
|
|
2
|
+
* record is addressed. The `id=` selector goes BEFORE the free-text message so
|
|
3
|
+
* the skill's argument parser reads it as a flag rather than as prose. */
|
|
4
|
+
export declare function skillCommandSeed(slug: string, message: string, itemId?: string): string;
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { Where } from './where';
|
|
2
|
+
import { CollectionFieldSpec, CollectionItem } from './schema';
|
|
2
3
|
/** The slice of a parsed schema the done predicate reads — minimal
|
|
3
4
|
* structural shape (like `DerivableSchema`) so the client and server
|
|
4
5
|
* `CollectionSchema` types both satisfy it as-is. */
|
|
@@ -16,3 +17,55 @@ export interface CompletionSchemaView {
|
|
|
16
17
|
/** True iff the schema declares completion tracking AND `item` is done
|
|
17
18
|
* under whichever completion form the schema uses (see module doc). */
|
|
18
19
|
export declare function itemIsDone(schema: CompletionSchemaView, item: Record<string, unknown>): boolean;
|
|
20
|
+
/** The schema slice `completionCoveredByFieldChip` reads: field kinds (it
|
|
21
|
+
* inspects `boolean` / `toggle` variants' `field` / `onValue`) plus the
|
|
22
|
+
* completion pair. Minimal structural shape so both the client and server
|
|
23
|
+
* `CollectionSchema` types satisfy it as-is. */
|
|
24
|
+
export interface CompletionChipSchemaView {
|
|
25
|
+
fields: Record<string, {
|
|
26
|
+
type: string;
|
|
27
|
+
field?: string;
|
|
28
|
+
onValue?: string;
|
|
29
|
+
}>;
|
|
30
|
+
completionField?: string;
|
|
31
|
+
completionDoneValues?: readonly string[];
|
|
32
|
+
}
|
|
33
|
+
/** True when an existing FIELD chip already expresses the legacy completion
|
|
34
|
+
* predicate exactly, so a synthesized "done" chip would be a duplicate: a
|
|
35
|
+
* boolean `completionField` (done ⇔ `"true"` ⇔ the boolean's own chip), or a
|
|
36
|
+
* `toggle` projecting the `completionField` whose `onValue` is the single
|
|
37
|
+
* done value (the todos-schema shape: toggle "Done" on `status` +
|
|
38
|
+
* `completionDoneValues: ["done"]`). A superset pair (extra done values)
|
|
39
|
+
* still synthesizes — no field chip covers it. */
|
|
40
|
+
export declare function completionCoveredByFieldChip(schema: CompletionChipSchemaView): boolean;
|
|
41
|
+
/** Whether a `toggle` field reads as checked: its projected enum field currently
|
|
42
|
+
* equals `onValue`. The toggle stores nothing of its own, so this reads the raw
|
|
43
|
+
* projected value; a non-toggle field is never checked. */
|
|
44
|
+
export declare function toggleChecked(item: CollectionItem, field: CollectionFieldSpec): boolean;
|
|
45
|
+
/** A `flag` FIELD's boolean for one row, read off the already-enriched record
|
|
46
|
+
* (so a flag computed from derived/rollup inputs is correct). Strict `=== true`
|
|
47
|
+
* on purpose: a truthy non-boolean ("yes", 1) is NOT a set flag, so a stray
|
|
48
|
+
* value can never render as an active flag. */
|
|
49
|
+
export declare function flagFieldValue(record: Record<string, unknown>, key: string): boolean;
|
|
50
|
+
/** One entry in the table's flag-filter menu: a real `flag` / `boolean` /
|
|
51
|
+
* `toggle` field, or the synthesized legacy-completion chip (`synthetic`,
|
|
52
|
+
* predicate = `itemIsDone`). */
|
|
53
|
+
export interface FlagChip {
|
|
54
|
+
key: string;
|
|
55
|
+
label: string;
|
|
56
|
+
synthetic?: boolean;
|
|
57
|
+
}
|
|
58
|
+
/** The schema slice `chipMatches` reads: full field specs (it inspects the
|
|
59
|
+
* `toggle` variant's `field` / `onValue`) plus the completion pair that
|
|
60
|
+
* `itemIsDone` needs for the synthesized chip. */
|
|
61
|
+
export interface ChipMatchSchema {
|
|
62
|
+
fields: Record<string, CollectionFieldSpec>;
|
|
63
|
+
completionField?: string;
|
|
64
|
+
completionDoneValues?: readonly string[];
|
|
65
|
+
}
|
|
66
|
+
/** Whether one row satisfies a chip's predicate: `itemIsDone` for the
|
|
67
|
+
* synthesized completion chip, the projected value for a `toggle`, the stored
|
|
68
|
+
* boolean for a `boolean`, else the computed flag value. The flag branch reads
|
|
69
|
+
* the ENRICHED record via the injected `deriveRecord` (kept a parameter so this
|
|
70
|
+
* stays pure and framework-free). */
|
|
71
|
+
export declare function chipMatches(chip: FlagChip, schema: ChipMatchSchema, item: CollectionItem, deriveRecord: (item: CollectionItem) => Record<string, unknown>): boolean;
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { CollectionItem } from './schema';
|
|
1
2
|
export declare const SAFE_SLUG_PATTERN: RegExp;
|
|
2
3
|
export declare const SAFE_RECORD_ID_PATTERN: RegExp;
|
|
3
4
|
/** True when `value` is a well-formed collection slug (alphanumeric /
|
|
@@ -10,3 +11,12 @@ export declare function isSafeSlug(value: string): boolean;
|
|
|
10
11
|
* dots), with any `..` substring rejected explicitly. Validation-only
|
|
11
12
|
* counterpart of `../server/paths#safeRecordId`. */
|
|
12
13
|
export declare function isSafeRecordId(value: string): boolean;
|
|
14
|
+
/** Pick an id not already in `existing`, re-rolling `generate()` up to
|
|
15
|
+
* `maxAttempts` times before giving up and returning the last candidate.
|
|
16
|
+
* Collisions on a wide id space are astronomically unlikely, so a caller's
|
|
17
|
+
* own overwrite guard is the final backstop rather than an unbounded loop. */
|
|
18
|
+
export declare function generateUniqueId(existing: ReadonlySet<string>, generate: () => string, maxAttempts?: number): string;
|
|
19
|
+
/** An id not already used by any loaded record: collects the in-memory primary
|
|
20
|
+
* keys and re-rolls `generate()` against them (see {@link generateUniqueId}).
|
|
21
|
+
* The pure core of the view's create-form id prefill. */
|
|
22
|
+
export declare function nextUniqueItemId(items: readonly CollectionItem[], primaryKey: string, generate: () => string, maxAttempts?: number): string;
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { CollectionSchema } from './schema';
|
|
2
|
+
/** Slugs of every collection referenced by a `ref` field — top-level and
|
|
3
|
+
* one level into `table` sub-fields (nested tables are schema-rejected,
|
|
4
|
+
* so a single recursion suffices). */
|
|
5
|
+
export declare function uniqueRefTargets(schema: CollectionSchema): string[];
|
|
6
|
+
/** Slugs of every collection referenced by an `embed` field. Top-level
|
|
7
|
+
* only — the schema rejects `embed` inside a table's `of`, so no
|
|
8
|
+
* recursion. */
|
|
9
|
+
export declare function uniqueEmbedTargets(schema: CollectionSchema): string[];
|
|
10
|
+
/** Slugs of every SOURCE collection a `backlinks` or `rollup` field
|
|
11
|
+
* reverses over — loaded once (the two field kinds share one load).
|
|
12
|
+
* Top-level only, like `embed` (the schema rejects both inside a table's
|
|
13
|
+
* `of`). */
|
|
14
|
+
export declare function uniqueBacklinkSources(schema: CollectionSchema): string[];
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/** Read `obj[key]` only when it is an OWN property. A bare `obj[key]` reaches
|
|
2
|
+
* inherited Object.prototype members, so a dangling ref/embed id like
|
|
3
|
+
* `"constructor"` or `"__proto__"` resolves to a prototype value (the
|
|
4
|
+
* `Object` function) instead of being absent — breaking the "missing target
|
|
5
|
+
* ⇒ null (em-dash)" fail-soft contract and putting a non-serializable
|
|
6
|
+
* function into the API response, where `JSON.stringify` drops the key
|
|
7
|
+
* entirely (#2322). A prototype key resolves to `undefined`; a record whose
|
|
8
|
+
* id is legitimately `"constructor"` (an own key) still resolves. */
|
|
9
|
+
export declare function ownProp<T>(obj: Record<string, T>, key: string): T | undefined;
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { CollectionItem } from './schema';
|
|
2
|
+
/** The schema slice `snapshotEmptyEnums` reads — minimal structural shape so
|
|
3
|
+
* both the client and server collection-schema types satisfy it as-is. */
|
|
4
|
+
export interface EnumSnapshotSchema {
|
|
5
|
+
fields: Record<string, {
|
|
6
|
+
type: string;
|
|
7
|
+
}>;
|
|
8
|
+
primaryKey: string;
|
|
9
|
+
}
|
|
10
|
+
/** Stable key for one cell (`<rowId>:<fieldKey>`) in the per-cell state maps. */
|
|
11
|
+
export declare function cellKey(rowId: string, fieldKey: string): string;
|
|
12
|
+
/** A row's stable identity: the primary-key value as text (`""` when the schema
|
|
13
|
+
* has no primary key, or the value is absent). Every per-row / per-cell state
|
|
14
|
+
* map keys off this, and it is the SAME derivation `snapshotEmptyEnums` uses
|
|
15
|
+
* for its record ids (`fieldText`), so `cellKey(rowIdOf(...), field)` and the
|
|
16
|
+
* empty-enum snapshot always agree. A real primary key is a scalar, so this
|
|
17
|
+
* matches its plain string form. */
|
|
18
|
+
export declare function rowIdOf(primaryKey: string | undefined, item: CollectionItem): string;
|
|
19
|
+
/** The set of enum cells that were empty in the freshly-fetched records — the
|
|
20
|
+
* only cells whose inline dropdown offers an empty option. */
|
|
21
|
+
export declare function snapshotEmptyEnums(schema: EnumSnapshotSchema, records: CollectionItem[]): Set<string>;
|