@microteia/console 2.7.2 → 2.10.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 +56 -0
- package/README.md +1 -1
- package/lib/console/deploy.js +43 -5
- package/lib/console/environment.d.ts +15 -0
- package/lib/console/environment.js +11 -0
- package/lib/console/exportCatalog.d.ts +124 -0
- package/lib/console/exportCatalog.js +264 -0
- package/lib/console/functionsUpToDate.d.ts +55 -0
- package/lib/console/functionsUpToDate.js +192 -0
- package/lib/console/init.js +1 -0
- package/lib/console/installedExtensions.d.ts +37 -0
- package/lib/console/installedExtensions.js +37 -0
- package/lib/console/loginExtensions.d.ts +29 -0
- package/lib/console/loginExtensions.js +101 -0
- package/lib/console/provision.d.ts +12 -0
- package/lib/console/provision.js +39 -19
- package/lib/console/regionOrphans.d.ts +22 -1
- package/lib/console/regionOrphans.js +15 -3
- package/lib/index.js +10 -2
- package/lib/kernel/manifest.d.ts +16 -0
- package/lib/kernel/manifest.js +16 -0
- package/lib/shared/paths.d.ts +1 -1
- package/lib/shared/paths.js +1 -1
- package/lib/vendor/exampleSources.js +7 -3
- package/lib/vendor/installationDocs.js +33 -2
- package/lib/vendor/pack.js +3 -1
- package/package.json +1 -1
package/CONSOLE-VERSIONS.md
CHANGED
|
@@ -22,6 +22,62 @@
|
|
|
22
22
|
- fix(command): what was broken, and what it did to whoever hit it.
|
|
23
23
|
-->
|
|
24
24
|
|
|
25
|
+
#### [2.10.0] — 2026-08-27
|
|
26
|
+
|
|
27
|
+
- feat(export-catalog): a new command writes what an office HAS — its screens with their fields, its
|
|
28
|
+
lists of options, its tag lists and its installed extensions — into `extensions/installed-catalog.json`,
|
|
29
|
+
for the authoring tools to read. The MCP server's catalog knew only the `plugin.ts` files in that
|
|
30
|
+
directory plus the platform's own entities, so a screen built in the Form Design and an extension
|
|
31
|
+
already installed were invisible to it, and the check that catches a field belonging to another
|
|
32
|
+
entity could not fire against the entities that matter most. Closing it by giving the SDK a
|
|
33
|
+
credential was the wrong trade: `microteia-plugin` is offline by design. The Console already holds
|
|
34
|
+
the gcloud token, so the Console exports and the SDK only reads a file.
|
|
35
|
+
- feat(export-catalog): `--tenant` is required, and without it the command LISTS the offices in the
|
|
36
|
+
project instead of guessing one. A Firebase project can hold several, and exporting the wrong one
|
|
37
|
+
would put another office's vocabulary inside somebody's extension work.
|
|
38
|
+
- feat(export-catalog): the file is written beside `microteia.yaml`, in `extensions/` — never in
|
|
39
|
+
`.microteia/`, which `unpack` deletes whole on every kernel change. A snapshot that disappears on
|
|
40
|
+
upgrade is a snapshot nothing can depend on. `--output` overrides the path; without `extensions/`
|
|
41
|
+
the command refuses and names `microteia plugin-init`.
|
|
42
|
+
- feat(export-catalog): it exports SHAPE, not content. Records, the values inside a list of options or
|
|
43
|
+
a tag list, members, permissions, workflows, scripts, actions, secrets and settings never leave the
|
|
44
|
+
office. A disabled screen is left out too: nothing draws it, and carrying it would assert that the
|
|
45
|
+
entity exists.
|
|
46
|
+
- refactor(provision): reading what an office installed moved to `installedExtensions.ts`, and
|
|
47
|
+
`provision` now goes through it. Two paths to the same document are two chances to disagree about
|
|
48
|
+
what counts as installed.
|
|
49
|
+
|
|
50
|
+
#### [2.9.0] — 2026-08-26
|
|
51
|
+
|
|
52
|
+
- feat(deploy): `login.extensions` in `microteia.yaml` lists compiled extension packages that `deploy`
|
|
53
|
+
stamps into `hosting/microteia-extensions.js`, loaded before the bundle like `firebase-config.js`.
|
|
54
|
+
It is how a corporate SSO reaches the sign-in screen: that screen runs above the organization, so
|
|
55
|
+
nothing an organization installed can reach it, and until now the only way in was a commit in the
|
|
56
|
+
kernel.
|
|
57
|
+
- feat(deploy): the stamp is written on EVERY deploy, empty list included. Skipping the write would
|
|
58
|
+
leave the previous deploy's stamp live, so removing a package from the list would not remove it from
|
|
59
|
+
the screen.
|
|
60
|
+
- feat(deploy): a package that contributes to none of the points that work before sign-in is REFUSED,
|
|
61
|
+
not stamped. It would load with no effect, and whoever stamped it would believe it was live. The list
|
|
62
|
+
of those points comes from `preLoginPoints` in the installed package's manifest, never from a copy
|
|
63
|
+
here: the Console is versioned separately, and a copy would silently describe another version's
|
|
64
|
+
contract.
|
|
65
|
+
- feat(deploy): the stamp is capped — a warning past 256 KiB, a refusal past 1 MiB. The file blocks the
|
|
66
|
+
page on every visit, including visits by people who never sign in.
|
|
67
|
+
- feat(deploy): a duplicated package id is refused. The Kernel would ignore the second one, so the file
|
|
68
|
+
would say something the installation does not do. A missing path, broken JSON and a file that is not a
|
|
69
|
+
package each name themselves.
|
|
70
|
+
- feat(deploy): a stamped package that ships views gets a warning saying its code does not run before
|
|
71
|
+
sign-in — stamping is not trusting, and that is what allows stamping without a code review.
|
|
72
|
+
|
|
73
|
+
#### [2.8.0] — 2026-08-25
|
|
74
|
+
|
|
75
|
+
- feat(console/functionsUpToDate): new module. It reconstructs the skip that `firebase deploy` performs in bulk and that `--only functions:<name>` disables, WITHOUT reimplementing the hash: functions are grouped by the `firebase-functions-hash` label they carry, one member of each group is deployed, and the stamp it comes back with is the stamp of the current code. The probe being a MEMBER of its group is what makes the conclusion carry to the others — two functions with the same old stamp share source, environment and secret set, the three parcels of the hash. Absent, non-`ACTIVE`, unstamped or wrong-region always deploys: not knowing never becomes skipping.
|
|
76
|
+
- feat(console/deploy): asks the project once (`functions:list --json`) and feeds both decisions from that one answer — the region orphan check and the up-to-date check. Two questions could disagree, and then the decision depends on which answer the code happened to read.
|
|
77
|
+
- feat(console/provision): `outdatedByStamp` requeues an applied extension function that carries a stamp older than what this package builds, or that is missing from the project. The stamp comes from the probes the `deploy` step just ran — extension functions live in the same codebase, so it is their stamp too. Called on its own, provisioning decides as it always did: probing there would cost a deploy on every extension install to answer a question that is almost always "nothing".
|
|
78
|
+
- feat(console): `--all` on `deploy` and `provision` deploys every function without asking the project what it runs.
|
|
79
|
+
- fix(console/regionOrphans): `listDeployedFunctions` keeps `hash`, `state` and `platform` instead of discarding everything but id and region; `clearRegionOrphans` accepts an already-fetched listing.
|
|
80
|
+
|
|
25
81
|
#### [2.7.2] — 2026-08-25
|
|
26
82
|
|
|
27
83
|
- feat(vendor/exampleSources): stages `automation-example` alongside the others, so the workspace ships a complete automation to read. Every example that travelled carried `workflows: []`, and automation was the one mechanism an author had only prose for.
|
package/README.md
CHANGED
package/lib/console/deploy.js
CHANGED
|
@@ -5,8 +5,9 @@ import { run } from '../shared/run.js';
|
|
|
5
5
|
import { blank, bullets, failure, heading, info, step, success, warn } from '../shared/ui.js';
|
|
6
6
|
import { ENVIRONMENT_FILE, readEnvironment, writeEnvironment } from './environment.js';
|
|
7
7
|
import { FIREBASE_BIN } from './firebaseCli.js';
|
|
8
|
-
import {
|
|
9
|
-
import { clearRegionOrphans } from './regionOrphans.js';
|
|
8
|
+
import { reportFailures, reportNotices } from './deployFunctions.js';
|
|
9
|
+
import { clearRegionOrphans, listDeployedFunctions } from './regionOrphans.js';
|
|
10
|
+
import { deployFunctionsThatChanged } from './functionsUpToDate.js';
|
|
10
11
|
import { checkCallables, invokerGrantCommand } from './callableAccess.js';
|
|
11
12
|
import { checkHostingServed, htmlIsRevalidated } from './hostingCheck.js';
|
|
12
13
|
import { provisionBackend } from './provision.js';
|
|
@@ -14,6 +15,7 @@ import { installPath, readInstalledManifest } from './unpack.js';
|
|
|
14
15
|
import { ensureOfficeEnv, ensureRootGitignore, officeEnvName, stageOfficeEnv } from './backendEnv.js';
|
|
15
16
|
import { gatherCustomFunctions, readOfficeFunctions } from './customFunctions.js';
|
|
16
17
|
import { readResolvedConfig, stampedConfigJs, writeResolvedConfig, } from './workspace.js';
|
|
18
|
+
import { STAMP_FILE, buildLoginExtensions, stampPath } from './loginExtensions.js';
|
|
17
19
|
const COMPONENTS = ['rules', 'indexes', 'functions', 'hosting'];
|
|
18
20
|
const selectedComponents = (args) => {
|
|
19
21
|
const only = flagValue(args, 'only');
|
|
@@ -62,6 +64,20 @@ export const deploy = async (args) => {
|
|
|
62
64
|
await writeResolvedConfig(root, stamped);
|
|
63
65
|
await writeFile(join(install, 'hosting', 'firebase-config.js'), stampedConfigJs(stamped));
|
|
64
66
|
success(`hosting/firebase-config.js → ${projectId}`);
|
|
67
|
+
// As extensões que valem ANTES do login. Carimbadas no mesmo passo da configuração porque são a
|
|
68
|
+
// mesma coisa: o que o navegador precisa saber antes de o bundle subir. Escrito sempre, mesmo vazio
|
|
69
|
+
// — pular a escrita deixaria o carimbo do deploy anterior no ar, e tirar um pacote da lista não o
|
|
70
|
+
// tiraria da tela.
|
|
71
|
+
const loginExtensions = await buildLoginExtensions(root, environment.login.extensions, manifest.preLoginPoints);
|
|
72
|
+
await writeFile(stampPath(install), loginExtensions.contents);
|
|
73
|
+
if (loginExtensions.stamped.length === 0) {
|
|
74
|
+
success(`hosting/${STAMP_FILE} → no extensions before sign-in`);
|
|
75
|
+
}
|
|
76
|
+
else {
|
|
77
|
+
success(`hosting/${STAMP_FILE} → ${loginExtensions.stamped.join(', ')}`);
|
|
78
|
+
}
|
|
79
|
+
for (const warning of loginExtensions.warnings)
|
|
80
|
+
warn(warning);
|
|
65
81
|
// O arquivo que o escritório edita é o da RAIZ; o que o `firebase-functions` lê tem de estar em
|
|
66
82
|
// `install/functions/`, e por isso é uma cópia escrita agora. Antes a fonte era a cópia, e o deploy
|
|
67
83
|
// seguinte a apagava junto com o pacote.
|
|
@@ -83,6 +99,12 @@ export const deploy = async (args) => {
|
|
|
83
99
|
const failures = [];
|
|
84
100
|
const unreachableCallables = [];
|
|
85
101
|
let staleHosting = false;
|
|
102
|
+
let skippedFunctions = 0;
|
|
103
|
+
// O que as sondas do passo de functions descobriram: carimbo antigo -> carimbo de agora. O
|
|
104
|
+
// provisionamento está no MESMO codebase, então esse é o carimbo das functions de extensão também.
|
|
105
|
+
// Segue `undefined` quando o passo de functions não rodou: ali não há conclusão a tirar, e um mapa
|
|
106
|
+
// vazio diria a mesma coisa por acaso, e não por decisão.
|
|
107
|
+
let refreshedStamps;
|
|
86
108
|
for (const component of components) {
|
|
87
109
|
current += 1;
|
|
88
110
|
if (component === 'rules') {
|
|
@@ -132,16 +154,25 @@ export const deploy = async (args) => {
|
|
|
132
154
|
info(' npm may warn about the Node engine and about deprecated packages: both are expected —');
|
|
133
155
|
info(' "engines" names the Cloud Functions runtime, and the notices come from Firebase\'s deps.');
|
|
134
156
|
await run('npm', ['install', '--no-audit', '--no-fund'], { cwd: join(install, 'functions') });
|
|
157
|
+
// Uma pergunta só ao projeto, e duas decisões a partir dela: qual cópia ficou numa região
|
|
158
|
+
// abandonada, e o que ele já roda. Perguntar duas vezes não é caro, é ambíguo — as duas
|
|
159
|
+
// respostas podem divergir, e aí a decisão passa a depender de qual delas o código olhou.
|
|
160
|
+
const deployedNow = await listDeployedFunctions(projectId);
|
|
135
161
|
// ANTES de deployar: uma cópia da mesma function numa região que o pacote abandonou faz o
|
|
136
162
|
// `firebase` recusar o deploy dela, tentativa após tentativa, e faz um gatilho de Firestore
|
|
137
163
|
// disparar duas vezes por gravação enquanto ninguém percebe.
|
|
138
|
-
await clearRegionOrphans(projectId, own, region, hasFlag(args, 'prune'));
|
|
139
|
-
|
|
164
|
+
await clearRegionOrphans(projectId, own, region, hasFlag(args, 'prune'), deployedNow);
|
|
165
|
+
// Numa instalação nova não há carimbo nenhum com que comparar, e o mecanismo cai sozinho no
|
|
166
|
+
// caminho de sempre — não é preciso um desvio para isso. `--all` é o pedido explícito de subir
|
|
167
|
+
// tudo, para a configuração trocada por fora, que carimbo nenhum enxerga.
|
|
168
|
+
const report = await deployFunctionsThatChanged(own, {
|
|
140
169
|
projectId,
|
|
141
170
|
cwd: install,
|
|
142
171
|
delaySeconds,
|
|
143
172
|
force: firstInstall,
|
|
144
|
-
});
|
|
173
|
+
}, { deployed: deployedNow, region, all: hasFlag(args, 'all') });
|
|
174
|
+
refreshedStamps = report.refreshed;
|
|
175
|
+
skippedFunctions = report.skipped.length;
|
|
145
176
|
if (report.failed.length > 0) {
|
|
146
177
|
failures.push(`${report.failed.length} function(s): ${report.failed.map((entry) => entry.name).join(', ')}`);
|
|
147
178
|
blank();
|
|
@@ -227,6 +258,8 @@ export const deploy = async (args) => {
|
|
|
227
258
|
manifest,
|
|
228
259
|
delaySeconds,
|
|
229
260
|
prune: hasFlag(args, 'prune'),
|
|
261
|
+
all: hasFlag(args, 'all'),
|
|
262
|
+
refreshedStamps,
|
|
230
263
|
environmentFile: file,
|
|
231
264
|
environment: applied,
|
|
232
265
|
});
|
|
@@ -242,6 +275,11 @@ export const deploy = async (args) => {
|
|
|
242
275
|
blank();
|
|
243
276
|
throw new Error('Some components did not deploy.');
|
|
244
277
|
}
|
|
278
|
+
if (skippedFunctions > 0) {
|
|
279
|
+
info(`${skippedFunctions} Cloud Function(s) were already running this exact code and were left alone.`);
|
|
280
|
+
bullets(['microteia deploy --all', ' deploys every one of them, whatever the project already runs']);
|
|
281
|
+
blank();
|
|
282
|
+
}
|
|
245
283
|
info(`Microteia ${manifest.version} is installed in ${projectId}.`);
|
|
246
284
|
bullets([`https://${projectId}.web.app`]);
|
|
247
285
|
blank();
|
|
@@ -53,6 +53,21 @@ export interface Environment {
|
|
|
53
53
|
*/
|
|
54
54
|
indexes: string | null;
|
|
55
55
|
};
|
|
56
|
+
/**
|
|
57
|
+
* As extensões que a instalação carrega ANTES do login — os pacotes compilados que `deploy` carimba
|
|
58
|
+
* em `microteia-extensions.js`. É por aqui que um SSO chega à tela de entrada: ali não há escritório,
|
|
59
|
+
* logo não há como ler o que um escritório instalou.
|
|
60
|
+
*
|
|
61
|
+
* É uma lista de CAMINHOS, e é deliberado que seja explícita. Varrer `extensions/` poria no ar um
|
|
62
|
+
* rascunho de autoria — aquele diretório é onde se escreve, não o que roda. Carimbar é um ato, e
|
|
63
|
+
* escrever o caminho aqui é o ato.
|
|
64
|
+
*
|
|
65
|
+
* Escalar separado por vírgula, como `backend.functions`, pela mesma razão: o leitor de YAML daqui
|
|
66
|
+
* entende mapas de dois níveis e escalares.
|
|
67
|
+
*/
|
|
68
|
+
login: {
|
|
69
|
+
extensions: string | null;
|
|
70
|
+
};
|
|
56
71
|
}
|
|
57
72
|
export declare const ENVIRONMENT_FILE = "microteia.yaml";
|
|
58
73
|
export declare const parseEnvironment: (source: string) => Environment;
|
|
@@ -83,6 +83,7 @@ export const parseEnvironment = (source) => {
|
|
|
83
83
|
parameters: optional('backend', 'parameters'),
|
|
84
84
|
indexes: optional('backend', 'indexes'),
|
|
85
85
|
},
|
|
86
|
+
login: { extensions: optional('login', 'extensions') },
|
|
86
87
|
};
|
|
87
88
|
};
|
|
88
89
|
export const serializeEnvironment = (environment) => `# Microteia installation.
|
|
@@ -128,6 +129,16 @@ backend:
|
|
|
128
129
|
# "firebase deploy" offer to delete the indexes of every extension. Managed by
|
|
129
130
|
# "microteia provision".
|
|
130
131
|
indexes: ${quote(environment.backend.indexes)}
|
|
132
|
+
|
|
133
|
+
login:
|
|
134
|
+
# Compiled extension packages (.json) to load BEFORE anyone signs in — this is how a corporate SSO
|
|
135
|
+
# reaches the sign-in screen, since there is no organization to read it from yet. Comma-separated
|
|
136
|
+
# paths, relative to this file. "microteia deploy" stamps them into microteia-extensions.js and
|
|
137
|
+
# refuses a package that contributes to none of the points that work before sign-in.
|
|
138
|
+
#
|
|
139
|
+
# Nothing stamped here runs JavaScript: the packages load as untrusted, so only declarations apply.
|
|
140
|
+
# To USE an extension inside the organization, install it from the Extensions screen instead.
|
|
141
|
+
extensions: ${quote(environment.login.extensions)}
|
|
131
142
|
`;
|
|
132
143
|
export const readEnvironment = async (path) => {
|
|
133
144
|
try {
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import type { ParsedArgs } from '../shared/args.js';
|
|
2
|
+
import type { InstalledExtensionRecord, TenantSummary } from './installedExtensions.js';
|
|
3
|
+
/**
|
|
4
|
+
* `microteia export-catalog` — o RETRATO do domínio instalado, para o diretório de autoria.
|
|
5
|
+
*
|
|
6
|
+
* O servidor MCP do SDK monta o catálogo a partir dos `plugin.ts` do diretório mais o registro de
|
|
7
|
+
* entidades do kernel. Numa instalação real isso deixa de fora as duas coisas que mais importam: a
|
|
8
|
+
* tela que o escritório criou no Form Design e a extensão de terceiro já instalada. O detector de
|
|
9
|
+
* "campo que pertence a outra entidade conhecida" não podia disparar contra elas, porque para ele elas
|
|
10
|
+
* não existiam.
|
|
11
|
+
*
|
|
12
|
+
* A saída NÃO foi ligar o SDK ao Firestore. `microteia-plugin` é offline por desenho, e arrastar
|
|
13
|
+
* credencial para dentro da ferramenta de autoria trocaria uma cegueira por um problema pior. Quem já
|
|
14
|
+
* fala com o projeto do escritório é o Console — o token vem do `gcloud`, a cada execução, e nada é
|
|
15
|
+
* guardado. Então é o Console que lê e escreve o arquivo, e o SDK apenas o encontra ali.
|
|
16
|
+
*
|
|
17
|
+
* Três decisões que não são preferência:
|
|
18
|
+
*
|
|
19
|
+
* - **O arquivo NÃO mora em `.microteia/`.** Aquele diretório é apagado por inteiro a cada `unpack`,
|
|
20
|
+
* e um retrato que some na troca de kernel é um retrato que ninguém pode depender de ter. Ele fica
|
|
21
|
+
* em `extensions/`, ao lado do `microteia.yaml`, que é onde o autor trabalha.
|
|
22
|
+
* - **Ele é gitignorado**, pela conciliação que o SDK já faz do `.gitignore` daquele diretório. Ele
|
|
23
|
+
* carrega nome de tela e de campo que o escritório digitou: dado do tenant, não artefato do produto.
|
|
24
|
+
* - **`--tenant` é obrigatório.** Um projeto Firebase pode ter vários escritórios, e escolher um por
|
|
25
|
+
* adivinhação escreveria o vocabulário de um dentro do trabalho de outro. Sem a opção, o comando
|
|
26
|
+
* LISTA os que existem.
|
|
27
|
+
*/
|
|
28
|
+
/** O nome é contrato com o SDK, que o procura por este nome — ver `installedCatalog.ts` do kernel. */
|
|
29
|
+
export declare const CATALOG_FILE = "installed-catalog.json";
|
|
30
|
+
/**
|
|
31
|
+
* A versão do FORMATO. Ela existe para que um Console de outra época não seja lido como se falasse a
|
|
32
|
+
* mesma língua: o SDK recusa o que não for exatamente esta, e diz o comando que refaz o arquivo.
|
|
33
|
+
*/
|
|
34
|
+
export declare const CATALOG_FORMAT_VERSION = 1;
|
|
35
|
+
/**
|
|
36
|
+
* O que viaja de cada campo, e nada além.
|
|
37
|
+
*
|
|
38
|
+
* A linha é ESQUEMA versus CONTEÚDO. Nome, tipo, obrigatoriedade, referência, agregado e expressão
|
|
39
|
+
* descrevem o que a entidade É, e é disso que o catálogo e os detectores precisam. Opção de lista,
|
|
40
|
+
* máscara, texto de ajuda, valor padrão e regra de formulário são conteúdo e apresentação: o catálogo
|
|
41
|
+
* nunca os lê, e cada um deles é mais vocabulário do escritório saindo do banco sem precisar.
|
|
42
|
+
*/
|
|
43
|
+
export interface CatalogFieldExport {
|
|
44
|
+
key: string;
|
|
45
|
+
label: string;
|
|
46
|
+
type: string;
|
|
47
|
+
required: boolean;
|
|
48
|
+
order: number;
|
|
49
|
+
multi?: boolean;
|
|
50
|
+
refEntity?: string;
|
|
51
|
+
refDenormKey?: string;
|
|
52
|
+
lookupCollection?: string;
|
|
53
|
+
section?: string;
|
|
54
|
+
origin: string;
|
|
55
|
+
ownerPluginId?: string;
|
|
56
|
+
aggregation?: Record<string, unknown>;
|
|
57
|
+
compute?: string;
|
|
58
|
+
}
|
|
59
|
+
export interface CatalogScreenExport {
|
|
60
|
+
key: string;
|
|
61
|
+
collection?: string;
|
|
62
|
+
titleSingular: string;
|
|
63
|
+
titlePlural?: string;
|
|
64
|
+
titleFieldKey: string;
|
|
65
|
+
origin: string;
|
|
66
|
+
ownerPluginId?: string;
|
|
67
|
+
parentEntity?: string;
|
|
68
|
+
parentFieldKey?: string;
|
|
69
|
+
parentRelation?: string;
|
|
70
|
+
tagCollection?: string;
|
|
71
|
+
eventPrefix?: string;
|
|
72
|
+
fields: CatalogFieldExport[];
|
|
73
|
+
}
|
|
74
|
+
export interface CatalogListExport {
|
|
75
|
+
collection: string;
|
|
76
|
+
label: string;
|
|
77
|
+
ownerPluginId?: string;
|
|
78
|
+
}
|
|
79
|
+
export interface CatalogExtensionExport {
|
|
80
|
+
id: string;
|
|
81
|
+
name: string;
|
|
82
|
+
version: string;
|
|
83
|
+
active: boolean;
|
|
84
|
+
}
|
|
85
|
+
export interface CatalogExport {
|
|
86
|
+
version: number;
|
|
87
|
+
projectId: string;
|
|
88
|
+
tenantId: string;
|
|
89
|
+
tenantName: string;
|
|
90
|
+
exportedAt: string;
|
|
91
|
+
kernelVersion: string;
|
|
92
|
+
consoleVersion: string;
|
|
93
|
+
extensions: CatalogExtensionExport[];
|
|
94
|
+
screens: CatalogScreenExport[];
|
|
95
|
+
lookups: CatalogListExport[];
|
|
96
|
+
tagLists: CatalogListExport[];
|
|
97
|
+
}
|
|
98
|
+
/** Um documento já decodificado, com o id que a coleção lhe deu. */
|
|
99
|
+
export interface CatalogSourceDocument {
|
|
100
|
+
id: string;
|
|
101
|
+
data: Record<string, unknown>;
|
|
102
|
+
}
|
|
103
|
+
export interface CatalogExportInput {
|
|
104
|
+
projectId: string;
|
|
105
|
+
tenant: TenantSummary;
|
|
106
|
+
kernelVersion: string;
|
|
107
|
+
exportedAt: Date;
|
|
108
|
+
screens: CatalogSourceDocument[];
|
|
109
|
+
lookups: CatalogSourceDocument[];
|
|
110
|
+
tagLists: CatalogSourceDocument[];
|
|
111
|
+
extensions: InstalledExtensionRecord[];
|
|
112
|
+
}
|
|
113
|
+
/**
|
|
114
|
+
* O documento, montado a partir do que foi lido. É PURA de propósito: o que ela produz é o contrato com
|
|
115
|
+
* o SDK, e um contrato que só pode ser exercitado com um projeto Firebase na frente é um contrato que
|
|
116
|
+
* ninguém confere.
|
|
117
|
+
*
|
|
118
|
+
* Uma tela DESLIGADA (`enabled: false`) fica de fora: a coleção dela pode até existir no banco, mas
|
|
119
|
+
* nada da plataforma a desenha, e trazê-la faria o catálogo afirmar que a entidade existe. Uma tela de
|
|
120
|
+
* extensão DESATIVADA continua: o documento dela segue gravado, e é ele que explica um campo que o
|
|
121
|
+
* autor vai encontrar.
|
|
122
|
+
*/
|
|
123
|
+
export declare const buildCatalogExport: (input: CatalogExportInput) => CatalogExport;
|
|
124
|
+
export declare const exportCatalog: (args: ParsedArgs) => Promise<void>;
|
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs';
|
|
2
|
+
import { mkdir, readFile, writeFile } from 'node:fs/promises';
|
|
3
|
+
import { dirname, join, relative, resolve } from 'node:path';
|
|
4
|
+
import { flagValue } from '../shared/args.js';
|
|
5
|
+
import { CLI_VERSION } from '../shared/paths.js';
|
|
6
|
+
import { blank, bullets, heading, info, step, success, warn } from '../shared/ui.js';
|
|
7
|
+
import { decodeFields, documentId, listDocuments } from './firestoreRest.js';
|
|
8
|
+
import { listTenants, readInstalledExtensions } from './installedExtensions.js';
|
|
9
|
+
import { ENVIRONMENT_FILE, readEnvironment } from './environment.js';
|
|
10
|
+
import { EXTENSIONS_DIR } from './pluginWorkspace.js';
|
|
11
|
+
import { WORKSPACE_DIR } from './workspace.js';
|
|
12
|
+
/**
|
|
13
|
+
* `microteia export-catalog` — o RETRATO do domínio instalado, para o diretório de autoria.
|
|
14
|
+
*
|
|
15
|
+
* O servidor MCP do SDK monta o catálogo a partir dos `plugin.ts` do diretório mais o registro de
|
|
16
|
+
* entidades do kernel. Numa instalação real isso deixa de fora as duas coisas que mais importam: a
|
|
17
|
+
* tela que o escritório criou no Form Design e a extensão de terceiro já instalada. O detector de
|
|
18
|
+
* "campo que pertence a outra entidade conhecida" não podia disparar contra elas, porque para ele elas
|
|
19
|
+
* não existiam.
|
|
20
|
+
*
|
|
21
|
+
* A saída NÃO foi ligar o SDK ao Firestore. `microteia-plugin` é offline por desenho, e arrastar
|
|
22
|
+
* credencial para dentro da ferramenta de autoria trocaria uma cegueira por um problema pior. Quem já
|
|
23
|
+
* fala com o projeto do escritório é o Console — o token vem do `gcloud`, a cada execução, e nada é
|
|
24
|
+
* guardado. Então é o Console que lê e escreve o arquivo, e o SDK apenas o encontra ali.
|
|
25
|
+
*
|
|
26
|
+
* Três decisões que não são preferência:
|
|
27
|
+
*
|
|
28
|
+
* - **O arquivo NÃO mora em `.microteia/`.** Aquele diretório é apagado por inteiro a cada `unpack`,
|
|
29
|
+
* e um retrato que some na troca de kernel é um retrato que ninguém pode depender de ter. Ele fica
|
|
30
|
+
* em `extensions/`, ao lado do `microteia.yaml`, que é onde o autor trabalha.
|
|
31
|
+
* - **Ele é gitignorado**, pela conciliação que o SDK já faz do `.gitignore` daquele diretório. Ele
|
|
32
|
+
* carrega nome de tela e de campo que o escritório digitou: dado do tenant, não artefato do produto.
|
|
33
|
+
* - **`--tenant` é obrigatório.** Um projeto Firebase pode ter vários escritórios, e escolher um por
|
|
34
|
+
* adivinhação escreveria o vocabulário de um dentro do trabalho de outro. Sem a opção, o comando
|
|
35
|
+
* LISTA os que existem.
|
|
36
|
+
*/
|
|
37
|
+
/** O nome é contrato com o SDK, que o procura por este nome — ver `installedCatalog.ts` do kernel. */
|
|
38
|
+
export const CATALOG_FILE = 'installed-catalog.json';
|
|
39
|
+
/**
|
|
40
|
+
* A versão do FORMATO. Ela existe para que um Console de outra época não seja lido como se falasse a
|
|
41
|
+
* mesma língua: o SDK recusa o que não for exatamente esta, e diz o comando que refaz o arquivo.
|
|
42
|
+
*/
|
|
43
|
+
export const CATALOG_FORMAT_VERSION = 1;
|
|
44
|
+
const text = (value) => (typeof value === 'string' && value.trim() !== '' ? value : undefined);
|
|
45
|
+
const asRecord = (value) => (typeof value === 'object' && value !== null && !Array.isArray(value)
|
|
46
|
+
? value
|
|
47
|
+
: undefined);
|
|
48
|
+
const FIELD_ORIGINS = ['builtin', 'plugin', 'design'];
|
|
49
|
+
/**
|
|
50
|
+
* A mesma dedução que a aplicação faz ao ler a tela (`screenDefinitionMapper.ts`): o `origin` gravado
|
|
51
|
+
* quando ele existe, senão `plugin` para quem tem dono e `design` para o que o escritório criou. Ela é
|
|
52
|
+
* repetida aqui porque o Console não alcança `src/` — e é conferida contra a instalação real por teste.
|
|
53
|
+
*/
|
|
54
|
+
const originOf = (data, fallback) => {
|
|
55
|
+
const declared = text(data.origin);
|
|
56
|
+
if (declared !== undefined && FIELD_ORIGINS.includes(declared))
|
|
57
|
+
return declared;
|
|
58
|
+
return text(data.ownerPluginId) !== undefined ? 'plugin' : fallback;
|
|
59
|
+
};
|
|
60
|
+
const fieldExport = (raw, index, screenOrigin) => {
|
|
61
|
+
const field = asRecord(raw);
|
|
62
|
+
if (field === undefined)
|
|
63
|
+
return null;
|
|
64
|
+
const key = text(field.key);
|
|
65
|
+
if (key === undefined)
|
|
66
|
+
return null;
|
|
67
|
+
const logic = asRecord(field.logic);
|
|
68
|
+
const compute = text(logic?.compute);
|
|
69
|
+
return {
|
|
70
|
+
key,
|
|
71
|
+
label: text(field.label) ?? key,
|
|
72
|
+
type: text(field.type) ?? 'text',
|
|
73
|
+
required: field.required === true,
|
|
74
|
+
order: typeof field.order === 'number' ? field.order : index,
|
|
75
|
+
...(field.multi === true ? { multi: true } : {}),
|
|
76
|
+
...(text(field.refEntity) === undefined ? {} : { refEntity: text(field.refEntity) }),
|
|
77
|
+
...(text(field.refDenormKey) === undefined ? {} : { refDenormKey: text(field.refDenormKey) }),
|
|
78
|
+
...(text(field.lookupCollection) === undefined ? {} : { lookupCollection: text(field.lookupCollection) }),
|
|
79
|
+
...(text(field.section) === undefined ? {} : { section: text(field.section) }),
|
|
80
|
+
origin: originOf(field, screenOrigin),
|
|
81
|
+
...(text(field.ownerPluginId) === undefined ? {} : { ownerPluginId: text(field.ownerPluginId) }),
|
|
82
|
+
...(asRecord(field.aggregation) === undefined ? {} : { aggregation: asRecord(field.aggregation) }),
|
|
83
|
+
...(compute === undefined ? {} : { compute }),
|
|
84
|
+
};
|
|
85
|
+
};
|
|
86
|
+
const screenExport = (document) => {
|
|
87
|
+
const data = document.data;
|
|
88
|
+
const origin = originOf(data, 'design');
|
|
89
|
+
const fields = Array.isArray(data.fields) ? data.fields : [];
|
|
90
|
+
return {
|
|
91
|
+
key: document.id,
|
|
92
|
+
...(text(data.dataCollection) === undefined ? {} : { collection: text(data.dataCollection) }),
|
|
93
|
+
titleSingular: text(data.titleSingular) ?? document.id,
|
|
94
|
+
...(text(data.titlePlural) === undefined ? {} : { titlePlural: text(data.titlePlural) }),
|
|
95
|
+
titleFieldKey: text(data.titleFieldKey) ?? '',
|
|
96
|
+
origin,
|
|
97
|
+
...(text(data.ownerPluginId) === undefined ? {} : { ownerPluginId: text(data.ownerPluginId) }),
|
|
98
|
+
...(text(data.parentEntity) === undefined ? {} : { parentEntity: text(data.parentEntity) }),
|
|
99
|
+
...(text(data.parentFieldKey) === undefined ? {} : { parentFieldKey: text(data.parentFieldKey) }),
|
|
100
|
+
...(text(data.parentRelation) === undefined ? {} : { parentRelation: text(data.parentRelation) }),
|
|
101
|
+
...(text(data.tagCollection) === undefined ? {} : { tagCollection: text(data.tagCollection) }),
|
|
102
|
+
...(text(data.eventPrefix) === undefined ? {} : { eventPrefix: text(data.eventPrefix) }),
|
|
103
|
+
fields: fields
|
|
104
|
+
.map((field, index) => fieldExport(field, index, origin))
|
|
105
|
+
.filter((field) => field !== null),
|
|
106
|
+
};
|
|
107
|
+
};
|
|
108
|
+
/**
|
|
109
|
+
* O id do documento É o nome da coleção da lista, tanto em `lookupDefinitions` quanto em
|
|
110
|
+
* `tagDefinitions` — a mesma convenção que os repositórios da aplicação usam ao mapeá-las.
|
|
111
|
+
*/
|
|
112
|
+
const listExport = (document) => ({
|
|
113
|
+
collection: document.id,
|
|
114
|
+
label: text(document.data.label) ?? document.id,
|
|
115
|
+
...(text(document.data.ownerPluginId) === undefined
|
|
116
|
+
? {}
|
|
117
|
+
: { ownerPluginId: text(document.data.ownerPluginId) }),
|
|
118
|
+
});
|
|
119
|
+
/**
|
|
120
|
+
* O documento, montado a partir do que foi lido. É PURA de propósito: o que ela produz é o contrato com
|
|
121
|
+
* o SDK, e um contrato que só pode ser exercitado com um projeto Firebase na frente é um contrato que
|
|
122
|
+
* ninguém confere.
|
|
123
|
+
*
|
|
124
|
+
* Uma tela DESLIGADA (`enabled: false`) fica de fora: a coleção dela pode até existir no banco, mas
|
|
125
|
+
* nada da plataforma a desenha, e trazê-la faria o catálogo afirmar que a entidade existe. Uma tela de
|
|
126
|
+
* extensão DESATIVADA continua: o documento dela segue gravado, e é ele que explica um campo que o
|
|
127
|
+
* autor vai encontrar.
|
|
128
|
+
*/
|
|
129
|
+
export const buildCatalogExport = (input) => ({
|
|
130
|
+
version: CATALOG_FORMAT_VERSION,
|
|
131
|
+
projectId: input.projectId,
|
|
132
|
+
tenantId: input.tenant.id,
|
|
133
|
+
tenantName: input.tenant.name,
|
|
134
|
+
exportedAt: input.exportedAt.toISOString(),
|
|
135
|
+
kernelVersion: input.kernelVersion,
|
|
136
|
+
consoleVersion: CLI_VERSION,
|
|
137
|
+
extensions: input.extensions
|
|
138
|
+
.map((one) => ({ id: one.id, name: one.name, version: one.version, active: one.active }))
|
|
139
|
+
.sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0)),
|
|
140
|
+
screens: input.screens
|
|
141
|
+
.filter((document) => document.data.enabled !== false)
|
|
142
|
+
.map(screenExport)
|
|
143
|
+
.sort((a, b) => (a.key < b.key ? -1 : a.key > b.key ? 1 : 0)),
|
|
144
|
+
lookups: input.lookups
|
|
145
|
+
.map(listExport)
|
|
146
|
+
.sort((a, b) => (a.collection < b.collection ? -1 : a.collection > b.collection ? 1 : 0)),
|
|
147
|
+
tagLists: input.tagLists
|
|
148
|
+
.map(listExport)
|
|
149
|
+
.sort((a, b) => (a.collection < b.collection ? -1 : a.collection > b.collection ? 1 : 0)),
|
|
150
|
+
});
|
|
151
|
+
const decode = (documents) => documents.map((document) => ({ id: documentId(document), data: decodeFields(document.fields) }));
|
|
152
|
+
const readSubcollection = async (projectId, tenantId, name) => decode(await listDocuments(projectId, `/tenants/${tenantId}/${name}`));
|
|
153
|
+
/**
|
|
154
|
+
* O kernel que está NO LUGAR nesta instalação, e não o que já foi publicado: o retrato descreve o
|
|
155
|
+
* contrato contra o qual o autor compila agora. Ausente, o SDK dirá que não sabe — o que é melhor que
|
|
156
|
+
* carimbar uma versão inventada.
|
|
157
|
+
*/
|
|
158
|
+
const installedKernelVersion = async (root) => {
|
|
159
|
+
const path = join(root, WORKSPACE_DIR, 'install', 'manifest.json');
|
|
160
|
+
if (!existsSync(path))
|
|
161
|
+
return 'unknown';
|
|
162
|
+
try {
|
|
163
|
+
const manifest = JSON.parse(await readFile(path, 'utf8'));
|
|
164
|
+
return typeof manifest.kernelVersion === 'string' ? manifest.kernelVersion : 'unknown';
|
|
165
|
+
}
|
|
166
|
+
catch {
|
|
167
|
+
return 'unknown';
|
|
168
|
+
}
|
|
169
|
+
};
|
|
170
|
+
/**
|
|
171
|
+
* O arquivo é gitignorado pela conciliação que o SDK faz do `.gitignore` de `extensions/` — este
|
|
172
|
+
* comando não escreve naquele arquivo, porque a política é de lá. O que ele faz é OLHAR: se a linha
|
|
173
|
+
* ainda não chegou (um diretório sincronizado por um kernel anterior), diz qual comando a traz, em vez
|
|
174
|
+
* de deixar o retrato pronto para ser commitado sem ninguém notar.
|
|
175
|
+
*/
|
|
176
|
+
const checkIgnored = async (directory) => {
|
|
177
|
+
const path = join(directory, '.gitignore');
|
|
178
|
+
if (!existsSync(path))
|
|
179
|
+
return;
|
|
180
|
+
const lines = (await readFile(path, 'utf8')).split('\n').map((line) => line.trim());
|
|
181
|
+
if (lines.includes(CATALOG_FILE) || lines.includes('*.json'))
|
|
182
|
+
return;
|
|
183
|
+
warn(`${relative(process.cwd(), path) || '.gitignore'} does not ignore ${CATALOG_FILE}.`);
|
|
184
|
+
info(' It carries screen and field names the office typed, which is tenant data and does not belong');
|
|
185
|
+
info(` in an extension repository. Run "npm run sync" in ${EXTENSIONS_DIR}/ to get the line, or add it`);
|
|
186
|
+
info(' by hand.');
|
|
187
|
+
};
|
|
188
|
+
const listAndStop = (tenants, projectId) => {
|
|
189
|
+
if (tenants.length === 0) {
|
|
190
|
+
warn(`${projectId} has no office yet, so there is no catalog to export.`);
|
|
191
|
+
info(' An office is created the first time somebody signs in to the application.');
|
|
192
|
+
blank();
|
|
193
|
+
return;
|
|
194
|
+
}
|
|
195
|
+
info(`${projectId} has ${tenants.length} office(s). Pick one:`);
|
|
196
|
+
bullets(tenants.map((tenant) => `${tenant.name} — microteia export-catalog --tenant ${tenant.id}`));
|
|
197
|
+
blank();
|
|
198
|
+
info('The export is per office because screens, lists and tag lists are per office: exporting the');
|
|
199
|
+
info('wrong one would put another office\'s vocabulary inside your extension work.');
|
|
200
|
+
blank();
|
|
201
|
+
};
|
|
202
|
+
export const exportCatalog = async (args) => {
|
|
203
|
+
const file = resolve(flagValue(args, 'file') ?? join(process.cwd(), ENVIRONMENT_FILE));
|
|
204
|
+
const root = dirname(file);
|
|
205
|
+
const environment = await readEnvironment(file);
|
|
206
|
+
const projectId = environment.firebase.projectId;
|
|
207
|
+
if (projectId === null) {
|
|
208
|
+
throw new Error('This installation is not connected yet. Run "microteia connect --project <id>".');
|
|
209
|
+
}
|
|
210
|
+
heading(`Exporting the installed catalog from ${projectId}`);
|
|
211
|
+
const tenants = await listTenants(projectId);
|
|
212
|
+
const wanted = flagValue(args, 'tenant');
|
|
213
|
+
if (wanted === undefined) {
|
|
214
|
+
listAndStop(tenants, projectId);
|
|
215
|
+
return;
|
|
216
|
+
}
|
|
217
|
+
const tenant = tenants.find((one) => one.id === wanted);
|
|
218
|
+
if (tenant === undefined) {
|
|
219
|
+
throw new Error(`${projectId} has no office with id "${wanted}".\n`
|
|
220
|
+
+ ` Offices in this project: ${tenants.length === 0 ? '(none)' : tenants.map((one) => `${one.name} (${one.id})`).join(', ')}`);
|
|
221
|
+
}
|
|
222
|
+
const output = flagValue(args, 'output');
|
|
223
|
+
const directory = join(root, EXTENSIONS_DIR);
|
|
224
|
+
if (output === undefined && !existsSync(directory)) {
|
|
225
|
+
throw new Error(`There is no ${EXTENSIONS_DIR}/ in this installation, and that is where the catalog belongs — it\n`
|
|
226
|
+
+ ' exists to answer the authoring tools, and nothing else reads it.\n'
|
|
227
|
+
+ ' Run "microteia plugin-init" first, or pass --output <path> to put it somewhere else.');
|
|
228
|
+
}
|
|
229
|
+
const target = output === undefined ? join(directory, CATALOG_FILE) : resolve(output);
|
|
230
|
+
step(1, 2, `Reading what ${tenant.name} has`);
|
|
231
|
+
const [screens, lookups, tagLists, extensions] = await Promise.all([
|
|
232
|
+
readSubcollection(projectId, tenant.id, 'screenDefinitions'),
|
|
233
|
+
readSubcollection(projectId, tenant.id, 'lookupDefinitions'),
|
|
234
|
+
readSubcollection(projectId, tenant.id, 'tagDefinitions'),
|
|
235
|
+
readInstalledExtensions(projectId, tenant.id),
|
|
236
|
+
]);
|
|
237
|
+
const document = buildCatalogExport({
|
|
238
|
+
projectId,
|
|
239
|
+
tenant,
|
|
240
|
+
kernelVersion: await installedKernelVersion(root),
|
|
241
|
+
exportedAt: new Date(),
|
|
242
|
+
screens,
|
|
243
|
+
lookups,
|
|
244
|
+
tagLists,
|
|
245
|
+
extensions,
|
|
246
|
+
});
|
|
247
|
+
step(2, 2, 'Writing the snapshot');
|
|
248
|
+
await mkdir(dirname(target), { recursive: true });
|
|
249
|
+
await writeFile(target, `${JSON.stringify(document, null, 2)}\n`);
|
|
250
|
+
const fromExtensions = document.screens.filter((screen) => screen.origin === 'plugin').length;
|
|
251
|
+
success(`${relative(process.cwd(), target) || CATALOG_FILE} — ${document.screens.length} screen(s), `
|
|
252
|
+
+ `${document.lookups.length} list(s), ${document.tagLists.length} tag list(s)`);
|
|
253
|
+
info(` ${fromExtensions} screen(s) come from the ${document.extensions.length} installed extension(s); `
|
|
254
|
+
+ `the rest the office built itself.`);
|
|
255
|
+
blank();
|
|
256
|
+
await checkIgnored(directory);
|
|
257
|
+
info('It is a SNAPSHOT, and it says so: the authoring tools report when it was taken and warn when it');
|
|
258
|
+
info('is old. Nothing keeps it in step with the office — run this again after screens change there.');
|
|
259
|
+
blank();
|
|
260
|
+
info('What it deliberately leaves out: records, the values inside a list of options or a tag list,');
|
|
261
|
+
info('members and permissions, workflows, scripts, actions, secrets and settings. It describes what');
|
|
262
|
+
info('the entities ARE, and nothing an office typed into them.');
|
|
263
|
+
blank();
|
|
264
|
+
};
|