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

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 (120) hide show
  1. package/LICENSE +4 -7
  2. package/README.md +136 -13
  3. package/consistency.json +41 -6
  4. package/dist/catalog/fixtures/consumer.json +35 -0
  5. package/dist/catalog/fixtures/provider.json +107 -0
  6. package/dist/catalog/suite.d.ts +4 -0
  7. package/dist/catalog/suite.js +50 -0
  8. package/dist/client-ui.d.ts +21 -3
  9. package/dist/client-ui.js +961 -94
  10. package/dist/client.js +2 -2
  11. package/dist/control-origin.js +9 -0
  12. package/dist/descriptor.js +6 -0
  13. package/dist/dsh-compat.js +6 -0
  14. package/dist/dsh.d.ts +21 -4
  15. package/dist/dsh.js +149 -58
  16. package/dist/gateway.js +140 -11
  17. package/dist/git-process.js +64 -0
  18. package/dist/git-worker.js +50 -0
  19. package/dist/index.d.ts +9 -7
  20. package/dist/install-target/fixtures/catalog.json +58 -0
  21. package/dist/install-target/fixtures/consumer-cases.json +148 -0
  22. package/dist/install-target/fixtures/install-failure-cases.json +58 -0
  23. package/dist/install-target/fixtures/provider-cases.json +618 -0
  24. package/dist/install-target/fixtures/reference-consumer.js +37 -0
  25. package/dist/install-target/fixtures/source-profile.js +123 -0
  26. package/dist/install-target/harness.js +43 -0
  27. package/dist/install-target/run.js +28 -0
  28. package/dist/install-target/suite.d.ts +19 -0
  29. package/dist/install-target/suite.js +328 -0
  30. package/dist/installation-status.d.ts +21 -0
  31. package/dist/installation-status.js +2 -0
  32. package/dist/library/catalog.js +134 -19
  33. package/dist/library/client-identity.js +31 -0
  34. package/dist/library/desktop-cli.js +7 -0
  35. package/dist/library/domain.js +3 -1
  36. package/dist/library/handoff.js +246 -0
  37. package/dist/library/install.js +138 -32
  38. package/dist/library/installation-cli.js +48 -0
  39. package/dist/library/installation-command-worker.js +27 -0
  40. package/dist/library/installation-status-http.js +26 -0
  41. package/dist/library/installation-status.js +91 -0
  42. package/dist/library/installation-worker.js +44 -0
  43. package/dist/library/locate.js +70 -9
  44. package/dist/library/node-binding.js +11 -0
  45. package/dist/library/recovery.js +66 -0
  46. package/dist/library/routes.js +90 -14
  47. package/dist/library/service.js +123 -27
  48. package/dist/library/suite.js +130 -0
  49. package/dist/manager.js +24 -8
  50. package/dist/node-runtime/domain.js +6 -0
  51. package/dist/node-runtime/releases.json +59 -0
  52. package/dist/node-runtime/service.js +101 -0
  53. package/dist/owned-probe-diagnostic.js +53 -0
  54. package/dist/router/receipts.js +8 -11
  55. package/dist/router/routes.js +7 -7
  56. package/dist/routes.js +9 -8
  57. package/dist/runtime.js +11 -1
  58. package/dist/usage-evidence.js +18 -86
  59. package/dist/workspace-origin.js +17 -0
  60. package/docs/APP_PACKAGE.md +4 -0
  61. package/docs/CONTRACT.md +68 -10
  62. package/docs/DSH_COMPATIBILITY.md +94 -0
  63. package/docs/DSH_INTEGRATION.md +41 -0
  64. package/docs/INSTALLATION_STATUS.md +96 -0
  65. package/docs/INSTALL_TARGET.md +130 -0
  66. package/docs/LIBRARY.md +44 -3
  67. package/docs/LICENSES.json +189 -61
  68. package/docs/PROVENANCE.json +53 -12
  69. package/docs/SECURITY.md +31 -0
  70. package/node_modules/@hanamesh/lib-provision/LICENSE +15 -0
  71. package/node_modules/@hanamesh/lib-provision/README.md +134 -0
  72. package/node_modules/@hanamesh/lib-provision/THIRD_PARTY_NOTICES.md +17 -0
  73. package/node_modules/@hanamesh/lib-provision/bin/hanamesh-provision.js +13 -0
  74. package/node_modules/@hanamesh/lib-provision/consistency.json +20 -0
  75. package/node_modules/@hanamesh/lib-provision/contract/CHANGELOG.md +5 -0
  76. package/node_modules/@hanamesh/lib-provision/contract/conformance.mjs +80 -0
  77. package/node_modules/@hanamesh/lib-provision/contract/fixtures/consumer.json +9 -0
  78. package/node_modules/@hanamesh/lib-provision/contract/fixtures/provider.json +11 -0
  79. package/node_modules/@hanamesh/lib-provision/contract/lib-provision.schema.json +47 -0
  80. package/node_modules/@hanamesh/lib-provision/package.json +50 -0
  81. package/package.json +81 -33
  82. package/schemas/catalog-provider-page.schema.json +4 -2
  83. package/schemas/catalog-source.schema.json +9 -7
  84. package/schemas/install-target.schema.json +298 -0
  85. package/dist/provision/LICENSE +0 -6
  86. package/dist/provision/THIRD_PARTY_NOTICES.md +0 -13
  87. /package/{dist/provision → node_modules/@hanamesh/lib-provision/lib}/archive.d.ts +0 -0
  88. /package/{dist/provision → node_modules/@hanamesh/lib-provision/lib}/archive.js +0 -0
  89. /package/{dist/provision → node_modules/@hanamesh/lib-provision/lib}/check.d.ts +0 -0
  90. /package/{dist/provision → node_modules/@hanamesh/lib-provision/lib}/check.js +0 -0
  91. /package/{dist/provision → node_modules/@hanamesh/lib-provision/lib}/checkpoints.d.ts +0 -0
  92. /package/{dist/provision → node_modules/@hanamesh/lib-provision/lib}/checkpoints.js +0 -0
  93. /package/{dist/provision → node_modules/@hanamesh/lib-provision/lib}/cli.d.ts +0 -0
  94. /package/{dist/provision → node_modules/@hanamesh/lib-provision/lib}/cli.js +0 -0
  95. /package/{dist/provision → node_modules/@hanamesh/lib-provision/lib}/download.d.ts +0 -0
  96. /package/{dist/provision → node_modules/@hanamesh/lib-provision/lib}/download.js +0 -0
  97. /package/{dist/provision → node_modules/@hanamesh/lib-provision/lib}/errors.d.ts +0 -0
  98. /package/{dist/provision → node_modules/@hanamesh/lib-provision/lib}/errors.js +0 -0
  99. /package/{dist/provision → node_modules/@hanamesh/lib-provision/lib}/extract.d.ts +0 -0
  100. /package/{dist/provision → node_modules/@hanamesh/lib-provision/lib}/extract.js +0 -0
  101. /package/{dist/provision → node_modules/@hanamesh/lib-provision/lib}/fsutil.d.ts +0 -0
  102. /package/{dist/provision → node_modules/@hanamesh/lib-provision/lib}/fsutil.js +0 -0
  103. /package/{dist/provision → node_modules/@hanamesh/lib-provision/lib}/index.d.ts +0 -0
  104. /package/{dist/provision → node_modules/@hanamesh/lib-provision/lib}/index.js +0 -0
  105. /package/{dist/provision → node_modules/@hanamesh/lib-provision/lib}/ledger.d.ts +0 -0
  106. /package/{dist/provision → node_modules/@hanamesh/lib-provision/lib}/ledger.js +0 -0
  107. /package/{dist/provision → node_modules/@hanamesh/lib-provision/lib}/manifest.d.ts +0 -0
  108. /package/{dist/provision → node_modules/@hanamesh/lib-provision/lib}/manifest.js +0 -0
  109. /package/{dist/provision → node_modules/@hanamesh/lib-provision/lib}/paths.d.ts +0 -0
  110. /package/{dist/provision → node_modules/@hanamesh/lib-provision/lib}/paths.js +0 -0
  111. /package/{dist/provision → node_modules/@hanamesh/lib-provision/lib}/platform.d.ts +0 -0
  112. /package/{dist/provision → node_modules/@hanamesh/lib-provision/lib}/platform.js +0 -0
  113. /package/{dist/provision → node_modules/@hanamesh/lib-provision/lib}/provision.d.ts +0 -0
  114. /package/{dist/provision → node_modules/@hanamesh/lib-provision/lib}/provision.js +0 -0
  115. /package/{dist/provision → node_modules/@hanamesh/lib-provision/lib}/tar.d.ts +0 -0
  116. /package/{dist/provision → node_modules/@hanamesh/lib-provision/lib}/tar.js +0 -0
  117. /package/{dist/provision → node_modules/@hanamesh/lib-provision/lib}/types.d.ts +0 -0
  118. /package/{dist/provision → node_modules/@hanamesh/lib-provision/lib}/types.js +0 -0
  119. /package/{dist/provision → node_modules/@hanamesh/lib-provision/lib}/zip.d.ts +0 -0
  120. /package/{dist/provision → node_modules/@hanamesh/lib-provision/lib}/zip.js +0 -0
