@peachlife/artisan 0.1.0 → 0.1.2

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.
package/README.md CHANGED
@@ -1,196 +1,110 @@
1
1
  # Artisan
2
2
 
3
- > Artifact-level dogfood tests for command-line tools.
3
+ > **End-to-end artifact testing for CLIs.** Stop merging green unit tests for broken binaries.
4
4
 
5
- Artisan runs the **compiled CLI artifact your users will run** inside clean Docker
6
- containers, then lets you assert its real stdout, stderr, exit code, config
7
- handling, and filesystem side effects with familiar JavaScript tests.
5
+ Unit tests verify your logic. Artisan proves your binary actually executes.
8
6
 
9
- Unit tests can say your code works. Artisan proves the binary works.
7
+ Artisan mounts your compiled CLI into ephemeral Linux containers across a distro matrix (Debian, Alpine, Arch) and lets you assert stdout, stderr, exit codes, and real filesystem mutations using a familiar Vitest API.
10
8
 
11
- ## What is Artisan?
9
+ Backed by **Testcontainers**, **Execa**, and **Vitest**.
12
10
 
13
- Artisan is a zero-configuration dogfood testing framework for CLI binaries. It
14
- mounts your artifact into isolated Linux containers across a distro matrix such
15
- as Debian, Alpine, and Arch, then runs `*.artisan.test.mjs` tests against it.
11
+ ## Why Artisan?
16
12
 
17
- It is built on:
13
+ * **Catch runtime blindspots:** Detect `glibc` vs `musl` mismatches, missing system dependencies, and hardcoded host paths.
14
+ * **Agent-proof your workflow:** LLMs are great at writing passing unit tests for code that fails at runtime. Artisan enforces that "done" means the *artifact* works.
15
+ * **Zero-config discovery:** Automatically finds executables in `package.json#bin`, `./dist`, or `./bin`.
18
16
 
19
- - **Testcontainers** for disposable Docker sandboxes.
20
- - **Execa** for command execution and stdout/stderr/exit-code capture.
21
- - **Vitest** for the `test()` and `expect()` API.
17
+ ## Quickstart
22
18
 
23
- ## Who is it for?
24
-
25
- ### Agent-assisted development
26
-
27
- LLM agents are good at making unit tests pass. They are also good at missing the
28
- thing users actually execute: the built artifact.
29
-
30
- Artisan makes “done” mean:
31
-
32
- - the binary exists;
33
- - it is executable;
34
- - it starts in a clean sandbox;
35
- - it handles real arguments, config, paths, and files;
36
- - it exits with the expected code.
37
-
38
- That catches the classic agent failure mode: “all green” while the CLI is broken
39
- in a real environment.
40
-
41
- ### CLI authors
42
-
43
- If you ship CLIs in Go, Rust, C/C++, Node, Python, Swift, or any other language,
44
- Artisan helps verify runtime behavior across Linux environments without
45
- hand-writing Dockerfiles or CI matrices.
46
-
47
- It is especially useful for catching:
48
-
49
- - `glibc` vs `musl` issues;
50
- - missing runtime packages;
51
- - hardcoded host paths;
52
- - broken config discovery;
53
- - bad permissions;
54
- - incorrect stdout/stderr/exit codes;
55
- - filesystem side effects that only work on your machine.
56
-
57
- ## Example
58
-
59
- Create test file:
60
-
61
- ```javascript
62
- // tests/artisan/cli-version.artisan.test.mjs
63
- import { expect, test } from "@peachlife/artisan";
64
-
65
- test("CLI outputs correct version", async ({ run }) => {
66
- const { stdout, exitCode } = await run("--version");
67
-
68
- expect(exitCode).toBe(0);
69
- expect(stdout).toContain("1.0.0"); // Replace with your real version.
70
- });
71
- ```
72
-
73
- Run it:
19
+ Run Artisan directly. If you don't have tests yet, it will automatically bootstrap your environment, install dependencies, and generate a starter test.
74
20
 
