@aexol/spectral 0.2.2 → 0.2.5

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/dist/cli.js CHANGED
@@ -124,8 +124,9 @@ function printHeader() {
124
124
  TAGLINE,
125
125
  "",
126
126
  "Subcommands:",
127
- " spectral login Authenticate with the Aexol MCP backend",
128
- " spectral logout Remove stored Aexol credentials",
127
+ " spectral login Authenticate with a team API key",
128
+ " spectral login --oauth Authorize via browser (Aexol Studio OAuth)",
129
+ " spectral logout Remove stored Aexol credentials",
129
130
  " spectral serve Connect this machine to the Aexol relay backend",
130
131
  " spectral bind Link this directory to an Aexol Studio project",
131
132
  " spectral unbind Remove the Aexol Studio project binding",
@@ -193,6 +194,11 @@ async function main() {
193
194
  // Subcommands. Dynamic import keeps the cold-start path light when users
194
195
  // are just running pi.
195
196
  if (first === "login") {
197
+ if (args[1] === "--oauth") {
198
+ const { runLoginOAuth } = await import("./commands/login-oauth.js");
199
+ await runLoginOAuth();
200
+ process.exit(0);
201
+ }
196
202
  const { runLogin } = await import("./commands/login.js");
197
203
  await runLogin();
198
204
  process.exit(0);
@@ -0,0 +1,116 @@
1
+ /**
2
+ * `spectral login --oauth` — browser-based OAuth login for CLI machine ownership.
3
+ *
4
+ * Flow:
5
+ * 1. Start a temporary HTTP server on a random localhost port.
6
+ * 2. Open the browser to `https://studio.aexol.ai/cli-auth?port=<PORT>`.
7
+ * 3. Wait for the browser to redirect to `http://localhost:<PORT>?token=<JWT>`.
8
+ * 4. Save the token to `~/.spectral/config.json` as `userJwt`.
9
+ * 5. Shutdown the HTTP server and exit.
10
+ *
11
+ * On failure (timeout, network error, user closes browser), the server shuts
12
+ * down after a configurable timeout (default 120s).
13
+ */
14
+ import { createServer } from "node:http";
15
+ import pc from "picocolors";
16
+ import { DEFAULT_API_URL, getConfigFile, readConfig, writeConfig, } from "../config.js";
17
+ const DEFAULT_BACKEND_URL = "https://studio.aexol.ai";
18
+ const CALLBACK_TIMEOUT_MS = 120_000; // 2 minutes
19
+ /**
20
+ * Start a temporary HTTP server to receive the OAuth callback.
21
+ * Returns the port and a promise that resolves with the JWT token.
22
+ */
23
+ function listenForCallback(timeoutMs) {
24
+ return new Promise((resolve, reject) => {
25
+ const server = createServer();
26
+ const timeout = setTimeout(() => {
27
+ server.close();
28
+ reject(new Error(`Timed out after ${timeoutMs / 1000}s waiting for browser authorization.`));
29
+ }, timeoutMs);
30
+ server.on("request", (req, res) => {
31
+ const url = new URL(req.url ?? "/", `http://localhost`);
32
+ const token = url.searchParams.get("token");
33
+ if (token) {
34
+ clearTimeout(timeout);
35
+ res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
36
+ res.end("<html><body><h1>✓ Authorized</h1><p>You can close this tab and return to your terminal.</p></body></html>");
37
+ resolve({ token, server });
38
+ }
39
+ else {
40
+ res.writeHead(400, { "Content-Type": "text/plain" });
41
+ res.end("Missing token parameter.");
42
+ }
43
+ });
44
+ server.on("error", (err) => {
45
+ clearTimeout(timeout);
46
+ server.close();
47
+ reject(new Error(`Failed to start local server: ${err.message}`));
48
+ });
49
+ server.listen(0, "127.0.0.1", () => {
50
+ // Port 0 lets the OS assign an ephemeral port — we read it from the server.
51
+ });
52
+ });
53
+ }
54
+ /** Derive the landing base URL from the backend URL. */
55
+ function deriveLandingUrl(backendUrl) {
56
+ const env = process.env.SPECTRAL_LANDING_URL;
57
+ if (env)
58
+ return env;
59
+ // If using local dev backend, use local dev landing
60
+ if (backendUrl.includes("localhost") || backendUrl.includes("127.0.0.1")) {
61
+ return "http://localhost:3000";
62
+ }
63
+ return DEFAULT_BACKEND_URL;
64
+ }
65
+ export async function runLoginOAuth() {
66
+ process.stdout.write(pc.bold("Spectral login (OAuth)\n"));
67
+ process.stdout.write(pc.dim(`Opens a browser to authorize the CLI. The JWT is stored at ${getConfigFile()}.\n\n`));
68
+ // Load existing config to get backendUrl
69
+ const existingCfg = await readConfig();
70
+ const landingUrl = deriveLandingUrl(existingCfg?.apiUrl ?? process.env.SPECTRAL_MCP_URL ?? DEFAULT_API_URL);
71
+ // Start the callback listener FIRST (port must be known before opening the browser)
72
+ let server;
73
+ let token;
74
+ try {
75
+ const result = await listenForCallback(CALLBACK_TIMEOUT_MS);
76
+ server = result.server;
77
+ const addr = server.address();
78
+ const port = addr.port;
79
+ // Open browser to the CLI auth page
80
+ const authUrl = `${landingUrl}/cli-auth?port=${port}`;
81
+ process.stdout.write(pc.dim(`Opening browser: ${authUrl}\n`));
82
+ const { exec } = await import("node:child_process");
83
+ const platform = process.platform;
84
+ const openCmd = platform === "darwin"
85
+ ? `open "${authUrl}"`
86
+ : platform === "win32"
87
+ ? `start "" "${authUrl}"`
88
+ : `xdg-open "${authUrl}"`;
89
+ exec(openCmd, (err) => {
90
+ if (err) {
91
+ process.stderr.write(pc.yellow(`⚠ Could not open browser automatically. Please visit:\n ${authUrl}\n`));
92
+ }
93
+ });
94
+ // Wait for the token
95
+ process.stdout.write(pc.dim("Waiting for authorization...\n"));
96
+ token = result.token;
97
+ }
98
+ catch (err) {
99
+ const msg = err instanceof Error ? err.message : String(err);
100
+ process.stderr.write(pc.red(`✗ ${msg}\n`));
101
+ process.exit(1);
102
+ }
103
+ finally {
104
+ server?.close();
105
+ }
106
+ // Save the token. Preserve existing config (especially teamApiKey).
107
+ const cfg = existingCfg ?? {
108
+ apiUrl: process.env.SPECTRAL_MCP_URL ?? DEFAULT_API_URL,
109
+ teamApiKey: "",
110
+ };
111
+ cfg.userJwt = token;
112
+ await writeConfig(cfg);
113
+ process.stdout.write(pc.green("✓ CLI authorized.\n"));
114
+ process.stdout.write(pc.dim(`Saved user JWT to ${getConfigFile()}\n`));
115
+ process.stdout.write(pc.dim("Run `spectral serve` to register your machine with owner info.\n"));
116
+ }
@@ -154,6 +154,7 @@ export async function runServe(opts = {}) {
154
154
  registration = await ensureMachineRegistered({
155
155
  backendUrl,
156
156
  apiKey: cfg.teamApiKey,
157
+ userJwt: cfg.userJwt,
157
158
  machineNameOverride: opts.machineName,
158
159
  version,
159
160
  fetchImpl: opts.fetchImpl,
package/dist/config.js CHANGED
@@ -59,7 +59,11 @@ export async function readConfig() {
59
59
  if (typeof parsed.apiUrl === "string" &&
60
60
  typeof parsed.teamApiKey === "string" &&
61
61
  parsed.teamApiKey.length > 0) {
62
- return { apiUrl: parsed.apiUrl, teamApiKey: parsed.teamApiKey };
62
+ return {
63
+ apiUrl: parsed.apiUrl,
64
+ teamApiKey: parsed.teamApiKey,
65
+ userJwt: typeof parsed.userJwt === "string" ? parsed.userJwt : undefined,
66
+ };
63
67
  }
64
68
  return null;
65
69
  }
@@ -29,6 +29,7 @@ const KNOWN_KEYS = new Set([
29
29
  "machineName",
30
30
  "machineJwt",
31
31
  "teamId",
32
+ "ownerId",
32
33
  "registeredAt",
33
34
  "hostname",
34
35
  "version",
@@ -77,6 +78,7 @@ export async function loadMachine() {
77
78
  machineName: parsed.machineName,
78
79
  machineJwt: parsed.machineJwt,
79
80
  teamId: typeof parsed.teamId === "string" ? parsed.teamId : undefined,
81
+ ownerId: typeof parsed.ownerId === "string" ? parsed.ownerId : undefined,
80
82
  registeredAt: parsed.registeredAt,
81
83
  hostname: parsed.hostname,
82
84
  version: parsed.version,
@@ -105,6 +107,8 @@ export async function saveMachine(rec) {
105
107
  };
106
108
  if (rec.teamId !== undefined)
107
109
  toWrite.teamId = rec.teamId;
110
+ if (rec.ownerId !== undefined)
111
+ toWrite.ownerId = rec.ownerId;
108
112
  if (rec.extra) {
109
113
  for (const [k, v] of Object.entries(rec.extra))
110
114
  toWrite[k] = v;
@@ -3,15 +3,19 @@
3
3
  *
4
4
  * Contract (Batch 1 backend):
5
5
  * POST <backend>/api/machines/register
6
- * Headers: Authorization: Bearer <teamApiKey>
6
+ * Headers: Authorization: Bearer <teamApiKey|userJwt>
7
7
  * Body: { name?: string, hostname: string, version: string, pid: number }
8
- * 200: { machineId: string, jwt: string, name: string }
8
+ * 200: { machineId: string, jwt: string, name: string, ownerId?: string }
9
+ *
10
+ * Auth priority:
11
+ * - If `userJwt` is set → used as Bearer token (machine gets ownerId)
12
+ * - Else → `apiKey` (team API key, machine gets no owner)
9
13
  *
10
14
  * Why this lives in its own module:
11
- * - It's the ONLY consumer of the team API key. Once registration succeeds,
12
- * the team key is dropped on the floor — only the short-lived `machineJwt`
13
- * is persisted (in `machine.json`) and threaded into `RelayClient`. Keeps
14
- * the blast radius of an in-memory leak minimal.
15
+ * - It's the ONLY consumer of the team API key / user JWT for registration.
16
+ * Once registration succeeds, the auth token is dropped on the floor —
17
+ * only the `machineJwt` is persisted (in `machine.json`) and threaded
18
+ * into `RelayClient`. Keeps the blast radius of an in-memory leak minimal.
15
19
  * - It owns JWT expiry decoding so `serve.ts` doesn't have to know about
16
20
  * JWT internals; a single boundary for "is the saved record still good".
17
21
  *
@@ -87,7 +91,7 @@ export async function ensureMachineRegistered(deps) {
87
91
  res = await fetchImpl(url, {
88
92
  method: "POST",
89
93
  headers: {
90
- Authorization: `Bearer ${deps.apiKey}`,
94
+ Authorization: `Bearer ${deps.userJwt ?? deps.apiKey}`,
91
95
  "Content-Type": "application/json",
92
96
  },
93
97
  body: JSON.stringify(body),
@@ -126,6 +130,7 @@ export async function ensureMachineRegistered(deps) {
126
130
  machineName: obj.name,
127
131
  machineJwt: obj.jwt,
128
132
  teamId: typeof obj.teamId === "string" ? obj.teamId : undefined,
133
+ ownerId: typeof obj.ownerId === "string" ? obj.ownerId : undefined,
129
134
  registeredAt: Date.now(),
130
135
  hostname: hostname(),
131
136
  version: deps.version,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aexol/spectral",
3
- "version": "0.2.2",
3
+ "version": "0.2.5",
4
4
  "description": "Always-on coding agent for Aexol — branded pi wrapper with relay-based browser access.",
5
5
  "type": "module",
6
6
  "private": false,
@@ -55,6 +55,9 @@
55
55
  "@mariozechner/pi-coding-agent": "^0.70.2",
56
56
  "better-sqlite3": "^12.9.0",
57
57
  "pi-mcp-adapter": "file:../packages/pi-mcp-adapter",
58
+ "@modelcontextprotocol/sdk": "^1.25.1",
59
+ "@modelcontextprotocol/ext-apps": "^1.2.2",
60
+ "open": "^10.2.0",
58
61
  "picocolors": "^1.1.1",
59
62
  "ws": "^8.20.0"
60
63
  },