uberf-9383: fix ws init and import (#8005)

Signed-off-by: Alexey Zinoviev <alexey.zinoviev@xored.com>
This commit is contained in:
Alexey Zinoviev 2025-02-14 06:00:23 +04:00 committed by GitHub
parent 07e9c88287
commit 7dec3cf7fb
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
14 changed files with 176 additions and 89 deletions

17
.vscode/launch.json vendored
View File

@ -138,11 +138,10 @@
"env": {
"MONGO_URL": "mongodb://localhost:27017",
"DB_URL": "mongodb://localhost:27017",
// "DB_URL": "postgresql://postgres:example@localhost:5432",
// "DB_URL": "postgresql://root@host.docker.internal:26257/defaultdb?sslmode=disable",
"SERVER_SECRET": "secret",
"REGION_INFO":"|Mongo;pg|Postgres;cockroach|CockroachDB",
"TRANSACTOR_URL": "ws://host.docker.internal:3333,ws://host.docker.internal:3331;;pg,ws://host.docker.internal:3332;;cockroach",
"REGION_INFO":"|Mongo;cockroach|CockroachDB",
"TRANSACTOR_URL": "ws://host.docker.internal:3333,ws://host.docker.internal:3332;;cockroach",
"ACCOUNTS_URL": "http://localhost:3000",
"ACCOUNT_PORT": "3000",
"FRONT_URL": "http://localhost:8080",
@ -154,8 +153,6 @@
"MINIO_SECRET_KEY": "minioadmin",
"MINIO_ENDPOINT": "localhost"
// "DISABLE_SIGNUP": "true",
// "INIT_SCRIPT_URL": "https://raw.githubusercontent.com/hcengineering/init/main/script.yaml",
// "INIT_WORKSPACE": "onboarding",
},
"runtimeVersion": "20",
"runtimeArgs": ["--nolazy", "-r", "ts-node/register"],
@ -187,8 +184,6 @@
"MINIO_SECRET_KEY": "minioadmin",
"MINIO_ENDPOINT": "localhost"
// "DISABLE_SIGNUP": "true",
// "INIT_SCRIPT_URL": "https://raw.githubusercontent.com/hcengineering/init/main/script.yaml",
// "INIT_WORKSPACE": "onboarding",
},
"runtimeVersion": "20",
"runtimeArgs": ["--nolazy", "-r", "ts-node/register"],
@ -236,8 +231,8 @@
"WS_OPERATION": "all+backup",
"BACKUP_STORAGE": "minio|minio?accessKey=minioadmin&secretKey=minioadmin",
"BACKUP_BUCKET": "dev-backups",
// "INIT_SCRIPT_URL": "https://raw.githubusercontent.com/hcengineering/init/main/script.yaml",
// "INIT_WORKSPACE": "onboarding",
// "INIT_REPO_DIR": "${workspaceRoot}/pods/workspace/init",
// "INIT_WORKSPACE": "staging-dev"
},
"runtimeVersion": "20",
"runtimeArgs": ["--nolazy", "-r", "ts-node/register"],
@ -269,8 +264,8 @@
"WS_OPERATION": "all+backup",
"BACKUP_STORAGE": "minio|minio?accessKey=minioadmin&secretKey=minioadmin",
"BACKUP_BUCKET": "dev-backups",
// "INIT_SCRIPT_URL": "https://raw.githubusercontent.com/hcengineering/init/main/script.yaml",
// "INIT_WORKSPACE": "onboarding",
// "INIT_REPO_DIR": "${workspaceRoot}/pods/workspace/init",
// "INIT_WORKSPACE": "staging-dev"
},
"runtimeVersion": "20",
"runtimeArgs": ["--nolazy", "-r", "ts-node/register"],

View File

