@tomorrowos/sdk 0.9.4 → 0.9.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/REPLIT_SETUP.md CHANGED
@@ -12,7 +12,7 @@ When a human pastes a prompt like this, **read this file and start Question 1 im
12
12
  ```text
13
13
  Follow @tomorrowos/sdk REPLIT_SETUP.md and set up my TomorrowOS CMS.
14
14
  Ask only the questions listed in REPLIT_SETUP.md, in order. Do not use LLM_PROMPT.md.
15
- Artifact kind must be **web** (never **api**). Question 4 only updates **brand.json**.
15
+ Artifact kind must be **web** (never **api**). Question 3 only updates **brand.json**.
16
16
  After all setup questions are answered (including Cloudinary Secrets if you chose Cloudinary), configure and start — minimal verification only (Web app type + Preview at /). **Never test Supabase connection** — save `SUPABASE_URL` and move on. No Supabase or WebSocket tests.
17
17
  Do not skip steps.
18
18
  ```
@@ -58,21 +58,20 @@ Replit Agent must ask **only** the questions defined in **this file**, in **this
58
58
 
59
59
  | Step | Section | When |
60
60
  |------|---------|------|
61
- | 1 | **Question 1** — How many screens / devices? | Always |
62
- | 2 | **Question 2** — Supabase connection | Only if answer to Q1 is **> 5** |
63
- | 3 | **Question 3** — Media storage (+ Cloudinary Secrets if chosen) | Always (after Q1 or Q2) |
64
- | 4 | **Question 4** — Brand / TomorrowOS app look | Always last, before execution |
61
+ | 1 | **Question 1** — Supabase connection string | Always |
62
+ | 2 | **Question 2** — Media storage (+ Cloudinary Secrets if chosen) | Always (after Q1) |
63
+ | 3 | **Question 3** — Brand / TomorrowOS app look | Always last, before execution |
65
64
 
66
- **That is the complete list.** There are no other setup questions. There is **no** “Question 3b” — Cloudinary credentials are collected **inside Question 3**, immediately after the user chooses Cloudinary.
65
+ **That is the complete list.** There are no other setup questions. **Do not** ask how many screens / devices. There is **no** “Question 2b” — Cloudinary credentials are collected **inside Question 2**, immediately after the user chooses Cloudinary.
67
66
 
68
- **Critical:** Question 3 is **not complete** when the user says “Cloudinary” or “yes”. You **must** immediately show the Cloudinary Secrets input (same step — do **not** label a new question) and collect `CLOUDINARY_CLOUD_NAME`, `CLOUDINARY_API_KEY`, and `CLOUDINARY_API_SECRET` **before** Question 4. Do **not** skip to branding with placeholder or invented credentials.
67
+ **Critical:** Question 2 is **not complete** when the user says “Cloudinary” or “yes”. You **must** immediately show the Cloudinary Secrets input (same step — do **not** label a new question) and collect `CLOUDINARY_CLOUD_NAME`, `CLOUDINARY_API_KEY`, and `CLOUDINARY_API_SECRET` **before** Question 3. Do **not** skip to branding with placeholder or invented credentials.
69
68
 
70
69
 
71
- If the user volunteers extra info early (e.g. brand name before Q4), **record it** and still ask the current question’s required fields you do not yet have. Do not skip ahead to execution until Q1–Q4 are complete (Question 3 includes Cloudinary Secrets when applicable).
70
+ If the user volunteers extra info early (e.g. brand name before Q3), **record it** and still ask the current question’s required fields you do not yet have. Do not skip ahead to execution until Q1–Q3 are complete (Question 2 includes Cloudinary Secrets when applicable).
72
71
 
73
72
  If the user says “just set it up, don’t ask questions,” respond:
74
73
 
75
- > I need a few quick answers from REPLIT_SETUP.md (screen count, media storage — plus Cloudinary credentials if you choose Cloudinary — and branding). It takes about a minute and ensures the CMS deploys correctly on Replit.
74
+ > I need a few quick answers from REPLIT_SETUP.md (Supabase connection string, media storage — plus Cloudinary credentials if you choose Cloudinary — and branding). It takes about a minute and ensures the CMS deploys correctly on Replit.
76
75
 
77
76
  Then ask **Question 1** — do not switch to `LLM_PROMPT.md`.
78
77
 
@@ -80,9 +79,9 @@ Then ask **Question 1** — do not switch to `LLM_PROMPT.md`.
80
79
 
81
80
  ## Hard rules
82
81
 
83
- 1. **Ask only REPLIT_SETUP.md questions 1–4** (see **Questionnaire scope**). Never use `LLM_PROMPT.md` on Replit.
84
- 2. **One question at a time.** Wait for the user’s answer before asking the next question (unless they already answered several in one message). **Exception:** If the user chooses Cloudinary in Question 3, **stay on Question 3** and immediately show the Secrets form — do **not** open a separate “3b” step or announce a new question number.
85
- 3. **Do not invent** screen counts, Cloudinary credentials, Supabase URLs, or brand colours.
82
+ 1. **Ask only REPLIT_SETUP.md questions 1–3** (see **Questionnaire scope**). Never use `LLM_PROMPT.md` on Replit.
83
+ 2. **One question at a time.** Wait for the user’s answer before asking the next question (unless they already answered several in one message). **Exception:** If the user chooses Cloudinary in Question 2, **stay on Question 2** and immediately show the Secrets form — do **not** open a separate “2b” step or announce a new question number.
84
+ 3. **Do not invent** Cloudinary credentials, Supabase URLs, or brand colours. **Do not ask** screen / device count.
86
85
  4. **Do not use Replit’s reserved `DATABASE_URL` Secret for Supabase.** On Replit, `DATABASE_URL` is often pre-claimed. Always use **`SUPABASE_URL`** for TomorrowOS ↔ Supabase.
87
86
  5. **Prefer `npx @tomorrowos/sdk init`** (or the package’s `templates/cms-starter`) as the project seed. Do not rebuild pairing, WebSocket, or playlist APIs from scratch.
88
87
  6. **Never commit secrets** into git. Put credentials only in Replit **Secrets** (and optionally mirror non-secret flags in `.env` that reference env vars).
@@ -91,12 +90,11 @@ Then ask **Question 1** — do not switch to `LLM_PROMPT.md`.
91
90
  9. **Supabase: configure only — never test.** Save `SUPABASE_URL` + `TOMORROWOS_STORE=supabase` and proceed. **Do not** ping Postgres, run `psql`, start the server solely to verify DB connectivity, or treat log errors like `ENOTFOUND` / `database connection failure` in **Replit dev / Preview** as a failed setup. Supabase often works only after **Publish** (production networking), not in the dev shell.
92
91
  10. **Artifact kind = `web` only.** **`kind = "api"` is forbidden** for the TomorrowOS CMS artifact. See **IRON RULE** above. Setup is incomplete if Publish would still see an `api` artifact.
93
92
 
94
- **Question order after Q1:**
93
+ **Question order (always):**
95
94
 
96
- - If **≤ 5 screens:** Q1 → Q3Q4 → execution checklist
97
- - If **> 5 screens:** Q1 → Q2 → Q3 → Q4 → execution checklist
95
+ - Q1 → Q2Q3 → execution checklist
98
96
 
99
- Never insert extra questions between these steps. When the user chooses Cloudinary, **Question 3 continues** with the Secrets form immediately — then proceed to Q4 only after secrets are saved.
97
+ Never insert extra questions between these steps. When the user chooses Cloudinary, **Question 2 continues** with the Secrets form immediately — then proceed to Q3 only after secrets are saved.
100
98
 
101
99
  ---
102
100
 
@@ -232,7 +230,7 @@ Allowed extras (optional): `"build-player": "tomorrowos build --platform tizen"`
232
230
  "node": ">=20"
233
231
  },
234
232
  "dependencies": {
235
- "@tomorrowos/sdk": "^0.9.4",
233
+ "@tomorrowos/sdk": "^0.9.6",
236
234
  "dotenv": "^17.2.3",
237
235
  "tsx": "^4.19.0"
238
236
  },
