@simon-says-build/ssb-overlay 0.1.0 → 0.5.1

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.
Binary file
package/index.d.ts CHANGED
@@ -33,6 +33,12 @@ export function SsbHost(props: {
33
33
  addNotificationResponseReceivedListener(fn: (r: unknown) => void): { remove(): void };
34
34
  getLastNotificationResponseAsync?(): Promise<unknown>;
35
35
  };
36
+ /**
37
+ * 🎙 Talk: SSB by voice, which can draft and start jobs. The app's own LiveKit client:
38
+ * `{ Room }` from livekit-client, and @livekit/react-native's AudioSession start/stop.
39
+ * Without it there is no Talk button.
40
+ */
41
+ voice?: { Room: new (opts?: object) => any; startAudio?: () => Promise<void> | void; stopAudio?: () => Promise<void> | void };
36
42
  /** Keep the button off the home indicator / tab bar. Default {top: 60, bottom: 40}. */
37
43
  insets?: { top?: number; bottom?: number };
38
44
  children?: ReactNode;
package/index.js CHANGED
@@ -1,2 +1,2 @@
1
1
  export { SsbHost } from './src/SsbHost.js';
2
- export { overlayConfig, inAppApi, markOf, sendBody, ssbLinkOf } from './src/core.js';
2
+ export { overlayConfig, inAppApi, drawingMark, sendBody, ssbLinkOf } from './src/core.js';
@@ -0,0 +1,26 @@
1
+ #!/usr/bin/env node
2
+ // npx @simon-says-build/ssb-overlay install <appDir> --app-id <id> [--no-install] [--json]
3
+ // (install/install.mjs; docs/IN-APP-SSB.md "Installing it in an app")
4
+ import path from 'node:path';
5
+ import { install } from './install.mjs';
6
+
7
+ const args = process.argv.slice(2);
8
+ const opt = (k) => { const i = args.indexOf(k); return i >= 0 ? args[i + 1] : undefined; };
9
+ if (args[0] !== 'install') {
10
+ console.error('usage: ssb-overlay install <appDir> --app-id <ssb app id> [--no-install] [--json]');
11
+ process.exit(64);
12
+ }
13
+ const dir = path.resolve(args[1] && !args[1].startsWith('--') ? args[1] : '.');
14
+ try {
15
+ const r = install(dir, { appId: opt('--app-id'), npmInstall: !args.includes('--no-install') });
16
+ if (args.includes('--json')) console.log(JSON.stringify(r, null, 2));
17
+ else {
18
+ for (const d of r.done) console.log(`✓ ${d}`);
19
+ for (const t of r.todo) console.log(`! ${t}`);
20
+ console.log(`SSB settings for this app (inAppSsb): ${JSON.stringify(r.settings)}`);
21
+ }
22
+ process.exit(0);
23
+ } catch (e) {
24
+ console.error(`ssb-overlay install: ${e.message}`);
25
+ process.exit(1);
26
+ }
@@ -0,0 +1,343 @@
1
+ // INSTALL SSB IN AN APP (docs/IN-APP-SSB.md "Installing it in an app",
2
+ // 2026-10-20, the product owner: "we just need SSB to reliably be able to
3
+ // install the package").
4
+ //
5
+ // npx @simon-says-build/ssb-overlay install <appDir> --app-id <ssb app id> [--no-install] [--json]
6
+ //
7
+ // Reads the app's facts off its repo, adds the packages, writes ONE component
8
+ // (`SsbOverlay`) wired to what the app already has, wraps the root layout in
9
+ // it, and says what SSB should record about the app (`inAppSsb`). Anything it
10
+ // cannot do safely it does NOT guess: it says so by name (`todo[]`), so the
11
+ // job that ran it — or a person — finishes that part. Never throws on a fact
12
+ // it cannot find; exits non-zero only when it changed nothing it was asked to.
13
+ import fs from 'node:fs';
14
+ import path from 'node:path';
15
+ import { execFileSync } from 'node:child_process';
16
+
17
+ const read = (p) => { try { return fs.readFileSync(p, 'utf8'); } catch { return null; } };
18
+ const json = (p) => { try { return JSON.parse(fs.readFileSync(p, 'utf8')); } catch { return null; } };
19
+ const exists = (p) => fs.existsSync(p);
20
+
21
+ /** The packages an install adds (react-native-view-shot through `expo install`, so its version fits the SDK). */
22
+ export const PACKAGES = ['@simon-says-build/ssb-overlay', '@simon-says-build/demo-player', 'react-native-view-shot'];
23
+
24
+ // ---- what the app is ----------------------------------------------------------
25
+ /** Every fact the install decides by, read off the repo. Pure apart from reading files. */
26
+ export function detect(dir) {
27
+ const pkg = json(path.join(dir, 'package.json')) || {};
28
+ const deps = { ...(pkg.dependencies || {}), ...(pkg.devDependencies || {}) };
29
+ const has = (n) => Object.prototype.hasOwnProperty.call(deps, n);
30
+ const ts = exists(path.join(dir, 'tsconfig.json'));
31
+ const layout = ['app/_layout.tsx', 'app/_layout.jsx', 'app/_layout.js', 'src/app/_layout.tsx', 'src/app/_layout.js']
32
+ .map((p) => path.join(dir, p)).find(exists) || null;
33
+
34
+ // How people sign in: the app's own backend, which SSB checks their token against.
35
+ let auth = null, backend = null, firebaseProject = null, supabaseClient = null;
36
+ if (has('@react-native-firebase/auth')) auth = 'rn-firebase';
37
+ else if (has('firebase')) auth = 'firebase-js';
38
+ else if (has('@supabase/supabase-js')) auth = 'supabase';
39
+ if (auth === 'rn-firebase' || auth === 'firebase-js') {
40
+ firebaseProject = firebaseProjectOf(dir);
41
+ backend = firebaseProject ? `firebase:${firebaseProject}` : null;
42
+ }
43
+ if (auth === 'supabase') {
44
+ supabaseClient = supabaseClientOf(dir);
45
+ backend = 'process.env.EXPO_PUBLIC_SUPABASE_URL';
46
+ }
47
+ // The claim the app's own code marks its admins with (`claims.admin`, `admin?: boolean`).
48
+ const adminClaim = auth && auth !== 'supabase' && grepSource(dir, /\bclaims\??\.admin\b|\badmin\?\s*:\s*boolean/) ? 'admin' : null;
49
+
50
+ return {
51
+ dir, ts, layout,
52
+ expo: deps.expo || null,
53
+ router: has('expo-router'),
54
+ auth, backend, firebaseProject, supabaseClient, adminClaim,
55
+ voice: has('livekit-client') && has('@livekit/react-native'),
56
+ notifications: has('expo-notifications'),
57
+ asyncStorage: has('@react-native-async-storage/async-storage'),
58
+ constants: has('expo-constants'),
59
+ // The app already plays SSB's demos itself (Impulse wraps its root in <DemoHost>): keep its own.
60
+ demoHost: !!(layout && /<DemoHost[\s>]/.test(read(layout) || '')),
61
+ installed: PACKAGES.filter(has),
62
+ componentsDir: ['components', 'src/components'].map((p) => path.join(dir, p)).find(exists) || path.join(dir, 'components'),
63
+ };
64
+ }
65
+
66
+ export function firebaseProjectOf(dir) {
67
+ for (const p of ['GoogleService-Info.plist', 'ios/GoogleService-Info.plist']) {
68
+ const t = read(path.join(dir, p));
69
+ const m = t && /<key>PROJECT_ID<\/key>\s*<string>([^<]+)<\/string>/.exec(t);
70
+ if (m) return m[1].trim();
71
+ }
72
+ for (const p of ['google-services.json', 'android/app/google-services.json']) {
73
+ const j = json(path.join(dir, p));
74
+ if (j?.project_info?.project_id) return j.project_info.project_id;
75
+ }
76
+ return null;
77
+ }
78
+
79
+ /** The module that makes the app's Supabase client (`createClient(` … `export const supabase`), as an import path from `dir`. */
80
+ export function supabaseClientOf(dir) {
81
+ for (const f of sourceFiles(dir)) {
82
+ const t = read(f) || '';
83
+ if (/createClient\s*\(/.test(t) && /export\s+(const|let)\s+supabase\b/.test(t)) return f;
84
+ }
85
+ return null;
86
+ }
87
+
88
+ function sourceFiles(dir) {
89
+ const out = [];
90
+ const walk = (d, depth) => {
91
+ if (depth > 6) return;
92
+ let es = [];
93
+ try { es = fs.readdirSync(d, { withFileTypes: true }); } catch { return; }
94
+ for (const e of es) {
95
+ if (['node_modules', '.git', 'ios', 'android', '.expo', 'dist', 'build'].includes(e.name)) continue;
96
+ const p = path.join(d, e.name);
97
+ if (e.isDirectory()) walk(p, depth + 1);
98
+ else if (/\.(tsx?|jsx?|mjs)$/.test(e.name)) out.push(p);
99
+ }
100
+ };
101
+ walk(dir, 0);
102
+ return out;
103
+ }
104
+ const grepSource = (dir, re) => sourceFiles(dir).some((f) => re.test(read(f) || ''));
105
+
106
+ // ---- the component ------------------------------------------------------------
107
+ /** The relative import of `to` from the folder of `from`, without extension. */
108
+ export function importPath(from, to) {
109
+ let r = path.relative(path.dirname(from), to).replace(/\\/g, '/').replace(/\.(tsx?|jsx?|mjs)$/, '');
110
+ if (!r.startsWith('.')) r = `./${r}`;
111
+ return r;
112
+ }
113
+
114
+ /**
115
+ * `SsbOverlay`, written for THIS app: its backend and how its session is read,
116
+ * its LiveKit for 🎙 Talk, its expo-notifications for "ready to see", its
117
+ * storage. Returns { file, source }.
118
+ */
119
+ export function component(facts, { appId }) {
120
+ const ext = facts.ts ? 'tsx' : 'js';
121
+ const file = path.join(facts.componentsDir, `SsbOverlay.${ext}`);
122
+ const T = facts.ts;
123
+ const lines = [];
124
+ const imp = [];
125
+ imp.push(`import { SsbHost } from "@simon-says-build/ssb-overlay";`);
126
+ if (!facts.demoHost) imp.push(`import { DemoHost } from "@simon-says-build/demo-player";`);
127
+ imp.push(facts.demoHost ? `import { usePathname } from "expo-router";` : `import { router, usePathname } from "expo-router";`);
128
+ imp.push(`import ${T ? 'React, { type ReactNode, useEffect, useState }' : 'React, { useEffect, useState }'} from "react";`);
129
+ imp.push(`import { captureScreen } from "react-native-view-shot";`);
130
+ if (facts.asyncStorage) imp.push(`import AsyncStorage from "@react-native-async-storage/async-storage";`);
131
+ if (facts.notifications) imp.push(`import * as Notifications from "expo-notifications";`);
132
+ if (facts.notifications && facts.constants) imp.push(`import Constants from "expo-constants";`);
133
+ if (facts.voice) { imp.push(`import { AudioSession } from "@livekit/react-native";`); imp.push(`import { Room } from "livekit-client";`); }
134
+
135
+ // How the overlay reads the session — the app's own sign-in, never a new one.
136
+ let session;
137
+ if (facts.auth === 'rn-firebase') {
138
+ imp.push(`import { getAuth, onAuthStateChanged } from "@react-native-firebase/auth";`);
139
+ session = [
140
+ `// The signed-in user, from the app's own Firebase sign-in.`,
141
+ `function useSession()${T ? ': { uid: string | null; token: () => Promise<string | null> }' : ''} {`,
142
+ ` const [uid, setUid] = useState${T ? '<string | null>' : ''}(getAuth().currentUser?.uid ?? null);`,
143
+ ` useEffect(() => onAuthStateChanged(getAuth(), (u) => setUid(u?.uid ?? null)), []);`,
144
+ ` return { uid, token: async () => (getAuth().currentUser ? getAuth().currentUser${T ? '!' : ''}.getIdToken() : null) };`,
145
+ `}`,
146
+ ];
147
+ } else if (facts.auth === 'firebase-js') {
148
+ imp.push(`import { getAuth, onAuthStateChanged } from "firebase/auth";`);
149
+ session = [
150
+ `// The signed-in user, from the app's own Firebase sign-in.`,
151
+ `function useSession()${T ? ': { uid: string | null; token: () => Promise<string | null> }' : ''} {`,
152
+ ` const [uid, setUid] = useState${T ? '<string | null>' : ''}(getAuth().currentUser?.uid ?? null);`,
153
+ ` useEffect(() => onAuthStateChanged(getAuth(), (u) => setUid(u?.uid ?? null)), []);`,
154
+ ` return { uid, token: async () => (getAuth().currentUser ? getAuth().currentUser${T ? '!' : ''}.getIdToken() : null) };`,
155
+ `}`,
156
+ ];
157
+ } else if (facts.auth === 'supabase' && facts.supabaseClient) {
158
+ imp.push(`import { supabase } from "${importPath(file, facts.supabaseClient)}";`);
159
+ session = [
160
+ `// The signed-in user, from the app's own Supabase sign-in.`,
161
+ `function useSession()${T ? ': { uid: string | null; token: () => Promise<string | null> }' : ''} {`,
162
+ ` const [uid, setUid] = useState${T ? '<string | null>' : ''}(null);`,
163
+ ` useEffect(() => {`,
164
+ ` if (!supabase) return undefined;`,
165
+ ` supabase.auth.getSession().then(({ data }) => setUid(data.session?.user.id ?? null));`,
166
+ ` const { data } = supabase.auth.onAuthStateChange((_e, s) => setUid(s?.user.id ?? null));`,
167
+ ` return () => data.subscription.unsubscribe();`,
168
+ ` }, []);`,
169
+ ` return { uid, token: async () => (supabase ? (await supabase.auth.getSession()).data.session?.access_token ?? null : null) };`,
170
+ `}`,
171
+ ];
172
+ } else {
173
+ session = [
174
+ `// TODO(ssb): the app's sign-in could not be read off the repo. Return the`,
175
+ `// signed-in user's id and their session token from the app's own auth.`,
176
+ `function useSession()${T ? ': { uid: string | null; token: () => Promise<string | null> }' : ''} {`,
177
+ ` return { uid: null, token: async () => null };`,
178
+ `}`,
179
+ ];
180
+ }
181
+
182
+ const backend = facts.backend?.startsWith('process.env.') ? facts.backend : JSON.stringify(facts.backend || '');
183
+ lines.push(
184
+ `// SimonSays inside the app (written by \`npx @simon-says-build/ssb-overlay install\`;`,
185
+ `// docs: https://github.com/simon-says-studio/simon-says-build/blob/main/docs/IN-APP-SSB.md).`,
186
+ `//`,
187
+ `// <SsbHost> draws SSB's button ONLY for a login SimonSays lets in — checked by`,
188
+ `// SimonSays against this app's own backend with the user's own session token;`,
189
+ ...(facts.demoHost ? [`// everyone else sees nothing. The app's own demo player plays SSB's demos.`] : [
190
+ `// everyone else sees nothing. <DemoHost> plays a change's demo, and renders the`,
191
+ `// app unchanged in any build that does not carry one.`,
192
+ ]),
193
+ ...imp, ``,
194
+ `// This app in SimonSays, and where its people sign in.`,
195
+ `const SSB_APP_ID = ${JSON.stringify(appId)};`,
196
+ `const SSB_BACKEND = ${backend};`, ``,
197
+ );
198
+ if (facts.voice) {
199
+ lines.push(
200
+ `// 🎙 Talk, on the app's own LiveKit. If the app manages its audio session itself`,
201
+ `// (calls, recording), start and stop it here through that code instead.`,
202
+ `const SSB_VOICE = {`,
203
+ ` Room,`,
204
+ ` startAudio: () => AudioSession.startAudioSession(),`,
205
+ ` stopAudio: () => AudioSession.stopAudioSession(),`,
206
+ `};`, ``,
207
+ );
208
+ }
209
+ if (facts.notifications) {
210
+ lines.push(
211
+ `// The device, for "it's done, tap to see it" — asked only once SimonSays lets the`,
212
+ `// login in, and only when the app already has notification permission.`,
213
+ `async function pushToken() {`,
214
+ ` ${facts.constants ? 'const projectId = Constants.expoConfig?.extra?.eas?.projectId;' : 'const projectId = undefined;'}`,
215
+ ` if (!projectId) return null;`,
216
+ ` const { status } = await Notifications.getPermissionsAsync();`,
217
+ ` if (status !== "granted") return null;`,
218
+ ` return (await Notifications.getExpoPushTokenAsync({ projectId })).data;`,
219
+ `}`, ``,
220
+ );
221
+ }
222
+ if (!facts.demoHost) lines.push(
223
+ `// The demo player opens a step's in-app path by plain string.`,
224
+ `const DEMO_ROUTER = { push: (path${T ? ': string' : ''}) => router.push(path${T ? ' as never' : ''}) };`, ``,
225
+ );
226
+ lines.push(
227
+ ...session, ``,
228
+ `export function SsbOverlay({ children }${T ? ': { children: ReactNode }' : ''}) {`,
229
+ ` const session = useSession();`,
230
+ ` const route = usePathname();`,
231
+ ` return (`,
232
+ ` <SsbHost`,
233
+ ` appId={SSB_APP_ID}`,
234
+ ` backend={SSB_BACKEND}`,
235
+ ` getToken={session.token}`,
236
+ ` sessionKey={session.uid}`,
237
+ ` route={route}`,
238
+ ` capture={() => captureScreen({ format: "jpg", quality: 0.6, result: "data-uri" })}`,
239
+ ...(facts.asyncStorage ? [` storage={AsyncStorage}`] : []),
240
+ ...(facts.notifications ? [` pushToken={pushToken}`, ` notifications={Notifications}`] : []),
241
+ ...(facts.voice ? [` voice={SSB_VOICE}`] : []),
242
+ ` >`,
243
+ ...(facts.demoHost ? [` {children}`] : [
244
+ ` <DemoHost`,
245
+ ` router={DEMO_ROUTER}`,
246
+ ` demo={{`,
247
+ ` id: process.env.EXPO_PUBLIC_SSB_DEMO_ID,`,
248
+ ` token: process.env.EXPO_PUBLIC_SSB_DEMO_TOKEN,`,
249
+ ` api: process.env.EXPO_PUBLIC_SSB_API,`,
250
+ ` }}`,
251
+ ` >`,
252
+ ` {children}`,
253
+ ` </DemoHost>`,
254
+ ]),
255
+ ` </SsbHost>`,
256
+ ` );`,
257
+ `}`, ``,
258
+ );
259
+ return { file, source: lines.join('\n') };
260
+ }
261
+
262
+ // ---- the root layout ------------------------------------------------------------
263
+ /**
264
+ * The layout with its default export's returned JSX wrapped in <SsbOverlay>,
265
+ * or { error } when its shape is not the one this can wrap safely: an
266
+ * `export default function` whose body returns `( … )`.
267
+ */
268
+ export function wrapLayout(source, importFrom) {
269
+ if (/<SsbOverlay[\s>]/.test(source)) return { source, already: true };
270
+ const fn = /export\s+default\s+function\s+[A-Za-z0-9_]*\s*\([^)]*\)\s*(:\s*[^{]+)?\{/.exec(source);
271
+ if (!fn) return { error: 'the root layout does not `export default function` — wrap its returned JSX in <SsbOverlay> by hand' };
272
+ // The function body, by brace depth (strings and comments are rare enough in a layout's braces to accept).
273
+ const bodyStart = fn.index + fn[0].length;
274
+ let depth = 1, i = bodyStart;
275
+ for (; i < source.length && depth > 0; i++) {
276
+ if (source[i] === '{') depth++;
277
+ else if (source[i] === '}') depth--;
278
+ }
279
+ const bodyEnd = i - 1;
280
+ // The LAST `return (` at the body's own depth: the layout's main render.
281
+ let d = 0, ret = -1;
282
+ for (let j = bodyStart; j < bodyEnd; j++) {
283
+ const c = source[j];
284
+ if (c === '{') d++;
285
+ else if (c === '}') d--;
286
+ else if (d === 0 && source.startsWith('return', j) && /^return\s*\(/.test(source.slice(j, j + 20))) ret = j;
287
+ }
288
+ if (ret < 0) return { error: 'the root layout does not `return ( … )` at its top level — wrap its returned JSX in <SsbOverlay> by hand' };
289
+ const open = source.indexOf('(', ret);
290
+ let p = 1, k = open + 1;
291
+ for (; k < bodyEnd && p > 0; k++) {
292
+ if (source[k] === '(') p++;
293
+ else if (source[k] === ')') p--;
294
+ }
295
+ const close = k - 1;
296
+ const inner = source.slice(open + 1, close);
297
+ if (!inner.trim().startsWith('<')) return { error: 'the root layout returns something other than JSX — wrap it in <SsbOverlay> by hand' };
298
+ const wrapped = `${source.slice(0, open + 1)}\n <SsbOverlay>${inner.replace(/\s+$/, '')}\n </SsbOverlay>\n ${source.slice(close)}`;
299
+ // The import, after the last import line.
300
+ const imports = [...wrapped.matchAll(/^import[\s\S]*?from\s+["'][^"']+["'];?[ \t]*$/gm)];
301
+ const at = imports.length ? imports.at(-1).index + imports.at(-1)[0].length : 0;
302
+ const line = `\nimport { SsbOverlay } from "${importFrom}";`;
303
+ return { source: wrapped.slice(0, at) + line + wrapped.slice(at) };
304
+ }
305
+
306
+ // ---- what SSB records about the app ------------------------------------------------
307
+ export function settingsFor(facts) {
308
+ const s = {};
309
+ if (facts.firebaseProject) s.firebaseProjects = [facts.firebaseProject];
310
+ if (facts.adminClaim) s.adminClaims = [facts.adminClaim];
311
+ return s;
312
+ }
313
+
314
+ // ---- the install ------------------------------------------------------------------
315
+ export function install(dir, { appId, npmInstall = true, run = (cmd, args, cwd) => execFileSync(cmd, args, { cwd, stdio: 'inherit' }) } = {}) {
316
+ if (!appId) throw new Error('--app-id is required (the app\'s id in SimonSays)');
317
+ const facts = detect(dir);
318
+ const done = [], todo = [];
319
+ if (!facts.expo) todo.push('this is not an Expo app (no `expo` dependency) — the overlay is for Expo apps');
320
+ if (!facts.router) todo.push('no expo-router: pass the current route to <SsbHost route> from the app\'s own navigation');
321
+ if (!facts.backend) todo.push(facts.auth ? `could not find the ${facts.auth === 'supabase' ? 'Supabase URL' : 'Firebase project id (GoogleService-Info.plist / google-services.json)'} — set SSB_BACKEND by hand` : 'could not tell how the app signs in — fill in useSession() and SSB_BACKEND by hand');
322
+ if (facts.auth === 'supabase' && !facts.supabaseClient) todo.push('could not find the module exporting the Supabase client — fill in useSession() by hand');
323
+
324
+ if (npmInstall) {
325
+ run('npx', ['--yes', 'expo', 'install', ...PACKAGES], dir);
326
+ done.push(`added ${PACKAGES.join(', ')}`);
327
+ }
328
+
329
+ const { file, source } = component(facts, { appId });
330
+ fs.mkdirSync(path.dirname(file), { recursive: true });
331
+ fs.writeFileSync(file, source);
332
+ done.push(`wrote ${path.relative(dir, file)}`);
333
+
334
+ if (facts.layout) {
335
+ const r = wrapLayout(fs.readFileSync(facts.layout, 'utf8'), importPath(facts.layout, file));
336
+ if (r.error) todo.push(`${path.relative(dir, facts.layout)}: ${r.error}`);
337
+ else if (r.already) done.push(`${path.relative(dir, facts.layout)} already wraps <SsbOverlay>`);
338
+ else { fs.writeFileSync(facts.layout, r.source); done.push(`wrapped ${path.relative(dir, facts.layout)} in <SsbOverlay>`); }
339
+ } else todo.push('no root layout (app/_layout) — wrap the app\'s root component in <SsbOverlay> by hand');
340
+
341
+ if (facts.voice) todo.push('🎙 Talk uses @livekit/react-native\'s AudioSession; if the app manages its own audio session (calls, recording), route SSB_VOICE.startAudio/stopAudio through that code');
342
+ return { facts: { ...facts, dir: undefined }, settings: settingsFor(facts), done, todo };
343
+ }
package/package.json CHANGED
@@ -1,17 +1,32 @@
1
1
  {
2
2
  "name": "@simon-says-build/ssb-overlay",
3
- "version": "0.1.0",
3
+ "version": "0.5.1",
4
4
  "description": "The SSB button inside a native app SimonSays controls: screenshot the screen, mark it, and ask SSB for a change. Renders nothing for anyone SSB does not let in.",
5
+ "repository": {
6
+ "type": "git",
7
+ "url": "git+https://github.com/simon-says-studio/simon-says-build.git",
8
+ "directory": "packages/ssb-overlay"
9
+ },
5
10
  "type": "module",
6
11
  "main": "index.js",
7
12
  "types": "index.d.ts",
8
- "files": ["index.js", "index.d.ts", "src/", "README.md"],
13
+ "bin": {
14
+ "ssb-overlay": "install/cli.mjs"
15
+ },
16
+ "files": [
17
+ "index.js",
18
+ "index.d.ts",
19
+ "src/",
20
+ "assets/",
21
+ "install/",
22
+ "README.md"
23
+ ],
9
24
  "peerDependencies": {
10
25
  "react": ">=18",
11
26
  "react-native": ">=0.76"
12
27
  },
13
28
  "scripts": {
14
- "test": "node --test test/"
29
+ "test": "node --test test/*.test.mjs"
15
30
  },
16
31
  "license": "UNLICENSED"
17
32
  }