@42wp/dev-env 1.0.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 João Carvalho
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,202 @@
1
+ # 42wp — 42WP dev environments
2
+
3
+ > *O Guia do Mochileiro do WordPress* — a tiny CLI that spins up disposable
4
+ > WordPress dev environments with Docker, the 42WP way.
5
+
6
+ `42wp` runs a shared **global layer** (Traefik reverse proxy + MySQL + phpMyAdmin +
7
+ Mailpit) and, on top of it, **per-project** WordPress containers reachable at
8
+ `http://<project>.localhost`. Your current working directory is mounted as
9
+ `wp-content`, so you develop your theme/plugin locally and WordPress runs in a
10
+ container with Xdebug, Memcached and WP-CLI preinstalled.
11
+
12
+ ## Requirements
13
+
14
+ - [Docker](https://docs.docker.com/get-docker/) with the Compose v2 plugin (`docker compose`)
15
+ - Node.js **>= 18.17**
16
+
17
+ ## Install
18
+
19
+ ```bash
20
+ npm install -g @42wp/dev-env
21
+ ```
22
+
23
+ This installs the `42wp` command globally.
24
+
25
+ ## Usage
26
+
27
+ ```bash
28
+ 42wp global start # start Traefik + MySQL + phpMyAdmin + Mailpit
29
+ 42wp start <project> # build & start a project (run from your theme/plugin repo)
30
+ 42wp update <project> # update an existing project to a newer WordPress image
31
+ 42wp wp <project> <args> # run a WP-CLI command inside the project container
32
+ 42wp stop <project> # stop a project's containers
33
+ 42wp rm <project> # remove a site (container, image, database) — keeps your repo
34
+ 42wp seed <project> [n] # generate demo content in a running project (default 200)
35
+ 42wp global stop # tear the global layer down
36
+ ```
37
+
38
+ Run `42wp` with no arguments for the full command list.
39
+
40
+ ### WordPress version
41
+
42
+ By default projects are built on **`wordpress:php8.4-apache`** (newest WordPress,
43
+ PHP 8.4, Apache). Pick a different image tag per project with `--wp <tag>`:
44
+
45
+ ```bash
46
+ 42wp start my-theme --wp latest # newest WP, image's default PHP
47
+ 42wp start legacy --wp 6.9-php8.5-apache # pin a specific WP + PHP
48
+ 42wp update my-theme # rebuild on the default tag, run wp core update-db
49
+ 42wp update my-theme --wp php8.5-apache # update to a specific image
50
+ ```
51
+
52
+ `update` rewrites the project's Dockerfile, re-pulls the base image, recreates the
53
+ container and runs `wp core update-db` to migrate the schema. Set a different
54
+ default for every project with the `FORTYTWO_WP_TAG` env var.
55
+
56
+ ### Removing a site
57
+
58
+ `42wp rm <project>` tears a site down completely — its containers, the locally
59
+ built image, and its database — and deletes the generated project dir
60
+ (`~/.42wp/projects/<name>/`, including any cloned VIP mu-plugins). **Your
61
+ repository is never touched**: wp-content is a bind mount of your working
62
+ directory, which Docker leaves alone. It asks for confirmation; skip the prompt
63
+ with `--yes` (or `--force`):
64
+
65
+ ```bash
66
+ 42wp rm my-theme # prompts before removing
67
+ 42wp rm my-theme --yes # no prompt (for scripts/CI)
68
+ ```
69
+
70
+ ### Demo content
71
+
72
+ Seed a project with a full demo dataset using `--demo-content` (defaults to 200
73
+ posts), or set a count with `--demo-content=<n>`:
74
+
75
+ ```bash
76
+ 42wp start my-site --demo-content # ~200 posts
77
+ 42wp start my-site --demo-content=50 # 50 posts
78
+ ```
79
+
80
+ It runs a PHP seeder (`wp eval-file`) — no plugin required (FakerPress has no CLI) —
81
+ that creates:
82
+
83
+ - ~10 `42wp_author` terms (Portuguese names), ~10 tags, ~10 categories
84
+ - a small pool of real images downloaded from [picsum.photos](https://picsum.photos/),
85
+ reused as featured + inline images
86
+ - posts with rich HTML content (h1/h2/h3, lists, blockquote, images, links), a
87
+ plain-text excerpt, varied **past** dates, and each linked to 1 category, 2–5
88
+ tags and 1–3 authors
89
+
90
+ Works on any project (not just `--vip`). It always creates the requested number of
91
+ posts (it doesn't skip when posts already exist), and a failure never aborts
92
+ `start`. The author terms use the `42wp_author` taxonomy, which the project
93
+ registers itself (e.g. the `42-framework` mu-plugin) — the seeder does not register
94
+ any taxonomy. If `42wp_author` isn't available, the author links are simply skipped.
95
+
96
+ To add demo content to an **already-running** project without recreating it, use
97
+ `seed`:
98
+
99
+ ```bash
100
+ 42wp seed my-site # 200 posts
101
+ 42wp seed my-site 500 # 500 posts
102
+ 42wp seed my-site --demo-content=50
103
+ ```
104
+
105
+ ### Admin credentials
106
+
107
+ The silent install creates an `admin` / `password` user by default. Override per
108
+ project on `start`:
109
+
110
+ ```bash
111
+ 42wp start my-theme --user joao --pass s3cr3t
112
+ ```
113
+
114
+ ### Multisite
115
+
116
+ Start a site as a WordPress network with `--multisite` (subdirectory) or add
117
+ `--subdomains` for a subdomain network:
118
+
119
+ ```bash
120
+ 42wp start my-network --multisite # sites at my-network.localhost/site2
121
+ 42wp start my-network --multisite --subdomains # sites at site2.my-network.localhost
122
+ ```
123
+
124
+ This installs WordPress, converts it to a network, writes the `MULTISITE` constants
125
+ into `wp-config.php`, and mounts a `.htaccess` with the matching network rewrite
126
+ rules (WP-CLI refuses to generate those for a multisite, which otherwise causes a
127
+ redirect loop on sub-sites). For **subdomain** networks the Traefik router is
128
+ widened to a wildcard (`*.my-network.localhost`) so sub-sites route to the
129
+ container; `*.localhost` already resolves to `127.0.0.1`, so no `/etc/hosts` edits
130
+ are needed. Network admin lives at `…/wp-admin/network/`.
131
+
132
+ ### WordPress VIP (mu-plugins)
133
+
134
+ WPVIP sites expect the VIP Go mu-plugins under `wp-content/mu-plugins`. Pass
135
+ `--vip` to `start` and the tool clones
136
+ [`vip-go-mu-plugins-built`](https://github.com/Automattic/vip-go-mu-plugins-built)
137
+ (shallow) and mounts it into the container at `wp-content/mu-plugins`:
138
+
139
+ ```bash
140
+ 42wp start my-vip-site --vip
141
+ ```
142
+
143
+ The clone lives in the project's data dir (`~/.42wp/projects/<name>/mu-plugins`),
144
+ so it never touches your repo, and re-running `start` fast-forwards it. Regular
145
+ (non-VIP) projects are unaffected.
146
+
147
+ `--vip` also mounts a small **dev-helper mu-plugin** (`00-42wp-dev.php`) that loads
148
+ the WordPress admin media includes in admin/REST/CLI contexts. This fixes
149
+ "Call to undefined function `media_handle_sideload()`"-style errors from plugins
150
+ like FakerPress, which the VIP environment triggers by loading `file.php` early.
151
+ It's mounted from the data dir, so your repo and the cloned VIP suite stay clean.
152
+
153
+ ### Example
154
+
155
+ ```bash
156
+ cd ~/code/my-theme
157
+ 42wp global start
158
+ 42wp start my-theme
159
+ # → http://my-theme.localhost (admin / password at /wp-admin)
160
+ 42wp wp my-theme plugin list
161
+ 42wp stop my-theme
162
+ ```
163
+
164
+ Shared services once the global layer is up:
165
+
166
+ | Service | URL |
167
+ | ----------- | ---------------------------- |
168
+ | Traefik | http://localhost:8080 |
169
+ | phpMyAdmin | http://db.42wp.localhost |
170
+ | Mailpit | http://mail.42wp.localhost |
171
+
172
+ ## How it works
173
+
174
+ State lives under `~/.42wp` (override with `FORTYTWO_HOME`):
175
+
176
+ ```
177
+ ~/.42wp/
178
+ docker-compose.global.yml # the global layer (created on first run)
179
+ mysql-data/ # persisted MySQL data
180
+ projects/<project>/ # generated wp-config.php, Dockerfile, docker-compose.yml
181
+ ```
182
+
183
+ `42wp start` generates an ephemeral `wp-config.php` (fetching real salts from
184
+ api.wordpress.org, with a local fallback when offline), a `Dockerfile`, and a
185
+ `docker-compose.yml`, then builds the container and runs a silent
186
+ `wp core install`. The DB name is the project name with hyphens turned into
187
+ underscores; a trailing `.suffix` (e.g. `.localhost`) is stripped.
188
+
189
+ ## Configuration
190
+
191
+ | Env var | Default | Description |
192
+ | --------------- | -------------- | --------------------------------------------- |
193
+ | `FORTYTWO_HOME` | `~/.42wp` | Where the global compose, DB data and projects live |
194
+ | `FORTYTWO_WP_TAG` | `php8.4-apache` | Default WordPress image tag for new projects (overridden by `--wp`) |
195
+ | `FORTYTWO_LANG` | auto (`LANG`) | UI language: `en` or `pt` |
196
+ | `NO_COLOR` | unset | Disable colored output |
197
+
198
+ You can also pass `--lang en` / `--lang pt` before the command.
199
+
200
+ ## License
201
+
202
+ MIT
package/bin/42wp.js ADDED
@@ -0,0 +1,10 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { run } from '../src/cli.js';
4
+ import { error } from '../src/lib/log.js';
5
+
6
+ run(process.argv.slice(2)).catch((err) => {
7
+ // Top-level safety net: anything that bubbles up here is unexpected.
8
+ error(err && err.message ? err.message : String(err));
9
+ process.exitCode = 1;
10
+ });
package/package.json ADDED
@@ -0,0 +1,42 @@
1
+ {
2
+ "name": "@42wp/dev-env",
3
+ "version": "1.0.0",
4
+ "description": "42WP — a CLI to spin up disposable WordPress dev environments with Docker (Traefik + MySQL + per-project containers).",
5
+ "type": "module",
6
+ "bin": {
7
+ "42wp": "bin/42wp.js"
8
+ },
9
+ "files": [
10
+ "bin",
11
+ "src"
12
+ ],
13
+ "engines": {
14
+ "node": ">=18.17"
15
+ },
16
+ "scripts": {
17
+ "test": "node --test",
18
+ "prepublishOnly": "node --test"
19
+ },
20
+ "repository": {
21
+ "type": "git",
22
+ "url": "git+https://github.com/42wp/dev-env.git"
23
+ },
24
+ "bugs": {
25
+ "url": "https://github.com/42wp/dev-env/issues"
26
+ },
27
+ "homepage": "https://github.com/42wp/dev-env#readme",
28
+ "keywords": [
29
+ "wordpress",
30
+ "docker",
31
+ "cli",
32
+ "dev-environment",
33
+ "traefik",
34
+ "wp-cli",
35
+ "42wp"
36
+ ],
37
+ "author": "João Carvalho <joao@carvalho.cc>",
38
+ "license": "MIT",
39
+ "publishConfig": {
40
+ "access": "public"
41
+ }
42
+ }
package/src/cli.js ADDED
@@ -0,0 +1,119 @@
1
+ // Argument parsing and command dispatch — mirrors the `case "$1"` block in 42script.sh.
2
+ // A leading --lang / --lang=<locale> overrides the UI language; it's consumed before
3
+ // dispatch so it never leaks into WP-CLI arguments passed through `42wp wp`.
4
+
5
+ import { setLocale, t } from './lib/i18n.js';
6
+ import { plain, error } from './lib/log.js';
7
+ import { start } from './commands/start.js';
8
+ import { stop } from './commands/stop.js';
9
+ import { wp } from './commands/wp.js';
10
+ import { update } from './commands/update.js';
11
+ import { rm } from './commands/rm.js';
12
+ import { seed } from './commands/seed.js';
13
+ import { startGlobal, stopGlobal } from './commands/global.js';
14
+
15
+ function extractLang(argv) {
16
+ let lang;
17
+ while (argv.length && argv[0].startsWith('--lang')) {
18
+ const flag = argv.shift();
19
+ if (flag.includes('=')) lang = flag.slice(flag.indexOf('=') + 1);
20
+ else lang = argv.shift(); // value is the next token
21
+ }
22
+ return lang;
23
+ }
24
+
25
+ // Flags that take a value (--wp tag, --user bob, --pass secret). Anything else
26
+ // (e.g. --vip) is a boolean. The `--key=value` form works for all of them.
27
+ const VALUE_FLAGS = new Set(['wp', 'user', 'pass']);
28
+
29
+ // Pull options out of an arg list, leaving positionals behind. Used by `start`
30
+ // and `update`; NOT by `wp`, whose args pass through verbatim to WP-CLI.
31
+ export function parseOpts(args) {
32
+ const opts = {};
33
+ const positionals = [];
34
+ for (let i = 0; i < args.length; i++) {
35
+ const a = args[i];
36
+ if (a.startsWith('--')) {
37
+ const eq = a.indexOf('=');
38
+ if (eq !== -1) {
39
+ opts[a.slice(2, eq)] = a.slice(eq + 1);
40
+ } else {
41
+ const key = a.slice(2);
42
+ if (VALUE_FLAGS.has(key)) opts[key] = args[++i];
43
+ else opts[key] = true; // boolean flag
44
+ }
45
+ } else {
46
+ positionals.push(a);
47
+ }
48
+ }
49
+ return { opts, positionals };
50
+ }
51
+
52
+ function printUsage() {
53
+ plain(t('usage.line'));
54
+ plain('');
55
+ plain(t('usage.commands'));
56
+ plain(t('usage.start'));
57
+ plain(t('usage.update'));
58
+ plain(t('usage.stop'));
59
+ plain(t('usage.rm'));
60
+ plain(t('usage.seed'));
61
+ plain(t('usage.wp'));
62
+ plain(t('usage.globalStart'));
63
+ plain(t('usage.globalStop'));
64
+ }
65
+
66
+ export async function run(rawArgs) {
67
+ const argv = [...rawArgs];
68
+ const lang = extractLang(argv);
69
+ setLocale(lang);
70
+
71
+ const command = argv[0];
72
+
73
+ switch (command) {
74
+ case 'start': {
75
+ const { opts, positionals } = parseOpts(argv.slice(1));
76
+ await start(positionals[0], opts);
77
+ break;
78
+ }
79
+
80
+ case 'update': {
81
+ const { opts, positionals } = parseOpts(argv.slice(1));
82
+ await update(positionals[0], opts);
83
+ break;
84
+ }
85
+
86
+ case 'stop':
87
+ await stop(argv[1]);
88
+ break;
89
+
90
+ case 'rm': {
91
+ const { opts, positionals } = parseOpts(argv.slice(1));
92
+ await rm(positionals[0], opts);
93
+ break;
94
+ }
95
+
96
+ case 'seed': {
97
+ const { opts, positionals } = parseOpts(argv.slice(1));
98
+ await seed(positionals[0], opts, positionals[1]);
99
+ break;
100
+ }
101
+
102
+ case 'wp':
103
+ await wp(argv[1], argv.slice(2));
104
+ break;
105
+
106
+ case 'global':
107
+ if (argv[1] === 'start') await startGlobal();
108
+ else if (argv[1] === 'stop') await stopGlobal();
109
+ else {
110
+ error(t('global.invalid'));
111
+ process.exitCode = 1;
112
+ }
113
+ break;
114
+
115
+ default:
116
+ printUsage();
117
+ process.exitCode = 1;
118
+ }
119
+ }
@@ -0,0 +1,62 @@
1
+ // `42wp global start|stop` and the shared global-layer bootstrap used by `start`.
2
+ // The global layer is Traefik + MySQL + phpMyAdmin + Mailpit on the 42wp_network.
3
+
4
+ import {
5
+ dockerIsRunning,
6
+ composeCapture,
7
+ compose,
8
+ dockerExecCapture,
9
+ } from '../lib/docker.js';
10
+ import { ensureGlobalCompose } from '../lib/render.js';
11
+ import { globalComposePath } from '../lib/paths.js';
12
+ import { pollUntil } from '../lib/wait.js';
13
+ import { step, error } from '../lib/log.js';
14
+ import { t } from '../lib/i18n.js';
15
+
16
+ // Exit early with a clear message if the Docker daemon isn't reachable.
17
+ export async function ensureDockerRunning() {
18
+ if (!(await dockerIsRunning())) {
19
+ error(t('docker.notRunning'));
20
+ process.exitCode = 1;
21
+ return false;
22
+ }
23
+ return true;
24
+ }
25
+
26
+ async function mysqlReady() {
27
+ const { code } = await dockerExecCapture('42wp_mysql', [
28
+ 'mysqladmin',
29
+ 'ping',
30
+ '-uroot',
31
+ '-proot',
32
+ '--silent',
33
+ ]);
34
+ return code === 0;
35
+ }
36
+
37
+ // Bring the global layer up if it isn't already running, then wait for MySQL.
38
+ // Replaces the bash `check_global_layer` (which used a fixed `sleep 10`).
39
+ export async function checkGlobalLayer() {
40
+ const file = await ensureGlobalCompose();
41
+ const { stdout } = await composeCapture(['-f', file, 'ps', '-q']);
42
+ if (stdout.length === 0) {
43
+ step(t('global.starting'));
44
+ await compose(['-f', file, 'up', '-d']);
45
+ step(t('global.waitingMysql'));
46
+ await pollUntil(mysqlReady, { label: 'MySQL', timeout: 90000 });
47
+ }
48
+ }
49
+
50
+ export async function startGlobal() {
51
+ if (!(await ensureDockerRunning())) return;
52
+ const file = await ensureGlobalCompose();
53
+ step(t('global.starting'));
54
+ await compose(['-f', file, 'up', '-d']);
55
+ }
56
+
57
+ export async function stopGlobal() {
58
+ if (!(await ensureDockerRunning())) return;
59
+ const file = globalComposePath();
60
+ step(t('global.stopping'));
61
+ await compose(['-f', file, 'down']);
62
+ }
@@ -0,0 +1,82 @@
1
+ // `42wp rm <project> [--yes]` — remove a site from the dev env.
2
+ //
3
+ // Removes the project's containers + locally-built image, drops its database, and
4
+ // deletes the generated project dir (~/.42wp/projects/<name>/, including any cloned
5
+ // VIP mu-plugins). It does NOT touch your repository: wp-content is a bind mount of
6
+ // your working directory, which `docker compose down` leaves untouched.
7
+
8
+ import fs from 'node:fs/promises';
9
+ import path from 'node:path';
10
+
11
+ import { normalizeName, validateName, dbName } from '../lib/naming.js';
12
+ import { projectDir } from '../lib/paths.js';
13
+ import { compose, dockerExec, containerIsRunning } from '../lib/docker.js';
14
+ import { confirm } from '../lib/prompt.js';
15
+ import { step, success, plain, error } from '../lib/log.js';
16
+ import { t } from '../lib/i18n.js';
17
+ import { ensureDockerRunning } from './global.js';
18
+
19
+ export async function rm(rawName, opts = {}) {
20
+ if (!rawName) {
21
+ error(t('rm.needName'));
22
+ process.exitCode = 1;
23
+ return;
24
+ }
25
+
26
+ const name = normalizeName(rawName);
27
+ validateName(name);
28
+ const db = dbName(name);
29
+ const envDir = projectDir(name);
30
+
31
+ // Must be a known project.
32
+ try {
33
+ await fs.access(envDir);
34
+ } catch {
35
+ error(t('rm.notFound', { name, dir: envDir }));
36
+ process.exitCode = 1;
37
+ return;
38
+ }
39
+
40
+ // Confirm unless --yes/--force. In a non-interactive shell we refuse rather
41
+ // than guess.
42
+ const skip = opts.yes || opts.force;
43
+ if (!skip) {
44
+ if (!process.stdin.isTTY) {
45
+ error(t('rm.needYes'));
46
+ process.exitCode = 1;
47
+ return;
48
+ }
49
+ const ok = await confirm(t('rm.confirm', { name }));
50
+ if (!ok) {
51
+ plain(t('rm.cancelled'));
52
+ return;
53
+ }
54
+ }
55
+
56
+ if (!(await ensureDockerRunning())) return;
57
+
58
+ // 1. Stop & remove containers + the image built for this project.
59
+ step(t('rm.removingContainers', { name }));
60
+ await compose(['down', '--rmi', 'local', '--remove-orphans'], { cwd: envDir });
61
+
62
+ // 2. Drop the database (only possible while the global MySQL is up).
63
+ if (await containerIsRunning('42wp_mysql')) {
64
+ step(t('rm.droppingDb', { db }));
65
+ await dockerExec('42wp_mysql', [
66
+ 'mysql',
67
+ '-uroot',
68
+ '-proot',
69
+ '-e',
70
+ `DROP DATABASE IF EXISTS \`${db}\`;`,
71
+ ]);
72
+ } else {
73
+ step(t('rm.mysqlDown', { db }));
74
+ }
75
+
76
+ // 3. Delete the generated project dir (config, Dockerfile, compose, cloned
77
+ // mu-plugins). The repo bind-mounted as wp-content is never inside here.
78
+ step(t('rm.removingData', { dir: envDir }));
79
+ await fs.rm(envDir, { recursive: true, force: true });
80
+
81
+ success(t('rm.done', { name }));
82
+ }
@@ -0,0 +1,42 @@
1
+ // `42wp seed <project> [<count>] [--demo-content=<n>]` — generate demo content in
2
+ // an already-running project, without recreating the container. Always creates the
3
+ // requested number of posts (default 200).
4
+
5
+ import { normalizeName, validateName, dbName, containerName } from '../lib/naming.js';
6
+ import { containerIsRunning } from '../lib/docker.js';
7
+ import { runSeeder } from '../lib/seeder.js';
8
+ import { resolveDemoCount, DEFAULT_DEMO_COUNT } from '../lib/config.js';
9
+ import { step, error } from '../lib/log.js';
10
+ import { t } from '../lib/i18n.js';
11
+ import { ensureDockerRunning } from './global.js';
12
+
13
+ export async function seed(rawName, opts = {}, countArg) {
14
+ if (!rawName) {
15
+ error(t('seed.needName'));
16
+ process.exitCode = 1;
17
+ return;
18
+ }
19
+
20
+ const name = normalizeName(rawName);
21
+ validateName(name);
22
+ const container = containerName(dbName(name));
23
+
24
+ if (!(await ensureDockerRunning())) return;
25
+
26
+ if (!(await containerIsRunning(container))) {
27
+ error(t('seed.notRunning', { name }));
28
+ process.exitCode = 1;
29
+ return;
30
+ }
31
+
32
+ // Count from a positional arg or --demo-content=<n>; default 200.
33
+ const count = resolveDemoCount(countArg ?? opts['demo-content']) || DEFAULT_DEMO_COUNT;
34
+
35
+ step(t('start.demoGenerating', { count }));
36
+ try {
37
+ await runSeeder(container, count);
38
+ } catch {
39
+ error(t('seed.failed'));
40
+ process.exitCode = 1;
41
+ }
42
+ }