@microteia/console 2.8.0 → 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,54 @@
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
+
25
73
  #### [2.8.0] — 2026-08-25
26
74
 
27
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.
package/README.md CHANGED
@@ -1,4 +1,4 @@
1
- # Microteia Console 2.8.0
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.
@@ -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.
@@ -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
+ };
@@ -31,6 +31,7 @@ export const init = async (args) => {
31
31
  firebase: { projectId: null, region: DEFAULT_FUNCTIONS_REGION },
32
32
  kernel: { version: null, deployed: null },
33
33
  backend: { functions: null, parameters: null, indexes: null },
34
+ login: { extensions: null },
34
35
  };
35
36
  await writeEnvironment(file, environment);
36
37
  // O diretório de trabalho guarda o pacote desempacotado e a configuração resolvida. Ele é local à
@@ -0,0 +1,37 @@
1
+ /**
2
+ * O CAMINHO ÚNICO até o que um escritório instalou.
3
+ *
4
+ * Estava dentro do `provision`, e é dele que a leitura nasceu: para saber quais Cloud Functions subir,
5
+ * o comando precisa do `manifest.backend` de cada extensão instalada. O `export-catalog` faz a mesma
6
+ * pergunta com outro propósito, e um segundo caminho até o mesmo documento seria uma segunda chance de
7
+ * discordar — a forma do documento, o que conta como instalada, o que fazer com a desativada.
8
+ *
9
+ * Então o caminho é este, e o `provision` também passa por aqui.
10
+ */
11
+ /** Um escritório do projeto. O `name` existe para que escolher um `--tenant` não seja adivinhar um id. */
12
+ export interface TenantSummary {
13
+ id: string;
14
+ name: string;
15
+ }
16
+ export interface InstalledExtensionRecord {
17
+ tenantId: string;
18
+ documentPath: string;
19
+ id: string;
20
+ name: string;
21
+ version: string;
22
+ /** Desligada no escritório: o pacote continua ali, e nada dele está no ar. */
23
+ active: boolean;
24
+ /** O manifesto cru da cópia instalada — é dele que sai `backend`, e nada aqui o interpreta. */
25
+ manifest: Record<string, unknown> | undefined;
26
+ /** O documento inteiro, decodificado, para quem precisa de mais que o manifesto. */
27
+ data: Record<string, unknown>;
28
+ }
29
+ export declare const listTenants: (projectId: string) => Promise<TenantSummary[]>;
30
+ /**
31
+ * As extensões instaladas num escritório.
32
+ *
33
+ * A DESATIVADA vem junto, marcada. Quem decide o que fazer com ela é quem chamou: para o `provision`
34
+ * ela não exige backend nenhum; para o catálogo ela ainda explica de onde veio uma tela que continua
35
+ * gravada no banco.
36
+ */
37
+ export declare const readInstalledExtensions: (projectId: string, tenantId: string) => Promise<InstalledExtensionRecord[]>;
@@ -0,0 +1,37 @@
1
+ import { decodeFields, documentId, listDocuments } from './firestoreRest.js';
2
+ export const listTenants = async (projectId) => {
3
+ const documents = await listDocuments(projectId, '/tenants');
4
+ return documents
5
+ .map((document) => {
6
+ const id = documentId(document);
7
+ const data = decodeFields(document.fields);
8
+ return { id, name: typeof data.name === 'string' && data.name !== '' ? data.name : id };
9
+ })
10
+ .sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
11
+ };
12
+ /**
13
+ * As extensões instaladas num escritório.
14
+ *
15
+ * A DESATIVADA vem junto, marcada. Quem decide o que fazer com ela é quem chamou: para o `provision`
16
+ * ela não exige backend nenhum; para o catálogo ela ainda explica de onde veio uma tela que continua
17
+ * gravada no banco.
18
+ */
19
+ export const readInstalledExtensions = async (projectId, tenantId) => {
20
+ const documents = await listDocuments(projectId, `/tenants/${tenantId}/installed_extensions`);
21
+ return documents.map((document) => {
22
+ const data = decodeFields(document.fields);
23
+ const pkg = data.package;
24
+ const manifest = pkg?.manifest;
25
+ const fallbackId = documentId(document);
26
+ return {
27
+ tenantId,
28
+ documentPath: `/tenants/${tenantId}/installed_extensions/${fallbackId}`,
29
+ id: String(manifest?.id ?? fallbackId),
30
+ name: String(manifest?.name ?? manifest?.id ?? fallbackId),
31
+ version: String(manifest?.version ?? '0.0.0'),
32
+ active: data.active !== false,
33
+ manifest,
34
+ data,
35
+ };
36
+ });
37
+ };
@@ -0,0 +1,29 @@
1
+ /**
2
+ * Este arquivo BLOQUEIA a página: é um `<script>` no `<head>`, antes do bundle. Um pacote gordo aqui
3
+ * custa em toda visita, inclusive na de quem nem vai entrar. O aviso vem antes de doer, e a recusa
4
+ * vem quando já dói para todo mundo.
5
+ */
6
+ export declare const STAMP_WARN_BYTES: number;
7
+ export declare const STAMP_MAX_BYTES: number;
8
+ export declare const STAMP_FILE = "microteia-extensions.js";
9
+ export interface LoginExtensionsResult {
10
+ /** O conteúdo do arquivo carimbado. Escrito sempre, mesmo vazio — ver `stampLoginExtensions`. */
11
+ contents: string;
12
+ /** `id@version` de cada pacote carimbado, na ordem declarada. */
13
+ stamped: string[];
14
+ /** O que quem instala precisa saber sem que o deploy pare. */
15
+ warnings: string[];
16
+ bytes: number;
17
+ }
18
+ export declare const parseExtensionPaths: (declared: string | null) => string[];
19
+ /**
20
+ * Monta o carimbo. `preLoginPoints` vem do manifesto do PACOTE do kernel instalado, e não de uma lista
21
+ * aqui: o Console é versionado separadamente, e uma cópia dela passaria a descrever o contrato de
22
+ * outra versão em silêncio. Ausente (pacote antigo), a checagem é pulada em vez de inventada.
23
+ */
24
+ export declare const buildLoginExtensions: (root: string, declared: string | null, preLoginPoints: string[] | undefined) => Promise<LoginExtensionsResult>;
25
+ /**
26
+ * Escreve o carimbo SEMPRE, inclusive vazio. Pular a escrita quando a lista está vazia deixaria o
27
+ * carimbo do deploy anterior no ar: tirar um pacote de `login.extensions` não o tiraria da tela.
28
+ */
29
+ export declare const stampPath: (install: string) => string;
@@ -0,0 +1,101 @@
1
+ import { readFile } from 'node:fs/promises';
2
+ import { join, resolve } from 'node:path';
3
+ /**
4
+ * Este arquivo BLOQUEIA a página: é um `<script>` no `<head>`, antes do bundle. Um pacote gordo aqui
5
+ * custa em toda visita, inclusive na de quem nem vai entrar. O aviso vem antes de doer, e a recusa
6
+ * vem quando já dói para todo mundo.
7
+ */
8
+ export const STAMP_WARN_BYTES = 256 * 1024;
9
+ export const STAMP_MAX_BYTES = 1024 * 1024;
10
+ export const STAMP_FILE = 'microteia-extensions.js';
11
+ export const parseExtensionPaths = (declared) => (declared ?? '')
12
+ .split(',')
13
+ .map((entry) => entry.trim())
14
+ .filter((entry) => entry !== '');
15
+ const asStampedPackage = (parsed, path) => {
16
+ const manifest = parsed?.manifest;
17
+ if (!manifest || typeof manifest !== 'object') {
18
+ throw new Error(`${path} is not an extension package: it has no "manifest".`);
19
+ }
20
+ const { id, version, contributes } = manifest;
21
+ if (typeof id !== 'string' || id === '') {
22
+ throw new Error(`${path} has no "manifest.id".`);
23
+ }
24
+ if (typeof version !== 'string' || version === '') {
25
+ throw new Error(`${path} has no "manifest.version".`);
26
+ }
27
+ if (!Array.isArray(contributes)) {
28
+ throw new Error(`${path} has no "manifest.contributes" array.`);
29
+ }
30
+ return parsed;
31
+ };
32
+ /**
33
+ * Monta o carimbo. `preLoginPoints` vem do manifesto do PACOTE do kernel instalado, e não de uma lista
34
+ * aqui: o Console é versionado separadamente, e uma cópia dela passaria a descrever o contrato de
35
+ * outra versão em silêncio. Ausente (pacote antigo), a checagem é pulada em vez de inventada.
36
+ */
37
+ export const buildLoginExtensions = async (root, declared, preLoginPoints) => {
38
+ const paths = parseExtensionPaths(declared);
39
+ const packages = [];
40
+ const stamped = [];
41
+ const warnings = [];
42
+ for (const path of paths) {
43
+ const absolute = resolve(root, path);
44
+ let parsed;
45
+ try {
46
+ parsed = JSON.parse(await readFile(absolute, 'utf8'));
47
+ }
48
+ catch (error) {
49
+ if (error.code === 'ENOENT') {
50
+ throw new Error(`login.extensions lists ${path}, which does not exist.\n`
51
+ + 'Compile the extension first ("microteia-plugin build" inside extensions/), '
52
+ + 'or remove the path from microteia.yaml.', { cause: error });
53
+ }
54
+ throw new Error(`${path} is not valid JSON.`, { cause: error });
55
+ }
56
+ const pkg = asStampedPackage(parsed, path);
57
+ if (preLoginPoints !== undefined) {
58
+ const points = pkg.manifest.contributes
59
+ .map((contribution) => contribution.point)
60
+ .filter((point) => typeof point === 'string');
61
+ const effective = points.filter((point) => preLoginPoints.includes(point));
62
+ if (effective.length === 0) {
63
+ throw new Error(`${path} (${pkg.manifest.id}) contributes nothing that works before sign-in, so stamping it `
64
+ + 'would load it with no effect.\n'
65
+ + ` Points that work before sign-in: ${preLoginPoints.join(', ')}.\n`
66
+ + ` This package contributes: ${points.length === 0 ? '(none)' : [...new Set(points)].join(', ')}.\n`
67
+ + ' To USE it inside the organization, install it from the Extensions screen instead.');
68
+ }
69
+ }
70
+ if (Array.isArray(pkg.views) && pkg.views.length > 0) {
71
+ warnings.push(`${pkg.manifest.id} ships ${pkg.views.length} view(s): stamped packages load untrusted, `
72
+ + 'so their code does not run before sign-in — only declarations apply');
73
+ }
74
+ const duplicate = packages.find((existing) => existing.manifest.id === pkg.manifest.id);
75
+ if (duplicate !== undefined) {
76
+ throw new Error(`login.extensions lists ${pkg.manifest.id} twice. The Kernel would ignore the second one, `
77
+ + 'so the file says something the installation does not do.');
78
+ }
79
+ packages.push(pkg);
80
+ stamped.push(`${pkg.manifest.id}@${pkg.manifest.version}`);
81
+ }
82
+ const contents = `// Generated by "microteia deploy". Do not edit by hand.\n`
83
+ + `window.__MICROTEIA_EXTENSIONS__ = ${JSON.stringify(packages)};\n`;
84
+ const bytes = Buffer.byteLength(contents, 'utf8');
85
+ if (bytes > STAMP_MAX_BYTES) {
86
+ throw new Error(`The stamped extensions would be ${Math.round(bytes / 1024)} KiB, over the `
87
+ + `${STAMP_MAX_BYTES / 1024} KiB limit. This file blocks the page on every visit, including `
88
+ + 'visits by people who never sign in.\n'
89
+ + ' Keep before-sign-in packages to what the sign-in screen needs, and install the rest.');
90
+ }
91
+ if (bytes > STAMP_WARN_BYTES) {
92
+ warnings.push(`the stamped extensions are ${Math.round(bytes / 1024)} KiB, and this file blocks the page on `
93
+ + 'every visit');
94
+ }
95
+ return { contents, stamped, warnings, bytes };
96
+ };
97
+ /**
98
+ * Escreve o carimbo SEMPRE, inclusive vazio. Pular a escrita quando a lista está vazia deixaria o
99
+ * carimbo do deploy anterior no ar: tirar um pacote de `login.extensions` não o tiraria da tela.
100
+ */
101
+ export const stampPath = (install) => join(install, 'hosting', STAMP_FILE);
@@ -13,7 +13,8 @@ import { deployFunctionsOneByOne, reportFailures, reportNotices } from './deploy
13
13
  import { clearRegionOrphans, listDeployedFunctions } from './regionOrphans.js';
