@pipelab/core-node 1.0.0-beta.2 → 1.0.0-beta.21
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 -1
- package/CHANGELOG.md +164 -0
- package/dist/config-Bi0ORcTK.mjs +2 -0
- package/dist/config-CFgGRD9U.mjs +265 -0
- package/dist/config-CFgGRD9U.mjs.map +1 -0
- package/dist/index.d.mts +74 -53
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +1705 -576
- package/dist/index.mjs.map +1 -1
- package/package.json +9 -5
- package/scratch/simulate-updates.ts +120 -0
- package/src/config.test.ts +232 -0
- package/src/config.ts +111 -50
- package/src/context.ts +117 -5
- package/src/handler-func.ts +2 -77
- package/src/handlers/build-history.ts +60 -90
- package/src/handlers/config.ts +265 -55
- package/src/handlers/engine.ts +32 -35
- package/src/handlers/history.ts +0 -40
- package/src/handlers/index.ts +4 -0
- package/src/handlers/migration.ts +621 -0
- package/src/handlers/plugins.ts +305 -0
- package/src/handlers/system.ts +7 -2
- package/src/index.ts +9 -2
- package/src/plugins-registry.ts +280 -38
- package/src/presets/c3toSteam.ts +13 -7
- package/src/presets/demo.ts +9 -9
- package/src/presets/list.ts +0 -6
- package/src/presets/moreToCome.ts +3 -2
- package/src/presets/newProject.ts +3 -2
- package/src/presets/test-c3-offline.ts +15 -6
- package/src/presets/test-c3-unzip.ts +19 -8
- package/src/runner.ts +2 -8
- package/src/server.ts +45 -4
- package/src/types/runner.ts +2 -30
- package/src/utils/fs-extras.ts +6 -24
- package/src/utils/github.test.ts +211 -0
- package/src/utils/github.ts +90 -10
- package/src/utils/remote.test.ts +209 -0
- package/src/utils/remote.ts +325 -87
- package/src/utils/storage.ts +2 -1
- package/src/utils.ts +20 -24
- package/src/migrations.ts +0 -72
- package/src/presets/if.ts +0 -69
- package/src/presets/loop.ts +0 -65
|
@@ -7,7 +7,7 @@ export const testC3Unzip: PresetFn = async () => {
|
|
|
7
7
|
const steamUpload = "steam-upload-node";
|
|
8
8
|
|
|
9
9
|
const data: SavedFile = {
|
|
10
|
-
version: "
|
|
10
|
+
version: "5.0.0",
|
|
11
11
|
name: "From Construct to Steam",
|
|
12
12
|
description: "Export from Construct, package with Electron, then upload to Steam",
|
|
13
13
|
variables: [],
|
|
@@ -16,8 +16,9 @@ export const testC3Unzip: PresetFn = async () => {
|
|
|
16
16
|
{
|
|
17
17
|
type: "event",
|
|
18
18
|
origin: {
|
|
19
|
-
pluginId: "system",
|
|
19
|
+
pluginId: "@pipelab/plugin-system",
|
|
20
20
|
nodeId: "manual",
|
|
21
|
+
version: "latest",
|
|
21
22
|
},
|
|
22
23
|
uid: "manual-start",
|
|
23
24
|
params: {},
|
|
@@ -29,7 +30,8 @@ export const testC3Unzip: PresetFn = async () => {
|
|
|
29
30
|
type: "action",
|
|
30
31
|
origin: {
|
|
31
32
|
nodeId: "export-construct-project",
|
|
32
|
-
pluginId: "construct",
|
|
33
|
+
pluginId: "@pipelab/plugin-construct",
|
|
34
|
+
version: "latest",
|
|
33
35
|
},
|
|
34
36
|
params: {
|
|
35
37
|
file: {
|
|
@@ -59,7 +61,8 @@ export const testC3Unzip: PresetFn = async () => {
|
|
|
59
61
|
type: "action",
|
|
60
62
|
origin: {
|
|
61
63
|
nodeId: "unzip-file-node",
|
|
62
|
-
pluginId: "filesystem",
|
|
64
|
+
pluginId: "@pipelab/plugin-filesystem",
|
|
65
|
+
version: "latest",
|
|
63
66
|
},
|
|
64
67
|
params: {
|
|
65
68
|
file: {
|
|
@@ -73,15 +76,22 @@ export const testC3Unzip: PresetFn = async () => {
|
|
|
73
76
|
type: "action",
|
|
74
77
|
origin: {
|
|
75
78
|
nodeId: "electron:package",
|
|
76
|
-
pluginId: "electron",
|
|
79
|
+
pluginId: "@pipelab/plugin-electron",
|
|
80
|
+
version: "latest",
|
|
77
81
|
},
|
|
78
82
|
params: {
|
|
79
83
|
"input-folder": {
|
|
80
84
|
editor: "editor",
|
|
81
85
|
value: `steps['${unzipFileId}']['outputs']['output']`,
|
|
82
86
|
},
|
|
83
|
-
arch:
|
|
84
|
-
|
|
87
|
+
arch: {
|
|
88
|
+
editor: "simple",
|
|
89
|
+
value: undefined,
|
|
90
|
+
},
|
|
91
|
+
platform: {
|
|
92
|
+
editor: "simple",
|
|
93
|
+
value: undefined,
|
|
94
|
+
},
|
|
85
95
|
},
|
|
86
96
|
},
|
|
87
97
|
{
|
|
@@ -89,7 +99,8 @@ export const testC3Unzip: PresetFn = async () => {
|
|
|
89
99
|
type: "action",
|
|
90
100
|
origin: {
|
|
91
101
|
nodeId: "steam-upload",
|
|
92
|
-
pluginId: "steam",
|
|
102
|
+
pluginId: "@pipelab/plugin-steam",
|
|
103
|
+
version: "latest",
|
|
93
104
|
},
|
|
94
105
|
params: {
|
|
95
106
|
folder: {
|
package/src/runner.ts
CHANGED
|
@@ -1,12 +1,9 @@
|
|
|
1
1
|
import { executeGraphWithHistory } from "./utils";
|
|
2
2
|
import { setupConfigFile } from "./config";
|
|
3
|
-
import { isDev, PipelabContext } from "./context";
|
|
3
|
+
import { isDev, PipelabContext, CacheFolder } from "./context";
|
|
4
4
|
import { readFile, access, writeFile, mkdir } from "node:fs/promises";
|
|
5
5
|
import { resolve, isAbsolute, join, dirname } from "node:path";
|
|
6
|
-
import { tmpdir } from "node:os";
|
|
7
6
|
import { savedFileMigrator } from "@pipelab/shared";
|
|
8
|
-
import type { AppConfig } from "@pipelab/shared";
|
|
9
|
-
import { registerMigrationHandlers } from "./migrations";
|
|
10
7
|
import { registerAllHandlers } from "./handlers/index";
|
|
11
8
|
|
|
12
9
|
export interface RunOptions {
|
|
@@ -66,11 +63,8 @@ export async function runPipelineCommand(file: string, options: RunOptions, vers
|
|
|
66
63
|
});
|
|
67
64
|
|
|
68
65
|
await registerAllHandlers({ version, context });
|
|
69
|
-
registerMigrationHandlers(context);
|
|
70
66
|
|
|
71
|
-
const
|
|
72
|
-
const config = await settings.getConfig();
|
|
73
|
-
const cachePath = join(context.userDataPath, "cache");
|
|
67
|
+
const cachePath = context.getCachePath(CacheFolder.Pipelines, effectivePipelineId);
|
|
74
68
|
await mkdir(cachePath, { recursive: true });
|
|
75
69
|
|
|
76
70
|
const abortController = new AbortController();
|
package/src/server.ts
CHANGED
|
@@ -9,10 +9,9 @@ import {
|
|
|
9
9
|
import { getUiDevServerMissingWarning, uiDevPort } from "@pipelab/constants";
|
|
10
10
|
import { existsSync } from "node:fs";
|
|
11
11
|
import { readFile } from "node:fs/promises";
|
|
12
|
-
import { join } from "node:path";
|
|
13
12
|
import http from "http";
|
|
13
|
+
// @ts-expect-error serve-handler has no type definitions
|
|
14
14
|
import handler from "serve-handler";
|
|
15
|
-
import { registerMigrationHandlers } from "./migrations";
|
|
16
15
|
|
|
17
16
|
export interface ServeOptions {
|
|
18
17
|
port: string | number;
|
|
@@ -38,16 +37,58 @@ export const sendStartupReady = () => {
|
|
|
38
37
|
|
|
39
38
|
export async function serveCommand(options: ServeOptions, version: string, _dirname: string) {
|
|
40
39
|
if (!options.userData) throw new Error("userDataPath is required for serveCommand");
|
|
40
|
+
const releaseTag = version.includes("beta") ? "beta" : "latest";
|
|
41
41
|
const context = new PipelabContext({
|
|
42
42
|
userDataPath: options.userData,
|
|
43
|
+
releaseTag,
|
|
43
44
|
});
|
|
44
45
|
|
|
45
46
|
let rawAssetFolder: string | undefined;
|
|
46
47
|
if (!isDev) {
|
|
47
|
-
rawAssetFolder = await fetchPipelabAsset("@pipelab/ui",
|
|
48
|
+
rawAssetFolder = await fetchPipelabAsset("@pipelab/ui", releaseTag, { context });
|
|
48
49
|
}
|
|
49
50
|
|
|
50
51
|
const server = http.createServer(async (request, response) => {
|
|
52
|
+
// Serve local media files securely via HTTP
|
|
53
|
+
if (request.url?.startsWith("/media-file/")) {
|
|
54
|
+
const prefix = "/media-file/";
|
|
55
|
+
const encodedPath = request.url.substring(prefix.length);
|
|
56
|
+
const filePath = decodeURIComponent(encodedPath);
|
|
57
|
+
// Strip leading slash on Windows if followed by a drive letter (e.g. /C:/...)
|
|
58
|
+
const normalizedPath =
|
|
59
|
+
filePath.startsWith("/") && filePath.match(/^\/[a-zA-Z]:/)
|
|
60
|
+
? filePath.substring(1)
|
|
61
|
+
: filePath;
|
|
62
|
+
|
|
63
|
+
if (existsSync(normalizedPath)) {
|
|
64
|
+
try {
|
|
65
|
+
const content = await readFile(normalizedPath);
|
|
66
|
+
let contentType = "application/octet-stream";
|
|
67
|
+
if (normalizedPath.endsWith(".png")) contentType = "image/png";
|
|
68
|
+
else if (normalizedPath.endsWith(".jpg") || normalizedPath.endsWith(".jpeg"))
|
|
69
|
+
contentType = "image/jpeg";
|
|
70
|
+
else if (normalizedPath.endsWith(".svg")) contentType = "image/svg+xml";
|
|
71
|
+
else if (normalizedPath.endsWith(".gif")) contentType = "image/gif";
|
|
72
|
+
else if (normalizedPath.endsWith(".webp")) contentType = "image/webp";
|
|
73
|
+
|
|
74
|
+
response.writeHead(200, {
|
|
75
|
+
"Content-Type": contentType,
|
|
76
|
+
"Access-Control-Allow-Origin": "*",
|
|
77
|
+
});
|
|
78
|
+
response.end(content);
|
|
79
|
+
return;
|
|
80
|
+
} catch (e) {
|
|
81
|
+
response.writeHead(500, { "Content-Type": "text/plain" });
|
|
82
|
+
response.end(`Error reading file: ${e}`);
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
} else {
|
|
86
|
+
response.writeHead(404, { "Content-Type": "text/plain" });
|
|
87
|
+
response.end(`File not found: ${normalizedPath}`);
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
51
92
|
if (isDev) {
|
|
52
93
|
response.writeHead(200, { "Content-Type": "text/html" });
|
|
53
94
|
response.end(`
|
|
@@ -73,6 +114,7 @@ export async function serveCommand(options: ServeOptions, version: string, _dirn
|
|
|
73
114
|
|
|
74
115
|
return handler(request, response, {
|
|
75
116
|
public: rawAssetFolder,
|
|
117
|
+
rewrites: [{ source: "/**", destination: "/index.html" }],
|
|
76
118
|
});
|
|
77
119
|
});
|
|
78
120
|
|
|
@@ -91,7 +133,6 @@ export async function serveCommand(options: ServeOptions, version: string, _dirn
|
|
|
91
133
|
version,
|
|
92
134
|
context,
|
|
93
135
|
});
|
|
94
|
-
registerMigrationHandlers(context);
|
|
95
136
|
|
|
96
137
|
sendStartupReady();
|
|
97
138
|
|
package/src/types/runner.ts
CHANGED
|
@@ -2,32 +2,22 @@ import type { BrowserWindow } from "electron";
|
|
|
2
2
|
import type { PipelabContext } from "../context";
|
|
3
3
|
import type {
|
|
4
4
|
Action,
|
|
5
|
-
Condition,
|
|
6
|
-
Loop,
|
|
7
5
|
Expression,
|
|
8
6
|
Event,
|
|
9
7
|
SetOutputActionFn,
|
|
10
|
-
SetOutputLoopFn,
|
|
11
8
|
SetOutputExpressionFn,
|
|
12
9
|
ExtractInputsFromAction,
|
|
13
|
-
ExtractInputsFromCondition,
|
|
14
|
-
ExtractInputsFromLoop,
|
|
15
10
|
ExtractInputsFromEvent,
|
|
16
11
|
ExtractInputsFromExpression,
|
|
17
12
|
} from "@pipelab/shared";
|
|
18
13
|
|
|
19
14
|
export type {
|
|
20
15
|
Action,
|
|
21
|
-
Condition,
|
|
22
|
-
Loop,
|
|
23
16
|
Expression,
|
|
24
17
|
Event,
|
|
25
18
|
SetOutputActionFn,
|
|
26
|
-
SetOutputLoopFn,
|
|
27
19
|
SetOutputExpressionFn,
|
|
28
20
|
ExtractInputsFromAction,
|
|
29
|
-
ExtractInputsFromCondition,
|
|
30
|
-
ExtractInputsFromLoop,
|
|
31
21
|
ExtractInputsFromEvent,
|
|
32
22
|
ExtractInputsFromExpression,
|
|
33
23
|
};
|
|
@@ -45,6 +35,7 @@ export type ActionRunnerData<ACTION extends Action> = {
|
|
|
45
35
|
setMeta: (callback: (data: ACTION["meta"]) => ACTION["meta"]) => void;
|
|
46
36
|
meta: ACTION["meta"];
|
|
47
37
|
cwd: string;
|
|
38
|
+
/** @deprecated Use `context` instead to resolve sandboxed folders and binary paths. */
|
|
48
39
|
paths: {
|
|
49
40
|
cache: string;
|
|
50
41
|
pnpm: string;
|
|
@@ -60,25 +51,6 @@ export type ActionRunnerData<ACTION extends Action> = {
|
|
|
60
51
|
|
|
61
52
|
export type ActionRunner<ACTION extends Action> = (data: ActionRunnerData<ACTION>) => Promise<void>;
|
|
62
53
|
|
|
63
|
-
export type ConditionRunner<CONDITION extends Condition> = (data: {
|
|
64
|
-
log: typeof console.log;
|
|
65
|
-
inputs: ExtractInputsFromCondition<CONDITION>;
|
|
66
|
-
setMeta: (callback: (data: CONDITION["meta"]) => CONDITION["meta"]) => void;
|
|
67
|
-
meta: CONDITION["meta"];
|
|
68
|
-
cwd: string;
|
|
69
|
-
context: PipelabContext;
|
|
70
|
-
}) => Promise<boolean>;
|
|
71
|
-
|
|
72
|
-
export type LoopRunner<LOOP extends Loop> = (data: {
|
|
73
|
-
log: typeof console.log;
|
|
74
|
-
setOutput: SetOutputLoopFn<LOOP>;
|
|
75
|
-
inputs: ExtractInputsFromLoop<LOOP>;
|
|
76
|
-
setMeta: (callback: (data: LOOP["meta"]) => LOOP["meta"]) => void;
|
|
77
|
-
meta: LOOP["meta"];
|
|
78
|
-
cwd: string;
|
|
79
|
-
context: PipelabContext;
|
|
80
|
-
}) => Promise<"step" | "exit">;
|
|
81
|
-
|
|
82
54
|
export type ExpressionRunner<EXPRESSION extends Expression> = (data: {
|
|
83
55
|
log: typeof console.log;
|
|
84
56
|
setOutput: SetOutputExpressionFn<EXPRESSION>;
|
|
@@ -98,4 +70,4 @@ export type EventRunner<EVENT extends Event> = (data: {
|
|
|
98
70
|
context: PipelabContext;
|
|
99
71
|
}) => Promise<void>;
|
|
100
72
|
|
|
101
|
-
export type Runner = ActionRunner<any> |
|
|
73
|
+
export type Runner = ActionRunner<any> | EventRunner<any>;
|
package/src/utils/fs-extras.ts
CHANGED
|
@@ -1,17 +1,7 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { createWriteStream } from "node:fs";
|
|
2
2
|
import { execa, Options, Subprocess } from "execa";
|
|
3
|
-
import {
|
|
4
|
-
mkdir as mkdirP,
|
|
5
|
-
access,
|
|
6
|
-
writeFile,
|
|
7
|
-
realpath,
|
|
8
|
-
mkdtemp,
|
|
9
|
-
chmod,
|
|
10
|
-
stat,
|
|
11
|
-
readdir,
|
|
12
|
-
} from "node:fs/promises";
|
|
3
|
+
import { mkdir as mkdirP, writeFile, stat, readdir } from "node:fs/promises";
|
|
13
4
|
import { join, dirname } from "node:path";
|
|
14
|
-
import { tmpdir } from "node:os";
|
|
15
5
|
import tar from "tar";
|
|
16
6
|
import yauzl from "yauzl";
|
|
17
7
|
import archiver from "archiver";
|
|
@@ -23,23 +13,15 @@ import { pipeline } from "node:stream/promises";
|
|
|
23
13
|
export const ensure = async (filesPath: string, defaultContent = "{}") => {
|
|
24
14
|
await mkdirP(dirname(filesPath), { recursive: true });
|
|
25
15
|
try {
|
|
26
|
-
await
|
|
16
|
+
const s = await stat(filesPath);
|
|
17
|
+
if (s.size === 0) {
|
|
18
|
+
await writeFile(filesPath, defaultContent);
|
|
19
|
+
}
|
|
27
20
|
} catch {
|
|
28
21
|
await writeFile(filesPath, defaultContent);
|
|
29
22
|
}
|
|
30
23
|
};
|
|
31
24
|
|
|
32
|
-
/**
|
|
33
|
-
* Generates a unique temporary folder.
|
|
34
|
-
*/
|
|
35
|
-
export const generateTempFolder = async (base?: string) => {
|
|
36
|
-
const targetBase = base || tmpdir();
|
|
37
|
-
await mkdirP(targetBase, { recursive: true });
|
|
38
|
-
const realPath = await realpath(targetBase);
|
|
39
|
-
const tempFolder = await mkdtemp(join(realPath, "pipelab-"));
|
|
40
|
-
return tempFolder;
|
|
41
|
-
};
|
|
42
|
-
|
|
43
25
|
/**
|
|
44
26
|
* Extracts a .tar.gz archive.
|
|
45
27
|
*/
|
|
@@ -0,0 +1,211 @@
|
|
|
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 () => [{ ref: "refs/tags/@pipelab/app@3.0.0" }],
|
|
193
|
+
} as any);
|
|
194
|
+
|
|
195
|
+
// Mock release lookup
|
|
196
|
+
fetchSpy.mockResolvedValueOnce({
|
|
197
|
+
ok: true,
|
|
198
|
+
status: 200,
|
|
199
|
+
json: async () => ({
|
|
200
|
+
tag_name: "@pipelab/app@3.0.0",
|
|
201
|
+
prerelease: false,
|
|
202
|
+
published_at: "2026-06-04T00:00:00Z",
|
|
203
|
+
assets: [],
|
|
204
|
+
}),
|
|
205
|
+
} as any);
|
|
206
|
+
|
|
207
|
+
const latest = await fetchLatestDesktopRelease();
|
|
208
|
+
expect(latest).not.toBeNull();
|
|
209
|
+
expect(latest?.tag_name).toBe("@pipelab/app@3.0.0");
|
|
210
|
+
});
|
|
211
|
+
});
|
package/src/utils/github.ts
CHANGED
|
@@ -25,30 +25,110 @@ export async function fetchPackageReleases(
|
|
|
25
25
|
options: FetchReleaseOptions = {},
|
|
26
26
|
): Promise<GitHubRelease[]> {
|
|
27
27
|
const { repo = "CynToolkit/pipelab", allowPrerelease = false } = options;
|
|
28
|
-
const url = `https://api.github.com/repos/${repo}/releases`;
|
|
29
|
-
|
|
30
|
-
console.log(`[GitHub] Fetching releases for ${packageName} from ${url}...`);
|
|
31
28
|
|
|
32
29
|
try {
|
|
33
|
-
const
|
|
30
|
+
const override = process.env.PIPELAB_OVERRIDE_RELEASE;
|
|
31
|
+
if (override) {
|
|
32
|
+
const targetTag = override.includes("@") ? override : `${packageName}@${override}`;
|
|
33
|
+
console.log(`[GitHub] Fetching specific override release: ${targetTag}`);
|
|
34
|
+
const response = await fetch(
|
|
35
|
+
`https://api.github.com/repos/${repo}/releases/tags/${encodeURIComponent(targetTag)}`,
|
|
36
|
+
{
|
|
37
|
+
headers: {
|
|
38
|
+
"User-Agent": "Pipelab-Desktop-Updater",
|
|
39
|
+
Accept: "application/vnd.github.v3+json",
|
|
40
|
+
},
|
|
41
|
+
signal: AbortSignal.timeout(10000),
|
|
42
|
+
},
|
|
43
|
+
);
|
|
44
|
+
if (response.ok) {
|
|
45
|
+
const release: GitHubRelease = await response.json();
|
|
46
|
+
return [release];
|
|
47
|
+
}
|
|
48
|
+
console.warn(`[GitHub] Override release tag ${targetTag} not found or error occurred`);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const matchingRefsUrl = `https://api.github.com/repos/${repo}/git/matching-refs/tags/${encodeURIComponent(packageName)}`;
|
|
52
|
+
console.log(`[GitHub] Querying matching tags from ${matchingRefsUrl}...`);
|
|
53
|
+
|
|
54
|
+
const response = await fetch(matchingRefsUrl, {
|
|
34
55
|
headers: {
|
|
35
56
|
"User-Agent": "Pipelab-Desktop-Updater",
|
|
36
57
|
Accept: "application/vnd.github.v3+json",
|
|
37
58
|
},
|
|
59
|
+
signal: AbortSignal.timeout(10000),
|
|
38
60
|
});
|
|
39
61
|
|
|
40
62
|
if (!response.ok) {
|
|
41
63
|
throw new Error(`GitHub API error: ${response.status} ${response.statusText}`);
|
|
42
64
|
}
|
|
43
65
|
|
|
44
|
-
const
|
|
66
|
+
const refs = await response.json();
|
|
67
|
+
if (!Array.isArray(refs)) {
|
|
68
|
+
return [];
|
|
69
|
+
}
|
|
45
70
|
|
|
46
|
-
//
|
|
47
|
-
const
|
|
48
|
-
.
|
|
49
|
-
.filter((
|
|
71
|
+
// Extract tags matching the packageName@ pattern
|
|
72
|
+
const matchingTags = refs
|
|
73
|
+
.map((r: any) => r.ref.replace("refs/tags/", ""))
|
|
74
|
+
.filter((tag: string) => tag.startsWith(`${packageName}@`));
|
|
75
|
+
|
|
76
|
+
// Filter by semver and prerelease options
|
|
77
|
+
const filteredTags = matchingTags.filter((tag: string) => {
|
|
78
|
+
const version = tag.split("@").pop();
|
|
79
|
+
if (!version || !semver.valid(version)) {
|
|
80
|
+
return false;
|
|
81
|
+
}
|
|
82
|
+
const isPrerelease = semver.prerelease(version) !== null;
|
|
83
|
+
return allowPrerelease || !isPrerelease;
|
|
84
|
+
});
|
|
50
85
|
|
|
51
|
-
|
|
86
|
+
if (filteredTags.length === 0) {
|
|
87
|
+
return [];
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// Sort by version (newest/highest first)
|
|
91
|
+
filteredTags.sort((a, b) => {
|
|
92
|
+
const vA = a.split("@").pop() || "0.0.0";
|
|
93
|
+
const vB = b.split("@").pop() || "0.0.0";
|
|
94
|
+
return semver.rcompare(vA, vB);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
// Walk tags from newest to oldest, skipping any that have no GitHub Release attached.
|
|
98
|
+
// A bare git tag (no release) returns 404 from the releases/tags endpoint.
|
|
99
|
+
for (const tag of filteredTags) {
|
|
100
|
+
console.log(`[GitHub] Fetching release details for tag: ${tag}`);
|
|
101
|
+
|
|
102
|
+
const releaseResponse = await fetch(
|
|
103
|
+
`https://api.github.com/repos/${repo}/releases/tags/${encodeURIComponent(tag)}`,
|
|
104
|
+
{
|
|
105
|
+
headers: {
|
|
106
|
+
"User-Agent": "Pipelab-Desktop-Updater",
|
|
107
|
+
Accept: "application/vnd.github.v3+json",
|
|
108
|
+
},
|
|
109
|
+
signal: AbortSignal.timeout(10000),
|
|
110
|
+
},
|
|
111
|
+
);
|
|
112
|
+
|
|
113
|
+
if (releaseResponse.status === 404) {
|
|
114
|
+
// Tag exists but no Release was published for it — skip and try older tag
|
|
115
|
+
console.warn(`[GitHub] Tag "${tag}" has no associated Release, skipping.`);
|
|
116
|
+
continue;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
if (!releaseResponse.ok) {
|
|
120
|
+
throw new Error(
|
|
121
|
+
`GitHub API error: ${releaseResponse.status} ${releaseResponse.statusText}`,
|
|
122
|
+
);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const release: GitHubRelease = await releaseResponse.json();
|
|
126
|
+
return [release];
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// All tags were bare (no Release found)
|
|
130
|
+
console.warn(`[GitHub] No published Release found for any matching tag of "${packageName}".`);
|
|
131
|
+
return [];
|
|
52
132
|
} catch (error) {
|
|
53
133
|
console.error(`[GitHub] Failed to fetch releases for ${packageName}:`, error);
|
|
54
134
|
return [];
|