create-pyric 0.1.0-alpha.22 → 0.1.0-alpha.25

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.
Files changed (47) hide show
  1. package/dist/bin.js +5 -1
  2. package/dist/bin.js.map +1 -1
  3. package/dist/firebase-pins.d.ts +16 -0
  4. package/dist/firebase-pins.d.ts.map +1 -0
  5. package/dist/firebase-pins.js +16 -0
  6. package/dist/firebase-pins.js.map +1 -0
  7. package/dist/package-manager.d.ts +18 -0
  8. package/dist/package-manager.d.ts.map +1 -0
  9. package/dist/package-manager.js +33 -0
  10. package/dist/package-manager.js.map +1 -0
  11. package/dist/scaffold.d.ts +3 -3
  12. package/dist/scaffold.d.ts.map +1 -1
  13. package/dist/scaffold.js +3 -3
  14. package/dist/scaffold.js.map +1 -1
  15. package/dist/template-nextjs.d.ts +0 -3
  16. package/dist/template-nextjs.d.ts.map +1 -1
  17. package/dist/template-nextjs.js +6 -2
  18. package/dist/template-nextjs.js.map +1 -1
  19. package/dist/templates.d.ts.map +1 -1
  20. package/dist/templates.js +68 -19
  21. package/dist/templates.js.map +1 -1
  22. package/package.json +1 -1
  23. package/src/bin.ts +5 -1
  24. package/src/firebase-pins.ts +15 -0
  25. package/src/package-manager.ts +38 -0
  26. package/src/scaffold.ts +5 -5
  27. package/src/template-nextjs.ts +3 -2
  28. package/src/templates.ts +68 -19
  29. package/templates/chat/.env.development +6 -0
  30. package/templates/chat/database.rules.json +4 -4
  31. package/templates/chat/functions/package.json +1 -1
  32. package/templates/chat/package.json +4 -2
  33. package/templates/chat/scaffold.json +1 -0
  34. package/templates/chat/scripts/check-listener-owner-build.ts +73 -0
  35. package/templates/chat/src/services/conversation-service.ts +6 -5
  36. package/templates/chat/src/services/firestore-helpers.ts +18 -0
  37. package/templates/chat/src/services/message-service.ts +4 -3
  38. package/templates/chat/src/services/presence-service.ts +4 -4
  39. package/templates/chat/src/ui/chat/chat-format.ts +8 -0
  40. package/templates/chat/src/ui/chat/chat-page.tsx +29 -126
  41. package/templates/chat/src/ui/chat/chat-types.ts +16 -4
  42. package/templates/chat/src/ui/chat/conversation-header.tsx +26 -0
  43. package/templates/chat/src/ui/chat/conversation-list.tsx +38 -0
  44. package/templates/chat/src/ui/chat/firebase-chat-gateway.ts +4 -4
  45. package/templates/chat/src/ui/chat/message-thread.tsx +156 -0
  46. package/templates/chat/src/ui/chat/presence-bar.tsx +23 -0
  47. package/templates/chat/vite.config.ts +6 -2
@@ -0,0 +1,38 @@
1
+ /**
2
+ * The package manager that ran `create pyric`, so the printed next steps
3
+ * name the commands the user already uses.
4
+ */
5
+
6
+ export type PackageManager = 'npm' | 'pnpm' | 'yarn' | 'bun';
7
+
8
+ const PACKAGE_MANAGERS: readonly PackageManager[] = ['npm', 'pnpm', 'yarn', 'bun'];
9
+
10
+ /** A step a template writes for one package manager, such as `bun run dev`. */
11
+ const PACKAGE_MANAGER_STEP = /^(npm|bun) /;
12
+
13
+ /** The alternative a template names after a step, such as `# or: bun install`. */
14
+ const ALTERNATIVE_COMMENT = /\s+# or: .*$/;
15
+
16
+ /**
17
+ * Reads the `npm_config_user_agent` value every package manager sets for
18
+ * `create` and `exec`, such as `pnpm/9.0.0 npm/? node/v22.15.0 linux x64`.
19
+ * Without one, the user ran the binary directly, and the docs use npm.
20
+ */
21
+ export function detectPackageManager(userAgent: string | undefined): PackageManager {
22
+ const name = userAgent?.split('/')[0];
23
+ const known = PACKAGE_MANAGERS.find((pm) => pm === name);
24
+ return known ?? 'npm';
25
+ }
26
+
27
+ /**
28
+ * Rewrites each `npm <command>` or `bun <command>` step to the same command
29
+ * for `pm`, and drops the `# or: ...` alternative, since the step now names
30
+ * the package manager in use.
31
+ */
32
+ export function nextStepsFor(steps: readonly string[], pm: PackageManager): string[] {
33
+ return steps.map((step) => {
34
+ const isPackageManagerStep = PACKAGE_MANAGER_STEP.test(step);
35
+ if (!isPackageManagerStep) return step;
36
+ return step.replace(PACKAGE_MANAGER_STEP, `${pm} `).replace(ALTERNATIVE_COMMENT, '');
37
+ });
38
+ }
package/src/scaffold.ts CHANGED
@@ -19,16 +19,16 @@ import type { FlagValue } from './parse-args.js';
19
19
  export type { ScaffoldTemplate, TemplateName };
