@microteia/console 2.7.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/CONSOLE-VERSIONS.md +85 -0
- package/README.md +64 -0
- package/lib/console/backendEnv.d.ts +52 -0
- package/lib/console/backendEnv.js +118 -0
- package/lib/console/backendIndexes.d.ts +40 -0
- package/lib/console/backendIndexes.js +178 -0
- package/lib/console/backendParameters.d.ts +48 -0
- package/lib/console/backendParameters.js +100 -0
- package/lib/console/callableAccess.d.ts +39 -0
- package/lib/console/callableAccess.js +61 -0
- package/lib/console/channel.d.ts +49 -0
- package/lib/console/channel.js +145 -0
- package/lib/console/connect.d.ts +2 -0
- package/lib/console/connect.js +74 -0
- package/lib/console/customFunctions.d.ts +120 -0
- package/lib/console/customFunctions.js +298 -0
- package/lib/console/deploy.d.ts +2 -0
- package/lib/console/deploy.js +261 -0
- package/lib/console/deployFunctions.d.ts +45 -0
- package/lib/console/deployFunctions.js +204 -0
- package/lib/console/environment.d.ts +62 -0
- package/lib/console/environment.js +151 -0
- package/lib/console/firebaseCli.d.ts +15 -0
- package/lib/console/firebaseCli.js +90 -0
- package/lib/console/firestoreRest.d.ts +39 -0
- package/lib/console/firestoreRest.js +131 -0
- package/lib/console/hostingCheck.d.ts +29 -0
- package/lib/console/hostingCheck.js +44 -0
- package/lib/console/init.d.ts +2 -0
- package/lib/console/init.js +60 -0
- package/lib/console/pluginWorkspace.d.ts +48 -0
- package/lib/console/pluginWorkspace.js +148 -0
- package/lib/console/provision.d.ts +70 -0
- package/lib/console/provision.js +478 -0
- package/lib/console/regionOrphans.d.ts +43 -0
- package/lib/console/regionOrphans.js +99 -0
- package/lib/console/runtimeConfigShape.d.ts +25 -0
- package/lib/console/runtimeConfigShape.js +35 -0
- package/lib/console/unpack.d.ts +9 -0
- package/lib/console/unpack.js +333 -0
- package/lib/console/versions.d.ts +10 -0
- package/lib/console/versions.js +72 -0
- package/lib/console/workspace.d.ts +19 -0
- package/lib/console/workspace.js +33 -0
- package/lib/index.d.ts +2 -0
- package/lib/index.js +136 -0
- package/lib/kernel/backendShape.d.ts +74 -0
- package/lib/kernel/backendShape.js +91 -0
- package/lib/kernel/channel.d.ts +79 -0
- package/lib/kernel/channel.js +157 -0
- package/lib/kernel/manifest.d.ts +138 -0
- package/lib/kernel/manifest.js +145 -0
- package/lib/shared/args.d.ts +14 -0
- package/lib/shared/args.js +44 -0
- package/lib/shared/digest.d.ts +30 -0
- package/lib/shared/digest.js +34 -0
- package/lib/shared/paths.d.ts +90 -0
- package/lib/shared/paths.js +97 -0
- package/lib/shared/run.d.ts +28 -0
- package/lib/shared/run.js +49 -0
- package/lib/shared/ui.d.ts +23 -0
- package/lib/shared/ui.js +43 -0
- package/lib/vendor/authoringGuide.d.ts +19 -0
- package/lib/vendor/authoringGuide.js +284 -0
- package/lib/vendor/bundleAudit.d.ts +19 -0
- package/lib/vendor/bundleAudit.js +95 -0
- package/lib/vendor/changelogs.d.ts +62 -0
- package/lib/vendor/changelogs.js +131 -0
- package/lib/vendor/exampleSources.d.ts +29 -0
- package/lib/vendor/exampleSources.js +126 -0
- package/lib/vendor/installationDocs.d.ts +35 -0
- package/lib/vendor/installationDocs.js +243 -0
- package/lib/vendor/pack.d.ts +2 -0
- package/lib/vendor/pack.js +276 -0
- package/lib/vendor/packConsole.d.ts +13 -0
- package/lib/vendor/packConsole.js +63 -0
- package/lib/vendor/packageTemplates.d.ts +66 -0
- package/lib/vendor/packageTemplates.js +200 -0
- package/lib/vendor/pluginWorkspaceTemplates.d.ts +19 -0
- package/lib/vendor/pluginWorkspaceTemplates.js +29 -0
- package/lib/vendor/publish.d.ts +2 -0
- package/lib/vendor/publish.js +277 -0
- package/lib/vendor/repository.d.ts +9 -0
- package/lib/vendor/repository.js +26 -0
- package/package.json +21 -0
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
import { stripVTControlCharacters } from 'node:util';
|
|
2
|
+
import { capture, sleep } from '../shared/run.js';
|
|
3
|
+
import { blank, failure, info, pending, success } from '../shared/ui.js';
|
|
4
|
+
import { FIREBASE_BIN } from './firebaseCli.js';
|
|
5
|
+
const NOTICES = [
|
|
6
|
+
{
|
|
7
|
+
title: /DEPRECATION NOTICE/,
|
|
8
|
+
body: /functions[.:]config|Runtime Config|params package|config-env/,
|
|
9
|
+
summary: 'this project still holds legacy functions:config values. They keep working, but Firebase'
|
|
10
|
+
+ ' shuts Runtime Config down in March 2027 and deploys that read it stop working then.'
|
|
11
|
+
+ ' "firebase functions:config:get" lists what is there.',
|
|
12
|
+
},
|
|
13
|
+
];
|
|
14
|
+
export const stripNotices = (text) => {
|
|
15
|
+
const lines = text.split('\n');
|
|
16
|
+
const kept = [];
|
|
17
|
+
const summaries = [];
|
|
18
|
+
for (let index = 0; index < lines.length; index += 1) {
|
|
19
|
+
const notice = NOTICES.find((candidate) => candidate.title.test(lines[index]));
|
|
20
|
+
if (notice === undefined) {
|
|
21
|
+
kept.push(lines[index]);
|
|
22
|
+
continue;
|
|
23
|
+
}
|
|
24
|
+
summaries.push(notice.summary);
|
|
25
|
+
index += 1;
|
|
26
|
+
while (index < lines.length && (lines[index].trim() === '' || notice.body.test(lines[index]))) {
|
|
27
|
+
index += 1;
|
|
28
|
+
}
|
|
29
|
+
index -= 1;
|
|
30
|
+
}
|
|
31
|
+
return { text: kept.join('\n'), summaries };
|
|
32
|
+
};
|
|
33
|
+
/**
|
|
34
|
+
* A linha que EXPLICA a falha, tirada da saída do `firebase`.
|
|
35
|
+
*
|
|
36
|
+
* Existe porque imprimir só `fail [13/23] auditNestedEntityChange` não deixa nada a fazer para quem
|
|
37
|
+
* está instalando: se a segunda passada resolver, o motivo é descartado e nunca chega a ser visto; se
|
|
38
|
+
* não resolver, ele só aparece no fim, depois de mais dez minutos de deploy. O motivo tem de sair no
|
|
39
|
+
* momento da falha.
|
|
40
|
+
*
|
|
41
|
+
* A escolha é por QUANTO a linha informa, e não por onde ela está. Nem a primeira nem a última
|
|
42
|
+
* servem: uma falha real imprime três coisas parecidas em sequência — quem falhou
|
|
43
|
+
* (`failed to create function <nome>`), por que falhou (`HTTP Error: 400, Invalid trigger`) e o
|
|
44
|
+
* epílogo (`There was an error deploying functions`), e a ordem entre as duas primeiras varia. Por
|
|
45
|
+
* posição, sempre se perde uma das duas: pela última vinha o epílogo, pela primeira viria o nome de
|
|
46
|
+
* quem falhou — que o `fail` da linha de cima já disse.
|
|
47
|
+
*/
|
|
48
|
+
const REASON_LIMIT = 240;
|
|
49
|
+
/** Carrega a CAUSA: é ela que diz algo que o resto da saída não diz. */
|
|
50
|
+
const CAUSE = /(^|\s)(Error:|HTTP Error|functions\[|✖)/;
|
|
51
|
+
/** Só nomeia quem falhou. Vale como motivo apenas quando a causa não foi impressa. */
|
|
52
|
+
const SUBJECT = /failed to (create|update|delete)/;
|
|
53
|
+
/** O epílogo do `firebase`: verdadeiro em toda falha e específico em nenhuma. */
|
|
54
|
+
const EPILOGUE = /^Error: There was an error deploying functions/;
|
|
55
|
+
export const failureReason = (output) => {
|
|
56
|
+
const lines = stripVTControlCharacters(output)
|
|
57
|
+
.split('\n')
|
|
58
|
+
.map((line) => line.trim())
|
|
59
|
+
.filter((line) => line !== '');
|
|
60
|
+
const chosen = lines.find((line) => CAUSE.test(line) && !EPILOGUE.test(line))
|
|
61
|
+
?? lines.find((line) => SUBJECT.test(line))
|
|
62
|
+
?? lines.find((line) => EPILOGUE.test(line))
|
|
63
|
+
?? lines[lines.length - 1]
|
|
64
|
+
?? 'no output from firebase';
|
|
65
|
+
return chosen.length > REASON_LIMIT ? `${chosen.slice(0, REASON_LIMIT - 1)}…` : chosen;
|
|
66
|
+
};
|
|
67
|
+
const TERMINAL_FAILURES = [
|
|
68
|
+
{
|
|
69
|
+
match: /Aborting because deletion cannot proceed in non-interactive mode/,
|
|
70
|
+
hint: [
|
|
71
|
+
'The project holds a copy of this function that the source no longer declares — almost always',
|
|
72
|
+
'a leftover in a region the package moved away from. The Firebase CLI will not deploy over it',
|
|
73
|
+
'and cannot ask for permission here, so it refuses. Delete the old copy:',
|
|
74
|
+
' microteia deploy --prune',
|
|
75
|
+
],
|
|
76
|
+
},
|
|
77
|
+
{
|
|
78
|
+
match: /does not have a default and cannot be answered in non-interactive mode/,
|
|
79
|
+
hint: [
|
|
80
|
+
'The Firebase CLI needed an answer and this deploy has no terminal to ask it. Run the same',
|
|
81
|
+
'deploy by hand once, from the installation directory, answer the question, and then run',
|
|
82
|
+
'"microteia deploy" again.',
|
|
83
|
+
],
|
|
84
|
+
},
|
|
85
|
+
];
|
|
86
|
+
export const terminalFailure = (output) => {
|
|
87
|
+
const clean = stripVTControlCharacters(output);
|
|
88
|
+
return TERMINAL_FAILURES.find((candidate) => candidate.match.test(clean));
|
|
89
|
+
};
|
|
90
|
+
/**
|
|
91
|
+
* O deploy é UMA A UMA, e não `--only functions` de uma vez, porque em lote ele estoura a quota de
|
|
92
|
+
* CPU do projeto — o motivo pelo qual `scripts/deploy-functions-individually.cjs` existe deste lado.
|
|
93
|
+
* O cliente bate exatamente na mesma parede, com a diferença de que ele não tem o script.
|
|
94
|
+
*
|
|
95
|
+
* A segunda passada não é teimosia: numa instalação nova as identidades de serviço do Pub/Sub e do
|
|
96
|
+
* Eventarc são criadas DURANTE o primeiro deploy e não propagam a tempo, então toda function de
|
|
97
|
+
* gatilho falha enquanto as callables sobem normalmente. Repetir resolve, e sem isso a primeira
|
|
98
|
+
* instalação parece quebrada quando está apenas incompleta.
|
|
99
|
+
*/
|
|
100
|
+
const deployOne = async (name, options) => {
|
|
101
|
+
const args = ['deploy', '--only', `functions:${name}`, '--project', options.projectId];
|
|
102
|
+
if (options.force)
|
|
103
|
+
args.push('--force');
|
|
104
|
+
const startedAt = Date.now();
|
|
105
|
+
const result = await capture(FIREBASE_BIN, args, { cwd: options.cwd });
|
|
106
|
+
const cleaned = stripNotices(`${result.stdout}${result.stderr}`);
|
|
107
|
+
return {
|
|
108
|
+
ok: result.code === 0,
|
|
109
|
+
output: cleaned.text.trim(),
|
|
110
|
+
notices: cleaned.summaries,
|
|
111
|
+
seconds: Math.round((Date.now() - startedAt) / 1000),
|
|
112
|
+
};
|
|
113
|
+
};
|
|
114
|
+
export const deployFunctionsOneByOne = async (names, options) => {
|
|
115
|
+
const deployed = [];
|
|
116
|
+
const failed = [];
|
|
117
|
+
const notices = new Set();
|
|
118
|
+
let remaining = names;
|
|
119
|
+
for (let pass = 1; pass <= 2 && remaining.length > 0; pass += 1) {
|
|
120
|
+
if (pass === 2) {
|
|
121
|
+
info(`\n Retrying ${remaining.length} function(s). On a new project the Pub/Sub and Eventarc`);
|
|
122
|
+
info(' service identities are created during the first deploy and do not propagate in time.');
|
|
123
|
+
}
|
|
124
|
+
else if (options.force) {
|
|
125
|
+
// Só na PRIMEIRA instalação, que é o que `force` significa: é ali que o Firebase provisiona build
|
|
126
|
+
// e Artifact Registry, e a primeira function leva vários minutos. Dizer isso ANTES é o que separa
|
|
127
|
+
// "demorado" de "travado" para quem está olhando. Num `provision` de projeto já montado a dica
|
|
128
|
+
// seria falsa, e por isso ela não aparece lá.
|
|
129
|
+
info(` the first of the ${remaining.length} takes the longest — on a new project Firebase sets`);
|
|
130
|
+
info(' up the build infrastructure before it can deploy anything.');
|
|
131
|
+
}
|
|
132
|
+
const retryable = [];
|
|
133
|
+
for (let index = 0; index < remaining.length; index += 1) {
|
|
134
|
+
const name = remaining[index];
|
|
135
|
+
const label = `[${index + 1}/${remaining.length}] ${name}`;
|
|
136
|
+
pending(label);
|
|
137
|
+
const { ok, output, notices: given, seconds } = await deployOne(name, options);
|
|
138
|
+
for (const notice of given)
|
|
139
|
+
notices.add(notice);
|
|
140
|
+
if (ok) {
|
|
141
|
+
success(`${label} (${seconds}s)`);
|
|
142
|
+
deployed.push(name);
|
|
143
|
+
}
|
|
144
|
+
else {
|
|
145
|
+
const terminal = terminalFailure(output);
|
|
146
|
+
const reason = failureReason(output);
|
|
147
|
+
failure(`${label} (failed after ${seconds}s)`);
|
|
148
|
+
// O motivo AGORA, não só no relatório final: numa instalação nova as functions de gatilho
|
|
149
|
+
// falham na primeira passada por propagação de identidade de serviço, a segunda resolve, e sem
|
|
150
|
+
// esta linha quem instala vê um "fail" que nunca é explicado.
|
|
151
|
+
info(` ${reason}`);
|
|
152
|
+
if (terminal === undefined && pass === 1) {
|
|
153
|
+
info(' retrying at the end of this pass');
|
|
154
|
+
retryable.push(name);
|
|
155
|
+
}
|
|
156
|
+
else {
|
|
157
|
+
if (terminal !== undefined)
|
|
158
|
+
info(' repeating this does not change the answer');
|
|
159
|
+
failed.push({
|
|
160
|
+
name,
|
|
161
|
+
output,
|
|
162
|
+
reason,
|
|
163
|
+
terminal: terminal !== undefined,
|
|
164
|
+
hint: terminal?.hint ?? [],
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
if (index < remaining.length - 1 && options.delaySeconds > 0) {
|
|
169
|
+
await sleep(options.delaySeconds);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
remaining = retryable;
|
|
173
|
+
}
|
|
174
|
+
return { deployed, failed, notices: [...notices] };
|
|
175
|
+
};
|
|
176
|
+
/**
|
|
177
|
+
* O relatório do que não subiu, no fim do comando.
|
|
178
|
+
*
|
|
179
|
+
* Mora aqui, e não em cada comando, porque `deploy` e `provision` imprimiam o mesmo bloco escrito
|
|
180
|
+
* duas vezes — e a cauda de 12 linhas dos dois mostrava o rodapé do aviso de descontinuação, não o
|
|
181
|
+
* erro. A dica sai UMA vez por motivo distinto: oito functions barradas pela mesma região antiga são
|
|
182
|
+
* um problema, não oito.
|
|
183
|
+
*/
|
|
184
|
+
const TAIL_LINES = 12;
|
|
185
|
+
export const reportFailures = (failures) => {
|
|
186
|
+
const hints = new Set();
|
|
187
|
+
for (const entry of failures) {
|
|
188
|
+
failure(entry.name);
|
|
189
|
+
info(entry.output.split('\n').slice(-TAIL_LINES).map((line) => ` ${line}`).join('\n'));
|
|
190
|
+
if (entry.hint.length > 0)
|
|
191
|
+
hints.add(entry.hint.join('\n'));
|
|
192
|
+
}
|
|
193
|
+
for (const hint of hints) {
|
|
194
|
+
blank();
|
|
195
|
+
for (const line of hint.split('\n'))
|
|
196
|
+
info(` ${line}`);
|
|
197
|
+
}
|
|
198
|
+
};
|
|
199
|
+
export const reportNotices = (notices) => {
|
|
200
|
+
for (const notice of notices) {
|
|
201
|
+
blank();
|
|
202
|
+
info(` Note: ${notice}`);
|
|
203
|
+
}
|
|
204
|
+
};
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Um Environment é UMA instalação da Microteia associada a UM projeto Firebase. Nada além disso por
|
|
3
|
+
* enquanto: organização, staging e promoção entre ambientes são conceitos que ainda não existem, e
|
|
4
|
+
* inventá-los agora seria decidir sem saber.
|
|
5
|
+
*/
|
|
6
|
+
export interface Environment {
|
|
7
|
+
project: {
|
|
8
|
+
name: string;
|
|
9
|
+
};
|
|
10
|
+
environment: {
|
|
11
|
+
id: string;
|
|
12
|
+
name: string;
|
|
13
|
+
};
|
|
14
|
+
firebase: {
|
|
15
|
+
projectId: string | null;
|
|
16
|
+
region: string;
|
|
17
|
+
};
|
|
18
|
+
/**
|
|
19
|
+
* `version` é o kernel EM VIGOR nesta instalação — quem o grava é o `unpack`, que é o comando que o
|
|
20
|
+
* põe no lugar. `deployed` é o que já foi publicado no projeto, gravado pelo `deploy`.
|
|
21
|
+
*
|
|
22
|
+
* São dois porque a separação entre `unpack` e `deploy` é o contrato: existe um intervalo legítimo em
|
|
23
|
+
* que o pacote está no lugar e não foi publicado. Um campo só mentia numa das duas pontas — e mentia
|
|
24
|
+
* na que se olha, porque o `MICROTEIA.md` reescrito pelo `unpack` já anunciava a versão nova enquanto
|
|
25
|
+
* o `microteia.yaml` ao lado dele seguia na anterior.
|
|
26
|
+
*/
|
|
27
|
+
kernel: {
|
|
28
|
+
version: string | null;
|
|
29
|
+
deployed: string | null;
|
|
30
|
+
};
|
|
31
|
+
/**
|
|
32
|
+
* Quais Cloud Functions de EXTENSÃO já foram deployadas neste projeto. Fica aqui, e não no banco,
|
|
33
|
+
* porque é estado da instalação e não do escritório — e é o que permite ao `provision` deployar só o
|
|
34
|
+
* delta e reconhecer o que ficou órfão depois de uma desinstalação.
|
|
35
|
+
*
|
|
36
|
+
* Uma lista num escalar separado por vírgula porque o leitor de YAML daqui entende mapas de dois
|
|
37
|
+
* níveis e escalares, e trazer sequências para guardar meia dúzia de nomes seria pagar caro pela
|
|
38
|
+
* generalidade.
|
|
39
|
+
*/
|
|
40
|
+
backend: {
|
|
41
|
+
functions: string | null;
|
|
42
|
+
/**
|
|
43
|
+
* Uma impressão digital por parâmetro de backend já aplicado (`NOME:digest`), para o
|
|
44
|
+
* provisionamento reconhecer o valor que foi preenchido ou trocado DEPOIS do deploy — ver
|
|
45
|
+
* `backendParameters.ts`. Sem isso, a function deployada continua com o valor anterior e nenhum
|
|
46
|
+
* comando repara nisso.
|
|
47
|
+
*/
|
|
48
|
+
parameters: string | null;
|
|
49
|
+
/**
|
|
50
|
+
* Os índices compostos declarados por causa de uma extensão — ver `backendIndexes.ts`. Fica na
|
|
51
|
+
* RAIZ porque tem de sobreviver ao `unpack`, que apaga `.microteia/install/` inteiro e devolve o
|
|
52
|
+
* `firestore.indexes.json` do pacote, que é só a baseline.
|
|
53
|
+
*/
|
|
54
|
+
indexes: string | null;
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
export declare const ENVIRONMENT_FILE = "microteia.yaml";
|
|
58
|
+
export declare const parseEnvironment: (source: string) => Environment;
|
|
59
|
+
export declare const serializeEnvironment: (environment: Environment) => string;
|
|
60
|
+
export declare const readEnvironment: (path: string) => Promise<Environment>;
|
|
61
|
+
export declare const writeEnvironment: (path: string, environment: Environment) => Promise<void>;
|
|
62
|
+
export declare const slug: (value: string) => string;
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
import { readFile, writeFile } from 'node:fs/promises';
|
|
2
|
+
export const ENVIRONMENT_FILE = 'microteia.yaml';
|
|
3
|
+
/**
|
|
4
|
+
* O YAML aqui é um SUBCONJUNTO deliberado: mapas de dois níveis com valores escalares, que é a forma
|
|
5
|
+
* exata deste arquivo. Não há biblioteca de YAML no repositório, e trazer uma para ler quatro seções
|
|
6
|
+
* seria pagar uma dependência pela generalidade que não vamos usar. O que o leitor não entende ele
|
|
7
|
+
* REJEITA com o número da linha, em vez de adivinhar.
|
|
8
|
+
*/
|
|
9
|
+
const unquote = (raw) => {
|
|
10
|
+
const value = raw.trim();
|
|
11
|
+
if (value === '' || value === 'null' || value === '~')
|
|
12
|
+
return null;
|
|
13
|
+
if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) {
|
|
14
|
+
return value.slice(1, -1).replace(/\\"/g, '"').replace(/\\\\/g, '\\');
|
|
15
|
+
}
|
|
16
|
+
return value;
|
|
17
|
+
};
|
|
18
|
+
const needsQuotes = (value) => value === ''
|
|
19
|
+
|| /^[-?:,[\]{}#&*!|>'"%@`]/.test(value)
|
|
20
|
+
|| value.includes(': ')
|
|
21
|
+
|| value.includes(' #')
|
|
22
|
+
|| /^(true|false|null|yes|no|on|off)$/i.test(value)
|
|
23
|
+
|| /^-?\d+(\.\d+)?$/.test(value)
|
|
24
|
+
|| value.trim() !== value;
|
|
25
|
+
const quote = (value) => {
|
|
26
|
+
if (value === null)
|
|
27
|
+
return 'null';
|
|
28
|
+
if (!needsQuotes(value))
|
|
29
|
+
return value;
|
|
30
|
+
return `"${value.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`;
|
|
31
|
+
};
|
|
32
|
+
export const parseEnvironment = (source) => {
|
|
33
|
+
const sections = new Map();
|
|
34
|
+
let current = null;
|
|
35
|
+
const lines = source.split('\n');
|
|
36
|
+
for (let index = 0; index < lines.length; index += 1) {
|
|
37
|
+
const line = lines[index];
|
|
38
|
+
// O comentário pode estar INDENTADO: o arquivo que este módulo gera tem um dentro da seção
|
|
39
|
+
// `firebase`, e olhar só a coluna zero faria o leitor rejeitar o que ele mesmo escreveu.
|
|
40
|
+
const withoutComment = line.trim().startsWith('#') ? '' : line;
|
|
41
|
+
if (withoutComment.trim() === '')
|
|
42
|
+
continue;
|
|
43
|
+
const topLevel = withoutComment.match(/^([A-Za-z][A-Za-z0-9_]*):\s*$/);
|
|
44
|
+
if (topLevel !== null) {
|
|
45
|
+
current = new Map();
|
|
46
|
+
sections.set(topLevel[1], current);
|
|
47
|
+
continue;
|
|
48
|
+
}
|
|
49
|
+
const entry = withoutComment.match(/^ {2}([A-Za-z][A-Za-z0-9_]*):\s*(.*)$/);
|
|
50
|
+
if (entry !== null) {
|
|
51
|
+
if (current === null) {
|
|
52
|
+
throw new Error(`${ENVIRONMENT_FILE} line ${index + 1}: entry outside of any section.`);
|
|
53
|
+
}
|
|
54
|
+
current.set(entry[1], unquote(entry[2]));
|
|
55
|
+
continue;
|
|
56
|
+
}
|
|
57
|
+
throw new Error(`${ENVIRONMENT_FILE} line ${index + 1}: unsupported syntax: ${line.trim()}`);
|
|
58
|
+
}
|
|
59
|
+
const required = (section, key) => {
|
|
60
|
+
const value = sections.get(section)?.get(key);
|
|
61
|
+
if (value === undefined || value === null || value === '') {
|
|
62
|
+
throw new Error(`${ENVIRONMENT_FILE} is missing ${section}.${key}.`);
|
|
63
|
+
}
|
|
64
|
+
return value;
|
|
65
|
+
};
|
|
66
|
+
const optional = (section, key) => sections.get(section)?.get(key) ?? null;
|
|
67
|
+
return {
|
|
68
|
+
project: { name: required('project', 'name') },
|
|
69
|
+
environment: {
|
|
70
|
+
id: required('environment', 'id'),
|
|
71
|
+
name: required('environment', 'name'),
|
|
72
|
+
},
|
|
73
|
+
firebase: {
|
|
74
|
+
projectId: optional('firebase', 'projectId'),
|
|
75
|
+
region: required('firebase', 'region'),
|
|
76
|
+
},
|
|
77
|
+
kernel: {
|
|
78
|
+
version: optional('kernel', 'version'),
|
|
79
|
+
deployed: optional('kernel', 'deployed'),
|
|
80
|
+
},
|
|
81
|
+
backend: {
|
|
82
|
+
functions: optional('backend', 'functions'),
|
|
83
|
+
parameters: optional('backend', 'parameters'),
|
|
84
|
+
indexes: optional('backend', 'indexes'),
|
|
85
|
+
},
|
|
86
|
+
};
|
|
87
|
+
};
|
|
88
|
+
export const serializeEnvironment = (environment) => `# Microteia installation.
|
|
89
|
+
#
|
|
90
|
+
# Created by "microteia init" and completed by "microteia connect" and "microteia deploy".
|
|
91
|
+
# It describes ONE installation bound to ONE Firebase project. It holds no credentials and is safe
|
|
92
|
+
# to keep next to your own Cloud Functions — but it is specific to this installation, so it does not
|
|
93
|
+
# belong in a shared repository.
|
|
94
|
+
|
|
95
|
+
project:
|
|
96
|
+
name: ${quote(environment.project.name)}
|
|
97
|
+
|
|
98
|
+
environment:
|
|
99
|
+
id: ${quote(environment.environment.id)}
|
|
100
|
+
name: ${quote(environment.environment.name)}
|
|
101
|
+
|
|
102
|
+
firebase:
|
|
103
|
+
projectId: ${quote(environment.firebase.projectId)}
|
|
104
|
+
# The region the kernel is compiled for. Cloud Functions are pinned to it, and Firestore must be
|
|
105
|
+
# in the same region or its triggers will never fire. Changing it here does not move anything.
|
|
106
|
+
region: ${quote(environment.firebase.region)}
|
|
107
|
+
|
|
108
|
+
kernel:
|
|
109
|
+
# The version in place in this installation, written by "microteia unpack".
|
|
110
|
+
version: ${quote(environment.kernel.version)}
|
|
111
|
+
# The version already published to the Firebase project, written by "microteia deploy". It trails
|
|
112
|
+
# the one above between taking a package and publishing it, which is what those two commands are
|
|
113
|
+
# separate for.
|
|
114
|
+
deployed: ${quote(environment.kernel.deployed)}
|
|
115
|
+
|
|
116
|
+
backend:
|
|
117
|
+
# Cloud Functions deployed because an installed extension asked for them. Managed by
|
|
118
|
+
# "microteia provision" — editing it by hand only makes the next run deploy or keep the wrong set.
|
|
119
|
+
functions: ${quote(environment.backend.functions)}
|
|
120
|
+
# A fingerprint of each backend setting already applied from .env.<projectId> (next to this file).
|
|
121
|
+
# It is how "microteia provision" notices a setting filled in after the deploy and redeploys the
|
|
122
|
+
# functions that carry it. It holds no value, only a salted digest of each one — editing it by hand only makes
|
|
123
|
+
# the next run redeploy, or skip, the wrong set.
|
|
124
|
+
parameters: ${quote(environment.backend.parameters)}
|
|
125
|
+
# Composite indexes declared because an installed extension asked for them. It has to live here,
|
|
126
|
+
# and not only in the unpacked package, because "microteia unpack" replaces that whole directory
|
|
127
|
+
# and the package carries only the kernel's own indexes. Without it, a kernel upgrade made
|
|
128
|
+
# "firebase deploy" offer to delete the indexes of every extension. Managed by
|
|
129
|
+
# "microteia provision".
|
|
130
|
+
indexes: ${quote(environment.backend.indexes)}
|
|
131
|
+
`;
|
|
132
|
+
export const readEnvironment = async (path) => {
|
|
133
|
+
try {
|
|
134
|
+
return parseEnvironment(await readFile(path, 'utf8'));
|
|
135
|
+
}
|
|
136
|
+
catch (error) {
|
|
137
|
+
if (error.code === 'ENOENT') {
|
|
138
|
+
throw new Error(`No ${ENVIRONMENT_FILE} here. Run "microteia init" first.`, { cause: error });
|
|
139
|
+
}
|
|
140
|
+
throw error;
|
|
141
|
+
}
|
|
142
|
+
};
|
|
143
|
+
export const writeEnvironment = async (path, environment) => {
|
|
144
|
+
await writeFile(path, serializeEnvironment(environment));
|
|
145
|
+
};
|
|
146
|
+
export const slug = (value) => value
|
|
147
|
+
.normalize('NFD')
|
|
148
|
+
.replace(/[\u0300-\u036f]/g, '')
|
|
149
|
+
.toLowerCase()
|
|
150
|
+
.replace(/[^a-z0-9]+/g, '-')
|
|
151
|
+
.replace(/^-+|-+$/g, '');
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tudo aqui passa pelo `firebase` oficial. O Console não implementa autenticação própria e nunca vê
|
|
3
|
+
* credencial do cliente: quem está logado é quem rodou `firebase login`, e o projeto é dele.
|
|
4
|
+
*/
|
|
5
|
+
export declare const FIREBASE_BIN = "firebase";
|
|
6
|
+
export interface WebAppSummary {
|
|
7
|
+
appId: string;
|
|
8
|
+
displayName: string;
|
|
9
|
+
}
|
|
10
|
+
export declare const parseJson: (raw: string) => unknown;
|
|
11
|
+
export declare const assertFirebaseCli: () => Promise<string>;
|
|
12
|
+
export declare const assertLoggedIn: () => Promise<void>;
|
|
13
|
+
export declare const listWebApps: (projectId: string) => Promise<WebAppSummary[]>;
|
|
14
|
+
export declare const createWebApp: (projectId: string, displayName: string) => Promise<string>;
|
|
15
|
+
export declare const webAppSdkConfig: (projectId: string, appId: string) => Promise<Record<string, string>>;
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { capture, captureOrThrow } from '../shared/run.js';
|
|
2
|
+
/**
|
|
3
|
+
* Tudo aqui passa pelo `firebase` oficial. O Console não implementa autenticação própria e nunca vê
|
|
4
|
+
* credencial do cliente: quem está logado é quem rodou `firebase login`, e o projeto é dele.
|
|
5
|
+
*/
|
|
6
|
+
export const FIREBASE_BIN = 'firebase';
|
|
7
|
+
export const parseJson = (raw) => {
|
|
8
|
+
// O `firebase --json` às vezes imprime aviso antes do JSON; o objeto começa na primeira chave.
|
|
9
|
+
const start = raw.indexOf('{');
|
|
10
|
+
if (start === -1)
|
|
11
|
+
throw new Error(`Expected JSON from the Firebase CLI, got:\n${raw.trim()}`);
|
|
12
|
+
return JSON.parse(raw.slice(start));
|
|
13
|
+
};
|
|
14
|
+
/**
|
|
15
|
+
* Procura recursivamente o objeto que contém a configuração do SDK. O `apps:sdkconfig` já mudou o
|
|
16
|
+
* formato de saída entre versões do `firebase`, e casar a forma exata quebraria a cada atualização
|
|
17
|
+
* do CLI do cliente — que não é uma versão que possamos fixar.
|
|
18
|
+
*/
|
|
19
|
+
const findSdkConfig = (value) => {
|
|
20
|
+
if (value === null || typeof value !== 'object')
|
|
21
|
+
return null;
|
|
22
|
+
if (!Array.isArray(value)) {
|
|
23
|
+
const record = value;
|
|
24
|
+
if (typeof record.apiKey === 'string' && typeof record.projectId === 'string') {
|
|
25
|
+
const config = {};
|
|
26
|
+
for (const [key, entry] of Object.entries(record)) {
|
|
27
|
+
if (typeof entry === 'string')
|
|
28
|
+
config[key] = entry;
|
|
29
|
+
}
|
|
30
|
+
return config;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
for (const entry of Array.isArray(value) ? value : Object.values(value)) {
|
|
34
|
+
const found = findSdkConfig(entry);
|
|
35
|
+
if (found !== null)
|
|
36
|
+
return found;
|
|
37
|
+
}
|
|
38
|
+
return null;
|
|
39
|
+
};
|
|
40
|
+
export const assertFirebaseCli = async () => {
|
|
41
|
+
const result = await capture(FIREBASE_BIN, ['--version']);
|
|
42
|
+
if (result.code !== 0) {
|
|
43
|
+
throw new Error('The Firebase CLI is required and was not found.\n'
|
|
44
|
+
+ 'Install it with: npm install -g firebase-tools');
|
|
45
|
+
}
|
|
46
|
+
return result.stdout.trim().split('\n')[0];
|
|
47
|
+
};
|
|
48
|
+
export const assertLoggedIn = async () => {
|
|
49
|
+
const result = await capture(FIREBASE_BIN, ['login:list']);
|
|
50
|
+
const output = `${result.stdout}${result.stderr}`;
|
|
51
|
+
if (result.code !== 0 || /no authorized accounts/i.test(output)) {
|
|
52
|
+
throw new Error('You are not signed in to Firebase. Run: firebase login');
|
|
53
|
+
}
|
|
54
|
+
};
|
|
55
|
+
export const listWebApps = async (projectId) => {
|
|
56
|
+
const result = await capture(FIREBASE_BIN, ['apps:list', 'WEB', '--project', projectId, '--json']);
|
|
57
|
+
if (result.code !== 0) {
|
|
58
|
+
throw new Error(`Cannot reach Firebase project "${projectId}".\n`
|
|
59
|
+
+ 'Check that the project id is correct and that the signed-in account has access to it.\n'
|
|
60
|
+
+ `${result.stderr.trim()}`);
|
|
61
|
+
}
|
|
62
|
+
const parsed = parseJson(result.stdout);
|
|
63
|
+
const apps = Array.isArray(parsed.result) ? parsed.result : [];
|
|
64
|
+
return apps
|
|
65
|
+
.filter((app) => app !== null && typeof app === 'object')
|
|
66
|
+
.map((app) => ({
|
|
67
|
+
appId: String(app.appId ?? ''),
|
|
68
|
+
displayName: String(app.displayName ?? ''),
|
|
69
|
+
}))
|
|
70
|
+
.filter((app) => app.appId !== '');
|
|
71
|
+
};
|
|
72
|
+
export const createWebApp = async (projectId, displayName) => {
|
|
73
|
+
const stdout = await captureOrThrow(FIREBASE_BIN, ['apps:create', 'WEB', displayName, '--project', projectId, '--json']);
|
|
74
|
+
const config = findSdkConfig(parseJson(stdout));
|
|
75
|
+
if (config !== null && config.appId !== undefined)
|
|
76
|
+
return config.appId;
|
|
77
|
+
const parsed = parseJson(stdout);
|
|
78
|
+
const appId = parsed.result?.appId;
|
|
79
|
+
if (typeof appId !== 'string')
|
|
80
|
+
throw new Error('Firebase did not return an app id for the new web app.');
|
|
81
|
+
return appId;
|
|
82
|
+
};
|
|
83
|
+
export const webAppSdkConfig = async (projectId, appId) => {
|
|
84
|
+
const stdout = await captureOrThrow(FIREBASE_BIN, ['apps:sdkconfig', 'WEB', appId, '--project', projectId, '--json']);
|
|
85
|
+
const config = findSdkConfig(parseJson(stdout));
|
|
86
|
+
if (config === null) {
|
|
87
|
+
throw new Error(`Could not read the web SDK configuration for app ${appId}.`);
|
|
88
|
+
}
|
|
89
|
+
return config;
|
|
90
|
+
};
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
export interface FirestoreDocument {
|
|
2
|
+
name: string;
|
|
3
|
+
fields: Record<string, unknown>;
|
|
4
|
+
}
|
|
5
|
+
export declare const accessToken: () => Promise<string>;
|
|
6
|
+
/** Um valor tipado da REST virando valor de JavaScript. Timestamp vira string ISO, que é o que basta. */
|
|
7
|
+
export declare const decodeValue: (value: unknown) => unknown;
|
|
8
|
+
export declare const decodeFields: (fields: Record<string, unknown>) => Record<string, unknown>;
|
|
9
|
+
/**
|
|
10
|
+
* Os documentos de uma coleção, seguindo a paginação até o fim.
|
|
11
|
+
*
|
|
12
|
+
* Sem máscara de campo, e de propósito: mascarar um caminho aninhado (`package.manifest`) funciona até
|
|
13
|
+
* uma versão da API discordar da forma, e o sintoma seria uma extensão sumindo do provisionamento sem
|
|
14
|
+
* erro. Documento de extensão é grande, mas são poucos por escritório, e isto roda uma vez por comando.
|
|
15
|
+
*/
|
|
16
|
+
export declare const listDocuments: (projectId: string, path: string) => Promise<FirestoreDocument[]>;
|
|
17
|
+
export declare const documentId: (document: FirestoreDocument) => string;
|
|
18
|
+
/** Um valor com o tipo que a REST espera, montado por quem sabe o que o campo é. */
|
|
19
|
+
export type FirestoreTypedValue = {
|
|
20
|
+
stringValue: string;
|
|
21
|
+
} | {
|
|
22
|
+
timestampValue: string;
|
|
23
|
+
};
|
|
24
|
+
export declare const asString: (value: string) => FirestoreTypedValue;
|
|
25
|
+
/**
|
|
26
|
+
* Data como DATA, e não como texto que se parece com uma.
|
|
27
|
+
*
|
|
28
|
+
* O tipo do valor gravado é escolhido aqui e lido do outro lado do produto, meses depois, por quem não
|
|
29
|
+
* viu esta linha. Gravar uma data como `stringValue` custou um botão que não funcionava: a tela lia o
|
|
30
|
+
* texto, exibia certo, e só falhava ao gravar de volta. O `timestampValue` fecha essa porta — e é
|
|
31
|
+
* também o que permite ordenar e comparar o campo no banco. A REST exige RFC 3339 em UTC, que é
|
|
32
|
+
* exatamente o que `toISOString` devolve.
|
|
33
|
+
*/
|
|
34
|
+
export declare const asTimestamp: (value: Date) => FirestoreTypedValue;
|
|
35
|
+
/**
|
|
36
|
+
* Grava UM campo de mapa, com `updateMask` — o resto do documento não é tocado. É o que permite o
|
|
37
|
+
* Console carimbar o estado sem conhecer (nem arriscar reescrever) o pacote inteiro que vive ao lado.
|
|
38
|
+
*/
|
|
39
|
+
export declare const patchMapField: (projectId: string, path: string, field: string, entries: Record<string, FirestoreTypedValue>) => Promise<void>;
|