@hanamesh/dsh-app-host 0.6.18 → 0.6.21

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=>item?.installMethods===undefined?item?.oneClickInstall?.allowed!==false: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: '安装失败' } });
@@ -10,10 +10,11 @@ import {noteFailurePhase} from './diagnostics.js';
10
10
  import { validateDefinition } from '../descriptor.js';
11
11
  import { itemKind } from './catalog.js';
12
12
  import { profileSnapshot, runtimeSnapshot, profileChecks, runtimeChecks, packageRestoreArgs, keepOriginals } from './recovery.js';
13
- import {prepareSuiteUpgrade,readSuiteStatus,createCoreSuiteAdapter} from './suite.js';
13
+ import {SUITE_BUNDLE,prepareSuiteUpgrade,readSuiteStatus,createCoreSuiteAdapter} from './suite.js';
14
+ import {packageInstallTarget,installTargetName,pinnedGithubSpec} from './github-target.js';
14
15
 
15
- /** The HanaMesh suite itself (real names + the historical scoped spellings) is never installed or removed through the market. */
16
- export const PROTECTED_PACKAGES=Object.freeze(['@hanamesh/core','@hanamesh/usage','@hanamesh/brand','@hanamesh/dsh-app-host','hanamesh-core','hanamesh-usage','hanamesh-brand','@hanamesh/dsh-core','@hanamesh/dsh-usage']);
16
+ /** Suite packages are changed only by the confirmed suite upgrade command. */
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
20
  const versionText=value=>typeof value==='string'&&/^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/.test(value);
@@ -160,23 +161,31 @@ export function createLibraryInstaller({profileDir,profileName,dataRoot,nodeBina
160
161
  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
162
  try{
162
163
  base=await profileSnapshot(profileDir,name);
163
- stage='before-add';let version;({version,registry:installRegistry}=await latest(name));
164
+ stage='before-add';let version;const target=item.installTarget,github=target?.source==='github';
165
+ if(target?.source==='npm'){version=target.version;installRegistry=registry??null;}
166
+ else if(!github)({version,registry:installRegistry}=await latest(name));
167
+ const spec=github?pinnedGithubSpec(target.repo,target.ref,target.path):`${name}@${version}`;
164
168
  const status='restart-required';
165
- stage='add';await run(['add','--save-exact',...(installRegistry===null?[]:[`--registry=${installRegistry}`]),`${name}@${version}`]);
169
+ stage='add';await run(['add','--save-exact',...(installRegistry===null?[]:[`--registry=${installRegistry}`]),...(target?.allowBuild??[]).map(entry=>`--allow-build=${entry}`),spec]);
166
170
  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};
171
+ if(github){
172
+ const actual=await json(join(profileDir,'node_modules',...name.split('/'),'package.json'));
173
+ 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),
174
+ '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);
175
+ version=actual.version;
176
+ }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);
177
+ if(kind==='plugin')return{status,kind,packageName:name,version,...(target?{installTarget:target}:{}),...(github?{commit:target.ref}:{})};
169
178
  stage='definition';const record=await installed(name);const declared=record.definition.deployments.map(deployment=>deployment.runtime).find(Boolean);
170
179
  if(declared){stage='provision';const root=runtimeRoot(record.definition.id);runtime={root,base:await runtimeSnapshot(await runtimeApi('ledger'),root)};
171
180
  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};
181
+ return{status:'restart-required',kind,appId:record.definition.id,packageName:name,version,...(target?{installTarget:target}:{}),...(github?{commit:target.ref}:{})};
173
182
  }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
183
  }
175
184
  async function installAs(item,kind,context){
176
185
  permitted(item.package.name);const name=item.package.name;
177
- emit({type:'library.install-started',...(item.id?{itemId:item.id}:{}),packageName:name,kind});
186
+ emit({type:'library.install-started',...(item.id?{itemId:item.id}:{}),packageName:name,kind,...(item.installTarget?{installTarget:item.installTarget}:{})});
178
187
  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;}
188
+ 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
189
  }
181
190
  let suitePlan;
182
191
  const suiteProvider=coreSuitePlan??createCoreSuiteAdapter({core:coreService,registry:registry??'https://registry.npmjs.org',suiteSourceMode,suiteSourceService});
