@microteia/console 2.18.0 → 2.20.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,20 @@
22
22
  - fix(command): what was broken, and what it did to whoever hit it.
23
23
  -->
24
24
 
25
+ #### [2.20.0] — 2026-09-01
26
+
27
+ - feat(cli/export-catalog): the snapshot carries what each installed extension declares in `manifest.exports` — the artifacts another extension may reference as `<extension-id>/<source-id>`. Whoever authors outside the office knew an extension was installed and had no way to learn the name of anything inside it, so the reference could be written by hand and never discovered. Each entry carries the id, the artifact's name, and its `kind`, which is the part that decides where the reference fits: a script goes in `scriptId`, an automation in `targetWorkflowId`. What the artifact DOES still never travels — the steps and the code are behaviour, and the snapshot is shape.
28
+ - feat(cli/export-catalog): a declared export that matches no artifact in the package travels as `unknown` rather than being dropped. The install gate checks the LIST, so a reference to it would be accepted there and fail only on the step's first run; saying it is better than hiding it.
29
+ - feat(cli/export-catalog): `exports` is written for every extension, empty list included. A present-and-empty list is the answer "this extension exports nothing"; an absent key is the silence of a Console older than the field, and the kernel tells the two apart instead of reading absence as a claim.
30
+
31
+ #### [2.19.0] — 2026-08-28
32
+
33
+ - fix(cli/deploy): the installation gate is charged against the functions that are GOING UP, not against every source found on disk. It lived in `functionProblems` — the door that answers "can this set be assembled?" — and that door reads every `extensions/<x>/functions/` directory that exists, including one left behind by an extension that was uninstalled and removed. The result was `microteia deploy` refusing in full — hosting, rules and indexes with it — over a callable no command would have deployed. `gateProblems` now carries the function's name, and `gateProblemsFor` filters by what is being deployed.
34
+ - fix(cli/provision): a gated function that fails the check leaves the deploy queue instead of taking the command down with it. Everything else provisions, `--prune` still removes the orphan, and the command ends in an error naming the function that was held back.
35
+ - feat(cli/provision): orphan functions are found in the PROJECT, not only in the record. `backend.functions` in `microteia.yaml` was the single source of truth about what had been deployed, and it is a truth that gets lost — a redone `init`, a restored backup, a Console older than the field. Lost it, and the Cloud Function of an extension nobody has stayed in the air with no command mentioning it. `straysInProject` crosses `firebase functions:list` with what the installation can justify; only a name this installation knows how to produce can become an orphan, and what it does not know is reported and never touched.
36
+ - feat(cli/provision): an orphan found that way enters the record when it is not removed, so it stays known from the next run on even if its source leaves the disk.
37
+ - fix(cli/provision): `functions:list` is asked on every run. It was skipped when there was nothing applied to check — the exact case that needed it, since an installation with no extensions left is where the departed extension's function sits. When the listing cannot be read, the command says so instead of letting silence read as "nothing left over".
38
+
25
39
  #### [2.18.0] — 2026-08-28
26
40
 
27
41
  - feat(console/functionGate): an extension's callable without `requireInstalledExtension` is refused, and a guard naming another extension's id is rejected the way a misgated trigger is. The refusal exists because the deploy makes every callable publicly invocable — this Console's own `callableAccess.ts` is there to confirm that grant went through — so the callable answers any signed-in user of the project, from any organization, whether or not they installed the extension, with the organization arriving in the payload.
package/README.md CHANGED
@@ -1,4 +1,4 @@
1
- # Microteia Console 2.18.0
1
+ # Microteia Console 2.20.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.
@@ -77,3 +77,38 @@ export declare const appliedAfterRun: (input: {
77
77
  }) => AppliedFunction[];
78
78
  /** Como uma function aplicada aparece num relatório: sem dono conhecido, só o nome. */
79
79
  export declare const describeApplied: (entry: AppliedFunction) => string;
80
+ export interface StrayFunctions {
81
+ /** Roda no projeto, esta instalação sabe produzi-la, e nada a justifica agora: órfã de verdade. */
82
+ orphans: AppliedFunction[];
83
+ /** Roda no projeto e esta instalação não a conhece. Relatada, nunca apagada. */
84
+ strangers: string[];
85
+ }
86
+ /**
87
+ * A órfã que o REGISTRO não conhece — e o limite que impede a varredura de virar um estrago.
88
+ *
89
+ * O registro (`backend.functions` do `microteia.yaml`) era a ÚNICA verdade sobre o que havia sido
90
+ * deployado, e é uma verdade que se perde: um Console anterior ao campo, um `init` refeito, um deploy
91
+ * dado à mão, um arquivo restaurado de um backup mais velho. Perdido o registro, `classifyApplied` não
92
+ * tinha o que classificar, `--prune` não tinha o que apagar, e a Cloud Function de uma extensão que
93
+ * ninguém tem mais ficava no ar para sempre, sem nenhum comando que a mencionasse. É o pior sintoma da
94
+ * família: uma callable publicamente invocável, de código que ninguém instalou, e silêncio.
95
+ *
96
+ * A resposta é perguntar ao PROJETO o que ele roda, em vez de só ao arquivo. E o limite é `known`: só é
97
+ * candidata a órfã a function que ESTA instalação sabe produzir — o que o pacote traz mais o que o
98
+ * escritório escreveu. O que o projeto roda e a instalação não conhece pode ser de qualquer um: um
99
+ * projeto Firebase é compartilhável, e apagar ali seria a Microteia decidir sobre o que não é dela.
100
+ * Esse caso é relatado e nunca tocado.
101
+ *
102
+ * `recorded` sai do resultado porque quem responde por ele é `classifyApplied`, que sabe mais: ele tem o
103
+ * dono e as duas razões de preservação. Contar duas vezes a mesma function proporia apagá-la duas vezes.
104
+ */
105
+ export declare const straysInProject: (input: {
106
+ /** O que o projeto roda AGORA, na região do kernel. */
107
+ running: string[];
108
+ /** O que esta instalação sabe produzir: o pacote mais o que o escritório escreveu. */
109
+ known: string[];
110
+ /** O que tem razão de estar no ar agora: exigida, baseline, comum sem declaração, ou não resolvida. */
111
+ justified: string[];
112
+ /** O que o registro já conhece — respondido por `classifyApplied`. */
113
+ recorded: string[];
114
+ }) => StrayFunctions;
@@ -86,3 +86,38 @@ export const appliedAfterRun = (input) => {
86
86
  };
