capns 0.76.17552 → 0.77.17595

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 (3) hide show
  1. package/capns.js +442 -1
  2. package/capns.test.js +375 -0
  3. package/package.json +1 -1
package/capns.js CHANGED
@@ -3657,6 +3657,440 @@ class StdinSource {
3657
3657
  }
3658
3658
  }
3659
3659
 
3660
+ // =============================================================================
3661
+ // Plugin Repository System
3662
+ // =============================================================================
3663
+
3664
+ /**
3665
+ * Plugin capability summary from registry
3666
+ */
3667
+ class PluginCapSummary {
3668
+ constructor(urn, title, description = '') {
3669
+ this.urn = urn;
3670
+ this.title = title;
3671
+ this.description = description;
3672
+ }
3673
+ }
3674
+
3675
+ /**
3676
+ * Plugin information from registry
3677
+ */
3678
+ class PluginInfo {
3679
+ constructor(data) {
3680
+ this.id = data.id;
3681
+ this.name = data.name;
3682
+ this.version = data.version || '';
3683
+ this.description = data.description || '';
3684
+ this.author = data.author || '';
3685
+ this.pageUrl = data.pageUrl || '';
3686
+ this.teamId = data.teamId || '';
3687
+ this.signedAt = data.signedAt || '';
3688
+ this.minAppVersion = data.minAppVersion || '';
3689
+ this.caps = (data.caps || []).map(c => new PluginCapSummary(c.urn, c.title, c.description || ''));
3690
+ this.categories = data.categories || [];
3691
+ this.tags = data.tags || [];
3692
+ this.changelog = data.changelog || {};
3693
+ // Distribution fields
3694
+ this.platform = data.platform || '';
3695
+ this.packageName = data.packageName || '';
3696
+ this.packageSha256 = data.packageSha256 || '';
3697
+ this.packageSize = data.packageSize || 0;
3698
+ this.binaryName = data.binaryName || '';
3699
+ this.binarySha256 = data.binarySha256 || '';
3700
+ this.binarySize = data.binarySize || 0;
3701
+ this.availableVersions = data.availableVersions || [];
3702
+ }
3703
+
3704
+ /**
3705
+ * Check if plugin is signed (has team_id and signed_at)
3706
+ */
3707
+ isSigned() {
3708
+ return this.teamId.length > 0 && this.signedAt.length > 0;
3709
+ }
3710
+
3711
+ /**
3712
+ * Check if binary download info is available
3713
+ */
3714
+ hasBinary() {
3715
+ return this.binaryName.length > 0 && this.binarySha256.length > 0;
3716
+ }
3717
+ }
3718
+
3719
+ /**
3720
+ * Plugin suggestion for a missing cap
3721
+ */
3722
+ class PluginSuggestion {
3723
+ constructor(data) {
3724
+ this.pluginId = data.pluginId;
3725
+ this.pluginName = data.pluginName;
3726
+ this.pluginDescription = data.pluginDescription;
3727
+ this.capUrn = data.capUrn;
3728
+ this.capTitle = data.capTitle;
3729
+ this.latestVersion = data.latestVersion;
3730
+ this.repoUrl = data.repoUrl;
3731
+ this.pageUrl = data.pageUrl;
3732
+ }
3733
+ }
3734
+
3735
+ /**
3736
+ * Plugin registry cache entry
3737
+ */
3738
+ class PluginRepoCache {
3739
+ constructor(repoUrl) {
3740
+ this.plugins = new Map(); // plugin_id -> PluginInfo
3741
+ this.capToPlugins = new Map(); // cap_urn -> [plugin_ids]
3742
+ this.lastUpdated = Date.now();
3743
+ this.repoUrl = repoUrl;
3744
+ }
3745
+ }
3746
+
3747
+ /**
3748
+ * Plugin repository client - fetches and caches plugin registry
3749
+ */
3750
+ class PluginRepoClient {
3751
+ constructor(cacheTtlSeconds = 3600) {
3752
+ this.caches = new Map(); // repo_url -> PluginRepoCache
3753
+ this.cacheTtl = cacheTtlSeconds * 1000; // Convert to milliseconds
3754
+ }
3755
+
3756
+ /**
3757
+ * Fetch registry from a URL
3758
+ */
3759
+ async fetchRegistry(repoUrl) {
3760
+ const response = await fetch(repoUrl);
3761
+
3762
+ if (!response.ok) {
3763
+ throw new Error(`Plugin registry request failed: HTTP ${response.status} from ${repoUrl}`);
3764
+ }
3765
+
3766
+ const data = await response.json();
3767
+
3768
+ if (!data.plugins || !Array.isArray(data.plugins)) {
3769
+ throw new Error(`Invalid plugin registry response from ${repoUrl}: missing plugins array`);
3770
+ }
3771
+
3772
+ return data.plugins.map(p => new PluginInfo(p));
3773
+ }
3774
+
3775
+ /**
3776
+ * Update cache from registry data
3777
+ */
3778
+ updateCache(repoUrl, plugins) {
3779
+ const cache = new PluginRepoCache(repoUrl);
3780
+
3781
+ for (const plugin of plugins) {
3782
+ cache.plugins.set(plugin.id, plugin);
3783
+
3784
+ for (const cap of plugin.caps) {
3785
+ if (!cache.capToPlugins.has(cap.urn)) {
3786
+ cache.capToPlugins.set(cap.urn, []);
3787
+ }
3788
+ cache.capToPlugins.get(cap.urn).push(plugin.id);
3789
+ }
3790
+ }
3791
+
3792
+ this.caches.set(repoUrl, cache);
3793
+ }
3794
+
3795
+ /**
3796
+ * Check if cache is stale
3797
+ */
3798
+ isCacheStale(cache) {
3799
+ return (Date.now() - cache.lastUpdated) > this.cacheTtl;
3800
+ }
3801
+
3802
+ /**
3803
+ * Sync plugin data from repository URLs
3804
+ */
3805
+ async syncRepos(repoUrls) {
3806
+ for (const repoUrl of repoUrls) {
3807
+ try {
3808
+ const plugins = await this.fetchRegistry(repoUrl);
3809
+ this.updateCache(repoUrl, plugins);
3810
+ } catch (e) {
3811
+ console.warn(`Failed to sync plugin repo ${repoUrl}: ${e.message}`);
3812
+ // Continue with other repos
3813
+ }
3814
+ }
3815
+ }
3816
+
3817
+ /**
3818
+ * Check if any repo needs syncing
3819
+ */
3820
+ needsSync(repoUrls) {
3821
+ for (const repoUrl of repoUrls) {
3822
+ const cache = this.caches.get(repoUrl);
3823
+ if (!cache || this.isCacheStale(cache)) {
3824
+ return true;
3825
+ }
3826
+ }
3827
+ return false;
3828
+ }
3829
+
3830
+ /**
3831
+ * Get plugin suggestions for a cap URN
3832
+ */
3833
+ getSuggestionsForCap(capUrn) {
3834
+ const suggestions = [];
3835
+
3836
+ for (const cache of this.caches.values()) {
3837
+ const pluginIds = cache.capToPlugins.get(capUrn);
3838
+ if (!pluginIds) continue;
3839
+
3840
+ for (const pluginId of pluginIds) {
3841
+ const plugin = cache.plugins.get(pluginId);
3842
+ if (!plugin) continue;
3843
+
3844
+ const capInfo = plugin.caps.find(c => c.urn === capUrn);
3845
+ if (!capInfo) continue;
3846
+
3847
+ const pageUrl = plugin.pageUrl || cache.repoUrl;
3848
+
3849
+ suggestions.push(new PluginSuggestion({
3850
+ pluginId: plugin.id,
3851
+ pluginName: plugin.name,
3852
+ pluginDescription: plugin.description,
3853
+ capUrn: capUrn,
3854
+ capTitle: capInfo.title,
3855
+ latestVersion: plugin.version,
3856
+ repoUrl: cache.repoUrl,
3857
+ pageUrl: pageUrl
3858
+ }));
3859
+ }
3860
+ }
3861
+
3862
+ return suggestions;
3863
+ }
3864
+
3865
+ /**
3866
+ * Get all available plugins from all repos
3867
+ */
3868
+ getAllPlugins() {
3869
+ const plugins = [];
3870
+ for (const cache of this.caches.values()) {
3871
+ for (const [pluginId, pluginInfo] of cache.plugins) {
3872
+ plugins.push([pluginId, pluginInfo]);
3873
+ }
3874
+ }
3875
+ return plugins;
3876
+ }
3877
+
3878
+ /**
3879
+ * Get all available cap URNs from plugins
3880
+ */
3881
+ getAllAvailableCaps() {
3882
+ const caps = new Set();
3883
+ for (const cache of this.caches.values()) {
3884
+ for (const capUrn of cache.capToPlugins.keys()) {
3885
+ caps.add(capUrn);
3886
+ }
3887
+ }
3888
+ return Array.from(caps).sort();
3889
+ }
3890
+
3891
+ /**
3892
+ * Get plugin info by ID
3893
+ */
3894
+ getPlugin(pluginId) {
3895
+ for (const cache of this.caches.values()) {
3896
+ const plugin = cache.plugins.get(pluginId);
3897
+ if (plugin) {
3898
+ return plugin;
3899
+ }
3900
+ }
3901
+ return null;
3902
+ }
3903
+
3904
+ /**
3905
+ * Get suggestions for missing caps
3906
+ */
3907
+ getSuggestionsForMissingCaps(availableCaps, requestedCaps) {
3908
+ const availableSet = new Set(availableCaps);
3909
+ const suggestions = [];
3910
+
3911
+ for (const capUrn of requestedCaps) {
3912
+ if (!availableSet.has(capUrn)) {
3913
+ suggestions.push(...this.getSuggestionsForCap(capUrn));
3914
+ }
3915
+ }
3916
+
3917
+ return suggestions;
3918
+ }
3919
+ }
3920
+
3921
+ /**
3922
+ * Plugin repository server - serves registry data with queries
3923
+ */
3924
+ class PluginRepoServer {
3925
+ constructor(registry) {
3926
+ this.registry = registry;
3927
+ this.validateRegistry();
3928
+ }
3929
+
3930
+ /**
3931
+ * Validate registry schema
3932
+ */
3933
+ validateRegistry() {
3934
+ if (!this.registry) {
3935
+ throw new Error('Registry is required');
3936
+ }
3937
+ if (this.registry.schemaVersion !== '3.0') {
3938
+ throw new Error(`Unsupported registry schema version: ${this.registry.schemaVersion}. Required: 3.0`);
3939
+ }
3940
+ if (!this.registry.plugins || typeof this.registry.plugins !== 'object') {
3941
+ throw new Error('Registry must have plugins object');
3942
+ }
3943
+ }
3944
+
3945
+ /**
3946
+ * Validate version data has all required fields
3947
+ */
3948
+ validateVersionData(id, version, versionData) {
3949
+ if (!versionData.platform) {
3950
+ throw new Error(`Plugin ${id} v${version}: missing required field 'platform'`);
3951
+ }
3952
+ if (!versionData.package || !versionData.package.name) {
3953
+ throw new Error(`Plugin ${id} v${version}: missing required field 'package'`);
3954
+ }
3955
+ if (!versionData.binary || !versionData.binary.name) {
3956
+ throw new Error(`Plugin ${id} v${version}: missing required field 'binary'`);
3957
+ }
3958
+ }
3959
+
3960
+ /**
3961
+ * Compare version strings
3962
+ */
3963
+ compareVersions(a, b) {
3964
+ const partsA = a.split('.').map(x => parseInt(x) || 0);
3965
+ const partsB = b.split('.').map(x => parseInt(x) || 0);
3966
+
3967
+ for (let i = 0; i < Math.max(partsA.length, partsB.length); i++) {
3968
+ const numA = partsA[i] || 0;
3969
+ const numB = partsB[i] || 0;
3970
+ if (numA !== numB) {
3971
+ return numA - numB;
3972
+ }
3973
+ }
3974
+ return 0;
3975
+ }
3976
+
3977
+ /**
3978
+ * Build changelog map from versions
3979
+ */
3980
+ buildChangelogMap(versions) {
3981
+ const changelog = {};
3982
+ for (const [version, versionData] of Object.entries(versions)) {
3983
+ if (versionData.changelog && Array.isArray(versionData.changelog)) {
3984
+ changelog[version] = versionData.changelog;
3985
+ }
3986
+ }
3987
+ return changelog;
3988
+ }
3989
+
3990
+ /**
3991
+ * Transform registry to flat plugin array
3992
+ */
3993
+ transformToPluginArray() {
3994
+ const pluginsObject = this.registry.plugins || {};
3995
+ const plugins = [];
3996
+
3997
+ for (const [id, plugin] of Object.entries(pluginsObject)) {
3998
+ const latestVersion = plugin.latestVersion;
3999
+ const versionData = plugin.versions[latestVersion];
4000
+
4001
+ if (!versionData) {
4002
+ throw new Error(`Plugin ${id}: latest version ${latestVersion} not found in versions`);
4003
+ }
4004
+
4005
+ // Validate required fields - fail hard
4006
+ this.validateVersionData(id, latestVersion, versionData);
4007
+
4008
+ // Get all version numbers sorted descending
4009
+ const availableVersions = Object.keys(plugin.versions).sort((a, b) => {
4010
+ return this.compareVersions(b, a);
4011
+ });
4012
+
4013
+ // Build flat plugin object with latest version data
4014
+ const packageUrl = `https://filegrind.com/plugins/packages/${versionData.package.name}`;
4015
+ plugins.push({
4016
+ id,
4017
+ name: plugin.name,
4018
+ version: latestVersion,
4019
+ description: plugin.description,
4020
+ author: plugin.author,
4021
+ pageUrl: plugin.pageUrl || packageUrl,
4022
+ teamId: plugin.teamId,
4023
+ signedAt: versionData.releaseDate,
4024
+ minAppVersion: versionData.minAppVersion || plugin.minAppVersion,
4025
+ caps: plugin.caps || [],
4026
+ categories: plugin.categories,
4027
+ tags: plugin.tags,
4028
+ changelog: this.buildChangelogMap(plugin.versions),
4029
+ // Distribution fields - ALL REQUIRED
4030
+ platform: versionData.platform,
4031
+ packageName: versionData.package.name,
4032
+ packageSha256: versionData.package.sha256,
4033
+ packageSize: versionData.package.size,
4034
+ binaryName: versionData.binary.name,
4035
+ binarySha256: versionData.binary.sha256,
4036
+ binarySize: versionData.binary.size,
4037
+ // All available versions
4038
+ availableVersions
4039
+ });
4040
+ }
4041
+
4042
+ return plugins;
4043
+ }
4044
+
4045
+ /**
4046
+ * Get all plugins (API response format)
4047
+ */
4048
+ getPlugins() {
4049
+ return {
4050
+ plugins: this.transformToPluginArray()
4051
+ };
4052
+ }
4053
+
4054
+ /**
4055
+ * Get plugin by ID
4056
+ */
4057
+ getPluginById(id) {
4058
+ const plugins = this.transformToPluginArray();
4059
+ return plugins.find(p => p.id === id);
4060
+ }
4061
+
4062
+ /**
4063
+ * Search plugins by query
4064
+ */
4065
+ searchPlugins(query) {
4066
+ const plugins = this.transformToPluginArray();
4067
+ const lowerQuery = query.toLowerCase();
4068
+
4069
+ return plugins.filter(p =>
4070
+ p.name.toLowerCase().includes(lowerQuery) ||
4071
+ p.description.toLowerCase().includes(lowerQuery) ||
4072
+ p.tags.some(t => t.toLowerCase().includes(lowerQuery)) ||
4073
+ p.caps.some(c => c.urn.toLowerCase().includes(lowerQuery) || c.title.toLowerCase().includes(lowerQuery))
4074
+ );
4075
+ }
4076
+
4077
+ /**
4078
+ * Get plugins by category
4079
+ */
4080
+ getPluginsByCategory(category) {
4081
+ const plugins = this.transformToPluginArray();
4082
+ return plugins.filter(p => p.categories.includes(category));
4083
+ }
4084
+
4085
+ /**
4086
+ * Get plugins that provide a specific cap
4087
+ */
4088
+ getPluginsByCap(capUrn) {
4089
+ const plugins = this.transformToPluginArray();
4090
+ return plugins.filter(p => p.caps.some(c => c.urn === capUrn));
4091
+ }
4092
+ }
4093
+
3660
4094
  // Export for CommonJS
