@peachlife/artisan 0.1.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,158 @@
1
+ import { basename, dirname, join } from "node:path";
2
+ import { fileURLToPath } from "node:url";
3
+ import { execa } from "execa";
4
+ import { resolveArtifact } from "../../core/artifact-manager.mjs";
5
+ import { loadConfig, mergeConfigWithFlags } from "../../core/config.mjs";
6
+ import { DEFAULT_XDG_CONFIG_HOME } from "../../core/constants.mjs";
7
+ import { resolveConfigs } from "../../core/fixture-resolver.mjs";
8
+ import { DockerError, UsageError } from "../../utils/errors.mjs";
9
+ import { resolveTestFiles } from "../../utils/glob.mjs";
10
+
11
+ const __dirname = dirname(fileURLToPath(import.meta.url));
12
+ const artisanVitestConfig = join(
13
+ __dirname,
14
+ "../../../templates/vitest-artisan.config.mjs",
15
+ );
16
+
17
+ function resolveTimeout(timeout) {
18
+ if (timeout === 0) return String(Number.MAX_SAFE_INTEGER);
19
+ if (timeout) return String(timeout);
20
+ }
21
+
22
+ function dockerUnavailableError(detail) {
23
+ return new DockerError(
24
+ `Docker is not available. Start Docker and retry.\n${detail}`,
25
+ );
26
+ }
27
+
28
+ async function ensureDockerAvailable() {
29
+ try {
30
+ const result = await execa("docker", ["info"], {
31
+ stdio: "pipe",
32
+ reject: false,
33
+ });
34
+ if (result.exitCode === 0) return;
35
+ throw dockerUnavailableError(result.stderr || result.stdout);
36
+ } catch (error) {
37
+ if (error instanceof DockerError) throw error;
38
+ const message =
39
+ error instanceof Error ? error.message : String(error ?? "unknown error");
40
+ throw dockerUnavailableError(message);
41
+ }
42
+ }
43
+
44
+ function buildVitestArguments({ options, merged, testFiles }) {
45
+ const configArguments = ["--config", artisanVitestConfig];
46
+ const vitestArguments = options.watch
47
+ ? ["--watch", ...configArguments, ...testFiles]
48
+ : ["run", ...configArguments, ...testFiles];
49
+ let testNamePattern;
50
+ if (options.testName) {
51
+ testNamePattern = options.invert
52
+ ? `^(?!.*(?:${options.testName})).*$`
53
+ : options.testName;
54
+ }
55
+ const flagRules = [
56
+ {
57
+ flag: "--reporter",
58
+ value: merged.reporter === "default" ? undefined : merged.reporter,
59
+ },
60
+ {
61
+ flag: "--testTimeout",
62
+ value: resolveTimeout(merged.timeout),
63
+ },
64
+ { flag: "--testNamePattern", value: testNamePattern },
65
+ { flag: "--bail", value: options.bail, boolean: true },
66
+ {
67
+ flag: "--retry",
68
+ value:
69
+ options.retries === undefined ? undefined : String(options.retries),
70
+ },
71
+ { flag: "--verbose", value: options.verbose, boolean: true },
72
+ ];
73
+ for (const { flag, value, boolean = false } of flagRules) {
74
+ if (!value) continue;
75
+ vitestArguments.push(flag);
76
+ if (!boolean) vitestArguments.push(value);
77
+ }
78
+ return vitestArguments;
79
+ }
80
+
81
+ export async function runTest(files, options) {
82
+ const config = await loadConfig();
83
+ const flags = {};
84
+ if (options.artifact !== undefined) flags.artifact = options.artifact;
85
+ if (options.distros !== undefined) {
86
+ flags.distros = options.distros
87
+ .split(",")
88
+ .map((d) => d.trim())
89
+ .filter(Boolean);
90
+ }
91
+ if (options.reporter !== undefined) flags.reporter = options.reporter;
92
+ if (options.timeout !== undefined) flags.timeout = options.timeout;
93
+ if (options.serial) flags.parallel = false;
94
+
95
+ const merged = mergeConfigWithFlags(config, flags);
96
+ const testFiles =
97
+ files.length > 0
98
+ ? files
99
+ : await resolveTestFiles(merged.testMatch, process.cwd());
100
+ if (testFiles.length === 0) {
101
+ console.log("No test files found matching:", merged.testMatch);
102
+ // eslint-disable-next-line unicorn/no-process-exit
103
+ process.exit(0);
104
+ }
105
+
106
+ const artifactPath = await resolveArtifact(merged.artifact);
107
+ const distros = merged.distros;
108
+ if (distros.length === 0) {
109
+ throw new UsageError(
110
+ 'No distros configured. Set "distros" in artisan.config.json or pass --distros',
111
+ );
112
+ }
113
+
114
+ await ensureDockerAvailable();
115
+ const resolvedConfigs = await resolveConfigs(
116
+ merged.configs ?? [],
117
+ process.cwd(),
118
+ basename(artifactPath),
119
+ );
120
+
121
+ let overallExitCode = 0;
122
+ const runForDistro = async (distro) => {
123
+ const setupCommands = merged.setup?.[distro.split(":", 1)[0]] ?? [];
124
+ const vitestArguments = buildVitestArguments({
125
+ options,
126
+ merged,
127
+ testFiles,
128
+ });
129
+
130
+ const result = await execa("vitest", vitestArguments, {
131
+ preferLocal: true,
132
+ localDir: join(__dirname, "../../.."),
133
+ env: {
134
+ ...process.env,
135
+ ARTISAN_DISTRO: distro,
136
+ ARTISAN_ARTIFACT: artifactPath,
137
+ ARTISAN_SETUP: JSON.stringify(setupCommands),
138
+ ARTISAN_CONFIGS: JSON.stringify(resolvedConfigs),
139
+ ARTISAN_XDG_CONFIG_HOME: DEFAULT_XDG_CONFIG_HOME,
140
+ ...(options.noColor && { NO_COLOR: "1" }),
141
+ },
142
+ stdio: options.quiet ? "pipe" : "inherit",
143
+ reject: false,
144
+ });
145
+ if (result.exitCode !== 0) overallExitCode = 1;
146
+ };
147
+
148
+ if (merged.parallel) {
149
+ // eslint-disable-next-line unicorn/no-array-callback-reference
150
+ await Promise.all(distros.map(runForDistro));
151
+ } else {
152
+ for (const distro of distros) {
153
+ await runForDistro(distro);
154
+ }
155
+ }
156
+ // eslint-disable-next-line unicorn/no-process-exit
157
+ process.exit(overallExitCode);
158
+ }
@@ -0,0 +1,115 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { dirname, join } from "node:path";
3
+ import { fileURLToPath } from "node:url";
4
+ import { Command } from "commander";
5
+ import { UsageError } from "../utils/errors.mjs";
6
+ import { runAdd } from "./commands/add.mjs";
7
+ import { runInit } from "./commands/init.mjs";
8
+ import { runTest } from "./commands/test.mjs";
9
+
10
+ function parseNonNegativeInt(value, label) {
11
+ if (!/^\d+$/.test(value)) {
12
+ throw new UsageError(
13
+ `--${label} must be a non-negative integer, got: ${value}`,
14
+ );
15
+ }
16
+ return Number(value);
17
+ }
18
+
19
+ function parsePositiveInt(value, label) {
20
+ const n = parseNonNegativeInt(value, label);
21
+ if (n === 0) {
22
+ throw new UsageError(
23
+ `--${label} must be a positive integer (> 0), got: ${value}`,
24
+ );
25
+ }
26
+ return n;
27
+ }
28
+
29
+ const DEFAULT_TESTS_DIR = "./tests/";
30
+ const OPT_DIR = "--dir <path>";
31
+ const OPT_DIR_DESC = "Target directory";
32
+ const OPT_FORCE_DESC = "Overwrite existing file";
33
+
34
+ function addScaffoldCommand(parent, name, description, configureFunction) {
35
+ const command = parent
36
+ .command(name)
37
+ .description(description)
38
+ .option(OPT_DIR, OPT_DIR_DESC, DEFAULT_TESTS_DIR)
39
+ .option("--force", OPT_FORCE_DESC);
40
+ if (configureFunction) configureFunction(command);
41
+ command.action((options) => runAdd(name, options));
42
+ }
43
+
44
+ const __dirname = dirname(fileURLToPath(import.meta.url));
45
+ const package_ = JSON.parse(
46
+ readFileSync(join(__dirname, "../../package.json"), "utf8"),
47
+ );
48
+
49
+ export function createProgram() {
50
+ const program = new Command();
51
+
52
+ program
53
+ .name("artisan")
54
+ .description("Test CLI artifacts in isolated Linux sandboxes.")
55
+ .version(package_.version);
56
+
57
+ program
58
+ .command("init")
59
+ .description("Scaffold Artisan into the current project")
60
+ .option("-a, --artifact <path>", "Path to compiled CLI binary")
61
+ .option("-d, --distros <list>", "Comma-separated Docker images")
62
+ .option("--testMatch <glob>", "Test file glob pattern")
63
+ .option("-y, --yes", "Skip prompts, use defaults/flags")
64
+ .option("--force", "Overwrite existing config/test files")
65
+ .action((options) => runInit(options));
66
+
67
+ program
68
+ .command("test [files...]")
69
+ .description("Run tests across configured distros")
70
+ .option(
71
+ "-t, --testName <pattern>",
72
+ "Filter by test name (substring or regex)",
73
+ )
74
+ .option("-i, --invert", "Run tests that do NOT match the -t filter")
75
+ .option("-d, --distros <list>", "Override the distro matrix")
76
+ .option("-a, --artifact <path>", "Override the artifact path")
77
+ .option("-w, --watch", "Re-run tests on file changes")
78
+ .option("-r, --reporter <name>", "default, junit, json, tap")
79
+ .option("--timeout <ms>", "Override per-test timeout (ms)", (v) =>
80
+ parseNonNegativeInt(v, "timeout"),
81
+ )
82
+ .option("--no-color", "Disable ANSI color codes")
83
+ .option("-q, --quiet", "Only output failures and summary")
84
+ .option("-v, --verbose", "Print full Docker and Execa output")
85
+ .option("--parallel", "Run distro matrix in parallel (default)")
86
+ .option("-s, --serial", "Run distro matrix sequentially")
87
+ .option("--retries <n>", "Retry failed tests up to N times", (v) =>
88
+ parsePositiveInt(v, "retries"),
89
+ )
90
+ .option("-b, --bail", "Stop on first failure")
91
+ .action((files, options) => runTest(files, options));
92
+
93
+ const add = program.command("add").description("Scaffold tests and fixtures");
94
+
95
+ addScaffoldCommand(add, "version", "Scaffold a CLI version test");
96
+ addScaffoldCommand(add, "help", "Scaffold a CLI help test");
97
+ addScaffoldCommand(add, "custom", "Scaffold a custom test", (command) => {
98
+ command.requiredOption("-n, --name <name>", "Test name");
99
+ });
100
+
101
+ add
102
+ .command("config <source>")
103
+ .description(
104
+ "Scaffold a committed config fixture from a local config directory",
105
+ )
106
+ .option(
107
+ "-n, --name <name>",
108
+ "Fixture directory name (default: basename of source)",
109
+ )
110
+ .option("--raw", "Copy without sanitizing secrets, caches, or temp files")
111
+ .option("--force", "Overwrite existing fixture directory")
112
+ .action((source, options) => runAdd("config", { ...options, source }));
113
+
114
+ return program;
115
+ }
@@ -0,0 +1,137 @@
1
+ import {
2
+ access,
3
+ constants,
4
+ readdir,
5
+ readFile,
6
+ realpath,
7
+ stat,
8
+ } from "node:fs/promises";
9
+ import { isAbsolute, join, relative, resolve } from "node:path";
10
+ import { UsageError } from "../utils/errors.mjs";
11
+
12
+ const COMMON_ARTIFACT_DIRS = ["dist", "bin"];
13
+
14
+ async function isExecutableFile(path) {
15
+ try {
16
+ const stats = await stat(path);
17
+ if (!stats.isFile()) return false;
18
+ await access(path, constants.X_OK);
19
+ return true;
20
+ } catch (error) {
21
+ if (
22
+ error?.code === "ENOENT" ||
23
+ error?.code === "ENOTDIR" ||
24
+ error?.code === "EACCES"
25
+ ) {
26
+ return false;
27
+ }
28
+ throw error;
29
+ }
30
+ }
31
+
32
+ async function packageBinCandidates(cwd) {
33
+ try {
34
+ const raw = await readFile(join(cwd, "package.json"), "utf8");
35
+ const package_ = JSON.parse(raw);
36
+ if (typeof package_.bin === "string") return [resolve(cwd, package_.bin)];
37
+ if (
38
+ package_.bin &&
39
+ typeof package_.bin === "object" &&
40
+ !Array.isArray(package_.bin)
41
+ ) {
42
+ return Object.values(package_.bin)
43
+ .filter((value) => typeof value === "string" && value.trim() !== "")
44
+ .map((value) => resolve(cwd, value));
45
+ }
46
+ return [];
47
+ } catch (error) {
48
+ if (error?.code === "ENOENT") return [];
49
+ if (error instanceof SyntaxError) {
50
+ throw new UsageError(
51
+ `package.json contains invalid JSON at ${join(cwd, "package.json")}`,
52
+ );
53
+ }
54
+ throw error;
55
+ }
56
+ }
57
+
58
+ async function directoryArtifactCandidates(cwd) {
59
+ const candidates = [];
60
+ for (const directoryName of COMMON_ARTIFACT_DIRS) {
61
+ const directory = join(cwd, directoryName);
62
+ let entries;
63
+ try {
64
+ entries = await readdir(directory, { withFileTypes: true });
65
+ } catch (error) {
66
+ if (error?.code === "ENOENT" || error?.code === "ENOTDIR") continue;
67
+ throw error;
68
+ }
69
+ for (const entry of entries) {
70
+ if (entry.isFile()) candidates.push(join(directory, entry.name));
71
+ }
72
+ }
73
+ return candidates;
74
+ }
75
+
76
+ async function discoverArtifact(cwd) {
77
+ const candidates = [
78
+ ...(await packageBinCandidates(cwd)),
79
+ ...(await directoryArtifactCandidates(cwd)),
80
+ ];
81
+ const unique = [...new Set(candidates)];
82
+ const realCwd = await realpath(cwd);
83
+ const executable = [];
84
+ for (const candidate of unique) {
85
+ try {
86
+ const realCandidate = await realpath(candidate);
87
+ const relativePath = relative(realCwd, realCandidate);
88
+ if (relativePath.startsWith("..") || isAbsolute(relativePath)) continue;
89
+ } catch {
90
+ continue;
91
+ }
92
+ if (await isExecutableFile(candidate)) executable.push(candidate);
93
+ }
94
+ if (executable.length === 1) return executable[0];
95
+ if (executable.length > 1) {
96
+ throw new UsageError(
97
+ `Multiple executable artifacts found: ${executable.join(", ")}. Pass --artifact or set "artifact" in artisan.config.json`,
98
+ );
99
+ }
100
+ throw new UsageError(
101
+ 'No artifact specified and no executable artifact was auto-discovered. Set "artifact" in artisan.config.json or pass --artifact',
102
+ );
103
+ }
104
+
105
+ export async function resolveArtifact(artifactPath, cwd = process.cwd()) {
106
+ if (
107
+ artifactPath === undefined ||
108
+ artifactPath === null ||
109
+ artifactPath.trim?.() === ""
110
+ ) {
111
+ return discoverArtifact(cwd);
112
+ }
113
+ if (typeof artifactPath !== "string") {
114
+ throw new UsageError("Artifact path must be a string");
115
+ }
116
+ const abs = isAbsolute(artifactPath)
117
+ ? artifactPath
118
+ : resolve(cwd, artifactPath);
119
+ try {
120
+ await access(abs, constants.F_OK);
121
+ } catch (error) {
122
+ if (error?.code === "ENOENT" || error?.code === "ENOTDIR") {
123
+ throw new UsageError(`Artifact not found: ${abs}`);
124
+ }
125
+ throw new UsageError(`Cannot access artifact at ${abs}: ${error.message}`);
126
+ }
127
+ const stats = await stat(abs);
128
+ if (!stats.isFile()) {
129
+ throw new UsageError(`Artifact is not a file: ${abs}`);
130
+ }
131
+ try {
132
+ await access(abs, constants.X_OK);
133
+ } catch {
134
+ throw new UsageError(`Artifact is not executable: ${abs}`);
135
+ }
136
+ return abs;
137
+ }
@@ -0,0 +1,139 @@
1
+ import { readFile } from "node:fs/promises";
2
+ import { join } from "node:path";
3
+ import { UsageError } from "../utils/errors.mjs";
4
+ import { DEFAULT_DISTROS } from "./constants.mjs";
5
+
6
+ const DEFAULTS = {
7
+ artifact: "",
8
+ distros: DEFAULT_DISTROS,
9
+ testMatch: "**/*.artisan.test.mjs",
10
+ timeout: undefined,
11
+ parallel: true,
12
+ setup: {},
13
+ reporter: "default",
14
+ configs: [],
15
+ };
16
+
17
+ function validateConfigEntry(entry) {
18
+ if (typeof entry === "string") {
19
+ if (entry.trim() === "")
20
+ throw new UsageError("configs entry must be a non-empty path");
21
+ return;
22
+ }
23
+ if (typeof entry === "object" && entry !== null) {
24
+ if (typeof entry.src !== "string" || entry.src.trim() === "") {
25
+ throw new UsageError('configs object entry missing valid "src"');
26
+ }
27
+ if (typeof entry.dest !== "string" || entry.dest.trim() === "") {
28
+ throw new UsageError('configs object entry missing valid "dest"');
29
+ }
30
+ return;
31
+ }
32
+ throw new UsageError(
33
+ `configs entry must be a string or {src, dest} object, got: ${typeof entry}`,
34
+ );
35
+ }
36
+
37
+ function validateConfigs(configs) {
38
+ if (!Array.isArray(configs)) {
39
+ throw new UsageError('"configs" in artisan.config.json must be an array');
40
+ }
41
+ for (const entry of configs) validateConfigEntry(entry);
42
+ }
43
+
44
+ function assertStringArray(value, label) {
45
+ if (
46
+ !Array.isArray(value) ||
47
+ value.some((v) => typeof v !== "string" || v.trim() === "")
48
+ ) {
49
+ throw new UsageError(`"${label}" must be an array of non-empty strings`);
50
+ }
51
+ }
52
+
53
+ function validateSetup(setup) {
54
+ if (typeof setup !== "object" || Array.isArray(setup) || setup === null) {
55
+ throw new UsageError('"setup" must be an object of distro -> command[]');
56
+ }
57
+ for (const [key, cmds] of Object.entries(setup)) {
58
+ assertStringArray(cmds, `setup["${key}"]`);
59
+ }
60
+ }
61
+
62
+ function validateConfigShape(config) {
63
+ if (config.artifact !== undefined && typeof config.artifact !== "string") {
64
+ throw new UsageError('"artifact" must be a string');
65
+ }
66
+ if (config.distros !== undefined)
67
+ assertStringArray(config.distros, "distros");
68
+ if (
69
+ config.timeout !== undefined &&
70
+ (typeof config.timeout !== "number" || config.timeout < 0)
71
+ ) {
72
+ throw new UsageError(
73
+ '"timeout" must be a non-negative number (ms), 0 means no timeout',
74
+ );
75
+ }
76
+ if (config.parallel !== undefined && typeof config.parallel !== "boolean") {
77
+ throw new UsageError('"parallel" must be a boolean');
78
+ }
79
+ if (config.setup !== undefined) validateSetup(config.setup);
80
+ if (config.reporter !== undefined && typeof config.reporter !== "string") {
81
+ throw new UsageError('"reporter" must be a string');
82
+ }
83
+ if (config.testMatch !== undefined && typeof config.testMatch !== "string") {
84
+ throw new UsageError('"testMatch" must be a string');
85
+ }
86
+ }
87
+
88
+ export async function readJsonConfig(configPath, { optional = false } = {}) {
89
+ try {
90
+ const raw = await readFile(configPath, "utf8");
91
+ try {
92
+ const config = JSON.parse(raw);
93
+ if (
94
+ config === null ||
95
+ Array.isArray(config) ||
96
+ typeof config !== "object"
97
+ ) {
98
+ throw new UsageError(
99
+ `artisan.config.json must contain a JSON object at ${configPath}`,
100
+ );
101
+ }
102
+ return config;
103
+ } catch (error) {
104
+ if (error instanceof UsageError) throw error;
105
+ throw new UsageError(
106
+ `artisan.config.json contains invalid JSON at ${configPath}`,
107
+ );
108
+ }
109
+ } catch (error) {
110
+ if (error instanceof UsageError) throw error;
111
+ if (optional && error?.code === "ENOENT") return {};
112
+ if (error?.code === "ENOENT") {
113
+ throw new UsageError(
114
+ "artisan.config.json not found — run `artisan init` first",
115
+ );
116
+ }
117
+ throw error;
118
+ }
119
+ }
120
+
121
+ export async function loadConfig(cwd = process.cwd()) {
122
+ const configPath = join(cwd, "artisan.config.json");
123
+ const fileConfig = await readJsonConfig(configPath, { optional: true });
124
+ if (fileConfig.configs !== undefined) {
125
+ validateConfigs(fileConfig.configs);
126
+ }
127
+ validateConfigShape(fileConfig);
128
+ return { ...DEFAULTS, ...fileConfig };
129
+ }
130
+
131
+ export function mergeConfigWithFlags(config, flags) {
132
+ const merged = { ...config };
133
+ for (const [key, value] of Object.entries(flags)) {
134
+ if (value !== undefined && value !== null) {
135
+ merged[key] = value;
136
+ }
137
+ }
138
+ return merged;
139
+ }
@@ -0,0 +1,5 @@
1
+ // No default timeout. Pass --timeout <ms> or set "timeout" in artisan.config.json.
2
+ // Per-command timeout is also uncapped by default.
3
+ export const DEFAULT_XDG_CONFIG_HOME = "/root/.config";
4
+ export const BINARY_MOUNT_DIR = "/usr/local/bin";
5
+ export const DEFAULT_DISTROS = ["alpine:3", "debian:bookworm-slim"];