@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.
- package/CONSOLE-VERSIONS.md +97 -0
- package/README.md +1 -1
- package/lib/console/appliedFunctions.d.ts +79 -0
- package/lib/console/appliedFunctions.js +88 -0
- package/lib/console/backendResidue.d.ts +55 -0
- package/lib/console/backendResidue.js +53 -0
- package/lib/console/customFunctions.d.ts +51 -1
- package/lib/console/customFunctions.js +94 -1
- package/lib/console/deploy.js +32 -7
- package/lib/console/environment.d.ts +22 -6
- 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/firestoreRest.d.ts +27 -0
- package/lib/console/firestoreRest.js +23 -0
- package/lib/console/functionGate.d.ts +198 -0
- package/lib/console/functionGate.js +400 -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 +26 -13
- package/lib/console/provision.js +312 -73
- package/lib/index.js +8 -0
- package/lib/kernel/backendShape.d.ts +27 -0
- package/lib/kernel/backendShape.js +38 -0
- 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 +18 -0
- package/lib/vendor/pack.js +3 -1
- package/lib/vendor/packageTemplates.d.ts +12 -1
- package/lib/vendor/packageTemplates.js +42 -2
- package/package.json +1 -1
package/lib/console/deploy.js
CHANGED
|
@@ -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
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
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
|
|
122
|
-
if (
|
|
123
|
-
info(` ${
|
|
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
|
|
33
|
-
* porque é estado da instalação e não do escritório — e é o que permite ao `provision`
|
|
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
|
-
*
|
|
37
|
-
*
|
|
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
|
+
};
|