Skip to content

Commit 53d5925

Browse files
cevianclaude
andcommitted
feat(cli): add crayon local run-dev for testing cloud features locally
Starts the auth-server as a background process alongside the dev UI so cloud features (cron scheduling, webhook tokens, Schedule/Trigger tabs) work against localhost. Only available in the monorepo — the command is conditionally registered based on auth-server directory existence. Changes: - New `local run-dev` CLI command (guarded to monorepo only) - startLocalAuthServer() spawns `next dev` on port 3000, waits for health, sets CRAYON_SERVER_URL + CRAYON_TOKEN, registers cleanup - Widen isCloud detection to include CRAYON_SERVER_URL (not just FLY_APP_NAME) - TriggerSection fetches appUrl from /api/claude-command instead of hardcoding hostname check - Updated DEVELOPMENT.md with local dev instructions Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 0f85bce commit 53d5925

File tree

5 files changed

+143
-18
lines changed

5 files changed

+143
-18
lines changed

DEVELOPMENT.md

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,12 +7,35 @@ git clone https://github.com/timescale/crayon.git
77
cd crayon
88
pnpm install
99
pnpm build
10-
npx tsx packages/core/src/cli/index.ts install --force
1110
```
1211

1312
> **Note:** This outputs the `claude --plugin-dir <path>` command you need to run Claude Code with the local plugin.
1413
15-
## Testing Local Changes Against Cloud
14+
## Local Development
15+
16+
`crayon local run-dev` starts the auth-server alongside the dev UI so you can test cloud features (cron scheduling, webhook tokens) locally. This command is only available when running from the monorepo.
17+
18+
### Prerequisites
19+
20+
1. **Auth-server `.env.local`** must exist at `packages/auth-server/.env.local` with the required env vars (see `packages/auth-server/README.md`). It should point to the same `DATABASE_URL` as the deployed auth-server so your CLI token works. Contents can be found in 1password "Crayon auth-server secrets".
21+
22+
2. **CLI login** — run `crayon login` once so `~/.crayon/credentials` has a valid token.
23+
24+
### Usage
25+
26+
```bash
27+
npx tsx /path/to/crayon/packages/core/src/cli/index.ts local run-dev
28+
```
29+
30+
This will:
31+
- Start the auth-server on `http://localhost:3000`
32+
- Set `CRAYON_SERVER_URL` and `CRAYON_TOKEN` automatically
33+
- Launch the dev UI with cloud features enabled (webhook section, cron scheduling)
34+
- Open the browser and start Claude Code
35+
36+
37+
38+
## Testing Local Changes On Cloud
1639

1740
To test local core changes on a cloud dev machine:
1841

packages/core/dev-ui/src/components/TriggerSection.tsx

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { useState, useCallback } from "react";
1+
import { useState, useCallback, useEffect } from "react";
22
import type { WorkflowDAG } from "../types";
33

