@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 +10 -0
- package/README.md +225 -0
- package/bin/firestack.mjs +122 -0
- package/package.json +36 -0
- package/scripts/cli/config-migrate.mjs +129 -0
- package/scripts/cli/config.mjs +14 -0
- package/scripts/cli/docker-init.mjs +102 -0
- package/scripts/cli/docker-runner.mjs +579 -0
- package/scripts/cli/env.mjs +168 -0
- package/scripts/cli/functions-env.mjs +98 -0
- package/scripts/cli/init.mjs +134 -0
- package/scripts/cli/install.mjs +105 -0
- package/scripts/cli/internal-e2e-runner.mjs +94 -0
- package/scripts/cli/internal-log-router.mjs +116 -0
- package/scripts/cli/internal-run-e2e-staging.mjs +49 -0
- package/scripts/cli/internal-run-e2e.mjs +153 -0
- package/scripts/cli/internal-run-functions-build.mjs +91 -0
- package/scripts/cli/internal-run-integration-report.mjs +132 -0
- package/scripts/cli/test.mjs +1094 -0
- package/scripts/publish-package.sh +16 -0
- package/templates/dockerignore +37 -0
- package/templates/env/.env.default.example +4 -0
- package/templates/env/.env.development.example +4 -0
- package/templates/env/.env.production.example +2 -0
- package/templates/env/.env.staging.example +4 -0
- package/templates/env/.env.test.default.example +6 -0
- package/templates/env/.env.test.development.example +6 -0
- package/templates/env/.env.test.production.example +4 -0
- package/templates/env/.env.test.staging.example +8 -0
- package/templates/firestack.config.json +101 -0
- package/templates/playwright.config.mjs +65 -0
- package/templates/tests.Dockerfile +43 -0
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
|
+
}
|