@tomorrowos/sdk 0.9.20 → 0.9.22

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
@@ -4,7 +4,7 @@
4
4
 
5
5
  **On Replit:** if the user asks to set up / configure a CMS interactively, follow **`REPLIT_SETUP.md`** instead (Q&A for store → media → `brand.json`).
6
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"`).
7
+ **On Vercel:** if the user asks to set up / configure a CMS interactively, follow **`VERCEL_SETUP.md`** (Supabase or Neon pooled Postgres, Cloudinary or Vercel Blob media, Fluid compute + WebSocket, `cms.hostingTarget: "vercel"`).
8
8
 
9
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.
10
10
 
@@ -71,8 +71,9 @@ Follow @tomorrowos/sdk VERCEL_SETUP.md and set up my TomorrowOS CMS.
71
71
  IF any step contradicts Vercel, adapt to settings that Vercel can accept.
72
72
  ```
73
73
 
74
- That wizard requires the Supabase **Session pooler** URL, prefers Cloudinary,
75
- enables Fluid compute for WebSockets, and sets `cms.hostingTarget` to `"vercel"`.
74
+ That wizard asks: database (**Supabase** **Neon** → SQLite demo), media (**Cloudinary** → **Vercel Blob** → local),
75
+ branding (manual fields or **infer from a website URL**), enables Fluid compute for WebSockets, and sets
76
+ `cms.hostingTarget` to `"vercel"`.
76
77
 
77
78
  ### Replit upgrade (existing CMS)
78
79
 
package/VERCEL_SETUP.md CHANGED
@@ -5,7 +5,7 @@
5
5
  >
6
6
  > **On Vercel, this file is the setup questionnaire** (not `REPLIT_SETUP.md`, not `LLM_PROMPT.md`).
7
7
  >
8
- > Protocol id: `vercel-setup/1.0` — pairs with `@tomorrowos/sdk` packages that ship `templates/cms-starter` and this file.
8
+ > Protocol id: `vercel-setup/1.1` — pairs with `@tomorrowos/sdk` packages that ship `templates/cms-starter` and this file.
9
9
 
10
10
  ## User trigger (copy-paste)
11
11
 
@@ -15,7 +15,7 @@ When a human pastes a prompt like this, **read this file and start Question 1 im
15
15
  Follow @tomorrowos/sdk VERCEL_SETUP.md and set up my TomorrowOS CMS.
16
16
  Ask only the questions listed in VERCEL_SETUP.md, in order. Do not use LLM_PROMPT.md or REPLIT_SETUP.md.
17
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.
18
+ After all setup questions are answered (including Cloudinary or Vercel Blob Env Vars if you chose those), configure and deploy — minimal verification only (Control Panel at /). Prefer Supabase Session pooler URL or Neon pooled URL. Do not treat sandbox ENETUNREACH / IPv6 :5432 errors as a hard setup failure when a pooler URL is configured for production.
19
19
  Question 3 only updates brand.json. Do not skip steps.
20
20
  ```
21
21
 
@@ -41,9 +41,9 @@ TomorrowOS CMS is **one Node process** that serves:
41
41
  |----------|-----------|
42
42
  | Single **Node HTTP server** entry (`tsx server.ts` / exported `http.Server` from `listen()`) | Next.js / Remix / static-only site as the CMS |
43
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 |
44
+ | Durable store = **Postgres** (Supabase or Neon, pooled URL) | **SQLite / `data/*.db` as production store** on Vercel (ephemeral filesystem) |
45
+ | Media = **Cloudinary** or **Vercel Blob** (durable HTTPS URLs) | Relying on local `public/uploads` alone for production fleets |
46
+ | Env vars in **Vercel Project → Settings → Environment Variables** | Committing passwords / API secrets / `.env` with secrets |
47
47
  | `cms.hostingTarget`: `"vercel"` in `brand.json` | Creating `.replit` / Replit artifact files |
48
48
 
49
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.
@@ -56,15 +56,15 @@ Ask **only** these questions, in **this exact order**:
56
56
 
57
57
  | Step | Section | When |
58
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) |
59
+ | 1 | **Question 1** — Database (Supabase Neon SQLite) | Always |
60
+ | 2 | **Question 2** — Media storage (Cloudinary Vercel Blob → local) | Always (after Q1) |
61
61
  | 3 | **Question 3** — Brand / TomorrowOS look (`brand.json` only) | Always last |