@@ -0,0 +1,11 @@
1
+ /** Each operation captures one delegate; consecutive operations share its writer until the binding changes. */
2
+ export function nodeBinding(current,make){
3
+ const cache=new Map();
4
+ const delegate=()=>{const node=current();if(!cache.has(node))cache.set(node,make(node));return cache.get(node);};
5
+ return new Proxy({}, {get(_target,key){const value=delegate()[key];return typeof value==='function'?value.bind(delegate()):value;}});
6
+ }
7
+ export function nodeStatusBinding(current,make){
8
+ const cache=new Map();
9
+ const read=(...args)=>{const node=current();if(!cache.has(node))cache.set(node,make(node));return cache.get(node)(...args);};
10
+ read.close=async()=>{for(const reader of cache.values())await reader.close?.();cache.clear();};return read;
11
+ }
@@ -0,0 +1,66 @@
1
+ /**
2
+ * rc.10 install recovery: what one market install changed in the profile and in the application runtime root, and the
3
+ * item-by-item readback that decides whether a compensation really restored the state before the operation.
4
+ * Nothing here writes profile files: package changes go back through the official `dsh plugin` command only.
5
+ */
6
+ import { readFile, mkdir, writeFile } from 'node:fs/promises';
7
+ import { createHash } from 'node:crypto';
8
+ import { join } from 'node:path';
9
+
10
+ const DEPENDENCY_FIELDS=['devDependencies','dependencies','optionalDependencies'];
11
+ const EXACT=/^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/;
12
+ const digest=bytes=>bytes===null?null:createHash('sha256').update(bytes).digest('hex');
13
+ const same=(a,b)=>JSON.stringify(a)===JSON.stringify(b);
14
+ async function optional(path){try{return await readFile(path);}catch(error){if(error.code==='ENOENT')return null;throw error;}}
15
+
16
+ /** The profile facts one package operation can change: its direct spec, bundle selection, lockfile and resolved module. */
17
+ export async function profileSnapshot(profileDir,name){
18
+ const manifestBytes=await optional(join(profileDir,'package.json')),lockBytes=await optional(join(profileDir,'pnpm-lock.yaml'));
19
+ const manifest=manifestBytes===null?{}:JSON.parse(manifestBytes.toString('utf8'));
20
+ const direct=Object.assign({},...DEPENDENCY_FIELDS.map(field=>manifest[field]??{}));
21
+ const moduleBytes=await optional(join(profileDir,'node_modules',...name.split('/'),'package.json'));
22
+ return{manifestBytes,lockBytes,spec:direct[name]??null,others:Object.fromEntries(Object.entries(direct).filter(([key])=>key!==name)),
23
+ bundles:manifest.dsh?.profile?.bundles??[],lock:digest(lockBytes),module:moduleBytes===null?null:JSON.parse(moduleBytes.toString('utf8')).version??null};
24
+ }
25
+
26
+ /** Ledger entries of one runtime root, without the install timestamp (a re-provision of the same bytes is the same state). */
27
+ export async function runtimeSnapshot(readLedger,root){
28
+ const ledger=await readLedger(root);
29
+ return Object.fromEntries(Object.entries(ledger?.items??{}).map(([id,entry])=>[id,{version:entry.version,sha256:entry.sha256,platform:entry.platform,installTo:entry.installTo}]));
30
+ }
31
+
32
+ /** Names of other direct dependencies that changed while this operation ran: a concurrent profile writer, never ours to undo. */
33
+ export const concurrentChanges=(base,now)=>[...new Set([...Object.keys(base.others),...Object.keys(now.others)])].filter(key=>base.others[key]!==now.others[key]).sort();
34
+
35
+ /** Item-by-item comparison of the profile with its baseline; every row carries expected, actual and ok. */
36
+ export function profileChecks(base,now,name){
37
+ const concurrent=concurrentChanges(base,now);
38
+ const rows=[
39
+ {item:'dependency',expected:base.spec,actual:now.spec},
40
+ {item:'module',expected:base.module,actual:now.module},
41
+ {item:'bundle',expected:base.bundles.includes(name),actual:now.bundles.includes(name)},
42
+ ].map(row=>({...row,ok:same(row.expected,row.actual)}));
43
+ if(concurrent.length)rows.push({item:'lockfile',expected:base.lock,actual:now.lock,ok:false,reason:'CONCURRENT_PROFILE_WRITE',concurrent});
44
+ else rows.push({item:'lockfile',expected:base.lock,actual:now.lock,ok:base.lock===now.lock},{item:'bundles',expected:base.bundles,actual:now.bundles,ok:same(base.bundles,now.bundles)});
45
+ return rows;
46
+ }
47
+
48
+ export function runtimeChecks(base,now){
49
+ return [...new Set([...Object.keys(base),...Object.keys(now)])].sort().map(id=>({item:'runtime:'+id,expected:base[id]??null,actual:now[id]??null,ok:same(base[id]??null,now[id]??null)}));
50
+ }
51
+
52
+ /** The official CLI arguments that put one package back to its baseline spec, or none when nothing package-side changed. */
53
+ export function packageRestoreArgs(base,now,name){
54
+ if(profileChecks(base,now,name).every(row=>row.ok||row.reason==='CONCURRENT_PROFILE_WRITE'))return null;
55
+ if(base.spec===null)return now.spec===null?['install']:['remove',name];
56
+ return['add',...(EXACT.test(base.spec)?['--save-exact']:[]),`${name}@${base.spec}`];
57
+ }
58
+
59
+ /** Keeps the pre-operation profile files and runtime ledger where the user (or a later operation) can restore them by hand. */
60
+ export async function keepOriginals(dir,base,runtimeBase){
61
+ await mkdir(dir,{recursive:true,mode:0o700});const files=[];
62
+ if(base.manifestBytes!==null){await writeFile(join(dir,'package.json'),base.manifestBytes,{mode:0o600});files.push('package.json');}
63
+ if(base.lockBytes!==null){await writeFile(join(dir,'pnpm-lock.yaml'),base.lockBytes,{mode:0o600});files.push('pnpm-lock.yaml');}
64
+ if(runtimeBase){await writeFile(join(dir,'runtime-ledger.json'),JSON.stringify(runtimeBase,undefined,2)+'\n',{mode:0o600});files.push('runtime-ledger.json');}
65
+ return{dir,files,sha256:{...(base.manifestBytes===null?{}:{'package.json':digest(base.manifestBytes)}),...(base.lockBytes===null?{}:{'pnpm-lock.yaml':digest(base.lockBytes)})}};
66
+ }
@@ -1,17 +1,93 @@
1
+ import { installationStatusCors, serveInstallationStatus } from './installation-status-http.js';
1
2
  import { randomUUID } from 'node:crypto';
