@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.
package/README.md ADDED
@@ -0,0 +1,220 @@
1
+ # Artisan
2
+
3
+ > Artifact-level dogfood tests for command-line tools.
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.
8
+
9
+ Unit tests can say your code works. Artisan proves the binary works.
10
+
11
+ ## What is Artisan?
12
+
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.
16
+
17
+ It is built on:
18
+
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.
22
+
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:
74
+
75
+ ```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
+ }
94
+ ```
95
+
96
+ Then run:
97
+
98
+ ```bash
99
+ npx @peachlife/artisan test
100
+ ```
101
+
102
+ ## Test real behavior, not internals
103
+
104
+ Artisan tests should assert what a user can observe.
105
+
106
+ ```javascript
107
+ import { expect, test } from "@peachlife/artisan";
108
+
109
+ test("CLI writes an output file", async ({ run, setup }) => {
110
+ await setup(["rm -f /tmp/output.txt"]);
111
+
112
+ const { stdout, exitCode } = await run([
113
+ "write-file",
114
+ "/tmp/output.txt",
115
+ "hello from the sandbox",
116
+ ]);
117
+
118
+ expect(exitCode).toBe(0);
119
+ expect(stdout).toContain("wrote /tmp/output.txt");
120
+
121
+ await setup([
122
+ "test -f /tmp/output.txt",
123
+ "grep -q '^hello from the sandbox$' /tmp/output.txt",
124
+ ]);
125
+ });
126
+ ```
127
+
128
+ ## Test config safely
129
+
130
+ Config bugs are where many CLIs break: `~` paths, missing files, leaked
131
+ credentials, wrong XDG paths, and “works on my machine” assumptions.
132
+
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>/
147
+ ```
148
+
149
+ Now your tests can prove the artifact reads config in a clean sandbox instead of
150
+ depending on your host machine.
151
+
152
+ ## Common commands
153
+
154
+ ```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.
171
+ npx @peachlife/artisan add config ~/.config/mycli --name mycli-config
172
+ ```
173
+
174
+ Run `npx @peachlife/artisan --help` or `npx @peachlife/artisan <command> --help` for the full
175
+ CLI reference.
176
+
177
+ ## Exit codes
178
+
179
+ | 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. |
186
+
187
+
188
+ ## CI shape
189
+
190
+ A minimal GitHub Actions job looks like this:
191
+
192
+ ```yaml
193
+ name: Artifact tests
194
+ on: [push, pull_request]
195
+
196
+ jobs:
197
+ artisan:
198
+ runs-on: ubuntu-latest
199
+ steps:
200
+ - uses: actions/checkout@v4
201
+ - uses: actions/setup-node@v4
202
+ with:
203
+ node-version: 22
204
+ - run: npm ci
205
+ - run: npx @peachlife/artisan test --no-color
206
+ ```
207
+
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
+ ## License
219
+
220
+ MIT
@@ -0,0 +1,18 @@
1
+ #!/usr/bin/env node
2
+ import { createProgram } from "../src/cli/parser.mjs";
3
+ import { ArtisanError } from "../src/utils/errors.mjs";
4
+
5
+ const program = createProgram();
6
+
7
+ try {
8
+ await program.parseAsync(process.argv);
9
+ } catch (error) {
10
+ if (error instanceof ArtisanError) {
11
+ console.error(`artisan: ${error.message}`);
12
+ process.exit(error.exitCode);
13
+ }
14
+ const message =
15
+ error instanceof Error ? error.message : String(error ?? "unknown error");
16
+ console.error(`artisan: unexpected error: ${message}`);
17
+ process.exit(1);
18
+ }
package/package.json ADDED
@@ -0,0 +1,84 @@
1
+ {
2
+ "name": "@peachlife/artisan",
3
+ "version": "0.1.0",
4
+ "type": "module",
5
+ "bin": {
6
+ "artisan": "./bin/artisan.mjs"
7
+ },
8
+ "exports": {
9
+ ".": {
10
+ "types": "./dist/api/types.d.ts",
11
+ "import": "./src/api/injection.mjs"
12
+ }
13
+ },
14
+ "scripts": {
15
+ "build": "npx biome check src bin examples && npm test && npm run test:dogfood",
16
+ "test": "vitest run",
17
+ "test:watch": "vitest",
18
+ "test:integration": "npm run test:dogfood",
19
+ "test:dogfood": "npm run test:dogfood:fixture && npm run test:dogfood:self",
20
+ "test:dogfood:fixture": "node bin/artisan.mjs test tests/artisan-dogfood.artisan.test.mjs",
21
+ "test:dogfood:self": "node bin/artisan.mjs test tests/artisan-self.artisan.test.mjs --artifact ./bin/artisan.mjs --distros debian:stable-slim,alpine:latest,archlinux/archlinux:latest",
22
+ "test:all": "npm run test && npm run test:integration && npm run test:dogfood && npm run test:dogfood:fixture && npm run test:dogfood:self",
23
+ "eslint": "eslint .",
24
+ "eslint:fix": "eslint . --fix",
25
+ "lint": "biome check .",
26
+ "lint:fix": "biome check --write .",
27
+ "lint:dup": "jscpd",
28
+ "lint:arch": "depcruise src --config .dependency-cruiser.cjs",
29
+ "lint:dead": "knip --include-entry-exports",
30
+ "lint:secrets": "gitleaks detect --redact --source .",
31
+ "lint:sbom": "cyclonedx-npm --output-file ./sbom.cdx.json --output-format json",
32
+ "lint:semgrep": "semgrep ci --config p/javascript --config p/security-audit",
33
+ "typecheck": "tsc --noEmit --allowImportingTsExtensions --allowJs --checkJs false --noResolve --skipLibCheck --module nodenext --moduleResolution nodenext --target esnext src/**/*.mjs bin/**/*.mjs || true",
34
+ "lint:all": "npm run lint && npm run eslint && npm run lint:dup && npm run lint:arch && npm run lint:dead",
35
+ "fix:all": "biome check --write . && eslint . --fix && knip --fix",
36
+ "ci": "npm run lint:all && npm run lint:secrets && npm run lint:semgrep && npm run lint:sbom && npm run test:all"
37
+ },
38
+ "lint-staged": {
39
+ "*.mjs": [
40
+ "biome check --write",
41
+ "eslint --fix",
42
+ "eslint"
43
+ ]
44
+ },
45
+ "dependencies": {
46
+ "commander": "^15.0.0",
47
+ "execa": "^9.6.1",
48
+ "fast-glob": "^3.3.3",
49
+ "testcontainers": "^12.0.3"
50
+ },
51
+ "devDependencies": {
52
+ "@biomejs/biome": "^2.5.1",
53
+ "@cyclonedx/cyclonedx-npm": "^5.0.0",
54
+ "@eslint/js": "^10.0.1",
55
+ "@typescript-eslint/eslint-plugin": "^8.62.0",
56
+ "@typescript-eslint/parser": "^8.62.0",
57
+ "dependency-cruiser": "^18.0.0",
58
+ "eslint": "^10.5.0",
59
+ "eslint-plugin-security": "^4.0.1",
60
+ "eslint-plugin-sonarjs": "^4.1.0",
61
+ "eslint-plugin-unicorn": "^69.0.0",
62
+ "jscpd": "^5.0.11",
63
+ "knip": "^6.21.0",
64
+ "lint-staged": "^17.0.8",
65
+ "vitest": "^4.1.9"
66
+ },
67
+ "files": [
68
+ "src",
69
+ "bin",
70
+ "templates"
71
+ ],
72
+ "publishConfig": {
73
+ "access": "public"
74
+ },
75
+ "engines": {
76
+ "node": ">=22"
77
+ },
78
+ "allowScripts": {
79
+ "cpu-features@0.0.10": true,
80
+ "fsevents@2.3.3": true,
81
+ "protobufjs@7.6.4": true,
82
+ "ssh2@1.17.0": true
83
+ }
84
+ }
@@ -0,0 +1,87 @@
1
+ export {
2
+ afterAll,
3
+ afterEach,
4
+ beforeAll,
5
+ beforeEach,
6
+ describe,
7
+ expect,
8
+ } from "vitest";
9
+
10
+ import { test as vitestTest } from "vitest";
11
+ import { DEFAULT_XDG_CONFIG_HOME } from "../core/constants.mjs";
12
+ import { ContainerManager } from "../core/container-manager.mjs";
13
+
14
+ export function createTestContext(container) {
15
+ return {
16
+ async run(arguments_ = "", options = {}) {
17
+ return container.exec(arguments_, options);
18
+ },
19
+ async copyFixture(localPath, containerPath) {
20
+ return container.copyFile(localPath, containerPath);
21
+ },
22
+ async setup(commands, options = {}) {
23
+ for (const command of commands) {
24
+ const result = await container.shell(command, options);
25
+ if (result.exitCode !== 0) {
26
+ throw new Error(
27
+ `setup cmd failed: ${command}\n${result.stderr || result.stdout}`,
28
+ );
29
+ }
30
+ }
31
+ },
32
+ };
33
+ }
34
+
35
+ function createContainer(
36
+ distro,
37
+ artifact,
38
+ setupCommands,
39
+ configs,
40
+ xdgConfigHome,
41
+ ) {
42
+ return new ContainerManager(distro, artifact, {
43
+ setupCommands,
44
+ configs,
45
+ env: {
46
+ HOME: "/root",
47
+ XDG_CONFIG_HOME: xdgConfigHome,
48
+ },
49
+ });
50
+ }
51
+
52
+ // Build fixture-extended test from env vars (set by artisan test command).
53
+ function buildExtendedTest() {
54
+ const distro = process.env.ARTISAN_DISTRO;
55
+ const artifact = process.env.ARTISAN_ARTIFACT;
56
+ if (!distro || !artifact) return vitestTest;
57
+
58
+ const setupCommands = JSON.parse(process.env.ARTISAN_SETUP ?? "[]");
59
+ const configs = JSON.parse(process.env.ARTISAN_CONFIGS ?? "[]");
60
+ const xdgConfigHome =
61
+ process.env.ARTISAN_XDG_CONFIG_HOME ?? DEFAULT_XDG_CONFIG_HOME;
62
+
63
+ return vitestTest
64
+ .extend("artisanContext", async ({ task: _task }, { onCleanup }) => {
65
+ const container = createContainer(
66
+ distro,
67
+ artifact,
68
+ setupCommands,
69
+ configs,
70
+ xdgConfigHome,
71
+ );
72
+ await container.start();
73
+ onCleanup(() => container.stop());
74
+ return createTestContext(container);
75
+ })
76
+ .extend("run", async ({ artisanContext }) =>
77
+ artisanContext.run.bind(artisanContext),
78
+ )
79
+ .extend("copyFixture", async ({ artisanContext }) =>
80
+ artisanContext.copyFixture.bind(artisanContext),
81
+ )
82
+ .extend("setup", async ({ artisanContext }) =>
83
+ artisanContext.setup.bind(artisanContext),
84
+ );
85
+ }
86
+
87
+ export const test = buildExtendedTest();
@@ -0,0 +1,91 @@
1
+ import { rm, stat, writeFile } from "node:fs/promises";
2
+ import { basename, join } from "node:path";
3
+ import { readJsonConfig } from "../../core/config.mjs";
4
+ import { sanitizeConfigDirectory } from "../../core/sanitizer.mjs";
5
+ import { UsageError } from "../../utils/errors.mjs";
6
+ import { fileExists } from "../../utils/fs.mjs";
7
+ import { expandUserPath } from "../../utils/path.mjs";
8
+ import { validateSinglePathName } from "../../utils/validation.mjs";
9
+
10
+ export async function runAddConfig(options = {}) {
11
+ const cwd = options._cwd ?? process.cwd();
12
+ const source = options.source;
13
+ if (!source) {
14
+ throw new UsageError(
15
+ "Usage: artisan add config <source> [--name <name>] [--raw] [--force]",
16
+ );
17
+ }
18
+
19
+ const configPath = join(cwd, "artisan.config.json");
20
+ const config = await readConfig(configPath);
21
+ const sourcePath = expandUserPath(source, cwd);
22
+ await assertSourceDirectory(sourcePath, source);
23
+
24
+ const name = options.name ?? basename(sourcePath);
25
+ validateSinglePathName(name, "fixture directory name");
26
+ const fixturePath = join(cwd, "fixtures", name);
27
+
28
+ if (!options.force && (await fileExists(fixturePath))) {
29
+ console.log(
30
+ `Skipped ${fixturePath} (already exists, use --force to overwrite)`,
31
+ );
32
+ return;
33
+ }
34
+
35
+ await rm(fixturePath, { recursive: true, force: true });
36
+ const result = await sanitizeConfigDirectory(sourcePath, fixturePath, {
37
+ raw: options.raw ?? false,
38
+ });
39
+ await appendToConfigs(configPath, config, `./fixtures/${name}`);
40
+
41
+ console.log(`Created ${fixturePath}`);
42
+ if (result.raw) {
43
+ console.log(" copied: all entries (raw)");
44
+ } else {
45
+ console.log(` copied: ${result.copied.length} entries`);
46
+ }
47
+ if (result.scrubbed.length > 0) {
48
+ const preview = result.scrubbed.slice(0, 10).join(", ");
49
+ const suffix = result.scrubbed.length > 10 ? "…" : "";
50
+ console.log(` scrubbed (${result.scrubbed.length}): ${preview}${suffix}`);
51
+ }
52
+ if (result.manifestPath) console.log(` manifest: ${result.manifestPath}`);
53
+ if (result.raw)
54
+ console.warn(
55
+ ` ⚠️ --raw: secrets NOT scrubbed. Review ${fixturePath} before committing.`,
56
+ );
57
+ }
58
+
59
+ async function assertSourceDirectory(sourcePath, displaySource) {
60
+ let sourceStat;
61
+ try {
62
+ sourceStat = await stat(sourcePath);
63
+ } catch {
64
+ throw new UsageError(
65
+ `config source "${displaySource}" not found at ${sourcePath}`,
66
+ );
67
+ }
68
+
69
+ if (!sourceStat.isDirectory()) {
70
+ throw new UsageError(
71
+ `config source "${displaySource}" must be a directory`,
72
+ );
73
+ }
74
+ }
75
+
76
+ async function readConfig(configPath) {
77
+ return readJsonConfig(configPath);
78
+ }
79
+
80
+ async function appendToConfigs(configPath, config, entry) {
81
+ if (config.configs !== undefined && !Array.isArray(config.configs)) {
82
+ throw new UsageError('"configs" in artisan.config.json must be an array');
83
+ }
84
+ config.configs ??= [];
85
+ if (!config.configs.includes(entry)) config.configs.push(entry);
86
+ await writeFile(
87
+ configPath,
88
+ `${JSON.stringify(config, undefined, 2)}\n`,
89
+ "utf8",
90
+ );
91
+ }
@@ -0,0 +1,64 @@
1
+ import { mkdir, writeFile } from "node:fs/promises";
2
+ import { join } from "node:path";
3
+ import { UsageError } from "../../utils/errors.mjs";
4
+ import { fileExists } from "../../utils/fs.mjs";
5
+ import {
6
+ readTemplate,
7
+ resolveTemplatesDirectory,
8
+ } from "../../utils/template.mjs";
9
+ import { validateSinglePathName } from "../../utils/validation.mjs";
10
+ import { runAddConfig } from "./add-config.mjs";
11
+
12
+ const TEMPLATES_DIR = resolveTemplatesDirectory(
13
+ import.meta.url,
14
+ "../../../templates/tests",
15
+ );
16
+
17
+ const TYPE_MAP = {
18
+ version: {
19
+ template: "version.test.mjs.tmpl",
20
+ filename: "cli-version.artisan.test.mjs",
21
+ },
22
+ help: {
23
+ template: "help.test.mjs.tmpl",
24
+ filename: "cli-help.artisan.test.mjs",
25
+ },
26
+ custom: { template: "custom.test.mjs.tmpl", filename: undefined },
27
+ };
28
+
29
+ export async function runAdd(type, options) {
30
+ if (type === "config") return runAddConfig(options);
31
+
32
+ const spec = TYPE_MAP[type];
33
+ if (!spec)
34
+ throw new UsageError(
35
+ `Unknown test type: ${type}. Valid types: version, help, custom, config`,
36
+ );
37
+
38
+ let filename = spec.filename;
39
+ if (type === "custom") {
40
+ if (!options.name)
41
+ throw new UsageError("--name is required for custom type");
42
+ validateSinglePathName(options.name, "test name");
43
+ filename = `${options.name}.artisan.test.mjs`;
44
+ }
45
+
46
+ const targetDirectory = join(process.cwd(), options.dir ?? "./tests/");
47
+ await mkdir(targetDirectory, { recursive: true });
48
+
49
+ const outPath = join(targetDirectory, filename);
50
+ if (!options.force && (await fileExists(outPath))) {
51
+ console.log(
52
+ `Skipped ${outPath} (already exists, use --force to overwrite)`,
53
+ );
54
+ return;
55
+ }
56
+
57
+ let content = await readTemplate(TEMPLATES_DIR, spec.template);
58
+ if (type === "custom") {
59
+ // eslint-disable-next-line unicorn/no-unsafe-string-replacement
60
+ content = content.replaceAll("{{TEST_NAME}}", options.name ?? "my test");
61
+ }
62
+ await writeFile(outPath, content, "utf8");
63
+ console.log(`Created ${outPath}`);
64
+ }
@@ -0,0 +1,71 @@
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";
11
+
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
+ }
71
+ }