@@ -195,27 +204,28 @@ export function createLibraryInstaller({profileDir,profileName,dataRoot,nodeBina
195
204
  requireCondition(suitePlan?.planId===planId,'SUITE_PLAN_CHANGED','升级确认已变化,请重新查看并确认。',{},409);
196
205
  const confirmed=suitePlan;suitePlan=undefined;
197
206
  const fresh=await prepareSuiteUpgrade({coreSuitePlan:suiteProvider});
198
- const identity=plan=>JSON.stringify({sourceBinding:plan.sourceBinding,components:plan.components,declarationId:plan.declarationId,registry:plan.registry,selectedUpgrade:plan.selectedUpgrade,physicalBaseline:plan.physicalBaseline});
207
+ 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});
199
208
  requireCondition(identity(fresh)===identity(confirmed),'SUITE_PLAN_CHANGED','套件声明或版本已变化,请重新查看并确认。',{},409);
200
209
  requireCondition(fresh.available,'SUITE_NO_UPDATE','当前没有兼容新版。',{},409);
201
210
  const before=await readSuiteStatus(profileDir);
202
- requireCondition(before.status==='installed'&&fresh.components.every((c,i)=>before.components[i]?.version===c.from),'SUITE_PLAN_CHANGED','已装版本或启用状态已变化,请重新查看并确认。',{},409);
211
+ requireCondition(before.status==='installed'&&before.version===fresh.bundle.from&&fresh.components.every((c,i)=>before.components[i]?.version===c.from),'SUITE_PLAN_CHANGED','已装版本或启用状态已变化,请重新查看并确认。',{},409);
203
212
  requireCondition(cliAvailable,'DSH_CLI_UNAVAILABLE','套件升级需客户端随附的官方 CLI;请恢复后重试。',{},503);
204
213
  const executing=await prepareSuiteUpgrade({coreSuitePlan:suiteProvider});
205
214
  requireCondition(identity(executing)===identity(confirmed),'SUITE_PLAN_CHANGED','执行前套件安装包来源或声明已变化,请重新查看并确认。',{},409);
206
- emit({type:'library.install-started',itemId:'hanamesh:suite-upgrade',packageName:'@hanamesh/core',kind:'suite-upgrade'});
215
+ emit({type:'library.install-started',itemId:'hanamesh:suite-upgrade',packageName:SUITE_BUNDLE,kind:'suite-upgrade'});
207
216
  try{
208
- await run(['add','--save-exact','--strict-peer-dependencies',`--registry=${fresh.registry??registry??'https://registry.npmjs.org'}`,...fresh.components.map(c=>`${c.name}@${c.to}`)]);
217
+ await run(['add','--save-exact','--strict-peer-dependencies',`--registry=${fresh.registry??registry??'https://registry.npmjs.org'}`,`${SUITE_BUNDLE}@${fresh.bundle.to}`]);
209
218
  const actual=await readSuiteStatus(profileDir);
210
219
  const saved=await json(join(profileDir,'package.json'));
211
- 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);
212
- return{status:'restart-required',kind:'suite-upgrade',packageName:'@hanamesh/core',components:fresh.components,version:actual.version};
220
+ 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);
221
+ return{status:'restart-required',kind:'suite-upgrade',packageName:SUITE_BUNDLE,components:fresh.components,version:actual.version};
213
222
  }catch(error){
214
223
  error.details={...error.details,stage:'suite-upgrade',actual:await readSuiteStatus(profileDir),command:fresh.installCommand};
215
- emit({type:'library.install-failed',packageName:'@hanamesh/core',code:error.code??'SUITE_UPGRADE_FAILED'});throw error;
224
+ emit({type:'library.install-failed',packageName:SUITE_BUNDLE,code:error.code??'SUITE_UPGRADE_FAILED'});throw error;
216
225
  }
217
226
  });},
