@microteia/console 2.36.0 → 2.38.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,19 @@
22
22
  - fix(command): what was broken, and what it did to whoever hit it.
23
23
  -->
24
24
 
25
+ #### [2.38.0] — 2026-11-05
26
+
27
+ - feat(provision): deploys the Cloud Functions source an extension ships in its package, after the project operator approves that exact SHA-256. The Console recomputes the hash over what it writes (mismatch or a path outside the folder is refused), shows the files, the npm dependencies and the kind of each function, and asks; outside a terminal it holds the extension and prints `microteia provision --approve <id>@<hash>`. Approved source goes to `.microteia/packaged-functions/` and is read like any extension source (gate, duplicate names, dependencies); the copy to read before approving goes to `.microteia/packaged-review/` and is never mounted. A source already in the installation or the kernel wins over the packaged one.
28
+ - feat(provision): approval belongs to the project — the hash approved on any active copy counts, and the stamp propagates it with the approver (`gcloud` account) to every copy with the same fingerprint. A held extension stays refused, so `--prune` keeps the previous version's functions running. With different versions across organizations, the newest one is deployed. `deploy` passes `--approve` through.
29
+
30
+ #### [2.37.0] — 2026-11-05
31
+
32
+ - feat(provision): records on each installed copy WHY an extension is still pending (`backendPending`), refused claims included — function source not found, claim refused (platform or shared name, misgated trigger), gate missing, deploy failed — with the functions involved, so the Extensions screen can say it. The stamp now writes `backendState` and clears `backendPending` in one PATCH, so the screen never shows an extension as running and as refused at once. A copy already stamped is left alone.
33
+
34
+ #### [2.36.1] — 2026-11-05
35
+
36
+ - fix(publish): resuming an interrupted kernel publish now takes the release out of draft (`gh release edit --draft=false`) before writing the catalog. `gh release create` with assets creates a draft, uploads, then publishes; a GitHub 502 in between left the draft, and the resume only re-uploaded the assets — so `kernel/index.json` promoted to `latest` a tarball that only the publisher could download. On a release already published the edit changes nothing.
37
+
25
38
  #### [2.36.0] — 2026-11-05
26
39
 
27
40
  - fix(environment): every command that acts on an installation (`readEnvironment`) refuses when the installation's `CONSOLE-VERSIONS.md` names a Console newer than the one running. An older Console rewrites `microteia.yaml` with only the sections it knows and replaces `CONSOLE-VERSIONS.md` and `MICROTEIA.md` with its own texts — nothing failed: a forgotten copy of Console 2.6.0, left in a folder of the installation, ran `unpack` there, the `login:` block disappeared and the Console history lost thirty versions without a word. No new field marks it, on purpose: an older Console would drop the field it does not know.
