mulmoclaude 2.9.0 → 2.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (30) hide show
  1. package/client/assets/{feat-3497-qPLQlhNs.js → feat-3497-Cwr8t4l4.js} +469 -469
  2. package/client/assets/{feat-3497-CVVJjM2K.css → feat-3497-DEfIEPf6.css} +1 -1
  3. package/client/assets/{marp-C3oyTBz_.js → marp-CmMNIT1E.js} +1 -1
  4. package/client/index.html +2 -2
  5. package/package.json +3 -3
  6. package/server/api/routes/files.ts +230 -89
  7. package/server/api/routes/pdf.ts +68 -62
  8. package/server/api/routes/runtime-plugin.ts +33 -32
  9. package/server/build/mcp-server.mjs +590 -590
  10. package/server/index.ts +27 -94
  11. package/server/plugins/confined-file-ops.ts +5 -3
  12. package/server/system/config.ts +1 -1
  13. package/server/utils/exif.ts +4 -7
  14. package/server/utils/files/artifactMount.ts +89 -0
  15. package/server/utils/files/attachment-store.ts +17 -29
  16. package/server/utils/files/image-store.ts +7 -13
  17. package/server/utils/files/plugins-io.ts +13 -5
  18. package/server/utils/files/safe.ts +0 -8
  19. package/server/utils/files/sendFileHandle.ts +115 -0
  20. package/server/utils/files/store-files.ts +86 -0
  21. package/server/utils/files/storePaths.ts +23 -0
  22. package/server/utils/files/thumbnail-store.ts +25 -23
  23. package/server/utils/files/upload-io.ts +9 -8
  24. package/server/utils/html/htmlArtifactSplicer.ts +19 -14
  25. package/server/utils/share/packHtml.ts +23 -34
  26. package/server/workspace/photo-locations/index.ts +26 -27
  27. package/server/workspace/photo-locations/list.ts +17 -29
  28. package/server/workspace/photo-locations/store.ts +4 -0
  29. package/server/workspace/wiki-pages/io.ts +9 -8
  30. package/server/utils/files/store-resolvers.ts +0 -58
@@ -8,13 +8,13 @@
8
8
  // "list all my photo locations" or "render every captured pin on
9
9
  // a map view".
10
10
 
11
- import { readdir, readFile } from "node:fs/promises";
12
11
  import path from "node:path";
13
- import { WORKSPACE_DIRS, WORKSPACE_PATHS } from "../paths.js";
12
+ import { WORKSPACE_DIRS } from "../paths.js";
14
13
  import { log } from "../../system/logger/index.js";
15
14
  import { isRecord } from "../../utils/types.js";
16
15
  import type { PhotoExif } from "../../utils/exif.js";
17
16
  import type { PhotoLocationSidecar } from "./index.js";
17
+ import { locationsStore } from "./store.js";
18
18
 
19
19
  /** A sidecar paired with the id used in the filename. The id is the
20
20
  * attachment's stable identifier; consumers (UI, LLM) use it to
@@ -33,15 +33,12 @@ const SIDECAR_EXT = ".json";
33
33
  * the path so an operator can investigate without the listing
34
34
  * crashing. */