62
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**.
63
+ **That is the complete list.** Do **not** ask how many screens / devices. There is **no** “Question 2b” — Cloudinary / Vercel Blob credentials stay **inside Question 2**.
64
64
 
65
65
  If the user says “just set it up, don’t ask questions,” respond:
66
66
 
67
- > I need a few quick answers from VERCEL_SETUP.md (Supabase Session pooler URL, media storageplus Cloudinary credentials if you choose Cloudinary — and branding). It takes about a minute and ensures the CMS works on Vercel.
67
+ > I need a few quick answers from VERCEL_SETUP.md (database Supabase or Neon preferredmedia storage, and branding). It takes about a minute and ensures the CMS works on Vercel.
68
68
 
69
69
  Then ask **Question 1**.
70
70
 
@@ -73,14 +73,14 @@ Then ask **Question 1**.
73
73
  ## Hard rules
74
74
 
75
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).
76
+ 2. **One question at a time** (exception: after Cloudinary or Vercel Blob choice, immediately collect the required env vars in the same Question 2).
77
+ 3. **Do not invent** Cloudinary credentials, database URLs, Vercel Blob tokens, or brand colours.
78
+ 4. **Postgres env naming:** prefer **`SUPABASE_URL`** when using Supabase; prefer **`DATABASE_URL`** (or `NEON_DATABASE_URL`) when using Neon. `createTomorrowOSStore` accepts `SUPABASE_URL` then `DATABASE_URL`.
79
+ 5. **Always prefer pooled connection strings** (Supabase Session pooler port **6543**; Neon pooled host). Direct `:5432` URLs commonly fail on Vercel/sandbox with **`ENETUNREACH`** (often IPv6).
80
80
  6. **Prefer `npx @tomorrowos/sdk init`** (or `templates/cms-starter`) as the seed. Do not rebuild pairing / WebSocket / playlist APIs.
81
81
  7. **Never commit secrets.** Use Vercel Environment Variables (+ optional non-secret flags in committed `.env.example`).
82
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`.
83
+ 9. **SQLite is not a production store on Vercel.** Offer it only as option **3** after explicit warning. Keep `sqlitePath` as local-dev fallback in `createTomorrowOSStore`.
84
84
  10. **Enable Fluid compute** for WebSockets. Confirm Project settings after scaffold.
85
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
86
  12. After Q&A: **configure → install → run/deploy → minimal verify** (Control Panel at `/`). Do not run a long test suite.
@@ -137,6 +137,8 @@ Then ask **Question 1**.
137
137
 
138
138
  Use the latest published `@tomorrowos/sdk` when scaffolding (do not invent versions).
139
139
 
140
+ Add `@vercel/blob` to `dependencies` only when the user chooses **Vercel Blob** in Question 2.
141
+
140
142
  ### `server.ts` pattern (Vercel-adapted)
141
143
 
142
144
  ```ts
@@ -150,8 +152,11 @@ const __dirname = dirname(fileURLToPath(import.meta.url));
150
152
  const brand = JSON.parse(readFileSync(join(__dirname, "brand.json"), "utf8"));
151
153
 
152
154
  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
+ // Supabase: SUPABASE_URL (pooler). Neon: DATABASE_URL (pooled).
156
+ databaseUrl:
157
+ process.env.SUPABASE_URL ||
158
+ process.env.NEON_DATABASE_URL ||
159
+ process.env.DATABASE_URL,
155
160
  sqlitePath: join(__dirname, "data", "tomorrowos.db") // local/dev fallback only
156
161
  });
157
162
 
@@ -192,24 +197,40 @@ If Vercel’s UI and this file disagree on bundling, **prefer whatever keeps a s
192
197
 
193
198
  - Device sockets are pinned to **one** function/instance. TomorrowOS device maps are **in-memory**.
194
199
  - 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.
200
+ - Always use **Postgres** (Supabase or Neon) so pairings/playlists survive cold starts and deploys.
196
201
 
197
202
  ### What not to create
198
203
 
199
204
  - ❌ `.replit`, `.replit-artifact`
200
205
  - ❌ Next.js `app/` / `pages/` CMS
