@dashai/cli 0.8.0 → 0.9.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
@@ -256,53 +256,185 @@ async function revokeApiKey(installationId, keyId, options = {}) {
256
256
  options
257
257
  );
258
258
  }
259
- async function createTable(installationId, body, options = {}) {
259
+ async function getOrigins(installationId, options = {}) {
260
+ return apiRequest(
261
+ "GET",
262
+ `/api/installations/${installationId}/origins`,
263
+ void 0,
264
+ options
265
+ );
266
+ }
267
+ async function setOrigins(installationId, allowedOrigins, options = {}) {
268
+ return apiRequest(
269
+ "PUT",
270
+ `/api/installations/${installationId}/origins`,
271
+ { allowed_origins: allowedOrigins },
272
+ options
273
+ );
274
+ }
275
+ async function createTable(body, options = {}) {
260
276
  return apiRequest(
261
277
  "POST",
262
- `/api/installations/${installationId}/schema/tables`,
278
+ `/api/module-api/schema/tables`,
263
279
  body,
264
280
  options
265
281
  );
266
282
  }
267
- async function dropTable(installationId, tableSlug, options = {}) {
283
+ async function dropTable(tableSlug, options = {}) {
268
284
  return apiRequest(
269
285
  "DELETE",
270
- `/api/installations/${installationId}/schema/tables/${tableSlug}`,
286
+ `/api/module-api/schema/tables/${tableSlug}`,
271
287
  void 0,
272
288
  options
273
289
  );
274
290
  }
275
- async function addField(installationId, body, options = {}) {
291
+ async function addField(body, options = {}) {
276
292
  return apiRequest(
277
293
  "POST",
278
- `/api/installations/${installationId}/schema/fields`,
294
+ `/api/module-api/schema/fields`,
279
295
  body,
280
296
  options
281
297
  );
282
298
  }
283
- async function updateField(installationId, tableSlug, fieldSlug, body, options = {}) {
299
+ async function updateField(tableSlug, fieldSlug, body, options = {}) {
284
300
  return apiRequest(
285
301
  "PATCH",
286
- `/api/installations/${installationId}/schema/fields/${tableSlug}/${fieldSlug}`,
302
+ `/api/module-api/schema/fields/${tableSlug}/${fieldSlug}`,
287
303
  body,
288
304
  options
289
305
  );
290
306
  }
291
- async function getInstallationSchema(installationId, options = {}) {
307
+ async function getInstallationSchema(options = {}) {
292
308
  return apiRequest(
293
309
  "GET",
294
- `/api/installations/${installationId}/schema`,
310
+ `/api/module-api/schema`,
295
311
  void 0,
296
312
  options
297
313
  );
298
314
  }
299
- async function removeField(installationId, tableSlug, fieldSlug, options = {}) {
315
+ async function removeField(tableSlug, fieldSlug, options = {}) {
300
316
  return apiRequest(
301
317
  "DELETE",
302
- `/api/installations/${installationId}/schema/fields/${tableSlug}/${fieldSlug}`,
318
+ `/api/module-api/schema/fields/${tableSlug}/${fieldSlug}`,
319
+ void 0,
320
+ options
321
+ );
322
+ }
323
+ async function getInstallationDependencies(installationId, options = {}) {
324
+ return apiRequest(
325
+ "GET",
326
+ `/api/installations/${installationId}/dependencies`,
327
+ void 0,
328
+ options
329
+ );
330
+ }
331
+ async function bindDependency(installationId, providerSlug, body = {}, options = {}) {
332
+ return apiRequest(
333
+ "POST",
334
+ `/api/installations/${installationId}/dependencies/${providerSlug}/bind`,
335
+ body,
336
+ options
337
+ );
338
+ }
339
+ async function unbindDependency(installationId, providerSlug, options = {}) {
340
+ return apiRequest(
341
+ "POST",
342
+ `/api/installations/${installationId}/dependencies/${providerSlug}/unbind`,
343
+ void 0,
344
+ options
345
+ );
346
+ }
347
+ async function getInstallationBindings(installationId, options = {}) {
348
+ return apiRequest(
349
+ "GET",
350
+ `/api/installations/${installationId}/bindings`,
351
+ void 0,
352
+ options
353
+ );
354
+ }
355
+ async function getBindingCandidates(installationId, usesSlug, options = {}) {
356
+ return apiRequest(
357
+ "GET",
358
+ `/api/installations/${installationId}/bindings/${encodeURIComponent(usesSlug)}/candidates`,
359
+ void 0,
360
+ options
361
+ );
362
+ }
363
+ async function putBinding(installationId, usesSlug, body, options = {}) {
364
+ return apiRequest(
365
+ "PUT",
366
+ `/api/installations/${installationId}/bindings/${encodeURIComponent(usesSlug)}`,
367
+ body,
368
+ options
369
+ );
370
+ }
371
+ async function unbindBinding(installationId, usesSlug, options = {}) {
372
+ return apiRequest(
373
+ "POST",
374
+ `/api/installations/${installationId}/bindings/${encodeURIComponent(usesSlug)}/unbind`,
375
+ void 0,
376
+ options
377
+ );
378
+ }
379
+ async function declineBinding(installationId, usesSlug, options = {}) {
380
+ return apiRequest(
381
+ "POST",
382
+ `/api/installations/${installationId}/bindings/${encodeURIComponent(usesSlug)}/decline`,
383
+ void 0,
384
+ options
385
+ );
386
+ }
387
+ function slugifyFieldName(name) {
388
+ return name.trim().toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
389
+ }
390
+ async function introspectWorkspaceTable(_installationId, tableNameOrId, options = {}) {
391
+ const ref = String(tableNameOrId).trim();
392
+ const asNumber = Number(ref);
393
+ if (!Number.isInteger(asNumber) || asNumber <= 0 || String(asNumber) !== ref) {
394
+ const message = `"${ref}" is not a numeric table id. Pass the workspace table's numeric id (find it in the Dashwise UI URL, or via \`GET /api/tables/application/:applicationId\`). Resolving a table by name is not yet supported by this command.`;
395
+ throw new ApiError(0, { code: "NON_NUMERIC_TABLE_REF", message }, message);
396
+ }
397
+ const table = await apiRequest(
398
+ "GET",
399
+ `/api/tables/${asNumber}`,
303
400
  void 0,
304
401
  options
305
402
  );
403
+ const rawFields = await apiRequest(
404
+ "GET",
405
+ `/api/fields/table/${asNumber}`,
406
+ void 0,
407
+ options
408
+ );
409
+ const fields = rawFields.map((f) => ({
410
+ id: f.id,
411
+ slug: slugifyFieldName(f.name),
412
+ name: f.name,
413
+ type: f.type,
414
+ ...f.is_required !== void 0 ? { required: f.is_required } : {}
415
+ }));
416
+ return {
417
+ table_id: table.id,
418
+ name: table.name,
419
+ slug: slugifyFieldName(table.name),
420
+ fields
421
+ };
422
+ }
423
+ async function getModuleBySlug(slug, options = {}) {
424
+ return apiRequest(
425
+ "GET",
426
+ `/api/modules/by-slug/${encodeURIComponent(slug)}`,
427
+ void 0,
428
+ options
429
+ );
430
+ }
431
+ async function applyDraftManifest(moduleId, manifest, options = {}) {
432
+ return apiRequest(
433
+ "PUT",
434
+ `/api/modules/${moduleId}/draft/manifest`,
435
+ manifest,
436
+ options
437
+ );
306
438
  }
307
439
  var ApiError;
308
440
  var init_api_client = __esm({
@@ -326,38 +458,6 @@ var init_api_client = __esm({
326
458
  };
327
459
  }
328
460
  });
329
- function manifestPath(dir) {
330
- return join(resolve(dir ?? process.cwd()), "module.json");
331
- }
332
- function readManifest(dir) {
333
- const path = manifestPath(dir);
334
- if (!existsSync(path)) return null;
335
- const raw = readFileSync(path, "utf-8");
336
- try {
337
- return JSON.parse(raw);
338
- } catch (err) {
339
- throw new Error(
340
- `Failed to parse ${path}: ${err.message}. Fix or delete the file and re-run.`
341
- );
342
- }
343
- }
344
- function writeManifest(manifest, dir) {
345
- const path = manifestPath(dir);
346
- writeFileSync(path, JSON.stringify(manifest, null, 2) + "\n", "utf-8");
347
- }
348
- function readManifestOrThrow(dir) {
349
- const manifest = readManifest(dir);
350
- if (!manifest) {
351
- throw new Error(
352
- `No module.json found in ${resolve(dir ?? process.cwd())}. Run \`dashwise module init\` first or pass \`--dir <path>\`.`
353
- );
354
- }
355
- return manifest;
356
- }
357
- var init_manifest_io = __esm({
358
- "src/lib/manifest-io.ts"() {
359
- }
360
- });
361
461
  function success(msg) {
362
462
  process.stderr.write(`${pc.green("\u2713")} ${msg}
363
463
  `);
@@ -393,6 +493,38 @@ var init_output = __esm({
393
493
  "src/lib/output.ts"() {
394
494
  }
395
495
  });
496
+ function manifestPath(dir) {
497
+ return join(resolve(dir ?? process.cwd()), "module.json");
498
+ }
499
+ function readManifest(dir) {
500
+ const path = manifestPath(dir);
501
+ if (!existsSync(path)) return null;
502
+ const raw = readFileSync(path, "utf-8");
503
+ try {
504
+ return JSON.parse(raw);
505
+ } catch (err) {
506
+ throw new Error(
507
+ `Failed to parse ${path}: ${err.message}. Fix or delete the file and re-run.`
508
+ );
509
+ }
510
+ }
511
+ function writeManifest(manifest, dir) {
512
+ const path = manifestPath(dir);
513
+ writeFileSync(path, JSON.stringify(manifest, null, 2) + "\n", "utf-8");
514
+ }
515
+ function readManifestOrThrow(dir) {
516
+ const manifest = readManifest(dir);
517
+ if (!manifest) {
518
+ throw new Error(
519
+ `No module.json found in ${resolve(dir ?? process.cwd())}. Run \`dashwise module init\` first or pass \`--dir <path>\`.`
520
+ );
521
+ }
522
+ return manifest;
523
+ }
524
+ var init_manifest_io = __esm({
525
+ "src/lib/manifest-io.ts"() {
526
+ }
527
+ });
396
528
 
397
529
  // src/lib/scaffold.ts
398
530
  function isDeployTarget(value) {
@@ -409,7 +541,16 @@ function manifestJson(opts) {
409
541
  },
410
542
  module_kind: kind,
411
543
  owns: {
412
- tables: []
544
+ tables: [
545
+ {
546
+ slug: "items",
547
+ name: "Items",
548
+ fields: [
549
+ { slug: "title", type: "text" },
550
+ { slug: "done", type: "boolean" }
551
+ ]
552
+ }
553
+ ]
413
554
  }
414
555
  };
415
556
  if (kind === "custom") {
@@ -440,7 +581,7 @@ function packageJson(opts) {
440
581
  start: "next start"
441
582
  },
442
583
  dependencies: {
443
- "@dashai/sdk": "^0.8.0",
584
+ "@dashai/sdk": "^2.0.0",
444
585
  next: "^15.0.0",
445
586
  react: "^19.0.0",
446
587
  "react-dom": "^19.0.0"
@@ -485,9 +626,27 @@ function tsconfigJson() {
485
626
  }
486
627
  function nextConfig() {
487
628
  return `/** @type {import("next").NextConfig} */
629
+ //
630
+ // Phase 2 two-var contract: the deploy env supplies exactly
631
+ // DASHWISE_API_URL + DASHWISE_API_KEY. The browser needs the API URL
632
+ // too (the SDK's <AuthProvider> calls /api/auth/sessions/me on it), so
633
+ // we DERIVE NEXT_PUBLIC_DASHWISE_API_URL here from DASHWISE_API_URL at
634
+ // build/start time \u2014 you never set the NEXT_PUBLIC_ var yourself. If
635
+ // you already have one set (e.g. a custom setup), it wins.
636
+ const apiUrl = process.env.DASHWISE_API_URL;
637
+
488
638
  const nextConfig = {
639
+ // Required for the platform-hosted (per-install Lambda) runtime: the
640
+ // injected wrapper probes [.next/standalone/server.js, server.js] and
641
+ // exit(1)s the cold start if neither exists. Without this the built
642
+ // Lambda FATALs. Harmless for local \`next dev\` / \`next start\`.
643
+ output: 'standalone',
489
644
  typescript: { ignoreBuildErrors: true },
490
645
  eslint: { ignoreDuringBuilds: true },
646
+ env: {
647
+ NEXT_PUBLIC_DASHWISE_API_URL:
648
+ process.env.NEXT_PUBLIC_DASHWISE_API_URL ?? apiUrl,
649
+ },
491
650
  };
492
651
  export default nextConfig;
493
652
  `;
@@ -531,16 +690,18 @@ function gitignore() {
531
690
  function envLocalExample(apiUrl = SCAFFOLD_DEFAULT_API_URL) {
532
691
  return [
533
692
  "# Copy to .env.local + fill in real values.",
534
- "# These vars are auto-injected by `dashwise dev` (the unified dev loop)",
535
- "# from ~/.config/dashwise/auth.json \u2014 you only need to edit this file if",
536
- "# you want to point at a non-default backend or run `next dev` standalone.",
693
+ "# Phase 2 two-var contract: exactly these two vars. `dashwise dev`",
694
+ "# auto-injects both from ~/.config/dashwise/auth.json \u2014 you only need",
695
+ "# to edit this file if you point at a non-default backend or run",
696
+ "# `next dev` standalone.",
697
+ "#",
698
+ "# The browser needs the API URL too; next.config derives",
699
+ "# NEXT_PUBLIC_DASHWISE_API_URL from DASHWISE_API_URL, so you never set",
700
+ "# it yourself.",
537
701
  `DASHWISE_API_URL=${apiUrl}`,
538
- "# Client-side mirror \u2014 Next.js inlines NEXT_PUBLIC_* into the browser",
539
- "# bundle so the SDK's <AuthProvider> can hit `/api/auth/sessions/me`",
540
- "# on the right origin. Should match DASHWISE_API_URL above.",
541
- `NEXT_PUBLIC_DASHWISE_API_URL=${apiUrl}`,
542
- "DASHWISE_API_TOKEN=",
543
- "DASHWISE_INSTALLATION_ID=local",
702
+ "# Scoped `dwk_` API key \u2014 mint with `dashwise api-keys create <name>`",
703
+ "# for deploys; `dashwise dev` injects the local-dev key automatically.",
704
+ "DASHWISE_API_KEY=",
544
705
  ""
545
706
  ].join("\n");
546
707
  }
@@ -548,24 +709,20 @@ function envLocal(apiUrl = SCAFFOLD_DEFAULT_API_URL) {
548
709
  return [
549
710
  "# Local dev config \u2014 auto-generated by `dashwise module init`.",
550
711
  `# Backend URL pinned to your logged-in CLI config (${apiUrl}). Gitignored;`,
551
- "# safe to edit. `dashwise dev` (the unified loop) auto-injects",
552
- "# DASHWISE_API_TOKEN from ~/.config/dashwise/auth.json AND",
553
- "# DASHWISE_DEV_SESSION_ID from the dev session it registers \u2014 so",
554
- "# you typically don't need to set either here. Change DASHWISE_API_URL",
555
- "# (and NEXT_PUBLIC_DASHWISE_API_URL to match) to point at a different",
556
- "# backend.",
712
+ "# safe to edit. `dashwise dev` (the unified loop) auto-injects the",
713
+ "# Phase 2 two-var contract \u2014 DASHWISE_API_URL + the per-module `dwk_`",
714
+ "# DASHWISE_API_KEY \u2014 from ~/.config/dashwise/auth.json, so you",
715
+ "# typically don't need to set either here.",
557
716
  "#",
558
- "# DASHWISE_INSTALLATION_ID is only needed AFTER you publish + install",
559
- "# the module in a workspace; until then dev mode (DASHWISE_DEV_SESSION_ID,",
560
- "# auto-set by `dashwise dev`) is the auth path.",
717
+ "# The browser needs the API URL too; next.config derives",
718
+ "# NEXT_PUBLIC_DASHWISE_API_URL from DASHWISE_API_URL automatically.",
719
+ "# There is no DASHWISE_INSTALLATION_ID \u2014 the API key resolves the",
720
+ "# install server-side.",
561
721
  `DASHWISE_API_URL=${apiUrl}`,
562
- "# Client-side mirror (must match DASHWISE_API_URL). Next.js inlines",
563
- "# NEXT_PUBLIC_* into the browser bundle so the SDK's AuthProvider can",
564
- "# call /api/auth/sessions/me on the right origin instead of bouncing",
565
- "# off the local module server with a 404.",
566
- `NEXT_PUBLIC_DASHWISE_API_URL=${apiUrl}`,
567
- "DASHWISE_API_TOKEN=",
568
- "DASHWISE_INSTALLATION_ID=",
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=",
569
726
  ""
570
727
  ].join("\n");
571
728
  }
@@ -687,7 +844,7 @@ npm run dev:next # next dev on :3000 (terminal 2)
687
844
  - **\`app/page.tsx\`** \u2014 public landing (allowlisted in \`middleware.ts\`).
688
845
  - **\`app/dashboard/page.tsx\`** \u2014 Server Component requiring a valid session via \`getServerSession()\`.
689
846
  - **\`app/api/auth/callback/route.ts\`** \u2014 exchanges the SSO one-time code for a session cookie (uses \`exchangeCode()\` from the SDK).
690
- - **\`app/api/auth/sign-in/route.ts\`** \u2014 entry point that redirects to the backend's \`/sso/start\` with the right \`return_to\` + \`client_id\`.
847
+ - **\`app/api/auth/sign-in/route.ts\`** \u2014 one entry point that calls the backend's \`POST /api/module-api/sso/start-url\` (authenticated with your \`dwk_\` key) and 302s the browser to the returned \`redirect_url\`. Same path in dev + prod.
691
848
  - **\`app/api/auth/sign-out/route.ts\`** \u2014 clears the session cookie + tells the backend to revoke.
692
849
 
693
850
  ## Imports
@@ -736,25 +893,23 @@ the canonical block-discovery flow.
736
893
 
737
894
  ## Deploying elsewhere (custom domain)
738
895
 
739
- The scaffolded SSO flow is wired for DashWise's own infrastructure
740
- (\`m-<id>.dashwise.net\`). If you want to host this module on your own
741
- domain (e.g. \`mymodule.example.com\`), there are two options:
742
-
743
- 1. **Wait for custom-domain support (in design).** A future \`--callback-origin\`
744
- flag on \`dashwise module init\` plus a \`module_oauth_clients\` allowlist will
745
- let modules run at arbitrary domains while still using DashWise SSO. Tracked
746
- in [\`modules-author-experience-v1.md\`](https://github.com/sharmaintuize/dashwise-devops-docs/blob/main/plans/modules-author-experience-v1.md) \xA73.
747
- 2. **Bypass SSO + use a Workspace API Token today.** Replace the SSO middleware
748
- with a token-check middleware that reads \`Authorization: Token <key>\` from
749
- each request. Tokens are created at \`<dashwise-dashboard>/settings/tokens\`
750
- and scoped to a workspace. The SDK accepts these via the standard
751
- \`DASHWISE_API_TOKEN\` env var. You'll lose the per-user session model
752
- (every request runs as the token's owner) but gain the freedom to deploy
753
- anywhere.
754
-
755
- The CLI's \`dashwise dev\` command auto-injects the developer's CLI token (also
756
- a bearer token) into local dev \u2014 so local development works regardless of
757
- which path you pick for production.
896
+ The scaffolded SSO flow works on any domain. The \`/api/auth/sign-in\`
897
+ route sends its own public origin as the \`callback_url\` to the backend;
898
+ the backend accepts it as long as the origin is on your installation's
899
+ allowlist. Local dev (\`http://localhost:<port>\`) is allowlisted
900
+ automatically for \`kind='local'\` installs.
901
+
902
+ For a deployed custom domain (e.g. \`https://mymodule.example.com\`), add
903
+ it to the allowlist:
904
+
905
+ \`\`\`bash
906
+ dashwise origins add https://mymodule.example.com
907
+ dashwise origins list # confirm
908
+ \`\`\`
909
+
910
+ That's it \u2014 no middleware changes, no separate token scheme. The single
911
+ \`dwk_\` \`DASHWISE_API_KEY\` drives both the data plane and the SSO
912
+ start-url call.
758
913
  `;
759
914
  }
760
915
  function middlewareTs() {
@@ -796,7 +951,10 @@ export default function RootLayout({ children }: { children: React.ReactNode })
796
951
  enableSystem
797
952
  disableTransitionOnChange
798
953
  >
799
- <AuthProvider>{children}</AuthProvider>
954
+ {/* sessionEndpoint points at the same-origin /api/auth/session
955
+ route so the host-scoped session cookie is actually sent \u2014
956
+ a cross-origin fetch to the API would drop it (D8). */}
957
+ <AuthProvider sessionEndpoint="/api/auth/session">{children}</AuthProvider>
800
958
  </ThemeProvider>
801
959
  </body>
802
960
  </html>
@@ -1001,73 +1159,102 @@ function Row({ label, value }: { label: string; value: string }) {
1001
1159
  `;
1002
1160
  }
1003
1161
  function appApiAuthSignInTs() {
1004
- return `// /api/auth/sign-in \u2014 kick off the SSO flow.
1005
- //
1006
- // Reads \`?return_to=\` from the caller (defaults to '/'). Branches
1007
- // on env:
1162
+ return `// /api/auth/sign-in \u2014 kick off the SSO flow (one path, dev + prod).
1008
1163
  //
1009
- // - DASHWISE_DEV_SESSION_ID set (set automatically by
1010
- // \`dashwise dev\`): hits /api/auth/dev/sso/start with the dev
1011
- // session id. No installation_id required \u2014 the module isn't
1012
- // installed in a workspace yet.
1164
+ // Reads \`?return_to=\` from the caller (defaults to '/'), then asks the
1165
+ // DashWise backend for a redirect URL by calling the module-API
1166
+ // server-side start endpoint:
1013
1167
  //
1014
- // - DASHWISE_INSTALLATION_ID set (production / installed-module
1015
- // path): hits /api/auth/sso/start with the installation id.
1168
+ // POST /api/module-api/sso/start-url
1169
+ // Authorization: Bearer <DASHWISE_API_KEY> (the module's dwk_ key)
1170
+ // { "callback_url": "<this module>/api/auth/callback?return_to=..." }
1016
1171
  //
1017
- // In both cases the backend mints an exchange code + 302s back to
1018
- // \`/api/auth/callback?code=...\` where we set the session cookie.
1172
+ // The backend resolves the install from the key (no installation id in
1173
+ // the request), validates the callback_url origin against the install's
1174
+ // allowlist (kind='local' installs implicitly allow localhost), and
1175
+ // returns { redirect_url, expires_in_seconds }. We 302 the browser to
1176
+ // redirect_url, which runs the existing main-app SSO flow and ends in a
1177
+ // 302 back to callback_url?code=<one-time>. The /api/auth/callback route
1178
+ // exchanges that code for a session cookie.
1019
1179
  //
1020
- // Unauthenticated browser hits to either backend endpoint 302 to
1021
- // the DashWise FE \`/login?return_to=...\` automatically \u2014 you don't
1022
- // need to handle the "user not logged in" case here.
1180
+ // No dev-vs-prod branching, no installation id, no dev-session id \u2014 the
1181
+ // single dwk_ key drives both local dev and production.
1023
1182
  import { NextResponse } from 'next/server';
1024
1183
  import { getPublicOrigin } from '@/lib/request-origin';
1025
1184
 
1026
- export function GET(request: Request) {
1185
+ export async function GET(request: Request) {
1027
1186
  const url = new URL(request.url);
1028
1187
  const returnTo = url.searchParams.get('return_to') ?? '/';
1029
1188
  const apiBaseUrl = process.env.DASHWISE_API_URL;
1030
- const installationId = process.env.DASHWISE_INSTALLATION_ID;
1031
- const devSessionId = process.env.DASHWISE_DEV_SESSION_ID;
1189
+ const apiKey = process.env.DASHWISE_API_KEY;
1032
1190
 
1033
1191
  if (!apiBaseUrl) {
1034
- return new NextResponse(
1035
- 'DASHWISE_API_URL must be set in .env.local',
1036
- { status: 500 },
1037
- );
1192
+ return new NextResponse('DASHWISE_API_URL must be set in the environment', {
1193
+ status: 500,
1194
+ });
1038
1195
  }
1039
- if (!installationId && !devSessionId) {
1196
+ if (!apiKey) {
1040
1197
  return new NextResponse(
1041
- 'Either DASHWISE_INSTALLATION_ID (installed module) or DASHWISE_DEV_SESSION_ID ' +
1042
- '(local dev via \`dashwise dev\`) must be set. Run \`dashwise dev\` to register ' +
1043
- 'a dev session, or set DASHWISE_INSTALLATION_ID after publishing your module.',
1198
+ 'DASHWISE_API_KEY must be set in the environment. Run \`dashwise dev\` ' +
1199
+ '(local) or paste a key from \`dashwise api-keys create\` (deployed).',
1044
1200
  { status: 500 },
1045
1201
  );
1046
1202
  }
1047
1203
 
1048
- // Absolute URL for return_to so the backend knows which host to
1049
- // bounce back to (the callback path is fixed at /api/auth/callback
1050
- // by SDK convention). \`getPublicOrigin\` reads \`X-Forwarded-Host\`
1051
- // when present so the redirect URL points at the public domain,
1052
- // not the internal Node origin behind reverse proxies.
1204
+ // Absolute callback URL so the backend knows which host to bounce back
1205
+ // to. \`getPublicOrigin\` reads \`X-Forwarded-Host\` when present so the
1206
+ // URL points at the public domain, not the internal Node origin behind
1207
+ // reverse proxies (Railway/Vercel/Fly). The callback path is fixed at
1208
+ // /api/auth/callback by SDK convention; we tuck \`return_to\` onto it so
1209
+ // the callback route can resume the user's original destination.
1053
1210
  const moduleOrigin = getPublicOrigin(request);
1054
- const absoluteReturnTo = \`\${moduleOrigin}\${returnTo}\`;
1055
-
1056
- let ssoStart;
1057
- if (devSessionId) {
1058
- // Dev mode wins when both are set \u2014 \`dashwise dev\` injects
1059
- // DASHWISE_DEV_SESSION_ID into the child env, which overrides
1060
- // whatever was in .env.local. The dev session id keys back to
1061
- // the dev's workspace + callback origin allowlist.
1062
- ssoStart = new URL('/api/auth/dev/sso/start', apiBaseUrl);
1063
- ssoStart.searchParams.set('return_to', absoluteReturnTo);
1064
- ssoStart.searchParams.set('dev_session_id', devSessionId);
1065
- } else {
1066
- ssoStart = new URL('/api/auth/sso/start', apiBaseUrl);
1067
- ssoStart.searchParams.set('return_to', absoluteReturnTo);
1068
- ssoStart.searchParams.set('client_id', installationId);
1211
+ const callback = new URL('/api/auth/callback', moduleOrigin);
1212
+ callback.searchParams.set('return_to', returnTo);
1213
+
1214
+ let res: Response;
1215
+ try {
1216
+ res = await fetch(\`\${apiBaseUrl}/api/module-api/sso/start-url\`, {
1217
+ method: 'POST',
1218
+ headers: {
1219
+ 'Content-Type': 'application/json',
1220
+ Accept: 'application/json',
1221
+ Authorization: \`Bearer \${apiKey}\`,
1222
+ },
1223
+ body: JSON.stringify({ callback_url: callback.toString() }),
1224
+ });
1225
+ } 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);
1230
+ }
1231
+
1232
+ if (!res.ok) {
1233
+ // The backend rejected the start (bad key, callback origin not
1234
+ // allowlisted, install missing). Surface a real error page instead
1235
+ // of a raw 4xx.
1236
+ const body = (await res.json().catch(() => null)) as
1237
+ | { code?: string; message?: string }
1238
+ | 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);
1069
1243
  }
1070
- return NextResponse.redirect(ssoStart);
1244
+
1245
+ const body = (await res.json().catch(() => null)) as
1246
+ | { redirect_url?: string }
1247
+ | null;
1248
+ 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',
1254
+ );
1255
+ return NextResponse.redirect(target);
1256
+ }
1257
+ return NextResponse.redirect(body.redirect_url);
1071
1258
  }
1072
1259
  `;
1073
1260
  }
@@ -1139,6 +1326,65 @@ export async function POST(request: Request) {
1139
1326
  }
1140
1327
  `;
1141
1328
  }
1329
+ function appApiItemsRouteTs() {
1330
+ return `// Example data-plane route \u2014 lists + creates rows in the seeded
1331
+ // \`items\` table via the generated typed SDK. Delete this (and the
1332
+ // \`items\` table in module.json) once you add your own tables.
1333
+ //
1334
+ // \`db\` comes from @dashai/generated, regenerated on every module.json
1335
+ // save by \`dashwise dev\`. Run it (or \`dashwise module generate\`) first.
1336
+ import { NextResponse } from 'next/server';
1337
+ import { db } from '@dashai/generated';
1338
+
1339
+ export async function GET() {
1340
+ const page = await db.items.list({ limit: 50 });
1341
+ return NextResponse.json(page.rows);
1342
+ }
1343
+
1344
+ export async function POST(request: Request) {
1345
+ const body = (await request.json().catch(() => null)) as
1346
+ | { title?: unknown; done?: unknown }
1347
+ | null;
1348
+ const title = typeof body?.title === 'string' ? body.title : '';
1349
+ if (!title) {
1350
+ return NextResponse.json(
1351
+ { error: 'title is required (string)' },
1352
+ { status: 400 },
1353
+ );
1354
+ }
1355
+ const done = typeof body?.done === 'boolean' ? body.done : false;
1356
+ const row = await db.items.create({ title, done });
1357
+ return NextResponse.json(row, { status: 201 });
1358
+ }
1359
+ `;
1360
+ }
1361
+ function appApiAuthSessionTs() {
1362
+ return `// /api/auth/session \u2014 SAME-ORIGIN session probe for client components.
1363
+ //
1364
+ // D8: the browser's <AuthProvider> can't read the session by calling the
1365
+ // cross-origin DashWise API directly, because the \`dashwise.session\`
1366
+ // cookie is host-scoped to THIS module's origin and is never sent to
1367
+ // \`api.dashwise.net\`. So <AuthProvider sessionEndpoint="/api/auth/session">
1368
+ // hits this route (same origin \u2192 the cookie IS sent); we validate it
1369
+ // server-side via \`getServerSession()\` (which forwards the cookie to the
1370
+ // API for us) and return the fully-enriched session \u2014 or \`null\` \u2014 as JSON.
1371
+ //
1372
+ // \`getServerSession()\` already merges the RBAC slice, so the client
1373
+ // receives \`rbacRoles\` / \`rbacPermissions\` and skips a second fetch.
1374
+ import { NextResponse } from 'next/server';
1375
+ import { getServerSession } from '@dashai/sdk/auth/server';
1376
+
1377
+ export async function GET() {
1378
+ const session = await getServerSession();
1379
+ // Always 200 \u2014 \`null\` is a valid "not signed in" answer the client
1380
+ // maps to \`unauthenticated\`. \`no-store\` so a stale signed-in body is
1381
+ // never served to a signed-out user after sign-out.
1382
+ return NextResponse.json(session, {
1383
+ headers: { 'Cache-Control': 'no-store' },
1384
+ });
1385
+ }
1386
+ `;
1387
+ }
1142
1388
  function packageJsonCustom(opts) {
1143
1389
  return JSON.stringify(
1144
1390
  {
@@ -1154,7 +1400,7 @@ function packageJsonCustom(opts) {
1154
1400
  start: "next start"
1155
1401
  },
1156
1402
  dependencies: {
1157
- "@dashai/sdk": "^0.8.0",
1403
+ "@dashai/sdk": "^2.0.0",
1158
1404
  next: "^15.0.0",
1159
1405
  react: "^19.0.0",
1160
1406
  "react-dom": "^19.0.0",
@@ -1504,7 +1750,7 @@ const ERROR_MESSAGES: Record<string, string> = {
1504
1750
  server_error:
1505
1751
  'The DashWise sign-in server returned an internal error. This is usually transient \u2014 try again in a moment.',
1506
1752
  invalid_request:
1507
- 'The sign-in request was malformed. This is almost always a configuration bug \u2014 check that DASHWISE_API_URL and DASHWISE_INSTALLATION_ID are set correctly.',
1753
+ 'The sign-in request was malformed. This is almost always a configuration bug \u2014 check that DASHWISE_API_URL and DASHWISE_API_KEY are set correctly and the callback origin is allowlisted (run "dashwise origins add <url>").',
1508
1754
  missing_code:
1509
1755
  'The callback URL was missing the expected exchange code. This usually means the SSO flow was interrupted.',
1510
1756
  exchange_failed:
@@ -1577,7 +1823,14 @@ function escapeHtml(s) {
1577
1823
  return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
1578
1824
  }
1579
1825
  function isValidSlug(slug) {
1580
- return /^[a-z][a-z0-9_-]*$/.test(slug);
1826
+ return MODULE_SLUG_REGEX.test(slug);
1827
+ }
1828
+ function slugValidationMessage(slug) {
1829
+ if (slug.includes("_")) {
1830
+ const suggestion = slug.replace(/_/g, "-").replace(/^-+|-+$/g, "");
1831
+ return `Slug "${slug}" uses underscores \u2014 module slugs must be kebab-case (letters, digits, hyphens; e.g. "${suggestion || "my-module"}"). Rename it and retry.`;
1832
+ }
1833
+ return `Slug "${slug}" must be kebab-case \u2014 match /^[a-z][a-z0-9-]*[a-z0-9]$/ (a leading letter, then letters/digits/hyphens, no trailing hyphen).`;
1581
1834
  }
1582
1835
  function claudeMd(opts) {
1583
1836
  const isCustom = (opts.kind ?? "hand_authored") === "custom";
@@ -1660,17 +1913,20 @@ await db.<table>.create({ /* fields */ });
1660
1913
  const stats = await qb.from('<table>').where({ /* ... */ }).execute();
1661
1914
  \`\`\`
1662
1915
 
1663
- The data plane authenticates via the per-module runtime token cached
1664
- in \`~/.config/dashwise/auth.json#modules[${opts.slug}]\` after
1665
- \`dashwise init\` provisions the dev install. \`dashwise dev\` injects
1666
- this token into the Next.js child env automatically.
1916
+ The data plane authenticates via the per-module scoped \`dwk_\` API key
1917
+ cached in \`~/.config/dashwise/auth.json#modules[${opts.slug}]\` after
1918
+ \`dashwise init\` provisions the dev install. \`dashwise dev\` injects it
1919
+ as \`DASHWISE_API_KEY\` into the Next.js child env automatically. The
1920
+ key binds the install server-side \u2014 there is no \`DASHWISE_INSTALLATION_ID\`.
1667
1921
 
1668
1922
  ## Auth in dev mode
1669
1923
 
1670
- \`dashwise dev\` registers a dev-mode SSO session with the backend
1671
- and injects \`DASHWISE_DEV_SESSION_ID\` so workspace members can sign
1672
- into the locally-running module via DashWise SSO \u2014 no installed
1673
- \`installation_id\` required.
1924
+ \`dashwise dev\` registers a dev-mode SSO session with the backend so
1925
+ workspace members can sign into the locally-running module via DashWise
1926
+ SSO. The scaffold's \`/api/auth/sign-in\` route is a single path: it
1927
+ calls \`POST /api/module-api/sso/start-url\` with your \`dwk_\` key and
1928
+ follows the returned \`redirect_url\` \u2014 the same code runs in dev and in
1929
+ production, with no installation id or dev-session id in the request.
1674
1930
 
1675
1931
  In Server Components: \`await getServerSession()\` (from
1676
1932
  \`@dashai/sdk/auth/server\`).
@@ -1727,13 +1983,14 @@ what tables it owns.
1727
1983
 
1728
1984
  | Table | Columns | Notes |
1729
1985
  |---|---|---|
1730
- | (none yet) | run \`dashwise table create <slug> --field <slug:type>\` to add one | |
1986
+ | \`items\` | \`title:text\`, \`done:boolean\` | seeded example \u2014 edit or replace; \`dashwise table create <slug> --field <slug:type>\` adds more |
1731
1987
 
1732
1988
  ## API / contract
1733
1989
 
1734
1990
  | Method | Path | Auth | Purpose |
1735
1991
  |---|---|---|---|
1736
- | (none yet) | | | |
1992
+ | GET | \`/api/items\` | dwk_ (server-side) | list \`items\` rows via \`db.items.list()\` |
1993
+ | POST | \`/api/items\` | dwk_ (server-side) | create an \`items\` row via \`db.items.create()\` |
1737
1994
 
1738
1995
  ## Business rules
1739
1996
 
@@ -1849,11 +2106,12 @@ primary_region = "iad"
1849
2106
  memory_mb = 256
1850
2107
  `;
1851
2108
  }
1852
- var DEPLOY_TARGETS, SCAFFOLD_DEFAULT_API_URL;
2109
+ var DEPLOY_TARGETS, SCAFFOLD_DEFAULT_API_URL, MODULE_SLUG_REGEX;
1853
2110
  var init_scaffold = __esm({
1854
2111
  "src/lib/scaffold.ts"() {
1855
2112
  DEPLOY_TARGETS = ["railway", "vercel", "fly"];
1856
2113
  SCAFFOLD_DEFAULT_API_URL = "http://localhost:3000";
2114
+ MODULE_SLUG_REGEX = /^[a-z]([a-z0-9-]*[a-z0-9])?$/;
1857
2115
  }
1858
2116
  });
1859
2117
  async function selectWorkspace(opts) {
@@ -1921,7 +2179,7 @@ async function moduleInitCommand(slugArg, options) {
1921
2179
  const answer = await text({
1922
2180
  message: "Module slug?",
1923
2181
  placeholder: "tasks",
1924
- validate: (v) => isValidSlug(v) ? void 0 : "Slug must match /^[a-z][a-z0-9_-]*$/"
2182
+ validate: (v) => isValidSlug(v) ? void 0 : slugValidationMessage(v)
1925
2183
  });
1926
2184
  if (isCancel(answer)) {
1927
2185
  fail("Aborted.");
@@ -1934,7 +2192,7 @@ async function moduleInitCommand(slugArg, options) {
1934
2192
  return 1;
1935
2193
  }
1936
2194
  if (!isValidSlug(slug)) {
1937
- fail(`Invalid slug ${code(slug)} \u2014 must match /^[a-z][a-z0-9_-]*$/`);
2195
+ fail(slugValidationMessage(slug));
1938
2196
  return 1;
1939
2197
  }
1940
2198
  const name = options.name ?? humanizeSlug(slug);
@@ -1982,7 +2240,11 @@ async function moduleInitCommand(slugArg, options) {
1982
2240
  // discipline + SDK pointers loaded into context from byte one.
1983
2241
  ["CLAUDE.md", claudeMd(scaffoldOpts)],
1984
2242
  ["spec.md", specMd(scaffoldOpts)],
1985
- ["agent-log.md", agentLogMd(scaffoldOpts)]
2243
+ ["agent-log.md", agentLogMd(scaffoldOpts)],
2244
+ // Example data-plane route (both flavors) — exercises the generated
2245
+ // SDK against the seeded `items` table. Ships for simple + custom
2246
+ // since both produce `@dashai/generated`.
2247
+ ["app/api/items/route.ts", appApiItemsRouteTs()]
1986
2248
  ];
1987
2249
  if (kind === "custom") {
1988
2250
  files.push(
@@ -2002,7 +2264,10 @@ async function moduleInitCommand(slugArg, options) {
2002
2264
  ["app/sign-in-error/page.tsx", appSignInErrorPageTsx()],
2003
2265
  ["app/api/auth/sign-in/route.ts", appApiAuthSignInTs()],
2004
2266
  ["app/api/auth/callback/route.ts", appApiAuthCallbackTs()],
2005
- ["app/api/auth/sign-out/route.ts", appApiAuthSignOutTs()]
2267
+ ["app/api/auth/sign-out/route.ts", appApiAuthSignOutTs()],
2268
+ // D8: same-origin session probe that <AuthProvider> hits so the
2269
+ // host-scoped session cookie is actually sent.
2270
+ ["app/api/auth/session/route.ts", appApiAuthSessionTs()]
2006
2271
  );
2007
2272
  } else {
2008
2273
  files.push(
@@ -2061,7 +2326,7 @@ async function moduleInitCommand(slugArg, options) {
2061
2326
  ${dim(" dashwise module dev")} ${dim("# watch + auto-regenerate")}
2062
2327
  ${dim(" npm run dev:next # next dev on :3000)")}
2063
2328
  ${devInstall ? `
2064
- ${dim(`Dev installation id=${devInstall.installation.id}; runtime token expires ${devInstall.runtime_token_expires_at}.`)}
2329
+ ${dim(`Dev installation id=${devInstall.installation.id}; API key expires ${devInstall.api_key_expires_at}.`)}
2065
2330
  ` : ""}`);
2066
2331
  outro("Happy authoring.");
2067
2332
  return 0;
@@ -2115,20 +2380,21 @@ async function tryProvisionDevInstall(args) {
2115
2380
  }
2116
2381
  try {
2117
2382
  upsertModuleEntry(args.slug, {
2118
- runtimeToken: response.runtime_token,
2383
+ apiKey: response.api_key,
2384
+ apiKeyId: response.api_key_id,
2119
2385
  installationId: response.installation.id,
2120
- expiresAt: response.runtime_token_expires_at
2386
+ expiresAt: response.api_key_expires_at
2121
2387
  });
2122
2388
  } catch (err) {
2123
2389
  warn(
2124
- `Provisioned successfully (installation id=${response.installation.id}) but failed to cache the runtime token: ${err.message}
2125
- Runtime token (save this manually): ${response.runtime_token}
2126
- Token expires: ${response.runtime_token_expires_at}`
2390
+ `Provisioned successfully (installation id=${response.installation.id}) but failed to cache the API key: ${err.message}
2391
+ API key (save this manually): ${response.api_key}
2392
+ Key expires: ${response.api_key_expires_at}`
2127
2393
  );
2128
2394
  return response;
2129
2395
  }
2130
2396
  success(
2131
- `Provisioned dev install id=${response.installation.id}. Runtime token cached in your CLI config.`
2397
+ `Provisioned dev install id=${response.installation.id}. API key cached in your CLI config.`
2132
2398
  );
2133
2399
  return response;
2134
2400
  }
@@ -2197,6 +2463,7 @@ function loadSchemaCommandContext(opts) {
2197
2463
  `No dev install cached for module "${slug}". Run \`dashwise init ${slug} --force\` to provision one.`
2198
2464
  );
2199
2465
  }
2466
+ const token = resolveSchemaAuthToken(entry, slug);
2200
2467
  return {
2201
2468
  projectRoot,
2202
2469
  manifestPath: manifestPath2,
@@ -2207,10 +2474,16 @@ function loadSchemaCommandContext(opts) {
2207
2474
  entry,
2208
2475
  requestOptions: {
2209
2476
  baseUrl: resolveApiUrl(),
2210
- auth: { token: entry.runtimeToken }
2477
+ auth: { token }
2211
2478
  }
2212
2479
  };
2213
2480
  }
2481
+ function resolveSchemaAuthToken(entry, slug) {
2482
+ if (entry.apiKey) return entry.apiKey;
2483
+ throw new Error(
2484
+ `The cached credential for "${slug}" is a legacy runtime token, which the schema endpoints no longer accept. Run \`dashwise dev\` once (it mints a dwk_ API key and updates your config), or \`dashwise init ${slug} --force\` to re-provision.`
2485
+ );
2486
+ }
2214
2487
  var init_schema_command_context = __esm({
2215
2488
  "src/lib/schema-command-context.ts"() {
2216
2489
  init_config();
@@ -2549,7 +2822,6 @@ async function tableCreateCommand(slug, options) {
2549
2822
  info(`Creating table ${code(slug)} in installation ${ctx.entry.installationId}\u2026`);
2550
2823
  try {
2551
2824
  const result = await createTable(
2552
- ctx.entry.installationId,
2553
2825
  { slug, fields },
2554
2826
  ctx.requestOptions
2555
2827
  );
@@ -2563,10 +2835,7 @@ async function tableCreateCommand(slug, options) {
2563
2835
  }
2564
2836
  async function syncLocalManifest(ctx) {
2565
2837
  try {
2566
- const { manifest } = await getInstallationSchema(
2567
- ctx.entry.installationId,
2568
- ctx.requestOptions
2569
- );
2838
+ const { manifest } = await getInstallationSchema(ctx.requestOptions);
2570
2839
  writeManifest(manifest, ctx.projectRoot);
2571
2840
  return 0;
2572
2841
  } catch (err) {
@@ -2599,17 +2868,17 @@ function surfaceFailure(err) {
2599
2868
  break;
2600
2869
  case "UNAUTHENTICATED":
2601
2870
  fail(
2602
- `Runtime token rejected by backend. The cached token may be expired \u2014 re-run \`dashwise init <slug> --force\` to mint a fresh one.`
2871
+ `API key rejected by backend. The cached key may be expired or revoked \u2014 re-run \`dashwise init <slug> --force\` to mint a fresh one.`
2603
2872
  );
2604
2873
  break;
2605
- case "SANDBOX_TOKEN_NOT_ACCEPTED":
2874
+ case "SCHEMA_LOCKED":
2606
2875
  fail(
2607
- `Schema-mutation endpoints require a local-dev runtime token (kind=local). The cached token is the wrong kind \u2014 re-run \`dashwise init <slug> --force\`.`
2876
+ `Schema mutations require a local-dev install (kind=local). The cached API key belongs to a non-local install \u2014 schema is locked. Run \`dashwise init <slug> --force\` for a local dev install.`
2608
2877
  );
2609
2878
  break;
2610
2879
  case "INSTALL_MISMATCH":
2611
2880
  fail(
2612
- `Runtime token is bound to a different installation than the URL. The cached install id may be stale \u2014 re-run \`dashwise init <slug> --force\`.`
2881
+ `API key is bound to a different installation than the URL. The cached install id may be stale \u2014 re-run \`dashwise init <slug> --force\`.`
2613
2882
  );
2614
2883
  break;
2615
2884
  default:
@@ -2651,11 +2920,7 @@ async function tableDeleteCommand(slug, options) {
2651
2920
  }
2652
2921
  info(`Dropping table ${code(slug)} from installation ${ctx.entry.installationId}\u2026`);
2653
2922
  try {
2654
- const result = await dropTable(
2655
- ctx.entry.installationId,
2656
- slug,
2657
- ctx.requestOptions
2658
- );
2923
+ const result = await dropTable(slug, ctx.requestOptions);
2659
2924
  success(
2660
2925
  `Dropped table ${code(slug)} (manifest_version=${result.manifest_version}). Physical table soft-deleted; recoverable for 30 days via the dashboard's trash.`
2661
2926
  );
@@ -2723,7 +2988,6 @@ async function fieldAddCommand(tableSlug, fieldSpec, options) {
2723
2988
  );
2724
2989
  try {
2725
2990
  const result = await addField(
2726
- ctx.entry.installationId,
2727
2991
  { table_slug: tableSlug, field },
2728
2992
  ctx.requestOptions
2729
2993
  );
@@ -2769,7 +3033,6 @@ async function fieldRemoveCommand(tableSlug, fieldSlug, options) {
2769
3033
  );
2770
3034
  try {
2771
3035
  const result = await removeField(
2772
- ctx.entry.installationId,
2773
3036
  tableSlug,
2774
3037
  fieldSlug,
2775
3038
  ctx.requestOptions
@@ -2823,7 +3086,6 @@ async function fieldSetTypeCommand(tableSlug, fieldSlug, newType, options) {
2823
3086
  );
2824
3087
  try {
2825
3088
  const result = await updateField(
2826
- ctx.entry.installationId,
2827
3089
  tableSlug,
2828
3090
  fieldSlug,
2829
3091
  { type: newType },
@@ -2849,6 +3111,77 @@ var init_set_type = __esm({
2849
3111
 
2850
3112
  // src/commands/dev.ts
2851
3113
  init_api_client();
3114
+
3115
+ // src/lib/connect-status.ts
3116
+ init_api_client();
3117
+ init_output();
3118
+ async function reportConnectStatus(opts) {
3119
+ const [deps, bindings] = await Promise.all([
3120
+ safeGet(
3121
+ () => getInstallationDependencies(opts.installationId, opts.requestOptions)
3122
+ ),
3123
+ safeGet(
3124
+ () => getInstallationBindings(opts.installationId, opts.requestOptions)
3125
+ )
3126
+ ]);
3127
+ if (deps === null && bindings === null) return;
3128
+ const depList = deps ?? [];
3129
+ const bindList = bindings ?? [];
3130
+ if (depList.length === 0 && bindList.length === 0) return;
3131
+ const unboundDeps = depList.filter((d) => d.status === "unbound").length;
3132
+ const unboundUses = bindList.filter((b) => b.status === "unbound").length;
3133
+ info(
3134
+ `Connect status for ${code(opts.moduleSlug)} ${dim(
3135
+ `(${depList.length} dep${depList.length === 1 ? "" : "s"}, ${bindList.length} uses slot${bindList.length === 1 ? "" : "s"})`
3136
+ )}:`
3137
+ );
3138
+ for (const d of depList) renderDep(d);
3139
+ for (const b of bindList) renderBinding(b);
3140
+ const hints = [];
3141
+ if (unboundDeps > 0) {
3142
+ hints.push(
3143
+ `${code("dashwise deps bind <provider-slug>")} once the provider is installed`
3144
+ );
3145
+ }
3146
+ if (unboundUses > 0) {
3147
+ hints.push(`${code("dashwise bind <uses-slug> --table <name>")} to connect a table`);
3148
+ }
3149
+ if (hints.length > 0) {
3150
+ info(`Connect parked items with ${hints.join("; ")}.`);
3151
+ }
3152
+ out("");
3153
+ }
3154
+ function renderDep(d) {
3155
+ if (d.status === "resolved") {
3156
+ const providerLine = d.provider ? ` ${dim(`\u2192 install #${d.provider.installation_id} @ v${d.provider.version}`)}` : "";
3157
+ out(` ${pc.green("\u25CF resolved")} ${dim("dep")} ${code(d.slug)}@${d.range ?? "*"}${providerLine}`);
3158
+ } else {
3159
+ out(
3160
+ ` ${pc.yellow("\u25CB unbound")} ${dim("dep")} ${code(d.slug)}@${d.range ?? "*"} ${dim(`reason: ${d.reason ?? "(unspecified)"}`)}`
3161
+ );
3162
+ }
3163
+ }
3164
+ function renderBinding(b) {
3165
+ if (b.status === "bound") {
3166
+ const tableLine = b.table ? ` ${dim(`\u2192 ${b.table.name} (id ${b.table.id})`)}` : "";
3167
+ out(` ${pc.green("\u25CF bound")} ${dim("uses")} ${code(b.uses_slug)}${tableLine}`);
3168
+ } else if (b.status === "declined") {
3169
+ out(` ${pc.dim("\u2298 declined")} ${dim("uses")} ${code(b.uses_slug)}`);
3170
+ } else {
3171
+ out(
3172
+ ` ${pc.yellow("\u25CB unbound")} ${dim("uses")} ${code(b.uses_slug)} ${dim(`reason: ${b.reason ?? "(unspecified)"}`)}`
3173
+ );
3174
+ }
3175
+ }
3176
+ async function safeGet(fn) {
3177
+ try {
3178
+ return await fn();
3179
+ } catch (err) {
3180
+ return null;
3181
+ }
3182
+ }
3183
+
3184
+ // src/commands/dev.ts
2852
3185
  init_config();
2853
3186
  init_manifest_io();
2854
3187
 
@@ -2856,7 +3189,6 @@ init_manifest_io();
2856
3189
  init_api_client();
2857
3190
  function startManifestPollLoop(opts) {
2858
3191
  const {
2859
- installationId,
2860
3192
  manifestPath: manifestPath2,
2861
3193
  requestOptions,
2862
3194
  intervalMs,
@@ -2869,10 +3201,7 @@ function startManifestPollLoop(opts) {
2869
3201
  const tick = async () => {
2870
3202
  if (stopped) return;
2871
3203
  try {
2872
- const { manifest } = await getInstallationSchema(
2873
- installationId,
2874
- requestOptions
2875
- );
3204
+ const { manifest } = await getInstallationSchema(requestOptions);
2876
3205
  const remoteJson = JSON.stringify(manifest, null, 2) + "\n";
2877
3206
  const currentLocal = existsSync(manifestPath2) ? readFileSync(manifestPath2, "utf-8") : "";
2878
3207
  if (remoteJson === currentLocal) {
@@ -2967,16 +3296,15 @@ async function devCommand(options) {
2967
3296
  }
2968
3297
  const explicitInstallationId = options.installationId ?? process.env.DASHWISE_INSTALLATION_ID;
2969
3298
  const reuseDevSessionId = process.env.DASHWISE_DEV_SESSION_ID;
2970
- const moduleEntry = manifestSlug !== void 0 ? readModuleEntry(manifestSlug) : null;
2971
- const runtimeTokenEnv = process.env.DASHWISE_RUNTIME_TOKEN ?? moduleEntry?.runtimeToken;
3299
+ let moduleEntry = manifestSlug !== void 0 ? readModuleEntry(manifestSlug) : null;
3300
+ const healed = manifestSlug !== void 0 ? await resolveModuleApiKey(manifestSlug, moduleEntry, apiUrl, apiToken) : null;
3301
+ if (healed) moduleEntry = healed;
3302
+ const apiKeyEnv = process.env.DASHWISE_API_KEY ?? moduleEntry?.apiKey;
2972
3303
  let installationIdEnv;
2973
- let devSessionIdEnv;
2974
3304
  let revokeDevSession;
2975
3305
  if (explicitInstallationId && explicitInstallationId !== "local") {
2976
3306
  installationIdEnv = explicitInstallationId;
2977
- } else if (reuseDevSessionId) {
2978
- devSessionIdEnv = reuseDevSessionId;
2979
- } else {
3307
+ } else if (reuseDevSessionId) ; else {
2980
3308
  let workspaceContext;
2981
3309
  try {
2982
3310
  workspaceContext = await resolveTargetWorkspace(apiUrl, apiToken, options.workspace);
@@ -2990,7 +3318,6 @@ async function devCommand(options) {
2990
3318
  target_workspace_id: workspaceContext.id,
2991
3319
  callback_origins: callbackOrigins
2992
3320
  });
2993
- devSessionIdEnv = session.id;
2994
3321
  revokeDevSession = () => revokeDevSessionBestEffort(apiUrl, apiToken, session.id);
2995
3322
  info(` ${dim("dev session")} ${session.id}`);
2996
3323
  info(` ${dim("workspace")} ${workspaceContext.name} (id=${workspaceContext.id}, slug=${workspaceContext.slug})`);
@@ -3005,43 +3332,39 @@ async function devCommand(options) {
3005
3332
  return 1;
3006
3333
  }
3007
3334
  }
3008
- const childInstallationId = installationIdEnv ?? (runtimeTokenEnv !== void 0 ? "sandbox" : "");
3009
3335
  const childEnv = {
3010
3336
  ...process.env,
3011
3337
  DASHWISE_API_URL: apiUrl,
3012
- // HH-CC-FUP-3: client-side mirror. Next.js inlines NEXT_PUBLIC_*
3013
- // env vars into the browser bundle at build time (or, in dev, at
3014
- // process-start time). Without this, the SDK's <AuthProvider> hits
3015
- // `/api/auth/sessions/me` on its own origin (localhost:<port>),
3016
- // 404s, and stays unauthenticated. We mirror DASHWISE_API_URL so
3017
- // the client + server agree on the backend origin without the
3018
- // module author having to touch .env.local.
3019
3338
  NEXT_PUBLIC_DASHWISE_API_URL: apiUrl,
3020
- DASHWISE_API_TOKEN: apiToken,
3021
- DASHWISE_INSTALLATION_ID: childInstallationId,
3022
- ...devSessionIdEnv !== void 0 ? { DASHWISE_DEV_SESSION_ID: devSessionIdEnv } : {},
3023
- ...runtimeTokenEnv !== void 0 ? { DASHWISE_RUNTIME_TOKEN: runtimeTokenEnv } : {}
3339
+ ...apiKeyEnv !== void 0 ? { DASHWISE_API_KEY: apiKeyEnv } : {}
3024
3340
  };
3025
3341
  info(`Dev loop starting \u2014 Ctrl-C to stop.`);
3026
3342
  info(` ${dim("api")} ${apiUrl}`);
3027
3343
  if (installationIdEnv !== void 0) {
3028
3344
  info(` ${dim("installation")} ${installationIdEnv} (prod-mode SSO)`);
3029
3345
  } else {
3030
- info(` ${dim("mode")} dev (DASHWISE_DEV_SESSION_ID injected)`);
3346
+ info(` ${dim("mode")} dev (dev SSO session registered)`);
3031
3347
  }
3032
- info(` ${dim("token")} ${maskedToken(apiToken)} ${dim(`(from ${apiToken === savedConfig?.token ? "~/.config/dashwise/auth.json" : "$DASHWISE_API_TOKEN"})`)}`);
3033
- if (runtimeTokenEnv !== void 0) {
3034
- const source = process.env.DASHWISE_RUNTIME_TOKEN === runtimeTokenEnv ? "$DASHWISE_RUNTIME_TOKEN" : `~/.config/dashwise/auth.json#modules[${manifestSlug}]`;
3348
+ info(` ${dim("cli token")} ${maskedToken(apiToken)} ${dim(`(from ${apiToken === savedConfig?.token ? "~/.config/dashwise/auth.json" : "$DASHWISE_API_TOKEN"})`)}`);
3349
+ if (apiKeyEnv !== void 0) {
3350
+ const source = process.env.DASHWISE_API_KEY === apiKeyEnv ? "$DASHWISE_API_KEY" : `~/.config/dashwise/auth.json#modules[${manifestSlug}]`;
3035
3351
  const installId = moduleEntry?.installationId;
3036
3352
  info(
3037
- ` ${dim("runtime")} ${maskedToken(runtimeTokenEnv)} ${dim(`(from ${source}${installId ? `, install id=${installId}` : ""})`)}`
3353
+ ` ${dim("api key")} ${maskedToken(apiKeyEnv)} ${dim(`(from ${source}${installId ? `, install id=${installId}` : ""})`)}`
3038
3354
  );
3039
3355
  } else if (manifestSlug !== void 0 && installationIdEnv === void 0) {
3040
3356
  warn(
3041
- `No runtime token 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 the token.`
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.`
3042
3358
  );
3043
3359
  }
3044
3360
  info("");
3361
+ if (moduleEntry !== null && manifestSlug !== void 0) {
3362
+ await reportConnectStatus({
3363
+ installationId: moduleEntry.installationId,
3364
+ moduleSlug: manifestSlug,
3365
+ requestOptions: { baseUrl: apiUrl, auth: { token: apiToken } }
3366
+ });
3367
+ }
3045
3368
  const children = [];
3046
3369
  if (!options.nextOnly) {
3047
3370
  const sdkChild = spawnSdkWatcher(projectRoot, childEnv);
@@ -3056,18 +3379,19 @@ async function devCommand(options) {
3056
3379
  pipeWithPrefix(child.stderr, process.stderr, prefix);
3057
3380
  }
3058
3381
  let manifestPoll = null;
3059
- const enablePoll = !options.noRemotePoll && moduleEntry !== null && manifestSlug !== void 0;
3060
- if (enablePoll) {
3382
+ const pollEntry = moduleEntry;
3383
+ const enablePoll = !options.noRemotePoll && pollEntry !== null && apiKeyEnv !== void 0 && manifestSlug !== void 0;
3384
+ if (enablePoll && pollEntry !== null) {
3061
3385
  const intervalSec = Math.max(
3062
3386
  MIN_REMOTE_POLL_INTERVAL_SEC,
3063
3387
  options.remotePollIntervalSec ?? DEFAULT_REMOTE_POLL_INTERVAL_SEC
3064
3388
  );
3065
3389
  manifestPoll = startManifestPollLoop({
3066
- installationId: moduleEntry.installationId,
3067
3390
  manifestPath: manifestPath2,
3068
3391
  requestOptions: {
3069
3392
  baseUrl: apiUrl,
3070
- auth: { token: moduleEntry.runtimeToken }
3393
+ // The `dwk_` key resolves the install server-side.
3394
+ auth: { token: apiKeyEnv }
3071
3395
  },
3072
3396
  intervalMs: intervalSec * 1e3,
3073
3397
  onUpdate: (msg) => {
@@ -3200,13 +3524,58 @@ function maskedToken(token) {
3200
3524
  const tail = token.slice(-4);
3201
3525
  return `${prefix}\u2026${tail}`;
3202
3526
  }
3203
- function computeCallbackOrigins(port, extra) {
3204
- const def = `http://localhost:${port}`;
3205
- return Array.from(/* @__PURE__ */ new Set([def, ...extra]));
3206
- }
3207
- var PORT_PROBE_LIMIT = 10;
3208
- async function resolveDevPort(requested) {
3209
- if (requested !== void 0) {
3527
+ async function resolveModuleApiKey(slug, entry, apiUrl, cliBearer) {
3528
+ if (process.env.DASHWISE_API_KEY) return null;
3529
+ if (!entry) return null;
3530
+ if (entry.apiKey) return null;
3531
+ if (!entry.runtimeToken) return null;
3532
+ info(
3533
+ `Upgrading cached credentials for ${code(slug)} to a ${code("dwk_")} API key (the local runtime token format was retired)\u2026`
3534
+ );
3535
+ let minted;
3536
+ try {
3537
+ minted = await createApiKey(
3538
+ entry.installationId,
3539
+ { name: "local-dev" },
3540
+ { baseUrl: apiUrl, auth: { token: cliBearer } }
3541
+ );
3542
+ } catch (err) {
3543
+ if (err instanceof ApiError && err.code === "INSTALLATION_NOT_FOUND") {
3544
+ warn(
3545
+ `Could not upgrade credentials for ${code(slug)}: its dev install no longer exists. Run ${code(`dashwise init ${slug} --force`)} to re-provision (a clean re-register).`
3546
+ );
3547
+ return null;
3548
+ }
3549
+ const reason = err instanceof ApiError ? `${err.code}: ${err.message}` : err.message;
3550
+ warn(
3551
+ `Could not upgrade credentials for ${code(slug)} (${reason}). Data-plane calls may fail; run ${code(`dashwise init ${slug} --force`)} to refresh.`
3552
+ );
3553
+ return null;
3554
+ }
3555
+ const upgraded = {
3556
+ apiKey: minted.raw_key,
3557
+ apiKeyId: minted.api_key.id,
3558
+ installationId: entry.installationId,
3559
+ expiresAt: minted.api_key.expires_at ?? entry.expiresAt
3560
+ };
3561
+ try {
3562
+ upsertModuleEntry(slug, upgraded);
3563
+ } catch (err) {
3564
+ warn(
3565
+ `Minted a new API key for ${code(slug)} but failed to cache it: ${err.message}
3566
+ API key (save manually): ${minted.raw_key}`
3567
+ );
3568
+ }
3569
+ success(`Upgraded ${code(slug)} to a dwk_ API key (id=${minted.api_key.id}).`);
3570
+ return upgraded;
3571
+ }
3572
+ function computeCallbackOrigins(port, extra) {
3573
+ const def = `http://localhost:${port}`;
3574
+ return Array.from(/* @__PURE__ */ new Set([def, ...extra]));
3575
+ }
3576
+ var PORT_PROBE_LIMIT = 10;
3577
+ async function resolveDevPort(requested) {
3578
+ if (requested !== void 0) {
3210
3579
  if (await isPortFree(requested)) return requested;
3211
3580
  const suggestion = await findFirstFreePort(requested + 1, PORT_PROBE_LIMIT);
3212
3581
  const suggestionHint = suggestion !== null ? `Try \`dashwise dev --port ${suggestion}\`.` : `No free port found between ${requested + 1} and ${requested + PORT_PROBE_LIMIT} either \u2014 check what's listening with \`lsof -i :${requested}\`.`;
@@ -3887,7 +4256,7 @@ function isPlausibleRepoUrl(url) {
3887
4256
 
3888
4257
  // src/commands/module/convert.ts
3889
4258
  init_scaffold();
3890
- var SDK_DEP_RANGE = "^0.8.0";
4259
+ var SDK_DEP_RANGE = "^2.0.0";
3891
4260
  var SCAN_DIRS = ["app", "src", "pages", "lib", "components"];
3892
4261
  var SCAN_EXTENSIONS = [".ts", ".tsx", ".js", ".jsx", ".mjs"];
3893
4262
  var MAX_SCAN_FILES = 2e3;
@@ -3943,6 +4312,7 @@ function convertModule(opts) {
3943
4312
  writeIfMissing("app/api/auth/sign-in/route.ts", appApiAuthSignInTs());
3944
4313
  writeIfMissing("app/api/auth/callback/route.ts", appApiAuthCallbackTs());
3945
4314
  writeIfMissing("app/api/auth/sign-out/route.ts", appApiAuthSignOutTs());
4315
+ writeIfMissing("app/api/auth/session/route.ts", appApiAuthSessionTs());
3946
4316
  writeIfMissing("app/sign-in-error/page.tsx", appSignInErrorPageTsx());
3947
4317
  writeIfMissing(".env.local.example", envLocalExample());
3948
4318
  const layoutFile = [
@@ -3954,7 +4324,7 @@ function convertModule(opts) {
3954
4324
  writeIfMissing("app/layout.tsx", appLayoutCustomTsx(sopts));
3955
4325
  } else if (!readFileSync(join(dir, layoutFile), "utf-8").includes("AuthProvider")) {
3956
4326
  warnings.push(
3957
- `${layoutFile}: wrap your tree in <AuthProvider> from "@dashai/sdk/auth/client" so client components can read the session.`
4327
+ `${layoutFile}: wrap your tree in <AuthProvider sessionEndpoint="/api/auth/session"> from "@dashai/sdk/auth/client" so client components can read the session (the same-origin session route was scaffolded for you).`
3958
4328
  );
3959
4329
  } else {
3960
4330
  skipped.push(layoutFile);
@@ -4236,6 +4606,7 @@ async function moduleExportCommand(opts) {
4236
4606
  init_config();
4237
4607
  init_output();
4238
4608
  init_manifest_io();
4609
+ init_scaffold();
4239
4610
  init_workspaces();
4240
4611
  init_api_client();
4241
4612
  var MODULE_KINDS = ["ai_builder", "hand_authored", "custom"];
@@ -4258,6 +4629,13 @@ async function moduleRegisterCommand(opts) {
4258
4629
  fail("module.json is missing `module.slug`.");
4259
4630
  return 1;
4260
4631
  }
4632
+ if (!isValidSlug(slug)) {
4633
+ fail(
4634
+ `${slugValidationMessage(slug)}
4635
+ Fix ${code("module.slug")} in module.json, then re-run ${code("dashwise module register")}.`
4636
+ );
4637
+ return 1;
4638
+ }
4261
4639
  const name = opts.name ?? (typeof manifest.module?.name === "string" ? manifest.module.name : slug);
4262
4640
  const moduleKind = MODULE_KINDS.includes(
4263
4641
  manifest.module_kind
@@ -4295,7 +4673,12 @@ async function moduleRegisterCommand(opts) {
4295
4673
  name,
4296
4674
  ...description !== void 0 ? { description } : {},
4297
4675
  workspaceId: workspace2.id,
4298
- ...moduleKind !== void 0 ? { moduleKind } : {}
4676
+ ...moduleKind !== void 0 ? { moduleKind } : {},
4677
+ // P1-C C2: push the whole local manifest so the backend
4678
+ // materializes owns.tables into the fresh draft. `init` omits
4679
+ // this (scaffolds empty); `register` sends it because the local
4680
+ // module.json is the source of truth for an already-authored app.
4681
+ manifest
4299
4682
  },
4300
4683
  { baseUrl: resolveApiUrl(), auth: { token: cfg.token } }
4301
4684
  );
@@ -4307,6 +4690,13 @@ async function moduleRegisterCommand(opts) {
4307
4690
  );
4308
4691
  } else if (err.code === "FORBIDDEN") {
4309
4692
  fail(`Not a member of workspace "${workspace2.slug}". Join it, then retry.`);
4693
+ } else if (err.code === "MANIFEST_INVALID") {
4694
+ const issues = extractManifestIssues(err);
4695
+ fail(
4696
+ `Your module.json failed validation:
4697
+ ${issues}
4698
+ Fix the issues above (or run ${code("dashwise module validate")} for the full report) and retry.`
4699
+ );
4310
4700
  } else {
4311
4701
  fail(`Registration failed (${err.code || err.status}): ${err.message}`);
4312
4702
  }
@@ -4317,26 +4707,49 @@ async function moduleRegisterCommand(opts) {
4317
4707
  }
4318
4708
  try {
4319
4709
  upsertModuleEntry(slug, {
4320
- runtimeToken: response.runtime_token,
4710
+ apiKey: response.api_key,
4711
+ apiKeyId: response.api_key_id,
4321
4712
  installationId: response.installation.id,
4322
- expiresAt: response.runtime_token_expires_at
4713
+ expiresAt: response.api_key_expires_at
4323
4714
  });
4324
4715
  } catch (err) {
4325
4716
  warn(
4326
- `Registered (installation id=${response.installation.id}) but failed to cache the runtime token: ${err.message}
4327
- Runtime token (save this manually): ${response.runtime_token}
4328
- Expires: ${response.runtime_token_expires_at}`
4717
+ `Registered (installation id=${response.installation.id}) but failed to cache the API key: ${err.message}
4718
+ API key (save this manually): ${response.api_key}
4719
+ Expires: ${response.api_key_expires_at}`
4329
4720
  );
4330
4721
  return 0;
4331
4722
  }
4723
+ const tablesApplied = response.tables_applied ?? 0;
4332
4724
  success(
4333
- `Registered "${name}" \u2192 module #${response.module.id}, dev install #${response.installation.id}. Runtime token cached.`
4725
+ `Registered "${name}" \u2192 module #${response.module.id}, dev install #${response.installation.id}. API key cached.`
4334
4726
  );
4727
+ if (tablesApplied > 0) {
4728
+ info(
4729
+ `Materialized ${code(String(tablesApplied))} ${tablesApplied === 1 ? "table" : "tables"} from your module.json into the dev install.`
4730
+ );
4731
+ }
4335
4732
  info(
4336
- `Next: ${code("dashwise dev")} to run it against DashWise, or ${code("dashwise module publish")} to deploy. Add tables with ${code("dashwise table create")}.`
4733
+ `Next: ${code("dashwise dev")} to run it against DashWise, or ${code("dashwise module publish")} to deploy.${tablesApplied === 0 ? ` Add tables with ${code("dashwise table create")}.` : ""}`
4337
4734
  );
4338
4735
  return 0;
4339
4736
  }
4737
+ function extractManifestIssues(err) {
4738
+ const issues = err.context?.issues;
4739
+ if (Array.isArray(issues) && issues.length > 0) {
4740
+ return issues.map((issue) => {
4741
+ if (typeof issue === "string") return ` - ${issue}`;
4742
+ if (issue && typeof issue === "object") {
4743
+ const o = issue;
4744
+ const path = typeof o.path === "string" ? `${o.path}: ` : "";
4745
+ const message = typeof o.message === "string" ? o.message : JSON.stringify(issue);
4746
+ return ` - ${path}${message}`;
4747
+ }
4748
+ return ` - ${String(issue)}`;
4749
+ }).join("\n");
4750
+ }
4751
+ return ` - ${err.message}`;
4752
+ }
4340
4753
 
4341
4754
  // src/commands/module/generate.ts
4342
4755
  init_api_client();
@@ -5174,6 +5587,7 @@ function readIssues(context) {
5174
5587
 
5175
5588
  // src/commands/deps/add.ts
5176
5589
  init_api_client();
5590
+ init_config();
5177
5591
  init_manifest_io();
5178
5592
  init_output();
5179
5593
  var MIN_PURPOSE_LEN = 10;
@@ -5309,9 +5723,59 @@ async function depsAddCommand(providerSlug, options) {
5309
5723
  );
5310
5724
  info(`Reads: ${entry.reads.map((r) => `${code(r.table)} (${r.fields.join(", ")})`).join("; ")}`);
5311
5725
  info(`Run ${code("dashwise module generate")} to refresh typed wrappers.`);
5726
+ await reportConnectStatus2(manifest.module?.slug, providerSlug);
5312
5727
  if (isInteractive) outro("Done.");
5313
5728
  return 0;
5314
5729
  }
5730
+ async function reportConnectStatus2(consumerSlug, providerSlug) {
5731
+ if (!consumerSlug) return;
5732
+ let cfg;
5733
+ let entry;
5734
+ try {
5735
+ cfg = readConfig();
5736
+ entry = readModuleEntry(consumerSlug);
5737
+ } catch {
5738
+ cfg = null;
5739
+ entry = null;
5740
+ }
5741
+ if (!cfg || !entry) {
5742
+ info(
5743
+ `Once provisioned (${code("dashwise init")}) + pushed (${code("dashwise dev")}), run ${code("dashwise deps")} to see whether ${code(providerSlug)} resolves or parks.`
5744
+ );
5745
+ return;
5746
+ }
5747
+ const requestOptions = {
5748
+ baseUrl: resolveApiUrl(),
5749
+ auth: { token: cfg.token }
5750
+ };
5751
+ try {
5752
+ const deps = await getInstallationDependencies(entry.installationId, requestOptions);
5753
+ const match = deps.find((d) => d.slug === providerSlug);
5754
+ if (!match) {
5755
+ info(
5756
+ `${code(providerSlug)} isn't reconciled server-side yet. Push the manifest (${code("dashwise dev")} watches + applies it) \u2014 then ${code("dashwise deps")} shows its resolve/park status.`
5757
+ );
5758
+ return;
5759
+ }
5760
+ if (match.status === "resolved") {
5761
+ const providerLine = match.provider != null ? ` (${dim(`provider install #${match.provider.installation_id} @ v${match.provider.version}`)})` : "";
5762
+ out(` ${pc.green("\u25CF resolved")}${providerLine}`);
5763
+ } else {
5764
+ out(` ${pc.yellow("\u25CB unbound")} ${dim("reason:")} ${match.reason ?? dim("(unspecified)")}`);
5765
+ info(
5766
+ `Declared but not connected. Install the provider in this workspace, then ${code(`dashwise deps bind ${providerSlug}`)} \u2014 reads return ${code("DEP_UNBOUND")} until it's bound.`
5767
+ );
5768
+ }
5769
+ } catch (err) {
5770
+ if (err instanceof ApiError && err.status === 401) {
5771
+ info(`(Skipped connect-status check \u2014 CLI token stale; run ${code("dashwise login")} to refresh.)`);
5772
+ } else {
5773
+ info(
5774
+ `(Couldn't fetch connect status right now \u2014 run ${code("dashwise deps")} later to see whether ${code(providerSlug)} resolves or parks.)`
5775
+ );
5776
+ }
5777
+ }
5778
+ }
5315
5779
  async function collectInteractive(providerSlug, summary, resolvedVersion, options) {
5316
5780
  const reads = [];
5317
5781
  if (summary && summary.exposes && summary.exposes.length > 0) {
@@ -5730,107 +6194,241 @@ async function depsRemoveCommand(providerSlug, options) {
5730
6194
  return 0;
5731
6195
  }
5732
6196
 
5733
- // src/commands/pull.ts
6197
+ // src/commands/deps/connect.ts
5734
6198
  init_api_client();
6199
+ init_config();
5735
6200
  init_manifest_io();
5736
- init_schema_command_context();
5737
6201
  init_output();
5738
- async function pullCommand(options) {
6202
+ function loadContext(opts) {
6203
+ const manifest = readManifestOrThrow(opts.dir);
6204
+ const slug = manifest.module?.slug;
6205
+ if (!slug) {
6206
+ throw new Error("module.json has no module.slug \u2014 the manifest is malformed.");
6207
+ }
6208
+ const cfg = readConfig();
6209
+ if (!cfg) {
6210
+ throw new Error("Not logged in. Run `dashwise login` first.");
6211
+ }
6212
+ const entry = readModuleEntry(slug);
6213
+ if (!entry) {
6214
+ throw new Error(
6215
+ `No dev install cached for module "${slug}". Run \`dashwise init ${slug} --force\` to provision one.`
6216
+ );
6217
+ }
6218
+ return {
6219
+ installationId: entry.installationId,
6220
+ slug,
6221
+ requestOptions: {
6222
+ baseUrl: resolveApiUrl(),
6223
+ auth: { token: cfg.token }
6224
+ }
6225
+ };
6226
+ }
6227
+ function statusChip(status) {
6228
+ if (status === "resolved") return pc.green("\u25CF resolved");
6229
+ if (status === "unbound") return pc.yellow("\u25CB unbound");
6230
+ return pc.dim(`? ${status}`);
6231
+ }
6232
+ async function depsConnectListCommand(options) {
5739
6233
  let ctx;
5740
6234
  try {
5741
- ctx = loadSchemaCommandContext({
5742
- ...options.dir !== void 0 ? { dir: options.dir } : {}
5743
- });
6235
+ ctx = loadContext(options);
5744
6236
  } catch (err) {
5745
6237
  fail(err.message);
5746
6238
  return 1;
5747
6239
  }
5748
- info(`Pulling manifest for installation ${ctx.entry.installationId}\u2026`);
5749
- let remoteManifest;
6240
+ let deps;
5750
6241
  try {
5751
- const response = await getInstallationSchema(
5752
- ctx.entry.installationId,
5753
- ctx.requestOptions
5754
- );
5755
- remoteManifest = response.manifest;
6242
+ deps = await getInstallationDependencies(ctx.installationId, ctx.requestOptions);
5756
6243
  } catch (err) {
5757
- return surfacePullFailure(err);
6244
+ return surfaceDepError(err);
5758
6245
  }
5759
- const localJson = JSON.stringify(ctx.manifest, null, 2) + "\n";
5760
- const remoteJson = JSON.stringify(remoteManifest, null, 2) + "\n";
5761
- if (localJson === remoteJson) {
5762
- success("Already in sync \u2014 local module.json matches backend.");
6246
+ if (options.json) {
6247
+ out(JSON.stringify(deps, null, 2));
5763
6248
  return 0;
5764
6249
  }
5765
- const localBytes = Buffer.byteLength(localJson, "utf8");
5766
- const remoteBytes = Buffer.byteLength(remoteJson, "utf8");
5767
- const delta = remoteBytes - localBytes;
5768
- const deltaStr = delta === 0 ? "0 bytes" : `${delta > 0 ? "+" : ""}${delta} bytes`;
5769
- if (options.dryRun) {
6250
+ if (deps.length === 0) {
6251
+ info(`No cross-module dependencies declared for ${code(ctx.slug)}.`);
6252
+ info(`Add one with ${code("dashwise deps add <provider-slug>")}.`);
6253
+ return 0;
6254
+ }
6255
+ const unbound = deps.filter((d) => d.status === "unbound").length;
6256
+ info(
6257
+ `Cross-module dependencies for ${code(ctx.slug)} ${dim(
6258
+ `(${deps.length} declared${unbound > 0 ? `, ${unbound} unbound` : ""})`
6259
+ )}:`
6260
+ );
6261
+ out("");
6262
+ for (const dep of deps) {
6263
+ out(` ${statusChip(dep.status)} ${code(dep.slug)}@${dep.range ?? "*"}`);
6264
+ if (dep.status === "resolved" && dep.provider) {
6265
+ out(
6266
+ ` ${dim("provider:")} install #${dep.provider.installation_id} @ v${dep.provider.version}`
6267
+ );
6268
+ }
6269
+ if (dep.status === "unbound") {
6270
+ out(` ${dim("reason:")} ${dep.reason ?? dim("(unspecified)")}`);
6271
+ }
6272
+ out("");
6273
+ }
6274
+ if (unbound > 0) {
5770
6275
  info(
5771
- `Drift detected (${deltaStr}). ${code("--dry-run")} \u2014 not writing.`
6276
+ `Connect a parked dep with ${code("dashwise deps bind <provider-slug>")} once its provider is installed.`
5772
6277
  );
5773
- info(`Run ${code("dashwise pull")} without ${code("--dry-run")} to apply.`);
6278
+ }
6279
+ return 0;
6280
+ }
6281
+ async function depsBindCommand(providerSlug, options) {
6282
+ if (!providerSlug || typeof providerSlug !== "string") {
6283
+ fail("Usage: `dashwise deps bind <provider-slug> [--installation <id>]`");
6284
+ return 1;
6285
+ }
6286
+ let ctx;
6287
+ try {
6288
+ ctx = loadContext(options);
6289
+ } catch (err) {
6290
+ fail(err.message);
6291
+ return 1;
6292
+ }
6293
+ try {
6294
+ const result = await bindDependency(
6295
+ ctx.installationId,
6296
+ providerSlug,
6297
+ options.installation !== void 0 ? { provider_installation_id: options.installation } : {},
6298
+ ctx.requestOptions
6299
+ );
6300
+ const providerLine = result.provider != null ? ` \u2192 install #${result.provider.installation_id} @ v${result.provider.version}` : "";
6301
+ success(`Bound ${code(providerSlug)} for ${code(ctx.slug)}${providerLine}.`);
6302
+ info("Runtime reads against this dependency will now succeed.");
5774
6303
  return 0;
6304
+ } catch (err) {
6305
+ return surfaceBindError(err, providerSlug, ctx.slug);
6306
+ }
6307
+ }
6308
+ async function depsUnbindCommand(providerSlug, options) {
6309
+ if (!providerSlug || typeof providerSlug !== "string") {
6310
+ fail("Usage: `dashwise deps unbind <provider-slug>`");
6311
+ return 1;
5775
6312
  }
6313
+ let ctx;
5776
6314
  try {
5777
- writeManifest(remoteManifest, ctx.projectRoot);
6315
+ ctx = loadContext(options);
5778
6316
  } catch (err) {
5779
- fail(`Failed to write ${manifestPath(ctx.projectRoot)}: ${err.message}`);
6317
+ fail(err.message);
5780
6318
  return 1;
5781
6319
  }
5782
- success(
5783
- `Synced module.json from backend (${deltaStr}). ${dim(`Run \`dashwise module generate\` to refresh typed SDK if your watcher isn't running.`)}`
5784
- );
5785
- return 0;
6320
+ try {
6321
+ await unbindDependency(ctx.installationId, providerSlug, ctx.requestOptions);
6322
+ success(`Unbound ${code(providerSlug)} for ${code(ctx.slug)}.`);
6323
+ warn(
6324
+ `Runtime reads against this dependency now return ${code("DEP_UNBOUND")} until you ${code("dashwise deps bind")} it again.`
6325
+ );
6326
+ return 0;
6327
+ } catch (err) {
6328
+ return surfaceDepError(err);
6329
+ }
5786
6330
  }
5787
- function surfacePullFailure(err) {
6331
+ function surfaceBindError(err, providerSlug, consumerSlug) {
6332
+ if (err instanceof ApiError) {
6333
+ switch (err.code) {
6334
+ case "MISSING_DEPENDENCY":
6335
+ fail(
6336
+ `No installation of ${code(providerSlug)} found in ${code(consumerSlug)}'s workspace. Install the provider module first, then re-run ${code(`dashwise deps bind ${providerSlug}`)}.`
6337
+ );
6338
+ return 1;
6339
+ case "DEPENDENCY_VERSION_MISMATCH":
6340
+ fail(
6341
+ `The installed ${code(providerSlug)} version is outside your declared range (${err.message}). Update the range in module.json (${code(`dashwise deps add ${providerSlug}`)}) or upgrade the provider install.`
6342
+ );
6343
+ return 1;
6344
+ case "PROVIDER_VERSION_RECORD_MISSING":
6345
+ fail(
6346
+ `The provider ${code(providerSlug)}'s version record is missing (${err.message}). This is a provider-side data issue \u2014 the provider may need to re-publish.`
6347
+ );
6348
+ return 1;
6349
+ case "EXPOSES_MISSING":
6350
+ fail(
6351
+ `${code(providerSlug)} no longer exposes a table/field your dependency reads (${err.message}). Trim your ${code("reads[]")} or ask the provider to re-expose it.`
6352
+ );
6353
+ return 1;
6354
+ case "EXPOSES_PRIVATE":
6355
+ fail(
6356
+ `${code(providerSlug)} exposes the requested surface as ${code("private")} \u2014 it can't be read by depending modules (${err.message}).`
6357
+ );
6358
+ return 1;
6359
+ case "ACTION_NOT_EXPOSED":
6360
+ fail(
6361
+ `A cross-module action your dependency declares isn't exposed by ${code(providerSlug)} (${err.message}). Remove it from your dep's ${code("actions[]")} or ask the provider to expose it.`
6362
+ );
6363
+ return 1;
6364
+ case "PROVIDER_NOT_PRODUCTION":
6365
+ fail(
6366
+ `A production install can only bind to a production provider install; the available ${code(providerSlug)} install isn't production (${err.message}).`
6367
+ );
6368
+ return 1;
6369
+ case "DEPENDENCY_NOT_DECLARED":
6370
+ fail(
6371
+ `${code(consumerSlug)}'s module.json doesn't declare a dependency on ${code(providerSlug)}. Add it with ${code(`dashwise deps add ${providerSlug}`)} + push the manifest first.`
6372
+ );
6373
+ return 1;
6374
+ default:
6375
+ fail(
6376
+ `Could not bind ${code(providerSlug)}: ${err.code}${err.message ? ` \u2014 ${err.message}` : ""}. The dependency stays unbound.`
6377
+ );
6378
+ return 1;
6379
+ }
6380
+ }
6381
+ fail(`Bind failed: ${err.message}`);
6382
+ return 1;
6383
+ }
6384
+ function surfaceDepError(err) {
5788
6385
  if (err instanceof ApiError) {
5789
6386
  switch (err.code) {
5790
6387
  case "UNAUTHENTICATED":
5791
6388
  fail(
5792
- `Runtime token rejected. The cached token for this module may be expired \u2014 re-run \`dashwise init <slug> --force\` to mint a fresh one.`
6389
+ `Auth rejected by backend. Your CLI token may be stale \u2014 run ${code("dashwise login")} then retry.`
5793
6390
  );
5794
- break;
5795
- case "SANDBOX_TOKEN_NOT_ACCEPTED":
6391
+ return 1;
6392
+ case "NOT_WORKSPACE_MEMBER":
5796
6393
  fail(
5797
- `Schema-read endpoint requires a local-dev runtime token (kind=local). The cached token is the wrong kind \u2014 re-run \`dashwise init <slug> --force\`.`
6394
+ `You're not a member of this installation's workspace. Dependency management requires workspace membership.`
5798
6395
  );
5799
- break;
5800
- case "INSTALL_MISMATCH":
6396
+ return 1;
6397
+ case "NOT_WORKSPACE_ADMIN":
6398
+ case "FORBIDDEN":
5801
6399
  fail(
5802
- `Runtime token is bound to a different installation than expected. The cached install id may be stale \u2014 re-run \`dashwise init <slug> --force\`.`
6400
+ `Not authorized. Binding / unbinding dependencies requires workspace-admin.`
5803
6401
  );
5804
- break;
6402
+ return 1;
5805
6403
  case "INSTALLATION_NOT_FOUND":
5806
6404
  fail(
5807
- `The backend has no record of this installation. It may have been destroyed (GC, manual cleanup). Re-run \`dashwise init <slug>\` to provision a fresh one.`
6405
+ `Installation not found. It may have been destroyed; re-provision via ${code("dashwise init <slug>")}.`
5808
6406
  );
5809
- break;
6407
+ return 1;
6408
+ case "DEPENDENCY_NOT_DECLARED":
6409
+ fail(
6410
+ `No such dependency declared in this module's manifest. Run ${code("dashwise deps")} to see declared deps.`
6411
+ );
6412
+ return 1;
5810
6413
  default:
5811
6414
  fail(`Backend rejected: ${err.code} ${err.status} \u2014 ${err.message}`);
6415
+ return 1;
5812
6416
  }
5813
- return 1;
5814
6417
  }
5815
- fail(`Pull failed: ${err.message}`);
6418
+ fail(`Dependency operation failed: ${err.message}`);
5816
6419
  return 1;
5817
6420
  }
5818
6421
 
5819
- // src/bin.ts
5820
- init_scaffold();
5821
-
5822
- // src/commands/api-keys.ts
6422
+ // src/commands/uses/bind.ts
5823
6423
  init_api_client();
5824
6424
  init_config();
5825
6425
  init_manifest_io();
5826
6426
  init_output();
5827
- function loadContext(opts) {
6427
+ function loadContext2(opts) {
5828
6428
  const manifest = readManifestOrThrow(opts.dir);
5829
6429
  const slug = manifest.module?.slug;
5830
6430
  if (!slug) {
5831
- throw new Error(
5832
- `module.json has no module.slug \u2014 the manifest is malformed.`
5833
- );
6431
+ throw new Error("module.json has no module.slug \u2014 the manifest is malformed.");
5834
6432
  }
5835
6433
  const cfg = readConfig();
5836
6434
  if (!cfg) {
@@ -5845,47 +6443,895 @@ function loadContext(opts) {
5845
6443
  return {
5846
6444
  installationId: entry.installationId,
5847
6445
  slug,
6446
+ ...opts.dir !== void 0 ? { dir: opts.dir } : {},
5848
6447
  requestOptions: {
5849
6448
  baseUrl: resolveApiUrl(),
5850
6449
  auth: { token: cfg.token }
5851
6450
  }
5852
6451
  };
5853
6452
  }
5854
- async function apiKeysListCommand(options) {
6453
+ function statusChip2(status) {
6454
+ if (status === "bound") return pc.green("\u25CF bound");
6455
+ if (status === "unbound") return pc.yellow("\u25CB unbound");
6456
+ if (status === "declined") return pc.dim("\u2298 declined");
6457
+ return pc.dim(`? ${status}`);
6458
+ }
6459
+ function opsLine(ops) {
6460
+ return ops.length > 0 ? ops.join(", ") : dim("(none)");
6461
+ }
6462
+ async function bindListCommand(options) {
5855
6463
  let ctx;
5856
6464
  try {
5857
- ctx = loadContext(options);
6465
+ ctx = loadContext2(options);
5858
6466
  } catch (err) {
5859
6467
  fail(err.message);
5860
6468
  return 1;
5861
6469
  }
5862
- let keys;
6470
+ let bindings;
5863
6471
  try {
5864
- const resp = await listApiKeys(ctx.installationId, ctx.requestOptions);
5865
- keys = resp.api_keys;
6472
+ bindings = await getInstallationBindings(ctx.installationId, ctx.requestOptions);
5866
6473
  } catch (err) {
5867
- return surfaceApiKeysError(err);
6474
+ return surfaceBindingError(err);
5868
6475
  }
5869
- const filtered = options.all ? keys : keys.filter((k) => k.revoked_at === null);
5870
6476
  if (options.json) {
5871
- process.stdout.write(`${JSON.stringify(filtered, null, 2)}
5872
- `);
6477
+ out(JSON.stringify(bindings, null, 2));
5873
6478
  return 0;
5874
6479
  }
5875
- if (filtered.length === 0) {
6480
+ if (bindings.length === 0) {
6481
+ info(`No workspace-table ${code("uses")} slots declared for ${code(ctx.slug)}.`);
5876
6482
  info(
5877
- options.all ? `No API keys (active or revoked) for ${code(ctx.slug)}.` : `No active API keys for ${code(ctx.slug)}. Run ${code("dashwise api-keys create <name>")} to mint one, or ${code("dashwise api-keys list --all")} to include revoked.`
6483
+ `Declare one by hand in ${code("module.json#uses.tables")}, or on-ramp an existing table with ${code("dashwise uses add --from-table <name>")}.`
5878
6484
  );
5879
6485
  return 0;
5880
6486
  }
5881
- info(`API keys for ${code(ctx.slug)} (install id=${ctx.installationId}):`);
5882
- for (const k of filtered) {
5883
- const status = formatKeyStatus(k);
5884
- const lastUsed = k.last_used_at ? `last used ${formatRelative(new Date(k.last_used_at))}` : "never used";
5885
- process.stdout.write(
5886
- ` ${code(`#${k.id}`)} ${k.name} ${dim(`dwk_${k.key_prefix}\u2026`)} ${status} ${dim(`(${lastUsed})`)}
5887
- `
6487
+ const unbound = bindings.filter((b) => b.status === "unbound").length;
6488
+ info(
6489
+ `Workspace-table bindings for ${code(ctx.slug)} ${dim(
6490
+ `(${bindings.length} declared${unbound > 0 ? `, ${unbound} unbound` : ""})`
6491
+ )}:`
6492
+ );
6493
+ out("");
6494
+ for (const b of bindings) {
6495
+ const enforced = b.ops.filter((o) => (b.consented_ops ?? []).includes(o));
6496
+ const opsSummary = b.status === "bound" && enforced.length !== b.ops.length ? `${opsLine(enforced)} ${dim(`(declared ${opsLine(b.ops)}; re-bind to consent to the rest)`)}` : opsLine(b.ops);
6497
+ out(` ${statusChip2(b.status)} ${code(b.uses_slug)} ${dim(`[${opsSummary}]`)}`);
6498
+ if (b.purpose) {
6499
+ out(` ${dim("purpose:")} ${b.purpose}`);
6500
+ }
6501
+ if (b.status === "bound" && b.table) {
6502
+ out(` ${dim("table:")} ${b.table.name} ${dim(`(id ${b.table.id})`)}`);
6503
+ const mapped = b.field_map ? Object.keys(b.field_map).length : 0;
6504
+ if (mapped > 0) {
6505
+ out(` ${dim("fields:")} ${mapped} mapped`);
6506
+ }
6507
+ }
6508
+ if (b.status === "unbound") {
6509
+ out(` ${dim("reason:")} ${b.reason ?? dim("(unspecified)")}`);
6510
+ }
6511
+ if (b.missing_fields && b.missing_fields.length > 0) {
6512
+ out(
6513
+ ` ${pc.yellow("missing:")} ${b.missing_fields.join(", ")} ${dim("(required field(s) not mapped)")}`
6514
+ );
6515
+ }
6516
+ out("");
6517
+ }
6518
+ if (unbound > 0) {
6519
+ info(
6520
+ `Connect a parked slot with ${code("dashwise bind <uses-slug> --table <name>")} (or ${code("--create")} to materialize a fresh table).`
6521
+ );
6522
+ }
6523
+ return 0;
6524
+ }
6525
+ async function bindCommand(usesSlug, options) {
6526
+ if (!usesSlug || typeof usesSlug !== "string") {
6527
+ fail(
6528
+ "Usage: `dashwise bind <uses-slug> [--table <name-or-id>] [--map <usesField>=<physicalFieldName>...]`, `--create`, or `--decline`."
6529
+ );
6530
+ return 1;
6531
+ }
6532
+ const modes = [options.create, options.decline, options.table !== void 0].filter(
6533
+ Boolean
6534
+ ).length;
6535
+ if (modes > 1) {
6536
+ fail(
6537
+ `Pass at most one of ${code("--table")}, ${code("--create")}, ${code("--decline")} \u2014 they select different bind modes.`
6538
+ );
6539
+ return 1;
6540
+ }
6541
+ if (options.decline && (options.map ?? []).length > 0) {
6542
+ fail(`${code("--map")} makes no sense with ${code("--decline")} (nothing is being bound).`);
6543
+ return 1;
6544
+ }
6545
+ let ctx;
6546
+ try {
6547
+ ctx = loadContext2(options);
6548
+ } catch (err) {
6549
+ fail(err.message);
6550
+ return 1;
6551
+ }
6552
+ if (options.decline) {
6553
+ return declineSlot(ctx, usesSlug);
6554
+ }
6555
+ if (options.create) {
6556
+ return bindViaCreate(ctx, usesSlug);
6557
+ }
6558
+ let mapOverrides;
6559
+ try {
6560
+ mapOverrides = parseMapOverrides(options.map);
6561
+ } catch (err) {
6562
+ fail(err.message);
6563
+ return 1;
6564
+ }
6565
+ let candidates;
6566
+ try {
6567
+ candidates = await getBindingCandidates(
6568
+ ctx.installationId,
6569
+ usesSlug,
6570
+ ctx.requestOptions
6571
+ );
6572
+ } catch (err) {
6573
+ return surfaceBindingError(err);
6574
+ }
6575
+ const chosen = await chooseCandidate(candidates, usesSlug, options.table);
6576
+ if (chosen === "cancelled") {
6577
+ info("Cancelled.");
6578
+ return 130;
6579
+ }
6580
+ if (chosen === null) {
6581
+ return 1;
6582
+ }
6583
+ let fieldMap;
6584
+ try {
6585
+ fieldMap = await buildFieldMap(ctx, chosen, mapOverrides);
6586
+ } catch (err) {
6587
+ if (err instanceof ApiError) return surfaceBindingError(err);
6588
+ fail(err.message);
6589
+ return 1;
6590
+ }
6591
+ let result;
6592
+ try {
6593
+ result = await putBinding(
6594
+ ctx.installationId,
6595
+ usesSlug,
6596
+ fieldMap !== void 0 ? { table_id: chosen.table_id, field_map: fieldMap } : { table_id: chosen.table_id },
6597
+ ctx.requestOptions
6598
+ );
6599
+ } catch (err) {
6600
+ return surfacePutBindingError(err, usesSlug, ctx.slug);
6601
+ }
6602
+ reportBoundOk(result, ctx.slug);
6603
+ return 0;
6604
+ }
6605
+ async function chooseCandidate(candidates, usesSlug, tableFlag) {
6606
+ if (candidates.length === 0) {
6607
+ fail(
6608
+ `No workspace table can satisfy ${code(usesSlug)} yet. Create a matching table (or ${code(`dashwise bind ${usesSlug} --create`)} to materialize one from the declared fields).`
6609
+ );
6610
+ return null;
6611
+ }
6612
+ if (tableFlag !== void 0) {
6613
+ const match = matchCandidate(candidates, tableFlag);
6614
+ if (!match) {
6615
+ const list2 = candidates.map((c2) => ` - ${c2.name} ${dim(`(id ${c2.table_id}, score ${c2.match_score})`)}`).join("\n");
6616
+ fail(
6617
+ `No candidate table matching ${code(tableFlag)} for ${code(usesSlug)}. Candidates:
6618
+ ${list2}`
6619
+ );
6620
+ return null;
6621
+ }
6622
+ return match;
6623
+ }
6624
+ if (candidates.length === 1) {
6625
+ return candidates[0];
6626
+ }
6627
+ if (process.stdout.isTTY) {
6628
+ const choice = await select({
6629
+ message: `Which workspace table should ${usesSlug} bind to?`,
6630
+ options: candidates.map((c2) => ({
6631
+ value: String(c2.table_id),
6632
+ label: c2.name,
6633
+ hint: c2.missing_required.length > 0 ? `id=${c2.table_id}, score=${c2.match_score}, missing: ${c2.missing_required.join(", ")}` : `id=${c2.table_id}, score=${c2.match_score}, full coverage`
6634
+ }))
6635
+ });
6636
+ if (isCancel(choice)) return "cancelled";
6637
+ return candidates.find((c2) => String(c2.table_id) === choice) ?? null;
6638
+ }
6639
+ const list = candidates.map((c2) => ` - ${c2.name} ${dim(`(id ${c2.table_id}, score ${c2.match_score})`)}`).join("\n");
6640
+ fail(
6641
+ `${candidates.length} candidate tables for ${code(usesSlug)}. Re-run with ${code("--table <name-or-id>")} to pick one:
6642
+ ${list}`
6643
+ );
6644
+ return null;
6645
+ }
6646
+ function matchCandidate(candidates, flag) {
6647
+ const asNumber = Number.parseInt(flag, 10);
6648
+ const isNumeric = !Number.isNaN(asNumber) && String(asNumber) === flag;
6649
+ if (isNumeric) {
6650
+ return candidates.find((c2) => c2.table_id === asNumber);
6651
+ }
6652
+ return candidates.find((c2) => c2.name === flag);
6653
+ }
6654
+ async function buildFieldMap(ctx, candidate, overrides) {
6655
+ const overrideKeys = Object.keys(overrides);
6656
+ if (overrideKeys.length === 0) {
6657
+ return Object.keys(candidate.auto_field_map).length > 0 ? { ...candidate.auto_field_map } : void 0;
6658
+ }
6659
+ const table = await introspectWorkspaceTable(
6660
+ ctx.installationId,
6661
+ String(candidate.table_id),
6662
+ ctx.requestOptions
6663
+ );
6664
+ const byName = /* @__PURE__ */ new Map();
6665
+ for (const f of table.fields) {
6666
+ byName.set(f.slug, f.id);
6667
+ if (f.name) byName.set(f.name, f.id);
6668
+ }
6669
+ const merged = { ...candidate.auto_field_map };
6670
+ for (const [usesField, physicalName] of Object.entries(overrides)) {
6671
+ const id = byName.get(physicalName);
6672
+ if (id === void 0) {
6673
+ const available = table.fields.map((f) => f.name && f.name !== f.slug ? `${f.slug} (${f.name})` : f.slug).join(", ");
6674
+ throw new Error(
6675
+ `--map ${usesField}=${physicalName}: table "${table.name}" has no field named "${physicalName}". Available: ${available}.`
6676
+ );
6677
+ }
6678
+ merged[usesField] = id;
6679
+ }
6680
+ return merged;
6681
+ }
6682
+ function parseMapOverrides(specs) {
6683
+ const out2 = {};
6684
+ for (const spec of specs ?? []) {
6685
+ const eq = spec.indexOf("=");
6686
+ if (eq <= 0 || eq === spec.length - 1) {
6687
+ throw new Error(
6688
+ `Invalid --map "${spec}". Expected \`<usesField>=<physicalFieldName>\` (e.g. \`--map name=full_name\`).`
6689
+ );
6690
+ }
6691
+ const usesField = spec.slice(0, eq).trim();
6692
+ const physicalName = spec.slice(eq + 1).trim();
6693
+ if (!usesField || !physicalName) {
6694
+ throw new Error(`Invalid --map "${spec}": both sides must be non-empty.`);
6695
+ }
6696
+ if (out2[usesField] !== void 0) {
6697
+ throw new Error(`Duplicate --map for uses field "${usesField}".`);
6698
+ }
6699
+ out2[usesField] = physicalName;
6700
+ }
6701
+ return out2;
6702
+ }
6703
+ async function bindViaCreate(ctx, usesSlug) {
6704
+ const slot = findUsesSlot(ctx, usesSlug);
6705
+ if (slot && slot.if_missing !== void 0 && slot.if_missing !== "create") {
6706
+ warn(
6707
+ `${code(usesSlug)} declares ${code(`if_missing: "${slot.if_missing}"`)}, not ${code('"create"')} \u2014 the backend may reject ${code("--create")}. Set it to ${code("create")} in module.json if you want a table materialized.`
6708
+ );
6709
+ }
6710
+ let result;
6711
+ try {
6712
+ result = await putBinding(
6713
+ ctx.installationId,
6714
+ usesSlug,
6715
+ { create: true },
6716
+ ctx.requestOptions
6717
+ );
6718
+ } catch (err) {
6719
+ return surfacePutBindingError(err, usesSlug, ctx.slug);
6720
+ }
6721
+ if (result.table) {
6722
+ success(
6723
+ `Materialized ${code(result.table.name)} ${dim(`(id ${result.table.id})`)} and bound ${code(usesSlug)} for ${code(ctx.slug)}.`
6724
+ );
6725
+ info(
6726
+ `The new table is a plain user-owned Data Hub table \u2014 it stays in the workspace when the module is uninstalled.`
6727
+ );
6728
+ } else {
6729
+ success(`Bound ${code(usesSlug)} for ${code(ctx.slug)} to a freshly-created table.`);
6730
+ }
6731
+ return 0;
6732
+ }
6733
+ async function declineSlot(ctx, usesSlug) {
6734
+ try {
6735
+ await declineBinding(ctx.installationId, usesSlug, ctx.requestOptions);
6736
+ success(`Declined ${code(usesSlug)} for ${code(ctx.slug)} \u2014 the module runs without it.`);
6737
+ return 0;
6738
+ } catch (err) {
6739
+ return surfacePutBindingError(err, usesSlug, ctx.slug);
6740
+ }
6741
+ }
6742
+ function findUsesSlot(ctx, usesSlug) {
6743
+ try {
6744
+ const manifest = readManifestOrThrow(ctx.dir);
6745
+ return manifest.uses?.tables?.find((t) => t.slug === usesSlug);
6746
+ } catch {
6747
+ return void 0;
6748
+ }
6749
+ }
6750
+ function reportBoundOk(result, moduleSlug) {
6751
+ const tableLine = result.table ? ` \u2192 ${result.table.name} ${dim(`(id ${result.table.id})`)}` : "";
6752
+ success(`Bound ${code(result.uses_slug)} for ${code(moduleSlug)}${tableLine}.`);
6753
+ const consented = result.consented_ops ?? [];
6754
+ info(
6755
+ `Consented ops: ${opsLine(consented)}. Runtime reads/writes against this slot now succeed.`
6756
+ );
6757
+ if (result.missing_fields && result.missing_fields.length > 0) {
6758
+ warn(
6759
+ `Still unmapped required field(s): ${result.missing_fields.join(", ")}. Re-run with ${code("--map <usesField>=<physicalFieldName>")} to map them.`
6760
+ );
6761
+ }
6762
+ }
6763
+ async function unbindCommand(usesSlug, options) {
6764
+ if (!usesSlug || typeof usesSlug !== "string") {
6765
+ fail("Usage: `dashwise unbind <uses-slug>`");
6766
+ return 1;
6767
+ }
6768
+ let ctx;
6769
+ try {
6770
+ ctx = loadContext2(options);
6771
+ } catch (err) {
6772
+ fail(err.message);
6773
+ return 1;
6774
+ }
6775
+ try {
6776
+ await unbindBinding(ctx.installationId, usesSlug, ctx.requestOptions);
6777
+ success(`Unbound ${code(usesSlug)} for ${code(ctx.slug)}.`);
6778
+ warn(
6779
+ `Runtime reads/writes against this slot now return ${code("USES_UNBOUND")} until you ${code("dashwise bind")} it again.`
6780
+ );
6781
+ return 0;
6782
+ } catch (err) {
6783
+ return surfaceBindingError(err);
6784
+ }
6785
+ }
6786
+ function surfacePutBindingError(err, usesSlug, moduleSlug) {
6787
+ if (err instanceof ApiError) {
6788
+ switch (err.code) {
6789
+ case "TABLE_NOT_IN_WORKSPACE":
6790
+ fail(
6791
+ `The chosen table isn't in ${code(moduleSlug)}'s workspace (${err.message}). A ${code("uses")} binding can only target a table the install's workspace owns.`
6792
+ );
6793
+ return 1;
6794
+ case "TABLE_IS_MODULE_MANAGED":
6795
+ fail(
6796
+ `That table is module-managed \u2014 ${code("uses")} bindings target plain WORKSPACE tables only. Reach a module's own tables via ${code("dashwise deps")} instead.`
6797
+ );
6798
+ return 1;
6799
+ case "MISSING_REQUIRED_FIELDS":
6800
+ fail(
6801
+ `The table is missing required field(s) the ${code(usesSlug)} contract needs (${err.message}). Map them explicitly with ${code("--map <usesField>=<physicalFieldName>")}, or pick a table that has them.`
6802
+ );
6803
+ return 1;
6804
+ case "FIELD_TYPE_INCOMPATIBLE":
6805
+ fail(
6806
+ `A mapped physical field's type is incompatible with the declared abstract type (${err.message}). Map to a compatible field with ${code("--map")}.`
6807
+ );
6808
+ return 1;
6809
+ case "FIELD_NOT_FOUND":
6810
+ fail(
6811
+ `A ${code("--map")} referenced a physical field that doesn't exist on the table (${err.message}). Check the field name.`
6812
+ );
6813
+ return 1;
6814
+ case "DECLINE_NOT_ALLOWED":
6815
+ fail(
6816
+ `${code(usesSlug)} is a required need \u2014 it can't be declined. Bind it to a table (${code(`dashwise bind ${usesSlug} --table <name>`)}) or set ${code('if_missing: "optional"')} in module.json.`
6817
+ );
6818
+ return 1;
6819
+ case "USES_SLOT_NOT_DECLARED":
6820
+ case "BINDING_NOT_FOUND":
6821
+ fail(
6822
+ `${code(moduleSlug)}'s module.json doesn't declare a ${code("uses")} slot named ${code(usesSlug)}. Add it (or ${code("dashwise uses add --from-table <name>")}) + push the manifest first.`
6823
+ );
6824
+ return 1;
6825
+ case "NOT_WORKSPACE_ADMIN":
6826
+ case "FORBIDDEN":
6827
+ fail(`Not authorized. Binding / unbinding / declining requires workspace-admin.`);
6828
+ return 1;
6829
+ default:
6830
+ fail(
6831
+ `Could not bind ${code(usesSlug)}: ${err.code}${err.message ? ` \u2014 ${err.message}` : ""}. The binding is unchanged.`
6832
+ );
6833
+ return 1;
6834
+ }
6835
+ }
6836
+ fail(`Bind failed: ${err.message}`);
6837
+ return 1;
6838
+ }
6839
+ function surfaceBindingError(err) {
6840
+ if (err instanceof ApiError) {
6841
+ switch (err.code) {
6842
+ case "UNAUTHENTICATED":
6843
+ fail(
6844
+ `Auth rejected by backend. Your CLI token may be stale \u2014 run ${code("dashwise login")} then retry.`
6845
+ );
6846
+ return 1;
6847
+ case "NOT_WORKSPACE_MEMBER":
6848
+ fail(
6849
+ `You're not a member of this installation's workspace. Binding management requires workspace membership.`
6850
+ );
6851
+ return 1;
6852
+ case "NOT_WORKSPACE_ADMIN":
6853
+ case "FORBIDDEN":
6854
+ fail(`Not authorized. Binding / unbinding requires workspace-admin.`);
6855
+ return 1;
6856
+ case "INSTALLATION_NOT_FOUND":
6857
+ fail(
6858
+ `Installation not found. It may have been destroyed; re-provision via ${code("dashwise init <slug>")}.`
6859
+ );
6860
+ return 1;
6861
+ case "USES_SLOT_NOT_DECLARED":
6862
+ case "BINDING_NOT_FOUND":
6863
+ fail(
6864
+ `No such ${code("uses")} slot declared in this module's manifest. Run ${code("dashwise bind")} to see declared slots.`
6865
+ );
6866
+ return 1;
6867
+ default:
6868
+ fail(`Backend rejected: ${err.code} ${err.status} \u2014 ${err.message}`);
6869
+ return 1;
6870
+ }
6871
+ }
6872
+ fail(`Binding operation failed: ${err.message}`);
6873
+ return 1;
6874
+ }
6875
+
6876
+ // src/commands/uses/add.ts
6877
+ init_api_client();
6878
+ init_config();
6879
+ init_manifest_io();
6880
+
6881
+ // src/lib/uses-types.ts
6882
+ var PHYSICAL_TO_ABSTRACT = {
6883
+ text: "text",
6884
+ long_text: "long_text",
6885
+ number: "number",
6886
+ rating: "number",
6887
+ boolean: "boolean",
6888
+ // A date-with-time column has physical registry type `date` (with
6889
+ // `config.includeTime`), NOT a distinct `datetime` type — the backend
6890
+ // registry has no `datetime` key. So introspection only ever reports
6891
+ // `date` here; there is no `datetime` physical entry to fold.
6892
+ date: "date",
6893
+ email: "email",
6894
+ url: "url",
6895
+ single_select: "single_select",
6896
+ multi_select: "multi_select"
6897
+ };
6898
+ function abstractTypeForPhysical(physicalType) {
6899
+ return PHYSICAL_TO_ABSTRACT[physicalType] ?? null;
6900
+ }
6901
+ var USES_OPERATIONS = ["create", "read", "update", "delete"];
6902
+
6903
+ // src/commands/uses/add.ts
6904
+ init_output();
6905
+ async function usesAddCommand(options) {
6906
+ if (!options.fromTable) {
6907
+ fail("Usage: `dashwise uses add --from-table <name-or-id>`");
6908
+ return 1;
6909
+ }
6910
+ let ops;
6911
+ try {
6912
+ ops = parseOps(options.ops);
6913
+ } catch (err) {
6914
+ fail(err.message);
6915
+ return 1;
6916
+ }
6917
+ let ctx;
6918
+ try {
6919
+ ctx = loadContext3(options);
6920
+ } catch (err) {
6921
+ fail(err.message);
6922
+ return 1;
6923
+ }
6924
+ let table;
6925
+ try {
6926
+ table = await introspectWorkspaceTable(
6927
+ ctx.installationId,
6928
+ options.fromTable,
6929
+ ctx.requestOptions
6930
+ );
6931
+ } catch (err) {
6932
+ return surfaceIntrospectError(err, options.fromTable);
6933
+ }
6934
+ const slotSlug = options.slug ?? slugify(table.name);
6935
+ if (!isKebab(slotSlug)) {
6936
+ fail(
6937
+ `Generated slot slug ${code(slotSlug)} isn't kebab-case. Pass ${code("--slug <kebab-slug>")} explicitly.`
6938
+ );
6939
+ return 1;
6940
+ }
6941
+ const { fields, skipped } = generateFields(table.fields);
6942
+ if (fields.length === 0) {
6943
+ fail(
6944
+ `Table ${code(table.name)} has no fields representable as a v1 ${code("uses")} contract (all are link_row / formula / ai / file). Nothing to declare.`
6945
+ );
6946
+ return 1;
6947
+ }
6948
+ const slot = {
6949
+ slug: slotSlug,
6950
+ ops,
6951
+ fields,
6952
+ if_missing: "require"
6953
+ };
6954
+ if (options.purpose) slot.purpose = options.purpose;
6955
+ info(
6956
+ `Generated a ${code("uses")} contract ${code(slotSlug)} from table ${code(table.name)} ${dim(`(id ${table.table_id})`)}:`
6957
+ );
6958
+ out(` ${dim("ops:")} ${ops.join(", ")}`);
6959
+ for (const f of fields) {
6960
+ const req = f.required ? dim(" (required)") : "";
6961
+ out(` ${dim("field:")} ${code(f.slug)} \u2192 ${f.types.join(" | ")}${req}`);
6962
+ }
6963
+ if (skipped.length > 0) {
6964
+ warn(
6965
+ `Skipped ${skipped.length} field(s) not representable in a v1 uses contract: ${skipped.join(", ")}.`
6966
+ );
6967
+ }
6968
+ const uses = ctx.manifest.uses ?? { tables: [] };
6969
+ const tables = uses.tables ?? [];
6970
+ const existingIdx = tables.findIndex((t) => t.slug === slotSlug);
6971
+ if (existingIdx >= 0 && !options.yes) {
6972
+ fail(
6973
+ `A ${code("uses")} slot ${code(slotSlug)} already exists in module.json. Re-run with ${code("--yes")} to replace it, or pass a different ${code("--slug")}.`
6974
+ );
6975
+ return 1;
6976
+ }
6977
+ if (existingIdx >= 0) {
6978
+ tables[existingIdx] = slot;
6979
+ } else {
6980
+ tables.push(slot);
6981
+ }
6982
+ uses.tables = tables;
6983
+ ctx.manifest.uses = uses;
6984
+ try {
6985
+ writeManifest(ctx.manifest, ctx.dir);
6986
+ } catch (err) {
6987
+ fail(`Could not write manifest: ${err.message}`);
6988
+ return 1;
6989
+ }
6990
+ success(
6991
+ `${existingIdx >= 0 ? "Replaced" : "Added"} ${code(`uses.tables[${slotSlug}]`)} in module.json.`
6992
+ );
6993
+ const pushed = await pushManifest(ctx);
6994
+ if (!pushed) {
6995
+ info(
6996
+ `The manifest edit is saved locally. Push it (${code("dashwise dev")} watches + applies, or re-run once the backend is reachable), then ${code(`dashwise bind ${slotSlug} --table ${table.table_id}`)}.`
6997
+ );
6998
+ return 1;
6999
+ }
7000
+ if (options.noBind) {
7001
+ info(
7002
+ `Skipped auto-bind (${code("--no-bind")}). Connect it when ready: ${code(`dashwise bind ${slotSlug} --table ${table.table_id}`)}.`
7003
+ );
7004
+ return 0;
7005
+ }
7006
+ info(`Binding ${code(slotSlug)} to ${code(table.name)}\u2026`);
7007
+ const bindExit = await bindCommand(slotSlug, {
7008
+ ...ctx.dir !== void 0 ? { dir: ctx.dir } : {},
7009
+ table: String(table.table_id)
7010
+ });
7011
+ if (bindExit !== 0) {
7012
+ warn(
7013
+ `The ${code("uses")} slot was declared + pushed, but the auto-bind didn't complete. Retry with ${code(`dashwise bind ${slotSlug} --table ${table.table_id}`)}.`
7014
+ );
7015
+ return bindExit;
7016
+ }
7017
+ return 0;
7018
+ }
7019
+ function loadContext3(opts) {
7020
+ const manifest = readManifestOrThrow(opts.dir);
7021
+ const moduleSlug = manifest.module?.slug;
7022
+ if (!moduleSlug) {
7023
+ throw new Error("module.json has no module.slug \u2014 the manifest is malformed.");
7024
+ }
7025
+ const cfg = readConfig();
7026
+ if (!cfg) {
7027
+ throw new Error("Not logged in. Run `dashwise login` first.");
7028
+ }
7029
+ const entry = readModuleEntry(moduleSlug);
7030
+ if (!entry) {
7031
+ throw new Error(
7032
+ `No dev install cached for module "${moduleSlug}". Run \`dashwise init ${moduleSlug} --force\` to provision one.`
7033
+ );
7034
+ }
7035
+ return {
7036
+ installationId: entry.installationId,
7037
+ moduleSlug,
7038
+ ...opts.dir !== void 0 ? { dir: opts.dir } : {},
7039
+ manifest,
7040
+ requestOptions: {
7041
+ baseUrl: resolveApiUrl(),
7042
+ auth: { token: cfg.token }
7043
+ }
7044
+ };
7045
+ }
7046
+ async function pushManifest(ctx) {
7047
+ let moduleId;
7048
+ try {
7049
+ const mod = await getModuleBySlug(ctx.moduleSlug, ctx.requestOptions);
7050
+ moduleId = mod.id;
7051
+ } catch (err) {
7052
+ if (err instanceof ApiError && err.status === 404) {
7053
+ warn(
7054
+ `Module ${code(ctx.moduleSlug)} isn't registered on the backend yet. Run ${code("dashwise module register")} (or ${code("dashwise init")}) before on-ramping a table.`
7055
+ );
7056
+ return false;
7057
+ }
7058
+ warn(`Couldn't resolve the module id to push the manifest: ${err.message}`);
7059
+ return false;
7060
+ }
7061
+ try {
7062
+ await applyDraftManifest(
7063
+ moduleId,
7064
+ ctx.manifest,
7065
+ ctx.requestOptions
7066
+ );
7067
+ success("Pushed the manifest \u2014 the backend parked the new slot, ready to bind.");
7068
+ return true;
7069
+ } catch (err) {
7070
+ if (err instanceof ApiError) {
7071
+ if (err.code === "MANIFEST_INVALID" || err.status === 400) {
7072
+ fail(`Manifest rejected by the backend validator: ${err.message}`);
7073
+ const issues = extractIssues(err.context);
7074
+ for (const issue of issues) out(` ${dim("-")} ${issue}`);
7075
+ } else {
7076
+ fail(`Manifest push failed: ${err.code} ${err.status} \u2014 ${err.message}`);
7077
+ }
7078
+ } else {
7079
+ fail(`Manifest push failed: ${err.message}`);
7080
+ }
7081
+ return false;
7082
+ }
7083
+ }
7084
+ function extractIssues(context) {
7085
+ if (!context) return [];
7086
+ const raw = context.issues;
7087
+ if (!Array.isArray(raw)) return [];
7088
+ return raw.map((i) => {
7089
+ if (typeof i === "string") return i;
7090
+ if (i && typeof i === "object") {
7091
+ const o = i;
7092
+ const path = typeof o.path === "string" ? o.path : "";
7093
+ const message = typeof o.message === "string" ? o.message : JSON.stringify(i);
7094
+ return path ? `${path}: ${message}` : message;
7095
+ }
7096
+ return String(i);
7097
+ });
7098
+ }
7099
+ function generateFields(physical) {
7100
+ const fields = [];
7101
+ const skipped = [];
7102
+ for (const f of physical) {
7103
+ const abstract = abstractTypeForPhysical(f.type);
7104
+ if (abstract === null) {
7105
+ skipped.push(`${f.slug} (${f.type})`);
7106
+ continue;
7107
+ }
7108
+ const entry = { slug: f.slug, types: [abstract] };
7109
+ if (f.required) entry.required = true;
7110
+ fields.push(entry);
7111
+ }
7112
+ return { fields, skipped };
7113
+ }
7114
+ function parseOps(opsCsv) {
7115
+ if (opsCsv === void 0) return ["read"];
7116
+ const parsed = opsCsv.split(",").map((o) => o.trim()).filter(Boolean);
7117
+ if (parsed.length === 0) {
7118
+ throw new Error(`--ops must list at least one of ${USES_OPERATIONS.join(", ")}.`);
7119
+ }
7120
+ const seen = /* @__PURE__ */ new Set();
7121
+ for (const op of parsed) {
7122
+ if (!USES_OPERATIONS.includes(op)) {
7123
+ throw new Error(
7124
+ `Invalid --ops value "${op}". Allowed: ${USES_OPERATIONS.join(", ")}.`
7125
+ );
7126
+ }
7127
+ seen.add(op);
7128
+ }
7129
+ return USES_OPERATIONS.filter((o) => seen.has(o));
7130
+ }
7131
+ function slugify(name) {
7132
+ return name.trim().toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
7133
+ }
7134
+ var KEBAB_REGEX = /^[a-z][a-z0-9-]*[a-z0-9]$/;
7135
+ function isKebab(slug) {
7136
+ return KEBAB_REGEX.test(slug);
7137
+ }
7138
+ function surfaceIntrospectError(err, tableRef) {
7139
+ if (err instanceof ApiError) {
7140
+ switch (err.code) {
7141
+ case "NON_NUMERIC_TABLE_REF":
7142
+ fail(err.message);
7143
+ return 1;
7144
+ case "NOT_FOUND":
7145
+ fail(
7146
+ `No workspace table with id ${code(tableRef)} is visible to you. Check the id (find it in the Dashwise UI URL, or via ${code("GET /api/tables/application/:applicationId")}).`
7147
+ );
7148
+ return 1;
7149
+ case "TABLE_IS_MODULE_MANAGED":
7150
+ fail(
7151
+ `Table ${code(tableRef)} is module-managed \u2014 ${code("uses")} is for plain WORKSPACE tables. Reach a module's own tables via ${code("dashwise deps")} instead.`
7152
+ );
7153
+ return 1;
7154
+ case "UNAUTHENTICATED":
7155
+ fail(
7156
+ `Auth rejected by backend. Your CLI token may be stale \u2014 run ${code("dashwise login")} then retry.`
7157
+ );
7158
+ return 1;
7159
+ case "NOT_WORKSPACE_MEMBER":
7160
+ case "FORBIDDEN":
7161
+ fail(`You're not a member of the workspace that owns table ${code(tableRef)}.`);
7162
+ return 1;
7163
+ default:
7164
+ fail(`Backend rejected: ${err.code} ${err.status} \u2014 ${err.message}`);
7165
+ return 1;
7166
+ }
7167
+ }
7168
+ fail(`Table introspection failed: ${err.message}`);
7169
+ return 1;
7170
+ }
7171
+
7172
+ // src/commands/pull.ts
7173
+ init_api_client();
7174
+ init_manifest_io();
7175
+ init_schema_command_context();
7176
+ init_output();
7177
+ async function pullCommand(options) {
7178
+ let ctx;
7179
+ try {
7180
+ ctx = loadSchemaCommandContext({
7181
+ ...options.dir !== void 0 ? { dir: options.dir } : {}
7182
+ });
7183
+ } catch (err) {
7184
+ fail(err.message);
7185
+ return 1;
7186
+ }
7187
+ info(`Pulling manifest for installation ${ctx.entry.installationId}\u2026`);
7188
+ let remoteManifest;
7189
+ try {
7190
+ const response = await getInstallationSchema(ctx.requestOptions);
7191
+ remoteManifest = response.manifest;
7192
+ } catch (err) {
7193
+ return surfacePullFailure(err);
7194
+ }
7195
+ const localJson = JSON.stringify(ctx.manifest, null, 2) + "\n";
7196
+ const remoteJson = JSON.stringify(remoteManifest, null, 2) + "\n";
7197
+ if (localJson === remoteJson) {
7198
+ success("Already in sync \u2014 local module.json matches backend.");
7199
+ return 0;
7200
+ }
7201
+ const localBytes = Buffer.byteLength(localJson, "utf8");
7202
+ const remoteBytes = Buffer.byteLength(remoteJson, "utf8");
7203
+ const delta = remoteBytes - localBytes;
7204
+ const deltaStr = delta === 0 ? "0 bytes" : `${delta > 0 ? "+" : ""}${delta} bytes`;
7205
+ if (options.dryRun) {
7206
+ info(
7207
+ `Drift detected (${deltaStr}). ${code("--dry-run")} \u2014 not writing.`
7208
+ );
7209
+ info(`Run ${code("dashwise pull")} without ${code("--dry-run")} to apply.`);
7210
+ return 0;
7211
+ }
7212
+ try {
7213
+ writeManifest(remoteManifest, ctx.projectRoot);
7214
+ } catch (err) {
7215
+ fail(`Failed to write ${manifestPath(ctx.projectRoot)}: ${err.message}`);
7216
+ return 1;
7217
+ }
7218
+ success(
7219
+ `Synced module.json from backend (${deltaStr}). ${dim(`Run \`dashwise module generate\` to refresh typed SDK if your watcher isn't running.`)}`
7220
+ );
7221
+ return 0;
7222
+ }
7223
+ function surfacePullFailure(err) {
7224
+ if (err instanceof ApiError) {
7225
+ switch (err.code) {
7226
+ case "UNAUTHENTICATED":
7227
+ fail(
7228
+ `Runtime token rejected. The cached token for this module may be expired \u2014 re-run \`dashwise init <slug> --force\` to mint a fresh one.`
7229
+ );
7230
+ break;
7231
+ case "SANDBOX_TOKEN_NOT_ACCEPTED":
7232
+ fail(
7233
+ `Schema-read endpoint requires a local-dev runtime token (kind=local). The cached token is the wrong kind \u2014 re-run \`dashwise init <slug> --force\`.`
7234
+ );
7235
+ break;
7236
+ case "INSTALL_MISMATCH":
7237
+ fail(
7238
+ `Runtime token is bound to a different installation than expected. The cached install id may be stale \u2014 re-run \`dashwise init <slug> --force\`.`
7239
+ );
7240
+ break;
7241
+ case "INSTALLATION_NOT_FOUND":
7242
+ fail(
7243
+ `The backend has no record of this installation. It may have been destroyed (GC, manual cleanup). Re-run \`dashwise init <slug>\` to provision a fresh one.`
7244
+ );
7245
+ break;
7246
+ default:
7247
+ fail(`Backend rejected: ${err.code} ${err.status} \u2014 ${err.message}`);
7248
+ }
7249
+ return 1;
7250
+ }
7251
+ fail(`Pull failed: ${err.message}`);
7252
+ return 1;
7253
+ }
7254
+
7255
+ // src/bin.ts
7256
+ init_scaffold();
7257
+
7258
+ // src/commands/api-keys.ts
7259
+ init_api_client();
7260
+ init_config();
7261
+ init_manifest_io();
7262
+ init_output();
7263
+ function loadContext4(opts) {
7264
+ const manifest = readManifestOrThrow(opts.dir);
7265
+ const slug = manifest.module?.slug;
7266
+ if (!slug) {
7267
+ throw new Error(
7268
+ `module.json has no module.slug \u2014 the manifest is malformed.`
7269
+ );
7270
+ }
7271
+ const cfg = readConfig();
7272
+ if (!cfg) {
7273
+ throw new Error("Not logged in. Run `dashwise login` first.");
7274
+ }
7275
+ const entry = readModuleEntry(slug);
7276
+ if (!entry) {
7277
+ throw new Error(
7278
+ `No dev install cached for module "${slug}". Run \`dashwise init ${slug} --force\` to provision one.`
7279
+ );
7280
+ }
7281
+ return {
7282
+ installationId: entry.installationId,
7283
+ slug,
7284
+ requestOptions: {
7285
+ baseUrl: resolveApiUrl(),
7286
+ auth: { token: cfg.token }
7287
+ }
7288
+ };
7289
+ }
7290
+ async function apiKeysListCommand(options) {
7291
+ let ctx;
7292
+ try {
7293
+ ctx = loadContext4(options);
7294
+ } catch (err) {
7295
+ fail(err.message);
7296
+ return 1;
7297
+ }
7298
+ let keys;
7299
+ try {
7300
+ const resp = await listApiKeys(ctx.installationId, ctx.requestOptions);
7301
+ keys = resp.api_keys;
7302
+ } catch (err) {
7303
+ return surfaceApiKeysError(err);
7304
+ }
7305
+ const filtered = options.all ? keys : keys.filter((k) => k.revoked_at === null);
7306
+ if (options.json) {
7307
+ process.stdout.write(`${JSON.stringify(filtered, null, 2)}
7308
+ `);
7309
+ return 0;
7310
+ }
7311
+ if (filtered.length === 0) {
7312
+ info(
7313
+ options.all ? `No API keys (active or revoked) for ${code(ctx.slug)}.` : `No active API keys for ${code(ctx.slug)}. Run ${code("dashwise api-keys create <name>")} to mint one, or ${code("dashwise api-keys list --all")} to include revoked.`
7314
+ );
7315
+ return 0;
7316
+ }
7317
+ info(`API keys for ${code(ctx.slug)} (install id=${ctx.installationId}):`);
7318
+ for (const k of filtered) {
7319
+ const status = formatKeyStatus(k);
7320
+ const lastUsed = k.last_used_at ? `last used ${formatRelative(new Date(k.last_used_at))}` : "never used";
7321
+ process.stdout.write(
7322
+ ` ${code(`#${k.id}`)} ${k.name} ${dim(`dwk_${k.key_prefix}\u2026`)} ${status} ${dim(`(${lastUsed})`)}
7323
+ `
5888
7324
  );
7325
+ const grantLines = formatGrants(k.grants);
7326
+ if (grantLines.length > 0) {
7327
+ for (const line of grantLines) {
7328
+ process.stdout.write(` ${dim(line)}
7329
+ `);
7330
+ }
7331
+ } else if (k.grants !== void 0) {
7332
+ process.stdout.write(` ${dim("full install access (no grants)")}
7333
+ `);
7334
+ }
5889
7335
  }
5890
7336
  return 0;
5891
7337
  }
@@ -5895,20 +7341,30 @@ async function apiKeysCreateCommand(nameArg, options) {
5895
7341
  fail("Usage: `dashwise api-keys create <name>` (e.g. `dashwise api-keys create production`)");
5896
7342
  return 1;
5897
7343
  }
5898
- let ctx;
7344
+ let grants;
5899
7345
  try {
5900
- ctx = loadContext(options);
7346
+ grants = parseGrants(options.grant);
5901
7347
  } catch (err) {
5902
7348
  fail(err.message);
5903
7349
  return 1;
5904
7350
  }
5905
- const body = { name };
5906
- if (options.scopes !== void 0) {
5907
- body.scopes = options.scopes.split(",").map((s) => s.trim()).filter(Boolean);
7351
+ let expiresAt;
7352
+ try {
7353
+ expiresAt = resolveExpiresAt(options.expires);
7354
+ } catch (err) {
7355
+ fail(err.message);
7356
+ return 1;
5908
7357
  }
5909
- if (options.expiresAt !== void 0) {
5910
- body.expires_at = options.expiresAt;
7358
+ let ctx;
7359
+ try {
7360
+ ctx = loadContext4(options);
7361
+ } catch (err) {
7362
+ fail(err.message);
7363
+ return 1;
5911
7364
  }
7365
+ const body = { name };
7366
+ if (grants !== void 0) body.grants = grants;
7367
+ if (expiresAt !== void 0) body.expires_at = expiresAt;
5912
7368
  let resp;
5913
7369
  try {
5914
7370
  resp = await createApiKey(ctx.installationId, body, ctx.requestOptions);
@@ -5927,6 +7383,13 @@ async function apiKeysCreateCommand(nameArg, options) {
5927
7383
  success(
5928
7384
  `Created API key ${code(`#${resp.api_key.id}`)} (${resp.api_key.name}) for ${code(ctx.slug)}.`
5929
7385
  );
7386
+ const grantLines = formatGrants(resp.api_key.grants);
7387
+ if (grantLines.length > 0) {
7388
+ info(` ${dim("scope:")} scoped to`);
7389
+ for (const line of grantLines) info(` ${line}`);
7390
+ } else {
7391
+ info(` ${dim("scope:")} full install access (no grants)`);
7392
+ }
5930
7393
  info("");
5931
7394
  warn(
5932
7395
  "This is the ONLY time the full key is displayed. Save it now \u2014 the backend stores a hash, not the plaintext."
@@ -5935,14 +7398,9 @@ async function apiKeysCreateCommand(nameArg, options) {
5935
7398
  process.stdout.write(` ${code(resp.raw_key)}
5936
7399
  `);
5937
7400
  info("");
5938
- info(`Use in a deployed module's env:`);
7401
+ info(`Use in a deployed module's env (the two-var contract):`);
7402
+ info(` ${code(`DASHWISE_API_URL=${resolveApiUrl()}`)}`);
5939
7403
  info(` ${code(`DASHWISE_API_KEY=${resp.raw_key.slice(0, 12)}\u2026`)}`);
5940
- info(
5941
- ` ${code(`DASHWISE_INSTALLATION_ID=${ctx.installationId}`)}`
5942
- );
5943
- info(
5944
- ` ${code(`DASHWISE_API_URL=${resolveApiUrl()}`)}`
5945
- );
5946
7404
  return 0;
5947
7405
  }
5948
7406
  async function apiKeysRevokeCommand(keyIdArg, options) {
@@ -5957,7 +7415,7 @@ async function apiKeysRevokeCommand(keyIdArg, options) {
5957
7415
  }
5958
7416
  let ctx;
5959
7417
  try {
5960
- ctx = loadContext(options);
7418
+ ctx = loadContext4(options);
5961
7419
  } catch (err) {
5962
7420
  fail(err.message);
5963
7421
  return 1;
@@ -5991,6 +7449,17 @@ function surfaceApiKeysError(err) {
5991
7449
  `You're not a member of this installation's workspace. API-key management requires workspace membership.`
5992
7450
  );
5993
7451
  return 1;
7452
+ case "NOT_WORKSPACE_ADMIN":
7453
+ case "FORBIDDEN":
7454
+ fail(
7455
+ `Not authorized. Minting scoped keys (with --grant) or keys on non-local installs requires workspace-admin; members can mint full-access keys on local dev installs only.`
7456
+ );
7457
+ return 1;
7458
+ case "INVALID_GRANT":
7459
+ fail(
7460
+ `A --grant referenced a table/app the backend rejected (${err.message}). Data Hub grants need a table or app that exists in this workspace; module grants need a table your manifest owns.`
7461
+ );
7462
+ return 1;
5994
7463
  case "INSTALLATION_NOT_FOUND":
5995
7464
  fail(
5996
7465
  `Installation not found. It may have been destroyed; re-provision via ${code("dashwise init <slug>")}.`
@@ -6009,6 +7478,117 @@ function surfaceApiKeysError(err) {
6009
7478
  fail(`API-key operation failed: ${err.message}`);
6010
7479
  return 1;
6011
7480
  }
7481
+ var GRANT_OPERATIONS = ["create", "read", "update", "delete"];
7482
+ function parseGrants(specs) {
7483
+ if (!specs || specs.length === 0) return void 0;
7484
+ return specs.map((spec) => parseGrantSpec(spec));
7485
+ }
7486
+ function parseGrantSpec(spec) {
7487
+ const raw = spec.trim();
7488
+ const parts = raw.split(":");
7489
+ const source = parts[0];
7490
+ if (source === "module") {
7491
+ if (parts.length !== 3) {
7492
+ throw new Error(
7493
+ `Invalid --grant "${spec}". Module grants are \`module:<tableSlug>:<ops>\` (e.g. \`module:tasks:read,create\`).`
7494
+ );
7495
+ }
7496
+ const tableSlug = parts[1];
7497
+ if (!tableSlug) {
7498
+ throw new Error(`Invalid --grant "${spec}": missing table slug.`);
7499
+ }
7500
+ return {
7501
+ source: "module",
7502
+ tableSlug,
7503
+ operations: parseOps2(parts[2], spec)
7504
+ };
7505
+ }
7506
+ if (source === "datahub") {
7507
+ const scope = parts[1];
7508
+ if (!scope) {
7509
+ throw new Error(
7510
+ `Invalid --grant "${spec}". Data Hub grants are \`datahub:table=<id>:<ops>\`, \`datahub:app=<id>:<ops>\`, or \`datahub:workspace:<ops>\`.`
7511
+ );
7512
+ }
7513
+ if (scope === "workspace") {
7514
+ if (parts.length !== 3) {
7515
+ throw new Error(
7516
+ `Invalid --grant "${spec}". Workspace-wide Data Hub grants are \`datahub:workspace:<ops>\`.`
7517
+ );
7518
+ }
7519
+ return { source: "datahub", operations: parseOps2(parts[2], spec) };
7520
+ }
7521
+ const eq = scope.indexOf("=");
7522
+ const scopeKind = eq === -1 ? scope : scope.slice(0, eq);
7523
+ const scopeVal = eq === -1 ? "" : scope.slice(eq + 1);
7524
+ if (scopeKind !== "table" && scopeKind !== "app" || scopeVal === "") {
7525
+ throw new Error(
7526
+ `Invalid --grant "${spec}". Data Hub scope must be \`table=<id>\`, \`app=<id>\`, or \`workspace\`.`
7527
+ );
7528
+ }
7529
+ if (parts.length !== 3) {
7530
+ throw new Error(
7531
+ `Invalid --grant "${spec}". Data Hub grants are \`datahub:${scopeKind}=<id>:<ops>\`.`
7532
+ );
7533
+ }
7534
+ const id = Number.parseInt(scopeVal, 10);
7535
+ if (!Number.isInteger(id) || id <= 0 || String(id) !== scopeVal) {
7536
+ throw new Error(
7537
+ `Invalid --grant "${spec}": ${scopeKind} id must be a positive integer (got "${scopeVal}").`
7538
+ );
7539
+ }
7540
+ const operations = parseOps2(parts[2], spec);
7541
+ return scopeKind === "table" ? { source: "datahub", tableId: id, operations } : { source: "datahub", applicationId: id, operations };
7542
+ }
7543
+ throw new Error(
7544
+ `Invalid --grant "${spec}": unknown source "${source}". Use \`module:\u2026\` or \`datahub:\u2026\`.`
7545
+ );
7546
+ }
7547
+ function parseOps2(opsCsv, spec) {
7548
+ const ops = opsCsv.split(",").map((o) => o.trim()).filter(Boolean);
7549
+ if (ops.length === 0) {
7550
+ throw new Error(
7551
+ `Invalid --grant "${spec}": at least one operation is required (${GRANT_OPERATIONS.join(", ")}).`
7552
+ );
7553
+ }
7554
+ const seen = /* @__PURE__ */ new Set();
7555
+ for (const op of ops) {
7556
+ if (!GRANT_OPERATIONS.includes(op)) {
7557
+ throw new Error(
7558
+ `Invalid --grant "${spec}": unknown operation "${op}". Allowed: ${GRANT_OPERATIONS.join(", ")}.`
7559
+ );
7560
+ }
7561
+ seen.add(op);
7562
+ }
7563
+ return GRANT_OPERATIONS.filter((op) => seen.has(op));
7564
+ }
7565
+ function resolveExpiresAt(days) {
7566
+ if (days === void 0) return void 0;
7567
+ if (!Number.isFinite(days) || days <= 0) {
7568
+ throw new Error(`--expires must be a positive number of days (got ${days}).`);
7569
+ }
7570
+ return new Date(Date.now() + days * 24 * 60 * 60 * 1e3).toISOString();
7571
+ }
7572
+ function formatGrants(grants) {
7573
+ if (!grants || grants.length === 0) return [];
7574
+ const byScope = /* @__PURE__ */ new Map();
7575
+ for (const g of grants) {
7576
+ const label = grantScopeLabel(g);
7577
+ const existing = byScope.get(label);
7578
+ if (existing) existing.ops.add(g.operation);
7579
+ else byScope.set(label, { label, ops: /* @__PURE__ */ new Set([g.operation]) });
7580
+ }
7581
+ return [...byScope.values()].map((s) => {
7582
+ const ops = GRANT_OPERATIONS.filter((o) => s.ops.has(o));
7583
+ return `${s.label}: ${ops.join(", ")}`;
7584
+ });
7585
+ }
7586
+ function grantScopeLabel(g) {
7587
+ if (g.source === "module") return `module ${g.table_slug ?? "?"}`;
7588
+ if (g.table_id != null) return `datahub table#${g.table_id}`;
7589
+ if (g.application_id != null) return `datahub app#${g.application_id}`;
7590
+ return "datahub workspace";
7591
+ }
6012
7592
  function formatKeyStatus(k) {
6013
7593
  if (k.revoked_at) return dim("[revoked]");
6014
7594
  if (k.expires_at && new Date(k.expires_at).getTime() <= Date.now()) {
@@ -6028,6 +7608,190 @@ function formatRelative(d) {
6028
7608
  return `${day}d ago`;
6029
7609
  }
6030
7610
 
7611
+ // src/commands/origins.ts
7612
+ init_api_client();
7613
+ init_config();
7614
+ init_manifest_io();
7615
+ init_output();
7616
+ function loadContext5(opts) {
7617
+ const manifest = readManifestOrThrow(opts.dir);
7618
+ const slug = manifest.module?.slug;
7619
+ if (!slug) {
7620
+ throw new Error(
7621
+ `module.json has no module.slug \u2014 the manifest is malformed.`
7622
+ );
7623
+ }
7624
+ const cfg = readConfig();
7625
+ if (!cfg) {
7626
+ throw new Error("Not logged in. Run `dashwise login` first.");
7627
+ }
7628
+ const entry = readModuleEntry(slug);
7629
+ if (!entry) {
7630
+ throw new Error(
7631
+ `No dev install cached for module "${slug}". Run \`dashwise init ${slug} --force\` to provision one.`
7632
+ );
7633
+ }
7634
+ return {
7635
+ installationId: entry.installationId,
7636
+ slug,
7637
+ requestOptions: {
7638
+ baseUrl: resolveApiUrl(),
7639
+ auth: { token: cfg.token }
7640
+ }
7641
+ };
7642
+ }
7643
+ function normalizeOrigin(raw) {
7644
+ const trimmed = raw.trim();
7645
+ try {
7646
+ return new URL(trimmed).origin;
7647
+ } catch {
7648
+ return trimmed;
7649
+ }
7650
+ }
7651
+ async function originsListCommand(options) {
7652
+ let ctx;
7653
+ try {
7654
+ ctx = loadContext5(options);
7655
+ } catch (err) {
7656
+ fail(err.message);
7657
+ return 1;
7658
+ }
7659
+ let origins;
7660
+ try {
7661
+ const resp = await getOrigins(ctx.installationId, ctx.requestOptions);
7662
+ origins = resp.allowed_origins;
7663
+ } catch (err) {
7664
+ return surfaceOriginsError(err);
7665
+ }
7666
+ if (options.json) {
7667
+ process.stdout.write(`${JSON.stringify(origins, null, 2)}
7668
+ `);
7669
+ return 0;
7670
+ }
7671
+ info(
7672
+ `SSO callback origins for ${code(ctx.slug)} ${dim(
7673
+ "(localhost is always allowed for local dev installs)"
7674
+ )}:`
7675
+ );
7676
+ if (origins.length === 0) {
7677
+ info(` ${dim("(none configured \u2014 only implicit localhost applies)")}`);
7678
+ info("");
7679
+ info(
7680
+ `Add a deployed domain with ${code("dashwise origins add https://your-domain")}.`
7681
+ );
7682
+ return 0;
7683
+ }
7684
+ for (const o of origins) {
7685
+ process.stdout.write(` ${o}
7686
+ `);
7687
+ }
7688
+ return 0;
7689
+ }
7690
+ async function originsAddCommand(urlArg, options) {
7691
+ if (!urlArg) {
7692
+ fail("Usage: `dashwise origins add <url>` (e.g. `dashwise origins add https://app.example.com`)");
7693
+ return 1;
7694
+ }
7695
+ const origin = normalizeOrigin(urlArg);
7696
+ let ctx;
7697
+ try {
7698
+ ctx = loadContext5(options);
7699
+ } catch (err) {
7700
+ fail(err.message);
7701
+ return 1;
7702
+ }
7703
+ let current;
7704
+ try {
7705
+ current = (await getOrigins(ctx.installationId, ctx.requestOptions)).allowed_origins;
7706
+ } catch (err) {
7707
+ return surfaceOriginsError(err);
7708
+ }
7709
+ if (current.some((o) => normalizeOrigin(o) === origin)) {
7710
+ info(`${code(origin)} is already allowlisted for ${code(ctx.slug)}. Nothing to do.`);
7711
+ return 0;
7712
+ }
7713
+ const next = [...current, origin];
7714
+ try {
7715
+ const resp = await setOrigins(ctx.installationId, next, ctx.requestOptions);
7716
+ success(`Added ${code(origin)} to the SSO callback allowlist for ${code(ctx.slug)}.`);
7717
+ info(` ${dim(`${resp.allowed_origins.length} origin(s) now configured.`)}`);
7718
+ } catch (err) {
7719
+ return surfaceOriginsError(err);
7720
+ }
7721
+ return 0;
7722
+ }
7723
+ async function originsRemoveCommand(urlArg, options) {
7724
+ if (!urlArg) {
7725
+ fail("Usage: `dashwise origins remove <url>`");
7726
+ return 1;
7727
+ }
7728
+ const origin = normalizeOrigin(urlArg);
7729
+ let ctx;
7730
+ try {
7731
+ ctx = loadContext5(options);
7732
+ } catch (err) {
7733
+ fail(err.message);
7734
+ return 1;
7735
+ }
7736
+ let current;
7737
+ try {
7738
+ current = (await getOrigins(ctx.installationId, ctx.requestOptions)).allowed_origins;
7739
+ } catch (err) {
7740
+ return surfaceOriginsError(err);
7741
+ }
7742
+ const next = current.filter((o) => normalizeOrigin(o) !== origin);
7743
+ if (next.length === current.length) {
7744
+ fail(
7745
+ `${code(origin)} is not in the allowlist for ${code(ctx.slug)}. Run ${code("dashwise origins list")} to see current entries.`
7746
+ );
7747
+ return 1;
7748
+ }
7749
+ try {
7750
+ await setOrigins(ctx.installationId, next, ctx.requestOptions);
7751
+ success(`Removed ${code(origin)} from the SSO callback allowlist for ${code(ctx.slug)}.`);
7752
+ } catch (err) {
7753
+ return surfaceOriginsError(err);
7754
+ }
7755
+ return 0;
7756
+ }
7757
+ function surfaceOriginsError(err) {
7758
+ if (err instanceof ApiError) {
7759
+ switch (err.code) {
7760
+ case "UNAUTHENTICATED":
7761
+ fail(
7762
+ `Auth rejected by backend. Your CLI token may be stale \u2014 run ${code("dashwise login")} then retry.`
7763
+ );
7764
+ return 1;
7765
+ case "NOT_WORKSPACE_MEMBER":
7766
+ fail(
7767
+ `You're not a member of this installation's workspace. Origins management requires workspace membership.`
7768
+ );
7769
+ return 1;
7770
+ case "NOT_WORKSPACE_ADMIN":
7771
+ case "FORBIDDEN":
7772
+ fail(
7773
+ `Not authorized. Managing SSO callback origins requires workspace-admin.`
7774
+ );
7775
+ return 1;
7776
+ case "INVALID_ORIGIN":
7777
+ fail(
7778
+ `The backend rejected an origin (${err.message}). Non-localhost origins must be absolute https:// URLs.`
7779
+ );
7780
+ return 1;
7781
+ case "INSTALLATION_NOT_FOUND":
7782
+ fail(
7783
+ `Installation not found. It may have been destroyed; re-provision via ${code("dashwise init <slug>")}.`
7784
+ );
7785
+ return 1;
7786
+ default:
7787
+ fail(`Backend rejected: ${err.code} ${err.status} \u2014 ${err.message}`);
7788
+ return 1;
7789
+ }
7790
+ }
7791
+ fail(`Origins operation failed: ${err.message}`);
7792
+ return 1;
7793
+ }
7794
+
6031
7795
  // src/commands/env-export.ts
6032
7796
  init_api_client();
6033
7797
  init_config();
@@ -6117,16 +7881,6 @@ async function envExportCommand(options) {
6117
7881
  value: apiUrl,
6118
7882
  comment: "# DashWise backend URL \u2014 same value the CLI is logged into."
6119
7883
  },
6120
- {
6121
- key: "NEXT_PUBLIC_DASHWISE_API_URL",
6122
- value: apiUrl,
6123
- comment: "# Client-side mirror \u2014 Next.js inlines NEXT_PUBLIC_* into the browser bundle so <AuthProvider> hits the right origin."
6124
- },
6125
- {
6126
- key: "DASHWISE_INSTALLATION_ID",
6127
- value: String(installationId),
6128
- comment: `# Per-workspace install id for module "${slug}".`
6129
- },
6130
7884
  {
6131
7885
  key: "DASHWISE_API_KEY",
6132
7886
  value: apiKey,
@@ -6947,8 +8701,15 @@ moduleCmd.command("clone <slug>").description("Clone a published module locally.
6947
8701
  });
6948
8702
  }
6949
8703
  );
6950
- var depsCmd = program.command("deps").description("Cross-module dependency commands (manipulate module.json#dependencies).");
6951
- depsCmd.command("list").description("List declared cross-module dependencies.").option("--dir <path>", "Project root (default: current directory).").option("--json", "Emit JSON to stdout instead of human-readable columns.").action(async (cmdOpts) => {
8704
+ var depsCmd = program.command("deps").description(
8705
+ "Cross-module dependency commands. Bare `dashwise deps` shows the live connect status (resolved / unbound) of each declared dep on your installation; the subcommands manage module.json#dependencies (add / remove / list / check) and the connect lifecycle (bind / unbind)."
8706
+ ).option("--dir <path>", "Project root (default: current directory).").option("--json", "Emit the connect-status list as JSON instead of the human-readable view.").action(async (cmdOpts) => {
8707
+ process.exitCode = await depsConnectListCommand({
8708
+ ...cmdOpts.dir !== void 0 ? { dir: cmdOpts.dir } : {},
8709
+ ...cmdOpts.json !== void 0 ? { json: cmdOpts.json } : {}
8710
+ });
8711
+ });
8712
+ depsCmd.command("list").description("List declared cross-module dependencies (offline local module.json read).").option("--dir <path>", "Project root (default: current directory).").option("--json", "Emit JSON to stdout instead of human-readable columns.").action(async (cmdOpts) => {
6952
8713
  process.exitCode = await depsListCommand({
6953
8714
  ...cmdOpts.dir !== void 0 ? { dir: cmdOpts.dir } : {},
6954
8715
  ...cmdOpts.json !== void 0 ? { json: cmdOpts.json } : {}
@@ -6980,6 +8741,93 @@ depsCmd.command("check").description("Verify each declared dep still resolves to
6980
8741
  ...cmdOpts.json !== void 0 ? { json: cmdOpts.json } : {}
6981
8742
  });
6982
8743
  });
8744
+ depsCmd.command("bind <provider-slug>").description(
8745
+ "(Re-)connect a parked dependency to its provider installation. Re-runs resolution + snapshots the contract; on success runtime reads succeed. Fails (the dep stays unbound) with the park reason if the provider isn't installed / the version doesn't match / a table/action isn't exposed. Workspace-admin."
8746
+ ).option("--dir <path>", "Project root (default: current directory).").option(
8747
+ "--installation <id>",
8748
+ "Provider installation id to bind to (disambiguates when the workspace has more than one candidate install of the provider).",
8749
+ parseIntOption
8750
+ ).action(
8751
+ async (providerSlug, cmdOpts) => {
8752
+ process.exitCode = await depsBindCommand(providerSlug, {
8753
+ ...cmdOpts.dir !== void 0 ? { dir: cmdOpts.dir } : {},
8754
+ ...cmdOpts.installation !== void 0 ? { installation: cmdOpts.installation } : {}
8755
+ });
8756
+ }
8757
+ );
8758
+ depsCmd.command("unbind <provider-slug>").description(
8759
+ "Disconnect a bound dependency: NULLs the provider ref + contract snapshot and parks the dep (reason MANUALLY_UNBOUND). Runtime reads then return DEP_UNBOUND until you bind it again. Workspace-admin."
8760
+ ).option("--dir <path>", "Project root (default: current directory).").action(async (providerSlug, cmdOpts) => {
8761
+ process.exitCode = await depsUnbindCommand(providerSlug, {
8762
+ ...cmdOpts.dir !== void 0 ? { dir: cmdOpts.dir } : {}
8763
+ });
8764
+ });
8765
+ program.command("bind [uses-slug]").description(
8766
+ "Connect the module's declared workspace-table (`uses`) slots to real tables. Bare `dashwise bind` lists every slot's status (bound / unbound / declined) with reasons + missing fields. `dashwise bind <uses-slug> [--table <name-or-id>]` binds one (interactive pick when the match is ambiguous in a TTY); `--map <usesField>=<physicalFieldName>` overrides field mappings; `--create` materializes a fresh table; `--decline` declines an optional-needs slot. Bind/unbind/decline are workspace-admin."
8767
+ ).option("--dir <path>", "Project root (default: current directory).").option("--json", "With no slug: emit the binding-status list as JSON.").option(
8768
+ "--table <name-or-id>",
8769
+ "Workspace table (name or numeric id) to bind the slot to. Use for scripts / to skip the interactive pick."
8770
+ ).option(
8771
+ "--map <usesField=physicalFieldName>",
8772
+ "Override a field mapping (repeatable). The server auto-completes unmapped fields by name match.",
8773
+ collectRepeated,
8774
+ []
8775
+ ).option("--create", "Materialize a fresh Data Hub table from the declared fields + bind to it.").option("--decline", "Decline this slot (optional-needs only; the module runs without it).").action(
8776
+ async (usesSlug, cmdOpts) => {
8777
+ if (usesSlug === void 0) {
8778
+ process.exitCode = await bindListCommand({
8779
+ ...cmdOpts.dir !== void 0 ? { dir: cmdOpts.dir } : {},
8780
+ ...cmdOpts.json !== void 0 ? { json: cmdOpts.json } : {}
8781
+ });
8782
+ return;
8783
+ }
8784
+ process.exitCode = await bindCommand(usesSlug, {
8785
+ ...cmdOpts.dir !== void 0 ? { dir: cmdOpts.dir } : {},
8786
+ ...cmdOpts.table !== void 0 ? { table: cmdOpts.table } : {},
8787
+ ...cmdOpts.map !== void 0 && cmdOpts.map.length > 0 ? { map: cmdOpts.map } : {},
8788
+ ...cmdOpts.create !== void 0 ? { create: cmdOpts.create } : {},
8789
+ ...cmdOpts.decline !== void 0 ? { decline: cmdOpts.decline } : {}
8790
+ });
8791
+ }
8792
+ );
8793
+ program.command("unbind <uses-slug>").description(
8794
+ "Disconnect a bound workspace-table (`uses`) slot: NULLs the table ref + field map and parks the slot (reason MANUALLY_UNBOUND). Runtime reads/writes then return USES_UNBOUND until you bind it again. Workspace-admin."
8795
+ ).option("--dir <path>", "Project root (default: current directory).").action(async (usesSlug, cmdOpts) => {
8796
+ process.exitCode = await unbindCommand(usesSlug, {
8797
+ ...cmdOpts.dir !== void 0 ? { dir: cmdOpts.dir } : {}
8798
+ });
8799
+ });
8800
+ var usesCmd = program.command("uses").description("Workspace-table (`uses`) contract commands.");
8801
+ usesCmd.command("add").description(
8802
+ 'On-ramp an existing workspace table: introspect it, generate the `uses.tables` manifest entry (fields + abstract types, read op by default), write module.json, push (draft-apply reconciles + parks), then bind the new slot to that table. One command from "I have an Employees table" to typed access.'
8803
+ ).option("--dir <path>", "Project root (default: current directory).").requiredOption(
8804
+ "--from-table <id>",
8805
+ "The existing workspace table to generate a `uses` contract from \u2014 its NUMERIC id (find it in the Dashwise UI URL). Resolving by table name isn't wired yet."
8806
+ ).option(
8807
+ "--slug <slug>",
8808
+ "Override the generated `uses` slot slug (default: slugified table name)."
8809
+ ).option(
8810
+ "--ops <list>",
8811
+ "Comma-separated operations to declare (create,read,update,delete). Default: read."
8812
+ ).option(
8813
+ "--purpose <text>",
8814
+ "Optional author-facing explanation, shown to admins at bind/consent."
8815
+ ).option(
8816
+ "--no-bind",
8817
+ "Only write + push the manifest entry; don't attempt to bind it to the source table."
8818
+ ).option("-y, --yes", "Skip the overwrite-existing-slot confirmation prompt.").action(
8819
+ async (cmdOpts) => {
8820
+ process.exitCode = await usesAddCommand({
8821
+ ...cmdOpts.dir !== void 0 ? { dir: cmdOpts.dir } : {},
8822
+ fromTable: cmdOpts.fromTable,
8823
+ ...cmdOpts.slug !== void 0 ? { slug: cmdOpts.slug } : {},
8824
+ ...cmdOpts.ops !== void 0 ? { ops: cmdOpts.ops } : {},
8825
+ ...cmdOpts.purpose !== void 0 ? { purpose: cmdOpts.purpose } : {},
8826
+ ...cmdOpts.bind === false ? { noBind: true } : {},
8827
+ ...cmdOpts.yes !== void 0 ? { yes: cmdOpts.yes } : {}
8828
+ });
8829
+ }
8830
+ );
6983
8831
  program.command("query").description(
6984
8832
  'Run a typed query against the local module installation via @dashai/sdk/query. Pass one of `--inline "<expr>"`, `--file <path>`, or `--repl`. `qb`, `deps`, `db` are pre-bound to the local module\'s generated SDK.'
6985
8833
  ).option("--dir <path>", "Project root containing `module.json` (default: current directory).").option("--inline <expression>", "Evaluate a one-liner. e.g., `qb.tasks.where({done:false}).count()`.").option("--file <path>", "Dynamic-import a .mjs/.js file. Default export = Query | AggQuery | Promise | value.").option("--repl", "Open an interactive Node REPL with the generated SDK pre-bound. Top-level await supported.").option("--json", "Emit compact JSON (default: pretty-printed).").option(
@@ -7024,11 +8872,14 @@ apiKeysCmd.command("list").description(
7024
8872
  apiKeysCmd.command("create [name]").description(
7025
8873
  "Mint a new API key. The plaintext key is displayed ONCE \u2014 save it then."
7026
8874
  ).option("--dir <path>", "Project root (default: current directory).").option("--name <name>", "Override the positional name arg (e.g. for scripted use).").option(
7027
- "--scopes <csv>",
7028
- "Comma-separated scope strings (e.g. `runtime:read,runtime:write`). Default: full install access."
8875
+ "--grant <spec>",
8876
+ "Scoped grant (repeatable). `module:<tableSlug>:<ops>`, `datahub:table=<id>:<ops>`, `datahub:app=<id>:<ops>`, or `datahub:workspace:<ops>` where <ops> is a comma list of create,read,update,delete. Any grant mints a scoped (default-deny) key (workspace-admin required). Omit for a full-access key.",
8877
+ collectRepeated,
8878
+ []
7029
8879
  ).option(
7030
- "--expires-at <iso>",
7031
- "ISO-8601 expiry timestamp (e.g. 2026-12-31T23:59:59Z). Default: never expires."
8880
+ "--expires <days>",
8881
+ "Days until the key expires (converted to an absolute timestamp). Default: never expires.",
8882
+ parseIntOption
7032
8883
  ).option(
7033
8884
  "--raw-only",
7034
8885
  "Print only the plaintext key on stdout; chatter goes to stderr. Useful for shell capture: `KEY=$(dashwise api-keys create prod --raw-only)`"
@@ -7037,8 +8888,8 @@ apiKeysCmd.command("create [name]").description(
7037
8888
  process.exitCode = await apiKeysCreateCommand(nameArg, {
7038
8889
  ...cmdOpts.dir !== void 0 ? { dir: cmdOpts.dir } : {},
7039
8890
  ...cmdOpts.name !== void 0 ? { name: cmdOpts.name } : {},
7040
- ...cmdOpts.scopes !== void 0 ? { scopes: cmdOpts.scopes } : {},
7041
- ...cmdOpts.expiresAt !== void 0 ? { expiresAt: cmdOpts.expiresAt } : {},
8891
+ ...cmdOpts.grant !== void 0 && cmdOpts.grant.length > 0 ? { grant: cmdOpts.grant } : {},
8892
+ ...cmdOpts.expires !== void 0 ? { expires: cmdOpts.expires } : {},
7042
8893
  ...cmdOpts.rawOnly !== void 0 ? { rawOnly: cmdOpts.rawOnly } : {}
7043
8894
  });
7044
8895
  }
@@ -7053,6 +8904,27 @@ apiKeysCmd.command("revoke [key-id]").description(
7053
8904
  });
7054
8905
  }
7055
8906
  );
8907
+ var originsCmd = program.command("origins").description(
8908
+ "Manage the current module install's SSO callback-origin allowlist. Local dev (localhost) is always allowed; add deployed domains here."
8909
+ );
8910
+ originsCmd.command("list").description("Show the SSO callback origins configured for the current module install.").option("--dir <path>", "Project root (default: current directory).").option("--json", "Emit a JSON array instead of the human-friendly list.").action(async (cmdOpts) => {
8911
+ process.exitCode = await originsListCommand({
8912
+ ...cmdOpts.dir !== void 0 ? { dir: cmdOpts.dir } : {},
8913
+ ...cmdOpts.json !== void 0 ? { json: cmdOpts.json } : {}
8914
+ });
8915
+ });
8916
+ originsCmd.command("add <url>").description(
8917
+ "Add an SSO callback origin (e.g. https://app.example.com). Idempotent; non-localhost origins must be https://."
8918
+ ).option("--dir <path>", "Project root (default: current directory).").action(async (url, cmdOpts) => {
8919
+ process.exitCode = await originsAddCommand(url, {
8920
+ ...cmdOpts.dir !== void 0 ? { dir: cmdOpts.dir } : {}
8921
+ });
8922
+ });
8923
+ originsCmd.command("remove <url>").description("Remove an SSO callback origin from the allowlist.").option("--dir <path>", "Project root (default: current directory).").action(async (url, cmdOpts) => {
8924
+ process.exitCode = await originsRemoveCommand(url, {
8925
+ ...cmdOpts.dir !== void 0 ? { dir: cmdOpts.dir } : {}
8926
+ });
8927
+ });
7056
8928
  var envCmd = program.command("env").description("Environment-variable helpers for deploying a module.");
7057
8929
  envCmd.command("export").description(
7058
8930
  "Emit the env vars a deployed module needs to authenticate against DashWise. By default emits .env-style KEY=value lines; --format = env | json | railway | vercel | fly to wrap in platform-native syntax. Use --new-key <name> to mint + inline an API key in one step."
@@ -7266,7 +9138,7 @@ fieldCmd.command("set-type <table-slug> <field-slug> <new-type>").description(
7266
9138
  }
7267
9139
  })();
7268
9140
  function getVersion() {
7269
- return "0.8.0";
9141
+ return "0.9.0";
7270
9142
  }
7271
9143
  function parseIntOption(value) {
7272
9144
  const n = Number.parseInt(value, 10);