@microteia/console 2.38.0 → 2.42.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.
@@ -1,5 +1,6 @@
1
1
  import { existsSync } from 'node:fs';
2
2
  import { mkdir, readFile, writeFile } from 'node:fs/promises';
3
+ import { createRequire } from 'node:module';
3
4
  import { dirname, join, relative, resolve } from 'node:path';
4
5
  import { flagValue } from '../shared/args.js';
5
6
  import { CLI_VERSION } from '../shared/paths.js';
@@ -21,7 +22,11 @@ import { WORKSPACE_DIR } from './workspace.js';
21
22
  * A saída NÃO foi ligar o SDK ao Firestore. `microteia-plugin` é offline por desenho, e arrastar
22
23
  * credencial para dentro da ferramenta de autoria trocaria uma cegueira por um problema pior. Quem já
23
24
  * 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
+ * guardado. O Console LÊ; quem monta o arquivo é o escritor do SDK instalado (ver `loadCatalogWriter`).
26
+ *
27
+ * O token do `gcloud` é credencial de PROJETO: não passa pelas Security Rules, e por isso exige papel
28
+ * no Firebase inteiro. Quem administra só o escritório tira o mesmo retrato pela aplicação (Extensões →
29
+ * Exportar catálogo), lendo como membro, sob as regras.
25
30
  *
26
31
  * Três decisões que não são preferência:
27
32
  *
@@ -34,313 +39,50 @@ import { WORKSPACE_DIR } from './workspace.js';
34
39
  * adivinhação escreveria o vocabulário de um dentro do trabalho de outro. Sem a opção, o comando
35
40
  * LISTA os que existem.