@@ -330,13 +328,13 @@ Replace `YOUR-REPLIT-DOMAIN` with the actual published hostname (e.g. `my-cms.re
330
328
 
331
329
  ## Post-Q&A: minimal verification only
332
330
 
333
- After Questions 1–4, **configure → `npm install` → `npm run start` → done.**
331
+ After Questions 1–3, **configure → `npm install` → `npm run start` → done.**
334
332
 
335
333
  ### Supabase: never test in Replit dev (mandatory)
336
334
 
337
- When the user chose Supabase (>5 screens), setup is **save credentials + wire `server.ts` + start** — **nothing else**.
335
+ Setup always uses Supabase: **save credentials + wire `server.ts` + start** — **nothing else**.
338
336
 
339
- **Forbidden after Q&A (and after Question 2):**
337
+ **Forbidden after Q&A (and after Question 1):**
340
338
 
341
339
  - ❌ “Testing Supabase connection…”
342
340
  - ❌ `psql`, `pg`, Prisma `db pull`, or any one-off DB probe script
@@ -374,34 +372,17 @@ Then (and only then) check: API vs Web type → `.replit` ports/`deploymentTarge
374
372
 
375
373
  ---
376
374
 
377
- ## Question 1 — How many screens / devices?
375
+ ## Question 1 — Supabase connection (always)
378
376
 
379
- > **This is the first and only “screen count” question.** Do not re-ask later.
377
+ > **This is the first setup question.** Always configure Supabase. **Do not** ask how many screens / devices.
380
378
 
381
- **Ask exactly (copy wording; do not substitute LLM_PROMPT questions):**
382
-
383
- > How many TomorrowOS screens (devices) do you expect to manage with this CMS?
384
- >
385
- > - **5 or fewer** → I’ll use the default **SQLite** database (simple, good for demos and small venues).
386
- > - **More than 5** → I’ll configure **Supabase (Postgres)** so pairings and playlists scale more reliably.
387
-
388
- **Branching:**
389
-
390
- | Answer | Store driver | Next |
391
- |--------|--------------|------|
392
- | ≤ 5 | `TOMORROWOS_STORE=sqlite` | Skip Question 2 → go to Question 3 |
393
- | > 5 | `TOMORROWOS_STORE=supabase` | Ask Question 2 |
394
- | Unclear | Ask again | Do not proceed |
379
+ **Preferred on Replit:** open an **input / Secrets** UI where the user pastes the connection string into a field for:
395
380
 
396
- **Store in `brand.json`:** `cms.expectedScreens` (number), `cms.hostingTarget`: `"here"`.
381
+ - `SUPABASE_URL`
397
382
 
398
- ---
399
-
400
- ## Question 2 — Supabase connection (only if > 5 screens)
401
-
402
- **Ask exactly:**
383
+ **Ask exactly (copy wording; do not substitute LLM_PROMPT questions):**
403
384
 
404
- > Paste your Supabase Postgres connection string. I will store it as the Replit Secret **`SUPABASE_URL`** (not `DATABASE_URL` — that name is often reserved by Replit).
385
+ > Paste your Supabase Postgres connection string below. I will store it as the Replit Secret **`SUPABASE_URL`** (not `DATABASE_URL` — that name is often reserved by Replit).
405
386
  >
406
387
  > Example shape: `postgresql://postgres.[PROJECT]:[PASSWORD]@aws-0-[REGION].pooler.supabase.com:6543/postgres`
407
388
  >
@@ -449,13 +430,16 @@ DATABASE_SSL=true
449
430
  ```
450
431
 
451
432
  5. **Do not** write the real password into committed files.
452
- 6. **Do not** test the Supabase connection after saving the Secret — **configuration only**. No probe, no `npm run start` just to verify DB, no fixing “connection failure” in dev. Saving `SUPABASE_URL` completes Question 2.
433
+ 6. **Do not** test the Supabase connection after saving the Secret — **configuration only**. No probe, no `npm run start` just to verify DB, no fixing “connection failure” in dev. Saving `SUPABASE_URL` completes Question 1.
434
+ 7. **Do not** proceed to Question 2 until `SUPABASE_URL` is saved.
453
435
 
454
- If the user refuses Supabase but chose > 5 screens, warn them SQLite on Replit is fragile for fleets, then offer SQLite only after they confirm.
436
+ If the user refuses Supabase, warn that Replit fleets need a durable Postgres store, then offer SQLite only after they explicitly confirm.
437
+
438
+ **Store in `brand.json` later (Question 3):** `cms.hostingTarget`: `"here"`. Do **not** ask for `expectedScreens`; leave a default (e.g. `5`) when writing `brand.json`.
455
439
 
456
440
  ---
457
441
 
458
- ## Question 3 — Media storage (uploads / thumbnails)
442
+ ## Question 2 — Media storage (uploads / thumbnails)
459
443
 
460
444
  ### Step A — Ask storage choice
461
445
 
@@ -471,9 +455,9 @@ If the user refuses Supabase but chose > 5 screens, warn them SQLite on Replit i
471
455
  >
472
456
  > Do you want me to set up **Cloudinary**? (yes / no)
473
457
 
474
- ### Step B — If YES / Cloudinary (same Question 3 — no “3b”, no extra question)
458
+ ### Step B — If YES / Cloudinary (same Question 2 — no “2b”, no extra question)
475
459
 
476
- **Do this immediately** when the user chooses Cloudinary. **Do not** say “Question 3b”, “next step”, or “Great — I’ll use Cloudinary” as a separate message before the form. **Go straight to the Secrets input.**
460
+ **Do this immediately** when the user chooses Cloudinary. **Do not** say “Question 2b”, “next step”, or “Great — I’ll use Cloudinary” as a separate message before the form. **Go straight to the Secrets input.**
477
461
 
478
462
  **Preferred on Replit:** open the **Replit Secrets** UI with fields for:
479
463
 
@@ -497,10 +481,10 @@ If the user refuses Supabase but chose > 5 screens, warn them SQLite on Replit i
497
481
  1. Save Replit Secrets: `CLOUDINARY_CLOUD_NAME`, `CLOUDINARY_API_KEY`, `CLOUDINARY_API_SECRET` (and optional `CLOUDINARY_FOLDER`).
498
482
  2. **Do not** write real secrets into committed files.
499
483
  3. **Do not** invent or placeholder credentials.
500
- 4. **Do not** proceed to Question 4 until all three required Secrets exist.
484
+ 4. **Do not** proceed to Question 3 until all three required Secrets exist.
501
485
  5. **Do not** run a Cloudinary upload test — configuration only.
502
486
 
503
- **If the user does not have a Cloudinary account yet:** tell them to sign up at cloudinary.com and return with API keys. **Wait** on Question 3 — do not skip to Question 4.
487
+ **If the user does not have a Cloudinary account yet:** tell them to sign up at cloudinary.com and return with API keys. **Wait** on Question 2 — do not skip to Question 3.
504
488
 
505
489
  The SDK auto-detects these env vars and uploads media to Cloudinary. New uploads will return `https://res.cloudinary.com/...` URLs.
506
490
 
@@ -513,17 +497,15 @@ The SDK auto-detects these env vars and uploads media to Cloudinary. New uploads
513
497
  3. Warn: if Object Storage IAM denies `storage.objects.create`, uploads will fail with a Google Cloud Storage permission error — fix Object Storage permissions or switch to Cloudinary.
514
498
  4. Do **not** invent Cloudinary credentials.
515
499
 
516
- **Also persist metadata:** keep `data/tomorrowos.db` (SQLite path) on durable storage when using SQLite.
517
-
518
500
  ---
519
501
 
520
- ## Question 4 — Brand / TomorrowOS app look (`brand.json` only)
502
+ ## Question 3 — Brand / TomorrowOS app look (`brand.json` only)
521
503
 
522
- > **Scope:** Question 4 answers **only** update **`brand.json`** — colours, name, tagline, logo path, `cms.expectedScreens`, etc. They configure how the **TomorrowOS player app** looks and metadata flags.
504
+ > **Scope:** Question 3 answers **only** update **`brand.json`** — colours, name, tagline, logo path, etc. They configure how the **TomorrowOS player app** looks and metadata flags.
523
505
  >
524
- > **Question 4 does NOT affect:** Replit Publish, artifact `kind`, `.replit`, `server.ts`, Secrets, Supabase, Cloudinary, ports, deployment target, or CMS server wiring. **Never** change deploy/runtime config based on branding answers.
506
+ > **Question 3 does NOT affect:** Replit Publish, artifact `kind`, `.replit`, `server.ts`, Secrets, Supabase, Cloudinary, ports, deployment target, or CMS server wiring. **Never** change deploy/runtime config based on branding answers.
525
507
 
526
- > **This is the only branding / platform / use-case question block.** Do not ask separate LLM_PROMPT “target platform”, “use case”, or “hosting” questions before this.
508
+ > **This is the only branding / platform / use-case question block.** Do not ask separate LLM_PROMPT “target platform”, “use case”, or “hosting” questions before this. **Do not** ask screen count.
527
509
 
528
510
  **Ask exactly (one message; user may answer in one reply):**
529
511
 
@@ -568,7 +550,7 @@ If the user only gives a name and primary colour, use defaults for the rest and
568
550
  }
