revamp manifest types

This commit is contained in:
Matt Hill
2024-03-21 17:21:37 -06:00
parent ab836c6922
commit 66b0108c51
43 changed files with 279 additions and 834 deletions

View File

@@ -9,9 +9,7 @@
<ion-content class="ion-padding-top with-widgets">
<ng-container *ngIf="pkg$ | async as pkg">
<ion-item-group
*ngIf="pkg['state-info'].state === 'installed' && pkg['state-info'].manifest as manifest"
>
<ion-item-group *ngIf="pkg['state-info'].state === 'installed'">
<!-- ** standard actions ** -->
<ion-item-divider>Standard Actions</ion-item-divider>
<app-actions-item
@@ -24,11 +22,11 @@
></app-actions-item>
<!-- ** specific actions ** -->
<ion-item-divider *ngIf="!(manifest.actions | empty)">
Actions for {{ manifest.title }}
<ion-item-divider *ngIf="!(pkg.actions | empty)">
Actions for {{ pkg['state-info'].manifest.title }}
</ion-item-divider>
<app-actions-item
*ngFor="let action of manifest.actions | keyvalue: asIsOrder"
*ngFor="let action of pkg.actions | keyvalue: asIsOrder"
[action]="{
name: action.value.name,
description: action.value.description,

View File

