@moxxy/sdk 0.12.0 → 0.14.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/assert.d.ts +19 -0
- package/dist/assert.d.ts.map +1 -0
- package/dist/assert.js +29 -0
- package/dist/assert.js.map +1 -0
- package/dist/compactor-helpers.d.ts.map +1 -1
- package/dist/compactor-helpers.js +6 -3
- package/dist/compactor-helpers.js.map +1 -1
- package/dist/events.d.ts +1 -1
- package/dist/events.d.ts.map +1 -1
- package/dist/frontmatter.d.ts +40 -0
- package/dist/frontmatter.d.ts.map +1 -0
- package/dist/frontmatter.js +161 -0
- package/dist/frontmatter.js.map +1 -0
- package/dist/fs-utils.d.ts +8 -0
- package/dist/fs-utils.d.ts.map +1 -1
- package/dist/fs-utils.js +29 -0
- package/dist/fs-utils.js.map +1 -1
- package/dist/index.d.ts +6 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +6 -2
- package/dist/index.js.map +1 -1
- package/dist/json-file-store.d.ts +84 -0
- package/dist/json-file-store.d.ts.map +1 -0
- package/dist/json-file-store.js +56 -0
- package/dist/json-file-store.js.map +1 -0
- package/dist/mode.d.ts +10 -0
- package/dist/mode.d.ts.map +1 -1
- package/dist/mode.js.map +1 -1
- package/dist/semver.d.ts +16 -0
- package/dist/semver.d.ts.map +1 -0
- package/dist/semver.js +28 -0
- package/dist/semver.js.map +1 -0
- package/dist/tool-dispatch.d.ts.map +1 -1
- package/dist/tool-dispatch.js +5 -2
- package/dist/tool-dispatch.js.map +1 -1
- package/dist/types.test-d.js +5 -0
- package/dist/types.test-d.js.map +1 -1
- package/dist/view-renderer.d.ts +6 -0
- package/dist/view-renderer.d.ts.map +1 -1
- package/dist/view-renderer.js +18 -1
- package/dist/view-renderer.js.map +1 -1
- package/package.json +3 -3
- package/src/assert.test.ts +35 -0
- package/src/assert.ts +28 -0
- package/src/compactor-helpers.test.ts +52 -0
- package/src/compactor-helpers.ts +6 -2
- package/src/events.ts +8 -1
- package/src/frontmatter.test.ts +141 -0
- package/src/frontmatter.ts +159 -0
- package/src/fs-utils.test.ts +47 -1
- package/src/fs-utils.ts +31 -0
- package/src/index.ts +22 -2
- package/src/json-file-store.test.ts +207 -0
- package/src/json-file-store.ts +144 -0
- package/src/mode.ts +10 -0
- package/src/semver.test.ts +43 -0
- package/src/semver.ts +27 -0
- package/src/tool-dispatch.ts +5 -2
- package/src/types.test-d.ts +13 -0
- package/src/view-renderer.test.ts +57 -0
- package/src/view-renderer.ts +18 -1
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { parseFrontmatter, parseFrontmatterFile, renderFrontmatter } from './frontmatter.js';
|
|
3
|
+
|
|
4
|
+
// Golden tests for the canonical frontmatter parser. This module replaced two
|
|
5
|
+
// previously-copy-pasted copies (packages/core/src/skills/parse.ts and
|
|
6
|
+
// packages/plugin-memory/src/parse.ts). The cases below pin the EXACT prior
|
|
7
|
+
// behavior of the more-correct `core` copy: parse output fields, missing/blank
|
|
8
|
+
// frontmatter handling, body offset, and the inline-array/scalar typing where
|
|
9
|
+
// the two copies had diverged.
|
|
10
|
+
describe('parseFrontmatterFile', () => {
|
|
11
|
+
it('returns empty frontmatter and original body when no fence present', () => {
|
|
12
|
+
const { frontmatter, body } = parseFrontmatterFile('hello world');
|
|
13
|
+
expect(frontmatter).toEqual({});
|
|
14
|
+
expect(body).toBe('hello world');
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it('returns the original content unchanged when the closing fence is missing', () => {
|
|
18
|
+
const input = '---\nname: foo\nbody but no close';
|
|
19
|
+
const { frontmatter, body } = parseFrontmatterFile(input);
|
|
20
|
+
expect(frontmatter).toEqual({});
|
|
21
|
+
expect(body).toBe(input);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it('parses simple frontmatter and offsets the body past the closing fence', () => {
|
|
25
|
+
const { frontmatter, body } = parseFrontmatterFile(`---
|
|
26
|
+
name: foo
|
|
27
|
+
description: bar
|
|
28
|
+
---
|
|
29
|
+
body text`);
|
|
30
|
+
expect(frontmatter).toEqual({ name: 'foo', description: 'bar' });
|
|
31
|
+
expect(body).toBe('body text');
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it('preserves a multiline body verbatim after the --- fence', () => {
|
|
35
|
+
const md = `---\nname: foo\ndescription: d\n---\nline 1\n\nline 2`;
|
|
36
|
+
const { body } = parseFrontmatterFile(md);
|
|
37
|
+
expect(body).toBe('line 1\n\nline 2');
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it('treats an empty body after the fence as an empty string', () => {
|
|
41
|
+
const { frontmatter, body } = parseFrontmatterFile(`---\nname: foo\n---`);
|
|
42
|
+
expect(frontmatter).toEqual({ name: 'foo' });
|
|
43
|
+
expect(body).toBe('');
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it('handles CRLF opening + closing fences', () => {
|
|
47
|
+
const { frontmatter, body } = parseFrontmatterFile('---\r\nname: foo\r\n---\r\nbody');
|
|
48
|
+
expect(frontmatter).toEqual({ name: 'foo' });
|
|
49
|
+
expect(body).toBe('body');
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it('does not treat a leading "---" without a newline as a fence', () => {
|
|
53
|
+
const { frontmatter, body } = parseFrontmatterFile('---not a fence');
|
|
54
|
+
expect(frontmatter).toEqual({});
|
|
55
|
+
expect(body).toBe('---not a fence');
|
|
56
|
+
});
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
describe('parseFrontmatter', () => {
|
|
60
|
+
it('parses inline arrays with quoted commas (canonical splitArray behavior)', () => {
|
|
61
|
+
const fm = parseFrontmatter('triggers: [a, b, "c d"]');
|
|
62
|
+
expect(fm.triggers).toEqual(['a', 'b', 'c d']);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it('keeps a quoted comma inside one inline-array element (divergence fix)', () => {
|
|
66
|
+
// The diverged plugin-memory copy split on bare commas; the canonical
|
|
67
|
+
// (core) copy must keep "a, b" as a single element.
|
|
68
|
+
const fm = parseFrontmatter('tags: ["a, b", c]');
|
|
69
|
+
expect(fm.tags).toEqual(['a, b', 'c']);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it('parses nested inline arrays', () => {
|
|
73
|
+
const fm = parseFrontmatter('matrix: [[1, 2], [3, 4]]');
|
|
74
|
+
expect(fm.matrix).toEqual([
|
|
75
|
+
[1, 2],
|
|
76
|
+
[3, 4],
|
|
77
|
+
]);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it('parses empty inline arrays', () => {
|
|
81
|
+
expect(parseFrontmatter('items: []').items).toEqual([]);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it('parses block-list arrays', () => {
|
|
85
|
+
const fm = parseFrontmatter('allowed-tools:\n - Read\n - Edit\n - Bash');
|
|
86
|
+
expect(fm['allowed-tools']).toEqual(['Read', 'Edit', 'Bash']);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it('parses numbers, floats, booleans, and null', () => {
|
|
90
|
+
const fm = parseFrontmatter('count: 3\nratio: 1.5\nbig: true\nzero: false\nempty: null\ntwiddle: ~');
|
|
91
|
+
expect(fm.count).toBe(3);
|
|
92
|
+
expect(fm.ratio).toBe(1.5);
|
|
93
|
+
expect(fm.big).toBe(true);
|
|
94
|
+
expect(fm.zero).toBe(false);
|
|
95
|
+
expect(fm.empty).toBeNull();
|
|
96
|
+
expect(fm.twiddle).toBeNull();
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it('strips quotes and tolerates colons inside quoted values', () => {
|
|
100
|
+
const fm = parseFrontmatter('description: "a: b"');
|
|
101
|
+
expect(fm.description).toBe('a: b');
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it('skips blank lines, comments, and lines without a colon', () => {
|
|
105
|
+
const fm = parseFrontmatter('# a comment\n\nname: foo\nnocolon\ndescription: bar');
|
|
106
|
+
expect(fm).toEqual({ name: 'foo', description: 'bar' });
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it('returns an empty object for blank frontmatter text', () => {
|
|
110
|
+
expect(parseFrontmatter('')).toEqual({});
|
|
111
|
+
expect(parseFrontmatter(' \n ')).toEqual({});
|
|
112
|
+
});
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
describe('renderFrontmatter', () => {
|
|
116
|
+
it('renders scalars, arrays, and quotes values that need it', () => {
|
|
117
|
+
const rendered = renderFrontmatter({
|
|
118
|
+
name: 'foo',
|
|
119
|
+
tags: ['a', 'b'],
|
|
120
|
+
desc: 'a: b',
|
|
121
|
+
count: 3,
|
|
122
|
+
});
|
|
123
|
+
expect(rendered).toBe('---\nname: foo\ntags: [a, b]\ndesc: "a: b"\ncount: 3\n---');
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it('skips null and undefined values', () => {
|
|
127
|
+
const rendered = renderFrontmatter({ name: 'foo', gone: null, missing: undefined });
|
|
128
|
+
expect(rendered).toBe('---\nname: foo\n---');
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it('round-trips a representative document through render -> parse', () => {
|
|
132
|
+
const fm = { name: 'foo', description: 'a thing', tags: ['x', 'y'] };
|
|
133
|
+
const doc = `${renderFrontmatter(fm)}\n\nbody content\n`;
|
|
134
|
+
const parsed = parseFrontmatterFile(doc);
|
|
135
|
+
expect(parsed.frontmatter).toEqual(fm);
|
|
136
|
+
// The closing fence regex consumes `\n---\n`, so the blank line authored
|
|
137
|
+
// between the fence and the body survives in the body offset (unchanged
|
|
138
|
+
// historical behavior).
|
|
139
|
+
expect(parsed.body).toBe('\nbody content\n');
|
|
140
|
+
});
|
|
141
|
+
});
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Canonical minimal Markdown + YAML-subset frontmatter parser.
|
|
3
|
+
*
|
|
4
|
+
* Pure string work, zero dependencies (node builtins only). This is the single
|
|
5
|
+
* home for the mini-parser that was previously copy-pasted into
|
|
6
|
+
* `packages/core/src/skills/parse.ts` and `packages/plugin-memory/src/parse.ts`.
|
|
7
|
+
* The plugin-memory copy had diverged (its inline-array scalar split on bare
|
|
8
|
+
* commas and dropped null/float typing); this module keeps the more-correct
|
|
9
|
+
* `core` behavior — depth/quote-aware inline arrays via {@link splitArray},
|
|
10
|
+
* `null`/`~`, and float parsing — so both packages share one source of truth.
|
|
11
|
+
*
|
|
12
|
+
* Supports a deliberately tiny YAML subset:
|
|
13
|
+
* - `key: value` scalars (string/number/float/bool/null, with quote stripping)
|
|
14
|
+
* - inline arrays `key: [a, b, "c d"]` (depth- and quote-aware)
|
|
15
|
+
* - block-list arrays (`key:` followed by ` - item` lines)
|
|
16
|
+
* Comments (`#`) and blank lines are skipped. Anything not recognized is left
|
|
17
|
+
* out of the frontmatter object.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
export interface ParsedFrontmatter {
|
|
21
|
+
frontmatter: Record<string, unknown>;
|
|
22
|
+
body: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const FRONTMATTER_FENCE = '---';
|
|
26
|
+
const OPENING_FENCE_LF = FRONTMATTER_FENCE + '\n';
|
|
27
|
+
const OPENING_FENCE_CRLF = FRONTMATTER_FENCE + '\r\n';
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Split a `---`-fenced Markdown document into its parsed frontmatter and body.
|
|
31
|
+
*
|
|
32
|
+
* If the content does not open with a `---` fence (LF or CRLF), or the closing
|
|
33
|
+
* `---` is missing, returns `{ frontmatter: {}, body: <original content> }`
|
|
34
|
+
* unchanged. The body excludes the closing fence and its trailing newline.
|
|
35
|
+
*/
|
|
36
|
+
export function parseFrontmatterFile(content: string): ParsedFrontmatter {
|
|
37
|
+
if (!content.startsWith(OPENING_FENCE_LF) && !content.startsWith(OPENING_FENCE_CRLF)) {
|
|
38
|
+
return { frontmatter: {}, body: content };
|
|
39
|
+
}
|
|
40
|
+
const fenceLen = content.startsWith(OPENING_FENCE_CRLF) ? OPENING_FENCE_CRLF.length : OPENING_FENCE_LF.length;
|
|
41
|
+
const rest = content.slice(fenceLen);
|
|
42
|
+
const endMatch = /\r?\n---(?:\r?\n|$)/.exec(rest);
|
|
43
|
+
if (!endMatch) return { frontmatter: {}, body: content };
|
|
44
|
+
return {
|
|
45
|
+
frontmatter: parseFrontmatter(rest.slice(0, endMatch.index)),
|
|
46
|
+
body: rest.slice(endMatch.index + endMatch[0].length),
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Parse the inner text of a frontmatter block into a key/value object. */
|
|
51
|
+
export function parseFrontmatter(text: string): Record<string, unknown> {
|
|
52
|
+
const result: Record<string, unknown> = {};
|
|
53
|
+
const lines = text.split(/\r?\n/);
|
|
54
|
+
for (let i = 0; i < lines.length; i++) {
|
|
55
|
+
const trimmed = (lines[i] ?? '').trim();
|
|
56
|
+
if (!trimmed || trimmed.startsWith('#')) continue;
|
|
57
|
+
const colon = trimmed.indexOf(':');
|
|
58
|
+
if (colon === -1) continue;
|
|
59
|
+
const key = trimmed.slice(0, colon).trim();
|
|
60
|
+
const raw = trimmed.slice(colon + 1).trim();
|
|
61
|
+
|
|
62
|
+
if (raw === '' && i + 1 < lines.length && /^\s*-\s/.test(lines[i + 1] ?? '')) {
|
|
63
|
+
const items: unknown[] = [];
|
|
64
|
+
while (i + 1 < lines.length && /^\s*-\s/.test(lines[i + 1] ?? '')) {
|
|
65
|
+
i += 1;
|
|
66
|
+
items.push(parseScalar((lines[i] ?? '').replace(/^\s*-\s*/, '').trim()));
|
|
67
|
+
}
|
|
68
|
+
result[key] = items;
|
|
69
|
+
continue;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
result[key] = parseScalar(raw);
|
|
73
|
+
}
|
|
74
|
+
return result;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function parseScalar(v: string): unknown {
|
|
78
|
+
if (!v) return '';
|
|
79
|
+
if (v.startsWith('[') && v.endsWith(']')) {
|
|
80
|
+
const inner = v.slice(1, -1).trim();
|
|
81
|
+
if (!inner) return [];
|
|
82
|
+
return splitArray(inner).map((s) => parseScalar(s.trim()));
|
|
83
|
+
}
|
|
84
|
+
if (v === 'true') return true;
|
|
85
|
+
if (v === 'false') return false;
|
|
86
|
+
if (v === 'null' || v === '~') return null;
|
|
87
|
+
if (/^-?\d+$/.test(v)) return Number(v);
|
|
88
|
+
if (/^-?\d+\.\d+$/.test(v)) return Number(v);
|
|
89
|
+
return stripQuotes(v);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function stripQuotes(s: string): string {
|
|
93
|
+
if (s.length >= 2 && ((s.startsWith('"') && s.endsWith('"')) || (s.startsWith("'") && s.endsWith("'")))) {
|
|
94
|
+
return s.slice(1, -1);
|
|
95
|
+
}
|
|
96
|
+
return s;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** Split a comma-separated inline-array body, honoring nesting and quotes. */
|
|
100
|
+
function splitArray(s: string): string[] {
|
|
101
|
+
const out: string[] = [];
|
|
102
|
+
let depth = 0;
|
|
103
|
+
let buf = '';
|
|
104
|
+
let inStr: '"' | "'" | null = null;
|
|
105
|
+
for (const c of s) {
|
|
106
|
+
if (inStr) {
|
|
107
|
+
buf += c;
|
|
108
|
+
if (c === inStr) inStr = null;
|
|
109
|
+
continue;
|
|
110
|
+
}
|
|
111
|
+
if (c === '"' || c === "'") {
|
|
112
|
+
inStr = c;
|
|
113
|
+
buf += c;
|
|
114
|
+
continue;
|
|
115
|
+
}
|
|
116
|
+
if (c === '[' || c === '{') depth++;
|
|
117
|
+
if (c === ']' || c === '}') depth--;
|
|
118
|
+
if (c === ',' && depth === 0) {
|
|
119
|
+
out.push(buf);
|
|
120
|
+
buf = '';
|
|
121
|
+
continue;
|
|
122
|
+
}
|
|
123
|
+
buf += c;
|
|
124
|
+
}
|
|
125
|
+
if (buf.trim()) out.push(buf);
|
|
126
|
+
return out;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Render a frontmatter object back into a `---`-fenced YAML-subset block.
|
|
131
|
+
* Inverse-ish of {@link parseFrontmatter}; `undefined`/`null` values are
|
|
132
|
+
* skipped. (Used by callers that round-trip Markdown documents, e.g. the
|
|
133
|
+
* memory store.)
|
|
134
|
+
*/
|
|
135
|
+
export function renderFrontmatter(frontmatter: Record<string, unknown>): string {
|
|
136
|
+
const out: string[] = ['---'];
|
|
137
|
+
for (const [k, v] of Object.entries(frontmatter)) {
|
|
138
|
+
if (v === undefined || v === null) continue;
|
|
139
|
+
out.push(`${k}: ${renderValue(v)}`);
|
|
140
|
+
}
|
|
141
|
+
out.push('---');
|
|
142
|
+
return out.join('\n');
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function renderValue(v: unknown): string {
|
|
146
|
+
if (v === null || v === undefined) return '';
|
|
147
|
+
if (typeof v === 'string') {
|
|
148
|
+
return needsQuoting(v) ? `"${v.replace(/"/g, '\\"')}"` : v;
|
|
149
|
+
}
|
|
150
|
+
if (typeof v === 'boolean' || typeof v === 'number') return String(v);
|
|
151
|
+
if (Array.isArray(v)) {
|
|
152
|
+
return `[${v.map((x) => renderValue(x)).join(', ')}]`;
|
|
153
|
+
}
|
|
154
|
+
return JSON.stringify(v);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function needsQuoting(s: string): boolean {
|
|
158
|
+
return s.includes(':') || s.includes('#') || s.includes('"') || s.startsWith(' ') || s.endsWith(' ');
|
|
159
|
+
}
|
package/src/fs-utils.test.ts
CHANGED
|
@@ -2,7 +2,7 @@ import { mkdtemp, readFile, readdir, stat } from 'node:fs/promises';
|
|
|
2
2
|
import { tmpdir } from 'node:os';
|
|
3
3
|
import { join } from 'node:path';
|
|
4
4
|
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
|
5
|
-
import { moxxyHome, moxxyPath, writeFileAtomic } from './fs-utils.js';
|
|
5
|
+
import { moxxyHome, moxxyPath, writeFileAtomic, writeFileAtomicSync } from './fs-utils.js';
|
|
6
6
|
|
|
7
7
|
describe('writeFileAtomic', () => {
|
|
8
8
|
let dir: string;
|
|
@@ -41,6 +41,52 @@ describe('writeFileAtomic', () => {
|
|
|
41
41
|
});
|
|
42
42
|
});
|
|
43
43
|
|
|
44
|
+
describe('writeFileAtomicSync', () => {
|
|
45
|
+
let dir: string;
|
|
46
|
+
beforeEach(async () => {
|
|
47
|
+
dir = await mkdtemp(join(tmpdir(), 'moxxy-fss-'));
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it('writes content and creates missing parent dirs', async () => {
|
|
51
|
+
const target = join(dir, 'nested', 'deep', 'file.json');
|
|
52
|
+
writeFileAtomicSync(target, '{"a":1}');
|
|
53
|
+
expect(await readFile(target, 'utf8')).toBe('{"a":1}');
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it('overwrites an existing file and leaves no temp file behind', async () => {
|
|
57
|
+
const target = join(dir, 'file.txt');
|
|
58
|
+
writeFileAtomicSync(target, 'first');
|
|
59
|
+
writeFileAtomicSync(target, 'second');
|
|
60
|
+
expect(await readFile(target, 'utf8')).toBe('second');
|
|
61
|
+
const leftovers = (await readdir(dir)).filter((n) => n.includes('.tmp'));
|
|
62
|
+
expect(leftovers).toEqual([]);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it('enforces the requested mode past umask', async () => {
|
|
66
|
+
const target = join(dir, 'secret.json');
|
|
67
|
+
writeFileAtomicSync(target, 'shh', { mode: 0o600 });
|
|
68
|
+
const mode = (await stat(target)).mode & 0o777;
|
|
69
|
+
expect(mode).toBe(0o600);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it('writes binary data unchanged', async () => {
|
|
73
|
+
const target = join(dir, 'bytes.bin');
|
|
74
|
+
writeFileAtomicSync(target, new Uint8Array([0, 1, 2, 255]));
|
|
75
|
+
expect(Array.from(await readFile(target))).toEqual([0, 1, 2, 255]);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it('cleans up the temp file and throws when rename fails', async () => {
|
|
79
|
+
// A directory in place of the final file makes renameSync fail; the temp
|
|
80
|
+
// file must be removed and the error surfaced rather than swallowed.
|
|
81
|
+
const { mkdir } = await import('node:fs/promises');
|
|
82
|
+
const target = join(dir, 'as-dir');
|
|
83
|
+
await mkdir(target);
|
|
84
|
+
expect(() => writeFileAtomicSync(target, 'nope')).toThrow();
|
|
85
|
+
const leftovers = (await readdir(dir)).filter((n) => n.includes('.tmp'));
|
|
86
|
+
expect(leftovers).toEqual([]);
|
|
87
|
+
});
|
|
88
|
+
});
|
|
89
|
+
|
|
44
90
|
describe('moxxyHome / moxxyPath', () => {
|
|
45
91
|
const original = process.env.MOXXY_HOME;
|
|
46
92
|
afterEach(() => {
|
package/src/fs-utils.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import { chmodSync, mkdirSync, renameSync, rmSync, writeFileSync } from 'node:fs';
|
|
2
3
|
import { chmod, mkdir, rename, rm, writeFile } from 'node:fs/promises';
|
|
3
4
|
import { homedir } from 'node:os';
|
|
4
5
|
import { dirname, join } from 'node:path';
|
|
@@ -40,6 +41,36 @@ export async function writeFileAtomic(
|
|
|
40
41
|
}
|
|
41
42
|
}
|
|
42
43
|
|
|
44
|
+
/**
|
|
45
|
+
* Synchronous twin of {@link writeFileAtomic}, for the few call sites that
|
|
46
|
+
* cannot be async — boot/bootstrap and self-update gates that run before the
|
|
47
|
+
* event loop is the right tool. Same crash-atomic guarantee: write a unique
|
|
48
|
+
* sibling temp file, then `rename` it over the target. Hand-rolled
|
|
49
|
+
* `mkdirSync + writeFileSync(tmp) + renameSync` copies should call this instead.
|
|
50
|
+
*/
|
|
51
|
+
export function writeFileAtomicSync(
|
|
52
|
+
target: string,
|
|
53
|
+
data: string | Uint8Array,
|
|
54
|
+
opts: WriteFileAtomicOptions = {},
|
|
55
|
+
): void {
|
|
56
|
+
mkdirSync(dirname(target), { recursive: true });
|
|
57
|
+
const tmp = `${target}.${process.pid}.${randomUUID()}.tmp`;
|
|
58
|
+
try {
|
|
59
|
+
writeFileSync(tmp, data, { encoding: opts.encoding ?? 'utf8' });
|
|
60
|
+
// chmod explicitly: writeFileSync's mode option is masked by umask, but a
|
|
61
|
+
// 0o600 secret file must be exactly 0o600 regardless of the host umask.
|
|
62
|
+
if (opts.mode != null) chmodSync(tmp, opts.mode);
|
|
63
|
+
renameSync(tmp, target);
|
|
64
|
+
} catch (err) {
|
|
65
|
+
try {
|
|
66
|
+
rmSync(tmp, { force: true });
|
|
67
|
+
} catch {
|
|
68
|
+
/* best-effort cleanup */
|
|
69
|
+
}
|
|
70
|
+
throw err;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
43
74
|
/**
|
|
44
75
|
* The moxxy home directory: `$MOXXY_HOME` when set, else `~/.moxxy`. Single
|
|
45
76
|
* source of truth so the env override is honored uniformly — previously half
|
package/src/index.ts
CHANGED
|
@@ -173,7 +173,7 @@ export type {
|
|
|
173
173
|
ViewTagSpec,
|
|
174
174
|
ViewRendererDef,
|
|
175
175
|
} from './view-renderer.js';
|
|
176
|
-
export { VIEW_PRIMITIVES, VIEW_COMPONENTS, DEFAULT_VIEW_TAGS, isSafeViewUrl } from './view-renderer.js';
|
|
176
|
+
export { VIEW_PRIMITIVES, VIEW_COMPONENTS, DEFAULT_VIEW_TAGS, isSafeViewUrl, countNodes } from './view-renderer.js';
|
|
177
177
|
export type {
|
|
178
178
|
TunnelProviderDef,
|
|
179
179
|
TunnelHandle,
|
|
@@ -183,8 +183,21 @@ export type {
|
|
|
183
183
|
} from './tunnel.js';
|
|
184
184
|
export { spawnCliTunnel, isCliTunnelAvailable } from './tunnel.js';
|
|
185
185
|
export { isRetryableError, toFriendlyError, zodToJsonSchema, estimateTextTokens, type StopReason } from './provider-utils.js';
|
|
186
|
-
export {
|
|
186
|
+
export {
|
|
187
|
+
writeFileAtomic,
|
|
188
|
+
writeFileAtomicSync,
|
|
189
|
+
moxxyHome,
|
|
190
|
+
moxxyPath,
|
|
191
|
+
type WriteFileAtomicOptions,
|
|
192
|
+
} from './fs-utils.js';
|
|
187
193
|
export { createMutex, type Mutex } from './mutex.js';
|
|
194
|
+
export {
|
|
195
|
+
createJsonFileStore,
|
|
196
|
+
type JsonFileStore,
|
|
197
|
+
type JsonFileStoreOptions,
|
|
198
|
+
} from './json-file-store.js';
|
|
199
|
+
export { assertNever } from './assert.js';
|
|
200
|
+
export { compareSemver, parseSemverCore } from './semver.js';
|
|
188
201
|
export { readRequestBody, bearerTokenMatches } from './http-utils.js';
|
|
189
202
|
export {
|
|
190
203
|
resolveChannelToken,
|
|
@@ -435,6 +448,13 @@ export {
|
|
|
435
448
|
type MoxxyPackageInput,
|
|
436
449
|
} from './schemas.js';
|
|
437
450
|
|
|
451
|
+
export {
|
|
452
|
+
parseFrontmatterFile,
|
|
453
|
+
parseFrontmatter,
|
|
454
|
+
renderFrontmatter,
|
|
455
|
+
type ParsedFrontmatter,
|
|
456
|
+
} from './frontmatter.js';
|
|
457
|
+
|
|
438
458
|
export {
|
|
439
459
|
getInstallHint,
|
|
440
460
|
type InstallHint,
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
import { mkdir, mkdtemp, readFile, readdir, rm, writeFile } from 'node:fs/promises';
|
|
2
|
+
import { tmpdir } from 'node:os';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
|
5
|
+
import { z } from 'zod';
|
|
6
|
+
import { createJsonFileStore } from './json-file-store.js';
|
|
7
|
+
|
|
8
|
+
interface Item {
|
|
9
|
+
id: string;
|
|
10
|
+
n: number;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const itemSchema = z.object({ id: z.string(), n: z.number() });
|
|
14
|
+
const fileSchema = z.object({ version: z.literal(1), items: z.array(itemSchema) });
|
|
15
|
+
|
|
16
|
+
/** A `load` that mirrors the scheduler policy: corrupt/invalid -> empty. */
|
|
17
|
+
function lenientLoad(raw: string | null): Item[] {
|
|
18
|
+
if (raw === null) return [];
|
|
19
|
+
try {
|
|
20
|
+
const parsed = fileSchema.safeParse(JSON.parse(raw));
|
|
21
|
+
return parsed.success ? [...parsed.data.items] : [];
|
|
22
|
+
} catch {
|
|
23
|
+
return [];
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
describe('createJsonFileStore', () => {
|
|
28
|
+
let dir: string;
|
|
29
|
+
let file: string;
|
|
30
|
+
|
|
31
|
+
beforeEach(async () => {
|
|
32
|
+
dir = await mkdtemp(join(tmpdir(), 'moxxy-jfs-'));
|
|
33
|
+
file = join(dir, 'sub', 'store.json');
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
afterEach(async () => {
|
|
37
|
+
await rm(dir, { recursive: true, force: true });
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
function store() {
|
|
41
|
+
return createJsonFileStore<Item>({ file, load: lenientLoad });
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
it('returns an empty list when the file is missing (ENOENT)', async () => {
|
|
45
|
+
expect(await store().read()).toEqual([]);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it('persists { version: 1, items } pretty-printed and creates parent dirs', async () => {
|
|
49
|
+
const s = store();
|
|
50
|
+
await s.mutate((items) => [...items, { id: 'a', n: 1 }]);
|
|
51
|
+
const raw = await readFile(file, 'utf8');
|
|
52
|
+
expect(JSON.parse(raw)).toEqual({ version: 1, items: [{ id: 'a', n: 1 }] });
|
|
53
|
+
// pretty-printed with 2-space indent
|
|
54
|
+
expect(raw).toContain('\n "version": 1');
|
|
55
|
+
// a fresh instance reads the persisted state back
|
|
56
|
+
expect(await store().read()).toEqual([{ id: 'a', n: 1 }]);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it('get() finds by id and returns null for misses', async () => {
|
|
60
|
+
const s = store();
|
|
61
|
+
await s.mutate((items) => [...items, { id: 'a', n: 1 }, { id: 'b', n: 2 }]);
|
|
62
|
+
expect(await s.get('b')).toEqual({ id: 'b', n: 2 });
|
|
63
|
+
expect(await s.get('zzz')).toBeNull();
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it('read() and the mutator each get a fresh copy (caller cannot mutate cache)', async () => {
|
|
67
|
+
const s = store();
|
|
68
|
+
await s.mutate((items) => [...items, { id: 'a', n: 1 }]);
|
|
69
|
+
|
|
70
|
+
// Mutating the array returned by read() must not affect the store.
|
|
71
|
+
const snap = await s.read();
|
|
72
|
+
snap.push({ id: 'evil', n: 99 });
|
|
73
|
+
expect(await s.read()).toEqual([{ id: 'a', n: 1 }]);
|
|
74
|
+
|
|
75
|
+
// The mutator receives a fresh slice; pushing into it then returning a
|
|
76
|
+
// DIFFERENT array means the push is discarded (proves it's a copy, not the
|
|
77
|
+
// live cache).
|
|
78
|
+
await s.mutate((items) => {
|
|
79
|
+
items.push({ id: 'discarded', n: -1 });
|
|
80
|
+
return [{ id: 'a', n: 2 }];
|
|
81
|
+
});
|
|
82
|
+
expect(await s.read()).toEqual([{ id: 'a', n: 2 }]);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it('serializes concurrent mutate() calls so neither clobbers the other', async () => {
|
|
86
|
+
const s = store();
|
|
87
|
+
// Fire many overlapping appends. Without the mutex these read the same
|
|
88
|
+
// baseline and the last write wins, dropping entries.
|
|
89
|
+
await Promise.all(
|
|
90
|
+
Array.from({ length: 25 }, (_, i) =>
|
|
91
|
+
s.mutate((items) => [...items, { id: `k${i}`, n: i }]),
|
|
92
|
+
),
|
|
93
|
+
);
|
|
94
|
+
const all = await s.read();
|
|
95
|
+
expect(all).toHaveLength(25);
|
|
96
|
+
expect(new Set(all.map((x) => x.id)).size).toBe(25);
|
|
97
|
+
// The on-disk file agrees (every write landed atomically).
|
|
98
|
+
const onDisk = JSON.parse(await readFile(file, 'utf8')) as { items: Item[] };
|
|
99
|
+
expect(onDisk.items).toHaveLength(25);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it('leaves the prior file intact when serializing the new state throws', async () => {
|
|
103
|
+
const s = store();
|
|
104
|
+
await s.mutate((items) => [...items, { id: 'a', n: 1 }]);
|
|
105
|
+
const before = await readFile(file, 'utf8');
|
|
106
|
+
|
|
107
|
+
// A value JSON.stringify cannot serialize (BigInt) makes the persist throw
|
|
108
|
+
// before any bytes hit disk; the previously-persisted file must survive.
|
|
109
|
+
await expect(
|
|
110
|
+
s.mutate(() => [{ id: 'bad', n: 1n as unknown as number }]),
|
|
111
|
+
).rejects.toThrow();
|
|
112
|
+
|
|
113
|
+
expect(await readFile(file, 'utf8')).toBe(before);
|
|
114
|
+
const leftovers = (await readdir(join(dir, 'sub'))).filter((n) => n.includes('.tmp'));
|
|
115
|
+
expect(leftovers).toEqual([]);
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it('leaves the prior file intact when the atomic rename fails mid-write', async () => {
|
|
119
|
+
const s = store();
|
|
120
|
+
await s.mutate((items) => [...items, { id: 'a', n: 1 }]);
|
|
121
|
+
const before = await readFile(file, 'utf8');
|
|
122
|
+
|
|
123
|
+
// Replace the parent dir's write path: make the TARGET a directory so the
|
|
124
|
+
// temp-file rename throws. We can't do that to `file` itself without losing
|
|
125
|
+
// the prior copy, so use a sibling store whose target is a directory and
|
|
126
|
+
// assert no temp file is orphaned. The original store's file is untouched.
|
|
127
|
+
const sib = join(dir, 'sub', 'sibling');
|
|
128
|
+
await mkdir(sib, { recursive: true });
|
|
129
|
+
const s2 = createJsonFileStore<Item>({ file: sib, load: lenientLoad });
|
|
130
|
+
await expect(s2.mutate((items) => [...items, { id: 'x', n: 1 }])).rejects.toThrow();
|
|
131
|
+
|
|
132
|
+
// No orphaned temp files, and the unrelated prior file is intact.
|
|
133
|
+
const leftovers = (await readdir(join(dir, 'sub'))).filter((n) => n.includes('.tmp'));
|
|
134
|
+
expect(leftovers).toEqual([]);
|
|
135
|
+
expect(await readFile(file, 'utf8')).toBe(before);
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it('lenient load resets a corrupt file to empty (scheduler policy)', async () => {
|
|
139
|
+
await mkdir(join(dir, 'sub'), { recursive: true });
|
|
140
|
+
await writeFile(file, '{ not json', 'utf8');
|
|
141
|
+
const s = store();
|
|
142
|
+
expect(await s.read()).toEqual([]);
|
|
143
|
+
// The bad file is left in place until the next write.
|
|
144
|
+
expect(await readFile(file, 'utf8')).toBe('{ not json');
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
it('lenient load resets a schema-mismatched file to empty', async () => {
|
|
148
|
+
await mkdir(join(dir, 'sub'), { recursive: true });
|
|
149
|
+
await writeFile(file, JSON.stringify({ version: 99, items: [] }), 'utf8');
|
|
150
|
+
expect(await store().read()).toEqual([]);
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
it('onReadError lets a store refuse to operate on a non-ENOENT read error', async () => {
|
|
154
|
+
// Point the store at a directory so readFile throws EISDIR (not ENOENT).
|
|
155
|
+
const asDir = join(dir, 'as-dir');
|
|
156
|
+
await mkdir(asDir, { recursive: true });
|
|
157
|
+
const s = createJsonFileStore<Item>({
|
|
158
|
+
file: asDir,
|
|
159
|
+
load: lenientLoad,
|
|
160
|
+
onReadError: (err) => {
|
|
161
|
+
throw new Error(`refusing: ${(err as NodeJS.ErrnoException).code}`);
|
|
162
|
+
},
|
|
163
|
+
});
|
|
164
|
+
await expect(s.read()).rejects.toThrow(/refusing:/);
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
it('re-throws a non-ENOENT read error by default (no onReadError)', async () => {
|
|
168
|
+
const asDir = join(dir, 'as-dir2');
|
|
169
|
+
await mkdir(asDir, { recursive: true });
|
|
170
|
+
const s = createJsonFileStore<Item>({ file: asDir, load: lenientLoad });
|
|
171
|
+
await expect(s.read()).rejects.toThrow();
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
it('honors a custom itemsKey, fileFields, stringify and writeOptions', async () => {
|
|
175
|
+
const f = join(dir, 'custom.json');
|
|
176
|
+
const s = createJsonFileStore<Item>({
|
|
177
|
+
file: f,
|
|
178
|
+
itemsKey: 'rows',
|
|
179
|
+
fileFields: {},
|
|
180
|
+
stringify: (obj) => JSON.stringify(obj) + '\n',
|
|
181
|
+
writeOptions: { mode: 0o600 },
|
|
182
|
+
load: (raw) => {
|
|
183
|
+
if (raw === null) return [];
|
|
184
|
+
const obj = JSON.parse(raw) as { rows: Item[] };
|
|
185
|
+
return obj.rows;
|
|
186
|
+
},
|
|
187
|
+
});
|
|
188
|
+
await s.mutate((items) => [...items, { id: 'a', n: 1 }]);
|
|
189
|
+
const raw = await readFile(f, 'utf8');
|
|
190
|
+
// versionless, name-key `rows`, compact, trailing newline
|
|
191
|
+
expect(raw).toBe('{"rows":[{"id":"a","n":1}]}\n');
|
|
192
|
+
const { stat } = await import('node:fs/promises');
|
|
193
|
+
expect((await stat(f)).mode & 0o777).toBe(0o600);
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
it('invalidate() forces a re-read from disk', async () => {
|
|
197
|
+
const s = store();
|
|
198
|
+
await s.mutate((items) => [...items, { id: 'a', n: 1 }]);
|
|
199
|
+
// Write a second entry directly to disk behind the store's back.
|
|
200
|
+
await writeFile(file, JSON.stringify({ version: 1, items: [{ id: 'b', n: 2 }] }), 'utf8');
|
|
201
|
+
// Stale cache still shows the first entry…
|
|
202
|
+
expect(await s.read()).toEqual([{ id: 'a', n: 1 }]);
|
|
203
|
+
s.invalidate();
|
|
204
|
+
// …until invalidated.
|
|
205
|
+
expect(await s.read()).toEqual([{ id: 'b', n: 2 }]);
|
|
206
|
+
});
|
|
207
|
+
});
|