569
551
  ```
570
552
 
571
- Set `cms.expectedScreens` from Question 1 (do not ask screen count again). Always set `cms.hostingTarget` to `"here"` on Replit without asking. Set `cmsEndpoint` only if the user already knows their public `wss://…replit.app` URL; otherwise leave it out and tell them to pair TVs using the published HTTPS URL (player converts `https://` → `wss://`).
553
+ Do **not** ask for screen count. Keep a default `cms.expectedScreens` (e.g. `5`) unless the user already volunteered a number. Always set `cms.hostingTarget` to `"here"` on Replit without asking. Set `cmsEndpoint` only if the user already knows their public `wss://…replit.app` URL; otherwise leave it out and tell them to pair TVs using the published HTTPS URL (player converts `https://` → `wss://`).
572
554
 
573
555
  ---
574
556
 
@@ -593,14 +575,7 @@ If `init` is inappropriate (existing customised project), merge carefully:
593
575
 
594
576
  ### B. Apply store + media Secrets
595
577
 
596
- **SQLite (≤5):**
597
-
598
- ```env
599
- TOMORROWOS_STORE=sqlite
600
- TOMORROWOS_DB_PATH=./data/tomorrowos.db
601
- ```
602
-
603
- **Supabase (>5):**
578
+ **Supabase (always from Question 1):**
604
579
 
605
580
  ```env
606
581
  TOMORROWOS_STORE=supabase
@@ -669,8 +644,8 @@ Only investigate further if Preview is empty, server crashed, or user reports Pu
669
644
 
670
645
  ### Forbidden questionnaire sources
671
646
 
672
- - **`LLM_PROMPT.md`** — not used on Replit setup; its five questions are **replaced** by REPLIT_SETUP Questions 1–4
673
- - Standalone “target platform” / “hosting target” / “use case” prompts outside Question 4
647
+ - **`LLM_PROMPT.md`** — not used on Replit setup; its questions are **replaced** by REPLIT_SETUP Questions 1–3
648
+ - Standalone “target platform” / “hosting target” / “use case” / **screen count** prompts outside Question 3
674
649
 
675
650
  Do **not** ask the user how to implement:
676
651
 
@@ -685,24 +660,23 @@ Do **not**:
685
660
  - Change the production start command away from `tsx server.ts` / `npm run start`
686
661
  - Point Supabase at Replit’s reserved `DATABASE_URL` when `SUPABASE_URL` should be used
687
662
  - Skip Cloudinary recommendation without stating the persistence tradeoff
688
- - **Skip Cloudinary Secrets** after the user chooses Cloudinary — always collect `CLOUDINARY_*` inside Question 3 before Question 4
689
- - **Announce “Question 3b”** or a separate follow-up question — show the Secrets form immediately instead
663
+ - **Skip Cloudinary Secrets** after the user chooses Cloudinary — always collect `CLOUDINARY_*` inside Question 2 before Question 3
664
+ - **Announce “Question 2b”** or a separate follow-up question — show the Secrets form immediately instead
690
665
  - Claim uploads are “permanent” on Replit local disk without Object Storage or Cloudinary
691
666
  - Leave `tsx` only in `devDependencies` if the Deploy pipeline installs production deps only
692
667
  - Use Canvas preview as the primary CMS verification surface
693
668
  - Serve a different app or API-only response at `/` instead of the Control Panel
694
669
  - Create the Repl as **API-only** or publish with **Static Deployment** only
695
670
  - Set or leave artifact **`kind = "api"`** for the TomorrowOS CMS
696
- - Change `.replit`, `server.ts`, Secrets, or deployment config based on **Question 4** branding answers (those go in **`brand.json` only**)
671
+ - Change `.replit`, `server.ts`, Secrets, or deployment config based on **Question 3** branding answers (those go in **`brand.json` only**)
697
672
  - Omit `[[ports]]` / `deploymentTarget` from `.replit` when setting up for Publish
698
673
  - Run **Supabase connection tests**, **WebSocket console tests**, or other post-setup smoke tests unless Preview fails or the user asks
699
674
  - **Debug or “fix” Supabase** because dev logs show `getaddrinfo ENOTFOUND` or `database connection failure` — save config and finish setup instead
675
+ - Ask how many screens / devices (≤5 vs >5) — always start with Supabase
700
676
 
701
677
  ---
702
678
 
703
- ## SQLite path (≤5) — `server.ts` reference
704
-
705
- Default starter is fine:
679
+ ## SQLite fallback — `server.ts` reference (only if user refuses Supabase)
706
680
 
707
681
  ```ts
