@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
@@ -1,17 +1,21 @@
1
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';
2
+ import { join, resolve, dirname, sep, delimiter } from 'node:path';
3
+ import { fileURLToPath } from 'node:url';
4
+ import {safeGitEnv,guardNodeBinary,spawnGitSafe} from '../git-process.js';
5
+ import { randomUUID } from 'node:crypto';
6
+ import { AppHostError, requireCondition, SerialQueue } from '../errors.js';
5
7
  import { validateDefinition } from '../descriptor.js';
6
8
  import { itemKind } from './catalog.js';
9
+ import { profileSnapshot, runtimeSnapshot, profileChecks, runtimeChecks, packageRestoreArgs, keepOriginals } from './recovery.js';
10
+ import {prepareSuiteUpgrade,readSuiteStatus,createCoreSuiteAdapter,SUITE_INSTALL_COMMAND} from './suite.js';
7
11
 
8
12
  /** 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']);
13
+ export const PROTECTED_PACKAGES=Object.freeze(['@hanamesh/dsh-app-host','hanamesh-core','hanamesh-usage','hanamesh-brand','@hanamesh/dsh-core','@hanamesh/dsh-usage']);
10
14
  const blocked=new Set(PROTECTED_PACKAGES);
11
15
  const packageName=value=>typeof value==='string'&&/^(?:@[a-z0-9][a-z0-9._-]*\/)?[a-z0-9][a-z0-9._-]*$/.test(value);
12
16
  const versionText=value=>typeof value==='string'&&/^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/.test(value);
13
17
  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}));});}
18
+ const defaultSpawn=spawnGitSafe;
15
19
 
16
20
  async function defaults(){return await import('../provision/index.js');}
17
21
 
@@ -37,17 +41,32 @@ async function clearGeneratedRuntimeCache(result,runtimeRoot){
37
41
  requireCondition(remaining.length===0&&residue===null,'RUNTIME_RESIDUE','Runtime has unowned files that require manual review.',{count:remaining.length},409);
38
42
  }
39
43
 
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=()=>{}}){
44
+ /** Package mutations use the public CLI in an owned monitored process; upstream's in-process manager has no scoped env/monitor port. */
45
+ export function createLibraryInstaller({profileDir,profileName,dataRoot,nodeBinary,dshBin,desktopCli,desktopLauncher,pluginManagerVersion,registry='https://registry.npmjs.org',allowPrerelease=false,
46
+ fetchImpl=globalThis.fetch,spawn=defaultSpawn,provision,remove,ledger,coreSuitePlan,coreService=()=>undefined,suiteSourceMode=()=>'configured',suiteSourceService=()=>undefined,pluginManager=()=>undefined,emit=()=>{},profileWriter=new SerialQueue()}){
42
47
  requireCondition(typeof profileDir==='string'&&resolve(profileDir)===profileDir,'PROFILE_DIR_REQUIRED','library.profileDir must be an absolute path.');
43
48
  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.');
49
+ const launcherAvailable=typeof desktopLauncher?.command==='string'&&resolve(desktopLauncher.command)===desktopLauncher.command&&typeof desktopLauncher.home==='string'&&join(desktopLauncher.home,'profiles',profileName)===profileDir;
50
+ const supervisorNode=guardNodeBinary({workerNode:nodeBinary});
51
+ const cliAvailable=Boolean(supervisorNode)&&(launcherAvailable||(typeof nodeBinary==='string'&&resolve(nodeBinary)===nodeBinary&&typeof dshBin==='string'&&resolve(dshBin)===dshBin));
52
+ requireCondition(nodeBinary===undefined||(typeof nodeBinary==='string'&&resolve(nodeBinary)===nodeBinary),'NODE_RUNTIME_REQUIRED','nodeBinary must be an absolute path.');
53
+ requireCondition(dshBin===undefined||(typeof dshBin==='string'&&resolve(dshBin)===dshBin),'DSH_BIN_REQUIRED','dshBin must be an absolute DSH bin.js path.');
46
54
  requireCondition(typeof profileName==='string'&&/^[A-Za-z0-9_.-]+$/.test(profileName),'PROFILE_NAME_REQUIRED','library.profileName is required.');
55
+ const unavailable=()=>!supervisorNode?[{need:'找不到能运行安装监测的独立 Node,不能用 Electron 宿主代替',how:'在 AppHost 插件配置中,将 nodeBinary 填为独立 Node.js 24 的绝对路径(24.13.1 或更新的 24.x);完全退出后重开。Electron 的 Node 包装脚本不能代替独立 Node。'}]:cliAvailable?[]:[{need:'缺少能固定精确版本、隔离 Git 凭据并监测钥匙串调用的公开 DSH 命令行',how:'恢复客户端随附的公开 dsh 命令启动器后重试。'}];
47
56
  const runtimeRoot=appId=>join(dataRoot,'runtimes',appId);
48
57
  const permitted=name=>requireCondition(packageName(name)&&!blocked.has(name),'PACKAGE_DENIED','Package name is not permitted.',{packageName:name});
58
+ /** rc.10: one profile writer at a time inside this host; `dsh plugin` itself serialises against other writers per command. */
59
+ requireCondition(profileWriter&&typeof profileWriter.run==='function','INVALID_PROFILE_WRITER','A shared profile writer queue is required.');
60
+ const injected={provision,remove,ledger};
61
+ /** Injected runtime functions (tests, suites) or the vendored lib-provision export of the same name. */
62
+ const runtimeApi=async name=>injected[name]??(await defaults())[name];
49
63
  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;}
