@magneticjs/cli 0.1.0
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/README.md +99 -0
- package/dist/cli.js +498 -0
- package/package.json +30 -0
- package/scripts/install-server.js +92 -0
- package/src/bundler.ts +113 -0
- package/src/cli.ts +180 -0
- package/src/dev.ts +188 -0
- package/src/generator.ts +211 -0
package/README.md
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
# @magnetic/cli
|
|
2
|
+
|
|
3
|
+
Build, develop, and deploy Magnetic server-driven UI apps.
|
|
4
|
+
|
|
5
|
+
## Installation
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npm install -g @magnetic/cli
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
This also downloads the `magnetic-v8-server` binary for your platform via postinstall.
|
|
12
|
+
|
|
13
|
+
## Commands
|
|
14
|
+
|
|
15
|
+
### `magnetic dev`
|
|
16
|
+
|
|
17
|
+
Start a local development server with hot rebuild on file changes.
|
|
18
|
+
|
|
19
|
+
```bash
|
|
20
|
+
cd my-app
|
|
21
|
+
magnetic dev # http://localhost:3003
|
|
22
|
+
magnetic dev --port 4000 # custom port
|
|
23
|
+
magnetic dev --dir ./my-app # specify app directory
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
What happens:
|
|
27
|
+
1. Scans `pages/` for TSX page components
|
|
28
|
+
2. Detects `server/state.ts` for business logic
|
|
29
|
+
3. Auto-generates the V8 bridge (route map + state wiring)
|
|
30
|
+
4. Bundles with esbuild into a single IIFE
|
|
31
|
+
5. Starts `magnetic-v8-server` on the specified port
|
|
32
|
+
6. Watches for changes, rebuilds + restarts on save
|
|
33
|
+
|
|
34
|
+
### `magnetic build`
|
|
35
|
+
|
|
36
|
+
Generate the production bundle without starting a server.
|
|
37
|
+
|
|
38
|
+
```bash
|
|
39
|
+
magnetic build --dir ./my-app
|
|
40
|
+
magnetic build --dir ./my-app --minify --verbose
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
Output: `dist/app.js` (~15KB typical)
|
|
44
|
+
|
|
45
|
+
### `magnetic push`
|
|
46
|
+
|
|
47
|
+
Build and deploy to a Magnetic platform server.
|
|
48
|
+
|
|
49
|
+
```bash
|
|
50
|
+
magnetic push --dir ./my-app --server https://platform.magnetic.app --name my-app
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
Or configure in `magnetic.json`:
|
|
54
|
+
```json
|
|
55
|
+
{
|
|
56
|
+
"name": "my-app",
|
|
57
|
+
"server": "https://platform.magnetic.app"
|
|
58
|
+
}
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
Then just: `magnetic push`
|
|
62
|
+
|
|
63
|
+
## How It Works
|
|
64
|
+
|
|
65
|
+
The CLI is a **build tool**, not a runtime. It:
|
|
66
|
+
- Scans your `pages/` directory and maps filenames to routes
|
|
67
|
+
- Generates a bridge file that wires pages + state + router
|
|
68
|
+
- Bundles everything into a single JS file for the V8 engine
|
|
69
|
+
- The Rust V8 server executes this bundle to render JSON DOM descriptors
|
|
70
|
+
|
|
71
|
+
**You write**: pages, components, state
|
|
72
|
+
**CLI generates**: bridge, bundle
|
|
73
|
+
**Rust server runs**: V8 + HTTP + SSE
|
|
74
|
+
|
|
75
|
+
## File Conventions
|
|
76
|
+
|
|
77
|
+
| File | Route |
|
|
78
|
+
|------|-------|
|
|
79
|
+
| `pages/IndexPage.tsx` | `/` |
|
|
80
|
+
| `pages/AboutPage.tsx` | `/about` |
|
|
81
|
+
| `pages/SettingsPage.tsx` | `/settings` |
|
|
82
|
+
| `pages/[id].tsx` | `/:id` (dynamic) |
|
|
83
|
+
| `pages/NotFoundPage.tsx` | `*` (catch-all) |
|
|
84
|
+
|
|
85
|
+
## Server Binary
|
|
86
|
+
|
|
87
|
+
The Rust binary (`magnetic-v8-server`) is automatically downloaded during
|
|
88
|
+
`npm install`. If the download fails, you can build from source:
|
|
89
|
+
|
|
90
|
+
```bash
|
|
91
|
+
cd rs/crates/magnetic-v8-server
|
|
92
|
+
cargo build --release
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
Supported platforms: macOS (ARM64, x64), Linux (x64, ARM64)
|
|
96
|
+
|
|
97
|
+
## License
|
|
98
|
+
|
|
99
|
+
MIT
|
package/dist/cli.js
ADDED
|
@@ -0,0 +1,498 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// src/cli.ts
|
|
4
|
+
import { resolve as resolve3, join as join4 } from "node:path";
|
|
5
|
+
import { existsSync as existsSync4, readFileSync as readFileSync3 } from "node:fs";
|
|
6
|
+
|
|
7
|
+
// src/generator.ts
|
|
8
|
+
import { readdirSync, readFileSync, statSync, existsSync } from "node:fs";
|
|
9
|
+
import { join, relative, extname, basename } from "node:path";
|
|
10
|
+
var PAGE_EXTENSIONS = [".tsx", ".jsx", ".ts", ".js"];
|
|
11
|
+
var CATCH_ALL_NAMES = ["notfound", "404", "notfoundpage", "_404", "error"];
|
|
12
|
+
var INDEX_NAMES = ["index", "indexpage", "home", "homepage", "tasks", "taskspage", "main", "mainpage"];
|
|
13
|
+
function scanApp(appDir, monorepoRoot) {
|
|
14
|
+
const pagesDir = join(appDir, "pages");
|
|
15
|
+
const pages = [];
|
|
16
|
+
if (existsSync(pagesDir)) {
|
|
17
|
+
scanDir(pagesDir, "", pages);
|
|
18
|
+
}
|
|
19
|
+
const stateCandidates = [
|
|
20
|
+
"state.ts",
|
|
21
|
+
"state.tsx",
|
|
22
|
+
"server/state.ts",
|
|
23
|
+
"server/state.tsx",
|
|
24
|
+
"store.ts",
|
|
25
|
+
"store.tsx"
|
|
26
|
+
];
|
|
27
|
+
let statePath = null;
|
|
28
|
+
let hasViewModel = false;
|
|
29
|
+
for (const candidate of stateCandidates) {
|
|
30
|
+
const full = join(appDir, candidate);
|
|
31
|
+
if (existsSync(full)) {
|
|
32
|
+
statePath = "./" + candidate;
|
|
33
|
+
const content = readFileSync(full, "utf-8");
|
|
34
|
+
hasViewModel = /export\s+(function|const)\s+toViewModel/.test(content);
|
|
35
|
+
break;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
let serverPkgPath;
|
|
39
|
+
if (monorepoRoot) {
|
|
40
|
+
const relPath = relative(appDir, join(monorepoRoot, "js/packages/magnetic-server/src"));
|
|
41
|
+
serverPkgPath = relPath.startsWith(".") ? relPath : "./" + relPath;
|
|
42
|
+
} else {
|
|
43
|
+
serverPkgPath = "@magneticjs/server";
|
|
44
|
+
}
|
|
45
|
+
return { pages, statePath, hasViewModel, serverPkgPath };
|
|
46
|
+
}
|
|
47
|
+
function scanDir(dir, pathPrefix, pages, rootPagesDir) {
|
|
48
|
+
const pagesRoot = rootPagesDir || dir;
|
|
49
|
+
const entries = readdirSync(dir).sort();
|
|
50
|
+
for (const entry of entries) {
|
|
51
|
+
const fullPath = join(dir, entry);
|
|
52
|
+
const stat = statSync(fullPath);
|
|
53
|
+
if (stat.isDirectory()) {
|
|
54
|
+
let segment = entry;
|
|
55
|
+
if (entry.startsWith("[") && entry.endsWith("]")) {
|
|
56
|
+
segment = ":" + entry.slice(1, -1);
|
|
57
|
+
}
|
|
58
|
+
scanDir(fullPath, pathPrefix + "/" + segment, pages, pagesRoot);
|
|
59
|
+
continue;
|
|
60
|
+
}
|
|
61
|
+
const ext = extname(entry);
|
|
62
|
+
if (!PAGE_EXTENSIONS.includes(ext)) continue;
|
|
63
|
+
const nameNoExt = basename(entry, ext);
|
|
64
|
+
if (nameNoExt.startsWith("_") && !CATCH_ALL_NAMES.includes(nameNoExt.toLowerCase())) continue;
|
|
65
|
+
if (nameNoExt === "layout") continue;
|
|
66
|
+
const importName = nameNoExt;
|
|
67
|
+
const nameLower = nameNoExt.toLowerCase().replace(/page$/, "");
|
|
68
|
+
let routePath;
|
|
69
|
+
let isCatchAll = false;
|
|
70
|
+
if (CATCH_ALL_NAMES.includes(nameLower)) {
|
|
71
|
+
routePath = "*";
|
|
72
|
+
isCatchAll = true;
|
|
73
|
+
} else if (INDEX_NAMES.includes(nameLower)) {
|
|
74
|
+
routePath = pathPrefix || "/";
|
|
75
|
+
} else if (nameNoExt.startsWith("[") && nameNoExt.endsWith("]")) {
|
|
76
|
+
const param = nameNoExt.slice(1, -1);
|
|
77
|
+
routePath = pathPrefix + "/:" + param;
|
|
78
|
+
} else {
|
|
79
|
+
routePath = pathPrefix + "/" + nameLower;
|
|
80
|
+
}
|
|
81
|
+
const relFromPagesRoot = relative(pagesRoot, fullPath).replace(/\\/g, "/");
|
|
82
|
+
const filePath = "pages/" + relFromPagesRoot;
|
|
83
|
+
pages.push({ filePath, importName, routePath, isCatchAll });
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
function generateBridge(scan) {
|
|
87
|
+
const lines = [];
|
|
88
|
+
lines.push("// AUTO-GENERATED by @magnetic/cli \u2014 do not edit");
|
|
89
|
+
lines.push(`import { createRouter } from '${scan.serverPkgPath}/router.ts';`);
|
|
90
|
+
const catchAllPage = scan.pages.find((p) => p.isCatchAll);
|
|
91
|
+
for (const page of scan.pages) {
|
|
92
|
+
lines.push(`import { ${page.importName} } from './${page.filePath}';`);
|
|
93
|
+
}
|
|
94
|
+
if (scan.statePath) {
|
|
95
|
+
lines.push(`import { initialState, reduce as _reduce${scan.hasViewModel ? ", toViewModel" : ""} } from '${scan.statePath}';`);
|
|
96
|
+
} else {
|
|
97
|
+
lines.push("");
|
|
98
|
+
lines.push("// No state.ts found \u2014 using minimal default state");
|
|
99
|
+
lines.push("function initialState() { return {}; }");
|
|
100
|
+
lines.push("function _reduce(state, action, payload) { return state; }");
|
|
101
|
+
}
|
|
102
|
+
if (!scan.hasViewModel && scan.statePath) {
|
|
103
|
+
lines.push("function toViewModel(s) { return s; }");
|
|
104
|
+
} else if (!scan.statePath) {
|
|
105
|
+
lines.push("function toViewModel(s) { return s; }");
|
|
106
|
+
}
|
|
107
|
+
lines.push("");
|
|
108
|
+
lines.push("const router = createRouter([");
|
|
109
|
+
for (const page of scan.pages) {
|
|
110
|
+
if (!page.isCatchAll) {
|
|
111
|
+
lines.push(` { path: '${page.routePath}', page: ${page.importName} },`);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
if (catchAllPage) {
|
|
115
|
+
lines.push(` { path: '*', page: ${catchAllPage.importName} },`);
|
|
116
|
+
}
|
|
117
|
+
lines.push("]);");
|
|
118
|
+
lines.push("");
|
|
119
|
+
lines.push("let state = initialState();");
|
|
120
|
+
lines.push("");
|
|
121
|
+
lines.push("export function render(path) {");
|
|
122
|
+
lines.push(" const vm = toViewModel(state);");
|
|
123
|
+
lines.push(" const result = router.resolve(path, vm);");
|
|
124
|
+
if (catchAllPage) {
|
|
125
|
+
lines.push(` if (!result) return ${catchAllPage.importName}({ params: {} });`);
|
|
126
|
+
} else {
|
|
127
|
+
lines.push(' if (!result) return { tag: "div", text: "Not Found" };');
|
|
128
|
+
}
|
|
129
|
+
lines.push(" if (result.kind === 'redirect') {");
|
|
130
|
+
lines.push(" const r2 = router.resolve(result.to, vm);");
|
|
131
|
+
lines.push(" if (r2 && r2.kind === 'render') return r2.dom;");
|
|
132
|
+
if (catchAllPage) {
|
|
133
|
+
lines.push(` return ${catchAllPage.importName}({ params: {} });`);
|
|
134
|
+
} else {
|
|
135
|
+
lines.push(' return { tag: "div", text: "Not Found" };');
|
|
136
|
+
}
|
|
137
|
+
lines.push(" }");
|
|
138
|
+
lines.push(" return result.dom;");
|
|
139
|
+
lines.push("}");
|
|
140
|
+
lines.push("");
|
|
141
|
+
lines.push("export function reduce(ap) {");
|
|
142
|
+
lines.push(" const { action, payload = {}, path = '/' } = ap;");
|
|
143
|
+
lines.push(" state = _reduce(state, action, payload);");
|
|
144
|
+
lines.push(" return render(path);");
|
|
145
|
+
lines.push("}");
|
|
146
|
+
return lines.join("\n") + "\n";
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// src/bundler.ts
|
|
150
|
+
import { build } from "esbuild";
|
|
151
|
+
import { join as join2 } from "node:path";
|
|
152
|
+
import { mkdirSync, existsSync as existsSync2, statSync as statSync2, readdirSync as readdirSync2, readFileSync as readFileSync2 } from "node:fs";
|
|
153
|
+
async function bundleApp(opts) {
|
|
154
|
+
const outDir = opts.outDir || join2(opts.appDir, "dist");
|
|
155
|
+
const outFile = opts.outFile || "app.js";
|
|
156
|
+
const outPath = join2(outDir, outFile);
|
|
157
|
+
if (!existsSync2(outDir)) {
|
|
158
|
+
mkdirSync(outDir, { recursive: true });
|
|
159
|
+
}
|
|
160
|
+
const alias = {};
|
|
161
|
+
if (opts.monorepoRoot) {
|
|
162
|
+
const serverPkg = join2(opts.monorepoRoot, "js/packages/magnetic-server/src");
|
|
163
|
+
alias["@magneticjs/server"] = serverPkg;
|
|
164
|
+
alias["@magneticjs/server/jsx-runtime"] = join2(serverPkg, "jsx-runtime.ts");
|
|
165
|
+
}
|
|
166
|
+
const result = await build({
|
|
167
|
+
stdin: {
|
|
168
|
+
contents: opts.bridgeCode,
|
|
169
|
+
resolveDir: opts.appDir,
|
|
170
|
+
loader: "tsx"
|
|
171
|
+
},
|
|
172
|
+
bundle: true,
|
|
173
|
+
format: "iife",
|
|
174
|
+
globalName: "MagneticApp",
|
|
175
|
+
outfile: outPath,
|
|
176
|
+
minify: opts.minify || false,
|
|
177
|
+
sourcemap: false,
|
|
178
|
+
target: "es2020",
|
|
179
|
+
jsx: "automatic",
|
|
180
|
+
jsxImportSource: "@magneticjs/server",
|
|
181
|
+
alias,
|
|
182
|
+
logLevel: "warning"
|
|
183
|
+
});
|
|
184
|
+
const stat = statSync2(outPath);
|
|
185
|
+
return {
|
|
186
|
+
outPath,
|
|
187
|
+
sizeBytes: stat.size
|
|
188
|
+
};
|
|
189
|
+
}
|
|
190
|
+
async function buildForDeploy(opts) {
|
|
191
|
+
const bundle = await bundleApp({ ...opts, minify: true });
|
|
192
|
+
const publicDir = join2(opts.appDir, "public");
|
|
193
|
+
const assets = {};
|
|
194
|
+
if (existsSync2(publicDir)) {
|
|
195
|
+
const entries = readdirSync2(publicDir);
|
|
196
|
+
for (const entry of entries) {
|
|
197
|
+
const fullPath = join2(publicDir, entry);
|
|
198
|
+
if (statSync2(fullPath).isFile()) {
|
|
199
|
+
const ext = entry.split(".").pop() || "";
|
|
200
|
+
const textExts = ["css", "js", "json", "html", "svg", "txt", "xml"];
|
|
201
|
+
if (textExts.includes(ext)) {
|
|
202
|
+
assets[entry] = readFileSync2(fullPath, "utf-8");
|
|
203
|
+
} else {
|
|
204
|
+
assets[entry] = readFileSync2(fullPath).toString("base64");
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
return {
|
|
210
|
+
bundlePath: bundle.outPath,
|
|
211
|
+
bundleSize: bundle.sizeBytes,
|
|
212
|
+
assets
|
|
213
|
+
};
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// src/dev.ts
|
|
217
|
+
import { watch } from "node:fs";
|
|
218
|
+
import { join as join3 } from "node:path";
|
|
219
|
+
import { existsSync as existsSync3 } from "node:fs";
|
|
220
|
+
import { spawn } from "node:child_process";
|
|
221
|
+
async function startDev(opts) {
|
|
222
|
+
const {
|
|
223
|
+
appDir,
|
|
224
|
+
port = 3003,
|
|
225
|
+
monorepoRoot
|
|
226
|
+
} = opts;
|
|
227
|
+
const staticDir = join3(appDir, "public");
|
|
228
|
+
const outDir = join3(appDir, "dist");
|
|
229
|
+
const serverBin = opts.serverBin || findServerBinary(monorepoRoot || appDir);
|
|
230
|
+
if (!serverBin) {
|
|
231
|
+
console.error("[magnetic] Cannot find magnetic-v8-server binary.");
|
|
232
|
+
console.error(" Build it with: cargo build --release -p magnetic-v8-server");
|
|
233
|
+
process.exit(1);
|
|
234
|
+
}
|
|
235
|
+
let serverProcess = null;
|
|
236
|
+
async function rebuild() {
|
|
237
|
+
const start = Date.now();
|
|
238
|
+
try {
|
|
239
|
+
const scan = scanApp(appDir, monorepoRoot);
|
|
240
|
+
console.log(`[magnetic] Scanned ${scan.pages.length} pages, state: ${scan.statePath || "none"}`);
|
|
241
|
+
for (const page of scan.pages) {
|
|
242
|
+
console.log(` ${page.routePath} \u2192 ${page.filePath} (${page.importName})`);
|
|
243
|
+
}
|
|
244
|
+
const bridgeCode = generateBridge(scan);
|
|
245
|
+
const result = await bundleApp({
|
|
246
|
+
appDir,
|
|
247
|
+
bridgeCode,
|
|
248
|
+
outDir,
|
|
249
|
+
monorepoRoot
|
|
250
|
+
});
|
|
251
|
+
const ms = Date.now() - start;
|
|
252
|
+
const kb = (result.sizeBytes / 1024).toFixed(1);
|
|
253
|
+
console.log(`[magnetic] Built ${result.outPath} (${kb}KB) in ${ms}ms`);
|
|
254
|
+
return result.outPath;
|
|
255
|
+
} catch (err) {
|
|
256
|
+
console.error(`[magnetic] Build failed: ${err.message}`);
|
|
257
|
+
return null;
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
function startServer(bundlePath2) {
|
|
261
|
+
const args2 = [
|
|
262
|
+
"--bundle",
|
|
263
|
+
bundlePath2,
|
|
264
|
+
"--port",
|
|
265
|
+
String(port),
|
|
266
|
+
"--static",
|
|
267
|
+
staticDir
|
|
268
|
+
];
|
|
269
|
+
console.log(`[magnetic] Starting V8 server on :${port}`);
|
|
270
|
+
const proc = spawn(serverBin, args2, {
|
|
271
|
+
stdio: ["ignore", "inherit", "inherit"]
|
|
272
|
+
});
|
|
273
|
+
proc.on("exit", (code) => {
|
|
274
|
+
if (code !== null && code !== 0) {
|
|
275
|
+
console.error(`[magnetic] Server exited with code ${code}`);
|
|
276
|
+
}
|
|
277
|
+
});
|
|
278
|
+
return proc;
|
|
279
|
+
}
|
|
280
|
+
function stopServer() {
|
|
281
|
+
if (serverProcess) {
|
|
282
|
+
serverProcess.kill("SIGTERM");
|
|
283
|
+
serverProcess = null;
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
const bundlePath = await rebuild();
|
|
287
|
+
if (!bundlePath) {
|
|
288
|
+
console.error("[magnetic] Initial build failed. Fix errors and save to retry.");
|
|
289
|
+
} else {
|
|
290
|
+
serverProcess = startServer(bundlePath);
|
|
291
|
+
}
|
|
292
|
+
const watchDirs = [join3(appDir, "pages"), join3(appDir, "components")];
|
|
293
|
+
const watchFiles = ["state.ts", "state.tsx", "server/state.ts", "server/state.tsx"].map((f) => join3(appDir, f));
|
|
294
|
+
let rebuildTimer = null;
|
|
295
|
+
function scheduleRebuild() {
|
|
296
|
+
if (rebuildTimer) clearTimeout(rebuildTimer);
|
|
297
|
+
rebuildTimer = setTimeout(async () => {
|
|
298
|
+
console.log("\n[magnetic] Change detected, rebuilding...");
|
|
299
|
+
stopServer();
|
|
300
|
+
const path = await rebuild();
|
|
301
|
+
if (path) {
|
|
302
|
+
serverProcess = startServer(path);
|
|
303
|
+
}
|
|
304
|
+
}, 200);
|
|
305
|
+
}
|
|
306
|
+
for (const dir of watchDirs) {
|
|
307
|
+
if (existsSync3(dir)) {
|
|
308
|
+
watch(dir, { recursive: true }, (event, filename) => {
|
|
309
|
+
if (filename && /\.(tsx?|jsx?|css)$/.test(filename)) {
|
|
310
|
+
scheduleRebuild();
|
|
311
|
+
}
|
|
312
|
+
});
|
|
313
|
+
console.log(`[magnetic] Watching ${dir}`);
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
for (const file of watchFiles) {
|
|
317
|
+
if (existsSync3(file)) {
|
|
318
|
+
watch(file, () => scheduleRebuild());
|
|
319
|
+
console.log(`[magnetic] Watching ${file}`);
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
process.on("SIGINT", () => {
|
|
323
|
+
console.log("\n[magnetic] Shutting down...");
|
|
324
|
+
stopServer();
|
|
325
|
+
process.exit(0);
|
|
326
|
+
});
|
|
327
|
+
process.on("SIGTERM", () => {
|
|
328
|
+
stopServer();
|
|
329
|
+
process.exit(0);
|
|
330
|
+
});
|
|
331
|
+
console.log(`[magnetic] Dev mode ready. Edit pages/ and save to rebuild.`);
|
|
332
|
+
console.log(`[magnetic] http://localhost:${port}
|
|
333
|
+
`);
|
|
334
|
+
}
|
|
335
|
+
function findServerBinary(searchRoot) {
|
|
336
|
+
const cliPkgBin = join3(import.meta.dirname || __dirname, "..", "bin", "magnetic-v8-server");
|
|
337
|
+
const candidates = [
|
|
338
|
+
// npm-installed binary (from postinstall)
|
|
339
|
+
cliPkgBin,
|
|
340
|
+
// Monorepo development paths
|
|
341
|
+
join3(searchRoot, "rs/crates/magnetic-v8-server/target/debug/magnetic-v8-server"),
|
|
342
|
+
join3(searchRoot, "rs/crates/magnetic-v8-server/target/release/magnetic-v8-server"),
|
|
343
|
+
join3(searchRoot, "target/debug/magnetic-v8-server"),
|
|
344
|
+
join3(searchRoot, "target/release/magnetic-v8-server")
|
|
345
|
+
];
|
|
346
|
+
for (const path of candidates) {
|
|
347
|
+
if (existsSync3(path)) return path;
|
|
348
|
+
}
|
|
349
|
+
return null;
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
// src/cli.ts
|
|
353
|
+
var args = process.argv.slice(2);
|
|
354
|
+
var command = args[0];
|
|
355
|
+
function log(level, msg) {
|
|
356
|
+
const ts = (/* @__PURE__ */ new Date()).toISOString().slice(11, 23);
|
|
357
|
+
const prefix = level === "error" ? "\u2717" : level === "warn" ? "\u26A0" : level === "debug" ? "\xB7" : "\u2192";
|
|
358
|
+
const stream = level === "error" ? process.stderr : process.stdout;
|
|
359
|
+
stream.write(`[${ts}] ${prefix} ${msg}
|
|
360
|
+
`);
|
|
361
|
+
}
|
|
362
|
+
function findMonorepoRoot(from) {
|
|
363
|
+
let dir = from;
|
|
364
|
+
for (let i = 0; i < 10; i++) {
|
|
365
|
+
if (existsSync4(join4(dir, "js/packages/magnetic-server"))) return dir;
|
|
366
|
+
const parent = resolve3(dir, "..");
|
|
367
|
+
if (parent === dir) break;
|
|
368
|
+
dir = parent;
|
|
369
|
+
}
|
|
370
|
+
return null;
|
|
371
|
+
}
|
|
372
|
+
function getArg(flag) {
|
|
373
|
+
const idx = args.indexOf(flag);
|
|
374
|
+
return idx >= 0 ? args[idx + 1] : void 0;
|
|
375
|
+
}
|
|
376
|
+
function usage() {
|
|
377
|
+
console.log(`
|
|
378
|
+
@magnetic/cli \u2014 Build and deploy server-driven UI apps
|
|
379
|
+
|
|
380
|
+
Usage:
|
|
381
|
+
magnetic dev Start dev mode (watch + rebuild + serve)
|
|
382
|
+
magnetic build Build the app bundle for deployment
|
|
383
|
+
magnetic push Build and deploy to a Magnetic platform server
|
|
384
|
+
|
|
385
|
+
Options:
|
|
386
|
+
--port <n> Dev server port (default: 3003)
|
|
387
|
+
--dir <path> App directory (default: current directory)
|
|
388
|
+
--server <url> Platform server URL for push
|
|
389
|
+
--name <name> App name for push (default: from magnetic.json)
|
|
390
|
+
--minify Minify the output bundle
|
|
391
|
+
|
|
392
|
+
Developer workflow:
|
|
393
|
+
1. Write pages in pages/*.tsx
|
|
394
|
+
2. Write business logic in state.ts (optional)
|
|
395
|
+
3. Run \`magnetic dev\` to develop locally
|
|
396
|
+
4. Run \`magnetic push\` to deploy
|
|
397
|
+
`);
|
|
398
|
+
}
|
|
399
|
+
async function main() {
|
|
400
|
+
if (!command || command === "--help" || command === "-h") {
|
|
401
|
+
usage();
|
|
402
|
+
process.exit(0);
|
|
403
|
+
}
|
|
404
|
+
const appDir = resolve3(getArg("--dir") || ".");
|
|
405
|
+
const monorepoRoot = findMonorepoRoot(appDir);
|
|
406
|
+
const port = parseInt(getArg("--port") || "3003", 10);
|
|
407
|
+
let config = {};
|
|
408
|
+
const configPath = join4(appDir, "magnetic.json");
|
|
409
|
+
if (existsSync4(configPath)) {
|
|
410
|
+
config = JSON.parse(readFileSync3(configPath, "utf-8"));
|
|
411
|
+
}
|
|
412
|
+
switch (command) {
|
|
413
|
+
case "dev": {
|
|
414
|
+
await startDev({
|
|
415
|
+
appDir,
|
|
416
|
+
port,
|
|
417
|
+
monorepoRoot: monorepoRoot || void 0
|
|
418
|
+
});
|
|
419
|
+
break;
|
|
420
|
+
}
|
|
421
|
+
case "build": {
|
|
422
|
+
log("info", `Building ${appDir}`);
|
|
423
|
+
const buildStart = Date.now();
|
|
424
|
+
const scan = scanApp(appDir, monorepoRoot || void 0);
|
|
425
|
+
log("info", `Scanned: ${scan.pages.length} pages, state: ${scan.statePath || "none (using defaults)"}`);
|
|
426
|
+
for (const page of scan.pages) {
|
|
427
|
+
log("debug", ` route ${page.routePath.padEnd(15)} \u2190 ${page.filePath}`);
|
|
428
|
+
}
|
|
429
|
+
const bridgeCode = generateBridge(scan);
|
|
430
|
+
log("debug", `Bridge generated: ${bridgeCode.split("\n").length} lines`);
|
|
431
|
+
if (args.includes("--verbose")) {
|
|
432
|
+
console.log("\n--- Generated bridge ---");
|
|
433
|
+
console.log(bridgeCode);
|
|
434
|
+
console.log("--- End bridge ---\n");
|
|
435
|
+
}
|
|
436
|
+
const result = await bundleApp({
|
|
437
|
+
appDir,
|
|
438
|
+
bridgeCode,
|
|
439
|
+
minify: args.includes("--minify"),
|
|
440
|
+
monorepoRoot: monorepoRoot || void 0
|
|
441
|
+
});
|
|
442
|
+
const kb = (result.sizeBytes / 1024).toFixed(1);
|
|
443
|
+
const elapsed = Date.now() - buildStart;
|
|
444
|
+
log("info", `\u2713 Built ${result.outPath} (${kb}KB) in ${elapsed}ms`);
|
|
445
|
+
break;
|
|
446
|
+
}
|
|
447
|
+
case "push": {
|
|
448
|
+
const serverUrl = getArg("--server") || config.server;
|
|
449
|
+
const appName = getArg("--name") || config.name;
|
|
450
|
+
if (!serverUrl) {
|
|
451
|
+
console.error('[magnetic] No server URL. Use --server <url> or set "server" in magnetic.json');
|
|
452
|
+
process.exit(1);
|
|
453
|
+
}
|
|
454
|
+
if (!appName) {
|
|
455
|
+
console.error('[magnetic] No app name. Use --name <name> or set "name" in magnetic.json');
|
|
456
|
+
process.exit(1);
|
|
457
|
+
}
|
|
458
|
+
log("info", `Building for deploy...`);
|
|
459
|
+
const scan = scanApp(appDir, monorepoRoot || void 0);
|
|
460
|
+
log("info", `Scanned: ${scan.pages.length} pages, state: ${scan.statePath || "none"}`);
|
|
461
|
+
const bridgeCode = generateBridge(scan);
|
|
462
|
+
const deploy = await buildForDeploy({ appDir, bridgeCode, monorepoRoot: monorepoRoot || void 0 });
|
|
463
|
+
log("info", `Bundle: ${(deploy.bundleSize / 1024).toFixed(1)}KB (minified)`);
|
|
464
|
+
log("info", `Assets: ${Object.keys(deploy.assets).length} files`);
|
|
465
|
+
for (const [name, content] of Object.entries(deploy.assets)) {
|
|
466
|
+
log("debug", ` asset: ${name} (${(content.length / 1024).toFixed(1)}KB)`);
|
|
467
|
+
}
|
|
468
|
+
log("info", `Pushing to ${serverUrl}/api/apps/${appName}/deploy...`);
|
|
469
|
+
const bundleContent = readFileSync3(deploy.bundlePath, "utf-8");
|
|
470
|
+
const resp = await fetch(`${serverUrl}/api/apps/${appName}/deploy`, {
|
|
471
|
+
method: "POST",
|
|
472
|
+
headers: { "Content-Type": "application/json" },
|
|
473
|
+
body: JSON.stringify({
|
|
474
|
+
bundle: bundleContent,
|
|
475
|
+
assets: deploy.assets
|
|
476
|
+
})
|
|
477
|
+
});
|
|
478
|
+
if (resp.ok) {
|
|
479
|
+
const data = await resp.json();
|
|
480
|
+
log("info", `\u2713 Deployed! ${data.url || serverUrl + "/apps/" + appName + "/"}`);
|
|
481
|
+
log("info", ` Live at: ${serverUrl}/apps/${appName}/`);
|
|
482
|
+
} else {
|
|
483
|
+
const text = await resp.text();
|
|
484
|
+
log("error", `Deploy failed (${resp.status}): ${text}`);
|
|
485
|
+
process.exit(1);
|
|
486
|
+
}
|
|
487
|
+
break;
|
|
488
|
+
}
|
|
489
|
+
default:
|
|
490
|
+
console.error(`[magnetic] Unknown command: ${command}`);
|
|
491
|
+
usage();
|
|
492
|
+
process.exit(1);
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
main().catch((err) => {
|
|
496
|
+
console.error(`[magnetic] Fatal: ${err.message}`);
|
|
497
|
+
process.exit(1);
|
|
498
|
+
});
|
package/package.json
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@magneticjs/cli",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Magnetic CLI — build, dev, and deploy server-driven UI apps",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"magnetic": "./dist/cli.js"
|
|
8
|
+
},
|
|
9
|
+
"scripts": {
|
|
10
|
+
"build": "esbuild src/cli.ts --bundle --platform=node --format=esm --outfile=dist/cli.js --external:esbuild",
|
|
11
|
+
"dev": "tsx src/cli.ts",
|
|
12
|
+
"postinstall": "node scripts/install-server.js || true"
|
|
13
|
+
},
|
|
14
|
+
"files": ["dist", "src", "scripts"],
|
|
15
|
+
"publishConfig": {
|
|
16
|
+
"access": "public"
|
|
17
|
+
},
|
|
18
|
+
"dependencies": {
|
|
19
|
+
"@magneticjs/server": "^0.1.0",
|
|
20
|
+
"esbuild": "^0.27.3"
|
|
21
|
+
},
|
|
22
|
+
"devDependencies": {
|
|
23
|
+
"@types/node": "^25.2.3",
|
|
24
|
+
"tsx": "^4.21.0"
|
|
25
|
+
},
|
|
26
|
+
"repository": { "type": "git", "url": "https://github.com/inventhq/magnetic.git", "directory": "js/packages/magnetic-cli" },
|
|
27
|
+
"homepage": "https://github.com/inventhq/magnetic#readme",
|
|
28
|
+
"keywords": ["magnetic", "cli", "server-driven-ui", "build-tool"],
|
|
29
|
+
"license": "MIT"
|
|
30
|
+
}
|