@microteia/console 2.28.0 → 2.29.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,15 @@
22
22
  - fix(command): what was broken, and what it did to whoever hit it.
23
23
  -->
24
24
 
25
+ #### [2.29.0] — 2026-10-15
26
+
27
+ - feat(export-catalog): each installed extension now carries `navGroups` — the sidebar groups it declares, by key, with the name resolved in every language the package ships and its order. The authoring tools list them in `microteia://nav-groups`, so an extension joins an existing group instead of drawing a second one with the same title. Always written, even empty; a snapshot taken by an older Console is read as "groups unknown".
28
+
29
+ #### [2.28.1] — 2026-10-14
30
+
31
+ - fix(console/deploy): a function whose source declares `retry: true` is no longer refused on its first deploy. Without a terminal the Firebase CLI does not ask "Would you like to proceed with deployment?" — it aborts with "Pass the --force option to deploy functions with a failure policy", so `runAggregateBackfill` and `runRecordKeyAdoption` never went up through `microteia deploy` or `provision`. That one function is now redeployed with `--force`, and only when the project is known to hold no copy of it in another region: the same flag would delete that copy without asking, which stays a `--prune` decision. When a copy stands or the project could not be listed, the refusal is terminal and the hint points to `microteia deploy --prune`.
32
+ - refactor(console/regionOrphans): `clearRegionOrphans` returns the names whose old-region copy is still standing (`null` when the project could not be listed) instead of a boolean.
33
+
25
34
  #### [2.28.0] — 2026-10-13
26
35
 
27
36
  - feat(examples): the Console ships `business-days` as authoring source — a collection the kernel reads without knowing its name (`time.business.calendar`), and a code view that edits data no field type describes.
package/README.md CHANGED
@@ -1,4 +1,4 @@
1
- # Microteia Console 2.28.0
1
+ # Microteia Console 2.29.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.
@@ -185,7 +185,7 @@ export const deploy = async (args) => {
185
185
  // ANTES de deployar: uma cópia da mesma function numa região que o pacote abandonou faz o
186
186
  // `firebase` recusar o deploy dela, tentativa após tentativa, e faz um gatilho de Firestore
187
187
  // disparar duas vezes por gravação enquanto ninguém percebe.
188
- await clearRegionOrphans(projectId, own, region, hasFlag(args, 'prune'), deployedNow);
188
+ const standingCopies = await clearRegionOrphans(projectId, own, region, hasFlag(args, 'prune'), deployedNow);
189
189
  // Numa instalação nova não há carimbo nenhum com que comparar, e o mecanismo cai sozinho no
190
190
  // caminho de sempre — não é preciso um desvio para isso. `--all` é o pedido explícito de subir
191
191
  // tudo, para a configuração trocada por fora, que carimbo nenhum enxerga.
@@ -194,6 +194,7 @@ export const deploy = async (args) => {
194
194
  cwd: install,
195
195
  delaySeconds,
196
196
  force: firstInstall,
197
+ standingCopies,
197
198
  }, { deployed: deployedNow, region, all: hasFlag(args, 'all') });
198
199
  refreshedStamps = report.refreshed;
199
200
  skippedFunctions = report.skipped.length;
@@ -4,6 +4,11 @@ export interface FunctionDeployOptions {
4
4
  delaySeconds: number;
5
5
  /** `--force` deixa o Firebase configurar sozinho a política de limpeza do Artifact Registry. */
6
6
  force: boolean;
7
+ /**
8
+ * As functions que ainda têm cópia numa região abandonada, como `clearRegionOrphans` devolve;
9
+ * `null` quando não deu para saber. Só fora dele a recusa da política de repetição é contornada.
10
+ */
11
+ standingCopies: ReadonlySet<string> | null;
7
12
  }
