@mulmoclaude/core 4.8.0 → 4.9.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/bug-report-faq.md +12 -0
- package/assets/helps/custom-view.md +1 -1
- package/assets/helps/error-recovery.md +234 -26
- package/assets/helps/index.md +1 -1
- package/assets/helps/sandbox.md +2 -2
- package/assets/helps/telegram.md +10 -4
- package/dist/collection/index.cjs +3 -2
- package/dist/collection/index.cjs.map +1 -1
- package/dist/collection/index.js +3 -2
- package/dist/collection/index.js.map +1 -1
- package/dist/collection/registry/server/index.cjs +3 -3
- package/dist/collection/registry/server/index.js +3 -3
- package/dist/collection/server/index.cjs +2 -2
- package/dist/collection/server/index.js +2 -2
- package/dist/collection-watchers/index.cjs +4 -4
- package/dist/collection-watchers/index.js +4 -4
- package/dist/{discovery-BY-nMCuh.js → discovery-BkIKvdh0.js} +3 -3
- package/dist/{discovery-BY-nMCuh.js.map → discovery-BkIKvdh0.js.map} +1 -1
- package/dist/{discovery-2TvulIz8.cjs → discovery-tvFo-bUJ.cjs} +3 -3
- package/dist/{discovery-2TvulIz8.cjs.map → discovery-tvFo-bUJ.cjs.map} +1 -1
- package/dist/{dist-D8zokgGo.js → dist-BzoA9pDR.js} +6 -1
- package/dist/dist-BzoA9pDR.js.map +1 -0
- package/dist/{dist-CsgSfWwR.cjs → dist-Gj7ygaW2.cjs} +6 -1
- package/dist/dist-Gj7ygaW2.cjs.map +1 -0
- package/dist/errorMessage-BQNpSYdT.js +1 -0
- package/dist/errorMessage-CT-va_Pv.cjs +1 -0
- package/dist/errors-C7TI_t_4.js +10 -0
- package/dist/errors-C7TI_t_4.js.map +1 -0
- package/dist/errors-CdXsfFCF.cjs +15 -0
- package/dist/errors-CdXsfFCF.cjs.map +1 -0
- package/dist/feeds/server/index.cjs +4 -4
- package/dist/feeds/server/index.js +4 -4
- package/dist/google/index.cjs +3 -2
- package/dist/google/index.cjs.map +1 -1
- package/dist/google/index.js +3 -2
- package/dist/google/index.js.map +1 -1
- package/dist/{listen-UZA68Upt.cjs → listen-BXthb-9C.cjs} +2 -2
- package/dist/{listen-UZA68Upt.cjs.map → listen-BXthb-9C.cjs.map} +1 -1
- package/dist/{listen-D5av7GzB.js → listen-Dca-NoBi.js} +2 -2
- package/dist/{listen-D5av7GzB.js.map → listen-Dca-NoBi.js.map} +1 -1
- package/dist/notifier/index.cjs +1 -1
- package/dist/notifier/index.js +1 -1
- package/dist/notifier/types.d.ts +20 -11
- package/dist/{notifier-DZJj0sOh.cjs → notifier-B65Mvngb.cjs} +12 -7
- package/dist/notifier-B65Mvngb.cjs.map +1 -0
- package/dist/{notifier-DhnwR82U.js → notifier-vAp5t6wL.js} +12 -7
- package/dist/notifier-vAp5t6wL.js.map +1 -0
- package/dist/plugin-vue/index.cjs +31 -12
- package/dist/plugin-vue/index.cjs.map +1 -1
- package/dist/plugin-vue/index.js +31 -12
- package/dist/plugin-vue/index.js.map +1 -1
- package/dist/{promptSafety-Bte62OvU.js → promptSafety-BpBTh5xO.js} +2 -2
- package/dist/{promptSafety-Bte62OvU.js.map → promptSafety-BpBTh5xO.js.map} +1 -1
- package/dist/{promptSafety-Cv-SoTd4.cjs → promptSafety-caJWd1ZQ.cjs} +2 -2
- package/dist/{promptSafety-Cv-SoTd4.cjs.map → promptSafety-caJWd1ZQ.cjs.map} +1 -1
- package/dist/remote-host/index.cjs +1 -1
- package/dist/remote-host/index.js +1 -1
- package/dist/remote-host/server/index.cjs +30 -7
- package/dist/remote-host/server/index.cjs.map +1 -1
- package/dist/remote-host/server/index.js +30 -7
- package/dist/remote-host/server/index.js.map +1 -1
- package/dist/remote-host/server/sessionPersistence.d.ts +7 -2
- package/dist/{remote-host-CFnl1I-L.js → remote-host-Cjox2F2J.js} +2 -2
- package/dist/{remote-host-CFnl1I-L.js.map → remote-host-Cjox2F2J.js.map} +1 -1
- package/dist/{remote-host-EQPlccB9.cjs → remote-host-DhAHBYzN.cjs} +2 -2
- package/dist/{remote-host-EQPlccB9.cjs.map → remote-host-DhAHBYzN.cjs.map} +1 -1
- package/dist/remote-view/index.cjs +1 -1
- package/dist/remote-view/index.js +1 -1
- package/dist/scheduler/index.cjs +2 -1
- package/dist/scheduler/index.cjs.map +1 -1
- package/dist/scheduler/index.js +2 -1
- package/dist/scheduler/index.js.map +1 -1
- package/dist/{server-DXUt34Rt.cjs → server-Div1i_k5.cjs} +5 -4
- package/dist/{server-DXUt34Rt.cjs.map → server-Div1i_k5.cjs.map} +1 -1
- package/dist/{server-DyYwfNB6.js → server-Dk9x1VLG.js} +5 -4
- package/dist/{server-DyYwfNB6.js.map → server-Dk9x1VLG.js.map} +1 -1
- package/dist/utils/index.cjs +3 -10
- package/dist/utils/index.js +2 -9
- package/dist/whisper/index.cjs +2 -1
- package/dist/whisper/index.cjs.map +1 -1
- package/dist/whisper/index.js +2 -1
- package/dist/whisper/index.js.map +1 -1
- package/dist/wiki/index.cjs +1 -1
- package/dist/wiki/index.js +1 -1
- package/dist/workspace-setup/index.js +5 -0
- package/dist/workspace-setup/index.js.map +1 -1
- package/package.json +7 -7
- package/dist/dist-CsgSfWwR.cjs.map +0 -1
- package/dist/dist-D8zokgGo.js.map +0 -1
- package/dist/notifier-DZJj0sOh.cjs.map +0 -1
- package/dist/notifier-DhnwR82U.js.map +0 -1
- package/dist/utils/index.cjs.map +0 -1
- package/dist/utils/index.js.map +0 -1
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.js","names":[],"sources":["../../../common/dist/index.js","../../src/workspace-setup/sync.ts","../../src/workspace-setup/assets.ts","../../src/workspace-setup/faq-entries.ts"],"sourcesContent":["// General-purpose runtime type guards, shared across the MulmoClaude host,\n// bridges, and plugins. This is a leaf package — pure and dependency-free — so\n// any tier can import it without creating an uphill edge.\n//\n// These originated as `server/utils/types.ts` (#504), which centralised 40+\n// hand-written inline `typeof x === \"object\"` checks. They are promoted here so\n// the same guards stop being re-hand-written in every bridge and plugin too.\n/** Narrow `unknown` to a plain object (not null, not array). */\nexport function isRecord(value) {\n return typeof value === \"object\" && value !== null && !Array.isArray(value);\n}\n/** Narrow `unknown` to any object (not null, arrays allowed).\n * Use `isRecord` when you need to access string keys. */\nexport function isObj(value) {\n return typeof value === \"object\" && value !== null;\n}\n/** Non-empty string after trimming whitespace. */\nexport function isNonEmptyString(value) {\n return typeof value === \"string\" && value.trim().length > 0;\n}\n/** Record whose values are all strings. */\nexport function isStringRecord(value) {\n if (!isRecord(value))\n return false;\n return Object.values(value).every((val) => typeof val === \"string\");\n}\n/** String array (every element is a string). */\nexport function isStringArray(value) {\n return Array.isArray(value) && value.every((val) => typeof val === \"string\");\n}\n/** An array of unknowns. Prefer this over a bare `Array.isArray` in typed code:\n * `Array.isArray(x: unknown)` narrows to `any[]`, silently reintroducing\n * `any`, whereas this keeps the element type `unknown`. */\nexport function isUnknownArray(value) {\n return Array.isArray(value);\n}\n/** Error-like object with a `code` property (e.g. Node.js fs errors). */\nexport function isErrorWithCode(value) {\n return isRecord(value) && typeof value.code === \"string\";\n}\n/** Check that a record has a specific key with a string value. */\nexport function hasStringProp(value, key) {\n return isRecord(value) && typeof value[key] === \"string\";\n}\n/** Check that a record has a specific key with a number value. */\nexport function hasNumberProp(value, key) {\n return isRecord(value) && typeof value[key] === \"number\";\n}\n/** Split a comma-separated env value into trimmed, non-empty entries.\n * `lowercase` folds case for identifiers compared case-insensitively\n * (JIDs, email addresses, hex pubkeys). Absent/empty input → empty list. */\nexport function parseCsvList(raw, opts) {\n return (raw ?? \"\")\n .split(\",\")\n .map((entry) => (opts?.lowercase ? entry.trim().toLowerCase() : entry.trim()))\n .filter(Boolean);\n}\n/** A comma-separated env value as a Set — the canonical allowlist shape,\n * where an empty set is the \"allow all\" sentinel (`set.size === 0`). */\nexport function parseCsvSet(raw, opts) {\n return new Set(parseCsvList(raw, opts));\n}\n/** Normalise an unknown thrown value into a human-readable string. Isomorphic\n * (host, bridges, plugins, Vue) — this is the single home for the helper that\n * #2217 could only consolidate for server code, since `@mulmoclaude/core/utils`\n * is server-only.\n *\n * A non-Error object with a non-empty string `details` (gRPC convention) or\n * `message` field surfaces that field — `details` wins — instead of the\n * `[object Object]` a bare `String(err)` would print; an empty-string field\n * falls through. `fallback` covers the error-boundary idiom where a thrown\n * non-Error should read as a descriptive message rather than `String(err)`\n * noise; omit it in logging contexts where `String(err)` is fine. */\nexport function errorMessage(err, fallback) {\n if (err instanceof Error)\n return err.message;\n if (hasStringProp(err, \"details\") && err.details)\n return err.details;\n if (hasStringProp(err, \"message\") && err.message)\n return err.message;\n if (fallback !== undefined)\n return fallback;\n return String(err);\n}\n/** `Date` → `YYYY-MM-DD` in UTC — for dates that must not shift with the\n * host's local timezone (tool-trace search dirs, API date keys). Isomorphic\n * single source (#2480): the host re-exports it from `server/utils/date.ts`,\n * x-plugin imports it directly. The `@receptron/task-scheduler` copy stays\n * local on purpose — that leaf package is published independently and kept\n * dependency-free. Wall-clock questions use the host's `toLocalIsoDate`. */\nexport function toUtcIsoDate(timestamp) {\n const year = timestamp.getUTCFullYear();\n const month = String(timestamp.getUTCMonth() + 1).padStart(2, \"0\");\n const day = String(timestamp.getUTCDate()).padStart(2, \"0\");\n return `${year}-${month}-${day}`;\n}\n// A Map, not an object literal: `{}[char]` reads through the prototype chain,\n// so a future caller widening the regex would silently get `[object Object]`\n// for keys like `constructor`.\nconst HTML_ESCAPES = new Map([\n [\"&\", \"&\"],\n [\"<\", \"<\"],\n [\">\", \">\"],\n ['\"', \""\"],\n [\"'\", \"'\"],\n]);\n/** HTML-escape text destined for markup or an attribute value — the fixed\n * five-character map, nothing more. Lives here rather than in\n * `@mulmoclaude/core/wiki` (#2483) because `@mulmoclaude/markdown-utils` is a\n * leaf that core depends on and so cannot import back up; core/wiki\n * re-exports this, keeping its consumers' import path unchanged.\n *\n * Escaping `&` first is what makes a single pass safe — the entities this\n * introduces contain none of the other four characters, so nothing is\n * double-escaped. Not a sanitiser: it neither strips tags nor validates URLs. */\nexport function escapeHtml(value) {\n return value.replace(/[&<>\"']/g, (char) => HTML_ESCAPES.get(char) ?? char);\n}\n/** Split a JWS compact serialization into its three segments, or `null` when the\n * token isn't well-formed. Pure string work, so the Node bridges and the\n * Cloudflare Workers relay — which decode the segments differently (`Buffer` vs\n * `atob`) — can still share this one guard.\n *\n * A JWS compact serialization is EXACTLY three segments. Both a short token and\n * a longer one (a five-segment JWE, or an attacker appending `.junk`) are\n * rejected outright — never parsed from their first three segments, which would\n * let the signed input disagree with the token. */\nexport function splitJwtSegments(token) {\n const [headerSegment, payloadSegment, signatureSegment, ...extraSegments] = token.split(\".\");\n if (headerSegment === undefined || payloadSegment === undefined || signatureSegment === undefined)\n return null;\n if (extraSegments.length > 0)\n return null;\n return { headerSegment, payloadSegment, signatureSegment };\n}\nexport { scanEnvOptions, snakeToLowerCamel } from \"./envScan.js\";\n","// Preset skills bundled with mulmoclaude.\n//\n// History: introduced in #1210 to ship launcher-managed \"factory\"\n// skills like `mc-library`. Originally synced straight into\n// `<workspaceRoot>/.claude/skills/<slug>/`, which made every preset\n// auto-active and inflated the Claude system prompt as new presets\n// landed.\n//\n// #1335 PR-A flipped the destination to the catalog\n// (`<workspaceRoot>/data/skills/catalog/preset/<slug>/`). Catalog\n// entries are visible to UI / tooling but NOT discovered by Claude\n// Code's slash-command resolver — they don't enter the system\n// prompt unless the user (or a later UI in PR-B) explicitly copies\n// one into `.claude/skills/`.\n//\n// The launcher overwrites catalog entries unconditionally on every\n// boot — they're factory defaults, not user state. Anything in\n// `.claude/skills/` (active layer) is left untouched.\n//\n// `syncPresetSkills(...)` is exported as a pure-ish helper (takes\n// paths + a logger sink, returns a summary) so tests can drive it\n// against tmpdirs without touching a real workspace.\n\nimport { copyFileSync, existsSync, lstatSync, mkdirSync, readdirSync, readFileSync, realpathSync, renameSync, rmSync, statSync, type Dirent } from \"node:fs\";\nimport { randomUUID } from \"node:crypto\";\nimport path from \"node:path\";\nimport { PRESET_SLUG_PREFIX, isPresetSlug } from \"./slug.js\";\nimport { errorMessage } from \"../utils/errors.js\";\n\n// Recursively mirror `srcDir` into `destDir`. Used by the preset\n// sync so a preset skill that ships sibling assets (e.g.\n// `schema.json` for schema-driven apps, `templates/*.html`) gets\n// copied alongside `SKILL.md` rather than silently dropped. Only\n// regular files and directories are followed — symlinks / FIFOs /\n// sockets are skipped because the preset tree is launcher-managed\n// and shouldn't contain them.\nfunction copyDirTreeSync(srcDir: string, destDir: string): void {\n mkdirSync(destDir, { recursive: true });\n for (const entry of readdirSync(srcDir, { withFileTypes: true })) {\n const srcPath = path.join(srcDir, entry.name);\n const destPath = path.join(destDir, entry.name);\n if (entry.isDirectory()) {\n copyDirTreeSync(srcPath, destPath);\n } else if (entry.isFile()) {\n copyFileSync(srcPath, destPath);\n }\n }\n}\n\nconst SKILL_FILENAME = \"SKILL.md\";\n\nexport interface SyncPresetSkillsOptions {\n /** Source directory: `<launcher>/server/workspace/skills-preset/`. */\n sourceDir: string;\n /** Destination directory:\n * `<workspaceRoot>/data/skills/catalog/preset/`. The catalog\n * half of the catalog-vs-active split — entries here are visible\n * to UI but NOT to Claude Code's prompt-time skill resolver. */\n destDir: string;\n /** Logger callbacks — kept injectable so tests don't need to\n * spin up the structured logger. The boot-side wrapper threads\n * these through to `log.info` / `log.warn`. */\n onInfo?: (message: string, data?: Record<string, unknown>) => void;\n onWarn?: (message: string, data?: Record<string, unknown>) => void;\n}\n\nexport interface SyncPresetSkillsResult {\n /** Slugs successfully copied (or refreshed) from source to dest. */\n copied: string[];\n /** Slugs removed from dest because they no longer exist in source.\n * Bounded to `mc-*` entries — user-authored slugs are never\n * considered for removal. */\n removed: string[];\n /** Source entries that failed validation (wrong prefix, missing\n * SKILL.md, etc.) and were skipped. Each entry is human-readable. */\n skipped: string[];\n}\n\n// Classification of one source entry. `silent` distinguishes\n// structural skips (hidden files, non-directory entries — not the\n// dev's fault) from misconfigurations (bad slug, missing or\n// non-regular SKILL.md — the dev WANTS to know). The boolean lives\n// on the verdict so the caller never has to string-match `reason`,\n// which would silently drop warnings if reason wording changed\n// (CodeRabbit review).\ntype Verdict = { ok: true } | { ok: false; reason: string; silent: boolean };\n\nfunction classifySourceEntry(sourceDir: string, entry: string): Verdict {\n if (entry.startsWith(\".\")) return { ok: false, reason: \"hidden\", silent: true };\n const slugDir = path.join(sourceDir, entry);\n let dirInfo;\n try {\n dirInfo = statSync(slugDir);\n } catch {\n return { ok: false, reason: \"stat failed\", silent: true };\n }\n if (!dirInfo.isDirectory()) return { ok: false, reason: \"not a directory\", silent: true };\n if (!isPresetSlug(entry)) return { ok: false, reason: `slug must start with \"${PRESET_SLUG_PREFIX}\"`, silent: false };\n // Validate SKILL.md is a regular file — `existsSync` alone\n // accepts a directory at that path, which would then crash\n // copyFileSync. Codex review caught this edge case.\n const skillPath = path.join(slugDir, SKILL_FILENAME);\n let skillInfo;\n try {\n skillInfo = statSync(skillPath);\n } catch {\n return { ok: false, reason: `missing ${SKILL_FILENAME}`, silent: false };\n }\n if (!skillInfo.isFile()) return { ok: false, reason: `${SKILL_FILENAME} must be a regular file`, silent: false };\n return { ok: true };\n}\n\n/** Prepare the destination slug dir. Returns false if the slot is\n * occupied by a regular file (local corruption / hand edits) — the\n * caller logs + skips so one bad entry can't crash the whole boot\n * (Codex review iter-1). */\nfunction ensureDestSlugDir(destSlugDir: string): boolean {\n let info;\n try {\n info = statSync(destSlugDir);\n } catch {\n mkdirSync(destSlugDir, { recursive: true });\n return true;\n }\n return info.isDirectory();\n}\n\n/** Filesystem ops `_replaceSlugTree` depends on — injectable so the\n * mid-swap failure path can be regression-tested without real IO faults. */\nexport interface SlugTreeFsOps {\n copyTree: (src: string, dest: string) => void;\n rename: (from: string, dest: string) => void;\n remove: (target: string) => void;\n exists: (target: string) => boolean;\n}\n\nconst REAL_FS_OPS: SlugTreeFsOps = {\n copyTree: copyDirTreeSync,\n rename: renameSync,\n remove: (target) => rmSync(target, { recursive: true, force: true }),\n exists: existsSync,\n};\n\n/** Refresh one preset slot as a rollback-safe stage-and-swap. Wipe-and-replace\n * (not merge) so stale sibling assets — e.g. a schema.json dropped between\n * releases — don't linger; the catalog preset slot is launcher-owned, so user\n * edits there are not preserved across boots.\n *\n * The live slot is never left without a recoverable copy:\n * 1. Stage: copy the source into a temp sibling. A copy failure leaves the\n * existing preset untouched (temp dir removed).\n * 2. Move the existing tree ASIDE to a backup (rename, not delete), so the\n * old contents survive even if the next step fails.\n * 3. Move the staged copy into place. On failure, restore from the backup;\n * if even the restore fails, BOTH backup and staging are preserved for\n * manual recovery rather than deleted.\n * 4. On success, drop the backup.\n *\n * Exported with a `_` prefix only so the rename-failure-after-move path can be\n * regression-tested via the injected `fsOps`. Throws on failure (caller records\n * + skips). */\nexport function _replaceSlugTree(sourceSlugDir: string, destSlugDir: string, fsOps: SlugTreeFsOps = REAL_FS_OPS): void {\n const staging = `${destSlugDir}.tmp-${randomUUID()}`;\n try {\n fsOps.copyTree(sourceSlugDir, staging);\n } catch (err) {\n fsOps.remove(staging); // dest untouched\n throw err;\n }\n const backup = `${destSlugDir}.bak-${randomUUID()}`;\n let backedUp = false;\n if (fsOps.exists(destSlugDir)) {\n try {\n fsOps.rename(destSlugDir, backup);\n backedUp = true;\n } catch (err) {\n fsOps.remove(staging); // dest untouched; nothing was moved\n throw err;\n }\n }\n try {\n fsOps.rename(staging, destSlugDir);\n } catch (err) {\n if (backedUp) {\n try {\n fsOps.rename(backup, destSlugDir); // roll back to the previous tree\n fsOps.remove(staging);\n } catch {\n // Rollback failed — preserve BOTH backup and staging for manual\n // recovery rather than leaving the slot empty.\n }\n } else {\n fsOps.remove(staging); // dest never existed; nothing to restore\n }\n throw err;\n }\n if (backedUp) fsOps.remove(backup);\n}\n\nfunction copySourcesIntoDest(sourceDir: string, destDir: string, opts: SyncPresetSkillsOptions, result: SyncPresetSkillsResult): Set<string> {\n // `keep` is the set of slugs the retirement pass must NOT prune. It tracks\n // valid SOURCE slugs (present + usable dest slot), NOT successful copies — so\n // a slug whose refresh fails transiently keeps its existing contents (left\n // intact by the stage-and-swap above) instead of being pruned as \"retired\".\n const keep = new Set<string>();\n for (const entry of readdirSync(sourceDir)) {\n const verdict = classifySourceEntry(sourceDir, entry);\n if (!verdict.ok) {\n if (!verdict.silent) {\n result.skipped.push(`${entry}: ${verdict.reason}`);\n opts.onWarn?.(\"preset entry skipped\", { slug: entry, reason: verdict.reason });\n }\n continue;\n }\n const destSlugDir = path.join(destDir, entry);\n if (!ensureDestSlugDir(destSlugDir)) {\n const reason = \"destination slot occupied by a non-directory; skipping\";\n result.skipped.push(`${entry}: ${reason}`);\n opts.onWarn?.(\"preset entry skipped\", { slug: entry, reason, destSlugDir });\n continue;\n }\n // The slug exists in source with a usable dest slot — keep it regardless of\n // whether the refresh below succeeds, so a transient failure doesn't get it\n // pruned by `removeRetiredPresets`.\n keep.add(entry);\n // Per-slug isolation: a transient IO error / permission issue / partial\n // corruption on one preset must not abort syncing the rest, nor destroy\n // the existing copy. Skip the offender (recorded) and continue.\n try {\n _replaceSlugTree(path.join(sourceDir, entry), destSlugDir);\n result.copied.push(entry);\n } catch (err) {\n const reason = err instanceof Error ? err.message : String(err);\n result.skipped.push(`${entry}: ${reason}`);\n opts.onWarn?.(\"preset copy failed, skipping\", { slug: entry, reason });\n }\n }\n return keep;\n}\n\nfunction removeRetiredPresets(destDir: string, keep: ReadonlySet<string>, opts: SyncPresetSkillsOptions, result: SyncPresetSkillsResult): void {\n for (const entry of readdirSync(destDir)) {\n if (!isPresetSlug(entry)) continue;\n if (keep.has(entry)) continue;\n const stalePath = path.join(destDir, entry);\n try {\n if (!statSync(stalePath).isDirectory()) continue;\n } catch {\n continue;\n }\n rmSync(stalePath, { recursive: true, force: true });\n result.removed.push(entry);\n opts.onInfo?.(\"removed retired preset skill\", { slug: entry });\n }\n}\n\n/** Copy every preset slug from `sourceDir` into `destDir` (the\n * preset slot under the catalog root), then remove any `mc-*`\n * entries in `destDir` that no longer have a source. The catalog\n * preset subdir is fully launcher-owned, so the `mc-*` prefix\n * check at destination is defence-in-depth: a stray non-preset\n * slug landing in `catalog/preset/` is unexpected, and we'd\n * rather skip it than silently delete a directory we don't\n * recognise. */\nexport function syncPresetSkills(opts: SyncPresetSkillsOptions): SyncPresetSkillsResult {\n const result: SyncPresetSkillsResult = { copied: [], removed: [], skipped: [] };\n if (!existsSync(opts.sourceDir)) {\n // No preset directory in the launcher tarball — nothing to do.\n // This is the legitimate \"no presets shipped yet\" state.\n return result;\n }\n // Source-side validation: the launcher's preset path COULD exist\n // as a regular file (a packaging bug, a corrupted install). Without\n // this guard, `readdirSync(sourceDir)` would throw ENOTDIR and\n // crash boot. Codex review iter-3.\n let sourceInfo;\n try {\n sourceInfo = statSync(opts.sourceDir);\n } catch (err) {\n const reason = `source path stat failed: ${errorMessage(err)}`;\n result.skipped.push(`${opts.sourceDir}: ${reason}`);\n opts.onWarn?.(\"preset sync aborted\", { sourceDir: opts.sourceDir, reason });\n return result;\n }\n if (!sourceInfo.isDirectory()) {\n const reason = \"source path exists as a non-directory; preset sync skipped\";\n result.skipped.push(`${opts.sourceDir}: ${reason}`);\n opts.onWarn?.(\"preset sync aborted\", { sourceDir: opts.sourceDir, reason });\n return result;\n }\n // The root dest itself can be corrupted into a regular file by a\n // user / external tool; mkdirSync would throw EEXIST and crash\n // boot. Treat it as a recoverable \"skip the entire sync\" state\n // — log a clear warning so the user sees what to fix.\n // (Codex review iter-2.)\n if (!ensureDestSlugDir(opts.destDir)) {\n const reason = \"root dest exists as a non-directory; preset sync skipped\";\n result.skipped.push(`${opts.destDir}: ${reason}`);\n opts.onWarn?.(\"preset sync aborted\", { destDir: opts.destDir, reason });\n return result;\n }\n const keep = copySourcesIntoDest(opts.sourceDir, opts.destDir, opts, result);\n removeRetiredPresets(opts.destDir, keep, opts, result);\n if (result.copied.length > 0 || result.removed.length > 0) {\n opts.onInfo?.(\"preset skills synced\", {\n copied: result.copied.length,\n removed: result.removed.length,\n skipped: result.skipped.length,\n });\n }\n return result;\n}\n\n// ---------------------------------------------------------------\n// Active-layer sync: keep starred mc-* presets in lockstep with\n// their launcher-bundled source.\n//\n// Motivation: the catalog sync above refreshes\n// `data/skills/catalog/preset/<slug>/` on every boot, but once a\n// user stars an entry the active copy in\n// `<workspace>/.claude/skills/<slug>/` is never updated even when\n// the launcher ships a new SKILL.md (e.g. a typo fix or, as the\n// trigger for this code, the schema-driven-apps → collections\n// rename). The SKILL.md front-matter explicitly says\n// \"do not edit this file in the workspace, it is overwritten on\n// every server boot\" — until this function existed, that was a\n// promise the active layer didn't keep.\n//\n// Safety model:\n// - Only `mc-*` slugs are touched (defensive prefix check —\n// never touches user-authored skills).\n// - Per-file diff: a file is overwritten only if its bytes\n// differ from the source. No-op when already up to date.\n// - User-added files inside an active slug dir are left alone\n// (we walk the source tree, not the dest tree).\n// - If a file IS overwritten, the previous contents are first\n// renamed to `<file>.bak.<timestamp>` so a user who had\n// locally tweaked the preset can recover.\n// - A slug whose active dir doesn't exist (= not starred yet)\n// is skipped entirely, never auto-starred.\n// ---------------------------------------------------------------\n\nexport interface SyncActivePresetSkillsOptions {\n /** Source directory: `<launcher>/server/workspace/skills-preset/`. */\n sourceDir: string;\n /** Active skills directory: `<workspaceRoot>/.claude/skills/`. */\n activeDir: string;\n onInfo?: (message: string, data?: Record<string, unknown>) => void;\n onWarn?: (message: string, data?: Record<string, unknown>) => void;\n}\n\nexport interface SyncActivePresetSkillsResult {\n /** Slugs whose active copy had at least one file overwritten. */\n updated: string[];\n /** Slugs whose active copy already matched the source — no-op. */\n unchanged: string[];\n /** Slugs that haven't been starred yet (no active dir present).\n * Listed for diagnostics; the function never auto-stars. */\n notActive: string[];\n /** Active `mc-*` slugs pruned because the launcher no longer ships a\n * source preset for them (a retired preset). Bounded to `mc-*` —\n * user-authored skills are never removed. */\n removed: string[];\n /** Per-slug failure messages (permission errors, etc.). */\n skipped: string[];\n /** Common timestamp suffix used for every backup file produced by\n * this run. Exposed so the boot-time log can point a user at the\n * exact glob to inspect. */\n backupSuffix: string | null;\n}\n\ntype FileSyncOutcome = \"updated\" | \"unchanged\" | \"skipped\";\n\nfunction filesEqual(left: string, right: string): boolean {\n try {\n return readFileSync(left).equals(readFileSync(right));\n } catch {\n return false;\n }\n}\n\n/** Copy `srcPath` over `destPath`. If `destPath` already exists and\n * differs, rename it to `destPath + backupExt` first so the user's\n * prior contents are recoverable. If `destPath` doesn't exist, just\n * copy (no backup needed). Pure: no logging here — caller decides. */\nfunction syncOneFile(srcPath: string, destPath: string, backupExt: string): FileSyncOutcome {\n let destExists: boolean;\n try {\n destExists = statSync(destPath).isFile();\n } catch {\n destExists = false;\n }\n if (!destExists) {\n try {\n copyFileSync(srcPath, destPath);\n return \"updated\";\n } catch {\n return \"skipped\";\n }\n }\n if (filesEqual(srcPath, destPath)) return \"unchanged\";\n try {\n renameSync(destPath, destPath + backupExt);\n copyFileSync(srcPath, destPath);\n return \"updated\";\n } catch {\n return \"skipped\";\n }\n}\n\ninterface DirSyncStats {\n updated: number;\n unchanged: number;\n skipped: number;\n}\n\nfunction syncDirTreeDiff(srcDir: string, destDir: string, backupExt: string): DirSyncStats {\n const stats: DirSyncStats = { updated: 0, unchanged: 0, skipped: 0 };\n try {\n mkdirSync(destDir, { recursive: true });\n } catch {\n stats.skipped++;\n return stats;\n }\n let entries: Dirent[];\n try {\n entries = readdirSync(srcDir, { withFileTypes: true });\n } catch {\n stats.skipped++;\n return stats;\n }\n for (const entry of entries) {\n const srcPath = path.join(srcDir, entry.name);\n const destPath = path.join(destDir, entry.name);\n if (entry.isDirectory()) {\n const sub = syncDirTreeDiff(srcPath, destPath, backupExt);\n stats.updated += sub.updated;\n stats.unchanged += sub.unchanged;\n stats.skipped += sub.skipped;\n } else if (entry.isFile()) {\n const outcome = syncOneFile(srcPath, destPath, backupExt);\n stats[outcome]++;\n }\n // Symlinks / sockets / FIFOs intentionally ignored (the launcher\n // preset tree shouldn't contain them).\n }\n return stats;\n}\n\nfunction abortActiveSync(opts: SyncActivePresetSkillsOptions, result: SyncActivePresetSkillsResult, reason: string): SyncActivePresetSkillsResult {\n result.skipped.push(`${opts.sourceDir}: ${reason}`);\n opts.onWarn?.(\"active preset sync aborted\", { sourceDir: opts.sourceDir, reason });\n return result;\n}\n\n/** True iff `absPath`'s realpath resolves inside `rootPath`'s\n * realpath. Defends `processActiveSlug` against a starred `mc-*`\n * slug that's actually a symlink to somewhere outside\n * `activeDir`: without this check, the recursive copy below would\n * follow the symlink and write through to the link's target\n * (potentially anywhere on disk). Returns false on any error so\n * the caller treats unreadable paths as \"refused\" rather than\n * \"OK to write\". */\nfunction isRealpathInside(absPath: string, rootPath: string): boolean {\n try {\n const real = realpathSync(absPath);\n const rootReal = realpathSync(rootPath);\n return real === rootReal || real.startsWith(rootReal + path.sep);\n } catch {\n return false;\n }\n}\n\ntype DestVerdict = { ok: true } | { ok: false; reason: string } | { kind: \"not-active\" };\n\n/** Validate the active dest slug dir before writing through it.\n * Returns:\n * - `{ ok: true }` — proceed with the sync\n * - `{ kind: \"not-active\" }` — slug hasn't been starred yet\n * - `{ ok: false; reason }` — refuse to write (symlink escape,\n * non-directory, ancestor escape)\n *\n * Symlink defenses (Codex P1 review on PR #1490): `statSync`\n * follows symlinks, so a starred `mc-*` slug that's actually a\n * symlink to /etc would let the recursive copy below write\n * outside the workspace. Two-layer defense:\n * 1. `lstatSync` to see the link itself; if it's a symlink,\n * only accept when its target stays inside `activeDir`.\n * 2. Always realpath-verify the full path (catches the case\n * where an ancestor like `.claude/` is symlinked even when\n * the slug dir itself is a regular directory). */\nfunction classifyActiveDest(slug: string, destSlugDir: string, activeDir: string): DestVerdict {\n let destInfo;\n try {\n destInfo = lstatSync(destSlugDir);\n } catch {\n return { kind: \"not-active\" };\n }\n if (destInfo.isSymbolicLink()) {\n if (!isRealpathInside(destSlugDir, activeDir)) {\n return { ok: false, reason: \"active slot is a symlink whose target escapes activeDir; refusing to write through it\" };\n }\n return { ok: true };\n }\n if (!destInfo.isDirectory()) {\n return { ok: false, reason: \"active slot is occupied by a non-directory; skipping\" };\n }\n if (!isRealpathInside(destSlugDir, activeDir)) {\n return { ok: false, reason: \"active slug dir escapes activeDir via an ancestor symlink\" };\n }\n return { ok: true };\n}\n\n/** Per-slug worker for `syncActivePresetSkills`. Extracted to keep\n * the outer function under the `sonarjs/cognitive-complexity`\n * threshold; no behavior difference vs the inline loop. */\nfunction processActiveSlug(slug: string, opts: SyncActivePresetSkillsOptions, result: SyncActivePresetSkillsResult, backupExt: string): void {\n const srcSlugDir = path.join(opts.sourceDir, slug);\n try {\n if (!statSync(srcSlugDir).isDirectory()) return;\n } catch {\n return;\n }\n const destSlugDir = path.join(opts.activeDir, slug);\n const verdict = classifyActiveDest(slug, destSlugDir, opts.activeDir);\n if (\"kind\" in verdict) {\n result.notActive.push(slug);\n return;\n }\n if (!verdict.ok) {\n result.skipped.push(`${slug}: ${verdict.reason}`);\n opts.onWarn?.(\"active preset sync skipped\", { slug, reason: verdict.reason, destSlugDir });\n return;\n }\n const stats = syncDirTreeDiff(srcSlugDir, destSlugDir, backupExt);\n if (stats.updated > 0) {\n result.updated.push(slug);\n opts.onInfo?.(\"active preset skill updated from source\", { slug, files: stats.updated, backupSuffix: backupExt });\n } else if (stats.skipped === 0) {\n result.unchanged.push(slug);\n }\n if (stats.skipped > 0) {\n result.skipped.push(`${slug}: ${stats.skipped} file(s) skipped`);\n opts.onWarn?.(\"active preset skill partial update\", { slug, skipped: stats.skipped });\n }\n}\n\n/** Realpath targets of active symlinks. Used so the prune below never\n * deletes a directory that's serving as a symlink target (e.g. a\n * git-managed checkout the user symlinks an active slug to). */\nfunction collectSymlinkTargets(activeDir: string, entries: readonly string[]): Set<string> {\n const targets = new Set<string>();\n for (const slug of entries) {\n const entryPath = path.join(activeDir, slug);\n try {\n if (lstatSync(entryPath).isSymbolicLink()) targets.add(realpathSync(entryPath));\n } catch {\n /* unreadable — ignore */\n }\n }\n return targets;\n}\n\n/** True iff the active `slug` is a retired preset safe to delete: an\n * `mc-*` real directory inside `activeDir`, with no source preset, and\n * not itself a symlink or a symlink target. */\nfunction isRetiredActiveSlug(slug: string, activeDir: string, sourceSlugs: ReadonlySet<string>, symlinkTargets: ReadonlySet<string>): boolean {\n if (!isPresetSlug(slug) || sourceSlugs.has(slug)) return false;\n const destSlugDir = path.join(activeDir, slug);\n let info;\n try {\n info = lstatSync(destSlugDir);\n } catch {\n return false;\n }\n if (info.isSymbolicLink() || !info.isDirectory()) return false;\n if (!isRealpathInside(destSlugDir, activeDir)) return false;\n try {\n return !symlinkTargets.has(realpathSync(destSlugDir));\n } catch {\n return false;\n }\n}\n\n/** Prune active `mc-*` slugs whose launcher source preset no longer\n * exists (a retired preset). Without this, an upgraded workspace that\n * had starred a since-removed preset (e.g. `mc-manage-sources`) keeps\n * the active copy forever — Claude would still discover a skill whose\n * backend was deleted. Bounded to `mc-*` real directories that stay\n * inside `activeDir` (never a symlink escape, never a user skill). */\nfunction removeRetiredActivePresets(opts: SyncActivePresetSkillsOptions, result: SyncActivePresetSkillsResult, sourceSlugs: ReadonlySet<string>): void {\n let activeEntries: string[];\n try {\n activeEntries = readdirSync(opts.activeDir);\n } catch {\n return; // no active dir yet → nothing to prune\n }\n const symlinkTargets = collectSymlinkTargets(opts.activeDir, activeEntries);\n for (const slug of activeEntries) {\n if (!isRetiredActiveSlug(slug, opts.activeDir, sourceSlugs, symlinkTargets)) continue;\n try {\n rmSync(path.join(opts.activeDir, slug), { recursive: true, force: true });\n result.removed.push(slug);\n opts.onInfo?.(\"removed retired active preset skill\", { slug });\n } catch (err) {\n result.skipped.push(`${slug}: prune failed: ${errorMessage(err)}`);\n opts.onWarn?.(\"active preset prune failed\", { slug, reason: errorMessage(err) });\n }\n }\n}\n\n/** Refresh every already-starred `mc-*` preset's active copy in\n * `<workspaceRoot>/.claude/skills/<slug>/` to match the source.\n * Per-file diff with `.bak.<timestamp>` backup on overwrite. Slugs\n * that aren't starred yet are listed in `notActive` but never\n * auto-created. */\nexport function syncActivePresetSkills(opts: SyncActivePresetSkillsOptions): SyncActivePresetSkillsResult {\n const result: SyncActivePresetSkillsResult = { updated: [], unchanged: [], notActive: [], removed: [], skipped: [], backupSuffix: null };\n if (!existsSync(opts.sourceDir)) return result;\n let sourceInfo;\n try {\n sourceInfo = statSync(opts.sourceDir);\n } catch (err) {\n return abortActiveSync(opts, result, `source path stat failed: ${errorMessage(err)}`);\n }\n if (!sourceInfo.isDirectory()) {\n return abortActiveSync(opts, result, \"source path exists as a non-directory; active preset sync skipped\");\n }\n let sourceEntries: string[];\n try {\n sourceEntries = readdirSync(opts.sourceDir);\n } catch (err) {\n return abortActiveSync(opts, result, `source readdir failed: ${errorMessage(err)}`);\n }\n // Single timestamp per run so a multi-file update produces sibling\n // backups with a matching suffix — easier to grep / restore.\n const backupExt = `.bak.${Date.now()}`;\n result.backupSuffix = backupExt;\n const sourceSlugs = new Set<string>();\n for (const slug of sourceEntries) {\n if (slug.startsWith(\".\")) continue;\n if (!isPresetSlug(slug)) continue;\n sourceSlugs.add(slug);\n processActiveSlug(slug, opts, result, backupExt);\n }\n // Prune active copies of presets the launcher no longer ships.\n removeRetiredActivePresets(opts, result, sourceSlugs);\n if (result.updated.length > 0 || result.removed.length > 0) {\n opts.onInfo?.(\"active preset skills synced\", {\n updated: result.updated.length,\n unchanged: result.unchanged.length,\n notActive: result.notActive.length,\n removed: result.removed.length,\n skipped: result.skipped.length,\n backupSuffix: backupExt,\n });\n }\n return result;\n}\n","// Resolve the package's BUNDLED assets (shipped via package.json `files`) and seed\n// them into a workspace. ESM-only: `import.meta.url` points at this module under\n// `dist/workspace-setup/`, so `../../assets` is the package's assets dir at the\n// package root. (The workspace-setup entries build ESM only — `import.meta.url`\n// isn't available under CJS; both hosts run the server as ESM via tsx.)\nimport path from \"node:path\";\nimport { fileURLToPath } from \"node:url\";\nimport { copyFileSync, mkdirSync, readdirSync } from \"node:fs\";\n\n// The asset dir is shipped separately (package.json `files`), NOT bundled, so\n// this URL must resolve at runtime. `@vite-ignore` silences Vite's build-time\n// \"doesn't exist at build time\" warning — runtime resolution is intended.\nconst ASSETS_DIR = fileURLToPath(new URL(/* @vite-ignore */ \"../../assets\", import.meta.url));\n\n/** The bundled help-docs source dir (`assets/helps/`). */\nexport function helpsAssetDir(): string {\n return path.join(ASSETS_DIR, \"helps\");\n}\n\n/** The bundled preset-skills source dir (`assets/skills-preset/`) — pass as the\n * `sourceDir` of `syncPresetSkills` / `syncActivePresetSkills`. */\nexport function presetSkillsAssetDir(): string {\n return path.join(ASSETS_DIR, \"skills-preset\");\n}\n\n/** Copy every bundled help doc into `destDir` (created if missing). Idempotent —\n * overwrites on each call so the help docs always track the package's version. */\nexport function seedHelps(opts: { destDir: string }): void {\n mkdirSync(opts.destDir, { recursive: true });\n const src = helpsAssetDir();\n for (const file of readdirSync(src)) {\n copyFileSync(path.join(src, file), path.join(opts.destDir, file));\n }\n}\n","// The bug-report FAQ is an index of WHERE to check, never of what a value is —\n// values go stale silently, a config key or a path cannot (rename one and the\n// implementation stops working, so it gets fixed). That only holds if the\n// pointers are real, so they are parsed out here and checked by a test: a key\n// that no longer exists fails CI instead of misleading a user months later.\n\nexport interface FaqEntry {\n symptom: string;\n configKeys: string[];\n sources: string[];\n helps: string[];\n}\n\nexport type FieldList = \"configKeys\" | \"sources\" | \"helps\";\n\n// A Map, not an object literal: the field name comes from arbitrary prose in a\n// markdown file, and `FIELDS[\"constructor\"]` on a literal would answer through\n// the prototype chain instead of missing.\nconst FIELDS = new Map<string, FieldList>([\n [\"configKey\", \"configKeys\"],\n [\"source\", \"sources\"],\n [\"help\", \"helps\"],\n]);\n\nconst HEADING = \"## \";\nconst FENCE = \"```\";\n\nconst newEntry = (symptom: string): FaqEntry => ({ symptom, configKeys: [], sources: [], helps: [] });\n\nexport interface PointerLine {\n list: FieldList;\n value: string;\n}\n\n/** Read one `field: value` pointer line, or `null` for anything else — prose,\n * a blank line, or a sentence that merely contains a colon. Split out so the\n * line grammar can be tested on its own rather than through whole documents. */\nexport function parsePointerLine(line: string): PointerLine | null {\n const colon = line.indexOf(\":\");\n if (colon <= 0) return null;\n const list = FIELDS.get(line.slice(0, colon).trim());\n const value = line.slice(colon + 1).trim();\n return list && value ? { list, value } : null;\n}\n\n/** `## symptom` opens an entry; `field: value` lines under it are its pointers.\n * Everything else is prose for the model to read. A field line before the\n * first heading belongs to no entry and is dropped — the format block at the\n * top of the file is documentation, not an entry. Fenced blocks are skipped\n * for the same reason: the example in that block would parse as real. */\nexport function parseFaqEntries(markdown: string): FaqEntry[] {\n const entries: FaqEntry[] = [];\n let inFence = false;\n for (const line of markdown.split(\"\\n\")) {\n if (line.startsWith(FENCE)) {\n inFence = !inFence;\n } else if (inFence) {\n continue;\n } else if (line.startsWith(HEADING)) {\n entries.push(newEntry(line.slice(HEADING.length).trim()));\n } else {\n const pointer = parsePointerLine(line);\n const current = entries[entries.length - 1];\n if (pointer && current) current[pointer.list].push(pointer.value);\n }\n }\n return entries;\n}\n\n/** An entry with no pointer at all is prose the skill cannot verify against the\n * running system, which is exactly what this format exists to prevent. */\nexport const entryHasPointer = (entry: FaqEntry): boolean => entry.configKeys.length + entry.sources.length + entry.helps.length > 0;\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"index.js","names":[],"sources":["../../../common/dist/envCoerce.js","../../../common/dist/index.js","../../src/workspace-setup/sync.ts","../../src/workspace-setup/assets.ts","../../src/workspace-setup/faq-entries.ts"],"sourcesContent":["// Env-var type coercion for ports and other integer settings.\n//\n// It started inside `server/system/env.ts` (#504-era), moved to\n// `server/utils/envCoerce.ts` when #2650 needed the SAME rule for `yarn dev`'s\n// Vite proxy — the proxy has to resolve `PORT` exactly as the backend does, or\n// the two point at different servers — and moved here when #3084 needed it for\n// the messaging bridges too. A second opinion about \"is this a port\" is that\n// bug one level down, so there is exactly one copy and every tier imports it.\n/**\n * `Number()`-based integer coercion with an optional range, falling back when the\n * value is absent, empty, non-integer, or out of range.\n *\n * `Number()` — not `parseInt` — on purpose: it is what the server has always used,\n * so `0x1f`, `1e3`, `+3100` and `3100.0` coerce exactly as they did, and a\n * whitespace-only value coerces to 0. Anything stricter here would silently\n * disagree with the backend.\n */\nexport function asInt(value, fallback, opts = {}) {\n if (value === undefined || value === \"\")\n return fallback;\n const parsed = Number(value);\n if (!Number.isInteger(parsed))\n return fallback;\n if (opts.min !== undefined && parsed < opts.min)\n return fallback;\n if (opts.max !== undefined && parsed > opts.max)\n return fallback;\n return parsed;\n}\n/** The port range the backend accepts, so both sides bound the value identically.\n * `min: 0` is deliberate — 0 asks the OS for an ephemeral port.\n *\n * `Required<IntRange>`, not `IntRange`: both bounds are always present here, and\n * saying so lets a caller compare against them (`port <= PORT_RANGE.max`) without\n * a non-null assertion. Still assignable wherever an `IntRange` is expected. */\nexport const PORT_RANGE = Object.freeze({ min: 0, max: 65_535 });\n","// General-purpose runtime type guards, shared across the MulmoClaude host,\n// bridges, and plugins. This is a leaf package — pure and dependency-free — so\n// any tier can import it without creating an uphill edge.\n//\n// These originated as `server/utils/types.ts` (#504), which centralised 40+\n// hand-written inline `typeof x === \"object\"` checks. They are promoted here so\n// the same guards stop being re-hand-written in every bridge and plugin too.\n/** Narrow `unknown` to a plain object (not null, not array). */\nexport function isRecord(value) {\n return typeof value === \"object\" && value !== null && !Array.isArray(value);\n}\n/** Narrow `unknown` to any object (not null, arrays allowed).\n * Use `isRecord` when you need to access string keys. */\nexport function isObj(value) {\n return typeof value === \"object\" && value !== null;\n}\n/** Non-empty string after trimming whitespace. */\nexport function isNonEmptyString(value) {\n return typeof value === \"string\" && value.trim().length > 0;\n}\n/** Record whose values are all strings. */\nexport function isStringRecord(value) {\n if (!isRecord(value))\n return false;\n return Object.values(value).every((val) => typeof val === \"string\");\n}\n/** String array (every element is a string). */\nexport function isStringArray(value) {\n return Array.isArray(value) && value.every((val) => typeof val === \"string\");\n}\n/** An array of unknowns. Prefer this over a bare `Array.isArray` in typed code:\n * `Array.isArray(x: unknown)` narrows to `any[]`, silently reintroducing\n * `any`, whereas this keeps the element type `unknown`. */\nexport function isUnknownArray(value) {\n return Array.isArray(value);\n}\n/** Error-like object with a `code` property (e.g. Node.js fs errors). */\nexport function isErrorWithCode(value) {\n return isRecord(value) && typeof value.code === \"string\";\n}\n/** Check that a record has a specific key with a string value. */\nexport function hasStringProp(value, key) {\n return isRecord(value) && typeof value[key] === \"string\";\n}\n/** Check that a record has a specific key with a number value. */\nexport function hasNumberProp(value, key) {\n return isRecord(value) && typeof value[key] === \"number\";\n}\n/** Split a comma-separated env value into trimmed, non-empty entries.\n * `lowercase` folds case for identifiers compared case-insensitively\n * (JIDs, email addresses, hex pubkeys). Absent/empty input → empty list. */\nexport function parseCsvList(raw, opts) {\n return (raw ?? \"\")\n .split(\",\")\n .map((entry) => (opts?.lowercase ? entry.trim().toLowerCase() : entry.trim()))\n .filter(Boolean);\n}\n/** A comma-separated env value as a Set — the canonical allowlist shape,\n * where an empty set is the \"allow all\" sentinel (`set.size === 0`). */\nexport function parseCsvSet(raw, opts) {\n return new Set(parseCsvList(raw, opts));\n}\n/** Normalise an unknown thrown value into a human-readable string. Isomorphic\n * (host, bridges, plugins, Vue) — this is the single home for the helper that\n * #2217 could only consolidate for server code, since `@mulmoclaude/core/utils`\n * is server-only.\n *\n * A non-Error object with a non-empty string `details` (gRPC convention) or\n * `message` field surfaces that field — `details` wins — instead of the\n * `[object Object]` a bare `String(err)` would print; an empty-string field\n * falls through. `fallback` covers the error-boundary idiom where a thrown\n * non-Error should read as a descriptive message rather than `String(err)`\n * noise; omit it in logging contexts where `String(err)` is fine. */\nexport function errorMessage(err, fallback) {\n if (err instanceof Error)\n return err.message;\n if (hasStringProp(err, \"details\") && err.details)\n return err.details;\n if (hasStringProp(err, \"message\") && err.message)\n return err.message;\n if (fallback !== undefined)\n return fallback;\n return String(err);\n}\n/** `Date` → `YYYY-MM-DD` in UTC — for dates that must not shift with the\n * host's local timezone (tool-trace search dirs, API date keys). Isomorphic\n * single source (#2480): the host re-exports it from `server/utils/date.ts`,\n * x-plugin imports it directly. The `@receptron/task-scheduler` copy stays\n * local on purpose — that leaf package is published independently and kept\n * dependency-free. Wall-clock questions use the host's `toLocalIsoDate`. */\nexport function toUtcIsoDate(timestamp) {\n const year = timestamp.getUTCFullYear();\n const month = String(timestamp.getUTCMonth() + 1).padStart(2, \"0\");\n const day = String(timestamp.getUTCDate()).padStart(2, \"0\");\n return `${year}-${month}-${day}`;\n}\n// A Map, not an object literal: `{}[char]` reads through the prototype chain,\n// so a future caller widening the regex would silently get `[object Object]`\n// for keys like `constructor`.\nconst HTML_ESCAPES = new Map([\n [\"&\", \"&\"],\n [\"<\", \"<\"],\n [\">\", \">\"],\n ['\"', \""\"],\n [\"'\", \"'\"],\n]);\n/** HTML-escape text destined for markup or an attribute value — the fixed\n * five-character map, nothing more. Lives here rather than in\n * `@mulmoclaude/core/wiki` (#2483) because `@mulmoclaude/markdown-utils` is a\n * leaf that core depends on and so cannot import back up; core/wiki\n * re-exports this, keeping its consumers' import path unchanged.\n *\n * Escaping `&` first is what makes a single pass safe — the entities this\n * introduces contain none of the other four characters, so nothing is\n * double-escaped. Not a sanitiser: it neither strips tags nor validates URLs. */\nexport function escapeHtml(value) {\n return value.replace(/[&<>\"']/g, (char) => HTML_ESCAPES.get(char) ?? char);\n}\n/** Split a JWS compact serialization into its three segments, or `null` when the\n * token isn't well-formed. Pure string work, so the Node bridges and the\n * Cloudflare Workers relay — which decode the segments differently (`Buffer` vs\n * `atob`) — can still share this one guard.\n *\n * A JWS compact serialization is EXACTLY three segments. Both a short token and\n * a longer one (a five-segment JWE, or an attacker appending `.junk`) are\n * rejected outright — never parsed from their first three segments, which would\n * let the signed input disagree with the token. */\nexport function splitJwtSegments(token) {\n const [headerSegment, payloadSegment, signatureSegment, ...extraSegments] = token.split(\".\");\n if (headerSegment === undefined || payloadSegment === undefined || signatureSegment === undefined)\n return null;\n if (extraSegments.length > 0)\n return null;\n return { headerSegment, payloadSegment, signatureSegment };\n}\nexport { scanEnvOptions, snakeToLowerCamel } from \"./envScan.js\";\nexport { asInt, PORT_RANGE } from \"./envCoerce.js\";\n","// Preset skills bundled with mulmoclaude.\n//\n// History: introduced in #1210 to ship launcher-managed \"factory\"\n// skills like `mc-library`. Originally synced straight into\n// `<workspaceRoot>/.claude/skills/<slug>/`, which made every preset\n// auto-active and inflated the Claude system prompt as new presets\n// landed.\n//\n// #1335 PR-A flipped the destination to the catalog\n// (`<workspaceRoot>/data/skills/catalog/preset/<slug>/`). Catalog\n// entries are visible to UI / tooling but NOT discovered by Claude\n// Code's slash-command resolver — they don't enter the system\n// prompt unless the user (or a later UI in PR-B) explicitly copies\n// one into `.claude/skills/`.\n//\n// The launcher overwrites catalog entries unconditionally on every\n// boot — they're factory defaults, not user state. Anything in\n// `.claude/skills/` (active layer) is left untouched.\n//\n// `syncPresetSkills(...)` is exported as a pure-ish helper (takes\n// paths + a logger sink, returns a summary) so tests can drive it\n// against tmpdirs without touching a real workspace.\n\nimport { copyFileSync, existsSync, lstatSync, mkdirSync, readdirSync, readFileSync, realpathSync, renameSync, rmSync, statSync, type Dirent } from \"node:fs\";\nimport { randomUUID } from \"node:crypto\";\nimport path from \"node:path\";\nimport { PRESET_SLUG_PREFIX, isPresetSlug } from \"./slug.js\";\nimport { errorMessage } from \"../utils/errors.js\";\n\n// Recursively mirror `srcDir` into `destDir`. Used by the preset\n// sync so a preset skill that ships sibling assets (e.g.\n// `schema.json` for schema-driven apps, `templates/*.html`) gets\n// copied alongside `SKILL.md` rather than silently dropped. Only\n// regular files and directories are followed — symlinks / FIFOs /\n// sockets are skipped because the preset tree is launcher-managed\n// and shouldn't contain them.\nfunction copyDirTreeSync(srcDir: string, destDir: string): void {\n mkdirSync(destDir, { recursive: true });\n for (const entry of readdirSync(srcDir, { withFileTypes: true })) {\n const srcPath = path.join(srcDir, entry.name);\n const destPath = path.join(destDir, entry.name);\n if (entry.isDirectory()) {\n copyDirTreeSync(srcPath, destPath);\n } else if (entry.isFile()) {\n copyFileSync(srcPath, destPath);\n }\n }\n}\n\nconst SKILL_FILENAME = \"SKILL.md\";\n\nexport interface SyncPresetSkillsOptions {\n /** Source directory: `<launcher>/server/workspace/skills-preset/`. */\n sourceDir: string;\n /** Destination directory:\n * `<workspaceRoot>/data/skills/catalog/preset/`. The catalog\n * half of the catalog-vs-active split — entries here are visible\n * to UI but NOT to Claude Code's prompt-time skill resolver. */\n destDir: string;\n /** Logger callbacks — kept injectable so tests don't need to\n * spin up the structured logger. The boot-side wrapper threads\n * these through to `log.info` / `log.warn`. */\n onInfo?: (message: string, data?: Record<string, unknown>) => void;\n onWarn?: (message: string, data?: Record<string, unknown>) => void;\n}\n\nexport interface SyncPresetSkillsResult {\n /** Slugs successfully copied (or refreshed) from source to dest. */\n copied: string[];\n /** Slugs removed from dest because they no longer exist in source.\n * Bounded to `mc-*` entries — user-authored slugs are never\n * considered for removal. */\n removed: string[];\n /** Source entries that failed validation (wrong prefix, missing\n * SKILL.md, etc.) and were skipped. Each entry is human-readable. */\n skipped: string[];\n}\n\n// Classification of one source entry. `silent` distinguishes\n// structural skips (hidden files, non-directory entries — not the\n// dev's fault) from misconfigurations (bad slug, missing or\n// non-regular SKILL.md — the dev WANTS to know). The boolean lives\n// on the verdict so the caller never has to string-match `reason`,\n// which would silently drop warnings if reason wording changed\n// (CodeRabbit review).\ntype Verdict = { ok: true } | { ok: false; reason: string; silent: boolean };\n\nfunction classifySourceEntry(sourceDir: string, entry: string): Verdict {\n if (entry.startsWith(\".\")) return { ok: false, reason: \"hidden\", silent: true };\n const slugDir = path.join(sourceDir, entry);\n let dirInfo;\n try {\n dirInfo = statSync(slugDir);\n } catch {\n return { ok: false, reason: \"stat failed\", silent: true };\n }\n if (!dirInfo.isDirectory()) return { ok: false, reason: \"not a directory\", silent: true };\n if (!isPresetSlug(entry)) return { ok: false, reason: `slug must start with \"${PRESET_SLUG_PREFIX}\"`, silent: false };\n // Validate SKILL.md is a regular file — `existsSync` alone\n // accepts a directory at that path, which would then crash\n // copyFileSync. Codex review caught this edge case.\n const skillPath = path.join(slugDir, SKILL_FILENAME);\n let skillInfo;\n try {\n skillInfo = statSync(skillPath);\n } catch {\n return { ok: false, reason: `missing ${SKILL_FILENAME}`, silent: false };\n }\n if (!skillInfo.isFile()) return { ok: false, reason: `${SKILL_FILENAME} must be a regular file`, silent: false };\n return { ok: true };\n}\n\n/** Prepare the destination slug dir. Returns false if the slot is\n * occupied by a regular file (local corruption / hand edits) — the\n * caller logs + skips so one bad entry can't crash the whole boot\n * (Codex review iter-1). */\nfunction ensureDestSlugDir(destSlugDir: string): boolean {\n let info;\n try {\n info = statSync(destSlugDir);\n } catch {\n mkdirSync(destSlugDir, { recursive: true });\n return true;\n }\n return info.isDirectory();\n}\n\n/** Filesystem ops `_replaceSlugTree` depends on — injectable so the\n * mid-swap failure path can be regression-tested without real IO faults. */\nexport interface SlugTreeFsOps {\n copyTree: (src: string, dest: string) => void;\n rename: (from: string, dest: string) => void;\n remove: (target: string) => void;\n exists: (target: string) => boolean;\n}\n\nconst REAL_FS_OPS: SlugTreeFsOps = {\n copyTree: copyDirTreeSync,\n rename: renameSync,\n remove: (target) => rmSync(target, { recursive: true, force: true }),\n exists: existsSync,\n};\n\n/** Refresh one preset slot as a rollback-safe stage-and-swap. Wipe-and-replace\n * (not merge) so stale sibling assets — e.g. a schema.json dropped between\n * releases — don't linger; the catalog preset slot is launcher-owned, so user\n * edits there are not preserved across boots.\n *\n * The live slot is never left without a recoverable copy:\n * 1. Stage: copy the source into a temp sibling. A copy failure leaves the\n * existing preset untouched (temp dir removed).\n * 2. Move the existing tree ASIDE to a backup (rename, not delete), so the\n * old contents survive even if the next step fails.\n * 3. Move the staged copy into place. On failure, restore from the backup;\n * if even the restore fails, BOTH backup and staging are preserved for\n * manual recovery rather than deleted.\n * 4. On success, drop the backup.\n *\n * Exported with a `_` prefix only so the rename-failure-after-move path can be\n * regression-tested via the injected `fsOps`. Throws on failure (caller records\n * + skips). */\nexport function _replaceSlugTree(sourceSlugDir: string, destSlugDir: string, fsOps: SlugTreeFsOps = REAL_FS_OPS): void {\n const staging = `${destSlugDir}.tmp-${randomUUID()}`;\n try {\n fsOps.copyTree(sourceSlugDir, staging);\n } catch (err) {\n fsOps.remove(staging); // dest untouched\n throw err;\n }\n const backup = `${destSlugDir}.bak-${randomUUID()}`;\n let backedUp = false;\n if (fsOps.exists(destSlugDir)) {\n try {\n fsOps.rename(destSlugDir, backup);\n backedUp = true;\n } catch (err) {\n fsOps.remove(staging); // dest untouched; nothing was moved\n throw err;\n }\n }\n try {\n fsOps.rename(staging, destSlugDir);\n } catch (err) {\n if (backedUp) {\n try {\n fsOps.rename(backup, destSlugDir); // roll back to the previous tree\n fsOps.remove(staging);\n } catch {\n // Rollback failed — preserve BOTH backup and staging for manual\n // recovery rather than leaving the slot empty.\n }\n } else {\n fsOps.remove(staging); // dest never existed; nothing to restore\n }\n throw err;\n }\n if (backedUp) fsOps.remove(backup);\n}\n\nfunction copySourcesIntoDest(sourceDir: string, destDir: string, opts: SyncPresetSkillsOptions, result: SyncPresetSkillsResult): Set<string> {\n // `keep` is the set of slugs the retirement pass must NOT prune. It tracks\n // valid SOURCE slugs (present + usable dest slot), NOT successful copies — so\n // a slug whose refresh fails transiently keeps its existing contents (left\n // intact by the stage-and-swap above) instead of being pruned as \"retired\".\n const keep = new Set<string>();\n for (const entry of readdirSync(sourceDir)) {\n const verdict = classifySourceEntry(sourceDir, entry);\n if (!verdict.ok) {\n if (!verdict.silent) {\n result.skipped.push(`${entry}: ${verdict.reason}`);\n opts.onWarn?.(\"preset entry skipped\", { slug: entry, reason: verdict.reason });\n }\n continue;\n }\n const destSlugDir = path.join(destDir, entry);\n if (!ensureDestSlugDir(destSlugDir)) {\n const reason = \"destination slot occupied by a non-directory; skipping\";\n result.skipped.push(`${entry}: ${reason}`);\n opts.onWarn?.(\"preset entry skipped\", { slug: entry, reason, destSlugDir });\n continue;\n }\n // The slug exists in source with a usable dest slot — keep it regardless of\n // whether the refresh below succeeds, so a transient failure doesn't get it\n // pruned by `removeRetiredPresets`.\n keep.add(entry);\n // Per-slug isolation: a transient IO error / permission issue / partial\n // corruption on one preset must not abort syncing the rest, nor destroy\n // the existing copy. Skip the offender (recorded) and continue.\n try {\n _replaceSlugTree(path.join(sourceDir, entry), destSlugDir);\n result.copied.push(entry);\n } catch (err) {\n const reason = err instanceof Error ? err.message : String(err);\n result.skipped.push(`${entry}: ${reason}`);\n opts.onWarn?.(\"preset copy failed, skipping\", { slug: entry, reason });\n }\n }\n return keep;\n}\n\nfunction removeRetiredPresets(destDir: string, keep: ReadonlySet<string>, opts: SyncPresetSkillsOptions, result: SyncPresetSkillsResult): void {\n for (const entry of readdirSync(destDir)) {\n if (!isPresetSlug(entry)) continue;\n if (keep.has(entry)) continue;\n const stalePath = path.join(destDir, entry);\n try {\n if (!statSync(stalePath).isDirectory()) continue;\n } catch {\n continue;\n }\n rmSync(stalePath, { recursive: true, force: true });\n result.removed.push(entry);\n opts.onInfo?.(\"removed retired preset skill\", { slug: entry });\n }\n}\n\n/** Copy every preset slug from `sourceDir` into `destDir` (the\n * preset slot under the catalog root), then remove any `mc-*`\n * entries in `destDir` that no longer have a source. The catalog\n * preset subdir is fully launcher-owned, so the `mc-*` prefix\n * check at destination is defence-in-depth: a stray non-preset\n * slug landing in `catalog/preset/` is unexpected, and we'd\n * rather skip it than silently delete a directory we don't\n * recognise. */\nexport function syncPresetSkills(opts: SyncPresetSkillsOptions): SyncPresetSkillsResult {\n const result: SyncPresetSkillsResult = { copied: [], removed: [], skipped: [] };\n if (!existsSync(opts.sourceDir)) {\n // No preset directory in the launcher tarball — nothing to do.\n // This is the legitimate \"no presets shipped yet\" state.\n return result;\n }\n // Source-side validation: the launcher's preset path COULD exist\n // as a regular file (a packaging bug, a corrupted install). Without\n // this guard, `readdirSync(sourceDir)` would throw ENOTDIR and\n // crash boot. Codex review iter-3.\n let sourceInfo;\n try {\n sourceInfo = statSync(opts.sourceDir);\n } catch (err) {\n const reason = `source path stat failed: ${errorMessage(err)}`;\n result.skipped.push(`${opts.sourceDir}: ${reason}`);\n opts.onWarn?.(\"preset sync aborted\", { sourceDir: opts.sourceDir, reason });\n return result;\n }\n if (!sourceInfo.isDirectory()) {\n const reason = \"source path exists as a non-directory; preset sync skipped\";\n result.skipped.push(`${opts.sourceDir}: ${reason}`);\n opts.onWarn?.(\"preset sync aborted\", { sourceDir: opts.sourceDir, reason });\n return result;\n }\n // The root dest itself can be corrupted into a regular file by a\n // user / external tool; mkdirSync would throw EEXIST and crash\n // boot. Treat it as a recoverable \"skip the entire sync\" state\n // — log a clear warning so the user sees what to fix.\n // (Codex review iter-2.)\n if (!ensureDestSlugDir(opts.destDir)) {\n const reason = \"root dest exists as a non-directory; preset sync skipped\";\n result.skipped.push(`${opts.destDir}: ${reason}`);\n opts.onWarn?.(\"preset sync aborted\", { destDir: opts.destDir, reason });\n return result;\n }\n const keep = copySourcesIntoDest(opts.sourceDir, opts.destDir, opts, result);\n removeRetiredPresets(opts.destDir, keep, opts, result);\n if (result.copied.length > 0 || result.removed.length > 0) {\n opts.onInfo?.(\"preset skills synced\", {\n copied: result.copied.length,\n removed: result.removed.length,\n skipped: result.skipped.length,\n });\n }\n return result;\n}\n\n// ---------------------------------------------------------------\n// Active-layer sync: keep starred mc-* presets in lockstep with\n// their launcher-bundled source.\n//\n// Motivation: the catalog sync above refreshes\n// `data/skills/catalog/preset/<slug>/` on every boot, but once a\n// user stars an entry the active copy in\n// `<workspace>/.claude/skills/<slug>/` is never updated even when\n// the launcher ships a new SKILL.md (e.g. a typo fix or, as the\n// trigger for this code, the schema-driven-apps → collections\n// rename). The SKILL.md front-matter explicitly says\n// \"do not edit this file in the workspace, it is overwritten on\n// every server boot\" — until this function existed, that was a\n// promise the active layer didn't keep.\n//\n// Safety model:\n// - Only `mc-*` slugs are touched (defensive prefix check —\n// never touches user-authored skills).\n// - Per-file diff: a file is overwritten only if its bytes\n// differ from the source. No-op when already up to date.\n// - User-added files inside an active slug dir are left alone\n// (we walk the source tree, not the dest tree).\n// - If a file IS overwritten, the previous contents are first\n// renamed to `<file>.bak.<timestamp>` so a user who had\n// locally tweaked the preset can recover.\n// - A slug whose active dir doesn't exist (= not starred yet)\n// is skipped entirely, never auto-starred.\n// ---------------------------------------------------------------\n\nexport interface SyncActivePresetSkillsOptions {\n /** Source directory: `<launcher>/server/workspace/skills-preset/`. */\n sourceDir: string;\n /** Active skills directory: `<workspaceRoot>/.claude/skills/`. */\n activeDir: string;\n onInfo?: (message: string, data?: Record<string, unknown>) => void;\n onWarn?: (message: string, data?: Record<string, unknown>) => void;\n}\n\nexport interface SyncActivePresetSkillsResult {\n /** Slugs whose active copy had at least one file overwritten. */\n updated: string[];\n /** Slugs whose active copy already matched the source — no-op. */\n unchanged: string[];\n /** Slugs that haven't been starred yet (no active dir present).\n * Listed for diagnostics; the function never auto-stars. */\n notActive: string[];\n /** Active `mc-*` slugs pruned because the launcher no longer ships a\n * source preset for them (a retired preset). Bounded to `mc-*` —\n * user-authored skills are never removed. */\n removed: string[];\n /** Per-slug failure messages (permission errors, etc.). */\n skipped: string[];\n /** Common timestamp suffix used for every backup file produced by\n * this run. Exposed so the boot-time log can point a user at the\n * exact glob to inspect. */\n backupSuffix: string | null;\n}\n\ntype FileSyncOutcome = \"updated\" | \"unchanged\" | \"skipped\";\n\nfunction filesEqual(left: string, right: string): boolean {\n try {\n return readFileSync(left).equals(readFileSync(right));\n } catch {\n return false;\n }\n}\n\n/** Copy `srcPath` over `destPath`. If `destPath` already exists and\n * differs, rename it to `destPath + backupExt` first so the user's\n * prior contents are recoverable. If `destPath` doesn't exist, just\n * copy (no backup needed). Pure: no logging here — caller decides. */\nfunction syncOneFile(srcPath: string, destPath: string, backupExt: string): FileSyncOutcome {\n let destExists: boolean;\n try {\n destExists = statSync(destPath).isFile();\n } catch {\n destExists = false;\n }\n if (!destExists) {\n try {\n copyFileSync(srcPath, destPath);\n return \"updated\";\n } catch {\n return \"skipped\";\n }\n }\n if (filesEqual(srcPath, destPath)) return \"unchanged\";\n try {\n renameSync(destPath, destPath + backupExt);\n copyFileSync(srcPath, destPath);\n return \"updated\";\n } catch {\n return \"skipped\";\n }\n}\n\ninterface DirSyncStats {\n updated: number;\n unchanged: number;\n skipped: number;\n}\n\nfunction syncDirTreeDiff(srcDir: string, destDir: string, backupExt: string): DirSyncStats {\n const stats: DirSyncStats = { updated: 0, unchanged: 0, skipped: 0 };\n try {\n mkdirSync(destDir, { recursive: true });\n } catch {\n stats.skipped++;\n return stats;\n }\n let entries: Dirent[];\n try {\n entries = readdirSync(srcDir, { withFileTypes: true });\n } catch {\n stats.skipped++;\n return stats;\n }\n for (const entry of entries) {\n const srcPath = path.join(srcDir, entry.name);\n const destPath = path.join(destDir, entry.name);\n if (entry.isDirectory()) {\n const sub = syncDirTreeDiff(srcPath, destPath, backupExt);\n stats.updated += sub.updated;\n stats.unchanged += sub.unchanged;\n stats.skipped += sub.skipped;\n } else if (entry.isFile()) {\n const outcome = syncOneFile(srcPath, destPath, backupExt);\n stats[outcome]++;\n }\n // Symlinks / sockets / FIFOs intentionally ignored (the launcher\n // preset tree shouldn't contain them).\n }\n return stats;\n}\n\nfunction abortActiveSync(opts: SyncActivePresetSkillsOptions, result: SyncActivePresetSkillsResult, reason: string): SyncActivePresetSkillsResult {\n result.skipped.push(`${opts.sourceDir}: ${reason}`);\n opts.onWarn?.(\"active preset sync aborted\", { sourceDir: opts.sourceDir, reason });\n return result;\n}\n\n/** True iff `absPath`'s realpath resolves inside `rootPath`'s\n * realpath. Defends `processActiveSlug` against a starred `mc-*`\n * slug that's actually a symlink to somewhere outside\n * `activeDir`: without this check, the recursive copy below would\n * follow the symlink and write through to the link's target\n * (potentially anywhere on disk). Returns false on any error so\n * the caller treats unreadable paths as \"refused\" rather than\n * \"OK to write\". */\nfunction isRealpathInside(absPath: string, rootPath: string): boolean {\n try {\n const real = realpathSync(absPath);\n const rootReal = realpathSync(rootPath);\n return real === rootReal || real.startsWith(rootReal + path.sep);\n } catch {\n return false;\n }\n}\n\ntype DestVerdict = { ok: true } | { ok: false; reason: string } | { kind: \"not-active\" };\n\n/** Validate the active dest slug dir before writing through it.\n * Returns:\n * - `{ ok: true }` — proceed with the sync\n * - `{ kind: \"not-active\" }` — slug hasn't been starred yet\n * - `{ ok: false; reason }` — refuse to write (symlink escape,\n * non-directory, ancestor escape)\n *\n * Symlink defenses (Codex P1 review on PR #1490): `statSync`\n * follows symlinks, so a starred `mc-*` slug that's actually a\n * symlink to /etc would let the recursive copy below write\n * outside the workspace. Two-layer defense:\n * 1. `lstatSync` to see the link itself; if it's a symlink,\n * only accept when its target stays inside `activeDir`.\n * 2. Always realpath-verify the full path (catches the case\n * where an ancestor like `.claude/` is symlinked even when\n * the slug dir itself is a regular directory). */\nfunction classifyActiveDest(slug: string, destSlugDir: string, activeDir: string): DestVerdict {\n let destInfo;\n try {\n destInfo = lstatSync(destSlugDir);\n } catch {\n return { kind: \"not-active\" };\n }\n if (destInfo.isSymbolicLink()) {\n if (!isRealpathInside(destSlugDir, activeDir)) {\n return { ok: false, reason: \"active slot is a symlink whose target escapes activeDir; refusing to write through it\" };\n }\n return { ok: true };\n }\n if (!destInfo.isDirectory()) {\n return { ok: false, reason: \"active slot is occupied by a non-directory; skipping\" };\n }\n if (!isRealpathInside(destSlugDir, activeDir)) {\n return { ok: false, reason: \"active slug dir escapes activeDir via an ancestor symlink\" };\n }\n return { ok: true };\n}\n\n/** Per-slug worker for `syncActivePresetSkills`. Extracted to keep\n * the outer function under the `sonarjs/cognitive-complexity`\n * threshold; no behavior difference vs the inline loop. */\nfunction processActiveSlug(slug: string, opts: SyncActivePresetSkillsOptions, result: SyncActivePresetSkillsResult, backupExt: string): void {\n const srcSlugDir = path.join(opts.sourceDir, slug);\n try {\n if (!statSync(srcSlugDir).isDirectory()) return;\n } catch {\n return;\n }\n const destSlugDir = path.join(opts.activeDir, slug);\n const verdict = classifyActiveDest(slug, destSlugDir, opts.activeDir);\n if (\"kind\" in verdict) {\n result.notActive.push(slug);\n return;\n }\n if (!verdict.ok) {\n result.skipped.push(`${slug}: ${verdict.reason}`);\n opts.onWarn?.(\"active preset sync skipped\", { slug, reason: verdict.reason, destSlugDir });\n return;\n }\n const stats = syncDirTreeDiff(srcSlugDir, destSlugDir, backupExt);\n if (stats.updated > 0) {\n result.updated.push(slug);\n opts.onInfo?.(\"active preset skill updated from source\", { slug, files: stats.updated, backupSuffix: backupExt });\n } else if (stats.skipped === 0) {\n result.unchanged.push(slug);\n }\n if (stats.skipped > 0) {\n result.skipped.push(`${slug}: ${stats.skipped} file(s) skipped`);\n opts.onWarn?.(\"active preset skill partial update\", { slug, skipped: stats.skipped });\n }\n}\n\n/** Realpath targets of active symlinks. Used so the prune below never\n * deletes a directory that's serving as a symlink target (e.g. a\n * git-managed checkout the user symlinks an active slug to). */\nfunction collectSymlinkTargets(activeDir: string, entries: readonly string[]): Set<string> {\n const targets = new Set<string>();\n for (const slug of entries) {\n const entryPath = path.join(activeDir, slug);\n try {\n if (lstatSync(entryPath).isSymbolicLink()) targets.add(realpathSync(entryPath));\n } catch {\n /* unreadable — ignore */\n }\n }\n return targets;\n}\n\n/** True iff the active `slug` is a retired preset safe to delete: an\n * `mc-*` real directory inside `activeDir`, with no source preset, and\n * not itself a symlink or a symlink target. */\nfunction isRetiredActiveSlug(slug: string, activeDir: string, sourceSlugs: ReadonlySet<string>, symlinkTargets: ReadonlySet<string>): boolean {\n if (!isPresetSlug(slug) || sourceSlugs.has(slug)) return false;\n const destSlugDir = path.join(activeDir, slug);\n let info;\n try {\n info = lstatSync(destSlugDir);\n } catch {\n return false;\n }\n if (info.isSymbolicLink() || !info.isDirectory()) return false;\n if (!isRealpathInside(destSlugDir, activeDir)) return false;\n try {\n return !symlinkTargets.has(realpathSync(destSlugDir));\n } catch {\n return false;\n }\n}\n\n/** Prune active `mc-*` slugs whose launcher source preset no longer\n * exists (a retired preset). Without this, an upgraded workspace that\n * had starred a since-removed preset (e.g. `mc-manage-sources`) keeps\n * the active copy forever — Claude would still discover a skill whose\n * backend was deleted. Bounded to `mc-*` real directories that stay\n * inside `activeDir` (never a symlink escape, never a user skill). */\nfunction removeRetiredActivePresets(opts: SyncActivePresetSkillsOptions, result: SyncActivePresetSkillsResult, sourceSlugs: ReadonlySet<string>): void {\n let activeEntries: string[];\n try {\n activeEntries = readdirSync(opts.activeDir);\n } catch {\n return; // no active dir yet → nothing to prune\n }\n const symlinkTargets = collectSymlinkTargets(opts.activeDir, activeEntries);\n for (const slug of activeEntries) {\n if (!isRetiredActiveSlug(slug, opts.activeDir, sourceSlugs, symlinkTargets)) continue;\n try {\n rmSync(path.join(opts.activeDir, slug), { recursive: true, force: true });\n result.removed.push(slug);\n opts.onInfo?.(\"removed retired active preset skill\", { slug });\n } catch (err) {\n result.skipped.push(`${slug}: prune failed: ${errorMessage(err)}`);\n opts.onWarn?.(\"active preset prune failed\", { slug, reason: errorMessage(err) });\n }\n }\n}\n\n/** Refresh every already-starred `mc-*` preset's active copy in\n * `<workspaceRoot>/.claude/skills/<slug>/` to match the source.\n * Per-file diff with `.bak.<timestamp>` backup on overwrite. Slugs\n * that aren't starred yet are listed in `notActive` but never\n * auto-created. */\nexport function syncActivePresetSkills(opts: SyncActivePresetSkillsOptions): SyncActivePresetSkillsResult {\n const result: SyncActivePresetSkillsResult = { updated: [], unchanged: [], notActive: [], removed: [], skipped: [], backupSuffix: null };\n if (!existsSync(opts.sourceDir)) return result;\n let sourceInfo;\n try {\n sourceInfo = statSync(opts.sourceDir);\n } catch (err) {\n return abortActiveSync(opts, result, `source path stat failed: ${errorMessage(err)}`);\n }\n if (!sourceInfo.isDirectory()) {\n return abortActiveSync(opts, result, \"source path exists as a non-directory; active preset sync skipped\");\n }\n let sourceEntries: string[];\n try {\n sourceEntries = readdirSync(opts.sourceDir);\n } catch (err) {\n return abortActiveSync(opts, result, `source readdir failed: ${errorMessage(err)}`);\n }\n // Single timestamp per run so a multi-file update produces sibling\n // backups with a matching suffix — easier to grep / restore.\n const backupExt = `.bak.${Date.now()}`;\n result.backupSuffix = backupExt;\n const sourceSlugs = new Set<string>();\n for (const slug of sourceEntries) {\n if (slug.startsWith(\".\")) continue;\n if (!isPresetSlug(slug)) continue;\n sourceSlugs.add(slug);\n processActiveSlug(slug, opts, result, backupExt);\n }\n // Prune active copies of presets the launcher no longer ships.\n removeRetiredActivePresets(opts, result, sourceSlugs);\n if (result.updated.length > 0 || result.removed.length > 0) {\n opts.onInfo?.(\"active preset skills synced\", {\n updated: result.updated.length,\n unchanged: result.unchanged.length,\n notActive: result.notActive.length,\n removed: result.removed.length,\n skipped: result.skipped.length,\n backupSuffix: backupExt,\n });\n }\n return result;\n}\n","// Resolve the package's BUNDLED assets (shipped via package.json `files`) and seed\n// them into a workspace. ESM-only: `import.meta.url` points at this module under\n// `dist/workspace-setup/`, so `../../assets` is the package's assets dir at the\n// package root. (The workspace-setup entries build ESM only — `import.meta.url`\n// isn't available under CJS; both hosts run the server as ESM via tsx.)\nimport path from \"node:path\";\nimport { fileURLToPath } from \"node:url\";\nimport { copyFileSync, mkdirSync, readdirSync } from \"node:fs\";\n\n// The asset dir is shipped separately (package.json `files`), NOT bundled, so\n// this URL must resolve at runtime. `@vite-ignore` silences Vite's build-time\n// \"doesn't exist at build time\" warning — runtime resolution is intended.\nconst ASSETS_DIR = fileURLToPath(new URL(/* @vite-ignore */ \"../../assets\", import.meta.url));\n\n/** The bundled help-docs source dir (`assets/helps/`). */\nexport function helpsAssetDir(): string {\n return path.join(ASSETS_DIR, \"helps\");\n}\n\n/** The bundled preset-skills source dir (`assets/skills-preset/`) — pass as the\n * `sourceDir` of `syncPresetSkills` / `syncActivePresetSkills`. */\nexport function presetSkillsAssetDir(): string {\n return path.join(ASSETS_DIR, \"skills-preset\");\n}\n\n/** Copy every bundled help doc into `destDir` (created if missing). Idempotent —\n * overwrites on each call so the help docs always track the package's version. */\nexport function seedHelps(opts: { destDir: string }): void {\n mkdirSync(opts.destDir, { recursive: true });\n const src = helpsAssetDir();\n for (const file of readdirSync(src)) {\n copyFileSync(path.join(src, file), path.join(opts.destDir, file));\n }\n}\n","// The bug-report FAQ is an index of WHERE to check, never of what a value is —\n// values go stale silently, a config key or a path cannot (rename one and the\n// implementation stops working, so it gets fixed). That only holds if the\n// pointers are real, so they are parsed out here and checked by a test: a key\n// that no longer exists fails CI instead of misleading a user months later.\n\nexport interface FaqEntry {\n symptom: string;\n configKeys: string[];\n sources: string[];\n helps: string[];\n}\n\nexport type FieldList = \"configKeys\" | \"sources\" | \"helps\";\n\n// A Map, not an object literal: the field name comes from arbitrary prose in a\n// markdown file, and `FIELDS[\"constructor\"]` on a literal would answer through\n// the prototype chain instead of missing.\nconst FIELDS = new Map<string, FieldList>([\n [\"configKey\", \"configKeys\"],\n [\"source\", \"sources\"],\n [\"help\", \"helps\"],\n]);\n\nconst HEADING = \"## \";\nconst FENCE = \"```\";\n\nconst newEntry = (symptom: string): FaqEntry => ({ symptom, configKeys: [], sources: [], helps: [] });\n\nexport interface PointerLine {\n list: FieldList;\n value: string;\n}\n\n/** Read one `field: value` pointer line, or `null` for anything else — prose,\n * a blank line, or a sentence that merely contains a colon. Split out so the\n * line grammar can be tested on its own rather than through whole documents. */\nexport function parsePointerLine(line: string): PointerLine | null {\n const colon = line.indexOf(\":\");\n if (colon <= 0) return null;\n const list = FIELDS.get(line.slice(0, colon).trim());\n const value = line.slice(colon + 1).trim();\n return list && value ? { list, value } : null;\n}\n\n/** `## symptom` opens an entry; `field: value` lines under it are its pointers.\n * Everything else is prose for the model to read. A field line before the\n * first heading belongs to no entry and is dropped — the format block at the\n * top of the file is documentation, not an entry. Fenced blocks are skipped\n * for the same reason: the example in that block would parse as real. */\nexport function parseFaqEntries(markdown: string): FaqEntry[] {\n const entries: FaqEntry[] = [];\n let inFence = false;\n for (const line of markdown.split(\"\\n\")) {\n if (line.startsWith(FENCE)) {\n inFence = !inFence;\n } else if (inFence) {\n continue;\n } else if (line.startsWith(HEADING)) {\n entries.push(newEntry(line.slice(HEADING.length).trim()));\n } else {\n const pointer = parsePointerLine(line);\n const current = entries[entries.length - 1];\n if (pointer && current) current[pointer.list].push(pointer.value);\n }\n }\n return entries;\n}\n\n/** An entry with no pointer at all is prose the skill cannot verify against the\n * running system, which is exactly what this format exists to prevent. */\nexport const entryHasPointer = (entry: FaqEntry): boolean => entry.configKeys.length + entry.sources.length + entry.helps.length > 0;\n"],"mappings":";;;;;AAmC0B,OAAO,OAAO;CAAE,KAAK;CAAG,KAAK;AAAO,CAAC;;;;AC3B/D,SAAgB,SAAS,OAAO;CAC5B,OAAO,OAAO,UAAU,YAAY,UAAU,QAAQ,CAAC,MAAM,QAAQ,KAAK;AAC9E;;AA+BA,SAAgB,cAAc,OAAO,KAAK;CACtC,OAAO,SAAS,KAAK,KAAK,OAAO,MAAM,SAAS;AACpD;;;;;;;;;;;;AA8BA,SAAgB,aAAa,KAAK,UAAU;CACxC,IAAI,eAAe,OACf,OAAO,IAAI;CACf,IAAI,cAAc,KAAK,SAAS,KAAK,IAAI,SACrC,OAAO,IAAI;CACf,IAAI,cAAc,KAAK,SAAS,KAAK,IAAI,SACrC,OAAO,IAAI;CACf,IAAI,aAAa,KAAA,GACb,OAAO;CACX,OAAO,OAAO,GAAG;AACrB;;;AC/CA,SAAS,gBAAgB,QAAgB,SAAuB;CAC9D,UAAU,SAAS,EAAE,WAAW,KAAK,CAAC;CACtC,KAAK,MAAM,SAAS,YAAY,QAAQ,EAAE,eAAe,KAAK,CAAC,GAAG;EAChE,MAAM,UAAU,KAAK,KAAK,QAAQ,MAAM,IAAI;EAC5C,MAAM,WAAW,KAAK,KAAK,SAAS,MAAM,IAAI;EAC9C,IAAI,MAAM,YAAY,GACpB,gBAAgB,SAAS,QAAQ;OAC5B,IAAI,MAAM,OAAO,GACtB,aAAa,SAAS,QAAQ;CAElC;AACF;AAEA,IAAM,iBAAiB;AAsCvB,SAAS,oBAAoB,WAAmB,OAAwB;CACtE,IAAI,MAAM,WAAW,GAAG,GAAG,OAAO;EAAE,IAAI;EAAO,QAAQ;EAAU,QAAQ;CAAK;CAC9E,MAAM,UAAU,KAAK,KAAK,WAAW,KAAK;CAC1C,IAAI;CACJ,IAAI;EACF,UAAU,SAAS,OAAO;CAC5B,QAAQ;EACN,OAAO;GAAE,IAAI;GAAO,QAAQ;GAAe,QAAQ;EAAK;CAC1D;CACA,IAAI,CAAC,QAAQ,YAAY,GAAG,OAAO;EAAE,IAAI;EAAO,QAAQ;EAAmB,QAAQ;CAAK;CACxF,IAAI,CAAC,aAAa,KAAK,GAAG,OAAO;EAAE,IAAI;EAAO,QAAQ;EAAgD,QAAQ;CAAM;CAIpH,MAAM,YAAY,KAAK,KAAK,SAAS,cAAc;CACnD,IAAI;CACJ,IAAI;EACF,YAAY,SAAS,SAAS;CAChC,QAAQ;EACN,OAAO;GAAE,IAAI;GAAO,QAAQ,WAAW;GAAkB,QAAQ;EAAM;CACzE;CACA,IAAI,CAAC,UAAU,OAAO,GAAG,OAAO;EAAE,IAAI;EAAO,QAAQ,GAAG,eAAe;EAA0B,QAAQ;CAAM;CAC/G,OAAO,EAAE,IAAI,KAAK;AACpB;;;;;AAMA,SAAS,kBAAkB,aAA8B;CACvD,IAAI;CACJ,IAAI;EACF,OAAO,SAAS,WAAW;CAC7B,QAAQ;EACN,UAAU,aAAa,EAAE,WAAW,KAAK,CAAC;EAC1C,OAAO;CACT;CACA,OAAO,KAAK,YAAY;AAC1B;AAWA,IAAM,cAA6B;CACjC,UAAU;CACV,QAAQ;CACR,SAAS,WAAW,OAAO,QAAQ;EAAE,WAAW;EAAM,OAAO;CAAK,CAAC;CACnE,QAAQ;AACV;;;;;;;;;;;;;;;;;;;AAoBA,SAAgB,iBAAiB,eAAuB,aAAqB,QAAuB,aAAmB;CACrH,MAAM,UAAU,GAAG,YAAY,OAAO,WAAW;CACjD,IAAI;EACF,MAAM,SAAS,eAAe,OAAO;CACvC,SAAS,KAAK;EACZ,MAAM,OAAO,OAAO;EACpB,MAAM;CACR;CACA,MAAM,SAAS,GAAG,YAAY,OAAO,WAAW;CAChD,IAAI,WAAW;CACf,IAAI,MAAM,OAAO,WAAW,GAC1B,IAAI;EACF,MAAM,OAAO,aAAa,MAAM;EAChC,WAAW;CACb,SAAS,KAAK;EACZ,MAAM,OAAO,OAAO;EACpB,MAAM;CACR;CAEF,IAAI;EACF,MAAM,OAAO,SAAS,WAAW;CACnC,SAAS,KAAK;EACZ,IAAI,UACF,IAAI;GACF,MAAM,OAAO,QAAQ,WAAW;GAChC,MAAM,OAAO,OAAO;EACtB,QAAQ,CAGR;OAEA,MAAM,OAAO,OAAO;EAEtB,MAAM;CACR;CACA,IAAI,UAAU,MAAM,OAAO,MAAM;AACnC;AAEA,SAAS,oBAAoB,WAAmB,SAAiB,MAA+B,QAA6C;CAK3I,MAAM,uBAAO,IAAI,IAAY;CAC7B,KAAK,MAAM,SAAS,YAAY,SAAS,GAAG;EAC1C,MAAM,UAAU,oBAAoB,WAAW,KAAK;EACpD,IAAI,CAAC,QAAQ,IAAI;GACf,IAAI,CAAC,QAAQ,QAAQ;IACnB,OAAO,QAAQ,KAAK,GAAG,MAAM,IAAI,QAAQ,QAAQ;IACjD,KAAK,SAAS,wBAAwB;KAAE,MAAM;KAAO,QAAQ,QAAQ;IAAO,CAAC;GAC/E;GACA;EACF;EACA,MAAM,cAAc,KAAK,KAAK,SAAS,KAAK;EAC5C,IAAI,CAAC,kBAAkB,WAAW,GAAG;GACnC,MAAM,SAAS;GACf,OAAO,QAAQ,KAAK,GAAG,MAAM,IAAI,QAAQ;GACzC,KAAK,SAAS,wBAAwB;IAAE,MAAM;IAAO;IAAQ;GAAY,CAAC;GAC1E;EACF;EAIA,KAAK,IAAI,KAAK;EAId,IAAI;GACF,iBAAiB,KAAK,KAAK,WAAW,KAAK,GAAG,WAAW;GACzD,OAAO,OAAO,KAAK,KAAK;EAC1B,SAAS,KAAK;GACZ,MAAM,SAAS,eAAe,QAAQ,IAAI,UAAU,OAAO,GAAG;GAC9D,OAAO,QAAQ,KAAK,GAAG,MAAM,IAAI,QAAQ;GACzC,KAAK,SAAS,gCAAgC;IAAE,MAAM;IAAO;GAAO,CAAC;EACvE;CACF;CACA,OAAO;AACT;AAEA,SAAS,qBAAqB,SAAiB,MAA2B,MAA+B,QAAsC;CAC7I,KAAK,MAAM,SAAS,YAAY,OAAO,GAAG;EACxC,IAAI,CAAC,aAAa,KAAK,GAAG;EAC1B,IAAI,KAAK,IAAI,KAAK,GAAG;EACrB,MAAM,YAAY,KAAK,KAAK,SAAS,KAAK;EAC1C,IAAI;GACF,IAAI,CAAC,SAAS,SAAS,CAAC,CAAC,YAAY,GAAG;EAC1C,QAAQ;GACN;EACF;EACA,OAAO,WAAW;GAAE,WAAW;GAAM,OAAO;EAAK,CAAC;EAClD,OAAO,QAAQ,KAAK,KAAK;EACzB,KAAK,SAAS,gCAAgC,EAAE,MAAM,MAAM,CAAC;CAC/D;AACF;;;;;;;;;AAUA,SAAgB,iBAAiB,MAAuD;CACtF,MAAM,SAAiC;EAAE,QAAQ,CAAC;EAAG,SAAS,CAAC;EAAG,SAAS,CAAC;CAAE;CAC9E,IAAI,CAAC,WAAW,KAAK,SAAS,GAG5B,OAAO;CAMT,IAAI;CACJ,IAAI;EACF,aAAa,SAAS,KAAK,SAAS;CACtC,SAAS,KAAK;EACZ,MAAM,SAAS,4BAA4B,aAAa,GAAG;EAC3D,OAAO,QAAQ,KAAK,GAAG,KAAK,UAAU,IAAI,QAAQ;EAClD,KAAK,SAAS,uBAAuB;GAAE,WAAW,KAAK;GAAW;EAAO,CAAC;EAC1E,OAAO;CACT;CACA,IAAI,CAAC,WAAW,YAAY,GAAG;EAC7B,MAAM,SAAS;EACf,OAAO,QAAQ,KAAK,GAAG,KAAK,UAAU,IAAI,QAAQ;EAClD,KAAK,SAAS,uBAAuB;GAAE,WAAW,KAAK;GAAW;EAAO,CAAC;EAC1E,OAAO;CACT;CAMA,IAAI,CAAC,kBAAkB,KAAK,OAAO,GAAG;EACpC,MAAM,SAAS;EACf,OAAO,QAAQ,KAAK,GAAG,KAAK,QAAQ,IAAI,QAAQ;EAChD,KAAK,SAAS,uBAAuB;GAAE,SAAS,KAAK;GAAS;EAAO,CAAC;EACtE,OAAO;CACT;CACA,MAAM,OAAO,oBAAoB,KAAK,WAAW,KAAK,SAAS,MAAM,MAAM;CAC3E,qBAAqB,KAAK,SAAS,MAAM,MAAM,MAAM;CACrD,IAAI,OAAO,OAAO,SAAS,KAAK,OAAO,QAAQ,SAAS,GACtD,KAAK,SAAS,wBAAwB;EACpC,QAAQ,OAAO,OAAO;EACtB,SAAS,OAAO,QAAQ;EACxB,SAAS,OAAO,QAAQ;CAC1B,CAAC;CAEH,OAAO;AACT;AA8DA,SAAS,WAAW,MAAc,OAAwB;CACxD,IAAI;EACF,OAAO,aAAa,IAAI,CAAC,CAAC,OAAO,aAAa,KAAK,CAAC;CACtD,QAAQ;EACN,OAAO;CACT;AACF;;;;;AAMA,SAAS,YAAY,SAAiB,UAAkB,WAAoC;CAC1F,IAAI;CACJ,IAAI;EACF,aAAa,SAAS,QAAQ,CAAC,CAAC,OAAO;CACzC,QAAQ;EACN,aAAa;CACf;CACA,IAAI,CAAC,YACH,IAAI;EACF,aAAa,SAAS,QAAQ;EAC9B,OAAO;CACT,QAAQ;EACN,OAAO;CACT;CAEF,IAAI,WAAW,SAAS,QAAQ,GAAG,OAAO;CAC1C,IAAI;EACF,WAAW,UAAU,WAAW,SAAS;EACzC,aAAa,SAAS,QAAQ;EAC9B,OAAO;CACT,QAAQ;EACN,OAAO;CACT;AACF;AAQA,SAAS,gBAAgB,QAAgB,SAAiB,WAAiC;CACzF,MAAM,QAAsB;EAAE,SAAS;EAAG,WAAW;EAAG,SAAS;CAAE;CACnE,IAAI;EACF,UAAU,SAAS,EAAE,WAAW,KAAK,CAAC;CACxC,QAAQ;EACN,MAAM;EACN,OAAO;CACT;CACA,IAAI;CACJ,IAAI;EACF,UAAU,YAAY,QAAQ,EAAE,eAAe,KAAK,CAAC;CACvD,QAAQ;EACN,MAAM;EACN,OAAO;CACT;CACA,KAAK,MAAM,SAAS,SAAS;EAC3B,MAAM,UAAU,KAAK,KAAK,QAAQ,MAAM,IAAI;EAC5C,MAAM,WAAW,KAAK,KAAK,SAAS,MAAM,IAAI;EAC9C,IAAI,MAAM,YAAY,GAAG;GACvB,MAAM,MAAM,gBAAgB,SAAS,UAAU,SAAS;GACxD,MAAM,WAAW,IAAI;GACrB,MAAM,aAAa,IAAI;GACvB,MAAM,WAAW,IAAI;EACvB,OAAO,IAAI,MAAM,OAAO,GAAG;GACzB,MAAM,UAAU,YAAY,SAAS,UAAU,SAAS;GACxD,MAAM,QAAQ;EAChB;CAGF;CACA,OAAO;AACT;AAEA,SAAS,gBAAgB,MAAqC,QAAsC,QAA8C;CAChJ,OAAO,QAAQ,KAAK,GAAG,KAAK,UAAU,IAAI,QAAQ;CAClD,KAAK,SAAS,8BAA8B;EAAE,WAAW,KAAK;EAAW;CAAO,CAAC;CACjF,OAAO;AACT;;;;;;;;;AAUA,SAAS,iBAAiB,SAAiB,UAA2B;CACpE,IAAI;EACF,MAAM,OAAO,aAAa,OAAO;EACjC,MAAM,WAAW,aAAa,QAAQ;EACtC,OAAO,SAAS,YAAY,KAAK,WAAW,WAAW,KAAK,GAAG;CACjE,QAAQ;EACN,OAAO;CACT;AACF;;;;;;;;;;;;;;;;;AAoBA,SAAS,mBAAmB,MAAc,aAAqB,WAAgC;CAC7F,IAAI;CACJ,IAAI;EACF,WAAW,UAAU,WAAW;CAClC,QAAQ;EACN,OAAO,EAAE,MAAM,aAAa;CAC9B;CACA,IAAI,SAAS,eAAe,GAAG;EAC7B,IAAI,CAAC,iBAAiB,aAAa,SAAS,GAC1C,OAAO;GAAE,IAAI;GAAO,QAAQ;EAAwF;EAEtH,OAAO,EAAE,IAAI,KAAK;CACpB;CACA,IAAI,CAAC,SAAS,YAAY,GACxB,OAAO;EAAE,IAAI;EAAO,QAAQ;CAAuD;CAErF,IAAI,CAAC,iBAAiB,aAAa,SAAS,GAC1C,OAAO;EAAE,IAAI;EAAO,QAAQ;CAA4D;CAE1F,OAAO,EAAE,IAAI,KAAK;AACpB;;;;AAKA,SAAS,kBAAkB,MAAc,MAAqC,QAAsC,WAAyB;CAC3I,MAAM,aAAa,KAAK,KAAK,KAAK,WAAW,IAAI;CACjD,IAAI;EACF,IAAI,CAAC,SAAS,UAAU,CAAC,CAAC,YAAY,GAAG;CAC3C,QAAQ;EACN;CACF;CACA,MAAM,cAAc,KAAK,KAAK,KAAK,WAAW,IAAI;CAClD,MAAM,UAAU,mBAAmB,MAAM,aAAa,KAAK,SAAS;CACpE,IAAI,UAAU,SAAS;EACrB,OAAO,UAAU,KAAK,IAAI;EAC1B;CACF;CACA,IAAI,CAAC,QAAQ,IAAI;EACf,OAAO,QAAQ,KAAK,GAAG,KAAK,IAAI,QAAQ,QAAQ;EAChD,KAAK,SAAS,8BAA8B;GAAE;GAAM,QAAQ,QAAQ;GAAQ;EAAY,CAAC;EACzF;CACF;CACA,MAAM,QAAQ,gBAAgB,YAAY,aAAa,SAAS;CAChE,IAAI,MAAM,UAAU,GAAG;EACrB,OAAO,QAAQ,KAAK,IAAI;EACxB,KAAK,SAAS,2CAA2C;GAAE;GAAM,OAAO,MAAM;GAAS,cAAc;EAAU,CAAC;CAClH,OAAO,IAAI,MAAM,YAAY,GAC3B,OAAO,UAAU,KAAK,IAAI;CAE5B,IAAI,MAAM,UAAU,GAAG;EACrB,OAAO,QAAQ,KAAK,GAAG,KAAK,IAAI,MAAM,QAAQ,iBAAiB;EAC/D,KAAK,SAAS,sCAAsC;GAAE;GAAM,SAAS,MAAM;EAAQ,CAAC;CACtF;AACF;;;;AAKA,SAAS,sBAAsB,WAAmB,SAAyC;CACzF,MAAM,0BAAU,IAAI,IAAY;CAChC,KAAK,MAAM,QAAQ,SAAS;EAC1B,MAAM,YAAY,KAAK,KAAK,WAAW,IAAI;EAC3C,IAAI;GACF,IAAI,UAAU,SAAS,CAAC,CAAC,eAAe,GAAG,QAAQ,IAAI,aAAa,SAAS,CAAC;EAChF,QAAQ,CAER;CACF;CACA,OAAO;AACT;;;;AAKA,SAAS,oBAAoB,MAAc,WAAmB,aAAkC,gBAA8C;CAC5I,IAAI,CAAC,aAAa,IAAI,KAAK,YAAY,IAAI,IAAI,GAAG,OAAO;CACzD,MAAM,cAAc,KAAK,KAAK,WAAW,IAAI;CAC7C,IAAI;CACJ,IAAI;EACF,OAAO,UAAU,WAAW;CAC9B,QAAQ;EACN,OAAO;CACT;CACA,IAAI,KAAK,eAAe,KAAK,CAAC,KAAK,YAAY,GAAG,OAAO;CACzD,IAAI,CAAC,iBAAiB,aAAa,SAAS,GAAG,OAAO;CACtD,IAAI;EACF,OAAO,CAAC,eAAe,IAAI,aAAa,WAAW,CAAC;CACtD,QAAQ;EACN,OAAO;CACT;AACF;;;;;;;AAQA,SAAS,2BAA2B,MAAqC,QAAsC,aAAwC;CACrJ,IAAI;CACJ,IAAI;EACF,gBAAgB,YAAY,KAAK,SAAS;CAC5C,QAAQ;EACN;CACF;CACA,MAAM,iBAAiB,sBAAsB,KAAK,WAAW,aAAa;CAC1E,KAAK,MAAM,QAAQ,eAAe;EAChC,IAAI,CAAC,oBAAoB,MAAM,KAAK,WAAW,aAAa,cAAc,GAAG;EAC7E,IAAI;GACF,OAAO,KAAK,KAAK,KAAK,WAAW,IAAI,GAAG;IAAE,WAAW;IAAM,OAAO;GAAK,CAAC;GACxE,OAAO,QAAQ,KAAK,IAAI;GACxB,KAAK,SAAS,uCAAuC,EAAE,KAAK,CAAC;EAC/D,SAAS,KAAK;GACZ,OAAO,QAAQ,KAAK,GAAG,KAAK,kBAAkB,aAAa,GAAG,GAAG;GACjE,KAAK,SAAS,8BAA8B;IAAE;IAAM,QAAQ,aAAa,GAAG;GAAE,CAAC;EACjF;CACF;AACF;;;;;;AAOA,SAAgB,uBAAuB,MAAmE;CACxG,MAAM,SAAuC;EAAE,SAAS,CAAC;EAAG,WAAW,CAAC;EAAG,WAAW,CAAC;EAAG,SAAS,CAAC;EAAG,SAAS,CAAC;EAAG,cAAc;CAAK;CACvI,IAAI,CAAC,WAAW,KAAK,SAAS,GAAG,OAAO;CACxC,IAAI;CACJ,IAAI;EACF,aAAa,SAAS,KAAK,SAAS;CACtC,SAAS,KAAK;EACZ,OAAO,gBAAgB,MAAM,QAAQ,4BAA4B,aAAa,GAAG,GAAG;CACtF;CACA,IAAI,CAAC,WAAW,YAAY,GAC1B,OAAO,gBAAgB,MAAM,QAAQ,mEAAmE;CAE1G,IAAI;CACJ,IAAI;EACF,gBAAgB,YAAY,KAAK,SAAS;CAC5C,SAAS,KAAK;EACZ,OAAO,gBAAgB,MAAM,QAAQ,0BAA0B,aAAa,GAAG,GAAG;CACpF;CAGA,MAAM,YAAY,QAAQ,KAAK,IAAI;CACnC,OAAO,eAAe;CACtB,MAAM,8BAAc,IAAI,IAAY;CACpC,KAAK,MAAM,QAAQ,eAAe;EAChC,IAAI,KAAK,WAAW,GAAG,GAAG;EAC1B,IAAI,CAAC,aAAa,IAAI,GAAG;EACzB,YAAY,IAAI,IAAI;EACpB,kBAAkB,MAAM,MAAM,QAAQ,SAAS;CACjD;CAEA,2BAA2B,MAAM,QAAQ,WAAW;CACpD,IAAI,OAAO,QAAQ,SAAS,KAAK,OAAO,QAAQ,SAAS,GACvD,KAAK,SAAS,+BAA+B;EAC3C,SAAS,OAAO,QAAQ;EACxB,WAAW,OAAO,UAAU;EAC5B,WAAW,OAAO,UAAU;EAC5B,SAAS,OAAO,QAAQ;EACxB,SAAS,OAAO,QAAQ;EACxB,cAAc;CAChB,CAAC;CAEH,OAAO;AACT;;;ACtoBA,IAAM,aAAa,cAAc,IAAI;;CAAuB;CAAgB,YAAY;AAAG,CAAC;;AAG5F,SAAgB,gBAAwB;CACtC,OAAO,KAAK,KAAK,YAAY,OAAO;AACtC;;;AAIA,SAAgB,uBAA+B;CAC7C,OAAO,KAAK,KAAK,YAAY,eAAe;AAC9C;;;AAIA,SAAgB,UAAU,MAAiC;CACzD,UAAU,KAAK,SAAS,EAAE,WAAW,KAAK,CAAC;CAC3C,MAAM,MAAM,cAAc;CAC1B,KAAK,MAAM,QAAQ,YAAY,GAAG,GAChC,aAAa,KAAK,KAAK,KAAK,IAAI,GAAG,KAAK,KAAK,KAAK,SAAS,IAAI,CAAC;AAEpE;;;ACfA,IAAM,yBAAS,IAAI,IAAuB;CACxC,CAAC,aAAa,YAAY;CAC1B,CAAC,UAAU,SAAS;CACpB,CAAC,QAAQ,OAAO;AAClB,CAAC;AAED,IAAM,UAAU;AAChB,IAAM,QAAQ;AAEd,IAAM,YAAY,aAA+B;CAAE;CAAS,YAAY,CAAC;CAAG,SAAS,CAAC;CAAG,OAAO,CAAC;AAAE;;;;AAUnG,SAAgB,iBAAiB,MAAkC;CACjE,MAAM,QAAQ,KAAK,QAAQ,GAAG;CAC9B,IAAI,SAAS,GAAG,OAAO;CACvB,MAAM,OAAO,OAAO,IAAI,KAAK,MAAM,GAAG,KAAK,CAAC,CAAC,KAAK,CAAC;CACnD,MAAM,QAAQ,KAAK,MAAM,QAAQ,CAAC,CAAC,CAAC,KAAK;CACzC,OAAO,QAAQ,QAAQ;EAAE;EAAM;CAAM,IAAI;AAC3C;;;;;;AAOA,SAAgB,gBAAgB,UAA8B;CAC5D,MAAM,UAAsB,CAAC;CAC7B,IAAI,UAAU;CACd,KAAK,MAAM,QAAQ,SAAS,MAAM,IAAI,GACpC,IAAI,KAAK,WAAW,KAAK,GACvB,UAAU,CAAC;MACN,IAAI,SACT;MACK,IAAI,KAAK,WAAW,OAAO,GAChC,QAAQ,KAAK,SAAS,KAAK,MAAM,CAAc,CAAC,CAAC,KAAK,CAAC,CAAC;MACnD;EACL,MAAM,UAAU,iBAAiB,IAAI;EACrC,MAAM,UAAU,QAAQ,QAAQ,SAAS;EACzC,IAAI,WAAW,SAAS,QAAQ,QAAQ,KAAK,CAAC,KAAK,QAAQ,KAAK;CAClE;CAEF,OAAO;AACT;;;AAIA,IAAa,mBAAmB,UAA6B,MAAM,WAAW,SAAS,MAAM,QAAQ,SAAS,MAAM,MAAM,SAAS"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mulmoclaude/core",
|
|
3
|
-
"version": "4.
|
|
3
|
+
"version": "4.9.1",
|
|
4
4
|
"description": "Shared server-side core for MulmoClaude and MulmoTerminal — the always-shipped-together subsystems consolidated behind subpath exports so the two hosts can't drift. Server-only except the browser-safe ./artifacts, ./whisper/client, ./workspace-setup/slug, ./translation/client, ./remote-view, ./remote-host and ./plugin-vue entries. All host specifics are injected.",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -224,15 +224,15 @@
|
|
|
224
224
|
"lint": "eslint src test"
|
|
225
225
|
},
|
|
226
226
|
"dependencies": {
|
|
227
|
-
"@duckdb/node-api": "^1.5.5-r.
|
|
228
|
-
"@mulmoclaude/common": "^1.
|
|
227
|
+
"@duckdb/node-api": "^1.5.5-r.4",
|
|
228
|
+
"@mulmoclaude/common": "^1.3.0",
|
|
229
229
|
"@mulmoclaude/markdown-utils": "^2.2.0",
|
|
230
|
-
"dompurify": "^3.4.
|
|
230
|
+
"dompurify": "^3.4.15",
|
|
231
231
|
"fast-xml-parser": "^5.11.1",
|
|
232
232
|
"google-auth-library": "^10.9.1",
|
|
233
233
|
"iconv-lite": "^0.7.3",
|
|
234
234
|
"js-yaml": "^5.4.1",
|
|
235
|
-
"zod": "^4.
|
|
235
|
+
"zod": "^4.6.2"
|
|
236
236
|
},
|
|
237
237
|
"peerDependencies": {
|
|
238
238
|
"@receptron/task-scheduler": "^1.0.3",
|
|
@@ -258,8 +258,8 @@
|
|
|
258
258
|
"gui-chat-protocol": "^2.0.0",
|
|
259
259
|
"tsx": "^4.23.13",
|
|
260
260
|
"typescript": "^6.0.3",
|
|
261
|
-
"vite": "^8.
|
|
262
|
-
"vite-plugin-dts": "^5.0
|
|
261
|
+
"vite": "^8.3.0",
|
|
262
|
+
"vite-plugin-dts": "^5.1.0",
|
|
263
263
|
"vue": "^3.5.42",
|
|
264
264
|
"vue-i18n": "^11.4.10"
|
|
265
265
|
},
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"dist-CsgSfWwR.cjs","names":[],"sources":["../../common/dist/index.js"],"sourcesContent":["// General-purpose runtime type guards, shared across the MulmoClaude host,\n// bridges, and plugins. This is a leaf package — pure and dependency-free — so\n// any tier can import it without creating an uphill edge.\n//\n// These originated as `server/utils/types.ts` (#504), which centralised 40+\n// hand-written inline `typeof x === \"object\"` checks. They are promoted here so\n// the same guards stop being re-hand-written in every bridge and plugin too.\n/** Narrow `unknown` to a plain object (not null, not array). */\nexport function isRecord(value) {\n return typeof value === \"object\" && value !== null && !Array.isArray(value);\n}\n/** Narrow `unknown` to any object (not null, arrays allowed).\n * Use `isRecord` when you need to access string keys. */\nexport function isObj(value) {\n return typeof value === \"object\" && value !== null;\n}\n/** Non-empty string after trimming whitespace. */\nexport function isNonEmptyString(value) {\n return typeof value === \"string\" && value.trim().length > 0;\n}\n/** Record whose values are all strings. */\nexport function isStringRecord(value) {\n if (!isRecord(value))\n return false;\n return Object.values(value).every((val) => typeof val === \"string\");\n}\n/** String array (every element is a string). */\nexport function isStringArray(value) {\n return Array.isArray(value) && value.every((val) => typeof val === \"string\");\n}\n/** An array of unknowns. Prefer this over a bare `Array.isArray` in typed code:\n * `Array.isArray(x: unknown)` narrows to `any[]`, silently reintroducing\n * `any`, whereas this keeps the element type `unknown`. */\nexport function isUnknownArray(value) {\n return Array.isArray(value);\n}\n/** Error-like object with a `code` property (e.g. Node.js fs errors). */\nexport function isErrorWithCode(value) {\n return isRecord(value) && typeof value.code === \"string\";\n}\n/** Check that a record has a specific key with a string value. */\nexport function hasStringProp(value, key) {\n return isRecord(value) && typeof value[key] === \"string\";\n}\n/** Check that a record has a specific key with a number value. */\nexport function hasNumberProp(value, key) {\n return isRecord(value) && typeof value[key] === \"number\";\n}\n/** Split a comma-separated env value into trimmed, non-empty entries.\n * `lowercase` folds case for identifiers compared case-insensitively\n * (JIDs, email addresses, hex pubkeys). Absent/empty input → empty list. */\nexport function parseCsvList(raw, opts) {\n return (raw ?? \"\")\n .split(\",\")\n .map((entry) => (opts?.lowercase ? entry.trim().toLowerCase() : entry.trim()))\n .filter(Boolean);\n}\n/** A comma-separated env value as a Set — the canonical allowlist shape,\n * where an empty set is the \"allow all\" sentinel (`set.size === 0`). */\nexport function parseCsvSet(raw, opts) {\n return new Set(parseCsvList(raw, opts));\n}\n/** Normalise an unknown thrown value into a human-readable string. Isomorphic\n * (host, bridges, plugins, Vue) — this is the single home for the helper that\n * #2217 could only consolidate for server code, since `@mulmoclaude/core/utils`\n * is server-only.\n *\n * A non-Error object with a non-empty string `details` (gRPC convention) or\n * `message` field surfaces that field — `details` wins — instead of the\n * `[object Object]` a bare `String(err)` would print; an empty-string field\n * falls through. `fallback` covers the error-boundary idiom where a thrown\n * non-Error should read as a descriptive message rather than `String(err)`\n * noise; omit it in logging contexts where `String(err)` is fine. */\nexport function errorMessage(err, fallback) {\n if (err instanceof Error)\n return err.message;\n if (hasStringProp(err, \"details\") && err.details)\n return err.details;\n if (hasStringProp(err, \"message\") && err.message)\n return err.message;\n if (fallback !== undefined)\n return fallback;\n return String(err);\n}\n/** `Date` → `YYYY-MM-DD` in UTC — for dates that must not shift with the\n * host's local timezone (tool-trace search dirs, API date keys). Isomorphic\n * single source (#2480): the host re-exports it from `server/utils/date.ts`,\n * x-plugin imports it directly. The `@receptron/task-scheduler` copy stays\n * local on purpose — that leaf package is published independently and kept\n * dependency-free. Wall-clock questions use the host's `toLocalIsoDate`. */\nexport function toUtcIsoDate(timestamp) {\n const year = timestamp.getUTCFullYear();\n const month = String(timestamp.getUTCMonth() + 1).padStart(2, \"0\");\n const day = String(timestamp.getUTCDate()).padStart(2, \"0\");\n return `${year}-${month}-${day}`;\n}\n// A Map, not an object literal: `{}[char]` reads through the prototype chain,\n// so a future caller widening the regex would silently get `[object Object]`\n// for keys like `constructor`.\nconst HTML_ESCAPES = new Map([\n [\"&\", \"&\"],\n [\"<\", \"<\"],\n [\">\", \">\"],\n ['\"', \""\"],\n [\"'\", \"'\"],\n]);\n/** HTML-escape text destined for markup or an attribute value — the fixed\n * five-character map, nothing more. Lives here rather than in\n * `@mulmoclaude/core/wiki` (#2483) because `@mulmoclaude/markdown-utils` is a\n * leaf that core depends on and so cannot import back up; core/wiki\n * re-exports this, keeping its consumers' import path unchanged.\n *\n * Escaping `&` first is what makes a single pass safe — the entities this\n * introduces contain none of the other four characters, so nothing is\n * double-escaped. Not a sanitiser: it neither strips tags nor validates URLs. */\nexport function escapeHtml(value) {\n return value.replace(/[&<>\"']/g, (char) => HTML_ESCAPES.get(char) ?? char);\n}\n/** Split a JWS compact serialization into its three segments, or `null` when the\n * token isn't well-formed. Pure string work, so the Node bridges and the\n * Cloudflare Workers relay — which decode the segments differently (`Buffer` vs\n * `atob`) — can still share this one guard.\n *\n * A JWS compact serialization is EXACTLY three segments. Both a short token and\n * a longer one (a five-segment JWE, or an attacker appending `.junk`) are\n * rejected outright — never parsed from their first three segments, which would\n * let the signed input disagree with the token. */\nexport function splitJwtSegments(token) {\n const [headerSegment, payloadSegment, signatureSegment, ...extraSegments] = token.split(\".\");\n if (headerSegment === undefined || payloadSegment === undefined || signatureSegment === undefined)\n return null;\n if (extraSegments.length > 0)\n return null;\n return { headerSegment, payloadSegment, signatureSegment };\n}\nexport { scanEnvOptions, snakeToLowerCamel } from \"./envScan.js\";\n"],"mappings":";;AAQA,SAAgB,SAAS,OAAO;CAC5B,OAAO,OAAO,UAAU,YAAY,UAAU,QAAQ,CAAC,MAAM,QAAQ,KAAK;AAC9E;;;AAGA,SAAgB,MAAM,OAAO;CACzB,OAAO,OAAO,UAAU,YAAY,UAAU;AAClD;;AAYA,SAAgB,cAAc,OAAO;CACjC,OAAO,MAAM,QAAQ,KAAK,KAAK,MAAM,OAAO,QAAQ,OAAO,QAAQ,QAAQ;AAC/E;;;;AAIA,SAAgB,eAAe,OAAO;CAClC,OAAO,MAAM,QAAQ,KAAK;AAC9B;;AAEA,SAAgB,gBAAgB,OAAO;CACnC,OAAO,SAAS,KAAK,KAAK,OAAO,MAAM,SAAS;AACpD;;AAEA,SAAgB,cAAc,OAAO,KAAK;CACtC,OAAO,SAAS,KAAK,KAAK,OAAO,MAAM,SAAS;AACpD;;AAEA,SAAgB,cAAc,OAAO,KAAK;CACtC,OAAO,SAAS,KAAK,KAAK,OAAO,MAAM,SAAS;AACpD;;;;;;;;;;;;AA0BA,SAAgB,aAAa,KAAK,UAAU;CACxC,IAAI,eAAe,OACf,OAAO,IAAI;CACf,IAAI,cAAc,KAAK,SAAS,KAAK,IAAI,SACrC,OAAO,IAAI;CACf,IAAI,cAAc,KAAK,SAAS,KAAK,IAAI,SACrC,OAAO,IAAI;CACf,IAAI,aAAa,KAAA,GACb,OAAO;CACX,OAAO,OAAO,GAAG;AACrB;AAgBA,IAAM,+BAAe,IAAI,IAAI;CACzB,CAAC,KAAK,OAAO;CACb,CAAC,KAAK,MAAM;CACZ,CAAC,KAAK,MAAM;CACZ,CAAC,MAAK,QAAQ;CACd,CAAC,KAAK,OAAO;AACjB,CAAC;;;;;;;;;;AAUD,SAAgB,WAAW,OAAO;CAC9B,OAAO,MAAM,QAAQ,aAAa,SAAS,aAAa,IAAI,IAAI,KAAK,IAAI;AAC7E"}
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"dist-D8zokgGo.js","names":[],"sources":["../../common/dist/index.js"],"sourcesContent":["// General-purpose runtime type guards, shared across the MulmoClaude host,\n// bridges, and plugins. This is a leaf package — pure and dependency-free — so\n// any tier can import it without creating an uphill edge.\n//\n// These originated as `server/utils/types.ts` (#504), which centralised 40+\n// hand-written inline `typeof x === \"object\"` checks. They are promoted here so\n// the same guards stop being re-hand-written in every bridge and plugin too.\n/** Narrow `unknown` to a plain object (not null, not array). */\nexport function isRecord(value) {\n return typeof value === \"object\" && value !== null && !Array.isArray(value);\n}\n/** Narrow `unknown` to any object (not null, arrays allowed).\n * Use `isRecord` when you need to access string keys. */\nexport function isObj(value) {\n return typeof value === \"object\" && value !== null;\n}\n/** Non-empty string after trimming whitespace. */\nexport function isNonEmptyString(value) {\n return typeof value === \"string\" && value.trim().length > 0;\n}\n/** Record whose values are all strings. */\nexport function isStringRecord(value) {\n if (!isRecord(value))\n return false;\n return Object.values(value).every((val) => typeof val === \"string\");\n}\n/** String array (every element is a string). */\nexport function isStringArray(value) {\n return Array.isArray(value) && value.every((val) => typeof val === \"string\");\n}\n/** An array of unknowns. Prefer this over a bare `Array.isArray` in typed code:\n * `Array.isArray(x: unknown)` narrows to `any[]`, silently reintroducing\n * `any`, whereas this keeps the element type `unknown`. */\nexport function isUnknownArray(value) {\n return Array.isArray(value);\n}\n/** Error-like object with a `code` property (e.g. Node.js fs errors). */\nexport function isErrorWithCode(value) {\n return isRecord(value) && typeof value.code === \"string\";\n}\n/** Check that a record has a specific key with a string value. */\nexport function hasStringProp(value, key) {\n return isRecord(value) && typeof value[key] === \"string\";\n}\n/** Check that a record has a specific key with a number value. */\nexport function hasNumberProp(value, key) {\n return isRecord(value) && typeof value[key] === \"number\";\n}\n/** Split a comma-separated env value into trimmed, non-empty entries.\n * `lowercase` folds case for identifiers compared case-insensitively\n * (JIDs, email addresses, hex pubkeys). Absent/empty input → empty list. */\nexport function parseCsvList(raw, opts) {\n return (raw ?? \"\")\n .split(\",\")\n .map((entry) => (opts?.lowercase ? entry.trim().toLowerCase() : entry.trim()))\n .filter(Boolean);\n}\n/** A comma-separated env value as a Set — the canonical allowlist shape,\n * where an empty set is the \"allow all\" sentinel (`set.size === 0`). */\nexport function parseCsvSet(raw, opts) {\n return new Set(parseCsvList(raw, opts));\n}\n/** Normalise an unknown thrown value into a human-readable string. Isomorphic\n * (host, bridges, plugins, Vue) — this is the single home for the helper that\n * #2217 could only consolidate for server code, since `@mulmoclaude/core/utils`\n * is server-only.\n *\n * A non-Error object with a non-empty string `details` (gRPC convention) or\n * `message` field surfaces that field — `details` wins — instead of the\n * `[object Object]` a bare `String(err)` would print; an empty-string field\n * falls through. `fallback` covers the error-boundary idiom where a thrown\n * non-Error should read as a descriptive message rather than `String(err)`\n * noise; omit it in logging contexts where `String(err)` is fine. */\nexport function errorMessage(err, fallback) {\n if (err instanceof Error)\n return err.message;\n if (hasStringProp(err, \"details\") && err.details)\n return err.details;\n if (hasStringProp(err, \"message\") && err.message)\n return err.message;\n if (fallback !== undefined)\n return fallback;\n return String(err);\n}\n/** `Date` → `YYYY-MM-DD` in UTC — for dates that must not shift with the\n * host's local timezone (tool-trace search dirs, API date keys). Isomorphic\n * single source (#2480): the host re-exports it from `server/utils/date.ts`,\n * x-plugin imports it directly. The `@receptron/task-scheduler` copy stays\n * local on purpose — that leaf package is published independently and kept\n * dependency-free. Wall-clock questions use the host's `toLocalIsoDate`. */\nexport function toUtcIsoDate(timestamp) {\n const year = timestamp.getUTCFullYear();\n const month = String(timestamp.getUTCMonth() + 1).padStart(2, \"0\");\n const day = String(timestamp.getUTCDate()).padStart(2, \"0\");\n return `${year}-${month}-${day}`;\n}\n// A Map, not an object literal: `{}[char]` reads through the prototype chain,\n// so a future caller widening the regex would silently get `[object Object]`\n// for keys like `constructor`.\nconst HTML_ESCAPES = new Map([\n [\"&\", \"&\"],\n [\"<\", \"<\"],\n [\">\", \">\"],\n ['\"', \""\"],\n [\"'\", \"'\"],\n]);\n/** HTML-escape text destined for markup or an attribute value — the fixed\n * five-character map, nothing more. Lives here rather than in\n * `@mulmoclaude/core/wiki` (#2483) because `@mulmoclaude/markdown-utils` is a\n * leaf that core depends on and so cannot import back up; core/wiki\n * re-exports this, keeping its consumers' import path unchanged.\n *\n * Escaping `&` first is what makes a single pass safe — the entities this\n * introduces contain none of the other four characters, so nothing is\n * double-escaped. Not a sanitiser: it neither strips tags nor validates URLs. */\nexport function escapeHtml(value) {\n return value.replace(/[&<>\"']/g, (char) => HTML_ESCAPES.get(char) ?? char);\n}\n/** Split a JWS compact serialization into its three segments, or `null` when the\n * token isn't well-formed. Pure string work, so the Node bridges and the\n * Cloudflare Workers relay — which decode the segments differently (`Buffer` vs\n * `atob`) — can still share this one guard.\n *\n * A JWS compact serialization is EXACTLY three segments. Both a short token and\n * a longer one (a five-segment JWE, or an attacker appending `.junk`) are\n * rejected outright — never parsed from their first three segments, which would\n * let the signed input disagree with the token. */\nexport function splitJwtSegments(token) {\n const [headerSegment, payloadSegment, signatureSegment, ...extraSegments] = token.split(\".\");\n if (headerSegment === undefined || payloadSegment === undefined || signatureSegment === undefined)\n return null;\n if (extraSegments.length > 0)\n return null;\n return { headerSegment, payloadSegment, signatureSegment };\n}\nexport { scanEnvOptions, snakeToLowerCamel } from \"./envScan.js\";\n"],"mappings":";;AAQA,SAAgB,SAAS,OAAO;CAC5B,OAAO,OAAO,UAAU,YAAY,UAAU,QAAQ,CAAC,MAAM,QAAQ,KAAK;AAC9E;;;AAGA,SAAgB,MAAM,OAAO;CACzB,OAAO,OAAO,UAAU,YAAY,UAAU;AAClD;;AAYA,SAAgB,cAAc,OAAO;CACjC,OAAO,MAAM,QAAQ,KAAK,KAAK,MAAM,OAAO,QAAQ,OAAO,QAAQ,QAAQ;AAC/E;;;;AAIA,SAAgB,eAAe,OAAO;CAClC,OAAO,MAAM,QAAQ,KAAK;AAC9B;;AAEA,SAAgB,gBAAgB,OAAO;CACnC,OAAO,SAAS,KAAK,KAAK,OAAO,MAAM,SAAS;AACpD;;AAEA,SAAgB,cAAc,OAAO,KAAK;CACtC,OAAO,SAAS,KAAK,KAAK,OAAO,MAAM,SAAS;AACpD;;AAEA,SAAgB,cAAc,OAAO,KAAK;CACtC,OAAO,SAAS,KAAK,KAAK,OAAO,MAAM,SAAS;AACpD;;;;;;;;;;;;AA0BA,SAAgB,aAAa,KAAK,UAAU;CACxC,IAAI,eAAe,OACf,OAAO,IAAI;CACf,IAAI,cAAc,KAAK,SAAS,KAAK,IAAI,SACrC,OAAO,IAAI;CACf,IAAI,cAAc,KAAK,SAAS,KAAK,IAAI,SACrC,OAAO,IAAI;CACf,IAAI,aAAa,KAAA,GACb,OAAO;CACX,OAAO,OAAO,GAAG;AACrB;AAgBA,IAAM,+BAAe,IAAI,IAAI;CACzB,CAAC,KAAK,OAAO;CACb,CAAC,KAAK,MAAM;CACZ,CAAC,KAAK,MAAM;CACZ,CAAC,MAAK,QAAQ;CACd,CAAC,KAAK,OAAO;AACjB,CAAC;;;;;;;;;;AAUD,SAAgB,WAAW,OAAO;CAC9B,OAAO,MAAM,QAAQ,aAAa,SAAS,aAAa,IAAI,IAAI,KAAK,IAAI;AAC7E"}
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"notifier-DZJj0sOh.cjs","names":[],"sources":["../src/notifier/types.ts","../src/notifier/store.ts","../src/notifier/validate.ts","../src/notifier/engine.ts"],"sourcesContent":["// Notifier value types. Kept dependency-free (no node, no fs, no\n// pubsub) so the host's API-route layer and any future browser\n// consumer can validate inbound payloads against the same enum\n// constants the engine accepts without pulling in the engine's I/O.\n\n/** Two notification shapes, distinguished by who fires the close call:\n *\n * `fyi` — informational. The host (bell panel) clears it when the\n * user dismisses the row. No deep-link target.\n * `action` — pending obligation. The plugin clears it when the\n * underlying domain state changes (the user paid the tax,\n * viewed the digest, etc.). The bell row navigates to\n * `navigateTarget` on click.\n *\n * The engine reads `lifecycle` only to enforce two publish-time rules\n * (everything downstream — pubsub fan-out, persistence, history — is\n * lifecycle-blind):\n *\n * 1. `action` requires a non-empty `navigateTarget`. Without one,\n * clicking the row does nothing and the entry is a degraded fyi.\n * 2. `action` cannot use `info` severity. A low-priority obligation\n * is incoherent — fyi if it's a ping, `nudge`/`urgent` if it's a\n * real obligation worth a landing page.\n *\n * Both rules are mirrored in the HTTP layer so plugin-runtime callers\n * and HTTP callers hit the same wall. */\nexport const NOTIFIER_LIFECYCLES = [\"fyi\", \"action\"] as const;\nexport type NotifierLifecycle = (typeof NOTIFIER_LIFECYCLES)[number];\n\n/** Severity drives badge color (gray / amber / red, worst-wins) and\n * in a future iteration channel routing. Mostly stored verbatim by\n * the engine; the one engine-visible interaction is the rule that\n * `action` lifecycle cannot pair with `info` severity (see\n * `NotifierLifecycle` above). */\nexport const NOTIFIER_SEVERITIES = [\"info\", \"nudge\", \"urgent\"] as const;\nexport type NotifierSeverity = (typeof NOTIFIER_SEVERITIES)[number];\n\nexport interface NotifierEntry<TPluginData = unknown> {\n /** Engine-assigned UUID. Generated synchronously inside `publish()`\n * so the caller can use it before persistence completes. */\n id: string;\n /** Plugin namespace (e.g. `\"encore\"`, `\"debug__system\"`). The\n * engine never inspects it — used only for `listFor()` filtering\n * and as a UI grouping key. */\n pluginPkg: string;\n severity: NotifierSeverity;\n lifecycle?: NotifierLifecycle;\n title: string;\n body?: string;\n /** Optional in-app deep-link target (relative URL). The bell popup\n * routes here on row click, with `¬ificationId=<id>` appended\n * so the landing page can identify which entry to clear. The\n * engine doesn't read this — it's a UI hint stored on the entry. */\n navigateTarget?: string;\n /** Opaque to the engine. Round-trips through JSON unchanged; only\n * the originating plugin's UI knows the shape. */\n pluginData?: TPluginData;\n /** ISO-8601 timestamp set at `publish()` time. */\n createdAt: string;\n}\n\n/** A history entry — a `NotifierEntry` after it has been cleared or\n * cancelled, with the terminal type and timestamp recorded. The\n * bell popup's \"History\" section renders these read-only. */\nexport interface NotifierHistoryEntry<TPluginData = unknown> extends NotifierEntry<TPluginData> {\n terminalType: \"cleared\" | \"cancelled\";\n terminalAt: string;\n}\n\n/** Caller-supplied input for `publish()`. The engine fills in `id`\n * and `createdAt`; everything else flows through verbatim.\n *\n * Two publish-time rules apply to `action` lifecycle (see\n * `NotifierLifecycle`):\n *\n * - `navigateTarget` MUST be a non-empty string.\n * - `severity` MUST NOT be `\"info\"`.\n *\n * Violations cause `publish()` to throw. Currently expressed as\n * runtime validation rather than a discriminated-union type, so the\n * fields below are all individually optional / loose at the\n * type-level. */\nexport interface PublishInput<TPluginData = unknown> {\n pluginPkg: string;\n severity: NotifierSeverity;\n title: string;\n body?: string | undefined;\n lifecycle?: NotifierLifecycle | undefined;\n navigateTarget?: string | undefined;\n pluginData?: TPluginData | undefined;\n}\n\n/** On-disk shape of `~/mulmoclaude/data/notifier/active.json`. Holds\n * only entries that haven't been cleared or cancelled — the file is\n * a snapshot, not an event log. */\nexport interface NotifierFile {\n entries: Record<string, NotifierEntry>;\n}\n\n/** On-disk shape of `~/mulmoclaude/data/notifier/history.json`. Array\n * of terminated entries newest-first, capped at `HISTORY_CAP` with\n * FIFO eviction (push at index 0, slice from the tail). */\nexport interface NotifierHistoryFile {\n entries: NotifierHistoryEntry[];\n}\n\n/** History size cap. The bell popup's History section renders this\n * many entries; older ones fall off when new terminations land. */\nexport const HISTORY_CAP = 50;\n\n/** Pub-sub event published on the host's notifier channel after every\n * successful state change. Discriminated union — subscribers switch\n * on `type` to keep TypeScript narrowing the rest of the payload.\n *\n * `updated` carries the post-mutation entry — the receiver swaps\n * the matching `id` in their local active set. Reserved for in-\n * place edits via `updateForPlugin`; no history record is written\n * because the entry is still active, just with refreshed content. */\nexport type NotifierEvent =\n { type: \"published\"; entry: NotifierEntry } | { type: \"cleared\"; id: string } | { type: \"cancelled\"; id: string } | { type: \"updated\"; entry: NotifierEntry };\n","// Low-level file I/O for the notifier. Reads use node:fs directly;\n// writes go through an injected atomic-JSON writer (the host owns the\n// rename-based atomic write so it stays single-sourced with its other\n// writers). Kept separate from `engine.ts` so the path can be\n// overridden in tests without monkey-patching.\n\nimport { hasStringProp, isErrorWithCode, isRecord, isUnknownArray } from \"@mulmoclaude/common\";\nimport { promises as fsPromises } from \"node:fs\";\nimport {\n NOTIFIER_LIFECYCLES,\n NOTIFIER_SEVERITIES,\n type NotifierEntry,\n type NotifierFile,\n type NotifierHistoryEntry,\n type NotifierHistoryFile,\n} from \"./types.js\";\n\n/** Injected atomic JSON writer — the host's `writeJsonAtomic`. */\nexport type WriteJson = (filePath: string, data: unknown) => Promise<void>;\n\nconst TERMINAL_TYPES = [\"cleared\", \"cancelled\"] as const;\n\nfunction isOneOf<T extends string>(allowed: readonly T[], value: unknown): value is T {\n return allowed.some((candidate) => candidate === value);\n}\n\nfunction isOptionalString(value: unknown): boolean {\n return value === undefined || typeof value === \"string\";\n}\n\n// Checks every field `NotifierEntry` declares — `pluginData` is `unknown`,\n// so nothing is left unverified. A partial check would be an assertion\n// wearing a predicate's clothes.\nfunction isNotifierEntry(value: unknown): value is NotifierEntry {\n return (\n hasStringProp(value, \"id\") &&\n hasStringProp(value, \"pluginPkg\") &&\n hasStringProp(value, \"title\") &&\n hasStringProp(value, \"createdAt\") &&\n isOneOf(NOTIFIER_SEVERITIES, value.severity) &&\n (value.lifecycle === undefined || isOneOf(NOTIFIER_LIFECYCLES, value.lifecycle)) &&\n isOptionalString(value.body) &&\n isOptionalString(value.navigateTarget)\n );\n}\n\nfunction isNotifierHistoryEntry(value: unknown): value is NotifierHistoryEntry {\n return isNotifierEntry(value) && hasStringProp(value, \"terminalAt\") && isOneOf(TERMINAL_TYPES, value.terminalType);\n}\n\n// `isRecord` rejects `null` and arrays, so `{ entries: [] }` and\n// `{ entries: null }` stay malformed instead of reading as \"no entries\" —\n// downstream `engine.get` / `list*` mutations assume a plain object.\nfunction isNotifierFile(value: unknown): value is NotifierFile {\n return isRecord(value) && isRecord(value.entries) && Object.values(value.entries).every(isNotifierEntry);\n}\n\nfunction isNotifierHistoryFile(value: unknown): value is NotifierHistoryFile {\n return isRecord(value) && isUnknownArray(value.entries) && value.entries.every(isNotifierHistoryEntry);\n}\n\n/** Parsed JSON, or `undefined` when the file doesn't exist yet (first\n * ever call on a fresh workspace) — unambiguous as a sentinel because\n * no JSON document parses to `undefined`. */\nasync function readJsonIfPresent(filePath: string): Promise<unknown> {\n try {\n return JSON.parse(await fsPromises.readFile(filePath, \"utf-8\"));\n } catch (err) {\n if (isErrorWithCode(err) && err.code === \"ENOENT\") return undefined;\n throw err;\n }\n}\n\n/** Read the active-entries file. Returns an empty store when the file\n * doesn't exist yet. Any other read or parse failure throws — the\n * caller has to decide whether to surface or recover, since silently\n * treating \"malformed file\" as \"no entries\" would lose data. A rejected\n * file is never rewritten, so it stays on disk intact for repair. */\nexport async function loadActive(filePath: string): Promise<NotifierFile> {\n const parsed = await readJsonIfPresent(filePath);\n if (parsed === undefined) return { entries: {} };\n if (!isNotifierFile(parsed)) throw new Error(`notifier: malformed active.json at ${filePath}`);\n return parsed;\n}\n\n/** Write the active-entries file via the injected atomic writer so a\n * half-written file is never visible to readers. The caller serialises\n * writes (engine.ts queues mutations) — this function makes no\n * concurrency guarantees of its own. */\nexport async function saveActive(writeJson: WriteJson, filePath: string, state: NotifierFile): Promise<void> {\n await writeJson(filePath, state);\n}\n\n/** Read the history file. Empty array on first run. Same parse-error\n * policy as `loadActive`. */\nexport async function loadHistory(filePath: string): Promise<NotifierHistoryFile> {\n const parsed = await readJsonIfPresent(filePath);\n if (parsed === undefined) return { entries: [] };\n if (!isNotifierHistoryFile(parsed)) throw new Error(`notifier: malformed history.json at ${filePath}`);\n return parsed;\n}\n\nexport async function saveHistory(writeJson: WriteJson, filePath: string, state: NotifierHistoryFile): Promise<void> {\n await writeJson(filePath, state);\n}\n","// Publish-input validation — pure, dependency-free. Shared by\n// `engine.publish` (throws on error) and the host's HTTP route\n// (returns 400 on error). Single source of truth so plugin-runtime\n// callers and HTTP callers can't drift.\n\nimport type { PublishInput } from \"./types.js\";\n\n/** Hard caps on publish-input fields. The engine reads each entry on\n * every list/get call (no in-memory cache), so unbounded fields hurt\n * every reader. Caps chosen to be generous for legitimate UX copy\n * while bounding active.json growth: a notification fundamentally is\n * a short blurb, not a document. */\nexport const NOTIFIER_LIMITS = {\n titleMax: 200,\n bodyMax: 4000,\n navigateTargetMax: 1000,\n pluginDataMaxBytes: 16 * 1024,\n} as const;\n\nfunction validateTitle(title: string): string | null {\n if (typeof title !== \"string\" || title.length === 0) return \"title must be a non-empty string\";\n if (title.length > NOTIFIER_LIMITS.titleMax) return `title exceeds max length of ${NOTIFIER_LIMITS.titleMax} chars`;\n return null;\n}\n\nfunction validateBody(body: string | undefined): string | null {\n if (body === undefined) return null;\n if (body.length > NOTIFIER_LIMITS.bodyMax) return `body exceeds max length of ${NOTIFIER_LIMITS.bodyMax} chars`;\n return null;\n}\n\nfunction validateNavigateTarget(target: string | undefined): string | null {\n if (target === undefined) return null;\n if (target.length === 0) return \"navigateTarget must be a non-empty relative path when set\";\n if (target.length > NOTIFIER_LIMITS.navigateTargetMax) {\n return `navigateTarget exceeds max length of ${NOTIFIER_LIMITS.navigateTargetMax} chars`;\n }\n // Must be a same-origin relative path. Reject schemes\n // (`javascript:`, `https://...`) and scheme-relative URLs\n // (`//evil.com/...`, which an `<a href>` would resolve to the\n // attacker's origin). One leading \"/\" only.\n if (!target.startsWith(\"/\") || target.startsWith(\"//\")) {\n return \"navigateTarget must be a relative path beginning with a single '/' (no scheme, no '//')\";\n }\n return null;\n}\n\nfunction validatePluginData(pluginData: unknown): string | null {\n if (pluginData === undefined) return null;\n let serialized: string | undefined;\n try {\n serialized = JSON.stringify(pluginData);\n } catch (err) {\n return `pluginData is not JSON-serialisable: ${String(err)}`;\n }\n // `JSON.stringify` returns `undefined` for non-serialisable roots\n // (e.g. a bare function or symbol). Treat that as a serialisation\n // failure so it doesn't slip through as an empty-string size.\n if (typeof serialized !== \"string\") return \"pluginData is not JSON-serialisable\";\n if (serialized.length > NOTIFIER_LIMITS.pluginDataMaxBytes) {\n return `pluginData JSON exceeds ${NOTIFIER_LIMITS.pluginDataMaxBytes} bytes`;\n }\n return null;\n}\n\nfunction validateActionCoherence(input: PublishInput): string | null {\n if (input.lifecycle !== \"action\") return null;\n if (input.severity === \"info\") {\n return \"action lifecycle is incompatible with info severity (use fyi for low-priority pings)\";\n }\n if (typeof input.navigateTarget !== \"string\" || input.navigateTarget.length === 0) {\n return \"action lifecycle requires a non-empty navigateTarget\";\n }\n return null;\n}\n\n/** Validate a `PublishInput`. Returns `null` if OK, or a\n * human-readable error string. Order matters — shape/size errors are\n * reported before lifecycle/severity coherence errors so the message\n * the caller sees points at the most fundamental problem first. */\nexport function validatePublishInput(input: PublishInput): string | null {\n return (\n validateTitle(input.title) ??\n validateBody(input.body) ??\n validateNavigateTarget(input.navigateTarget) ??\n validatePluginData(input.pluginData) ??\n validateActionCoherence(input)\n );\n}\n","// Notifier engine — single-process, two-file (active + history),\n// single-channel. Host-agnostic: file paths, the atomic JSON writer,\n// the pub-sub event sink, and the logger are all injected via\n// `configureNotifier` + `setNotifierFilePaths` so MulmoClaude and\n// MulmoTerminal share one notification engine over their own\n// workspaces and pub-sub fabrics.\n//\n// API surface: publish / clear / cancel / get / listFor / listAll /\n// listHistory (+ plugin-scoped variants). Mutations queue through a\n// writing-flag + waiter-queue coordinator so concurrent callers can't\n// race on the atomic write's rename. Reads bypass the queue (rename\n// atomicity makes half-reads impossible) and trade strict\n// linearisability for simpler code: the contract is \"after\n// `await publish(x)` resolves, subsequent reads see x\" — which holds\n// because `publish` awaits the persist before returning.\n//\n// `clear` / `cancel` push to history *before* removing from active.\n// History persistence is best-effort: if it fails, the active write\n// still wins and the failure is logged. Active is the source of\n// truth; history is an audit aid.\n\nimport type { MinimalLogger } from \"@mulmoclaude/common\";\nimport { randomUUID } from \"node:crypto\";\nimport { loadActive, loadHistory, saveActive, saveHistory, type WriteJson } from \"./store.js\";\nimport { validatePublishInput } from \"./validate.js\";\nimport {\n HISTORY_CAP,\n type NotifierEntry,\n type NotifierEvent,\n type NotifierFile,\n type NotifierHistoryEntry,\n type NotifierSeverity,\n type PublishInput,\n} from \"./types.js\";\n\nexport { NOTIFIER_LIMITS, validatePublishInput } from \"./validate.js\";\n\n// ── Dependency injection ──────────────────────────────────────────\n\n/** Minimal logger the engine needs. The host passes its structured\n * logger; absent one, failures are swallowed (the engine never throws\n * on a fan-out/persist-best-effort path). */\nexport type NotifierLogger = Pick<MinimalLogger, \"warn\" | \"error\">;\n\nexport interface NotifierConfig {\n /** Atomic JSON writer (the host's `writeJsonAtomic`). */\n writeJson: WriteJson;\n /** Fan-out sink — the host binds this to `pubsub.publish(channel, event)`. */\n publishEvent: (event: NotifierEvent) => void;\n /** Optional logger. */\n log?: NotifierLogger;\n}\n\nconst NOOP_LOG: NotifierLogger = { warn: () => {}, error: () => {} };\n\nlet config: NotifierConfig | null = null;\nlet activeFilePath = \"\";\nlet historyFilePath = \"\";\n\nfunction logger(): NotifierLogger {\n return config?.log ?? NOOP_LOG;\n}\n\n/** Wire the engine's I/O deps. Call once at startup, before the first\n * mutation. Does NOT set file paths — those are set independently via\n * `setNotifierFilePaths` so a host can bind production paths at module\n * load and a test can override them without re-supplying the deps. */\nexport function configureNotifier(injected: NotifierConfig): void {\n config = injected;\n}\n\n// ── In-process event listeners ────────────────────────────────────\n//\n// Separate from the socket.io pubsub so server-side adapters (macOS\n// push, future Encore) can react to state changes without going\n// through a websocket round-trip. The host's pubsub is fan-out-only\n// with no server-side subscribe, so this listener registry is the\n// in-process equivalent. Listeners run synchronously inside `emit`,\n// before the pubsub fan-out.\n\ntype NotifierEventListener = (event: NotifierEvent) => void;\nconst listeners: NotifierEventListener[] = [];\n\n/** Register an in-process listener for engine events. Returns an\n * unsubscribe function the caller can use during teardown. */\nexport function onEvent(listener: NotifierEventListener): () => void {\n listeners.push(listener);\n return () => {\n const idx = listeners.indexOf(listener);\n if (idx >= 0) listeners.splice(idx, 1);\n };\n}\n\nfunction emit(event: NotifierEvent): void {\n // In-process fan-out first. Each listener is wrapped: a throwing\n // listener must not poison the rest, and must not propagate out of\n // `processBatch` and strand the still-unsettled waiters (their\n // resolve/reject is called *after* this emit loop). Fan-out is\n // best-effort by contract — losing one subscriber must not lose\n // the write that already committed.\n for (const listener of listeners) {\n try {\n listener(event);\n } catch (err) {\n logger().error(\"in-process listener failed\", { type: event.type, error: String(err) });\n }\n }\n if (!config) {\n logger().warn(\"emit before init\", { type: event.type });\n return;\n }\n try {\n config.publishEvent(event);\n } catch (err) {\n logger().error(\"emit failed\", { type: event.type, error: String(err) });\n }\n}\n\n// ── Write coordinator ─────────────────────────────────────────────\n\n/** A mutation function applied to the in-memory state object during\n * drain. Returns either:\n *\n * - `null` — no state change (e.g., `clear` on an unknown id).\n * The drainer skips the disk write and the emit if every\n * mutation in a batch returned `null`.\n * - `{ event, historyEntry? }` — state changed. The drainer emits\n * the event after the active write succeeds, and prepends\n * `historyEntry` to history (best-effort) when present.\n *\n * Mutations MUST NOT modify state when returning `null`. Violating\n * this invariant produces a write skip with stale on-disk state. */\ntype MutationOutcome = { event: NotifierEvent; historyEntry?: NotifierHistoryEntry } | null;\ntype Mutation = (state: NotifierFile) => MutationOutcome;\n\ninterface Waiter {\n mutate: Mutation;\n resolve: () => void;\n reject: (err: unknown) => void;\n}\n\ntype MutationResult = { ok: true; outcome: MutationOutcome } | { ok: false; error: unknown };\n\nlet writing = false;\nlet waiters: Waiter[] = [];\n\n/** Point the engine at its active/history files. Resets the write\n * queue, so callers must not have in-flight mutations. The host calls\n * this once with the workspace paths; tests call it per-case with temp\n * files. */\nexport function setNotifierFilePaths(paths: { active: string; history: string }): void {\n activeFilePath = paths.active;\n historyFilePath = paths.history;\n writing = false;\n waiters = [];\n}\n\n/** Test-only: clear config + queue so each suite starts clean. */\nexport function resetNotifier(): void {\n config = null;\n activeFilePath = \"\";\n historyFilePath = \"\";\n writing = false;\n waiters = [];\n listeners.length = 0;\n}\n\nfunction requireWriteJson(): WriteJson {\n if (!config) throw new Error(\"notifier: configureNotifier() not called\");\n return config.writeJson;\n}\n\n/** A waiter carried alongside the outcome of its own mutation, so settling can\n * never pair a result with the wrong caller's promise. */\ninterface SettledMutation {\n waiter: Waiter;\n result: MutationResult;\n}\n\nfunction applyBatchMutations(batch: Waiter[], state: NotifierFile): SettledMutation[] {\n return batch.map((waiter) => {\n try {\n return { waiter, result: { ok: true, outcome: waiter.mutate(state) } };\n } catch (err) {\n return { waiter, result: { ok: false, error: err } };\n }\n });\n}\n\nfunction collectEvents(settled: SettledMutation[]): NotifierEvent[] {\n const events: NotifierEvent[] = [];\n for (const { result } of settled) {\n if (result.ok && result.outcome !== null) events.push(result.outcome.event);\n }\n return events;\n}\n\nfunction collectHistoryEntries(settled: SettledMutation[]): NotifierHistoryEntry[] {\n const entries: NotifierHistoryEntry[] = [];\n for (const { result } of settled) {\n if (result.ok && result.outcome !== null && result.outcome.historyEntry) {\n entries.push(result.outcome.historyEntry);\n }\n }\n return entries;\n}\n\nfunction settleBatch(settled: SettledMutation[]): void {\n // Resolves come AFTER any emits so subscribers see the event\n // before the caller's `await` returns.\n for (const { waiter, result } of settled) {\n if (result.ok) waiter.resolve();\n else waiter.reject(result.error);\n }\n}\n\nfunction rejectBatch(batch: Waiter[], err: unknown): void {\n for (const waiter of batch) waiter.reject(err);\n}\n\nasync function persistHistory(newEntries: NotifierHistoryEntry[]): Promise<void> {\n const existing = await loadHistory(historyFilePath);\n // Newest-first ordering: a batch contains terminations in arrival\n // order; we want the last one to land at index 0 of history.\n const merged = [...newEntries.slice().reverse(), ...existing.entries].slice(0, HISTORY_CAP);\n await saveHistory(requireWriteJson(), historyFilePath, { entries: merged });\n}\n\nasync function processBatch(batch: Waiter[]): Promise<void> {\n let state: NotifierFile;\n try {\n state = await loadActive(activeFilePath);\n } catch (err) {\n logger().error(\"load failed\", { error: String(err) });\n rejectBatch(batch, err);\n return;\n }\n const settled = applyBatchMutations(batch, state);\n const events = collectEvents(settled);\n const historyEntries = collectHistoryEntries(settled);\n\n if (events.length > 0) {\n try {\n await saveActive(requireWriteJson(), activeFilePath, state);\n } catch (err) {\n logger().error(\"active write failed\", { error: String(err) });\n rejectBatch(batch, err);\n return;\n }\n if (historyEntries.length > 0) {\n // Best-effort: active is the source of truth, history is an\n // audit aid. A failed history write is logged but doesn't\n // unwind the active commit.\n try {\n await persistHistory(historyEntries);\n } catch (err) {\n logger().error(\"history write failed\", { error: String(err) });\n }\n }\n for (const event of events) emit(event);\n }\n settleBatch(settled);\n}\n\nasync function drain(): Promise<void> {\n writing = true;\n try {\n while (waiters.length > 0) {\n const batch = waiters;\n waiters = [];\n await processBatch(batch);\n }\n } finally {\n writing = false;\n }\n}\n\nfunction enqueue(mutate: Mutation): Promise<void> {\n return new Promise<void>((resolve, reject) => {\n waiters.push({ mutate, resolve, reject });\n if (!writing) void drain();\n });\n}\n\nfunction removeEntry(state: NotifierFile, entryId: string): NotifierFile[\"entries\"] {\n // Object-rest excludes the key without invoking `delete`.\n const { [entryId]: __removed, ...remaining } = state.entries;\n return remaining;\n}\n\nfunction buildHistoryEntry(entry: NotifierEntry, terminalType: \"cleared\" | \"cancelled\"): NotifierHistoryEntry {\n return { ...entry, terminalType, terminalAt: new Date().toISOString() };\n}\n\n// ── Public API ────────────────────────────────────────────────────\n\n/** The optional half of a publish input, with unset keys left OUT rather than\n * written as `undefined` — an entry is persisted and re-read through JSON,\n * where an unset field carries no key at all. */\nfunction optionalPublishFields<TPluginData>(input: PublishInput<TPluginData>): Partial<NotifierEntry<TPluginData>> {\n return {\n ...(input.lifecycle !== undefined ? { lifecycle: input.lifecycle } : {}),\n ...(input.body !== undefined ? { body: input.body } : {}),\n ...(input.navigateTarget !== undefined ? { navigateTarget: input.navigateTarget } : {}),\n ...(input.pluginData !== undefined ? { pluginData: input.pluginData } : {}),\n };\n}\n\nexport async function publish<TPluginData = unknown>(input: PublishInput<TPluginData>): Promise<{ id: string }> {\n // Validate at the engine boundary so plugin-runtime callers and\n // HTTP callers hit the same wall.\n const validationError = validatePublishInput(input);\n if (validationError) {\n throw new Error(`notifier.publish: ${validationError}`);\n }\n const entryId = randomUUID();\n const entry: NotifierEntry<TPluginData> = {\n id: entryId,\n pluginPkg: input.pluginPkg,\n severity: input.severity,\n title: input.title,\n createdAt: new Date().toISOString(),\n ...optionalPublishFields(input),\n };\n await enqueue((state) => {\n state.entries[entryId] = entry;\n return { event: { type: \"published\", entry } };\n });\n return { id: entryId };\n}\n\n/** Remove an active entry and record its terminal history. `clear`\n * (user dismissed) and `cancel` (producer withdrew) differ only in the\n * emitted event / history reason. No-op when the id is unknown. */\nasync function terminateEntry(entryId: string, reason: \"cleared\" | \"cancelled\"): Promise<void> {\n await enqueue((state) => {\n const entry = state.entries[entryId];\n if (!entry) return null;\n state.entries = removeEntry(state, entryId);\n return {\n event: reason === \"cleared\" ? { type: \"cleared\", id: entryId } : { type: \"cancelled\", id: entryId },\n historyEntry: buildHistoryEntry(entry, reason),\n };\n });\n}\n\nexport async function clear(entryId: string): Promise<void> {\n await terminateEntry(entryId, \"cleared\");\n}\n\nexport async function cancel(entryId: string): Promise<void> {\n await terminateEntry(entryId, \"cancelled\");\n}\n\n/** In-place update for an active entry. Only the fields present on\n * `patch` are rewritten; `id`, `pluginPkg`, `lifecycle`, and\n * `createdAt` stay fixed. Emits a single `\"updated\"` event with the\n * post-mutation entry — no history record is written because the\n * entry is still active, just with refreshed content.\n *\n * No-ops (no throw) when the id is unknown, the entry belongs to a\n * different plugin, or the merged shape would violate\n * `validatePublishInput`. The silent skip matches `clearForPlugin`'s\n * isolation semantics; validation failures are logged for diagnosis. */\nexport async function updateForPlugin<TPluginData = unknown>(\n pluginPkg: string,\n entryId: string,\n patch: {\n severity?: NotifierSeverity;\n title?: string;\n body?: string;\n navigateTarget?: string;\n pluginData?: TPluginData;\n },\n): Promise<void> {\n await enqueue((state) => {\n const entry = state.entries[entryId];\n if (!entry) return null;\n if (entry.pluginPkg !== pluginPkg) return null;\n const next: NotifierEntry = {\n ...entry,\n ...(patch.severity !== undefined ? { severity: patch.severity } : {}),\n ...(patch.title !== undefined ? { title: patch.title } : {}),\n ...(patch.body !== undefined ? { body: patch.body } : {}),\n ...(patch.navigateTarget !== undefined ? { navigateTarget: patch.navigateTarget } : {}),\n ...(patch.pluginData !== undefined ? { pluginData: patch.pluginData } : {}),\n };\n // Re-validate the merged shape so an update can't degrade the\n // entry below publish-time invariants.\n const validationError = validatePublishInput({\n pluginPkg: next.pluginPkg,\n severity: next.severity,\n title: next.title,\n body: next.body,\n lifecycle: next.lifecycle,\n navigateTarget: next.navigateTarget,\n pluginData: next.pluginData,\n });\n if (validationError) {\n logger().warn(\"update rejected by validation\", { entryId, pluginPkg, error: validationError });\n return null;\n }\n state.entries[entryId] = next;\n return { event: { type: \"updated\", entry: next } };\n });\n}\n\n/** Plugin-scoped point lookup. Returns the entry by id, but only if it\n * belongs to the caller's plugin; otherwise undefined. Cross-plugin\n * reads return undefined for isolation — same property as\n * `clearForPlugin` / `updateForPlugin`. */\nexport async function getForPlugin(pluginPkg: string, entryId: string): Promise<NotifierEntry | undefined> {\n const state = await loadActive(activeFilePath);\n const entry = state.entries[entryId];\n if (!entry) return undefined;\n if (entry.pluginPkg !== pluginPkg) return undefined;\n return entry;\n}\n\n/** Plugin-scoped clear. Same as `clear` but no-ops if the entry's\n * `pluginPkg` doesn't match the caller's, so a plugin can't dismiss\n * another plugin's notification by guessing or scraping its id. */\nexport async function clearForPlugin(pluginPkg: string, entryId: string): Promise<void> {\n await enqueue((state) => {\n const entry = state.entries[entryId];\n if (!entry) return null;\n if (entry.pluginPkg !== pluginPkg) return null;\n state.entries = removeEntry(state, entryId);\n return {\n event: { type: \"cleared\", id: entryId },\n historyEntry: buildHistoryEntry(entry, \"cleared\"),\n };\n });\n}\n\nexport async function get(entryId: string): Promise<NotifierEntry | undefined> {\n const state = await loadActive(activeFilePath);\n return state.entries[entryId];\n}\n\nexport async function listFor(pluginPkg: string): Promise<NotifierEntry[]> {\n const state = await loadActive(activeFilePath);\n return Object.values(state.entries).filter((entry) => entry.pluginPkg === pluginPkg);\n}\n\nexport async function listAll(): Promise<NotifierEntry[]> {\n const state = await loadActive(activeFilePath);\n return Object.values(state.entries);\n}\n\nexport async function listHistory(): Promise<NotifierHistoryEntry[]> {\n const state = await loadHistory(historyFilePath);\n return state.entries;\n}\n"],"mappings":"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"}
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"notifier-DhnwR82U.js","names":[],"sources":["../src/notifier/types.ts","../src/notifier/store.ts","../src/notifier/validate.ts","../src/notifier/engine.ts"],"sourcesContent":["// Notifier value types. Kept dependency-free (no node, no fs, no\n// pubsub) so the host's API-route layer and any future browser\n// consumer can validate inbound payloads against the same enum\n// constants the engine accepts without pulling in the engine's I/O.\n\n/** Two notification shapes, distinguished by who fires the close call:\n *\n * `fyi` — informational. The host (bell panel) clears it when the\n * user dismisses the row. No deep-link target.\n * `action` — pending obligation. The plugin clears it when the\n * underlying domain state changes (the user paid the tax,\n * viewed the digest, etc.). The bell row navigates to\n * `navigateTarget` on click.\n *\n * The engine reads `lifecycle` only to enforce two publish-time rules\n * (everything downstream — pubsub fan-out, persistence, history — is\n * lifecycle-blind):\n *\n * 1. `action` requires a non-empty `navigateTarget`. Without one,\n * clicking the row does nothing and the entry is a degraded fyi.\n * 2. `action` cannot use `info` severity. A low-priority obligation\n * is incoherent — fyi if it's a ping, `nudge`/`urgent` if it's a\n * real obligation worth a landing page.\n *\n * Both rules are mirrored in the HTTP layer so plugin-runtime callers\n * and HTTP callers hit the same wall. */\nexport const NOTIFIER_LIFECYCLES = [\"fyi\", \"action\"] as const;\nexport type NotifierLifecycle = (typeof NOTIFIER_LIFECYCLES)[number];\n\n/** Severity drives badge color (gray / amber / red, worst-wins) and\n * in a future iteration channel routing. Mostly stored verbatim by\n * the engine; the one engine-visible interaction is the rule that\n * `action` lifecycle cannot pair with `info` severity (see\n * `NotifierLifecycle` above). */\nexport const NOTIFIER_SEVERITIES = [\"info\", \"nudge\", \"urgent\"] as const;\nexport type NotifierSeverity = (typeof NOTIFIER_SEVERITIES)[number];\n\nexport interface NotifierEntry<TPluginData = unknown> {\n /** Engine-assigned UUID. Generated synchronously inside `publish()`\n * so the caller can use it before persistence completes. */\n id: string;\n /** Plugin namespace (e.g. `\"encore\"`, `\"debug__system\"`). The\n * engine never inspects it — used only for `listFor()` filtering\n * and as a UI grouping key. */\n pluginPkg: string;\n severity: NotifierSeverity;\n lifecycle?: NotifierLifecycle;\n title: string;\n body?: string;\n /** Optional in-app deep-link target (relative URL). The bell popup\n * routes here on row click, with `¬ificationId=<id>` appended\n * so the landing page can identify which entry to clear. The\n * engine doesn't read this — it's a UI hint stored on the entry. */\n navigateTarget?: string;\n /** Opaque to the engine. Round-trips through JSON unchanged; only\n * the originating plugin's UI knows the shape. */\n pluginData?: TPluginData;\n /** ISO-8601 timestamp set at `publish()` time. */\n createdAt: string;\n}\n\n/** A history entry — a `NotifierEntry` after it has been cleared or\n * cancelled, with the terminal type and timestamp recorded. The\n * bell popup's \"History\" section renders these read-only. */\nexport interface NotifierHistoryEntry<TPluginData = unknown> extends NotifierEntry<TPluginData> {\n terminalType: \"cleared\" | \"cancelled\";\n terminalAt: string;\n}\n\n/** Caller-supplied input for `publish()`. The engine fills in `id`\n * and `createdAt`; everything else flows through verbatim.\n *\n * Two publish-time rules apply to `action` lifecycle (see\n * `NotifierLifecycle`):\n *\n * - `navigateTarget` MUST be a non-empty string.\n * - `severity` MUST NOT be `\"info\"`.\n *\n * Violations cause `publish()` to throw. Currently expressed as\n * runtime validation rather than a discriminated-union type, so the\n * fields below are all individually optional / loose at the\n * type-level. */\nexport interface PublishInput<TPluginData = unknown> {\n pluginPkg: string;\n severity: NotifierSeverity;\n title: string;\n body?: string | undefined;\n lifecycle?: NotifierLifecycle | undefined;\n navigateTarget?: string | undefined;\n pluginData?: TPluginData | undefined;\n}\n\n/** On-disk shape of `~/mulmoclaude/data/notifier/active.json`. Holds\n * only entries that haven't been cleared or cancelled — the file is\n * a snapshot, not an event log. */\nexport interface NotifierFile {\n entries: Record<string, NotifierEntry>;\n}\n\n/** On-disk shape of `~/mulmoclaude/data/notifier/history.json`. Array\n * of terminated entries newest-first, capped at `HISTORY_CAP` with\n * FIFO eviction (push at index 0, slice from the tail). */\nexport interface NotifierHistoryFile {\n entries: NotifierHistoryEntry[];\n}\n\n/** History size cap. The bell popup's History section renders this\n * many entries; older ones fall off when new terminations land. */\nexport const HISTORY_CAP = 50;\n\n/** Pub-sub event published on the host's notifier channel after every\n * successful state change. Discriminated union — subscribers switch\n * on `type` to keep TypeScript narrowing the rest of the payload.\n *\n * `updated` carries the post-mutation entry — the receiver swaps\n * the matching `id` in their local active set. Reserved for in-\n * place edits via `updateForPlugin`; no history record is written\n * because the entry is still active, just with refreshed content. */\nexport type NotifierEvent =\n { type: \"published\"; entry: NotifierEntry } | { type: \"cleared\"; id: string } | { type: \"cancelled\"; id: string } | { type: \"updated\"; entry: NotifierEntry };\n","// Low-level file I/O for the notifier. Reads use node:fs directly;\n// writes go through an injected atomic-JSON writer (the host owns the\n// rename-based atomic write so it stays single-sourced with its other\n// writers). Kept separate from `engine.ts` so the path can be\n// overridden in tests without monkey-patching.\n\nimport { hasStringProp, isErrorWithCode, isRecord, isUnknownArray } from \"@mulmoclaude/common\";\nimport { promises as fsPromises } from \"node:fs\";\nimport {\n NOTIFIER_LIFECYCLES,\n NOTIFIER_SEVERITIES,\n type NotifierEntry,\n type NotifierFile,\n type NotifierHistoryEntry,\n type NotifierHistoryFile,\n} from \"./types.js\";\n\n/** Injected atomic JSON writer — the host's `writeJsonAtomic`. */\nexport type WriteJson = (filePath: string, data: unknown) => Promise<void>;\n\nconst TERMINAL_TYPES = [\"cleared\", \"cancelled\"] as const;\n\nfunction isOneOf<T extends string>(allowed: readonly T[], value: unknown): value is T {\n return allowed.some((candidate) => candidate === value);\n}\n\nfunction isOptionalString(value: unknown): boolean {\n return value === undefined || typeof value === \"string\";\n}\n\n// Checks every field `NotifierEntry` declares — `pluginData` is `unknown`,\n// so nothing is left unverified. A partial check would be an assertion\n// wearing a predicate's clothes.\nfunction isNotifierEntry(value: unknown): value is NotifierEntry {\n return (\n hasStringProp(value, \"id\") &&\n hasStringProp(value, \"pluginPkg\") &&\n hasStringProp(value, \"title\") &&\n hasStringProp(value, \"createdAt\") &&\n isOneOf(NOTIFIER_SEVERITIES, value.severity) &&\n (value.lifecycle === undefined || isOneOf(NOTIFIER_LIFECYCLES, value.lifecycle)) &&\n isOptionalString(value.body) &&\n isOptionalString(value.navigateTarget)\n );\n}\n\nfunction isNotifierHistoryEntry(value: unknown): value is NotifierHistoryEntry {\n return isNotifierEntry(value) && hasStringProp(value, \"terminalAt\") && isOneOf(TERMINAL_TYPES, value.terminalType);\n}\n\n// `isRecord` rejects `null` and arrays, so `{ entries: [] }` and\n// `{ entries: null }` stay malformed instead of reading as \"no entries\" —\n// downstream `engine.get` / `list*` mutations assume a plain object.\nfunction isNotifierFile(value: unknown): value is NotifierFile {\n return isRecord(value) && isRecord(value.entries) && Object.values(value.entries).every(isNotifierEntry);\n}\n\nfunction isNotifierHistoryFile(value: unknown): value is NotifierHistoryFile {\n return isRecord(value) && isUnknownArray(value.entries) && value.entries.every(isNotifierHistoryEntry);\n}\n\n/** Parsed JSON, or `undefined` when the file doesn't exist yet (first\n * ever call on a fresh workspace) — unambiguous as a sentinel because\n * no JSON document parses to `undefined`. */\nasync function readJsonIfPresent(filePath: string): Promise<unknown> {\n try {\n return JSON.parse(await fsPromises.readFile(filePath, \"utf-8\"));\n } catch (err) {\n if (isErrorWithCode(err) && err.code === \"ENOENT\") return undefined;\n throw err;\n }\n}\n\n/** Read the active-entries file. Returns an empty store when the file\n * doesn't exist yet. Any other read or parse failure throws — the\n * caller has to decide whether to surface or recover, since silently\n * treating \"malformed file\" as \"no entries\" would lose data. A rejected\n * file is never rewritten, so it stays on disk intact for repair. */\nexport async function loadActive(filePath: string): Promise<NotifierFile> {\n const parsed = await readJsonIfPresent(filePath);\n if (parsed === undefined) return { entries: {} };\n if (!isNotifierFile(parsed)) throw new Error(`notifier: malformed active.json at ${filePath}`);\n return parsed;\n}\n\n/** Write the active-entries file via the injected atomic writer so a\n * half-written file is never visible to readers. The caller serialises\n * writes (engine.ts queues mutations) — this function makes no\n * concurrency guarantees of its own. */\nexport async function saveActive(writeJson: WriteJson, filePath: string, state: NotifierFile): Promise<void> {\n await writeJson(filePath, state);\n}\n\n/** Read the history file. Empty array on first run. Same parse-error\n * policy as `loadActive`. */\nexport async function loadHistory(filePath: string): Promise<NotifierHistoryFile> {\n const parsed = await readJsonIfPresent(filePath);\n if (parsed === undefined) return { entries: [] };\n if (!isNotifierHistoryFile(parsed)) throw new Error(`notifier: malformed history.json at ${filePath}`);\n return parsed;\n}\n\nexport async function saveHistory(writeJson: WriteJson, filePath: string, state: NotifierHistoryFile): Promise<void> {\n await writeJson(filePath, state);\n}\n","// Publish-input validation — pure, dependency-free. Shared by\n// `engine.publish` (throws on error) and the host's HTTP route\n// (returns 400 on error). Single source of truth so plugin-runtime\n// callers and HTTP callers can't drift.\n\nimport type { PublishInput } from \"./types.js\";\n\n/** Hard caps on publish-input fields. The engine reads each entry on\n * every list/get call (no in-memory cache), so unbounded fields hurt\n * every reader. Caps chosen to be generous for legitimate UX copy\n * while bounding active.json growth: a notification fundamentally is\n * a short blurb, not a document. */\nexport const NOTIFIER_LIMITS = {\n titleMax: 200,\n bodyMax: 4000,\n navigateTargetMax: 1000,\n pluginDataMaxBytes: 16 * 1024,\n} as const;\n\nfunction validateTitle(title: string): string | null {\n if (typeof title !== \"string\" || title.length === 0) return \"title must be a non-empty string\";\n if (title.length > NOTIFIER_LIMITS.titleMax) return `title exceeds max length of ${NOTIFIER_LIMITS.titleMax} chars`;\n return null;\n}\n\nfunction validateBody(body: string | undefined): string | null {\n if (body === undefined) return null;\n if (body.length > NOTIFIER_LIMITS.bodyMax) return `body exceeds max length of ${NOTIFIER_LIMITS.bodyMax} chars`;\n return null;\n}\n\nfunction validateNavigateTarget(target: string | undefined): string | null {\n if (target === undefined) return null;\n if (target.length === 0) return \"navigateTarget must be a non-empty relative path when set\";\n if (target.length > NOTIFIER_LIMITS.navigateTargetMax) {\n return `navigateTarget exceeds max length of ${NOTIFIER_LIMITS.navigateTargetMax} chars`;\n }\n // Must be a same-origin relative path. Reject schemes\n // (`javascript:`, `https://...`) and scheme-relative URLs\n // (`//evil.com/...`, which an `<a href>` would resolve to the\n // attacker's origin). One leading \"/\" only.\n if (!target.startsWith(\"/\") || target.startsWith(\"//\")) {\n return \"navigateTarget must be a relative path beginning with a single '/' (no scheme, no '//')\";\n }\n return null;\n}\n\nfunction validatePluginData(pluginData: unknown): string | null {\n if (pluginData === undefined) return null;\n let serialized: string | undefined;\n try {\n serialized = JSON.stringify(pluginData);\n } catch (err) {\n return `pluginData is not JSON-serialisable: ${String(err)}`;\n }\n // `JSON.stringify` returns `undefined` for non-serialisable roots\n // (e.g. a bare function or symbol). Treat that as a serialisation\n // failure so it doesn't slip through as an empty-string size.\n if (typeof serialized !== \"string\") return \"pluginData is not JSON-serialisable\";\n if (serialized.length > NOTIFIER_LIMITS.pluginDataMaxBytes) {\n return `pluginData JSON exceeds ${NOTIFIER_LIMITS.pluginDataMaxBytes} bytes`;\n }\n return null;\n}\n\nfunction validateActionCoherence(input: PublishInput): string | null {\n if (input.lifecycle !== \"action\") return null;\n if (input.severity === \"info\") {\n return \"action lifecycle is incompatible with info severity (use fyi for low-priority pings)\";\n }\n if (typeof input.navigateTarget !== \"string\" || input.navigateTarget.length === 0) {\n return \"action lifecycle requires a non-empty navigateTarget\";\n }\n return null;\n}\n\n/** Validate a `PublishInput`. Returns `null` if OK, or a\n * human-readable error string. Order matters — shape/size errors are\n * reported before lifecycle/severity coherence errors so the message\n * the caller sees points at the most fundamental problem first. */\nexport function validatePublishInput(input: PublishInput): string | null {\n return (\n validateTitle(input.title) ??\n validateBody(input.body) ??\n validateNavigateTarget(input.navigateTarget) ??\n validatePluginData(input.pluginData) ??\n validateActionCoherence(input)\n );\n}\n","// Notifier engine — single-process, two-file (active + history),\n// single-channel. Host-agnostic: file paths, the atomic JSON writer,\n// the pub-sub event sink, and the logger are all injected via\n// `configureNotifier` + `setNotifierFilePaths` so MulmoClaude and\n// MulmoTerminal share one notification engine over their own\n// workspaces and pub-sub fabrics.\n//\n// API surface: publish / clear / cancel / get / listFor / listAll /\n// listHistory (+ plugin-scoped variants). Mutations queue through a\n// writing-flag + waiter-queue coordinator so concurrent callers can't\n// race on the atomic write's rename. Reads bypass the queue (rename\n// atomicity makes half-reads impossible) and trade strict\n// linearisability for simpler code: the contract is \"after\n// `await publish(x)` resolves, subsequent reads see x\" — which holds\n// because `publish` awaits the persist before returning.\n//\n// `clear` / `cancel` push to history *before* removing from active.\n// History persistence is best-effort: if it fails, the active write\n// still wins and the failure is logged. Active is the source of\n// truth; history is an audit aid.\n\nimport type { MinimalLogger } from \"@mulmoclaude/common\";\nimport { randomUUID } from \"node:crypto\";\nimport { loadActive, loadHistory, saveActive, saveHistory, type WriteJson } from \"./store.js\";\nimport { validatePublishInput } from \"./validate.js\";\nimport {\n HISTORY_CAP,\n type NotifierEntry,\n type NotifierEvent,\n type NotifierFile,\n type NotifierHistoryEntry,\n type NotifierSeverity,\n type PublishInput,\n} from \"./types.js\";\n\nexport { NOTIFIER_LIMITS, validatePublishInput } from \"./validate.js\";\n\n// ── Dependency injection ──────────────────────────────────────────\n\n/** Minimal logger the engine needs. The host passes its structured\n * logger; absent one, failures are swallowed (the engine never throws\n * on a fan-out/persist-best-effort path). */\nexport type NotifierLogger = Pick<MinimalLogger, \"warn\" | \"error\">;\n\nexport interface NotifierConfig {\n /** Atomic JSON writer (the host's `writeJsonAtomic`). */\n writeJson: WriteJson;\n /** Fan-out sink — the host binds this to `pubsub.publish(channel, event)`. */\n publishEvent: (event: NotifierEvent) => void;\n /** Optional logger. */\n log?: NotifierLogger;\n}\n\nconst NOOP_LOG: NotifierLogger = { warn: () => {}, error: () => {} };\n\nlet config: NotifierConfig | null = null;\nlet activeFilePath = \"\";\nlet historyFilePath = \"\";\n\nfunction logger(): NotifierLogger {\n return config?.log ?? NOOP_LOG;\n}\n\n/** Wire the engine's I/O deps. Call once at startup, before the first\n * mutation. Does NOT set file paths — those are set independently via\n * `setNotifierFilePaths` so a host can bind production paths at module\n * load and a test can override them without re-supplying the deps. */\nexport function configureNotifier(injected: NotifierConfig): void {\n config = injected;\n}\n\n// ── In-process event listeners ────────────────────────────────────\n//\n// Separate from the socket.io pubsub so server-side adapters (macOS\n// push, future Encore) can react to state changes without going\n// through a websocket round-trip. The host's pubsub is fan-out-only\n// with no server-side subscribe, so this listener registry is the\n// in-process equivalent. Listeners run synchronously inside `emit`,\n// before the pubsub fan-out.\n\ntype NotifierEventListener = (event: NotifierEvent) => void;\nconst listeners: NotifierEventListener[] = [];\n\n/** Register an in-process listener for engine events. Returns an\n * unsubscribe function the caller can use during teardown. */\nexport function onEvent(listener: NotifierEventListener): () => void {\n listeners.push(listener);\n return () => {\n const idx = listeners.indexOf(listener);\n if (idx >= 0) listeners.splice(idx, 1);\n };\n}\n\nfunction emit(event: NotifierEvent): void {\n // In-process fan-out first. Each listener is wrapped: a throwing\n // listener must not poison the rest, and must not propagate out of\n // `processBatch` and strand the still-unsettled waiters (their\n // resolve/reject is called *after* this emit loop). Fan-out is\n // best-effort by contract — losing one subscriber must not lose\n // the write that already committed.\n for (const listener of listeners) {\n try {\n listener(event);\n } catch (err) {\n logger().error(\"in-process listener failed\", { type: event.type, error: String(err) });\n }\n }\n if (!config) {\n logger().warn(\"emit before init\", { type: event.type });\n return;\n }\n try {\n config.publishEvent(event);\n } catch (err) {\n logger().error(\"emit failed\", { type: event.type, error: String(err) });\n }\n}\n\n// ── Write coordinator ─────────────────────────────────────────────\n\n/** A mutation function applied to the in-memory state object during\n * drain. Returns either:\n *\n * - `null` — no state change (e.g., `clear` on an unknown id).\n * The drainer skips the disk write and the emit if every\n * mutation in a batch returned `null`.\n * - `{ event, historyEntry? }` — state changed. The drainer emits\n * the event after the active write succeeds, and prepends\n * `historyEntry` to history (best-effort) when present.\n *\n * Mutations MUST NOT modify state when returning `null`. Violating\n * this invariant produces a write skip with stale on-disk state. */\ntype MutationOutcome = { event: NotifierEvent; historyEntry?: NotifierHistoryEntry } | null;\ntype Mutation = (state: NotifierFile) => MutationOutcome;\n\ninterface Waiter {\n mutate: Mutation;\n resolve: () => void;\n reject: (err: unknown) => void;\n}\n\ntype MutationResult = { ok: true; outcome: MutationOutcome } | { ok: false; error: unknown };\n\nlet writing = false;\nlet waiters: Waiter[] = [];\n\n/** Point the engine at its active/history files. Resets the write\n * queue, so callers must not have in-flight mutations. The host calls\n * this once with the workspace paths; tests call it per-case with temp\n * files. */\nexport function setNotifierFilePaths(paths: { active: string; history: string }): void {\n activeFilePath = paths.active;\n historyFilePath = paths.history;\n writing = false;\n waiters = [];\n}\n\n/** Test-only: clear config + queue so each suite starts clean. */\nexport function resetNotifier(): void {\n config = null;\n activeFilePath = \"\";\n historyFilePath = \"\";\n writing = false;\n waiters = [];\n listeners.length = 0;\n}\n\nfunction requireWriteJson(): WriteJson {\n if (!config) throw new Error(\"notifier: configureNotifier() not called\");\n return config.writeJson;\n}\n\n/** A waiter carried alongside the outcome of its own mutation, so settling can\n * never pair a result with the wrong caller's promise. */\ninterface SettledMutation {\n waiter: Waiter;\n result: MutationResult;\n}\n\nfunction applyBatchMutations(batch: Waiter[], state: NotifierFile): SettledMutation[] {\n return batch.map((waiter) => {\n try {\n return { waiter, result: { ok: true, outcome: waiter.mutate(state) } };\n } catch (err) {\n return { waiter, result: { ok: false, error: err } };\n }\n });\n}\n\nfunction collectEvents(settled: SettledMutation[]): NotifierEvent[] {\n const events: NotifierEvent[] = [];\n for (const { result } of settled) {\n if (result.ok && result.outcome !== null) events.push(result.outcome.event);\n }\n return events;\n}\n\nfunction collectHistoryEntries(settled: SettledMutation[]): NotifierHistoryEntry[] {\n const entries: NotifierHistoryEntry[] = [];\n for (const { result } of settled) {\n if (result.ok && result.outcome !== null && result.outcome.historyEntry) {\n entries.push(result.outcome.historyEntry);\n }\n }\n return entries;\n}\n\nfunction settleBatch(settled: SettledMutation[]): void {\n // Resolves come AFTER any emits so subscribers see the event\n // before the caller's `await` returns.\n for (const { waiter, result } of settled) {\n if (result.ok) waiter.resolve();\n else waiter.reject(result.error);\n }\n}\n\nfunction rejectBatch(batch: Waiter[], err: unknown): void {\n for (const waiter of batch) waiter.reject(err);\n}\n\nasync function persistHistory(newEntries: NotifierHistoryEntry[]): Promise<void> {\n const existing = await loadHistory(historyFilePath);\n // Newest-first ordering: a batch contains terminations in arrival\n // order; we want the last one to land at index 0 of history.\n const merged = [...newEntries.slice().reverse(), ...existing.entries].slice(0, HISTORY_CAP);\n await saveHistory(requireWriteJson(), historyFilePath, { entries: merged });\n}\n\nasync function processBatch(batch: Waiter[]): Promise<void> {\n let state: NotifierFile;\n try {\n state = await loadActive(activeFilePath);\n } catch (err) {\n logger().error(\"load failed\", { error: String(err) });\n rejectBatch(batch, err);\n return;\n }\n const settled = applyBatchMutations(batch, state);\n const events = collectEvents(settled);\n const historyEntries = collectHistoryEntries(settled);\n\n if (events.length > 0) {\n try {\n await saveActive(requireWriteJson(), activeFilePath, state);\n } catch (err) {\n logger().error(\"active write failed\", { error: String(err) });\n rejectBatch(batch, err);\n return;\n }\n if (historyEntries.length > 0) {\n // Best-effort: active is the source of truth, history is an\n // audit aid. A failed history write is logged but doesn't\n // unwind the active commit.\n try {\n await persistHistory(historyEntries);\n } catch (err) {\n logger().error(\"history write failed\", { error: String(err) });\n }\n }\n for (const event of events) emit(event);\n }\n settleBatch(settled);\n}\n\nasync function drain(): Promise<void> {\n writing = true;\n try {\n while (waiters.length > 0) {\n const batch = waiters;\n waiters = [];\n await processBatch(batch);\n }\n } finally {\n writing = false;\n }\n}\n\nfunction enqueue(mutate: Mutation): Promise<void> {\n return new Promise<void>((resolve, reject) => {\n waiters.push({ mutate, resolve, reject });\n if (!writing) void drain();\n });\n}\n\nfunction removeEntry(state: NotifierFile, entryId: string): NotifierFile[\"entries\"] {\n // Object-rest excludes the key without invoking `delete`.\n const { [entryId]: __removed, ...remaining } = state.entries;\n return remaining;\n}\n\nfunction buildHistoryEntry(entry: NotifierEntry, terminalType: \"cleared\" | \"cancelled\"): NotifierHistoryEntry {\n return { ...entry, terminalType, terminalAt: new Date().toISOString() };\n}\n\n// ── Public API ────────────────────────────────────────────────────\n\n/** The optional half of a publish input, with unset keys left OUT rather than\n * written as `undefined` — an entry is persisted and re-read through JSON,\n * where an unset field carries no key at all. */\nfunction optionalPublishFields<TPluginData>(input: PublishInput<TPluginData>): Partial<NotifierEntry<TPluginData>> {\n return {\n ...(input.lifecycle !== undefined ? { lifecycle: input.lifecycle } : {}),\n ...(input.body !== undefined ? { body: input.body } : {}),\n ...(input.navigateTarget !== undefined ? { navigateTarget: input.navigateTarget } : {}),\n ...(input.pluginData !== undefined ? { pluginData: input.pluginData } : {}),\n };\n}\n\nexport async function publish<TPluginData = unknown>(input: PublishInput<TPluginData>): Promise<{ id: string }> {\n // Validate at the engine boundary so plugin-runtime callers and\n // HTTP callers hit the same wall.\n const validationError = validatePublishInput(input);\n if (validationError) {\n throw new Error(`notifier.publish: ${validationError}`);\n }\n const entryId = randomUUID();\n const entry: NotifierEntry<TPluginData> = {\n id: entryId,\n pluginPkg: input.pluginPkg,\n severity: input.severity,\n title: input.title,\n createdAt: new Date().toISOString(),\n ...optionalPublishFields(input),\n };\n await enqueue((state) => {\n state.entries[entryId] = entry;\n return { event: { type: \"published\", entry } };\n });\n return { id: entryId };\n}\n\n/** Remove an active entry and record its terminal history. `clear`\n * (user dismissed) and `cancel` (producer withdrew) differ only in the\n * emitted event / history reason. No-op when the id is unknown. */\nasync function terminateEntry(entryId: string, reason: \"cleared\" | \"cancelled\"): Promise<void> {\n await enqueue((state) => {\n const entry = state.entries[entryId];\n if (!entry) return null;\n state.entries = removeEntry(state, entryId);\n return {\n event: reason === \"cleared\" ? { type: \"cleared\", id: entryId } : { type: \"cancelled\", id: entryId },\n historyEntry: buildHistoryEntry(entry, reason),\n };\n });\n}\n\nexport async function clear(entryId: string): Promise<void> {\n await terminateEntry(entryId, \"cleared\");\n}\n\nexport async function cancel(entryId: string): Promise<void> {\n await terminateEntry(entryId, \"cancelled\");\n}\n\n/** In-place update for an active entry. Only the fields present on\n * `patch` are rewritten; `id`, `pluginPkg`, `lifecycle`, and\n * `createdAt` stay fixed. Emits a single `\"updated\"` event with the\n * post-mutation entry — no history record is written because the\n * entry is still active, just with refreshed content.\n *\n * No-ops (no throw) when the id is unknown, the entry belongs to a\n * different plugin, or the merged shape would violate\n * `validatePublishInput`. The silent skip matches `clearForPlugin`'s\n * isolation semantics; validation failures are logged for diagnosis. */\nexport async function updateForPlugin<TPluginData = unknown>(\n pluginPkg: string,\n entryId: string,\n patch: {\n severity?: NotifierSeverity;\n title?: string;\n body?: string;\n navigateTarget?: string;\n pluginData?: TPluginData;\n },\n): Promise<void> {\n await enqueue((state) => {\n const entry = state.entries[entryId];\n if (!entry) return null;\n if (entry.pluginPkg !== pluginPkg) return null;\n const next: NotifierEntry = {\n ...entry,\n ...(patch.severity !== undefined ? { severity: patch.severity } : {}),\n ...(patch.title !== undefined ? { title: patch.title } : {}),\n ...(patch.body !== undefined ? { body: patch.body } : {}),\n ...(patch.navigateTarget !== undefined ? { navigateTarget: patch.navigateTarget } : {}),\n ...(patch.pluginData !== undefined ? { pluginData: patch.pluginData } : {}),\n };\n // Re-validate the merged shape so an update can't degrade the\n // entry below publish-time invariants.\n const validationError = validatePublishInput({\n pluginPkg: next.pluginPkg,\n severity: next.severity,\n title: next.title,\n body: next.body,\n lifecycle: next.lifecycle,\n navigateTarget: next.navigateTarget,\n pluginData: next.pluginData,\n });\n if (validationError) {\n logger().warn(\"update rejected by validation\", { entryId, pluginPkg, error: validationError });\n return null;\n }\n state.entries[entryId] = next;\n return { event: { type: \"updated\", entry: next } };\n });\n}\n\n/** Plugin-scoped point lookup. Returns the entry by id, but only if it\n * belongs to the caller's plugin; otherwise undefined. Cross-plugin\n * reads return undefined for isolation — same property as\n * `clearForPlugin` / `updateForPlugin`. */\nexport async function getForPlugin(pluginPkg: string, entryId: string): Promise<NotifierEntry | undefined> {\n const state = await loadActive(activeFilePath);\n const entry = state.entries[entryId];\n if (!entry) return undefined;\n if (entry.pluginPkg !== pluginPkg) return undefined;\n return entry;\n}\n\n/** Plugin-scoped clear. Same as `clear` but no-ops if the entry's\n * `pluginPkg` doesn't match the caller's, so a plugin can't dismiss\n * another plugin's notification by guessing or scraping its id. */\nexport async function clearForPlugin(pluginPkg: string, entryId: string): Promise<void> {\n await enqueue((state) => {\n const entry = state.entries[entryId];\n if (!entry) return null;\n if (entry.pluginPkg !== pluginPkg) return null;\n state.entries = removeEntry(state, entryId);\n return {\n event: { type: \"cleared\", id: entryId },\n historyEntry: buildHistoryEntry(entry, \"cleared\"),\n };\n });\n}\n\nexport async function get(entryId: string): Promise<NotifierEntry | undefined> {\n const state = await loadActive(activeFilePath);\n return state.entries[entryId];\n}\n\nexport async function listFor(pluginPkg: string): Promise<NotifierEntry[]> {\n const state = await loadActive(activeFilePath);\n return Object.values(state.entries).filter((entry) => entry.pluginPkg === pluginPkg);\n}\n\nexport async function listAll(): Promise<NotifierEntry[]> {\n const state = await loadActive(activeFilePath);\n return Object.values(state.entries);\n}\n\nexport async function listHistory(): Promise<NotifierHistoryEntry[]> {\n const state = await loadHistory(historyFilePath);\n return state.entries;\n}\n"],"mappings":"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"}
|
package/dist/utils/index.cjs.map
DELETED
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"index.cjs","names":[],"sources":["../../src/utils/errors.ts"],"sourcesContent":["// Canonical error helpers for the whole monorepo. Host (`server/`, `src/`)\n// re-exports these rather than keeping its own copy — before #2217 the same\n// function existed 14 times across 4 behaviours, and the two that mattered\n// disagreed on gRPC-shaped errors: `{ code, details, metadata }` surfaced as\n// \"quota exceeded\" through the host copy and as \"[object Object]\" through the\n// core ones.\n//\n// `errorMessage` itself now lives in `@mulmoclaude/common` (browser-safe leaf,\n// zero deps) so the Vue plugin surfaces can share the one implementation\n// instead of hand-copying it — `@mulmoclaude/core/utils` is server-only and\n// unreachable from browser code. This module keeps the same import surface so\n// no core/host consumer of `errorMessage` breaks.\nimport { errorMessage } from \"@mulmoclaude/common\";\n\nexport { errorMessage };\n\n// Coerce an unknown caught value into an Error, preserving the original if it\n// already was one. Use in error boundaries / Promise rejections / event-handler\n// onerror callbacks where the downstream API wants an Error object.\n//\n// `fallback` is the message used when coercing a non-Error value — pass a\n// descriptive string for cases where `String(err)` would just produce noise\n// (e.g. `<img>.onerror` hands you an Event, not the underlying load failure).\nexport function toError(err: unknown, fallback?: string): Error {\n if (err instanceof Error) return err;\n return new Error(fallback ?? errorMessage(err));\n}\n"],"mappings":";;;;AAuBA,SAAgB,QAAQ,KAAc,UAA0B;CAC9D,IAAI,eAAe,OAAO,OAAO;CACjC,OAAO,IAAI,MAAM,YAAY,aAAA,aAAa,GAAG,CAAC;AAChD"}
|
package/dist/utils/index.js.map
DELETED
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"index.js","names":[],"sources":["../../src/utils/errors.ts"],"sourcesContent":["// Canonical error helpers for the whole monorepo. Host (`server/`, `src/`)\n// re-exports these rather than keeping its own copy — before #2217 the same\n// function existed 14 times across 4 behaviours, and the two that mattered\n// disagreed on gRPC-shaped errors: `{ code, details, metadata }` surfaced as\n// \"quota exceeded\" through the host copy and as \"[object Object]\" through the\n// core ones.\n//\n// `errorMessage` itself now lives in `@mulmoclaude/common` (browser-safe leaf,\n// zero deps) so the Vue plugin surfaces can share the one implementation\n// instead of hand-copying it — `@mulmoclaude/core/utils` is server-only and\n// unreachable from browser code. This module keeps the same import surface so\n// no core/host consumer of `errorMessage` breaks.\nimport { errorMessage } from \"@mulmoclaude/common\";\n\nexport { errorMessage };\n\n// Coerce an unknown caught value into an Error, preserving the original if it\n// already was one. Use in error boundaries / Promise rejections / event-handler\n// onerror callbacks where the downstream API wants an Error object.\n//\n// `fallback` is the message used when coercing a non-Error value — pass a\n// descriptive string for cases where `String(err)` would just produce noise\n// (e.g. `<img>.onerror` hands you an Event, not the underlying load failure).\nexport function toError(err: unknown, fallback?: string): Error {\n if (err instanceof Error) return err;\n return new Error(fallback ?? errorMessage(err));\n}\n"],"mappings":";;;AAuBA,SAAgB,QAAQ,KAAc,UAA0B;CAC9D,IAAI,eAAe,OAAO,OAAO;CACjC,OAAO,IAAI,MAAM,YAAY,aAAa,GAAG,CAAC;AAChD"}
|