@pipelab/core-node 0.0.1

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 (54) hide show
  1. package/.oxfmtrc.json +3 -0
  2. package/.turbo/turbo-build.log +56 -0
  3. package/.turbo/turbo-format.log +6 -0
  4. package/.turbo/turbo-lint.log +618 -0
  5. package/CHANGELOG.md +62 -0
  6. package/LICENSE +110 -0
  7. package/README.md +10 -0
  8. package/dist/index.d.mts +449 -0
  9. package/dist/index.d.mts.map +1 -0
  10. package/dist/index.mjs +52693 -0
  11. package/dist/index.mjs.map +1 -0
  12. package/package.json +64 -0
  13. package/scratch/simulate-updates.ts +118 -0
  14. package/src/api.ts +115 -0
  15. package/src/config.ts +100 -0
  16. package/src/context.ts +74 -0
  17. package/src/handler-func.ts +234 -0
  18. package/src/handlers/agents.ts +32 -0
  19. package/src/handlers/auth.ts +95 -0
  20. package/src/handlers/build-history.ts +379 -0
  21. package/src/handlers/config.ts +109 -0
  22. package/src/handlers/engine.ts +229 -0
  23. package/src/handlers/fs.ts +97 -0
  24. package/src/handlers/history.ts +327 -0
  25. package/src/handlers/index.ts +41 -0
  26. package/src/handlers/shell.ts +57 -0
  27. package/src/handlers/system.ts +18 -0
  28. package/src/handlers.ts +2 -0
  29. package/src/heavy.ts +4 -0
  30. package/src/index.ts +17 -0
  31. package/src/ipc-core.ts +77 -0
  32. package/src/migrations.ts +72 -0
  33. package/src/paths.ts +1 -0
  34. package/src/plugins-registry.ts +84 -0
  35. package/src/presets/c3toSteam.ts +272 -0
  36. package/src/presets/demo.ts +123 -0
  37. package/src/presets/if.ts +69 -0
  38. package/src/presets/list.ts +30 -0
  39. package/src/presets/loop.ts +65 -0
  40. package/src/presets/moreToCome.ts +32 -0
  41. package/src/presets/newProject.ts +31 -0
  42. package/src/presets/preset.model.ts +0 -0
  43. package/src/presets/test-c3-offline.ts +78 -0
  44. package/src/presets/test-c3-unzip.ts +124 -0
  45. package/src/runner.ts +166 -0
  46. package/src/server.ts +101 -0
  47. package/src/types/runner.ts +101 -0
  48. package/src/utils/fs-extras.ts +220 -0
  49. package/src/utils/github.ts +119 -0
  50. package/src/utils/remote.ts +498 -0
  51. package/src/utils/storage.ts +99 -0
  52. package/src/utils.ts +268 -0
  53. package/src/websocket-server.ts +288 -0
  54. package/tsconfig.json +19 -0
