@portel/photon-core 2.22.0 → 2.23.0

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 (45) hide show
  1. package/dist/base.d.ts +19 -0
  2. package/dist/base.d.ts.map +1 -1
  3. package/dist/base.js +29 -0
  4. package/dist/base.js.map +1 -1
  5. package/dist/bases-registry.d.ts +57 -0
  6. package/dist/bases-registry.d.ts.map +1 -0
  7. package/dist/bases-registry.js +127 -0
  8. package/dist/bases-registry.js.map +1 -0
  9. package/dist/data-paths.d.ts +31 -18
  10. package/dist/data-paths.d.ts.map +1 -1
  11. package/dist/data-paths.js +42 -43
  12. package/dist/data-paths.js.map +1 -1
  13. package/dist/index.d.ts +3 -2
  14. package/dist/index.d.ts.map +1 -1
  15. package/dist/index.js +6 -2
  16. package/dist/index.js.map +1 -1
  17. package/dist/memory.d.ts.map +1 -1
  18. package/dist/memory.js +81 -1
  19. package/dist/memory.js.map +1 -1
  20. package/dist/path-resolver.d.ts +13 -1
  21. package/dist/path-resolver.d.ts.map +1 -1
  22. package/dist/path-resolver.js +23 -1
  23. package/dist/path-resolver.js.map +1 -1
  24. package/dist/photon-loader-lite.d.ts +17 -2
  25. package/dist/photon-loader-lite.d.ts.map +1 -1
  26. package/dist/photon-loader-lite.js +162 -26
  27. package/dist/photon-loader-lite.js.map +1 -1
  28. package/dist/schema-extractor.d.ts +7 -2
  29. package/dist/schema-extractor.d.ts.map +1 -1
  30. package/dist/schema-extractor.js +21 -3
  31. package/dist/schema-extractor.js.map +1 -1
  32. package/dist/stateful.d.ts +3 -2
  33. package/dist/stateful.d.ts.map +1 -1
  34. package/dist/stateful.js +18 -6
  35. package/dist/stateful.js.map +1 -1
  36. package/package.json +1 -1
  37. package/src/base.ts +43 -0
  38. package/src/bases-registry.ts +141 -0
  39. package/src/data-paths.ts +43 -49
  40. package/src/index.ts +14 -1
  41. package/src/memory.ts +81 -0
  42. package/src/path-resolver.ts +26 -1
  43. package/src/photon-loader-lite.ts +176 -33
  44. package/src/schema-extractor.ts +24 -3
  45. package/src/stateful.ts +19 -6
package/src/base.ts CHANGED
@@ -636,11 +636,45 @@ export class Photon {
636
636
  return result;
637
637
  } catch (error: any) {
638
638
  console.error(`Tool execution failed: ${toolName} - ${error.message}`);
639
+ await this._invokeErrorHook(error, { tool: toolName, params: parameters });
639
640
  throw error;
640
641
  }
641
642
  });
642
643
  }
643
644
 
645
+ /**
646
+ * Invoke the onError observability hook with a bounded timeout. Never
647
+ * suppresses the original error and never throws itself — a throw or
648
+ * timeout inside the hook is logged and swallowed so observability code
649
+ * can never cascade into the request path.
650
+ */
651
+ private async _invokeErrorHook(
652
+ error: unknown,
653
+ ctx: { tool: string; params: any }
654
+ ): Promise<void> {
655
+ const hook = (this as any).onError;
656
+ if (typeof hook !== 'function') return;
657
+ const TIMEOUT_MS = 5000;
658
+ let timer: ReturnType<typeof setTimeout> | undefined;
659
+ try {
660
+ await Promise.race([
661
+ Promise.resolve(hook.call(this, error, ctx)),
662
+ new Promise<never>((_, reject) => {
663
+ timer = setTimeout(
664
+ () => reject(new Error(`onError hook exceeded ${TIMEOUT_MS}ms`)),
665
+ TIMEOUT_MS
666
+ );
667
+ }),
668
+ ]);
669
+ } catch (hookError: any) {
670
+ console.error(
671
+ `onError hook failed for ${ctx.tool}: ${hookError?.message ?? String(hookError)}`
672
+ );
673
+ } finally {
674
+ if (timer) clearTimeout(timer);
675
+ }
676
+ }
677
+
644
678
  /**
645
679
  * Optional lifecycle hooks
646
680
  */