2
3
  import { AppHostError, requireCondition } from '../errors.js';
3
- import { loopbackOrigin } from '../descriptor.js';
4
+ import { controlOrigins } from '../control-origin.js';
5
+ import { HANDOFF_DEFAULTS, HANDOFF_RULES, HANDOFF_SETTING_NAMES, HANDOFF_BODY_MAX_BYTES } from './handoff.js';
4
6
 
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});}};}
7
+ // Keep older route order; dsh.js registers each exact path on the public host API.
8
+ 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','/hanamesh/library/target','/hanamesh/library/install-target','/hanamesh/library/handoffs','/hanamesh/library/handoffs/state','/hanamesh/library/handoff-settings','/hanamesh/library/suite/upgrade','/hanamesh/library/installation-status'];
9
+ export const NODE_RUNTIME_ROUTES=['/hanamesh/library/node-runtime','/hanamesh/library/node-runtime/settings','/hanamesh/library/node-runtime/plan','/hanamesh/library/node-runtime/prepare','/hanamesh/library/node-runtime/cancel'];
10
+ LIBRARY_ROUTES.push(...NODE_RUNTIME_ROUTES,'/hanamesh/library/suite-source');
11
+ const READ_ROUTES=new Set(['/hanamesh/library/suite-source','/hanamesh/library/node-runtime','/hanamesh/library','/hanamesh/library/sources','/hanamesh/library/events','/hanamesh/library/installedPlugins','/hanamesh/library/target','/hanamesh/library/handoffs','/hanamesh/library/handoff-settings']);
12
+ async function jsonBody(req){
13
+ requireCondition(req.headers['content-type']?.split(';')[0].trim()==='application/json','CONTENT_TYPE_REQUIRED','Expected application/json.',{},415);
14
+ 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);}
15
+ try{return JSON.parse(Buffer.concat(chunks).toString('utf8'));}catch{throw new AppHostError('INVALID_JSON','Malformed JSON.',{},400);}
16
+ }
17
+ function send(res,status,value,headers={}){
18
+ 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'",...headers});res.end(JSON.stringify(value));
19
+ }
20
+ /** `handoff` (optional): {queue, listener, restart} of the website handoff (handoff.js); the client window reads and reports through these authenticated routes. */
21
+ export function createLibraryHttpHandler(library,{parentOrigin,transportOrigin,authenticate,authorize,handoff,nodeRuntime}){
22
+ const origins=controlOrigins(parentOrigin,transportOrigin);parentOrigin=origins.parent;
23
+ requireCondition(typeof authenticate==='function'&&typeof authorize==='function','AUTH_BINDING_REQUIRED','Host authentication and per-operation authorization callbacks are mandatory.');
24
+ const parent=new URL(origins.transport),hosts=new Set(origins.forwarded?[parent.host]:[parent.host,`localhost:${parent.port}`]);
25
+ return async(req,res)=>{const traceId=randomUUID();let cors={};try{
26
+ requireCondition(hosts.has(req.headers.host),'HOST_DENIED','Unrecognized request host.',{},403);
27
+ requireCondition(req.url?.startsWith('/')&&!req.url.startsWith('//')&&!req.url.includes('\\'),'BAD_TARGET','Invalid request target.');
28
+ const requestOrigin=`${parent.protocol}//${req.headers.host}`,url=new URL(req.url,requestOrigin),path=url.pathname;
29
+ requireCondition(LIBRARY_ROUTES.includes(path),'NOT_FOUND','Unknown library route.',{},404);
30
+ requireCondition(req.headers['sec-fetch-dest']!=='iframe','FRAME_CONTROL_DENIED','An application frame cannot control the host.',{},403);
31
+ if(path==='/hanamesh/library/installation-status'){
32
+ cors=installationStatusCors(req,[requestOrigin,origins.parent,...library.handoffSettings().allowedOrigins]);
33
+ await serveInstallationStatus(req,res,{read:library.installationStatus,maxBodyBytes:HANDOFF_BODY_MAX_BYTES,traceId,cors,send});return;
34
+ }
35
+ if(path==='/hanamesh/library/install-target'){
36
+ // Match the website handoff policy; same-origin workspace and non-browser local reads remain available.
37
+ const origin=req.headers.origin;
38
+ if(origin!==undefined){
39
+ requireCondition(origin===requestOrigin||origins.acceptsOrigin(origin)||library.handoffSettings().allowedOrigins.includes(origin),'ORIGIN_DENIED','Not available.',undefined,403);
40
+ cors={'access-control-allow-origin':origin,vary:'Origin','access-control-allow-private-network':'true'};
41
+ }
42
+ if(req.method==='OPTIONS'){send(res,204,undefined,{...cors,'access-control-allow-methods':'GET'});return;}
43
+ requireCondition(req.method==='GET','METHOD_NOT_ALLOWED','Discovery requires GET.',{},405);
44
+ requireCondition(!url.search,'UNKNOWN_FIELDS','Discovery takes no parameters.');
45
+ send(res,200,{...library.installTarget(),traceId},cors);return;
46
+ }
47
+ const readOnly=req.method==='GET'&&READ_ROUTES.has(path);
48
+ requireCondition(readOnly||(req.method==='POST'&&!['/hanamesh/library/installedPlugins','/hanamesh/library/target','/hanamesh/library/handoffs'].includes(path)),'METHOD_NOT_ALLOWED','Unsupported library method.',{},405);
49
+ requireCondition(!req.headers.origin||(origins.forwarded?origins.acceptsOrigin(req.headers.origin):req.headers.origin===requestOrigin),'ORIGIN_DENIED','Unapproved browser origin.',{},403);
50
+ if(!readOnly)requireCondition((origins.forwarded?origins.acceptsOrigin(req.headers.origin):req.headers.origin===requestOrigin)&&req.headers['x-hanamesh-client']==='workspace-v1','CSRF_DENIED','Workspace origin and explicit client header are required.',{},403);
51
+ const subject=await authenticate(req);
52
+ requireCondition(subject&&typeof subject.principalId==='string','UNAUTHENTICATED','Host authentication is required.',{},401);
53
+ if(readOnly)requireCondition(new Set(url.searchParams.keys()).size===[...url.searchParams.keys()].length,'INVALID_INPUT','Duplicate query fields are not accepted.');
54
+ const input=readOnly?Object.fromEntries(url.searchParams):await jsonBody(req);
55
+ requireCondition(await authorize(subject,path,input)===true,'FORBIDDEN','Operation is not authorized.',{},403);
56
+ let result;
57
+ if(NODE_RUNTIME_ROUTES.includes(path)){
58
+ requireCondition(nodeRuntime,'NODE_RUNTIME_UNAVAILABLE','本插件尚未提供安装环境管理。',{},503);
59
+ requireCondition(!url.search,'UNKNOWN_FIELDS','安装环境请求不接受查询参数。');
60
+ try{if(path==='/hanamesh/library/node-runtime'){requireCondition(readOnly,'METHOD_NOT_ALLOWED','请使用读取操作。',{},405);result=nodeRuntime.status();}
61
+ else if(path.endsWith('/settings'))result=await nodeRuntime.save(input);
62
+ else if(path.endsWith('/plan'))result=await nodeRuntime.plan(input);
63
+ else if(path.endsWith('/prepare'))result=nodeRuntime.prepare(input);
64
+ else result=nodeRuntime.cancel(input);
65
+ }catch(error){if(error instanceof AppHostError)throw error;throw new AppHostError('NODE_ENVIRONMENT_FAILED','无法读取或保存安装环境:'+String(error?.message??error).slice(0,1500),{cause:error?.code??'NODE_CHECK_FAILED'},500);}
66
+ send(res,path.endsWith('/prepare')?202:200,{...result,traceId});return;
67
+ }
68
+ if(path==='/hanamesh/library/suite-source'){
69
+ requireCondition(!url.search,'UNKNOWN_FIELDS','套件安装包来源不接受查询参数。');
70
+ requireCondition(!readOnly||Object.keys(input).length===0,'UNKNOWN_FIELDS','套件安装包来源读取不接受参数。');
71
+ const result=readOnly?library.suiteSourceSettings():await library.replaceSuiteSourceSettings(input);
72
+ send(res,200,{...result,traceId});return;
73
+ }
74
+ if(path==='/hanamesh/library/suite/upgrade'){send(res,202,{...await library.upgradeSuite(input),traceId});return;}
75
+ if(path.startsWith('/hanamesh/library/handoff'))requireCondition(handoff,'HANDOFF_UNAVAILABLE','Website handoff is not wired in this host.',{},503);
76
+ if(path==='/hanamesh/library/handoffs'){requireCondition(Object.keys(input).length===0,'UNKNOWN_FIELDS','handoffs takes no parameters.');handoff.queue.clientPolled();result={handoffs:handoff.queue.open(),listener:handoff.listener.state(),pollMs:library.handoffSettings().pollMs,live:library.live,client:library.installTarget().client};}
77
+ else if(path==='/hanamesh/library/handoffs/state'){send(res,200,{...handoff.queue.report(input),traceId});return;}
78
+ else if(path==='/hanamesh/library/handoff-settings'&&readOnly){requireCondition(Object.keys(input).length===0,'UNKNOWN_FIELDS','handoff-settings takes no parameters.');result={settings:library.handoffSettings(),defaults:HANDOFF_DEFAULTS,names:HANDOFF_SETTING_NAMES,rules:HANDOFF_RULES,listener:handoff.listener.state()};}
79
+ else if(path==='/hanamesh/library/handoff-settings'){const settings=await library.replaceHandoffSettings(input);send(res,200,{settings,defaults:HANDOFF_DEFAULTS,names:HANDOFF_SETTING_NAMES,rules:HANDOFF_RULES,listener:await handoff.restart(),traceId});return;}
80
+ else if(path==='/hanamesh/library/target')result=await library.resolveTarget(input);
81
+ else 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);}
82
+ else if(path==='/hanamesh/library/sources'&&readOnly)result=library.sources();
83
+ else if(path==='/hanamesh/library/sources')result=await library.replaceSources(input);
84
+ else if(path==='/hanamesh/library/install')result=await library.install(input);
85
+ else if(path==='/hanamesh/library/provision')result=await library.provision(input);
86
+ else if(path==='/hanamesh/library/uninstall')result=await library.uninstall(input);
87
+ else if(path==='/hanamesh/library/installedPlugins'){requireCondition(Object.keys(input).length===0,'UNKNOWN_FIELDS','installedPlugins takes no parameters.');result=await library.installedSnapshot();}
88
+ else if(path==='/hanamesh/library/plugins/install')result=await library.installPlugin(input);
89
+ else if(path==='/hanamesh/library/plugins/uninstall')result=await library.uninstallPlugin(input);
90
+ else{requireCondition(Object.keys(input).every(key=>key==='after'),'UNKNOWN_FIELDS','Unknown event parameters.');result=library.events(Number(input.after??0));}
91
+ send(res,!readOnly&&path!=='/hanamesh/library/sources'&&result.status!=='already-installed'?202:200,{...result,traceId});
92
+ }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},cors);}};
93
+ }
@@ -1,56 +1,152 @@
1
1
  import { randomUUID } from 'node:crypto';
