@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.
- package/dist/bin.js +1186 -274
- package/dist/bin.js.map +1 -1
- 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
|
-
"#
|
|
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(`
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
1568
|
-
|
|
1569
|
-
|
|
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
|
|
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
|
|
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
|
|
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(`
|
|
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
|
-
|
|
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
|
-
<
|
|
1694
|
-
|
|
1695
|
-
|
|
1696
|
-
|
|
1697
|
-
|
|
1698
|
-
|
|
1699
|
-
|
|
1700
|
-
|
|
1701
|
-
|
|
1702
|
-
|
|
1703
|
-
|
|
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
|
|
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
|
|
1715
|
-
//
|
|
1716
|
-
//
|
|
1717
|
-
//
|
|
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
|
-
//
|
|
1722
|
-
//
|
|
1723
|
-
//
|
|
1724
|
-
//
|
|
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
|
-
//
|
|
1727
|
-
//
|
|
1728
|
-
//
|
|
1729
|
-
//
|
|
1730
|
-
|
|
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 {
|
|
1823
|
+
import { PlusIcon } from 'lucide-react';
|
|
1734
1824
|
import { Button } from '@/components/ui/button';
|
|
1735
|
-
import {
|
|
1736
|
-
|
|
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
|
|
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
|
-
<
|
|
1756
|
-
{
|
|
1757
|
-
|
|
1758
|
-
|
|
1759
|
-
|
|
1760
|
-
|
|
1761
|
-
</
|
|
1762
|
-
|
|
1763
|
-
|
|
1764
|
-
|
|
1765
|
-
|
|
1766
|
-
|
|
1767
|
-
|
|
1768
|
-
|
|
1769
|
-
|
|
1770
|
-
|
|
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
|
-
|
|
1782
|
-
|
|
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
|
|
1844
|
-
*
|
|
1845
|
-
*
|
|
1846
|
-
*
|
|
1847
|
-
*
|
|
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
|
-
*
|
|
1850
|
-
*
|
|
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
|
-
<
|
|
1864
|
-
|
|
1865
|
-
|
|
1866
|
-
|
|
1867
|
-
|
|
1868
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
|
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
|
-
//
|
|
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
|
|
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
|
-
|
|
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
|
|
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 `/*
|
|
2430
|
-
|
|
2431
|
-
|
|
2432
|
-
|
|
2433
|
-
|
|
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
|
-
|
|
2448
|
-
|
|
2449
|
-
--
|
|
2450
|
-
--
|
|
2451
|
-
|
|
2452
|
-
|
|
2453
|
-
--
|
|
2454
|
-
--
|
|
2455
|
-
|
|
2456
|
-
|
|
2457
|
-
|
|
2458
|
-
--accent:
|
|
2459
|
-
--accent-foreground:
|
|
2469
|
+
--primary: #4f46e5;
|
|
2470
|
+
--primary-foreground: oklch(0.99 0 0);
|
|
2471
|
+
--secondary: oklch(0.97 0 0);
|
|
2472
|
+
--secondary-foreground: oklch(0.2 0 0);
|
|
2473
|
+
--muted: oklch(0.97 0 0);
|
|
2474
|
+
--muted-foreground: oklch(0.5 0 0);
|
|
2475
|
+
--accent: oklch(0.96 0 0);
|
|
2476
|
+
--accent-foreground: oklch(0.14 0 0);
|
|
2477
|
+
/* A SECONDARY brand colour, distinct from the neutral --accent hover
|
|
2478
|
+
surface above. Workspace branding drives this one; never re-point
|
|
2479
|
+
--accent at a brand colour or every hover state in the app gets tinted. */
|
|
2480
|
+
--brand-accent: #4f46e5;
|
|
2481
|
+
--brand-accent-foreground: #ffffff;
|
|
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:
|
|
2464
|
-
--
|
|
2465
|
-
|
|
2466
|
-
|
|
2467
|
-
|
|
2468
|
-
--chart-
|
|
2469
|
-
|
|
2470
|
-
|
|
2471
|
-
|
|
2472
|
-
|
|
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,
|
|
2476
|
-
"Segoe UI", Roboto, "Helvetica Neue", Arial,
|
|
2515
|
+
"Geist", "Geist Fallback", ui-sans-serif, system-ui, -apple-system,
|
|
2516
|
+
BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial,
|
|
2517
|
+
"Noto Sans", sans-serif, "Apple Color Emoji", "Segoe UI Emoji",
|
|
2518
|
+
"Segoe UI Symbol", "Noto Color Emoji";
|
|
2477
2519
|
--font-mono:
|
|
2478
|
-
ui-monospace, SFMono-Regular, Menlo,
|
|
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
|
-
|
|
2490
|
-
|
|
2491
|
-
--
|
|
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
|
|
2496
|
-
--muted-foreground: oklch(0.71 0
|
|
2497
|
-
--accent: oklch(0.
|
|
2498
|
-
--accent-foreground: oklch(0.
|
|
2550
|
+
--muted: oklch(0.27 0 0);
|
|
2551
|
+
--muted-foreground: oklch(0.71 0 0);
|
|
2552
|
+
--accent: oklch(0.27 0 0);
|
|
2553
|
+
--accent-foreground: oklch(0.99 0 0);
|
|
2554
|
+
--brand-accent: #6253f0;
|
|
2555
|
+
--brand-accent-foreground: #ffffff;
|
|
2499
2556
|
--destructive: oklch(0.7 0.19 22.23);
|
|
2500
|
-
--
|
|
2501
|
-
--
|
|
2502
|
-
--
|
|
2503
|
-
|
|
2504
|
-
--
|
|
2505
|
-
--
|
|
2506
|
-
--
|
|
2507
|
-
--chart-
|
|
2508
|
-
--chart-
|
|
2557
|
+
--success: oklch(0.65 0.16 145);
|
|
2558
|
+
--success-foreground: oklch(0.14 0 0);
|
|
2559
|
+
--warning: oklch(0.75 0.15 62);
|
|
2560
|
+
--warning-foreground: oklch(0.14 0 0);
|
|
2561
|
+
--border: oklch(1 0 0 / 10%);
|
|
2562
|
+
--input: oklch(1 0 0 / 15%);
|
|
2563
|
+
--ring: #6253f0;
|
|
2564
|
+
--chart-1: oklch(0.49 0.24 264.4);
|
|
2565
|
+
--chart-2: oklch(0.7 0.16 160.43);
|
|
2566
|
+
--chart-3: oklch(0.77 0.17 65.36);
|
|
2567
|
+
--chart-4: oklch(0.62 0.26 305.32);
|
|
2568
|
+
--chart-5: oklch(0.64 0.25 16.51);
|
|
2569
|
+
--code-bg: oklch(0.2 0 0);
|
|
2570
|
+
--code-fg: oklch(0.97 0 0);
|
|
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 `
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
3289
|
+
in this Dashwise module. Read this before making changes.
|
|
2936
3290
|
|
|
2937
3291
|
## What this module is
|
|
2938
3292
|
|
|
2939
|
-
A
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
3060
|
-
|
|
3061
|
-
|
|
3062
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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 &&
|
|
6814
|
+
if (!existing && devInstall) {
|
|
6287
6815
|
const resolution = await resolveDevInstallCollision(
|
|
6288
|
-
|
|
6816
|
+
devInstall,
|
|
6289
6817
|
slug,
|
|
6290
6818
|
workspace2.slug,
|
|
6291
6819
|
options
|
|
6292
6820
|
);
|
|
6293
6821
|
if (typeof resolution === "number") return resolution;
|
|
6294
|
-
|
|
6295
|
-
|
|
6296
|
-
|
|
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
|
|
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})
|
|
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
|
|
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("--
|
|
6459
|
-
- ${code("--workspace <
|
|
7009
|
+
- ${code("--keep-dev-install")} \u2014 install production ALONGSIDE the dev install, keeping local dev working. Recommended while development is ongoing. Requires a backend \u2265 2026-07-15, where the live-install uniqueness is per KIND group, so one production and one dev install coexist.
|
|
7010
|
+
- ${code("--replace-dev-install")} \u2014 destroy the dev install, then fresh production install. Local dev can be re-provisioned afterwards with ${code(`dashwise init ${slug} --workspace <ws>`)} on backends \u2265 2026-07-15; on OLDER backends this is one-way \u2014 no re-provision anywhere once published (interim: a scoped key on the production install via ${code("api-keys create --install <id> --full-access")}, or duplicate the code under a NEW slug).
|
|
7011
|
+
- ${code("--workspace <other>")} \u2014 deploy to a separate test/client workspace and keep this dev install.`;
|
|
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: `
|
|
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
|
-
|
|
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
|
-
|
|
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 (
|
|
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(
|
|
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
|
-
|
|
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
|
|
7799
|
+
if (isDevInstallKind(row.kind)) {
|
|
7228
7800
|
fail(
|
|
7229
|
-
`Install ${code(`#${row.id}`)} of ${code(mod.slug)} in ${code(workspace2.slug)} is the ${code(
|
|
7801
|
+
`Install ${code(`#${row.id}`)} of ${code(mod.slug)} in ${code(workspace2.slug)} is the ${code(`kind=${row.kind ?? "unknown"}`)} DEV install backing local development.
|
|
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
|
|
7245
|
-
|
|
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 (
|
|
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:
|
|
7283
|
-
Reinstalling
|
|
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.
|
|
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.
|
|
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.
|
|
12163
|
+
return "0.18.0";
|
|
11252
12164
|
}
|
|
11253
12165
|
function parseIntOption(value) {
|
|
11254
12166
|
const n = Number.parseInt(value, 10);
|