@medicine-wheel/app 0.17.11 → 0.19.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +52 -0
- package/app/api/ceremonies/[id]/route.ts +3 -0
- package/app/api/ceremonies/route.ts +6 -0
- package/app/api/diary/[id]/route.ts +3 -0
- package/app/api/diary/route.ts +6 -0
- package/app/api/edges/route.ts +14 -1
- package/app/api/events/git/route.ts +23 -0
- package/app/api/events/publish/route.ts +42 -0
- package/app/api/events/route.ts +39 -0
- package/app/api/health/route.ts +5 -0
- package/app/api/memory/conclusions/[id]/route.ts +4 -0
- package/app/api/memory/conclusions/route.ts +5 -0
- package/app/api/narrative/beats/[id]/route.ts +6 -0
- package/app/api/narrative/beats/route.ts +9 -1
- package/app/api/narrative/cycles/route.ts +10 -3
- package/app/api/nodes/[id]/route.ts +7 -1
- package/app/api/nodes/route.ts +6 -0
- package/app/narrative/cycles/page.tsx +2 -0
- package/components/cycle-events.tsx +82 -0
- package/components/episode-file-tree.tsx +67 -0
- package/components/episode-files-panel.tsx +52 -18
- package/dist/cli/mw.js +103 -0
- package/instrumentation.ts +11 -0
- package/lib/episode-file-tree.ts +135 -0
- package/lib/event-log.ts +279 -0
- package/package.json +30 -27
package/lib/event-log.ts
ADDED
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The wheel's event log — what happened, kept beside the store (#164).
|
|
3
|
+
*
|
|
4
|
+
* The store keeps each record's latest state: a cycle amended twice is one
|
|
5
|
+
* line in `cycles.jsonl`, and the moment it was opened exists nowhere once it
|
|
6
|
+
* has passed. This log keeps those moments. Each write a route commits leaves
|
|
7
|
+
* one line in `events.jsonl` beside the store (`MW_EVENTS_FILE` overrides).
|
|
8
|
+
* What an event is, and which event a write is, lives in
|
|
9
|
+
* `@medicine-wheel/events` — the routes hand it before and after and record
|
|
10
|
+
* what it decides, and the MCP server's local store does the same.
|
|
11
|
+
*
|
|
12
|
+
* `source.instance` names this wheel as configured (`MW_INSTANCE`, e.g.
|
|
13
|
+
* `ilex:8040`). The address a request reached is not a name: behind
|
|
14
|
+
* `0.0.0.0` every wheel looks alike, and two browsers name the same wheel two
|
|
15
|
+
* ways.
|
|
16
|
+
*
|
|
17
|
+
* Publishing (episode 548's Send): with `MW_EVENTS_PUBLISH_URL` set, the log is
|
|
18
|
+
* sent there after every recorded event and every
|
|
19
|
+
* `MW_EVENTS_PUBLISH_INTERVAL_MS` (default one minute), from this wheel's
|
|
20
|
+
* cursor, with receipts beside the log. Unset, nothing leaves the machine.
|
|
21
|
+
*
|
|
22
|
+
* Carried by git, before any broker: with `MW_EVENTS_GIT_SYNC=push` (or
|
|
23
|
+
* `commit`), the wheel commits `events.jsonl` — that file only — in the data
|
|
24
|
+
* repository it lives in, about half a minute after events are recorded
|
|
25
|
+
* (`MW_EVENTS_GIT_DELAY_MS`), on start, and every ten minutes
|
|
26
|
+
* (`MW_EVENTS_GIT_INTERVAL_MS`), then pushes. A follower elsewhere pulls a
|
|
27
|
+
* clone and acts on what it has not seen (`mw-events follow`).
|
|
28
|
+
*
|
|
29
|
+
* Never the caller's error: a line that cannot be written is reported once on
|
|
30
|
+
* stderr with the record's id, and a publisher or a remote that cannot be
|
|
31
|
+
* reached is a receipt or a status, not a failed write.
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
import { buildEvent, filterEvents, type EventFilter, type WheelEvent, type WheelEventInput } from '@medicine-wheel/events';
|
|
35
|
+
import { appendEvent, eventLogPath, readEventLog } from '@medicine-wheel/events/log';
|
|
36
|
+
import {
|
|
37
|
+
drainLog,
|
|
38
|
+
httpPublisher,
|
|
39
|
+
publisherStatus,
|
|
40
|
+
readReceipts,
|
|
41
|
+
rewind,
|
|
42
|
+
type DrainResult,
|
|
43
|
+
type Publisher,
|
|
44
|
+
type PublisherStatus,
|
|
45
|
+
type Receipt,
|
|
46
|
+
} from '@medicine-wheel/events/publish';
|
|
47
|
+
import { commitEventLog, type CommitResult } from '@medicine-wheel/events/git';
|
|
48
|
+
import { resolveProjectDataDir } from '@/lib/jsonl-store';
|
|
49
|
+
|
|
50
|
+
/** Where the log lives: `MW_EVENTS_FILE`, else beside the wheel's JSONL store. */
|
|
51
|
+
export function eventLogFile(): string {
|
|
52
|
+
return eventLogPath(resolveProjectDataDir());
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** This wheel's name in `source.instance`, when one is configured. */
|
|
56
|
+
export function eventInstance(): string | undefined {
|
|
57
|
+
return process.env.MW_INSTANCE?.trim() || undefined;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Record what a decider returned (`null`: the write changed nothing, so
|
|
62
|
+
* nothing to record). Returns the event, or `undefined` when there was none or
|
|
63
|
+
* its line could not be written.
|
|
64
|
+
*/
|
|
65
|
+
export function recordEvent(input: WheelEventInput | null): WheelEvent | undefined {
|
|
66
|
+
if (!input) return undefined;
|
|
67
|
+
const file = eventLogFile();
|
|
68
|
+
let event: WheelEvent;
|
|
69
|
+
try {
|
|
70
|
+
event = buildEvent(input, { instance: eventInstance() });
|
|
71
|
+
appendEvent(file, event);
|
|
72
|
+
} catch (error) {
|
|
73
|
+
console.error(
|
|
74
|
+
`[events] medicine-wheel.${input.name} for ${input.subject.kind} ${input.subject.id} was not written to ${file}: ${(error as Error)?.message ?? String(error)}`,
|
|
75
|
+
);
|
|
76
|
+
return undefined;
|
|
77
|
+
}
|
|
78
|
+
kickPublisher();
|
|
79
|
+
kickGitSync();
|
|
80
|
+
return event;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Every event, oldest first. */
|
|
84
|
+
export function readEvents(): WheelEvent[] {
|
|
85
|
+
return readEventLog(eventLogFile());
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export { filterEvents, type EventFilter };
|
|
89
|
+
|
|
90
|
+
// ── publishing ──────────────────────────────────────────────────────────────
|
|
91
|
+
|
|
92
|
+
/** The publisher `MW_EVENTS_PUBLISH_URL` names, or `null`: nothing leaves the machine. */
|
|
93
|
+
export function publisherFromEnv(): Publisher | null {
|
|
94
|
+
const url = process.env.MW_EVENTS_PUBLISH_URL?.trim();
|
|
95
|
+
if (!url) return null;
|
|
96
|
+
return httpPublisher({
|
|
97
|
+
url,
|
|
98
|
+
token: process.env.MW_EVENTS_PUBLISH_TOKEN?.trim() || undefined,
|
|
99
|
+
name: process.env.MW_EVENTS_PUBLISH_NAME?.trim() || 'http',
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// One pass at a time across every copy of this module: Next compiles
|
|
104
|
+
// `instrumentation.ts` and the routes into separate bundles, and two passes
|
|
105
|
+
// over the same cursor would send the same event twice.
|
|
106
|
+
const STATE = Symbol.for('@medicine-wheel/app.event-publisher');
|
|
107
|
+
type PublishState = { running?: Promise<DrainResult | null>; again?: boolean; timer?: ReturnType<typeof setInterval>; down?: boolean };
|
|
108
|
+
const state: PublishState = ((globalThis as Record<symbol, PublishState>)[STATE] ??= {});
|
|
109
|
+
|
|
110
|
+
async function pass(): Promise<DrainResult | null> {
|
|
111
|
+
const publisher = publisherFromEnv();
|
|
112
|
+
if (!publisher) return null;
|
|
113
|
+
try {
|
|
114
|
+
const result = await drainLog(eventLogFile(), publisher);
|
|
115
|
+
// A destination that stays down is one line when it goes, one when it returns.
|
|
116
|
+
if (result.stopped && !state.down) {
|
|
117
|
+
console.error(`[events] publishing to ${publisher.name} stopped: ${result.error} — ${result.pending} event(s) waiting`);
|
|
118
|
+
} else if (!result.stopped && state.down) {
|
|
119
|
+
console.log(`[events] publishing to ${publisher.name} resumed; ${result.arrived} event(s) arrived`);
|
|
120
|
+
}
|
|
121
|
+
state.down = result.stopped;
|
|
122
|
+
return result;
|
|
123
|
+
} catch (error) {
|
|
124
|
+
console.error(`[events] publishing pass failed: ${(error as Error)?.message ?? String(error)}`);
|
|
125
|
+
return null;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** Send what waits. A pass already running is joined, and runs once more after it for what arrived meanwhile. */
|
|
130
|
+
export function publishPending(): Promise<DrainResult | null> {
|
|
131
|
+
if (state.running) {
|
|
132
|
+
state.again = true;
|
|
133
|
+
return state.running;
|
|
134
|
+
}
|
|
135
|
+
const run = pass().then(async (result) => {
|
|
136
|
+
while (state.again) {
|
|
137
|
+
state.again = false;
|
|
138
|
+
result = (await pass()) ?? result;
|
|
139
|
+
}
|
|
140
|
+
return result;
|
|
141
|
+
}).finally(() => {
|
|
142
|
+
if (state.running === run) state.running = undefined;
|
|
143
|
+
});
|
|
144
|
+
state.running = run;
|
|
145
|
+
return run;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** After a recorded event: send it soon, in the background. Nothing when no publisher is set. */
|
|
149
|
+
export function kickPublisher(): void {
|
|
150
|
+
if (!process.env.MW_EVENTS_PUBLISH_URL?.trim()) return;
|
|
151
|
+
void publishPending();
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Start the publishing clock: one pass now, then one every
|
|
156
|
+
* `MW_EVENTS_PUBLISH_INTERVAL_MS` (default one minute). Called once from
|
|
157
|
+
* `instrumentation.ts`; a second call is a no-op, and without a publisher
|
|
158
|
+
* there is nothing to send.
|
|
159
|
+
*/
|
|
160
|
+
export function startEventPublishing(intervalMs = Number(process.env.MW_EVENTS_PUBLISH_INTERVAL_MS) || 60_000): void {
|
|
161
|
+
if (state.timer || !publisherFromEnv()) return;
|
|
162
|
+
state.timer = setInterval(() => { void publishPending(); }, intervalMs);
|
|
163
|
+
state.timer.unref?.();
|
|
164
|
+
void publishPending();
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** Replay: the next pass sends `fromEventId` and everything after it again (the whole log without one). */
|
|
168
|
+
export function replayEvents(fromEventId?: string): { pending: number } | null {
|
|
169
|
+
const publisher = publisherFromEnv();
|
|
170
|
+
if (!publisher) return null;
|
|
171
|
+
return rewind(eventLogFile(), publisher.name, fromEventId);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/** Where the configured publisher stands, and its latest receipts. */
|
|
175
|
+
export function eventPublisherReport(limit = 20): { publisher: PublisherStatus; receipts: Receipt[] } | null {
|
|
176
|
+
const publisher = publisherFromEnv();
|
|
177
|
+
if (!publisher) return null;
|
|
178
|
+
const file = eventLogFile();
|
|
179
|
+
return { publisher: publisherStatus(file, publisher.name), receipts: readReceipts(file, publisher.name).slice(-limit).reverse() };
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// ── carried by git ──────────────────────────────────────────────────────────
|
|
183
|
+
|
|
184
|
+
export type GitSyncMode = 'off' | 'commit' | 'push';
|
|
185
|
+
|
|
186
|
+
/** `MW_EVENTS_GIT_SYNC`: `push` (or `1`, `true`), `commit`, else off. */
|
|
187
|
+
export function gitSyncMode(): GitSyncMode {
|
|
188
|
+
const value = process.env.MW_EVENTS_GIT_SYNC?.trim().toLowerCase();
|
|
189
|
+
if (value === 'push' || value === '1' || value === 'true') return 'push';
|
|
190
|
+
if (value === 'commit') return 'commit';
|
|
191
|
+
return 'off';
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
const GIT = Symbol.for('@medicine-wheel/app.event-git');
|
|
195
|
+
type GitState = {
|
|
196
|
+
running?: Promise<CommitResult | null>;
|
|
197
|
+
again?: boolean;
|
|
198
|
+
timer?: ReturnType<typeof setTimeout>;
|
|
199
|
+
interval?: ReturnType<typeof setInterval>;
|
|
200
|
+
failing?: boolean;
|
|
201
|
+
last?: CommitResult & { at: string };
|
|
202
|
+
};
|
|
203
|
+
const gitState: GitState = ((globalThis as Record<symbol, GitState>)[GIT] ??= {});
|
|
204
|
+
|
|
205
|
+
async function gitPass(): Promise<CommitResult | null> {
|
|
206
|
+
const mode = gitSyncMode();
|
|
207
|
+
if (mode === 'off') return null;
|
|
208
|
+
const result = await commitEventLog(eventLogFile(), { push: mode === 'push', instance: eventInstance() });
|
|
209
|
+
gitState.last = { ...result, at: new Date().toISOString() };
|
|
210
|
+
if (result.error && !gitState.failing) {
|
|
211
|
+
console.error(`[events] git carriage stopped: ${result.error} — the log keeps growing and the next pass tries again`);
|
|
212
|
+
} else if (!result.error && gitState.failing) {
|
|
213
|
+
console.log('[events] git carriage resumed');
|
|
214
|
+
}
|
|
215
|
+
if (result.committed) {
|
|
216
|
+
console.log(`[events] committed ${result.events} event(s) in ${result.commit?.slice(0, 8)}${result.pushed ? ', pushed' : ''}`);
|
|
217
|
+
}
|
|
218
|
+
gitState.failing = Boolean(result.error);
|
|
219
|
+
return result;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/** Commit (and push) the log now. A pass already running is joined, and runs once more after it. */
|
|
223
|
+
export function syncEventsToGit(): Promise<CommitResult | null> {
|
|
224
|
+
if (gitState.running) {
|
|
225
|
+
gitState.again = true;
|
|
226
|
+
return gitState.running;
|
|
227
|
+
}
|
|
228
|
+
const run = gitPass().then(async (result) => {
|
|
229
|
+
while (gitState.again) {
|
|
230
|
+
gitState.again = false;
|
|
231
|
+
result = (await gitPass()) ?? result;
|
|
232
|
+
}
|
|
233
|
+
return result;
|
|
234
|
+
}).finally(() => {
|
|
235
|
+
if (gitState.running === run) gitState.running = undefined;
|
|
236
|
+
});
|
|
237
|
+
gitState.running = run;
|
|
238
|
+
return run;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/** After a recorded event: commit soon, so a burst of writes travels as one commit. */
|
|
242
|
+
export function kickGitSync(): void {
|
|
243
|
+
if (gitSyncMode() === 'off' || gitState.timer) return;
|
|
244
|
+
gitState.timer = setTimeout(() => {
|
|
245
|
+
gitState.timer = undefined;
|
|
246
|
+
void syncEventsToGit();
|
|
247
|
+
}, Number(process.env.MW_EVENTS_GIT_DELAY_MS) || 30_000);
|
|
248
|
+
gitState.timer.unref?.();
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/** Start the carriage clock from `instrumentation.ts`: a pass now, then every ten minutes. */
|
|
252
|
+
export function startEventGitSync(intervalMs = Number(process.env.MW_EVENTS_GIT_INTERVAL_MS) || 10 * 60_000): void {
|
|
253
|
+
if (gitSyncMode() === 'off' || gitState.interval) return;
|
|
254
|
+
gitState.interval = setInterval(() => { void syncEventsToGit(); }, intervalMs);
|
|
255
|
+
gitState.interval.unref?.();
|
|
256
|
+
void syncEventsToGit();
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/** The carriage's mode and its last pass. */
|
|
260
|
+
export function eventGitStatus(): { mode: GitSyncMode; last?: GitState['last'] } {
|
|
261
|
+
return { mode: gitSyncMode(), ...(gitState.last ? { last: gitState.last } : {}) };
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/** What `/api/health` reports: how many events, the last one, what the publisher owes, how the git carriage stands. */
|
|
265
|
+
export function eventLogStatus(): { count: number; last_at?: string; publisher?: PublisherStatus; git?: ReturnType<typeof eventGitStatus> } | { error: string } {
|
|
266
|
+
try {
|
|
267
|
+
const events = readEvents();
|
|
268
|
+
const last = events[events.length - 1];
|
|
269
|
+
const publisher = publisherFromEnv();
|
|
270
|
+
return {
|
|
271
|
+
count: events.length,
|
|
272
|
+
...(last ? { last_at: last.observed_at } : {}),
|
|
273
|
+
...(publisher ? { publisher: publisherStatus(eventLogFile(), publisher.name) } : {}),
|
|
274
|
+
...(gitSyncMode() !== 'off' ? { git: eventGitStatus() } : {}),
|
|
275
|
+
};
|
|
276
|
+
} catch (error) {
|
|
277
|
+
return { error: (error as Error)?.message ?? String(error) };
|
|
278
|
+
}
|
|
279
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@medicine-wheel/app",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.19.0",
|
|
4
4
|
"description": "Medicine Wheel — Interactive visual layer for Indigenous relational research with Four Directions, ceremonies, and narrative arcs",
|
|
5
5
|
"bin": {
|
|
6
6
|
"mw": "dist/cli/mw.js",
|
|
@@ -28,6 +28,7 @@
|
|
|
28
28
|
"workspaces": [
|
|
29
29
|
"src/ontology-core",
|
|
30
30
|
"src/infra",
|
|
31
|
+
"src/events",
|
|
31
32
|
"src/creative-orientation",
|
|
32
33
|
"src/session-reader",
|
|
33
34
|
"src/ceremony-protocol",
|
|
@@ -91,32 +92,33 @@
|
|
|
91
92
|
"release:major": "npm run version:major && npm run publish:all && npm run release:commit"
|
|
92
93
|
},
|
|
93
94
|
"dependencies": {
|
|
94
|
-
"@medicine-wheel/ceremonial-diary": "^0.
|
|
95
|
-
"@medicine-wheel/ceremony-protocol": "^0.
|
|
96
|
-
"@medicine-wheel/community-identity": "^0.
|
|
97
|
-
"@medicine-wheel/community-review": "^0.
|
|
98
|
-
"@medicine-wheel/consent-lifecycle": "^0.
|
|
99
|
-
"@medicine-wheel/creative-orientation": "^0.
|
|
100
|
-
"@medicine-wheel/data-store": "^0.
|
|
101
|
-
"@medicine-wheel/data-store-postgres": "^0.
|
|
102
|
-
"@medicine-wheel/
|
|
103
|
-
"@medicine-wheel/
|
|
104
|
-
"@medicine-wheel/
|
|
105
|
-
"@medicine-wheel/
|
|
106
|
-
"@medicine-wheel/
|
|
107
|
-
"@medicine-wheel/
|
|
108
|
-
"@medicine-wheel/
|
|
109
|
-
"@medicine-wheel/
|
|
110
|
-
"@medicine-wheel/narrative-
|
|
111
|
-
"@medicine-wheel/
|
|
112
|
-
"@medicine-wheel/
|
|
113
|
-
"@medicine-wheel/
|
|
114
|
-
"@medicine-wheel/
|
|
115
|
-
"@medicine-wheel/relational-
|
|
116
|
-
"@medicine-wheel/
|
|
117
|
-
"@medicine-wheel/
|
|
118
|
-
"@medicine-wheel/
|
|
119
|
-
"@medicine-wheel/
|
|
95
|
+
"@medicine-wheel/ceremonial-diary": "^0.19.0",
|
|
96
|
+
"@medicine-wheel/ceremony-protocol": "^0.19.0",
|
|
97
|
+
"@medicine-wheel/community-identity": "^0.19.0",
|
|
98
|
+
"@medicine-wheel/community-review": "^0.19.0",
|
|
99
|
+
"@medicine-wheel/consent-lifecycle": "^0.19.0",
|
|
100
|
+
"@medicine-wheel/creative-orientation": "^0.19.0",
|
|
101
|
+
"@medicine-wheel/data-store": "^0.19.0",
|
|
102
|
+
"@medicine-wheel/data-store-postgres": "^0.19.0",
|
|
103
|
+
"@medicine-wheel/events": "^0.19.0",
|
|
104
|
+
"@medicine-wheel/fire-keeper": "^0.19.0",
|
|
105
|
+
"@medicine-wheel/github-ceremony": "^0.19.0",
|
|
106
|
+
"@medicine-wheel/graph-viz": "^0.19.0",
|
|
107
|
+
"@medicine-wheel/honcho": "^0.19.0",
|
|
108
|
+
"@medicine-wheel/importance-unit": "^0.19.0",
|
|
109
|
+
"@medicine-wheel/mcp": "^4.19.0",
|
|
110
|
+
"@medicine-wheel/memory": "^0.19.0",
|
|
111
|
+
"@medicine-wheel/narrative-cluster": "^0.19.0",
|
|
112
|
+
"@medicine-wheel/narrative-engine": "^0.19.0",
|
|
113
|
+
"@medicine-wheel/ontology-core": "^0.19.0",
|
|
114
|
+
"@medicine-wheel/perception-layer": "^0.19.0",
|
|
115
|
+
"@medicine-wheel/prompt-decomposition": "^0.19.0",
|
|
116
|
+
"@medicine-wheel/relational-index": "^0.19.0",
|
|
117
|
+
"@medicine-wheel/relational-query": "^0.19.0",
|
|
118
|
+
"@medicine-wheel/session-reader": "^0.19.0",
|
|
119
|
+
"@medicine-wheel/storage-provider": "^0.19.0",
|
|
120
|
+
"@medicine-wheel/transformation-tracker": "^0.19.0",
|
|
121
|
+
"@medicine-wheel/ui-components": "^0.19.0",
|
|
120
122
|
"@miadi/episode-vessel": "^0.2.1",
|
|
121
123
|
"@neondatabase/serverless": "^0.10.0",
|
|
122
124
|
"@xyflow/react": "^12.3.0",
|
|
@@ -138,6 +140,7 @@
|
|
|
138
140
|
"@types/node": "^22.0.0",
|
|
139
141
|
"@types/react": "^19.0.0",
|
|
140
142
|
"@types/react-dom": "^19.0.0",
|
|
143
|
+
"ajv": "^6.12.6",
|
|
141
144
|
"eslint": "^9.39.5",
|
|
142
145
|
"eslint-config-next": "^15.5.25",
|
|
143
146
|
"shx": "^0.4.0",
|