diff --git a/docs/docs/workspaces.md b/docs/docs/workspaces.md index dabc85e8af..f0db0dda9e 100644 --- a/docs/docs/workspaces.md +++ b/docs/docs/workspaces.md @@ -38,12 +38,10 @@ you are currently working on. ## Limitations -- Compas only supports a single package manager (npm, yarn or pnpm) in a - workspace. - Compas assumes that nested projects are also setup correctly in your package manager and only runs installation (e.g `npm install`) in the root project. - For sibling projects, Compas runs the package manager in all individual - projects, but it is still limited to a single package manager. + For sibling projects, Compas runs the inferred package manager in each + project. - Compas stores its cache in the project that you started Compas in and removes the cache from referenced projects. This way Compas has a single source of truth. So the most efficient way of developing is to always start Compas from diff --git a/gen/compas.js b/gen/compas.js index a61ea59901..f029ead8dc 100644 --- a/gen/compas.js +++ b/gen/compas.js @@ -40,7 +40,7 @@ export function applyCompasStructure(generator) { projects: T.array() .values(T.string()) - .optional() + .default(`[]`) .docs( "Relative paths to projects. Each project is expected to provide their own configuration.", ), @@ -51,7 +51,7 @@ export function applyCompasStructure(generator) { shortcut: T.string(), command: T.array().values(T.string()).min(1), }) - .optional() + .default(`[]`) .docs("Available actions for this project."), }; @@ -68,8 +68,35 @@ export function applyCompasStructure(generator) { .loose(), T.object("cache").keys({ - version: T.string(), - config: T.reference("compas", "resolvedConfig").optional(), + version: T.string().docs("Compas version, used for cache invalidations."), + + config: T.reference("compas", "resolvedConfig") + .optional() + .docs( + "The resolved config. Managed by {@link ConfigLoaderIntegration}.", + ), + + rootDirectories: T.array() + .values(T.string()) + .optional() + .min(1) + .docs( + "Resolved project root directories. Managed by {@link RootDirectoriesIntegration}.", + ), + + cachesCleaned: T.bool() + .optional() + .docs( + "Did clean caches from project directories. Managed by {@link CacheCleanupIntegration}.", + ), + + packageManagerInstallCommand: T.generic() + .keys(T.string()) + .values(T.array().values(T.string())) + .optional() + .docs( + "The inferred package install command per rootDirectory. Managed by {@link PackageManagerIntegration}.", + ), }), ); } diff --git a/packages/compas/src/cli/bin.js b/packages/compas/src/cli/bin.js index 2b00b4533c..5a6cb7c841 100755 --- a/packages/compas/src/cli/bin.js +++ b/packages/compas/src/cli/bin.js @@ -1,8 +1,15 @@ #!/usr/bin/env node import { existsSync } from "node:fs"; -import { isNil } from "@compas/stdlib"; -import { configLoadEnvironment } from "../config.js"; +import { newLogger } from "@compas/stdlib"; +import { configLoadEnvironment } from "../shared/config.js"; +import { + debugDisable, + debugEnable, + debugPrint, + logger, + loggerEnable, +} from "../shared/output.js"; // Just execute some temporary command matching const args = process.argv.slice(2); @@ -10,18 +17,26 @@ const debug = args.includes("--debug"); if (debug) { args.splice(args.indexOf("--debug"), 1); + await debugEnable(); +} else { + debugDisable(); } +debugPrint({ + argv: process.argv, + args, +}); + if (args.length === 0) { if (!existsSync("./package.json")) { // eslint-disable-next-line no-console console.log(`Please run 'npx compas@latest init' to install Compas.`); } else { - // TODO: check if we are in a project or someone forgot to run 'compas init'. + // TODO: check if we are in a project with Compas installed or if we should nudge the user to run Compas init. We probably want to do this differently in the different modes. - // TODO: debug + const env = await configLoadEnvironment(false); - const env = await configLoadEnvironment("", !isNil(process.env.NODE_ENV)); + debugPrint(env); if (env.isCI) { const { ciMode } = await import("../main/ci/index.js"); @@ -34,15 +49,26 @@ if (args.length === 0) { await developmentMode(env); } } -} else if (args.length === 1) { - if (args[0] === "init") { - const { initCompas } = await import("../main/init/compas.js"); - await initCompas(); - } } else { - // eslint-disable-next-line no-console - console.log(`Unsupported command. Available commands: + const command = args.join(" "); + const env = await configLoadEnvironment(true); + + loggerEnable( + newLogger({ + ctx: { + type: env.appName, + }, + }), + ); + + if (command === "init") { + const { initCompas } = await import("../main/init/compas.js"); + await initCompas(env); + } else { + // eslint-disable-next-line no-console + logger.info(`Unsupported command. Available commands: - compas - compas init`); + } } diff --git a/packages/compas/src/config.js b/packages/compas/src/config.js deleted file mode 100644 index 6926f52ace..0000000000 --- a/packages/compas/src/config.js +++ /dev/null @@ -1,207 +0,0 @@ -import { existsSync } from "node:fs"; -import { mkdir, readFile, writeFile } from "node:fs/promises"; -import path from "node:path"; -import { - dirnameForModule, - environment, - isNil, - isProduction, - loggerDetermineDefaultDestination, - pathJoin, - refreshEnvironmentCache, -} from "@compas/stdlib"; -import dotenv from "dotenv"; -import { validateCompasConfig } from "./generated/compas/validators.js"; -import { debugTimeEnd, debugTimeStart } from "./output/debug.js"; -import { output } from "./output/static.js"; - -/** - * @typedef {{ - * isCI: boolean, - * isDevelopment: boolean, - * appName: string, - * compasVersion: string, - * nodeVersion: string, - * }} ConfigEnvironment - */ - -/** - * Load .env files, resolve the Compas version and information to determine in which mode we're booting. - * - * @param {string} projectDirectory - * @param {boolean} hasNodeEnvSet - * @returns {Promise} - */ -export async function configLoadEnvironment(projectDirectory, hasNodeEnvSet) { - debugTimeStart("config.environment"); - - const defaultDotEnvFile = pathJoin(projectDirectory, ".env"); - - if (!hasNodeEnvSet && !existsSync(defaultDotEnvFile)) { - // Write a default .env file, we only do this if a NODE_ENV is not explicitly set. - - output.config.environment.creating(); - - const dirname = process.cwd().split(path.sep).pop(); - await writeFile( - defaultDotEnvFile, - `NODE_ENV=development -APP_NAME=${dirname} -`, - ); - } - - // Load .env.local first, since existing values in `process.env` are not overwritten. - dotenv.config({ path: path.resolve(process.cwd(), ".env.local") }); - dotenv.config(); - - refreshEnvironmentCache(); - - const packageJson = JSON.parse( - await readFile( - pathJoin(dirnameForModule(import.meta), "../package.json"), - "utf-8", - ), - ); - - const env = { - isCI: environment.CI === "true", - isDevelopment: !isProduction(), - appName: environment.APP_NAME ?? process.cwd().split(path.sep).pop(), - compasVersion: packageJson.version - ? `Compas ${packageJson.version}` - : "Compas v0.0.0", - nodeVersion: process.version, - }; - - loggerDetermineDefaultDestination(); - - // Doesn't log anything here, the caller should do that after enabling the appropriate - // systems. - debugTimeEnd("config.environment"); - - return env; -} - -/** - * Load .env if exists, but other than that prepare for development mode. - * - * @returns {Promise} - */ -export async function configLoadReadOnlyEnvironment() { - // Load .env.local first, since existing values in `process.env` are not overwritten. - dotenv.config({ path: path.resolve(process.cwd(), ".env.local") }); - dotenv.config(); - - refreshEnvironmentCache(); - - environment.COMPAS_LOG_PRINTER = "pretty"; - - const packageJson = JSON.parse( - await readFile( - pathJoin(dirnameForModule(import.meta), "../package.json"), - "utf-8", - ), - ); - - const env = { - isCI: environment.CI === "true", - isDevelopment: isNil(process.env.NODE_ENV) || !isProduction(), - appName: environment.APP_NAME ?? process.cwd().split(path.sep).pop(), - compasVersion: packageJson.version - ? `Compas ${packageJson.version}` - : "Compas v0.0.0", - nodeVersion: process.version, - }; - - loggerDetermineDefaultDestination(); - - return env; -} - -/** - * Try to load the config recursively from disk. - * - * Returns undefined when the config couldn't be loaded, for soft errors like no config - * present, it returns a default empty config. - * - * @param {string} projectDirectory - * @param {boolean} isRootProject - * @returns {Promise} - */ -export async function configResolve(projectDirectory, isRootProject) { - const expectedFileLocation = pathJoin(projectDirectory, "config/compas.json"); - - if (isRootProject) { - debugTimeStart("config.resolve"); - output.config.resolve.starting(); - } - - if (!existsSync(expectedFileLocation)) { - if (isRootProject) { - output.config.resolve.creating(); - - await mkdir(pathJoin(projectDirectory, "config"), { recursive: true }); - await writeFile(expectedFileLocation, JSON.stringify({}, null, 2)); - - return await configResolve(projectDirectory, isRootProject); - } - - output.config.resolve.notFound(expectedFileLocation); - - return { - rootDirectory: projectDirectory, - - projects: [], - }; - } - - const rawConfigContents = await readFile(expectedFileLocation, "utf-8"); - - let parsedConfigContents = undefined; - try { - parsedConfigContents = JSON.parse(rawConfigContents); - } catch (e) { - output.config.resolve.parseError(e, expectedFileLocation); - - return undefined; - } - - const { error, value } = validateCompasConfig(parsedConfigContents); - - if (error) { - output.config.resolve.validationError(error, expectedFileLocation); - - return undefined; - } - - const projects = value.projects - ? await Promise.all( - value.projects.map((it) => - configResolve(pathJoin(projectDirectory, it), false), - ), - ) - : []; - - if (projects.some((it) => isNil(it))) { - // Can't resolve if a sub config returns undefined. The user is already notified. - return undefined; - } - - /** @type {import("./generated/common/types.d.ts").CompasResolvedConfig} */ - const resolvedConfig = { - ...value, - - rootDirectory: projectDirectory, - - // @ts-expect-error - projects, - }; - - if (isRootProject) { - debugTimeEnd("config.resolve"); - output.config.resolve.resolved(resolvedConfig); - } - - return resolvedConfig; -} diff --git a/packages/compas/src/config.test.js b/packages/compas/src/config.test.js deleted file mode 100644 index f4bd1774d8..0000000000 --- a/packages/compas/src/config.test.js +++ /dev/null @@ -1,205 +0,0 @@ -import { existsSync } from "node:fs"; -import { mkdir, readFile, rm, writeFile } from "node:fs/promises"; -import { mainTestFn, test } from "@compas/cli"; -import { isNil, pathJoin } from "@compas/stdlib"; -import { configLoadEnvironment, configResolve } from "./config.js"; - -mainTestFn(import.meta); - -test("compas/config/environment", (t) => { - t.jobs = 2; - - const baseDirectory = ".cache/test/config"; - - const getFixtureDirectory = async (name) => { - const dir = pathJoin(baseDirectory, name); - - await rm(dir, { force: true, recursive: true }); - await mkdir(dir, { recursive: true }); - - return dir; - }; - - t.test("configLoadEnvironment", (t) => { - t.test("writes default .env if no .env is present", async (t) => { - const fixtureDirectory = await getFixtureDirectory( - "environment/default-0", - ); - - const env = await configLoadEnvironment(fixtureDirectory, false); - - t.ok(existsSync(pathJoin(fixtureDirectory, ".env"))); - t.equal( - await readFile(pathJoin(fixtureDirectory, ".env"), "utf-8"), - "NODE_ENV=development\nAPP_NAME=compas\n", - ); - - t.equal(env.isDevelopment, true); - t.equal(env.appName, "compas"); - }); - - t.test("does not write default .env if NODE_ENV is set", async (t) => { - const fixtureDirectory = await getFixtureDirectory( - "environment/default-1", - ); - - const env = await configLoadEnvironment(fixtureDirectory, true); - - t.ok(!existsSync(pathJoin(fixtureDirectory, ".env"))); - - t.equal(env.isDevelopment, true); - t.equal(env.appName, "compas"); - }); - }); - - t.test("configResolve", (t) => { - t.test( - "write default config if no config is present in root", - async (t) => { - const fixtureDirectory = await getFixtureDirectory("resolve/default-0"); - - const config = await configResolve(fixtureDirectory, true); - - t.ok(existsSync(pathJoin(fixtureDirectory, "config/compas.json"))); - t.equal( - await readFile( - pathJoin(fixtureDirectory, "config/compas.json"), - "utf-8", - ), - "{}", - ); - t.deepEqual(JSON.parse(JSON.stringify(config)), { - rootDirectory: fixtureDirectory, - projects: [], - }); - }, - ); - - t.test( - "returns default config if no config is present in non root projects", - async (t) => { - const fixtureDirectory = await getFixtureDirectory("resolve/default-1"); - - const config = await configResolve(fixtureDirectory, false); - - t.ok(!existsSync(pathJoin(fixtureDirectory, "config/compas.json"))); - t.deepEqual(config, { - rootDirectory: fixtureDirectory, - projects: [], - }); - }, - ); - - t.test("returns undefined when the file can't be parsed", async (t) => { - const fixtureDirectory = await getFixtureDirectory( - "resolve/parse-error-0", - ); - - await mkdir(pathJoin(fixtureDirectory, "config")); - await writeFile(pathJoin(fixtureDirectory, "config/compas.json"), "{"); - - const config = await configResolve(fixtureDirectory, true); - - t.ok(isNil(config)); - }); - - t.test( - "returns undefined when the config doesn't pass the validators", - async (t) => { - const fixtureDirectory = await getFixtureDirectory( - "resolve/validate-error-0", - ); - - await mkdir(pathJoin(fixtureDirectory, "config")); - await writeFile( - pathJoin(fixtureDirectory, "config/compas.json"), - JSON.stringify({ - projects: [1], - }), - ); - - const config = await configResolve(fixtureDirectory, true); - - t.ok(isNil(config)); - }, - ); - - t.test( - "returns undefined when a nested project doesn't pass the validators", - async (t) => { - const fixtureDirectory = await getFixtureDirectory( - "resolve/validate-error-1", - ); - const nestedFixtureDirectory = await getFixtureDirectory( - "resolve/validate-error-1/nested-0", - ); - - await mkdir(pathJoin(fixtureDirectory, "config")); - await writeFile( - pathJoin(fixtureDirectory, "config/compas.json"), - JSON.stringify({ - projects: ["nested-0"], - }), - ); - - await mkdir(pathJoin(nestedFixtureDirectory, "config")); - await writeFile( - pathJoin(nestedFixtureDirectory, "config/compas.json"), - JSON.stringify({ - projects: [1], - }), - ); - - const config = await configResolve(fixtureDirectory, false); - - t.ok(isNil(config)); - }, - ); - - t.test("returns the resolved config", async (t) => { - const fixtureDirectory = await getFixtureDirectory("resolve/ok-0"); - - await mkdir(pathJoin(fixtureDirectory, "config")); - await writeFile( - pathJoin(fixtureDirectory, "config/compas.json"), - JSON.stringify({ - projects: [], - }), - ); - - const config = await configResolve(fixtureDirectory, true); - - t.deepEqual(JSON.parse(JSON.stringify(config)), { - rootDirectory: fixtureDirectory, - projects: [], - }); - }); - - t.test("returns the resolved config recursively", async (t) => { - const fixtureDirectory = await getFixtureDirectory("resolve/ok-1"); - const nestedFixtureDirectory = await getFixtureDirectory( - "resolve/ok-1/nested-0", - ); - - await mkdir(pathJoin(fixtureDirectory, "config")); - await writeFile( - pathJoin(fixtureDirectory, "config/compas.json"), - JSON.stringify({ - projects: ["nested-0"], - }), - ); - - const config = await configResolve(fixtureDirectory, true); - - t.deepEqual(JSON.parse(JSON.stringify(config)), { - rootDirectory: fixtureDirectory, - projects: [ - { - rootDirectory: nestedFixtureDirectory, - projects: [], - }, - ], - }); - }); - }); -}); diff --git a/packages/compas/src/generated/common/structure.json b/packages/compas/src/generated/common/structure.json index d58e46cd39..f6c07b1762 100644 --- a/packages/compas/src/generated/common/structure.json +++ b/packages/compas/src/generated/common/structure.json @@ -14,7 +14,7 @@ "relations": [], "keys": { "version": { - "docString": "", + "docString": "Compas version, used for cache invalidations.", "isOptional": false, "validator": { "allowNull": false, @@ -27,7 +27,7 @@ "type": "string" }, "config": { - "docString": "", + "docString": "The resolved config. Managed by {@link ConfigLoaderIntegration}.", "isOptional": true, "validator": {}, "sql": {}, @@ -37,6 +37,80 @@ "name": "resolvedConfig", "uniqueName": "CompasResolvedConfig" } + }, + "rootDirectories": { + "docString": "Resolved project root directories. Managed by {@link RootDirectoriesIntegration}.", + "isOptional": true, + "validator": { + "convert": false, + "min": 1 + }, + "sql": {}, + "type": "array", + "values": { + "docString": "", + "isOptional": false, + "validator": { + "allowNull": false, + "trim": false, + "lowerCase": false, + "upperCase": false, + "min": 1 + }, + "sql": {}, + "type": "string" + } + }, + "cachesCleaned": { + "docString": "Did clean caches from project directories. Managed by {@link CacheCleanupIntegration}.", + "isOptional": true, + "validator": { + "allowNull": false + }, + "sql": {}, + "type": "boolean" + }, + "packageManagerInstallCommand": { + "docString": "The inferred package install command per rootDirectory. Managed by {@link PackageManagerIntegration}.", + "isOptional": true, + "validator": {}, + "sql": {}, + "type": "generic", + "keys": { + "docString": "", + "isOptional": false, + "validator": { + "allowNull": false, + "trim": false, + "lowerCase": false, + "upperCase": false, + "min": 1 + }, + "sql": {}, + "type": "string" + }, + "values": { + "docString": "", + "isOptional": false, + "validator": { + "convert": false + }, + "sql": {}, + "type": "array", + "values": { + "docString": "", + "isOptional": false, + "validator": { + "allowNull": false, + "trim": false, + "lowerCase": false, + "upperCase": false, + "min": 1 + }, + "sql": {}, + "type": "string" + } + } } } }, @@ -153,6 +227,7 @@ }, "sql": {}, "type": "array", + "defaultValue": "[]", "values": { "docString": "", "isOptional": false, @@ -175,6 +250,7 @@ }, "sql": {}, "type": "array", + "defaultValue": "[]", "values": { "docString": "", "isOptional": false, @@ -387,6 +463,7 @@ }, "sql": {}, "type": "array", + "defaultValue": "[]", "values": { "docString": "", "isOptional": false, diff --git a/packages/compas/src/generated/common/types.d.ts b/packages/compas/src/generated/common/types.d.ts index 180eb71b0c..89311f8b2e 100644 --- a/packages/compas/src/generated/common/types.d.ts +++ b/packages/compas/src/generated/common/types.d.ts @@ -35,16 +35,108 @@ export type CompasResolvedConfig = { /** * Available actions for this project. */ - "actions"?: ({ + "actions": ({ "name": string; "shortcut": string; "command": (string)[]; - })[]|undefined; + })[]; }; export type CompasCache = { + + /** + * Compas version, used for cache invalidations. + */ "version": string; + + /** + * The resolved config. Managed by {@link ConfigLoaderIntegration}. + */ "config"?: CompasResolvedConfig|undefined; + + /** + * Resolved project root directories. Managed by {@link RootDirectoriesIntegration}. + */ + "rootDirectories"?: (string)[]|undefined; + + /** + * Did clean caches from project directories. Managed by {@link CacheCleanupIntegration}. + */ + "cachesCleaned"?: boolean|undefined; + + /** + * The inferred package install command per rootDirectory. Managed by {@link PackageManagerIntegration}. + */ + "packageManagerInstallCommand"?: { [key: string]: (string)[]}|undefined; +}; + +export type CompasResolvedConfigInput = { + "rootDirectory": string; + + /** + * Old @compas/cli config + */ + "cli"?: { + + /** + * Array of directories relative to the project root. All JavaScript files will be imported by the CLI and checked if it exports a 'cliDefinition'. + */ + "commandDirectories"?: (string)[]|undefined; + + /** + * Project level watch options, applied to all commands running in 'watch' mode via the Compas CLI. + */ + "globalWatchOptions"?: { + + /** + * Add file extensions that should be watched + */ + "extensions"?: (string)[]|undefined; + + /** + * Remove directories from being watched, this has precedence over the included extensions + */ + "ignorePatterns"?: (string)[]|undefined; + }|undefined; + }|undefined; + "projects": (CompasResolvedConfigInput)[]; + + /** + * Available actions for this project. + */ + "actions"?: ({ + "name": string; + "shortcut": string; + "command": (string)[]; + })[]|undefined; +}; + +export type CompasCacheInput = { + + /** + * Compas version, used for cache invalidations. + */ + "version": string; + + /** + * The resolved config. Managed by {@link ConfigLoaderIntegration}. + */ + "config"?: CompasResolvedConfigInput|undefined; + + /** + * Resolved project root directories. Managed by {@link RootDirectoriesIntegration}. + */ + "rootDirectories"?: (string)[]|undefined; + + /** + * Did clean caches from project directories. Managed by {@link CacheCleanupIntegration}. + */ + "cachesCleaned"?: boolean|"true"|"false"|undefined; + + /** + * The inferred package install command per rootDirectory. Managed by {@link PackageManagerIntegration}. + */ + "packageManagerInstallCommand"?: { [key: string]: (string)[]}|undefined; }; export type CompasConfig = { @@ -76,6 +168,50 @@ export type CompasConfig = { }|undefined; }|undefined; + /** + * Relative paths to projects. Each project is expected to provide their own configuration. + */ + "projects": (string)[]; + + /** + * Available actions for this project. + */ + "actions": ({ + "name": string; + "shortcut": string; + "command": (string)[]; + })[]; +}; + +export type CompasConfigInput = { + + /** + * Old @compas/cli config + */ + "cli"?: { + + /** + * Array of directories relative to the project root. All JavaScript files will be imported by the CLI and checked if it exports a 'cliDefinition'. + */ + "commandDirectories"?: (string)[]|undefined; + + /** + * Project level watch options, applied to all commands running in 'watch' mode via the Compas CLI. + */ + "globalWatchOptions"?: { + + /** + * Add file extensions that should be watched + */ + "extensions"?: (string)[]|undefined; + + /** + * Remove directories from being watched, this has precedence over the included extensions + */ + "ignorePatterns"?: (string)[]|undefined; + }|undefined; + }|undefined; + /** * Relative paths to projects. Each project is expected to provide their own configuration. */ diff --git a/packages/compas/src/generated/compas/validators.js b/packages/compas/src/generated/compas/validators.js index 9b534a0356..83cd937983 100644 --- a/packages/compas/src/generated/compas/validators.js +++ b/packages/compas/src/generated/compas/validators.js @@ -10,7 +10,7 @@ */ /** - * @param {import("../common/types.js").CompasCache|any} value + * @param {import("../common/types.js").CompasCacheInput|any} value * @returns {Either} */ export function validateCompasCache(value) { @@ -32,7 +32,13 @@ export function validateCompasCache(value) { }; } else { /** @type {Set} */ - const knownKeys0 = new Set(["version", "config"]); + const knownKeys0 = new Set([ + "version", + "config", + "rootDirectories", + "cachesCleaned", + "packageManagerInstallCommand", + ]); for (const key of Object.keys(value)) { if ( !knownKeys0.has(key) && @@ -51,7 +57,13 @@ export function validateCompasCache(value) { break; } } - result = { version: undefined, config: undefined }; + result = { + version: undefined, + config: undefined, + rootDirectories: undefined, + cachesCleaned: undefined, + packageManagerInstallCommand: undefined, + }; if (value["version"] === null || value["version"] === undefined) { errorMap[`$.version`] = { @@ -88,6 +100,231 @@ export function validateCompasCache(value) { } result["config"] = refResult2.value; } + if ( + value["rootDirectories"] === null || + value["rootDirectories"] === undefined + ) { + result["rootDirectories"] = undefined; + } else { + /** @type {ValidatorErrorMap} */ + const intermediateErrorMap4 = {}; + /** @type {any[]} */ + let intermediateResult4 = []; + /** @type {any|any[]} */ + let intermediateValue4 = value["rootDirectories"]; + + if (!Array.isArray(intermediateValue4)) { + errorMap[`$.rootDirectories`] = { + key: "validator.array", + value: intermediateValue4, + }; + } else { + if (intermediateValue4.length < 1) { + errorMap[`$.rootDirectories`] = { + key: "validator.length", + minLength: 1, + foundLength: intermediateValue4.length, + }; + } + result["rootDirectories"] = []; + for (let i4 = 0; i4 < intermediateValue4.length; ++i4) { + if ( + intermediateValue4[i4] === null || + intermediateValue4[i4] === undefined + ) { + intermediateErrorMap4[`$.${i4}`] = { + key: "validator.undefined", + }; + } else { + /** @type {string} */ + let convertedString4 = intermediateValue4[i4]; + if (typeof convertedString4 !== "string") { + intermediateErrorMap4[`$.${i4}`] = { + key: "validator.string", + }; + } else { + if (convertedString4.length < 1) { + intermediateErrorMap4[`$.${i4}`] = { + key: "validator.length", + minLength: 1, + }; + } else { + intermediateResult4[i4] = convertedString4; + } + } + } + } + } + if (Object.keys(intermediateErrorMap4).length) { + for (const errorKey of Object.keys(intermediateErrorMap4)) { + errorMap[`$.rootDirectories${errorKey.substring(1)}`] = + intermediateErrorMap4[errorKey]; + } + } else { + result["rootDirectories"] = intermediateResult4; + } + } + if ( + value["cachesCleaned"] === null || + value["cachesCleaned"] === undefined + ) { + result["cachesCleaned"] = undefined; + } else { + if ( + value["cachesCleaned"] === true || + value["cachesCleaned"] === "true" || + value["cachesCleaned"] === 1 || + value["cachesCleaned"] === "1" + ) { + result["cachesCleaned"] = true; + } else if ( + value["cachesCleaned"] === false || + value["cachesCleaned"] === "false" || + value["cachesCleaned"] === 0 || + value["cachesCleaned"] === "0" + ) { + result["cachesCleaned"] = false; + } else { + errorMap[`$.cachesCleaned`] = { + key: "validator.type", + expectedType: "boolean", + }; + } + } + if ( + value["packageManagerInstallCommand"] === null || + value["packageManagerInstallCommand"] === undefined + ) { + result["packageManagerInstallCommand"] = undefined; + } else { + if ( + typeof value["packageManagerInstallCommand"] !== "object" || + Array.isArray(value["packageManagerInstallCommand"]) + ) { + errorMap[`$.packageManagerInstallCommand`] = { + key: "validator.generic", + }; + } else { + result["packageManagerInstallCommand"] = {}; + for (let genericKeyInput5 of Object.keys( + value["packageManagerInstallCommand"], + )) { + /** @type {any} */ + let genericKeyResult6 = undefined; + /** @type {ValidatorErrorMap} */ + const genericKeyErrorMap7 = {}; + if (genericKeyInput5 === null || genericKeyInput5 === undefined) { + genericKeyErrorMap7[`$`] = { + key: "validator.undefined", + }; + } else { + /** @type {string} */ + let convertedString8 = genericKeyInput5; + if (typeof convertedString8 !== "string") { + genericKeyErrorMap7[`$`] = { + key: "validator.string", + }; + } else { + if (convertedString8.length < 1) { + genericKeyErrorMap7[`$`] = { + key: "validator.length", + minLength: 1, + }; + } else { + genericKeyResult6 = convertedString8; + } + } + } + if (Object.keys(genericKeyErrorMap7).length !== 0) { + if (errorMap[`$.packageManagerInstallCommand`]) { + errorMap[`$.packageManagerInstallCommand`].inputs.push({ + key: genericKeyInput5, + errors: genericKeyErrorMap7, + }); + } else { + errorMap[`$.packageManagerInstallCommand`] = { + key: "validator.generic", + inputs: [ + { key: genericKeyInput5, errors: genericKeyErrorMap7 }, + ], + }; + } + } else { + if ( + value["packageManagerInstallCommand"][genericKeyResult6] === + null || + value["packageManagerInstallCommand"][genericKeyResult6] === + undefined + ) { + errorMap[ + `$.packageManagerInstallCommand.${genericKeyResult6}` + ] = { + key: "validator.undefined", + }; + } else { + /** @type {ValidatorErrorMap} */ + const intermediateErrorMap9 = {}; + /** @type {any[]} */ + let intermediateResult9 = []; + /** @type {any|any[]} */ + let intermediateValue9 = + value["packageManagerInstallCommand"][genericKeyResult6]; + + if (!Array.isArray(intermediateValue9)) { + errorMap[ + `$.packageManagerInstallCommand.${genericKeyResult6}` + ] = { + key: "validator.array", + value: intermediateValue9, + }; + } else { + result["packageManagerInstallCommand"][genericKeyResult6] = + []; + for (let i9 = 0; i9 < intermediateValue9.length; ++i9) { + if ( + intermediateValue9[i9] === null || + intermediateValue9[i9] === undefined + ) { + intermediateErrorMap9[`$.${i9}`] = { + key: "validator.undefined", + }; + } else { + /** @type {string} */ + let convertedString9 = intermediateValue9[i9]; + if (typeof convertedString9 !== "string") { + intermediateErrorMap9[`$.${i9}`] = { + key: "validator.string", + }; + } else { + if (convertedString9.length < 1) { + intermediateErrorMap9[`$.${i9}`] = { + key: "validator.length", + minLength: 1, + }; + } else { + intermediateResult9[i9] = convertedString9; + } + } + } + } + } + if (Object.keys(intermediateErrorMap9).length) { + for (const errorKey of Object.keys(intermediateErrorMap9)) { + errorMap[ + `$.packageManagerInstallCommand.${genericKeyResult6}${errorKey.substring( + 1, + )}` + ] = intermediateErrorMap9[errorKey]; + } + } else { + result["packageManagerInstallCommand"][genericKeyResult6] = + intermediateResult9; + } + } + } + } + } + } } } if (Object.keys(errorMap).length > 0) { @@ -97,7 +334,7 @@ export function validateCompasCache(value) { } /** - * @param {import("../common/types.js").CompasResolvedConfig|any} value + * @param {import("../common/types.js").CompasResolvedConfigInput|any} value * @returns {Either} */ export function validateCompasResolvedConfig(value) { @@ -469,7 +706,7 @@ export function validateCompasResolvedConfig(value) { } } if (value["actions"] === null || value["actions"] === undefined) { - result["actions"] = undefined; + result["actions"] = []; } else { /** @type {ValidatorErrorMap} */ const intermediateErrorMap5 = {}; @@ -671,7 +908,7 @@ export function validateCompasResolvedConfig(value) { } /** - * @param {import("../common/types.js").CompasConfig|any} value + * @param {import("../common/types.js").CompasConfigInput|any} value * @returns {Either} */ export function validateCompasConfig(value) { @@ -967,7 +1204,7 @@ export function validateCompasConfig(value) { } } if (value["projects"] === null || value["projects"] === undefined) { - result["projects"] = undefined; + result["projects"] = []; } else { /** @type {ValidatorErrorMap} */ const intermediateErrorMap3 = {}; @@ -1021,7 +1258,7 @@ export function validateCompasConfig(value) { } } if (value["actions"] === null || value["actions"] === undefined) { - result["actions"] = undefined; + result["actions"] = []; } else { /** @type {ValidatorErrorMap} */ const intermediateErrorMap4 = {}; diff --git a/packages/compas/src/index.js b/packages/compas/src/index.js deleted file mode 100644 index 0682aa9b9e..0000000000 --- a/packages/compas/src/index.js +++ /dev/null @@ -1 +0,0 @@ -export const $compas = ""; diff --git a/packages/compas/src/main/ci/index.js b/packages/compas/src/main/ci/index.js index 5be57cb7df..9a21bb8132 100644 --- a/packages/compas/src/main/ci/index.js +++ b/packages/compas/src/main/ci/index.js @@ -1,22 +1,32 @@ import { newLogger } from "@compas/stdlib"; -import { configResolve } from "../../config.js"; -import { logger, loggerEnable } from "../../output/log.js"; -import { output } from "../../output/static.js"; +import { logger, loggerEnable } from "../../shared/output.js"; /** * Run Compas in CI mode * - * @param {import("../../config.js").ConfigEnvironment} env + * @param {import("../../shared/config.js").ConfigEnvironment} env * @returns {Promise} */ -export async function ciMode(env) { - loggerEnable(newLogger()); - output.config.environment.loaded(env); +export function ciMode(env) { + loggerEnable( + newLogger({ + ctx: { + type: env.appName, + }, + }), + ); - const config = await configResolve("", true); + logger.info({ + message: `Starting up ${env.appName} with ${env.compasVersion} in CI.`, + }); + logger.info({ + message: + "Thank you for trying out the new Compas CLI. This is still a work in progress. Checkout https://github.com/compasjs/compas/issues/2774 for planned features and known issues.", + }); logger.info({ - env, - config, + message: "TODO: a future update will do more things...", }); + + return Promise.resolve(); } diff --git a/packages/compas/src/main/development/cache.js b/packages/compas/src/main/development/cache.js index 8421514564..2847807f22 100644 --- a/packages/compas/src/main/development/cache.js +++ b/packages/compas/src/main/development/cache.js @@ -1,93 +1,132 @@ +/* + - Load .cache/compas/cache.json + - Parse errors + - Run through validators + - Version mismatch + - Default to new cache + clean file watcher snapshots in all configured directories + - Load up config + - Start up file watchers + - Watcher snapshot should be stored per top-level watched directory + - Clean any found cache in a directory + + - Integrate with State somehow + - Config should be retrievable + - Derivatives from config should be cached + - Derivatives from config should be notified on change + - File changes should be executed in order of listener submissions + + */ + import { existsSync } from "node:fs"; -import { mkdir, readFile, writeFile } from "node:fs/promises"; +import { readFile, rm } from "node:fs/promises"; import { AppError, pathJoin } from "@compas/stdlib"; import { validateCompasCache } from "../../generated/compas/validators.js"; -import { debugTimeEnd, debugTimeStart } from "../../output/debug.js"; -import { output } from "../../output/static.js"; -import { watcherWriteSnapshot } from "./watcher.js"; +import { writeFileChecked } from "../../shared/fs.js"; +import { + debugPrint, + debugTimeEnd, + debugTimeStart, +} from "../../shared/output.js"; -/** - * @typedef {import("../../generated/common/types.js").CompasCache} Cache - */ +const CACHE_PATH = ".cache/compas/cache.json"; /** - * Load the cache from disk. Runs the cache through the validators and validates if the - * loaded cache works with the provided Compas version. + * Load the cache based on the working directory. * - * @param {string} projectDirectory * @param {string} compasVersion - * @returns {Promise} + * @returns {Promise<{ + * empty: boolean, + * cache: import("../../generated/common/types.js").CompasCache, + * }>} */ -export async function cacheLoadFromDisk(projectDirectory, compasVersion) { +export async function cacheLoad(compasVersion) { debugTimeStart("cache.load"); - const cachePath = pathJoin(projectDirectory, ".cache/compas/cache.json"); - const defaultConfig = { + const defaultCache = { version: compasVersion, }; - if (!existsSync(cachePath)) { - output.cache.notExisting(); + if (!existsSync(CACHE_PATH)) { + debugPrint("Cache not found."); - return defaultConfig; + debugTimeEnd("cache.load"); + return { + empty: true, + cache: defaultCache, + }; } let _cache = undefined; try { - _cache = JSON.parse(await readFile(cachePath, "utf-8")); - } catch (e) { - output.cache.errorReadingCache(e); + _cache = JSON.parse(await readFile(CACHE_PATH, "utf-8")); + } catch { + debugPrint("Cache not parseable"); + + await cacheClean(); - return defaultConfig; + debugTimeEnd("cache.load"); + return { + empty: true, + cache: defaultCache, + }; } const { value, error } = validateCompasCache(_cache); if (error) { - output.cache.errorValidatingCache(error); + debugPrint("Cache not valid."); + + await cacheClean(); - return defaultConfig; + debugTimeEnd("cache.load"); + return { + empty: true, + cache: defaultCache, + }; } - if (value.version !== compasVersion) { - output.cache.invalidCompasVersion(value.version, compasVersion); + if (value.version !== defaultCache.version) { + debugPrint("Cache from old version"); - return defaultConfig; + await cacheClean(); + + debugTimeEnd("cache.load"); + return { + empty: true, + cache: defaultCache, + }; } - output.cache.loaded(value); debugTimeEnd("cache.load"); - return value; + return { + empty: false, + cache: value, + }; } /** - * Writes the cache to disk. Before writing the cache, we run the validator to make sure - * that we don't write an invalid cache. + * Clean the cache for the specific project. * - * @param {string} projectDirectory - * @param {Cache} cache + * @param {string} project * @returns {Promise} */ -export async function cacheWriteToDisk(projectDirectory, cache) { - debugTimeStart("cache.write"); +export async function cacheClean(project = "") { + const cacheFile = pathJoin(project, CACHE_PATH); + + await rm(cacheFile, { force: true }); +} - const cachePath = pathJoin(projectDirectory, ".cache/compas/cache.json"); +export async function cachePersist(cache) { + const { error, value } = validateCompasCache(cache); - const { error } = validateCompasCache(cache); if (error) { throw AppError.serverError({ - message: - "Compas failed to set a valid cache entry. Please copy and paste this error (removing private parts of the stacktrace) in to a new issue on GitHub.", + message: "Invariant failed. Could not validate cache before persisting.", error, }); } - await mkdir(cachePath.split("/").slice(0, -1).join("/"), { recursive: true }); - await writeFile(cachePath, JSON.stringify(cache)); - - await watcherWriteSnapshot(projectDirectory); - - debugTimeEnd("cache.write"); + await writeFileChecked(CACHE_PATH, JSON.stringify(value)); } diff --git a/packages/compas/src/main/development/cache.test.js b/packages/compas/src/main/development/cache.test.js deleted file mode 100644 index be96ff6a10..0000000000 --- a/packages/compas/src/main/development/cache.test.js +++ /dev/null @@ -1,149 +0,0 @@ -import { existsSync } from "node:fs"; -import { mkdir, rm, writeFile } from "node:fs/promises"; -import { mainTestFn, test } from "@compas/cli"; -import { isNil, pathJoin } from "@compas/stdlib"; -import { cacheLoadFromDisk } from "./cache.js"; - -mainTestFn(import.meta); - -test("compas/main/development/cache", (t) => { - t.jobs = 2; - - const baseDirectory = ".cache/test/config"; - - const getFixtureDirectory = async (name) => { - const dir = pathJoin(baseDirectory, name); - - await rm(dir, { force: true, recursive: true }); - await mkdir(dir, { recursive: true }); - - return dir; - }; - - t.test("cacheLoadFromDisk", (t) => { - t.test( - "returns a default cache if cache.json is not present", - async (t) => { - const fixtureDirectory = await getFixtureDirectory( - "cache/load-default-0", - ); - - const cache = await cacheLoadFromDisk(fixtureDirectory, "0.0.0"); - - t.ok( - !existsSync(pathJoin(fixtureDirectory, ".cache/compas/cache.json")), - ); - t.equal(cache.version, "0.0.0"); - }, - ); - - t.test( - "returns a default cache if cache.json can not be parsed", - async (t) => { - const fixtureDirectory = await getFixtureDirectory( - "cache/load-default-1", - ); - - await mkdir(pathJoin(fixtureDirectory, ".cache/compas"), { - recursive: true, - }); - await writeFile( - pathJoin(fixtureDirectory, ".cache/compas/cache.json"), - "{", - ); - - const cache = await cacheLoadFromDisk(fixtureDirectory, "0.0.0"); - - t.equal(cache.version, "0.0.0"); - }, - ); - - t.test( - "returns a default cache if cache.json can not be validated", - async (t) => { - const fixtureDirectory = await getFixtureDirectory( - "cache/load-default-2", - ); - - await mkdir(pathJoin(fixtureDirectory, ".cache/compas"), { - recursive: true, - }); - await writeFile( - pathJoin(fixtureDirectory, ".cache/compas/cache.json"), - "{}", - ); - - const cache = await cacheLoadFromDisk(fixtureDirectory, "0.0.0"); - - t.equal(cache.version, "0.0.0"); - }, - ); - - t.test( - "returns a default cache if cache.json does not match version", - async (t) => { - const fixtureDirectory = await getFixtureDirectory( - "cache/load-default-3", - ); - - await mkdir(pathJoin(fixtureDirectory, ".cache/compas"), { - recursive: true, - }); - await writeFile( - pathJoin(fixtureDirectory, ".cache/compas/cache.json"), - `{"version": "1",}`, - ); - - const cache = await cacheLoadFromDisk(fixtureDirectory, "0.0.0"); - - t.equal(cache.version, "0.0.0"); - }, - ); - - t.test( - "returns a default cache if cache.json does not match version", - async (t) => { - const fixtureDirectory = await getFixtureDirectory( - "cache/load-default-4", - ); - - await mkdir(pathJoin(fixtureDirectory, ".cache/compas"), { - recursive: true, - }); - await writeFile( - pathJoin(fixtureDirectory, ".cache/compas/cache.json"), - `{"version": "1",}`, - ); - - const cache = await cacheLoadFromDisk(fixtureDirectory, "0.0.0"); - - t.equal(cache.version, "0.0.0"); - }, - ); - - t.test("returns the loaded cache", async (t) => { - const fixtureDirectory = await getFixtureDirectory( - "cache/load-success-0", - ); - - await mkdir(pathJoin(fixtureDirectory, ".cache/compas"), { - recursive: true, - }); - await writeFile( - pathJoin(fixtureDirectory, ".cache/compas/cache.json"), - JSON.stringify({ - version: "0.0.0", - config: { - rootDirectory: "", - projects: [], - }, - }), - ); - - const cache = await cacheLoadFromDisk(fixtureDirectory, "0.0.0"); - - t.equal(cache.version, "0.0.0"); - t.ok(!isNil(cache.config)); - }); - }); -}); diff --git a/packages/compas/src/main/development/index.js b/packages/compas/src/main/development/index.js index 288fe2f52e..71dd1ae08f 100644 --- a/packages/compas/src/main/development/index.js +++ b/packages/compas/src/main/development/index.js @@ -1,369 +1,12 @@ -import { spawn as cpSpawn } from "node:child_process"; -import { once } from "node:events"; -import treeKill from "tree-kill"; -import { configResolve } from "../../config.js"; -import { debugEnable } from "../../output/debug.js"; -import { output } from "../../output/static.js"; -import { - tuiEnable, - tuiEraseLayout, - tuiExit, - tuiPaintLayout, - tuiPrintInformation, - tuiStateSetAvailableActions, - tuiStateSetMetadata, - tuiWritePersistent, -} from "../../output/tui.js"; -import { cacheLoadFromDisk, cacheWriteToDisk } from "./cache.js"; -import { - watcherAddListener, - watcherEnable, - watcherProcessChangesSinceSnapshot, - watcherRemoveSnapshot, - watcherWriteSnapshot, -} from "./watcher.js"; +import { State } from "./state.js"; /** - * @typedef {{ - * env: import("../../config.js").ConfigEnvironment, - * config: import("../../generated/common/types.js").CompasResolvedConfig, - * cache: import("../../generated/common/types.js").CompasCache, - * tui: { - * activeConfig: import("../../generated/common/types.js").CompasResolvedConfig, - * navigationStack: import("../../generated/common/types.js").CompasResolvedConfig[], - * activeProcess?: import("child_process").ChildProcess & { - * exitListener: (signal?: number|null) => void, - * command: string[], - * } - * } - * }} DevelopmentState - */ - -/** - * Run Compas in development mode * - * @param {import("../../config.js").ConfigEnvironment} env + * @param {import("../../shared/config.js").ConfigEnvironment} env * @returns {Promise} */ export async function developmentMode(env) { - output.config.environment.loaded(env); - - /** @type {DevelopmentState} */ - const state = { - env, - // @ts-expect-error - config: undefined, - // @ts-expect-error - cache: undefined, - // @ts-expect-error - tui: { - navigationStack: [], - }, - }; - - debugEnable(); - tuiEnable(); - tuiStateSetMetadata({ - appName: env.appName, - compasVersion: env.compasVersion, - }); - - state.cache = await cacheLoadFromDisk("", env.compasVersion); - - if (!state.cache.config) { - // Load from disk. - - // Remove watcher snapshot, we are going to resolve everything from scratch - await watcherRemoveSnapshot(""); - - // All actions - - // @ts-expect-error - state.config = await configResolve("", true); - state.cache.config = state.config; - state.tui.activeConfig = state.config; - - // TODO: ... - - // End all actions - - // Persist cache to disk - await watcherWriteSnapshot(""); - await cacheWriteToDisk("", state.cache); - } else { - // Restore from cache - tuiPrintInformation("Booted from cache."); - - // Restoring state - state.config = state.cache.config; - state.tui.activeConfig = state.config; - - // TODO: ... - - // End restoring state... - } - - // Start input cycle - developmentManagementCycle(state); - - // Register watcher - watcherAddListener({ - glob: "**/config/compas.json", - delay: 150, - callback: developmentReloadConfig.bind(undefined, state), - }); - - await watcherEnable(""); - await watcherProcessChangesSinceSnapshot(""); - // End register watcher -} - -/** - * @param {DevelopmentState} state - * @returns {Promise} - */ -async function developmentReloadConfig(state) { - const newConfig = await configResolve("", true); - - if (!newConfig) { - tuiPrintInformation("Error while reloading config."); - return; - } - - tuiPrintInformation("Reloaded config due to a file change."); - - state.config = newConfig; - state.cache.config = newConfig; - await cacheWriteToDisk("", state.cache); - - state.tui.activeConfig = state.config; - state.tui.navigationStack = []; - developmentManagementCycle(state); -} - -/** - * @param {DevelopmentState} state - */ -function developmentManagementCycle(state) { - if (state.tui.activeProcess) { - tuiStateSetAvailableActions([ - { - title: "Process actions", - actions: [ - { - name: "Restart", - shortcut: "R", - callback: () => { - if (!state.tui.activeProcess) { - return; - } - - const command = state.tui.activeProcess.command; - return new Promise((r) => { - if (!state.tui.activeProcess) { - return; - } - - state.tui.activeProcess.removeListener( - "exit", - state.tui.activeProcess.exitListener, - ); - - Promise.all([ - once(state.tui.activeProcess, "exit"), - new Promise((r) => { - // @ts-expect-error - treeKill(state.tui.activeProcess.pid, r); - }), - ]).then(() => { - // @ts-expect-error - r(); - }); - }).then(() => { - if (!state.tui.activeProcess) { - return; - } - - state.tui.activeProcess.exitListener(null); - developmentSpawnAction(state, command); - }); - }, - }, - { - name: "Kill", - shortcut: "K", - callback: () => { - if (!state.tui.activeProcess) { - return; - } - - state.tui.activeProcess.removeListener( - "exit", - state.tui.activeProcess.exitListener, - ); - - return new Promise((r) => { - if (!state.tui.activeProcess) { - return; - } - - Promise.all([ - once(state.tui.activeProcess, "exit"), - new Promise((r) => { - // @ts-expect-error - treeKill(state.tui.activeProcess.pid, r); - }), - ]).then(() => { - if (!state.tui.activeProcess) { - return; - } - - state.tui.activeProcess.exitListener(null); - r(); - }); - }); - }, - }, - ], - }, - ]); - } else if (state.tui.activeConfig) { - const actions = - state.tui.activeConfig.actions?.map((it) => ({ - name: it.name, - shortcut: it.shortcut, - callback: () => { - developmentSpawnAction(state, it.command); - }, - })) ?? []; - - tuiStateSetAvailableActions([ - { - title: "Navigation:", - // @ts-expect-error - actions: [ - state.tui.navigationStack.length - ? { - name: "Back", - shortcut: "B", - callback: () => { - // @ts-expect-error - state.tui.activeConfig = state.tui.navigationStack.pop(); - developmentManagementCycle(state); - }, - } - : undefined, - { - name: "Quit", - shortcut: "Q", - callback: () => { - tuiExit(); - }, - }, - ...state.tui.activeConfig.projects.map((it, idx) => ({ - name: it.rootDirectory, - shortcut: `${idx + 1}`, - callback: () => { - state.tui.navigationStack.push(state.tui.activeConfig); - state.tui.activeConfig = it; - developmentManagementCycle(state); - }, - })), - ].filter((it) => !!it), - }, - { - title: "Available actions:", - actions, - }, - { - title: "Shortcuts while an action is active:", - actions: [ - { - name: "Restart", - shortcut: "R", - callback: () => { - // noop - }, - }, - { - name: "Kill", - shortcut: "K", - callback: () => { - // noop - }, - }, - ], - }, - ]); - - // Repaint! - tuiPaintLayout(); - } -} - -/** - * @param {DevelopmentState} state - * @param {string[]} command - */ -function developmentSpawnAction(state, command) { - tuiEraseLayout(); - - // Reregister actions before the command is running - // @ts-expect-error - state.tui.activeProcess = true; - developmentManagementCycle(state); - - tuiWritePersistent((cursor) => - cursor - .reset() - .write("> Spawning '") - .fg.magenta() - .write(command.join(" ")) - .reset() - .write("'\n"), - ); - - const start = Date.now(); - - const cp = cpSpawn(command[0], command.slice(1), { - stdio: ["ignore", "inherit", "inherit"], - }); - - // @ts-expect-error - cp.exitListener = (status) => { - delete state.tui.activeProcess; - - const time = Number((Date.now() - start) / 1000).toFixed(1); - if (status === 0) { - tuiWritePersistent((cursor) => - cursor - .reset() - .fg.green() - .write(`> Action complete in ${time}s.\n`) - .reset(), - ); - } else if (status) { - tuiWritePersistent((cursor) => - cursor.reset().fg.red().write(`> Action failed in ${time}s.\n`).reset(), - ); - } else { - tuiWritePersistent((cursor) => - cursor - .reset() - .fg.yellow() - .write(`> Action stopped after ${time}s.\n`) - .reset(), - ); - } - - developmentManagementCycle(state); - }; - - // @ts-expect-error - cp.on("exit", cp.exitListener); + const state = new State(env); - // @ts-expect-error - cp.command = command; - // @ts-expect-error - state.tui.activeProcess = cp; + await state.init(); } diff --git a/packages/compas/src/main/development/integrations/actions.js b/packages/compas/src/main/development/integrations/actions.js new file mode 100644 index 0000000000..16e7019c1f --- /dev/null +++ b/packages/compas/src/main/development/integrations/actions.js @@ -0,0 +1,296 @@ +import { spawn as cpSpawn } from "node:child_process"; +import { once } from "node:events"; +import path from "node:path"; +import treeKill from "tree-kill"; +import { BaseIntegration } from "./base.js"; + +export class ActionsIntegration extends BaseIntegration { + constructor(state) { + super(state, "actions"); + + /** + * @type {import("../../../generated/common/types.js").CompasResolvedConfig[]} + */ + this.navigationStack = []; + } + + async init() { + await super.init(); + + if (this.state.cache.config) { + this.navigationStack = [this.state.cache.config]; + } + + /** + * @type {undefined|{ + * cp: import("child_process").ChildProcess, + * command: string[], + * workingDirectory: string, + * startTime: number, + * boundResetState: () => void, + * }} + */ + this.activeProcess = undefined; + + this.setActionsGroups(); + + if (this.state.screen.state === "idle") { + this.state.paintScreen(); + } + } + + async onConfigUpdated() { + await super.onConfigUpdated(); + + // @ts-expect-error + this.navigationStack = [this.state.cache.config]; + this.setActionsGroups(); + + if (this.state.screen.state === "idle") { + this.state.paintScreen(); + } + } + + async onExit() { + await new Promise((r) => { + if (this.activeProcess) { + // @ts-expect-error + treeKill(this.activeProcess.cp.pid, r); + } else { + // @ts-expect-error + r(); + } + }); + } + + async onKeypress(key) { + await super.onKeypress(key); + + const name = key.name.toLowerCase(); + + if (this.state.screen.state === "action") { + if (name === "k") { + return this.killAction(); + } else if (name === "r") { + return this.restartAction(); + } + + // Ignore any other keypress when an action is running. + return; + } + + if (name === "b" && this.navigationStack.length > 1) { + this.navigationStack.pop(); + + this.setActionsGroups(); + this.state.paintScreen(); + return; + } + + if (name === "q" && this.navigationStack.length === 1) { + return this.state.exit(); + } + + /** + * @type {import("../../../generated/common/types.js").CompasResolvedConfig} + */ + // @ts-expect-error + const currentProject = this.navigationStack.at(-1); + + for (let i = 0; i < currentProject.projects.length; ++i) { + if (name === String(i + 1)) { + this.navigationStack.push(currentProject.projects[i]); + + this.setActionsGroups(); + this.state.paintScreen(); + return; + } + } + + for (const action of currentProject.actions ?? []) { + if (action.shortcut.toLowerCase() === name) { + await this.spawnAction({ + command: action.command, + workingDirectory: currentProject.rootDirectory, + }); + return; + } + } + } + + setActionsGroups() { + /** + * @type {import("../../../generated/common/types.js").CompasResolvedConfig} + */ + // @ts-expect-error + const currentProject = this.navigationStack.at(-1); + + this.state.screen.actionGroups = [ + { + title: "Navigation", + actions: [ + this.navigationStack.length > 1 + ? { + shortcut: "B", + name: "Back", + } + : { + shortcut: "Q", + name: "Quit", + }, + ...currentProject.projects.map((it, idx) => ({ + shortcut: String(idx + 1), + name: path.relative(currentProject.rootDirectory, it.rootDirectory), + })), + ], + }, + ]; + + if (currentProject.actions?.length) { + this.state.screen.actionGroups.push( + { + title: "Available actions:", + actions: currentProject.actions.map((it) => ({ + shortcut: it.shortcut, + name: it.name, + })), + }, + { + title: "Shortcuts while an action is active:", + actions: [ + { + shortcut: "K", + name: "Kill action", + }, + { + shortcut: "R", + name: "Restart action", + }, + ], + }, + ); + } + } + + /** + * @param {{ + * command: string[], + * workingDirectory: string + * }} action + * @returns {void} + */ + spawnAction(action) { + this.state.clearScreen(); + this.state.screen.state = "action"; + + this.state.logPersistent((cursor) => + cursor + .reset() + .write("> Spawning '") + .fg.magenta() + .write(action.command.join(" ")) + .reset() + .write("'\n"), + ); + + this.activeProcess = { + command: action.command, + workingDirectory: action.workingDirectory, + startTime: Date.now(), + cp: cpSpawn(action.command[0], action.command.slice(1), { + cwd: action.workingDirectory, + stdio: ["ignore", "inherit", "inherit"], + }), + boundResetState: this.resetState.bind(this), + }; + + // Separate listeners for screen reset and user information + this.activeProcess.cp.once("exit", this.onActionExit.bind(this)); + this.activeProcess.cp.once("exit", this.activeProcess.boundResetState); + } + + async killAction() { + if (!this.activeProcess) { + return; + } + + await Promise.all([ + once(this.activeProcess.cp, "exit"), + new Promise((r) => { + // @ts-expect-error + treeKill(this.activeProcess.cp.pid, r); + }), + ]); + } + + async restartAction() { + if (!this.activeProcess) { + return; + } + + this.activeProcess.cp.removeAllListeners("exit"); + this.activeProcess.cp.once("exit", this.onActionExit.bind(this)); + + await Promise.all([ + once(this.activeProcess.cp, "exit"), + new Promise((r) => { + if (this.activeProcess) { + // @ts-expect-error + treeKill(this.activeProcess.cp.pid, r); + } else { + // @ts-expect-error + r(); + } + }), + ]); + + // Respawn action + this.spawnAction({ + command: this.activeProcess.command, + workingDirectory: this.activeProcess.workingDirectory, + }); + } + + resetState() { + delete this.activeProcess; + + this.state.screen.state = "idle"; + this.state.paintScreen(); + } + + onActionExit(status) { + if (!this.activeProcess) { + return; + } + + const elapsedTime = Number( + (Date.now() - this.activeProcess.startTime) / 1000, + ).toFixed(1); + + if (status === 0) { + this.state.logPersistent((cursor) => + cursor + .reset() + .fg.green() + .write(`> Action completed in ${elapsedTime}s.\n`) + .reset(), + ); + } else if (status) { + this.state.logPersistent((cursor) => + cursor + .reset() + .fg.red() + .write(`> Action failed in ${elapsedTime}s.\n`) + .reset(), + ); + } else { + this.state.logPersistent((cursor) => + cursor + .reset() + .fg.yellow() + .write(`> Action stopped after ${elapsedTime}s.\n`) + .reset(), + ); + } + } +} diff --git a/packages/compas/src/main/development/integrations/base.js b/packages/compas/src/main/development/integrations/base.js new file mode 100644 index 0000000000..f1bee752ee --- /dev/null +++ b/packages/compas/src/main/development/integrations/base.js @@ -0,0 +1,59 @@ +import { debugPrint } from "../../../shared/output.js"; + +export class BaseIntegration { + /** + * @param {import("../state.js").State} state + * @param {string} name + */ + constructor(state, name) { + /** + * @type {import("../state.js").State} + */ + this.state = state; + + /** + * @type {string} name + */ + this.name = name; + } + + // eslint-disable-next-line require-await + async init() { + debugPrint(`${this.name} :: init`); + } + + // eslint-disable-next-line require-await + async onCacheUpdated() { + debugPrint(`${this.name} :: onCacheUpdated`); + } + + /** + * @param {{ + * name: string + * }} key + * @returns {Promise} + */ + // eslint-disable-next-line require-await,no-unused-vars + async onKeypress(key) { + debugPrint(`${this.name} :: onKeypress`); + } + + /** + * @param {string[]} paths + * @returns {Promise} + */ + // eslint-disable-next-line require-await,no-unused-vars + async onFileChanged(paths) { + debugPrint(`${this.name} :: onFileChanged`); + } + + // eslint-disable-next-line require-await + async onConfigUpdated() { + debugPrint(`${this.name} :: onConfigUpdated`); + } + + // eslint-disable-next-line require-await + async onExit() { + debugPrint(`${this.name} :: onExit`); + } +} diff --git a/packages/compas/src/main/development/integrations/cache-cleanup.js b/packages/compas/src/main/development/integrations/cache-cleanup.js new file mode 100644 index 0000000000..45d0711a1a --- /dev/null +++ b/packages/compas/src/main/development/integrations/cache-cleanup.js @@ -0,0 +1,76 @@ +import { existsSync } from "node:fs"; +import { rm } from "node:fs/promises"; +import { isNil, pathJoin } from "@compas/stdlib"; +import { BaseIntegration } from "./base.js"; + +export class CacheCleanupIntegration extends BaseIntegration { + constructor(state) { + super(state, "cacheCleanup"); + } + + async init() { + await super.init(); + + await this.cleanup(); + + this.state.cache.cachesCleaned = true; + await this.state.emitCacheUpdated(); + } + + async onConfigUpdated() { + await super.onConfigUpdated(); + + await this.cleanup(); + } + + async cleanup() { + const isCleanBoot = isNil(this.state.cache.cachesCleaned); + + async function handleProject(project) { + const isRootProject = (project.rootDirectory = process.cwd()); + + if (isCleanBoot && isRootProject) { + // Watcher snapshot when ran from another project + await rm(".cache/compas/watcher-snapshot.txt", { force: true }); + } else if (isCleanBoot && !isRootProject) { + // Remove caches and snapshots from other runs + await rm(pathJoin(project.rootDirectory, ".cache/compas/cache.json"), { + force: true, + }); + await rm( + pathJoin( + project.rootDirectory, + ".cache/compas/watcher-snapshot.json", + ), + { force: true }, + ); + } else if (!isCleanBoot && !isRootProject) { + // Project could have ran in standalone, without referencing this project. Which + // means that we will be quite confused. At least clean this up. We may need to + // revisit this later, that we still want to boot up freshly after cleaning up + // the root cache. + if ( + existsSync( + pathJoin(project.rootDirectory, ".cache/compas/cache.json"), + ) + ) { + await rm( + pathJoin(project.rootDirectory, ".cache/compas/cache.json"), + { + force: true, + }, + ); + await rm( + pathJoin( + project.rootDirectory, + ".cache/compas/watcher-snapshot.json", + ), + { force: true }, + ); + } + } + } + + await handleProject(this.state.cache.config); + } +} diff --git a/packages/compas/src/main/development/integrations/config-loader.js b/packages/compas/src/main/development/integrations/config-loader.js new file mode 100644 index 0000000000..383084fe84 --- /dev/null +++ b/packages/compas/src/main/development/integrations/config-loader.js @@ -0,0 +1,52 @@ +import { configResolveProjectConfig } from "../../../shared/config.js"; +import { BaseIntegration } from "./base.js"; + +export class ConfigLoaderIntegration extends BaseIntegration { + constructor(state) { + super(state, "configLoader"); + } + + async init() { + await super.init(); + + if (!this.state.cache.config) { + this.state.cache.config = await configResolveProjectConfig(); + + await this.state.emitCacheUpdated(); + await this.state.emitConfigUpdated(); + } + + this.state.fileChangeRegister.push({ + glob: "**/config/compas.json", + integration: this, + debounceDelay: 50, + }); + } + + async onFileChanged(paths) { + await super.onFileChanged(paths); + + try { + this.state.cache.config = await configResolveProjectConfig(); + + await this.state.emitCacheUpdated(); + await this.state.emitConfigUpdated(); + + this.state.logInformation("Reloaded config, due to file change."); + } catch (/** @type {any} */ e) { + if (e.key === "config.resolve.parseError") { + this.state.logInformation( + `Could not reload config due to a syntax error.`, + ); + } else if (e.key === "config.resolve.validationError") { + this.state.logInformation( + `Could not reload config due to a validation error. Check the docs for supported properties.`, + ); + } else { + this.state.logInformation( + `Could not reload the config due to an error. Please check your file.`, + ); + } + } + } +} diff --git a/packages/compas/src/main/development/integrations/file-watcher.js b/packages/compas/src/main/development/integrations/file-watcher.js new file mode 100644 index 0000000000..bda09cc7c2 --- /dev/null +++ b/packages/compas/src/main/development/integrations/file-watcher.js @@ -0,0 +1,103 @@ +import { existsSync } from "node:fs"; +import { AppError, isNil, pathJoin } from "@compas/stdlib"; +import watcher from "@parcel/watcher"; +import { BaseIntegration } from "./base.js"; + +export class FileWatcherIntegration extends BaseIntegration { + static DEFAULT_WATCH_OPTIONS = { + ignore: [".cache", ".git"], + }; + + constructor(state) { + super(state, "fileWatcher"); + + /** + * + * @type {Record} + */ + this.subscriptions = {}; + } + + async init() { + await super.init(); + + await this.refreshWatchers(); + + // Handle events since snapshot. + let events = []; + + for (const dir of Object.keys(this.subscriptions)) { + const snapshot = pathJoin(dir, ".cache/compas/watcher-snapshot.txt"); + + if (!existsSync(snapshot)) { + continue; + } + + events = events.concat( + await watcher.getEventsSince( + dir, + snapshot, + FileWatcherIntegration.DEFAULT_WATCH_OPTIONS, + ), + ); + } + + this.state.logInformation(`Started file watchers, ready for some action!`); + + return this.state.emitFileChange(events.map((it) => it.path)); + } + + async onCacheUpdated() { + await super.onCacheUpdated(); + + await this.refreshWatchers(); + + for (const dir of Object.keys(this.subscriptions)) { + await watcher.writeSnapshot( + dir, + pathJoin(dir, ".cache/compas/watcher-snapshot.txt"), + FileWatcherIntegration.DEFAULT_WATCH_OPTIONS, + ); + } + } + + async refreshWatchers() { + const dirs = this.state.cache.rootDirectories ?? []; + + // Cleanup unnecessary subscriptions; + for (const key of Object.keys(this.subscriptions)) { + if (!dirs.includes(key)) { + await this.subscriptions[key].unsubscribe(); + delete this.subscriptions[key]; + } + } + + const boundEmitFileChange = this.state.emitFileChange.bind(this.state); + + for (const dir of dirs) { + if (!isNil(this.subscriptions[dir])) { + continue; + } + + // Only resubscribe if we don't have subscription yet. + this.subscriptions[dir] = await watcher.subscribe( + dir, + (err, events) => { + if (err) { + throw AppError.serverError( + { + message: "Invariant failed. Not expecting file watcher errors.", + dir, + }, + err, + ); + } + + // Dangling promise! + boundEmitFileChange(events.map((it) => it.path)); + }, + FileWatcherIntegration.DEFAULT_WATCH_OPTIONS, + ); + } + } +} diff --git a/packages/compas/src/main/development/integrations/package-manager.js b/packages/compas/src/main/development/integrations/package-manager.js new file mode 100644 index 0000000000..1b64666a66 --- /dev/null +++ b/packages/compas/src/main/development/integrations/package-manager.js @@ -0,0 +1,111 @@ +import { exec } from "@compas/stdlib"; +import { packageManagerDetermineInstallCommand } from "../../../shared/package-manager.js"; +import { BaseIntegration } from "./base.js"; + +export class PackageManagerIntegration extends BaseIntegration { + constructor(state) { + super(state, "packageManager"); + } + + async init() { + await super.init(); + + if (!this.state.cache.packageManagerInstallCommand) { + await this.resolve(); + + // TODO: do we want to start with an 'await this.execute();' + // May want to do that as background task. + } + + this.state.fileChangeRegister.push({ + glob: "**/package.json", + integration: this, + debounceDelay: 150, + }); + + this.state.fileChangeRegister.push({ + glob: "**/{package-lock.json,yarn.lock,pnpm-lock.yaml}", + integration: this, + debounceDelay: 100, + }); + } + + async onCacheUpdated() { + await super.onCacheUpdated(); + + await this.resolve(); + } + + async onFileChanged(paths) { + await super.onFileChanged(paths); + + let installCommand = false; + let updateCache = false; + + for (const path of paths) { + if (path.endsWith("package.json")) { + installCommand = true; + } + + if ( + path.endsWith("pnpm-lock.yaml") || + path.endsWith("package-lock.json") || + path.endsWith("yarn.lock") + ) { + updateCache = true; + } + } + + if (updateCache) { + await this.resolve(); + } + + if (installCommand) { + return this.execute(); + } + } + + async resolve() { + const existingMapping = { + ...this.state.cache.packageManagerInstallCommand, + }; + + const newMapping = {}; + for (const dir of this.state.cache.rootDirectories ?? []) { + newMapping[dir] = packageManagerDetermineInstallCommand(dir); + } + + // @ts-expect-error + this.state.cache.packageManagerInstallCommand = newMapping; + + let hasDiff = + Object.keys(existingMapping).length !== Object.keys(newMapping).length; + + for (const key of Object.keys(existingMapping)) { + if (existingMapping[key] !== newMapping[key]) { + hasDiff = true; + } + + if (hasDiff) { + break; + } + } + + if (hasDiff) { + await this.state.emitCacheUpdated(); + } + } + + async execute() { + // TODO: this again write the package.json on changes, so we may want to prevent + // triggering shortly after the last run. + + for (const [dir, command] of Object.entries( + this.state.cache.packageManagerInstallCommand ?? {}, + )) { + await exec(command.join(" "), { + cwd: dir, + }); + } + } +} diff --git a/packages/compas/src/main/development/integrations/root-directories.js b/packages/compas/src/main/development/integrations/root-directories.js new file mode 100644 index 0000000000..4f048da339 --- /dev/null +++ b/packages/compas/src/main/development/integrations/root-directories.js @@ -0,0 +1,68 @@ +import { BaseIntegration } from "./base.js"; + +export class RootDirectoriesIntegration extends BaseIntegration { + constructor(state) { + super(state, "rootDirectories"); + } + + async init() { + await super.init(); + + if (!this.state.cache.rootDirectories) { + this.state.cache.rootDirectories = this.resolveRootDirectories(); + + await this.state.emitCacheUpdated(); + } + } + + async onConfigUpdated() { + await super.onConfigUpdated(); + + const existingRootDirectories = [ + ...(this.state.cache.rootDirectories ?? []), + ]; + + const newRootDirectories = this.resolveRootDirectories(); + + if (existingRootDirectories.length !== newRootDirectories.length) { + this.state.cache.rootDirectories = newRootDirectories; + return this.state.emitCacheUpdated(); + } + + for (const dir of newRootDirectories) { + if (!existingRootDirectories.includes(dir)) { + this.state.cache.rootDirectories = newRootDirectories; + return this.state.emitCacheUpdated(); + } + } + } + + resolveRootDirectories() { + const resolved = [process.cwd()]; + + function handleProject(project) { + for (const dir of resolved) { + if (project.rootDirectory.startsWith(dir)) { + // Project is in subdirectory of current directory. + break; + } + + if (dir.startsWith(project.rootDirectory)) { + // More root directory than existing one. + resolved.splice(resolved.indexOf(dir), 1); + resolved.push(dir); + break; + } + resolved.push(project.rootDirectory); + } + + for (const sub of project.projects) { + handleProject(sub); + } + } + + handleProject(this.state.cache.config); + + return resolved; + } +} diff --git a/packages/compas/src/main/development/state.js b/packages/compas/src/main/development/state.js new file mode 100644 index 0000000000..599621246c --- /dev/null +++ b/packages/compas/src/main/development/state.js @@ -0,0 +1,293 @@ +import { setTimeout } from "node:timers"; +import { AppError } from "@compas/stdlib"; +import ansi from "ansi"; +import micromatch from "micromatch"; +import { + debugPrint, + debugTimeEnd, + debugTimeStart, +} from "../../shared/output.js"; +import { cacheLoad, cachePersist } from "./cache.js"; +import { ActionsIntegration } from "./integrations/actions.js"; +import { CacheCleanupIntegration } from "./integrations/cache-cleanup.js"; +import { ConfigLoaderIntegration } from "./integrations/config-loader.js"; +import { FileWatcherIntegration } from "./integrations/file-watcher.js"; +import { PackageManagerIntegration } from "./integrations/package-manager.js"; +import { RootDirectoriesIntegration } from "./integrations/root-directories.js"; +import { tuiClearScreen, tuiExit, tuiInit, tuiPaint } from "./tui.js"; + +export class State { + /** + * @param {import("../../shared/config.js").ConfigEnvironment} env + */ + constructor(env) { + /** + * @type {import("../../shared/config.js").ConfigEnvironment} + */ + this.env = env; + + /** + * @type {import("ansi").Cursor} + */ + this.cursor = ansi(process.stdout); + + /** + * @type {{ + * ghostOutputLineCount: number, + * state: "idle"|"action", + * actionGroups: { + * title: string, + * actions: { + * shortcut: string, + * name: string, + * }[], + * }[], + * }} + */ + this.screen = { + ghostOutputLineCount: 0, + state: "idle", + actionGroups: [], + }; + + /** + * Set of information lines. + * + * @type {string[]} + */ + this.information = [ + "Thank you for trying out the new Compas CLI. This is still a work in progress. Checkout https://github.com/compasjs/compas/issues/2774 for planned features and known issues.", + ]; + + /** + * @type {import("./integrations/base.js").BaseIntegration[]} + */ + this.integrations = []; + + /** + * + * @type {{ + * glob: string, + * integration: import("./integrations/base.js").BaseIntegration, + * debounceDelay: number, + * existingTimeout?: NodeJS.Timeout + * }[]} + */ + this.fileChangeRegister = []; + + /** + * @type {import("../../generated/common/types.js").CompasCache} + */ + this.cache = { + version: "unknown", + }; + } + + // ==== generic ==== + + async init() { + debugTimeStart(`State#init`); + debugPrint("State#init"); + + tuiInit(this); + + const { empty, cache } = await cacheLoad(this.env.compasVersion); + + if (empty) { + this.logInformation("Starting up..."); + } else { + this.logInformation("Starting up from cache..."); + } + + this.cache = cache; + + // We start with a separate array, to make sure that we init things in order, without + // causing reactivity loops. + const integrations = [ + new ConfigLoaderIntegration(this), + new RootDirectoriesIntegration(this), + new CacheCleanupIntegration(this), + new ActionsIntegration(this), + new PackageManagerIntegration(this), + + // Should be the last integration + new FileWatcherIntegration(this), + ]; + + // TODO: Package install command + + // TODO: package install + + // Init and add to state + for (const integration of integrations) { + await integration.init(); + + this.integrations.push(integration); + } + + debugTimeEnd(`State#init`); + } + + async exit() { + debugPrint("State#exit"); + + for (const integration of this.integrations) { + await integration.onExit(); + } + + tuiExit(this); + process.exit(); + } + + // === user information === + + /** + * @param {string} line + */ + logInformation(line) { + debugPrint(`State#logInformation :: ${line}`); + + this.information.push(line); + + while (this.information.length > 10) { + this.information.shift(); + } + + if (this.screen.state === "idle") { + this.paintScreen(); + } + } + + /** + * @param {(cursor: import("ansi").Cursor)=> void} callback + */ + logPersistent(callback) { + if (this.screen.state !== "action") { + throw AppError.serverError({ + message: `Invariant failed. Expected screen state to be in 'action', found '${this.screen.state}'.`, + }); + } + + this.cursor.reset().buffer(); + callback(this.cursor); + this.cursor.reset().flush(); + } + + // ==== screen ==== + + paintScreen() { + debugPrint(`State#paintScreen :: ${this.screen.state}`); + + if (this.screen.state !== "idle") { + throw AppError.serverError({ + message: `Invariant failed. Expected screen state to be in 'idle', found '${this.screen.state}'.`, + }); + } + + this.clearScreen(); + + this.screen.ghostOutputLineCount = tuiPaint(this, { + compasVersion: this.env.compasVersion, + appName: this.env.appName, + information: this.information, + actionGroups: this.screen.actionGroups, + }); + } + + clearScreen() { + debugPrint(`State#clearScreen :: ${this.screen.state}`); + + tuiClearScreen(this, this.screen.ghostOutputLineCount); + + this.screen.ghostOutputLineCount = 0; + } + + resizeScreen() { + debugPrint(`State#resizeScreen :: ${this.screen.state}`); + + if (this.screen.state === "idle") { + this.paintScreen(); + } + } + + // ==== integrations ==== + + /** + * Notify that the cache is updated. + * + * @returns {Promise} + */ + async emitCacheUpdated() { + debugPrint(`State#emitCacheUpdated`); + + await cachePersist(this.cache); + + for (const integration of this.integrations) { + await integration.onCacheUpdated(); + } + } + + /** + * + * @param {{ + * name: string, + * }} key + * @returns {Promise} + */ + async emitKeypress(key) { + debugPrint(`State#emitKeypress :: ${JSON.stringify(key)}`); + + if (!key.name) { + return; + } + + // Rename a few keypress for easier matching and shorter shortcuts, we may want to expand this setup later. + if (key.name === "escape") { + key.name = "esc"; + } + + for (const integration of this.integrations) { + await integration.onKeypress(key); + } + } + + /** + * Emit file changes to integrations. + * + * This is different from most other integrations, in that we match on the registered + * glob, added to {@link State#fileChangeRegister}, and call with the specified + * debounce-delay. + * + * @param paths + */ + emitFileChange(paths) { + debugPrint(`State#emitFileChange :: ${JSON.stringify(paths)}}`); + + for (const integration of this.fileChangeRegister) { + if (micromatch.some(paths, integration.glob)) { + debugPrint( + `State#emitFileChange :: Matched ${integration.glob} for ${integration.integration.name} debouncing with ${integration.debounceDelay}.`, + ); + + if (integration.existingTimeout) { + integration.existingTimeout.refresh(); + } else { + integration.existingTimeout = setTimeout(() => { + // We don't ever clear the timeout, since refreshing will restart the timeout. + + // Dangling promise! + integration.integration.onFileChanged(paths); + }, integration.debounceDelay); + } + } + } + } + + async emitConfigUpdated() { + debugPrint(`State#emitConfigUpdated`); + + for (const integration of this.integrations) { + await integration.onConfigUpdated(); + } + } +} diff --git a/packages/compas/src/main/development/tui.js b/packages/compas/src/main/development/tui.js new file mode 100644 index 0000000000..cfcfeb2e84 --- /dev/null +++ b/packages/compas/src/main/development/tui.js @@ -0,0 +1,205 @@ +import * as readline from "node:readline"; +import { emitKeypressEvents } from "node:readline"; + +/** + * Setup cursor, stdin and stdout + * + * @param {import("./state.js").State} state + */ +export function tuiInit(state) { + // General setup + state.cursor.reset(); + state.cursor.hide(); + + // Exit listeners + process.on("SIGABRT", () => { + state.exit(); + }); + process.on("SIGINT", () => { + state.exit(); + }); + process.on("beforeExit", () => { + state.exit(); + }); + + process.stdout.on("resize", () => state.resizeScreen()); + + // Input setup + listener + process.stdin.setRawMode(true); + emitKeypressEvents(process.stdin); + + process.stdin.on("keypress", (char, raw) => { + if (raw.name === "c" && raw.ctrl) { + // Ctrl + C + state.exit(); + return; + } + + state.emitKeypress(raw); + }); +} + +/** + * Reset cursor, stdin and stdout + * + * @param {import("./state.js").State} state + */ +export function tuiExit(state) { + state.cursor.buffer(); + state.cursor.show(); + + state.clearScreen(); + + state.cursor.reset(); + state.cursor.flush(); + process.stdin.setRawMode(false); +} + +/** + * Clear the number of ghost output lines. + * + * @param {import("./state.js").State} state + * @param {number} ghostOutputLineCount + */ +export function tuiClearScreen(state, ghostOutputLineCount) { + state.cursor.reset(); + + if (ghostOutputLineCount) { + readline.moveCursor(process.stdout, 0, -ghostOutputLineCount); + state.cursor.eraseData().eraseLine(); + } +} + +/** + * + * @param {import("./state.js").State} state + * @param {{ + * appName: string, + * compasVersion: string, + * information: string[], + * actionGroups: { + * title: string, + * actions: { shortcut: string, name: string }[] + * }[], + * }} data + * @returns {number} ghostOutputLineCount + */ +export function tuiPaint(state, data) { + let longestName = 0; + let longestShortcut = 0; + + for (const group of data.actionGroups) { + for (const action of group.actions) { + longestName = Math.max(longestName, action.name.length); + longestShortcut = Math.max(longestShortcut, action.shortcut.length); + } + } + + state.cursor.reset(); + state.cursor.buffer(); + + // Keep track of the line count, so we can easily clear the screen when necessary. + // For this to work correctly, we shouldn't use any custom cursor movements in this + // function, so `ansi` can keep track of `\n` and return an accurate value. + const newlineCountStart = state.cursor.newlines; + + state.cursor + .blue() + .write(data.appName) + .reset() + .write(" running with ") + .green() + .write(data.compasVersion) + .reset() + .write("\n"); + + // Split up the lines to be nicely printed on screen. + // We handle new lines and split on spaces when necessary. + const linesToWrite = []; + const maxLineWidth = process.stdout.columns; + + // Loop through the buffer in reverse, so we always handle the latest messages first + for (let i = data.information.length - 1; i >= 0; i--) { + let infoLine = data.information[i]; + const lineParts = []; + + while (infoLine.length) { + const newLineIndex = infoLine.indexOf("\n"); + const spaceIndex = infoLine.lastIndexOf( + " ", + newLineIndex === -1 + ? maxLineWidth + : Math.min(newLineIndex, newLineIndex), + ); + const part = infoLine.slice( + 0, + newLineIndex === -1 + ? infoLine.length > maxLineWidth + ? spaceIndex + : maxLineWidth + : Math.min(newLineIndex, process.stdout.columns), + ); + + infoLine = infoLine.slice(part.length).trimStart(); + lineParts.push(part); + } + + // This automatically reverses the array. + linesToWrite.unshift(...lineParts); + } + + for (const line of linesToWrite) { + state.cursor.write(line).write("\n"); + } + + if (data.actionGroups.length) { + state.cursor.write("\n"); + } + + for (const actionGroup of data.actionGroups) { + state.cursor.write(`${actionGroup.title}\n`); + + const shallowCopy = [...actionGroup.actions]; + + // Write all actions in 2 column mode. + // The widths are based on the longest available shortcut and name. + while (shallowCopy.length) { + const pop1 = shallowCopy.shift(); + const pop2 = shallowCopy.shift(); + + if (!pop1) { + break; + } + + state.cursor + .reset() + .write(" ") + .green() + .write( + " ".repeat(longestShortcut - pop1.shortcut.length) + pop1.shortcut, + ) + .reset() + .write(` ${pop1.name}${" ".repeat(longestName - pop1.name.length)}`); + + if (!pop2) { + // May be undefined with an odd number of actions. + state.cursor.write("\n"); + break; + } + + state.cursor + .reset() + .green() + .write( + " ".repeat(longestShortcut - pop2.shortcut.length) + pop2.shortcut, + ) + .reset() + .write(` ${pop2.name}${" ".repeat(longestName - pop2.name.length)}\n`); + } + } + + state.cursor.flush(); + + // @ts-expect-error + return state.cursor.newlines - newlineCountStart; +} diff --git a/packages/compas/src/main/development/watcher.js b/packages/compas/src/main/development/watcher.js deleted file mode 100644 index 2ba2fdc4f0..0000000000 --- a/packages/compas/src/main/development/watcher.js +++ /dev/null @@ -1,182 +0,0 @@ -import { existsSync } from "node:fs"; -import { rm } from "node:fs/promises"; -import { clearTimeout } from "node:timers"; -import { AppError, pathJoin } from "@compas/stdlib"; -import watcher from "@parcel/watcher"; -import micromatch from "micromatch"; -import { debugPrint } from "../../output/debug.js"; -import { tuiPrintInformation } from "../../output/tui.js"; - -/** - * @typedef {object} WatcherListener - * @property {string} name - * @property {string} glob - * @property {() => (void|Promise)} callback - */ - -const globalOptions = { - ignore: [".cache", ".git"], -}; - -/** - * @type {WatcherListener[]} - */ -const listeners = []; - -/** - * Enable the watcher we process things directly, so make sure that the listeners are - * added via {@link watcherAddListener} before starting the watcher. The watcher by - * default ignores the `.cache` directory. - * - * @param {string} projectDirectory - * @returns {Promise} - */ -export async function watcherEnable(projectDirectory) { - await watcher.subscribe( - projectDirectory, - (err, events) => { - if (err) { - debugPrint(AppError.format(err)); - } - - const pathArray = events.map((it) => it.path); - debugPrint(JSON.stringify({ err: AppError.format(err), events })); - - for (const listener of listeners) { - if (micromatch.some(pathArray, listener.glob)) { - debugPrint(`Matched ${listener.name} invoking callback.`); - listener.callback(); - } - } - }, - { - ...globalOptions, - }, - ); -} - -/** - * Register the watcher listener. The glob is matched with 'micromatch' and the - * {@link callback} is called with a trailing debounce using the {@link delay} in - * milliseconds. - * - * @param {{ - * glob: string, - * delay: number, - * callback: () => (void|Promise) - * }} listener - */ -export function watcherAddListener({ glob, delay, callback }) { - if (!callback.name) { - throw AppError.serverError({ - message: "Compas issue, all watcher callbacks should have a name.", - }); - } - - debugPrint( - `Registering ${callback.name} on changes to ${glob} after ${delay}ms.`, - ); - - listeners.push({ - name: callback.name, - callback: debounce(callback, delay), - glob, - }); -} - -/** - * Remove the snapshot path, to prevent loading of a snapshot if the cache is invalid. - * - * @param {string} projectDirectory - * @returns {Promise} - */ -export async function watcherRemoveSnapshot(projectDirectory) { - const snapshotPath = pathJoin( - projectDirectory, - ".cache/compas/watcher-snapshot.txt", - ); - - await rm(snapshotPath, { force: true }); -} - -/** - * Write the snapshot path. - * - * @param {string} projectDirectory - * @returns {Promise} - */ -export async function watcherWriteSnapshot(projectDirectory) { - const snapshotPath = pathJoin( - projectDirectory, - ".cache/compas/watcher-snapshot.txt", - ); - - await watcher.writeSnapshot("", snapshotPath, { - ...globalOptions, - }); -} - -/** - * Load the snapshot and process all changes that happened since. Is a noop if no - * snapshot exists. It should be called when all systems are started, so everything has - * added its listeners. - * - * @param {string} projectDirectory - * @returns {Promise} - */ -export async function watcherProcessChangesSinceSnapshot(projectDirectory) { - const snapshotPath = pathJoin( - projectDirectory, - ".cache/compas/watcher-snapshot.txt", - ); - - if (!existsSync(snapshotPath)) { - return; - } - - const events = await watcher.getEventsSince(projectDirectory, snapshotPath, { - ...globalOptions, - }); - - const pathArray = events.map((it) => it.path); - - let didLog = false; - - for (const listener of listeners) { - if (micromatch.some(pathArray, listener.glob)) { - if (!didLog) { - // Only log once. We don't tell the user that we are processing the events until - // it triggers some action. - tuiPrintInformation("Processing file events since the last run."); - didLog = true; - } - debugPrint(`Matched ${listener.callback.name} invoking callback.`); - listener.callback(); - } - } -} - -/** - * @template {(...args: any[]) => any} T - * - * Trailing debounce function. - * - * @param {T} fn - * @param {number} delay - * @returns {T} - */ -function debounce(fn, delay) { - let _timeout = 0; - - // @ts-expect-error - return (...args) => { - if (_timeout) { - clearTimeout(_timeout); - } - - // @ts-expect-error - _timeout = setTimeout(() => { - fn(...args); - }, delay); - }; -} diff --git a/packages/compas/src/main/init/compas.js b/packages/compas/src/main/init/compas.js index eaf6f04bbe..2cd48592cb 100644 --- a/packages/compas/src/main/init/compas.js +++ b/packages/compas/src/main/init/compas.js @@ -1,31 +1,19 @@ import { existsSync } from "node:fs"; -import { readFile, writeFile } from "node:fs/promises"; -import { exec, newLogger, spawn } from "@compas/stdlib"; -import { configLoadReadOnlyEnvironment } from "../../config.js"; -import { logger, loggerEnable } from "../../output/log.js"; -import { packageManagerDetermineInstallCommand } from "../../package-manager.js"; - -export async function initCompas() { - const env = await configLoadReadOnlyEnvironment(); - - loggerEnable( - newLogger({ - ctx: { - type: env.appName, - }, - }), - ); +import { readFile } from "node:fs/promises"; +import { exec, spawn } from "@compas/stdlib"; +import { writeFileChecked } from "../../shared/fs.js"; +import { logger } from "../../shared/output.js"; +import { packageManagerDetermineInstallCommand } from "../../shared/package-manager.js"; + +/** + * + * @param {import("../../shared/config.js").ConfigEnvironment} env + * @returns {Promise} + */ +export async function initCompas(env) { if (env.isCI) { logger.info({ - message: "Compas init is not supported in CI.", - }); - return; - } - - if (!env.isDevelopment) { - logger.info({ - message: - "Compas init is not supported when NODE_ENV is explicitly set, but is not 'development'.", + message: "'compas init' is not supported in CI.", }); return; } @@ -37,6 +25,11 @@ export async function initCompas() { } } +/** + * + * @param {import("../../shared/config.js").ConfigEnvironment} env + * @returns {Promise} + */ async function initCompasInExistingProject(env) { const packageJson = JSON.parse(await readFile("package.json", "utf-8")); @@ -58,7 +51,7 @@ async function initCompasInExistingProject(env) { logger.info( `Patching package.json with ${env.compasVersion} and installing dependencies...`, ); - await writeFile( + await writeFileChecked( "package.json", `${JSON.stringify(packageJson, null, 2)}\n`, ); @@ -75,6 +68,11 @@ Tip: See https://compasjs.com/docs/getting-started.html#development-setup on how } } +/** + * + * @param {import("../../shared/config.js").ConfigEnvironment} env + * @returns {Promise} + */ async function initCompasInNewProject(env) { const compasVersion = env.compasVersion.split("v").pop(); const packageJson = { @@ -89,7 +87,10 @@ async function initCompasInNewProject(env) { }, }; - await writeFile("package.json", `${JSON.stringify(packageJson, null, 2)}\n`); + await writeFileChecked( + "package.json", + `${JSON.stringify(packageJson, null, 2)}\n`, + ); logger.info("Created a package.json. Installing with npm..."); @@ -97,7 +98,7 @@ async function initCompasInNewProject(env) { await spawn(packageManagerCommand[0], packageManagerCommand.slice(1)); if (!existsSync(".gitignore")) { - await writeFile( + await writeFileChecked( ".gitignore", `# Compas .cache @@ -136,7 +137,7 @@ coverage logger.info(` Ready to roll! Run 'npx compas' to start the Compas development environment. -You can switch to a different supported package manager like Yarn or pnpm by removing the created package-lock.json and running the equivalent of 'npm install' with your favorite package manager. +You can switch to a different supported package manager like Yarn or Pnpm by removing the created package-lock.json and running the equivalent of 'npm install' with your favorite package manager. Tip: See https://compasjs.com/docs/getting-started.html#development-setup on how to run 'compas' without the 'npx' prefix. `); diff --git a/packages/compas/src/main/production/index.js b/packages/compas/src/main/production/index.js index 2bed73c1d5..d34d1ef5f7 100644 --- a/packages/compas/src/main/production/index.js +++ b/packages/compas/src/main/production/index.js @@ -1,25 +1,32 @@ import { newLogger } from "@compas/stdlib"; -import { configResolve } from "../../config.js"; -import { logger, loggerEnable } from "../../output/log.js"; -import { output } from "../../output/static.js"; +import { logger, loggerEnable } from "../../shared/output.js"; /** * Run Compas in production mode * - * @param {import("../../config.js").ConfigEnvironment} env + * @param {import("../../shared/config.js").ConfigEnvironment} env * @returns {Promise} */ -export async function productionMode(env) { - loggerEnable(newLogger()); - output.config.environment.loaded(env); +export function productionMode(env) { + loggerEnable( + newLogger({ + ctx: { + type: env.appName, + }, + }), + ); - const config = await configResolve("", true); + logger.info({ + message: `Starting up ${env.appName} with ${env.compasVersion} in production.`, + }); + logger.info({ + message: + "Thank you for trying out the new Compas CLI. This is still a work in progress. Checkout https://github.com/compasjs/compas/issues/2774 for planned features and known issues.", + }); logger.info({ - env, - config, + message: "TODO: a future update will do more things...", }); - logger.error("Booting in prod is not yet supported."); - process.exit(1); + return Promise.resolve(); } diff --git a/packages/compas/src/output/log.js b/packages/compas/src/output/log.js deleted file mode 100644 index ae9d5199e2..0000000000 --- a/packages/compas/src/output/log.js +++ /dev/null @@ -1,25 +0,0 @@ -import { noop } from "@compas/stdlib"; - -const noopLogger = { - info: noop, - error: noop, -}; - -/** - * This is not used in the dev mode, but should be used in production and CI - * environments. - * - * Note that a 'noop' logger is used as long as {@link loggerEnable} is not called. - * - * @type {import("@compas/stdlib").Logger} - */ -export let logger = noopLogger; - -/** - * Set the logger. - * - * @param {import("@compas/stdlib").Logger} setLogger - */ -export function loggerEnable(setLogger) { - logger = setLogger; -} diff --git a/packages/compas/src/output/static.js b/packages/compas/src/output/static.js deleted file mode 100644 index 07e37d0593..0000000000 --- a/packages/compas/src/output/static.js +++ /dev/null @@ -1,164 +0,0 @@ -import { AppError } from "@compas/stdlib"; -import { debugPrint } from "./debug.js"; -import { logger } from "./log.js"; -import { tuiPrintInformation } from "./tui.js"; - -export const output = { - cache: { - notExisting: () => { - debugPrint("Did not find a cache file. Defaulting to empty cache."); - }, - errorReadingCache: (e) => { - debugPrint("Could not read cache."); - debugPrint(AppError.format(e)); - - logger.info("Cache is unreadable. Starting with a fresh cache."); - }, - errorValidatingCache: (validationError) => { - debugPrint("Could not validate cache."); - debugPrint(validationError); - - logger.info("Cache is invalid. Starting with a fresh cache."); - }, - invalidCompasVersion: (existingVersion, expectedVersion) => { - debugPrint( - `Found a different cache version. Actual: ${existingVersion}, expected: ${expectedVersion}. Removing existing cache.`, - ); - - logger.info( - `Compas version from cache ('${existingVersion}') is not equal to the installed version ('${expectedVersion}'). Starting with a fresh cache.`, - ); - }, - loaded: (cache) => { - debugPrint("Loaded the cache."); - debugPrint(cache); - }, - }, - config: { - environment: { - creating: () => { - debugPrint("No .env file found, writing .env file."); - tuiPrintInformation( - "No .env file was found. Creating a default .env file.", - ); - }, - loaded: (env) => { - debugPrint("Loaded environment"); - debugPrint(env); - - logger.info({ - message: `Starting up ${env.appName} with ${env.compasVersion}${ - env.isCI ? " in CI" : env.isDevelopment ? " in production" : "" - }.`, - }); - logger.info({ - message: - "Thank you for trying out the new Compas CLI. This is still a work in progress. Checkout https://github.com/compasjs/compas/issues/2774 for planned features and known issues.", - }); - - tuiPrintInformation( - `Thank you for trying out the new Compas CLI. This is still a work in progress. Checkout https://github.com/compasjs/compas/issues/2774 for planned features and known issues.`, - ); - }, - }, - resolve: { - starting: () => { - debugPrint("Resolving config"); - }, - creating: () => { - debugPrint( - "Did not find a config in the project root. Creating empty config.", - ); - }, - notFound: (expectedFileLocation) => { - debugPrint(`Could not find config at ${expectedFileLocation}.`); - logger.error(`Could not find config at ${expectedFileLocation}.`); - tuiPrintInformation( - `Could not find config at ${expectedFileLocation}.`, - ); - }, - parseError: (e, expectedFileLocation) => { - debugPrint( - `Parsing error in config at ${expectedFileLocation}: ${JSON.stringify( - AppError.format(e), - )}.`, - ); - logger.error({ - message: "Unknown error while parsing config", - expectedFileLocation, - error: AppError.format(e), - }); - tuiPrintInformation( - `Could not parse the config in ${expectedFileLocation}.`, - ); - }, - validationError: (error, expectedFileLocation) => { - debugPrint( - `Validation error while resolving config at ${expectedFileLocation}.`, - ); - debugPrint(error); - - for (const key of Object.keys(error)) { - const value = error[key]; - if (key === "$") { - if (value.key === "validator.object") { - logger.error({ - message: - "Expected that the config file contains at least a JSON object.", - configFile: expectedFileLocation, - }); - tuiPrintInformation( - `Config file at ${expectedFileLocation} does not contain a top level object.`, - ); - } else if (value.key === "validator.keys") { - logger.error({ - message: "The config may only specify known keys", - configFile: expectedFileLocation, - unknownKeys: value.unknownKeys, - }); - tuiPrintInformation( - `Config file at ${expectedFileLocation} contains unknown keys which is not allowed: ${value.unknownKeys.join( - ", ", - )}`, - ); - } else { - logger.error({ - message: "Unknown error while loading config", - configFile: expectedFileLocation, - error, - }); - tuiPrintInformation( - `Config file at ${expectedFileLocation} contains an unknown error. Run 'zakmes verify' to retrieve the raw error.`, - ); - return; - } - } else if (key === "$.projects") { - logger.error({ - message: "Unknown error while loading config", - configFile: expectedFileLocation, - error, - }); - tuiPrintInformation( - `Config file at ${expectedFileLocation} contains an invalid projects array. Run 'zakmes verify' to retrieve the raw error.`, - ); - return; - } else { - logger.error({ - message: "Unknown error while loading config", - configFile: expectedFileLocation, - error, - }); - tuiPrintInformation( - `Config file at ${expectedFileLocation} contains an unknown error. Run 'zakmes verify' to retrieve the raw error.`, - ); - return; - } - } - }, - resolved: (resolvedConfig) => { - debugPrint("Successfully resolved a config"); - debugPrint(resolvedConfig); - }, - }, - }, -}; diff --git a/packages/compas/src/output/tui.js b/packages/compas/src/output/tui.js deleted file mode 100644 index 8f6fb3f1b7..0000000000 --- a/packages/compas/src/output/tui.js +++ /dev/null @@ -1,386 +0,0 @@ -import { emitKeypressEvents } from "node:readline"; -import * as readline from "node:readline"; -import { AppError } from "@compas/stdlib"; -import ansi from "ansi"; -import { debugPrint } from "./debug.js"; - -/** - * @type {import("ansi").Cursor} - */ -let cursor; - -const MAX_NUMBER_OF_INFO_LINES = 10; - -// TODO: Use a virtual buffer in combination with node-pty. -// This allows us to keep information at the bottom of the screen like in earlier -// screenshots. To do this, we need some way to translate ansi escape sequences with an -// offset and have a stable API for where the cursor will be. - -/** - * @type {{ - * isEnabled: boolean, - * metadata: { - * compasVersion: string, - * appName: string, - * }, - * informationBuffer: string[], - * ghostOutputLineCount: number, - * dynamicActions: ({ - * title: string, - * actions: { - * name: string, - * shortcut: string, - * callback: () => (void|Promise), - * }[], - * })[], - * dynamicActionsComputed: { - * longestShortcut: number, - * longestName: number, - * }, - * }} - */ -const tuiState = { - isEnabled: false, - metadata: { - appName: "unknown", - compasVersion: "Compas (unknown)", - }, - informationBuffer: [], - ghostOutputLineCount: 0, - - dynamicActions: [], - dynamicActionsComputed: { - longestName: 0, - longestShortcut: 0, - }, -}; - -/** - * Set process metadata and repaint. - * - * @param {{ - * appName: string, - * compasVersion: string, - * }} metadata - */ -export function tuiStateSetMetadata(metadata) { - tuiState.metadata = { - ...tuiState.metadata, - ...metadata, - }; - - if (tuiState.isEnabled) { - tuiPaintLayout(); - } -} - -/** - * Set the available actions. - * - * @param {(typeof tuiState)["dynamicActions"]} actions - */ -export function tuiStateSetAvailableActions(actions) { - tuiState.dynamicActions = actions; - - let longestName = 0; - let longestShortcut = 0; - - for (const group of actions) { - for (const action of group.actions) { - longestName = Math.max(longestName, action.name.length); - longestShortcut = Math.max(longestShortcut, action.shortcut.length); - } - } - - tuiState.dynamicActionsComputed = { - longestName: longestName + 4, - longestShortcut, - }; - debugPrint(tuiState.dynamicActionsComputed); -} - -/** - * Add an information line to the TUI output. - * - * Contents added to this information buffer should clarify when and why Compas does - * certain things. We only keep the last 10 messages, regardless if they are multi-line - * or not. - * - * We automatically repaint when the system is enabled. - * - * @param {string} information - */ -export function tuiPrintInformation(information) { - tuiState.informationBuffer.push(information.trim()); - debugPrint(`[tui] ${information}`); - - // Let go of old information, that we for sure will never print again - while (tuiState.informationBuffer.length > 10) { - tuiState.informationBuffer.shift(); - } - - if (tuiState.isEnabled) { - tuiPaintLayout(); - } -} - -/** - * Executes callback with the ANSI cursor. Make sure to call `tuiClearLayout` before - * attempting to call this function. - * - * In contrast to {@link tuiPrintInformation} these lines persist in the output. So - * should be used around useful context when executing actions. - * - * @param {(cursor: import("ansi").Cursor) => *} callback - */ -export function tuiWritePersistent(callback) { - if (tuiState.ghostOutputLineCount !== 0 || !tuiState.isEnabled) { - throw AppError.serverError({ - message: - "Can only write persistent if the tui is enabled and the information is cleared.", - }); - } - - cursor.reset().buffer(); - callback(cursor); - cursor.reset().flush(); -} - -/** - * Set up callbacks for various actions to manage and redraw the TUI. - */ -export function tuiEnable() { - cursor = ansi(process.stdout); - tuiState.isEnabled = true; - - // General setup - cursor.reset(); - cursor.hide(); - - // Do the initial render - tuiPaintLayout(); - - // Exit listeners - process.on("SIGABRT", () => { - tuiExit(); - }); - process.on("SIGINT", () => { - tuiExit(); - }); - process.on("beforeExit", () => { - tuiExit(); - }); - - // Resize listener - // process.stdout.on("resize", () => tuiPaintLayout()); - - // Input setup + listener - emitKeypressEvents(process.stdin); - process.stdin.setRawMode(true); - - process.stdin.on("keypress", (char, raw) => { - if (raw.name === "c" && raw.ctrl) { - // Ctrl + C - tuiExit(); - } - - tuiOnKeyPress(raw); - }); -} - -/** - * Go through all actions in order and match the keypress name with the configured - * actions. - * - * @param {*} keypress - */ -export function tuiOnKeyPress(keypress) { - if (!keypress.name) { - return; - } - - // Rename a few keypress for easier matching, we may want to expand this setup later. - if (keypress.name === "escape") { - keypress.name = "esc"; - } - - for (const actionGroup of tuiState.dynamicActions) { - for (const action of actionGroup.actions) { - if (action.shortcut.toLowerCase() === keypress.name.toLowerCase()) { - action.callback(); - return; - } - } - } -} - -/** - * Cleanup the screen and exit. - */ -export function tuiExit() { - cursor.buffer(); - - // show the cursor position, it's pretty strange when that gets lost on ya. - cursor.show(); - - tuiEraseLayout(); - - cursor.reset(); - cursor.flush(); - - process.stdin.setRawMode(false); - process.exit(1); -} - -/** - * Write out information and actions + the known metadata. - */ -export function tuiPaintLayout() { - tuiEraseLayout(); - - cursor.reset(); - cursor.buffer(); - - // Keep track of the line count, so we can easily clear the screen when necessary. - // For this to work correctly, we shouldn't use any custom cursor movements in this - // function, so `ansi` can keep track of `\n` and return an accurate value. - const newlineCountStart = cursor.newlines; - - cursor - .blue() - .write(tuiState.metadata.appName) - .reset() - .write(" running with ") - .green() - .write(tuiState.metadata.compasVersion) - .reset() - .write("\n"); - - // Split up the lines to be nicely printed on screen. - // We handle new lines and split on spaces when necessary. - const linesToWrite = []; - const maxLineWidth = process.stdout.columns; - - // Loop through the buffer in reverse, so we always handle the latest messages first - for (let i = tuiState.informationBuffer.length - 1; i >= 0; i--) { - let infoLine = tuiState.informationBuffer[i]; - const lineParts = []; - - while (infoLine.length) { - const newLineIndex = infoLine.indexOf("\n"); - const spaceIndex = infoLine.lastIndexOf( - " ", - newLineIndex === -1 - ? maxLineWidth - : Math.min(newLineIndex, newLineIndex), - ); - const part = infoLine.slice( - 0, - newLineIndex === -1 - ? infoLine.length > maxLineWidth - ? spaceIndex - : maxLineWidth - : Math.min(newLineIndex, process.stdout.columns), - ); - - infoLine = infoLine.slice(part.length).trimStart(); - lineParts.push(part); - } - - // This automatically reverses the array. - linesToWrite.unshift(...lineParts); - - if (linesToWrite.length >= MAX_NUMBER_OF_INFO_LINES) { - break; - } - } - - // We always add all parts, so we may need to truncate the last added message (the - // oldest). - while (linesToWrite.length > MAX_NUMBER_OF_INFO_LINES) { - linesToWrite.shift(); - } - - for (const line of linesToWrite) { - cursor.write(line).write("\n"); - } - - if (tuiState.dynamicActions.length) { - cursor.write("\n"); - } - - for (const actionGroup of tuiState.dynamicActions) { - cursor.write(`${actionGroup.title}\n`); - - const shallowCopy = [...actionGroup.actions]; - - // Write all actions in 2 column mode. - // The widths are based on the longest available shortcut and name. - while (shallowCopy.length) { - const pop1 = shallowCopy.shift(); - const pop2 = shallowCopy.shift(); - - if (!pop1) { - break; - } - - cursor - .reset() - .write(" ") - .green() - .write( - " ".repeat( - tuiState.dynamicActionsComputed.longestShortcut - - pop1.shortcut.length, - ) + pop1.shortcut, - ) - .reset() - .write( - ` ${pop1.name}${" ".repeat( - tuiState.dynamicActionsComputed.longestName - pop1.name.length, - )}`, - ); - - if (!pop2) { - // May be undefined with an odd number of actions. - cursor.write("\n"); - break; - } - - cursor - .reset() - .green() - .write( - " ".repeat( - tuiState.dynamicActionsComputed.longestShortcut - - pop2.shortcut.length, - ) + pop2.shortcut, - ) - .reset() - .write( - ` ${pop2.name}${" ".repeat( - tuiState.dynamicActionsComputed.longestName - pop2.name.length, - )}\n`, - ); - } - } - - cursor.flush(); - - // @ts-expect-error - tuiState.ghostOutputLineCount = cursor.newlines - newlineCountStart; -} - -/** - * Clean up intermediate tui output. This should be done before spawning a process. - */ -export function tuiEraseLayout() { - cursor.reset(); - - if (tuiState.ghostOutputLineCount) { - readline.moveCursor(process.stdout, 0, -tuiState.ghostOutputLineCount); - cursor.eraseData().eraseLine(); - } - - tuiState.ghostOutputLineCount = 0; -} diff --git a/packages/compas/src/package-manager.js b/packages/compas/src/package-manager.js deleted file mode 100644 index 264088cf4c..0000000000 --- a/packages/compas/src/package-manager.js +++ /dev/null @@ -1,19 +0,0 @@ -import { existsSync } from "node:fs"; - -/** - * Determine package manager command to use for installing dependencies. - * - * @returns {string[]} - */ -export function packageManagerDetermineInstallCommand() { - if (existsSync("package-lock.json")) { - return ["npm", "install"]; - } else if (existsSync("yarn.lock")) { - return ["yarn"]; - } else if (existsSync("pnpm-lock.yaml")) { - return ["pnpm", "install"]; - } - - // Default to NPM - return ["npm", "install"]; -} diff --git a/packages/compas/src/shared/README.md b/packages/compas/src/shared/README.md new file mode 100644 index 0000000000..3e562ed720 --- /dev/null +++ b/packages/compas/src/shared/README.md @@ -0,0 +1,5 @@ +# Shared + +- Functions should use `debugPrint` +- Functions should throw on errors with unique error keys +- Functions should help the user in doing the correct thing whenever possible. diff --git a/packages/compas/src/shared/config.js b/packages/compas/src/shared/config.js new file mode 100644 index 0000000000..bbe529e14b --- /dev/null +++ b/packages/compas/src/shared/config.js @@ -0,0 +1,158 @@ +import { existsSync } from "node:fs"; +import { readFile } from "node:fs/promises"; +import path from "node:path"; +import { + AppError, + dirnameForModule, + environment, + isNil, + isProduction, + loggerDetermineDefaultDestination, + pathJoin, + refreshEnvironmentCache, +} from "@compas/stdlib"; +import dotenv from "dotenv"; +import { validateCompasConfig } from "../generated/compas/validators.js"; +import { writeFileChecked } from "./fs.js"; +import { debugPrint, debugTimeEnd, debugTimeStart } from "./output.js"; + +/** + * @typedef {{ + * isCI: boolean, + * isDevelopment: boolean, + * appName: string, + * compasVersion: string, + * nodeVersion: string, + * }} ConfigEnvironment + */ + +/** + * Load .env files, resolve the Compas version and information to determine in which mode + * we're booting. + * + * @param {boolean} preferPrettyPrint + * @returns {Promise} + */ +export async function configLoadEnvironment(preferPrettyPrint) { + debugTimeStart("config.environment"); + + const defaultDotEnvFile = ".env"; + + if (isNil(process.env.NODE_ENV) && !existsSync(defaultDotEnvFile)) { + // Write a default .env file, we only do this if a NODE_ENV is not explicitly set. + + debugPrint("No .env file found, writing .env file."); + + const dirname = process.cwd().split(path.sep).pop(); + await writeFileChecked( + defaultDotEnvFile, + `NODE_ENV=development +APP_NAME=${dirname} +`, + ); + } + + // Load .env.local first, since existing values in `process.env` are not overwritten. + dotenv.config({ path: path.resolve(process.cwd(), ".env.local") }); + dotenv.config(); + + refreshEnvironmentCache(); + + if (preferPrettyPrint && environment.NODE_ENV !== "development") { + environment.COMPAS_LOG_PRINTER = "pretty"; + } + + loggerDetermineDefaultDestination(); + + const packageJson = JSON.parse( + await readFile( + pathJoin(dirnameForModule(import.meta), "../../package.json"), + "utf-8", + ), + ); + + const env = { + isCI: environment.CI === "true", + isDevelopment: !isProduction(), + appName: environment.APP_NAME ?? process.cwd().split(path.sep).pop(), + compasVersion: packageJson.version + ? `Compas ${packageJson.version}` + : "Compas v0.0.0", + nodeVersion: process.version, + }; + + debugPrint(env); + + debugTimeEnd("config.environment"); + + return env; +} + +/** + * Resolve the full project config. + * + * @returns {Promise} + */ +export async function configResolveProjectConfig() { + debugTimeStart("config.resolveProjectConfig"); + + async function loadRelativeConfig(relativeDirectory) { + debugPrint(`Resolving config in '${relativeDirectory}'.`); + + const configPath = pathJoin(relativeDirectory, "config/compas.json"); + + let rawConfig = {}; + if (existsSync(configPath)) { + try { + rawConfig = JSON.parse(await readFile(configPath, "utf-8")); + } catch (e) { + debugPrint({ + message: "parseError", + relativeDirectory, + error: AppError.format(e), + }); + + // @ts-expect-error + throw AppError.validationError("config.resolve.parseError", {}, e); + } + } + + const { error, value } = validateCompasConfig(rawConfig); + + if (error) { + debugPrint({ + message: "validationError", + relativeDirectory, + error, + }); + + throw AppError.validationError("config.resolve.validationError", { + relativeDirectory, + error, + }); + } + + const projects = []; + + for (const subProject of value.projects ?? []) { + projects.push( + await loadRelativeConfig(pathJoin(relativeDirectory, subProject)), + ); + } + + // @ts-expect-error + value.projects = projects; + // @ts-expect-error + value.rootDirectory = path.resolve(relativeDirectory); + + return value; + } + + const config = await loadRelativeConfig(process.cwd()); + + debugTimeEnd("config.resolveProjectConfig"); + debugPrint(config); + + // @ts-expect-error + return config; +} diff --git a/packages/compas/src/shared/fs.js b/packages/compas/src/shared/fs.js new file mode 100644 index 0000000000..97d15fa502 --- /dev/null +++ b/packages/compas/src/shared/fs.js @@ -0,0 +1,21 @@ +import { writeFile, mkdir } from "node:fs/promises"; + +/** + * Write a file. + * + * Always executes a mkdir operation, to ensure that the directory exists. + * + * @param {string} file + * @param {string|Buffer} contents + * @returns {Promise} + */ +export async function writeFileChecked(file, contents) { + const dir = file.split("/").slice(0, -1).join("/"); + + if (dir.length && file.includes("/")) { + // file could be something like "package.json", which means that we write to the + // current working directory. Which most likely exists already. + await mkdir(dir, { recursive: true }); + } + await writeFile(file, contents); +} diff --git a/packages/compas/src/output/debug.js b/packages/compas/src/shared/output.js similarity index 70% rename from packages/compas/src/output/debug.js rename to packages/compas/src/shared/output.js index 219e4693ca..4392269bf7 100644 --- a/packages/compas/src/output/debug.js +++ b/packages/compas/src/shared/output.js @@ -1,4 +1,6 @@ -import { mkdirSync, appendFileSync } from "node:fs"; +import { appendFileSync, existsSync, mkdirSync } from "node:fs"; +import { noop } from "@compas/stdlib"; +import { writeFileChecked } from "./fs.js"; const DEBUG_LOCATION = `.cache/compas/debug-${String(Date.now()).slice( 0, @@ -27,6 +29,33 @@ const inMemoryDebugOutput = []; */ const activeTimers = {}; +/** + * Default logger, consisting of noop's + */ +const noopLogger = { + info: noop, + error: noop, +}; + +/** + * \@compas/stdlib Logger to use for pretty printing in normal scenario's. Shouldn't be + * used in combination with the TUI. + * + * Note that a 'noop' logger is used as long as {@link loggerEnable} is not called. + * + * @type {import("@compas/stdlib").Logger} + */ +export let logger = noopLogger; + +/** + * Set the logger. + * + * @param {import("@compas/stdlib").Logger} setLogger + */ +export function loggerEnable(setLogger) { + logger = setLogger; +} + /** * Appends the provided contents with a timestamp to {@link DEBUG_LOCATION}. * @@ -51,9 +80,16 @@ export function debugPrint(contents) { } // Add a date so we know what's up. - const outputString = `${new Date().toISOString()} - ${contents}`; + const outputString = `${new Date().toISOString()} :: ${contents}`; if (shouldOutputDebugInfo === true) { + if (!existsSync(DEBUG_LOCATION)) { + // File is removed for some reason... + const dir = DEBUG_LOCATION.split("/").slice(0, -1).join("/"); + + mkdirSync(dir, { recursive: true }); + } + appendFileSync(DEBUG_LOCATION, `${outputString}\n`, {}); } else { inMemoryDebugOutput.push(outputString); @@ -99,16 +135,15 @@ export function debugTimeEnd(label) { /** * Enable writing debug info to the debug file. */ -export function debugEnable() { +export async function debugEnable() { if (shouldOutputDebugInfo === true) { return; } - // Write local cache - mkdirSync(DEBUG_LOCATION.split("/").slice(0, -1).join("/"), { - recursive: true, - }); - appendFileSync(DEBUG_LOCATION, `${inMemoryDebugOutput.join("\n")}\n`); + await writeFileChecked( + DEBUG_LOCATION, + inMemoryDebugOutput.length > 0 ? `${inMemoryDebugOutput.join("\n")}\n` : "", + ); shouldOutputDebugInfo = true; inMemoryDebugOutput.splice(0, inMemoryDebugOutput.length); diff --git a/packages/compas/src/shared/package-manager.js b/packages/compas/src/shared/package-manager.js new file mode 100644 index 0000000000..8296a84d70 --- /dev/null +++ b/packages/compas/src/shared/package-manager.js @@ -0,0 +1,22 @@ +import { existsSync } from "node:fs"; +import { pathJoin } from "@compas/stdlib"; +import { debugPrint } from "./output.js"; + +/** + * Determine package manager command to use for installing dependencies. + * + * @returns {string[]} + */ +export function packageManagerDetermineInstallCommand(rootDirectory = "") { + if (existsSync(pathJoin(rootDirectory, "package-lock.json"))) { + return ["npm", "install"]; + } else if (existsSync(pathJoin(rootDirectory, "yarn.lock"))) { + return ["yarn"]; + } else if (existsSync(pathJoin(rootDirectory, "pnpm-lock.yaml"))) { + return ["pnpm", "install"]; + } + + debugPrint(`Defaulting install command to npm for '${rootDirectory}'.`); + + return ["npm", "install"]; +}