@pipelab/core-node 1.0.0-beta.2 → 1.0.0-beta.22

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 (45) hide show
  1. package/.oxfmtrc.json +1 -1
  2. package/CHANGELOG.md +173 -0
  3. package/dist/config-Bi0ORcTK.mjs +2 -0
  4. package/dist/config-CFgGRD9U.mjs +265 -0
  5. package/dist/config-CFgGRD9U.mjs.map +1 -0
  6. package/dist/index.d.mts +74 -53
  7. package/dist/index.d.mts.map +1 -1
  8. package/dist/index.mjs +1705 -576
  9. package/dist/index.mjs.map +1 -1
  10. package/package.json +9 -5
  11. package/scratch/simulate-updates.ts +120 -0
  12. package/src/config.test.ts +232 -0
  13. package/src/config.ts +111 -50
  14. package/src/context.ts +117 -5
  15. package/src/handler-func.ts +2 -77
  16. package/src/handlers/build-history.ts +60 -90
  17. package/src/handlers/config.ts +265 -55
  18. package/src/handlers/engine.ts +32 -35
  19. package/src/handlers/history.ts +0 -40
  20. package/src/handlers/index.ts +4 -0
  21. package/src/handlers/migration.ts +621 -0
  22. package/src/handlers/plugins.ts +305 -0
  23. package/src/handlers/system.ts +7 -2
  24. package/src/index.ts +9 -2
  25. package/src/plugins-registry.ts +280 -38
  26. package/src/presets/c3toSteam.ts +13 -7
  27. package/src/presets/demo.ts +9 -9
  28. package/src/presets/list.ts +0 -6
  29. package/src/presets/moreToCome.ts +3 -2
  30. package/src/presets/newProject.ts +3 -2
  31. package/src/presets/test-c3-offline.ts +15 -6
  32. package/src/presets/test-c3-unzip.ts +19 -8
  33. package/src/runner.ts +2 -8
  34. package/src/server.ts +45 -4
  35. package/src/types/runner.ts +2 -30
  36. package/src/utils/fs-extras.ts +6 -24
  37. package/src/utils/github.test.ts +211 -0
  38. package/src/utils/github.ts +90 -10
  39. package/src/utils/remote.test.ts +209 -0
  40. package/src/utils/remote.ts +325 -87
  41. package/src/utils/storage.ts +2 -1
  42. package/src/utils.ts +20 -24
  43. package/src/migrations.ts +0 -72
  44. package/src/presets/if.ts +0 -69
  45. package/src/presets/loop.ts +0 -65
@@ -1,16 +1,48 @@
1
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";
2
+ import {
3
+ mkdir,
4
+ readdir,
5
+ readFile,
6
+ writeFile,
7
+ access,
8
+ chmod,
9
+ rm,
10
+ cp,
11
+ rename,
12
+ } from "node:fs/promises";
13
+ import { existsSync, constants, statSync, readdirSync } from "node:fs";
14
+ import dns from "node:dns/promises";
5
15
  import pacote from "pacote";
6
16
  import semver from "semver";
7
- import { isDev, projectRoot, PipelabContext } from "../context";
17
+ import { isDev, projectRoot, PipelabContext, CacheFolder } from "../context";
8
18
  import { execa } from "execa";
9
19
  import { sendStartupProgress } from "../server";
10
- import { downloadFile, extractZip, extractTarGz, generateTempFolder } from "./fs-extras";
20
+ import { downloadFile, extractZip, extractTarGz } from "./fs-extras";
11
21
 
12
- export const DEFAULT_NODE_VERSION = "24.14.1";
13
- export const DEFAULT_PNPM_VERSION = "10.12.0";
22
+ import { DEFAULT_NODE_VERSION, DEFAULT_PNPM_VERSION } from "@pipelab/constants";
23
+
24
+ function isPackageComplete(packageDir: string): boolean {
25
+ return existsSync(join(packageDir, "package.json"));
26
+ }
27
+
28
+ function isNodeJSComplete(nodePath: string): boolean {
29
+ try {
30
+ return existsSync(nodePath) && statSync(nodePath).size > 0;
31
+ } catch {
32
+ return false;
33
+ }
34
+ }
35
+
36
+ function isDependenciesInstalledSync(packageDir: string): boolean {
37
+ const nodeModulesPath = join(packageDir, "node_modules");
38
+ if (!existsSync(nodeModulesPath)) return false;
39
+ try {
40
+ const files = readdirSync(nodeModulesPath);
41
+ return files.length > 0;
42
+ } catch {
43
+ return false;
44
+ }
45
+ }
14
46
 