3661
4095
  module.exports = {
3662
4096
  CapUrn,
@@ -3756,5 +4190,12 @@ module.exports = {
3756
4190
  CapGraphStats,
3757
4191
  CapGraph,
3758
4192
  StdinSource,
3759
- StdinSourceKind
4193
+ StdinSourceKind,
4194
+ // Plugin Repository
4195
+ PluginCapSummary,
4196
+ PluginInfo,
4197
+ PluginSuggestion,
4198
+ PluginRepoCache,
4199
+ PluginRepoClient,
4200
+ PluginRepoServer
3760
4201
  };
package/capns.test.js CHANGED
@@ -8,6 +8,7 @@ const {
8
8
  Cap, MediaSpec, MediaSpecError, MediaSpecErrorCodes,
9
9
  resolveMediaUrn, buildExtensionIndex, mediaUrnsForExtension, getExtensionMappings,
10
10
  CapMatrixError, CapMatrix, BestCapSetMatch, CompositeCapSet, CapBlock,
11
+ PluginInfo, PluginCapSummary, PluginSuggestion, PluginRepoClient, PluginRepoServer,
11
12
  CapGraphEdge, CapGraphStats, CapGraph,
12
13
  StdinSource, StdinSourceKind,
13
14
  validateNoMediaSpecRedefinitionSync,
@@ -1903,6 +1904,361 @@ function testJS_mediaSpecConstruction() {
1903
1904
  assertEqual(spec2.profile, null, 'Should have null profile');
1904
1905
  }
1905
1906
 
1907
+ // =============================================================================
1908
+ // Plugin Repository Tests (TEST320-TEST335)
1909
+ // =============================================================================
1910
+
1911
+ // Sample registry for testing
1912
+ const sampleRegistry = {
1913
+ schemaVersion: '3.0',
1914
+ lastUpdated: '2026-02-07T16:48:28Z',
1915
+ plugins: {
1916
+ pdfcartridge: {
1917
+ name: 'pdfcartridge',
1918
+ description: 'PDF document processor',
1919
+ author: 'test-author',
1920
+ pageUrl: 'https://example.com/pdf',
1921
+ teamId: 'P336JK947M',
1922
+ minAppVersion: '1.0.0',
1923
+ categories: ['document'],
1924
+ tags: ['pdf', 'extractor'],
1925
+ caps: [
1926
+ {
1927
+ urn: 'cap:in="media:pdf;bytes";op=disbind;out="media:disbound-page;textable;form=list"',
1928
+ title: 'Disbind PDF',
1929
+ description: 'Extract pages'
1930
+ },
1931
+ {
1932
+ urn: 'cap:in="media:pdf;bytes";op=extract_metadata;out="media:file-metadata;textable;form=map"',
1933
+ title: 'Extract Metadata',
1934
+ description: 'Get PDF metadata'
1935
+ }
1936
+ ],
1937
+ latestVersion: '0.81.5325',
1938
+ versions: {
1939
+ '0.81.5325': {
1940
+ releaseDate: '2026-02-07T16:40:28Z',
1941
+ changelog: ['Initial release'],
1942
+ minAppVersion: '1.0.0',
1943
+ platform: 'darwin-arm64',
1944
+ package: {
1945
+ name: 'pdfcartridge-0.81.5325.pkg',
1946
+ sha256: '9b68724eb9220ecf01e8ed4f5f80c594fbac2239bc5bf675005ec882ecc5eba0',
1947
+ size: 5187485
1948
+ },
1949
+ binary: {
1950
+ name: 'pdfcartridge-0.81.5325-darwin-arm64',
1951
+ sha256: '908187ec35632758f1a00452ff4755ba01020ea288619098b6998d5d33851d19',
1952
+ size: 12980288
1953
+ }
1954
+ }
1955
+ }
1956
+ },
1957
+ txtcartridge: {
1958
+ name: 'txtcartridge',
1959
+ description: 'Text file processor',
1960
+ author: 'test-author',
1961
+ pageUrl: 'https://example.com/txt',
1962
+ teamId: 'P336JK947M',
1963
+ minAppVersion: '1.0.0',
1964
+ categories: ['text'],
1965
+ tags: ['txt', 'text'],
1966
+ caps: [
1967
+ {
1968
+ urn: 'cap:in="media:txt;textable";op=disbind;out="media:disbound-page;textable;form=list"',
1969
+ title: 'Disbind Text',
1970
+ description: 'Extract text pages'
1971
+ }
1972
+ ],
1973
+ latestVersion: '0.54.6408',
1974
+ versions: {
1975
+ '0.54.6408': {
1976
+ releaseDate: '2026-02-07T17:44:00Z',
1977
+ changelog: ['First version'],
1978
+ minAppVersion: '1.0.0',
1979
+ platform: 'darwin-arm64',
1980
+ package: {
1981
+ name: 'txtcartridge-0.54.6408.pkg',
1982
+ sha256: 'abc123',
1983
+ size: 821000
1984
+ },
1985
+ binary: {
1986
+ name: 'txtcartridge-0.54.6408-darwin-arm64',
1987
+ sha256: 'def456',
1988
+ size: 1700000
1989
+ }
1990
+ }
1991
+ }
1992
+ }
1993
+ }
1994
+ };
1995
+
1996
+ // TEST320: Plugin info construction
1997
+ function test320_pluginInfoConstruction() {
1998
+ const data = {
1999
+ id: 'testplugin',
2000
+ name: 'Test Plugin',
2001
+ version: '1.0.0',
2002
+ description: 'A test',
2003
+ teamId: 'TEAM123',
2004
+ signedAt: '2026-01-01',
2005
+ binaryName: 'test-binary',
2006
+ binarySha256: 'abc123',
2007
+ caps: [{urn: 'cap:in="media:void";op=test;out="media:void"', title: 'Test', description: ''}]
2008
+ };
2009
+ const plugin = new PluginInfo(data);
2010
+ assert(plugin.id === 'testplugin', 'ID should match');
2011
+ assert(plugin.teamId === 'TEAM123', 'Team ID should match');
2012
+ assert(plugin.caps.length === 1, 'Should have 1 cap');
2013
+ assert(plugin.caps[0].urn === 'cap:in="media:void";op=test;out="media:void"', 'Cap URN should match');
2014
+ }
2015
+
2016
+ // TEST321: Plugin info is signed check
2017
+ function test321_pluginInfoIsSigned() {
2018
+ const signed = new PluginInfo({id: 'test', teamId: 'TEAM', signedAt: '2026-01-01', caps: []});
2019
+ assert(signed.isSigned() === true, 'Plugin with teamId and signedAt should be signed');
2020
+
2021
+ const unsigned1 = new PluginInfo({id: 'test', teamId: '', signedAt: '2026-01-01', caps: []});
2022
+ assert(unsigned1.isSigned() === false, 'Plugin without teamId should not be signed');
2023
+
2024
+ const unsigned2 = new PluginInfo({id: 'test', teamId: 'TEAM', signedAt: '', caps: []});
2025
+ assert(unsigned2.isSigned() === false, 'Plugin without signedAt should not be signed');
2026
+ }
2027
+
2028
+ // TEST322: Plugin info has binary check
2029
+ function test322_pluginInfoHasBinary() {
2030
+ const withBinary = new PluginInfo({id: 'test', binaryName: 'test-bin', binarySha256: 'abc', caps: []});
2031
+ assert(withBinary.hasBinary() === true, 'Plugin with binary info should return true');
2032
+
2033
+ const noBinary1 = new PluginInfo({id: 'test', binaryName: '', binarySha256: 'abc', caps: []});
2034
+ assert(noBinary1.hasBinary() === false, 'Plugin without binaryName should return false');
2035
+
2036
+ const noBinary2 = new PluginInfo({id: 'test', binaryName: 'test', binarySha256: '', caps: []});
2037
+ assert(noBinary2.hasBinary() === false, 'Plugin without binarySha256 should return false');
2038
+ }
2039
+
2040
+ // TEST323: PluginRepoServer validate registry
2041
+ function test323_pluginRepoServerValidateRegistry() {
2042
+ // Valid registry
2043
+ const server = new PluginRepoServer(sampleRegistry);
2044
+ assert(server.registry.schemaVersion === '3.0', 'Should accept valid registry');
2045
+
2046
+ // Invalid schema version
2047
+ let threw = false;
2048
+ try {
2049
+ new PluginRepoServer({schemaVersion: '2.0', plugins: {}});
2050
+ } catch (e) {
2051
+ threw = true;
2052
+ assert(e.message.includes('schema version'), 'Should reject wrong schema version');
2053
+ }
2054
+ assert(threw, 'Should throw for invalid schema');
2055
+
2056
+ // Missing plugins
2057
+ threw = false;
2058
+ try {
2059
+ new PluginRepoServer({schemaVersion: '3.0'});
2060
+ } catch (e) {
2061
+ threw = true;
2062
+ assert(e.message.includes('plugins'), 'Should reject missing plugins');
2063
+ }
2064
+ assert(threw, 'Should throw for missing plugins');
2065
+ }
2066
+
2067
+ // TEST324: PluginRepoServer transform to array
2068
+ function test324_pluginRepoServerTransformToArray() {
2069
+ const server = new PluginRepoServer(sampleRegistry);
2070
+ const plugins = server.transformToPluginArray();
2071
+
2072
+ assert(Array.isArray(plugins), 'Should return array');
2073
+ assert(plugins.length === 2, 'Should have 2 plugins');
2074
+
2075
+ const pdf = plugins.find(p => p.id === 'pdfcartridge');
2076
+ assert(pdf !== undefined, 'Should include pdfcartridge');
2077
+ assert(pdf.version === '0.81.5325', 'Should have latest version');
2078
+ assert(pdf.teamId === 'P336JK947M', 'Should have teamId');
2079
+ assert(pdf.signedAt === '2026-02-07T16:40:28Z', 'Should have signedAt from releaseDate');
2080
+ assert(pdf.binaryName === 'pdfcartridge-0.81.5325-darwin-arm64', 'Should have binary name');
2081
+ assert(pdf.binarySha256 === '908187ec35632758f1a00452ff4755ba01020ea288619098b6998d5d33851d19', 'Should have SHA256');
2082
+ assert(Array.isArray(pdf.caps), 'Should have caps array');
2083
+ assert(pdf.caps.length === 2, 'Should have 2 caps');
2084
+ }
2085
+
2086
+ // TEST325: PluginRepoServer get plugins
2087
+ function test325_pluginRepoServerGetPlugins() {
2088
+ const server = new PluginRepoServer(sampleRegistry);
2089
+ const response = server.getPlugins();
2090
+
2091
+ assert(response.plugins !== undefined, 'Should have plugins field');
2092
+ assert(Array.isArray(response.plugins), 'Plugins should be array');
2093
+ assert(response.plugins.length === 2, 'Should have 2 plugins');
2094
+ }
2095
+
2096
+ // TEST326: PluginRepoServer get plugin by ID
2097
+ function test326_pluginRepoServerGetPluginById() {
2098
+ const server = new PluginRepoServer(sampleRegistry);
2099
+
2100
+ const pdf = server.getPluginById('pdfcartridge');
2101
+ assert(pdf !== undefined, 'Should find pdfcartridge');
2102
+ assert(pdf.id === 'pdfcartridge', 'Should have correct ID');
2103
+
2104
+ const notFound = server.getPluginById('nonexistent');
2105
+ assert(notFound === undefined, 'Should return undefined for missing plugin');
2106
+ }
2107
+
2108
+ // TEST327: PluginRepoServer search plugins
2109
+ function test327_pluginRepoServerSearchPlugins() {
2110
+ const server = new PluginRepoServer(sampleRegistry);
2111
+
2112
+ const pdfResults = server.searchPlugins('pdf');
2113
+ assert(pdfResults.length === 1, 'Should find 1 PDF plugin');
2114
+ assert(pdfResults[0].id === 'pdfcartridge', 'Should find pdfcartridge');
2115
+
2116
+ const metadataResults = server.searchPlugins('metadata');
2117
+ assert(metadataResults.length === 1, 'Should find plugin by cap title');
2118
+
2119
+ const noResults = server.searchPlugins('nonexistent');
2120
+ assert(noResults.length === 0, 'Should return empty for no matches');
2121
+ }
2122
+
2123
+ // TEST328: PluginRepoServer get by category
2124
+ function test328_pluginRepoServerGetByCategory() {
2125
+ const server = new PluginRepoServer(sampleRegistry);
2126
+
2127
+ const docPlugins = server.getPluginsByCategory('document');
2128
+ assert(docPlugins.length === 1, 'Should find 1 document plugin');
2129
+ assert(docPlugins[0].id === 'pdfcartridge', 'Should be pdfcartridge');
2130
+
2131
+ const textPlugins = server.getPluginsByCategory('text');
2132
+ assert(textPlugins.length === 1, 'Should find 1 text plugin');
2133
+ assert(textPlugins[0].id === 'txtcartridge', 'Should be txtcartridge');
2134
+ }
2135
+
2136
+ // TEST329: PluginRepoServer get by cap
2137
+ function test329_pluginRepoServerGetByCap() {
2138
+ const server = new PluginRepoServer(sampleRegistry);
2139
+
2140
+ const disbindCap = 'cap:in="media:pdf;bytes";op=disbind;out="media:disbound-page;textable;form=list"';
2141
+ const plugins = server.getPluginsByCap(disbindCap);
2142
+
2143
+ assert(plugins.length === 1, 'Should find 1 plugin with this cap');
2144
+ assert(plugins[0].id === 'pdfcartridge', 'Should be pdfcartridge');
2145
+
2146
+ const metadataCap = 'cap:in="media:pdf;bytes";op=extract_metadata;out="media:file-metadata;textable;form=map"';
2147
+ const metadataPlugins = server.getPluginsByCap(metadataCap);
2148
+ assert(metadataPlugins.length === 1, 'Should find metadata cap');
2149
+ }
2150
+
2151
+ // TEST330: PluginRepoClient update cache
2152
+ function test330_pluginRepoClientUpdateCache() {
2153
+ const client = new PluginRepoClient(3600);
2154
+ const server = new PluginRepoServer(sampleRegistry);
2155
+ const plugins = server.transformToPluginArray().map(p => new PluginInfo(p));
2156
+
2157
+ client.updateCache('https://example.com/api/plugins', plugins);
2158
+
2159
+ const cache = client.caches.get('https://example.com/api/plugins');
2160
+ assert(cache !== undefined, 'Cache should exist');
2161
+ assert(cache.plugins.size === 2, 'Should have 2 plugins in cache');
2162
+ assert(cache.capToPlugins.size > 0, 'Should have cap mappings');
2163
+ }
2164
+
2165
+ // TEST331: PluginRepoClient get suggestions
2166
+ function test331_pluginRepoClientGetSuggestions() {
2167
+ const client = new PluginRepoClient(3600);
2168
+ const server = new PluginRepoServer(sampleRegistry);
2169
+ const plugins = server.transformToPluginArray().map(p => new PluginInfo(p));
2170
+
2171
+ client.updateCache('https://example.com/api/plugins', plugins);
2172
+
2173
+ const disbindCap = 'cap:in="media:pdf;bytes";op=disbind;out="media:disbound-page;textable;form=list"';
2174
+ const suggestions = client.getSuggestionsForCap(disbindCap);
2175
+
2176
+ assert(suggestions.length === 1, 'Should find 1 suggestion');
2177
+ assert(suggestions[0].pluginId === 'pdfcartridge', 'Should suggest pdfcartridge');
2178
+ assert(suggestions[0].capUrn === disbindCap, 'Should have correct cap URN');
2179
+ assert(suggestions[0].capTitle === 'Disbind PDF', 'Should have cap title');
2180
+ }
2181
+
2182
+ // TEST332: PluginRepoClient get plugin
2183
+ function test332_pluginRepoClientGetPlugin() {
2184
+ const client = new PluginRepoClient(3600);
2185
+ const server = new PluginRepoServer(sampleRegistry);
2186
+ const plugins = server.transformToPluginArray().map(p => new PluginInfo(p));
2187
+
2188
+ client.updateCache('https://example.com/api/plugins', plugins);
2189
+
2190
+ const plugin = client.getPlugin('pdfcartridge');
2191
+ assert(plugin !== null, 'Should find plugin');
2192
+ assert(plugin.id === 'pdfcartridge', 'Should have correct ID');
2193
+
2194
+ const notFound = client.getPlugin('nonexistent');
2195
+ assert(notFound === null, 'Should return null for missing plugin');
2196
+ }
2197
+
2198
+ // TEST333: PluginRepoClient get all caps
2199
+ function test333_pluginRepoClientGetAllCaps() {
2200
+ const client = new PluginRepoClient(3600);
2201
+ const server = new PluginRepoServer(sampleRegistry);
2202
+ const plugins = server.transformToPluginArray().map(p => new PluginInfo(p));
2203
+
2204
+ client.updateCache('https://example.com/api/plugins', plugins);
2205
+
2206
+ const caps = client.getAllAvailableCaps();
2207
+ assert(Array.isArray(caps), 'Should return array');
2208
+ assert(caps.length === 3, 'Should have 3 unique caps');
2209
+ assert(caps.every(c => typeof c === 'string'), 'All caps should be strings');
2210
+ }
2211
+
2212
+ // TEST334: PluginRepoClient needs sync
2213
+ function test334_pluginRepoClientNeedsSync() {
2214
+ const client = new PluginRepoClient(1); // 1 second TTL
2215
+ const server = new PluginRepoServer(sampleRegistry);
2216
+ const plugins = server.transformToPluginArray().map(p => new PluginInfo(p));
2217
+
2218
+ const urls = ['https://example.com/api/plugins'];
2219
+
2220
+ // Should need sync initially
2221
+ assert(client.needsSync(urls) === true, 'Should need sync with empty cache');
2222
+
2223
+ // Update cache
2224
+ client.updateCache(urls[0], plugins);
2225
+
2226
+ // Should not need sync immediately
2227
+ assert(client.needsSync(urls) === false, 'Should not need sync right after update');
2228
+
2229
+ // Wait for cache to expire (1 second)
2230
+ // Note: Can't test this synchronously, would need async test
2231
+ }
2232
+
2233
+ // TEST335: PluginRepoServer and Client integration
2234
+ function test335_pluginRepoServerClientIntegration() {
2235
+ // Server creates API response
2236
+ const server = new PluginRepoServer(sampleRegistry);
2237
+ const apiResponse = server.getPlugins();
2238
+
2239
+ // Client consumes API response
2240
+ const client = new PluginRepoClient(3600);
2241
+ const plugins = apiResponse.plugins.map(p => new PluginInfo(p));
2242
+ client.updateCache('https://example.com/api/plugins', plugins);
2243
+
2244
+ // Client can find plugin
2245
+ const plugin = client.getPlugin('pdfcartridge');
2246
+ assert(plugin !== null, 'Client should find plugin from server data');
2247
+ assert(plugin.isSigned(), 'Plugin should be signed');
2248
+ assert(plugin.hasBinary(), 'Plugin should have binary');
2249
+
2250
+ // Client can get suggestions
2251
+ const capUrn = 'cap:in="media:pdf;bytes";op=disbind;out="media:disbound-page;textable;form=list"';
2252
+ const suggestions = client.getSuggestionsForCap(capUrn);
2253
+ assert(suggestions.length === 1, 'Should get suggestions');
2254
+ assert(suggestions[0].pluginId === 'pdfcartridge', 'Should suggest correct plugin');
2255
+
2256
+ // Server can search
2257
+ const searchResults = server.searchPlugins('pdf');
2258
+ assert(searchResults.length === 1, 'Server search should work');
2259
+ assert(searchResults[0].id === plugin.id, 'Search and client should agree');
2260
+ }
2261
+
1906
2262
  // ============================================================================
1907
2263
  // Test runner
1908
2264
  // ============================================================================
@@ -2078,6 +2434,25 @@ async function runTests() {
2078
2434
  if (p2) await p2;
2079
2435
  runTest('JS: media_spec_construction', testJS_mediaSpecConstruction);
2080
2436
 
2437
+ // plugin_repo: PluginRepoServer and PluginRepoClient tests
2438
+ console.log('\n--- plugin_repo ---');
2439
+ runTest('TEST320: plugin_info_construction', test320_pluginInfoConstruction);
2440
+ runTest('TEST321: plugin_info_is_signed', test321_pluginInfoIsSigned);
2441
+ runTest('TEST322: plugin_info_has_binary', test322_pluginInfoHasBinary);
2442
+ runTest('TEST323: plugin_repo_server_validate_registry', test323_pluginRepoServerValidateRegistry);
2443
+ runTest('TEST324: plugin_repo_server_transform_to_array', test324_pluginRepoServerTransformToArray);
2444
+ runTest('TEST325: plugin_repo_server_get_plugins', test325_pluginRepoServerGetPlugins);
2445
+ runTest('TEST326: plugin_repo_server_get_plugin_by_id', test326_pluginRepoServerGetPluginById);
2446
+ runTest('TEST327: plugin_repo_server_search_plugins', test327_pluginRepoServerSearchPlugins);
2447
+ runTest('TEST328: plugin_repo_server_get_by_category', test328_pluginRepoServerGetByCategory);
2448
+ runTest('TEST329: plugin_repo_server_get_by_cap', test329_pluginRepoServerGetByCap);
2449
+ runTest('TEST330: plugin_repo_client_update_cache', test330_pluginRepoClientUpdateCache);
2450
+ runTest('TEST331: plugin_repo_client_get_suggestions', test331_pluginRepoClientGetSuggestions);
2451
+ runTest('TEST332: plugin_repo_client_get_plugin', test332_pluginRepoClientGetPlugin);
2452
+ runTest('TEST333: plugin_repo_client_get_all_caps', test333_pluginRepoClientGetAllCaps);
2453
+ runTest('TEST334: plugin_repo_client_needs_sync', test334_pluginRepoClientNeedsSync);
2454
+ runTest('TEST335: plugin_repo_server_client_integration', test335_pluginRepoServerClientIntegration);
2455
+
2081
2456
  // Summary
2082
2457
  console.log(`\n${passCount + failCount} tests: ${passCount} passed, ${failCount} failed`);
2083
2458
  if (failCount > 0) {
package/package.json CHANGED
@@ -32,5 +32,5 @@
32
32
  "scripts": {
33
33
  "test": "node capns.test.js"
34
34
  },
35
- "version": "0.76.17552"
35
+ "version": "0.77.17595"
36
36
  }