14
14
  import { outdatedByStamp } from './functionsUpToDate.js';
15
15
  import { checkCallables, invokerGrantCommand } from './callableAccess.js';
16
- import { asString, asTimestamp, decodeFields, documentId, listDocuments, patchMapField } from './firestoreRest.js';
16
+ import { asString, asTimestamp, patchMapField } from './firestoreRest.js';
17
+ import { listTenants, readInstalledExtensions } from './installedExtensions.js';
17
18
  import { gatherCustomFunctions, readOfficeFunctions } from './customFunctions.js';
18
19
  /**
19
20
  * O que cada escritório do projeto instalou e exige do backend.
@@ -23,27 +24,22 @@ import { gatherCustomFunctions, readOfficeFunctions } from './customFunctions.js
23
24
  * explicação; não há injeção possível, porque o código é sempre o do pacote.
24
25
  */
25
26
  export const readInstalledClaims = async (projectId) => {
26
- const tenants = await listDocuments(projectId, '/tenants');
27
+ const tenants = await listTenants(projectId);
27
28
  const claims = [];
28
29
  for (const tenant of tenants) {
29
- const tenantId = documentId(tenant);
30
- const extensions = await listDocuments(projectId, `/tenants/${tenantId}/installed_extensions`);
31
- for (const document of extensions) {
32
- const data = decodeFields(document.fields);
33
- if (data.active === false)
30
+ for (const extension of await readInstalledExtensions(projectId, tenant.id)) {
31
+ if (!extension.active)
34
32
  continue;
35
- const pkg = data.package;
36
- const manifest = pkg?.manifest;
37
- const backend = manifest?.backend;
33
+ const backend = extension.manifest?.backend;
38
34
  if (!hasBackendRequirement(backend))
39
35
  continue;
40
- const state = data.backendState;
36
+ const state = extension.data.backendState;
41
37
  claims.push({
42
- tenantId,
43
- documentPath: `/tenants/${tenantId}/installed_extensions/${documentId(document)}`,
44
- pluginId: String(manifest?.id ?? documentId(document)),
45
- name: String(manifest?.name ?? manifest?.id ?? documentId(document)),
46
- version: String(manifest?.version ?? '0.0.0'),
38
+ tenantId: extension.tenantId,
39
+ documentPath: extension.documentPath,
40
+ pluginId: extension.id,
41
+ name: extension.name,
42
+ version: extension.version,
47
43
  backend: backend,
48
44
  applied: state?.fingerprint === backendFingerprint(backend),
49
45
  });
package/lib/index.js CHANGED
@@ -10,6 +10,7 @@ import { connect } from './console/connect.js';
10
10
  import { unpack } from './console/unpack.js';
11
11
  import { deploy } from './console/deploy.js';
12
12
  import { provision } from './console/provision.js';
13
+ import { exportCatalog } from './console/exportCatalog.js';
13
14
  import { pluginInit } from './console/pluginWorkspace.js';
14
15
  import { versionReport } from './console/versions.js';
15
16
  import { newerThan } from './console/channel.js';
@@ -49,6 +50,13 @@ const COMMANDS = [
49
50
  audience: 'office',
50
51
  execute: provision,
51
52
  },
53
+ {
54
+ name: 'export-catalog',
55
+ usage: 'microteia export-catalog --tenant <tenantId> [--output <path>]',
56
+ summary: 'Write what an office has — screens, lists, extensions — for the authoring tools to read.',
57
+ audience: 'office',
58
+ execute: exportCatalog,
59
+ },
52
60
  {
53
61
  name: 'plugin-init',
54
62
  usage: 'microteia plugin-init [--dir <path>] [--force]',
@@ -44,6 +44,16 @@ export interface KernelManifest {
44
44
  baselineIndexes: FirestoreIndexSpec[];
45
45
  /** Ausente nos pacotes anteriores a 7.51.0, que não conheciam a verificação de alcance. */
46
46
  callables?: string[];
47
+ /**
48
+ * Os pontos de extensão que fazem efeito ANTES do login. É contra esta lista que o `deploy` decide
49
+ * se um pacote carimbado tem o que fazer no boot — carimbar um que não contribua para nenhum deles
50
+ * o carregaria sem efeito, e quem o carimbou acreditaria que está no ar.
51
+ *
52
+ * Ela é DERIVADA do kernel e viaja no manifesto justamente para o Console não a conhecer de cor: o
53
+ * Console é versionado separadamente, e uma cópia aqui passaria a descrever o contrato de outra
54
+ * versão em silêncio. Ausente nos pacotes anteriores às extensões carimbadas.
55
+ */
56
+ preLoginPoints?: string[];
47
57
  backendParameters: BackendParameter[];
48
58
  manualPrerequisites: KernelPrerequisite[];
49
59
  /**
@@ -135,4 +145,10 @@ export declare const callableFunctionNames: (source: string) => string[];
135
145
  export declare const declaredParametersIn: (source: string) => BackendParameter[];
136
146
  /** Comparação de versão suficiente para `minimumCliVersion`: três números, sem pré-lançamento. */
137
147
  export declare const compareVersions: (left: string, right: string) => number;
148
+ /**
149
+ * Os pontos que valem antes do login, lidos de `domain/schema/plugin/preLoginPoints.ts`. Lista vazia é
150
+ * recusada em vez de aceita: um pacote sem nenhum ponto pré-login recusaria todo carimbo, e a causa
151
+ * (o parser não achou nada) não teria nada a ver com o sintoma.
152
+ */
153
+ export declare const parsePreLoginPoints: (source: string) => string[];
138
154
  export declare const parseKernelVersion: (source: string) => string;
@@ -137,6 +137,22 @@ export const compareVersions = (left, right) => {
137
137
  }
138
138
  return 0;
139
139
  };
140
+ /**
141
+ * Os pontos que valem antes do login, lidos de `domain/schema/plugin/preLoginPoints.ts`. Lista vazia é
142
+ * recusada em vez de aceita: um pacote sem nenhum ponto pré-login recusaria todo carimbo, e a causa
143
+ * (o parser não achou nada) não teria nada a ver com o sintoma.
144
+ */
145
+ export const parsePreLoginPoints = (source) => {
146
+ const block = source.match(/PRE_LOGIN_POINTS[^=]*=\s*\[([^\]]*)\]/);
147
+ if (block === null) {
148
+ throw new Error('PRE_LOGIN_POINTS not found in domain/schema/plugin/preLoginPoints.ts');
149
+ }
150
+ const points = [...block[1].matchAll(/'([^']+)'/g)].map((match) => match[1]);
151
+ if (points.length === 0) {
152
+ throw new Error('PRE_LOGIN_POINTS is empty in domain/schema/plugin/preLoginPoints.ts');
153
+ }
154
+ return points;
155
+ };
140
156
  export const parseKernelVersion = (source) => {
141
157
  const match = source.match(/KERNEL_VERSION\s*=\s*'([^']+)'/);
142
158
  if (match === null)
@@ -87,4 +87,4 @@ export declare const consoleRoot: string;
87
87
  * de código nunca chegou a quem instala. Este bloco fica sendo o que ele já era antes de crescer: a
88
88
  * política de semver da ferramenta, mais o registro do que motivou as versões até a `2.5.0`.
89
89
  */
90
- export declare const CLI_VERSION = "2.8.0";
90
+ export declare const CLI_VERSION = "2.10.0";
@@ -94,4 +94,4 @@ export const consoleRoot = resolve(here, '..', '..');
94
94
  * de código nunca chegou a quem instala. Este bloco fica sendo o que ele já era antes de crescer: a
95
95
  * política de semver da ferramenta, mais o registro do que motivou as versões até a `2.5.0`.
96
96
  */
97
- export const CLI_VERSION = '2.8.0';
97
+ export const CLI_VERSION = '2.10.0';
@@ -31,7 +31,9 @@ export const EXAMPLE_SOURCES = {
31
31
  * extensão tem a sua pasta — aqui elas moram num diretório comum, com um nível a mais.
32
32
  */
33
33
  export const rewriteImports = (source) => source.replace(/from '(\.[^']*)'/g, (match, specifier) => {
34
- if (/(^|\/)contributionBuilders$/.test(specifier) || specifier.includes('domain/')) {
34
+ if (/(^|\/)contributionBuilders$/.test(specifier)
35
+ || specifier.includes('domain/')
36
+ || specifier.includes('sdk/')) {
35
37
  return "from '@microteia/sdk'";
36
38
  }
37
39
  // `./views/<plugin>/Nome.tsx?raw` -> `./views/Nome.tsx?raw`
@@ -44,8 +46,10 @@ const HEADER = (id) => `// Example: a Microteia first-party extension, as SOURCE
44
46
  //
45
47
  // npx microteia-plugin build ${id}/plugin.ts
46
48
  //
47
- // The only difference from how it is written inside Microteia is the import path: there it reaches into
48
- // the kernel tree, here everything comes from '@microteia/sdk'.
49
+ // Two things differ from how you would write the same extension. The imports: inside Microteia they
50
+ // reach into the kernel tree, here everything comes from '@microteia/sdk'. And the view sources: these
51
+ // travel as TEXT and are merged by 'mergeViewSources', because the platform bundles them; you point at
52
+ // the files with 'sourceFile' and the compiler merges them for you. See AUTHORING.md, section 7.
49
53
 
50
54
  `;
51
55
  /**
@@ -94,6 +94,7 @@ const commandRows = () => [
94
94
  '| `microteia provision [--prune] [--all]` | Gives installed extensions the functions and indexes they ask for |',
95
95
  '| `microteia connect --project <id>` | Binds this installation to a Firebase project you own |',
96
96
  '| `microteia plugin-init` | Prepares `extensions/` for authoring in TypeScript |',
97
+ '| `microteia export-catalog --tenant <id>` | Writes what an office has — screens, lists, installed extensions — into `extensions/`, so the authoring tools can see it. Reads only; with no `--tenant` it lists the offices. The file is a snapshot and is git-ignored: it carries names the office typed |',
97
98
  ].join('\n');
98
99
  /**
99
100
  * O documento técnico. SOBREPOSTO a cada `unpack`, e por isso é onde o detalhe que acompanha a versão
@@ -145,6 +146,23 @@ it is what makes an upgrade trustworthy, and the reason your files are elsewhere
145
146
 
146
147
  ${commandRows()}
147
148
 
149
+ ### Signing in before there is an organization
150
+
151
+ The sign-in screen runs above the organization: there is no tenant there, so nothing an organization
152
+ installed can reach it. That is why \`login.extensions\` in \`microteia.yaml\` exists — it lists compiled
153
+ extension packages that \`deploy\` stamps into the site itself, and it is how a corporate SSO, or your own
154
+ mark on the sign-in screen, gets there.
155
+
156
+ Two things follow, and both are deliberate:
157
+
158
+ - **Nothing stamped runs JavaScript.** The packages load untrusted, so only declarations apply. That is
159
+ what lets you put an SSO live without anyone reviewing code.
160
+ - **Stamping is not installing.** A stamped package applies before sign-in only; inside the organization
161
+ what applies is what the organization installed, from the Extensions screen. To have both, do both.
162
+
163
+ \`deploy\` refuses a package that contributes to nothing which works before sign-in, rather than stamping
164
+ it: it would load with no effect, and you would believe it was live.
165
+
148
166
  \`unpack\` and \`deploy\` are separate so that taking a new version does not oblige you to publish
149
167
  everything, and so that there is a moment between the two to fill in a setting the new version asks for.
150
168
 
@@ -4,7 +4,7 @@ import { flagValue, hasFlag } from '../shared/args.js';
4
4
  import { repoRoot } from '../shared/paths.js';
5
5
  import { run } from '../shared/run.js';
6
6
  import { blank, bullets, heading, info, step, success } from '../shared/ui.js';
7
- import { DEFAULT_FUNCTIONS_REGION, KERNEL_COMPONENTS, KERNEL_DISTRIBUTION_ID, MANUAL_PREREQUISITES, MINIMUM_CLI_VERSION, REQUIRED_NODE_VERSION, callableFunctionNames, declaredParametersIn, exportedFunctionNames, parseKernelVersion, } from '../kernel/manifest.js';
7
+ import { DEFAULT_FUNCTIONS_REGION, KERNEL_COMPONENTS, KERNEL_DISTRIBUTION_ID, MANUAL_PREREQUISITES, MINIMUM_CLI_VERSION, REQUIRED_NODE_VERSION, callableFunctionNames, declaredParametersIn, exportedFunctionNames, parseKernelVersion, parsePreLoginPoints, } from '../kernel/manifest.js';
8
8
  import { digestOfFile } from '../shared/digest.js';
9
9
  import { kernelTarballName } from '../kernel/channel.js';
10
10
  import { baselineFunctionsOf, baselineIndexesOf, claimProblems, claimsFromTemplates, } from '../kernel/backendShape.js';
@@ -123,6 +123,7 @@ export const pack = async (args) => {
123
123
  */
124
124
  const changelog = await readChangelog(root, KERNEL_CHANGELOG, version);
125
125
  const kernelVersion = parseKernelVersion(await readFile(join(root, 'src/domain/schema/plugin/kernelVersion.ts'), 'utf8'));
126
+ const preLoginPoints = parsePreLoginPoints(await readFile(join(root, 'src/domain/schema/plugin/preLoginPoints.ts'), 'utf8'));
126
127
  const functionNames = exportedFunctionNames(await readFile(join(root, 'functions/src/index.ts'), 'utf8'));
127
128
  const scan = await scanBackendSources(join(root, 'functions/src'));
128
129
  const backendParameters = scan.parameters;
@@ -225,6 +226,7 @@ export const pack = async (args) => {
225
226
  baselineFunctions,
226
227
  baselineIndexes,
227
228
  callables,
229
+ preLoginPoints,
228
230
  backendParameters,
229
231
  manualPrerequisites: MANUAL_PREREQUISITES,
230
232
  sdk,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@microteia/console",
3
- "version": "2.8.0",
3
+ "version": "2.10.0",
4
4
  "description": "Install and update Microteia in a Firebase project you own.",
5
5
  "license": "UNLICENSED",
6
6
  "type": "module",