@pipelab/core-node 1.0.0-beta.13 → 1.0.0-beta.15

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.
@@ -0,0 +1,213 @@
1
+ import { describe, test, expect, beforeEach, afterEach, vi } from "vitest";
2
+ import {
3
+ fetchPackageReleases,
4
+ fetchLatestPackageRelease,
5
+ fetchLatestDesktopRelease,
6
+ } from "./github";
7
+
8
+ describe("GitHub Release Updates API", () => {
9
+ let fetchSpy: any;
10
+
11
+ beforeEach(() => {
12
+ fetchSpy = vi.spyOn(global, "fetch");
13
+ // Clean up environment variables
14
+ delete process.env.PIPELAB_OVERRIDE_RELEASE;
15
+ });
16
+
17
+ afterEach(() => {
18
+ vi.restoreAllMocks();
19
+ });
20
+
21
+ test("fetchPackageReleases - returns empty array when git matching-refs fails", async () => {
22
+ fetchSpy.mockResolvedValueOnce({
23
+ ok: false,
24
+ status: 404,
25
+ statusText: "Not Found",
26
+ } as any);
27
+
28
+ const releases = await fetchPackageReleases("@pipelab/app");
29
+ expect(releases).toEqual([]);
30
+ expect(fetchSpy).toHaveBeenCalledWith(
31
+ expect.stringContaining("matching-refs/tags/%40pipelab%2Fapp"),
32
+ expect.any(Object)
33
+ );
34
+ });
35
+
36
+ test("fetchPackageReleases - returns empty array when no tags match package", async () => {
37
+ fetchSpy.mockResolvedValueOnce({
38
+ ok: true,
39
+ json: async () => [],
40
+ } as any);
41
+
42
+ const releases = await fetchPackageReleases("@pipelab/app");
43
+ expect(releases).toEqual([]);
44
+ });
45
+
46
+ test("fetchPackageReleases - correctly filters prereleases when allowPrerelease is false", async () => {
47
+ // 1. mock matching refs
48
+ fetchSpy.mockResolvedValueOnce({
49
+ ok: true,
50
+ json: async () => [
51
+ { ref: "refs/tags/@pipelab/app@1.0.0-beta.1" },
52
+ { ref: "refs/tags/@pipelab/app@1.0.0" },
53
+ ],
54
+ } as any);
55
+
56
+ // 2. mock tag lookup for 1.0.0 (since 1.0.0-beta.1 is filtered out)
57
+ fetchSpy.mockResolvedValueOnce({
58
+ ok: true,
59
+ status: 200,
60
+ json: async () => ({
61
+ tag_name: "@pipelab/app@1.0.0",
62
+ prerelease: false,
63
+ published_at: "2026-06-04T00:00:00Z",
64
+ html_url: "https://github.com/CynToolkit/pipelab/releases/tag/%40pipelab%2Fapp%401.0.0",
65
+ assets: [],
66
+ }),
67
+ } as any);
68
+
69
+ const releases = await fetchPackageReleases("@pipelab/app", { allowPrerelease: false });
70
+ expect(releases).toHaveLength(1);
71
+ expect(releases[0].tag_name).toBe("@pipelab/app@1.0.0");
72
+ });
73
+
74
+ test("fetchPackageReleases - includes prereleases when allowPrerelease is true", async () => {
75
+ // 1. mock matching refs
76
+ fetchSpy.mockResolvedValueOnce({
77
+ ok: true,
78
+ json: async () => [
79
+ { ref: "refs/tags/@pipelab/app@1.0.0-beta.1" },
80
+ { ref: "refs/tags/@pipelab/app@1.0.0" },
81
+ ],
82
+ } as any);
83
+
84
+ // 2. mock tag lookup for 1.0.0 (since sorting puts 1.0.0 first)
85
+ fetchSpy.mockResolvedValueOnce({
86
+ ok: true,
87
+ status: 200,
88
+ json: async () => ({
89
+ tag_name: "@pipelab/app@1.0.0",
90
+ prerelease: false,
91
+ published_at: "2026-06-04T00:00:00Z",
92
+ html_url: "https://github.com/CynToolkit/pipelab/releases/tag/%40pipelab%2Fapp%401.0.0",
93
+ assets: [],
94
+ }),
95
+ } as any);
96
+
97
+ const releases = await fetchPackageReleases("@pipelab/app", { allowPrerelease: true });
98
+ expect(releases).toHaveLength(1);
99
+ expect(releases[0].tag_name).toBe("@pipelab/app@1.0.0");
100
+ });
101
+
102
+ test("fetchPackageReleases - handles tag that has no release (skips and falls back to older tag)", async () => {
103
+ // 1. mock matching refs
104
+ fetchSpy.mockResolvedValueOnce({
105
+ ok: true,
106
+ json: async () => [
107
+ { ref: "refs/tags/@pipelab/app@1.1.0" },
108
+ { ref: "refs/tags/@pipelab/app@1.0.0" },
109
+ ],
110
+ } as any);
111
+
112
+ // 2. mock tag lookup for 1.1.0 (returns 404 - no release yet, tag exists)
113
+ fetchSpy.mockResolvedValueOnce({
114
+ status: 404,
115
+ ok: false,
116
+ statusText: "Not Found",
117
+ } as any);
118
+
119
+ // 3. mock tag lookup for 1.0.0 (returns 200 - release exists)
120
+ fetchSpy.mockResolvedValueOnce({
121
+ ok: true,
122
+ status: 200,
123
+ json: async () => ({
124
+ tag_name: "@pipelab/app@1.0.0",
125
+ prerelease: false,
126
+ published_at: "2026-06-04T00:00:00Z",
127
+ assets: [],
128
+ }),
129
+ } as any);
130
+
131
+ const releases = await fetchPackageReleases("@pipelab/app");
132
+ expect(releases).toHaveLength(1);
133
+ expect(releases[0].tag_name).toBe("@pipelab/app@1.0.0");
134
+ });
135
+
136
+ test("fetchPackageReleases - handles environment variable PIPELAB_OVERRIDE_RELEASE override", async () => {
137
+ process.env.PIPELAB_OVERRIDE_RELEASE = "1.2.3";
138
+
139
+ // Mock direct tag retrieval
140
+ fetchSpy.mockResolvedValueOnce({
141
+ ok: true,
142
+ status: 200,
143
+ json: async () => ({
144
+ tag_name: "@pipelab/app@1.2.3",
145
+ prerelease: false,
146
+ published_at: "2026-06-04T00:00:00Z",
147
+ assets: [],
148
+ }),
149
+ } as any);
150
+
151
+ const releases = await fetchPackageReleases("@pipelab/app");
152
+ expect(releases).toHaveLength(1);
153
+ expect(releases[0].tag_name).toBe("@pipelab/app@1.2.3");
154
+ expect(fetchSpy).toHaveBeenCalledWith(
155
+ expect.stringContaining("releases/tags/%40pipelab%2Fapp%401.2.3"),
156
+ expect.any(Object)
157
+ );
158
+ });
159
+
160
+ test("fetchLatestPackageRelease - selects newest matching release", async () => {
161
+ // Mock matching refs
162
+ fetchSpy.mockResolvedValueOnce({
163
+ ok: true,
164
+ json: async () => [
165
+ { ref: "refs/tags/@pipelab/app@1.0.0" },
166
+ { ref: "refs/tags/@pipelab/app@2.0.0" },
167
+ { ref: "refs/tags/@pipelab/app@1.5.0" },
168
+ ],
169
+ } as any);
170
+
171
+ // Mock release lookup for 2.0.0 (since 2.0.0 is highest version)
172
+ fetchSpy.mockResolvedValueOnce({
173
+ ok: true,
174
+ status: 200,
175
+ json: async () => ({
176
+ tag_name: "@pipelab/app@2.0.0",
177
+ prerelease: false,
178
+ published_at: "2026-06-04T00:00:00Z",
179
+ assets: [],
180
+ }),
181
+ } as any);
182
+
183
+ const latest = await fetchLatestPackageRelease("@pipelab/app");
184
+ expect(latest).not.toBeNull();
185
+ expect(latest?.tag_name).toBe("@pipelab/app@2.0.0");
186
+ });
187
+
188
+ test("fetchLatestDesktopRelease - queries the @pipelab/app package specifically", async () => {
189
+ // Mock matching refs
190
+ fetchSpy.mockResolvedValueOnce({
191
+ ok: true,
192
+ json: async () => [
193
+ { ref: "refs/tags/@pipelab/app@3.0.0" },
194
+ ],
195
+ } as any);
196
+
197
+ // Mock release lookup
198
+ fetchSpy.mockResolvedValueOnce({
199
+ ok: true,
200
+ status: 200,
201
+ json: async () => ({
202
+ tag_name: "@pipelab/app@3.0.0",
203
+ prerelease: false,
204
+ published_at: "2026-06-04T00:00:00Z",
205
+ assets: [],
206
+ }),
207
+ } as any);
208
+
209
+ const latest = await fetchLatestDesktopRelease();
210
+ expect(latest).not.toBeNull();
211
+ expect(latest?.tag_name).toBe("@pipelab/app@3.0.0");
212
+ });
213
+ });
@@ -0,0 +1,200 @@
1
+ import { describe, test, expect, beforeEach, afterEach, vi } from "vitest";
2
+ import { isOnline, fetchPackage, fetchPipelabPlugin } from "./remote";
3
+ import dns from "node:dns/promises";
4
+ import pacote from "pacote";
5
+ import { vol } from "memfs";
6
+ import { PipelabContext } from "../context";
7
+ import fs from "node:fs/promises";
8
+ import path from "node:path";
9
+ import { existsSync } from "node:fs";
10
+
11
+ vi.mock("node:dns/promises", () => ({
12
+ default: {
13
+ lookup: vi.fn(),
14
+ },
15
+ }));
16
+
17
+ vi.mock("node:fs", async () => {
18
+ const memfs = await import("memfs");
19
+ return {
20
+ ...memfs.fs,
21
+ default: memfs.fs,
22
+ existsSync: (p: string) => memfs.fs.existsSync(p),
23
+ };
24
+ });
25
+
26
+ vi.mock("node:fs/promises", async () => {
27
+ const memfs = await import("memfs");
28
+ return {
29
+ ...memfs.fs.promises,
30
+ default: memfs.fs.promises,
31
+ };
32
+ });
33
+
34
+ vi.mock("pacote", () => ({
35
+ default: {
36
+ packument: vi.fn(),
37
+ extract: vi.fn(),
38
+ },
39
+ }));
40
+
41
+ describe("remote utilities & offline mode", () => {
42
+ beforeEach(() => {
43
+ vi.useFakeTimers();
44
+ vol.reset();
45
+ vi.restoreAllMocks();
46
+ });
47
+
48
+ afterEach(() => {
49
+ vi.useRealTimers();
50
+ });
51
+
52
+ test("isOnline returns true when dns.lookup succeeds", async () => {
53
+ vi.spyOn(dns, "lookup").mockResolvedValue({ address: "1.2.3.4", family: 4 } as any);
54
+ const online = await isOnline();
55
+ expect(online).toBe(true);
56
+ });
57
+
58
+ test("isOnline returns false when dns.lookup throws", async () => {
59
+ vi.advanceTimersByTime(11000); // Bypass 10s caching
60
+ vi.spyOn(dns, "lookup").mockRejectedValue(new Error("ENOTFOUND"));
61
+ const online = await isOnline();
62
+ expect(online).toBe(false);
63
+ });
64
+
65
+ test("fetchPackage falls back to local packages when offline", async () => {
66
+ // 1. Force offline status
67
+ vi.advanceTimersByTime(11000); // Bypass 10s caching
68
+ vi.spyOn(dns, "lookup").mockRejectedValue(new Error("Offline"));
69
+
70
+ const context = new PipelabContext({ userDataPath: "/tmp/pipelab-test-remote" });
71
+ const packageName = "my-plugin";
72
+ const packageBaseDir = context.getPackagesPath(packageName);
73
+ const cachedVersionDir = path.join(packageBaseDir, "1.0.0");
74
+
75
+ await fs.mkdir(cachedVersionDir, { recursive: true });
76
+ await fs.writeFile(
77
+ path.join(cachedVersionDir, "package.json"),
78
+ JSON.stringify({
79
+ name: packageName,
80
+ version: "1.0.0",
81
+ main: "index.mjs",
82
+ }),
83
+ );
84
+
85
+ // 2. Call fetchPackage
86
+ const result = await fetchPackage(packageName, "latest", { context });
87
+
88
+ // 3. Verify it resolved to local cached version without calling pacote
89
+ expect(result.resolvedVersion).toBe("1.0.0");
90
+ expect(result.packageDir).toBe(cachedVersionDir);
91
+ expect(pacote.packument).not.toHaveBeenCalled();
92
+ });
93
+
94
+ test("fetchPipelabPlugin maps latest to releaseTag for official plugins", async () => {
95
+ vi.advanceTimersByTime(30000); // Bypass 10s caching
96
+ const context = new PipelabContext({ userDataPath: "/tmp/pipelab-test-remote", releaseTag: "beta" });
97
+ const pluginName = "@pipelab/plugin-poki";
98
+ const packageBaseDir = context.getPackagesPath(pluginName);
99
+ const cachedVersionDir = path.join(packageBaseDir, "1.0.0-beta.15");
100
+
101
+ vi.spyOn(dns, "lookup").mockResolvedValue({ address: "1.2.3.4", family: 4 } as any);
102
+
103
+ vi.mocked(pacote.packument).mockResolvedValue({
104
+ name: pluginName,
105
+ versions: {
106
+ "1.0.0-beta.15": {},
107
+ },
108
+ "dist-tags": {
109
+ beta: "1.0.0-beta.15",
110
+ },
111
+ } as any);
112
+
113
+ await fs.mkdir(cachedVersionDir, { recursive: true });
114
+ await fs.writeFile(
115
+ path.join(cachedVersionDir, "package.json"),
116
+ JSON.stringify({
117
+ name: pluginName,
118
+ version: "1.0.0-beta.15",
119
+ main: "dist/index.mjs",
120
+ }),
121
+ );
122
+
123
+ const result = await fetchPipelabPlugin(pluginName, "latest", { context });
124
+
125
+ expect(pacote.packument).toHaveBeenCalledWith(pluginName, expect.any(Object));
126
+ expect(result.packageDir).toBe(cachedVersionDir);
127
+ });
128
+
129
+ test("fetchPipelabPlugin maps latest to releaseTag for custom plugins and falls back to latest if tag is missing", async () => {
130
+ vi.advanceTimersByTime(40000); // Bypass 10s caching
131
+ const context = new PipelabContext({ userDataPath: "/tmp/pipelab-test-remote-custom", releaseTag: "beta" });
132
+ const pluginName = "custom-cool-plugin";
133
+ const packageBaseDir = context.getPackagesPath(pluginName);
134
+ const cachedVersionDir = path.join(packageBaseDir, "2.0.0");
135
+
136
+ vi.spyOn(dns, "lookup").mockResolvedValue({ address: "1.2.3.4", family: 4 } as any);
137
+
138
+ vi.mocked(pacote.packument).mockResolvedValue({
139
+ name: pluginName,
140
+ versions: {
141
+ "2.0.0": {},
142
+ },
143
+ "dist-tags": {
144
+ latest: "2.0.0",
145
+ },
146
+ } as any);
147
+
148
+ await fs.mkdir(cachedVersionDir, { recursive: true });
149
+ await fs.writeFile(
150
+ path.join(cachedVersionDir, "package.json"),
151
+ JSON.stringify({
152
+ name: pluginName,
153
+ version: "2.0.0",
154
+ main: "dist/index.mjs",
155
+ }),
156
+ );
157
+
158
+ const result = await fetchPipelabPlugin(pluginName, "latest", { context });
159
+
160
+ expect(pacote.packument).toHaveBeenCalledWith(pluginName, expect.any(Object));
161
+ expect(result.packageDir).toBe(cachedVersionDir);
162
+ });
163
+
164
+ test("fetchPipelabPlugin maps latest to releaseTag for custom plugins and falls back to latest if beta is stale", async () => {
165
+ vi.advanceTimersByTime(50000); // Bypass 10s caching
166
+ const context = new PipelabContext({ userDataPath: "/tmp/pipelab-test-remote-stale", releaseTag: "beta" });
167
+ const pluginName = "stale-beta-plugin";
168
+ const packageBaseDir = context.getPackagesPath(pluginName);
169
+ const cachedVersionDir = path.join(packageBaseDir, "2.0.0");
170
+
171
+ vi.spyOn(dns, "lookup").mockResolvedValue({ address: "1.2.3.4", family: 4 } as any);
172
+
173
+ vi.mocked(pacote.packument).mockResolvedValue({
174
+ name: pluginName,
175
+ versions: {
176
+ "1.0.0-beta.1": {},
177
+ "2.0.0": {},
178
+ },
179
+ "dist-tags": {
180
+ beta: "1.0.0-beta.1",
181
+ latest: "2.0.0",
182
+ },
183
+ } as any);
184
+
185
+ await fs.mkdir(cachedVersionDir, { recursive: true });
186
+ await fs.writeFile(
187
+ path.join(cachedVersionDir, "package.json"),
188
+ JSON.stringify({
189
+ name: pluginName,
190
+ version: "2.0.0",
191
+ main: "dist/index.mjs",
192
+ }),
193
+ );
194
+
195
+ const result = await fetchPipelabPlugin(pluginName, "latest", { context });
196
+
197
+ expect(pacote.packument).toHaveBeenCalledWith(pluginName, expect.any(Object));
198
+ expect(result.packageDir).toBe(cachedVersionDir);
199
+ });
200
+ });
@@ -1,5 +1,4 @@
1
1
  import { dirname, delimiter, join } from "node:path";