218
227
  async install(item,context){
228
+ if(item.installTarget){const target=packageInstallTarget(item.installTarget);item={...item,installTarget:target,package:{registry:target.source,name:installTargetName(target)}};}
219
229
  const kind=itemKind(item);
220
230
  requireCondition(kind!=='listing','NOT_INSTALLABLE','Catalog item has no npm package to install.');
221
231
  return await installAs(item,kind,context);
@@ -1,7 +1,7 @@
1
1
  /** Read fresh public installation facts. No compatibility calculation, persistence or polling. */
2
2
  import semver from 'semver';
3
3
  import {AppHostError} from '../errors.js';
4
- import {createCoreSuiteAdapter,SUITE_PACKAGES} from './suite.js';
4
+ import {SUITE_BUNDLE,createCoreSuiteAdapter,SUITE_PACKAGES} from './suite.js';
5
5
  import {SUPPORTED_READER_VERSIONS,validReader,runOfficialInstallationCommand} from './installation-cli.js';
6
6
  const object=x=>x!==null&&typeof x==='object'&&!Array.isArray(x);
7
7
  const exact=x=>typeof x==='string'&&semver.valid(x)===x?x:null;
@@ -11,7 +11,7 @@ function abortable(work,signal){
11
11
  if(signal.aborted)return Promise.reject(interrupted());
12
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
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});
14
+ const base=(channel,id,kind,label)=>({channel,scope:{id,kind,label},status:'unknown',version:null,versionKind:['dsh','hanamesh'].includes(channel)?'suite-bundle':'agent-distribution',components:[],identity:null,reason:null});
15
15
  const unknown=(row,code,message,action)=>({...row,status:'unknown',version:null,reason:reason(code,message,action)});
16
16
  const agentBase=channel=>base(channel,'agent-cli','official-user-profile',channel==='claude'?'Claude Code 用户级插件(不覆盖项目或其他隔离资料)':'Codex CLI 本地市场用户资料(不代表桌面 App、项目或其他隔离资料)');
17
17
  const inventoryError=()=>{throw new AppHostError('AGENT_INVENTORY_INVALID','Official inventory shape is unsupported.',{},503)};
@@ -58,7 +58,7 @@ export function createInstallationStatusReader({client='dsh',core=()=>undefined,
58
58
  const row=base(client,'current-runtime','responding-runtime',client==='dsh'?'当前响应的 DSH runtime':'当前响应的 HanaMesh 客户端 runtime');
59
59
  try{const value=await abortable(suite.readFacts(),signal);
60
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}:{})}));
61
+ const blocked=value.blocked.map(r=>({code:/^CORE_[A-Z0-9_]{1,75}$/.test(r.code)?r.code:'CORE_SUITE_BLOCKED',...(r.moduleName===SUITE_BUNDLE||SUITE_PACKAGES.some(c=>c.name===r.moduleName)?{moduleName:r.moduleName}:{})}));
62
62
  return{...row,status:value.status,version:value.status==='unknown'?null:exact(value.version),components,blocked,
63
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
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,查看其设置并重新检测。')}
@@ -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,8 +1,9 @@
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';
5
- import {SUITE_TARGET,SUITE_PACKAGES,readSuiteStatus,CORE_SUITE_DECLARATION_MISSING,readSuiteSourceState} from './suite.js';
4
+ import { scanInstalled, scanInstalledPlugins, snapshotProfileDependencies, readProfileManifest } from './installed.js';
5
+ import {packageInstallTarget,installTargetName,sameInstallTarget,catalogPackageTargets,pinnedGithubSpec,catalogInstallationAllowed} from './github-target.js';
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';
8
9
  import { INSTALLATION_STATUS_CAPABILITY } from '../installation-status.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:'@hanamesh/core'},latestVersion:plan.components[0].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(catalogInstallationAllowed(found),'NOT_INSTALLABLE','The catalog has not approved one-click installation.',{reason:found.oneClickInstall?.reason});
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||(found.installMethods===undefined&&found.package?.registry==='npm'),'NOT_INSTALLABLE','This catalog entry has no fixed package installation target for the requested package.',{reason:'fixed-install-target-required'});
87
+ const name=selected?installTargetName(selected):found.package.name;
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,...(selected?{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};},
@@ -152,9 +168,9 @@ export function createLibraryService({domain,host,config={},dataRoot,ledgerReade
152
168
  get live(){return Boolean(installer?.live);},
153
169
  async upgradeSuite(input){requireInstaller('Suite upgrade is unavailable.');
154
170
  requireCondition(input&&Object.keys(input).length===1&&typeof input.planId==='string','INVALID_INPUT','Only the current planId is accepted.');
155
- 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:'@hanamesh/core',code:error.code??'SUITE_UPGRADE_FAILED',details:{stage:'suite-upgrade',actual,command:error.details.command}});throw error;}});
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.');
@@ -1,9 +1,12 @@
1
1
  import {readFile} from 'node:fs/promises';