8
13
  export interface FunctionDeployFailure {
9
14
  name: string;
@@ -39,7 +44,20 @@ export interface TerminalFailure {
39
44
  match: RegExp;
40
45
  hint: string[];
41
46
  }
47
+ /**
48
+ * A recusa do `firebase` sem terminal diante de uma function que passa a ter `retry`.
49
+ *
50
+ * Com terminal ele pergunta "Would you like to proceed with deployment?"; sem, recusa e pede
51
+ * `--force`. A pergunta é sobre o que o PRÓPRIO código declarou (`retry: true` no gatilho), e quem
52
+ * declarou já respondeu: repetir a entrega é o que torna o gatilho confiável. Por isso a recusa é
53
+ * contornada com `--force` naquela function, e só nela — e só quando não há cópia dela em outra
54
+ * região, porque o mesmo `--force` faria o `firebase` apagar essa cópia sem perguntar.
55
+ *
56
+ * A pergunta só existe na primeira vez: com a function no ar já com `retry`, ela não volta.
57
+ */
58
+ export declare const FAILURE_POLICY_REFUSAL: RegExp;
42
59
  export declare const terminalFailure: (output: string) => TerminalFailure | undefined;
60
+ export declare const mayConfirmRetryPolicy: (name: string, output: string, options: FunctionDeployOptions) => boolean;
43
61
  export declare const deployFunctionsOneByOne: (names: string[], options: FunctionDeployOptions) => Promise<FunctionDeployReport>;
44
62
  export declare const reportFailures: (failures: FunctionDeployFailure[]) => void;
45
63
  export declare const reportNotices: (notices: string[]) => void;
@@ -64,6 +64,18 @@ export const failureReason = (output) => {
64
64
  ?? 'no output from firebase';
65
65
  return chosen.length > REASON_LIMIT ? `${chosen.slice(0, REASON_LIMIT - 1)}…` : chosen;
66
66
  };
67
+ /**
68
+ * A recusa do `firebase` sem terminal diante de uma function que passa a ter `retry`.
69
+ *
70
+ * Com terminal ele pergunta "Would you like to proceed with deployment?"; sem, recusa e pede
71
+ * `--force`. A pergunta é sobre o que o PRÓPRIO código declarou (`retry: true` no gatilho), e quem
72
+ * declarou já respondeu: repetir a entrega é o que torna o gatilho confiável. Por isso a recusa é
73
+ * contornada com `--force` naquela function, e só nela — e só quando não há cópia dela em outra
74
+ * região, porque o mesmo `--force` faria o `firebase` apagar essa cópia sem perguntar.
75
+ *
76
+ * A pergunta só existe na primeira vez: com a function no ar já com `retry`, ela não volta.
77
+ */
78
+ export const FAILURE_POLICY_REFUSAL = /Pass the --force option to deploy functions with a failure policy/;
67
79
  const TERMINAL_FAILURES = [
68
80
  {
69
81
  match: /Aborting because deletion cannot proceed in non-interactive mode/,
@@ -74,6 +86,15 @@ const TERMINAL_FAILURES = [
74
86
  ' microteia deploy --prune',
75
87
  ],
76
88
  },
89
+ {
90
+ match: FAILURE_POLICY_REFUSAL,
91
+ hint: [
92
+ 'The function now retries failed deliveries, and the Firebase CLI asks to confirm that once.',
93
+ 'It was not confirmed here because the project may still hold a copy of the function in another',
94
+ 'region, which the same confirmation would delete without asking. Remove old copies first:',
95
+ ' microteia deploy --prune',
96
+ ],
97
+ },
77
98
  {
78
99
  match: /does not have a default and cannot be answered in non-interactive mode/,
79
100
  hint: [
@@ -97,9 +118,9 @@ export const terminalFailure = (output) => {
97
118
  * gatilho falha enquanto as callables sobem normalmente. Repetir resolve, e sem isso a primeira
98
119
  * instalação parece quebrada quando está apenas incompleta.
99
120
  */
100
- const deployOne = async (name, options) => {
121
+ const deployOne = async (name, options, force = options.force) => {
101
122
  const args = ['deploy', '--only', `functions:${name}`, '--project', options.projectId];
102
- if (options.force)
123
+ if (force)
103
124
  args.push('--force');
104
125
  const startedAt = Date.now();
105
126
  const result = await capture(FIREBASE_BIN, args, { cwd: options.cwd });
@@ -111,6 +132,10 @@ const deployOne = async (name, options) => {
111
132
  seconds: Math.round((Date.now() - startedAt) / 1000),
112
133
  };
113
134
  };
135
+ export const mayConfirmRetryPolicy = (name, output, options) => !options.force
136
+ && options.standingCopies !== null
137
+ && !options.standingCopies.has(name)
138
+ && FAILURE_POLICY_REFUSAL.test(stripVTControlCharacters(output));
114
139
  export const deployFunctionsOneByOne = async (names, options) => {
115
140
  const deployed = [];
116
141
  const failed = [];
@@ -134,9 +159,15 @@ export const deployFunctionsOneByOne = async (names, options) => {
134
159
  const name = remaining[index];
135
160
  const label = `[${index + 1}/${remaining.length}] ${name}`;
136
161
  pending(label);
137
- const { ok, output, notices: given, seconds } = await deployOne(name, options);
162
+ let { ok, output, notices: given, seconds } = await deployOne(name, options);
138
163
  for (const notice of given)
139
164
  notices.add(notice);
165
+ if (!ok && mayConfirmRetryPolicy(name, output, options)) {
166
+ info(` ${name} now retries failed deliveries, as its source declares: confirming it`);
167
+ ({ ok, output, notices: given, seconds } = await deployOne(name, options, true));
168
+ for (const notice of given)
169
+ notices.add(notice);
170
+ }
140
171
  if (ok) {
141
172
  success(`${label} (${seconds}s)`);
142
173
  deployed.push(name);
@@ -98,6 +98,20 @@ export interface CatalogExtensionExportedArtifact {
98
98
  kind: 'workflow' | 'action' | 'script' | 'unknown';
99
99
  name: string;
100
100
  }
101
+ /**
102
+ * Um grupo da barra lateral que a extensão declara (`ui.nav.group`). Viaja porque a CHAVE é contrato:
103
+ * é o que outra extensão escreve para entrar no grupo, e quem autora sem enxergá-la inventa outra — e
104
+ * o menu passa a mostrar dois grupos com o mesmo nome.
105
+ *
106
+ * O nome vai resolvido em cada idioma que o próprio pacote traz, porque é pelo nome que o autor
107
+ * reconhece o grupo; o rótulo literal vai como está, com `translations` vazio.
108
+ */
109
+ export interface CatalogExtensionNavGroup {
110
+ key: string;
111
+ label: string;
112
+ translations: Record<string, string>;
113
+ order: number;
114
+ }
101
115
  export interface CatalogExtensionExport {
102
116
  id: string;
103
117
  name: string;
@@ -108,6 +122,8 @@ export interface CatalogExtensionExport {
108
122
  * a chave ausente é o silêncio de um Console anterior a este campo, e o kernel distingue as duas.
109
123
  */
110
124
  exports: CatalogExtensionExportedArtifact[];
125
+ /** Sempre escrita, mesmo vazia, pela mesma razão de `exports`. */
126
+ navGroups: CatalogExtensionNavGroup[];
111
127
  }
112
128
  export interface CatalogExport {
113
129
  version: number;
@@ -78,6 +78,54 @@ const exportedArtifacts = (extension) => {
78
78
  .map((id) => named.get(id) ?? { id, kind: 'unknown', name: id })
79
79
  .sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
80
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
+ };
81
129
  /**
82
130
  * A mesma dedução que a aplicação faz ao ler a tela (`screenDefinitionMapper.ts`): o `origin` gravado
83
131
  * quando ele existe, senão `plugin` para quem tem dono e `design` para o que o escritório criou. Ela é
@@ -174,6 +222,7 @@ export const buildCatalogExport = (input) => ({
174
222
  version: one.version,
175
223
  active: one.active,
176
224
  exports: exportedArtifacts(one),
225
+ navGroups: navGroupsOf(one),
177
226
  }))
178
227
  .sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0)),
179
228
  screens: input.screens
@@ -293,6 +342,8 @@ export const exportCatalog = async (args) => {
293
342
  + `the rest the office built itself.`);
294
343
  const exported = document.extensions.reduce((total, one) => total + one.exports.length, 0);
295
344
  info(` ${exported} artifact(s) are declared for other extensions to reference as <extension-id>/<id>.`);
345
+ const groups = document.extensions.reduce((total, one) => total + one.navGroups.length, 0);
346
+ info(` ${groups} sidebar group(s) are declared, by key, for other extensions to join.`);
296
347
  blank();
297
348
  await checkIgnored(directory);
298
349
  info('It is a SNAPSHOT, and it says so: the authoring tools report when it was taken and warn when it');
@@ -428,8 +428,8 @@ export const provisionBackend = async (options) => {
428
428
  }
429
429
  // A extensão que mudou de região deixa a cópia antiga para trás igual ao kernel, e ali o efeito
430
430
  // é o mesmo: deploy recusado em toda tentativa, e gatilho disparando em duas regiões.
431
- await clearRegionOrphans(projectId, toDeploy, region, options.prune, await projectFunctions());
432
- const deployOptions = { projectId, cwd: install, delaySeconds, force: false };
431
+ const standingCopies = await clearRegionOrphans(projectId, toDeploy, region, options.prune, await projectFunctions());
432
+ const deployOptions = { projectId, cwd: install, delaySeconds, force: false, standingCopies };
433
433
  if (!probing)
434
434
  info(` deploying ${toDeploy.length} function(s), one at a time…`);
435
435
  const report = probing
@@ -50,10 +50,15 @@ export declare const deleteCommand: (projectId: string, orphan: RegionOrphan) =>
50
50
  * Avisa por padrão e só apaga com `--prune`, como `settleOrphans` faz com a function que perdeu dono.
51
51
  * Apagar uma Cloud Function é irreversível e é do escritório, não nosso: o comando tem de ser pedido.
52
52
  */
53
- export declare const settleRegionOrphans: (projectId: string, orphans: RegionOrphan[], prune: boolean) => Promise<boolean>;
53
+ export declare const settleRegionOrphans: (projectId: string, orphans: RegionOrphan[], prune: boolean) => Promise<ReadonlySet<string>>;
54
54
  /**
55
- * O passo inteiro, para quem só quer chamá-lo antes de deployar. Devolve `true` quando o caminho está
56
- * livre — inclusive quando não deu para verificar, pela mesma razão de `listDeployedFunctions`.
55
+ * O passo inteiro, para quem só quer chamá-lo antes de deployar. Devolve os nomes cuja cópia antiga
56
+ * CONTINUA de pé, ou `null` quando não deu para verificar — e não bloqueia o deploy em nenhum dos dois
57
+ * casos, pela mesma razão de `listDeployedFunctions`.
58
+ *
59
+ * O conjunto é o que autoriza o `--force` pontual de `deployFunctionsOneByOne`: com ele o `firebase`
60
+ * aceita a política de repetição E apaga sem perguntar a cópia de outra região, e essa segunda decisão
61
+ * é do escritório, pedida com `--prune`. Não saber equivale a ter cópia.
57
62
  */
58
63
  export declare const clearRegionOrphans: (projectId: string, wanted: string[], region: string, prune: boolean,
59
64
  /**
@@ -61,4 +66,4 @@ export declare const clearRegionOrphans: (projectId: string, wanted: string[], r
61
66
  * a mesma coisa ao mesmo projeto não é caro, é ambíguo: as duas respostas podem divergir, e a
62
67
  * decisão passa a depender de qual delas o código olhou.
63
68
  */
64
- listed?: DeployedFunction[] | null) => Promise<boolean>;
69
+ listed?: DeployedFunction[] | null) => Promise<ReadonlySet<string> | null>;
@@ -51,7 +51,7 @@ export const deleteCommand = (projectId, orphan) => `firebase functions:delete $
51
51
  */
52
52
  export const settleRegionOrphans = async (projectId, orphans, prune) => {
53
53
  if (orphans.length === 0)
54
- return true;
54
+ return new Set();
55
55
  blank();
56
56
  warn(`${orphans.length} function(s) are still deployed in a region this package no longer uses:`);
57
57
  bullets(orphans.map((orphan) => `${orphan.name} (${orphan.region})`));
@@ -66,11 +66,11 @@ export const settleRegionOrphans = async (projectId, orphans, prune) => {
66
66
  info(' or one at a time:');
67
67
  bullets(orphans.map((orphan) => deleteCommand(projectId, orphan)));
68
68
  blank();
69
- return false;
69
+ return new Set(orphans.map((orphan) => orphan.name));
70
70
  }
71
71
  const plural = orphans.length === 1 ? 'copy' : 'copies';
72
72
  info(` --prune: removing the ${orphans.length} old ${plural} before deploying.`);
73
- let cleared = true;
73
+ const standing = new Set();
74
74
  for (const orphan of orphans) {
75
75
  const label = `${orphan.name} (${orphan.region})`;
76
76
  pending(label);
@@ -87,15 +87,20 @@ export const settleRegionOrphans = async (projectId, orphans, prune) => {
87
87
  failure(`${label} could not be removed`);
88
88
  info(` ${failureReason(`${result.stdout}${result.stderr}`)}`);
89
89
  info(` ${deleteCommand(projectId, orphan)}`);
90
- cleared = false;
90
+ standing.add(orphan.name);
91
91
  }
92
92
  }
93
93
  blank();
94
- return cleared;
94
+ return standing;
95
95
  };
96
96
  /**
97
- * O passo inteiro, para quem só quer chamá-lo antes de deployar. Devolve `true` quando o caminho está
98
- * livre — inclusive quando não deu para verificar, pela mesma razão de `listDeployedFunctions`.
97
+ * O passo inteiro, para quem só quer chamá-lo antes de deployar. Devolve os nomes cuja cópia antiga
98
+ * CONTINUA de pé, ou `null` quando não deu para verificar — e não bloqueia o deploy em nenhum dos dois
99
+ * casos, pela mesma razão de `listDeployedFunctions`.
100
+ *
101
+ * O conjunto é o que autoriza o `--force` pontual de `deployFunctionsOneByOne`: com ele o `firebase`
102
+ * aceita a política de repetição E apaga sem perguntar a cópia de outra região, e essa segunda decisão
103
+ * é do escritório, pedida com `--prune`. Não saber equivale a ter cópia.
99
104
  */
100
105
  export const clearRegionOrphans = async (projectId, wanted, region, prune,
101
106
  /**
@@ -106,6 +111,6 @@ export const clearRegionOrphans = async (projectId, wanted, region, prune,
106
111
  listed) => {
107
112
  const deployed = listed === undefined ? await listDeployedFunctions(projectId) : listed;
108
113
  if (deployed === null)
109
- return true;
114
+ return null;
110
115
  return settleRegionOrphans(projectId, regionOrphans(deployed, wanted, region), prune);
111
116
  };
@@ -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.28.0";
90
+ export declare const CLI_VERSION = "2.29.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.28.0';
97
+ export const CLI_VERSION = '2.29.0';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@microteia/console",
3
- "version": "2.28.0",
3
+ "version": "2.29.0",
4
4
  "description": "Install and update Microteia in a Firebase project you own.",
5
5
  "license": "UNLICENSED",
6
6
  "type": "module",