2
- import { AppHostError, requireCondition } from '../errors.js';
2
+ import { AppHostError, requireCondition, SerialQueue } from '../errors.js';
3
3
  import { loadCatalog, isApplication, itemKind, upgradeAvailable, validateManifestUrl } from './catalog.js';
4
4
  import { scanInstalled, scanInstalledPlugins, snapshotProfileDependencies } from './installed.js';
5
+ import {SUITE_TARGET,SUITE_PACKAGES,readSuiteStatus,SUITE_INSTALL_COMMAND,CORE_SUITE_DECLARATION_MISSING,readSuiteSourceState} from './suite.js';
5
6
  import { PROTECTED_PACKAGES } from './install.js';
7
+ import { handoffSettings } from './handoff.js';
8
+ import { INSTALLATION_STATUS_CAPABILITY } from '../installation-status.js';
6
9
 
10
+ /** Install-target contract version this market implements (schemas/install-target.schema.json). */
11
+ export const INSTALL_TARGET_CONTRACT_VERSION='1';
7
12
  const PACKAGE_NAME=/^(?:@[a-z0-9][a-z0-9._-]*\/)?[a-z0-9][a-z0-9._-]*$/;
8
13
  const text=(value,max)=>typeof value==='string'&&value.length>0&&value.length<=max;
9
14
  const RESTART_STATES=new Set(['installed-not-loaded','uninstalled-not-unloaded']);
10
15
  const protectedPackage=name=>PROTECTED_PACKAGES.includes(name);