36
41
  */
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
42
  /**
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.
43
+ * O nome do arquivo que o SDK procura. O valor de verdade vem do SDK carregado; este é só o que se diz
44
+ * ao autor ANTES de o SDK ter sido encontrado.
42
45
  */
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'];
46
+ const CATALOG_FILE_HINT = 'installed-catalog.json';
49
47
  /**
50
- * A superfície exportada de uma cópia instalada: os ids de `manifest.exports`, cada um casado com o
51
- * artefato do pacote que o atende, para ganhar tipo e nome.
48
+ * O escritor do retrato, carregado do SDK que o escritório tem em `extensions/`.
52
49
  *
53
- * O Console não alcança `src/` — logo não há `MarketplacePlugin` tipado aqui, e a leitura é do
54
- * documento cru, como todo o resto deste arquivo. Um export que não casa com nada continua na lista
55
- * como `unknown`: a extensão está declarando algo que não entrega, e esconder isso do autor apenas
56
- * adia a descoberta para a primeira execução.
57
- */
58
- const exportedArtifacts = (extension) => {
59
- const declared = extension.manifest?.exports;
60
- if (!Array.isArray(declared))
61
- return [];
62
- const pkg = asRecord(extension.data.package) ?? {};
63
- const named = new Map();
64
- const collect = (raw, kind) => {
65
- for (const one of Array.isArray(raw) ? raw : []) {
66
- const record = asRecord(one);
67
- const id = text(record?.templateId);
68
- if (id === undefined || named.has(id))
69
- continue;
70
- named.set(id, { id, kind, name: text(record?.name) ?? text(record?.title) ?? id });
71
- }
72
- };
73
- collect(pkg.workflows, 'workflow');
74
- collect(pkg.actions, 'action');
75
- collect(pkg.scripts, 'script');
76
- return declared
77
- .filter((id) => typeof id === 'string' && id !== '')
78
- .map((id) => named.get(id) ?? { id, kind: 'unknown', name: id })
79
- .sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
80
- };
81
- const NAV_GROUP_POINT = 'ui.nav.group';
82
- const MESSAGES_POINT = 'i18n.messages';
83
- const DEFAULT_LOCALE = 'en';
84
- const MESSAGE_REFERENCE = /^@([a-z0-9.-]+)\/(.+)$/;
85
- /**
86
- * Os grupos da barra lateral de uma cópia instalada, com o nome resolvido pelos `i18n.messages` do
87
- * próprio manifesto. É a mesma leitura que `declaredNavGroupsOf` faz no kernel, repetida porque o
88
- * Console não alcança `src/` — e é o teste que atravessa a fronteira que confere as duas.
89
- */
90
- const navGroupsOf = (extension) => {
91
- const contributes = Array.isArray(extension.manifest?.contributes) ? extension.manifest.contributes : [];
92
- const contributions = contributes.map(asRecord).filter((one) => one !== undefined);
93
- const bundles = contributions
94
- .filter((one) => one.point === MESSAGES_POINT)
95
- .map((one) => asRecord(one.metadata))
96
- .filter((one) => one !== undefined);
97
- const translationsOf = (label) => {
98
- const match = MESSAGE_REFERENCE.exec(label);
99
- if (!match)
100
- return {};
101
- const found = {};
102
- for (const bundle of bundles) {
103
- const locale = text(bundle.locale);
104
- const message = asRecord(bundle.messages)?.[match[2]];
105
- if (bundle.namespace !== match[1] || locale === undefined || typeof message !== 'string' || message.trim() === '')
106
- continue;
107
- if (found[locale] === undefined)
108
- found[locale] = message;
109
- }
110
- return found;
111
- };
112
- return contributions
113
- .filter((one) => one.point === NAV_GROUP_POINT)
114
- .flatMap((one) => {
115
- const metadata = asRecord(one.metadata) ?? {};
116
- const key = text(metadata.key)?.trim();
117
- if (key === undefined)
118
- return [];
119
- const raw = text(metadata.label) ?? key;
120
- const translations = translationsOf(raw);
121
- return [{
122
- key,
123
- label: translations[DEFAULT_LOCALE] ?? Object.values(translations)[0] ?? raw,
124
- translations,
125
- order: typeof metadata.order === 'number' ? metadata.order : 0,
126
- }];
127
- });
128
- };
129
- const DOCUMENT_TEMPLATE_POINT = 'document.template';
130
- const documentParameterOf = (raw) => {
131
- const record = asRecord(raw);
132
- const key = text(record?.key);
133
- const type = text(record?.type);
134
- if (record === undefined || key === undefined || type === undefined)
135
- return [];
136
- const entityType = text(record.entityType);
137
- return [{
138
- key,
139
- type,
140
- ...(record.required === true ? { required: true } : {}),
141
- ...(record.list === true ? { list: true } : {}),
142
- ...(entityType === undefined ? {} : { entityType }),
143
- }];
144
- };
145
- /**
146
- * Os modelos de documento de uma cópia instalada, como contrato. É a mesma leitura que
147
- * `documentTemplateContractOf` faz no kernel, repetida porque o Console não alcança `src/` — e é o
148
- * teste que atravessa a fronteira que confere as duas.
149
- */
150
- const documentTemplatesOf = (extension) => {
151
- const contributes = Array.isArray(extension.manifest?.contributes) ? extension.manifest.contributes : [];
152
- return contributes
153
- .map(asRecord)
154
- .filter((one) => one?.point === DOCUMENT_TEMPLATE_POINT)
155
- .flatMap((one) => {
156
- const metadata = asRecord(one.metadata);
157
- const key = text(metadata?.key);
158
- if (metadata === undefined || key === undefined)
159
- return [];
160
- return [{
161
- key,
162
- source: metadata.source === 'payload' ? 'payload' : 'record',
163
- parameters: (Array.isArray(metadata.parameters) ? metadata.parameters : []).flatMap(documentParameterOf),
164
- }];
165
- })
166
- .sort((a, b) => (a.key < b.key ? -1 : a.key > b.key ? 1 : 0));
167
- };
168
- const FIELD_PRESET_POINT = 'data.field.preset';
169
- /**
170
- * Os presets de campo de uma cópia instalada. É a mesma leitura que `declaredFieldPresets` faz no
171
- * kernel — chave e rótulo obrigatórios, e as partes pelo critério do editor de campo —, repetida porque
172
- * o Console não alcança `src/`; o teste que atravessa a fronteira confere as duas.
173
- */
174
- const fieldPresetsOf = (extension) => {
175
- const contributes = Array.isArray(extension.manifest?.contributes) ? extension.manifest.contributes : [];
176
- return contributes
177
- .map(asRecord)
178
- .filter((one) => one?.point === FIELD_PRESET_POINT)
179
- .flatMap((one) => {
180
- const metadata = asRecord(one.metadata);
181
- const key = text(metadata?.key);
182
- if (metadata === undefined || key === undefined || text(metadata.label) === undefined)
183
- return [];
184
- return [{
185
- key,
186
- slots: [
187
- ...(metadata.mask ? ['mask'] : []),
188
- ...(Array.isArray(metadata.format) && metadata.format.length > 0 ? ['format'] : []),
189
- ...(metadata.validate ? ['validate'] : []),
190
- ],
191
- }];
192
- })
193
- .sort((a, b) => (a.key < b.key ? -1 : a.key > b.key ? 1 : 0));
194
- };
195
- /**
196
- * A mesma dedução que a aplicação faz ao ler a tela (`screenDefinitionMapper.ts`): o `origin` gravado
197
- * quando ele existe, senão `plugin` para quem tem dono e `design` para o que o escritório criou. Ela é
198
- * repetida aqui porque o Console não alcança `src/` — e é conferida contra a instalação real por teste.
50
+ * O Console trazia uma CÓPIA do escritor, e o leitor morava no SDK: os dois lados eram compilados
51
+ * separadamente e casados só por teste, e o Console é distribuído à parte do kernel. Carregar o
52
+ * escritor de onde mora o leitor faz o formato ter um dono só — e é a mesma função que a aplicação usa
53
+ * no botão Exportar catálogo da tela de Extensões.
54
+ *
55
+ * O SDK de um kernel anterior a esse escritor não o tem, e aí o comando RECUSA dizendo como trazê-lo,
56
+ * em vez de escrever com uma cópia que já não é conferida contra nada.
199
57
  */