75
21
  ```bash
76
- npx @peachlife/artisan test
77
- ```
78
-
79
- If your project has exactly one executable artifact in `package.json#bin`, `./dist`,
80
- or `./bin`, Artisan can usually discover it without config.
81
-
82
- ## Add explicit config when needed
83
-
84
- Use `artisan.config.json` when you want to choose the artifact, distros, setup
85
- commands, or config fixtures:
86
-
87
- ```json
88
- {
89
- "artifact": "./dist/mycli",
90
- "distros": ["debian:stable-slim", "alpine:latest", "archlinux/archlinux:latest"],
91
- "testMatch": "**/*.artisan.test.mjs",
92
- "parallel": true
93
- }
22
+ npx @peachlife/artisan test --artifact ./dist/mycli
94
23
  ```
95
24
 
96
- Then run:
97
-
98
- ```bash
99
- npx @peachlife/artisan test
100
25
  ```
101
26
 
102
- ## Test real behavior, not internals
27
+ ## The API
103
28
 
104
- Artisan tests should assert what a user can observe.
29
+ Test what users actually observe. Assert on exit codes, output, and container filesystem side-effects.
105
30
 
106
31
  ```javascript
32
+ // tests/artisan/cli.artisan.test.mjs
107
33
  import { expect, test } from "@peachlife/artisan";
108
34
 
109
- test("CLI writes an output file", async ({ run, setup }) => {
35
+ test("writes expected output to the filesystem", async ({ run, setup }) => {
36
+ // Setup container state
110
37
  await setup(["rm -f /tmp/output.txt"]);
111
38
 
39
+ // Execute the compiled artifact
112
40
  const { stdout, exitCode } = await run([
113
41
  "write-file",
114
42
  "/tmp/output.txt",
115
- "hello from the sandbox",
43
+ "unicorn sequence initiated",
116
44
  ]);
117
45
 
46
+ // Assert binary execution
118
47
  expect(exitCode).toBe(0);
119
48
  expect(stdout).toContain("wrote /tmp/output.txt");
120
49
 
50
+ // Assert filesystem side-effects
121
51
  await setup([
122
52
  "test -f /tmp/output.txt",
123
- "grep -q '^hello from the sandbox$' /tmp/output.txt",
53
+ "grep -q '^unicorn sequence initiated$' /tmp/output.txt",
124
54
  ]);
125
55
  });
126
56
  ```
127
57
 
128
- ## Test config safely
58
+ ## Matrix Testing via Config
129
59
 
130
- Config bugs are where many CLIs break: `~` paths, missing files, leaked
131
- credentials, wrong XDG paths, and “works on my machine” assumptions.
60
+ Need to test across different environments? Drop an `artisan.config.json` in your root:
132
61
 
133
- After `artisan.config.json` exists, import a sanitized fixture from a real
134
- config directory:
135
-
136
- ```bash
137
- npx @peachlife/artisan add config ~/.config/mycli --name mycli-config
138
- ```
139
-
140
- Artisan copies it into `./fixtures/mycli-config`, scrubs secret-like files, and
141
- wires the fixture into `artisan.config.json`.
142
-
143
- Inside the container, fixture directories are copied under:
144
-
145
- ```text
146
- $XDG_CONFIG_HOME/<artifact-name>/
62
+ ```json
63
+ {
64
+ "artifact": "./dist/mycli",
65
+ "distros": ["debian:stable-slim", "alpine:latest", "archlinux/archlinux:latest"],
66
+ "testMatch": "**/*.artisan.test.mjs",
67
+ "parallel": true
68
+ }
147
69
  ```
148
70
 
149
- Now your tests can prove the artifact reads config in a clean sandbox instead of
150
- depending on your host machine.
71
+ ## Config Fixtures
151
72
 
152
- ## Common commands
73
+ Config parsing is where most CLIs silently fail (`~` paths, wrong XDG locations, missing files). Artisan can capture your host configuration, scrub secrets, and mount it cleanly into the sandbox's `$XDG_CONFIG_HOME`.
153
74
 
154
75
  ```bash
