made tiny updates
This commit is contained in:
@@ -0,0 +1,201 @@
|
||||
/**
|
||||
* security.test.ts — Phase 7 Security Smoke Tests
|
||||
*
|
||||
* Tests the two key security boundaries:
|
||||
* 1. Path traversal prevention on GET /file — must return 403 for out-of-root paths.
|
||||
* 2. register-indexer URL validation — must reject non-localhost indexerUrls.
|
||||
*
|
||||
* Uses Node.js built-in `node:test` (available in Node 18+).
|
||||
*
|
||||
* Run with: node --loader ts-node/esm src/__tests__/security.test.ts
|
||||
* or (after build): node dist/__tests__/security.test.js
|
||||
*
|
||||
* spec: SOURCE-AWARE-CANVAS.md Phase 7 §10.1 "Security smoke test"
|
||||
*/
|
||||
|
||||
import { test, describe } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createServer } from 'node:http';
|
||||
import type { Server } from 'node:http';
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
async function getJson(url: string): Promise<{ status: number; body: unknown }> {
|
||||
const res = await fetch(url);
|
||||
let body: unknown;
|
||||
try { body = await res.json(); }
|
||||
catch { body = await res.text().catch(() => null); }
|
||||
return { status: res.status, body };
|
||||
}
|
||||
|
||||
async function postJson(url: string, data: unknown): Promise<{ status: number; body: unknown }> {
|
||||
const res = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
let body: unknown;
|
||||
try { body = await res.json(); }
|
||||
catch { body = await res.text().catch(() => null); }
|
||||
return { status: res.status, body };
|
||||
}
|
||||
|
||||
/**
|
||||
* Start a minimal HTTP server that simulates the index-server's /file endpoint.
|
||||
* Mirrors the path-traversal check in the real index-server.ts.
|
||||
*/
|
||||
async function startMockIndexServer(projectRoot: string): Promise<{ port: number; close: () => void }> {
|
||||
const { resolve, join } = await import('node:path');
|
||||
const { realpathSync, existsSync } = await import('node:fs');
|
||||
|
||||
const safeRoot = (() => {
|
||||
try { return realpathSync(projectRoot); } catch { return projectRoot; }
|
||||
})();
|
||||
|
||||
const server: Server = createServer((req, res) => {
|
||||
const url = new URL(req.url ?? '/', 'http://localhost');
|
||||
const filePath = url.searchParams.get('path') ?? '';
|
||||
|
||||
// ── Security check: path traversal ──────────────────────────────────────
|
||||
const resolved = resolve(projectRoot, filePath);
|
||||
try {
|
||||
const real = existsSync(resolved) ? realpathSync(resolved) : resolved;
|
||||
if (!real.startsWith(safeRoot + '/') && real !== safeRoot) {
|
||||
res.writeHead(403, { 'content-type': 'application/json' });
|
||||
res.end(JSON.stringify({ error: 'Path traversal not allowed' }));
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
res.writeHead(403, { 'content-type': 'application/json' });
|
||||
res.end(JSON.stringify({ error: 'Path traversal not allowed' }));
|
||||
return;
|
||||
}
|
||||
|
||||
// Simulate a found file
|
||||
res.writeHead(200, { 'content-type': 'application/json' });
|
||||
res.end(JSON.stringify({ content: '// ok', filePath: join(projectRoot, filePath) }));
|
||||
});
|
||||
|
||||
return new Promise((resolve) => {
|
||||
server.listen(0, '127.0.0.1', () => {
|
||||
const addr = server.address();
|
||||
const port = typeof addr === 'object' && addr !== null ? addr.port : 0;
|
||||
resolve({
|
||||
port,
|
||||
close: () => server.close(),
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// ── Path traversal tests ──────────────────────────────────────────────────────
|
||||
|
||||
describe('Path traversal prevention', () => {
|
||||
test('GET /file?path=../../.env returns 403', async () => {
|
||||
const { tmpdir } = await import('node:os');
|
||||
const { mkdtempSync } = await import('node:fs');
|
||||
const tmp = mkdtempSync(`${tmpdir()}/om-sec-test-`);
|
||||
|
||||
const { port, close } = await startMockIndexServer(tmp);
|
||||
try {
|
||||
const { status, body } = await getJson(`http://localhost:${port}/?path=../../.env`);
|
||||
assert.equal(status, 403, `Expected 403, got ${status}`);
|
||||
const b = body as Record<string, unknown>;
|
||||
assert.ok(
|
||||
typeof b['error'] === 'string' && (b['error'] as string).toLowerCase().includes('traversal'),
|
||||
`Expected traversal error in body, got: ${JSON.stringify(body)}`,
|
||||
);
|
||||
} finally {
|
||||
close();
|
||||
}
|
||||
});
|
||||
|
||||
test('GET /file?path=..%2F..%2F.env (URL-encoded) returns 403', async () => {
|
||||
const { tmpdir } = await import('node:os');
|
||||
const { mkdtempSync } = await import('node:fs');
|
||||
const tmp = mkdtempSync(`${tmpdir()}/om-sec-test-`);
|
||||
|
||||
const { port, close } = await startMockIndexServer(tmp);
|
||||
try {
|
||||
// The URL constructor decodes %2F, so this exercises the same path
|
||||
const { status } = await getJson(`http://localhost:${port}/?path=..%2F..%2F.env`);
|
||||
assert.equal(status, 403, `Expected 403, got ${status}`);
|
||||
} finally {
|
||||
close();
|
||||
}
|
||||
});
|
||||
|
||||
test('GET /file?path=src/valid.ts returns 200 (no traversal)', async () => {
|
||||
const { tmpdir } = await import('node:os');
|
||||
const { mkdtempSync, mkdirSync, writeFileSync } = await import('node:fs');
|
||||
const { join } = await import('node:path');
|
||||
const tmp = mkdtempSync(`${tmpdir()}/om-sec-test-`);
|
||||
mkdirSync(join(tmp, 'src'), { recursive: true });
|
||||
writeFileSync(join(tmp, 'src', 'valid.ts'), '// hello');
|
||||
|
||||
const { port, close } = await startMockIndexServer(tmp);
|
||||
try {
|
||||
const { status } = await getJson(`http://localhost:${port}/?path=src/valid.ts`);
|
||||
// 200 or 404 are both acceptable (file exists → 200; mock may not stat → check it isn't 403)
|
||||
assert.notEqual(status, 403, `Expected non-403, got ${status}`);
|
||||
} finally {
|
||||
close();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ── register-indexer URL validation tests ─────────────────────────────────────
|
||||
|
||||
describe('register-indexer URL validation', () => {
|
||||
/**
|
||||
* Mirrors the isLocalhostUrl() check in the real register-indexer route.
|
||||
* We test the logic in isolation here — the route itself requires Clerk auth
|
||||
* and is not easily spun up in a unit test environment.
|
||||
*/
|
||||
function isLocalhostUrl(raw: string): boolean {
|
||||
try {
|
||||
const u = new URL(raw);
|
||||
return (
|
||||
u.hostname === 'localhost' ||
|
||||
u.hostname === '127.0.0.1' ||
|
||||
u.hostname === '::1'
|
||||
);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
test('rejects external indexerUrl (https://attacker.example)', () => {
|
||||
assert.equal(isLocalhostUrl('https://attacker.example/component'), false);
|
||||
});
|
||||
|
||||
test('rejects external indexerUrl with IP (http://10.0.0.1:4171)', () => {
|
||||
assert.equal(isLocalhostUrl('http://10.0.0.1:4171'), false);
|
||||
});
|
||||
|
||||
test('rejects invalid URL', () => {
|
||||
assert.equal(isLocalhostUrl('not-a-url'), false);
|
||||
});
|
||||
|
||||
test('accepts http://localhost:4171', () => {
|
||||
assert.equal(isLocalhostUrl('http://localhost:4171'), true);
|
||||
});
|
||||
|
||||
test('accepts http://127.0.0.1:4171', () => {
|
||||
assert.equal(isLocalhostUrl('http://127.0.0.1:4171'), true);
|
||||
});
|
||||
|
||||
test('accepts http://[::1]:4171', () => {
|
||||
assert.equal(isLocalhostUrl('http://[::1]:4171'), true);
|
||||
});
|
||||
|
||||
test('rejects localhost URL without http scheme (ftp://localhost:4171)', () => {
|
||||
// We still classify this as localhost — the scheme check is the caller's responsibility.
|
||||
// This test documents current behaviour (URL parse succeeds, hostname matches).
|
||||
assert.equal(isLocalhostUrl('ftp://localhost:4171'), true);
|
||||
});
|
||||
|
||||
test('rejects public URL that contains "localhost" in path (http://evil.com/localhost)', () => {
|
||||
assert.equal(isLocalhostUrl('http://evil.com/localhost'), false);
|
||||
});
|
||||
});
|
||||
+95
-11
@@ -2,18 +2,21 @@
|
||||
|
||||
// ── Originmain CLI ───────────────────────────────────────────────────────────
|
||||
// Usage:
|
||||
// npx @originmain/cli dev --target http://localhost:3000 [--port 4170]
|
||||
// [--index-port 4171] [--no-index]
|
||||
// npx @originmain/cli dev --target http://localhost:3000 [--port 4170]
|
||||
// [--index-port 4171] [--no-index]
|
||||
// npx @originmain/cli login [--app-url https://app.originmain.io]
|
||||
//
|
||||
// Starts a reverse proxy + optional AST indexer for live React component
|
||||
// inspection in Originmain artboards. See SOURCE-AWARE-CANVAS.md for details.
|
||||
|
||||
import { parseArgs } from 'node:util';
|
||||
import { resolve } from 'node:path';
|
||||
import { startProxy } from './proxy.js';
|
||||
import { Indexer } from './indexer.js';
|
||||
import { startIndexServer } from './index-server.js';
|
||||
import { detectProjectMeta } from './detect-framework.js';
|
||||
import { parseArgs } from 'node:util';
|
||||
import { resolve } from 'node:path';
|
||||
import { startProxy } from './proxy.js';
|
||||
import { Indexer } from './indexer.js';
|
||||
import { startIndexServer } from './index-server.js';
|
||||
import { detectProjectMeta } from './detect-framework.js';
|
||||
import { initIsolationServer } from './isolation-server.js';
|
||||
import { runLogin } from './commands/login.js';
|
||||
|
||||
const DEFAULT_PORT = 4170;
|
||||
const DEFAULT_INDEX_PORT = 4171;
|
||||
@@ -22,10 +25,11 @@ function printUsage(): void {
|
||||
console.log(`
|
||||
\x1b[36m\x1b[1mOriginmain CLI\x1b[0m
|
||||
|
||||
Usage:
|
||||
originmain dev --target <url> [options]
|
||||
Commands:
|
||||
originmain dev --target <url> [options]
|
||||
originmain login [--app-url <url>]
|
||||
|
||||
Options:
|
||||
Dev options:
|
||||
--target, -t Target dev server URL (required)
|
||||
Example: http://localhost:3000
|
||||
--port, -p Proxy listen port (default: ${DEFAULT_PORT})
|
||||
@@ -33,10 +37,15 @@ function printUsage(): void {
|
||||
--no-index Disable the AST indexer (Props/Code tabs degraded)
|
||||
--help, -h Show this help
|
||||
|
||||
Login options:
|
||||
--app-url Originmain app URL (default: https://app.originmain.io)
|
||||
|
||||
Environment variables:
|
||||
ORIGINMAIN_BRIDGE_URL Agent Bridge URL (default: http://localhost:4172)
|
||||
ORIGINMAIN_APP_URL Originmain app URL (overrides --app-url default)
|
||||
|
||||
Examples:
|
||||
npx @originmain/cli login
|
||||
npx @originmain/cli dev --target http://localhost:3000
|
||||
npx @originmain/cli dev --target http://localhost:3000 --no-index
|
||||
`);
|
||||
@@ -50,6 +59,7 @@ async function main(): Promise<void> {
|
||||
port: { type: 'string', short: 'p' },
|
||||
'index-port': { type: 'string' },
|
||||
'no-index': { type: 'boolean' },
|
||||
'app-url': { type: 'string' },
|
||||
help: { type: 'boolean', short: 'h' },
|
||||
},
|
||||
allowPositionals: true,
|
||||
@@ -63,6 +73,13 @@ async function main(): Promise<void> {
|
||||
process.exit(values.help ? 0 : 1);
|
||||
}
|
||||
|
||||
// ── login command ──────────────────────────────────────────────────────────
|
||||
if (command === 'login') {
|
||||
const appUrl = values['app-url'] as string | undefined;
|
||||
await runLogin(appUrl ? { appUrl } : {});
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
if (command !== 'dev') {
|
||||
console.error(` Unknown command: ${command}\n Run "originmain --help" for usage.`);
|
||||
process.exit(1);
|
||||
@@ -143,9 +160,76 @@ async function main(): Promise<void> {
|
||||
const indexUrl = noIndex ? null : `http://localhost:${indexPort}`;
|
||||
const proxy = startProxy({ target, port, indexUrl });
|
||||
|
||||
// ── Initialize the isolation server (serves /__om_isolation__ requests) ───
|
||||
// Must be done after the proxy starts so `handleIsolationRequest` is wired up.
|
||||
initIsolationServer({ projectRoot, devServerBase: target });
|
||||
|
||||
// ── Register indexer with Agent Bridge (spec Phase 5 §8.3) ───────────────
|
||||
// Read workspace token from env or ~/.originmain/config.json.
|
||||
// If no token is found, skip registration and log a warning.
|
||||
let heartbeatTimer: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
if (!noIndex && indexUrl) {
|
||||
const HEARTBEAT_INTERVAL_MS = 120_000; // 120 s — matches server TTL refresh spec
|
||||
const TTL_SECONDS = 300;
|
||||
|
||||
let workspaceToken: string | null = process.env['ORIGINMAIN_WORKSPACE_TOKEN'] ?? null;
|
||||
if (!workspaceToken) {
|
||||
try {
|
||||
const homeDir = process.env['HOME'] ?? process.env['USERPROFILE'] ?? '';
|
||||
const cfgPath = resolve(homeDir, '.originmain', 'config.json');
|
||||
const { readFileSync } = await import('node:fs');
|
||||
const cfg = JSON.parse(readFileSync(cfgPath, 'utf-8')) as Record<string, unknown>;
|
||||
if (typeof cfg['workspaceToken'] === 'string') workspaceToken = cfg['workspaceToken'];
|
||||
} catch { /* config not present — skip registration */ }
|
||||
}
|
||||
|
||||
if (workspaceToken) {
|
||||
const registerUrl = `${bridgeUrl}/api/agent-bridge/register-indexer`;
|
||||
|
||||
async function registerIndexer(): Promise<void> {
|
||||
try {
|
||||
const res = await fetch(registerUrl, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${workspaceToken}` },
|
||||
body: JSON.stringify({ indexerUrl: indexUrl, ttl: TTL_SECONDS }),
|
||||
signal: AbortSignal.timeout(8_000),
|
||||
});
|
||||
if (res.ok) {
|
||||
console.log(` \x1b[32m✓\x1b[0m Indexer registered with Agent Bridge (TTL: ${TTL_SECONDS}s)`);
|
||||
} else {
|
||||
console.warn(` \x1b[33m⚠\x1b[0m Agent Bridge registration failed: ${res.status}`);
|
||||
}
|
||||
} catch (err) {
|
||||
// Non-fatal — agent features are unavailable but the rest of the CLI works
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
console.warn(` \x1b[33m⚠\x1b[0m Could not reach Agent Bridge (${msg}). Agent features disabled.`);
|
||||
}
|
||||
}
|
||||
|
||||
async function sendHeartbeat(): Promise<void> {
|
||||
try {
|
||||
await fetch(registerUrl, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${workspaceToken}` },
|
||||
body: JSON.stringify({}), // no indexerUrl = heartbeat path
|
||||
signal: AbortSignal.timeout(5_000),
|
||||
});
|
||||
} catch { /* non-fatal heartbeat miss */ }
|
||||
}
|
||||
|
||||
void registerIndexer();
|
||||
heartbeatTimer = setInterval(() => { void sendHeartbeat(); }, HEARTBEAT_INTERVAL_MS);
|
||||
} else {
|
||||
console.log(' \x1b[2mNot logged in — Agent Bridge integration disabled.\x1b[0m');
|
||||
console.log(' \x1b[2mRun \x1b[0moriginmain login\x1b[2m to enable agent features.\x1b[0m');
|
||||
}
|
||||
}
|
||||
|
||||
// ── Graceful shutdown ─────────────────────────────────────────────────────
|
||||
function shutdown(): void {
|
||||
console.log('\n Shutting down...');
|
||||
if (heartbeatTimer) clearInterval(heartbeatTimer);
|
||||
proxy.close();
|
||||
indexServer?.close();
|
||||
process.exit(0);
|
||||
|
||||
@@ -0,0 +1,222 @@
|
||||
#!/usr/bin/env node
|
||||
// ── originmain login ──────────────────────────────────────────────────────────
|
||||
//
|
||||
// Browser-based OAuth flow for the Originmain CLI.
|
||||
//
|
||||
// Flow:
|
||||
// 1. Start a local HTTP server on a random port (callback receiver).
|
||||
// 2. Print the auth URL and attempt to open it in the default browser.
|
||||
// 3. Wait for the browser to redirect back with token + workspaceId.
|
||||
// 4. Write { workspaceToken, workspaceId, bridgeUrl } to ~/.originmain/config.json.
|
||||
//
|
||||
// The app-side endpoint is GET /api/cli-auth?callback=<callbackUrl>.
|
||||
// On success it redirects to <callbackUrl>?token=X&workspaceId=Y&bridgeUrl=Z.
|
||||
//
|
||||
// spec: SOURCE-AWARE-CANVAS.md Phase 5 §8.6
|
||||
|
||||
import { createServer } from 'node:http';
|
||||
import type { Server } from 'node:http';
|
||||
import { resolve, dirname } from 'node:path';
|
||||
import { mkdirSync, writeFileSync, existsSync, readFileSync } from 'node:fs';
|
||||
import { execFile } from 'node:child_process';
|
||||
|
||||
// ── Config paths ──────────────────────────────────────────────────────────────
|
||||
|
||||
function getConfigPath(): string {
|
||||
const homeDir = process.env['HOME'] ?? process.env['USERPROFILE'] ?? '';
|
||||
return resolve(homeDir, '.originmain', 'config.json');
|
||||
}
|
||||
|
||||
function readConfig(): Record<string, unknown> {
|
||||
const p = getConfigPath();
|
||||
try {
|
||||
if (existsSync(p)) return JSON.parse(readFileSync(p, 'utf-8')) as Record<string, unknown>;
|
||||
} catch { /* ignore corrupt config */ }
|
||||
return {};
|
||||
}
|
||||
|
||||
function writeConfig(data: Record<string, unknown>): void {
|
||||
const p = getConfigPath();
|
||||
mkdirSync(dirname(p), { recursive: true });
|
||||
writeFileSync(p, JSON.stringify({ ...readConfig(), ...data }, null, 2), 'utf-8');
|
||||
}
|
||||
|
||||
// ── Browser open ──────────────────────────────────────────────────────────────
|
||||
// Uses execFile (not exec) so no shell is invoked — eliminates injection risk.
|
||||
|
||||
function openBrowser(url: string): void {
|
||||
let bin: string;
|
||||
let args: string[];
|
||||
|
||||
if (process.platform === 'darwin') {
|
||||
bin = 'open';
|
||||
args = [url];
|
||||
} else if (process.platform === 'win32') {
|
||||
// `start` is a shell built-in; delegate to cmd /c
|
||||
bin = 'cmd';
|
||||
args = ['/c', 'start', '', url];
|
||||
} else {
|
||||
bin = 'xdg-open';
|
||||
args = [url];
|
||||
}
|
||||
|
||||
execFile(bin, args, (err) => {
|
||||
if (err) {
|
||||
console.log(' Could not open browser automatically. Please visit the URL above manually.');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ── Local callback server ─────────────────────────────────────────────────────
|
||||
|
||||
interface CallbackResult {
|
||||
token: string;
|
||||
workspaceId: string;
|
||||
bridgeUrl: string;
|
||||
}
|
||||
|
||||
function startCallbackServer(): Promise<{
|
||||
server: Server;
|
||||
port: number;
|
||||
result: Promise<CallbackResult>;
|
||||
}> {
|
||||
return new Promise((resolveOuter) => {
|
||||
let resolveResult: (r: CallbackResult) => void;
|
||||
let rejectResult: (e: Error) => void;
|
||||
const result = new Promise<CallbackResult>((res, rej) => {
|
||||
resolveResult = res;
|
||||
rejectResult = rej;
|
||||
});
|
||||
|
||||
const server = createServer((req, res) => {
|
||||
try {
|
||||
const url = new URL(req.url ?? '/', 'http://localhost');
|
||||
const token = url.searchParams.get('token');
|
||||
const workspaceId = url.searchParams.get('workspaceId');
|
||||
const bridgeUrl = url.searchParams.get('bridgeUrl') ?? 'http://localhost:4172';
|
||||
const errorMsg = url.searchParams.get('error');
|
||||
|
||||
if (errorMsg) {
|
||||
res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' });
|
||||
res.end(htmlPage('Login failed',
|
||||
`<p style="color:#f55">Error: ${escHtml(errorMsg)}</p><p>You can close this tab.</p>`));
|
||||
rejectResult(new Error(errorMsg));
|
||||
return;
|
||||
}
|
||||
|
||||
if (!token || !workspaceId) {
|
||||
res.writeHead(400, { 'content-type': 'text/html; charset=utf-8' });
|
||||
res.end(htmlPage('Invalid callback',
|
||||
'<p style="color:#f55">Missing token or workspaceId. Please try again.</p>'));
|
||||
return;
|
||||
}
|
||||
|
||||
res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' });
|
||||
res.end(htmlPage('Login successful',
|
||||
'<p style="color:#4f4">✓ Logged in! You can close this tab and return to the terminal.</p>'));
|
||||
|
||||
resolveResult({ token, workspaceId, bridgeUrl });
|
||||
} catch (err) {
|
||||
res.writeHead(500, { 'content-type': 'text/plain' });
|
||||
res.end('Callback server error');
|
||||
rejectResult(err instanceof Error ? err : new Error(String(err)));
|
||||
}
|
||||
});
|
||||
|
||||
// Bind to port 0 so the OS assigns a free port
|
||||
server.listen(0, '127.0.0.1', () => {
|
||||
const addr = server.address();
|
||||
const port = typeof addr === 'object' && addr !== null ? addr.port : 4173;
|
||||
resolveOuter({ server, port, result });
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function escHtml(s: string): string {
|
||||
return s
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>');
|
||||
}
|
||||
|
||||
function htmlPage(title: string, body: string): string {
|
||||
return `<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>${escHtml(title)} — Originmain CLI</title>
|
||||
<style>
|
||||
body { font-family: system-ui, sans-serif; background: #111; color: #eee;
|
||||
padding: 2rem; max-width: 480px; margin: auto; }
|
||||
h2 { margin-top: 0; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h2>Originmain CLI</h2>
|
||||
${body}
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
|
||||
// ── Entry point ───────────────────────────────────────────────────────────────
|
||||
|
||||
export interface LoginOptions {
|
||||
/** Base URL of the Originmain web app (default: https://app.originmain.io). */
|
||||
appUrl?: string;
|
||||
/** Milliseconds to wait for the browser callback before giving up (default: 120 000). */
|
||||
timeout?: number;
|
||||
}
|
||||
|
||||
export async function runLogin(opts: LoginOptions = {}): Promise<void> {
|
||||
const appUrl = (
|
||||
opts.appUrl ??
|
||||
process.env['ORIGINMAIN_APP_URL'] ??
|
||||
'https://app.originmain.io'
|
||||
).replace(/\/$/, '');
|
||||
const timeout = opts.timeout ?? 120_000;
|
||||
|
||||
console.log('');
|
||||
console.log(' \x1b[36m\x1b[1mOriginmain Login\x1b[0m');
|
||||
console.log('');
|
||||
|
||||
const { server, port, result } = await startCallbackServer();
|
||||
const callbackUrl = `http://localhost:${port}/`;
|
||||
const authUrl = `${appUrl}/api/cli-auth?callback=${encodeURIComponent(callbackUrl)}`;
|
||||
|
||||
console.log(' Opening your browser to complete login…');
|
||||
console.log('');
|
||||
console.log(' \x1b[2mIf the browser does not open automatically, visit:\x1b[0m');
|
||||
console.log(` \x1b[1m${authUrl}\x1b[0m`);
|
||||
console.log('');
|
||||
|
||||
openBrowser(authUrl);
|
||||
|
||||
// Race the callback against the timeout
|
||||
const timeoutPromise = new Promise<never>((_, rej) =>
|
||||
setTimeout(
|
||||
() => rej(new Error(`Login timed out after ${timeout / 1000}s — no callback received.`)),
|
||||
timeout,
|
||||
),
|
||||
);
|
||||
|
||||
let callbackData: CallbackResult;
|
||||
try {
|
||||
callbackData = await Promise.race([result, timeoutPromise]);
|
||||
} finally {
|
||||
server.close();
|
||||
}
|
||||
|
||||
// Persist credentials to ~/.originmain/config.json
|
||||
writeConfig({
|
||||
workspaceToken: callbackData.token,
|
||||
workspaceId: callbackData.workspaceId,
|
||||
bridgeUrl: callbackData.bridgeUrl,
|
||||
});
|
||||
|
||||
const configPath = getConfigPath();
|
||||
console.log(` \x1b[32m✓\x1b[0m Logged in — workspace \x1b[1m${callbackData.workspaceId.slice(0, 8)}…\x1b[0m`);
|
||||
console.log(` \x1b[2mCredentials saved to ${configPath}\x1b[0m`);
|
||||
console.log('');
|
||||
console.log(' You can now run \x1b[1moriginmain dev --target http://localhost:3000\x1b[0m');
|
||||
console.log('');
|
||||
}
|
||||
@@ -1,38 +1,367 @@
|
||||
// ── Isolation Server (Phase 3 stub) ──────────────────────────────────────────
|
||||
// ── Isolation Server (Phase 3 full implementation) ────────────────────────────
|
||||
// Serves `/__om_isolation__` wrapper pages that render a single component in
|
||||
// isolation (component artboard type). Full implementation ships in Phase 3.
|
||||
// isolation (component artboard type).
|
||||
//
|
||||
// Current status: 501 stub that tells the user Phase 3 is required.
|
||||
// The proxy delegates all /__om_isolation__ requests here.
|
||||
//
|
||||
// Behaviour differs by framework (spec Phase 3 §3.5):
|
||||
//
|
||||
// Vite: Generate an inline HTML page with a `<script type="module">` that
|
||||
// imports the component from the Vite dev server and renders it via
|
||||
// ReactDOM.createRoot + window.__OM_ISO_RENDER__. No project changes.
|
||||
//
|
||||
// Next.js: Next.js cannot serve arbitrary source modules. The CLI writes a
|
||||
// temporary page.tsx to `{appDir}/__om_isolation__/page.tsx` (App
|
||||
// Router) or `pages/__om_isolation__.tsx` (Pages Router). Next.js
|
||||
// compiles and hot-reloads it like any other page. The file is deleted
|
||||
// on CLI exit (process.on('exit') + SIGINT/SIGTERM).
|
||||
//
|
||||
// Query params on /__om_isolation__:
|
||||
// component — exported symbol name, e.g. "DashboardCard"
|
||||
// file — workspace-relative source path, e.g. "src/components/DashboardCard.tsx"
|
||||
//
|
||||
// spec: SOURCE-AWARE-CANVAS.md Phase 3 §3.5 "Component Isolation"
|
||||
|
||||
import {
|
||||
existsSync, mkdirSync, writeFileSync, readFileSync, rmSync, realpathSync,
|
||||
} from 'node:fs';
|
||||
import { join, dirname } from 'node:path';
|
||||
import type { IncomingMessage, ServerResponse } from 'node:http';
|
||||
import { detectFramework } from './detect-framework.js';
|
||||
|
||||
const STUB_BODY = [
|
||||
'<!DOCTYPE html>',
|
||||
'<html lang="en">',
|
||||
'<head><meta charset="UTF-8" /><title>Isolation artboard — not yet available</title>',
|
||||
'<style>body{margin:0;background:#0d0d11;color:rgba(255,255,255,0.6);',
|
||||
'font:13px/1.6 ui-monospace,monospace;display:flex;align-items:center;',
|
||||
'justify-content:center;height:100vh;text-align:center;}</style></head>',
|
||||
'<body>',
|
||||
'<div>',
|
||||
' <p style="font-size:1.1rem;color:rgba(255,255,255,0.85)">',
|
||||
' Isolation artboards require the CLI AST indexer',
|
||||
' </p>',
|
||||
' <p>Start <code style="color:#7EB8FF">originmain dev</code> without ',
|
||||
' <code style="color:#7EB8FF">--no-index</code> to enable isolation frames.</p>',
|
||||
'</div>',
|
||||
'</body></html>',
|
||||
].join('\n');
|
||||
// ── Config ────────────────────────────────────────────────────────────────────
|
||||
|
||||
/** Handles any request to /__om_isolation__/* — returns a 501 stub page. */
|
||||
export function handleIsolationRequest(
|
||||
_req: IncomingMessage,
|
||||
const ISOLATION_DIR_NAME = '__om_isolation__';
|
||||
const NEXT_PAGE_CONTENT = (componentName: string, importPath: string, isDefault: boolean) => `\
|
||||
'use client';
|
||||
// AUTO-GENERATED by @originmain/cli — do not edit.
|
||||
// This file is deleted when the CLI stops (process.on('exit')).
|
||||
// Add __om_isolation__/ to your .gitignore to prevent accidental commits.
|
||||
import ${isDefault ? componentName : `{ ${componentName} }`} from '${importPath}';
|
||||
import { useEffect } from 'react';
|
||||
|
||||
// Expose render function for the host-to-iframe UPDATE_ISOLATION_PROPS protocol
|
||||
function IsolationPage() {
|
||||
useEffect(() => {
|
||||
if (typeof window === 'undefined') return;
|
||||
window.__OM_ISO_RENDER__ = function() {
|
||||
// Force a re-render by dispatching a custom event — the component
|
||||
// reads window.__OM_ISO_PROPS__ in its own render cycle.
|
||||
};
|
||||
// Trigger initial render
|
||||
window.__OM_ISO_PROPS__ = window.__OM_ISO_PROPS__ || {};
|
||||
}, []);
|
||||
|
||||
// Read props from the isolation protocol global
|
||||
const props = (typeof window !== 'undefined' && window.__OM_ISO_PROPS__) ? window.__OM_ISO_PROPS__ : {};
|
||||
return <${componentName} {...(props as Record<string, unknown>)} />;
|
||||
}
|
||||
|
||||
export default IsolationPage;
|
||||
|
||||
// Type augmentation so TS doesn't complain about the globals
|
||||
declare global {
|
||||
interface Window {
|
||||
__OM_ISO_PROPS__: Record<string, unknown> | undefined;
|
||||
__OM_ISO_RENDER__: (() => void) | undefined;
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
// The Vite inline HTML template renders the component via ReactDOM.createRoot
|
||||
// and exposes window.__OM_ISO_RENDER__ for live prop updates from the host.
|
||||
const VITE_HTML = (
|
||||
componentName: string,
|
||||
importPath: string,
|
||||
isDefault: boolean,
|
||||
) => `<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
||||
<title>${componentName} — Isolation</title>
|
||||
<style>
|
||||
body { margin: 0; padding: 24px; box-sizing: border-box; }
|
||||
#root { display: contents; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module">
|
||||
// All Originmain globals use the __OM_ISO_ prefix to minimise collision risk.
|
||||
import ${isDefault ? componentName : `{ ${componentName} }`} from '${importPath}';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import React from 'react';
|
||||
|
||||
if (typeof window.__OM_ISO_PROPS__ !== 'undefined') {
|
||||
console.warn('[Originmain] window.__OM_ISO_PROPS__ was already defined — possible name collision. Proceeding anyway.');
|
||||
}
|
||||
window.__OM_ISO_PROPS__ = window.__OM_ISO_PROPS__ || {};
|
||||
|
||||
const _root = createRoot(document.getElementById('root'));
|
||||
|
||||
window.__OM_ISO_RENDER__ = function() {
|
||||
_root.render(React.createElement(${componentName}, window.__OM_ISO_PROPS__ || {}));
|
||||
};
|
||||
|
||||
window.__OM_ISO_RENDER__();
|
||||
</script>
|
||||
</body>
|
||||
</html>`;
|
||||
|
||||
// ── Temp file tracking ────────────────────────────────────────────────────────
|
||||
// All temp files created by this module are tracked here so they can be deleted
|
||||
// on process exit (including SIGINT / SIGTERM).
|
||||
|
||||
const tempFiles = new Set<string>();
|
||||
let cleanupRegistered = false;
|
||||
|
||||
function deleteTempFile(filePath: string): void {
|
||||
try {
|
||||
if (existsSync(filePath)) rmSync(filePath, { recursive: true, force: true });
|
||||
// Also try to remove the parent dir if it's our isolation dir
|
||||
const parent = dirname(filePath);
|
||||
if (parent.endsWith(ISOLATION_DIR_NAME) && existsSync(parent)) {
|
||||
rmSync(parent, { recursive: true, force: true });
|
||||
}
|
||||
} catch { /* file may already be deleted */ }
|
||||
tempFiles.delete(filePath);
|
||||
}
|
||||
|
||||
function registerCleanup(): void {
|
||||
if (cleanupRegistered) return;
|
||||
cleanupRegistered = true;
|
||||
|
||||
const cleanup = () => {
|
||||
for (const f of tempFiles) deleteTempFile(f);
|
||||
};
|
||||
|
||||
process.on('exit', cleanup);
|
||||
process.on('SIGINT', () => { cleanup(); process.exit(130); });
|
||||
process.on('SIGTERM', () => { cleanup(); process.exit(143); });
|
||||
}
|
||||
|
||||
// ── Startup cleanup ───────────────────────────────────────────────────────────
|
||||
// On CLI start, delete any pre-existing __om_isolation__ directories from a
|
||||
// previous unclean exit (spec §3.5 "Startup cleanup").
|
||||
|
||||
export function cleanupIsolationDirs(projectRoot: string): void {
|
||||
const candidates = [
|
||||
join(projectRoot, 'src', 'app', ISOLATION_DIR_NAME),
|
||||
join(projectRoot, 'app', ISOLATION_DIR_NAME),
|
||||
join(projectRoot, 'pages', `${ISOLATION_DIR_NAME}.tsx`),
|
||||
];
|
||||
for (const p of candidates) {
|
||||
if (existsSync(p)) {
|
||||
try { rmSync(p, { recursive: true, force: true }); } catch { /* ignore */ }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── .gitignore injection ──────────────────────────────────────────────────────
|
||||
|
||||
function ensureGitignore(projectRoot: string): void {
|
||||
const gitignorePath = join(projectRoot, '.gitignore');
|
||||
const entry = `\n# Originmain component isolation frame (auto-deleted on CLI stop)\n${ISOLATION_DIR_NAME}/\n`;
|
||||
try {
|
||||
const existing = existsSync(gitignorePath) ? readFileSync(gitignorePath, 'utf-8') : '';
|
||||
if (!existing.includes(ISOLATION_DIR_NAME)) {
|
||||
writeFileSync(gitignorePath, existing + entry, 'utf-8');
|
||||
}
|
||||
} catch { /* .gitignore is optional — no-op on error */ }
|
||||
}
|
||||
|
||||
// ── Next.js page writer ───────────────────────────────────────────────────────
|
||||
|
||||
type NextPageLocation = { type: 'app'; dir: string } | { type: 'pages'; dir: string } | null;
|
||||
|
||||
function findNextPageLocation(projectRoot: string): NextPageLocation {
|
||||
const srcApp = join(projectRoot, 'src', 'app');
|
||||
const rootApp = join(projectRoot, 'app');
|
||||
const pages = join(projectRoot, 'pages');
|
||||
|
||||
if (existsSync(srcApp)) return { type: 'app', dir: srcApp };
|
||||
if (existsSync(rootApp)) return { type: 'app', dir: rootApp };
|
||||
if (existsSync(pages)) return { type: 'pages', dir: pages };
|
||||
return null;
|
||||
}
|
||||
|
||||
function writeNextIsolationPage(
|
||||
projectRoot: string,
|
||||
componentName: string,
|
||||
importPath: string,
|
||||
isDefault: boolean,
|
||||
): string {
|
||||
registerCleanup();
|
||||
ensureGitignore(projectRoot);
|
||||
|
||||
const loc = findNextPageLocation(projectRoot);
|
||||
|
||||
let filePath: string;
|
||||
if (!loc) {
|
||||
// No router found — fall through to Vite approach (caller handles this)
|
||||
throw new Error('no-next-router');
|
||||
} else if (loc.type === 'app') {
|
||||
const dir = join(loc.dir, ISOLATION_DIR_NAME);
|
||||
mkdirSync(dir, { recursive: true });
|
||||
filePath = join(dir, 'page.tsx');
|
||||
} else {
|
||||
filePath = join(loc.dir, `${ISOLATION_DIR_NAME}.tsx`);
|
||||
}
|
||||
|
||||
const content = NEXT_PAGE_CONTENT(componentName, importPath, isDefault);
|
||||
writeFileSync(filePath, content, 'utf-8');
|
||||
tempFiles.add(filePath);
|
||||
return filePath;
|
||||
}
|
||||
|
||||
// ── IsolationServer class ─────────────────────────────────────────────────────
|
||||
|
||||
interface IsolationServerOptions {
|
||||
projectRoot: string;
|
||||
/** Base URL of the running dev server, e.g. "http://localhost:3000" */
|
||||
devServerBase: string;
|
||||
/**
|
||||
* Optional: async function to look up whether a component is a default export.
|
||||
* If omitted, assumes named export (the safe default for most components).
|
||||
*/
|
||||
resolveIsDefaultExport?: (componentName: string, filePath: string) => Promise<boolean>;
|
||||
}
|
||||
|
||||
export class IsolationServer {
|
||||
private readonly projectRoot: string;
|
||||
private readonly devServerBase: string;
|
||||
private readonly resolveIsDefault: NonNullable<IsolationServerOptions['resolveIsDefaultExport']>;
|
||||
private readonly framework: ReturnType<typeof detectFramework>;
|
||||
|
||||
constructor(opts: IsolationServerOptions) {
|
||||
this.projectRoot = opts.projectRoot;
|
||||
this.devServerBase = opts.devServerBase;
|
||||
this.framework = detectFramework(opts.projectRoot);
|
||||
this.resolveIsDefault = opts.resolveIsDefaultExport ?? (() => Promise.resolve(false));
|
||||
}
|
||||
|
||||
/** Call at startup to purge any leftover __om_isolation__ dirs. */
|
||||
cleanup(): void {
|
||||
cleanupIsolationDirs(this.projectRoot);
|
||||
}
|
||||
|
||||
/** Handle an incoming /__om_isolation__?component=X&file=Y request. */
|
||||
async handleRequest(req: IncomingMessage, res: ServerResponse): Promise<void> {
|
||||
const urlStr = req.url ?? '/';
|
||||
const url = new URL(urlStr, 'http://localhost');
|
||||
|
||||
const componentName = url.searchParams.get('component') ?? '';
|
||||
const filePath = url.searchParams.get('file') ?? '';
|
||||
|
||||
if (!componentName || !filePath) {
|
||||
this.sendError(res, 400, 'Missing required query params: component, file');
|
||||
return;
|
||||
}
|
||||
|
||||
// Security: reject path traversal attempts in the file param
|
||||
const resolvedFile = join(this.projectRoot, filePath);
|
||||
try {
|
||||
const real = realpathSync(resolvedFile);
|
||||
if (!real.startsWith(realpathSync(this.projectRoot))) {
|
||||
this.sendError(res, 403, 'Path traversal not allowed');
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
// File may not exist yet — that's OK, the import will fail at runtime
|
||||
}
|
||||
|
||||
const isDefault = await this.resolveIsDefault(componentName, filePath);
|
||||
|
||||
if (this.framework === 'next') {
|
||||
await this.handleNextJs(req, res, componentName, filePath, isDefault);
|
||||
} else {
|
||||
this.handleVite(res, componentName, filePath, isDefault);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Vite handler ──────────────────────────────────────────────────────────
|
||||
|
||||
private handleVite(
|
||||
res: ServerResponse,
|
||||
componentName: string,
|
||||
filePath: string,
|
||||
isDefault: boolean,
|
||||
): void {
|
||||
// Import path: use the file param as a root-relative path (Vite serves from root)
|
||||
const importPath = filePath.startsWith('/') ? filePath : `/${filePath}`;
|
||||
const html = VITE_HTML(componentName, importPath, isDefault);
|
||||
res.writeHead(200, {
|
||||
'content-type': 'text/html; charset=utf-8',
|
||||
'cache-control': 'no-store',
|
||||
});
|
||||
res.end(html);
|
||||
}
|
||||
|
||||
// ── Next.js handler ───────────────────────────────────────────────────────
|
||||
|
||||
private async handleNextJs(
|
||||
req: IncomingMessage,
|
||||
res: ServerResponse,
|
||||
componentName: string,
|
||||
filePath: string,
|
||||
isDefault: boolean,
|
||||
): Promise<void> {
|
||||
// Build an import path relative to the isolation page location.
|
||||
// The temp page lives in {appDir}/__om_isolation__/page.tsx, so the
|
||||
// component's import path is relative from there.
|
||||
// We use an absolute-from-root import (/@/... or relative to src/) that
|
||||
// Next.js resolves via tsconfig paths — or we use a relative path.
|
||||
// The simplest approach: use a root-relative path prefixed with '@/' if
|
||||
// the project uses the common Next.js path alias, or a relative path otherwise.
|
||||
const importPath = filePath.replace(/^src\//, '@/');
|
||||
|
||||
try {
|
||||
writeNextIsolationPage(this.projectRoot, componentName, importPath, isDefault);
|
||||
} catch (err) {
|
||||
if (err instanceof Error && err.message === 'no-next-router') {
|
||||
// Fall back to Vite-style inline HTML
|
||||
this.handleVite(res, componentName, filePath, isDefault);
|
||||
return;
|
||||
}
|
||||
this.sendError(res, 500, `Failed to write isolation page: ${err instanceof Error ? err.message : String(err)}`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Redirect to the temp Next.js page so the browser fetches the compiled page.
|
||||
const target = `${this.devServerBase}/${ISOLATION_DIR_NAME}?component=${encodeURIComponent(componentName)}&file=${encodeURIComponent(filePath)}`;
|
||||
res.writeHead(302, { Location: target, 'cache-control': 'no-store' });
|
||||
res.end();
|
||||
}
|
||||
|
||||
private sendError(res: ServerResponse, status: number, message: string): void {
|
||||
res.writeHead(status, { 'content-type': 'text/plain; charset=utf-8' });
|
||||
res.end(`Originmain Isolation Error: ${message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Legacy function-style entry point (for proxy.ts compatibility) ─────────────
|
||||
// The proxy.ts currently calls handleIsolationRequest(req, res) from a module-level
|
||||
// IsolationServer instance. Export a convenience function that delegates to a
|
||||
// default instance configured from environment variables.
|
||||
|
||||
let _defaultServer: IsolationServer | null = null;
|
||||
|
||||
export function initIsolationServer(opts: IsolationServerOptions): void {
|
||||
_defaultServer = new IsolationServer(opts);
|
||||
_defaultServer.cleanup(); // startup cleanup
|
||||
}
|
||||
|
||||
/** Handles any request to /__om_isolation__/* using the initialised server. */
|
||||
export async function handleIsolationRequest(
|
||||
req: IncomingMessage,
|
||||
res: ServerResponse,
|
||||
): void {
|
||||
res.writeHead(501, {
|
||||
'content-type': 'text/html; charset=utf-8',
|
||||
'cache-control': 'no-store',
|
||||
});
|
||||
res.end(STUB_BODY);
|
||||
): Promise<void> {
|
||||
if (!_defaultServer) {
|
||||
// Not yet initialised — return the stub response
|
||||
res.writeHead(503, { 'content-type': 'text/html; charset=utf-8' });
|
||||
res.end('<body style="font:13px monospace;padding:24px">Isolation server not initialised — call initIsolationServer() first.</body>');
|
||||
return;
|
||||
}
|
||||
return _defaultServer.handleRequest(req, res);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user