87
87
  /** Como uma function aplicada aparece num relatório: sem dono conhecido, só o nome. */
88
88
  export const describeApplied = (entry) => entry.owner === null ? entry.name : `${entry.name} (${entry.owner})`;
89
+ /**
90
+ * A órfã que o REGISTRO não conhece — e o limite que impede a varredura de virar um estrago.
91
+ *
92
+ * O registro (`backend.functions` do `microteia.yaml`) era a ÚNICA verdade sobre o que havia sido
93
+ * deployado, e é uma verdade que se perde: um Console anterior ao campo, um `init` refeito, um deploy
94
+ * dado à mão, um arquivo restaurado de um backup mais velho. Perdido o registro, `classifyApplied` não
95
+ * tinha o que classificar, `--prune` não tinha o que apagar, e a Cloud Function de uma extensão que
96
+ * ninguém tem mais ficava no ar para sempre, sem nenhum comando que a mencionasse. É o pior sintoma da
97
+ * família: uma callable publicamente invocável, de código que ninguém instalou, e silêncio.
98
+ *
99
+ * A resposta é perguntar ao PROJETO o que ele roda, em vez de só ao arquivo. E o limite é `known`: só é
100
+ * candidata a órfã a function que ESTA instalação sabe produzir — o que o pacote traz mais o que o
101
+ * escritório escreveu. O que o projeto roda e a instalação não conhece pode ser de qualquer um: um
102
+ * projeto Firebase é compartilhável, e apagar ali seria a Microteia decidir sobre o que não é dela.
103
+ * Esse caso é relatado e nunca tocado.
104
+ *
105
+ * `recorded` sai do resultado porque quem responde por ele é `classifyApplied`, que sabe mais: ele tem o
106
+ * dono e as duas razões de preservação. Contar duas vezes a mesma function proporia apagá-la duas vezes.
107
+ */
108
+ export const straysInProject = (input) => {
109
+ const known = new Set(input.known);
110
+ const justified = new Set(input.justified);
111
+ const recorded = new Set(input.recorded);
112
+ const stray = { orphans: [], strangers: [] };
113
+ for (const name of [...new Set(input.running)].sort()) {
114
+ if (!known.has(name)) {
115
+ stray.strangers.push(name);
116
+ continue;
117
+ }
118
+ if (justified.has(name) || recorded.has(name))
119
+ continue;
120
+ stray.orphans.push({ name, owner: null });
121
+ }
122
+ return stray;
123
+ };
@@ -1,7 +1,7 @@
1
1
  import { access, cp, mkdir, readFile, readdir, rm, writeFile } from 'node:fs/promises';
2
2
  import { basename, join } from 'node:path';
3
3
  import { callableFunctionNames, declaredParametersIn } from '../kernel/manifest.js';
4
- import { callableDeclarations, triggerDeclarations, ungatedTriggerProblems, unguardedCallableProblems, unguardedSharedProblems, unscopedDeclarations, unscopedTriggerProblems, } from './functionGate.js';
4
+ import { callableDeclarations, triggerDeclarations, unscopedDeclarations, } from './functionGate.js';
5
5
  /**
6
6
  * As Cloud Functions do ESCRITÓRIO — as que ele escreve, não as do kernel.
7
7
  *
@@ -273,12 +273,11 @@ export const functionProblems = (sources, kernelFunctions) => {
273
273
  }
274
274
  }
275
275
  problems.push(...servesProblems(sources));
276
- // O portão de instalação entra aqui, e não numa verificação à parte, porque o momento é o mesmo: é
277
- // este o ponto em que a fonte do escritório passa a fazer parte do pacote que vai subir.
278
- problems.push(...ungatedTriggerProblems(sources));
279
- problems.push(...unscopedTriggerProblems(sources));
280
- problems.push(...unguardedSharedProblems(sources));
281
- problems.push(...unguardedCallableProblems(sources));
276
+ // O portão de instalação NÃO entra aqui. Esta porta responde "dá para montar o conjunto?", e a
277
+ // resposta dela vale para toda fonte que existe no disco — inclusive a da extensão que foi
278
+ // desinstalada e cujo diretório ficou. O portão responde outra coisa: "esta function pode ir para o
279
+ // ar?", e ela só é devida de quem vai. Quem pergunta é `gateProblemsFor`, no ponto em que os nomes
280
+ // que sobem já estão decididos: `deploy` contra a baseline mais as comuns, `provision` contra a fila.
282
281
  return problems;
283
282
  };
284
283
  const barrel = (sources) => `// Generated by "microteia deploy". Do not edit.
@@ -7,6 +7,7 @@ import { ENVIRONMENT_FILE, readEnvironment, writeEnvironment } from './environme
7
7
  import { FIREBASE_BIN } from './firebaseCli.js';
8
8
  import { reportFailures, reportNotices } from './deployFunctions.js';
9
9
  import { clearRegionOrphans, listDeployedFunctions } from './regionOrphans.js';
10
+ import { gateProblemsFor } from './functionGate.js';
10
11
  import { deployFunctionsThatChanged } from './functionsUpToDate.js';
11
12
  import { checkCallables, invokerGrantCommand } from './callableAccess.js';
12
13
  import { checkHostingServed, htmlIsRevalidated } from './hostingCheck.js';
@@ -138,6 +139,19 @@ export const deploy = async (args) => {
138
139
  * impede esta versão de tirar do ar function de uma instalação que já existe.
139
140
  */