200
- const originOf = (data, fallback) => {
201
- const declared = text(data.origin);
202
- if (declared !== undefined && FIELD_ORIGINS.includes(declared))
203
- return declared;
204
- return text(data.ownerPluginId) !== undefined ? 'plugin' : fallback;
205
- };
206
- const fieldExport = (raw, index, screenOrigin) => {
207
- const field = asRecord(raw);
208
- if (field === undefined)
209
- return null;
210
- const key = text(field.key);
211
- if (key === undefined)
212
- return null;
213
- const logic = asRecord(field.logic);
214
- const compute = text(logic?.compute);
215
- return {
216
- key,
217
- label: text(field.label) ?? key,
218
- type: text(field.type) ?? 'text',
219
- required: field.required === true,
220
- order: typeof field.order === 'number' ? field.order : index,
221
- ...(field.multi === true ? { multi: true } : {}),
222
- ...(text(field.refEntity) === undefined ? {} : { refEntity: text(field.refEntity) }),
223
- ...(text(field.refDenormKey) === undefined ? {} : { refDenormKey: text(field.refDenormKey) }),
224
- ...(text(field.lookupCollection) === undefined ? {} : { lookupCollection: text(field.lookupCollection) }),
225
- ...(text(field.section) === undefined ? {} : { section: text(field.section) }),
226
- origin: originOf(field, screenOrigin),
227
- ...(text(field.ownerPluginId) === undefined ? {} : { ownerPluginId: text(field.ownerPluginId) }),
228
- ...(asRecord(field.aggregation) === undefined ? {} : { aggregation: asRecord(field.aggregation) }),
229
- ...(compute === undefined ? {} : { compute }),
230
- ...(field.unique === true ? { unique: true } : {}),
231
- };
232
- };
233
- const normalizeExport = (raw) => {
234
- const normalize = asRecord(raw);
235
- if (normalize === undefined)
236
- return undefined;
237
- const formatPreset = text(normalize.formatPreset);
238
- const picked = {
239
- ...(typeof normalize.trim === 'boolean' ? { trim: normalize.trim } : {}),
240
- ...(typeof normalize.caseInsensitive === 'boolean' ? { caseInsensitive: normalize.caseInsensitive } : {}),
241
- ...(formatPreset === undefined ? {} : { formatPreset }),
242
- };
243
- return Object.keys(picked).length > 0 ? picked : undefined;
244
- };
245
- /** A chave como o kernel a grava, sem o que é apresentação. Sem nome ou sem campo, não é chave. */
246
- const keyExport = (raw) => {
247
- const key = asRecord(raw);
248
- const name = text(key?.name);
249
- const kind = text(key?.kind);
250
- const fields = (Array.isArray(key?.fields) ? key.fields : []).filter((one) => typeof one === 'string' && one !== '');
251
- if (key === undefined || name === undefined || kind === undefined || fields.length === 0)
252
- return null;
253
- const normalize = normalizeExport(key.normalize);
254
- return {
255
- name,
256
- kind,
257
- fields,
258
- ...(text(key.scope) === undefined ? {} : { scope: text(key.scope) }),
259
- ...(normalize === undefined ? {} : { normalize }),
260
- ...(typeof key.sparse === 'boolean' ? { sparse: key.sparse } : {}),
261
- ...(text(key.ownerPluginId) === undefined ? {} : { ownerPluginId: text(key.ownerPluginId) }),
262
- };
263
- };
264
- const screenExport = (document) => {
265
- const data = document.data;
266
- const origin = originOf(data, 'design');
267
- const fields = Array.isArray(data.fields) ? data.fields : [];
58
+ export const loadCatalogWriter = (directory) => {
59
+ if (!existsSync(join(directory, 'node_modules', '@microteia', 'sdk'))) {
60
+ throw new Error(`The authoring SDK is not installed in ${EXTENSIONS_DIR}/, and it is the SDK that writes the catalog —\n`
61
+ + ' the same one that reads it. Run "microteia plugin-init" (or "npm install" inside it), then run this again.');
62
+ }
63
+ const sdk = createRequire(join(directory, 'package.json'))('@microteia/sdk');
64
+ const build = sdk.buildInstalledCatalog;
65
+ const serialize = sdk.serializeInstalledCatalog;
66
+ const file = sdk.INSTALLED_CATALOG_FILE;
67
+ if (typeof build !== 'function' || typeof serialize !== 'function' || typeof file !== 'string') {
68
+ throw new Error(`The authoring SDK in ${EXTENSIONS_DIR}/ is older than the catalog writer this Console relies on.\n`
69
+ + ' Install a newer kernel ("microteia unpack --version latest"), which brings an SDK that writes it.\n'
70
+ + ' Without access to the Firebase project, "Export catalog" on the Extensions screen of the\n'
71
+ + ' application writes the same file, once the installation runs a kernel that has it.');
72
+ }
268
73
  return {
269
- key: document.id,
270
- ...(text(data.sourceId) === undefined ? {} : { sourceId: text(data.sourceId) }),
271
- ...(text(data.dataCollection) === undefined ? {} : { collection: text(data.dataCollection) }),
272
- titleSingular: text(data.titleSingular) ?? document.id,
273
- ...(text(data.titlePlural) === undefined ? {} : { titlePlural: text(data.titlePlural) }),
274
- titleFieldKey: text(data.titleFieldKey) ?? '',
275
- origin,
276
- ...(text(data.ownerPluginId) === undefined ? {} : { ownerPluginId: text(data.ownerPluginId) }),
277
- ...(text(data.parentEntity) === undefined ? {} : { parentEntity: text(data.parentEntity) }),
278
- ...(text(data.parentFieldKey) === undefined ? {} : { parentFieldKey: text(data.parentFieldKey) }),
279
- ...(text(data.parentRelation) === undefined ? {} : { parentRelation: text(data.parentRelation) }),
280
- ...(text(data.tagCollection) === undefined ? {} : { tagCollection: text(data.tagCollection) }),
281
- ...(text(data.eventPrefix) === undefined ? {} : { eventPrefix: text(data.eventPrefix) }),
282
- keys: (Array.isArray(data.keys) ? data.keys : [])
283
- .map(keyExport)
284
- .filter((key) => key !== null),
285
- fields: fields
286
- .map((field, index) => fieldExport(field, index, origin))
287
- .filter((field) => field !== null),
74
+ file,
75
+ build: build,
76
+ serialize: serialize,
288
77
  };
289
78
  };
290
- /**
291
- * O id do documento É o nome da coleção da lista, tanto em `lookupDefinitions` quanto em
292
- * `tagDefinitions` — a mesma convenção que os repositórios da aplicação usam ao mapeá-las.
293
- */
294
- const listExport = (document) => ({
295
- collection: document.id,
296
- label: text(document.data.label) ?? document.id,
297
- ...(text(document.data.ownerPluginId) === undefined
298
- ? {}
299
- : { ownerPluginId: text(document.data.ownerPluginId) }),
300
- });
301
- /**
302
- * O documento, montado a partir do que foi lido. É PURA de propósito: o que ela produz é o contrato com
303
- * o SDK, e um contrato que só pode ser exercitado com um projeto Firebase na frente é um contrato que
304
- * ninguém confere.
305
- *
306
- * Uma tela DESLIGADA (`enabled: false`) fica de fora: a coleção dela pode até existir no banco, mas
307
- * nada da plataforma a desenha, e trazê-la faria o catálogo afirmar que a entidade existe. Uma tela de
308
- * extensão DESATIVADA continua: o documento dela segue gravado, e é ele que explica um campo que o
309
- * autor vai encontrar.
310
- */
311
- export const buildCatalogExport = (input) => ({
312
- version: CATALOG_FORMAT_VERSION,
313
- projectId: input.projectId,
314
- tenantId: input.tenant.id,
315
- tenantName: input.tenant.name,
316
- exportedAt: input.exportedAt.toISOString(),
317
- kernelVersion: input.kernelVersion,
318
- consoleVersion: CLI_VERSION,
319
- extensions: input.extensions
320
- .map((one) => ({
321
- id: one.id,
322
- name: one.name,
323
- version: one.version,
324
- active: one.active,
325
- exports: exportedArtifacts(one),
326
- navGroups: navGroupsOf(one),
327
- documentTemplates: documentTemplatesOf(one),
328
- fieldPresets: fieldPresetsOf(one),
329
- }))
330
- .sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0)),
331
- screens: input.screens
332
- .filter((document) => document.data.enabled !== false)
333
- .map(screenExport)
334
- .sort((a, b) => (a.key < b.key ? -1 : a.key > b.key ? 1 : 0)),
335
- lookups: input.lookups
336
- .map(listExport)
337
- .sort((a, b) => (a.collection < b.collection ? -1 : a.collection > b.collection ? 1 : 0)),
338
- tagLists: input.tagLists
339
- .map(listExport)
340
- .sort((a, b) => (a.collection < b.collection ? -1 : a.collection > b.collection ? 1 : 0)),
341
- });
342
79
  const decode = (documents) => documents.map((document) => ({ id: documentId(document), data: decodeFields(document.fields) }));