@@ -654,6 +688,15 @@ export class Photon {
654
688
  * During hot-reload, receives context so you can skip resource cleanup.
655
689
  */
656
690
  async onShutdown?(ctx?: { reason?: string }): Promise<void>;
691
+ /**
692
+ * Called when any tool method throws. Observability only — the hook
693
+ * cannot suppress or transform the error. A throw or timeout inside
694
+ * the hook is logged and swallowed. Default timeout is 5s.
695
+ *
696
+ * Use it for centralized logging, metrics, or error reporting instead
697
+ * of wrapping every method in try/catch.
698
+ */
699
+ async onError?(error: unknown, ctx: { tool: string; params: any }): Promise<void>;
657
700
 
658
701
  /**
659
702
  * Get an MCP client for calling external MCP servers
@@ -0,0 +1,141 @@
1
+ /**
2
+ * Bases Registry
3
+ *
4
+ * Tracks every PHOTON_DIR the daemon has served. The daemon upserts an
5
+ * entry on every photon invocation and, on startup, scans each surviving
6
+ * base for long-lived per-photon state (schedules, etc.) that must be
7
+ * reinstated across restarts.
8
+ *
9
+ * The registry itself is daemon infrastructure and lives at the global
10
+ * path `~/.photon/.data/.bases.json`. The data it points at is NOT — each
11
+ * listed base owns its own data under `{base}/.data/`.
12
+ *
13
+ * See docs/internals/PHOTON-DIR-AND-NAMESPACE.md §8.
14
+ */
15
+
16
+ import * as fs from 'fs';
17
+ import * as path from 'path';
18
+ import { getBasesRegistryPath } from './data-paths.js';
19
+
20
+ export interface BaseRegistryEntry {
21
+ /** Absolute, resolved path to the PHOTON_DIR. */
22
+ path: string;
23
+ /** ISO-8601 timestamp of first record. */
24
+ firstSeen: string;
25
+ /** ISO-8601 timestamp of most recent invocation. */
26
+ lastSeen: string;
27
+ }
28
+
29
+ export interface BasesRegistry {
30
+ bases: BaseRegistryEntry[];
31
+ }
32
+
33
+ const EMPTY_REGISTRY: BasesRegistry = { bases: [] };
34
+
35
+ function normalizeBase(basePath: string): string {
36
+ return path.resolve(basePath);
37
+ }
38
+
39
+ /**
40
+ * Read the registry from disk. Returns an empty registry if the file is
41
+ * missing or malformed. Never throws for absent data.
42
+ */
43
+ export function readBasesRegistry(): BasesRegistry {
44
+ const file = getBasesRegistryPath();
45
+ let raw: string;
46
+ try {
47
+ raw = fs.readFileSync(file, 'utf-8');
48
+ } catch {
49
+ return { bases: [] };
50
+ }
51
+ try {
52
+ const parsed = JSON.parse(raw);
53
+ if (!parsed || !Array.isArray(parsed.bases)) return { bases: [] };
54
+ // Trust the shape but filter out entries that are obviously broken.
55
+ const bases = parsed.bases.filter(
56
+ (b: any) =>
57
+ b &&
58
+ typeof b.path === 'string' &&
59
+ typeof b.firstSeen === 'string' &&
60
+ typeof b.lastSeen === 'string'
61
+ );
62
+ return { bases };
63
+ } catch {
64
+ return { bases: [] };
65
+ }
66
+ }
67
+
68
+ /**
69
+ * Write the registry to disk atomically. Creates the parent directory if
70
+ * needed. Uses a temp-file + rename pattern so readers never observe a
71
+ * half-written file.
72
+ */
73
+ export function writeBasesRegistry(registry: BasesRegistry): void {
74
+ const file = getBasesRegistryPath();
75
+ fs.mkdirSync(path.dirname(file), { recursive: true });
76
+ const tmp = `${file}.${process.pid}.tmp`;
77
+ fs.writeFileSync(tmp, JSON.stringify(registry, null, 2));
78
+ fs.renameSync(tmp, file);
79
+ }
80
+
81
+ /**
82
+ * Upsert a base with a fresh `lastSeen`. Sets `firstSeen` on new entries.
83
+ * Safe to call on every photon invocation — cost is one JSON round-trip
84
+ * per call.
85
+ */
86
+ export function touchBase(basePath: string, now: Date = new Date()): void {
87
+ const normalized = normalizeBase(basePath);
88
+ const iso = now.toISOString();
89
+ const registry = readBasesRegistry();
90
+ const existing = registry.bases.find((b) => b.path === normalized);
91
+ if (existing) {
92
+ existing.lastSeen = iso;
93
+ } else {
94
+ registry.bases.push({ path: normalized, firstSeen: iso, lastSeen: iso });
95
+ }
96
+ writeBasesRegistry(registry);
97
+ }
98
+
99
+ /**
100
+ * Return registry entries whose path still exists on disk as a directory.
101
+ * Entries whose path cannot be confirmed (permission errors, unmounted
102
+ * drives, transient failures) are KEPT — we only filter on confirmed
103
+ * absence, so a momentarily-unreachable path does not get silently
104
+ * dropped.
105
+ */
106
+ export function listActiveBases(): BaseRegistryEntry[] {
107
+ const registry = readBasesRegistry();
108
+ return registry.bases.filter((b) => {
109
+ try {
110
+ return fs.statSync(b.path).isDirectory();
111
+ } catch (err: any) {
112
+ // Only treat "does not exist" as inactive. Other errors keep the entry.
113
+ return err?.code !== 'ENOENT';
114
+ }
115
+ });
116
+ }
117
+
118
+ /**
119
+ * Remove entries whose path is confirmed absent on disk (ENOENT only).
120
+ * Writes the pruned registry back. Returns the list of entries that were
121
+ * removed so callers can log them.
122
+ */
123
+ export function pruneBasesRegistry(): BaseRegistryEntry[] {
124
+ const registry = readBasesRegistry();
125
+ const removed: BaseRegistryEntry[] = [];
126
+ const kept: BaseRegistryEntry[] = [];
127
+ for (const b of registry.bases) {
128
+ let confirmedAbsent = false;
129
+ try {
130
+ fs.statSync(b.path);
131
+ } catch (err: any) {
132
+ if (err?.code === 'ENOENT') confirmedAbsent = true;
133
+ }
134
+ if (confirmedAbsent) removed.push(b);
135
+ else kept.push(b);
136
+ }
137
+ if (removed.length > 0) {
138
+ writeBasesRegistry({ bases: kept });
139
+ }
140
+ return removed;
141
+ }
package/src/data-paths.ts CHANGED
@@ -31,10 +31,16 @@ import * as path from 'path';
31
31
  import * as os from 'os';
32
32
  import { execSync } from 'child_process';
33
33
 
34
- const DEFAULT_BASE = path.join(os.homedir(), '.photon');
34
+ /**
35
+ * Default PHOTON_DIR when none is explicitly resolved: the user's home
36
+ * `.photon` directory. Single source of truth — path-resolver.ts and
37
+ * photon/src/context.ts re-export this so there is exactly one constant
38
+ * for the concept.
39
+ */
40
+ export const DEFAULT_PHOTON_DIR = path.join(os.homedir(), '.photon');
35
41
 
36
42
  function getBase(baseDir?: string): string {
37
- return baseDir || process.env.PHOTON_DIR || DEFAULT_BASE;
43
+ return baseDir || process.env.PHOTON_DIR || DEFAULT_PHOTON_DIR;
38
44
  }
39
45
 
40
46
  // ── Root ─────────────────────────────────────────────────────────────────────
@@ -142,17 +148,33 @@ export function getDaemonSocketPath(): string {
142
148
  if (process.platform === 'win32') {
143
149
  return '\\\\.\\pipe\\photon-daemon';
144
150
  }
145
- return path.join(DEFAULT_BASE, '.data', 'daemon.sock');
151
+ return path.join(DEFAULT_PHOTON_DIR, '.data', 'daemon.sock');
146
152
  }
147
153
 
148
154
  /** Daemon PID file: always ~/.photon/.data/daemon.pid */
149
155
  export function getDaemonPidPath(): string {
150
- return path.join(DEFAULT_BASE, '.data', 'daemon.pid');
156
+ return path.join(DEFAULT_PHOTON_DIR, '.data', 'daemon.pid');
151
157
  }
152
158
 
153
159
  /** Daemon log file: always ~/.photon/.data/daemon.log */
154
160
  export function getDaemonLogPath(): string {
155
- return path.join(DEFAULT_BASE, '.data', 'daemon.log');
161
+ return path.join(DEFAULT_PHOTON_DIR, '.data', 'daemon.log');
162
+ }
163
+
164
+ /**
165
+ * Bases registry: always ~/.photon/.data/.bases.json (global, one per user).
166
+ *
167
+ * The daemon maintains this file to track every PHOTON_DIR it has served.
168
+ * On startup it reads the registry and scans each base for schedules and
169
+ * other long-lived per-photon state that must survive daemon restarts.
170
+ *
171
+ * The `PHOTON_BASES_REGISTRY` env var exists as a test-only override;
172
+ * production always uses the fixed global path.
173
+ *
174
+ * See docs/internals/PHOTON-DIR-AND-NAMESPACE.md §8.
175
+ */
176
+ export function getBasesRegistryPath(): string {
177
+ return process.env.PHOTON_BASES_REGISTRY ?? path.join(DEFAULT_PHOTON_DIR, '.data', '.bases.json');
156
178
  }
157
179
 
158
180
  // ── Namespace Detection ──────────────────────────────────────────────────────
@@ -165,6 +187,11 @@ export function getDaemonLogPath(): string {
165
187
  * git@github.com:portel-dev/photons.git → 'portel-dev'
166
188
  * https://github.com/arul-kumar/my-photons → 'arul-kumar'
167
189
  * (no git remote) → ''
190
+ *
191
+ * @deprecated Under Option B the namespace is a pure function of the
192
+ * photon file's position relative to PHOTON_DIR; git state must never
193
+ * influence data paths. Scheduled for removal in the next minor release.
194
+ * See docs/internals/PHOTON-DIR-AND-NAMESPACE.md §3.
168
195
  */
169
196
  export function detectNamespace(dir: string): string {
170
197
  try {
@@ -228,62 +255,29 @@ export function getLegacySessionMemoryDir(sessionId: string, photonName: string,
228
255
  return path.join(getBase(baseDir), 'sessions', safeSession, safeName);
229
256
  }
230
257
 
231
- /** Old runs dir: ~/.photon/runs/ */
232
- export function getLegacyRunsDir(): string {
233
- return path.join(DEFAULT_BASE, 'runs');
234
- }
235
-
236
- /** Old logs dir: ~/.photon/logs/{photonId}/ */
237
- export function getLegacyLogsDir(photonName: string): string {
238
- return path.join(DEFAULT_BASE, 'logs', photonName);
239
- }
240
-
241
- /** Old tasks dir: ~/.photon/tasks/ */
242
- export function getLegacyTasksDir(): string {
243
- return path.join(DEFAULT_BASE, 'tasks');
244
- }
245
-
246
- /** Old audit path: ~/.photon/audit.jsonl */
247
- export function getLegacyAuditPath(): string {
248
- return path.join(DEFAULT_BASE, 'audit.jsonl');
249
- }
250
-
251
- /** Old metadata path: {baseDir}/.metadata.json */
252
- export function getLegacyMetadataPath(baseDir?: string): string {
253
- return path.join(getBase(baseDir), '.metadata.json');
254
- }
255
-
256
- /** Old daemon socket: ~/.photon/daemon.sock */
257
- export function getLegacyDaemonSocketPath(): string {
258
- if (process.platform === 'win32') {
259
- return '\\\\.\\pipe\\photon-daemon';
260
- }
261
- return path.join(DEFAULT_BASE, 'daemon.sock');
262
- }
263
-
264
- /** Old daemon PID: ~/.photon/daemon.pid */
265
- export function getLegacyDaemonPidPath(): string {
266
- return path.join(DEFAULT_BASE, 'daemon.pid');
258
+ /** Old runs dir: {baseDir}/runs/ */
259
+ export function getLegacyRunsDir(baseDir?: string): string {
260
+ return path.join(getBase(baseDir), 'runs');
267
261
  }
268
262
 
269
- /** Old daemon log: ~/.photon/daemon.log */
270
- export function getLegacyDaemonLogPath(): string {
271
- return path.join(DEFAULT_BASE, 'daemon.log');
263
+ /** Old logs dir: {baseDir}/logs/{photonId}/ */
264
+ export function getLegacyLogsDir(photonName: string, baseDir?: string): string {
265
+ return path.join(getBase(baseDir), 'logs', photonName);
272
266
  }
273
267
 
274
- /** Old cache dir: {baseDir}/.cache/ or {baseDir}/cache/ */
275
- export function getLegacyCacheDir(baseDir?: string): string {
276
- return path.join(getBase(baseDir), '.cache');
268
+ /** Old tasks dir: {baseDir}/tasks/ */
269
+ export function getLegacyTasksDir(baseDir?: string): string {
270
+ return path.join(getBase(baseDir), 'tasks');
277
271
  }
278
272
 
279
273
  /** Old schedules dir: ~/.photon/schedules/{photonName}/ */
280
274
  export function getLegacySchedulesDir(photonName: string): string {
281
275
  const safeName = photonName.replace(/[^a-zA-Z0-9_-]/g, '_');
282
- return path.join(DEFAULT_BASE, 'schedules', safeName);
276
+ return path.join(DEFAULT_PHOTON_DIR, 'schedules', safeName);
283
277
  }
284
278
 
285
279
  /** Old per-photon config: ~/.photon/{photonName}/config.json */
286
280
  export function getLegacyPhotonConfigPath(photonName: string): string {
287
281
  const safeName = photonName.replace(/[^a-zA-Z0-9_-]/g, '_');
288
- return path.join(DEFAULT_BASE, safeName, 'config.json');
282
+ return path.join(DEFAULT_PHOTON_DIR, safeName, 'config.json');
289
283
  }
package/src/index.ts CHANGED
@@ -172,6 +172,7 @@ export {
172
172
  resolvePhotonPath,
173
173
  listPhotonFiles,
174
174
  listPhotonFilesWithNamespace,
175
+ listPhotonSourceFiles,
175
176
  ensurePhotonDir,
176
177
  DEFAULT_PHOTON_DIR,
177
178
  type ResolverOptions,
@@ -494,7 +495,14 @@ export {
494
495
 
495
496
  // ===== PHOTON LOADER LITE =====
496
497
  // Direct TypeScript API for loading .photon.ts files with full enhancements
497
- export { photon, clearPhotonCache, type PhotonOptions, type PhotonEvent } from './photon-loader-lite.js';
498
+ export {
499
+ photon,
500
+ clearPhotonCache,
501
+ disposePhoton,
502
+ disposeAllPhotons,
503
+ type PhotonOptions,
504
+ type PhotonEvent,
505
+ } from './photon-loader-lite.js';
498
506
 
499
507
  // ===== FILE WATCHING =====
500
508
  // Reusable photon file watcher with symlink resolution, debouncing, rename handling
@@ -592,3 +600,8 @@ export {
592
600
  // ===== DATA PATHS =====
593
601
  // Central data path resolver — single source of truth for all runtime data locations
594
602
  export * from './data-paths.js';
603
+
604
+ // ===== BASES REGISTRY =====
605
+ // Daemon-owned registry of every PHOTON_DIR served. Used to discover schedules
606
+ // and other per-base data on daemon startup. See data-paths getBasesRegistryPath.
607
+ export * from './bases-registry.js';
package/src/memory.ts CHANGED
@@ -27,6 +27,7 @@ import {
27
27
  getLegacyMemoryDir,
28
28
  getLegacyGlobalMemoryDir,
29
29
  getLegacySessionMemoryDir,
30
+ getDataRoot,
30
31
  } from './data-paths.js';
31
32
 
32
33
  export type MemoryScope = 'photon' | 'session' | 'global';
@@ -216,6 +217,83 @@ export class FileMemoryBackend implements MemoryBackend {
216
217
  // SCOPE RESOLUTION
217
218
  // ════════════════════════════════════════════════════════════════════════════════
218
219
 
220
+ /**
221
+ * Compatibility shim — one-release migration only.
222
+ *
223
+ * Under docs/internals/PHOTON-DIR-AND-NAMESPACE.md the namespace comes
224
+ * purely from directory position and never changes silently. But installs
225
+ * that ran with the old git-remote-based namespace detection may have data
226
+ * sitting under a stale namespace bucket. On first read, if the canonical
227
+ * dir is empty but exactly one sibling namespace contains this photon's
228
+ * memory, migrate it atomically to the canonical location.
229
+ *
230
+ * Multiple matches → ambiguous; warn to stderr so the user can consolidate
231
+ * manually rather than proceeding with empty memory.
232
+ *
233
+ * @deprecated Remove this helper and its caller one release after the
234
+ * PHOTON_DIR-and-namespace change ships.
235
+ */
236
+ function findAndMigrateStrandedMemory(
237
+ photonId: string,
238
+ canonicalDir: string,
239
+ baseDir?: string
240
+ ): string | null {
241
+ const dataRoot = getDataRoot(baseDir);
242
+ let entries: string[];
243
+ try {
244
+ entries = fsSync.readdirSync(dataRoot);
245
+ } catch {
246
+ return null;
247
+ }
248
+
249
+ const matches: string[] = [];
250
+ for (const entry of entries) {
251
+ // Skip non-namespace buckets. `_global`, `_sessions`, `.cache`, `tasks`
252
+ // live at the same level but are reserved names.
253
+ if (entry.startsWith('_') || entry.startsWith('.') || entry === 'tasks') continue;
254
+ const candidate = path.join(dataRoot, entry, photonId, 'memory');
255
+ try {
256
+ const stat = fsSync.statSync(candidate);
257
+ if (stat.isDirectory()) {
258
+ // Only count non-empty dirs as real data.
259
+ if (fsSync.readdirSync(candidate).length > 0) matches.push(candidate);
260
+ }
261
+ } catch {
262
+ // Not a match, continue.
263
+ }
264
+ }
265
+
266
+ if (matches.length === 0) return null;
267
+ if (matches.length > 1) {
268
+ process.stderr.write(
269
+ `[photon] warning: photon '${photonId}' has memory data stranded under multiple namespaces:\n` +
270
+ matches.map((m) => ` - ${m}`).join('\n') +
271
+ `\n Canonical path is ${canonicalDir}.\n` +
272
+ ` Move the correct one into the canonical path and delete the others to consolidate.\n`
273
+ );
274
+ return null;
275
+ }
276
+
277
+ const stranded = matches[0];
278
+ try {
279
+ fsSync.mkdirSync(path.dirname(canonicalDir), { recursive: true });
280
+ fsSync.renameSync(stranded, canonicalDir);
281
+ // Clean up now-empty parent if it has no siblings.
282
+ try {
283
+ const strandedParent = path.dirname(stranded);
284
+ if (fsSync.readdirSync(strandedParent).length === 0) fsSync.rmdirSync(strandedParent);
285
+ const strandedNs = path.dirname(strandedParent);
286
+ if (fsSync.readdirSync(strandedNs).length === 0) fsSync.rmdirSync(strandedNs);
287
+ } catch {
288
+ // Non-fatal: leave parent dirs if cleanup fails.
289
+ }
290
+ return canonicalDir;
291
+ } catch {
292
+ // Cross-device rename or permission issue — fall back to reading in place.
293
+ return stranded;
294
+ }
295
+ }
296
+
219
297
  function resolveDir(
220
298
  photonId: string,
221
299
  namespace: string,
@@ -229,6 +307,9 @@ function resolveDir(
229
307
  if (!fsSync.existsSync(newDir)) {
230
308
  const legacyDir = getLegacyMemoryDir(photonId, baseDir);
231
309
  if (fsSync.existsSync(legacyDir)) return legacyDir;
310
+ // Last resort: data stranded under a different namespace bucket.
311
+ const recovered = findAndMigrateStrandedMemory(photonId, newDir, baseDir);
312
+ if (recovered) return recovered;
232
313
  }
233
314
  return newDir;
234
315
  }
@@ -18,7 +18,10 @@ import * as fsSync from 'fs';
18
18
  import * as path from 'path';
19
19
  import * as os from 'os';
20
20
 
21
- export const DEFAULT_PHOTON_DIR = path.join(os.homedir(), '.photon');
21
+ import { DEFAULT_PHOTON_DIR } from './data-paths.js';
22
+ // Re-exported so existing `import { DEFAULT_PHOTON_DIR } from '@portel/photon-core'`
23
+ // callers keep working. One canonical definition lives in data-paths.ts.
24
+ export { DEFAULT_PHOTON_DIR };
22
25
 
23
26
  /**
24
27
  * Expand tilde (~) to user's home directory
@@ -53,6 +56,28 @@ const SKIP_DIRS = new Set([
53
56
  'node_modules', 'marketplace', 'photons', 'templates',
54
57
  ]);
55
58
 
59
+ /**
60
+ * List photon source file names in a directory. Returns just filenames
61
+ * (not full paths) so callers can choose to either join with the dir
62
+ * or operate on the basename directly. Missing/unreadable dirs return [].
63
+ *
64
+ * Replaces scattered `readdirSync(dir).filter(f => f.endsWith('.photon.ts'))`
65
+ * expressions so the default extension list stays consistent.
66
+ */
67
+ export function listPhotonSourceFiles(
68
+ dir: string,
69
+ options?: { extensions?: string[] },
70
+ ): string[] {
71
+ const extensions = options?.extensions ?? defaultOptions.extensions;
72
+ try {
73
+ return fsSync
74
+ .readdirSync(dir)
75
+ .filter((f) => extensions.some((ext) => f.endsWith(ext)));
76
+ } catch {
77
+ return [];
78
+ }
79
+ }
80
+
56
81
  /**
57
82
  * Resolve a file path from name.
58
83
  *