@pipelab/core-node 1.0.0-beta.0 → 1.0.0-beta.10

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.
@@ -1,7 +1,7 @@
1
1
  import { dirname, delimiter, join } from "node:path";
2
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";
3
+ import { mkdir, readdir, readFile, writeFile, access, chmod, rm, cp, rename } from "node:fs/promises";
4
+ import { existsSync, constants, statSync, readdirSync } from "node:fs";
5
5
  import pacote from "pacote";
6
6
  import semver from "semver";
7
7
  import { isDev, projectRoot, PipelabContext } from "../context";
@@ -12,6 +12,29 @@ import { downloadFile, extractZip, extractTarGz, generateTempFolder } from "./fs
12
12
  export const DEFAULT_NODE_VERSION = "24.14.1";
13
13
  export const DEFAULT_PNPM_VERSION = "10.12.0";
14
14
 
15
+ function isPackageComplete(packageDir: string): boolean {
16
+ return existsSync(join(packageDir, "package.json"));
17
+ }
18
+
19
+ function isNodeJSComplete(nodePath: string): boolean {
20
+ try {
21
+ return existsSync(nodePath) && statSync(nodePath).size > 0;
22
+ } catch {
23
+ return false;
24
+ }
25
+ }
26
+
27
+ function isDependenciesInstalledSync(packageDir: string): boolean {
28
+ const nodeModulesPath = join(packageDir, "node_modules");
29
+ if (!existsSync(nodeModulesPath)) return false;
30
+ try {
31
+ const files = readdirSync(nodeModulesPath);
32
+ return files.length > 0;
33
+ } catch {
34
+ return false;
35
+ }
36
+ }
37
+
15
38
  /**
16
39
  * In-memory lock to prevent concurrent operations on the same resource (e.g., downloading Node.js).
17
40
  */