201
206
  - ❌ Separate “API project” without the Control Panel
202
- - ❌ Committing `SUPABASE_URL` passwords or Cloudinary secrets
207
+ - ❌ Committing database passwords, Cloudinary secrets, or Blob tokens
203
208
 
204
209
  ---
205
210
 
206
- ## Question 1 — Supabase Session pooler (always)
211
+ ## Question 1 — Database (always)
212
+
213
+ > **Do not** ask screen counts. Present all three options; recommend **Supabase** for Vercel fleets.
207
214
 
208
- > Always configure Supabase for Vercel. Do **not** ask screen counts.
215
+ ### Step A Ask storage choice
209
216
 
210
217
  **Ask exactly:**
211
218
 
212
- > Paste your Supabase Postgres **Session pooler** connection string. I will store it as the Vercel Environment Variable **`SUPABASE_URL`**.
219
+ > Which database should TomorrowOS use on Vercel?
220
+ >
221
+ > **1. Supabase Postgres (recommended)** — durable pairing/playlists; use the **Session pooler** URL (`*.pooler.supabase.com:6543`).
222
+ >
223
+ > **2. Neon Postgres** — serverless Postgres native to Vercel; use Neon’s **pooled** connection string from the Neon dashboard (not the direct un-pooled host for serverless).
224
+ >
225
+ > **3. SQLite (`data/tomorrowos.db`)** — **demo / local only on Vercel.** The filesystem is ephemeral; pairings and playlists will not survive redeploys. Only choose this if you understand the limitation.
226
+ >
227
+ > Reply with **1**, **2**, or **3** (or “Supabase” / “Neon” / “SQLite”).
228
+
229
+ ### Step B — If **1 / Supabase**
230
+
231
+ **Ask exactly:**
232
+
233
+ > Paste your Supabase Postgres connection string. I will store it as the Vercel Environment Variable **`SUPABASE_URL`**.
213
234
  >
214
235
  > In Supabase: **Project Settings → Database → Connection string → Connection pooling** (Session mode).
215
236
  > Preferred shape:
@@ -230,9 +251,45 @@ If Vercel’s UI and this file disagree on bundling, **prefer whatever keeps a s
230
251
  4. **Do not** commit the real connection string.
231
252
  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
253
 
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).
254
+ ### Step C If **2 / Neon**
234
255
 
235
- **Later in Question 3:** set `cms.hostingTarget` to **`"vercel"`**.
256
+ **Ask exactly:**
257
+
258
+ > Paste your **Neon pooled** Postgres connection string. I will store it as **`DATABASE_URL`** (or `NEON_DATABASE_URL` if you prefer — wire whichever you set in `server.ts`).
259
+ >
260
+ > In Neon: **Dashboard → Connection details → Pooled connection** (enable pooling).
261
+ > Typical shape:
262
+ > `postgresql://[user]:[password]@[endpoint].pooler.[region].aws.neon.tech/[dbname]?sslmode=require`
263
+ >
264
+ > **Do not** use the direct (non-pooled) connection string for Vercel serverless unless Neon docs explicitly say otherwise for your plan.
265
+ >
266
+ > Confirm SSL (`sslmode=require`) is on (usually **yes**).
267
+
268
+ **You must then:**
269
+
270
+ 1. Set Vercel Env Vars:
271
+ - `DATABASE_URL=<pooled Neon string>` (or `NEON_DATABASE_URL` + read it in `server.ts`)
272
+ - `TOMORROWOS_STORE=postgres`
273
+ - `DATABASE_SSL=true` (unless user explicitly disables SSL)
274
+ 2. Wire `server.ts` — `createTomorrowOSStore` uses `PostgresStore` for `postgres` / `supabase` drivers.
275
+ 3. **Do not** commit the real connection string.
276
+ 4. Same pooler guidance as Supabase: if you see `ENETUNREACH` on `:5432`, switch to Neon’s **pooled** URL.
277
+
278
+ ### Step D — If **3 / SQLite**
279
+
280
+ **Warn exactly before continuing:**
281
+
282
+ > SQLite on Vercel is **not** suitable for production fleets — data in `data/tomorrowos.db` is lost when the instance is recycled or redeployed. Continue only for a quick demo.
283
+
284
+ Only proceed after the user explicitly confirms.
285
+
286
+ **You must then:**
287
+
288
+ 1. Set `TOMORROWOS_STORE=sqlite` (or omit `DATABASE_URL` / `SUPABASE_URL` so the factory defaults to SQLite).
289
+ 2. Keep `sqlitePath` in `server.ts` for `data/tomorrowos.db`.
290
+ 3. Warn again in the final summary that they should move to Supabase or Neon for real devices.
291
+
292
+ **Later in Question 3:** set `cms.hostingTarget` to **`"vercel"`** (all Q1 branches).
236
293
 
