@vistta/cli 1.0.1 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,210 @@
1
+ /**
2
+ * @typedef {{ only?: boolean; filter?: string }} TestRunnerOptions
3
+ */
4
+
5
+ /**
6
+ * @typedef {{
7
+ * name: string;
8
+ * start?: number;
9
+ * end?: number;
10
+ * status?: "pass" | "fail";
11
+ * result?: any;
12
+ * error?: any;
13
+ * }} TestResult
14
+ */
15
+
16
+ /**
17
+ * @typedef {{ only: boolean; skip: boolean; name: string; callback: () => any }} SuiteDefinition
18
+ */
19
+
20
+ /**
21
+ * @typedef {{ only?: boolean; skip?: boolean; name?: string; tests: TestResult[]; time?: number }} SuiteResult
22
+ */
23
+
24
+ /**
25
+ * @typedef {{
26
+ * toEqual: (value: any, unit?: string) => void;
27
+ * toBeLessThan: (value: any, unit?: string) => void;
28
+ * toBeGreaterThan: (value: any, unit?: string) => void;
29
+ * toInclude: (value: any) => void;
30
+ * toMatch: (regex: string | RegExp) => void;
31
+ * toThrow: (message?: string) => void;
32
+ * }} ExpectMatcherSet
33
+ */
34
+
35
+ /**
36
+ * @typedef {ExpectMatcherSet & { not: ExpectMatcherSet }} ExpectMatchers
37
+ */
38
+
39
+ export class TestRunner {
40
+ /** @type {TestRunnerOptions} */
41
+ #options = {};
42
+ /** @type {SuiteDefinition[]} */
43
+ #suites = [];
44
+ /** @type {SuiteResult[]} */
45
+ #results;
46
+ /** @type {SuiteResult | null} */
47
+ #active;
48
+ /** @type {number} */
49
+ #running;
50
+
51
+ /**
52
+ * @param {TestRunnerOptions} [options]
53
+ */
54
+ constructor(options = {}) {
55
+ this.#options = options;
56
+ this.suite = this.#suite.bind(this);
57
+ this.suite.only = (name, callback) => this.#suite(name, callback, { only: true });
58
+ this.suite.skip = (name, callback) => this.#suite(name, callback, { skip: true });
59
+ this.test = this.#test.bind(this);
60
+ this.test.only = (name, callback) => this.#test(name, callback, { only: true });
61
+ this.test.skip = (name, callback) => this.#test(name, callback, { skip: true });
62
+ }
63
+
64
+ /**
65
+ * @returns {Promise<SuiteResult[]>}
66
+ */
67
+ async run() {
68
+ this.#running = 0;
69
+ this.#results = [];
70
+ for (const suite of this.#suites) {
71
+ this.#active = { only: suite.only, name: suite.name, skip: suite.skip, tests: [] };
72
+ try {
73
+ await suite.callback();
74
+ } catch {
75
+ /* Do Nothing */
76
+ }
77
+ this.#results.push(this.#active);
78
+ this.#active = null;
79
+ }
80
+ while (this.#running > 0) await new Promise((resolve) => setTimeout(resolve, 100));
81
+ return this.#results;
82
+ }
83
+
84
+ /**
85
+ * @param {string} name
86
+ * @param {() => any} callback
87
+ * @param {{ only?: boolean, skip?: boolean }} options
88
+ * @returns {Promise<void>}
89
+ */
90
+ async #suite(name, callback, { only, skip } = {}) {
91
+ if (this.#active) throw new Error("Suites cannot be stacked");
92
+ this.#suites.push({ only, skip, name, callback });
93
+ }
94
+
95
+ /**
96
+ * @param {string} name
97
+ * @param {() => any | Promise<any>} callback
98
+ * @param {{ only?: boolean, skip?: boolean }} options
99
+ * @returns {Promise<void>}
100
+ */
101
+ async #test(name, callback, { only, skip } = {}) {
102
+ if (
103
+ (this.#options.filter && !name.match(new RegExp(this.#options.filter, "i"))) ||
104
+ (this.#options.only && !(only || this.#active?.only))
105
+ )
106
+ return;
107
+ const test = { name };
108
+ if (skip || this.#active?.skip) {
109
+ test.status = "skip";
110
+ this.#active?.tests.push(test);
111
+ return;
112
+ } else this.#running++;
113
+ test.start = performance();
114
+ if (this.#active) this.#active.tests.push(test);
115
+ else this.#results.push({ tests: [test], time: test.time });
116
+ try {
117
+ test.result = await callback();
118
+ test.status = "pass";
119
+ test.end = performance();
120
+ } catch (error) {
121
+ test.status = "fail";
122
+ test.error = error;
123
+ test.end = performance();
124
+ }
125
+ this.#running--;
126
+ }
127
+
128
+ /**
129
+ * @param {any} target
130
+ * @param {string} [label]
131
+ * @returns {ExpectMatchers}
132
+ */
133
+ expect(target, label) {
134
+ return {
135
+ not: {
136
+ toEqual: (value, unit = "") => {
137
+ if (target !== value) return;
138
+ throw new Error(`expected ${label ? label + "(" + target + ")" : target} to not equal ${value}${unit}`);
139
+ },
140
+ toBeLessThan: (value, unit = "") => {
141
+ if (target >= value) return;
142
+ throw new Error(`expected ${label ? label + "(" + target + ")" : target} to not be less than ${value}${unit}`);
143
+ },
144
+ toBeGreaterThan: (value, unit = "") => {
145
+ if (target <= value) return;
146
+ throw new Error(`expected ${label ? label + "(" + target + ")" : target} to not be greater than ${value}${unit}`);
147
+ },
148
+ toInclude: (value) => {
149
+ if (target.indexOf(value) === -1) return;
150
+ throw new Error(`expected ${label ? label + "(" + target + ")" : target} to not include ${value}`);
151
+ },
152
+ toMatch: (regex) => {
153
+ if (!target.match(new RegExp(regex))) return;
154
+ throw new Error(`expected ${label ? label + "(" + target + ")" : target} to not match ${regex}`);
155
+ },
156
+ toThrow: (message) => {
157
+ try {
158
+ target();
159
+ } catch (error) {
160
+ if (!message)
161
+ throw new Error(`expected ${label ? label + "()" : "function()"} to not throw, but got ${error.message}`);
162
+ if (error.message === message)
163
+ throw new Error(
164
+ `expected ${label ? label + "()" : "function()"} to not throw ${message}, but got ${error.message}`,
165
+ );
166
+ if (error.message.match(new RegExp(message)))
167
+ throw new Error(
168
+ `expected ${label ? label + "()" : "function()"} to not throw ${message}, but got ${error.message}`,
169
+ );
170
+ return;
171
+ }
172
+ },
173
+ },
174
+ toEqual: (value, unit = "") => {
175
+ if (target === value) return;
176
+ throw new Error(`expected ${label ? label + "(" + target + ")" : target} to equal ${value}${unit}`);
177
+ },
178
+ toBeLessThan: (value, unit = "") => {
179
+ if (target < value) return;
180
+ throw new Error(`expected ${label ? label + "(" + target + ")" : target} to be less than ${value}${unit}`);
181
+ },
182
+ toBeGreaterThan: (value, unit = "") => {
183
+ if (target > value) return;
184
+ throw new Error(`expected ${label ? label + "(" + target + ")" : target} to be greater than ${value}${unit}`);
185
+ },
186
+ toInclude: (value) => {
187
+ if (target.indexOf(value) > -1) return;
188
+ throw new Error(`expected ${label ? label + "(" + target + ")" : target} to include ${value}`);
189
+ },
190
+ toMatch: (regex) => {
191
+ if (target.match(new RegExp(regex))) return;
192
+ throw new Error(`expected ${label ? label + "(" + target + ")" : target} to match ${regex}`);
193
+ },
194
+ toThrow: (message) => {
195
+ try {
196
+ target();
197
+ } catch (error) {
198
+ if (!message) return;
199
+ if (error.message === message) return;
200
+ if (error.message.match(new RegExp(message))) return;
201
+ throw new Error(`expected ${label ? label + "()" : "function()"} to throw ${message}, but got ${error.message}`);
202
+ }
203
+ },
204
+ };
205
+ }
206
+ }
207
+
208
+ function performance() {
209
+ return globalThis.performance.now();
210
+ }
@@ -1,19 +1,20 @@
1
1
  import { resolve } from "node:path";
2
2
  import { Bundler } from "../classes/bundler.js";
3
3
  import { Runtime } from "../classes/runtime.js";
4
+ import { parseArgs } from "../utils.js";
4
5
  import DefaultCommand from "./default.js";
5
6
 
6
7
  export default class BundlerCommand extends DefaultCommand {
7
- help() {
8
+ async help() {
8
9
  console.print("Bundles the specified entry file");
9
10
  console.print("\nUsage:");
10
11
  console.print("vistta bundle [filename] [outdir]\tBundles the specified entry file");
11
12
  }
12
13
 
13
14
  async main(_, ...argv) {
14
- let [args, options] = this.parse(argv);
15
+ let [args, options] = parseArgs(argv);
15
16
  if (args.length === 0) return this.help();
16
- options = Object.assign({}, this.options, options, { outdir: args[1] });
17
+ options = Object.assign({}, process.vistta?.options?.bundler || {}, options, { outdir: args[1] });
17
18
  new Bundler(new Runtime(global.vistta)).run(resolve(process.cwd(), args[0]), options);
18
19
  }
19
20
  }
@@ -39,7 +39,9 @@ export default class DefaultCommand extends Command {
39
39
  const modules = await (await import("../utils.js"))?.getOutdatedPackages(process.cwd());
40
40
  for (let i = 0, len = modules.length; i < len; i++) {
41
41
  const { name, current, wanted, latest } = modules[i];
42
- if (current === wanted) console.print(`${COLORS.CYAN}Module "${name}" has a new version (${latest})${COLORS.RESET}`);
42
+ if (!current) console.print(`${COLORS.YELLOW}Module "${name}" is missing${COLORS.RESET}`);
43
+ else if (current === wanted)
44
+ console.print(`${COLORS.CYAN}Module "${name}" has a new version (${latest})${COLORS.RESET}`);
43
45
  else console.print(`${COLORS.YELLOW}Module "${name}" is outdated (${latest})${COLORS.RESET}`);
44
46
  }
45
47
  } catch {
@@ -1,101 +1,137 @@
1
- import { COLORS } from "@vistta/console";
2
1
  import fs from "@vistta/fs";
3
- import { capitalize } from "@vistta/utils";
2
+ import { relative } from "node:path";
4
3
  import { Command } from "../classes/command.js";
5
- import { getOutdatedPackages, importJSON, incrementPackageVersion } from "../utils.js";
4
+ import { getOutdatedPackages, importJSON, incrementPackageVersion, parseArgs } from "../utils.js";
6
5
 
7
6
  const HELP = {
8
- name: "outputs current project name",
9
- version: "outputs the project version",
10
- "version [increment]": "updates the project version",
11
- outdated: "lists all outdated modules in the project",
12
- patch: "updates the version of outdated modules to the latest patch version in the project",
13
- "patch [module]": "updates the version of outdated module to the latest patch version in the project",
14
- update: "updates the version of outdated modules to the latest version in the project",
15
- "update [module]": "updates the version of outdated module to the latest version in the project",
7
+ name: ["name", "Print the project name."],
8
+ version: [
9
+ "version [increment|version] [prerelease-id]",
10
+ "Print or change the project version. Increments: major, minor, patch, premajor, preminor, prepatch, prerelease.",
11
+ ],
12
+ outdated: [
13
+ "outdated [dependency]",
14
+ "Compare installed, wanted (within the declared range), and latest dependency versions.",
15
+ ],
16
+ update: [
17
+ "update [dependency] [--compatible] [--dry-run]",
18
+ "Update dependency ranges to latest; this may include major upgrades. --compatible selects wanted versions instead.",
19
+ ],
20
+ patch: ["patch [dependency] [--dry-run]", "Alias for update --compatible. This is range-compatible, not patch-only."],
16
21
  };
17
22
 
18
23
  export default class extends Command {
19
- constructor(options) {
20
- super(options);
21
- }
22
-
23
- help(_, command) {
24
- switch (command) {
25
- case "name":
26
- console.print(capitalize(HELP["name"]));
27
- console.print("\nUsage:");
28
- console.print("vistta project name");
29
- break;
30
- case "version":
31
- console.print(capitalize(HELP["version"]) + " or " + HELP["version [increment]"]);
32
- console.print("\nUsage:");
33
- console.print("vistta project version");
34
- console.print("vistta project version [increment]");
35
- break;
36
- case "outdated":
37
- console.print(capitalize(HELP["outdated"]));
38
- console.print("\nUsage:");
39
- console.print("vistta project outdated");
40
- break;
41
- case "patch":
42
- console.print(capitalize(HELP["patch"]) + " or " + HELP["patch [module]"]);
43
- console.print("\nUsage:");
44
- console.print("vistta project patch");
45
- console.print("vistta project patch [module]");
46
- break;
47
- case "update":
48
- console.print(capitalize(HELP["update"]) + " or " + HELP["update [module]"]);
49
- console.print("\nUsage:");
50
- console.print("vistta project update");
51
- console.print("vistta project update [module]");
52
- break;
53
- default:
54
- console.print("Outputs the current project name or version, or updates the version");
55
- console.print("\nUsage:");
56
- Object.keys(HELP).forEach((key) => console.print(`vistta project ${key}\t\t\t${HELP[key]}`));
57
- break;
24
+ async help(_, command) {
25
+ if (command && !Object.hasOwn(HELP, command))
26
+ throw new Error(`Unknown project command "${command}". Run vistta project --help.`);
27
+ console.print("Project metadata and dependency maintenance\n");
28
+ for (const [usage, description] of command ? [HELP[command]] : Object.values(HELP)) {
29
+ console.print(` vistta project ${usage}\n ${description}\n`);
58
30
  }
31
+ if (!command || ["outdated", "update", "patch"].includes(command))
32
+ console.print(
33
+ "Wanted respects npm policies and dependency ranges. Updates edit package.json only; run npm install afterward.",
34
+ );
59
35
  }
60
36
 
61
37
  async main(_, command, ...args) {
62
- console.clear();
38
+ if (!command || command === "help") return this.help(_, args[0]);
39
+ if (!Object.hasOwn(HELP, command)) return this.help(_, command);
40
+ const [values, options] = parseArgs(args);
41
+ const allowed = command === "update" ? ["compatible", "dry-run"] : command === "patch" ? ["dry-run"] : [];
42
+ for (const [name, value] of Object.entries(options)) {
43
+ if (!allowed.includes(name)) throw new Error(`Unknown option --${name} for project ${command}`);
44
+ if (typeof value !== "boolean") throw new Error(`--${name} must be a boolean flag`);
45
+ }
46
+ const limit = command === "name" ? 0 : command === "version" ? 2 : 1;
47
+ if (values.length > limit) throw new Error(`Too many arguments. Run vistta project ${command} --help.`);
63
48
  switch (command) {
64
49
  case "name":
65
50
  return console.print(process.env.PROJECT_NAME);
66
51
  case "version":
67
- if (!args[0]) return console.print(process.env.PROJECT_VERSION);
52
+ if (!values[0]) return console.print(process.env.PROJECT_VERSION);
68
53
  return console.print(
69
- await incrementPackageVersion(fs.resolve(process.env.PROJECT_PATH, "package.json"), args[0], args[1]),
54
+ await incrementPackageVersion(fs.resolve(process.env.PROJECT_PATH, "package.json"), values[0], values[1]),
70
55
  );
71
56
  case "outdated":
72
57
  case "patch":
73
58
  case "update":
74
- return modules(command, args[0]);
59
+ process.exit(await dependencies(command, values[0], options));
75
60
  }
76
- return this.help(_, command);
77
61
  }
78
62
  }
79
63
 
80
- async function modules(command, arg1) {
64
+ async function dependencies(command, dependency, options) {
81
65
  const modules = await getOutdatedPackages(process.cwd());
82
- let valid = true;
83
- let update = 0;
84
- for (let i = 0, len = modules.length; i < len; i++) {
85
- const { name, package: packagePath, current, wanted, latest, dev } = modules[i];
86
- if (command === "patch" || command === "update") {
87
- if (command === "patch" && current === wanted) continue;
88
- if (arg1 && name !== arg1) continue;
89
- const packageObj = await importJSON(packagePath);
90
- packageObj[dev ? "devDependencies" : "dependencies"][name] = "^" + (command === "patch" ? wanted : latest);
91
- await fs.writeFile(packagePath, JSON.stringify(packageObj, null, 2));
92
- update++;
93
- } else if (current === wanted)
94
- console.print(`${COLORS.CYAN}Module "${name}" has a new version (${latest})${COLORS.RESET}`);
95
- else (console.print(`${COLORS.YELLOW}Module "${name}" is outdated (${latest})${COLORS.RESET}`), (valid = false));
66
+ const selected = modules.filter((entry) => !dependency || entry.name === dependency);
67
+ if (!selected.length) {
68
+ console.print(
69
+ dependency
70
+ ? `No outdated dependency "${dependency}" found in this scope.`
71
+ : "All checked dependencies are up to date.",
72
+ );
73
+ return 0;
96
74
  }
97
-
98
- if (modules.length == 0) console.print("Everything is up to date");
99
- else if (update > 0) console.print(`Updated ${update} module${update > 1 ? "s" : ""} versions in project package.`);
100
- process.exit(valid ? 0 : -1);
75
+ if (command === "outdated") {
76
+ const rows = [
77
+ ["Dependency", "Current", "Wanted", "Latest", "Manifest", "Type"],
78
+ ...selected.map((entry) => [
79
+ entry.name,
80
+ entry.current || "MISSING",
81
+ entry.wanted || "-",
82
+ entry.latest || "-",
83
+ relative(process.cwd(), entry.package) || "package.json",
84
+ entry.dev ? "dev" : "prod",
85
+ ]),
86
+ ];
87
+ const widths = rows[0].map((_, column) => Math.max(...rows.map((row) => row[column].length)));
88
+ for (const row of rows)
89
+ console.print(
90
+ row
91
+ .map((value, column) => value.padEnd(widths[column]))
92
+ .join(" ")
93
+ .trimEnd(),
94
+ );
95
+ const compatible = selected.filter((entry) => !entry.current || entry.current !== entry.wanted).length;
96
+ console.print(
97
+ `\n${compatible} compatible update(s) or missing dependency(s); ${selected.length - compatible} latest-only update(s).`,
98
+ );
99
+ console.print("Compatible updates cause a nonzero exit status; latest-only updates are informational.");
100
+ return compatible ? 1 : 0;
101
+ }
102
+ const compatible = command === "patch" || options.compatible === true;
103
+ const changes = new Map();
104
+ let count = 0;
105
+ for (const entry of selected) {
106
+ const target = compatible ? entry.wanted : entry.latest;
107
+ if (!target || entry.current === target) continue;
108
+ const manifest = changes.get(entry.package) || (await importJSON(entry.package));
109
+ const section = entry.dev ? "devDependencies" : "dependencies";
110
+ const previous = manifest[section]?.[entry.name];
111
+ if (typeof previous !== "string") throw new Error(`Dependency "${entry.name}" is missing from ${entry.package}`);
112
+ const next = "^" + target;
113
+ if (previous === next) continue;
114
+ manifest[section][entry.name] = next;
115
+ changes.set(entry.package, manifest);
116
+ count++;
117
+ console.print(
118
+ `${options["dry-run"] ? "Would update" : "Update"}: ${entry.name} ${previous} -> ${next} (${relative(process.cwd(), entry.package)}, ${section})`,
119
+ );
120
+ }
121
+ if (!count) {
122
+ console.print(
123
+ `No ${compatible ? "compatible " : ""}dependency range changes needed. Run npm install if installed versions are behind.`,
124
+ );
125
+ return 0;
126
+ }
127
+ if (!options["dry-run"]) {
128
+ for (const [path, manifest] of changes) await fs.writeFile(path, JSON.stringify(manifest, null, 2) + "\n");
129
+ }
130
+ console.print(
131
+ `\n${options["dry-run"] ? "Would change" : "Changed"} ${count} dependency range(s) in ${changes.size} manifest(s).`,
132
+ );
133
+ console.print(
134
+ options["dry-run"] ? "Dry run: no files changed." : "Run npm install to update installed packages and the lockfile.",
135
+ );
136
+ return 0;
101
137
  }
@@ -0,0 +1,52 @@
1
+ import { Command } from "../classes/command.js";
2
+ import { loadProjectConfig, mergeConfig } from "../config.js";
3
+ import { runChecks } from "../tools.js";
4
+ import { parseArgs } from "../utils.js";
5
+
6
+ export default class extends Command {
7
+ help() {
8
+ console.log(
9
+ "vistta check [patterns] [--fix]\nvistta lint [patterns] [--fix]\nvistta fmt [patterns] [--write]\nvistta types\nvistta config [--sources]",
10
+ );
11
+ }
12
+
13
+ async main(command, ...argv) {
14
+ const [patterns, options] = parseArgs(argv);
15
+ const allowed = command === "config" ? ["sources"] : command === "fmt" ? ["write", "check", "fix"] : ["fix"];
16
+ for (const [option, value] of Object.entries(options)) {
17
+ if (!allowed.includes(option)) throw new Error(`Unknown option --${option}`);
18
+ if (typeof value !== "boolean") throw new Error(`--${option} must be a boolean flag`);
19
+ }
20
+ if (["types", "config"].includes(command) && patterns.length)
21
+ throw new Error(`${command} does not accept file patterns`);
22
+ if (command === "fmt" && options.check && (options.write || options.fix))
23
+ throw new Error("Use either --check or --write, not both");
24
+ const project = loadProjectConfig();
25
+ if (command === "config") {
26
+ const defaults = {
27
+ cli: {
28
+ check: { format: true, lint: true, types: true },
29
+ lint: { engine: "eslint" },
30
+ format: { engine: "prettier" },
31
+ },
32
+ };
33
+ const config = mergeConfig(defaults, project.config);
34
+ const sources = {
35
+ "cli.check.format": "<default>",
36
+ "cli.check.lint": "<default>",
37
+ "cli.check.types": "<default>",
38
+ "cli.lint.engine": "<default>",
39
+ "cli.format.engine": "<default>",
40
+ ...project.sources,
41
+ };
42
+ console.print(JSON.stringify(options.sources ? { file: project.file, config, sources } : config, null, 2));
43
+ return;
44
+ }
45
+ const names =
46
+ command === "check"
47
+ ? ["format", "lint", "types"].filter((name) => project.config.cli?.check?.[name] !== false)
48
+ : [command === "fmt" ? "format" : command];
49
+ const code = await runChecks(names, project, { patterns, fix: options.fix === true || options.write === true });
50
+ process.exit(code);
51
+ }
52
+ }
@@ -0,0 +1,23 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { resolve } from "node:path";
3
+ import { Command } from "../classes/command.js";
4
+ import { loadProjectConfig } from "../config.js";
5
+ import { planTasks, runTasks } from "../tools.js";
6
+
7
+ export default class extends Command {
8
+ help() {
9
+ console.log("vistta task <name> [-- args]\nvistta run <name> [-- args] (unless overridden by a framework)");
10
+ }
11
+
12
+ async main(_, name, ...args) {
13
+ if (!name) return this.help();
14
+ const cwd = process.cwd();
15
+ const { config } = loadProjectConfig(cwd);
16
+ const { scripts = {} } = JSON.parse(readFileSync(resolve(cwd, "package.json"), "utf8"));
17
+ const commands = Object.keys(process.vistta.options.cli.commands).filter(
18
+ (name) => !["default", "run", "task"].includes(name),
19
+ );
20
+ const plan = planTasks(name, config.cli?.tasks, scripts, commands);
21
+ process.exit(await runTasks(plan, cwd, args[0] === "--" ? args.slice(1) : args));
22
+ }
23
+ }