@pulse-editor/cli 0.1.1-beta.33 → 0.1.1-beta.35

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.
@@ -3,7 +3,7 @@ import fs from 'fs';
3
3
  export async function publishApp(isStage) {
4
4
  // Upload the zip file to the server
5
5
  // Read pulse.config.json for visibility
6
- const config = JSON.parse(fs.readFileSync('./dist/client/pulse.config.json', 'utf-8'));
6
+ const config = JSON.parse(fs.readFileSync('./dist/pulse.config.json', 'utf-8'));
7
7
  const visibility = config.visibility;
8
8
  const formData = new FormData();
9
9
  const buffer = fs.readFileSync('./node_modules/@pulse-editor/dist.zip');
@@ -1,32 +1,32 @@
1
1
  /**
2
2
  * This is a local dev server for "npm run dev" and "npm run preview".
3
3
  */
4
- import express from 'express';
5
- import cors from 'cors';
6
- import dotenv from 'dotenv';
7
- import livereload from 'livereload';
8
- import connectLivereload from 'connect-livereload';
9
- import { networkInterfaces } from 'os';
10
- import { pipeline, Readable } from 'stream';
11
- import { promisify } from 'util';
12
- import { readConfigFile } from './utils.js';
13
- import path from 'path';
14
- import { pathToFileURL } from 'url';
4
+ import connectLivereload from "connect-livereload";
5
+ import cors from "cors";
6
+ import dotenv from "dotenv";
7
+ import express from "express";
8
+ import livereload from "livereload";
9
+ import { networkInterfaces } from "os";
10
+ import path from "path";
11
+ import { pipeline, Readable } from "stream";
12
+ import { pathToFileURL } from "url";
13
+ import { promisify } from "util";
14
+ import { readConfigFile } from "../webpack/configs/utils.js";
15
15
  dotenv.config({
16
16
  quiet: true,
17
17
  });
18
- const isPreview = process.env?.['PREVIEW'];
19
- const isDev = process.env?.['NODE_ENV'] === 'development';
20
- const workspaceId = process.env?.['WORKSPACE_ID'];
18
+ const isPreview = process.env?.["PREVIEW"];
19
+ const isDev = process.env?.["NODE_ENV"] === "development";
20
+ const workspaceId = process.env?.["WORKSPACE_ID"];
21
21
  const pulseConfig = await readConfigFile();
22
22
  if (isDev || isPreview) {
23
23
  const livereloadServer = livereload.createServer({
24
24
  // @ts-expect-error override server options
25
- host: '0.0.0.0',
25
+ host: "0.0.0.0",
26
26
  });
27
- livereloadServer.watch('dist');
28
- livereloadServer.server.once('connection', () => {
29
- console.log('✅ LiveReload connected');
27
+ livereloadServer.watch("dist");
28
+ livereloadServer.server.once("connection", () => {
29
+ console.log("✅ LiveReload connected");
30
30
  });
31
31
  }
32
32
  const app = express();
@@ -49,24 +49,24 @@ app.use((req, res, next) => {
49
49
  return next();
50
50
  });
51
51
  // Serve backend
52
- app.use(`/${pulseConfig.id}/${pulseConfig.version}/server`, express.static('dist/server'));
52
+ app.use(`/${pulseConfig.id}/${pulseConfig.version}/server`, express.static("dist/server"));
53
53
  // Catch backend function calls
54
54
  app.all(/^\/server-function\/(.*)/, async (req, res) => {
55
55
  const func = req.params[0];
56
- const url = `${req.protocol}://${req.get('host')}${req.originalUrl}`;
56
+ const url = `${req.protocol}://${req.get("host")}${req.originalUrl}`;
57
57
  // Convert Express req -> Fetch Request
58
58
  const request = new Request(url, {
59
59
  method: req.method,
60
60
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
61
61
  headers: req.headers,
62
- body: ['GET', 'HEAD'].includes(req.method)
62
+ body: ["GET", "HEAD"].includes(req.method)
63
63
  ? null
64
64
  : JSON.stringify(req.body),
65
65
  });
66
- const dir = path.resolve('node_modules/@pulse-editor/cli/dist/lib/server/preview/backend/load-remote.cjs');
66
+ const dir = path.resolve("node_modules/@pulse-editor/cli/dist/lib/server/preview/backend/load-remote.cjs");
67
67
  const fileUrl = pathToFileURL(dir).href;
68
68
  const { loadFunc, loadPrice } = await import(fileUrl);
69
- const price = await loadPrice(func, pulseConfig.id, 'http://localhost:3030', pulseConfig.version);
69
+ const price = await loadPrice(func, pulseConfig.id, "http://localhost:3030", pulseConfig.version);
70
70
  if (price) {
71
71
  // Make func name and price bold in console
72
72
  console.log(`🏃 Running function \x1b[1m${func}\x1b[0m, credits consumed: \x1b[1m${price}\x1b[0m`);
@@ -75,7 +75,7 @@ app.all(/^\/server-function\/(.*)/, async (req, res) => {
75
75
  console.log(`🏃 Running function \x1b[1m${func}\x1b[0m.`);
76
76
  }
77
77
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
78
- const loadedFunc = await loadFunc(func, pulseConfig.id, 'http://localhost:3030', pulseConfig.version);
78
+ const loadedFunc = await loadFunc(func, pulseConfig.id, "http://localhost:3030", pulseConfig.version);
79
79
  const response = await loadedFunc(request);
80
80
  const streamPipeline = promisify(pipeline);
81
81
  if (response) {
@@ -97,18 +97,18 @@ app.all(/^\/server-function\/(.*)/, async (req, res) => {
97
97
  });
98
98
  if (isPreview) {
99
99
  /* Preview mode */
100
- app.use(express.static('dist/client'));
101
- app.listen(3030, '0.0.0.0');
100
+ app.use(express.static("dist/client"));
101
+ app.listen(3030, "0.0.0.0");
102
102
  }
103
103
  else if (isDev) {
104
104
  /* Dev mode */
105
- app.use(`/${pulseConfig.id}/${pulseConfig.version}`, express.static('dist'));
106
- app.listen(3030, '0.0.0.0');
105
+ app.use(`/${pulseConfig.id}/${pulseConfig.version}`, express.static("dist"));
106
+ app.listen(3030, "0.0.0.0");
107
107
  }
108
108
  else {
109
109
  /* Production mode */
110
- app.use(`/${pulseConfig.id}/${pulseConfig.version}`, express.static('dist'));
111
- app.listen(3030, '0.0.0.0', () => {
110
+ app.use(`/${pulseConfig.id}/${pulseConfig.version}`, express.static("dist"));
111
+ app.listen(3030, "0.0.0.0", () => {
112
112
  console.log(`\
113
113
  🎉 Your Pulse extension \x1b[1m${pulseConfig.displayName}\x1b[0m is LIVE!
114
114
 
@@ -124,10 +124,10 @@ function getLocalNetworkIP() {
124
124
  if (!iface)
125
125
  continue;
126
126
  for (const config of iface) {
127
- if (config.family === 'IPv4' && !config.internal) {
127
+ if (config.family === "IPv4" && !config.internal) {
128
128
  return config.address; // Returns the first non-internal IPv4 address
129
129
  }
130
130
  }
131
131
  }
132
- return 'localhost'; // Fallback
132
+ return "localhost"; // Fallback
133
133
  }
@@ -1,10 +1,10 @@
1
1
  import fs from 'fs/promises';
2
2
  export async function readConfigFile() {
3
3
  // Read pulse.config.json from dist/client
4
- // Wait until dist/client/pulse.config.json exists
4
+ // Wait until dist/pulse.config.json exists
5
5
  while (true) {
6
6
  try {
7
- await fs.access('dist/client/pulse.config.json');
7
+ await fs.access('dist/pulse.config.json');
8
8
  break;
9
9
  }
10
10
  catch (err) {
@@ -12,6 +12,6 @@ export async function readConfigFile() {
12
12
  await new Promise(resolve => setTimeout(resolve, 100));
13
13
  }
14
14
  }
15
- const data = await fs.readFile('dist/client/pulse.config.json', 'utf-8');
15
+ const data = await fs.readFile('dist/pulse.config.json', 'utf-8');
16
16
  return JSON.parse(data);
17
17
  }
@@ -1,5 +1,5 @@
1
1
  import webpack from 'webpack';
2
- import { createWebpackConfig } from '../../lib/webpack/webpack.config.js';
2
+ import { createWebpackConfig } from './webpack-config.js';
3
3
  export async function webpackCompile(mode, buildTarget, isWatchMode = false) {
4
4
  const configs = await createWebpackConfig(mode === 'preview', buildTarget ?? 'both', mode === 'development'
5
5
  ? 'development'
@@ -0,0 +1,3 @@
1
+ import { Configuration as WebpackConfig } from "webpack";
2
+ import { Configuration as DevServerConfig } from "webpack-dev-server";
3
+ export declare function makeMFClientConfig(mode: "development" | "production"): Promise<WebpackConfig & DevServerConfig>;
@@ -0,0 +1,183 @@
1
+ /* eslint-disable @typescript-eslint/no-explicit-any */
2
+ import { ModuleFederationPlugin } from "@module-federation/enhanced/webpack";
3
+ import CopyWebpackPlugin from "copy-webpack-plugin";
4
+ import fs from "fs";
5
+ import { globSync } from "glob";
6
+ import MiniCssExtractPlugin from "mini-css-extract-plugin";
7
+ import path from "path";
8
+ import ts from "typescript";
9
+ import { discoverAppSkillActions, getLocalNetworkIP, loadPulseConfig, } from "./utils.js";
10
+ class MFClientPlugin {
11
+ projectDirName;
12
+ pulseConfig;
13
+ origin;
14
+ constructor(pulseConfig) {
15
+ this.projectDirName = process.cwd();
16
+ this.pulseConfig = pulseConfig;
17
+ this.origin = getLocalNetworkIP();
18
+ }
19
+ apply(compiler) {
20
+ if (compiler.options.mode === "development") {
21
+ let isFirstRun = true;
22
+ // Before build starts
23
+ compiler.hooks.watchRun.tap("ReloadMessagePlugin", () => {
24
+ if (!isFirstRun) {
25
+ console.log("[client] 🔄 reloading app...");
26
+ }
27
+ else {
28
+ console.log("[client] 🔄 building app...");
29
+ }
30
+ });
31
+ // Log file updates
32
+ compiler.hooks.invalid.tap("LogFileUpdates", (file, changeTime) => {
33
+ console.log(`[watch] change detected in: ${file} at ${new Date(changeTime || Date.now()).toLocaleTimeString()}`);
34
+ });
35
+ const devStartupMessage = `
36
+ 🎉 Your Pulse extension \x1b[1m${this.pulseConfig.displayName}\x1b[0m is LIVE!
37
+
38
+ ⚡️ Local: http://localhost:3030/${this.pulseConfig.id}/${this.pulseConfig.version}/
39
+ ⚡️ Network: http://${this.origin}:3030/${this.pulseConfig.id}/${this.pulseConfig.version}/
40
+
41
+ ✨ Try it out in the Pulse Editor and let the magic happen! 🚀
42
+ `;
43
+ // After build finishes
44
+ compiler.hooks.done.tap("ReloadMessagePlugin", () => {
45
+ if (isFirstRun) {
46
+ console.log("[client] ✅ Successfully built client.");
47
+ console.log(devStartupMessage);
48
+ isFirstRun = false;
49
+ }
50
+ else {
51
+ console.log("[client] ✅ Reload finished.");
52
+ }
53
+ // Write pulse config to dist
54
+ fs.writeFileSync(path.resolve(this.projectDirName, "dist/pulse.config.json"), JSON.stringify(this.pulseConfig, null, 2));
55
+ });
56
+ }
57
+ else {
58
+ // Print build success/failed message
59
+ compiler.hooks.done.tap("BuildMessagePlugin", (stats) => {
60
+ if (stats.hasErrors()) {
61
+ console.log(`[client] ❌ Failed to build client.`);
62
+ }
63
+ else {
64
+ console.log(`[client] ✅ Successfully built client.`);
65
+ // Write pulse config to dist
66
+ fs.writeFileSync(path.resolve(this.projectDirName, "dist/pulse.config.json"), JSON.stringify(this.pulseConfig, null, 2));
67
+ }
68
+ });
69
+ }
70
+ compiler.hooks.beforeCompile.tap("PulseConfigPlugin", () => {
71
+ let requireFS = false;
72
+ function isWorkspaceHook(node) {
73
+ return (ts.isCallExpression(node) &&
74
+ ts.isIdentifier(node.expression) &&
75
+ [
76
+ "useFileSystem",
77
+ "useFile",
78
+ "useReceiveFile",
79
+ "useTerminal",
80
+ "useWorkspaceInfo",
81
+ ].includes(node.expression.text));
82
+ }
83
+ function scanSource(sourceText) {
84
+ const sourceFile = ts.createSourceFile("temp.tsx", sourceText, ts.ScriptTarget.Latest, true);
85
+ const visit = (node) => {
86
+ // Detect: useFileSystem(...)
87
+ if (isWorkspaceHook(node)) {
88
+ requireFS = true;
89
+ }
90
+ ts.forEachChild(node, visit);
91
+ };
92
+ visit(sourceFile);
93
+ }
94
+ globSync(["src/**/*.tsx", "src/**/*.ts"]).forEach((file) => {
95
+ const source = fs.readFileSync(file, "utf8");
96
+ scanSource(source);
97
+ });
98
+ // Persist result
99
+ this.pulseConfig.requireWorkspace = requireFS;
100
+ });
101
+ }
102
+ }
103
+ export async function makeMFClientConfig(mode) {
104
+ const projectDirName = process.cwd();
105
+ const pulseConfig = await loadPulseConfig();
106
+ const mainComponent = "./src/main.tsx";
107
+ const actions = discoverAppSkillActions();
108
+ return {
109
+ mode: mode,
110
+ name: "client",
111
+ entry: mainComponent,
112
+ output: {
113
+ publicPath: "auto",
114
+ path: path.resolve(projectDirName, "dist/client"),
115
+ },
116
+ resolve: {
117
+ extensions: [".ts", ".tsx", ".js"],
118
+ },
119
+ plugins: [
120
+ new MiniCssExtractPlugin({
121
+ filename: "globals.css",
122
+ }),
123
+ // Copy assets to dist
124
+ new CopyWebpackPlugin({
125
+ patterns: [{ from: "src/assets", to: "assets" }],
126
+ }),
127
+ new ModuleFederationPlugin({
128
+ // Do not use hyphen character '-' in the name
129
+ name: pulseConfig.id + "_client",
130
+ filename: "remoteEntry.js",
131
+ exposes: {
132
+ "./main": mainComponent,
133
+ ...actions,
134
+ },
135
+ shared: {
136
+ react: {
137
+ requiredVersion: "19.2.0",
138
+ import: "react", // the "react" package will be used a provided and fallback module
139
+ shareKey: "react", // under this name the shared module will be placed in the share scope
140
+ shareScope: "default", // share scope with this name will be used
141
+ singleton: true, // only a single version of the shared module is allowed
142
+ },
143
+ "react-dom": {
144
+ requiredVersion: "19.2.0",
145
+ singleton: true, // only a single version of the shared module is allowed
146
+ },
147
+ },
148
+ }),
149
+ new MFClientPlugin(pulseConfig),
150
+ ],
151
+ module: {
152
+ rules: [
153
+ {
154
+ test: /\.tsx?$/,
155
+ use: "ts-loader",
156
+ exclude: [/node_modules/, /dist/],
157
+ },
158
+ {
159
+ test: /\.css$/i,
160
+ use: [
161
+ MiniCssExtractPlugin.loader,
162
+ "css-loader",
163
+ {
164
+ loader: "postcss-loader",
165
+ },
166
+ ],
167
+ exclude: [/dist/],
168
+ },
169
+ ],
170
+ },
171
+ stats: {
172
+ all: false,
173
+ errors: true,
174
+ warnings: true,
175
+ logging: "warn",
176
+ colors: true,
177
+ assets: false,
178
+ },
179
+ infrastructureLogging: {
180
+ level: "warn",
181
+ },
182
+ };
183
+ }
@@ -0,0 +1,2 @@
1
+ import { Configuration as WebpackConfig } from "webpack";
2
+ export declare function makeMFServerConfig(mode: "development" | "production"): Promise<WebpackConfig>;
@@ -0,0 +1,395 @@
1
+ /* eslint-disable @typescript-eslint/no-explicit-any */
2
+ import mfNode from "@module-federation/node";
3
+ import fs from "fs";
4
+ import { globSync } from "glob";
5
+ import path from "path";
6
+ import { Node, Project, SyntaxKind } from "ts-morph";
7
+ import wp from "webpack";
8
+ import { discoverAppSkillActions, loadPulseConfig } from "./utils.js";
9
+ const { NodeFederationPlugin } = mfNode;
10
+ const { webpack } = wp;
11
+ class MFServerPlugin {
12
+ projectDirName;
13
+ pulseConfig;
14
+ constructor(pulseConfig) {
15
+ this.projectDirName = process.cwd();
16
+ this.pulseConfig = pulseConfig;
17
+ }
18
+ apply(compiler) {
19
+ if (compiler.options.mode === "development") {
20
+ let isFirstRun = true;
21
+ // Before build starts
22
+ compiler.hooks.watchRun.tap("ReloadMessagePlugin", async (compilation) => {
23
+ this.cleanDist();
24
+ if (!isFirstRun) {
25
+ console.log(`[Server] 🔄 Reloading app...`);
26
+ const isServerFunctionChange = compilation.modifiedFiles
27
+ ? Array.from(compilation.modifiedFiles).some((file) => file.includes("src/server-function"))
28
+ : false;
29
+ if (isServerFunctionChange) {
30
+ await this.compileServerFunctions(compiler);
31
+ }
32
+ const isActionChange = compilation.modifiedFiles
33
+ ? Array.from(compilation.modifiedFiles).some((file) => file.includes("src/action"))
34
+ : false;
35
+ if (isActionChange) {
36
+ console.log(`[Server] Detected changes in actions. Recompiling...`);
37
+ this.compileAppActionSkills();
38
+ }
39
+ }
40
+ else {
41
+ console.log(`[Server] 🔄 Building app...`);
42
+ await this.compileServerFunctions(compiler);
43
+ this.compileAppActionSkills();
44
+ console.log(`[Server] ✅ Successfully built server.`);
45
+ }
46
+ });
47
+ // After build finishes
48
+ compiler.hooks.done.tap("ReloadMessagePlugin", () => {
49
+ if (isFirstRun) {
50
+ isFirstRun = false;
51
+ }
52
+ else {
53
+ console.log(`[Server] ✅ Reload finished.`);
54
+ }
55
+ });
56
+ // Watch for file changes in the server-function directory to trigger server-function rebuilds
57
+ compiler.hooks.thisCompilation.tap("WatchServerFunctions", (compilation) => {
58
+ compilation.contextDependencies.add(path.resolve(this.projectDirName, "src/server-function"));
59
+ });
60
+ // Watch for file changes in the action directory to trigger action rebuilds
61
+ compiler.hooks.thisCompilation.tap("WatchActions", (compilation) => {
62
+ compilation.contextDependencies.add(path.resolve(this.projectDirName, "src/action"));
63
+ });
64
+ }
65
+ else {
66
+ // Print build success/failed message
67
+ compiler.hooks.done.tap("BuildMessagePlugin", async (stats) => {
68
+ if (stats.hasErrors()) {
69
+ console.log(`[Server] ❌ Failed to build server.`);
70
+ }
71
+ else {
72
+ this.cleanDist();
73
+ try {
74
+ await this.compileServerFunctions(compiler);
75
+ this.compileAppActionSkills();
76
+ }
77
+ catch (err) {
78
+ console.log(`[Server] ❌ Error during compilation:`, err);
79
+ return;
80
+ }
81
+ console.log(`[Server] ✅ Successfully built server.`);
82
+ }
83
+ });
84
+ }
85
+ }
86
+ cleanDist() {
87
+ // Remove existing entry points
88
+ try {
89
+ fs.rmSync("dist/server", { recursive: true, force: true });
90
+ }
91
+ catch (e) {
92
+ console.error("Error removing dist/server:", e);
93
+ console.log("Continuing...");
94
+ }
95
+ }
96
+ /**
97
+ * Programmatically call webpack to compile server functions
98
+ * whenever there are changes in the src/server-function directory.
99
+ * This is necessary because Module Federation needs to know about
100
+ * all the exposed modules at build time, so we have to trigger a
101
+ * new compilation whenever server functions are added/removed/changed.
102
+ * @param compiler
103
+ */
104
+ async compileServerFunctions(compiler) {
105
+ // Generate tsconfig for server functions
106
+ function generateTempTsConfig() {
107
+ const tempTsConfigPath = path.join(process.cwd(), "node_modules/.pulse/tsconfig.server.json");
108
+ const tsConfig = {
109
+ compilerOptions: {
110
+ target: "ES2020",
111
+ module: "esnext",
112
+ moduleResolution: "bundler",
113
+ strict: true,
114
+ declaration: true,
115
+ outDir: path.join(process.cwd(), "dist"),
116
+ },
117
+ include: [
118
+ path.join(process.cwd(), "src/server-function/**/*"),
119
+ path.join(process.cwd(), "pulse.config.ts"),
120
+ path.join(process.cwd(), "global.d.ts"),
121
+ ],
122
+ exclude: [
123
+ path.join(process.cwd(), "node_modules"),
124
+ path.join(process.cwd(), "dist"),
125
+ ],
126
+ };
127
+ fs.writeFileSync(tempTsConfigPath, JSON.stringify(tsConfig, null, 2));
128
+ }
129
+ generateTempTsConfig();
130
+ // Run a new webpack compilation to pick up new server functions
131
+ const options = {
132
+ ...compiler.options,
133
+ watch: false,
134
+ plugins: [
135
+ // Add a new NodeFederationPlugin with updated entry points
136
+ this.makeNodeFederationPlugin(),
137
+ ],
138
+ };
139
+ const newCompiler = webpack(options);
140
+ // Run the new compiler
141
+ return new Promise((resolve, reject) => {
142
+ newCompiler?.run((err, stats) => {
143
+ if (err) {
144
+ console.error(`[Server] ❌ Error during recompilation:`, err);
145
+ reject(err);
146
+ }
147
+ else if (stats?.hasErrors()) {
148
+ console.error(`[Server] ❌ Compilation errors:`, stats.toJson().errors);
149
+ reject(new Error("Compilation errors"));
150
+ }
151
+ else {
152
+ console.log(`[Server] ✅ Compiled server functions successfully.`);
153
+ resolve();
154
+ }
155
+ });
156
+ });
157
+ }
158
+ makeNodeFederationPlugin() {
159
+ function discoverServerFunctions() {
160
+ // Get all .ts files under src/server-function and read use default exports as entry points
161
+ const files = globSync("./src/server-function/**/*.ts");
162
+ const entryPoints = files
163
+ .map((file) => file.replaceAll("\\", "/"))
164
+ .map((file) => {
165
+ return {
166
+ ["./" +
167
+ file.replace("src/server-function/", "").replace(/\.ts$/, "")]: "./" + file,
168
+ };
169
+ })
170
+ .reduce((acc, curr) => {
171
+ return { ...acc, ...curr };
172
+ }, {});
173
+ return entryPoints;
174
+ }
175
+ const funcs = discoverServerFunctions();
176
+ const actions = discoverAppSkillActions();
177
+ console.log(`Discovered server functions:
178
+ ${Object.entries(funcs)
179
+ .map(([name, file]) => {
180
+ return ` - ${name.slice(2)} (from ${file})`;
181
+ })
182
+ .join("\n")}
183
+ `);
184
+ return new NodeFederationPlugin({
185
+ name: this.pulseConfig.id + "_server",
186
+ remoteType: "script",
187
+ useRuntimePlugin: true,
188
+ library: { type: "commonjs-module" },
189
+ filename: "remoteEntry.js",
190
+ exposes: {
191
+ ...funcs,
192
+ ...actions,
193
+ },
194
+ }, {});
195
+ }
196
+ /**
197
+ * Register default functions defined in src/action as exposed modules in Module Federation.
198
+ * This will:
199
+ * 1. Search for all .ts files under src/action
200
+ * 2. Use ts-morph to get the default function information, including function name, parameters, and JSDoc comments
201
+ * 3. Organize the functions' information into a list of Action
202
+ * @param compiler
203
+ */
204
+ compileAppActionSkills() {
205
+ // 1. Get all TypeScript files under src/skill
206
+ const files = globSync("./src/skill/*/action.ts");
207
+ const project = new Project({
208
+ tsConfigFilePath: path.join(process.cwd(), "node_modules/.pulse/tsconfig.server.json"),
209
+ });
210
+ const actions = [];
211
+ files.forEach((file) => {
212
+ const sourceFile = project.addSourceFileAtPath(file);
213
+ const defaultExportSymbol = sourceFile.getDefaultExportSymbol();
214
+ if (!defaultExportSymbol)
215
+ return;
216
+ const defaultExportDeclarations = defaultExportSymbol.getDeclarations();
217
+ defaultExportDeclarations.forEach((declaration) => {
218
+ if (declaration.getKind() !== SyntaxKind.FunctionDeclaration)
219
+ return;
220
+ const funcDecl = declaration.asKindOrThrow(SyntaxKind.FunctionDeclaration);
221
+ // Extract JSDoc comments
222
+ const funcName = funcDecl.getName() ?? "default";
223
+ // Throw an error if the funcName is duplicated with an existing action to prevent accidental overwriting
224
+ if (actions.some((action) => action.name === funcName)) {
225
+ throw new Error(`Duplicate action name "${funcName}" detected in file ${file}. Please ensure all actions have unique names to avoid conflicts.`);
226
+ }
227
+ const defaultExportJSDocs = funcDecl.getJsDocs();
228
+ const description = defaultExportJSDocs
229
+ .map((doc) => doc.getFullText())
230
+ .join("\n");
231
+ const allJSDocs = sourceFile.getDescendantsOfKind(SyntaxKind.JSDoc);
232
+ const typeDefs = this.parseTypeDefs(allJSDocs);
233
+ /* Extract parameter descriptions from JSDoc */
234
+ // Check if the function's first param is an object
235
+ if (funcDecl.getParameters().length !== 1) {
236
+ throw new Error(`[Action Registration] Function ${funcName} should have exactly one parameter which is an object. Skipping...`);
237
+ }
238
+ else if (!funcDecl.getParameters()[0]?.getType().isObject()) {
239
+ throw new Error(`[Action Registration] Function ${funcName}'s parameter should be an object. Skipping...`);
240
+ }
241
+ const funcParam = funcDecl.getParameters()[0];
242
+ const params = {};
243
+ /**
244
+ * Extract default values from the destructured parameter
245
+ * (ObjectBindingPattern → BindingElement initializer)
246
+ */
247
+ const defaults = new Map();
248
+ const nameNode = funcParam.getNameNode();
249
+ if (Node.isObjectBindingPattern(nameNode)) {
250
+ nameNode.getElements().forEach((el) => {
251
+ if (!Node.isBindingElement(el))
252
+ return;
253
+ const name = el.getName();
254
+ const initializer = el.getInitializer()?.getText();
255
+ if (initializer) {
256
+ defaults.set(name, initializer);
257
+ }
258
+ });
259
+ }
260
+ funcParam
261
+ .getType()
262
+ .getProperties()
263
+ .forEach((prop) => {
264
+ const name = prop.getName();
265
+ const inputTypeDef = typeDefs["input"] ?? {};
266
+ const variable = {
267
+ description: inputTypeDef[name]?.description ?? "",
268
+ type: this.getType(inputTypeDef[name]?.type ?? ""),
269
+ optional: prop.isOptional() ? true : undefined,
270
+ defaultValue: defaults.get(name),
271
+ };
272
+ params[name] = variable;
273
+ });
274
+ /* Extract return type from JSDoc */
275
+ // Check if the return type is an object
276
+ if (!funcDecl.getReturnType().isObject()) {
277
+ console.warn(`[Action Registration] Function ${funcName}'s return type should be an object. Skipping...`);
278
+ return;
279
+ }
280
+ const returns = {};
281
+ funcDecl
282
+ .getReturnType()
283
+ .getProperties()
284
+ .forEach((prop) => {
285
+ const name = prop.getName();
286
+ const outputTypeDef = typeDefs["output"] ?? {};
287
+ const variable = {
288
+ description: outputTypeDef[name]?.description ?? "",
289
+ type: this.getType(outputTypeDef[name]?.type ?? ""),
290
+ optional: prop.isOptional() ? true : undefined,
291
+ defaultValue: undefined,
292
+ };
293
+ returns[name] = variable;
294
+ });
295
+ actions.push({
296
+ name: funcName,
297
+ description,
298
+ parameters: params,
299
+ returns,
300
+ });
301
+ });
302
+ });
303
+ // You can now register `actions` in Module Federation or expose them as needed
304
+ console.log("Discovered skill actions:\n", actions.map((a) => "- " + a.name).join("\n"));
305
+ // Register actions in pulse config for runtime access
306
+ this.pulseConfig.actions = actions;
307
+ }
308
+ parseTypeDefs(jsDocs) {
309
+ const typeDefs = {};
310
+ // Match @typedef {Type} Name
311
+ const typedefRegex = /@typedef\s+{([^}]+)}\s+([^\s-]+)/g;
312
+ // Match @property {Type} [name] Description text...
313
+ const propertyRegex = /@property\s+{([^}]+)}\s+(\[?[^\]\s]+\]?)\s*-?\s*(.*)/g;
314
+ jsDocs.forEach((doc) => {
315
+ const text = doc.getFullText();
316
+ let typedefMatches;
317
+ while ((typedefMatches = typedefRegex.exec(text)) !== null) {
318
+ const typeName = typedefMatches[2];
319
+ if (!typeName)
320
+ continue;
321
+ const properties = {};
322
+ let propertyMatches;
323
+ while ((propertyMatches = propertyRegex.exec(text)) !== null) {
324
+ const propName = propertyMatches[2];
325
+ const propType = propertyMatches[1];
326
+ const propDescription = propertyMatches[3] || "";
327
+ if (propName && propType) {
328
+ properties[propName] = {
329
+ type: propType,
330
+ description: propDescription.trim(),
331
+ };
332
+ }
333
+ }
334
+ typeDefs[typeName.toLowerCase()] = properties;
335
+ }
336
+ });
337
+ return typeDefs;
338
+ }
339
+ getType(text) {
340
+ if (text === "string")
341
+ return "string";
342
+ if (text === "number")
343
+ return "number";
344
+ if (text === "boolean")
345
+ return "boolean";
346
+ if (text === "any")
347
+ return "object";
348
+ if (text.endsWith("[]"))
349
+ return [this.getType(text.slice(0, -2))];
350
+ console.warn(`[Type Warning] Unrecognized type "${text}". Consider adding explicit types in your action's JSDoc comments for better type safety and documentation.`);
351
+ return text;
352
+ }
353
+ }
354
+ export async function makeMFServerConfig(mode) {
355
+ const projectDirName = process.cwd();
356
+ const pulseConfig = await loadPulseConfig();
357
+ return {
358
+ mode: mode,
359
+ name: "server",
360
+ entry: {},
361
+ target: "async-node",
362
+ output: {
363
+ publicPath: "auto",
364
+ path: path.resolve(projectDirName, "dist/server"),
365
+ },
366
+ resolve: {
367
+ extensions: [".ts", ".js"],
368
+ },
369
+ plugins: [new MFServerPlugin(pulseConfig)],
370
+ module: {
371
+ rules: [
372
+ {
373
+ test: /\.tsx?$/,
374
+ use: {
375
+ loader: "ts-loader",
376
+ options: {
377
+ configFile: "node_modules/.pulse/tsconfig.server.json",
378
+ },
379
+ },
380
+ exclude: [/node_modules/, /dist/],
381
+ },
382
+ ],
383
+ },
384
+ stats: {
385
+ all: false,
386
+ errors: true,
387
+ warnings: true,
388
+ logging: "warn",
389
+ colors: true,
390
+ },
391
+ infrastructureLogging: {
392
+ level: "warn",
393
+ },
394
+ };
395
+ }
@@ -0,0 +1,3 @@
1
+ import { Configuration as WebpackConfig } from "webpack";
2
+ import { Configuration as DevServerConfig } from "webpack-dev-server";
3
+ export declare function makePreviewClientConfig(mode: "development" | "production"): Promise<WebpackConfig & DevServerConfig>;
@@ -0,0 +1,110 @@
1
+ /* eslint-disable @typescript-eslint/no-explicit-any */
2
+ import CopyWebpackPlugin from "copy-webpack-plugin";
3
+ import fs from "fs";
4
+ import HtmlWebpackPlugin from "html-webpack-plugin";
5
+ import MiniCssExtractPlugin from "mini-css-extract-plugin";
6
+ import path from "path";
7
+ import { getLocalNetworkIP, loadPulseConfig } from "./utils.js";
8
+ class PreviewClientPlugin {
9
+ projectDirName;
10
+ pulseConfig;
11
+ origin;
12
+ constructor(pulseConfig) {
13
+ this.projectDirName = process.cwd();
14
+ this.pulseConfig = pulseConfig;
15
+ this.origin = getLocalNetworkIP();
16
+ }
17
+ apply(compiler) {
18
+ let isFirstRun = true;
19
+ // Before build starts
20
+ compiler.hooks.watchRun.tap("ReloadMessagePlugin", () => {
21
+ if (!isFirstRun) {
22
+ console.log("[client-preview] 🔄 Reloading app...");
23
+ }
24
+ else {
25
+ console.log("[client-preview] 🔄 Building app...");
26
+ }
27
+ });
28
+ // After build finishes
29
+ compiler.hooks.done.tap("ReloadMessagePlugin", () => {
30
+ if (isFirstRun) {
31
+ const previewStartupMessage = `
32
+ 🎉 Your Pulse extension preview \x1b[1m${this.pulseConfig.displayName}\x1b[0m is LIVE!
33
+
34
+ ⚡️ Local: http://localhost:3030
35
+ ⚡️ Network: http://${this.origin}:3030
36
+
37
+ ✨ Try it out in your browser and let the magic happen! 🚀
38
+ `;
39
+ console.log("[client-preview] ✅ Successfully built preview.");
40
+ console.log(previewStartupMessage);
41
+ isFirstRun = false;
42
+ }
43
+ else {
44
+ console.log("[client-preview] ✅ Reload finished");
45
+ }
46
+ // Write pulse config to dist
47
+ fs.writeFileSync(path.resolve(this.projectDirName, "dist/pulse.config.json"), JSON.stringify(this.pulseConfig, null, 2));
48
+ });
49
+ }
50
+ }
51
+ export async function makePreviewClientConfig(mode) {
52
+ const projectDirName = process.cwd();
53
+ const pulseConfig = await loadPulseConfig();
54
+ return {
55
+ mode: mode,
56
+ entry: {
57
+ main: "./node_modules/.pulse/server/preview/frontend/index.js",
58
+ },
59
+ output: {
60
+ path: path.resolve(projectDirName, "dist/client"),
61
+ },
62
+ resolve: {
63
+ extensions: [".ts", ".tsx", ".js"],
64
+ },
65
+ plugins: [
66
+ new HtmlWebpackPlugin({
67
+ template: "./node_modules/.pulse/server/preview/frontend/index.html",
68
+ }),
69
+ new MiniCssExtractPlugin({
70
+ filename: "globals.css",
71
+ }),
72
+ new CopyWebpackPlugin({
73
+ patterns: [{ from: "src/assets", to: "assets" }],
74
+ }),
75
+ new PreviewClientPlugin(pulseConfig),
76
+ ],
77
+ watchOptions: {
78
+ ignored: /src\/server-function/,
79
+ },
80
+ module: {
81
+ rules: [
82
+ {
83
+ test: /\.tsx?$/,
84
+ use: "ts-loader",
85
+ exclude: [/node_modules/, /dist/],
86
+ },
87
+ {
88
+ test: /\.css$/i,
89
+ use: [
90
+ MiniCssExtractPlugin.loader,
91
+ "css-loader",
92
+ {
93
+ loader: "postcss-loader",
94
+ },
95
+ ],
96
+ },
97
+ ],
98
+ },
99
+ stats: {
100
+ all: false,
101
+ errors: true,
102
+ warnings: true,
103
+ logging: "warn",
104
+ colors: true,
105
+ },
106
+ infrastructureLogging: {
107
+ level: "warn",
108
+ },
109
+ };
110
+ }
@@ -0,0 +1,6 @@
1
+ export declare function loadPulseConfig(): Promise<any>;
2
+ export declare function getLocalNetworkIP(): string;
3
+ export declare function readConfigFile(): Promise<any>;
4
+ export declare function discoverAppSkillActions(): {
5
+ [x: string]: string;
6
+ } | null;
@@ -0,0 +1,118 @@
1
+ /* eslint-disable @typescript-eslint/no-explicit-any */
2
+ import { existsSync } from "fs";
3
+ import fs from "fs/promises";
4
+ import { globSync } from "glob";
5
+ import { networkInterfaces } from "os";
6
+ import path from "path";
7
+ import { Project, SyntaxKind } from "ts-morph";
8
+ import ts from "typescript";
9
+ import { pathToFileURL } from "url";
10
+ export async function loadPulseConfig() {
11
+ const projectDirName = process.cwd();
12
+ // compile to js file and import
13
+ const program = ts.createProgram({
14
+ rootNames: [path.join(projectDirName, "pulse.config.ts")],
15
+ options: {
16
+ module: ts.ModuleKind.ESNext,
17
+ target: ts.ScriptTarget.ES2020,
18
+ outDir: path.join(projectDirName, "node_modules/.pulse/config"),
19
+ esModuleInterop: true,
20
+ skipLibCheck: true,
21
+ forceConsistentCasingInFileNames: true,
22
+ },
23
+ });
24
+ program.emit();
25
+ // Fix imports in the generated js file for all files in node_modules/.pulse/config
26
+ globSync("node_modules/.pulse/config/**/*.js", {
27
+ cwd: projectDirName,
28
+ absolute: true,
29
+ }).forEach(async (jsFile) => {
30
+ let content = await fs.readFile(jsFile, "utf-8");
31
+ content = content.replace(/(from\s+["']\.\/[^\s"']+)(["'])/g, (match, p1, p2) => {
32
+ // No change if the import already has any extension
33
+ if (p1.match(/\.(js|cjs|mjs|ts|tsx|json)$/)) {
34
+ return match; // No change needed
35
+ }
36
+ return `${p1}.js${p2}`;
37
+ });
38
+ await fs.writeFile(jsFile, content);
39
+ });
40
+ // Copy package.json if exists
41
+ const pkgPath = path.join(projectDirName, "package.json");
42
+ if (existsSync(pkgPath)) {
43
+ const destPath = path.join(projectDirName, "node_modules/.pulse/config/package.json");
44
+ await fs.copyFile(pkgPath, destPath);
45
+ }
46
+ const compiledConfig = path.join(projectDirName, "node_modules/.pulse/config/pulse.config.js");
47
+ const mod = await import(pathToFileURL(compiledConfig).href);
48
+ // delete the compiled config after importing
49
+ await fs.rm(path.join(projectDirName, "node_modules/.pulse/config"), {
50
+ recursive: true,
51
+ force: true,
52
+ });
53
+ return mod.default;
54
+ }
55
+ export function getLocalNetworkIP() {
56
+ const interfaces = networkInterfaces();
57
+ for (const iface of Object.values(interfaces)) {
58
+ if (!iface)
59
+ continue;
60
+ for (const config of iface) {
61
+ if (config.family === "IPv4" && !config.internal) {
62
+ return config.address; // Returns the first non-internal IPv4 address
63
+ }
64
+ }
65
+ }
66
+ return "localhost"; // Fallback
67
+ }
68
+ export async function readConfigFile() {
69
+ // Read pulse.config.json from dist/client
70
+ // Wait until dist/pulse.config.json exists
71
+ while (true) {
72
+ try {
73
+ await fs.access("dist/pulse.config.json");
74
+ break;
75
+ }
76
+ catch (err) {
77
+ // Wait for 100ms before trying again
78
+ await new Promise((resolve) => setTimeout(resolve, 100));
79
+ }
80
+ }
81
+ const data = await fs.readFile("dist/pulse.config.json", "utf-8");
82
+ return JSON.parse(data);
83
+ }
84
+ export function discoverAppSkillActions() {
85
+ // Get all .ts files under src/skill and read use default exports as entry points
86
+ const files = globSync("./src/skill/*/action.ts");
87
+ const entryPoints = files
88
+ .map((file) => file.replaceAll("\\", "/"))
89
+ .map((file) => {
90
+ // Read default export info in the file using ts-morph to get the function name, and use the function name as the entry point key instead of the file name
91
+ const project = new Project({
92
+ tsConfigFilePath: path.join(process.cwd(), "node_modules/.pulse/tsconfig.server.json"),
93
+ });
94
+ const sourceFile = project.addSourceFileAtPath(file);
95
+ const defaultExportSymbol = sourceFile.getDefaultExportSymbol();
96
+ if (!defaultExportSymbol) {
97
+ console.warn(`No default export found in action file ${file}. Skipping...`);
98
+ return null;
99
+ }
100
+ const defaultExportDeclarations = defaultExportSymbol.getDeclarations();
101
+ if (defaultExportDeclarations.length === 0) {
102
+ console.warn(`No declarations found for default export in action file ${file}. Skipping...`);
103
+ return null;
104
+ }
105
+ const funcDecl = defaultExportDeclarations[0]?.asKind(SyntaxKind.FunctionDeclaration);
106
+ if (!funcDecl) {
107
+ console.warn(`Default export in action file ${file} is not a function declaration. Skipping...`);
108
+ return null;
109
+ }
110
+ return {
111
+ ["./skill/" + funcDecl.getName()]: "./" + file,
112
+ };
113
+ })
114
+ .reduce((acc, curr) => {
115
+ return { ...acc, ...curr };
116
+ }, {});
117
+ return entryPoints;
118
+ }
@@ -0,0 +1 @@
1
+ export declare function createWebpackConfig(isPreview: boolean, buildTarget: "client" | "server" | "both", mode: "development" | "production"): Promise<import("webpack").Configuration[]>;
@@ -0,0 +1,24 @@
1
+ /* eslint-disable @typescript-eslint/no-explicit-any */
2
+ import { makeMFClientConfig } from "./configs/mf-client.js";
3
+ import { makeMFServerConfig } from "./configs/mf-server.js";
4
+ import { makePreviewClientConfig } from "./configs/preview.js";
5
+ export async function createWebpackConfig(isPreview, buildTarget, mode) {
6
+ if (isPreview) {
7
+ const previewClientConfig = await makePreviewClientConfig("development");
8
+ const mfServerConfig = await makeMFServerConfig("development");
9
+ return [previewClientConfig, mfServerConfig];
10
+ }
11
+ else if (buildTarget === "server") {
12
+ const mfServerConfig = await makeMFServerConfig(mode);
13
+ return [mfServerConfig];
14
+ }
15
+ else if (buildTarget === "client") {
16
+ const mfClientConfig = await makeMFClientConfig(mode);
17
+ return [mfClientConfig];
18
+ }
19
+ else {
20
+ const mfClientConfig = await makeMFClientConfig(mode);
21
+ const mfServerConfig = await makeMFServerConfig(mode);
22
+ return [mfClientConfig, mfServerConfig];
23
+ }
24
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pulse-editor/cli",
3
- "version": "0.1.1-beta.33",
3
+ "version": "0.1.1-beta.35",
4
4
  "license": "MIT",
5
5
  "bin": {
6
6
  "pulse": "dist/cli.js"
@@ -22,7 +22,7 @@
22
22
  "@module-federation/enhanced": "2.0.1",
23
23
  "@module-federation/node": "2.7.32",
24
24
  "@module-federation/runtime": "2.0.1",
25
- "@pulse-editor/shared-utils": "^0.1.1-beta.67",
25
+ "@pulse-editor/shared-utils": "^0.1.1-beta.76",
26
26
  "concurrently": "^9.2.1",
27
27
  "connect-livereload": "^0.6.1",
28
28
  "copy-webpack-plugin": "^13.0.1",
@@ -42,6 +42,7 @@
42
42
  "mini-css-extract-plugin": "^2.10.0",
43
43
  "openid-client": "^6.8.2",
44
44
  "rimraf": "^6.1.3",
45
+ "ts-morph": "^27.0.2",
45
46
  "tsx": "^4.21.0",
46
47
  "webpack": "^5.105.2",
47
48
  "webpack-dev-server": "^5.2.3"
@@ -54,14 +55,12 @@
54
55
  "@types/livereload": "^0.9.5",
55
56
  "@types/react": "^19.2.14",
56
57
  "@types/react-dom": "^19.2.3",
57
- "@vdemedes/prettier-config": "^2.0.1",
58
58
  "ava": "^6.4.1",
59
59
  "chalk": "^5.6.2",
60
60
  "eslint-config-xo-react": "^0.29.0",
61
61
  "eslint-plugin-react": "^7.37.5",
62
62
  "eslint-plugin-react-hooks": "^7.0.1",
63
63
  "ink-testing-library": "^4.0.0",
64
- "prettier": "^3.8.1",
65
64
  "react": "19.2.4",
66
65
  "react-dom": "19.2.4",
67
66
  "ts-node": "^10.9.2",
@@ -87,6 +86,5 @@
87
86
  "rules": {
88
87
  "react/prop-types": "off"
89
88
  }
90
- },
91
- "prettier": "@vdemedes/prettier-config"
89
+ }
92
90
  }