237
294
  ---
238
295
 
@@ -244,14 +301,15 @@ If the user refuses Supabase, warn that Vercel cannot use durable SQLite for fle
244
301
 
245
302
  > How should playlist media (images/videos) be stored?
246
303
  >
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.
304
+ > **1. Cloudinary (recommended)** — durable public HTTPS URLs (`https://res.cloudinary.com/...`). Works out of the box with `@tomorrowos/sdk` auto-detection.
248
305
  >
249
- > Alternatives:
250
- > - **Local disk only** — `public/uploads` (OK for quick local tests; **not** for production Vercel fleets).
306
+ > **2. Vercel Blob** — Vercel-native object storage; durable `https://*.public.blob.vercel-storage.com/...` URLs. Good when you want media on the same Vercel project without a Cloudinary account.
251
307
  >
252
- > Do you want me to set up **Cloudinary**? (yes / no)
308
+ > **3. Local disk only** `public/uploads` (OK for quick local tests; **not** for production Vercel fleets — files are ephemeral).
309
+ >
310
+ > Reply with **1**, **2**, or **3** (or “Cloudinary” / “Vercel Blob” / “local”).
253
311
 
254
- ### Step B — If YES / Cloudinary (same Question 2)
312
+ ### Step B — If **1 / Cloudinary** (same Question 2)
255
313
 
256
314
  Immediately collect and store as **Vercel Environment Variables**:
257
315
 
@@ -264,10 +322,39 @@ Do **not** invent placeholders. Do **not** proceed to Question 3 until the three
264
322
 
265
323
  The SDK auto-detects these env vars.
266
324
 
267
- ### If NOlocal uploads
325
+ ### Step CIf **2 / Vercel Blob** (same Question 2)
326
+
327
+ **Ask exactly:**
328
+
329
+ > I will enable **Vercel Blob** for media uploads.
330
+ >
331
+ > 1. In the Vercel project: **Storage → Create → Blob** (or link an existing Blob store to this project).
332
+ > 2. Confirm **`BLOB_READ_WRITE_TOKEN`** is available (Vercel usually injects it when Blob is linked).
333
+ >
334
+ > Paste the token only if it is not already set in your project Env Vars. Do you already have Blob linked on this Vercel project? (yes / no)
335
+
336
+ **You must then:**
337
+
338
+ 1. Set Vercel Env Var: `BLOB_READ_WRITE_TOKEN=<token>` (if not auto-injected).
339
+ 2. `npm install @vercel/blob` (add to `dependencies`).
340
+ 3. Wire uploads so media returns **absolute HTTPS Blob URLs** stored in `uploaded_assets` (players need stable public URLs).
341
+ - The SDK **natively auto-detects Cloudinary** today. For Vercel Blob, add a thin upload bridge in the CMS project (e.g. custom route or middleware that calls `put()` from `@vercel/blob` and persists the returned `url` the same way Cloudinary URLs are stored).
342
+ - Minimum pattern:
343
+
344
+ ```ts
345
+ import { put } from "@vercel/blob";
346
+
347
+ // On upload: const blob = await put(filename, body, { access: "public", token: process.env.BLOB_READ_WRITE_TOKEN });
348
+ // Store blob.url in uploaded_assets — same shape as Cloudinary secure URLs.
349
+ ```
350
+
351
+ 4. Warn: until the project’s Blob bridge is wired, Control Panel **Media** status may show local/ephemeral — verify uploads return `https://*.blob.vercel-storage.com/...` before calling setup complete.
352
+ 5. Do **not** proceed to Question 3 until `BLOB_READ_WRITE_TOKEN` exists (or Blob is linked and token is confirmed in the Vercel dashboard).
353
+
354
+ ### Step D — If **3 / local uploads**
268
355
 