64
+ const commandPath=command=>[dirname(command),...(process.platform==='win32'?(process.env.PATH??process.env.Path??'').split(delimiter):['/opt/homebrew/bin','/usr/local/bin','/usr/bin','/bin'])].join(delimiter);
65
+ async function run(args){requireCondition(cliAvailable,'DSH_CLI_UNAVAILABLE','The official dsh plugin CLI is unavailable in this host.',{need:['nodeBinary','dshBin']},503);
66
+ const options={cwd:profileDir,workerNode:supervisorNode,env:safeGitEnv({HOME:process.env.HOME,DSH_HOME:process.env.DSH_HOME,LANG:process.env.LANG??'C.UTF-8',PATH:commandPath(nodeBinary??desktopLauncher.command)})},tail=['plugin','--profile',profileName,...args];
67
+ if(launcherAvailable)options.env=safeGitEnv({...options.env,DSH_HOME:desktopLauncher.home,PATH:commandPath(desktopLauncher.command)});
68
+ const result=await spawn(launcherAvailable?desktopLauncher.command:nodeBinary,launcherAvailable?tail:[...(desktopCli?['--expose-internals',fileURLToPath(new URL('./desktop-cli.js',import.meta.url)),desktopCli.entry,desktopCli.runtimeDir,desktopCli.supportDir]:[dshBin]),...tail],options);
69
+ 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
70
  /** 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
71
  async function latest(name){
53
72
  const base=new URL(registry);requireCondition(['https:','http:'].includes(base.protocol),'REGISTRY_DENIED','Registry URL is invalid.');
@@ -56,32 +75,119 @@ export function createLibraryInstaller({profileDir,profileName,dataRoot,nodeBina
56
75
  requireCondition(versionText(version)&&(allowPrerelease||!version.includes('-')),'UNSTABLE_VERSION','Registry latest must be an exact stable version.');
57
76
  return version;
58
77
  }
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;}
78
+ /**
79
+ * rc.10 compensation after a failed install: the package goes back to its baseline spec through the official CLI, runtime
80
+ * items this operation newly claimed are removed by ownership, a replaced baseline item is provisioned again from the
81
+ * restored definition, and then every item is read back. `restored`/`not-needed` only when every row matches the baseline.
82
+ */
83
+ async function recover({name,stage,base,runtime,operationId}){
84
+ const report={status:'failed',mode:base?.spec===null?'new-install':'upgrade',stage,previousVersion:base?.module??null,actions:[],checks:[],residue:[]};
85
+ if(!base){report.mode='unknown';report.status='not-needed';return report;}
86
+ const fail=error=>{report.error??={code:error.code??'INSTALL_RECOVERY_FAILED',message:String(error.message??error)};};
87
+ try{
88
+ let now=await profileSnapshot(profileDir,name);const args=packageRestoreArgs(base,now,name);
89
+ if(args){report.actions.push(['dsh','plugin',...args].join(' '));try{await run(args);}catch(error){fail(error);}now=await profileSnapshot(profileDir,name);}
90
+ report.checks.push(...profileChecks(base,now,name));
91
+ }catch(error){fail(error);report.residue.push({item:'profile',reason:'NOT_VERIFIED'});}
92
+ if(runtime)try{
93
+ const readLedger=await runtimeApi('ledger');let current=await runtimeSnapshot(readLedger,runtime.root);
94
+ for(const id of Object.keys(current).filter(id=>!(id in runtime.base))){
95
+ report.actions.push('provision remove '+id);
96
+ try{const removed=await (await runtimeApi('remove'))(id,{root:runtime.root});if(removed?.kept?.length)report.residue.push({item:'runtime:'+id,reason:'UNOWNED_FILES_KEPT',count:removed.kept.length});}catch(error){fail(error);}
97
+ }
98
+ const replaced=Object.keys(runtime.base).filter(id=>JSON.stringify(runtime.base[id])!==JSON.stringify(current[id]));
99
+ if(replaced.length&&base.spec!==null&&report.checks.every(row=>row.ok))try{
100
+ const record=await installed(name);
101
+ for(const id of replaced){const declared=record.definition.deployments.map(deployment=>deployment.runtime).find(value=>value?.item===id);if(!declared)continue;
102
+ report.actions.push('provision '+id);await (await runtimeApi('provision'))(declared.manifest,{root:runtime.root,only:[id],onProgress:event=>emit({type:'library.provision-progress',appId:record.definition.id,...event})});}
103
+ }catch(error){fail(error);}
104
+ current=await runtimeSnapshot(readLedger,runtime.root);report.checks.push(...runtimeChecks(runtime.base,current));
105
+ }catch(error){fail(error);report.residue.push({item:'runtime',reason:'NOT_VERIFIED'});}
106
+ for(const row of report.checks)if(!row.ok)report.residue.push({item:row.item,expected:row.expected,actual:row.actual,reason:row.reason??'NOT_RESTORED',...(row.concurrent?{concurrent:row.concurrent}:{})});
107
+ report.status=report.error||report.residue.length?'failed':report.actions.length?'restored':'not-needed';
108
+ if(report.status==='failed'){try{report.originals=await keepOriginals(join(dataRoot,'library-recovery',operationId),base,runtime?.base);}catch(error){report.originals={error:{code:error.code??'ORIGINALS_NOT_KEPT',message:String(error.message??error)}};}}
109
+ return report;
110
+ }
111
+ /**
112
+ * Same path for applications and plugins: baseline → `dsh plugin add --save-exact <name>@<version>` (also the upgrade
113
+ * path) → profile readback → application definition → runtime provision. Any failure after the baseline is compensated
114
+ * and reported with the original error; nothing is retried.
115
+ */
116
+ async function transaction(item,kind,context){
117
+ requireCondition(cliAvailable,'DSH_EXACT_INSTALL_UNAVAILABLE','The host cannot install a package with an exact saved version.',{need:unavailable()},503);
118
+ const name=item.package.name,operationId=/^[A-Za-z0-9-]{1,64}$/.test(context?.operationId??'')?context.operationId:randomUUID();let stage='baseline',base,runtime;
119
+ try{
120
+ base=await profileSnapshot(profileDir,name);
121
+ stage='before-add';const version=await latest(name);
122
+ const status='restart-required';
123
+ stage='add';await run(['add','--save-exact',`${name}@${version}`]);
124
+ stage='readback';const added=await profileSnapshot(profileDir,name);
125
+ requireCondition(added.spec===version&&added.module===version,'INSTALL_READBACK_MISMATCH','The profile does not resolve the exact version that was added.',{expected:version,spec:added.spec,module:added.module},502);
126
+ if(kind==='plugin')return{status,kind,packageName:name,version};
127
+ stage='definition';const record=await installed(name);const declared=record.definition.deployments.map(deployment=>deployment.runtime).find(Boolean);
128
+ if(declared){stage='provision';const root=runtimeRoot(record.definition.id);runtime={root,base:await runtimeSnapshot(await runtimeApi('ledger'),root)};
129
+ await (await runtimeApi('provision'))(declared.manifest,{root,only:[declared.item],onProgress:event=>emit({type:'library.provision-progress',appId:record.definition.id,...event})});}
130
+ return{status:'restart-required',kind,appId:record.definition.id,packageName:name,version};
131
+ }catch(error){error.recovery=await recover({name,stage,base,runtime,operationId});throw error;}
132
+ }
133
+ async function installAs(item,kind,context){
134
+ permitted(item.package.name);const name=item.package.name;
135
+ emit({type:'library.install-started',...(item.id?{itemId:item.id}:{}),packageName:name,kind});
136
+ try{const result=await profileWriter.run(()=>transaction(item,kind,context));emit({type:'library.install-done',...result});return result;}
137
+ catch(error){emit({type:'library.install-failed',...(item.id?{itemId:item.id}:{}),packageName:name,kind,code:error.code??'INSTALL_FAILED',...(error.recovery?{recovery:error.recovery}:{})});throw error;}
138
+ }
139
+ let suitePlan;
140
+ const suiteProvider=coreSuitePlan??createCoreSuiteAdapter({core:coreService,registry,suiteSourceMode,suiteSourceService});
141
+ async function previewSuite(){
142
+ const next=await prepareSuiteUpgrade({coreSuitePlan:suiteProvider});
143
+ suitePlan={...next,planId:randomUUID()};return structuredClone(suitePlan);
144
+ }
61
145
  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});
