@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.
@@ -17,42 +17,392 @@
17
17
  "latest": "1.2.3"
18
18
  },
19
19
  "cases": [
20
- { "id": "RB01", "mode": "new-install", "stage": "before-add", "note": "registry lookup fails before the profile is touched", "registryMissing": true,
21
- "expect": { "code": "REGISTRY_LOOKUP_FAILED", "status": "not-needed", "actions": [] } },
22
- { "id": "RB02", "mode": "new-install", "stage": "add", "note": "official CLI fails before writing", "inject": [["add", "before"]],
23
- "expect": { "code": "DSH_PLUGIN_FAILED", "status": "not-needed", "actions": [] } },
24
- { "id": "RB03", "mode": "new-install", "stage": "add", "note": "official CLI fails after writing package/lock/module", "inject": [["add", "after"]],
25
- "expect": { "code": "DSH_PLUGIN_FAILED", "status": "restored", "actions": ["dsh plugin remove $package"] } },
26
- { "id": "RB04", "mode": "new-install", "stage": "definition", "note": "add succeeded, application definition is invalid", "invalidDefinition": true,
27
- "expect": { "code": "INVALID_DEFINITION", "status": "restored", "actions": ["dsh plugin remove $package"] } },
28
- { "id": "RB05", "mode": "new-install", "stage": "provision", "note": "provision fails before promote (runtime untouched)", "inject": [["provision", "before"]],
29
- "expect": { "code": "E_DOWNLOAD", "status": "restored", "actions": ["dsh plugin remove $package"] } },
30
- { "id": "RB06", "mode": "new-install", "stage": "provision", "note": "provision fails after promoting a newly claimed runtime item", "inject": [["provision", "after"]],
31
- "expect": { "code": "E_VERIFY", "status": "restored", "actions": ["dsh plugin remove $package", "provision remove runtime"] } },
32
- { "id": "RB07", "mode": "upgrade", "stage": "before-add", "note": "upgrade: registry lookup fails before the profile is touched", "registryMissing": true,
33
- "expect": { "code": "REGISTRY_LOOKUP_FAILED", "status": "not-needed", "actions": [] } },
34
- { "id": "RB08", "mode": "upgrade", "stage": "add", "note": "upgrade: official CLI fails after writing the new version", "inject": [["add", "after"]],
35
- "expect": { "code": "DSH_PLUGIN_FAILED", "status": "restored", "actions": ["dsh plugin add --save-exact $package@$previous"] } },
36
- { "id": "RB09", "mode": "upgrade", "stage": "definition", "note": "upgrade: new definition is invalid; original exact version comes back", "invalidDefinition": true,
37
- "expect": { "code": "INVALID_DEFINITION", "status": "restored", "actions": ["dsh plugin add --save-exact $package@$previous"] } },
38
- { "id": "RB10", "mode": "upgrade", "stage": "provision", "note": "upgrade: new runtime promoted then failed; original runtime is provisioned again from the restored definition", "inject": [["provision", "after"]],
39
- "expect": { "code": "E_VERIFY", "status": "restored", "actions": ["dsh plugin add --save-exact $package@$previous", "provision runtime"] } },
40
- { "id": "RB11", "mode": "new-install", "stage": "definition", "note": "compensation itself fails: the original error is kept and the residue is reported", "invalidDefinition": true, "inject": [["remove", "before"]],
41
- "expect": { "code": "INVALID_DEFINITION", "status": "failed", "recoveryError": "DSH_PLUGIN_FAILED", "actions": ["dsh plugin remove $package"], "residue": ["dependency", "module", "lockfile"] } },
42
- { "id": "RB12", "mode": "upgrade", "stage": "provision", "note": "upgrade compensation fails: original version cannot be added back", "inject": [["provision", "after"], ["add", "before", 1]],
43
- "expect": { "code": "E_VERIFY", "status": "failed", "recoveryError": "DSH_PLUGIN_FAILED", "actions": ["dsh plugin add --save-exact $package@$previous"], "residue": ["dependency", "module", "lockfile", "runtime:runtime"] } },
44
- { "id": "RB13", "mode": "new-install", "stage": "definition", "note": "a concurrent profile writer is never undone; the lockfile cannot be proven restored", "invalidDefinition": true, "concurrent": true,
45
- "expect": { "code": "INVALID_DEFINITION", "status": "failed", "actions": ["dsh plugin remove $package"], "residue": ["lockfile"] } },
46
- { "id": "RB14", "mode": "new-install", "stage": "provision", "note": "unowned files in a newly claimed runtime are kept and reported, not deleted", "inject": [["provision", "unowned"], ["provision", "after"]],
47
- "expect": { "code": "E_VERIFY", "status": "failed", "actions": ["dsh plugin remove $package", "provision remove runtime"], "residue": ["runtime:runtime"] } },
48
- { "id": "RB15", "mode": "new-install", "stage": "add", "kind": "plugin", "note": "plugin bundle: CLI fails after selecting the bundle; selection and lock come back", "inject": [["add", "after"]],
49
- "expect": { "code": "DSH_PLUGIN_FAILED", "status": "restored", "actions": ["dsh plugin remove $package"] } }
20
+ {
21
+ "id": "RB01",
22
+ "mode": "new-install",
23
+ "stage": "before-add",
24
+ "note": "fixed catalog target is unavailable: refuse before an operation or CLI mutation starts",
25
+ "expect": {
26
+ "code": "NOT_INSTALLABLE",
27
+ "status": "not-started",
28
+ "actions": []
29
+ },
30
+ "targetUnavailable": true
31
+ },
32
+ {
33
+ "id": "RB02",
34
+ "mode": "new-install",
35
+ "stage": "add",
36
+ "note": "official CLI fails before writing",
37
+ "inject": [
38
+ [
39
+ "add",
40
+ "before"
41
+ ]
42
+ ],
43
+ "expect": {
44
+ "code": "DSH_PLUGIN_FAILED",
45
+ "status": "not-needed",
46
+ "actions": []
47
+ }
48
+ },
49
+ {
50
+ "id": "RB03",
51
+ "mode": "new-install",
52
+ "stage": "add",
53
+ "note": "official CLI fails after writing package/lock/module",
54
+ "inject": [
55
+ [
56
+ "add",
57
+ "after"
58
+ ]
59
+ ],
60
+ "expect": {
61
+ "code": "DSH_PLUGIN_FAILED",
62
+ "status": "restored",
63
+ "actions": [
64
+ "dsh plugin remove $package"
65
+ ]
66
+ }
67
+ },
68
+ {
69
+ "id": "RB04",
70
+ "mode": "new-install",
71
+ "stage": "definition",
72
+ "note": "add succeeded, application definition is invalid",
73
+ "invalidDefinition": true,
74
+ "expect": {
75
+ "code": "INVALID_DEFINITION",
76
+ "status": "restored",
77
+ "actions": [
78
+ "dsh plugin remove $package"
79
+ ]
80
+ }
81
+ },
82
+ {
83
+ "id": "RB05",
84
+ "mode": "new-install",
85
+ "stage": "provision",
86
+ "note": "provision fails before promote (runtime untouched)",
87
+ "inject": [
88
+ [
89
+ "provision",
90
+ "before"
91
+ ]
92
+ ],
93
+ "expect": {
94
+ "code": "E_DOWNLOAD",
95
+ "status": "restored",
96
+ "actions": [
97
+ "dsh plugin remove $package"
98
+ ]
99
+ }
100
+ },
101
+ {
102
+ "id": "RB06",
103
+ "mode": "new-install",
104
+ "stage": "provision",
105
+ "note": "provision fails after promoting a newly claimed runtime item",
106
+ "inject": [
107
+ [
108
+ "provision",
109
+ "after"
110
+ ]
111
+ ],
112
+ "expect": {
113
+ "code": "E_VERIFY",
114
+ "status": "restored",
115
+ "actions": [
116
+ "dsh plugin remove $package",
117
+ "provision remove runtime"
118
+ ]
119
+ }
120
+ },
121
+ {
122
+ "id": "RB07",
123
+ "mode": "upgrade",
124
+ "stage": "before-add",
125
+ "note": "fixed catalog target is unavailable: refuse before an operation or CLI mutation starts",
126
+ "expect": {
127
+ "code": "NOT_INSTALLABLE",
128
+ "status": "not-started",
129
+ "actions": []
130
+ },
131
+ "targetUnavailable": true
132
+ },
133
+ {
134
+ "id": "RB08",
135
+ "mode": "upgrade",
136
+ "stage": "add",
137
+ "note": "upgrade: official CLI fails after writing the new version",
138
+ "inject": [
139
+ [
140
+ "add",
141
+ "after"
142
+ ]
143
+ ],
144
+ "expect": {
145
+ "code": "DSH_PLUGIN_FAILED",
146
+ "status": "restored",
147
+ "actions": [
148
+ "dsh plugin add --save-exact $package@$previous"
149
+ ]
150
+ }
151
+ },
152
+ {
153
+ "id": "RB09",
154
+ "mode": "upgrade",
155
+ "stage": "definition",
156
+ "note": "upgrade: new definition is invalid; original exact version comes back",
157
+ "invalidDefinition": true,
158
+ "expect": {
159
+ "code": "INVALID_DEFINITION",
160
+ "status": "restored",
161
+ "actions": [
162
+ "dsh plugin add --save-exact $package@$previous"
163
+ ]
164
+ }
165
+ },
166
+ {
167
+ "id": "RB10",
168
+ "mode": "upgrade",
169
+ "stage": "provision",
170
+ "note": "upgrade: new runtime promoted then failed; original runtime is provisioned again from the restored definition",
171
+ "inject": [
172
+ [
173
+ "provision",
174
+ "after"
175
+ ]
176
+ ],
177
+ "expect": {
178
+ "code": "E_VERIFY",
179
+ "status": "restored",
180
+ "actions": [
181
+ "dsh plugin add --save-exact $package@$previous",
182
+ "provision runtime"
183
+ ]
184
+ }
185
+ },
186
+ {
187
+ "id": "RB11",
188
+ "mode": "new-install",
189
+ "stage": "definition",
190
+ "note": "compensation itself fails: the original error is kept and the residue is reported",
191
+ "invalidDefinition": true,
192
+ "inject": [
193
+ [
194
+ "remove",
195
+ "before"
196
+ ]
197
+ ],
198
+ "expect": {
199
+ "code": "INVALID_DEFINITION",
200
+ "status": "failed",
201
+ "recoveryError": "DSH_PLUGIN_FAILED",
202
+ "actions": [
203
+ "dsh plugin remove $package"
204
+ ],
205
+ "residue": [
206
+ "dependency",
207
+ "module",
208
+ "lockfile"
209
+ ]
210
+ }
211
+ },
212
+ {
213
+ "id": "RB12",
214
+ "mode": "upgrade",
215
+ "stage": "provision",
216
+ "note": "upgrade compensation fails: original version cannot be added back",
217
+ "inject": [
218
+ [
219
+ "provision",
220
+ "after"
221
+ ],
222
+ [
223
+ "add",
224
+ "before",
225
+ 1
226
+ ]
227
+ ],
228
+ "expect": {
229
+ "code": "E_VERIFY",
230
+ "status": "failed",
231
+ "recoveryError": "DSH_PLUGIN_FAILED",
232
+ "actions": [
233
+ "dsh plugin add --save-exact $package@$previous"
234
+ ],
235
+ "residue": [
236
+ "dependency",
237
+ "module",
238
+ "lockfile",
239
+ "runtime:runtime"
240
+ ]
241
+ }
242
+ },
243
+ {
244
+ "id": "RB13",
245
+ "mode": "new-install",
246
+ "stage": "definition",
247
+ "note": "a concurrent profile writer is never undone; the lockfile cannot be proven restored",
248
+ "invalidDefinition": true,
249
+ "concurrent": true,
250
+ "expect": {
251
+ "code": "INVALID_DEFINITION",
252
+ "status": "failed",
253
+ "actions": [
254
+ "dsh plugin remove $package"
255
+ ],
256
+ "residue": [
257
+ "lockfile"
258
+ ]
259
+ }
260
+ },
261
+ {
262
+ "id": "RB14",
263
+ "mode": "new-install",
264
+ "stage": "provision",
265
+ "note": "unowned files in a newly claimed runtime are kept and reported, not deleted",
266
+ "inject": [
267
+ [
268
+ "provision",
269
+ "unowned"
270
+ ],
271
+ [
272
+ "provision",
273
+ "after"
274
+ ]
275
+ ],
276
+ "expect": {
277
+ "code": "E_VERIFY",
278
+ "status": "failed",
279
+ "actions": [
280
+ "dsh plugin remove $package",
281
+ "provision remove runtime"
282
+ ],
283
+ "residue": [
284
+ "runtime:runtime"
285
+ ]
286
+ }
287
+ },
288
+ {
289
+ "id": "RB15",
290
+ "mode": "new-install",
291
+ "stage": "add",
292
+ "kind": "plugin",
293
+ "note": "plugin bundle: CLI fails after selecting the bundle; selection and lock come back",
294
+ "inject": [
295
+ [
296
+ "add",
297
+ "after"
298
+ ]
299
+ ],
300
+ "expect": {
301
+ "code": "DSH_PLUGIN_FAILED",
302
+ "status": "restored",
303
+ "actions": [
304
+ "dsh plugin remove $package"
305
+ ]
306
+ }
307
+ }
50
308
  ],