package/README.md CHANGED
@@ -1,4 +1,4 @@
1
- # Microteia Console 2.36.0
1
+ # Microteia Console 2.38.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.
@@ -0,0 +1,36 @@
1
+ import type { BackendPendingReason, PluginBackendRequirement } from '../kernel/backendShape.js';
2
+ export interface PendingCause {
3
+ reason: BackendPendingReason;
4
+ functions: string[];
5
+ }
6
+ /** O que uma execução do `provision` sabe sobre as functions — a mesma conta que ele já faz. */
7
+ export interface PendingInputs {
8
+ /**
9
+ * As extensões cuja fonte EMPACOTADA não subiu nesta execução: aguardando aprovação, ou recusada por
10
+ * não conferir. Vem antes de tudo — sem ela, a fonte "não existe" para o resto da conta, e o motivo
11
+ * mandaria procurar a pasta que o autor nunca precisou criar.
12
+ */
13
+ held?: ReadonlyMap<string, BackendPendingReason>;
14
+ /** O que existe para ser deployado: o pacote do kernel mais as fontes da instalação. */
15
+ deployable: ReadonlySet<string>;
16
+ /** Nome -> a extensão cuja reivindicação foi recusada por ele (plataforma, comum, portão trocado). */
17
+ refusedClaims: ReadonlyMap<string, string>;
18
+ /** As que estavam na fila e saíram dela por falta do portão. */
19
+ ungated: ReadonlySet<string>;
20
+ /** O que está no ar depois desta execução: aplicado antes, subido agora ou pulado por estar igual. */
21
+ satisfied: ReadonlySet<string>;
22
+ }
23
+ /**
24
+ * Por que ESTA extensão continua pendente, na ordem em que um autor teria de resolver.
25
+ *
26
+ * A ordem importa porque as causas se encadeiam: sem fonte não há portão a conferir, e sem portão não
27
+ * há deploy que possa falhar. Relatar a última em vez da primeira mandaria corrigir algo que nem chegou
28
+ * a ser tentado.
29
+ *
30
+ * Nulo quando nada falta — inclusive quando a extensão não exige function nenhuma: índice que não
31
+ * converge derruba o comando inteiro, e parâmetro vazio não impede o carimbo.
32
+ */
33
+ export declare const pendingCauseOf: (claim: {
34
+ pluginId: string;
35
+ backend: PluginBackendRequirement;
36
+ }, inputs: PendingInputs) => PendingCause | null;
@@ -0,0 +1,30 @@
1
+ /**
2
+ * Por que ESTA extensão continua pendente, na ordem em que um autor teria de resolver.
3
+ *
4
+ * A ordem importa porque as causas se encadeiam: sem fonte não há portão a conferir, e sem portão não
5
+ * há deploy que possa falhar. Relatar a última em vez da primeira mandaria corrigir algo que nem chegou
6
+ * a ser tentado.
7
+ *
8
+ * Nulo quando nada falta — inclusive quando a extensão não exige function nenhuma: índice que não
9
+ * converge derruba o comando inteiro, e parâmetro vazio não impede o carimbo.
10
+ */
11
+ export const pendingCauseOf = (claim, inputs) => {
12
+ const names = claim.backend.functions ?? [];
13
+ const pick = (test) => names.filter(test);
14
+ const held = inputs.held?.get(claim.pluginId);
15
+ if (held !== undefined && names.length > 0)
16
+ return { reason: held, functions: [...names] };
17
+ const missing = pick((name) => !inputs.deployable.has(name));
18
+ if (missing.length > 0)
19
+ return { reason: 'function-source-missing', functions: missing };
20
+ const refused = pick((name) => inputs.refusedClaims.get(name) === claim.pluginId);
21
+ if (refused.length > 0)
22
+ return { reason: 'function-claim-refused', functions: refused };
23
+ const ungated = pick((name) => inputs.ungated.has(name));
24
+ if (ungated.length > 0)
25
+ return { reason: 'gate-missing', functions: ungated };
26
+ const failed = pick((name) => !inputs.satisfied.has(name));
27
+ if (failed.length > 0)
28
+ return { reason: 'deploy-failed', functions: failed };
29
+ return null;
30
+ };
@@ -73,6 +73,7 @@ export interface FunctionSource {
73
73
  * `export const x = onCall(...)` de quem declara no lugar, e o `export { a, b }` de quem reexporta.
74
74
  */
75
75
  export declare const exportedNames: (source: string) => string[];
76
+ export declare const readFunctionSource: (path: string, label: string, slug: string, shared: boolean) => Promise<FunctionSource>;
76
77
  /** Onde o escritório pode ter escrito function, na ordem em que as mensagens as citam. */
77
78
  export declare const findFunctionSources: (root: string) => Promise<FunctionSource[]>;
78
79
  /**
@@ -2,6 +2,7 @@ import { access, cp, mkdir, readFile, readdir, rm, writeFile } from 'node:fs/pro
2
2
  import { basename, join } from 'node:path';
3
3
  import { callableFunctionNames, declaredParametersIn } from '../kernel/manifest.js';
4
4
  import { callableDeclarations, triggerDeclarations, unscopedDeclarations, } from './functionGate.js';
5
+ import { packagedFunctionsPath, packagedLabel } from './packagedPaths.js';
5
6
  /**
6
7
  * As Cloud Functions do ESCRITÓRIO — as que ele escreve, não as do kernel.
7
8
  *
@@ -107,7 +108,7 @@ const readServes = async (path, label) => {
107
108
  }
108
109
  return serves;
109
110
  };
110
- const readSource = async (path, label, slug, shared) => {
111
+ export const readFunctionSource = async (path, label, slug, shared) => {
111
112
  const entry = join(path, 'index.ts');
112
113
  if (!await exists(entry)) {
113
114
  throw new Error(`${label}/ has no index.ts.\n`
@@ -160,7 +161,7 @@ const readSource = async (path, label, slug, shared) => {
160
161
  export const findFunctionSources = async (root) => {
161
162
  const found = [];
162
163
  if (await exists(join(root, FUNCTIONS_DIR))) {
163
- found.push(await readSource(join(root, FUNCTIONS_DIR), FUNCTIONS_DIR, 'shared', true));
164
+ found.push(await readFunctionSource(join(root, FUNCTIONS_DIR), FUNCTIONS_DIR, 'shared', true));
164
165
  }
165
166
  const extensions = join(root, EXTENSIONS_DIR);
166
167
  if (await exists(extensions)) {
@@ -170,7 +171,18 @@ export const findFunctionSources = async (root) => {
170
171
  const path = join(extensions, entry.name, FUNCTIONS_DIR);
171
172
  if (!await exists(path))
172
173
  continue;
173
- found.push(await readSource(path, `${EXTENSIONS_DIR}/${entry.name}/${FUNCTIONS_DIR}`, entry.name, false));
174
+ found.push(await readFunctionSource(path, `${EXTENSIONS_DIR}/${entry.name}/${FUNCTIONS_DIR}`, entry.name, false));
175
+ }
176
+ }
177
+ // A fonte que veio DENTRO do pacote de uma extensão, extraída pelo `provision` — só a que foi aprovada
178
+ // chega aqui. Entra como qualquer outra fonte de extensão: o portão, os nomes repetidos e as
179
+ // dependências são conferidos pelas mesmas funções, e é isso que dispensa uma segunda régua.
180
+ const packaged = packagedFunctionsPath(root);
181
+ if (await exists(packaged)) {
182
+ for (const entry of (await readdir(packaged, { withFileTypes: true })).sort((a, b) => a.name.localeCompare(b.name))) {
183
+ if (!entry.isDirectory())
184
+ continue;
185
+ found.push(await readFunctionSource(join(packaged, entry.name), packagedLabel(entry.name), entry.name, false));
174
186
  }
175
187
  }
176
188
  return found;
@@ -1,6 +1,7 @@
1
1
  import { writeFile } from 'node:fs/promises';
2
2
  import { dirname, join, resolve } from 'node:path';
3
3
  import { flagValue, hasFlag, requiredFlag } from '../shared/args.js';
4
+ import { parseApprovals } from './packagedSources.js';
4
5
  import { run } from '../shared/run.js';
5
6
  import { blank, bullets, failure, heading, info, step, success, warn } from '../shared/ui.js';
6
7
  import { ENVIRONMENT_FILE, readEnvironment, writeEnvironment } from './environment.js';
@@ -305,6 +306,8 @@ export const deploy = async (args) => {
305
306
  refreshedStamps,
306
307
  environmentFile: file,
307
308
  environment: applied,
309
+ approvals: parseApprovals(flagValue(args, 'approve')),
310
+ interactive: process.stdin.isTTY === true,
308
311
  });
309
312
  if (provisioned.failed.length > 0) {
310
313
  failures.push(`${provisioned.failed.length} extension function(s): ${provisioned.failed.join(', ')}`);
@@ -3,6 +3,12 @@ export interface FirestoreDocument {
3
3
  fields: Record<string, unknown>;
4
4
  }
5
5
  export declare const accessToken: () => Promise<string>;
6
+ /**
7
+ * A conta do `gcloud` que está agindo — é quem fica registrado como tendo aprovado o código de uma
8
+ * extensão. Vazio vira "unknown" em vez de recusa: a aprovação já aconteceu, e perdê-la por não saber
9
+ * dizer o nome seria pior que gravá-la sem ele.
10
+ */
11
+ export declare const activeAccount: () => Promise<string>;
6
12
  /** Um valor tipado da REST virando valor de JavaScript. Timestamp vira string ISO, que é o que basta. */
7
13
  export declare const decodeValue: (value: unknown) => unknown;
8
14
  export declare const decodeFields: (fields: Record<string, unknown>) => Record<string, unknown>;
@@ -30,6 +36,8 @@ export type FirestoreTypedValue = {
30
36
  mapValue: {
31
37
  fields: Record<string, FirestoreTypedValue>;
32
38
  };
39
+ } | {
40
+ nullValue: null;
33
41
  };
34
42
  export declare const asString: (value: string) => FirestoreTypedValue;
35
43
  /**
@@ -49,6 +57,8 @@ export declare const asMap: (fields: Record<string, FirestoreTypedValue>) => Fir
49
57
  * também o que permite ordenar e comparar o campo no banco. A REST exige RFC 3339 em UTC, que é
50
58
  * exatamente o que `toISOString` devolve.
51
59
  */
60
+ /** O campo continua no documento, valendo nulo — é o que o lado do kernel lê como "nada a dizer". */
61
+ export declare const asNull: () => FirestoreTypedValue;
52
62
  export declare const asTimestamp: (value: Date) => FirestoreTypedValue;
53
63
  /**
54
64
  * Grava UM campo de mapa, com `updateMask` — o resto do documento não é tocado. É o que permite o
@@ -27,6 +27,20 @@ export const accessToken = async () => {
27
27
  throw new Error('gcloud returned an empty access token. Run: gcloud auth login');
28
28
  return cachedToken;
29
29
  };
30
+ /**
31
+ * A conta do `gcloud` que está agindo — é quem fica registrado como tendo aprovado o código de uma
32
+ * extensão. Vazio vira "unknown" em vez de recusa: a aprovação já aconteceu, e perdê-la por não saber
33
+ * dizer o nome seria pior que gravá-la sem ele.
34
+ */
35
+ export const activeAccount = async () => {
36
+ try {
37
+ const account = (await captureOrThrow(GCLOUD_BIN, ['config', 'get-value', 'account'])).trim();
38
+ return account === '' || account === '(unset)' ? 'unknown' : account;
39
+ }
40
+ catch {
41
+ return 'unknown';
42
+ }
43
+ };
30
44
  /**
31
45
  * `X-Goog-User-Project` diz de qual projeto é a cota. Sem ele, uma credencial de usuário é recusada
32
46
  * com uma mensagem sobre "quota project" que não menciona o que fazer.
@@ -125,6 +139,8 @@ export const asMap = (fields) => ({ mapValue: { fields } });
125
139
  * também o que permite ordenar e comparar o campo no banco. A REST exige RFC 3339 em UTC, que é
126
140
  * exatamente o que `toISOString` devolve.
127
141
  */
142
+ /** O campo continua no documento, valendo nulo — é o que o lado do kernel lê como "nada a dizer". */
143
+ export const asNull = () => ({ nullValue: null });
128
144
  export const asTimestamp = (value) => ({ timestampValue: value.toISOString() });
129
145
  /**
130
146
  * Grava UM campo de mapa, com `updateMask` — o resto do documento não é tocado. É o que permite o
@@ -0,0 +1,27 @@
1
+ import type { PackagedClaim } from './packagedSources.js';
2
+ export type HeldReason = 'awaiting-approval' | 'source-invalid';
3
+ export interface PackagedSettlement {
4
+ /** As que não sobem nesta execução, e por quê — vira o motivo que a tela da extensão mostra. */
5
+ held: Map<string, HeldReason>;
6
+ /** Quem aprovou, para as aprovadas AGORA. As já aprovadas antes guardam o aprovador que tinham. */
7
+ approvedNow: Map<string, string>;
8
+ /** Quantas fontes foram extraídas para `.microteia/packaged-functions/`. */
9
+ extracted: number;
10
+ }
11
+ /**
12
+ * Decide, extrai e — quando ninguém respondeu antes — pergunta. Só a fonte APROVADA fica no disco
13
+ * quando esta função devolve, e é por isso que o resto do `provision` pode lê-la como qualquer outra:
14
+ * `findFunctionSources` só encontra o que passou por aqui.
15
+ *
16
+ * Fora de um terminal não há pergunta: a extensão fica pendente e o comando diz a linha exata que a
17
+ * aprova. Um `--yes` genérico foi recusado de propósito — ele aprovaria também a versão que chegar
18
+ * amanhã, sem ninguém ter lido.
19
+ */
20
+ export declare const settlePackagedSources: (input: {
21
+ root: string;
22
+ projectId: string;
23
+ claims: PackagedClaim[];
24
+ provided: ReadonlySet<string>;
25
+ flags: ReadonlyMap<string, string>;
26
+ interactive: boolean;
27
+ }) => Promise<PackagedSettlement>;
@@ -0,0 +1,109 @@
1
+ import { relative } from 'node:path';
2
+ import { createInterface } from 'node:readline/promises';
3
+ import { blank, bullets, failure, info, success, warn } from '../shared/ui.js';
4
+ import { readFunctionSource } from './customFunctions.js';
5
+ import { activeAccount } from './firestoreRest.js';
6
+ import { packagedLabel, packagedSlug } from './packagedPaths.js';
7
+ import { approvalFlag, declaredDependencies, decideWithoutAsking, writePackagedSource, } from './packagedSources.js';
8
+ const kb = (text) => `${Math.max(1, Math.round(Buffer.byteLength(text, 'utf8') / 1024))} KB`;
9
+ /**
10
+ * O que quem aprova precisa ver ANTES de dizer sim — e é a lista inteira, não um resumo: o que vai ao
11
+ * ar, com que dependência, e de que tipo é cada function. O tipo importa porque muda o alcance: uma
12
+ * callable responde a quem a chama; um gatilho age sozinho sobre os dados; uma agendada age sobre todos
13
+ * os escritórios do projeto.
14
+ */
15
+ const describe = async (root, projectId, claim, path) => {
16
+ const slug = packagedSlug(claim.pluginId);
17
+ const source = await readFunctionSource(path, packagedLabel(slug), slug, false);
18
+ const kindOf = (name) => {
19
+ if (source.callables.includes(name))
20
+ return 'callable — answers whoever calls it';
21
+ const trigger = source.triggers.find((entry) => entry.name === name);
22
+ if (trigger !== undefined)
23
+ return `${trigger.trigger} — acts on its own when data changes`;
24
+ const unscoped = source.unscoped.find((entry) => entry.name === name);
25
+ if (unscoped !== undefined)
26
+ return `${unscoped.trigger} — acts on its own, for every organization`;
27
+ return 'not exported by its index.ts';
28
+ };
29
+ const dependencies = Object.entries(declaredDependencies(claim.source));
30
+ blank();
31
+ warn(`${claim.name} ${claim.version} (${claim.pluginId}) brings the source of its own Cloud Functions.`);
32
+ info(` They run on the server with full access to every organization in ${projectId}.`);
33
+ info(` sha-256: ${claim.source.hash}`);
34
+ info(` read it in: ${relative(root, path)}/`);
35
+ info(' files:');
36
+ bullets(Object.keys(claim.source.files).sort().map((file) => `${file} (${kb(claim.source.files[file] ?? '')})`));
37
+ info(' npm dependencies it adds:');
38
+ bullets(dependencies.length === 0 ? ['none beyond the platform\'s own'] : dependencies.map(([name, range]) => `${name} ${range}`));
39
+ info(' functions:');
40
+ bullets(claim.functions.map((name) => `${name} — ${kindOf(name)}`));
41
+ };
42
+ const ask = async (question) => {
43
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
44
+ try {
45
+ return /^(y|yes)$/i.test((await rl.question(question)).trim());
46
+ }
47
+ finally {
48
+ rl.close();
49
+ }
50
+ };
51
+ /**
52
+ * Decide, extrai e — quando ninguém respondeu antes — pergunta. Só a fonte APROVADA fica no disco
53
+ * quando esta função devolve, e é por isso que o resto do `provision` pode lê-la como qualquer outra:
54
+ * `findFunctionSources` só encontra o que passou por aqui.
55
+ *
56
+ * Fora de um terminal não há pergunta: a extensão fica pendente e o comando diz a linha exata que a
57
+ * aprova. Um `--yes` genérico foi recusado de propósito — ele aprovaria também a versão que chegar
58
+ * amanhã, sem ninguém ter lido.
59
+ */
60
+ export const settlePackagedSources = async (input) => {
61
+ const settlement = { held: new Map(), approvedNow: new Map(), extracted: 0 };
62
+ let account;
63
+ const approver = async () => {
64
+ account ??= await activeAccount();
65
+ return account;
66
+ };
67
+ for (const claim of input.claims) {
68
+ const decision = decideWithoutAsking(claim, input.provided, input.flags);
69
+ if (decision?.kind === 'provided') {
70
+ info(` ${claim.name}: using the source in this installation, not the copy in its package`);
71
+ continue;
72
+ }
73
+ if (decision?.kind === 'invalid') {
74
+ failure(`${claim.name}: the server code in its package cannot be used —`);
75
+ bullets(decision.problems);
76
+ info(' Install the extension again from a file its author built.');
77
+ settlement.held.set(claim.pluginId, 'source-invalid');
78
+ continue;
79
+ }
80
+ if (decision?.kind === 'approved') {
81
+ await writePackagedSource(input.root, claim);
82
+ settlement.extracted += 1;
83
+ if (decision.how === 'flag') {
84
+ settlement.approvedNow.set(claim.pluginId, await approver());
85
+ success(`${claim.name}: server code approved by --approve`);
86
+ }
87
+ continue;
88
+ }
89
+ if (decision?.kind === 'held') {
90
+ warn(`${claim.name}: the hash given to --approve is not the one in its package.`);
91
+ info(` the package carries ${claim.source.hash} — read it before approving that one.`);
92
+ settlement.held.set(claim.pluginId, 'awaiting-approval');
93
+ continue;
94
+ }
95
+ const review = await writePackagedSource(input.root, claim, 'review');
96
+ await describe(input.root, input.projectId, claim, review);
97
+ if (input.interactive && await ask(`Approve this exact code for ${input.projectId}? [y/N] `)) {
98
+ await writePackagedSource(input.root, claim);
99
+ settlement.extracted += 1;
100
+ settlement.approvedNow.set(claim.pluginId, await approver());
101
+ success(`${claim.name}: server code approved`);
102
+ continue;
103
+ }
104
+ settlement.held.set(claim.pluginId, 'awaiting-approval');
105
+ info(' Not approved — the extension stays pending. To approve it without a prompt:');
106
+ bullets([`microteia provision ${approvalFlag(claim.pluginId, claim.source.hash)}`]);
107
+ }
108
+ return settlement;
109
+ };
@@ -0,0 +1,23 @@
1
+ /**
2
+ * Onde o Console extrai a fonte das Cloud Functions que veio DENTRO do pacote de uma extensão.
3
+ *
4
+ * Fica em `.microteia/` de propósito, e é a exceção que confirma a regra daquele diretório: nada ali é
5
+ * digitado por ninguém. É derivado do banco — o pacote instalado está em `installed_extensions` —, então
6
+ * o `unpack` apagar tudo não perde nada: o próximo `provision` extrai de novo. O que o autor escreve
7
+ * continua em `extensions/<pasta>/functions/`, e o Console nunca escreve lá.
8
+ */
9
+ export declare const PACKAGED_FUNCTIONS_DIR = "packaged-functions";
10
+ export declare const packagedFunctionsPath: (root: string) => string;
11
+ /**
12
+ * Onde fica a cópia para LEITURA da fonte que ainda não foi aprovada. Separada de propósito: o que está
13
+ * em `packaged-functions/` é montado e sobe; o que está aqui só é lido por quem vai decidir. Uma pasta
14
+ * só obrigaria a apagar o código justamente quando alguém precisa lê-lo para aprovar com `--approve`.
15
+ */
16
+ export declare const PACKAGED_REVIEW_DIR = "packaged-review";
17
+ export declare const packagedReviewPath: (root: string) => string;
18
+ export declare const packagedLabel: (slug: string) => string;
19
+ /**
20
+ * O diretório da extensão. O prefixo impede colisão com `extensions/<pasta>`, que vai para o mesmo
21
+ * `custom/src/`: as duas origens viram irmãs ali dentro.
22
+ */
23
+ export declare const packagedSlug: (pluginId: string) => string;
@@ -0,0 +1,25 @@
1
+ import { join } from 'node:path';
2
+ import { WORKSPACE_DIR } from './workspace.js';
3
+ /**
4
+ * Onde o Console extrai a fonte das Cloud Functions que veio DENTRO do pacote de uma extensão.
5
+ *
6
+ * Fica em `.microteia/` de propósito, e é a exceção que confirma a regra daquele diretório: nada ali é
7
+ * digitado por ninguém. É derivado do banco — o pacote instalado está em `installed_extensions` —, então
8
+ * o `unpack` apagar tudo não perde nada: o próximo `provision` extrai de novo. O que o autor escreve
9
+ * continua em `extensions/<pasta>/functions/`, e o Console nunca escreve lá.
10
+ */
11
+ export const PACKAGED_FUNCTIONS_DIR = 'packaged-functions';
12
+ export const packagedFunctionsPath = (root) => join(root, WORKSPACE_DIR, PACKAGED_FUNCTIONS_DIR);
13
+ /**
14
+ * Onde fica a cópia para LEITURA da fonte que ainda não foi aprovada. Separada de propósito: o que está
15
+ * em `packaged-functions/` é montado e sobe; o que está aqui só é lido por quem vai decidir. Uma pasta
16
+ * só obrigaria a apagar o código justamente quando alguém precisa lê-lo para aprovar com `--approve`.
17
+ */
18
+ export const PACKAGED_REVIEW_DIR = 'packaged-review';
19
+ export const packagedReviewPath = (root) => join(root, WORKSPACE_DIR, PACKAGED_REVIEW_DIR);
20
+ export const packagedLabel = (slug) => `${WORKSPACE_DIR}/${PACKAGED_FUNCTIONS_DIR}/${slug}`;
21
+ /**
22
+ * O diretório da extensão. O prefixo impede colisão com `extensions/<pasta>`, que vai para o mesmo
23
+ * `custom/src/`: as duas origens viram irmãs ali dentro.
24
+ */
25
+ export const packagedSlug = (pluginId) => `package-${pluginId.replace(/[^a-zA-Z0-9]+/g, '-')}`;
@@ -0,0 +1,72 @@
1
+ /**
2
+ * A fonte das Cloud Functions que viaja no pacote — redeclarada do lado do Console pelo mesmo motivo de
3
+ * `backendShape.ts`: ele é publicado sem o `src/`. `packagedSources.test.ts` confere a serialização e a
4
+ * regra de caminho contra o kernel; separadas em silêncio, o hash que o build grava nunca conferiria
5
+ * aqui, e toda extensão com fonte seria recusada como adulterada.
6
+ */
7
+ export interface PackagedSource {
8
+ files: Record<string, string>;
9
+ hash: string;
10
+ }
11
+ export declare const canonicalSource: (files: Record<string, string>) => string;
12
+ export declare const hashSource: (files: Record<string, string>) => string;
13
+ export declare const isSourcePath: (path: string) => boolean;
14
+ /**
15
+ * A fonte como ela está gravada no pacote instalado. Leniente na LEITURA — a forma é cobrada por
16
+ * `sourceProblems` —, porque o que veio do banco é dado, e um dado torto tem de virar recusa explicada,
17
+ * não exceção.
18
+ */
19
+ export declare const packagedSourceOf: (pkg: unknown) => PackagedSource | undefined;
20
+ /**
21
+ * O que impede a fonte de ir ao disco. A conferência é feita AQUI, e não só na importação: o pacote
22
+ * chega ao Console pelo banco, e quem tem `extensions.manage` grava o documento instalado diretamente.
23
+ * O hash é recalculado sobre o conteúdo que vai ser gravado — é ele que se aprova.
24
+ */
25
+ export declare const sourceProblems: (source: PackagedSource) => string[];
26
+ /** `--approve id@hash,id@hash`. O hash INTEIRO: aprovar por prefixo é aprovar o que não se leu. */
27
+ export declare const parseApprovals: (value: string | undefined) => Map<string, string>;
28
+ export declare const approvalFlag: (pluginId: string, hash: string) => string;
29
+ /** Uma extensão cuja fonte veio no pacote, com o que o projeto já aprovou dela. */
30
+ export interface PackagedClaim {
31
+ pluginId: string;
32
+ name: string;
33
+ version: string;
34
+ functions: string[];
35
+ source: PackagedSource;
36
+ /** Os hashes aprovados em QUALQUER cópia instalada no projeto — a aprovação é do projeto. */
37
+ approvedHashes: ReadonlySet<string>;
38
+ }
39
+ export type PackagedDecision = {
40
+ kind: 'provided';
41
+ } | {
42
+ kind: 'invalid';
43
+ problems: string[];
44
+ } | {
45
+ kind: 'held';
46
+ flagMismatch: boolean;
47
+ } | {
48
+ kind: 'approved';
49
+ how: 'recorded' | 'flag' | 'prompt';
50
+ };
51
+ /**
52
+ * A decisão que não depende de perguntar a ninguém. `null` é "pergunte": só então a revisão humana
53
+ * entra, e só para o que nenhuma outra resposta cobre.
54
+ *
55
+ * `provided` vem antes de tudo: se o pacote do kernel ou a instalação (`extensions/<pasta>/functions/`)
56
+ * já exporta um nome que a extensão reivindica, a fonte local vale — é onde o autor desenvolve, e é o
57
+ * caso da instalação que é a casa da fonte. As duas juntas seriam o mesmo nome exportado duas vezes,
58
+ * que a montagem recusa.
59
+ */
60
+ export declare const decideWithoutAsking: (claim: PackagedClaim, provided: ReadonlySet<string>, flags: ReadonlyMap<string, string>) => PackagedDecision | null;
61
+ /**
62
+ * Grava a fonte no diretório da extensão, dentro de `.microteia/packaged-functions/`.
63
+ *
64
+ * Cada caminho é conferido DE NOVO contra o diretório de destino depois de resolvido. `sourceProblems`
65
+ * já recusou `..` e caminho absoluto, mas é esta a linha que escreve no disco, e a regra dela não pode
66
+ * depender de alguém lembrar de chamar a outra antes.
67
+ */
68
+ export declare const writePackagedSource: (root: string, claim: PackagedClaim, purpose?: "deploy" | "review") => Promise<string>;
69
+ /** As duas pastas recomeçam a cada execução: o que vale é o que está instalado AGORA. */
70
+ export declare const clearPackagedSources: (root: string) => Promise<void>;
71
+ /** As dependências npm que a fonte declara — o que a aprovação precisa mostrar além do código. */
72
+ export declare const declaredDependencies: (source: PackagedSource) => Record<string, string>;
@@ -0,0 +1,135 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { mkdir, rm, writeFile } from 'node:fs/promises';
3
+ import { dirname, join, resolve, sep } from 'node:path';
4
+ import { packagedFunctionsPath, packagedReviewPath, packagedSlug } from './packagedPaths.js';
5
+ const SEPARATOR = '\u0000';
6
+ export const canonicalSource = (files) => Object.keys(files)
7
+ .sort()
8
+ .map((path) => `${path}${SEPARATOR}${files[path]}`)
9
+ .join(SEPARATOR);
10
+ export const hashSource = (files) => createHash('sha256').update(canonicalSource(files), 'utf8').digest('hex');
11
+ const TEST_FILE = /\.(test|spec)\.ts$/;
12
+ export const isSourcePath = (path) => {
13
+ if (path === '' || path.startsWith('/') || path.includes('\\'))
14
+ return false;
15
+ const segments = path.split('/');
16
+ if (segments.some((segment) => segment === '' || segment === '.' || segment === '..'))
17
+ return false;
18
+ if (segments.some((segment) => segment.startsWith('.') || segment === 'node_modules' || segment === '__tests__')) {
19
+ return false;
20
+ }
21
+ const name = segments.at(-1) ?? '';
22
+ if (TEST_FILE.test(name))
23
+ return false;
24
+ return name.endsWith('.ts') || name.endsWith('.json');
25
+ };
26
+ /**
27
+ * A fonte como ela está gravada no pacote instalado. Leniente na LEITURA — a forma é cobrada por
28
+ * `sourceProblems` —, porque o que veio do banco é dado, e um dado torto tem de virar recusa explicada,
29
+ * não exceção.
30
+ */
31
+ export const packagedSourceOf = (pkg) => {
32
+ const raw = pkg?.backendSource;
33
+ if (typeof raw !== 'object' || raw === null)
34
+ return undefined;
35
+ const { files, hash } = raw;
36
+ const entries = typeof files === 'object' && files !== null
37
+ ? Object.entries(files)
38
+ .filter((entry) => typeof entry[1] === 'string')
39
+ : [];
40
+ return { files: Object.fromEntries(entries), hash: typeof hash === 'string' ? hash : '' };
41
+ };
42
+ /**
43
+ * O que impede a fonte de ir ao disco. A conferência é feita AQUI, e não só na importação: o pacote
44
+ * chega ao Console pelo banco, e quem tem `extensions.manage` grava o documento instalado diretamente.
45
+ * O hash é recalculado sobre o conteúdo que vai ser gravado — é ele que se aprova.
46
+ */
47
+ export const sourceProblems = (source) => {
48
+ const problems = [];
49
+ const bad = Object.keys(source.files).filter((path) => !isSourcePath(path));
50
+ if (bad.length > 0)
51
+ problems.push(`it names files that cannot be written: ${bad.join(', ')}`);
52
+ if (source.files['index.ts'] === undefined)
53
+ problems.push('it has no index.ts');
54
+ if (hashSource(source.files) !== source.hash) {
55
+ problems.push('its checksum does not match its content — it was changed after it was built');
56
+ }
57
+ return problems;
58
+ };
59
+ /** `--approve id@hash,id@hash`. O hash INTEIRO: aprovar por prefixo é aprovar o que não se leu. */
60
+ export const parseApprovals = (value) => {
61
+ const approvals = new Map();
62
+ for (const part of (value ?? '').split(',').map((item) => item.trim()).filter(Boolean)) {
63
+ const at = part.lastIndexOf('@');
64
+ if (at <= 0 || at === part.length - 1) {
65
+ throw new Error(`--approve takes <extension id>@<sha-256>, and "${part}" is not that.`);
66
+ }
67
+ approvals.set(part.slice(0, at), part.slice(at + 1));
68
+ }
69
+ return approvals;
70
+ };
71
+ export const approvalFlag = (pluginId, hash) => `--approve ${pluginId}@${hash}`;
72
+ /**
73
+ * A decisão que não depende de perguntar a ninguém. `null` é "pergunte": só então a revisão humana
74
+ * entra, e só para o que nenhuma outra resposta cobre.
75
+ *
76
+ * `provided` vem antes de tudo: se o pacote do kernel ou a instalação (`extensions/<pasta>/functions/`)
77
+ * já exporta um nome que a extensão reivindica, a fonte local vale — é onde o autor desenvolve, e é o
78
+ * caso da instalação que é a casa da fonte. As duas juntas seriam o mesmo nome exportado duas vezes,
79
+ * que a montagem recusa.
80
+ */
81
+ export const decideWithoutAsking = (claim, provided, flags) => {
82
+ if (claim.functions.some((name) => provided.has(name)))
83
+ return { kind: 'provided' };
84
+ const problems = sourceProblems(claim.source);
85
+ if (problems.length > 0)
86
+ return { kind: 'invalid', problems };
87
+ if (claim.approvedHashes.has(claim.source.hash))
88
+ return { kind: 'approved', how: 'recorded' };
89
+ const flagged = flags.get(claim.pluginId);
90
+ if (flagged === claim.source.hash)
91
+ return { kind: 'approved', how: 'flag' };
92
+ if (flagged !== undefined)
93
+ return { kind: 'held', flagMismatch: true };
94
+ return null;
95
+ };
96
+ /**
97
+ * Grava a fonte no diretório da extensão, dentro de `.microteia/packaged-functions/`.
98
+ *
99
+ * Cada caminho é conferido DE NOVO contra o diretório de destino depois de resolvido. `sourceProblems`
100
+ * já recusou `..` e caminho absoluto, mas é esta a linha que escreve no disco, e a regra dela não pode
101
+ * depender de alguém lembrar de chamar a outra antes.
102
+ */
103
+ export const writePackagedSource = async (root, claim, purpose = 'deploy') => {
104
+ const base = purpose === 'deploy' ? packagedFunctionsPath(root) : packagedReviewPath(root);
105
+ const target = join(base, packagedSlug(claim.pluginId));
106
+ await rm(target, { recursive: true, force: true });
107
+ for (const [path, content] of Object.entries(claim.source.files)) {
108
+ const file = resolve(target, path);
109
+ if (!file.startsWith(`${resolve(target)}${sep}`)) {
110
+ throw new Error(`${claim.name}: refusing to write "${path}" outside its folder.`);
111
+ }
112
+ await mkdir(dirname(file), { recursive: true });
113
+ await writeFile(file, content);
114
+ }
115
+ return target;
116
+ };
117
+ /** As duas pastas recomeçam a cada execução: o que vale é o que está instalado AGORA. */
118
+ export const clearPackagedSources = async (root) => {
119
+ await rm(packagedFunctionsPath(root), { recursive: true, force: true });
120
+ await rm(packagedReviewPath(root), { recursive: true, force: true });
121
+ };
122
+ /** As dependências npm que a fonte declara — o que a aprovação precisa mostrar além do código. */
123
+ export const declaredDependencies = (source) => {
124
+ const manifest = source.files['package.json'];
125
+ if (manifest === undefined)
126
+ return {};
127
+ try {
128
+ const parsed = JSON.parse(manifest);
129
+ return Object.fromEntries(Object.entries(parsed.dependencies ?? {})
130
+ .filter((entry) => typeof entry[1] === 'string'));
131
+ }
132
+ catch {
133
+ return {};
134
+ }
135
+ };
@@ -2,11 +2,17 @@ import type { ParsedArgs } from '../shared/args.js';
2
2
  import type { KernelManifest } from '../kernel/manifest.js';
