@microteia/console 2.8.0 → 2.18.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.
Files changed (37) hide show
  1. package/CONSOLE-VERSIONS.md +97 -0
  2. package/README.md +1 -1
  3. package/lib/console/appliedFunctions.d.ts +79 -0
  4. package/lib/console/appliedFunctions.js +88 -0
  5. package/lib/console/backendResidue.d.ts +55 -0
  6. package/lib/console/backendResidue.js +53 -0
  7. package/lib/console/customFunctions.d.ts +51 -1
  8. package/lib/console/customFunctions.js +94 -1
  9. package/lib/console/deploy.js +32 -7
  10. package/lib/console/environment.d.ts +22 -6
  11. package/lib/console/environment.js +11 -0
  12. package/lib/console/exportCatalog.d.ts +124 -0
  13. package/lib/console/exportCatalog.js +264 -0
  14. package/lib/console/firestoreRest.d.ts +27 -0
  15. package/lib/console/firestoreRest.js +23 -0
  16. package/lib/console/functionGate.d.ts +198 -0
  17. package/lib/console/functionGate.js +400 -0
  18. package/lib/console/init.js +1 -0
  19. package/lib/console/installedExtensions.d.ts +37 -0
  20. package/lib/console/installedExtensions.js +37 -0
  21. package/lib/console/loginExtensions.d.ts +29 -0
  22. package/lib/console/loginExtensions.js +101 -0
  23. package/lib/console/provision.d.ts +26 -13
  24. package/lib/console/provision.js +312 -73
  25. package/lib/index.js +8 -0
  26. package/lib/kernel/backendShape.d.ts +27 -0
  27. package/lib/kernel/backendShape.js +38 -0
  28. package/lib/kernel/manifest.d.ts +16 -0
  29. package/lib/kernel/manifest.js +16 -0
  30. package/lib/shared/paths.d.ts +1 -1
  31. package/lib/shared/paths.js +1 -1
  32. package/lib/vendor/exampleSources.js +7 -3
  33. package/lib/vendor/installationDocs.js +18 -0
  34. package/lib/vendor/pack.js +3 -1
  35. package/lib/vendor/packageTemplates.d.ts +12 -1
  36. package/lib/vendor/packageTemplates.js +42 -2
  37. package/package.json +1 -1
@@ -15,6 +15,7 @@ import { installPath, readInstalledManifest } from './unpack.js';
15
15
  import { ensureOfficeEnv, ensureRootGitignore, officeEnvName, stageOfficeEnv } from './backendEnv.js';
16
16
  import { gatherCustomFunctions, readOfficeFunctions } from './customFunctions.js';
17
17
  import { readResolvedConfig, stampedConfigJs, writeResolvedConfig, } from './workspace.js';
18
+ import { STAMP_FILE, buildLoginExtensions, stampPath } from './loginExtensions.js';
18
19
  const COMPONENTS = ['rules', 'indexes', 'functions', 'hosting'];