51
309
  "presentation": [
52
- { "id": "RP01", "note": "restored new install is a failure that left nothing behind", "recovery": { "status": "restored", "mode": "new-install", "stage": "definition", "previousVersion": null, "actions": ["dsh plugin remove @hanamesh-fixture/install-target-app"], "checks": [], "residue": [] }, "expect": { "installed": false, "restored": true } },
53
- { "id": "RP02", "note": "restored upgrade names the original version", "recovery": { "status": "restored", "mode": "upgrade", "stage": "provision", "previousVersion": "0.3.0", "actions": [], "checks": [], "residue": [] }, "expect": { "installed": false, "restored": true, "version": "0.3.0" } },
54
- { "id": "RP03", "note": "nothing was changed before the failure", "recovery": { "status": "not-needed", "mode": "new-install", "stage": "before-add", "previousVersion": null, "actions": [], "checks": [], "residue": [] }, "expect": { "installed": false, "restored": true } },
55
- { "id": "RP04", "note": "compensation failed: never shown as success or as rolled back", "recovery": { "status": "failed", "mode": "upgrade", "stage": "provision", "previousVersion": "0.3.0", "actions": [], "checks": [], "residue": [{ "item": "module", "reason": "NOT_RESTORED" }], "error": { "code": "DSH_PLUGIN_FAILED" }, "originals": { "dir": "/data/library-recovery/op", "files": ["package.json"] } }, "expect": { "installed": false, "restored": false, "residue": ["module"] } },
56
- { "id": "RP05", "note": "an rc.9 provider sends no recovery: unknown, not restored", "recovery": null, "expect": { "installed": false, "restored": false } }
310
+ {
311
+ "id": "RP01",
312
+ "note": "restored new install is a failure that left nothing behind",
313
+ "recovery": {
314
+ "status": "restored",
315
+ "mode": "new-install",
316
+ "stage": "definition",
317
+ "previousVersion": null,
318
+ "actions": [
319
+ "dsh plugin remove @hanamesh-fixture/install-target-app"
320
+ ],
321
+ "checks": [],
322
+ "residue": []
323
+ },
324
+ "expect": {
325
+ "installed": false,
326
+ "restored": true
327
+ }
328
+ },
329
+ {
330
+ "id": "RP02",
331
+ "note": "restored upgrade names the original version",
332
+ "recovery": {
333
+ "status": "restored",
334
+ "mode": "upgrade",
335
+ "stage": "provision",
336
+ "previousVersion": "0.3.0",
337
+ "actions": [],
338
+ "checks": [],
339
+ "residue": []
340
+ },
341
+ "expect": {
342
+ "installed": false,
343
+ "restored": true,
344
+ "version": "0.3.0"
345
+ }
346
+ },
347
+ {
348
+ "id": "RP03",
349
+ "note": "nothing was changed before the failure",
350
+ "recovery": {
351
+ "status": "not-needed",
352
+ "mode": "new-install",
353
+ "stage": "before-add",
354
+ "previousVersion": null,
355
+ "actions": [],
356
+ "checks": [],
357
+ "residue": []
358
+ },
359
+ "expect": {
360
+ "installed": false,
361
+ "restored": true
362
+ }
363
+ },
364
+ {
365
+ "id": "RP04",
366
+ "note": "compensation failed: never shown as success or as rolled back",
367
+ "recovery": {
368
+ "status": "failed",
369
+ "mode": "upgrade",
370
+ "stage": "provision",
371
+ "previousVersion": "0.3.0",
372
+ "actions": [],
373
+ "checks": [],
374
+ "residue": [
375
+ {
376
+ "item": "module",
377
+ "reason": "NOT_RESTORED"
378
+ }
379
+ ],
380
+ "error": {
381
+ "code": "DSH_PLUGIN_FAILED"
382
+ },
383
+ "originals": {
384
+ "dir": "/data/library-recovery/op",
385
+ "files": [
386
+ "package.json"
387
+ ]
388
+ }
389
+ },
390
+ "expect": {
391
+ "installed": false,
392
+ "restored": false,
393
+ "residue": [
394
+ "module"
395
+ ]
396
+ }
397
+ },
398
+ {
399
+ "id": "RP05",
400
+ "note": "an rc.9 provider sends no recovery: unknown, not restored",
401
+ "recovery": null,
402
+ "expect": {
403
+ "installed": false,
404
+ "restored": false
405
+ }
406
+ }
57
407
  ]