@ -80,7 +80,7 @@ services:
# Pass only one region to disallow selection for new workspaces.Ø
- REGION_INFO=|Mongo;cockroach|CockroachDB
# - REGION_INFO=cockroach|CockroachDB
- TRANSACTOR_URL=ws://host.docker.internal:3333,ws://host.docker.internal:3331;;pg,ws://host.docker.internal:3332;;cockroach,
- TRANSACTOR_URL=ws://host.docker.internal:3333,ws://host.docker.internal:3332;;cockroach,
- SES_URL=
- STORAGE_CONFIG=${STORAGE_CONFIG}
- FRONT_URL=http://host.docker.internal:8087
@ -91,8 +91,6 @@ services:
- ACCOUNTS_URL=http://host.docker.internal:3000
- BRANDING_PATH=/var/cfg/branding.json
# - DISABLE_SIGNUP=true
# - INIT_SCRIPT_URL=https://raw.githubusercontent.com/hcengineering/init/main/script.yaml
# - INIT_WORKSPACE=onboarding
restart: unless-stopped
stats:
image: hardcoreeng/stats
@ -126,9 +124,9 @@ services:
- ACCOUNTS_URL=http://host.docker.internal:3000
- BRANDING_PATH=/var/cfg/branding.json
# - PARALLEL=2
- INIT_WORKSPACE=test
- BACKUP_STORAGE=${BACKUP_STORAGE_CONFIG}
- BACKUP_BUCKET=${BACKUP_BUCKET_NAME}
# - INIT_WORKSPACE=staging-dev
restart: unless-stopped
workspace_cockroach:
image: hardcoreeng/workspace
@ -153,9 +151,9 @@ services:
- ACCOUNTS_URL=http://host.docker.internal:3000
- BRANDING_PATH=/var/cfg/branding.json
# - PARALLEL=2
# - INIT_WORKSPACE=onboarding
- BACKUP_STORAGE=${BACKUP_STORAGE_CONFIG}
- BACKUP_BUCKET=${BACKUP_BUCKET_NAME}
# - INIT_WORKSPACE=staging-dev
restart: unless-stopped
collaborator:
image: hardcoreeng/collaborator

View File

@ -334,7 +334,7 @@ export async function moveAccountDbFromMongoToPG (
}
if (workspacesCount % 100 === 0) {
ctx.info(`Migrated ${workspacesCount} invites...`)
ctx.info(`Migrated ${workspacesCount} workspaces...`)
}
}
ctx.info(`Migrated ${workspacesCount} workspaces with ${membersCount} member assignments`)

View File

