@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.
Files changed (85) hide show
  1. package/CONSOLE-VERSIONS.md +85 -0
  2. package/README.md +64 -0
  3. package/lib/console/backendEnv.d.ts +52 -0
  4. package/lib/console/backendEnv.js +118 -0
  5. package/lib/console/backendIndexes.d.ts +40 -0
  6. package/lib/console/backendIndexes.js +178 -0
  7. package/lib/console/backendParameters.d.ts +48 -0
  8. package/lib/console/backendParameters.js +100 -0
  9. package/lib/console/callableAccess.d.ts +39 -0
  10. package/lib/console/callableAccess.js +61 -0
  11. package/lib/console/channel.d.ts +49 -0
  12. package/lib/console/channel.js +145 -0
  13. package/lib/console/connect.d.ts +2 -0
  14. package/lib/console/connect.js +74 -0
  15. package/lib/console/customFunctions.d.ts +120 -0
  16. package/lib/console/customFunctions.js +298 -0
  17. package/lib/console/deploy.d.ts +2 -0
  18. package/lib/console/deploy.js +261 -0
  19. package/lib/console/deployFunctions.d.ts +45 -0
  20. package/lib/console/deployFunctions.js +204 -0
  21. package/lib/console/environment.d.ts +62 -0
  22. package/lib/console/environment.js +151 -0
  23. package/lib/console/firebaseCli.d.ts +15 -0
  24. package/lib/console/firebaseCli.js +90 -0
  25. package/lib/console/firestoreRest.d.ts +39 -0
  26. package/lib/console/firestoreRest.js +131 -0
  27. package/lib/console/hostingCheck.d.ts +29 -0
  28. package/lib/console/hostingCheck.js +44 -0
  29. package/lib/console/init.d.ts +2 -0
  30. package/lib/console/init.js +60 -0
  31. package/lib/console/pluginWorkspace.d.ts +48 -0
  32. package/lib/console/pluginWorkspace.js +148 -0
  33. package/lib/console/provision.d.ts +70 -0
  34. package/lib/console/provision.js +478 -0
  35. package/lib/console/regionOrphans.d.ts +43 -0
  36. package/lib/console/regionOrphans.js +99 -0
  37. package/lib/console/runtimeConfigShape.d.ts +25 -0
  38. package/lib/console/runtimeConfigShape.js +35 -0
  39. package/lib/console/unpack.d.ts +9 -0
  40. package/lib/console/unpack.js +333 -0
  41. package/lib/console/versions.d.ts +10 -0
  42. package/lib/console/versions.js +72 -0
  43. package/lib/console/workspace.d.ts +19 -0
  44. package/lib/console/workspace.js +33 -0
  45. package/lib/index.d.ts +2 -0
  46. package/lib/index.js +136 -0
  47. package/lib/kernel/backendShape.d.ts +74 -0
  48. package/lib/kernel/backendShape.js +91 -0
  49. package/lib/kernel/channel.d.ts +79 -0
  50. package/lib/kernel/channel.js +157 -0
  51. package/lib/kernel/manifest.d.ts +138 -0
  52. package/lib/kernel/manifest.js +145 -0
  53. package/lib/shared/args.d.ts +14 -0
  54. package/lib/shared/args.js +44 -0
  55. package/lib/shared/digest.d.ts +30 -0
  56. package/lib/shared/digest.js +34 -0
  57. package/lib/shared/paths.d.ts +90 -0
  58. package/lib/shared/paths.js +97 -0
  59. package/lib/shared/run.d.ts +28 -0
  60. package/lib/shared/run.js +49 -0
  61. package/lib/shared/ui.d.ts +23 -0
  62. package/lib/shared/ui.js +43 -0
  63. package/lib/vendor/authoringGuide.d.ts +19 -0
  64. package/lib/vendor/authoringGuide.js +284 -0
  65. package/lib/vendor/bundleAudit.d.ts +19 -0
  66. package/lib/vendor/bundleAudit.js +95 -0
  67. package/lib/vendor/changelogs.d.ts +62 -0
  68. package/lib/vendor/changelogs.js +131 -0
  69. package/lib/vendor/exampleSources.d.ts +29 -0
  70. package/lib/vendor/exampleSources.js +126 -0
  71. package/lib/vendor/installationDocs.d.ts +35 -0
  72. package/lib/vendor/installationDocs.js +243 -0
  73. package/lib/vendor/pack.d.ts +2 -0
  74. package/lib/vendor/pack.js +276 -0
  75. package/lib/vendor/packConsole.d.ts +13 -0
  76. package/lib/vendor/packConsole.js +63 -0
  77. package/lib/vendor/packageTemplates.d.ts +66 -0
  78. package/lib/vendor/packageTemplates.js +200 -0
  79. package/lib/vendor/pluginWorkspaceTemplates.d.ts +19 -0
  80. package/lib/vendor/pluginWorkspaceTemplates.js +29 -0
  81. package/lib/vendor/publish.d.ts +2 -0
  82. package/lib/vendor/publish.js +277 -0
  83. package/lib/vendor/repository.d.ts +9 -0
  84. package/lib/vendor/repository.js +26 -0
  85. package/package.json +21 -0