58
408
  }
@@ -19,7 +19,7 @@ export function createReferenceConsumer(options={}){
19
19
  navigate(workspaceUrl,target){
20
20
  const fields={...target,...(contractVersion===undefined?{}:{contractVersion})};
21
21
  if(validate(schema,fields).length)return{ok:false,code:'INVALID_INSTALL_TARGET'};
22
- const url=new URL(workspaceUrl);url.hash=contract.fragmentPrefix.slice(1)+new URLSearchParams(fields);
22
+ const url=new URL(workspaceUrl);url.hash=contract.fragmentPrefix.slice(1)+new URLSearchParams({...fields,...(fields.installTarget?{installTarget:JSON.stringify(fields.installTarget)}:{})});
23
23
  return{ok:true,url:url.href};
24
24
  },
25
25
  /** rc.10: present a library.install-failed event; only a schema-valid restored/not-needed recovery counts as restored. */
@@ -1,7 +1,8 @@
1
1
  /** Install-target contract conformance suite (hanamesh.install-target v1). */
2
2
  export interface ContractCaseResult {id:string;semantic:string;note:string;outcome:'pass'|'fail'|'skipped';detail?:string;}
3
3
  export interface ContractSuiteReport {suite:'provider'|'consumer'|'chain';contract:'hanamesh.install-target';contractVersion:string;total:number;pass:number;fail:number;skipped:number;results:ContractCaseResult[];[key:string]:unknown;}
4
- export interface InstallTargetFields {itemId?:string;packageName?:string;contractVersion?:string;}
4
+ import type {PackageInstallTarget} from '../client-ui.js';
5
+ export interface InstallTargetFields {itemId?:string;packageName?:string;contractVersion?:string;installTarget?:PackageInstallTarget;}
5
6
  export interface ProviderDeclaration {contract:string;contractVersion:string;supported:string[];schema?:string;}
6
7
  /** A caller handing catalog identities to the market by normal navigation of its own workspace. */
7
8
  export interface InstallTargetConsumer {
@@ -43,6 +43,7 @@ export function validate(schema,value,ref='#/$defs/target'){
43
43
  }
44
44
  if(type==='object'){
45
45
  for(const key of node.required??[])if(!(key in value))errors.push(`${path}: missing ${key}`);
46
+ for(const [key,required] of Object.entries(node.dependentRequired??{}))if(key in value)for(const field of required)if(!(field in value))errors.push(`${path}: ${key} requires ${field}`);
46
47
  for(const [key,child] of Object.entries(node.properties??{}))if(key in value)check(child,value[key],`${path}.${key}`);
47
48
  if(node.additionalProperties===false)for(const key of Object.keys(value))if(!(key in (node.properties??{})))errors.push(`${path}: field ${key} is not part of the contract`);
48
49
  }
@@ -95,7 +96,7 @@ export async function sourceRecoveryProfile({recovery},item={}){
95
96
  if(item.concurrent)profile.concurrentAdd('@fixture/concurrent','1.0.0');
96
97
  // Named single-source contract fixture; not production registry policy.
97
98
  const installer=emit=>createLibraryInstaller({registryRules:async()=>({registryPlan:(_requested,configured)=>[configured.registry],attributeFailure:()=>'other'}),pluginManager:()=>({registries:async()=>({registry:'https://source-fixture.invalid/',fallbackRegistries:[],resolved:'https://source-fixture.invalid/'})}),profileDir:profile.profileDir,profileName:profile.profileName,dataRoot:profile.dataRoot,nodeBinary:'/source-fixture/bin/node',dshBin:'/source-fixture/dsh/lib/bin.js',
98
- fetchImpl:profile.fetchImpl(item.registryMissing?{}:{[app.packageName]:app.latest,[plugin.packageName]:plugin.latest}),spawn:profile.spawn,provision:profile.provision,remove:profile.remove,ledger:profile.ledger,emit});
99
+ fetchImpl:profile.fetchImpl({[app.packageName]:app.latest,[plugin.packageName]:plugin.latest}),spawn:profile.spawn,provision:profile.provision,remove:profile.remove,ledger:profile.ledger,emit});
99
100
  return{profile,target,installer};
100
101
  }
