@acms-subir/core 0.3.3-next.20260929113209 → 0.3.4

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 (289) hide show
  1. package/CHANGELOG.md +105 -0
  2. package/cli/common.mjs +2 -2
  3. package/cli/legacy.mjs +1 -1
  4. package/cli/main.mjs +82 -8
  5. package/cli/mobile-doctor.mjs +430 -0
  6. package/cli/mobile.mjs +315 -0
  7. package/cli/native-sections.mjs +168 -0
  8. package/cli/preview.mjs +8 -4
  9. package/cli/sections.mjs +188 -0
  10. package/cli/themes.mjs +52 -8
  11. package/docs/mobile.md +527 -0
  12. package/docs/sections.md +203 -0
  13. package/docs/theme-authoring.md +19 -3
  14. package/integration.mjs +76 -5
  15. package/mobile-template/client/AGENTS.md +97 -0
  16. package/mobile-template/client/README.md +65 -0
  17. package/mobile-template/client/app/_layout.tsx +56 -0
  18. package/mobile-template/client/app/index.tsx +21 -0
  19. package/mobile-template/client/app.json +21 -0
  20. package/mobile-template/client/components/LanguageSwitch.tsx +31 -0
  21. package/mobile-template/client/eas.json +21 -0
  22. package/mobile-template/client/env.example +3 -0
  23. package/mobile-template/client/gitignore +42 -0
  24. package/mobile-template/client/package.json +38 -0
  25. package/mobile-template/client/sections/index.ts +11 -0
  26. package/mobile-template/client/tsconfig.json +7 -0
  27. package/mobile-template/editor/AGENTS.md +66 -0
  28. package/mobile-template/editor/README.md +73 -0
  29. package/mobile-template/editor/app/_layout.tsx +76 -0
  30. package/mobile-template/editor/app/auth.tsx +13 -0
  31. package/mobile-template/editor/app/index.tsx +55 -0
  32. package/mobile-template/editor/app.json +21 -0
  33. package/mobile-template/editor/components/Screen.tsx +51 -0
  34. package/mobile-template/editor/components/SignIn.tsx +36 -0
  35. package/mobile-template/editor/components/TextTabs.tsx +27 -0
  36. package/mobile-template/editor/eas.json +21 -0
  37. package/mobile-template/editor/env.example +5 -0
  38. package/mobile-template/editor/gitignore +42 -0
  39. package/mobile-template/editor/lib/appearance.tsx +12 -0
  40. package/mobile-template/editor/lib/config.ts +17 -0
  41. package/mobile-template/editor/lib/format.ts +24 -0
  42. package/mobile-template/editor/lib/tools.ts +16 -0
  43. package/mobile-template/editor/package.json +40 -0
  44. package/mobile-template/editor/tools/README.md +2 -0
  45. package/mobile-template/editor/tsconfig.json +7 -0
  46. package/mobile-template/recipes/booking/files/app/book/[resource].tsx +57 -0
  47. package/mobile-template/recipes/booking/files/app/book/details.tsx +105 -0
  48. package/mobile-template/recipes/booking/files/app/book/index.tsx +38 -0
  49. package/mobile-template/recipes/booking/recipe.json +17 -0
  50. package/mobile-template/recipes/editor-bookings/files/app/bookings/index.tsx +113 -0
  51. package/mobile-template/recipes/editor-bookings/files/tools/bookings.ts +1 -0
  52. package/mobile-template/recipes/editor-bookings/recipe.json +8 -0
  53. package/mobile-template/recipes/editor-content/files/app/content/[kind]/[id].tsx +13 -0
  54. package/mobile-template/recipes/editor-content/files/app/content/index.tsx +76 -0
  55. package/mobile-template/recipes/editor-content/files/components/DocumentDetail.tsx +55 -0
  56. package/mobile-template/recipes/editor-content/files/tools/content.ts +1 -0
  57. package/mobile-template/recipes/editor-content/recipe.json +8 -0
  58. package/mobile-template/recipes/editor-media/files/app/media/index.tsx +73 -0
  59. package/mobile-template/recipes/editor-media/files/tools/media.ts +1 -0
  60. package/mobile-template/recipes/editor-media/recipe.json +8 -0
  61. package/mobile-template/recipes/editor-sections/files/app/sections/[pageId]/[sectionId].tsx +12 -0
  62. package/mobile-template/recipes/editor-sections/files/app/sections/index.tsx +48 -0
  63. package/mobile-template/recipes/editor-sections/files/components/SectionEditorPane.tsx +77 -0
  64. package/mobile-template/recipes/editor-sections/files/tools/sections.ts +1 -0
  65. package/mobile-template/recipes/editor-sections/recipe.json +8 -0
  66. package/mobile-template/recipes/editor-translations/files/app/translations/index.tsx +63 -0
  67. package/mobile-template/recipes/editor-translations/files/app/translations/review.tsx +14 -0
  68. package/mobile-template/recipes/editor-translations/files/components/ReviewPane.tsx +72 -0
  69. package/mobile-template/recipes/editor-translations/files/tools/translations.ts +1 -0
  70. package/mobile-template/recipes/editor-translations/recipe.json +8 -0
  71. package/mobile-template/recipes/editor-writer/files/app/write/[kind]/[id].tsx +132 -0
  72. package/mobile-template/recipes/editor-writer/files/app/write/new.tsx +61 -0
  73. package/mobile-template/recipes/editor-writer/files/tools/writer.ts +1 -0
  74. package/mobile-template/recipes/editor-writer/recipe.json +8 -0
  75. package/mobile-template/recipes/events/files/app/events/[slug].tsx +41 -0
  76. package/mobile-template/recipes/events/files/app/events/index.tsx +50 -0
  77. package/mobile-template/recipes/events/files/lib/addToCalendar.ts +27 -0
  78. package/mobile-template/recipes/events/recipe.json +15 -0
  79. package/mobile-template/recipes/news/files/app/news/[slug].tsx +25 -0
  80. package/mobile-template/recipes/news/files/app/news/index.tsx +50 -0
  81. package/mobile-template/recipes/news/recipe.json +13 -0
  82. package/mobile-template/recipes/push/files/app/notifications.tsx +64 -0
  83. package/mobile-template/recipes/push/files/components/PushSetup.tsx +15 -0
  84. package/mobile-template/recipes/push/files/lib/push.ts +15 -0
  85. package/mobile-template/recipes/push/recipe.json +20 -0
  86. package/package.json +17 -13
  87. package/section-library/README.md +16 -0
  88. package/section-library/contact/Contact.astro +49 -0
  89. package/section-library/contact/section.json +106 -0
  90. package/section-library/cta-band/CtaBand.astro +28 -0
  91. package/section-library/cta-band/section.json +51 -0
  92. package/section-library/feature-grid/FeatureGrid.astro +54 -0
  93. package/section-library/feature-grid/section.json +107 -0
  94. package/section-library/gallery/Gallery.astro +29 -0
  95. package/section-library/gallery/section.json +51 -0
  96. package/section-library/hero-banner/HeroBanner.astro +42 -0
  97. package/section-library/hero-banner/section.json +81 -0
  98. package/section-library/latest-posts/LatestPosts.astro +22 -0
  99. package/section-library/latest-posts/section.json +37 -0
  100. package/section-library/pricing/Pricing.astro +40 -0
  101. package/section-library/pricing/section.json +117 -0
  102. package/section-library/questions/Questions.astro +38 -0
  103. package/section-library/questions/section.json +69 -0
  104. package/section-library/service-times/ServiceTimes.astro +42 -0
  105. package/section-library/service-times/section.json +127 -0
  106. package/section-library/team/Team.astro +45 -0
  107. package/section-library/team/section.json +88 -0
  108. package/section-library/upcoming-events/UpcomingEvents.astro +54 -0
  109. package/section-library/upcoming-events/section.json +51 -0
  110. package/src/actions/index.ts +205 -174
  111. package/src/components/AiSettings.tsx +45 -5
  112. package/src/components/DesignManager.tsx +112 -16
  113. package/src/components/MobileApp.tsx +392 -0
  114. package/src/components/ThemeSections.astro +40 -21
  115. package/src/components/ThemeSectionsEditor.tsx +757 -229
  116. package/src/components/booking/BookingFlow.astro +6 -3
  117. package/src/components/calendar/BookingsHead.astro +51 -0
  118. package/src/components/calendar/CalendarBoard.tsx +618 -0
  119. package/src/components/calendar/MiniMonth.tsx +54 -0
  120. package/src/components/calendar/NewEventDialog.tsx +44 -0
  121. package/src/components/calendar/QuickAdd.tsx +263 -0
  122. package/src/components/calendar/RoomHolds.tsx +203 -0
  123. package/src/components/calendar/WhereItLands.tsx +51 -0
  124. package/src/components/editor/EventPanel.tsx +23 -2
  125. package/src/components/editor/PostEditor.tsx +206 -14
  126. package/src/components/import/ImportExport.tsx +4 -1
  127. package/src/components/import/runner.ts +1 -0
  128. package/src/components/sections/SectionFields.tsx +560 -0
  129. package/src/components/sections/SectionPicker.tsx +120 -0
  130. package/src/components/sections/TranslationPanel.tsx +143 -0
  131. package/src/components/sections/checks.ts +64 -0
  132. package/src/layouts/Admin.astro +11 -7
  133. package/src/layouts/Site.astro +136 -21
  134. package/src/lib/agent-discovery.ts +11 -7
  135. package/src/lib/agent.ts +35 -2
  136. package/src/lib/ai-schema.ts +5 -0
  137. package/src/lib/ai.ts +5 -0
  138. package/src/lib/api.ts +12 -0
  139. package/src/lib/backup/content.ts +6 -1
  140. package/src/lib/booking/agent.ts +3 -2
  141. package/src/lib/booking/approval.ts +70 -0
  142. package/src/lib/booking/holds.ts +120 -0
  143. package/src/lib/booking/service.ts +37 -3
  144. package/src/lib/calendar/agent-schema.ts +9 -1
  145. package/src/lib/calendar/agent.ts +16 -1
  146. package/src/lib/calendar/board.ts +171 -0
  147. package/src/lib/calendar/config.ts +3 -0
  148. package/src/lib/calendar/grid.ts +169 -0
  149. package/src/lib/calendar/migration.ts +16 -0
  150. package/src/lib/calendar/render.ts +9 -7
  151. package/src/lib/calendar/schedule.ts +166 -0
  152. package/src/lib/content-service.ts +203 -0
  153. package/src/lib/design-service.ts +153 -5
  154. package/src/lib/drafts.ts +3 -3
  155. package/src/lib/editor/auth.ts +264 -0
  156. package/src/lib/editor/bookings.ts +60 -0
  157. package/src/lib/editor/config.ts +25 -0
  158. package/src/lib/editor/documents.ts +227 -0
  159. package/src/lib/editor/http.ts +48 -0
  160. package/src/lib/editor/me.ts +30 -0
  161. package/src/lib/editor/migration.ts +57 -0
  162. package/src/lib/editor/next.ts +11 -0
  163. package/src/lib/editor/openapi.ts +124 -0
  164. package/src/lib/editor/schemas.ts +91 -0
  165. package/src/lib/editor/sections.ts +96 -0
  166. package/src/lib/editor/store.ts +184 -0
  167. package/src/lib/editor/token-route.ts +74 -0
  168. package/src/lib/editor/translations.ts +163 -0
  169. package/src/lib/email/accounts.ts +2 -1
  170. package/src/lib/email/types.ts +2 -1
  171. package/src/lib/home-source.ts +34 -0
  172. package/src/lib/i18n.ts +34 -0
  173. package/src/lib/import/apply.ts +31 -2
  174. package/src/lib/import/bundle.ts +4 -0
  175. package/src/lib/import/export.ts +1 -0
  176. package/src/lib/import/ghost.ts +1 -0
  177. package/src/lib/media/upload.ts +83 -0
  178. package/src/lib/mobile/associations.ts +25 -0
  179. package/src/lib/mobile/calendar.ts +26 -14
  180. package/src/lib/mobile/config.mjs +59 -0
  181. package/src/lib/mobile/content.ts +18 -13
  182. package/src/lib/mobile/embed.ts +83 -0
  183. package/src/lib/mobile/http.ts +14 -6
  184. package/src/lib/mobile/locale.ts +18 -0
  185. package/src/lib/mobile/openapi.ts +111 -0
  186. package/src/lib/mobile/pages.ts +71 -0
  187. package/src/lib/mobile/route.ts +3 -3
  188. package/src/lib/mobile/schemas.ts +60 -0
  189. package/src/lib/mobile/site.ts +41 -0
  190. package/src/lib/push/api.ts +158 -0
  191. package/src/lib/push/config.ts +20 -0
  192. package/src/lib/push/expo.ts +76 -0
  193. package/src/lib/push/migration.ts +60 -0
  194. package/src/lib/push/service.ts +302 -0
  195. package/src/lib/push/store.ts +144 -0
  196. package/src/lib/push/types.ts +43 -0
  197. package/src/lib/repo/migrations.ts +14 -0
  198. package/src/lib/repo/sql.ts +65 -20
  199. package/src/lib/repo/types.ts +10 -4
  200. package/src/lib/roles.ts +5 -0
  201. package/src/lib/scheduler.ts +6 -1
  202. package/src/lib/schema.ts +76 -1
  203. package/src/lib/section-ai.ts +55 -0
  204. package/src/lib/section-targets.ts +62 -0
  205. package/src/lib/sections-agent.ts +146 -0
  206. package/src/lib/sections.ts +242 -0
  207. package/src/lib/site-chrome.ts +34 -0
  208. package/src/lib/theme-assets.ts +16 -0
  209. package/src/lib/theme.ts +33 -4
  210. package/src/lib/translate.ts +97 -0
  211. package/src/lib/translations.ts +39 -0
  212. package/src/middleware.ts +38 -2
  213. package/src/pages/.well-known/apple-app-site-association.ts +6 -0
  214. package/src/pages/.well-known/assetlinks.json.ts +6 -0
  215. package/src/pages/[...slug].astro +8 -3
  216. package/src/pages/admin/app-auth.astro +159 -0
  217. package/src/pages/admin/c/[collection]/[id].astro +9 -0
  218. package/src/pages/admin/calendar/bookings.astro +32 -26
  219. package/src/pages/admin/calendar/calendars.astro +4 -263
  220. package/src/pages/admin/calendar/events/[id].astro +9 -0
  221. package/src/pages/admin/calendar/events/index.astro +41 -83
  222. package/src/pages/admin/calendar/index.astro +43 -0
  223. package/src/pages/admin/calendar/resources/[id].astro +27 -5
  224. package/src/pages/admin/calendar/resources/index.astro +16 -17
  225. package/src/pages/admin/calendar/resources/link.astro +52 -12
  226. package/src/pages/admin/calendar/settings.astro +458 -0
  227. package/src/pages/admin/integrations.astro +5 -11
  228. package/src/pages/admin/login.astro +6 -2
  229. package/src/pages/admin/logout.ts +5 -2
  230. package/src/pages/admin/mobile.astro +18 -0
  231. package/src/pages/admin/pages/[id].astro +62 -0
  232. package/src/pages/admin/section-preview.astro +61 -0
  233. package/src/pages/admin/users.astro +107 -11
  234. package/src/pages/api/agent/docs.ts +6 -2
  235. package/src/pages/api/agent/index.ts +1 -1
  236. package/src/pages/api/ai/settings.ts +7 -4
  237. package/src/pages/api/design/ai.ts +15 -0
  238. package/src/pages/api/design/index.ts +12 -42
  239. package/src/pages/api/design/translate.ts +15 -0
  240. package/src/pages/api/live/thumb.ts +1 -1
  241. package/src/pages/api/mcp.ts +2 -2
  242. package/src/pages/api/media/upload.ts +10 -78
  243. package/src/pages/api/push/admin.ts +27 -0
  244. package/src/pages/api/v1/admin/bookings/[id]/[action].ts +14 -0
  245. package/src/pages/api/v1/admin/bookings/index.ts +6 -0
  246. package/src/pages/api/v1/admin/documents/[kind]/[id]/[action].ts +17 -0
  247. package/src/pages/api/v1/admin/documents/[kind]/[id]/index.ts +20 -0
  248. package/src/pages/api/v1/admin/documents/[kind]/[id]/translations/[locale]/[action].ts +26 -0
  249. package/src/pages/api/v1/admin/documents/[kind]/[id]/translations/[locale]/index.ts +18 -0
  250. package/src/pages/api/v1/admin/documents/index.ts +13 -0
  251. package/src/pages/api/v1/admin/me.ts +6 -0
  252. package/src/pages/api/v1/admin/media/index.ts +32 -0
  253. package/src/pages/api/v1/admin/sections/[pageId]/[sectionId]/index.ts +14 -0
  254. package/src/pages/api/v1/admin/sections/[pageId]/[sectionId]/publish.ts +14 -0
  255. package/src/pages/api/v1/admin/sections/[pageId]/[sectionId]/translations/[locale]/[action].ts +22 -0
  256. package/src/pages/api/v1/admin/sections/[pageId]/[sectionId]/translations/[locale]/index.ts +14 -0
  257. package/src/pages/api/v1/admin/sections/index.ts +6 -0
  258. package/src/pages/api/v1/auth/revoke.ts +5 -0
  259. package/src/pages/api/v1/auth/token.ts +5 -0
  260. package/src/pages/api/v1/devices/[token].ts +20 -0
  261. package/src/pages/api/v1/devices/index.ts +17 -0
  262. package/src/pages/api/v1/openapi.json.ts +7 -0
  263. package/src/pages/api/v1/pages/[...slug].ts +7 -0
  264. package/src/pages/api/v1/pages/home.ts +7 -0
  265. package/src/pages/api/v1/push/topics.ts +7 -0
  266. package/src/pages/api/v1/site.ts +7 -0
  267. package/src/pages/api/v1/theme.ts +7 -0
  268. package/src/pages/blog/[slug].astro +8 -2
  269. package/src/pages/blog/index.astro +11 -4
  270. package/src/pages/book/[resource].ics.ts +7 -1
  271. package/src/pages/embed/section/[...path].astro +42 -0
  272. package/src/pages/events/[slug].astro +7 -2
  273. package/src/pages/index.astro +11 -5
  274. package/src/pages/sitemap.xml.ts +8 -0
  275. package/src/styles/admin.css +117 -0
  276. package/src/styles/cards.css +5 -1
  277. package/src/styles/editor.css +57 -0
  278. package/src/styles/mobile-app.css +151 -0
  279. package/src/styles/schedule.css +325 -0
  280. package/src/styles/sections.css +21 -12
  281. package/src/styles/theme-editor.css +255 -0
  282. package/src/theme-sdk/contract.d.mts +2193 -89
  283. package/src/theme-sdk/contract.mjs +77 -20
  284. package/src/theme-sdk/fields.d.mts +88 -0
  285. package/src/theme-sdk/fields.mjs +330 -0
  286. package/src/theme-sdk/index.ts +14 -2
  287. package/src/theme-sdk/tokens.d.mts +11 -0
  288. package/src/virtual.d.ts +4 -0
  289. package/src/pages/admin/calendar/index.ts +0 -4
