Flatten the repo: move everything out of app/ to the root
Now that the CLI and the Next.js app are one package, nesting it inside app/ served no purpose - the repo root itself becomes the published npm package. Merges app/.gitignore and app/README.md into the root versions, drops the now-duplicate app/LICENSE, and updates path references (README, docs/ARCHITECTURE.md, docs/CONFIG_REFERENCE.md, package.json's repository.directory) that assumed the app/ nesting. Also fixes a real bug this surfaced: the in-app docs viewer resolved docs/ relative to process.cwd(), which only worked by accident when the CLI happened to be invoked from app/'s parent directory. A first attempt at fixing it with import.meta.dirname broke instead, for the same cross-module-graph reason config-path resolution already documented - Next compiles Route Handlers through a separate module graph that doesn't preserve source-relative import.meta paths. Fixed by exposing the app root via TRIGGERSHELL_APP_ROOT (set once in server.ts, where import.meta *does* resolve correctly), the same pattern already used for TRIGGERSHELL_CONFIG_PATH. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,62 @@
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
import { eq } from "drizzle-orm";
|
||||
import { z } from "zod";
|
||||
import { getDb } from "@/lib/db/client";
|
||||
import { users } from "@/lib/db/schema";
|
||||
import { verifyPassword } from "@/lib/auth/password";
|
||||
import { getSession } from "@/lib/auth/session";
|
||||
import {
|
||||
isRateLimited,
|
||||
recordFailedAttempt,
|
||||
clearAttempts,
|
||||
} from "@/lib/auth/rate-limit";
|
||||
|
||||
const loginSchema = z.object({
|
||||
username: z.string().min(1),
|
||||
password: z.string().min(1),
|
||||
});
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const rateLimitKey = request.headers.get("x-forwarded-for") ?? "local";
|
||||
if (isRateLimited(rateLimitKey)) {
|
||||
return Response.json(
|
||||
{ error: "Too many attempts, try again later." },
|
||||
{ status: 429 },
|
||||
);
|
||||
}
|
||||
|
||||
const body = await request.json().catch(() => null);
|
||||
const parsed = loginSchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
return Response.json({ error: "Invalid request body" }, { status: 400 });
|
||||
}
|
||||
|
||||
const db = getDb();
|
||||
const user = db
|
||||
.select()
|
||||
.from(users)
|
||||
.where(eq(users.username, parsed.data.username))
|
||||
.get();
|
||||
|
||||
if (
|
||||
!user ||
|
||||
!(await verifyPassword(user.passwordHash, parsed.data.password))
|
||||
) {
|
||||
recordFailedAttempt(rateLimitKey);
|
||||
return Response.json({ error: "Invalid credentials" }, { status: 401 });
|
||||
}
|
||||
|
||||
clearAttempts(rateLimitKey);
|
||||
db.update(users)
|
||||
.set({ lastLoginAt: new Date() })
|
||||
.where(eq(users.id, user.id))
|
||||
.run();
|
||||
|
||||
const session = await getSession();
|
||||
session.userId = user.id;
|
||||
session.username = user.username;
|
||||
await session.save();
|
||||
|
||||
return Response.json({ user: { username: user.username } });
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
import { getSession } from "@/lib/auth/session";
|
||||
|
||||
export async function POST() {
|
||||
const session = await getSession();
|
||||
session.destroy();
|
||||
return new Response(null, { status: 204 });
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
import { requireAuth } from "@/lib/auth/guard";
|
||||
import { getConfig } from "@/lib/config/load";
|
||||
|
||||
export async function GET(request: Request) {
|
||||
const { config } = getConfig();
|
||||
const auth = await requireAuth(request);
|
||||
|
||||
return Response.json({
|
||||
authRequired: config.auth.enabled,
|
||||
authenticated: auth.authenticated,
|
||||
user:
|
||||
auth.authenticated && auth.identity ? { username: auth.identity } : null,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export async function GET() {
|
||||
return Response.json({
|
||||
ok: true,
|
||||
version: process.env.npm_package_version ?? "dev",
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
import { eq } from "drizzle-orm";
|
||||
import { requireAuth, unauthorizedResponse } from "@/lib/auth/guard";
|
||||
import { getDb } from "@/lib/db/client";
|
||||
import { runs } from "@/lib/db/schema";
|
||||
import { cancelRun } from "@/lib/runner/registry";
|
||||
|
||||
export async function POST(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ runId: string }> },
|
||||
) {
|
||||
const auth = await requireAuth(request);
|
||||
if (!auth.authenticated) return unauthorizedResponse();
|
||||
|
||||
const { runId } = await params;
|
||||
const db = getDb();
|
||||
const run = db.select().from(runs).where(eq(runs.id, runId)).get();
|
||||
if (!run) return Response.json({ error: "Run not found" }, { status: 404 });
|
||||
|
||||
if (run.status !== "queued" && run.status !== "running") {
|
||||
return Response.json(
|
||||
{ error: `Run already ${run.status}` },
|
||||
{ status: 409 },
|
||||
);
|
||||
}
|
||||
|
||||
const cancelled = cancelRun(runId);
|
||||
if (!cancelled) {
|
||||
return Response.json(
|
||||
{ error: "Run is not active in this server process" },
|
||||
{ status: 409 },
|
||||
);
|
||||
}
|
||||
|
||||
return Response.json({ status: "cancelling" }, { status: 202 });
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
import fs from "node:fs";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { requireAuth, unauthorizedResponse } from "@/lib/auth/guard";
|
||||
import { getDb } from "@/lib/db/client";
|
||||
import { runs } from "@/lib/db/schema";
|
||||
|
||||
export async function GET(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ runId: string }> },
|
||||
) {
|
||||
const auth = await requireAuth(request);
|
||||
if (!auth.authenticated) return unauthorizedResponse();
|
||||
|
||||
const { runId } = await params;
|
||||
const db = getDb();
|
||||
const run = db.select().from(runs).where(eq(runs.id, runId)).get();
|
||||
if (!run) return Response.json({ error: "Run not found" }, { status: 404 });
|
||||
|
||||
let content = fs.existsSync(run.logFilePath)
|
||||
? fs.readFileSync(run.logFilePath, "utf-8")
|
||||
: "";
|
||||
|
||||
const url = new URL(request.url);
|
||||
const tailParam = url.searchParams.get("tail");
|
||||
if (tailParam) {
|
||||
const tailLines = Math.max(1, Number(tailParam) || 500);
|
||||
content = content.split("\n").slice(-tailLines).join("\n");
|
||||
}
|
||||
|
||||
const headers: Record<string, string> = {
|
||||
"Content-Type": "text/plain; charset=utf-8",
|
||||
};
|
||||
if (url.searchParams.get("download") === "1") {
|
||||
headers["Content-Disposition"] = `attachment; filename="${runId}.log"`;
|
||||
}
|
||||
|
||||
return new Response(content, { headers });
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
import { eq } from "drizzle-orm";
|
||||
import { requireAuth, unauthorizedResponse } from "@/lib/auth/guard";
|
||||
import { getDb } from "@/lib/db/client";
|
||||
import { runs } from "@/lib/db/schema";
|
||||
|
||||
export async function GET(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ runId: string }> },
|
||||
) {
|
||||
const auth = await requireAuth(request);
|
||||
if (!auth.authenticated) return unauthorizedResponse();
|
||||
|
||||
const { runId } = await params;
|
||||
const db = getDb();
|
||||
const run = db.select().from(runs).where(eq(runs.id, runId)).get();
|
||||
if (!run) return Response.json({ error: "Run not found" }, { status: 404 });
|
||||
|
||||
return Response.json(run);
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
import { and, desc, eq } from "drizzle-orm";
|
||||
import { requireAuth, unauthorizedResponse } from "@/lib/auth/guard";
|
||||
import { getDb } from "@/lib/db/client";
|
||||
import { runs, runStatusValues, type RunStatus } from "@/lib/db/schema";
|
||||
|
||||
const DEFAULT_LIMIT = 50;
|
||||
const MAX_LIMIT = 200;
|
||||
|
||||
export async function GET(request: Request) {
|
||||
const auth = await requireAuth(request);
|
||||
if (!auth.authenticated) return unauthorizedResponse();
|
||||
|
||||
const url = new URL(request.url);
|
||||
const scriptId = url.searchParams.get("scriptId") ?? undefined;
|
||||
const statusParam = url.searchParams.get("status") ?? undefined;
|
||||
const status =
|
||||
statusParam && (runStatusValues as readonly string[]).includes(statusParam)
|
||||
? (statusParam as RunStatus)
|
||||
: undefined;
|
||||
const limit = Math.min(
|
||||
MAX_LIMIT,
|
||||
Math.max(1, Number(url.searchParams.get("limit")) || DEFAULT_LIMIT),
|
||||
);
|
||||
const offset = Math.max(0, Number(url.searchParams.get("cursor")) || 0);
|
||||
|
||||
const db = getDb();
|
||||
const conditions = [
|
||||
scriptId ? eq(runs.scriptId, scriptId) : undefined,
|
||||
status ? eq(runs.status, status) : undefined,
|
||||
].filter((c): c is NonNullable<typeof c> => Boolean(c));
|
||||
const whereClause = conditions.length ? and(...conditions) : undefined;
|
||||
|
||||
const rows = db
|
||||
.select()
|
||||
.from(runs)
|
||||
.where(whereClause)
|
||||
.orderBy(desc(runs.createdAt))
|
||||
.limit(limit + 1)
|
||||
.offset(offset)
|
||||
.all();
|
||||
|
||||
const hasMore = rows.length > limit;
|
||||
const page = hasMore ? rows.slice(0, limit) : rows;
|
||||
|
||||
return Response.json({
|
||||
runs: page,
|
||||
nextCursor: hasMore ? String(offset + limit) : null,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
import { requireAuth, unauthorizedResponse } from "@/lib/auth/guard";
|
||||
import { getScript } from "@/lib/config/load";
|
||||
import { serializeScriptForClient } from "@/lib/config/serialize";
|
||||
|
||||
export async function GET(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ scriptId: string }> },
|
||||
) {
|
||||
const auth = await requireAuth(request);
|
||||
if (!auth.authenticated) return unauthorizedResponse();
|
||||
|
||||
const { scriptId } = await params;
|
||||
const script = getScript(scriptId);
|
||||
if (!script)
|
||||
return Response.json({ error: "Script not found" }, { status: 404 });
|
||||
|
||||
return Response.json(serializeScriptForClient(script));
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
import { requireAuth, unauthorizedResponse } from "@/lib/auth/guard";
|
||||
import { getScript } from "@/lib/config/load";
|
||||
import { buildVariableSchema } from "@/lib/validation/variable-schema";
|
||||
import { startRun } from "@/lib/runner/engine";
|
||||
|
||||
export async function POST(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ scriptId: string }> },
|
||||
) {
|
||||
const auth = await requireAuth(request);
|
||||
if (!auth.authenticated) return unauthorizedResponse();
|
||||
|
||||
const { scriptId } = await params;
|
||||
const script = getScript(scriptId);
|
||||
if (!script)
|
||||
return Response.json({ error: "Script not found" }, { status: 404 });
|
||||
|
||||
const body = await request.json().catch(() => null);
|
||||
const variablesInput =
|
||||
(body && typeof body === "object" && "variables" in body
|
||||
? body.variables
|
||||
: body) ?? {};
|
||||
|
||||
const variableSchema = buildVariableSchema(script);
|
||||
const parsed = variableSchema.safeParse(variablesInput);
|
||||
if (!parsed.success) {
|
||||
return Response.json(
|
||||
{
|
||||
error: "Validation failed",
|
||||
fieldErrors: parsed.error.flatten().fieldErrors,
|
||||
},
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
const runId = await startRun({
|
||||
scriptId: script.id,
|
||||
variables: parsed.data,
|
||||
triggeredBy: auth.identity ?? "anonymous",
|
||||
});
|
||||
|
||||
return Response.json({ runId, status: "queued" }, { status: 201 });
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
import { requireAuth, unauthorizedResponse } from "@/lib/auth/guard";
|
||||
import { getConfig } from "@/lib/config/load";
|
||||
import { serializeScriptSummary } from "@/lib/config/serialize";
|
||||
|
||||
export async function GET(request: Request) {
|
||||
const auth = await requireAuth(request);
|
||||
if (!auth.authenticated) return unauthorizedResponse();
|
||||
|
||||
const { config } = getConfig();
|
||||
return Response.json({ scripts: config.scripts.map(serializeScriptSummary) });
|
||||
}
|
||||
Reference in New Issue
Block a user