@pipelab/core-node 1.0.0-beta.11 → 1.0.0-beta.13

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pipelab/core-node",
3
- "version": "1.0.0-beta.11",
3
+ "version": "1.0.0-beta.13",
4
4
  "private": false,
5
5
  "description": "The Pipelab automation engine for Node.js",
6
6
  "license": "FSL-1.1-MIT",
@@ -40,8 +40,8 @@
40
40
  "type-fest": "4.39.0",
41
41
  "ws": "8.18.3",
42
42
  "yauzl": "2.10.0",
43
- "@pipelab/constants": "1.0.0-beta.9",
44
- "@pipelab/shared": "1.0.0-beta.7"
43
+ "@pipelab/constants": "1.0.0-beta.11",
44
+ "@pipelab/shared": "1.0.0-beta.9"
45
45
  },
46
46
  "devDependencies": {
47
47
  "@types/adm-zip": "0.5.7",
@@ -54,7 +54,7 @@
54
54
  "@types/yauzl": "2.10.3",
55
55
  "tsdown": "0.21.2",
56
56
  "typescript": "^5.0.0",
57
- "@pipelab/tsconfig": "1.0.0-beta.7"
57
+ "@pipelab/tsconfig": "1.0.0-beta.9"
58
58
  },
59
59
  "scripts": {
60
60
  "build": "tsdown",
package/src/config.ts CHANGED
@@ -3,14 +3,14 @@ import path from "node:path";
3
3
  import { ensure } from "./utils/fs-extras";
4
4
  import fs from "node:fs/promises";
5
5
  import { useLogger } from "@pipelab/shared";
6
- import { configRegistry, Migrator } from "@pipelab/shared";
6
+ import { configRegistry, Migrator, normalizePipelineConfig } from "@pipelab/shared";
7
7
 
8
8
  export const getMigrator = <T>(name: string) => {
9
9
  if (configRegistry[name]) {
10
10
  return configRegistry[name] as Migrator<T>;
11
11
  }
12
12
 
13
- if (name.startsWith("pipeline-")) {
13
+ if (name.startsWith("pipeline-") || path.isAbsolute(name) || name.endsWith(".json")) {
14
14
  return configRegistry["pipeline"] as Migrator<T>;
15
15
  }
16
16
 
@@ -50,6 +50,7 @@ export const setupConfigFile = async <T>(
50
50
  const { logger } = useLogger();
51
51
  let content = undefined;
52
52
  let originalJson: any = undefined;
53
+ let parseFailed = false;
53
54
 
54
55
  try {
55
56
  content = await fs.readFile(filesPath, "utf8");
@@ -58,10 +59,9 @@ export const setupConfigFile = async <T>(
58
59
  }
59
60
  } catch (e) {
60
61
  logger().error(`Error reading or parsing config ${name}:`, e);
62
+ parseFailed = true;
61
63
  }
62
64
 
63
- // console.log("content", content);
64
-
65
65
  let json: any = undefined;
66
66
  try {
67
67
  json = await migrator.migrate(originalJson, {
@@ -82,11 +82,21 @@ export const setupConfigFile = async <T>(
82
82
  json = migrator.defaultValue;
83
83
  }
84
84
 
85
+ let normalized = false;
86
+ const isPipeline =
87
+ name.startsWith("pipeline-") ||
88
+ path.isAbsolute(name) ||
89
+ name.endsWith(".json") ||
90
+ name === "pipeline";
91
+ if (isPipeline) {
92
+ normalized = normalizePipelineConfig(json);
93
+ }
94
+
85
95
  const originalVersion = originalJson?.version;
86
96
  const newVersion = json?.version;
87
97
 
88
- // Save back migrated config if changed or if it's a new file
89
- if (originalVersion !== newVersion || content === undefined) {
98
+ // Save back migrated config if changed, if normalized, if it's a new file, or if parse failed
99
+ if (originalVersion !== newVersion || normalized || content === undefined || parseFailed) {
90
100
  try {
91
101
  await fs.writeFile(filesPath, JSON.stringify(json));
92
102
  } catch (e) {
@@ -1,13 +1,13 @@
1
1
  import { End } from "@pipelab/shared";
2
2
  import { ensureNodeJS, ensurePNPM } from "./utils/remote";
3
- import { Action, ActionRunner, Condition, ConditionRunner } from "./types/runner";
3
+ import { Action, ActionRunner } from "./types/runner";
4
4
  import { InputsDefinition } from "@pipelab/shared";
5
5
  import { usePlugins } from "@pipelab/shared";
6
6
  import { isRequired } from "@pipelab/shared";
7
7
  import { mkdir, stat } from "node:fs/promises";
8
8
  import { PipelabContext } from "./context";
9
9
  import { useLogger } from "@pipelab/shared";
10
- import { BlockCondition } from "@pipelab/shared";
10
+
11
11
  import { HandleListenerSendFn } from "./handlers";
12
12
  import path from "node:path";
13
13
  const { join } = path;
@@ -38,81 +38,6 @@ const checkParams = (definitionParams: InputsDefinition, elementParams: Record<s
38
38
  }
39
39
  };
40
40
 
41
- export const handleConditionExecute = async (
42
- nodeId: string,
43
- pluginId: string,
44
- params: BlockCondition["params"],
45
- cwd: string,
46
- context: PipelabContext,
47
- ): Promise<End<"condition:execute">> => {
48
- const ctx = context;
49
- const { plugins } = usePlugins();
50
- const { logger } = useLogger();
51
-
52
- const node = plugins.value
53
- .find((plugin) => plugin.id === pluginId)
54
- ?.nodes.find((node: any) => node.node.id === nodeId) as
55
- | {
56
- node: Condition;
57
- runner: ConditionRunner<any>;
58
- }
59
- | undefined;
60
-
61
- if (!node) {
62
- return {
63
- type: "error",
64
- ipcError: "Node not found",
65
- };
66
- }
67
-
68
- try {
69
- const s = await stat(cwd);
70
- if (!s.isDirectory()) {
71
- throw new Error(`Execution directory "${cwd}" exists but is not a directory.`);
72
- }
73
- } catch (e: any) {
74
- if (e.code === "ENOENT") {
75
- await mkdir(cwd, { recursive: true });
76
- } else {
77
- throw e;
78
- }
79
- }
80
-
81
- try {
82
- checkParams(node.node.params, params);
83
- const resolvedInputs = params; // await resolveConditionInputs(params, node.node, steps)
84
-
85
- const result = await node.runner({
86
- inputs: resolvedInputs,
87
- log: (...args) => {
88
- logger().info(`[${node.node.name}]`, ...args);
89
- },
90
- meta: {
91
- definition: "",
92
- },
93
- setMeta: () => {
94
- logger().info("set meta defined here");
95
- },
96
- cwd,
97
- context: ctx,
98
- });
99
-
100
- return {
101
- type: "success",
102
- result: {
103
- outputs: {},
104
- value: result,
105
- },
106
- };
107
- } catch (e) {
108
- logger().error("Error in condition execution:", e);
109
- return {
110
- type: "error",
111
- ipcError: String(e),
112
- };
113
- }
114
- };
115
-
116
41
  export const handleActionExecute = async (
117
42
  nodeId: string,
118
43
  pluginId: string,
@@ -2,6 +2,8 @@ import { useAPI } from "../ipc-core";
2
2
  import { useLogger, configRegistry } from "@pipelab/shared";
3
3
  import { setupConfigFile, getMigrator } from "../config";
4
4
  import { PipelabContext } from "../context";
5
+ import fs from "node:fs/promises";
6
+ import path from "node:path";
5
7
 
6
8
  export const registerConfigHandlers = (context: PipelabContext) => {
7
9
  const { handle } = useAPI();
@@ -106,4 +108,51 @@ export const registerConfigHandlers = (context: PipelabContext) => {
106
108
  });
107
109
  }
108
110
  });
111
+
112
+ handle("config:delete", async (_, { send, value }) => {
113
+ const { config: name } = value;
114
+ logger().info("config:delete", name);
115
+
116
+ try {
117
+ // Config could be an absolute path (for external files/pipelines)
118
+ // or a name/identifier (for internal config files stored in Pipelab's app data directory).
119
+ const isAbsolutePath = path.isAbsolute(name);
120
+ const filesPath = isAbsolutePath ? name : context.getConfigPath(`${name}.json`);
121
+
122
+ await fs.rm(filesPath, { force: true });
123
+
124
+ // Clean up versioned backups too
125
+ const parsedPath = path.parse(filesPath);
126
+ const dirEntries = await fs.readdir(parsedPath.dir).catch(() => [] as string[]);
127
+ const prefix = `${parsedPath.name}.v`;
128
+ const suffix = `.json`;
129
+ for (const entry of dirEntries) {
130
+ if (entry.startsWith(prefix) && entry.endsWith(suffix)) {
131
+ const backupPath = path.join(parsedPath.dir, entry);
132
+ await fs.rm(backupPath, { force: true }).catch((err) => {
133
+ logger().error(`Failed to delete backup ${backupPath}:`, err);
134
+ });
135
+ }
136
+ }
137
+
138
+ send({
139
+ type: "end",
140
+ data: {
141
+ type: "success",
142
+ result: {
143
+ result: "ok",
144
+ },
145
+ },
146
+ });
147
+ } catch (e) {
148
+ logger().error(`config:delete error for ${name}:`, e);
149
+ send({
150
+ type: "end",
151
+ data: {
152
+ type: "error",
153
+ ipcError: e instanceof Error ? e.message : `Unable to delete config ${name}`,
154
+ },
155
+ });
156
+ }
157
+ });
109
158
  };
@@ -3,7 +3,7 @@ import { PipelabContext } from "../context";
3
3
  import { useLogger } from "@pipelab/shared";
4
4
  import { getFinalPlugins, executeGraphWithHistory } from "../utils";
5
5
  import { presets } from "../presets/list";
6
- import { handleActionExecute, handleConditionExecute } from "../handler-func";
6
+ import { handleActionExecute } from "../handler-func";
7
7
  import { generateTempFolder } from "../utils/fs-extras";
8
8
  import { tmpdir } from "node:os";
9
9
  import { setupConfigFile } from "../config";
@@ -37,12 +37,6 @@ export const registerEngineHandlers = (context: PipelabContext) => {
37
37
  });
38
38
  });
39
39
 
40
- handle("condition:execute", async (_, { value }) => {
41
- const { nodeId, params, pluginId } = value;
42
- const cwd = await generateTempFolder(tmpdir());
43
- await handleConditionExecute(nodeId, pluginId, params, cwd, context);
44
- });
45
-
46
40
  let abortControllerGraph: undefined | AbortController = undefined;
47
41
 
48
42
  const effectiveActionExecute = async (
@@ -6,6 +6,7 @@ import { registerEngineHandlers } from "./engine";
6
6
  import { registerAgentsHandlers } from "./agents";
7
7
  import { registerAuthHandlers } from "./auth";
8
8
  import { registerSystemHandlers } from "./system";
9
+ import { registerPluginsHandlers } from "./plugins";
9
10
  import { builtInPlugins } from "../plugins-registry";
10
11
  import { usePlugins } from "@pipelab/shared";
11
12
  import { PipelabContext } from "../context";
@@ -24,6 +25,7 @@ export const registerAllHandlers = async (options: {
24
25
  registerAgentsHandlers(context);
25
26
  registerAuthHandlers(context);
26
27
  registerSystemHandlers(options);
28
+ registerPluginsHandlers(context);
27
29
 
28
30
  const { registerPlugins } = usePlugins();
29
31
  // Execute in the background! The plugins will be dynamically registered and broadcasted to the UI.
@@ -0,0 +1,310 @@
1
+ import { useAPI } from "../ipc-core";
2
+ import { PipelabContext, isDev, projectRoot } from "../context";
3
+ import pacote from "pacote";
4
+ import { rm } from "node:fs/promises";
5
+ import { existsSync, readdirSync, readFileSync } from "node:fs";
6
+ import { join } from "node:path";
7
+ import { usePlugins } from "@pipelab/shared";
8
+ import { webSocketServer } from "../websocket-server";
9
+ import { fetchPipelabPlugin } from "../utils/remote";
10
+ import { loadCustomPlugin, findInstalledPlugins } from "../plugins-registry";
11
+
12
+ // Maps workspace plugin package names (e.g. "@pipelab/plugin-steam") to their physical directory paths.
13
+ // This is populated at startup in dev mode, supporting plugin folders whose directory names
14
+ // differ from their actual package.json name.
15
+ const localPluginsMap = new Map<string, string>();
16
+
17
+ if (isDev && projectRoot) {
18
+ const pluginsDir = join(projectRoot, "plugins");
19
+ if (existsSync(pluginsDir)) {
20
+ try {
21
+ const entries = readdirSync(pluginsDir, { withFileTypes: true });
22
+ for (const entry of entries) {
23
+ if (entry.isDirectory()) {
24
+ const pkgPath = join(pluginsDir, entry.name, "package.json");
25
+ if (existsSync(pkgPath)) {
26
+ try {
27
+ const pkg = JSON.parse(readFileSync(pkgPath, "utf-8"));
28
+ if (pkg.name) {
29
+ localPluginsMap.set(pkg.name, join(pluginsDir, entry.name));
30
+ }
31
+ } catch (e) {
32
+ console.error(`[Plugins] Failed to parse package.json for ${entry.name}:`, e);
33
+ }
34
+ }
35
+ }
36
+ }
37
+ } catch (e) {
38
+ console.error(`[Plugins] Failed to scan local workspace plugins directory:`, e);
39
+ }
40
+ }
41
+ }
42
+
43
+ /**
44
+ * Resolves a requested plugin version.
45
+ * If the plugin is present in the local workspace map during development,
46
+ * its version is overridden to "local" so Pipelab loads it from the source files.
47
+ */
48
+ export function resolvePluginVersion(packageName: string, requestedVersion?: string): string {
49
+ if (
50
+ isDev &&
51
+ projectRoot &&
52
+ process.env.PIPELAB_FORCE_NPM !== "true" &&
53
+ localPluginsMap.has(packageName)
54
+ ) {
55
+ return "local";
56
+ }
57
+ return requestedVersion || "latest";
58
+ }
59
+
60
+ export const registerPluginsHandlers = (context: PipelabContext) => {
61
+ const { handle } = useAPI();
62
+
63
+ handle("plugin:search", async (_, { send, value }) => {
64
+ try {
65
+ const query = value.query || "";
66
+ const text = query ? `${query} keywords:pipelab-plugin` : "keywords:pipelab-plugin";
67
+ const url = `https://registry.npmjs.org/-/v1/search?text=${encodeURIComponent(text)}&size=50`;
68
+
69
+ const response = await fetch(url);
70
+ if (!response.ok) {
71
+ throw new Error(`NPM registry search failed with status ${response.status}`);
72
+ }
73
+
74
+ const data = await response.json();
75
+ const results = (data.objects || []).map((obj: any) => ({
76
+ name: obj.package.name,
77
+ version: obj.package.version,
78
+ description: obj.package.description,
79
+ keywords: obj.package.keywords,
80
+ date: obj.package.date,
81
+ }));
82
+
83
+ send({
84
+ type: "end",
85
+ data: {
86
+ type: "success",
87
+ result: { results },
88
+ },
89
+ });
90
+ } catch (e: any) {
91
+ send({
92
+ type: "end",
93
+ data: {
94
+ type: "error",
95
+ ipcError: e.message || "Failed to search npm registry",
96
+ },
97
+ });
98
+ }
99
+ });
100
+
101
+ handle("plugin:get-details", async (_, { send, value }) => {
102
+ try {
103
+ const { packageName } = value;
104
+ const packument = await pacote.packument(packageName, { fullMetadata: true });
105
+ const latestVersion =
106
+ packument["dist-tags"]?.latest || Object.keys(packument.versions).pop() || "0.0.0";
107
+ const latestPkg = packument.versions[latestVersion];
108
+
109
+ send({
110
+ type: "end",
111
+ data: {
112
+ type: "success",
113
+ result: {
114
+ name: packument.name,
115
+ latestVersion,
116
+ versions: Object.keys(packument.versions).reverse(),
117
+ description: latestPkg?.description,
118
+ },
119
+ },
120
+ });
121
+ } catch (e: any) {
122
+ send({
123
+ type: "end",
124
+ data: {
125
+ type: "error",
126
+ ipcError: e.message || "Failed to get plugin details",
127
+ },
128
+ });
129
+ }
130
+ });
131
+
132
+ handle("plugin:install", async (_, { send, value }) => {
133
+ try {
134
+ const { packageName, version } = value;
135
+ const mappedVersion = resolvePluginVersion(packageName, version);
136
+ console.log(`[Plugins] Installing ${packageName}@${mappedVersion}...`);
137
+
138
+ const { packageDir } = await fetchPipelabPlugin(packageName, mappedVersion, {
139
+ context,
140
+ installDeps: false,
141
+ });
142
+
143
+ const plugin = await loadCustomPlugin(packageName, mappedVersion, { context });
144
+ if (!plugin) {
145
+ throw new Error("Failed to load installed plugin module.");
146
+ }
147
+
148
+ const { registerPlugins } = usePlugins();
149
+ registerPlugins([plugin]);
150
+
151
+ webSocketServer.broadcast("plugin:loaded", { plugin });
152
+
153
+ send({
154
+ type: "end",
155
+ data: {
156
+ type: "success",
157
+ result: { result: "ok" },
158
+ },
159
+ });
160
+ } catch (e: any) {
161
+ console.error(`[Plugins] Installation failed for ${value.packageName}:`, e);
162
+ send({
163
+ type: "end",
164
+ data: {
165
+ type: "error",
166
+ ipcError: e.message || "Failed to install plugin",
167
+ },
168
+ });
169
+ }
170
+ });
171
+
172
+ handle("plugin:uninstall", async (_, { send, value }) => {
173
+ try {
174
+ const { packageName } = value;
175
+ console.log(`[Plugins] Uninstalling plugin ${packageName}...`);
176
+ const targetDir = context.getPackagesPath(packageName);
177
+
178
+ if (existsSync(targetDir)) {
179
+ await rm(targetDir, { recursive: true, force: true });
180
+ }
181
+
182
+ send({
183
+ type: "end",
184
+ data: {
185
+ type: "success",
186
+ result: { result: "ok" },
187
+ },
188
+ });
189
+ } catch (e: any) {
190
+ send({
191
+ type: "end",
192
+ data: {
193
+ type: "error",
194
+ ipcError: e.message || "Failed to uninstall plugin",
195
+ },
196
+ });
197
+ }
198
+ });
199
+
200
+ handle("plugin:list-installed", async (_, { send }) => {
201
+ try {
202
+ const packagesDir = context.getPackagesPath();
203
+ const rawInstalled = await findInstalledPlugins(packagesDir);
204
+
205
+ const DEFAULT_PLUGIN_IDS = [
206
+ "construct",
207
+ "filesystem",
208
+ "system",
209
+ "steam",
210
+ "itch",
211
+ "electron",
212
+ "discord",
213
+ "poki",
214
+ "nvpatch",
215
+ "tauri",
216
+ "minify",
217
+ "netlify",
218
+ ];
219
+
220
+ const installed = rawInstalled
221
+ .filter((item) => {
222
+ const isDefault = DEFAULT_PLUGIN_IDS.some((id) => item.name === `@pipelab/plugin-${id}`);
223
+ return !isDefault;
224
+ })
225
+ .map((item) => ({
226
+ name: item.name,
227
+ version: item.version,
228
+ description: item.description,
229
+ }));
230
+
231
+ send({
232
+ type: "end",
233
+ data: {
234
+ type: "success",
235
+ result: { installed },
236
+ },
237
+ });
238
+ } catch (e: any) {
239
+ send({
240
+ type: "end",
241
+ data: {
242
+ type: "error",
243
+ ipcError: e.message || "Failed to list installed plugins",
244
+ },
245
+ });
246
+ }
247
+ });
248
+
249
+ // Ensures all required plugin IDs are loaded, JIT-installing any that are missing.
250
+ // Called before opening a pipeline in the editor.
251
+ handle("plugin:ensure-loaded", async (_, { send, value }) => {
252
+ const { pluginIds, plugins } = value;
253
+ const { plugins: registeredPlugins, registerPlugins } = usePlugins();
254
+ const loaded: string[] = [];
255
+ const failed: string[] = [];
256
+
257
+ const pluginsToEnsure = new Set<string>();
258
+ if (Array.isArray(pluginIds)) {
259
+ for (const id of pluginIds) {
260
+ if (id) pluginsToEnsure.add(id);
261
+ }
262
+ }
263
+ if (plugins && typeof plugins === "object") {
264
+ for (const id of Object.keys(plugins)) {
265
+ if (id) pluginsToEnsure.add(id);
266
+ }
267
+ }
268
+
269
+ for (const packageName of pluginsToEnsure) {
270
+ const mappedVersion = resolvePluginVersion(packageName);
271
+
272
+ const isRegistered = registeredPlugins.value.some((p) => {
273
+ if (p.packageName !== packageName) return false;
274
+ if (mappedVersion === "latest") return true;
275
+ return p.version === mappedVersion;
276
+ });
277
+
278
+ if (isRegistered) {
279
+ continue; // already loaded, nothing to do
280
+ }
281
+
282
+ try {
283
+ console.log(
284
+ `[Plugins] JIT loading plugin "${packageName}@${mappedVersion}" for pipeline...`,
285
+ );
286
+ const plugin = await loadCustomPlugin(packageName, mappedVersion, { context });
287
+ if (plugin) {
288
+ registerPlugins([plugin]);
289
+ webSocketServer.broadcast("plugin:loaded", { plugin });
290
+ loaded.push(packageName);
291
+ console.log(`[Plugins] JIT loaded "${packageName}" successfully.`);
292
+ } else {
293
+ console.warn(`[Plugins] JIT load for "${packageName}" returned no plugin.`);
294
+ failed.push(packageName);
295
+ }
296
+ } catch (e: any) {
297
+ console.error(`[Plugins] JIT load failed for "${packageName}":`, e);
298
+ failed.push(packageName);
299
+ }
300
+ }
301
+
302
+ send({
303
+ type: "end",
304
+ data: {
305
+ type: "success",
306
+ result: { loaded, failed },
307
+ },
308
+ });
309
+ });
310
+ };