leedab 0.2.6 → 0.3.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/bin/leedab.js CHANGED
@@ -11,7 +11,7 @@ import { startGateway, stopGateway } from "../dist/gateway.js";
11
11
  import { loadConfig, initConfig } from "../dist/config/index.js";
12
12
  import { setupChannels } from "../dist/channels/index.js";
13
13
  import { runOnboard } from "../dist/onboard/index.js";
14
- import { startDashboard } from "../dist/dashboard/server.js";
14
+ import { startConsole, stopConsole } from "../dist/console-launcher.js";
15
15
  import { execBranded } from "../dist/brand.js";
16
16
  import { resolveOpenClawBin, openclawEnv } from "../dist/openclaw.js";
17
17
  import { addEntry, removeEntry, listEntries, encryptVault, decryptVault } from "../dist/vault.js";
@@ -203,7 +203,9 @@ program
203
203
  const { logPath } = await startGateway(config);
204
204
  clearInterval(rotate);
205
205
  spinner.succeed(chalk.green("LeedAB is live"));
206
- await startDashboard(config, parseInt(opts.dashboardPort));
206
+
207
+ // Boot the Next.js console (apps/console).
208
+ const { logPath: consoleLogPath } = await startConsole(parseInt(opts.dashboardPort));
207
209
 
208
210
  const enabledChannels = Object.entries(config.channels)
209
211
  .filter(([_, v]) => v?.enabled)
@@ -214,12 +216,13 @@ program
214
216
  console.log();
215
217
  const dashUrl = `http://localhost:${opts.dashboardPort}`;
216
218
  const link = `\x1b]8;;${dashUrl}\x1b\\${dashUrl}\x1b]8;;\x1b\\`;
217
- console.log(` ${chalk.cyan(link)} Dashboard`);
219
+ console.log(` ${chalk.cyan(link)} Console`);
218
220
  if (enabledChannels.length > 0) {
219
221
  console.log(` ${chalk.green("Channels")} ${enabledChannels.join(", ")}`);
220
222
  }
221
223
  console.log();
222
- console.log(chalk.dim(` Logs: ${logPath}`));
224
+ console.log(chalk.dim(` Gateway log: ${logPath}`));
225
+ console.log(chalk.dim(` Console log: ${consoleLogPath}`));
223
226
  console.log();
