@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/README.md +10 -0
- package/dist/bin.js +1232 -71
- package/dist/bin.js.map +1 -1
- package/package.json +2 -2
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,
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
{/*
|
|
2104
|
-
|
|
2105
|
-
|
|
2106
|
-
|
|
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 {
|
|
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
|
|
2150
|
-
//
|
|
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
|
-
|
|
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
|
-
//
|
|
2163
|
-
//
|
|
2164
|
-
//
|
|
2165
|
-
//
|
|
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
|
-
|
|
2169
|
-
|
|
2170
|
-
<
|
|
2171
|
-
|
|
2172
|
-
|
|
2173
|
-
|
|
2174
|
-
|
|
2175
|
-
|
|
2176
|
-
|
|
2177
|
-
|
|
2178
|
-
|
|
2179
|
-
|
|
2180
|
-
|
|
2181
|
-
|
|
2182
|
-
|
|
2183
|
-
|
|
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 {
|
|
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
|
|
2209
|
-
*
|
|
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
|
|
2219
|
-
// getServerSession() at render, which needs
|
|
2220
|
-
// prerender during \`next build\` runs with no
|
|
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
|
|
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 `//
|
|
2606
|
-
//
|
|
2607
|
-
//
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
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/
|
|
3890
|
-
already have it \u2014 any new session-gated page you add
|
|
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_
|
|
3953
|
-
| POST | \`/api/items\` | dwk_
|
|
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
|
-
|
|
4172
|
-
|
|
4510
|
+
const linked = entries.filter(
|
|
4511
|
+
(name2) => isFreshCloneEntry(name2) && lstatSync(join(dir, name2)).isSymbolicLink()
|
|
4173
4512
|
);
|
|
4174
|
-
|
|
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
|
|
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" &&
|
|
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
|
|
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
|
|
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.
|
|
15581
|
+
return "0.22.0";
|
|
14421
15582
|
}
|
|
14422
15583
|
function parseIntOption(value) {
|
|
14423
15584
|
const n = Number.parseInt(value, 10);
|