@hanamesh/dsh-app-host 0.6.20 → 0.6.22

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.
@@ -0,0 +1,42 @@
1
+ import { requireCondition } from '../errors.js';
2
+ import semver from 'semver';
3
+
4
+ const npmName=value=>typeof value==='string'&&value.length<=214&&/^(?:@[a-z0-9][a-z0-9._-]*\/)?[a-z0-9][a-z0-9._-]*$/.test(value);
5
+ const own=(value,keys)=>value&&typeof value==='object'&&!Array.isArray(value)&&Object.keys(value).every(key=>keys.includes(key));
6
+
7
+ /** Encode a reviewed immutable target for the official DSH CLI's native pnpm Git path. */
8
+ export function pinnedGithubSpec(repository,commit,subdirectory){
9
+ requireCondition(typeof repository==='string'&&/^[A-Za-z0-9](?:[A-Za-z0-9-]*[A-Za-z0-9])?\/[A-Za-z0-9_][A-Za-z0-9._-]*$/.test(repository),
10
+ 'INVALID_INPUT','GitHub installation requires an owner/repository identity.');
11
+ requireCondition(typeof commit==='string'&&/^[0-9a-f]{40}$/i.test(commit),'INVALID_INPUT','GitHub installation requires a full commit SHA.');
12
+ requireCondition(subdirectory===undefined||subdirectory===null||(typeof subdirectory==='string'&&subdirectory.length>0&&
13
+ subdirectory.split('/').every(part=>part!=='.'&&part!=='..'&&/^[A-Za-z0-9_@+.-]+$/.test(part))),
14
+ 'INVALID_INPUT','GitHub installation requires a relative repository subdirectory.');
15
+ return `github:${repository}#${commit.toLowerCase()}${subdirectory?'&path:'+subdirectory:''}`;
16
+ }
17
+
18
+ /** The Indexer's package target is a data selector, never an executable command. */
19
+ export function packageInstallTarget(value){
20
+ const github=value?.source==='github';
21
+ requireCondition(own(value,github?['source','repo','ref','path','packageName','allowBuild']:['source','name','version','allowBuild'])&&
22
+ (github||value.source==='npm')&&npmName(github?value.packageName:value.name)&&
23
+ Array.isArray(value.allowBuild)&&value.allowBuild.every(entry=>typeof entry==='string'&&entry.length>0&&entry.length<=2048&&!/[\s\u0000-\u001f\u007f-\u009f]/u.test(entry)),
24
+ 'INVALID_INPUT','Invalid catalog package installation target.');
25
+ if(github){
26
+ requireCondition(value.path===null||typeof value.path==='string','INVALID_INPUT','GitHub path must be a relative path or null.');
27
+ pinnedGithubSpec(value.repo,value.ref,value.path);
28
+ return{source:'github',repo:value.repo,ref:value.ref.toLowerCase(),path:value.path,packageName:value.packageName,allowBuild:[...value.allowBuild]};
29
+ }
30
+ requireCondition(typeof value.version==='string'&&value.version.length<=128&&semver.valid(value.version)===value.version,
31
+ 'INVALID_INPUT','npm target version must be the exact version fixed at collection.');
32
+ return{source:'npm',name:value.name,version:value.version,allowBuild:[...value.allowBuild]};
33
+ }
34
+
35
+ export const installTargetName=target=>target.source==='github'?target.packageName:target.name;
36
+ export const sameInstallTarget=(left,right)=>JSON.stringify(packageInstallTarget(left))===JSON.stringify(packageInstallTarget(right));
37
+ export const catalogInstallationAllowed=item=>Array.isArray(item?.installMethods)&&item?.oneClickInstall?.allowed===true;
38
+ export function catalogPackageTargets(item){
39
+ if(!catalogInstallationAllowed(item))return[];
40
+ return (item.installMethods??[]).filter(method=>['hanamesh','dsh'].includes(method.channel)&&method.target)
41
+ .map(method=>packageInstallTarget(method.target));
42
+ }
@@ -14,6 +14,7 @@ import { installationStatusCors, serveInstallationStatus } from './installation-
14
14
  */
15
15
  import { createServer } from 'node:http';
16
16
  import { randomUUID } from 'node:crypto';
17
+ import {sameInstallTarget} from './github-target.js';
17
18
  import { AppHostError, requireCondition } from '../errors.js';
18
19
 
19
20
  export const HANDOFF_STATES = Object.freeze(['pending', 'shown', 'confirmed', 'installed', 'cancelled', 'failed']);