20
20
  export { TEMPLATES, TEMPLATE_NAMES, isTemplateName } from './templates.js';
21
21
 
22
- /** Where the scaffold's `pyric` / `@pyric/cli` deps come from. */
22
+ /** Where the scaffold's `pyric` / `@pyric/cli` / `@pyric/ui` deps come from. */
23
23
  export type DepsMode = 'vendor' | 'npm';
24
24
 
25
- /** Return a copy of `t` with `pyric` / `@pyric/cli` deps rewritten for `mode`. */
25
+ /** Return a copy of `t` with `pyric` / `@pyric/cli` / `@pyric/ui` deps rewritten for `mode`. */
26
26
  export function applyDepsMode(
27
27
  t: ScaffoldTemplate,
28
28
  mode: DepsMode,
29
29
  opts: { vendorSpecs?: Record<string, string>; version?: string | null },
30
30
  ): ScaffoldTemplate {
31
- const WORKSPACE_PKGS = ['pyric', '@pyric/cli'];
31
+ const WORKSPACE_PKGS = ['pyric', '@pyric/cli', '@pyric/ui'];
32
32
  const rewrite = (section: Record<string, string>): Record<string, string> => {
33
33
  const next = { ...section };
34
34
  for (const pkg of WORKSPACE_PKGS) {
@@ -202,7 +202,7 @@ export interface ScaffoldRequest {
202
202
  depsMode?: DepsMode;
203
203
  /** Already-rewritten template; defaults to TEMPLATES[template] (+ npm pin). */
204
204
  effectiveTemplate?: ScaffoldTemplate;
205
- /** Version pin for npm-mode `@pyric/cli` / `pyric` ranges. */
205
+ /** Version pin for npm-mode `@pyric/cli` / `pyric` / `@pyric/ui` ranges. */
206
206
  pinVersion?: string | null;
207
207
  /** Label used in human report lines (`create-pyric` or `pyric init`). */
208
208
  commandLabel?: string;
@@ -281,7 +281,7 @@ export async function runScaffold(
281
281
  merged: [],
282
282
  skipped: [],
283
283
  conflicts: [],
284
- nextSteps: template.nextSteps,
284
+ nextSteps: effective.nextSteps,
285
285
  };
286
286
 
287
287
  const pkgPath = join(dir, 'package.json');
@@ -1,6 +1,7 @@
1
1
  /**
2
2
  * Scaffold template definition for Next.js (`npm create pyric -- --template nextjs`).
3
3
  */
4
+ import { FIREBASE_ADMIN_PIN, FIREBASE_PIN } from './firebase-pins.js';
4
5
  import type { ScaffoldTemplate } from './templates.js';
5
6
 
6
7
  const NEXTJS_CONFIG_MJS = `import { fileURLToPath } from 'node:url';
@@ -491,8 +492,8 @@ export const NEXTJS_TEMPLATE: ScaffoldTemplate = {
491
492
  start: 'next start',
492
493
  },
493
494
  dependencies: {
494
- firebase: '^12.12.0',
495
- 'firebase-admin': '^13.0.0',
495
+ firebase: FIREBASE_PIN,
496
+ 'firebase-admin': FIREBASE_ADMIN_PIN,
496
497
  next: '^15.0.0',
497
498
  react: '^19.0.0',
498
499
  'react-dom': '^19.0.0',
package/src/templates.ts CHANGED
@@ -19,6 +19,7 @@
19
19
  import { lstatSync, readFileSync, readdirSync } from 'node:fs';
20
20
  import { basename, dirname, isAbsolute, relative, resolve, sep } from 'node:path';
21
21
  import { fileURLToPath } from 'node:url';
22
+ import { FIREBASE_PIN } from './firebase-pins.js';
22
23
  import { NEXTJS_TEMPLATE } from './template-nextjs.js';
23
24
 
24
25
  export const TEMPLATE_NAMES = ['web', 'node', 'static', 'chat', 'nextjs'] as const;
@@ -182,8 +183,9 @@ const WEB_INDEX_HTML = (name: string): string => `<!doctype html>
182
183
  `;
183
184
 
184
185
  const WEB_APP_JS = `// Canonical firebase/* imports. Under \`pyric sandbox\` they are served by the
185
- // in-page pyric sandbox (the config below is ignored); under any standard
186
- // bundler/pipeline the same imports resolve to the real \`firebase\` package.
186
+ // in-page pyric sandbox (no request reaches the project the config names);
187
+ // under any standard bundler/pipeline the same imports resolve to the real
188
+ // \`firebase\` package.
187
189
  // Graduation changes where you run this code, never the code itself.
188
190
  import { initializeApp } from 'firebase/app';
189
191
  import {
@@ -347,7 +349,8 @@ const FIRESTORE_INDEXES = `{
347
349
  // ─── node template (init v1 scaffold, carried over) ───────────────────
348
350
 
349
351
  const NODE_APP_TS = `// Canonical Firebase imports stay unchanged between sandbox and production.
350
- // \`bun run dev\` activates @pyric/cli/register; \`bun start\` loads Firebase.
352
+ // \`bun run dev\` runs this file against the Node-hosted Pyric sandbox
353
+ // (\`pyric sandbox --hosted\`); \`bun start\` loads Firebase.
351
354
  import { initializeApp } from 'firebase/app';
352
355
  import { getFirestore, collection, getDocs } from 'firebase/firestore';
353
356
  import { seed } from './seed.ts';
@@ -371,15 +374,17 @@ snap.forEach((doc) => console.log(\` \${doc.id}:\`, doc.data()));
371
374
  // Production: fill .env, deploy firestore.rules, then \`bun start\`.
372
375
  `;
373
376
 
374
- const NODE_SEED_TS = `import { collection, addDoc, type Firestore } from 'firebase/firestore';
377
+ const NODE_SEED_TS = `import { doc, setDoc, type Firestore } from 'firebase/firestore';
375
378
 
379
+ // Fixed ids: the hosted sandbox keeps its data between runs, so each
380
+ // \`bun run dev\` rewrites the same two posts instead of adding two more.
376
381
  export async function seed(db: Firestore): Promise<void> {
377
- await addDoc(collection(db, 'posts'), {
382
+ await setDoc(doc(db, 'posts', 'hello'), {
378
383
  title: 'Hello, Pyric',
379
384
  author: 'sandbox',
380
385
  createdAt: new Date(),
381
386
  });
382
- await addDoc(collection(db, 'posts'), {
387
+ await setDoc(doc(db, 'posts', 'local-first'), {
383
388
  title: 'Local-first by default',
384
389
  author: 'sandbox',
385
390
  createdAt: new Date(),
@@ -432,7 +437,7 @@ real Firebase in production. No application-code switch is required.
432
437
 
433
438
  \`\`\`bash
434
439
  bun install
435
- bun run dev # Pyric sandbox through the Node package swap
440
+ bun run dev # Pyric sandbox hosted in Node (pyric sandbox --hosted)
436
441
  bun start # production: real Firebase
437
442
  \`\`\`
438
443
 
@@ -490,13 +495,17 @@ const VITE_INDEX_HTML = (name: string): string => `<!doctype html>
490
495
  form { display: flex; gap: 0.5rem; margin: 1rem 0; }
491
496
  input { flex: 1; padding: 0.4rem 0.6rem; }
492
497
  ul { padding-left: 1.2rem; }
493
- .status { color: #666; }
498
+ .status { color: #666; display: flex; align-items: center; gap: 0.5rem; }
499
+ .avatar { width: 32px; height: 32px; border-radius: 50%; }
494
500
  </style>
495
501
  </head>
496
502
  <body>
497
503
  <main>
498
504
  <h1>${name}</h1>
499
- <p class="status" id="auth-status">Signed out</p>
505
+ <p class="status">
506
+ <img class="avatar" id="avatar" alt="" hidden />
507
+ <span id="auth-status">Signed out</span>
508
+ </p>
500
509
  <button id="sign-in">Sign in with Google</button>
501
510
  <button id="sign-out" hidden>Sign out</button>
502
511
  <!-- Visible even while signed out ON PURPOSE — submitting attempts the
@@ -512,10 +521,11 @@ const VITE_INDEX_HTML = (name: string): string => `<!doctype html>
512
521
  </html>
513
522
  `;
514
523
 
515
- const VITE_MAIN_TS = `// Canonical firebase/* imports — UNCHANGED between dev and prod.
524
+ const VITE_MAIN_TS = `// Canonical firebase/* imports, the same in dev and prod.
516
525
  // In \`vite dev\` the \`@pyric/cli/vite\` plugin swaps these to an in-process
517
- // sandbox (the config below is accepted but ignored). \`vite build\` ships the
518
- // real \`firebase\` package and uses the SAME config. Graduation is a build, not
526
+ // sandbox: no request reaches the project the config below names, and the
527
+ // sandbox reads its projectId the way Firebase does. \`vite build\` ships the
528
+ // real \`firebase\` package with the same config. Graduation is a build, not
519
529
  // a code edit.
520
530
  import { initializeApp } from 'firebase/app';
521
531
  import {
@@ -553,7 +563,7 @@ if (hasProjectId) {
553
563
 
554
564
  const app = initializeApp({
555
565
  // Filled from .env (see .env.example) at \`vite build\` time for production.
556
- // Ignored in \`vite dev\` — the pyric sandbox stands in for Firebase.
566
+ // In \`vite dev\` the pyric sandbox stands in for Firebase and reads projectId.
557
567
  apiKey,
558
568
  authDomain,
559
569
  projectId,
@@ -564,6 +574,7 @@ const db = getFirestore(app);
564
574
  const $ = <T extends HTMLElement>(id: string): T => document.getElementById(id) as T;
565
575
  const els = {
566
576
  status: $('auth-status'),
577
+ avatar: $<HTMLImageElement>('avatar'),
567
578
  signIn: $<HTMLButtonElement>('sign-in'),
568
579
  signOut: $<HTMLButtonElement>('sign-out'),
569
580
  form: $<HTMLFormElement>('add-post'),
@@ -579,7 +590,7 @@ let unsubscribePosts: (() => void) | undefined = undefined;
579
590
  onAuthStateChanged(auth, (user) => {
580
591
  const hasActiveSubscription = unsubscribePosts !== undefined;
581
592
  if (hasActiveSubscription) {
582
- unsubscribePosts();
593
+ unsubscribePosts?.();
583
594
  unsubscribePosts = undefined;
584
595
  }
585
596
 
@@ -595,6 +606,16 @@ onAuthStateChanged(auth, (user) => {
595
606
  displayLabel = user.displayName as string;
596
607
  }
597
608
  els.status.textContent = 'Signed in as ' + displayLabel;
609
+ // Provider sign-ins always carry a photoURL, in the sandbox as in
610
+ // production; email/password and anonymous users have none.
611
+ const hasPhoto = user.photoURL !== null;
612
+ if (hasPhoto) {
613
+ els.avatar.src = user.photoURL as string;
614
+ els.avatar.hidden = false;
615
+ } else {
616
+ els.avatar.src = '';
617
+ els.avatar.hidden = true;
618
+ }
598
619
  els.signIn.hidden = true;
599
620
  els.signOut.hidden = false;
600
621
 
@@ -616,6 +637,8 @@ onAuthStateChanged(auth, (user) => {
616
637
  });
617
638
  } else {
618
639
  els.status.textContent = 'Signed out';
640
+ els.avatar.hidden = true;
641
+ els.avatar.src = '';
619
642
  els.signIn.hidden = false;
620
643
  els.signOut.hidden = true;
621
644
  els.posts.replaceChildren();
@@ -671,8 +694,34 @@ import { pyric } from '@pyric/cli/vite';
671
694
  // self-contained sandbox preview you can serve under \`pyric sandbox\`, build with a
672
695
  // non-production mode: \`vite build --mode development\` (see the \`build:sandbox\`
673
696
  // script). That output is marked and can never be deployed.
697
+
698
+ // A custom avatar generator lives in its own file so this config stays
699
+ // small. The pyric example app ships vite.avatars.ts with a worked example
700
+ // that calls an image API; write your own function of the same shape to
701
+ // use the \`avatars.source\` option below.
702
+ // import { nanoBananaAvatar } from './vite.avatars';
703
+
674
704
  export default defineConfig({
675
- plugins: [pyric()],
705
+ plugins: [
706
+ pyric({
707
+ // Profile photos for provider sign-ins. The default needs no
708
+ // configuration: every federated sign-in gets a deterministic
709
+ // generated avatar. Comment exactly one option back in to try the
710
+ // alternatives.
711
+
712
+ // Firebase-null behaviour — photoURL stays null and the avatar
713
+ // route unmounts:
714
+ // avatars: false,
715
+
716
+ // A pre-created avatar set: a directory holding a manifest.json and
717
+ // image files; each user is deterministically assigned one:
718
+ // avatars: './avatars',
719
+
720
+ // Generate each user's photo once, on demand, with the function
721
+ // named in the commented import above:
722
+ // avatars: { source: nanoBananaAvatar },
723
+ }),
724
+ ],
676
725
  });
677
726
  `;
678
727
 
@@ -777,7 +826,7 @@ export const TEMPLATES: Record<TemplateName, ScaffoldTemplate> = {
777
826
  },
778
827
  // The real firebase package ships day one so the production `vite build`
779
828
  // resolves the same canonical imports against it — no code edit at graduation.
780
- dependencies: { firebase: '^12.12.0' },
829
+ dependencies: { firebase: FIREBASE_PIN },
781
830
  devDependencies: { '@pyric/cli': '*', vite: '^6.0.0', typescript: '^5.7.0' },
782
831
  dirs: ['src'],
783
832
  files: (name) => [
@@ -802,10 +851,10 @@ export const TEMPLATES: Record<TemplateName, ScaffoldTemplate> = {
802
851
  node: {
803
852
  scripts: {
804
853
  start: 'node --env-file-if-exists=.env --experimental-strip-types src/app.ts',
805
- dev: 'pyric sandbox --no-open -- node --env-file-if-exists=.env --experimental-strip-types src/app.ts',
854
+ dev: 'pyric sandbox --hosted --no-open -- node --env-file-if-exists=.env --experimental-strip-types src/app.ts',
806
855
  bridge: 'pyric bridge',
807
856
  },
808
- dependencies: { firebase: '^12.12.0' },
857
+ dependencies: { firebase: FIREBASE_PIN },
809
858
  devDependencies: { '@pyric/cli': '*', '@types/node': '^22.0.0', typescript: '^5.7.0' },
810
859
  dirs: ['src'],
811
860
  files: (name) => [
@@ -828,7 +877,7 @@ export const TEMPLATES: Record<TemplateName, ScaffoldTemplate> = {
828
877
  dev: 'pyric sandbox --seed seed.json',
829
878
  'dev:agent': 'pyric sandbox --bridge --seed seed.json',
830
879
  },
831
- dependencies: { firebase: '^12.12.0' },
880
+ dependencies: { firebase: FIREBASE_PIN },
832
881
  devDependencies: { '@pyric/cli': '*' },
833
882
  dirs: ['public'],
834
883
  files: (name) => [
@@ -0,0 +1,6 @@
1
+ # Loaded by Vite for `--mode development`, which includes the served sandbox
2
+ # build (`npm run build:sandbox`). Vite otherwise treats every `vite build` as
3
+ # NODE_ENV=production, which turns off pyric's listener attribution and the
4
+ # React component capture behind it. Development is also what the plain
5
+ # `vite dev` path runs as, so the two sandbox paths behave the same.
6
+ NODE_ENV=development
@@ -1,16 +1,16 @@
1
1
  {
2
2
  "rules": {
3
3
  "presence": {
4
- ".read": "auth !== null",
4
+ ".read": "auth != null",
5
5
  "$uid": {
6
- ".write": "auth !== null && auth.uid === $uid",
6
+ ".write": "auth != null && auth.uid == $uid",
7
7
  ".validate": "newData.hasChildren(['state', 'at']) && newData.child('state').isString()"
8
8
  }
9
9
  },
10
10
  "notify": {
11
11
  "$uid": {
12
- ".read": "auth !== null && auth.uid === $uid",
13
- ".write": "auth !== null && auth.uid === $uid",
12
+ ".read": "auth != null && auth.uid == $uid",
13
+ ".write": "auth != null && auth.uid == $uid",
14
14
  "$pushId": {
15
15
  ".validate": "newData.hasChildren(['title', 'body']) && newData.child('title').isString() && newData.child('body').isString()"
16
16
  }
@@ -9,7 +9,7 @@
9
9
  "node": "20"
10
10
  },
11
11
  "dependencies": {
12
- "firebase-admin": "^13.10.0",
12
+ "firebase-admin": "~13.10.0",
13
13
  "firebase-functions": "^6.0.0"
14
14
  }
15
15
  }
@@ -9,6 +9,7 @@
9
9
  "build:sandbox": "vite build --mode development",
10
10
  "test": "node --experimental-strip-types --test test/*.test.*",
11
11
  "test:notifications": "npm run build && node --experimental-strip-types scripts/check-notification-build.ts",
12
+ "test:listener-owner": "node --experimental-strip-types scripts/check-listener-owner-build.ts",
12
13
  "preview": "vite preview",
13
14
  "typecheck": "tsc --noEmit",
14
15
  "rules:resolve": "pyric firestore rules resolve firestore.modules.rules --out firestore.rules"
@@ -25,7 +26,7 @@
25
26
  "class-variance-authority": "^0.7.1",
26
27
  "clsx": "^2.1.1",
27
28
  "esbuild-wasm": "^0.25.0",
28
- "firebase": "^12.12.0",
29
+ "firebase": "~12.13.0",
29
30
  "lucide-react": "^1.25.0",
30
31
  "react": "^19.2.7",
31
32
  "react-dom": "^19.2.7",
@@ -37,6 +38,7 @@
37
38
  },
38
39
  "devDependencies": {
39
40
  "@pyric/cli": "*",
41
+ "@pyric/ui": "*",
40
42
  "@tailwindcss/vite": "^4.3.3",
41
43
  "@types/react": "^19.2.17",
42
44
  "@types/react-dom": "^19.2.3",
@@ -46,7 +48,7 @@
46
48
  "typescript": "^5.7.0",
47
49
  "vite": "^6.0.0",
48
50
  "pyric": "*",
49
- "firebase-admin": "^13.10.0",
51
+ "firebase-admin": "~13.10.0",
50
52
  "firebase-functions": "^6.0.0"
51
53
  }
52
54
  }
@@ -1,5 +1,6 @@
1
1
  {
2
2
  "include": [
3
+ ".env.development",
3
4
  ".env.example",
4
5
  "README.md",
5
6
  "components.json",
@@ -0,0 +1,73 @@
1
+ /**
2
+ * Proves that listener attribution costs the production build nothing.
3
+ *
4
+ * The app calls `useListenerOwner` from `@pyric/ui/listener-owner` to name
5
+ * the React component that owns each Firestore listener. That hook gates its
6
+ * capture on `process.env.NODE_ENV`, and `@pyric/cli/vite` swaps `firebase/*`
7
+ * to pyric only outside production mode. Both claims are checked here by
8
+ * building the app twice and reading the emitted JavaScript.
9
+ *
10
+ * Two markers are read:
11
+ *
12
+ * - `pyric`, which appears throughout the sandbox implementation. A
13
+ * production bundle must not contain it anywhere.
14
+ * - the string `/node_modules/`, the frame filter the hook uses when it reads
15
+ * a render-phase call stack. It is present only when the capture survived
16
+ * the build, so it stands in for the capture itself.
17
+ *
18
+ * A production build must carry neither. A development-mode build, the one
19
+ * `npm run build:sandbox` produces for serving under `pyric sandbox`, must
20
+ * carry both, which is what makes the production result evidence of the gate
21
+ * rather than of a marker that never existed.
22
+ */
23
+ import { execFileSync } from 'node:child_process';
24
+ import { readdir, readFile } from 'node:fs/promises';
25
+ import { dirname, join } from 'node:path';
26
+ import { fileURLToPath } from 'node:url';
27
+
28
+ const PYRIC_MARKER = 'pyric';
29
+ const CAPTURE_MARKER = '/node_modules/';
30
+
31
+ const scriptDirectory = dirname(fileURLToPath(import.meta.url));
32
+ const templateDirectory = join(scriptDirectory, '..');
33
+
34
+ /** Build the app in `mode` into its own directory and return the emitted
35
+ * application JavaScript, the service worker excluded. */
36
+ async function buildAndRead(mode: string, outDirectory: string): Promise<string> {
37
+ execFileSync(
38
+ 'npx',
39
+ ['vite', 'build', '--mode', mode, '--emptyOutDir', '--outDir', outDirectory],
40
+ { cwd: templateDirectory, stdio: 'inherit' },
41
+ );
42
+ const assetsDirectory = join(templateDirectory, outDirectory, 'assets');
43
+ const names = (await readdir(assetsDirectory)).filter(
44
+ (name) => name.endsWith('.js') && !name.startsWith('firebase-messaging-sw-'),
45
+ );
46
+ if (names.length === 0) {
47
+ throw new Error(`The ${mode} build emitted no application JavaScript.`);
48
+ }
49
+ const sources = await Promise.all(
50
+ names.map((name) => readFile(join(assetsDirectory, name), 'utf8')),
51
+ );
52
+ return sources.join('\n');
53
+ }
54
+
55
+ const production = await buildAndRead('production', 'dist/listener-owner-check/production');
56
+
57
+ if (production.includes(PYRIC_MARKER)) {
58
+ throw new Error('The production bundle contains pyric.');
59
+ }
60
+ if (production.includes(CAPTURE_MARKER)) {
61
+ throw new Error('The production bundle contains the listener-owner stack capture.');
62
+ }
63
+
64
+ const development = await buildAndRead('development', 'dist/listener-owner-check/development');
65
+
66
+ if (!development.includes(PYRIC_MARKER)) {
67
+ throw new Error('The development-mode bundle does not contain pyric, so the production result proves nothing.');
68
+ }
69
+ if (!development.includes(CAPTURE_MARKER)) {
70
+ throw new Error('The development-mode bundle does not contain the listener-owner stack capture, so the production result proves nothing.');
71
+ }
72
+
73
+ console.log('Listener owner build checks passed: pyric and the stack capture are in the development build and absent from the production build.');
@@ -16,6 +16,7 @@ import {
16
16
  type Query,
17
17
  type QueryDocumentSnapshot,
18
18
  type QuerySnapshot,
19
+ type SnapshotListenOptions,
19
20
  } from 'firebase/firestore';
20
21
  import { auth, db } from '../firebase/app';
21
22
  import {
@@ -28,7 +29,7 @@ import {
28
29
  type PageOptions,
29
30
  ServiceError,
30
31
  } from '../firebase/types';
31
- import { clampPageSize, mapFirestoreError, requireUid } from './firestore-helpers';
32
+ import { clampPageSize, listenOptions, mapFirestoreError, requireUid, type ListenerOptions } from './firestore-helpers';
32
33
 
33
34
  const allowedModels = new Set(['gemini-3.5-flash', 'gemini-3.1-flash-lite', 'gemini-2.5-flash', 'gemini-2.0-flash', 'gemini-1.5-flash', 'gemini-flash-lite-latest']);
34
35
  const cleanTitle = (title: string): string => title.trim().slice(0, 120) || 'New conversation';
@@ -59,10 +60,10 @@ export class ConversationService {
59
60
  }
60
61
  }
61
62
 
62
- observeList(callback: (conversations: Conversation[]) => void, options: PageOptions = {}): () => void {
63
+ observeList(callback: (conversations: Conversation[]) => void, options: PageOptions & ListenerOptions = {}): () => void {
63
64
  const uid = requireUid(auth.currentUser?.uid);
64
65
  const pageSize = clampPageSize(options.pageSize);
65
- return onSnapshot(conversationQuery(uid, pageSize), (snapshot) => callback(snapshot.docs.map(toConversation)));
66
+ return onSnapshot(conversationQuery(uid, pageSize), listenOptions<SnapshotListenOptions>(options), (snapshot) => callback(snapshot.docs.map(toConversation)));
66
67
  }
67
68
 
68
69
  async create(input: CreateConversationInput = {}): Promise<Conversation['id']> {
@@ -146,9 +147,9 @@ export class ConversationService {
146
147
  }
147
148
  }
148
149
 
149
- observe(id: Conversation['id'], callback: (value: Conversation) => void): () => void {
150
+ observe(id: Conversation['id'], callback: (value: Conversation) => void, options?: ListenerOptions): () => void {
150
151
  requireUid(auth.currentUser?.uid);
151
- return onSnapshot(doc(db, 'conversations', id), (snapshot) => {
152
+ return onSnapshot(doc(db, 'conversations', id), listenOptions<SnapshotListenOptions>(options), (snapshot) => {
152
153
  if (snapshot.exists()) callback({ id, ...(snapshot.data() as ConversationDocument) });
153
154
  });
154
155
  }
@@ -1,3 +1,4 @@
1
+ import type { ListenerOwner } from '@pyric/ui/listener-owner';
1
2
  import {
2
3
  doc,
3
4
  type DocumentData,
@@ -37,3 +38,20 @@ export const typedDoc = <T extends DocumentData>(firestore: Firestore, path: str
37
38
 
38
39
  export const clampPageSize = (value: number | undefined, fallback = 50): number =>
39
40
  Math.min(Math.max(Math.trunc(value ?? fallback), 1), 100);
41
+
42
+ /**
43
+ * Who owns a listener: either a region name the caller gives it, or the
44
+ * React component `useListenerOwner` identified. Under the local sandbox the
45
+ * SDK records this as the listener's owner, which is how the runtime chip and
46
+ * Studio group listeners by the UI they feed. The production SDK reads only
47
+ * its own keys from the listen options and ignores the owner.
48
+ *
49
+ * `ListenerOwner` is a type-only import. `@pyric/ui` is a development
50
+ * dependency and none of it reaches the production build.
51
+ */
52
+ export type ListenerOptions = { owner?: string | ListenerOwner };
53
+
54
+ /** The listen options object to hand the SDK, typed as whatever the SDK expects so the extra key passes. */
55
+ export function listenOptions<T extends object>(options: ListenerOptions | undefined): T {
56
+ return (options?.owner === undefined ? {} : { owner: options.owner }) as T;
57
+ }
@@ -11,11 +11,12 @@ import {
11
11
  serverTimestamp,
12
12
  startAfter,
13
13
  where,
14
+ type SnapshotListenOptions,
14
15
  } from 'firebase/firestore';
15
16
  import { push, ref, serverTimestamp as rtdbServerTimestamp } from 'firebase/database';
16
17
  import { auth, db, rtdb } from '../firebase/app';
17
18
  import { asMessageId, type AppendAssistantMessageInput, type AppendUserMessageInput, type Message, type MessageDocument, type Page, type PageOptions, ServiceError } from '../firebase/types';
18
- import { clampPageSize, mapFirestoreError, requireUid } from './firestore-helpers';
19
+ import { clampPageSize, mapFirestoreError, requireUid, listenOptions, type ListenerOptions } from './firestore-helpers';
19
20
 
20
21
  const toMessage = (snapshot: { id: string; data: () => unknown }): Message => ({
21
22
  id: asMessageId(snapshot.id),
@@ -144,9 +145,9 @@ export class MessageService {
144
145
  }
145
146
  }
146
147
 
147
- observeRecent(conversationId: string, callback: (messages: Message[]) => void): () => void {
148
+ observeRecent(conversationId: string, callback: (messages: Message[]) => void, options?: ListenerOptions): () => void {
148
149
  const uid = requireUid(auth.currentUser?.uid);
149
150
  const messageQuery = query(messageCollection(conversationId), where('ownerUid', '==', uid), orderBy('createdAt', 'desc'), limit(50));
150
- return onSnapshot(messageQuery, (snapshot) => callback(snapshot.docs.map(toMessage).reverse()));
151
+ return onSnapshot(messageQuery, listenOptions<SnapshotListenOptions>(options), (snapshot) => callback(snapshot.docs.map(toMessage).reverse()));
151
152
  }
152
153
  }
@@ -1,7 +1,7 @@
1
- import { onDisconnect, onValue, ref, serverTimestamp, set } from 'firebase/database';
1
+ import { onDisconnect, onValue, ref, serverTimestamp, set, type ListenOptions } from 'firebase/database';
2
2
  import { auth, rtdb } from '../firebase/app';
3
3
  import { asUserId, type AuthUser, type PresenceEntry, type PresenceRecord, ServiceError } from '../firebase/types';
4
- import { requireUid } from './firestore-helpers';
4
+ import { listenOptions, requireUid, type ListenerOptions } from './firestore-helpers';
5
5
 
6
6
  const mapRtdbError = (error: unknown): ServiceError => {
7
7
  const code = typeof error === 'object' && error && 'code' in error ? String(error.code) : '';
@@ -50,7 +50,7 @@ export class PresenceService {
50
50
  }
51
51
 
52
52
  /** Observe every presence node; the callback receives the online members. */
53
- observe(callback: (online: PresenceEntry[]) => void): () => void {
53
+ observe(callback: (online: PresenceEntry[]) => void, options?: ListenerOptions): () => void {
54
54
  requireUid(auth.currentUser?.uid);
55
55
  return onValue(ref(rtdb, 'presence'), (snapshot) => {
56
56
  const entries: PresenceEntry[] = [];
@@ -59,6 +59,6 @@ export class PresenceService {
59
59
  if (entry && entry.state === 'online') entries.push(entry);
60
60
  });
61
61
  callback(entries);
62
- });
62
+ }, listenOptions<ListenOptions>(options));
63
63
  }
64
64
  }
@@ -0,0 +1,8 @@
1
+ import type { UiUser } from './chat-types';
2
+
3
+ /** The initials shown in an avatar for a user, or for "You" when there is none. */
4
+ export const initials = (user: UiUser | null): string =>
5
+ (user?.displayName ?? user?.email ?? 'You').split(' ').map((part) => part[0]).join('').slice(0, 2).toUpperCase();
6
+
7
+ /** A short local time for a conversation or message timestamp. */
8
+ export const formatTime = (date?: Date): string => date ? new Intl.DateTimeFormat(undefined, { hour: 'numeric', minute: '2-digit' }).format(date) : '';