@dashai/cli 0.15.0 → 0.17.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
@@ -464,6 +464,19 @@ async function applyDraftManifest(moduleId, manifest, options = {}) {
464
464
  options
465
465
  );
466
466
  }
467
+ async function getRuntimeLogs(installationId, opts = {}, options = {}) {
468
+ const params = new URLSearchParams();
469
+ if (opts.since !== void 0 && opts.since !== "") params.set("since", opts.since);
470
+ if (opts.limit !== void 0) params.set("limit", String(opts.limit));
471
+ if (opts.tail) params.set("tail", "true");
472
+ const qs = params.toString();
473
+ return apiRequest(
474
+ "GET",
475
+ `/api/modules/installations/${installationId}/runtime-logs${qs ? `?${qs}` : ""}`,
476
+ void 0,
477
+ options
478
+ );
479
+ }
467
480
  var ApiError;
468
481
  var init_api_client = __esm({
469
482
  "src/lib/api-client.ts"() {
@@ -840,6 +853,7 @@ async function devCommand(options) {
840
853
  const apiKeyEnv = process.env.DASHWISE_API_KEY ?? moduleEntry?.apiKey;
841
854
  let installationIdEnv;
842
855
  let revokeDevSession;
856
+ let devHandoffEnv = {};
843
857
  if (explicitInstallationId && explicitInstallationId !== "local") {
844
858
  installationIdEnv = explicitInstallationId;
845
859
  } else if (reuseDevSessionId) ; else {
@@ -862,10 +876,14 @@ async function devCommand(options) {
862
876
  callback_origins: callbackOrigins
863
877
  });
864
878
  revokeDevSession = () => revokeDevSessionBestEffort(apiUrl, apiToken, session.id);
879
+ devHandoffEnv = devSessionHandoffEnv(session);
865
880
  info(` ${dim("dev session")} ${session.id}`);
866
881
  info(` ${dim("workspace")} ${workspaceContext.name} (id=${workspaceContext.id}, slug=${workspaceContext.slug})`);
867
882
  info(` ${dim("callbacks")} ${callbackOrigins.join(", ")}`);
868
883
  info(` ${dim("expires")} ${session.expires_at}`);
884
+ info(
885
+ ` ${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)"}`
886
+ );
869
887
  } catch (err) {
870
888
  if (err instanceof ApiError) {
871
889
  fail(`Failed to register dev session: ${err.code} \u2014 ${err.message}`);
@@ -879,7 +897,8 @@ async function devCommand(options) {
879
897
  ...process.env,
880
898
  DASHWISE_API_URL: apiUrl,
881
899
  NEXT_PUBLIC_DASHWISE_API_URL: apiUrl,
882
- ...apiKeyEnv !== void 0 ? { DASHWISE_API_KEY: apiKeyEnv } : {}
900
+ ...apiKeyEnv !== void 0 ? { DASHWISE_API_KEY: apiKeyEnv } : {},
901
+ ...devHandoffEnv
883
902
  };
884
903
  info(`Dev loop starting \u2014 Ctrl-C to stop.`);
885
904
  info(` ${dim("api")} ${apiUrl}`);
@@ -1117,6 +1136,14 @@ async function resolveModuleApiKey(slug, entry, apiUrl, cliBearer) {
1117
1136
  success(`Upgraded ${code(slug)} to a dwk_ API key (id=${minted.api_key.id}).`);
1118
1137
  return upgraded;
1119
1138
  }
1139
+ function devSessionHandoffEnv(session) {
1140
+ const secret = session.handoff_secret;
1141
+ if (typeof secret !== "string" || secret.length === 0) return {};
1142
+ return {
1143
+ DASHWISE_DEV_SESSION_ID: session.id,
1144
+ DASHWISE_DEV_HANDOFF_SECRET: secret
1145
+ };
1146
+ }
1120
1147
  function computeCallbackOrigins(port, extra) {
1121
1148
  const def = `http://localhost:${port}`;
1122
1149
  return Array.from(/* @__PURE__ */ new Set([def, ...extra]));
@@ -1285,7 +1312,7 @@ function packageJson(opts) {
1285
1312
  start: "next start"
1286
1313
  },
1287
1314
  dependencies: {
1288
- "@dashai/sdk": "^2.0.0",
1315
+ "@dashai/sdk": "^2.3.0",
1289
1316
  next: "^15.0.0",
1290
1317
  react: "^19.0.0",
1291
1318
  "react-dom": "^19.0.0"
@@ -1953,8 +1980,20 @@ function appApiAuthSignInTs() {
1953
1980
  // 302 back to callback_url?code=<one-time>. The /api/auth/callback route
1954
1981
  // exchanges that code for a session cookie.
1955
1982
  //
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.
1983
+ // ZERO-CLICK LOCAL DEV (CLI \u2265 0.16.0 + backend \u2265 2026-07-15): when
1984
+ // \`dashwise dev\` injected BOTH DASHWISE_DEV_SESSION_ID and
1985
+ // DASHWISE_DEV_HANDOFF_SECRET, this route first presents the handoff
1986
+ // secret to the backend SERVER-SIDE (POST /api/auth/dev/sso/authorize)
1987
+ // and 302s the browser straight to the returned callback URL \u2014 the user
1988
+ // NEVER sees a login page and no platform browser session is needed. On
1989
+ // ANY failure it logs one server-side warn line and falls back to the
1990
+ // browser flow above unchanged (that path still matters for signing in
1991
+ // as a DIFFERENT workspace member and for stale dev sessions). The
1992
+ // secret is server-env-only: never in a URL, response body, or the
1993
+ // client bundle.
1994
+ //
1995
+ // No dev-vs-prod branching for the browser flow \u2014 the single dwk_ key
1996
+ // drives both local dev and production.
1958
1997
  import { NextResponse } from 'next/server';
1959
1998
  import { getPublicOrigin } from '@/lib/request-origin';
1960
1999
 
@@ -1974,6 +2013,30 @@ export async function GET(request: Request) {
1974
2013
  status: 500,
1975
2014
  });
1976
2015
  }
2016
+
2017
+ // \`getPublicOrigin\` reads \`X-Forwarded-Host\` when present so URLs
2018
+ // point at the public domain, not the internal Node origin behind
2019
+ // reverse proxies (Railway/Vercel/Fly).
2020
+ const moduleOrigin = getPublicOrigin(request);
2021
+
2022
+ // \u2500\u2500 Zero-click dev handoff (server-side; runs BEFORE the dwk_-key
2023
+ // guard \u2014 the authorize endpoint authenticates with the handoff
2024
+ // secret, not the key). Both env vars must be present: \`dashwise dev\`
2025
+ // injects them together, and only when the backend issued a secret.
2026
+ const devSessionId = process.env.DASHWISE_DEV_SESSION_ID;
2027
+ const devHandoffSecret = process.env.DASHWISE_DEV_HANDOFF_SECRET;
2028
+ if (devSessionId && devHandoffSecret) {
2029
+ const callbackUrl = await tryDevHandoff(
2030
+ apiBaseUrl,
2031
+ devSessionId,
2032
+ devHandoffSecret,
2033
+ returnTo,
2034
+ moduleOrigin,
2035
+ );
2036
+ if (callbackUrl) return NextResponse.redirect(callbackUrl);
2037
+ // null \u2192 warned server-side already; fall through to the browser flow.
2038
+ }
2039
+
1977
2040
  if (!apiKey) {
1978
2041
  return new NextResponse(
1979
2042
  'DASHWISE_API_KEY must be set in the environment. Run \`dashwise dev\` ' +
@@ -1983,12 +2046,9 @@ export async function GET(request: Request) {
1983
2046
  }
1984
2047
 
1985
2048
  // 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);
2049
+ // to. The callback path is fixed at /api/auth/callback by SDK
2050
+ // convention; we tuck \`return_to\` onto it so the callback route can
2051
+ // resume the user's original destination.
1992
2052
  const callback = new URL('/api/auth/callback', moduleOrigin);
1993
2053
  callback.searchParams.set('return_to', returnTo);
1994
2054
 
@@ -2041,6 +2101,74 @@ export async function GET(request: Request) {
2041
2101
  }
2042
2102
  return NextResponse.redirect(body.redirect_url);
2043
2103
  }
2104
+
2105
+ /**
2106
+ * Zero-click dev sign-in (CLI \u2265 0.16.0 + backend \u2265 2026-07-15).
2107
+ *
2108
+ * SERVER-SIDE ONLY: presents the dev session's one-time handoff secret
2109
+ * to \`POST /api/auth/dev/sso/authorize\`. On 200 the backend mints a
2110
+ * single-use 60s exchange code for the DEV'S OWN user and returns
2111
+ * \`{ callback_url }\` (= \`<origin>/api/auth/callback?code=\u2026&return_to=\u2026\`)
2112
+ * \u2014 the caller 302s the browser straight there; the normal callback
2113
+ * route exchanges the code exactly like the browser flow.
2114
+ *
2115
+ * Returns the callback URL, or \`null\` on ANY failure (404
2116
+ * DEV_SESSION_NOT_FOUND, 401 DEV_HANDOFF_INVALID, 400 INVALID_RETURN_TO,
2117
+ * 403 WORKSPACE_NOT_MEMBER, network) after logging a one-line warn \u2014
2118
+ * the caller then falls back to the browser SSO flow, which remains the
2119
+ * path for signing in as a DIFFERENT workspace member and for stale dev
2120
+ * sessions. The secret must never appear in a URL or response body.
2121
+ */
2122
+ async function tryDevHandoff(
2123
+ apiBaseUrl: string,
2124
+ devSessionId: string,
2125
+ devHandoffSecret: string,
2126
+ returnTo: string,
2127
+ moduleOrigin: string,
2128
+ ): Promise<string | null> {
2129
+ // Same root-relative-only rule as the callback's safeReturnTo \u2014 an
2130
+ // absolute/protocol-relative return_to must not steer the handoff.
2131
+ const path =
2132
+ returnTo.startsWith('/') && !returnTo.startsWith('//') ? returnTo : '/';
2133
+ // \`return_to\` is the user's ABSOLUTE destination on this module's own
2134
+ // (loopback) origin; the backend validates origin allowlist + loopback
2135
+ // and builds the callback URL from it.
2136
+ const destination = new URL(path, moduleOrigin);
2137
+ let res: Response;
2138
+ try {
2139
+ res = await fetch(\`\${apiBaseUrl}/api/auth/dev/sso/authorize\`, {
2140
+ method: 'POST',
2141
+ headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
2142
+ body: JSON.stringify({
2143
+ dev_session_id: devSessionId,
2144
+ handoff_secret: devHandoffSecret,
2145
+ return_to: destination.toString(),
2146
+ }),
2147
+ });
2148
+ } catch (err) {
2149
+ console.warn(
2150
+ \`[auth] dev handoff failed (\${(err as Error).message}) \u2014 falling back to browser SSO\`,
2151
+ );
2152
+ return null;
2153
+ }
2154
+ if (!res.ok) {
2155
+ const body = (await res.json().catch(() => null)) as { code?: string } | null;
2156
+ console.warn(
2157
+ \`[auth] dev handoff rejected (\${body?.code ?? \`HTTP \${res.status}\`}) \u2014 falling back to browser SSO\`,
2158
+ );
2159
+ return null;
2160
+ }
2161
+ const body = (await res.json().catch(() => null)) as
2162
+ | { callback_url?: string }
2163
+ | null;
2164
+ if (!body?.callback_url) {
2165
+ console.warn(
2166
+ '[auth] dev handoff response missing callback_url \u2014 falling back to browser SSO',
2167
+ );
2168
+ return null;
2169
+ }
2170
+ return body.callback_url;
2171
+ }
2044
2172
  `;
2045
2173
  }
2046
2174
  function appApiAuthCallbackTs() {
@@ -2049,7 +2177,14 @@ function appApiAuthCallbackTs() {
2049
2177
  // SSO flow failed. Two paths:
2050
2178
  //
2051
2179
  // 1. Success path: exchange \`?code\` for a session cookie via
2052
- // \`exchangeCode()\`, then redirect to \`return_to\`.
2180
+ // \`exchangeCode()\`, then redirect to \`return_to\`. SDK \u2265 2.3.0:
2181
+ // the result's \`cookieWritten\` flag surfaces a cookie store that
2182
+ // rejected the write \u2014 without it the exchange "succeeds", the
2183
+ // browser has no session, and the user silently bounces back to
2184
+ // sign-in. We render /sign-in-error?code=cookie_write_failed
2185
+ // instead. \`requestUrl\` lets the SDK fall back to the actual
2186
+ // request protocol for the cookie's Secure attribute when no
2187
+ // x-forwarded-proto header is present (plain-http \`next start\`).
2053
2188
  // 2. Error path: SSO returned \`?error=\` \u2014 redirect to the polished
2054
2189
  // sign-in-error page so the user sees a real error UI instead of
2055
2190
  // a raw 4xx page. The error code maps to known SSO error codes
@@ -2086,7 +2221,17 @@ export async function GET(request: Request) {
2086
2221
  return NextResponse.redirect(target);
2087
2222
  }
2088
2223
  try {
2089
- await exchangeCode(code); // sets the dashwise.session cookie
2224
+ // Sets the dashwise.session cookie. \`requestUrl\` = the Secure-attr
2225
+ // protocol fallback when no x-forwarded-proto header is present.
2226
+ const result = await exchangeCode(code, { requestUrl: request.url });
2227
+ if (!result.cookieWritten) {
2228
+ // The exchange succeeded but the cookie store rejected the write \u2014
2229
+ // the browser has NO session. Surface it instead of redirecting
2230
+ // into an unauthenticated loop (SDK \u2265 2.3.0).
2231
+ const target = new URL('/sign-in-error', publicOrigin);
2232
+ target.searchParams.set('code', 'cookie_write_failed');
2233
+ return NextResponse.redirect(target);
2234
+ }
2090
2235
  } catch (err) {
2091
2236
  const target = new URL('/sign-in-error', publicOrigin);
2092
2237
  target.searchParams.set('code', 'exchange_failed');
@@ -2123,7 +2268,12 @@ import { signOut } from '@dashai/sdk/auth/server';
2123
2268
  import { getPublicOrigin } from '@/lib/request-origin';
2124
2269
 
2125
2270
  export async function POST(request: Request) {
2126
- await signOut();
2271
+ // Pass requestUrl so the cookie-CLEAR resolves its Secure attribute the
2272
+ // SAME way the sign-in SET did (exchangeCode also gets request.url) \u2014
2273
+ // otherwise a plain-http localhost dev server sets a non-Secure cookie
2274
+ // but tries to clear a Secure one, which the browser ignores, leaving a
2275
+ // stale (backend-revoked, but client-present) cookie after sign-out.
2276
+ await signOut({ requestUrl: request.url });
2127
2277
  // Build the redirect against the public origin so we don't land
2128
2278
  // the user on \`http://localhost:<PORT>/\` when running behind a
2129
2279
  // reverse proxy (Railway/Vercel/Fly).
@@ -2210,7 +2360,7 @@ function packageJsonCustom(opts) {
2210
2360
  start: "next start"
2211
2361
  },
2212
2362
  dependencies: {
2213
- "@dashai/sdk": "^2.0.0",
2363
+ "@dashai/sdk": "^2.3.0",
2214
2364
  next: "^15.0.0",
2215
2365
  react: "^19.0.0",
2216
2366
  "react-dom": "^19.0.0",
@@ -2711,6 +2861,8 @@ const ERROR_MESSAGES: Record<string, string> = {
2711
2861
  'The callback URL was missing the expected exchange code. This usually means the SSO flow was interrupted.',
2712
2862
  exchange_failed:
2713
2863
  "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.",
2864
+ cookie_write_failed:
2865
+ '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
2866
  };
2715
2867
 
2716
2868
  interface SignInErrorPageProps {
@@ -3517,6 +3669,14 @@ function surfaceProvisionApiError(err, slug) {
3517
3669
  `Not authorized to create a module in that workspace. Confirm you're a member at the DashWise dashboard.`
3518
3670
  );
3519
3671
  return;
3672
+ case "DEV_INSTALL_ALREADY_EXISTS": {
3673
+ const cached = readModuleEntry(slug);
3674
+ warn(
3675
+ `Module ${code(slug)} already has a live dev install${cached?.installationId ? ` (cached id=${cached.installationId})` : ""} \u2014 only ONE dev install per module can exist.
3676
+ 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.`
3677
+ );
3678
+ return;
3679
+ }
3520
3680
  case "SLUG_ALREADY_EXISTS": {
3521
3681
  const cached = readModuleEntry(slug);
3522
3682
  if (cached) {
@@ -3526,9 +3686,9 @@ function surfaceProvisionApiError(err, slug) {
3526
3686
  );
3527
3687
  } else {
3528
3688
  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.`
3689
+ `A module with slug ${code(slug)} already exists.
3690
+ - If it belongs to ANOTHER workspace: module slugs are globally unique \u2014 pick a NEW slug (duplicate the code if needed).
3691
+ - 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
3692
  );
3533
3693
  }
3534
3694
  return;
@@ -4177,9 +4337,29 @@ function parseFieldSpec(spec) {
4177
4337
  `The ${quote("unique")} flag is not supported on field specs \u2014 uniqueness is a TABLE-level index. Declare it in module.json as ${quote(`owns.tables[].indexes: [{ "fields": ["${slug}"], "unique": true }]`)} and apply it with ${quote("dashwise schema push")}.`
4178
4338
  );
4179
4339
  }
4340
+ if (DECIMALS_FLAG_REGEX.test(flag)) {
4341
+ if (type !== "number") {
4342
+ throw new FieldSpecError(
4343
+ `Decimal places (${quote(flag)}) are only valid on ${quote("number")} fields, not ${quote(type)}. Field spec ${quote(spec)} sets a scale on a non-number type.`
4344
+ );
4345
+ }
4346
+ if (result.decimal_places !== void 0) {
4347
+ throw new FieldSpecError(
4348
+ `Decimal places specified twice in field spec ${quote(spec)}.`
4349
+ );
4350
+ }
4351
+ const places = Number.parseInt(flag, 10);
4352
+ if (places < 0 || places > MAX_DECIMAL_PLACES) {
4353
+ throw new FieldSpecError(
4354
+ `Decimal places must be between 0 and ${MAX_DECIMAL_PLACES} (got ${quote(flag)}) in field spec ${quote(spec)}.`
4355
+ );
4356
+ }
4357
+ result.decimal_places = places;
4358
+ continue;
4359
+ }
4180
4360
  if (!KNOWN_FLAGS.has(flag)) {
4181
4361
  throw new FieldSpecError(
4182
- `Unknown flag ${quote(flag)} in field spec ${quote(spec)}. Known flags: ${[...KNOWN_FLAGS].join(", ")}. For options/target/default, use ${quote("--field-json")} instead.`
4362
+ `Unknown flag ${quote(flag)} in field spec ${quote(spec)}. Known flags: ${[...KNOWN_FLAGS].join(", ")}${type === "number" ? ` (plus a bare 0\u2013${MAX_DECIMAL_PLACES} integer for decimal places, e.g. ${quote(`${slug}:number:2`)})` : ""}. For options/target/default, use ${quote("--field-json")} instead.`
4183
4363
  );
4184
4364
  }
4185
4365
  if (flag === "required") result.required = true;
@@ -4230,7 +4410,7 @@ function parseFieldJson(json) {
4230
4410
  function quote(s) {
4231
4411
  return `\`${s}\``;
4232
4412
  }
4233
- var FIELD_TYPES, SLUG_REGEX, KNOWN_FLAGS, FieldSpecError;
4413
+ var FIELD_TYPES, SLUG_REGEX, KNOWN_FLAGS, DECIMALS_FLAG_REGEX, MAX_DECIMAL_PLACES, FieldSpecError;
4234
4414
  var init_field_spec = __esm({
4235
4415
  "src/lib/field-spec.ts"() {
4236
4416
  FIELD_TYPES = [
@@ -4253,6 +4433,8 @@ var init_field_spec = __esm({
4253
4433
  ];
4254
4434
  SLUG_REGEX = /^[a-z][a-z0-9_]*[a-z0-9]$/;
4255
4435
  KNOWN_FLAGS = /* @__PURE__ */ new Set(["required"]);
4436
+ DECIMALS_FLAG_REGEX = /^\d+$/;
4437
+ MAX_DECIMAL_PLACES = 10;
4256
4438
  FieldSpecError = class extends Error {
4257
4439
  constructor(message) {
4258
4440
  super(message);
@@ -6275,13 +6457,14 @@ async function moduleInstallCommand(slugArg, options) {
6275
6457
  return 0;
6276
6458
  }
6277
6459
  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:
6460
+ 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.
6461
+ Older backends 409 SLUG_ALREADY_EXISTS on that path (no re-provision anywhere). Fallback for them (production repair):
6279
6462
  - 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
6463
  - Or duplicate the code under a NEW slug for greenfield iteration.`;
6281
6464
  }
6282
6465
  function printDevInstallAftermath(slug, workspaceSlug) {
6283
6466
  warn(
6284
- `Local dev for ${code(slug)} is now DISABLED (the dev install in ${code(workspaceSlug)} and its ${code("dwk_")} key were destroyed).
6467
+ `Local dev for ${code(slug)} is DISCONNECTED (the dev install in ${code(workspaceSlug)} and its ${code("dwk_")} key were destroyed).
6285
6468
  ${publishedModuleLocalDevGuidance(slug)}`
6286
6469
  );
6287
6470
  }
@@ -6305,9 +6488,9 @@ async function resolveDevInstallCollision(existing, slug, workspaceSlug, options
6305
6488
  return 1;
6306
6489
  }
6307
6490
  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.
6491
+ 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
6492
  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).
6493
+ - ${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
6494
  - ${code("--workspace <other>")} \u2014 deploy to a separate test/client workspace and keep this dev install. Recommended for ongoing development.`;
6312
6495
  if (options.replaceDevInstall) {
6313
6496
  info(explanation);
@@ -6321,7 +6504,7 @@ async function resolveDevInstallCollision(existing, slug, workspaceSlug, options
6321
6504
  {
6322
6505
  value: "replace",
6323
6506
  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)"
6507
+ hint: "revokes local-dev credentials; re-provision later via `dashwise init` (backend \u2265 2026-07-15 \u2014 older backends: scoped-key repair only)"
6325
6508
  },
6326
6509
  { value: "cancel", label: "Cancel \u2014 I will deploy to another workspace" }
6327
6510
  ],
@@ -7093,13 +7276,13 @@ async function moduleUninstallCommand(slugArg, options) {
7093
7276
  }
7094
7277
  info(
7095
7278
  `Uninstalling ${code(mod.slug)} (install ${code(`#${row.id}`)}, kind=${row.kind ?? "production"}${row.module_version?.version ? `, v${row.module_version.version}` : ""}) from workspace ${code(workspace2.slug)}.
7096
- The app is soft-deleted, recoverable ONLY by a PLATFORM OPERATOR (contact support/HQ) within ~7 days \u2014 it does NOT appear in your dashboard trash.
7097
- An operator restore KEEPS the public URL${row.public_url ? ` ${code(row.public_url)}` : ""}; a REINSTALL mints a NEW one \u2014 deep links to the old URL break.`
7279
+ The app is soft-deleted and appears in your workspace trash \u2014 a workspace admin can self-serve restore its DATA (~30 days). Reviving a running module within ~7 days is a platform-operator action.
7280
+ A REINSTALL now KEEPS the same public URL${row.public_url ? ` ${code(row.public_url)}` : ""}, so deep links survive. (On backends older than 2026-07-15: restore is operator-only and a reinstall mints a new URL.)`
7098
7281
  );
7099
7282
  const answer = await select({
7100
7283
  message: `Uninstall ${code(mod.slug)} from ${code(workspace2.slug)}?`,
7101
7284
  options: [
7102
- { value: "uninstall", label: "Uninstall \u2014 soft-delete the app (operator-restorable ~7 days)" },
7285
+ { value: "uninstall", label: "Uninstall \u2014 soft-delete the app (data restorable from workspace trash ~30 days)" },
7103
7286
  { value: "cancel", label: "Cancel \u2014 nothing is changed" }
7104
7287
  ],
7105
7288
  initialValue: "cancel"
@@ -7131,8 +7314,8 @@ async function moduleUninstallCommand(slugArg, options) {
7131
7314
  `Uninstalled ${code(mod.slug)} (install ${code(`#${row.id}`)}) from workspace ${code(workspace2.slug)}.`
7132
7315
  );
7133
7316
  info(
7134
- `The app was soft-deleted. If this was a mistake: only a PLATFORM OPERATOR can restore it (contact support/HQ) within ~7 days \u2014 it does NOT appear in your dashboard trash. An operator restore keeps the same public URL.
7135
- Reinstalling instead (${code(`dashwise module install ${mod.slug} --workspace ${workspace2.slug}`)}) mints a NEW public URL.`
7317
+ `The app was soft-deleted. If this was a mistake: restore its DATA yourself from your workspace trash (~30 days); to bring the running module back within ~7 days, contact a platform operator (support/HQ).
7318
+ Reinstalling (${code(`dashwise module install ${mod.slug} --workspace ${workspace2.slug}`)}) now KEEPS the same public URL. (Older backends, < 2026-07-15: restore is operator-only and reinstall mints a new URL.)`
7136
7319
  );
7137
7320
  if (options.parkConsumers) {
7138
7321
  info(
@@ -10341,6 +10524,319 @@ function mapAndPrintError(err, json) {
10341
10524
  return 1;
10342
10525
  }
10343
10526
 
10527
+ // src/commands/module/logs.ts
10528
+ init_api_client();
10529
+ init_config();
10530
+ init_manifest_io();
10531
+ init_output();
10532
+ var POLL_INTERVAL_MS3 = 2e3;
10533
+ var DURATION_UNIT_MS = {
10534
+ s: 1e3,
10535
+ m: 6e4,
10536
+ h: 36e5,
10537
+ d: 864e5
10538
+ };
10539
+ function resolveSince(raw) {
10540
+ if (raw === void 0) return void 0;
10541
+ const trimmed = raw.trim();
10542
+ if (trimmed === "") return void 0;
10543
+ const m = /^(\d+)\s*(s|m|h|d)$/i.exec(trimmed);
10544
+ if (m) {
10545
+ const n = Number.parseInt(m[1], 10);
10546
+ const unitMs = DURATION_UNIT_MS[m[2].toLowerCase()];
10547
+ return new Date(Date.now() - n * unitMs).toISOString();
10548
+ }
10549
+ return trimmed;
10550
+ }
10551
+ async function moduleLogsCommand(slugArg, options) {
10552
+ if (options.follow && options.json) {
10553
+ fail("`--follow` and `--json` are mutually exclusive.");
10554
+ return 1;
10555
+ }
10556
+ if (!isLoggedIn()) {
10557
+ fail(`Not logged in. Run ${code("dashwise login")} first.`);
10558
+ return 1;
10559
+ }
10560
+ const cfg = readConfig();
10561
+ if (!cfg) {
10562
+ fail(`CLI config missing. Run ${code("dashwise login")}.`);
10563
+ return 1;
10564
+ }
10565
+ const auth = {
10566
+ baseUrl: resolveApiUrl(),
10567
+ auth: { token: cfg.token }
10568
+ };
10569
+ const since = resolveSince(options.since);
10570
+ let installationId;
10571
+ let label;
10572
+ if (options.install !== void 0) {
10573
+ if (!Number.isInteger(options.install) || options.install <= 0) {
10574
+ fail(
10575
+ `--install expects a positive integer installation id (got "${options.install}").`
10576
+ );
10577
+ return 1;
10578
+ }
10579
+ installationId = options.install;
10580
+ label = `install #${installationId}`;
10581
+ } else {
10582
+ const resolved = await resolveInstallationFromSlug(slugArg, options, cfg, auth);
10583
+ if (typeof resolved === "number") return resolved;
10584
+ installationId = resolved.installationId;
10585
+ label = resolved.label;
10586
+ }
10587
+ if (options.follow) {
10588
+ return runFollow(installationId, label, { since, limit: options.limit }, auth);
10589
+ }
10590
+ return runOnce3(installationId, label, options, since, auth);
10591
+ }
10592
+ async function resolveInstallationFromSlug(slugArg, options, cfg, auth) {
10593
+ let slug = slugArg;
10594
+ if (!slug) {
10595
+ try {
10596
+ const fromManifest = readManifest(options.dir)?.module?.slug;
10597
+ if (typeof fromManifest === "string" && fromManifest.length > 0) {
10598
+ slug = fromManifest;
10599
+ }
10600
+ } catch {
10601
+ }
10602
+ }
10603
+ if (!slug) {
10604
+ fail(
10605
+ `No module slug \u2014 pass one (${code("dashwise module logs <slug>")}), run from a directory with a valid module.json, or target an install directly with ${code("--install <id>")}.`
10606
+ );
10607
+ return 1;
10608
+ }
10609
+ let modId;
10610
+ let modSlug;
10611
+ try {
10612
+ const mod = await getModuleBySlug(slug, auth);
10613
+ modId = mod.id;
10614
+ modSlug = mod.slug;
10615
+ } catch (err) {
10616
+ if (err instanceof ApiError && err.status === 404) {
10617
+ fail(`Module ${code(slug)} not found on the backend.`);
10618
+ return 1;
10619
+ }
10620
+ fail(`Could not resolve module ${code(slug)}: ${err.message}`);
10621
+ return 1;
10622
+ }
10623
+ let workspace2;
10624
+ try {
10625
+ workspace2 = await resolveWorkspaceWithFallback({
10626
+ userFlag: options.workspace,
10627
+ cachedWorkspaceId: readModuleEntry(slug)?.workspaceId,
10628
+ configDefault: cfg.workspaceSlug,
10629
+ apiUrl: resolveApiUrl(),
10630
+ apiToken: cfg.token
10631
+ });
10632
+ } catch (err) {
10633
+ fail(`Workspace selection failed: ${err.message}`);
10634
+ return 1;
10635
+ }
10636
+ let row;
10637
+ try {
10638
+ const installs = await listWorkspaceInstallations(workspace2.id, auth);
10639
+ const matches = installs.filter((r) => r.module_id === modId);
10640
+ row = matches.find((r) => !isDevInstallKind(r.kind) && isRealRuntimeUrl(r.runtime_url)) ?? matches.find((r) => !isDevInstallKind(r.kind)) ?? matches[0];
10641
+ } catch (err) {
10642
+ fail(`Could not read workspace installations: ${err.message}`);
10643
+ return 1;
10644
+ }
10645
+ if (!row) {
10646
+ info(
10647
+ `${code(modSlug)} is not installed in workspace ${code(workspace2.slug)} \u2014 nothing to read logs from. Deploy it with ${code(`dashwise module install ${modSlug} --workspace ${workspace2.slug}`)}.`
10648
+ );
10649
+ return 0;
10650
+ }
10651
+ return {
10652
+ installationId: row.id,
10653
+ label: `${modSlug} in ${workspace2.slug} (install #${row.id})`
10654
+ };
10655
+ }
10656
+ async function runOnce3(installationId, label, options, since, auth) {
10657
+ let body;
10658
+ try {
10659
+ body = await getRuntimeLogs(
10660
+ installationId,
10661
+ {
10662
+ ...since !== void 0 && !options.tail ? { since } : {},
10663
+ ...options.limit !== void 0 ? { limit: options.limit } : {},
10664
+ ...options.tail ? { tail: true } : {}
10665
+ },
10666
+ auth
10667
+ );
10668
+ } catch (err) {
10669
+ return mapLogsError(err, options.json);
10670
+ }
10671
+ if (options.json) {
10672
+ out(JSON.stringify(body));
10673
+ return 0;
10674
+ }
10675
+ if (body.entries.length === 0) {
10676
+ info(`No runtime log entries for ${label} in the requested window.`);
10677
+ return 0;
10678
+ }
10679
+ for (const entry of body.entries) out(formatRow2(entry));
10680
+ out("");
10681
+ info(
10682
+ `${body.entries.length} ${body.entries.length === 1 ? "line" : "lines"} (${label}).`
10683
+ );
10684
+ return 0;
10685
+ }
10686
+ async function runFollow(installationId, label, opts, auth) {
10687
+ info(`Following runtime logs for ${label} (every ${POLL_INTERVAL_MS3 / 1e3}s). Ctrl-C to stop.`);
10688
+ out("");
10689
+ let stopped = false;
10690
+ const onSigint = () => {
10691
+ stopped = true;
10692
+ };
10693
+ process.on("SIGINT", onSigint);
10694
+ let lastTs;
10695
+ let seenAtLastTs = /* @__PURE__ */ new Set();
10696
+ let firstPoll = true;
10697
+ try {
10698
+ while (!stopped) {
10699
+ const query = {};
10700
+ if (opts.limit !== void 0) query.limit = opts.limit;
10701
+ if (firstPoll) {
10702
+ if (opts.since !== void 0) query.since = opts.since;
10703
+ else query.tail = true;
10704
+ } else if (lastTs !== void 0) {
10705
+ query.since = new Date(lastTs).toISOString();
10706
+ }
10707
+ let body;
10708
+ try {
10709
+ body = await getRuntimeLogs(installationId, query, auth);
10710
+ } catch (err) {
10711
+ if (isFatalFollowError(err)) {
10712
+ return mapLogsError(err, false);
10713
+ }
10714
+ info(dim(`(follow: transient error \u2014 ${err.message})`));
10715
+ await sleep4(POLL_INTERVAL_MS3, () => stopped);
10716
+ firstPoll = false;
10717
+ continue;
10718
+ }
10719
+ const fresh = [];
10720
+ for (const entry of body.entries) {
10721
+ if (lastTs !== void 0 && entry.ts < lastTs) continue;
10722
+ if (lastTs !== void 0 && entry.ts === lastTs && seenAtLastTs.has(entryKey(entry))) {
10723
+ continue;
10724
+ }
10725
+ fresh.push(entry);
10726
+ }
10727
+ fresh.sort((a, b) => a.ts - b.ts);
10728
+ for (const entry of fresh) out(formatRow2(entry));
10729
+ if (fresh.length > 0) {
10730
+ const newMax = fresh[fresh.length - 1].ts;
10731
+ if (newMax !== lastTs) seenAtLastTs = /* @__PURE__ */ new Set();
10732
+ lastTs = newMax;
10733
+ for (const entry of fresh) {
10734
+ if (entry.ts === lastTs) seenAtLastTs.add(entryKey(entry));
10735
+ }
10736
+ }
10737
+ firstPoll = false;
10738
+ await sleep4(POLL_INTERVAL_MS3, () => stopped);
10739
+ }
10740
+ } finally {
10741
+ process.off("SIGINT", onSigint);
10742
+ }
10743
+ out("");
10744
+ return 0;
10745
+ }
10746
+ function isFatalFollowError(err) {
10747
+ if (!(err instanceof ApiError)) return false;
10748
+ if (err.status === 401 || err.status === 403 || err.status === 404) return true;
10749
+ return err.code === "INSTALLATION_NOT_FOUND" || err.code === "RUNTIME_NOT_PROVISIONED" || err.code === "INVALID_SINCE";
10750
+ }
10751
+ function formatTs(ts) {
10752
+ if (!Number.isFinite(ts)) return "--:--:--";
10753
+ return new Date(ts).toISOString().slice(11, 19);
10754
+ }
10755
+ function colorLevel(level) {
10756
+ const padded = level.toUpperCase().padEnd(5);
10757
+ switch (level) {
10758
+ case "error":
10759
+ return pc.red(padded);
10760
+ case "warn":
10761
+ return pc.yellow(padded);
10762
+ case "info":
10763
+ return pc.green(padded);
10764
+ case "debug":
10765
+ default:
10766
+ return dim(padded);
10767
+ }
10768
+ }
10769
+ function formatRow2(entry) {
10770
+ const time = dim(formatTs(entry.ts));
10771
+ const level = colorLevel(entry.level);
10772
+ const reqId = entry.request_id ? ` ${dim(`[${entry.request_id}]`)}` : "";
10773
+ return `${time} ${level} ${entry.message}${reqId}`;
10774
+ }
10775
+ function entryKey(entry) {
10776
+ return `${entry.ts}\0${entry.request_id ?? ""}\0${entry.message}`;
10777
+ }
10778
+ function mapLogsError(err, json) {
10779
+ if (err instanceof ApiError) {
10780
+ const emit = (code_, message2) => {
10781
+ if (json) out(JSON.stringify({ ok: false, error: { code: code_, message: message2 } }));
10782
+ else fail(message2);
10783
+ };
10784
+ if (err.status === 401 || err.code === "UNAUTHENTICATED") {
10785
+ emit("NOT_LOGGED_IN", `Not logged in. Run ${code("dashwise login")} first.`);
10786
+ return 1;
10787
+ }
10788
+ if (err.status === 403) {
10789
+ emit(
10790
+ "FORBIDDEN",
10791
+ `Reading runtime logs requires workspace-admin on this install's workspace.`
10792
+ );
10793
+ return 1;
10794
+ }
10795
+ if (err.status === 404 || err.code === "INSTALLATION_NOT_FOUND") {
10796
+ emit(
10797
+ "INSTALLATION_NOT_FOUND",
10798
+ `Installation not found. Check the id (${code("dashwise module status <slug>")}) \u2014 it may have been uninstalled.`
10799
+ );
10800
+ return 1;
10801
+ }
10802
+ if (err.code === "RUNTIME_NOT_PROVISIONED") {
10803
+ emit(
10804
+ "RUNTIME_NOT_PROVISIONED",
10805
+ `This install has no running runtime yet \u2014 deploy it first (${code("dashwise module install <slug>")}). The ${code("kind=local")} dev install never provisions a machine runtime, so it has no runtime logs.`
10806
+ );
10807
+ return 1;
10808
+ }
10809
+ if (err.code === "INVALID_SINCE") {
10810
+ emit(
10811
+ "INVALID_SINCE",
10812
+ `${code("--since")} must be an ISO-8601 timestamp (e.g. ${code("2026-07-15T09:00:00Z")}) or a duration like ${code("15m")} / ${code("2h")} / ${code("2d")}.`
10813
+ );
10814
+ return 1;
10815
+ }
10816
+ if (err.code.startsWith("RUNTIME_")) {
10817
+ emit(
10818
+ err.code,
10819
+ `The runtime host rejected the log read (${err.code}): ${err.message}. This is a host-side error \u2014 retry, or check the runtime with ${code("dashwise module status <slug>")}.`
10820
+ );
10821
+ return 1;
10822
+ }
10823
+ emit(err.code, `Could not read runtime logs: ${err.code} ${err.status} \u2014 ${err.message}`);
10824
+ return 1;
10825
+ }
10826
+ const message = `Could not read runtime logs: ${err.message}`;
10827
+ if (json) out(JSON.stringify({ ok: false, error: { code: "FETCH_FAILED", message } }));
10828
+ else fail(message);
10829
+ return 1;
10830
+ }
10831
+ async function sleep4(ms, aborted) {
10832
+ const slice = 100;
10833
+ let waited = 0;
10834
+ while (waited < ms && !aborted()) {
10835
+ await new Promise((resolve13) => setTimeout(resolve13, Math.min(slice, ms - waited)));
10836
+ waited += slice;
10837
+ }
10838
+ }
10839
+
10344
10840
  // src/bin.ts
10345
10841
  init_output();
10346
10842
  var program = new Command();
@@ -10967,6 +11463,29 @@ moduleCmd.command("action-log").description(
10967
11463
  });
10968
11464
  }
10969
11465
  );
11466
+ moduleCmd.command("logs [slug]").description(
11467
+ "Read a deployed module install's runtime logs (the host's log group). Resolves the install from the slug + workspace like `module status`, or target any install directly with `--install <id>`. `--since 15m|2h|<iso>` sets a lower bound; `--tail` shows the most-recent page; `--follow`/`-f` streams new lines every ~2s (Ctrl-C to stop). Workspace-admin."
11468
+ ).option("--dir <path>", "Project root for the module.json slug lookup (default: current directory).").option("--workspace <slug-or-id>", "Target workspace (default: cached module workspace, config default, or interactive picker).").option(
11469
+ "--install <id>",
11470
+ "Target a specific installation id directly (deployed installs included). Find ids via `dashwise module status <slug>`. Skips slug + workspace resolution.",
11471
+ parseIntOption
11472
+ ).option(
11473
+ "--since <iso-or-duration>",
11474
+ 'Only lines newer than this. Accepts an ISO-8601 timestamp or a duration like "15m", "2h", "2d". Ignored with --tail.'
11475
+ ).option("--limit <n>", "Max lines per fetch (1-1000, backend-clamped, default 100).", parseIntOption).option("--tail", "Show the most-recent page (ignores --since).").option("-f, --follow", "Poll every ~2s for new lines. Ctrl-C to stop. Mutually exclusive with --json.").option("--json", "Emit the raw `{ entries }` JSON response. Ignored with --follow.").action(
11476
+ async (slug, cmdOpts) => {
11477
+ process.exitCode = await moduleLogsCommand(slug, {
11478
+ ...cmdOpts.dir !== void 0 ? { dir: cmdOpts.dir } : {},
11479
+ ...cmdOpts.workspace !== void 0 ? { workspace: cmdOpts.workspace } : {},
11480
+ ...cmdOpts.install !== void 0 ? { install: cmdOpts.install } : {},
11481
+ ...cmdOpts.since !== void 0 ? { since: cmdOpts.since } : {},
11482
+ ...cmdOpts.limit !== void 0 ? { limit: cmdOpts.limit } : {},
11483
+ ...cmdOpts.tail !== void 0 ? { tail: cmdOpts.tail } : {},
11484
+ ...cmdOpts.follow !== void 0 ? { follow: cmdOpts.follow } : {},
11485
+ ...cmdOpts.json !== void 0 ? { json: cmdOpts.json } : {}
11486
+ });
11487
+ }
11488
+ );
10970
11489
  moduleCmd.command("destroy-dev-install").description(
10971
11490
  "Destroy the local-dev install for the current module. Tears down the install row + dashboard tile; if the module had no other installs and was never published, drops the module too. Local module.json is preserved (only the cached runtime token + install id in auth.json get cleared)."
10972
11491
  ).option("--dir <path>", "Project root (default: current directory).").option(
@@ -11100,7 +11619,7 @@ fieldCmd.command("set-type <table-slug> <field-slug> <new-type>").description(
11100
11619
  }
11101
11620
  })();
11102
11621
  function getVersion() {
11103
- return "0.15.0";
11622
+ return "0.17.0";
11104
11623
  }
11105
11624
  function parseIntOption(value) {
11106
11625
  const n = Number.parseInt(value, 10);