2
2
  import {join} from 'node:path';
3
3
  import semver from 'semver';
4
+ import {createRequire} from 'node:module';
5
+ import {pathToFileURL} from 'node:url';
4
6
  import {randomUUID} from 'node:crypto';
5
7
  import {requireCondition} from '../errors.js';
6
8
 
9
+ export const SUITE_BUNDLE='@hanamesh/hanamesh';
7
10
  export const SUITE_TARGET='hanamesh:suite-upgrade';
8
11
  export const SUITE_PACKAGES=Object.freeze([
9
12
  {name:'@hanamesh/core',label:'Core',repo:'hanamesh-core'},
@@ -17,11 +20,16 @@ export async function readSuiteStatus(profileDir){
17
20
  try{
18
21
  const profile=await json(join(profileDir,'package.json'));
19
22
  if(!profile.dependencies||!Array.isArray(profile.dsh?.profile?.bundles))throw new Error('unreadable profile');
23
+ const resolver=createRequire(pathToFileURL(join(profileDir,'package.json')));
24
+ let bundle,bundlePath;try{bundlePath=resolver.resolve(`${SUITE_BUNDLE}/package.json`);bundle=await json(bundlePath);}catch{}
25
+ const memberResolver=bundlePath?createRequire(bundlePath):resolver;
26
+ const selected=profile.dsh.profile.bundles.includes(SUITE_BUNDLE)&&Object.hasOwn(profile.dependencies,SUITE_BUNDLE);
20
27
  const components=await Promise.all(SUITE_PACKAGES.map(async({name,label})=>{
21
- let pkg;try{pkg=await json(join(profileDir,'node_modules',name,'package.json'));}catch{}
22
- return{name,label,version:pkg?.name===name&&semver.valid(pkg.version)?pkg.version:null,active:profile.dsh.profile.bundles.includes(name)};
28
+ let pkg;try{pkg=await json(memberResolver.resolve(`${name}/package.json`));}catch{}
29
+ return{name,label,version:pkg?.name===name&&semver.valid(pkg.version)?pkg.version:null,active:selected};
23
30
  }));
24
- return{status:components.every(c=>c.version&&c.active)?'installed':'incomplete',version:components[0].version,components};
31
+ const version=bundle?.name===SUITE_BUNDLE&&semver.valid(bundle.version)?bundle.version:null;
32
+ return{status:version&&selected&&components.every(c=>c.version&&bundle.dependencies?.[c.name]===c.version)?'installed':'incomplete',version,components};
25
33
  }catch{return{status:'unknown',version:null,components:[]};}
26
34
  }
27
35
  export const CORE_SUITE_DECLARATION_MISSING='Core 未提供套件声明';
@@ -31,10 +39,16 @@ const exact=value=>typeof value==='string'&&semver.valid(value)===value;
31
39
  const digest=value=>typeof value==='string'&&/^[a-f0-9]{64}$/.test(value);
32
40
  const nullableText=value=>value===null||text(value);
33
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));
46
+ }
34
47
  function validateDeclaration(value){
35
- if(!object(value)||value.schemaVersion!==1||!text(value.checkedAt)||!text(value.installCommand)||!digest(value.declarationId)||!text(value.nodeVersion)||!nullableText(value.registry)||!['complete','incomplete','unknown'].includes(value.completeness))invalid();
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();
49
+ if(value.completeness==='complete'&&!exact(value.bundleVersion))invalid();
36
50
  const manifest=value.manifest;
37
- if(!object(manifest)||manifest.schemaVersion!==1||!text(manifest.id)||!object(manifest.core)||manifest.core.name!==SUITE_PACKAGES[0].name||!exact(manifest.core.version)||!text(manifest.core.repository)||!Array.isArray(manifest.components)||manifest.components.length!==2)invalid();
51
+ 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();
38
52
  const members=new Map();
39
53
  for(const row of manifest.components){if(!object(row)||!text(row.id)||!SUITE_PACKAGES.slice(1).some(p=>p.name===row.moduleName)||members.has(row.moduleName)||!text(row.versionRange)||!text(row.repository))invalid();members.set(row.moduleName,row.id);}
40
54
  if(!Array.isArray(value.installed)||value.installed.length!==3||!Array.isArray(value.upgrades)||!Array.isArray(value.blocked)||!object(value.peerFacts)||!object(value.metadataDigests))invalid();
@@ -43,13 +57,12 @@ function validateDeclaration(value){
43
57
  if(!object(row)||!text(row.id)||!SUITE_PACKAGES.some(p=>p.name===row.moduleName)||installed.has(row.moduleName)||!text(row.requiredRange)||!text(row.repository)||!(row.actualVersion===null||exact(row.actualVersion))||!nullableText(row.actualRepository)||!text(row.status)||(row.runningVersion!==undefined&&!exact(row.runningVersion)))invalid();
44
58
  installed.set(row.moduleName,row);
45
59
  }
46
- for(const row of value.blocked)if(!object(row)||!text(row.code)||(row.coreVersion!==undefined&&!text(row.coreVersion))||(row.moduleName!==undefined&&!text(row.moduleName)))invalid();
60
+ for(const row of value.blocked)if(!object(row)||!text(row.code)||(row.bundleVersion!==undefined&&!exact(row.bundleVersion))||(row.moduleName!==undefined&&!text(row.moduleName)))invalid();
47
61
  if(!Object.values(value.peerFacts).every(nullableText)||!Object.values(value.metadataDigests).every(text))invalid();
48
62
  for(const upgrade of value.upgrades){
49
- if(!object(upgrade)||!exact(upgrade.coreVersion)||!text(upgrade.registry)||!digest(upgrade.manifestDigest)||!Array.isArray(upgrade.targets)||upgrade.targets.length!==3)invalid();
63
+ 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();
50
64
  const targets=new Set();
51
65
  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);}
