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:
2026-08-16 11:14:01 +02:00
co-authored by Claude Sonnet 5
parent 30350d80f4
commit 3f379ca2ac
123 changed files with 65 additions and 104 deletions
+62
View File
@@ -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 } });
}
+9
View File
@@ -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 });
}
+16
View File
@@ -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,
});
}
+8
View File
@@ -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",
});
}
+37
View File
@@ -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 });
}
+40
View File
@@ -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 });
}
+21
View File
@@ -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);
}
+51
View File
@@ -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,
});
}
+20
View File
@@ -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 });
}
+13
View File
@@ -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) });
}