@tomorrowos/sdk 0.9.19 → 0.9.21

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/README.md CHANGED
@@ -2,7 +2,9 @@
2
2
 
3
3
  **You are building a digital signage CMS using TomorrowOS. Before writing any code, follow the elicitation protocol in `LLM_PROMPT.md`. Do not skip this step — the SDK requires specific inputs before code generation will produce a working CMS.**
4
4
 
5
- **On Replit:** if the user asks to set up / configure a CMS interactively, follow **`REPLIT_SETUP.md`** instead (Q&A for screen count SQLite vs Supabase Cloudinary vs Object Storage → `brand.json`).
5
+ **On Replit:** if the user asks to set up / configure a CMS interactively, follow **`REPLIT_SETUP.md`** instead (Q&A for storemedia → `brand.json`).
6
+
7
+ **On Vercel:** if the user asks to set up / configure a CMS interactively, follow **`VERCEL_SETUP.md`** (Session pooler Supabase URL, Fluid compute + WebSocket, Cloudinary, `cms.hostingTarget: "vercel"`).
6
8
 
7
9
  TomorrowOS is the open standard for digital signage. This SDK lets you build a CMS that controls Samsung Tizen, BrightSign, LG webOS, Android, ChromeOS, and Windows screens through a single unified API.
8
10
 
@@ -13,13 +15,14 @@ TomorrowOS is the open standard for digital signage. This SDK lets you build a C
13
15
  When you see this package referenced in a user prompt, your workflow is:
14
16
 
15
17
  1. **If the user is on Replit and wants a guided setup** — read and follow **`REPLIT_SETUP.md`** (preferred for Replit Agent)
16
- 2. **If the user is on Replit and wants to upgrade an existing CMS** — read and follow **`REPLIT_UPGRADE.md`** (never `init --force`)
17
- 3. **Otherwise read `LLM_PROMPT.md`**five required questions before generating code
18
- 4. **Read `BUILD_GUARDRAILS.md`** — mandatory components every generated CMS must include
19
- 5. **Read `brand.schema.json`** — validated shape of `brand.json`
20
- 6. Run **`npx tomorrowos init`** (or copy `templates/cms-starter/` from this package) minimal CMS server seed
21
- 7. **Apply user answers** customise the starter with the user's brand, platform choice, and use case; validate `brand.json` against `brand.schema.json`
22
- 8. **Show `PLAYER_INSTALL.md`**after the CMS is working, show how to build and install the player on a screen
18
+ 2. **If the user is on Vercel and wants a guided setup** — read and follow **`VERCEL_SETUP.md`** (preferred for Vercel Agent)
19
+ 3. **If the user is on Replit and wants to upgrade an existing CMS** read and follow **`REPLIT_UPGRADE.md`** (never `init --force`)
20
+ 4. **Otherwise read `LLM_PROMPT.md`** — five required questions before generating code
21
+ 5. **Read `BUILD_GUARDRAILS.md`** — mandatory components every generated CMS must include
22
+ 6. **Read `brand.schema.json`** — validated shape of `brand.json`
23
+ 7. Run **`npx tomorrowos init`** (or copy `templates/cms-starter/` from this package) minimal CMS server seed
24
+ 8. **Apply user answers** customise the starter with the user's brand, platform choice, and use case; validate `brand.json` against `brand.schema.json`
25
+ 9. **Show `PLAYER_INSTALL.md`** — after the CMS is working, show how to build and install the player on a screen
23
26
 
24
27
  You must not write WebSocket transport, pairing wire format, or platform-specific player bridge code in the CMS — the SDK handles server-side device sessions and HTTP helpers. You customise UI, branding, and how you call the SDK.
25
28
 
@@ -59,6 +62,18 @@ That wizard configures Supabase, prefers Cloudinary for media, and writes
59
62
  `brand.json` from your answers. On Replit, Supabase must use Secret
60
63
  **`SUPABASE_URL`** (not the reserved `DATABASE_URL`).
61
64
 
65
+ ### Vercel guided setup
66
+
67
+ In Vercel Agent (or any AI setup prompt), paste:
68
+
69
+ ```text
70
+ Follow @tomorrowos/sdk VERCEL_SETUP.md and set up my TomorrowOS CMS.
71
+ IF any step contradicts Vercel, adapt to settings that Vercel can accept.
72
+ ```
73
+
74
+ That wizard requires the Supabase **Session pooler** URL, prefers Cloudinary,
75
+ enables Fluid compute for WebSockets, and sets `cms.hostingTarget` to `"vercel"`.
76
+
62
77
  ### Replit upgrade (existing CMS)
63
78
 
64
79
  In Replit Agent, paste:
@@ -153,6 +168,7 @@ See `PLAYER_INSTALL.md` for installation notes.
153
168
  |------|---------|
154
169
  | `LLM_PROMPT.md` | Five-question elicitation for LLMs |
155
170
  | `REPLIT_SETUP.md` | Replit Agent guided CMS setup |
171
+ | `VERCEL_SETUP.md` | Vercel Agent guided CMS setup |
156
172
  | `REPLIT_UPGRADE.md` | Replit Agent upgrade to latest SDK (no init) |
157
173
  | `BUILD_GUARDRAILS.md` | Mandatory CMS components |
158
174
  | `PLAYER_INSTALL.md` | Player build / install / pairing |