15
47
  /**
16
48
  * In-memory lock to prevent concurrent operations on the same resource (e.g., downloading Node.js).
@@ -37,6 +69,27 @@ async function withLock<T>(key: string, fn: () => Promise<T>): Promise<T> {
37
69
  return promise;
38
70
  }
39
71
 
72
+ let isOnlineCached: boolean | null = null;
73
+ let lastCheckTime = 0;
74
+
75
+ export async function isOnline(): Promise<boolean> {
76
+ const now = Date.now();
77
+ if (isOnlineCached !== null && now - lastCheckTime < 10000) {
78
+ return isOnlineCached;
79
+ }
80
+ try {
81
+ await Promise.race([
82
+ dns.lookup("registry.npmjs.org"),
83
+ new Promise((_, reject) => setTimeout(() => reject(new Error("timeout")), 1500)),
84
+ ]);
85
+ isOnlineCached = true;
86
+ } catch {
87
+ isOnlineCached = false;
88
+ }
89
+ lastCheckTime = now;
90
+ return isOnlineCached;
91
+ }
92
+
40
93
  export type FetchOptions = {
41
94
  installDeps?: boolean;
42
95
  signal?: AbortSignal;
@@ -57,12 +110,16 @@ export async function fetchPackage(
57
110
  isLocal?: boolean;
58
111
  entryPoint?: string;
59
112
  }> {
113
+ const start = Date.now();
60
114
  // 0. Check for local monorepo package in development
61
115
  if (isDev && projectRoot && process.env.PIPELAB_FORCE_NPM !== "true") {
62
116
  if (packageName.startsWith("@pipelab/")) {
117
+ const localStart = Date.now();
63
118
  const local = await tryResolveMonorepoPackage(packageName);
64
119
  if (local) {
65
- console.log(`[Fetcher] ${packageName}: Resolved to local source at ${local.packageDir}`);
120
+ console.log(
121
+ `[Fetcher] ${packageName}: Resolved to local source at ${local.packageDir} (${Date.now() - localStart}ms)`,
122
+ );
66
123
  return {
67
124
  ...local,
68
125
  resolvedVersion: "workspace",
@@ -75,66 +132,182 @@ export async function fetchPackage(
75
132
  const baseDir = ctx.getPackagesPath(packageName);
76
133
  let resolvedVersion: string;
77
134
 
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";
135
+ let resolvedVersionOrRange = versionOrRange;
136
+ if (resolvedVersionOrRange === "local") {
137
+ resolvedVersionOrRange = "latest";
138
+ }
92
139
 
93
- // Prioritize tags (like 'latest', 'beta', etc.) over semver ranges
94
- const foundVersion = packument["dist-tags"]?.[range] || semver.maxSatisfying(versions, range);
140
+ console.log(`[Fetcher] Resolving ${packageName}@${resolvedVersionOrRange || "latest"}...`);
141
+ const resolveStart = Date.now();
95
142
 
96
- if (!foundVersion) {
97
- throw new Error(
98
- `Package ${packageName}@${range} not found on npm (available tags: ${Object.keys(packument["dist-tags"] || {}).join(", ")})`,
99
- );
100
- }
101
- resolvedVersion = foundVersion;
102
- console.log(`[Fetcher] ${packageName}: Resolved to v${resolvedVersion} via npm`);
103
- } catch (error) {
104
- console.warn(`[Fetcher] ${packageName}: remote resolution failed, trying local fallback...`);
105
- const fallbackVersion = await tryLocalFallback(versionOrRange, error, baseDir, packageName);
143
+ const online = await isOnline();
144
+ if (!online) {
145
+ console.warn(
146
+ `[Fetcher] ${packageName}: offline mode detected (${Date.now() - resolveStart}ms), trying local fallback...`,
147
+ );
148
+ const fallbackStart = Date.now();
149
+ const fallbackVersion = await tryLocalFallback(
150
+ resolvedVersionOrRange,
151
+ new Error("Offline"),
152
+ baseDir,
153
+ packageName,
154
+ );
106
155
  if (fallbackVersion) {
107
156
  resolvedVersion = fallbackVersion;
157
+ console.log(
158
+ `[Fetcher] ${packageName}: Resolved to local fallback ${resolvedVersion} (${Date.now() - fallbackStart}ms)`,
159
+ );
108
160
  } else {
109
- throw error;
161
+ throw new Error(`Offline and no local fallback version available for ${packageName}`);
162
+ }
163
+ } else {
164
+ try {
165
+ // 1. Resolve version/range using npm with session-wide memoization and disk cache
166
+ const cachePath = ctx.getCachePath(CacheFolder.Pacote);
167
+ let packumentPromise = packumentRequests.get(packageName);
168
+ if (!packumentPromise) {
169
+ packumentPromise = pacote.packument(packageName, { cache: cachePath });
170
+ packumentRequests.set(packageName, packumentPromise);
171
+ }
172
+
173
+ const packument = await packumentPromise;
174
+ const versions = Object.keys(packument.versions);
175
+ const range = resolvedVersionOrRange || "latest";
176
+
177
+ // Prioritize tags (like 'latest', 'beta', etc.) over semver ranges
178
+ let foundVersion = packument["dist-tags"]?.[range] || semver.maxSatisfying(versions, range);
179
+
180
+ // If we are in a non-latest releaseTag channel (like "beta" or "dev"),
181
+ // and we are resolving "latest", we prefer the releaseTag version if it exists
182
+ // and is newer than (or equal to) the latest stable version.
183
+ if (range === "latest" && ctx.releaseTag && ctx.releaseTag !== "latest") {
184
+ const releaseTagVersion = packument["dist-tags"]?.[ctx.releaseTag];
185
+ if (releaseTagVersion && semver.valid(releaseTagVersion)) {
186
+ if (
187
+ !foundVersion ||
188
+ (semver.valid(foundVersion) && semver.gte(releaseTagVersion, foundVersion))
189
+ ) {
190
+ console.log(
191
+ `[Fetcher] Using release tag "${ctx.releaseTag}" (${releaseTagVersion}) instead of "latest" (${foundVersion || "none"}) for ${packageName}`,
192
+ );
193
+ foundVersion = releaseTagVersion;
194
+ } else if (foundVersion) {
195
+ console.warn(
196
+ `[Fetcher] Tag "${ctx.releaseTag}" (${releaseTagVersion}) is older than "latest" (${foundVersion}) for ${packageName}, keeping "latest"`,
197
+ );
198
+ }
199
+ }
200
+ }
201
+
202
+ if (!foundVersion) {
203
+ throw new Error(
204
+ `Package ${packageName}@${range} not found on npm (available tags: ${Object.keys(packument["dist-tags"] || {}).join(", ")})`,
205
+ );
206
+ }
207
+ resolvedVersion = foundVersion;
208
+ console.log(
209
+ `[Fetcher] ${packageName}: Resolved to v${resolvedVersion} via npm (${Date.now() - resolveStart}ms)`,
210
+ );
211
+ } catch (error) {
212
+ console.warn(
213
+ `[Fetcher] ${packageName}: remote resolution failed (${Date.now() - resolveStart}ms), trying local fallback...`,
214
+ );
215
+ const fallbackStart = Date.now();
216
+ const fallbackVersion = await tryLocalFallback(
217
+ resolvedVersionOrRange,
218
+ error,
219
+ baseDir,
220
+ packageName,
221
+ );
222
+ if (fallbackVersion) {
223
+ resolvedVersion = fallbackVersion;
224
+ console.log(
225
+ `[Fetcher] ${packageName}: Resolved to local fallback ${resolvedVersion} (${Date.now() - fallbackStart}ms)`,
226
+ );
227
+ } else {
228
+ throw error;
229
+ }
110
230
  }
111
231
  }
112
232
 
113
- const cachePath = join(ctx.userDataPath, "cache", "pacote");
233
+ const cachePath = ctx.getCachePath(CacheFolder.Pacote);
114
234
  const packageDir = join(baseDir, resolvedVersion);
115
235
 
116
- // If the package already exists and we don't need to install dependencies, return immediately
117
- if (existsSync(packageDir) && !options?.installDeps) {
236
+ const checkStart = Date.now();
237
+ // If the package already exists and we don't need to install dependencies (or they are already installed), return immediately
238
+ const isInstalled = options?.installDeps
239
+ ? isPackageComplete(packageDir) && isDependenciesInstalledSync(packageDir)
240
+ : isPackageComplete(packageDir);
241
+ const checkDuration = Date.now() - checkStart;
242
+
243
+ if (isInstalled) {
244
+ console.log(
245
+ `[Fetcher] ${packageName}@${resolvedVersion}: Already installed (check took ${checkDuration}ms, fetchPackage took ${Date.now() - start}ms)`,
246
+ );
118
247
  return { packageDir, resolvedVersion };
119
248
  }
120
249
 
121
250
  const lockKey = `package:${packageName}:${resolvedVersion}`;
122
251
  return withLock(lockKey, async () => {
123
- if (!existsSync(packageDir)) {
252
+ if (!isPackageComplete(packageDir)) {
124
253
  console.log(`[Fetcher] ${packageName}@${resolvedVersion}: Downloading to ${packageDir}...`);
125
- await mkdir(packageDir, { recursive: true });
126
- await pacote.extract(`${packageName}@${resolvedVersion}`, packageDir, {
127
- cache: cachePath,
128
- });
254
+ const downloadStart = Date.now();
255
+
256
+ const tempDir = join(
257
+ baseDir,
258
+ `.tmp-${resolvedVersion}-${Math.random().toString(36).slice(2)}`,
259
+ );
260
+ await mkdir(tempDir, { recursive: true });
261
+ try {
262
+ await pacote.extract(`${packageName}@${resolvedVersion}`, tempDir, {
263
+ cache: cachePath,
264
+ });
265
+
266
+ // Resolve entryPoint from package.json inside the temp directory first to ensure everything works
267
+ await resolveEntryPoint(tempDir, packageName);
268
+
269
+ // Remove existing incomplete packageDir if any
270
+ if (existsSync(packageDir)) {
271
+ await rm(packageDir, { recursive: true, force: true }).catch(() => {});
272
+ }
273
+
274
+ // Atomically rename the temp directory to the target packageDir
275
+ try {
276
+ await rename(tempDir, packageDir);
277
+ } catch (err: any) {
278
+ if (isPackageComplete(packageDir)) {
279
+ console.log(`[Fetcher] Destination ${packageDir} already exists and is valid.`);
280
+ } else {
281
+ throw err;
282
+ }
283
+ }
284
+ console.log(
285
+ `[Fetcher] ${packageName}@${resolvedVersion}: Downloaded and extracted in ${Date.now() - downloadStart}ms`,
286
+ );
287
+ } catch (err) {
288
+ await rm(tempDir, { recursive: true, force: true }).catch(() => {});
289
+ throw err;
290
+ }
129
291
  }
130
292
 
131
293
  // 2. Resolve entry point from package.json for downloaded package
294
+ const entryStart = Date.now();
132
295
  const entryPoint = await resolveEntryPoint(packageDir, packageName);
296
+ console.log(
297
+ `[Fetcher] ${packageName}@${resolvedVersion}: Resolved entry point in ${Date.now() - entryStart}ms`,
298
+ );
133
299
 
134
300
  if (options?.installDeps) {
301
+ const depsStart = Date.now();
135
302
  await installDependencies(packageDir, packageName, options);
303
+ console.log(
304
+ `[Fetcher] ${packageName}@${resolvedVersion}: Installed dependencies in ${Date.now() - depsStart}ms`,
305
+ );
136
306
  }
137
307
 
308
+ console.log(
309
+ `[Fetcher] ${packageName}@${resolvedVersion}: FetchPackage complete in ${Date.now() - start}ms`,
310
+ );
138
311
  return { packageDir, resolvedVersion, entryPoint };
139
312
  });
140
313
  }
@@ -181,7 +354,7 @@ export async function runPnpm(
181
354
  ...process.env,
182
355
  NODE_ENV: "production",
183
356
  PATH: nodePath ? `${dirname(nodePath)}${delimiter}${process.env.PATH}` : process.env.PATH,
184
- PNPM_HOME: join(ctx.userDataPath, "pnpm"),
357
+ PNPM_HOME: ctx.getPnpmPath(),
185
358
  PNPM_ONLY_ALLOW_TRUSTED_DEPENDENCIES: "false",
186
359
  ...extraEnv,
187
360
  },
@@ -192,17 +365,21 @@ export async function runPnpm(
192
365
  * Installs a specific version of Node.js if not already present.
193
366
  */
