@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 +54 -149
- package/dist/api/types.d.ts +43 -0
- package/package.json +5 -1
- package/src/cli/bootstrap.mjs +204 -0
- package/src/cli/commands/init.mjs +17 -69
- package/src/cli/commands/test.mjs +87 -12
- package/src/cli/parser.mjs +4 -0
package/README.md
CHANGED
|
@@ -1,196 +1,110 @@
|
|
|
1
1
|
# Artisan
|
|
2
2
|
|
|
3
|
-
>
|
|
3
|
+
> **End-to-end artifact testing for CLIs.** Stop merging green unit tests for broken binaries.
|
|
4
4
|
|
|
5
|
-
|
|
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
|
-
|
|
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
|
-
|
|
9
|
+
Backed by **Testcontainers**, **Execa**, and **Vitest**.
|
|
12
10
|
|
|
13
|
-
|
|
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
|
-
|
|
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
|
-
|
|
20
|
-
- **Execa** for command execution and stdout/stderr/exit-code capture.
|
|
21
|
-
- **Vitest** for the `test()` and `expect()` API.
|
|
17
|
+
## Quickstart
|
|
22
18
|
|
|
23
|
-
|
|
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
|
-
##
|
|
27
|
+
## The API
|
|
103
28
|
|
|
104
|
-
|
|
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("
|
|
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
|
-
"
|
|
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 '^
|
|
53
|
+
"grep -q '^unicorn sequence initiated$' /tmp/output.txt",
|
|
124
54
|
]);
|
|
125
55
|
});
|
|
126
56
|
```
|
|
127
57
|
|
|
128
|
-
##
|
|
58
|
+
## Matrix Testing via Config
|
|
129
59
|
|
|
130
|
-
|
|
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
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
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
|
-
|
|
150
|
-
depending on your host machine.
|
|
71
|
+
## Config Fixtures
|
|
151
72
|
|
|
152
|
-
|
|
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
|
-
#
|
|
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
|
-
|
|
175
|
-
CLI reference.
|
|
80
|
+
## CLI Reference
|
|
176
81
|
|
|
177
|
-
|
|
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
|
|
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
|
-
|
|
102
|
+
Artisan runs perfectly in CI as long as the runner has Docker access.
|
|
189
103
|
|
|
190
|
-
|
|
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
|
|
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.
|
|
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 {
|
|
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
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
);
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
.
|
|
23
|
-
|
|
24
|
-
.
|
|
25
|
-
|
|
26
|
-
|
|
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
|
|
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
|
|
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;
|
package/src/cli/parser.mjs
CHANGED
|
@@ -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");
|