146
+ get suiteUpgradeUnavailable(){return suiteProvider.unavailable??null;},
147
+ async suiteFacts(){
148
+ if(!suiteProvider.readFacts)return await readSuiteStatus(profileDir);
149
+ try{return await suiteProvider.readFacts();}catch(error){return{...await readSuiteStatus(profileDir),status:'unknown',completeness:'unknown',blocked:[{code:error.code??'CORE_SUITE_DECLARATION_UNAVAILABLE'}],suiteUpgradeUnavailable:error.code==='CORE_SUITE_DECLARATION_MISSING'?suiteProvider.unavailable:'Core 套件声明读取失败('+(error.code??'CORE_SUITE_DECLARATION_UNAVAILABLE')+')'};}
150
+ },
151
+ prepareSuiteUpgrade:previewSuite,
152
+ async upgradeSuite({planId}={}){return await profileWriter.run(async()=>{
153
+ requireCondition(suitePlan?.planId===planId,'SUITE_PLAN_CHANGED','升级确认已变化,请重新查看并确认。',{},409);
154
+ const confirmed=suitePlan;suitePlan=undefined;
155
+ const fresh=await prepareSuiteUpgrade({coreSuitePlan:suiteProvider});
156
+ const identity=plan=>JSON.stringify({sourceBinding:plan.sourceBinding,components:plan.components,declarationId:plan.declarationId,registry:plan.registry,selectedUpgrade:plan.selectedUpgrade,physicalBaseline:plan.physicalBaseline});
157
+ requireCondition(identity(fresh)===identity(confirmed),'SUITE_PLAN_CHANGED','套件声明或版本已变化,请重新查看并确认。',{},409);
158
+ requireCondition(fresh.available,'SUITE_NO_UPDATE','当前没有兼容新版。',{},409);
159
+ const before=await readSuiteStatus(profileDir);
160
+ requireCondition(before.status==='installed'&&fresh.components.every((c,i)=>before.components[i]?.version===c.from),'SUITE_PLAN_CHANGED','已装版本或启用状态已变化,请重新查看并确认。',{},409);
161
+ requireCondition(cliAvailable,'DSH_CLI_UNAVAILABLE','套件升级需客户端随附的官方 CLI;请恢复后重试。',{},503);
162
+ const executing=await prepareSuiteUpgrade({coreSuitePlan:suiteProvider});
163
+ requireCondition(identity(executing)===identity(confirmed),'SUITE_PLAN_CHANGED','执行前套件安装包来源或声明已变化,请重新查看并确认。',{},409);
164
+ emit({type:'library.install-started',itemId:'hanamesh:suite-upgrade',packageName:'hanamesh-core',kind:'suite-upgrade'});
67
165
  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;}
166
+ await run(['add','--save-exact','--strict-peer-dependencies',`--registry=${fresh.registry??registry}`,...fresh.components.map(c=>`${c.name}@${c.to}`)]);
167
+ const actual=await readSuiteStatus(profileDir);
168
+ const saved=await json(join(profileDir,'package.json'));
169
+ requireCondition(actual.status==='installed'&&fresh.components.every((c,i)=>actual.components[i]?.version===c.to&&saved.dependencies?.[c.name]===c.to),'INSTALL_READBACK_MISMATCH','套件升级后的版本或启用状态不符,请完全退出后按补齐命令修复。',{},502);
170
+ return{status:'restart-required',kind:'suite-upgrade',packageName:'hanamesh-core',components:fresh.components,version:actual.version};
171
+ }catch(error){
172
+ error.details={...error.details,stage:'suite-upgrade',actual:await readSuiteStatus(profileDir),command:SUITE_INSTALL_COMMAND};
173
+ emit({type:'library.install-failed',packageName:'hanamesh-core',code:error.code??'SUITE_UPGRADE_FAILED'});throw error;
174
+ }
175
+ });},
176
+ async install(item,context){
177
+ const kind=itemKind(item);
178
+ requireCondition(kind!=='listing','NOT_INSTALLABLE','Catalog item has no npm package to install.');
179
+ return await installAs(item,kind,context);
74
180
  },
75
181
  /** 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};},
182
+ async installPlugin({packageName:name},context){return await installAs({package:{registry:'npm',name}},'plugin',context);},
183
+ async provisionRuntime({appId,packageName:installedName,runtimeItem}){return await profileWriter.run(async()=>{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 result=await (await runtimeApi('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};});},
184
+ async uninstall({packageName:installedName,appId,runtimeItem,running}){permitted(installedName);requireCondition(!running,'INSTANCE_IN_USE','Stop all application instances before uninstalling.',{},409);return await profileWriter.run(async()=>{await run(['remove',installedName]);if(runtimeItem){const result=await (await runtimeApi('remove'))(runtimeItem,{root:runtimeRoot(appId)});await clearGeneratedRuntimeCache(result,runtimeRoot(appId));}return{status:'restart-required',kind:'application',appId,packageName:installedName,dataPreserved:true};});},
84
185
  /** 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};},
186
+ async uninstallPlugin({packageName:installedName}){permitted(installedName);return await profileWriter.run(async()=>{ await run(['remove',installedName]);return{status:'restart-required',kind:'plugin',packageName:installedName};});},
187
+ /** Guarded public CLI mutations require a restart. */
188
+ get live(){return false;},
189
+ /** Installation requires a public CLI that can run inside the owned monitor. */
190
+ get ready(){return cliAvailable;},
191
+ get unavailable(){return unavailable();},
86
192
  };
87
193
  }
