@hanamesh/dsh-app-host 0.2.0-rc.2

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 (82) hide show
  1. package/LICENSE +31 -0
  2. package/README.md +104 -0
  3. package/consistency.json +101 -0
  4. package/dist/client-ui.d.ts +19 -0
  5. package/dist/client-ui.js +271 -0
  6. package/dist/client.d.ts +16 -0
  7. package/dist/client.js +42 -0
  8. package/dist/consistency.js +12 -0
  9. package/dist/descriptor.js +141 -0
  10. package/dist/dsh.d.ts +25 -0
  11. package/dist/dsh.js +194 -0
  12. package/dist/errors.js +25 -0
  13. package/dist/gateway.js +224 -0
  14. package/dist/guardian.js +113 -0
  15. package/dist/index.d.ts +130 -0
  16. package/dist/index.js +15 -0
  17. package/dist/launcher.js +17 -0
  18. package/dist/library/catalog.js +108 -0
  19. package/dist/library/domain.js +8 -0
  20. package/dist/library/install.js +87 -0
  21. package/dist/library/installed.js +67 -0
  22. package/dist/library/locate.js +79 -0
  23. package/dist/library/routes.js +17 -0
  24. package/dist/library/service.js +76 -0
  25. package/dist/manager.js +605 -0
  26. package/dist/provision/LICENSE +6 -0
  27. package/dist/provision/THIRD_PARTY_NOTICES.md +13 -0
  28. package/dist/provision/archive.d.ts +28 -0
  29. package/dist/provision/archive.js +66 -0
  30. package/dist/provision/check.d.ts +10 -0
  31. package/dist/provision/check.js +51 -0
  32. package/dist/provision/checkpoints.d.ts +12 -0
  33. package/dist/provision/checkpoints.js +23 -0
  34. package/dist/provision/cli.d.ts +1 -0
  35. package/dist/provision/cli.js +169 -0
  36. package/dist/provision/download.d.ts +21 -0
  37. package/dist/provision/download.js +242 -0
  38. package/dist/provision/errors.d.ts +11 -0
  39. package/dist/provision/errors.js +13 -0
  40. package/dist/provision/extract.d.ts +17 -0
  41. package/dist/provision/extract.js +124 -0
  42. package/dist/provision/fsutil.d.ts +8 -0
  43. package/dist/provision/fsutil.js +71 -0
  44. package/dist/provision/index.d.ts +7 -0
  45. package/dist/provision/index.js +6 -0
  46. package/dist/provision/ledger.d.ts +14 -0
  47. package/dist/provision/ledger.js +50 -0
  48. package/dist/provision/manifest.d.ts +11 -0
  49. package/dist/provision/manifest.js +171 -0
  50. package/dist/provision/paths.d.ts +27 -0
  51. package/dist/provision/paths.js +95 -0
  52. package/dist/provision/platform.d.ts +6 -0
  53. package/dist/provision/platform.js +7 -0
  54. package/dist/provision/provision.d.ts +17 -0
  55. package/dist/provision/provision.js +454 -0
  56. package/dist/provision/tar.d.ts +7 -0
  57. package/dist/provision/tar.js +169 -0
  58. package/dist/provision/types.d.ts +174 -0
  59. package/dist/provision/types.js +7 -0
  60. package/dist/provision/zip.d.ts +4 -0
  61. package/dist/provision/zip.js +194 -0
  62. package/dist/router/broker.js +161 -0
  63. package/dist/router/domain.js +18 -0
  64. package/dist/router/receipts-domain.js +8 -0
  65. package/dist/router/receipts.js +156 -0
  66. package/dist/router/routes.js +48 -0
  67. package/dist/router/sources.js +87 -0
  68. package/dist/routes.js +74 -0
  69. package/dist/runtime.js +222 -0
  70. package/dist/store.js +250 -0
  71. package/dist/usage-evidence.js +117 -0
  72. package/docs/APP_PACKAGE.md +38 -0
  73. package/docs/CONTRACT.md +190 -0
  74. package/docs/DSH_INTEGRATION.md +43 -0
  75. package/docs/LIBRARY.md +45 -0
  76. package/docs/LICENSES.json +185 -0
  77. package/docs/PROVENANCE.json +63 -0
  78. package/docs/SECURITY.md +52 -0
  79. package/package.json +112 -0
  80. package/profile/cordis.patch.yml +4 -0
  81. package/schemas/catalog-provider-page.schema.json +376 -0
  82. package/schemas/catalog-source.schema.json +151 -0
