@pipelab/core-node 1.0.0-beta.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 (48) hide show
  1. package/CHANGELOG.md +13 -0
  2. package/LICENSE +110 -0
  3. package/README.md +10 -0
  4. package/dist/index.d.mts +416 -0
  5. package/dist/index.d.mts.map +1 -0
  6. package/dist/index.mjs +52612 -0
  7. package/dist/index.mjs.map +1 -0
  8. package/package.json +64 -0
  9. package/src/api.ts +115 -0
  10. package/src/config.ts +105 -0
  11. package/src/context.ts +68 -0
  12. package/src/handler-func.ts +234 -0
  13. package/src/handlers/agents.ts +32 -0
  14. package/src/handlers/auth.ts +95 -0
  15. package/src/handlers/build-history.ts +381 -0
  16. package/src/handlers/config.ts +109 -0
  17. package/src/handlers/engine.ts +229 -0
  18. package/src/handlers/fs.ts +97 -0
  19. package/src/handlers/history.ts +329 -0
  20. package/src/handlers/index.ts +41 -0
  21. package/src/handlers/shell.ts +57 -0
  22. package/src/handlers/system.ts +18 -0
  23. package/src/handlers.ts +2 -0
  24. package/src/heavy.ts +4 -0
  25. package/src/index.ts +16 -0
  26. package/src/ipc-core.ts +77 -0
  27. package/src/migrations.ts +72 -0
  28. package/src/paths.ts +1 -0
  29. package/src/plugins-registry.ts +78 -0
  30. package/src/presets/c3toSteam.ts +272 -0
  31. package/src/presets/demo.ts +123 -0
  32. package/src/presets/if.ts +69 -0
  33. package/src/presets/list.ts +30 -0
  34. package/src/presets/loop.ts +65 -0
  35. package/src/presets/moreToCome.ts +32 -0
  36. package/src/presets/newProject.ts +31 -0
  37. package/src/presets/preset.model.ts +0 -0
  38. package/src/presets/test-c3-offline.ts +78 -0
  39. package/src/presets/test-c3-unzip.ts +124 -0
  40. package/src/runner.ts +160 -0
  41. package/src/server.ts +99 -0
  42. package/src/types/runner.ts +101 -0
  43. package/src/utils/fs-extras.ts +211 -0
  44. package/src/utils/remote.ts +497 -0
  45. package/src/utils/storage.ts +99 -0
  46. package/src/utils.ts +268 -0
  47. package/src/websocket-server.ts +288 -0
  48. package/tsconfig.json +19 -0