155
- # Initialize Artisan in a project.
156
- npx @peachlife/artisan init -y --artifact ./dist/mycli --distros debian:stable-slim,alpine:latest
157
-
158
- # Run every artifact test.
159
- npx @peachlife/artisan test
160
-
161
- # Run one test file.
162
- npx @peachlife/artisan test ./tests/artisan/cli-version.artisan.test.mjs
163
-
164
- # Filter by test name.
165
- npx @peachlife/artisan test -t "version"
166
-
167
- # Debug one distro serially with verbose output.
168
- npx @peachlife/artisan test --distros alpine:latest --serial --verbose
169
-
170
- # Import a sanitized config fixture.
76
+ # Capture, sanitize, and inject real config into your tests
171
77
  npx @peachlife/artisan add config ~/.config/mycli --name mycli-config
172
78
  ```
173
79
 
174
- Run `npx @peachlife/artisan --help` or `npx @peachlife/artisan <command> --help` for the full
175
- CLI reference.
80
+ ## CLI Reference
176
81
 
177
- ## Exit codes
82
+ | Command | Description |
83
+ |---|---|
84
+ | `npx @peachlife/artisan test` | Run all tests across the configured matrix |
85
+ | `npx @peachlife/artisan test <file>` | Run a specific test file |
86
+ | `npx @peachlife/artisan test -t "auth"` | Filter test execution by regex |
87
+ | `npx @peachlife/artisan test --serial` | Run distros sequentially (ideal for debugging) |
88
+ | `npx @peachlife/artisan init` | Scaffold setup without running tests |
89
+
90
+ ### Exit Codes
178
91
 
179
92
  | Code | Meaning |
180
- | --- | --- |
181
- | `0` | All tests passed. |
182
- | `1` | One or more tests failed, including timed-out commands. |
183
- | `2` | Usage or configuration error. |
184
- | `125` | Docker/container startup error. |
185
- | `130` | Interrupted by the user. |
93
+ |---|---|
94
+ | `0` | All tests passed |
95
+ | `1` | One or more tests failed / timed out |
96
+ | `2` | Usage or configuration error |
97
+ | `125` | Docker/container startup error |
98
+ | `130` | Interrupted by the user |
186
99
 
100
+ ## CI Integration
187
101
 
188
- ## CI shape
102
+ Artisan runs perfectly in CI as long as the runner has Docker access.
189
103
 
190
- A minimal GitHub Actions job looks like this:
104
+ > **CI tip:** if your pipeline manages its own `npm ci` step, pass `--no-install` to `artisan init` or `artisan test --bootstrap` to skip the redundant install.
191
105
 
192
106
  ```yaml
193
- name: Artifact tests
107
+ name: Artifact Tests
194
108
  on: [push, pull_request]
195
109
 
196
110
  jobs:
@@ -202,19 +116,10 @@ jobs:
202
116
  with:
203
117
  node-version: 22
204
118
  - run: npm ci
119
+ - run: npm run build # Ensure your artifact is compiled!
205
120
  - run: npx @peachlife/artisan test --no-color
