@microteia/console 2.7.1 → 2.8.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.
@@ -22,6 +22,18 @@
22
22
  - fix(command): what was broken, and what it did to whoever hit it.
23
23
  -->
24
24
 
25
+ #### [2.8.0] — 2026-08-25
26
+
27
+ - feat(console/functionsUpToDate): new module. It reconstructs the skip that `firebase deploy` performs in bulk and that `--only functions:<name>` disables, WITHOUT reimplementing the hash: functions are grouped by the `firebase-functions-hash` label they carry, one member of each group is deployed, and the stamp it comes back with is the stamp of the current code. The probe being a MEMBER of its group is what makes the conclusion carry to the others — two functions with the same old stamp share source, environment and secret set, the three parcels of the hash. Absent, non-`ACTIVE`, unstamped or wrong-region always deploys: not knowing never becomes skipping.
28
+ - feat(console/deploy): asks the project once (`functions:list --json`) and feeds both decisions from that one answer — the region orphan check and the up-to-date check. Two questions could disagree, and then the decision depends on which answer the code happened to read.
29
+ - feat(console/provision): `outdatedByStamp` requeues an applied extension function that carries a stamp older than what this package builds, or that is missing from the project. The stamp comes from the probes the `deploy` step just ran — extension functions live in the same codebase, so it is their stamp too. Called on its own, provisioning decides as it always did: probing there would cost a deploy on every extension install to answer a question that is almost always "nothing".
30
+ - feat(console): `--all` on `deploy` and `provision` deploys every function without asking the project what it runs.
31
+ - fix(console/regionOrphans): `listDeployedFunctions` keeps `hash`, `state` and `platform` instead of discarding everything but id and region; `clearRegionOrphans` accepts an already-fetched listing.
32
+
33
+ #### [2.7.2] — 2026-08-25
34
+
35
+ - feat(vendor/exampleSources): stages `automation-example` alongside the others, so the workspace ships a complete automation to read. Every example that travelled carried `workflows: []`, and automation was the one mechanism an author had only prose for.
36
+
25
37
  #### [2.7.1] — 2026-08-24
26
38
 
27
39
  - fix(unpack): the default catalog URL is `https://microteia.github.io/releases/kernel/index.json`.
package/README.md CHANGED
@@ -1,4 +1,4 @@
1
- # Microteia Console 2.7.1
1
+ # Microteia Console 2.8.0
2
2
 
3
3
  Installs and updates Microteia in a Firebase project you own. It has no npm dependencies of its own —
4
4
  what it needs are command-line tools that are already on the machine, listed at the bottom.
@@ -5,8 +5,9 @@ import { run } from '../shared/run.js';
5
5
  import { blank, bullets, failure, heading, info, step, success, warn } from '../shared/ui.js';
6
6
  import { ENVIRONMENT_FILE, readEnvironment, writeEnvironment } from './environment.js';
7
7
  import { FIREBASE_BIN } from './firebaseCli.js';
8
- import { deployFunctionsOneByOne, reportFailures, reportNotices } from './deployFunctions.js';
9
- import { clearRegionOrphans } from './regionOrphans.js';
8
+ import { reportFailures, reportNotices } from './deployFunctions.js';
9
+ import { clearRegionOrphans, listDeployedFunctions } from './regionOrphans.js';
10
+ import { deployFunctionsThatChanged } from './functionsUpToDate.js';
10
11
  import { checkCallables, invokerGrantCommand } from './callableAccess.js';
11
12
  import { checkHostingServed, htmlIsRevalidated } from './hostingCheck.js';
12
13
  import { provisionBackend } from './provision.js';