@@ -0,0 +1,427 @@
1
+ # Vercel Setup Protocol for TomorrowOS CMS
2
+
3
+ > **This file is a contract for Vercel Agent / AI coding assistants setting up TomorrowOS on Vercel.**
4
+ > Follow it literally. Ask questions in order. Do not invent secrets. Prefer configuring **Vercel Environment Variables**, `.env` (non-secrets only), `brand.json`, and `server.ts` store wiring over inventing a custom CMS.
5
+ >
6
+ > **On Vercel, this file is the setup questionnaire** (not `REPLIT_SETUP.md`, not `LLM_PROMPT.md`).
7
+ >
8
+ > Protocol id: `vercel-setup/1.0` — pairs with `@tomorrowos/sdk` packages that ship `templates/cms-starter` and this file.
9
+
10
+ ## User trigger (copy-paste)
11
+
12
+ When a human pastes a prompt like this, **read this file and start Question 1 immediately**:
13
+
14
+ ```text
15
+ Follow @tomorrowos/sdk VERCEL_SETUP.md and set up my TomorrowOS CMS.
16
+ Ask only the questions listed in VERCEL_SETUP.md, in order. Do not use LLM_PROMPT.md or REPLIT_SETUP.md.
17
+ If any step contradicts Vercel, adapt to settings Vercel can accept (document the adaptation).
18
+ After all setup questions are answered (including Cloudinary Env Vars if you chose Cloudinary), configure and deploy — minimal verification only (Control Panel at /). Prefer Supabase Session pooler URL. Do not treat sandbox ENETUNREACH / IPv6 :5432 errors as a hard setup failure when the pooler URL is configured for production.
19
+ Question 3 only updates brand.json. Do not skip steps.
20
+ ```
21
+
22
+ Alternate triggers:
23
+
24
+ - “Set up TomorrowOS on Vercel using VERCEL_SETUP.md”
25
+ - “Configure my TomorrowOS CMS (Vercel wizard)”
26
+
27
+ When any of these fire, **ignore `REPLIT_SETUP.md` and `LLM_PROMPT.md`** for the Q&A phase (you may skim Replit docs only for shared SDK semantics).
28
+
29
+ ---
30
+
31
+ ## IRON RULE: long-lived Node + WebSocket (not classic static / short Functions)
32
+
33
+ > **This is the #1 deployability rule on Vercel. Non-negotiable.**
34
+
35
+ TomorrowOS CMS is **one Node process** that serves:
36
+
37
+ - Browser Control Panel at `GET /` (`public/` via `staticRoot`)
38
+ - Device **WebSocket** on the same origin (`https` → players use `wss`)
39
+
40
+ | Required | Forbidden |
41
+ |----------|-----------|
42
+ | Single **Node HTTP server** entry (`tsx server.ts` / exported `http.Server` from `listen()`) | Next.js / Remix / static-only site as the CMS |
43
+ | **Fluid compute** enabled (needed for WebSockets on Vercel) | Classic short-lived serverless with no WebSocket / no Fluid |
44
+ | Durable store = **Supabase Postgres** (pooler URL) | **SQLite / `data/*.db` as production store** on Vercel (ephemeral filesystem) |
45
+ | Media = **Cloudinary** (recommended) or other durable HTTPS URLs | Relying on local `public/uploads` alone for production fleets |
46
+ | Env vars in **Vercel Project → Settings → Environment Variables** | Committing passwords / Cloudinary secrets / `.env` with secrets |
47
+ | `cms.hostingTarget`: `"vercel"` in `brand.json` | Creating `.replit` / Replit artifact files |
48
+
49
+ **Do not** scaffold a leftover Next.js app “and put TomorrowOS beside it.” Remove Next scaffolds so TomorrowOS `server.ts` is the sole app.
50
+
51
+ ---
52
+
53
+ ## Questionnaire scope (STRICT)
54
+
55
+ Ask **only** these questions, in **this exact order**:
56
+
57
+ | Step | Section | When |
58
+ |------|---------|------|
59
+ | 1 | **Question 1** — Supabase **Session pooler** connection string | Always |
60
+ | 2 | **Question 2** — Media storage (+ Cloudinary Env Vars if chosen) | Always (after Q1) |
61
+ | 3 | **Question 3** — Brand / TomorrowOS look (`brand.json` only) | Always last |
62
+
63
+ **That is the complete list.** Do **not** ask how many screens / devices. There is **no** “Question 2b” — Cloudinary credentials stay **inside Question 2**.
64
+
65
+ If the user says “just set it up, don’t ask questions,” respond:
66
+
67
+ > I need a few quick answers from VERCEL_SETUP.md (Supabase Session pooler URL, media storage — plus Cloudinary credentials if you choose Cloudinary — and branding). It takes about a minute and ensures the CMS works on Vercel.
68
+
69
+ Then ask **Question 1**.
70
+
71
+ ---
72
+
73
+ ## Hard rules
74
+
75
+ 1. **Ask only VERCEL_SETUP.md questions 1–3.** Never use `LLM_PROMPT.md` or `REPLIT_SETUP.md` as the questionnaire.
76
+ 2. **One question at a time** (exception: after Cloudinary choice, immediately collect the three Cloudinary env vars in the same Question 2).
77
+ 3. **Do not invent** Cloudinary credentials, Supabase URLs, or brand colours.
78
+ 4. **Prefer `SUPABASE_URL`** for Supabase (same as Replit). `DATABASE_URL` may be used as fallback on Vercel, but document one canonical name — prefer **`SUPABASE_URL`**.
79
+ 5. **Always prefer the Supabase Session pooler URL** (port **6543**, host like `*.pooler.supabase.com`). Direct `db.*.supabase.co:5432` URLs commonly fail on Vercel/sandbox with **`ENETUNREACH`** (often IPv6).
80
+ 6. **Prefer `npx @tomorrowos/sdk init`** (or `templates/cms-starter`) as the seed. Do not rebuild pairing / WebSocket / playlist APIs.
81
+ 7. **Never commit secrets.** Use Vercel Environment Variables (+ optional non-secret flags in committed `.env.example`).
82
+ 8. **Skip Replit-only files:** do **not** create `.replit`, `.replit-artifact`, or Replit `deploymentTarget`.
83
+ 9. **SQLite is not a production store on Vercel.** Configure Supabase for pairing/playlists. Keep `sqlitePath` only as a local-dev fallback in `createTomorrowOSStore`.
84
+ 10. **Enable Fluid compute** for WebSockets. Confirm Project settings after scaffold.
85
+ 11. **Export the HTTP server** Vercel can capture: `const server = tomorrowos.listen(...); export default server;` (or equivalent). Do not export only the `TomorrowOS` class instance unless the platform documents another capture path.
86
+ 12. After Q&A: **configure → install → run/deploy → minimal verify** (Control Panel at `/`). Do not run a long test suite.
87
+
88
+ **Question order:** Q1 → Q2 → Q3 → execution checklist.
89
+
90
+ ---
91
+
92
+ ## Runtime & Vercel deploy rules (mandatory)
93
+
94
+ ### Runtime
95
+
96
+ - Serve with **`tsx server.ts`** (or compiled Node that still calls `TomorrowOS.listen` with `staticRoot`).
97
+ - Keep **`@tomorrowos/sdk`** and put **`tsx` in `dependencies`** (not only `devDependencies`) if production runs via `tsx`.
98
+ - Node **20+**.
99
+ - **Do not** replace the SDK with a hand-rolled server that drops WebSocket upgrades.
100
+
101
+ ### Expected `package.json` scripts
102
+
103
+ ```json
104
+ {
105
+ "scripts": {
106
+ "dev": "tsx watch server.ts",
107
+ "start": "tsx server.ts"
108
+ }
109
+ }
110
+ ```
111
+
112
+ **Forbidden as the Deploy entrypoint:** `next start`, static export only, or any command that does not start `TomorrowOS.listen(...)`.
113
+
114
+ ### Minimal dependencies
115
+
116
+ ```json
117
+ {
118
+ "type": "module",
119
+ "engines": {
120
+ "node": ">=20"
121
+ },
122
+ "dependencies": {
123
+ "@tomorrowos/sdk": "latest",
124
+ "dotenv": "^17.2.3",
125
+ "tsx": "^4.19.0"
126
+ },
127
+ "devDependencies": {
128
+ "@types/node": "^20.0.0",
129
+ "typescript": "^5.5.0"
130
+ },
131
+ "scripts": {
132
+ "dev": "tsx watch server.ts",
133
+ "start": "tsx server.ts"
134
+ }
135
+ }
136
+ ```
137
+
138
+ Use the latest published `@tomorrowos/sdk` when scaffolding (do not invent versions).
139
+
140
+ ### `server.ts` pattern (Vercel-adapted)
141
+
142
+ ```ts
143
+ import "dotenv/config";
144
+ import { readFileSync } from "fs";
145
+ import { fileURLToPath } from "url";
146
+ import { dirname, join } from "path";
147
+ import { createTomorrowOSStore, TomorrowOS } from "@tomorrowos/sdk";
148
+
149
+ const __dirname = dirname(fileURLToPath(import.meta.url));
150
+ const brand = JSON.parse(readFileSync(join(__dirname, "brand.json"), "utf8"));
151
+
152
+ const store = createTomorrowOSStore({
153
+ // Prefer SUPABASE_URL. On Vercel use the Session pooler connection string.
154
+ databaseUrl: process.env.SUPABASE_URL || process.env.DATABASE_URL,
155
+ sqlitePath: join(__dirname, "data", "tomorrowos.db") // local/dev fallback only
156
+ });
157
+
158
+ const tomorrowos = new TomorrowOS({ brand, store });
159
+
160
+ const server = tomorrowos.listen({
161
+ port: Number(process.env.PORT) || 3000,
162
+ host: "0.0.0.0",
163
+ staticRoot: join(__dirname, "public")
164
+ });
165
+
166
+ // Vercel / Node server capture: export the HTTP server, not only TomorrowOS.
167
+ export default server;
168
+ ```
169
+
170
+ Load env with `dotenv` for local/`vercel dev`. On production Deploy, Vercel injects Environment Variables natively.
171
+
172
+ ### `vercel.json` (recommended minimum)
173
+
174
+ Adapt to the project’s current Vercel Node-server / Fluid conventions. At minimum ensure:
175
+
176
+ ```json
177
+ {
178
+ "fluid": true
179
+ }
180
+ ```
181
+
182
+ Also:
183
+
184
+ 1. Project → **Fluid compute** enabled (required for WebSockets).
185
+ 2. Framework Preset: **Other** (or Node) — **not** Next.js unless wrapping is intentional and still exposes TomorrowOS `listen()`.
186
+ 3. Install / Start commands must end in running the CMS server (e.g. `npm run start` or Vercel’s detected `server.ts` entry).
187
+ 4. Do **not** enable “Output Directory” static-only hosting for this CMS.
188
+
189
+ If Vercel’s UI and this file disagree on bundling, **prefer whatever keeps a single long-lived Node HTTP server with WebSocket upgrade on `/`**. Document the adaptation in the final summary.
190
+
191
+ ### WebSocket / multi-instance note
192
+
193
+ - Device sockets are pinned to **one** function/instance. TomorrowOS device maps are **in-memory**.
194
+ - For large fleets on multi-instance Vercel, prefer **one stable instance / region**, or accept that reconnects may land on another instance until the product adds shared presence (out of scope for this setup).
195
+ - Always use **Supabase** so pairings/playlists survive cold starts and deploys.
196
+
197
+ ### What not to create
198
+
199
+ - ❌ `.replit`, `.replit-artifact`
200
+ - ❌ Next.js `app/` / `pages/` CMS
201
+ - ❌ Separate “API project” without the Control Panel
202
+ - ❌ Committing `SUPABASE_URL` passwords or Cloudinary secrets
203
+
204
+ ---
205
+
206
+ ## Question 1 — Supabase Session pooler (always)
207
+
208
+ > Always configure Supabase for Vercel. Do **not** ask screen counts.
209
+
210
+ **Ask exactly:**
211
+
212
+ > Paste your Supabase Postgres **Session pooler** connection string. I will store it as the Vercel Environment Variable **`SUPABASE_URL`**.
213
+ >
214
+ > In Supabase: **Project Settings → Database → Connection string → Connection pooling** (Session mode).
215
+ > Preferred shape:
216
+ > `postgresql://postgres.[PROJECT]:[PASSWORD]@aws-0-[REGION].pooler.supabase.com:6543/postgres`
217
+ >
218
+ > **Do not** use the direct host `db.*.supabase.co:5432` for Vercel — sandbox/serverless often fails with `ENETUNREACH` (commonly on IPv6).
219
+ >
220
+ > Confirm SSL is required (usually **yes** for Supabase).
221
+
222
+ **You must then:**
223
+
224
+ 1. Set Vercel Env Vars (Production + Preview + Development as appropriate):
225
+ - `SUPABASE_URL=<pooler string>`
226
+ - `TOMORROWOS_STORE=supabase`
227
+ - `DATABASE_SSL=true` (unless user explicitly disables SSL)
228
+ 2. Wire `server.ts` as in **Runtime & Vercel deploy rules**.
229
+ 3. Optional committed `.env.example` with **placeholders only** (no real password).
230
+ 4. **Do not** commit the real connection string.
231
+ 5. **Configuration over probing:** if local/`vercel dev` still shows `ENETUNREACH` / IPv6 errors, verify the string is the **pooler** (`pooler.supabase.com` + `:6543`). Treat remaining sandbox DNS issues as non-fatal when Production deploy Env Vars are correct — but **do** fix an obvious direct `:5432` URL before finishing.
232
+
233
+ If the user refuses Supabase, warn that Vercel cannot use durable SQLite for fleets, then only continue with SQLite after explicit confirmation (local/demo only).
234
+
235
+ **Later in Question 3:** set `cms.hostingTarget` to **`"vercel"`**.
236
+
237
+ ---
238
+
239
+ ## Question 2 — Media storage
240
+
241
+ ### Step A — Ask storage choice
242
+
243
+ **Ask exactly:**
244
+
245
+ > How should playlist media (images/videos) be stored?
246
+ >
247
+ > **Recommended on Vercel: Cloudinary** — durable public HTTPS URLs (`https://res.cloudinary.com/...`). Local `public/uploads` is ephemeral on serverless/filesystem deploys and is a poor fleet default.
248
+ >
249
+ > Alternatives:
250
+ > - **Local disk only** — `public/uploads` (OK for quick local tests; **not** for production Vercel fleets).
251
+ >
252
+ > Do you want me to set up **Cloudinary**? (yes / no)
253
+
254
+ ### Step B — If YES / Cloudinary (same Question 2)
255
+
256
+ Immediately collect and store as **Vercel Environment Variables**:
257
+
258
+ - `CLOUDINARY_CLOUD_NAME`
259
+ - `CLOUDINARY_API_KEY`
260
+ - `CLOUDINARY_API_SECRET`
261
+ - Optional: `CLOUDINARY_FOLDER` (e.g. `tomorrowos`)
262
+
263
+ Do **not** invent placeholders. Do **not** proceed to Question 3 until the three required vars exist. Do **not** run a formal Cloudinary upload test unless the user asks.
264
+
265
+ The SDK auto-detects these env vars.
266
+
267
+ ### If NO — local uploads
268
+
269
+ 1. `mkdir -p public/uploads`
270
+ 2. Warn clearly: files may vanish on redeploy; players need stable HTTPS URLs — Cloudinary is strongly preferred for Vercel.
271
+
272
+ ---
273
+
274
+ ## Question 3 — Brand (`brand.json` only)
275
+
276
+ > Updates **only** `brand.json`. Does **not** change Vercel project settings, Env Vars, or `server.ts` store wiring beyond what Q1–Q2 already required.
277
+
278
+ **Ask exactly:**
279
+
280
+ > Let’s brand your TomorrowOS experience. Please provide:
281
+ >
282
+ > 1. **Product / venue name**
283
+ > 2. **Tagline** (optional)
284
+ > 3. **Primary colour** (hex, e.g. `#FF8A3D`)
285
+ > 4. **Background colour** (hex, optional — default `#FAFAF9`)
286
+ > 5. **Text colour** (hex, optional — default `#0A0908`)
287
+ > 6. **Secondary / accent colour** (hex, optional)
288
+ > 7. **Logo** — upload SVG/PNG into the project, or a URL I can fetch into `./assets/`
289
+
290
+ Then write `brand.json` (validate mentally against `brand.schema.json`). Minimum:
291
+
292
+ ```json
293
+ {
294
+ "name": "<user name>",
295
+ "tagline": "<user tagline or Digital signage>",
296
+ "targetPlatforms": ["tizen"],
297
+ "primaryColor": "#FF8A3D",
298
+ "secondaryColor": "#F5F3EF",
299
+ "backgroundColor": "#FAFAF9",
300
+ "textColor": "#0A0908",
301
+ "logoPath": "./assets/logo.svg",
302
+ "fontFamily": "Inter",
303
+ "cms": {
304
+ "useCase": "other",
305
+ "hostingTarget": "vercel",
306
+ "expectedScreens": 5,
307
+ "features": {
308
+ "bulkCommands": false,
309
+ "proofOfPlay": false,
310
+ "contentScheduling": true,
311
+ "userManagement": false
312
+ }
313
+ },
314
+ "protocolVersion": "1.0"
315
+ }
316
+ ```
317
+
318
+ Always set **`cms.hostingTarget`: `"vercel"`** without asking. Default `expectedScreens` to `5` unless the user already volunteered a number. Set `cmsEndpoint` only if they already know the public `https://…vercel.app` URL; otherwise tell them to pair TVs with the deployed HTTPS origin (player maps `https://` → `wss://`).
319
+
320
+ ---
321
+
322
+ ## After all answers — execution checklist
323
+
324
+ ### A. Seed the project
325
+
326
+ ```bash
327
+ npx @tomorrowos/sdk@latest init .
328
+ # If not empty and user confirms starter overwrite:
329
+ # npx @tomorrowos/sdk@latest init . --force
330
+ ```
331
+
332
+ Then:
333
+
334
+ 1. Remove leftover Next.js / other frameworks so TomorrowOS is sole app.
335
+ 2. Apply Vercel-adapted `server.ts` (**export `listen()` HTTP server**).
336
+ 3. Add/adjust `vercel.json` with **`fluid: true`** (or enable Fluid in the dashboard).
337
+ 4. Do **not** add `.replit*`.
338
+
339
+ ### B. Environment Variables (Vercel dashboard + local)
340
+
341
+ | Name | Required | Notes |
342
+ |------|----------|--------|
343
+ | `SUPABASE_URL` | Yes (production) | **Session pooler** `:6543` |
344
+ | `TOMORROWOS_STORE` | Yes | `supabase` |
345
+ | `DATABASE_SSL` | Yes (typical) | `true` |
346
+ | `CLOUDINARY_*` | If Q2 = Cloudinary | Three keys |
347
+ | `PORT` | Optional | Vercel injects as needed |
348
+
349
+ Mirror non-secrets in `.env` for local `npm run dev` if helpful; never commit secrets.
350
+
351
+ ### C. Install and run
352
+
353
+ ```bash
354
+ npm install
355
+ npm run start
356
+ # or: vercel dev
357
+ ```
358
+
359
+ Confirm logs: `[TomorrowOS] listening on http://0.0.0.0:…`
360
+ Confirm browser: Control Panel at `/`.
361
+
362
+ ### D. Deploy
363
+
364
+ ```bash
365
+ vercel --prod
366
+ # or git push → Vercel Git integration
367
+ ```
368
+
369
+ Tell the user:
370
+
371
+ - **Control Panel:** `https://YOUR-PROJECT.vercel.app`
372
+ - **TV CMS endpoint:** same HTTPS origin (players use `wss://`)
373
+
374
+ ### E. Minimal verification only
375
+
376
+ **Do:**
377
+
378
+ 1. Control Panel HTML at `/`
379
+ 2. Server status: Media OK when Cloudinary configured
380
+ 3. Database: if ERROR shows **`ENETUNREACH` … IPv6 … `:5432`**, switch `SUPABASE_URL` to **pooler `:6543`** and redeploy — call this out explicitly to the user
381
+ 4. Fluid compute on
382
+
383
+ **Do not block** on long WebSocket/device pairing tests unless the user asks.
384
+
385
+ ---
386
+
387
+ ## Failure recovery cheat sheet
388
+
389
+ | Symptom | Likely cause | Fix |
390
+ |---------|--------------|-----|
391
+ | `ENETUNREACH` / IPv6 / `:5432` | Direct Supabase DB URL | Use **Session pooler** `*.pooler.supabase.com:6543` |
392
+ | Control Panel OK, devices never stay paired after restart | SQLite / ephemeral disk | Must use Supabase |
393
+ | Uploads break / broken thumbs in prod | Local `public/uploads` | Cloudinary |
394
+ | WebSocket fails / devices drop immediately | Fluid off / static-only / Next wrapper | Enable Fluid; sole Node `listen()` server |
395
+ | Agent created Next.js + TomorrowOS | Wrong scaffold | Remove Next; keep cms-starter |
396
+ | Secrets in git | Mistake | Rotate keys; move to Vercel Env Vars |
397
+ | Replit files present | Copied wrong protocol | Delete `.replit*`; use this file |
398
+
399
+ ---
400
+
401
+ ## How this differs from `REPLIT_SETUP.md`
402
+
403
+ | Topic | Replit | Vercel |
404
+ |-------|--------|--------|
405
+ | Secrets | Replit Secrets | Vercel Environment Variables |
406
+ | Deploy config | `.replit`, artifact `kind=web` | `vercel.json` + Fluid; **no** `.replit` |
407
+ | `hostingTarget` | `"here"` | `"vercel"` |
408
+ | Supabase URL | Prefer pooler; Preview DNS may fail | **Require pooler**; direct `:5432` often `ENETUNREACH` |
409
+ | SQLite | Demo OK on persistent Repl disk | **Not** production on Vercel |
410
+ | Object Storage option | Replit Object Storage | N/A — prefer Cloudinary |
411
+ | WebSocket | Long-lived Node (Autoscale) | Fluid compute + Node server export |
412
+
413
+ ---
414
+
415
+ ## Final summary template (Agent → human)
416
+
417
+ After setup, report:
418
+
419
+ 1. **Live URL** (Production)
420
+ 2. **Env Vars set** (names only — never values)
421
+ 3. **Store:** Supabase pooler / SSL flags
422
+ 4. **Media:** Cloudinary or local (with warning)
423
+ 5. **Brand:** name + `hostingTarget: vercel`
424
+ 6. **Adaptations from Replit protocol:** list (e.g. skipped `.replit`, exported HTTP server, Fluid, pooler URL)
425
+ 7. **Open action:** if Database still ERROR, paste the pooler instruction again
426
+
427
+ Protocol complete when the Control Panel renders at `/` on the deployed (or local) server and Q1–Q3 configuration is saved — even if a sandbox DB probe still flakes **after** the pooler URL is correctly set for Production.
@@ -136,6 +136,15 @@ export declare class TomorrowOS extends EventEmitter {
136
136
  onOffTimer: DeviceOnOffTimer;
137
137
  pushed: boolean;
138
138
  }>;