708
682
  const store = createTomorrowOSStore({
@@ -738,14 +712,16 @@ TOMORROWOS_STORE=sqlite
738
712
 
739
713
  ## Protocol version
740
714
 
741
- `replit-setup/1.7` — aligned with TomorrowOS protocol `1.0` and `@tomorrowos/sdk` store drivers `sqlite` | `supabase` | `postgres` | `memory`.
715
+ `replit-setup/1.8` — aligned with TomorrowOS protocol `1.0` and `@tomorrowos/sdk` store drivers `sqlite` | `supabase` | `postgres` | `memory`.
716
+
717
+ **Changelog 1.8:** Question 1 is always Supabase connection string (input / Secret). Removed screen-count branching (≤5 SQLite vs >5 Supabase). Media storage is Question 2; branding is Question 3.
742
718
 
743
- **Changelog 1.7:** **IRON RULE** — artifact **`kind = "web"` only** (never `api`); Question 4 **only** writes `brand.json` (no deploy/server changes).
719
+ **Changelog 1.7:** **IRON RULE** — artifact **`kind = "web"` only** (never `api`); Question 4 (**now 3**) **only** writes `brand.json` (no deploy/server changes).
744
720
 
745
721
  **Changelog 1.6:** **Never test Supabase** in Replit dev — `ENOTFOUND` in Preview logs is expected.
746
722
 
747
- **Changelog 1.5:** Cloudinary Secrets inside Question 3 — no “3b” step.
723
+ **Changelog 1.5:** Cloudinary Secrets inside media question — no “3b” step.
748
724
 
749
- **Changelog 1.4:** mandatory Cloudinary credential collection before Question 4 (superseded by 1.5 flow).
725
+ **Changelog 1.4:** mandatory Cloudinary credential collection before branding (superseded by 1.5 flow).
750
726
 
751
727
  **Changelog 1.3:** minimal post-Q&A verification — no Supabase/WS smoke tests; prioritize **Web app vs API** only.
@@ -17,4 +17,6 @@ export declare function uploadBufferToCloudinary(body: Buffer, options: {
17
17
  filename: string;
18
18
  }): Promise<CloudinaryUploadResult>;
19
19
  export declare function deleteCloudinaryAsset(config: CloudinaryStorageConfig, publicId: string, resourceType?: string): Promise<void>;
20
+ /** Lightweight credential / reachability check for CMS status UI. */
21
+ export declare function pingCloudinary(config: CloudinaryStorageConfig): Promise<void>;
20
22
  //# sourceMappingURL=cloudinary-storage.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"cloudinary-storage.d.ts","sourceRoot":"","sources":["../src/cloudinary-storage.ts"],"names":[],"mappings":"AAEA,MAAM,WAAW,uBAAuB;IACtC,SAAS,EAAE,MAAM,CAAC;IAClB,MAAM,EAAE,MAAM,CAAC;IACf,SAAS,EAAE,MAAM,CAAC;IAClB,MAAM,CAAC,EAAE,MAAM,CAAC;CACjB;AAED,MAAM,WAAW,sBAAsB;IACrC,QAAQ,EAAE,MAAM,CAAC;IACjB,SAAS,EAAE,MAAM,CAAC;IAClB,YAAY,EAAE,MAAM,CAAC;IACrB,KAAK,CAAC,EAAE,MAAM,CAAC;CAChB;AAOD,wBAAgB,uBAAuB,CACrC,GAAG,GAAE,MAAM,CAAC,UAAwB,GACnC,uBAAuB,GAAG,IAAI,CAoBhC;AAWD,wBAAsB,wBAAwB,CAC5C,IAAI,EAAE,MAAM,EACZ,OAAO,EAAE;IACP,MAAM,EAAE,uBAAuB,CAAC;IAChC,QAAQ,EAAE,MAAM,CAAC;IACjB,QAAQ,EAAE,MAAM,CAAC;CAClB,GACA,OAAO,CAAC,sBAAsB,CAAC,CAsCjC;AAED,wBAAsB,qBAAqB,CACzC,MAAM,EAAE,uBAAuB,EAC/B,QAAQ,EAAE,MAAM,EAChB,YAAY,SAAU,GACrB,OAAO,CAAC,IAAI,CAAC,CAKf"}
1
+ {"version":3,"file":"cloudinary-storage.d.ts","sourceRoot":"","sources":["../src/cloudinary-storage.ts"],"names":[],"mappings":"AAEA,MAAM,WAAW,uBAAuB;IACtC,SAAS,EAAE,MAAM,CAAC;IAClB,MAAM,EAAE,MAAM,CAAC;IACf,SAAS,EAAE,MAAM,CAAC;IAClB,MAAM,CAAC,EAAE,MAAM,CAAC;CACjB;AAED,MAAM,WAAW,sBAAsB;IACrC,QAAQ,EAAE,MAAM,CAAC;IACjB,SAAS,EAAE,MAAM,CAAC;IAClB,YAAY,EAAE,MAAM,CAAC;IACrB,KAAK,CAAC,EAAE,MAAM,CAAC;CAChB;AAOD,wBAAgB,uBAAuB,CACrC,GAAG,GAAE,MAAM,CAAC,UAAwB,GACnC,uBAAuB,GAAG,IAAI,CAoBhC;AAWD,wBAAsB,wBAAwB,CAC5C,IAAI,EAAE,MAAM,EACZ,OAAO,EAAE;IACP,MAAM,EAAE,uBAAuB,CAAC;IAChC,QAAQ,EAAE,MAAM,CAAC;IACjB,QAAQ,EAAE,MAAM,CAAC;CAClB,GACA,OAAO,CAAC,sBAAsB,CAAC,CAsCjC;AAED,wBAAsB,qBAAqB,CACzC,MAAM,EAAE,uBAAuB,EAC/B,QAAQ,EAAE,MAAM,EAChB,YAAY,SAAU,GACrB,OAAO,CAAC,IAAI,CAAC,CAKf;AAED,qEAAqE;AACrE,wBAAsB,cAAc,CAClC,MAAM,EAAE,uBAAuB,GAC9B,OAAO,CAAC,IAAI,CAAC,CAGf"}
@@ -68,3 +68,8 @@ export async function deleteCloudinaryAsset(config, publicId, resourceType = "im
68
68
  resource_type: resourceType
69
69
  });
70
70
  }
71
+ /** Lightweight credential / reachability check for CMS status UI. */
72
+ export async function pingCloudinary(config) {
73
+ configureCloudinary(config);
74
+ await cloudinary.api.ping();
75
+ }
package/dist/index.d.ts CHANGED
@@ -1,5 +1,7 @@
1
1
  export { TomorrowOS } from "./tomorrowos.js";
2
2
  export type { DeviceListItem, DeviceScreenshotInfo, ListenOptions, TomorrowOSBrand, TomorrowOSOptions } from "./tomorrowos.js";
3
+ export type { ConnectorState, ConnectorStatus, ServerStatusReport, StatusBlocker } from "./server-status.js";
4
+ export { buildServerStatus } from "./server-status.js";
3
5
  export type { DevicePlaylistAssignment, DeviceRegistryRecord, PairedDeviceRecord, PendingCodeEntry, PendingCodeRecord, DeviceRegistryEntry, PlaylistItemRecord, PlaylistSchedule, PublishedPlaylistSnapshot, StoredPlaylist, TomorrowOSDataSnapshot, TomorrowOSMigratableStore, TomorrowOSStore } from "./store/types.js";
4
6
  export { PlaylistCatalog } from "./playlist-catalog.js";
5
7
  export type { BuiltDevicePolicy, SavePlaylistInput } from "./playlist-catalog.js";
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,UAAU,EAAE,MAAM,iBAAiB,CAAC;AAC7C,YAAY,EACV,cAAc,EACd,oBAAoB,EACpB,aAAa,EACb,eAAe,EACf,iBAAiB,EAClB,MAAM,iBAAiB,CAAC;AAEzB,YAAY,EACV,wBAAwB,EACxB,oBAAoB,EACpB,kBAAkB,EAClB,gBAAgB,EAChB,iBAAiB,EACjB,mBAAmB,EACnB,kBAAkB,EAClB,gBAAgB,EAChB,yBAAyB,EACzB,cAAc,EACd,sBAAsB,EACtB,yBAAyB,EACzB,eAAe,EAChB,MAAM,kBAAkB,CAAC;AAC1B,OAAO,EAAE,eAAe,EAAE,MAAM,uBAAuB,CAAC;AACxD,YAAY,EAAE,iBAAiB,EAAE,iBAAiB,EAAE,MAAM,uBAAuB,CAAC;AAClF,OAAO,EACL,2BAA2B,EAC3B,gCAAgC,EAChC,yBAAyB,EACzB,wBAAwB,EACxB,oBAAoB,EACpB,qBAAqB,EACrB,mBAAmB,EACnB,wBAAwB,EACzB,MAAM,mBAAmB,CAAC;AAC3B,OAAO,EAAE,WAAW,EAAE,MAAM,yBAAyB,CAAC;AACtD,OAAO,EAAE,aAAa,EAAE,MAAM,2BAA2B,CAAC;AAC1D,YAAY,EAAE,oBAAoB,EAAE,MAAM,2BAA2B,CAAC;AACtE,OAAO,EAAE,WAAW,EAAE,MAAM,yBAAyB,CAAC;AACtD,OAAO,EACL,qBAAqB,EACrB,iBAAiB,EAClB,MAAM,oBAAoB,CAAC;AAC5B,YAAY,EACV,4BAA4B,EAC5B,qBAAqB,EACtB,MAAM,oBAAoB,CAAC;AAC5B,OAAO,EACL,oBAAoB,EACpB,oBAAoB,EACpB,qBAAqB,EACtB,MAAM,sBAAsB,CAAC;AAC9B,YAAY,EAAE,eAAe,EAAE,MAAM,sBAAsB,CAAC"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,UAAU,EAAE,MAAM,iBAAiB,CAAC;AAC7C,YAAY,EACV,cAAc,EACd,oBAAoB,EACpB,aAAa,EACb,eAAe,EACf,iBAAiB,EAClB,MAAM,iBAAiB,CAAC;AACzB,YAAY,EACV,cAAc,EACd,eAAe,EACf,kBAAkB,EAClB,aAAa,EACd,MAAM,oBAAoB,CAAC;AAC5B,OAAO,EAAE,iBAAiB,EAAE,MAAM,oBAAoB,CAAC;AAEvD,YAAY,EACV,wBAAwB,EACxB,oBAAoB,EACpB,kBAAkB,EAClB,gBAAgB,EAChB,iBAAiB,EACjB,mBAAmB,EACnB,kBAAkB,EAClB,gBAAgB,EAChB,yBAAyB,EACzB,cAAc,EACd,sBAAsB,EACtB,yBAAyB,EACzB,eAAe,EAChB,MAAM,kBAAkB,CAAC;AAC1B,OAAO,EAAE,eAAe,EAAE,MAAM,uBAAuB,CAAC;AACxD,YAAY,EAAE,iBAAiB,EAAE,iBAAiB,EAAE,MAAM,uBAAuB,CAAC;AAClF,OAAO,EACL,2BAA2B,EAC3B,gCAAgC,EAChC,yBAAyB,EACzB,wBAAwB,EACxB,oBAAoB,EACpB,qBAAqB,EACrB,mBAAmB,EACnB,wBAAwB,EACzB,MAAM,mBAAmB,CAAC;AAC3B,OAAO,EAAE,WAAW,EAAE,MAAM,yBAAyB,CAAC;AACtD,OAAO,EAAE,aAAa,EAAE,MAAM,2BAA2B,CAAC;AAC1D,YAAY,EAAE,oBAAoB,EAAE,MAAM,2BAA2B,CAAC;AACtE,OAAO,EAAE,WAAW,EAAE,MAAM,yBAAyB,CAAC;AACtD,OAAO,EACL,qBAAqB,EACrB,iBAAiB,EAClB,MAAM,oBAAoB,CAAC;AAC5B,YAAY,EACV,4BAA4B,EAC5B,qBAAqB,EACtB,MAAM,oBAAoB,CAAC;AAC5B,OAAO,EACL,oBAAoB,EACpB,oBAAoB,EACpB,qBAAqB,EACtB,MAAM,sBAAsB,CAAC;AAC9B,YAAY,EAAE,eAAe,EAAE,MAAM,sBAAsB,CAAC"}
package/dist/index.js CHANGED
@@ -1,4 +1,5 @@
1
1
  export { TomorrowOS } from "./tomorrowos.js";