343
80
  const readSubcollection = async (projectId, tenantId, name) => decode(await listDocuments(projectId, `/tenants/${tenantId}/${name}`));
81
+ /** A cópia instalada como está gravada: o escritor lê o manifesto de dentro de `package`, como o kernel. */
82
+ const storedExtension = (extension) => ({
83
+ id: extension.documentId,
84
+ data: extension.data,
85
+ });
344
86
  /**
345
87
  * O kernel que está NO LUGAR nesta instalação, e não o que já foi publicado: o retrato descreve o
346
88
  * contrato contra o qual o autor compila agora. Ausente, o SDK dirá que não sabe — o que é melhor que
@@ -364,14 +106,14 @@ const installedKernelVersion = async (root) => {
364
106
  * ainda não chegou (um diretório sincronizado por um kernel anterior), diz qual comando a traz, em vez
365
107
  * de deixar o retrato pronto para ser commitado sem ninguém notar.
366
108
  */
367
- const checkIgnored = async (directory) => {
109
+ const checkIgnored = async (directory, catalogFile) => {
368
110
  const path = join(directory, '.gitignore');
369
111
  if (!existsSync(path))
370
112
  return;
371
113
  const lines = (await readFile(path, 'utf8')).split('\n').map((line) => line.trim());
372
- if (lines.includes(CATALOG_FILE) || lines.includes('*.json'))
114
+ if (lines.includes(catalogFile) || lines.includes('*.json'))
373
115
  return;
374
- warn(`${relative(process.cwd(), path) || '.gitignore'} does not ignore ${CATALOG_FILE}.`);
116
+ warn(`${relative(process.cwd(), path) || '.gitignore'} does not ignore ${catalogFile}.`);
375
117
  info(' It carries screen and field names the office typed, which is tenant data and does not belong');
376
118
  info(` in an extension repository. Run "npm run sync" in ${EXTENSIONS_DIR}/ to get the line, or add it`);
377
119
  info(' by hand.');
@@ -412,12 +154,14 @@ export const exportCatalog = async (args) => {
412
154
  }
413
155
  const output = flagValue(args, 'output');
414
156
  const directory = join(root, EXTENSIONS_DIR);
415
- if (output === undefined && !existsSync(directory)) {
157
+ // Mesmo com `--output`: o escritor mora no SDK de `extensions/`, e sem ele não há o que escrever.
158
+ if (!existsSync(directory)) {
416
159
  throw new Error(`There is no ${EXTENSIONS_DIR}/ in this installation, and that is where the catalog belongs — it\n`
417
- + ' exists to answer the authoring tools, and nothing else reads it.\n'
418
- + ' Run "microteia plugin-init" first, or pass --output <path> to put it somewhere else.');
160
+ + ` exists to answer the authoring tools, and the SDK there is what writes ${CATALOG_FILE_HINT}.\n`
161
+ + ' Run "microteia plugin-init" first.');
419
162
  }
420
- const target = output === undefined ? join(directory, CATALOG_FILE) : resolve(output);
163
+ const writer = loadCatalogWriter(directory);
164
+ const target = output === undefined ? join(directory, writer.file) : resolve(output);
421
165
  step(1, 2, `Reading what ${tenant.name} has`);
422
166
  const [screens, lookups, tagLists, extensions] = await Promise.all([
423
167
  readSubcollection(projectId, tenant.id, 'screenDefinitions'),
@@ -425,21 +169,22 @@ export const exportCatalog = async (args) => {
425
169
  readSubcollection(projectId, tenant.id, 'tagDefinitions'),
426
170
  readInstalledExtensions(projectId, tenant.id),
427
171
  ]);
428
- const document = buildCatalogExport({
172
+ const document = writer.build({
429
173
  projectId,
430
174
  tenant,
431
175
  kernelVersion: await installedKernelVersion(root),
432
176
  exportedAt: new Date(),
177
+ exporter: { tool: 'console', version: CLI_VERSION },
433
178
  screens,
434
179
  lookups,
435
180
  tagLists,
436
- extensions,
181
+ extensions: extensions.map(storedExtension),
437
182
  });
438
183
  step(2, 2, 'Writing the snapshot');
439
184
  await mkdir(dirname(target), { recursive: true });
440
- await writeFile(target, `${JSON.stringify(document, null, 2)}\n`);
185
+ await writeFile(target, writer.serialize(document));
441
186
  const fromExtensions = document.screens.filter((screen) => screen.origin === 'plugin').length;
442
- success(`${relative(process.cwd(), target) || CATALOG_FILE} — ${document.screens.length} screen(s), `
187
+ success(`${relative(process.cwd(), target) || writer.file} — ${document.screens.length} screen(s), `
443
188
  + `${document.lookups.length} list(s), ${document.tagLists.length} tag list(s)`);
444
189
  info(` ${fromExtensions} screen(s) come from the ${document.extensions.length} installed extension(s); `
445
190
  + `the rest the office built itself.`);
@@ -450,7 +195,7 @@ export const exportCatalog = async (args) => {
450
195
  const templates = document.extensions.reduce((total, one) => total + one.documentTemplates.length, 0);
451
196
  info(` ${templates} document template(s) are declared, with the shape of their parameters, for other extensions to generate.`);
452
197
  blank();
453
- await checkIgnored(directory);
198
+ await checkIgnored(directory, writer.file);
454
199
  info('It is a SNAPSHOT, and it says so: the authoring tools report when it was taken and warn when it');
455
200
  info('is old. Nothing keeps it in step with the office — run this again after screens change there.');
456
201
  blank();
@@ -20,6 +20,8 @@ export declare const decodeFields: (fields: Record<string, unknown>) => Record<s
20
20
  * erro. Documento de extensão é grande, mas são poucos por escritório, e isto roda uma vez por comando.
21
21
  */
22
22
  export declare const listDocuments: (projectId: string, path: string) => Promise<FirestoreDocument[]>;
23
+ /** Os campos de UM documento, decodificados — `null` quando ele não existe. */
24
+ export declare const getDocument: (projectId: string, path: string) => Promise<Record<string, unknown> | null>;
23
25
  export declare const documentId: (document: FirestoreDocument) => string;
24
26
  /** Um valor com o tipo que a REST espera, montado por quem sabe o que o campo é. */
25
27
  export type FirestoreTypedValue = {
@@ -62,6 +62,10 @@ const request = async (projectId, path, init = { method: 'GET' }) => {
62
62
  ...(init.body === undefined ? {} : { body: JSON.stringify(init.body) }),
63
63
  });
64
64
  const text = await response.text();
65
+ // Documento que ainda não existe é resposta, não falha, para quem pediu assim: é o caso de todo
66
+ // documento que o Console escreve pela primeira vez num projeto que nunca o recebeu.
67
+ if (response.status === 404 && init.allowMissing === true)
68
+ return null;
65
69
  if (!response.ok) {
66
70
  const detail = text.slice(0, 400);
67
71
  if (response.status === 403) {
@@ -113,13 +117,20 @@ export const listDocuments = async (projectId, path) => {
113
117
  method: 'GET',
114
118
  query: { pageSize: '300', ...(pageToken === undefined ? {} : { pageToken }) },
115
119
  });
116
- for (const entry of page.documents ?? []) {
120
+ for (const entry of page?.documents ?? []) {
117
121
  documents.push({ name: entry.name, fields: entry.fields ?? {} });
118
122
  }
119
- pageToken = page.nextPageToken;
123
+ pageToken = page?.nextPageToken;
120
124
  } while (pageToken !== undefined);
121
125
  return documents;
122
126
  };
127
+ /** Os campos de UM documento, decodificados — `null` quando ele não existe. */
128
+ export const getDocument = async (projectId, path) => {
129
+ const document = await request(projectId, path, { method: 'GET', allowMissing: true });
130
+ if (document === null)
131
+ return null;
132
+ return decodeFields(document.fields ?? {});
133
+ };
123
134
  export const documentId = (document) => document.name.slice(document.name.lastIndexOf('/') + 1);
124
135
  export const asString = (value) => ({ stringValue: value });
125
136
  /**
@@ -16,6 +16,8 @@ export interface TenantSummary {
16
16
  export interface InstalledExtensionRecord {
17
17
  tenantId: string;
18
18
  documentPath: string;
19
+ /** O id do documento, que pode diferir do `manifest.id` — é por ele que a cópia é endereçada. */
20
+ documentId: string;
19
21
  id: string;
20
22
  name: string;
21
23
  version: string;
@@ -26,6 +26,7 @@ export const readInstalledExtensions = async (projectId, tenantId) => {
26
26
  return {
27
27
  tenantId,
28
28
  documentPath: `/tenants/${tenantId}/installed_extensions/${fallbackId}`,
29
+ documentId: fallbackId,
29
30
  id: String(manifest?.id ?? fallbackId),
30
31
  name: String(manifest?.name ?? manifest?.id ?? fallbackId),
31
32
  version: String(manifest?.version ?? '0.0.0'),