16
+ const present=row=>row&&['registered','installed','installed-not-loaded'].includes(row.state);
11
17
 
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;};
18
+ /**
19
+ * `client`: which client this host is ('dsh' | 'hanamesh', see client-identity.js). `unavailable`: when there is no
20
+ * installer, the facts that are missing, in words a person can act on (`[{need, how}]`); probes and 503s carry them.
21
+ */
22
+ export function createLibraryService({domain,host,config={},dataRoot,ledgerReader,installer,client='dsh',unavailable=()=>[],installationStatus,suiteSourceService=()=>undefined,logger}){
23
+ const warn=(message,...args)=>{if(typeof logger?.warn==='function')logger.warn(message,...args);else console.warn(message,...args);};
24
+ const ready=()=>typeof installer?.install==='function'&&installer.ready!==false;
25
+ let suiteStatus={status:'unknown',version:null,components:[]};
26
+ const suiteFacts=async()=>installer?.suiteFacts?await installer.suiteFacts():{...await readSuiteStatus(config.profileDir),status:'unknown',completeness:'unknown',blocked:[{code:'CORE_SUITE_DECLARATION_MISSING'}],suiteUpgradeUnavailable:CORE_SUITE_DECLARATION_MISSING};
27
+ const events=[];let sequence=0;let state;const running=new Map(),installing=new Map();let bootDependencies;
28
+ const eventRetention=config.eventRetention??512;
29
+ requireCondition(Number.isSafeInteger(eventRetention)&&eventRetention>0,'INVALID_INPUT','library.eventRetention must be a positive safe integer.');
30
+ const observers=new Set();
31
+ const emit=event=>{const value={sequence:++sequence,at:Date.now(),...event};events.push(value);if(events.length>eventRetention)events.shift();for(const observer of observers){try{observer(value);}catch(error){warn('hanamesh-app-host library: event observer failed (%s): %s',value.type,error?.message??error);}}return value;};
15
32
  // Real sources hold ~10k entries (2026-09-20: market.hanamesh.com 12,121). `/hanamesh/library` keeps its rc.16 default
16
33
  // (`category=hanamesh-app`, backward compatible); the rc.28 market page asks for `category=''` (everything) explicitly.
17
34
  const catalog=({q='',category='hanamesh-app',cursor}={})=>loadCatalog({fixture:config.fixture,sources:state.sources,fetchImpl:config.fetchImpl,query:q,category,cursor});
18
35
  async function installed(){return config.profileDir?await scanInstalled({profileDir:config.profileDir,host,ledgerReader,dataRoot}):[];}
19
36
  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.');
37
+ const settingsWriter=new SerialQueue();
38
+ function persist(update){return settingsWriter.run(async()=>{const next={...update(state),revision:state.revision+1};await domain.global.set(next);state=next;});}
39
+ async function operation(type,task){const operationId=randomUUID();emit({type:`library.${type}-started`,operationId});const work=Promise.resolve().then(()=>task(operationId)).then(result=>{emit({type:`library.${type}-done`,operationId,result});return result;},error=>{emit({type:`library.${type}-failed`,operationId,code:error.code??'LIBRARY_OPERATION_FAILED',...(error.recovery?{recovery:error.recovery}:{}),...(error.details?.stage==='suite-upgrade'?{details:{stage:'suite-upgrade',actual:error.details.actual,command:SUITE_INSTALL_COMMAND}}:{})});throw error;}).finally(()=>running.delete(operationId));running.set(operationId,work);work.catch(()=>{});return{operationId,status:'started'};}
40
+ const requireInstaller=(message)=>requireCondition(ready(),'LIBRARY_INSTALL_UNAVAILABLE',message,{need:unavailable()},503);
41
+ /**
42
+ * The host itself loaded/unloaded a plugin (pluginManager `applied`): it no longer waits for a restart. When the profile
43
+ * cannot be read back, the result says so (`restartBaseline`) instead of the installed list silently showing 「需重启」.
44
+ */
45
+ let bootSnapshotError;
46
+ async function markLive(result){
47
+ if(result?.status!=='applied'||!config.profileDir)return result;
48
+ if(!bootDependencies)return{...result,restartBaseline:{updated:false,code:'BOOT_SNAPSHOT_UNREADABLE',cause:bootSnapshotError}};
49
+ let now;
50
+ try{now=await snapshotProfileDependencies(config.profileDir);}
51
+ catch(error){const code=error?.code??'PROFILE_READ_FAILED';warn('hanamesh-app-host library: profile read-back after live %s failed (%s)',result.packageName,code);return{...result,restartBaseline:{updated:false,code}};}
52
+ if(now[result.packageName])bootDependencies[result.packageName]=now[result.packageName];else delete bootDependencies[result.packageName];
53
+ return{...result,restartBaseline:{updated:true}};
54
+ }
55
+ async function resolveTarget(input){
56
+ requireCondition(input&&typeof input==='object'&&!Array.isArray(input),'INVALID_INPUT','Only itemId and packageName are accepted.');
57
+ // rc.8: optional install-target contract declaration. Absent keeps the v1 semantics; anything but '1' is refused explicitly, before field checks.
58
+ requireCondition(input.contractVersion===undefined||input.contractVersion===INSTALL_TARGET_CONTRACT_VERSION,'CONTRACT_VERSION_UNSUPPORTED','This market does not implement the requested install-target contract version.',{supported:[INSTALL_TARGET_CONTRACT_VERSION]});
59
+ requireCondition(Object.keys(input).every(key=>['itemId','packageName','contractVersion'].includes(key)),'INVALID_INPUT','Only itemId and packageName are accepted.');
60
+ const{itemId,packageName}=input;
61
+ if(itemId===SUITE_TARGET){
62
+ requireCondition(packageName===undefined,'INVALID_INPUT','套件升级不接受包名或任意替换参数。');
63
+ requireCondition(typeof installer?.prepareSuiteUpgrade==='function','SUITE_UPGRADE_UNAVAILABLE','当前客户端还不支持安全套件升级,请使用补齐命令。',{},503);
64
+ const plan=await installer.prepareSuiteUpgrade();
65
+ return{contractVersion:'1',source:{kind:'suite',manifestUrl:'https://github.com/yzsnstotz'},item:{id:SUITE_TARGET,displayName:'HanaMesh 套件',kind:'suite-upgrade',package:{registry:'npm',name:'hanamesh-core'},latestVersion:plan.components[0].to,installed:{state:'installed',version:suiteStatus.version},upgradeAvailable:plan.available,suiteUpgrade:plan}};
66
+ }
67
+ requireCondition(itemId!==undefined||packageName!==undefined,'INVALID_INPUT','itemId or packageName is required.');
68
+ requireCondition(itemId===undefined||(text(itemId,160)&&!/[\u0000-\u001f\u007f]/.test(itemId)),'INVALID_INPUT','itemId is invalid.');
26
69
  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;
70
+ let cursor;const seen=new Set(),matches=new Map();
71
+ do{
72
+ const page=await catalog({q:packageName??'',category:'',cursor});
73
+ const found=page.items.find(row=>(itemId===undefined||row.id===itemId)&&(packageName===undefined||row.package?.name===packageName));
74
+ if(found&&itemId===undefined&&page.schemaVersion==='hanamesh.catalog.v1'){
75
+ for(const row of page.items.filter(row=>row.package?.name===packageName))matches.set(row.id,{row,source:page.source});
76
+ requireCondition(matches.size<=1,'CATALOG_TARGET_AMBIGUOUS','Choose an itemId: this npm name belongs to multiple catalog identities.',{packageName},409);
77
+ }else if(found){
78
+ requireCondition(found.package?.registry==='npm','NOT_INSTALLABLE','This catalog entry cannot be installed.');
79
+ requireCondition(!protectedPackage(found.package.name),'PACKAGE_DENIED','The HanaMesh suite is not managed through the market.',{},403);
80
+ const rows=[...await installedPlugins(),...await installed()],row=rows.find(value=>value.packageName===found.package.name)??null;
81
+ return{contractVersion:INSTALL_TARGET_CONTRACT_VERSION,source:page.source,item:{...found,kind:itemKind(found),installed:row,upgradeAvailable:Boolean(row?.version&&row.state!=='uninstalled-not-unloaded'&&upgradeAvailable(found.latestVersion,row.version))}};
82
+ }
83
+ cursor=page.page?.nextCursor;
84
+ requireCondition(!cursor||!seen.has(cursor),'INVALID_CURSOR','Catalog cursor repeated.');if(cursor)seen.add(cursor);
85
+ }while(cursor);
86
+ if(matches.size===1){
87
+ const{row:found,source}=matches.values().next().value;
88
+ requireCondition(!protectedPackage(found.package.name),'PACKAGE_DENIED','The HanaMesh suite is not managed through the market.',{},403);
89
+ const rows=[...await installedPlugins(),...await installed()],row=rows.find(value=>value.packageName===found.package.name)??null;
90
+ return{contractVersion:INSTALL_TARGET_CONTRACT_VERSION,source,item:{...found,kind:itemKind(found),installed:row,upgradeAvailable:Boolean(row?.version&&row.state!=='uninstalled-not-unloaded'&&upgradeAvailable(found.latestVersion,row.version))}};
91
+ }
92
+ throw new AppHostError('CATALOG_ITEM_MISSING','The market catalog does not contain this target.',{},404);
93
+ }
94
+ function installItem(item){
95
+ const key=item.package.name;if(installing.has(key))return installing.get(key);
96
+ const started=operation('install',async operationId=>{try{return await markLive(await installer.install(item,{operationId}));}finally{installing.delete(key);}});
97
+ installing.set(key,started);return started;
30
98
  }
31
99
  async function findByPackage(packageName){
32
100
  requireCondition(text(packageName,214)&&PACKAGE_NAME.test(packageName),'INVALID_INPUT','packageName is required.');
33
101
  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;
102
+ const{item}=await resolveTarget({packageName});return item;
36
103
  }
37
104
  const runningApp=appId=>host.instanceList().some(instance=>instance.appId===appId&&!['stopped','failed','interrupted'].includes(instance.status));
38
105
  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;},