@@ -0,0 +1,478 @@
1
+ import { dirname, join, resolve } from 'node:path';
2
+ import { hasFlag, requiredFlag, flagValue } from '../shared/args.js';
3
+ import { run } from '../shared/run.js';
4
+ import { blank, bullets, failure, heading, info, step, success, warn } from '../shared/ui.js';
5
+ import { backendFingerprint, claimedIndexes, claimedParameters, functionClaimProblems, hasBackendRequirement, indexKey, } from '../kernel/backendShape.js';
6
+ import { convergeIndexes, describeIndex, parseIndexState, readIndexFile, serializeIndexState, writeIndexFile, } from './backendIndexes.js';
7
+ import { officeEnvPath, stageOfficeEnv } from './backendEnv.js';
8
+ import { installPath, readInstalledManifest } from './unpack.js';
9
+ import { changedParameters, functionsDeclaringParameters, parameterDigests, parseParameterState, readBackendEnv, serializeParameterState, } from './backendParameters.js';
10
+ import { ENVIRONMENT_FILE, readEnvironment, writeEnvironment } from './environment.js';
11
+ import { FIREBASE_BIN } from './firebaseCli.js';
12
+ import { deployFunctionsOneByOne, reportFailures, reportNotices } from './deployFunctions.js';
13
+ import { clearRegionOrphans } from './regionOrphans.js';
14
+ import { checkCallables, invokerGrantCommand } from './callableAccess.js';
15
+ import { asString, asTimestamp, decodeFields, documentId, listDocuments, patchMapField } from './firestoreRest.js';
16
+ import { gatherCustomFunctions, readOfficeFunctions } from './customFunctions.js';
17
+ /**
18
+ * O que cada escritório do projeto instalou e exige do backend.
19
+ *
20
+ * A exigência vem do banco, então ela é DADO e não código — é por isso que `claimProblems` a confere
21
+ * contra `manifest.functions` antes de qualquer deploy. Nome de function fora do pacote é recusado com
22
+ * explicação; não há injeção possível, porque o código é sempre o do pacote.
23
+ */
24
+ export const readInstalledClaims = async (projectId) => {
25
+ const tenants = await listDocuments(projectId, '/tenants');
26
+ const claims = [];
27
+ for (const tenant of tenants) {
28
+ const tenantId = documentId(tenant);
29
+ const extensions = await listDocuments(projectId, `/tenants/${tenantId}/installed_extensions`);
30
+ for (const document of extensions) {
31
+ const data = decodeFields(document.fields);
32
+ if (data.active === false)
33
+ continue;
34
+ const pkg = data.package;
35
+ const manifest = pkg?.manifest;
36
+ const backend = manifest?.backend;
37
+ if (!hasBackendRequirement(backend))
38
+ continue;
39
+ const state = data.backendState;
40
+ claims.push({
41
+ tenantId,
42
+ documentPath: `/tenants/${tenantId}/installed_extensions/${documentId(document)}`,
43
+ pluginId: String(manifest?.id ?? documentId(document)),
44
+ name: String(manifest?.name ?? manifest?.id ?? documentId(document)),
45
+ version: String(manifest?.version ?? '0.0.0'),
46
+ backend: backend,
47
+ applied: state?.fingerprint === backendFingerprint(backend),
48
+ });
49
+ }
50
+ }
51
+ return claims.sort((a, b) => (a.pluginId < b.pluginId ? -1 : a.pluginId > b.pluginId ? 1 : 0));
52
+ };
53
+ const uniqueByPlugin = (claims) => {
54
+ const seen = new Set();
55
+ return claims.filter((claim) => {
56
+ if (seen.has(claim.pluginId))
57
+ return false;
58
+ seen.add(claim.pluginId);
59
+ return true;
60
+ });
61
+ };
62
+ export const parseAppliedFunctions = (value) => (value ?? '').split(',').map((name) => name.trim()).filter((name) => name !== '');
63
+ /**
64
+ * O motor: alinha o projeto Firebase com o que as extensões instaladas exigem, e carimba nelas o
65
+ * estado. Um motor só, chamado pelo `deploy` no fim e pelo `provision` sozinho — duas implementações
66
+ * divergiriam exatamente no ponto em que ninguém olha.
67
+ */
68
+ export const provisionBackend = async (options) => {
69
+ const { projectId, region, install, manifest, delaySeconds } = options;
70
+ const root = dirname(options.environmentFile);
71
+ const outcome = { deployed: [], failed: [], orphans: [], pending: [] };
72
+ /**
73
+ * O que uma extensão pode reivindicar é o que EXISTE para ser deployado: o que o pacote traz mais o
74
+ * que o escritório escreveu em `extensions/<plugin>/functions/` e em `functions/`.
75
+ *
76
+ * Conferir só contra o pacote recusava justamente o caso que o `microteia-plugin create function`
77
+ * gera — a extensão declarava a própria function em `manifest.backend.functions`, o nome não estava no
78
+ * pacote (nem poderia: o kernel não sabe o que o escritório vai escrever), e a extensão inteira era
79
+ * rejeitada com "this kernel package does not contain".
80
+ */
81
+ const office = await readOfficeFunctions(root);
82
+ const deployable = [...manifest.functions, ...office.all];
83
+ const claims = await readInstalledClaims(projectId);
84
+ const problems = functionClaimProblems(claims, deployable);
85
+ const rejected = new Set();
86
+ if (problems.length > 0) {
87
+ warn('some extensions ask for backend artifacts that are nowhere to be found:');
88
+ bullets(problems);
89
+ // A mensagem sozinha é um beco sem saída: ela diz que o nome não existe, e não onde ele deveria
90
+ // existir. As duas respostas possíveis são estas, e a segunda é a que mais acontece — a function é
91
+ // da extensão, o autor a declarou, e ninguém a exportou do `index.ts` do diretório dela.
92
+ info(' a function comes from ONE of two places, and it has to be in one of them:');
93
+ bullets([
94
+ 'this Microteia package — the extension claims a function the platform ships',
95
+ `extensions/<folder>/functions/index.ts of this installation — the extension brings its own`,
96
+ ]);
97
+ info(` this installation currently exports ${office.all.length} function(s) of its own`
98
+ + `${office.all.length === 0 ? '' : `: ${office.all.join(', ')}`}`);
99
+ for (const claim of claims) {
100
+ if (problems.some((problem) => problem.includes(claim.pluginId)))
101
+ rejected.add(claim.pluginId);
102
+ }
103
+ }
104
+ const valid = uniqueByPlugin(claims.filter((claim) => !rejected.has(claim.pluginId)));
105
+ /**
106
+ * Os índices são resolvidos ANTES do desvio de "nenhuma extensão exige nada" — e não depois, onde
107
+ * estavam. Desinstalar a única extensão com backend caía justamente naquele desvio, então o índice
108
+ * dela não era relatado por ninguém: o aviso existia para o caso de sobrar um órfão entre outras
109
+ * extensões, e faltava no caso de sobrar sozinho.
110
+ */
111
+ if (valid.length === 0) {
112
+ info(' no installed extension asks for backend artifacts');
113
+ }
114
+ else {
115
+ info(` ${valid.length} extension(s) ask for backend artifacts:`);
116
+ bullets(valid.map((claim) => `${claim.name} ${claim.version} (${claim.pluginId})`));
117
+ }
118
+ const settled = await settleIndexes(options, valid);
119
+ if (valid.length === 0) {
120
+ await recordBackendState(options, parseAppliedFunctions(options.environment.backend.functions), parseParameterState(options.environment.backend.parameters), settled.declared);
121
+ reportOrphanIndexes(projectId, settled.orphans, options.prune);
122
+ return outcome;
123
+ }
124
+ const wantedFunctions = deployable.filter((name) => valid.some((claim) => (claim.backend.functions ?? []).includes(name)));
125
+ const alreadyApplied = parseAppliedFunctions(options.environment.backend.functions);
126
+ const trackedParameters = claimedParameters(valid);
127
+ // A raiz, e não o `install/`: a fonte é o arquivo que o escritório edita ao lado do `microteia.yaml`.
128
+ const filledParameters = await readBackendEnv(dirname(options.environmentFile), projectId);
129
+ const recordedDigests = parseParameterState(options.environment.backend.parameters);
130
+ const currentDigests = parameterDigests(projectId, filledParameters, trackedParameters);
131
+ const missingParameters = trackedParameters.filter((name) => !filledParameters.has(name));
132
+ if (missingParameters.length > 0) {
133
+ warn(`${missingParameters.length} backend setting(s) are still empty in .env.${projectId}`);
134
+ info(` (${officeEnvPath(dirname(options.environmentFile), projectId)})`);
135
+ bullets(missingParameters);
136
+ info(' the functions deploy anyway, but the feature that needs them will fail at runtime.');
137
+ }
138
+ // Preencher o arquivo não alcança function nenhuma: o valor é carimbado no deploy de cada uma. O que
139
+ // já está aplicado com o valor anterior volta para a fila — sem isso, o parâmetro preenchido depois da
140
+ // instalação fica sem nenhum comando que o aplique, e a feature falha no runtime.
141
+ const changed = changedParameters(recordedDigests, currentDigests, trackedParameters);
142
+ const staleFunctions = new Set(functionsDeclaringParameters(valid, changed)
143
+ .filter((name) => wantedFunctions.includes(name) && alreadyApplied.includes(name)));
144
+ if (changed.length > 0) {
145
+ blank();
146
+ info(` ${changed.length} backend setting(s) changed since the last run: ${changed.join(', ')}`);
147
+ if (staleFunctions.size > 0) {
148
+ info(` ${staleFunctions.size} deployed function(s) still carry the previous value and are`);
149
+ info(' redeployed now — a Cloud Function only sees a setting from the deploy that stamps it.');
150
+ }
151
+ }
152
+ const prerequisites = valid.flatMap((claim) => (claim.backend.prerequisites ?? []).map((prerequisite) => `[${claim.name}] ${prerequisite.title}\n ${prerequisite.detail}`));
153
+ if (prerequisites.length > 0) {
154
+ blank();
155
+ info('Manual steps these extensions need, which no command can do for you:');
156
+ bullets(prerequisites);
157
+ // O caminho sai daqui, resolvido, e não do texto de cada extensão: era assim que ele estava, e foi
158
+ // assim que ficou errado quando a configuração se mudou para a raiz.
159
+ info(` Backend settings go in ${officeEnvPath(dirname(options.environmentFile), projectId)}`);
160
+ info(' After filling one in, run "microteia provision" again — the value is stamped into each');
161
+ info(' Cloud Function at ITS deploy, so a function already running keeps the previous one.');
162
+ blank();
163
+ }
164
+ const toDeploy = wantedFunctions.filter((name) => !alreadyApplied.includes(name) || staleFunctions.has(name));
165
+ if (toDeploy.length === 0) {
166
+ success(`the ${wantedFunctions.length} function(s) these extensions need are already deployed`);
167
+ }
168
+ else {
169
+ // A cópia que o `firebase-functions` lê é atualizada AGORA, e não só no `deploy`: este comando é o
170
+ // que aplica um parâmetro preenchido depois da instalação, e subir a function contra uma cópia
171
+ // velha carimbaria nela justamente o valor que se acabou de trocar.
172
+ await stageOfficeEnv(dirname(options.environmentFile), projectId, install);
173
+ // As fontes do escritório são remontadas AGORA quando alguma delas está na fila: este comando roda
174
+ // sozinho depois de instalar uma extensão, e nesse caminho ninguém passou pelo `deploy` que as
175
+ // monta. Subir sem isso deployaria o `custom/` da montagem anterior — ou nenhum.
176
+ if (toDeploy.some((name) => office.all.includes(name))) {
177
+ await gatherCustomFunctions(root, install, manifest.functions);
178
+ info(' installing function dependencies…');
179
+ await run('npm', ['install', '--no-audit', '--no-fund'], { cwd: join(install, 'functions') });
180
+ }
181
+ // A extensão que mudou de região deixa a cópia antiga para trás igual ao kernel, e ali o efeito
182
+ // é o mesmo: deploy recusado em toda tentativa, e gatilho disparando em duas regiões.
183
+ await clearRegionOrphans(projectId, toDeploy, region, options.prune);
184
+ info(` deploying ${toDeploy.length} function(s), one at a time…`);
185
+ const report = await deployFunctionsOneByOne(toDeploy, {
186
+ projectId, cwd: install, delaySeconds, force: false,
187
+ });
188
+ outcome.deployed = report.deployed;
189
+ outcome.failed = report.failed.map((entry) => entry.name);
190
+ reportFailures(report.failed);
191
+ reportNotices(report.notices);
192
+ await checkNewCallables([...(manifest.callables ?? []), ...office.callables], report.deployed, projectId, region);
193
+ }
194
+ const deployedNow = new Set([...alreadyApplied, ...outcome.deployed]);
195
+ for (const claim of valid) {
196
+ const satisfied = (claim.backend.functions ?? []).every((name) => deployedNow.has(name));
197
+ if (satisfied) {
198
+ await stampApplied(projectId, claims, claim);
199
+ }
200
+ else {
201
+ outcome.pending.push(claim);
202
+ }
203
+ }
204
+ const orphans = alreadyApplied.filter((name) => !wantedFunctions.includes(name));
205
+ outcome.orphans = orphans;
206
+ const remaining = await settleOrphans(options, orphans);
207
+ await recordBackendState(options, appliedAfterRun({
208
+ alreadyApplied,
209
+ wanted: wantedFunctions,
210
+ deployed: outcome.deployed,
211
+ keptOrphans: remaining,
212
+ }), appliedParameterState({
213
+ recorded: recordedDigests,
214
+ current: currentDigests,
215
+ changed,
216
+ failed: outcome.failed,
217
+ claims: valid,
218
+ }), settled.declared);
219
+ reportOrphanIndexes(projectId, settled.orphans, options.prune);
220
+ return outcome;
221
+ };
222
+ /**
223
+ * O que o projeto tem de declarar de índice composto, e o que sobrou de uma extensão que saiu.
224
+ *
225
+ * A memória vem da RAIZ (`backend.indexes` do `microteia.yaml`), não do arquivo instalado: é o `unpack`
226
+ * que apaga `.microteia/install/` inteiro, e o pacote traz apenas a baseline. Enquanto o único registro
227
+ * era o arquivo de lá, trocar de kernel apagava a memória — e o `firebase deploy` seguinte oferecia
228
+ * apagar do PROJETO o índice de toda extensão instalada, o que é uma consulta em produção por resposta
229
+ * errada a uma pergunta feita no meio de um upgrade.
230
+ *
231
+ * O arquivo instalado entra na leitura só para ADOTAR o que uma execução anterior a este campo havia
232
+ * declarado. Passada essa vez, ele é derivado daqui.
233
+ */
234
+ const settleIndexes = async (options, valid) => {
235
+ const baseline = options.manifest.baselineIndexes ?? [];
236
+ const baselineKeys = new Set(baseline.map(indexKey));
237
+ const file = await readIndexFile(options.install);
238
+ const remembered = new Map();
239
+ for (const index of parseIndexState(options.environment.backend.indexes)) {
240
+ remembered.set(indexKey(index), index);
241
+ }
242
+ for (const index of file.indexes) {
243
+ if (!baselineKeys.has(indexKey(index)))
244
+ remembered.set(indexKey(index), index);
245
+ }
246
+ const wanted = claimedIndexes(valid);
247
+ const wantedKeys = new Set(wanted.map(indexKey));
248
+ const orphans = [...remembered.values()].filter((index) => !wantedKeys.has(indexKey(index)));
249
+ /**
250
+ * O órfão CONTINUA declarado, a não ser que se peça `--prune`. Deixá-lo cair fora do arquivo é o que
251
+ * fazia o `firebase` oferecer a exclusão dele; mantê-lo declarado custa nada (o índice já existe) e
252
+ * torna a remoção um ato explícito. Com `--prune` ele sai do arquivo e da memória — e é a próxima
253
+ * publicação de índices que oferece apagá-lo do projeto, com a pergunta feita na hora certa.
254
+ */
255
+ const declared = options.prune ? wanted : [...wanted, ...orphans];
256
+ const convergence = convergeIndexes(file.indexes, baseline, declared);
257
+ if (!convergence.changed) {
258
+ if (wanted.length > 0)
259
+ success('the composite indexes these extensions need are already declared');
260
+ return { declared, orphans };
261
+ }
262
+ await writeIndexFile(options.install, { ...file, indexes: convergence.desired });
263
+ if (convergence.missing.length === 0)
264
+ return { declared, orphans };
265
+ info(` creating ${convergence.missing.length} composite index(es)…`);
266
+ await run(FIREBASE_BIN, ['deploy', '--only', 'firestore:indexes', '--project', options.projectId], { cwd: options.install });
267
+ return { declared, orphans };
268
+ };
269
+ const checkNewCallables = async (known, deployed, projectId, region) => {
270
+ const callables = known.filter((name) => deployed.includes(name));
271
+ if (callables.length === 0)
272
+ return;
273
+ const origin = `https://${projectId}.web.app`;
274
+ const checks = await checkCallables(callables, { projectId, region, origin });
275
+ const blocked = checks.filter((check) => check.access === 'blocked');
276
+ if (blocked.length === 0) {
277
+ success(`all ${checks.length} new callable(s) answer calls from ${origin}`);
278
+ return;
279
+ }
280
+ failure(`${blocked.length} of ${checks.length} new callable(s) refuse calls from ${origin}.`);
281
+ info(' The application will open and these calls will fail in the browser as what looks like a');
282
+ info(' CORS error. Run once per function:');
283
+ bullets(blocked.map((check) => invokerGrantCommand(region, projectId, check.name)));
284
+ };
285
+ /**
286
+ * O carimbo é gravado em TODAS as cópias daquela extensão — o artefato é do projeto, então se ele
287
+ * existe, existe para todo escritório que o instalou, e não só para aquele cuja cópia foi lida por
288
+ * último.
289
+ */
290
+ const stampApplied = async (projectId, all, claim) => {
291
+ const fingerprint = backendFingerprint(claim.backend);
292
+ const appliedAt = new Date();
293
+ for (const copy of all.filter((entry) => entry.pluginId === claim.pluginId)) {
294
+ if (backendFingerprint(copy.backend) !== fingerprint)
295
+ continue;
296
+ await patchMapField(projectId, copy.documentPath, 'backendState', {
297
+ fingerprint: asString(fingerprint),
298
+ appliedAt: asTimestamp(appliedAt),
299
+ });
300
+ }
301
+ success(`${claim.name} is ready in ${projectId}`);
302
+ };
303
+ /**
304
+ * Function que nenhuma extensão instalada exige mais. Apagar é destrutivo e irreversível, então o
305
+ * padrão RELATA e só `--prune` remove — e o que foi removido sai da lista de aplicadas.
306
+ */
307
+ const settleOrphans = async (options, orphans) => {
308
+ if (orphans.length === 0)
309
+ return [];
310
+ blank();
311
+ if (!options.prune) {
312
+ warn(`${orphans.length} function(s) are deployed but no installed extension needs them:`);
313
+ bullets(orphans);
314
+ info(' they keep working and cost nothing while idle. To remove them:');
315
+ bullets(['microteia provision --prune']);
316
+ blank();
317
+ return orphans;
318
+ }
319
+ info(` deleting ${orphans.length} orphan function(s)…`);
320
+ const deleted = [];
321
+ for (const name of orphans) {
322
+ try {
323
+ await run(FIREBASE_BIN, ['functions:delete', name, '--project', options.projectId, '--region', options.region, '--force'], { cwd: options.install });
324
+ deleted.push(name);
325
+ }
326
+ catch {
327
+ warn(`could not delete ${name}; it stays recorded as deployed`);
328
+ }
329
+ }
330
+ success(`${deleted.length} orphan function(s) removed`);
331
+ return orphans.filter((name) => !deleted.includes(name));
332
+ };
333
+ /**
334
+ * Índice órfão é RELATADO, nunca apagado às cegas: o `firebase deploy` não remove índice por conta, e a
335
+ * API que remove pede o id GERADO do índice — apagar o errado custa uma consulta em produção, e listá-lo
336
+ * custa três linhas de saída.
337
+ *
338
+ * O que mudou é que ele segue DECLARADO enquanto ninguém pedir o contrário. Antes ele caía fora do
339
+ * arquivo, e a exclusão acabava sendo oferecida pelo `firebase` na publicação seguinte — uma pergunta
340
+ * sobre a coleção de uma extensão, feita no meio de um upgrade, a quem está lendo saída de deploy.
341
+ */
342
+ const reportOrphanIndexes = (projectId, orphans, prune) => {
343
+ if (orphans.length === 0)
344
+ return;
345
+ blank();
346
+ warn(`${orphans.length} composite index(es) belong to extensions that are no longer installed:`);
347
+ bullets(orphans.map(describeIndex));
348
+ if (!prune) {
349
+ info(' they stay declared, so no deploy offers to delete them, and they cost only writes on the');
350
+ info(' collections they cover. To let them go:');
351
+ bullets(['microteia provision --prune']);
352
+ blank();
353
+ return;
354
+ }
355
+ warn(' --prune removed them from this installation, and nothing deleted them from the project:');
356
+ info(' the delete API needs each index\'s generated id, and guessing wrong costs a query in');
357
+ info(' production. The next index publish offers to delete them, and you answer it deliberately:');
358
+ bullets([
359
+ 'microteia deploy --only indexes',
360
+ `gcloud firestore indexes composite list --project=${projectId}`,
361
+ ]);
362
+ blank();
363
+ };
364
+ /**
365
+ * A lista aplicada CONVERGE para o que ainda é exigido: o que já estava e continua sendo pedido, mais
366
+ * o que subiu agora, mais o órfão que não foi removido. Reescrevê-la só com o que subiu NESTA execução
367
+ * a esvaziava na segunda passada — e a terceira redeployava tudo, achando que nada havia sido feito.
368
+ */
369
+ export const appliedAfterRun = (input) => [...new Set([
370
+ ...input.alreadyApplied.filter((name) => input.wanted.includes(name)),
371
+ ...input.deployed,
372
+ ...input.keptOrphans,
373
+ ])].sort();
374
+ /**
375
+ * A impressão digital do parâmetro só avança quando TODAS as functions que o carregam subiram. Gravar
376
+ * antes disso é o que transformaria uma falha de deploy em silêncio permanente: o valor novo ficaria
377
+ * registrado como aplicado, e nenhuma execução seguinte tentaria de novo.
378
+ *
379
+ * O que não é mais reivindicado por extensão nenhuma fica registrado em vez de ser removido: apagar
380
+ * faria uma extensão reinstalada redeployar functions que já carregam o valor certo, e uma linha a mais
381
+ * no arquivo não decide nada.
382
+ */
383
+ export const appliedParameterState = (input) => {
384
+ const failed = new Set(input.failed);
385
+ const next = new Map(input.recorded);
386
+ for (const name of input.changed) {
387
+ const carriers = functionsDeclaringParameters(input.claims, [name]);
388
+ if (carriers.some((functionName) => failed.has(functionName)))
389
+ continue;
390
+ const digest = input.current.get(name);
391
+ if (digest === undefined)
392
+ next.delete(name);
393
+ else
394
+ next.set(name, digest);
395
+ }
396
+ return next;
397
+ };
398
+ const recordBackendState = async (options, names, parameters, indexes) => {
399
+ const state = serializeIndexState(indexes);
400
+ /**
401
+ * Índice que a codificação não representa sem ambiguidade fica de fora COM AVISO, em vez de voltar do
402
+ * arquivo virado noutro índice. Só acontece com nome de coleção ou de campo que contenha um dos
403
+ * separadores, o que exigiria acento grave no caminho do campo — e "só aconteceria" é como um defeito
404
+ * silencioso começa.
405
+ */
406
+ if (state.skipped.length > 0) {
407
+ warn(`${state.skipped.length} composite index(es) cannot be remembered across a kernel upgrade:`);
408
+ bullets(state.skipped.map(describeIndex));
409
+ info(' a collection or field name uses one of the separators. Rename it, or a future');
410
+ info(' "microteia deploy" will offer to delete the index.');
411
+ }
412
+ await writeEnvironment(options.environmentFile, {
413
+ ...options.environment,
414
+ backend: {
415
+ functions: names.length === 0 ? null : names.join(','),
416
+ parameters: serializeParameterState(parameters),
417
+ indexes: state.value,
418
+ },
419
+ });
420
+ };
421
+ /** O comando: provisiona sem reenviar o kernel. É o que se roda depois de instalar uma extensão. */
422
+ export const provision = async (args) => {
423
+ const file = resolve(hasFlag(args, 'file') ? requiredFlag(args, 'file') : join(process.cwd(), ENVIRONMENT_FILE));
424
+ const root = dirname(file);
425
+ const delaySeconds = Number.parseInt(flagValue(args, 'delay') ?? '1', 10);
426
+ const environment = await readEnvironment(file);
427
+ const projectId = environment.firebase.projectId;
428
+ if (projectId === null) {
429
+ throw new Error('This installation is not connected yet. Run "microteia connect --project <id>".');
430
+ }
431
+ /**
432
+ * Quem recusa não haver pacote no lugar é o `readInstalledManifest` abaixo, e com o comando que falta.
433
+ * A pergunta aqui é outra: houve deploy? Provisionar sobe a function de uma EXTENSÃO, e fazer isso
434
+ * contra um projeto que nunca recebeu o kernel entrega uma extensão pendurada num backend que não
435
+ * existe.
436
+ *
437
+ * É aviso e não recusa porque uma instalação anterior ao campo `deployed` não tem como provar que foi
438
+ * publicada — e recusar por falta de prova impediria de provisionar uma instalação que está no ar.
439
+ */
440
+ if (environment.kernel.deployed === null) {
441
+ warn('there is no record of a kernel deploy in this installation.');
442
+ info(' if this project has never had one, run "microteia deploy" first: provisioning gives the');
443
+ info(' installed extensions their backend, not the platform itself.');
444
+ }
445
+ // Pela mesma porta que o `deploy` usa: ela recusa com o comando que falta, e confere a versão mínima
446
+ // do Console — montar o caminho à mão aqui devolveria um ENOENT cru.
447
+ const install = installPath(root);
448
+ const manifest = await readInstalledManifest(root);
449
+ const region = manifest.requires.functionsRegion;
450
+ heading(`Provisioning extension backends in ${projectId}`);
451
+ step(1, 1, 'Reading what the offices installed');
452
+ const outcome = await provisionBackend({
453
+ projectId,
454
+ region,
455
+ install,
456
+ manifest,
457
+ delaySeconds,
458
+ prune: hasFlag(args, 'prune'),
459
+ environmentFile: file,
460
+ environment,
461
+ });
462
+ blank();
463
+ if (outcome.failed.length > 0) {
464
+ failure(`${outcome.failed.length} function(s) did not deploy: ${outcome.failed.join(', ')}`);
465
+ info('Re-run to retry:');
466
+ bullets(['microteia provision']);
467
+ blank();
468
+ throw new Error('Some extension functions did not deploy.');
469
+ }
470
+ if (outcome.pending.length > 0) {
471
+ warn(`${outcome.pending.length} extension(s) are still pending: `
472
+ + outcome.pending.map((claim) => claim.name).join(', '));
473
+ blank();
474
+ return;
475
+ }
476
+ info('Every installed extension has what it needs. Reload the application to use them.');
477
+ blank();
478
+ };
@@ -0,0 +1,43 @@
1
+ export interface DeployedFunction {
2
+ name: string;
3
+ region: string;
4
+ }
5
+ /**
6
+ * A cópia da mesma function numa região que o pacote não usa mais.
7
+ *
8
+ * Mudar a região de uma Cloud Function não move nada: o Firebase cria a nova e a antiga fica. A
9
+ * partir daí o `firebase deploy --only functions:<nome>` PARA — o filtro `--only` casa por id e
10
+ * ignora região (`endpointMatchesFilter`), então a cópia velha entra no plano como algo a apagar, e
11
+ * apagar exige confirmação que este deploy não tem como dar. A recusa é a mesma em toda tentativa.
12
+ *
13
+ * E o deploy travado é o menor dos dois problemas. Enquanto as duas cópias existem, um gatilho de
14
+ * Firestore dispara NAS DUAS a cada gravação: linha de auditoria em dobro, agregado contado duas
15
+ * vezes, evento de workflow publicado duas vezes. Isso não aparece em lugar nenhum como erro.
16
+ */
17
+ export interface RegionOrphan {
18
+ name: string;
19
+ region: string;
20
+ }
21
+ /**
22
+ * Devolve `null` quando não deu para perguntar — conta sem permissão de listar, projeto fora do ar.
23
+ * Não saber não é motivo para impedir um deploy que talvez funcionasse: a verificação existe para
24
+ * explicar uma falha, não para criar uma.
25
+ */
26
+ export declare const listDeployedFunctions: (projectId: string) => Promise<DeployedFunction[] | null>;
27
+ /**
28
+ * Só o que ESTE deploy vai tocar. Casar por "região diferente da esperada" acusaria também uma
29
+ * function que o escritório escreveu de propósito em outra região; casar pelos nomes que estão para
30
+ * subir é exatamente o conjunto que o `firebase` vai querer apagar, nem um a mais.
31
+ */
32
+ export declare const regionOrphans: (deployed: DeployedFunction[], wanted: string[], region: string) => RegionOrphan[];
33
+ export declare const deleteCommand: (projectId: string, orphan: RegionOrphan) => string;
34
+ /**
35
+ * Avisa por padrão e só apaga com `--prune`, como `settleOrphans` faz com a function que perdeu dono.
36
+ * Apagar uma Cloud Function é irreversível e é do escritório, não nosso: o comando tem de ser pedido.
37
+ */
38
+ export declare const settleRegionOrphans: (projectId: string, orphans: RegionOrphan[], prune: boolean) => Promise<boolean>;
39
+ /**
40
+ * O passo inteiro, para quem só quer chamá-lo antes de deployar. Devolve `true` quando o caminho está
41
+ * livre — inclusive quando não deu para verificar, pela mesma razão de `listDeployedFunctions`.
42
+ */
43
+ export declare const clearRegionOrphans: (projectId: string, wanted: string[], region: string, prune: boolean) => Promise<boolean>;
@@ -0,0 +1,99 @@
1
+ import { capture } from '../shared/run.js';
2
+ import { blank, bullets, failure, info, pending, success, warn } from '../shared/ui.js';
3
+ import { FIREBASE_BIN, parseJson } from './firebaseCli.js';
4
+ import { failureReason } from './deployFunctions.js';
5
+ /**
6
+ * Devolve `null` quando não deu para perguntar — conta sem permissão de listar, projeto fora do ar.
7
+ * Não saber não é motivo para impedir um deploy que talvez funcionasse: a verificação existe para
8
+ * explicar uma falha, não para criar uma.
9
+ */
10
+ export const listDeployedFunctions = async (projectId) => {
11
+ const result = await capture(FIREBASE_BIN, ['functions:list', '--project', projectId, '--json']);
12
+ if (result.code !== 0)
13
+ return null;
14
+ try {
15
+ const parsed = parseJson(result.stdout);
16
+ if (!Array.isArray(parsed.result))
17
+ return null;
18
+ return parsed.result
19
+ .filter((entry) => entry !== null && typeof entry === 'object')
20
+ .map((entry) => ({ name: String(entry.id ?? ''), region: String(entry.region ?? '') }))
21
+ .filter((entry) => entry.name !== '' && entry.region !== '');
22
+ }
23
+ catch {
24
+ return null;
25
+ }
26
+ };
27
+ /**
28
+ * Só o que ESTE deploy vai tocar. Casar por "região diferente da esperada" acusaria também uma
29
+ * function que o escritório escreveu de propósito em outra região; casar pelos nomes que estão para
30
+ * subir é exatamente o conjunto que o `firebase` vai querer apagar, nem um a mais.
31
+ */
32
+ export const regionOrphans = (deployed, wanted, region) => {
33
+ const names = new Set(wanted);
34
+ return deployed
35
+ .filter((entry) => names.has(entry.name) && entry.region !== region)
36
+ .map((entry) => ({ name: entry.name, region: entry.region }))
37
+ .sort((left, right) => (left.name === right.name
38
+ ? left.region.localeCompare(right.region)
39
+ : left.name.localeCompare(right.name)));
40
+ };
41
+ export const deleteCommand = (projectId, orphan) => `firebase functions:delete ${orphan.name} --region ${orphan.region} --project ${projectId} --force`;
42
+ /**
43
+ * Avisa por padrão e só apaga com `--prune`, como `settleOrphans` faz com a function que perdeu dono.
44
+ * Apagar uma Cloud Function é irreversível e é do escritório, não nosso: o comando tem de ser pedido.
45
+ */
46
+ export const settleRegionOrphans = async (projectId, orphans, prune) => {
47
+ if (orphans.length === 0)
48
+ return true;
49
+ blank();
50
+ warn(`${orphans.length} function(s) are still deployed in a region this package no longer uses:`);
51
+ bullets(orphans.map((orphan) => `${orphan.name} (${orphan.region})`));
52
+ info(' Changing the region of a function does not move it: Firebase created the new one and left');
53
+ info(' the old one behind. The deploy of each of these will refuse to run — it would have to');
54
+ info(' delete the old copy first, and it has no terminal to ask. Every attempt answers the same.');
55
+ info(' While both copies exist, a Firestore trigger fires TWICE on every write: audit rows,');
56
+ info(' aggregates and workflow events are all counted twice, and nothing reports that as an error.');
57
+ if (!prune) {
58
+ info(' To remove them:');
59
+ bullets(['microteia deploy --prune']);
60
+ info(' or one at a time:');
61
+ bullets(orphans.map((orphan) => deleteCommand(projectId, orphan)));
62
+ blank();
63
+ return false;
64
+ }
65
+ const plural = orphans.length === 1 ? 'copy' : 'copies';
66
+ info(` --prune: removing the ${orphans.length} old ${plural} before deploying.`);
67
+ let cleared = true;
68
+ for (const orphan of orphans) {
69
+ const label = `${orphan.name} (${orphan.region})`;
70
+ pending(label);
71
+ const result = await capture(FIREBASE_BIN, [
72
+ 'functions:delete', orphan.name,
73
+ '--region', orphan.region,
74
+ '--project', projectId,
75
+ '--force',
76
+ ]);
77
+ if (result.code === 0) {
78
+ success(`${label} removed`);
79
+ }
80
+ else {
81
+ failure(`${label} could not be removed`);
82
+ info(` ${failureReason(`${result.stdout}${result.stderr}`)}`);
83
+ info(` ${deleteCommand(projectId, orphan)}`);
84
+ cleared = false;
85
+ }
86
+ }
87
+ blank();
88
+ return cleared;
89
+ };
90
+ /**
91
+ * O passo inteiro, para quem só quer chamá-lo antes de deployar. Devolve `true` quando o caminho está
92
+ * livre — inclusive quando não deu para verificar, pela mesma razão de `listDeployedFunctions`.
93
+ */
94
+ export const clearRegionOrphans = async (projectId, wanted, region, prune) => {
95
+ const deployed = await listDeployedFunctions(projectId);
96
+ if (deployed === null)
97
+ return true;
98
+ return settleRegionOrphans(projectId, regionOrphans(deployed, wanted, region), prune);
99
+ };
@@ -0,0 +1,25 @@
1
+ /**
2
+ * A configuração que o kernel espera encontrar em `window.__MICROTEIA_CONFIG__`.
3
+ *
4
+ * É o CONTRATO entre o Console e o kernel, e está declarado aqui de novo de propósito: o Console
5
+ * precisa poder ser publicado sem o `src/` do kernel junto, então não pode importar de lá. Para que
6
+ * as duas pontas não se separem em silêncio, `runtimeConfigShape.test.ts` confere esta lista contra
7
+ * `REQUIRED_CONFIG_KEYS` de `src/infrastructure/firebase/runtimeConfig.ts`.
8
+ */
9
+ export interface MicroteiaRuntimeConfig {
10
+ apiKey: string;
11
+ authDomain: string;
12
+ projectId: string;
13
+ storageBucket: string;
14
+ messagingSenderId: string;
15
+ appId: string;
16
+ measurementId?: string;
17
+ functionsRegion?: string;
18
+ }
19
+ export declare const REQUIRED_CONFIG_KEYS: readonly ["apiKey", "authDomain", "projectId", "storageBucket", "messagingSenderId", "appId"];
20
+ export declare const OPTIONAL_CONFIG_KEYS: readonly ["measurementId"];
21
+ /**
22
+ * Monta a configuração a partir do que o `firebase apps:sdkconfig` devolveu, recusando-se a seguir
23
+ * com campo faltando: uma instalação carimbada pela metade só falha quando alguém abre a aplicação.
24
+ */
25
+ export declare const runtimeConfigFrom: (sdkConfig: Record<string, string>, functionsRegion: string) => MicroteiaRuntimeConfig;