@peachlife/artisan 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,240 @@
1
+ import { stat } from "node:fs/promises";
2
+ import { basename } from "node:path";
3
+ import { join as joinPosix } from "node:path/posix";
4
+ import { GenericContainer } from "testcontainers";
5
+ import { DockerError } from "../utils/errors.mjs";
6
+ import { BINARY_MOUNT_DIR } from "./constants.mjs";
7
+
8
+ const NO_CAP = 0;
9
+ const SH_C = ["sh", "-c"];
10
+ const ERR_NOT_STARTED = "Container not started";
11
+
12
+ function parseChar(state, char) {
13
+ if (state.isEscaped) {
14
+ state.current += char;
15
+ state.isEscaped = false;
16
+ return;
17
+ }
18
+ if (char === "\\" && !state.isInSingle) {
19
+ state.isEscaped = true;
20
+ return;
21
+ }
22
+ if (char === "'" && !state.isInDouble) {
23
+ state.isInSingle = !state.isInSingle;
24
+ state.isInToken = true;
25
+ return;
26
+ }
27
+ if (char === '"' && !state.isInSingle) {
28
+ state.isInDouble = !state.isInDouble;
29
+ state.isInToken = true;
30
+ return;
31
+ }
32
+ if (/\s/.test(char) && !state.isInSingle && !state.isInDouble) {
33
+ if (state.isInToken) {
34
+ state.parsed.push(state.current);
35
+ state.current = "";
36
+ state.isInToken = false;
37
+ }
38
+ return;
39
+ }
40
+ state.current += char;
41
+ state.isInToken = true;
42
+ }
43
+
44
+ function splitArguments(arguments_) {
45
+ const state = {
46
+ parsed: [],
47
+ current: "",
48
+ isInToken: false,
49
+ isInSingle: false,
50
+ isInDouble: false,
51
+ isEscaped: false,
52
+ };
53
+ for (const char of arguments_) parseChar(state, char);
54
+ if (state.isEscaped)
55
+ throw new Error("Malformed args string: trailing backslash");
56
+ if (state.isInSingle)
57
+ throw new Error("Malformed args string: unmatched single quote");
58
+ if (state.isInDouble)
59
+ throw new Error("Malformed args string: unmatched double quote");
60
+ if (state.isInToken) state.parsed.push(state.current);
61
+ return state.parsed;
62
+ }
63
+
64
+ function splitExecOutput(output) {
65
+ if (Array.isArray(output)) return [output[0] ?? "", output[1] ?? ""];
66
+ if (typeof output === "string") return [output, ""];
67
+ return ["", ""];
68
+ }
69
+
70
+ function errorMessage(error) {
71
+ return error instanceof Error
72
+ ? error.message
73
+ : String(error ?? "unknown error");
74
+ }
75
+
76
+ export class ContainerManager {
77
+ #distro;
78
+ #artifactPath;
79
+ #setupCommands;
80
+ #configs;
81
+ #env;
82
+ #container = undefined;
83
+
84
+ constructor(distro, artifactPath, options = {}) {
85
+ this.#distro = distro;
86
+ this.#artifactPath = artifactPath;
87
+ this.#setupCommands = options.setupCommands ?? [];
88
+ this.#configs = options.configs ?? [];
89
+ this.#env = options.env ?? {};
90
+ }
91
+
92
+ get #mountTarget() {
93
+ return joinPosix(BINARY_MOUNT_DIR, basename(this.#artifactPath));
94
+ }
95
+
96
+ /**
97
+ * Shared exec with optional timeout. timeout <= 0 means no cap (the
98
+ * per-test timeout is the authoritative safety net). When a timeout fires,
99
+ * the container is stopped and a timedOut result is returned.
100
+ */
101
+ async #execBounded(command, execOptions, timeout, label = "exec") {
102
+ let isTimedOut = false;
103
+ let timeoutId;
104
+ const execPromise = this.#container
105
+ .exec(command, execOptions)
106
+ // eslint-disable-next-line unicorn/prefer-await
107
+ .catch((error) => {
108
+ if (isTimedOut) return { output: ["", ""], exitCode: 1 };
109
+ throw error;
110
+ });
111
+ const timeoutPromise =
112
+ timeout > 0
113
+ ? new Promise((resolve) => {
114
+ timeoutId = setTimeout(() => {
115
+ isTimedOut = true;
116
+ resolve({ timedOut: true });
117
+ }, timeout);
118
+ })
119
+ : undefined;
120
+ try {
121
+ const result = timeoutPromise
122
+ ? await Promise.race([execPromise, timeoutPromise])
123
+ : await execPromise;
124
+ if (timeoutId) clearTimeout(timeoutId);
125
+ if (result?.timedOut) {
126
+ await this.stop();
127
+ return {
128
+ stdout: "",
129
+ stderr: "Command timed out",
130
+ exitCode: 1,
131
+ timedOut: true,
132
+ };
133
+ }
134
+ const [stdout, stderr] = splitExecOutput(result.output);
135
+ return {
136
+ stdout,
137
+ stderr,
138
+ exitCode: result.exitCode ?? 0,
139
+ timedOut: false,
140
+ };
141
+ } catch (error) {
142
+ if (timeoutId) clearTimeout(timeoutId);
143
+ throw new DockerError(`${label} failed: ${errorMessage(error)}`);
144
+ }
145
+ }
146
+
147
+ async start() {
148
+ if (this.#container) return;
149
+ try {
150
+ let builder = new GenericContainer(this.#distro)
151
+ .withCommand([...SH_C, "tail -f /dev/null"])
152
+ .withBindMounts([
153
+ {
154
+ source: this.#artifactPath,
155
+ target: this.#mountTarget,
156
+ mode: "ro",
157
+ },
158
+ ])
159
+ .withEnvironment(this.#env);
160
+
161
+ const files = this.#configs.filter((c) => !c.isDir);
162
+ const directories = this.#configs.filter((c) => c.isDir);
163
+ if (files.length > 0) {
164
+ builder = builder.withCopyFilesToContainer(
165
+ files.map((c) => ({ source: c.source, target: c.target })),
166
+ );
167
+ }
168
+ if (directories.length > 0) {
169
+ builder = builder.withCopyDirectoriesToContainer(
170
+ directories.map((c) => ({ source: c.source, target: c.target })),
171
+ );
172
+ }
173
+
174
+ this.#container = await builder.start();
175
+
176
+ for (const command of this.#setupCommands) {
177
+ const result = await this.#execBounded([...SH_C, command], {}, NO_CAP);
178
+ if (result.exitCode !== 0) {
179
+ throw new DockerError(
180
+ `setup cmd failed for ${this.#distro}: ${command}\n${result.stderr || result.stdout}`,
181
+ );
182
+ }
183
+ }
184
+ } catch (error) {
185
+ if (this.#container) {
186
+ try {
187
+ await this.#container.stop();
188
+ // eslint-disable-next-line no-empty
189
+ } catch {}
190
+ this.#container = undefined;
191
+ }
192
+ if (error instanceof DockerError) throw error;
193
+ throw new DockerError(
194
+ `Failed to start container for ${this.#distro}: ${errorMessage(error)}`,
195
+ );
196
+ }
197
+ }
198
+
199
+ async exec(arguments_, options = {}) {
200
+ if (!this.#container) throw new DockerError(ERR_NOT_STARTED);
201
+ const {
202
+ env: environment = {},
203
+ timeout = NO_CAP,
204
+ cwd = BINARY_MOUNT_DIR,
205
+ } = options;
206
+ const command = [
207
+ this.#mountTarget,
208
+ ...(Array.isArray(arguments_) ? arguments_ : splitArguments(arguments_)),
209
+ ];
210
+ return this.#execBounded(
211
+ command,
212
+ { env: environment, workingDir: cwd },
213
+ timeout,
214
+ "exec",
215
+ );
216
+ }
217
+
218
+ async shell(command, options = {}) {
219
+ if (!this.#container) throw new DockerError(ERR_NOT_STARTED);
220
+ const { timeout = NO_CAP } = options;
221
+ return this.#execBounded([...SH_C, command], {}, timeout, "shell");
222
+ }
223
+
224
+ async copyFile(localPath, containerPath) {
225
+ if (!this.#container) throw new DockerError(ERR_NOT_STARTED);
226
+ const st = await stat(localPath);
227
+ const entry = { source: localPath, target: containerPath };
228
+ if (st.isDirectory()) {
229
+ await this.#container.copyDirectoriesToContainer([entry]);
230
+ } else {
231
+ await this.#container.copyFilesToContainer([entry]);
232
+ }
233
+ }
234
+
235
+ async stop() {
236
+ if (!this.#container) return;
237
+ await this.#container.stop();
238
+ this.#container = undefined;
239
+ }
240
+ }
@@ -0,0 +1,97 @@
1
+ import { realpath, stat } from "node:fs/promises";
2
+ import { basename, isAbsolute, relative, resolve } from "node:path";
3
+ import { join as joinPosix } from "node:path/posix";
4
+ import { UsageError } from "../utils/errors.mjs";
5
+
6
+ /**
7
+ * @typedef {{ source: string; target: string; isDir: boolean }} ResolvedConfig
8
+ */
9
+
10
+ function validateEntrySource(source_, destination) {
11
+ if (source_.startsWith("~")) {
12
+ throw new UsageError(
13
+ `fixture src "${source_}" must not start with "~" — scaffold it with: artisan add config ${source_}`,
14
+ );
15
+ }
16
+ if (isAbsolute(source_)) {
17
+ throw new UsageError(
18
+ `fixture src "${source_}" must be repo-relative, not absolute — copy it into ./fixtures/ first`,
19
+ );
20
+ }
21
+ if (destination !== undefined && !isAbsolute(destination)) {
22
+ throw new UsageError(
23
+ `dest "${destination}" must be an absolute container path`,
24
+ );
25
+ }
26
+ }
27
+
28
+ async function resolveEntrySource(source_, source, repoRoot) {
29
+ try {
30
+ const real = await realpath(source);
31
+ const relativeToRoot = relative(repoRoot, real);
32
+ if (relativeToRoot.startsWith("..") || isAbsolute(relativeToRoot)) {
33
+ throw new UsageError(
34
+ `fixture "${source_}" escapes the project root — copy it into ./fixtures/ first`,
35
+ );
36
+ }
37
+ const st = await stat(real);
38
+ return { real, st };
39
+ } catch (error) {
40
+ if (error instanceof UsageError) throw error;
41
+ if (error?.code === "ENOENT" || error?.code === "ENOTDIR") {
42
+ throw new UsageError(
43
+ `fixture "${source_}" not found at ${source} — scaffold it with: artisan add config ${source_}`,
44
+ );
45
+ }
46
+ throw error;
47
+ }
48
+ }
49
+
50
+ async function resolveEntry(entry, cwd, repoRoot, defaultDestinationDirectory) {
51
+ const source_ = typeof entry === "string" ? entry : entry.src;
52
+ const destination = typeof entry === "string" ? undefined : entry.dest;
53
+
54
+ validateEntrySource(source_, destination);
55
+
56
+ const source = resolve(cwd, source_);
57
+ const { real, st } = await resolveEntrySource(source_, source, repoRoot);
58
+
59
+ let target;
60
+ if (destination) {
61
+ target = destination;
62
+ } else if (st.isDirectory()) {
63
+ target = defaultDestinationDirectory;
64
+ } else {
65
+ target = joinPosix(defaultDestinationDirectory, basename(source_));
66
+ }
67
+
68
+ return { source: real, target, isDir: st.isDirectory() };
69
+ }
70
+
71
+ /**
72
+ * Validate and resolve config entries to absolute source/target pairs.
73
+ *
74
+ * @param {Array<string|{src: string, dest: string}>} configs
75
+ * @param {string} cwd - project root (absolute)
76
+ * @param {string} artifactName - name used as XDG sub-directory
77
+ * @param {string} [xdgBase='/root/.config'] - base for XDG config dir
78
+ * @returns {Promise<ResolvedConfig[]>}
79
+ */
80
+ export async function resolveConfigs(
81
+ configs,
82
+ cwd,
83
+ artifactName,
84
+ xdgBase = "/root/.config",
85
+ ) {
86
+ const defaultDestinationDirectory = joinPosix(xdgBase, artifactName);
87
+ const repoRoot = await realpath(cwd);
88
+ const resolved = [];
89
+
90
+ for (const entry of configs) {
91
+ resolved.push(
92
+ await resolveEntry(entry, cwd, repoRoot, defaultDestinationDirectory),
93
+ );
94
+ }
95
+
96
+ return resolved;
97
+ }
@@ -0,0 +1,83 @@
1
+ import { cp, mkdir, readdir, writeFile } from "node:fs/promises";
2
+ import { basename, join } from "node:path";
3
+
4
+ const SCRUB_PATTERNS = [
5
+ /^\.env$/,
6
+ /^\.env\..+$/,
7
+ /^credentials?$/,
8
+ /^creds$/,
9
+ /\.token$/,
10
+ /\.key$/,
11
+ /\.pem$/,
12
+ /^id_\w+$/,
13
+ /\.pfx$/,
14
+ /\.p12$/,
15
+ /^temp$/,
16
+ /^tmp$/,
17
+ /^backups?$/,
18
+ /^cache$/,
19
+ /^\.cache$/,
20
+ ];
21
+
22
+ function shouldScrub(name) {
23
+ return SCRUB_PATTERNS.some((p) => p.test(name));
24
+ }
25
+
26
+ export async function sanitizeConfigDirectory(
27
+ sourceDirectory,
28
+ destinationDirectory,
29
+ options = {},
30
+ ) {
31
+ const { raw = false } = options;
32
+ const copied = [];
33
+ const scrubbed = [];
34
+
35
+ await mkdir(destinationDirectory, { recursive: true });
36
+
37
+ if (raw) {
38
+ await cp(sourceDirectory, destinationDirectory, { recursive: true });
39
+ return { copied: [], scrubbed: [], manifestPath: undefined, raw: true };
40
+ }
41
+
42
+ await copyFiltered(sourceDirectory, destinationDirectory, "");
43
+
44
+ const manifestPath = `${destinationDirectory}.artisan-sanitized`;
45
+ await writeFile(
46
+ manifestPath,
47
+ JSON.stringify(
48
+ {
49
+ scaffoldedFrom: basename(sourceDirectory),
50
+ scaffoldedAt: new Date().toISOString(),
51
+ scrubbed,
52
+ note: "Re-scaffold with: artisan add config <source>",
53
+ },
54
+ undefined,
55
+ 2,
56
+ ),
57
+ );
58
+
59
+ return { copied, scrubbed, manifestPath, raw: false };
60
+
61
+ async function copyFiltered(source, destination, relativePath) {
62
+ const entries = await readdir(source, { withFileTypes: true });
63
+ entries.sort((a, b) => a.name.localeCompare(b.name));
64
+ for (const entry of entries) {
65
+ const childRelative = relativePath
66
+ ? `${relativePath}/${entry.name}`
67
+ : entry.name;
68
+ const sourcePath = join(source, entry.name);
69
+ const destinationPath = join(destination, entry.name);
70
+ if (shouldScrub(entry.name) || entry.isSymbolicLink()) {
71
+ scrubbed.push(childRelative);
72
+ continue;
73
+ }
74
+ if (entry.isDirectory()) {
75
+ await mkdir(destinationPath, { recursive: true });
76
+ await copyFiltered(sourcePath, destinationPath, childRelative);
77
+ } else {
78
+ await cp(sourcePath, destinationPath);
79
+ copied.push(childRelative);
80
+ }
81
+ }
82
+ }
83
+ }
@@ -0,0 +1,28 @@
1
+ export class ArtisanError extends Error {
2
+ constructor(message, exitCode = 1) {
3
+ super(message);
4
+ this.name = "ArtisanError";
5
+ this.exitCode = exitCode;
6
+ }
7
+ }
8
+
9
+ export class UsageError extends ArtisanError {
10
+ constructor(message) {
11
+ super(message, 2);
12
+ this.name = "UsageError";
13
+ }
14
+ }
15
+
16
+ export class DockerError extends ArtisanError {
17
+ constructor(message) {
18
+ super(message, 125);
19
+ this.name = "DockerError";
20
+ }
21
+ }
22
+
23
+ export class InterruptError extends ArtisanError {
24
+ constructor(message = "Interrupted") {
25
+ super(message, 130);
26
+ this.name = "InterruptError";
27
+ }
28
+ }
@@ -0,0 +1,11 @@
1
+ import { access } from "node:fs/promises";
2
+
3
+ export async function fileExists(path) {
4
+ try {
5
+ await access(path);
6
+ return true;
7
+ } catch (error) {
8
+ if (error?.code === "ENOENT" || error?.code === "ENOTDIR") return false;
9
+ throw error;
10
+ }
11
+ }
@@ -0,0 +1,5 @@
1
+ import fg from "fast-glob";
2
+
3
+ export async function resolveTestFiles(pattern, cwd) {
4
+ return fg(pattern, { cwd, absolute: true });
5
+ }
@@ -0,0 +1,9 @@
1
+ import { homedir } from "node:os";
2
+ import { isAbsolute, join, resolve } from "node:path";
3
+
4
+ export function expandUserPath(path, cwd = process.cwd()) {
5
+ if (path === "~") return homedir();
6
+ if (path.startsWith("~/")) return join(homedir(), path.slice(2));
7
+ if (isAbsolute(path)) return path;
8
+ return resolve(cwd, path);
9
+ }
@@ -0,0 +1,15 @@
1
+ import { readFile } from "node:fs/promises";
2
+ import { dirname, join } from "node:path";
3
+ import { fileURLToPath } from "node:url";
4
+
5
+ export function resolveTemplatesDirectory(importMetaUrl, relativePath) {
6
+ return join(dirname(fileURLToPath(importMetaUrl)), relativePath);
7
+ }
8
+
9
+ export async function readTemplate(templatesDirectory, name) {
10
+ return readFile(join(templatesDirectory, name), "utf8");
11
+ }
12
+
13
+ export function renderTemplate(tmpl, variables) {
14
+ return tmpl.replaceAll(/\{\{(\w+)\}\}/g, (_, key) => variables[key] ?? "");
15
+ }
@@ -0,0 +1,13 @@
1
+ import { UsageError } from "./errors.mjs";
2
+
3
+ export function validateSinglePathName(name, label) {
4
+ if (
5
+ !name ||
6
+ name === "." ||
7
+ name === ".." ||
8
+ name.includes("/") ||
9
+ name.includes("\\")
10
+ ) {
11
+ throw new UsageError(`--name must be a single ${label}, not a path`);
12
+ }
13
+ }
@@ -0,0 +1,9 @@
1
+ {
2
+ "artifact": "{{ARTIFACT}}",
3
+ "distros": [{{DISTROS}}],
4
+ "testMatch": "**/*.artisan.test.mjs",
5
+ "parallel": true,
6
+ "setup": {},
7
+ "configs": [],
8
+ "reporter": "default"
9
+ }
@@ -0,0 +1,7 @@
1
+ import { test, expect } from '@peachlife/artisan';
2
+
3
+ test('{{TEST_NAME}}', async ({ run, copyFixture, setup }) => {
4
+ const { stdout, stderr, exitCode } = await run('');
5
+
6
+ expect(exitCode).toBe(0);
7
+ });
@@ -0,0 +1,8 @@
1
+ import { test, expect } from '@peachlife/artisan';
2
+
3
+ test('CLI help shows usage', async ({ run }) => {
4
+ const { stdout, exitCode } = await run('--help');
5
+
6
+ expect(exitCode).toBe(0);
7
+ expect(stdout).toContain('Usage');
8
+ });
@@ -0,0 +1,8 @@
1
+ import { test, expect } from '@peachlife/artisan';
2
+
3
+ test('CLI outputs correct version', async ({ run }) => {
4
+ const { stdout, exitCode } = await run('--version');
5
+
6
+ expect(exitCode).toBe(0);
7
+ expect(stdout).toMatch(/\d+\.\d+\.\d+/);
8
+ });
@@ -0,0 +1,9 @@
1
+ import { defineConfig } from "vitest/config";
2
+
3
+ export default defineConfig({
4
+ test: {
5
+ include: ["**/*.artisan.test.mjs"],
6
+ exclude: ["**/node_modules/**", "**/.review/**"],
7
+ testTimeout: 300_000,
8
+ },
9
+ });