269
356
  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.
357
+ 2. Warn clearly: files may vanish on redeploy; players need stable HTTPS URLs — Cloudinary or Vercel Blob is strongly preferred for Vercel.
271
358
 
272
359
  ---
273
360
 
@@ -275,10 +362,17 @@ The SDK auto-detects these env vars.
275
362
 
276
363
  > Updates **only** `brand.json`. Does **not** change Vercel project settings, Env Vars, or `server.ts` store wiring beyond what Q1–Q2 already required.
277
364
 
365
+ ### Step A — Ask branding input
366
+
278
367
  **Ask exactly:**
279
368
 
280
- > Let’s brand your TomorrowOS experience. Please provide:
369
+ > Let’s brand your TomorrowOS experience. You can answer in either way:
370
+ >
371
+ > **Option A — Website URL only**
372
+ > Paste **one public website URL** (your company / venue site). I will infer colours, fonts, name, tagline, and logo from that page and write `brand.json` to match its look.
281
373
  >
374
+ > **Option B — Manual fields**
375
+ > Provide:
282
376
  > 1. **Product / venue name**
283
377
  > 2. **Tagline** (optional)
284
378
  > 3. **Primary colour** (hex, e.g. `#FF8A3D`)
@@ -287,12 +381,33 @@ The SDK auto-detects these env vars.
287
381
  > 6. **Secondary / accent colour** (hex, optional)
288
382
  > 7. **Logo** — upload SVG/PNG into the project, or a URL I can fetch into `./assets/`
289
383
 
290
- Then write `brand.json` (validate mentally against `brand.schema.json`). Minimum:
384
+ ### Step B If the user gives **only a website URL** (Option A)
385
+
386
+ **You must:**
387
+
388
+ 1. **Fetch and inspect** the page (HTTP GET the URL; follow one redirect if needed). Do not invent colours.
389
+ 2. **Derive branding** using this priority order:
390
+ - **Name:** `<title>`, `og:site_name`, or prominent header / logo `alt` text (trim to ≤ 60 chars).
391
+ - **Tagline:** `meta[name="description"]`, `og:description`, or first hero subtitle (≤ 120 chars).
392
+ - **Primary colour:** `meta[name="theme-color"]`, CSS `--primary` / `--brand` variables, or dominant accent from linked stylesheets / inline styles (convert to `#RRGGBB`).
393
+ - **Background colour:** `body` / `:root` background (default `#FAFAF9` if light site).
394
+ - **Text colour:** main body text colour (default `#0A0908` if light site).
395
+ - **Secondary colour:** muted border / secondary button colour, or a tint of the primary.
396
+ - **Font:** first `font-family` on `body` (strip quotes; default `Inter` if generic system stack).
397
+ - **Logo:** prefer `og:image`, then `link[rel="icon"]` / apple-touch-icon, then header `<img>` logo. Download into `./assets/logo.png` or `./assets/logo.svg` and set `logoPath` accordingly. If only favicon exists, use it and note size in the summary.
398
+ 3. **Contrast check:** if background is dark, set `activationScreen.theme` to `"dark"` and add `logoPathOnDark` when a light logo variant exists.
399
+ 4. **Infer `cms.useCase`** from page content when obvious (restaurant / retail / corporate / etc.); otherwise `"other"`.
400
+ 5. **Show a one-line summary** to the user (name + primary hex + logo path) before or while writing the file — do not ask a second branding question unless fetch failed.
401
+ 6. If the URL is unreachable, blocked, or has no usable styles, say so and fall back to **Option B** manual questions for missing fields only.
402
+
403
+ ### Step C — Write `brand.json`
404
+
405
+ Validate mentally against `brand.schema.json`. Minimum:
291
406
 