@@ -83,6 +84,12 @@ export const deploy = async (args) => {
83
84
  const failures = [];
84
85
  const unreachableCallables = [];
85
86
  let staleHosting = false;
87
+ let skippedFunctions = 0;
88
+ // O que as sondas do passo de functions descobriram: carimbo antigo -> carimbo de agora. O
89
+ // provisionamento está no MESMO codebase, então esse é o carimbo das functions de extensão também.
90
+ // Segue `undefined` quando o passo de functions não rodou: ali não há conclusão a tirar, e um mapa
91
+ // vazio diria a mesma coisa por acaso, e não por decisão.
92
+ let refreshedStamps;
86
93
  for (const component of components) {
87
94
  current += 1;
88
95
  if (component === 'rules') {
@@ -132,16 +139,25 @@ export const deploy = async (args) => {
132
139
  info(' npm may warn about the Node engine and about deprecated packages: both are expected —');
133
140
  info(' "engines" names the Cloud Functions runtime, and the notices come from Firebase\'s deps.');
134
141
  await run('npm', ['install', '--no-audit', '--no-fund'], { cwd: join(install, 'functions') });
142
+ // Uma pergunta só ao projeto, e duas decisões a partir dela: qual cópia ficou numa região
143
+ // abandonada, e o que ele já roda. Perguntar duas vezes não é caro, é ambíguo — as duas
144
+ // respostas podem divergir, e aí a decisão passa a depender de qual delas o código olhou.
145
+ const deployedNow = await listDeployedFunctions(projectId);
135
146
  // ANTES de deployar: uma cópia da mesma function numa região que o pacote abandonou faz o
136
147
  // `firebase` recusar o deploy dela, tentativa após tentativa, e faz um gatilho de Firestore
137
148
  // 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, {
149
+ await clearRegionOrphans(projectId, own, region, hasFlag(args, 'prune'), deployedNow);
150
+ // Numa instalação nova não há carimbo nenhum com que comparar, e o mecanismo cai sozinho no
151
+ // caminho de sempre — não é preciso um desvio para isso. `--all` é o pedido explícito de subir
152
+ // tudo, para a configuração trocada por fora, que carimbo nenhum enxerga.
153
+ const report = await deployFunctionsThatChanged(own, {
140
154
  projectId,
141
155
  cwd: install,
142
156
  delaySeconds,
143
157
  force: firstInstall,
144
- });
158
+ }, { deployed: deployedNow, region, all: hasFlag(args, 'all') });
159
+ refreshedStamps = report.refreshed;
160
+ skippedFunctions = report.skipped.length;
145
161
  if (report.failed.length > 0) {
146
162
  failures.push(`${report.failed.length} function(s): ${report.failed.map((entry) => entry.name).join(', ')}`);
147
163
  blank();
@@ -227,6 +243,8 @@ export const deploy = async (args) => {
227
243
  manifest,
228
244
  delaySeconds,
229
245
  prune: hasFlag(args, 'prune'),
246
+ all: hasFlag(args, 'all'),
247
+ refreshedStamps,
230
248
  environmentFile: file,
231
249
  environment: applied,
232
250
  });
@@ -242,6 +260,11 @@ export const deploy = async (args) => {
242
260
  blank();
243
261
  throw new Error('Some components did not deploy.');
244
262
  }
263
+ if (skippedFunctions > 0) {
264
+ info(`${skippedFunctions} Cloud Function(s) were already running this exact code and were left alone.`);
265
+ bullets(['microteia deploy --all', ' deploys every one of them, whatever the project already runs']);
266
+ blank();
267
+ }
245
268
  info(`Microteia ${manifest.version} is installed in ${projectId}.`);
246
269
  bullets([`https://${projectId}.web.app`]);
247
270
  blank();
@@ -0,0 +1,55 @@
1
+ import type { FunctionDeployOptions, FunctionDeployReport } from './deployFunctions.js';
2
+ import type { DeployedFunction } from './regionOrphans.js';
3
+ /** As functions que o projeto roda com exatamente o mesmo carimbo. */
4
+ export interface HashGroup {
5
+ hash: string;
6
+ platform: string;
7
+ names: string[];
8
+ }
9
+ export interface DeployPlan {
10
+ /**
11
+ * Sem carimbo com que comparar: ausente do projeto, em outra região, no meio de um deploy, ou
12
+ * deployada por um CLI que não gravava o label. Sobem sempre — não saber nunca vira pular.
13
+ */
14
+ unverifiable: string[];
15
+ groups: HashGroup[];
16
+ }
17
+ export declare const planFunctionDeploy: (wanted: string[], deployed: DeployedFunction[] | null, region: string) => DeployPlan;
18
+ export interface UpToDateReport extends FunctionDeployReport {
19
+ /** Não subiram porque o projeto já roda exatamente este código. */
20
+ skipped: string[];
21
+ /**
22
+ * O carimbo antigo de cada grupo sondado, e o que a sonda dele voltou carregando. É o que deixa o
23
+ * provisionamento decidir sobre as functions de extensão sem sondar de novo: elas estão no MESMO
24
+ * codebase, então o carimbo de agora é o mesmo.
25
+ */
26
+ refreshed: Map<string, string>;
27
+ }
28
+ export interface UpToDateContext {
29
+ /** A listagem que o comando já fez, para não perguntar duas vezes a mesma coisa ao projeto. */
30
+ deployed: DeployedFunction[] | null;
31
+ region: string;
32
+ /** `--all`: sobe todas, sem perguntar ao projeto o que ele já roda. */
33
+ all: boolean;
34
+ }
35
+ export declare const deployFunctionsThatChanged: (wanted: string[], options: FunctionDeployOptions, context: UpToDateContext) => Promise<UpToDateReport>;
36
+ /**
37
+ * A function de extensão que o projeto roda e que este pacote não constrói mais assim.
38
+ *
39
+ * Ela existe porque o provisionamento sempre decidiu por NOME: uma function já aplicada só voltava
40
+ * para a fila quando um parâmetro dela mudava de valor. Editar o código em
41
+ * `extensions/<plugin>/functions/` não movia nada — a fonte mudava, o pacote era remontado, e a
42
+ * function seguia rodando a versão anterior sem que comando nenhum a alcançasse.
43
+ *
44
+ * O carimbo responde isso de graça, e sem sondar nada: a function de extensão está no MESMO codebase
45
+ * da baseline, então o carimbo que a sonda do `deploy` acabou de observar é o dela também.
46
+ */
47
+ export interface OutdatedFunctions {
48
+ /** Não estão rodando em lugar nenhum do projeto, apesar de a lista de nomes dizer que subiram. */
49
+ absent: string[];
50
+ /** Estão lá, e carregam um carimbo anterior ao que este pacote constrói. */
51
+ stale: string[];
52
+ }
53
+ export declare const outdatedByStamp: (applied: string[], deployed: DeployedFunction[] | null, region: string,
54
+ /** Carimbo antigo -> carimbo de agora, observado pelas sondas. Ausente: não há o que concluir. */
55
+ refreshed: Map<string, string> | undefined) => OutdatedFunctions;
@@ -0,0 +1,192 @@
1
+ import { blank, info, success, warn } from '../shared/ui.js';
2
+ import { deployFunctionsOneByOne } from './deployFunctions.js';
3
+ import { listDeployedFunctions } from './regionOrphans.js';
4
+ /**
5
+ * O deploy que não sobe de novo o que o projeto já roda.
6
+ *
7
+ * O `firebase` sabe responder isso, e sempre soube: ele grava em cada function o label
8
+ * `firebase-functions-hash` — sha1 do diretório de fontes INTEIRO, mais as variáveis de ambiente
9
+ * resolvidas, mais as versões de segredo — e num deploy em lote pula toda function cujo carimbo
10
+ * gravado é igual ao que o código atual produz. O que desliga esse pulo é justamente o
11
+ * `--only functions:<nome>` com que o Console deploya: `planner.js` só pula endpoint que
12
+ * `!targetedByOnly`, e nomear a function no `--only` a marca. Deployar uma a uma não é opcional (em
13
+ * lote estoura a quota de CPU do projeto), então o pulo teve de ser reconstruído aqui.
14
+ *
15
+ * **Não é um mecanismo de detecção novo, e essa é a decisão inteira.** Não há digest nosso, não há
16
+ * lista de insumos que alguém possa esquecer de atualizar, não há estado guardado que possa divergir
17
+ * do projeto. O carimbo é do `firebase`: calculado por ele, gravado por ele na function, lido do
18
+ * projeto a cada execução. Comparar por ele é tomar a MESMA decisão que o `firebase deploy` tomaria.
19
+ *
20
+ * O que faltava é o outro lado da comparação: o carimbo que o código de AGORA produziria. Calculá-lo
21
+ * aqui exigiria reimplementar o algoritmo dele — e uma reimplementação que envelhece em silêncio é
22
+ * exatamente o que não se quer num comando que decide NÃO publicar. Em vez disso ele é OBSERVADO: as
23
+ * functions são agrupadas pelo carimbo que carregam, UMA de cada grupo é deployada, e o carimbo que
24
+ * ela volta carregando é o carimbo do código de agora. Voltou igual, o grupo inteiro é byte a byte o
25
+ * que o projeto já roda; voltou diferente, o grupo inteiro sobe.
26
+ *
27
+ * A sonda ser MEMBRO do grupo é o que faz a conclusão valer para os outros: duas functions com o
28
+ * mesmo carimbo antigo têm, além da mesma fonte e do mesmo ambiente, o mesmo conjunto de segredos —
29
+ * é a terceira parcela do hash. Uma sonda de fora do grupo não provaria isso.
30
+ *
31
+ * Ler o carimbo da sonda logo depois de ela subir depende de o `functions:list` já enxergar o que o
32
+ * deploy acabou de gravar. Não é uma aposta nossa: é a MESMA leitura de que o `firebase deploy`
33
+ * depende para montar o `haveBackend` com que ele decide o que pular, e o comando só retorna depois
34
+ * de a operação do Google ter terminado.
35
+ *
36
+ * O custo do pior caso é o comportamento de hoje; o do caso bom é uma function em vez de 28.
37
+ *
38
+ * O que ele NÃO enxerga — porque o `firebase deploy` em lote também não — é configuração trocada por
39
+ * fora, pelo console do Google: memória, timeout, concorrência, política de invocação. Para isso
40
+ * existe `--all`. O alcance das callables continua conferido em TODAS a cada deploy, inclusive nas
41
+ * que não subiram, e é ele que acusa uma concessão de IAM removida por fora.
42
+ */
43
+ const ACTIVE = 'ACTIVE';
44
+ const SEPARATOR = ' ';
45
+ export const planFunctionDeploy = (wanted, deployed, region) => {
46
+ if (deployed === null)
47
+ return { unverifiable: [...wanted], groups: [] };
48
+ const byName = new Map();
49
+ for (const entry of deployed) {
50
+ if (entry.region === region)
51
+ byName.set(entry.name, entry);
52
+ }
53
+ const unverifiable = [];
54
+ const grouped = new Map();
55
+ for (const name of wanted) {
56
+ const entry = byName.get(name);
57
+ if (entry === undefined || entry.state !== ACTIVE || entry.hash === null) {
58
+ unverifiable.push(name);
59
+ continue;
60
+ }
61
+ // A plataforma entra na chave porque `gcfv1` e `gcfv2` empacotam fontes diferentes e carimbam
62
+ // hashes diferentes: o carimbo de uma nunca fala pela outra.
63
+ const key = `${entry.platform}${SEPARATOR}${entry.hash}`;
64
+ const names = grouped.get(key);
65
+ if (names === undefined)
66
+ grouped.set(key, [name]);
67
+ else
68
+ names.push(name);
69
+ }
70
+ const groups = [...grouped.entries()].map(([key, names]) => {
71
+ const [platform, hash] = key.split(SEPARATOR);
72
+ return { hash, platform, names };
73
+ });
74
+ return { unverifiable, groups };
75
+ };
76
+ const merge = (left, right) => ({
77
+ deployed: [...left.deployed, ...right.deployed],
78
+ failed: [...left.failed, ...right.failed],
79
+ notices: [...new Set([...left.notices, ...right.notices])],
80
+ });
81
+ const plural = (count, singular) => (count === 1 ? singular : `${singular}s`);
82
+ export const deployFunctionsThatChanged = async (wanted, options, context) => {
83
+ const plain = async (names) => ({
84
+ ...(await deployFunctionsOneByOne(names, options)),
85
+ skipped: [],
86
+ refreshed: new Map(),
87
+ });
88
+ if (wanted.length === 0)
89
+ return plain(wanted);
90
+ if (context.all) {
91
+ info(` --all: deploying all ${wanted.length}, without asking the project what it already runs.`);
92
+ return plain(wanted);
93
+ }
94
+ const plan = planFunctionDeploy(wanted, context.deployed, context.region);
95
+ if (plan.groups.length === 0) {
96
+ // Nada com que comparar. Numa instalação nova é o caso normal, e é o certo: não há nada lá.
97
+ return plain(wanted);
98
+ }
99
+ const comparable = plan.groups.reduce((count, group) => count + group.names.length, 0);
100
+ const probes = plan.groups.map((group) => group.names[0]);
101
+ const candidates = comparable - probes.length;
102
+ info(` ${comparable} of the ${wanted.length} already run in this project carrying a Firebase stamp`);
103
+ info(` of the source, settings and secrets they were built with. Deploying ${probes.length} reference`);
104
+ info(` ${plural(probes.length, 'function')} first (${probes.join(', ')}): if the stamp comes back`);
105
+ info(` unchanged, the other ${candidates} are what the project already runs and are left alone.`);
106
+ info(' It is the same comparison "firebase deploy" makes when it deploys in bulk.');
107
+ blank();
108
+ // As sondas primeiro: é o que se aprende com elas que decide o resto.
109
+ const first = await deployFunctionsOneByOne([...probes, ...plan.unverifiable], options);
110
+ const fresh = await listDeployedFunctions(options.projectId);
111
+ const freshByName = new Map();
112
+ for (const entry of fresh ?? []) {
113
+ if (entry.region === context.region)
114
+ freshByName.set(entry.name, entry);
115
+ }
116
+ const refreshed = new Map();
117
+ const skipped = [];
118
+ const rest = [];
119
+ let unconfirmed = 0;
120
+ for (const group of plan.groups) {
121
+ const [probe, ...others] = group.names;
122
+ if (others.length === 0)
123
+ continue;
124
+ const entry = freshByName.get(probe);
125
+ const stamp = first.deployed.includes(probe) && entry !== undefined && entry.state === ACTIVE
126
+ ? entry.hash
127
+ : null;
128
+ if (stamp === null) {
129
+ // Não deu para ler o carimbo novo da sonda: a resposta é subir, que é o que se fazia antes
130
+ // deste mecanismo existir. Não saber nunca vira pular.
131
+ unconfirmed += others.length;
132
+ rest.push(...others);
133
+ continue;
134
+ }
135
+ refreshed.set(group.hash, stamp);
136
+ if (stamp === group.hash)
137
+ skipped.push(...others);
138
+ else
139
+ rest.push(...others);
140
+ }
141
+ blank();
142
+ if (skipped.length > 0) {
143
+ success(`${skipped.length} ${plural(skipped.length, 'function')} already up to date — same source,`);
144
+ info(' settings and secrets as what this project runs. Not deployed.');
145
+ }
146
+ if (unconfirmed > 0) {
147
+ warn(`could not read the new stamp of ${unconfirmed} ${plural(unconfirmed, 'function')}: deploying them.`);
148
+ }
149
+ if (rest.length > 0) {
150
+ info(` ${rest.length} ${plural(rest.length, 'function')} changed and go up now.`);
151
+ }
152
+ blank();
153
+ const second = await deployFunctionsOneByOne(rest, options);
154
+ return { ...merge(first, second), skipped, refreshed };
155
+ };
156
+ export const outdatedByStamp = (applied, deployed, region,
157
+ /** Carimbo antigo -> carimbo de agora, observado pelas sondas. Ausente: não há o que concluir. */
158
+ refreshed) => {
159
+ if (deployed === null)
160
+ return { absent: [], stale: [] };
161
+ const byName = new Map();
162
+ const running = new Set();
163
+ for (const entry of deployed) {
164
+ // A ausência é perguntada ao projeto INTEIRO, e não à região do pacote: uma function que o
165
+ // escritório escreveu de propósito noutra região está rodando, e devolvê-la à fila por não estar
166
+ // aqui a redeployaria em toda execução, para sempre. É a mesma distinção que `regionOrphans` faz
167
+ // ao casar por nome-que-vai-subir em vez de por região diferente da esperada.
168
+ if (entry.state === ACTIVE)
169
+ running.add(entry.name);
170
+ if (entry.region === region)
171
+ byName.set(entry.name, entry);
172
+ }
173
+ const absent = [];
174
+ const stale = [];
175
+ for (const name of applied) {
176
+ if (!running.has(name)) {
177
+ absent.push(name);
178
+ continue;
179
+ }
180
+ const entry = byName.get(name);
181
+ if (entry === undefined || entry.state !== ACTIVE)
182
+ continue;
183
+ // Sem carimbo, ou sem o carimbo de agora com que compará-lo, não há conclusão a tirar — e a
184
+ // ausência de conclusão mantém o comportamento anterior a este mecanismo, que é não mexer.
185
+ if (entry.hash === null || refreshed === undefined)
186
+ continue;
187
+ const now = refreshed.get(entry.hash);
188
+ if (now !== undefined && now !== entry.hash)
189
+ stale.push(name);
190
+ }
191
+ return { absent, stale };
192
+ };
@@ -24,6 +24,18 @@ export interface ProvisionOptions {
24
24
  manifest: KernelManifest;
25
25
  delaySeconds: number;
26
26
  prune: boolean;
27
+ /** `--all`: ignora o carimbo e sobe toda function que as extensões instaladas exigem. */
28
+ all?: boolean;
29
+ /**
30
+ * O que as sondas do passo de functions do `deploy` observaram: carimbo antigo -> carimbo de
31
+ * agora. A function de extensão está no MESMO codebase da baseline, então esse carimbo vale para
32
+ * ela — e é por ele que uma cujo CÓDIGO mudou volta para a fila, coisa que o nome nunca disse.
33
+ *
34
+ * Sem ele — o `provision` chamado sozinho, logo depois de alguém instalar uma extensão pela
35
+ * aplicação — o comando decide como sempre decidiu. Sondar ali custaria um deploy a cada
36
+ * instalação de extensão para responder uma pergunta que naquele momento é quase sempre "nada".
37
+ */
38
+ refreshedStamps?: Map<string, string>;
27
39
  environmentFile: string;
28
40
  environment: Environment;
29
41
  }
@@ -10,7 +10,8 @@ import { changedParameters, functionsDeclaringParameters, parameterDigests, pars
10
10
  import { ENVIRONMENT_FILE, readEnvironment, writeEnvironment } from './environment.js';
11
11
  import { FIREBASE_BIN } from './firebaseCli.js';
12
12
  import { deployFunctionsOneByOne, reportFailures, reportNotices } from './deployFunctions.js';
13
- import { clearRegionOrphans } from './regionOrphans.js';
13
+ import { clearRegionOrphans, listDeployedFunctions } from './regionOrphans.js';
14
+ import { outdatedByStamp } from './functionsUpToDate.js';
14
15
  import { checkCallables, invokerGrantCommand } from './callableAccess.js';
15
16
  import { asString, asTimestamp, decodeFields, documentId, listDocuments, patchMapField } from './firestoreRest.js';
16
17
  import { gatherCustomFunctions, readOfficeFunctions } from './customFunctions.js';
@@ -141,6 +142,28 @@ export const provisionBackend = async (options) => {
141
142
  const changed = changedParameters(recordedDigests, currentDigests, trackedParameters);
142
143
  const staleFunctions = new Set(functionsDeclaringParameters(valid, changed)
143
144
  .filter((name) => wantedFunctions.includes(name) && alreadyApplied.includes(name)));
145
+ // Uma pergunta só ao projeto, e duas decisões a partir dela: o que ele já roda, e qual cópia ficou
146
+ // numa região abandonada.
147
+ const deployedFunctions = await listDeployedFunctions(projectId);
148
+ // O que a lista de nomes nunca respondeu: a function aplicada que sumiu do projeto, e a que segue
149
+ // lá carregando o carimbo de um código anterior. As duas voltam para a fila.
150
+ const outdated = outdatedByStamp(wantedFunctions.filter((name) => alreadyApplied.includes(name)), deployedFunctions, region, options.refreshedStamps);
151
+ for (const name of [...outdated.absent, ...outdated.stale])
152
+ staleFunctions.add(name);
153
+ if (outdated.absent.length > 0) {
154
+ blank();
155
+ info(` ${outdated.absent.length} function(s) recorded as applied are not running in this project:`);
156
+ bullets(outdated.absent);
157
+ info(' they are deployed again — the record is a list of names, and a name does not know what');
158
+ info(' happened to the function after it went up.');
159
+ }
160
+ if (outdated.stale.length > 0) {
161
+ blank();
162
+ info(` ${outdated.stale.length} function(s) still carry the code of a previous package:`);
163
+ bullets(outdated.stale);
164
+ info(' Firebase stamps every function with what it was built from, and theirs no longer matches');
165
+ info(' what this package builds. They are deployed again.');
166
+ }
144
167
  if (changed.length > 0) {
145
168
  blank();
146
169
  info(` ${changed.length} backend setting(s) changed since the last run: ${changed.join(', ')}`);
@@ -161,7 +184,7 @@ export const provisionBackend = async (options) => {
161
184
  info(' Cloud Function at ITS deploy, so a function already running keeps the previous one.');
162
185
  blank();
163
186
  }
164
- const toDeploy = wantedFunctions.filter((name) => !alreadyApplied.includes(name) || staleFunctions.has(name));
187
+ const toDeploy = wantedFunctions.filter((name) => options.all === true || !alreadyApplied.includes(name) || staleFunctions.has(name));
165
188
  if (toDeploy.length === 0) {
166
189
  success(`the ${wantedFunctions.length} function(s) these extensions need are already deployed`);
167
190
  }
@@ -180,7 +203,7 @@ export const provisionBackend = async (options) => {
180
203
  }
181
204
  // A extensão que mudou de região deixa a cópia antiga para trás igual ao kernel, e ali o efeito
182
205
  // é o mesmo: deploy recusado em toda tentativa, e gatilho disparando em duas regiões.
183
- await clearRegionOrphans(projectId, toDeploy, region, options.prune);
206
+ await clearRegionOrphans(projectId, toDeploy, region, options.prune, deployedFunctions);
184
207
  info(` deploying ${toDeploy.length} function(s), one at a time…`);
185
208
  const report = await deployFunctionsOneByOne(toDeploy, {
186
209
  projectId, cwd: install, delaySeconds, force: false,
@@ -456,6 +479,7 @@ export const provision = async (args) => {
456
479
  manifest,
457
480
  delaySeconds,
458
481
  prune: hasFlag(args, 'prune'),
482
+ all: hasFlag(args, 'all'),
459
483
  environmentFile: file,
460
484
  environment,
461
485
  });
@@ -1,6 +1,21 @@
1
1
  export interface DeployedFunction {
2
2
  name: string;
3
3
  region: string;
4
+ /**
5
+ * O carimbo que o PRÓPRIO `firebase` grava na function, no label `firebase-functions-hash`:
6
+ * sha1 do diretório de fontes inteiro, mais as variáveis de ambiente resolvidas, mais as versões
7
+ * de segredo. É o valor que ele compara consigo mesmo para pular uma function que não mudou num
8
+ * deploy em lote (`calculateChangesets`), e é por isso que ele serve aqui: comparar por ele é
9
+ * tomar a MESMA decisão que o `firebase deploy` tomaria, não uma parecida.
10
+ *
11
+ * `null` quando a function foi deployada por uma versão do CLI que não gravava o label — nesse
12
+ * caso não há o que comparar, e ela sobe.
13
+ */
14
+ hash: string | null;
15
+ /** `ACTIVE` é a única resposta que autoriza comparar: o resto está no meio de alguma coisa. */
16
+ state: string;
17
+ /** `gcfv1` e `gcfv2` empacotam fontes diferentes, então carimbam hashes diferentes. */
18
+ platform: string;
4
19
  }
5
20
  /**
6
21
  * A cópia da mesma function numa região que o pacote não usa mais.
@@ -40,4 +55,10 @@ export declare const settleRegionOrphans: (projectId: string, orphans: RegionOrp
40
55
  * O passo inteiro, para quem só quer chamá-lo antes de deployar. Devolve `true` quando o caminho está
41
56
  * livre — inclusive quando não deu para verificar, pela mesma razão de `listDeployedFunctions`.
42
57
  */
43
- export declare const clearRegionOrphans: (projectId: string, wanted: string[], region: string, prune: boolean) => Promise<boolean>;
58
+ export declare const clearRegionOrphans: (projectId: string, wanted: string[], region: string, prune: boolean,
59
+ /**
60
+ * A listagem já feita, quando quem chama vai usá-la para outra coisa também. Perguntar duas vezes
61
+ * a mesma coisa ao mesmo projeto não é caro, é ambíguo: as duas respostas podem divergir, e a
62
+ * decisão passa a depender de qual delas o código olhou.
63
+ */
64
+ listed?: DeployedFunction[] | null) => Promise<boolean>;
@@ -17,7 +17,13 @@ export const listDeployedFunctions = async (projectId) => {
17
17
  return null;
18
18
  return parsed.result
19
19
  .filter((entry) => entry !== null && typeof entry === 'object')
20
- .map((entry) => ({ name: String(entry.id ?? ''), region: String(entry.region ?? '') }))
20
+ .map((entry) => ({
21
+ name: String(entry.id ?? ''),
22
+ region: String(entry.region ?? ''),
23
+ hash: typeof entry.hash === 'string' && entry.hash !== '' ? entry.hash : null,
24
+ state: typeof entry.state === 'string' ? entry.state : '',
25
+ platform: typeof entry.platform === 'string' ? entry.platform : '',
26
+ }))
21
27
  .filter((entry) => entry.name !== '' && entry.region !== '');
22
28
  }
23
29
  catch {
@@ -91,8 +97,14 @@ export const settleRegionOrphans = async (projectId, orphans, prune) => {
91
97
  * O passo inteiro, para quem só quer chamá-lo antes de deployar. Devolve `true` quando o caminho está
92
98
  * livre — inclusive quando não deu para verificar, pela mesma razão de `listDeployedFunctions`.
93
99
  */
94
- export const clearRegionOrphans = async (projectId, wanted, region, prune) => {
95
- const deployed = await listDeployedFunctions(projectId);
100
+ export const clearRegionOrphans = async (projectId, wanted, region, prune,
101
+ /**
102
+ * A listagem já feita, quando quem chama vai usá-la para outra coisa também. Perguntar duas vezes
103
+ * a mesma coisa ao mesmo projeto não é caro, é ambíguo: as duas respostas podem divergir, e a
104
+ * decisão passa a depender de qual delas o código olhou.
105
+ */
106
+ listed) => {
107
+ const deployed = listed === undefined ? await listDeployedFunctions(projectId) : listed;
96
108
  if (deployed === null)
97
109
  return true;
98
110
  return settleRegionOrphans(projectId, regionOrphans(deployed, wanted, region), prune);
package/lib/index.js CHANGED
@@ -37,14 +37,14 @@ const COMMANDS = [
37
37
  },
38
38
  {
39
39
  name: 'deploy',
40
- usage: 'microteia deploy [--only rules,indexes,functions,hosting] [--delay <seconds>] [--prune]',
40
+ usage: 'microteia deploy [--only rules,indexes,functions,hosting] [--delay <seconds>] [--prune] [--all]',
41
41
  summary: 'Publish the package that is in place to the connected Firebase project.',
42
42
  audience: 'office',
43
43
  execute: deploy,
44
44
  },
45
45
  {
46
46
  name: 'provision',
47
- usage: 'microteia provision [--prune] [--delay <seconds>]',
47
+ usage: 'microteia provision [--prune] [--delay <seconds>] [--all]',
48
48
  summary: 'Give the installed extensions the Cloud Functions and indexes they ask for.',
49
49
  audience: 'office',
50
50
  execute: provision,
@@ -87,4 +87,4 @@ export declare const consoleRoot: string;
87
87
  * de código nunca chegou a quem instala. Este bloco fica sendo o que ele já era antes de crescer: a
88
88
  * política de semver da ferramenta, mais o registro do que motivou as versões até a `2.5.0`.
89
89
  */
90
- export declare const CLI_VERSION = "2.7.1";
90
+ export declare const CLI_VERSION = "2.8.0";
@@ -94,4 +94,4 @@ export const consoleRoot = resolve(here, '..', '..');
94
94
  * de código nunca chegou a quem instala. Este bloco fica sendo o que ele já era antes de crescer: a
95
95
  * política de semver da ferramenta, mais o registro do que motivou as versões até a `2.5.0`.
96
96
  */
97
- export const CLI_VERSION = '2.7.1';
97
+ export const CLI_VERSION = '2.8.0';
@@ -16,6 +16,7 @@ import { join } from 'node:path';
16
16
  */
17
17
  /** Arquivo distribuído → módulo de origem. O nome da pasta do exemplo é o do `.json`, sem extensão. */
18
18
  export const EXAMPLE_SOURCES = {
19
+ 'automation-example': 'automation.ts',
19
20
  brasil: 'brasil.ts',
20
21
  calendar: 'calendar.ts',
21
22
  dashboard: 'dashboard.ts',
@@ -90,8 +90,8 @@ const commandRows = () => [
90
90
  '|---|---|',
91
91
  '| `microteia unpack --version <latest\\|x.y.z>` | Downloads a platform version, verifies it, and puts it in place. Touches no Firebase — safe any time |',
92
92
  '| `microteia unpack --kernel <pkg>.tgz` | The same, from a file you already have — for a machine with no access to the catalog |',
93
- '| `microteia deploy [--only …] [--prune]` | Publishes what is in place: `rules`, `indexes`, `functions`, `hosting`. `--prune` also deletes functions left behind in a region this version no longer uses |',
94
- '| `microteia provision [--prune]` | Gives installed extensions the functions and indexes they ask for |',
93
+ '| `microteia deploy [--only …] [--prune] [--all]` | Publishes what is in place: `rules`, `indexes`, `functions`, `hosting`. A Cloud Function the project already runs with this exact code is not deployed again; `--all` deploys every one of them anyway. `--prune` also deletes functions left behind in a region this version no longer uses |',
94
+ '| `microteia provision [--prune] [--all]` | Gives installed extensions the functions and indexes they ask for |',
95
95
  '| `microteia connect --project <id>` | Binds this installation to a Firebase project you own |',
96
96
  '| `microteia plugin-init` | Prepares `extensions/` for authoring in TypeScript |',
97
97
  ].join('\n');
@@ -179,6 +179,19 @@ carry that setting.
179
179
 
180
180
  An empty line means "not configured". A feature that needs it stays off rather than failing halfway.
181
181
 
182
+ ## 3.1 Why a deploy is usually much faster than the first one
183
+
184
+ Firebase stamps every Cloud Function with what it was built from — the whole source directory, the
185
+ resolved environment variables, and the secret versions. \`microteia deploy\` reads those stamps, deploys
186
+ one reference function, and compares: a function whose stamp comes back unchanged is byte-for-byte what
187
+ the project already runs, and is left alone. It is the same comparison \`firebase deploy\` makes when it
188
+ deploys in bulk, which is why a version that only changed the application's screens finishes in minutes
189
+ instead of publishing every function again.
190
+
191
+ What the stamp does not see is configuration changed **outside** this installation — memory, timeout,
192
+ concurrency or invoker policy edited in the Google console. \`microteia deploy --all\` deploys everything
193
+ regardless, and is the answer for that.
194
+
182
195
  ## 4. Your own Cloud Functions
183
196
 
184
197
  Two places, answering two different questions:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@microteia/console",
3
- "version": "2.7.1",
3
+ "version": "2.8.0",
4
4
  "description": "Install and update Microteia in a Firebase project you own.",
5
5
  "license": "UNLICENSED",
6
6
  "type": "module",