3
3
  import type { BackendClaim } from '../kernel/backendShape.js';
4
4
  import type { Environment } from './environment.js';
5
+ import type { PackagedSource } from './packagedSources.js';
5
6
  /** Uma exigência, o escritório que a instalou e o documento que carrega o estado dela. */
6
7
  export interface InstalledClaim extends BackendClaim {
7
8
  tenantId: string;
8
9
  documentPath: string;
9
10
  applied: boolean;
11
+ /** A fonte das functions que veio no pacote — ver `PackagedSource`. */
12
+ source?: PackagedSource;
13
+ /** O hash aprovado nesta cópia, e por quem. A aprovação é do projeto: vale a de qualquer cópia. */
14
+ approvedHash?: string;
15
+ approvedBy?: string;
10
16
  }
11
17
  /**
12
18
  * A varredura inteira, e não só o que ela achou.
@@ -62,6 +68,10 @@ export interface ProvisionOptions {
62
68
  refreshedStamps?: Map<string, string>;
63
69
  environmentFile: string;
64
70
  environment: Environment;
71
+ /** `--approve id@hash`: aprovação da fonte empacotada sem pergunta. Ver `settlePackagedSources`. */
72
+ approvals?: ReadonlyMap<string, string>;
73
+ /** Há um terminal para perguntar. Sem ele, o que não foi aprovado fica pendente. */
74
+ interactive?: boolean;
65
75
  }