2
- import { tmpdir } from "node:os";
3
2
  import {
4
3
  mkdir,
5
4
  readdir,
@@ -12,15 +11,15 @@ import {
12
11
  rename,
13
12
  } from "node:fs/promises";
14
13
  import { existsSync, constants, statSync, readdirSync } from "node:fs";
14
+ import dns from "node:dns/promises";
15
15
  import pacote from "pacote";
16
16
  import semver from "semver";
17
17
  import { isDev, projectRoot, PipelabContext } from "../context";
18
18
  import { execa } from "execa";
19
19
  import { sendStartupProgress } from "../server";
20
- import { downloadFile, extractZip, extractTarGz, generateTempFolder } from "./fs-extras";
20
+ import { downloadFile, extractZip, extractTarGz } from "./fs-extras";
21
21
 
22
- export const DEFAULT_NODE_VERSION = "24.14.1";
23
- export const DEFAULT_PNPM_VERSION = "10.12.0";
22
+ import { DEFAULT_NODE_VERSION, DEFAULT_PNPM_VERSION } from "@pipelab/constants";
24
23
 
25
24
  function isPackageComplete(packageDir: string): boolean {
26
25
  return existsSync(join(packageDir, "package.json"));
@@ -70,6 +69,27 @@ async function withLock<T>(key: string, fn: () => Promise<T>): Promise<T> {
70
69
  return promise;
71
70
  }
72
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
+
73
93
  export type FetchOptions = {
74
94
  installDeps?: boolean;
75
95
  signal?: AbortSignal;
@@ -120,39 +140,15 @@ export async function fetchPackage(
120
140
  console.log(`[Fetcher] Resolving ${packageName}@${resolvedVersionOrRange || "latest"}...`);
121
141
  const resolveStart = Date.now();
122
142
 
123
- try {
124
- // 1. Resolve version/range using npm with session-wide memoization and disk cache
125
- const cachePath = join(ctx.userDataPath, "cache", "pacote");
126
- let packumentPromise = packumentRequests.get(packageName);
127
- if (!packumentPromise) {
128
- packumentPromise = pacote.packument(packageName, { cache: cachePath });
129
- packumentRequests.set(packageName, packumentPromise);
130
- }
131
-
132
- const packument = await packumentPromise;
133
- const versions = Object.keys(packument.versions);
134
- const range = resolvedVersionOrRange || "latest";
135
-
136
- // Prioritize tags (like 'latest', 'beta', etc.) over semver ranges
137
- const foundVersion = packument["dist-tags"]?.[range] || semver.maxSatisfying(versions, range);
138
-
139
- if (!foundVersion) {
140
- throw new Error(
141
- `Package ${packageName}@${range} not found on npm (available tags: ${Object.keys(packument["dist-tags"] || {}).join(", ")})`,
142
- );
143
- }
144
- resolvedVersion = foundVersion;
145
- console.log(
146
- `[Fetcher] ${packageName}: Resolved to v${resolvedVersion} via npm (${Date.now() - resolveStart}ms)`,
147
- );
148
- } catch (error) {
143
+ const online = await isOnline();
144
+ if (!online) {
149
145
  console.warn(
150
- `[Fetcher] ${packageName}: remote resolution failed (${Date.now() - resolveStart}ms), trying local fallback...`,
146
+ `[Fetcher] ${packageName}: offline mode detected (${Date.now() - resolveStart}ms), trying local fallback...`,
151
147
  );
152
148
  const fallbackStart = Date.now();
153
149
  const fallbackVersion = await tryLocalFallback(
154
150
  resolvedVersionOrRange,
155
- error,
151
+ new Error("Offline"),
156
152
  baseDir,
157
153
  packageName,
158
154
  );
@@ -162,7 +158,72 @@ export async function fetchPackage(
162
158
  `[Fetcher] ${packageName}: Resolved to local fallback ${resolvedVersion} (${Date.now() - fallbackStart}ms)`,
163
159
  );
164
160
  } else {
165
- 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 = join(ctx.userDataPath, "cache", "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 (!foundVersion || (semver.valid(foundVersion) && semver.gte(releaseTagVersion, foundVersion))) {
187
+ console.log(
188
+ `[Fetcher] Using release tag "${ctx.releaseTag}" (${releaseTagVersion}) instead of "latest" (${foundVersion || "none"}) for ${packageName}`,
189
+ );
190
+ foundVersion = releaseTagVersion;
191
+ } else if (foundVersion) {
192
+ console.warn(
193
+ `[Fetcher] Tag "${ctx.releaseTag}" (${releaseTagVersion}) is older than "latest" (${foundVersion}) for ${packageName}, keeping "latest"`,
194
+ );
195
+ }
196
+ }
197
+ }
198
+
199
+ if (!foundVersion) {
200
+ throw new Error(
201
+ `Package ${packageName}@${range} not found on npm (available tags: ${Object.keys(packument["dist-tags"] || {}).join(", ")})`,
202
+ );
203
+ }
204
+ resolvedVersion = foundVersion;
205
+ console.log(
206
+ `[Fetcher] ${packageName}: Resolved to v${resolvedVersion} via npm (${Date.now() - resolveStart}ms)`,
207
+ );
208
+ } catch (error) {
209
+ console.warn(
210
+ `[Fetcher] ${packageName}: remote resolution failed (${Date.now() - resolveStart}ms), trying local fallback...`,
211
+ );
212
+ const fallbackStart = Date.now();
213
+ const fallbackVersion = await tryLocalFallback(
214
+ resolvedVersionOrRange,
215
+ error,
216
+ baseDir,
217
+ packageName,
218
+ );
219
+ if (fallbackVersion) {
220
+ resolvedVersion = fallbackVersion;
221
+ console.log(
222
+ `[Fetcher] ${packageName}: Resolved to local fallback ${resolvedVersion} (${Date.now() - fallbackStart}ms)`,
223
+ );
224
+ } else {
225
+ throw error;
226
+ }
166
227
  }
167
228
  }
168
229
 
@@ -324,7 +385,7 @@ export async function ensureNodeJS(context: PipelabContext, version = DEFAULT_NO
324
385
 
325
386
  const fileName = `node-v${version}-${downloadPlatform}-${arch}.${extension}`;
326
387
  const downloadUrl = `https://nodejs.org/dist/v${version}/${fileName}`;
327
- const tempDir = await generateTempFolder(tmpdir());
388
+ const tempDir = await context.createTempFolder("node-download-");
328
389
  const archivePath = join(tempDir, fileName);
329
390
 
330
391
  sendStartupProgress(`Downloading Node.js v${version}...`);
package/src/utils.ts CHANGED
@@ -18,7 +18,7 @@ import { constants, existsSync } from "node:fs";
18
18
  import { handleActionExecute } from "./handler-func";
19
19
  import { BuildHistoryStorage } from "./handlers/build-history";
20
20
 
21
- import { ensure, generateTempFolder, extractTarGz, extractZip, zipFolder } from "./utils/fs-extras";
21
+ import { ensure, extractTarGz, extractZip, zipFolder } from "./utils/fs-extras";
22
22
  import { fetchPipelabAsset } from "./utils/remote";
23
23
  import { loadPipelabPlugin } from "./plugins-registry";
24
24
  import { setupConfigFile } from "./config";
@@ -120,7 +120,7 @@ export const executeGraphWithHistory = async ({
120
120
  }
121
121
 
122
122
  const logs: any[] = [];
123
- const sandboxPath = await generateTempFolder(cachePath);
123
+ const sandboxPath = await ctx.createTempFolder("pipeline-sandbox-");
124
124
  const { logger } = useLogger();
125
125
  let completedSteps = 0;
126
126
  let failedSteps = 0;