@@ -9,19 +9,17 @@ import {
} from '@ionic/angular'
import { PatchDB } from 'patch-db-client'
import {
Action,
DataModel,
InstalledState,
PackageDataEntry,
PackageMainStatus,
StateInfo,
Status,
} from 'src/app/services/patch-db/data-model'
import { GenericFormPage } from 'src/app/modals/generic-form/generic-form.page'
import { isEmptyObject, ErrorToastService, getPkgId } from '@start9labs/shared'
import { ActionSuccessPage } from 'src/app/modals/action-success/action-success.page'
import { hasCurrentDeps } from 'src/app/util/has-deps'
import { getManifest } from 'src/app/util/get-package-data'
import { getAllPackages, getManifest } from 'src/app/util/get-package-data'
import { ActionMetadata } from '@start9labs/start-sdk/cjs/sdk/lib/types'
@Component({
selector: 'app-actions',
@@ -44,19 +42,22 @@ export class AppActionsPage {
private readonly patch: PatchDB<DataModel>,
) {}
async handleAction(status: Status, action: { key: string; value: Action }) {
async handleAction(
status: Status,
action: { key: string; value: ActionMetadata },
) {
if (
status &&
(action.value['allowed-statuses'] as PackageMainStatus[]).includes(
status.main.status,
action.value.allowedStatuses.includes(
status.main.status, // @TODO
)
) {
if (!isEmptyObject(action.value['input-spec'] || {})) {
if (!isEmptyObject(action.value.input || {})) {
const modal = await this.modalCtrl.create({
component: GenericFormPage,
componentProps: {
title: action.value.name,
spec: action.value['input-spec'],
spec: action.value.input,
buttons: [
{
text: 'Execute',
@@ -92,7 +93,7 @@ export class AppActionsPage {
await alert.present()
}
} else {
const statuses = [...action.value['allowed-statuses']]
const statuses = [...action.value.allowedStatuses] // @TODO
const last = statuses.pop()
let statusesStr = statuses.join(', ')
let error = ''
@@ -126,7 +127,7 @@ export class AppActionsPage {
alerts.uninstall ||
`Uninstalling ${title} will permanently delete its data`
if (hasCurrentDeps(pkg)) {
if (hasCurrentDeps(this.pkgId, await getAllPackages(this.patch))) {
message = `${message}. Services that depend on ${title} will no longer work properly and may crash`
}

View File

@@ -3,7 +3,12 @@ import { CommonModule } from '@angular/common'
import { Routes, RouterModule } from '@angular/router'
import { IonicModule } from '@ionic/angular'
import { AppShowPage } from './app-show.page'
import { EmverPipesModule, ResponsiveColModule } from '@start9labs/shared'
import {
EmptyPipe,
EmverPipesModule,
ResponsiveColModule,
SharedPipesModule,
} from '@start9labs/shared'
import { StatusComponentModule } from 'src/app/components/status/status.component.module'
import { AppConfigPageModule } from 'src/app/modals/app-config/app-config.module'
import { LaunchablePipeModule } from 'src/app/pipes/launchable/launchable.module'
@@ -52,6 +57,7 @@ const routes: Routes = [
UiPipeModule,
ResponsiveColModule,
StatusComponentModule,
SharedPipesModule,
],
})
export class AppShowPageModule {}

View File

@@ -23,8 +23,8 @@
>
<!-- ** health checks ** -->
<app-show-health-checks
*ngIf="status.primary === 'running'"
[manifest]="pkg['state-info'].manifest"
*ngIf="pkg.status.main.status === 'running'"
[healthChecks]="pkg.status.main.health"
></app-show-health-checks>
<!-- ** dependencies ** -->
<app-show-dependencies

View File

@@ -4,7 +4,6 @@ import { PatchDB } from 'patch-db-client'
import {
DataModel,
InstallingState,
Manifest,
PackageDataEntry,
UpdatingState,
} from 'src/app/services/patch-db/data-model'
@@ -27,6 +26,7 @@ import {
isRestoring,
isUpdating,
} from 'src/app/util/get-package-data'
import { Manifest } from '@start9labs/marketplace'
export interface DependencyInfo {
id: string
@@ -97,6 +97,7 @@ export class AppShowPage {
depErrors: PkgDependencyErrors,
): DependencyInfo {
const { errorText, fixText, fixAction } = this.getDepErrors(
pkg,
manifest,
depId,
depErrors,
@@ -106,7 +107,7 @@ export class AppShowPage {
return {
id: depId,
version: manifest.dependencies[depId].version, // do we want this version range?
version: pkg['current-dependencies'][depId].versionRange, // @TODO do we want this version range?
title: depInfo?.title || depId,
icon: depInfo?.icon || '',
errorText: errorText
@@ -119,6 +120,7 @@ export class AppShowPage {
}
private getDepErrors(
pkg: PackageDataEntry,
manifest: Manifest,
depId: string,
depErrors: PkgDependencyErrors,
@@ -133,15 +135,15 @@ export class AppShowPage {
if (depError.type === DependencyErrorType.NotInstalled) {
errorText = 'Not installed'
fixText = 'Install'
fixAction = () => this.fixDep(manifest, 'install', depId)
fixAction = () => this.fixDep(pkg, manifest, 'install', depId)
} else if (depError.type === DependencyErrorType.IncorrectVersion) {
errorText = 'Incorrect version'
fixText = 'Update'
fixAction = () => this.fixDep(manifest, 'update', depId)
fixAction = () => this.fixDep(pkg, manifest, 'update', depId)
} else if (depError.type === DependencyErrorType.ConfigUnsatisfied) {
errorText = 'Config not satisfied'
fixText = 'Auto config'
fixAction = () => this.fixDep(manifest, 'configure', depId)
fixAction = () => this.fixDep(pkg, manifest, 'configure', depId)
} else if (depError.type === DependencyErrorType.NotRunning) {
errorText = 'Not running'
fixText = 'Start'
@@ -160,6 +162,7 @@ export class AppShowPage {
}
private async fixDep(
pkg: PackageDataEntry,
pkgManifest: Manifest,
action: 'install' | 'update' | 'configure',
id: string,
@@ -167,22 +170,21 @@ export class AppShowPage {
switch (action) {
case 'install':
case 'update':
return this.installDep(pkgManifest, id)
return this.installDep(pkg, pkgManifest, id)
case 'configure':
return this.configureDep(pkgManifest, id)
}
}
private async installDep(
pkg: PackageDataEntry,
pkgManifest: Manifest,
depId: string,
): Promise<void> {
const version = pkgManifest.dependencies[depId].version
const dependentInfo: DependentInfo = {
id: pkgManifest.id,
title: pkgManifest.title,
version,
version: pkg['current-dependencies'][depId].versionRange,
}
const navigationExtras: NavigationExtras = {
state: { dependentInfo },

View File

@@ -3,7 +3,7 @@ import { ModalController, ToastController } from '@ionic/angular'
import { copyToClipboard, MarkdownComponent } from '@start9labs/shared'
import { from } from 'rxjs'
import { ApiService } from 'src/app/services/api/embassy-api.service'
import { Manifest } from 'src/app/services/patch-db/data-model'
import { Manifest } from '@start9labs/marketplace'
@Component({
selector: 'app-show-additional',

View File

@@ -1,91 +1,81 @@
<ng-container
*ngIf="manifest | toHealthChecks | async | keyvalue: asIsOrder as checks"
>
<ng-container *ngIf="checks.length">
<ion-item-divider>Health Checks</ion-item-divider>
<!-- connected -->
<ng-container *ngIf="connected$ | async; else disconnected">
<ion-item *ngFor="let health of checks">
<!-- result -->
<ng-container *ngIf="health.value?.result as result; else noResult">
<ion-spinner
*ngIf="isLoading(result)"
class="icon-spinner"
color="primary"
slot="start"
></ion-spinner>
<ion-icon
*ngIf="result === HealthResult.Success"
slot="start"
name="checkmark"
color="success"
></ion-icon>
<ion-icon
*ngIf="result === HealthResult.Failure"
slot="start"
name="warning-outline"
color="warning"
></ion-icon>
<ion-icon
*ngIf="result === HealthResult.Disabled"
slot="start"
name="remove"
color="dark"
></ion-icon>
<ion-label>
<h2 class="bold">
{{ manifest['health-checks'][health.key].name }}
</h2>
<ion-text [color]="result | healthColor">
<p>
<span *ngIf="isReady(result)">{{ result | titlecase }}</span>
<span *ngIf="result === HealthResult.Starting">...</span>
<span *ngIf="result === HealthResult.Failure">
{{ $any(health.value).error }}
</span>
<span *ngIf="result === HealthResult.Loading">
{{ $any(health.value).message }}
</span>
<span
*ngIf="
result === HealthResult.Success &&
manifest['health-checks'][health.key]['success-message']
"
>
:
{{ manifest['health-checks'][health.key]['success-message'] }}
</span>
</p>
</ion-text>
</ion-label>
</ng-container>
<!-- no result -->
<ng-template #noResult>
<ion-spinner
class="icon-spinner"
color="dark"
slot="start"
></ion-spinner>
<ion-label>
<h2 class="bold">
{{ manifest['health-checks'][health.key].name }}
</h2>
<p class="primary">Awaiting result...</p>
</ion-label>
</ng-template>
</ion-item>
</ng-container>
<!-- disconnected -->
<ng-template #disconnected>
<ion-item *ngFor="let health of checks">
<ion-avatar slot="start">
<ion-skeleton-text class="avatar"></ion-skeleton-text>
</ion-avatar>
<ng-container *ngIf="!(healthChecks | empty)">
<ion-item-divider>Health Checks</ion-item-divider>
<!-- connected -->
<ng-container *ngIf="connected$ | async; else disconnected">
<ion-item *ngFor="let check of healthChecks | keyvalue">
<!-- result -->
<ng-container *ngIf="check.value.result as result; else noResult">
<ion-spinner
*ngIf="isLoading(result)"
class="icon-spinner"
color="primary"
slot="start"
></ion-spinner>
<ion-icon
*ngIf="result === 'success'"
slot="start"
name="checkmark"
color="success"
></ion-icon>
<ion-icon
*ngIf="result === 'failure'"
slot="start"
name="warning-outline"
color="warning"
></ion-icon>
<ion-icon
*ngIf="result === 'disabled'"
slot="start"
name="remove"
color="dark"
></ion-icon>
<ion-label>
<ion-skeleton-text class="label"></ion-skeleton-text>
<ion-skeleton-text class="description"></ion-skeleton-text>
<h2 class="bold">
{{ check.value.name }}
</h2>
<ion-text [color]="result | healthColor">
<p>
<span *ngIf="isReady(result)">{{ result | titlecase }}</span>
<span *ngIf="result === 'starting'">...</span>
<span
*ngIf="
check.value.result === 'failure' ||
check.value.result === 'loading' ||
check.value.result === 'success'
"
>
{{ check.value.message }}
</span>
</p>
</ion-text>
</ion-label>
</ion-item>
</ng-template>
</ng-container>
<!-- no result -->
<ng-template #noResult>
<ion-spinner
class="icon-spinner"
color="dark"
slot="start"
></ion-spinner>
<ion-label>
<h2 class="bold">
{{ check.value.name }}
</h2>
<p class="primary">Awaiting result...</p>
</ion-label>
</ng-template>
</ion-item>
</ng-container>
<!-- disconnected -->
<ng-template #disconnected>
<ion-item *ngFor="let health; in: checks">
<ion-avatar slot="start">
<ion-skeleton-text class="avatar"></ion-skeleton-text>
</ion-avatar>
<ion-label>
<ion-skeleton-text class="label"></ion-skeleton-text>
<ion-skeleton-text class="description"></ion-skeleton-text>
</ion-label>
</ion-item>
</ng-template>
</ng-container>

View File

@@ -1,6 +1,11 @@
import { ChangeDetectionStrategy, Component, Input } from '@angular/core'
import { ConnectionService } from 'src/app/services/connection.service'
import { HealthResult, Manifest } from 'src/app/services/patch-db/data-model'
import {
HealthCheckResult,
HealthResult,
MainStatus,
} from 'src/app/services/patch-db/data-model'
import { Manifest } from '@start9labs/marketplace'
@Component({
selector: 'app-show-health-checks',
@@ -10,9 +15,7 @@ import { HealthResult, Manifest } from 'src/app/services/patch-db/data-model'
})
export class AppShowHealthChecksComponent {
@Input()
manifest!: Manifest
HealthResult = HealthResult
healthChecks!: Record<string, HealthCheckResult>
readonly connected$ = this.connectionService.connected$

View File

@@ -6,10 +6,9 @@ import {
PrimaryStatus,
} from 'src/app/services/pkg-status-rendering.service'
import {
Manifest,
DataModel,
PackageDataEntry,
PackageMainStatus,
PackageState,
Status,
} from 'src/app/services/patch-db/data-model'
import { ErrorToastService } from '@start9labs/shared'
@@ -18,7 +17,13 @@ import { ApiService } from 'src/app/services/api/embassy-api.service'
import { ModalService } from 'src/app/services/modal.service'
import { hasCurrentDeps } from 'src/app/util/has-deps'
import { ConnectionService } from 'src/app/services/connection.service'
import { isInstalled, getManifest } from 'src/app/util/get-package-data'
import {
isInstalled,
getManifest,
getAllPackages,
} from 'src/app/util/get-package-data'
import { Manifest } from '@start9labs/marketplace'
import { PatchDB } from 'patch-db-client'
@Component({
selector: 'app-show-status',
@@ -47,6 +52,7 @@ export class AppShowStatusComponent {
private readonly launcherService: UiLauncherService,
private readonly modalService: ModalService,
private readonly connectionService: ConnectionService,
private readonly patch: PatchDB<DataModel>,
) {}
get interfaces(): PackageDataEntry['service-interfaces'] {
@@ -116,7 +122,7 @@ export class AppShowStatusComponent {
const { title, alerts } = this.manifest
let message = alerts.stop || ''
if (hasCurrentDeps(this.pkg)) {
if (hasCurrentDeps(this.manifest.id, await getAllPackages(this.patch))) {
const depMessage = `Services that depend on ${title} will no longer work properly and may crash`
message = message ? `${message}.\n\n${depMessage}` : depMessage
}
@@ -148,7 +154,7 @@ export class AppShowStatusComponent {
}
async tryRestart(): Promise<void> {
if (hasCurrentDeps(this.pkg)) {
if (hasCurrentDeps(this.manifest.id, await getAllPackages(this.patch))) {
const alert = await this.alertCtrl.create({
header: 'Warning',
message: `Services that depend on ${this.manifest.title} may temporarily experiences issues`,

View File

@@ -5,14 +5,13 @@ import { MarkdownComponent } from '@start9labs/shared'
import {
DataModel,
InstalledState,
Manifest,
PackageDataEntry,
} from 'src/app/services/patch-db/data-model'
import { ModalService } from 'src/app/services/modal.service'
import { ApiService } from 'src/app/services/api/embassy-api.service'
import { from, map, Observable } from 'rxjs'
import { PatchDB } from 'patch-db-client'
import { getManifest } from 'src/app/util/get-package-data'
import { Manifest } from '@start9labs/marketplace'
export interface Button {
title: string

View File

@@ -2,13 +2,13 @@ import { Pipe, PipeTransform } from '@angular/core'
import {
DataModel,
HealthCheckResult,
Manifest,
PackageMainStatus,
} from 'src/app/services/patch-db/data-model'
import { isEmptyObject } from '@start9labs/shared'
import { map, startWith } from 'rxjs/operators'
import { PatchDB } from 'patch-db-client'
import { Observable } from 'rxjs'
import { Manifest } from '@start9labs/marketplace'
@Pipe({
name: 'toHealthChecks',
@@ -18,26 +18,17 @@ export class ToHealthChecksPipe implements PipeTransform {
transform(
manifest: Manifest,
): Observable<Record<string, HealthCheckResult | null>> | null {
const healthChecks = Object.keys(manifest['health-checks']).reduce(
(obj, key) => ({ ...obj, [key]: null }),
{},
)
const healthChecks$ = this.patch
): Observable<Record<string, HealthCheckResult | null> | null> {
return this.patch
.watch$('package-data', manifest.id, 'status', 'main')
.pipe(
map(main => {
// Question: is this ok or do we have to use Object.keys
// to maintain order and the keys initially present in pkg?
return main.status === PackageMainStatus.Running &&
!isEmptyObject(main.health)
? main.health
: healthChecks
: null
}),
startWith(healthChecks),
startWith(null),
)
return isEmptyObject(healthChecks) ? null : healthChecks$
}
}

View File

@@ -8,9 +8,14 @@
View Installed
</ion-button>
<ng-container *ngIf="localPkg; else install">
<ng-container *ngIf="localPkg['state-info'].state === 'installed'">
<ng-container
*ngIf="
localPkg['state-info'].state === 'installed' &&
(localPkg | toManifest) as manifest
"
>
<ion-button
*ngIf="(localVersion | compareEmver: pkg.manifest.version) === -1"
*ngIf="(manifest.version | compareEmver: pkg.manifest.version) === -1"
expand="block"
color="success"
(click)="tryInstall()"
@@ -18,7 +23,7 @@
Update
</ion-button>
<ion-button
*ngIf="(localVersion | compareEmver: pkg.manifest.version) === 1"
*ngIf="(manifest.version | compareEmver: pkg.manifest.version) === 1"
expand="block"
color="warning"
(click)="tryInstall()"
@@ -27,7 +32,7 @@
</ion-button>
<ng-container *ngIf="showDevTools$ | async">
<ion-button
*ngIf="(localVersion | compareEmver: pkg.manifest.version) === 0"
*ngIf="(manifest.version | compareEmver: pkg.manifest.version) === 0"
expand="block"
color="success"
(click)="tryInstall()"

View File

@@ -7,6 +7,7 @@ import {
import { AlertController, LoadingController } from '@ionic/angular'
import {
AbstractMarketplaceService,
Manifest,
MarketplacePkg,
} from '@start9labs/marketplace'
import {
@@ -18,7 +19,6 @@ import {
import {
DataModel,
PackageDataEntry,
PackageState,
} from 'src/app/services/patch-db/data-model'
import { ClientStorageService } from 'src/app/services/client-storage.service'
import { MarketplaceService } from 'src/app/services/marketplace.service'
@@ -57,10 +57,6 @@ export class MarketplaceShowControlsComponent {
private readonly patch: PatchDB<DataModel>,
) {}
get localVersion(): string {
return this.localPkg ? getManifest(this.localPkg).version : ''
}
async tryInstall() {
const currentMarketplace = await firstValueFrom(
this.marketplaceService.getSelectedHost$(),
@@ -80,10 +76,12 @@ export class MarketplaceShowControlsComponent {
if (!proceed) return
}
const localManifest = getManifest(this.localPkg)
if (
this.emver.compare(this.localVersion, this.pkg.manifest.version) !==
this.emver.compare(localManifest.version, this.pkg.manifest.version) !==
0 &&
hasCurrentDeps(this.localPkg)
hasCurrentDeps(localManifest.id, await getAllPackages(this.patch))
) {
this.dryInstall(url)
} else {

View File

@@ -19,6 +19,7 @@ import { MarketplaceShowPage } from './marketplace-show.page'
import { MarketplaceShowHeaderComponent } from './marketplace-show-header/marketplace-show-header.component'
import { MarketplaceShowDependentComponent } from './marketplace-show-dependent/marketplace-show-dependent.component'
import { MarketplaceShowControlsComponent } from './marketplace-show-controls/marketplace-show-controls.component'
import { UiPipeModule } from 'src/app/pipes/ui/ui.module'
const routes: Routes = [
{
@@ -41,6 +42,7 @@ const routes: Routes = [
AboutModule,
DependenciesModule,
AdditionalModule,
UiPipeModule,
],
declarations: [
MarketplaceShowPage,

View File

@@ -1,10 +1,10 @@
import { Component } from '@angular/core'
import { isPlatform, LoadingController, NavController } from '@ionic/angular'
import { ApiService } from 'src/app/services/api/embassy-api.service'
import { Manifest } from 'src/app/services/patch-db/data-model'
import { ConfigService } from 'src/app/services/config.service'
import cbor from 'cbor'
import { ErrorToastService } from '@start9labs/shared'
import { Manifest } from '@start9labs/marketplace'
interface Positions {
[key: string]: [bigint, bigint] // [position, length]
@@ -133,9 +133,7 @@ export class SideloadPage {
}
async getIcon(positions: Positions, file: Blob) {
const contentType = `image/${this.toUpload.manifest?.assets.icon
.split('.')
.pop()}`
const contentType = '' // @TODO
const data = file.slice(
Number(positions['icon'][0]),
Number(positions['icon'][0]) + Number(positions['icon'][1]),

View File

@@ -5,7 +5,6 @@ import { RouterModule, Routes } from '@angular/router'
import { FilterUpdatesPipe, UpdatesPage } from './updates.page'
import { BadgeMenuComponentModule } from 'src/app/components/badge-menu-button/badge-menu.component.module'
import {
EmverDisplayPipe,
EmverPipesModule,
MarkdownPipeModule,
SharedPipesModule,
@@ -14,7 +13,6 @@ import { SkeletonListComponentModule } from 'src/app/components/skeleton-list/sk
import { RoundProgressModule } from 'angular-svg-round-progressbar'
import { InstallingProgressPipeModule } from 'src/app/pipes/install-progress/install-progress.module'
import { StoreIconComponentModule } from 'src/app/components/store-icon/store-icon.component.module'
import { MimeTypePipeModule } from '@start9labs/marketplace'
const routes: Routes = [
{
@@ -37,7 +35,6 @@ const routes: Routes = [
InstallingProgressPipeModule,
StoreIconComponentModule,
EmverPipesModule,
MimeTypePipeModule,
],
})
export class UpdatesPageModule {}

View File

@@ -33,7 +33,7 @@
<ion-accordion *ngIf="data.localPkgs[pkg.manifest.id] as local">
<ion-item lines="none" slot="header">
<ion-avatar slot="start" style="width: 50px; height: 50px">
<img [src]="pkg | mimeType | trustUrl" />
<img [src]="pkg.icon | trustUrl" />
</ion-avatar>
<ion-label>
<h1 style="line-height: 1.3">{{ pkg.manifest.title }}</h1>
@@ -72,7 +72,7 @@
></ion-spinner>
<ng-template #updateBtn>
<ion-button
(click)="tryUpdate(pkg.manifest, host.url, local, $event)"
(click)="tryUpdate(pkg.manifest, host.url, $event)"
[color]="marketplaceService.updateErrors[pkg.manifest.id] ? 'danger' : 'tertiary'"
strong
>

View File

@@ -10,7 +10,7 @@ import { MarketplaceService } from 'src/app/services/marketplace.service'
import {
AbstractMarketplaceService,
Marketplace,
MarketplaceManifest,
Manifest,
MarketplacePkg,
StoreIdentity,
} from '@start9labs/marketplace'
@@ -70,12 +70,7 @@ export class UpdatesPage {
})
}
async tryUpdate(
manifest: MarketplaceManifest,
url: string,
local: PackageDataEntry,
e: Event,
): Promise<void> {
async tryUpdate(manifest: Manifest, url: string, e: Event): Promise<void> {
e.stopPropagation()
const { id, version } = manifest
@@ -83,14 +78,15 @@ export class UpdatesPage {
delete this.marketplaceService.updateErrors[id]
this.marketplaceService.updateQueue[id] = true
if (hasCurrentDeps(local)) {
// manifest.id OK because same as local id for update
if (hasCurrentDeps(manifest.id, await getAllPackages(this.patch))) {
this.dryInstall(manifest, url)
} else {
this.install(id, version, url)
}
}
private async dryInstall(manifest: MarketplaceManifest, url: string) {
private async dryInstall(manifest: Manifest, url: string) {
const { id, version, title } = manifest
const breakages = dryUpdate(