52
- if(upgrade.targets.find(row=>row.moduleName===SUITE_PACKAGES[0].name).version!==upgrade.coreVersion)invalid();
53
66
  }
54
67
  return structuredClone(value);
55
68
  }
@@ -98,12 +111,12 @@ export function createCoreSuiteAdapter({core=()=>undefined,registry,suiteSourceM
98
111
  const selectedUpgrade=declaration.upgrades[0];
99
112
  requireCondition(selectedUpgrade.registry===declaration.registry,'CORE_SUITE_SOURCE_MISMATCH','Core 升级组合来源与执行 registry 不符。',{},409);
100
113
  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};});
101
- return{available:true,components,installCommand:declaration.installCommand,sourceBinding:binding,declarationId:declaration.declarationId,registry:declaration.registry,selectedUpgrade,physicalBaseline:declaration.installed,upgrades:declaration.upgrades,blocked:declaration.blocked};
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};
102
115
  };
103
116
  Object.defineProperty(get,'unavailable',{get:()=>current()?null:CORE_SUITE_DECLARATION_MISSING});
104
117
  get.readFacts=async()=>{
105
118
  const declaration=await read();
106
- return{status:declaration.completeness==='complete'?'installed':declaration.completeness,completeness:declaration.completeness,version:declaration.installed.find(row=>row.moduleName==='@hanamesh/core').actualVersion,
119
+ return{status:declaration.completeness==='complete'?'installed':declaration.completeness,completeness:declaration.completeness,version:declaration.bundleVersion,
107
120
  components:declaration.installed.map(row=>({...row,name:row.moduleName,label:SUITE_PACKAGES.find(p=>p.name===row.moduleName).label,version:row.actualVersion})),blocked:declaration.blocked};
108
121
  };
109
122
  return get;
@@ -119,7 +132,7 @@ export async function prepareSuiteUpgrade({coreSuitePlan}={}){
119
132
  const plan=await coreSuitePlan();
120
133
  requireCondition(plan!==undefined&&plan!==null,'CORE_SUITE_DECLARATION_MISSING',CORE_SUITE_DECLARATION_MISSING,{},503);
121
134
  const invalid=()=>requireCondition(false,'CORE_SUITE_DECLARATION_INVALID','Core 套件声明无法用于精确安装。',{},503);
122
- if(typeof plan!=='object'||Array.isArray(plan)||typeof plan.available!=='boolean'||!text(plan.installCommand)||!Array.isArray(plan.components)||plan.components.length!==SUITE_PACKAGES.length)invalid();
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();
123
136
  const rows=new Map();
124
137
  for(const row of plan.components){
125
138
  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();
package/docs/CONTRACT.md CHANGED
@@ -149,9 +149,9 @@ DSH 绑定必须提供同一个 `single` domain 的一次完整镜像 publish
149
149
 
150
150
  | 路由 | 方法 | 输入 | 输出 |
151
151
  |---|---|---|---|
152
- | `/hanamesh/library` | GET | `q`、`category`(缺省 `hanamesh-app`;`category=` 空 = 全部)、`cursor` | `{source, items[], categories[], installed[], plugins[], restartRequired, page}`;`items[]` 每条目在目录字段之外加 `kind`(`application` = `hanamesh-app` + npm 包;`plugin` = 其它 npm 包;`listing` = 无 npm 包,仅收录)、`application`(兼容)、`installed`(应用行或插件行,或 `null`)、`upgradeAvailable`(目录 `latestVersion` 严格新于已装 `version`;已装行处于 `uninstalled-not-unloaded` 时恒 false);`categories` = 本页条目出现过的类别(目录契约没有类别清单端点) |
152
+ | `/hanamesh/library` | GET | `q`、`category`(缺省 `hanamesh-app`;`category=` 空 = 全部)、`cursor` | `{source, items[], categories[], installed[], plugins[], restartRequired, page}`;`items[]` 每条目在目录字段之外加 `kind`(`application` = `hanamesh-app` + npm 包;`plugin` = 其它 npm 包;`listing` = 无 npm 包,仅收录)、`application`(兼容)、`installed`(应用行或插件行,或 `null`)、`upgradeAvailable`(已选 npm `installTarget.version` 严格新于已装 `version`;不读取退休目录版本;已装行处于 `uninstalled-not-unloaded` 时恒 false);`categories` = 本页条目出现过的类别(目录契约没有类别清单端点) |
153
153
  | `/hanamesh/library/installedPlugins` | GET(POST → 405) | 无参数 | `{plugins[], apps[], restartRequired}`;`plugins[]`:`{packageName, version, kind:'plugin', bundle, active, state, errorCode?}`,`state` ∈ `installed` / `installed-not-loaded`(启动后新增)/ `uninstalled-not-unloaded`(启动后移除,进程仍加载)/ `invalid`(依赖里有、`node_modules` 里读不到);`apps[]` = 原已安装应用扫描行(`registered` / `installed-not-loaded` / `runtime-missing` / `invalid`) |
154
- | `/hanamesh/library/install` | POST | `{itemId, packageName?}` | 202 `{operationId,status:'started'}`;`packageName` 给出时用它精确查目录(`q=<包名>&category=`)再按 `itemId` 匹配;应用与插件同一路径 `dsh plugin add --save-exact <包>@<registry latest>`,应用另外供给 runtime;也是升级路径 |
154
+ | `/hanamesh/library/install` | POST | `{itemId, packageName?}` | 202 `{operationId,status:'started'}`;`packageName` 给出时用它精确查目录(`q=<包名>&category=`)再按 `itemId` 匹配;应用与插件同一路径走官方 CLI;有 installMethods 时必须选中固定 target,npm 用 target.version,GitHub 用完整 SHA/子目录/构建名单,不在安装时解析 latest。无匹配目标拒绝 NOT_INSTALLABLE 并返回具名原因。未提供 installMethods 的标准目录保持既有 npm 路径;应用另外供给 runtime;也是升级路径 |
155
155
  | `/hanamesh/library/plugins/install` | POST | `{packageName}` | 202;包必须在启用目录里能搜到(否则 404 `CATALOG_ITEM_MISSING`);目录条目是应用则走应用路径 |
156
156
  | `/hanamesh/library/plugins/uninstall` | POST | `{packageName}` | 202;`dsh plugin remove <包>`;若该包是已装应用则转应用卸载(runtime 清理 + 运行中 `INSTANCE_IN_USE` 409) |
157
157
  | `/hanamesh/library/provision` | POST | `{appId, packageName, runtimeItem}` 三者必填 | 202;缺字段/非法 → 400 `INVALID_INPUT`(`LIB-PROVISION-INPUT`) |
@@ -40,7 +40,7 @@ interface InstallationObservation {
40
40
  kind:'responding-runtime'|'unobserved-runtime'|'official-user-profile'|'desktop-app';label:string};
41
41
  status:'installed'|'not-installed'|'incomplete'|'unknown';
42
42
  version:string|null;
43
- versionKind:'core'|'agent-distribution';
43
+ versionKind:'suite-bundle'|'agent-distribution';
44
44
  components:readonly {name:string;version:string|null;active?:boolean;status?:string}[];
45
45
  identity:{pluginName:string;marketplace:string}|null;
46
46
  reason:{code:string;message:string;action:string}|null;
@@ -50,7 +50,7 @@ interface InstallationObservation {
50
50
 
51
51
  The five distinct observations are: responding runtime, the other DSH/HanaMesh runtime (always `unknown`, not observed by this responder), Claude Code configured user-level CLI scope, Codex CLI configured local-marketplace user scope, and Codex/ChatGPT desktop App scope (always named `unknown`). CLI labels explicitly exclude project/other isolated profiles and desktop App. A valid absence is absence **only in the named configured scope**; never show it as a machine-wide uninstall. Preserve every responder/scope; ports are not durable instance identities. Conflicts between responders are unknown, never resolved by choosing one installed result.
52
52
 
53
- Current runtime status/version/components come only from the optional public Core protocol `'1'` `getSuiteDeclaration()` **without an argument**, through the existing Core adapter. No registry request, health substitution, physical file-count completeness, version-range/peer/source algorithm or local upgrade selection is added. Complete maps to installed; incomplete stays incomplete; unknown stays unknown with top-level version null but known component versions and sanitized named blocked codes retained. Missing method, malformed declaration and read failure have distinct reasons. Each detection re-reads; Core removal/invalidation cannot reuse an old installed answer. Agent distribution version is independent of Core version. Missing active fields remain absent.
53
+ Current runtime status/version/components come only from the optional public Core protocol `'1'` `getSuiteDeclaration()` **without an argument**, through the existing Core adapter. No registry request, health substitution, physical file-count completeness, version-range/peer/source algorithm or local upgrade selection is added. Complete maps to installed; incomplete stays incomplete; unknown stays unknown with top-level version null but known component versions and sanitized named blocked codes retained. Missing method, malformed declaration and read failure have distinct reasons. Each detection re-reads; Core removal/invalidation cannot reuse an old installed answer. Agent distribution version is independent of the DSH suite bundle version. Missing active fields remain absent.
54
54
 
55
55
  ## Explicit agent reader settings
56
56
 
@@ -94,3 +94,5 @@ Per-scope unknown reasons always include a safe code/message/action:
94
94
  Start each explicit detection by clearing previous observations. Cancel fetch when the person clears/revokes/leaves, the consumer/identity changes, or the window unmounts. Discard late request generations. A failed read/capability disappearance never keeps old installed status. Do not poll, persist, forward inventory or change Core/Usage recording consent. G's website consumer owns these page lifecycle rules; the bridge cancels on disconnect and module unload and holds no cross-request observation cache.
95
95
 
96
96
  Official command references: [Claude Code plugin commands](https://code.claude.com/docs/en/plugins/cli-reference), [Codex CLI plugin reference](https://learn.chatgpt.com/docs/cli/reference). [OpenAI local plugin scope documentation](https://developers.openai.com/plugins/build/plugins) describes multiple client scopes; CLI installation is not used here as proof of the desktop App.
97
+
98
+ For DSH/HanaMesh runtime observations, versionKind is suite-bundle and version is the installed @hanamesh/hanamesh version. Component rows retain their physical versions. Agent distribution observations retain their existing semantics.