@pipelab/core-node 1.0.0-beta.14 → 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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pipelab/core-node",
3
- "version": "1.0.0-beta.14",
3
+ "version": "1.0.0-beta.15",
4
4
  "private": false,
5
5
  "description": "The Pipelab automation engine for Node.js",
6
6
  "license": "FSL-1.1-MIT",
@@ -40,8 +40,8 @@
40
40
  "type-fest": "4.39.0",
41
41
  "ws": "8.18.3",
42
42
  "yauzl": "2.10.0",
43
- "@pipelab/constants": "1.0.0-beta.12",
44
- "@pipelab/shared": "1.0.0-beta.10"
43
+ "@pipelab/constants": "1.0.0-beta.13",
44
+ "@pipelab/shared": "1.0.0-beta.11"
45
45
  },
46
46
  "devDependencies": {
47
47
  "@types/adm-zip": "0.5.7",
@@ -56,7 +56,7 @@
56
56
  "tsdown": "0.21.2",
57
57
  "typescript": "^5.0.0",
58
58
  "vitest": "3.1.4",
59
- "@pipelab/tsconfig": "1.0.0-beta.10"
59
+ "@pipelab/tsconfig": "1.0.0-beta.11"
60
60
  },
61
61
  "scripts": {
62
62
  "build": "tsdown",
@@ -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
+ });
@@ -11,6 +11,7 @@ import {
11
11
  rename,
12
12
  } from "node:fs/promises";
13
13
  import { existsSync, constants, statSync, readdirSync } from "node:fs";
14
+ import dns from "node:dns/promises";
14
15
  import pacote from "pacote";
15
16
  import semver from "semver";
16
17
  import { isDev, projectRoot, PipelabContext } from "../context";
@@ -68,6 +69,27 @@ async function withLock<T>(key: string, fn: () => Promise<T>): Promise<T> {
68
69
  return promise;
69
70
  }
70
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
+
71
93
  export type FetchOptions = {
72
94
  installDeps?: boolean;
73
95
  signal?: AbortSignal;
@@ -118,39 +140,15 @@ export async function fetchPackage(
118
140
  console.log(`[Fetcher] Resolving ${packageName}@${resolvedVersionOrRange || "latest"}...`);
119
141
  const resolveStart = Date.now();
120
142
 
121
- try {
122
- // 1. Resolve version/range using npm with session-wide memoization and disk cache
123
- const cachePath = join(ctx.userDataPath, "cache", "pacote");
124
- let packumentPromise = packumentRequests.get(packageName);
125
- if (!packumentPromise) {
126
- packumentPromise = pacote.packument(packageName, { cache: cachePath });
127
- packumentRequests.set(packageName, packumentPromise);
128
- }
129
-
130
- const packument = await packumentPromise;
131
- const versions = Object.keys(packument.versions);
132
- const range = resolvedVersionOrRange || "latest";
133
-
134
- // Prioritize tags (like 'latest', 'beta', etc.) over semver ranges
135
- const foundVersion = packument["dist-tags"]?.[range] || semver.maxSatisfying(versions, range);
136
-
137
- if (!foundVersion) {
138
- throw new Error(
139
- `Package ${packageName}@${range} not found on npm (available tags: ${Object.keys(packument["dist-tags"] || {}).join(", ")})`,
140
- );
141
- }
142
- resolvedVersion = foundVersion;
143
- console.log(
144
- `[Fetcher] ${packageName}: Resolved to v${resolvedVersion} via npm (${Date.now() - resolveStart}ms)`,
145
- );
146
- } catch (error) {
143
+ const online = await isOnline();
144
+ if (!online) {
147
145
  console.warn(
148
- `[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...`,
149
147
  );
150
148
  const fallbackStart = Date.now();
151
149
  const fallbackVersion = await tryLocalFallback(
152
150
  resolvedVersionOrRange,
153
- error,
151
+ new Error("Offline"),
154
152
  baseDir,
155
153
  packageName,
156
154
  );
@@ -160,7 +158,72 @@ export async function fetchPackage(
160
158
  `[Fetcher] ${packageName}: Resolved to local fallback ${resolvedVersion} (${Date.now() - fallbackStart}ms)`,
161
159
  );
162
160
  } else {
163
- 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
+ }
164
227
  }
165
228
  }
166
229