@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.
Files changed (45) hide show
  1. package/.oxfmtrc.json +1 -1
  2. package/CHANGELOG.md +164 -0
  3. package/dist/config-Bi0ORcTK.mjs +2 -0
  4. package/dist/config-CFgGRD9U.mjs +265 -0
  5. package/dist/config-CFgGRD9U.mjs.map +1 -0
  6. package/dist/index.d.mts +74 -53
  7. package/dist/index.d.mts.map +1 -1
  8. package/dist/index.mjs +1705 -576
  9. package/dist/index.mjs.map +1 -1
  10. package/package.json +9 -5
  11. package/scratch/simulate-updates.ts +120 -0
  12. package/src/config.test.ts +232 -0
  13. package/src/config.ts +111 -50
  14. package/src/context.ts +117 -5
  15. package/src/handler-func.ts +2 -77
  16. package/src/handlers/build-history.ts +60 -90
  17. package/src/handlers/config.ts +265 -55
  18. package/src/handlers/engine.ts +32 -35
  19. package/src/handlers/history.ts +0 -40
  20. package/src/handlers/index.ts +4 -0
  21. package/src/handlers/migration.ts +621 -0
  22. package/src/handlers/plugins.ts +305 -0
  23. package/src/handlers/system.ts +7 -2
  24. package/src/index.ts +9 -2
  25. package/src/plugins-registry.ts +280 -38
  26. package/src/presets/c3toSteam.ts +13 -7
  27. package/src/presets/demo.ts +9 -9
  28. package/src/presets/list.ts +0 -6
  29. package/src/presets/moreToCome.ts +3 -2
  30. package/src/presets/newProject.ts +3 -2
  31. package/src/presets/test-c3-offline.ts +15 -6
  32. package/src/presets/test-c3-unzip.ts +19 -8
  33. package/src/runner.ts +2 -8
  34. package/src/server.ts +45 -4
  35. package/src/types/runner.ts +2 -30
  36. package/src/utils/fs-extras.ts +6 -24
  37. package/src/utils/github.test.ts +211 -0
  38. package/src/utils/github.ts +90 -10
  39. package/src/utils/remote.test.ts +209 -0
  40. package/src/utils/remote.ts +325 -87
  41. package/src/utils/storage.ts +2 -1
  42. package/src/utils.ts +20 -24
  43. package/src/migrations.ts +0 -72
  44. package/src/presets/if.ts +0 -69
  45. 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: "3.0.0",
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: undefined,
84
- platform: undefined,
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 settings = await setupConfigFile<AppConfig>("settings", { context });
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", "latest", { context });
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
 
@@ -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> | LoopRunner<any> | EventRunner<any> | ConditionRunner<any>;
73
+ export type Runner = ActionRunner<any> | EventRunner<any>;
@@ -1,17 +1,7 @@
1
- import { mkdir, createWriteStream, createReadStream } from "node:fs";
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 access(filesPath);
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
+ });
@@ -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 response = await fetch(url, {
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 releases: GitHubRelease[] = await response.json();
66
+ const refs = await response.json();
67
+ if (!Array.isArray(refs)) {
68
+ return [];
69
+ }
45
70
 
46
- // Filter for releases that follow the {packageName}@X.Y.Z tag pattern
47
- const packageReleases = releases
48
- .filter((r) => r.tag_name.startsWith(`${packageName}@`))
49
- .filter((r) => allowPrerelease || !r.prerelease);
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
- return packageReleases;
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 [];