@dashai/cli 0.20.1 → 0.22.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 CHANGED
@@ -1,10 +1,10 @@
1
1
  #!/usr/bin/env node
2
2
  import pc from 'picocolors';
3
- import { existsSync, readFileSync, writeFileSync, rmSync, mkdirSync, watch, readdirSync, chmodSync, renameSync, mkdtempSync, statSync } from 'fs';
3
+ import { existsSync, readFileSync, writeFileSync, rmSync, mkdirSync, watch, readdirSync, chmodSync, renameSync, lstatSync, mkdtempSync, statSync } from 'fs';
4
4
  import { hostname, tmpdir, platform, homedir } from 'os';
5
5
  import { resolve, join, relative, dirname, basename, win32, sep } from 'path';
6
6
  import { spawn, spawnSync } from 'child_process';
7
- import { intro, log, select, isCancel, cancel, spinner, outro, text, multiselect, confirm } from '@clack/prompts';
7
+ import { intro, log, select, isCancel, cancel, spinner, outro, text, confirm, multiselect } from '@clack/prompts';
8
8
  import { Command, Option } from 'commander';
9
9
  import open from 'open';
10
10
  import AdmZip from 'adm-zip';
@@ -286,6 +286,15 @@ function asEnvelope(v) {
286
286
  } else if (env.details && typeof env.details === "object") {
287
287
  out2.context = env.details;
288
288
  }
289
+ const folded = {};
290
+ for (const key of TOP_LEVEL_CONTEXT_KEYS) {
291
+ if (typeof env[key] === "string" && out2.context?.[key] === void 0) {
292
+ folded[key] = env[key];
293
+ }
294
+ }
295
+ if (Object.keys(folded).length > 0) {
296
+ out2.context = { ...out2.context ?? {}, ...folded };
297
+ }
289
298
  return out2;
290
299
  }
291
300
  function codeFromStatus(status) {
@@ -587,7 +596,34 @@ async function getRuntimeLogs(installationId, opts = {}, options = {}) {
587
596
  options
588
597
  );
589
598
  }
590
- var ApiError, CODE_SHAPE, slugifyFieldName;
599
+ async function getInstallationRuntime(installationId, options = {}) {
600
+ return apiRequest(
601
+ "GET",
602
+ `/api/installations/${installationId}/runtime`,
603
+ void 0,
604
+ options
605
+ );
606
+ }
607
+ async function setInstallationRuntime(installationId, body, options = {}) {
608
+ return apiRequest(
609
+ "PATCH",
610
+ `/api/installations/${installationId}/runtime`,
611
+ body,
612
+ options
613
+ );
614
+ }
615
+ async function rebuildModule(moduleId, body, options = {}) {
616
+ return apiRequest("POST", `/api/modules/${moduleId}/rebuild`, body, options);
617
+ }
618
+ async function getModuleBuild(moduleId, buildId, options = {}) {
619
+ return apiRequest(
620
+ "GET",
621
+ `/api/modules/${moduleId}/builds/${buildId}`,
622
+ void 0,
623
+ options
624
+ );
625
+ }
626
+ var ApiError, CODE_SHAPE, TOP_LEVEL_CONTEXT_KEYS, slugifyFieldName;
591
627
  var init_api_client = __esm({
592
628
  "src/lib/api-client.ts"() {
593
629
  init_config();
@@ -609,6 +645,7 @@ var init_api_client = __esm({
609
645
  }
610
646
  };
611
647
  CODE_SHAPE = /^[A-Z][A-Z0-9_]*$/;
648
+ TOP_LEVEL_CONTEXT_KEYS = ["feature", "permission", "plane"];
612
649
  slugifyFieldName = slugifyUsesName;
613
650
  }
614
651
  });
@@ -2068,13 +2105,27 @@ import { AuthProvider, HostRouteSync } from '@dashai/sdk/auth/client';
2068
2105
  import { PlatformProvider, PLATFORM_PREPAINT_SCRIPT } from '@dashai/sdk/platform';
2069
2106
  import { ThemeProvider } from '@/components/theme-provider';
2070
2107
  import { PlatformThemeSync } from '@/components/platform-theme-sync';
2108
+ import { getSession } from '@/lib/session';
2071
2109
 
2072
2110
  export const metadata = {
2073
2111
  title: ${JSON.stringify(opts.name)},
2074
2112
  description: ${JSON.stringify(`Dashwise module: ${opts.slug}`)},
2075
2113
  };
2076
2114
 
