diff --git a/cli/deno.json b/cli/deno.json index 77d97af4..f6e04bb1 100644 --- a/cli/deno.json +++ b/cli/deno.json @@ -1,17 +1,17 @@ { "name": "@molt/cli", - "version": "0.18.5", + "version": "0.19.0-rc.9", "exports": { ".": "./main.ts" }, "publish": { "include": [ "deno.json", - "*.ts", - "modules/*.ts" + "main.ts", + "src/*.ts" ], "exclude": [ - "*_test.ts" + "**/*_test.ts" ] } } diff --git a/cli/fixtures/deno.json b/cli/fixtures/deno.json new file mode 100644 index 00000000..f17b9cef --- /dev/null +++ b/cli/fixtures/deno.json @@ -0,0 +1,7 @@ +{ + "imports": { + "std/": "https://deno.land/std@0.222.0/", + "@luca/flag": "jsr:@luca/flag@^1.0.0", + "@conventional-commits/parser": "npm:@conventional-commits/parser@^0.3.0" + } +} diff --git a/cli/fixtures/deno.lock b/cli/fixtures/deno.lock new file mode 100644 index 00000000..2ba19844 --- /dev/null +++ b/cli/fixtures/deno.lock @@ -0,0 +1,55 @@ +{ + "version": "3", + "packages": { + "specifiers": { + "jsr:@luca/flag@^1.0.0": "jsr:@luca/flag@1.0.0", + "npm:@conventional-commits/parser@^0.3.0": "npm:@conventional-commits/parser@0.3.0" + }, + "jsr": { + "@luca/flag@1.0.0": { + "integrity": "1c76cf54839a86d0929a619c61bd65bb73d7d8a4e31788e48c720dbc46c5d546" + } + }, + "npm": { + "@conventional-commits/parser@0.3.0": { + "integrity": "sha512-d4zk0gf9hQAILHE1pXWuIuqM/OBsvcOxcUgGZN4PQkIXQWvjbFOkoSv6HztKFEqfZEY95fe85XqBWLWqWtaW6g==", + "dependencies": { + "unist-util-visit": "unist-util-visit@2.0.3", + "unist-util-visit-parents": "unist-util-visit-parents@3.1.1" + } + }, + "@types/unist@2.0.10": { + "integrity": "sha512-IfYcSBWE3hLpBg8+X2SEa8LVkJdJEkT2Ese2aaLs3ptGdVtABxndrMaxuFlQ1qdFf9Q5rDvDpxI3WwgvKFAsQA==", + "dependencies": {} + }, + "unist-util-is@4.1.0": { + "integrity": "sha512-ZOQSsnce92GrxSqlnEEseX0gi7GH9zTJZ0p9dtu87WRb/37mMPO2Ilx1s/t9vBHrFhbgweUwb+t7cIn5dxPhZg==", + "dependencies": {} + }, + "unist-util-visit-parents@3.1.1": { + "integrity": "sha512-1KROIZWo6bcMrZEwiH2UrXDyalAa0uqzWCxCJj6lPOvTve2WkfgCytoDTPaMnodXh1WrXOq0haVYHj99ynJlsg==", + "dependencies": { + "@types/unist": "@types/unist@2.0.10", + "unist-util-is": "unist-util-is@4.1.0" + } + }, + "unist-util-visit@2.0.3": { + "integrity": "sha512-iJ4/RczbJMkD0712mGktuGpm/U4By4FfDonL7N/9tATGIF4imikjOuagyMY53tnZq3NP6BcmlrHhEKAfGWjh7Q==", + "dependencies": { + "@types/unist": "@types/unist@2.0.10", + "unist-util-is": "unist-util-is@4.1.0", + "unist-util-visit-parents": "unist-util-visit-parents@3.1.1" + } + } + } + }, + "remote": { + "https://deno.land/std@0.222.0/bytes/copy.ts": "f29c03168853720dfe82eaa57793d0b9e3543ebfe5306684182f0f1e3bfd422a" + }, + "workspace": { + "dependencies": [ + "jsr:@luca/flag@^1.0.0", + "npm:@conventional-commits/parser@^0.3.0" + ] + } +} diff --git a/cli/fixtures/mod.ts b/cli/fixtures/mod.ts new file mode 100644 index 00000000..069de2e3 --- /dev/null +++ b/cli/fixtures/mod.ts @@ -0,0 +1,3 @@ +import "https://deno.land/std@0.222.0/bytes/copy.ts"; +import "jsr:@luca/flag@1.0.0"; +import "npm:@conventional-commits/parser@0.3.0"; diff --git a/cli/main.ts b/cli/main.ts index e49637a4..05c86836 100644 --- a/cli/main.ts +++ b/cli/main.ts @@ -1,110 +1,113 @@ import { Command } from "@cliffy/command"; import $ from "@david/dax"; +import { collect } from "@molt/core"; +import type { Update } from "@molt/core/types"; + +import { printChangelog } from "./src/changelog.ts"; +import { findConfig, findLock, findSource } from "./src/files.ts"; +import { runTasks } from "./src/tasks.ts"; +import { print, printRefs } from "./src/updates.ts"; const main = new Command() .name("molt") - .description( - "Check updates to dependencies in Deno modules and configuration files", - ) - .versionOption("-v, --version", "Print version info", versionCommand) - .option("-w, --write", "Write changes to local files", { + .description("Check updates to dependencies in a Deno project.") + .versionOption("-v, --version", "Print version info.", version) + .option("-w, --write", "Write changes to the local files.", { conflicts: ["commit"], }) - .option("-c, --commit", "Commit changes to local git repository", { + .option("-c, --commit", "Commit changes to the local git repository.", { conflicts: ["write"], }) .option( - "--changelog=[commit_types:string[]]", - "Show a curated changelog for each update", + "--changelog=[types:string[]]", + "Print a curated changelog for each update.", ) - .option("--debug", "Print debug information") - .option("--import-map ", "Specify import map file") - .option("--ignore=", "Ignore dependencies") - .option("--no-resolve", "Do not resolve local imports") - .option("--only=", "Check specified dependencies") + .option("--config ", "Specify the Deno configuration file.") + .option("--dry-run", "See what would happen without actually doing it.") + .option("--ignore ", "Specify dependencies to ignore.") + .option("--only ", "Specify dependencies to check.") + .option("--lock ", "Specify the lock file.") + .option("--no-config", "Disable automatic loading of the configuration file.") + .option("--no-lock", "Disable automatic loading of the lock file.") .option("--pre-commit=", "Run tasks before each commit", { depends: ["commit"], }) .option("--prefix ", "Prefix for commit messages", { depends: ["commit"], }) - .option( - "--prefix-lock ", - "Prefix for commit messages of updating a lock file", - { depends: ["commit", "unstable-lock"] }, - ) - .option( - "--unstable-lock [file:string]", - "Enable unstable updating of a lock file", - ) - .arguments("") - .action(async function (options, ...files) { - if ( - options.importMap && await $.path(options.importMap).exists() === false - ) { - throw new Error(`Import map ${options.importMap} does not exist.`); - } - ensureFiles(files); - const updates = await import("./modules/collect.ts").then((mod) => - mod.default(files, options) - ); - await import("./modules/print.ts").then((mod) => - mod.default(files, updates, options) - ); - if (options.write) { - await import("./modules/write.ts").then((mod) => mod.default(updates)); - } - if (options.commit) { - const tasks = await import("./modules/tasks.ts").then((mod) => - mod.getTasks() - ); - const { filterKeys } = await import("@std/collections/filter-keys"); - await import("./modules/commit.ts").then((mod) => - mod.default(updates, { - ...options, - preCommit: filterKeys( - tasks, - (key) => options.preCommit?.includes(key) ?? false, - ), - }) - ); - } - }); + .option("--referrer", "Print files that import the dependency.") + .arguments("[source...:string]"); -async function versionCommand() { +async function version() { const { default: configs } = await import("./deno.json", { with: { type: "json" }, }); console.log(configs.version); } -function ensureFiles(paths: string[]) { - for (const path of paths) { - try { - if (!Deno.statSync(path).isFile) { - throw new Error(`Not a valid file: "${path}"`); - } - } catch { - throw new Error(`Path does not exist: "${path}"`); - } +main.action(async function (options, ...source) { + const config = options.config === false + ? undefined + : options.config ?? await findConfig(); + + const lock = options.lock === false + ? undefined + : options.lock ?? await findLock(); + + source = source.length ? source : config ? [] : await findSource(); + + if (options.dryRun) { + const paths = [config, lock, ...source].filter((it) => it != null); + await import("./src/mock.ts").then((m) => m.mock(paths)); } -} -if (import.meta.main) { - const debug = Deno.args.includes("--debug"); - try { - const env = await Deno.permissions.query({ name: "env" }); - if (env.state === "granted" && Deno.env.get("MOLT_TEST")) { - (await import("./modules/testing.ts")).default(); + const deps = await $.progress("Collecting dependencies").with( + () => collect({ config, lock, source }), + ); + const filtered = deps + .filter((dep) => options.only ? dep.name.match(options.only) : true) + .filter((dep) => options.ignore ? !dep.name.match(options.ignore) : true); + + const updates = (await $.progress("Fetching updates").with(() => + Promise.all(filtered.map((dep) => + dep.check() + )) + )).filter((u) => u != null).sort(compare); + + for (const update of updates) { + print(update); + if (options.referrer) { + printRefs(update); } - await main.parse(Deno.args); - } catch (error) { - if (debug) { - throw error; + if (options.changelog) { + await $.progress("Fetching changelog").with(() => + printChangelog(update, options) + ); } - if (error.message) { - console.error("Error: " + error.message); + } + + if (options.write) { + await $.progress("Writing changes").with(async () => { + for (const update of updates) { + await update.write(); + } + }); + } + + if (options.commit) { + for (const update of updates) { + const message = update.summary(options.prefix); + await $.progress(`Committing ${message}`).with(async () => { + await update.write(); + if (options.preCommit) { + await runTasks(options.preCommit, config); + } + await update.commit(message); + }); } - Deno.exit(1); } -} +}); + +const compare = (a: Update, b: Update) => a.dep.name.localeCompare(b.dep.name); + +await main.parse(Deno.args); diff --git a/cli/main_test.ts b/cli/main_test.ts index 67512de4..00bfc54b 100644 --- a/cli/main_test.ts +++ b/cli/main_test.ts @@ -1,12 +1,9 @@ -import { assertEquals, assertStringIncludes } from "@std/assert"; +import { assertEquals } from "@std/assert"; import { stripAnsiCode } from "@std/fmt/colors"; +import { fromFileUrl } from "@std/path"; import { describe, it } from "@std/testing/bdd"; import dedent from "dedent"; -const BIN = new URL("./main.ts", import.meta.url).pathname; -const DIR = new URL("../test/fixtures", import.meta.url).pathname; -const CONFIG = new URL("../deno.json", import.meta.url).pathname; - interface CommandResult { code: number; stdout: string; @@ -15,14 +12,16 @@ interface CommandResult { async function molt(argstr: string): Promise { const args = argstr.split(" ").filter((it) => it.length); + const main = fromFileUrl(new URL("./main.ts", import.meta.url)); + const { code, stderr, stdout } = await new Deno.Command("deno", { - args: ["run", "-A", "--unstable-kv", "--config", CONFIG, BIN, ...args], - env: { MOLT_TEST: "1" }, - cwd: DIR, + args: ["run", "-A", main, "--dry-run", ...args], + cwd: new URL("./fixtures", import.meta.url), }).output(); - const decoder = new TextDecoder(); + const format = (bytes: Uint8Array) => - stripAnsiCode(decoder.decode(bytes)).trim(); + stripAnsiCode(new TextDecoder().decode(bytes)).trim(); + return { code, stderr: format(stderr), @@ -31,248 +30,143 @@ async function molt(argstr: string): Promise { } describe("CLI", () => { - it("should error without arguments", async () => { - const { code, stderr } = await molt(""); - assertEquals(code, 2); - assertEquals(stderr, "error: Missing argument(s): modules"); - }); - - it("should print the version", async () => { - const { stdout } = await molt("--version"); - const { default: config } = await import("./deno.json", { - with: { type: "json" }, - }); - assertEquals(stdout, config.version); - }); - - it("should find updates from `deno.jsonc`", async () => { - const { stdout } = await molt("deno.jsonc"); + it("should find updates in `deno.json` by default", async () => { + const { stderr, stdout } = await molt(""); assertEquals( stdout, dedent` - 📦 @octokit/core 6.1.0 => 123.456.789 - 📦 @std/assert 0.222.0 => 123.456.789 - 📦 @std/bytes => 123.456.789 - 📦 @std/testing 0.222.0 => 123.456.789 - 📦 deno.land/std 0.222.0, => 123.456.789 - 📦 deno.land/x/deno_graph 0.50.0 => 123.456.789 + 📦 @conventional-commits/parser 0.3.0 → 0.4.1 (^0.3.0 → ^0.4.0) + 📦 @luca/flag 1.0.0 → 1.0.1 + 📦 deno.land/std 0.222.0 → 0.224.0 `, ); - }); - - it("should update `deno.jsonc`", async () => { - const { code, stdout } = await molt("deno.jsonc --write"); - assertEquals(code, 0); assertEquals( - stdout, + stderr, dedent` - 📦 @octokit/core 6.1.0 => 123.456.789 - 📦 @std/assert 0.222.0 => 123.456.789 - 📦 @std/bytes => 123.456.789 - 📦 @std/testing 0.222.0 => 123.456.789 - 📦 deno.land/std 0.222.0, => 123.456.789 - 📦 deno.land/x/deno_graph 0.50.0 => 123.456.789 - - 💾 deno.jsonc + Collecting dependencies + Fetching updates `, ); }); - it("should commit changes to `deno.jsonc`", async () => { - const { code, stdout } = await molt("deno.jsonc --commit"); - assertEquals(code, 0); + it("should update `deno.json` with `--write`", async () => { + const { stderr, stdout } = await molt("--write"); assertEquals( stdout, dedent` - 📦 @octokit/core 6.1.0 => 123.456.789 - 📦 @std/assert 0.222.0 => 123.456.789 - 📦 @std/bytes => 123.456.789 - 📦 @std/testing 0.222.0 => 123.456.789 - 📦 deno.land/std 0.222.0, => 123.456.789 - 📦 deno.land/x/deno_graph 0.50.0 => 123.456.789 - - 📝 bump @octokit/core from 6.1.0 to 123.456.789 - 📝 bump @std/assert from 0.222.0 to 123.456.789 - 📝 bump @std/bytes to 123.456.789 - 📝 bump @std/testing from 0.222.0 to 123.456.789 - 📝 bump deno.land/std from 0.222.0 to 123.456.789 - 📝 bump deno.land/x/deno_graph from 0.50.0 to 123.456.789 + 📦 @conventional-commits/parser 0.3.0 → 0.4.1 (^0.3.0 → ^0.4.0) + 📦 @luca/flag 1.0.0 → 1.0.1 + 📦 deno.land/std 0.222.0 → 0.224.0 `, ); - }); - - it("should find updates to mod_test.ts", async () => { - // FIXME: We must pass `--import-map` explicitly because `@chiezo/amber` - // does not support `Deno.readDir` yet, which is called by `findFileUp`. - const { stdout } = await molt("mod_test.ts --import-map deno.jsonc"); assertEquals( - stdout, - dedent` - 📦 @std/assert 0.222.0 => 123.456.789 - mod_test.ts - 📦 @std/testing 0.222.0 => 123.456.789 - deno.jsonc - 📦 deno.land/x/deno_graph 0.50.0 => 123.456.789 - mod.ts - `, - ); - }); - - it("should write updates collected from mod_test.ts", async () => { - const { code, stdout } = await molt( - "mod_test.ts --import-map deno.jsonc --write", - ); - assertEquals(code, 0); - assertEquals( - stdout, + stderr, dedent` - 📦 @std/assert 0.222.0 => 123.456.789 - mod_test.ts - 📦 @std/testing 0.222.0 => 123.456.789 - deno.jsonc - 📦 deno.land/x/deno_graph 0.50.0 => 123.456.789 - mod.ts - - 💾 deno.jsonc - 💾 mod_test.ts - 💾 mod.ts + Collecting dependencies + Fetching updates + Writing changes `, ); }); - it("should commit updates collected from mod_test.ts", async () => { - const { code, stdout } = await molt( - "mod_test.ts --import-map deno.jsonc --commit", - ); - assertEquals(code, 0); + it("should commit updates with `--commit`", async () => { + const { stderr, stdout } = await molt("--commit --prefix chore:"); assertEquals( stdout, dedent` - 📦 @std/assert 0.222.0 => 123.456.789 - mod_test.ts - 📦 @std/testing 0.222.0 => 123.456.789 - deno.jsonc - 📦 deno.land/x/deno_graph 0.50.0 => 123.456.789 - mod.ts - - 📝 bump @std/assert from 0.222.0 to 123.456.789 - 📝 bump @std/testing from 0.222.0 to 123.456.789 - 📝 bump deno.land/x/deno_graph from 0.50.0 to 123.456.789 + 📦 @conventional-commits/parser 0.3.0 → 0.4.1 (^0.3.0 → ^0.4.0) + 📦 @luca/flag 1.0.0 → 1.0.1 + 📦 deno.land/std 0.222.0 → 0.224.0 `, ); - }); - - it("should ignore dependencies with `--ignore` option", async () => { - const { stdout } = await molt("deno.jsonc --ignore std"); assertEquals( - stdout, + stderr, dedent` - 📦 @octokit/core 6.1.0 => 123.456.789 - 📦 deno.land/x/deno_graph 0.50.0 => 123.456.789 + Collecting dependencies + Fetching updates + Committing chore: bump @conventional-commits/parser to 0.4.1 + Committing chore: bump @luca/flag from 1.0.0 to 1.0.1 + Committing chore: bump deno.land/std from 0.222.0 to 0.224.0 `, ); }); - it("should accept multiple entries for `--ignore` option", async () => { - const { stdout } = await molt("deno.jsonc --ignore=std,octokit"); + it("should find the same updates with `--lock deno.lock`", async () => { + const { stdout } = await molt("--lock deno.lock"); assertEquals( stdout, dedent` - 📦 deno.land/x/deno_graph 0.50.0 => 123.456.789 + 📦 @conventional-commits/parser 0.3.0 → 0.4.1 (^0.3.0 → ^0.4.0) + 📦 @luca/flag 1.0.0 → 1.0.1 + 📦 deno.land/std 0.222.0 → 0.224.0 `, ); }); - it("should filter updates with `--only` option", async () => { - const { stdout } = await molt("deno.jsonc --only std"); + it("should only find updates to constraints with `--no-lock`", async () => { + const { stdout } = await molt("--no-lock"); assertEquals( stdout, dedent` - 📦 @std/assert 0.222.0 => 123.456.789 - 📦 @std/bytes => 123.456.789 - 📦 @std/testing 0.222.0 => 123.456.789 - 📦 deno.land/std 0.222.0, => 123.456.789 + 📦 @conventional-commits/parser ^0.3.0 → ^0.4.0 + 📦 deno.land/std 0.222.0 → 0.224.0 `, ); }); - it("should accept multiple entries for `--only` option", async () => { - const { stdout } = await molt("deno.jsonc --only=octokit,deno_graph"); + it("should filter dependencies with `--only`", async () => { + const { stdout } = await molt("--only flag"); assertEquals( stdout, dedent` - 📦 @octokit/core 6.1.0 => 123.456.789 - 📦 deno.land/x/deno_graph 0.50.0 => 123.456.789 + 📦 @luca/flag 1.0.0 → 1.0.1 `, ); }); - it("should not resolve local imports with `--no-resolve` option", async () => { - const { stdout } = await molt("mod_test.ts --no-resolve"); + it("should filter out dependencies with `--ignore`", async () => { + const { stdout } = await molt("--ignore flag"); assertEquals( stdout, dedent` - 📦 @std/assert 0.222.0 => 123.456.789 + 📦 @conventional-commits/parser 0.3.0 → 0.4.1 (^0.3.0 → ^0.4.0) + 📦 deno.land/std 0.222.0 → 0.224.0 `, ); }); - it("should run tasks before each commit with `--pre-commit` option", async () => { - const { stdout, stderr } = await molt( - "mod.ts --commit --pre-commit=fmt", - ); + it("should find updates in modules with `--no-config`", async () => { + const { stdout } = await molt("--no-config"); assertEquals( stdout, dedent` - 📦 deno.land/x/deno_graph 0.50.0 => 123.456.789 - - 💾 bump deno.land/x/deno_graph from 0.50.0 to 123.456.789 - 🔨 Running task fmt... - 📝 bump deno.land/x/deno_graph from 0.50.0 to 123.456.789 + 📦 @conventional-commits/parser 0.3.0 → 0.4.1 + 📦 @luca/flag 1.0.0 → 1.0.1 + 📦 deno.land/std 0.222.0 → 0.224.0 `, - stderr, ); }); - it("should prefix commit messages with `--prefix` option", async () => { - const { stdout } = await molt("mod.ts --commit --prefix=chore:"); + it("should find updates in the specified module", async () => { + const { stdout } = await molt("--no-config mod.ts"); assertEquals( stdout, dedent` - 📦 deno.land/x/deno_graph 0.50.0 => 123.456.789 - - 📝 chore: bump deno.land/x/deno_graph from 0.50.0 to 123.456.789 + 📦 @conventional-commits/parser 0.3.0 → 0.4.1 + 📦 @luca/flag 1.0.0 → 1.0.1 + 📦 deno.land/std 0.222.0 → 0.224.0 `, ); }); - // FIXME: Abandon the stub for `fetch` to test this feature. - it("should find updates to a lock file with `--unstable-lock` option", async () => { - const { code, stdout, stderr } = await molt( - "mod_test.ts --unstable-lock --import-map deno.jsonc --write", - ); - assertEquals(code, 1); + it("should also find updates in modules with `--no-config` and `--no-lock`", async () => { + const { stdout } = await molt("--no-config --no-lock"); assertEquals( stdout, dedent` - 📦 @std/assert 0.222.0 => 123.456.789 - mod_test.ts - deno.lock - 📦 @std/testing 0.222.0 => 123.456.789 - deno.jsonc - deno.lock - 📦 deno.land/x/deno_graph 0.50.0 => 123.456.789 - mod.ts - deno.lock - - 💾 deno.jsonc - `, - ); - assertStringIncludes( - stderr, - dedent` - Error: Download https://deno.land/x/deno_graph@123.456.789/mod.ts - error: Module not found "https://deno.land/x/deno_graph@123.456.789/mod.ts". + 📦 @conventional-commits/parser 0.3.0 → 0.4.1 + 📦 @luca/flag 1.0.0 → 1.0.1 + 📦 deno.land/std 0.222.0 → 0.224.0 `, ); }); diff --git a/cli/modules/collect.ts b/cli/modules/collect.ts deleted file mode 100644 index aa635af5..00000000 --- a/cli/modules/collect.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { $ } from "@david/dax"; -import { collect, type CollectResult } from "@molt/core"; - -export default async function ( - entrypoints: string[], - options: { - resolve?: boolean; - ignore?: string[]; - importMap?: string; - only?: string[]; - unstableLock?: true | string; - }, -): Promise { - const result = await $.progress("Checking for updates").with(() => - collect(entrypoints, { - lock: !!options.unstableLock, - lockFile: typeof options.unstableLock === "string" - ? options.unstableLock - : undefined, - importMap: options.importMap, - ignore: options.ignore - ? (dep) => options.ignore!.some((it) => dep.name.includes(it)) - : undefined, - only: options.only - ? (dep) => options.only!.some((it) => dep.name.includes(it)) - : undefined, - resolveLocal: options.resolve, - }) - ); - if (!result.updates.length) { - console.log("🍵 No updates found"); - Deno.exit(0); - } - return result; -} diff --git a/cli/modules/commit.ts b/cli/modules/commit.ts deleted file mode 100644 index 37e5f050..00000000 --- a/cli/modules/commit.ts +++ /dev/null @@ -1,52 +0,0 @@ -import { type CollectResult, createCommitSequence, execute } from "@molt/core"; -import { runTask, type TaskRecord } from "./tasks.ts"; - -const formatPrefix = (prefix: string | undefined) => - prefix ? prefix.trimEnd() + " " : ""; - -export default async function ( - result: CollectResult, - options: { - preCommit?: TaskRecord; - postCommit?: TaskRecord; - prefix?: string; - prefixLock?: string; - }, -) { - console.log(); - - const preCommitTasks = Object.entries(options?.preCommit ?? {}); - const postCommitTasks = Object.entries(options?.postCommit ?? {}); - const hasTask = preCommitTasks.length > 0 || postCommitTasks.length > 0; - - let count = 0; - const commits = createCommitSequence(result, { - groupBy: (dependency) => dependency.to.name, - composeCommitMessage: ({ group, types, version }) => - formatPrefix( - types.length === 1 && types.includes("lockfile") - ? options.prefixLock - : options.prefix, - ) + `bump ${group}` + - (version?.from ? ` from ${version?.from}` : "") + - (version?.to ? ` to ${version?.to}` : ""), - preCommit: preCommitTasks.length > 0 - ? async (commit) => { - console.log(`💾 ${commit.message}`); - for (const t of preCommitTasks) { - await runTask(t); - } - } - : undefined, - postCommit: async (commit) => { - console.log(`📝 ${commit.message}`); - for (const task of postCommitTasks) { - await runTask(task); - } - if (hasTask && ++count < commits.commits.length) { - console.log(); - } - }, - }); - await execute(commits); -} diff --git a/cli/modules/print.ts b/cli/modules/print.ts deleted file mode 100644 index 3d2ff62e..00000000 --- a/cli/modules/print.ts +++ /dev/null @@ -1,62 +0,0 @@ -import { distinct, mapNotNullish } from "@std/collections"; -import { relative } from "@std/path"; -import { colors } from "@cliffy/ansi"; -import type { CollectResult, DependencyUpdate } from "@molt/core"; - -const { gray, yellow, bold } = colors; - -export default async function ( - entrypoints: string[], - result: CollectResult, - options: { changelog?: true | string[] }, -): Promise { - /** A map of names of dependencies to a list of updates */ - const dependencies = new Map(); - for (const u of result.updates) { - const list = dependencies.get(u.to.name) ?? []; - list.push(u); - dependencies.set(u.to.name, list); - } - /** A list of files that being updated */ - const files = distinct(result.updates.map((u) => u.referrer)); - // - // Print information on each dependency - // - for (const [name, updates] of dependencies) { - const froms = mapNotNullish(updates, (it) => it.from); - const updated = updates[0].to; - // - // Print the name of the dependency and the version change - // ex. deno.land/std 0.220, 0.222.1 => 0.223.0 - // - const versions = distinct(froms.map((d) => d.version)); - const joined = versions.join(", "); - console.log( - `📦 ${bold(name)} ${yellow(joined)} => ${yellow(updated.version)}`, - ); - // - // Print a curated changelog for the dependency - // - if (options.changelog) { - const { default: printChangeLog } = await import("./changelog.ts"); - try { - await printChangeLog(updated, froms, options); - } catch { - // The dependency is a package but not tagged in the repository - } - } - // - // Print modules that import the dependency. - // ex. /path/to/mod.ts 0.222.1 - // - if (entrypoints.length > 1 || files.length > 1) { - distinct( - updates.map((u) => { - const source = relative(Deno.cwd(), u.map?.source ?? u.referrer); - const version = versions.length > 1 ? u.from?.version : undefined; - return " " + gray(source + (version ? ` (${version})` : "")); - }), - ).forEach((it) => console.log(it)); - } - } -} diff --git a/cli/modules/tasks.ts b/cli/modules/tasks.ts deleted file mode 100644 index c24fa23b..00000000 --- a/cli/modules/tasks.ts +++ /dev/null @@ -1,45 +0,0 @@ -import { mapEntries } from "@std/collections/map-entries"; -import { parse as parseJsonc } from "@std/jsonc"; -import { colors } from "@cliffy/ansi"; -import { ensure, is } from "@core/unknownutil"; -import { findFileUp } from "@molt/lib/path"; - -const { cyan } = colors; - -export type TaskRecord = Record; - -export async function getTasks() { - const tasks: TaskRecord = { - fmt: ["fmt", "--no-config"], - lint: ["lint", "--no-config"], - test: ["test", "--no-config"], - }; - const config = await findFileUp(Deno.cwd(), "deno.json", "deno.jsonc"); - if (!config) { - return tasks; - } - try { - const json = ensure( - parseJsonc(await Deno.readTextFile(config)), - is.ObjectOf({ tasks: is.Record }), - ); - return { - ...tasks, - ...mapEntries(json.tasks, ([name]) => [name, ["task", "-q", name]]), - }; - } catch { - return tasks; - } -} - -export async function runTask([name, args]: [string, string[]]) { - console.log(`🔨 Running task ${cyan(name)}...`); - const { code } = await new Deno.Command("deno", { - args, - stdout: "inherit", - stderr: "inherit", - }).output(); - if (code != 0) { - Deno.exit(code); - } -} diff --git a/cli/modules/testing.ts b/cli/modules/testing.ts deleted file mode 100644 index 7cbb90c0..00000000 --- a/cli/modules/testing.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { all, cmd, fs } from "@chiezo/amber"; -import { LatestVersionStub } from "../../test/mock.ts"; - -/** - * Enables all test stubs. - */ -export default function () { - LatestVersionStub.create("123.456.789"); - fs.stub(new URL("../../test/fixtures", import.meta.url)); - cmd.stub("git"); - all(cmd, fs).mock(); -} diff --git a/cli/modules/write.ts b/cli/modules/write.ts deleted file mode 100644 index 808ed191..00000000 --- a/cli/modules/write.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { relative } from "@std/path"; - -import { CollectResult, write } from "@molt/core"; - -export default async function ( - result: CollectResult, -): Promise { - console.log(); - await write(result, { - onWrite: (file) => console.log(`💾 ${relative(Deno.cwd(), file.path)}`), - }); -} diff --git a/cli/modules/changelog.ts b/cli/src/changelog.ts similarity index 57% rename from cli/modules/changelog.ts rename to cli/src/changelog.ts index 0605a803..dc72431a 100644 --- a/cli/modules/changelog.ts +++ b/cli/src/changelog.ts @@ -2,21 +2,29 @@ import { minWith } from "@std/collections"; import { curateChangeLog } from "@molt/lib/changelog"; import { compareCommits, - fromDependency, resolveCreatedDate, resolvePackageRoot, resolveRepository, + tryParse, } from "@molt/integration"; -import type { Dependency, UpdatedDependency } from "@molt/core"; +import type { Update } from "@molt/core/types"; +import * as SemVer from "@std/semver"; -export default async function ( - updated: UpdatedDependency, - froms: Dependency[], +export async function printChangelog( + update: Update, options: { changelog?: true | string[]; }, ) { - const pkg = fromDependency(updated); + const bump = update.lock ?? update.constraint!; + // Can't provide a changelog for a non-semver update + if (!SemVer.tryParse(bump.to)) { + return ""; + } + const froms = bump.from.split(", "); + const to = bump.to; + + const pkg = tryParse(update.dep.specifier); if (!pkg) { // Can't provide a changelog for a non-package dependency return; @@ -25,28 +33,29 @@ export default async function ( if (!repo) { return; } + /** A map of dependency names to the created date of the oldest update */ const dates = new Map(); - await Promise.all( - froms.map(async (it) => { - dates.set(name, await resolveCreatedDate(pkg, it.version!)); - }), - ); + await Promise.all(froms.map(async (it) => { + dates.set(it, await resolveCreatedDate(pkg, it)); + })); /** The oldest update from which to fetch commit logs */ const oldest = minWith( froms, - (a, b) => Math.sign(dates.get(a.name)! - dates.get(b.name)!), + (a, b) => Math.sign(dates.get(a)! - dates.get(b)!), ); if (!oldest) { // The dependency was newly added in this update return; } - const messages = await compareCommits( - repo, - oldest.version!, - updated.version, - ); - const root = await resolvePackageRoot(repo, pkg, updated.version); + const messages: string[] = []; + try { + // The refs might not exist + messages.push(...await compareCommits(repo, oldest, to)); + } catch { + return; + } + const root = await resolvePackageRoot(repo, pkg, to); if (!root) { // The package seems to be generated dynamically on publish return; @@ -59,7 +68,7 @@ export default async function ( }); for (const [type, records] of Object.entries(changelog)) { for (const record of records) { - console.log(` ${type}: ${record.text}`); + console.log(` ${type}: ${record.text}`); } } } diff --git a/cli/src/files.ts b/cli/src/files.ts new file mode 100644 index 00000000..26893570 --- /dev/null +++ b/cli/src/files.ts @@ -0,0 +1,33 @@ +import { exists } from "@std/fs"; +import { parse } from "@std/jsonc"; + +export async function findConfig() { + if (await exists("deno.json") && await hasImports("deno.json")) { + return "deno.json"; + } + if (await exists("deno.jsonc") && await hasImports("deno.jsonc")) { + return "deno.jsonc"; + } +} + +async function hasImports(config: string): Promise { + if (!config) return false; + const jsonc = parse(await Deno.readTextFile(config)); + return jsonc !== null && typeof jsonc === "object" && "imports" in jsonc; +} + +export async function findLock() { + if (await exists("deno.lock")) { + return "deno.lock"; + } +} + +export async function findSource() { + const source: string[] = []; + for await (const entry of Deno.readDir(".")) { + if (entry.isFile && entry.name.endsWith(".ts")) { + source.push(entry.name); + } + } + return source; +} diff --git a/cli/src/mock.ts b/cli/src/mock.ts new file mode 100644 index 00000000..1eb6c4b8 --- /dev/null +++ b/cli/src/mock.ts @@ -0,0 +1,6 @@ +import { all, cmd, fs } from "@chiezo/amber"; + +export function mock(paths: string[]) { + paths.forEach((it) => fs.stub(it)); + all(cmd, fs).mock(); +} diff --git a/cli/src/tasks.ts b/cli/src/tasks.ts new file mode 100644 index 00000000..f09f5f62 --- /dev/null +++ b/cli/src/tasks.ts @@ -0,0 +1,67 @@ +import { ensure, is } from "@core/unknownutil"; +import { colors } from "@cliffy/ansi/colors"; +import $ from "@david/dax"; +import { associateWith, mapEntries } from "@std/collections"; +import { parse as parseJsonc } from "@std/jsonc"; +import { mergeReadableStreams, toText } from "@std/streams"; + +export type TaskRecord = Partial>; + +const DEFAULT_TASKS = ["fmt", "lint", "test"] as const; + +async function getTasks(config?: string): Promise { + const tasks: TaskRecord = associateWith( + DEFAULT_TASKS, + (name) => config ? [name, "--config", config] : [name, "--no-config"], + ); + if (!config) { + return tasks; + } + const json = ensure( + parseJsonc(await Deno.readTextFile(config)), + is.ObjectOf({ tasks: is.OptionalOf(is.RecordOf(is.String, is.String)) }), + ); + return { + ...tasks, + ...mapEntries( + json.tasks ?? {}, + ([name]) => [name, ["task", "--config", config, name]], + ), + }; +} + +const { cyan } = colors; + +async function runTask([name, args]: [string, string[]]) { + const cmd = new Deno.Command("deno", { + args, + stdout: "piped", + stderr: "piped", + }); + const child = cmd.spawn(); + const output = mergeReadableStreams(child.stdout, child.stderr); + + const { code } = await $.progress(`Running task ${cyan(name)}...`) + .with(() => child.status); + + if (code !== 0) { + console.log(await toText(output)); + Deno.exit(code); + } + await output.cancel(); +} + +export async function runTasks( + names: string[], + config?: string, +) { + const tasks = await getTasks(config); + for (const name of names) { + const task = tasks[name]; + if (!task) { + console.error(`Unknown task: ${name}`); + Deno.exit(1); + } + await runTask([name, task]); + } +} diff --git a/cli/src/updates.ts b/cli/src/updates.ts new file mode 100644 index 00000000..20fdc584 --- /dev/null +++ b/cli/src/updates.ts @@ -0,0 +1,35 @@ +import { colors } from "@cliffy/ansi/colors"; +import type { Update } from "@molt/core/types"; +import * as SemVer from "@std/semver"; + +const { bold, yellow, gray, cyan } = colors; + +export function print(update: Update) { + const { constraint, lock } = update; + let output = `📦 ${bold(update.dep.name)}`; + + const versions = constraint && SemVer.tryParse(constraint.to) + ? constraint + : lock; + + if (versions) { + output += yellow(` ${versions.from} → ${versions.to}`); + } + + const ranges = constraint && !SemVer.tryParse(constraint.to) + ? constraint + : undefined; + + if (ranges) { + output += " "; + if (versions) output += cyan("("); + output += cyan(`${ranges.from} → ${ranges.to}`); + if (versions) output += cyan(")"); + } + + console.log(output); +} + +export function printRefs(update: Update) { + update.dep.refs.forEach((file) => console.log(" " + gray(file.toString()))); +}