@@ -35,7 +36,7 @@ export const HANDOFF_RULES = Object.freeze([
35
36
  { rule: '只有「允许交接的网站」里的网站能探测本客户端、交接安装、读交接状态;别的网站什么也读不到', why: '否则任何网页都能知道你装了什么客户端、缺什么,用来识别你' },
36
37
  { rule: '网站交接只会在应用库里弹出确认;只有你点「确认安装」才会安装', why: '网站不能替你决定装什么' },
37
38
  { rule: '交接状态只给发起它的那个网站看', why: '一个网站不能读另一个网站的交接' },
38
- { rule: `交接请求体不超过 ${HANDOFF_BODY_MAX_BYTES} 字节`, why: '合法请求只有插件 id、包名、合约版本三项,最长不到 500 字节;更大的一定不是交接' },
39
+ { rule: `交接请求体不超过 ${HANDOFF_BODY_MAX_BYTES} 字节`, why: '交接只包含目录身份、合约版本及已审核的结构化安装目标;正文上限为 4096 字节,不接受来源命令' },
39
40
  { rule: '端口在 1024–65535 之间、互不重复,至少一个', why: '1024 以下要管理员权限;没有端口就没有地方让网站找到' },
40
41
  { rule: '网站必须写成来源(协议 + 域名 [+ 端口]),http 只允许 127.0.0.1 / localhost', why: '浏览器按来源校验;明文 http 的公网网站可被中间人冒充' },
41
42
  { rule: `轮询间隔是 1–${TIMER_MAX_MS} 毫秒的整数,作废时间必须大于轮询间隔`, why: '计时器超过上限会立即触发;作废早于第一次轮询,交接永远不会被看到' },
@@ -75,6 +76,7 @@ export function createHandoffQueue({ ttlMs = () => HANDOFF_DEFAULTS.ttlMs, now =
75
76
  const publish = record => { for (const listener of listeners) { try { listener(view(record)); } catch (error) { onError('hanamesh-app-host handoff: subscriber failed', error); } } };
76
77
  const view = record => ({ handoffId: record.handoffId, state: record.state, origin: record.origin,
77
78
  ...(record.itemId ? { itemId: record.itemId } : {}), ...(record.packageName ? { packageName: record.packageName } : {}),
79
+ ...(record.target.installTarget ? {installTarget:structuredClone(record.target.installTarget)} : {}),
78
80
  ...(record.displayName ? { displayName: record.displayName } : {}), ...(record.result ? { result: record.result } : {}),
79
81
  ...(record.error ? { error: record.error } : {}), createdAt: record.createdAt, updatedAt: record.updatedAt });
80
82
  const move = (record, state, extra = {}) => {
@@ -97,7 +99,7 @@ export function createHandoffQueue({ ttlMs = () => HANDOFF_DEFAULTS.ttlMs, now =
97
99
  return Object.freeze({
98
100
  create({ origin, target, item, alreadyInstalled }) {
99
101
  expire();
100
- const at = now(), record = { handoffId: randomUUID(), state: 'pending', origin, target,
102
+ const at = now(), record = { handoffId: randomUUID(), state: 'pending', origin, target:structuredClone({...target,...(item.installTarget?{installTarget:item.installTarget}:{})}),
101
103
  itemId: item.id, packageName: item.package?.name, displayName: item.displayName, createdAt: at, updatedAt: at };
102
104
  records.set(record.handoffId, record);
103
105
  if (alreadyInstalled) move(record, 'installed', { result: { status: 'already-installed' } }); else publish(record);
@@ -132,6 +134,7 @@ export function createHandoffQueue({ ttlMs = () => HANDOFF_DEFAULTS.ttlMs, now =
132
134
  const name = event?.packageName ?? event?.result?.packageName; if (!name) return;
133
135
  for (const record of records.values()) {
134
136
  if (record.packageName !== name || !['shown', 'confirmed'].includes(record.state)) continue;
137
+ if(record.target.installTarget&&(!event.installTarget&&!event.result?.installTarget||!sameInstallTarget(record.target.installTarget,event.installTarget??event.result?.installTarget)))continue;
135
138
  if (event.type === 'library.install-started') move(record, 'confirmed');
136
139
  else if (event.type === 'library.install-done') move(record, 'installed', { result: { status: event.status ?? event.result?.status } });
137
140
  else if (event.type === 'library.install-failed') move(record, 'failed', { error: { code: event.code ?? 'INSTALL_FAILED', message: '安装失败' } });
@@ -8,15 +8,15 @@ import { redact } from '../runtime.js';
8
8
  import { AppHostError, requireCondition, SerialQueue } from '../errors.js';
9
9
  import {noteFailurePhase} from './diagnostics.js';
10
10
  import { validateDefinition } from '../descriptor.js';
11
- import { itemKind } from './catalog.js';
11
+ import { isApplication } from './catalog.js';
12
12
  import { profileSnapshot, runtimeSnapshot, profileChecks, runtimeChecks, packageRestoreArgs, keepOriginals } from './recovery.js';
13
13
  import {SUITE_BUNDLE,prepareSuiteUpgrade,readSuiteStatus,createCoreSuiteAdapter} from './suite.js';
14
+ import {packageInstallTarget,installTargetName,pinnedGithubSpec} from './github-target.js';
14
15
 
15
16
  /** Suite packages are changed only by the confirmed suite upgrade command. */
16
17
  export const PROTECTED_PACKAGES=Object.freeze([SUITE_BUNDLE,'@hanamesh/core','@hanamesh/usage','@hanamesh/dsh-app-host','hanamesh-brand']);
17
18
  const blocked=new Set(PROTECTED_PACKAGES);
18
19
  const packageName=value=>typeof value==='string'&&/^(?:@[a-z0-9][a-z0-9._-]*\/)?[a-z0-9][a-z0-9._-]*$/.test(value);
19
- const versionText=value=>typeof value==='string'&&/^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/.test(value);
20
20
  async function json(path){return JSON.parse(await readFile(path,'utf8'));}
21
21
  const defaultSpawn=spawnGitSafe;
22
22
 
@@ -45,8 +45,8 @@ async function clearGeneratedRuntimeCache(result,runtimeRoot){
45
45
  }
46
46
 
47
47
  /** Package mutations use the public CLI in an owned monitored process; upstream's in-process manager has no scoped env/monitor port. */
48
- export function createLibraryInstaller({profileDir,profileName,dataRoot,nodeBinary,dshBin,desktopCli,desktopLauncher,pluginManagerVersion,registryRules,registry,allowPrerelease=false,
49
- fetchImpl=globalThis.fetch,spawn=defaultSpawn,provision,remove,ledger,coreSuitePlan,coreService=()=>undefined,suiteSourceMode=()=>'configured',suiteSourceService=()=>undefined,pluginManager=()=>undefined,emit=()=>{},profileWriter=new SerialQueue()}){
48
+ export function createLibraryInstaller({profileDir,profileName,dataRoot,nodeBinary,dshBin,desktopCli,desktopLauncher,pluginManagerVersion,registry,
49
+ spawn=defaultSpawn,provision,remove,ledger,coreSuitePlan,coreService=()=>undefined,suiteSourceMode=()=>'configured',suiteSourceService=()=>undefined,emit=()=>{},profileWriter=new SerialQueue()}){
50
50
  requireCondition(typeof profileDir==='string'&&resolve(profileDir)===profileDir,'PROFILE_DIR_REQUIRED','library.profileDir must be an absolute path.');
51
51
  requireCondition(typeof dataRoot==='string'&&resolve(dataRoot)===dataRoot,'INVALID_ROOT','dataRoot must be absolute.');
52
52
  const launcherAvailable=typeof desktopLauncher?.command==='string'&&resolve(desktopLauncher.command)===desktopLauncher.command&&typeof desktopLauncher.home==='string'&&join(desktopLauncher.home,'profiles',profileName)===profileDir;
@@ -65,58 +65,15 @@ export function createLibraryInstaller({profileDir,profileName,dataRoot,nodeBina
65
65
  const runtimeApi=async name=>injected[name]??(await defaults())[name];
66
66
  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)))};}
67
67
  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);
68
- async function run(args){try{requireCondition(cliAvailable,'DSH_CLI_UNAVAILABLE','The official dsh plugin CLI is unavailable in this host.',{need:['nodeBinary','dshBin']},503);
69
- 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];
68
+ const run=args=>runCli(['plugin','--profile',profileName,...args]);
69
+ async function runCli(tail){try{requireCondition(cliAvailable,'DSH_CLI_UNAVAILABLE','The official dsh plugin CLI is unavailable in this host.',{need:['nodeBinary','dshBin']},503);
70
+ 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)})};
70
71
  if(launcherAvailable)options.env=safeGitEnv({...options.env,DSH_HOME:desktopLauncher.home,PATH:commandPath(desktopLauncher.command)});
71
72
  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);
72
73
  const clean=text=>redact(stripVTControlCharacters(String(text??''))).replace(/(https?:\/\/)[^/\s@]+@/g,'$1[REDACTED]@').slice(-2000);
73
74
  const stdout=clean(result.stdout),stderr=clean(result.stderr);
74
75
  requireCondition(result.code===0,'DSH_PLUGIN_FAILED','DSH plugin operation failed.',{code:result.code,signal:result.signal,stderr,diagnostic:[stdout,stderr].filter(Boolean).join('\n')||`DSH plugin exited with code ${result.code}${result.signal?' ('+result.signal+')':''}.`},502);return result;
75
76
  }catch(error){noteFailurePhase(error,'CLI run');throw error;}}
76
- /** Bound the entire pre-install lookup, including public source discovery and metadata readback. */
77
- async function latest(name){
78
- const controller=new AbortController();let timer,phase='registry configuration';
79
- const timeout=new Promise((_,reject)=>{timer=setTimeout(()=>{
80
- const error=new AppHostError('REGISTRY_LOOKUP_TIMEOUT','Registry lookup timed out after 30000ms. Check DSH registry settings and network, then retry explicitly.',{},504);
81
- noteFailurePhase(error,phase);controller.abort(error);reject(error);
82
- },30000);});
83
- async function lookup(){
84
- const manager=pluginManager();
85
- requireCondition(typeof manager?.registries==='function','REGISTRY_CONFIG_UNAVAILABLE','DSH does not expose registry configuration. Restore the public plugin manager before installing.',{},503);
86
- // Public DSH ports own ordering, normalization, de-duplication and private-registry isolation.
87
- const ruleMessage='DSH does not expose its registry rules. Restore the public plugin manager before installing.';
88
- requireCondition(typeof registryRules==='function','REGISTRY_CONFIG_UNAVAILABLE',ruleMessage,{},503);
89
- let rules;try{rules=await registryRules();}catch(cause){const error=new AppHostError('REGISTRY_CONFIG_UNAVAILABLE',ruleMessage,{},503);error.cause=cause;throw error;}
90
- const {registryPlan,attributeFailure}=rules??{};
91
- requireCondition(typeof registryPlan==='function'&&typeof attributeFailure==='function','REGISTRY_CONFIG_UNAVAILABLE',ruleMessage,{},503);
92
- controller.signal.throwIfAborted();const configured=await manager.registries();controller.signal.throwIfAborted();
93
- const plan=registryPlan(registry,configured);
94
- for(let index=0;index<plan.length;index++){
95
- const selected=plan[index],source=selected??configured.resolved;let failureKind='unknown';
96
- controller.signal.throwIfAborted();phase='registry configuration';
97
- requireCondition(typeof source==='string','REGISTRY_CONFIG_UNAVAILABLE','DSH could not resolve its configured registry. Check DSH registry settings.',{},503);
98
- const base=new URL(source);requireCondition(['https:','http:'].includes(base.protocol),'REGISTRY_DENIED','Registry URL is invalid.');
99
- try{
100
- phase='registry fetch';let response;
101
- try{response=await fetchImpl(new URL(`${base.href.replace(/\/$/,'')}/${encodeURIComponent(name)}/latest`),{headers:{accept:'application/json','accept-encoding':'identity'},signal:controller.signal});}
102
- catch(error){failureKind='network';throw error;}
103
- controller.signal.throwIfAborted();if(!response.ok){failureKind=response.status===404?'not-found':response.status>=500?'network':'unknown';throw new AppHostError('REGISTRY_LOOKUP_FAILED','Registry latest lookup failed. Check DSH registry settings and network, then retry explicitly.',{status:response.status},502);}
104
- phase='registry readback';const metadata=await response.json();controller.signal.throwIfAborted();const version=metadata.version;
105
- requireCondition(versionText(version)&&(allowPrerelease||!version.includes('-')),'UNSTABLE_VERSION','Registry latest must be an exact stable version.');
106
- return{version,registry:selected};
107
- }catch(error){
108
- // Ask only fallbacks configured by DSH, and only when its public rule permits another source.
109
- if(!controller.signal.aborted&&index+1<plan.length&&attributeFailure(failureKind,'',{kind:'registry',name,spec:name})==='registry')continue;
110
- throw error;
111
- }
112
- }
113
- }
114
- try{return await Promise.race([lookup(),timeout]);}
115
- catch(cause){
116
- const error=cause instanceof AppHostError?cause:new AppHostError('REGISTRY_LOOKUP_FAILED','Registry lookup failed. Check DSH registry settings and network, then retry explicitly.',{},502);
117
- if(error!==cause)error.cause=cause;noteFailurePhase(error,phase);throw error;
118
- }finally{clearTimeout(timer);}
119
- }
120
77
  /**
121
78
  * rc.10 compensation after a failed install: the package goes back to its baseline spec through the official CLI, runtime
122
79
  * items this operation newly claimed are removed by ownership, a replaced baseline item is provisioned again from the
@@ -160,26 +117,33 @@ export function createLibraryInstaller({profileDir,profileName,dataRoot,nodeBina
160
117
  const name=item.package.name,operationId=/^[A-Za-z0-9-]{1,64}$/.test(context?.operationId??'')?context.operationId:randomUUID();let stage='baseline',base,runtime,installRegistry=null;
161
118
  try{
162
119
  base=await profileSnapshot(profileDir,name);
163
- stage='before-add';let version;({version,registry:installRegistry}=await latest(name));
120
+ stage='before-add';let version;const target=item.installTarget,github=target?.source==='github';
121
+ if(target.source==='npm'){version=target.version;installRegistry=registry??null;}
122
+ const spec=github?pinnedGithubSpec(target.repo,target.ref,target.path):`${name}@${version}`;
164
123
  const status='restart-required';
165
- stage='add';await run(['add','--save-exact',...(installRegistry===null?[]:[`--registry=${installRegistry}`]),`${name}@${version}`]);
124
+ stage='add';await run(['add','--save-exact',...(installRegistry===null?[]:[`--registry=${installRegistry}`]),...(target?.allowBuild??[]).map(entry=>`--allow-build=${entry}`),spec]);
166
125
  stage='readback';const added=await profileSnapshot(profileDir,name);
167
- 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);
168
- if(kind==='plugin')return{status,kind,packageName:name,version};
126
+ if(github){
127
+ const actual=await json(join(profileDir,'node_modules',...name.split('/'),'package.json'));
128
+ requireCondition(added.spec===spec&&actual.name===name&&typeof actual.version==='string'&&typeof actual.dsh?.bundle?.patch==='string'&&actual.dsh.bundle.patch.length>0&&added.bundles.includes(name),
129
+ 'INSTALL_READBACK_MISMATCH','The profile does not resolve and enable the reviewed GitHub package.',{expected:spec,spec:added.spec,packageName:actual.name,active:added.bundles.includes(name)},502);
130
+ version=actual.version;
131
+ }else 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);
132
+ if(kind==='plugin')return{status,kind,packageName:name,version,...(target?{installTarget:target}:{}),...(github?{commit:target.ref}:{})};
169
133
  stage='definition';const record=await installed(name);const declared=record.definition.deployments.map(deployment=>deployment.runtime).find(Boolean);
170
134
  if(declared){stage='provision';const root=runtimeRoot(record.definition.id);runtime={root,base:await runtimeSnapshot(await runtimeApi('ledger'),root)};
171
135
  await (await runtimeApi('provision'))(declared.manifest,{root,only:[declared.item],onProgress:event=>emit({type:'library.provision-progress',appId:record.definition.id,...event})});}
172
- return{status:'restart-required',kind,appId:record.definition.id,packageName:name,version};
136
+ return{status:'restart-required',kind,appId:record.definition.id,packageName:name,version,...(target?{installTarget:target}:{}),...(github?{commit:target.ref}:{})};
173
137
  }catch(error){noteFailurePhase(error,{baseline:'profile baseline','before-add':'registry fetch',add:'CLI run',readback:'profile readback',definition:'application readback',provision:'runtime provision'}[stage]);error.recovery=await recover({name,stage,base,runtime,operationId,installRegistry});throw error;}
174
138
  }
175
139
  async function installAs(item,kind,context){
176
140
  permitted(item.package.name);const name=item.package.name;
177
- emit({type:'library.install-started',...(item.id?{itemId:item.id}:{}),packageName:name,kind});
141
+ emit({type:'library.install-started',...(item.id?{itemId:item.id}:{}),packageName:name,kind,...(item.installTarget?{installTarget:item.installTarget}:{})});
178
142
  try{const result=await profileWriter.run(()=>transaction(item,kind,context));emit({type:'library.install-done',...result});return result;}
179
- 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;}
143
+ catch(error){emit({type:'library.install-failed',...(item.id?{itemId:item.id}:{}),packageName:name,kind,...(item.installTarget?{installTarget:item.installTarget}:{}),code:error.code??'INSTALL_FAILED',...(error.recovery?{recovery:error.recovery}:{})});throw error;}
180
144
  }
181
145
  let suitePlan;
182
- const suiteProvider=coreSuitePlan??createCoreSuiteAdapter({core:coreService,registry:registry??'https://registry.npmjs.org',suiteSourceMode,suiteSourceService});
146
+ const suiteProvider=coreSuitePlan??createCoreSuiteAdapter({core:coreService,registry,profileName,suiteSourceMode,suiteSourceService});
183
147
  async function previewSuite(){
184
148
  const next=await prepareSuiteUpgrade({coreSuitePlan:suiteProvider});
185
149
  suitePlan={...next,planId:randomUUID()};return structuredClone(suitePlan);
@@ -195,7 +159,7 @@ export function createLibraryInstaller({profileDir,profileName,dataRoot,nodeBina
195
159
  requireCondition(suitePlan?.planId===planId,'SUITE_PLAN_CHANGED','升级确认已变化,请重新查看并确认。',{},409);
196
160
  const confirmed=suitePlan;suitePlan=undefined;
197
161
  const fresh=await prepareSuiteUpgrade({coreSuitePlan:suiteProvider});
198
- const identity=plan=>JSON.stringify({sourceBinding:plan.sourceBinding,bundle:plan.bundle,components:plan.components,declarationId:plan.declarationId,registry:plan.registry,selectedUpgrade:plan.selectedUpgrade,physicalBaseline:plan.physicalBaseline});
162
+ const identity=plan=>JSON.stringify({installation:plan.installation,sourceBinding:plan.sourceBinding,bundle:plan.bundle,components:plan.components,declarationId:plan.declarationId,registry:plan.registry,selectedUpgrade:plan.selectedUpgrade,physicalBaseline:plan.physicalBaseline});
199
163
  requireCondition(identity(fresh)===identity(confirmed),'SUITE_PLAN_CHANGED','套件声明或版本已变化,请重新查看并确认。',{},409);
200
164
  requireCondition(fresh.available,'SUITE_NO_UPDATE','当前没有兼容新版。',{},409);
201
165
  const before=await readSuiteStatus(profileDir);
@@ -205,7 +169,7 @@ export function createLibraryInstaller({profileDir,profileName,dataRoot,nodeBina
205
169
  requireCondition(identity(executing)===identity(confirmed),'SUITE_PLAN_CHANGED','执行前套件安装包来源或声明已变化,请重新查看并确认。',{},409);
206
170
  emit({type:'library.install-started',itemId:'hanamesh:suite-upgrade',packageName:SUITE_BUNDLE,kind:'suite-upgrade'});
207
171
  try{
208
- await run(['add','--save-exact','--strict-peer-dependencies',`--registry=${fresh.registry??registry??'https://registry.npmjs.org'}`,`${SUITE_BUNDLE}@${fresh.bundle.to}`]);
172
+ await runCli(fresh.installation.args);
209
173
  const actual=await readSuiteStatus(profileDir);
210
174
  const saved=await json(join(profileDir,'package.json'));
211
175
  requireCondition(actual.status==='installed'&&actual.version===fresh.bundle.to&&saved.dependencies?.[SUITE_BUNDLE]===fresh.bundle.to&&fresh.components.every((c,i)=>actual.components[i]?.version===c.to),'INSTALL_READBACK_MISMATCH','套件升级后的版本或启用状态不符,请完全退出后按补齐命令修复。',{},502);
@@ -216,12 +180,11 @@ export function createLibraryInstaller({profileDir,profileName,dataRoot,nodeBina
216
180
  }
217
181
  });},
218
182
  async install(item,context){
219
- const kind=itemKind(item);
220
- requireCondition(kind!=='listing','NOT_INSTALLABLE','Catalog item has no npm package to install.');
183
+ requireCondition(item?.installTarget,'NOT_INSTALLABLE','A fixed catalog installation target is required.',{reason:'fixed-install-target-required'});
184
+ const target=packageInstallTarget(item.installTarget);item={...item,installTarget:target,package:{registry:target.source,name:installTargetName(target)}};
185
+ const kind=isApplication(item)?'application':'plugin';
221
186
  return await installAs(item,kind,context);
222
187
  },
223
- /** rc.28: install a plugin by package name (already verified against the catalog by the service). */
224
- async installPlugin({packageName:name},context){return await installAs({package:{registry:'npm',name}},'plugin',context);},
225
188
  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};});},
226
189
  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};});},