194
367
  export async function ensureNodeJS(context: PipelabContext, version = DEFAULT_NODE_VERSION) {
368
+ const checkStart = Date.now();
195
369
  const isWindows = process.platform === "win32";
196
370
  const nodeDir = context.getThirdPartyPath("node", version);
197
371
  const finalNodePath = join(nodeDir, isWindows ? "node.exe" : "bin/node");
198
372
 
199
- if (existsSync(finalNodePath)) {
373
+ if (isNodeJSComplete(finalNodePath)) {
374
+ console.log(
375
+ `[Environment] Node.js check took ${Date.now() - checkStart}ms (found at ${finalNodePath})`,
376
+ );
200
377
  return finalNodePath;
201
378
  }
202
379
 
203
380
  const lockKey = `node:${version}`;
204
381
  return withLock(lockKey, async () => {
205
- if (existsSync(finalNodePath)) return finalNodePath;
382
+ if (isNodeJSComplete(finalNodePath)) return finalNodePath;
206
383
 
207
384
  const arch = process.arch === "x64" ? "x64" : process.arch === "arm64" ? "arm64" : "x86";
208
385
  const platform = isWindows ? "win" : process.platform === "darwin" ? "osx" : "linux";
@@ -211,15 +388,18 @@ export async function ensureNodeJS(context: PipelabContext, version = DEFAULT_NO
211
388
 
212
389
  const fileName = `node-v${version}-${downloadPlatform}-${arch}.${extension}`;
213
390
  const downloadUrl = `https://nodejs.org/dist/v${version}/${fileName}`;
214
- const tempDir = await generateTempFolder(tmpdir());
391
+ const tempDir = await context.createTempFolder("node-download-");
215
392
  const archivePath = join(tempDir, fileName);
216
393
 
217
394
  sendStartupProgress(`Downloading Node.js v${version}...`);
218
395
  console.log(`Downloading Node.js from ${downloadUrl}...`);
396
+ const dlStart = Date.now();
219
397
  await downloadFile(downloadUrl, archivePath);
398
+ console.log(`[Environment] Node.js download took ${Date.now() - dlStart}ms`);
220
399
 
221
400
  sendStartupProgress(`Extracting Node.js v${version}...`);
222
401
  console.log(`Extracting Node.js to ${tempDir}...`);
402
+ const extStart = Date.now();
223
403
  const extractTempDir = join(tempDir, "extracted");
224
404
  await mkdir(extractTempDir, { recursive: true });
225
405
 
@@ -234,12 +414,42 @@ export async function ensureNodeJS(context: PipelabContext, version = DEFAULT_NO
234
414
  if (!nodeSubDir) throw new Error(`Could not find extracted Node.js directory`);
235
415
 
236
416
  const sourceDir = join(extractTempDir, nodeSubDir);
237
- await mkdir(dirname(nodeDir), { recursive: true });
238
- await rm(nodeDir, { recursive: true, force: true });
239
- await cp(sourceDir, nodeDir, { recursive: true });
240
- await rm(tempDir, { recursive: true, force: true });
417
+ const parentDir = dirname(nodeDir);
418
+ await mkdir(parentDir, { recursive: true });
419
+
420
+ const tempNodeDir = join(
421
+ parentDir,
422
+ `.tmp-node-${version}-${Math.random().toString(36).slice(2)}`,
423
+ );
424
+ await mkdir(tempNodeDir, { recursive: true });
425
+
426
+ try {
427
+ await cp(sourceDir, tempNodeDir, { recursive: true });
428
+ if (!isWindows) {
429
+ const tempNodePath = join(tempNodeDir, "bin/node");
430
+ await chmod(tempNodePath, 0o755).catch(() => {});
431
+ }
241
432
 
242
- if (!isWindows) await chmod(finalNodePath, 0o755).catch(() => {});
433
+ if (existsSync(nodeDir)) {
434
+ await rm(nodeDir, { recursive: true, force: true }).catch(() => {});
435
+ }
436
+
437
+ try {
438
+ await rename(tempNodeDir, nodeDir);
439
+ } catch (err: any) {
440
+ if (isNodeJSComplete(finalNodePath)) {
441
+ console.log(`[Fetcher] Node.js directory already exists and is valid.`);
442
+ } else {
443
+ throw err;
444
+ }
445
+ }
446
+ console.log(`[Environment] Node.js extraction took ${Date.now() - extStart}ms`);
447
+ } finally {
448
+ await rm(tempNodeDir, { recursive: true, force: true }).catch(() => {});
449
+ await rm(tempDir, { recursive: true, force: true }).catch(() => {});
450
+ }
451
+
452
+ console.log(`[Environment] Node.js set up complete in ${Date.now() - checkStart}ms`);
243
453
  return finalNodePath;
244
454
  });
245
455
  }
@@ -248,10 +458,14 @@ export async function ensureNodeJS(context: PipelabContext, version = DEFAULT_NO
248
458
  * Installs the PNPM package from npm if not already present.
249
459
  */
250
460
  export async function ensurePNPM(context: PipelabContext, version = DEFAULT_PNPM_VERSION) {
461
+ const checkStart = Date.now();
251
462
  const pnpmDir = context.getPackagesPath("pnpm", version);
252
463
  const pnpmPath = join(pnpmDir, "bin", "pnpm.cjs");
253
464
 
254
465
  if (existsSync(pnpmPath)) {
466
+ console.log(
467
+ `[Environment] PNPM check took ${Date.now() - checkStart}ms (found at ${pnpmPath})`,
468
+ );
255
469
  return pnpmPath;
256
470
  }
257
471
 
@@ -262,44 +476,56 @@ export async function ensurePNPM(context: PipelabContext, version = DEFAULT_PNPM
262
476
  const { packageDir } = await fetchPackage("pnpm", version, {
263
477
  context,
264
478
  });
479
+ console.log(`[Environment] PNPM set up complete in ${Date.now() - checkStart}ms`);
265
480
  return join(packageDir, "bin", "pnpm.cjs");
266
481
  });
267
482
  }
268
483
 
269
484
  async function installDependencies(packageDir: string, packageName: string, options: FetchOptions) {
485
+ const start = Date.now();
270
486
  const nodeModulesPath = join(packageDir, "node_modules");
271
487
 
272
- if (existsSync(nodeModulesPath)) {
273
- try {
274
- const files = await readdir(nodeModulesPath);
275
- if (files.length === 0) {
276
- console.warn(
277
- `[Fetcher] ${packageName}: node_modules exists but is empty. Re-installing...`,
278
- );
279
- } else {
280
- console.log(`[Fetcher] ${packageName}: Dependencies already installed, skipping.`);
281
- return;
282
- }
283
- } catch (e) {
284
- // Continue to install if readdir fails
285
- }
488
+ if (isDependenciesInstalledSync(packageDir)) {
489
+ console.log(`[Fetcher] ${packageName}: Dependencies already installed, skipping.`);
490
+ return;
286
491
  }
287
492
 
288
- console.log(`[Fetcher] ${packageName}: Ensuring dependencies are installed...`);
493
+ // To prevent other processes from seeing a partially populated node_modules,
494
+ // we can create a temp directory next to packageDir, run pnpm there, and rename node_modules
495
+ const tempDir = `${packageDir}.tmp-deps-${Math.random().toString(36).slice(2)}`;
496
+ await mkdir(tempDir, { recursive: true });
289
497
  try {
290
- const { all } = await runPnpm(packageDir, {
498
+ // Copy package.json to tempDir so pnpm can install dependencies
499
+ await cp(join(packageDir, "package.json"), join(tempDir, "package.json"));
500
+
501
+ console.log(`[Fetcher] ${packageName}: Ensuring dependencies are installed...`);
502
+ const pnpmStart = Date.now();
503
+ const { all } = await runPnpm(tempDir, {
291
504
  signal: options.signal,
292
505
  context: options.context,
293
506
  });
507
+ console.log(`[Fetcher] ${packageName}: pnpm install command took ${Date.now() - pnpmStart}ms`);
294
508
 
295
509
  if (all) console.log(`[Fetcher] ${packageName}: Installation trace:\n${all}`);
296
- console.log(`[Fetcher] ${packageName}: Dependencies installed successfully.`);
510
+
511
+ // Atomically rename node_modules from tempDir to packageDir/node_modules
512
+ const tempNodeModules = join(tempDir, "node_modules");
513
+ if (existsSync(nodeModulesPath)) {
514
+ await rm(nodeModulesPath, { recursive: true, force: true }).catch(() => {});
515
+ }
516
+ const renameStart = Date.now();
517
+ await rename(tempNodeModules, nodeModulesPath);
518
+ console.log(
519
+ `[Fetcher] ${packageName}: Dependencies installed successfully (rename took ${Date.now() - renameStart}ms, total installDependencies took ${Date.now() - start}ms).`,
520
+ );
297
521
  } catch (err: any) {
298
522
  console.error(
299
523
  `[Fetcher] ${packageName}: CRITICAL ERROR during dependency installation: ${err.message}`,
300
524
  );
301
525
  if (err.all) console.error(`[Fetcher] ${packageName}: Error details:\n${err.all}`);
302
526
  throw new Error(`Failed to install dependencies for ${packageName}. See logs for details.`);
527
+ } finally {
528
+ await rm(tempDir, { recursive: true, force: true }).catch(() => {});
303
529
  }
304
530
  }
305
531
 
@@ -444,30 +670,42 @@ async function crawlMonorepoPackages(): Promise<Record<string, string>> {
444
670
  return cache;
445
671
  }
446
672
 
447
- async function findLatestLocalVersion(baseDir: string): Promise<string | null> {
673
+ async function tryLocalFallback(
674
+ versionOrRange: string | undefined,
675
+ _error: unknown,
676
+ baseDir: string,
677
+ logPrefix: string,
678
+ ): Promise<string | null> {
448
679
  if (!existsSync(baseDir)) return null;
449
680
  try {
450
681
  const entries = await readdir(baseDir, { withFileTypes: true });
451
- const versions = entries
682
+ const localVersions = entries
452
683
  .filter((e) => e.isDirectory() || e.isSymbolicLink())
453
684
  .map((e) => e.name)
454
- .sort((a, b) => b.localeCompare(a, undefined, { numeric: true, sensitivity: "base" }));
455
- return versions[0] || null;
456
- } catch {
457
- return null;
458
- }
459
- }
685
+ .filter((name) => !!semver.valid(name));
460
686
 
461
- async function tryLocalFallback(
462
- _version: string | undefined,
463
- _error: unknown,
464
- baseDir: string,
465
- logPrefix: string,
466
- ): Promise<string | null> {
467
- const latestLocal = await findLatestLocalVersion(baseDir);
468
- if (latestLocal) {
469
- console.info(`[Fetcher] ${logPrefix}: Using locally cached version: ${latestLocal}`);
470
- return latestLocal;
687
+ if (localVersions.length === 0) return null;
688
+
689
+ const range = versionOrRange || "latest";
690
+
691
+ if (range === "latest") {
692
+ const sorted = localVersions.sort((a, b) => semver.rcompare(a, b));
693
+ const latestLocal = sorted[0] || null;
694
+ if (latestLocal) {
695
+ console.info(`[Fetcher] ${logPrefix}: Using locally cached latest version: ${latestLocal}`);
696
+ return latestLocal;
697
+ }
698
+ } else {
699
+ const matched = semver.maxSatisfying(localVersions, range);
700
+ if (matched) {
701
+ console.info(
702
+ `[Fetcher] ${logPrefix}: Using locally cached matching version: ${matched} for range ${range}`,
703
+ );
704
+ return matched;
705
+ }
706
+ }
707
+ } catch (e) {
708
+ console.warn(`[Fetcher] ${logPrefix}: Error during local fallback resolution:`, e);
471
709
  }
472
710
  return null;
473
711
  }
@@ -12,7 +12,8 @@ export class JsonFileStorage {
12
12
  private logger = useLogger().logger;
13
13
 
14
14
  constructor(fileName: string, context: PipelabContext) {
15
- this.filePath = path.join(context.userDataPath, fileName);
15
+ this.filePath = context.getConfigPath(fileName);
16
+
16
17
  const dir = path.dirname(this.filePath);
17
18
  if (!fs.existsSync(dir)) {
18
19
  try {
package/src/utils.ts CHANGED
@@ -1,24 +1,27 @@
1
1
  import { nanoid } from "nanoid";
2
- import { usePlugins } from "@pipelab/shared";
3
- import { RendererPluginDefinition } from "@pipelab/shared";
2
+ import {
3
+ usePlugins,
4
+ RendererPluginDefinition,
5
+ processGraph,
6
+ useLogger,
7
+ BuildHistoryEntry,
8
+ Variable,
9
+ AppConfig,
10
+ transformUrl,
11
+ } from "@pipelab/shared";
4
12
  import { downloadFile, DownloadHooks } from "./utils/fs-extras";
5
13
  import { access, chmod, mkdir, rm, writeFile, readdir, cp } from "node:fs/promises";
6
14
  import { dirname, join } from "node:path";
7
15
  import { pathToFileURL } from "node:url";
8
16
  import { isDev, projectRoot, PipelabContext } from "./context";
9
17
  import { constants, existsSync } from "node:fs";
10
- import { processGraph } from "@pipelab/shared";
11
18
  import { handleActionExecute } from "./handler-func";
12
- import { useLogger } from "@pipelab/shared";
13
19
  import { BuildHistoryStorage } from "./handlers/build-history";
14
- import type { BuildHistoryEntry } from "@pipelab/shared";
15
- import type { Variable } from "@pipelab/shared";
16
20
 
17
- import { ensure, generateTempFolder, extractTarGz, extractZip, zipFolder } from "./utils/fs-extras";
21
+ import { ensure, extractTarGz, extractZip, zipFolder } from "./utils/fs-extras";
18
22
  import { fetchPipelabAsset } from "./utils/remote";
19
23
  import { loadPipelabPlugin } from "./plugins-registry";
20
- import { setupConfigFile } from "./config";
21
- import { AppConfig } from "@pipelab/shared";
24
+ import { setupSettingsConfigFile } from "./config";
22
25
 
23
26
  export const getFinalPlugins = () => {
24
27
  const { plugins } = usePlugins();
@@ -29,13 +32,6 @@ export const getFinalPlugins = () => {
29
32
  for (const plugin of plugins.value) {
30
33
  const finalNodes = [];
31
34
 
32
- const transformUrl = (url: string) => {
33
- if (url.startsWith("file://")) {
34
- return url.replace("file://", "media://");
35
- }
36
- return url;
37
- };
38
-
39
35
  const finalIcon =
40
36
  plugin.icon?.type === "image"
41
37
  ? {
@@ -106,6 +102,7 @@ export const executeGraphWithHistory = async ({
106
102
  projectName,
107
103
  projectPath,
108
104
  pipelineId,
105
+ cachePath,
109
106
  startTime,
110
107
  status: "running",
111
108
  logs: [],
@@ -124,7 +121,7 @@ export const executeGraphWithHistory = async ({
124
121
  }
125
122
 
126
123
  const logs: any[] = [];
127
- const sandboxPath = await generateTempFolder(cachePath);
124
+ const sandboxPath = await ctx.createTempFolder("pipeline-sandbox-");
128
125
  const { logger } = useLogger();
129
126
  let completedSteps = 0;
130
127
  let failedSteps = 0;
@@ -152,7 +149,7 @@ export const executeGraphWithHistory = async ({
152
149
  }
153
150
 
154
151
  logger().info(`[Sandbox] Execution sandbox created at: ${sandboxPath}`);
155
- const settingsFile = await setupConfigFile<AppConfig>("settings", { context: ctx });
152
+ const settingsFile = await setupSettingsConfigFile(ctx);
156
153
  const config = await settingsFile.getConfig();
157
154
  const shouldCleanup = true;
158
155
 
@@ -251,18 +248,17 @@ export const executeGraphWithHistory = async ({
251
248
 
252
249
  throw error;
253
250
  } finally {
254
- // Apply retention policy regardless of outcome
255
- if (!shouldDisableHistory) {
256
- // Don't await, let it run in the background
257
- buildHistoryStorage.applyRetentionPolicy();
258
- }
259
-
260
251
  if (shouldCleanup) {
261
252
  try {
262
253
  await rm(sandboxPath, { recursive: true, force: true });
263
254
  } catch (e) {
264
255
  console.warn(`Failed to cleanup sandbox at ${sandboxPath}:`, e);
265
256
  }
257
+ try {
258
+ await rm(cachePath, { recursive: true, force: true });
259
+ } catch (e) {
260
+ console.warn(`Failed to cleanup cache at ${cachePath}:`, e);
261
+ }
266
262
  }
267
263
  }
268
264
  };