206
121
  ```
207
122
 
208
- Docker must be available on the runner.
209
-
210
- ## Install
211
-
212
- Run without installing:
213
-
214
- ```bash
215
- npx @peachlife/artisan test
216
- ```
217
-
218
123
  ## License
219
124
 
220
125
  MIT
@@ -0,0 +1,43 @@
1
+ /// <reference lib="dom" />
2
+
3
+ export type ConfigEntry = string | { src: string; dest: string };
4
+
5
+ export interface ArtisanConfig {
6
+ artifact: string;
7
+ distros: string[];
8
+ testMatch: string;
9
+ timeout: number;
10
+ parallel: boolean;
11
+ setup: Record<string, string[]>;
12
+ configs: ConfigEntry[];
13
+ reporter: "default" | "junit" | "json" | "tap";
14
+ }
15
+
16
+ export interface RunResult {
17
+ stdout: string;
18
+ stderr: string;
19
+ exitCode: number;
20
+ timedOut: boolean;
21
+ }
22
+
23
+ export interface RunOptions {
24
+ env?: Record<string, string>;
25
+ timeout?: number;
26
+ cwd?: string;
27
+ }
28
+
29
+ export interface ArtisanTestContext {
30
+ run: (args?: string, options?: RunOptions) => Promise<RunResult>;
31
+ copyFixture: (localPath: string, containerPath: string) => Promise<void>;
32
+ setup: (commands: string[]) => Promise<void>;
33
+ }
34
+
35
+ export {
36
+ afterAll,
37
+ afterEach,
38
+ beforeAll,
39
+ beforeEach,
40
+ describe,
41
+ expect,
42
+ test,
43
+ } from "vitest";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@peachlife/artisan",
3
- "version": "0.1.0",
3
+ "version": "0.1.2",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "artisan": "./bin/artisan.mjs"
@@ -48,6 +48,9 @@
48
48
  "fast-glob": "^3.3.3",
49
49
  "testcontainers": "^12.0.3"
50
50
  },
51
+ "peerDependencies": {
52
+ "vitest": "^4.0.0"
53
+ },
51
54
  "devDependencies": {
52
55
  "@biomejs/biome": "^2.5.1",
53
56
  "@cyclonedx/cyclonedx-npm": "^5.0.0",
@@ -67,6 +70,7 @@
67
70
  "files": [
68
71
  "src",
69
72
  "bin",
73
+ "dist",
70
74
  "templates"
71
75
  ],
72
76
  "publishConfig": {
@@ -0,0 +1,204 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { mkdir, readFile, writeFile } from "node:fs/promises";
3
+ import { dirname, join } from "node:path";
4
+ import { createInterface } from "node:readline/promises";
5
+ import { fileURLToPath } from "node:url";
6
+ import { execa } from "execa";
7
+ import { DEFAULT_DISTROS } from "../core/constants.mjs";
8
+ import { UsageError } from "../utils/errors.mjs";
9
+ import { fileExists } from "../utils/fs.mjs";
10
+ import {
11
+ readTemplate,
12
+ renderTemplate,
13
+ resolveTemplatesDirectory,
14
+ } from "../utils/template.mjs";
15
+
16
+ // Packages a bootstrapped project must be able to resolve. Artisan re-exports
17
+ // vitest primitives and spawns the vitest CLI, so the consumer must own a single
18
+ // shared vitest instance (declared as a peer in package.json).
19
+ const ARTISAN_PACKAGE = "@peachlife/artisan";
20
+ const VITEST_PACKAGE = "vitest";
21
+ const VITEST_PEER_RANGE = "^4.0.0";
22
+
23
+ const TEMPLATES_DIR = resolveTemplatesDirectory(
24
+ import.meta.url,
25
+ "../../templates",
26
+ );
27
+
28
+ const __dirname = dirname(fileURLToPath(import.meta.url));
29
+ // The bootstrapping Artisan knows its own version; pin the consumer devDep to it.
30
+ const artisanVersion = JSON.parse(
31
+ readFileSync(join(__dirname, "../../package.json"), "utf8"),
32
+ ).version;
33
+
34
+ /**
35
+ * Detect whether a project already has an Artisan config. A project counts as
36
+ * bootstrapped once artisan.config.json exists.
37
+ */
38
+ export async function detectBootstrapState(cwd = process.cwd()) {
39
+ return { hasConfig: await fileExists(join(cwd, "artisan.config.json")) };
40
+ }
41
+
42
+ /**
43
+ * Detect the package manager from lockfile presence.
44
+ * Order: bun > pnpm > yarn > npm (npm is the default fallback).
45
+ */
46
+ export async function detectPackageManager(cwd = process.cwd()) {
47
+ if (await fileExists(join(cwd, "bun.lock"))) return "bun";
48
+ if (await fileExists(join(cwd, "bun.lockb"))) return "bun";
49
+ if (await fileExists(join(cwd, "pnpm-lock.yaml"))) return "pnpm";
50
+ if (await fileExists(join(cwd, "yarn.lock"))) return "yarn";
51
+ return "npm";
52
+ }
53
+
54
+ /**
55
+ * Merge entries into a project's devDependencies, creating package.json if absent.
56
+ * Fails fast on unparseable JSON instead of clobbering the user's manifest.
57
+ */
58
+ export async function addDevelopmentDependencies(cwd, dependencies) {
59
+ const packagePath = join(cwd, "package.json");
60
+ let package_ = {};
61
+ if (await fileExists(packagePath)) {
62
+ const raw = await readFile(packagePath, "utf8");
63
+ try {
64
+ package_ = JSON.parse(raw);
65
+ } catch {
66
+ throw new UsageError(
67
+ `Cannot parse ${packagePath}; fix its JSON before bootstrapping.`,
68
+ );
69
+ }
70
+ }
71
+ package_.devDependencies = { ...package_.devDependencies, ...dependencies };
72
+ await writeFile(
73
+ packagePath,
74
+ `${JSON.stringify(package_, undefined, 2)}\n`,
75
+ "utf8",
76
+ );
77
+ }
78
+
79
+ /**
80
+ * Run the package manager's install command in a directory.
81
+ */
82
+ export async function runInstall({ cwd, packageManager }) {
83
+ await execa(packageManager, ["install"], {
84
+ cwd,
85
+ stdio: "inherit",
86
+ reject: true,
87
+ });
88
+ }
89
+
90
+ export function parseDistros(distrosRaw) {
91
+ if (typeof distrosRaw !== "string") {
92
+ throw new UsageError("Distros must be a comma-separated string");
93
+ }
94
+ const distros = distrosRaw
95
+ .split(",")
96
+ .map((d) => d.trim())
97
+ .filter(Boolean);
98
+ if (distros.length === 0) {
99
+ throw new UsageError("At least one distro is required");
100
+ }
101
+ return distros;
102
+ }
103
+
104
+ async function writeConfig({ cwd, artifact, distros, force }) {
105
+ const configPath = join(cwd, "artisan.config.json");
106
+ if (!force && (await fileExists(configPath))) {
107
+ console.log(
108
+ "Skipped artisan.config.json (already exists, use --force to overwrite)",
109
+ );
110
+ return;
111
+ }
112
+ const tmpl = await readTemplate(TEMPLATES_DIR, "config.json.tmpl");
113
+ const distrosJson = distros.map((d) => JSON.stringify(d)).join(", ");
114
+ const content = renderTemplate(tmpl, {
115
+ ARTIFACT: artifact,
116
+ DISTROS: distrosJson,
117
+ });
118
+ await writeFile(configPath, content, "utf8");
119
+ console.log("Created artisan.config.json");
120
+ }
121
+
122
+ async function writeStarterTest({ cwd, force }) {
123
+ const testsDirectory = join(cwd, "tests/artisan");
124
+ await mkdir(testsDirectory, { recursive: true });
125
+ const testPath = join(testsDirectory, "cli-version.artisan.test.mjs");
126
+ if (!force && (await fileExists(testPath))) {
127
+ console.log(
128
+ "Skipped tests/artisan/cli-version.artisan.test.mjs (already exists)",
129
+ );
130
+ return;
131
+ }
132
+ const tmpl = await readTemplate(TEMPLATES_DIR, "tests/version.test.mjs.tmpl");
133
+ await writeFile(testPath, tmpl, "utf8");
134
+ console.log("Created ./tests/artisan/cli-version.artisan.test.mjs");
135
+ }
136
+
137
+ async function installDependencies({ cwd, installer }) {
138
+ await addDevelopmentDependencies(cwd, {
139
+ [ARTISAN_PACKAGE]: `^${artisanVersion}`,
140
+ [VITEST_PACKAGE]: VITEST_PEER_RANGE,
141
+ });
142
+ const packageManager = await detectPackageManager(cwd);
143
+ console.log(`Installing dependencies with ${packageManager}...`);
144
+ await installer({ cwd, packageManager });
145
+ console.log(`Installed dependencies with ${packageManager}`);
146
+ }
147
+
148
+ /**
149
+ * Scaffold Artisan into a project: config, starter test, and (optionally)
150
+ * devDependency install. Shared by `init` and the first-run `test` gate.
151
+ *
152
+ * Defaults to NO install so the pure function is side-effect free; the CLI
153
+ * layer opts into installing for the user-facing commands.
154
+ */
155
+ export async function bootstrapProject({
156
+ cwd = process.cwd(),
157
+ artifact = "./dist/mycli",
158
+ distros = DEFAULT_DISTROS.join(","),
159
+ force = false,
160
+ install = false,
161
+ installer = runInstall,
162
+ } = {}) {
163
+ const distroList = parseDistros(distros);
164
+ await writeConfig({ cwd, artifact, distros: distroList, force });
165
+ await writeStarterTest({ cwd, force });
166
+ if (install) {
167
+ await installDependencies({ cwd, installer });
168
+ }
169
+ }
170
+
171
+ /**
172
+ * True when stdin is a TTY and no CI runner is detected.
173
+ */
174
+ export function isInteractive() {
175
+ return process.stdin.isTTY === true && !process.env.CI;
176
+ }
177
+
178
+ /**
179
+ * Ask the user a yes/no bootstrap question. Defaults to yes on empty input.
180
+ * input/output are injectable for tests.
181
+ */
182
+ export async function promptBootstrap({
183
+ input = process.stdin,
184
+ output = process.stdout,
185
+ } = {}) {
186
+ const rl = createInterface({ input, output, terminal: false });
187
+ try {
188
+ const answer = await rl.question("Bootstrap? [Y/n] ");
189
+ const value = answer.trim().toLowerCase();
190
+ return ["", "y", "yes"].includes(value);
191
+ } finally {
192
+ rl.close();
193
+ }
194
+ }
195
+
196
+ /**
197
+ * Resolve the three-state bootstrap decision from parsed CLI options.
198
+ * Returns "always" | "never" | "ask".
199
+ */
200
+ export function resolveBootstrapDecision(options) {
201
+ if (options.bootstrap === true) return "always";
202
+ if (options.bootstrap === false) return "never";
203
+ return "ask";
204
+ }
@@ -1,71 +1,19 @@
1
- import { mkdir, writeFile } from "node:fs/promises";
2
- import { join } from "node:path";
3
- import { DEFAULT_DISTROS } from "../../core/constants.mjs";
4
- import { UsageError } from "../../utils/errors.mjs";
5
- import { fileExists } from "../../utils/fs.mjs";
6
- import {
7
- readTemplate,
8
- renderTemplate,
9
- resolveTemplatesDirectory,
10
- } from "../../utils/template.mjs";
1
+ import { bootstrapProject } from "../bootstrap.mjs";
11
2
 
12
- const TEMPLATES_DIR = resolveTemplatesDirectory(
13
- import.meta.url,
14
- "../../../templates",
15
- );
16
-
17
- function parseDistros(distrosRaw) {
18
- if (typeof distrosRaw !== "string") {
19
- throw new UsageError("Distros must be a comma-separated string");
20
- }
21
- const distros = distrosRaw
22
- .split(",")
23
- .map((d) => d.trim())
24
- .filter(Boolean);
25
- if (distros.length === 0) {
26
- throw new UsageError("At least one distro is required");
27
- }
28
- return distros;
29
- }
30
-
31
- export async function runInit(options) {
32
- const cwd = process.cwd();
33
- const artifact = options.artifact ?? "./dist/mycli";
34
- const distrosRaw = options.distros ?? DEFAULT_DISTROS.join(",");
35
- const distros = parseDistros(distrosRaw);
36
- const force = options.force ?? false;
37
-
38
- // Write artisan.config.json
39
- const configPath = join(cwd, "artisan.config.json");
40
- if (!force && (await fileExists(configPath))) {
41
- console.log(
42
- `Skipped artisan.config.json (already exists, use --force to overwrite)`,
43
- );
44
- } else {
45
- const tmpl = await readTemplate(TEMPLATES_DIR, "config.json.tmpl");
46
- const distrosJson = distros.map((d) => JSON.stringify(d)).join(", ");
47
- const content = renderTemplate(tmpl, {
48
- ARTIFACT: artifact,
49
- DISTROS: distrosJson,
50
- });
51
- await writeFile(configPath, content, "utf8");
52
- console.log(`Created artisan.config.json`);
53
- }
54
-
55
- // Write starter test file
56
- const testsDirectory = join(cwd, "tests/artisan");
57
- await mkdir(testsDirectory, { recursive: true });
58
- const testPath = join(testsDirectory, "cli-version.artisan.test.mjs");
59
- if (!force && (await fileExists(testPath))) {
60
- console.log(
61
- `Skipped tests/artisan/cli-version.artisan.test.mjs (already exists)`,
62
- );
63
- } else {
64
- const tmpl = await readTemplate(
65
- TEMPLATES_DIR,
66
- "tests/version.test.mjs.tmpl",
67
- );
68
- await writeFile(testPath, tmpl, "utf8");
69
- console.log(`Created ./tests/artisan/cli-version.artisan.test.mjs`);
70
- }
3
+ /**
4
+ * Scaffold Artisan into the current project.
5
+ *
6
+ * CLI (`artisan init`) installs dependencies by default; `--no-install` skips.
7
+ * Programmatic callers default to NO install so the function is side-effect
8
+ * free (unit tests stay network-free).
9
+ */
10
+ export async function runInit(options = {}) {
11
+ await bootstrapProject({
12
+ cwd: process.cwd(),
13
+ artifact: options.artifact,
14
+ distros: options.distros,
15
+ force: options.force ?? false,
16
+ install: options.install === true,
17
+ installer: options.installer,
18
+ });
71
19
  }
@@ -7,6 +7,14 @@ import { DEFAULT_XDG_CONFIG_HOME } from "../../core/constants.mjs";
7
7
  import { resolveConfigs } from "../../core/fixture-resolver.mjs";
8
8
  import { DockerError, UsageError } from "../../utils/errors.mjs";
9
9
  import { resolveTestFiles } from "../../utils/glob.mjs";
10
+ import {
11
+ bootstrapProject,
12
+ detectBootstrapState,
13
+ isInteractive,
14
+ parseDistros,
15
+ promptBootstrap,
16
+ resolveBootstrapDecision,
17
+ } from "../bootstrap.mjs";
10
18
 
11
19
  const __dirname = dirname(fileURLToPath(import.meta.url));
12
20
  const artisanVitestConfig = join(
@@ -19,12 +27,6 @@ function resolveTimeout(timeout) {
19
27
  if (timeout) return String(timeout);
20
28
  }
21
29
 
22
- function dockerUnavailableError(detail) {
23
- return new DockerError(
24
- `Docker is not available. Start Docker and retry.\n${detail}`,
25
- );
26
- }
27
-
28
30
  async function ensureDockerAvailable() {
29
31
  try {
30
32
  const result = await execa("docker", ["info"], {
@@ -32,12 +34,19 @@ async function ensureDockerAvailable() {
32
34
  reject: false,
33
35
  });
34
36
  if (result.exitCode === 0) return;
35
- throw dockerUnavailableError(result.stderr || result.stdout);
37
+ throw new DockerError(
38
+ `Docker is not running. Start Docker and retry.\n${result.stderr || result.stdout}`,
39
+ );
36
40
  } catch (error) {
37
41
  if (error instanceof DockerError) throw error;
42
+ if (error instanceof Error && error.code === "ENOENT") {
43
+ throw new DockerError(
44
+ "Docker is not installed.\nInstall it from https://docs.docker.com/engine/install/ and retry.",
45
+ );
46
+ }
38
47
  const message =
39
48
  error instanceof Error ? error.message : String(error ?? "unknown error");
40
- throw dockerUnavailableError(message);
49
+ throw new DockerError(`Docker is not available.\n${message}`);
41
50
  }
42
51
  }
43
52
 
@@ -78,15 +87,81 @@ function buildVitestArguments({ options, merged, testFiles }) {
78
87
  return vitestArguments;
79
88
  }
80
89
 
90
+ function printBootstrapPlan() {
91
+ console.log("No Artisan setup found.\n");
92
+ console.log("Bootstrap this project now? This will:");
93
+ console.log(" - add @peachlife/artisan and vitest to devDependencies");
94
+ console.log(" - create artisan.config.json");
95
+ console.log(" - create tests/artisan/cli-version.artisan.test.mjs");
96
+ console.log(" - install dependencies\n");
97
+ }
98
+
99
+ function printSetupInstructions() {
100
+ console.error("No Artisan setup found in this project.");
101
+ console.error("");
102
+ console.error("Get started with:");
103
+ console.error(" npx @peachlife/artisan init");
104
+ console.error("");
105
+ console.error("Or bootstrap on the fly:");
106
+ console.error(" npx @peachlife/artisan test --bootstrap");
107
+ }
108
+
109
+ /**
110
+ * Handle a project with no artisan.config.json before `test` runs.
111
+ *
112
+ * Returns `{ exit: <code> }` to short-circuit, or `{ ran: true }` once the
113
+ * project has been bootstrapped. prompt/interactive/bootstrap are injectable so
114
+ * the full decision matrix is unit-testable without a TTY, network, or exit.
115
+ */
116
+ export async function handleFreshProject({
117
+ cwd,
118
+ options,
119
+ prompt = promptBootstrap,
120
+ interactive = isInteractive,
121
+ bootstrap = bootstrapProject,
122
+ }) {
123
+ const decision = resolveBootstrapDecision(options);
124
+ if (decision === "never" || (decision === "ask" && !interactive())) {
125
+ printSetupInstructions();
126
+ return { exit: 1 };
127
+ }
128
+ if (decision === "ask") {
129
+ printBootstrapPlan();
130
+ }
131
+ const proceed = decision === "always" ? true : await prompt();
132
+ if (!proceed) {
133
+ console.log("\nCancelled.\n");
134
+ printSetupInstructions();
135
+ return { exit: 0 };
136
+ }
137
+ console.log("\nBootstrapping Artisan...");
138
+ // CLI-only path: commander always supplies true/false for --install/--no-install,
139
+ // so `options.install !== false` defaults to install=true for interactive users.
140
+ await bootstrap({
141
+ cwd,
142
+ install: options.install !== false,
143
+ installer: options.installer,
144
+ });
145
+ console.log("\nRunning tests...");
146
+ return { ran: true };
147
+ }
148
+
81
149
  export async function runTest(files, options) {
150
+ const cwd = process.cwd();
151
+ const { hasConfig } = await detectBootstrapState(cwd);
152
+ if (!hasConfig) {
153
+ const result = await handleFreshProject({ cwd, options });
154
+ if (result.exit !== undefined) {
155
+ // eslint-disable-next-line unicorn/no-process-exit
156
+ process.exit(result.exit);
157
+ }
158
+ }
159
+
82
160
  const config = await loadConfig();
83
161
  const flags = {};
84
162
  if (options.artifact !== undefined) flags.artifact = options.artifact;
85
163
  if (options.distros !== undefined) {
86
- flags.distros = options.distros
87
- .split(",")
88
- .map((d) => d.trim())
89
- .filter(Boolean);
164
+ flags.distros = parseDistros(options.distros);
90
165
  }
91
166
  if (options.reporter !== undefined) flags.reporter = options.reporter;
92
167
  if (options.timeout !== undefined) flags.timeout = options.timeout;
@@ -62,6 +62,7 @@ export function createProgram() {
62
62
  .option("--testMatch <glob>", "Test file glob pattern")
63
63
  .option("-y, --yes", "Skip prompts, use defaults/flags")
64
64
  .option("--force", "Overwrite existing config/test files")
65
+ .option("--no-install", "Skip dependency installation")
65
66
  .action((options) => runInit(options));
66
67
 
67
68
  program
@@ -88,6 +89,9 @@ export function createProgram() {
88
89
  parsePositiveInt(v, "retries"),
89
90
  )
90
91
  .option("-b, --bail", "Stop on first failure")
92
+ .option("--bootstrap", "Bootstrap a fresh project without prompting")
93
+ .option("--no-bootstrap", "Never bootstrap; exit with setup instructions")
94
+ .option("--no-install", "Skip dependency install during bootstrap")
91
95
  .action((files, options) => runTest(files, options));
92
96
 
93
97
  const add = program.command("add").description("Scaffold tests and fixtures");