66
76
  export interface ProvisionOutcome {
67
77
  deployed: string[];
@@ -14,7 +14,10 @@ import { deployFunctionsThatChanged } from './functionsUpToDate.js';
14
14
  import { clearRegionOrphans, listDeployedFunctions } from './regionOrphans.js';
15
15
  import { outdatedByStamp } from './functionsUpToDate.js';
16
16
  import { checkCallables, invokerGrantCommand } from './callableAccess.js';
17
- import { asString, asTimestamp, patchMapField } from './firestoreRest.js';
17
+ import { asArray, asMap, asNull, asString, asTimestamp, patchFields, patchMapField } from './firestoreRest.js';
18
+ import { pendingCauseOf } from './backendPending.js';
19
+ import { settlePackagedSources } from './packagedApproval.js';
20
+ import { clearPackagedSources, packagedSourceOf, parseApprovals } from './packagedSources.js';
18
21
  import { writeBackendResidue } from './backendResidue.js';
19
22
  import { listTenants, readInstalledExtensions } from './installedExtensions.js';
20
23
  import { gatherCustomFunctions, readOfficeFunctions } from './customFunctions.js';
@@ -42,6 +45,7 @@ export const surveyInstallations = async (projectId) => {
42
45
  if (!hasBackendRequirement(backend))
43
46
  continue;
44
47
  const state = extension.data.backendState;
48
+ const source = packagedSourceOf(extension.data.package);
45
49
  claims.push({
46
50
  tenantId: extension.tenantId,
47
51
  documentPath: extension.documentPath,
@@ -49,17 +53,32 @@ export const surveyInstallations = async (projectId) => {
49
53
  name: extension.name,
50
54
  version: extension.version,
51
55
  backend: backend,
52
- applied: state?.fingerprint === backendFingerprint(backend),
56
+ applied: state?.fingerprint === backendFingerprint(backend, source?.hash),
57
+ ...(source !== undefined ? { source } : {}),
58
+ ...(typeof state?.approvedHash === 'string' ? { approvedHash: state.approvedHash } : {}),
59
+ ...(typeof state?.approvedBy === 'string' ? { approvedBy: state.approvedBy } : {}),
53
60
  });
54
61
  }
55
62
  }
56
63
  return {
57
64
  tenants: tenants.length,
58
65
  extensions,
59
- claims: claims.sort((a, b) => (a.pluginId < b.pluginId ? -1 : a.pluginId > b.pluginId ? 1 : 0)),
66
+ // A versão mais nova primeiro, dentro de cada extensão: é ela que `uniqueByPlugin` escolhe quando
67
+ // dois escritórios do projeto têm versões diferentes — e a function é uma só, do projeto.
68
+ claims: claims.sort((a, b) => (a.pluginId < b.pluginId ? -1 : a.pluginId > b.pluginId ? 1 : compareVersions(b.version, a.version))),
60
69
  installedIds,
61
70
  };
62
71
  };
