@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.
@@ -0,0 +1,236 @@
1
+ // `42wp start <project>` — port of start_project from 42script.sh.
2
+ // Creates the DB, generates wp-config.php / Dockerfile / docker-compose.yml,
3
+ // builds & starts the project containers, then runs a silent WordPress install.
4
+
5
+ import fs from 'node:fs/promises';
6
+ import path from 'node:path';
7
+
8
+ import {
9
+ normalizeName,
10
+ validateName,
11
+ dbName,
12
+ domain,
13
+ containerName,
14
+ routerRule,
15
+ } from '../lib/naming.js';
16
+ import { projectDir } from '../lib/paths.js';
17
+ import { renderTemplate, render, readTemplate } from '../lib/render.js';
18
+ import { fetchSalts } from '../lib/salts.js';
19
+ import { run, compose, dockerExec, dockerExecCapture } from '../lib/docker.js';
20
+ import { runSeeder } from '../lib/seeder.js';
21
+ import { pollUntil } from '../lib/wait.js';
22
+ import { step, success, plain, error, colors } from '../lib/log.js';
23
+ import { t } from '../lib/i18n.js';
24
+ import {
25
+ resolveWpTag,
26
+ wpImage,
27
+ multisiteConfig,
28
+ resolveDemoCount,
29
+ DEFAULT_ADMIN_USER,
30
+ DEFAULT_ADMIN_PASS,
31
+ VIP_MU_PLUGINS_REPO,
32
+ } from '../lib/config.js';
33
+ import { ensureDockerRunning, checkGlobalLayer } from './global.js';
34
+
35
+ const ADMIN_EMAIL = 'dev@42wp.localhost';
36
+
37
+ // Clone (or update) the VIP mu-plugins into <envDir>/mu-plugins; it gets mounted
38
+ // into the container at wp-content/mu-plugins. Shallow + single-branch — the
39
+ // prebuilt repo is large and we only need the working tree.
40
+ async function ensureVipMuPlugins(envDir) {
41
+ const muDir = path.join(envDir, 'mu-plugins');
42
+ try {
43
+ await fs.access(path.join(muDir, '.git'));
44
+ step(t('start.vipUpdating'));
45
+ await run('git', ['-C', muDir, 'pull', '--ff-only']);
46
+ } catch {
47
+ step(t('start.vipCloning', { repo: VIP_MU_PLUGINS_REPO }));
48
+ await run('git', ['clone', '--depth', '1', '--single-branch', VIP_MU_PLUGINS_REPO, muDir]);
49
+ }
50
+ }
51
+
52
+ export async function start(rawName, opts = {}) {
53
+ if (!rawName) {
54
+ error(t('start.needName'));
55
+ process.exitCode = 1;
56
+ return;
57
+ }
58
+
59
+ // The repo the user is sitting in becomes the container's wp-content.
60
+ const repoDir = process.cwd();
61
+
62
+ const name = normalizeName(rawName);
63
+ validateName(name);
64
+
65
+ const dom = domain(name);
66
+ const db = dbName(name);
67
+ const container = containerName(db);
68
+ const envDir = projectDir(name);
69
+ const adminUser = opts.user || DEFAULT_ADMIN_USER;
70
+ const adminPass = opts.pass || DEFAULT_ADMIN_PASS;
71
+ // --subdomains implies multisite; without it, --multisite is subdirectory mode.
72
+ const subdomains = !!opts.subdomains;
73
+ const multisite = !!opts.multisite || subdomains;
74
+ const demoCount = resolveDemoCount(opts['demo-content']); // null when not requested
75
+
76
+ if (!(await ensureDockerRunning())) return;
77
+ await checkGlobalLayer();
78
+
79
+ step(t('start.preparing', { domain: dom }));
80
+ await fs.mkdir(envDir, { recursive: true });
81
+
82
+ // 1. Database on the global MySQL.
83
+ step(t('start.creatingDb', { db }));
84
+ await dockerExec('42wp_mysql', [
85
+ 'mysql',
86
+ '-uroot',
87
+ '-proot',
88
+ '-e',
89
+ `CREATE DATABASE IF NOT EXISTS \`${db}\`;`,
90
+ ]);
91
+
92
+ // 2. Ephemeral wp-config.php. The multisite constants are added later (step 7b),
93
+ // AFTER the network tables exist — defining MULTISITE before they're created
94
+ // would break wp-cli's bootstrap. For now write it without them.
95
+ step(t('start.genConfig'));
96
+ const { salts, fromFallback } = await fetchSalts();
97
+ if (fromFallback) step(t('start.saltsFallback'));
98
+ const wpConfigPath = path.join(envDir, 'wp-config.php');
99
+ const wpConfigTemplate = await readTemplate('wp-config.php');
100
+ const writeWpConfig = (multisiteBlock) =>
101
+ fs.writeFile(
102
+ wpConfigPath,
103
+ render(wpConfigTemplate, { DB_NAME: db, WP_SALTS: salts, MULTISITE_CONFIG: multisiteBlock }),
104
+ 'utf8',
105
+ );
106
+ await writeWpConfig('');
107
+
108
+ // 3. Dockerfile (FROM the resolved WordPress image tag).
109
+ const wpTag = resolveWpTag(opts.wp);
110
+ step(t('start.genDockerfile', { image: wpImage(wpTag) }));
111
+ const dockerfile = await renderTemplate('project.Dockerfile', { WP_TAG: wpTag });
112
+ await fs.writeFile(path.join(envDir, 'Dockerfile'), dockerfile, 'utf8');
113
+
114
+ // 4. Project docker-compose.yml. Extra bind mounts: VIP mu-plugins (--vip) and a
115
+ // multisite .htaccess (wp-cli won't write network rewrite rules itself).
116
+ step(t('start.genCompose'));
117
+ const extraVolumeLines = [];
118
+ if (opts.vip) {
119
+ extraVolumeLines.push(' - ./mu-plugins:/var/www/html/wp-content/mu-plugins');
120
+ // Dev-helper mu-plugin (loads admin media includes). Mounted as a top-level
121
+ // mu-plugin so WordPress auto-loads it; the 00- prefix loads it first.
122
+ extraVolumeLines.push(
123
+ ' - ./dev-helper.php:/var/www/html/wp-content/mu-plugins/00-42wp-dev.php',
124
+ );
125
+ }
126
+ if (multisite) extraVolumeLines.push(' - ./.htaccess:/var/www/html/.htaccess');
127
+ const extraVolumes = extraVolumeLines.length ? `\n${extraVolumeLines.join('\n')}` : '';
128
+ const projectCompose = await renderTemplate('project.docker-compose.yml', {
129
+ DB_NAME: db,
130
+ DOMAIN: dom,
131
+ REPO_DIR: repoDir,
132
+ EXTRA_VOLUMES: extraVolumes,
133
+ // docker compose treats $ as interpolation, so a literal $ (the regex anchor)
134
+ // must be written as $$.
135
+ ROUTER_RULE: routerRule(dom, { subdomains }).split('$').join('$$'),
136
+ });
137
+ await fs.writeFile(path.join(envDir, 'docker-compose.yml'), projectCompose, 'utf8');
138
+
139
+ // 4b. Multisite needs custom rewrite rules; provide them via a mounted .htaccess
140
+ // (written before `up` so the bind-mount source exists).
141
+ if (multisite) {
142
+ const htTemplate = subdomains ? 'htaccess.multisite-subdomain' : 'htaccess.multisite-subdir';
143
+ await fs.writeFile(path.join(envDir, '.htaccess'), await readTemplate(htTemplate), 'utf8');
144
+ }
145
+
146
+ // 4c. WordPress VIP: clone the mu-plugins and write the dev-helper mu-plugin
147
+ // before bringing the container up (so both mount sources exist).
148
+ if (opts.vip) {
149
+ await ensureVipMuPlugins(envDir);
150
+ await fs.writeFile(path.join(envDir, 'dev-helper.php'), await readTemplate('dev-helper.php'), 'utf8');
151
+ }
152
+
153
+ // 5. Build & start project containers.
154
+ step(t('start.upping'));
155
+ await compose(['up', '-d', '--build'], { cwd: envDir });
156
+
157
+ // 6. Wait for WP-CLI to answer inside the container (replaces `sleep 5`).
158
+ step(t('start.waitingWp'));
159
+ await pollUntil(
160
+ async () => {
161
+ const { code } = await dockerExecCapture(container, ['wp', 'core', 'version', '--allow-root']);
162
+ return code === 0;
163
+ },
164
+ { label: 'WordPress', timeout: 90000 },
165
+ );
166
+
167
+ // 7. Silent install of the base single site.
168
+ step(t('start.installing'));
169
+ await dockerExec(container, [
170
+ 'wp',
171
+ 'core',
172
+ 'install',
173
+ `--url=http://${dom}`,
174
+ `--title=Dev: ${dom}`,
175
+ `--admin_user=${adminUser}`,
176
+ `--admin_password=${adminPass}`,
177
+ `--admin_email=${ADMIN_EMAIL}`,
178
+ '--skip-email',
179
+ '--skip-plugins',
180
+ '--skip-themes',
181
+ '--allow-root',
182
+ ]);
183
+
184
+ // 7b. Multisite: create the network tables while WP is still single-site
185
+ // (--skip-config: we manage wp-config ourselves), then enable the MULTISITE
186
+ // constants. Order matters — the constants must come AFTER the tables exist.
187
+ if (multisite) {
188
+ step(t('start.enablingMultisite'));
189
+ const conv = await dockerExecCapture(container, [
190
+ 'wp',
191
+ 'core',
192
+ 'multisite-convert',
193
+ '--skip-config',
194
+ ...(subdomains ? ['--subdomains'] : []),
195
+ `--title=Dev: ${dom}`,
196
+ '--allow-root',
197
+ ]);
198
+ // Tolerate an already-converted network so re-running `start` is idempotent.
199
+ if (conv.code !== 0 && !/already/i.test(`${conv.stdout}\n${conv.stderr}`)) {
200
+ throw new Error(conv.stderr || conv.stdout || 'wp core multisite-convert failed');
201
+ }
202
+ await writeWpConfig(multisiteConfig(dom, { subdomains }));
203
+ }
204
+
205
+ // 8. Pretty permalinks.
206
+ step(t('start.permalinks'));
207
+ await dockerExec(container, [
208
+ 'wp',
209
+ 'rewrite',
210
+ 'structure',
211
+ '/%postname%/',
212
+ '--hard',
213
+ '--allow-root',
214
+ ]);
215
+
216
+ // 9. Optional demo content (--demo-content[=<n>]) — always creates, never fatal.
217
+ if (demoCount) {
218
+ step(t('start.demoGenerating', { count: demoCount }));
219
+ try {
220
+ await runSeeder(container, demoCount);
221
+ } catch {
222
+ step(t('start.demoFailed'));
223
+ }
224
+ }
225
+
226
+ const url = `http://${dom}`;
227
+ success(t('start.success'));
228
+ plain(colors.green(t('start.url', { url })));
229
+ plain(colors.green(t('start.admin', { url })));
230
+ plain(colors.green(t('start.user', { user: adminUser })));
231
+ plain(colors.green(t('start.pass', { pass: adminPass })));
232
+ if (multisite) {
233
+ const mode = subdomains ? 'subdomain' : 'subdirectory';
234
+ plain(colors.green(t('start.multisite', { mode, url })));
235
+ }
236
+ }
@@ -0,0 +1,32 @@
1
+ // `42wp stop <project>` — port of stop_project from 42script.sh.
2
+
3
+ import fs from 'node:fs/promises';
4
+
5
+ import { normalizeName, validateName } from '../lib/naming.js';
6
+ import { projectDir } from '../lib/paths.js';
7
+ import { compose } from '../lib/docker.js';
8
+ import { step, error } from '../lib/log.js';
9
+ import { t } from '../lib/i18n.js';
10
+
11
+ export async function stop(rawName) {
12
+ if (!rawName) {
13
+ error(t('stop.needName'));
14
+ process.exitCode = 1;
15
+ return;
16
+ }
17
+
18
+ const name = normalizeName(rawName);
19
+ validateName(name);
20
+ const envDir = projectDir(name);
21
+
22
+ try {
23
+ await fs.access(envDir);
24
+ } catch {
25
+ error(t('stop.notFound', { name, dir: envDir }));
26
+ process.exitCode = 1;
27
+ return;
28
+ }
29
+
30
+ step(t('stop.stopping', { name }));
31
+ await compose(['down'], { cwd: envDir });
32
+ }
@@ -0,0 +1,78 @@
1
+ // `42wp update <project> [--wp <tag>]` — update an existing project to a newer
2
+ // WordPress image. Regenerates the project's Dockerfile with the resolved tag,
3
+ // re-pulls the base image, rebuilds & recreates the container, then runs
4
+ // `wp core update-db` to migrate the database schema to the new core.
5
+
6
+ import fs from 'node:fs/promises';
7
+ import path from 'node:path';
8
+
9
+ import { normalizeName, validateName, dbName, containerName } from '../lib/naming.js';
10
+ import { projectDir } from '../lib/paths.js';
11
+ import { renderTemplate } from '../lib/render.js';
12
+ import { compose, dockerExec, dockerExecCapture } from '../lib/docker.js';
13
+ import { pollUntil } from '../lib/wait.js';
14
+ import { step, success, error } from '../lib/log.js';
15
+ import { t } from '../lib/i18n.js';
16
+ import { resolveWpTag, wpImage } from '../lib/config.js';
17
+ import { ensureDockerRunning, checkGlobalLayer } from './global.js';
18
+
19
+ export async function update(rawName, opts = {}) {
20
+ if (!rawName) {
21
+ error(t('update.needName'));
22
+ process.exitCode = 1;
23
+ return;
24
+ }
25
+
26
+ const name = normalizeName(rawName);
27
+ validateName(name);
28
+ const db = dbName(name);
29
+ const container = containerName(db);
30
+ const envDir = projectDir(name);
31
+
32
+ // The project must already exist (created by `start`).
33
+ try {
34
+ await fs.access(path.join(envDir, 'docker-compose.yml'));
35
+ } catch {
36
+ error(t('update.notFound', { name, dir: envDir }));
37
+ process.exitCode = 1;
38
+ return;
39
+ }
40
+
41
+ if (!(await ensureDockerRunning())) return;
42
+ await checkGlobalLayer();
43
+
44
+ const wpTag = resolveWpTag(opts.wp);
45
+ const image = wpImage(wpTag);
46
+
47
+ // Rewrite the Dockerfile with the new tag.
48
+ step(t('update.rebuilding', { name, image }));
49
+ const dockerfile = await renderTemplate('project.Dockerfile', { WP_TAG: wpTag });
50
+ await fs.writeFile(path.join(envDir, 'Dockerfile'), dockerfile, 'utf8');
51
+
52
+ // `--pull` forces a re-fetch of the base image even when the tag (e.g.
53
+ // php8.4-apache) is unchanged but has moved upstream; then recreate.
54
+ await compose(['build', '--pull'], { cwd: envDir });
55
+ await compose(['up', '-d'], { cwd: envDir });
56
+
57
+ // Wait for WP-CLI to respond in the recreated container.
58
+ step(t('start.waitingWp'));
59
+ await pollUntil(
60
+ async () => {
61
+ const { code } = await dockerExecCapture(container, ['wp', 'core', 'version', '--allow-root']);
62
+ return code === 0;
63
+ },
64
+ { label: 'WordPress', timeout: 90000 },
65
+ );
66
+
67
+ // Migrate the database schema to match the new core.
68
+ step(t('update.updatingDb'));
69
+ await dockerExec(container, ['wp', 'core', 'update-db', '--allow-root']);
70
+
71
+ const { stdout: version } = await dockerExecCapture(container, [
72
+ 'wp',
73
+ 'core',
74
+ 'version',
75
+ '--allow-root',
76
+ ]);
77
+ success(t('update.done', { name, version: version || wpTag }));
78
+ }
@@ -0,0 +1,28 @@
1
+ // `42wp wp <project> <args...>` — port of wp_cli_proxy from 42script.sh.
2
+ // Proxies a WP-CLI command into the project's running container.
3
+
4
+ import { normalizeName, validateName, dbName, containerName } from '../lib/naming.js';
5
+ import { containerIsRunning, dockerExec } from '../lib/docker.js';
6
+ import { error } from '../lib/log.js';
7
+ import { t } from '../lib/i18n.js';
8
+
9
+ export async function wp(rawName, args) {
10
+ if (!rawName) {
11
+ error(t('wp.needArgs'));
12
+ process.exitCode = 1;
13
+ return;
14
+ }
15
+
16
+ const name = normalizeName(rawName);
17
+ validateName(name);
18
+ const container = containerName(dbName(name));
19
+
20
+ if (!(await containerIsRunning(container))) {
21
+ error(t('wp.notRunning', { container }));
22
+ process.exitCode = 1;
23
+ return;
24
+ }
25
+
26
+ // Original always appends --allow-root.
27
+ await dockerExec(container, ['wp', ...args, '--allow-root'], { interactive: true });
28
+ }
@@ -0,0 +1,56 @@
1
+ // Configurable defaults.
2
+ //
3
+ // The WordPress image tag is everything after `wordpress:` in the Dockerfile's
4
+ // FROM line. The default is `php8.4-apache` — the newest WordPress while keeping
5
+ // PHP pinned at 8.4 + Apache. Override per project with `--wp <tag>`, or globally
6
+ // with FORTYTWO_WP_TAG. Examples of valid tags: `php8.4-apache`, `latest`,
7
+ // `6.9-php8.5-apache`.
8
+
9
+ export const DEFAULT_WP_TAG = 'php8.4-apache';
10
+
11
+ // Default admin credentials for the silent install (override with --user / --pass).
12
+ export const DEFAULT_ADMIN_USER = 'admin';
13
+ export const DEFAULT_ADMIN_PASS = 'password';
14
+
15
+ // WordPress VIP: the prebuilt mu-plugins, cloned into the project and mounted at
16
+ // wp-content/mu-plugins when `start --vip` is used.
17
+ export const VIP_MU_PLUGINS_REPO = 'https://github.com/Automattic/vip-go-mu-plugins-built';
18
+
19
+ // Demo content: how many posts `--demo-content` generates by default.
20
+ export const DEFAULT_DEMO_COUNT = 200;
21
+
22
+ // Resolve the requested demo-post count from the --demo-content option value.
23
+ // Returns null when not requested; the default when passed as a bare flag; or the
24
+ // parsed positive integer for --demo-content=<n> (falling back to the default).
25
+ export function resolveDemoCount(value) {
26
+ if (value === undefined || value === false) return null;
27
+ if (value === true) return DEFAULT_DEMO_COUNT;
28
+ const n = parseInt(value, 10);
29
+ return Number.isFinite(n) && n > 0 ? n : DEFAULT_DEMO_COUNT;
30
+ }
31
+
32
+ // Resolve the effective tag: explicit flag wins, then env var, then default.
33
+ export function resolveWpTag(override) {
34
+ return override || process.env.FORTYTWO_WP_TAG || DEFAULT_WP_TAG;
35
+ }
36
+
37
+ // Full image reference for display/logging.
38
+ export function wpImage(tag) {
39
+ return `wordpress:${tag}`;
40
+ }
41
+
42
+ // The multisite constants block injected into wp-config.php (the {{MULTISITE_CONFIG}}
43
+ // placeholder). Must only be written AFTER the network tables exist — see the
44
+ // two-phase flow in commands/start.js.
45
+ export function multisiteConfig(dom, { subdomains = false } = {}) {
46
+ return [
47
+ '/* Multisite */',
48
+ "define( 'WP_ALLOW_MULTISITE', true );",
49
+ "define( 'MULTISITE', true );",
50
+ `define( 'SUBDOMAIN_INSTALL', ${subdomains ? 'true' : 'false'} );`,
51
+ `define( 'DOMAIN_CURRENT_SITE', '${dom}' );`,
52
+ "define( 'PATH_CURRENT_SITE', '/' );",
53
+ "define( 'SITE_ID_CURRENT_SITE', 1 );",
54
+ "define( 'BLOG_ID_CURRENT_SITE', 1 );",
55
+ ].join('\n');
56
+ }
@@ -0,0 +1,93 @@
1
+ // Thin wrappers around the `docker` / `docker compose` CLIs using node:child_process.
2
+ // Args are always passed as arrays (never through a shell), so project names and
3
+ // WP-CLI arguments can't be reinterpreted by the shell.
4
+
5
+ import { spawn } from 'node:child_process';
6
+
7
+ // Run a command, streaming its stdio to the user. Resolves on exit 0, rejects otherwise.
8
+ export function run(cmd, args, opts = {}) {
9
+ return new Promise((resolve, reject) => {
10
+ const child = spawn(cmd, args, { stdio: 'inherit', ...opts });
11
+ child.on('error', reject);
12
+ child.on('close', (code) => {
13
+ if (code === 0) resolve();
14
+ else reject(new Error(`${cmd} ${args.join(' ')} exited with code ${code}`));
15
+ });
16
+ });
17
+ }
18
+
19
+ // Run a command with `input` piped to its stdin, streaming stdout/stderr to the
20
+ // user. Used to feed a PHP script to `docker exec -i ... wp eval-file -`.
21
+ export function runWithInput(cmd, args, input, opts = {}) {
22
+ return new Promise((resolve, reject) => {
23
+ const child = spawn(cmd, args, { stdio: ['pipe', 'inherit', 'inherit'], ...opts });
24
+ child.on('error', reject);
25
+ child.on('close', (code) => {
26
+ if (code === 0) resolve();
27
+ else reject(new Error(`${cmd} ${args.join(' ')} exited with code ${code}`));
28
+ });
29
+ child.stdin.write(input);
30
+ child.stdin.end();
31
+ });
32
+ }
33
+
34
+ // Run a command quietly and capture its output. Never rejects on a non-zero exit;
35
+ // callers inspect `.code`. Rejects only if the binary can't be spawned at all.
36
+ export function capture(cmd, args, opts = {}) {
37
+ return new Promise((resolve, reject) => {
38
+ const child = spawn(cmd, args, { stdio: ['ignore', 'pipe', 'pipe'], ...opts });
39
+ let stdout = '';
40
+ let stderr = '';
41
+ child.stdout.on('data', (d) => (stdout += d));
42
+ child.stderr.on('data', (d) => (stderr += d));
43
+ child.on('error', reject);
44
+ child.on('close', (code) =>
45
+ resolve({ code, stdout: stdout.trim(), stderr: stderr.trim() }),
46
+ );
47
+ });
48
+ }
49
+
50
+ // `docker info` succeeds only when the daemon is reachable.
51
+ export async function dockerIsRunning() {
52
+ try {
53
+ const { code } = await capture('docker', ['info']);
54
+ return code === 0;
55
+ } catch {
56
+ // docker binary not found
57
+ return false;
58
+ }
59
+ }
60
+
61
+ // True when a container matching the given name is currently running.
62
+ export async function containerIsRunning(name) {
63
+ const { code, stdout } = await capture('docker', [
64
+ 'ps',
65
+ '-q',
66
+ '-f',
67
+ `name=^${name}$`,
68
+ ]);
69
+ return code === 0 && stdout.length > 0;
70
+ }
71
+
72
+ // `docker compose -f <file> ...` (or, when cwd is set, `docker compose ...`).
73
+ export function compose(args, opts = {}) {
74
+ return run('docker', ['compose', ...args], opts);
75
+ }
76
+
77
+ export function composeCapture(args, opts = {}) {
78
+ return capture('docker', ['compose', ...args], opts);
79
+ }
80
+
81
+ // `docker exec [-i] [-t] <container> <cmd...>`
82
+ export function dockerExec(container, cmdArgs, { interactive = false } = {}) {
83
+ const flags = [];
84
+ if (interactive) {
85
+ flags.push('-i');
86
+ if (process.stdout.isTTY) flags.push('-t'); // -t only with a real TTY, so piping works
87
+ }
88
+ return run('docker', ['exec', ...flags, container, ...cmdArgs]);
89
+ }
90
+
91
+ export function dockerExecCapture(container, cmdArgs) {
92
+ return capture('docker', ['exec', container, ...cmdArgs]);
93
+ }
@@ -0,0 +1,64 @@
1
+ // Tiny i18n layer. Ships English + Portuguese; locale is resolved once, from
2
+ // (in priority order) an explicit override, the FORTYTWO_LANG env var, then the
3
+ // usual locale env vars (LC_ALL / LC_MESSAGES / LANG / LANGUAGE). Anything that
4
+ // looks like Portuguese ("pt", "pt_BR", "pt-PT", ...) selects Portuguese;
5
+ // everything else falls back to English.
6
+
7
+ import en from '../locales/en.js';
8
+ import pt from '../locales/pt.js';
9
+
10
+ const CATALOGS = { en, pt };
11
+ const DEFAULT_LOCALE = 'en';
12
+
13
+ let current = null;
14
+
15
+ function normalize(tag) {
16
+ if (!tag) return null;
17
+ const lower = String(tag).toLowerCase();
18
+ if (lower.startsWith('pt')) return 'pt';
19
+ if (lower.startsWith('en')) return 'en';
20
+ return null;
21
+ }
22
+
23
+ function detectLocale(override) {
24
+ const candidates = [
25
+ override,
26
+ process.env.FORTYTWO_LANG,
27
+ process.env.LC_ALL,
28
+ process.env.LC_MESSAGES,
29
+ process.env.LANG,
30
+ process.env.LANGUAGE,
31
+ ];
32
+ for (const candidate of candidates) {
33
+ const locale = normalize(candidate);
34
+ if (locale) return locale;
35
+ }
36
+ return DEFAULT_LOCALE;
37
+ }
38
+
39
+ // Call once at startup (CLI passes the parsed --lang value, if any).
40
+ export function setLocale(override) {
41
+ current = detectLocale(override);
42
+ return current;
43
+ }
44
+
45
+ export function getLocale() {
46
+ if (!current) current = detectLocale();
47
+ return current;
48
+ }
49
+
50
+ function interpolate(template, params) {
51
+ if (!params) return template;
52
+ return template.replace(/\$\{(\w+)\}/g, (match, key) =>
53
+ Object.prototype.hasOwnProperty.call(params, key) ? String(params[key]) : match,
54
+ );
55
+ }
56
+
57
+ // Translate a key. Falls back to English, then to the raw key, so a missing
58
+ // translation degrades gracefully instead of throwing.
59
+ export function t(key, params) {
60
+ const locale = getLocale();
61
+ const message =
62
+ CATALOGS[locale]?.[key] ?? CATALOGS[DEFAULT_LOCALE]?.[key] ?? key;
63
+ return interpolate(message, params);
64
+ }
package/src/lib/log.js ADDED
@@ -0,0 +1,44 @@
1
+ // Terminal output helpers — same look as 42script.sh's echo_step / echo_error.
2
+ // Colors are emitted only when stdout is a TTY and NO_COLOR is unset.
3
+
4
+ import { t } from './i18n.js';
5
+
6
+ const useColor = process.stdout.isTTY && !process.env.NO_COLOR;
7
+
8
+ const CODES = {
9
+ green: '\x1b[0;32m',
10
+ blue: '\x1b[0;34m',
11
+ yellow: '\x1b[1;33m',
12
+ red: '\x1b[0;31m',
13
+ reset: '\x1b[0m',
14
+ };
15
+
16
+ function paint(code, text) {
17
+ return useColor ? `${code}${text}${CODES.reset}` : text;
18
+ }
19
+
20
+ // "==> message" in blue arrow + green text (mirrors echo_step).
21
+ export function step(message) {
22
+ const arrow = paint(CODES.blue, '==>');
23
+ console.log(`${arrow} ${paint(CODES.green, message)}`);
24
+ }
25
+
26
+ // "Error: message" in red (mirrors echo_error), printed to stderr.
27
+ export function error(message) {
28
+ console.error(paint(CODES.red, `${t('err.prefix')}: ${message}`));
29
+ }
30
+
31
+ export function plain(message = '') {
32
+ console.log(message);
33
+ }
34
+
35
+ export function success(message) {
36
+ console.log(`\nšŸš€ ${paint(CODES.green, message)}`);
37
+ }
38
+
39
+ export const colors = {
40
+ green: (s) => paint(CODES.green, s),
41
+ blue: (s) => paint(CODES.blue, s),
42
+ yellow: (s) => paint(CODES.yellow, s),
43
+ red: (s) => paint(CODES.red, s),
44
+ };
@@ -0,0 +1,44 @@
1
+ // Project-name handling, ported 1:1 from 42script.sh, plus a validation guard.
2
+ //
3
+ // bash: PROJECT_NAME="${PROJECT_NAME%.*}" -> strip a trailing ".suffix"
4
+ // bash: DB_NAME=$(... | tr '-' '_') -> hyphens become underscores
5
+ // bash: DOMAIN="${PROJECT_NAME}.localhost"
6
+
7
+ import { t } from './i18n.js';
8
+
9
+ // Remove the shortest trailing ".something", e.g. "jovempan.localhost" -> "jovempan".
10
+ export function normalizeName(raw) {
11
+ return String(raw ?? '').replace(/\.[^.]*$/, '');
12
+ }
13
+
14
+ // MySQL database name and container suffix.
15
+ export function dbName(name) {
16
+ return name.replace(/-/g, '_');
17
+ }
18
+
19
+ export function domain(name) {
20
+ return `${name}.localhost`;
21
+ }
22
+
23
+ export function containerName(db) {
24
+ return `wp_${db}`;
25
+ }
26
+
27
+ // Traefik router rule for a project. Subdomain multisite needs a wildcard host so
28
+ // that sub-site hosts (<sub>.<domain>) route to the same container; otherwise an
29
+ // exact Host() match is enough. Returned with literal backticks for the label;
30
+ // the compose template wraps it in single quotes so the backslashes survive YAML.
31
+ export function routerRule(dom, { subdomains = false } = {}) {
32
+ if (!subdomains) return `Host(\`${dom}\`)`;
33
+ const esc = dom.replace(/\./g, '\\.');
34
+ return `HostRegexp(\`^([a-z0-9-]+\\.)?${esc}$\`)`;
35
+ }
36
+
37
+ // Guard against shell/SQL/container-name injection in the original's interpolated
38
+ // `CREATE DATABASE` and container names. Accepts simple slug characters only.
39
+ export function validateName(name) {
40
+ if (!name || !/^[A-Za-z0-9_-]+$/.test(name)) {
41
+ throw new Error(t('name.invalid', { name }));
42
+ }
43
+ return name;
44
+ }