@pipelab/core-node 1.0.0-beta.14 → 1.0.0-beta.16
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/CHANGELOG.md +18 -0
- package/dist/index.d.mts +2 -1
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +37 -4
- package/dist/index.mjs.map +1 -1
- package/package.json +4 -4
- package/src/plugins-registry.ts +5 -1
- package/src/utils/remote.test.ts +200 -0
- package/src/utils/remote.ts +92 -29
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pipelab/core-node",
|
|
3
|
-
"version": "1.0.0-beta.
|
|
3
|
+
"version": "1.0.0-beta.16",
|
|
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.
|
|
44
|
-
"@pipelab/shared": "1.0.0-beta.
|
|
43
|
+
"@pipelab/constants": "1.0.0-beta.14",
|
|
44
|
+
"@pipelab/shared": "1.0.0-beta.12"
|
|
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.
|
|
59
|
+
"@pipelab/tsconfig": "1.0.0-beta.12"
|
|
60
60
|
},
|
|
61
61
|
"scripts": {
|
|
62
62
|
"build": "tsdown",
|
package/src/plugins-registry.ts
CHANGED
|
@@ -215,6 +215,10 @@ export const builtInPlugins = async (options: { context: PipelabContext }): Prom
|
|
|
215
215
|
const { usePlugins } = await import("@pipelab/shared");
|
|
216
216
|
const { registerPlugins } = usePlugins();
|
|
217
217
|
const { webSocketServer } = await import("./index");
|
|
218
|
+
|
|
219
|
+
// Broadcast ready signal immediately so the UI launches while plugins load in the background
|
|
220
|
+
webSocketServer.broadcast("startup:progress", { type: "ready" });
|
|
221
|
+
|
|
218
222
|
// Load plugins asynchronously in the background
|
|
219
223
|
(async () => {
|
|
220
224
|
const totalStart = Date.now();
|
|
@@ -315,7 +319,7 @@ export const builtInPlugins = async (options: { context: PipelabContext }): Prom
|
|
|
315
319
|
console.log(`[Plugins] All startup plugins loaded in ${Date.now() - totalStart}ms.`);
|
|
316
320
|
sendStartupProgress("All plugins loaded.");
|
|
317
321
|
setTimeout(() => {
|
|
318
|
-
webSocketServer.broadcast("startup:progress", { type: "
|
|
322
|
+
webSocketServer.broadcast("startup:progress", { type: "done" });
|
|
319
323
|
}, 2000);
|
|
320
324
|
})();
|
|
321
325
|
};
|
|
@@ -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
|
+
});
|
package/src/utils/remote.ts
CHANGED
|
@@ -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
|
-
|
|
122
|
-
|
|
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}:
|
|
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
|
-
|
|
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
|
|
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
|
|