292
407
  ```json
293
408
  {
294
- "name": "<user name>",
295
- "tagline": "<user tagline or Digital signage>",
409
+ "name": "<user name or site-derived name>",
410
+ "tagline": "<tagline or Digital signage>",
296
411
  "targetPlatforms": ["tizen"],
297
412
  "primaryColor": "#FF8A3D",
298
413
  "secondaryColor": "#F5F3EF",
@@ -335,15 +450,18 @@ Then:
335
450
  2. Apply Vercel-adapted `server.ts` (**export `listen()` HTTP server**).
336
451
  3. Add/adjust `vercel.json` with **`fluid: true`** (or enable Fluid in the dashboard).
337
452
  4. Do **not** add `.replit*`.
453
+ 5. If Q2 = Vercel Blob: install `@vercel/blob` and wire the upload bridge.
338
454
 
339
455
  ### B. Environment Variables (Vercel dashboard + local)
340
456
 
341
457
  | Name | Required | Notes |
342
458
  |------|----------|--------|
343
- | `SUPABASE_URL` | Yes (production) | **Session pooler** `:6543` |
344
- | `TOMORROWOS_STORE` | Yes | `supabase` |
459
+ | `SUPABASE_URL` | If Q1 = Supabase | **Session pooler** `:6543` |
460
+ | `DATABASE_URL` | If Q1 = Neon | **Pooled** Neon string |
461
+ | `TOMORROWOS_STORE` | Yes | `supabase`, `postgres`, or `sqlite` |
345
462
  | `DATABASE_SSL` | Yes (typical) | `true` |
346
463
  | `CLOUDINARY_*` | If Q2 = Cloudinary | Three keys |
464
+ | `BLOB_READ_WRITE_TOKEN` | If Q2 = Vercel Blob | From Vercel Blob store |
347
465
  | `PORT` | Optional | Vercel injects as needed |
348
466
 
349
467
  Mirror non-secrets in `.env` for local `npm run dev` if helpful; never commit secrets.
@@ -376,8 +494,8 @@ Tell the user:
376
494
  **Do:**
377
495
 
378
496
  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
497
+ 2. Server status: Media OK when Cloudinary or Blob configured
498
+ 3. Database: if ERROR shows **`ENETUNREACH` … IPv6 … `:5432`**, switch to **pooled** URL (Supabase `:6543` or Neon pooler) and redeploy — call this out explicitly to the user
381
499
  4. Fluid compute on
382
500
 
383
501
  **Do not block** on long WebSocket/device pairing tests unless the user asks.
@@ -388,13 +506,15 @@ Tell the user:
388
506
 
389
507
  | Symptom | Likely cause | Fix |
390
508
  |---------|--------------|-----|
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 |
509
+ | `ENETUNREACH` / IPv6 / `:5432` | Direct Postgres URL | Use **pooled** URL (Supabase `:6543` or Neon pooler) |
510
+ | Control Panel OK, devices never stay paired after restart | SQLite / ephemeral disk | Use Supabase or Neon |
511
+ | Uploads break / broken thumbs in prod | Local `public/uploads` | Cloudinary or Vercel Blob |
512
+ | Blob uploads 401 / missing token | Blob not linked | Vercel Storage → Blob + `BLOB_READ_WRITE_TOKEN` |
394
513
  | WebSocket fails / devices drop immediately | Fluid off / static-only / Next wrapper | Enable Fluid; sole Node `listen()` server |
395
514
  | Agent created Next.js + TomorrowOS | Wrong scaffold | Remove Next; keep cms-starter |
396
515
  | Secrets in git | Mistake | Rotate keys; move to Vercel Env Vars |
397
516
  | Replit files present | Copied wrong protocol | Delete `.replit*`; use this file |
517
+ | Brand colours wrong from URL | SPA / blocked fetch | Manual Option B for colours + logo |
398
518
 
399
519
  ---
400
520
 
@@ -405,10 +525,10 @@ Tell the user:
405
525
  | Secrets | Replit Secrets | Vercel Environment Variables |
406
526
  | Deploy config | `.replit`, artifact `kind=web` | `vercel.json` + Fluid; **no** `.replit` |
407
527
  | `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 |
528
+ | Database | Supabase (primary) | Supabase **or Neon** (pooled); SQLite demo only |
529
+ | Media | Cloudinary or Replit Object Storage | Cloudinary **or Vercel Blob** |
411
530
  | WebSocket | Long-lived Node (Autoscale) | Fluid compute + Node server export |
531
+ | Brand from URL | Not in Replit protocol | **Option A** in Question 3 — infer from website |
412
532
 
413
533
  ---
414
534
 
@@ -418,10 +538,10 @@ After setup, report:
418
538
 
419
539
  1. **Live URL** (Production)
420
540
  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)
