@i-santos/firestack 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/CHANGELOG.md ADDED
@@ -0,0 +1,10 @@
1
+ # Changelog
2
+
3
+ ## Unreleased
4
+
5
+ - `firestack test`: added deterministic Functions env parity for test processes.
6
+ - Firestack now resolves Functions env files per module using:
7
+ 1. `<functions.source>/.env`
8
+ 2. `<functions.source>/.env.<GCLOUD_PROJECT>`
9
+ 3. `<functions.source>/.env.local`
10
+ - The same merged result is injected into spawned test commands (integration/e2e/ci, docker and non-docker), reducing mismatches between test process env and Functions runtime env.
package/README.md ADDED
@@ -0,0 +1,225 @@
1
+ # FireStack (Standalone)
2
+
3
+ CLI para bootstrap e execução de testes/env em projetos Firebase sem copiar scripts para o repositório do app.
4
+
5
+ ## Modelo Híbrido
6
+
7
+ Comandos principais:
8
+
9
+ ```bash
10
+ npx firestack install
11
+ npx firestack init
12
+ npx firestack docker init
13
+ npx firestack env --development
14
+ npx firestack test --ci
15
+ npx firestack test --ci --docker --docker-rebuild
16
+ ```
17
+
18
+ ### `install`
19
+
20
+ - cria `firestack.config.json` e `playwright.config.mjs` (se não existirem)
21
+ - adiciona `@playwright/test` em `devDependencies`
22
+ - roda `npm install`
23
+ - roda `playwright install chromium`
24
+
25
+ ### `init`
26
+
27
+ Cria `firestack.config.json`, `playwright.config.mjs`, `tests/Dockerfile` e `.dockerignore` no projeto alvo.
28
+ O template organiza tudo de teste em `out/tests/...`:
29
+
30
+ - `out/tests/unit`
31
+ - `out/tests/integration`
32
+ - `out/tests/e2e` (inclui `html/`, `junit.xml` e `artifacts/`)
33
+
34
+ ### `docker init`
35
+
36
+ Cria (ou atualiza com `--force`) o `tests/Dockerfile` e `.dockerignore` padrão do FireStack no projeto alvo.
37
+
38
+ ### `config migrate`
39
+
40
+ Aplica migrações pontuais no `firestack.config.json` por chave estável de migração.
41
+
42
+ Exemplos:
43
+
44
+ ```bash
45
+ npx firestack config migrate --migration integration-spec-default
46
+ npx firestack config migrate --migration integration-spec-default --dry-run
47
+ ```
48
+
49
+ Na migração `integration-spec-default`, o Firestack ajusta `test.commands.integration` (e segmentos legados de integração em `ci`/`ciFailFast`) para usar o runner interno de integração padrão.
50
+
51
+ ### `env`
52
+
53
+ Gera arquivos `.env` a partir dos templates embutidos no pacote, usando `firestack.config.json`.
54
+ Quando existe `.firebaserc`, os aliases em `projects` são usados como perfis de ambiente.
55
+
56
+ Exemplos:
57
+
58
+ ```bash
59
+ npx firestack env --profile default
60
+ npx firestack env --development
61
+ npx firestack env --staging --production
62
+ npx firestack env --all --force
63
+ ```
64
+
65
+ Mapeamento padrão por ambiente:
66
+
67
+ - `default` -> `.env.default` e `.env.test.default`
68
+ - `staging` -> `.env.staging` e `.env.test.staging`
69
+ - `production` -> `.env.production` e `.env.test.production`
70
+
71
+ No `--all`, o FireStack prioriza aliases de `.firebaserc` (ex.: `default`, `staging`, `production`) e aplica fallback para perfis do `firestack.config.json` quando `.firebaserc` não existe.
72
+
73
+ ### `test`
74
+
75
+ Executa suites usando comandos definidos em `firestack.config.json`.
76
+
77
+ Resolução de perfil/projeto/config Firebase para testes:
78
+
79
+ 1. Resolve `profile` por `--profile <alias>`, senão usa `staging` quando `--staging`, senão `FIREBASE_ALIAS`, senão `default`.
80
+ 2. Carrega `.env` com fallback por perfil: `.env`, `.env.test`, `.env.<profile>`, `.env.test.<profile>` (para `default`, também considera `development`).
81
+ 3. Usa `GCLOUD_PROJECT` se estiver definido; senão resolve de `.firebaserc` pelo alias do profile.
82
+ 4. Resolve config Firebase por `--firebase-config <path>`; se não vier, tenta `firebase.<profile>.json` e depois `firebase.json`.
83
+ 5. Injeta no processo de testes o mesmo contexto efetivo de env de Cloud Functions (por módulo `functions.source`): `<source>/.env` -> `<source>/.env.<GCLOUD_PROJECT>` -> `<source>/.env.local` (último vence).
84
+
85
+ Quando o fallback do `.firebaserc` é usado, o CLI também define `FIREBASE_PROJECT_ALIAS` no ambiente (incluindo execução com `--docker`).
86
+ No modo `--docker`, as variáveis resolvidas de Functions também são propagadas para o container de testes.
87
+
88
+ Exemplos:
89
+
90
+ ```bash
91
+ npx firestack test
92
+ npx firestack test --ci --docker
93
+ npx firestack test --ci --docker --docker-rebuild
94
+ npx firestack test --ci --fail-fast
95
+ npx firestack test --ci --infra-logs compact
96
+ npx firestack test --ci --infra-logs verbose
97
+ npx firestack test --ci --no-log-routing
98
+ npx firestack test --unit
99
+ npx firestack test --integration
100
+ npx firestack test --integration --profile default
101
+ npx firestack test --staging --profile staging --firebase-config firebase.staging.json
102
+ npx firestack test --e2e --full
103
+ npx firestack test --staging --full --docker
104
+ ```
105
+
106
+ No `--ci`, o padrão é executar `integration` e `e2e smoke` e falhar só no final se qualquer suite falhar.
107
+ Para modo fail-fast, use `--fail-fast`.
108
+
109
+ Roteamento de logs para melhor DX (padrão ativo):
110
+
111
+ - `--infra-logs compact`: mantém output de testes no terminal e reduz ruído dos emuladores.
112
+ - `--infra-logs verbose`: mostra tudo (comportamento tradicional).
113
+ - `--infra-logs quiet`: mostra só infra importante (warnings/errors) no terminal.
114
+ - `--infra-log-file <path>`: define destino do log completo de infra (default `out/tests/infra/emulator.log`).
115
+ - `--suite-log-file <path>`: define destino do log de suites (default `out/tests/suite/output.log`).
116
+ - por padrão, os arquivos de log são resetados a cada execução.
117
+ - `--log-append`: acumula logs entre execuções (append).
118
+ - `--no-log-routing`: desativa roteamento e mantém stdout original sem filtro.
119
+
120
+ ## Docker (Imagem + Rebuild Inteligente)
121
+
122
+ Configure no `firestack.config.json`:
123
+
124
+ ```json
125
+ {
126
+ "test": {
127
+ "docker": {
128
+ "dockerfile": "tests/Dockerfile",
129
+ "imageBaseName": "firestack-tests",
130
+ "nodeModulesVolumePrefix": "firestack-node_modules-",
131
+ "functionsNodeModulesVolumePrefix": "firestack-functions-node_modules-",
132
+ "emulatorCacheVolumePrefix": "firestack-firebase-cache-",
133
+ "buildNetwork": "host",
134
+ "bootstrapCommand": "if [ ! -d /work/node_modules/.bin ]; then mkdir -p /work/node_modules && cp -a /opt/deps/node_modules/. /work/node_modules/; fi && if [ -n \"${FIRESTACK_FUNCTIONS_PATHS:-}\" ]; then IFS=',' read -r -a firestack_functions <<< \"$FIRESTACK_FUNCTIONS_PATHS\"; for rel in \"${firestack_functions[@]}\"; do if [ -n \"$rel\" ] && [ -d \"/opt/deps/$rel/node_modules\" ] && [ -f \"/work/$rel/package.json\" ] && [ ! -d \"/work/$rel/node_modules/.bin\" ]; then mkdir -p \"/work/$rel/node_modules\" && cp -a \"/opt/deps/$rel/node_modules/.\" \"/work/$rel/node_modules/\"; fi; done; fi",
135
+ "runAsHostUser": true,
136
+ "preloadFirestoreEmulator": true,
137
+ "writablePaths": ["out"],
138
+ "addHosts": ["host.docker.internal:host-gateway"],
139
+ "registry": {
140
+ "defaultHostUrl": "http://127.0.0.1:4873",
141
+ "defaultDockerUrl": "http://host.docker.internal:4873"
142
+ }
143
+ }
144
+ }
145
+ }
146
+ ```
147
+
148
+ No modo `--docker`, o FireStack:
149
+
150
+ - detecta módulos Cloud Functions automaticamente via config Firebase resolvida (`--firebase-config` ou fallback de profile; `functions.source`, incluindo múltiplos codebases);
151
+ - builda imagem com tag baseada em hash de `Dockerfile` + lockfiles + deps de `package.json` (raiz + módulos Functions detectados);
152
+ - reutiliza imagem/volume quando o hash não muda;
153
+ - faz rebuild automático quando deps mudam;
154
+ - monta `node_modules` em volume dedicado por hash para acelerar as execuções.
155
+ - monta `<functions.source>/node_modules` em volume dedicado por hash para cada módulo detectado.
156
+ - mantém cache persistente dos emulators Firebase em volume Docker dedicado e prioriza seed do cache a partir da imagem.
157
+
158
+ Para forçar rebuild manual da imagem:
159
+
160
+ ```bash
161
+ npx firestack test --ci --docker --docker-rebuild
162
+ ```
163
+
164
+ Guards compatíveis com os scripts legados:
165
+
166
+ - `ci --docker` bloqueia `E2E_BASE_URL` externo (a menos de `ALLOW_NON_STAGING_E2E=true`);
167
+ - `e2e --docker` valida host permitido para `E2E_BASE_URL`;
168
+ - `staging --docker` valida `GCLOUD_PROJECT` e host staging.
169
+ - por padrão, `GCLOUD_PROJECT` é resolvido de `.firebaserc` (ou de env se já definido). Se quiser travar staging em um projeto específico, configure `test.docker.stagingProjectId`.
170
+
171
+ `runAsHostUser: true` mantém escrita no bind mount com UID/GID do host.
172
+ Para lockfiles com pacotes em registry local (`127.0.0.1`), use `buildNetwork: "host"` no Linux para o `npm ci` do build enxergar o Verdaccio do host.
173
+ `writablePaths` define diretórios no bind mount que o runner prepara com permissão de escrita para gerar artefatos.
174
+ Para E2E, o FireStack também detecta caminhos locais de output no `playwright.config.*` e libera escrita automaticamente.
175
+
176
+ Layout recomendado de artefatos (centralizado):
177
+
178
+ - `out/tests/unit/junit.xml`
179
+ - `out/tests/integration/serial.junit.xml`
180
+ - `out/tests/integration/parallel.junit.xml`
181
+ - `out/tests/integration/junit.xml`
182
+ - `out/tests/e2e/junit.xml`
183
+ - `out/tests/e2e/html/`
184
+ - `out/tests/e2e/artifacts/`
185
+ - `out/tests/e2e/staging/junit.xml`
186
+ - `out/tests/e2e/staging/html/`
187
+ - `out/tests/e2e/staging/artifacts/`
188
+
189
+ O comando `firestack test` também imprime um resumo final consolidado (unit/integration/e2e) com totais e falhas principais.
190
+
191
+ ## Release (Changesets-only)
192
+
193
+ O Firestack usa o fluxo padrao do `package-starter`, baseado em Changesets.
194
+
195
+ Comandos oficiais:
196
+
197
+ ```bash
198
+ npm run check
199
+ npm run changeset
200
+ npm run version-packages
201
+ npm run release
202
+ ```
203
+
204
+ Fluxo de release:
205
+
206
+ 1. Crie um changeset na sua PR (`npm run changeset`).
207
+ 2. Faça merge na branch `main`.
208
+ 3. O workflow `.github/workflows/release.yml` cria/atualiza a PR `chore: release packages`.
209
+ 4. Ao fazer merge dessa PR de release, o publish no npm e executado.
210
+
211
+ ### Bootstrap de projeto existente
212
+
213
+ Para aplicar esse padrao em um pacote npm ja existente:
214
+
215
+ ```bash
216
+ npx @i-santos/create-package-starter init --dir .
217
+ ```
218
+
219
+ ### Pre-requisitos de publicacao
220
+
221
+ - Configure npm Trusted Publishing para este pacote com:
222
+ - owner/repo: `i-santos/firestack`
223
+ - workflow: `.github/workflows/release.yml`
224
+ - branch: `main`
225
+ - Se `main` for protegida e exigir checks na release PR, configure o secret `CHANGESETS_GH_TOKEN`.
@@ -0,0 +1,122 @@
1
+ #!/usr/bin/env node
2
+ import { dirname, join } from 'node:path';
3
+ import { fileURLToPath } from 'node:url';
4
+ import { readFileSync } from 'node:fs';
5
+ import { spawnSync } from 'node:child_process';
6
+ import { runInstall } from '../scripts/cli/install.mjs';
7
+ import { runInit } from '../scripts/cli/init.mjs';
8
+ import { runDockerInit } from '../scripts/cli/docker-init.mjs';
9
+ import { runConfigMigrate } from '../scripts/cli/config-migrate.mjs';
10
+ import { runEnv } from '../scripts/cli/env.mjs';
11
+ import { runTest } from '../scripts/cli/test.mjs';
12
+
13
+ const __dirname = dirname(fileURLToPath(import.meta.url));
14
+ const root = dirname(__dirname);
15
+ const pkgPath = join(root, 'package.json');
16
+
17
+ function printHelp() {
18
+ console.log(`FireStack CLI
19
+
20
+ Usage:
21
+ firestack install [--target <dir>] [--dry-run] [--force]
22
+ firestack init [--target <dir>] [--dry-run] [--force]
23
+ firestack docker init [--target <dir>] [--dry-run] [--force]
24
+ firestack config migrate --migration <key> [--target <dir>] [--config <path>] [--dry-run]
25
+ firestack env [--development|--staging|--production|--all] [--force] [--target <dir>] [--config <path>]
26
+ firestack test [--ci|--unit|--integration|--e2e|--staging] [--docker] [--docker-rebuild] [--fail-fast] [--full] [--target <dir>] [--config <path>]
27
+ firestack version
28
+ firestack help`);
29
+ }
30
+
31
+ function runInternal(rest) {
32
+ const [internalCommand, ...internalArgs] = rest;
33
+ const internalScriptMap = {
34
+ 'run-integration-report': join(root, 'scripts', 'cli', 'internal-run-integration-report.mjs'),
35
+ 'run-functions-build': join(root, 'scripts', 'cli', 'internal-run-functions-build.mjs'),
36
+ 'run-e2e': join(root, 'scripts', 'cli', 'internal-run-e2e.mjs'),
37
+ 'run-e2e-staging': join(root, 'scripts', 'cli', 'internal-run-e2e-staging.mjs'),
38
+ };
39
+
40
+ const scriptPath = internalScriptMap[internalCommand];
41
+ if (!scriptPath) {
42
+ console.error(`[firestack] unknown internal command: ${internalCommand ?? '(empty)'}`);
43
+ process.exit(1);
44
+ }
45
+
46
+ const result = spawnSync(process.execPath, [scriptPath, ...internalArgs], {
47
+ stdio: 'inherit',
48
+ env: process.env,
49
+ });
50
+
51
+ if (result.error) {
52
+ console.error(`[firestack] failed to run internal command "${internalCommand}": ${result.error.message}`);
53
+ process.exit(1);
54
+ }
55
+ process.exit(result.status ?? 1);
56
+ }
57
+
58
+ function printVersion() {
59
+ const pkg = JSON.parse(readFileSync(pkgPath, 'utf8'));
60
+ console.log(pkg.version);
61
+ }
62
+
63
+ const [command, ...rest] = process.argv.slice(2);
64
+
65
+ if (!command || command === 'help' || command === '--help' || command === '-h') {
66
+ printHelp();
67
+ process.exit(0);
68
+ }
69
+
70
+ if (command === 'version' || command === '--version' || command === '-v') {
71
+ printVersion();
72
+ process.exit(0);
73
+ }
74
+
75
+ if (command === 'install') {
76
+ runInstall(rest);
77
+ process.exit(0);
78
+ }
79
+
80
+ if (command === 'init') {
81
+ runInit(rest);
82
+ process.exit(0);
83
+ }
84
+
85
+ if (command === 'env') {
86
+ runEnv(rest);
87
+ process.exit(0);
88
+ }
89
+
90
+ if (command === 'docker') {
91
+ const [dockerCommand, ...dockerArgs] = rest;
92
+ if (dockerCommand === 'init') {
93
+ runDockerInit(dockerArgs);
94
+ process.exit(0);
95
+ }
96
+ console.error(`[firestack] unknown docker command: ${dockerCommand ?? '(empty)'}`);
97
+ process.exit(1);
98
+ }
99
+
100
+ if (command === 'config') {
101
+ const [configCommand, ...configArgs] = rest;
102
+ if (configCommand === 'migrate') {
103
+ runConfigMigrate(configArgs);
104
+ process.exit(0);
105
+ }
106
+ console.error(`[firestack] unknown config command: ${configCommand ?? '(empty)'}`);
107
+ process.exit(1);
108
+ }
109
+
110
+ if (command === 'test') {
111
+ runTest(rest);
112
+ process.exit(0);
113
+ }
114
+
115
+ if (command === 'internal') {
116
+ runInternal(rest);
117
+ process.exit(0);
118
+ }
119
+
120
+ console.error(`[firestack] unknown command: ${command}`);
121
+ printHelp();
122
+ process.exit(1);
package/package.json ADDED
@@ -0,0 +1,36 @@
1
+ {
2
+ "name": "@i-santos/firestack",
3
+ "version": "1.0.0",
4
+ "description": "Portable DX and testing bootstrap for Node/Firebase projects",
5
+ "type": "module",
6
+ "private": false,
7
+ "license": "UNLICENSED",
8
+ "bin": {
9
+ "firestack": "bin/firestack.mjs"
10
+ },
11
+ "files": [
12
+ "bin",
13
+ "templates",
14
+ "README.md",
15
+ "CHANGELOG.md",
16
+ "scripts"
17
+ ],
18
+ "engines": {
19
+ "node": ">=20"
20
+ },
21
+ "scripts": {
22
+ "test": "node --test tests/**/*.test.mjs",
23
+ "check": "npm run test",
24
+ "pack": "npm pack",
25
+ "publish:local": "bash scripts/publish-package.sh",
26
+ "changeset": "changeset",
27
+ "version-packages": "changeset version",
28
+ "release": "npm run check && changeset publish",
29
+ "release:ci": "npm run check && npm run version-packages"
30
+ },
31
+ "devDependencies": {
32
+ "@playwright/test": "^1.58.2",
33
+ "@types/node": "^24.10.1",
34
+ "@changesets/cli": "^2.29.7"
35
+ }
36
+ }
@@ -0,0 +1,129 @@
1
+ import { existsSync, readFileSync, writeFileSync } from 'node:fs';
2
+ import { resolve } from 'node:path';
3
+
4
+ const MIGRATIONS = ['integration-spec-default'];
5
+
6
+ function printHelp() {
7
+ console.log(
8
+ 'Usage: firestack config migrate --migration <key> [--target <dir>] [--config <path>] [--dry-run]\n' +
9
+ `Available migrations: ${MIGRATIONS.join(', ')}`
10
+ );
11
+ }
12
+
13
+ function loadConfig(path) {
14
+ if (!existsSync(path)) {
15
+ throw new Error(`missing firestack config: ${path}`);
16
+ }
17
+ try {
18
+ return JSON.parse(readFileSync(path, 'utf8'));
19
+ } catch {
20
+ throw new Error(`invalid JSON in ${path}`);
21
+ }
22
+ }
23
+
24
+ function integrationRunnerCommand() {
25
+ return 'firestack internal run-functions-build && firebase emulators:exec --project ${GCLOUD_PROJECT:?Set GCLOUD_PROJECT} "firestack internal run-integration-report"';
26
+ }
27
+
28
+ function applyIntegrationSpecDefault(config) {
29
+ const next = structuredClone(config);
30
+ const changes = [];
31
+
32
+ next.test = next.test ?? {};
33
+ next.test.commands = next.test.commands ?? {};
34
+
35
+ const currentIntegration = next.test.commands.integration;
36
+ const desiredIntegration = integrationRunnerCommand();
37
+ if (currentIntegration !== desiredIntegration) {
38
+ next.test.commands.integration = desiredIntegration;
39
+ changes.push('updated test.commands.integration to internal integration runner');
40
+ }
41
+
42
+ const ci = String(next.test.commands.ci ?? '');
43
+ if (ci && !ci.includes('firestack internal run-integration-report')) {
44
+ const legacy = /node\s+--test[\s\S]*?tests\/integration\/\*\*\/\*\.test\.ts/g;
45
+ if (legacy.test(ci)) {
46
+ next.test.commands.ci = ci.replace(legacy, 'firestack internal run-integration-report');
47
+ changes.push('updated test.commands.ci integration segment to internal runner');
48
+ }
49
+ }
50
+
51
+ const ciFailFast = String(next.test.commands.ciFailFast ?? '');
52
+ if (ciFailFast && !ciFailFast.includes('firestack internal run-integration-report')) {
53
+ const legacy = /node\s+--test[\s\S]*?tests\/integration\/\*\*\/\*\.test\.ts/g;
54
+ if (legacy.test(ciFailFast)) {
55
+ next.test.commands.ciFailFast = ciFailFast.replace(legacy, 'firestack internal run-integration-report');
56
+ changes.push('updated test.commands.ciFailFast integration segment to internal runner');
57
+ }
58
+ }
59
+
60
+ return { next, changes };
61
+ }
62
+
63
+ function runNamedMigration(config, migration) {
64
+ if (migration === 'integration-spec-default') return applyIntegrationSpecDefault(config);
65
+ throw new Error(`unsupported --migration "${migration}". available: ${MIGRATIONS.join(', ')}`);
66
+ }
67
+
68
+ export function runConfigMigrate(argv) {
69
+ const args = {
70
+ target: process.cwd(),
71
+ config: null,
72
+ dryRun: false,
73
+ migration: '',
74
+ };
75
+
76
+ for (let i = 0; i < argv.length; i += 1) {
77
+ const token = argv[i];
78
+ if (token === '--target') {
79
+ args.target = resolve(argv[i + 1] ?? '.');
80
+ i += 1;
81
+ continue;
82
+ }
83
+ if (token === '--config') {
84
+ args.config = resolve(args.target, argv[i + 1] ?? 'firestack.config.json');
85
+ i += 1;
86
+ continue;
87
+ }
88
+ if (token === '--migration') {
89
+ args.migration = String(argv[i + 1] ?? '').trim();
90
+ i += 1;
91
+ continue;
92
+ }
93
+ if (token === '--dry-run') {
94
+ args.dryRun = true;
95
+ continue;
96
+ }
97
+ if (token === '-h' || token === '--help') {
98
+ printHelp();
99
+ process.exit(0);
100
+ }
101
+ throw new Error(`unknown argument: ${token}`);
102
+ }
103
+
104
+ if (!args.migration) {
105
+ throw new Error('missing required argument: --migration <key>');
106
+ }
107
+
108
+ const configPath = args.config ?? resolve(args.target, 'firestack.config.json');
109
+ const current = loadConfig(configPath);
110
+ const { next, changes } = runNamedMigration(current, args.migration);
111
+
112
+ if (changes.length === 0) {
113
+ console.log(`[firestack] config already up to date for migration "${args.migration}"`);
114
+ return;
115
+ }
116
+
117
+ console.log(`[firestack] config migration (${args.migration}) changes:`);
118
+ for (const change of changes) {
119
+ console.log(`- ${change}`);
120
+ }
121
+
122
+ if (args.dryRun) {
123
+ console.log('[firestack] dry-run mode: no file changes written');
124
+ return;
125
+ }
126
+
127
+ writeFileSync(configPath, `${JSON.stringify(next, null, 2)}\n`, 'utf8');
128
+ console.log(`[firestack] migrated ${configPath}`);
129
+ }
@@ -0,0 +1,14 @@
1
+ import { existsSync, readFileSync } from 'node:fs';
2
+ import { resolve } from 'node:path';
3
+
4
+ export function loadProjectConfig(cwd, explicitPath = null) {
5
+ const configPath = explicitPath ?? resolve(cwd, 'firestack.config.json');
6
+ if (!existsSync(configPath)) {
7
+ throw new Error(`missing firestack.config.json in ${cwd}. Run: npx firestack init`);
8
+ }
9
+ return {
10
+ path: configPath,
11
+ data: JSON.parse(readFileSync(configPath, 'utf8')),
12
+ };
13
+ }
14
+
@@ -0,0 +1,102 @@
1
+ import { cpSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
2
+ import { dirname, join, resolve } from 'node:path';
3
+ import { fileURLToPath } from 'node:url';
4
+
5
+ const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..', '..');
6
+ const TEMPLATE_DOCKERFILE = join(ROOT, 'templates', 'tests.Dockerfile');
7
+ const TEMPLATE_DOCKERIGNORE = join(ROOT, 'templates', 'dockerignore');
8
+
9
+ function printHelp() {
10
+ console.log('Usage: firestack docker init [--target <dir>] [--force] [--dry-run]');
11
+ }
12
+
13
+ function ensureDockerignoreEntries(targetDir, dryRun) {
14
+ const dockerignorePath = join(targetDir, '.dockerignore');
15
+ const hasDockerignore = existsSync(dockerignorePath);
16
+ const currentContent = hasDockerignore ? readFileSync(dockerignorePath, 'utf8') : '';
17
+ const desiredEntries = readFileSync(TEMPLATE_DOCKERIGNORE, 'utf8')
18
+ .split(/\r?\n/)
19
+ .map((line) => line.trim())
20
+ .filter((line) => line && !line.startsWith('#'));
21
+
22
+ const existingEntries = new Set(
23
+ currentContent
24
+ .split(/\r?\n/)
25
+ .map((line) => line.trim())
26
+ .filter(Boolean)
27
+ );
28
+ const missing = desiredEntries.filter((entry) => !existingEntries.has(entry));
29
+ if (missing.length === 0) {
30
+ console.log('[firestack] .dockerignore already contains default entries');
31
+ return;
32
+ }
33
+
34
+ if (dryRun) {
35
+ console.log(`[firestack] would add ${missing.length} entries to ${dockerignorePath}`);
36
+ return;
37
+ }
38
+
39
+ const endsWithNewline = currentContent === '' || currentContent.endsWith('\n');
40
+ const prefix = currentContent === '' || endsWithNewline ? '' : '\n';
41
+ const next = `${currentContent}${prefix}${missing.join('\n')}\n`;
42
+ writeFileSync(dockerignorePath, next, 'utf8');
43
+ console.log(`[firestack] updated ${dockerignorePath} with ${missing.length} entries`);
44
+ }
45
+
46
+ export function runDockerInit(argv) {
47
+ const args = {
48
+ target: process.cwd(),
49
+ force: false,
50
+ dryRun: false,
51
+ };
52
+
53
+ for (let i = 0; i < argv.length; i += 1) {
54
+ const token = argv[i];
55
+ if (token === '--target') {
56
+ args.target = resolve(argv[i + 1] ?? '.');
57
+ i += 1;
58
+ continue;
59
+ }
60
+ if (token === '--force') {
61
+ args.force = true;
62
+ continue;
63
+ }
64
+ if (token === '--dry-run') {
65
+ args.dryRun = true;
66
+ continue;
67
+ }
68
+ if (token === '-h' || token === '--help') {
69
+ printHelp();
70
+ process.exit(0);
71
+ }
72
+ throw new Error(`unknown argument: ${token}`);
73
+ }
74
+
75
+ const files = [
76
+ { template: TEMPLATE_DOCKERFILE, destination: join(args.target, 'tests', 'Dockerfile'), label: 'tests/Dockerfile' },
77
+ ];
78
+ let skippedExisting = false;
79
+
80
+ for (const file of files) {
81
+ if (existsSync(file.destination) && !args.force) {
82
+ console.log(`[firestack] ${file.label} already exists: ${file.destination}`);
83
+ skippedExisting = true;
84
+ continue;
85
+ }
86
+
87
+ if (!args.dryRun) {
88
+ mkdirSync(dirname(file.destination), { recursive: true });
89
+ cpSync(file.template, file.destination, { recursive: false });
90
+ }
91
+ console.log(`[firestack] initialized ${file.destination}`);
92
+ }
93
+
94
+ if (skippedExisting) {
95
+ console.log('[firestack] use --force to overwrite existing files');
96
+ }
97
+ ensureDockerignoreEntries(args.target, args.dryRun);
98
+
99
+ if (args.dryRun) {
100
+ console.log('[firestack] dry-run mode: no files were written');
101
+ }
102
+ }