72
+ const compareVersions = (left, right) => {
73
+ const parts = (value) => value.split(/[.+-]/).slice(0, 3).map((part) => Number.parseInt(part, 10) || 0);
74
+ const [a, b] = [parts(left), parts(right)];
75
+ for (let index = 0; index < 3; index += 1) {
76
+ const difference = (a.at(index) ?? 0) - (b.at(index) ?? 0);
77
+ if (difference !== 0)
78
+ return difference;
79
+ }
80
+ return 0;
81
+ };
63
82
  const uniqueByPlugin = (claims) => {
64
83
  const seen = new Set();
65
84
  return claims.filter((claim) => {
@@ -87,11 +106,30 @@ export const provisionBackend = async (options) => {
87
106
  * pacote (nem poderia: o kernel não sabe o que o escritório vai escrever), e a extensão inteira era
88
107
  * rejeitada com "this kernel package does not contain".
89
108
  */
90
- const office = await readOfficeFunctions(root);
91
- const deployable = [...manifest.functions, ...office.all];
92
109
  const survey = await surveyInstallations(projectId);
93
110
  const claims = survey.claims;
94
- const missing = functionClaimProblems(claims, deployable);
111
+ /**
112
+ * A fonte que veio NO PACOTE é decidida antes de qualquer conta: o que for aprovado é extraído para
113
+ * `.microteia/packaged-functions/` e passa a ser lido como qualquer fonte da instalação. A leitura
114
+ * local vem antes porque é ela que decide a precedência — o que a instalação já escreve vale mais que
115
+ * a cópia empacotada.
116
+ */
117
+ await clearPackagedSources(root);
118
+ const local = await readOfficeFunctions(root);
119
+ const settlement = await settlePackagedSources({
120
+ root,
121
+ projectId,
122
+ claims: packagedClaimsOf(claims),
123
+ provided: new Set([...manifest.functions, ...local.all]),
124
+ flags: options.approvals ?? new Map(),
125
+ interactive: options.interactive === true,
126
+ });
127
+ const office = settlement.extracted > 0 ? await readOfficeFunctions(root) : local;
128
+ const deployable = [...manifest.functions, ...office.all];
129
+ // A retida não entra em "nowhere to be found": a fonte dela existe, só não foi aprovada — e a
130
+ // explicação certa já foi dada acima. Ela continua RECUSADA (logo abaixo), e é isso que protege do
131
+ // `--prune` a function de uma versão anterior que ainda está no ar.
132
+ const missing = functionClaimProblems(claims.filter((claim) => !settlement.held.has(claim.pluginId)), deployable);
95
133
  const overlapping = baselineClaimProblems(claims, manifest.baselineFunctions ?? []);
96
134
  // A comum do escritório tem o MESMO desfecho da function da plataforma, e por isso a mesma recusa: o
97
135
  // `deploy` a sobe sempre, então reivindicá-la não muda nada — até o dia em que `--prune` a apaga.
@@ -195,7 +233,24 @@ export const provisionBackend = async (options) => {
195
233
  for (const entry of misgated)
196
234
  rejected.add(entry.owner);
197
235
  }
236
+ for (const pluginId of settlement.held.keys())
237
+ rejected.add(pluginId);
198
238
  const valid = uniqueByPlugin(claims.filter((claim) => !rejected.has(claim.pluginId)));
239
+ /**
240
+ * Qual nome derrubou qual reivindicação — é o que vai para a tela da extensão no fim. As mensagens
241
+ * acima já dizem isto em prosa; o mapa existe porque a tela é lida por quem não tem o terminal.
242
+ */
243
+ const refusedClaims = new Map();
244
+ const baselineNames = new Set(manifest.baselineFunctions ?? []);
245
+ const sharedNames = new Set(office.shared);
246
+ for (const claim of claims) {
247
+ for (const name of claim.backend.functions ?? []) {
248
+ if (baselineNames.has(name) || sharedNames.has(name))
249
+ refusedClaims.set(name, claim.pluginId);
250
+ }
251
+ }
252
+ for (const entry of misgated)
253
+ refusedClaims.set(entry.name, entry.owner);
199
254
  /**
200
255
  * O nome que uma reivindicação RECUSADA carrega. Ele não entra em `wantedFunctions` — não há como
201
256
  * deployá-lo —, mas também não pode virar órfão: a extensão que o pede continua instalada, e o que
@@ -450,14 +505,30 @@ export const provisionBackend = async (options) => {
450
505
  // o mesmo que estar satisfeito. Deixá-lo de fora manteria a extensão "pendente" para sempre numa
451
506
  // instalação em que não falta nada.
452
507
  const deployedNow = new Set([...appliedNames, ...outcome.deployed, ...skippedNow]);
453
- for (const claim of valid) {
454
- const satisfied = (claim.backend.functions ?? []).every((name) => deployedNow.has(name));
455
- if (satisfied) {
456
- await stampApplied(projectId, claims, claim);
508
+ const validIds = new Set(valid.map((claim) => claim.pluginId));
509
+ const pendingInputs = {
510
+ held: settlement.held,
511
+ deployable: new Set(deployable),
512
+ refusedClaims,
513
+ ungated: new Set(refused),
514
+ satisfied: deployedNow,
515
+ };
516
+ // As RECUSADAS entram aqui também: nunca foram carimbadas, e eram justamente as que a tela mandava
517
+ // "rodar o provision" sem dizer que ele já tinha rodado e recusado.
518
+ for (const claim of uniqueByPlugin(claims)) {
519
+ const isValid = validIds.has(claim.pluginId);
520
+ // A regra do carimbo é a de sempre — tudo o que ela pede está no ar —, e vem ANTES do motivo: uma
521
+ // function que já estava aplicada continua satisfazendo a extensão mesmo que o portão dela tenha
522
+ // saído da fila nesta execução.
523
+ if (isValid && (claim.backend.functions ?? []).every((name) => deployedNow.has(name))) {
524
+ await stampApplied(projectId, claims, claim, settlement.approvedNow.get(claim.pluginId));
525
+ continue;
457
526
  }
458
- else {
527
+ if (isValid)
459
528
  outcome.pending.push(claim);
460
- }
529
+ const cause = pendingCauseOf(claim, pendingInputs);
530
+ if (cause !== null)
531
+ await recordPending(projectId, claims, claim, cause);
461
532
  }
462
533
  /**
463
534
  * O que o PROJETO roda, cruzado com o que esta instalação consegue justificar.
@@ -609,19 +680,76 @@ const checkNewCallables = async (known, deployed, projectId, region) => {
609
680
  * existe, existe para todo escritório que o instalou, e não só para aquele cuja cópia foi lida por
610
681
  * último.
611
682
  */
612
- const stampApplied = async (projectId, all, claim) => {
613
- const fingerprint = backendFingerprint(claim.backend);
683
+ const stampApplied = async (projectId, all, claim, approvedNowBy) => {
684
+ const fingerprint = claimFingerprint(claim);
614
685
  const appliedAt = new Date();
686
+ /**
687
+ * A aprovação vai junto do carimbo quando a fonte veio no pacote. Aprovada agora, o aprovador é quem
688
+ * rodou o comando; aprovada antes, é quem a aprovou numa das cópias — e é isso que a PROPAGA para a
689
+ * cópia nova de um escritório que reinstalou, sem perguntar de novo pelo código que já roda.
690
+ */
691
+ const hash = claim.source?.hash;
692
+ const approval = hash === undefined
693
+ ? {}
694
+ : {
695
+ approvedHash: asString(hash),
696
+ approvedBy: asString(approvedNowBy
697
+ ?? all.find((entry) => entry.pluginId === claim.pluginId && entry.approvedHash === hash)?.approvedBy
698
+ ?? 'unknown'),
699
+ approvedAt: asTimestamp(appliedAt),
700
+ };
615
701
  for (const copy of all.filter((entry) => entry.pluginId === claim.pluginId)) {
616
- if (backendFingerprint(copy.backend) !== fingerprint)
702
+ if (claimFingerprint(copy) !== fingerprint)
617
703
  continue;
618
- await patchMapField(projectId, copy.documentPath, 'backendState', {
619
- fingerprint: asString(fingerprint),
620
- appliedAt: asTimestamp(appliedAt),
704
+ // O motivo de uma recusa anterior sai na MESMA escrita: duas escritas abririam uma janela em que a
705
+ // tela mostra a extensão no ar e, ao mesmo tempo, por que ela não estava.
706
+ await patchFields(projectId, copy.documentPath, {
707
+ backendState: asMap({
708
+ fingerprint: asString(fingerprint),
709
+ appliedAt: asTimestamp(appliedAt),
710
+ ...approval,
711
+ }),
712
+ backendPending: asNull(),
621
713
  });
622
714
  }
623
715
  success(`${claim.name} is ready in ${projectId}`);
624
716
  };
717
+ /** O fingerprint que a cópia exige — o mesmo que a tela calcula, com o hash da fonte quando ela existe. */
718
+ const claimFingerprint = (claim) => backendFingerprint(claim.backend, claim.source?.hash);
719
+ /**
720
+ * As extensões cuja fonte veio no pacote, uma por id — a escolhida por `uniqueByPlugin`, a versão mais
721
+ * nova —, com todos os hashes que QUALQUER cópia do projeto já aprovou.
722
+ */
723
+ const packagedClaimsOf = (claims) => uniqueByPlugin(claims).flatMap((claim) => (claim.source === undefined ? [] : [{
724
+ pluginId: claim.pluginId,
725
+ name: claim.name,
726
+ version: claim.version,
727
+ functions: claim.backend.functions ?? [],
728
+ source: claim.source,
729
+ approvedHashes: new Set(claims
730
+ .filter((entry) => entry.pluginId === claim.pluginId && entry.approvedHash !== undefined)
731
+ .map((entry) => entry.approvedHash)),
732
+ }]));
733
+ /**
734
+ * Grava na instalação POR QUE ela continua pendente — o que até aqui só o terminal sabia.
735
+ *
736
+ * Vai para toda cópia daquela extensão com a mesma exigência, pelo mesmo motivo do carimbo: a recusa é
737
+ * do projeto. A cópia já carimbada fica de fora: ela está no ar, e um motivo nela só confundiria.
738
+ */
739
+ const recordPending = async (projectId, all, claim, cause) => {
740
+ const fingerprint = claimFingerprint(claim);
741
+ const recordedAt = new Date();
742
+ for (const copy of all.filter((entry) => entry.pluginId === claim.pluginId)) {
743
+ if (copy.applied || claimFingerprint(copy) !== fingerprint)
744
+ continue;
745
+ await patchMapField(projectId, copy.documentPath, 'backendPending', {
746
+ fingerprint: asString(fingerprint),
747
+ reason: asString(cause.reason),
748
+ functions: asArray(cause.functions.map(asString)),
749
+ recordedAt: asTimestamp(recordedAt),
750
+ });
751
+ }
752
+ };
625
753
  /**
626
754
  * O que ninguém exige e mesmo assim NÃO é órfã.
627
755
  *
@@ -847,6 +975,8 @@ export const provision = async (args) => {
847
975
  all: hasFlag(args, 'all'),
848
976
  environmentFile: file,
849
977
  environment,
978
+ approvals: parseApprovals(flagValue(args, 'approve')),
979
+ interactive: process.stdin.isTTY === true,
850
980
  });
851
981
  blank();
852
982
  if (outcome.failed.length > 0) {
@@ -33,7 +33,10 @@ export interface BackendClaim {
33
33
  backend: PluginBackendRequirement;
34
34
  }
35
35
  /** Cópia byte a byte de `domain/schema/plugin/backendRequirement.ts`. Ver o teste de paridade. */
36
- export declare const backendFingerprint: (backend?: PluginBackendRequirement) => string;
36
+ export declare const backendFingerprint: (backend?: PluginBackendRequirement, sourceHash?: string) => string;
37
+ /** Ver `BACKEND_PENDING_REASONS` no kernel — a paridade é cobrada por `backendShape.test.ts`. */
38
+ export declare const BACKEND_PENDING_REASONS: readonly ["function-source-missing", "function-claim-refused", "gate-missing", "deploy-failed", "awaiting-approval", "source-invalid"];
39
+ export type BackendPendingReason = typeof BACKEND_PENDING_REASONS[number];
37
40
  export declare const hasBackendRequirement: (backend?: PluginBackendRequirement) => boolean;
38
41
  /** Chave de identidade de um índice, para deduplicar e para casar reivindicação com a raiz. */
39
42
  export declare const indexKey: (index: FirestoreIndexSpec) => string;
@@ -4,11 +4,21 @@ const canonicalIndex = (index) => JSON.stringify([
4
4
  index.fields.map((field) => [field.fieldPath, field.order ?? '', field.arrayConfig ?? '']),
5
5
  ]);
6
6
  /** Cópia byte a byte de `domain/schema/plugin/backendRequirement.ts`. Ver o teste de paridade. */
7
- export const backendFingerprint = (backend) => JSON.stringify({
7
+ export const backendFingerprint = (backend, sourceHash) => JSON.stringify({
8
8
  functions: [...(backend?.functions ?? [])].sort(),
9
9
  indexes: [...(backend?.indexes ?? [])].map(canonicalIndex).sort(),
10
10
  parameters: [...(backend?.parameters ?? [])].sort(),
11
+ ...(sourceHash !== undefined ? { code: sourceHash } : {}),
11
12
  });
13
+ /** Ver `BACKEND_PENDING_REASONS` no kernel — a paridade é cobrada por `backendShape.test.ts`. */
14
+ export const BACKEND_PENDING_REASONS = [
15
+ 'function-source-missing',
16
+ 'function-claim-refused',
17
+ 'gate-missing',
18
+ 'deploy-failed',
19
+ 'awaiting-approval',
20
+ 'source-invalid',
21
+ ];
12
22
  export const hasBackendRequirement = (backend) => (backend?.functions?.length ?? 0) > 0
13
23
  || (backend?.indexes?.length ?? 0) > 0
14
24
  || (backend?.parameters?.length ?? 0) > 0;
@@ -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.36.0";
90
+ export declare const CLI_VERSION = "2.38.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.36.0';
97
+ export const CLI_VERSION = '2.38.0';
@@ -247,6 +247,13 @@ const publishKernel = async (root, version, repo, dryRun) => {
247
247
  'release', 'upload', tagOf(version), tarball, manifestPath,
248
248
  '--repo', repo, '--clobber',
249
249
  ]);
250
+ /**
251
+ * O `gh release create` com arquivos cria o release como RASCUNHO, sobe os arquivos e só então o
252
+ * publica. Interrompido no meio — foi um 502 do GitHub —, sobra o rascunho, e o arquivo de um
253
+ * rascunho não se baixa sem credencial: retomar só subindo os arquivos gravava no catálogo um
254
+ * `latest` que nenhum escritório consegue instalar. Em release já publicado, isto não muda nada.
255
+ */
256
+ await run('gh', ['release', 'edit', tagOf(version), '--repo', repo, '--draft=false']);
250
257
  }
251
258
  else {
252
259
  pending(`creating ${tagOf(version)} in ${repo}`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@microteia/console",
3
- "version": "2.36.0",
3
+ "version": "2.38.0",
4
4
  "description": "Install and update Microteia in a Firebase project you own.",
5
5
  "license": "UNLICENSED",
6
6
  "type": "module",