224
227
  } catch (err) {
225
228
  clearInterval(rotate);
@@ -230,14 +233,14 @@ program
230
233
 
231
234
  program
232
235
  .command("dashboard")
233
- .description("Open the setup dashboard (without starting the agent)")
236
+ .description("Open the LeedAB console (without starting the agent)")
234
237
  .option("-c, --config <path>", "Path to config file", resolve(STATE_DIR, "config.json"))
235
- .option("-p, --port <port>", "Dashboard port", "3000")
238
+ .option("-p, --port <port>", "Console port", "3000")
236
239
  .action(async (opts) => {
237
240
  try {
238
- const config = await loadConfig(opts.config);
239
- console.log(chalk.bold("\n LeedAB Dashboard\n"));
240
- await startDashboard(config, parseInt(opts.port));
241
+ await loadConfig(opts.config);
242
+ console.log(chalk.bold("\n LeedAB Console\n"));
243
+ await startConsole(parseInt(opts.port));
241
244
  console.log(chalk.dim(" Open the URL above in your browser.\n"));
242
245
  } catch (err) {
243
246
  console.error(chalk.red(`Failed: ${err.message}`));
@@ -283,6 +286,7 @@ program
283
286
  .command("stop")
284
287
  .description("Stop the LeedAB agent")
285
288
  .action(async () => {
289
+ await stopConsole().catch(() => {});
286
290
  await stopGateway();
287
291
  console.log(chalk.green("LeedAB agent stopped."));
288
292
  });
@@ -0,0 +1,11 @@
1
+ /**
2
+ * Start the console subprocess on the given port. Resolves once the
3
+ * subprocess is alive (we don't poll the port; `next start` exits on its
4
+ * own if the port is taken or the build is missing, so a process-error
5
+ * would surface there).
6
+ */
7
+ export declare function startConsole(port?: number): Promise<{
8
+ url: string;
9
+ logPath: string;
10
+ }>;
11
+ export declare function stopConsole(): Promise<void>;
@@ -0,0 +1,184 @@
1
+ // Spawn the LeedAB console (Next.js app at apps/console) as a subprocess.
2
+ // `leedab start` and `leedab dashboard` use this to swap the legacy
3
+ // src/dashboard server for the modern Next.js admin UI.
4
+ //
5
+ // Production-only: requires a built `.next` directory (`npm run build`
6
+ // inside apps/console). For dev, run `npm run dev` from apps/console
7
+ // directly — that gives hot reload and is independent of the gateway.
8
+ import { spawn } from "node:child_process";
9
+ import { createWriteStream, existsSync, readFileSync, unlinkSync, writeFileSync } from "node:fs";
10
+ import { mkdir } from "node:fs/promises";
11
+ import { resolve, dirname } from "node:path";
12
+ import { fileURLToPath } from "node:url";
13
+ import { STATE_DIR } from "./paths.js";
14
+ let consoleProcess = null;
15
+ // Cross-process handle for `leedab stop`. The leedab start process and the
16
+ // leedab stop process are different OS processes, so an in-memory ref to
17
+ // the spawned console isn't enough — stop reads the PID from this file.
18
+ const PID_FILE = resolve(STATE_DIR, "console.pid");
19
+ function writePidFile(pid) {
20
+ try {
21
+ writeFileSync(PID_FILE, String(pid), "utf8");
22
+ }
23
+ catch {
24
+ // best-effort — losing the pid file just means leedab stop falls back
25
+ // to the in-process handle, which is correct in the common case.
26
+ }
27
+ }
28
+ function readPidFile() {
29
+ try {
30
+ const raw = readFileSync(PID_FILE, "utf8").trim();
31
+ const pid = Number.parseInt(raw, 10);
32
+ return Number.isFinite(pid) && pid > 0 ? pid : null;
33
+ }
34
+ catch {
35
+ return null;
36
+ }
37
+ }
38
+ function clearPidFile() {
39
+ try {
40
+ unlinkSync(PID_FILE);
41
+ }
42
+ catch {
43
+ // already gone, fine
44
+ }
45
+ }
46
+ function isAlive(pid) {
47
+ try {
48
+ process.kill(pid, 0);
49
+ return true;
50
+ }
51
+ catch {
52
+ return false;
53
+ }
54
+ }
55
+ /** Walk up from this module to find the leedab repo root. */
56
+ function findRepoRoot() {
57
+ let dir = dirname(fileURLToPath(import.meta.url));
58
+ for (let i = 0; i < 10; i++) {
59
+ const pkgPath = resolve(dir, "package.json");
60
+ if (existsSync(pkgPath)) {
61
+ try {
62
+ const pkg = JSON.parse(readFileSync(pkgPath, "utf-8"));
63
+ if (pkg.name === "leedab")
64
+ return dir;
65
+ }
66
+ catch {
67
+ // ignore unreadable / non-JSON
68
+ }
69
+ }
70
+ const parent = resolve(dir, "..");
71
+ if (parent === dir)
72
+ break;
73
+ dir = parent;
74
+ }
75
+ throw new Error("Could not locate the leedab repo root from the console launcher.");
76
+ }
77
+ function consoleDir() {
78
+ return resolve(findRepoRoot(), "apps", "console");
79
+ }
80
+ /**
81
+ * Start the console subprocess on the given port. Resolves once the
82
+ * subprocess is alive (we don't poll the port; `next start` exits on its
83
+ * own if the port is taken or the build is missing, so a process-error
84
+ * would surface there).
85
+ */
86
+ export async function startConsole(port = 3000) {
87
+ const dir = consoleDir();
88
+ const buildDir = resolve(dir, ".next");
89
+ if (!existsSync(buildDir)) {
90
+ throw new Error(`Console build not found at ${buildDir}.\n` +
91
+ `Run \`cd ${dir} && npm run build\` once, then retry.`);
92
+ }
93
+ const nextBin = resolve(dir, "node_modules", ".bin", "next");
94
+ if (!existsSync(nextBin)) {
95
+ throw new Error(`Next.js binary not found at ${nextBin}.\n` +
96
+ `Run \`cd ${dir} && npm install\` first.`);
97
+ }
98
+ const logDir = resolve(STATE_DIR, "logs");
99
+ await mkdir(logDir, { recursive: true });
100
+ const today = new Date().toISOString().slice(0, 10);
101
+ const logPath = resolve(logDir, `console-${today}.log`);
102
+ const logStream = createWriteStream(logPath, { flags: "a" });
103
+ // If a previous run left a stale pid, clear it before spawning so a
104
+ // failed start doesn't leave us pointing at a dead process.
105
+ const stale = readPidFile();
106
+ if (stale && !isAlive(stale))
107
+ clearPidFile();
108
+ consoleProcess = spawn(nextBin, ["start", "-H", "127.0.0.1", "-p", String(port)], {
109
+ cwd: dir,
110
+ env: {
111
+ ...process.env,
112
+ LEEDAB_STATE_DIR: STATE_DIR,
113
+ NODE_ENV: "production",
114
+ },
115
+ stdio: ["inherit", "pipe", "pipe"],
116
+ });
117
+ if (consoleProcess.pid)
118
+ writePidFile(consoleProcess.pid);
119
+ if (consoleProcess.stdout)
120
+ consoleProcess.stdout.pipe(logStream);
121
+ if (consoleProcess.stderr)
122
+ consoleProcess.stderr.pipe(logStream);
123
+ consoleProcess.on("error", (err) => {
124
+ console.error(`Console process error: ${err.message}`);
125
+ });
126
+ consoleProcess.on("exit", () => {
127
+ // If the subprocess crashed or was killed externally, drop the pid
128
+ // file so the next stop/start sequence isn't confused.
129
+ clearPidFile();
130
+ });
131
+ // Clean up on parent SIGTERM/SIGINT alongside the gateway.
132
+ const cleanup = () => {
133
+ if (consoleProcess) {
134
+ consoleProcess.kill("SIGTERM");
135
+ consoleProcess = null;
136
+ }
137
+ clearPidFile();
138
+ };
139
+ process.once("SIGTERM", cleanup);
140
+ process.once("SIGINT", cleanup);
141
+ return { url: `http://localhost:${port}`, logPath };
142
+ }
143
+ export async function stopConsole() {
144
+ // Same-process path first: if we hold the handle, kill directly.
145
+ if (consoleProcess) {
146
+ consoleProcess.kill("SIGTERM");
147
+ consoleProcess = null;
148
+ clearPidFile();
149
+ return;
150
+ }
151
+ // Cross-process path (`leedab stop` from a different terminal): signal
152
+ // the PID we wrote at start time. If it's already gone or was reaped,
153
+ // clear the stale file and return cleanly.
154
+ const pid = readPidFile();
155
+ if (!pid)
156
+ return;
157
+ if (!isAlive(pid)) {
158
+ clearPidFile();
159
+ return;
160
+ }
161
+ try {
162
+ process.kill(pid, "SIGTERM");
163
+ }
164
+ catch {
165
+ // already gone in the race window — fine.
166
+ }
167
+ // Give next a moment to flush + exit; if it's still alive after the
168
+ // grace window, escalate to SIGKILL so the operator isn't left with a
169
+ // zombie they have to hunt with `lsof`.
170
+ for (let i = 0; i < 20; i++) {
171
+ if (!isAlive(pid))
172
+ break;
173
+ await new Promise((r) => setTimeout(r, 250));
174
+ }
175
+ if (isAlive(pid)) {
176
+ try {
177
+ process.kill(pid, "SIGKILL");
178
+ }
179
+ catch {
180
+ // already gone
181
+ }
182
+ }
183
+ clearPidFile();
184
+ }
package/dist/gateway.js CHANGED
@@ -25,6 +25,10 @@ export async function startGateway(config) {
25
25
  await seedWorkspace();
26
26
  // Auto-approve all tool executions (enterprise local deployment)
27
27
  await ensureAutoApprove(bin, env);
28
+ // Make sure the OpenAI-compat /v1/chat/completions HTTP endpoint is on.
29
+ // The Next.js console proxies every chat through it, and OpenClaw ships
30
+ // it disabled by default.
31
+ await ensureChatCompletionsEndpoint(stateDir);
28
32
  // Register channels before starting gateway
29
33
  await registerChannels(bin, stateDir, config);
30
34
  // Start gateway, pipe output to log file instead of terminal
@@ -129,6 +133,41 @@ async function registerChannels(bin, stateDir, config) {
129
133
  }
130
134
  }
131
135
  }
136
+ /**
137
+ * Idempotently flip `gateway.http.endpoints.chatCompletions.enabled` to true
138
+ * in ~/.leedab/openclaw.json. The console (Next.js, apps/console) proxies
139
+ * every chat call through that endpoint, and OpenClaw ships it disabled.
140
+ *
141
+ * Safe to run on every startGateway: if the file is missing (first run
142
+ * before onboard) we no-op; if the flag is already true we don't write.
143
+ */
144
+ async function ensureChatCompletionsEndpoint(stateDir) {
145
+ const configPath = resolve(stateDir, "openclaw.json");
146
+ let raw;
147
+ try {
148
+ raw = await readFile(configPath, "utf-8");
149
+ }
150
+ catch {
151
+ // No openclaw.json yet — onboard hasn't run. Skip; openclaw will create
152
+ // the file and a subsequent `leedab start` will flip the flag.
153
+ return;
154
+ }
155
+ let cfg;
156
+ try {
157
+ cfg = JSON.parse(raw);
158
+ }
159
+ catch {
160
+ return;
161
+ }
162
+ cfg.gateway = cfg.gateway ?? {};
163
+ cfg.gateway.http = cfg.gateway.http ?? {};
164
+ cfg.gateway.http.endpoints = cfg.gateway.http.endpoints ?? {};
165
+ cfg.gateway.http.endpoints.chatCompletions = cfg.gateway.http.endpoints.chatCompletions ?? {};
166
+ if (cfg.gateway.http.endpoints.chatCompletions.enabled === true)
167
+ return;
168
+ cfg.gateway.http.endpoints.chatCompletions.enabled = true;
169
+ await writeFile(configPath, JSON.stringify(cfg, null, 2) + "\n");
170
+ }
132
171
  /**
133
172
  * Set up exec-approvals so the agent can run tools without prompting.
134
173
  * Safe for local enterprise deployments where the admin controls the box.
package/dist/index.d.ts CHANGED
@@ -2,5 +2,5 @@ export { startGateway, stopGateway } from "./gateway.js";
2
2
  export { loadConfig, initConfig } from "./config/index.js";
3
3
  export { setupChannels } from "./channels/index.js";
4
4
  export { runOnboard } from "./onboard/index.js";
5
- export { startDashboard } from "./dashboard/server.js";
5
+ export { startConsole, stopConsole } from "./console-launcher.js";
6
6
  export type { LeedABConfig } from "./config/schema.js";
package/dist/index.js CHANGED
@@ -2,4 +2,4 @@ export { startGateway, stopGateway } from "./gateway.js";
2
2
  export { loadConfig, initConfig } from "./config/index.js";
3
3
  export { setupChannels } from "./channels/index.js";
4
4
  export { runOnboard } from "./onboard/index.js";
5
- export { startDashboard } from "./dashboard/server.js";
5
+ export { startConsole, stopConsole } from "./console-launcher.js";
package/dist/license.d.ts CHANGED
@@ -6,6 +6,8 @@ export interface LicenseInfo {
6
6
  seatsUsed: number;
7
7
  maxSeats: number;
8
8
  validatedAt: string;
9
+ expiresAt?: string;
10
+ signature?: string;
9
11
  email?: string;
10
12
  name?: string;
11
13
  orgName?: string;
package/dist/license.js CHANGED
@@ -38,6 +38,8 @@ export async function validateLicenseKey(key) {
38
38
  seatsUsed: data.seats_used ?? 0,
39
39
  maxSeats: data.max_seats ?? 1,
40
40
  validatedAt: new Date().toISOString(),
41
+ expiresAt: data.expires_at ?? undefined,
42
+ signature: data.signature ?? undefined,
41
43
  email: data.email ?? undefined,
42
44
  name: data.name ?? undefined,
43
45
  orgName: data.org_name ?? undefined,
@@ -5,6 +5,12 @@ export interface ProviderResult {
5
5
  }
6
6
  /**
7
7
  * Write the chosen model into .leedab/openclaw.json so the gateway actually uses it.
8
+ *
9
+ * Allowlist also gets every sibling model for the chosen provider, so the console's
10
+ * per-agent defaultModel (which can vary across COO, Procurement, CX, etc.) is
11
+ * accepted without re-onboarding. A single-model allowlist forces every agent to
12
+ * the same backend and surfaces as "Model X is not allowed for agent main" the
13
+ * first time a workflow routes through a non-primary agent.
8
14
  */
9
- export declare function updateOpenClawModel(openclawModel: string): Promise<void>;
15
+ export declare function updateOpenClawModel(openclawModel: string, providerValue?: string): Promise<void>;
10
16
  export declare function onboardProvider(): Promise<ProviderResult | null>;
@@ -14,7 +14,7 @@ const PROVIDERS = [
14
14
  models: [
15
15
  { name: "Claude Opus 4.7 — most capable", value: "claude-opus-4-7", openclaw: "anthropic/claude-opus-4-7" },
16
16
  { name: "Claude Sonnet 4.6 — fast, capable (recommended)", value: "claude-sonnet-4-6", openclaw: "anthropic/claude-sonnet-4-6" },
17
- { name: "Claude Haiku 4.5 — fastest, cheapest", value: "claude-haiku-4-5", openclaw: "anthropic/claude-haiku-4-5" },
17
+ { name: "Claude Haiku 4.5 — fastest, cheapest", value: "claude-haiku-4-5", openclaw: "anthropic/claude-haiku-4-5-20251001" },
18
18
  ],
19
19
  },
20
20
  {
@@ -56,7 +56,7 @@ const PROVIDERS = [
56
56
  models: [
57
57
  { name: "Claude Sonnet 4.6 via Bedrock", value: "anthropic.claude-sonnet-4-6", openclaw: "anthropic/claude-sonnet-4-6" },
58
58
  { name: "Claude Opus 4.7 via Bedrock", value: "anthropic.claude-opus-4-7", openclaw: "anthropic/claude-opus-4-7" },
59
- { name: "Claude Haiku 4.5 via Bedrock", value: "anthropic.claude-haiku-4-5", openclaw: "anthropic/claude-haiku-4-5" },
59
+ { name: "Claude Haiku 4.5 via Bedrock", value: "anthropic.claude-haiku-4-5", openclaw: "anthropic/claude-haiku-4-5-20251001" },
60
60
  ],
61
61
  },
62
62
  {
@@ -163,8 +163,14 @@ async function passKeyToOpenClaw(flag, apiKey) {
163
163
  }
164
164
  /**
165
165
  * Write the chosen model into .leedab/openclaw.json so the gateway actually uses it.
166
+ *
167
+ * Allowlist also gets every sibling model for the chosen provider, so the console's
168
+ * per-agent defaultModel (which can vary across COO, Procurement, CX, etc.) is
169
+ * accepted without re-onboarding. A single-model allowlist forces every agent to
170
+ * the same backend and surfaces as "Model X is not allowed for agent main" the
171
+ * first time a workflow routes through a non-primary agent.
166
172
  */
167
- export async function updateOpenClawModel(openclawModel) {
173
+ export async function updateOpenClawModel(openclawModel, providerValue) {
168
174
  const configPath = resolve(STATE_DIR, "openclaw.json");
169
175
  try {
170
176
  const raw = JSON.parse(await readFile(configPath, "utf-8"));
@@ -173,7 +179,17 @@ export async function updateOpenClawModel(openclawModel) {
173
179
  if (!raw.agents.defaults)
174
180
  raw.agents.defaults = {};
175
181
  raw.agents.defaults.model = { primary: openclawModel };
176
- raw.agents.defaults.models = { [openclawModel]: {} };
182
+ const allowed = new Set([openclawModel]);
183
+ if (providerValue) {
184
+ const provider = PROVIDERS.find(p => p.value === providerValue);
185
+ if (provider)
186
+ for (const m of provider.models)
187
+ allowed.add(m.openclaw);
188
+ }
189
+ const models = {};
190
+ for (const id of allowed)
191
+ models[id] = {};
192
+ raw.agents.defaults.models = models;
177
193
  await writeFile(configPath, JSON.stringify(raw, null, 2) + "\n");
178
194
  }
179
195
  catch {
@@ -203,7 +219,7 @@ export async function onboardProvider() {
203
219
  if (existing.provider.flag) {
204
220
  await passKeyToOpenClaw(existing.provider.flag, apiKey);
205
221
  }
206
- await updateOpenClawModel(model.openclaw);
222
+ await updateOpenClawModel(model.openclaw, existing.provider.value);
207
223
  console.log(chalk.green(` Using ${existing.provider.name}. Model: ${model.value}`));
208
224
  return {
209
225
  provider: existing.provider.value,
@@ -238,7 +254,7 @@ export async function onboardProvider() {
238
254
  }
239
255
  const bedrockProvider = PROVIDERS.find((p) => p.value === "bedrock");
240
256
  const model = await pickModel(bedrockProvider);
241
- await updateOpenClawModel(model.openclaw);
257
+ await updateOpenClawModel(model.openclaw, "bedrock");
242
258
  return {
243
259
  provider: "bedrock",
244
260
  apiKey: "",
@@ -283,7 +299,7 @@ export async function onboardProvider() {
283
299
  await passKeyToOpenClaw(selected.flag, trimmedKey);
284
300
  }
285
301
  const model = await pickModel(selected);
286
- await updateOpenClawModel(model.openclaw);
302
+ await updateOpenClawModel(model.openclaw, selected.value);
287
303
  console.log(chalk.green(`\n Using ${selected.name}. Model: ${model.value}`));
288
304
  return {
289
305
  provider: selected.value,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "leedab",
3
- "version": "0.2.6",
3
+ "version": "0.3.0",
4
4
  "description": "LeedAB — Your enterprise AI agent. Local-first, private by default.",
5
5
  "license": "SEE LICENSE IN LICENSE",
6
6
  "author": "LeedAB <hello@leedab.com>",
@@ -16,7 +16,7 @@
16
16
  "LICENSE"
17
17
  ],
18
18
  "scripts": {
19
- "build": "tsc && rm -rf dist/dashboard/static dist/templates && cp -r src/dashboard/static dist/dashboard/static && cp -r src/templates dist/templates",
19
+ "build": "tsc && rm -rf dist/templates && cp -r src/templates dist/templates",
20
20
  "dev": "tsc --watch",
21
21
  "start": "node bin/leedab.js",
22
22
  "lint": "eslint src/",
@@ -1,17 +0,0 @@
1
- import { type IncomingMessage, type ServerResponse } from "node:http";
2
- import type { LeedABConfig } from "../config/schema.js";
3
- import { type ChannelName } from "../team/permissions.js";
4
- type RouteHandler = (req: IncomingMessage, res: ServerResponse, url: URL) => Promise<void>;
5
- export declare function createRoutes(config: LeedABConfig): Record<string, RouteHandler>;
6
- /**
7
- * Build the permission preamble injected into the agent prompt for a given
8
- * channel message. Returns null when we have nothing to add (unknown user
9
- * on a non-dashboard channel — the allowlist will already have rejected
10
- * those).
11
- *
12
- * This preamble is the sole workflow-permission gate. We trust the agent to
13
- * honor it. There is no server-side hard gate; admins restrict access by
14
- * naming the workflows a member may use in the team page.
15
- */
16
- export declare function buildPermissionPreamble(channel: ChannelName, userId: string): Promise<string | null>;
17
- export {};