35
35
  export async function listAllSidecars(): Promise<ListedSidecar[]> {
36
- const root = WORKSPACE_PATHS.locations;
37
- const yearDirs = await readSubdirsSafe(root);
36
+ const yearDirs = await readSubdirs("");
38
37
  const all: ListedSidecar[] = [];
39
38
  for (const year of yearDirs) {
40
- const yearAbs = path.join(root, year);
41
- const monthDirs = await readSubdirsSafe(yearAbs);
39
+ const monthDirs = await readSubdirs(year);
42
40
  for (const month of monthDirs) {
43
- const monthAbs = path.join(yearAbs, month);
44
- const sidecars = await readSidecarsInDir(monthAbs, year, month);
41
+ const sidecars = await readSidecarsInDir(year, month);
45
42
  all.push(...sidecars);
46
43
  }
47
44
  }
@@ -146,39 +143,30 @@ function validateSidecarShape(value: unknown): PhotoLocationSidecar | null {
146
143
  return { version: 1, capturedAt, photo: { relativePath, mimeType }, exif: readSidecarExif(exif) };
147
144
  }
148
145
 
149
- async function readSubdirsSafe(dir: string): Promise<string[]> {
150
- try {
151
- const entries = await readdir(dir, { withFileTypes: true });
152
- return entries.filter((entry) => entry.isDirectory()).map((entry) => entry.name);
153
- } catch {
154
- // ENOENT on a fresh workspace — locations dir doesn't exist
155
- // yet, treat as empty.
156
- return [];
157
- }
146
+ // A fresh workspace has no locations folder yet; the store lists that as empty.
147
+ async function readSubdirs(storeDir: string): Promise<string[]> {
148
+ const entries = await locationsStore.listDir(storeDir);
149
+ return entries.filter((entry) => entry.isDirectory()).map((entry) => entry.name);
158
150
  }
159
151
 
160
- async function readSidecarsInDir(absDir: string, year: string, month: string): Promise<ListedSidecar[]> {
161
- const entries = await readdir(absDir).catch(() => []);
152
+ async function readSidecarsInDir(year: string, month: string): Promise<ListedSidecar[]> {
153
+ const entries = await locationsStore.listDir(path.posix.join(year, month));
162
154
  const out: ListedSidecar[] = [];
163
- for (const entry of entries) {
155
+ for (const { name: entry } of entries) {
164
156
  if (!entry.endsWith(SIDECAR_EXT)) continue;
165
157
  const sidecarId = entry.slice(0, -SIDECAR_EXT.length);
166
- const absPath = path.join(absDir, entry);
158
+ const relativePath = path.posix.join(WORKSPACE_DIRS.locations, year, month, entry);
167
159
  try {
168
- const raw = await readFile(absPath, "utf8");
160
+ const raw = (await locationsStore.read(relativePath)).toString("utf8");
169
161
  const parsed: unknown = JSON.parse(raw);
170
162
  const sidecar = validateSidecarShape(parsed);
171
163
  if (!sidecar) {
172
- log.warn("photo-locations", "skipping sidecar with invalid shape", { path: absPath });
164
+ log.warn("photo-locations", "skipping sidecar with invalid shape", { path: relativePath });
173
165
  continue;
174
166
  }
175
- out.push({
176
- id: sidecarId,
177
- relativePath: path.posix.join(WORKSPACE_DIRS.locations, year, month, entry),
178
- sidecar,
179
- });
167
+ out.push({ id: sidecarId, relativePath, sidecar });
180
168
  } catch (err) {
181
- log.warn("photo-locations", "skipping malformed sidecar", { path: absPath, error: String(err) });
169
+ log.warn("photo-locations", "skipping malformed sidecar", { path: relativePath, error: String(err) });
182
170
  }
183
171
  }
184
172
  return out;
@@ -0,0 +1,4 @@
1
+ import { makeStoreFiles } from "../../utils/files/store-files.js";
2
+ import { WORKSPACE_DIRS, WORKSPACE_PATHS } from "../paths.js";
3
+
4
+ export const locationsStore = makeStoreFiles(() => WORKSPACE_PATHS.locations, WORKSPACE_DIRS.locations);
@@ -19,9 +19,10 @@
19
19
  // site wired up means PR 2 is purely an internal change.
20
20
 
21
21
  import path from "node:path";
22
- import { mkdir, writeFile } from "node:fs/promises";
22
+ import { realpath } from "node:fs/promises";
23
23
  import { readTextSafe } from "../../utils/files/safe.js";
24
24
  import { writeFileAtomic } from "../../utils/files/atomic.js";
25
+ import { writeNewFileExclusive } from "../../utils/files/upload-io.js";
25
26
  import { mergeFrontmatter, parseFrontmatter, serializeWithFrontmatter } from "@mulmoclaude/markdown-utils/markdown/frontmatter";
26
27
  import { isSafeSlug } from "@mulmoclaude/core/wiki";
27
28
  import { wikiSlugFromAbsPath } from "@mulmoclaude/core/wiki/paths";
@@ -56,7 +57,7 @@ export interface WikiPageWriteOptions {
56
57
  * injection. Tests pass a fixed `Date` so the round-trip is
57
58
  * deterministic; production uses the wall clock. */
58
59
  now?: (() => Date) | undefined;
59
- /** Create-only mode: write via `O_EXCL` (`flag: "wx"`) so the
60
+ /** Create-only mode: write via `O_EXCL` so the
60
61
  * call atomically refuses to overwrite an existing page. The
61
62
  * thrown error has `code: "EEXIST"` — used by the
62
63
  * `POST /api/files/create` route (#1598) to map to a 409 and
@@ -107,7 +108,7 @@ export async function writeWikiPage(slug: string, content: string, meta: WikiWri
107
108
  const finalContent = stampFrontmatter(oldContent, content, meta, opts);
108
109
  if (opts.exclusive) {
109
110
  // Re-anchor the absolute path against the workspace root before
110
- // handing it to `mkdir`/`writeFile`. `wikiPagePath` already
111
+ // handing it to the create. `wikiPagePath` already
111
112
  // enforces containment via `isSafeSlug` + `path.join`, but
112
113
  // CodeQL's `js/path-injection` analysis doesn't trace through
113
114
  // those — the explicit `relative` + `startsWith` containment
@@ -120,11 +121,11 @@ export async function writeWikiPage(slug: string, content: string, meta: WikiWri
120
121
  throw new Error(`wiki-pages: refusing to write outside workspace root: ${JSON.stringify(absPath)}`);
121
122
  }
122
123
  const safeAbsPath = path.join(root, relFromRoot);
123
- await mkdir(path.dirname(safeAbsPath), { recursive: true });
124
- // `flag: "wx"` is the POSIX `O_EXCL`: atomic create-or-fail.
125
- // On a concurrent create race only one call wins; the other
126
- // throws `EEXIST`, which the route handler maps to HTTP 409.
127
- await writeFile(safeAbsPath, finalContent, { flag: "wx" });
124
+ // `O_EXCL`: atomic create-or-fail. On a concurrent create race
125
+ // only one call wins; the other throws `EEXIST`, which the route
126
+ // handler maps to HTTP 409. The new file is also proven to sit
127
+ // inside the workspace before any byte is written (#3573).
128
+ await writeNewFileExclusive(await realpath(root), safeAbsPath, finalContent);
128
129
  } else {
129
130
  await writeFileAtomic(absPath, finalContent, { uniqueTmp: true });
130
131
  }
@@ -1,58 +0,0 @@
1
- // Shared resolver factory for file-store modules (attachment-store,
2
- // image-store). Each store used to define its own
3
- // `safeResolve(relativePath)` wrapper of identical shape: ensure the
4
- // root dir exists, cache its realpath, strip the leading store
5
- // prefix, hand off to `resolveWithinRoot`. That left three name-cloned
6
- // `safeResolve` functions that all looked read-only but two of them
7
- // were also being used for writes — see #1754 / #1744.
8
- //
9
- // `makeStoreResolvers` collapses the three copies into one and
10
- // exposes the read/write split through method names. A caller that
11
- // wants to write reaches for `.forWrite`, which routes through the
12
- // write-time confinement primitive instead of the read-time one.
13
-
14
- import { mkdir, realpath } from "fs/promises";
15
- import { resolveWithinRoot, resolveWriteWithinRoot } from "./safe.js";
16
-
17
- export interface StoreResolvers {
18
- forRead: (relativePath: string) => Promise<string>;
19
- forWrite: (relativePath: string) => Promise<string>;
20
- }
21
-
22
- // Cache realpath per absolute root, not per factory instance — test
23
- // setups override `WORKSPACE_PATHS.<dir>` mid-run, so capturing the
24
- // path at factory-call time would pin every store to the original
25
- // workspace. Re-reading via `getRoot()` and keying the cache on the
26
- // returned absolute path means each tmp workspace gets its own entry.
27
- const realPathCacheByRoot = new Map<string, string>();
28
-
29
- async function ensureRoot(getRoot: () => string): Promise<string> {
30
- const root = getRoot();
31
- const cached = realPathCacheByRoot.get(root);
32
- if (cached) return cached;
33
- await mkdir(root, { recursive: true });
34
- const real = await realpath(root);
35
- realPathCacheByRoot.set(root, real);
36
- return real;
37
- }
38
-
39
- function stripDirPrefix(relativePath: string, dirPrefix: string): string {
40
- return relativePath.replace(new RegExp(`^${dirPrefix}/`), "");
41
- }
42
-
43
- export function makeStoreResolvers(getRoot: () => string, dirPrefix: string): StoreResolvers {
44
- return {
45
- forRead: async (relativePath) => {
46
- const root = await ensureRoot(getRoot);
47
- const result = resolveWithinRoot(root, stripDirPrefix(relativePath, dirPrefix));
48
- if (!result) throw new Error(`path traversal rejected: ${relativePath}`);
49
- return result;
50
- },
51
- forWrite: async (relativePath) => {
52
- const root = await ensureRoot(getRoot);
53
- const result = await resolveWriteWithinRoot(root, stripDirPrefix(relativePath, dirPrefix));
54
- if (!result) throw new Error(`path traversal rejected: ${relativePath}`);
55
- return result;
56
- },
57
- };
58
- }