106
+ async init(){state=await domain.global.get();if(state.revision===0&&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(current=>({...current,sources}));}
107
+ // rc.28: the dependency set at boot is the "loaded" baseline for plugin restart states; unreadable → everything counts as
108
+ // loaded, logged and reported as `restartBaseline` on list() so the market can say restart states are unknown.
109
+ if(config.profileDir){try{bootDependencies=await snapshotProfileDependencies(config.profileDir);}catch(error){bootDependencies=undefined;bootSnapshotError=error?.code??'PROFILE_READ_FAILED';warn('hanamesh-app-host library: boot profile snapshot unreadable (%s); restart states cannot be told',bootSnapshotError);}}
110
+ suiteStatus=await suiteFacts();return this;},
43
111
  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
112
  const page=await catalog({q,category,cursor});const installedRows=await installed();const pluginRows=await installedPlugins();
113
+ suiteStatus={...await suiteFacts(),...(suiteStatus.restartRequired?{restartRequired:true}:{}),...(suiteStatus.upgradeError?{upgradeError:suiteStatus.upgradeError}:{})};
45
114
  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);
115
+ let items=page.items.filter(item=>!protectedPackage(item.package?.name)).map(item=>{const row=byPackage.get(item.package?.name)??null;const kind=itemKind(item);
47
116
  return{...item,kind,application:isApplication(item),installed:row,upgradeAvailable:Boolean(row&&row.version&&row.state!=='uninstalled-not-unloaded'&&upgradeAvailable(item.latestVersion,row.version))};});
117
+ if(config.profileDir&&!cursor&&(!category||category==='plugin')&&(!q||'hanamesh 套件'.includes(q.toLowerCase())))items.unshift({id:'hanamesh:suite',displayName:'HanaMesh 套件',summary:'官方 · 不计分。Core、使用记录与应用库一次装齐。',kind:'suite',official:true,latestVersion:suiteStatus.version,installed:suiteStatus.status==='installed'?{state:'installed',version:suiteStatus.version}:null,suite:suiteStatus,...((installer?.suiteUpgradeUnavailable??suiteStatus.suiteUpgradeUnavailable)?{suiteUpgradeUnavailable:installer?.suiteUpgradeUnavailable??suiteStatus.suiteUpgradeUnavailable}:{})});
48
118
  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};},
119
+ return{source:page.source,items,categories,installed:installedRows,plugins:pluginRows,restartRequired:[...pluginRows,...installedRows].some(row=>RESTART_STATES.has(row.state)),...(bootSnapshotError?{restartBaseline:{known:false,code:bootSnapshotError}}:{}),page:page.page};},
50
120
  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();},
121
+ /** Suite package registry selection is independent of catalog manifest sources. */
122
+ suiteSourceSettings(){
123
+ let observation;try{observation={observable:true,state:readSuiteSourceState(suiteSourceService())};}catch(error){observation={observable:false,code:error.code??'CORE_SUITE_SOURCE_INVALID'};}
124
+ return{mode:state.suiteSource?.mode??'configured',configuredRegistry:config.registry??'https://registry.npmjs.org',observation};
125
+ },
126
+ async replaceSuiteSourceSettings(input){
127
+ requireCondition(input&&typeof input==='object'&&!Array.isArray(input)&&Object.keys(input).length===1&&['configured','core'].includes(input.mode),'INVALID_SUITE_SOURCE_SETTINGS','只接受 mode: configured 或 core。');
128
+ const selection={mode:input.mode};
129
+ try{await persist(current=>({...current,suiteSource:selection}));}catch(error){throw new AppHostError('SUITE_SOURCE_SAVE_FAILED','套件安装包来源保存失败;原选择保持。',{},500);}
130
+ return this.suiteSourceSettings();
131
+ },
132
+ /** Effective website handoff settings: saved (library settings page) → plugin config `library.handoff` → proposed defaults. */
133
+ handoffSettings(){return handoffSettings(state.handoff??config.handoff??{});},
134
+ async replaceHandoffSettings(input){const next=handoffSettings(input);await persist(current=>({...current,handoff:next}));return next;},
135
+ observe(listener){observers.add(listener);return()=>observers.delete(listener);},
136
+ 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(current=>({...current,sources}));return this.sources();},
52
137
  /** `{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));},
138
+ resolveTarget,
139
+ /** Public discovery is metadata only: no profile, authentication or installed rows. */
140
+ installationStatus,
141
+ installTarget(){const need=ready()?[]:unavailable();return{client,ready:ready(),...(typeof installationStatus==='function'?{installationStatus:INSTALLATION_STATUS_CAPABILITY}:{}),...((suiteStatus.status!=='unknown'||suiteStatus.restartRequired)&&!installer?.suiteUpgradeUnavailable?{suite:structuredClone(suiteStatus)}:{}),installTarget:{contract:'hanamesh.install-target',contractVersion:INSTALL_TARGET_CONTRACT_VERSION,supported:[INSTALL_TARGET_CONTRACT_VERSION],fragmentPrefix:'#hanamesh-install?'},
142
+ ...(!ready()?{error:{code:'INSTALL_TARGET_UNAVAILABLE',message:need.length?'应用库还不能安装:'+need.map(row=>row.need).join(';'):'Controlled installer is unavailable.',need}}:{})};},
143
+ /** Plugin changes load without a restart (the host's public plugin manager is present). */
144
+ get live(){return Boolean(installer?.live);},
145
+ async upgradeSuite(input){requireInstaller('Suite upgrade is unavailable.');
146
+ requireCondition(input&&Object.keys(input).length===1&&typeof input.planId==='string','INVALID_INPUT','Only the current planId is accepted.');
147
+ return await operation('install',async()=>{try{const result=await installer.upgradeSuite(input);suiteStatus={...await suiteFacts(),restartRequired:true};return result;}catch(error){if(error.details?.stage!=='suite-upgrade')throw error;const actual=await readSuiteStatus(config.profileDir);suiteStatus={...await suiteFacts(),restartRequired:true,upgradeError:error.code??'SUITE_UPGRADE_FAILED'};error.details={...error.details,stage:'suite-upgrade',actual,command:SUITE_INSTALL_COMMAND};emit({type:'library.install-failed',packageName:'hanamesh-core',code:error.code??'SUITE_UPGRADE_FAILED',details:{stage:'suite-upgrade',actual,command:SUITE_INSTALL_COMMAND}});throw error;}});
148
+ },
149
+ async install(input){requireCondition(input?.itemId!==SUITE_TARGET,'SUITE_CONFIRMATION_REQUIRED','请在客户端确认完整升级计划。',{},403);requireInstaller('Install requires absolute profileDir, nodeBinary, dshBin and profileName.');const{item}=await resolveTarget(input??{});if(present(item.installed)&&!item.upgradeAvailable)return{status:'already-installed',itemId:item.id,packageName:item.package.name};return await installItem(item);},
54
150
  async provision(input){requireInstaller('Runtime provision is unavailable.');
55
151
  // LIB-PROVISION-INPUT: a missing field is the caller's error (400), not an internal TypeError inside the operation.
56
152
  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.');
@@ -59,18 +155,18 @@ export function createLibraryService({domain,host,config={},dataRoot,ledgerReade
59
155
  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
156
  const{appId,packageName,runtimeItem}=input;return await operation('uninstall',()=>installer.uninstall({appId,packageName,runtimeItem,running:runningApp(appId)}));},
61
157
  /** 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));},
158
+ async installPlugin(input){requireInstaller('Plugin install requires absolute profileDir, nodeBinary, dshBin and profileName.');const item=await findByPackage(input?.packageName);return await installItem(item);},
63
159
  /** rc.28 `{packageName}`: `dsh plugin remove`; an installed application package is routed through the application uninstall (runtime + running check). */
64
160
  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
161
  const app=(await installed()).find(row=>row.packageName===packageName&&row.appId);
66
162
  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}));},
