@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 +21 -0
- package/README.md +202 -0
- package/bin/42wp.js +10 -0
- package/package.json +42 -0
- package/src/cli.js +119 -0
- package/src/commands/global.js +62 -0
- package/src/commands/rm.js +82 -0
- package/src/commands/seed.js +42 -0
- package/src/commands/start.js +236 -0
- package/src/commands/stop.js +32 -0
- package/src/commands/update.js +78 -0
- package/src/commands/wp.js +28 -0
- package/src/lib/config.js +56 -0
- package/src/lib/docker.js +93 -0
- package/src/lib/i18n.js +64 -0
- package/src/lib/log.js +44 -0
- package/src/lib/naming.js +44 -0
- package/src/lib/paths.js +34 -0
- package/src/lib/prompt.js +17 -0
- package/src/lib/render.js +41 -0
- package/src/lib/salts.js +57 -0
- package/src/lib/seeder.js +14 -0
- package/src/lib/wait.js +26 -0
- package/src/locales/en.js +91 -0
- package/src/locales/pt.js +91 -0
- package/src/templates/demo-seed.php +202 -0
- package/src/templates/dev-helper.php +45 -0
- package/src/templates/docker-compose.global.yml +57 -0
- package/src/templates/htaccess.multisite-subdir +17 -0
- package/src/templates/htaccess.multisite-subdomain +17 -0
- package/src/templates/project.Dockerfile +16 -0
- package/src/templates/project.docker-compose.yml +29 -0
- package/src/templates/wp-config.php +24 -0
package/src/lib/paths.js
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
// Filesystem layout. Everything the tool persists at runtime lives under the
|
|
2
|
+
// data dir, which defaults to ~/.42wp and can be
|
|
3
|
+
// overridden with FORTYTWO_HOME. Bundled templates live inside the package and
|
|
4
|
+
// are resolved relative to this module via import.meta.url.
|
|
5
|
+
|
|
6
|
+
import os from 'node:os';
|
|
7
|
+
import path from 'node:path';
|
|
8
|
+
import { fileURLToPath } from 'node:url';
|
|
9
|
+
|
|
10
|
+
const here = path.dirname(fileURLToPath(import.meta.url)); // .../src/lib
|
|
11
|
+
|
|
12
|
+
export function dataDir() {
|
|
13
|
+
return process.env.FORTYTWO_HOME || path.join(os.homedir(), '.42wp');
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function projectsDir() {
|
|
17
|
+
return path.join(dataDir(), 'projects');
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function projectDir(name) {
|
|
21
|
+
return path.join(projectsDir(), name);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function globalComposePath() {
|
|
25
|
+
return path.join(dataDir(), 'docker-compose.global.yml');
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function templatesDir() {
|
|
29
|
+
return path.join(here, '..', 'templates');
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function templatePath(name) {
|
|
33
|
+
return path.join(templatesDir(), name);
|
|
34
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
// Minimal interactive yes/no prompt for destructive actions.
|
|
2
|
+
|
|
3
|
+
import { createInterface } from 'node:readline/promises';
|
|
4
|
+
|
|
5
|
+
// Ask a y/N question on the terminal. Defaults to "no" (safe for destructive ops).
|
|
6
|
+
// Callers should only invoke this when process.stdin.isTTY is true.
|
|
7
|
+
export async function confirm(question, { defaultYes = false } = {}) {
|
|
8
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
9
|
+
try {
|
|
10
|
+
const suffix = defaultYes ? '[Y/n]' : '[y/N]';
|
|
11
|
+
const answer = (await rl.question(`${question} ${suffix} `)).trim().toLowerCase();
|
|
12
|
+
if (!answer) return defaultYes;
|
|
13
|
+
return answer === 'y' || answer === 'yes';
|
|
14
|
+
} finally {
|
|
15
|
+
rl.close();
|
|
16
|
+
}
|
|
17
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
// Template rendering for the files `start` generates, plus a helper to make sure
|
|
2
|
+
// the global compose file exists in the data dir before the global layer comes up.
|
|
3
|
+
|
|
4
|
+
import fs from 'node:fs/promises';
|
|
5
|
+
import { dataDir, globalComposePath, templatePath } from './paths.js';
|
|
6
|
+
|
|
7
|
+
// Replace {{VAR}} placeholders. Throws if a placeholder has no matching var, so a
|
|
8
|
+
// typo fails loudly instead of writing a half-rendered file.
|
|
9
|
+
export function render(template, vars) {
|
|
10
|
+
return template.replace(/\{\{(\w+)\}\}/g, (match, key) => {
|
|
11
|
+
if (!Object.prototype.hasOwnProperty.call(vars, key)) {
|
|
12
|
+
throw new Error(`Missing template variable: ${key}`);
|
|
13
|
+
}
|
|
14
|
+
return vars[key];
|
|
15
|
+
});
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export async function readTemplate(name) {
|
|
19
|
+
return fs.readFile(templatePath(name), 'utf8');
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export async function renderTemplate(name, vars) {
|
|
23
|
+
return render(await readTemplate(name), vars);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// Write the bundled global compose into the data dir if it isn't there yet.
|
|
27
|
+
// Never overwrites an existing file, so a user's edits (and their mysql-data
|
|
28
|
+
// binding) are preserved.
|
|
29
|
+
export async function ensureGlobalCompose() {
|
|
30
|
+
const dest = globalComposePath();
|
|
31
|
+
try {
|
|
32
|
+
await fs.access(dest);
|
|
33
|
+
return dest; // already present
|
|
34
|
+
} catch {
|
|
35
|
+
// The data dir may not exist yet on a first run — create it before writing.
|
|
36
|
+
await fs.mkdir(dataDir(), { recursive: true });
|
|
37
|
+
const contents = await readTemplate('docker-compose.global.yml');
|
|
38
|
+
await fs.writeFile(dest, contents, 'utf8');
|
|
39
|
+
return dest;
|
|
40
|
+
}
|
|
41
|
+
}
|
package/src/lib/salts.js
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
// WordPress secret keys/salts. Mirrors the original `curl api.wordpress.org/.../salt/`,
|
|
2
|
+
// but with a local fallback so `start` still works offline (the original would have
|
|
3
|
+
// written an empty salts block in that case).
|
|
4
|
+
|
|
5
|
+
import crypto from 'node:crypto';
|
|
6
|
+
|
|
7
|
+
const SALT_URL = 'https://api.wordpress.org/secret-key/1.1/salt/';
|
|
8
|
+
|
|
9
|
+
const KEYS = [
|
|
10
|
+
'AUTH_KEY',
|
|
11
|
+
'SECURE_AUTH_KEY',
|
|
12
|
+
'LOGGED_IN_KEY',
|
|
13
|
+
'NONCE_KEY',
|
|
14
|
+
'AUTH_SALT',
|
|
15
|
+
'SECURE_AUTH_SALT',
|
|
16
|
+
'LOGGED_IN_SALT',
|
|
17
|
+
'NONCE_SALT',
|
|
18
|
+
];
|
|
19
|
+
|
|
20
|
+
// Printable ASCII excluding ' and \ so the value is safe inside a single-quoted PHP string.
|
|
21
|
+
const CHARSET = (() => {
|
|
22
|
+
let s = '';
|
|
23
|
+
for (let c = 33; c <= 126; c++) {
|
|
24
|
+
if (c === 39 /* ' */ || c === 92 /* \ */) continue;
|
|
25
|
+
s += String.fromCharCode(c);
|
|
26
|
+
}
|
|
27
|
+
return s;
|
|
28
|
+
})();
|
|
29
|
+
|
|
30
|
+
function randomSalt(length = 64) {
|
|
31
|
+
const bytes = crypto.randomBytes(length);
|
|
32
|
+
let out = '';
|
|
33
|
+
for (let i = 0; i < length; i++) out += CHARSET[bytes[i] % CHARSET.length];
|
|
34
|
+
return out;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function generateSaltsLocally() {
|
|
38
|
+
return KEYS.map((key) => `define( '${key}', '${randomSalt()}' );`).join('\n');
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// Returns { salts, fromFallback }. Tries api.wordpress.org first (5s timeout),
|
|
42
|
+
// then falls back to locally generated salts.
|
|
43
|
+
export async function fetchSalts() {
|
|
44
|
+
try {
|
|
45
|
+
const controller = new AbortController();
|
|
46
|
+
const timer = setTimeout(() => controller.abort(), 5000);
|
|
47
|
+
const res = await fetch(SALT_URL, { signal: controller.signal });
|
|
48
|
+
clearTimeout(timer);
|
|
49
|
+
if (res.ok) {
|
|
50
|
+
const text = (await res.text()).trim();
|
|
51
|
+
if (text.includes('define(')) return { salts: text, fromFallback: false };
|
|
52
|
+
}
|
|
53
|
+
} catch {
|
|
54
|
+
// network error / abort -> fall through to local generation
|
|
55
|
+
}
|
|
56
|
+
return { salts: generateSaltsLocally(), fromFallback: true };
|
|
57
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
// Run the demo-content seeder (demo-seed.php) against a running container by
|
|
2
|
+
// piping it to `wp eval-file -`. Shared by `start --demo-content` and `seed`.
|
|
3
|
+
|
|
4
|
+
import { runWithInput } from './docker.js';
|
|
5
|
+
import { readTemplate } from './render.js';
|
|
6
|
+
|
|
7
|
+
export async function runSeeder(container, count) {
|
|
8
|
+
const seeder = await readTemplate('demo-seed.php');
|
|
9
|
+
await runWithInput(
|
|
10
|
+
'docker',
|
|
11
|
+
['exec', '-i', container, 'wp', 'eval-file', '-', String(count), '--allow-root'],
|
|
12
|
+
seeder,
|
|
13
|
+
);
|
|
14
|
+
}
|
package/src/lib/wait.js
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
// Readiness polling — replaces the original's fixed `sleep 10` / `sleep 5`.
|
|
2
|
+
// Repeatedly runs an async check until it returns truthy or the timeout elapses.
|
|
3
|
+
|
|
4
|
+
import { t } from './i18n.js';
|
|
5
|
+
|
|
6
|
+
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
7
|
+
|
|
8
|
+
// check: async () => boolean. Resolves when check() is truthy; rejects on timeout.
|
|
9
|
+
export async function pollUntil(check, { timeout = 60000, interval = 1500, label = '' } = {}) {
|
|
10
|
+
const deadline = Date.now() + timeout;
|
|
11
|
+
// First attempt immediately, then poll on the interval.
|
|
12
|
+
// eslint-disable-next-line no-constant-condition
|
|
13
|
+
while (true) {
|
|
14
|
+
let ok = false;
|
|
15
|
+
try {
|
|
16
|
+
ok = await check();
|
|
17
|
+
} catch {
|
|
18
|
+
ok = false;
|
|
19
|
+
}
|
|
20
|
+
if (ok) return;
|
|
21
|
+
if (Date.now() >= deadline) {
|
|
22
|
+
throw new Error(t('wait.timeout', { label, seconds: Math.round(timeout / 1000) }));
|
|
23
|
+
}
|
|
24
|
+
await sleep(interval);
|
|
25
|
+
}
|
|
26
|
+
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
// English message catalog. Keys are referenced via t('key', params) in src/lib/i18n.js.
|
|
2
|
+
// Placeholders use ${name} and are filled from the params object.
|
|
3
|
+
|
|
4
|
+
export default {
|
|
5
|
+
// generic
|
|
6
|
+
'err.prefix': 'Error',
|
|
7
|
+
'docker.notRunning': 'Docker is not running.',
|
|
8
|
+
|
|
9
|
+
// global layer
|
|
10
|
+
'global.starting': 'Starting the Global Layer...',
|
|
11
|
+
'global.stopping': 'Stopping the Global Layer...',
|
|
12
|
+
'global.waitingMysql': 'Waiting for MySQL to be ready...',
|
|
13
|
+
'global.invalid': "Invalid global command. Use 'start' or 'stop'.",
|
|
14
|
+
|
|
15
|
+
// start
|
|
16
|
+
'start.needName': 'You must provide a project name. e.g.: 42wp start jovempan',
|
|
17
|
+
'start.preparing': 'Preparing environment for ${domain}...',
|
|
18
|
+
'start.creatingDb': 'Creating database: ${db}',
|
|
19
|
+
'start.genConfig': 'Generating ephemeral wp-config.php...',
|
|
20
|
+
'start.saltsFallback': 'Could not reach api.wordpress.org; generated salts locally.',
|
|
21
|
+
'start.genDockerfile': 'Generating Dockerfile (${image})...',
|
|
22
|
+
'start.genCompose': 'Generating project docker-compose...',
|
|
23
|
+
'start.vipCloning': 'Cloning WordPress VIP mu-plugins (${repo})...',
|
|
24
|
+
'start.vipUpdating': 'Updating WordPress VIP mu-plugins...',
|
|
25
|
+
'start.upping': 'Starting project containers...',
|
|
26
|
+
'start.waitingWp': 'Waiting for the WordPress container to respond...',
|
|
27
|
+
'start.installing': 'Running silent WordPress install...',
|
|
28
|
+
'start.enablingMultisite': 'Enabling multisite (network tables + wp-config constants)...',
|
|
29
|
+
'start.permalinks': 'Configuring permalinks...',
|
|
30
|
+
'start.demoGenerating': 'Seeding ${count} demo posts (authors, tags, categories, images)...',
|
|
31
|
+
'start.demoFailed': 'Could not generate demo content (continuing anyway).',
|
|
32
|
+
'start.success': 'Success! The project is online.',
|
|
33
|
+
'start.url': 'URL: ${url}',
|
|
34
|
+
'start.admin': 'Admin: ${url}/wp-admin',
|
|
35
|
+
'start.user': 'User: ${user}',
|
|
36
|
+
'start.pass': 'Password: ${pass}',
|
|
37
|
+
'start.multisite': 'Multisite: ${mode}. Network admin: ${url}/wp-admin/network/',
|
|
38
|
+
|
|
39
|
+
// update
|
|
40
|
+
'update.needName': 'Tell me which project to update. e.g.: 42wp update jovempan',
|
|
41
|
+
'update.notFound': "Project '${name}' not found at ${dir}. Run 42wp start ${name} first.",
|
|
42
|
+
'update.rebuilding': 'Rebuilding ${name} on ${image}...',
|
|
43
|
+
'update.updatingDb': 'Updating database schema (wp core update-db)...',
|
|
44
|
+
'update.done': 'Updated! ${name} is now on WordPress ${version}.',
|
|
45
|
+
|
|
46
|
+
// rm
|
|
47
|
+
'rm.needName': 'Tell me which project to remove. e.g.: 42wp rm jovempan',
|
|
48
|
+
'rm.notFound': "Project '${name}' not found at ${dir}.",
|
|
49
|
+
'rm.confirm':
|
|
50
|
+
"Remove '${name}'? This deletes its container, image and database. Your repository is kept.",
|
|
51
|
+
'rm.needYes': 'Refusing to remove without confirmation. Re-run with --yes.',
|
|
52
|
+
'rm.cancelled': 'Cancelled. Nothing was removed.',
|
|
53
|
+
'rm.removingContainers': 'Removing containers and image for ${name}...',
|
|
54
|
+
'rm.droppingDb': 'Dropping database: ${db}',
|
|
55
|
+
'rm.mysqlDown': 'Global MySQL is not running — skipped dropping database ${db}.',
|
|
56
|
+
'rm.removingData': 'Removing project data: ${dir}',
|
|
57
|
+
'rm.done': "Removed '${name}'. Your repository was left untouched.",
|
|
58
|
+
|
|
59
|
+
// seed
|
|
60
|
+
'seed.needName': 'Tell me which project to seed. e.g.: 42wp seed jovempan',
|
|
61
|
+
'seed.notRunning': "Project '${name}' is not running. Start it first with 42wp start ${name}.",
|
|
62
|
+
'seed.failed': 'Demo content generation failed.',
|
|
63
|
+
|
|
64
|
+
// stop
|
|
65
|
+
'stop.needName': 'Tell me which project to stop. e.g.: 42wp stop jovempan',
|
|
66
|
+
'stop.stopping': 'Stopping the ${name} environment...',
|
|
67
|
+
'stop.notFound': 'Environment for ${name} not found at ${dir}.',
|
|
68
|
+
|
|
69
|
+
// wp proxy
|
|
70
|
+
'wp.needArgs': 'Provide the project and command. e.g.: 42wp wp jovempan plugin list',
|
|
71
|
+
'wp.notRunning': 'Container ${container} is not running.',
|
|
72
|
+
|
|
73
|
+
// validation
|
|
74
|
+
'name.invalid':
|
|
75
|
+
"Invalid project name '${name}'. Use only letters, numbers, hyphens and underscores.",
|
|
76
|
+
|
|
77
|
+
// usage
|
|
78
|
+
'usage.line': 'Usage: 42wp <command> [project] [arguments]',
|
|
79
|
+
'usage.commands': 'Commands:',
|
|
80
|
+
'usage.start': ' start <project> Start the environment using \'.localhost\'.',
|
|
81
|
+
'usage.update': ' update <project> Update an existing project to a newer WordPress image.',
|
|
82
|
+
'usage.stop': ' stop <project> Stop the project containers.',
|
|
83
|
+
'usage.rm': ' rm <project> Remove a site (container, image, database) — keeps your repo.',
|
|
84
|
+
'usage.seed': ' seed <project> [n] Generate demo content in a running project (default 200 posts).',
|
|
85
|
+
'usage.wp': ' wp <project> ... Run a WP-CLI command inside the container.',
|
|
86
|
+
'usage.globalStart': ' global start Start the Traefik proxy and MySQL.',
|
|
87
|
+
'usage.globalStop': ' global stop Stop the global infrastructure.',
|
|
88
|
+
|
|
89
|
+
// wait
|
|
90
|
+
'wait.timeout': 'Timed out waiting for ${label} after ${seconds}s.',
|
|
91
|
+
};
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
// Catálogo de mensagens em Português. As chaves são as mesmas de en.js.
|
|
2
|
+
// Mantém os textos originais do 42script.sh.
|
|
3
|
+
|
|
4
|
+
export default {
|
|
5
|
+
// genérico
|
|
6
|
+
'err.prefix': 'Erro',
|
|
7
|
+
'docker.notRunning': 'Docker não está rodando.',
|
|
8
|
+
|
|
9
|
+
// camada global
|
|
10
|
+
'global.starting': 'Iniciando a Camada Global...',
|
|
11
|
+
'global.stopping': 'Parando Camada Global...',
|
|
12
|
+
'global.waitingMysql': 'Aguardando MySQL iniciar...',
|
|
13
|
+
'global.invalid': "Comando global inválido. Use 'start' ou 'stop'.",
|
|
14
|
+
|
|
15
|
+
// start
|
|
16
|
+
'start.needName': 'Você precisa informar o nome do projeto. Ex: 42wp start jovempan',
|
|
17
|
+
'start.preparing': 'Preparando ambiente para ${domain}...',
|
|
18
|
+
'start.creatingDb': 'Criando banco de dados: ${db}',
|
|
19
|
+
'start.genConfig': 'Gerando wp-config.php efêmero...',
|
|
20
|
+
'start.saltsFallback': 'Não foi possível acessar api.wordpress.org; salts gerados localmente.',
|
|
21
|
+
'start.genDockerfile': 'Gerando Dockerfile (${image})...',
|
|
22
|
+
'start.genCompose': 'Gerando docker-compose do projeto...',
|
|
23
|
+
'start.vipCloning': 'Clonando os mu-plugins do WordPress VIP (${repo})...',
|
|
24
|
+
'start.vipUpdating': 'Atualizando os mu-plugins do WordPress VIP...',
|
|
25
|
+
'start.upping': 'Subindo containers do projeto...',
|
|
26
|
+
'start.waitingWp': 'Aguardando o container WP responder...',
|
|
27
|
+
'start.installing': 'Executando instalação silenciosa do WordPress...',
|
|
28
|
+
'start.enablingMultisite': 'Habilitando multisite (tabelas de rede + constantes no wp-config)...',
|
|
29
|
+
'start.permalinks': 'Configurando Permalinks...',
|
|
30
|
+
'start.demoGenerating': 'Gerando ${count} posts de demonstração (autores, tags, categorias, imagens)...',
|
|
31
|
+
'start.demoFailed': 'Não foi possível gerar o conteúdo de demonstração (continuando mesmo assim).',
|
|
32
|
+
'start.success': 'Sucesso! O projeto está online.',
|
|
33
|
+
'start.url': 'URL: ${url}',
|
|
34
|
+
'start.admin': 'Admin: ${url}/wp-admin',
|
|
35
|
+
'start.user': 'Usuário: ${user}',
|
|
36
|
+
'start.pass': 'Senha: ${pass}',
|
|
37
|
+
'start.multisite': 'Multisite: ${mode}. Admin da rede: ${url}/wp-admin/network/',
|
|
38
|
+
|
|
39
|
+
// update
|
|
40
|
+
'update.needName': 'Informe o projeto para atualizar. Ex: 42wp update jovempan',
|
|
41
|
+
'update.notFound': "Projeto '${name}' não encontrado em ${dir}. Rode 42wp start ${name} primeiro.",
|
|
42
|
+
'update.rebuilding': 'Reconstruindo ${name} com ${image}...',
|
|
43
|
+
'update.updatingDb': 'Atualizando o schema do banco (wp core update-db)...',
|
|
44
|
+
'update.done': 'Atualizado! ${name} agora está no WordPress ${version}.',
|
|
45
|
+
|
|
46
|
+
// rm
|
|
47
|
+
'rm.needName': 'Informe o projeto para remover. Ex: 42wp rm jovempan',
|
|
48
|
+
'rm.notFound': "Projeto '${name}' não encontrado em ${dir}.",
|
|
49
|
+
'rm.confirm':
|
|
50
|
+
"Remover '${name}'? Isso apaga o container, a imagem e o banco de dados. Seu repositório é mantido.",
|
|
51
|
+
'rm.needYes': 'Removendo apenas com confirmação. Rode novamente com --yes.',
|
|
52
|
+
'rm.cancelled': 'Cancelado. Nada foi removido.',
|
|
53
|
+
'rm.removingContainers': 'Removendo containers e imagem de ${name}...',
|
|
54
|
+
'rm.droppingDb': 'Removendo banco de dados: ${db}',
|
|
55
|
+
'rm.mysqlDown': 'O MySQL global não está rodando — banco ${db} não foi removido.',
|
|
56
|
+
'rm.removingData': 'Removendo dados do projeto: ${dir}',
|
|
57
|
+
'rm.done': "Removido '${name}'. Seu repositório não foi tocado.",
|
|
58
|
+
|
|
59
|
+
// seed
|
|
60
|
+
'seed.needName': 'Informe o projeto para popular. Ex: 42wp seed jovempan',
|
|
61
|
+
'seed.notRunning': "O projeto '${name}' não está rodando. Inicie com 42wp start ${name} primeiro.",
|
|
62
|
+
'seed.failed': 'Falha ao gerar o conteúdo de demonstração.',
|
|
63
|
+
|
|
64
|
+
// stop
|
|
65
|
+
'stop.needName': 'Informe o projeto para parar. Ex: 42wp stop jovempan',
|
|
66
|
+
'stop.stopping': 'Parando o ambiente ${name}...',
|
|
67
|
+
'stop.notFound': 'Ambiente para ${name} não encontrado em ${dir}.',
|
|
68
|
+
|
|
69
|
+
// proxy wp
|
|
70
|
+
'wp.needArgs': 'Informe o projeto e o comando. Ex: 42wp wp jovempan plugin list',
|
|
71
|
+
'wp.notRunning': 'Container ${container} não está rodando.',
|
|
72
|
+
|
|
73
|
+
// validação
|
|
74
|
+
'name.invalid':
|
|
75
|
+
"Nome de projeto inválido '${name}'. Use apenas letras, números, hífens e underscores.",
|
|
76
|
+
|
|
77
|
+
// uso
|
|
78
|
+
'usage.line': 'Uso: 42wp <comando> [projeto] [argumentos]',
|
|
79
|
+
'usage.commands': 'Comandos:',
|
|
80
|
+
'usage.start': " start <projeto> Inicia o ambiente usando '.localhost'.",
|
|
81
|
+
'usage.update': ' update <projeto> Atualiza um projeto existente para uma imagem mais recente do WordPress.',
|
|
82
|
+
'usage.stop': ' stop <projeto> Para os containers do projeto.',
|
|
83
|
+
'usage.rm': ' rm <projeto> Remove um site (container, imagem, banco) — mantém seu repositório.',
|
|
84
|
+
'usage.seed': ' seed <projeto> [n] Gera conteúdo de demo em um projeto rodando (padrão 200 posts).',
|
|
85
|
+
'usage.wp': ' wp <projeto> ... Executa um comando WP-CLI no container.',
|
|
86
|
+
'usage.globalStart': ' global start Inicia o proxy Traefik e o MySQL.',
|
|
87
|
+
'usage.globalStop': ' global stop Para a infraestrutura global.',
|
|
88
|
+
|
|
89
|
+
// wait
|
|
90
|
+
'wait.timeout': 'Tempo esgotado aguardando ${label} após ${seconds}s.',
|
|
91
|
+
};
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
<?php
|
|
2
|
+
/**
|
|
3
|
+
* 42WP demo content seeder — run by @42wp/dev-env's `--demo-content` flag via:
|
|
4
|
+
* wp eval-file - <count> (script piped on STDIN, post count as the argument)
|
|
5
|
+
*
|
|
6
|
+
* Creates:
|
|
7
|
+
* - ~10 `42wp_author` terms (Portuguese names), ~10 tags, ~10 categories
|
|
8
|
+
* - a pool of free images downloaded once from picsum.photos and reused
|
|
9
|
+
* - <count> posts: rich HTML content + plain-text excerpt + featured image,
|
|
10
|
+
* each linked to 1 category, 2-5 tags and 1-3 authors, with varied past dates.
|
|
11
|
+
*
|
|
12
|
+
* This file is managed by the dev environment; edits will be overwritten.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
if ( ! defined( 'WP_CLI' ) || ! WP_CLI ) {
|
|
16
|
+
return;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// media_handle_sideload() lives in wp-admin/includes/media.php.
|
|
20
|
+
if ( ! function_exists( 'media_handle_sideload' ) ) {
|
|
21
|
+
require_once ABSPATH . 'wp-admin/includes/file.php';
|
|
22
|
+
require_once ABSPATH . 'wp-admin/includes/image.php';
|
|
23
|
+
require_once ABSPATH . 'wp-admin/includes/media.php';
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
$count = isset( $args[0] ) ? max( 1, (int) $args[0] ) : 200;
|
|
27
|
+
|
|
28
|
+
/* -------------------------------------------------------------------------- *
|
|
29
|
+
* Taxonomy terms
|
|
30
|
+
* -------------------------------------------------------------------------- */
|
|
31
|
+
|
|
32
|
+
// The `42wp_author` taxonomy is registered by the project itself (the 42-framework
|
|
33
|
+
// composer mu-plugin), so we don't register it here. If it isn't available, the
|
|
34
|
+
// author terms below are simply skipped (wp_insert_term returns a WP_Error).
|
|
35
|
+
|
|
36
|
+
$author_names = [ 'José Almeida', 'Ricardo Feitosa', 'Júlia Silva', 'Marina Costa', 'Pedro Henrique', 'Ana Beatriz', 'Carlos Eduardo', 'Fernanda Lima', 'Rafael Oliveira', 'Beatriz Santos' ];
|
|
37
|
+
$tag_names = [ 'Guerra', 'Viagem', 'Reality Show', 'Tecnologia', 'Economia', 'Cultura', 'Saúde', 'Educação', 'Meio Ambiente', 'Entretenimento' ];
|
|
38
|
+
$cat_names = [ 'Brasil', 'Mundo', 'Esportes', 'Política', 'Ciência', 'Cultura', 'Tecnologia', 'Entretenimento', 'Saúde', 'Economia' ];
|
|
39
|
+
|
|
40
|
+
$ensure_terms = static function ( array $names, $taxonomy ) {
|
|
41
|
+
$ids = [];
|
|
42
|
+
foreach ( $names as $name ) {
|
|
43
|
+
$existing = term_exists( $name, $taxonomy );
|
|
44
|
+
if ( $existing ) {
|
|
45
|
+
$ids[] = (int) ( is_array( $existing ) ? $existing['term_id'] : $existing );
|
|
46
|
+
continue;
|
|
47
|
+
}
|
|
48
|
+
$new = wp_insert_term( $name, $taxonomy );
|
|
49
|
+
if ( ! is_wp_error( $new ) ) {
|
|
50
|
+
$ids[] = (int) $new['term_id'];
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
return $ids;
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
$author_ids = $ensure_terms( $author_names, '42wp_author' );
|
|
57
|
+
$tag_ids = $ensure_terms( $tag_names, 'post_tag' );
|
|
58
|
+
$cat_ids = $ensure_terms( $cat_names, 'category' );
|
|
59
|
+
|
|
60
|
+
WP_CLI::log( sprintf( 'Terms ready: %d authors, %d tags, %d categories.', count( $author_ids ), count( $tag_ids ), count( $cat_ids ) ) );
|
|
61
|
+
|
|
62
|
+
/* -------------------------------------------------------------------------- *
|
|
63
|
+
* Image pool — downloaded once from picsum.photos, reused across posts
|
|
64
|
+
* -------------------------------------------------------------------------- */
|
|
65
|
+
|
|
66
|
+
$pool_size = min( $count, 25 );
|
|
67
|
+
$image_ids = [];
|
|
68
|
+
for ( $i = 0; $i < $pool_size; $i++ ) {
|
|
69
|
+
$seed = 'fortytwo-' . wp_generate_password( 10, false );
|
|
70
|
+
$url = "https://picsum.photos/seed/{$seed}/1200/630.jpg";
|
|
71
|
+
$id = media_sideload_image( $url, 0, null, 'id' );
|
|
72
|
+
if ( ! is_wp_error( $id ) ) {
|
|
73
|
+
$image_ids[] = (int) $id;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
WP_CLI::log( sprintf( 'Images: %d/%d downloaded from picsum.photos.', count( $image_ids ), $pool_size ) );
|
|
77
|
+
|
|
78
|
+
/* -------------------------------------------------------------------------- *
|
|
79
|
+
* Lorem ipsum corpus + helpers
|
|
80
|
+
* -------------------------------------------------------------------------- */
|
|
81
|
+
|
|
82
|
+
$sentences = [
|
|
83
|
+
'Lorem ipsum dolor sit amet, consectetur adipiscing elit',
|
|
84
|
+
'Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua',
|
|
85
|
+
'Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris',
|
|
86
|
+
'Duis aute irure dolor in reprehenderit in voluptate velit esse cillum',
|
|
87
|
+
'Excepteur sint occaecat cupidatat non proident sunt in culpa',
|
|
88
|
+
'Nemo enim ipsam voluptatem quia voluptas sit aspernatur aut odit',
|
|
89
|
+
'Neque porro quisquam est qui dolorem ipsum quia dolor sit amet',
|
|
90
|
+
'Quis autem vel eum iure reprehenderit qui in ea voluptate velit',
|
|
91
|
+
'At vero eos et accusamus et iusto odio dignissimos ducimus',
|
|
92
|
+
'Et harum quidem rerum facilis est et expedita distinctio',
|
|
93
|
+
'Temporibus autem quibusdam et aut officiis debitis aut rerum',
|
|
94
|
+
'Itaque earum rerum hic tenetur a sapiente delectus ut aut',
|
|
95
|
+
'Vestibulum ante ipsum primis in faucibus orci luctus et ultrices',
|
|
96
|
+
'Pellentesque habitant morbi tristique senectus et netus malesuada',
|
|
97
|
+
'Curabitur pretium tincidunt lacus gravida ornare quam viverra',
|
|
98
|
+
'Donec sollicitudin molestie malesuada nulla quis lorem ut libero',
|
|
99
|
+
'Vivamus suscipit tortor eget felis porttitor volutpat aliquam',
|
|
100
|
+
'Cras ultricies ligula sed magna dictum porta morbi leo risus',
|
|
101
|
+
];
|
|
102
|
+
|
|
103
|
+
$para = static function ( $min = 3, $max = 6 ) use ( $sentences ) {
|
|
104
|
+
$n = wp_rand( $min, $max );
|
|
105
|
+
$picked = [];
|
|
106
|
+
for ( $i = 0; $i < $n; $i++ ) {
|
|
107
|
+
$picked[] = $sentences[ array_rand( $sentences ) ];
|
|
108
|
+
}
|
|
109
|
+
return implode( '. ', $picked ) . '.';
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
$heading = static function ( $words = 3 ) use ( $sentences ) {
|
|
113
|
+
$parts = explode( ' ', $sentences[ array_rand( $sentences ) ] );
|
|
114
|
+
$parts = array_slice( $parts, 0, $words );
|
|
115
|
+
return ucfirst( strtolower( implode( ' ', $parts ) ) );
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
// Pick between $min and $max random values from $arr (without repeats).
|
|
119
|
+
$pick = static function ( array $arr, $min, $max ) {
|
|
120
|
+
if ( empty( $arr ) ) {
|
|
121
|
+
return [];
|
|
122
|
+
}
|
|
123
|
+
$n = min( count( $arr ), wp_rand( $min, $max ) );
|
|
124
|
+
$keys = (array) array_rand( $arr, $n );
|
|
125
|
+
return array_map( static function ( $k ) use ( $arr ) {
|
|
126
|
+
return $arr[ $k ];
|
|
127
|
+
}, $keys );
|
|
128
|
+
};
|
|
129
|
+
|
|
130
|
+
$figure = static function () use ( $image_ids ) {
|
|
131
|
+
if ( empty( $image_ids ) ) {
|
|
132
|
+
return '';
|
|
133
|
+
}
|
|
134
|
+
$src = wp_get_attachment_image_url( $image_ids[ array_rand( $image_ids ) ], 'large' );
|
|
135
|
+
if ( ! $src ) {
|
|
136
|
+
return '';
|
|
137
|
+
}
|
|
138
|
+
return '<figure><img src="' . esc_url( $src ) . '" alt="" loading="lazy" /></figure>' . "\n";
|
|
139
|
+
};
|
|
140
|
+
|
|
141
|
+
/* -------------------------------------------------------------------------- *
|
|
142
|
+
* Posts
|
|
143
|
+
* -------------------------------------------------------------------------- */
|
|
144
|
+
|
|
145
|
+
$now = time();
|
|
146
|
+
$two_year = 2 * YEAR_IN_SECONDS;
|
|
147
|
+
$created = 0;
|
|
148
|
+
|
|
149
|
+
for ( $i = 0; $i < $count; $i++ ) {
|
|
150
|
+
$title = rtrim( $sentences[ array_rand( $sentences ) ], '.' );
|
|
151
|
+
|
|
152
|
+
$content = '<h1>' . esc_html( ucfirst( $heading( 5 ) ) ) . "</h1>\n";
|
|
153
|
+
$content .= '<p>' . $para( 3, 5 ) . "</p>\n";
|
|
154
|
+
$content .= $figure();
|
|
155
|
+
$content .= '<h2>' . esc_html( $heading( 3 ) ) . "</h2>\n";
|
|
156
|
+
$content .= '<p><strong>' . $heading( 2 ) . '.</strong> ' . $para( 2, 4 ) . ' <em>' . $heading( 3 ) . '.</em></p>' . "\n";
|
|
157
|
+
$content .= "<ul>\n\t<li>" . $heading( 4 ) . "</li>\n\t<li>" . $heading( 4 ) . "</li>\n\t<li>" . $heading( 4 ) . "</li>\n</ul>\n";
|
|
158
|
+
$content .= '<blockquote><p>' . $sentences[ array_rand( $sentences ) ] . ".</p></blockquote>\n";
|
|
159
|
+
$content .= '<h2>' . esc_html( $heading( 3 ) ) . "</h2>\n";
|
|
160
|
+
$content .= '<p>' . $para( 3, 5 ) . "</p>\n";
|
|
161
|
+
$content .= $figure();
|
|
162
|
+
$content .= '<h3>' . esc_html( $heading( 2 ) ) . "</h3>\n";
|
|
163
|
+
$content .= '<p>' . $para( 2, 4 ) . " <a href=\"#\">" . $heading( 2 ) . "</a>.</p>\n";
|
|
164
|
+
|
|
165
|
+
$excerpt = $para( 1, 2 ); // plain text, no HTML
|
|
166
|
+
|
|
167
|
+
$date_ts = $now - wp_rand( 0, $two_year ); // always in the past
|
|
168
|
+
$post_id = wp_insert_post( [
|
|
169
|
+
'post_title' => ucfirst( $title ),
|
|
170
|
+
'post_content' => $content,
|
|
171
|
+
'post_excerpt' => $excerpt,
|
|
172
|
+
'post_status' => 'publish',
|
|
173
|
+
'post_type' => 'post',
|
|
174
|
+
'post_author' => 1,
|
|
175
|
+
'post_date' => date( 'Y-m-d H:i:s', $date_ts ),
|
|
176
|
+
'post_date_gmt' => gmdate( 'Y-m-d H:i:s', $date_ts ),
|
|
177
|
+
], true );
|
|
178
|
+
|
|
179
|
+
if ( is_wp_error( $post_id ) ) {
|
|
180
|
+
continue;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
if ( $cat_ids ) {
|
|
184
|
+
wp_set_object_terms( $post_id, $cat_ids[ array_rand( $cat_ids ) ], 'category' );
|
|
185
|
+
}
|
|
186
|
+
if ( $tag_ids ) {
|
|
187
|
+
wp_set_object_terms( $post_id, $pick( $tag_ids, 2, 5 ), 'post_tag' );
|
|
188
|
+
}
|
|
189
|
+
if ( $author_ids ) {
|
|
190
|
+
wp_set_object_terms( $post_id, $pick( $author_ids, 1, 3 ), '42wp_author' );
|
|
191
|
+
}
|
|
192
|
+
if ( $image_ids ) {
|
|
193
|
+
set_post_thumbnail( $post_id, $image_ids[ array_rand( $image_ids ) ] );
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
$created++;
|
|
197
|
+
if ( 0 === $created % 25 ) {
|
|
198
|
+
WP_CLI::log( sprintf( ' %d/%d posts created...', $created, $count ) );
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
WP_CLI::success( sprintf( 'Created %d demo posts.', $created ) );
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
<?php
|
|
2
|
+
/**
|
|
3
|
+
* Plugin Name: 42WP Dev Helper
|
|
4
|
+
* Description: Local dev shims injected by @42wp/dev-env (--vip). Ensures the
|
|
5
|
+
* WordPress admin media/file functions (media_handle_sideload,
|
|
6
|
+
* download_url, media_sideload_image, ...) are loaded in admin, REST
|
|
7
|
+
* and CLI contexts. Works around plugins such as FakerPress that call
|
|
8
|
+
* these without loading wp-admin/includes/media.php themselves — a
|
|
9
|
+
* common failure on VIP, where other code loads file.php early.
|
|
10
|
+
* Author: 42WP
|
|
11
|
+
*
|
|
12
|
+
* This file is managed by the dev environment and mounted read-only into the
|
|
13
|
+
* container; do not edit by hand.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
if ( ! defined( 'ABSPATH' ) ) {
|
|
17
|
+
exit;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
if ( ! function_exists( 'fortytwo_wp_load_media_includes' ) ) {
|
|
21
|
+
/**
|
|
22
|
+
* Load the wp-admin media includes if they aren't already present.
|
|
23
|
+
*
|
|
24
|
+
* media_handle_sideload() lives in wp-admin/includes/media.php, while
|
|
25
|
+
* download_url() lives in file.php — plugins that guard on the latter can
|
|
26
|
+
* skip loading the former and fatal. Loading all three here is safe and idempotent.
|
|
27
|
+
*/
|
|
28
|
+
function fortytwo_wp_load_media_includes() {
|
|
29
|
+
if ( function_exists( 'media_handle_sideload' ) ) {
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
32
|
+
require_once ABSPATH . 'wp-admin/includes/file.php';
|
|
33
|
+
require_once ABSPATH . 'wp-admin/includes/image.php';
|
|
34
|
+
require_once ABSPATH . 'wp-admin/includes/media.php';
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// Cover the contexts where attachment generation runs: classic admin, the REST
|
|
39
|
+
// API (FakerPress 0.9+ generates via REST), and WP-CLI.
|
|
40
|
+
add_action( 'admin_init', 'fortytwo_wp_load_media_includes' );
|
|
41
|
+
add_action( 'rest_api_init', 'fortytwo_wp_load_media_includes' );
|
|
42
|
+
|
|
43
|
+
if ( defined( 'WP_CLI' ) && WP_CLI ) {
|
|
44
|
+
fortytwo_wp_load_media_includes();
|
|
45
|
+
}
|