44
interface TriggerSectionProps {
@@ -49,8 +49,17 @@ export function TriggerSection({ dag, onSuccess }: TriggerSectionProps) {
4949
const [webhookExpiry, setWebhookExpiry] = useState("30d");
5050
const [generatingToken, setGeneratingToken] = useState(false);
5151

52-
const isCloud = typeof window !== "undefined" && window.location.hostname.endsWith(".fly.dev");
53-
const appUrl = isCloud ? `${window.location.origin}/dev` : "";
52+
// Fetch cloud info from the backend
53+
const [cloudInfo, setCloudInfo] = useState<{ isCloud: boolean; appUrl: string } | null>(null);
54+
useEffect(() => {
55+
fetch("/dev/api/claude-command")
56+
.then((r) => r.json())
57+
.then((data) => setCloudInfo({ isCloud: data.isCloud, appUrl: data.appUrl ?? "" }))
58+
.catch(() => {});
59+
}, []);
60+
61+
const isCloud = cloudInfo?.isCloud ?? false;
62+
const appUrl = cloudInfo?.appUrl ?? "";
5463

5564
const buildInput = useCallback((): Record<string, unknown> => {
5665
if (useRawJson) {

packages/core/src/cli/index.ts

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,9 @@
22
import { Command } from "commander";
33
import pc from "picocolors";
44
import Table from "cli-table3";
5-
import { readFileSync } from "node:fs";
5+
import { readFileSync, existsSync } from "node:fs";
66
import { randomUUID } from "node:crypto";
7-
import { dirname, resolve } from "node:path";
7+
import { dirname, resolve, join } from "node:path";
88
import { fileURLToPath } from "node:url";
99
import { DBOS } from "@dbos-inc/dbos-sdk";
1010
import { createCrayon } from "../index.js";
@@ -98,6 +98,17 @@ local
9898
await runRun();
9999
});
100100

101+
// Only register run-dev in the monorepo (auth-server must exist nearby)
102+
const authServerDir = resolve(__dirname, "../../../auth-server");
103+
if (existsSync(join(authServerDir, "package.json"))) {
104+
local
105+
.command("run-dev")
106+
.description("Launch with local auth-server (for testing cloud features)")
107+
.action(async () => {
108+
await runRun({ withAuthServer: true });
109+
});
110+
}
111+
101112
// ============ Build command ============
102113
program
103114
.command("build")

packages/core/src/cli/run.ts

Lines changed: 88 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
1-
import { exec, execSync, spawn } from "node:child_process";
1+
import { exec, execSync, spawn, type ChildProcess } from "node:child_process";
22
import { existsSync, readFileSync, readdirSync } from "node:fs";
33
import { homedir } from "node:os";
44
import { promisify } from "node:util";
5-
import { join, resolve } from "node:path";
5+
import { join, resolve, dirname } from "node:path";
6+
import { fileURLToPath } from "node:url";
67
import * as p from "@clack/prompts";
78
import pc from "picocolors";
89
import * as dotenv from "dotenv";
@@ -202,7 +203,81 @@ function discoverProjects(): ProjectInfo[] {
202203
return projects;
203204
}
204205

205-
async function launchExistingProject(projectPath: string): Promise<void> {
206+
/**
207+
* Resolve the auth-server directory relative to this file's location in the monorepo.
208+
* Returns null if not in the monorepo (e.g., published npm package).
209+
*/
210+
function getAuthServerDir(): string | null {
211+
const __dirname = dirname(fileURLToPath(import.meta.url));
212+
const candidate = resolve(__dirname, "../../../auth-server");
213+
return existsSync(join(candidate, "package.json")) ? candidate : null;
214+
}
215+
216+
/**
217+
* Start the auth-server as a local Next.js dev process.
218+
* Sets CRAYON_SERVER_URL and CRAYON_TOKEN in process.env.
219+
* Registers cleanup handlers to kill the child on exit.
220+
*/
221+
async function startLocalAuthServer(): Promise<void> {
222+
const authServerDir = getAuthServerDir();
223+
if (!authServerDir) {
224+
throw new Error("Auth-server not found. This command is only available in the crayon monorepo.");
225+
}
226+
if (!existsSync(join(authServerDir, ".env.local"))) {
227+
throw new Error(`auth-server/.env.local not found. Create it first — see packages/auth-server/README.md`);
228+
}
229+
230+
const { getToken } = await import("../connections/cloud-auth.js");
231+
const token = getToken();
232+
if (!token) {
233+
throw new Error("Not authenticated with crayon cloud. Run `crayon login` first.");
234+
}
235+
236+
p.log.info("Starting local auth-server on port 3000...");
237+
238+
const child = spawn("npx", ["next", "dev", "--port", "3000"], {
239+
cwd: authServerDir,
240+
stdio: "pipe",
241+
env: { ...process.env },
242+
});
243+
244+
// Log auth-server errors to stderr
245+
child.stderr?.on("data", (data: Buffer) => {
246+
const msg = data.toString().trim();
247+
if (msg) process.stderr.write(`[auth-server] ${msg}\n`);
248+
});
249+
250+
// Cleanup on exit
251+
const cleanup = () => { try { child.kill("SIGTERM"); } catch {} };
252+
process.on("exit", cleanup);
253+
process.on("SIGINT", () => { cleanup(); process.exit(0); });
254+
process.on("SIGTERM", () => { cleanup(); process.exit(0); });
255+
256+
// Wait for health
257+
const timeout = 30_000;
258+
const start = Date.now();
259+
while (Date.now() - start < timeout) {
260+
try {
261+
const res = await fetch("http://localhost:3000");
262+
if (res.ok || res.status < 500) break;
263+
} catch {
264+
// not ready yet
265+
}
266+
await new Promise((r) => setTimeout(r, 500));
267+
}
268+
269+
if (Date.now() - start >= timeout) {
270+
cleanup();
271+
throw new Error("Auth-server failed to start within 30 seconds");
272+
}
273+
274+
p.log.success("Auth-server ready at http://localhost:3000");
275+
276+
process.env.CRAYON_SERVER_URL = "http://localhost:3000";
277+
process.env.CRAYON_TOKEN = token;
278+
}
279+
280+
async function launchExistingProject(projectPath: string, opts?: { withAuthServer?: boolean }): Promise<void> {
206281
// Check if database is paused and start it if needed
207282
try {
208283
const { findEnvFile } = await import("./env.js");
@@ -246,10 +321,10 @@ async function launchExistingProject(projectPath: string): Promise<void> {
246321
}
247322

248323
p.outro(pc.green("Launching..."));
249-
await launchDevServer(projectPath, { yolo: mode === "yolo" });
324+
await launchDevServer(projectPath, { yolo: mode === "yolo", withAuthServer: opts?.withAuthServer });
250325
}
251326

252-
async function launchDevServer(cwd: string, { yolo = false }: { yolo?: boolean } = {}): Promise<void> {
327+
async function launchDevServer(cwd: string, { yolo = false, withAuthServer = false }: { yolo?: boolean; withAuthServer?: boolean } = {}): Promise<void> {
253328
// Load .env from the app directory (not process.cwd(), which may be a parent)
254329
try {
255330
const { findEnvFile, loadEnv } = await import("./env.js");
@@ -259,6 +334,10 @@ async function launchDevServer(cwd: string, { yolo = false }: { yolo?: boolean }
259334
// Dev UI can work without env
260335
}
261336

337+
if (withAuthServer) {
338+
await startLocalAuthServer();
339+
}
340+
262341
const { startDevServer } = await import("../dev-ui/index.js");
263342
const { url } = await startDevServer({
264343
projectRoot: cwd,
@@ -282,7 +361,7 @@ async function launchDevServer(cwd: string, { yolo = false }: { yolo?: boolean }
282361
claude.on("exit", (code) => process.exit(code ?? 0));
283362
}
284363

285-
export async function runRun(): Promise<void> {
364+
export async function runRun(opts?: { withAuthServer?: boolean }): Promise<void> {
286365
p.intro(pc.red("crayon"));
287366

288367
if (!isClaudeAvailable()) {
@@ -292,7 +371,7 @@ export async function runRun(): Promise<void> {
292371

293372
// ── Existing project (CWD) → launch directly ───────────────────────
294373
if (isExistingcrayon()) {
295-
await launchExistingProject(process.cwd());
374+
await launchExistingProject(process.cwd(), opts);
296375
return;
297376
}
298377

@@ -323,7 +402,7 @@ export async function runRun(): Promise<void> {
323402
}
324403

325404
if (projectChoice !== CREATE_NEW) {
326-
await launchExistingProject(projectChoice);
405+
await launchExistingProject(projectChoice, opts);
327406
return;
328407
}
329408
}
@@ -553,7 +632,7 @@ export async function runRun(): Promise<void> {
553632

554633
if (!p.isCancel(launchChoice) && launchChoice !== "no") {
555634
p.outro(pc.green("Launching..."));
556-
await launchDevServer(resolve(appPath), { yolo: launchChoice === "yolo" });
635+
await launchDevServer(resolve(appPath), { yolo: launchChoice === "yolo", withAuthServer: opts?.withAuthServer });
557636
return;
558637
}
559638

packages/core/src/dev-ui/dev-server.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -153,10 +153,13 @@ export async function startDevServer(options: DevServerOptions) {
153153

154154
// Claude command hint (no database required)
155155
if (devPath === "/api/claude-command" && req.method === "GET") {
156-
const isCloud = !!process.env.FLY_APP_NAME;
156+
const isCloud = !!(process.env.FLY_APP_NAME || process.env.CRAYON_SERVER_URL);
157157
const appName = process.env.APP_NAME;
158+
const appUrl = process.env.FLY_APP_NAME
159+
? `https://${process.env.FLY_APP_NAME}.fly.dev/dev`
160+
: `http://localhost:${actualPort}/dev`;
158161
res.writeHead(200, { "Content-Type": "application/json", "Access-Control-Allow-Origin": "*" });
159-
res.end(JSON.stringify({ projectRoot, isCloud, appName }));
162+
res.end(JSON.stringify({ projectRoot, isCloud, appName, appUrl }));
160163
return;
161164
}
162165

0 commit comments

Comments
 (0)