@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 +220 -0
- package/bin/artisan.mjs +18 -0
- package/package.json +84 -0
- package/src/api/injection.mjs +87 -0
- package/src/cli/commands/add-config.mjs +91 -0
- package/src/cli/commands/add.mjs +64 -0
- package/src/cli/commands/init.mjs +71 -0
- package/src/cli/commands/test.mjs +158 -0
- package/src/cli/parser.mjs +115 -0
- package/src/core/artifact-manager.mjs +137 -0
- package/src/core/config.mjs +139 -0
- package/src/core/constants.mjs +5 -0
- package/src/core/container-manager.mjs +240 -0
- package/src/core/fixture-resolver.mjs +97 -0
- package/src/core/sanitizer.mjs +83 -0
- package/src/utils/errors.mjs +28 -0
- package/src/utils/fs.mjs +11 -0
- package/src/utils/glob.mjs +5 -0
- package/src/utils/path.mjs +9 -0
- package/src/utils/template.mjs +15 -0
- package/src/utils/validation.mjs +13 -0
- package/templates/config.json.tmpl +9 -0
- package/templates/tests/custom.test.mjs.tmpl +7 -0
- package/templates/tests/help.test.mjs.tmpl +8 -0
- package/templates/tests/version.test.mjs.tmpl +8 -0
- package/templates/vitest-artisan.config.mjs +9 -0
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
|
package/bin/artisan.mjs
ADDED
|
@@ -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
|
+
}
|