overlaify 0.0.0-stage → 0.1.2

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/lib/publish.js ADDED
@@ -0,0 +1,272 @@
1
+ // login / logout / whoami / publish / download: move overlays between this machine and My overlays on overlaify.com.
2
+ // The CLI token lives in ~/.overlaify/credentials.json, one per site, so a local copy of the site
3
+ // (--site http://localhost:8788) has its own. OVERLAIFY_TOKEN overrides it, for CI.
4
+ import { createServer } from 'node:http';
5
+ import { readFileSync, writeFileSync, mkdirSync, existsSync, chmodSync } from 'node:fs';
6
+ import { basename, dirname, join, resolve } from 'node:path';
7
+ import { homedir, hostname } from 'node:os';
8
+ import { randomBytes } from 'node:crypto';
9
+ import { spawn } from 'node:child_process';
10
+ import { createInterface } from 'node:readline/promises';
11
+ import { validateOverlay } from './validate.js';
12
+ import { SDK_TAG } from './commands.js';
13
+
14
+ export const DEFAULT_SITE = 'https://www.overlaify.com';
15
+ const CREDENTIALS = join(homedir(), '.overlaify/credentials.json');
16
+ const KINDS = ['alert', 'chat', 'goal', 'ticker', 'frame', 'custom'];
17
+
18
+ export function siteUrl(site) {
19
+ const raw = site || process.env.OVERLAIFY_SITE || DEFAULT_SITE;
20
+ let url;
21
+ try { url = new URL(raw); } catch { throw new Error(`Not a URL: ${raw}`); }
22
+ return url.origin;
23
+ }
24
+
25
+ const readCredentials = () => {
26
+ try { return JSON.parse(readFileSync(CREDENTIALS, 'utf8')); } catch { return {}; }
27
+ };
28
+
29
+ function writeCredentials(all) {
30
+ mkdirSync(dirname(CREDENTIALS), { recursive: true });
31
+ writeFileSync(CREDENTIALS, JSON.stringify(all, null, 2) + '\n', { mode: 0o600 });
32
+ chmodSync(CREDENTIALS, 0o600);
33
+ }
34
+
35
+ const tokenFor = (site) => process.env.OVERLAIFY_TOKEN || readCredentials()[site]?.token || null;
36
+
37
+ async function api(site, path, { method = 'GET', body, token = tokenFor(site) } = {}) {
38
+ const headers = { accept: 'application/json' };
39
+ if (token) headers.authorization = `Bearer ${token}`;
40
+ if (body) headers['content-type'] = 'application/json';
41
+ let res;
42
+ try {
43
+ res = await fetch(site + path, { method, headers, body: body && JSON.stringify(body) });
44
+ } catch (err) {
45
+ throw new Error(`Could not reach ${site} (${err.cause?.code || err.message}).`);
46
+ }
47
+ const data = await res.json().catch(() => ({}));
48
+ if (!res.ok) {
49
+ const e = new Error(data.error || `${method} ${path} failed (${res.status}).`);
50
+ e.status = res.status;
51
+ e.code = data.code;
52
+ throw e;
53
+ }
54
+ return data;
55
+ }
56
+
57
+ /** The signed-in user for a site, or null. */
58
+ async function me(site, token) {
59
+ const { user } = await api(site, '/api/me', { token });
60
+ return user;
61
+ }
62
+
63
+ function openBrowser(url) {
64
+ const cmd = process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'cmd' : 'xdg-open';
65
+ const args = process.platform === 'win32' ? ['/c', 'start', '', url] : [url];
66
+ try {
67
+ spawn(cmd, args, { stdio: 'ignore', detached: true }).on('error', () => {}).unref();
68
+ } catch {}
69
+ }
70
+
71
+ async function ask(question) {
72
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
73
+ try { return (await rl.question(question)).trim(); } finally { rl.close(); }
74
+ }
75
+
76
+ /**
77
+ * overlaify login [--site url] [--token t] [--no-browser]
78
+ * Opens <site>/cli, where the signed-in user allows the CLI; the page hands the token back to a one-off
79
+ * server on 127.0.0.1. With --no-browser (or over SSH) the page shows the token to paste instead.
80
+ */
81
+ export async function login({ site, token, browser = true, log = console.log } = {}) {
82
+ site = siteUrl(site);
83
+ if (!token) token = browser ? await tokenFromBrowser(site, log) : await tokenFromPaste(site, log);
84
+ token = token.trim();
85
+ const user = await me(site, token).catch(() => null);
86
+ if (!user) throw new Error('That token was not accepted. Run overlaify login again.');
87
+ const all = readCredentials();
88
+ all[site] = { token, user: { id: user.id, name: user.name } };
89
+ writeCredentials(all);
90
+ return { site, user };
91
+ }
92
+
93
+ function tokenFromBrowser(site, log) {
94
+ const state = randomBytes(16).toString('hex');
95
+ return new Promise((resolveToken, reject) => {
96
+ const server = createServer((req, res) => {
97
+ const url = new URL(req.url, 'http://127.0.0.1');
98
+ if (url.pathname !== '/callback') { res.writeHead(404).end(); return; }
99
+ const token = url.searchParams.get('token');
100
+ if (url.searchParams.get('state') !== state || !/^ovl_[0-9a-f]{64}$/.test(token || '')) {
101
+ res.writeHead(400, { 'content-type': 'text/plain' }).end('This sign-in link does not match. Run overlaify login again.');
102
+ return;
103
+ }
104
+ res.writeHead(302, { location: `${site}/my` }).end();
105
+ clearTimeout(timer);
106
+ server.close();
107
+ resolveToken(token);
108
+ });
109
+ const timer = setTimeout(() => { server.close(); reject(new Error('Timed out waiting for the browser. Run overlaify login again, or use --no-browser.')); }, 5 * 60 * 1000);
110
+ server.on('error', reject);
111
+ server.listen(0, '127.0.0.1', () => {
112
+ const q = new URLSearchParams({ port: String(server.address().port), state, name: hostname() });
113
+ const url = `${site}/cli?${q}`;
114
+ log(`Opening ${url}\nAllow the CLI there (sign in first if asked). Waiting…`);
115
+ openBrowser(url);
116
+ });
117
+ });
118
+ }
119
+
120
+ async function tokenFromPaste(site, log) {
121
+ if (!process.stdin.isTTY) throw new Error('Pass the token with --token, or set OVERLAIFY_TOKEN.');
122
+ log(`Open ${site}/cli in a browser, allow the CLI and copy the token it shows.`);
123
+ return ask('Token: ');
124
+ }
125
+
126
+ /** overlaify logout [--site url] — revokes the token on the site and forgets it here. */
127
+ export async function logout({ site } = {}) {
128
+ site = siteUrl(site);
129
+ const all = readCredentials();
130
+ if (!all[site]) return false;
131
+ await api(site, '/api/cli/token', { method: 'DELETE', token: all[site].token }).catch(() => {});
132
+ delete all[site];
133
+ writeCredentials(all);
134
+ return true;
135
+ }
136
+
137
+ /** overlaify whoami [--site url] → the user, or null when not logged in. */
138
+ export async function whoami({ site } = {}) {
139
+ site = siteUrl(site);
140
+ if (!tokenFor(site)) return null;
141
+ return me(site);
142
+ }
143
+
144
+ // Where a file was published: .overlaify/<name>.publish.json → { [site]: { projectId, id, url } },
145
+ // next to the settings `overlaify dev` keeps in .overlaify/<name>.json.
146
+ const stateFile = (file, ext) => join(dirname(file), '.overlaify', basename(file).replace(/\.html?$/i, '') + ext);
147
+ const readJsonFile = (f) => {
148
+ try { return JSON.parse(readFileSync(f, 'utf8')); } catch { return null; }
149
+ };
150
+
151
+ const NAME_RE = /^[a-z0-9](?:[a-z0-9-]{0,58}[a-z0-9])?$/;
152
+ const NAME_RULE = 'must be 1–60 lowercase letters, digits or dashes, not at the start or end.';
153
+
154
+ // The file name as an overlay name: my_Alert v2.html → my-alert-v2. Empty when nothing usable is left.
155
+ const nameOf = (file) =>
156
+ basename(file).replace(/\.html?$/i, '').toLowerCase().replace(/[^a-z0-9]+/g, '-').slice(0, 60).replace(/^-+|-+$/g, '');
157
+
158
+ const titleOf = (html, file) =>
159
+ (/<title[^>]*>([^<]*)<\/title>/i.exec(html)?.[1] || '').replace(/\s+/g, ' ').trim() || basename(file).replace(/\.html?$/i, '');
160
+
161
+ /**
162
+ * overlaify publish [file] [--public | --private] [--name n] [--title t] [--kind k] [--new] [--site url]
163
+ * Uploads the overlay to My overlays. The first publish of a file asks public or private; publishing it
164
+ * again adds a new version to the same overlay (its OBS URL changes to the new version's) unless --new.
165
+ * `name` is its address, overlaify.com/<handle>/<name>; a new overlay is otherwise named after its file
166
+ * (asked at a terminal, with the file name as the default), or after its title when the file name has no usable letters.
167
+ */
168
+ export async function publish(file, { site, visibility, name, title, kind, fresh, log = console.log } = {}) {
169
+ site = siteUrl(site);
170
+ file = resolve(file);
171
+ if (!existsSync(file)) throw new Error(`No such file: ${file}`);
172
+ if (kind && !KINDS.includes(kind)) throw new Error(`--kind must be one of ${KINDS.join(', ')}.`);
173
+ if (name != null && !NAME_RE.test(name)) throw new Error(`--name ${NAME_RULE}`);
174
+ if (!tokenFor(site)) throw new Error(`Not logged in to ${site}. Run: overlaify login${site === DEFAULT_SITE ? '' : ` --site ${site}`}`);
175
+
176
+ const html = readFileSync(file, 'utf8');
177
+ const check = validateOverlay(html);
178
+ if (!check.ok) {
179
+ throw new Error(`${basename(file)} doesn't pass the spec. Fix these first (overlaify validate ${basename(file)}):\n` +
180
+ check.errors.map((i) => ` [${i.code}] ${i.message}`).join('\n'));
181
+ }
182
+
183
+ const published = readJsonFile(stateFile(file, '.publish.json')) || {};
184
+ const previous = fresh ? null : published[site];
185
+ if (!visibility && !previous) {
186
+ if (!process.stdin.isTTY) throw new Error('Choose who can see it: --public or --private.');
187
+ log('Who can open this overlay on Overlaify?\n 1) Private — only you (its OBS URL still works)\n 2) Public — anyone with a link');
188
+ const a = (await ask('Choose 1 or 2 [1]: ')).toLowerCase();
189
+ visibility = a === '2' || a.startsWith('pub') ? 'public' : a === '' || a === '1' || a.startsWith('pri') ? 'private' : null;
190
+ if (!visibility) throw new Error('Publishing cancelled.');
191
+ }
192
+ // A new overlay is named after its file unless --name says otherwise; at a terminal the name can be changed.
193
+ if (name == null && !previous) {
194
+ const fallback = nameOf(file);
195
+ if (process.stdin.isTTY) {
196
+ const a = (await ask(`Name, its address overlaify.com/<you>/<name>${fallback ? ` [${fallback}]` : ''}: `)).toLowerCase();
197
+ name = a || fallback || undefined;
198
+ if (name && !NAME_RE.test(name)) throw new Error(`The name ${NAME_RULE}`);
199
+ } else {
200
+ name = fallback || undefined;
201
+ }
202
+ }
203
+
204
+ const body = { html, title: title || titleOf(html, file), name, kind, visibility, config: readJsonFile(stateFile(file, '.json')) || undefined };
205
+ let out;
206
+ try {
207
+ out = await api(site, '/api/my/overlays/publish', { method: 'POST', body: { ...body, projectId: previous?.projectId } });
208
+ } catch (err) {
209
+ // The overlay was deleted on the site: publish it as a new one, which needs a visibility.
210
+ if (err.code !== 'project_not_found' || !previous) throw err;
211
+ log(`The overlay published from ${basename(file)} is no longer in your account; publishing it as a new one.`);
212
+ out = await api(site, '/api/my/overlays/publish', { method: 'POST', body: { ...body, visibility: visibility || 'private' } });
213
+ }
214
+
215
+ published[site] = { projectId: out.projectId, id: out.id, url: out.url };
216
+ mkdirSync(dirname(stateFile(file, '.publish.json')), { recursive: true });
217
+ writeFileSync(stateFile(file, '.publish.json'), JSON.stringify(published, null, 2) + '\n');
218
+ return out;
219
+ }
220
+
221
+ // An overlay on the site: handle/name, @handle/name, or its page URL (whose origin is then the site).
222
+ function parseRef(ref, site) {
223
+ let path = String(ref || '');
224
+ if (/^https?:\/\//i.test(path)) {
225
+ const url = new URL(path);
226
+ site ||= url.origin;
227
+ path = url.pathname;
228
+ }
229
+ const m = /^\/?@?([a-z0-9_-]+)\/([a-z0-9-]+)\/?$/i.exec(path);
230
+ if (!m) throw new Error('Name an overlay as <user>/<name>, e.g. overlaify download alice/neon-alert (or paste its page URL).');
231
+ return { site: siteUrl(site), handle: m[1].toLowerCase(), name: m[2].toLowerCase() };
232
+ }
233
+
234
+ /**
235
+ * overlaify download <user>/<name> [file] [--version n] [--force] [--site url]
236
+ * Saves an overlay from overlaify.com/<user>/<name> as a local file (default <name>.html), with the SDK tag
237
+ * put back so it runs on its own. A private overlay needs its owner to be logged in. For your own overlay the
238
+ * saved settings come along for `overlaify dev`, and `overlaify publish` then adds versions to that overlay.
239
+ */
240
+ export async function download(ref, file, { site, version, force } = {}) {
241
+ const { site: origin, handle, name } = parseRef(ref, site);
242
+ site = origin;
243
+ if (version != null && !/^[1-9]\d*$/.test(String(version))) throw new Error('--version must be a version number: 1 is the first.');
244
+ let out;
245
+ try {
246
+ out = await api(site, `/api/u/${handle}/${name}${version != null ? `?version=${version}` : ''}`);
247
+ } catch (err) {
248
+ if (err.status === 401) {
249
+ const how = `overlaify login${site === DEFAULT_SITE ? '' : ` --site ${site}`}`;
250
+ throw new Error(`${handle}/${name} is private. ${tokenFor(site) ? 'Your login has expired; log in again' : 'If it is yours, log in first'}: ${how}`);
251
+ }
252
+ throw err;
253
+ }
254
+ if (typeof out.html !== 'string') throw new Error(`${site} doesn't support downloads yet (no /api/u/${handle}/${name}).`);
255
+
256
+ file = resolve(file || `${name}.html`);
257
+ if (existsSync(file) && !force) throw new Error(`${basename(file)} already exists (use --force to overwrite).`);
258
+ const html = /<head[^>]*>/i.test(out.html)
259
+ ? out.html.replace(/<head[^>]*>/i, (m) => `${m}\n${SDK_TAG}`)
260
+ : `${SDK_TAG}\n${out.html}`;
261
+ mkdirSync(dirname(file), { recursive: true });
262
+ writeFileSync(file, html);
263
+
264
+ if (out.owned) {
265
+ mkdirSync(dirname(stateFile(file, '.json')), { recursive: true });
266
+ if (out.config && Object.keys(out.config).length) writeFileSync(stateFile(file, '.json'), JSON.stringify(out.config, null, 2) + '\n');
267
+ const published = readJsonFile(stateFile(file, '.publish.json')) || {};
268
+ published[site] = { projectId: out.projectId, id: out.id, url: `${site}/o/${out.id}` };
269
+ writeFileSync(stateFile(file, '.publish.json'), JSON.stringify(published, null, 2) + '\n');
270
+ }
271
+ return { ...out, file, page: `${site}/${handle}/${name}` };
272
+ }
@@ -0,0 +1,51 @@
1
+ <!doctype html>
2
+ <html>
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <title>My overlay</title>
6
+ <script src="https://www.overlaify.com/sdk/v1/overlaify.js"></script>
7
+ <!-- Follows Overlay Spec v1; check it with: npx overlaify validate <file> -->
8
+ <script type="application/json" id="overlaify-manifest">
9
+ {
10
+ "spec": 1,
11
+ "settings": [
12
+ { "key": "color", "label": "Accent color", "type": "color", "default": "#c6ff3d", "swatches": ["#c6ff3d", "#ff4f8b", "#5ce1ff", "#ffb547", "#b48bff"] },
13
+ { "key": "duration", "label": "On screen", "type": "range", "min": 2, "max": 15, "step": 0.5, "unit": "s", "default": 5 }
14
+ ],
15
+ "actions": [
16
+ { "name": "test", "label": "Show a test alert" }
17
+ ]
18
+ }
19
+ </script>
20
+ <style>
21
+ html, body { margin: 0; width: 1920px; height: 1080px; overflow: hidden; background: transparent; }
22
+ #card { position: absolute; left: 50%; top: 120px; transform: translate(-50%, -30px); opacity: 0; transition: transform .45s, opacity .45s;
23
+ padding: 28px 48px; border-radius: 20px; background: rgba(11, 10, 16, .85); border: 3px solid var(--c); box-shadow: 0 0 40px var(--c);
24
+ color: #fff; font: 800 54px/1.1 system-ui, sans-serif; text-align: center; text-shadow: 0 3px 10px rgba(0, 0, 0, .6); }
25
+ #card.on { opacity: 1; transform: translate(-50%, 0); }
26
+ #card small { display: block; font-size: 24px; font-weight: 600; letter-spacing: .08em; text-transform: uppercase; color: var(--c); }
27
+ </style>
28
+ </head>
29
+ <body>
30
+ <div id="card"><small id="kicker"></small><span id="name"></span></div>
31
+ <script>
32
+ const card = document.getElementById('card');
33
+ document.documentElement.style.setProperty('--c', Overlaify.param('color', '#c6ff3d'));
34
+ const DURATION = Overlaify.param('duration', 5) * 1000;
35
+
36
+ const WORDS = { follow: 'New follower', sub: 'New subscriber', gift: 'Gifted a sub', giftbomb: 'Gift bomb', tip: 'Tipped', raid: 'Raid' };
37
+ const show = Overlaify.queue(async (e) => {
38
+ document.getElementById('kicker').textContent = WORDS[e.type] || e.type;
39
+ document.getElementById('name').textContent = e.type === 'tip' ? `${e.name} · ${e.display}` : e.name;
40
+ card.classList.add('on');
41
+ Overlaify.chime('alert');
42
+ await Overlaify.sleep(DURATION);
43
+ card.classList.remove('on');
44
+ await Overlaify.sleep(500);
45
+ });
46
+
47
+ Object.keys(WORDS).forEach((type) => Overlaify.on(type, show));
48
+ Overlaify.action('test', () => Overlaify.simulate('sub'));
49
+ </script>
50
+ </body>
51
+ </html>