2
+ export { buildServerStatus } from "./server-status.js";
2
3
  export { PlaylistCatalog } from "./playlist-catalog.js";
3
4
  export { DEFAULT_PAIRING_CODE_SECRET, generateDeterministicPairingCode, generateRandomPairingCode, isValidPairingCodeFormat, normalizePairingCode, PAIRING_CODE_ALPHABET, PAIRING_CODE_LENGTH, resolvePairingCodeSecret } from "./pairing-code.js";
4
5
  export { MemoryStore } from "./store/memory-store.js";
@@ -0,0 +1,30 @@
1
+ import type { TomorrowOSStore } from "./store/types.js";
2
+ export type ConnectorState = "ok" | "warn" | "error" | "missing";
3
+ export interface ConnectorStatus {
4
+ id: string;
5
+ label: string;
6
+ provider?: string;
7
+ state: ConnectorState;
8
+ detail: string;
9
+ }
10
+ export interface StatusBlocker {
11
+ connectorId: string;
12
+ title: string;
13
+ message: string;
14
+ fixHint: string;
15
+ }
16
+ export interface ServerStatusReport {
17
+ status: "success";
18
+ overall: "ok" | "degraded" | "blocked";
19
+ checkedAt: string;
20
+ connectors: ConnectorStatus[];
21
+ blockers: StatusBlocker[];
22
+ }
23
+ export interface BuildServerStatusOptions {
24
+ store: TomorrowOSStore;
25
+ staticRoot?: string | null;
26
+ env?: NodeJS.ProcessEnv;
27
+ probeTimeoutMs?: number;
28
+ }
29
+ export declare function buildServerStatus(options: BuildServerStatusOptions): Promise<ServerStatusReport>;
30
+ //# sourceMappingURL=server-status.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"server-status.d.ts","sourceRoot":"","sources":["../src/server-status.ts"],"names":[],"mappings":"AAOA,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,kBAAkB,CAAC;AAExD,MAAM,MAAM,cAAc,GAAG,IAAI,GAAG,MAAM,GAAG,OAAO,GAAG,SAAS,CAAC;AAEjE,MAAM,WAAW,eAAe;IAC9B,EAAE,EAAE,MAAM,CAAC;IACX,KAAK,EAAE,MAAM,CAAC;IACd,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,KAAK,EAAE,cAAc,CAAC;IACtB,MAAM,EAAE,MAAM,CAAC;CAChB;AAED,MAAM,WAAW,aAAa;IAC5B,WAAW,EAAE,MAAM,CAAC;IACpB,KAAK,EAAE,MAAM,CAAC;IACd,OAAO,EAAE,MAAM,CAAC;IAChB,OAAO,EAAE,MAAM,CAAC;CACjB;AAED,MAAM,WAAW,kBAAkB;IACjC,MAAM,EAAE,SAAS,CAAC;IAClB,OAAO,EAAE,IAAI,GAAG,UAAU,GAAG,SAAS,CAAC;IACvC,SAAS,EAAE,MAAM,CAAC;IAClB,UAAU,EAAE,eAAe,EAAE,CAAC;IAC9B,QAAQ,EAAE,aAAa,EAAE,CAAC;CAC3B;AAED,MAAM,WAAW,wBAAwB;IACvC,KAAK,EAAE,eAAe,CAAC;IACvB,UAAU,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IAC3B,GAAG,CAAC,EAAE,MAAM,CAAC,UAAU,CAAC;IACxB,cAAc,CAAC,EAAE,MAAM,CAAC;CACzB;AAyQD,wBAAsB,iBAAiB,CACrC,OAAO,EAAE,wBAAwB,GAChC,OAAO,CAAC,kBAAkB,CAAC,CA4B7B"}
@@ -0,0 +1,244 @@
1
+ import fs from "fs/promises";
2
+ import path from "path";
3
+ import { pingCloudinary, resolveCloudinaryConfig } from "./cloudinary-storage.js";
4
+ function isReplitHost(env) {
5
+ return !!(env.REPL_ID ||
6
+ env.REPLIT_DEV_DOMAIN ||
7
+ env.REPLIT_DEPLOYMENT ||
8
+ env.REPL_SLUG);
9
+ }
10
+ function resolveDatabaseProvider(env) {
11
+ const driver = String(env.TOMORROWOS_STORE || "").trim().toLowerCase();
12
+ const hasUrl = !!(env.SUPABASE_URL || env.DATABASE_URL);
13
+ if (driver === "supabase" || (!driver && env.SUPABASE_URL)) {
14
+ return {
15
+ provider: "supabase",
16
+ label: "Supabase",
17
+ configured: !!env.SUPABASE_URL || !!env.DATABASE_URL
18
+ };
19
+ }
20
+ if (driver === "postgres" || (!driver && hasUrl && !env.SUPABASE_URL)) {
21
+ return {
22
+ provider: "postgres",
23
+ label: "Postgres",
24
+ configured: hasUrl
25
+ };
26
+ }
27
+ if (driver === "memory") {
28
+ return { provider: "memory", label: "Memory Store", configured: true };
29
+ }
30
+ if (driver === "sqlite" || !driver) {
31
+ return { provider: "sqlite", label: "SQLite", configured: true };
32
+ }
33
+ return {
34
+ provider: driver || "unknown",
35
+ label: driver ? `Store (${driver})` : "Database",
36
+ configured: hasUrl || driver === "sqlite" || driver === "memory"
37
+ };
38
+ }
39
+ async function withTimeout(promise, timeoutMs, label) {
40
+ let timer;
41
+ try {
42
+ return await Promise.race([
43
+ promise,
44
+ new Promise((_, reject) => {
45
+ timer = setTimeout(() => reject(new Error(`${label} probe timed out after ${timeoutMs}ms`)), timeoutMs);
46
+ })
47
+ ]);
48
+ }
49
+ finally {
50
+ if (timer)
51
+ clearTimeout(timer);
52
+ }
53
+ }
54
+ async function probeDatabase(store, env, timeoutMs) {
55
+ const meta = resolveDatabaseProvider(env);
56
+ const wantsRemote = meta.provider === "supabase" || meta.provider === "postgres";
57
+ if (wantsRemote && !meta.configured) {
58
+ return {
59
+ id: "database",
60
+ label: meta.label,
61
+ provider: meta.provider,
62
+ state: "missing",
63
+ detail: "Connection string not set. Add SUPABASE_URL (preferred) or DATABASE_URL."
64
+ };
65
+ }
66
+ try {
67
+ await withTimeout(store.listPairedDevices(), timeoutMs, meta.label);
68
+ if (meta.provider === "memory") {
69
+ return {
70
+ id: "database",
71
+ label: meta.label,
72
+ provider: meta.provider,
73
+ state: "warn",
74
+ detail: "In-memory only — data is lost on restart."
75
+ };
76
+ }
77
+ if (meta.provider === "sqlite" && isReplitHost(env)) {
78
+ return {
79
+ id: "database",
80
+ label: meta.label,
81
+ provider: meta.provider,
82
+ state: "warn",
83
+ detail: "SQLite works for demos; pairings may reset on rebuild. Prefer Supabase for production."
84
+ };
85
+ }
86
+ return {
87
+ id: "database",
88
+ label: meta.label,
89
+ provider: meta.provider,
90
+ state: "ok",
91
+ detail: `${meta.label} reachable`
92
+ };
93
+ }
94
+ catch (err) {
95
+ const message = err instanceof Error ? err.message : String(err);
96
+ return {
97
+ id: "database",
98
+ label: meta.label,
99
+ provider: meta.provider,
100
+ state: "error",
101
+ detail: message
102
+ };
103
+ }
104
+ }
105
+ async function probeMedia(staticRoot, env, timeoutMs) {
106
+ const cloudinary = resolveCloudinaryConfig(env);
107
+ if (cloudinary) {
108
+ try {
109
+ await withTimeout(pingCloudinary(cloudinary), timeoutMs, "Cloudinary");
110
+ return {
111
+ id: "media",
112
+ label: "Media Server",
113
+ provider: "cloudinary",
114
+ state: "ok",
115
+ detail: `Cloudinary (${cloudinary.cloudName}) reachable`
116
+ };
117
+ }
118
+ catch (err) {
119
+ const message = err instanceof Error ? err.message : String(err);
120
+ return {
121
+ id: "media",
122
+ label: "Media Server",
123
+ provider: "cloudinary",
124
+ state: "error",
125
+ detail: message
126
+ };
127
+ }
128
+ }
129
+ const hasPartialCloudinary = !!(env.CLOUDINARY_CLOUD_NAME ||
130
+ env.CLOUDINARY_API_KEY ||
131
+ env.CLOUDINARY_API_SECRET);
132
+ if (hasPartialCloudinary) {
133
+ return {
134
+ id: "media",
135
+ label: "Media Server",
136
+ provider: "cloudinary",
137
+ state: "error",
138
+ detail: "Cloudinary config incomplete — set CLOUDINARY_CLOUD_NAME, CLOUDINARY_API_KEY, and CLOUDINARY_API_SECRET."
139
+ };
140
+ }
141
+ const provider = isReplitHost(env) ? "replit-object-storage" : "local";
142
+ const providerLabel = provider === "replit-object-storage"
143
+ ? "Replit Object Storage / local uploads"
144
+ : "Local uploads";
145
+ if (!staticRoot) {
146
+ return {
147
+ id: "media",
148
+ label: "Media Server",
149
+ provider,
150
+ state: "missing",
151
+ detail: "No Cloudinary and staticRoot is unset — media uploads are unavailable."
152
+ };
153
+ }
154
+ const uploadsDir = path.join(path.resolve(staticRoot), "uploads");
155
+ try {
156
+ await fs.mkdir(uploadsDir, { recursive: true });
157
+ await fs.access(uploadsDir);
158
+ return {
159
+ id: "media",
160
+ label: "Media Server",
161
+ provider,
162
+ state: "warn",
163
+ detail: `${providerLabel} writable. Uploads may not survive rebuilds — Cloudinary recommended.`
164
+ };
165
+ }
166
+ catch (err) {
167
+ const message = err instanceof Error ? err.message : String(err);
168
+ return {
169
+ id: "media",
170
+ label: "Media Server",
171
+ provider,
172
+ state: "error",
173
+ detail: `Uploads directory not writable (${uploadsDir}): ${message}`
174
+ };
175
+ }
176
+ }
177
+ function buildBlockers(connectors) {
178
+ const blockers = [];
179
+ for (const c of connectors) {
180
+ if (c.id === "database" && (c.state === "missing" || c.state === "error")) {
181
+ blockers.push({
182
+ connectorId: c.id,
183
+ title: `${c.label} not connected`,
184
+ message: c.detail,
185
+ fixHint: c.state === "missing"
186
+ ? "Paste your Supabase Postgres connection string as Secret SUPABASE_URL, set TOMORROWOS_STORE=supabase, then restart the CMS."
187
+ : "Check SUPABASE_URL / network / SSL (DATABASE_SSL=true). After Publish, confirm the live URL can reach Supabase."
188
+ });
189
+ }
190
+ if (c.id === "media" && c.state === "error") {
191
+ blockers.push({
192
+ connectorId: c.id,
193
+ title: "Media storage not ready",
194
+ message: c.detail,
195
+ fixHint: c.provider === "cloudinary"
196
+ ? "Fix Cloudinary Secrets (CLOUDINARY_CLOUD_NAME, CLOUDINARY_API_KEY, CLOUDINARY_API_SECRET) and restart."
197
+ : "Enable Object Storage for public/uploads, or configure Cloudinary for durable media URLs."
198
+ });
199
+ }
200
+ if (c.id === "media" && c.state === "missing") {
201
+ blockers.push({
202
+ connectorId: c.id,
203
+ title: "Media storage not configured",
204
+ message: c.detail,
205
+ fixHint: "Set Cloudinary Secrets, or ensure the CMS listens with staticRoot pointing at public/ (uploads folder)."
206
+ });
207
+ }
208
+ }
209
+ return blockers;
210
+ }
211
+ function overallFrom(connectors) {
212
+ const hardFail = connectors.some((c) => (c.id === "database" || c.id === "media") &&
213
+ (c.state === "error" || c.state === "missing"));
214
+ if (hardFail)
215
+ return "blocked";
216
+ if (connectors.some((c) => c.state === "warn"))
217
+ return "degraded";
218
+ return "ok";
219
+ }
220
+ export async function buildServerStatus(options) {
221
+ const env = options.env ?? process.env;
222
+ const timeoutMs = options.probeTimeoutMs ?? 5000;
223
+ const server = {
224
+ id: "server",
225
+ label: "Server",
226
+ provider: "tomorrowos",
227
+ state: "ok",
228
+ detail: "CMS process is running"
229
+ };
230
+ const [database, media] = await Promise.all([
231
+ probeDatabase(options.store, env, timeoutMs),
232
+ probeMedia(options.staticRoot, env, timeoutMs)
233
+ ]);
234
+ const connectors = [server, database, media];
235
+ const blockers = buildBlockers(connectors);
236
+ const overall = overallFrom(connectors);
237
+ return {
238
+ status: "success",
239
+ overall,
240
+ checkedAt: new Date().toISOString(),
241
+ connectors,
242
+ blockers
243
+ };
244
+ }
@@ -2,6 +2,7 @@ import { EventEmitter } from "events";
2
2
  import http from "http";