163
+ return await operation('uninstall',async()=>{return await markLive(await installer.uninstallPlugin({packageName}));});},
68
164
  /** Installed application packages of the profile (empty without `profileDir`); rc.27 binds package names for usage evidence. */
69
165
  installed,
70
166
  /** rc.28: installed plugins (every non-application dependency of the profile) with restart states. */
71
167
  installedPlugins,
72
168
  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,
169
+ 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()],resetRequired:after>sequence||(events.length>0&&after<events[0].sequence-1)};},
170
+ async close(){installationStatus?.close?.();await Promise.allSettled([...running.values()]);await settingsWriter.drained();await domain.close();},emit,
75
171
  };
76
172
  }
@@ -0,0 +1,130 @@
1
+ import {readFile} from 'node:fs/promises';
2
+ import {join} from 'node:path';
3
+ import semver from 'semver';
4
+ import {randomUUID} from 'node:crypto';
5
+ import {requireCondition} from '../errors.js';
6
+
7
+ export const SUITE_TARGET='hanamesh:suite-upgrade';
8
+ export const SUITE_PACKAGES=Object.freeze([
9
+ {name:'hanamesh-core',label:'Core',repo:'hanamesh-core'},
10
+ {name:'hanamesh-usage',label:'使用记录',repo:'hanamesh-usage'},
11
+ {name:'@hanamesh/dsh-app-host',label:'应用库',repo:'hanamesh-plugin-app-host'},
12
+ ].map(Object.freeze));
13
+ export const SUITE_INSTALL_COMMAND="env -u GIT_CONFIG_PARAMETERS -u GIT_ASKPASS -u SSH_ASKPASS GIT_CONFIG_NOSYSTEM=1 GIT_TERMINAL_PROMPT=0 GIT_CONFIG_COUNT=2 GIT_CONFIG_KEY_0=credential.helper GIT_CONFIG_VALUE_0= GIT_CONFIG_KEY_1=credential.helper GIT_CONFIG_VALUE_1='!gh auth git-credential' dsh plugin --profile desktop add --save-exact 'github:yzsnstotz/hanamesh-core#main' 'github:yzsnstotz/hanamesh-usage#main' 'github:yzsnstotz/hanamesh-plugin-app-host#main'";
14
+ const json=async path=>JSON.parse(await readFile(path,'utf8'));
15
+ /** Physical inventory only, never a compatibility declaration or permission to upgrade. */
16
+ export async function readSuiteStatus(profileDir){
17
+ if(!profileDir)return{status:'unknown',version:null,components:[]};
18
+ try{
19
+ const profile=await json(join(profileDir,'package.json'));
20
+ if(!profile.dependencies||!Array.isArray(profile.dsh?.profile?.bundles))throw new Error('unreadable profile');
21
+ const components=await Promise.all(SUITE_PACKAGES.map(async({name,label})=>{
22
+ let pkg;try{pkg=await json(join(profileDir,'node_modules',name,'package.json'));}catch{}
23
+ return{name,label,version:pkg?.name===name&&semver.valid(pkg.version)?pkg.version:null,active:profile.dsh.profile.bundles.includes(name)};
24
+ }));
25
+ return{status:components.every(c=>c.version&&c.active)?'installed':'incomplete',version:components[0].version,components};
26
+ }catch{return{status:'unknown',version:null,components:[]};}
27
+ }
28
+ export const CORE_SUITE_DECLARATION_MISSING='Core 未提供套件声明';
29
+ const object=value=>value!==null&&typeof value==='object'&&!Array.isArray(value);
30
+ const text=value=>typeof value==='string'&&value.length>0;
31
+ const exact=value=>typeof value==='string'&&semver.valid(value)===value;
32
+ const digest=value=>typeof value==='string'&&/^[a-f0-9]{64}$/.test(value);
33
+ const nullableText=value=>value===null||text(value);
34
+ const invalid=()=>requireCondition(false,'CORE_SUITE_DECLARATION_INVALID','Core 套件声明无法用于精确安装。',{},503);
35
+ function validateDeclaration(value){
36
+ if(!object(value)||value.schemaVersion!==1||!text(value.checkedAt)||!digest(value.declarationId)||!text(value.nodeVersion)||!nullableText(value.registry)||!['complete','incomplete','unknown'].includes(value.completeness))invalid();
37
+ const manifest=value.manifest;
38
+ if(!object(manifest)||manifest.schemaVersion!==1||!text(manifest.id)||!object(manifest.core)||manifest.core.name!==SUITE_PACKAGES[0].name||!exact(manifest.core.version)||!text(manifest.core.repository)||!Array.isArray(manifest.components)||manifest.components.length!==2)invalid();
39
+ const members=new Map();
40
+ for(const row of manifest.components){if(!object(row)||!text(row.id)||!SUITE_PACKAGES.slice(1).some(p=>p.name===row.moduleName)||members.has(row.moduleName)||!text(row.versionRange)||!text(row.repository))invalid();members.set(row.moduleName,row.id);}
41
+ if(!Array.isArray(value.installed)||value.installed.length!==3||!Array.isArray(value.upgrades)||!Array.isArray(value.blocked)||!object(value.peerFacts)||!object(value.metadataDigests))invalid();
42
+ const installed=new Map();
43
+ for(const row of value.installed){
44
+ if(!object(row)||!text(row.id)||!SUITE_PACKAGES.some(p=>p.name===row.moduleName)||installed.has(row.moduleName)||!text(row.requiredRange)||!text(row.repository)||!(row.actualVersion===null||exact(row.actualVersion))||!nullableText(row.actualRepository)||!text(row.status)||(row.runningVersion!==undefined&&!exact(row.runningVersion)))invalid();
45
+ installed.set(row.moduleName,row);
46
+ }
47
+ for(const row of value.blocked)if(!object(row)||!text(row.code)||(row.coreVersion!==undefined&&!text(row.coreVersion))||(row.moduleName!==undefined&&!text(row.moduleName)))invalid();
48
+ if(!Object.values(value.peerFacts).every(nullableText)||!Object.values(value.metadataDigests).every(text))invalid();
49
+ for(const upgrade of value.upgrades){
50
+ if(!object(upgrade)||!exact(upgrade.coreVersion)||!text(upgrade.registry)||!digest(upgrade.manifestDigest)||!Array.isArray(upgrade.targets)||upgrade.targets.length!==3)invalid();
51
+ const targets=new Set();
52
+ for(const row of upgrade.targets){if(!object(row)||!text(row.id)||!SUITE_PACKAGES.some(p=>p.name===row.moduleName)||targets.has(row.moduleName)||!exact(row.version)||!text(row.repository)||!digest(row.metadataDigest))invalid();targets.add(row.moduleName);}
53
+ if(upgrade.targets.find(row=>row.moduleName===SUITE_PACKAGES[0].name).version!==upgrade.coreVersion)invalid();
54
+ }
55
+ return structuredClone(value);
56
+ }
57
+ /** Read only the optional protocol-1 public state; absence is unobservable, never starting/ready. */
58
+ export function readSuiteSourceState(source){
59
+
60
+ requireCondition(source!==undefined&&source!==null,'CORE_SUITE_SOURCE_MISSING','Core 本机套件来源尚不可观察;请检查 Core 插件及其来源设置。',{},503);
61
+ requireCondition(object(source)&&source.protocolVersion==='1'&&typeof source.getState==='function','CORE_SUITE_SOURCE_INVALID','Core 本机套件来源协议或读取接口无效。',{},503);
62
+ let state;try{state=source.getState();}catch{requireCondition(false,'CORE_SUITE_SOURCE_INVALID','Core 本机套件来源读取失败。',{},503);}
63
+ requireCondition(object(state)&&state.protocolVersion==='1'&&['disabled','ready','failed','disposed'].includes(state.status)&&['configured','custom','disabled'].includes(state.selection)&&(state.registry===null||text(state.registry))&&Number.isSafeInteger(state.archiveCount)&&state.archiveCount>=0&&(state.lastError===null||typeof state.lastError==='string'),'CORE_SUITE_SOURCE_INVALID','Core 本机套件来源状态无效。',{},503);
64
+ return{protocolVersion:state.protocolVersion,status:state.status,selection:state.selection,registry:state.registry,archiveCount:state.archiveCount,lastError:state.lastError};
65
+ }
66
+ /** Consume only the optional protocol-1 Cordis method and the public C-CORE section D DTO. */
67
+ export function createCoreSuiteAdapter({core=()=>undefined,registry,suiteSourceMode=()=>'configured',suiteSourceService=()=>undefined}={}){
68
+ const current=()=>{const value=core();return value?.protocolVersion==='1'&&typeof value.getSuiteDeclaration==='function'?value:undefined;};
69
+ // AppHost-local service identity only: Core protocol 1 supplies no revision/generation.
70
+ const instances=new WeakMap();
71
+ function sourceBinding(){
72
+ const mode=suiteSourceMode();
73
+ requireCondition(['configured','core'].includes(mode),'INVALID_SUITE_SOURCE_SETTINGS','套件安装包来源选择无效。');
74
+ if(mode==='configured')return{mode,registry};
75
+ const source=suiteSourceService();
76
+ const state=readSuiteSourceState(source);
77
+ requireCondition(state.status==='ready'&&state.selection!=='disabled'&&text(state.registry),'CORE_SUITE_SOURCE_NOT_READY','Core 本机套件来源未就绪('+state.status+')。',{status:state.status,selection:state.selection,lastError:state.lastError},409);
78
+ let url;try{url=new URL(state.registry);}catch{}
79
+ requireCondition(url&&['http:','https:'].includes(url.protocol)&&!url.username&&!url.password&&!url.search&&!url.hash,'CORE_SUITE_SOURCE_INVALID','Core 本机套件来源 registry 无效。',{},503);
80
+ if(!instances.has(source))instances.set(source,randomUUID());
81
+ return{mode,registry:state.registry,selection:state.selection,serviceInstance:instances.get(source)};
82
+ }
83
+ async function read(request){
84
+ const service=current();
85
+ requireCondition(service,'CORE_SUITE_DECLARATION_MISSING',CORE_SUITE_DECLARATION_MISSING,{},503);
86
+ const result=request===undefined?await service.getSuiteDeclaration():await service.getSuiteDeclaration(request);
87
+ requireCondition(current()===service,'CORE_SUITE_DECLARATION_MISSING',CORE_SUITE_DECLARATION_MISSING,{},503);
88
+ return validateDeclaration(result);
89
+ }
90
+ const get=async()=>{
91
+ const binding=sourceBinding();
92
+ const declaration=await read({registry:binding.registry});
93
+ requireCondition(JSON.stringify(sourceBinding())===JSON.stringify(binding),'SUITE_PLAN_CHANGED','套件安装包来源已变化,请重新查看并确认。',{},409);
94
+ requireCondition(declaration.completeness==='complete','CORE_SUITE_CURRENT_INCOMPLETE','Core 套件完整性:'+declaration.completeness,{completeness:declaration.completeness,blocked:declaration.blocked},409);
95
+ let expected;try{const url=new URL(binding.registry);url.pathname=url.pathname.replace(/\/$/,'')+'/';expected=url.href;}catch{}
96
+ requireCondition(expected&&declaration.registry===expected,'CORE_SUITE_SOURCE_MISMATCH','Core 声明来源与执行 registry 不符。',{blocked:declaration.blocked},409);
97
+ requireCondition(declaration.upgrades.length>0,'CORE_SUITE_NO_COMPATIBLE_TUPLE','Core 未提供可执行升级组合。',{blocked:declaration.blocked},409);
98
+ // The first displayed Core tuple is the presentation default; App-host never ranks versions.
99
+ const selectedUpgrade=declaration.upgrades[0];
100
+ requireCondition(selectedUpgrade.registry===declaration.registry,'CORE_SUITE_SOURCE_MISMATCH','Core 升级组合来源与执行 registry 不符。',{},409);
101
+ const components=SUITE_PACKAGES.map(({name,label})=>{const from=declaration.installed.find(row=>row.moduleName===name).actualVersion;if(!exact(from))invalid();return{name,label,from,to:selectedUpgrade.targets.find(row=>row.moduleName===name).version};});
102
+ return{available:true,components,sourceBinding:binding,declarationId:declaration.declarationId,registry:declaration.registry,selectedUpgrade,physicalBaseline:declaration.installed,upgrades:declaration.upgrades,blocked:declaration.blocked};
103
+ };
104
+ Object.defineProperty(get,'unavailable',{get:()=>current()?null:CORE_SUITE_DECLARATION_MISSING});
105
+ get.readFacts=async()=>{
106
+ const declaration=await read();
107
+ return{status:declaration.completeness==='complete'?'installed':declaration.completeness,completeness:declaration.completeness,version:declaration.installed.find(row=>row.moduleName==='hanamesh-core').actualVersion,
108
+ components:declaration.installed.map(row=>({...row,name:row.moduleName,label:SUITE_PACKAGES.find(p=>p.name===row.moduleName).label,version:row.actualVersion})),blocked:declaration.blocked};
109
+ };
110
+ return get;
111
+ }
112
+ /**
113
+ * Internal App-host execution port. The public adapter above supplies this plan in production;
114
+ * injected normalized stand-ins remain useful for testing CLI/confirmation behavior in isolation.
115
+ * Core owns candidate selection, completeness, ranges and compatibility; this module only checks
116
+ * that an exact execution plan is well formed and confined to our three protected packages.
117
+ */
118
+ export async function prepareSuiteUpgrade({coreSuitePlan}={}){
119
+ requireCondition(typeof coreSuitePlan==='function','CORE_SUITE_DECLARATION_MISSING',CORE_SUITE_DECLARATION_MISSING,{},503);
120
+ const plan=await coreSuitePlan();
121
+ requireCondition(plan!==undefined&&plan!==null,'CORE_SUITE_DECLARATION_MISSING',CORE_SUITE_DECLARATION_MISSING,{},503);
122
+ const invalid=()=>requireCondition(false,'CORE_SUITE_DECLARATION_INVALID','Core 套件声明无法用于精确安装。',{},503);
123
+ if(typeof plan!=='object'||Array.isArray(plan)||typeof plan.available!=='boolean'||!Array.isArray(plan.components)||plan.components.length!==SUITE_PACKAGES.length)invalid();
124
+ const rows=new Map();
125
+ for(const row of plan.components){
126
+ if(!row||typeof row!=='object'||!SUITE_PACKAGES.some(p=>p.name===row.name)||rows.has(row.name)||typeof row.from!=='string'||semver.valid(row.from)!==row.from||typeof row.to!=='string'||semver.valid(row.to)!==row.to)invalid();
127
+ rows.set(row.name,row);
128
+ }
129
+ return{...structuredClone(plan),available:plan.available,components:SUITE_PACKAGES.map(({name,label})=>({name,label,from:rows.get(name).from,to:rows.get(name).to}))};
130
+ }