@@ -0,0 +1,430 @@
1
+ import { readdir, readFile } from 'node:fs/promises';
2
+ import { join, resolve, relative, sep } from 'node:path';
3
+ import { CORE, exists, fail, readJson } from './common.mjs';
4
+ import { listApps, readApp, appDir, recipes, KINDS, editorRedirect } from './mobile.mjs';
5
+ import { libraryComponents, registryKeys } from './native-sections.mjs';
6
+
7
+ // `acms mobile doctor [--dir] [--url] [--offline]`: fast, offline-tolerant
8
+ // checks for the site's Expo apps. Each check is pass | warn | fail with a
9
+ // hint; any fail makes the command exit 1. Network checks warn (never fail)
10
+ // when the site can't be reached, and --offline skips them.
11
+
12
+ /**
13
+ * Which @acms-subir/client and /native releases go with a core release line,
14
+ * and the public API version they speak. Rule: the installed packages'
15
+ * major.minor must equal the line listed for the site's core major.minor.
16
+ * Add a row whenever a core minor ships with new mobile packages.
17
+ */
18
+ export const MOBILE_COMPAT = {
19
+ '0.3': { client: '0.1', native: '0.1', api: 1 },
20
+ '0.4': { client: '0.1', native: '0.1', api: 1 },
21
+ };
22
+ const line = (v) => String(v ?? '').match(/^(\d+)\.(\d+)/)?.slice(1, 3).join('.') ?? null;
23
+ export function compatFor(coreVersion) { return MOBILE_COMPAT[line(coreVersion)] ?? null; }
24
+
25
+ const SKIP_DIRS = new Set(['node_modules', '.expo', 'dist', 'web-build', 'ios', 'android', '.git', '.turbo', 'coverage']);
26
+ const SOURCE = /\.(tsx?|jsx?|mjs|cjs)$/;
27
+ const ADMIN_ROUTES = /['"`](?:https?:\/\/[^'"`\s]*)?\/api\/(agent|ai|backup|design|drafts|email|export|import|live|mcp|media|ops|restore|webhooks)(?:[/?'"`]|$)/;
28
+ const SECRET_KEY = /(TOKEN|SECRET|PASSWORD|PASSWD|PRIVATE|API_?KEY|ACCESS_?KEY)/i;
29
+ const PLACEHOLDER_ID = /^com\.example\.|__ACMS_/;
30
+
31
+ async function files(root, dir = root, out = []) {
32
+ for (const e of await readdir(dir, { withFileTypes: true }).catch(() => [])) {
33
+ if (e.isDirectory()) { if (!SKIP_DIRS.has(e.name)) await files(root, join(dir, e.name), out); }
34
+ else out.push(relative(root, join(dir, e.name)).split(sep).join('/'));
35
+ }
36
+ return out;
37
+ }
38
+ const pkgVersion = async (dir, name) => (await readJson(join(dir, 'node_modules', name, 'package.json')).catch(() => null))?.version ?? null;
39
+
40
+ function hostKind(url) {
41
+ let u;
42
+ try { u = new URL(url); } catch { return 'invalid'; }
43
+ const h = u.hostname.replace(/^\[|\]$/g, '');
44
+ if (h === 'localhost' || h === '::1' || /^127\./.test(h) || h === '0.0.0.0') return 'loopback';
45
+ if (/^(10\.|192\.168\.|172\.(1[6-9]|2\d|3[01])\.|100\.(6[4-9]|[7-9]\d|1[01]\d|12[0-7])\.)/.test(h) || /\.(local|lan|internal|ts\.net)$/.test(h) || !h.includes('.')) return 'private';
46
+ return 'public';
47
+ }
48
+
49
+ async function getJson(url, timeout = 3000) {
50
+ try {
51
+ const r = await fetch(url, { signal: AbortSignal.timeout(timeout), headers: { accept: 'application/json' }, redirect: 'follow' });
52
+ const text = await r.text();
53
+ let json = null;
54
+ try { json = JSON.parse(text); } catch { /* not JSON */ }
55
+ return { status: r.status, json, type: r.headers.get('content-type') ?? '' };
56
+ } catch (e) {
57
+ return { status: 0, error: e?.name === 'TimeoutError' ? 'timed out' : 'unreachable' };
58
+ }
59
+ }
60
+
61
+ const envValue = async (dir, file) => (await readFile(join(dir, file), 'utf8').catch(() => '')).match(/^\s*EXPO_PUBLIC_ACMS_URL\s*=\s*(.*)$/m)?.[1]?.trim().replace(/^['"]|['"]$/g, '') || null;
62
+
63
+ function checker() {
64
+ const checks = [];
65
+ const add = (status, id, title, message, extra = {}) => { checks.push({ id, status, title, message, ...extra }); };
66
+ return { checks, pass: (...a) => add('pass', ...a), warn: (...a) => add('warn', ...a), fail: (...a) => add('fail', ...a) };
67
+ }
68
+
69
+ async function siteMobileConfig() {
70
+ const discovered = await readJson(resolve('.astro/acms-project.json')).catch(() => null);
71
+ return discovered && 'mobile' in discovered ? (discovered.mobile ?? {}) : null;
72
+ }
73
+
74
+ async function coreVersion() {
75
+ // The site's installed core decides compatibility; this CLI's own version otherwise.
76
+ const own = (await readJson(join(CORE, 'package.json'))).version;
77
+ const site = await readJson(resolve('node_modules/@acms-subir/core/package.json')).catch(() => null);
78
+ return site?.version ?? own;
79
+ }
80
+
81
+ /**
82
+ * Editor apps: the sign-in pieces must line up. The site must have editor
83
+ * apps on and list this app's return address (<scheme>://auth) exactly; the
84
+ * app needs secure storage and the system browser module.
85
+ */
86
+ async function editorChecks(c, { app, deps, hasModules, dir, expo, url, live, ctx }) {
87
+ // packages that keep the sign-in safe
88
+ const need = [['expo-secure-store', 'fail', 'tokens are kept in the Keychain / Keystore'], ['expo-web-browser', 'fail', 'sign-in opens the site in the system browser'], ['expo-crypto', 'warn', 'PKCE randomness on devices without Web Crypto']];
89
+ const lacking = [], lackingSoft = [];
90
+ for (const [name, level, why] of need) {
91
+ const declared = !!deps[name];
92
+ const installed = hasModules ? await exists(join(dir, 'node_modules', name)) : declared;
93
+ if (!declared || !installed) (level === 'fail' ? lacking : lackingSoft).push(`${name} ${declared ? 'is declared but not installed' : 'is missing'} (${why})`);
94
+ }
95
+ const install = `cd ${app.dir} && npx expo install expo-secure-store expo-web-browser expo-crypto`;
96
+ if (lacking.length) c.fail('editor-storage', 'Secure sign-in', `${[...lacking, ...lackingSoft].join('; ')}.`, { hint: install });
97
+ else if (lackingSoft.length) c.warn('editor-storage', 'Secure sign-in', `${lackingSoft.join('; ')}.`, { hint: install });
98
+ else c.pass('editor-storage', 'Secure sign-in', 'expo-secure-store, expo-web-browser and expo-crypto installed.');
99
+
100
+ // return address: app scheme ↔ the site's redirectUris
101
+ const scheme = Array.isArray(expo.scheme) ? expo.scheme[0] : expo.scheme;
102
+ const redirect = scheme ? editorRedirect(scheme) : null;
103
+ const config = ctx.mobileConfig?.editor;
104
+ const snippet = `acms({ mobile: { editor: { redirectUris: ['${redirect ?? '<scheme>://auth'}'] } } })`;
105
+ let online = null;
106
+ if (live && url) {
107
+ const r = await getJson(`${url}/api/v1/admin/me`);
108
+ online = r.status === 404 ? 'off' : r.status === 401 ? 'on' : null;
109
+ }
110
+ if (!redirect) c.fail('editor-redirect', 'Sign-in return address', 'app.json has no "scheme", so the site can\'t send the person back to the app.', { hint: `Set expo.scheme (e.g. your bundle id, lowercase) in ${app.dir}/app.json, then add <scheme>://auth to the site: ${snippet}` });
111
+ else if (config === undefined || ctx.mobileConfig === null) {
112
+ if (online === 'off') c.fail('editor-redirect', 'Sign-in return address', `Editor apps are turned off on ${url}.`, { hint: `Add ${snippet} to astro.config.mjs, then restart or deploy the site.` });
113
+ else c.warn('editor-redirect', 'Sign-in return address', `Couldn't read the site's config to confirm ${redirect} is allowed${online === 'on' ? ' (editor apps are on)' : ''}.`, { hint: 'Run npx astro sync (or start the dev server) so doctor can read .astro/acms-project.json.' });
114
+ }
115
+ else if (!config.enabled) c.fail('editor-redirect', 'Sign-in return address', 'Editor apps are not turned on for this site (no mobile.editor.redirectUris).', { hint: `Add ${snippet} to astro.config.mjs, then restart or deploy the site.` });
116
+ else if (!config.redirectUris.includes(redirect)) c.fail('editor-redirect', 'Sign-in return address', `The site allows ${config.redirectUris.join(', ')}, not this app's ${redirect}.`, { hint: `Add '${redirect}' to mobile.editor.redirectUris in astro.config.mjs (it must match app.json's scheme exactly), then restart or deploy.` });
117
+ else if (online === 'off') c.fail('editor-redirect', 'Sign-in return address', `astro.config lists ${redirect}, but ${url} has editor apps off.`, { hint: 'Restart the dev server or deploy the site so it picks up the config.' });
118
+ else {
119
+ const web = config.redirectUris.filter((u) => /^https?:/.test(u));
120
+ c.pass('editor-redirect', 'Sign-in return address', `${redirect} is allowed by the site${online === 'on' ? ' (editor API on)' : ''}${web.length ? `; web: ${web.join(', ')}` : '; add an https://…/auth address too if you ship the web build'}.`);
121
+ }
122
+ }
123
+
124
+ async function doctorApp(app, flags, ctx) {
125
+ const dir = resolve(app.dir);
126
+ const c = checker();
127
+ const pkg = await readJson(join(dir, 'package.json'));
128
+ const deps = { ...pkg.dependencies, ...pkg.devDependencies };
129
+ const hasModules = await exists(join(dir, 'node_modules'));
130
+ const appJson = await readJson(join(dir, 'app.json')).catch(() => null);
131
+ const expo = appJson?.expo ?? appJson ?? {};
132
+
133
+ // 1. marker
134
+ const editor = app.kind === 'editor';
135
+ if (KINDS.includes(app.kind)) c.pass('app', 'App', `${app.kind} app “${app.name}” in ${app.dir}/`);
136
+ else c.warn('app', 'App', `Unknown app kind “${app.kind}” in ${app.dir}/package.json.`, { hint: 'This version of acms knows "client" and "editor" apps; update @acms-subir/core.' });
137
+
138
+ // 2. Expo SDK
139
+ const expoVersion = hasModules ? await pkgVersion(dir, 'expo') : null;
140
+ if (!deps.expo) c.fail('expo', 'Expo SDK', 'expo is not a dependency.', { hint: `cd ${app.dir} && npx expo install expo` });
141
+ else if (!hasModules) c.fail('expo', 'Expo SDK', `Dependencies are not installed (${app.dir}/node_modules is missing).`, { hint: `cd ${app.dir} && npm install` });
142
+ else if (!expoVersion) c.fail('expo', 'Expo SDK', 'expo is declared but not installed.', { hint: `cd ${app.dir} && npm install` });
143
+ else {
144
+ const sdk = expoVersion.split('.')[0];
145
+ const off = [];
146
+ for (const name of Object.keys(deps).filter((n) => /^expo-/.test(n))) {
147
+ const v = await pkgVersion(dir, name);
148
+ const major = v?.split('.')[0];
149
+ if (v && Number(major) >= 50 && major !== sdk) off.push(`${name}@${v}`);
150
+ }
151
+ if (off.length) c.warn('expo', 'Expo SDK', `SDK ${sdk}, but some Expo packages are from another SDK: ${off.join(', ')}.`, { hint: `cd ${app.dir} && npx expo install --fix` });
152
+ else c.pass('expo', 'Expo SDK', `SDK ${sdk} (expo ${expoVersion})`);
153
+ }
154
+
155
+ // 3. ACMS packages vs core
156
+ const compat = compatFor(ctx.core);
157
+ const versions = {};
158
+ const problems = [], notes = [];
159
+ for (const name of ['client', 'native']) {
160
+ const full = `@acms-subir/${name}`;
161
+ if (!deps[full]) { problems.push(`${full} is not a dependency`); continue; }
162
+ const v = hasModules ? await pkgVersion(dir, full) : null;
163
+ versions[name] = v;
164
+ if (!hasModules) continue;
165
+ if (!v) { problems.push(`${full} is declared but not installed`); continue; }
166
+ if (v === '0.0.0' || v.includes('-')) notes.push(`${full} ${v} is a local or prerelease build`);
167
+ else if (compat && line(v) !== compat[name]) problems.push(`${full} ${v} does not match core ${ctx.core} (needs ${compat[name]}.x)`);
168
+ }
169
+ if (problems.length) c.fail('packages', 'ACMS packages', `${problems.join('; ')}.`, { hint: `cd ${app.dir} && npx expo install @acms-subir/client@~${compat?.client ?? '0'}.0 @acms-subir/native@~${compat?.native ?? '0'}.0` + (hasModules ? '' : ` (or npm install)`) });
170
+ else if (!hasModules) c.pass('packages', 'ACMS packages', 'Declared (not installed yet; see Expo SDK).');
171
+ else if (!compat) c.warn('packages', 'ACMS packages', `client ${versions.client}, native ${versions.native}; core ${ctx.core} has no compatibility entry in this CLI.`, { hint: 'Update the site: npx acms update' });
172
+ else if (notes.length) c.warn('packages', 'ACMS packages', `${notes.join('; ')}; compatibility with core ${ctx.core} can't be checked.`, { hint: `Released versions for core ${line(ctx.core)}.x are ${compat.client}.x.` });
173
+ else c.pass('packages', 'ACMS packages', `client ${versions.client}, native ${versions.native} match core ${ctx.core}`);
174
+
175
+ // 4. site address
176
+ const prod = await envValue(dir, '.env'), dev = await envValue(dir, '.env.local');
177
+ const url = (flags.url ?? dev ?? prod ?? '').replace(/\/+$/, '') || null;
178
+ const urlNotes = [], urlWarn = [];
179
+ if (!prod && !dev && !flags.url) c.fail('site-url', 'Site address', 'EXPO_PUBLIC_ACMS_URL is not set in .env or .env.local.', { hint: `Add EXPO_PUBLIC_ACMS_URL=https://your-site to ${app.dir}/.env` });
180
+ else {
181
+ for (const [label, value] of [['.env', prod], ['.env.local', dev]]) {
182
+ if (!value) continue;
183
+ const kind = hostKind(value);
184
+ if (kind === 'invalid') { urlWarn.push(`${label} has an invalid URL (${value})`); continue; }
185
+ if (kind === 'loopback') urlWarn.push(`${label} points at ${value}; phones can't reach localhost (simulators and web can)`);
186
+ else if (label === '.env' && kind === 'private') urlWarn.push(`.env (the address baked into store builds) is a private address (${value})`);
187
+ if (kind === 'public' && new URL(value).protocol === 'http:') urlWarn.push(`${label} uses http on a public host (${value}); use https`);
188
+ urlNotes.push(`${label}: ${value}`);
189
+ }
190
+ if (!prod) urlWarn.push('.env has no EXPO_PUBLIC_ACMS_URL, so builds have no production address');
191
+ if (urlWarn.length) c.warn('site-url', 'Site address', `${urlWarn.join('; ')}.`, { hint: 'For a phone: npx acms mobile dev (writes .env.local). For builds: .env = https://your-site.' });
192
+ else c.pass('site-url', 'Site address', urlNotes.join(', '));
193
+ }
194
+
195
+ // 5. site reachable + API version
196
+ let site = null;
197
+ const online = !flags.offline && url && hostKind(url) !== 'invalid';
198
+ if (flags.offline) c.pass('site', 'Site API', 'Skipped (--offline).');
199
+ else if (!url || hostKind(url) === 'invalid') c.warn('site', 'Site API', 'No usable site address to check.');
200
+ else {
201
+ const r = await getJson(`${url}/api/v1/site`);
202
+ if (r.status === 200 && r.json?.version === 1 && r.json.data) {
203
+ site = r.json.data;
204
+ const want = compat?.api ?? 1;
205
+ if (site.apiVersion !== want) c.fail('site', 'Site API', `${url} speaks API v${site.apiVersion}; this app's client expects v${want}.`, { hint: 'Update the site and the app packages to matching releases (npx acms update, then npx acms mobile doctor).' });
206
+ else c.pass('site', 'Site API', `${url} is up (“${site.name}”, API v${site.apiVersion})`);
207
+ } else if (r.status === 0) c.warn('site', 'Site API', `${url} is ${r.error}.`, { hint: 'Start the site (npx acms mobile dev) or pass --url; --offline skips network checks.' });
208
+ else c.fail('site', 'Site API', `${url}/api/v1/site returned ${r.status}${r.json ? '' : ' (not the ACMS API)'}.`, { hint: 'Check EXPO_PUBLIC_ACMS_URL points at the site origin and the site runs @acms-subir/core with the mobile API.' });
209
+ }
210
+ const live = !!site;
211
+
212
+ // 6. app ids
213
+ if (!appJson) c.fail('app-ids', 'App identity', 'app.json is missing or not valid JSON.', { hint: `Restore ${app.dir}/app.json (see the starter: npx acms add mobile --dry-run).` });
214
+ else {
215
+ const ios = expo.ios?.bundleIdentifier, android = expo.android?.package;
216
+ const issues = [];
217
+ if (!ios) issues.push('ios.bundleIdentifier is not set');
218
+ else if (PLACEHOLDER_ID.test(ios)) issues.push(`ios.bundleIdentifier ${ios} is a placeholder`);
219
+ if (!android) issues.push('android.package is not set');
220
+ else if (PLACEHOLDER_ID.test(android)) issues.push(`android.package ${android} is a placeholder`);
221
+ if (!expo.scheme) issues.push('scheme is not set (needed for myapp:// links and auth redirects)');
222
+ if (issues.length) c.warn('app-ids', 'App identity', `${issues.join('; ')}.`, { hint: `Set real reverse-DNS ids (e.g. org.yoursite.app) in ${app.dir}/app.json before the first store build; they can't change after release.` });
223
+ else c.pass('app-ids', 'App identity', `iOS ${ios}, Android ${android}, scheme ${expo.scheme}`);
224
+ }
225
+
226
+ // 7. deep links (client apps) / sign-in (editor apps)
227
+ if (editor) await editorChecks(c, { app, dir, deps, hasModules, expo, url, live, ctx });
228
+ else if (appJson) {
229
+ const config = ctx.mobileConfig;
230
+ const host = (() => { try { return new URL(prod ?? url).hostname; } catch { return null; } })();
231
+ const issues = [], ok = [];
232
+ if (config?.ios) {
233
+ if (config.ios.bundleId !== expo.ios?.bundleIdentifier) issues.push({ s: 'fail', m: `site mobile.ios.bundleId ${config.ios.bundleId} ≠ app.json ios.bundleIdentifier ${expo.ios?.bundleIdentifier ?? '(unset)'}` });
234
+ if (host && !(expo.ios?.associatedDomains ?? []).includes(`applinks:${host}`)) issues.push({ s: 'warn', m: `app.json ios.associatedDomains lacks "applinks:${host}"` });
235
+ }
236
+ if (config?.android) {
237
+ if (config.android.package !== expo.android?.package) issues.push({ s: 'fail', m: `site mobile.android.package ${config.android.package} ≠ app.json android.package ${expo.android?.package ?? '(unset)'}` });
238
+ const filters = expo.android?.intentFilters ?? [];
239
+ if (host && !filters.some((f) => f.autoVerify && (f.data ?? []).some((d) => d.host === host && d.scheme === 'https'))) issues.push({ s: 'warn', m: `app.json android.intentFilters has no autoVerify https filter for ${host}` });
240
+ }
241
+ if (live) {
242
+ const base = url;
243
+ const [aasa, links] = await Promise.all([getJson(`${base}/.well-known/apple-app-site-association`), getJson(`${base}/.well-known/assetlinks.json`)]);
244
+ const iosId = expo.ios?.bundleIdentifier, pkgId = expo.android?.package;
245
+ if (aasa.status === 200) {
246
+ const ids = (aasa.json?.applinks?.details ?? []).flatMap((d) => d.appID ? [d.appID] : d.appIDs ?? []);
247
+ if (ids.some((id) => id.endsWith(`.${iosId}`))) ok.push('apple-app-site-association lists this app');
248
+ else issues.push({ s: 'fail', m: `apple-app-site-association does not list ${iosId} (has ${ids.join(', ') || 'none'})` });
249
+ } else if (config?.ios) issues.push({ s: 'fail', m: `/.well-known/apple-app-site-association returned ${aasa.status || aasa.error}` });
250
+ if (links.status === 200) {
251
+ const pkgs = Array.isArray(links.json) ? links.json.map((s) => s?.target?.package_name).filter(Boolean) : [];
252
+ if (pkgs.includes(pkgId)) ok.push('assetlinks.json lists this app');
253
+ else issues.push({ s: 'fail', m: `assetlinks.json does not list ${pkgId} (has ${pkgs.join(', ') || 'none'})` });
254
+ } else if (config?.android) issues.push({ s: 'fail', m: `/.well-known/assetlinks.json returned ${links.status || links.error}` });
255
+ }
256
+ const configured = config?.ios || config?.android || ok.length;
257
+ const hint = 'Set acms({ mobile: { ios: { teamId, bundleId }, android: { package, sha256CertFingerprints } } }) in astro.config.mjs to match app.json, restart or deploy the site, and add associatedDomains / intentFilters in app.json (acms docs mobile → Deep links).';
258
+ if (issues.some((i) => i.s === 'fail')) c.fail('deep-links', 'Deep links', `${issues.map((i) => i.m).join('; ')}.`, { hint });
259
+ else if (issues.length) c.warn('deep-links', 'Deep links', `${issues.map((i) => i.m).join('; ')}.`, { hint });
260
+ else if (configured) c.pass('deep-links', 'Deep links', ok.length ? ok.join('; ') : `Configured${live ? '' : ' (site files not checked offline)'}.`);
261
+ else c.pass('deep-links', 'Deep links', `Not configured (optional)${config === null && !live ? '; run astro sync to let doctor read the site config' : ''}.`, { hint: 'To open site links in the app, see acms docs mobile → Deep links.' });
262
+ }
263
+
264
+ // 8. section coverage (client apps render pages; editor apps edit them)
265
+ const sources = (await files(dir)).filter((f) => SOURCE.test(f) && !f.endsWith('.d.ts'));
266
+ const texts = new Map(await Promise.all(sources.map(async (f) => [f, await readFile(join(dir, f), 'utf8').catch(() => '')])));
267
+ if (editor) { /* not applicable */ }
268
+ else if (!live) c.pass('sections', 'Section coverage', flags.offline ? 'Skipped (--offline).' : 'Skipped (site not reachable).');
269
+ else {
270
+ // Home first: its absolute url tells us the site's own origin for menu links.
271
+ const pageUrl = (slug) => `${url}/api/v1/pages/${slug.split('/').map(encodeURIComponent).join('/')}`;
272
+ const home = await getJson(pageUrl('home'));
273
+ let origin = new URL(url).origin;
274
+ try { if (home.json?.data?.url) origin = new URL(home.json.data.url).origin; } catch { /* keep */ }
275
+ const slugs = [];
276
+ for (const item of site.menu ?? []) {
277
+ try {
278
+ const u = new URL(item.href, origin);
279
+ if (u.origin !== origin && u.origin !== new URL(url).origin) continue;
280
+ const slug = u.pathname.replace(/^\/+|\/+$/g, '');
281
+ if (slug && slug !== 'home' && !slugs.includes(slug) && slugs.length < 11) slugs.push(slug);
282
+ } catch { /* not a URL */ }
283
+ }
284
+ const pages = [{ slug: 'home', r: home }, ...(await Promise.all(slugs.map(async (slug) => ({ slug, r: await getJson(pageUrl(slug)) }))))];
285
+ const used = new Map();
286
+ for (const { slug, r } of pages) if (r.status === 200) for (const s of r.json?.data?.sections ?? []) used.set(s.type, [...(used.get(s.type) ?? []), slug]);
287
+ const registry = new Set(registryKeys(texts.get('sections/index.ts') ?? '').map((k) => k.split(':')[0]));
288
+ const library = await libraryComponents();
289
+ const all = [...texts.values()].join('\n');
290
+ const fallbackOff = !deps['react-native-webview'] || /webSectionFallback=\{\s*false\s*\}/.test(all);
291
+ const partlyOff = /webFallback=\{\s*false\s*\}/.test(all);
292
+ const web = [...used.keys()].filter((t) => !registry.has(t) && !library[t]).sort();
293
+ const where = (t) => `${t} (${[...new Set(used.get(t))].join(', ')})`;
294
+ const checked = pages.filter((p) => p.r.status === 200).map((p) => p.slug);
295
+ if (!web.length) c.pass('sections', 'Section coverage', `All ${used.size} section types on ${checked.join(', ')} render natively.`);
296
+ else if (fallbackOff) c.warn('sections', 'Section coverage', `No native component and the web view is off${deps['react-native-webview'] ? '' : ' (react-native-webview is not installed)'}: ${web.map(where).join(', ')}. These show as a generic view.`, { hint: 'Write sections/<Type>.tsx and register it in sections/index.ts (acms docs mobile → Common tasks), or turn the web view back on.' });
297
+ else if (partlyOff) c.warn('sections', 'Section coverage', `Shown as a web view where allowed, generic where webFallback={false}: ${web.map(where).join(', ')}.`, { hint: 'Give these a native component in sections/index.ts.' });
298
+ else c.pass('sections', 'Section coverage', `Native: ${used.size - web.length} types; web view: ${web.map(where).join(', ')}.`, { hint: 'Give the busiest of these a native component for a native look.' });
299
+ }
300
+
301
+ // 9. content the app asks for by slug (pages, calendars) exists on the site
302
+ const consts = new Map();
303
+ for (const t of texts.values()) for (const m of t.matchAll(/\bconst\s+([A-Za-z_$][\w$]*)\s*(?::\s*string\s*)?=\s*['"]([^'"\n]+)['"]/g)) consts.set(m[1], m[2]);
304
+ const refs = { page: new Map(), calendar: new Map() };
305
+ const note = (kind, raw, file) => {
306
+ const slug = /^['"]/.test(raw) ? raw.slice(1, -1) : consts.get(raw);
307
+ if (slug && !/[${}]/.test(slug)) refs[kind].set(slug, [...new Set([...(refs[kind].get(slug) ?? []), file])]);
308
+ };
309
+ for (const [f, t] of texts) {
310
+ for (const m of t.matchAll(/\busePage\(\s*('[^']*'|"[^"]*"|[A-Za-z_$][\w$]*)/g)) note('page', m[1], f);
311
+ for (const m of t.matchAll(/<Sections\b[^>]*?\bpage=(?:\{\s*)?('[^']*'|"[^"]*"|[A-Za-z_$][\w$]*)/g)) note('page', m[1], f);
312
+ for (const m of t.matchAll(/\bcalendar\s*(?::|=\{?)\s*('[^']*'|"[^"]*"|[A-Za-z_$][\w$]*)/g)) note('calendar', m[1], f);
313
+ }
314
+ const wanted = refs.page.size + refs.calendar.size;
315
+ if (!live) c.pass('content', 'Site content', wanted ? `${wanted} page/calendar slug${wanted === 1 ? '' : 's'} not checked (${flags.offline ? '--offline' : 'site not reachable'}).` : 'The app asks for no pages or calendars by slug.');
316
+ else {
317
+ const missing = [];
318
+ for (const [slug, where] of refs.page) {
319
+ const r = await getJson(`${url}/api/v1/pages/${slug.split('/').map(encodeURIComponent).join('/')}`);
320
+ if (r.status === 404) missing.push(`page “${slug}” (${where.join(', ')}) is not published`);
321
+ }
322
+ if (refs.calendar.size) {
323
+ const r = await getJson(`${url}/api/v1/calendars`);
324
+ const known = new Set((r.json?.data?.calendars ?? []).map((cal) => cal.slug));
325
+ if (r.status === 200) for (const [slug, where] of refs.calendar) if (!known.has(slug)) missing.push(`calendar “${slug}” (${where.join(', ')}) is not a public calendar`);
326
+ }
327
+ if (missing.length) c.warn('content', 'Site content', `${missing.join('; ')}.`, { hint: 'The app shows an empty state until the owner creates it in the admin (Pages, Calendar). Tell them what to create; never hardcode it in the app.' });
328
+ else c.pass('content', 'Site content', wanted ? `Found on the site: ${[...[...refs.page.keys()].map((s) => `page ${s}`), ...[...refs.calendar.keys()].map((s) => `calendar ${s}`)].join(', ')}.` : 'The app asks for no pages or calendars by slug.');
329
+ }
330
+
331
+ // 10. secrets
332
+ const found = [], soft = [];
333
+ for (const [f, text] of texts) {
334
+ const lines = text.split('\n');
335
+ lines.forEach((l, i) => {
336
+ const at = `${f}:${i + 1}`;
337
+ if (/\bACMS_TOKEN\b/.test(l)) found.push(`${at} ACMS_TOKEN`);
338
+ if (/acms_(?:key|agent)_[A-Za-z0-9]{8,}/.test(l)) found.push(`${at} an ACMS API key`);
339
+ if (/['"`](?:Authorization['"`]?\s*[:,]\s*['"`])?Bearer\s+[A-Za-z0-9._~+/=-]{12,}/.test(l)) found.push(`${at} a Bearer token literal`);
340
+ if (ADMIN_ROUTES.test(l)) soft.push(`${at} an admin API route`);
341
+ if (editor) {
342
+ if (/AsyncStorage|async-storage/.test(l) && /token|auth|session|credential/i.test(l)) found.push(`${at} tokens in AsyncStorage (use the EditorProvider's secure storage)`);
343
+ if (/console\.(log|info|debug|warn|error)\s*\(/.test(l) && /(access|refresh)_?token|accessToken|refreshToken|authorization|tokens\b|code_verifier|codeVerifier/i.test(l)) found.push(`${at} logs a token`);
344
+ if (/secureTextEntry|type=['"]password['"]|autoComplete=['"](current-)?password['"]/.test(l)) found.push(`${at} a password field (staff sign in on the website, never in the app)`);
345
+ }
346
+ });
347
+ }
348
+ const all = await readdir(dir).catch(() => []);
349
+ const gitignore = await readFile(join(dir, '.gitignore'), 'utf8').catch(() => null);
350
+ const ignored = (name) => gitignore != null && gitignore.split(/\r?\n/).map((l) => l.trim()).some((p) => p && !p.startsWith('#') && (p === name || new RegExp(`^${p.replace(/[.+^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '.*')}$`).test(name)));
351
+ for (const env of all.filter((n) => /^\.env/.test(n))) {
352
+ const text = await readFile(join(dir, env), 'utf8').catch(() => '');
353
+ for (const m of text.matchAll(/^\s*([A-Z0-9_]+)\s*=\s*(.*)$/gm)) {
354
+ if (!SECRET_KEY.test(m[1]) || !m[2].trim()) continue;
355
+ if (m[1].startsWith('EXPO_PUBLIC_')) found.push(`${env} ${m[1]} (EXPO_PUBLIC_ values are bundled into the app)`);
356
+ else if (!ignored(env)) found.push(`${env} ${m[1]} (this file is not git-ignored)`);
357
+ else soft.push(`${env} ${m[1]} (apps don't need secrets)`);
358
+ }
359
+ if (env !== '.env' && env !== '.env.example' && !ignored(env)) soft.push(`${env} is not in .gitignore`);
360
+ }
361
+ if (gitignore == null) soft.push('.gitignore is missing');
362
+ else if (!ignored('.env.local')) soft.push('.gitignore does not ignore .env.local (the dev address)');
363
+ const hint = editor
364
+ ? 'Editor apps sign in through the site only: no password fields, tokens only through <EditorProvider> (secure storage), never logged or in AsyncStorage. No API keys: anything in the bundle is readable by anyone.'
365
+ : 'Remove tokens and keys from the app: it uses the public /api/v1 only, with no credentials. Anything in the bundle is readable by anyone.';
366
+ if (found.length) c.fail('secrets', 'Secrets', `${found.join('; ')}.`, { hint });
367
+ else if (soft.length) c.warn('secrets', 'Secrets', `${soft.join('; ')}.`, { hint });
368
+ else c.pass('secrets', 'Secrets', `No tokens, keys or admin routes in ${sources.length} source files.`);
369
+
370
+ // 11. recipes
371
+ const added = [];
372
+ for (const r of (await recipes()).filter((x) => x.kind === app.kind)) {
373
+ let have = 0;
374
+ for (const f of r.files) if (await exists(join(dir, f))) have++;
375
+ if (have && have * 2 >= r.files.length) added.push(r);
376
+ }
377
+ const missing = [], notes2 = [];
378
+ for (const r of added) {
379
+ const lack = r.packages.filter((p) => !deps[p]);
380
+ if (lack.length) missing.push({ recipe: r.name, packages: lack });
381
+ else if (hasModules) for (const p of r.packages) if (!(await exists(join(dir, 'node_modules', p)))) missing.push({ recipe: r.name, packages: [p], install: true });
382
+ if (r.name === 'push') {
383
+ if (!(expo.plugins ?? []).some((p) => (Array.isArray(p) ? p[0] : p) === 'expo-notifications')) notes2.push('push: add "expo-notifications" to app.json plugins');
384
+ if (!expo.extra?.eas?.projectId) notes2.push('push: no EAS project id in app.json (extra.eas.projectId), so Expo push tokens fail; run npx eas-cli init in the app folder');
385
+ // the site side: /api/v1/push/topics exists only when push is on
386
+ let siteOff = ctx.mobileConfig?.push?.enabled === false;
387
+ if (live) siteOff = (await getJson(`${url}/api/v1/push/topics`)).status === 404;
388
+ if (siteOff) notes2.push('push: push notifications are off for the site; set acms({ mobile: { push: true } }) in astro.config.mjs and restart or deploy');
389
+ }
390
+ }
391
+ if (missing.length) c.fail('recipes', 'Recipes', missing.map((m) => `${m.recipe} needs ${m.packages.join(', ')}${m.install ? ' (declared, not installed)' : ''}`).join('; ') + '.', { hint: `cd ${app.dir} && npx expo install ${[...new Set(missing.flatMap((m) => m.packages))].join(' ')}` });
392
+ else if (notes2.length) c.warn('recipes', 'Recipes', `${notes2.join('; ')}.`);
393
+ else c.pass('recipes', 'Recipes', added.length ? `${added.map((r) => r.name).join(', ')}: packages installed.` : 'None added.');
394
+
395
+ const summary = { pass: 0, warn: 0, fail: 0 };
396
+ for (const x of c.checks) summary[x.status]++;
397
+ return { dir: app.dir, kind: app.kind, name: app.name, url, ok: summary.fail === 0, summary, checks: c.checks };
398
+ }
399
+
400
+ export async function mobileDoctor(flags) {
401
+ let apps;
402
+ if (flags.dir) {
403
+ const app = await readApp(appDir(flags.dir).path);
404
+ if (!app) fail('NOT_FOUND', `${flags.dir} is not an ACMS mobile app (no "acms" marker in its package.json).`, [], ['acms mobile list']);
405
+ apps = [app];
406
+ } else apps = await listApps();
407
+ if (!apps.length) fail('NOT_FOUND', 'No mobile app found in this site.', [], ['acms add mobile']);
408
+ if (flags.url) { try { new URL(flags.url); } catch { fail('USAGE', '--url must be your site address, e.g. https://example.com.'); } }
409
+ const ctx = { core: await coreVersion(), mobileConfig: await siteMobileConfig() };
410
+ const results = [];
411
+ for (const app of apps) results.push(await doctorApp(app, flags, ctx));
412
+ const summary = { pass: 0, warn: 0, fail: 0 };
413
+ for (const r of results) for (const k of Object.keys(summary)) summary[k] += r.summary[k];
414
+ return { ok: summary.fail === 0, core: ctx.core, compatibility: compatFor(ctx.core), offline: !!flags.offline, summary, apps: results };
415
+ }
416
+
417
+ export function renderDoctor(data) {
418
+ const out = [];
419
+ for (const app of data.apps) {
420
+ out.push(`${app.dir}/ ${app.kind} app “${app.name}”`);
421
+ for (const x of app.checks) {
422
+ out.push(` ${x.status === 'pass' ? 'ok ' : x.status === 'warn' ? 'warn' : 'FAIL'} ${x.title}: ${x.message}`);
423
+ if (x.hint && x.status !== 'pass') out.push(` → ${x.hint}`);
424
+ }
425
+ out.push('');
426
+ }
427
+ const s = data.summary;
428
+ out.push(`${s.pass} passed, ${s.warn} warning${s.warn === 1 ? '' : 's'}, ${s.fail} failed${data.offline ? ' (offline)' : ''}`);
429
+ return out.join('\n');
430
+ }