@datarails-ai/dr-appspace-cli 0.12.1 → 0.12.3

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.
Files changed (2) hide show
  1. package/dist/cli.js +738 -67
  2. package/package.json +1 -1
package/dist/cli.js CHANGED
@@ -13,7 +13,7 @@ import { fileURLToPath as fileURLToPath6 } from "url";
13
13
  // package.json
14
14
  var package_default = {
15
15
  name: "@datarails-ai/dr-appspace-cli",
16
- version: "0.12.1",
16
+ version: "0.12.3",
17
17
  description: "Datarails App Space CLI \u2014 build, try, push and publish App Space apps",
18
18
  type: "module",
19
19
  engines: {
@@ -57,7 +57,7 @@ var package_default = {
57
57
  };
58
58
 
59
59
  // src/buildInfo.ts
60
- var BUILD_TIME = "2026-10-07T21:55:46.945Z";
60
+ var BUILD_TIME = "2026-10-08T11:11:55.601Z";
61
61
  function formatBuildTime(iso) {
62
62
  if (iso === "") return "";
63
63
  const d = new Date(iso);
@@ -208,7 +208,17 @@ function decodeShape(value) {
208
208
 
209
209
  // src/auth/keyring/types.ts
210
210
  function isCookielessSession(s) {
211
- return s.impersonatorEmail !== void 0 && s.impersonatorEmail !== "" || s.cookieless === true;
211
+ return s.impersonatorEmail !== void 0 && s.impersonatorEmail !== "" || s.cookieless === true || hasApiKey(s) || hasApiKeyEnvVar(s);
212
+ }
213
+ function hasApiKey(s) {
214
+ return s.apiKey !== void 0 && s.apiKey !== "";
215
+ }
216
+ function hasApiKeyEnvVar(s) {
217
+ return s.apiKeyEnvVar !== void 0 && s.apiKeyEnvVar !== "";
218
+ }
219
+ function usesApiKey(s) {
220
+ const impersonated = s.impersonatorEmail !== void 0 && s.impersonatorEmail !== "";
221
+ return (hasApiKey(s) || hasApiKeyEnvVar(s)) && !impersonated;
212
222
  }
213
223
  var KeychainError = class extends Error {
214
224
  constructor(message, options) {
@@ -428,7 +438,9 @@ var KeyringStore = class _KeyringStore {
428
438
  try {
429
439
  parsed = JSON.parse(raw);
430
440
  } catch (err) {
431
- throw new KeychainError(`decode session for ${serverKey}:${email}`, { cause: err });
441
+ throw new KeychainError(`decode session for ${serverKey}:${email}`, {
442
+ cause: err
443
+ });
432
444
  }
433
445
  const sessionId = typeof parsed.sessionId === "string" ? parsed.sessionId : "";
434
446
  const csrfToken = typeof parsed.csrfToken === "string" ? parsed.csrfToken : "";
@@ -436,7 +448,10 @@ var KeyringStore = class _KeyringStore {
436
448
  const impersonatorEmail = typeof parsed.impersonatorEmail === "string" ? parsed.impersonatorEmail : "";
437
449
  const impersonated = impersonatorEmail !== "";
438
450
  const cookielessFlag = parsed.cookieless === true;
439
- const cookieless = impersonated || cookielessFlag;
451
+ const apiKey = typeof parsed.apiKey === "string" && parsed.apiKey !== "" ? parsed.apiKey : "";
452
+ const apiKeyEnvVar = typeof parsed.apiKeyEnvVar === "string" && parsed.apiKeyEnvVar !== "" ? parsed.apiKeyEnvVar : "";
453
+ const keyed = apiKey !== "" || apiKeyEnvVar !== "";
454
+ const cookieless = impersonated || cookielessFlag || keyed;
440
455
  if (savedAtStr === "" || !cookieless && (sessionId === "" || csrfToken === "")) {
441
456
  throw new KeychainError(`session for ${serverKey}:${email} is malformed`);
442
457
  }
@@ -458,6 +473,18 @@ var KeyringStore = class _KeyringStore {
458
473
  if (cookielessFlag) {
459
474
  session.cookieless = true;
460
475
  }
476
+ if (apiKey !== "") {
477
+ session.apiKey = apiKey;
478
+ }
479
+ if (apiKeyEnvVar !== "") {
480
+ session.apiKeyEnvVar = apiKeyEnvVar;
481
+ }
482
+ if (typeof parsed.apiKeyExpiresAt === "string" && parsed.apiKeyExpiresAt !== "") {
483
+ session.apiKeyExpiresAt = new Date(parsed.apiKeyExpiresAt);
484
+ }
485
+ if (typeof parsed.jwtApiKeyFingerprint === "string" && parsed.jwtApiKeyFingerprint !== "") {
486
+ session.jwtApiKeyFingerprint = parsed.jwtApiKeyFingerprint;
487
+ }
461
488
  if (typeof parsed.jwtExpiresAt === "string" && parsed.jwtExpiresAt !== "") {
462
489
  session.jwtExpiresAt = new Date(parsed.jwtExpiresAt);
463
490
  }
@@ -499,7 +526,7 @@ var KeyringStore = class _KeyringStore {
499
526
  const impersonated = session.impersonatorEmail !== void 0 && session.impersonatorEmail !== "";
500
527
  const cookieless = session.cookieless === true;
501
528
  if (isCookielessSession(session)) {
502
- if (session.jwtAccess === void 0 || session.jwtAccess === "") {
529
+ if (!usesApiKey(session) && (session.jwtAccess === void 0 || session.jwtAccess === "")) {
503
530
  throw new Error("saveSession: cookie-less session must have a jwtAccess token");
504
531
  }
505
532
  } else {
@@ -533,6 +560,18 @@ var KeyringStore = class _KeyringStore {
533
560
  if (cookieless) {
534
561
  metaBlob.cookieless = true;
535
562
  }
563
+ if (session.apiKey !== void 0 && session.apiKey !== "") {
564
+ metaBlob.apiKey = session.apiKey;
565
+ }
566
+ if (session.apiKeyEnvVar !== void 0 && session.apiKeyEnvVar !== "") {
567
+ metaBlob.apiKeyEnvVar = session.apiKeyEnvVar;
568
+ }
569
+ if (session.apiKeyExpiresAt !== void 0) {
570
+ metaBlob.apiKeyExpiresAt = session.apiKeyExpiresAt.toISOString();
571
+ }
572
+ if (session.jwtApiKeyFingerprint !== void 0 && session.jwtApiKeyFingerprint !== "") {
573
+ metaBlob.jwtApiKeyFingerprint = session.jwtApiKeyFingerprint;
574
+ }
536
575
  await this.backend.set(SERVICE, sessionAccount, JSON.stringify(metaBlob));
537
576
  if (session.jwtAccess !== void 0 && session.jwtAccess !== "") {
538
577
  await this.backend.set(SERVICE, `${sessionAccount}:access`, session.jwtAccess);
@@ -564,7 +603,11 @@ var KeyringStore = class _KeyringStore {
564
603
  blob.lastUsedAt = now.toISOString();
565
604
  await this.backend.set(SERVICE, sessionAccount, JSON.stringify(blob));
566
605
  const savedAt = parseDate(blob.savedAt) ?? now;
567
- await this.tryUpsertLegacyIndex(serverKey, { email, savedAt, lastUsedAt: now });
606
+ await this.tryUpsertLegacyIndex(serverKey, {
607
+ email,
608
+ savedAt,
609
+ lastUsedAt: now
610
+ });
568
611
  }
569
612
  /**
570
613
  * Upserts one account into the legacy `__index__:<server>` entry via a
@@ -826,6 +869,9 @@ async function pollForToken(host, deviceCode, options = {}) {
826
869
  }
827
870
  }
828
871
 
872
+ // src/auth/jwt.ts
873
+ import { createHash } from "crypto";
874
+
829
875
  // src/auth/types.ts
830
876
  function jwtExpiringSoon(e, bufferMs) {
831
877
  if (!e.jwtExpiresAt) return true;
@@ -1040,7 +1086,11 @@ async function loginAs(host, operatorAccessToken, userId, options) {
1040
1086
  if (typeof parsed["detail"] === "string") detail = parsed["detail"];
1041
1087
  } catch {
1042
1088
  }
1043
- throw new LoginAsError(res.status, { code, detail, body: truncate(text3, TRUNCATE) });
1089
+ throw new LoginAsError(res.status, {
1090
+ code,
1091
+ detail,
1092
+ body: truncate(text3, TRUNCATE)
1093
+ });
1044
1094
  }
1045
1095
  let raw;
1046
1096
  try {
@@ -1065,6 +1115,93 @@ async function loginAs(host, operatorAccessToken, userId, options) {
1065
1115
  }
1066
1116
  return { access, refresh, expiresAt, user: obj4["user"] };
1067
1117
  }
1118
+ async function exchangeApiKey(host, apiKey, options) {
1119
+ const key = apiKey.trim();
1120
+ if (key === "") {
1121
+ throw new Error("api-key exchange: the stored API key is empty");
1122
+ }
1123
+ if (options?.opaque === true) {
1124
+ if (/[\r\n\0]/.test(key) || !/^[\x00-\xff]+$/.test(key)) {
1125
+ throw new Error(
1126
+ "api-key exchange: the environment variable named by apiKeyEnvVar holds a value that cannot travel in an HTTP header (a line break, NUL, or a character above U+00FF)"
1127
+ );
1128
+ }
1129
+ } else if (!/^[\x21-\x7e]+$/.test(key)) {
1130
+ throw new Error(
1131
+ "api-key exchange: only a printable-ASCII API key with no spaces is accepted; the stored one contains whitespace, control characters, or non-ASCII characters"
1132
+ );
1133
+ }
1134
+ const base = trimTrailingSlash(host);
1135
+ const res = await fetch(`${base}/jwt/api/apikey/token/`, {
1136
+ method: "POST",
1137
+ headers: { Authorization: `Bearer ${key}`, Accept: "application/json" },
1138
+ signal: options?.signal
1139
+ });
1140
+ const text3 = await res.text();
1141
+ if (!res.ok) {
1142
+ let code;
1143
+ try {
1144
+ const value = JSON.parse(text3)["error"];
1145
+ if (value === "invalid_key" || value === "exchange_unavailable") code = value;
1146
+ } catch {
1147
+ }
1148
+ throw new Error(
1149
+ `api-key exchange: HTTP ${res.status}${code !== void 0 ? ` (${code})` : ""}`
1150
+ );
1151
+ }
1152
+ let raw;
1153
+ try {
1154
+ raw = JSON.parse(text3);
1155
+ } catch {
1156
+ throw new Error("api-key exchange parse: the response is not JSON");
1157
+ }
1158
+ if (raw === null || typeof raw !== "object") {
1159
+ throw new Error("api-key exchange parse: expected object");
1160
+ }
1161
+ const access = raw["access_token"];
1162
+ if (typeof access !== "string" || access === "") {
1163
+ throw new Error("api-key exchange: empty access token");
1164
+ }
1165
+ let expiresAt;
1166
+ try {
1167
+ expiresAt = decodeJwtExpiration(access);
1168
+ } catch (err) {
1169
+ throw new Error(`api-key exchange: ${msgOf3(err)}`);
1170
+ }
1171
+ return { access, expiresAt };
1172
+ }
1173
+ function apiKeyFingerprint(apiKey) {
1174
+ return createHash("sha256").update(apiKey.trim()).digest("hex").slice(0, 32);
1175
+ }
1176
+ function resolveApiKeyMaterial(session) {
1177
+ if (session.apiKey !== void 0 && session.apiKey !== "") {
1178
+ return { key: session.apiKey, fromEnv: false };
1179
+ }
1180
+ const name = session.apiKeyEnvVar;
1181
+ if (name === void 0 || name === "") {
1182
+ throw new Error("api-key exchange: the session carries no API key");
1183
+ }
1184
+ const value = process.env[name];
1185
+ if (value === void 0 || value === "") {
1186
+ throw new Error(
1187
+ `api-key exchange: the credential file names environment variable ${name} as the API-key carrier, but it is unset or empty in this environment`
1188
+ );
1189
+ }
1190
+ return { key: value, fromEnv: true };
1191
+ }
1192
+ function mintedByStoredApiKey(session) {
1193
+ if (session.jwtAccess === void 0 || session.jwtAccess === "") return false;
1194
+ if (session.jwtApiKeyFingerprint === void 0 || session.jwtApiKeyFingerprint === "") {
1195
+ return false;
1196
+ }
1197
+ let material;
1198
+ try {
1199
+ material = resolveApiKeyMaterial(session);
1200
+ } catch {
1201
+ return false;
1202
+ }
1203
+ return session.jwtApiKeyFingerprint === apiKeyFingerprint(material.key);
1204
+ }
1068
1205
  async function ensureValidJwt(store, serverKey, email, options) {
1069
1206
  return ensureJwt(store, serverKey, email, {
1070
1207
  buffer: options?.buffer ?? 0,
@@ -1086,6 +1223,10 @@ async function ensureJwt(store, serverKey, email, opts) {
1086
1223
  if (session === void 0) {
1087
1224
  throw new NotLoggedInError(`not logged in: ${serverKey}:${email}`);
1088
1225
  }
1226
+ if (usesApiKey(session)) {
1227
+ const host2 = opts.host ?? resolve(serverKey).host;
1228
+ return exchangeKeyedSession(store, serverKey, email, session, host2, opts);
1229
+ }
1089
1230
  if (!opts.force && session.jwtAccess !== void 0 && session.jwtAccess !== "" && !jwtExpiringSoon(session, opts.buffer)) {
1090
1231
  return session;
1091
1232
  }
@@ -1099,7 +1240,9 @@ async function ensureJwt(store, serverKey, email, opts) {
1099
1240
  if (session.jwtRefresh !== void 0 && session.jwtRefresh !== "" && host !== "") {
1100
1241
  try {
1101
1242
  const refresh = session.cookieless === true ? refreshExternalJwt : refreshJwt;
1102
- const tok = await refresh(host, session.jwtRefresh, { signal: opts.signal });
1243
+ const tok = await refresh(host, session.jwtRefresh, {
1244
+ signal: opts.signal
1245
+ });
1103
1246
  refreshNext = {
1104
1247
  ...session,
1105
1248
  jwtAccess: tok.access,
@@ -1157,10 +1300,47 @@ async function ensureJwt(store, serverKey, email, opts) {
1157
1300
  const detail = reasons.length > 0 ? reasons.join("; ") : "no recovery path available";
1158
1301
  throw new SessionExpiredError(`${serverKey}:${email} (${detail})`);
1159
1302
  }
1303
+ async function exchangeKeyedSession(store, serverKey, email, session, host, opts) {
1304
+ if (!opts.force && mintedByStoredApiKey(session) && !jwtExpiringSoon(session, opts.buffer)) {
1305
+ return session;
1306
+ }
1307
+ if (host === "") {
1308
+ throw new SessionExpiredError(
1309
+ `${serverKey}:${email} (no host to exchange the API key against)`
1310
+ );
1311
+ }
1312
+ const material = resolveApiKeyMaterial(session);
1313
+ let tok;
1314
+ try {
1315
+ tok = await exchangeApiKey(host, material.key, {
1316
+ signal: opts.signal,
1317
+ opaque: material.fromEnv
1318
+ });
1319
+ } catch (err) {
1320
+ throw new SessionExpiredError(`${serverKey}:${email} (${msgOf3(err)})`);
1321
+ }
1322
+ const next = {
1323
+ ...session,
1324
+ jwtAccess: tok.access,
1325
+ jwtExpiresAt: tok.expiresAt,
1326
+ jwtApiKeyFingerprint: apiKeyFingerprint(material.key),
1327
+ // An API-key identity has no refresh token; clear any stale one so the
1328
+ // refresh ladder is never tempted by it on a later non-keyed read.
1329
+ jwtRefresh: void 0
1330
+ };
1331
+ try {
1332
+ await store.saveSession(serverKey, next);
1333
+ } catch (err) {
1334
+ throw new Error(`save credentials: ${msgOf3(err)}`, { cause: err });
1335
+ }
1336
+ return next;
1337
+ }
1160
1338
  async function recoverImpersonatedJwt(store, serverKey, email, session, host, opts) {
1161
1339
  if (session.jwtRefresh !== void 0 && session.jwtRefresh !== "" && host !== "") {
1162
1340
  try {
1163
- const tok = await refreshExternalJwt(host, session.jwtRefresh, { signal: opts.signal });
1341
+ const tok = await refreshExternalJwt(host, session.jwtRefresh, {
1342
+ signal: opts.signal
1343
+ });
1164
1344
  const next2 = {
1165
1345
  ...session,
1166
1346
  jwtAccess: tok.access,
@@ -1341,7 +1521,7 @@ var AuthorizedClient = class _AuthorizedClient {
1341
1521
  }
1342
1522
  const cookieless = session.cookieless === true;
1343
1523
  if (isCookielessSession(session)) {
1344
- if (session.jwtAccess === void 0 || session.jwtAccess === "") {
1524
+ if (!usesApiKey(session) && (session.jwtAccess === void 0 || session.jwtAccess === "")) {
1345
1525
  throw new NotLoggedInError(
1346
1526
  cookieless ? `not logged in: ${serverKey}:${email} (cookie-less session has no JWT)` : `not logged in: ${serverKey}:${email} (impersonated session has no JWT; re-run dr-appspace login-as)`
1347
1527
  );
@@ -1845,7 +2025,7 @@ var AuthorizedClient = class _AuthorizedClient {
1845
2025
  async ensureFreshAuth(bufferMs, signal) {
1846
2026
  const access = this._session.jwtAccess ?? "";
1847
2027
  const refresh = this._session.jwtRefresh ?? "";
1848
- if (access === "" && refresh === "") {
2028
+ if (access === "" && refresh === "" && !usesApiKey(this._session)) {
1849
2029
  return;
1850
2030
  }
1851
2031
  try {
@@ -1894,8 +2074,27 @@ import { z } from "zod";
1894
2074
  var CREDENTIALS_FILE_ENV = "DR_CREDENTIALS_FILE";
1895
2075
  var DEFAULT_CREDENTIALS_FILE = join3(homedir3(), ".dr", "session.json");
1896
2076
  var credentialsFileSchema = z.object({
1897
- access: z.string().min(1),
1898
- refresh: z.string().min(1),
2077
+ access: z.string().min(1).optional(),
2078
+ refresh: z.string().min(1).optional(),
2079
+ // A user-bound API key, exchanged for `access` on demand. A file may hold
2080
+ // only this; `access` then appears after the first exchange.
2081
+ apiKey: z.string().min(1).optional(),
2082
+ // DR-52657: the NAME of an environment variable holding the API key, for
2083
+ // provisioners that must not put the key on disk — an agent sandbox, where
2084
+ // the variable's in-sandbox value is an opaque vault placeholder, or a CI
2085
+ // runner keeping the key in its secret env. Resolved at exchange time and
2086
+ // passed verbatim. Mutually exclusive with `apiKey` (refinement below).
2087
+ apiKeyEnvVar: z.string().min(1).optional(),
2088
+ // Carried verbatim, judged where it is read. `z.string()` (not validated or
2089
+ // `.catch`): a rotation rewrites the whole file from the parsed object, so a
2090
+ // value this schema dropped would be deleted silently, and a top-level
2091
+ // rejection would fail the WHOLE file and lock the operator out of logout.
2092
+ apiKeyExpiresAt: z.string().optional(),
2093
+ // Which key minted `access`, as a non-secret fingerprint. Written by the
2094
+ // CLI, not a provisioner: a rotation replaces `apiKey` and leaves this
2095
+ // naming the old one, which is how the next command knows `access` is not
2096
+ // the new key's. `z.string()` for the same lock-out reason as above.
2097
+ accessApiKeyFingerprint: z.string().optional(),
1899
2098
  server: z.string().min(1),
1900
2099
  email: z.string().min(1),
1901
2100
  // min(1): an empty string is how a hand-rolled producer spells "no cookie",
@@ -1911,7 +2110,19 @@ var credentialsFileSchema = z.object({
1911
2110
  }).loose().refine((c) => c.sessionId === void 0 === (c.csrfToken === void 0), {
1912
2111
  message: "sessionId and csrfToken must be provided together",
1913
2112
  path: ["sessionId"]
1914
- });
2113
+ }).refine((c) => !(c.apiKey !== void 0 && c.apiKeyEnvVar !== void 0), {
2114
+ message: "apiKey and apiKeyEnvVar are mutually exclusive",
2115
+ path: ["apiKeyEnvVar"]
2116
+ }).refine((c) => c.access !== void 0 || c.apiKey !== void 0 || c.apiKeyEnvVar !== void 0, {
2117
+ message: "one of access, apiKey or apiKeyEnvVar is required",
2118
+ path: ["access"]
2119
+ }).refine(
2120
+ (c) => c.apiKey !== void 0 || c.apiKeyEnvVar !== void 0 || c.refresh !== void 0 || c.sessionId !== void 0,
2121
+ {
2122
+ message: "refresh is required unless an apiKey, apiKeyEnvVar or a session cookie is present",
2123
+ path: ["refresh"]
2124
+ }
2125
+ );
1915
2126
  function credentialsFilePath() {
1916
2127
  const override = process.env[CREDENTIALS_FILE_ENV];
1917
2128
  return override !== void 0 && override !== "" ? override : DEFAULT_CREDENTIALS_FILE;
@@ -1924,8 +2135,14 @@ async function readCredentialsFile(path3) {
1924
2135
  if (err.code === "ENOENT") return void 0;
1925
2136
  throw err;
1926
2137
  }
2138
+ let parsed;
2139
+ try {
2140
+ parsed = JSON.parse(raw);
2141
+ } catch {
2142
+ throw new Error(`credential file ${path3} is not valid JSON`);
2143
+ }
1927
2144
  try {
1928
- return credentialsFileSchema.parse(JSON.parse(raw));
2145
+ return credentialsFileSchema.parse(parsed);
1929
2146
  } catch (err) {
1930
2147
  const reason = err instanceof Error ? err.message : String(err);
1931
2148
  throw new Error(`credential file ${path3} is malformed: ${reason}`, { cause: err });
@@ -1933,11 +2150,19 @@ async function readCredentialsFile(path3) {
1933
2150
  }
1934
2151
  async function writeCredentialsFile(path3, creds) {
1935
2152
  await mkdir(dirname2(path3), { recursive: true, mode: 448 });
1936
- const body = JSON.stringify(
1937
- { ...creds, savedAt: creds.savedAt ?? (/* @__PURE__ */ new Date()).toISOString() },
1938
- null,
1939
- 2
1940
- );
2153
+ let next;
2154
+ try {
2155
+ next = credentialsFileSchema.parse({
2156
+ ...creds,
2157
+ savedAt: creds.savedAt ?? (/* @__PURE__ */ new Date()).toISOString()
2158
+ });
2159
+ } catch (err) {
2160
+ const reason = err instanceof Error ? err.message : String(err);
2161
+ throw new Error(`refusing to write an unreadable credential file ${path3}: ${reason}`, {
2162
+ cause: err
2163
+ });
2164
+ }
2165
+ const body = JSON.stringify(next, null, 2);
1941
2166
  const tmp = `${path3}.${process.pid}.tmp`;
1942
2167
  await writeFile(tmp, body, { mode: 384 });
1943
2168
  await chmod(tmp, 384);
@@ -1966,9 +2191,11 @@ var FileKeyringBackend = class _FileKeyringBackend {
1966
2191
  const savedAt = creds.savedAt ?? (/* @__PURE__ */ new Date()).toISOString();
1967
2192
  const sessionId = creds.sessionId ?? "";
1968
2193
  let jwtExpiresAt;
1969
- try {
1970
- jwtExpiresAt = decodeJwtExpiration(creds.access).toISOString();
1971
- } catch {
2194
+ if (creds.access !== void 0 && creds.access !== "") {
2195
+ try {
2196
+ jwtExpiresAt = decodeJwtExpiration(creds.access).toISOString();
2197
+ } catch {
2198
+ }
1972
2199
  }
1973
2200
  return JSON.stringify({
1974
2201
  sessionId,
@@ -1976,7 +2203,19 @@ var FileKeyringBackend = class _FileKeyringBackend {
1976
2203
  savedAt,
1977
2204
  lastUsedAt: savedAt,
1978
2205
  ...sessionId === "" && { cookieless: true },
1979
- ...jwtExpiresAt !== void 0 && { jwtExpiresAt }
2206
+ ...jwtExpiresAt !== void 0 && { jwtExpiresAt },
2207
+ // Carry the API-key carrier through so `KeyringStore.getSession` sees a
2208
+ // keyed identity and routes it to the exchange, plus the fingerprint of the
2209
+ // key that minted the stored `access` so a reload can tell the cached JWT
2210
+ // is still the current key's. Without it a file store would re-exchange on
2211
+ // EVERY request — `ensureFreshAuth` reloads the session each time, and
2212
+ // `mintedByStoredApiKey` can never match a fingerprint that was never
2213
+ // written. `set('meta')` persists it back below.
2214
+ ...creds.apiKey !== void 0 && creds.apiKey !== "" && { apiKey: creds.apiKey },
2215
+ ...creds.apiKeyEnvVar !== void 0 && creds.apiKeyEnvVar !== "" && { apiKeyEnvVar: creds.apiKeyEnvVar },
2216
+ ...creds.accessApiKeyFingerprint !== void 0 && creds.accessApiKeyFingerprint !== "" && {
2217
+ jwtApiKeyFingerprint: creds.accessApiKeyFingerprint
2218
+ }
1980
2219
  });
1981
2220
  }
1982
2221
  static slotOf(creds, account) {
@@ -2001,6 +2240,20 @@ var FileKeyringBackend = class _FileKeyringBackend {
2001
2240
  ...csrfToken !== "" && { csrfToken }
2002
2241
  };
2003
2242
  }
2243
+ /**
2244
+ * The key fingerprint out of a metadata blob (`KeyringStore` emits it as
2245
+ * `jwtApiKeyFingerprint`), empty when absent. Unlike the cookie it is NOT
2246
+ * derived, so it has to be persisted back to the file or every request
2247
+ * re-exchanges.
2248
+ */
2249
+ static fingerprintOf(blob) {
2250
+ try {
2251
+ const parsed = JSON.parse(blob);
2252
+ return typeof parsed.jwtApiKeyFingerprint === "string" ? parsed.jwtApiKeyFingerprint : "";
2253
+ } catch {
2254
+ return "";
2255
+ }
2256
+ }
2004
2257
  async get(_service, account) {
2005
2258
  const creds = await this.load();
2006
2259
  if (creds === void 0) return void 0;
@@ -2026,10 +2279,14 @@ var FileKeyringBackend = class _FileKeyringBackend {
2026
2279
  switch (_FileKeyringBackend.slotOf(creds, account)) {
2027
2280
  case "meta": {
2028
2281
  const cookie = _FileKeyringBackend.cookieOf(value);
2029
- if (cookie.sessionId === creds.sessionId && cookie.csrfToken === creds.csrfToken) {
2282
+ const fingerprint = _FileKeyringBackend.fingerprintOf(value);
2283
+ if (cookie.sessionId === creds.sessionId && cookie.csrfToken === creds.csrfToken && fingerprint === (creds.accessApiKeyFingerprint ?? "")) {
2030
2284
  return;
2031
2285
  }
2032
- await writeCredentialsFile(this.path, { ...creds, ...cookie });
2286
+ const next = { ...creds, ...cookie };
2287
+ if (fingerprint !== "") next.accessApiKeyFingerprint = fingerprint;
2288
+ else delete next.accessApiKeyFingerprint;
2289
+ await writeCredentialsFile(this.path, next);
2033
2290
  return;
2034
2291
  }
2035
2292
  case "access":
@@ -6605,6 +6862,10 @@ function partCalls(element) {
6605
6862
  for (const action of Array.isArray(typed.row_actions) ? typed.row_actions : []) {
6606
6863
  if (action && typeof action === "object" && named(action.call)) out.push(action.call);
6607
6864
  }
6865
+ const bulk = typed.selection && typeof typed.selection === "object" ? typed.selection.bulk_actions : void 0;
6866
+ for (const action of Array.isArray(bulk) ? bulk : []) {
6867
+ if (action && typeof action === "object" && named(action.call)) out.push(action.call);
6868
+ }
6608
6869
  for (const column of Array.isArray(typed.columns) ? typed.columns : []) {
6609
6870
  if (column && typeof column === "object" && column.cell === "toggle" && named(column.call)) out.push(column.call);
6610
6871
  }
@@ -7313,9 +7574,7 @@ function backendSection(app, manifest, pages, elements) {
7313
7574
  signature_ok: signatureOk(source),
7314
7575
  reads_ctx_role: CTX_ROLE.test(source),
7315
7576
  tables_touched: [...new Set([...source.matchAll(TABLE_CALL)].map((match) => match[2]))].sort(),
7316
- verbs: DATA_VERBS.filter(
7317
- (verb) => TABLE_ONLY_VERBS.has(verb) ? callsOnTable(source, verb) : new RegExp(`\\.\\s*${verb}\\s*\\(`).test(source)
7318
- ),
7577
+ verbs: handlerVerbs(source),
7319
7578
  referenced_by_page: isReferenced(handler.name, scriptText) || attributed.has(handler.name)
7320
7579
  };
7321
7580
  }
@@ -7334,6 +7593,10 @@ function backendSection(app, manifest, pages, elements) {
7334
7593
  invariants: invariants(model, roles, handlers)
7335
7594
  };
7336
7595
  }
7596
+ function handlerVerbs(source) {
7597
+ const text3 = typeof source === "string" ? source : "";
7598
+ return DATA_VERBS.filter((verb) => TABLE_ONLY_VERBS.has(verb) ? callsOnTable(text3, verb) : new RegExp(`\\.\\s*${verb}\\s*\\(`).test(text3));
7599
+ }
7337
7600
  function callsOnTable(source, verb) {
7338
7601
  const holders = [TABLE_INLINE];
7339
7602
  for (const match of source.matchAll(TABLE_LOCAL)) holders.push(match[1]);
@@ -8810,7 +9073,7 @@ import { tmpdir as tmpdir2 } from "os";
8810
9073
  import { dirname as dirname3, join as join10 } from "path";
8811
9074
 
8812
9075
  // src/commands/appspace/generate/report.ts
8813
- import { createHash } from "crypto";
9076
+ import { createHash as createHash2 } from "crypto";
8814
9077
  import { mkdir as mkdir3, readFile as readFile7, writeFile as writeFile4 } from "fs/promises";
8815
9078
  import { join as join9 } from "path";
8816
9079
  function reportPath(dir) {
@@ -8858,7 +9121,7 @@ async function ownedFiles(dir) {
8858
9121
  }
8859
9122
  }
8860
9123
  function digestOf(text3) {
8861
- return createHash("sha256").update(text3, "utf8").digest("hex");
9124
+ return createHash2("sha256").update(text3, "utf8").digest("hex");
8862
9125
  }
8863
9126
  async function recordedDigests(dir) {
8864
9127
  try {
@@ -9124,7 +9387,7 @@ function registerGetCommand(parent) {
9124
9387
 
9125
9388
  // src/commands/appspace/generate/catalog.ts
9126
9389
  import { readFile as readFile9 } from "fs/promises";
9127
- import { createHash as createHash2 } from "crypto";
9390
+ import { createHash as createHash3 } from "crypto";
9128
9391
  var NO_CATALOG = {
9129
9392
  source: "none",
9130
9393
  metrics: [],
@@ -11041,6 +11304,9 @@ function payloadProducers(contract, { moduleSource = () => void 0, pageSource =
11041
11304
  for (const action of Array.isArray(typed.row_actions) ? typed.row_actions : []) {
11042
11305
  if (isPlainObject3(action) && named2(action.call)) add(action.call, { page, uid, what: "data_grid", keys: ["id", "row"] });
11043
11306
  }
11307
+ for (const action of isPlainObject3(typed.selection) && Array.isArray(typed.selection.bulk_actions) ? typed.selection.bulk_actions : []) {
11308
+ if (isPlainObject3(action) && named2(action.call)) add(action.call, { page, uid, what: "data_grid", keys: ["ids"] });
11309
+ }
11044
11310
  for (const column of Array.isArray(typed.columns) ? typed.columns : []) {
11045
11311
  if (isPlainObject3(column) && column.cell === "toggle" && named2(column.call)) add(column.call, { page, uid, what: "data_grid", keys: ["id", "row", "field", "value"] });
11046
11312
  }
@@ -11835,6 +12101,11 @@ function stateCallers(contract, { moduleSource = () => void 0, handlerSource = (
11835
12101
  const asks = action.confirm === true || named3(action.confirm);
11836
12102
  add(action.call, { page, uid, id, what: `row action "${action.label ?? action.call}"`, asks });
11837
12103
  }
12104
+ for (const action of isPlainObject4(typed.selection) && Array.isArray(typed.selection.bulk_actions) ? typed.selection.bulk_actions : []) {
12105
+ if (!isPlainObject4(action) || !named3(action.call)) continue;
12106
+ const asks = action.confirm === true || named3(action.confirm);
12107
+ add(action.call, { page, uid, id, what: `bulk action "${action.label ?? action.call}"`, asks });
12108
+ }
11838
12109
  break;
11839
12110
  case "action":
11840
12111
  case "detail": {
@@ -12164,13 +12435,18 @@ function isPlatformChartColour(n, value) {
12164
12435
  var look_exports = {};
12165
12436
  __export(look_exports, {
12166
12437
  HEADER_ACTION_TYPES: () => HEADER_ACTION_TYPES,
12438
+ STATE_NAMES: () => STATE_NAMES,
12439
+ folderParams: () => folderParams,
12167
12440
  headerActionProblems: () => headerActionProblems,
12168
12441
  humanise: () => humanise,
12442
+ isAppContract: () => isAppContract,
12443
+ kpiStripWorkflowProblems: () => kpiStripWorkflowProblems,
12169
12444
  layoutProblems: () => layoutProblems,
12170
12445
  lookPages: () => lookPages,
12171
12446
  navCountProblems: () => navCountProblems,
12172
12447
  railReachProblems: () => railReachProblems,
12173
12448
  rootsOf: () => rootsOf,
12449
+ shellExpectedProblems: () => shellExpectedProblems,
12174
12450
  untitledCards: () => untitledCards
12175
12451
  });
12176
12452
  var isPlainObject5 = (value) => value !== null && typeof value === "object" && !Array.isArray(value);
@@ -12478,6 +12754,181 @@ function railReachProblems(contract) {
12478
12754
  }
12479
12755
  return out;
12480
12756
  }
12757
+ var STATE_NAMES = ["folder", "status", "stage", "state", "queue"];
12758
+ var CATCH_ALL = /* @__PURE__ */ new Set(["", "all", "any", "*", "everything"]);
12759
+ var PICK_KINDS = /* @__PURE__ */ new Set(["select", "multi"]);
12760
+ var ROW_TYPES = /* @__PURE__ */ new Set(["data_grid", "list", "queue"]);
12761
+ var NOT_COUNTS = ["sum", "avg", "avg_days", "count_events"];
12762
+ var VALUES_FIX = "state `app.shell` with `nav: rail` and one entry per value, counts from a `summary` handler; look.md rule 1";
12763
+ var PAGES_FIX = "state `app.shell` with `nav: rail`; with no `rail.entries` the rail lists the pages; look.md rule 1";
12764
+ var COUNTS_FIX = "state `app.shell` with `nav: rail` and one entry per state, each counting a measure of that summary (`pages[].nav.counts.source`); look.md rule 1";
12765
+ function isAppContract(contract) {
12766
+ const kind = contract?.app?.kind;
12767
+ return kind !== "dashboard" && (kind === "app" || isPlainObject5(contract?.data?.app));
12768
+ }
12769
+ function tablesOf(contract) {
12770
+ const tables = contract?.app?.data_model?.tables;
12771
+ return isPlainObject5(tables) ? tables : {};
12772
+ }
12773
+ function enumOf(column) {
12774
+ return Array.isArray(column?.enum) ? column.enum.filter((one) => typeof one === "string") : [];
12775
+ }
12776
+ function stateColumn(contract, table) {
12777
+ const columns = isPlainObject5(tablesOf(contract)[table]?.columns) ? tablesOf(contract)[table].columns : {};
12778
+ return Object.keys(columns).find((name) => STATE_NAMES.includes(name) && enumOf(columns[name]).length >= 3) ?? null;
12779
+ }
12780
+ function realValues(param) {
12781
+ return Array.isArray(param?.values) ? param.values.map(String).filter((value) => !CATCH_ALL.has(value.toLowerCase())) : [];
12782
+ }
12783
+ function ownPages(contract) {
12784
+ return (Array.isArray(contract?.pages) ? contract.pages : []).filter((page) => page?.owned !== true && named4(page?.path));
12785
+ }
12786
+ function folderParams(contract) {
12787
+ const enums = [];
12788
+ for (const [table, spec] of Object.entries(tablesOf(contract))) {
12789
+ const columns = isPlainObject5(spec?.columns) ? spec.columns : {};
12790
+ for (const name of STATE_NAMES) if (enumOf(columns[name]).length >= 3) enums.push({ where: `${table}.${name}`, values: new Set(enumOf(columns[name])) });
12791
+ }
12792
+ const out = [];
12793
+ for (const page of ownPages(contract)) {
12794
+ for (const param of Array.isArray(page.params) ? page.params : []) {
12795
+ if (!PICK_KINDS.has(param?.kind) || !named4(param?.name)) continue;
12796
+ const values = realValues(param);
12797
+ if (STATE_NAMES.includes(param.name) && (values.length >= 3 || !Array.isArray(param.values))) {
12798
+ out.push({ page, param, values, why: `named "${param.name}"` });
12799
+ continue;
12800
+ }
12801
+ if (values.length < 3) continue;
12802
+ const match = enums.find((one) => values.every((value) => one.values.has(value)));
12803
+ if (match !== void 0) out.push({ page, param, values, why: `its values are the enum of ${match.where}` });
12804
+ }
12805
+ }
12806
+ return out;
12807
+ }
12808
+ function tableOf2(contract, element) {
12809
+ const props = isPlainObject5(element?.props) ? element.props : {};
12810
+ if (named4(props.entity)) return props.entity;
12811
+ const source = props.source;
12812
+ if (!named4(source)) return null;
12813
+ const handler = contract?.data?.app?.handlers?.[source];
12814
+ if (isPlainObject5(handler) && named4(handler.table)) return handler.table;
12815
+ const read = contract?.data?.sources?.[source];
12816
+ if (isPlainObject5(read?.from) && named4(read.from.table)) return read.from.table;
12817
+ return null;
12818
+ }
12819
+ function listingOn(contract, page, test) {
12820
+ const elements = isPlainObject5(contract?.elements) ? contract.elements : {};
12821
+ for (const [uid, element] of Object.entries(elements)) {
12822
+ if (element?.page !== page.path || !ROW_TYPES.has(element.type)) continue;
12823
+ const table = tableOf2(contract, element);
12824
+ if (table !== null && test(table)) return [uid, element, table];
12825
+ }
12826
+ return null;
12827
+ }
12828
+ function stateCountColumn(spec) {
12829
+ if (!isPlainObject5(spec) || NOT_COUNTS.some((key) => spec[key] !== void 0)) return null;
12830
+ const where2 = spec.count === true ? isPlainObject5(spec.where) ? spec.where : {} : spec.count === void 0 ? spec : null;
12831
+ if (where2 === null) return null;
12832
+ return Object.keys(where2).map((key) => key.split("__")[0]).find((column) => STATE_NAMES.includes(column)) ?? null;
12833
+ }
12834
+ function stateCounts(handler) {
12835
+ const out = {};
12836
+ for (const [measure, spec] of Object.entries(isPlainObject5(handler?.measures) ? handler.measures : {})) {
12837
+ const column = stateCountColumn(spec);
12838
+ if (column !== null) out[measure] = column;
12839
+ }
12840
+ return out;
12841
+ }
12842
+ function pageReads(contract, page, name) {
12843
+ const elements = isPlainObject5(contract?.elements) ? contract.elements : {};
12844
+ if (page?.nav?.counts?.source === name) return true;
12845
+ return Object.values(elements).some((element) => element?.page === page.path && element.props?.source === name);
12846
+ }
12847
+ function shellExpectedProblems(contract) {
12848
+ if (!isAppContract(contract)) return [];
12849
+ const shell = isPlainObject5(contract?.app?.shell) ? contract.app.shell : {};
12850
+ if (shell.nav === "rail") return [];
12851
+ const out = [];
12852
+ const say = (page, param, where2, message, fix) => out.push({ rule: "L8", code: "shell-expected", page, param, where: where2, message, fix });
12853
+ const has = shell.nav === void 0 || shell.nav === null ? "states no app.shell" : `states app.shell.nav ${JSON.stringify(shell.nav)}`;
12854
+ const pages = ownPages(contract);
12855
+ if (pages.length > 4) say(null, null, "pages", `the app has ${pages.length} pages and ${has}, so its screens are a row of tabs too long to scan; an app this size is a rail`, PAGES_FIX);
12856
+ const named_ = /* @__PURE__ */ new Set();
12857
+ for (const { page, param, values, why } of folderParams(contract)) {
12858
+ named_.add(page.path);
12859
+ const listed = values.length > 0 ? ` with ${values.length} values (${values.map((one) => JSON.stringify(one)).join(", ")})` : " (its values from elsewhere)";
12860
+ say(page.path, param.name, `${page.path} params.${param.name}`, `${page.path} declares ${param.kind} param "${param.name}" (${why})${listed} and the app ${has}, so its folders are a dropdown with no counts; they are the rail's entries`, VALUES_FIX);
12861
+ }
12862
+ const handlers = isPlainObject5(contract?.data?.app?.handlers) ? contract.data.app.handlers : {};
12863
+ for (const [name, handler] of Object.entries(handlers)) {
12864
+ if (handler?.role !== "summary" || !named4(handler.table)) continue;
12865
+ const column = stateColumn(contract, handler.table);
12866
+ const counts = Object.fromEntries(Object.entries(stateCounts(handler)).filter(([, by]) => by === column));
12867
+ if (column === null || Object.keys(counts).length < 3) continue;
12868
+ for (const page of pages) {
12869
+ if (named_.has(page.path) || !pageReads(contract, page, name)) continue;
12870
+ const listing = listingOn(contract, page, (table) => table === handler.table);
12871
+ if (listing === null) continue;
12872
+ const columns = [column];
12873
+ say(
12874
+ page.path,
12875
+ null,
12876
+ `${page.path} ${name}`,
12877
+ `${page.path} reads the summary "${name}", whose measures count ${handler.table} by ${columns.join(", ")} (${Object.keys(counts).map((one) => JSON.stringify(one)).join(", ")}), beside ${nameOf3(listing[0], listing[1])} listing ${handler.table}, and the app ${has}: those counts are folders, and folders are the rail's entries`,
12878
+ COUNTS_FIX
12879
+ );
12880
+ }
12881
+ }
12882
+ return out;
12883
+ }
12884
+ function kpiStripWorkflowProblems(contract) {
12885
+ if (!isAppContract(contract)) return [];
12886
+ const elements = isPlainObject5(contract?.elements) ? contract.elements : {};
12887
+ const handlers = isPlainObject5(contract?.data?.app?.handlers) ? contract.data.app.handlers : {};
12888
+ const folderPages = /* @__PURE__ */ new Map();
12889
+ for (const { page, param } of folderParams(contract)) if (!folderPages.has(page.path)) folderPages.set(page.path, param.name);
12890
+ const out = [];
12891
+ for (const page of ownPages(contract)) {
12892
+ const listing = listingOn(contract, page, (table) => stateColumn(contract, table) !== null);
12893
+ const workflow = listing !== null ? `${nameOf3(listing[0], listing[1])} lists ${listing[2]}, whose ${stateColumn(contract, listing[2])} column is a set of states` : folderPages.has(page.path) ? `the page declares the folder-like param "${folderPages.get(page.path)}"` : null;
12894
+ if (workflow === null) continue;
12895
+ const banded = bandsOf(contract, page);
12896
+ const inBand = new Set(banded.flatMap((band) => band.roots));
12897
+ const loose = rootsOf(contract, page).filter((uid) => !inBand.has(uid));
12898
+ for (const { section, roots } of [...banded, { section: null, roots: loose }]) {
12899
+ const stats = roots.filter((uid) => elements[uid]?.type === "stat");
12900
+ if (stats.length < 2) continue;
12901
+ const bySource = /* @__PURE__ */ new Map();
12902
+ for (const uid of stats) {
12903
+ const source = elements[uid]?.props?.source;
12904
+ if (!named4(source) || handlers[source]?.role !== "summary") continue;
12905
+ if (!bySource.has(source)) bySource.set(source, []);
12906
+ bySource.get(source).push(uid);
12907
+ }
12908
+ for (const [source, group] of bySource) {
12909
+ if (group.length < 2) continue;
12910
+ const measures = handlers[source].measures;
12911
+ const known = isPlainObject5(measures) && group.every((uid) => Object.prototype.hasOwnProperty.call(measures, elements[uid]?.props?.measure));
12912
+ const counts = stateCounts(handlers[source]);
12913
+ if (known ? !group.every((uid) => counts[elements[uid].props.measure] !== void 0) : group.length !== stats.length) continue;
12914
+ const where2 = section === null ? page.path : `band "${section.id}" on ${page.path}`;
12915
+ const shape = section?.layout === "kpi_strip" ? "is a kpi_strip of" : "holds a row of";
12916
+ const what = known ? `${group.length} counts of rows by ${[...new Set(group.map((uid) => counts[elements[uid].props.measure]))].join(", ")}` : `${group.length} counts`;
12917
+ out.push({
12918
+ rule: "L9",
12919
+ code: "kpi-strip-on-workflow",
12920
+ page: page.path,
12921
+ section: section?.id ?? null,
12922
+ where: where2,
12923
+ message: `${where2} ${shape} ${what} from the summary "${source}", and ${workflow}: folder or state counts belong on the rail, not in a KPI strip (workflow.md \xA78)`,
12924
+ fix: VALUES_FIX
12925
+ });
12926
+ break;
12927
+ }
12928
+ }
12929
+ }
12930
+ return out;
12931
+ }
12481
12932
 
12482
12933
  // ../flows/insights-builder/kit/contract/platformBadges.mjs
12483
12934
  var PLATFORM_BADGE_COLOURS = Object.freeze([
@@ -13629,8 +14080,9 @@ function compileApp(contract, { kit = null, runtime = RUNTIME, extra = {} } = {}
13629
14080
  const warnings = [...sourceFindings, ...widgetFindings].filter((one) => one.advisory).map(({ advisory: _advisory, ...one }) => one);
13630
14081
  problems.push(...widgetFindings.filter((one) => !one.advisory));
13631
14082
  if (tree.problems.length === 0) problems.push(...partProblems(declared, tree, handlers, sources, contract?.pages, model, { "dr-components": appComponents }));
13632
- if (tree.problems.length === 0) problems.push(...workflowProblems(declared, tree, handlers, sources, contract?.backend?.handlers, model));
13633
- const selectedFindings = tree.problems.length === 0 ? selectedProblems(declared, tree, handlers, sources, contract?.backend?.handlers, contract?.pages) : [];
14083
+ const backendFacts = inferredBackendHandlers(contract?.backend?.handlers, handlers, extra);
14084
+ if (tree.problems.length === 0) problems.push(...workflowProblems(declared, tree, handlers, sources, backendFacts, model));
14085
+ const selectedFindings = tree.problems.length === 0 ? selectedProblems(declared, tree, handlers, sources, backendFacts, contract?.pages) : [];
13634
14086
  problems.push(...selectedFindings.filter((one) => !one.advisory));
13635
14087
  warnings.push(...selectedFindings.filter((one) => one.advisory).map(({ advisory: _advisory, ...one }) => one));
13636
14088
  if (problems.length > 0) return { files: {}, problems, contract, warnings };
@@ -14684,7 +15136,7 @@ function workflowProblems(elements, tree, handlers = {}, sources = {}, backendHa
14684
15136
  const say = (code, pointer2, message) => problems.push({ code, pointer: pointer2, message: `${type} "${props.id ?? uid}" ${message}` });
14685
15137
  const readsFrom = (source, pointer2, what) => {
14686
15138
  if (!stated(source)) say("part_source_shape", pointer2, `names no source; a ${type} reads ${what} from a reader \u2014 a ${either3(WORKFLOW_READERS)} handler, or a data.sources entry`);
14687
- else if (!reader(source)) say("part_source_shape", pointer2, `reads "${source}", which ${roleOf(source) === "custom" || roleOf(source) === void 0 ? `is no reader (a custom handler is one only when its backend.handlers verbs are all query, get or aggregate${Array.isArray(verbsOf(source)) ? `; it states ${JSON.stringify(verbsOf(source))}` : ""})` : `has role ${roleOf(source)}`}; a ${type} reads ${what} from a list, inbox, history or summary handler, a data.sources entry, or such a custom handler, and never calls a writer`);
15139
+ else if (!reader(source)) say("part_source_shape", pointer2, `reads "${source}", which ${roleOf(source) === "custom" || roleOf(source) === void 0 ? `is no reader (a custom handler is one only when its verbs \u2014 stated in backend.handlers, else read off handlers/${source}.py, a guess you settle by stating them \u2014 are all query, get or aggregate${Array.isArray(verbsOf(source)) ? `; ${backendHandlers?.[source]?.inferred === true ? "its file reads as" : "it states"} ${JSON.stringify(verbsOf(source))}` : ""})` : `has role ${roleOf(source)}`}; a ${type} reads ${what} from a list, inbox, history or summary handler, a data.sources entry, or such a custom handler, and never calls a writer`);
14688
15140
  };
14689
15141
  if (type !== "split_pane" && typed.bind === "selected" && !bindReaches(uid)) {
14690
15142
  say("part_bind", `${at}/props/props/bind`, `binds the selected row where no row is ever picked for it: put it at the page root, in a root split_pane or panel, or inside a form or detail with bind: "selected"`);
@@ -14767,6 +15219,21 @@ function workflowProblems(elements, tree, handlers = {}, sources = {}, backendHa
14767
15219
  return problems;
14768
15220
  }
14769
15221
  var ROW_READERS = ["list", "inbox", "history", "summary"];
15222
+ function inferredBackendHandlers(stated2, handlers = {}, extra = {}) {
15223
+ const out = isPlainObject6(stated2) ? { ...stated2 } : {};
15224
+ for (const [name, row] of Object.entries(isPlainObject6(handlers) ? handlers : {})) {
15225
+ if (row?.role !== "custom") continue;
15226
+ if (isPlainObject6(out[name]) && Array.isArray(out[name].verbs)) continue;
15227
+ const source = extra?.[`handlers/${name}.py`];
15228
+ if (typeof source !== "string") continue;
15229
+ const verbs = handlerVerbs(source);
15230
+ const hidden = !verbs.some((verb) => INFER_WRITES.includes(verb)) && WRITE_ANYWHERE.test(source);
15231
+ out[name] = { ...isPlainObject6(out[name]) ? out[name] : {}, verbs: hidden ? [...verbs, "update"] : verbs, inferred: true };
15232
+ }
15233
+ return out;
15234
+ }
15235
+ var INFER_WRITES = ["insert", "update", "delete"];
15236
+ var WRITE_ANYWHERE = /\.\s*(?:insert|update|delete)\s*\(/;
14770
15237
  var READ_VERBS = ["query", "get", "aggregate"];
14771
15238
  var ROOT_HOLDERS = ["panel", "split_pane"];
14772
15239
  var BOUND_TYPES = ["form", "detail"];
@@ -14858,7 +15325,7 @@ function selectedProblems(elements, tree, handlers = {}, sources = {}, backendHa
14858
15325
  "bind_source",
14859
15326
  uid,
14860
15327
  "/props/source",
14861
- `${element.type} "${nameOf5(uid)}" binds the selected row and reads it from "${source}", ${role !== void 0 && role !== "custom" ? `a ${role} handler` : "a custom handler whose backend.handlers verbs are not all reads"}; a pick would call a writer, so the runtime never calls it and draws an error \u2014 read through a list, inbox, history or summary handler, a data.sources entry, or a custom handler whose verbs are all query, get or aggregate`
15328
+ `${element.type} "${nameOf5(uid)}" binds the selected row and reads it from "${source}", ${role !== void 0 && role !== "custom" ? `a ${role} handler` : `a custom handler whose verbs (${backendHandlers?.[source]?.inferred === true ? `read off handlers/${source}.py \u2014 state them in backend.handlers if that guess is wrong` : "stated in backend.handlers"}) are not all reads`}; a pick would call a writer, so the runtime never calls it and draws an error \u2014 read through a list, inbox, history or summary handler, a data.sources entry, or a custom handler whose verbs are all query, get or aggregate`
14862
15329
  );
14863
15330
  } else if (named5(props.entity) && named5(tableOfSource(source, handlers, sources)) && tableOfSource(source, handlers, sources) !== props.entity) {
14864
15331
  say("bind_source", uid, "/props/source", `${element.type} "${nameOf5(uid)}" is over "${props.entity}" and reads the selected row from "${source}", whose rows are "${tableOfSource(source, handlers, sources)}"; it would show (and a Save would post) another table's row \u2014 read the row from a reader over "${props.entity}"`);
@@ -19145,7 +19612,7 @@ function interpret(status, body, path3, contract) {
19145
19612
  }
19146
19613
  function fromRefusal(status, detail, contract) {
19147
19614
  const refused = `the push would be refused (${String(status)})`;
19148
- const tables = tablesOf(contract);
19615
+ const tables = tablesOf2(contract);
19149
19616
  const conflicts = conflictTables(detail);
19150
19617
  if (conflicts !== void 0) {
19151
19618
  return conflicts.map(([table, owners]) => {
@@ -19229,7 +19696,7 @@ async function stage(dir, scratch, files) {
19229
19696
  }
19230
19697
  return null;
19231
19698
  }
19232
- function tablesOf(contract) {
19699
+ function tablesOf2(contract) {
19233
19700
  const out = /* @__PURE__ */ new Map();
19234
19701
  const app = isObj3(contract) && isObj3(contract["app"]) ? contract["app"] : {};
19235
19702
  const model = isObj3(app["data_model"]) ? app["data_model"] : {};
@@ -22619,6 +23086,10 @@ function partCalls2(element) {
22619
23086
  for (const action of Array.isArray(typed.row_actions) ? typed.row_actions : []) {
22620
23087
  if (action && typeof action === "object" && named6(action.call)) out.push(action.call);
22621
23088
  }
23089
+ const bulk = typed.selection && typeof typed.selection === "object" ? typed.selection.bulk_actions : void 0;
23090
+ for (const action of Array.isArray(bulk) ? bulk : []) {
23091
+ if (action && typeof action === "object" && named6(action.call)) out.push(action.call);
23092
+ }
22622
23093
  for (const column of Array.isArray(typed.columns) ? typed.columns : []) {
22623
23094
  if (column && typeof column === "object" && column.cell === "toggle" && named6(column.call)) out.push(column.call);
22624
23095
  }
@@ -23327,9 +23798,7 @@ function backendSection2(app, manifest, pages, elements) {
23327
23798
  signature_ok: signatureOk2(source),
23328
23799
  reads_ctx_role: CTX_ROLE2.test(source),
23329
23800
  tables_touched: [...new Set([...source.matchAll(TABLE_CALL2)].map((match) => match[2]))].sort(),
23330
- verbs: DATA_VERBS2.filter(
23331
- (verb) => TABLE_ONLY_VERBS2.has(verb) ? callsOnTable2(source, verb) : new RegExp(`\\.\\s*${verb}\\s*\\(`).test(source)
23332
- ),
23801
+ verbs: handlerVerbs2(source),
23333
23802
  referenced_by_page: isReferenced3(handler.name, scriptText) || attributed.has(handler.name)
23334
23803
  };
23335
23804
  }
@@ -23348,6 +23817,10 @@ function backendSection2(app, manifest, pages, elements) {
23348
23817
  invariants: invariants2(model, roles, handlers)
23349
23818
  };
23350
23819
  }
23820
+ function handlerVerbs2(source) {
23821
+ const text3 = typeof source === "string" ? source : "";
23822
+ return DATA_VERBS2.filter((verb) => TABLE_ONLY_VERBS2.has(verb) ? callsOnTable2(text3, verb) : new RegExp(`\\.\\s*${verb}\\s*\\(`).test(text3));
23823
+ }
23351
23824
  function callsOnTable2(source, verb) {
23352
23825
  const holders = [TABLE_INLINE2];
23353
23826
  for (const match of source.matchAll(TABLE_LOCAL2)) holders.push(match[1]);
@@ -24099,6 +24572,9 @@ function payloadProducers2(contract, { moduleSource = () => void 0, pageSource =
24099
24572
  for (const action of Array.isArray(typed.row_actions) ? typed.row_actions : []) {
24100
24573
  if (isPlainObject11(action) && named7(action.call)) add(action.call, { page, uid, what: "data_grid", keys: ["id", "row"] });
24101
24574
  }
24575
+ for (const action of isPlainObject11(typed.selection) && Array.isArray(typed.selection.bulk_actions) ? typed.selection.bulk_actions : []) {
24576
+ if (isPlainObject11(action) && named7(action.call)) add(action.call, { page, uid, what: "data_grid", keys: ["ids"] });
24577
+ }
24102
24578
  for (const column of Array.isArray(typed.columns) ? typed.columns : []) {
24103
24579
  if (isPlainObject11(column) && column.cell === "toggle" && named7(column.call)) add(column.call, { page, uid, what: "data_grid", keys: ["id", "row", "field", "value"] });
24104
24580
  }
@@ -25194,6 +25670,181 @@ function railReachProblems2(contract) {
25194
25670
  }
25195
25671
  return out;
25196
25672
  }
25673
+ var STATE_NAMES2 = ["folder", "status", "stage", "state", "queue"];
25674
+ var CATCH_ALL2 = /* @__PURE__ */ new Set(["", "all", "any", "*", "everything"]);
25675
+ var PICK_KINDS2 = /* @__PURE__ */ new Set(["select", "multi"]);
25676
+ var ROW_TYPES2 = /* @__PURE__ */ new Set(["data_grid", "list", "queue"]);
25677
+ var NOT_COUNTS2 = ["sum", "avg", "avg_days", "count_events"];
25678
+ var VALUES_FIX2 = "state `app.shell` with `nav: rail` and one entry per value, counts from a `summary` handler; look.md rule 1";
25679
+ var PAGES_FIX2 = "state `app.shell` with `nav: rail`; with no `rail.entries` the rail lists the pages; look.md rule 1";
25680
+ var COUNTS_FIX2 = "state `app.shell` with `nav: rail` and one entry per state, each counting a measure of that summary (`pages[].nav.counts.source`); look.md rule 1";
25681
+ function isAppContract2(contract) {
25682
+ const kind = contract?.app?.kind;
25683
+ return kind !== "dashboard" && (kind === "app" || isPlainObject13(contract?.data?.app));
25684
+ }
25685
+ function tablesOf3(contract) {
25686
+ const tables = contract?.app?.data_model?.tables;
25687
+ return isPlainObject13(tables) ? tables : {};
25688
+ }
25689
+ function enumOf2(column) {
25690
+ return Array.isArray(column?.enum) ? column.enum.filter((one) => typeof one === "string") : [];
25691
+ }
25692
+ function stateColumn2(contract, table) {
25693
+ const columns = isPlainObject13(tablesOf3(contract)[table]?.columns) ? tablesOf3(contract)[table].columns : {};
25694
+ return Object.keys(columns).find((name) => STATE_NAMES2.includes(name) && enumOf2(columns[name]).length >= 3) ?? null;
25695
+ }
25696
+ function realValues2(param) {
25697
+ return Array.isArray(param?.values) ? param.values.map(String).filter((value) => !CATCH_ALL2.has(value.toLowerCase())) : [];
25698
+ }
25699
+ function ownPages2(contract) {
25700
+ return (Array.isArray(contract?.pages) ? contract.pages : []).filter((page) => page?.owned !== true && named9(page?.path));
25701
+ }
25702
+ function folderParams2(contract) {
25703
+ const enums = [];
25704
+ for (const [table, spec] of Object.entries(tablesOf3(contract))) {
25705
+ const columns = isPlainObject13(spec?.columns) ? spec.columns : {};
25706
+ for (const name of STATE_NAMES2) if (enumOf2(columns[name]).length >= 3) enums.push({ where: `${table}.${name}`, values: new Set(enumOf2(columns[name])) });
25707
+ }
25708
+ const out = [];
25709
+ for (const page of ownPages2(contract)) {
25710
+ for (const param of Array.isArray(page.params) ? page.params : []) {
25711
+ if (!PICK_KINDS2.has(param?.kind) || !named9(param?.name)) continue;
25712
+ const values = realValues2(param);
25713
+ if (STATE_NAMES2.includes(param.name) && (values.length >= 3 || !Array.isArray(param.values))) {
25714
+ out.push({ page, param, values, why: `named "${param.name}"` });
25715
+ continue;
25716
+ }
25717
+ if (values.length < 3) continue;
25718
+ const match = enums.find((one) => values.every((value) => one.values.has(value)));
25719
+ if (match !== void 0) out.push({ page, param, values, why: `its values are the enum of ${match.where}` });
25720
+ }
25721
+ }
25722
+ return out;
25723
+ }
25724
+ function tableOf3(contract, element) {
25725
+ const props = isPlainObject13(element?.props) ? element.props : {};
25726
+ if (named9(props.entity)) return props.entity;
25727
+ const source = props.source;
25728
+ if (!named9(source)) return null;
25729
+ const handler = contract?.data?.app?.handlers?.[source];
25730
+ if (isPlainObject13(handler) && named9(handler.table)) return handler.table;
25731
+ const read = contract?.data?.sources?.[source];
25732
+ if (isPlainObject13(read?.from) && named9(read.from.table)) return read.from.table;
25733
+ return null;
25734
+ }
25735
+ function listingOn2(contract, page, test) {
25736
+ const elements = isPlainObject13(contract?.elements) ? contract.elements : {};
25737
+ for (const [uid, element] of Object.entries(elements)) {
25738
+ if (element?.page !== page.path || !ROW_TYPES2.has(element.type)) continue;
25739
+ const table = tableOf3(contract, element);
25740
+ if (table !== null && test(table)) return [uid, element, table];
25741
+ }
25742
+ return null;
25743
+ }
25744
+ function stateCountColumn2(spec) {
25745
+ if (!isPlainObject13(spec) || NOT_COUNTS2.some((key) => spec[key] !== void 0)) return null;
25746
+ const where2 = spec.count === true ? isPlainObject13(spec.where) ? spec.where : {} : spec.count === void 0 ? spec : null;
25747
+ if (where2 === null) return null;
25748
+ return Object.keys(where2).map((key) => key.split("__")[0]).find((column) => STATE_NAMES2.includes(column)) ?? null;
25749
+ }
25750
+ function stateCounts2(handler) {
25751
+ const out = {};
25752
+ for (const [measure, spec] of Object.entries(isPlainObject13(handler?.measures) ? handler.measures : {})) {
25753
+ const column = stateCountColumn2(spec);
25754
+ if (column !== null) out[measure] = column;
25755
+ }
25756
+ return out;
25757
+ }
25758
+ function pageReads2(contract, page, name) {
25759
+ const elements = isPlainObject13(contract?.elements) ? contract.elements : {};
25760
+ if (page?.nav?.counts?.source === name) return true;
25761
+ return Object.values(elements).some((element) => element?.page === page.path && element.props?.source === name);
25762
+ }
25763
+ function shellExpectedProblems2(contract) {
25764
+ if (!isAppContract2(contract)) return [];
25765
+ const shell = isPlainObject13(contract?.app?.shell) ? contract.app.shell : {};
25766
+ if (shell.nav === "rail") return [];
25767
+ const out = [];
25768
+ const say = (page, param, where2, message, fix) => out.push({ rule: "L8", code: "shell-expected", page, param, where: where2, message, fix });
25769
+ const has = shell.nav === void 0 || shell.nav === null ? "states no app.shell" : `states app.shell.nav ${JSON.stringify(shell.nav)}`;
25770
+ const pages = ownPages2(contract);
25771
+ if (pages.length > 4) say(null, null, "pages", `the app has ${pages.length} pages and ${has}, so its screens are a row of tabs too long to scan; an app this size is a rail`, PAGES_FIX2);
25772
+ const named_ = /* @__PURE__ */ new Set();
25773
+ for (const { page, param, values, why } of folderParams2(contract)) {
25774
+ named_.add(page.path);
25775
+ const listed = values.length > 0 ? ` with ${values.length} values (${values.map((one) => JSON.stringify(one)).join(", ")})` : " (its values from elsewhere)";
25776
+ say(page.path, param.name, `${page.path} params.${param.name}`, `${page.path} declares ${param.kind} param "${param.name}" (${why})${listed} and the app ${has}, so its folders are a dropdown with no counts; they are the rail's entries`, VALUES_FIX2);
25777
+ }
25778
+ const handlers = isPlainObject13(contract?.data?.app?.handlers) ? contract.data.app.handlers : {};
25779
+ for (const [name, handler] of Object.entries(handlers)) {
25780
+ if (handler?.role !== "summary" || !named9(handler.table)) continue;
25781
+ const column = stateColumn2(contract, handler.table);
25782
+ const counts = Object.fromEntries(Object.entries(stateCounts2(handler)).filter(([, by]) => by === column));
25783
+ if (column === null || Object.keys(counts).length < 3) continue;
25784
+ for (const page of pages) {
25785
+ if (named_.has(page.path) || !pageReads2(contract, page, name)) continue;
25786
+ const listing = listingOn2(contract, page, (table) => table === handler.table);
25787
+ if (listing === null) continue;
25788
+ const columns = [column];
25789
+ say(
25790
+ page.path,
25791
+ null,
25792
+ `${page.path} ${name}`,
25793
+ `${page.path} reads the summary "${name}", whose measures count ${handler.table} by ${columns.join(", ")} (${Object.keys(counts).map((one) => JSON.stringify(one)).join(", ")}), beside ${nameOf4(listing[0], listing[1])} listing ${handler.table}, and the app ${has}: those counts are folders, and folders are the rail's entries`,
25794
+ COUNTS_FIX2
25795
+ );
25796
+ }
25797
+ }
25798
+ return out;
25799
+ }
25800
+ function kpiStripWorkflowProblems2(contract) {
25801
+ if (!isAppContract2(contract)) return [];
25802
+ const elements = isPlainObject13(contract?.elements) ? contract.elements : {};
25803
+ const handlers = isPlainObject13(contract?.data?.app?.handlers) ? contract.data.app.handlers : {};
25804
+ const folderPages = /* @__PURE__ */ new Map();
25805
+ for (const { page, param } of folderParams2(contract)) if (!folderPages.has(page.path)) folderPages.set(page.path, param.name);
25806
+ const out = [];
25807
+ for (const page of ownPages2(contract)) {
25808
+ const listing = listingOn2(contract, page, (table) => stateColumn2(contract, table) !== null);
25809
+ const workflow = listing !== null ? `${nameOf4(listing[0], listing[1])} lists ${listing[2]}, whose ${stateColumn2(contract, listing[2])} column is a set of states` : folderPages.has(page.path) ? `the page declares the folder-like param "${folderPages.get(page.path)}"` : null;
25810
+ if (workflow === null) continue;
25811
+ const banded = bandsOf3(contract, page);
25812
+ const inBand = new Set(banded.flatMap((band) => band.roots));
25813
+ const loose = rootsOf3(contract, page).filter((uid) => !inBand.has(uid));
25814
+ for (const { section, roots } of [...banded, { section: null, roots: loose }]) {
25815
+ const stats = roots.filter((uid) => elements[uid]?.type === "stat");
25816
+ if (stats.length < 2) continue;
25817
+ const bySource = /* @__PURE__ */ new Map();
25818
+ for (const uid of stats) {
25819
+ const source = elements[uid]?.props?.source;
25820
+ if (!named9(source) || handlers[source]?.role !== "summary") continue;
25821
+ if (!bySource.has(source)) bySource.set(source, []);
25822
+ bySource.get(source).push(uid);
25823
+ }
25824
+ for (const [source, group] of bySource) {
25825
+ if (group.length < 2) continue;
25826
+ const measures = handlers[source].measures;
25827
+ const known = isPlainObject13(measures) && group.every((uid) => Object.prototype.hasOwnProperty.call(measures, elements[uid]?.props?.measure));
25828
+ const counts = stateCounts2(handlers[source]);
25829
+ if (known ? !group.every((uid) => counts[elements[uid].props.measure] !== void 0) : group.length !== stats.length) continue;
25830
+ const where2 = section === null ? page.path : `band "${section.id}" on ${page.path}`;
25831
+ const shape = section?.layout === "kpi_strip" ? "is a kpi_strip of" : "holds a row of";
25832
+ const what = known ? `${group.length} counts of rows by ${[...new Set(group.map((uid) => counts[elements[uid].props.measure]))].join(", ")}` : `${group.length} counts`;
25833
+ out.push({
25834
+ rule: "L9",
25835
+ code: "kpi-strip-on-workflow",
25836
+ page: page.path,
25837
+ section: section?.id ?? null,
25838
+ where: where2,
25839
+ message: `${where2} ${shape} ${what} from the summary "${source}", and ${workflow}: folder or state counts belong on the rail, not in a KPI strip (workflow.md \xA78)`,
25840
+ fix: VALUES_FIX2
25841
+ });
25842
+ break;
25843
+ }
25844
+ }
25845
+ }
25846
+ return out;
25847
+ }
25197
25848
 
25198
25849
  // ../flows/appspace-builder/kit/contract/platformPalette.mjs
25199
25850
  var PLATFORM_CHART2 = Object.freeze([
@@ -25415,7 +26066,7 @@ function rgbText2(parts) {
25415
26066
 
25416
26067
  // ../flows/appspace-builder/kit/checks/rules/look.mjs
25417
26068
  var THEME_FILE8 = "ui/theme.css";
25418
- var RULES3 = ["L1", "L2", "L3", "L4", "L5", "L6", "L7"];
26069
+ var RULES3 = ["L1", "L2", "L3", "L4", "L5", "L6", "L7", "L8", "L9"];
25419
26070
  function checkLook(app, contract, contractError) {
25420
26071
  const view = {
25421
26072
  ...app,
@@ -25444,6 +26095,9 @@ function checkLook(app, contract, contractError) {
25444
26095
  for (const found of railReachProblems2(contract)) {
25445
26096
  violations.push({ rule: "L7", where: found.where, message: found.message });
25446
26097
  }
26098
+ for (const found of [...shellExpectedProblems2(contract), ...kpiStripWorkflowProblems2(contract)]) {
26099
+ violations.push({ rule: found.rule, where: found.where, message: `${found.code}: ${found.message}; ${found.fix}` });
26100
+ }
25447
26101
  return { violations, skipped: [] };
25448
26102
  }
25449
26103
 
@@ -25628,7 +26282,7 @@ function checkUx(app, contract, contractError) {
25628
26282
  return out;
25629
26283
  };
25630
26284
  const nameOf5 = (uid) => said(elements[uid]?.props?.id) ?? said(elements[uid]?.id) ?? uid;
25631
- const tableOf2 = (el) => said(el?.props?.entity) ?? said(handlers[el?.props?.source]?.table);
26285
+ const tableOf4 = (el) => said(el?.props?.entity) ?? said(handlers[el?.props?.source]?.table);
25632
26286
  const violations = [];
25633
26287
  for (const uid of uids) {
25634
26288
  const el = elements[uid];
@@ -25638,7 +26292,7 @@ function checkUx(app, contract, contractError) {
25638
26292
  if (!table || handlers[el.props?.action]?.role === "save") continue;
25639
26293
  const listing = uids.find((other) => {
25640
26294
  const o = elements[other];
25641
- return o?.page === el.page && ROWS.has(o.type) && tableOf2(o) === table && !ancestors(other).some((up) => up.type === "detail");
26295
+ return o?.page === el.page && ROWS.has(o.type) && tableOf4(o) === table && !ancestors(other).some((up) => up.type === "detail");
25642
26296
  });
25643
26297
  if (!listing) continue;
25644
26298
  const label = said(el.props?.label) ?? "New";
@@ -25655,7 +26309,7 @@ function checkUx(app, contract, contractError) {
25655
26309
  const el = elements[uid];
25656
26310
  if (!ROWS.has(el?.type)) continue;
25657
26311
  if (ancestors(uid).some((up) => up.type === "detail")) continue;
25658
- const table = tableOf2(el);
26312
+ const table = tableOf4(el);
25659
26313
  if (!table || table.startsWith("_") || !isPlainObject14(tables[table]?.columns) || tables[table].singleton === true) continue;
25660
26314
  const columns = tables[table].columns;
25661
26315
  const pk = Object.keys(columns).find((c) => isPlainObject14(columns[c]) && columns[c].pk === true) ?? "id";
@@ -25696,7 +26350,7 @@ function checkUx(app, contract, contractError) {
25696
26350
  for (const uid of uids) {
25697
26351
  const el = elements[uid];
25698
26352
  if (!ROWS.has(el?.type) || el.type === "data_grid") continue;
25699
- const table = tableOf2(el);
26353
+ const table = tableOf4(el);
25700
26354
  const columns = isPlainObject14(tables[table]?.columns) ? tables[table].columns : {};
25701
26355
  const state = STATE_COLUMNS.find((c) => isPlainObject14(columns[c]) && Array.isArray(columns[c].enum));
25702
26356
  const seen = /* @__PURE__ */ new Map();
@@ -26012,6 +26666,11 @@ function stateCallers2(contract, { moduleSource = () => void 0, handlerSource =
26012
26666
  const asks = action.confirm === true || named10(action.confirm);
26013
26667
  add(action.call, { page, uid, id, what: `row action "${action.label ?? action.call}"`, asks });
26014
26668
  }
26669
+ for (const action of isPlainObject15(typed.selection) && Array.isArray(typed.selection.bulk_actions) ? typed.selection.bulk_actions : []) {
26670
+ if (!isPlainObject15(action) || !named10(action.call)) continue;
26671
+ const asks = action.confirm === true || named10(action.confirm);
26672
+ add(action.call, { page, uid, id, what: `bulk action "${action.label ?? action.call}"`, asks });
26673
+ }
26015
26674
  break;
26016
26675
  case "action":
26017
26676
  case "detail": {
@@ -26305,7 +26964,7 @@ function kitFromDisk2() {
26305
26964
  }
26306
26965
  return kitOnDisk2;
26307
26966
  }
26308
- var ADVISORY = /* @__PURE__ */ new Set(["L6", "L7", "A1.5", "A1.6", "A2.5", "A2.6", "A3.15", "A3.17", "A3.19", "A3.20", "A3.21", "A3.22", "A3.23", "A3.24", "A3.25", "A3.26", "A3.27", "A3.28", "A3.29"]);
26967
+ var ADVISORY = /* @__PURE__ */ new Set(["L6", "L7", "L8", "L9", "A1.5", "A1.6", "A2.5", "A2.6", "A3.15", "A3.17", "A3.19", "A3.20", "A3.21", "A3.22", "A3.23", "A3.24", "A3.25", "A3.26", "A3.27", "A3.28", "A3.29"]);
26309
26968
  function verify2({ app: appRef }, { extractContract: extractContract2 = extract2, kit } = {}) {
26310
26969
  const KIT = kit ?? kitFromDisk2();
26311
26970
  const app = loadApp2(resolve12(appRef.dir));
@@ -30634,7 +31293,7 @@ function auditedOf(model) {
30634
31293
  if (typeof tables !== "object" || tables === null) return [];
30635
31294
  return Object.keys(tables).filter((name) => tables[name]?.audit === true);
30636
31295
  }
30637
- function tablesOf2(model) {
31296
+ function tablesOf4(model) {
30638
31297
  const tables = model?.tables;
30639
31298
  if (typeof tables !== "object" || tables === null) return {};
30640
31299
  const out = {};
@@ -30672,7 +31331,7 @@ function toRemove(rows2, { stage: stage2, cleanUp, keep, wrote = true }) {
30672
31331
  return rows2.filter((row) => row.recent);
30673
31332
  }
30674
31333
  async function sweepTestRows(client, appId, opts) {
30675
- const tables = tablesOf2(opts.model);
31334
+ const tables = tablesOf4(opts.model);
30676
31335
  if (Object.keys(tables).length === 0) {
30677
31336
  return { checked: false, why: "the app has no tables", left: [], removed: [], errors: [], truncated: [] };
30678
31337
  }
@@ -30930,7 +31589,7 @@ function pushedContract(bundle, pages) {
30930
31589
  }
30931
31590
  return null;
30932
31591
  }
30933
- function isAppContract(contract) {
31592
+ function isAppContract3(contract) {
30934
31593
  return contract.app?.kind !== "dashboard" && typeof contract.data?.app === "object";
30935
31594
  }
30936
31595
  var COMPONENTS2 = /* @__PURE__ */ new Set(["form", "list", "detail", "queue", "action", "filter", "stat", "chart", "custom", "widget"]);
@@ -30959,7 +31618,7 @@ function sectionOf3(element) {
30959
31618
  return typeof raw === "string" && raw !== "" ? raw : void 0;
30960
31619
  }
30961
31620
  function contractFindings(contract) {
30962
- if (!isAppContract(contract)) return [];
31621
+ if (!isAppContract3(contract)) return [];
30963
31622
  const pages = contract.pages ?? [];
30964
31623
  const elements = contract.elements ?? {};
30965
31624
  const out = [];
@@ -31024,7 +31683,7 @@ function contractFindings(contract) {
31024
31683
  return out;
31025
31684
  }
31026
31685
  function auditFindings(contract) {
31027
- if (!isAppContract(contract)) return [];
31686
+ if (!isAppContract3(contract)) return [];
31028
31687
  const tables = contract.app?.data_model?.tables ?? {};
31029
31688
  const handlers = contract.data?.app?.handlers ?? {};
31030
31689
  const elements = contract.elements ?? {};
@@ -31153,7 +31812,7 @@ function renderFindings(runs, contract, seed) {
31153
31812
  return out;
31154
31813
  }
31155
31814
  function chartFindings(runs, contract, seed) {
31156
- if (contract === null || !isAppContract(contract)) return [];
31815
+ if (contract === null || !isAppContract3(contract)) return [];
31157
31816
  const handlers = contract.data?.app?.handlers ?? {};
31158
31817
  const out = [];
31159
31818
  const said2 = /* @__PURE__ */ new Set();
@@ -31249,7 +31908,7 @@ function mapDecisions(handler) {
31249
31908
  return Object.keys(decision).sort();
31250
31909
  }
31251
31910
  function refusalFindings(runs, contract) {
31252
- if (contract === null || !isAppContract(contract)) return [];
31911
+ if (contract === null || !isAppContract3(contract)) return [];
31253
31912
  const handlers = contract.data?.app?.handlers ?? {};
31254
31913
  const byButton = /* @__PURE__ */ new Map();
31255
31914
  for (const run of runs) {
@@ -31645,7 +32304,7 @@ function partsSummary(contract, bundle, handlerCode) {
31645
32304
  custom_handler_lines: customHandlerLines,
31646
32305
  custom_share: components === 0 ? 0 : Math.round(customUi / components * 100)
31647
32306
  };
31648
- if (isAppContract(contract)) {
32307
+ if (isAppContract3(contract)) {
31649
32308
  const files = transformFiles2(contract, handlerCode);
31650
32309
  let lines = 0;
31651
32310
  for (const text3 of files.values()) if (text3 !== void 0) lines += nonBlankLinesOf(text3);
@@ -31671,7 +32330,7 @@ function pushedFiles(bundle, handlerCode) {
31671
32330
  };
31672
32331
  }
31673
32332
  function payloadFindings(contract, bundle, handlerCode) {
31674
- if (contract === null || !isAppContract(contract)) return [];
32333
+ if (contract === null || !isAppContract3(contract)) return [];
31675
32334
  return DECLARES.unproducedPayloadKeys(contract, pushedFiles(bundle, handlerCode)).map((found) => ({
31676
32335
  code: "payload-key-unproduced",
31677
32336
  where: `${found.pages.join(", ")} \u2192 ${found.handler}`,
@@ -31681,7 +32340,7 @@ function payloadFindings(contract, bundle, handlerCode) {
31681
32340
  }));
31682
32341
  }
31683
32342
  function leverFindings(contract, bundle, handlerCode) {
31684
- if (contract === null || !isAppContract(contract)) return [];
32343
+ if (contract === null || !isAppContract3(contract)) return [];
31685
32344
  const files = pushedFiles(bundle, handlerCode);
31686
32345
  const finding = (code) => (found) => ({
31687
32346
  code,
@@ -31696,7 +32355,7 @@ function leverFindings(contract, bundle, handlerCode) {
31696
32355
  ];
31697
32356
  }
31698
32357
  function workflowFindings(contract, bundle, handlerCode) {
31699
- if (contract === null || !isAppContract(contract)) return [];
32358
+ if (contract === null || !isAppContract3(contract)) return [];
31700
32359
  const files = pushedFiles(bundle, handlerCode);
31701
32360
  return [
31702
32361
  // A role that opens the app on a 404: counted, like the unreachable page
@@ -31755,7 +32414,7 @@ function shown(value) {
31755
32414
  return typeof value === "number" || typeof value === "boolean" || typeof value === "string" ? String(value) : JSON.stringify(value);
31756
32415
  }
31757
32416
  function leverStaticFindings(runs, contract, unread = /* @__PURE__ */ new Set()) {
31758
- if (contract === null || !isAppContract(contract)) return [];
32417
+ if (contract === null || !isAppContract3(contract)) return [];
31759
32418
  const sources = contract.data?.sources ?? {};
31760
32419
  const out = [];
31761
32420
  const seen = /* @__PURE__ */ new Set();
@@ -31853,7 +32512,7 @@ function declaresOf(element) {
31853
32512
  return { calls, shows };
31854
32513
  }
31855
32514
  function customDeclaresFindings(contract, bundle, runs) {
31856
- if (contract === null || !isAppContract(contract)) return [];
32515
+ if (contract === null || !isAppContract3(contract)) return [];
31857
32516
  const out = [];
31858
32517
  const said2 = /* @__PURE__ */ new Set();
31859
32518
  const undeclared = (uid, page, module, handler, problem5, fix) => {
@@ -31906,7 +32565,7 @@ function customDeclaresFindings(contract, bundle, runs) {
31906
32565
  return out;
31907
32566
  }
31908
32567
  function roleFindings(contract, held) {
31909
- if (held.own === null || !isAppContract(contract)) return [];
32568
+ if (held.own === null || !isAppContract3(contract)) return [];
31910
32569
  const elements = contract.elements ?? {};
31911
32570
  const parentOf2 = /* @__PURE__ */ new Map();
31912
32571
  for (const [uid, element] of Object.entries(elements)) {
@@ -31951,7 +32610,7 @@ function roleFindings(contract, held) {
31951
32610
  ];
31952
32611
  }
31953
32612
  function cardTitleFindings(contract) {
31954
- if (!isAppContract(contract)) return [];
32613
+ if (!isAppContract3(contract)) return [];
31955
32614
  return LOOK.untitledCards(contract).map((found) => ({
31956
32615
  code: "card-untitled",
31957
32616
  where: `${found.page} \u2192 ${found.uid}`,
@@ -31960,8 +32619,19 @@ function cardTitleFindings(contract) {
31960
32619
  see: SEE.labels
31961
32620
  }));
31962
32621
  }
32622
+ function shellFindings(contract) {
32623
+ if (contract === null) return [];
32624
+ return [...LOOK.shellExpectedProblems(contract), ...LOOK.kpiStripWorkflowProblems(contract)].map((found) => ({
32625
+ code: found.code,
32626
+ where: found.where,
32627
+ message: found.message,
32628
+ fix: `${found.fix.charAt(0).toUpperCase()}${found.fix.slice(1)}.`,
32629
+ see: `App kit ${found.rule} (contract/look.mjs)`,
32630
+ severity: "warning"
32631
+ }));
32632
+ }
31963
32633
  function uxFindings(contract) {
31964
- if (contract === null || !isAppContract(contract)) return [];
32634
+ if (contract === null || !isAppContract3(contract)) return [];
31965
32635
  const codes = {
31966
32636
  "A3.23": "create-form-inline",
31967
32637
  "A3.24": "row-no-detail",
@@ -32006,7 +32676,8 @@ function collectFindings(input) {
32006
32676
  ...input.declared === null ? [] : unreachedFindings(input.declared, input.cases, input.contract, input.runs),
32007
32677
  // Last: said and never counted, so they never push a counted finding down the list.
32008
32678
  ...workflow.filter((one) => one.severity === "warning"),
32009
- ...uxFindings(input.contract)
32679
+ ...uxFindings(input.contract),
32680
+ ...shellFindings(input.contract)
32010
32681
  ];
32011
32682
  }
32012
32683
  function formatFindings(findings) {
@@ -32426,7 +33097,7 @@ function shownValue(value) {
32426
33097
  return typeof value === "number" || typeof value === "boolean" || typeof value === "string" ? String(value) : JSON.stringify(value);
32427
33098
  }
32428
33099
  function paramsOf3(contract, page) {
32429
- if (contract === null || !isAppContract(contract)) return [];
33100
+ if (contract === null || !isAppContract3(contract)) return [];
32430
33101
  const found = (contract?.pages ?? []).find((one) => one.path === page && one.owned !== true);
32431
33102
  return Array.isArray(found?.params) ? found.params : [];
32432
33103
  }
@@ -32478,7 +33149,7 @@ function leversNotChecked(skipUi, runs, contract) {
32478
33149
  }
32479
33150
  }
32480
33151
  const loaded = new Set(runs.map((run) => `ui/${run.entry}`));
32481
- if (contract === null || !isAppContract(contract)) return out;
33152
+ if (contract === null || !isAppContract3(contract)) return out;
32482
33153
  for (const page of contract.pages ?? []) {
32483
33154
  if (page.owned === true || !Array.isArray(page.params) || page.params.length === 0) continue;
32484
33155
  if (judged.has(page.path) || !loaded.has(page.path)) continue;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@datarails-ai/dr-appspace-cli",
3
- "version": "0.12.1",
3
+ "version": "0.12.3",
4
4
  "description": "Datarails App Space CLI — build, try, push and publish App Space apps",
5
5
  "type": "module",
6
6
  "engines": {