package/src/runner.ts ADDED
@@ -0,0 +1,166 @@
1
+ import { executeGraphWithHistory } from "./utils";
2
+ import { setupConfigFile } from "./config";
3
+ import { isDev, PipelabContext } from "./context";
4
+ import { readFile, access, writeFile, mkdir } from "node:fs/promises";
5
+ import { resolve, isAbsolute, join, dirname } from "node:path";
6
+ import { tmpdir } from "node:os";
7
+ import { savedFileMigrator } from "@pipelab/shared";
8
+ import type { AppConfig } from "@pipelab/shared";
9
+ import { registerMigrationHandlers } from "./migrations";
10
+ import { registerAllHandlers } from "./handlers/index";
11
+
12
+ export interface RunOptions {
13
+ userData?: string;
14
+ variables?: string;
15
+ output?: string;
16
+ cloud?: boolean;
17
+ }
18
+
19
+ export async function runPipelineCommand(file: string, options: RunOptions, version: string) {
20
+ const pipelinePath = isAbsolute(file) ? file : resolve(process.cwd(), file);
21
+
22
+ try {
23
+ await access(pipelinePath);
24
+ } catch (e) {
25
+ throw new Error(`Pipeline file not found at ${pipelinePath}`);
26
+ }
27
+
28
+ const pipelineContent = await readFile(pipelinePath, "utf-8");
29
+ const pipeline = JSON.parse(pipelineContent);
30
+
31
+ let finalPipeline = pipeline;
32
+ if (pipeline.version) {
33
+ finalPipeline = await savedFileMigrator.migrate(pipeline);
34
+ }
35
+
36
+ const {
37
+ graph: rawGraph,
38
+ variables: pipelineVariables,
39
+ projectName,
40
+ projectPath,
41
+ pipelineId,
42
+ canvas,
43
+ name,
44
+ } = finalPipeline;
45
+
46
+ const graph = rawGraph || canvas?.blocks;
47
+
48
+ if (!graph) {
49
+ throw new Error("Pipeline does not contain a valid graph or canvas.blocks");
50
+ }
51
+
52
+ let vars = pipelineVariables || finalPipeline.variables || [];
53
+ if (options.variables) {
54
+ vars = JSON.parse(options.variables);
55
+ }
56
+
57
+ const effectiveProjectName = projectName || name || "CLI Run";
58
+ const effectiveProjectPath = projectPath || process.cwd();
59
+ const effectivePipelineId = pipelineId || "cli-run";
60
+
61
+ console.log(`Executing pipeline: ${effectiveProjectName} (${effectivePipelineId})`);
62
+
63
+ if (!options.userData) throw new Error("userDataPath is required for runPipelineCommand");
64
+ const context = new PipelabContext({
65
+ userDataPath: options.userData,
66
+ });
67
+
68
+ await registerAllHandlers({ version, context });
69
+ registerMigrationHandlers(context);
70
+
71
+ const settings = await setupConfigFile<AppConfig>("settings", { context });
72
+ const config = await settings.getConfig();
73
+ const cachePath = join(context.userDataPath, "cache");
74
+ await mkdir(cachePath, { recursive: true });
75
+
76
+ const abortController = new AbortController();
77
+
78
+ let supabase: any;
79
+ const cloudRunId = process.env.CLOUD_RUN_ID;
80
+ if (options.cloud && cloudRunId) {
81
+ const { createClient } = await import("@supabase/supabase-js");
82
+ supabase = createClient(process.env.SUPABASE_URL!, process.env.SUPABASE_SERVICE_ROLE_KEY!);
83
+ console.log(`Cloud mode enabled. Streaming logs for run: ${cloudRunId}`);
84
+ }
85
+
86
+ try {
87
+ const { result, buildId } = await executeGraphWithHistory({
88
+ graph,
89
+ variables: vars,
90
+ projectName: effectiveProjectName,
91
+ projectPath: effectiveProjectPath,
92
+ pipelineId: effectivePipelineId,
93
+ cachePath: cachePath,
94
+ onNodeEnter: (node) => {
95
+ console.log(`[ENTER] ${node.name} (${node.uid})`);
96
+ if (supabase) {
97
+ supabase
98
+ .from("cloud_run_logs")
99
+ .insert({
100
+ run_id: cloudRunId,
101
+ message: `[ENTER] ${node.name} (${node.uid})`,
102
+ node_uid: node.uid,
103
+ type: "node-enter",
104
+ })
105
+ .then();
106
+ }
107
+ },
108
+ onNodeExit: (node) => {
109
+ console.log(`[EXIT] ${node.name} (${node.uid})`);
110
+ if (supabase) {
111
+ supabase
112
+ .from("cloud_run_logs")
113
+ .insert({
114
+ run_id: cloudRunId,
115
+ message: `[EXIT] ${node.name} (${node.uid})`,
116
+ node_uid: node.uid,
117
+ type: "node-exit",
118
+ })
119
+ .then();
120
+ }
121
+ },
122
+ onLog: (data, node) => {
123
+ if (data.type === "log") {
124
+ const message = data.data.message.join(" ");
125
+ console.log(`[LOG] ${message}`);
126
+ if (supabase) {
127
+ supabase
128
+ .from("cloud_run_logs")
129
+ .insert({
130
+ run_id: cloudRunId,
131
+ message: message,
132
+ node_uid: node?.uid,
133
+ type: "log",
134
+ })
135
+ .then();
136
+ }
137
+ }
138
+ },
139
+ abortSignal: abortController.signal,
140
+ context,
141
+ });
142
+
143
+ console.log(`Pipeline execution finished. Build ID: ${buildId}`);
144
+
145
+ if (supabase) {
146
+ await supabase.from("cloud_runs").update({ status: "success" }).eq("id", cloudRunId);
147
+ }
148
+
149
+ if (options.output) {
150
+ const outputPath = isAbsolute(options.output)
151
+ ? options.output
152
+ : resolve(process.cwd(), options.output);
153
+
154
+ await mkdir(dirname(outputPath), { recursive: true });
155
+ await writeFile(outputPath, JSON.stringify(result, null, 2), "utf-8");
156
+ console.log(`Result saved to ${outputPath}`);
157
+ }
158
+
159
+ return result;
160
+ } catch (e) {
161
+ if (supabase) {
162
+ await supabase.from("cloud_runs").update({ status: "failed" }).eq("id", cloudRunId);
163
+ }
164
+ throw e;
165
+ }
166
+ }
package/src/server.ts ADDED
@@ -0,0 +1,101 @@
1
+ import {
2
+ ensurePNPM,
3
+ PipelabContext,
4
+ isDev,
5
+ fetchPipelabAsset,
6
+ registerAllHandlers,
7
+ webSocketServer,
8
+ } from "./index";
9
+ import { getUiDevServerMissingWarning, uiDevPort } from "@pipelab/constants";
10
+ import { existsSync } from "node:fs";
11
+ import { readFile } from "node:fs/promises";
12
+ import { join } from "node:path";
13
+ import http from "http";
14
+ import handler from "serve-handler";
15
+ import { registerMigrationHandlers } from "./migrations";
16
+
17
+ export interface ServeOptions {
18
+ port: string | number;
19
+ userData?: string;
20
+ nodePath?: string;
21
+ pnpmPath?: string;
22
+ }
23
+
24
+ export const sendStartupProgress = (message: string) => {
25
+ console.log(`[Startup Progress] ${message}`);
26
+ webSocketServer.broadcast("startup:progress", {
27
+ type: "progress",
28
+ data: { message },
29
+ });
30
+ };
31
+
32
+ export const sendStartupReady = () => {
33
+ console.log(`[Startup Progress] Ready!`);
34
+ webSocketServer.broadcast("startup:progress", {
35
+ type: "ready",
36
+ });
37
+ };
38
+
39
+ export async function serveCommand(options: ServeOptions, version: string, _dirname: string) {
40
+ if (!options.userData) throw new Error("userDataPath is required for serveCommand");
41
+ const releaseTag = version.includes("beta") ? "beta" : "latest";
42
+ const context = new PipelabContext({
43
+ userDataPath: options.userData,
44
+ releaseTag,
45
+ });
46
+
47
+ let rawAssetFolder: string | undefined;
48
+ if (!isDev) {
49
+ rawAssetFolder = await fetchPipelabAsset("@pipelab/ui", releaseTag, { context });
50
+ }
51
+
52
+ const server = http.createServer(async (request, response) => {
53
+ if (isDev) {
54
+ response.writeHead(200, { "Content-Type": "text/html" });
55
+ response.end(`
56
+ <html>
57
+ <body style="font-family: sans-serif; display: flex; flex-direction: column; align-items: center; justify-content: center; height: 100vh; margin: 0; background: #0f172a; color: #f8fafc;">
58
+ <h1 style="color: #38bdf8;">Pipelab Dev Mode</h1>
59
+ <p>The CLI server is running (API/WebSocket), but the UI is not served here in development.</p>
60
+ <p>Please open the UI through its own dev server (usually <a href="http://localhost:5173" style="color: #38bdf8;">http://localhost:5173</a>).</p>
61
+ </body>
62
+ </html>
63
+ `);
64
+ return;
65
+ }
66
+
67
+ if (!rawAssetFolder || !existsSync(rawAssetFolder)) {
68
+ response.writeHead(404, { "Content-Type": "text/plain" });
69
+ response.end(
70
+ `Error: UI directory not found at ${rawAssetFolder}.\n` +
71
+ "Please run 'pnpm build' in apps/ui to generate the distribution.",
72
+ );
73
+ return;
74
+ }
75
+
76
+ return handler(request, response, {
77
+ public: rawAssetFolder,
78
+ });
79
+ });
80
+
81
+ console.log(`Starting Pipelab server on port ${options.port}...`);
82
+ if (isDev) {
83
+ console.info(getUiDevServerMissingWarning());
84
+ console.log(`UI available at http://localhost:${uiDevPort}`);
85
+ } else {
86
+ console.log(`UI available at http://localhost:${options.port}`);
87
+ }
88
+
89
+ // Start the server EARLY so the UI can connect and receive progress updates
90
+ await webSocketServer.start(Number(options.port), server);
91
+
92
+ await registerAllHandlers({
93
+ version,
94
+ context,
95
+ });
96
+ registerMigrationHandlers(context);
97
+
98
+ sendStartupReady();
99
+
100
+ return server;
101
+ }
@@ -0,0 +1,101 @@
1
+ import type { BrowserWindow } from "electron";
2
+ import type { PipelabContext } from "../context";
3
+ import type {
4
+ Action,
5
+ Condition,
6
+ Loop,
7
+ Expression,
8
+ Event,
9
+ SetOutputActionFn,
10
+ SetOutputLoopFn,
11
+ SetOutputExpressionFn,
12
+ ExtractInputsFromAction,
13
+ ExtractInputsFromCondition,
14
+ ExtractInputsFromLoop,
15
+ ExtractInputsFromEvent,
16
+ ExtractInputsFromExpression,
17
+ } from "@pipelab/shared";
18
+
19
+ export type {
20
+ Action,
21
+ Condition,
22
+ Loop,
23
+ Expression,
24
+ Event,
25
+ SetOutputActionFn,
26
+ SetOutputLoopFn,
27
+ SetOutputExpressionFn,
28
+ ExtractInputsFromAction,
29
+ ExtractInputsFromCondition,
30
+ ExtractInputsFromLoop,
31
+ ExtractInputsFromEvent,
32
+ ExtractInputsFromExpression,
33
+ };
34
+
35
+ export type RunnerCallbackFnArgument = {
36
+ done: () => void;
37
+ id: string;
38
+ log: (...args: Parameters<(typeof console)["log"]>) => void;
39
+ };
40
+
41
+ export type ActionRunnerData<ACTION extends Action> = {
42
+ log: typeof console.log;
43
+ setOutput: SetOutputActionFn<ACTION>;
44
+ inputs: ExtractInputsFromAction<ACTION>;
45
+ setMeta: (callback: (data: ACTION["meta"]) => ACTION["meta"]) => void;
46
+ meta: ACTION["meta"];
47
+ cwd: string;
48
+ paths: {
49
+ cache: string;
50
+ pnpm: string;
51
+ node: string;
52
+ userData: string;
53
+ modules: string;
54
+ thirdparty: string;
55
+ };
56
+ browserWindow: BrowserWindow;
57
+ abortSignal: AbortSignal;
58
+ context: PipelabContext;
59
+ };
60
+
61
+ export type ActionRunner<ACTION extends Action> = (data: ActionRunnerData<ACTION>) => Promise<void>;
62
+
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
+ export type ExpressionRunner<EXPRESSION extends Expression> = (data: {
83
+ log: typeof console.log;
84
+ setOutput: SetOutputExpressionFn<EXPRESSION>;
85
+ inputs: ExtractInputsFromExpression<EXPRESSION>;
86
+ setMeta: (callback: (data: EXPRESSION["meta"]) => EXPRESSION["meta"]) => void;
87
+ meta: EXPRESSION["meta"];
88
+ cwd: string;
89
+ context: PipelabContext;
90
+ }) => Promise<string>;
91
+
92
+ export type EventRunner<EVENT extends Event> = (data: {
93
+ log: typeof console.log;
94
+ inputs: ExtractInputsFromEvent<EVENT>;
95
+ setMeta: (callback: (data: EVENT["meta"]) => EVENT["meta"]) => void;
96
+ meta: EVENT["meta"];
97
+ cwd: string;
98
+ context: PipelabContext;
99
+ }) => Promise<void>;
100
+
101
+ export type Runner = ActionRunner<any> | LoopRunner<any> | EventRunner<any> | ConditionRunner<any>;
@@ -0,0 +1,220 @@
1
+ import { mkdir, createWriteStream, createReadStream } from "node:fs";
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";
13
+ import { join, dirname } from "node:path";
14
+ import { tmpdir } from "node:os";
15
+ import tar from "tar";
16
+ import yauzl from "yauzl";
17
+ import archiver from "archiver";
18
+ import { pipeline } from "node:stream/promises";
19
+
20
+ /**
21
+ * Ensures a directory exists and a file is created with default content if missing.
22
+ */
23
+ export const ensure = async (filesPath: string, defaultContent = "{}") => {
24
+ await mkdirP(dirname(filesPath), { recursive: true });
25
+ try {
26
+ await access(filesPath);
27
+ } catch {
28
+ await writeFile(filesPath, defaultContent);
29
+ }
30
+ };
31
+
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
+ /**
44
+ * Extracts a .tar.gz archive.
45
+ */
46
+ export async function extractTarGz(archivePath: string, destinationDir: string): Promise<void> {
47
+ await mkdirP(destinationDir, { recursive: true });
48
+ await tar.x({
49
+ file: archivePath,
50
+ cwd: destinationDir,
51
+ });
52
+ }
53
+
54
+ /**
55
+ * Extracts a .zip archive.
56
+ */
57
+ export async function extractZip(archivePath: string, destinationDir: string): Promise<void> {
58
+ await mkdirP(destinationDir, { recursive: true });
59
+ return new Promise((resolve, reject) => {
60
+ yauzl.open(archivePath, { lazyEntries: true }, (err, zipfile) => {
61
+ if (err || !zipfile) return reject(err || new Error("Could not open zip file"));
62
+ zipfile.on("error", reject);
63
+ zipfile.readEntry();
64
+ zipfile.on("entry", (entry) => {
65
+ const entryPath = join(destinationDir, entry.fileName);
66
+ if (/\/$/.test(entry.fileName)) {
67
+ mkdirP(entryPath, { recursive: true })
68
+ .then(() => zipfile.readEntry())
69
+ .catch(reject);
70
+ } else {
71
+ mkdirP(dirname(entryPath), { recursive: true })
72
+ .then(() => {
73
+ zipfile.openReadStream(entry, (err, readStream) => {
74
+ if (err || !readStream)
75
+ return reject(err || new Error("Could not open read stream"));
76
+ readStream.on("error", reject);
77
+ const writeStream = createWriteStream(entryPath);
78
+ writeStream.on("error", reject);
79
+ writeStream.on("close", () => zipfile.readEntry());
80
+ readStream.pipe(writeStream);
81
+ });
82
+ })
83
+ .catch(reject);
84
+ }
85
+ });
86
+ zipfile.on("end", () => resolve());
87
+ });
88
+ });
89
+ }
90
+
91
+ /**
92
+ * Zips a folder.
93
+ */
94
+ export const zipFolder = async (
95
+ from: string,
96
+ to: string,
97
+ log: typeof console.log = console.log,
98
+ ) => {
99
+ const output = createWriteStream(to);
100
+ const archive = archiver("zip", { zlib: { level: 9 } });
101
+ return new Promise<string>((resolve, reject) => {
102
+ output.on("close", () => {
103
+ log(archive.pointer() + " total bytes");
104
+ resolve(to);
105
+ });
106
+ archive.on("error", reject);
107
+ archive.pipe(output);
108
+ archive.directory(from, false);
109
+ archive.finalize();
110
+ });
111
+ };
112
+
113
+ /**
114
+ * Downloads a file with progress tracking.
115
+ */
116
+ export interface DownloadHooks {
117
+ onProgress?: (data: { progress: number; downloadedSize: number }) => void;
118
+ }
119
+
120
+ export const downloadFile = async (
121
+ url: string,
122
+ localPath: string,
123
+ hooks?: DownloadHooks,
124
+ abortSignal?: AbortSignal,
125
+ ): Promise<void> => {
126
+ const response = await fetch(url, { signal: abortSignal });
127
+ if (!response.ok) throw new Error(`Failed to fetch file: ${response.statusText}`);
128
+ const contentLength = response.headers.get("content-length");
129
+ if (!contentLength) throw new Error("Content-Length header is missing");
130
+ const totalSize = parseInt(contentLength, 10);
131
+ let downloadedSize = 0;
132
+ const fileStream = createWriteStream(localPath);
133
+ const progressStream = new TransformStream({
134
+ transform(chunk, controller) {
135
+ downloadedSize += chunk.length;
136
+ const progress = (downloadedSize / totalSize) * 100;
137
+ hooks?.onProgress?.({ progress, downloadedSize });
138
+ controller.enqueue(chunk);
139
+ },
140
+ });
141
+ const readable = response.body?.pipeThrough(progressStream);
142
+ if (!readable) throw new Error("Failed to create a readable stream");
143
+ await pipeline(readable, fileStream);
144
+ };
145
+
146
+ export const runWithLiveLogs = async (
147
+ command: string,
148
+ args: string[],
149
+ execaOptions: Options,
150
+ log: typeof console.log,
151
+ hooks?: {
152
+ onStdout?: (data: string, subprocess: Subprocess) => void;
153
+ onStderr?: (data: string, subprocess: Subprocess) => void;
154
+ onExit?: (code: number) => void;
155
+ onCreated?: (subprocess: Subprocess) => void;
156
+ },
157
+ abortSignal?: AbortSignal,
158
+ ): Promise<void> => {
159
+ const subprocess = execa(command, args, {
160
+ ...execaOptions,
161
+ stdout: "pipe",
162
+ stderr: "pipe",
163
+ stdin: "pipe",
164
+ env: {
165
+ ...process.env,
166
+ ...execaOptions.env,
167
+ TERM: "xterm-256color",
168
+ FORCE_STDERR_LOGGING: "1",
169
+ },
170
+ cancelSignal: abortSignal,
171
+ });
172
+
173
+ hooks?.onCreated?.(subprocess);
174
+
175
+ subprocess.stdout?.on("data", (data: Buffer) => {
176
+ hooks?.onStdout?.(data.toString(), subprocess);
177
+ });
178
+
179
+ subprocess.stderr?.on("data", (data: Buffer) => {
180
+ hooks?.onStderr?.(data.toString(), subprocess);
181
+ });
182
+
183
+ try {
184
+ const { exitCode } = await subprocess;
185
+ hooks?.onExit?.(exitCode ?? 0);
186
+ } catch (error: any) {
187
+ const code = error.exitCode ?? 1;
188
+ hooks?.onExit?.(code);
189
+ throw new Error(`Command failed with exit code ${code}: ${error.message}`);
190
+ }
191
+ };
192
+
193
+ /**
194
+ * Calculates the total size of a directory recursively.
195
+ */
196
+ export async function getFolderSize(dirPath: string): Promise<number> {
197
+ try {
198
+ const files = await readdir(dirPath, { withFileTypes: true });
199
+ const ArrayOfPromises = files.map(async (file) => {
200
+ const path = join(dirPath, file.name);
201
+ if (file.isDirectory()) {
202
+ try {
203
+ return await getFolderSize(path);
204
+ } catch {
205
+ return 0;
206
+ }
207
+ }
208
+ try {
209
+ const { size } = await stat(path);
210
+ return size;
211
+ } catch {
212
+ return 0;
213
+ }
214
+ });
215
+ const results = await Promise.all(ArrayOfPromises);
216
+ return results.reduce((acc, size) => acc + size, 0);
217
+ } catch {
218
+ return 0;
219
+ }
220
+ }
@@ -0,0 +1,119 @@
1
+ import semver from "semver";
2
+
3
+ export interface GitHubRelease {
4
+ tag_name: string;
5
+ prerelease: boolean;
6
+ published_at: string;
7
+ html_url: string;
8
+ assets: Array<{
9
+ name: string;
10
+ browser_download_url: string;
11
+ }>;
12
+ }
13
+
14
+ export interface FetchReleaseOptions {
15
+ repo?: string;
16
+ allowPrerelease?: boolean;
17
+ }
18
+
19
+ /**
20
+ * Fetches all releases for a specific package from GitHub.
21
+ * Tags are expected to follow the pattern "{packageName}@{version}"
22
+ */
23
+ export async function fetchPackageReleases(
24
+ packageName: string,
25
+ options: FetchReleaseOptions = {},
26
+ ): Promise<GitHubRelease[]> {
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
+
32
+ try {
33
+ const response = await fetch(url, {
34
+ headers: {
35
+ "User-Agent": "Pipelab-Desktop-Updater",
36
+ Accept: "application/vnd.github.v3+json",
37
+ },
38
+ });
39
+
40
+ if (!response.ok) {
41
+ throw new Error(`GitHub API error: ${response.status} ${response.statusText}`);
42
+ }
43
+
44
+ const releases: GitHubRelease[] = await response.json();
45
+
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);
50
+
51
+ return packageReleases;
52
+ } catch (error) {
53
+ console.error(`[GitHub] Failed to fetch releases for ${packageName}:`, error);
54
+ return [];
55
+ }
56
+ }
57
+
58
+ /**
59
+ * Fetches the latest release for a specific package.
60
+ * Supports PIPELAB_OVERRIDE_RELEASE environment variable.
61
+ */
62
+ export async function fetchLatestPackageRelease(
63
+ packageName: string,
64
+ options: FetchReleaseOptions = {},
65
+ ): Promise<GitHubRelease | null> {
66
+ const releases = await fetchPackageReleases(packageName, options);
67
+
68
+ if (releases.length === 0) {
69
+ return null;
70
+ }
71
+
72
+ // Handle environment variable override
73
+ const override = process.env.PIPELAB_OVERRIDE_RELEASE;
74
+ if (override) {
75
+ const targetTag = override.includes("@") ? override : `${packageName}@${override}`;
76
+ const release = releases.find((r) => r.tag_name === targetTag);
77
+ if (release) {
78
+ console.log(
79
+ `[GitHub] Using release override from PIPELAB_OVERRIDE_RELEASE: ${release.tag_name}`,
80
+ );
81
+ return release;
82
+ }
83
+ console.warn(`[GitHub] Override release ${override} not found for package ${packageName}`);
84
+ }
85
+
86
+ // Sort by semver
87
+ const getVersion = (tagName: string) => tagName.split("@").pop() || "0.0.0";
88
+
89
+ releases.sort((a, b) => {
90
+ const vA = getVersion(a.tag_name);
91
+ const vB = getVersion(b.tag_name);
92
+ if (semver.valid(vA) && semver.valid(vB)) {
93
+ return semver.rcompare(vA, vB);
94
+ }
95
+ return new Date(b.published_at).getTime() - new Date(a.published_at).getTime();
96
+ });
97
+
98
+ return releases[0];
99
+ }
100
+
101
+ /**
102
+ * Fetches all releases from GitHub and finds the latest one that matches the Pipelab Desktop app tag.
103
+ * This ensures we don't accidentally pick a package release (e.g. @pipelab/shared) as the "latest".
104
+ */
105
+ export async function fetchLatestDesktopRelease(
106
+ options: FetchReleaseOptions = {},
107
+ ): Promise<GitHubRelease | null> {
108
+ const latest = await fetchLatestPackageRelease("@pipelab/app", options);
109
+
110
+ if (latest) {
111
+ console.log(
112
+ `[GitHub] Found latest desktop release: ${latest.tag_name} (${latest.prerelease ? "pre-release" : "stable"})`,
113
+ );
114
+ } else {
115
+ console.warn(`[GitHub] No desktop releases found`);
116
+ }
117
+
118
+ return latest;
119
+ }