@dashai/cli 0.9.0 → 0.11.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
@@ -543,8 +543,11 @@ function manifestJson(opts) {
543
543
  owns: {
544
544
  tables: [
545
545
  {
546
+ // NOTE: no `name` key here — the backend manifest validator
547
+ // (POST /api/installations/dev) is strict and rejects unrecognized
548
+ // table keys, and it derives the display name from the slug. A
549
+ // `name` here would fail provisioning's table materialization (#4).
546
550
  slug: "items",
547
- name: "Items",
548
551
  fields: [
549
552
  { slug: "title", type: "text" },
550
553
  { slug: "done", type: "boolean" }
@@ -719,10 +722,12 @@ function envLocal(apiUrl = SCAFFOLD_DEFAULT_API_URL) {
719
722
  "# There is no DASHWISE_INSTALLATION_ID \u2014 the API key resolves the",
720
723
  "# install server-side.",
721
724
  `DASHWISE_API_URL=${apiUrl}`,
722
- "# Scoped `dwk_` key. Left blank; `dashwise dev` injects the local-dev",
723
- "# key. For a standalone `next dev`, paste one from `dashwise api-keys",
724
- "# create <name>`.",
725
- "DASHWISE_API_KEY=",
725
+ "# Scoped `dwk_` key. Commented out on purpose: `dashwise dev` injects",
726
+ "# the local-dev key from ~/.config/dashwise/auth.json, and an empty",
727
+ "# `DASHWISE_API_KEY=` here would SHADOW that (and make a bare `next dev`",
728
+ "# fail with a confusing empty-key error). For a standalone `next dev`,",
729
+ "# uncomment + paste a key from `dashwise api-keys create <name>`.",
730
+ "# DASHWISE_API_KEY=",
726
731
  ""
727
732
  ].join("\n");
728
733
  }
@@ -924,6 +929,15 @@ export default withAuth({
924
929
  // redirect-loop trying to re-authenticate.
925
930
  publicPaths: ['/', '/about', '/sign-in-error'],
926
931
  loginPath: '/api/auth/sign-in',
932
+ // Silent sign-in: when a signed-in DashWise user opens the module through
933
+ // the platform (in-app embed or the module subdomain), the SSO handshake
934
+ // completes with zero clicks \u2014 they land straight in the app, landing page
935
+ // included. It never touches:
936
+ // - anonymous visitors on public installs (no platform-session hint),
937
+ // - local \`dashwise dev\` (no platform proxy \u2192 no hint header) \u2014 the
938
+ // landing's "Sign in with DashWise" button stays your dev flow,
939
+ // - anything within 60s of a failed silent attempt (no redirect loops).
940
+ autoSignIn: true,
927
941
  });
928
942
 
929
943
  export const config = {
@@ -965,11 +979,13 @@ export default function RootLayout({ children }: { children: React.ReactNode })
965
979
  function appPageCustomTsx(opts) {
966
980
  return `// Public landing page \u2014 listed in middleware.ts publicPaths.
967
981
  //
968
- // First-run experience for unauthenticated visitors: hero with the
969
- // module name + tagline, a "Sign in with DashWise" button that kicks
970
- // off the SSO flow (via /api/auth/sign-in), and a feature card
971
- // describing what the module does. After signing in, the user lands
972
- // on /dashboard (Server Component, session-validated).
982
+ // NOTE: signed-in DashWise users usually never see this page \u2014 the
983
+ // middleware's \`autoSignIn\` signs them in silently and they land on the
984
+ // page they asked for. This landing serves anonymous visitors (public
985
+ // installs), local \`dashwise dev\`, and the rare failed silent attempt \u2014
986
+ // its "Sign in with DashWise" button is the manual fallback that kicks
987
+ // off the same SSO flow (via /api/auth/sign-in). After signing in, the
988
+ // user lands on /dashboard (Server Component, session-validated).
973
989
  //
974
990
  // Styling: shadcn/ui primitives (\`<Button>\`, \`<Card>\`) on Tailwind 4
975
991
  // theme tokens. Add more blocks via \`npx shadcn add @shadcn-studio/...\`
@@ -1185,6 +1201,11 @@ import { getPublicOrigin } from '@/lib/request-origin';
1185
1201
  export async function GET(request: Request) {
1186
1202
  const url = new URL(request.url);
1187
1203
  const returnTo = url.searchParams.get('return_to') ?? '/';
1204
+ // \`auto=1\` marks a SILENT attempt (the middleware's autoSignIn redirect,
1205
+ // no user click). Its failures degrade to the public landing page \u2014 where
1206
+ // the manual "Sign in with DashWise" button lives \u2014 instead of the error
1207
+ // page: the user never asked for anything, so don't show them an error.
1208
+ const isAuto = url.searchParams.get('auto') === '1';
1188
1209
  const apiBaseUrl = process.env.DASHWISE_API_URL;
1189
1210
  const apiKey = process.env.DASHWISE_API_KEY;
1190
1211
 
@@ -1211,6 +1232,16 @@ export async function GET(request: Request) {
1211
1232
  const callback = new URL('/api/auth/callback', moduleOrigin);
1212
1233
  callback.searchParams.set('return_to', returnTo);
1213
1234
 
1235
+ // Where a failed start lands: the error page for user-initiated sign-ins,
1236
+ // the landing page for silent (auto) attempts.
1237
+ const failTarget = (code: string, description?: string): URL => {
1238
+ if (isAuto) return new URL('/', moduleOrigin);
1239
+ const target = new URL('/sign-in-error', moduleOrigin);
1240
+ target.searchParams.set('code', code);
1241
+ if (description) target.searchParams.set('description', description);
1242
+ return target;
1243
+ };
1244
+
1214
1245
  let res: Response;
1215
1246
  try {
1216
1247
  res = await fetch(\`\${apiBaseUrl}/api/module-api/sso/start-url\`, {
@@ -1223,10 +1254,9 @@ export async function GET(request: Request) {
1223
1254
  body: JSON.stringify({ callback_url: callback.toString() }),
1224
1255
  });
1225
1256
  } catch (err) {
1226
- const target = new URL('/sign-in-error', moduleOrigin);
1227
- target.searchParams.set('code', 'server_error');
1228
- target.searchParams.set('description', (err as Error).message);
1229
- return NextResponse.redirect(target);
1257
+ return NextResponse.redirect(
1258
+ failTarget('server_error', (err as Error).message),
1259
+ );
1230
1260
  }
1231
1261
 
1232
1262
  if (!res.ok) {
@@ -1236,23 +1266,18 @@ export async function GET(request: Request) {
1236
1266
  const body = (await res.json().catch(() => null)) as
1237
1267
  | { code?: string; message?: string }
1238
1268
  | null;
1239
- const target = new URL('/sign-in-error', moduleOrigin);
1240
- target.searchParams.set('code', body?.code ?? 'invalid_request');
1241
- if (body?.message) target.searchParams.set('description', body.message);
1242
- return NextResponse.redirect(target);
1269
+ return NextResponse.redirect(
1270
+ failTarget(body?.code ?? 'invalid_request', body?.message),
1271
+ );
1243
1272
  }
1244
1273
 
1245
1274
  const body = (await res.json().catch(() => null)) as
1246
1275
  | { redirect_url?: string }
1247
1276
  | null;
1248
1277
  if (!body?.redirect_url) {
1249
- const target = new URL('/sign-in-error', moduleOrigin);
1250
- target.searchParams.set('code', 'server_error');
1251
- target.searchParams.set(
1252
- 'description',
1253
- 'start-url response missing redirect_url',
1278
+ return NextResponse.redirect(
1279
+ failTarget('server_error', 'start-url response missing redirect_url'),
1254
1280
  );
1255
- return NextResponse.redirect(target);
1256
1281
  }
1257
1282
  return NextResponse.redirect(body.redirect_url);
1258
1283
  }
@@ -1288,7 +1313,13 @@ export async function GET(request: Request) {
1288
1313
  return NextResponse.redirect(target);
1289
1314
  }
1290
1315
  const code = url.searchParams.get('code');
1291
- const returnTo = url.searchParams.get('return_to') ?? '/';
1316
+ // Only ever redirect to a ROOT-RELATIVE, same-module path. \`return_to\`
1317
+ // rides through the SSO round-trip unvalidated (the backend checks only the
1318
+ // callback ORIGIN, not this sub-param), so an absolute (\`https://evil\`),
1319
+ // protocol-relative (\`//evil\`), or control-char-smuggled value would be a
1320
+ // post-auth open redirect off the module host. Reject anything that isn't a
1321
+ // clean root-relative path.
1322
+ const returnTo = safeReturnTo(url.searchParams.get('return_to'));
1292
1323
  if (!code) {
1293
1324
  const target = new URL('/sign-in-error', publicOrigin);
1294
1325
  target.searchParams.set('code', 'missing_code');
@@ -1304,6 +1335,20 @@ export async function GET(request: Request) {
1304
1335
  }
1305
1336
  return NextResponse.redirect(new URL(returnTo, publicOrigin));
1306
1337
  }
1338
+
1339
+ /** Root-relative in-module paths only \u2014 defends against open-redirect via
1340
+ * a crafted \`return_to\`. Rejects absolute/protocol-relative/backslash and
1341
+ * any ASCII control char (which WHATWG URL parsing strips, defeating a naive
1342
+ * prefix check). Falls back to '/'. */
1343
+ function safeReturnTo(raw: string | null): string {
1344
+ if (!raw || raw[0] !== '/') return '/';
1345
+ if (raw.startsWith('//') || raw.startsWith('/\\\\')) return '/';
1346
+ for (let i = 0; i < raw.length; i++) {
1347
+ const c = raw.charCodeAt(i);
1348
+ if (c < 0x20 || c === 0x7f) return '/';
1349
+ }
1350
+ return raw;
1351
+ }
1307
1352
  `;
1308
1353
  }
1309
1354
  function appApiAuthSignOutTs() {
@@ -1919,6 +1964,29 @@ cached in \`~/.config/dashwise/auth.json#modules[${opts.slug}]\` after
1919
1964
  as \`DASHWISE_API_KEY\` into the Next.js child env automatically. The
1920
1965
  key binds the install server-side \u2014 there is no \`DASHWISE_INSTALLATION_ID\`.
1921
1966
 
1967
+ ### Own tables vs. the shared Data Hub
1968
+
1969
+ The dev key \`dashwise init\` mints gives you **full access to your
1970
+ module's OWN tables** (\`owns.tables\` in \`module.json\`) through \`db\` /
1971
+ \`qb\` \u2014 no grant setup needed. That covers the common case.
1972
+
1973
+ The **shared workspace Data Hub** (other tables in the workspace, reached
1974
+ via \`datahub.table(<id>)\`) is separate: it is default-deny and returns
1975
+ **403** with the dev key until you mint a key that carries an explicit
1976
+ Data Hub grant. Do that with:
1977
+
1978
+ \`\`\`
1979
+ # read a specific Data Hub table:
1980
+ dashwise api-keys create datahub-read --grant datahub:table=<tableId>:read
1981
+ # or a whole database (application):
1982
+ dashwise api-keys create datahub-rw --grant datahub:app=<appId>:read,create,update
1983
+ \`\`\`
1984
+
1985
+ Paste the printed key as \`DASHWISE_API_KEY\` (or, for local dev, set it
1986
+ before \`dashwise dev\` so it wins over the auto-injected own-tables key).
1987
+ So: \`db\`/\`qb\` on your own tables works out of the box; \`datahub\` needs a
1988
+ granted key.
1989
+
1922
1990
  ## Auth in dev mode
1923
1991
 
1924
1992
  \`dashwise dev\` registers a dev-mode SSO session with the backend so
@@ -2303,18 +2371,40 @@ async function moduleInitCommand(slugArg, options) {
2303
2371
  let devInstall = null;
2304
2372
  const wantsProvision = options.provision !== false;
2305
2373
  if (wantsProvision) {
2374
+ const manifest = (() => {
2375
+ try {
2376
+ return readManifest(dir) ?? void 0;
2377
+ } catch {
2378
+ return void 0;
2379
+ }
2380
+ })();
2306
2381
  devInstall = await tryProvisionDevInstall({
2307
2382
  slug,
2308
2383
  name,
2309
2384
  description: options.description,
2310
2385
  workspaceFlag: options.workspace,
2311
- moduleKind: kind
2386
+ moduleKind: kind,
2387
+ ...manifest !== void 0 ? { manifest } : {}
2312
2388
  });
2313
2389
  } else {
2314
2390
  info(
2315
2391
  `Skipping provisioning (${code("--no-provision")}). ${dim("Run with provisioning later to enable `dashwise dev` data-plane.")}`
2316
2392
  );
2317
2393
  }
2394
+ const keyCached = !wantsProvision || readModuleEntry(slug)?.apiKey ? true : false;
2395
+ if (wantsProvision && !keyCached) {
2396
+ fail(
2397
+ `NO API KEY WAS CACHED for ${code(slug)} \u2014 the scaffold is written, but ${code("dashwise dev")} will run with NO token (data-plane + sign-in will fail).`
2398
+ );
2399
+ info(
2400
+ `Fix it by re-running provisioning once the cause above is resolved:
2401
+ ${code(`dashwise init ${slug} --force --workspace <your-workspace-slug>`)}
2402
+ Confirm success: the line ${code("\u2713 Provisioned dev install id=\u2026 API key cached")} must appear,
2403
+ and ${code("cat ~/.config/dashwise/auth.json")} must show a non-empty ${code(`modules.${slug}.apiKey`)} starting with ${code("dwk_")}.`
2404
+ );
2405
+ outro("Scaffold written, but provisioning failed \u2014 see above.");
2406
+ return 2;
2407
+ }
2318
2408
  info(`Next steps:`);
2319
2409
  process.stdout.write(`
2320
2410
  ${dim("1.")} ${code(`cd ${dir}`)}
@@ -2343,6 +2433,20 @@ async function tryProvisionDevInstall(args) {
2343
2433
  warn("CLI config disappeared mid-init. Skipping provisioning.");
2344
2434
  return null;
2345
2435
  }
2436
+ const targetApiUrl = resolveApiUrl();
2437
+ info(`Backend: ${code(targetApiUrl)}`);
2438
+ const hostOf = (u) => {
2439
+ try {
2440
+ return new URL(u).host;
2441
+ } catch {
2442
+ return u;
2443
+ }
2444
+ };
2445
+ if (hostOf(targetApiUrl) !== hostOf(cfg.apiUrl)) {
2446
+ warn(
2447
+ `Your saved login is for ${code(cfg.apiUrl)} but this run targets ${code(targetApiUrl)}. Provisioning uses the saved token, which the target backend will reject (401). Log in against the target first: ${code(`dashwise login --api-url ${targetApiUrl}`)}.`
2448
+ );
2449
+ }
2346
2450
  let workspace2;
2347
2451
  try {
2348
2452
  workspace2 = await selectWorkspace({
@@ -2358,25 +2462,42 @@ async function tryProvisionDevInstall(args) {
2358
2462
  info(
2359
2463
  `Provisioning dev install in workspace ${code(workspace2.slug)} (id=${workspace2.id})\u2026`
2360
2464
  );
2465
+ const baseBody = {
2466
+ slug: args.slug,
2467
+ name: args.name,
2468
+ ...args.description !== void 0 ? { description: args.description } : {},
2469
+ workspaceId: workspace2.id,
2470
+ ...args.moduleKind !== void 0 ? { moduleKind: args.moduleKind } : {}
2471
+ };
2472
+ const reqOpts = { baseUrl: resolveApiUrl(), auth: { token: cfg.token } };
2361
2473
  let response;
2362
2474
  try {
2363
2475
  response = await createDevInstallation(
2364
- {
2365
- slug: args.slug,
2366
- name: args.name,
2367
- ...args.description !== void 0 ? { description: args.description } : {},
2368
- workspaceId: workspace2.id,
2369
- ...args.moduleKind !== void 0 ? { moduleKind: args.moduleKind } : {}
2370
- },
2371
- { baseUrl: resolveApiUrl(), auth: { token: cfg.token } }
2476
+ { ...baseBody, ...args.manifest !== void 0 ? { manifest: args.manifest } : {} },
2477
+ reqOpts
2372
2478
  );
2373
2479
  } catch (err) {
2374
- if (err instanceof ApiError) {
2480
+ if (err instanceof ApiError && err.code === "MANIFEST_INVALID" && args.manifest !== void 0) {
2481
+ warn(
2482
+ `Couldn't pre-materialize the seeded table(s) (${err.message}). Provisioning without them; add tables later with ${code("dashwise table create")}.`
2483
+ );
2484
+ try {
2485
+ response = await createDevInstallation(baseBody, reqOpts);
2486
+ } catch (err2) {
2487
+ if (err2 instanceof ApiError) {
2488
+ surfaceProvisionApiError(err2, args.slug);
2489
+ return null;
2490
+ }
2491
+ warn(`Provisioning failed: ${err2.message}`);
2492
+ return null;
2493
+ }
2494
+ } else if (err instanceof ApiError) {
2375
2495
  surfaceProvisionApiError(err, args.slug);
2376
2496
  return null;
2497
+ } else {
2498
+ warn(`Provisioning failed: ${err.message}`);
2499
+ return null;
2377
2500
  }
2378
- warn(`Provisioning failed: ${err.message}`);
2379
- return null;
2380
2501
  }
2381
2502
  try {
2382
2503
  upsertModuleEntry(args.slug, {
@@ -2394,7 +2515,7 @@ async function tryProvisionDevInstall(args) {
2394
2515
  return response;
2395
2516
  }
2396
2517
  success(
2397
- `Provisioned dev install id=${response.installation.id}. API key cached in your CLI config.`
2518
+ `Provisioned dev install id=${response.installation.id}. API key cached in your CLI config.` + (response.tables_applied > 0 ? ` ${response.tables_applied} seeded table(s) materialized.` : "")
2398
2519
  );
2399
2520
  return response;
2400
2521
  }
@@ -2439,6 +2560,7 @@ var init_init = __esm({
2439
2560
  init_output();
2440
2561
  init_api_client();
2441
2562
  init_config();
2563
+ init_manifest_io();
2442
2564
  init_workspaces();
2443
2565
  }
2444
2566
  });
@@ -3353,8 +3475,13 @@ async function devCommand(options) {
3353
3475
  ` ${dim("api key")} ${maskedToken(apiKeyEnv)} ${dim(`(from ${source}${installId ? `, install id=${installId}` : ""})`)}`
3354
3476
  );
3355
3477
  } else if (manifestSlug !== void 0 && installationIdEnv === void 0) {
3356
- warn(
3357
- `No API key cached for module ${code(manifestSlug)}. Data-plane calls (db, qb) will fail with UnauthenticatedError. Run ${code(`dashwise init ${manifestSlug} --force`)} to provision a dev install + cache a key.`
3478
+ fail(
3479
+ `No ${code("dwk_")} API key for module ${code(manifestSlug)} \u2014 the module will run with NO token.`
3480
+ );
3481
+ info(
3482
+ ` Data-plane calls (db, qb) AND sign-in will fail until you provision a dev install:
3483
+ ${code(`dashwise init ${manifestSlug} --force --workspace <your-workspace-slug>`)}
3484
+ (Ensure you're logged into the right backend: ${code(`dashwise login --api-url ${apiUrl}`)}.)`
3358
3485
  );
3359
3486
  }
3360
3487
  info("");
@@ -3528,9 +3655,9 @@ async function resolveModuleApiKey(slug, entry, apiUrl, cliBearer) {
3528
3655
  if (process.env.DASHWISE_API_KEY) return null;
3529
3656
  if (!entry) return null;
3530
3657
  if (entry.apiKey) return null;
3531
- if (!entry.runtimeToken) return null;
3658
+ if (!entry.installationId) return null;
3532
3659
  info(
3533
- `Upgrading cached credentials for ${code(slug)} to a ${code("dwk_")} API key (the local runtime token format was retired)\u2026`
3660
+ `No usable ${code("dwk_")} API key cached for ${code(slug)} \u2014 minting a fresh one against dev install ${entry.installationId} (a stale CLI can cache an empty key)\u2026`
3534
3661
  );
3535
3662
  let minted;
3536
3663
  try {
@@ -8501,6 +8628,12 @@ program.name("dashwise").description("DashWise CLI \u2014 log in, author modules
8501
8628
  "--api-url <url>",
8502
8629
  "Override DashWise backend URL (default: $DASHWISE_API_URL or saved config or http://localhost:3000)"
8503
8630
  );
8631
+ program.hook("preAction", () => {
8632
+ const apiUrl = program.opts().apiUrl?.trim();
8633
+ if (apiUrl) {
8634
+ process.env.DASHWISE_API_URL = apiUrl.replace(/\/+$/, "");
8635
+ }
8636
+ });
8504
8637
  program.command("login").description("Sign in via the device-code flow.").option("--no-prompt", 'Skip the "open browser" prompt; just print the URL.').option("--name <name>", "Suggested name for the minted token (default: dashwise-cli).").action(async (cmdOpts) => {
8505
8638
  const apiUrl = program.opts().apiUrl;
8506
8639
  const exit = await loginCommand({
@@ -9138,7 +9271,7 @@ fieldCmd.command("set-type <table-slug> <field-slug> <new-type>").description(
9138
9271
  }
9139
9272
  })();
9140
9273
  function getVersion() {
9141
- return "0.9.0";
9274
+ return "0.11.0";
9142
9275
  }
9143
9276
  function parseIntOption(value) {
9144
9277
  const n = Number.parseInt(value, 10);