227
190
  /** rc.28: `dsh plugin remove <name>`; the suite itself (PROTECTED_PACKAGES) is refused with PACKAGE_DENIED. */
@@ -28,18 +28,10 @@ async function pluginManagerFacts(anchor){
28
28
  try{
29
29
  const require=createRequire(anchor),manifestPath=require.resolve('@deepseek-ai/dsh-plugin-manager/package.json'),pkg=await json(manifestPath);
30
30
  if(pkg?.name!=='@deepseek-ai/dsh-plugin-manager'||typeof pkg.version!=='string')return {};
31
- let pluginManagerRegistry;
32
- try{const path=require.resolve('@deepseek-ai/dsh-plugin-manager/registry');if(path.startsWith(dirname(manifestPath)+sep))pluginManagerRegistry=path;}catch{}
33
- return{pluginManagerVersion:pkg.version,...(pluginManagerRegistry?{pluginManagerRegistry}:{})};
31
+ return{pluginManagerVersion:pkg.version};
34
32
  }catch{return {};}
35
33
  }
36
34
  export async function inferPluginManagerVersion(anchor){return(await pluginManagerFacts(anchor)).pluginManagerVersion;}
37
- async function desktopRegistryPath(root,pkg){
38
- const entry=pkg.exports?.['./registry'],target=typeof entry==='string'?entry:entry?.import??entry?.default;
39
- if(typeof target!=='string'||!target.startsWith('./'))return undefined;
40
- const path=resolve(root,target);if(!path.startsWith(root+sep))return undefined;
41
- try{await access(path);return path;}catch{return undefined;}
42
- }
43
35
  /** A DSH profile manifest that carries HanaMesh: `dsh.profile` (written by `dsh plugin`) or a hanamesh dependency. */
