@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,298 @@
1
+ import { access, cp, mkdir, readFile, readdir, rm, writeFile } from 'node:fs/promises';
2
+ import { basename, join } from 'node:path';
3
+ import { callableFunctionNames, declaredParametersIn } from '../kernel/manifest.js';
4
+ /**
5
+ * As Cloud Functions do ESCRITÓRIO — as que ele escreve, não as do kernel.
6
+ *
7
+ * Elas moravam em `.microteia/install/functions/custom/src/`, e isso tinha o mesmo defeito que a
8
+ * configuração de backend tinha: editar à mão dentro de um diretório que o `unpack` substitui por
9
+ * inteiro. Sobreviviam porque `functions/custom` estava numa lista de exceção — o que funciona até
10
+ * alguém mexer na lista, e não resolve o problema de o lugar certo não ser óbvio.
11
+ *
12
+ * Agora a fonte fica fora, ao lado do `microteia.yaml`, em dois lugares que respondem a duas perguntas
13
+ * diferentes:
14
+ *
15
+ * - `extensions/<plugin>/functions/` — a function é DAQUELA extensão. Fica junto do plugin que a usa,
16
+ * e quem lê a pasta da extensão vê tudo o que ela é.
17
+ * - `functions/` — a function é COMUM a várias. É o lugar de quem não pertence a uma só.
18
+ *
19
+ * O que vai para dentro de `install/` é uma cópia montada no deploy, com um barril gerado. É por isso
20
+ * que `functions/custom` saiu da lista de preservação do `unpack`: não há mais nada ali para preservar.
21
+ */
22
+ /** O nome do diretório, nos dois lugares. */
23
+ export const FUNCTIONS_DIR = 'functions';
24
+ export const EXTENSIONS_DIR = 'extensions';
25
+ const exists = async (path) => {
26
+ try {
27
+ await access(path);
28
+ return true;
29
+ }
30
+ catch {
31
+ return false;
32
+ }
33
+ };
34
+ /**
35
+ * Os nomes que um módulo de entrada exporta. Cobre as duas formas que se escreve de fato: o
36
+ * `export const x = onCall(...)` de quem declara no lugar, e o `export { a, b }` de quem reexporta.
37
+ */
38
+ export const exportedNames = (source) => {
39
+ const names = new Set();
40
+ for (const match of source.matchAll(/export\s+(?:const|let|var|function|async\s+function)\s+([A-Za-z_$][\w$]*)/g)) {
41
+ names.add(match[1]);
42
+ }
43
+ for (const match of source.matchAll(/export\s*\{([^}]*)\}/g)) {
44
+ for (const part of match[1].split(',')) {
45
+ const name = part.trim().split(/\s+as\s+/).pop()?.trim();
46
+ if (name !== undefined && name !== '' && name !== 'default')
47
+ names.add(name);
48
+ }
49
+ }
50
+ return [...names].sort();
51
+ };
52
+ /** Todo `.ts` do diretório, recursivamente — a fonte das declarações que não moram no `index.ts`. */
53
+ const sourceFiles = async (path) => {
54
+ const found = [];
55
+ for (const entry of await readdir(path, { withFileTypes: true })) {
56
+ if (entry.name === 'node_modules' || entry.name.startsWith('.'))
57
+ continue;
58
+ const child = join(path, entry.name);
59
+ if (entry.isDirectory())
60
+ found.push(...await sourceFiles(child));
61
+ else if (entry.name.endsWith('.ts') && !entry.name.endsWith('.test.ts'))
62
+ found.push(child);
63
+ }
64
+ return found.sort();
65
+ };
66
+ /**
67
+ * As dependências npm que o diretório declara. O `package.json` é OPCIONAL: a maioria das functions do
68
+ * escritório só usa o que o pacote já traz, e exigir o arquivo cobraria cerimônia de quem não precisa.
69
+ */
70
+ const readDependencies = async (path, label) => {
71
+ const manifest = join(path, 'package.json');
72
+ if (!await exists(manifest))
73
+ return {};
74
+ const parsed = JSON.parse(await readFile(manifest, 'utf8'));
75
+ if (parsed.dependencies === undefined)
76
+ return {};
77
+ if (typeof parsed.dependencies !== 'object' || parsed.dependencies === null) {
78
+ throw new Error(`${label}/package.json has a "dependencies" that is not an object.`);
79
+ }
80
+ return parsed.dependencies;
81
+ };
82
+ const readSource = async (path, label, slug, shared) => {
83
+ const entry = join(path, 'index.ts');
84
+ if (!await exists(entry)) {
85
+ throw new Error(`${label}/ has no index.ts.\n`
86
+ + 'A directory of Cloud Functions is read through its index.ts — export from there what should be'
87
+ + ' deployed.');
88
+ }
89
+ const exported = exportedNames(await readFile(entry, 'utf8'));
90
+ const callables = new Set();
91
+ const parameters = new Map();
92
+ for (const file of await sourceFiles(path)) {
93
+ const source = await readFile(file, 'utf8');
94
+ for (const name of callableFunctionNames(source))
95
+ callables.add(name);
96
+ // Um nome declarado em vários lugares é UM parâmetro, e ele é exigido se ALGUMA declaração o pede
97
+ // sem `default`: é essa que recusa o deploy quando o valor falta, e a conservadora é a que avisa.
98
+ for (const parameter of declaredParametersIn(source)) {
99
+ const previous = parameters.get(parameter.name);
100
+ parameters.set(parameter.name, {
101
+ name: parameter.name,
102
+ required: parameter.required || (previous?.required ?? false),
103
+ });
104
+ }
105
+ }
106
+ return {
107
+ label,
108
+ slug,
109
+ path,
110
+ exported,
111
+ shared,
112
+ // Só as que o `index.ts` de fato exporta: uma callable escrita e não exportada não é deployada, e
113
+ // conferir o alcance dela mandaria procurar um erro que não existe.
114
+ callables: exported.filter((name) => callables.has(name)),
115
+ parameters: [...parameters.values()].sort((a, b) => a.name.localeCompare(b.name)),
116
+ dependencies: await readDependencies(path, label),
117
+ };
118
+ };
119
+ /** Onde o escritório pode ter escrito function, na ordem em que as mensagens as citam. */
120
+ export const findFunctionSources = async (root) => {
121
+ const found = [];
122
+ if (await exists(join(root, FUNCTIONS_DIR))) {
123
+ found.push(await readSource(join(root, FUNCTIONS_DIR), FUNCTIONS_DIR, 'shared', true));
124
+ }
125
+ const extensions = join(root, EXTENSIONS_DIR);
126
+ if (await exists(extensions)) {
127
+ for (const entry of (await readdir(extensions, { withFileTypes: true })).sort((a, b) => a.name.localeCompare(b.name))) {
128
+ if (!entry.isDirectory() || entry.name === 'node_modules' || entry.name.startsWith('.'))
129
+ continue;
130
+ const path = join(extensions, entry.name, FUNCTIONS_DIR);
131
+ if (!await exists(path))
132
+ continue;
133
+ found.push(await readSource(path, `${EXTENSIONS_DIR}/${entry.name}/${FUNCTIONS_DIR}`, entry.name, false));
134
+ }
135
+ }
136
+ return found;
137
+ };
138
+ export const officeFunctionsOf = (sources) => {
139
+ const parameters = new Map();
140
+ for (const source of sources) {
141
+ for (const parameter of source.parameters) {
142
+ const previous = parameters.get(parameter.name);
143
+ parameters.set(parameter.name, {
144
+ name: parameter.name,
145
+ required: parameter.required || (previous?.required ?? false),
146
+ });
147
+ }
148
+ }
149
+ return {
150
+ sources,
151
+ all: sources.flatMap((source) => source.exported),
152
+ shared: sources.filter((source) => source.shared).flatMap((source) => source.exported),
153
+ callables: sources.flatMap((source) => source.callables),
154
+ parameters: [...parameters.values()].sort((a, b) => a.name.localeCompare(b.name)),
155
+ };
156
+ };
157
+ /**
158
+ * O que o escritório escreveu, lido de onde ele escreve. É a porta única: `deploy`, `provision` e
159
+ * `unpack` precisam da mesma resposta, e três leituras divergiriam no ponto em que ninguém olha.
160
+ */
161
+ export const readOfficeFunctions = async (root) => officeFunctionsOf(await findFunctionSources(root));
162
+ /**
163
+ * Os problemas que impedem montar o conjunto. São recusa e não aviso, pelo mesmo motivo do `pack`: o
164
+ * `index.js` do pacote espalha o customizado DEPOIS do kernel, então um nome repetido substitui em
165
+ * silêncio — e substituir um gatilho do kernel é o tipo de defeito que só aparece quando algo deixa de
166
+ * acontecer, sem erro em lugar nenhum.
167
+ */
168
+ export const functionProblems = (sources, kernelFunctions) => {
169
+ const problems = [];
170
+ const kernel = new Set(kernelFunctions);
171
+ const seen = new Map();
172
+ for (const source of sources) {
173
+ if (source.exported.length === 0) {
174
+ problems.push(`${source.label}/index.ts exports nothing — remove the directory or export a function`);
175
+ }
176
+ for (const name of source.exported) {
177
+ if (kernel.has(name)) {
178
+ problems.push(`${source.label} exports "${name}", which is the name of a Microteia function — rename yours`);
179
+ }
180
+ const previous = seen.get(name);
181
+ if (previous !== undefined) {
182
+ problems.push(`"${name}" is exported by both ${previous} and ${source.label} — names must be unique`);
183
+ }
184
+ else {
185
+ seen.set(name, source.label);
186
+ }
187
+ }
188
+ }
189
+ return problems;
190
+ };
191
+ const barrel = (sources) => `// Generated by "microteia deploy". Do not edit.
192
+ //
193
+ // The Cloud Functions this installation adds, gathered from where they are written:
194
+ ${sources.map((source) => `// ${source.label}/`).join('\n')}
195
+ //
196
+ // Write them there, not here — this directory is rebuilt on every deploy.
197
+
198
+ ${sources.map((source) => `export * from './${source.slug}';`).join('\n')}
199
+ `;
200
+ /**
201
+ * O que de um diretório de functions vai para dentro do pacote — e o que NÃO vai.
202
+ *
203
+ * Teste fica fora, e não por economia de bytes: o `predeploy` do pacote compila `custom/` com o `tsc`, e
204
+ * um `import ... from 'vitest'` é `Cannot find module` ali dentro. O `vitest` é dependência de
205
+ * DESENVOLVIMENTO do escritório e não pode ser dependência das functions deployadas — então a compilação
206
+ * falha, e com ela falha o deploy de TODAS as functions, não só das da extensão que trouxe o teste.
207
+ *
208
+ * Foi assim que as cinco functions de uma extensão falharam juntas, com o relatório culpando cada uma
209
+ * delas. O `AGENTS.md` manda o autor escrever teste ao lado da function; era este lado que faltava.
210
+ *
211
+ * O `__fixtures__` continua entrando: é módulo comum, compila sozinho, e adivinhar pelo nome do
212
+ * diretório seria heurística. O que se reconhece aqui são as duas convenções inequívocas.
213
+ */
214
+ const deployable = (source) => {
215
+ const name = basename(source);
216
+ return !/\.(test|spec)\.tsx?$/.test(name) && name !== '__tests__';
217
+ };
218
+ export const dependencyPlan = (sources, kernelDependencies) => {
219
+ const added = {};
220
+ const overridden = [];
221
+ const problems = [];
222
+ const owner = new Map();
223
+ for (const source of sources) {
224
+ for (const [name, range] of Object.entries(source.dependencies)) {
225
+ const inKernel = kernelDependencies[name];
226
+ if (inKernel !== undefined) {
227
+ if (inKernel !== range)
228
+ overridden.push(`${name}: ${source.label} asks for ${range}, the package has ${inKernel}`);
229
+ continue;
230
+ }
231
+ const previous = added[name];
232
+ if (previous !== undefined && previous !== range) {
233
+ problems.push(`dependency "${name}" is declared as ${previous} by ${owner.get(name)} and as ${range} by `
234
+ + `${source.label} — one installation has one node_modules, so the two must agree`);
235
+ continue;
236
+ }
237
+ added[name] = range;
238
+ owner.set(name, source.label);
239
+ }
240
+ }
241
+ return { added, overridden: overridden.sort(), problems };
242
+ };
243
+ /**
244
+ * Escreve no `package.json` do pacote as dependências que os diretórios do escritório declaram.
245
+ *
246
+ * Elas não podem viajar no pacote: o kernel não sabe o que o escritório vai escrever. E o `npm install`
247
+ * do deploy é o que as instala — o `package-lock.json` que veio do pacote deixa de cobrir tudo, e o npm
248
+ * resolve o que falta e reescreve o lock. É por isso que a primeira instalação depois de declarar uma
249
+ * dependência nova demora mais que as seguintes.
250
+ */
251
+ export const applyFunctionDependencies = async (install, sources) => {
252
+ // Nenhum diretório declarou dependência: não há o que decidir, e não se abre o `package.json` do
253
+ // pacote para nada. É o caso comum — a maioria das functions do escritório só usa o que já vem.
254
+ if (sources.every((source) => Object.keys(source.dependencies).length === 0)) {
255
+ return { added: {}, overridden: [], problems: [] };
256
+ }
257
+ const path = join(install, 'functions', 'package.json');
258
+ const parsed = JSON.parse(await readFile(path, 'utf8'));
259
+ const kernel = parsed.dependencies ?? {};
260
+ const plan = dependencyPlan(sources, kernel);
261
+ if (plan.problems.length > 0) {
262
+ throw new Error(`Your Cloud Functions cannot be assembled:\n${plan.problems.map((problem) => ` ${problem}`).join('\n')}`);
263
+ }
264
+ if (Object.keys(plan.added).length === 0)
265
+ return plan;
266
+ const merged = Object.fromEntries(Object.entries({ ...kernel, ...plan.added }).sort(([left], [right]) => left.localeCompare(right)));
267
+ await writeFile(path, `${JSON.stringify({ ...parsed, dependencies: merged }, null, 2)}\n`);
268
+ return plan;
269
+ };
270
+ /**
271
+ * Monta `install/functions/custom/src/` a partir das fontes de fora, escreve o barril e acrescenta ao
272
+ * `package.json` do pacote as dependências que essas fontes declaram.
273
+ *
274
+ * O diretório é apagado antes: ele é DERIVADO, e um resto da montagem anterior seria deployado sem que
275
+ * nada apontasse a origem dele.
276
+ */
277
+ export const gatherCustomFunctions = async (root, install, kernelFunctions) => {
278
+ const sources = await findFunctionSources(root);
279
+ const problems = functionProblems(sources, kernelFunctions);
280
+ if (problems.length > 0) {
281
+ throw new Error(`Your Cloud Functions cannot be assembled:\n${problems.map((p) => ` ${p}`).join('\n')}`);
282
+ }
283
+ const target = join(install, 'functions', 'custom', 'src');
284
+ await rm(target, { recursive: true, force: true });
285
+ await mkdir(target, { recursive: true });
286
+ for (const source of sources) {
287
+ await cp(source.path, join(target, source.slug), { recursive: true, filter: deployable });
288
+ }
289
+ await writeFile(join(target, 'index.ts'), sources.length === 0
290
+ ? '// Generated by "microteia deploy". No Cloud Functions of your own were found.\nexport {};\n'
291
+ : barrel(sources));
292
+ const office = officeFunctionsOf(sources);
293
+ return {
294
+ ...office,
295
+ functions: [...office.all].sort(),
296
+ dependencies: await applyFunctionDependencies(install, sources),
297
+ };
298
+ };
@@ -0,0 +1,2 @@
1
+ import type { ParsedArgs } from '../shared/args.js';
2
+ export declare const deploy: (args: ParsedArgs) => Promise<void>;
@@ -0,0 +1,261 @@
1
+ import { writeFile } from 'node:fs/promises';
2
+ import { dirname, join, resolve } from 'node:path';
3
+ import { flagValue, hasFlag, requiredFlag } from '../shared/args.js';
4
+ import { run } from '../shared/run.js';
5
+ import { blank, bullets, failure, heading, info, step, success, warn } from '../shared/ui.js';
6
+ import { ENVIRONMENT_FILE, readEnvironment, writeEnvironment } from './environment.js';
7
+ import { FIREBASE_BIN } from './firebaseCli.js';
8
+ import { deployFunctionsOneByOne, reportFailures, reportNotices } from './deployFunctions.js';
9
+ import { clearRegionOrphans } from './regionOrphans.js';
10
+ import { checkCallables, invokerGrantCommand } from './callableAccess.js';
11
+ import { checkHostingServed, htmlIsRevalidated } from './hostingCheck.js';
12
+ import { provisionBackend } from './provision.js';
13
+ import { installPath, readInstalledManifest } from './unpack.js';
14
+ import { ensureOfficeEnv, ensureRootGitignore, officeEnvName, stageOfficeEnv } from './backendEnv.js';
15
+ import { gatherCustomFunctions, readOfficeFunctions } from './customFunctions.js';
16
+ import { readResolvedConfig, stampedConfigJs, writeResolvedConfig, } from './workspace.js';
17
+ const COMPONENTS = ['rules', 'indexes', 'functions', 'hosting'];
18
+ const selectedComponents = (args) => {
19
+ const only = flagValue(args, 'only');
20
+ if (only === undefined)
21
+ return [...COMPONENTS];
22
+ const requested = only.split(',').map((name) => name.trim());
23
+ const unknown = requested.filter((name) => !COMPONENTS.includes(name));
24
+ if (unknown.length > 0) {
25
+ throw new Error(`Unknown component(s): ${unknown.join(', ')}. Valid: ${COMPONENTS.join(', ')}.`);
26
+ }
27
+ return requested;
28
+ };
29
+ export const deploy = async (args) => {
30
+ const file = resolve(hasFlag(args, 'file') ? requiredFlag(args, 'file') : join(process.cwd(), ENVIRONMENT_FILE));
31
+ const root = dirname(file);
32
+ const components = selectedComponents(args);
33
+ const delaySeconds = Number.parseInt(flagValue(args, 'delay') ?? '1', 10);
34
+ const environment = await readEnvironment(file);
35
+ const projectId = environment.firebase.projectId;
36
+ if (projectId === null) {
37
+ throw new Error('This installation is not connected yet. Run "microteia connect --project <id>".');
38
+ }
39
+ const resolvedConfig = await readResolvedConfig(root);
40
+ const firstInstall = environment.kernel.version === null;
41
+ heading(`Deploying Microteia to ${projectId}`);
42
+ const total = components.length + 1;
43
+ let current = 0;
44
+ // Publica o que o `unpack` trouxe. Separar os dois é o que permite trocar de kernel e publicar SÓ o
45
+ // que mudou naquela versão — e é o que dá um momento para preencher um parâmetro novo antes de subir.
46
+ const install = installPath(root);
47
+ const manifest = await readInstalledManifest(root);
48
+ // A região é do PACOTE, não do arquivo: as Cloud Functions são compiladas com ela fixa, então o
49
+ // que está no `microteia.yaml` é registro, e não escolha.
50
+ const region = manifest.requires.functionsRegion;
51
+ if (environment.firebase.region !== region) {
52
+ warn(`this package is compiled for region ${region}; the installation recorded ${environment.firebase.region}.`);
53
+ }
54
+ const baselineCount = (manifest.baselineFunctions ?? manifest.functions).length;
55
+ success(`kernel ${manifest.version} (contract ${manifest.kernelVersion}), ${baselineCount} baseline function(s)`
56
+ + `, ${manifest.functions.length - baselineCount} claimed by extensions`);
57
+ if (firstInstall)
58
+ info(' first install: functions are deployed with --force so cleanup policies get configured');
59
+ current += 1;
60
+ step(current, total, 'Stamping the installation configuration');
61
+ const stamped = { ...resolvedConfig, functionsRegion: region };
62
+ await writeResolvedConfig(root, stamped);
63
+ await writeFile(join(install, 'hosting', 'firebase-config.js'), stampedConfigJs(stamped));
64
+ success(`hosting/firebase-config.js → ${projectId}`);
65
+ // O arquivo que o escritório edita é o da RAIZ; o que o `firebase-functions` lê tem de estar em
66
+ // `install/functions/`, e por isso é uma cópia escrita agora. Antes a fonte era a cópia, e o deploy
67
+ // seguinte a apagava junto com o pacote.
68
+ await ensureRootGitignore(root);
69
+ // A união, e não só o que o pacote declara: o `firebase-functions` valida os `defineString`
70
+ // GLOBALMENTE, então um parâmetro declarado por uma function do escritório e sem linha no arquivo
71
+ // recusa o deploy de TODAS elas — inclusive as que não têm relação com ele.
72
+ const office = await readOfficeFunctions(root);
73
+ const settings = await ensureOfficeEnv(root, projectId, [...manifest.backendParameters, ...office.parameters]);
74
+ if (settings.created) {
75
+ warn(`created ${officeEnvName(projectId)} with ${settings.added.length} setting(s), all empty —`);
76
+ warn(' features that need one stay off until it is filled in and "microteia provision" runs again.');
77
+ }
78
+ else if (settings.added.length > 0) {
79
+ warn(`this package declares ${settings.added.length} new setting(s), added empty to `
80
+ + `${officeEnvName(projectId)}: ${settings.added.join(', ')}`);
81
+ }
82
+ await stageOfficeEnv(root, projectId, install);
83
+ const failures = [];
84
+ const unreachableCallables = [];
85
+ let staleHosting = false;
86
+ for (const component of components) {
87
+ current += 1;
88
+ if (component === 'rules') {
89
+ step(current, total, 'Firestore and Storage rules');
90
+ await run(FIREBASE_BIN, ['deploy', '--only', 'firestore:rules,storage', '--project', projectId], { cwd: install });
91
+ continue;
92
+ }
93
+ if (component === 'indexes') {
94
+ step(current, total, 'Firestore indexes');
95
+ await run(FIREBASE_BIN, ['deploy', '--only', 'firestore:indexes', '--project', projectId], { cwd: install });
96
+ continue;
97
+ }
98
+ if (component === 'functions') {
99
+ // A BASELINE, e não tudo o que foi empacotado: function de extensão sobe quando a extensão for
100
+ // instalada, pelo provisionamento no fim deste comando.
101
+ const baseline = manifest.baselineFunctions ?? manifest.functions;
102
+ // As functions do escritório são montadas AGORA, a partir de onde ele as escreve: `functions/` (as
103
+ // comuns) e `extensions/<plugin>/functions/` (as de cada extensão). O `predeploy` do pacote compila
104
+ // só o `custom/`, então o que não estiver aqui não sobe.
105
+ const gathered = await gatherCustomFunctions(root, install, manifest.functions);
106
+ // O que sobe aqui é a baseline MAIS as functions comuns do escritório (`functions/`). As de
107
+ // `extensions/<plugin>/functions/` têm dono e sobem pelo provisionamento, no fim deste comando:
108
+ // é o mesmo "empacotar não é deployar" que vale para as do kernel reivindicadas por extensão.
109
+ const own = [...baseline, ...gathered.shared];
110
+ step(current, total, `Cloud Functions (${own.length}, one at a time)`);
111
+ if (gathered.sources.length > 0) {
112
+ success(`${gathered.functions.length} function(s) of your own, from `
113
+ + `${gathered.sources.map((source) => `${source.label}/`).join(', ')}`);
114
+ const owned = gathered.all.length - gathered.shared.length;
115
+ if (owned > 0)
116
+ info(` ${owned} of them belong to an extension and go up with "provision"`);
117
+ }
118
+ if (Object.keys(gathered.dependencies.added).length > 0) {
119
+ success(`${Object.keys(gathered.dependencies.added).length} npm dependency(ies) your functions declare: `
120
+ + `${Object.keys(gathered.dependencies.added).join(', ')}`);
121
+ info(' the packaged lockfile does not cover them, so this npm install resolves them and takes');
122
+ info(' longer than the following ones.');
123
+ }
124
+ for (const line of gathered.dependencies.overridden) {
125
+ warn(`${line} — the package's range wins; the runtime is the kernel's choice`);
126
+ }
127
+ info(' installing function dependencies… (~250 packages, a few seconds)');
128
+ // Os dois avisos que o npm dá aqui são esperados, e explicá-los antes evita que quem instala pare
129
+ // para investigar: `EBADENGINE` compara a Node DESTA máquina com a do runtime das functions (que
130
+ // é fixa em 22 de propósito), e as notas de descontinuação vêm de dependências do próprio
131
+ // Firebase, não do kernel. Nenhum dos dois impede o deploy.
132
+ info(' npm may warn about the Node engine and about deprecated packages: both are expected —');
133
+ info(' "engines" names the Cloud Functions runtime, and the notices come from Firebase\'s deps.');
134
+ await run('npm', ['install', '--no-audit', '--no-fund'], { cwd: join(install, 'functions') });
135
+ // ANTES de deployar: uma cópia da mesma function numa região que o pacote abandonou faz o
136
+ // `firebase` recusar o deploy dela, tentativa após tentativa, e faz um gatilho de Firestore
137
+ // disparar duas vezes por gravação enquanto ninguém percebe.
138
+ await clearRegionOrphans(projectId, own, region, hasFlag(args, 'prune'));
139
+ const report = await deployFunctionsOneByOne(own, {
140
+ projectId,
141
+ cwd: install,
142
+ delaySeconds,
143
+ force: firstInstall,
144
+ });
145
+ if (report.failed.length > 0) {
146
+ failures.push(`${report.failed.length} function(s): ${report.failed.map((entry) => entry.name).join(', ')}`);
147
+ blank();
148
+ reportFailures(report.failed);
149
+ }
150
+ reportNotices(report.notices);
151
+ // Deployada não quer dizer alcançável: a concessão de invocação pública pode ter sido
152
+ // recusada, e o `firebase deploy` termina bem do mesmo jeito. Quem paga a conta é o escritório,
153
+ // com uma aplicação que abre e não consegue falar com o servidor.
154
+ const callables = [...(manifest.callables ?? []), ...gathered.callables]
155
+ .filter((name) => own.includes(name));
156
+ if (callables.length > 0) {
157
+ info(` checking that the browser can reach the ${callables.length} callable(s)…`);
158
+ const origin = `https://${projectId}.web.app`;
159
+ const checks = await checkCallables(callables, { projectId, region, origin });
160
+ const blocked = checks.filter((check) => check.access === 'blocked');
161
+ const unclear = checks.filter((check) => check.access !== 'blocked' && check.access !== 'reachable');
162
+ if (blocked.length > 0) {
163
+ unreachableCallables.push(...blocked.map((check) => check.name));
164
+ blank();
165
+ failure(`${blocked.length} of ${checks.length} callable(s) refuse calls from ${origin}.`);
166
+ info(' The deploy could not make them public — an organization policy that forbids allUsers');
167
+ info(' in IAM does exactly this. The application will open and every server call will fail,');
168
+ info(' in the browser, as what looks like a CORS error. Run once per function:');
169
+ bullets(blocked.map((check) => invokerGrantCommand(region, projectId, check.name)));
170
+ }
171
+ else if (unclear.length > 0) {
172
+ warn(`could not confirm ${unclear.length} of ${checks.length} callable(s): `
173
+ + `${unclear.map((check) => check.name).join(', ')}. Re-run the check, or open the`
174
+ + ' application and watch the browser console.');
175
+ }
176
+ else {
177
+ success(`all ${checks.length} callables answer calls from ${origin}`);
178
+ }
179
+ }
180
+ continue;
181
+ }
182
+ step(current, total, 'Hosting');
183
+ await run(FIREBASE_BIN, ['deploy', '--only', 'hosting', '--project', projectId], { cwd: install });
184
+ // Publicado não é servido: se o navegador ainda recebe o `index.html` antigo, ele continua
185
+ // carregando o bundle antigo — e a aplicação segue na versão anterior com o deploy em sucesso.
186
+ const hosting = await checkHostingServed(install, `https://${projectId}.web.app`);
187
+ if (hosting.served === 'current') {
188
+ success(`the site serves this version's bundle (${hosting.servedBundle})`);
189
+ }
190
+ else if (hosting.served === 'stale') {
191
+ warn(`the site is still serving the PREVIOUS bundle (${hosting.servedBundle},`);
192
+ warn(` this package ships ${hosting.packagedBundle}). Give the CDN a moment and reload; if the`);
193
+ warn(' application keeps showing the old version, force a reload to drop the cached page.');
194
+ staleHosting = true;
195
+ }
196
+ else {
197
+ info(' could not confirm which bundle the site serves — open it and check the version in the footer');
198
+ }
199
+ if (!htmlIsRevalidated(hosting.htmlCacheControl) && hosting.htmlCacheControl !== null) {
200
+ warn(`the page is served with "cache-control: ${hosting.htmlCacheControl}", so a browser that`);
201
+ warn(' already visited it may keep the previous version until that expires. This package sets');
202
+ warn(' the page to revalidate on every load, so the value above is the one from the deploy');
203
+ warn(' BEFORE this one — it settles once this deploy propagates.');
204
+ }
205
+ }
206
+ /**
207
+ * O que o `deploy` carimba é o `deployed`: o `version` já foi gravado pelo `unpack`, que é quem põe o
208
+ * pacote no lugar. Um número só mentia numa das duas pontas — entre desempacotar e publicar, o
209
+ * `MICROTEIA.md` anunciava a versão nova e o `microteia.yaml` ao lado dele, a anterior.
210
+ */
211
+ const applied = {
212
+ ...environment,
213
+ firebase: { ...environment.firebase, region },
214
+ kernel: { version: manifest.version, deployed: manifest.version },
215
+ };
216
+ await writeEnvironment(file, applied);
217
+ // Um motor só: o que as extensões instaladas exigem é aplicado aqui, no fim do deploy, e pelo
218
+ // `microteia provision` quando alguém instala uma extensão depois. Numa instalação nova não há nada
219
+ // instalado e este passo não faz nada — é justamente o que se quer.
220
+ if (components.includes('functions') || components.includes('indexes')) {
221
+ blank();
222
+ info('Extension backends');
223
+ const provisioned = await provisionBackend({
224
+ projectId,
225
+ region,
226
+ install,
227
+ manifest,
228
+ delaySeconds,
229
+ prune: hasFlag(args, 'prune'),
230
+ environmentFile: file,
231
+ environment: applied,
232
+ });
233
+ if (provisioned.failed.length > 0) {
234
+ failures.push(`${provisioned.failed.length} extension function(s): ${provisioned.failed.join(', ')}`);
235
+ }
236
+ }
237
+ blank();
238
+ if (failures.length > 0) {
239
+ failure(`Deploy finished with problems: ${failures.join('; ')}`);
240
+ info('Re-run to retry only what failed:');
241
+ bullets(['microteia deploy --only functions']);
242
+ blank();
243
+ throw new Error('Some components did not deploy.');
244
+ }
245
+ info(`Microteia ${manifest.version} is installed in ${projectId}.`);
246
+ bullets([`https://${projectId}.web.app`]);
247
+ blank();
248
+ if (staleHosting) {
249
+ warn('the browser may still load the previous version for a few minutes. A forced reload');
250
+ warn(' (Shift+Reload, or Empty Cache and Hard Reload) settles it immediately.');
251
+ blank();
252
+ }
253
+ if (unreachableCallables.length > 0) {
254
+ warn(`the deploy finished, but ${unreachableCallables.length} function(s) still refuse calls from`);
255
+ warn(' the browser. Grant the invoker role with the commands printed above before signing in,');
256
+ warn(' otherwise the application opens and nothing it asks the server for will work.');
257
+ blank();
258
+ }
259
+ info('Sign in to create the first office. Everything else is configured from inside the application.');
260
+ blank();
261
+ };
@@ -0,0 +1,45 @@
1
+ export interface FunctionDeployOptions {
2
+ projectId: string;
3
+ cwd: string;
4
+ delaySeconds: number;
5
+ /** `--force` deixa o Firebase configurar sozinho a política de limpeza do Artifact Registry. */
6
+ force: boolean;
7
+ }
8
+ export interface FunctionDeployFailure {
9
+ name: string;
10
+ /** A saída do `firebase`, sem os avisos que não têm relação com o resultado. */
11
+ output: string;
12
+ /** A linha que explica a falha. */
13
+ reason: string;
14
+ /** Repetir dá o mesmo resultado: é uma recusa do `firebase`, não uma corrida com o Google. */
15
+ terminal: boolean;
16
+ /** O que fazer a respeito, quando dá para dizer. Impresso UMA vez, não uma por function. */
17
+ hint: string[];
18
+ }
19
+ export interface FunctionDeployReport {
20
+ deployed: string[];
21
+ failed: FunctionDeployFailure[];
22
+ /** Avisos que valem uma linha no fim do deploy, e não uma linha por function. */
23
+ notices: string[];
24
+ }
25
+ export declare const stripNotices: (text: string) => {
26
+ text: string;
27
+ summaries: string[];
28
+ };
29
+ export declare const failureReason: (output: string) => string;
30
+ /**
31
+ * A falha que repetir não conserta.
32
+ *
33
+ * A segunda passada existe para UMA coisa (identidade de serviço que ainda não propagou), e tratá-la
34
+ * como remédio de tudo custa uma rodada inteira de deploy por function — a de região antiga leva
35
+ * ~40s cada, e a resposta da segunda vez é palavra por palavra a mesma. Pior: repetir sugere a quem
36
+ * está olhando que aquilo é intermitente, quando é uma recusa deliberada do `firebase`.
37
+ */
38
+ export interface TerminalFailure {
39
+ match: RegExp;
40
+ hint: string[];
41
+ }
42
+ export declare const terminalFailure: (output: string) => TerminalFailure | undefined;
43
+ export declare const deployFunctionsOneByOne: (names: string[], options: FunctionDeployOptions) => Promise<FunctionDeployReport>;
44
+ export declare const reportFailures: (failures: FunctionDeployFailure[]) => void;
45
+ export declare const reportNotices: (notices: string[]) => void;