@@ -0,0 +1,48 @@
1
+ /** Fixed official read-only CLI calls. No browser-controlled argv, environment or profile path. */
2
+ import {fork} from 'node:child_process';
3
+ import {guardNodeBinary} from '../git-process.js';
4
+ import {stat,realpath,rm} from 'node:fs/promises';
5
+ import {tmpdir} from 'node:os';
6
+ import {dirname,isAbsolute,delimiter} from 'node:path';
7
+ import {AppHostError} from '../errors.js';
8
+ const fail=code=>{throw new AppHostError(code,'The configured official installation reader could not complete.',{},503)};
9
+ export const SUPPORTED_READER_VERSIONS=Object.freeze({claude:'2.1.301 (Claude Code)',codex:'codex-cli 0.162.1'});
10
+ export function validReader(value){
11
+ if(!value||typeof value!=='object'||Array.isArray(value))return false;
12
+ const keys=['executable','home','configDir','pluginName','marketplace','marketplaceUrl','timeoutMs','maxOutputBytes'];
13
+ if(Object.keys(value).some(key=>!keys.includes(key))||!['executable','home','configDir'].every(key=>typeof value[key]==='string'&&isAbsolute(value[key])&&!/[\x00-\x1f]/.test(value[key])))return false;
14
+ if(!['pluginName','marketplace'].every(key=>typeof value[key]==='string'&&/^[a-z0-9][a-z0-9._-]{0,127}$/.test(value[key])))return false;
15
+ try{const url=new URL(value.marketplaceUrl);if(url.protocol!=='https:'||url.username||url.password||url.hash||url.search||url.href!==value.marketplaceUrl)return false}catch{return false}
16
+ return Number.isSafeInteger(value.timeoutMs)&&value.timeoutMs>0&&value.timeoutMs<=2_147_483_647&&Number.isSafeInteger(value.maxOutputBytes)&&value.maxOutputBytes>0;
17
+ }
18
+ export async function runOfficialInstallationCommand({channel,reader,args,signal,workerNode}){
19
+ if(signal?.aborted)fail('INSTALLATION_STATUS_CANCELLED');
20
+ if(process.platform==='win32')fail('AGENT_READER_PLATFORM_UNSUPPORTED');
21
+ if(!validReader(reader))fail('AGENT_READER_CONFIG_INVALID');
22
+ const nodeRuntime=guardNodeBinary({workerNode});if(!nodeRuntime)fail('AGENT_READER_NODE_REQUIRED');
23
+ for(const path of [reader.home,reader.configDir]){let value;try{value=await stat(path)}catch{fail('AGENT_PROFILE_UNAVAILABLE')}if(!value.isDirectory())fail('AGENT_PROFILE_UNAVAILABLE')}
24
+ const temporaryRoot=await realpath(tmpdir());let cwd;
25
+ // An exact configuration directory is explicit. Inherited model/API tokens, proxy, shell startup and askpass are absent.
26
+ const env={PATH:[dirname(nodeRuntime),'/usr/bin','/bin','/usr/local/bin'].join(delimiter),HOME:reader.home,USERPROFILE:reader.home,
27
+ ...(channel==='claude'?{CLAUDE_CONFIG_DIR:reader.configDir,CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC:'1',DISABLE_AUTOUPDATER:'1'}:{CODEX_HOME:reader.configDir}),
28
+ GIT_TERMINAL_PROMPT:'0',GIT_CONFIG_NOSYSTEM:'1',GIT_CONFIG_GLOBAL:'/dev/null',GIT_CONFIG_COUNT:'1',GIT_CONFIG_KEY_0:'credential.helper',GIT_CONFIG_VALUE_0:''};
29
+ try{return await new Promise((resolve,reject)=>{
30
+ if(signal?.aborted){reject(new AppHostError('INSTALLATION_STATUS_CANCELLED','Installation read was cancelled.',{},499));return}
31
+ const child=fork(new URL('./installation-worker.js',import.meta.url),[],{cwd:temporaryRoot,env,execPath:nodeRuntime,execArgv:[],detached:true,stdio:['ignore','pipe','pipe','ipc']});
32
+ const chunks=[];let total=0,stderr=false,fault,exited=false,message;
33
+ const kill=code=>{fault??=code;if(!exited&&child.pid){child.kill('SIGTERM')}};
34
+ const abort=()=>kill('INSTALLATION_STATUS_CANCELLED');signal?.addEventListener('abort',abort,{once:true});
35
+ const timer=setTimeout(()=>kill('AGENT_READER_TIMEOUT'),reader.timeoutMs);
36
+ child.stdout.on('data',chunk=>{total+=chunk.length;if(total>reader.maxOutputBytes)kill('AGENT_READER_OUTPUT_LIMIT');else chunks.push(chunk)});
37
+ child.stderr.on('data',chunk=>{stderr=true;total+=chunk.length;if(total>reader.maxOutputBytes)kill('AGENT_READER_OUTPUT_LIMIT')});
38
+ child.on('message',value=>{if(typeof value.directory==='string')cwd=value.directory;else message=value});
39
+ child.once('exit',()=>{exited=true});
40
+ child.once('error',error=>{fault??=error.code==='EACCES'?'AGENT_READER_PERMISSION_DENIED':'AGENT_READER_UNAVAILABLE'});
41
+ child.once('close',code=>{exited=true;clearTimeout(timer);signal?.removeEventListener('abort',abort);
42
+ if(fault||message?.error||message?.result!==0||stderr)reject(new AppHostError(fault??message?.error??(stderr?'AGENT_READER_DIAGNOSTIC':'AGENT_READER_FAILED'),'The configured official reader did not return a complete clean result.',{},503));
43
+ else resolve(Buffer.concat(chunks).toString('utf8'));
44
+ });
45
+ if(child.connected)child.send({executable:reader.executable,args,temporaryRoot,env},()=>{});
46
+ if(signal?.aborted)abort();
47
+ })}finally{if(cwd)await rm(cwd,{recursive:true,force:true})}
48
+ }
@@ -0,0 +1,27 @@
1
+ /** Live command-group anchor. Its separate reaper removes the cwd only after this group has ended. */
2
+ import {spawn} from 'node:child_process';
3
+ let started=false,reported=false;
4
+ const stopOwnedGroup=()=>process.kill(-process.pid,'SIGKILL');
5
+ function report(payload){
6
+ if(reported)return;reported=true;
7
+ if(process.connected){try{process.send(payload,error=>{if(error)stopOwnedGroup()})}catch{stopOwnedGroup()}}else stopOwnedGroup();
8
+ // Remain the live group owner until the reaper terminates us; never release a reusable PGID early.
9
+ }
10
+ process.once('disconnect',stopOwnedGroup);
11
+ process.once('SIGTERM',stopOwnedGroup);process.once('SIGINT',stopOwnedGroup);
12
+ process.once('uncaughtException',()=>report({error:'AGENT_READER_FAILED'}));
13
+ process.once('unhandledRejection',()=>report({error:'AGENT_READER_FAILED'}));
14
+ if(!process.connected)process.exit(1);
15
+ process.on('message',request=>{
16
+ if(started)return;started=true;
17
+ try{
18
+ const child=spawn(request.executable,request.args,{cwd:process.cwd(),env:process.env,detached:false,shell:false,stdio:['ignore','pipe','pipe']});
19
+ child.stdout.pipe(process.stdout,{end:false});child.stderr.pipe(process.stderr,{end:false});
20
+ child.once('error',error=>report({error:error.code==='EACCES'?'AGENT_READER_PERMISSION_DENIED':'AGENT_READER_UNAVAILABLE'}));
21
+ // An exited leader may leave descendants holding its pipes. Keep ownership until EOF or reaper cancellation.
22
+ child.once('close',code=>{
23
+ let flushed=0;const complete=()=>{if(++flushed===2)report({result:code})};
24
+ process.stdout.write('',complete);process.stderr.write('',complete);
25
+ });
26
+ }catch{report({error:'AGENT_READER_UNAVAILABLE'})}
27
+ });
@@ -0,0 +1,26 @@
1
+ import {AppHostError,requireCondition} from '../errors.js';
2
+ export function installationStatusCors(req,allowedOrigins){
3
+ const origin=req.headers.origin;
4
+ requireCondition(typeof origin==='string'&&allowedOrigins.includes(origin),'ORIGIN_DENIED','This origin is not allowed to read installation status.',{},403);
5
+ return{'access-control-allow-origin':origin,vary:'Origin','access-control-allow-private-network':'true'};
6
+ }
7
+ /** Called only after the owning listener's exact loopback Host and Origin checks. */
8
+ export async function serveInstallationStatus(req,res,{read,maxBodyBytes,traceId,cors,send}){
9
+ requireCondition(req.url?.startsWith('/')&&!req.url.startsWith('//')&&!req.url.includes('\\'),'BAD_TARGET','Invalid request target.');
10
+ requireCondition(req.headers['sec-fetch-dest']!=='iframe','FRAME_CONTROL_DENIED','An application frame cannot inspect the host.',{},403);
11
+ requireCondition(req.headers.cookie===undefined&&req.headers.authorization===undefined,'CREDENTIALS_NOT_ACCEPTED','Installation reads require credentials: omit.',{},403);
12
+ requireCondition(!new URL(req.url,'http://127.0.0.1').search,'UNKNOWN_FIELDS','Installation status takes no query.');
13
+ if(req.method==='OPTIONS'){send(res,204,undefined,{...cors,'access-control-allow-methods':'POST','access-control-allow-headers':'content-type'});return;}
14
+ requireCondition(req.method==='POST','METHOD_NOT_ALLOWED','Installation status requires POST.',{},405);
15
+ requireCondition(req.headers['content-type']?.split(';')[0].trim()==='application/json','CONTENT_TYPE_REQUIRED','Expected application/json.',{},415);
16
+ let total=0;const chunks=[];
17
+ for await(const chunk of req){total+=chunk.length;requireCondition(total<=maxBodyBytes,'BODY_TOO_LARGE','Installation status body is too large.',{},413);chunks.push(chunk)}
18
+ let body;try{body=JSON.parse(Buffer.concat(chunks).toString('utf8'))}catch{throw new AppHostError('INVALID_JSON','Malformed JSON.',{},400)}
19
+ requireCondition(body&&typeof body==='object'&&!Array.isArray(body)&&Object.keys(body).every(key=>['contractVersion','consent'].includes(key)),'UNKNOWN_FIELDS','Only contractVersion and consent are accepted.');
20
+ requireCondition(body.contractVersion===undefined||body.contractVersion==='1','INSTALLATION_STATUS_CONTRACT_UNSUPPORTED','Installation status contractVersion 1 is required.');
21
+ requireCondition(Object.keys(body).length===2&&body.contractVersion==='1'&&body.consent==='read-installations','INSTALLATION_STATUS_CONSENT_REQUIRED','Explicit one-request read-installations consent is required.',{},403);
22
+ requireCondition(typeof read==='function','INSTALLATION_STATUS_UNAVAILABLE','The read-only installation bridge is not supplied.',{},503);
23
+ const controller=new AbortController();const abort=()=>{if(!res.writableEnded)controller.abort()};req.once('aborted',abort);res.once('close',abort);
24
+ try{if(req.aborted||res.destroyed)controller.abort();const value=await read({signal:controller.signal});if(!controller.signal.aborted)send(res,200,{...value,traceId},cors)}
25
+ finally{req.off('aborted',abort);res.off('close',abort)}
26
+ }
@@ -0,0 +1,91 @@
1
+ /** Read fresh public installation facts. No compatibility calculation, persistence or polling. */
2
+ import semver from 'semver';
3
+ import {AppHostError} from '../errors.js';
4
+ import {createCoreSuiteAdapter,SUITE_PACKAGES} from './suite.js';
5
+ import {SUPPORTED_READER_VERSIONS,validReader,runOfficialInstallationCommand} from './installation-cli.js';
6
+ const object=x=>x!==null&&typeof x==='object'&&!Array.isArray(x);
7
+ const exact=x=>typeof x==='string'&&semver.valid(x)===x?x:null;
8
+ const reason=(code,message,action)=>({code,message,action});
9
+ const interrupted=()=>new AppHostError('INSTALLATION_STATUS_CANCELLED','Installation read was cancelled.',{},499);
10
+ function abortable(work,signal){
11
+ if(signal.aborted)return Promise.reject(interrupted());
12
+ return new Promise((resolve,reject)=>{const abort=()=>reject(interrupted());signal.addEventListener('abort',abort,{once:true});Promise.resolve(work).then(resolve,reject).finally(()=>signal.removeEventListener('abort',abort))});
13
+ }
14
+ const base=(channel,id,kind,label)=>({channel,scope:{id,kind,label},status:'unknown',version:null,versionKind:['dsh','hanamesh'].includes(channel)?'core':'agent-distribution',components:[],identity:null,reason:null});
15
+ const unknown=(row,code,message,action)=>({...row,status:'unknown',version:null,reason:reason(code,message,action)});
16
+ const agentBase=channel=>base(channel,'agent-cli','official-user-profile',channel==='claude'?'Claude Code 用户级插件(不覆盖项目或其他隔离资料)':'Codex CLI 本地市场用户资料(不代表桌面 App、项目或其他隔离资料)');
17
+ const inventoryError=()=>{throw new AppHostError('AGENT_INVENTORY_INVALID','Official inventory shape is unsupported.',{},503)};
18
+ const qualified=x=>typeof x==='string'&&/^[a-z0-9][a-z0-9._-]*@[a-z0-9][a-z0-9._-]*$/.test(x)&&x.length<=257;
19
+ function parseInventory(channel,plugins,marketplaces,target){
20
+ let rows,markets;
21
+ if(channel==='claude'){
22
+ if(!Array.isArray(plugins)||!Array.isArray(marketplaces))inventoryError();rows=plugins;markets=marketplaces;
23
+ if(!rows.every(r=>object(r)&&qualified(r.id)&&['user','project','local','managed','session','synced'].includes(r.scope)&&typeof r.enabled==='boolean'&&typeof r.version==='string'&&typeof r.installPath==='string'&&(r.errors===undefined||Array.isArray(r.errors))&&(r.notes===undefined||Array.isArray(r.notes))))inventoryError();
24
+ }else{
25
+ if(!object(plugins)||!Array.isArray(plugins.installed)||!Array.isArray(plugins.available)||!object(marketplaces)||!Array.isArray(marketplaces.marketplaces))inventoryError();rows=plugins.installed;markets=marketplaces.marketplaces;
26
+ if(!rows.every(r=>object(r)&&qualified(r.pluginId)&&r.pluginId===`${r.name}@${r.marketplaceName}`&&typeof r.installed==='boolean'&&typeof r.enabled==='boolean'))inventoryError();
27
+ }
28
+ if(!markets.every(r=>object(r)&&typeof r.name==='string'&&r.name.length>0&&r.name.length<=128))inventoryError();
29
+ const sourceRows=markets.filter(r=>r.name===target.marketplace);
30
+ if(sourceRows.length>1)throw new AppHostError('AGENT_INVENTORY_CONFLICT','Conflicting marketplace identities.',{},503);
31
+ if(sourceRows.length){const m=sourceRows[0],url=channel==='claude'?(['url','git'].includes(m.source)?m.url:null):(['git','url'].includes(m.marketplaceSource?.sourceType)?m.marketplaceSource.source:null);
32
+ if(url!==target.marketplaceUrl)throw new AppHostError('AGENT_MARKETPLACE_SOURCE_MISMATCH','The configured marketplace source is not the registered target.',{},503);
33
+ }
34
+ const id=`${target.pluginName}@${target.marketplace}`;
35
+ const matches=rows.filter(r=>(channel==='claude'?r.id:r.pluginId)===id);
36
+ const scoped=channel==='claude'?matches.filter(r=>r.scope==='user'):matches;
37
+ if(matches.length&&!scoped.length)throw new AppHostError('AGENT_SCOPE_UNOBSERVABLE','The target is outside the configured user scope.',{},503);
38
+ if(scoped.length>1)throw new AppHostError('AGENT_INVENTORY_CONFLICT','Conflicting target installations.',{},503);
39
+ if(!scoped.length)return{status:'not-installed',version:null,components:[],reason:null};
40
+ if(!sourceRows.length)throw new AppHostError('AGENT_MARKETPLACE_SOURCE_UNAVAILABLE','The target marketplace source is not observable.',{},503);
41
+ const installedSource=scoped[0].marketplaceSource;
42
+ if(channel==='codex'){
43
+ if(!installedSource||!['git','url'].includes(installedSource.sourceType)||typeof installedSource.source!=='string')throw new AppHostError('AGENT_MARKETPLACE_SOURCE_UNAVAILABLE','Installed marketplace source is not observable.',{},503);
44
+ if(installedSource.source!==target.marketplaceUrl)throw new AppHostError('AGENT_MARKETPLACE_SOURCE_MISMATCH','Installed marketplace source conflicts with the registered target.',{},503);
45
+ }
46
+ const row=scoped[0],version=exact(channel==='claude'&&row.readFromFolder!==undefined?row.folderVersion:row.version);
47
+ const failed=channel==='claude'?Boolean(row.errors?.length||row.notes?.length):!row.installed;
48
+ return{status:failed?'incomplete':'installed',version,components:[{name:target.pluginName,version,active:row.enabled}],
49
+ reason:failed?reason(channel==='claude'?'AGENT_PLUGIN_LOAD_FAILED':'AGENT_PLUGIN_PAYLOAD_MISSING','官方清单报告插件负载或加载问题。','在该官方客户端的插件详情中检查并修复。'):version===null?reason('AGENT_VERSION_UNAVAILABLE','已确认该作用域的安装记录,但实际版本未知。','在该官方客户端插件详情中核对实际版本。'):null};
50
+ }
51
+ const failureCodes=new Set(['AGENT_READER_UNAVAILABLE','AGENT_READER_PERMISSION_DENIED','AGENT_READER_PLATFORM_UNSUPPORTED','AGENT_READER_NODE_REQUIRED','AGENT_PROFILE_UNAVAILABLE','AGENT_READER_TIMEOUT','AGENT_READER_OUTPUT_LIMIT','AGENT_READER_CLEANUP_FAILED','AGENT_READER_DIAGNOSTIC','AGENT_READER_FAILED','AGENT_INVENTORY_INVALID','AGENT_INVENTORY_CONFLICT','AGENT_MARKETPLACE_SOURCE_MISMATCH','AGENT_MARKETPLACE_SOURCE_UNAVAILABLE','AGENT_SCOPE_UNOBSERVABLE']);
52
+ /** Trusted static configuration only; `core` is the optional public protocol-1 Cordis service accessor. */
53
+ export function createInstallationStatusReader({client='dsh',core=()=>undefined,readers={},workerNode,runCommand=runOfficialInstallationCommand}={}){
54
+ if(!['dsh','hanamesh'].includes(client))throw new TypeError('client must be dsh or hanamesh');
55
+ const settings=structuredClone(readers??{}),active=new Set();let closed=false;
56
+ const suite=createCoreSuiteAdapter({core});
57
+ async function runtime(signal){
58
+ const row=base(client,'current-runtime','responding-runtime',client==='dsh'?'当前响应的 DSH runtime':'当前响应的 HanaMesh 客户端 runtime');
59
+ try{const value=await abortable(suite.readFacts(),signal);
60
+ const components=value.components.map(c=>({name:c.name,version:exact(c.version),...(['satisfied','missing','incompatible','damaged','unreadable','inactive','failed','ambiguous'].includes(c.status)?{status:c.status}:{}),...(typeof c.active==='boolean'?{active:c.active}:{})}));
61
+ const blocked=value.blocked.map(r=>({code:/^CORE_[A-Z0-9_]{1,75}$/.test(r.code)?r.code:'CORE_SUITE_BLOCKED',...(SUITE_PACKAGES.some(c=>c.name===r.moduleName)?{moduleName:r.moduleName}:{})}));
62
+ return{...row,status:value.status,version:value.status==='unknown'?null:exact(value.version),components,blocked,
63
+ reason:value.status==='unknown'?reason('CORE_SUITE_DECLARATION_UNKNOWN','Core 尚不能确认本 runtime 的套件完整性。','查看 Core 设置中的具名阻断原因,补齐其公开事实后重新检测。'):value.status==='incomplete'?reason('CORE_SUITE_CURRENT_INCOMPLETE','Core 报告本 runtime 的套件不完整。','在本客户端按 Core 组件说明修复后重新检测。'):null};
64
+ }catch(error){if(signal.aborted)throw interrupted();const code=['CORE_SUITE_DECLARATION_MISSING','CORE_SUITE_DECLARATION_INVALID'].includes(error.code)?error.code:'CORE_SUITE_READ_FAILED';return unknown(row,code,code==='CORE_SUITE_DECLARATION_MISSING'?'本 runtime 未提供 Core 套件声明方法。':'本次 Core 套件声明读取失败。','启用支持 protocol 1 getSuiteDeclaration 的 Core,查看其设置并重新检测。')}
65
+ }
66
+ async function agent(channel,signal){
67
+ const initial=agentBase(channel),config=settings[channel];
68
+ if(config===undefined)return unknown(initial,'AGENT_READER_NOT_CONFIGURED','尚未配置该渠道的官方只读清单读取器。','在 AppHost library.installationReaders 中配置官方命令、用户资料、登记目标与读取预算后重新检测。');
69
+ if(!validReader(config))return unknown(initial,'AGENT_READER_CONFIG_INVALID','官方读取器缺少有效的显式配置。','核对 library.installationReaders 的绝对路径、登记插件名/市场来源和必填读取预算。');
70
+ const row={...initial,identity:{pluginName:config.pluginName,marketplace:config.marketplace}};
71
+ const invoke=args=>abortable(runCommand({channel,reader:config,args,signal,workerNode}),signal);
72
+ try{
73
+ const version=(await invoke(['--version'])).trim();
74
+ if(version!==SUPPORTED_READER_VERSIONS[channel])return unknown(row,'AGENT_READER_VERSION_UNSUPPORTED','该官方 CLI 版本尚未验证此读取协议。','使用提供方正文列明的支持版本,或先核实新版本官方清单协议。');
75
+ const suffix=channel==='codex'?['--disable','remote_plugin']:[];
76
+ const text=await invoke(['plugin','list','--json',...suffix]);
77
+ const sourceText=await invoke(['plugin','marketplace','list','--json',...suffix]);
78
+ let plugins,marketplaces;try{plugins=JSON.parse(text);marketplaces=JSON.parse(sourceText)}catch{inventoryError()}
79
+ return{...row,...parseInventory(channel,plugins,marketplaces,config)};
80
+ }catch(error){if(signal.aborted||error.code==='INSTALLATION_STATUS_CANCELLED')throw interrupted();const code=failureCodes.has(error.code)?error.code:'AGENT_READER_FAILED';return unknown(row,code,'本次官方清单读取未能提供完整且一致的安装事实。','在对应官方 CLI 的已配置用户资料中检查插件与市场清单、权限或诊断,修复后显式重检。')}
81
+ }
82
+ const read=async({signal}={})=>{
83
+ if(closed||signal?.aborted)throw interrupted();const controller=new AbortController();active.add(controller);const combined=signal?AbortSignal.any([signal,controller.signal]):controller.signal;
84
+ try{const current=await runtime(combined);const agents=await Promise.all(['claude','codex'].map(channel=>agent(channel,combined)));if(combined.aborted)throw interrupted();
85
+ return{schemaVersion:1,client,checkedAt:new Date().toISOString(),observations:[current,
86
+ unknown(base(client==='dsh'?'hanamesh':'dsh','other-runtime','unobserved-runtime','另一客户端 runtime(须由其自身监听回答)'),'OTHER_RUNTIME_NOT_OBSERVED','本监听未读取另一客户端的 runtime。','启动该客户端,并向其已广告的状态入口显式检测。'),...agents,
87
+ unknown(base('codex','desktop-app','desktop-app','Codex / ChatGPT 桌面 App(独立作用域)'),'CODEX_DESKTOP_SCOPE_UNOBSERVABLE','Codex CLI 清单不能证明桌面 App 的安装。','在桌面 App 的插件目录核对;需官方公开桌面状态协议后才可接入。')]};
88
+ }finally{active.delete(controller)}
89
+ };
90
+ read.close=()=>{closed=true;for(const controller of active)controller.abort();active.clear()};return read;
91
+ }
@@ -0,0 +1,44 @@
1
+ /** Detached reaper: it outlives the caller and owns the neutral directory, outside the command group. */
2
+ import {fork} from 'node:child_process';
3
+ import {mkdtempSync,realpathSync,rmSync} from 'node:fs';
4
+ import {join} from 'node:path';
5
+ let started=false,finishing=false,finalized=false,directory,anchor,anchorExited=false,payload;
6
+ function finalize(){
7
+ if(finalized)return;finalized=true;
8
+ // Normal owned shutdown reaches here after the live group was killed and its anchor closed.
9
+ // An external SIGKILL/native crash of an internal supervisor is outside automatic recovery.
10
+ if(directory){try{rmSync(directory,{recursive:true,force:true})}catch{payload={error:'AGENT_READER_CLEANUP_FAILED'}}}
11
+ let flushed=0;const complete=()=>{if(++flushed!==2)return;const exit=()=>process.exit(0);
12
+ if(process.connected){try{process.send(payload,exit)}catch{exit()}}else exit();
13
+ };
14
+ process.stdout.write('',complete);process.stderr.write('',complete);
15
+ }
16
+ function finish(value){
17
+ if(finishing)return;finishing=true;payload=value;
18
+ if(anchor&&!anchorExited){
19
+ // The IPC anchor deliberately stays alive after command completion. Kill only its still-owned group.
20
+ try{process.kill(-anchor.pid,'SIGKILL')}catch{payload={error:'AGENT_READER_FAILED'}}
21
+ }else if(!anchor)finalize();
22
+ }
23
+ process.once('disconnect',()=>finish({error:'INSTALLATION_STATUS_CANCELLED'}));
24
+ process.once('SIGTERM',()=>finish({error:'INSTALLATION_STATUS_CANCELLED'}));
25
+ process.once('SIGINT',()=>finish({error:'INSTALLATION_STATUS_CANCELLED'}));
26
+ process.once('uncaughtException',()=>finish({error:'AGENT_READER_FAILED'}));
27
+ process.once('unhandledRejection',()=>finish({error:'AGENT_READER_FAILED'}));
28
+ if(!process.connected)process.exit(1);
29
+ process.on('message',request=>{
30
+ if(started)return;started=true;
31
+ try{
32
+ if(!process.connected){finish({error:'INSTALLATION_STATUS_CANCELLED'});return}
33
+ directory=mkdtempSync(join(realpathSync(request.temporaryRoot),'hanamesh-installation-read-'));
34
+ process.send({directory},error=>{if(error)finish({error:'INSTALLATION_STATUS_CANCELLED'})});
35
+ const env={...request.env,XDG_CONFIG_HOME:join(directory,'config'),XDG_CACHE_HOME:join(directory,'cache'),XDG_STATE_HOME:join(directory,'state'),TMPDIR:directory,TMP:directory,TEMP:directory};
36
+ anchor=fork(new URL('./installation-command-worker.js',import.meta.url),[],{cwd:directory,env,execArgv:[],detached:true,stdio:['ignore','pipe','pipe','ipc']});
37
+ anchor.stdout.pipe(process.stdout,{end:false});anchor.stderr.pipe(process.stderr,{end:false});
38
+ anchor.on('message',value=>finish(value));
39
+ anchor.once('exit',()=>{anchorExited=true});
40
+ anchor.once('error',()=>{if(!finishing){finishing=true;payload={error:'AGENT_READER_UNAVAILABLE'}}});
41
+ anchor.once('close',()=>{if(!finishing){finishing=true;payload={error:'AGENT_READER_FAILED'}}finalize()});
42
+ anchor.send({executable:request.executable,args:request.args},error=>{if(error)finish({error:'AGENT_READER_UNAVAILABLE'})});
43
+ }catch{finish({error:'AGENT_READER_UNAVAILABLE'})}
44
+ });
@@ -2,22 +2,31 @@
2
2
  * Library locations on plain DSH (PRD §A.8 official-distribution path, rc.26).
