@dashai/cli 0.17.0 → 0.18.1
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/dist/bin.js +885 -279
- package/dist/bin.js.map +1 -1
- package/package.json +2 -2
package/dist/bin.js
CHANGED
|
@@ -1276,9 +1276,30 @@ function manifestJson(opts) {
|
|
|
1276
1276
|
// table keys, and it derives the display name from the slug. A
|
|
1277
1277
|
// `name` here would fail provisioning's table materialization (#4).
|
|
1278
1278
|
slug: "items",
|
|
1279
|
+
// `human_edit` declares how safe it is for a HUMAN to hand-edit a
|
|
1280
|
+
// column directly in the Data Hub grid. Your tables are visible and
|
|
1281
|
+
// editable there, and you are the only one who knows which columns
|
|
1282
|
+
// your code actually depends on — a wrong edit doesn't raise an
|
|
1283
|
+
// error, your module just misbehaves later. Seeded on both example
|
|
1284
|
+
// fields so the vocabulary is discoverable without the docs:
|
|
1285
|
+
//
|
|
1286
|
+
// 'locked' — an invariant your module maintains: the status your
|
|
1287
|
+
// state machine advances, foreign keys, external ids,
|
|
1288
|
+
// money/ledger amounts, sync cursors. Writes are
|
|
1289
|
+
// REFUSED, and `human_edit_reason` is REQUIRED.
|
|
1290
|
+
// 'guarded' — a human may legitimately set it, but your module
|
|
1291
|
+
// reacts to the value (due dates you notify from, the
|
|
1292
|
+
// assignee you route to). The edit lands, after a
|
|
1293
|
+
// warning. This is the default when omitted.
|
|
1294
|
+
// 'open' — data your module never reads: notes, descriptions.
|
|
1295
|
+
// No lock, no warning.
|
|
1296
|
+
//
|
|
1297
|
+
// Prefer marking genuinely inert columns 'open' over leaving
|
|
1298
|
+
// everything on the default — a warning on every column just trains
|
|
1299
|
+
// people to click through warnings.
|
|
1279
1300
|
fields: [
|
|
1280
|
-
{ slug: "title", type: "text" },
|
|
1281
|
-
{ slug: "done", type: "boolean" }
|
|
1301
|
+
{ slug: "title", type: "text", human_edit: "open" },
|
|
1302
|
+
{ slug: "done", type: "boolean", human_edit: "guarded" }
|
|
1282
1303
|
]
|
|
1283
1304
|
}
|
|
1284
1305
|
]
|
|
@@ -1378,6 +1399,33 @@ const nextConfig = {
|
|
|
1378
1399
|
NEXT_PUBLIC_DASHWISE_API_URL:
|
|
1379
1400
|
process.env.NEXT_PUBLIC_DASHWISE_API_URL ?? apiUrl,
|
|
1380
1401
|
},
|
|
1402
|
+
// WHO MAY FRAME THIS MODULE.
|
|
1403
|
+
//
|
|
1404
|
+
// A module is designed to be embedded, and \`@dashai/sdk/platform\` treats
|
|
1405
|
+
// "framed" as "embedded in Dashwise": it hides the header, nav, theme toggle
|
|
1406
|
+
// and sign-out, and accepts theme + brand from the parent. Without a
|
|
1407
|
+
// frame-ancestors directive ANY site can iframe a signed-in module and get a
|
|
1408
|
+
// chrome-less, re-skinned copy of it \u2014 the sign-out included.
|
|
1409
|
+
//
|
|
1410
|
+
// Default: only the Dashwise app. Set DASHWISE_FRAME_ANCESTORS to a
|
|
1411
|
+
// space-separated source list to allow others (e.g. a white-labelled host or
|
|
1412
|
+
// your own portal), or to \`'none'\` for a module that is never embedded.
|
|
1413
|
+
async headers() {
|
|
1414
|
+
const ancestors =
|
|
1415
|
+
process.env.DASHWISE_FRAME_ANCESTORS ??
|
|
1416
|
+
"https://app.dashwise.dev https://app.dashwise.net";
|
|
1417
|
+
return [
|
|
1418
|
+
{
|
|
1419
|
+
source: '/:path*',
|
|
1420
|
+
headers: [
|
|
1421
|
+
{
|
|
1422
|
+
key: 'Content-Security-Policy',
|
|
1423
|
+
value: \`frame-ancestors \${ancestors};\`,
|
|
1424
|
+
},
|
|
1425
|
+
],
|
|
1426
|
+
},
|
|
1427
|
+
];
|
|
1428
|
+
},
|
|
1381
1429
|
};
|
|
1382
1430
|
export default nextConfig;
|
|
1383
1431
|
`;
|
|
@@ -1412,7 +1460,7 @@ function gitignore() {
|
|
|
1412
1460
|
".vscode/",
|
|
1413
1461
|
".idea/",
|
|
1414
1462
|
"",
|
|
1415
|
-
"#
|
|
1463
|
+
"# Dashwise-generated SDK output is regenerable from module.json",
|
|
1416
1464
|
"# (run `dashwise module generate`); keep it out of source control.",
|
|
1417
1465
|
"/node_modules/@dashai/generated/",
|
|
1418
1466
|
""
|
|
@@ -1462,7 +1510,7 @@ function envLocal(apiUrl = SCAFFOLD_DEFAULT_API_URL) {
|
|
|
1462
1510
|
function appLayoutTsx(opts) {
|
|
1463
1511
|
return `export const metadata = {
|
|
1464
1512
|
title: ${JSON.stringify(opts.name)},
|
|
1465
|
-
description: ${JSON.stringify(`
|
|
1513
|
+
description: ${JSON.stringify(`Dashwise module: ${opts.slug}`)},
|
|
1466
1514
|
};
|
|
1467
1515
|
|
|
1468
1516
|
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
|
@@ -1505,7 +1553,7 @@ function readme(opts) {
|
|
|
1505
1553
|
function readmeHandAuthored(opts) {
|
|
1506
1554
|
return `# ${opts.name}
|
|
1507
1555
|
|
|
1508
|
-
A
|
|
1556
|
+
A Dashwise module (hand-authored). Slug: \`${opts.slug}\`.
|
|
1509
1557
|
|
|
1510
1558
|
## Get started
|
|
1511
1559
|
|
|
@@ -1546,9 +1594,9 @@ import { createClient, type Where } from '@dashai/sdk';
|
|
|
1546
1594
|
function readmeCustom(opts) {
|
|
1547
1595
|
return `# ${opts.name}
|
|
1548
1596
|
|
|
1549
|
-
A **custom
|
|
1597
|
+
A **custom Dashwise module** \u2014 a full Next.js App Router app that
|
|
1550
1598
|
runs in a per-installation Fly Machine (P7 substrate). Authenticated
|
|
1551
|
-
via the
|
|
1599
|
+
via the Dashwise NestJS backend through \`@dashai/sdk/auth\`.
|
|
1552
1600
|
|
|
1553
1601
|
Slug: \`${opts.slug}\`.
|
|
1554
1602
|
|
|
@@ -1572,14 +1620,18 @@ npm run dev:next # next dev on :3000 (terminal 2)
|
|
|
1572
1620
|
## Architecture
|
|
1573
1621
|
|
|
1574
1622
|
- **\`module.json\`** \u2014 manifest (\`module_kind: 'custom'\`, \`runtime.kind: 'machine'\`).
|
|
1575
|
-
- **\`middleware.ts\`** \u2014 \`withAuth\` from \`@dashai/sdk/auth/middleware\`. Redirects unauthenticated requests through the
|
|
1623
|
+
- **\`middleware.ts\`** \u2014 \`withAuth\` from \`@dashai/sdk/auth/middleware\`. Redirects unauthenticated requests through the Dashwise SSO flow.
|
|
1576
1624
|
- **\`app/layout.tsx\`** \u2014 wraps the tree in \`<AuthProvider>\` from \`@dashai/sdk/auth/client\`.
|
|
1577
1625
|
- **\`app/page.tsx\`** \u2014 the app's home at \`/\` (auth-gated; zero-click for platform users via \`autoSignIn\`). **This is where you build your app.**
|
|
1578
1626
|
- **\`app/dashboard/page.tsx\`** \u2014 an additional example Server Component route, also session-gated via \`getServerSession()\`.
|
|
1579
1627
|
|
|
1580
|
-
The design is
|
|
1581
|
-
|
|
1582
|
-
|
|
1628
|
+
**The design is not a blank slate.** This module renders inside the Dashwise
|
|
1629
|
+
app shell, so \`app/globals.css\` ships the app's REAL theme tokens: style with
|
|
1630
|
+
them (\`bg-primary\`, \`text-muted-foreground\`, \`rounded-lg\`) and the module
|
|
1631
|
+
inherits the workspace's theme, dark mode and branding automatically. Hardcode
|
|
1632
|
+
a hex and you opt out of all three, permanently. Layout rules (no header, no
|
|
1633
|
+
nav, no \`100vh\`, one \`<h1>\`) are in \`CLAUDE.md\` \u2192 "Embedded-first UI"; reach
|
|
1634
|
+
for more shadcn blocks freely. A manual "Sign in" landing page is only for a
|
|
1583
1635
|
\`public_access_allowed\` module \u2014 re-add \`'/'\` to \`middleware.ts\` publicPaths
|
|
1584
1636
|
and drop the home's session guard to build one.
|
|
1585
1637
|
- **\`app/api/auth/callback/route.ts\`** \u2014 exchanges the SSO one-time code for a session cookie (uses \`exchangeCode()\` from the SDK).
|
|
@@ -1652,7 +1704,7 @@ start-url call.
|
|
|
1652
1704
|
`;
|
|
1653
1705
|
}
|
|
1654
1706
|
function middlewareTs() {
|
|
1655
|
-
return `// Next.js middleware \u2014 gates every route on the
|
|
1707
|
+
return `// Next.js middleware \u2014 gates every route on the Dashwise session cookie.
|
|
1656
1708
|
// Public routes (and /api/auth/*) bypass the redirect; everything else
|
|
1657
1709
|
// 302s through the SSO flow when no \`dashwise.session\` cookie is set.
|
|
1658
1710
|
import { withAuth } from '@dashai/sdk/auth/middleware';
|
|
@@ -1668,12 +1720,12 @@ export default withAuth({
|
|
|
1668
1720
|
// error page instead of redirect-looping back into re-authentication.
|
|
1669
1721
|
//
|
|
1670
1722
|
// Building a \`public_access_allowed\` module that wants an ANONYMOUS
|
|
1671
|
-
// landing page (a marketing / manual "Sign in with
|
|
1723
|
+
// landing page (a marketing / manual "Sign in with Dashwise" screen)?
|
|
1672
1724
|
// Re-add \`'/'\` here AND make app/page.tsx public (drop its
|
|
1673
1725
|
// getServerSession guard). See the README's "Design & home" note.
|
|
1674
1726
|
publicPaths: ['/sign-in-error'],
|
|
1675
1727
|
loginPath: '/api/auth/sign-in',
|
|
1676
|
-
// Silent sign-in: when a signed-in
|
|
1728
|
+
// Silent sign-in: when a signed-in Dashwise user opens the module through
|
|
1677
1729
|
// the platform (in-app embed or the module subdomain), the SSO handshake
|
|
1678
1730
|
// completes with zero clicks \u2014 they land straight on the page they asked
|
|
1679
1731
|
// for, the home at \`/\` included. It never touches:
|
|
@@ -1691,29 +1743,53 @@ export const config = {
|
|
|
1691
1743
|
}
|
|
1692
1744
|
function appLayoutCustomTsx(opts) {
|
|
1693
1745
|
return `import './globals.css';
|
|
1694
|
-
import { AuthProvider } from '@dashai/sdk/auth/client';
|
|
1746
|
+
import { AuthProvider, HostRouteSync } from '@dashai/sdk/auth/client';
|
|
1747
|
+
import { PlatformProvider, PLATFORM_PREPAINT_SCRIPT } from '@dashai/sdk/platform';
|
|
1695
1748
|
import { ThemeProvider } from '@/components/theme-provider';
|
|
1749
|
+
import { PlatformThemeSync } from '@/components/platform-theme-sync';
|
|
1696
1750
|
|
|
1697
1751
|
export const metadata = {
|
|
1698
1752
|
title: ${JSON.stringify(opts.name)},
|
|
1699
|
-
description: ${JSON.stringify(`
|
|
1753
|
+
description: ${JSON.stringify(`Dashwise module: ${opts.slug}`)},
|
|
1700
1754
|
};
|
|
1701
1755
|
|
|
1702
1756
|
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
|
1703
1757
|
return (
|
|
1704
|
-
|
|
1758
|
+
// \`data-dw-surface="embedded"\` is the SERVER default: this module normally
|
|
1759
|
+
// renders inside the Dashwise app shell, where the header/nav/theme toggle
|
|
1760
|
+
// already exist. The pre-paint script below only ever UPGRADES it to
|
|
1761
|
+
// "standalone", so chrome can never flash on top of the app's own.
|
|
1762
|
+
<html lang="en" data-dw-surface="embedded" suppressHydrationWarning>
|
|
1763
|
+
<head>
|
|
1764
|
+
{/* Runs before first paint: stamps data-dw-surface on <html> and
|
|
1765
|
+
re-applies the cached workspace brand. Keeps \`.dw-standalone-only\`
|
|
1766
|
+
correct on the very first frame \u2014 a React-only guard would pop in
|
|
1767
|
+
after hydration. */}
|
|
1768
|
+
<script dangerouslySetInnerHTML={{ __html: PLATFORM_PREPAINT_SCRIPT }} />
|
|
1769
|
+
</head>
|
|
1705
1770
|
<body className="antialiased">
|
|
1706
|
-
<
|
|
1707
|
-
|
|
1708
|
-
|
|
1709
|
-
|
|
1710
|
-
|
|
1711
|
-
|
|
1712
|
-
|
|
1713
|
-
|
|
1714
|
-
|
|
1715
|
-
|
|
1716
|
-
|
|
1771
|
+
<PlatformProvider>
|
|
1772
|
+
<ThemeProvider
|
|
1773
|
+
attribute="class"
|
|
1774
|
+
defaultTheme="system"
|
|
1775
|
+
enableSystem
|
|
1776
|
+
disableTransitionOnChange
|
|
1777
|
+
>
|
|
1778
|
+
{/* Follows the Dashwise app's light/dark choice when embedded.
|
|
1779
|
+
Bridges the SDK to next-themes so the SDK stays theming-library
|
|
1780
|
+
agnostic. Inert when standalone \u2014 your own toggle wins there. */}
|
|
1781
|
+
<PlatformThemeSync />
|
|
1782
|
+
{/* sessionEndpoint points at the same-origin /api/auth/session
|
|
1783
|
+
route so the host-scoped session cookie is actually sent \u2014
|
|
1784
|
+
a cross-origin fetch to the API would drop it (D8). */}
|
|
1785
|
+
<AuthProvider sessionEndpoint="/api/auth/session">
|
|
1786
|
+
{/* Mirrors in-module navigation to the Dashwise host's address
|
|
1787
|
+
bar when embedded; inert when standalone. */}
|
|
1788
|
+
<HostRouteSync />
|
|
1789
|
+
{children}
|
|
1790
|
+
</AuthProvider>
|
|
1791
|
+
</ThemeProvider>
|
|
1792
|
+
</PlatformProvider>
|
|
1717
1793
|
</body>
|
|
1718
1794
|
</html>
|
|
1719
1795
|
);
|
|
@@ -1721,40 +1797,37 @@ export default function RootLayout({ children }: { children: React.ReactNode })
|
|
|
1721
1797
|
`;
|
|
1722
1798
|
}
|
|
1723
1799
|
function appPageCustomTsx(opts) {
|
|
1724
|
-
return `// app/page.tsx \u2014
|
|
1725
|
-
// Build it here.
|
|
1800
|
+
return `// app/page.tsx \u2014 your module's REAL home, served at \`/\`. Build it here.
|
|
1726
1801
|
//
|
|
1727
|
-
// A signed-in
|
|
1728
|
-
//
|
|
1729
|
-
//
|
|
1730
|
-
//
|
|
1731
|
-
// below mirrors app/dashboard/page.tsx \u2014 no session \u2192 bounce through the
|
|
1732
|
-
// SSO flow and come right back to \`/\`.
|
|
1802
|
+
// A signed-in Dashwise user who opens this module through the platform (the
|
|
1803
|
+
// in-app embed or the module subdomain) lands on THIS page with zero clicks:
|
|
1804
|
+
// the middleware's \`autoSignIn\` completes the SSO handshake silently, so there
|
|
1805
|
+
// is no sign-in wall in front of your app.
|
|
1733
1806
|
//
|
|
1734
|
-
//
|
|
1735
|
-
//
|
|
1736
|
-
//
|
|
1737
|
-
//
|
|
1807
|
+
// \u2500\u2500\u2500 READ THIS BEFORE YOU REDESIGN THE PAGE \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
|
|
1808
|
+
// This module normally renders as a full-bleed IFRAME inside the Dashwise app
|
|
1809
|
+
// shell. The header, sidebar, workspace switcher, user menu, theme toggle and
|
|
1810
|
+
// global search ALREADY EXIST around you. So:
|
|
1738
1811
|
//
|
|
1739
|
-
//
|
|
1740
|
-
//
|
|
1741
|
-
//
|
|
1742
|
-
//
|
|
1743
|
-
|
|
1812
|
+
// \xB7 no logo, no top nav, no sidebar, no footer, no theme toggle, no sign-out
|
|
1813
|
+
// \xB7 no \`min-h-screen\` / \`h-screen\` / \`100vh\` \u2014 you are not the screen
|
|
1814
|
+
// \xB7 exactly ONE <h1> per screen, and one primary action
|
|
1815
|
+
// \xB7 style with TOKENS (bg-primary, text-muted-foreground, rounded-lg) \u2014 never
|
|
1816
|
+
// a raw hex/px, or the module stops inheriting the workspace's theme
|
|
1817
|
+
//
|
|
1818
|
+
// \`ModuleShell\` below encodes all of that. Anything that should appear only
|
|
1819
|
+
// when the module is opened on its own subdomain goes in \`.dw-standalone-only\`.
|
|
1820
|
+
// See CLAUDE.md \u2192 "Embedded-first UI" for the full contract.
|
|
1744
1821
|
import { getServerSession } from '@dashai/sdk/auth/server';
|
|
1745
1822
|
import { redirect } from 'next/navigation';
|
|
1746
|
-
import {
|
|
1823
|
+
import { PlusIcon } from 'lucide-react';
|
|
1747
1824
|
import { Button } from '@/components/ui/button';
|
|
1748
|
-
import {
|
|
1749
|
-
|
|
1750
|
-
CardDescription,
|
|
1751
|
-
CardHeader,
|
|
1752
|
-
CardTitle,
|
|
1753
|
-
} from '@/components/ui/card';
|
|
1825
|
+
import { ModuleShell } from '@/components/module-shell';
|
|
1826
|
+
import { EmptyState } from '@/components/states';
|
|
1754
1827
|
|
|
1755
1828
|
// force-dynamic: this page calls getServerSession() at render, which
|
|
1756
1829
|
// reads DASHWISE_API_URL/KEY. \`next build\` statically PRERENDERS pages by
|
|
1757
|
-
// default and runs with NO
|
|
1830
|
+
// default and runs with NO Dashwise env, so the SDK would throw
|
|
1758
1831
|
// "missing apiBaseUrl" and the platform's first publish would fail with
|
|
1759
1832
|
// an opaque BUILD_FAILED. Opting into dynamic rendering skips prerender.
|
|
1760
1833
|
export const dynamic = 'force-dynamic';
|
|
@@ -1764,76 +1837,32 @@ export default async function Home() {
|
|
|
1764
1837
|
if (!session) {
|
|
1765
1838
|
redirect('/api/auth/sign-in?return_to=/');
|
|
1766
1839
|
}
|
|
1840
|
+
|
|
1841
|
+
// Replace this with your real first screen: list what the user came to see,
|
|
1842
|
+
// and render EmptyState only while there is genuinely nothing yet. Keep the
|
|
1843
|
+
// loading state (ListSkeleton) and error state (ErrorState) \u2014 they're in
|
|
1844
|
+
// components/states.tsx and they are what makes a module feel finished.
|
|
1767
1845
|
return (
|
|
1768
|
-
<
|
|
1769
|
-
{
|
|
1770
|
-
|
|
1771
|
-
|
|
1772
|
-
|
|
1773
|
-
|
|
1774
|
-
</
|
|
1775
|
-
|
|
1776
|
-
|
|
1777
|
-
|
|
1778
|
-
|
|
1779
|
-
|
|
1780
|
-
|
|
1781
|
-
|
|
1782
|
-
|
|
1783
|
-
|
|
1784
|
-
</code>{' '}
|
|
1785
|
-
to build it.
|
|
1786
|
-
</p>
|
|
1787
|
-
<div className="flex flex-wrap gap-3 pt-2">
|
|
1788
|
-
<Button asChild size="lg" variant="secondary">
|
|
1789
|
-
<Link href="/dashboard">
|
|
1790
|
-
See the example route
|
|
1791
|
-
<ArrowRight aria-hidden="true" />
|
|
1792
|
-
</Link>
|
|
1846
|
+
<ModuleShell
|
|
1847
|
+
title="${escapeHtml(opts.name)}"
|
|
1848
|
+
primaryAction={
|
|
1849
|
+
<Button>
|
|
1850
|
+
<PlusIcon aria-hidden="true" />
|
|
1851
|
+
Add item
|
|
1852
|
+
</Button>
|
|
1853
|
+
}
|
|
1854
|
+
>
|
|
1855
|
+
<EmptyState
|
|
1856
|
+
title="Nothing here yet"
|
|
1857
|
+
description="This is your module's home. Build the first screen in app/page.tsx \u2014 declare your tables with \`dashwise table create\`, then read them with the typed \`db\` client from @dashai/generated."
|
|
1858
|
+
action={
|
|
1859
|
+
<Button>
|
|
1860
|
+
<PlusIcon aria-hidden="true" />
|
|
1861
|
+
Add item
|
|
1793
1862
|
</Button>
|
|
1794
|
-
|
|
1795
|
-
|
|
1796
|
-
|
|
1797
|
-
{/* A few colored pointers to get moving. */}
|
|
1798
|
-
<section className="grid gap-4 sm:grid-cols-3">
|
|
1799
|
-
<Card className="border-l-4 border-l-chart-1">
|
|
1800
|
-
<CardHeader>
|
|
1801
|
-
<Table2 className="size-5 text-chart-1" aria-hidden="true" />
|
|
1802
|
-
<CardTitle className="text-base">Declare your data</CardTitle>
|
|
1803
|
-
<CardDescription>
|
|
1804
|
-
Add tables to{' '}
|
|
1805
|
-
<code className="rounded bg-muted px-1 py-0.5 font-mono">module.json</code>{' '}
|
|
1806
|
-
(or run{' '}
|
|
1807
|
-
<code className="rounded bg-muted px-1 py-0.5 font-mono">dashwise table create</code>).
|
|
1808
|
-
</CardDescription>
|
|
1809
|
-
</CardHeader>
|
|
1810
|
-
</Card>
|
|
1811
|
-
<Card className="border-l-4 border-l-chart-2">
|
|
1812
|
-
<CardHeader>
|
|
1813
|
-
<Database className="size-5 text-chart-2" aria-hidden="true" />
|
|
1814
|
-
<CardTitle className="text-base">Read it, typed</CardTitle>
|
|
1815
|
-
<CardDescription>
|
|
1816
|
-
Import{' '}
|
|
1817
|
-
<code className="rounded bg-muted px-1 py-0.5 font-mono">db</code>{' '}
|
|
1818
|
-
from{' '}
|
|
1819
|
-
<code className="rounded bg-muted px-1 py-0.5 font-mono">@dashai/generated</code>{' '}
|
|
1820
|
-
\u2014 regenerated on every manifest save.
|
|
1821
|
-
</CardDescription>
|
|
1822
|
-
</CardHeader>
|
|
1823
|
-
</Card>
|
|
1824
|
-
<Card className="border-l-4 border-l-chart-3">
|
|
1825
|
-
<CardHeader>
|
|
1826
|
-
<PenLine className="size-5 text-chart-3" aria-hidden="true" />
|
|
1827
|
-
<CardTitle className="text-base">Write via server actions</CardTitle>
|
|
1828
|
-
<CardDescription>
|
|
1829
|
-
Mutate data from Server Actions / route handlers so the scoped{' '}
|
|
1830
|
-
<code className="rounded bg-muted px-1 py-0.5 font-mono">dwk_</code>{' '}
|
|
1831
|
-
key stays server-side.
|
|
1832
|
-
</CardDescription>
|
|
1833
|
-
</CardHeader>
|
|
1834
|
-
</Card>
|
|
1835
|
-
</section>
|
|
1836
|
-
</main>
|
|
1863
|
+
}
|
|
1864
|
+
/>
|
|
1865
|
+
</ModuleShell>
|
|
1837
1866
|
);
|
|
1838
1867
|
}
|
|
1839
1868
|
`;
|
|
@@ -1843,6 +1872,7 @@ function appDashboardPageTsx() {
|
|
|
1843
1872
|
import { redirect } from 'next/navigation';
|
|
1844
1873
|
import { LogOut, ShieldCheck } from 'lucide-react';
|
|
1845
1874
|
import { Button } from '@/components/ui/button';
|
|
1875
|
+
import { ModuleShell } from '@/components/module-shell';
|
|
1846
1876
|
import {
|
|
1847
1877
|
Card,
|
|
1848
1878
|
CardContent,
|
|
@@ -1853,14 +1883,16 @@ import {
|
|
|
1853
1883
|
|
|
1854
1884
|
/**
|
|
1855
1885
|
* An ADDITIONAL example route \u2014 your real home is app/page.tsx (\`/\`).
|
|
1856
|
-
* This \`/dashboard\` page
|
|
1857
|
-
*
|
|
1858
|
-
*
|
|
1859
|
-
*
|
|
1860
|
-
*
|
|
1886
|
+
* This \`/dashboard\` page shows a second auth-gated Server Component: keep
|
|
1887
|
+
* it, rename it, or delete it. The middleware ensures the user has a session
|
|
1888
|
+
* cookie before this page renders; \`getServerSession()\` validates the cookie
|
|
1889
|
+
* against the backend's \`/api/auth/sessions/me\` endpoint, returning the
|
|
1890
|
+
* decoded \`Session\` (or \`null\` if stale).
|
|
1861
1891
|
*
|
|
1862
|
-
*
|
|
1863
|
-
*
|
|
1892
|
+
* Note the shape: \`ModuleShell\` (one <h1>, app-matching padding, no 100vh)
|
|
1893
|
+
* and a sign-out control wrapped in \`.dw-standalone-only\` \u2014 inside the
|
|
1894
|
+
* Dashwise app the user menu already offers sign-out, so rendering our own
|
|
1895
|
+
* would duplicate the app's chrome. See CLAUDE.md \u2192 "Embedded-first UI".
|
|
1864
1896
|
*/
|
|
1865
1897
|
// force-dynamic: same reason as app/page.tsx \u2014 this route calls
|
|
1866
1898
|
// getServerSession() at render, which needs DASHWISE_API_URL/KEY. Static
|
|
@@ -1873,23 +1905,19 @@ export default async function Dashboard() {
|
|
|
1873
1905
|
redirect('/api/auth/sign-in?return_to=/');
|
|
1874
1906
|
}
|
|
1875
1907
|
return (
|
|
1876
|
-
<
|
|
1877
|
-
|
|
1878
|
-
|
|
1879
|
-
|
|
1880
|
-
|
|
1881
|
-
|
|
1882
|
-
validates the session cookie on every request.
|
|
1883
|
-
</p>
|
|
1884
|
-
</div>
|
|
1885
|
-
<form action="/api/auth/sign-out" method="POST">
|
|
1908
|
+
<ModuleShell
|
|
1909
|
+
title="Dashboard"
|
|
1910
|
+
description="Rendered as a Server Component; the session cookie is validated on every request."
|
|
1911
|
+
width="narrow"
|
|
1912
|
+
actions={
|
|
1913
|
+
<form action="/api/auth/sign-out" method="POST" className="dw-standalone-only">
|
|
1886
1914
|
<Button variant="outline" size="sm" type="submit">
|
|
1887
1915
|
<LogOut aria-hidden="true" />
|
|
1888
1916
|
Sign out
|
|
1889
1917
|
</Button>
|
|
1890
1918
|
</form>
|
|
1891
|
-
|
|
1892
|
-
|
|
1919
|
+
}
|
|
1920
|
+
>
|
|
1893
1921
|
<Card>
|
|
1894
1922
|
<CardHeader>
|
|
1895
1923
|
<CardTitle className="flex items-center gap-2">
|
|
@@ -1918,36 +1946,7 @@ export default async function Dashboard() {
|
|
|
1918
1946
|
)}
|
|
1919
1947
|
</CardContent>
|
|
1920
1948
|
</Card>
|
|
1921
|
-
|
|
1922
|
-
<Card>
|
|
1923
|
-
<CardHeader>
|
|
1924
|
-
<CardTitle>Next steps</CardTitle>
|
|
1925
|
-
<CardDescription>
|
|
1926
|
-
This is an extra example route \u2014 your real home is{' '}
|
|
1927
|
-
<code className="rounded bg-muted px-1.5 py-0.5">app/page.tsx</code>.
|
|
1928
|
-
Keep, rename, or delete this one.
|
|
1929
|
-
</CardDescription>
|
|
1930
|
-
</CardHeader>
|
|
1931
|
-
<CardContent className="space-y-2 text-sm text-muted-foreground">
|
|
1932
|
-
<p>
|
|
1933
|
-
<strong className="text-foreground">1.</strong> Edit{' '}
|
|
1934
|
-
<code className="rounded bg-muted px-1.5 py-0.5">module.json</code>{' '}
|
|
1935
|
-
to add tables, dependencies, and exposed actions.
|
|
1936
|
-
</p>
|
|
1937
|
-
<p>
|
|
1938
|
-
<strong className="text-foreground">2.</strong> Import typed clients
|
|
1939
|
-
from{' '}
|
|
1940
|
-
<code className="rounded bg-muted px-1.5 py-0.5">@dashai/generated</code>{' '}
|
|
1941
|
-
(regenerated on every manifest save).
|
|
1942
|
-
</p>
|
|
1943
|
-
<p>
|
|
1944
|
-
<strong className="text-foreground">3.</strong> Run{' '}
|
|
1945
|
-
<code className="rounded bg-muted px-1.5 py-0.5">dashwise module publish --bump patch</code>{' '}
|
|
1946
|
-
when you're ready to ship.
|
|
1947
|
-
</p>
|
|
1948
|
-
</CardContent>
|
|
1949
|
-
</Card>
|
|
1950
|
-
</main>
|
|
1949
|
+
</ModuleShell>
|
|
1951
1950
|
);
|
|
1952
1951
|
}
|
|
1953
1952
|
|
|
@@ -1965,7 +1964,7 @@ function appApiAuthSignInTs() {
|
|
|
1965
1964
|
return `// /api/auth/sign-in \u2014 kick off the SSO flow (one path, dev + prod).
|
|
1966
1965
|
//
|
|
1967
1966
|
// Reads \`?return_to=\` from the caller (defaults to '/'), then asks the
|
|
1968
|
-
//
|
|
1967
|
+
// Dashwise backend for a redirect URL by calling the module-API
|
|
1969
1968
|
// server-side start endpoint:
|
|
1970
1969
|
//
|
|
1971
1970
|
// POST /api/module-api/sso/start-url
|
|
@@ -2002,7 +2001,7 @@ export async function GET(request: Request) {
|
|
|
2002
2001
|
const returnTo = url.searchParams.get('return_to') ?? '/';
|
|
2003
2002
|
// \`auto=1\` marks a SILENT attempt (the middleware's autoSignIn redirect,
|
|
2004
2003
|
// no user click). Its failures degrade to the public landing page \u2014 where
|
|
2005
|
-
// the manual "Sign in with
|
|
2004
|
+
// the manual "Sign in with Dashwise" button lives \u2014 instead of the error
|
|
2006
2005
|
// page: the user never asked for anything, so don't show them an error.
|
|
2007
2006
|
const isAuto = url.searchParams.get('auto') === '1';
|
|
2008
2007
|
const apiBaseUrl = process.env.DASHWISE_API_URL;
|
|
@@ -2173,7 +2172,7 @@ async function tryDevHandoff(
|
|
|
2173
2172
|
}
|
|
2174
2173
|
function appApiAuthCallbackTs() {
|
|
2175
2174
|
return `// /api/auth/callback \u2014 receives \`?code=&return_to=\` from the
|
|
2176
|
-
//
|
|
2175
|
+
// Dashwise SSO start endpoint OR \`?error=&error_description=\` if the
|
|
2177
2176
|
// SSO flow failed. Two paths:
|
|
2178
2177
|
//
|
|
2179
2178
|
// 1. Success path: exchange \`?code\` for a session cookie via
|
|
@@ -2322,7 +2321,7 @@ function appApiAuthSessionTs() {
|
|
|
2322
2321
|
return `// /api/auth/session \u2014 SAME-ORIGIN session probe for client components.
|
|
2323
2322
|
//
|
|
2324
2323
|
// D8: the browser's <AuthProvider> can't read the session by calling the
|
|
2325
|
-
// cross-origin
|
|
2324
|
+
// cross-origin Dashwise API directly, because the \`dashwise.session\`
|
|
2326
2325
|
// cookie is host-scoped to THIS module's origin and is never sent to
|
|
2327
2326
|
// \`api.dashwise.net\`. So <AuthProvider sessionEndpoint="/api/auth/session">
|
|
2328
2327
|
// hits this route (same origin \u2192 the cookie IS sent); we validate it
|
|
@@ -2360,12 +2359,15 @@ function packageJsonCustom(opts) {
|
|
|
2360
2359
|
start: "next start"
|
|
2361
2360
|
},
|
|
2362
2361
|
dependencies: {
|
|
2363
|
-
|
|
2362
|
+
// >=2.4.0 for `@dashai/sdk/platform`, which app/layout.tsx imports. A
|
|
2363
|
+
// ^2.3.0 floor resolves to 2.3.0 until 2.4.0 publishes, failing the
|
|
2364
|
+
// first `next build` with an opaque module-not-found.
|
|
2365
|
+
"@dashai/sdk": "^2.4.0",
|
|
2364
2366
|
next: "^15.0.0",
|
|
2365
2367
|
react: "^19.0.0",
|
|
2366
2368
|
"react-dom": "^19.0.0",
|
|
2367
2369
|
// Styling system — same shadcn + Tailwind 4 stack as the
|
|
2368
|
-
// main
|
|
2370
|
+
// main Dashwise frontend, so look-and-feel stays consistent
|
|
2369
2371
|
// and module authors can add more blocks via the
|
|
2370
2372
|
// shadcn-studio MCP (`npx shadcn add @shadcn-studio/...`).
|
|
2371
2373
|
tailwindcss: "^4",
|
|
@@ -2439,11 +2441,18 @@ export default config;
|
|
|
2439
2441
|
`;
|
|
2440
2442
|
}
|
|
2441
2443
|
function globalsCss() {
|
|
2442
|
-
return `/*
|
|
2443
|
-
|
|
2444
|
-
|
|
2445
|
-
|
|
2446
|
-
|
|
2444
|
+
return `/* Dashwise design tokens \u2014 the SAME values the Dashwise app uses.
|
|
2445
|
+
This is the entire mechanism by which a module looks like it belongs
|
|
2446
|
+
inside Dashwise: style with the TOKEN (\`bg-primary\`, \`text-muted-foreground\`,
|
|
2447
|
+
\`rounded-lg\`, \`border-border\`), never a raw hex/px/shadow, and the module
|
|
2448
|
+
inherits the app's look \u2014 and every future theme change \u2014 for free.
|
|
2449
|
+
|
|
2450
|
+
Hardcoding a colour opts out of that permanently. If you're typing \`#\`,
|
|
2451
|
+
\`oklch(\`, \`rounded-[\` or \`shadow-[\` in a component, there is a token for it.
|
|
2452
|
+
|
|
2453
|
+
\`--primary\` and \`--brand-accent\` are additionally overridden at runtime by
|
|
2454
|
+
the workspace's branding (via @dashai/sdk/platform) \u2014 which is why a module
|
|
2455
|
+
that uses the tokens picks up white-labelling with no code changes. */
|
|
2447
2456
|
@import "tailwindcss";
|
|
2448
2457
|
@import "tw-animate-css";
|
|
2449
2458
|
@import "shadcn/tailwind.css";
|
|
@@ -2457,39 +2466,74 @@ function globalsCss() {
|
|
|
2457
2466
|
--card-foreground: oklch(0.14 0 0);
|
|
2458
2467
|
--popover: oklch(1 0 0);
|
|
2459
2468
|
--popover-foreground: oklch(0.14 0 0);
|
|
2460
|
-
|
|
2461
|
-
|
|
2462
|
-
--
|
|
2463
|
-
--
|
|
2464
|
-
|
|
2465
|
-
|
|
2466
|
-
--
|
|
2467
|
-
--
|
|
2468
|
-
|
|
2469
|
-
|
|
2470
|
-
|
|
2471
|
-
--accent:
|
|
2472
|
-
--accent-foreground:
|
|
2469
|
+
--primary: #4f46e5;
|
|
2470
|
+
--primary-foreground: oklch(0.99 0 0);
|
|
2471
|
+
--secondary: oklch(0.97 0 0);
|
|
2472
|
+
--secondary-foreground: oklch(0.2 0 0);
|
|
2473
|
+
--muted: oklch(0.97 0 0);
|
|
2474
|
+
--muted-foreground: oklch(0.5 0 0);
|
|
2475
|
+
--accent: oklch(0.96 0 0);
|
|
2476
|
+
--accent-foreground: oklch(0.14 0 0);
|
|
2477
|
+
/* A SECONDARY brand colour, distinct from the neutral --accent hover
|
|
2478
|
+
surface above. Workspace branding drives this one; never re-point
|
|
2479
|
+
--accent at a brand colour or every hover state in the app gets tinted. */
|
|
2480
|
+
--brand-accent: #4f46e5;
|
|
2481
|
+
--brand-accent-foreground: #ffffff;
|
|
2473
2482
|
--destructive: oklch(0.58 0.24 28.48);
|
|
2483
|
+
--success: oklch(0.53 0.15 145);
|
|
2484
|
+
--success-foreground: oklch(0.99 0 0);
|
|
2485
|
+
--warning: oklch(0.63 0.15 62);
|
|
2486
|
+
--warning-foreground: oklch(0.99 0 0);
|
|
2474
2487
|
--border: oklch(0.92 0 0);
|
|
2475
2488
|
--input: oklch(0.92 0 0);
|
|
2476
|
-
--ring:
|
|
2477
|
-
--
|
|
2478
|
-
|
|
2479
|
-
|
|
2480
|
-
|
|
2481
|
-
--chart-
|
|
2482
|
-
|
|
2483
|
-
|
|
2484
|
-
|
|
2485
|
-
|
|
2489
|
+
--ring: #4f46e5;
|
|
2490
|
+
--chart-1: oklch(0.65 0.22 36.85);
|
|
2491
|
+
--chart-2: oklch(0.6 0.11 184.15);
|
|
2492
|
+
--chart-3: oklch(0.4 0.07 227.18);
|
|
2493
|
+
--chart-4: oklch(0.83 0.17 81.03);
|
|
2494
|
+
--chart-5: oklch(0.77 0.17 65.36);
|
|
2495
|
+
|
|
2496
|
+
/* Option colours \u2014 the palette for single_select / multi_select values and
|
|
2497
|
+
any status chip. Stable across themes. Use these instead of inventing a
|
|
2498
|
+
colour for a status, so labels match the app's. */
|
|
2499
|
+
--option-slate: oklch(0.55 0.041 257.4);
|
|
2500
|
+
--option-red: oklch(0.637 0.208 25.3);
|
|
2501
|
+
--option-dark-red: oklch(0.443 0.177 26.9);
|
|
2502
|
+
--option-orange: oklch(0.705 0.191 47.6);
|
|
2503
|
+
--option-amber: oklch(0.769 0.166 70.1);
|
|
2504
|
+
--option-yellow: oklch(0.852 0.176 91.7);
|
|
2505
|
+
--option-green: oklch(0.696 0.149 162.5);
|
|
2506
|
+
--option-blue: oklch(0.623 0.214 259.8);
|
|
2507
|
+
--option-indigo: oklch(0.585 0.222 277.1);
|
|
2508
|
+
--option-purple: oklch(0.627 0.265 303.9);
|
|
2509
|
+
--option-pink: oklch(0.656 0.241 354.3);
|
|
2510
|
+
|
|
2511
|
+
--code-bg: oklch(0.14 0 0);
|
|
2512
|
+
--code-fg: oklch(0.97 0 0);
|
|
2486
2513
|
|
|
2487
2514
|
--font-sans:
|
|
2488
|
-
ui-sans-serif, system-ui, -apple-system,
|
|
2489
|
-
"Segoe UI", Roboto, "Helvetica Neue", Arial,
|
|
2515
|
+
"Geist", "Geist Fallback", ui-sans-serif, system-ui, -apple-system,
|
|
2516
|
+
BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial,
|
|
2517
|
+
"Noto Sans", sans-serif, "Apple Color Emoji", "Segoe UI Emoji",
|
|
2518
|
+
"Segoe UI Symbol", "Noto Color Emoji";
|
|
2490
2519
|
--font-mono:
|
|
2491
|
-
ui-monospace, SFMono-Regular, Menlo,
|
|
2492
|
-
"Liberation Mono", "Courier New", monospace;
|
|
2520
|
+
"Geist Mono", "Geist Mono Fallback", ui-monospace, SFMono-Regular, Menlo,
|
|
2521
|
+
Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
|
|
2522
|
+
|
|
2523
|
+
--radius: 0.625rem;
|
|
2524
|
+
|
|
2525
|
+
--shadow-2xs: 0 1px 3px 0px oklch(0 0 0 / 0.05);
|
|
2526
|
+
--shadow-xs: 0 1px 3px 0px oklch(0 0 0 / 0.05);
|
|
2527
|
+
--shadow-sm:
|
|
2528
|
+
0 1px 3px 0px oklch(0 0 0 / 0.1), 0 1px 2px -1px oklch(0 0 0 / 0.1);
|
|
2529
|
+
--shadow: 0 1px 3px 0px oklch(0 0 0 / 0.1), 0 1px 2px -1px oklch(0 0 0 / 0.1);
|
|
2530
|
+
--shadow-md:
|
|
2531
|
+
0 1px 3px 0px oklch(0 0 0 / 0.1), 0 2px 4px -1px oklch(0 0 0 / 0.1);
|
|
2532
|
+
--shadow-lg:
|
|
2533
|
+
0 1px 3px 0px oklch(0 0 0 / 0.1), 0 4px 6px -1px oklch(0 0 0 / 0.1);
|
|
2534
|
+
--shadow-xl:
|
|
2535
|
+
0 1px 3px 0px oklch(0 0 0 / 0.1), 0 8px 10px -1px oklch(0 0 0 / 0.1);
|
|
2536
|
+
--shadow-2xl: 0 1px 3px 0px oklch(0 0 0 / 0.25);
|
|
2493
2537
|
}
|
|
2494
2538
|
|
|
2495
2539
|
.dark {
|
|
@@ -2499,26 +2543,31 @@ function globalsCss() {
|
|
|
2499
2543
|
--card-foreground: oklch(0.99 0 0);
|
|
2500
2544
|
--popover: oklch(0.2 0 0);
|
|
2501
2545
|
--popover-foreground: oklch(0.99 0 0);
|
|
2502
|
-
|
|
2503
|
-
|
|
2504
|
-
--
|
|
2505
|
-
--primary-foreground: oklch(0.985 0 0);
|
|
2506
|
-
--secondary: oklch(0.27 0.01 275);
|
|
2546
|
+
--primary: #6253f0;
|
|
2547
|
+
--primary-foreground: #ffffff;
|
|
2548
|
+
--secondary: oklch(0.27 0 0);
|
|
2507
2549
|
--secondary-foreground: oklch(0.99 0 0);
|
|
2508
|
-
--muted: oklch(0.27 0
|
|
2509
|
-
--muted-foreground: oklch(0.71 0
|
|
2510
|
-
--accent: oklch(0.
|
|
2511
|
-
--accent-foreground: oklch(0.
|
|
2550
|
+
--muted: oklch(0.27 0 0);
|
|
2551
|
+
--muted-foreground: oklch(0.71 0 0);
|
|
2552
|
+
--accent: oklch(0.27 0 0);
|
|
2553
|
+
--accent-foreground: oklch(0.99 0 0);
|
|
2554
|
+
--brand-accent: #6253f0;
|
|
2555
|
+
--brand-accent-foreground: #ffffff;
|
|
2512
2556
|
--destructive: oklch(0.7 0.19 22.23);
|
|
2513
|
-
--
|
|
2514
|
-
--
|
|
2515
|
-
--
|
|
2516
|
-
|
|
2517
|
-
--
|
|
2518
|
-
--
|
|
2519
|
-
--
|
|
2520
|
-
--chart-
|
|
2521
|
-
--chart-
|
|
2557
|
+
--success: oklch(0.65 0.16 145);
|
|
2558
|
+
--success-foreground: oklch(0.14 0 0);
|
|
2559
|
+
--warning: oklch(0.75 0.15 62);
|
|
2560
|
+
--warning-foreground: oklch(0.14 0 0);
|
|
2561
|
+
--border: oklch(1 0 0 / 10%);
|
|
2562
|
+
--input: oklch(1 0 0 / 15%);
|
|
2563
|
+
--ring: #6253f0;
|
|
2564
|
+
--chart-1: oklch(0.49 0.24 264.4);
|
|
2565
|
+
--chart-2: oklch(0.7 0.16 160.43);
|
|
2566
|
+
--chart-3: oklch(0.77 0.17 65.36);
|
|
2567
|
+
--chart-4: oklch(0.62 0.26 305.32);
|
|
2568
|
+
--chart-5: oklch(0.64 0.25 16.51);
|
|
2569
|
+
--code-bg: oklch(0.2 0 0);
|
|
2570
|
+
--code-fg: oklch(0.97 0 0);
|
|
2522
2571
|
}
|
|
2523
2572
|
|
|
2524
2573
|
/* Wire EVERY design token into Tailwind's namespace. In Tailwind 4,
|
|
@@ -2542,7 +2591,13 @@ function globalsCss() {
|
|
|
2542
2591
|
--color-muted-foreground: var(--muted-foreground);
|
|
2543
2592
|
--color-accent: var(--accent);
|
|
2544
2593
|
--color-accent-foreground: var(--accent-foreground);
|
|
2594
|
+
--color-brand-accent: var(--brand-accent);
|
|
2595
|
+
--color-brand-accent-foreground: var(--brand-accent-foreground);
|
|
2545
2596
|
--color-destructive: var(--destructive);
|
|
2597
|
+
--color-success: var(--success);
|
|
2598
|
+
--color-success-foreground: var(--success-foreground);
|
|
2599
|
+
--color-warning: var(--warning);
|
|
2600
|
+
--color-warning-foreground: var(--warning-foreground);
|
|
2546
2601
|
--color-border: var(--border);
|
|
2547
2602
|
--color-input: var(--input);
|
|
2548
2603
|
--color-ring: var(--ring);
|
|
@@ -2551,10 +2606,33 @@ function globalsCss() {
|
|
|
2551
2606
|
--color-chart-3: var(--chart-3);
|
|
2552
2607
|
--color-chart-4: var(--chart-4);
|
|
2553
2608
|
--color-chart-5: var(--chart-5);
|
|
2609
|
+
--color-option-slate: var(--option-slate);
|
|
2610
|
+
--color-option-red: var(--option-red);
|
|
2611
|
+
--color-option-dark-red: var(--option-dark-red);
|
|
2612
|
+
--color-option-orange: var(--option-orange);
|
|
2613
|
+
--color-option-amber: var(--option-amber);
|
|
2614
|
+
--color-option-yellow: var(--option-yellow);
|
|
2615
|
+
--color-option-green: var(--option-green);
|
|
2616
|
+
--color-option-blue: var(--option-blue);
|
|
2617
|
+
--color-option-indigo: var(--option-indigo);
|
|
2618
|
+
--color-option-purple: var(--option-purple);
|
|
2619
|
+
--color-option-pink: var(--option-pink);
|
|
2620
|
+
--color-code-bg: var(--code-bg);
|
|
2621
|
+
--color-code-fg: var(--code-fg);
|
|
2622
|
+
--font-sans: var(--font-sans);
|
|
2623
|
+
--font-mono: var(--font-mono);
|
|
2554
2624
|
--radius-sm: calc(var(--radius) - 4px);
|
|
2555
2625
|
--radius-md: calc(var(--radius) - 2px);
|
|
2556
2626
|
--radius-lg: var(--radius);
|
|
2557
2627
|
--radius-xl: calc(var(--radius) + 4px);
|
|
2628
|
+
--shadow-2xs: var(--shadow-2xs);
|
|
2629
|
+
--shadow-xs: var(--shadow-xs);
|
|
2630
|
+
--shadow-sm: var(--shadow-sm);
|
|
2631
|
+
--shadow: var(--shadow);
|
|
2632
|
+
--shadow-md: var(--shadow-md);
|
|
2633
|
+
--shadow-lg: var(--shadow-lg);
|
|
2634
|
+
--shadow-xl: var(--shadow-xl);
|
|
2635
|
+
--shadow-2xl: var(--shadow-2xl);
|
|
2558
2636
|
}
|
|
2559
2637
|
|
|
2560
2638
|
* {
|
|
@@ -2566,6 +2644,39 @@ body {
|
|
|
2566
2644
|
color: var(--foreground);
|
|
2567
2645
|
font-family: var(--font-sans);
|
|
2568
2646
|
}
|
|
2647
|
+
|
|
2648
|
+
/* Tailwind v4's Preflight no longer sets \`cursor: pointer\` on buttons \u2014
|
|
2649
|
+
restore it so controls feel like the app's. */
|
|
2650
|
+
button:not(:disabled),
|
|
2651
|
+
[role="button"]:not([aria-disabled="true"]),
|
|
2652
|
+
label[for],
|
|
2653
|
+
summary {
|
|
2654
|
+
cursor: pointer;
|
|
2655
|
+
}
|
|
2656
|
+
|
|
2657
|
+
/* \u2500\u2500 Surface chrome \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
|
|
2658
|
+
\`data-dw-surface\` is stamped on <html> before first paint by
|
|
2659
|
+
PLATFORM_PREPAINT_SCRIPT (@dashai/sdk/platform). Chrome the Dashwise app
|
|
2660
|
+
shell already provides \u2014 header, nav, logo, theme toggle, sign-out,
|
|
2661
|
+
footer \u2014 goes in \`.dw-standalone-only\` and disappears when embedded.
|
|
2662
|
+
CSS-only, so it is correct on the first frame and needs no JavaScript. */
|
|
2663
|
+
[data-dw-surface="embedded"] .dw-standalone-only {
|
|
2664
|
+
display: none !important;
|
|
2665
|
+
}
|
|
2666
|
+
[data-dw-surface="standalone"] .dw-embedded-only {
|
|
2667
|
+
display: none !important;
|
|
2668
|
+
}
|
|
2669
|
+
|
|
2670
|
+
@media (prefers-reduced-motion: reduce) {
|
|
2671
|
+
*,
|
|
2672
|
+
*::before,
|
|
2673
|
+
*::after {
|
|
2674
|
+
animation-duration: 0.01ms !important;
|
|
2675
|
+
animation-iteration-count: 1 !important;
|
|
2676
|
+
transition-duration: 0.01ms !important;
|
|
2677
|
+
scroll-behavior: auto !important;
|
|
2678
|
+
}
|
|
2679
|
+
}
|
|
2569
2680
|
`;
|
|
2570
2681
|
}
|
|
2571
2682
|
function libUtilsTs() {
|
|
@@ -2608,7 +2719,9 @@ export function getPublicOrigin(request: Request): string {
|
|
|
2608
2719
|
`;
|
|
2609
2720
|
}
|
|
2610
2721
|
function componentsUiButtonTsx() {
|
|
2611
|
-
return `
|
|
2722
|
+
return `"use client";
|
|
2723
|
+
|
|
2724
|
+
import * as React from "react";
|
|
2612
2725
|
import { cva, type VariantProps } from "class-variance-authority";
|
|
2613
2726
|
import { Slot } from "radix-ui";
|
|
2614
2727
|
|
|
@@ -2754,6 +2867,234 @@ export function ThemeProvider({
|
|
|
2754
2867
|
}
|
|
2755
2868
|
`;
|
|
2756
2869
|
}
|
|
2870
|
+
function componentsPlatformThemeSyncTsx() {
|
|
2871
|
+
return `"use client";
|
|
2872
|
+
|
|
2873
|
+
import { useEffect } from "react";
|
|
2874
|
+
import { useTheme } from "next-themes";
|
|
2875
|
+
import { usePlatform } from "@dashai/sdk/platform";
|
|
2876
|
+
|
|
2877
|
+
export function PlatformThemeSync() {
|
|
2878
|
+
const { surface, theme: hostTheme } = usePlatform();
|
|
2879
|
+
const { setTheme } = useTheme();
|
|
2880
|
+
|
|
2881
|
+
useEffect(() => {
|
|
2882
|
+
// Standalone: the user's own choice (or \`system\`) is authoritative.
|
|
2883
|
+
if (surface !== "embedded") return;
|
|
2884
|
+
// null until the host answers \u2014 don't fight \`system\` in the meantime.
|
|
2885
|
+
if (!hostTheme) return;
|
|
2886
|
+
setTheme(hostTheme);
|
|
2887
|
+
}, [surface, hostTheme, setTheme]);
|
|
2888
|
+
|
|
2889
|
+
return null;
|
|
2890
|
+
}
|
|
2891
|
+
`;
|
|
2892
|
+
}
|
|
2893
|
+
function componentsModuleShellTsx() {
|
|
2894
|
+
return `import type { ReactNode } from "react";
|
|
2895
|
+
import { cn } from "@/lib/utils";
|
|
2896
|
+
|
|
2897
|
+
interface ModuleShellProps {
|
|
2898
|
+
/** The screen's name. Becomes the single <h1>. */
|
|
2899
|
+
title: string;
|
|
2900
|
+
/** One short line. Omit it rather than padding the screen with prose. */
|
|
2901
|
+
description?: string;
|
|
2902
|
+
/** The single primary action (one filled button). */
|
|
2903
|
+
primaryAction?: ReactNode;
|
|
2904
|
+
/** Up to three secondary actions; anything else belongs in a \u22EF menu. */
|
|
2905
|
+
actions?: ReactNode;
|
|
2906
|
+
/** Tabs, directly under the heading. Only with 2+ screens \u2014 never a sidebar. */
|
|
2907
|
+
nav?: ReactNode;
|
|
2908
|
+
/** Narrower container for forms and detail screens. */
|
|
2909
|
+
width?: "default" | "narrow";
|
|
2910
|
+
children: ReactNode;
|
|
2911
|
+
}
|
|
2912
|
+
|
|
2913
|
+
/**
|
|
2914
|
+
* Embedded, this renders content only \u2014 the Dashwise app shell already
|
|
2915
|
+
* provides the header, sidebar, workspace switcher, user menu and theme
|
|
2916
|
+
* toggle. Standalone, the slim header below appears.
|
|
2917
|
+
*
|
|
2918
|
+
* \`.dw-standalone-only\` is hidden by CSS when embedded (see globals.css), and
|
|
2919
|
+
* the pre-paint script has already stamped <html>, so it is correct on the
|
|
2920
|
+
* first painted frame with no layout shift.
|
|
2921
|
+
*/
|
|
2922
|
+
export function ModuleShell({
|
|
2923
|
+
title,
|
|
2924
|
+
description,
|
|
2925
|
+
primaryAction,
|
|
2926
|
+
actions,
|
|
2927
|
+
nav,
|
|
2928
|
+
width = "default",
|
|
2929
|
+
children,
|
|
2930
|
+
}: ModuleShellProps) {
|
|
2931
|
+
return (
|
|
2932
|
+
<div className="min-h-0 w-full">
|
|
2933
|
+
<header className="dw-standalone-only border-b border-border">
|
|
2934
|
+
<div className="mx-auto flex h-14 max-w-6xl items-center justify-between px-4 md:px-6">
|
|
2935
|
+
<span className="text-sm font-medium">{title}</span>
|
|
2936
|
+
{/* Theme toggle + sign out belong here \u2014 standalone only. */}
|
|
2937
|
+
</div>
|
|
2938
|
+
</header>
|
|
2939
|
+
|
|
2940
|
+
<div
|
|
2941
|
+
className={cn(
|
|
2942
|
+
"mx-auto w-full p-4 md:p-6",
|
|
2943
|
+
width === "narrow" ? "max-w-3xl" : "max-w-6xl",
|
|
2944
|
+
)}
|
|
2945
|
+
>
|
|
2946
|
+
<div className="flex flex-wrap items-start justify-between gap-3">
|
|
2947
|
+
<div className="min-w-0 space-y-1">
|
|
2948
|
+
<h1 className="truncate text-xl font-semibold tracking-tight">
|
|
2949
|
+
{title}
|
|
2950
|
+
</h1>
|
|
2951
|
+
{description && (
|
|
2952
|
+
<p className="text-sm text-muted-foreground">{description}</p>
|
|
2953
|
+
)}
|
|
2954
|
+
</div>
|
|
2955
|
+
{(primaryAction || actions) && (
|
|
2956
|
+
<div className="flex shrink-0 items-center gap-2">
|
|
2957
|
+
{actions}
|
|
2958
|
+
{primaryAction}
|
|
2959
|
+
</div>
|
|
2960
|
+
)}
|
|
2961
|
+
</div>
|
|
2962
|
+
|
|
2963
|
+
{nav && <div className="mt-4">{nav}</div>}
|
|
2964
|
+
|
|
2965
|
+
<main className="mt-6 space-y-6">{children}</main>
|
|
2966
|
+
</div>
|
|
2967
|
+
</div>
|
|
2968
|
+
);
|
|
2969
|
+
}
|
|
2970
|
+
`;
|
|
2971
|
+
}
|
|
2972
|
+
function componentsStatesTsx() {
|
|
2973
|
+
return `"use client";
|
|
2974
|
+
|
|
2975
|
+
import type { ComponentType, ReactNode } from "react";
|
|
2976
|
+
import { AlertCircleIcon, InboxIcon, SearchXIcon } from "lucide-react";
|
|
2977
|
+
import { Button } from "@/components/ui/button";
|
|
2978
|
+
import { cn } from "@/lib/utils";
|
|
2979
|
+
|
|
2980
|
+
/** Nothing created yet. Teaches what the screen is for + offers the action. */
|
|
2981
|
+
export function EmptyState({
|
|
2982
|
+
icon: Icon = InboxIcon,
|
|
2983
|
+
title,
|
|
2984
|
+
description,
|
|
2985
|
+
action,
|
|
2986
|
+
}: {
|
|
2987
|
+
icon?: ComponentType<{ className?: string }>;
|
|
2988
|
+
/** What this screen is for \u2014 never "No data". */
|
|
2989
|
+
title: string;
|
|
2990
|
+
description?: string;
|
|
2991
|
+
/** An empty state without an action is a dead end. */
|
|
2992
|
+
action?: ReactNode;
|
|
2993
|
+
}) {
|
|
2994
|
+
return (
|
|
2995
|
+
<div className="flex flex-col items-center justify-center rounded-lg border border-dashed border-border px-6 py-12 text-center">
|
|
2996
|
+
<Icon className="h-8 w-8 text-muted-foreground" aria-hidden />
|
|
2997
|
+
<p className="mt-4 text-sm font-medium">{title}</p>
|
|
2998
|
+
{description && (
|
|
2999
|
+
<p className="mt-1 max-w-sm text-sm text-muted-foreground">{description}</p>
|
|
3000
|
+
)}
|
|
3001
|
+
{action && <div className="mt-4">{action}</div>}
|
|
3002
|
+
</div>
|
|
3003
|
+
);
|
|
3004
|
+
}
|
|
3005
|
+
|
|
3006
|
+
/** The data exists, the filter hid it. Distinct from EmptyState on purpose. */
|
|
3007
|
+
export function NoResultsState({
|
|
3008
|
+
title = "No matches",
|
|
3009
|
+
description = "Try a different search, or clear the filters.",
|
|
3010
|
+
onClear,
|
|
3011
|
+
clearLabel = "Clear filters",
|
|
3012
|
+
}: {
|
|
3013
|
+
title?: string;
|
|
3014
|
+
description?: string;
|
|
3015
|
+
onClear?: () => void;
|
|
3016
|
+
clearLabel?: string;
|
|
3017
|
+
}) {
|
|
3018
|
+
return (
|
|
3019
|
+
<div className="flex flex-col items-center justify-center rounded-lg border border-dashed border-border px-6 py-12 text-center">
|
|
3020
|
+
<SearchXIcon className="h-8 w-8 text-muted-foreground" aria-hidden />
|
|
3021
|
+
<p className="mt-4 text-sm font-medium">{title}</p>
|
|
3022
|
+
<p className="mt-1 max-w-sm text-sm text-muted-foreground">{description}</p>
|
|
3023
|
+
{onClear && (
|
|
3024
|
+
<Button variant="outline" size="sm" className="mt-4" onClick={onClear}>
|
|
3025
|
+
{clearLabel}
|
|
3026
|
+
</Button>
|
|
3027
|
+
)}
|
|
3028
|
+
</div>
|
|
3029
|
+
);
|
|
3030
|
+
}
|
|
3031
|
+
|
|
3032
|
+
/** What happened \xB7 what now. Never render a raw exception here. */
|
|
3033
|
+
export function ErrorState({
|
|
3034
|
+
title = "That didn't load",
|
|
3035
|
+
description = "We couldn't reach the server. Check your connection and try again.",
|
|
3036
|
+
onRetry,
|
|
3037
|
+
retryLabel = "Try again",
|
|
3038
|
+
}: {
|
|
3039
|
+
title?: string;
|
|
3040
|
+
description?: string;
|
|
3041
|
+
onRetry?: () => void;
|
|
3042
|
+
retryLabel?: string;
|
|
3043
|
+
}) {
|
|
3044
|
+
return (
|
|
3045
|
+
<div
|
|
3046
|
+
role="alert"
|
|
3047
|
+
className="flex flex-col items-center justify-center rounded-lg border border-border bg-card px-6 py-12 text-center"
|
|
3048
|
+
>
|
|
3049
|
+
<AlertCircleIcon className="h-8 w-8 text-destructive" aria-hidden />
|
|
3050
|
+
<p className="mt-4 text-sm font-medium">{title}</p>
|
|
3051
|
+
<p className="mt-1 max-w-sm text-sm text-muted-foreground">{description}</p>
|
|
3052
|
+
{onRetry && (
|
|
3053
|
+
<Button variant="outline" size="sm" className="mt-4" onClick={onRetry}>
|
|
3054
|
+
{retryLabel}
|
|
3055
|
+
</Button>
|
|
3056
|
+
)}
|
|
3057
|
+
</div>
|
|
3058
|
+
);
|
|
3059
|
+
}
|
|
3060
|
+
|
|
3061
|
+
export function Skeleton({ className }: { className?: string }) {
|
|
3062
|
+
return (
|
|
3063
|
+
<div className={cn("animate-pulse rounded-md bg-muted", className)} aria-hidden />
|
|
3064
|
+
);
|
|
3065
|
+
}
|
|
3066
|
+
|
|
3067
|
+
/**
|
|
3068
|
+
* Loading, shaped like the content that's coming \u2014 a centred spinner tells the
|
|
3069
|
+
* user nothing about what they're waiting for. Match \`rows\` and the row height
|
|
3070
|
+
* to the real list or it will visibly jump on load.
|
|
3071
|
+
*/
|
|
3072
|
+
export function ListSkeleton({ rows = 5 }: { rows?: number }) {
|
|
3073
|
+
return (
|
|
3074
|
+
<div
|
|
3075
|
+
className="space-y-2"
|
|
3076
|
+
role="status"
|
|
3077
|
+
aria-live="polite"
|
|
3078
|
+
aria-busy="true"
|
|
3079
|
+
aria-label="Loading"
|
|
3080
|
+
>
|
|
3081
|
+
{Array.from({ length: rows }).map((_, i) => (
|
|
3082
|
+
<Skeleton key={i} className="h-12 w-full" />
|
|
3083
|
+
))}
|
|
3084
|
+
</div>
|
|
3085
|
+
);
|
|
3086
|
+
}
|
|
3087
|
+
|
|
3088
|
+
/** Announced by screen readers without stealing focus (WCAG 4.1.3). */
|
|
3089
|
+
export function StatusMessage({ children }: { children: ReactNode }) {
|
|
3090
|
+
return (
|
|
3091
|
+
<p role="status" aria-live="polite" className="text-sm text-muted-foreground">
|
|
3092
|
+
{children}
|
|
3093
|
+
</p>
|
|
3094
|
+
);
|
|
3095
|
+
}
|
|
3096
|
+
`;
|
|
3097
|
+
}
|
|
2757
3098
|
function appErrorTsx() {
|
|
2758
3099
|
return `"use client";
|
|
2759
3100
|
|
|
@@ -2812,7 +3153,7 @@ function seedMjs() {
|
|
|
2812
3153
|
return `/**
|
|
2813
3154
|
* seed.mjs \u2014 OPTIONAL seed/maintenance script template.
|
|
2814
3155
|
*
|
|
2815
|
-
* Run it with the module's
|
|
3156
|
+
* Run it with the module's Dashwise env injected (DASHWISE_API_URL +
|
|
2816
3157
|
* DASHWISE_API_KEY resolved like \`dashwise query\`: explicit env \u2192
|
|
2817
3158
|
* .env.local \u2192 the dwk_ key cached by your CLI login):
|
|
2818
3159
|
*
|
|
@@ -2852,9 +3193,9 @@ import {
|
|
|
2852
3193
|
|
|
2853
3194
|
const ERROR_MESSAGES: Record<string, string> = {
|
|
2854
3195
|
access_denied:
|
|
2855
|
-
"You declined the sign-in request. To use this module, you'll need to authorize it on the
|
|
3196
|
+
"You declined the sign-in request. To use this module, you'll need to authorize it on the Dashwise sign-in page.",
|
|
2856
3197
|
server_error:
|
|
2857
|
-
'The
|
|
3198
|
+
'The Dashwise sign-in server returned an internal error. This is usually transient \u2014 try again in a moment.',
|
|
2858
3199
|
invalid_request:
|
|
2859
3200
|
'The sign-in request was malformed. This is almost always a configuration bug \u2014 check that DASHWISE_API_URL and DASHWISE_API_KEY are set correctly and the callback origin is allowlisted (run "dashwise origins add <url>").',
|
|
2860
3201
|
missing_code:
|
|
@@ -2884,7 +3225,7 @@ export default async function SignInErrorPage({ searchParams }: SignInErrorPageP
|
|
|
2884
3225
|
<div className="flex-1">
|
|
2885
3226
|
<CardTitle>Sign-in failed</CardTitle>
|
|
2886
3227
|
<CardDescription>
|
|
2887
|
-
The
|
|
3228
|
+
The Dashwise SSO flow couldn't complete.
|
|
2888
3229
|
</CardDescription>
|
|
2889
3230
|
</div>
|
|
2890
3231
|
</div>
|
|
@@ -2945,18 +3286,116 @@ function claudeMd(opts) {
|
|
|
2945
3286
|
return `# CLAUDE.md \u2014 ${opts.name}
|
|
2946
3287
|
|
|
2947
3288
|
Instructions for AI coding agents (Claude Code, Cursor, etc.) working
|
|
2948
|
-
in this
|
|
3289
|
+
in this Dashwise module. Read this before making changes.
|
|
2949
3290
|
|
|
2950
3291
|
## What this module is
|
|
2951
3292
|
|
|
2952
|
-
A
|
|
3293
|
+
A Dashwise ${isCustom ? "**custom Next.js** module (full app runtime, machine-class)" : "**hand-authored simple** module (isolate runtime)"} authored against \`@dashai/sdk\`.
|
|
2953
3294
|
Module slug: \`${opts.slug}\`.
|
|
2954
3295
|
|
|
2955
|
-
|
|
3296
|
+
Dashwise calls apps "modules". A module is a Next.js app + a
|
|
2956
3297
|
manifest (\`module.json\`) declaring the tables it owns, modules it
|
|
2957
|
-
depends on, and actions it exposes. The
|
|
3298
|
+
depends on, and actions it exposes. The Dashwise backend hosts the
|
|
2958
3299
|
data plane + auth; this module ships UI + business logic.
|
|
2959
3300
|
|
|
3301
|
+
## Embedded-first UI \u2014 read this before writing any component
|
|
3302
|
+
|
|
3303
|
+
Your module normally renders as a **full-bleed iframe inside the Dashwise app
|
|
3304
|
+
shell**. Above it is the app header; to its left the app sidebar. The
|
|
3305
|
+
workspace switcher, user menu, theme toggle, global search and logo
|
|
3306
|
+
**already exist around you**. The single most common way an AI-built module
|
|
3307
|
+
gives itself away is rendering a second set of all that.
|
|
3308
|
+
|
|
3309
|
+
### Never render (embedded)
|
|
3310
|
+
- a logo, wordmark or product name; a top nav, sidebar or hamburger
|
|
3311
|
+
- a theme toggle, user avatar, sign-out control, language switcher
|
|
3312
|
+
- a footer, "powered by", version string, marketing hero, landing page
|
|
3313
|
+
- a full-screen splash or a full-page centred spinner
|
|
3314
|
+
- \`min-h-screen\` / \`h-screen\` / \`100vh\` \u2014 you are not the screen
|
|
3315
|
+
- a second scroll container (the iframe scrolls; nested scrollbars are the
|
|
3316
|
+
most recognisable "bolted on" tell). Sticky table headers are fine.
|
|
3317
|
+
|
|
3318
|
+
### Always
|
|
3319
|
+
- exactly **one \`<h1>\`** per screen; page padding \`p-4 md:p-6\`; content in a
|
|
3320
|
+
capped width. \`<ModuleShell>\` (components/module-shell.tsx) does all three
|
|
3321
|
+
- in-module nav only with 2+ screens, and then as **tabs under the \`<h1>\`**,
|
|
3322
|
+
never a sidebar
|
|
3323
|
+
- real routes, not client-side tab state \u2014 the app deep-links into
|
|
3324
|
+
\`/modules/<slug>/<your/path>\` and \`HostRouteSync\` keeps the address bar honest
|
|
3325
|
+
|
|
3326
|
+
### Standalone
|
|
3327
|
+
Opened on its own subdomain (or \`dashwise dev\`) a slim header is fine. Put it
|
|
3328
|
+
in \`.dw-standalone-only\` \u2014 CSS-only, correct on the first painted frame \u2014 or
|
|
3329
|
+
use \`<StandaloneOnly>\` from \`@dashai/sdk/platform\` when something must not
|
|
3330
|
+
mount. **Chrome is OFF by default**: standalone must be proven, never assumed.
|
|
3331
|
+
|
|
3332
|
+
### Tokens, never raw values
|
|
3333
|
+
If you are typing \`#\`, \`oklch(\`, \`rounded-[\` or \`shadow-[\` in a component,
|
|
3334
|
+
stop \u2014 there is a token, and hardcoding permanently opts that element out of
|
|
3335
|
+
the workspace's theme and branding.
|
|
3336
|
+
|
|
3337
|
+
| Use | Token |
|
|
3338
|
+
|---|---|
|
|
3339
|
+
| primary action | \`bg-primary text-primary-foreground\` |
|
|
3340
|
+
| page / text | \`bg-background text-foreground\` |
|
|
3341
|
+
| surfaces | \`bg-card\`, \`border-border\` |
|
|
3342
|
+
| secondary text | \`text-muted-foreground\` |
|
|
3343
|
+
| hover surface | \`hover:bg-accent hover:text-accent-foreground\` |
|
|
3344
|
+
| focus ring | \`focus-visible:ring-ring\` |
|
|
3345
|
+
| danger / status | \`bg-destructive\`, \`text-success\`, \`text-warning\` |
|
|
3346
|
+
| select + status chips | the \`--option-*\` palette (\`bg-option-blue/10 text-option-blue\`) |
|
|
3347
|
+
| corners / elevation | \`rounded-md\`/\`rounded-lg\`, \`shadow-xs\`\u2026\`shadow-md\` |
|
|
3348
|
+
| type | inherit \`--font-sans\`; ids and code use \`font-mono\` |
|
|
3349
|
+
|
|
3350
|
+
**Theme and branding arrive from the host.** \`PlatformProvider\` +
|
|
3351
|
+
\`PlatformThemeSync\` (already wired in \`app/layout.tsx\`) follow the app's
|
|
3352
|
+
light/dark choice and apply the workspace's brand colour to \`--primary\` /
|
|
3353
|
+
\`--brand-accent\`. Never hardcode a brand colour, and never override
|
|
3354
|
+
\`--accent\` \u2014 that's the neutral hover surface, and re-pointing it tints every
|
|
3355
|
+
hover state in the product. Dark mode is a gate: check both.
|
|
3356
|
+
|
|
3357
|
+
### One job per screen \u2014 hard budgets
|
|
3358
|
+
| Budget | Cap |
|
|
3359
|
+
|---|---|
|
|
3360
|
+
| primary actions per screen | 1 |
|
|
3361
|
+
| secondary actions in the header | 3 (rest go in a \`\u22EF\` menu) |
|
|
3362
|
+
| table columns shown by default | 7 |
|
|
3363
|
+
| levels of visual hierarchy | 2 \u2014 no card-in-card-in-card |
|
|
3364
|
+
| accent colours on a screen | 1 (status colours don't count) |
|
|
3365
|
+
| nav items | 5 \u2014 more means the module does too much |
|
|
3366
|
+
|
|
3367
|
+
If a screen needs two primary actions it is two screens. A tile nobody can
|
|
3368
|
+
click is decoration \u2014 delete it. Empty space is a feature.
|
|
3369
|
+
|
|
3370
|
+
### States are mandatory, not polish
|
|
3371
|
+
Every data surface needs all of these \u2014 \`components/states.tsx\` has them:
|
|
3372
|
+
- **empty**: icon + one line of what this screen is for + the primary action.
|
|
3373
|
+
Never a blank table, never "No data"
|
|
3374
|
+
- **filtered-empty**: distinct from empty, offers "Clear filters"
|
|
3375
|
+
- **loading**: a skeleton shaped like the content, not a centred spinner
|
|
3376
|
+
- **error**: what happened \xB7 why \xB7 what now, plus a retry. Never a raw
|
|
3377
|
+
exception, never "Something went wrong" on its own
|
|
3378
|
+
- **success**: a toast after every create/update/delete
|
|
3379
|
+
|
|
3380
|
+
### Accessibility is a gate (WCAG 2.1 AA)
|
|
3381
|
+
- every input has a real \`<label for>\` \u2014 a placeholder is not a label
|
|
3382
|
+
- **never \`outline-none\` without \`focus-visible:ring-2 ring-ring\`** \u2014 this is
|
|
3383
|
+
the single most common failure
|
|
3384
|
+
- icon-only buttons need \`aria-label\`; \`Esc\` closes every dialog; focus
|
|
3385
|
+
returns where it came from
|
|
3386
|
+
- errors are text (not colour alone), tied to the field via
|
|
3387
|
+
\`aria-describedby\`, and say how to fix it
|
|
3388
|
+
- toasts and inline "Saved" live in an \`aria-live="polite"\` region
|
|
3389
|
+
- usable at 320px wide; tables get an \`overflow-x-auto\` wrapper
|
|
3390
|
+
- 4.5:1 contrast on body text; minimum 24\xD724px hit targets
|
|
3391
|
+
|
|
3392
|
+
### Do not ship
|
|
3393
|
+
Gradient heroes \xB7 emoji as icons \xB7 \`alert()\`/\`confirm()\` \xB7 raw JSON or stack
|
|
3394
|
+
traces shown to a user \xB7 "Coming soon" cards \xB7 a table with no empty state \xB7
|
|
3395
|
+
unlabelled icon buttons \xB7 hardcoded English when the module is multilingual \xB7
|
|
3396
|
+
a viewer who can see buttons that then 403 \xB7 \`console.log\` left in shipped
|
|
3397
|
+
code \xB7 the \`dwk_\` key reachable from a client component.
|
|
3398
|
+
|
|
2960
3399
|
## Spec-first, log-every-change
|
|
2961
3400
|
|
|
2962
3401
|
This scaffold mirrors the dashwise-backend convention:
|
|
@@ -3004,6 +3443,33 @@ Allowed field types (18): \`text\`, \`long_text\`, \`number\`, \`boolean\`,
|
|
|
3004
3443
|
\`link_row\`, \`file\`, \`multi_file\`, \`email\`, \`url\`,
|
|
3005
3444
|
\`phone\`, \`formula\`, \`ai\`, \`uuid\`, \`jsonb\`.
|
|
3006
3445
|
|
|
3446
|
+
### \`human_edit\` \u2014 who may hand-edit each column
|
|
3447
|
+
|
|
3448
|
+
Your owned tables are visible **and editable** in the Dashwise Data Hub, so a
|
|
3449
|
+
workspace Editor can hand-edit any column you create. You are the only party
|
|
3450
|
+
who knows which columns your logic depends on, and a wrong edit raises no
|
|
3451
|
+
error \u2014 the module just misbehaves later, somewhere else. Declare it per field
|
|
3452
|
+
(the scaffolded \`items\` table shows two of the three):
|
|
3453
|
+
|
|
3454
|
+
| Value | Use for | Effect on a human edit |
|
|
3455
|
+
|---|---|---|
|
|
3456
|
+
| \`open\` | Data you never read \u2014 notes, descriptions, annotations. | Nothing. |
|
|
3457
|
+
| \`guarded\` | A human may legitimately set it, but you REACT to the value \u2014 dates you notify from, the assignee you route to, config you read each run. | Lands; the grid warns first. **Default.** |
|
|
3458
|
+
| \`locked\` | An invariant YOUR code maintains \u2014 state-machine status, external ids, ledger amounts, sync cursors. | Refused (\`MODULE_LOCKED_FIELD\`). \`human_edit_reason\` is REQUIRED and shown verbatim to the user. |
|
|
3459
|
+
|
|
3460
|
+
\`\`\`jsonc
|
|
3461
|
+
{ "slug": "sync_cursor", "type": "datetime",
|
|
3462
|
+
"human_edit": "locked",
|
|
3463
|
+
"human_edit_reason": "the billing sync maintains this \u2014 change it on the invoice screen" }
|
|
3464
|
+
\`\`\`
|
|
3465
|
+
|
|
3466
|
+
**Don't leave everything on the default.** A warning on every column trains
|
|
3467
|
+
people to click through warnings, which costs you the one column that mattered.
|
|
3468
|
+
Mark genuinely inert columns \`open\`.
|
|
3469
|
+
|
|
3470
|
+
Only tables you OWN can be locked \u2014 a \`uses\` field def has no \`human_edit\` key
|
|
3471
|
+
at all, because that table belongs to the user, not to your module.
|
|
3472
|
+
|
|
3007
3473
|
## Read + write data via the generated SDK
|
|
3008
3474
|
|
|
3009
3475
|
The CLI's watcher regenerates typed clients into
|
|
@@ -3055,7 +3521,7 @@ granted key.
|
|
|
3055
3521
|
## Auth in dev mode
|
|
3056
3522
|
|
|
3057
3523
|
\`dashwise dev\` registers a dev-mode SSO session with the backend so
|
|
3058
|
-
workspace members can sign into the locally-running module via
|
|
3524
|
+
workspace members can sign into the locally-running module via Dashwise
|
|
3059
3525
|
SSO. The scaffold's \`/api/auth/sign-in\` route is a single path: it
|
|
3060
3526
|
calls \`POST /api/module-api/sso/start-url\` with your \`dwk_\` key and
|
|
3061
3527
|
follows the returned \`redirect_url\` \u2014 the same code runs in dev and in
|
|
@@ -3069,15 +3535,24 @@ in \`app/layout.tsx\`).
|
|
|
3069
3535
|
${isCustom ? `
|
|
3070
3536
|
## Design & home
|
|
3071
3537
|
|
|
3072
|
-
You
|
|
3073
|
-
|
|
3074
|
-
|
|
3075
|
-
|
|
3538
|
+
You own the PRODUCT design \u2014 the screens, the flows, the components. You do
|
|
3539
|
+
not own the chrome or the palette: this module renders as a full-bleed iframe
|
|
3540
|
+
**inside** the Dashwise app shell, which already provides the header, sidebar,
|
|
3541
|
+
workspace switcher, user menu and theme toggle. So: no header of your own, no
|
|
3542
|
+
nav, no logo, no \`100vh\`, one \`<h1>\` per screen \u2014 see \`CLAUDE.md\` \u2192
|
|
3543
|
+
"Embedded-first UI" for the full contract, and use \`<ModuleShell>\` which
|
|
3544
|
+
encodes it.
|
|
3545
|
+
|
|
3546
|
+
\`app/globals.css\` ships the app's REAL tokens (indigo \`--primary\`, Geist,
|
|
3547
|
+
\`--radius\`, the \`--option-*\` chip palette). Style with them and the module
|
|
3548
|
+
inherits the workspace's theme, dark mode and white-label branding for free;
|
|
3549
|
+
hardcode a hex and you permanently opt that element out. Pull in more shadcn
|
|
3550
|
+
blocks freely (see the README's "Adding UI blocks").
|
|
3076
3551
|
|
|
3077
3552
|
The app's **home is \`app/page.tsx\`, served at \`/\`** \u2014 build the real app
|
|
3078
3553
|
there. It's auth-gated: a platform user lands on it zero-click (the
|
|
3079
3554
|
middleware's \`autoSignIn\` signs them in silently), and \`app/dashboard/page.tsx\`
|
|
3080
|
-
is just an extra example route. A manual "Sign in with
|
|
3555
|
+
is just an extra example route. A manual "Sign in with Dashwise" landing
|
|
3081
3556
|
page belongs ONLY to a \`public_access_allowed\` module that wants an
|
|
3082
3557
|
anonymous entry point (re-add \`'/'\` to \`middleware.ts\` publicPaths + drop
|
|
3083
3558
|
the home's session guard to build one).
|
|
@@ -3087,7 +3562,7 @@ the home's session guard to build one).
|
|
|
3087
3562
|
Add \`export const dynamic = 'force-dynamic'\` to the top of ANY page that
|
|
3088
3563
|
reads the session or data at render (calls \`getServerSession()\` or a
|
|
3089
3564
|
\`dwk_\`-authenticated data call). Without it, \`next build\` statically
|
|
3090
|
-
prerenders the page with NO
|
|
3565
|
+
prerenders the page with NO Dashwise env vars, the SDK throws
|
|
3091
3566
|
\`missing apiBaseUrl\`, and the platform's first publish fails with an
|
|
3092
3567
|
opaque \`BUILD_FAILED\`. The scaffolded \`app/page.tsx\` + \`app/dashboard/page.tsx\`
|
|
3093
3568
|
already have it \u2014 any new session-gated page you add needs it too.
|
|
@@ -3431,6 +3906,13 @@ async function moduleInitCommand(slugArg, options) {
|
|
|
3431
3906
|
["lib/utils.ts", libUtilsTs()],
|
|
3432
3907
|
["lib/request-origin.ts", libRequestOriginTs()],
|
|
3433
3908
|
["components/theme-provider.tsx", componentsThemeProviderTsx()],
|
|
3909
|
+
// Embedded-first defaults: follow the Dashwise app's theme, and ship the
|
|
3910
|
+
// page frame + the empty/loading/error states that a module needs to
|
|
3911
|
+
// look finished. All three encode the CLAUDE.md "Embedded-first UI"
|
|
3912
|
+
// contract so a module gets it right without being told.
|
|
3913
|
+
["components/platform-theme-sync.tsx", componentsPlatformThemeSyncTsx()],
|
|
3914
|
+
["components/module-shell.tsx", componentsModuleShellTsx()],
|
|
3915
|
+
["components/states.tsx", componentsStatesTsx()],
|
|
3434
3916
|
["components/ui/button.tsx", componentsUiButtonTsx()],
|
|
3435
3917
|
["components/ui/card.tsx", componentsUiCardTsx()],
|
|
3436
3918
|
["app/layout.tsx", appLayoutCustomTsx(scaffoldOpts)],
|
|
@@ -6209,6 +6691,14 @@ init_workspaces();
|
|
|
6209
6691
|
function isDevInstallKind(kind) {
|
|
6210
6692
|
return kind === "local" || kind === "sandbox";
|
|
6211
6693
|
}
|
|
6694
|
+
function selectInstallsByKind(installs, moduleId) {
|
|
6695
|
+
const all = installs.filter((row) => row.module_id === moduleId);
|
|
6696
|
+
return {
|
|
6697
|
+
production: all.find((row) => !isDevInstallKind(row.kind)),
|
|
6698
|
+
dev: all.find((row) => isDevInstallKind(row.kind)),
|
|
6699
|
+
all
|
|
6700
|
+
};
|
|
6701
|
+
}
|
|
6212
6702
|
function isRealRuntimeUrl(url) {
|
|
6213
6703
|
return !!url && /^https:\/\//i.test(url);
|
|
6214
6704
|
}
|
|
@@ -6306,9 +6796,12 @@ async function moduleInstallCommand(slugArg, options) {
|
|
|
6306
6796
|
return 1;
|
|
6307
6797
|
}
|
|
6308
6798
|
let existing;
|
|
6799
|
+
let devInstall;
|
|
6309
6800
|
try {
|
|
6310
6801
|
const installs = await listWorkspaceInstallations(workspace2.id, auth);
|
|
6311
|
-
|
|
6802
|
+
const byKind = selectInstallsByKind(installs, mod.id);
|
|
6803
|
+
existing = byKind.production;
|
|
6804
|
+
devInstall = byKind.dev;
|
|
6312
6805
|
} catch (err) {
|
|
6313
6806
|
fail(
|
|
6314
6807
|
`Could not read workspace installations: ${err.message}`
|
|
@@ -6318,18 +6811,35 @@ async function moduleInstallCommand(slugArg, options) {
|
|
|
6318
6811
|
let isUpgrade = false;
|
|
6319
6812
|
let devInstallToReplace;
|
|
6320
6813
|
let confirmedViaPicker = false;
|
|
6321
|
-
if (existing &&
|
|
6814
|
+
if (!existing && devInstall) {
|
|
6322
6815
|
const resolution = await resolveDevInstallCollision(
|
|
6323
|
-
|
|
6816
|
+
devInstall,
|
|
6324
6817
|
slug,
|
|
6325
6818
|
workspace2.slug,
|
|
6326
6819
|
options
|
|
6327
6820
|
);
|
|
6328
6821
|
if (typeof resolution === "number") return resolution;
|
|
6329
|
-
|
|
6330
|
-
|
|
6331
|
-
|
|
6822
|
+
if (resolution === "alongside") {
|
|
6823
|
+
info(
|
|
6824
|
+
`Keeping dev install #${devInstall.id} \u2014 installing production alongside it.`
|
|
6825
|
+
);
|
|
6826
|
+
} else {
|
|
6827
|
+
devInstallToReplace = devInstall;
|
|
6828
|
+
confirmedViaPicker = resolution === "replace-confirmed";
|
|
6829
|
+
}
|
|
6332
6830
|
} else if (existing) {
|
|
6831
|
+
if (devInstall) {
|
|
6832
|
+
if (options.replaceDevInstall) {
|
|
6833
|
+
devInstallToReplace = devInstall;
|
|
6834
|
+
info(
|
|
6835
|
+
`Dev install #${devInstall.id} (${code(`kind=${devInstall.kind ?? "unknown"}`)}) will be DESTROYED as requested \u2014 its local-dev credentials are revoked. Re-provision later with ${code("dashwise module reset-dev-install")}.`
|
|
6836
|
+
);
|
|
6837
|
+
} else {
|
|
6838
|
+
info(
|
|
6839
|
+
`Dev install #${devInstall.id} (${code(`kind=${devInstall.kind ?? "unknown"}`)}) also exists and is left untouched \u2014 the two coexist.`
|
|
6840
|
+
);
|
|
6841
|
+
}
|
|
6842
|
+
}
|
|
6333
6843
|
isUpgrade = true;
|
|
6334
6844
|
const installedVersion2 = existing.module_version?.version;
|
|
6335
6845
|
if (installedVersion2 && installedVersion2 === targetVersion) {
|
|
@@ -6343,13 +6853,13 @@ async function moduleInstallCommand(slugArg, options) {
|
|
|
6343
6853
|
}
|
|
6344
6854
|
}
|
|
6345
6855
|
if (!options.yes && process.stdout.isTTY && !confirmedViaPicker) {
|
|
6346
|
-
const message = devInstallToReplace ? `Replace the dev install (revokes local-dev credentials) and install ${code(mod.slug)} ${code(`v${targetVersion}`)} into ${code(workspace2.slug)} as production?` : `${isUpgrade ? "Upgrade" : "Install"} ${code(mod.slug)} ${code(`v${targetVersion}`)} into workspace ${code(workspace2.slug)}?`;
|
|
6856
|
+
const message = devInstallToReplace ? isUpgrade ? `Destroy the dev install (revokes local-dev credentials) and upgrade ${code(mod.slug)} to ${code(`v${targetVersion}`)} in ${code(workspace2.slug)}?` : `Replace the dev install (revokes local-dev credentials) and install ${code(mod.slug)} ${code(`v${targetVersion}`)} into ${code(workspace2.slug)} as production?` : `${isUpgrade ? "Upgrade" : "Install"} ${code(mod.slug)} ${code(`v${targetVersion}`)} into workspace ${code(workspace2.slug)}?`;
|
|
6347
6857
|
const answer = await select({
|
|
6348
6858
|
message,
|
|
6349
6859
|
options: [
|
|
6350
6860
|
{
|
|
6351
6861
|
value: "go",
|
|
6352
|
-
label: devInstallToReplace ? "Yes, replace the dev install + production-install" : isUpgrade ? "Yes, upgrade now" : "Yes, install now"
|
|
6862
|
+
label: devInstallToReplace ? isUpgrade ? "Yes, destroy the dev install + upgrade" : "Yes, replace the dev install + production-install" : isUpgrade ? "Yes, upgrade now" : "Yes, install now"
|
|
6353
6863
|
},
|
|
6354
6864
|
{ value: "cancel", label: "No, abort" }
|
|
6355
6865
|
],
|
|
@@ -6400,7 +6910,7 @@ async function moduleInstallCommand(slugArg, options) {
|
|
|
6400
6910
|
let verified = false;
|
|
6401
6911
|
try {
|
|
6402
6912
|
const after = await listWorkspaceInstallations(workspace2.id, auth);
|
|
6403
|
-
row = after
|
|
6913
|
+
row = selectInstallsByKind(after, mod.id).production;
|
|
6404
6914
|
verified = row !== void 0;
|
|
6405
6915
|
} catch {
|
|
6406
6916
|
}
|
|
@@ -6479,19 +6989,26 @@ function resolveSlug(slugArg, dir) {
|
|
|
6479
6989
|
}
|
|
6480
6990
|
}
|
|
6481
6991
|
async function resolveDevInstallCollision(existing, slug, workspaceSlug, options) {
|
|
6992
|
+
if (options.keepDevInstall) {
|
|
6993
|
+
return "alongside";
|
|
6994
|
+
}
|
|
6482
6995
|
if (existing.kind === "sandbox") {
|
|
6483
6996
|
fail(
|
|
6484
|
-
`Workspace ${code(workspaceSlug)} already has a ${code("kind=sandbox")} install of ${code(slug)} (id=${existing.id})
|
|
6997
|
+
`Workspace ${code(workspaceSlug)} already has a ${code("kind=sandbox")} install of ${code(slug)} (id=${existing.id}).
|
|
6485
6998
|
A plain install would upgrade that row in place \u2014 but ${code("kind")} is immutable, so it would stay sandbox and hidden from the app sidebar.
|
|
6486
|
-
The CLI can't
|
|
6999
|
+
The CLI can't DESTROY a sandbox install, so ${code("--replace-dev-install")} is unavailable here. Either:
|
|
7000
|
+
- ${code("--keep-dev-install")} \u2014 install production alongside it (non-destructive; the two coexist on backends \u2265 2026-07-15),
|
|
7001
|
+
- uninstall the sandbox install from the dashboard first, or
|
|
7002
|
+
- ${code("--workspace <other>")} \u2014 deploy to a separate workspace.`
|
|
6487
7003
|
);
|
|
6488
7004
|
return 1;
|
|
6489
7005
|
}
|
|
6490
7006
|
const explanation = `Workspace ${code(workspaceSlug)} already has the DEV install of ${code(slug)} (kind=local, id=${existing.id}) created by ${code("dashwise init")}.
|
|
6491
7007
|
Install ${code("kind")} is immutable: a plain install could land on that row's upgrade path \u2014 the runtime would provision, but it would STAY a dev install, hidden from the app sidebar.
|
|
6492
7008
|
Options:
|
|
6493
|
-
- ${code("--
|
|
6494
|
-
- ${code("--workspace <
|
|
7009
|
+
- ${code("--keep-dev-install")} \u2014 install production ALONGSIDE the dev install, keeping local dev working. Recommended while development is ongoing. Requires a backend \u2265 2026-07-15, where the live-install uniqueness is per KIND group, so one production and one dev install coexist.
|
|
7010
|
+
- ${code("--replace-dev-install")} \u2014 destroy the dev install, then fresh production install. Local dev can be re-provisioned afterwards with ${code(`dashwise init ${slug} --workspace <ws>`)} on backends \u2265 2026-07-15; on OLDER backends this is one-way \u2014 no re-provision anywhere once published (interim: a scoped key on the production install via ${code("api-keys create --install <id> --full-access")}, or duplicate the code under a NEW slug).
|
|
7011
|
+
- ${code("--workspace <other>")} \u2014 deploy to a separate test/client workspace and keep this dev install.`;
|
|
6495
7012
|
if (options.replaceDevInstall) {
|
|
6496
7013
|
info(explanation);
|
|
6497
7014
|
return "replace";
|
|
@@ -6499,8 +7016,13 @@ async function resolveDevInstallCollision(existing, slug, workspaceSlug, options
|
|
|
6499
7016
|
if (process.stdout.isTTY && !options.yes) {
|
|
6500
7017
|
info(explanation);
|
|
6501
7018
|
const answer = await select({
|
|
6502
|
-
message: `
|
|
7019
|
+
message: `How should ${code(slug)} be installed into ${code(workspaceSlug)}?`,
|
|
6503
7020
|
options: [
|
|
7021
|
+
{
|
|
7022
|
+
value: "alongside",
|
|
7023
|
+
label: "Install alongside \u2014 keep the dev install and local dev working",
|
|
7024
|
+
hint: "non-destructive; the two coexist (backend \u2265 2026-07-15)"
|
|
7025
|
+
},
|
|
6504
7026
|
{
|
|
6505
7027
|
value: "replace",
|
|
6506
7028
|
label: "Replace \u2014 destroy the dev install, then production-install",
|
|
@@ -6508,12 +7030,15 @@ async function resolveDevInstallCollision(existing, slug, workspaceSlug, options
|
|
|
6508
7030
|
},
|
|
6509
7031
|
{ value: "cancel", label: "Cancel \u2014 I will deploy to another workspace" }
|
|
6510
7032
|
],
|
|
6511
|
-
|
|
7033
|
+
// Non-destructive default: the safe choice should be one Enter away, and
|
|
7034
|
+
// "keep both" is what the platform has supported since 2026-07-15.
|
|
7035
|
+
initialValue: "alongside"
|
|
6512
7036
|
});
|
|
6513
7037
|
if (isCancel(answer) || answer === "cancel") {
|
|
6514
7038
|
fail("Aborted. Nothing was changed.");
|
|
6515
7039
|
return 130;
|
|
6516
7040
|
}
|
|
7041
|
+
if (answer === "alongside") return "alongside";
|
|
6517
7042
|
return "replace-confirmed";
|
|
6518
7043
|
}
|
|
6519
7044
|
fail(explanation);
|
|
@@ -7122,9 +7647,12 @@ async function moduleStatusCommand(slugArg, options) {
|
|
|
7122
7647
|
return 1;
|
|
7123
7648
|
}
|
|
7124
7649
|
let row;
|
|
7650
|
+
let siblingDevInstall;
|
|
7125
7651
|
try {
|
|
7126
7652
|
const installs = await listWorkspaceInstallations(workspace2.id, auth);
|
|
7127
|
-
|
|
7653
|
+
const byKind = selectInstallsByKind(installs, mod.id);
|
|
7654
|
+
row = byKind.production ?? byKind.dev;
|
|
7655
|
+
siblingDevInstall = byKind.production ? byKind.dev : void 0;
|
|
7128
7656
|
} catch (err) {
|
|
7129
7657
|
fail(`Could not read workspace installations: ${err.message}`);
|
|
7130
7658
|
return 1;
|
|
@@ -7165,7 +7693,10 @@ async function moduleStatusCommand(slugArg, options) {
|
|
|
7165
7693
|
runtime_provisioned: runtimeProvisioned,
|
|
7166
7694
|
runtime_url: row.runtime_url,
|
|
7167
7695
|
public_url: row.public_url,
|
|
7168
|
-
state
|
|
7696
|
+
state,
|
|
7697
|
+
// The coexisting dev install, so a script can see BOTH slots — the
|
|
7698
|
+
// human output reports it and `--json` used to hide it.
|
|
7699
|
+
dev_install: siblingDevInstall ? { id: siblingDevInstall.id, kind: siblingDevInstall.kind ?? null } : null
|
|
7169
7700
|
})
|
|
7170
7701
|
);
|
|
7171
7702
|
return 0;
|
|
@@ -7186,9 +7717,14 @@ async function moduleStatusCommand(slugArg, options) {
|
|
|
7186
7717
|
kv("Public URL:", row.public_url ?? dim("(none)"));
|
|
7187
7718
|
kv("State:", stateLabel);
|
|
7188
7719
|
out("");
|
|
7189
|
-
if (
|
|
7720
|
+
if (siblingDevInstall) {
|
|
7721
|
+
info(
|
|
7722
|
+
`A ${code(`kind=${siblingDevInstall.kind ?? "unknown"}`)} dev install (#${siblingDevInstall.id}) also exists in this workspace and coexists with the production install above \u2014 that is the one ${code("dashwise dev")} talks to.`
|
|
7723
|
+
);
|
|
7724
|
+
}
|
|
7725
|
+
if (isDevInstallKind(row.kind)) {
|
|
7190
7726
|
info(
|
|
7191
|
-
`This is the ${code(
|
|
7727
|
+
`This is the ${code(`kind=${row.kind ?? "unknown"}`)} dev install \u2014 hidden from the app sidebar. There is no production install in this workspace yet: ${code("dashwise module install --keep-dev-install")} adds one alongside (keeps local dev working), ${code("--replace-dev-install")} swaps this row for one, or ${code("--workspace <other>")} deploys elsewhere.`
|
|
7192
7728
|
);
|
|
7193
7729
|
} else if (state === "not_deployed") {
|
|
7194
7730
|
info(
|
|
@@ -7248,7 +7784,8 @@ async function moduleUninstallCommand(slugArg, options) {
|
|
|
7248
7784
|
let row;
|
|
7249
7785
|
try {
|
|
7250
7786
|
const installs = await listWorkspaceInstallations(workspace2.id, auth);
|
|
7251
|
-
|
|
7787
|
+
const byKind = selectInstallsByKind(installs, mod.id);
|
|
7788
|
+
row = byKind.production ?? byKind.dev;
|
|
7252
7789
|
} catch (err) {
|
|
7253
7790
|
fail(`Could not read workspace installations: ${err.message}`);
|
|
7254
7791
|
return 1;
|
|
@@ -7259,9 +7796,9 @@ async function moduleUninstallCommand(slugArg, options) {
|
|
|
7259
7796
|
);
|
|
7260
7797
|
return 1;
|
|
7261
7798
|
}
|
|
7262
|
-
if (row.kind
|
|
7799
|
+
if (isDevInstallKind(row.kind)) {
|
|
7263
7800
|
fail(
|
|
7264
|
-
`Install ${code(`#${row.id}`)} of ${code(mod.slug)} in ${code(workspace2.slug)} is the ${code(
|
|
7801
|
+
`Install ${code(`#${row.id}`)} of ${code(mod.slug)} in ${code(workspace2.slug)} is the ${code(`kind=${row.kind ?? "unknown"}`)} DEV install backing local development.
|
|
7265
7802
|
Use ${code("dashwise module destroy-dev-install")} (or ${code("dashwise module reset-dev-install")} for a clean re-provision) instead \u2014 it also revokes the cached local-dev credentials.`
|
|
7266
7803
|
);
|
|
7267
7804
|
return 1;
|
|
@@ -9185,6 +9722,32 @@ init_api_client();
|
|
|
9185
9722
|
init_config();
|
|
9186
9723
|
init_manifest_io();
|
|
9187
9724
|
init_output();
|
|
9725
|
+
var SMART_DASH_RE = /^[‒–—―−]/;
|
|
9726
|
+
function strayArgsError(stray, usage) {
|
|
9727
|
+
if (!stray || stray.length === 0) return null;
|
|
9728
|
+
const lines = [
|
|
9729
|
+
`Unexpected argument${stray.length > 1 ? "s" : ""} ${stray.map((s) => `"${s}"`).join(", ")} \u2014 ${usage}`
|
|
9730
|
+
];
|
|
9731
|
+
if (stray.some((s) => SMART_DASH_RE.test(s))) {
|
|
9732
|
+
lines.push(
|
|
9733
|
+
`A token starts with a typographic dash (\u2013 / \u2014) instead of ASCII "--". If this command was copy-pasted, the editor/chat likely rewrote the double hyphen \u2014 retype the flag by hand (e.g. ${code("--grant")}, ${code("--full-access")}).`
|
|
9734
|
+
);
|
|
9735
|
+
}
|
|
9736
|
+
if (stray.some((s) => /^(module|datahub):/.test(s))) {
|
|
9737
|
+
lines.push(
|
|
9738
|
+
`Grant specs must be passed via the (repeatable) ${code("--grant")} flag, e.g. ${code("--grant module:employees:create,read")}.`
|
|
9739
|
+
);
|
|
9740
|
+
}
|
|
9741
|
+
if (stray.some((s) => s.startsWith("--"))) {
|
|
9742
|
+
lines.push(
|
|
9743
|
+
`Tokens after a bare ${code("--")} separator are treated as plain arguments, not flags \u2014 remove the ${code("--")}.`
|
|
9744
|
+
);
|
|
9745
|
+
}
|
|
9746
|
+
lines.push(
|
|
9747
|
+
"Refusing to continue: silently ignoring these arguments is how grantless (deny-all) keys get minted by accident."
|
|
9748
|
+
);
|
|
9749
|
+
return lines.join("\n ");
|
|
9750
|
+
}
|
|
9188
9751
|
function loadContext4(opts) {
|
|
9189
9752
|
const cfg = readConfig();
|
|
9190
9753
|
if (!cfg) {
|
|
@@ -9250,6 +9813,14 @@ async function resolveInstallKind(ctx) {
|
|
|
9250
9813
|
return void 0;
|
|
9251
9814
|
}
|
|
9252
9815
|
async function apiKeysListCommand(options) {
|
|
9816
|
+
const strayErr = strayArgsError(
|
|
9817
|
+
options.stray,
|
|
9818
|
+
"`dashwise api-keys list` takes no positional arguments."
|
|
9819
|
+
);
|
|
9820
|
+
if (strayErr) {
|
|
9821
|
+
fail(strayErr);
|
|
9822
|
+
return 1;
|
|
9823
|
+
}
|
|
9253
9824
|
let ctx;
|
|
9254
9825
|
try {
|
|
9255
9826
|
ctx = loadContext4(options);
|
|
@@ -9300,6 +9871,20 @@ async function apiKeysListCommand(options) {
|
|
|
9300
9871
|
return 0;
|
|
9301
9872
|
}
|
|
9302
9873
|
async function apiKeysCreateCommand(nameArg, options) {
|
|
9874
|
+
if (nameArg !== void 0 && SMART_DASH_RE.test(nameArg)) {
|
|
9875
|
+
fail(
|
|
9876
|
+
`"${nameArg}" was read as the key NAME but looks like a flag typed with a typographic dash (\u2013 / \u2014) instead of ASCII "--". If this command was copy-pasted, the editor/chat likely rewrote the double hyphen \u2014 retype the flag by hand (e.g. ${code("--grant")}, ${code("--full-access")}).`
|
|
9877
|
+
);
|
|
9878
|
+
return 1;
|
|
9879
|
+
}
|
|
9880
|
+
const strayErr = strayArgsError(
|
|
9881
|
+
options.stray,
|
|
9882
|
+
"`dashwise api-keys create` takes a single <name>."
|
|
9883
|
+
);
|
|
9884
|
+
if (strayErr) {
|
|
9885
|
+
fail(strayErr);
|
|
9886
|
+
return 1;
|
|
9887
|
+
}
|
|
9303
9888
|
const name = nameArg ?? options.name;
|
|
9304
9889
|
if (!name) {
|
|
9305
9890
|
fail("Usage: `dashwise api-keys create <name>` (e.g. `dashwise api-keys create production`)");
|
|
@@ -9388,6 +9973,14 @@ async function apiKeysCreateCommand(nameArg, options) {
|
|
|
9388
9973
|
return 0;
|
|
9389
9974
|
}
|
|
9390
9975
|
async function apiKeysRevokeCommand(keyIdArg, options) {
|
|
9976
|
+
const strayErr = strayArgsError(
|
|
9977
|
+
options.stray,
|
|
9978
|
+
"`dashwise api-keys revoke` takes a single <key-id> \u2014 extra ids would be silently skipped."
|
|
9979
|
+
);
|
|
9980
|
+
if (strayErr) {
|
|
9981
|
+
fail(strayErr);
|
|
9982
|
+
return 1;
|
|
9983
|
+
}
|
|
9391
9984
|
if (!keyIdArg) {
|
|
9392
9985
|
fail("Usage: `dashwise api-keys revoke <key-id>` \u2014 id from `dashwise api-keys list`");
|
|
9393
9986
|
return 1;
|
|
@@ -11223,14 +11816,19 @@ apiKeysCmd.command("list").description(
|
|
|
11223
11816
|
"--install <id>",
|
|
11224
11817
|
"Target a specific installation id instead of the cached dev install (production installs included). Find ids via `dashwise module status <slug>`.",
|
|
11225
11818
|
parseIntOption
|
|
11226
|
-
).option("--all", "Include revoked + expired keys in the output.").option("--json", "Emit JSON instead of the human-friendly table.").action(
|
|
11227
|
-
|
|
11228
|
-
|
|
11229
|
-
|
|
11230
|
-
|
|
11231
|
-
|
|
11232
|
-
|
|
11233
|
-
|
|
11819
|
+
).option("--all", "Include revoked + expired keys in the output.").option("--json", "Emit JSON instead of the human-friendly table.").action(
|
|
11820
|
+
async (cmdOpts, cmd) => {
|
|
11821
|
+
process.exitCode = await apiKeysListCommand({
|
|
11822
|
+
...cmdOpts.dir !== void 0 ? { dir: cmdOpts.dir } : {},
|
|
11823
|
+
...cmdOpts.install !== void 0 ? { install: cmdOpts.install } : {},
|
|
11824
|
+
...cmdOpts.all !== void 0 ? { all: cmdOpts.all } : {},
|
|
11825
|
+
...cmdOpts.json !== void 0 ? { json: cmdOpts.json } : {},
|
|
11826
|
+
// Operands commander would otherwise SILENTLY drop (default
|
|
11827
|
+
// allowExcessArguments) — the command hard-errors on them.
|
|
11828
|
+
...cmd.args.length > 0 ? { stray: cmd.args } : {}
|
|
11829
|
+
});
|
|
11830
|
+
}
|
|
11831
|
+
);
|
|
11234
11832
|
apiKeysCmd.command("create [name]").description(
|
|
11235
11833
|
"Mint a new API key. The plaintext key is displayed ONCE \u2014 save it then."
|
|
11236
11834
|
).option("--dir <path>", "Project root (default: current directory).").option(
|
|
@@ -11253,7 +11851,8 @@ apiKeysCmd.command("create [name]").description(
|
|
|
11253
11851
|
"--raw-only",
|
|
11254
11852
|
"Print only the plaintext key on stdout; chatter goes to stderr. Useful for shell capture: `KEY=$(dashwise api-keys create prod --raw-only)`"
|
|
11255
11853
|
).action(
|
|
11256
|
-
async (nameArg, cmdOpts) => {
|
|
11854
|
+
async (nameArg, cmdOpts, cmd) => {
|
|
11855
|
+
const stray = cmd.args.slice(1);
|
|
11257
11856
|
process.exitCode = await apiKeysCreateCommand(nameArg, {
|
|
11258
11857
|
...cmdOpts.dir !== void 0 ? { dir: cmdOpts.dir } : {},
|
|
11259
11858
|
...cmdOpts.install !== void 0 ? { install: cmdOpts.install } : {},
|
|
@@ -11261,7 +11860,8 @@ apiKeysCmd.command("create [name]").description(
|
|
|
11261
11860
|
...cmdOpts.grant !== void 0 && cmdOpts.grant.length > 0 ? { grant: cmdOpts.grant } : {},
|
|
11262
11861
|
...cmdOpts.fullAccess !== void 0 ? { fullAccess: cmdOpts.fullAccess } : {},
|
|
11263
11862
|
...cmdOpts.expires !== void 0 ? { expires: cmdOpts.expires } : {},
|
|
11264
|
-
...cmdOpts.rawOnly !== void 0 ? { rawOnly: cmdOpts.rawOnly } : {}
|
|
11863
|
+
...cmdOpts.rawOnly !== void 0 ? { rawOnly: cmdOpts.rawOnly } : {},
|
|
11864
|
+
...stray.length > 0 ? { stray } : {}
|
|
11265
11865
|
});
|
|
11266
11866
|
}
|
|
11267
11867
|
);
|
|
@@ -11272,11 +11872,13 @@ apiKeysCmd.command("revoke [key-id]").description(
|
|
|
11272
11872
|
"Target a specific installation id instead of the cached dev install (production installs included). Find ids via `dashwise module status <slug>`.",
|
|
11273
11873
|
parseIntOption
|
|
11274
11874
|
).option("-y, --yes", "Skip the per-key confirmation message (for CI / scripts).").action(
|
|
11275
|
-
async (keyIdArg, cmdOpts) => {
|
|
11875
|
+
async (keyIdArg, cmdOpts, cmd) => {
|
|
11876
|
+
const stray = cmd.args.slice(1);
|
|
11276
11877
|
process.exitCode = await apiKeysRevokeCommand(keyIdArg, {
|
|
11277
11878
|
...cmdOpts.dir !== void 0 ? { dir: cmdOpts.dir } : {},
|
|
11278
11879
|
...cmdOpts.install !== void 0 ? { install: cmdOpts.install } : {},
|
|
11279
|
-
...cmdOpts.yes !== void 0 ? { yes: cmdOpts.yes } : {}
|
|
11880
|
+
...cmdOpts.yes !== void 0 ? { yes: cmdOpts.yes } : {},
|
|
11881
|
+
...stray.length > 0 ? { stray } : {}
|
|
11280
11882
|
});
|
|
11281
11883
|
}
|
|
11282
11884
|
);
|
|
@@ -11364,16 +11966,20 @@ moduleCmd.command("publish").description("Pack the current directory + upload it
|
|
|
11364
11966
|
}
|
|
11365
11967
|
);
|
|
11366
11968
|
moduleCmd.command("install [slug]").alias("deploy").description(
|
|
11367
|
-
"Install (deploy) a published module into a workspace \u2014 provisions the runtime and prints the public URL. Upgrades/repairs an existing production install idempotently.
|
|
11969
|
+
"Install (deploy) a published module into a workspace \u2014 provisions the runtime and prints the public URL. Upgrades/repairs an existing production install idempotently. When only a dev install exists it asks whether to install alongside it (--keep-dev-install) or replace it (--replace-dev-install), rather than silently landing on the dev row."
|
|
11368
11970
|
).option("--workspace <slug-or-id>", "Target workspace (default: cached module workspace, config default, or interactive picker).").option("--version <semver>", "Install a specific published version (default: latest).").option("--force", "Proceed even when the upgrade breaks consumer modules (UPGRADE_BREAKS_CONSUMERS).").option(
|
|
11971
|
+
"--keep-dev-install",
|
|
11972
|
+
"Install production ALONGSIDE the workspace's kind=local dev install, leaving local dev working. Non-destructive. Backends >= 2026-07-15 keep a separate live-install slot per kind group, so the two coexist."
|
|
11973
|
+
).option(
|
|
11369
11974
|
"--replace-dev-install",
|
|
11370
|
-
"Destroy the workspace's kind=local dev install first, then fresh production install.
|
|
11975
|
+
"Destroy the workspace's kind=local dev install first, then fresh production install. Revokes the cached local-dev credentials; on backends >= 2026-07-15 local dev can be re-provisioned afterwards (dashwise module reset-dev-install, or dashwise init <slug> --workspace <ws>) and will coexist with the production install. On OLDER backends this is one-way \u2014 prefer --keep-dev-install."
|
|
11371
11976
|
).option("--dir <path>", "Project root for the module.json slug lookup (default: current directory).").option("-y, --yes", "Skip the confirmation prompt (for CI / scripted use).").option("--json", "Emit the final result as machine-readable JSON.").action(
|
|
11372
11977
|
async (slug, cmdOpts) => {
|
|
11373
11978
|
process.exitCode = await moduleInstallCommand(slug, {
|
|
11374
11979
|
...cmdOpts.workspace !== void 0 ? { workspace: cmdOpts.workspace } : {},
|
|
11375
11980
|
...cmdOpts.version !== void 0 ? { version: cmdOpts.version } : {},
|
|
11376
11981
|
...cmdOpts.force !== void 0 ? { force: cmdOpts.force } : {},
|
|
11982
|
+
...cmdOpts.keepDevInstall !== void 0 ? { keepDevInstall: cmdOpts.keepDevInstall } : {},
|
|
11377
11983
|
...cmdOpts.replaceDevInstall !== void 0 ? { replaceDevInstall: cmdOpts.replaceDevInstall } : {},
|
|
11378
11984
|
...cmdOpts.dir !== void 0 ? { dir: cmdOpts.dir } : {},
|
|
11379
11985
|
...cmdOpts.yes !== void 0 ? { yes: cmdOpts.yes } : {},
|
|
@@ -11619,7 +12225,7 @@ fieldCmd.command("set-type <table-slug> <field-slug> <new-type>").description(
|
|
|
11619
12225
|
}
|
|
11620
12226
|
})();
|
|
11621
12227
|
function getVersion() {
|
|
11622
|
-
return "0.
|
|
12228
|
+
return "0.18.1";
|
|
11623
12229
|
}
|
|
11624
12230
|
function parseIntOption(value) {
|
|
11625
12231
|
const n = Number.parseInt(value, 10);
|