101
102
  /** Independent readback of the SOURCE profile: exact package.json/lockfile text, resolved module version, runtime ledger without timestamps. */
@@ -105,13 +106,29 @@ async function profileState({profile},name,appId){
105
106
  const ledger=Object.fromEntries(Object.entries((await profile.ledger(join(profile.dataRoot,'runtimes',appId))).items).map(([id,{installedAt:_,...entry}])=>[id,entry]));
106
107
  return{manifest:await read(join(profile.profileDir,'package.json')),lock:await read(join(profile.profileDir,'pnpm-lock.yaml')),module:module===null?null:JSON.parse(module).version,ledger};
107
108
  }
108
- /** Runs one install-failure case through the real service and installer on a fresh SOURCE profile; returns the failure event. */
109
+ /** Runs a real fixed-target failure on a fresh SOURCE profile, or verifies admission refusal before installation. */
109
110
  export async function runRecoveryCase(contract,item,{check=true}={}){
110
111
  const {schema,recovery}=contract;const source=await sourceRecoveryProfile(contract,item);const {profile,target}=source;const appId=recovery.application.appId;
111
- const host=await startProvider(contract,{profile,installer:source.installer});
112
+ let catalogPath=contract.catalogPath;
113
+ if(item.targetUnavailable){
114
+ const catalog=await json(new URL(contract.provider.catalog,FIXTURES)),row=catalog.items.find(row=>row.packageName===target.packageName);
115
+ row.oneClickInstall={allowed:false,reason:'npm-unchecked'};
116
+ for(const method of row.installMethods){delete method.target;method.targetUnavailableReason='npm-unchecked';}
117
+ catalogPath=join(profile.profileDir,'unavailable-catalog.json');await writeFile(catalogPath,JSON.stringify(catalog));
118
+ }
119
+ const host=await startProvider({...contract,catalogPath},{profile,installer:source.installer});
112
120
  try{
113
121
  const sentinel=join(profile.dataRoot,'principal',appId,'local','data-v1','single','user.db');await mkdir(dirname(sentinel),{recursive:true});await writeFile(sentinel,'keep');
114
122
  const before=await profileState(source,target.packageName,appId);
123
+ if(item.targetUnavailable){
124
+ const calls=profile.calls.length;let refused;
125
+ try{await host.service.install({packageName:target.packageName});}catch(error){refused=error;}
126
+ expectEqual(refused?.code,item.expect.code,'unresolved fixed target is refused');
127
+ expectEqual(host.installs,[],'no installer call');expectEqual(profile.calls.length,calls,'no CLI mutation');
128
+ expectEqual(host.service.events().events,[],'no install operation');expectEqual(await profileState(source,target.packageName,appId),before,'profile unchanged');
129
+ expectEqual(await readFile(sentinel,'utf8'),'keep','application data unchanged');
130
+ return{type:'catalog-refused',code:refused.code};
131
+ }
115
132
  const started=await host.service.install({packageName:target.packageName});await host.settled();
116
133
  const events=host.service.events().events.filter(event=>event.operationId===started.operationId),failed=events.find(event=>event.type==='library.install-failed');
117
134
  if(!failed||events.some(event=>event.type==='library.install-done'))throw new Error('expected one failed operation, got '+JSON.stringify(events.map(event=>event.type)));
@@ -122,7 +139,7 @@ export async function runRecoveryCase(contract,item,{check=true}={}){
122
139
  expectEqual(result.actions,item.expect.actions.map(fill),'compensation actions');
123
140
  expectEqual(result.error?.code??null,item.expect.recoveryError??null,'compensation error');
124
141
  expectEqual([...new Set(result.residue.map(row=>row.item))].sort(),[...(item.expect.residue??[])].sort(),'residue');
125
- expectEqual(profile.calls.filter(call=>call[0]==='add'&&call.at(-1)===`${target.packageName}@${target.latest}`).length,item.registryMissing?0:1,'add attempts (no automatic retry)');
142
+ expectEqual(profile.calls.filter(call=>call[0]==='add'&&call.at(-1)===`${target.packageName}@${target.latest}`).length,1,'add attempts (no automatic retry)');
126
143
  expectEqual(await readFile(sentinel,'utf8'),'keep','application data under dataRoot');
127
144
  const after=await profileState(source,target.packageName,appId);
128
145
  if(result.status==='failed'){
@@ -272,7 +289,8 @@ export async function runConsumerSuite(consumer){
272
289
  const prefix=contract.contract.fragmentPrefix;if(!url.hash.startsWith(prefix))throw new Error('fragment does not start with '+prefix);
273
290
  const params=new URLSearchParams(url.hash.slice(prefix.length)),keys=[...params.keys()];
274
291
  if(new Set(keys).size!==keys.length)throw new Error('duplicate fragment fields');
275
- const fields=Object.fromEntries(params),errors=validate(schema,fields);if(errors.length)throw new Error(errors.join('; '));
292
+ const fields=Object.fromEntries(params);if(fields.installTarget!==undefined)fields.installTarget=JSON.parse(fields.installTarget);
293
+ const errors=validate(schema,fields);if(errors.length)throw new Error(errors.join('; '));
276
294
  expectEqual(fields,{...item.target,...(declared===undefined?{}:{contractVersion:declared})},'fragment fields');
277
295
  });
278
296
  for(const item of cases.handshake)await record(item.id,'handshake',item.note,async()=>{
@@ -306,7 +324,9 @@ export async function runChainSuite(consumer){
306
324
  const result=await consumer.navigate(workspaceUrl,item.target);if(result?.ok!==true){if(item.optional)return'skipped';throw new Error('consumer refused');}
307
325
  await client.navigate(new URL(result.url).hash);
308
326
  if(!client.button('确认安装'))throw new Error('market did not reach confirmation: '+client.text().slice(0,400));
309
- const resolved=await client.module.openInstallTarget(Object.fromEntries(new URLSearchParams(new URL(result.url).hash.slice(contract.contract.fragmentPrefix.length))));
327
+ const fields=Object.fromEntries(new URLSearchParams(new URL(result.url).hash.slice(contract.contract.fragmentPrefix.length)));
328
+ if(fields.installTarget!==undefined)fields.installTarget=JSON.parse(fields.installTarget);
329
+ const resolved=await client.module.openInstallTarget(fields);
310
330
  expectEqual(resolved.itemId,item.resolves,'resolved catalog item');expectEqual([host.posts.length,host.installs.length],[0,0],'installs');
311
331
  await client.click('取消');
312
332
  });
@@ -12,7 +12,7 @@ export interface InstallationStatusRequest {contractVersion:'1';consent:'read-in
12
12
  export interface InstallationObservation {
13
13
  channel:'hanamesh'|'dsh'|'claude'|'codex';
14
14
  scope:{id:'current-runtime'|'other-runtime'|'agent-cli'|'desktop-app';kind:'responding-runtime'|'unobserved-runtime'|'official-user-profile'|'desktop-app';label:string};
15
- status:'installed'|'not-installed'|'incomplete'|'unknown';version:string|null;versionKind:'core'|'agent-distribution';
15
+ status:'installed'|'not-installed'|'incomplete'|'unknown';version:string|null;versionKind:'suite-bundle'|'agent-distribution';
16
16
  components:readonly {name:string;version:string|null;active?:boolean;status?:string}[];
17
17
  identity:{pluginName:string;marketplace:string}|null;
18
18
  reason:{code:string;message:string;action:string}|null;
@@ -1,4 +1,5 @@
1
1
  import semver from 'semver';
2
+ import {packageInstallTarget,catalogInstallationAllowed} from './github-target.js';
2
3
  import { readFile } from 'node:fs/promises';
3
4
  import { AppHostError, requireCondition } from '../errors.js';
4
5
 
@@ -107,9 +108,9 @@ function suitePresentation(value,item){
107
108
  }
108
109
 
109
110
  function validateHanaItem(item,maxSourceLinks){
110
- const optionalText={description:5000,latestVersion:64,license:80};
111
+ const optionalText={description:5000};
111
112
  const source=item?.source;
112
- requireCondition(own(item,['id','itemId','packageName','name','displayName','summary','catalogKind','source','description','homepage','latestVersion','license','categories','keywords','repository','package','publisher','updatedAt','official','suite'])&&
113
+ requireCondition(own(item,['id','itemId','packageName','name','displayName','summary','catalogKind','source','description','homepage','categories','keywords','repository','package','publisher','updatedAt','official','suite','installMethods','oneClickInstall'])&&
113
114
  (item.official===undefined||typeof item.official==='boolean')&&(item.suite===undefined||suitePresentation(item.suite,item))&&
114
115
  plain(item.id)&&item.id===item.itemId&&
115
116
  textWithin(item.name,160,true)&&textWithin(item.displayName,120,true)&&textWithin(item.summary,1000)&&
@@ -129,6 +130,22 @@ function validateHanaItem(item,maxSourceLinks){
129
130
  own(link,['sourceId','sourceRef','sourceUrl','firstSeenAt','lastSeenAt'])&&textWithin(link.sourceId,120,true)&&textWithin(link.sourceRef,512,true)&&
130
131
  displayUrl(link.sourceUrl)&&timestamp(link.firstSeenAt)&&timestamp(link.lastSeenAt)),
131
132
  'INVALID_CATALOG_PAGE','HanaMesh catalog item does not match its public schema.');
133
+ requireCondition(item.oneClickInstall===undefined||(own(item.oneClickInstall,['allowed','reason'])&&typeof item.oneClickInstall.allowed==='boolean'&&
134
+ (item.oneClickInstall.reason===null||textWithin(item.oneClickInstall.reason,160,true))),
135
+ 'INVALID_CATALOG_PAGE','Invalid catalog installation permission.');
136
+ if(item.installMethods!==undefined){
137
+ requireCondition(Array.isArray(item.installMethods),'INVALID_CATALOG_PAGE','Invalid catalog installation methods.');
138
+ for(const method of item.installMethods){
139
+ requireCondition(own(method,['channel','kind','spec','command','allowBuild','sourceId','sourceRef','npmOwnership','target','targetUnavailableReason'])&&
140
+ ['hanamesh','dsh','claude','codex'].includes(method.channel)&&['npm','github','agent'].includes(method.kind)&&
141
+ textWithin(method.spec,4096,true)&&(method.command===null||textWithin(method.command,4096,true))&&
142
+ textWithin(method.sourceId,120,true)&&textWithin(method.sourceRef,512,true)&&Array.isArray(method.allowBuild)&&
143
+ method.allowBuild.every(entry=>textWithin(entry,2048,true)),
144
+ 'INVALID_CATALOG_PAGE','Invalid catalog installation method.');
145
+ if(method.targetUnavailableReason!==undefined)requireCondition(textWithin(method.targetUnavailableReason,160,true),'INVALID_CATALOG_PAGE','Invalid unavailable installation target reason.');
146
+ if(method.target!==undefined){try{packageInstallTarget(method.target);}catch{throw new AppHostError('INVALID_CATALOG_PAGE','Invalid fixed catalog package target.');}}
147
+ }
148
+ }
132
149
  }
133
150
 
134
151
  function validateItem(item){
@@ -156,11 +173,11 @@ export function validateProviderPage(value,{maxItems=200,maxSourceLinks=MAX_SOUR
156
173
  return structuredClone(value);
157
174
  }
158
175
 
159
- export function isApplication(item){return Boolean(item?.categories?.includes('hanamesh-app')&&item?.package?.registry==='npm');}
176
+ export function isApplication(item){return Boolean(item?.categories?.includes('hanamesh-app')&&(item?.package?.registry==='npm'||item?.installTarget));}
160
177
  /** rc.28 market: an npm-backed entry that is not a HanaMesh application installs as a plain DSH plugin (`dsh plugin add`). */
161
- export function isPlugin(item){return Boolean(item?.package?.registry==='npm'&&!isApplication(item));}
178
+ export function isPlugin(item){return Boolean((item?.package?.registry==='npm'||item?.installTarget)&&!isApplication(item));}
162
179
  /** `application` | `plugin` | `listing` (repository-only / no npm package: browse only). */
163
- export function itemKind(item){return isApplication(item)?'application':isPlugin(item)?'plugin':'listing';}
180
+ export function itemKind(item){return !catalogInstallationAllowed(item)?'listing':isApplication(item)?'application':isPlugin(item)?'plugin':'listing';}
164
181
 
165
182
  /** Exact semver compare without a dependency: numeric core, then a prerelease sorts below its release; unparsable → 0. */
166
183
  export function compareVersions(a,b){
@@ -172,8 +189,8 @@ export function compareVersions(a,b){
172
189
  if(np&&nq){if(+p!==+q)return +p<+q?-1:1;}else if(np!==nq)return np?-1:1;else if(p!==q)return p<q?-1:1;}
173
190
  return 0;
174
191
  }
175
- /** `latestVersion` of the catalog entry is strictly newer than the installed version. */
176
- export function upgradeAvailable(latestVersion,installedVersion){return compareVersions(latestVersion,installedVersion)>0;}
192
+ /** The reviewed target version is strictly newer than the installed version. */
193
+ export function upgradeAvailable(targetVersion,installedVersion){return compareVersions(targetVersion,installedVersion)>0;}
177
194
 
178
195
  async function responseJson(response,label,maxBytes=MAX_BYTES,providerErrors=false){
179
196
  if(!providerErrors)requireCondition(response.ok,'CATALOG_HTTP_ERROR',`${label} returned HTTP ${response.status}.`,{status:response.status},502);