541
+ 3. **Store:** Supabase pooler / Neon pooled / SQLite (with warning)
542
+ 4. **Media:** Cloudinary, Vercel Blob, or local (with warning)
543
+ 5. **Brand:** name + `hostingTarget: vercel` (+ note if derived from website URL)
544
+ 6. **Adaptations from Replit protocol:** list (e.g. skipped `.replit`, exported HTTP server, Fluid, pooler URL, Blob bridge)
425
545
  7. **Open action:** if Database still ERROR, paste the pooler instruction again
426
546
 
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.
547
+ 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 pooled 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.20",
3
+ "version": "0.9.22",
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.20",
3
+ "version": "0.9.22",
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.20",
16
+ "@tomorrowos/sdk": "^0.9.22",
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>
@@ -1102,7 +1102,7 @@ function appendHardwareTimerStatusRow(meta, device) {
1102
1102
  row.className = "device-meta-row device-meta-row--timer-status";
1103
1103
 
1104
1104
  const dt = document.createElement("dt");
1105
- dt.textContent = "Hardware Timer Status";
1105
+ dt.textContent = "Hardware timer status";
1106
1106
 
1107
1107
  const dd = document.createElement("dd");
1108
1108
  const statusWrap = document.createElement("span");
@@ -1895,11 +1895,17 @@ function openOnOffTimerModal(deviceId) {
1895
1895
  const modal = document.getElementById("onOffTimerModal");
1896
1896
  const onEl = document.getElementById("onOffTimerTurnOnAt");
1897
1897
  const offEl = document.getElementById("onOffTimerTurnOffAt");
1898
+ const removeBtn = document.getElementById("onOffTimerRemoveBtn");
1898
1899
  if (!modal || !onEl || !offEl) return;
1899
1900
 
1900
1901
  const timer = device.onOffTimer || {};
1902
+ const configured = hasConfiguredOnOffTimer(device.onOffTimer);
1901
1903
  onEl.value = normalizeTimeInputValue(timer.turnOnAt, "06:00");
1902
1904
  offEl.value = normalizeTimeInputValue(timer.turnOffAt, "18:00");
1905
+ if (removeBtn) {
1906
+ removeBtn.classList.toggle("hidden", !configured);
1907
+ removeBtn.disabled = false;
1908
+ }
1903
1909
  modal.classList.remove("hidden");
1904
1910
  }
1905
1911
 
@@ -1916,6 +1922,7 @@ async function saveOnOffTimerModal() {
1916
1922
  const onEl = document.getElementById("onOffTimerTurnOnAt");
1917
1923
  const offEl = document.getElementById("onOffTimerTurnOffAt");
1918
1924
  const saveBtn = document.getElementById("onOffTimerSaveBtn");
1925
+ const removeBtn = document.getElementById("onOffTimerRemoveBtn");
1919
1926
  if (!onEl || !offEl) return;
1920
1927
 
1921
1928
  const onOffTimer = {
@@ -1929,6 +1936,7 @@ async function saveOnOffTimerModal() {
1929
1936
  }
1930
1937
 
1931
1938
  if (saveBtn) saveBtn.disabled = true;
1939
+ if (removeBtn) removeBtn.disabled = true;
1932
1940
  try {
1933
1941
  const res = await fetch(`/device/${encodeURIComponent(deviceId)}/on-off-timer`, {
1934
1942
  method: "POST",
@@ -1947,6 +1955,43 @@ async function saveOnOffTimerModal() {
1947
1955
  alert(err?.message || "Could not save on/off timer");
1948
1956
  } finally {
1949
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;
1950
1995
  }
1951
1996
  }
1952
1997
 
@@ -2275,6 +2320,9 @@ document.addEventListener("DOMContentLoaded", () => {
2275
2320
  document
2276
2321
  .getElementById("onOffTimerSaveBtn")
2277
2322
  ?.addEventListener("click", () => void saveOnOffTimerModal());
2323
+ document
2324
+ .getElementById("onOffTimerRemoveBtn")
2325
+ ?.addEventListener("click", () => void removeOnOffTimerModal());
2278
2326
  document.querySelectorAll("[data-close-download-failed-modal]").forEach((el) => {
2279
2327
  el.addEventListener("click", closeDownloadFailedModal);
2280
2328
  });