3
3
  *
4
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.
5
+ * Historical desktop shell fills `library.profileDir/profileName/dshBin` and `nodeBinary` through its overlay.
6
6
  * A `dsh plugin add` user has no overlay, so without these the installed-app scan returned `[]` and
7
7
  * every install/uninstall answered `LIBRARY_INSTALL_UNAVAILABLE`. Each value below is inferred ONLY
8
8
  * when the explicit config leaves it undefined, and only from facts this process can prove about
9
9
  * itself: where its own package really lives, which script started it, and which Node runs it.
10
10
  */
11
- import { readFile, realpath } from 'node:fs/promises';
11
+ import { readFile, realpath, access } from 'node:fs/promises';
12
+ import { constants } from 'node:fs';
13
+ import { execFile } from 'node:child_process';
14
+ import { promisify } from 'node:util';
15
+ import { createRequire } from 'node:module';
12
16
  import { basename, dirname, isAbsolute, join, sep } from 'node:path';
13
17
  import { fileURLToPath } from 'node:url';
14
18
 
15
19
  export const PACKAGE_NAME='@hanamesh/dsh-app-host';
16
20
  /** 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})]);
21
+ export const DEFAULT_CATALOG_SOURCES=Object.freeze([Object.freeze({manifestUrl:'https://market.hanamesh.com/catalog-source.json?view=hanamesh',enabled:true})]);
18
22
  const DSH_BIN_SUFFIX='/@deepseek-ai/dsh/lib/bin.js';
19
23
 
20
24
  async function json(path){try{return JSON.parse(await readFile(path,'utf8'));}catch{return undefined;}}
25
+ /** The public package manifest resolved by the host's installation/profile anchor. No service flags are inferred. */
26
+ export async function inferPluginManagerVersion(anchor){
27
+ if(typeof anchor!=='string'||!isAbsolute(anchor))return undefined;
28
+ try{const pkg=await json(createRequire(anchor).resolve('@deepseek-ai/dsh-plugin-manager/package.json'));return pkg?.name==='@deepseek-ai/dsh-plugin-manager'?pkg.version:undefined;}catch{return undefined;}
29
+ }
21
30
  /** A DSH profile manifest that carries HanaMesh: `dsh.profile` (written by `dsh plugin`) or a hanamesh dependency. */
