@dashai/cli 0.15.0 → 0.16.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
@@ -840,6 +840,7 @@ async function devCommand(options) {
840
840
  const apiKeyEnv = process.env.DASHWISE_API_KEY ?? moduleEntry?.apiKey;
841
841
  let installationIdEnv;
842
842
  let revokeDevSession;
843
+ let devHandoffEnv = {};
843
844
  if (explicitInstallationId && explicitInstallationId !== "local") {
844
845
  installationIdEnv = explicitInstallationId;
845
846
  } else if (reuseDevSessionId) ; else {
@@ -862,10 +863,14 @@ async function devCommand(options) {
862
863
  callback_origins: callbackOrigins
863
864
  });
864
865
  revokeDevSession = () => revokeDevSessionBestEffort(apiUrl, apiToken, session.id);
866
+ devHandoffEnv = devSessionHandoffEnv(session);
865
867
  info(` ${dim("dev session")} ${session.id}`);
866
868
  info(` ${dim("workspace")} ${workspaceContext.name} (id=${workspaceContext.id}, slug=${workspaceContext.slug})`);
867
869
  info(` ${dim("callbacks")} ${callbackOrigins.join(", ")}`);
868
870
  info(` ${dim("expires")} ${session.expires_at}`);
871
+ info(
872
+ ` ${dim("sign-in")} ${Object.keys(devHandoffEnv).length > 0 ? "zero-click (server-side dev handoff armed)" : "browser SSO (backend issued no handoff secret \u2014 pre-2026-07-15 backend)"}`
873
+ );
869
874
  } catch (err) {
870
875
  if (err instanceof ApiError) {
871
876
  fail(`Failed to register dev session: ${err.code} \u2014 ${err.message}`);
@@ -879,7 +884,8 @@ async function devCommand(options) {
879
884
  ...process.env,
880
885
  DASHWISE_API_URL: apiUrl,
881
886
  NEXT_PUBLIC_DASHWISE_API_URL: apiUrl,
882
- ...apiKeyEnv !== void 0 ? { DASHWISE_API_KEY: apiKeyEnv } : {}
887
+ ...apiKeyEnv !== void 0 ? { DASHWISE_API_KEY: apiKeyEnv } : {},
888
+ ...devHandoffEnv
883
889
  };
884
890
  info(`Dev loop starting \u2014 Ctrl-C to stop.`);
885
891
  info(` ${dim("api")} ${apiUrl}`);
@@ -1117,6 +1123,14 @@ async function resolveModuleApiKey(slug, entry, apiUrl, cliBearer) {
1117
1123
  success(`Upgraded ${code(slug)} to a dwk_ API key (id=${minted.api_key.id}).`);
1118
1124
  return upgraded;
1119
1125
  }
1126
+ function devSessionHandoffEnv(session) {
1127
+ const secret = session.handoff_secret;
1128
+ if (typeof secret !== "string" || secret.length === 0) return {};
1129
+ return {
1130
+ DASHWISE_DEV_SESSION_ID: session.id,
1131
+ DASHWISE_DEV_HANDOFF_SECRET: secret
1132
+ };
1133
+ }
1120
1134
  function computeCallbackOrigins(port, extra) {
1121
1135
  const def = `http://localhost:${port}`;
1122
1136
  return Array.from(/* @__PURE__ */ new Set([def, ...extra]));
@@ -1285,7 +1299,7 @@ function packageJson(opts) {
1285
1299
  start: "next start"
1286
1300
  },
1287
1301
  dependencies: {
1288
- "@dashai/sdk": "^2.0.0",
1302
+ "@dashai/sdk": "^2.3.0",
1289
1303
  next: "^15.0.0",
1290
1304
  react: "^19.0.0",
1291
1305
  "react-dom": "^19.0.0"
@@ -1953,8 +1967,20 @@ function appApiAuthSignInTs() {
1953
1967
  // 302 back to callback_url?code=<one-time>. The /api/auth/callback route
1954
1968
  // exchanges that code for a session cookie.
1955
1969
  //
1956
- // No dev-vs-prod branching, no installation id, no dev-session id \u2014 the
1957
- // single dwk_ key drives both local dev and production.
1970
+ // ZERO-CLICK LOCAL DEV (CLI \u2265 0.16.0 + backend \u2265 2026-07-15): when
1971
+ // \`dashwise dev\` injected BOTH DASHWISE_DEV_SESSION_ID and
1972
+ // DASHWISE_DEV_HANDOFF_SECRET, this route first presents the handoff
1973
+ // secret to the backend SERVER-SIDE (POST /api/auth/dev/sso/authorize)
1974
+ // and 302s the browser straight to the returned callback URL \u2014 the user
1975
+ // NEVER sees a login page and no platform browser session is needed. On
1976
+ // ANY failure it logs one server-side warn line and falls back to the
1977
+ // browser flow above unchanged (that path still matters for signing in
1978
+ // as a DIFFERENT workspace member and for stale dev sessions). The
1979
+ // secret is server-env-only: never in a URL, response body, or the
1980
+ // client bundle.
1981
+ //
1982
+ // No dev-vs-prod branching for the browser flow \u2014 the single dwk_ key
1983
+ // drives both local dev and production.
1958
1984
  import { NextResponse } from 'next/server';
1959
1985
  import { getPublicOrigin } from '@/lib/request-origin';
1960
1986
 
@@ -1974,6 +2000,30 @@ export async function GET(request: Request) {
1974
2000
  status: 500,
1975
2001
  });
1976
2002
  }
2003
+
2004
+ // \`getPublicOrigin\` reads \`X-Forwarded-Host\` when present so URLs
2005
+ // point at the public domain, not the internal Node origin behind
2006
+ // reverse proxies (Railway/Vercel/Fly).
2007
+ const moduleOrigin = getPublicOrigin(request);
2008
+
2009
+ // \u2500\u2500 Zero-click dev handoff (server-side; runs BEFORE the dwk_-key
2010
+ // guard \u2014 the authorize endpoint authenticates with the handoff
2011
+ // secret, not the key). Both env vars must be present: \`dashwise dev\`
2012
+ // injects them together, and only when the backend issued a secret.
2013
+ const devSessionId = process.env.DASHWISE_DEV_SESSION_ID;
2014
+ const devHandoffSecret = process.env.DASHWISE_DEV_HANDOFF_SECRET;
2015
+ if (devSessionId && devHandoffSecret) {
2016
+ const callbackUrl = await tryDevHandoff(
2017
+ apiBaseUrl,
2018
+ devSessionId,
2019
+ devHandoffSecret,
2020
+ returnTo,
2021
+ moduleOrigin,
2022
+ );
2023
+ if (callbackUrl) return NextResponse.redirect(callbackUrl);
2024
+ // null \u2192 warned server-side already; fall through to the browser flow.
2025
+ }
2026
+
1977
2027
  if (!apiKey) {
1978
2028
  return new NextResponse(
1979
2029
  'DASHWISE_API_KEY must be set in the environment. Run \`dashwise dev\` ' +
@@ -1983,12 +2033,9 @@ export async function GET(request: Request) {
1983
2033
  }
1984
2034
 
1985
2035
  // Absolute callback URL so the backend knows which host to bounce back
1986
- // to. \`getPublicOrigin\` reads \`X-Forwarded-Host\` when present so the
1987
- // URL points at the public domain, not the internal Node origin behind
1988
- // reverse proxies (Railway/Vercel/Fly). The callback path is fixed at
1989
- // /api/auth/callback by SDK convention; we tuck \`return_to\` onto it so
1990
- // the callback route can resume the user's original destination.
1991
- const moduleOrigin = getPublicOrigin(request);
2036
+ // to. The callback path is fixed at /api/auth/callback by SDK
2037
+ // convention; we tuck \`return_to\` onto it so the callback route can
2038
+ // resume the user's original destination.
1992
2039
  const callback = new URL('/api/auth/callback', moduleOrigin);
1993
2040
  callback.searchParams.set('return_to', returnTo);
1994
2041
 
@@ -2041,6 +2088,74 @@ export async function GET(request: Request) {
2041
2088
  }
2042
2089
  return NextResponse.redirect(body.redirect_url);
2043
2090
  }
2091
+
2092
+ /**
2093
+ * Zero-click dev sign-in (CLI \u2265 0.16.0 + backend \u2265 2026-07-15).
2094
+ *
2095
+ * SERVER-SIDE ONLY: presents the dev session's one-time handoff secret
2096
+ * to \`POST /api/auth/dev/sso/authorize\`. On 200 the backend mints a
2097
+ * single-use 60s exchange code for the DEV'S OWN user and returns
2098
+ * \`{ callback_url }\` (= \`<origin>/api/auth/callback?code=\u2026&return_to=\u2026\`)
2099
+ * \u2014 the caller 302s the browser straight there; the normal callback
2100
+ * route exchanges the code exactly like the browser flow.
2101
+ *
2102
+ * Returns the callback URL, or \`null\` on ANY failure (404
2103
+ * DEV_SESSION_NOT_FOUND, 401 DEV_HANDOFF_INVALID, 400 INVALID_RETURN_TO,
2104
+ * 403 WORKSPACE_NOT_MEMBER, network) after logging a one-line warn \u2014
2105
+ * the caller then falls back to the browser SSO flow, which remains the
2106
+ * path for signing in as a DIFFERENT workspace member and for stale dev
2107
+ * sessions. The secret must never appear in a URL or response body.
2108
+ */
2109
+ async function tryDevHandoff(
2110
+ apiBaseUrl: string,
2111
+ devSessionId: string,
2112
+ devHandoffSecret: string,
2113
+ returnTo: string,
2114
+ moduleOrigin: string,
2115
+ ): Promise<string | null> {
2116
+ // Same root-relative-only rule as the callback's safeReturnTo \u2014 an
2117
+ // absolute/protocol-relative return_to must not steer the handoff.
2118
+ const path =
2119
+ returnTo.startsWith('/') && !returnTo.startsWith('//') ? returnTo : '/';
2120
+ // \`return_to\` is the user's ABSOLUTE destination on this module's own
2121
+ // (loopback) origin; the backend validates origin allowlist + loopback
2122
+ // and builds the callback URL from it.
2123
+ const destination = new URL(path, moduleOrigin);
2124
+ let res: Response;
2125
+ try {
2126
+ res = await fetch(\`\${apiBaseUrl}/api/auth/dev/sso/authorize\`, {
2127
+ method: 'POST',
2128
+ headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
2129
+ body: JSON.stringify({
2130
+ dev_session_id: devSessionId,
2131
+ handoff_secret: devHandoffSecret,
2132
+ return_to: destination.toString(),
2133
+ }),
2134
+ });
2135
+ } catch (err) {
2136
+ console.warn(
2137
+ \`[auth] dev handoff failed (\${(err as Error).message}) \u2014 falling back to browser SSO\`,
2138
+ );
2139
+ return null;
2140
+ }
2141
+ if (!res.ok) {
2142
+ const body = (await res.json().catch(() => null)) as { code?: string } | null;
2143
+ console.warn(
2144
+ \`[auth] dev handoff rejected (\${body?.code ?? \`HTTP \${res.status}\`}) \u2014 falling back to browser SSO\`,
2145
+ );
2146
+ return null;
2147
+ }
2148
+ const body = (await res.json().catch(() => null)) as
2149
+ | { callback_url?: string }
2150
+ | null;
2151
+ if (!body?.callback_url) {
2152
+ console.warn(
2153
+ '[auth] dev handoff response missing callback_url \u2014 falling back to browser SSO',
2154
+ );
2155
+ return null;
2156
+ }
2157
+ return body.callback_url;
2158
+ }
2044
2159
  `;
2045
2160
  }
2046
2161
  function appApiAuthCallbackTs() {
@@ -2049,7 +2164,14 @@ function appApiAuthCallbackTs() {
2049
2164
  // SSO flow failed. Two paths:
2050
2165
  //
2051
2166
  // 1. Success path: exchange \`?code\` for a session cookie via
2052
- // \`exchangeCode()\`, then redirect to \`return_to\`.
2167
+ // \`exchangeCode()\`, then redirect to \`return_to\`. SDK \u2265 2.3.0:
2168
+ // the result's \`cookieWritten\` flag surfaces a cookie store that
2169
+ // rejected the write \u2014 without it the exchange "succeeds", the
2170
+ // browser has no session, and the user silently bounces back to
2171
+ // sign-in. We render /sign-in-error?code=cookie_write_failed
2172
+ // instead. \`requestUrl\` lets the SDK fall back to the actual
2173
+ // request protocol for the cookie's Secure attribute when no
2174
+ // x-forwarded-proto header is present (plain-http \`next start\`).
2053
2175
  // 2. Error path: SSO returned \`?error=\` \u2014 redirect to the polished
2054
2176
  // sign-in-error page so the user sees a real error UI instead of
2055
2177
  // a raw 4xx page. The error code maps to known SSO error codes
@@ -2086,7 +2208,17 @@ export async function GET(request: Request) {
2086
2208
  return NextResponse.redirect(target);
2087
2209
  }
2088
2210
  try {
2089
- await exchangeCode(code); // sets the dashwise.session cookie
2211
+ // Sets the dashwise.session cookie. \`requestUrl\` = the Secure-attr
2212
+ // protocol fallback when no x-forwarded-proto header is present.
2213
+ const result = await exchangeCode(code, { requestUrl: request.url });
2214
+ if (!result.cookieWritten) {
2215
+ // The exchange succeeded but the cookie store rejected the write \u2014
2216
+ // the browser has NO session. Surface it instead of redirecting
2217
+ // into an unauthenticated loop (SDK \u2265 2.3.0).
2218
+ const target = new URL('/sign-in-error', publicOrigin);
2219
+ target.searchParams.set('code', 'cookie_write_failed');
2220
+ return NextResponse.redirect(target);
2221
+ }
2090
2222
  } catch (err) {
2091
2223
  const target = new URL('/sign-in-error', publicOrigin);
2092
2224
  target.searchParams.set('code', 'exchange_failed');
@@ -2123,7 +2255,12 @@ import { signOut } from '@dashai/sdk/auth/server';
2123
2255
  import { getPublicOrigin } from '@/lib/request-origin';
2124
2256
 
2125
2257
  export async function POST(request: Request) {
2126
- await signOut();
2258
+ // Pass requestUrl so the cookie-CLEAR resolves its Secure attribute the
2259
+ // SAME way the sign-in SET did (exchangeCode also gets request.url) \u2014
2260
+ // otherwise a plain-http localhost dev server sets a non-Secure cookie
2261
+ // but tries to clear a Secure one, which the browser ignores, leaving a
2262
+ // stale (backend-revoked, but client-present) cookie after sign-out.
2263
+ await signOut({ requestUrl: request.url });
2127
2264
  // Build the redirect against the public origin so we don't land
2128
2265
  // the user on \`http://localhost:<PORT>/\` when running behind a
2129
2266
  // reverse proxy (Railway/Vercel/Fly).
@@ -2210,7 +2347,7 @@ function packageJsonCustom(opts) {
2210
2347
  start: "next start"
2211
2348
  },
2212
2349
  dependencies: {
2213
- "@dashai/sdk": "^2.0.0",
2350
+ "@dashai/sdk": "^2.3.0",
2214
2351
  next: "^15.0.0",
2215
2352
  react: "^19.0.0",
2216
2353
  "react-dom": "^19.0.0",
@@ -2711,6 +2848,8 @@ const ERROR_MESSAGES: Record<string, string> = {
2711
2848
  'The callback URL was missing the expected exchange code. This usually means the SSO flow was interrupted.',
2712
2849
  exchange_failed:
2713
2850
  "The exchange code couldn't be redeemed for a session. The code may have expired (they're single-use and short-lived) \u2014 try signing in again.",
2851
+ cookie_write_failed:
2852
+ 'Sign-in completed, but the session cookie could not be written, so the session would have been lost. This means exchangeCode ran outside a Route Handler / Server Action context (Next can only set cookies there) or next/headers was unavailable \u2014 verify /api/auth/callback is a standard route handler and check the module server logs.',
2714
2853
  };
2715
2854
 
2716
2855
  interface SignInErrorPageProps {
@@ -3517,6 +3656,14 @@ function surfaceProvisionApiError(err, slug) {
3517
3656
  `Not authorized to create a module in that workspace. Confirm you're a member at the DashWise dashboard.`
3518
3657
  );
3519
3658
  return;
3659
+ case "DEV_INSTALL_ALREADY_EXISTS": {
3660
+ const cached = readModuleEntry(slug);
3661
+ warn(
3662
+ `Module ${code(slug)} already has a live dev install${cached?.installationId ? ` (cached id=${cached.installationId})` : ""} \u2014 only ONE dev install per module can exist.
3663
+ Run ${code("dashwise module reset-dev-install")} for a clean slate (keeps ALL local files), or ${code("dashwise module destroy-dev-install")} then re-run init.`
3664
+ );
3665
+ return;
3666
+ }
3520
3667
  case "SLUG_ALREADY_EXISTS": {
3521
3668
  const cached = readModuleEntry(slug);
3522
3669
  if (cached) {
@@ -3526,9 +3673,9 @@ function surfaceProvisionApiError(err, slug) {
3526
3673
  );
3527
3674
  } else {
3528
3675
  warn(
3529
- `A module with slug ${code(slug)} already exists (published and/or production-installed). Local development for an already-published module is not currently possible \u2014 module slugs are GLOBAL, so this 409s in every workspace (a platform fix is planned). Interim options:
3530
- - Develop against the production install with a scoped key: ${code("dashwise api-keys create local-dev --install <id> --full-access")} + ${code("dashwise origins add http://localhost:3000 --install <id>")} (id via ${code(`dashwise module status ${slug}`)}), then run bare ${code("next dev")} \u2014 and CLEAN UP after (revoke the key, remove the origin).
3531
- - Or pick a NEW slug (duplicate the code) for greenfield iteration.`
3676
+ `A module with slug ${code(slug)} already exists.
3677
+ - If it belongs to ANOTHER workspace: module slugs are globally unique \u2014 pick a NEW slug (duplicate the code if needed).
3678
+ - If it's YOUR OWN published module: backends \u2265 2026-07-15 re-provision a dev install from this exact command (${code(`dashwise init ${slug} --workspace <ws>`)} \u2014 the dev install coexists with the production install), so this 409 means the backend predates that. Older-backend fallback (production repair): ${code("dashwise api-keys create local-dev --install <id> --full-access")} + ${code("dashwise origins add http://localhost:3000 --install <id>")} (id via ${code(`dashwise module status ${slug}`)}), then run bare ${code("next dev")} \u2014 and CLEAN UP after (revoke the key, remove the origin).`
3532
3679
  );
3533
3680
  }
3534
3681
  return;
@@ -6275,13 +6422,14 @@ async function moduleInstallCommand(slugArg, options) {
6275
6422
  return 0;
6276
6423
  }
6277
6424
  function publishedModuleLocalDevGuidance(slug) {
6278
- return `Local development for an already-published module is not currently possible \u2014 in ANY workspace (module slugs are global; a platform fix is planned). Interim options:
6425
+ return `Re-provision local dev any time with ${code(`dashwise init ${slug} --workspace <ws>`)} (or ${code("dashwise module reset-dev-install")}) \u2014 requires backend \u2265 2026-07-15; the fresh dev install COEXISTS with the production install.
6426
+ Older backends 409 SLUG_ALREADY_EXISTS on that path (no re-provision anywhere). Fallback for them (production repair):
6279
6427
  - Develop against the production install with a scoped key: ${code(`dashwise api-keys create local-dev --install <id> --full-access`)} + ${code(`dashwise origins add http://localhost:3000 --install <id>`)} (id via ${code(`dashwise module status ${slug}`)}), then run bare ${code("next dev")} \u2014 and CLEAN UP after (revoke the key, remove the origin).
6280
6428
  - Or duplicate the code under a NEW slug for greenfield iteration.`;
6281
6429
  }
6282
6430
  function printDevInstallAftermath(slug, workspaceSlug) {
6283
6431
  warn(
6284
- `Local dev for ${code(slug)} is now DISABLED (the dev install in ${code(workspaceSlug)} and its ${code("dwk_")} key were destroyed).
6432
+ `Local dev for ${code(slug)} is DISCONNECTED (the dev install in ${code(workspaceSlug)} and its ${code("dwk_")} key were destroyed).
6285
6433
  ${publishedModuleLocalDevGuidance(slug)}`
6286
6434
  );
6287
6435
  }
@@ -6305,9 +6453,9 @@ async function resolveDevInstallCollision(existing, slug, workspaceSlug, options
6305
6453
  return 1;
6306
6454
  }
6307
6455
  const explanation = `Workspace ${code(workspaceSlug)} already has the DEV install of ${code(slug)} (kind=local, id=${existing.id}) created by ${code("dashwise init")}.
6308
- Only ONE install per (workspace, module) can exist, and ${code("kind")} is immutable: a plain install would upgrade that row in place \u2014 the runtime would provision, but it would STAY a dev install, hidden from the app sidebar.
6456
+ Install ${code("kind")} is immutable: a plain install could land on that row's upgrade path \u2014 the runtime would provision, but it would STAY a dev install, hidden from the app sidebar.
6309
6457
  Options:
6310
- - ${code("--replace-dev-install")} \u2014 destroy the dev install, then fresh production install. Recommended when development is done. Note: this PERMANENTLY disables local dev for ${code(slug)} \u2014 module slugs are global, so a dev install cannot be re-provisioned in ANY workspace once the module is published (interim: a scoped key on the production install via ${code("api-keys create --install <id> --full-access")}, or duplicate the code under a NEW slug).
6458
+ - ${code("--replace-dev-install")} \u2014 destroy the dev install, then fresh production install. Recommended when development is done. Local dev can be re-provisioned afterwards with ${code(`dashwise init ${slug} --workspace <ws>`)} on backends \u2265 2026-07-15 (dev + production installs coexist); on OLDER backends this is one-way \u2014 no re-provision anywhere once published (interim: a scoped key on the production install via ${code("api-keys create --install <id> --full-access")}, or duplicate the code under a NEW slug).
6311
6459
  - ${code("--workspace <other>")} \u2014 deploy to a separate test/client workspace and keep this dev install. Recommended for ongoing development.`;
6312
6460
  if (options.replaceDevInstall) {
6313
6461
  info(explanation);
@@ -6321,7 +6469,7 @@ async function resolveDevInstallCollision(existing, slug, workspaceSlug, options
6321
6469
  {
6322
6470
  value: "replace",
6323
6471
  label: "Replace \u2014 destroy the dev install, then production-install",
6324
- hint: "local dev for this module is permanently disabled afterwards (interim repair: scoped key on the production install)"
6472
+ hint: "revokes local-dev credentials; re-provision later via `dashwise init` (backend \u2265 2026-07-15 \u2014 older backends: scoped-key repair only)"
6325
6473
  },
6326
6474
  { value: "cancel", label: "Cancel \u2014 I will deploy to another workspace" }
6327
6475
  ],
@@ -11100,7 +11248,7 @@ fieldCmd.command("set-type <table-slug> <field-slug> <new-type>").description(
11100
11248
  }
11101
11249
  })();
11102
11250
  function getVersion() {
11103
- return "0.15.0";
11251
+ return "0.16.0";
11104
11252
  }
11105
11253
  function parseIntOption(value) {
11106
11254
  const n = Number.parseInt(value, 10);