diff --git a/package.json b/package.json index ea7788d37..102b3b7a1 100644 --- a/package.json +++ b/package.json @@ -126,6 +126,13 @@ "error", "never" ], + "curly": [ + "error", + "all" + ], + "no-unused-expressions": [ + "error" + ], "dot-notation": [ "error", { @@ -142,6 +149,54 @@ "case": "camelCase" } ], + "padding-line-between-statements": [ + "error", + { + "blankLine": "always", + "prev": "multiline-expression", + "next": "*" + }, + { + "blankLine": "always", + "prev": "*", + "next": "multiline-expression" + }, + { + "blankLine": "always", + "prev": "multiline-block-like", + "next": "*" + }, + { + "blankLine": "always", + "prev": "*", + "next": "multiline-block-like" + }, + { + "blankLine": "always", + "prev": "multiline-const", + "next": "*" + }, + { + "blankLine": "always", + "prev": "*", + "next": "multiline-const" + }, + { + "blankLine": "always", + "prev": "multiline-let", + "next": "*" + }, + { + "blankLine": "always", + "prev": "*", + "next": "multiline-let" + }, + { + "blankLine": "any", + "prev": "case", + "next": "case" + } + ], "@typescript-eslint/explicit-function-return-type": [ "warn", { @@ -192,7 +247,6 @@ "./resources/license-header.js" ], "brace-style": "off", - "padding-line-between-statements": "off", "lines-between-class-members": "off", "padded-blocks": "off", "indent": "off", diff --git a/src/commands/dependency.ts b/src/commands/dependency.ts index c68da759c..297c635b3 100644 --- a/src/commands/dependency.ts +++ b/src/commands/dependency.ts @@ -45,6 +45,7 @@ const NAME = "NAME"; const RECOMMENDED = "recommended"; const VERSION_FLAG_NAME = "version"; const USE_FLAG_NAME = "use"; + const RESET_ALL_MESSAGE = `If you omit ${color.yellow( NAME )} argument and include ${color.yellow( @@ -97,15 +98,18 @@ export default class Dependency extends Command { const dependencyConfig = await initDependencyConfig(this); dependencyConfig.dependency = {}; await dependencyConfig.$commit(); + for (const dependencyName of dependencyList) { // eslint-disable-next-line no-await-in-loop await ensureDependency(dependencyName, this); } + this.log( `Successfully reset all dependencies to ${color.yellow( RECOMMENDED )} versions` ); + return; } @@ -136,6 +140,7 @@ export default class Dependency extends Command { } const dependencyName = name; + const { recommendedVersion, packageName } = { ...npmDependencies, ...cargoDependencies, @@ -244,8 +249,10 @@ const ensureDependency = async ( name: dependencyName, commandObj, }); + break; } + default: { const _exhaustiveCheck: never = dependencyName; return _exhaustiveCheck; @@ -265,6 +272,7 @@ const handleUse = async ({ version, }: HandleUseArg): Promise => { const dependencyConfig = await initDependencyConfig(commandObj); + const updatedDependencyVersionsConfig = { ...dependencyConfig.dependency, [dependencyName]: version === RECOMMENDED ? undefined : version, diff --git a/src/commands/deploy.ts b/src/commands/deploy.ts index 1be110d04..37e93b18a 100644 --- a/src/commands/deploy.ts +++ b/src/commands/deploy.ts @@ -102,21 +102,28 @@ export default class Deploy extends Command { await ensureFluenceProject(this, isInteractive); const keyPair = await getKeyPairFromFlags(flags, this, isInteractive); + if (keyPair instanceof Error) { this.error(keyPair.message); } + const fluenceConfig = await initReadonlyFluenceConfig(this); + if (fluenceConfig === null) { this.error("You must init Fluence project first to deploy"); } + const relay = flags.relay ?? getRandomRelayAddr(fluenceConfig.relays); + const preparedForDeployItems = await prepareForDeploy({ commandObj: this, fluenceConfig, }); + const aquaCli = await initAquaCli(this); const tmpDeployJSONPath = await ensureFluenceTmpDeployJsonPath(); const appConfig = await initAppConfig(this); + if (appConfig !== null) { // Prompt user to remove previously deployed app if // it was already deployed before @@ -144,11 +151,13 @@ export default class Deploy extends Command { } const successfullyDeployedServices: ServicesV2 = {}; + this.log( `Going to deploy project described in ${color.yellow( replaceHomeDir(fluenceConfig.$getPath()) )}` ); + for (const { count, deployJSON, @@ -170,23 +179,30 @@ export default class Deploy extends Command { tmpDeployJSONPath, commandObj: this, }); + if (res !== null) { const { deployedServiceConfig, deployId, serviceName } = res; + const successfullyDeployedServicesByName = successfullyDeployedServices[serviceName] ?? {}; + successfullyDeployedServicesByName[deployId] = [ ...(successfullyDeployedServicesByName[deployId] ?? []), deployedServiceConfig, ]; + successfullyDeployedServices[serviceName] = successfullyDeployedServicesByName; } } } + if (Object.keys(successfullyDeployedServices).length === 0) { this.error("No services were deployed successfully"); } + await generateDeployedAppAqua(successfullyDeployedServices); + await generateRegisterApp({ deployedServices: successfullyDeployedServices, aquaCli, @@ -254,6 +270,7 @@ const prepareForDeploy = async ({ }>; CliUx.ux.action.start("Making sure all services are downloaded"); + const servicePaths = await Promise.all( Object.entries(fluenceConfig.services).map( ([serviceName, { get, deploy }]): ServicePathPromises => @@ -272,6 +289,7 @@ const prepareForDeploy = async ({ }))() ) ); + CliUx.ux.action.stop(); type ServiceConfigPromises = Promise<{ @@ -330,6 +348,7 @@ const prepareForDeploy = async ({ ).filter( (moduleName): boolean => !(moduleName in serviceConfig.modules) ); + if (modulesNotFoundInServiceYaml.length > 0) { commandObj.error( `${color.yellow( @@ -345,8 +364,10 @@ const prepareForDeploy = async ({ )} spelled correctly ` ); } + const { [FACADE_MODULE_NAME]: facadeModule, ...otherModules } = serviceConfig.modules; + return [ ...Object.entries(otherModules).map( ([moduleName, mod]): ModuleV0 => @@ -369,8 +390,10 @@ const prepareForDeploy = async ({ ) ), ]; + const marineCli = await initMarineCli(commandObj); CliUx.ux.action.start("Making sure all modules are downloaded and built"); + const mapOfAllModuleConfigs = new Map( await Promise.all( setOfAllGets.map( @@ -397,11 +420,13 @@ const prepareForDeploy = async ({ workingDir: path.dirname(moduleConfig.$getPath()), }); } + return [get, moduleConfig]; })() ) ) ); + CliUx.ux.action.stop(); return allDeployInfos.map( @@ -416,10 +441,12 @@ const prepareForDeploy = async ({ commandObj.error( `Unreachable. Wasn't able to find module config for ${get}` ); + return serviceModuleToJSONModuleConfig(moduleConfig, overrides); }), }, }; + return { ...rest, deployJSON, @@ -453,6 +480,7 @@ const serviceModuleToJSONModuleConfig = ( overrides: Omit ): JSONModuleConf => { const overriddenConfig = { ...moduleConfig, ...overrides }; + const { name, loggerEnabled, @@ -468,30 +496,38 @@ const serviceModuleToJSONModuleConfig = ( name, path: getModuleWasmPath(overriddenConfig), }; + if (loggerEnabled === true) { jsonModuleConfig.logger_enabled = true; } + if (typeof loggingMask === "number") { jsonModuleConfig.logging_mask = loggingMask; } + if (typeof maxHeapSize === "string") { jsonModuleConfig.max_heap_size = maxHeapSize; } + if (volumes !== undefined) { jsonModuleConfig.mapped_dirs = Object.entries(volumes); jsonModuleConfig.preopened_files = [...new Set(Object.values(volumes))]; } + if (preopenedFiles !== undefined) { jsonModuleConfig.preopened_files = [ ...new Set([...Object.values(volumes ?? {}), ...preopenedFiles]), ]; } + if (envs !== undefined) { jsonModuleConfig.envs = Object.entries(envs); } + if (mountedBinaries !== undefined) { jsonModuleConfig.mounted_binaries = Object.entries(mountedBinaries); } + return jsonModuleConfig; }; /* eslint-enable camelcase */ @@ -534,7 +570,9 @@ const deployService = async ({ JSON.stringify(deployJSON, null, 2), FS_OPTIONS ); + let result: string; + try { result = await aquaCli( { @@ -558,10 +596,12 @@ const deployService = async ({ const [, blueprintId] = /Blueprint id:\n(.*)/.exec(result) ?? []; const [, serviceId] = /And your service id is:\n"(.*)"/.exec(result) ?? []; + if (blueprintId === undefined || serviceId === undefined) { commandObj.warn( `Deployment finished without errors but not able to parse serviceId or blueprintId from aqua cli output:\n\n${result}` ); + return null; } diff --git a/src/commands/init.ts b/src/commands/init.ts index 0b81babf1..72da4062c 100644 --- a/src/commands/init.ts +++ b/src/commands/init.ts @@ -60,6 +60,7 @@ export default class Init extends Command { const isInteractive = getIsInteractive(flags); const projectPath: unknown = args[PATH]; assert(projectPath === undefined || typeof projectPath === "string"); + await init({ commandObj: this, isInteractive, @@ -73,6 +74,7 @@ const RECOMMENDATIONS = "recommendations"; type ExtensionsJson = { [RECOMMENDATIONS]?: Array; }; + const extensionsJsonSchema: JSONSchemaType = { type: "object", properties: { @@ -84,7 +86,9 @@ const extensionsJsonSchema: JSONSchemaType = { }, required: [], }; + const validateExtensionsJson = ajv.compile(extensionsJsonSchema); + const extensionsConfig: ExtensionsJson = { [RECOMMENDATIONS]: ["redhat.vscode-yaml", "FluenceLabs.aqua"], }; @@ -93,6 +97,7 @@ const ensureRecommendedExtensions = async (): Promise => { const extensionsJsonPath = await ensureVSCodeExtensionsJsonPath(); let fileContent: string; + try { fileContent = await fsPromises.readFile(extensionsJsonPath, FS_OPTIONS); } catch { @@ -102,6 +107,7 @@ const ensureRecommendedExtensions = async (): Promise => { } let parsedFileContent: unknown; + try { parsedFileContent = JSON.parse(fileContent); } catch { @@ -115,6 +121,7 @@ const ensureRecommendedExtensions = async (): Promise => { ...(extensionsConfig[RECOMMENDATIONS] ?? []), ]), ]; + await fsPromises.writeFile( extensionsJsonPath, JSON.stringify(parsedFileContent, null, 2) + "\n", @@ -128,6 +135,7 @@ const AQUA_SETTINGS_IMPORTS = "aquaSettings.imports"; type SettingsJson = { [AQUA_SETTINGS_IMPORTS]?: Array; }; + const settingsJsonSchema: JSONSchemaType = { type: "object", properties: { @@ -139,7 +147,9 @@ const settingsJsonSchema: JSONSchemaType = { }, required: [], }; + const validateSettingsJson = ajv.compile(settingsJsonSchema); + const initSettingsConfig = async (): Promise => ({ [AQUA_SETTINGS_IMPORTS]: [await ensureFluenceAquaDir()], }); @@ -148,6 +158,7 @@ const ensureRecommendedSettings = async (): Promise => { const settingsJsonPath = await ensureVSCodeSettingsJsonPath(); let fileContent: string; + try { fileContent = await fsPromises.readFile(settingsJsonPath, FS_OPTIONS); } catch { @@ -157,6 +168,7 @@ const ensureRecommendedSettings = async (): Promise => { } let parsedFileContent: unknown; + try { parsedFileContent = JSON.parse(fileContent); } catch { @@ -170,6 +182,7 @@ const ensureRecommendedSettings = async (): Promise => { ...((await initSettingsConfig())[AQUA_SETTINGS_IMPORTS] ?? []), ]), ]; + await fsPromises.writeFile( settingsJsonPath, JSON.stringify(parsedFileContent, null, 2) + "\n", @@ -181,17 +194,21 @@ const ensureRecommendedSettings = async (): Promise => { const ensureGitIgnore = async (): Promise => { const gitIgnorePath = getGitignorePath(); let newGitIgnoreContent: string; + try { const currentGitIgnoreContent = await fsPromises.readFile( gitIgnorePath, FS_OPTIONS ); + const currentGitIgnoreEntries = new Set( currentGitIgnoreContent.split("\n") ); + const missingGitIgnoreEntries = RECOMMENDED_GIT_IGNORE_CONTENT.split("\n") .filter((entry): boolean => !currentGitIgnoreEntries.has(entry)) .join("\n"); + newGitIgnoreContent = missingGitIgnoreEntries === "" ? currentGitIgnoreContent @@ -231,6 +248,7 @@ export const init = async (options: InitArg): Promise => { await initNewReadonlyFluenceConfig(commandObj); const srcMainAquaPath = await ensureSrcAquaMainPath(); + try { await fsPromises.access(srcMainAquaPath); } catch { diff --git a/src/commands/module/add.ts b/src/commands/module/add.ts index 52df76ec8..e49ab9a2e 100644 --- a/src/commands/module/add.ts +++ b/src/commands/module/add.ts @@ -64,13 +64,16 @@ export default class Add extends Command { async run(): Promise { const { args, flags } = await this.parse(Add); const isInteractive = getIsInteractive(flags); + const pathToModule: unknown = args[PATH_OR_URL] ?? (await input({ isInteractive, message: "Enter path to a module or url to .tar.gz archive", })); + assert(typeof pathToModule === "string"); + const serviceNameOrPath = flags.service ?? (await input({ @@ -79,22 +82,27 @@ export default class Add extends Command { FLUENCE_CONFIG_FILE_NAME )} or path to the service directory`, })); + const fluenceConfig = await initFluenceConfig(this); let servicePath = serviceNameOrPath; + if (hasKey(serviceNameOrPath, fluenceConfig?.services)) { const serviceGet = fluenceConfig?.services[serviceNameOrPath]?.get; assert(typeof serviceGet === "string"); servicePath = serviceGet; } + if (isUrl(servicePath)) { this.error( `Can't modify downloaded service ${color.yellow(servicePath)}` ); } + const serviceConfig = await initServiceConfig( path.resolve(servicePath), this ); + if (serviceConfig === null) { this.error( `Directory ${color.yellow(servicePath)} does not contain ${color.yellow( @@ -102,9 +110,11 @@ export default class Add extends Command { )}` ); } + const moduleName = flags[NAME_FLAG_NAME] ?? stringToCamelCaseName(path.basename(pathToModule)); + if (camelcase(moduleName) !== moduleName) { this.error( `Module name ${color.yellow( @@ -114,6 +124,7 @@ export default class Add extends Command { )} flag to specify service name` ); } + if (moduleName in serviceConfig.modules) { this.error( `You already have ${color.yellow(moduleName)} in ${color.yellow( @@ -125,6 +136,7 @@ export default class Add extends Command { )} manually` ); } + serviceConfig.modules = { ...serviceConfig.modules, [moduleName]: { @@ -136,7 +148,9 @@ export default class Add extends Command { ), }, }; + await serviceConfig.$commit(); + this.log( `Added ${color.yellow(moduleName)} to ${color.yellow( replaceHomeDir(path.resolve(servicePath)) diff --git a/src/commands/module/new.ts b/src/commands/module/new.ts index 4cced443e..3c1086421 100644 --- a/src/commands/module/new.ts +++ b/src/commands/module/new.ts @@ -48,6 +48,7 @@ export default class New extends Command { const pathToModuleDir: unknown = args[PATH]; assert(typeof pathToModuleDir === "string"); await generateNewModule(pathToModuleDir, this); + this.log( `Successfully generated template for new module at ${color.yellow( pathToModuleDir @@ -63,10 +64,12 @@ export const generateNewModule = async ( await fsPromises.mkdir(pathToModuleDir, { recursive: true }); const marineCli = await initMarineCli(commandObj); const name = path.basename(pathToModuleDir); + await marineCli({ command: "generate", flags: { init: true, name }, workingDir: pathToModuleDir, }); + await initNewReadonlyModuleConfig(pathToModuleDir, commandObj, name); }; diff --git a/src/commands/module/remove.ts b/src/commands/module/remove.ts index 8a2beee04..01e5adba2 100644 --- a/src/commands/module/remove.ts +++ b/src/commands/module/remove.ts @@ -63,10 +63,12 @@ export default class Remove extends Command { const { args, flags } = await this.parse(Remove); const isInteractive = getIsInteractive(flags); const nameOrPathOrUrlFromArgs: unknown = args[NAME_OR_PATH_OR_URL]; + assert( typeof nameOrPathOrUrlFromArgs === "string" || typeof nameOrPathOrUrlFromArgs === "undefined" ); + const nameOrPathOrUrl = nameOrPathOrUrlFromArgs ?? (await input({ @@ -75,7 +77,9 @@ export default class Remove extends Command { SERVICE_CONFIG_FILE_NAME )}, path to a module or url to .tar.gz archive`, })); + assert(typeof nameOrPathOrUrl === "string"); + const serviceNameOrPath = flags.service ?? (await input({ @@ -84,22 +88,27 @@ export default class Remove extends Command { FLUENCE_CONFIG_FILE_NAME )} or path to the service directory`, })); + const fluenceConfig = await initFluenceConfig(this); let servicePath = serviceNameOrPath; + if (hasKey(serviceNameOrPath, fluenceConfig?.services)) { const serviceGet = fluenceConfig?.services[serviceNameOrPath]?.get; assert(typeof serviceGet === "string"); servicePath = serviceGet; } + if (isUrl(servicePath)) { this.error( `Can't modify downloaded service ${color.yellow(servicePath)}` ); } + const serviceConfig = await initServiceConfig( path.resolve(servicePath), this ); + if (serviceConfig === null) { this.error( `Directory ${color.yellow(servicePath)} does not contain ${color.yellow( @@ -107,6 +116,7 @@ export default class Remove extends Command { )}` ); } + if (nameOrPathOrUrl === FACADE_MODULE_NAME) { this.error( `Each service must have a facade module, if you want to change it either override it in ${color.yellow( @@ -124,6 +134,7 @@ export default class Remove extends Command { Object.entries(serviceConfig.modules).find( ([, { get }]): boolean => get === nameOrPathOrUrl ) ?? []; + assert(typeof moduleName === "string"); delete serviceConfig.modules[moduleName]; } else { @@ -133,7 +144,9 @@ export default class Remove extends Command { )}` ); } + await serviceConfig.$commit(); + this.log( `Removed module ${color.yellow(nameOrPathOrUrl)} from ${color.yellow( servicePath diff --git a/src/commands/remove.ts b/src/commands/remove.ts index f6020c077..ecdc7f638 100644 --- a/src/commands/remove.ts +++ b/src/commands/remove.ts @@ -116,6 +116,7 @@ export const removeApp = async ({ replaceHomeDir(appConfig.$getPath()) )}` ); + const { keyPairName, services, relays } = appConfig; const keyPair = await getKeyPair({ commandObj, keyPairName, isInteractive }); const aquaCli = await initAquaCli(commandObj); @@ -124,9 +125,11 @@ export const removeApp = async ({ for (const [serviceName, servicesByName] of Object.entries(services)) { const notRemovedServicesByName: typeof servicesByName = {}; + for (const [deployId, services] of Object.entries(servicesByName)) { for (const service of services) { const { serviceId, peerId } = service; + try { // eslint-disable-next-line no-await-in-loop await aquaCli( @@ -149,6 +152,7 @@ export const removeApp = async ({ ); } catch (error) { commandObj.warn(`When removing service\n${String(error)}`); + notRemovedServicesByName[deployId] = [ ...(notRemovedServicesByName[deployId] ?? []), service, @@ -175,10 +179,12 @@ export const removeApp = async ({ await Promise.allSettled( pathsToRemove.map((path): Promise => fsPromises.unlink(path)) ); + return; } await generateDeployedAppAqua(notRemovedServices); + await generateRegisterApp({ deployedServices: notRemovedServices, aquaCli, @@ -186,6 +192,7 @@ export const removeApp = async ({ appConfig.services = notRemovedServices; await appConfig.$commit(); + commandObj.error( "Not all services were successful removed. Please make sure to remove all of them in order to continue" ); diff --git a/src/commands/run.ts b/src/commands/run.ts index c69b8570d..d74dfe9a9 100644 --- a/src/commands/run.ts +++ b/src/commands/run.ts @@ -110,12 +110,14 @@ export default class Run extends Command { const relay = flags.relay ?? getRandomRelayAddr(appConfig?.relays); const data = await getRunData(flags, this); + const imports: Array = [ ...(flags.import ?? []), await ensureFluenceAquaDir(), ]; const appJsonServicePath = await ensureFluenceTmpAppServiceJsonPath(); + if (appConfig !== null) { await fsPromises.writeFile( appJsonServicePath, @@ -123,8 +125,10 @@ export default class Run extends Command { FS_OPTIONS ); } + let result: string; const aquaCli = await initAquaCli(this); + try { result = await aquaCli( { @@ -179,6 +183,7 @@ const getRunData = async ( if (typeof dataPath === "string") { let data: string; + try { data = await fsPromises.readFile(dataPath, FS_OPTIONS); } catch { @@ -188,11 +193,13 @@ const getRunData = async ( } let parsedData: unknown; + try { parsedData = JSON.parse(data); } catch { commandObj.error(`Unable to parse ${color.yellow(dataPath)}`); } + if (!validateRunData(parsedData)) { commandObj.error( `Invalid ${color.yellow(dataPath)}: ${JSON.stringify( @@ -200,6 +207,7 @@ const getRunData = async ( )}` ); } + for (const key in parsedData) { if (Object.prototype.hasOwnProperty.call(parsedData, key)) { runData[key] = parsedData[key]; @@ -209,16 +217,19 @@ const getRunData = async ( if (typeof data === "string") { let parsedData: unknown; + try { parsedData = JSON.parse(data); } catch { commandObj.error(`Unable to parse --${DATA_FLAG_NAME}`); } + if (!validateRunData(parsedData)) { commandObj.error( `Invalid --${DATA_FLAG_NAME}: ${JSON.stringify(validateRunData.errors)}` ); } + for (const key in parsedData) { if (Object.prototype.hasOwnProperty.call(parsedData, key)) { runData[key] = parsedData[key]; diff --git a/src/commands/service/add.ts b/src/commands/service/add.ts index de4d4b1d3..58fa0da0a 100644 --- a/src/commands/service/add.ts +++ b/src/commands/service/add.ts @@ -58,10 +58,12 @@ export default class Add extends Command { const isInteractive = getIsInteractive(flags); await ensureFluenceProject(this, isInteractive); const pathOrUrlFromArgs: unknown = args[PATH_OR_URL]; + assert( typeof pathOrUrlFromArgs === "string" || typeof pathOrUrlFromArgs === "undefined" ); + await addService({ commandObj: this, nameFromFlags: flags[NAME_FLAG_NAME], @@ -84,15 +86,19 @@ export const addService = async ({ pathOrUrl: pathOrUrlFromArgs, }: AddServiceArg): Promise => { const fluenceConfig = await initFluenceConfig(commandObj); + if (fluenceConfig === null) { return commandObj.error( "You must init Fluence project first to add services" ); } + if (fluenceConfig.services === undefined) { fluenceConfig.services = {}; } + const serviceName = nameFromFlags ?? stringToCamelCaseName(pathOrUrlFromArgs); + if (camelcase(serviceName) !== serviceName) { commandObj.error( `Service name ${color.yellow( @@ -102,6 +108,7 @@ export const addService = async ({ )} flag to specify service name` ); } + if (serviceName in fluenceConfig.services) { commandObj.error( `You already have ${color.yellow(serviceName)} in ${color.yellow( @@ -113,6 +120,7 @@ export const addService = async ({ )} manually` ); } + fluenceConfig.services = { ...fluenceConfig.services, [serviceName]: { @@ -124,7 +132,9 @@ export const addService = async ({ ], }, }; + await fluenceConfig.$commit(); + commandObj.log( `Added ${color.yellow(serviceName)} to ${color.yellow( FLUENCE_CONFIG_FILE_NAME diff --git a/src/commands/service/new.ts b/src/commands/service/new.ts index 3a27b204a..03f226584 100644 --- a/src/commands/service/new.ts +++ b/src/commands/service/new.ts @@ -56,29 +56,36 @@ export default class New extends Command { const isInteractive = getIsInteractive(flags); await ensureFluenceProject(this, isInteractive); const servicePathFromArgs: unknown = args[PATH]; + assert( typeof servicePathFromArgs === "string" || typeof servicePathFromArgs === "undefined" ); + const servicePath = servicePathFromArgs ?? (await input({ isInteractive, message: "Enter service path" })); + const pathToModuleDir = path.join( servicePath, "modules", FACADE_MODULE_NAME ); + await generateNewModule(pathToModuleDir, this); + await initNewReadonlyServiceConfig( servicePath, this, path.relative(servicePath, pathToModuleDir) ); + this.log( `Successfully generated template for new service at ${color.yellow( servicePath )}` ); + if ( isInteractive && (await confirm({ diff --git a/src/commands/service/remove.ts b/src/commands/service/remove.ts index 70e40c3c4..216ef1475 100644 --- a/src/commands/service/remove.ts +++ b/src/commands/service/remove.ts @@ -48,10 +48,12 @@ export default class Remove extends Command { const isInteractive = getIsInteractive(flags); await ensureFluenceProject(this, isInteractive); const nameOrPathOrUrlFromArgs: unknown = args[NAME_OR_PATH_OR_URL]; + assert( typeof nameOrPathOrUrlFromArgs === "string" || typeof nameOrPathOrUrlFromArgs === "undefined" ); + const nameOrPathOrUrl = nameOrPathOrUrlFromArgs ?? (await input({ @@ -60,15 +62,19 @@ export default class Remove extends Command { FLUENCE_CONFIG_FILE_NAME )}, path to a service or url to .tar.gz archive`, })); + const fluenceConfig = await initFluenceConfig(this); + if (fluenceConfig === null) { this.error("You must init Fluence project first to remove services"); } + if (fluenceConfig.services === undefined) { this.error( `There are no services in ${color.yellow(FLUENCE_CONFIG_FILE_NAME)}` ); } + if (nameOrPathOrUrl in fluenceConfig.services) { delete fluenceConfig.services[nameOrPathOrUrl]; } else if ( @@ -80,6 +86,7 @@ export default class Remove extends Command { Object.entries(fluenceConfig.services).find( ([, { get }]): boolean => get === nameOrPathOrUrl ) ?? []; + assert(typeof serviceName === "string"); delete fluenceConfig.services[serviceName]; } else { @@ -89,10 +96,13 @@ export default class Remove extends Command { )}` ); } + if (Object.keys(fluenceConfig.services).length === 0) { delete fluenceConfig.services; } + await fluenceConfig.$commit(); + this.log( `Removed service ${color.yellow(nameOrPathOrUrl)} from ${color.yellow( FLUENCE_CONFIG_FILE_NAME diff --git a/src/commands/service/repl.ts b/src/commands/service/repl.ts index ca4c551b9..b71fe1f2c 100644 --- a/src/commands/service/repl.ts +++ b/src/commands/service/repl.ts @@ -71,10 +71,12 @@ export default class REPL extends Command { const { args, flags } = await this.parse(REPL); const isInteractive = getIsInteractive(flags); const nameOrPathOrUrlFromArgs: unknown = args[NAME_OR_PATH_OR_URL]; + assert( typeof nameOrPathOrUrlFromArgs === "string" || typeof nameOrPathOrUrlFromArgs === "undefined" ); + const nameOrPathOrUrl = nameOrPathOrUrlFromArgs ?? (await input({ @@ -87,17 +89,20 @@ export default class REPL extends Command { CliUx.ux.action.start( "Making sure service and modules are downloaded and built" ); + const serviceModules = await ensureServiceConfig({ commandObj: this, nameOrPathOrUrl, }); const marineCli = await initMarineCli(this); + const moduleConfigs = await ensureModuleConfigs({ serviceModules, commandObj: this, marineCli, }); + CliUx.ux.action.stop(); const fluenceTmpConfigTomlPath = await ensureFluenceTmpConfigTomlPath(); @@ -129,11 +134,14 @@ const ensureServiceConfig = async ({ nameOrPathOrUrl, }: EnsureServiceConfigArg): Promise> => { const fluenceConfig = await initReadonlyFluenceConfig(commandObj); + const get = fluenceConfig?.services?.[nameOrPathOrUrl]?.get ?? nameOrPathOrUrl; + const serviceDirPath = isUrl(get) ? await downloadService(get) : path.resolve(get); + const { facade, ...otherModules } = (await initReadonlyServiceConfig(serviceDirPath, commandObj))?.modules ?? CliUx.ux.action.stop(color.red("error")) ?? @@ -142,6 +150,7 @@ const ensureServiceConfig = async ({ SERVICE_CONFIG_FILE_NAME )}` ); + return [...Object.values(otherModules), facade].map( (mod): ServiceModule => ({ ...mod, @@ -149,6 +158,7 @@ const ensureServiceConfig = async ({ }) ); }; + /* eslint-disable camelcase */ type TomlModuleConfig = { name: string; @@ -180,6 +190,7 @@ const ensureModuleConfigs = ({ ({ get, ...overrides }): Promise => (async (): Promise => { const modulePath = isUrl(get) ? await downloadModule(get) : get; + const moduleConfig = (await initReadonlyModuleConfig(modulePath, commandObj)) ?? CliUx.ux.action.stop(color.red("error")) ?? @@ -210,26 +221,33 @@ const ensureModuleConfigs = ({ workingDir: path.dirname(moduleConfig.$getPath()), }); } + const load_from = getModuleWasmPath(overridenModules); + const tomlModuleConfig: TomlModuleConfig = { name, load_from, }; + if (loggerEnabled === true) { tomlModuleConfig.logger_enabled = true; } + if (typeof loggingMask === "number") { tomlModuleConfig.logging_mask = loggingMask; } + if (typeof maxHeapSize === "string") { tomlModuleConfig.max_heap_size = maxHeapSize; } + if (volumes !== undefined) { tomlModuleConfig.wasi = { mapped_dirs: volumes, preopened_files: [...new Set(Object.values(volumes))], }; } + if (preopenedFiles !== undefined) { tomlModuleConfig.wasi = { preopened_files: [ @@ -240,12 +258,15 @@ const ensureModuleConfigs = ({ ], }; } + if (envs !== undefined) { tomlModuleConfig.wasi = { envs }; } + if (mountedBinaries !== undefined) { tomlModuleConfig.mounted_binaries = mountedBinaries; } + return tomlModuleConfig; })() ) diff --git a/src/lib/configs/initConfig.ts b/src/lib/configs/initConfig.ts index d3d6cdf8c..e9797c9ab 100644 --- a/src/lib/configs/initConfig.ts +++ b/src/lib/configs/initConfig.ts @@ -49,13 +49,16 @@ const ensureSchema = async ({ : await getSchemaDirPath(commandObj), SCHEMAS_DIR_NAME ); + await fsPromises.mkdir(schemaDir, { recursive: true }); const schemaPath = path.join(schemaDir, `${name}.json`); + await fsPromises.writeFile( path.join(schemaDir, `${name}.json`), JSON.stringify(schema, null, 2) + "\n", FS_OPTIONS ); + return path.relative(configDirPath, schemaPath); }; @@ -77,8 +80,10 @@ const getConfigString = async ({ const schemaPathCommentStart = "# yaml-language-server: $schema="; const schemaPathComment = `${schemaPathCommentStart}${schemaRelativePath}`; let configString: string; + try { const fileContent = await fsPromises.readFile(configPath, FS_OPTIONS); + configString = fileContent.startsWith(schemaPathCommentStart) ? `${[schemaPathComment, ...fileContent.split("\n").slice(1)] .join("\n") @@ -88,6 +93,7 @@ const getConfigString = async ({ if (getDefaultConfig === undefined) { return null; } + configString = yamlDiffPatch( `${schemaPathComment}\n\n${ examples === undefined @@ -102,6 +108,7 @@ const getConfigString = async ({ await getDefaultConfig(commandObj) ); } + await fsPromises.writeFile(configPath, `${configString}\n`, FS_OPTIONS); return configString; }; @@ -133,6 +140,7 @@ const migrateConfig = async < configString: string; }> => { let migratedConfig = config; + for (const migration of migrations.slice(config.version)) { // eslint-disable-next-line no-await-in-loop migratedConfig = await migration(migratedConfig); @@ -143,6 +151,7 @@ const migrateConfig = async < parse(configString), migratedConfig ); + const latestConfig: unknown = parse(migratedConfigString); if (!validateLatestConfig(latestConfig)) { @@ -152,6 +161,7 @@ const migrateConfig = async < )}. Errors: ${JSON.stringify(validateLatestConfig.errors, null, 2)}` ); } + const maybeValidationError = validate !== undefined && (await validate(latestConfig, configPath)); @@ -163,6 +173,7 @@ const migrateConfig = async < )} after successful migration. Config after migration looks like this:\n\n${migratedConfigString}\n\nErrors: ${maybeValidationError}` ); } + if (configString !== migratedConfigString) { await fsPromises.writeFile( configPath, @@ -170,6 +181,7 @@ const migrateConfig = async < FS_OPTIONS ); } + return { latestConfig: latestConfig, configString: migratedConfigString, @@ -204,6 +216,7 @@ const ensureConfigIsValidLatest = async < )}` ); } + const maybeValidationError = validate !== undefined && (await validate(config, configPath)); @@ -290,6 +303,7 @@ export function getReadonlyConfigInitFunction< options: InitConfigOptions, getDefaultConfig?: GetDefaultConfig ): InitReadonlyFunctionWithDefault; + export function getReadonlyConfigInitFunction< Config extends BaseConfig, LatestConfig extends BaseConfig @@ -335,12 +349,15 @@ export function getReadonlyConfigInitFunction< getDefaultConfig, examples, }); + if (maybeConfigString === null) { return null; } + let configString = maybeConfigString; const config: unknown = parse(configString); + if (!validateAllConfigVersions(config)) { throw new Error( `Invalid config at ${color.yellow( @@ -354,6 +371,7 @@ export function getReadonlyConfigInitFunction< } let latestConfig: LatestConfig; + if (config.version < migrations.length) { ({ latestConfig, configString } = await migrateConfig({ config, @@ -401,6 +419,7 @@ export function getConfigInitFunction< options: InitConfigOptions, getDefaultConfig: GetDefaultConfig ): InitFunctionWithDefault; + export function getConfigInitFunction< Config extends BaseConfig, LatestConfig extends BaseConfig @@ -423,15 +442,18 @@ export function getConfigInitFunction< )} was already initialized. Please initialize readonly config instead or use previously initialized mutable config` ); } + initializedConfigs.add(configPath); const maybeInitializedReadonlyConfig = await getReadonlyConfigInitFunction( options, getDefaultConfig )(commandObj); + if (maybeInitializedReadonlyConfig === null) { return null; } + const initializedReadonlyConfig = maybeInitializedReadonlyConfig; let configString = initializedReadonlyConfig.$getConfigString(); @@ -470,6 +492,7 @@ export function getConfigInitFunction< if (configString !== newConfigString) { configString = newConfigString; + await fsPromises.writeFile( configPath, configString + "\n", diff --git a/src/lib/configs/project/app.ts b/src/lib/configs/project/app.ts index dd7e05e97..e8114612f 100644 --- a/src/lib/configs/project/app.ts +++ b/src/lib/configs/project/app.ts @@ -199,6 +199,7 @@ const migrations: Migrations = [ const { keyPairName, knownRelays, timestamp, services } = config; const newServices: ServicesV1 = {}; + for (const { name, peerId, serviceId, blueprintId } of services) { const service = { peerId, @@ -240,6 +241,7 @@ const migrations: Migrations = [ services, relays: relaysFromConfig, } = config; + const relays = typeof relaysFromConfig === "string" ? relaysFromConfig diff --git a/src/lib/configs/project/fluence.ts b/src/lib/configs/project/fluence.ts index c8bafc9b6..888d5235f 100644 --- a/src/lib/configs/project/fluence.ts +++ b/src/lib/configs/project/fluence.ts @@ -244,23 +244,29 @@ const validate: ConfigValidateFunction = ( if (config.services === undefined) { return true; } + const notUnique: Array<{ serviceName: string; notUniqueDeployIds: Set; }> = []; + for (const [serviceName, { deploy }] of Object.entries(config.services)) { const deployIds = new Set(); const notUniqueDeployIds = new Set(); + for (const { deployId } of deploy) { if (deployIds.has(deployId)) { notUniqueDeployIds.add(deployId); } + deployIds.add(deployId); } + if (notUniqueDeployIds.size > 0) { notUnique.push({ serviceName, notUniqueDeployIds }); } } + if (notUnique.length > 0) { return `Deploy ids must be unique. Not unique deploy ids found:\n${notUnique .map( @@ -269,6 +275,7 @@ const validate: ConfigValidateFunction = ( ) .join("\n")}`; } + return true; }; diff --git a/src/lib/configs/project/module.ts b/src/lib/configs/project/module.ts index a9a7bb4ce..3e9711c70 100644 --- a/src/lib/configs/project/module.ts +++ b/src/lib/configs/project/module.ts @@ -115,6 +115,7 @@ export const initReadonlyModuleConfig = ( commandObj: CommandObj ): Promise | null> => getReadonlyConfigInitFunction(getInitConfigOptions(configPath))(commandObj); + const getDefault: (name: string) => GetDefaultConfig = (name: string): GetDefaultConfig => (): LatestConfig => ({ @@ -122,6 +123,7 @@ const getDefault: (name: string) => GetDefaultConfig = type: "rust", name, }); + export const initNewReadonlyModuleConfig = ( configPath: string, commandObj: CommandObj, diff --git a/src/lib/configs/user/dependency.ts b/src/lib/configs/user/dependency.ts index c009cb42e..824cdfd2a 100644 --- a/src/lib/configs/user/dependency.ts +++ b/src/lib/configs/user/dependency.ts @@ -52,6 +52,7 @@ export const getVersionToUse = async ( ): Promise => { const version = (await initReadonlyDependencyConfig(commandObj)) ?.dependency?.[name]; + return typeof version === "string" ? version : recommendedVersion; }; diff --git a/src/lib/deployedApp.ts b/src/lib/deployedApp.ts index ecd49be50..13d94eb43 100644 --- a/src/lib/deployedApp.ts +++ b/src/lib/deployedApp.ts @@ -126,6 +126,7 @@ export const generateRegisterApp = async ( replaceHomeDir(await ensureFluenceAquaDeployedAppPath()) )}` ); + await generateRegisterAppTSorJS({ ...options, isJS: true }); await generateRegisterAppTSorJS({ ...options, isJS: false }); CliUx.ux.action.stop(); @@ -138,6 +139,7 @@ export const generateDeployedAppAqua = async ( services: ServicesV2 ): Promise => { const appServicesFilePath = await ensureFluenceAquaDeployedAppPath(); + const appServicesAqua = // Codegeneration: `export App @@ -170,5 +172,6 @@ ${Object.keys(services) service ${APP}("${APP}"): ${SERVICE_IDS}: -> ${SERVICES} `; + await fsPromises.writeFile(appServicesFilePath, appServicesAqua, FS_OPTIONS); }; diff --git a/src/lib/execPromise.ts b/src/lib/execPromise.ts index 445b864d0..6cba67d77 100644 --- a/src/lib/execPromise.ts +++ b/src/lib/execPromise.ts @@ -46,7 +46,9 @@ export const execPromise = ( if (typeof message === "string") { CliUx.ux.action.stop(color.red("Timed out")); } + childProcess.kill(); + rej( new Error( `Execution timed out: command didn't yield any result in ${color.yellow( @@ -67,11 +69,13 @@ export const execPromise = ( if (typeof message === "string") { CliUx.ux.action.stop(color.red("Failed")); } + rej( new Error( `Command execution failed:\n\n${stderr}\n\n${failedCommandText}\n` ) ); + return; } diff --git a/src/lib/helpers/downloadFile.ts b/src/lib/helpers/downloadFile.ts index 0ec96a0b8..03208a246 100644 --- a/src/lib/helpers/downloadFile.ts +++ b/src/lib/helpers/downloadFile.ts @@ -33,6 +33,7 @@ export const getHashOfString = (str: string): Promise => { md5Hash.on("readable", (): void => { // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment const data = md5Hash.read(); + // eslint-disable-next-line @typescript-eslint/strict-boolean-expressions if (data) { // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-call @@ -47,9 +48,11 @@ export const getHashOfString = (str: string): Promise => { const downloadFile = async (path: string, url: string): Promise => { const res = await fetch(url); + if (res.status === 404) { throw new Error(`Failed when downloading ${color.yellow(url)}`); } + const buffer = await res.buffer(); await fsPromises.writeFile(path, buffer); return path; @@ -75,10 +78,12 @@ const downloadAndDecompress = async ( dir: string ): Promise => { const dirPath = await getHashPath(get, dir); + try { await fsPromises.access(dirPath); return dirPath; } catch {} + await fsPromises.mkdir(dirPath, { recursive: true }); const archivePath = path.join(dirPath, ARCHIVE_FILE); await downloadFile(archivePath, get); diff --git a/src/lib/helpers/validations.ts b/src/lib/helpers/validations.ts index 64f823bbd..8be353bb3 100644 --- a/src/lib/helpers/validations.ts +++ b/src/lib/helpers/validations.ts @@ -22,13 +22,17 @@ export const validateUnique = ( getError: (notUniqueField: string) => string ): ValidationResult => { const set = new Set(); + for (const item of array) { const field = getField(item); + if (set.has(field)) { return getError(field); } + set.add(field); } + return true; }; diff --git a/src/lib/keypairs.ts b/src/lib/keypairs.ts index 9f59ebb76..91ade4ff9 100644 --- a/src/lib/keypairs.ts +++ b/src/lib/keypairs.ts @@ -43,6 +43,7 @@ const getUserKeyPair = async ({ const defaultKeyPair = userSecretsConfig.keyPairs.find( ({ name }): boolean => name === userSecretsConfig.defaultKeyPairName ); + assert(defaultKeyPair !== undefined); return defaultKeyPair; } @@ -60,6 +61,7 @@ const getUserKeyPair = async ({ if (!isInteractive) { commandObj.error(noUserKeyPairMessage); } + commandObj.warn(noUserKeyPairMessage); const readonlyProjectSecretsConfig = await initReadonlyProjectSecretsConfig( diff --git a/src/lib/marineCli.ts b/src/lib/marineCli.ts index c2de9721f..a34bc19c7 100644 --- a/src/lib/marineCli.ts +++ b/src/lib/marineCli.ts @@ -52,10 +52,12 @@ export const initMarineCli = async ( name: MARINE_CARGO_DEPENDENCY, commandObj, }); + await ensureCargoDependency({ name: CARGO_GENERATE_CARGO_DEPENDENCY, commandObj, }); + return async ({ command, flags, @@ -64,18 +66,22 @@ export const initMarineCli = async ( workingDir, }): Promise => { const cwd = process.cwd(); + if (workingDir !== undefined) { process.chdir(workingDir); } + const result = await execPromise( `${marineCliPath} ${command ?? ""}${unparseFlags(flags, commandObj)}`, message === undefined ? undefined : getMessageWithKeyValuePairs(message, keyValuePairs) ); + if (workingDir !== undefined) { process.chdir(cwd); } + return result; }; }; diff --git a/src/lib/npm.ts b/src/lib/npm.ts index 35dd1c953..2c7f61893 100644 --- a/src/lib/npm.ts +++ b/src/lib/npm.ts @@ -73,14 +73,17 @@ export const ensureNpmDependency = async ({ }: NpmDependencyArg): Promise => { const { bin, packageName, recommendedVersion } = npmDependencies[name]; const npmDirPath = await ensureUserFluenceNpmDir(commandObj); + const dependencyPath = commandObj.config.windows ? path.join(npmDirPath, bin) : path.join(npmDirPath, BIN_DIR_NAME, bin); + const version = await getVersionToUse(recommendedVersion, name, commandObj); try { await fsPromises.access(dependencyPath); const result = await getNpmDependencyVersion(dependencyPath); + if (!result.includes(version)) { throw new Error("Outdated"); } @@ -93,7 +96,9 @@ export const ensureNpmDependency = async ({ )} of ${packageName} to ${replaceHomeDir(npmDirPath)}`, commandObj, }); + const result = await getNpmDependencyVersion(dependencyPath); + if (!result.includes(version)) { return commandObj.error( `Not able to install version ${color.yellow( diff --git a/src/lib/prompt.ts b/src/lib/prompt.ts index da8099bd4..27e823e9c 100644 --- a/src/lib/prompt.ts +++ b/src/lib/prompt.ts @@ -181,6 +181,7 @@ const handleList = async ( } const firstChoice = choices[0]; + if ( choices.length === 1 && firstChoice !== undefined && @@ -191,12 +192,14 @@ const handleList = async ( isInteractive, flagName, }); + if (doConfirm) { return { result: firstChoice.value, choices, }; } + return { result: onNoChoices(), choices, diff --git a/src/lib/rust.ts b/src/lib/rust.ts index d95d0eafd..86b09acad 100644 --- a/src/lib/rust.ts +++ b/src/lib/rust.ts @@ -82,6 +82,7 @@ const ensureRust = async (commandObj: CommandObj): Promise => { RUST_TOOLCHAIN_REQUIRED_TO_INSTALL_MARINE )} rust toolchain` ); + if (!(await hasRequiredRustToolchain())) { commandObj.error( `Not able to install ${color.yellow( @@ -96,6 +97,7 @@ const ensureRust = async (commandObj: CommandObj): Promise => { `${RUSTUP} target add ${RUST_WASM32_WASI_TARGET}`, `Adding ${color.yellow(RUST_WASM32_WASI_TARGET)} rust target` ); + if (!(await hasRequiredRustTarget())) { commandObj.error( `Not able to install ${color.yellow( @@ -196,10 +198,12 @@ const isCorrectVersionInstalled = async ({ isGlobalDependency: true | undefined; }): Promise => { const { packageName, recommendedVersion } = cargoDependencies[name]; + const cratesTomlPath = await ensureUserFluenceCargoCratesPath( commandObj, isGlobalDependency ); + const version = await getVersionToUse(recommendedVersion, name, commandObj); try { @@ -207,6 +211,7 @@ const isCorrectVersionInstalled = async ({ cratesTomlPath, FS_OPTIONS ); + return cratesTomlContent.includes(`${packageName} ${version}`); } catch { return false; @@ -220,20 +225,24 @@ export const ensureCargoDependency = async ({ await ensureRust(commandObj); const dependency = cargoDependencies[name]; const { isGlobalDependency, packageName, recommendedVersion } = dependency; + const userFluenceCargoCratesPath = await ensureUserFluenceCargoCratesPath( commandObj, isGlobalDependency ); + const dependencyPath = path.join( await ensureUserFluenceCargoDir(commandObj, isGlobalDependency), BIN_DIR_NAME, packageName ); + if ( await isCorrectVersionInstalled({ name, commandObj, isGlobalDependency }) ) { return dependencyPath; } + const version = await getVersionToUse(recommendedVersion, name, commandObj); await cargoInstall({