@dashai/cli 0.16.0 → 0.18.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.
Files changed (3) hide show
  1. package/dist/bin.js +1186 -274
  2. package/dist/bin.js.map +1 -1
  3. package/package.json +2 -2
package/dist/bin.js CHANGED
@@ -464,6 +464,19 @@ async function applyDraftManifest(moduleId, manifest, options = {}) {
464
464
  options
465
465
  );
466
466
  }
467
+ async function getRuntimeLogs(installationId, opts = {}, options = {}) {
468
+ const params = new URLSearchParams();
469
+ if (opts.since !== void 0 && opts.since !== "") params.set("since", opts.since);
470
+ if (opts.limit !== void 0) params.set("limit", String(opts.limit));
471
+ if (opts.tail) params.set("tail", "true");
472
+ const qs = params.toString();
473
+ return apiRequest(
474
+ "GET",
475
+ `/api/modules/installations/${installationId}/runtime-logs${qs ? `?${qs}` : ""}`,
476
+ void 0,
477
+ options
478
+ );
479
+ }
467
480
  var ApiError;
468
481
  var init_api_client = __esm({
469
482
  "src/lib/api-client.ts"() {
@@ -1263,9 +1276,30 @@ function manifestJson(opts) {
1263
1276
  // table keys, and it derives the display name from the slug. A
1264
1277
  // `name` here would fail provisioning's table materialization (#4).
1265
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.
1266
1300
  fields: [
1267
- { slug: "title", type: "text" },
1268
- { slug: "done", type: "boolean" }
1301
+ { slug: "title", type: "text", human_edit: "open" },
1302
+ { slug: "done", type: "boolean", human_edit: "guarded" }
1269
1303
  ]
1270
1304
  }
1271
1305
  ]
@@ -1365,6 +1399,33 @@ const nextConfig = {
1365
1399
  NEXT_PUBLIC_DASHWISE_API_URL:
1366
1400
  process.env.NEXT_PUBLIC_DASHWISE_API_URL ?? apiUrl,
1367
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
+ },
1368
1429
  };
1369
1430
  export default nextConfig;
1370
1431
  `;
@@ -1399,7 +1460,7 @@ function gitignore() {
1399
1460
  ".vscode/",
1400
1461
  ".idea/",
1401
1462
  "",
1402
- "# DashWise-generated SDK output is regenerable from module.json",
1463
+ "# Dashwise-generated SDK output is regenerable from module.json",
1403
1464
  "# (run `dashwise module generate`); keep it out of source control.",
1404
1465
  "/node_modules/@dashai/generated/",
1405
1466
  ""
@@ -1449,7 +1510,7 @@ function envLocal(apiUrl = SCAFFOLD_DEFAULT_API_URL) {
1449
1510
  function appLayoutTsx(opts) {
1450
1511
  return `export const metadata = {
1451
1512
  title: ${JSON.stringify(opts.name)},
1452
- description: ${JSON.stringify(`DashWise module: ${opts.slug}`)},
1513
+ description: ${JSON.stringify(`Dashwise module: ${opts.slug}`)},
1453
1514
  };
1454
1515
 
1455
1516
  export default function RootLayout({ children }: { children: React.ReactNode }) {
@@ -1492,7 +1553,7 @@ function readme(opts) {
1492
1553
  function readmeHandAuthored(opts) {
1493
1554
  return `# ${opts.name}
1494
1555
 
1495
- A DashWise module (hand-authored). Slug: \`${opts.slug}\`.
1556
+ A Dashwise module (hand-authored). Slug: \`${opts.slug}\`.
1496
1557
 
1497
1558
  ## Get started
1498
1559
 
@@ -1533,9 +1594,9 @@ import { createClient, type Where } from '@dashai/sdk';
1533
1594
  function readmeCustom(opts) {
1534
1595
  return `# ${opts.name}
1535
1596
 
1536
- A **custom DashWise module** \u2014 a full Next.js App Router app that
1597
+ A **custom Dashwise module** \u2014 a full Next.js App Router app that
1537
1598
  runs in a per-installation Fly Machine (P7 substrate). Authenticated
1538
- via the DashWise NestJS backend through \`@dashai/sdk/auth\`.
1599
+ via the Dashwise NestJS backend through \`@dashai/sdk/auth\`.
1539
1600
 
1540
1601
  Slug: \`${opts.slug}\`.
1541
1602
 
@@ -1559,14 +1620,18 @@ npm run dev:next # next dev on :3000 (terminal 2)
1559
1620
  ## Architecture
1560
1621
 
1561
1622
  - **\`module.json\`** \u2014 manifest (\`module_kind: 'custom'\`, \`runtime.kind: 'machine'\`).
1562
- - **\`middleware.ts\`** \u2014 \`withAuth\` from \`@dashai/sdk/auth/middleware\`. Redirects unauthenticated requests through the DashWise SSO flow.
1623
+ - **\`middleware.ts\`** \u2014 \`withAuth\` from \`@dashai/sdk/auth/middleware\`. Redirects unauthenticated requests through the Dashwise SSO flow.
1563
1624
  - **\`app/layout.tsx\`** \u2014 wraps the tree in \`<AuthProvider>\` from \`@dashai/sdk/auth/client\`.
1564
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.**
1565
1626
  - **\`app/dashboard/page.tsx\`** \u2014 an additional example Server Component route, also session-gated via \`getServerSession()\`.
1566
1627
 
1567
- The design is yours: edit the theme tokens in \`app/globals.css\` (they ship
1568
- colored on purpose, as a starting point) and use any Tailwind colors or
1569
- shadcn blocks you like. A manual "Sign in" landing page is only for a
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
1570
1635
  \`public_access_allowed\` module \u2014 re-add \`'/'\` to \`middleware.ts\` publicPaths
1571
1636
  and drop the home's session guard to build one.
1572
1637
  - **\`app/api/auth/callback/route.ts\`** \u2014 exchanges the SSO one-time code for a session cookie (uses \`exchangeCode()\` from the SDK).
@@ -1639,7 +1704,7 @@ start-url call.
1639
1704
  `;
1640
1705
  }
1641
1706
  function middlewareTs() {
1642
- return `// Next.js middleware \u2014 gates every route on the DashWise session cookie.
1707
+ return `// Next.js middleware \u2014 gates every route on the Dashwise session cookie.
1643
1708
  // Public routes (and /api/auth/*) bypass the redirect; everything else
1644
1709
  // 302s through the SSO flow when no \`dashwise.session\` cookie is set.
1645
1710
  import { withAuth } from '@dashai/sdk/auth/middleware';
@@ -1655,12 +1720,12 @@ export default withAuth({
1655
1720
  // error page instead of redirect-looping back into re-authentication.
1656
1721
  //
1657
1722
  // Building a \`public_access_allowed\` module that wants an ANONYMOUS
1658
- // landing page (a marketing / manual "Sign in with DashWise" screen)?
1723
+ // landing page (a marketing / manual "Sign in with Dashwise" screen)?
1659
1724
  // Re-add \`'/'\` here AND make app/page.tsx public (drop its
1660
1725
  // getServerSession guard). See the README's "Design & home" note.
1661
1726
  publicPaths: ['/sign-in-error'],
1662
1727
  loginPath: '/api/auth/sign-in',
1663
- // Silent sign-in: when a signed-in DashWise user opens the module through
1728
+ // Silent sign-in: when a signed-in Dashwise user opens the module through
1664
1729
  // the platform (in-app embed or the module subdomain), the SSO handshake
1665
1730
  // completes with zero clicks \u2014 they land straight on the page they asked
1666
1731
  // for, the home at \`/\` included. It never touches:
@@ -1678,29 +1743,53 @@ export const config = {
1678
1743
  }
1679
1744
  function appLayoutCustomTsx(opts) {
1680
1745
  return `import './globals.css';
1681
- 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';
1682
1748
  import { ThemeProvider } from '@/components/theme-provider';
1749
+ import { PlatformThemeSync } from '@/components/platform-theme-sync';
1683
1750
 
1684
1751
  export const metadata = {
1685
1752
  title: ${JSON.stringify(opts.name)},
1686
- description: ${JSON.stringify(`DashWise custom module: ${opts.slug}`)},
1753
+ description: ${JSON.stringify(`Dashwise module: ${opts.slug}`)},
1687
1754
  };
1688
1755
 
1689
1756
  export default function RootLayout({ children }: { children: React.ReactNode }) {
1690
1757
  return (
1691
- <html lang="en" suppressHydrationWarning>
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>
1692
1770
  <body className="antialiased">
1693
- <ThemeProvider
1694
- attribute="class"
1695
- defaultTheme="system"
1696
- enableSystem
1697
- disableTransitionOnChange
1698
- >
1699
- {/* sessionEndpoint points at the same-origin /api/auth/session
1700
- route so the host-scoped session cookie is actually sent \u2014
1701
- a cross-origin fetch to the API would drop it (D8). */}
1702
- <AuthProvider sessionEndpoint="/api/auth/session">{children}</AuthProvider>
1703
- </ThemeProvider>
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>
1704
1793
  </body>
1705
1794
  </html>
1706
1795
  );
@@ -1708,40 +1797,37 @@ export default function RootLayout({ children }: { children: React.ReactNode })
1708
1797
  `;
1709
1798
  }
1710
1799
  function appPageCustomTsx(opts) {
1711
- return `// app/page.tsx \u2014 the app's REAL home, served at \`/\`. THIS IS YOUR APP.
1712
- // Build it here.
1800
+ return `// app/page.tsx \u2014 your module's REAL home, served at \`/\`. Build it here.
1713
1801
  //
1714
- // A signed-in DashWise user who opens this module through the platform
1715
- // (in-app embed or the module subdomain) lands on THIS page with zero
1716
- // clicks: the middleware's \`autoSignIn\` completes the SSO handshake
1717
- // silently, so there is no sign-in wall in front of your app. The guard
1718
- // below mirrors app/dashboard/page.tsx \u2014 no session \u2192 bounce through the
1719
- // 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.
1720
1806
  //
1721
- // A public, anonymous sign-in landing page is only for a
1722
- // \`public_access_allowed\` module that wants an unauthenticated entry
1723
- // point. To build one, re-add \`'/'\` to middleware.ts publicPaths and drop
1724
- // the getServerSession guard here (see the README's "Design & home" note).
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:
1725
1811
  //
1726
- // The design is YOURS to own. These shadcn primitives sit on the theme
1727
- // tokens in app/globals.css \u2014 edit the hues freely \u2014 and the full
1728
- // Tailwind palette (bg-indigo-600, text-emerald-500, \u2026) is available too.
1729
- // This colored starter look is a starting point, not a mandate.
1730
- import Link from 'next/link';
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.
1731
1821
  import { getServerSession } from '@dashai/sdk/auth/server';
1732
1822
  import { redirect } from 'next/navigation';
1733
- import { ArrowRight, Table2, Database, PenLine } from 'lucide-react';
1823
+ import { PlusIcon } from 'lucide-react';
1734
1824
  import { Button } from '@/components/ui/button';
1735
- import {
1736
- Card,
1737
- CardDescription,
1738
- CardHeader,
1739
- CardTitle,
1740
- } from '@/components/ui/card';
1825
+ import { ModuleShell } from '@/components/module-shell';
1826
+ import { EmptyState } from '@/components/states';
1741
1827
 
1742
1828
  // force-dynamic: this page calls getServerSession() at render, which
1743
1829
  // reads DASHWISE_API_URL/KEY. \`next build\` statically PRERENDERS pages by
1744
- // default and runs with NO DashWise env, so the SDK would throw
1830
+ // default and runs with NO Dashwise env, so the SDK would throw
1745
1831
  // "missing apiBaseUrl" and the platform's first publish would fail with
1746
1832
  // an opaque BUILD_FAILED. Opting into dynamic rendering skips prerender.
1747
1833
  export const dynamic = 'force-dynamic';
@@ -1751,76 +1837,32 @@ export default async function Home() {
1751
1837
  if (!session) {
1752
1838
  redirect('/api/auth/sign-in?return_to=/');
1753
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.
1754
1845
  return (
1755
- <main className="mx-auto flex min-h-dvh max-w-4xl flex-col gap-10 px-6 py-12">
1756
- {/* Primary-colored hero \u2014 the default look is colored on purpose,
1757
- to signal the design is yours to change. */}
1758
- <section className="flex flex-col gap-4 rounded-2xl bg-primary px-8 py-12 text-primary-foreground shadow-sm">
1759
- <p className="text-sm font-medium uppercase tracking-wide opacity-80">
1760
- ${escapeHtml(opts.name)}
1761
- </p>
1762
- <h1 className="text-balance text-4xl font-semibold tracking-tight sm:text-5xl">
1763
- Your app starts here.
1764
- </h1>
1765
- <p className="max-w-xl text-pretty text-lg opacity-90">
1766
- Signed in as{' '}
1767
- <span className="font-medium">{session.user.email}</span>. This is the
1768
- real home of your module \u2014 edit{' '}
1769
- <code className="rounded bg-primary-foreground/15 px-1.5 py-0.5 font-mono">
1770
- app/page.tsx
1771
- </code>{' '}
1772
- to build it.
1773
- </p>
1774
- <div className="flex flex-wrap gap-3 pt-2">
1775
- <Button asChild size="lg" variant="secondary">
1776
- <Link href="/dashboard">
1777
- See the example route
1778
- <ArrowRight aria-hidden="true" />
1779
- </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
1780
1862
  </Button>
1781
- </div>
1782
- </section>
1783
-
1784
- {/* A few colored pointers to get moving. */}
1785
- <section className="grid gap-4 sm:grid-cols-3">
1786
- <Card className="border-l-4 border-l-chart-1">
1787
- <CardHeader>
1788
- <Table2 className="size-5 text-chart-1" aria-hidden="true" />
1789
- <CardTitle className="text-base">Declare your data</CardTitle>
1790
- <CardDescription>
1791
- Add tables to{' '}
1792
- <code className="rounded bg-muted px-1 py-0.5 font-mono">module.json</code>{' '}
1793
- (or run{' '}
1794
- <code className="rounded bg-muted px-1 py-0.5 font-mono">dashwise table create</code>).
1795
- </CardDescription>
1796
- </CardHeader>
1797
- </Card>
1798
- <Card className="border-l-4 border-l-chart-2">
1799
- <CardHeader>
1800
- <Database className="size-5 text-chart-2" aria-hidden="true" />
1801
- <CardTitle className="text-base">Read it, typed</CardTitle>
1802
- <CardDescription>
1803
- Import{' '}
1804
- <code className="rounded bg-muted px-1 py-0.5 font-mono">db</code>{' '}
1805
- from{' '}
1806
- <code className="rounded bg-muted px-1 py-0.5 font-mono">@dashai/generated</code>{' '}
1807
- \u2014 regenerated on every manifest save.
1808
- </CardDescription>
1809
- </CardHeader>
1810
- </Card>
1811
- <Card className="border-l-4 border-l-chart-3">
1812
- <CardHeader>
1813
- <PenLine className="size-5 text-chart-3" aria-hidden="true" />
1814
- <CardTitle className="text-base">Write via server actions</CardTitle>
1815
- <CardDescription>
1816
- Mutate data from Server Actions / route handlers so the scoped{' '}
1817
- <code className="rounded bg-muted px-1 py-0.5 font-mono">dwk_</code>{' '}
1818
- key stays server-side.
1819
- </CardDescription>
1820
- </CardHeader>
1821
- </Card>
1822
- </section>
1823
- </main>
1863
+ }
1864
+ />
1865
+ </ModuleShell>
1824
1866
  );
1825
1867
  }
1826
1868
  `;
@@ -1830,6 +1872,7 @@ function appDashboardPageTsx() {
1830
1872
  import { redirect } from 'next/navigation';
1831
1873
  import { LogOut, ShieldCheck } from 'lucide-react';
1832
1874
  import { Button } from '@/components/ui/button';
1875
+ import { ModuleShell } from '@/components/module-shell';
1833
1876
  import {
1834
1877
  Card,
1835
1878
  CardContent,
@@ -1840,14 +1883,16 @@ import {
1840
1883
 
1841
1884
  /**
1842
1885
  * An ADDITIONAL example route \u2014 your real home is app/page.tsx (\`/\`).
1843
- * This \`/dashboard\` page is here to show a second auth-gated Server
1844
- * Component: keep it, rename it, or delete it. The middleware ensures the
1845
- * user has a session cookie before this page renders; \`getServerSession()\`
1846
- * validates the cookie against the backend's \`/api/auth/sessions/me\`
1847
- * endpoint, returning the decoded \`Session\` (or \`null\` if stale).
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).
1848
1891
  *
1849
- * On a successful render, this is the polished session card \u2014 replace
1850
- * the contents with whatever your module actually needs to show.
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".
1851
1896
  */
1852
1897
  // force-dynamic: same reason as app/page.tsx \u2014 this route calls
1853
1898
  // getServerSession() at render, which needs DASHWISE_API_URL/KEY. Static
@@ -1860,23 +1905,19 @@ export default async function Dashboard() {
1860
1905
  redirect('/api/auth/sign-in?return_to=/');
1861
1906
  }
1862
1907
  return (
1863
- <main className="mx-auto flex min-h-dvh max-w-3xl flex-col gap-6 px-6 py-12">
1864
- <header className="flex items-start justify-between gap-4">
1865
- <div>
1866
- <h1 className="text-2xl font-semibold tracking-tight">Dashboard</h1>
1867
- <p className="text-sm text-muted-foreground">
1868
- You're signed in. This page is rendered as a Server Component and
1869
- validates the session cookie on every request.
1870
- </p>
1871
- </div>
1872
- <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">
1873
1914
  <Button variant="outline" size="sm" type="submit">
1874
1915
  <LogOut aria-hidden="true" />
1875
1916
  Sign out
1876
1917
  </Button>
1877
1918
  </form>
1878
- </header>
1879
-
1919
+ }
1920
+ >
1880
1921
  <Card>
1881
1922
  <CardHeader>
1882
1923
  <CardTitle className="flex items-center gap-2">
@@ -1905,36 +1946,7 @@ export default async function Dashboard() {
1905
1946
  )}
1906
1947
  </CardContent>
1907
1948
  </Card>
1908
-
1909
- <Card>
1910
- <CardHeader>
1911
- <CardTitle>Next steps</CardTitle>
1912
- <CardDescription>
1913
- This is an extra example route \u2014 your real home is{' '}
1914
- <code className="rounded bg-muted px-1.5 py-0.5">app/page.tsx</code>.
1915
- Keep, rename, or delete this one.
1916
- </CardDescription>
1917
- </CardHeader>
1918
- <CardContent className="space-y-2 text-sm text-muted-foreground">
1919
- <p>
1920
- <strong className="text-foreground">1.</strong> Edit{' '}
1921
- <code className="rounded bg-muted px-1.5 py-0.5">module.json</code>{' '}
1922
- to add tables, dependencies, and exposed actions.
1923
- </p>
1924
- <p>
1925
- <strong className="text-foreground">2.</strong> Import typed clients
1926
- from{' '}
1927
- <code className="rounded bg-muted px-1.5 py-0.5">@dashai/generated</code>{' '}
1928
- (regenerated on every manifest save).
1929
- </p>
1930
- <p>
1931
- <strong className="text-foreground">3.</strong> Run{' '}
1932
- <code className="rounded bg-muted px-1.5 py-0.5">dashwise module publish --bump patch</code>{' '}
1933
- when you're ready to ship.
1934
- </p>
1935
- </CardContent>
1936
- </Card>
1937
- </main>
1949
+ </ModuleShell>
1938
1950
  );
1939
1951
  }
1940
1952
 
@@ -1952,7 +1964,7 @@ function appApiAuthSignInTs() {
1952
1964
  return `// /api/auth/sign-in \u2014 kick off the SSO flow (one path, dev + prod).
1953
1965
  //
1954
1966
  // Reads \`?return_to=\` from the caller (defaults to '/'), then asks the
1955
- // DashWise backend for a redirect URL by calling the module-API
1967
+ // Dashwise backend for a redirect URL by calling the module-API
1956
1968
  // server-side start endpoint:
1957
1969
  //
1958
1970
  // POST /api/module-api/sso/start-url
@@ -1989,7 +2001,7 @@ export async function GET(request: Request) {
1989
2001
  const returnTo = url.searchParams.get('return_to') ?? '/';
1990
2002
  // \`auto=1\` marks a SILENT attempt (the middleware's autoSignIn redirect,
1991
2003
  // no user click). Its failures degrade to the public landing page \u2014 where
1992
- // the manual "Sign in with DashWise" button lives \u2014 instead of the error
2004
+ // the manual "Sign in with Dashwise" button lives \u2014 instead of the error
1993
2005
  // page: the user never asked for anything, so don't show them an error.
1994
2006
  const isAuto = url.searchParams.get('auto') === '1';
1995
2007
  const apiBaseUrl = process.env.DASHWISE_API_URL;
@@ -2160,7 +2172,7 @@ async function tryDevHandoff(
2160
2172
  }
2161
2173
  function appApiAuthCallbackTs() {
2162
2174
  return `// /api/auth/callback \u2014 receives \`?code=&return_to=\` from the
2163
- // DashWise SSO start endpoint OR \`?error=&error_description=\` if the
2175
+ // Dashwise SSO start endpoint OR \`?error=&error_description=\` if the
2164
2176
  // SSO flow failed. Two paths:
2165
2177
  //
2166
2178
  // 1. Success path: exchange \`?code\` for a session cookie via
@@ -2309,7 +2321,7 @@ function appApiAuthSessionTs() {
2309
2321
  return `// /api/auth/session \u2014 SAME-ORIGIN session probe for client components.
2310
2322
  //
2311
2323
  // D8: the browser's <AuthProvider> can't read the session by calling the
2312
- // cross-origin DashWise API directly, because the \`dashwise.session\`
2324
+ // cross-origin Dashwise API directly, because the \`dashwise.session\`
2313
2325
  // cookie is host-scoped to THIS module's origin and is never sent to
2314
2326
  // \`api.dashwise.net\`. So <AuthProvider sessionEndpoint="/api/auth/session">
2315
2327
  // hits this route (same origin \u2192 the cookie IS sent); we validate it
@@ -2347,12 +2359,15 @@ function packageJsonCustom(opts) {
2347
2359
  start: "next start"
2348
2360
  },
2349
2361
  dependencies: {
2350
- "@dashai/sdk": "^2.3.0",
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",
2351
2366
  next: "^15.0.0",
2352
2367
  react: "^19.0.0",
2353
2368
  "react-dom": "^19.0.0",
2354
2369
  // Styling system — same shadcn + Tailwind 4 stack as the
2355
- // main DashWise frontend, so look-and-feel stays consistent
2370
+ // main Dashwise frontend, so look-and-feel stays consistent
2356
2371
  // and module authors can add more blocks via the
2357
2372
  // shadcn-studio MCP (`npx shadcn add @shadcn-studio/...`).
2358
2373
  tailwindcss: "^4",
@@ -2426,11 +2441,18 @@ export default config;
2426
2441
  `;
2427
2442
  }
2428
2443
  function globalsCss() {
2429
- return `/* These theme tokens are a STARTING POINT \u2014 change the hues freely.
2430
- The default \`--primary\` is a violet/indigo and the \`--chart-*\` ramp is
2431
- a 5-hue set; swap them for your brand. The full Tailwind color palette
2432
- (bg-indigo-600, text-emerald-500, \u2026) is also available on top of these
2433
- tokens. This is YOUR design to own. */
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. */
2434
2456
  @import "tailwindcss";
2435
2457
  @import "tw-animate-css";
2436
2458
  @import "shadcn/tailwind.css";
@@ -2444,39 +2466,74 @@ function globalsCss() {
2444
2466
  --card-foreground: oklch(0.14 0 0);
2445
2467
  --popover: oklch(1 0 0);
2446
2468
  --popover-foreground: oklch(0.14 0 0);
2447
- /* Colored primary (violet/indigo, ~indigo-600). White foreground reads
2448
- cleanly on it (~5:1). Change the hue (275) to re-brand. */
2449
- --primary: oklch(0.55 0.22 275);
2450
- --primary-foreground: oklch(0.985 0 0);
2451
- /* secondary + muted stay near-neutral (clean UI) but carry a faint
2452
- violet tint so nothing is pure grayscale. */
2453
- --secondary: oklch(0.97 0.005 275);
2454
- --secondary-foreground: oklch(0.25 0.02 275);
2455
- --muted: oklch(0.97 0.005 275);
2456
- --muted-foreground: oklch(0.55 0.02 275);
2457
- /* accent = a soft, low-chroma wash of the primary hue. */
2458
- --accent: oklch(0.96 0.03 275);
2459
- --accent-foreground: oklch(0.4 0.1 275);
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;
2460
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);
2461
2487
  --border: oklch(0.92 0 0);
2462
2488
  --input: oklch(0.92 0 0);
2463
- --ring: oklch(0.55 0.2 275);
2464
- --radius: 0.625rem;
2465
-
2466
- /* 5-hue chart ramp \u2014 distinct, readable hues for data viz + accents.
2467
- Wired to \`bg-chart-1\` \u2026 \`text-chart-5\` via the @theme block below. */
2468
- --chart-1: oklch(0.55 0.2 275);
2469
- --chart-2: oklch(0.6 0.13 195);
2470
- --chart-3: oklch(0.7 0.16 70);
2471
- --chart-4: oklch(0.65 0.17 145);
2472
- --chart-5: oklch(0.6 0.22 15);
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);
2473
2513
 
2474
2514
  --font-sans:
2475
- ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont,
2476
- "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
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";
2477
2519
  --font-mono:
2478
- ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas,
2479
- "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);
2480
2537
  }
2481
2538
 
2482
2539
  .dark {
@@ -2486,26 +2543,31 @@ function globalsCss() {
2486
2543
  --card-foreground: oklch(0.99 0 0);
2487
2544
  --popover: oklch(0.2 0 0);
2488
2545
  --popover-foreground: oklch(0.99 0 0);
2489
- /* Slightly lighter violet so it pops on the dark background; white
2490
- foreground stays readable (~4.8:1). */
2491
- --primary: oklch(0.58 0.21 275);
2492
- --primary-foreground: oklch(0.985 0 0);
2493
- --secondary: oklch(0.27 0.01 275);
2546
+ --primary: #6253f0;
2547
+ --primary-foreground: #ffffff;
2548
+ --secondary: oklch(0.27 0 0);
2494
2549
  --secondary-foreground: oklch(0.99 0 0);
2495
- --muted: oklch(0.27 0.01 275);
2496
- --muted-foreground: oklch(0.71 0.02 275);
2497
- --accent: oklch(0.3 0.04 275);
2498
- --accent-foreground: oklch(0.97 0.01 275);
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;
2499
2556
  --destructive: oklch(0.7 0.19 22.23);
2500
- --border: oklch(0.27 0 0);
2501
- --input: oklch(0.27 0 0);
2502
- --ring: oklch(0.58 0.19 275);
2503
-
2504
- --chart-1: oklch(0.62 0.19 275);
2505
- --chart-2: oklch(0.67 0.13 195);
2506
- --chart-3: oklch(0.75 0.15 70);
2507
- --chart-4: oklch(0.7 0.16 145);
2508
- --chart-5: oklch(0.67 0.2 15);
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);
2509
2571
  }
2510
2572
 
2511
2573
  /* Wire EVERY design token into Tailwind's namespace. In Tailwind 4,
@@ -2529,7 +2591,13 @@ function globalsCss() {
2529
2591
  --color-muted-foreground: var(--muted-foreground);
2530
2592
  --color-accent: var(--accent);
2531
2593
  --color-accent-foreground: var(--accent-foreground);
2594
+ --color-brand-accent: var(--brand-accent);
2595
+ --color-brand-accent-foreground: var(--brand-accent-foreground);
2532
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);
2533
2601
  --color-border: var(--border);
2534
2602
  --color-input: var(--input);
2535
2603
  --color-ring: var(--ring);
@@ -2538,10 +2606,33 @@ function globalsCss() {
2538
2606
  --color-chart-3: var(--chart-3);
2539
2607
  --color-chart-4: var(--chart-4);
2540
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);
2541
2624
  --radius-sm: calc(var(--radius) - 4px);
2542
2625
  --radius-md: calc(var(--radius) - 2px);
2543
2626
  --radius-lg: var(--radius);
2544
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);
2545
2636
  }
2546
2637
 
2547
2638
  * {
@@ -2553,6 +2644,39 @@ body {
2553
2644
  color: var(--foreground);
2554
2645
  font-family: var(--font-sans);
2555
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
+ }
2556
2680
  `;
2557
2681
  }
2558
2682
  function libUtilsTs() {
@@ -2595,7 +2719,9 @@ export function getPublicOrigin(request: Request): string {
2595
2719
  `;
2596
2720
  }
2597
2721
  function componentsUiButtonTsx() {
2598
- return `import * as React from "react";
2722
+ return `"use client";
2723
+
2724
+ import * as React from "react";
2599
2725
  import { cva, type VariantProps } from "class-variance-authority";
2600
2726
  import { Slot } from "radix-ui";
2601
2727
 
@@ -2741,6 +2867,234 @@ export function ThemeProvider({
2741
2867
  }
2742
2868
  `;
2743
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
+ }
2744
3098
  function appErrorTsx() {
2745
3099
  return `"use client";
2746
3100
 
@@ -2799,7 +3153,7 @@ function seedMjs() {
2799
3153
  return `/**
2800
3154
  * seed.mjs \u2014 OPTIONAL seed/maintenance script template.
2801
3155
  *
2802
- * Run it with the module's DashWise env injected (DASHWISE_API_URL +
3156
+ * Run it with the module's Dashwise env injected (DASHWISE_API_URL +
2803
3157
  * DASHWISE_API_KEY resolved like \`dashwise query\`: explicit env \u2192
2804
3158
  * .env.local \u2192 the dwk_ key cached by your CLI login):
2805
3159
  *
@@ -2839,9 +3193,9 @@ import {
2839
3193
 
2840
3194
  const ERROR_MESSAGES: Record<string, string> = {
2841
3195
  access_denied:
2842
- "You declined the sign-in request. To use this module, you'll need to authorize it on the DashWise sign-in page.",
3196
+ "You declined the sign-in request. To use this module, you'll need to authorize it on the Dashwise sign-in page.",
2843
3197
  server_error:
2844
- 'The DashWise sign-in server returned an internal error. This is usually transient \u2014 try again in a moment.',
3198
+ 'The Dashwise sign-in server returned an internal error. This is usually transient \u2014 try again in a moment.',
2845
3199
  invalid_request:
2846
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>").',
2847
3201
  missing_code:
@@ -2871,7 +3225,7 @@ export default async function SignInErrorPage({ searchParams }: SignInErrorPageP
2871
3225
  <div className="flex-1">
2872
3226
  <CardTitle>Sign-in failed</CardTitle>
2873
3227
  <CardDescription>
2874
- The DashWise SSO flow couldn't complete.
3228
+ The Dashwise SSO flow couldn't complete.
2875
3229
  </CardDescription>
2876
3230
  </div>
2877
3231
  </div>
@@ -2932,18 +3286,116 @@ function claudeMd(opts) {
2932
3286
  return `# CLAUDE.md \u2014 ${opts.name}
2933
3287
 
2934
3288
  Instructions for AI coding agents (Claude Code, Cursor, etc.) working
2935
- in this DashWise module. Read this before making changes.
3289
+ in this Dashwise module. Read this before making changes.
2936
3290
 
2937
3291
  ## What this module is
2938
3292
 
2939
- A DashWise ${isCustom ? "**custom Next.js** module (full app runtime, machine-class)" : "**hand-authored simple** module (isolate runtime)"} authored against \`@dashai/sdk\`.
3293
+ A Dashwise ${isCustom ? "**custom Next.js** module (full app runtime, machine-class)" : "**hand-authored simple** module (isolate runtime)"} authored against \`@dashai/sdk\`.
2940
3294
  Module slug: \`${opts.slug}\`.
2941
3295
 
2942
- DashWise calls apps "modules". A module is a Next.js app + a
3296
+ Dashwise calls apps "modules". A module is a Next.js app + a
2943
3297
  manifest (\`module.json\`) declaring the tables it owns, modules it
2944
- depends on, and actions it exposes. The DashWise backend hosts the
3298
+ depends on, and actions it exposes. The Dashwise backend hosts the
2945
3299
  data plane + auth; this module ships UI + business logic.
2946
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
+
2947
3399
  ## Spec-first, log-every-change
2948
3400
 
2949
3401
  This scaffold mirrors the dashwise-backend convention:
@@ -2991,6 +3443,33 @@ Allowed field types (18): \`text\`, \`long_text\`, \`number\`, \`boolean\`,
2991
3443
  \`link_row\`, \`file\`, \`multi_file\`, \`email\`, \`url\`,
2992
3444
  \`phone\`, \`formula\`, \`ai\`, \`uuid\`, \`jsonb\`.
2993
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
+
2994
3473
  ## Read + write data via the generated SDK
2995
3474
 
2996
3475
  The CLI's watcher regenerates typed clients into
@@ -3042,7 +3521,7 @@ granted key.
3042
3521
  ## Auth in dev mode
3043
3522
 
3044
3523
  \`dashwise dev\` registers a dev-mode SSO session with the backend so
3045
- workspace members can sign into the locally-running module via DashWise
3524
+ workspace members can sign into the locally-running module via Dashwise
3046
3525
  SSO. The scaffold's \`/api/auth/sign-in\` route is a single path: it
3047
3526
  calls \`POST /api/module-api/sso/start-url\` with your \`dwk_\` key and
3048
3527
  follows the returned \`redirect_url\` \u2014 the same code runs in dev and in
@@ -3056,15 +3535,24 @@ in \`app/layout.tsx\`).
3056
3535
  ${isCustom ? `
3057
3536
  ## Design & home
3058
3537
 
3059
- You OWN the UI \u2014 layout, components, and colors. Edit the theme tokens in
3060
- \`app/globals.css\` freely (they're a starting point, not a mandate), reach
3061
- for any Tailwind color (\`bg-indigo-600\`, \`text-emerald-500\`, \u2026), and pull
3062
- in more shadcn blocks (see the README's "Adding UI blocks").
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").
3063
3551
 
3064
3552
  The app's **home is \`app/page.tsx\`, served at \`/\`** \u2014 build the real app
3065
3553
  there. It's auth-gated: a platform user lands on it zero-click (the
3066
3554
  middleware's \`autoSignIn\` signs them in silently), and \`app/dashboard/page.tsx\`
3067
- is just an extra example route. A manual "Sign in with DashWise" landing
3555
+ is just an extra example route. A manual "Sign in with Dashwise" landing
3068
3556
  page belongs ONLY to a \`public_access_allowed\` module that wants an
3069
3557
  anonymous entry point (re-add \`'/'\` to \`middleware.ts\` publicPaths + drop
3070
3558
  the home's session guard to build one).
@@ -3074,7 +3562,7 @@ the home's session guard to build one).
3074
3562
  Add \`export const dynamic = 'force-dynamic'\` to the top of ANY page that
3075
3563
  reads the session or data at render (calls \`getServerSession()\` or a
3076
3564
  \`dwk_\`-authenticated data call). Without it, \`next build\` statically
3077
- prerenders the page with NO DashWise env vars, the SDK throws
3565
+ prerenders the page with NO Dashwise env vars, the SDK throws
3078
3566
  \`missing apiBaseUrl\`, and the platform's first publish fails with an
3079
3567
  opaque \`BUILD_FAILED\`. The scaffolded \`app/page.tsx\` + \`app/dashboard/page.tsx\`
3080
3568
  already have it \u2014 any new session-gated page you add needs it too.
@@ -3418,6 +3906,13 @@ async function moduleInitCommand(slugArg, options) {
3418
3906
  ["lib/utils.ts", libUtilsTs()],
3419
3907
  ["lib/request-origin.ts", libRequestOriginTs()],
3420
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()],
3421
3916
  ["components/ui/button.tsx", componentsUiButtonTsx()],
3422
3917
  ["components/ui/card.tsx", componentsUiCardTsx()],
3423
3918
  ["app/layout.tsx", appLayoutCustomTsx(scaffoldOpts)],
@@ -4324,9 +4819,29 @@ function parseFieldSpec(spec) {
4324
4819
  `The ${quote("unique")} flag is not supported on field specs \u2014 uniqueness is a TABLE-level index. Declare it in module.json as ${quote(`owns.tables[].indexes: [{ "fields": ["${slug}"], "unique": true }]`)} and apply it with ${quote("dashwise schema push")}.`
4325
4820
  );
4326
4821
  }
4822
+ if (DECIMALS_FLAG_REGEX.test(flag)) {
4823
+ if (type !== "number") {
4824
+ throw new FieldSpecError(
4825
+ `Decimal places (${quote(flag)}) are only valid on ${quote("number")} fields, not ${quote(type)}. Field spec ${quote(spec)} sets a scale on a non-number type.`
4826
+ );
4827
+ }
4828
+ if (result.decimal_places !== void 0) {
4829
+ throw new FieldSpecError(
4830
+ `Decimal places specified twice in field spec ${quote(spec)}.`
4831
+ );
4832
+ }
4833
+ const places = Number.parseInt(flag, 10);
4834
+ if (places < 0 || places > MAX_DECIMAL_PLACES) {
4835
+ throw new FieldSpecError(
4836
+ `Decimal places must be between 0 and ${MAX_DECIMAL_PLACES} (got ${quote(flag)}) in field spec ${quote(spec)}.`
4837
+ );
4838
+ }
4839
+ result.decimal_places = places;
4840
+ continue;
4841
+ }
4327
4842
  if (!KNOWN_FLAGS.has(flag)) {
4328
4843
  throw new FieldSpecError(
4329
- `Unknown flag ${quote(flag)} in field spec ${quote(spec)}. Known flags: ${[...KNOWN_FLAGS].join(", ")}. For options/target/default, use ${quote("--field-json")} instead.`
4844
+ `Unknown flag ${quote(flag)} in field spec ${quote(spec)}. Known flags: ${[...KNOWN_FLAGS].join(", ")}${type === "number" ? ` (plus a bare 0\u2013${MAX_DECIMAL_PLACES} integer for decimal places, e.g. ${quote(`${slug}:number:2`)})` : ""}. For options/target/default, use ${quote("--field-json")} instead.`
4330
4845
  );
4331
4846
  }
4332
4847
  if (flag === "required") result.required = true;
@@ -4377,7 +4892,7 @@ function parseFieldJson(json) {
4377
4892
  function quote(s) {
4378
4893
  return `\`${s}\``;
4379
4894
  }
4380
- var FIELD_TYPES, SLUG_REGEX, KNOWN_FLAGS, FieldSpecError;
4895
+ var FIELD_TYPES, SLUG_REGEX, KNOWN_FLAGS, DECIMALS_FLAG_REGEX, MAX_DECIMAL_PLACES, FieldSpecError;
4381
4896
  var init_field_spec = __esm({
4382
4897
  "src/lib/field-spec.ts"() {
4383
4898
  FIELD_TYPES = [
@@ -4400,6 +4915,8 @@ var init_field_spec = __esm({
4400
4915
  ];
4401
4916
  SLUG_REGEX = /^[a-z][a-z0-9_]*[a-z0-9]$/;
4402
4917
  KNOWN_FLAGS = /* @__PURE__ */ new Set(["required"]);
4918
+ DECIMALS_FLAG_REGEX = /^\d+$/;
4919
+ MAX_DECIMAL_PLACES = 10;
4403
4920
  FieldSpecError = class extends Error {
4404
4921
  constructor(message) {
4405
4922
  super(message);
@@ -6174,6 +6691,14 @@ init_workspaces();
6174
6691
  function isDevInstallKind(kind) {
6175
6692
  return kind === "local" || kind === "sandbox";
6176
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
+ }
6177
6702
  function isRealRuntimeUrl(url) {
6178
6703
  return !!url && /^https:\/\//i.test(url);
6179
6704
  }
@@ -6271,9 +6796,12 @@ async function moduleInstallCommand(slugArg, options) {
6271
6796
  return 1;
6272
6797
  }
6273
6798
  let existing;
6799
+ let devInstall;
6274
6800
  try {
6275
6801
  const installs = await listWorkspaceInstallations(workspace2.id, auth);
6276
- existing = installs.find((row2) => row2.module_id === mod.id);
6802
+ const byKind = selectInstallsByKind(installs, mod.id);
6803
+ existing = byKind.production;
6804
+ devInstall = byKind.dev;
6277
6805
  } catch (err) {
6278
6806
  fail(
6279
6807
  `Could not read workspace installations: ${err.message}`
@@ -6283,18 +6811,35 @@ async function moduleInstallCommand(slugArg, options) {
6283
6811
  let isUpgrade = false;
6284
6812
  let devInstallToReplace;
6285
6813
  let confirmedViaPicker = false;
6286
- if (existing && isDevInstallKind(existing.kind)) {
6814
+ if (!existing && devInstall) {
6287
6815
  const resolution = await resolveDevInstallCollision(
6288
- existing,
6816
+ devInstall,
6289
6817
  slug,
6290
6818
  workspace2.slug,
6291
6819
  options
6292
6820
  );
6293
6821
  if (typeof resolution === "number") return resolution;
6294
- devInstallToReplace = existing;
6295
- confirmedViaPicker = resolution === "replace-confirmed";
6296
- existing = void 0;
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
+ }
6297
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
+ }
6298
6843
  isUpgrade = true;
6299
6844
  const installedVersion2 = existing.module_version?.version;
6300
6845
  if (installedVersion2 && installedVersion2 === targetVersion) {
@@ -6308,13 +6853,13 @@ async function moduleInstallCommand(slugArg, options) {
6308
6853
  }
6309
6854
  }
6310
6855
  if (!options.yes && process.stdout.isTTY && !confirmedViaPicker) {
6311
- 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)}?`;
6312
6857
  const answer = await select({
6313
6858
  message,
6314
6859
  options: [
6315
6860
  {
6316
6861
  value: "go",
6317
- 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"
6318
6863
  },
6319
6864
  { value: "cancel", label: "No, abort" }
6320
6865
  ],
@@ -6365,7 +6910,7 @@ async function moduleInstallCommand(slugArg, options) {
6365
6910
  let verified = false;
6366
6911
  try {
6367
6912
  const after = await listWorkspaceInstallations(workspace2.id, auth);
6368
- row = after.find((r) => r.module_id === mod.id);
6913
+ row = selectInstallsByKind(after, mod.id).production;
6369
6914
  verified = row !== void 0;
6370
6915
  } catch {
6371
6916
  }
@@ -6444,19 +6989,26 @@ function resolveSlug(slugArg, dir) {
6444
6989
  }
6445
6990
  }
6446
6991
  async function resolveDevInstallCollision(existing, slug, workspaceSlug, options) {
6992
+ if (options.keepDevInstall) {
6993
+ return "alongside";
6994
+ }
6447
6995
  if (existing.kind === "sandbox") {
6448
6996
  fail(
6449
- `Workspace ${code(workspaceSlug)} already has a ${code("kind=sandbox")} install of ${code(slug)} (id=${existing.id}), and only ONE install per (workspace, module) can exist.
6997
+ `Workspace ${code(workspaceSlug)} already has a ${code("kind=sandbox")} install of ${code(slug)} (id=${existing.id}).
6450
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.
6451
- The CLI can't destroy a sandbox install. Uninstall it from the dashboard first, or deploy with ${code("--workspace <other>")} to a separate workspace.`
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.`
6452
7003
  );
6453
7004
  return 1;
6454
7005
  }
6455
7006
  const explanation = `Workspace ${code(workspaceSlug)} already has the DEV install of ${code(slug)} (kind=local, id=${existing.id}) created by ${code("dashwise init")}.
6456
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.
6457
7008
  Options:
6458
- - ${code("--replace-dev-install")} \u2014 destroy the dev install, then fresh production install. Recommended when development is done. Local dev can be re-provisioned afterwards with ${code(`dashwise init ${slug} --workspace <ws>`)} on backends \u2265 2026-07-15 (dev + production installs coexist); 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).
6459
- - ${code("--workspace <other>")} \u2014 deploy to a separate test/client workspace and keep this dev install. Recommended for ongoing development.`;
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.`;
6460
7012
  if (options.replaceDevInstall) {
6461
7013
  info(explanation);
6462
7014
  return "replace";
@@ -6464,8 +7016,13 @@ async function resolveDevInstallCollision(existing, slug, workspaceSlug, options
6464
7016
  if (process.stdout.isTTY && !options.yes) {
6465
7017
  info(explanation);
6466
7018
  const answer = await select({
6467
- message: `Replace the dev install of ${code(slug)} in ${code(workspaceSlug)} with a production install? (revokes local-dev credentials)`,
7019
+ message: `How should ${code(slug)} be installed into ${code(workspaceSlug)}?`,
6468
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
+ },
6469
7026
  {
6470
7027
  value: "replace",
6471
7028
  label: "Replace \u2014 destroy the dev install, then production-install",
@@ -6473,12 +7030,15 @@ async function resolveDevInstallCollision(existing, slug, workspaceSlug, options
6473
7030
  },
6474
7031
  { value: "cancel", label: "Cancel \u2014 I will deploy to another workspace" }
6475
7032
  ],
6476
- initialValue: "cancel"
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"
6477
7036
  });
6478
7037
  if (isCancel(answer) || answer === "cancel") {
6479
7038
  fail("Aborted. Nothing was changed.");
6480
7039
  return 130;
6481
7040
  }
7041
+ if (answer === "alongside") return "alongside";
6482
7042
  return "replace-confirmed";
6483
7043
  }
6484
7044
  fail(explanation);
@@ -7087,9 +7647,12 @@ async function moduleStatusCommand(slugArg, options) {
7087
7647
  return 1;
7088
7648
  }
7089
7649
  let row;
7650
+ let siblingDevInstall;
7090
7651
  try {
7091
7652
  const installs = await listWorkspaceInstallations(workspace2.id, auth);
7092
- row = installs.find((r) => r.module_id === mod.id);
7653
+ const byKind = selectInstallsByKind(installs, mod.id);
7654
+ row = byKind.production ?? byKind.dev;
7655
+ siblingDevInstall = byKind.production ? byKind.dev : void 0;
7093
7656
  } catch (err) {
7094
7657
  fail(`Could not read workspace installations: ${err.message}`);
7095
7658
  return 1;
@@ -7130,7 +7693,10 @@ async function moduleStatusCommand(slugArg, options) {
7130
7693
  runtime_provisioned: runtimeProvisioned,
7131
7694
  runtime_url: row.runtime_url,
7132
7695
  public_url: row.public_url,
7133
- 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
7134
7700
  })
7135
7701
  );
7136
7702
  return 0;
@@ -7151,9 +7717,14 @@ async function moduleStatusCommand(slugArg, options) {
7151
7717
  kv("Public URL:", row.public_url ?? dim("(none)"));
7152
7718
  kv("State:", stateLabel);
7153
7719
  out("");
7154
- if (row.kind === "local") {
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)) {
7155
7726
  info(
7156
- `This is the ${code("kind=local")} dev install \u2014 hidden from the app sidebar. A production deploy needs ${code("dashwise module install --replace-dev-install")} (or ${code("--workspace <other>")}).`
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.`
7157
7728
  );
7158
7729
  } else if (state === "not_deployed") {
7159
7730
  info(
@@ -7213,7 +7784,8 @@ async function moduleUninstallCommand(slugArg, options) {
7213
7784
  let row;
7214
7785
  try {
7215
7786
  const installs = await listWorkspaceInstallations(workspace2.id, auth);
7216
- row = installs.find((r) => r.module_id === mod.id);
7787
+ const byKind = selectInstallsByKind(installs, mod.id);
7788
+ row = byKind.production ?? byKind.dev;
7217
7789
  } catch (err) {
7218
7790
  fail(`Could not read workspace installations: ${err.message}`);
7219
7791
  return 1;
@@ -7224,9 +7796,9 @@ async function moduleUninstallCommand(slugArg, options) {
7224
7796
  );
7225
7797
  return 1;
7226
7798
  }
7227
- if (row.kind === "local") {
7799
+ if (isDevInstallKind(row.kind)) {
7228
7800
  fail(
7229
- `Install ${code(`#${row.id}`)} of ${code(mod.slug)} in ${code(workspace2.slug)} is the ${code("kind=local")} DEV install backing local development.
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.
7230
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.`
7231
7803
  );
7232
7804
  return 1;
@@ -7241,13 +7813,13 @@ async function moduleUninstallCommand(slugArg, options) {
7241
7813
  }
7242
7814
  info(
7243
7815
  `Uninstalling ${code(mod.slug)} (install ${code(`#${row.id}`)}, kind=${row.kind ?? "production"}${row.module_version?.version ? `, v${row.module_version.version}` : ""}) from workspace ${code(workspace2.slug)}.
7244
- The app is soft-deleted, recoverable ONLY by a PLATFORM OPERATOR (contact support/HQ) within ~7 days \u2014 it does NOT appear in your dashboard trash.
7245
- An operator restore KEEPS the public URL${row.public_url ? ` ${code(row.public_url)}` : ""}; a REINSTALL mints a NEW one \u2014 deep links to the old URL break.`
7816
+ The app is soft-deleted and appears in your workspace trash \u2014 a workspace admin can self-serve restore its DATA (~30 days). Reviving a running module within ~7 days is a platform-operator action.
7817
+ A REINSTALL now KEEPS the same public URL${row.public_url ? ` ${code(row.public_url)}` : ""}, so deep links survive. (On backends older than 2026-07-15: restore is operator-only and a reinstall mints a new URL.)`
7246
7818
  );
7247
7819
  const answer = await select({
7248
7820
  message: `Uninstall ${code(mod.slug)} from ${code(workspace2.slug)}?`,
7249
7821
  options: [
7250
- { value: "uninstall", label: "Uninstall \u2014 soft-delete the app (operator-restorable ~7 days)" },
7822
+ { value: "uninstall", label: "Uninstall \u2014 soft-delete the app (data restorable from workspace trash ~30 days)" },
7251
7823
  { value: "cancel", label: "Cancel \u2014 nothing is changed" }
7252
7824
  ],
7253
7825
  initialValue: "cancel"
@@ -7279,8 +7851,8 @@ async function moduleUninstallCommand(slugArg, options) {
7279
7851
  `Uninstalled ${code(mod.slug)} (install ${code(`#${row.id}`)}) from workspace ${code(workspace2.slug)}.`
7280
7852
  );
7281
7853
  info(
7282
- `The app was soft-deleted. If this was a mistake: only a PLATFORM OPERATOR can restore it (contact support/HQ) within ~7 days \u2014 it does NOT appear in your dashboard trash. An operator restore keeps the same public URL.
7283
- Reinstalling instead (${code(`dashwise module install ${mod.slug} --workspace ${workspace2.slug}`)}) mints a NEW public URL.`
7854
+ `The app was soft-deleted. If this was a mistake: restore its DATA yourself from your workspace trash (~30 days); to bring the running module back within ~7 days, contact a platform operator (support/HQ).
7855
+ Reinstalling (${code(`dashwise module install ${mod.slug} --workspace ${workspace2.slug}`)}) now KEEPS the same public URL. (Older backends, < 2026-07-15: restore is operator-only and reinstall mints a new URL.)`
7284
7856
  );
7285
7857
  if (options.parkConsumers) {
7286
7858
  info(
@@ -10489,6 +11061,319 @@ function mapAndPrintError(err, json) {
10489
11061
  return 1;
10490
11062
  }
10491
11063
 
11064
+ // src/commands/module/logs.ts
11065
+ init_api_client();
11066
+ init_config();
11067
+ init_manifest_io();
11068
+ init_output();
11069
+ var POLL_INTERVAL_MS3 = 2e3;
11070
+ var DURATION_UNIT_MS = {
11071
+ s: 1e3,
11072
+ m: 6e4,
11073
+ h: 36e5,
11074
+ d: 864e5
11075
+ };
11076
+ function resolveSince(raw) {
11077
+ if (raw === void 0) return void 0;
11078
+ const trimmed = raw.trim();
11079
+ if (trimmed === "") return void 0;
11080
+ const m = /^(\d+)\s*(s|m|h|d)$/i.exec(trimmed);
11081
+ if (m) {
11082
+ const n = Number.parseInt(m[1], 10);
11083
+ const unitMs = DURATION_UNIT_MS[m[2].toLowerCase()];
11084
+ return new Date(Date.now() - n * unitMs).toISOString();
11085
+ }
11086
+ return trimmed;
11087
+ }
11088
+ async function moduleLogsCommand(slugArg, options) {
11089
+ if (options.follow && options.json) {
11090
+ fail("`--follow` and `--json` are mutually exclusive.");
11091
+ return 1;
11092
+ }
11093
+ if (!isLoggedIn()) {
11094
+ fail(`Not logged in. Run ${code("dashwise login")} first.`);
11095
+ return 1;
11096
+ }
11097
+ const cfg = readConfig();
11098
+ if (!cfg) {
11099
+ fail(`CLI config missing. Run ${code("dashwise login")}.`);
11100
+ return 1;
11101
+ }
11102
+ const auth = {
11103
+ baseUrl: resolveApiUrl(),
11104
+ auth: { token: cfg.token }
11105
+ };
11106
+ const since = resolveSince(options.since);
11107
+ let installationId;
11108
+ let label;
11109
+ if (options.install !== void 0) {
11110
+ if (!Number.isInteger(options.install) || options.install <= 0) {
11111
+ fail(
11112
+ `--install expects a positive integer installation id (got "${options.install}").`
11113
+ );
11114
+ return 1;
11115
+ }
11116
+ installationId = options.install;
11117
+ label = `install #${installationId}`;
11118
+ } else {
11119
+ const resolved = await resolveInstallationFromSlug(slugArg, options, cfg, auth);
11120
+ if (typeof resolved === "number") return resolved;
11121
+ installationId = resolved.installationId;
11122
+ label = resolved.label;
11123
+ }
11124
+ if (options.follow) {
11125
+ return runFollow(installationId, label, { since, limit: options.limit }, auth);
11126
+ }
11127
+ return runOnce3(installationId, label, options, since, auth);
11128
+ }
11129
+ async function resolveInstallationFromSlug(slugArg, options, cfg, auth) {
11130
+ let slug = slugArg;
11131
+ if (!slug) {
11132
+ try {
11133
+ const fromManifest = readManifest(options.dir)?.module?.slug;
11134
+ if (typeof fromManifest === "string" && fromManifest.length > 0) {
11135
+ slug = fromManifest;
11136
+ }
11137
+ } catch {
11138
+ }
11139
+ }
11140
+ if (!slug) {
11141
+ fail(
11142
+ `No module slug \u2014 pass one (${code("dashwise module logs <slug>")}), run from a directory with a valid module.json, or target an install directly with ${code("--install <id>")}.`
11143
+ );
11144
+ return 1;
11145
+ }
11146
+ let modId;
11147
+ let modSlug;
11148
+ try {
11149
+ const mod = await getModuleBySlug(slug, auth);
11150
+ modId = mod.id;
11151
+ modSlug = mod.slug;
11152
+ } catch (err) {
11153
+ if (err instanceof ApiError && err.status === 404) {
11154
+ fail(`Module ${code(slug)} not found on the backend.`);
11155
+ return 1;
11156
+ }
11157
+ fail(`Could not resolve module ${code(slug)}: ${err.message}`);
11158
+ return 1;
11159
+ }
11160
+ let workspace2;
11161
+ try {
11162
+ workspace2 = await resolveWorkspaceWithFallback({
11163
+ userFlag: options.workspace,
11164
+ cachedWorkspaceId: readModuleEntry(slug)?.workspaceId,
11165
+ configDefault: cfg.workspaceSlug,
11166
+ apiUrl: resolveApiUrl(),
11167
+ apiToken: cfg.token
11168
+ });
11169
+ } catch (err) {
11170
+ fail(`Workspace selection failed: ${err.message}`);
11171
+ return 1;
11172
+ }
11173
+ let row;
11174
+ try {
11175
+ const installs = await listWorkspaceInstallations(workspace2.id, auth);
11176
+ const matches = installs.filter((r) => r.module_id === modId);
11177
+ row = matches.find((r) => !isDevInstallKind(r.kind) && isRealRuntimeUrl(r.runtime_url)) ?? matches.find((r) => !isDevInstallKind(r.kind)) ?? matches[0];
11178
+ } catch (err) {
11179
+ fail(`Could not read workspace installations: ${err.message}`);
11180
+ return 1;
11181
+ }
11182
+ if (!row) {
11183
+ info(
11184
+ `${code(modSlug)} is not installed in workspace ${code(workspace2.slug)} \u2014 nothing to read logs from. Deploy it with ${code(`dashwise module install ${modSlug} --workspace ${workspace2.slug}`)}.`
11185
+ );
11186
+ return 0;
11187
+ }
11188
+ return {
11189
+ installationId: row.id,
11190
+ label: `${modSlug} in ${workspace2.slug} (install #${row.id})`
11191
+ };
11192
+ }
11193
+ async function runOnce3(installationId, label, options, since, auth) {
11194
+ let body;
11195
+ try {
11196
+ body = await getRuntimeLogs(
11197
+ installationId,
11198
+ {
11199
+ ...since !== void 0 && !options.tail ? { since } : {},
11200
+ ...options.limit !== void 0 ? { limit: options.limit } : {},
11201
+ ...options.tail ? { tail: true } : {}
11202
+ },
11203
+ auth
11204
+ );
11205
+ } catch (err) {
11206
+ return mapLogsError(err, options.json);
11207
+ }
11208
+ if (options.json) {
11209
+ out(JSON.stringify(body));
11210
+ return 0;
11211
+ }
11212
+ if (body.entries.length === 0) {
11213
+ info(`No runtime log entries for ${label} in the requested window.`);
11214
+ return 0;
11215
+ }
11216
+ for (const entry of body.entries) out(formatRow2(entry));
11217
+ out("");
11218
+ info(
11219
+ `${body.entries.length} ${body.entries.length === 1 ? "line" : "lines"} (${label}).`
11220
+ );
11221
+ return 0;
11222
+ }
11223
+ async function runFollow(installationId, label, opts, auth) {
11224
+ info(`Following runtime logs for ${label} (every ${POLL_INTERVAL_MS3 / 1e3}s). Ctrl-C to stop.`);
11225
+ out("");
11226
+ let stopped = false;
11227
+ const onSigint = () => {
11228
+ stopped = true;
11229
+ };
11230
+ process.on("SIGINT", onSigint);
11231
+ let lastTs;
11232
+ let seenAtLastTs = /* @__PURE__ */ new Set();
11233
+ let firstPoll = true;
11234
+ try {
11235
+ while (!stopped) {
11236
+ const query = {};
11237
+ if (opts.limit !== void 0) query.limit = opts.limit;
11238
+ if (firstPoll) {
11239
+ if (opts.since !== void 0) query.since = opts.since;
11240
+ else query.tail = true;
11241
+ } else if (lastTs !== void 0) {
11242
+ query.since = new Date(lastTs).toISOString();
11243
+ }
11244
+ let body;
11245
+ try {
11246
+ body = await getRuntimeLogs(installationId, query, auth);
11247
+ } catch (err) {
11248
+ if (isFatalFollowError(err)) {
11249
+ return mapLogsError(err, false);
11250
+ }
11251
+ info(dim(`(follow: transient error \u2014 ${err.message})`));
11252
+ await sleep4(POLL_INTERVAL_MS3, () => stopped);
11253
+ firstPoll = false;
11254
+ continue;
11255
+ }
11256
+ const fresh = [];
11257
+ for (const entry of body.entries) {
11258
+ if (lastTs !== void 0 && entry.ts < lastTs) continue;
11259
+ if (lastTs !== void 0 && entry.ts === lastTs && seenAtLastTs.has(entryKey(entry))) {
11260
+ continue;
11261
+ }
11262
+ fresh.push(entry);
11263
+ }
11264
+ fresh.sort((a, b) => a.ts - b.ts);
11265
+ for (const entry of fresh) out(formatRow2(entry));
11266
+ if (fresh.length > 0) {
11267
+ const newMax = fresh[fresh.length - 1].ts;
11268
+ if (newMax !== lastTs) seenAtLastTs = /* @__PURE__ */ new Set();
11269
+ lastTs = newMax;
11270
+ for (const entry of fresh) {
11271
+ if (entry.ts === lastTs) seenAtLastTs.add(entryKey(entry));
11272
+ }
11273
+ }
11274
+ firstPoll = false;
11275
+ await sleep4(POLL_INTERVAL_MS3, () => stopped);
11276
+ }
11277
+ } finally {
11278
+ process.off("SIGINT", onSigint);
11279
+ }
11280
+ out("");
11281
+ return 0;
11282
+ }
11283
+ function isFatalFollowError(err) {
11284
+ if (!(err instanceof ApiError)) return false;
11285
+ if (err.status === 401 || err.status === 403 || err.status === 404) return true;
11286
+ return err.code === "INSTALLATION_NOT_FOUND" || err.code === "RUNTIME_NOT_PROVISIONED" || err.code === "INVALID_SINCE";
11287
+ }
11288
+ function formatTs(ts) {
11289
+ if (!Number.isFinite(ts)) return "--:--:--";
11290
+ return new Date(ts).toISOString().slice(11, 19);
11291
+ }
11292
+ function colorLevel(level) {
11293
+ const padded = level.toUpperCase().padEnd(5);
11294
+ switch (level) {
11295
+ case "error":
11296
+ return pc.red(padded);
11297
+ case "warn":
11298
+ return pc.yellow(padded);
11299
+ case "info":
11300
+ return pc.green(padded);
11301
+ case "debug":
11302
+ default:
11303
+ return dim(padded);
11304
+ }
11305
+ }
11306
+ function formatRow2(entry) {
11307
+ const time = dim(formatTs(entry.ts));
11308
+ const level = colorLevel(entry.level);
11309
+ const reqId = entry.request_id ? ` ${dim(`[${entry.request_id}]`)}` : "";
11310
+ return `${time} ${level} ${entry.message}${reqId}`;
11311
+ }
11312
+ function entryKey(entry) {
11313
+ return `${entry.ts}\0${entry.request_id ?? ""}\0${entry.message}`;
11314
+ }
11315
+ function mapLogsError(err, json) {
11316
+ if (err instanceof ApiError) {
11317
+ const emit = (code_, message2) => {
11318
+ if (json) out(JSON.stringify({ ok: false, error: { code: code_, message: message2 } }));
11319
+ else fail(message2);
11320
+ };
11321
+ if (err.status === 401 || err.code === "UNAUTHENTICATED") {
11322
+ emit("NOT_LOGGED_IN", `Not logged in. Run ${code("dashwise login")} first.`);
11323
+ return 1;
11324
+ }
11325
+ if (err.status === 403) {
11326
+ emit(
11327
+ "FORBIDDEN",
11328
+ `Reading runtime logs requires workspace-admin on this install's workspace.`
11329
+ );
11330
+ return 1;
11331
+ }
11332
+ if (err.status === 404 || err.code === "INSTALLATION_NOT_FOUND") {
11333
+ emit(
11334
+ "INSTALLATION_NOT_FOUND",
11335
+ `Installation not found. Check the id (${code("dashwise module status <slug>")}) \u2014 it may have been uninstalled.`
11336
+ );
11337
+ return 1;
11338
+ }
11339
+ if (err.code === "RUNTIME_NOT_PROVISIONED") {
11340
+ emit(
11341
+ "RUNTIME_NOT_PROVISIONED",
11342
+ `This install has no running runtime yet \u2014 deploy it first (${code("dashwise module install <slug>")}). The ${code("kind=local")} dev install never provisions a machine runtime, so it has no runtime logs.`
11343
+ );
11344
+ return 1;
11345
+ }
11346
+ if (err.code === "INVALID_SINCE") {
11347
+ emit(
11348
+ "INVALID_SINCE",
11349
+ `${code("--since")} must be an ISO-8601 timestamp (e.g. ${code("2026-07-15T09:00:00Z")}) or a duration like ${code("15m")} / ${code("2h")} / ${code("2d")}.`
11350
+ );
11351
+ return 1;
11352
+ }
11353
+ if (err.code.startsWith("RUNTIME_")) {
11354
+ emit(
11355
+ err.code,
11356
+ `The runtime host rejected the log read (${err.code}): ${err.message}. This is a host-side error \u2014 retry, or check the runtime with ${code("dashwise module status <slug>")}.`
11357
+ );
11358
+ return 1;
11359
+ }
11360
+ emit(err.code, `Could not read runtime logs: ${err.code} ${err.status} \u2014 ${err.message}`);
11361
+ return 1;
11362
+ }
11363
+ const message = `Could not read runtime logs: ${err.message}`;
11364
+ if (json) out(JSON.stringify({ ok: false, error: { code: "FETCH_FAILED", message } }));
11365
+ else fail(message);
11366
+ return 1;
11367
+ }
11368
+ async function sleep4(ms, aborted) {
11369
+ const slice = 100;
11370
+ let waited = 0;
11371
+ while (waited < ms && !aborted()) {
11372
+ await new Promise((resolve13) => setTimeout(resolve13, Math.min(slice, ms - waited)));
11373
+ waited += slice;
11374
+ }
11375
+ }
11376
+
10492
11377
  // src/bin.ts
10493
11378
  init_output();
10494
11379
  var program = new Command();
@@ -11016,16 +11901,20 @@ moduleCmd.command("publish").description("Pack the current directory + upload it
11016
11901
  }
11017
11902
  );
11018
11903
  moduleCmd.command("install [slug]").alias("deploy").description(
11019
- "Install (deploy) a published module into a workspace \u2014 provisions the runtime and prints the public URL. Upgrades/repairs an existing production install idempotently. Refuses to silently land on a dev install: use --replace-dev-install or --workspace <other>."
11904
+ "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."
11020
11905
  ).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(
11906
+ "--keep-dev-install",
11907
+ "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."
11908
+ ).option(
11021
11909
  "--replace-dev-install",
11022
- "Destroy the workspace's kind=local dev install first, then fresh production install. PERMANENTLY disables local dev for this module in this workspace (credentials revoked; a published module cannot be re-provisioned here) \u2014 continue development in a different workspace."
11910
+ "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."
11023
11911
  ).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(
11024
11912
  async (slug, cmdOpts) => {
11025
11913
  process.exitCode = await moduleInstallCommand(slug, {
11026
11914
  ...cmdOpts.workspace !== void 0 ? { workspace: cmdOpts.workspace } : {},
11027
11915
  ...cmdOpts.version !== void 0 ? { version: cmdOpts.version } : {},
11028
11916
  ...cmdOpts.force !== void 0 ? { force: cmdOpts.force } : {},
11917
+ ...cmdOpts.keepDevInstall !== void 0 ? { keepDevInstall: cmdOpts.keepDevInstall } : {},
11029
11918
  ...cmdOpts.replaceDevInstall !== void 0 ? { replaceDevInstall: cmdOpts.replaceDevInstall } : {},
11030
11919
  ...cmdOpts.dir !== void 0 ? { dir: cmdOpts.dir } : {},
11031
11920
  ...cmdOpts.yes !== void 0 ? { yes: cmdOpts.yes } : {},
@@ -11115,6 +12004,29 @@ moduleCmd.command("action-log").description(
11115
12004
  });
11116
12005
  }
11117
12006
  );
12007
+ moduleCmd.command("logs [slug]").description(
12008
+ "Read a deployed module install's runtime logs (the host's log group). Resolves the install from the slug + workspace like `module status`, or target any install directly with `--install <id>`. `--since 15m|2h|<iso>` sets a lower bound; `--tail` shows the most-recent page; `--follow`/`-f` streams new lines every ~2s (Ctrl-C to stop). Workspace-admin."
12009
+ ).option("--dir <path>", "Project root for the module.json slug lookup (default: current directory).").option("--workspace <slug-or-id>", "Target workspace (default: cached module workspace, config default, or interactive picker).").option(
12010
+ "--install <id>",
12011
+ "Target a specific installation id directly (deployed installs included). Find ids via `dashwise module status <slug>`. Skips slug + workspace resolution.",
12012
+ parseIntOption
12013
+ ).option(
12014
+ "--since <iso-or-duration>",
12015
+ 'Only lines newer than this. Accepts an ISO-8601 timestamp or a duration like "15m", "2h", "2d". Ignored with --tail.'
12016
+ ).option("--limit <n>", "Max lines per fetch (1-1000, backend-clamped, default 100).", parseIntOption).option("--tail", "Show the most-recent page (ignores --since).").option("-f, --follow", "Poll every ~2s for new lines. Ctrl-C to stop. Mutually exclusive with --json.").option("--json", "Emit the raw `{ entries }` JSON response. Ignored with --follow.").action(
12017
+ async (slug, cmdOpts) => {
12018
+ process.exitCode = await moduleLogsCommand(slug, {
12019
+ ...cmdOpts.dir !== void 0 ? { dir: cmdOpts.dir } : {},
12020
+ ...cmdOpts.workspace !== void 0 ? { workspace: cmdOpts.workspace } : {},
12021
+ ...cmdOpts.install !== void 0 ? { install: cmdOpts.install } : {},
12022
+ ...cmdOpts.since !== void 0 ? { since: cmdOpts.since } : {},
12023
+ ...cmdOpts.limit !== void 0 ? { limit: cmdOpts.limit } : {},
12024
+ ...cmdOpts.tail !== void 0 ? { tail: cmdOpts.tail } : {},
12025
+ ...cmdOpts.follow !== void 0 ? { follow: cmdOpts.follow } : {},
12026
+ ...cmdOpts.json !== void 0 ? { json: cmdOpts.json } : {}
12027
+ });
12028
+ }
12029
+ );
11118
12030
  moduleCmd.command("destroy-dev-install").description(
11119
12031
  "Destroy the local-dev install for the current module. Tears down the install row + dashboard tile; if the module had no other installs and was never published, drops the module too. Local module.json is preserved (only the cached runtime token + install id in auth.json get cleared)."
11120
12032
  ).option("--dir <path>", "Project root (default: current directory).").option(
@@ -11248,7 +12160,7 @@ fieldCmd.command("set-type <table-slug> <field-slug> <new-type>").description(
11248
12160
  }
11249
12161
  })();
11250
12162
  function getVersion() {
11251
- return "0.16.0";
12163
+ return "0.18.0";
11252
12164
  }
11253
12165
  function parseIntOption(value) {
11254
12166
  const n = Number.parseInt(value, 10);