22
31
  export function isHanameshProfileManifest(manifest){
23
32
  if(!manifest||typeof manifest!=='object'||Array.isArray(manifest))return false;
@@ -51,6 +60,56 @@ export async function inferProfileDir(moduleUrl){
51
60
  }
52
61
  }
53
62
 
63
+ /** Proven upstream Desktop launch layout, from desktop/src/host-process.ts and desktop-host/src/index.ts.
64
+ * The shell passes runtimeDir and profileDir to its named entry; prove the entry and this package's
65
+ * profile link before using them. Never infer from cwd, HOME, a port, or a supply overlay.
66
+ */
67
+ export async function inferDesktopLayout({moduleUrl=import.meta.url,argv=process.argv}={}) {
68
+ const [entry,runtimeDir,profileDir]=[argv?.[1],argv?.[2],argv?.[3]];
69
+ if (![entry,runtimeDir,profileDir].every(value=>typeof value==='string'&&isAbsolute(value))) return undefined;
70
+ const hostRoot=join(runtimeDir,'node_modules/@deepseek-ai/dsh-desktop-host'),hostManifest=await json(join(hostRoot,'package.json'));
71
+ if (entry!==join(hostRoot,'lib/index.js') || hostManifest?.name!=='@deepseek-ai/dsh-desktop-host') return undefined;
72
+ const root=await packageRoot(moduleUrl);
73
+ const linked=await realpath(join(profileDir,'node_modules',...PACKAGE_NAME.split('/'))).catch(()=>undefined);
74
+ if (!root || root!==linked || basename(profileDir)!=='desktop' || !isHanameshProfileManifest(await json(join(profileDir,'package.json')))) return undefined;
75
+ const dshRoot=join(runtimeDir,'node_modules/@deepseek-ai/dsh');
76
+ const dshManifest=await json(join(dshRoot,'package.json'));
77
+ if (dshManifest?.name!=='@deepseek-ai/dsh') return undefined;
78
+ const dshBin=await realpath(join(dshRoot,'lib/bin.js')).catch(()=>undefined);
79
+ if (!dshBin || !await realpath(entry).catch(()=>undefined)) return undefined;
80
+ const payload=argv[4]??join(dirname(runtimeDir),'runtime/primary-runtime');
81
+ const cli=join(hostRoot,'lib/cli.js');
82
+ const payloadNamed=typeof payload==='string'&&isAbsolute(payload)&&basename(payload)==='primary-runtime';
83
+ const desktopCli=payloadNamed&&!runtimeDir.split(sep).some(segment=>segment.endsWith('.asar'))&&await realpath(cli).catch(()=>undefined)
84
+ ? {entry:cli,runtimeDir,supportDir:dirname(payload)} : undefined;
85
+ let nodeBinary;
86
+ const metadata=payloadNamed?await json(join(payload,'runtime.json')):undefined;
87
+ const matchingRuntime=typeof hostManifest.version==='string'&&metadata?.platform===process.platform&&metadata?.arch===process.arch&&metadata.desktopVersion===hostManifest.version&&hostManifest.version===dshManifest.version;
88
+ // Packaged Desktop supplies a public launcher outside ASAR. Invoke that launcher unchanged;
89
+ // standalone Node cannot import the archived CLI, and the launcher owns Electron's boot details.
90
+ let desktopLauncher;
91
+ const resources=dirname(dirname(runtimeDir));
92
+ if(matchingRuntime&&runtimeDir===join(resources,'app.asar','dsh')&&payload===join(resources,'runtime','primary-runtime')&&basename(dirname(profileDir))==='profiles'){
93
+ // Upstream ships a POSIX launcher on macOS. Windows' .cmd needs a separately verified carrier;
94
+ // Linux has no official packaged Desktop launcher. Both stay explicitly unavailable here.
95
+ if(process.platform==='darwin')try{const command=join(resources,'runtime','cli','bin','dsh');await access(command,constants.X_OK);desktopLauncher={command:await realpath(command),home:await realpath(dirname(dirname(profileDir)))};}catch{}
96
+ }
97
+ const managerManifest=await json(join(runtimeDir,'node_modules/@deepseek-ai/dsh-plugin-manager/package.json'));
98
+ const pluginManagerVersion=managerManifest?.name==='@deepseek-ai/dsh-plugin-manager'&&managerManifest.version===hostManifest.version?managerManifest.version:undefined;
99
+ // Current canonical runtime manifest only. No legacy descriptor inference or PATH search.
100
+ if(metadata?.platform===process.platform&&metadata?.arch===process.arch&&
101
+ metadata.desktopVersion===(await json(join(hostRoot,'package.json')))?.version&&/^24\.\d+\.\d+$/.test(metadata.node??'')) {
102
+ const node=join(payload,'dependencies/node/bin',process.platform==='win32'?'node.exe':'node');
103
+ try {
104
+ await access(node,constants.X_OK);
105
+ const {stdout}=await promisify(execFile)(node,['-p','JSON.stringify({node:process.versions.node,electron:process.versions.electron})'],{timeout:1000,env:{},maxBuffer:1024});
106
+ const actual=JSON.parse(stdout);
107
+ if(actual.node===metadata.node&&actual.electron===undefined)nodeBinary=await realpath(node);
108
+ } catch { /* Unavailable/unproven payload Node stays unavailable; never use Electron. */ }
109
+ }
110
+ return {profileDir:await realpath(profileDir),profileName:'desktop',dshBin,parentOrigin:'dsh-app://app',frameAncestors:[],desktopCli,desktopLauncher,pluginManagerVersion,nodeBinary,runtimeDir};
111
+ }
112
+
54
113
  /** The DSH CLI that started this process — `argv[1]` only when it IS `@deepseek-ai/dsh/lib/bin.js`. */