@@ -0,0 +1,497 @@
1
+ import { dirname, delimiter, join } from "node:path";
2
+ import { tmpdir } from "node:os";
3
+ import { mkdir, readdir, readFile, writeFile, access, chmod, rm, cp } from "node:fs/promises";
4
+ import { existsSync, constants } from "node:fs";
5
+ import pacote from "pacote";
6
+ import semver from "semver";
7
+ import { isDev, projectRoot, PipelabContext } from "../context";
8
+ import { execa } from "execa";
9
+ import { sendStartupProgress } from "../server";
10
+ import { downloadFile, extractZip, extractTarGz, generateTempFolder } from "./fs-extras";
11
+
12
+ export const DEFAULT_NODE_VERSION = "24.14.1";
13
+ export const DEFAULT_PNPM_VERSION = "10.12.0";
14
+
15
+ /**
16
+ * In-memory lock to prevent concurrent operations on the same resource (e.g., downloading Node.js).
17
+ */
18
+ const activeOperations = new Map<string, Promise<any>>();
19
+ const packumentRequests = new Map<string, Promise<any>>();
20
+
21
+ async function withLock<T>(key: string, fn: () => Promise<T>): Promise<T> {
22
+ const existing = activeOperations.get(key);
23
+ if (existing) {
24
+ console.log(`[Lock] Waiting for concurrent operation on: ${key}`);
25
+ return existing;
26
+ }
27
+
28
+ const promise = (async () => {
29
+ try {
30
+ return await fn();
31
+ } finally {
32
+ activeOperations.delete(key);
33
+ }
34
+ })();
35
+
36
+ activeOperations.set(key, promise);
37
+ return promise;
38
+ }
39
+
40
+ export type FetchOptions = {
41
+ installDeps?: boolean;
42
+ signal?: AbortSignal;
43
+ context: PipelabContext;
44
+ };
45
+
46
+ /**
47
+ * Robust utility to fetch, cache, and resolve an NPM package.
48
+ * Centralized in core-node to avoid circular dependencies.
49
+ */
50
+ export async function fetchPackage(
51
+ packageName: string,
52
+ versionOrRange: string,
53
+ options: FetchOptions,
54
+ ): Promise<{
55
+ packageDir: string;
56
+ resolvedVersion: string;
57
+ isLocal?: boolean;
58
+ entryPoint?: string;
59
+ }> {
60
+ // 0. Check for local monorepo package in development
61
+ if (isDev && projectRoot && process.env.PIPELAB_FORCE_NPM !== "true") {
62
+ if (packageName.startsWith("@pipelab/")) {
63
+ const local = await tryResolveMonorepoPackage(packageName);
64
+ if (local) {
65
+ console.log(`[Fetcher] ${packageName}: Resolved to local source at ${local.packageDir}`);
66
+ return {
67
+ ...local,
68
+ resolvedVersion: "workspace",
69
+ };
70
+ }
71
+ }
72
+ }
73
+
74
+ const ctx = options.context;
75
+ const baseDir = ctx.getPackagesPath(packageName);
76
+ let resolvedVersion: string;
77
+
78
+ console.log(`[Fetcher] Resolving ${packageName}@${versionOrRange || "latest"}...`);
79
+
80
+ try {
81
+ // 1. Resolve version/range using npm with session-wide memoization and disk cache
82
+ const cachePath = join(ctx.userDataPath, "cache", "pacote");
83
+ let packumentPromise = packumentRequests.get(packageName);
84
+ if (!packumentPromise) {
85
+ packumentPromise = pacote.packument(packageName, { cache: cachePath });
86
+ packumentRequests.set(packageName, packumentPromise);
87
+ }
88
+
89
+ const packument = await packumentPromise;
90
+ const versions = Object.keys(packument.versions);
91
+ const range = versionOrRange || "latest";
92
+
93
+ // Prioritize tags (like 'latest', 'beta', etc.) over semver ranges
94
+ const foundVersion = packument["dist-tags"]?.[range] || semver.maxSatisfying(versions, range);
95
+
96
+ if (!foundVersion) {
97
+ throw new Error(`Package ${packageName}@${range} not found on npm (available tags: ${Object.keys(packument["dist-tags"] || {}).join(", ")})`);
98
+ }
99
+ resolvedVersion = foundVersion;
100
+ console.log(`[Fetcher] ${packageName}: Resolved to v${resolvedVersion} via npm`);
101
+ } catch (error) {
102
+ console.warn(`[Fetcher] ${packageName}: remote resolution failed, trying local fallback...`);
103
+ const fallbackVersion = await tryLocalFallback(versionOrRange, error, baseDir, packageName);
104
+ if (fallbackVersion) {
105
+ resolvedVersion = fallbackVersion;
106
+ } else {
107
+ throw error;
108
+ }
109
+ }
110
+
111
+ const cachePath = join(ctx.userDataPath, "cache", "pacote");
112
+ const packageDir = join(baseDir, resolvedVersion);
113
+
114
+ // If the package already exists and we don't need to install dependencies, return immediately
115
+ if (existsSync(packageDir) && !options?.installDeps) {
116
+ return { packageDir, resolvedVersion };
117
+ }
118
+
119
+ const lockKey = `package:${packageName}:${resolvedVersion}`;
120
+ return withLock(lockKey, async () => {
121
+ if (!existsSync(packageDir)) {
122
+ console.log(`[Fetcher] ${packageName}@${resolvedVersion}: Downloading to ${packageDir}...`);
123
+ await mkdir(packageDir, { recursive: true });
124
+ await pacote.extract(`${packageName}@${resolvedVersion}`, packageDir, {
125
+ cache: cachePath,
126
+ });
127
+ }
128
+
129
+ // 2. Resolve entry point from package.json for downloaded package
130
+ const entryPoint = await resolveEntryPoint(packageDir, packageName);
131
+
132
+ if (options?.installDeps) {
133
+ await installDependencies(packageDir, packageName, options);
134
+ }
135
+
136
+ return { packageDir, resolvedVersion, entryPoint };
137
+ });
138
+ }
139
+
140
+ /**
141
+ * Executes a pnpm install in a specific directory with a portable environment.
142
+ */
143
+ export async function runPnpm(
144
+ cwd: string,
145
+ options: {
146
+ args?: string[];
147
+ extraEnv?: Record<string, string>;
148
+ signal?: AbortSignal;
149
+ context: PipelabContext;
150
+ },
151
+ ) {
152
+ const {
153
+ args = ["install", "--prod", "--no-lockfile", "--prefer-offline", "--no-verify-store-integrity"],
154
+ extraEnv = {},
155
+ signal,
156
+ context: ctx,
157
+ } = options;
158
+
159
+ const [nodePath, pnpmPath] = await Promise.all([
160
+ ensureNodeJS(ctx).catch(() => process.execPath),
161
+ ensurePNPM(ctx).catch(() => "pnpm"),
162
+ ]);
163
+
164
+ const isScript = pnpmPath.endsWith(".cjs") || pnpmPath.endsWith(".js");
165
+ const command = isScript ? nodePath : pnpmPath;
166
+ const finalArgs = isScript ? [pnpmPath, ...args] : args;
167
+
168
+ return execa(command, finalArgs, {
169
+ cwd,
170
+ all: true,
171
+ cancelSignal: signal,
172
+ env: {
173
+ ...process.env,
174
+ NODE_ENV: "production",
175
+ PATH: nodePath ? `${dirname(nodePath)}${delimiter}${process.env.PATH}` : process.env.PATH,
176
+ PNPM_HOME: join(ctx.userDataPath, "pnpm"),
177
+ PNPM_ONLY_ALLOW_TRUSTED_DEPENDENCIES: "false",
178
+ ...extraEnv,
179
+ },
180
+ });
181
+ }
182
+
183
+ /**
184
+ * Installs a specific version of Node.js if not already present.
185
+ */
186
+ export async function ensureNodeJS(
187
+ context: PipelabContext,
188
+ version = DEFAULT_NODE_VERSION,
189
+ ) {
190
+ const isWindows = process.platform === "win32";
191
+ const nodeDir = context.getThirdPartyPath("node", version);
192
+ const finalNodePath = join(nodeDir, isWindows ? "node.exe" : "bin/node");
193
+
194
+ if (existsSync(finalNodePath)) {
195
+ return finalNodePath;
196
+ }
197
+
198
+ const lockKey = `node:${version}`;
199
+ return withLock(lockKey, async () => {
200
+ if (existsSync(finalNodePath)) return finalNodePath;
201
+
202
+ const arch = process.arch === "x64" ? "x64" : process.arch === "arm64" ? "arm64" : "x86";
203
+ const platform = isWindows ? "win" : process.platform === "darwin" ? "osx" : "linux";
204
+ const extension = isWindows ? "zip" : "tar.gz";
205
+ const downloadPlatform = platform === "osx" ? "darwin" : platform;
206
+
207
+ const fileName = `node-v${version}-${downloadPlatform}-${arch}.${extension}`;
208
+ const downloadUrl = `https://nodejs.org/dist/v${version}/${fileName}`;
209
+ const tempDir = await generateTempFolder(tmpdir());
210
+ const archivePath = join(tempDir, fileName);
211
+
212
+ sendStartupProgress(`Downloading Node.js v${version}...`);
213
+ console.log(`Downloading Node.js from ${downloadUrl}...`);
214
+ await downloadFile(downloadUrl, archivePath);
215
+
216
+ sendStartupProgress(`Extracting Node.js v${version}...`);
217
+ console.log(`Extracting Node.js to ${tempDir}...`);
218
+ const extractTempDir = join(tempDir, "extracted");
219
+ await mkdir(extractTempDir, { recursive: true });
220
+
221
+ if (extension === "zip") {
222
+ await extractZip(archivePath, extractTempDir);
223
+ } else {
224
+ await extractTarGz(archivePath, extractTempDir);
225
+ }
226
+
227
+ const extractedEntries = await readdir(extractTempDir);
228
+ const nodeSubDir = extractedEntries.find((entry) => entry.startsWith(`node-v${version}`));
229
+ if (!nodeSubDir) throw new Error(`Could not find extracted Node.js directory`);
230
+
231
+ const sourceDir = join(extractTempDir, nodeSubDir);
232
+ await mkdir(dirname(nodeDir), { recursive: true });
233
+ await rm(nodeDir, { recursive: true, force: true });
234
+ await cp(sourceDir, nodeDir, { recursive: true });
235
+ await rm(tempDir, { recursive: true, force: true });
236
+
237
+ if (!isWindows) await chmod(finalNodePath, 0o755).catch(() => { });
238
+ return finalNodePath;
239
+ });
240
+ }
241
+
242
+ /**
243
+ * Installs the PNPM package from npm if not already present.
244
+ */
245
+ export async function ensurePNPM(
246
+ context: PipelabContext,
247
+ version = DEFAULT_PNPM_VERSION,
248
+ ) {
249
+ const pnpmDir = context.getPackagesPath("pnpm", version);
250
+ const pnpmPath = join(pnpmDir, "bin", "pnpm.cjs");
251
+
252
+ if (existsSync(pnpmPath)) {
253
+ return pnpmPath;
254
+ }
255
+
256
+ const lockKey = `pnpm:${version}`;
257
+ return withLock(lockKey, async () => {
258
+ if (existsSync(pnpmPath)) return pnpmPath;
259
+ sendStartupProgress(`Checking PNPM v${version}...`);
260
+ const { packageDir } = await fetchPackage("pnpm", version, {
261
+ context,
262
+ });
263
+ return join(packageDir, "bin", "pnpm.cjs");
264
+ });
265
+ }
266
+
267
+ async function installDependencies(packageDir: string, packageName: string, options: FetchOptions) {
268
+ const nodeModulesPath = join(packageDir, "node_modules");
269
+
270
+ if (existsSync(nodeModulesPath)) {
271
+ try {
272
+ const files = await readdir(nodeModulesPath);
273
+ if (files.length === 0) {
274
+ console.warn(`[Fetcher] ${packageName}: node_modules exists but is empty. Re-installing...`);
275
+ }
276
+ } catch (e) {
277
+ // Continue to install if readdir fails
278
+ }
279
+ }
280
+
281
+ console.log(`[Fetcher] ${packageName}: Ensuring dependencies are installed...`);
282
+ try {
283
+ const { all } = await runPnpm(packageDir, {
284
+ signal: options.signal,
285
+ context: options.context,
286
+ });
287
+
288
+ if (all) console.log(`[Fetcher] ${packageName}: Installation trace:\n${all}`);
289
+ console.log(`[Fetcher] ${packageName}: Dependencies installed successfully.`);
290
+ } catch (err: any) {
291
+ console.error(
292
+ `[Fetcher] ${packageName}: CRITICAL ERROR during dependency installation: ${err.message}`,
293
+ );
294
+ if (err.all) console.error(`[Fetcher] ${packageName}: Error details:\n${err.all}`);
295
+ throw new Error(`Failed to install dependencies for ${packageName}. See logs for details.`);
296
+ }
297
+ }
298
+
299
+ export async function fetchPipelabAsset(
300
+ packageName: string,
301
+ versionOrRange: string,
302
+ options: FetchOptions,
303
+ ): Promise<string> {
304
+ if (isDev && projectRoot) {
305
+ const assetId = packageName.replace("@pipelab/asset-", "");
306
+ const localPath = join(projectRoot, "assets", `asset-${assetId}`);
307
+ if (existsSync(localPath)) return localPath;
308
+ }
309
+ const { packageDir } = await fetchPackage(packageName, versionOrRange, options);
310
+ return packageDir;
311
+ }
312
+
313
+ export async function fetchPipelabPlugin(
314
+ pluginName: string,
315
+ versionOrRange: string,
316
+ options: FetchOptions,
317
+ ): Promise<{ packageDir: string; entryPoint: string; isLocal: boolean }> {
318
+ const { packageDir, isLocal, entryPoint } = await fetchPackage(pluginName, versionOrRange, {
319
+ installDeps: true,
320
+ ...options,
321
+ });
322
+
323
+ // Default entry point if not provided by fetchPackage
324
+ let finalEntryPoint = entryPoint;
325
+ if (!finalEntryPoint) {
326
+ const patterns = [
327
+ join(packageDir, "dist", "index.mjs"),
328
+ join(packageDir, "index.mjs"),
329
+ ];
330
+ finalEntryPoint = patterns.find((p) => existsSync(p)) || patterns[0];
331
+ }
332
+
333
+ return { packageDir, entryPoint: finalEntryPoint, isLocal: !!isLocal };
334
+ }
335
+
336
+ export async function fetchPipelabCli(
337
+ versionOrRange: string,
338
+ options: FetchOptions,
339
+ ): Promise<{ packageDir: string; entryPoint: string; isLocal: boolean }> {
340
+ const { packageDir, isLocal, entryPoint } = await fetchPackage(
341
+ "@pipelab/cli",
342
+ versionOrRange,
343
+ options,
344
+ );
345
+
346
+ // Default entry point for CLI if not provided
347
+ let finalEntryPoint = entryPoint;
348
+ if (!finalEntryPoint) {
349
+ const patterns = [
350
+ join(packageDir, "dist", "index.mjs"),
351
+ join(packageDir, "index.mjs"),
352
+ ];
353
+ finalEntryPoint = patterns.find((p) => existsSync(p)) || patterns[0];
354
+ }
355
+
356
+ return { packageDir, entryPoint: finalEntryPoint, isLocal: !!isLocal };
357
+ }
358
+
359
+ /**
360
+ * Cache for monorepo package locations to avoid repeated disk crawling.
361
+ */
362
+ let monorepoCache: Record<string, string> | null = null;
363
+
364
+ async function tryResolveMonorepoPackage(
365
+ packageName: string,
366
+ ): Promise<{ packageDir: string; isLocal: boolean; entryPoint: string } | null> {
367
+ if (!monorepoCache) {
368
+ monorepoCache = await crawlMonorepoPackages();
369
+ }
370
+
371
+ const packageDir = monorepoCache[packageName];
372
+ if (!packageDir) return null;
373
+
374
+ // Find best entry point from package.json
375
+ let entryPoint: string | undefined;
376
+ try {
377
+ const pkgPath = join(packageDir, "package.json");
378
+ const pkg = JSON.parse(await readFile(pkgPath, "utf-8"));
379
+
380
+ // 1. Try to find the entry point from package.json
381
+ // User tip: main is usually source in dev, publishConfig.main is compiled for prod
382
+ const publishMain = pkg.publishConfig?.module || pkg.publishConfig?.main;
383
+ const devMain = pkg.module || pkg.main;
384
+
385
+ // Check bin field if it's a CLI
386
+ const binField =
387
+ typeof pkg.bin === "string"
388
+ ? pkg.bin
389
+ : pkg.bin?.[packageName.replace("@pipelab/", "")] ||
390
+ (pkg.bin ? pkg.bin[Object.keys(pkg.bin)[0]] : undefined);
391
+
392
+ // In dev, we prefer the "main" field if it points to TS, or a hardcoded src/index.ts
393
+ const tsSource = join(packageDir, "src", "index.ts");
394
+
395
+ if (devMain && devMain.endsWith(".ts")) {
396
+ entryPoint = join(packageDir, devMain);
397
+ } else if (existsSync(tsSource)) {
398
+ entryPoint = tsSource;
399
+ } else if (binField) {
400
+ entryPoint = join(packageDir, binField);
401
+ } else if (publishMain) {
402
+ entryPoint = join(packageDir, publishMain);
403
+ } else if (devMain) {
404
+ entryPoint = join(packageDir, devMain);
405
+ }
406
+ } catch (e) {
407
+ console.warn(`[Fetcher] Failed to parse package.json for ${packageName}:`, e);
408
+ }
409
+
410
+ return {
411
+ packageDir,
412
+ isLocal: true,
413
+ entryPoint: entryPoint || join(packageDir, "dist", "index.mjs"),
414
+ };
415
+ }
416
+
417
+ async function crawlMonorepoPackages(): Promise<Record<string, string>> {
418
+ const cache: Record<string, string> = {};
419
+ if (!projectRoot) return cache;
420
+
421
+ const searchDirs = ["plugins", "packages", "apps"];
422
+ for (const dir of searchDirs) {
423
+ const fullDir = join(projectRoot, dir);
424
+ if (!existsSync(fullDir)) continue;
425
+
426
+ try {
427
+ const entries = await readdir(fullDir, { withFileTypes: true });
428
+ for (const entry of entries) {
429
+ if (entry.isDirectory()) {
430
+ const pkgPath = join(fullDir, entry.name, "package.json");
431
+ if (existsSync(pkgPath)) {
432
+ try {
433
+ const pkg = JSON.parse(await readFile(pkgPath, "utf-8"));
434
+ if (pkg.name) {
435
+ cache[pkg.name] = join(fullDir, entry.name);
436
+ }
437
+ } catch (e) { }
438
+ }
439
+ }
440
+ }
441
+ } catch (e) { }
442
+ }
443
+ return cache;
444
+ }
445
+
446
+ async function findLatestLocalVersion(baseDir: string): Promise<string | null> {
447
+ if (!existsSync(baseDir)) return null;
448
+ try {
449
+ const entries = await readdir(baseDir, { withFileTypes: true });
450
+ const versions = entries
451
+ .filter((e) => e.isDirectory() || e.isSymbolicLink())
452
+ .map((e) => e.name)
453
+ .sort((a, b) => b.localeCompare(a, undefined, { numeric: true, sensitivity: "base" }));
454
+ return versions[0] || null;
455
+ } catch {
456
+ return null;
457
+ }
458
+ }
459
+
460
+ async function tryLocalFallback(
461
+ _version: string | undefined,
462
+ _error: unknown,
463
+ baseDir: string,
464
+ logPrefix: string,
465
+ ): Promise<string | null> {
466
+ const latestLocal = await findLatestLocalVersion(baseDir);
467
+ if (latestLocal) {
468
+ console.info(`[Fetcher] ${logPrefix}: Using locally cached version: ${latestLocal}`);
469
+ return latestLocal;
470
+ }
471
+ return null;
472
+ }
473
+
474
+ /**
475
+ * Resolves the entry point of a package by looking at its package.json.
476
+ */
477
+ async function resolveEntryPoint(packageDir: string, packageName: string) {
478
+ try {
479
+ const pkgPath = join(packageDir, "package.json");
480
+ if (!existsSync(pkgPath)) return undefined;
481
+
482
+ const pkg = JSON.parse(await readFile(pkgPath, "utf-8"));
483
+ const main = pkg.module || pkg.main || pkg.publishConfig?.module || pkg.publishConfig?.main;
484
+
485
+ if (main) {
486
+ return join(packageDir, main);
487
+ }
488
+
489
+ if (pkg.bin) {
490
+ const binFile = typeof pkg.bin === "string" ? pkg.bin : Object.values(pkg.bin)[0];
491
+ if (binFile) return join(packageDir, binFile as string);
492
+ }
493
+ } catch (e) {
494
+ console.warn(`[Fetcher] ${packageName}: Failed to resolve entry point:`, e);
495
+ }
496
+ return undefined;
497
+ }
@@ -0,0 +1,99 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { useLogger } from "@pipelab/shared";
4
+ import { PipelabContext } from "../context";
5
+
6
+ /**
7
+ * A generic file-based storage implementation for persisting JSON data.
8
+ * Useful for Supabase Auth and other CLI-specific persistent data.
9
+ */
10
+ export class JsonFileStorage {
11
+ private filePath: string;
12
+ private logger = useLogger().logger;
13
+
14
+ constructor(fileName: string, context: PipelabContext) {
15
+ this.filePath = path.join(context.userDataPath, fileName);
16
+ const dir = path.dirname(this.filePath);
17
+ if (!fs.existsSync(dir)) {
18
+ try {
19
+ fs.mkdirSync(dir, { recursive: true });
20
+ } catch (e) {
21
+ this.logger().error(`[Storage] Failed to create directory ${dir}:`, e);
22
+ throw e;
23
+ }
24
+ }
25
+ }
26
+
27
+ getItem(key: string): string | null {
28
+ try {
29
+ if (!fs.existsSync(this.filePath)) return null;
30
+ const content = fs.readFileSync(this.filePath, "utf8");
31
+ if (!content) return null;
32
+ const data = JSON.parse(content);
33
+ return data[key] || null;
34
+ } catch (e) {
35
+ this.logger().error(`[Storage] Failed to read or parse ${this.filePath}:`, e);
36
+ // We throw here because the user explicitly asked NOT to ignore errors
37
+ throw e;
38
+ }
39
+ }
40
+
41
+ setItem(key: string, value: string): void {
42
+ try {
43
+ let data: any = {};
44
+ if (fs.existsSync(this.filePath)) {
45
+ const content = fs.readFileSync(this.filePath, "utf8");
46
+ if (content) {
47
+ data = JSON.parse(content);
48
+ }
49
+ }
50
+ data[key] = value;
51
+ fs.writeFileSync(this.filePath, JSON.stringify(data, null, 2), "utf8");
52
+ } catch (e) {
53
+ this.logger().error(`[Storage] Failed to write to ${this.filePath}:`, e);
54
+ throw e;
55
+ }
56
+ }
57
+
58
+ removeItem(key: string): void {
59
+ try {
60
+ if (!fs.existsSync(this.filePath)) return;
61
+ const content = fs.readFileSync(this.filePath, "utf8");
62
+ if (!content) return;
63
+ const data = JSON.parse(content);
64
+ delete data[key];
65
+ fs.writeFileSync(this.filePath, JSON.stringify(data, null, 2), "utf8");
66
+ } catch (e) {
67
+ this.logger().error(`[Storage] Failed to remove key from ${this.filePath}:`, e);
68
+ throw e;
69
+ }
70
+ }
71
+
72
+ /**
73
+ * Helper to get the entire data object
74
+ */
75
+ getAll(): Record<string, any> {
76
+ try {
77
+ if (!fs.existsSync(this.filePath)) return {};
78
+ const content = fs.readFileSync(this.filePath, "utf8");
79
+ return content ? JSON.parse(content) : {};
80
+ } catch (e) {
81
+ this.logger().error(`[Storage] Failed to read all data from ${this.filePath}:`, e);
82
+ throw e;
83
+ }
84
+ }
85
+
86
+ /**
87
+ * Helper to clear the entire storage file
88
+ */
89
+ clear(): void {
90
+ try {
91
+ if (fs.existsSync(this.filePath)) {
92
+ fs.unlinkSync(this.filePath);
93
+ }
94
+ } catch (e) {
95
+ this.logger().error(`[Storage] Failed to clear ${this.filePath}:`, e);
96
+ throw e;
97
+ }
98
+ }
99
+ }