@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.
@@ -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
@@ -1,4 +1,4 @@
1
- # Microteia Console 2.7.2
1
+ # Microteia Console 2.10.0
2
2
 
3
3
  Installs and updates Microteia in a Firebase project you own. It has no npm dependencies of its own —
4
4
  what it needs are command-line tools that are already on the machine, listed at the bottom.
@@ -5,8 +5,9 @@ import { run } from '../shared/run.js';
5
5
  import { blank, bullets, failure, heading, info, step, success, warn } from '../shared/ui.js';
6
6
  import { ENVIRONMENT_FILE, readEnvironment, writeEnvironment } from './environment.js';
7
7
  import { FIREBASE_BIN } from './firebaseCli.js';
8
- import { deployFunctionsOneByOne, reportFailures, reportNotices } from './deployFunctions.js';
9
- import { clearRegionOrphans } from './regionOrphans.js';
8
+ import { reportFailures, reportNotices } from './deployFunctions.js';
9
+ import { clearRegionOrphans, listDeployedFunctions } from './regionOrphans.js';
10
+ import { deployFunctionsThatChanged } from './functionsUpToDate.js';
10
11
  import { checkCallables, invokerGrantCommand } from './callableAccess.js';
11
12
  import { checkHostingServed, htmlIsRevalidated } from './hostingCheck.js';
12
13
  import { provisionBackend } from './provision.js';
@@ -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
- const report = await deployFunctionsOneByOne(own, {
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
+ };