@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.
@@ -0,0 +1,92 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * install-server.js — Downloads the prebuilt magnetic-v8-server binary
4
+ * for the current platform during `npm install @magnetic/cli`.
5
+ *
6
+ * Update strategy:
7
+ * - Version is read from ../package.json (matches @magnetic/cli version)
8
+ * - When user runs `npm update @magnetic/cli`, this script re-runs
9
+ * - Binary version is tracked in bin/.version
10
+ * - If version mismatch → re-download; if match → skip
11
+ *
12
+ * Binary distribution:
13
+ * GitHub Releases: magnetic-v8-server-{target}.tar.gz
14
+ * Targets: x86_64-apple-darwin, aarch64-apple-darwin, x86_64-unknown-linux-gnu
15
+ *
16
+ * Falls back gracefully if download fails — user can build from source.
17
+ */
18
+ import { existsSync, mkdirSync, readFileSync, writeFileSync, chmodSync } from 'fs';
19
+ import { join, dirname } from 'path';
20
+ import { execSync } from 'child_process';
21
+ import { fileURLToPath } from 'url';
22
+
23
+ const __dirname = dirname(fileURLToPath(import.meta.url));
24
+ const pkgDir = join(__dirname, '..');
25
+ const binDir = join(pkgDir, 'bin');
26
+ const binPath = join(binDir, 'magnetic-v8-server');
27
+ const versionFile = join(binDir, '.version');
28
+
29
+ // Read version from package.json (stays in sync with npm version)
30
+ const pkg = JSON.parse(readFileSync(join(pkgDir, 'package.json'), 'utf-8'));
31
+ const version = pkg.version;
32
+
33
+ // Check if installed binary matches current CLI version
34
+ if (existsSync(binPath) && existsSync(versionFile)) {
35
+ const installed = readFileSync(versionFile, 'utf-8').trim();
36
+ if (installed === version) {
37
+ console.log(`[magnetic] Server binary v${version} already installed, skipping`);
38
+ process.exit(0);
39
+ }
40
+ console.log(`[magnetic] Server binary outdated (${installed} → ${version}), updating...`);
41
+ }
42
+
43
+ // Determine platform target
44
+ function getTarget() {
45
+ const platform = process.platform;
46
+ const arch = process.arch;
47
+
48
+ if (platform === 'darwin' && arch === 'arm64') return 'aarch64-apple-darwin';
49
+ if (platform === 'darwin' && arch === 'x64') return 'x86_64-apple-darwin';
50
+ if (platform === 'linux' && arch === 'x64') return 'x86_64-unknown-linux-gnu';
51
+ if (platform === 'linux' && arch === 'arm64') return 'aarch64-unknown-linux-gnu';
52
+ return null;
53
+ }
54
+
55
+ const target = getTarget();
56
+ if (!target) {
57
+ console.warn(`[magnetic] No prebuilt binary for ${process.platform}-${process.arch}`);
58
+ console.warn('[magnetic] Build from source: cargo build --release -p magnetic-v8-server');
59
+ process.exit(0);
60
+ }
61
+
62
+ const baseUrl = process.env.MAGNETIC_BINARY_URL ||
63
+ `https://github.com/inventhq/magnetic/releases/download/v${version}`;
64
+ const filename = `magnetic-v8-server-${target}.tar.gz`;
65
+ const url = `${baseUrl}/${filename}`;
66
+
67
+ console.log(`[magnetic] Downloading server binary for ${target}...`);
68
+ console.log(`[magnetic] ${url}`);
69
+
70
+ try {
71
+ mkdirSync(binDir, { recursive: true });
72
+ // Use curl or wget — available on all platforms
73
+ try {
74
+ execSync(`curl -fsSL "${url}" | tar xz -C "${binDir}"`, { stdio: 'pipe' });
75
+ } catch {
76
+ execSync(`wget -qO- "${url}" | tar xz -C "${binDir}"`, { stdio: 'pipe' });
77
+ }
78
+
79
+ if (existsSync(binPath)) {
80
+ chmodSync(binPath, 0o755);
81
+ writeFileSync(versionFile, version);
82
+ console.log(`[magnetic] ✓ Server binary v${version} installed: ${binPath}`);
83
+ } else {
84
+ throw new Error('Binary not found after extraction');
85
+ }
86
+ } catch (err) {
87
+ console.warn(`[magnetic] Could not download prebuilt binary: ${err.message}`);
88
+ console.warn('[magnetic] The CLI will still work, but you need the server binary for `magnetic dev`.');
89
+ console.warn('[magnetic] Build from source: cargo build --release -p magnetic-v8-server');
90
+ // Don't fail the install — just warn
91
+ process.exit(0);
92
+ }
package/src/bundler.ts ADDED
@@ -0,0 +1,113 @@
1
+ // bundler.ts — esbuild wrapper for Magnetic apps
2
+ // Takes generated bridge code and bundles it into an IIFE for V8
3
+
4
+ import { build } from 'esbuild';
5
+ import { join, resolve } from 'node:path';
6
+ import { mkdirSync, existsSync, writeFileSync, statSync, readdirSync, readFileSync } from 'node:fs';
7
+
8
+ export interface BundleOptions {
9
+ /** Absolute path to the app directory */
10
+ appDir: string;
11
+ /** Generated bridge source code (from generator) */
12
+ bridgeCode: string;
13
+ /** Output directory (default: dist/) */
14
+ outDir?: string;
15
+ /** Output filename (default: app.js) */
16
+ outFile?: string;
17
+ /** Minify output */
18
+ minify?: boolean;
19
+ /** Monorepo root (for resolving @magneticjs/server) */
20
+ monorepoRoot?: string;
21
+ }
22
+
23
+ export interface BundleResult {
24
+ outPath: string;
25
+ sizeBytes: number;
26
+ }
27
+
28
+ /**
29
+ * Bundle the generated bridge code into an IIFE for V8 consumption.
30
+ * Uses esbuild with stdin so no temp file is needed.
31
+ */
32
+ export async function bundleApp(opts: BundleOptions): Promise<BundleResult> {
33
+ const outDir = opts.outDir || join(opts.appDir, 'dist');
34
+ const outFile = opts.outFile || 'app.js';
35
+ const outPath = join(outDir, outFile);
36
+
37
+ if (!existsSync(outDir)) {
38
+ mkdirSync(outDir, { recursive: true });
39
+ }
40
+
41
+ // Resolve @magneticjs/server — in monorepo use actual path, otherwise npm package
42
+ const alias: Record<string, string> = {};
43
+ if (opts.monorepoRoot) {
44
+ const serverPkg = join(opts.monorepoRoot, 'js/packages/magnetic-server/src');
45
+ alias['@magneticjs/server'] = serverPkg;
46
+ alias['@magneticjs/server/jsx-runtime'] = join(serverPkg, 'jsx-runtime.ts');
47
+ }
48
+
49
+ const result = await build({
50
+ stdin: {
51
+ contents: opts.bridgeCode,
52
+ resolveDir: opts.appDir,
53
+ loader: 'tsx',
54
+ },
55
+ bundle: true,
56
+ format: 'iife',
57
+ globalName: 'MagneticApp',
58
+ outfile: outPath,
59
+ minify: opts.minify || false,
60
+ sourcemap: false,
61
+ target: 'es2020',
62
+ jsx: 'automatic',
63
+ jsxImportSource: '@magneticjs/server',
64
+ alias,
65
+ logLevel: 'warning',
66
+ });
67
+
68
+ const stat = statSync(outPath);
69
+
70
+ return {
71
+ outPath,
72
+ sizeBytes: stat.size,
73
+ };
74
+ }
75
+
76
+ /**
77
+ * Bundle and also prepare the assets manifest for deployment.
78
+ * Returns the bundle path + a map of public/ files.
79
+ */
80
+ export async function buildForDeploy(opts: BundleOptions): Promise<{
81
+ bundlePath: string;
82
+ bundleSize: number;
83
+ assets: Record<string, string>;
84
+ }> {
85
+ const bundle = await bundleApp({ ...opts, minify: true });
86
+
87
+ // Collect public/ files as a map for upload
88
+ const publicDir = join(opts.appDir, 'public');
89
+ const assets: Record<string, string> = {};
90
+
91
+ if (existsSync(publicDir)) {
92
+ const entries = readdirSync(publicDir);
93
+ for (const entry of entries) {
94
+ const fullPath = join(publicDir, entry);
95
+ if (statSync(fullPath).isFile()) {
96
+ // For text files, include as string; for binary, base64 encode
97
+ const ext = entry.split('.').pop() || '';
98
+ const textExts = ['css', 'js', 'json', 'html', 'svg', 'txt', 'xml'];
99
+ if (textExts.includes(ext)) {
100
+ assets[entry] = readFileSync(fullPath, 'utf-8');
101
+ } else {
102
+ assets[entry] = readFileSync(fullPath).toString('base64');
103
+ }
104
+ }
105
+ }
106
+ }
107
+
108
+ return {
109
+ bundlePath: bundle.outPath,
110
+ bundleSize: bundle.sizeBytes,
111
+ assets,
112
+ };
113
+ }
package/src/cli.ts ADDED
@@ -0,0 +1,180 @@
1
+ #!/usr/bin/env node
2
+ // cli.ts — Magnetic CLI entry point
3
+ // Commands: dev, build, push
4
+
5
+ import { resolve, join } from 'node:path';
6
+ import { existsSync, readFileSync } from 'node:fs';
7
+ import { scanApp, generateBridge } from './generator.ts';
8
+ import { bundleApp, buildForDeploy } from './bundler.ts';
9
+ import { startDev } from './dev.ts';
10
+
11
+ const args = process.argv.slice(2);
12
+ const command = args[0];
13
+
14
+ // Structured logging
15
+ function log(level: 'info' | 'warn' | 'error' | 'debug', msg: string) {
16
+ const ts = new Date().toISOString().slice(11, 23);
17
+ const prefix = level === 'error' ? '✗' : level === 'warn' ? '⚠' : level === 'debug' ? '·' : '→';
18
+ const stream = level === 'error' ? process.stderr : process.stdout;
19
+ stream.write(`[${ts}] ${prefix} ${msg}\n`);
20
+ }
21
+
22
+ function findMonorepoRoot(from: string): string | null {
23
+ let dir = from;
24
+ for (let i = 0; i < 10; i++) {
25
+ if (existsSync(join(dir, 'js/packages/magnetic-server'))) return dir;
26
+ const parent = resolve(dir, '..');
27
+ if (parent === dir) break;
28
+ dir = parent;
29
+ }
30
+ return null;
31
+ }
32
+
33
+ function getArg(flag: string): string | undefined {
34
+ const idx = args.indexOf(flag);
35
+ return idx >= 0 ? args[idx + 1] : undefined;
36
+ }
37
+
38
+ function usage() {
39
+ console.log(`
40
+ @magnetic/cli — Build and deploy server-driven UI apps
41
+
42
+ Usage:
43
+ magnetic dev Start dev mode (watch + rebuild + serve)
44
+ magnetic build Build the app bundle for deployment
45
+ magnetic push Build and deploy to a Magnetic platform server
46
+
47
+ Options:
48
+ --port <n> Dev server port (default: 3003)
49
+ --dir <path> App directory (default: current directory)
50
+ --server <url> Platform server URL for push
51
+ --name <name> App name for push (default: from magnetic.json)
52
+ --minify Minify the output bundle
53
+
54
+ Developer workflow:
55
+ 1. Write pages in pages/*.tsx
56
+ 2. Write business logic in state.ts (optional)
57
+ 3. Run \`magnetic dev\` to develop locally
58
+ 4. Run \`magnetic push\` to deploy
59
+ `);
60
+ }
61
+
62
+ async function main() {
63
+ if (!command || command === '--help' || command === '-h') {
64
+ usage();
65
+ process.exit(0);
66
+ }
67
+
68
+ const appDir = resolve(getArg('--dir') || '.');
69
+ const monorepoRoot = findMonorepoRoot(appDir);
70
+ const port = parseInt(getArg('--port') || '3003', 10);
71
+
72
+ // Load magnetic.json if it exists
73
+ let config: any = {};
74
+ const configPath = join(appDir, 'magnetic.json');
75
+ if (existsSync(configPath)) {
76
+ config = JSON.parse(readFileSync(configPath, 'utf-8'));
77
+ }
78
+
79
+ switch (command) {
80
+ case 'dev': {
81
+ await startDev({
82
+ appDir,
83
+ port,
84
+ monorepoRoot: monorepoRoot || undefined,
85
+ });
86
+ break;
87
+ }
88
+
89
+ case 'build': {
90
+ log('info', `Building ${appDir}`);
91
+ const buildStart = Date.now();
92
+ const scan = scanApp(appDir, monorepoRoot || undefined);
93
+ log('info', `Scanned: ${scan.pages.length} pages, state: ${scan.statePath || 'none (using defaults)'}`);
94
+
95
+ for (const page of scan.pages) {
96
+ log('debug', ` route ${page.routePath.padEnd(15)} ← ${page.filePath}`);
97
+ }
98
+
99
+ const bridgeCode = generateBridge(scan);
100
+ log('debug', `Bridge generated: ${bridgeCode.split('\n').length} lines`);
101
+
102
+ if (args.includes('--verbose')) {
103
+ console.log('\n--- Generated bridge ---');
104
+ console.log(bridgeCode);
105
+ console.log('--- End bridge ---\n');
106
+ }
107
+
108
+ const result = await bundleApp({
109
+ appDir,
110
+ bridgeCode,
111
+ minify: args.includes('--minify'),
112
+ monorepoRoot: monorepoRoot || undefined,
113
+ });
114
+
115
+ const kb = (result.sizeBytes / 1024).toFixed(1);
116
+ const elapsed = Date.now() - buildStart;
117
+ log('info', `✓ Built ${result.outPath} (${kb}KB) in ${elapsed}ms`);
118
+ break;
119
+ }
120
+
121
+ case 'push': {
122
+ const serverUrl = getArg('--server') || config.server;
123
+ const appName = getArg('--name') || config.name;
124
+
125
+ if (!serverUrl) {
126
+ console.error('[magnetic] No server URL. Use --server <url> or set "server" in magnetic.json');
127
+ process.exit(1);
128
+ }
129
+ if (!appName) {
130
+ console.error('[magnetic] No app name. Use --name <name> or set "name" in magnetic.json');
131
+ process.exit(1);
132
+ }
133
+
134
+ log('info', `Building for deploy...`);
135
+ const scan = scanApp(appDir, monorepoRoot || undefined);
136
+ log('info', `Scanned: ${scan.pages.length} pages, state: ${scan.statePath || 'none'}`);
137
+ const bridgeCode = generateBridge(scan);
138
+ const deploy = await buildForDeploy({ appDir, bridgeCode, monorepoRoot: monorepoRoot || undefined });
139
+
140
+ log('info', `Bundle: ${(deploy.bundleSize / 1024).toFixed(1)}KB (minified)`);
141
+ log('info', `Assets: ${Object.keys(deploy.assets).length} files`);
142
+ for (const [name, content] of Object.entries(deploy.assets)) {
143
+ log('debug', ` asset: ${name} (${(content.length / 1024).toFixed(1)}KB)`);
144
+ }
145
+ log('info', `Pushing to ${serverUrl}/api/apps/${appName}/deploy...`);
146
+
147
+ const bundleContent = readFileSync(deploy.bundlePath, 'utf-8');
148
+
149
+ const resp = await fetch(`${serverUrl}/api/apps/${appName}/deploy`, {
150
+ method: 'POST',
151
+ headers: { 'Content-Type': 'application/json' },
152
+ body: JSON.stringify({
153
+ bundle: bundleContent,
154
+ assets: deploy.assets,
155
+ }),
156
+ });
157
+
158
+ if (resp.ok) {
159
+ const data = await resp.json() as any;
160
+ log('info', `✓ Deployed! ${data.url || serverUrl + '/apps/' + appName + '/'}`);
161
+ log('info', ` Live at: ${serverUrl}/apps/${appName}/`);
162
+ } else {
163
+ const text = await resp.text();
164
+ log('error', `Deploy failed (${resp.status}): ${text}`);
165
+ process.exit(1);
166
+ }
167
+ break;
168
+ }
169
+
170
+ default:
171
+ console.error(`[magnetic] Unknown command: ${command}`);
172
+ usage();
173
+ process.exit(1);
174
+ }
175
+ }
176
+
177
+ main().catch((err) => {
178
+ console.error(`[magnetic] Fatal: ${err.message}`);
179
+ process.exit(1);
180
+ });
package/src/dev.ts ADDED
@@ -0,0 +1,188 @@
1
+ // dev.ts — Dev mode: watch pages/, auto-rebuild, start V8 server
2
+ // Gives developers instant feedback as they edit TSX pages
3
+
4
+ import { watch } from 'node:fs';
5
+ import { join, resolve } from 'node:path';
6
+ import { existsSync } from 'node:fs';
7
+ import { spawn, ChildProcess } from 'node:child_process';
8
+ import { scanApp, generateBridge } from './generator.ts';
9
+ import { bundleApp } from './bundler.ts';
10
+
11
+ export interface DevOptions {
12
+ /** Absolute path to the app directory */
13
+ appDir: string;
14
+ /** Port for the V8 server (default: 3003) */
15
+ port?: number;
16
+ /** Path to the magnetic-v8-server binary */
17
+ serverBin?: string;
18
+ /** Monorepo root (for resolving @magneticjs/server) */
19
+ monorepoRoot?: string;
20
+ }
21
+
22
+ /**
23
+ * Start dev mode:
24
+ * 1. Scan pages/ and generate bridge
25
+ * 2. Bundle with esbuild
26
+ * 3. Start the Rust V8 server
27
+ * 4. Watch for file changes → rebuild → restart server
28
+ */
29
+ export async function startDev(opts: DevOptions): Promise<void> {
30
+ const {
31
+ appDir,
32
+ port = 3003,
33
+ monorepoRoot,
34
+ } = opts;
35
+
36
+ const staticDir = join(appDir, 'public');
37
+ const outDir = join(appDir, 'dist');
38
+
39
+ // Find the V8 server binary
40
+ const serverBin = opts.serverBin || findServerBinary(monorepoRoot || appDir);
41
+ if (!serverBin) {
42
+ console.error('[magnetic] Cannot find magnetic-v8-server binary.');
43
+ console.error(' Build it with: cargo build --release -p magnetic-v8-server');
44
+ process.exit(1);
45
+ }
46
+
47
+ let serverProcess: ChildProcess | null = null;
48
+
49
+ async function rebuild(): Promise<string | null> {
50
+ const start = Date.now();
51
+ try {
52
+ const scan = scanApp(appDir, monorepoRoot);
53
+ console.log(`[magnetic] Scanned ${scan.pages.length} pages, state: ${scan.statePath || 'none'}`);
54
+
55
+ for (const page of scan.pages) {
56
+ console.log(` ${page.routePath} → ${page.filePath} (${page.importName})`);
57
+ }
58
+
59
+ const bridgeCode = generateBridge(scan);
60
+ const result = await bundleApp({
61
+ appDir,
62
+ bridgeCode,
63
+ outDir,
64
+ monorepoRoot,
65
+ });
66
+
67
+ const ms = Date.now() - start;
68
+ const kb = (result.sizeBytes / 1024).toFixed(1);
69
+ console.log(`[magnetic] Built ${result.outPath} (${kb}KB) in ${ms}ms`);
70
+ return result.outPath;
71
+ } catch (err: any) {
72
+ console.error(`[magnetic] Build failed: ${err.message}`);
73
+ return null;
74
+ }
75
+ }
76
+
77
+ function startServer(bundlePath: string): ChildProcess {
78
+ const args = [
79
+ '--bundle', bundlePath,
80
+ '--port', String(port),
81
+ '--static', staticDir,
82
+ ];
83
+
84
+ console.log(`[magnetic] Starting V8 server on :${port}`);
85
+ const proc = spawn(serverBin!, args, {
86
+ stdio: ['ignore', 'inherit', 'inherit'],
87
+ });
88
+
89
+ proc.on('exit', (code) => {
90
+ if (code !== null && code !== 0) {
91
+ console.error(`[magnetic] Server exited with code ${code}`);
92
+ }
93
+ });
94
+
95
+ return proc;
96
+ }
97
+
98
+ function stopServer() {
99
+ if (serverProcess) {
100
+ serverProcess.kill('SIGTERM');
101
+ serverProcess = null;
102
+ }
103
+ }
104
+
105
+ // Initial build + start
106
+ const bundlePath = await rebuild();
107
+ if (!bundlePath) {
108
+ console.error('[magnetic] Initial build failed. Fix errors and save to retry.');
109
+ } else {
110
+ serverProcess = startServer(bundlePath);
111
+ }
112
+
113
+ // Watch for changes
114
+ const watchDirs = [join(appDir, 'pages'), join(appDir, 'components')];
115
+ const watchFiles = ['state.ts', 'state.tsx', 'server/state.ts', 'server/state.tsx']
116
+ .map(f => join(appDir, f));
117
+
118
+ let rebuildTimer: ReturnType<typeof setTimeout> | null = null;
119
+
120
+ function scheduleRebuild() {
121
+ if (rebuildTimer) clearTimeout(rebuildTimer);
122
+ rebuildTimer = setTimeout(async () => {
123
+ console.log('\n[magnetic] Change detected, rebuilding...');
124
+ stopServer();
125
+ const path = await rebuild();
126
+ if (path) {
127
+ serverProcess = startServer(path);
128
+ }
129
+ }, 200); // Debounce 200ms
130
+ }
131
+
132
+ for (const dir of watchDirs) {
133
+ if (existsSync(dir)) {
134
+ watch(dir, { recursive: true }, (event, filename) => {
135
+ if (filename && /\.(tsx?|jsx?|css)$/.test(filename)) {
136
+ scheduleRebuild();
137
+ }
138
+ });
139
+ console.log(`[magnetic] Watching ${dir}`);
140
+ }
141
+ }
142
+
143
+ for (const file of watchFiles) {
144
+ if (existsSync(file)) {
145
+ watch(file, () => scheduleRebuild());
146
+ console.log(`[magnetic] Watching ${file}`);
147
+ }
148
+ }
149
+
150
+ // Handle cleanup
151
+ process.on('SIGINT', () => {
152
+ console.log('\n[magnetic] Shutting down...');
153
+ stopServer();
154
+ process.exit(0);
155
+ });
156
+
157
+ process.on('SIGTERM', () => {
158
+ stopServer();
159
+ process.exit(0);
160
+ });
161
+
162
+ console.log(`[magnetic] Dev mode ready. Edit pages/ and save to rebuild.`);
163
+ console.log(`[magnetic] http://localhost:${port}\n`);
164
+ }
165
+
166
+ /**
167
+ * Look for the magnetic-v8-server binary in common locations.
168
+ */
169
+ function findServerBinary(searchRoot: string): string | null {
170
+ // __dirname equivalent for the CLI package
171
+ const cliPkgBin = join(import.meta.dirname || __dirname, '..', 'bin', 'magnetic-v8-server');
172
+
173
+ const candidates = [
174
+ // npm-installed binary (from postinstall)
175
+ cliPkgBin,
176
+ // Monorepo development paths
177
+ join(searchRoot, 'rs/crates/magnetic-v8-server/target/debug/magnetic-v8-server'),
178
+ join(searchRoot, 'rs/crates/magnetic-v8-server/target/release/magnetic-v8-server'),
179
+ join(searchRoot, 'target/debug/magnetic-v8-server'),
180
+ join(searchRoot, 'target/release/magnetic-v8-server'),
181
+ ];
182
+
183
+ for (const path of candidates) {
184
+ if (existsSync(path)) return path;
185
+ }
186
+
187
+ return null;
188
+ }