@ -46,6 +46,7 @@ import {
AccountRole,
MeasureMetricsContext,
metricsToString,
type PersonId,
type Data,
type Tx,
type Version,
@ -304,7 +305,7 @@ export function devTool (
const measureCtx = new MeasureMetricsContext('create-workspace', {})
const brandingObj =
cmd.branding !== undefined || cmd.init !== undefined ? { key: cmd.branding, initWorkspace: cmd.init } : null
const socialId = await db.socialId.findOne({ key: socialString })
const socialId = await db.socialId.findOne({ key: socialString as PersonId })
if (socialId == null) {
throw new Error(`Social id ${socialString} not found`)
}

View File

@ -17,7 +17,8 @@ import {
BackupStatus,
Data,
type Person,
PersonUuid,
type PersonUuid,
type PersonInfo,
SocialId,
Version,
type WorkspaceInfoWithStatus,
@ -73,6 +74,7 @@ export interface AccountClient {
signUp: (email: string, password: string, first: string, last: string) => Promise<LoginInfo>
login: (email: string, password: string) => Promise<LoginInfo>
getPerson: () => Promise<Person>
getPersonInfo: (account: PersonUuid) => Promise<PersonInfo>
getSocialIds: () => Promise<SocialId[]>
getWorkspaceMembers: () => Promise<WorkspaceMemberInfo[]>
updateWorkspaceRole: (account: string, role: AccountRole) => Promise<void>
@ -399,6 +401,15 @@ class AccountClientImpl implements AccountClient {
return await this.rpc(request)
}
async getPersonInfo (account: PersonUuid): Promise<PersonInfo> {
const request = {
method: 'getPersonInfo' as const,
params: [account]
}
return await this.rpc(request)
}
async getSocialIds (): Promise<SocialId[]> {
const request = {
method: 'getSocialIds' as const,

View File

@ -538,6 +538,10 @@ export interface Person {
city?: string
}
export interface PersonInfo extends BasePerson {
socialIds: PersonId[]
}
/**
* @public
*/

View File

@ -16,11 +16,13 @@
import { type Attachment } from '@hcengineering/attachment'
import contact, { Employee, type Person } from '@hcengineering/contact'
import {
buildSocialIdString,
type Class,
type Doc,
generateId,
PersonId,
type Ref,
SocialIdType,
type Space,
type TxOperations
} from '@hcengineering/core'
@ -330,24 +332,26 @@ interface AttachmentMetadata {
}
export class UnifiedFormatImporter {
private readonly importerEmailPlaceholder = 'newuser@huly.io'
private readonly importerNamePlaceholder = 'New User'
private readonly pathById = new Map<Ref<Doc>, string>()
private readonly refMetaByPath = new Map<string, ReferenceMetadata>()
private readonly fileMetaByPath = new Map<string, AttachmentMetadata>()
private readonly ctrlDocTemplateIdByPath = new Map<string, Ref<ControlledDocument>>()
private personsByName = new Map<string, Ref<Person>>()
// private accountsByEmail = new Map<string, Ref<PersonAccount>>()
// private employeesByName = new Map<string, Ref<Employee>>()
private employeesByName = new Map<string, Ref<Employee>>()
constructor (
private readonly client: TxOperations,
private readonly fileUploader: FileUploader,
private readonly logger: Logger
private readonly logger: Logger,
private readonly importerSocialId?: PersonId,
private readonly importerPerson?: Ref<Person>
) {}
private async initCaches (): Promise<void> {
await this.cachePersonsByNames()
await this.cacheAccountsByEmails()
await this.cacheEmployeesByName()
}
@ -588,6 +592,10 @@ export class UnifiedFormatImporter {
if (name === undefined) {
return undefined
}
if (name === this.importerNamePlaceholder && this.importerPerson != null) {
return this.importerPerson
}
const person = this.personsByName.get(name)
if (person === undefined) {
throw new Error(`Person not found: ${name}`)
@ -595,24 +603,20 @@ export class UnifiedFormatImporter {
return person
}
private findAccountByEmail (email: string): PersonId {
// TODO: FIXME
throw new Error('Not implemented')
// const account = this.accountsByEmail.get(email)
// if (account === undefined) {
// throw new Error(`Account not found: ${email}`)
// }
// return account
private getSocialIdByEmail (email: string): PersonId {
if (email === this.importerEmailPlaceholder && this.importerSocialId != null) {
return this.importerSocialId
}
return buildSocialIdString({ type: SocialIdType.EMAIL, value: email })
}
private findEmployeeByName (name: string): Ref<Employee> {
// TODO: FIXME
throw new Error('Not implemented')
// const employee = this.employeesByName.get(name)
// if (employee === undefined) {
// throw new Error(`Employee not found: ${name}`)
// }
// return employee
const employee = this.employeesByName.get(name)
if (employee === undefined) {
throw new Error(`Employee not found: ${name}`)
}
return employee
}
private async processDocumentsRecursively (
@ -752,7 +756,7 @@ export class UnifiedFormatImporter {
}
return {
text: comment.text,
author: this.findAccountByEmail(comment.author),
author: this.getSocialIdByEmail(comment.author),
attachments
}
})
@ -789,9 +793,9 @@ export class UnifiedFormatImporter {
defaultIssueStatus:
projectHeader.defaultIssueStatus !== undefined ? { name: projectHeader.defaultIssueStatus } : undefined,
owners:
projectHeader.owners !== undefined ? projectHeader.owners.map((email) => this.findAccountByEmail(email)) : [],
projectHeader.owners !== undefined ? projectHeader.owners.map((email) => this.getSocialIdByEmail(email)) : [],
members:
projectHeader.members !== undefined ? projectHeader.members.map((email) => this.findAccountByEmail(email)) : [],
projectHeader.members !== undefined ? projectHeader.members.map((email) => this.getSocialIdByEmail(email)) : [],
docs: []
}
}
@ -805,9 +809,9 @@ export class UnifiedFormatImporter {
archived: spaceHeader.archived ?? false,
description: spaceHeader.description,
emoji: spaceHeader.emoji,
owners: spaceHeader.owners !== undefined ? spaceHeader.owners.map((email) => this.findAccountByEmail(email)) : [],
owners: spaceHeader.owners !== undefined ? spaceHeader.owners.map((email) => this.getSocialIdByEmail(email)) : [],
members:
spaceHeader.members !== undefined ? spaceHeader.members.map((email) => this.findAccountByEmail(email)) : [],
spaceHeader.members !== undefined ? spaceHeader.members.map((email) => this.getSocialIdByEmail(email)) : [],
docs: []
}
}
@ -819,11 +823,11 @@ export class UnifiedFormatImporter {
private: spaceHeader.private ?? false,
archived: spaceHeader.archived ?? false,
description: spaceHeader.description,
owners: spaceHeader.owners?.map((email) => this.findAccountByEmail(email)) ?? [],
members: spaceHeader.members?.map((email) => this.findAccountByEmail(email)) ?? [],
qualified: spaceHeader.qualified !== undefined ? this.findAccountByEmail(spaceHeader.qualified) : undefined,
manager: spaceHeader.manager !== undefined ? this.findAccountByEmail(spaceHeader.manager) : undefined,
qara: spaceHeader.qara !== undefined ? this.findAccountByEmail(spaceHeader.qara) : undefined,
owners: spaceHeader.owners?.map((email) => this.getSocialIdByEmail(email)) ?? [],
members: spaceHeader.members?.map((email) => this.getSocialIdByEmail(email)) ?? [],
qualified: spaceHeader.qualified !== undefined ? this.getSocialIdByEmail(spaceHeader.qualified) : undefined,
manager: spaceHeader.manager !== undefined ? this.getSocialIdByEmail(spaceHeader.manager) : undefined,
qara: spaceHeader.qara !== undefined ? this.getSocialIdByEmail(spaceHeader.qara) : undefined,
docs: []
}
}
@ -950,30 +954,18 @@ export class UnifiedFormatImporter {
}, new Map())
}
private async cacheAccountsByEmails (): Promise<void> {
// TODO: FIXME
throw new Error('Not implemented')
// const accounts = await this.client.findAll(contact.class.PersonAccount, {})
// this.accountsByEmail = accounts.reduce((map, account) => {
// map.set(account.email, account._id)
// return map
// }, new Map())
}
private async cacheEmployeesByName (): Promise<void> {
// TODO: FIXME
throw new Error('Not implemented')
// this.employeesByName = (await this.client.findAll(contact.mixin.Employee, {}))
// .map((employee) => {
// return {
// _id: employee._id,
// name: employee.name.split(',').reverse().join(' ')
// }
// })
// .reduce((refByName, employee) => {
// refByName.set(employee.name, employee._id)
// return refByName
// }, new Map())
this.employeesByName = (await this.client.findAll(contact.mixin.Employee, {}))
.map((employee) => {
return {
_id: employee._id,
name: employee.name.split(',').reverse().join(' ')
}
})
.reduce((refByName, employee) => {
refByName.set(employee.name, employee._id)
return refByName
}, new Map())
}
private async collectFileMetadata (folderPath: string): Promise<void> {

1
pods/workspace/.gitignore vendored Normal file
View File

@ -0,0 +1 @@
init

View File

@ -28,9 +28,11 @@ import {
type Branding,
type Person,
type PersonUuid,
type PersonInfo,
type WorkspaceMemberInfo,
type WorkspaceMode,
type WorkspaceUuid
type WorkspaceUuid,
type PersonId
} from '@hcengineering/core'
import platform, {
getMetadata,
@ -86,7 +88,8 @@ import {
setPassword,
signUpByEmail,
verifyPassword,
wrap
wrap,
verifyAllowedServices
} from './utils'
// Move to config?
@ -1052,6 +1055,31 @@ export async function getPerson (
return person
}
export async function getPersonInfo (
ctx: MeasureContext,
db: AccountDB,
branding: Branding | null,
token: string,
account: PersonUuid
): Promise<PersonInfo> {
const { extra } = decodeTokenVerbose(ctx, token)
verifyAllowedServices(['workspace', 'tool'], extra)
const person = await db.person.findOne({ uuid: account })
if (person == null) {
throw new PlatformError(new Status(Severity.ERROR, platform.status.PersonNotFound, { person: account }))
}
const verifiedSocialIds = await db.socialId.find({ personUuid: account, verifiedOn: { $gt: 0 } })
return {
personUuid: account,
name: `${person?.firstName} ${person?.lastName}`, // Should we control the order by config?
socialIds: verifiedSocialIds.map((it) => it.key)
}
}
export async function findPerson (
ctx: MeasureContext,
db: AccountDB,
@ -1061,7 +1089,7 @@ export async function findPerson (
): Promise<PersonUuid | undefined> {
decodeTokenVerbose(ctx, token)
const socialId = await db.socialId.findOne({ key: socialString })
const socialId = await db.socialId.findOne({ key: socialString as PersonId })
if (socialId == null) {
return
@ -1105,7 +1133,7 @@ export async function updateWorkspaceRoleBySocialId (
throw new PlatformError(new Status(Severity.ERROR, platform.status.Forbidden, {}))
}
const socialId = await getSocialIdByKey(db, socialKey.toLowerCase())
const socialId = await getSocialIdByKey(db, socialKey.toLowerCase() as PersonId)
if (socialId == null) {
throw new PlatformError(new Status(Severity.ERROR, platform.status.AccountNotFound, {}))
}
@ -1452,6 +1480,7 @@ export type AccountMethods =
| 'updateBackupInfo'
| 'assignWorkspace'
| 'getPerson'
| 'getPersonInfo'
| 'getWorkspaceMembers'
| 'updateWorkspaceRole'
| 'findPerson'
@ -1493,6 +1522,7 @@ export function getMethods (hasSignUp: boolean = true): Partial<Record<AccountMe
getLoginInfoByToken: wrap(getLoginInfoByToken),
getSocialIds: wrap(getSocialIds),
getPerson: wrap(getPerson),
getPersonInfo: wrap(getPersonInfo),
findPerson: wrap(findPerson),
getWorkspaceMembers: wrap(getWorkspaceMembers),

View File

@ -26,7 +26,8 @@ import {
BackupStatus,
type PersonUuid,
type WorkspaceUuid,
type WorkspaceDataId
type WorkspaceDataId,
type PersonId
} from '@hcengineering/core'
/* ========= D A T A B A S E E N T I T I E S ========= */
@ -43,7 +44,7 @@ export enum Location {
export interface SocialId {
type: SocialIdType
value: string
key: string // Calculated from type and value
key: PersonId // Calculated from type and value
personUuid: PersonUuid
createdOn?: Timestamp
verifiedOn?: Timestamp

View File

@ -27,7 +27,8 @@ import {
systemAccountUuid,
type WorkspaceInfoWithStatus as WorkspaceInfoWithStatusCore,
isActiveMode,
type PersonUuid
type PersonUuid,
type PersonId
} from '@hcengineering/core'
import { getMongoClient } from '@hcengineering/mongo' // TODO: get rid of this import later
import platform, { getMetadata, PlatformError, Severity, Status, translate } from '@hcengineering/platform'
@ -854,7 +855,7 @@ export async function getWorkspaceInvite (db: AccountDB, id: string): Promise<Wo
return await db.invite.findOne({ migratedFrom: id })
}
export async function getSocialIdByKey (db: AccountDB, socialKey: string): Promise<SocialId | null> {
export async function getSocialIdByKey (db: AccountDB, socialKey: PersonId): Promise<SocialId | null> {
return await db.socialId.findOne({ key: socialKey })
}
@ -1106,3 +1107,9 @@ export async function getWorkspaces (
status: statusesMap[it.uuid]
}))
}
export function verifyAllowedServices (services: string[], extra: any): void {
if (!services.includes(extra?.service) && extra?.admin !== 'true') {
throw new PlatformError(new Status(Severity.ERROR, platform.status.Forbidden, {}))
}
}

View File

@ -34,7 +34,8 @@ import core, {
type Client,
type Ref,
type WithLookup,
type WorkspaceDataId
type WorkspaceDataId,
type PersonInfo
} from '@hcengineering/core'
import { consoleModelLogger, MigrateOperation, ModelLogger, tryMigrate } from '@hcengineering/model'
import { DomainIndexHelperImpl, Pipeline, StorageAdapter, type DbAdapter } from '@hcengineering/server-core'
@ -187,6 +188,7 @@ export async function initializeWorkspace (
ctx: MeasureContext,
branding: Branding | null,
wsIds: WorkspaceIds,
personInfo: PersonInfo,
storageAdapter: StorageAdapter,
client: TxOperations,
logger: ModelLogger = consoleModelLogger,
@ -218,7 +220,7 @@ export async function initializeWorkspace (
return
}
const initializer = new WorkspaceInitializer(ctx, storageAdapter, wsIds, client, initRepoDir)
const initializer = new WorkspaceInitializer(ctx, storageAdapter, wsIds, client, initRepoDir, personInfo)
await initializer.processScript(script, logger, progress)
} catch (err: any) {
ctx.error('Failed to initialize workspace', { error: err })

View File

@ -8,7 +8,11 @@ import core, {
makeCollabId,
MeasureContext,
Mixin,
parseSocialIdString,
type PersonId,
type PersonInfo,
Ref,
SocialIdType,
Space,
TxOperations,
type WorkspaceDataId,
@ -19,6 +23,7 @@ import { makeRank } from '@hcengineering/rank'
import { StorageFileUploader, UnifiedFormatImporter } from '@hcengineering/importer'
import type { StorageAdapter } from '@hcengineering/server-core'
import { jsonToMarkup, parseMessageMarkdown } from '@hcengineering/text'
import { pickPrimarySocialId } from '@hcengineering/contact'
import { v4 as uuid } from 'uuid'
import path from 'path'
@ -96,21 +101,38 @@ export class WorkspaceInitializer {
private readonly imageUrl = 'image://'
private readonly nextRank = '#nextRank'
private readonly now = '#now'
private readonly creatorPersonVar = 'creatorPerson'
private readonly socialKey: PersonId
private readonly socialType: SocialIdType
private readonly socialValue: string
constructor (
private readonly ctx: MeasureContext,
private readonly storageAdapter: StorageAdapter,
private readonly wsIds: WorkspaceIds,
private readonly client: TxOperations,
private readonly initRepoDir: string
) {}
private readonly initRepoDir: string,
private readonly creator: PersonInfo
) {
this.socialKey = pickPrimarySocialId(creator.socialIds)
const socialKeyObj = parseSocialIdString(this.socialKey)
this.socialType = socialKeyObj.type
this.socialValue = socialKeyObj.value
}
async processScript (
script: InitScript,
logger: ModelLogger,
progress: (value: number) => Promise<void>
): Promise<void> {
const vars: Record<string, any> = {}
const vars: Record<string, any> = {
'${creatorName@global}': this.creator.name, // eslint-disable-line no-template-curly-in-string
'${creatorUuid@global}': this.creator.personUuid, // eslint-disable-line no-template-curly-in-string
'${creatorSocialKey@global}': this.socialKey, // eslint-disable-line no-template-curly-in-string
'${creatorSocialType@global}': this.socialType, // eslint-disable-line no-template-curly-in-string
'${creatorSocialValue@global}': this.socialValue // eslint-disable-line no-template-curly-in-string
}
const defaults = new Map<Ref<Class<Doc>>, Props<Doc>>()
for (let index = 0; index < script.steps.length; index++) {
try {
@ -169,7 +191,9 @@ export class WorkspaceInitializer {
try {
const uploader = new StorageFileUploader(this.ctx, this.storageAdapter, this.wsIds)
const initPath = path.resolve(this.initRepoDir, step.path)
const importer = new UnifiedFormatImporter(this.client, uploader, logger)
// eslint-disable-next-line no-template-curly-in-string
const initPerson = vars[`\${${this.creatorPersonVar}}`]
const importer = new UnifiedFormatImporter(this.client, uploader, logger, this.socialKey, initPerson)
await importer.importFolder(initPath)
} catch (error) {
logger.error('Import failed', error)

View File

@ -101,10 +101,31 @@ export async function createWorkspace (
})
ctx.info('Starting init script if any')
await initializeWorkspace(ctx, branding, wsIds, storageAdapter, client, ctxModellogger, async (value) => {
ctx.info('Init script progress', { value })
await handleWsEvent?.('progress', version, 20 + Math.round((Math.min(value, 100) / 100) * 60))
})
const creatorUuid = workspaceInfo.createdBy
if (creatorUuid != null) {
const personInfo = await accountClient.getPersonInfo(creatorUuid)
if (personInfo?.socialIds.length > 0) {
await initializeWorkspace(
ctx,
branding,
wsIds,
personInfo,
storageAdapter,
client,
ctxModellogger,
async (value) => {
ctx.info('Init script progress', { value })
await handleWsEvent?.('progress', version, 20 + Math.round((Math.min(value, 100) / 100) * 60))
}
)
} else {
ctx.warn('No person info or verified social ids found for workspace creator. Skipping init script.')
}
} else {
ctx.warn('No workspace creator found. Skipping init script.')
}
await upgradeWorkspaceWith(
ctx,