@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.
- package/.oxfmtrc.json +1 -7
- package/CHANGELOG.md +18 -0
- package/dist/index.d.mts +24 -12
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +138 -43
- package/dist/index.mjs.map +1 -1
- package/package.json +9 -5
- package/src/config.test.ts +176 -0
- package/src/config.ts +44 -24
- package/src/context.ts +51 -2
- package/src/handlers/build-history.ts +14 -1
- package/src/handlers/engine.ts +6 -7
- package/src/handlers/history.ts +1 -1
- package/src/handlers/plugins.ts +1 -6
- package/src/migrations.ts +3 -15
- package/src/plugins-registry.ts +1 -3
- package/src/runner.ts +0 -1
- package/src/server.ts +1 -0
- package/src/types/runner.ts +1 -0
- package/src/utils/fs-extras.ts +2 -23
- package/src/utils/github.test.ts +213 -0
- package/src/utils/remote.test.ts +200 -0
- package/src/utils/remote.ts +95 -34
- package/src/utils.ts +2 -2
|
@@ -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
|
+
});
|
package/src/utils/remote.ts
CHANGED
|
@@ -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
|
|
20
|
+
import { downloadFile, extractZip, extractTarGz } from "./fs-extras";
|
|
21
21
|
|
|
22
|
-
|
|
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
|
-
|
|
124
|
-
|
|
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}:
|
|
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
|
-
|
|
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
|
|
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
|
|
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,
|
|
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
|
|
123
|
+
const sandboxPath = await ctx.createTempFolder("pipeline-sandbox-");
|
|
124
124
|
const { logger } = useLogger();
|
|
125
125
|
let completedSteps = 0;
|
|
126
126
|
let failedSteps = 0;
|