makersclaw 0.1.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/README.md +68 -0
- package/bin/makersclaw.mjs +10 -0
- package/package.json +34 -0
- package/registry/blocks/board/README.md +130 -0
- package/registry/blocks/board/block.json +10 -0
- package/registry/blocks/board/board.jsx +435 -0
- package/registry/blocks/board/demo.jsx +50 -0
- package/registry/blocks/board/index.js +4 -0
- package/registry/blocks/job-panel/README.md +68 -0
- package/registry/blocks/job-panel/block.json +10 -0
- package/registry/blocks/job-panel/demo.jsx +75 -0
- package/registry/blocks/job-panel/index.js +3 -0
- package/registry/blocks/job-panel/job-panel.jsx +110 -0
- package/src/cli.mjs +301 -0
- package/src/fsutil.mjs +76 -0
- package/src/install.mjs +457 -0
- package/src/registry.mjs +191 -0
- package/templates/next/files/README.md +626 -0
- package/templates/next/files/app/auto-refresh.jsx +26 -0
- package/templates/next/files/app/error-relay.jsx +53 -0
- package/templates/next/files/app/error.jsx +44 -0
- package/templates/next/files/app/global-error.jsx +36 -0
- package/templates/next/files/app/globals.css +178 -0
- package/templates/next/files/app/layout.jsx +51 -0
- package/templates/next/files/app/not-found.jsx +23 -0
- package/templates/next/files/app/page.jsx +22 -0
- package/templates/next/files/app/s/[key]/page.jsx +47 -0
- package/templates/next/files/app/shell.jsx +64 -0
- package/templates/next/files/check/run.mjs +683 -0
- package/templates/next/files/components/parts/fixture.jsx +60 -0
- package/templates/next/files/components/parts/index.js +5 -0
- package/templates/next/files/components/parts/record-switcher.jsx +49 -0
- package/templates/next/files/components/ui/button.jsx +81 -0
- package/templates/next/files/components/ui/cx.js +4 -0
- package/templates/next/files/components/ui/depiction.jsx +66 -0
- package/templates/next/files/components/ui/field.jsx +126 -0
- package/templates/next/files/components/ui/icon.jsx +87 -0
- package/templates/next/files/components/ui/index.js +12 -0
- package/templates/next/files/components/ui/menu.jsx +123 -0
- package/templates/next/files/components/ui/notice.jsx +86 -0
- package/templates/next/files/components/ui/sheet.jsx +89 -0
- package/templates/next/files/components/ui/tabs.jsx +81 -0
- package/templates/next/files/lib/actions.js +187 -0
- package/templates/next/files/lib/claw.js +219 -0
- package/templates/next/files/lib/data.js +171 -0
- package/templates/next/files/lib/db.js +72 -0
- package/templates/next/files/lib/hooks.js +218 -0
- package/templates/next/files/lib/platform.js +59 -0
- package/templates/next/files/lib/sample.js +12 -0
- package/templates/next/files/middleware.js +42 -0
- package/templates/next/files/next.config.mjs +41 -0
- package/templates/next/files/package.json +19 -0
- package/templates/next/files/postcss.config.mjs +3 -0
- package/templates/next/files/schema.sql +22 -0
- package/templates/next/files/surfaces/README.md +32 -0
- package/templates/next/files/surfaces.js +53 -0
- package/templates/next/template.json +118 -0
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { cx } from "./cx";
|
|
2
|
+
import { Button } from "./button";
|
|
3
|
+
import { Icon } from "./icon";
|
|
4
|
+
|
|
5
|
+
// Static parts: a card, a notice, a badge, a kicker, the empty state.
|
|
6
|
+
// Server-safe (no hooks), so a server surface can use them directly.
|
|
7
|
+
|
|
8
|
+
const TONES = {
|
|
9
|
+
neutral: { box: "border-border bg-card text-card-foreground", icon: "info" },
|
|
10
|
+
info: { box: "border-info/40 bg-card text-card-foreground", icon: "info" },
|
|
11
|
+
success: { box: "border-success/40 bg-card text-card-foreground", icon: "check" },
|
|
12
|
+
warning: { box: "border-warning/50 bg-card text-card-foreground", icon: "warning" },
|
|
13
|
+
danger: { box: "border-destructive/50 bg-card text-card-foreground", icon: "warning" },
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
/** A raised panel with an optional heading row. */
|
|
17
|
+
export function Card({ title, description, actions, className, children, as: Tag = "section" }) {
|
|
18
|
+
return (
|
|
19
|
+
<Tag className={cx("rounded-lg border border-border bg-card p-4 text-card-foreground", className)}>
|
|
20
|
+
{title || actions ? (
|
|
21
|
+
<div className="mb-3 flex items-start justify-between gap-3">
|
|
22
|
+
<div className="min-w-0">
|
|
23
|
+
{title ? <h2 className="text-base font-semibold leading-tight">{title}</h2> : null}
|
|
24
|
+
{description ? <p className="mt-0.5 text-sm text-muted-foreground">{description}</p> : null}
|
|
25
|
+
</div>
|
|
26
|
+
{actions ? <div className="flex shrink-0 gap-2">{actions}</div> : null}
|
|
27
|
+
</div>
|
|
28
|
+
) : null}
|
|
29
|
+
{children}
|
|
30
|
+
</Tag>
|
|
31
|
+
);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** One line the user should read, with an optional action. `tone`
|
|
35
|
+
* neutral · info · success · warning · danger. Colour is the signal, so
|
|
36
|
+
* status tones are for status only — never decoration. */
|
|
37
|
+
export function Notice({ tone = "neutral", title, action, onDismiss, className, children }) {
|
|
38
|
+
const t = TONES[tone] ?? TONES.neutral;
|
|
39
|
+
return (
|
|
40
|
+
<div role={tone === "danger" ? "alert" : "status"} className={cx("flex items-start gap-3 rounded-lg border p-3 text-sm", t.box, className)}>
|
|
41
|
+
<Icon name={t.icon} className="mt-0.5 shrink-0 text-muted-foreground" />
|
|
42
|
+
<div className="min-w-0 flex-1">
|
|
43
|
+
{title ? <p className="font-medium">{title}</p> : null}
|
|
44
|
+
{children ? <div className={cx(title && "mt-0.5", "text-muted-foreground")}>{children}</div> : null}
|
|
45
|
+
</div>
|
|
46
|
+
{action ? <div className="shrink-0">{action}</div> : null}
|
|
47
|
+
{onDismiss ? (
|
|
48
|
+
<Button variant="quiet" size="sm" onClick={onDismiss} aria-label="Dismiss" className="-my-1 -mr-1 w-9 px-0">
|
|
49
|
+
<Icon name="x" />
|
|
50
|
+
</Button>
|
|
51
|
+
) : null}
|
|
52
|
+
</div>
|
|
53
|
+
);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** A small label for a state or a count. */
|
|
57
|
+
export function Badge({ tone = "neutral", className, children }) {
|
|
58
|
+
const look = {
|
|
59
|
+
neutral: "bg-muted text-muted-foreground",
|
|
60
|
+
primary: "bg-primary text-primary-foreground",
|
|
61
|
+
success: "bg-success/15 text-success",
|
|
62
|
+
warning: "bg-warning/15 text-warning",
|
|
63
|
+
danger: "bg-destructive/15 text-destructive",
|
|
64
|
+
info: "bg-info/15 text-info",
|
|
65
|
+
}[tone];
|
|
66
|
+
return <span className={cx("inline-flex items-center rounded-sm px-1.5 py-0.5 text-2xs font-medium uppercase tracking-wide", look, className)}>{children}</span>;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** The small uppercase line above a heading. */
|
|
70
|
+
export function Kicker({ className, children }) {
|
|
71
|
+
return <p className={cx("text-2xs font-medium uppercase tracking-wider text-muted-foreground", className)}>{children}</p>;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** What a surface shows when a real collection is empty AND there is no
|
|
75
|
+
* fixture to show (a filter with no matches, a search with no results).
|
|
76
|
+
* Says what would appear here and offers the one way to make it. */
|
|
77
|
+
export function Empty({ icon = "search", title, body, action, className }) {
|
|
78
|
+
return (
|
|
79
|
+
<div className={cx("grid place-items-center gap-2 rounded-lg border border-dashed border-border px-6 py-10 text-center", className)}>
|
|
80
|
+
<Icon name={icon} size={22} className="text-muted-foreground" />
|
|
81
|
+
<p className="text-sm font-medium">{title}</p>
|
|
82
|
+
{body ? <p className="max-w-prose text-sm text-muted-foreground">{body}</p> : null}
|
|
83
|
+
{action ? <div className="mt-2">{action}</div> : null}
|
|
84
|
+
</div>
|
|
85
|
+
);
|
|
86
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useEffect, useRef } from "react";
|
|
4
|
+
import { cx } from "./cx";
|
|
5
|
+
import { Button, IconButton } from "./button";
|
|
6
|
+
|
|
7
|
+
// Sheet — the one overlay. A native <dialog> (focus trap, Esc, scroll
|
|
8
|
+
// lock and the top layer come from the browser), docked to the side the
|
|
9
|
+
// surface asks for. Mobile first: on a narrow window every side becomes
|
|
10
|
+
// a bottom sheet the thumb can reach. Controlled: `open` + `onClose`.
|
|
11
|
+
//
|
|
12
|
+
// side end (a rail beside the work; the default) · center (a dialog)
|
|
13
|
+
// · bottom
|
|
14
|
+
//
|
|
15
|
+
// Confirm below is the "delete this?" case — never window.confirm().
|
|
16
|
+
|
|
17
|
+
const SIDES = {
|
|
18
|
+
end:
|
|
19
|
+
"inset-y-0 right-0 left-auto h-dvh max-h-none w-full max-w-md rounded-none border-l " +
|
|
20
|
+
"max-sm:inset-x-0 max-sm:top-auto max-sm:bottom-0 max-sm:h-auto max-sm:max-h-[85dvh] max-sm:max-w-none max-sm:rounded-t-xl max-sm:border-l-0 max-sm:border-t",
|
|
21
|
+
center: "m-auto w-[calc(100%-2rem)] max-w-lg rounded-lg border max-sm:mb-0 max-sm:mt-auto max-sm:w-full max-sm:max-w-none max-sm:rounded-b-none max-sm:rounded-t-xl",
|
|
22
|
+
bottom: "inset-x-0 top-auto bottom-0 w-full max-w-none max-h-[85dvh] rounded-t-xl border-t",
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
export function Sheet({ open, onClose, title, description, side = "end", footer, className, children }) {
|
|
26
|
+
const ref = useRef(null);
|
|
27
|
+
|
|
28
|
+
useEffect(() => {
|
|
29
|
+
const el = ref.current;
|
|
30
|
+
if (!el) return;
|
|
31
|
+
if (open && !el.open) el.showModal();
|
|
32
|
+
else if (!open && el.open) el.close();
|
|
33
|
+
}, [open]);
|
|
34
|
+
|
|
35
|
+
return (
|
|
36
|
+
<dialog
|
|
37
|
+
ref={ref}
|
|
38
|
+
onClose={() => onClose?.()}
|
|
39
|
+
onClick={(e) => {
|
|
40
|
+
if (e.target === ref.current) ref.current.close();
|
|
41
|
+
}}
|
|
42
|
+
className={cx(
|
|
43
|
+
"fixed m-0 flex-col bg-popover p-0 text-popover-foreground shadow-xl border-border open:flex",
|
|
44
|
+
SIDES[side] ?? SIDES.end,
|
|
45
|
+
className,
|
|
46
|
+
)}
|
|
47
|
+
aria-labelledby={title ? "sheet-title" : undefined}
|
|
48
|
+
>
|
|
49
|
+
<div className="flex min-h-0 flex-1 flex-col">
|
|
50
|
+
{title || onClose ? (
|
|
51
|
+
<header className="flex items-start justify-between gap-3 border-b border-border px-4 py-3">
|
|
52
|
+
<div className="min-w-0">
|
|
53
|
+
{title ? <h2 id="sheet-title" className="text-base font-semibold leading-tight">{title}</h2> : null}
|
|
54
|
+
{description ? <p className="mt-0.5 text-sm text-muted-foreground">{description}</p> : null}
|
|
55
|
+
</div>
|
|
56
|
+
<IconButton icon="x" label="Close" size="sm" onClick={() => ref.current?.close()} className="-mr-2" />
|
|
57
|
+
</header>
|
|
58
|
+
) : null}
|
|
59
|
+
<div className="min-h-0 flex-1 overflow-y-auto px-4 py-4">{children}</div>
|
|
60
|
+
{footer ? <footer className="flex flex-wrap justify-end gap-2 border-t border-border px-4 py-3">{footer}</footer> : null}
|
|
61
|
+
</div>
|
|
62
|
+
</dialog>
|
|
63
|
+
);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** A yes/no with the consequence spelled out. `tone="danger"` for a
|
|
67
|
+
* destructive confirm. Resolves through `onConfirm`; closes either way. */
|
|
68
|
+
export function Confirm({ open, onClose, onConfirm, title, body, confirmLabel = "Confirm", cancelLabel = "Cancel", tone = "neutral", busy }) {
|
|
69
|
+
return (
|
|
70
|
+
<Sheet
|
|
71
|
+
open={open}
|
|
72
|
+
onClose={onClose}
|
|
73
|
+
side="center"
|
|
74
|
+
title={title}
|
|
75
|
+
footer={
|
|
76
|
+
<>
|
|
77
|
+
<Button variant="quiet" onClick={onClose} disabled={busy}>
|
|
78
|
+
{cancelLabel}
|
|
79
|
+
</Button>
|
|
80
|
+
<Button variant={tone === "danger" ? "danger" : "primary"} onClick={onConfirm} disabled={busy}>
|
|
81
|
+
{busy ? "Working…" : confirmLabel}
|
|
82
|
+
</Button>
|
|
83
|
+
</>
|
|
84
|
+
}
|
|
85
|
+
>
|
|
86
|
+
{body ? <p className="text-sm text-muted-foreground">{body}</p> : null}
|
|
87
|
+
</Sheet>
|
|
88
|
+
);
|
|
89
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useId, useRef } from "react";
|
|
4
|
+
import { cx } from "./cx";
|
|
5
|
+
|
|
6
|
+
// Tabs — one strip, arrow keys move and select, Home/End jump. Controlled:
|
|
7
|
+
// `value` + `onChange`. The strip is `Tabs`; each panel is a `TabPanel`
|
|
8
|
+
// keyed the same way, so the caller decides what a tab shows. Tabs are
|
|
9
|
+
// for views of ONE thing (a record's notes | history), never for the
|
|
10
|
+
// app's surfaces — those are the shell's nav.
|
|
11
|
+
//
|
|
12
|
+
// tabs [{ key, label, count? }]
|
|
13
|
+
|
|
14
|
+
export function Tabs({ tabs, value, onChange, id, className, "aria-label": ariaLabel }) {
|
|
15
|
+
const auto = useId();
|
|
16
|
+
const base = id ?? auto;
|
|
17
|
+
const refs = useRef([]);
|
|
18
|
+
|
|
19
|
+
function onKeyDown(e, index) {
|
|
20
|
+
const last = tabs.length - 1;
|
|
21
|
+
let next = null;
|
|
22
|
+
if (e.key === "ArrowRight") next = index === last ? 0 : index + 1;
|
|
23
|
+
else if (e.key === "ArrowLeft") next = index === 0 ? last : index - 1;
|
|
24
|
+
else if (e.key === "Home") next = 0;
|
|
25
|
+
else if (e.key === "End") next = last;
|
|
26
|
+
if (next === null) return;
|
|
27
|
+
e.preventDefault();
|
|
28
|
+
refs.current[next]?.focus();
|
|
29
|
+
onChange?.(tabs[next].key);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
return (
|
|
33
|
+
<div role="tablist" aria-label={ariaLabel} className={cx("flex gap-1 overflow-x-auto border-b border-border", className)}>
|
|
34
|
+
{tabs.map((t, i) => {
|
|
35
|
+
const selected = t.key === value;
|
|
36
|
+
return (
|
|
37
|
+
<button
|
|
38
|
+
key={t.key}
|
|
39
|
+
ref={(el) => {
|
|
40
|
+
refs.current[i] = el;
|
|
41
|
+
}}
|
|
42
|
+
type="button"
|
|
43
|
+
role="tab"
|
|
44
|
+
id={`${base}-tab-${t.key}`}
|
|
45
|
+
aria-selected={selected}
|
|
46
|
+
aria-controls={`${base}-panel-${t.key}`}
|
|
47
|
+
tabIndex={selected ? 0 : -1}
|
|
48
|
+
onClick={() => onChange?.(t.key)}
|
|
49
|
+
onKeyDown={(e) => onKeyDown(e, i)}
|
|
50
|
+
className={cx(
|
|
51
|
+
"-mb-px inline-flex min-h-11 items-center gap-2 whitespace-nowrap border-b-2 px-3 text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring",
|
|
52
|
+
selected ? "border-primary font-medium text-foreground" : "border-transparent text-muted-foreground hover:text-foreground",
|
|
53
|
+
)}
|
|
54
|
+
>
|
|
55
|
+
{t.label}
|
|
56
|
+
{typeof t.count === "number" ? (
|
|
57
|
+
<span data-nums className="rounded-sm bg-muted px-1.5 text-2xs text-muted-foreground">
|
|
58
|
+
{t.count}
|
|
59
|
+
</span>
|
|
60
|
+
) : null}
|
|
61
|
+
</button>
|
|
62
|
+
);
|
|
63
|
+
})}
|
|
64
|
+
</div>
|
|
65
|
+
);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function TabPanel({ tab, value, id, className, children }) {
|
|
69
|
+
const selected = tab === value;
|
|
70
|
+
return (
|
|
71
|
+
<div
|
|
72
|
+
role="tabpanel"
|
|
73
|
+
id={id ? `${id}-panel-${tab}` : undefined}
|
|
74
|
+
aria-labelledby={id ? `${id}-tab-${tab}` : undefined}
|
|
75
|
+
hidden={!selected}
|
|
76
|
+
className={cx(selected && "pt-4", className)}
|
|
77
|
+
>
|
|
78
|
+
{selected ? children : null}
|
|
79
|
+
</div>
|
|
80
|
+
);
|
|
81
|
+
}
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
"use server";
|
|
2
|
+
|
|
3
|
+
import { headers } from "next/headers";
|
|
4
|
+
import { revalidatePath } from "next/cache";
|
|
5
|
+
import { ident, logActivity, query } from "./db";
|
|
6
|
+
|
|
7
|
+
// Every write the app makes. Callable from server and client components
|
|
8
|
+
// alike (they are server actions). Each one goes through lib/db.js,
|
|
9
|
+
// records an activity row, and re-renders the app. Table and column names
|
|
10
|
+
// are quoted through ident(), which refuses anything that is not a plain
|
|
11
|
+
// identifier. Returns { ok } or { ok: false, error }; never throws.
|
|
12
|
+
//
|
|
13
|
+
// `surface` is optional: middleware.js stamps the current surface key on
|
|
14
|
+
// the request, so a surface rarely needs to pass it.
|
|
15
|
+
//
|
|
16
|
+
// `detail` is the founder's sentence for the row. The OS Activity tab
|
|
17
|
+
// prints the action word and then the detail, verbatim, side by side —
|
|
18
|
+
// so a detail never repeats the verb ("added" + "added to leads") and
|
|
19
|
+
// never names the table, which is the app's plumbing, not the founder's
|
|
20
|
+
// words. Pass a detail when the surface can say what changed in plain
|
|
21
|
+
// words; leave it out and the tab shows the action word alone.
|
|
22
|
+
|
|
23
|
+
async function actor() {
|
|
24
|
+
try {
|
|
25
|
+
const email = (await headers()).get("x-claw-user-email");
|
|
26
|
+
return email ? `user:${email}` : "user:unknown";
|
|
27
|
+
} catch {
|
|
28
|
+
return "user:unknown";
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
async function surfaceOf(explicit) {
|
|
33
|
+
if (typeof explicit === "string" && explicit) return explicit;
|
|
34
|
+
if (explicit && typeof explicit === "object" && typeof explicit.key === "string") return explicit.key;
|
|
35
|
+
try {
|
|
36
|
+
return (await headers()).get("x-claw-surface") || null;
|
|
37
|
+
} catch {
|
|
38
|
+
return null;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function done() {
|
|
43
|
+
revalidatePath("/", "layout");
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function fail(error) {
|
|
47
|
+
const message = error?.message ?? String(error);
|
|
48
|
+
console.error(`[actions] write failed: ${message}`);
|
|
49
|
+
return { ok: false, error: message };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Change columns of one row. `patch` is { column: value }. */
|
|
53
|
+
export async function updateRow({ source, idColumn = "id", id, patch, surface, action = "edited", detail, data }) {
|
|
54
|
+
try {
|
|
55
|
+
const cols = Object.keys(patch ?? {}).filter((c) => patch[c] !== undefined);
|
|
56
|
+
if (!cols.length) return { ok: false, error: "nothing to change" };
|
|
57
|
+
const sets = cols.map((c, i) => `${ident(c)} = $${i + 2}`).join(", ");
|
|
58
|
+
const { rowCount } = await query(
|
|
59
|
+
`UPDATE ${ident(source)} SET ${sets} WHERE ${ident(idColumn)} = $1`,
|
|
60
|
+
[id, ...cols.map((c) => patch[c])],
|
|
61
|
+
);
|
|
62
|
+
if (!rowCount) return { ok: false, error: "row not found" };
|
|
63
|
+
await logActivity({
|
|
64
|
+
action,
|
|
65
|
+
surface: await surfaceOf(surface),
|
|
66
|
+
row_ref: id,
|
|
67
|
+
detail: detail ?? `${cols.join(", ")} changed`,
|
|
68
|
+
data: data ?? patch,
|
|
69
|
+
by: await actor(),
|
|
70
|
+
});
|
|
71
|
+
done();
|
|
72
|
+
return { ok: true };
|
|
73
|
+
} catch (error) {
|
|
74
|
+
return fail(error);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Move a row to another state (a board column, a stage select). */
|
|
79
|
+
export async function moveRow({ source, idColumn = "id", id, stateColumn, to, surface }) {
|
|
80
|
+
return updateRow({
|
|
81
|
+
source,
|
|
82
|
+
idColumn,
|
|
83
|
+
id,
|
|
84
|
+
patch: { [stateColumn]: to },
|
|
85
|
+
surface,
|
|
86
|
+
action: "moved",
|
|
87
|
+
detail: `to ${to}`,
|
|
88
|
+
data: { [stateColumn]: to },
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** Add a row. `values` is { column: value }; returns the new id. */
|
|
93
|
+
export async function insertRow({ source, idColumn = "id", values, surface, action = "added", detail }) {
|
|
94
|
+
try {
|
|
95
|
+
const cols = Object.keys(values ?? {}).filter((c) => values[c] !== undefined);
|
|
96
|
+
if (!cols.length) return { ok: false, error: "nothing to add" };
|
|
97
|
+
const placeholders = cols.map((_, i) => `$${i + 1}`).join(", ");
|
|
98
|
+
const { rows } = await query(
|
|
99
|
+
`INSERT INTO ${ident(source)} (${cols.map(ident).join(", ")}) VALUES (${placeholders}) RETURNING ${ident(idColumn)}`,
|
|
100
|
+
cols.map((c) => values[c]),
|
|
101
|
+
);
|
|
102
|
+
const id = rows[0]?.[idColumn];
|
|
103
|
+
await logActivity({
|
|
104
|
+
action,
|
|
105
|
+
surface: await surfaceOf(surface),
|
|
106
|
+
row_ref: id,
|
|
107
|
+
detail: detail ?? null,
|
|
108
|
+
data: values,
|
|
109
|
+
by: await actor(),
|
|
110
|
+
});
|
|
111
|
+
done();
|
|
112
|
+
return { ok: true, id };
|
|
113
|
+
} catch (error) {
|
|
114
|
+
return fail(error);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export async function deleteRow({ source, idColumn = "id", id, surface, detail }) {
|
|
119
|
+
try {
|
|
120
|
+
const { rowCount } = await query(`DELETE FROM ${ident(source)} WHERE ${ident(idColumn)} = $1`, [id]);
|
|
121
|
+
if (!rowCount) return { ok: false, error: "row not found" };
|
|
122
|
+
await logActivity({
|
|
123
|
+
action: "removed",
|
|
124
|
+
surface: await surfaceOf(surface),
|
|
125
|
+
row_ref: id,
|
|
126
|
+
detail: detail ?? null,
|
|
127
|
+
by: await actor(),
|
|
128
|
+
});
|
|
129
|
+
done();
|
|
130
|
+
return { ok: true };
|
|
131
|
+
} catch (error) {
|
|
132
|
+
return fail(error);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Settings: one key. Table mode updates the (key, value) row in `source`
|
|
138
|
+
* and inserts one when there is none — an UPDATE then INSERT rather than
|
|
139
|
+
* ON CONFLICT, because a settings table made with the `sql` verb usually
|
|
140
|
+
* has a plain `key text` column and Postgres rejects ON CONFLICT without a
|
|
141
|
+
* unique index on it. JSON mode sets the key inside the jsonb `column` of
|
|
142
|
+
* the row `id` in `source`.
|
|
143
|
+
*/
|
|
144
|
+
export async function saveSetting({
|
|
145
|
+
mode = "table",
|
|
146
|
+
source,
|
|
147
|
+
keyColumn = "key",
|
|
148
|
+
valueColumn = "value",
|
|
149
|
+
column,
|
|
150
|
+
idColumn = "id",
|
|
151
|
+
id,
|
|
152
|
+
key,
|
|
153
|
+
value,
|
|
154
|
+
surface,
|
|
155
|
+
}) {
|
|
156
|
+
try {
|
|
157
|
+
if (!key) return { ok: false, error: "no key" };
|
|
158
|
+
if (mode === "json") {
|
|
159
|
+
const { rowCount } = await query(
|
|
160
|
+
`UPDATE ${ident(source)} SET ${ident(column)} = jsonb_set(COALESCE(${ident(column)}, '{}'::jsonb), $2::text[], $3::jsonb, true) WHERE ${ident(idColumn)} = $1`,
|
|
161
|
+
[id, [key], JSON.stringify(value ?? null)],
|
|
162
|
+
);
|
|
163
|
+
if (!rowCount) return { ok: false, error: "row not found" };
|
|
164
|
+
} else {
|
|
165
|
+
const stored = typeof value === "object" && value !== null ? JSON.stringify(value) : value;
|
|
166
|
+
const { rowCount } = await query(
|
|
167
|
+
`UPDATE ${ident(source)} SET ${ident(valueColumn)} = $2 WHERE ${ident(keyColumn)} = $1`,
|
|
168
|
+
[key, stored],
|
|
169
|
+
);
|
|
170
|
+
if (!rowCount) {
|
|
171
|
+
await query(`INSERT INTO ${ident(source)} (${ident(keyColumn)}, ${ident(valueColumn)}) VALUES ($1, $2)`, [key, stored]);
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
await logActivity({
|
|
175
|
+
action: "edited",
|
|
176
|
+
surface: await surfaceOf(surface),
|
|
177
|
+
row_ref: mode === "json" ? id : key,
|
|
178
|
+
detail: `setting ${key} changed`,
|
|
179
|
+
data: { [key]: value },
|
|
180
|
+
by: await actor(),
|
|
181
|
+
});
|
|
182
|
+
done();
|
|
183
|
+
return { ok: true };
|
|
184
|
+
} catch (error) {
|
|
185
|
+
return fail(error);
|
|
186
|
+
}
|
|
187
|
+
}
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
// The _claw API: what the Makersclaw gateway answers itself under this
|
|
2
|
+
// product's path, before it proxies anything to the app
|
|
3
|
+
// (docs/ARTIFACT-CONTRACTS.md §5). It is reachable from the BROWSER only —
|
|
4
|
+
// the grant cookie is the identity — so everything here is client-side.
|
|
5
|
+
//
|
|
6
|
+
// Every call resolves; nothing throws. A 404 or a network error means the
|
|
7
|
+
// gateway has no such route yet, and comes back as `unavailable: true` so
|
|
8
|
+
// a surface renders a quiet "not connected yet" state instead of breaking.
|
|
9
|
+
//
|
|
10
|
+
// An app calls four of these. `me` (identity, status) and `events`
|
|
11
|
+
// (errors) are plumbing. `chat` and `cancel` are COMMISSIONING — work the
|
|
12
|
+
// founder asks for from inside the app, streamed back so they can watch
|
|
13
|
+
// it (SOP §2.3); lib/platform.js wraps them as startJob / cancelJob and
|
|
14
|
+
// lib/hooks.js as useJob. What an app still never calls is monitoring:
|
|
15
|
+
// the OS draws Agent, Runs and Activity as tabs on its window. The rest
|
|
16
|
+
// of the gateway's routes (§5) stay reachable through claw() /
|
|
17
|
+
// clawStream() for a surface that has a real reason.
|
|
18
|
+
|
|
19
|
+
/** The prefix the app is served under. The <html> attribute is set by the
|
|
20
|
+
* running server, so it wins over the value inlined at build time. */
|
|
21
|
+
export function clawBasePath() {
|
|
22
|
+
if (typeof document !== "undefined") {
|
|
23
|
+
const fromDom = document.documentElement.dataset.clawBasePath;
|
|
24
|
+
if (fromDom !== undefined) return fromDom;
|
|
25
|
+
}
|
|
26
|
+
return process.env.CLAW_BASE_PATH || "";
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function clawUrl(path) {
|
|
30
|
+
return `${clawBasePath()}/_claw/${String(path).replace(/^\/+/, "")}`;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* The artifact's status (draft | active | paused | …), as last heard from
|
|
35
|
+
* _claw/me. It lives on <html> so that reportError — which runs from
|
|
36
|
+
* error boundaries with no access to any component's state — can tell a
|
|
37
|
+
* fault in the live preview of a draft from one in the launched artifact.
|
|
38
|
+
* app/error-relay.jsx (the fixed layout mounts it) publishes it on mount
|
|
39
|
+
* and when the tab comes back; useArtifact() publishes it too.
|
|
40
|
+
*/
|
|
41
|
+
export function publishStatus(status) {
|
|
42
|
+
if (typeof document === "undefined") return;
|
|
43
|
+
if (typeof status === "string" && status) document.documentElement.dataset.clawStatus = status;
|
|
44
|
+
else delete document.documentElement.dataset.clawStatus;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function clawStatus() {
|
|
48
|
+
if (typeof document === "undefined") return null;
|
|
49
|
+
return document.documentElement.dataset.clawStatus || null;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Before launch the artifact is only ever seen in the bench's live preview;
|
|
53
|
+
// `piloting` is the pre-launch status of rows from before this cut.
|
|
54
|
+
const PRELAUNCH = new Set(["draft", "piloting"]);
|
|
55
|
+
|
|
56
|
+
/** Where an error happened, for _claw/events: the live preview of an
|
|
57
|
+
* unlaunched artifact, or the running one. Unknown status → runtime. */
|
|
58
|
+
export function errorSource() {
|
|
59
|
+
return PRELAUNCH.has(clawStatus()) ? "preview" : "runtime";
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
async function readError(res) {
|
|
63
|
+
const text = await res.text().catch(() => "");
|
|
64
|
+
try {
|
|
65
|
+
const parsed = JSON.parse(text);
|
|
66
|
+
return parsed.error || parsed.message || text || res.statusText;
|
|
67
|
+
} catch {
|
|
68
|
+
return text || res.statusText || `HTTP ${res.status}`;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* claw("me") / claw("agent", { method: "PATCH", body: {...} })
|
|
74
|
+
* → { ok: true, status, data }
|
|
75
|
+
* → { ok: false, status, error, unavailable }
|
|
76
|
+
*/
|
|
77
|
+
export async function claw(path, { method = "GET", body, signal } = {}) {
|
|
78
|
+
let res;
|
|
79
|
+
try {
|
|
80
|
+
res = await fetch(clawUrl(path), {
|
|
81
|
+
method,
|
|
82
|
+
credentials: "same-origin",
|
|
83
|
+
cache: "no-store",
|
|
84
|
+
headers: {
|
|
85
|
+
accept: "application/json",
|
|
86
|
+
...(body !== undefined ? { "content-type": "application/json" } : {}),
|
|
87
|
+
},
|
|
88
|
+
body: body !== undefined ? JSON.stringify(body) : undefined,
|
|
89
|
+
signal,
|
|
90
|
+
});
|
|
91
|
+
} catch (error) {
|
|
92
|
+
if (error?.name === "AbortError") return { ok: false, status: 0, error: "aborted", aborted: true };
|
|
93
|
+
return { ok: false, status: 0, error: "not reachable", unavailable: true };
|
|
94
|
+
}
|
|
95
|
+
if (res.status === 404) {
|
|
96
|
+
return { ok: false, status: 404, error: "not available yet", unavailable: true };
|
|
97
|
+
}
|
|
98
|
+
if (!res.ok) {
|
|
99
|
+
return { ok: false, status: res.status, error: await readError(res), unavailable: false };
|
|
100
|
+
}
|
|
101
|
+
const text = await res.text().catch(() => "");
|
|
102
|
+
let data = null;
|
|
103
|
+
if (text) {
|
|
104
|
+
try {
|
|
105
|
+
data = JSON.parse(text);
|
|
106
|
+
} catch {
|
|
107
|
+
data = text;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
return { ok: true, status: res.status, data };
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** One SSE frame → { event, data }. The gateway names the event on the
|
|
114
|
+
* `event:` line; a frame that carries it inside the JSON instead
|
|
115
|
+
* (`{type}` or `{event}`) is read the same way. */
|
|
116
|
+
function parseFrame(frame) {
|
|
117
|
+
let event = "message";
|
|
118
|
+
const lines = [];
|
|
119
|
+
for (const raw of frame.split("\n")) {
|
|
120
|
+
const line = raw.replace(/\r$/, "");
|
|
121
|
+
if (line.startsWith("event:")) event = line.slice(6).trim();
|
|
122
|
+
else if (line.startsWith("data:")) lines.push(line.slice(5).replace(/^ /, ""));
|
|
123
|
+
}
|
|
124
|
+
if (!lines.length) return null;
|
|
125
|
+
const text = lines.join("\n");
|
|
126
|
+
let data = text;
|
|
127
|
+
try {
|
|
128
|
+
data = JSON.parse(text);
|
|
129
|
+
} catch {
|
|
130
|
+
/* plain text data */
|
|
131
|
+
}
|
|
132
|
+
if (event === "message" && data && typeof data === "object") {
|
|
133
|
+
event = data.event || data.type || event;
|
|
134
|
+
}
|
|
135
|
+
return { event, data };
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* POST and consume a text/event-stream. `onEvent({ event, data })` is
|
|
140
|
+
* called for every frame as it arrives (delta / tool / done / error for
|
|
141
|
+
* the chat). Resolves when the stream ends, with the same shape as claw().
|
|
142
|
+
*/
|
|
143
|
+
export async function clawStream(path, body, onEvent, { signal } = {}) {
|
|
144
|
+
let res;
|
|
145
|
+
try {
|
|
146
|
+
res = await fetch(clawUrl(path), {
|
|
147
|
+
method: "POST",
|
|
148
|
+
credentials: "same-origin",
|
|
149
|
+
cache: "no-store",
|
|
150
|
+
headers: { accept: "text/event-stream", "content-type": "application/json" },
|
|
151
|
+
body: JSON.stringify(body ?? {}),
|
|
152
|
+
signal,
|
|
153
|
+
});
|
|
154
|
+
} catch (error) {
|
|
155
|
+
if (error?.name === "AbortError") return { ok: false, status: 0, error: "aborted", aborted: true };
|
|
156
|
+
return { ok: false, status: 0, error: "not reachable", unavailable: true };
|
|
157
|
+
}
|
|
158
|
+
if (res.status === 404) {
|
|
159
|
+
return { ok: false, status: 404, error: "not available yet", unavailable: true };
|
|
160
|
+
}
|
|
161
|
+
if (!res.ok) {
|
|
162
|
+
return { ok: false, status: res.status, error: await readError(res), unavailable: false };
|
|
163
|
+
}
|
|
164
|
+
if (!res.body) return { ok: false, status: res.status, error: "empty stream" };
|
|
165
|
+
|
|
166
|
+
const reader = res.body.getReader();
|
|
167
|
+
const decoder = new TextDecoder();
|
|
168
|
+
let buffer = "";
|
|
169
|
+
const dispatch = (frame) => {
|
|
170
|
+
const parsed = parseFrame(frame);
|
|
171
|
+
if (parsed) onEvent(parsed);
|
|
172
|
+
};
|
|
173
|
+
try {
|
|
174
|
+
for (;;) {
|
|
175
|
+
const { value, done } = await reader.read();
|
|
176
|
+
if (done) break;
|
|
177
|
+
buffer += decoder.decode(value, { stream: true }).replace(/\r\n/g, "\n");
|
|
178
|
+
let idx;
|
|
179
|
+
while ((idx = buffer.indexOf("\n\n")) >= 0) {
|
|
180
|
+
dispatch(buffer.slice(0, idx));
|
|
181
|
+
buffer = buffer.slice(idx + 2);
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
if (buffer.trim()) dispatch(buffer);
|
|
185
|
+
} catch (error) {
|
|
186
|
+
if (error?.name === "AbortError") return { ok: false, status: 0, error: "aborted", aborted: true };
|
|
187
|
+
return { ok: false, status: res.status, error: `stream broke: ${String(error)}` };
|
|
188
|
+
}
|
|
189
|
+
return { ok: true, status: res.status, data: null };
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
let posted = 0;
|
|
193
|
+
const seen = new Set();
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Report a browser-side error to Makersclaw (POST _claw/events). Deduped
|
|
197
|
+
* by message+stack and capped per page load, so an error that fires on
|
|
198
|
+
* every render cannot flood the gateway. Best-effort: never throws.
|
|
199
|
+
*
|
|
200
|
+
* `source` defaults to errorSource() — "preview" while the artifact is a
|
|
201
|
+
* draft, "runtime" once launched — so the bench and the platform agent can
|
|
202
|
+
* tell the two apart. Pass it only when the caller knows better.
|
|
203
|
+
*/
|
|
204
|
+
export async function reportError({ message, stack, url, source }) {
|
|
205
|
+
const key = `${message}\n${stack ?? ""}`;
|
|
206
|
+
if (seen.has(key) || posted >= 20) return { ok: false, status: 0, error: "skipped" };
|
|
207
|
+
seen.add(key);
|
|
208
|
+
posted += 1;
|
|
209
|
+
return claw("events", {
|
|
210
|
+
method: "POST",
|
|
211
|
+
body: {
|
|
212
|
+
kind: "error",
|
|
213
|
+
source: source ?? errorSource(),
|
|
214
|
+
message: String(message ?? "error").slice(0, 2000),
|
|
215
|
+
stack: stack ? String(stack).slice(0, 8000) : undefined,
|
|
216
|
+
url: url ?? (typeof location !== "undefined" ? location.href : undefined),
|
|
217
|
+
},
|
|
218
|
+
});
|
|
219
|
+
}
|