19
20
  const selectedComponents = (args) => {
20
21
  const only = flagValue(args, 'only');
@@ -63,6 +64,20 @@ export const deploy = async (args) => {
63
64
  await writeResolvedConfig(root, stamped);
64
65
  await writeFile(join(install, 'hosting', 'firebase-config.js'), stampedConfigJs(stamped));
65
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);
66
81
  // O arquivo que o escritório edita é o da RAIZ; o que o `firebase-functions` lê tem de estar em
67
82
  // `install/functions/`, e por isso é uma cópia escrita agora. Antes a fonte era a cópia, e o deploy
68
83
  // seguinte a apagava junto com o pacote.
@@ -110,17 +125,27 @@ export const deploy = async (args) => {
110
125
  // comuns) e `extensions/<plugin>/functions/` (as de cada extensão). O `predeploy` do pacote compila
111
126
  // só o `custom/`, então o que não estiver aqui não sobe.
112
127
  const gathered = await gatherCustomFunctions(root, install, manifest.functions);
113
- // O que sobe aqui é a baseline MAIS as functions comuns do escritório (`functions/`). As de
114
- // `extensions/<plugin>/functions/` têm dono e sobem pelo provisionamento, no fim deste comando:
115
- // é o mesmo "empacotar não é deployar" que vale para as do kernel reivindicadas por extensão.
116
- const own = [...baseline, ...gathered.shared];
128
+ /**
129
+ * O que sobe aqui é a baseline MAIS as functions comuns que NÃO dizem a quem servem.
130
+ *
131
+ * As de `extensions/<plugin>/functions/` têm dono e sobem pelo provisionamento, no fim deste
132
+ * comando — é o mesmo "empacotar não é deployar" que vale para as do kernel reivindicadas por
133
+ * extensão. E a comum que DECLARA quem serve passou a ter a mesma condição, pela mesma razão: ela
134
+ * é devida enquanto alguma das extensões servidas estiver instalada, e subi-la aqui de qualquer
135
+ * forma faria o `--prune` apagá-la e este passo repô-la, indefinidamente.
136
+ *
137
+ * A comum sem declaração continua subindo sempre. É o comportamento anterior, e mantê-lo é o que
138
+ * impede esta versão de tirar do ar function de uma instalação que já existe.
139
+ */
140
+ const own = [...baseline, ...gathered.unserved];
117
141
  step(current, total, `Cloud Functions (${own.length}, one at a time)`);
118
142
  if (gathered.sources.length > 0) {
119
143
  success(`${gathered.functions.length} function(s) of your own, from `
120
144
  + `${gathered.sources.map((source) => `${source.label}/`).join(', ')}`);
121
- const owned = gathered.all.length - gathered.shared.length;
122
- if (owned > 0)
123
- info(` ${owned} of them belong to an extension and go up with "provision"`);
145
+ const provisioned = gathered.all.length - gathered.unserved.length;
146
+ if (provisioned > 0) {
147
+ info(` ${provisioned} of them go up with "provision", only where they are needed`);
148
+ }
124
149
  }
125
150
  if (Object.keys(gathered.dependencies.added).length > 0) {
126
151
  success(`${Object.keys(gathered.dependencies.added).length} npm dependency(ies) your functions declare: `
@@ -29,13 +29,14 @@ export interface Environment {
29
29
  deployed: string | null;
30
30
  };
31
31
  /**
32
- * Quais Cloud Functions de EXTENSÃO já foram deployadas neste projeto. Fica aqui, e não no banco,
33
- * porque é estado da instalação e não do escritório — e é o que permite ao `provision` deployar só o
34
- * delta e reconhecer o que ficou órfão depois de uma desinstalação.
32
+ * Quais Cloud Functions de EXTENSÃO já foram deployadas neste projeto, e de QUEM. Fica aqui, e não no
33
+ * banco, porque é estado da instalação e não do escritório — e é o que permite ao `provision`
34
+ * deployar só o delta e reconhecer o que ficou órfão depois de uma desinstalação.
35
35
  *
36
- * Uma lista num escalar separado por vírgula porque o leitor de YAML daqui entende mapas de dois
37
- * níveis e escalares, e trazer sequências para guardar meia dúzia de nomes seria pagar caro pela
38
- * generalidade.
36
+ * `nome@pluginId` separado por vírgula — ver `appliedFunctions.ts`, que o lê e o escreve. O escalar é
37
+ * porque o leitor de YAML daqui entende mapas de dois níveis e escalares, e trazer sequências para
38
+ * guardar meia dúzia de pares seria pagar caro pela generalidade. Uma entrada sem `@` é o que uma
39
+ * instalação anterior ao dono gravou, e continua valendo.
39
40
  */
40
41
  backend: {
41
42
  functions: string | null;
@@ -53,6 +54,21 @@ export interface Environment {
53
54
  */
54
55
  indexes: string | null;
55
56
  };
57
+ /**
58
+ * As extensões que a instalação carrega ANTES do login — os pacotes compilados que `deploy` carimba
59
+ * em `microteia-extensions.js`. É por aqui que um SSO chega à tela de entrada: ali não há escritório,
60
+ * logo não há como ler o que um escritório instalou.
61
+ *
62
+ * É uma lista de CAMINHOS, e é deliberado que seja explícita. Varrer `extensions/` poria no ar um
63
+ * rascunho de autoria — aquele diretório é onde se escreve, não o que roda. Carimbar é um ato, e
64
+ * escrever o caminho aqui é o ato.
65
+ *
66
+ * Escalar separado por vírgula, como `backend.functions`, pela mesma razão: o leitor de YAML daqui
67
+ * entende mapas de dois níveis e escalares.
68
+ */
69
+ login: {
70
+ extensions: string | null;
71
+ };
56
72
  }
57
73
  export declare const ENVIRONMENT_FILE = "microteia.yaml";
58
74
  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
+ };
@@ -20,8 +20,26 @@ export type FirestoreTypedValue = {
20
20
  stringValue: string;
21
21
  } | {
22
22
  timestampValue: string;
23
+ } | {
24
+ integerValue: string;
25
+ } | {
26
+ arrayValue: {
27
+ values: FirestoreTypedValue[];
28
+ };
29
+ } | {
30
+ mapValue: {
31
+ fields: Record<string, FirestoreTypedValue>;
32
+ };
23
33
  };
24
34
  export declare const asString: (value: string) => FirestoreTypedValue;
35
+ /**
36
+ * Inteiro como INTEIRO. A REST o transporta em string — é assim que ela representa um int64 sem perder
37
+ * precisão em JSON —, mas o campo gravado é numérico, e é isso que faz o outro lado do produto poder
38
+ * compará-lo em vez de tentar convertê-lo.
39
+ */
40
+ export declare const asInteger: (value: number) => FirestoreTypedValue;
41
+ export declare const asArray: (values: FirestoreTypedValue[]) => FirestoreTypedValue;
42
+ export declare const asMap: (fields: Record<string, FirestoreTypedValue>) => FirestoreTypedValue;
25
43
  /**
26
44
  * Data como DATA, e não como texto que se parece com uma.
27
45
  *
@@ -37,3 +55,12 @@ export declare const asTimestamp: (value: Date) => FirestoreTypedValue;
37
55
  * Console carimbar o estado sem conhecer (nem arriscar reescrever) o pacote inteiro que vive ao lado.
38
56
  */
39
57
  export declare const patchMapField: (projectId: string, path: string, field: string, entries: Record<string, FirestoreTypedValue>) => Promise<void>;
58
+ /**
59
+ * Grava um documento inteiro, com `updateMask` listando cada campo escrito.
60
+ *
61
+ * A máscara é explícita e não omitida de propósito: sem ela o comportamento de um PATCH depende de o
62
+ * documento existir, e o que se quer aqui é convergência — o que é escrito passa a valer, e nada de uma
63
+ * execução anterior sobra. Como todos os campos são escritos a cada vez, a máscara completa é também o
64
+ * que apaga o que deixou de ser verdade.
65
+ */
66
+ export declare const patchFields: (projectId: string, path: string, fields: Record<string, FirestoreTypedValue>) => Promise<void>;
@@ -108,6 +108,14 @@ export const listDocuments = async (projectId, path) => {
108
108
  };
109
109
  export const documentId = (document) => document.name.slice(document.name.lastIndexOf('/') + 1);
110
110
  export const asString = (value) => ({ stringValue: value });
111
+ /**
112
+ * Inteiro como INTEIRO. A REST o transporta em string — é assim que ela representa um int64 sem perder
113
+ * precisão em JSON —, mas o campo gravado é numérico, e é isso que faz o outro lado do produto poder
114
+ * compará-lo em vez de tentar convertê-lo.
115
+ */
116
+ export const asInteger = (value) => ({ integerValue: String(value) });
117
+ export const asArray = (values) => ({ arrayValue: { values } });
118
+ export const asMap = (fields) => ({ mapValue: { fields } });
111
119
  /**
112
120
  * Data como DATA, e não como texto que se parece com uma.
113
121
  *
@@ -129,3 +137,18 @@ export const patchMapField = async (projectId, path, field, entries) => {
129
137
  body: { fields: { [field]: { mapValue: { fields: entries } } } },
130
138
  });
131
139
  };
140
+ /**
141
+ * Grava um documento inteiro, com `updateMask` listando cada campo escrito.
142
+ *
143
+ * A máscara é explícita e não omitida de propósito: sem ela o comportamento de um PATCH depende de o
144
+ * documento existir, e o que se quer aqui é convergência — o que é escrito passa a valer, e nada de uma
145
+ * execução anterior sobra. Como todos os campos são escritos a cada vez, a máscara completa é também o
146
+ * que apaga o que deixou de ser verdade.
147
+ */
148
+ export const patchFields = async (projectId, path, fields) => {
149
+ await request(projectId, path, {
150
+ method: 'PATCH',
151
+ query: { 'updateMask.fieldPaths': Object.keys(fields) },
152
+ body: { fields },
153
+ });
154
+ };