@@ -94,7 +117,9 @@ export async function fetchPackage(
94
117
  const foundVersion = packument["dist-tags"]?.[range] || semver.maxSatisfying(versions, range);
95
118
 
96
119
  if (!foundVersion) {
97
- throw new Error(`Package ${packageName}@${range} not found on npm (available tags: ${Object.keys(packument["dist-tags"] || {}).join(", ")})`);
120
+ throw new Error(
121
+ `Package ${packageName}@${range} not found on npm (available tags: ${Object.keys(packument["dist-tags"] || {}).join(", ")})`,
122
+ );
98
123
  }
99
124
  resolvedVersion = foundVersion;
100
125
  console.log(`[Fetcher] ${packageName}: Resolved to v${resolvedVersion} via npm`);
@@ -111,19 +136,49 @@ export async function fetchPackage(
111
136
  const cachePath = join(ctx.userDataPath, "cache", "pacote");
112
137
  const packageDir = join(baseDir, resolvedVersion);
113
138
 
114
- // If the package already exists and we don't need to install dependencies, return immediately
115
- if (existsSync(packageDir) && !options?.installDeps) {
139
+ // If the package already exists and we don't need to install dependencies (or they are already installed), return immediately
140
+ const isInstalled = options?.installDeps
141
+ ? (isPackageComplete(packageDir) && isDependenciesInstalledSync(packageDir))
142
+ : isPackageComplete(packageDir);
143
+
144
+ if (isInstalled) {
116
145
  return { packageDir, resolvedVersion };
117
146
  }
118
147
 
119
148
  const lockKey = `package:${packageName}:${resolvedVersion}`;
120
149
  return withLock(lockKey, async () => {
121
- if (!existsSync(packageDir)) {
150
+ if (!isPackageComplete(packageDir)) {
122
151
  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
- });
152
+
153
+ const tempDir = join(baseDir, `.tmp-${resolvedVersion}-${Math.random().toString(36).slice(2)}`);
154
+ await mkdir(tempDir, { recursive: true });
155
+ try {
156
+ await pacote.extract(`${packageName}@${resolvedVersion}`, tempDir, {
157
+ cache: cachePath,
158
+ });
159
+
160
+ // Resolve entryPoint from package.json inside the temp directory first to ensure everything works
161
+ await resolveEntryPoint(tempDir, packageName);
162
+
163
+ // Remove existing incomplete packageDir if any
164
+ if (existsSync(packageDir)) {
165
+ await rm(packageDir, { recursive: true, force: true }).catch(() => {});
166
+ }
167
+
168
+ // Atomically rename the temp directory to the target packageDir
169
+ try {
170
+ await rename(tempDir, packageDir);
171
+ } catch (err: any) {
172
+ if (isPackageComplete(packageDir)) {
173
+ console.log(`[Fetcher] Destination ${packageDir} already exists and is valid.`);
174
+ } else {
175
+ throw err;
176
+ }
177
+ }
178
+ } catch (err) {
179
+ await rm(tempDir, { recursive: true, force: true }).catch(() => {});
180
+ throw err;
181
+ }
127
182
  }
128
183
 
129
184
  // 2. Resolve entry point from package.json for downloaded package
@@ -150,7 +205,13 @@ export async function runPnpm(
150
205
  },
151
206
  ) {
152
207
  const {
153
- args = ["install", "--prod", "--no-lockfile", "--prefer-offline", "--no-verify-store-integrity"],
208
+ args = [
209
+ "install",
210
+ "--prod",
211
+ "--no-lockfile",
212
+ "--prefer-offline",
213
+ "--no-verify-store-integrity",
214
+ ],
154
215
  extraEnv = {},
155
216
  signal,
156
217
  context: ctx,
@@ -183,21 +244,18 @@ export async function runPnpm(
183
244
  /**
184
245
  * Installs a specific version of Node.js if not already present.
185
246
  */
186
- export async function ensureNodeJS(
187
- context: PipelabContext,
188
- version = DEFAULT_NODE_VERSION,
189
- ) {
247
+ export async function ensureNodeJS(context: PipelabContext, version = DEFAULT_NODE_VERSION) {
190
248
  const isWindows = process.platform === "win32";
191
249
  const nodeDir = context.getThirdPartyPath("node", version);
192
250
  const finalNodePath = join(nodeDir, isWindows ? "node.exe" : "bin/node");
193
251
 
194
- if (existsSync(finalNodePath)) {
252
+ if (isNodeJSComplete(finalNodePath)) {
195
253
  return finalNodePath;
196
254
  }
197
255
 
198
256
  const lockKey = `node:${version}`;
199
257
  return withLock(lockKey, async () => {
200
- if (existsSync(finalNodePath)) return finalNodePath;
258
+ if (isNodeJSComplete(finalNodePath)) return finalNodePath;
201
259
 
202
260
  const arch = process.arch === "x64" ? "x64" : process.arch === "arm64" ? "arm64" : "x86";
203
261
  const platform = isWindows ? "win" : process.platform === "darwin" ? "osx" : "linux";
@@ -229,12 +287,37 @@ export async function ensureNodeJS(
229
287
  if (!nodeSubDir) throw new Error(`Could not find extracted Node.js directory`);
230
288
 
231
289
  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 });
290
+ const parentDir = dirname(nodeDir);
291
+ await mkdir(parentDir, { recursive: true });
292
+
293
+ const tempNodeDir = join(parentDir, `.tmp-node-${version}-${Math.random().toString(36).slice(2)}`);
294
+ await mkdir(tempNodeDir, { recursive: true });
295
+
296
+ try {
297
+ await cp(sourceDir, tempNodeDir, { recursive: true });
298
+ if (!isWindows) {
299
+ const tempNodePath = join(tempNodeDir, "bin/node");
300
+ await chmod(tempNodePath, 0o755).catch(() => {});
301
+ }
302
+
303
+ if (existsSync(nodeDir)) {
304
+ await rm(nodeDir, { recursive: true, force: true }).catch(() => {});
305
+ }
306
+
307
+ try {
308
+ await rename(tempNodeDir, nodeDir);
309
+ } catch (err: any) {
310
+ if (isNodeJSComplete(finalNodePath)) {
311
+ console.log(`[Fetcher] Node.js directory already exists and is valid.`);
312
+ } else {
313
+ throw err;
314
+ }
315
+ }
316
+ } finally {
317
+ await rm(tempNodeDir, { recursive: true, force: true }).catch(() => {});
318
+ await rm(tempDir, { recursive: true, force: true }).catch(() => {});
319
+ }
236
320
 
237
- if (!isWindows) await chmod(finalNodePath, 0o755).catch(() => { });
238
321
  return finalNodePath;
239
322
  });
240
323
  }
@@ -242,10 +325,7 @@ export async function ensureNodeJS(
242
325
  /**
243
326
  * Installs the PNPM package from npm if not already present.
244
327
  */
245
- export async function ensurePNPM(
246
- context: PipelabContext,
247
- version = DEFAULT_PNPM_VERSION,
248
- ) {
328
+ export async function ensurePNPM(context: PipelabContext, version = DEFAULT_PNPM_VERSION) {
249
329
  const pnpmDir = context.getPackagesPath("pnpm", version);
250
330
  const pnpmPath = join(pnpmDir, "bin", "pnpm.cjs");
251
331
 
@@ -267,25 +347,33 @@ export async function ensurePNPM(
267
347
  async function installDependencies(packageDir: string, packageName: string, options: FetchOptions) {
268
348
  const nodeModulesPath = join(packageDir, "node_modules");
269
349
 
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
- }
350
+ if (isDependenciesInstalledSync(packageDir)) {
351
+ console.log(`[Fetcher] ${packageName}: Dependencies already installed, skipping.`);
352
+ return;
279
353
  }
280
354
 
281
- console.log(`[Fetcher] ${packageName}: Ensuring dependencies are installed...`);
355
+ // To prevent other processes from seeing a partially populated node_modules,
356
+ // we can create a temp directory next to packageDir, run pnpm there, and rename node_modules
357
+ const tempDir = `${packageDir}.tmp-deps-${Math.random().toString(36).slice(2)}`;
358
+ await mkdir(tempDir, { recursive: true });
282
359
  try {
283
- const { all } = await runPnpm(packageDir, {
360
+ // Copy package.json to tempDir so pnpm can install dependencies
361
+ await cp(join(packageDir, "package.json"), join(tempDir, "package.json"));
362
+
363
+ console.log(`[Fetcher] ${packageName}: Ensuring dependencies are installed...`);
364
+ const { all } = await runPnpm(tempDir, {
284
365
  signal: options.signal,
285
366
  context: options.context,
286
367
  });
287
368
 
288
369
  if (all) console.log(`[Fetcher] ${packageName}: Installation trace:\n${all}`);
370
+
371
+ // Atomically rename node_modules from tempDir to packageDir/node_modules
372
+ const tempNodeModules = join(tempDir, "node_modules");
373
+ if (existsSync(nodeModulesPath)) {
374
+ await rm(nodeModulesPath, { recursive: true, force: true }).catch(() => {});
375
+ }
376
+ await rename(tempNodeModules, nodeModulesPath);
289
377
  console.log(`[Fetcher] ${packageName}: Dependencies installed successfully.`);
290
378
  } catch (err: any) {
291
379
  console.error(
@@ -293,6 +381,8 @@ async function installDependencies(packageDir: string, packageName: string, opti
293
381
  );
294
382
  if (err.all) console.error(`[Fetcher] ${packageName}: Error details:\n${err.all}`);
295
383
  throw new Error(`Failed to install dependencies for ${packageName}. See logs for details.`);
384
+ } finally {
385
+ await rm(tempDir, { recursive: true, force: true }).catch(() => {});
296
386
  }
297
387
  }
298
388
 
@@ -316,17 +406,14 @@ export async function fetchPipelabPlugin(
316
406
  options: FetchOptions,
317
407
  ): Promise<{ packageDir: string; entryPoint: string; isLocal: boolean }> {
318
408
  const { packageDir, isLocal, entryPoint } = await fetchPackage(pluginName, versionOrRange, {
319
- installDeps: true,
409
+ installDeps: false,
320
410
  ...options,
321
411
  });
322
412
 
323
413
  // Default entry point if not provided by fetchPackage
324
414
  let finalEntryPoint = entryPoint;
325
415
  if (!finalEntryPoint) {
326
- const patterns = [
327
- join(packageDir, "dist", "index.mjs"),
328
- join(packageDir, "index.mjs"),
329
- ];
416
+ const patterns = [join(packageDir, "dist", "index.mjs"), join(packageDir, "index.mjs")];
330
417
  finalEntryPoint = patterns.find((p) => existsSync(p)) || patterns[0];
331
418
  }
332
419
 
@@ -346,10 +433,7 @@ export async function fetchPipelabCli(
346
433
  // Default entry point for CLI if not provided
347
434
  let finalEntryPoint = entryPoint;
348
435
  if (!finalEntryPoint) {
349
- const patterns = [
350
- join(packageDir, "dist", "index.mjs"),
351
- join(packageDir, "index.mjs"),
352
- ];
436
+ const patterns = [join(packageDir, "dist", "index.mjs"), join(packageDir, "index.mjs")];
353
437
  finalEntryPoint = patterns.find((p) => existsSync(p)) || patterns[0];
354
438
  }
355
439
 
@@ -387,7 +471,7 @@ async function tryResolveMonorepoPackage(
387
471
  typeof pkg.bin === "string"
388
472
  ? pkg.bin
389
473
  : pkg.bin?.[packageName.replace("@pipelab/", "")] ||
390
- (pkg.bin ? pkg.bin[Object.keys(pkg.bin)[0]] : undefined);
474
+ (pkg.bin ? pkg.bin[Object.keys(pkg.bin)[0]] : undefined);
391
475
 
392
476
  // In dev, we prefer the "main" field if it points to TS, or a hardcoded src/index.ts
393
477
  const tsSource = join(packageDir, "src", "index.ts");
@@ -434,11 +518,11 @@ async function crawlMonorepoPackages(): Promise<Record<string, string>> {
434
518
  if (pkg.name) {
435
519
  cache[pkg.name] = join(fullDir, entry.name);
436
520
  }
437
- } catch (e) { }
521
+ } catch (e) {}
438
522
  }
439
523
  }
440
524
  }
441
- } catch (e) { }
525
+ } catch (e) {}
442
526
  }
443
527
  return cache;
444
528
  }
package/src/utils.ts CHANGED
@@ -256,7 +256,7 @@ export const executeGraphWithHistory = async ({
256
256
  // Don't await, let it run in the background
257
257
  buildHistoryStorage.applyRetentionPolicy();
258
258
  }
259
-
259
+
260
260
  if (shouldCleanup) {
261
261
  try {
262
262
  await rm(sandboxPath, { recursive: true, force: true });