@microteia/console 2.35.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.
- package/CONSOLE-VERSIONS.md +18 -0
- package/README.md +1 -1
- package/lib/console/backendPending.d.ts +36 -0
- package/lib/console/backendPending.js +30 -0
- package/lib/console/customFunctions.d.ts +1 -0
- package/lib/console/customFunctions.js +15 -3
- package/lib/console/deploy.js +3 -0
- package/lib/console/environment.d.ts +22 -0
- package/lib/console/environment.js +40 -1
- package/lib/console/firestoreRest.d.ts +10 -0
- package/lib/console/firestoreRest.js +16 -0
- package/lib/console/packagedApproval.d.ts +27 -0
- package/lib/console/packagedApproval.js +109 -0
- package/lib/console/packagedPaths.d.ts +23 -0
- package/lib/console/packagedPaths.js +25 -0
- package/lib/console/packagedSources.d.ts +72 -0
- package/lib/console/packagedSources.js +135 -0
- package/lib/console/provision.d.ts +10 -0
- package/lib/console/provision.js +148 -18
- package/lib/console/versions.js +10 -1
- package/lib/kernel/backendShape.d.ts +4 -1
- package/lib/kernel/backendShape.js +11 -1
- package/lib/shared/paths.d.ts +1 -1
- package/lib/shared/paths.js +1 -1
- package/lib/vendor/publish.js +7 -0
- package/package.json +1 -1
package/CONSOLE-VERSIONS.md
CHANGED
|
@@ -22,6 +22,24 @@
|
|
|
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
|
+
|
|
38
|
+
#### [2.36.0] — 2026-11-05
|
|
39
|
+
|
|
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.
|
|
41
|
+
- feat(version): `microteia --version` still answers in that case, and says which Console last updated the installation.
|
|
42
|
+
|
|
25
43
|
#### [2.35.0] — 2026-11-02
|
|
26
44
|
|
|
27
45
|
- feat(publish): the releases repository README links to the official documentation (https://doc.microteia.com), and `publish` rewrites it whenever it differs from the current text — after the catalog, warning instead of failing. It was written only when seeding an empty repository, so the README of an existing one stayed frozen at the text of its first `publish` and no change to it ever arrived. `publish --dry-run` says when it would be rewritten.
|
package/README.md
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
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;
|
package/lib/console/deploy.js
CHANGED
|
@@ -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(', ')}`);
|
|
@@ -73,6 +73,28 @@ export interface Environment {
|
|
|
73
73
|
export declare const ENVIRONMENT_FILE = "microteia.yaml";
|
|
74
74
|
export declare const parseEnvironment: (source: string) => Environment;
|
|
75
75
|
export declare const serializeEnvironment: (environment: Environment) => string;
|
|
76
|
+
/**
|
|
77
|
+
* O Console que deixou esta instalação como ela está, quando ele é MAIS NOVO que o que está rodando.
|
|
78
|
+
*
|
|
79
|
+
* O sinal é o topo do `CONSOLE-VERSIONS.md` da raiz, que o `unpack` sobrepõe com o histórico do próprio
|
|
80
|
+
* binário — é, portanto, o último Console que trocou o kernel ali. Nenhum campo novo foi inventado para
|
|
81
|
+
* isso, e é deliberado: um Console antigo apagaria o campo que ele não conhece, que é o defeito inteiro.
|
|
82
|
+
*
|
|
83
|
+
* Sem o arquivo (instalação anterior a ele, ou apagado) não há o que comparar, e nada é recusado.
|
|
84
|
+
*/
|
|
85
|
+
export declare const newerConsoleThanThis: (root: string) => Promise<string | null>;
|
|
86
|
+
/**
|
|
87
|
+
* Lê o `microteia.yaml` — e RECUSA quando um Console mais novo já passou por aqui.
|
|
88
|
+
*
|
|
89
|
+
* Um Console antigo reescreve este arquivo só com as seções que conhece, e sobrepõe o
|
|
90
|
+
* `CONSOLE-VERSIONS.md` e o `MICROTEIA.md` com os textos dele. Nada disso falha: a configuração que ele
|
|
91
|
+
* não conhece some, e o histórico volta meses, em silêncio. Foi o que aconteceu quando uma cópia
|
|
92
|
+
* esquecida do Console, numa pasta da instalação, rodou o `unpack` — o bloco `login:` desapareceu, e o
|
|
93
|
+
* histórico do Console perdeu trinta versões.
|
|
94
|
+
*
|
|
95
|
+
* A recusa fica aqui porque este é o primeiro passo de todo comando que AGE sobre a instalação, antes de
|
|
96
|
+
* qualquer escrita. O `--version` lê o arquivo por outro caminho, e continua respondendo.
|
|
97
|
+
*/
|
|
76
98
|
export declare const readEnvironment: (path: string) => Promise<Environment>;
|
|
77
99
|
export declare const writeEnvironment: (path: string, environment: Environment) => Promise<void>;
|
|
78
100
|
export declare const slug: (value: string) => string;
|
|
@@ -1,4 +1,8 @@
|
|
|
1
1
|
import { readFile, writeFile } from 'node:fs/promises';
|
|
2
|
+
import { dirname, join } from 'node:path';
|
|
3
|
+
import { compareVersions } from '../kernel/manifest.js';
|
|
4
|
+
import { CLI_VERSION } from '../shared/paths.js';
|
|
5
|
+
import { CONSOLE_CHANGELOG, changelogVersions } from '../vendor/changelogs.js';
|
|
2
6
|
export const ENVIRONMENT_FILE = 'microteia.yaml';
|
|
3
7
|
/**
|
|
4
8
|
* O YAML aqui é um SUBCONJUNTO deliberado: mapas de dois níveis com valores escalares, que é a forma
|
|
@@ -140,9 +144,36 @@ login:
|
|
|
140
144
|
# To USE an extension inside the organization, install it from the Extensions screen instead.
|
|
141
145
|
extensions: ${quote(environment.login.extensions)}
|
|
142
146
|
`;
|
|
147
|
+
/**
|
|
148
|
+
* O Console que deixou esta instalação como ela está, quando ele é MAIS NOVO que o que está rodando.
|
|
149
|
+
*
|
|
150
|
+
* O sinal é o topo do `CONSOLE-VERSIONS.md` da raiz, que o `unpack` sobrepõe com o histórico do próprio
|
|
151
|
+
* binário — é, portanto, o último Console que trocou o kernel ali. Nenhum campo novo foi inventado para
|
|
152
|
+
* isso, e é deliberado: um Console antigo apagaria o campo que ele não conhece, que é o defeito inteiro.
|
|
153
|
+
*
|
|
154
|
+
* Sem o arquivo (instalação anterior a ele, ou apagado) não há o que comparar, e nada é recusado.
|
|
155
|
+
*/
|
|
156
|
+
export const newerConsoleThanThis = async (root) => {
|
|
157
|
+
const contents = await readFile(join(root, CONSOLE_CHANGELOG), 'utf8').catch(() => null);
|
|
158
|
+
const latest = contents === null ? undefined : changelogVersions(contents)[0];
|
|
159
|
+
return latest !== undefined && compareVersions(latest, CLI_VERSION) > 0 ? latest : null;
|
|
160
|
+
};
|
|
161
|
+
/**
|
|
162
|
+
* Lê o `microteia.yaml` — e RECUSA quando um Console mais novo já passou por aqui.
|
|
163
|
+
*
|
|
164
|
+
* Um Console antigo reescreve este arquivo só com as seções que conhece, e sobrepõe o
|
|
165
|
+
* `CONSOLE-VERSIONS.md` e o `MICROTEIA.md` com os textos dele. Nada disso falha: a configuração que ele
|
|
166
|
+
* não conhece some, e o histórico volta meses, em silêncio. Foi o que aconteceu quando uma cópia
|
|
167
|
+
* esquecida do Console, numa pasta da instalação, rodou o `unpack` — o bloco `login:` desapareceu, e o
|
|
168
|
+
* histórico do Console perdeu trinta versões.
|
|
169
|
+
*
|
|
170
|
+
* A recusa fica aqui porque este é o primeiro passo de todo comando que AGE sobre a instalação, antes de
|
|
171
|
+
* qualquer escrita. O `--version` lê o arquivo por outro caminho, e continua respondendo.
|
|
172
|
+
*/
|
|
143
173
|
export const readEnvironment = async (path) => {
|
|
174
|
+
let environment;
|
|
144
175
|
try {
|
|
145
|
-
|
|
176
|
+
environment = parseEnvironment(await readFile(path, 'utf8'));
|
|
146
177
|
}
|
|
147
178
|
catch (error) {
|
|
148
179
|
if (error.code === 'ENOENT') {
|
|
@@ -150,6 +181,14 @@ export const readEnvironment = async (path) => {
|
|
|
150
181
|
}
|
|
151
182
|
throw error;
|
|
152
183
|
}
|
|
184
|
+
const newer = await newerConsoleThanThis(dirname(path));
|
|
185
|
+
if (newer !== null) {
|
|
186
|
+
throw new Error(`This installation was last updated by Microteia Console ${newer}, and this is ${CLI_VERSION}.\n`
|
|
187
|
+
+ `An older Console rewrites ${ENVIRONMENT_FILE} without the settings it does not know, and replaces\n`
|
|
188
|
+
+ `${CONSOLE_CHANGELOG} with an older history, so it stops here. Update it with "npm i -g @microteia/console",\n`
|
|
189
|
+
+ 'and check that the "microteia" you ran is that one, not a copy left in a folder.');
|
|
190
|
+
}
|
|
191
|
+
return environment;
|
|
153
192
|
};
|
|
154
193
|
export const writeEnvironment = async (path, environment) => {
|
|
155
194
|
await writeFile(path, serializeEnvironment(environment));
|
|
@@ -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[];
|
package/lib/console/provision.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
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
|
-
|
|
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 =
|
|
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 (
|
|
702
|
+
if (claimFingerprint(copy) !== fingerprint)
|
|
617
703
|
continue;
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
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) {
|
package/lib/console/versions.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { readFile } from 'node:fs/promises';
|
|
2
2
|
import { join } from 'node:path';
|
|
3
3
|
import { CLI_VERSION } from '../shared/paths.js';
|
|
4
|
-
import { ENVIRONMENT_FILE, parseEnvironment } from './environment.js';
|
|
4
|
+
import { ENVIRONMENT_FILE, newerConsoleThanThis, parseEnvironment } from './environment.js';
|
|
5
5
|
import { installPath } from './unpack.js';
|
|
6
6
|
/**
|
|
7
7
|
* O que `microteia --version` responde.
|
|
@@ -59,6 +59,15 @@ export const versionReport = async (directory, newer) => {
|
|
|
59
59
|
lines.push('');
|
|
60
60
|
lines.push(`${inPlace} is in place and ${deployed} is what the project runs. "microteia deploy" publishes it.`);
|
|
61
61
|
}
|
|
62
|
+
/**
|
|
63
|
+
* É a pergunta que se faz quando um comando recusa por isto — e a resposta tem de estar aqui, porque
|
|
64
|
+
* o `--version` é o único que continua rodando com o Console velho.
|
|
65
|
+
*/
|
|
66
|
+
const newerConsole = await newerConsoleThanThis(directory);
|
|
67
|
+
if (newerConsole !== null) {
|
|
68
|
+
lines.push('');
|
|
69
|
+
lines.push(`Console ${newerConsole} last updated this installation. This one is older and will not act on it.`);
|
|
70
|
+
}
|
|
62
71
|
/**
|
|
63
72
|
* E o que o canal oferece, quando há canal alcançável. Vem por último porque é a única linha que
|
|
64
73
|
* depende de rede: se ela faltar, tudo acima continua sendo a resposta inteira.
|
|
@@ -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;
|
package/lib/shared/paths.d.ts
CHANGED
|
@@ -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.
|
|
90
|
+
export declare const CLI_VERSION = "2.38.0";
|
package/lib/shared/paths.js
CHANGED
|
@@ -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.
|
|
97
|
+
export const CLI_VERSION = '2.38.0';
|
package/lib/vendor/publish.js
CHANGED
|
@@ -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}`);
|