44
36
  export function isHanameshProfileManifest(manifest){
45
37
  if(!manifest||typeof manifest!=='object'||Array.isArray(manifest))return false;
@@ -109,7 +101,6 @@ export async function inferDesktopLayout({moduleUrl=import.meta.url,argv=process
109
101
  }
110
102
  const managerManifest=await json(join(runtimeDir,'node_modules/@deepseek-ai/dsh-plugin-manager/package.json'));
111
103
  const pluginManagerVersion=managerManifest?.name==='@deepseek-ai/dsh-plugin-manager'&&managerManifest.version===hostManifest.version?managerManifest.version:undefined;
112
- const pluginManagerRegistry=pluginManagerVersion?await desktopRegistryPath(join(runtimeDir,'node_modules/@deepseek-ai/dsh-plugin-manager'),managerManifest):undefined;
113
104
  // Current canonical runtime manifest only. No legacy descriptor inference or PATH search.
114
105
  if(metadata?.platform===process.platform&&metadata?.arch===process.arch&&
115
106
  metadata.desktopVersion===(await json(join(hostRoot,'package.json')))?.version&&/^24\.\d+\.\d+$/.test(metadata.node??'')) {
@@ -121,7 +112,7 @@ export async function inferDesktopLayout({moduleUrl=import.meta.url,argv=process
121
112
  if(actual.node===metadata.node&&actual.electron===undefined)nodeBinary=await realpath(node);
122
113
  } catch { /* Unavailable/unproven payload Node stays unavailable; never use Electron. */ }
123
114
  }
124
- return {profileDir:await realpath(profileDir),profileName:'desktop',dshBin,parentOrigin:'dsh-app://app',frameAncestors:[],desktopCli,desktopLauncher,pluginManagerVersion,pluginManagerRegistry,nodeBinary,runtimeDir};
115
+ return {profileDir:await realpath(profileDir),profileName:'desktop',dshBin,parentOrigin:'dsh-app://app',frameAncestors:[],desktopCli,desktopLauncher,pluginManagerVersion,nodeBinary,runtimeDir};
125
116
  }
126
117
 
127
118
  /** The DSH CLI that started this process — `argv[1]` only when it IS `@deepseek-ai/dsh/lib/bin.js`. */
@@ -149,6 +140,6 @@ export async function resolveLibraryLocations(config={},{moduleUrl=import.meta.u
149
140
  let dshBin=config.dshBin;if(dshBin===undefined){dshBin=desktop?.dshBin??inferDshBin(argv);if(dshBin)inferred.push('dshBin');}
150
141
  let node=nodeBinary;if(node===undefined){node=desktop?.nodeBinary??inferNodeBinary({execPath,versions});if(node)inferred.push('nodeBinary');}
151
142
  let sources=config.sources;if(sources===undefined){sources=structuredClone(DEFAULT_CATALOG_SOURCES);inferred.push('sources');}
152
- const {pluginManagerVersion,pluginManagerRegistry}=desktop??await pluginManagerFacts(inferDshBin(argv)??(profileDir?join(profileDir,'package.json'):undefined));
153
- return{profileDir,profileName,dshBin,nodeBinary:node,sources,inferred,...(pluginManagerVersion?{pluginManagerVersion}:{}),...(pluginManagerRegistry?{pluginManagerRegistry}:{}),...(desktop&&desktop.profileDir===profileDir&&config.dshBin===undefined?{desktopCli:desktop.desktopCli,desktopLauncher:desktop.desktopLauncher}:{})};
143
+ const {pluginManagerVersion}=desktop??await pluginManagerFacts(inferDshBin(argv)??(profileDir?join(profileDir,'package.json'):undefined));
144
+ return{profileDir,profileName,dshBin,nodeBinary:node,sources,inferred,...(pluginManagerVersion?{pluginManagerVersion}:{}),...(desktop&&desktop.profileDir===profileDir&&config.dshBin===undefined?{desktopCli:desktop.desktopCli,desktopLauncher:desktop.desktopLauncher}:{})};
154
145
  }
@@ -15,11 +15,11 @@ async function optional(path){try{return await readFile(path);}catch(error){if(e
15
15
 
16
16
  /** The profile facts one package operation can change: its direct spec, bundle selection, lockfile and resolved module. */
17
17
  export async function profileSnapshot(profileDir,name){
18
- const manifestBytes=await optional(join(profileDir,'package.json')),lockBytes=await optional(join(profileDir,'pnpm-lock.yaml'));
18
+ const manifestBytes=await optional(join(profileDir,'package.json')),lockBytes=await optional(join(profileDir,'pnpm-lock.yaml')),workspaceBytes=await optional(join(profileDir,'pnpm-workspace.yaml'));
19
19
  const manifest=manifestBytes===null?{}:JSON.parse(manifestBytes.toString('utf8'));
20
20
  const direct=Object.assign({},...DEPENDENCY_FIELDS.map(field=>manifest[field]??{}));
21
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)),
22
+ return{manifestBytes,lockBytes,workspaceBytes,buildPermissions:digest(workspaceBytes),spec:direct[name]??null,others:Object.fromEntries(Object.entries(direct).filter(([key])=>key!==name)),
23
23
  bundles:manifest.dsh?.profile?.bundles??[],lock:digest(lockBytes),module:moduleBytes===null?null:JSON.parse(moduleBytes.toString('utf8')).version??null};
24
24
  }
25
25
 
@@ -39,6 +39,7 @@ export function profileChecks(base,now,name){
39
39
  {item:'dependency',expected:base.spec,actual:now.spec},
40
40
  {item:'module',expected:base.module,actual:now.module},
41
41
  {item:'bundle',expected:base.bundles.includes(name),actual:now.bundles.includes(name)},
42
+ {item:'build-permissions',expected:base.buildPermissions,actual:now.buildPermissions},
42
43
  ].map(row=>({...row,ok:same(row.expected,row.actual)}));
43
44
  if(concurrent.length)rows.push({item:'lockfile',expected:base.lock,actual:now.lock,ok:false,reason:'CONCURRENT_PROFILE_WRITE',concurrent});
44
45
  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)});
@@ -51,7 +52,7 @@ export function runtimeChecks(base,now){
51
52
 
52
53
  /** The official CLI arguments that put one package back to its baseline spec, or none when nothing package-side changed. */
53
54
  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(profileChecks(base,now,name).every(row=>row.ok||row.reason==='CONCURRENT_PROFILE_WRITE'||row.item==='build-permissions'))return null;
55
56
  if(base.spec===null)return now.spec===null?['install']:['remove',name];
56
57
  return['add',...(EXACT.test(base.spec)?['--save-exact']:[]),`${name}@${base.spec}`];
57
58
  }
@@ -61,6 +62,7 @@ export async function keepOriginals(dir,base,runtimeBase){
61
62
  await mkdir(dir,{recursive:true,mode:0o700});const files=[];
62
63
  if(base.manifestBytes!==null){await writeFile(join(dir,'package.json'),base.manifestBytes,{mode:0o600});files.push('package.json');}
63
64
  if(base.lockBytes!==null){await writeFile(join(dir,'pnpm-lock.yaml'),base.lockBytes,{mode:0o600});files.push('pnpm-lock.yaml');}
65
+ if(base.workspaceBytes){await writeFile(join(dir,'pnpm-workspace.yaml'),base.workspaceBytes,{mode:0o600});files.push('pnpm-workspace.yaml');}
64
66
  if(runtimeBase){await writeFile(join(dir,'runtime-ledger.json'),JSON.stringify(runtimeBase,undefined,2)+'\n',{mode:0o600});files.push('runtime-ledger.json');}
65
67
  return{dir,files,sha256:{...(base.manifestBytes===null?{}:{'package.json':digest(base.manifestBytes)}),...(base.lockBytes===null?{}:{'pnpm-lock.yaml':digest(base.lockBytes)})}};
66
68
  }
@@ -52,6 +52,7 @@ export function createLibraryHttpHandler(library,{parentOrigin,transportOrigin,a
52
52
  requireCondition(subject&&typeof subject.principalId==='string','UNAUTHENTICATED','Host authentication is required.',{},401);
53
53
  if(readOnly)requireCondition(new Set(url.searchParams.keys()).size===[...url.searchParams.keys()].length,'INVALID_INPUT','Duplicate query fields are not accepted.');
54
54
  const input=readOnly?Object.fromEntries(url.searchParams):await jsonBody(req);
55
+ if(readOnly&&path==='/hanamesh/library/target'&&input.installTarget!==undefined){try{input.installTarget=JSON.parse(input.installTarget);}catch{throw new AppHostError('INVALID_INPUT','installTarget must contain a structured catalog target.');}}
55
56
  requireCondition(await authorize(subject,path,input)===true,'FORBIDDEN','Operation is not authorized.',{},403);
56
57
  let result;
57
58
  if(NODE_RUNTIME_ROUTES.includes(path)){
@@ -1,7 +1,8 @@
1
1
  import { randomUUID } from 'node:crypto';
2
2
  import { AppHostError, requireCondition, SerialQueue } from '../errors.js';
3
3
  import { loadCatalog, isApplication, itemKind, upgradeAvailable, validateManifestUrl } from './catalog.js';
4
- import { scanInstalled, scanInstalledPlugins, snapshotProfileDependencies } from './installed.js';
4
+ import { scanInstalled, scanInstalledPlugins, snapshotProfileDependencies, readProfileManifest } from './installed.js';
5
+ import {packageInstallTarget,installTargetName,sameInstallTarget,catalogPackageTargets,pinnedGithubSpec} from './github-target.js';
5
6
  import {SUITE_BUNDLE,SUITE_TARGET,SUITE_PACKAGES,readSuiteStatus,CORE_SUITE_DECLARATION_MISSING,readSuiteSourceState} from './suite.js';
6
7
  import { PROTECTED_PACKAGES } from './install.js';
7
8
  import { handoffSettings } from './handoff.js';
@@ -15,6 +16,7 @@ const text=(value,max)=>typeof value==='string'&&value.length>0&&value.length<=m
15
16
  const RESTART_STATES=new Set(['installed-not-loaded','uninstalled-not-unloaded']);
16
17
  const protectedPackage=name=>PROTECTED_PACKAGES.includes(name);
17
18
  const present=row=>row&&['registered','installed','installed-not-loaded'].includes(row.state);
19
+ const npmUpgradeAvailable=(target,row)=>Boolean(target?.source==='npm'&&row?.version&&row.state!=='uninstalled-not-unloaded'&&upgradeAvailable(target.version,row.version));
18
20
 
19
21
  /**
20
22
  * `client`: which client this host is ('dsh' | 'hanamesh', see client-identity.js). `unavailable`: when there is no
@@ -64,45 +66,59 @@ export function createLibraryService({domain,host,config={},dataRoot,ledgerReade
64
66
  requireCondition(input&&typeof input==='object'&&!Array.isArray(input),'INVALID_INPUT','Only itemId and packageName are accepted.');
65
67
  // rc.8: optional install-target contract declaration. Absent keeps the v1 semantics; anything but '1' is refused explicitly, before field checks.
66
68
  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]});
67
- requireCondition(Object.keys(input).every(key=>['itemId','packageName','contractVersion'].includes(key)),'INVALID_INPUT','Only itemId and packageName are accepted.');
69
+ requireCondition(Object.keys(input).every(key=>['itemId','packageName','contractVersion','installTarget'].includes(key)),'INVALID_INPUT','Only catalog identity and package installation target are accepted.');
68
70
  const{itemId,packageName}=input;
71
+ const expected=input.installTarget===undefined?undefined:packageInstallTarget(input.installTarget);
72
+ requireCondition(!expected||itemId!==undefined,'INVALID_INPUT','A structured installation target requires itemId.');
69
73
  if(itemId===SUITE_TARGET){
70
- requireCondition(packageName===undefined,'INVALID_INPUT','套件升级不接受包名或任意替换参数。');
74
+ requireCondition(packageName===undefined&&!expected,'INVALID_INPUT','套件升级不接受包名或任意替换参数。');
71
75
  requireCondition(typeof installer?.prepareSuiteUpgrade==='function','SUITE_UPGRADE_UNAVAILABLE','当前客户端还不支持安全套件升级,请使用补齐命令。',{},503);
72
76
  const plan=await installer.prepareSuiteUpgrade();
73
- return{contractVersion:'1',source:{kind:'suite',manifestUrl:'https://github.com/yzsnstotz'},item:{id:SUITE_TARGET,displayName:'HanaMesh 套件',kind:'suite-upgrade',package:{registry:'npm',name:SUITE_BUNDLE},latestVersion:plan.bundle.to,installed:{state:'installed',version:suiteStatus.version},upgradeAvailable:plan.available,suiteUpgrade:plan}};
77
+ return{contractVersion:'1',source:{kind:'suite',manifestUrl:'https://github.com/yzsnstotz'},item:{id:SUITE_TARGET,displayName:'HanaMesh 套件',kind:'suite-upgrade',package:{registry:'npm',name:SUITE_BUNDLE},installed:{state:'installed',version:suiteStatus.version},upgradeAvailable:plan.available,suiteUpgrade:plan}};
74
78
  }
75
79
  requireCondition(itemId!==undefined||packageName!==undefined,'INVALID_INPUT','itemId or packageName is required.');
76
80
  requireCondition(itemId===undefined||(text(itemId,160)&&!/[\u0000-\u001f\u007f]/.test(itemId)),'INVALID_INPUT','itemId is invalid.');
77
81
  requireCondition(packageName===undefined||(text(packageName,214)&&PACKAGE_NAME.test(packageName)),'INVALID_INPUT','packageName is invalid.');
82
+ async function resolved(found,source){
83
+ requireCondition(found.oneClickInstall?.allowed===true,'NOT_INSTALLABLE','The catalog has not approved one-click installation.',{reason:found.oneClickInstall?.reason??'fixed-install-target-required'});
84
+ const targets=catalogPackageTargets(found),selected=targets.find(target=>expected?sameInstallTarget(target,expected):packageName===undefined||installTargetName(target)===packageName);
85
+ requireCondition(!expected||selected,'CATALOG_CHANGED','The reviewed installation source or build permission changed. Review the current target again.',{},409);
86
+ requireCondition(selected,'NOT_INSTALLABLE','This catalog entry has no fixed package installation target for the requested package.',{reason:'fixed-install-target-required'});
87
+ const name=installTargetName(selected);
88
+ requireCondition(!protectedPackage(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===name)??null;
90
+ let upgrade=npmUpgradeAvailable(selected,row);
91
+ if(selected?.source==='github'&&row){
92
+ const saved=await readProfileManifest(config.profileDir);
93
+ upgrade=saved.dependencies[name]!==pinnedGithubSpec(selected.repo,selected.ref,selected.path)||row.active===false;
94
+ }
95
+ const item={...found,package:{registry:selected.source,name},installTarget:selected,installed:row,upgradeAvailable:upgrade};
96
+ return{contractVersion:INSTALL_TARGET_CONTRACT_VERSION,source,item:{...item,kind:itemKind(item)}};
97
+ }
98
+ const packageMatches=row=>row.package?.name===packageName||(row.installMethods??[]).some(method=>method.target&&(method.target.packageName??method.target.name)===packageName);
78
99
  let cursor;const seen=new Set(),matches=new Map();
79
100
  do{
80
- const page=await catalog({q:packageName??'',category:'',cursor});
81
- const found=page.items.find(row=>(itemId===undefined||row.id===itemId)&&(packageName===undefined||row.package?.name===packageName));
101
+ const page=await catalog({q:itemId===undefined?packageName??'':'',category:'',cursor});
102
+ const found=page.items.find(row=>(itemId===undefined||row.id===itemId)&&(packageName===undefined||packageMatches(row)));
82
103
  if(found&&itemId===undefined&&page.schemaVersion==='hanamesh.catalog.v1'){
83
104
  for(const row of page.items.filter(row=>row.package?.name===packageName))matches.set(row.id,{row,source:page.source});
84
105
  requireCondition(matches.size<=1,'CATALOG_TARGET_AMBIGUOUS','Choose an itemId: this npm name belongs to multiple catalog identities.',{packageName},409);
85
106
  }else if(found){
86
- requireCondition(found.package?.registry==='npm','NOT_INSTALLABLE','This catalog entry cannot be installed.');
87
- requireCondition(!protectedPackage(found.package.name),'PACKAGE_DENIED','The HanaMesh suite is not managed through the market.',{},403);
88
- const rows=[...await installedPlugins(),...await installed()],row=rows.find(value=>value.packageName===found.package.name)??null;
89
- 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))}};
107
+ return await resolved(found,page.source);
90
108
  }
91
109
  cursor=page.page?.nextCursor;
92
110
  requireCondition(!cursor||!seen.has(cursor),'INVALID_CURSOR','Catalog cursor repeated.');if(cursor)seen.add(cursor);
93
111
  }while(cursor);
94
112
  if(matches.size===1){
95
113
  const{row:found,source}=matches.values().next().value;
96
- requireCondition(!protectedPackage(found.package.name),'PACKAGE_DENIED','The HanaMesh suite is not managed through the market.',{},403);
97
- const rows=[...await installedPlugins(),...await installed()],row=rows.find(value=>value.packageName===found.package.name)??null;
98
- 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))}};
114
+ return await resolved(found,source);
99
115
  }
100
116
  throw new AppHostError('CATALOG_ITEM_MISSING','The market catalog does not contain this target.',{},404);
101
117
  }
102
118
  function installItem(item){
103
- const key=item.package.name;if(installing.has(key))return installing.get(key);
119
+ const key=item.package.name;if(installing.has(key)){const prior=installing.get(key);requireCondition(JSON.stringify(prior.target)===JSON.stringify(item.installTarget),'CATALOG_CHANGED','Another installation of this package is using a different reviewed target.',{},409);return prior.started;}
104
120
  const started=operation('install',async operationId=>{try{return await markLive(await installer.install(item,{operationId}));}finally{installing.delete(key);}});
105
- installing.set(key,started);return started;
121
+ installing.set(key,{started,target:item.installTarget});return started;
106
122
  }
107
123
  async function findByPackage(packageName){
108
124
  requireCondition(text(packageName,214)&&PACKAGE_NAME.test(packageName),'INVALID_INPUT','packageName is required.');
@@ -120,9 +136,9 @@ export function createLibraryService({domain,host,config={},dataRoot,ledgerReade
120
136
  const page=await catalog({q,category,cursor});const installedRows=await installed();const pluginRows=await installedPlugins();
121
137
  suiteStatus={...await suiteFacts(),...(suiteStatus.restartRequired?{restartRequired:true}:{}),...(suiteStatus.upgradeError?{upgradeError:suiteStatus.upgradeError}:{})};
122
138
  const byPackage=new Map([...pluginRows,...installedRows].map(row=>[row.packageName,row]));
123
- let items=page.items.filter(item=>!protectedPackage(item.package?.name)).map(item=>{const row=byPackage.get(item.package?.name)??null;const kind=itemKind(item);
124
- return{...item,kind,application:isApplication(item),installed:row,upgradeAvailable:Boolean(row&&row.version&&row.state!=='uninstalled-not-unloaded'&&upgradeAvailable(item.latestVersion,row.version))};});
125
- 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}:{})});
139
+ let items=page.items.map(item=>{const selected=catalogPackageTargets(item)[0];return selected?{...item,installTarget:selected,package:{registry:selected.source,name:installTargetName(selected)}}:item;}).filter(item=>!protectedPackage(item.package?.name)).map(item=>{const row=byPackage.get(item.package?.name)??null;const kind=itemKind(item);
140
+ return{...item,kind,application:isApplication(item),installed:row,upgradeAvailable:npmUpgradeAvailable(item.installTarget,row)};});
141
+ 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,installed:suiteStatus.status==='installed'?{state:'installed',version:suiteStatus.version}:null,suite:suiteStatus,...((installer?.suiteUpgradeUnavailable??suiteStatus.suiteUpgradeUnavailable)?{suiteUpgradeUnavailable:installer?.suiteUpgradeUnavailable??suiteStatus.suiteUpgradeUnavailable}:{})});
126
142
  const categories=[...new Set(page.items.flatMap(item=>item.categories??[]))].sort();
127
143
  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};},
128
144
  sources(){return{sources:structuredClone(state.sources),revision:state.revision};},
@@ -154,7 +170,7 @@ export function createLibraryService({domain,host,config={},dataRoot,ledgerReade
154
170
  requireCondition(input&&Object.keys(input).length===1&&typeof input.planId==='string','INVALID_INPUT','Only the current planId is accepted.');
155
171
  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:error.details.command};emit({type:'library.install-failed',packageName:SUITE_BUNDLE,code:error.code??'SUITE_UPGRADE_FAILED',details:{stage:'suite-upgrade',actual,command:error.details.command}});throw error;}});
156
172
  },
157
- 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);},
173
+ 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??{});requireCondition(item.installTarget?.source!=='github'||input.installTarget,'INVALID_INPUT','Confirm the displayed fixed GitHub installation target before installing.');if(present(item.installed)&&!item.upgradeAvailable)return{status:'already-installed',itemId:item.id,packageName:item.package.name};return await installItem(item);},
158
174
  async provision(input){requireInstaller('Runtime provision is unavailable.');
159
175
  // LIB-PROVISION-INPUT: a missing field is the caller's error (400), not an internal TypeError inside the operation.
160
176
  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.');
@@ -39,13 +39,11 @@ const exact=value=>typeof value==='string'&&semver.valid(value)===value;
39
39
  const digest=value=>typeof value==='string'&&/^[a-f0-9]{64}$/.test(value);
40
40
  const nullableText=value=>value===null||text(value);
41
41
  const invalid=()=>requireCondition(false,'CORE_SUITE_DECLARATION_INVALID','Core 套件声明无法用于精确安装。',{},503);
42
- function exactInstallCommand(value){
43
- if(value===null)return true;
44
- const prefix=`dsh plugin --profile desktop add --save-exact ${SUITE_BUNDLE}@`;
45
- return typeof value==='string'&&value.startsWith(prefix)&&exact(value.slice(prefix.length));
42
+ function validInstallation(value,version){
43
+ return object(value)&&value.name===SUITE_BUNDLE&&value.version===version&&text(value.command)&&Array.isArray(value.args)&&value.args.length>0&&value.args.every(text);
46
44
  }
47
45
  function validateDeclaration(value){
48
- if(!object(value)||value.schemaVersion!==1||!text(value.checkedAt)||!exactInstallCommand(value.installCommand)||!digest(value.declarationId)||!text(value.nodeVersion)||!nullableText(value.registry)||!(value.bundleVersion===null||exact(value.bundleVersion))||!['complete','incomplete','unknown'].includes(value.completeness))invalid();
46
+ if(!object(value)||value.schemaVersion!==1||!text(value.checkedAt)||!nullableText(value.installCommand)||!digest(value.declarationId)||!text(value.nodeVersion)||!nullableText(value.registry)||!(value.bundleVersion===null||exact(value.bundleVersion))||!['complete','incomplete','unknown'].includes(value.completeness))invalid();
49
47
  if(value.completeness==='complete'&&!exact(value.bundleVersion))invalid();
50
48
  const manifest=value.manifest;
51
49
  if(!object(manifest)||manifest.schemaVersion!==1||!text(manifest.id)||!object(manifest.bundle)||manifest.bundle.name!==SUITE_BUNDLE||!text(manifest.bundle.repository)||!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();
@@ -61,6 +59,7 @@ function validateDeclaration(value){
61
59
  if(!Object.values(value.peerFacts).every(nullableText)||!Object.values(value.metadataDigests).every(text))invalid();
62
60
  for(const upgrade of value.upgrades){
63
61
  if(!object(upgrade)||!object(upgrade.bundle)||!text(upgrade.bundle.id)||upgrade.bundle.moduleName!==SUITE_BUNDLE||!exact(upgrade.bundle.version)||!text(upgrade.bundle.repository)||!digest(upgrade.bundle.metadataDigest)||!text(upgrade.registry)||!Array.isArray(upgrade.targets)||upgrade.targets.length!==3)invalid();
62
+ if(!validInstallation(upgrade.installation,upgrade.bundle.version))invalid();
64
63
  const targets=new Set();
65
64
  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);}
66
65
  }
@@ -76,7 +75,7 @@ export function readSuiteSourceState(source){
76
75
  return{protocolVersion:state.protocolVersion,status:state.status,selection:state.selection,registry:state.registry,archiveCount:state.archiveCount,lastError:state.lastError};
77
76
  }
78
77
  /** Consume only the optional protocol-1 Cordis method and the public C-CORE section D DTO. */
79
- export function createCoreSuiteAdapter({core=()=>undefined,registry,suiteSourceMode=()=>'configured',suiteSourceService=()=>undefined}={}){
78
+ export function createCoreSuiteAdapter({core=()=>undefined,registry,suiteSourceMode=()=>'configured',suiteSourceService=()=>undefined,profileName}={}){
80
79
  const current=()=>{const value=core();return value?.protocolVersion==='1'&&typeof value.getSuiteDeclaration==='function'?value:undefined;};
81
80
  // AppHost-local service identity only: Core protocol 1 supplies no revision/generation.
82
81
  const instances=new WeakMap();
@@ -101,17 +100,17 @@ export function createCoreSuiteAdapter({core=()=>undefined,registry,suiteSourceM
101
100
  }
102
101
  const get=async()=>{
103
102
  const binding=sourceBinding();
104
- const declaration=await read({registry:binding.registry});
103
+ const declaration=await read({...(binding.registry===undefined?{}:{registry:binding.registry}),...(profileName===undefined?{}:{profile:profileName})});
105
104
  requireCondition(JSON.stringify(sourceBinding())===JSON.stringify(binding),'SUITE_PLAN_CHANGED','套件安装包来源已变化,请重新查看并确认。',{},409);
106
105
  requireCondition(declaration.completeness==='complete','CORE_SUITE_CURRENT_INCOMPLETE','Core 套件完整性:'+declaration.completeness,{completeness:declaration.completeness,blocked:declaration.blocked},409);
107
106
  let expected;try{const url=new URL(binding.registry);url.pathname=url.pathname.replace(/\/$/,'')+'/';expected=url.href;}catch{}
108
- requireCondition(expected&&declaration.registry===expected,'CORE_SUITE_SOURCE_MISMATCH','Core 声明来源与执行 registry 不符。',{blocked:declaration.blocked},409);
107
+ requireCondition(text(declaration.registry)&&(binding.registry===undefined||declaration.registry===expected),'CORE_SUITE_SOURCE_MISMATCH','Core 声明来源与执行 registry 不符。',{blocked:declaration.blocked},409);
109
108
  requireCondition(declaration.upgrades.length>0,'CORE_SUITE_NO_COMPATIBLE_TUPLE','Core 未提供可执行升级组合。',{blocked:declaration.blocked},409);
110
109
  // The first displayed Core tuple is the presentation default; App-host never ranks versions.
111
110
  const selectedUpgrade=declaration.upgrades[0];
112
111
  requireCondition(selectedUpgrade.registry===declaration.registry,'CORE_SUITE_SOURCE_MISMATCH','Core 升级组合来源与执行 registry 不符。',{},409);
113
112
  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};});
114
- return{available:true,bundle:{name:SUITE_BUNDLE,from:declaration.bundleVersion,to:selectedUpgrade.bundle.version},components,installCommand:declaration.installCommand,sourceBinding:binding,declarationId:declaration.declarationId,registry:declaration.registry,selectedUpgrade,physicalBaseline:declaration.installed,upgrades:declaration.upgrades,blocked:declaration.blocked};
113
+ return{available:true,bundle:{name:SUITE_BUNDLE,from:declaration.bundleVersion,to:selectedUpgrade.bundle.version},components,installation:selectedUpgrade.installation,installCommand:declaration.installCommand,sourceBinding:binding,declarationId:declaration.declarationId,registry:declaration.registry,selectedUpgrade,physicalBaseline:declaration.installed,upgrades:declaration.upgrades,blocked:declaration.blocked};
115
114
  };
116
115
  Object.defineProperty(get,'unavailable',{get:()=>current()?null:CORE_SUITE_DECLARATION_MISSING});
117
116
  get.readFacts=async()=>{
@@ -132,7 +131,7 @@ export async function prepareSuiteUpgrade({coreSuitePlan}={}){
132
131
  const plan=await coreSuitePlan();
133
132
  requireCondition(plan!==undefined&&plan!==null,'CORE_SUITE_DECLARATION_MISSING',CORE_SUITE_DECLARATION_MISSING,{},503);
134
133
  const invalid=()=>requireCondition(false,'CORE_SUITE_DECLARATION_INVALID','Core 套件声明无法用于精确安装。',{},503);
135
- if(typeof plan!=='object'||Array.isArray(plan)||typeof plan.available!=='boolean'||!object(plan.bundle)||plan.bundle.name!==SUITE_BUNDLE||!exact(plan.bundle.from)||!exact(plan.bundle.to)||!exactInstallCommand(plan.installCommand)||!Array.isArray(plan.components)||plan.components.length!==SUITE_PACKAGES.length)invalid();
134
+ if(typeof plan!=='object'||Array.isArray(plan)||typeof plan.available!=='boolean'||!object(plan.bundle)||plan.bundle.name!==SUITE_BUNDLE||!exact(plan.bundle.from)||!exact(plan.bundle.to)||!nullableText(plan.installCommand)||!validInstallation(plan.installation,plan.bundle.to)||!Array.isArray(plan.components)||plan.components.length!==SUITE_PACKAGES.length)invalid();
136
135
  const rows=new Map();
137
136
  for(const row of plan.components){
138
137
  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();
@@ -35,7 +35,7 @@ export async function apply(ctx, config = {}) {
35
35
  }
36
36
  ```
37
37
 
38
- 预发布版本只用于隔离验收且不得成为 `latest`;应用库生产路径只接受 registry `latest` 返回的稳定精确版本。发布者必须同时保留 npm 包完整性、runtime 三方来源与 sha256、以及与 app-host 精确 peer 版本对应的可复核构建记录。
38
+ 预发布版本只用于隔离验收且不得成为 `latest`;应用库安装路径只接受启用目录 installMethods 给出的审核精确版本,且条目 oneClickInstall.allowed:true;不在安装时读取可变标签。发布者必须同时保留 npm 包完整性、runtime 三方来源与 sha256、以及与 app-host 精确 peer 版本对应的可复核构建记录。
39
39
 
40
40
  ## Existing configuration and original upstream archives (D-230)
41
41