@@ -0,0 +1,87 @@
1
+ import { readFile, lstat, unlink, rmdir } from 'node:fs/promises';
2
+ import { join, resolve, dirname, sep } from 'node:path';
3
+ import { spawn as nodeSpawn } from 'node:child_process';
4
+ import { AppHostError, requireCondition } from '../errors.js';
5
+ import { validateDefinition } from '../descriptor.js';
6
+ import { itemKind } from './catalog.js';
7
+
8
+ /** The HanaMesh suite itself (real names + the historical scoped spellings) is never installed or removed through the market. */
9
+ export const PROTECTED_PACKAGES=Object.freeze(['@hanamesh/dsh-app-host','hanamesh-core','hanamesh-usage','@hanamesh/dsh-core','@hanamesh/dsh-usage']);
10
+ const blocked=new Set(PROTECTED_PACKAGES);
11
+ const packageName=value=>typeof value==='string'&&/^(?:@[a-z0-9][a-z0-9._-]*\/)?[a-z0-9][a-z0-9._-]*$/.test(value);
12
+ const versionText=value=>typeof value==='string'&&/^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/.test(value);
13
+ async function json(path){return JSON.parse(await readFile(path,'utf8'));}
14
+ function defaultSpawn(command,args,options){return new Promise((resolvePromise,reject)=>{const child=nodeSpawn(command,args,{...options,env:{HOME:process.env.HOME,DSH_HOME:process.env.DSH_HOME,LANG:process.env.LANG??'C.UTF-8',PATH:`${command.slice(0,command.lastIndexOf('/'))}:/usr/bin:/bin`},stdio:['ignore','pipe','pipe']});let stdout='',stderr='';child.stdout.on('data',chunk=>stdout+=chunk);child.stderr.on('data',chunk=>stderr+=chunk);child.on('error',reject);child.on('exit',(code,signal)=>resolvePromise({code,signal,stdout,stderr}));});}
15
+
16
+ async function defaults(){return await import('../provision/index.js');}
17
+
18
+ /** Runtime-created Python import caches are disposable. Preserve every other unowned file. */
19
+ async function clearGeneratedRuntimeCache(result,runtimeRoot){
20
+ if(!Array.isArray(result?.kept)||result.kept.length===0)return;
21
+ const target=resolve(result.target??'');
22
+ requireCondition(target.startsWith(resolve(runtimeRoot)+sep),'RUNTIME_RESIDUE','Runtime cleanup target is outside the application runtime root.');
23
+ const remaining=[];
24
+ for(const rel of result.kept){
25
+ if(typeof rel!=='string'||!/(?:^|\/)__pycache__\/[^/]+\.pyc$/.test(rel)){remaining.push(rel);continue;}
26
+ const file=resolve(target,rel);
27
+ if(!file.startsWith(target+sep)){remaining.push(rel);continue;}
28
+ const info=await lstat(file).catch(error=>error.code==='ENOENT'?null:Promise.reject(error));
29
+ if(!info?.isFile()){remaining.push(rel);continue;}
30
+ await unlink(file);
31
+ for(let dir=dirname(file);dir.startsWith(target+sep);dir=dirname(dir)){
32
+ try{await rmdir(dir);}catch(error){if(error.code==='ENOTEMPTY'||error.code==='ENOENT')break;throw error;}
33
+ }
34
+ }
35
+ if(remaining.length===0){try{await rmdir(target);}catch(error){if(error.code!=='ENOTEMPTY'&&error.code!=='ENOENT')throw error;}}
36
+ const residue=await lstat(target).catch(error=>error.code==='ENOENT'?null:Promise.reject(error));
37
+ requireCondition(remaining.length===0&&residue===null,'RUNTIME_RESIDUE','Runtime has unowned files that require manual review.',{count:remaining.length},409);
38
+ }
39
+
40
+ export function createLibraryInstaller({profileDir,profileName,dataRoot,nodeBinary,dshBin,registry='https://registry.npmjs.org',allowPrerelease=false,
41
+ fetchImpl=globalThis.fetch,spawn=defaultSpawn,provision,remove,emit=()=>{}}){
42
+ requireCondition(typeof profileDir==='string'&&resolve(profileDir)===profileDir,'PROFILE_DIR_REQUIRED','library.profileDir must be an absolute path.');
43
+ requireCondition(typeof dataRoot==='string'&&resolve(dataRoot)===dataRoot,'INVALID_ROOT','dataRoot must be absolute.');
44
+ requireCondition(typeof nodeBinary==='string'&&resolve(nodeBinary)===nodeBinary,'NODE_RUNTIME_REQUIRED','An absolute nodeBinary is required for library operations.');
45
+ requireCondition(typeof dshBin==='string'&&resolve(dshBin)===dshBin,'DSH_BIN_REQUIRED','An absolute DSH bin.js path is required.');
46
+ requireCondition(typeof profileName==='string'&&/^[A-Za-z0-9_.-]+$/.test(profileName),'PROFILE_NAME_REQUIRED','library.profileName is required.');
47
+ const runtimeRoot=appId=>join(dataRoot,'runtimes',appId);
48
+ const permitted=name=>requireCondition(packageName(name)&&!blocked.has(name),'PACKAGE_DENIED','Package name is not permitted.',{packageName:name});
49
+ async function installed(packageNameValue){const root=join(profileDir,'node_modules',...packageNameValue.split('/')),pkg=await json(join(root,'package.json'));const appFile=typeof pkg.hanamesh?.app==='string'?pkg.hanamesh.app:'app.json';return{pkg,definition:validateDefinition(await json(join(root,appFile)))};}
50
+ async function run(args){const result=await spawn(nodeBinary,[dshBin,'plugin','--profile',profileName,...args],{cwd:profileDir});requireCondition(result.code===0,'DSH_PLUGIN_FAILED','DSH plugin operation failed.',{code:result.code,signal:result.signal,stderr:String(result.stderr??'').slice(-2000)},502);return result;}
51
+ /** The exact version `dsh plugin add` will pin: the registry's `latest` dist-tag, re-checked here (the catalog's `latestVersion` is a hint only). */
52
+ async function latest(name){
53
+ const base=new URL(registry);requireCondition(['https:','http:'].includes(base.protocol),'REGISTRY_DENIED','Registry URL is invalid.');
54
+ const target=new URL(`${base.href.replace(/\/$/,'')}/${encodeURIComponent(name)}/latest`);const response=await fetchImpl(target,{headers:{accept:'application/json','accept-encoding':'identity'}});
55
+ requireCondition(response.ok,'REGISTRY_LOOKUP_FAILED','Registry latest lookup failed.',{status:response.status},502);const metadata=await response.json();const version=metadata.version;
56
+ requireCondition(versionText(version)&&(allowPrerelease||!version.includes('-')),'UNSTABLE_VERSION','Registry latest must be an exact stable version.');
57
+ return version;
58
+ }
59
+ /** Same path for applications and plugins: `dsh plugin add --save-exact <name>@<version>` (also the upgrade path: a newer exact version replaces the installed one). */
60
+ async function add(name){const version=await latest(name);await run(['add','--save-exact',`${name}@${version}`]);return version;}
61
+ return{
62
+ async install(item){
63
+ const kind=itemKind(item);
64
+ requireCondition(kind!=='listing','NOT_INSTALLABLE','Catalog item has no npm package to install.');const name=item.package.name;
65
+ permitted(name);
66
+ emit({type:'library.install-started',itemId:item.id,packageName:name,kind});
67
+ try{
68
+ const version=await add(name);
69
+ if(kind==='plugin'){const result={status:'restart-required',kind,packageName:name,version};emit({type:'library.install-done',...result});return result;}
70
+ const record=await installed(name);const runtime=record.definition.deployments.map(deployment=>deployment.runtime).find(Boolean);
71
+ if(runtime){const api=provision?{provision}:{...(await defaults())};await api.provision(runtime.manifest,{root:runtimeRoot(record.definition.id),only:[runtime.item],onProgress:event=>emit({type:'library.provision-progress',appId:record.definition.id,...event})});}
72
+ const result={status:'restart-required',kind,appId:record.definition.id,packageName:name,version};emit({type:'library.install-done',...result});return result;
73
+ }catch(error){emit({type:'library.install-failed',itemId:item.id,packageName:name,kind,code:error.code??'INSTALL_FAILED'});throw error;}
74
+ },
75
+ /** rc.28: install a plugin by package name (already verified against the catalog by the service). */
76
+ async installPlugin({packageName:name}){
77
+ permitted(name);
78
+ emit({type:'library.install-started',packageName:name,kind:'plugin'});
79
+ try{const version=await add(name);const result={status:'restart-required',kind:'plugin',packageName:name,version};emit({type:'library.install-done',...result});return result;}
80
+ catch(error){emit({type:'library.install-failed',packageName:name,kind:'plugin',code:error.code??'INSTALL_FAILED'});throw error;}
81
+ },
82
+ async provisionRuntime({appId,packageName:installedName,runtimeItem}){const record=await installed(installedName);requireCondition(record.definition.id===appId,'APP_ID_MISMATCH','Installed package app id does not match.');const runtime=record.definition.deployments.map(deployment=>deployment.runtime).find(value=>value?.item===runtimeItem);requireCondition(runtime,'RUNTIME_NOT_DECLARED','Runtime item is not declared.');const api=provision?{provision}:{...(await defaults())};const result=await api.provision(runtime.manifest,{root:runtimeRoot(appId),only:[runtime.item],onProgress:event=>emit({type:'library.provision-progress',appId,...event})});return{status:'restart-required',appId,packageName:installedName,result};},
83
+ async uninstall({packageName:installedName,appId,runtimeItem,running}){permitted(installedName);requireCondition(!running,'INSTANCE_IN_USE','Stop all application instances before uninstalling.',{},409);await run(['remove',installedName]);if(runtimeItem){const api=remove?{remove}:{...(await defaults())};const result=await api.remove(runtimeItem,{root:runtimeRoot(appId)});await clearGeneratedRuntimeCache(result,runtimeRoot(appId));}return{status:'restart-required',kind:'application',appId,packageName:installedName,dataPreserved:true};},
84
+ /** rc.28: `dsh plugin remove <name>`; the suite itself (PROTECTED_PACKAGES) is refused with PACKAGE_DENIED. */
85
+ async uninstallPlugin({packageName:installedName}){permitted(installedName);await run(['remove',installedName]);return{status:'restart-required',kind:'plugin',packageName:installedName};},
86
+ };
87
+ }
@@ -0,0 +1,67 @@
1
+ import { readFile, readdir } from 'node:fs/promises';
2
+ import { join, resolve } from 'node:path';
3
+ import { requireCondition } from '../errors.js';
4
+ import { validateDefinition } from '../descriptor.js';
5
+
6
+ async function json(path){return JSON.parse(await readFile(path,'utf8'));}
7
+ const absolute=profileDir=>requireCondition(typeof profileDir==='string'&&resolve(profileDir)===profileDir,'PROFILE_DIR_REQUIRED','library.profileDir must be an absolute path.');
8
+
9
+ /** The profile manifest written by `dsh plugin`: `dependencies` = every package the user added; `dsh.profile.bundles` = the active layer stack. */
10
+ export async function readProfileManifest(profileDir){
11
+ absolute(profileDir);
12
+ let profile;try{profile=await json(join(profileDir,'package.json'));}catch{profile={};}
13
+ return{dependencies:{...(profile.dependencies??{})},bundles:[...(profile.dsh?.profile?.bundles??[])]};
14
+ }
15
+ async function packageRoots(profileDir){
16
+ const names=new Set(Object.keys((await readProfileManifest(profileDir)).dependencies));
17
+ const scope=join(profileDir,'node_modules','@hanamesh');
18
+ for(const name of await readdir(scope).catch(()=>[]))if(name.startsWith('app-'))names.add(`@hanamesh/${name}`);
19
+ return[...names].map(name=>({name,root:join(profileDir,'node_modules',...name.split('/'))}));
20
+ }
21
+
22
+ export async function scanInstalled({profileDir,host,ledgerReader,dataRoot}){
23
+ absolute(profileDir);
24
+ const registered=new Set((host.list().apps??[]).map(app=>app.id));const rows=[];
25
+ for(const candidate of await packageRoots(profileDir)){
26
+ let pkg;try{pkg=await json(join(candidate.root,'package.json'));}catch{continue;}
27
+ const declaration=pkg.hanamesh?.app;if(!declaration)continue;
28
+ const appFile=typeof declaration==='string'?declaration:'app.json';
29
+ let definition;try{definition=validateDefinition(await json(join(candidate.root,appFile)));}catch(error){rows.push({packageName:pkg.name??candidate.name,version:pkg.version,state:'invalid',errorCode:error.code??'INVALID_DEFINITION'});continue;}
30
+ const runtime=definition.deployments.map(deployment=>deployment.runtime).find(Boolean);let state=registered.has(definition.id)?'registered':'installed-not-loaded';
31
+ if(runtime){const book=await ledgerReader(join(dataRoot,'runtimes',definition.id));if(!book.items?.[runtime.item])state='runtime-missing';}
32
+ rows.push({packageName:pkg.name??candidate.name,version:pkg.version,appId:definition.id,name:definition.name,state,definition,runtimeItem:runtime?.item});
33
+ }
34
+ return rows.sort((a,b)=>(a.packageName??'').localeCompare(b.packageName??''));
35
+ }
36
+
37
+ /**
38
+ * rc.28 market: every `dependencies` entry of the profile that is NOT a HanaMesh application package is a plugin
39
+ * (or a plain library) installed by `dsh plugin add`. The pinned kernel exposes nothing about what the running
40
+ * process loaded, so "needs a restart" is derived from the dependency set observed when this plugin started
41
+ * (`bootDependencies`): added since boot → `installed-not-loaded`; gone since boot → `uninstalled-not-unloaded`
42
+ * (still running until DSH restarts); otherwise `installed`. No snapshot (undefined) → everything present counts as loaded. `bundle` = the package declares `dsh.bundle`
43
+ * (a profile layer), `active` = it is in `dsh.profile.bundles`.
44
+ */
45
+ export async function scanInstalledPlugins({profileDir,bootDependencies}){
46
+ const loaded=name=>bootDependencies===undefined||name in bootDependencies;
47
+ const manifest=await readProfileManifest(profileDir);const rows=[];
48
+ for(const name of Object.keys(manifest.dependencies)){
49
+ let pkg;try{pkg=await json(join(profileDir,'node_modules',...name.split('/'),'package.json'));}catch{pkg=undefined;}
50
+ if(pkg?.hanamesh?.app)continue;
51
+ rows.push({packageName:name,version:pkg?.version??null,kind:'plugin',bundle:Boolean(pkg?.dsh?.bundle?.patch),active:manifest.bundles.includes(name),
52
+ state:pkg===undefined?'invalid':loaded(name)?'installed':'installed-not-loaded',...(pkg===undefined?{errorCode:'PACKAGE_UNREADABLE'}:{})});
53
+ }
54
+ for(const name of Object.keys(bootDependencies??{}))if(!(name in manifest.dependencies)&&!bootDependencies[name]?.application)
55
+ rows.push({packageName:name,version:bootDependencies[name]?.version??null,kind:'plugin',bundle:Boolean(bootDependencies[name]?.bundle),active:false,state:'uninstalled-not-unloaded'});
56
+ return rows.sort((a,b)=>a.packageName.localeCompare(b.packageName));
57
+ }
58
+
59
+ /** Snapshot taken at plugin start: which dependencies existed, and which of them are application packages. */
60
+ export async function snapshotProfileDependencies(profileDir){
61
+ const manifest=await readProfileManifest(profileDir);const snapshot={};
62
+ for(const name of Object.keys(manifest.dependencies)){
63
+ let pkg;try{pkg=await json(join(profileDir,'node_modules',...name.split('/'),'package.json'));}catch{pkg=undefined;}
64
+ snapshot[name]={version:pkg?.version??null,application:Boolean(pkg?.hanamesh?.app),bundle:Boolean(pkg?.dsh?.bundle?.patch)};
65
+ }
66
+ return snapshot;
67
+ }
@@ -0,0 +1,79 @@
1
+ /**
2
+ * Library locations on plain DSH (PRD §A.8 official-distribution path, rc.26).
3
+ *
4
+ * The pinned kernel exposes neither the profile directory nor the profile name to a plugin, and the
5
+ * desktop shell fills `library.profileDir/profileName/dshBin` and `nodeBinary` through its overlay.
6
+ * A `dsh plugin add` user has no overlay, so without these the installed-app scan returned `[]` and
7
+ * every install/uninstall answered `LIBRARY_INSTALL_UNAVAILABLE`. Each value below is inferred ONLY
8
+ * when the explicit config leaves it undefined, and only from facts this process can prove about
9
+ * itself: where its own package really lives, which script started it, and which Node runs it.
10
+ */
11
+ import { readFile, realpath } from 'node:fs/promises';
12
+ import { basename, dirname, isAbsolute, join, sep } from 'node:path';
13
+ import { fileURLToPath } from 'node:url';
14
+
15
+ export const PACKAGE_NAME='@hanamesh/dsh-app-host';
16
+ /** Used when `library.sources` is undefined (not configured). An explicit `[]` means "no source". */
17
+ export const DEFAULT_CATALOG_SOURCES=Object.freeze([Object.freeze({manifestUrl:'https://market.hanamesh.com/catalog-source.json',enabled:true})]);
18
+ const DSH_BIN_SUFFIX='/@deepseek-ai/dsh/lib/bin.js';
19
+
20
+ async function json(path){try{return JSON.parse(await readFile(path,'utf8'));}catch{return undefined;}}
21
+ /** A DSH profile manifest that carries HanaMesh: `dsh.profile` (written by `dsh plugin`) or a hanamesh dependency. */
22
+ export function isHanameshProfileManifest(manifest){
23
+ if(!manifest||typeof manifest!=='object'||Array.isArray(manifest))return false;
24
+ const hanamesh=name=>typeof name==='string'&&/hanamesh/.test(name);
25
+ const bundles=manifest.dsh?.profile?.bundles;
26
+ return(Array.isArray(bundles)&&bundles.some(hanamesh))||Object.keys(manifest.dependencies??{}).some(hanamesh);
27
+ }
28
+
29
+ /** Real directory of the package that owns `moduleUrl`: walk up to the package.json named PACKAGE_NAME. */
30
+ export async function packageRoot(moduleUrl){
31
+ let dir=dirname(fileURLToPath(moduleUrl));
32
+ for(;;){
33
+ if((await json(join(dir,'package.json')))?.name===PACKAGE_NAME)return await realpath(dir);
34
+ const parent=dirname(dir);if(parent===dir)return undefined;dir=parent;
35
+ }
36
+ }
37
+
38
+ /**
39
+ * The profile that lists this package: the nearest ancestor of the REAL package root whose
40
+ * `node_modules/@hanamesh/dsh-app-host` resolves to that root and whose package.json is a HanaMesh
41
+ * profile manifest. Ancestors of a real path are real, so a symlinked store path can never be
42
+ * returned — only the profile that links it.
43
+ */
44
+ export async function inferProfileDir(moduleUrl){
45
+ const root=await packageRoot(moduleUrl);if(!root)return undefined;
46
+ let dir=dirname(root);
47
+ for(;;){
48
+ const linked=await realpath(join(dir,'node_modules',...PACKAGE_NAME.split('/'))).catch(()=>undefined);
49
+ if(linked===root&&isHanameshProfileManifest(await json(join(dir,'package.json'))))return dir;
50
+ const parent=dirname(dir);if(parent===dir)return undefined;dir=parent;
51
+ }
52
+ }
53
+
54
+ /** The DSH CLI that started this process — `argv[1]` only when it IS `@deepseek-ai/dsh/lib/bin.js`. */
55
+ export function inferDshBin(argv=process.argv){
56
+ const candidate=argv?.[1];
57
+ if(typeof candidate!=='string'||!isAbsolute(candidate))return undefined;
58
+ return candidate.split(sep).join('/').endsWith(DSH_BIN_SUFFIX)?candidate:undefined;
59
+ }
60
+
61
+ /** K5: a plain Node process may run library operations with itself; an Electron host must configure `nodeBinary`. */
62
+ export function inferNodeBinary({execPath=process.execPath,versions=process.versions}={}){
63
+ if(versions.electron!==undefined)return undefined;
64
+ return typeof execPath==='string'&&isAbsolute(execPath)?execPath:undefined;
65
+ }
66
+
67
+ /**
68
+ * Explicit config always wins; every inferred key is named in `inferred` so the caller can log it.
69
+ * `sources`: undefined → DEFAULT_CATALOG_SOURCES; an explicit array (including `[]`) is kept as is.
70
+ */
71
+ export async function resolveLibraryLocations(config={},{moduleUrl=import.meta.url,argv=process.argv,execPath=process.execPath,versions=process.versions,nodeBinary}={}){
72
+ const inferred=[];
73
+ let profileDir=config.profileDir;if(profileDir===undefined){profileDir=await inferProfileDir(moduleUrl);if(profileDir)inferred.push('profileDir');}
74
+ let profileName=config.profileName;if(profileName===undefined&&profileDir){profileName=basename(profileDir);inferred.push('profileName');}
75
+ let dshBin=config.dshBin;if(dshBin===undefined){dshBin=inferDshBin(argv);if(dshBin)inferred.push('dshBin');}
76
+ let node=nodeBinary;if(node===undefined){node=inferNodeBinary({execPath,versions});if(node)inferred.push('nodeBinary');}
77
+ let sources=config.sources;if(sources===undefined){sources=structuredClone(DEFAULT_CATALOG_SOURCES);inferred.push('sources');}
78
+ return{profileDir,profileName,dshBin,nodeBinary:node,sources,inferred};
79
+ }
@@ -0,0 +1,17 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import { AppHostError, requireCondition } from '../errors.js';
3
+ import { loopbackOrigin } from '../descriptor.js';
4
+
5
+ /** rc.28 adds `installedPlugins` (GET) and `plugins/install` / `plugins/uninstall` (POST, by package name); every older route is kept. */
6
+ export const LIBRARY_ROUTES=['/hanamesh/library','/hanamesh/library/sources','/hanamesh/library/install','/hanamesh/library/provision','/hanamesh/library/uninstall','/hanamesh/library/events','/hanamesh/library/installedPlugins','/hanamesh/library/plugins/install','/hanamesh/library/plugins/uninstall'];
7
+ async function jsonBody(req){const chunks=[];let total=0;for await(const chunk of req){total+=chunk.length;requireCondition(total<=65_536,'BODY_TOO_LARGE','Request body is too large.',{},413);chunks.push(chunk);}try{return JSON.parse(Buffer.concat(chunks).toString('utf8'));}catch{throw new AppHostError('INVALID_JSON','Malformed JSON.',{},400);}}
8
+ function send(res,status,value){res.writeHead(status,{'content-type':'application/json; charset=utf-8','cache-control':'no-store','x-content-type-options':'nosniff','content-security-policy':"default-src 'none'; frame-ancestors 'none'"});res.end(JSON.stringify(value));}
9
+ export function createLibraryHttpHandler(library,{parentOrigin,authenticate,authorize}){parentOrigin=loopbackOrigin(parentOrigin);return async(req,res)=>{const traceId=randomUUID();try{
10
+ requireCondition(req.headers.host===new URL(parentOrigin).host,'HOST_DENIED','Unrecognized request host.',{},403);const url=new URL(req.url,parentOrigin),path=url.pathname;requireCondition(LIBRARY_ROUTES.includes(path),'NOT_FOUND','Unknown library route.',{},404);
11
+ const readOnly=req.method==='GET'&&['/hanamesh/library','/hanamesh/library/sources','/hanamesh/library/events','/hanamesh/library/installedPlugins'].includes(path);requireCondition(readOnly||(req.method==='POST'&&path!=='/hanamesh/library/installedPlugins'),'METHOD_NOT_ALLOWED','Unsupported library method.',{},405);
12
+ requireCondition(!req.headers.origin||req.headers.origin===parentOrigin,'ORIGIN_DENIED','Unapproved browser origin.',{},403);requireCondition(req.headers['sec-fetch-dest']!=='iframe','FRAME_CONTROL_DENIED','An application frame cannot control the host.',{},403);
13
+ if(!readOnly)requireCondition(req.headers.origin===parentOrigin&&req.headers['x-hanamesh-client']==='workspace-v1','CSRF_DENIED','Workspace origin and explicit client header are required.',{},403);
14
+ const subject=await authenticate(req);requireCondition(subject&&typeof subject.principalId==='string','UNAUTHENTICATED','Host authentication is required.',{},401);const input=readOnly?Object.fromEntries(url.searchParams):await jsonBody(req);requireCondition(await authorize(subject,path,input)===true,'FORBIDDEN','Operation is not authorized.',{},403);
15
+ let result;if(path==='/hanamesh/library'&&readOnly){requireCondition(Object.keys(input).every(key=>['q','category','cursor'].includes(key)),'UNKNOWN_FIELDS','Unknown library parameters.');result=await library.list(input);}else if(path==='/hanamesh/library/sources'&&readOnly)result=library.sources();else if(path==='/hanamesh/library/sources')result=await library.replaceSources(input);else if(path==='/hanamesh/library/install')result=await library.install(input);else if(path==='/hanamesh/library/provision')result=await library.provision(input);else if(path==='/hanamesh/library/uninstall')result=await library.uninstall(input);else if(path==='/hanamesh/library/installedPlugins'){requireCondition(Object.keys(input).length===0,'UNKNOWN_FIELDS','installedPlugins takes no parameters.');result=await library.installedSnapshot();}else if(path==='/hanamesh/library/plugins/install')result=await library.installPlugin(input);else if(path==='/hanamesh/library/plugins/uninstall')result=await library.uninstallPlugin(input);else{requireCondition(Object.keys(input).every(key=>key==='after'),'UNKNOWN_FIELDS','Unknown event parameters.');result=library.events(Number(input.after??0));}
16
+ send(res,!readOnly&&path!=='/hanamesh/library/sources'?202:200,{...result,traceId});
17
+ }catch(error){const trusted=error instanceof AppHostError;send(res,trusted?error.status:500,{error:{code:trusted?error.code:'INTERNAL_ERROR',message:trusted?error.message:'The operation could not be completed.',...(trusted?{details:error.details}:{})},traceId});}};}
@@ -0,0 +1,76 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import { AppHostError, requireCondition } from '../errors.js';
3
+ import { loadCatalog, isApplication, itemKind, upgradeAvailable, validateManifestUrl } from './catalog.js';
4
+ import { scanInstalled, scanInstalledPlugins, snapshotProfileDependencies } from './installed.js';
5
+ import { PROTECTED_PACKAGES } from './install.js';
6
+
7
+ const PACKAGE_NAME=/^(?:@[a-z0-9][a-z0-9._-]*\/)?[a-z0-9][a-z0-9._-]*$/;
8
+ const text=(value,max)=>typeof value==='string'&&value.length>0&&value.length<=max;
9
+ const RESTART_STATES=new Set(['installed-not-loaded','uninstalled-not-unloaded']);
10
+ const protectedPackage=name=>PROTECTED_PACKAGES.includes(name);
11
+
12
+ export function createLibraryService({domain,host,config={},dataRoot,ledgerReader,installer}){
13
+ const events=[];let sequence=0;let state;const running=new Map();let bootDependencies;
14
+ const emit=event=>{const value={sequence:++sequence,at:Date.now(),...event};events.push(value);if(events.length>512)events.shift();return value;};
15
+ // Real sources hold ~10k entries (2026-09-20: market.hanamesh.com 12,121). `/hanamesh/library` keeps its rc.16 default
16
+ // (`category=hanamesh-app`, backward compatible); the rc.28 market page asks for `category=''` (everything) explicitly.
17
+ const catalog=({q='',category='hanamesh-app',cursor}={})=>loadCatalog({fixture:config.fixture,sources:state.sources,fetchImpl:config.fetchImpl,query:q,category,cursor});
18
+ async function installed(){return config.profileDir?await scanInstalled({profileDir:config.profileDir,host,ledgerReader,dataRoot}):[];}
19
+ async function installedPlugins(){return config.profileDir?await scanInstalledPlugins({profileDir:config.profileDir,bootDependencies}):[];}
20
+ async function persist(next){next.revision=state.revision+1;await domain.global.set(next);state=next;}
21
+ async function operation(type,task){const operationId=randomUUID();emit({type:`library.${type}-started`,operationId});const work=Promise.resolve().then(task).then(result=>{emit({type:`library.${type}-done`,operationId,result});return result;},error=>{emit({type:`library.${type}-failed`,operationId,code:error.code??'LIBRARY_OPERATION_FAILED'});throw error;}).finally(()=>running.delete(operationId));running.set(operationId,work);work.catch(()=>{});return{operationId,status:'started'};}
22
+ const requireInstaller=(message)=>requireCondition(installer,'LIBRARY_INSTALL_UNAVAILABLE',message,{},503);
23
+ /** Find one catalog entry by id; a package name narrows the provider query (the catalog has no get-by-id, but `q=<package>` matches exactly). */
24
+ async function findItem({itemId,packageName}){
25
+ requireCondition(text(itemId,160),'INVALID_INPUT','itemId is required.');
26
+ requireCondition(packageName===undefined||(text(packageName,214)&&PACKAGE_NAME.test(packageName)),'INVALID_INPUT','packageName is invalid.');
27
+ const page=packageName?await catalog({q:packageName,category:''}):await catalog();
28
+ const item=page.items.find(row=>row.id===itemId&&(packageName===undefined||row.package?.name===packageName));
29
+ requireCondition(item,'CATALOG_ITEM_MISSING','Catalog item was not found.',{itemId},404);return item;
30
+ }
31
+ async function findByPackage(packageName){
32
+ requireCondition(text(packageName,214)&&PACKAGE_NAME.test(packageName),'INVALID_INPUT','packageName is required.');
33
+ requireCondition(!protectedPackage(packageName),'PACKAGE_DENIED','The HanaMesh suite is not managed through the market.',{packageName},403);
34
+ const page=await catalog({q:packageName,category:''});const item=page.items.find(row=>row.package?.registry==='npm'&&row.package?.name===packageName);
35
+ requireCondition(item,'CATALOG_ITEM_MISSING','The catalog does not list this package.',{packageName},404);return item;
36
+ }
37
+ const runningApp=appId=>host.instanceList().some(instance=>instance.appId===appId&&!['stopped','failed','interrupted'].includes(instance.status));
38
+ return{
39
+ async init(){state=await domain.global.get();if(state.sources.length===0&&Array.isArray(config.sources)&&config.sources.length){const sources=config.sources.map((source,index)=>({manifestUrl:validateManifestUrl(source.manifestUrl).href,enabled:source.enabled===true||(source.enabled!==false&&index===0)}));requireCondition(sources.filter(source=>source.enabled).length===1,'CATALOG_SOURCE_REQUIRED','Exactly one catalog source must be enabled.');await persist({...state,sources});}
40
+ // rc.28: the dependency set at boot is the "loaded" baseline for plugin restart states; unreadable → everything counts as loaded.
41
+ if(config.profileDir){try{bootDependencies=await snapshotProfileDependencies(config.profileDir);}catch{bootDependencies=undefined;}}
42
+ return this;},
43
+ async list({q='',category,cursor}={}){requireCondition(typeof q==='string'&&q.length<=200,'INVALID_QUERY','q must be a string of at most 200 characters.');requireCondition(category===undefined||(typeof category==='string'&&category.length<=64&&/^[a-z0-9._:-]*$/.test(category)),'INVALID_QUERY','category is invalid.');requireCondition(cursor===undefined||(typeof cursor==='string'&&cursor.length>0&&cursor.length<=2048),'INVALID_CURSOR','cursor is invalid.');
44
+ const page=await catalog({q,category,cursor});const installedRows=await installed();const pluginRows=await installedPlugins();
45
+ const byPackage=new Map([...pluginRows,...installedRows].map(row=>[row.packageName,row]));
46
+ const items=page.items.map(item=>{const row=byPackage.get(item.package?.name)??null;const kind=itemKind(item);
47
+ return{...item,kind,application:isApplication(item),installed:row,upgradeAvailable:Boolean(row&&row.version&&row.state!=='uninstalled-not-unloaded'&&upgradeAvailable(item.latestVersion,row.version))};});
48
+ const categories=[...new Set(page.items.flatMap(item=>item.categories??[]))].sort();
49
+ return{source:page.source,items,categories,installed:installedRows,plugins:pluginRows,restartRequired:[...pluginRows,...installedRows].some(row=>RESTART_STATES.has(row.state)),page:page.page};},
50
+ sources(){return{sources:structuredClone(state.sources),revision:state.revision};},
51
+ async replaceSources(input){requireCondition(Array.isArray(input?.sources)&&input.sources.length<=16,'INVALID_SOURCES','sources must be an array of at most 16 entries.');const sources=input.sources.map(source=>({manifestUrl:validateManifestUrl(source.manifestUrl).href,enabled:source.enabled===true}));requireCondition(sources.length===0||sources.filter(source=>source.enabled).length===1,'CATALOG_SOURCE_REQUIRED','Exactly one catalog source must be enabled.');await persist({...state,sources});return this.sources();},
52
+ /** `{itemId, packageName?}`: applications and plugins take the same path (`dsh plugin add --save-exact`); also the upgrade path. */
53
+ async install(input){requireInstaller('Install requires absolute profileDir, nodeBinary, dshBin and profileName.');const item=await findItem(input??{});return await operation('install',()=>installer.install(item));},
54
+ async provision(input){requireInstaller('Runtime provision is unavailable.');
55
+ // LIB-PROVISION-INPUT: a missing field is the caller's error (400), not an internal TypeError inside the operation.
56
+ requireCondition(text(input?.appId,160),'INVALID_INPUT','appId is required.');requireCondition(text(input?.packageName,214)&&PACKAGE_NAME.test(input.packageName),'INVALID_INPUT','packageName is required.');requireCondition(text(input?.runtimeItem,160),'INVALID_INPUT','runtimeItem is required.');
57
+ const{appId,packageName,runtimeItem}=input;return await operation('provision',()=>installer.provisionRuntime({appId,packageName,runtimeItem}));},
58
+ async uninstall(input){requireInstaller('Uninstall is unavailable.');
59
+ requireCondition(text(input?.appId,160),'INVALID_INPUT','appId is required.');requireCondition(text(input?.packageName,214)&&PACKAGE_NAME.test(input.packageName),'INVALID_INPUT','packageName is required.');requireCondition(input.runtimeItem===undefined||text(input.runtimeItem,160),'INVALID_INPUT','runtimeItem is invalid.');
60
+ const{appId,packageName,runtimeItem}=input;return await operation('uninstall',()=>installer.uninstall({appId,packageName,runtimeItem,running:runningApp(appId)}));},
61
+ /** rc.28 `{packageName}`: the package must be listed by the enabled catalog; an application package takes the application path. */
62
+ async installPlugin(input){requireInstaller('Plugin install requires absolute profileDir, nodeBinary, dshBin and profileName.');const item=await findByPackage(input?.packageName);return await operation('install',()=>installer.install(item));},
63
+ /** rc.28 `{packageName}`: `dsh plugin remove`; an installed application package is routed through the application uninstall (runtime + running check). */
64
+ async uninstallPlugin(input){requireInstaller('Plugin uninstall is unavailable.');const packageName=input?.packageName;requireCondition(text(packageName,214)&&PACKAGE_NAME.test(packageName),'INVALID_INPUT','packageName is required.');requireCondition(!protectedPackage(packageName),'PACKAGE_DENIED','The HanaMesh suite is not managed through the market.',{packageName},403);
65
+ const app=(await installed()).find(row=>row.packageName===packageName&&row.appId);
66
+ if(app)return await operation('uninstall',()=>installer.uninstall({appId:app.appId,packageName,runtimeItem:app.runtimeItem,running:runningApp(app.appId)}));
67
+ return await operation('uninstall',()=>installer.uninstallPlugin({packageName}));},
68
+ /** Installed application packages of the profile (empty without `profileDir`); rc.27 binds package names for usage evidence. */
69
+ installed,
70
+ /** rc.28: installed plugins (every non-application dependency of the profile) with restart states. */
71
+ installedPlugins,
72
+ async installedSnapshot(){const plugins=await installedPlugins(),apps=await installed();return{plugins,apps,restartRequired:[...plugins,...apps].some(row=>RESTART_STATES.has(row.state))};},
73
+ events(after=0){requireCondition(Number.isSafeInteger(after)&&after>=0,'INVALID_CURSOR','Invalid library event cursor.');return{events:events.filter(event=>event.sequence>after),sequence,operations:[...running.keys()]};},
74
+ async close(){await Promise.allSettled([...running.values()]);await domain.close();},emit,
75
+ };
76
+ }