2077
- export default function RootLayout({ children }: { children: React.ReactNode }) {
2115
+ // force-dynamic: the layout reads the session at render (below), which needs
2116
+ // DASHWISE_API_URL/KEY. \`next build\` prerenders with NO Dashwise env, so the
2117
+ // SDK would throw "missing apiBaseUrl" \u2192 BUILD_FAILED on first publish.
2118
+ // Required today; becomes optional once the SDK reads cookies before config.
2119
+ export const dynamic = 'force-dynamic';
2120
+
2121
+ export default async function RootLayout({ children }: { children: React.ReactNode }) {
2122
+ // Fetch the session ONCE, on the server, and hand it to <AuthProvider> as
2123
+ // \`initialSession\`. Without it the provider fetches /api/auth/session from
2124
+ // the browser after hydration: a second server invocation (and two more
2125
+ // backend calls) on every page load before useSession() knows anything.
2126
+ // \`getSession\` is React-\`cache\`d, so pages calling it in the same request
2127
+ // reuse this result instead of asking the backend again.
2128
+ const session = await getSession();
2078
2129
  return (
2079
2130
  // \`data-dw-surface="embedded"\` is the SERVER default: this module normally
2080
2131
  // renders inside the Dashwise app shell, where the header/nav/theme toggle
@@ -2100,10 +2151,12 @@ export default function RootLayout({ children }: { children: React.ReactNode })
2100
2151
  Bridges the SDK to next-themes so the SDK stays theming-library
2101
2152
  agnostic. Inert when standalone \u2014 your own toggle wins there. */}
2102
2153
  <PlatformThemeSync />
2103
- {/* sessionEndpoint points at the same-origin /api/auth/session
2104
- route so the host-scoped session cookie is actually sent \u2014
2105
- a cross-origin fetch to the API would drop it (D8). */}
2106
- <AuthProvider sessionEndpoint="/api/auth/session">
2154
+ {/* initialSession: server-fetched above, so the provider skips
2155
+ its mount-time fetch. sessionEndpoint stays for refresh():
2156
+ it points at the same-origin /api/auth/session route so the
2157
+ host-scoped session cookie is actually sent \u2014 a cross-origin
2158
+ fetch to the API would drop it (D8). */}
2159
+ <AuthProvider sessionEndpoint="/api/auth/session" initialSession={session}>
2107
2160
  {/* Mirrors in-module navigation to the Dashwise host's address
2108
2161
  bar when embedded; inert when standalone. */}
2109
2162
  <HostRouteSync />
@@ -2139,58 +2192,86 @@ function appPageCustomTsx(opts) {
2139
2192
  // \`ModuleShell\` below encodes all of that. Anything that should appear only
2140
2193
  // when the module is opened on its own subdomain goes in \`.dw-standalone-only\`.
2141
2194
  // See CLAUDE.md \u2192 "Embedded-first UI" for the full contract.
2142
- import { getServerSession } from '@dashai/sdk/auth/server';
2195
+ import { db } from '@dashai/generated';
2196
+ import { hasPermissionInSession } from '@dashai/sdk/auth/server';
2143
2197
  import { redirect } from 'next/navigation';
2144
- import { PlusIcon } from 'lucide-react';
2145
- import { Button } from '@/components/ui/button';
2146
2198
  import { ModuleShell } from '@/components/module-shell';
2199
+ import { NewItemForm } from '@/components/new-item-form';
2147
2200
  import { EmptyState } from '@/components/states';
2201
+ import { getSession } from '@/lib/session';
2148
2202
 
2149
- // force-dynamic: this page calls getServerSession() at render, which
2150
- // reads DASHWISE_API_URL/KEY. \`next build\` statically PRERENDERS pages by
2203
+ // force-dynamic: this page reads the session at render, which needs
2204
+ // DASHWISE_API_URL/KEY. \`next build\` statically PRERENDERS pages by
2151
2205
  // default and runs with NO Dashwise env, so the SDK would throw
2152
2206
  // "missing apiBaseUrl" and the platform's first publish would fail with
2153
2207
  // an opaque BUILD_FAILED. Opting into dynamic rendering skips prerender.
2208
+ // Required today; becomes optional once the SDK reads cookies before config.
2154
2209
  export const dynamic = 'force-dynamic';
2155
2210
 
2156
2211
  export default async function Home() {
2157
- const session = await getServerSession();
2212
+ // \`getSession()\` is the per-request cached getServerSession(): the layout
2213
+ // already started it, so awaiting it here costs no extra backend call.
2214
+ const session = await getSession();
2158
2215
  if (!session) {
2159
2216
  redirect('/api/auth/sign-in?return_to=/');
2160
2217
  }
2161
2218
 
2162
- // Replace this with your real first screen: list what the user came to see,
2163
- // and render EmptyState only while there is genuinely nothing yet. Keep the
2164
- // loading state (ListSkeleton) and error state (ErrorState) \u2014 they're in
2165
- // components/states.tsx and they are what makes a module feel finished.
2219
+ // The middleware only checks that a session cookie EXISTS, and the layout
2220
+ // checks no permission \u2014 neither is the guard. What the user may see and
2221
+ // do is a PERMISSION check: here, before any data is read, and again
2222
+ // inside every Server Action (app/actions.ts), because hiding a button is
2223
+ // not authorization. \`items:read\` / \`items:create\` are the per-table
2224
+ // permissions the platform synthesizes when module.json has no \`rbac\`.
2225
+ const canRead = hasPermissionInSession(session, 'items:read');
2226
+ const canCreate = hasPermissionInSession(session, 'items:create');
2227
+
2228
+ // Read only what the user may see. With two or more independent reads,
2229
+ // start them together after the check: \`await Promise.all([a(), b()])\`,
2230
+ // never one await after another.
2231
+ //
2232
+ // \`db.items.list()\` with no \`limit\` returns ONE page of 100 rows and drops
2233
+ // \`total\`. Pass a \`limit\`, use \`listPage()\` when you need the count, or
2234
+ // paginate \u2014 never "read everything and filter in JS".
2235
+ const items = canRead ? await db.items.list({ limit: 50 }) : [];
2236
+
2237
+ // Replace this with your real first screen. Keep the three states: the
2238
+ // loading state is app/loading.tsx (ListSkeleton), errors land in
2239
+ // app/error.tsx, and EmptyState covers "genuinely nothing yet".
2166
2240
  return (
2167
- <ModuleShell
2168
- title="${escapeHtml(opts.name)}"
2169
- primaryAction={
2170
- <Button>
2171
- <PlusIcon aria-hidden="true" />
2172
- Add item
2173
- </Button>
2174
- }
2175
- >
2176
- <EmptyState
2177
- title="Nothing here yet"
2178
- 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."
2179
- action={
2180
- <Button>
2181
- <PlusIcon aria-hidden="true" />
2182
- Add item
2183
- </Button>
2184
- }
2185
- />
2241
+ <ModuleShell title="${escapeHtml(opts.name)}">
2242
+ {canCreate ? <NewItemForm /> : null}
2243
+ {!canRead ? (
2244
+ <EmptyState
2245
+ title="You can't see items yet"
2246
+ description="Your role in this module doesn't include viewing items. Ask a workspace admin for access."
2247
+ />
2248
+ ) : items.length === 0 ? (
2249
+ <EmptyState
2250
+ title="Nothing here yet"
2251
+ 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."
2252
+ />
2253
+ ) : (
2254
+ <ul className="divide-y divide-border rounded-lg border border-border">
2255
+ {items.map((item) => (
2256
+ <li
2257
+ key={item.id}
2258
+ className="flex items-center justify-between gap-4 px-4 py-3 text-sm"
2259
+ >
2260
+ <span className="min-w-0 truncate">{item.title}</span>
2261
+ {item.done ? (
2262
+ <span className="shrink-0 text-muted-foreground">Done</span>
2263
+ ) : null}
2264
+ </li>
2265
+ ))}
2266
+ </ul>
2267
+ )}
2186
2268
  </ModuleShell>
2187
2269
  );
2188
2270
  }
2189
2271
  `;
2190
2272
  }
2191
2273
  function appDashboardPageTsx() {
2192
- return `import { getServerSession } from '@dashai/sdk/auth/server';
2193
- import { redirect } from 'next/navigation';
2274
+ return `import { redirect } from 'next/navigation';
2194
2275
  import { LogOut, ShieldCheck } from 'lucide-react';
2195
2276
  import { Button } from '@/components/ui/button';
2196
2277
  import { ModuleShell } from '@/components/module-shell';
@@ -2201,12 +2282,14 @@ import {
2201
2282
  CardHeader,
2202
2283
  CardTitle,
2203
2284
  } from '@/components/ui/card';
2285
+ import { getSession } from '@/lib/session';
2204
2286
 
2205
2287
  /**
2206
2288
  * An ADDITIONAL example route \u2014 your real home is app/page.tsx (\`/\`).
2207
2289
  * This \`/dashboard\` page shows a second auth-gated Server Component: keep
2208
- * it, rename it, or delete it. The middleware ensures the user has a session
2209
- * cookie before this page renders; \`getServerSession()\` validates the cookie
2290
+ * it, rename it, or delete it. The middleware only ensures a session cookie
2291
+ * EXISTS before this page renders; \`getSession()\` (lib/session.ts, a
2292
+ * per-request cached \`getServerSession()\`) validates the cookie
2210
2293
  * against the backend's \`/api/auth/sessions/me\` endpoint, returning the
2211
2294
  * decoded \`Session\` (or \`null\` if stale).
2212
2295
  *
@@ -2215,13 +2298,15 @@ import {
2215
2298
  * Dashwise app the user menu already offers sign-out, so rendering our own
2216
2299
  * would duplicate the app's chrome. See CLAUDE.md \u2192 "Embedded-first UI".
2217
2300
  */
2218
- // force-dynamic: same reason as app/page.tsx \u2014 this route calls
2219
- // getServerSession() at render, which needs DASHWISE_API_URL/KEY. Static
2220
- // prerender during \`next build\` runs with no env \u2192 BUILD_FAILED. Opt out.
2301
+ // force-dynamic: same reason as app/page.tsx \u2014 this route reads the session
2302
+ // (getSession() \u2192 getServerSession()) at render, which needs
2303
+ // DASHWISE_API_URL/KEY. Static prerender during \`next build\` runs with no
2304
+ // env \u2192 BUILD_FAILED. Opt out.
2305
+ // Required today; becomes optional once the SDK reads cookies before config.
2221
2306
  export const dynamic = 'force-dynamic';
2222
2307
 
2223
2308
  export default async function Dashboard() {
2224
- const session = await getServerSession();
2309
+ const session = await getSession();
2225
2310
  if (!session) {
2226
2311
  redirect('/api/auth/sign-in?return_to=/');
2227
2312
  }
@@ -2602,26 +2687,44 @@ export async function POST(request: Request) {
2602
2687
  `;
2603
2688
  }
2604
2689
  function appApiItemsRouteTs() {
2605
- return `// Example data-plane route \u2014 lists + creates rows in the seeded
2606
- // \`items\` table via the generated typed SDK. Delete this (and the
2607
- // \`items\` table in module.json) once you add your own tables.
2690
+ return `// \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550
2691
+ // EXAMPLE ROUTE \u2014 DELETE OR RENAME IT.
2692
+ // It exists to show a guarded JSON route over the seeded \`items\` table.
2693
+ // Delete this file (and the \`items\` table in module.json) once you add
2694
+ // your own tables; your UI's writes belong in Server Actions
2695
+ // (app/actions.ts in the full scaffold), not here.
2696
+ // \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550
2697
+ //
2698
+ // Every route handler checks a PERMISSION. The middleware only checks that a
2699
+ // session cookie exists, and \`db\` talks to the Data Hub with the module's own
2700
+ // dwk_ key \u2014 so an unguarded route hands the module's full data access to
2701
+ // anyone who can reach the URL. \`withPermission\` answers 401 with no session
2702
+ // and 403 without the permission. \`items:read\` / \`items:create\` are the
2703
+ // per-table permissions the platform synthesizes when module.json has no
2704
+ // \`rbac\` block. Per-user rows also need an ownership check in the handler.
2705
+ //
2706
+ // A module scaffolded with \`--simple\` has no sign-in routes, so this answers
2707
+ // 401 until you add sign-in \u2014 that is the intended default.
2608
2708
  //
2609
2709
  // \`db\` comes from @dashai/generated, regenerated on every module.json
2610
2710
  // save by \`dashwise dev\`. Run it (or \`dashwise module generate\`) first.
2611
2711
  import { NextResponse } from 'next/server';
2712
+ import { withPermission } from '@dashai/sdk/auth/middleware';
2612
2713
  import { db } from '@dashai/generated';
2613
2714
 
2614
- export async function GET() {
2715
+ export const GET = withPermission('items:read')(async () => {
2615
2716
  // \`db.<table>.list()\` resolves to the rows array itself \u2014 the typed
2616
2717
  // client unwraps the \`{ rows, total, ... }\` page envelope (see the
2617
2718
  // generated \`client/tables.d.ts\`: \`list(): Promise<ItemsRow[]>\`).
2618
2719
  // Don't reach for \`.rows\` here: it's \`undefined\` on an array, and
2619
2720
  // \`NextResponse.json(undefined)\` throws "Value is not JSON serializable".
2721
+ // Without a \`limit\` it returns ONE page of 100; use \`listPage()\` for
2722
+ // the total, or paginate.
2620
2723
  const rows = await db.items.list({ limit: 50 });
2621
2724
  return NextResponse.json(rows);
2622
- }
2725
+ });
2623
2726
 
2624
- export async function POST(request: Request) {
2727
+ export const POST = withPermission('items:create')(async (request) => {
2625
2728
  const body = (await request.json().catch(() => null)) as
2626
2729
  | { title?: unknown; done?: unknown }
2627
2730
  | null;
@@ -2635,7 +2738,7 @@ export async function POST(request: Request) {
2635
2738
  const done = typeof body?.done === 'boolean' ? body.done : false;
2636
2739
  const row = await db.items.create({ title, done });
2637
2740
  return NextResponse.json(row, { status: 201 });
2638
- }
2741
+ });
2639
2742
  `;
2640
2743
  }
2641
2744
  function appApiAuthSessionTs() {
@@ -3471,6 +3574,151 @@ export default function Error({
3471
3574
  }
3472
3575
  `;
3473
3576
  }
3577
+ function libSessionTs() {
3578
+ return `import { cache } from 'react';
3579
+ import { getServerSession } from '@dashai/sdk/auth/server';
3580
+
3581
+ /**
3582
+ * The current session (or \`null\`), fetched at most ONCE per request.
3583
+ *
3584
+ * \`getServerSession()\` is uncached and costs two backend round trips
3585
+ * (session + RBAC). The root layout reads it to hydrate <AuthProvider>, and
3586
+ * pages read it for their guard; React \`cache()\` makes the second caller
3587
+ * reuse the first one's result within the same server render.
3588
+ *
3589
+ * Use this in layouts, pages and Server Components. Server Actions and route
3590
+ * handlers run in their own request: guard those with
3591
+ * \`requirePermission()\` / \`withPermission()\` instead.
3592
+ */
3593
+ export const getSession = cache(() => getServerSession());
3594
+ `;
3595
+ }
3596
+ function appLoadingTsx() {
3597
+ return `// Root loading state. Next.js shows this IMMEDIATELY on navigation while the
3598
+ // next page renders on the server (session + data reads), so the user gets
3599
+ // feedback within 100 ms instead of a frozen screen. Shaped like the content
3600
+ // that is coming: match \`rows\` and the row height to your real list, or it
3601
+ // visibly jumps on load. Add a loading.tsx per route segment when a screen's
3602
+ // shape differs from a list.
3603
+ import { ListSkeleton } from '@/components/states';
3604
+
3605
+ export default function Loading() {
3606
+ return (
3607
+ <div className="mx-auto w-full max-w-6xl p-4 md:p-6">
3608
+ <ListSkeleton />
3609
+ </div>
3610
+ );
3611
+ }
3612
+ `;
3613
+ }
3614
+ function appActionsTs() {
3615
+ return `'use server';
3616
+
3617
+ // Server Actions \u2014 the canonical WRITE path for this module.
3618
+ //
3619
+ // Every action follows the same four steps, in this order:
3620
+ // 1. Check a PERMISSION. The middleware only checks that a session cookie
3621
+ // exists; it says nothing about what the user may do. A Server Action is
3622
+ // a public POST endpoint \u2014 anyone with a cookie can call it directly,
3623
+ // whatever your UI shows or hides.
3624
+ // 2. Validate the input (FormData is untrusted).
3625
+ // 3. Write through the generated \`db\` client (the module's server-side
3626
+ // dwk_ key \u2014 never send that key to the browser).
3627
+ // 4. Revalidate ONE narrow path (or tag) \u2014 the screen that shows the data.
3628
+ // Never call router.refresh() after it: revalidatePath already sends
3629
+ // the fresh render back with the action's response, and a refresh
3630
+ // re-renders everything a second time.
3631
+ //
3632
+ // Per-user data (rows that belong to one person) also needs an OWNERSHIP
3633
+ // check: store \`session.user.id\` on create, and on update/delete compare it
3634
+ // before writing. The dwk_ key is the module's ceiling, not a per-user rule.
3635
+ import { revalidatePath } from 'next/cache';
3636
+ import { requirePermission } from '@dashai/sdk/auth/server';
3637
+ import { db } from '@dashai/generated';
3638
+
3639
+ export type CreateItemState = { error: string } | null;
3640
+
3641
+ export async function createItem(
3642
+ _prev: CreateItemState,
3643
+ formData: FormData,
3644
+ ): Promise<CreateItemState> {
3645
+ // 1. Throws a DashwiseError (401 no session / 403 denied) \u2192 app/error.tsx.
3646
+ // The page only renders the form for users who hold the permission, so
3647
+ // reaching this throw means someone called the action directly.
3648
+ await requirePermission('items:create');
3649
+
3650
+ // 2. Validate.
3651
+ const raw = formData.get('title');
3652
+ const title = typeof raw === 'string' ? raw.trim() : '';
3653
+ if (!title) {
3654
+ return { error: 'Give the item a title.' };
3655
+ }
3656
+
3657
+ // 3. Write.
3658
+ await db.items.create({ title, done: false });
3659
+
3660
+ // 4. One narrow revalidate: only the home page lists items.
3661
+ revalidatePath('/');
3662
+ return null;
3663
+ }
3664
+ `;
3665
+ }
3666
+ function componentsNewItemFormTsx() {
3667
+ return `"use client";
3668
+
3669
+ // The client half of the canonical write path (see app/actions.ts).
3670
+ //
3671
+ // Feedback within 100 ms: the submit button reads \`useFormStatus()\` and
3672
+ // switches to a pending state the moment the form is submitted, while the
3673
+ // Server Action runs. For lists where the new row should appear instantly,
3674
+ // layer \`useOptimistic\` on top \u2014 it is DISPLAY-ONLY: the server still checks
3675
+ // the permission and is the source of truth when the action returns.
3676
+ import { useActionState } from "react";
3677
+ import { useFormStatus } from "react-dom";
3678
+ import { PlusIcon } from "lucide-react";
3679
+ import { Button } from "@/components/ui/button";
3680
+ import { createItem, type CreateItemState } from "@/app/actions";
3681
+
3682
+ export function NewItemForm() {
3683
+ const [state, formAction] = useActionState<CreateItemState, FormData>(
3684
+ createItem,
3685
+ null,
3686
+ );
3687
+ return (
3688
+ <form action={formAction} className="flex flex-wrap items-start gap-2">
3689
+ <label htmlFor="new-item-title" className="sr-only">
3690
+ Title
3691
+ </label>
3692
+ <input
3693
+ id="new-item-title"
3694
+ name="title"
3695
+ required
3696
+ maxLength={200}
3697
+ autoComplete="off"
3698
+ placeholder="New item"
3699
+ className="h-9 min-w-0 flex-1 rounded-md border border-input bg-background px-3 text-sm outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50"
3700
+ />
3701
+ <SubmitButton />
3702
+ {state?.error ? (
3703
+ <p role="alert" className="w-full text-sm text-destructive">
3704
+ {state.error}
3705
+ </p>
3706
+ ) : null}
3707
+ </form>
3708
+ );
3709
+ }
3710
+
3711
+ function SubmitButton() {
3712
+ const { pending } = useFormStatus();
3713
+ return (
3714
+ <Button type="submit" disabled={pending}>
3715
+ <PlusIcon aria-hidden="true" />
3716
+ {pending ? "Adding\u2026" : "Add item"}
3717
+ </Button>
3718
+ );
3719
+ }
3720
+ `;
3721
+ }
3474
3722
  function seedMjs() {
3475
3723
  return `/**
3476
3724
  * seed.mjs \u2014 OPTIONAL seed/maintenance script template.
@@ -3801,16 +4049,31 @@ Import from there:
3801
4049
  \`\`\`ts
3802
4050
  import { db, qb } from '@dashai/generated';
3803
4051
 
3804
- // Simple CRUD
3805
- const rows = await db.<table>.list();
4052
+ // Simple CRUD. list() with no \`limit\` returns ONE page of 100 rows and
4053
+ // drops \`total\` \u2014 pass a \`limit\`, use listPage() for the total, or paginate.
4054
+ const rows = await db.<table>.list({ limit: 50 });
4055
+ const page = await db.<table>.listPage({ limit: 50, offset: 0 }); // { rows, total, ... }
3806
4056
  await db.<table>.create({ /* fields */ });
3807
4057
 
3808
4058
  // Query builder \u2014 a per-table getter (NOT a \`.from()\` call); \`.where()\`
3809
4059
  // takes a single clause object (field \u2192 value or field \u2192 operator), and
3810
4060
  // \`.execute()\` runs it. Chain \`.orderBy()\` / \`.limit()\` / \`.count()\` etc.
3811
4061
  const open = await qb.<table>.where({ status: { equal: 'open' } }).execute();
4062
+
4063
+ // Aggregate ON THE SERVER \u2014 one call, not one count() per option.
4064
+ const openCount = await qb.<table>.where({ status: { equal: 'open' } }).count();
4065
+ const byStatus = await qb.<table>
4066
+ .groupBy(['status'])
4067
+ .agg({ total: { count: '*' } })
4068
+ .execute(); // byStatus.rows = [{ status: 'open', total: 7 }, ...]
3812
4069
  \`\`\`
3813
4070
 
4071
+ Children of a linked parent: \`link_row\` fields take no value filters in a
4072
+ bare \`where\`, so join through the link and filter the joined side \u2014
4073
+ \`qb.comments.joinTask().where({ 'task.id': taskId }).execute()\` \u2014 which runs
4074
+ in SQL. Never read the whole child table and filter it in JS: \`list()\` stops
4075
+ at 100 rows, so that pattern is slow AND silently wrong past the first page.
4076
+
3814
4077
  The data plane authenticates via the per-module scoped \`dwk_\` API key
3815
4078
  cached in \`~/.config/dashwise/auth.json#modules[${opts.slug}]\` after
3816
4079
  \`dashwise init\` provisions the dev install. \`dashwise dev\` injects it
@@ -3850,10 +4113,67 @@ follows the returned \`redirect_url\` \u2014 the same code runs in dev and in
3850
4113
  production, with no installation id or dev-session id in the request.
3851
4114
 
3852
4115
  In Server Components: \`await getServerSession()\` (from
3853
- \`@dashai/sdk/auth/server\`).
4116
+ \`@dashai/sdk/auth/server\`)${isCustom ? ` \u2014 or the per-request cached
4117
+ \`getSession()\` from \`lib/session.ts\`, which the root layout already uses` : ""}.
3854
4118
  In Client Components: \`useSession()\` (from
3855
- \`@dashai/sdk/auth/client\`), wrapped in \`<AuthProvider>\` (already
3856
- in \`app/layout.tsx\`).
4119
+ \`@dashai/sdk/auth/client\`), wrapped in \`<AuthProvider>\`${isCustom ? ` (already
4120
+ in \`app/layout.tsx\`, hydrated server-side through \`initialSession\`)` : ""}.
4121
+
4122
+ ## Authorization \u2014 every action and route checks a permission
4123
+
4124
+ Signing in is wired; **authorization is not automatic.** The middleware only
4125
+ checks that a session cookie EXISTS \u2014 it never checks what the user may do.
4126
+ And \`db\` / \`qb\` run with the module's \`dwk_\` key, which can touch every
4127
+ table the module owns. So:
4128
+
4129
+ - **Every Server Action** starts with \`await requirePermission('<table>:<op>')\`
4130
+ (from \`@dashai/sdk/auth/server\`; throws 401/403). A Server Action is a
4131
+ public POST endpoint \u2014 hiding the button does not protect it.
4132
+ - **Every route handler** is wrapped: \`export const GET =
4133
+ withPermission('<table>:read')(async (req) => \u2026)\` (from
4134
+ \`@dashai/sdk/auth/middleware\`; answers 401/403).
4135
+ - **Per-user data needs an ownership check.** Store \`session.user.id\` on the
4136
+ row and compare it before every update/delete. A permission says the user
4137
+ may edit *items*, not *this* item.
4138
+ - **Optimistic UI is display-only.** \`useOptimistic\` changes what the user
4139
+ sees; the server action still checks the permission and its result wins.
4140
+ - Shape the UI with \`hasPermissionInSession(session, key)\` (server) or
4141
+ \`useHasPermission(key)\` (client) so nobody sees a button that then 403s.
4142
+
4143
+ With no \`rbac\` block in \`module.json\` the platform synthesizes
4144
+ \`<table>:create|read|update|delete\` permissions plus admin/editor/viewer
4145
+ roles.${isCustom ? ` \`app/actions.ts\` and \`app/api/items/route.ts\` show both guards.` : ""}
4146
+
4147
+ ## Responsiveness \u2014 rules, not polish
4148
+
4149
+ A module runs behind the platform proxy, so every server round trip is
4150
+ expensive. These are the defaults; break one only with a reason in the code.
4151
+
4152
+ - **Feedback within 100 ms.** Every click that writes shows a pending state
4153
+ immediately: \`useFormStatus()\` in the submit button, or \`useOptimistic\`
4154
+ for lists${isCustom ? ` (\`components/new-item-form.tsx\` shows the pattern)` : ""}.
4155
+ Navigation shows \`loading.tsx\`${isCustom ? ` (\`app/loading.tsx\` renders \`ListSkeleton\`)` : ""}.
4156
+ - **One narrow revalidate per write.** After a write, call ONE
4157
+ \`revalidatePath('/the/screen')\` or \`revalidateTag(tag)\` \u2014 the screen
4158
+ that shows the data, not \`'/'\` with \`'layout'\`. **Never call
4159
+ \`router.refresh()\` after it**: the action's response already carries the
4160
+ fresh render, and a refresh renders everything a second time.
4161
+ - **Parallel reads.** Independent awaits go in \`Promise.all([...])\`, never
4162
+ one after another. Start reads that don't need the session in parallel
4163
+ with the session lookup, but check the permission before rendering or
4164
+ returning any data. Never rely on the middleware or the layout as the
4165
+ guard: the middleware only checks that a cookie exists and the layout
4166
+ checks no permission.
4167
+ - **No writes during a GET render.** Pages and layouts only read. Writes go
4168
+ in Server Actions or POST routes; "touch lastSeen on render" style writes
4169
+ make every view slow and break caching.
4170
+ - **\`prefetch={false}\`** on \`<Link>\`s in long row or card lists \u2014 Next
4171
+ otherwise prefetches every visible row's page.
4172
+ - **Aggregate on the server.** \`qb.<table>.groupBy([...]).agg({...})\` or
4173
+ \`.count()\` \u2014 never N \`count()\` calls in a loop, and never "list
4174
+ everything and count in JS".
4175
+ - **\`list()\` returns one page of 100** and hides \`total\`. Pass a \`limit\`,
4176
+ use \`listPage()\` when you need the total, or paginate.
3857
4177
  ${isCustom ? `
3858
4178
  ## Design & home
3859
4179
 
@@ -3886,8 +4206,10 @@ reads the session or data at render (calls \`getServerSession()\` or a
3886
4206
  \`dwk_\`-authenticated data call). Without it, \`next build\` statically
3887
4207
  prerenders the page with NO Dashwise env vars, the SDK throws
3888
4208
  \`missing apiBaseUrl\`, and the platform's first publish fails with an
3889
- opaque \`BUILD_FAILED\`. The scaffolded \`app/page.tsx\` + \`app/dashboard/page.tsx\`
3890
- already have it \u2014 any new session-gated page you add needs it too.
4209
+ opaque \`BUILD_FAILED\`. The scaffolded \`app/layout.tsx\`, \`app/page.tsx\` and
4210
+ \`app/dashboard/page.tsx\` already have it \u2014 any new session-gated page you add
4211
+ needs it too. Required today; becomes optional once the SDK reads cookies
4212
+ before config.
3891
4213
 
3892
4214
  A passing local \`npm run build\` is NOT proof: your \`.env.local\` supplies
3893
4215
  the env the platform build won't have, so it masks the failure. \`GET\` route
@@ -3949,8 +4271,12 @@ what tables it owns.
3949
4271
 
3950
4272
  | Method | Path | Auth | Purpose |
3951
4273
  |---|---|---|---|
3952
- | GET | \`/api/items\` | dwk_ (server-side) | list \`items\` rows via \`db.items.list()\` |
3953
- | POST | \`/api/items\` | dwk_ (server-side) | create an \`items\` row via \`db.items.create()\` |
4274
+ | GET | \`/api/items\` | session + \`items:read\` (\`withPermission\`); data via dwk_ server-side | EXAMPLE \u2014 delete or rename. List \`items\` rows via \`db.items.list({ limit: 50 })\` |
4275
+ | POST | \`/api/items\` | session + \`items:create\` (\`withPermission\`); data via dwk_ server-side | EXAMPLE \u2014 delete or rename. Create an \`items\` row via \`db.items.create()\` |
4276
+
4277
+ Every route and Server Action checks a permission; the middleware only checks
4278
+ that a session cookie exists. List each Server Action here too, with its
4279
+ permission (custom scaffold: \`createItem\` in \`app/actions.ts\`, \`items:create\`).
3954
4280
 
3955
4281
  ## Business rules
3956
4282
 
@@ -4075,6 +4401,17 @@ var init_scaffold = __esm({
4075
4401
  MODULE_SLUG_REGEX = /^[a-z]([a-z0-9-]*[a-z0-9])?$/;
4076
4402
  }
4077
4403
  });
4404
+ function isFreshCloneEntry(name) {
4405
+ return name === ".git" || /^(readme|license|licence)(\..+)?$/i.test(name);
4406
+ }
4407
+ function entryExists(path) {
4408
+ try {
4409
+ lstatSync(path);
4410
+ return true;
4411
+ } catch {
4412
+ return false;
4413
+ }
4414
+ }
4078
4415
  function resolveTargetDir(slug, dirFlag) {
4079
4416
  if (dirFlag !== void 0) return { dir: resolve(dirFlag) };
4080
4417
  const cwd = process.cwd();
@@ -4165,16 +4502,32 @@ async function moduleInitCommand(slugArg, options) {
4165
4502
  );
4166
4503
  return 1;
4167
4504
  }
4505
+ let freshClone = false;
4506
+ let keptReadme = false;
4168
4507
  if (!options.provisionOnly && existsSync(dir)) {
4169
4508
  const entries = readdirSync(dir);
4170
4509
  if (entries.length > 0 && !options.force && !manifestExists) {
4171
- fail(
4172
- `Directory ${code(dir)} is not empty. Pass ${code("--force")} to scaffold anyway (existing files will be overwritten).`
4510
+ const linked = entries.filter(
4511
+ (name2) => isFreshCloneEntry(name2) && lstatSync(join(dir, name2)).isSymbolicLink()
4173
4512
  );
4174
- return 1;
4513
+ if (linked.length > 0) {
4514
+ fail(
4515
+ `Directory ${code(dir)} holds ${linked.map((name2) => code(name2)).join(", ")} as a symbolic link. Replace it with a regular file, or pass ${code("--force")} to scaffold anyway (existing files will be overwritten).`
4516
+ );
4517
+ return 1;
4518
+ }
4519
+ if (entries.every(isFreshCloneEntry)) {
4520
+ freshClone = true;
4521
+ keptReadme = entries.some((name2) => /^readme/i.test(name2));
4522
+ } else {
4523
+ fail(
4524
+ `Directory ${code(dir)} is not empty. Pass ${code("--force")} to scaffold anyway (existing files will be overwritten).`
4525
+ );
4526
+ return 1;
4527
+ }
4175
4528
  }
4176
4529
  }
4177
- const writeMode = options.provisionOnly ? "none" : manifestExists && !options.force ? "missing-only" : "all";
4530
+ const writeMode = options.provisionOnly ? "none" : (manifestExists || freshClone) && !options.force ? "missing-only" : "all";
4178
4531
  if (options.simple && options.custom) {
4179
4532
  fail(
4180
4533
  `Cannot pass both ${code("--simple")} and ${code("--custom")} \u2014 they're mutually exclusive. ${code("--simple")} selects the legacy minimal scaffold; the default (no flag) is the full Next.js scaffold (formerly ${code("--custom")}).`
@@ -4211,8 +4564,8 @@ async function moduleInitCommand(slugArg, options) {
4211
4564
  ["spec.md", specMd(scaffoldOpts)],
4212
4565
  ["agent-log.md", agentLogMd(scaffoldOpts)],
4213
4566
  // Example data-plane route (both flavors) — exercises the generated
4214
- // SDK against the seeded `items` table. Ships for simple + custom
4215
- // since both produce `@dashai/generated`.
4567
+ // SDK against the seeded `items` table, guarded by withPermission.
4568
+ // Ships for simple + custom since both produce `@dashai/generated`.
4216
4569
  ["app/api/items/route.ts", appApiItemsRouteTs()],
4217
4570
  // C12/D5 (Wave 2) — commented-out seed-script template. Pairs with
4218
4571
  // `dashwise run -- node seed.mjs`; carries the publish-packs-
@@ -4228,6 +4581,9 @@ async function moduleInitCommand(slugArg, options) {
4228
4581
  ["app/globals.css", globalsCss()],
4229
4582
  ["lib/utils.ts", libUtilsTs()],
4230
4583
  ["lib/request-origin.ts", libRequestOriginTs()],
4584
+ // Per-request cached getServerSession(): the layout (initialSession)
4585
+ // and the pages share one session fetch instead of two.
4586
+ ["lib/session.ts", libSessionTs()],
4231
4587
  ["components/theme-provider.tsx", componentsThemeProviderTsx()],
4232
4588
  // Embedded-first defaults: follow the Dashwise app's theme, and ship the
4233
4589
  // page frame + the empty/loading/error states that a module needs to
@@ -4244,6 +4600,14 @@ async function moduleInitCommand(slugArg, options) {
4244
4600
  // reset() retry, semantic tokens (which resolve now that
4245
4601
  // globals.css maps them in @theme).
4246
4602
  ["app/error.tsx", appErrorTsx()],
4603
+ // Root loading state: renders ListSkeleton instantly on navigation
4604
+ // while the next page's server render is in flight.
4605
+ ["app/loading.tsx", appLoadingTsx()],
4606
+ // The canonical write path: a permission-guarded Server Action with
4607
+ // one narrow revalidatePath, and its form with a useFormStatus
4608
+ // pending state.
4609
+ ["app/actions.ts", appActionsTs()],
4610
+ ["components/new-item-form.tsx", componentsNewItemFormTsx()],
4247
4611
  ["app/dashboard/page.tsx", appDashboardPageTsx()],
4248
4612
  ["app/sign-in-error/page.tsx", appSignInErrorPageTsx()],
4249
4613
  ["app/api/auth/sign-in/route.ts", appApiAuthSignInTs()],
@@ -4278,7 +4642,7 @@ async function moduleInitCommand(slugArg, options) {
4278
4642
  if (writeMode !== "none") {
4279
4643
  for (const [relPath, content] of files) {
4280
4644
  const absPath = join(dir, relPath);
4281
- if (writeMode === "missing-only" && existsSync(absPath)) {
4645
+ if (writeMode === "missing-only" && (entryExists(absPath) || keptReadme && relPath === "README.md")) {
4282
4646
  skipped++;
4283
4647
  continue;
4284
4648
  }
@@ -4294,6 +4658,10 @@ async function moduleInitCommand(slugArg, options) {
4294
4658
  info(
4295
4659
  `Existing scaffold in ${code(dir)} left untouched (provision-only) \u2014 re-provisioning the backend dev install.`
4296
4660
  );
4661
+ } else if (freshClone) {
4662
+ success(
4663
+ `Scaffolded ${code(slug)} (${kind === "custom" ? "custom Next.js" : "simple module"}) into the clone in ${code(dir)}; ${code(".git")}, README and LICENSE left untouched.`
4664
+ );
4297
4665
  } else if (writeMode === "missing-only") {
4298
4666
  success(
4299
4667
  `Existing module project in ${code(dir)} \u2014 wrote ${written} missing file(s), preserved ${skipped} existing file(s). ${dim("(Pass --force to overwrite everything.)")}`
@@ -5694,7 +6062,7 @@ async function requestDeviceCode(args) {
5694
6062
  );
5695
6063
  }
5696
6064
  async function pollForApproval(args) {
5697
- const sleep4 = args.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
6065
+ const sleep6 = args.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
5698
6066
  const now = args.now ?? (() => Date.now());
5699
6067
  const pollIntervalMs = Math.max(1e3, args.intervalSeconds * 1e3);
5700
6068
  const deadline = now() + args.expiresInSeconds * 1e3;
@@ -5713,7 +6081,7 @@ async function pollForApproval(args) {
5713
6081
  switch (res.status) {
5714
6082
  case "pending":
5715
6083
  case "slow_down":
5716
- await sleep4(pollIntervalMs);
6084
+ await sleep6(pollIntervalMs);
5717
6085
  continue;
5718
6086
  case "denied":
5719
6087
  return { status: "denied" };
@@ -13528,6 +13896,738 @@ async function sleep3(ms, aborted) {
13528
13896
  }
13529
13897
  }
13530
13898
 
13899
+ // src/commands/module/rebuild.ts
13900
+ init_api_client();
13901
+ init_config();
13902
+ init_manifest_io();
13903
+ init_output();
13904
+
13905
+ // src/lib/permission-hint.ts
13906
+ init_api_client();
13907
+ init_output();
13908
+ var EXIT_NEEDS_GRANT = 3;
13909
+ var EXIT_TIMED_OUT = 4;
13910
+ var REBUILD_BUMP_OPERATOR_ONLY = "REBUILD_BUMP_OPERATOR_ONLY";
13911
+ var FEATURE_SUBJECTS = {
13912
+ module_runtime_streaming: "Streaming",
13913
+ module_rebuild: "Rebuild",
13914
+ module_building: "Building modules",
13915
+ module_runtime_sizing: "Memory size"
13916
+ };
13917
+ var OPTION_SUBJECTS = {
13918
+ streaming: "Streaming",
13919
+ memory_mb: "Memory size",
13920
+ reapply: "Re-applying the runtime",
13921
+ timeout: "The timeout",
13922
+ reserved_concurrency: "Reserved concurrency",
13923
+ log_retention: "Log retention"
13924
+ };
13925
+ var MODULE_RIGHT_CODES = /* @__PURE__ */ new Set([
13926
+ "MODULE_CAPABILITY_DENIED",
13927
+ "PUBLISH_REQUIRES_ADMIN",
13928
+ "NOT_COPY_OWNER"
13929
+ ]);
13930
+ function str(v) {
13931
+ return typeof v === "string" && v.length > 0 ? v : void 0;
13932
+ }
13933
+ function num(v) {
13934
+ return typeof v === "number" && Number.isFinite(v) ? v : void 0;
13935
+ }
13936
+ function featureFromPermission(permission) {
13937
+ const m = permission ? /^workspace\.feature\.([a-z0-9_]+)\.use$/.exec(permission) : null;
13938
+ return m?.[1];
13939
+ }
13940
+ function featureSubject(ctx) {
13941
+ const feature = str(ctx.feature) ?? featureFromPermission(str(ctx.permission));
13942
+ if (feature && FEATURE_SUBJECTS[feature]) return FEATURE_SUBJECTS[feature];
13943
+ return str(ctx.feature_label) ?? (feature ? `"${feature}"` : void 0);
13944
+ }
13945
+ function needsAdmin(what, module, ask) {
13946
+ const scope = module ? ` for ${module}` : "";
13947
+ return `${what}${scope} needs a Dashwise admin. Ask your Dashwise admin to ${ask}.`;
13948
+ }
13949
+ function refusalHint(code2, context, subject = {}) {
13950
+ const ctx = context ?? {};
13951
+ const module = subject.module ?? str(ctx.module_slug);
13952
+ const installationId = subject.installationId ?? num(ctx.installation_id);
13953
+ const installLabel = installationId !== void 0 ? ` (install #${installationId})` : "";
13954
+ const named = module ?? "this module";
13955
+ switch (code2) {
13956
+ case "FEATURE_DISABLED":
13957
+ case "PERMISSION_DENIED": {
13958
+ const what = featureSubject(ctx);
13959
+ if (!what) {
13960
+ return {
13961
+ message: `You don't have permission to do this${module ? ` for ${module}` : ""}. Ask your Dashwise admin for access.`,
13962
+ needsGrant: true
13963
+ };
13964
+ }
13965
+ return { message: needsAdmin(what, module, "enable it"), needsGrant: true };
13966
+ }
13967
+ case "RUNTIME_OPTION_OPERATOR_ONLY": {
13968
+ const option = str(ctx.option);
13969
+ const what = (option && OPTION_SUBJECTS[option]) ?? (option ? `"${option}"` : "This runtime option");
13970
+ return { message: needsAdmin(what, module, "change it"), needsGrant: true };
13971
+ }
13972
+ case REBUILD_BUMP_OPERATOR_ONLY: {
13973
+ const bump = str(ctx.bump) ?? "minor";
13974
+ return {
13975
+ message: needsAdmin(`A rebuild with a ${bump} version bump`, module, "run it"),
13976
+ needsGrant: true
13977
+ };
13978
+ }
13979
+ case "IMAGE_TAG_EXISTS":
13980
+ return {
13981
+ message: `This rebuild of ${named} needs a higher version bump than patch. A rebuild with a minor version bump needs a Dashwise admin. Ask your Dashwise admin to run it.`,
13982
+ needsGrant: true
13983
+ };
13984
+ case "RUNTIME_IMAGE_NOT_ELIGIBLE": {
13985
+ const version = str(ctx.version);
13986
+ const target = installationId !== void 0 ? ` --rollout installs:${installationId}` : "";
13987
+ return {
13988
+ message: `${named}${version ? ` v${version}` : ""} was built before streaming support. Rebuild it and upgrade this install first: dashwise module rebuild${module ? ` ${module}` : ""}${target}`,
13989
+ needsGrant: false
13990
+ };
13991
+ }
13992
+ case "RUNTIME_SWAP_PENDING": {
13993
+ const version = str(ctx.version);
13994
+ return {
13995
+ message: `${named}${installLabel} is still finishing its update${version ? ` to v${version}` : ""}, so streaming cannot be turned on yet. Try again in a few minutes.`,
13996
+ needsGrant: false
13997
+ };
13998
+ }
13999
+ case "RUNTIME_OPTION_UNAVAILABLE": {
14000
+ const option = str(ctx.option);
14001
+ const what = (option && OPTION_SUBJECTS[option]) ?? "This runtime option";
14002
+ return { message: `${what} is switched off platform-wide right now.`, needsGrant: false };
14003
+ }
14004
+ case "RUNTIME_CHANGE_IN_PROGRESS": {
14005
+ const op = num(ctx.operation_id);
14006
+ return {
14007
+ message: `A runtime change is already being applied to ${named}${op !== void 0 ? ` (operation #${op})` : ""}. Wait for it, then check: dashwise module runtime${module ? ` ${module}` : ""}`,
14008
+ needsGrant: false
14009
+ };
14010
+ }
14011
+ case "BUILD_IN_PROGRESS": {
14012
+ const buildId = num(ctx.build_id);
14013
+ const status = str(ctx.status);
14014
+ const which = buildId !== void 0 ? ` (#${buildId}${status ? `, ${status}` : ""})` : "";
14015
+ return {
14016
+ message: `A build of ${named} is already running${which}. Wait for it to finish, then run the rebuild again.`,
14017
+ needsGrant: false
14018
+ };
14019
+ }
14020
+ case "REBUILD_SOURCE_NOT_FOUND":
14021
+ return {
14022
+ message: `${named} has no build from source to rebuild. Publish it first: dashwise module publish`,
14023
+ needsGrant: false
14024
+ };
14025
+ case "INSTALLATION_NOT_FOUND":
14026
+ return {
14027
+ message: `${installationId !== void 0 ? `Installation #${installationId}` : "The installation"} was not found, or you cannot see it.`,
14028
+ needsGrant: false
14029
+ };
14030
+ case "PUBLISH_RATE_LIMITED":
14031
+ case "PUBLISH_USER_RATE_LIMITED":
14032
+ return {
14033
+ message: `Too many builds of ${named} in the last hour. Try again later.`,
14034
+ needsGrant: false
14035
+ };
14036
+ default:
14037
+ if (MODULE_RIGHT_CODES.has(code2)) {
14038
+ return {
14039
+ message: `You can't manage ${named}${installLabel}. Ask your Dashwise admin for access.`,
14040
+ needsGrant: true
14041
+ };
14042
+ }
14043
+ return null;
14044
+ }
14045
+ }
14046
+ var ANSI = /\x1b\[[0-9;]*m/g;
14047
+ function reportCliError(message, opts) {
14048
+ if (opts.json) {
14049
+ out(
14050
+ JSON.stringify({
14051
+ error: { code: opts.code ?? "CLI_ERROR", message: message.replace(ANSI, ""), status: null, context: null },
14052
+ hint: null,
14053
+ needs_grant: false
14054
+ })
14055
+ );
14056
+ } else {
14057
+ fail(message);
14058
+ }
14059
+ return 1;
14060
+ }
14061
+ function reportFailedOutcome(failure, hint, opts) {
14062
+ if (opts.json) {
14063
+ out(
14064
+ JSON.stringify({
14065
+ error: { code: failure.code, message: failure.message ?? null, status: null, context: null },
14066
+ hint: hint?.message ?? null,
14067
+ needs_grant: hint?.needsGrant ?? false,
14068
+ ...opts.extra
14069
+ })
14070
+ );
14071
+ } else {
14072
+ fail(hint?.message ?? opts.fallback);
14073
+ }
14074
+ return hint?.needsGrant ? EXIT_NEEDS_GRANT : 1;
14075
+ }
14076
+ function isTransientPollError(err) {
14077
+ if (!(err instanceof ApiError)) return true;
14078
+ return err.status === 0 || err.status === 429 || err.status >= 500;
14079
+ }
14080
+ function reportRefusal(err, subject, opts) {
14081
+ if (!(err instanceof ApiError)) {
14082
+ const message = err?.message ?? String(err);
14083
+ if (opts.json) {
14084
+ out(
14085
+ JSON.stringify({
14086
+ error: { code: "CLI_ERROR", message: message.replace(ANSI, ""), status: null, context: null },
14087
+ hint: null,
14088
+ needs_grant: false
14089
+ })
14090
+ );
14091
+ } else {
14092
+ fail(`${opts.action} failed: ${message}`);
14093
+ }
14094
+ return 1;
14095
+ }
14096
+ const hint = err.status === 401 ? { message: "Your login has expired. Run: dashwise login", needsGrant: false } : refusalHint(err.code, err.context, subject);
14097
+ if (opts.json) {
14098
+ out(
14099
+ JSON.stringify({
14100
+ error: { code: err.code, message: err.message, status: err.status, context: err.context ?? null },
14101
+ hint: hint?.message ?? null,
14102
+ needs_grant: hint?.needsGrant ?? false
14103
+ })
14104
+ );
14105
+ } else if (hint) {
14106
+ fail(hint.message);
14107
+ } else {
14108
+ fail(`${opts.action} failed (${err.code}): ${err.message}`);
14109
+ }
14110
+ return hint?.needsGrant ? EXIT_NEEDS_GRANT : 1;
14111
+ }
14112
+
14113
+ // src/commands/module/rebuild.ts
14114
+ var PLATFORM_DEFAULTS = ["manifest", "on", "off"];
14115
+ var BUMPS = ["patch", "minor", "major"];
14116
+ function parseRollout(raw) {
14117
+ if (raw === void 0) return null;
14118
+ const v = raw.trim();
14119
+ if (v === "" || v === "none") return null;
14120
+ if (v === "workspace") return { scope: "workspace" };
14121
+ const ws = /^workspace:(\d+)$/.exec(v);
14122
+ if (ws) return { scope: "workspace", workspace_id: Number(ws[1]) };
14123
+ const inst = /^installs?:(.+)$/.exec(v);
14124
+ if (inst) {
14125
+ const ids = inst[1].split(",").map((s) => s.trim());
14126
+ if (ids.length === 0 || ids.some((s) => !/^\d+$/.test(s) || Number(s) <= 0)) {
14127
+ return `--rollout installs: expects comma-separated installation ids (got "${raw}").`;
14128
+ }
14129
+ const unique = [...new Set(ids.map(Number))];
14130
+ if (unique.length > 50) return "--rollout installs: takes at most 50 installation ids.";
14131
+ return { scope: "installations", installation_ids: unique };
14132
+ }
14133
+ return `--rollout expects none, installs:<id,id> or workspace[:<id>] (got "${raw}").`;
14134
+ }
14135
+ async function moduleRebuildCommand(slugArg, options) {
14136
+ let slug = slugArg;
14137
+ if (!slug) {
14138
+ try {
14139
+ const fromManifest = readManifest(options.dir)?.module?.slug;
14140
+ if (typeof fromManifest === "string" && fromManifest.length > 0) slug = fromManifest;
14141
+ } catch {
14142
+ }
14143
+ }
14144
+ const json = options.json;
14145
+ const usage2 = (message) => reportCliError(message, { json, code: "USAGE" });
14146
+ if (!slug) {
14147
+ return usage2(
14148
+ `No module slug. Pass one (${code("dashwise module rebuild <slug>")}) or run from a directory with a valid module.json.`
14149
+ );
14150
+ }
14151
+ const bump = (options.bump ?? "patch").trim().toLowerCase();
14152
+ if (!BUMPS.includes(bump)) {
14153
+ return usage2(`--bump expects patch, minor or major (got "${options.bump}").`);
14154
+ }
14155
+ if (bump !== "patch") {
14156
+ const hint = refusalHint(REBUILD_BUMP_OPERATOR_ONLY, { bump }, { module: slug });
14157
+ if (options.json) {
14158
+ out(
14159
+ JSON.stringify({
14160
+ error: { code: REBUILD_BUMP_OPERATOR_ONLY, message: hint.message, status: null, context: { bump } },
14161
+ hint: hint.message,
14162
+ needs_grant: true
14163
+ })
14164
+ );
14165
+ } else {
14166
+ fail(hint.message);
14167
+ publishBumpTip(bump);
14168
+ }
14169
+ return EXIT_NEEDS_GRANT;
14170
+ }
14171
+ let platformDefaults;
14172
+ if (options.platformDefaults !== void 0) {
14173
+ const v = options.platformDefaults.trim().toLowerCase();
14174
+ if (!PLATFORM_DEFAULTS.includes(v)) {
14175
+ return usage2(`--platform-defaults expects manifest, on or off (got "${options.platformDefaults}").`);
14176
+ }
14177
+ platformDefaults = v;
14178
+ }
14179
+ if (options.fromBuild !== void 0 && (!Number.isInteger(options.fromBuild) || options.fromBuild <= 0)) {
14180
+ return usage2(`--from-build expects a positive build id (got "${options.fromBuild}").`);
14181
+ }
14182
+ const rollout = parseRollout(options.rollout);
14183
+ if (typeof rollout === "string") return usage2(rollout);
14184
+ if (!isLoggedIn()) {
14185
+ return reportCliError(`Not logged in. Run ${code("dashwise login")} first.`, { json });
14186
+ }
14187
+ const cfg = readConfig();
14188
+ if (!cfg) {
14189
+ return reportCliError(`CLI config missing. Run ${code("dashwise login")}.`, { json });
14190
+ }
14191
+ const auth = { baseUrl: resolveApiUrl(), auth: { token: cfg.token } };
14192
+ let moduleId;
14193
+ try {
14194
+ const mod = await getModuleBySlug(slug, auth);
14195
+ moduleId = mod.id;
14196
+ slug = mod.slug;
14197
+ } catch (err) {
14198
+ if (err instanceof ApiError && err.status === 404) {
14199
+ return reportCliError(`Module ${code(slug)} not found on the backend.`, { json });
14200
+ }
14201
+ return reportRefusal(err, { module: slug }, { json: options.json, action: "Resolving the module" });
14202
+ }
14203
+ const buildOptions = {
14204
+ ...platformDefaults !== void 0 ? { platform_defaults: platformDefaults } : {},
14205
+ ...options.clean ? { clean: true } : {}
14206
+ };
14207
+ const body = {
14208
+ ...options.fromBuild !== void 0 ? { from_build_id: options.fromBuild } : {},
14209
+ ...Object.keys(buildOptions).length > 0 ? { options: buildOptions } : {},
14210
+ ...rollout ? { rollout } : {},
14211
+ ...options.reason !== void 0 ? { reason: options.reason } : {}
14212
+ };
14213
+ let build;
14214
+ try {
14215
+ build = await rebuildModule(moduleId, body, auth);
14216
+ } catch (err) {
14217
+ return reportRefusal(err, { module: slug }, { json: options.json, action: "Rebuild" });
14218
+ }
14219
+ if (options.noWait) {
14220
+ if (options.json) out(JSON.stringify(build));
14221
+ else {
14222
+ success(`Rebuild of ${code(slug)} queued as build ${code(`#${build.id}`)}.`);
14223
+ info(`Not waiting (${code("--no-wait")}). A rebuild takes about 1\u20133 minutes.`);
14224
+ }
14225
+ return 0;
14226
+ }
14227
+ if (!options.json) info(`Rebuild of ${code(slug)} queued as build ${code(`#${build.id}`)}. Building\u2026`);
14228
+ let waited;
14229
+ try {
14230
+ waited = await waitForBuild(moduleId, build, Boolean(rollout), options, auth);
14231
+ } catch (err) {
14232
+ return reportRefusal(err, { module: slug }, { json: options.json, action: `Checking build #${build.id}` });
14233
+ }
14234
+ const final = waited.build;
14235
+ if (waited.timedOut) {
14236
+ if (options.json) out(JSON.stringify({ ...final, timed_out: true }));
14237
+ else warn(`Still running after ${options.timeoutSec ?? 900}s. Build ${code(`#${build.id}`)} may still finish.`);
14238
+ return EXIT_TIMED_OUT;
14239
+ }
14240
+ if (final.status === "failed") {
14241
+ const failureCode = final.error_code ?? "BUILD_FAILED";
14242
+ const hint = final.error_code ? refusalHint(final.error_code, void 0, { module: slug }) : null;
14243
+ const exit = reportFailedOutcome({ code: failureCode, message: final.error_message }, hint, {
14244
+ json: options.json,
14245
+ fallback: `Build ${code(`#${final.id}`)} failed (${failureCode}): ${final.error_message ?? "(no detail)"}`,
14246
+ extra: { build: final }
14247
+ });
14248
+ if (!options.json) {
14249
+ if (final.log_url) info(`Logs: ${code(final.log_url)}`);
14250
+ if (final.error_code === "IMAGE_TAG_EXISTS") publishBumpTip("minor");
14251
+ }
14252
+ return exit;
14253
+ }
14254
+ const r = final.rollout;
14255
+ if (rollout && r?.status === "failed") {
14256
+ const failureCode = r.code ?? "ROLLOUT_FAILED";
14257
+ const hint = r.code ? refusalHint(r.code, void 0, { module: slug }) : null;
14258
+ if (!options.json) success(`Rebuilt ${code(slug)} as ${code(`v${final.version ?? "?"}`)} ${dim(`(build #${final.id})`)}.`);
14259
+ return reportFailedOutcome({ code: failureCode, message: null }, hint, {
14260
+ json: options.json,
14261
+ fallback: `Upgrading the installs failed (${failureCode}).`,
14262
+ extra: { build: final }
14263
+ });
14264
+ }
14265
+ if (options.json) out(JSON.stringify(final));
14266
+ if (!options.json) success(`Rebuilt ${code(slug)} as ${code(`v${final.version ?? "?"}`)} ${dim(`(build #${final.id})`)}.`);
14267
+ if (!rollout) {
14268
+ if (!options.json) info(`Installs keep their version until upgraded: ${code(`dashwise module install ${slug}`)}`);
14269
+ return 0;
14270
+ }
14271
+ if (!r || r.status !== "completed") {
14272
+ if (!options.json) warn(`The upgrade of installs is still ${r?.status ?? "pending"}. Check with ${code(`dashwise module status ${slug}`)}.`);
14273
+ return 0;
14274
+ }
14275
+ if (!options.json) {
14276
+ const counts = Object.entries(r.counts ?? {}).map(([k, n]) => `${n} ${k}`).join(", ");
14277
+ success(`Installs upgraded to v${r.to_version ?? final.version ?? "?"}: ${counts || "none"}.`);
14278
+ const notUpgraded = (r.counts?.failed ?? 0) + (r.counts?.skipped ?? 0);
14279
+ if (notUpgraded > 0) {
14280
+ warn(
14281
+ `${notUpgraded} install${notUpgraded === 1 ? " was" : "s were"} not upgraded (failed or skipped). Check with ${code(`dashwise module status ${slug}`)}.`
14282
+ );
14283
+ }
14284
+ if ((r.counts?.blocked ?? 0) > 0) {
14285
+ warn(
14286
+ `Blocked installs were left on their version: the upgrade would break other modules or remove data. Upgrade them one by one with ${code(`dashwise module install ${slug}`)} to see why.`
14287
+ );
14288
+ }
14289
+ }
14290
+ return 0;
14291
+ }
14292
+ async function waitForBuild(moduleId, queued, withRollout, options, auth) {
14293
+ const buildId = queued.id;
14294
+ let lastSeen = queued;
14295
+ const deadline = Date.now() + (options.timeoutSec ?? 900) * 1e3;
14296
+ const interval = options.pollIntervalMs ?? 4e3;
14297
+ let last = "";
14298
+ while (Date.now() < deadline) {
14299
+ await sleep4(interval);
14300
+ let b;
14301
+ try {
14302
+ b = await getModuleBuild(moduleId, buildId, auth);
14303
+ } catch (err) {
14304
+ if (isTransientPollError(err)) continue;
14305
+ throw err;
14306
+ }
14307
+ lastSeen = b;
14308
+ const phase = b.status === "succeeded" && withRollout ? `upgrading installs (${b.rollout?.status ?? "pending"})` : b.status;
14309
+ if (!options.json && phase !== last) {
14310
+ info(`Build ${code(`#${buildId}`)}: ${phase}`);
14311
+ last = phase;
14312
+ }
14313
+ if (b.status === "failed") return { build: b, timedOut: false };
14314
+ if (b.status === "succeeded") {
14315
+ if (!withRollout) return { build: b, timedOut: false };
14316
+ if (b.rollout && (b.rollout.status === "completed" || b.rollout.status === "failed")) {
14317
+ return { build: b, timedOut: false };
14318
+ }
14319
+ }
14320
+ }
14321
+ return { build: lastSeen, timedOut: true };
14322
+ }
14323
+ function publishBumpTip(bump) {
14324
+ info(
14325
+ `A rebuild you run yourself always bumps patch. To choose the bump, publish from source: ${code(`dashwise module publish --bump ${bump}`)}`
14326
+ );
14327
+ }
14328
+ function sleep4(ms) {
14329
+ return new Promise((r) => setTimeout(r, ms));
14330
+ }
14331
+
14332
+ // src/commands/module/runtime.ts
14333
+ init_api_client();
14334
+ init_config();
14335
+ init_manifest_io();
14336
+ init_output();
14337
+ var TERMINAL_OPERATION = /* @__PURE__ */ new Set(["succeeded", "failed", "superseded"]);
14338
+ async function moduleRuntimeShowCommand(slugArg, options) {
14339
+ const auth = authOrFail(options.json);
14340
+ if (!auth) return 1;
14341
+ const target = await resolveInstall(slugArg, options, auth, "dashwise module runtime <slug>");
14342
+ if (typeof target === "number") return target;
14343
+ let view;
14344
+ try {
14345
+ view = await getInstallationRuntime(target.installationId, auth);
14346
+ } catch (err) {
14347
+ return reportRefusal(
14348
+ err,
14349
+ { module: target.slug, installationId: target.installationId },
14350
+ { json: options.json, action: "Reading the runtime settings" }
14351
+ );
14352
+ }
14353
+ if (options.json) {
14354
+ out(JSON.stringify(view));
14355
+ return 0;
14356
+ }
14357
+ renderRuntime(view);
14358
+ return 0;
14359
+ }
14360
+ function renderRuntime(view) {
14361
+ const slug = view.module.slug;
14362
+ const desired = view.desired.invoke_mode;
14363
+ const effective = view.effective.invoke_mode;
14364
+ const streamingLine = `${modeLabel(effective)}` + (desired !== null && desired !== effective ? ` ${dim(`(requested: ${modeLabel(desired)})`)}` : "") + (desired === null ? ` ${dim("(platform default)")}` : "");
14365
+ out("");
14366
+ kv("Module:", `${slug} ${dim(`(id ${view.module.id})`)}`);
14367
+ kv("Install:", `#${view.installation_id} ${dim(`(workspace ${view.workspace_id})`)}`);
14368
+ kv("Version:", view.version ?? dim("(none)"));
14369
+ kv("Streaming:", streamingLine);
14370
+ kv(
14371
+ "Memory:",
14372
+ `${view.effective.memory_mb} MB ${dim(view.effective.memory_source === "override" ? "(override)" : "(from module.json machine_size)")}`
14373
+ );
14374
+ kv("Eligible:", view.eligibility.streaming ? "yes" : `no ${dim(`(${eligibilityText(view.eligibility.reason)})`)}`);
14375
+ kv("Granted:", view.grant.module_runtime_streaming ? "yes" : "no");
14376
+ kv("Last change:", view.last_operation ? operationText(view.last_operation) : dim("(none)"));
14377
+ out("");
14378
+ if (!view.grant.module_runtime_streaming) {
14379
+ const hint = refusalHint("FEATURE_DISABLED", { feature: "module_runtime_streaming" }, { module: slug });
14380
+ if (hint) info(hint.message);
14381
+ } else if (!view.eligibility.streaming && view.eligibility.reason === "image_not_eligible") {
14382
+ const hint = refusalHint(
14383
+ "RUNTIME_IMAGE_NOT_ELIGIBLE",
14384
+ { version: view.version ?? void 0 },
14385
+ { module: slug, installationId: view.installation_id }
14386
+ );
14387
+ if (hint) info(hint.message);
14388
+ }
14389
+ }
14390
+ function modeLabel(mode) {
14391
+ if (mode === "RESPONSE_STREAM") return "on";
14392
+ if (mode === "BUFFERED") return "off";
14393
+ return "default";
14394
+ }
14395
+ function eligibilityText(reason) {
14396
+ switch (reason) {
14397
+ case "platform_disabled":
14398
+ return "streaming is switched off platform-wide";
14399
+ case "image_not_eligible":
14400
+ return "this version was built before streaming support; rebuild it";
14401
+ case "swap_pending":
14402
+ return "an update is still finishing";
14403
+ default:
14404
+ return reason ?? "unknown";
14405
+ }
14406
+ }
14407
+ function operationText(op) {
14408
+ const when = op.finished_at ?? op.started_at ?? op.created_at;
14409
+ const err = op.error_code ? ` ${op.error_code}` : "";
14410
+ return `${op.kind} ${op.status}${err} ${dim(`(#${op.id}, ${when})`)}`;
14411
+ }
14412
+ async function moduleRuntimeSetCommand(slugArg, options) {
14413
+ let streaming;
14414
+ if (options.streaming !== void 0) {
14415
+ const v = options.streaming.trim().toLowerCase();
14416
+ if (v === "on") streaming = "RESPONSE_STREAM";
14417
+ else if (v === "off") streaming = "BUFFERED";
14418
+ else {
14419
+ return usage(`--streaming expects "on" or "off" (got "${options.streaming}").`, options.json);
14420
+ }
14421
+ }
14422
+ if (options.memory !== void 0 && (!Number.isInteger(options.memory) || options.memory <= 0)) {
14423
+ return usage(`--memory expects a whole number of MB (got "${options.memory}").`, options.json);
14424
+ }
14425
+ if (streaming === void 0 && options.memory === void 0) {
14426
+ return usage(`Nothing to change. Pass ${code("--streaming on|off")} (or ${code("--memory <mb>")}).`, options.json);
14427
+ }
14428
+ const auth = authOrFail(options.json);
14429
+ if (!auth) return 1;
14430
+ const target = await resolveInstall(slugArg, options, auth, "dashwise module runtime set <slug>");
14431
+ if (typeof target === "number") return target;
14432
+ let before;
14433
+ try {
14434
+ before = await getInstallationRuntime(target.installationId, auth);
14435
+ } catch (err) {
14436
+ return reportRefusal(
14437
+ err,
14438
+ { module: target.slug, installationId: target.installationId },
14439
+ { json: options.json, action: "Reading the runtime settings" }
14440
+ );
14441
+ }
14442
+ const slug = before.module.slug;
14443
+ const subject = { module: slug, installationId: before.installation_id };
14444
+ if (streaming !== void 0 && !options.yes && !options.json && process.stdin.isTTY) {
14445
+ const ok = await confirm({
14446
+ message: `Turn streaming ${streaming === "RESPONSE_STREAM" ? "on" : "off"} for ${slug} (install #${before.installation_id})? The switch takes about a minute, and pages may come back empty while it runs.`
14447
+ });
14448
+ if (isCancel(ok) || ok !== true) {
14449
+ info("Cancelled. Nothing was changed.");
14450
+ return 1;
14451
+ }
14452
+ }
14453
+ const invokeMode = streaming ?? before.desired.invoke_mode ?? before.effective.invoke_mode;
14454
+ let accepted;
14455
+ try {
14456
+ accepted = await setInstallationRuntime(
14457
+ before.installation_id,
14458
+ {
14459
+ invoke_mode: invokeMode,
14460
+ ...options.memory !== void 0 ? { memory_mb: options.memory } : {},
14461
+ ...options.reason !== void 0 ? { reason: options.reason } : {}
14462
+ },
14463
+ auth
14464
+ );
14465
+ } catch (err) {
14466
+ return reportRefusal(err, subject, { json: options.json, action: "Changing the runtime settings" });
14467
+ }
14468
+ const what = describeChange(streaming, options.memory);
14469
+ if (accepted.noop) {
14470
+ if (options.json) out(JSON.stringify({ accepted, operation: before.last_operation }));
14471
+ else success(`${what} for ${code(slug)} is already in place. Nothing to do.`);
14472
+ return 0;
14473
+ }
14474
+ if (options.noWait || accepted.operation_id === null) {
14475
+ if (options.json) out(JSON.stringify({ accepted, operation: null }));
14476
+ else {
14477
+ success(`${what} for ${code(slug)} accepted (operation #${accepted.operation_id ?? "?"}).`);
14478
+ info(`It takes about a minute. Check with ${code(`dashwise module runtime ${slug}`)}.`);
14479
+ }
14480
+ return 0;
14481
+ }
14482
+ if (!options.json) info(`Applying: ${what.toLowerCase()} for ${code(slug)} (about a minute)\u2026`);
14483
+ let op;
14484
+ try {
14485
+ op = await waitForOperation(before.installation_id, accepted.operation_id, options, auth);
14486
+ } catch (err) {
14487
+ return reportRefusal(err, subject, { json: options.json, action: "Checking the runtime change" });
14488
+ }
14489
+ if (!op) {
14490
+ if (options.json) out(JSON.stringify({ accepted, operation: null, timed_out: true }));
14491
+ else {
14492
+ warn(
14493
+ `Still applying after ${options.timeoutSec ?? 240}s. Check with ${code(`dashwise module runtime ${slug}`)}.`
14494
+ );
14495
+ }
14496
+ return EXIT_TIMED_OUT;
14497
+ }
14498
+ if (op.status === "failed") {
14499
+ const failureCode = op.error_code ?? "UNKNOWN";
14500
+ const hint = op.error_code ? refusalHint(op.error_code, void 0, subject) : null;
14501
+ return reportFailedOutcome({ code: failureCode, message: op.error_message }, hint, {
14502
+ json: options.json,
14503
+ fallback: `The change failed (${failureCode}): ${op.error_message ?? "(no detail)"}`,
14504
+ extra: { accepted, operation: op }
14505
+ });
14506
+ }
14507
+ if (options.json) out(JSON.stringify({ accepted, operation: op }));
14508
+ if (op.status === "succeeded") {
14509
+ if (!options.json) {
14510
+ const outcome = typeof op.result?.outcome === "string" ? op.result.outcome : null;
14511
+ if (outcome === "swap_pending") {
14512
+ success(`${what} for ${code(slug)} is saved. Streaming starts once the module's pending update finishes.`);
14513
+ } else if (outcome === "no_runtime") {
14514
+ success(`${what} for ${code(slug)} is saved. It applies when the module is next deployed.`);
14515
+ } else {
14516
+ success(`${what} for ${code(slug)} is done.`);
14517
+ }
14518
+ }
14519
+ return 0;
14520
+ }
14521
+ if (!options.json) warn(`A newer change replaced this one. Check with ${code(`dashwise module runtime ${slug}`)}.`);
14522
+ return 0;
14523
+ }
14524
+ function describeChange(streaming, memory) {
14525
+ const parts = [];
14526
+ if (streaming !== void 0) parts.push(`Streaming ${streaming === "RESPONSE_STREAM" ? "on" : "off"}`);
14527
+ if (memory !== void 0) parts.push(`${parts.length ? "memory" : "Memory"} ${memory} MB`);
14528
+ return parts.join(", ");
14529
+ }
14530
+ async function waitForOperation(installationId, operationId, options, auth) {
14531
+ const deadline = Date.now() + (options.timeoutSec ?? 240) * 1e3;
14532
+ const interval = options.pollIntervalMs ?? 3e3;
14533
+ while (Date.now() < deadline) {
14534
+ await sleep5(interval);
14535
+ let view;
14536
+ try {
14537
+ view = await getInstallationRuntime(installationId, auth);
14538
+ } catch (err) {
14539
+ if (isTransientPollError(err)) continue;
14540
+ throw err;
14541
+ }
14542
+ const op = view.last_operation;
14543
+ if (!op) continue;
14544
+ if (op.id > operationId) {
14545
+ return { id: operationId, status: "superseded", superseded_by_operation_id: op.id };
14546
+ }
14547
+ if (op.id === operationId && TERMINAL_OPERATION.has(op.status)) return op;
14548
+ }
14549
+ return null;
14550
+ }
14551
+ function usage(message, json) {
14552
+ return reportCliError(message, { json, code: "USAGE" });
14553
+ }
14554
+ function authOrFail(json) {
14555
+ if (!isLoggedIn()) {
14556
+ reportCliError(`Not logged in. Run ${code("dashwise login")} first.`, { json });
14557
+ return null;
14558
+ }
14559
+ const cfg = readConfig();
14560
+ if (!cfg) {
14561
+ reportCliError(`CLI config missing. Run ${code("dashwise login")}.`, { json });
14562
+ return null;
14563
+ }
14564
+ return { baseUrl: resolveApiUrl(), auth: { token: cfg.token } };
14565
+ }
14566
+ async function resolveInstall(slugArg, options, auth, usageLine) {
14567
+ const json = options.json;
14568
+ if (options.install !== void 0) {
14569
+ if (!Number.isInteger(options.install) || options.install <= 0) {
14570
+ return usage(`--install expects a positive integer installation id (got "${options.install}").`, options.json);
14571
+ }
14572
+ return { installationId: options.install, ...slugArg ? { slug: slugArg } : {} };
14573
+ }
14574
+ let slug = slugArg;
14575
+ if (!slug) {
14576
+ try {
14577
+ const fromManifest = readManifest(options.dir)?.module?.slug;
14578
+ if (typeof fromManifest === "string" && fromManifest.length > 0) slug = fromManifest;
14579
+ } catch {
14580
+ }
14581
+ }
14582
+ if (!slug) {
14583
+ return reportCliError(
14584
+ `No module slug. Pass one (${code(usageLine)}), run from a directory with a valid module.json, or target an install with ${code("--install <id>")}.`,
14585
+ { json: options.json, code: "USAGE" }
14586
+ );
14587
+ }
14588
+ let modId;
14589
+ try {
14590
+ modId = (await getModuleBySlug(slug, auth)).id;
14591
+ } catch (err) {
14592
+ if (err instanceof ApiError && err.status === 404) {
14593
+ return reportCliError(`Module ${code(slug)} not found on the backend.`, { json });
14594
+ }
14595
+ return reportCliError(`Could not resolve module ${code(slug)}: ${err.message}`, { json });
14596
+ }
14597
+ const cfg = readConfig();
14598
+ let workspace2;
14599
+ try {
14600
+ workspace2 = await resolveWorkspaceWithFallback({
14601
+ userFlag: options.workspace,
14602
+ cachedWorkspaceId: readModuleEntry(slug)?.workspaceId,
14603
+ configDefault: cfg?.workspaceSlug,
14604
+ apiUrl: resolveApiUrl(),
14605
+ apiToken: cfg?.token ?? ""
14606
+ });
14607
+ } catch (err) {
14608
+ return reportCliError(`Workspace selection failed: ${err.message}`, { json });
14609
+ }
14610
+ let row;
14611
+ try {
14612
+ const matches = (await listWorkspaceInstallations(workspace2.id, auth)).filter(
14613
+ (r) => r.module_id === modId
14614
+ );
14615
+ row = matches.find((r) => !isDevInstallKind(r.kind) && isRealRuntimeUrl(r.runtime_url)) ?? matches.find((r) => !isDevInstallKind(r.kind));
14616
+ } catch (err) {
14617
+ return reportCliError(`Could not read workspace installations: ${err.message}`, { json });
14618
+ }
14619
+ if (!row) {
14620
+ return reportCliError(
14621
+ `${code(slug)} has no deployed install in workspace ${code(workspace2.slug)}. Deploy it with ${code(`dashwise module install ${slug} --workspace ${workspace2.slug}`)}, or pass ${code("--install <id>")}.`,
14622
+ { json }
14623
+ );
14624
+ }
14625
+ return { installationId: row.id, slug };
14626
+ }
14627
+ function sleep5(ms) {
14628
+ return new Promise((r) => setTimeout(r, ms));
14629
+ }
14630
+
13531
14631
  // src/program.ts
13532
14632
  init_output();
13533
14633
  var program = new Command();
@@ -14295,6 +15395,67 @@ moduleCmd.command("logs [slug]").description(
14295
15395
  });
14296
15396
  }
14297
15397
  );
15398
+ moduleCmd.command("runtime [slug-or-set] [slug]").usage("[slug] [--install <id>] [--json] | set [slug] --streaming on|off [options]").description(
15399
+ 'Show a deployed install\'s runtime settings: response streaming (on/off, requested vs in effect), memory, whether this version can stream, whether streaming is granted for the workspace, and the last change. `module runtime set --streaming on|off` changes streaming (needs the workspace grant "Module response streaming"; takes about a minute and waits unless --no-wait). `--memory <mb>` is set by Dashwise operators today and is refused with a hint. Exit 3 = a grant is missing (ask your Dashwise admin); exit 4 = `set` stopped waiting before the change finished.'
15400
+ ).option("--install <id>", "Target an installation id directly (skips slug + workspace resolution).", parseIntOption).option("--workspace <slug-or-id>", "Target workspace (default: cached module workspace, config default, or interactive picker).").option("--dir <path>", "Project root for the module.json slug lookup (default: current directory).").option("--streaming <on|off>", "`set` only: turn response streaming on or off.").option("--memory <mb>", "`set` only: memory in MB (operator-only today).", parseIntOption).option("--reason <text>", "`set` only: why; stored in the audit log.").option("-y, --yes", "`set` only: skip the confirmation prompt.").option("--no-wait", "`set` only: return once the change is accepted instead of waiting for it.").option("--timeout <seconds>", "`set` only: max seconds to wait for the change (default 240).", parseIntOption).option(
15401
+ "--json",
15402
+ 'One JSON object on stdout: the runtime view; for `set`, { accepted, operation } (+ "timed_out": true on exit 4); on a refusal or a failed change, { error, hint, needs_grant } (plus accepted and operation when the change ran and failed; error.code USAGE for a bad invocation).'
15403
+ ).action(
15404
+ async (first, second, cmdOpts) => {
15405
+ const target = {
15406
+ ...cmdOpts.install !== void 0 ? { install: cmdOpts.install } : {},
15407
+ ...cmdOpts.workspace !== void 0 ? { workspace: cmdOpts.workspace } : {},
15408
+ ...cmdOpts.dir !== void 0 ? { dir: cmdOpts.dir } : {},
15409
+ ...cmdOpts.json !== void 0 ? { json: cmdOpts.json } : {}
15410
+ };
15411
+ if (first === "set") {
15412
+ process.exitCode = await moduleRuntimeSetCommand(second, {
15413
+ ...target,
15414
+ ...cmdOpts.streaming !== void 0 ? { streaming: cmdOpts.streaming } : {},
15415
+ ...cmdOpts.memory !== void 0 ? { memory: cmdOpts.memory } : {},
15416
+ ...cmdOpts.reason !== void 0 ? { reason: cmdOpts.reason } : {},
15417
+ ...cmdOpts.yes !== void 0 ? { yes: cmdOpts.yes } : {},
15418
+ ...cmdOpts.wait === false ? { noWait: true } : {},
15419
+ ...cmdOpts.timeout !== void 0 ? { timeoutSec: cmdOpts.timeout } : {}
15420
+ });
15421
+ return;
15422
+ }
15423
+ const setOnly = [
15424
+ cmdOpts.streaming !== void 0 && "--streaming",
15425
+ cmdOpts.memory !== void 0 && "--memory",
15426
+ cmdOpts.reason !== void 0 && "--reason"
15427
+ ].filter(Boolean);
15428
+ if (setOnly.length > 0 || second !== void 0) {
15429
+ process.exitCode = reportCliError(
15430
+ setOnly.length > 0 ? `${setOnly.join(", ")} only work with \`set\`, for example: dashwise module runtime set ${first ?? "<slug>"} --streaming on` : `\`dashwise module runtime\` takes one slug (got "${first}" and "${second}"). To change settings: dashwise module runtime set <slug> --streaming on|off`,
15431
+ { json: cmdOpts.json, code: "USAGE" }
15432
+ );
15433
+ return;
15434
+ }
15435
+ process.exitCode = await moduleRuntimeShowCommand(first, target);
15436
+ }
15437
+ );
15438
+ moduleCmd.command("rebuild [slug]").description(
15439
+ "Rebuild the module's published source with today's platform image and defaults, as a new patch version, optionally with build options, then optionally upgrade installs. Needs the workspace grant \"Module rebuild\". Waits for the build (and the upgrades) unless `--no-wait`. Exit 3 = a grant is missing (ask your Dashwise admin); exit 4 = stopped waiting before the build (or the upgrades) finished."
15440
+ ).option("--platform-defaults <mode>", "manifest (default: what module.json says) | on | off.").option("--clean", "Build without the Docker cache and re-pull the base image (same as a normal rebuild until build caching exists).").option("--bump <direction>", "patch (default). minor and major need a Dashwise admin.").option("--rollout <scope>", "none (default) | installs:<id,id> | workspace[:<id>] \u2014 upgrade those installs after the build. Blocked upgrades are reported, never forced.").option("--from-build <id>", "Rebuild the source of this build (default: the build behind the current version).", parseIntOption).option("--reason <text>", "Why; stored in the audit log.").option("--dir <path>", "Project root for the module.json slug lookup (default: current directory).").option("--no-wait", "Return once the build is queued.").option("--timeout <seconds>", "Max seconds to wait for the build and upgrades (default 900).", parseIntOption).option(
15441
+ "--json",
15442
+ 'One JSON object on stdout: the final build (the last one seen + "timed_out": true on exit 4), or { error, hint, needs_grant } on a refusal or a failed build or rollout (plus the build when it ran; error.code USAGE for a bad invocation).'
15443
+ ).action(
15444
+ async (slug, cmdOpts) => {
15445
+ process.exitCode = await moduleRebuildCommand(slug, {
15446
+ ...cmdOpts.platformDefaults !== void 0 ? { platformDefaults: cmdOpts.platformDefaults } : {},
15447
+ ...cmdOpts.clean !== void 0 ? { clean: cmdOpts.clean } : {},
15448
+ ...cmdOpts.bump !== void 0 ? { bump: cmdOpts.bump } : {},
15449
+ ...cmdOpts.rollout !== void 0 ? { rollout: cmdOpts.rollout } : {},
15450
+ ...cmdOpts.fromBuild !== void 0 ? { fromBuild: cmdOpts.fromBuild } : {},
15451
+ ...cmdOpts.reason !== void 0 ? { reason: cmdOpts.reason } : {},
15452
+ ...cmdOpts.dir !== void 0 ? { dir: cmdOpts.dir } : {},
15453
+ ...cmdOpts.wait === false ? { noWait: true } : {},
15454
+ ...cmdOpts.timeout !== void 0 ? { timeoutSec: cmdOpts.timeout } : {},
15455
+ ...cmdOpts.json !== void 0 ? { json: cmdOpts.json } : {}
15456
+ });
15457
+ }
15458
+ );
14298
15459
  moduleCmd.command("destroy-dev-install").description(
14299
15460
  "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)."
14300
15461
  ).option("--dir <path>", "Project root (default: current directory).").option(
@@ -14417,7 +15578,7 @@ fieldCmd.command("set-type <table-slug> <field-slug> <new-type>").description(
14417
15578
  }
14418
15579
  );
14419
15580
  function getVersion() {
14420
- return "0.20.1";
15581
+ return "0.22.0";
14421
15582
  }
14422
15583
  function parseIntOption(value) {
14423
15584
  const n = Number.parseInt(value, 10);