3
3
  import { PlaylistCatalog, type BuiltDevicePolicy } from "./playlist-catalog.js";
4
4
  import type { TomorrowOSStore } from "./store/types.js";
5
+ export type { ConnectorState, ConnectorStatus, ServerStatusReport, StatusBlocker } from "./server-status.js";
5
6
  export interface TomorrowOSBrand {
6
7
  name?: string;
7
8
  cmsEndpoint?: string;
@@ -1 +1 @@
1
- {"version":3,"file":"tomorrowos.d.ts","sourceRoot":"","sources":["../src/tomorrowos.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"tomorrowos.d.ts","sourceRoot":"","sources":["../src/tomorrowos.ts"],"names":[],"mappings":"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"}
@@ -11,6 +11,7 @@ import { resolveBrandLogoPath, syncProjectAssetsToStaticRoot } from "./brand-ass
11
11
  import { deleteCloudinaryAsset, resolveCloudinaryConfig, uploadBufferToCloudinary } from "./cloudinary-storage.js";
12
12
  import { probeVideoDurationMs } from "./media-probe.js";
13
13
  import { contentHashHex, storeUploadIfNeeded } from "./upload-storage.js";
14
+ import { buildServerStatus } from "./server-status.js";
14
15
  function normalizeDevicePlatform(platform, system) {
15
16
  const explicit = typeof platform === "string" ? platform.trim().toLowerCase() : "";
16
17
  if (explicit)
@@ -1024,6 +1025,14 @@ export class TomorrowOS extends EventEmitter {
1024
1025
  res.end(JSON.stringify(this.brand));
1025
1026
  return;
1026
1027
  }
1028
+ if (req.method === "GET" && pathname === "/status") {
1029
+ const report = await buildServerStatus({
1030
+ store: this.store,
1031
+ staticRoot: this.staticRoot
1032
+ });
1033
+ sendJson(res, 200, report);
1034
+ return;
1035
+ }
1027
1036
  if (req.method === "POST" && pathname === "/media/upload") {
1028
1037
  await this.handleMediaUpload(req, res, url);
1029
1038
  return;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tomorrowos/sdk",
3
- "version": "0.9.4",
3
+ "version": "0.9.6",
4
4
  "description": "TomorrowOS CMS server SDK - WebSocket transport, pairing, device commands, optional static CMS UI. Includes CLI (tomorrowos init / build) and starter templates.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "my-cms",
3
- "version": "0.9.4",
3
+ "version": "0.9.6",
4
4
  "description": "CMS server on @tomorrowos/sdk. Add your UI (React, static files, etc.) alongside this server.",
5
5
  "private": true,
6
6
  "type": "module",
@@ -13,7 +13,7 @@
13
13
  "build-player": "tomorrowos build --platform tizen"
14
14
  },
15
15
  "dependencies": {
16
- "@tomorrowos/sdk": "^0.9.4",
16
+ "@tomorrowos/sdk": "^0.9.6",
17
17
  "dotenv": "^17.2.3",
18
18
  "tsx": "^4.19.0"
19
19
  },
@@ -27,6 +27,16 @@
27
27
  </aside>
28
28
 
29
29
  <main class="panel-main">
30
+ <section class="card server-status-card" id="serverStatusSection" aria-live="polite">
31
+ <div class="server-status-header">
32
+ <h2>Server status</h2>
33
+ <button type="button" id="serverStatusRefreshBtn" title="Refresh status">Refresh</button>
34
+ </div>
35
+ <p class="hint server-status-overall" id="serverStatusOverall">Checking connectors…</p>
36
+ <ul class="server-status-list" id="serverStatusList"></ul>
37
+ <div id="serverStatusBlockers" class="server-status-blockers hidden"></div>
38
+ </section>
39
+
30
40
  <section class="card">
31
41
  <h2>Pair device</h2>
32
42
  <p class="hint">Enter the 6-character code on the screen (A–Z or 0–9).</p>
@@ -27,6 +27,7 @@ let editingDeviceNameId = null;
27
27
  let editingDeviceNameValue = "";
28
28
 
29
29
  let devicePollTimer = null;
30
+ let serverStatusTimer = null;
30
31
  /** @type {number|null} CMS server boot time (ms) from GET /devices. */
31
32
  let serverStartedAtMs = null;
32
33
  /** @type {ReturnType<typeof setTimeout>|null} */
@@ -1857,12 +1858,169 @@ async function downloadMediaAsset(item) {
1857
1858
  openDownloadFailedModal(lastError);
1858
1859
  }
1859
1860
 
1861
+ function connectorStateLabel(state) {
1862
+ if (state === "ok") return "OK";
1863
+ if (state === "warn") return "Warning";
1864
+ if (state === "missing") return "Not set";
1865
+ return "Error";
1866
+ }
1867
+
1868
+ function overallStatusCopy(overall) {
1869
+ if (overall === "ok") return "All connectors look healthy.";
1870
+ if (overall === "degraded") {
1871
+ return "CMS is running with warnings — review media or database durability below.";
1872
+ }
1873
+ if (overall === "blocked") {
1874
+ return "Setup incomplete — fix the blockers below so pairing and media work reliably.";
1875
+ }
1876
+ return "Checking connectors…";
1877
+ }
1878
+
1879
+ function renderServerStatus(report) {
1880
+ const section = document.getElementById("serverStatusSection");
1881
+ const list = document.getElementById("serverStatusList");
1882
+ const overallEl = document.getElementById("serverStatusOverall");
1883
+ const blockersEl = document.getElementById("serverStatusBlockers");
1884
+ if (!section || !list || !overallEl || !blockersEl) return;
1885
+
1886
+ const overall = report?.overall || "blocked";
1887
+ section.classList.remove(
1888
+ "server-status-card--ok",
1889
+ "server-status-card--degraded",
1890
+ "server-status-card--blocked"
1891
+ );
1892
+ section.classList.add(`server-status-card--${overall}`);
1893
+ overallEl.textContent = overallStatusCopy(overall);
1894
+
1895
+ list.innerHTML = "";
1896
+ const connectors = Array.isArray(report?.connectors) ? report.connectors : [];
1897
+ for (const connector of connectors) {
1898
+ const li = document.createElement("li");
1899
+ li.className = "server-status-row";
1900
+
1901
+ const main = document.createElement("div");
1902
+ main.className = "server-status-row-main";
1903
+
1904
+ const label = document.createElement("span");
1905
+ label.className = "server-status-label";
1906
+ label.textContent = connector.label || connector.id || "Connector";
1907
+ main.appendChild(label);
1908
+
1909
+ if (connector.provider) {
1910
+ const provider = document.createElement("span");
1911
+ provider.className = "server-status-provider";
1912
+ provider.textContent = connector.provider;
1913
+ main.appendChild(provider);
1914
+ }
1915
+
1916
+ const badge = document.createElement("span");
1917
+ const state = connector.state || "error";
1918
+ badge.className = `server-status-badge server-status-badge--${state}`;
1919
+ badge.textContent = connectorStateLabel(state);
1920
+
1921
+ const detail = document.createElement("p");
1922
+ detail.className = "server-status-detail";
1923
+ detail.textContent = connector.detail || "";
1924
+
1925
+ li.appendChild(main);
1926
+ li.appendChild(badge);
1927
+ li.appendChild(detail);
1928
+ list.appendChild(li);
1929
+ }
1930
+
1931
+ const blockers = Array.isArray(report?.blockers) ? report.blockers : [];
1932
+ blockersEl.innerHTML = "";
1933
+ if (blockers.length === 0) {
1934
+ blockersEl.classList.add("hidden");
1935
+ return;
1936
+ }
1937
+
1938
+ blockersEl.classList.remove("hidden");
1939
+ for (const blocker of blockers) {
1940
+ const card = document.createElement("div");
1941
+ card.className = "server-status-blocker";
1942
+
1943
+ const title = document.createElement("h3");
1944
+ title.textContent = blocker.title || "Connector issue";
1945
+
1946
+ const message = document.createElement("p");
1947
+ message.textContent = blocker.message || "";
1948
+
1949
+ const fix = document.createElement("p");
1950
+ fix.className = "server-status-blocker-fix";
1951
+ fix.textContent = blocker.fixHint
1952
+ ? `How to fix: ${blocker.fixHint}`
1953
+ : "How to fix: update Secrets / env and restart the CMS.";
1954
+
1955
+ card.appendChild(title);
1956
+ card.appendChild(message);
1957
+ card.appendChild(fix);
1958
+ blockersEl.appendChild(card);
1959
+ }
1960
+ }
1961
+
1962
+ async function fetchServerStatus() {
1963
+ try {
1964
+ const res = await fetch("/status", { cache: "no-store" });
1965
+ const data = await res.json();
1966
+ if (!res.ok) {
1967
+ renderServerStatus({
1968
+ overall: "blocked",
1969
+ connectors: [
1970
+ {
1971
+ id: "server",
1972
+ label: "Server",
1973
+ state: "error",
1974
+ detail: data?.error || `HTTP ${res.status}`
1975
+ }
1976
+ ],
1977
+ blockers: [
1978
+ {
1979
+ connectorId: "server",
1980
+ title: "Could not load server status",
1981
+ message: data?.error || `HTTP ${res.status}`,
1982
+ fixHint: "Confirm the CMS is running with @tomorrowos/sdk that includes GET /status."
1983
+ }
1984
+ ]
1985
+ });
1986
+ return;
1987
+ }
1988
+ renderServerStatus(data);
1989
+ } catch (err) {
1990
+ renderServerStatus({
1991
+ overall: "blocked",
1992
+ connectors: [
1993
+ {
1994
+ id: "server",
1995
+ label: "Server",
1996
+ state: "error",
1997
+ detail: err?.message || "Network error"
1998
+ }
1999
+ ],
2000
+ blockers: [
2001
+ {
2002
+ connectorId: "server",
2003
+ title: "CMS unreachable",
2004
+ message: err?.message || "Network error",
2005
+ fixHint: "Start the CMS (`npm run start`) and reload this page."
2006
+ }
2007
+ ]
2008
+ });
2009
+ }
2010
+ }
2011
+
1860
2012
  function startDevicePolling() {
1861
2013
  if (devicePollTimer) clearInterval(devicePollTimer);
1862
2014
  void fetchDevices();
1863
2015
  devicePollTimer = setInterval(() => void fetchDevices(), 8000);
1864
2016
  }
1865
2017
 
2018
+ function startServerStatusPolling() {
2019
+ if (serverStatusTimer) clearInterval(serverStatusTimer);
2020
+ void fetchServerStatus();
2021
+ serverStatusTimer = setInterval(() => void fetchServerStatus(), 30000);
2022
+ }
2023
+
1866
2024
  document.addEventListener("DOMContentLoaded", () => {
1867
2025
  const cmsUrlSection = document.getElementById("cmsUrlSection");
1868
2026
  if (cmsUrlSection && !isLocalPanelHost(window.location.hostname)) {
@@ -1876,6 +2034,11 @@ document.addEventListener("DOMContentLoaded", () => {
1876
2034
  updatePlaylistEditorVisibility();
1877
2035
  void fetchPlaylists();
1878
2036
  startDevicePolling();
2037
+ startServerStatusPolling();
2038
+
2039
+ document
2040
+ .getElementById("serverStatusRefreshBtn")
2041
+ ?.addEventListener("click", () => void fetchServerStatus());
1879
2042
 
1880
2043
  document.getElementById("newPlaylistBtn")?.addEventListener("click", newPlaylistDraft);
1881
2044
  document.getElementById("savePlaylistBtn")?.addEventListener("click", () => void saveCurrentPlaylist());
@@ -118,6 +118,153 @@ body {
118
118
  color: #666;
119
119
  }
120
120
 
121
+ .server-status-card {
122
+ border-color: #e4e1dc;
123
+ }
124
+
125
+ .server-status-header {
126
+ display: flex;
127
+ align-items: center;
128
+ justify-content: space-between;
129
+ gap: 0.75rem;
130
+ margin-bottom: 0.35rem;
131
+ }
132
+
133
+ .server-status-header h2 {
134
+ margin: 0;
135
+ }
136
+
137
+ .server-status-header button {
138
+ font-size: 0.78rem;
139
+ padding: 0.35rem 0.65rem;
140
+ }
141
+
142
+ .server-status-overall {
143
+ margin-bottom: 0.65rem;
144
+ }
145
+
146
+ .server-status-list {
147
+ list-style: none;
148
+ margin: 0;
149
+ padding: 0;
150
+ display: grid;
151
+ gap: 0.45rem;
152
+ }
153
+
154
+ .server-status-row {
155
+ display: grid;
156
+ grid-template-columns: minmax(0, 1fr) auto;
157
+ gap: 0.35rem 0.75rem;
158
+ align-items: baseline;
159
+ padding: 0.55rem 0.65rem;
160
+ border: 1px solid #ece8e2;
161
+ border-radius: 8px;
162
+ background: #fafaf9;
163
+ }
164
+
165
+ .server-status-row-main {
166
+ display: flex;
167
+ flex-wrap: wrap;
168
+ align-items: baseline;
169
+ gap: 0.35rem 0.55rem;
170
+ min-width: 0;
171
+ }
172
+
173
+ .server-status-label {
174
+ font-size: 0.85rem;
175
+ font-weight: 600;
176
+ color: #0a0908;
177
+ }
178
+
179
+ .server-status-provider {
180
+ font-size: 0.72rem;
181
+ color: #888;
182
+ }
183
+
184
+ .server-status-detail {
185
+ grid-column: 1 / -1;
186
+ margin: 0;
187
+ font-size: 0.75rem;
188
+ color: #555;
189
+ line-height: 1.4;
190
+ }
191
+
192
+ .server-status-badge {
193
+ font-size: 0.72rem;
194
+ font-weight: 600;
195
+ letter-spacing: 0.02em;
196
+ text-transform: uppercase;
197
+ padding: 0.18rem 0.45rem;
198
+ border-radius: 999px;
199
+ border: 1px solid transparent;
200
+ white-space: nowrap;
201
+ }
202
+
203
+ .server-status-badge--ok {
204
+ color: #067647;
205
+ background: #ecfdf3;
206
+ border-color: #abefc6;
207
+ }
208
+
209
+ .server-status-badge--warn {
210
+ color: #b54708;
211
+ background: #fffaeb;
212
+ border-color: #fedf89;
213
+ }
214
+
215
+ .server-status-badge--error,
216
+ .server-status-badge--missing {
217
+ color: #b42318;
218
+ background: #fff5f5;
219
+ border-color: #fecdca;
220
+ }
221
+
222
+ .server-status-blockers {
223
+ margin-top: 0.75rem;
224
+ display: grid;
225
+ gap: 0.55rem;
226
+ }
227
+
228
+ .server-status-blocker {
229
+ padding: 0.7rem 0.8rem;
230
+ border-radius: 8px;
231
+ border: 1px solid #fecdca;
232
+ background: #fff8f7;
233
+ }
234
+
235
+ .server-status-blocker h3 {
236
+ margin: 0 0 0.3rem;
237
+ font-size: 0.85rem;
238
+ color: #b42318;
239
+ }
240
+
241
+ .server-status-blocker p {
242
+ margin: 0;
243
+ font-size: 0.78rem;
244
+ color: #444;
245
+ line-height: 1.45;
246
+ }
247
+
248
+ .server-status-blocker p + p {
249
+ margin-top: 0.35rem;
250
+ }
251
+
252
+ .server-status-blocker-fix {
253
+ color: #0a0908;
254
+ }
255
+
256
+ .server-status-card--ok {
257
+ border-color: #abefc6;
258
+ }
259
+
260
+ .server-status-card--degraded {
261
+ border-color: #fedf89;
262
+ }
263
+
264
+ .server-status-card--blocked {
265
+ border-color: #fecdca;
266
+ }
267
+
121
268
  .devices-grid {
122
269
  display: grid;
123
270
  grid-template-columns: repeat(auto-fill, minmax(308px, 1fr));