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
+41
View File
@@ -0,0 +1,41 @@
export const dynamic = "force-dynamic";
import type { Metadata } from "next";
import Link from "next/link";
import { notFound } from "next/navigation";
import { ArrowLeft } from "lucide-react";
import { getDocMeta, readDocContent } from "@/lib/docs";
import { MarkdownViewer } from "@/components/docs/markdown-viewer";
interface DocPageProps {
params: Promise<{ slug: string }>;
}
export async function generateMetadata({
params,
}: DocPageProps): Promise<Metadata> {
const { slug } = await params;
return { title: getDocMeta(slug)?.title ?? "Doc not found" };
}
export default async function DocPage({ params }: DocPageProps) {
const { slug } = await params;
const doc = getDocMeta(slug);
if (!doc) notFound();
const content = readDocContent(doc);
if (content === null) notFound();
return (
<div className="mx-auto flex max-w-3xl flex-col gap-6">
<Link
href="/docs"
className="text-muted-foreground hover:text-foreground flex w-fit items-center gap-1 text-sm"
>
<ArrowLeft className="size-3.5" />
All docs
</Link>
<MarkdownViewer content={content} />
</div>
);
}
+35
View File
@@ -0,0 +1,35 @@
export const dynamic = "force-dynamic";
import type { Metadata } from "next";
import Link from "next/link";
import { BookOpen, ChevronRight } from "lucide-react";
import { DOCS } from "@/lib/docs";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
export const metadata: Metadata = { title: "Docs" };
export default function DocsIndexPage() {
return (
<div className="flex flex-col gap-4">
<h1 className="text-2xl font-semibold tracking-tight">Docs</h1>
<div className="grid gap-3 sm:grid-cols-2">
{DOCS.map((doc) => (
<Link key={doc.slug} href={`/docs/${doc.slug}`}>
<Card className="hover:border-foreground/30 h-full transition-colors">
<CardHeader className="flex flex-row items-start justify-between gap-2 space-y-0">
<div className="flex items-center gap-2">
<BookOpen className="text-muted-foreground size-4.5 shrink-0" />
<CardTitle className="text-base">{doc.title}</CardTitle>
</div>
<ChevronRight className="text-muted-foreground size-4 shrink-0" />
</CardHeader>
<CardContent className="text-muted-foreground text-sm">
{doc.description}
</CardContent>
</Card>
</Link>
))}
</div>
</div>
);
}
+25
View File
@@ -0,0 +1,25 @@
export const dynamic = "force-dynamic";
import { getConfig } from "@/lib/config/load";
import { requireAuth } from "@/lib/auth/guard";
import { Nav } from "@/components/layout/nav";
import { Footer } from "@/components/layout/footer";
export default async function AppLayout({
children,
}: {
children: React.ReactNode;
}) {
const { config } = getConfig();
const auth = await requireAuth();
return (
<div className="flex min-h-full flex-1 flex-col">
<Nav authEnabled={config.auth.enabled} username={auth.identity} />
<main className="mx-auto w-full max-w-5xl flex-1 px-4 py-8">
{children}
</main>
<Footer />
</div>
);
}
+49
View File
@@ -0,0 +1,49 @@
export const dynamic = "force-dynamic";
import type { Metadata } from "next";
import Link from "next/link";
import { ChevronRight, PlayCircle } from "lucide-react";
import { getConfig } from "@/lib/config/load";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
export const metadata: Metadata = { title: "Scripts" };
export default function DashboardPage() {
const { config } = getConfig();
if (config.scripts.length === 0) {
return (
<div className="text-muted-foreground py-24 text-center">
No scripts configured yet. Add entries under{" "}
<code className="bg-muted rounded px-1.5 py-0.5">scripts:</code> in your
config file.
</div>
);
}
return (
<div className="flex flex-col gap-4">
<h1 className="text-2xl font-semibold tracking-tight">Scripts</h1>
<div className="grid gap-3 sm:grid-cols-2">
{config.scripts.map((script) => (
<Link key={script.id} href={`/scripts/${script.id}`}>
<Card className="hover:border-foreground/30 h-full transition-colors">
<CardHeader className="flex flex-row items-start justify-between gap-2 space-y-0">
<div className="flex items-center gap-2">
<PlayCircle className="text-muted-foreground size-4.5 shrink-0" />
<CardTitle className="text-base">{script.name}</CardTitle>
</div>
<ChevronRight className="text-muted-foreground size-4 shrink-0" />
</CardHeader>
{script.description && (
<CardContent className="text-muted-foreground text-sm">
{script.description}
</CardContent>
)}
</Card>
</Link>
))}
</div>
</div>
);
}
+140
View File
@@ -0,0 +1,140 @@
export const dynamic = "force-dynamic";
import type { Metadata } from "next";
import Link from "next/link";
import { eq } from "drizzle-orm";
import { notFound } from "next/navigation";
import { RotateCw } from "lucide-react";
import { getDb } from "@/lib/db/client";
import { runs } from "@/lib/db/schema";
import { getScript } from "@/lib/config/load";
import { readLogTail } from "@/lib/runner/log-file";
import { RunTerminal } from "@/components/runs/run-terminal";
import { buttonVariants } from "@/components/ui/button";
import {
Card,
CardAction,
CardContent,
CardHeader,
CardTitle,
} from "@/components/ui/card";
interface RunDetailPageProps {
params: Promise<{ runId: string }>;
}
function formatVariableValue(value: unknown): string {
if (value === undefined || value === null || value === "") return "—";
if (Array.isArray(value)) return value.length ? value.join(", ") : "—";
if (typeof value === "boolean") return value ? "true" : "false";
return String(value);
}
export async function generateMetadata({
params,
}: RunDetailPageProps): Promise<Metadata> {
const { runId } = await params;
const run = getDb()
.select({ scriptName: runs.scriptName })
.from(runs)
.where(eq(runs.id, runId))
.get();
return { title: run ? `${run.scriptName} run` : "Run not found" };
}
export default async function RunDetailPage({ params }: RunDetailPageProps) {
const { runId } = await params;
const db = getDb();
const run = db.select().from(runs).where(eq(runs.id, runId)).get();
if (!run) notFound();
const { text: initialLog, size: initialLogBytes } = readLogTail(
run.logFilePath,
);
const script = getScript(run.scriptId);
const variableEntries = script
? script.variables.map((variable) => ({
key: variable.name,
label: variable.label ?? variable.name,
value: run.variables[variable.name],
}))
: Object.entries(run.variables).map(([key, value]) => ({
key,
label: key,
value,
}));
return (
<div className="mx-auto flex max-w-2xl flex-col gap-4">
<Card>
<CardHeader>
<CardTitle>{run.scriptName}</CardTitle>
{script && (
<CardAction>
<Link
href={`/scripts/${run.scriptId}?fromRun=${run.id}`}
className={buttonVariants({ variant: "outline", size: "sm" })}
>
<RotateCw className="size-3.5" />
Re-run
</Link>
</CardAction>
)}
<dl className="text-muted-foreground grid grid-cols-2 gap-x-4 gap-y-1 text-xs sm:grid-cols-3">
<div>
<dt className="font-medium">Triggered by</dt>
<dd>{run.triggeredBy}</dd>
</div>
<div>
<dt className="font-medium">Started</dt>
<dd>
{run.startedAt ? new Date(run.startedAt).toLocaleString() : "-"}
</dd>
</div>
<div>
<dt className="font-medium">Run ID</dt>
<dd className="truncate font-mono">{run.id}</dd>
</div>
</dl>
</CardHeader>
<CardContent className="flex flex-col gap-4">
<div className="flex flex-col gap-1.5">
<span className="text-muted-foreground text-xs font-medium">
Command
</span>
<pre className="bg-muted overflow-x-auto rounded-md p-3 font-mono text-xs whitespace-pre">
{run.resolvedCommand}
</pre>
</div>
{variableEntries.length > 0 && (
<div className="flex flex-col gap-1.5">
<span className="text-muted-foreground text-xs font-medium">
Variables
</span>
<dl className="grid gap-x-6 gap-y-2 rounded-md border p-3 text-xs sm:grid-cols-2">
{variableEntries.map(({ key, label, value }) => (
<div key={key} className="flex flex-col gap-0.5">
<dt className="text-muted-foreground">{label}</dt>
<dd className="font-mono break-all">
{formatVariableValue(value)}
</dd>
</div>
))}
</dl>
</div>
)}
<RunTerminal
runId={run.id}
initialStatus={run.status}
initialLog={initialLog}
initialLogBytes={initialLogBytes}
initialExitCode={run.exitCode}
/>
</CardContent>
</Card>
</div>
);
}
+268
View File
@@ -0,0 +1,268 @@
export const dynamic = "force-dynamic";
import type { Metadata } from "next";
import Link from "next/link";
import { ArrowDown, ArrowUp, ArrowUpDown, ChevronLeft, ChevronRight } from "lucide-react";
import { and, asc, desc, eq, like, or, sql, type SQL } from "drizzle-orm";
import { getDb } from "@/lib/db/client";
import { runs } from "@/lib/db/schema";
import { RunStatusBadge, runStatusLabels } from "@/components/runs/run-status-badge";
import { RunsToolbar } from "@/components/runs/runs-toolbar";
import { buttonVariants } from "@/components/ui/button";
import { cn } from "@/lib/utils";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
export const metadata: Metadata = { title: "Run History" };
const PAGE_SIZE = 25;
// Duration isn't a stored column - it's derived from started_at/ended_at (both unix seconds), so
// it needs a SQL expression rather than a plain column reference to be sortable. Still-running
// rows (ended_at IS NULL) count as elapsed-so-far; never-started (queued) rows sort as NULL,
// which SQLite puts first in ASC / last in DESC - keeping them out of the way either direction.
const durationExpr = sql`case when ${runs.startedAt} is null then null else coalesce(${runs.endedAt}, unixepoch()) - ${runs.startedAt} end`;
const SORT_COLUMNS = {
script: runs.scriptName,
status: runs.status,
triggeredBy: runs.triggeredBy,
started: runs.startedAt,
duration: durationExpr,
} as const;
type SortKey = keyof typeof SORT_COLUMNS;
const DEFAULT_SORT: SortKey = "started";
function isSortKey(value: string | undefined): value is SortKey {
return !!value && value in SORT_COLUMNS;
}
function formatDuration(startedAt: Date | null, endedAt: Date | null): string {
if (!startedAt) return "-";
const end = endedAt ?? new Date();
const seconds = Math.max(
0,
Math.round((end.getTime() - startedAt.getTime()) / 1000),
);
if (seconds < 60) return `${seconds}s`;
const minutes = Math.floor(seconds / 60);
return `${minutes}m ${seconds % 60}s`;
}
interface RunsPageProps {
searchParams: Promise<{
q?: string;
status?: string;
scriptId?: string;
sort?: string;
dir?: string;
page?: string;
}>;
}
export default async function RunsPage({ searchParams }: RunsPageProps) {
const params = await searchParams;
const search = params.q?.trim() ?? "";
const statusFilter = params.status ?? "all";
const scriptFilter = params.scriptId ?? "all";
const sort = isSortKey(params.sort) ? params.sort : DEFAULT_SORT;
const dir = params.dir === "asc" ? "asc" : "desc";
const requestedPage = Math.max(1, Math.floor(Number(params.page)) || 1);
const db = getDb();
const conditions: SQL[] = [];
if (search) {
const term = `%${search}%`;
conditions.push(
or(
like(runs.scriptName, term),
like(runs.triggeredBy, term),
like(runs.resolvedCommand, term),
)!,
);
}
if (statusFilter !== "all" && statusFilter in runStatusLabels) {
conditions.push(eq(runs.status, statusFilter as keyof typeof runStatusLabels));
}
if (scriptFilter !== "all") {
conditions.push(eq(runs.scriptId, scriptFilter));
}
const whereClause = conditions.length ? and(...conditions) : undefined;
const orderColumn = SORT_COLUMNS[sort];
const orderBy = [
dir === "asc" ? asc(orderColumn) : desc(orderColumn),
desc(runs.createdAt),
];
const [{ total }] = db
.select({ total: sql<number>`count(*)` })
.from(runs)
.where(whereClause)
.all();
const scripts = db
.selectDistinct({ id: runs.scriptId, name: runs.scriptName })
.from(runs)
.orderBy(asc(runs.scriptName))
.all();
const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE));
const page = Math.min(requestedPage, totalPages);
const rows = db
.select()
.from(runs)
.where(whereClause)
.orderBy(...orderBy)
.limit(PAGE_SIZE)
.offset((page - 1) * PAGE_SIZE)
.all();
function hrefFor(overrides: Record<string, string | null>): string {
const query = new URLSearchParams();
if (search) query.set("q", search);
if (statusFilter !== "all") query.set("status", statusFilter);
if (scriptFilter !== "all") query.set("scriptId", scriptFilter);
if (sort !== DEFAULT_SORT) query.set("sort", sort);
if (dir !== "desc") query.set("dir", dir);
if (page !== 1) query.set("page", String(page));
for (const [key, value] of Object.entries(overrides)) {
if (value === null) query.delete(key);
else query.set(key, value);
}
const qs = query.toString();
return qs ? `/runs?${qs}` : "/runs";
}
function sortHeader(key: SortKey, label: string) {
const active = sort === key;
const nextDir = active && dir === "asc" ? "desc" : "asc";
const Icon = active ? (dir === "asc" ? ArrowUp : ArrowDown) : ArrowUpDown;
return (
<Link
href={hrefFor({ sort: key, dir: nextDir, page: null })}
className={cn(
"flex items-center gap-1 hover:text-foreground",
active && "text-foreground",
)}
>
{label}
<Icon className="size-3.5" />
</Link>
);
}
return (
<div className="flex flex-col gap-4">
<h1 className="text-2xl font-semibold tracking-tight">Run History</h1>
<RunsToolbar scripts={scripts} />
{rows.length === 0 ? (
<p className="text-muted-foreground py-12 text-center">
{total === 0 && !search && statusFilter === "all" && scriptFilter === "all"
? "No runs yet."
: "No runs match these filters."}
</p>
) : (
<>
<div className="overflow-x-auto rounded-lg border">
<Table>
<TableHeader>
<TableRow>
<TableHead>{sortHeader("script", "Script")}</TableHead>
<TableHead>{sortHeader("status", "Status")}</TableHead>
<TableHead>{sortHeader("triggeredBy", "Triggered by")}</TableHead>
<TableHead>{sortHeader("started", "Started")}</TableHead>
<TableHead>{sortHeader("duration", "Duration")}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{rows.map((run) => (
<TableRow key={run.id} className="cursor-pointer">
<TableCell>
<Link
href={`/runs/${run.id}`}
className="font-medium hover:underline"
>
{run.scriptName}
</Link>
</TableCell>
<TableCell>
<RunStatusBadge status={run.status} />
</TableCell>
<TableCell className="text-muted-foreground">
{run.triggeredBy}
</TableCell>
<TableCell className="text-muted-foreground">
{run.startedAt
? new Date(run.startedAt).toLocaleString()
: "-"}
</TableCell>
<TableCell className="text-muted-foreground">
{formatDuration(run.startedAt, run.endedAt)}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
<div className="text-muted-foreground flex items-center justify-between text-sm">
<span>
Page {page} of {totalPages} · {total} run{total === 1 ? "" : "s"}
</span>
<div className="flex items-center gap-2">
{page > 1 ? (
<Link
href={hrefFor({ page: String(page - 1) })}
className={buttonVariants({ variant: "outline", size: "sm" })}
>
<ChevronLeft className="size-3.5" />
Previous
</Link>
) : (
<span
aria-disabled
className={buttonVariants({
variant: "outline",
size: "sm",
className: "pointer-events-none opacity-50",
})}
>
<ChevronLeft className="size-3.5" />
Previous
</span>
)}
{page < totalPages ? (
<Link
href={hrefFor({ page: String(page + 1) })}
className={buttonVariants({ variant: "outline", size: "sm" })}
>
Next
<ChevronRight className="size-3.5" />
</Link>
) : (
<span
aria-disabled
className={buttonVariants({
variant: "outline",
size: "sm",
className: "pointer-events-none opacity-50",
})}
>
Next
<ChevronRight className="size-3.5" />
</span>
)}
</div>
</div>
</>
)}
</div>
);
}
+72
View File
@@ -0,0 +1,72 @@
export const dynamic = "force-dynamic";
import type { Metadata } from "next";
import { eq } from "drizzle-orm";
import { notFound } from "next/navigation";
import { getScript } from "@/lib/config/load";
import { serializeScriptForClient } from "@/lib/config/serialize";
import { getDb } from "@/lib/db/client";
import { runs } from "@/lib/db/schema";
import { DynamicForm } from "@/components/forms/dynamic-form";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
interface ScriptPageProps {
params: Promise<{ scriptId: string }>;
searchParams: Promise<{ fromRun?: string }>;
}
export async function generateMetadata({
params,
}: ScriptPageProps): Promise<Metadata> {
const { scriptId } = await params;
const script = getScript(scriptId);
return { title: script?.name ?? "Script not found" };
}
export default async function ScriptPage({
params,
searchParams,
}: ScriptPageProps) {
const { scriptId } = await params;
const { fromRun } = await searchParams;
const script = getScript(scriptId);
if (!script) notFound();
const clientScript = serializeScriptForClient(script);
// Only trust a previous run's variables as prefill if it's actually a run of this same
// script - a `fromRun` id for a different script's run wouldn't line up with these variables.
let initialValues: Record<string, unknown> | undefined;
if (fromRun) {
const previousRun = getDb()
.select({ scriptId: runs.scriptId, variables: runs.variables })
.from(runs)
.where(eq(runs.id, fromRun))
.get();
if (previousRun?.scriptId === scriptId) {
initialValues = previousRun.variables;
}
}
return (
<div className="mx-auto max-w-2xl">
<Card>
<CardHeader>
<CardTitle>{script.name}</CardTitle>
{script.description && (
<CardDescription>{script.description}</CardDescription>
)}
</CardHeader>
<CardContent>
<DynamicForm script={clientScript} initialValues={initialValues} />
</CardContent>
</Card>
</div>
);
}