140
141
  const own = [...baseline, ...gathered.unserved];
142
+ /**
143
+ * O portão de instalação, cobrado do que ESTE passo sobe — e não de toda fonte que existe no
144
+ * disco.
145
+ *
146
+ * Na prática ele quase nunca tem o que dizer aqui, e isso é a confirmação de que o lugar é este:
147
+ * o que sobe no `deploy` é a baseline (do kernel) e a comum sem declaração (de quem não há id a
148
+ * cobrar). É no `provision` que a function de extensão entra na fila, e é lá que a recusa morde.
149
+ * Cobrar aqui mantém a regra atada ao ato — se um dia esta lista crescer, ela vem coberta.
150
+ */
151
+ const refused = gateProblemsFor(gathered.sources, own);
152
+ if (refused.length > 0) {
153
+ throw new Error(`These Cloud Functions cannot go up as they are:\n${refused.map((line) => ` ${line}`).join('\n')}`);
154
+ }
141
155
  step(current, total, `Cloud Functions (${own.length}, one at a time)`);
142
156
  if (gathered.sources.length > 0) {
143
157
  success(`${gathered.functions.length} function(s) of your own, from `
@@ -76,11 +76,33 @@ export interface CatalogListExport {
76
76
  label: string;
77
77
  ownerPluginId?: string;
78
78
  }
79
+ /**
80
+ * Um artefato que a extensão declara em `manifest.exports` — a superfície que outra extensão pode
81
+ * referenciar como `<pluginId>/<sourceId>`.
82
+ *
83
+ * O `kind` viaja porque é ele que decide ONDE a referência cabe: script em `scriptId`, automação em
84
+ * `targetWorkflowId`. `unknown` é o export declarado que não casa com artefato nenhum do pacote —
85
+ * dizê-lo é melhor que omiti-lo, porque uma referência a ele passaria no portão da instalação, que
86
+ * confere a LISTA, e falharia só na primeira execução.
87
+ *
88
+ * O que NÃO viaja é o que o artefato faz: os passos e o código são comportamento, e o retrato é
89
+ * esquema. O nome e o tipo são contrato.
90
+ */
91
+ export interface CatalogExtensionExportedArtifact {
92
+ id: string;
93
+ kind: 'workflow' | 'action' | 'script' | 'unknown';
94
+ name: string;
95
+ }
79
96
  export interface CatalogExtensionExport {
80
97
  id: string;
81
98
  name: string;
82
99
  version: string;
83
100
  active: boolean;
101
+ /**
102
+ * Sempre escrita, mesmo vazia. É a lista PRESENTE e vazia que diz "esta extensão não exporta nada";
103
+ * a chave ausente é o silêncio de um Console anterior a este campo, e o kernel distingue as duas.
104
+ */
105
+ exports: CatalogExtensionExportedArtifact[];
84
106
  }
85
107
  export interface CatalogExport {
86
108
  version: number;
@@ -46,6 +46,38 @@ const asRecord = (value) => (typeof value === 'object' && value !== null && !Arr
46
46
  ? value
47
47
  : undefined);
48
48
  const FIELD_ORIGINS = ['builtin', 'plugin', 'design'];
49
+ /**
50
+ * A superfície exportada de uma cópia instalada: os ids de `manifest.exports`, cada um casado com o
51
+ * artefato do pacote que o atende, para ganhar tipo e nome.
52
+ *
53
+ * O Console não alcança `src/` — logo não há `MarketplacePlugin` tipado aqui, e a leitura é do
54
+ * documento cru, como todo o resto deste arquivo. Um export que não casa com nada continua na lista
55
+ * como `unknown`: a extensão está declarando algo que não entrega, e esconder isso do autor apenas
56
+ * adia a descoberta para a primeira execução.
57
+ */
58
+ const exportedArtifacts = (extension) => {
59
+ const declared = extension.manifest?.exports;
60
+ if (!Array.isArray(declared))
61
+ return [];
62
+ const pkg = asRecord(extension.data.package) ?? {};
63
+ const named = new Map();
64
+ const collect = (raw, kind) => {
65
+ for (const one of Array.isArray(raw) ? raw : []) {
66
+ const record = asRecord(one);
67
+ const id = text(record?.templateId);
68
+ if (id === undefined || named.has(id))
69
+ continue;
70
+ named.set(id, { id, kind, name: text(record?.name) ?? text(record?.title) ?? id });
71
+ }
72
+ };
73
+ collect(pkg.workflows, 'workflow');
74
+ collect(pkg.actions, 'action');
75
+ collect(pkg.scripts, 'script');
76
+ return declared
77
+ .filter((id) => typeof id === 'string' && id !== '')
78
+ .map((id) => named.get(id) ?? { id, kind: 'unknown', name: id })
79
+ .sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
80
+ };
49
81
  /**
50
82
  * A mesma dedução que a aplicação faz ao ler a tela (`screenDefinitionMapper.ts`): o `origin` gravado
51
83
  * quando ele existe, senão `plugin` para quem tem dono e `design` para o que o escritório criou. Ela é
@@ -135,7 +167,13 @@ export const buildCatalogExport = (input) => ({
135
167
  kernelVersion: input.kernelVersion,
136
168
  consoleVersion: CLI_VERSION,
137
169
  extensions: input.extensions
138
- .map((one) => ({ id: one.id, name: one.name, version: one.version, active: one.active }))
170
+ .map((one) => ({
171
+ id: one.id,
172
+ name: one.name,
173
+ version: one.version,
174
+ active: one.active,
175
+ exports: exportedArtifacts(one),
176
+ }))
139
177
  .sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0)),
140
178
  screens: input.screens
141
179
  .filter((document) => document.data.enabled !== false)
@@ -252,13 +290,16 @@ export const exportCatalog = async (args) => {
252
290
  + `${document.lookups.length} list(s), ${document.tagLists.length} tag list(s)`);
253
291
  info(` ${fromExtensions} screen(s) come from the ${document.extensions.length} installed extension(s); `
254
292
  + `the rest the office built itself.`);
293
+ const exported = document.extensions.reduce((total, one) => total + one.exports.length, 0);
294
+ info(` ${exported} artifact(s) are declared for other extensions to reference as <extension-id>/<id>.`);
255
295
  blank();
256
296
  await checkIgnored(directory);
257
297
  info('It is a SNAPSHOT, and it says so: the authoring tools report when it was taken and warn when it');
258
298
  info('is old. Nothing keeps it in step with the office — run this again after screens change there.');
259
299
  blank();
260
300
  info('What it deliberately leaves out: records, the values inside a list of options or a tag list,');
261
- info('members and permissions, workflows, scripts, actions, secrets and settings. It describes what');
262
- info('the entities ARE, and nothing an office typed into them.');
301
+ info('members and permissions, what a workflow, script or action DOES, secrets and settings. It');
302
+ info('describes what the entities ARE, and nothing an office typed into them. What each extension');
303
+ info('declares in "exports" travels: that is the surface another extension may reference.');
263
304
  blank();
264
305
  };
@@ -13,8 +13,11 @@
13
13
  * código de uma extensão que ninguém tem mais rodando sobre os dados de quem nunca a pediu, sem erro em
14
14
  * lugar nenhum.
15
15
  *
16
- * É recusa e não aviso pelo mesmo motivo do nome de function repetido (`functionProblems`): o defeito é
17
- * invisível depois de instalado, e um aviso na saída de um deploy é lido uma vez.
16
+ * É recusa, e não aviso, pelo mesmo motivo do nome de function repetido: o defeito é invisível depois de
17
+ * instalado, e um aviso na saída de um deploy é lido uma vez. Mas ela é cobrada da function que VAI
18
+ * SUBIR, e não de toda fonte que existe no disco — ver `gateProblemsFor`. Toda origem é lida, sempre, e
19
+ * o que não vai subir é relatado pelo `provision`: quem tem a fonte parada ao lado do `microteia.yaml`
20
+ * fica sabendo dela, sem ter o comando trancado por ela.
18
21
  *
19
22
  * A análise é por texto, no idioma dos outros leitores de fonte do Console (`callableFunctionNames`,
20
23
  * `declaredParametersIn`). Ela erra para o lado ALTO — o que ela não entende ela recusa —, e é por isso
@@ -105,6 +108,22 @@ export interface GateSubject {
105
108
  /** As callables declaradas, com o estado da guarda de cada uma. */
106
109
  callableGuards: TriggerDeclaration[];
107
110
  }
111
+ /**
112
+ * Um problema de portão, e a function a que ele pertence.
113
+ *
114
+ * O nome está aqui porque é ele que decide o PESO do problema. A verificação lê toda fonte que existe no
115
+ * disco — é assim que ela acha o que precisa achar —, mas o que existe no disco não é o que vai subir:
116
+ * `extensions/<x>/functions/` de uma extensão que ninguém instalou é lida, copiada e nunca deployada.
117
+ * Sem o nome, quem chama só tem a frase, e a única coisa que dá para fazer com uma frase é recusar tudo.
118
+ * Foi o que acontecia: uma extensão desinstalada, com a fonte parada ao lado do `microteia.yaml`,
119
+ * trancava o `deploy` inteiro — hosting, rules e índices junto — por causa de uma function que nenhum
120
+ * comando teria subido.
121
+ */
122
+ export interface GateProblem {
123
+ /** A function a que o problema pertence. É por ela que se sabe se ela vai, de fato, subir. */
124
+ name: string;
125
+ message: string;
126
+ }
108
127
  /**
109
128
  * Gatilho de extensão sem portão.
110
129
  *
@@ -113,7 +132,7 @@ export interface GateSubject {
113
132
  * endereço próprio — enquanto ela não fechar, exigir aqui um portão impossível seria recusar a única
114
133
  * forma que a plataforma oferece para uma function compartilhada.
115
134
  */
116
- export declare const ungatedTriggerProblems: (subjects: GateSubject[]) => string[];
135
+ export declare const ungatedTriggerProblems: (subjects: GateSubject[]) => GateProblem[];
117
136
  /**
118
137
  * Function de extensão cujo evento não tem escritório, e que não passa pelo laço.
119
138
  *
@@ -123,7 +142,7 @@ export declare const ungatedTriggerProblems: (subjects: GateSubject[]) => string
123
142
  * `functions/` fica de fora pelo mesmo motivo de sempre: a comum não tem um id de extensão para dar ao
124
143
  * laço. Quem responde por ela é a declaração de quem ela serve.
125
144
  */
126
- export declare const unscopedTriggerProblems: (subjects: GateSubject[]) => string[];
145
+ export declare const unscopedTriggerProblems: (subjects: GateSubject[]) => GateProblem[];
127
146
  /**
128
147
  * Gatilho de uma function COMUM que declara quem serve, e não guarda ninguém.
129
148
  *
@@ -139,7 +158,7 @@ export declare const unscopedTriggerProblems: (subjects: GateSubject[]) => strin
139
158
  * respondem à mesma pergunta em dois lugares, e divergirem faz uma delas mentir — a `serves` decide a
140
159
  * retirada, a guarda decide a execução, e a function passaria a agir onde não pode ser retirada.
141
160
  */
142
- export declare const unguardedSharedProblems: (subjects: GateSubject[]) => string[];
161
+ export declare const unguardedSharedProblems: (subjects: GateSubject[]) => GateProblem[];
143
162
  /**
144
163
  * Callable de extensão sem a guarda de instalação.
145
164
  *
@@ -157,7 +176,18 @@ export declare const unguardedSharedProblems: (subjects: GateSubject[]) => strin
157
176
  * conhecida e está registrada — a guarda "alguma das que eu sirvo" existe para gatilho, não para
158
177
  * callable.
159
178
  */
160
- export declare const unguardedCallableProblems: (subjects: GateSubject[]) => string[];
179
+ export declare const unguardedCallableProblems: (subjects: GateSubject[]) => GateProblem[];
180
+ /** Todo problema de portão de um conjunto de origens, na ordem em que as mensagens os citam. */
181
+ export declare const gateProblems: (subjects: GateSubject[]) => GateProblem[];
182
+ /**
183
+ * Os problemas das functions que ESTÃO subindo — a lista que autoriza uma recusa.
184
+ *
185
+ * É a diferença entre "a fonte foi lida" e "a function vai para o ar", e ela não é sutil: a primeira
186
+ * inclui toda extensão cujo diretório ficou no disco depois de desinstalada, e recusar por causa dela é
187
+ * recusar um comando que não faria nada de errado. A segunda é o momento em que a recusa tem
188
+ * consequência — e é o único em que ela é devida.
189
+ */
190
+ export declare const gateProblemsFor: (subjects: GateSubject[], deploying: string[]) => string[];
161
191
  export interface MisgatedTrigger {
162
192
  /** A origem, como as mensagens a citam. */
163
193
  label: string;
@@ -13,8 +13,11 @@
13
13
  * código de uma extensão que ninguém tem mais rodando sobre os dados de quem nunca a pediu, sem erro em
14
14
  * lugar nenhum.
15
15
  *
16
- * É recusa e não aviso pelo mesmo motivo do nome de function repetido (`functionProblems`): o defeito é
17
- * invisível depois de instalado, e um aviso na saída de um deploy é lido uma vez.
16
+ * É recusa, e não aviso, pelo mesmo motivo do nome de function repetido: o defeito é invisível depois de
17
+ * instalado, e um aviso na saída de um deploy é lido uma vez. Mas ela é cobrada da function que VAI
18
+ * SUBIR, e não de toda fonte que existe no disco — ver `gateProblemsFor`. Toda origem é lida, sempre, e
19
+ * o que não vai subir é relatado pelo `provision`: quem tem a fonte parada ao lado do `microteia.yaml`
20
+ * fica sabendo dela, sem ter o comando trancado por ela.
18
21
  *
19
22
  * A análise é por texto, no idioma dos outros leitores de fonte do Console (`callableFunctionNames`,
20
23
  * `declaredParametersIn`). Ela erra para o lado ALTO — o que ela não entende ela recusa —, e é por isso
@@ -269,9 +272,12 @@ export const ungatedTriggerProblems = (subjects) => subjects
269
272
  .filter((subject) => !subject.shared)
270
273
  .flatMap((subject) => exportedTriggers(subject)
271
274
  .filter((trigger) => trigger.gatedFor === null)
272
- .map((trigger) => `${subject.label} exports "${trigger.name}", a ${trigger.trigger} trigger with no `
273
- + `installation gate — wrap its handler in ${GATE}('<this extension's id>', …) or it fires for `
274
- + 'every office in the project, including the ones that never installed the extension'));
275
+ .map((trigger) => ({
276
+ name: trigger.name,
277
+ message: `${subject.label} exports "${trigger.name}", a ${trigger.trigger} trigger with no `
278
+ + `installation gate — wrap its handler in ${GATE}('<this extension's id>', …) or it fires for `
279
+ + 'every office in the project, including the ones that never installed the extension',
280
+ })));
275
281
  /**
276
282
  * Function de extensão cujo evento não tem escritório, e que não passa pelo laço.
277
283
  *
@@ -285,10 +291,13 @@ export const unscopedTriggerProblems = (subjects) => subjects
285
291
  .filter((subject) => !subject.shared)
286
292
  .flatMap((subject) => exportedUnscoped(subject)
287
293
  .filter((trigger) => trigger.gatedFor === null)
288
- .map((trigger) => `${subject.label} exports "${trigger.name}", a ${trigger.trigger} function whose `
289
- + `event carries no {tenantId} — ${GATE} cannot gate it (it fails closed, so the function would `
290
- + `never run at all). Loop with ${OFFICE_LOOP}('<this extension's id>', …) inside the handler, or `
291
- + 'it acts on every office in the project, including the ones that never installed the extension'));
294
+ .map((trigger) => ({
295
+ name: trigger.name,
296
+ message: `${subject.label} exports "${trigger.name}", a ${trigger.trigger} function whose `
297
+ + `event carries no {tenantId} — ${GATE} cannot gate it (it fails closed, so the function would `
298
+ + `never run at all). Loop with ${OFFICE_LOOP}('<this extension's id>', …) inside the handler, or `
299
+ + 'it acts on every office in the project, including the ones that never installed the extension',
300
+ })));
292
301
  /**
293
302
  * Gatilho de uma function COMUM que declara quem serve, e não guarda ninguém.
294
303
  *
@@ -311,16 +320,22 @@ export const unguardedSharedProblems = (subjects) => subjects
311
320
  if (serves === undefined || serves.length === 0)
312
321
  return [];
313
322
  if (trigger.guards.length === 0) {
314
- return [`${subject.label} exports "${trigger.name}", a ${trigger.trigger} trigger declared as `
315
- + `serving ${serves.join(', ')} — guard it with ${ANY_GATE}([${serves.map((id) => `'${id}'`).join(', ')}], …) `
316
- + 'or it fires for every office in the project, including the ones that installed none of them'];
323
+ return [{
324
+ name: trigger.name,
325
+ message: `${subject.label} exports "${trigger.name}", a ${trigger.trigger} trigger declared as `
326
+ + `serving ${serves.join(', ')} — guard it with ${ANY_GATE}([${serves.map((id) => `'${id}'`).join(', ')}], …) `
327
+ + 'or it fires for every office in the project, including the ones that installed none of them',
328
+ }];
317
329
  }
318
330
  const stranger = trigger.guards.filter((id) => !serves.includes(id));
319
331
  if (stranger.length === 0)
320
332
  return [];
321
- return [`${subject.label} guards "${trigger.name}" for ${stranger.join(', ')}, which it does not `
322
- + `declare serving in microteia.serves (${serves.join(', ')}) — the two lists answer the same `
323
- + 'question, and one of them is wrong'];
333
+ return [{
334
+ name: trigger.name,
335
+ message: `${subject.label} guards "${trigger.name}" for ${stranger.join(', ')}, which it does not `
336
+ + `declare serving in microteia.serves (${serves.join(', ')}) — the two lists answer the same `
337
+ + 'question, and one of them is wrong',
338
+ }];
324
339
  }));
325
340
  /**
326
341
  * Callable de extensão sem a guarda de instalação.
@@ -343,10 +358,32 @@ export const unguardedCallableProblems = (subjects) => subjects
343
358
  .filter((subject) => !subject.shared)
344
359
  .flatMap((subject) => exportedCallables(subject)
345
360
  .filter((callable) => callable.gatedFor === null)
346
- .map((callable) => `${subject.label} exports "${callable.name}", a callable with no installation `
347
- + `check — call ${CALLABLE_GUARD}('<this extension's id>', tenantId, request) before you read the `
348
- + 'payload. The deploy makes every callable publicly invocable, so it answers any signed-in user '
349
- + 'of this project, from any office, whether or not they installed the extension'));
361
+ .map((callable) => ({
362
+ name: callable.name,
363
+ message: `${subject.label} exports "${callable.name}", a callable with no installation `
364
+ + `check — call ${CALLABLE_GUARD}('<this extension's id>', tenantId, request) before you read the `
365
+ + 'payload. The deploy makes every callable publicly invocable, so it answers any signed-in user '
366
+ + 'of this project, from any office, whether or not they installed the extension',
367
+ })));
368
+ /** Todo problema de portão de um conjunto de origens, na ordem em que as mensagens os citam. */
369
+ export const gateProblems = (subjects) => [
370
+ ...ungatedTriggerProblems(subjects),
371
+ ...unscopedTriggerProblems(subjects),
372
+ ...unguardedSharedProblems(subjects),
373
+ ...unguardedCallableProblems(subjects),
374
+ ];
375
+ /**
376
+ * Os problemas das functions que ESTÃO subindo — a lista que autoriza uma recusa.
377
+ *
378
+ * É a diferença entre "a fonte foi lida" e "a function vai para o ar", e ela não é sutil: a primeira
379
+ * inclui toda extensão cujo diretório ficou no disco depois de desinstalada, e recusar por causa dela é
380
+ * recusar um comando que não faria nada de errado. A segunda é o momento em que a recusa tem
381
+ * consequência — e é o único em que ela é devida.
382
+ */
383
+ export const gateProblemsFor = (subjects, deploying) => {
384
+ const going = new Set(deploying);
385
+ return gateProblems(subjects).filter((problem) => going.has(problem.name)).map((p) => p.message);
386
+ };
350
387
  /**
351
388
  * Portão aberto para o id ERRADO.
352
389
  *
@@ -17,8 +17,8 @@ import { asString, asTimestamp, patchMapField } from './firestoreRest.js';
17
17
  import { writeBackendResidue } from './backendResidue.js';
18
18
  import { listTenants, readInstalledExtensions } from './installedExtensions.js';
19
19
  import { gatherCustomFunctions, readOfficeFunctions } from './customFunctions.js';
20
- import { describeMisgated, misgatedTriggers, singleClaimants, ungatedTriggerProblems, unguardedCallableProblems, unscopedTriggerProblems, } from './functionGate.js';
21
- import { appliedAfterRun, classifyApplied, describeApplied, parseAppliedFunctions, serializeAppliedFunctions, } from './appliedFunctions.js';
20
+ import { describeMisgated, gateProblems, misgatedTriggers, singleClaimants, } from './functionGate.js';
21
+ import { appliedAfterRun, classifyApplied, describeApplied, parseAppliedFunctions, serializeAppliedFunctions, straysInProject, } from './appliedFunctions.js';
22
22
  /**
23
23
  * O que cada escritório do projeto instalou e exige do backend.
24
24
  *
@@ -140,22 +140,26 @@ export const provisionBackend = async (options) => {
140
140
  info(' "serves" in functions/package.json, which is how a shared function gets withdrawn.');
141
141
  }
142
142
  /**
143
- * O gatilho sem portão é RELATADO aqui e RECUSADO na montagem (`functionProblems`), e a assimetria é
144
- * deliberada: este é o único comando capaz de RETIRAR uma function do projeto. Recusar aqui trancaria
145
- * `--prune` — o comando que apaga a órfã — por causa de uma fonte que ele não precisa ler. A recusa
146
- * acontece onde a fonte de fato entra no pacote, e aí ela não tranca a saída de ninguém.
143
+ * O portão é RELATADO por inteiro aqui, e COBRADO uma a uma na fila de deploy, mais abaixo.
144
+ *
145
+ * A assimetria é deliberada, e é o que este comando exige: ele é o único capaz de RETIRAR uma function
146
+ * do projeto. Recusar o comando inteiro trancaria `--prune` — que é justamente quem apaga a órfã — por
147
+ * causa de uma fonte que a órfã não tem nada a ver. Então a fonte defeituosa sai da fila, é dita em voz
148
+ * alta, o comando termina em erro por causa dela, e tudo o mais acontece.
149
+ *
150
+ * O relato é de TODAS, e não só das que estão subindo agora: uma fonte com portão faltando é uma
151
+ * function que ninguém vai conseguir deployar, e descobrir isso no dia da instalação da extensão é
152
+ * tarde. Aqui o que ela custa é uma linha.
147
153
  */
148
- const ungated = [
149
- ...ungatedTriggerProblems(office.sources),
150
- ...unscopedTriggerProblems(office.sources),
151
- ...unguardedCallableProblems(office.sources),
152
- ];
153
- if (ungated.length > 0) {
154
- warn(`${ungated.length} function(s) written in this installation act outside any office's`
154
+ const gateFaults = gateProblems(office.sources);
155
+ const gateFaultBy = new Map(gateFaults.map((fault) => [fault.name, fault.message]));
156
+ if (gateFaults.length > 0) {
157
+ warn(`${gateFaults.length} function(s) written in this installation act outside any office's`
155
158
  + ' installation:');
156
- bullets(ungated);
157
- info(' they are refused the moment something tries to deploy them. Until then they keep acting on');
158
- info(' every office in this project.');
159
+ bullets(gateFaults.map((fault) => fault.message));
160
+ info(' each one is refused the moment this command tries to deploy IT — the others go up as');
161
+ info(' usual, and a source sitting here for an extension nobody installed blocks nothing.');
162
+ info(' Whatever is already in the air keeps acting on every office in this project.');
159
163
  }
160
164
  /**
161
165
  * A comum SEM declaração de quem serve. Ela continua subindo no `deploy`, sempre, e nada a retira — é
@@ -305,10 +309,11 @@ export const provisionBackend = async (options) => {
305
309
  * numa região abandonada. Perguntar duas vezes não é caro, é ambíguo — as duas respostas podem
306
310
  * divergir, e aí a decisão passa a depender de qual delas o código olhou.
307
311
  *
308
- * E ela é feita só quando há o que decidir. Sem o desvio lá em cima, este caminho passou a ser
309
- * percorrido também pelo `provision` que roda quando não sobrou extensão nenhuma — e ali não há
310
- * function para conferir nem para subir, então um `functions:list` seria pagar segundos por uma
311
- * resposta que ninguém lê.
312
+ * Ela é feita SEMPRE, e a versão anterior a pulava quando não havia function aplicada a conferir —
313
+ * economia que custava exatamente o caso que mais importa. É a instalação sem extensão nenhuma que
314
+ * precisa da pergunta: é ali que mora a function de uma extensão que saiu, e sem perguntar ao projeto
315
+ * a resposta é o silêncio. A ida vale segundos; a alternativa é uma callable órfã que nenhum comando
316
+ * menciona.
312
317
  */
313
318
  let asked;
314
319
  const projectFunctions = async () => {
@@ -358,11 +363,29 @@ export const provisionBackend = async (options) => {
358
363
  info(' Cloud Function at ITS deploy, so a function already running keeps the previous one.');
359
364
  blank();
360
365
  }
361
- const toDeploy = wantedFunctions.filter((name) => options.all === true || !appliedNames.has(name) || staleFunctions.has(name));
366
+ const queued = wantedFunctions.filter((name) => options.all === true || !appliedNames.has(name) || staleFunctions.has(name));
367
+ /**
368
+ * A recusa do portão, cobrada de quem está na fila — e só dela.
369
+ *
370
+ * Ela sai da fila em vez de derrubar o comando, e as duas metades disso importam. Sair da fila é o que
371
+ * impede a function de ir para o ar agindo sobre escritório que não a instalou, que é o defeito
372
+ * inteiro. Não derrubar é o que deixa o resto acontecer: as outras extensões recebem o backend delas,
373
+ * os índices convergem, e `--prune` apaga a órfã. O comando termina em erro por causa dela, no fim,
374
+ * quando já não há nada a atrapalhar.
375
+ */
376
+ const refused = queued.filter((name) => gateFaultBy.has(name));
377
+ const toDeploy = queued.filter((name) => !gateFaultBy.has(name));
378
+ if (refused.length > 0) {
379
+ blank();
380
+ failure(`${refused.length} function(s) are needed by an installed extension and cannot go up:`);
381
+ bullets(refused.map((name) => gateFaultBy.get(name) ?? name));
382
+ info(' they are the only ones held back. Fix the guard and run "microteia provision" again.');
383
+ outcome.failed.push(...refused);
384
+ }
362
385
  if (toDeploy.length === 0) {
363
386
  // Anunciar "as 0 functions já estão deployadas" seria o preço de ter tirado o desvio, e não é
364
387
  // preciso pagá-lo: quando não há exigência nenhuma, quem já falou foi a linha lá em cima.
365
- if (wantedFunctions.length > 0) {
388
+ if (wantedFunctions.length > 0 && refused.length === 0) {
366
389
  success(`the ${wantedFunctions.length} function(s) these extensions need are already deployed`);
367
390
  }
368
391
  }
@@ -387,7 +410,7 @@ export const provisionBackend = async (options) => {
387
410
  projectId, cwd: install, delaySeconds, force: false,
388
411
  });
389
412
  outcome.deployed = report.deployed;
390
- outcome.failed = report.failed.map((entry) => entry.name);
413
+ outcome.failed.push(...report.failed.map((entry) => entry.name));
391
414
  reportFailures(report.failed);
392
415
  reportNotices(report.notices);
393
416
  await checkNewCallables([...(manifest.callables ?? []), ...office.callables], report.deployed, projectId, region);
@@ -402,9 +425,42 @@ export const provisionBackend = async (options) => {
402
425
  outcome.pending.push(claim);
403
426
  }
404
427
  }
428
+ /**
429
+ * O que o PROJETO roda, cruzado com o que esta instalação consegue justificar.
430
+ *
431
+ * O registro respondia sozinho por essa pergunta, e é um registro que se perde — e quando se perde,
432
+ * some com ele a única memória de que aquela function existe. Perguntar ao projeto é o que fecha o
433
+ * buraco: uma callable de extensão desinstalada, no ar, publicamente invocável, com o
434
+ * `backend.functions` do `microteia.yaml` vazio, não era mencionada por comando nenhum.
435
+ *
436
+ * O limite está em `straysInProject`, e é o que torna isto seguro: só vira órfã o nome que ESTA
437
+ * instalação sabe produzir. O que a órfã ganha aqui, além da remoção, é entrar no registro quando não
438
+ * for removida — a partir da próxima execução ela é conhecida mesmo que o código-fonte dela saia do
439
+ * disco.
440
+ */
441
+ const running = await projectFunctions();
442
+ const stray = straysInProject({
443
+ // A região é o filtro, e o ESTADO não é. Uma cópia em região abandonada tem endereço próprio
444
+ // (`clearRegionOrphans`); já o estado só decide se dá para comparar carimbo — para existir no
445
+ // projeto, basta estar na listagem. E o `functions:list` nem sempre traz o campo: exigir `ACTIVE`
446
+ // deixaria a varredura cega em silêncio no dia em que ele viesse vazio.
447
+ running: (running ?? []).filter((entry) => entry.region === region).map((entry) => entry.name),
448
+ known: [...manifest.functions, ...office.all],
449
+ justified: [
450
+ ...wantedFunctions,
451
+ // A baseline do PACOTE, com o mesmo fallback que o `deploy` usa para decidir o que subir. Um
452
+ // `?? []` aqui faria toda function da plataforma virar órfã num pacote sem o campo.
453
+ ...(manifest.baselineFunctions ?? manifest.functions),
454
+ ...office.unserved,
455
+ ...unresolved.keys(),
456
+ ],
457
+ recorded: applied.map((entry) => entry.name),
458
+ });
459
+ reportStrays(stray, running);
405
460
  reportPreserved(classification.preserved);
406
- outcome.orphans = classification.orphans.map((entry) => entry.name);
407
- const remaining = await settleOrphans(options, classification.orphans, survey);
461
+ const orphans = [...classification.orphans, ...stray.orphans];
462
+ outcome.orphans = orphans.map((entry) => entry.name);
463
+ const remaining = await settleOrphans(options, orphans, survey);
408
464
  await recordBackendState(options, appliedAfterRun({
409
465
  applied,
410
466
  wanted: wantedFunctions,
@@ -557,6 +613,42 @@ const reportPreserved = (preserved) => {
557
613
  info(' Fix what is reported above and run "microteia provision" again.');
558
614
  }
559
615
  };
616
+ /**
617
+ * O que a varredura do projeto achou, e que o registro não conhecia.
618
+ *
619
+ * As duas listas são ditas porque respondem a perguntas diferentes de quem lê. A órfã é uma decisão a
620
+ * tomar — ela vai para `settleOrphans` logo abaixo, e sai com `--prune`. O estranho não é decisão
621
+ * nenhuma: é um aviso de que o projeto Firebase tem inquilino que a Microteia não pôs ali, e dizer que
622
+ * nada o toca vale mais do que o silêncio, porque o silêncio aqui parece "não há mais nada".
623
+ */
624
+ const reportStrays = (stray, running) => {
625
+ if (running === null) {
626
+ blank();
627
+ warn('could not list what this project is running, so nothing was compared against it.');
628
+ info(' a function left behind by an extension that is gone would not be found this run. The');
629
+ info(' account needs permission to run "firebase functions:list".');
630
+ return;
631
+ }
632
+ if (stray.orphans.length > 0) {
633
+ blank();
634
+ info(` ${stray.orphans.length} function(s) are running in this project and this installation's`);
635
+ info(' record does not mention them:');
636
+ bullets(stray.orphans.map((entry) => entry.name));
637
+ info(' this installation is what produces them, and nothing installed needs them — so they are');
638
+ info(' treated as orphans below. A record is lost by a re-init, a restored backup, or a Console');
639
+ info(' older than the field; the function it forgot stays in the air either way.');
640
+ }
641
+ if (stray.strangers.length > 0) {
642
+ blank();
643
+ info(` ${stray.strangers.length} function(s) run in this project and did not come from this`);
644
+ info(' installation:');
645
+ bullets(stray.strangers);
646
+ info(' nothing here touches them — not even --prune. A Firebase project can be shared, and');
647
+ info(' deleting what Microteia did not put there is not this command\'s call.');
648
+ info(' A function of yours lands here once its source is gone from this installation: run');
649
+ info(' "microteia provision" BEFORE deleting extensions/<folder>/, and it is recognized.');
650
+ }
651
+ };
560
652
  /**
561
653
  * Function que nenhuma extensão instalada exige mais.
562
654
  *
@@ -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.18.0";
90
+ export declare const CLI_VERSION = "2.20.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.18.0';
97
+ export const CLI_VERSION = '2.20.0';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@microteia/console",
3
- "version": "2.18.0",
3
+ "version": "2.20.0",
4
4
  "description": "Install and update Microteia in a Firebase project you own.",
5
5
  "license": "UNLICENSED",
6
6
  "type": "module",