139
+ /**
140
+ * Clear the daily on/off timer. The player keeps its current mute / power-save
141
+ * state until a new timer is set.
142
+ */
143
+ clearDeviceOnOffTimer(deviceId: string): Promise<{
144
+ deviceId: string;
145
+ onOffTimer: null;
146
+ pushed: boolean;
147
+ }>;
139
148
  private pushOnOffTimerToDevice;
140
149
  setDeviceName(deviceId: string, deviceName: string): Promise<{
141
150
  deviceId: 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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"}
@@ -441,15 +441,33 @@ export class TomorrowOS extends EventEmitter {
441
441
  const pushed = await this.pushOnOffTimerToDevice(id);
442
442
  return { deviceId: id, onOffTimer, pushed };
443
443
  }
444
+ /**
445
+ * Clear the daily on/off timer. The player keeps its current mute / power-save
446
+ * state until a new timer is set.
447
+ */
448
+ async clearDeviceOnOffTimer(deviceId) {
449
+ const id = String(deviceId || "").trim();
450
+ if (!id) {
451
+ throw new Error("deviceId is required");
452
+ }
453
+ const existing = await this.store.getPairedDevice(id);
454
+ if (!existing) {
455
+ throw new Error("Device is not paired");
456
+ }
457
+ const { onOffTimer: _removed, ...rest } = existing;
458
+ await this.store.setPairedDevice(id, rest);
459
+ const pushed = await this.pushOnOffTimerToDevice(id);
460
+ return { deviceId: id, onOffTimer: null, pushed };
461
+ }
444
462
  async pushOnOffTimerToDevice(deviceId) {
445
463
  const existing = await this.store.getPairedDevice(deviceId);
446
- if (!existing?.onOffTimer)
464
+ if (!existing)
447
465
  return false;
448
466
  const ws = this.devices.get(deviceId);
449
467
  if (!ws || ws.readyState !== WebSocket.OPEN)
450
468
  return false;
451
469
  try {
452
- const result = await this.sendCommandToSocket(ws, deviceId, "device.display.setOnOffTimer", { onOffTimer: existing.onOffTimer }, 15_000);
470
+ const result = await this.sendCommandToSocket(ws, deviceId, "device.display.setOnOffTimer", { onOffTimer: existing.onOffTimer ?? null }, 15_000);
453
471
  return result.status === "success" || result.status === "accepted";
454
472
  }
455
473
  catch (err) {
@@ -1178,8 +1196,14 @@ export class TomorrowOS extends EventEmitter {
1178
1196
  const deviceId = decodeURIComponent(deviceOnOffTimer[1]);
1179
1197
  const body = (await readJsonBody(req));
1180
1198
  try {
1181
- const result = await this.setDeviceOnOffTimer(deviceId, body.onOffTimer ?? body);
1182
- sendJson(res, 200, { status: "success", ...result });
1199
+ if (body.clear === true || body.onOffTimer === null) {
1200
+ const result = await this.clearDeviceOnOffTimer(deviceId);
1201
+ sendJson(res, 200, { status: "success", ...result });
1202
+ }
1203
+ else {
1204
+ const result = await this.setDeviceOnOffTimer(deviceId, body.onOffTimer ?? body);
1205
+ sendJson(res, 200, { status: "success", ...result });
1206
+ }
1183
1207
  }
1184
1208
  catch (e) {
1185
1209
  const msg = e instanceof Error ? e.message : "On/off timer failed";
@@ -1187,6 +1211,18 @@ export class TomorrowOS extends EventEmitter {
1187
1211
  }
1188
1212
  return;
1189
1213
  }
1214
+ if (req.method === "DELETE" && deviceOnOffTimer) {
1215
+ const deviceId = decodeURIComponent(deviceOnOffTimer[1]);
1216
+ try {
1217
+ const result = await this.clearDeviceOnOffTimer(deviceId);
1218
+ sendJson(res, 200, { status: "success", ...result });
1219
+ }
1220
+ catch (e) {
1221
+ const msg = e instanceof Error ? e.message : "On/off timer clear failed";
1222
+ sendJson(res, 400, { status: "failed", error: msg });
1223
+ }
1224
+ return;
1225
+ }
1190
1226
  if (req.method === "GET" && pathname === "/playlists") {
1191
1227
  const playlists = await this.playlists.listPlaylists();
1192
1228
  sendJson(res, 200, { status: "success", playlists });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tomorrowos/sdk",
3
- "version": "0.9.19",
3
+ "version": "0.9.21",
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",
@@ -20,6 +20,7 @@
20
20
  "README.md",
21
21
  "LLM_PROMPT.md",
22
22
  "REPLIT_SETUP.md",
23
+ "VERCEL_SETUP.md",
23
24
  "REPLIT_UPGRADE.md",
24
25
  "BUILD_GUARDRAILS.md",
25
26
  "PLAYER_INSTALL.md",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "my-cms",
3
- "version": "0.9.19",
3
+ "version": "0.9.21",
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.19",
16
+ "@tomorrowos/sdk": "^0.9.21",
17
17
  "dotenv": "^17.2.3",
18
18
  "tsx": "^4.19.0"
19
19
  },
@@ -187,6 +187,7 @@
187
187
  <h2>On / off timer</h2>
188
188
  <p class="hint" id="onOffTimerModalHint">
189
189
  Daily on/off times follow the device clock. Off blacks the screen; the player stays powered and connected to the CMS.
190
+ Removing the timer stops the schedule and leaves the screen in its current on or off state until you set a timer again.
190
191
  </p>
191
192
  <div class="on-off-timer-grid">
192
193
  <label class="field-label">
@@ -200,6 +201,7 @@
200
201
  </div>
201
202
  <div class="modal-actions">
202
203
  <button type="button" id="onOffTimerSaveBtn" class="primary">Save</button>
204
+ <button type="button" id="onOffTimerRemoveBtn" class="danger hidden">Remove timer</button>
203
205
  <button type="button" data-close-on-off-timer-modal="1">Cancel</button>
204
206
  </div>
205
207
  </div>
@@ -1084,6 +1084,67 @@ function appendDeviceMetaRow(meta, label, value) {
1084
1084
  meta.appendChild(row);
1085
1085
  }
1086
1086
 
1087
+ function hasConfiguredOnOffTimer(timer) {
1088
+ return !!(
1089
+ timer &&
1090
+ typeof timer.turnOnAt === "string" &&
1091
+ typeof timer.turnOffAt === "string" &&
1092
+ timer.turnOnAt &&
1093
+ timer.turnOffAt
1094
+ );
1095
+ }
1096
+
1097
+ function appendHardwareTimerStatusRow(meta, device) {
1098
+ const timer = device.onOffTimer || null;
1099
+ const configured = hasConfiguredOnOffTimer(timer);
1100
+
1101
+ const row = document.createElement("div");
1102
+ row.className = "device-meta-row device-meta-row--timer-status";
1103
+
1104
+ const dt = document.createElement("dt");
1105
+ dt.textContent = "Hardware timer status";
1106
+
1107
+ const dd = document.createElement("dd");
1108
+ const statusWrap = document.createElement("span");
1109
+ statusWrap.className = "hardware-timer-status";
1110
+ statusWrap.tabIndex = 0;
1111
+
1112
+ const statusValue = document.createElement("span");
1113
+ statusValue.className = configured
1114
+ ? "hardware-timer-status__value hardware-timer-status__value--on"
1115
+ : "hardware-timer-status__value hardware-timer-status__value--off";
1116
+ statusValue.textContent = configured ? "On" : "Off";
1117
+
1118
+ const popup = document.createElement("span");
1119
+ popup.className = "hardware-timer-status__popup";
1120
+ popup.setAttribute("role", "tooltip");
1121
+ if (configured) {
1122
+ const onLine = document.createElement("div");
1123
+ const onLabel = document.createElement("strong");
1124
+ onLabel.textContent = "Turn on";
1125
+ onLine.appendChild(onLabel);
1126
+ onLine.appendChild(document.createTextNode(` ${timer.turnOnAt}`));
1127
+
1128
+ const offLine = document.createElement("div");
1129
+ const offLabel = document.createElement("strong");
1130
+ offLabel.textContent = "Turn off";
1131
+ offLine.appendChild(offLabel);
1132
+ offLine.appendChild(document.createTextNode(` ${timer.turnOffAt}`));
1133
+
1134
+ popup.appendChild(onLine);
1135
+ popup.appendChild(offLine);
1136
+ } else {
1137
+ popup.textContent = "No timer configured";
1138
+ }
1139
+
1140
+ statusWrap.appendChild(statusValue);
1141
+ statusWrap.appendChild(popup);
1142
+ dd.appendChild(statusWrap);
1143
+ row.appendChild(dt);
1144
+ row.appendChild(dd);
1145
+ meta.appendChild(row);
1146
+ }
1147
+
1087
1148
  function createDeviceScreenshotThumb(device) {
1088
1149
  const slot = document.createElement("div");
1089
1150
  slot.className = "device-screenshot-slot";
@@ -1250,6 +1311,7 @@ function renderDeviceCards() {
1250
1311
  for (const [label, value] of secondaryRows) {
1251
1312
  appendDeviceMetaRow(metaSecondary, label, value);
1252
1313
  }
1314
+ appendHardwareTimerStatusRow(metaSecondary, device);
1253
1315
 
1254
1316
  metaBlock.appendChild(metaTop);
1255
1317
  metaBlock.appendChild(metaSecondary);
@@ -1833,11 +1895,17 @@ function openOnOffTimerModal(deviceId) {
1833
1895
  const modal = document.getElementById("onOffTimerModal");
1834
1896
  const onEl = document.getElementById("onOffTimerTurnOnAt");
1835
1897
  const offEl = document.getElementById("onOffTimerTurnOffAt");
1898
+ const removeBtn = document.getElementById("onOffTimerRemoveBtn");
1836
1899
  if (!modal || !onEl || !offEl) return;
1837
1900
 
1838
1901
  const timer = device.onOffTimer || {};
1902
+ const configured = hasConfiguredOnOffTimer(device.onOffTimer);
1839
1903
  onEl.value = normalizeTimeInputValue(timer.turnOnAt, "06:00");
1840
1904
  offEl.value = normalizeTimeInputValue(timer.turnOffAt, "18:00");
1905
+ if (removeBtn) {
1906
+ removeBtn.classList.toggle("hidden", !configured);
1907
+ removeBtn.disabled = false;
1908
+ }
1841
1909
  modal.classList.remove("hidden");
1842
1910
  }
1843
1911
 
@@ -1854,6 +1922,7 @@ async function saveOnOffTimerModal() {
1854
1922
  const onEl = document.getElementById("onOffTimerTurnOnAt");
1855
1923
  const offEl = document.getElementById("onOffTimerTurnOffAt");
1856
1924
  const saveBtn = document.getElementById("onOffTimerSaveBtn");
1925
+ const removeBtn = document.getElementById("onOffTimerRemoveBtn");
1857
1926
  if (!onEl || !offEl) return;
1858
1927
 
1859
1928
  const onOffTimer = {
@@ -1867,6 +1936,7 @@ async function saveOnOffTimerModal() {
1867
1936
  }
1868
1937
 
1869
1938
  if (saveBtn) saveBtn.disabled = true;
1939
+ if (removeBtn) removeBtn.disabled = true;
1870
1940
  try {
1871
1941
  const res = await fetch(`/device/${encodeURIComponent(deviceId)}/on-off-timer`, {
1872
1942
  method: "POST",
@@ -1885,6 +1955,43 @@ async function saveOnOffTimerModal() {
1885
1955
  alert(err?.message || "Could not save on/off timer");
1886
1956
  } finally {
1887
1957
  if (saveBtn) saveBtn.disabled = false;
1958
+ if (removeBtn) removeBtn.disabled = false;
1959
+ }
1960
+ }
1961
+
1962
+ async function removeOnOffTimerModal() {
1963
+ const deviceId = onOffTimerModalDeviceId;
1964
+ if (!deviceId) return;
1965
+
1966
+ if (
1967
+ !confirm(
1968
+ "Remove the on/off timer? The screen will stay in its current on or off state until you set a timer again."
1969
+ )
1970
+ ) {
1971
+ return;
1972
+ }
1973
+
1974
+ const saveBtn = document.getElementById("onOffTimerSaveBtn");
1975
+ const removeBtn = document.getElementById("onOffTimerRemoveBtn");
1976
+ if (saveBtn) saveBtn.disabled = true;
1977
+ if (removeBtn) removeBtn.disabled = true;
1978
+ try {
1979
+ const res = await fetch(`/device/${encodeURIComponent(deviceId)}/on-off-timer`, {
1980
+ method: "DELETE"
1981
+ });
1982
+ const data = await res.json();
1983
+ if (!res.ok || data.status === "failed") {
1984
+ alert(data.error || "Could not remove on/off timer");
1985
+ return;
1986
+ }
1987
+ showResult({ deviceId, onOffTimer: null, pushed: data.pushed });
1988
+ closeOnOffTimerModal();
1989
+ await fetchDevices();
1990
+ } catch (err) {
1991
+ alert(err?.message || "Could not remove on/off timer");
1992
+ } finally {
1993
+ if (saveBtn) saveBtn.disabled = false;
1994
+ if (removeBtn) removeBtn.disabled = false;
1888
1995
  }
1889
1996
  }
1890
1997
 
@@ -2213,6 +2320,9 @@ document.addEventListener("DOMContentLoaded", () => {
2213
2320
  document
2214
2321
  .getElementById("onOffTimerSaveBtn")
2215
2322
  ?.addEventListener("click", () => void saveOnOffTimerModal());
2323
+ document
2324
+ .getElementById("onOffTimerRemoveBtn")
2325
+ ?.addEventListener("click", () => void removeOnOffTimerModal());
2216
2326
  document.querySelectorAll("[data-close-download-failed-modal]").forEach((el) => {
2217
2327
  el.addEventListener("click", closeDownloadFailedModal);
2218
2328
  });
@@ -283,6 +283,7 @@ body {
283
283
  border-radius: 8px;
284
284
  padding: 0.85rem 0.95rem;
285
285
  background: #fafaf9;
286
+ overflow: visible;
286
287
  }
287
288
 
288
289
  .device-card-header {
@@ -439,6 +440,73 @@ body {
439
440
  display: block;
440
441
  }
441
442
 
443
+ .device-meta-row--timer-status {
444
+ position: relative;
445
+ }
446
+
447
+ .hardware-timer-status {
448
+ position: relative;
449
+ display: inline-block;
450
+ cursor: default;
451
+ outline: none;
452
+ }
453
+
454
+ .hardware-timer-status__value--on {
455
+ color: #027a48;
456
+ font-weight: 600;
457
+ }
458
+
459
+ .hardware-timer-status__value--off {
460
+ color: #667085;
461
+ font-weight: 600;
462
+ }
463
+
464
+ .hardware-timer-status__popup {
465
+ position: absolute;
466
+ left: 0;
467
+ bottom: calc(100% + 0.45rem);
468
+ z-index: 20;
469
+ min-width: 9.5rem;
470
+ padding: 0.55rem 0.7rem;
471
+ border: 1px solid #e4e1dc;
472
+ border-radius: 6px;
473
+ background: #fff;
474
+ box-shadow: 0 6px 18px rgba(0, 0, 0, 0.12);
475
+ color: #333;
476
+ font-size: 0.75rem;
477
+ font-weight: 400;
478
+ line-height: 1.45;
479
+ white-space: nowrap;
480
+ opacity: 0;
481
+ visibility: hidden;
482
+ pointer-events: none;
483
+ transform: translateY(4px);
484
+ transition: opacity 0.12s ease, transform 0.12s ease, visibility 0.12s;
485
+ }
486
+
487
+ .hardware-timer-status__popup::after {
488
+ content: "";
489
+ position: absolute;
490
+ top: 100%;
491
+ left: 0.85rem;
492
+ border: 6px solid transparent;
493
+ border-top-color: #fff;
494
+ filter: drop-shadow(0 1px 0 #e4e1dc);
495
+ }
496
+
497
+ .hardware-timer-status__popup strong {
498
+ font-weight: 600;
499
+ color: #555;
500
+ margin-right: 0.35rem;
501
+ }
502
+
503
+ .hardware-timer-status:hover .hardware-timer-status__popup,
504
+ .hardware-timer-status:focus-within .hardware-timer-status__popup {
505
+ opacity: 1;
506
+ visibility: visible;
507
+ transform: translateY(0);
508
+ }
509
+
442
510
  .device-card-actions {
443
511
  display: flex;
444
512
  flex-wrap: wrap;