@portel/photon-core 2.14.0 → 2.16.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.
- package/README.md +189 -3
- package/dist/asset-discovery.d.ts.map +1 -1
- package/dist/asset-discovery.js +22 -1
- package/dist/asset-discovery.js.map +1 -1
- package/dist/base.d.ts +198 -0
- package/dist/base.d.ts.map +1 -1
- package/dist/base.js +228 -5
- package/dist/base.js.map +1 -1
- package/dist/generator.d.ts +16 -1
- package/dist/generator.d.ts.map +1 -1
- package/dist/generator.js.map +1 -1
- package/dist/index.d.ts +3 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/mixins.d.ts.map +1 -1
- package/dist/mixins.js +26 -1
- package/dist/mixins.js.map +1 -1
- package/dist/path-resolver.d.ts +36 -3
- package/dist/path-resolver.d.ts.map +1 -1
- package/dist/path-resolver.js +122 -17
- package/dist/path-resolver.js.map +1 -1
- package/dist/photon-loader-lite.js +1 -1
- package/dist/photon-loader-lite.js.map +1 -1
- package/dist/schema-extractor.d.ts +8 -1
- package/dist/schema-extractor.d.ts.map +1 -1
- package/dist/schema-extractor.js +56 -4
- package/dist/schema-extractor.js.map +1 -1
- package/dist/types.d.ts +3 -1
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js.map +1 -1
- package/package.json +1 -1
- package/src/asset-discovery.ts +21 -1
- package/src/base.ts +281 -8
- package/src/generator.ts +20 -1
- package/src/index.ts +6 -0
- package/src/mixins.ts +28 -2
- package/src/path-resolver.ts +141 -18
- package/src/photon-loader-lite.ts +1 -1
- package/src/schema-extractor.ts +66 -5
- package/src/types.ts +3 -1
package/src/base.ts
CHANGED
|
@@ -40,11 +40,13 @@
|
|
|
40
40
|
*/
|
|
41
41
|
|
|
42
42
|
import { MCPClient, MCPClientFactory, createMCPProxy } from '@portel/mcp';
|
|
43
|
-
import { executionContext } from '@portel/cli';
|
|
43
|
+
import { executionContext, type CallerInfo } from '@portel/cli';
|
|
44
44
|
import { getBroker } from './channels/index.js';
|
|
45
45
|
import { withLock as withLockHelper } from './decorators.js';
|
|
46
46
|
import { MemoryProvider } from './memory.js';
|
|
47
47
|
import { ScheduleProvider } from './schedule.js';
|
|
48
|
+
import * as path from 'path';
|
|
49
|
+
import * as fs from 'fs';
|
|
48
50
|
|
|
49
51
|
/**
|
|
50
52
|
* Simple base class for creating Photons
|
|
@@ -61,6 +63,20 @@ export class Photon {
|
|
|
61
63
|
*/
|
|
62
64
|
_photonName?: string;
|
|
63
65
|
|
|
66
|
+
/**
|
|
67
|
+
* Absolute path to the .photon.ts/.photon.js source file - set by runtime loader
|
|
68
|
+
* Used for storage() and assets() path resolution
|
|
69
|
+
* @internal
|
|
70
|
+
*/
|
|
71
|
+
_photonFilePath?: string;
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Dynamic photon resolver - injected by runtime loader
|
|
75
|
+
* Used by this.photon.use() for runtime photon access
|
|
76
|
+
* @internal
|
|
77
|
+
*/
|
|
78
|
+
_photonResolver?: (name: string, instance?: string) => Promise<any>;
|
|
79
|
+
|
|
64
80
|
/**
|
|
65
81
|
* Scoped memory provider - lazy-initialized on first access
|
|
66
82
|
* @internal
|
|
@@ -79,6 +95,28 @@ export class Photon {
|
|
|
79
95
|
*/
|
|
80
96
|
_sessionId?: string;
|
|
81
97
|
|
|
98
|
+
/**
|
|
99
|
+
* Authenticated caller identity
|
|
100
|
+
*
|
|
101
|
+
* Populated from MCP OAuth when `@auth` is enabled on the photon.
|
|
102
|
+
* Returns the identity of whoever is calling the current method —
|
|
103
|
+
* human (via social login) or agent (via API key).
|
|
104
|
+
*
|
|
105
|
+
* Returns an anonymous caller if no auth token was provided.
|
|
106
|
+
*
|
|
107
|
+
* @example
|
|
108
|
+
* ```typescript
|
|
109
|
+
* // In a method:
|
|
110
|
+
* const userId = this.caller.id; // stable user ID from JWT
|
|
111
|
+
* const name = this.caller.name; // display name
|
|
112
|
+
* const isAnon = this.caller.anonymous; // true if no auth
|
|
113
|
+
* ```
|
|
114
|
+
*/
|
|
115
|
+
get caller(): CallerInfo {
|
|
116
|
+
const store = executionContext.getStore();
|
|
117
|
+
return store?.caller ?? { id: 'anonymous', anonymous: true };
|
|
118
|
+
}
|
|
119
|
+
|
|
82
120
|
/**
|
|
83
121
|
* Scoped key-value storage for photon data
|
|
84
122
|
*
|
|
@@ -156,6 +194,103 @@ export class Photon {
|
|
|
156
194
|
return this._schedule;
|
|
157
195
|
}
|
|
158
196
|
|
|
197
|
+
/**
|
|
198
|
+
* Get an absolute path to a storage directory for this photon's data.
|
|
199
|
+
*
|
|
200
|
+
* Uses the symlink/installed path (not resolved) so data stays at the
|
|
201
|
+
* installed location. Directories are auto-created.
|
|
202
|
+
*
|
|
203
|
+
* @param subpath Sub-directory within the photon's data folder (e.g., 'auth', 'media')
|
|
204
|
+
* @returns Absolute path to the directory
|
|
205
|
+
*
|
|
206
|
+
* @example
|
|
207
|
+
* ```typescript
|
|
208
|
+
* const authDir = this.storage('auth');
|
|
209
|
+
* // ~/.photon/portel-dev/whatsapp/auth/
|
|
210
|
+
*
|
|
211
|
+
* const mediaDir = this.storage('media/images');
|
|
212
|
+
* // ~/.photon/portel-dev/whatsapp/media/images/
|
|
213
|
+
* ```
|
|
214
|
+
*/
|
|
215
|
+
protected storage(subpath: string): string {
|
|
216
|
+
if (!this._photonFilePath) {
|
|
217
|
+
throw new Error(
|
|
218
|
+
'storage() requires _photonFilePath to be set by the runtime loader. ' +
|
|
219
|
+
'Ensure this photon is loaded through the standard runtime.'
|
|
220
|
+
);
|
|
221
|
+
}
|
|
222
|
+
const dir = path.dirname(this._photonFilePath);
|
|
223
|
+
const name = path.basename(this._photonFilePath).replace(/\.photon\.(ts|js)$/, '');
|
|
224
|
+
const target = path.join(dir, name, subpath);
|
|
225
|
+
fs.mkdirSync(target, { recursive: true });
|
|
226
|
+
return target;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* Get an absolute path to an assets directory for this photon.
|
|
231
|
+
*
|
|
232
|
+
* Uses realpathSync to follow symlinks — assets travel with source code,
|
|
233
|
+
* not the installed location. Useful for marketplace-distributed resources
|
|
234
|
+
* like HTML templates, images, etc.
|
|
235
|
+
*
|
|
236
|
+
* @param subpath Sub-path within the assets folder (e.g., 'templates', 'icons/logo.png')
|
|
237
|
+
* @returns Absolute path to the asset file or directory
|
|
238
|
+
*
|
|
239
|
+
* @example
|
|
240
|
+
* ```typescript
|
|
241
|
+
* const templateDir = this.assets('templates');
|
|
242
|
+
* // /real/path/to/portel-dev/whatsapp/assets/templates/
|
|
243
|
+
*
|
|
244
|
+
* const logo = this.assets('icons/logo.png');
|
|
245
|
+
* // /real/path/to/portel-dev/whatsapp/assets/icons/logo.png
|
|
246
|
+
* ```
|
|
247
|
+
*/
|
|
248
|
+
protected assets(subpath: string): string {
|
|
249
|
+
if (!this._photonFilePath) {
|
|
250
|
+
throw new Error(
|
|
251
|
+
'assets() requires _photonFilePath to be set by the runtime loader. ' +
|
|
252
|
+
'Ensure this photon is loaded through the standard runtime.'
|
|
253
|
+
);
|
|
254
|
+
}
|
|
255
|
+
const realPath = fs.realpathSync(this._photonFilePath);
|
|
256
|
+
const dir = path.dirname(realPath);
|
|
257
|
+
const name = path.basename(realPath).replace(/\.photon\.(ts|js)$/, '');
|
|
258
|
+
return path.join(dir, name, 'assets', subpath);
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/**
|
|
262
|
+
* Dynamic photon access
|
|
263
|
+
*
|
|
264
|
+
* Provides runtime access to other photons by name, with optional instance selection.
|
|
265
|
+
* Supports both short names and namespace-qualified names.
|
|
266
|
+
*
|
|
267
|
+
* @example
|
|
268
|
+
* ```typescript
|
|
269
|
+
* // Get default instance
|
|
270
|
+
* const wa = await this.photon.use('whatsapp');
|
|
271
|
+
*
|
|
272
|
+
* // Get named instance
|
|
273
|
+
* const personal = await this.photon.use('whatsapp', 'personal');
|
|
274
|
+
*
|
|
275
|
+
* // Cross-namespace access
|
|
276
|
+
* const wa2 = await this.photon.use('portel-dev:whatsapp', 'work');
|
|
277
|
+
* ```
|
|
278
|
+
*/
|
|
279
|
+
get photon(): { use: (name: string, instance?: string) => Promise<any> } {
|
|
280
|
+
const resolver = this._photonResolver;
|
|
281
|
+
return {
|
|
282
|
+
use: async (name: string, instance?: string) => {
|
|
283
|
+
if (!resolver) {
|
|
284
|
+
throw new Error(
|
|
285
|
+
'this.photon.use() requires a runtime with photon resolution. ' +
|
|
286
|
+
'Ensure this photon is loaded through the standard runtime.'
|
|
287
|
+
);
|
|
288
|
+
}
|
|
289
|
+
return resolver(name, instance);
|
|
290
|
+
},
|
|
291
|
+
};
|
|
292
|
+
}
|
|
293
|
+
|
|
159
294
|
/**
|
|
160
295
|
* Emit an event/progress update
|
|
161
296
|
*
|
|
@@ -216,6 +351,45 @@ export class Photon {
|
|
|
216
351
|
});
|
|
217
352
|
}
|
|
218
353
|
}
|
|
354
|
+
|
|
355
|
+
/**
|
|
356
|
+
* Render a formatted value as an intermediate result
|
|
357
|
+
*
|
|
358
|
+
* Sends a value to the client (Beam, CLI, MCP) rendered with the specified
|
|
359
|
+
* format — the same formats available via `@format` docblock tags. Each call
|
|
360
|
+
* replaces the previous render in the result panel.
|
|
361
|
+
*
|
|
362
|
+
* For custom formats, place an HTML renderer at `assets/formats/<name>.html`.
|
|
363
|
+
*
|
|
364
|
+
* @param format The format type (table, qr, chart:bar, dashboard, or custom)
|
|
365
|
+
* @param value The data to render — same shape as a return value with that @format
|
|
366
|
+
*
|
|
367
|
+
* @example
|
|
368
|
+
* ```typescript
|
|
369
|
+
* // Show a QR code mid-execution
|
|
370
|
+
* this.render('qr', { value: 'https://wa.link/...' });
|
|
371
|
+
*
|
|
372
|
+
* // Show a status table
|
|
373
|
+
* this.render('table', [['Step', 'Status'], ['Auth', 'Done']]);
|
|
374
|
+
*
|
|
375
|
+
* // Composite dashboard
|
|
376
|
+
* this.render('dashboard', {
|
|
377
|
+
* qr: { format: 'qr', data: 'https://wa.link/...' },
|
|
378
|
+
* status: { format: 'text', data: 'Scan the QR code above' }
|
|
379
|
+
* });
|
|
380
|
+
* ```
|
|
381
|
+
*/
|
|
382
|
+
protected render(format: string, value: any): void;
|
|
383
|
+
protected render(): void;
|
|
384
|
+
protected render(format?: string, value?: any): void {
|
|
385
|
+
if (format === undefined) {
|
|
386
|
+
// Clear the render zone without rendering new content
|
|
387
|
+
this.emit({ emit: 'render:clear' });
|
|
388
|
+
} else {
|
|
389
|
+
this.emit({ emit: 'render', format, value });
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
|
|
219
393
|
/**
|
|
220
394
|
* Cross-photon call handler - injected by runtime
|
|
221
395
|
* @internal
|
|
@@ -305,16 +479,13 @@ export class Photon {
|
|
|
305
479
|
]);
|
|
306
480
|
|
|
307
481
|
// Get all property names from prototype chain
|
|
482
|
+
// Use getOwnPropertyDescriptor to avoid triggering getters (which may call storage())
|
|
308
483
|
let current = prototype;
|
|
309
484
|
while (current && current !== Photon.prototype) {
|
|
310
485
|
Object.getOwnPropertyNames(current).forEach((name) => {
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
!conventionMethods.has(name) &&
|
|
315
|
-
typeof (prototype as any)[name] === 'function' &&
|
|
316
|
-
!methods.includes(name)
|
|
317
|
-
) {
|
|
486
|
+
if (name.startsWith('_') || conventionMethods.has(name) || methods.includes(name)) return;
|
|
487
|
+
const desc = Object.getOwnPropertyDescriptor(current, name);
|
|
488
|
+
if (desc && typeof desc.value === 'function') {
|
|
318
489
|
methods.push(name);
|
|
319
490
|
}
|
|
320
491
|
});
|
|
@@ -466,4 +637,106 @@ export class Photon {
|
|
|
466
637
|
): Promise<T> {
|
|
467
638
|
return withLockHelper(lockName, fn, timeout);
|
|
468
639
|
}
|
|
640
|
+
|
|
641
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
642
|
+
// IDENTITY-AWARE LOCK MANAGEMENT
|
|
643
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
644
|
+
|
|
645
|
+
/**
|
|
646
|
+
* Identity-aware lock handler - injected by runtime
|
|
647
|
+
* @internal
|
|
648
|
+
*/
|
|
649
|
+
_lockHandler?: {
|
|
650
|
+
assign(lockName: string, holder: string, timeout?: number): Promise<boolean>;
|
|
651
|
+
transfer(lockName: string, fromHolder: string, toHolder: string, timeout?: number): Promise<boolean>;
|
|
652
|
+
release(lockName: string, holder: string): Promise<boolean>;
|
|
653
|
+
query(lockName: string): Promise<{ holder: string | null; acquiredAt?: number; expiresAt?: number }>;
|
|
654
|
+
};
|
|
655
|
+
|
|
656
|
+
/**
|
|
657
|
+
* Assign a lock to a specific caller (identity-aware)
|
|
658
|
+
*
|
|
659
|
+
* Unlike `withLock` which auto-acquires/releases around a function,
|
|
660
|
+
* this explicitly assigns a lock to a caller ID. The lock persists
|
|
661
|
+
* until transferred or released.
|
|
662
|
+
*
|
|
663
|
+
* @param lockName Name of the lock
|
|
664
|
+
* @param callerId Caller ID to assign the lock to
|
|
665
|
+
* @param timeout Lock timeout in ms (default 30000, auto-extended on transfer)
|
|
666
|
+
*
|
|
667
|
+
* @example
|
|
668
|
+
* ```typescript
|
|
669
|
+
* // Assign "turn" lock to first player
|
|
670
|
+
* await this.acquireLock('turn', this.caller.id);
|
|
671
|
+
* ```
|
|
672
|
+
*/
|
|
673
|
+
protected async acquireLock(lockName: string, callerId: string, timeout?: number): Promise<boolean> {
|
|
674
|
+
if (!this._lockHandler) {
|
|
675
|
+
console.warn(`[photon] acquireLock('${lockName}'): no lock handler configured`);
|
|
676
|
+
return true;
|
|
677
|
+
}
|
|
678
|
+
return this._lockHandler.assign(lockName, callerId, timeout);
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
/**
|
|
682
|
+
* Transfer a lock from the current holder to another caller
|
|
683
|
+
*
|
|
684
|
+
* Only succeeds if `fromCallerId` is the current holder.
|
|
685
|
+
*
|
|
686
|
+
* @param lockName Name of the lock
|
|
687
|
+
* @param toCallerId Caller ID to transfer the lock to
|
|
688
|
+
* @param fromCallerId Current holder (defaults to this.caller.id)
|
|
689
|
+
*
|
|
690
|
+
* @example
|
|
691
|
+
* ```typescript
|
|
692
|
+
* // After a chess move, transfer turn to opponent
|
|
693
|
+
* await this.transferLock('turn', opponentId);
|
|
694
|
+
* ```
|
|
695
|
+
*/
|
|
696
|
+
protected async transferLock(lockName: string, toCallerId: string, fromCallerId?: string): Promise<boolean> {
|
|
697
|
+
if (!this._lockHandler) {
|
|
698
|
+
console.warn(`[photon] transferLock('${lockName}'): no lock handler configured`);
|
|
699
|
+
return true;
|
|
700
|
+
}
|
|
701
|
+
return this._lockHandler.transfer(lockName, fromCallerId ?? this.caller.id, toCallerId);
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
/**
|
|
705
|
+
* Release a lock (make the method open to anyone)
|
|
706
|
+
*
|
|
707
|
+
* @param lockName Name of the lock
|
|
708
|
+
* @param callerId Holder to release from (defaults to this.caller.id)
|
|
709
|
+
*
|
|
710
|
+
* @example
|
|
711
|
+
* ```typescript
|
|
712
|
+
* // Presenter releases navigation control to audience
|
|
713
|
+
* await this.releaseLock('navigation');
|
|
714
|
+
* ```
|
|
715
|
+
*/
|
|
716
|
+
protected async releaseLock(lockName: string, callerId?: string): Promise<boolean> {
|
|
717
|
+
if (!this._lockHandler) {
|
|
718
|
+
console.warn(`[photon] releaseLock('${lockName}'): no lock handler configured`);
|
|
719
|
+
return true;
|
|
720
|
+
}
|
|
721
|
+
return this._lockHandler.release(lockName, callerId ?? this.caller.id);
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
/**
|
|
725
|
+
* Query who holds a specific lock
|
|
726
|
+
*
|
|
727
|
+
* @param lockName Name of the lock
|
|
728
|
+
* @returns Lock holder info, or null holder if unlocked
|
|
729
|
+
*
|
|
730
|
+
* @example
|
|
731
|
+
* ```typescript
|
|
732
|
+
* const lock = await this.getLock('turn');
|
|
733
|
+
* if (lock.holder === this.caller.id) { ... }
|
|
734
|
+
* ```
|
|
735
|
+
*/
|
|
736
|
+
protected async getLock(lockName: string): Promise<{ holder: string | null; acquiredAt?: number; expiresAt?: number }> {
|
|
737
|
+
if (!this._lockHandler) {
|
|
738
|
+
return { holder: null };
|
|
739
|
+
}
|
|
740
|
+
return this._lockHandler.query(lockName);
|
|
741
|
+
}
|
|
469
742
|
}
|
package/src/generator.ts
CHANGED
|
@@ -730,6 +730,23 @@ export interface EmitQR {
|
|
|
730
730
|
value: string;
|
|
731
731
|
}
|
|
732
732
|
|
|
733
|
+
/**
|
|
734
|
+
* Render emit — sends a formatted intermediate result to the client.
|
|
735
|
+
* The value is rendered using the same format pipeline as @format return values.
|
|
736
|
+
* Custom formats are resolved from assets/formats/<name>.html.
|
|
737
|
+
*/
|
|
738
|
+
export interface EmitRender {
|
|
739
|
+
emit: 'render';
|
|
740
|
+
/** Format type (table, qr, chart:bar, dashboard, or custom name) */
|
|
741
|
+
format: string;
|
|
742
|
+
/** Data to render — same shape as a return value with that @format */
|
|
743
|
+
value: any;
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
export interface EmitRenderClear {
|
|
747
|
+
emit: 'render:clear';
|
|
748
|
+
}
|
|
749
|
+
|
|
733
750
|
export type EmitYield =
|
|
734
751
|
| EmitStatus
|
|
735
752
|
| EmitProgress
|
|
@@ -739,7 +756,9 @@ export type EmitYield =
|
|
|
739
756
|
| EmitThinking
|
|
740
757
|
| EmitArtifact
|
|
741
758
|
| EmitUI
|
|
742
|
-
| EmitQR
|
|
759
|
+
| EmitQR
|
|
760
|
+
| EmitRender
|
|
761
|
+
| EmitRenderClear;
|
|
743
762
|
|
|
744
763
|
// ══════════════════════════════════════════════════════════════════════════════
|
|
745
764
|
// COMBINED TYPES
|
package/src/index.ts
CHANGED
|
@@ -74,6 +74,7 @@ export {
|
|
|
74
74
|
executionContext,
|
|
75
75
|
runWithContext,
|
|
76
76
|
getContext,
|
|
77
|
+
type CallerInfo,
|
|
77
78
|
|
|
78
79
|
// Text Utils
|
|
79
80
|
TextUtils,
|
|
@@ -166,12 +167,15 @@ export { SchemaExtractor, detectCapabilities, type PhotonCapability } from './sc
|
|
|
166
167
|
export {
|
|
167
168
|
resolvePath,
|
|
168
169
|
listFiles,
|
|
170
|
+
listFilesWithNamespace,
|
|
169
171
|
ensureDir,
|
|
170
172
|
resolvePhotonPath,
|
|
171
173
|
listPhotonFiles,
|
|
174
|
+
listPhotonFilesWithNamespace,
|
|
172
175
|
ensurePhotonDir,
|
|
173
176
|
DEFAULT_PHOTON_DIR,
|
|
174
177
|
type ResolverOptions,
|
|
178
|
+
type ListedPhoton,
|
|
175
179
|
} from './path-resolver.js';
|
|
176
180
|
|
|
177
181
|
// Types
|
|
@@ -239,6 +243,8 @@ export {
|
|
|
239
243
|
type EmitArtifact,
|
|
240
244
|
type EmitUI,
|
|
241
245
|
type EmitQR,
|
|
246
|
+
type EmitRender,
|
|
247
|
+
type EmitRenderClear,
|
|
242
248
|
|
|
243
249
|
// Checkpoint yield type
|
|
244
250
|
type CheckpointYield,
|
package/src/mixins.ts
CHANGED
|
@@ -23,7 +23,7 @@
|
|
|
23
23
|
*/
|
|
24
24
|
|
|
25
25
|
import { MCPClient, MCPClientFactory, createMCPProxy } from '@portel/mcp';
|
|
26
|
-
import { executionContext } from '@portel/cli';
|
|
26
|
+
import { executionContext, type CallerInfo } from '@portel/cli';
|
|
27
27
|
import { getBroker } from './channels/index.js';
|
|
28
28
|
import { MemoryProvider } from './memory.js';
|
|
29
29
|
import { ScheduleProvider } from './schedule.js';
|
|
@@ -94,6 +94,14 @@ export function withPhotonCapabilities<T extends Constructor>(Base: T): T {
|
|
|
94
94
|
*/
|
|
95
95
|
private _mcpClients: Map<string, MCPClient & Record<string, (params?: any) => Promise<any>>> = new Map();
|
|
96
96
|
|
|
97
|
+
/**
|
|
98
|
+
* Authenticated caller identity from MCP OAuth
|
|
99
|
+
*/
|
|
100
|
+
get caller(): CallerInfo {
|
|
101
|
+
const store = executionContext.getStore();
|
|
102
|
+
return store?.caller ?? { id: 'anonymous', anonymous: true };
|
|
103
|
+
}
|
|
104
|
+
|
|
97
105
|
/**
|
|
98
106
|
* Scoped key-value storage for photon data
|
|
99
107
|
*/
|
|
@@ -124,6 +132,18 @@ export function withPhotonCapabilities<T extends Constructor>(Base: T): T {
|
|
|
124
132
|
return this._schedule;
|
|
125
133
|
}
|
|
126
134
|
|
|
135
|
+
/**
|
|
136
|
+
* Render a formatted value as an intermediate result.
|
|
137
|
+
* Each call replaces the previous render. Call with no args to clear.
|
|
138
|
+
*/
|
|
139
|
+
protected render(format?: string, value?: any): void {
|
|
140
|
+
if (format === undefined) {
|
|
141
|
+
this.emit({ emit: 'render:clear' });
|
|
142
|
+
} else {
|
|
143
|
+
this.emit({ emit: 'render', format, value });
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
127
147
|
/**
|
|
128
148
|
* Emit an event/progress update
|
|
129
149
|
*/
|
|
@@ -139,9 +159,15 @@ export function withPhotonCapabilities<T extends Constructor>(Base: T): T {
|
|
|
139
159
|
}
|
|
140
160
|
|
|
141
161
|
if (data && typeof data.channel === 'string') {
|
|
162
|
+
// Auto-prefix channel with photon name if not already namespaced
|
|
163
|
+
const channel = data.channel.includes(':')
|
|
164
|
+
? data.channel
|
|
165
|
+
: this._photonName
|
|
166
|
+
? `${this._photonName}:${data.channel}`
|
|
167
|
+
: data.channel;
|
|
142
168
|
const broker = getBroker();
|
|
143
169
|
broker.publish({
|
|
144
|
-
channel
|
|
170
|
+
channel,
|
|
145
171
|
event: data.event || 'message',
|
|
146
172
|
data: data.data !== undefined ? data.data : data,
|
|
147
173
|
timestamp: Date.now(),
|
package/src/path-resolver.ts
CHANGED
|
@@ -3,6 +3,14 @@
|
|
|
3
3
|
*
|
|
4
4
|
* Generic path resolution utilities used by photon, lumina, ncp.
|
|
5
5
|
* Configurable file extensions and default directories.
|
|
6
|
+
*
|
|
7
|
+
* Supports namespace-based directory structure:
|
|
8
|
+
* ~/.photon/
|
|
9
|
+
* portel-dev/ ← namespace (marketplace author)
|
|
10
|
+
* whatsapp.photon.ts
|
|
11
|
+
* local/ ← implicit namespace for user-created photons
|
|
12
|
+
* todo.photon.ts
|
|
13
|
+
* legacy.photon.ts ← flat files (pre-migration, still supported)
|
|
6
14
|
*/
|
|
7
15
|
|
|
8
16
|
import * as fs from 'fs/promises';
|
|
@@ -37,9 +45,17 @@ const defaultOptions: Required<ResolverOptions> = {
|
|
|
37
45
|
defaultDir: DEFAULT_PHOTON_DIR,
|
|
38
46
|
};
|
|
39
47
|
|
|
48
|
+
/** Directories to skip when scanning for namespace subdirectories */
|
|
49
|
+
const SKIP_DIRS = new Set([
|
|
50
|
+
'state', 'context', 'env', '.cache', '.config',
|
|
51
|
+
'node_modules', 'marketplace', 'photons', 'templates',
|
|
52
|
+
]);
|
|
53
|
+
|
|
40
54
|
/**
|
|
41
|
-
* Resolve a file path from name
|
|
42
|
-
*
|
|
55
|
+
* Resolve a file path from name.
|
|
56
|
+
*
|
|
57
|
+
* Supports namespace-qualified names: 'namespace:photonName'
|
|
58
|
+
* For unqualified names, searches flat files first, then namespace subdirectories.
|
|
43
59
|
*/
|
|
44
60
|
export async function resolvePath(
|
|
45
61
|
name: string,
|
|
@@ -59,66 +75,172 @@ export async function resolvePath(
|
|
|
59
75
|
}
|
|
60
76
|
}
|
|
61
77
|
|
|
78
|
+
// Parse namespace:name format
|
|
79
|
+
const colonIndex = name.indexOf(':');
|
|
80
|
+
let namespace: string | undefined;
|
|
81
|
+
let photonName: string;
|
|
82
|
+
if (colonIndex !== -1) {
|
|
83
|
+
namespace = name.slice(0, colonIndex);
|
|
84
|
+
photonName = name.slice(colonIndex + 1);
|
|
85
|
+
} else {
|
|
86
|
+
photonName = name;
|
|
87
|
+
}
|
|
88
|
+
|
|
62
89
|
// Remove extension if provided (match any configured extension)
|
|
63
|
-
let basename =
|
|
90
|
+
let basename = photonName;
|
|
64
91
|
for (const ext of opts.extensions) {
|
|
65
|
-
if (
|
|
66
|
-
basename =
|
|
92
|
+
if (photonName.endsWith(ext)) {
|
|
93
|
+
basename = photonName.slice(0, -ext.length);
|
|
67
94
|
break;
|
|
68
95
|
}
|
|
69
96
|
}
|
|
70
97
|
|
|
71
|
-
//
|
|
98
|
+
// If namespace is specified, search only that namespace directory
|
|
99
|
+
if (namespace) {
|
|
100
|
+
for (const ext of opts.extensions) {
|
|
101
|
+
const filePath = path.join(dir, namespace, `${basename}${ext}`);
|
|
102
|
+
try {
|
|
103
|
+
await fs.access(filePath);
|
|
104
|
+
return filePath;
|
|
105
|
+
} catch {
|
|
106
|
+
// Continue
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
return null;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// Unqualified name: search flat files first (backward compat)
|
|
72
113
|
for (const ext of opts.extensions) {
|
|
73
114
|
const filePath = path.join(dir, `${basename}${ext}`);
|
|
74
115
|
try {
|
|
75
116
|
await fs.access(filePath);
|
|
76
117
|
return filePath;
|
|
77
118
|
} catch {
|
|
78
|
-
// Continue
|
|
119
|
+
// Continue
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// Then search namespace subdirectories (one level deep)
|
|
124
|
+
try {
|
|
125
|
+
const entries = await fs.readdir(dir, { withFileTypes: true });
|
|
126
|
+
for (const entry of entries) {
|
|
127
|
+
if (!entry.isDirectory() || SKIP_DIRS.has(entry.name) || entry.name.startsWith('.')) {
|
|
128
|
+
continue;
|
|
129
|
+
}
|
|
130
|
+
for (const ext of opts.extensions) {
|
|
131
|
+
const filePath = path.join(dir, entry.name, `${basename}${ext}`);
|
|
132
|
+
try {
|
|
133
|
+
await fs.access(filePath);
|
|
134
|
+
return filePath;
|
|
135
|
+
} catch {
|
|
136
|
+
// Continue
|
|
137
|
+
}
|
|
138
|
+
}
|
|
79
139
|
}
|
|
140
|
+
} catch {
|
|
141
|
+
// dir doesn't exist
|
|
80
142
|
}
|
|
81
143
|
|
|
82
|
-
// Not found
|
|
83
144
|
return null;
|
|
84
145
|
}
|
|
85
146
|
|
|
86
147
|
/**
|
|
87
|
-
*
|
|
148
|
+
* Result from listing files, including namespace information.
|
|
149
|
+
*/
|
|
150
|
+
export interface ListedPhoton {
|
|
151
|
+
/** Short name (e.g., 'whatsapp') */
|
|
152
|
+
name: string;
|
|
153
|
+
/** Namespace (e.g., 'portel-dev') or empty string for flat/root-level files */
|
|
154
|
+
namespace: string;
|
|
155
|
+
/** Qualified name (e.g., 'portel-dev:whatsapp' or 'whatsapp' for flat) */
|
|
156
|
+
qualifiedName: string;
|
|
157
|
+
/** Full absolute path to the file */
|
|
158
|
+
filePath: string;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* List all matching files in a directory.
|
|
163
|
+
*
|
|
164
|
+
* Scans both flat files (backward compat) and namespace subdirectories.
|
|
165
|
+
* Returns short names for backward compatibility.
|
|
88
166
|
*/
|
|
89
167
|
export async function listFiles(
|
|
90
168
|
workingDir?: string,
|
|
91
169
|
options?: ResolverOptions
|
|
92
170
|
): Promise<string[]> {
|
|
171
|
+
const listed = await listFilesWithNamespace(workingDir, options);
|
|
172
|
+
return listed.map((l) => l.name).sort();
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* List all matching files with full namespace metadata.
|
|
177
|
+
*
|
|
178
|
+
* Scans flat files at the root level and one level of namespace subdirectories.
|
|
179
|
+
*/
|
|
180
|
+
export async function listFilesWithNamespace(
|
|
181
|
+
workingDir?: string,
|
|
182
|
+
options?: ResolverOptions
|
|
183
|
+
): Promise<ListedPhoton[]> {
|
|
93
184
|
const opts = { ...defaultOptions, ...options };
|
|
94
185
|
const dir = expandTilde(workingDir || opts.defaultDir);
|
|
186
|
+
const results: ListedPhoton[] = [];
|
|
95
187
|
|
|
96
188
|
try {
|
|
97
|
-
// Ensure directory exists
|
|
98
189
|
await fs.mkdir(dir, { recursive: true });
|
|
99
|
-
|
|
100
190
|
const entries = await fs.readdir(dir, { withFileTypes: true });
|
|
101
|
-
const files: string[] = [];
|
|
102
191
|
|
|
192
|
+
// Scan flat files at root level (backward compat / pre-migration)
|
|
103
193
|
for (const entry of entries) {
|
|
104
|
-
// Include both regular files and symlinks
|
|
105
194
|
if (entry.isFile() || entry.isSymbolicLink()) {
|
|
106
|
-
// Check if file matches any extension
|
|
107
195
|
for (const ext of opts.extensions) {
|
|
108
196
|
if (entry.name.endsWith(ext)) {
|
|
109
|
-
// Remove extension for display
|
|
110
197
|
const name = entry.name.slice(0, -ext.length);
|
|
111
|
-
|
|
198
|
+
results.push({
|
|
199
|
+
name,
|
|
200
|
+
namespace: '',
|
|
201
|
+
qualifiedName: name,
|
|
202
|
+
filePath: path.join(dir, entry.name),
|
|
203
|
+
});
|
|
112
204
|
break;
|
|
113
205
|
}
|
|
114
206
|
}
|
|
115
207
|
}
|
|
116
208
|
}
|
|
117
209
|
|
|
118
|
-
|
|
210
|
+
// Scan namespace subdirectories (one level deep)
|
|
211
|
+
for (const entry of entries) {
|
|
212
|
+
if (!entry.isDirectory() || SKIP_DIRS.has(entry.name) || entry.name.startsWith('.')) {
|
|
213
|
+
continue;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
const nsDir = path.join(dir, entry.name);
|
|
217
|
+
try {
|
|
218
|
+
const nsEntries = await fs.readdir(nsDir, { withFileTypes: true });
|
|
219
|
+
for (const nsEntry of nsEntries) {
|
|
220
|
+
if (nsEntry.isFile() || nsEntry.isSymbolicLink()) {
|
|
221
|
+
for (const ext of opts.extensions) {
|
|
222
|
+
if (nsEntry.name.endsWith(ext)) {
|
|
223
|
+
const name = nsEntry.name.slice(0, -ext.length);
|
|
224
|
+
results.push({
|
|
225
|
+
name,
|
|
226
|
+
namespace: entry.name,
|
|
227
|
+
qualifiedName: `${entry.name}:${name}`,
|
|
228
|
+
filePath: path.join(nsDir, nsEntry.name),
|
|
229
|
+
});
|
|
230
|
+
break;
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
} catch {
|
|
236
|
+
// Namespace dir unreadable, skip
|
|
237
|
+
}
|
|
238
|
+
}
|
|
119
239
|
} catch {
|
|
120
|
-
|
|
240
|
+
// Root dir doesn't exist
|
|
121
241
|
}
|
|
242
|
+
|
|
243
|
+
return results;
|
|
122
244
|
}
|
|
123
245
|
|
|
124
246
|
/**
|
|
@@ -132,4 +254,5 @@ export async function ensureDir(dir?: string): Promise<void> {
|
|
|
132
254
|
// Convenience aliases for photon-specific usage
|
|
133
255
|
export const resolvePhotonPath = resolvePath;
|
|
134
256
|
export const listPhotonFiles = listFiles;
|
|
257
|
+
export const listPhotonFilesWithNamespace = listFilesWithNamespace;
|
|
135
258
|
export const ensurePhotonDir = ensureDir;
|