55
114
  export function inferDshBin(argv=process.argv){
56
115
  const candidate=argv?.[1];
@@ -68,12 +127,14 @@ export function inferNodeBinary({execPath=process.execPath,versions=process.vers
68
127
  * Explicit config always wins; every inferred key is named in `inferred` so the caller can log it.
69
128
  * `sources`: undefined → DEFAULT_CATALOG_SOURCES; an explicit array (including `[]`) is kept as is.
70
129
  */
71
- export async function resolveLibraryLocations(config={},{moduleUrl=import.meta.url,argv=process.argv,execPath=process.execPath,versions=process.versions,nodeBinary}={}){
130
+ export async function resolveLibraryLocations(config={},{moduleUrl=import.meta.url,argv=process.argv,execPath=process.execPath,versions=process.versions,nodeBinary,desktop}={}){
131
+ desktop ??= await inferDesktopLayout({moduleUrl,argv});
72
132
  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');}
133
+ let profileDir=config.profileDir;if(profileDir===undefined){profileDir=desktop?.profileDir??await inferProfileDir(moduleUrl);if(profileDir)inferred.push('profileDir');}
134
+ let profileName=config.profileName;if(profileName===undefined&&profileDir){profileName=desktop?.profileDir===profileDir?desktop.profileName:basename(profileDir);inferred.push('profileName');}
135
+ let dshBin=config.dshBin;if(dshBin===undefined){dshBin=desktop?.dshBin??inferDshBin(argv);if(dshBin)inferred.push('dshBin');}
136
+ let node=nodeBinary;if(node===undefined){node=desktop?.nodeBinary??inferNodeBinary({execPath,versions});if(node)inferred.push('nodeBinary');}
77
137
  let sources=config.sources;if(sources===undefined){sources=structuredClone(DEFAULT_CATALOG_SOURCES);inferred.push('sources');}
78
- return{profileDir,profileName,dshBin,nodeBinary:node,sources,inferred};
138
+ const pluginManagerVersion=desktop?desktop.pluginManagerVersion:await inferPluginManagerVersion(inferDshBin(argv)??(profileDir?join(profileDir,'package.json'):undefined));
139
+ return{profileDir,profileName,dshBin,nodeBinary:node,sources,inferred,...(pluginManagerVersion?{pluginManagerVersion}:{}),...(desktop&&desktop.profileDir===profileDir&&config.dshBin===undefined?{desktopCli:desktop.desktopCli,desktopLauncher:desktop.desktopLauncher}:{})};
79
140
  }