@microteia/console 2.33.0 → 2.36.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,22 @@
22
22
  - fix(command): what was broken, and what it did to whoever hit it.
23
23
  -->
24
24
 
25
+ #### [2.36.0] — 2026-11-05
26
+
27
+ - fix(environment): every command that acts on an installation (`readEnvironment`) refuses when the installation's `CONSOLE-VERSIONS.md` names a Console newer than the one running. An older Console rewrites `microteia.yaml` with only the sections it knows and replaces `CONSOLE-VERSIONS.md` and `MICROTEIA.md` with its own texts — nothing failed: a forgotten copy of Console 2.6.0, left in a folder of the installation, ran `unpack` there, the `login:` block disappeared and the Console history lost thirty versions without a word. No new field marks it, on purpose: an older Console would drop the field it does not know.
28
+ - feat(version): `microteia --version` still answers in that case, and says which Console last updated the installation.
29
+
30
+ #### [2.35.0] — 2026-11-02
31
+
32
+ - feat(publish): the releases repository README links to the official documentation (https://doc.microteia.com), and `publish` rewrites it whenever it differs from the current text — after the catalog, warning instead of failing. It was written only when seeding an empty repository, so the README of an existing one stayed frozen at the text of its first `publish` and no change to it ever arrived. `publish --dry-run` says when it would be rewritten.
33
+ - docs(pack-console): the npm README of the Console links to https://doc.microteia.com.
34
+
35
+ #### [2.34.0] — 2026-11-02
36
+
37
+ - fix(publish): `withEntry` promotes `channels.stable` to the HIGHER of the current and the new version. Publishing out of order — a fix on an older line, `9.180.1` after `9.191.0` — moved `latest` back, and every `unpack --version latest` received the older version. `publish` says whether the version became `latest` or only joined the list.
38
+ - fix(pack): `pack --output <dir>` warns that `publish` reads only `distributions/kernel/<version>/`; the package written elsewhere was simply not found.
39
+ - docs(installation): a platform setting (`SELF_SERVICE_TENANTS`, `AUDIT_ARCHIVE_BUCKET`) reaches the functions with `microteia deploy --only functions`, not `provision`, which only tracks settings declared by extensions and by the installation's `functions/`. The prerequisites and `MICROTEIA.md` §3 said `provision`, which changed nothing.
40
+
25
41
  #### [2.33.0] — 2026-10-28
26
42
 
27
43
  - feat(export-catalog): each screen now carries `keys` — every record key of the collection as `name`, `kind`, `fields`, `scope`, `normalize` and `sparse`, plus `ownerPluginId` when an extension declared it — and each field carries `unique: true` when it is set. The conflict `message` and the deprecated `status` stay out: they do not change which reservation a record gets. Without this, the artifact plan proposed as new, on every run, a key the office declared in the Form Design or a third-party extension installed. Always written, even empty; a snapshot taken by an older Console is read as "keys unknown", and the authoring tools say so.
package/README.md CHANGED
@@ -1,8 +1,10 @@
1
- # Microteia Console 2.33.0
1
+ # Microteia Console 2.36.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.
5
5
 
6
+ 📖 **Documentation: [doc.microteia.com](https://doc.microteia.com)**
7
+
6
8
  ## Installing it
7
9
 
8
10
  npm i -g @microteia/console
@@ -73,6 +73,28 @@ export interface Environment {
73
73
  export declare const ENVIRONMENT_FILE = "microteia.yaml";
74
74
  export declare const parseEnvironment: (source: string) => Environment;
75
75
  export declare const serializeEnvironment: (environment: Environment) => string;
76
+ /**
77
+ * O Console que deixou esta instalação como ela está, quando ele é MAIS NOVO que o que está rodando.
78
+ *
79
+ * O sinal é o topo do `CONSOLE-VERSIONS.md` da raiz, que o `unpack` sobrepõe com o histórico do próprio
80
+ * binário — é, portanto, o último Console que trocou o kernel ali. Nenhum campo novo foi inventado para
81
+ * isso, e é deliberado: um Console antigo apagaria o campo que ele não conhece, que é o defeito inteiro.
82
+ *
83
+ * Sem o arquivo (instalação anterior a ele, ou apagado) não há o que comparar, e nada é recusado.
84
+ */
85
+ export declare const newerConsoleThanThis: (root: string) => Promise<string | null>;
86
+ /**
87
+ * Lê o `microteia.yaml` — e RECUSA quando um Console mais novo já passou por aqui.
88
+ *
89
+ * Um Console antigo reescreve este arquivo só com as seções que conhece, e sobrepõe o
90
+ * `CONSOLE-VERSIONS.md` e o `MICROTEIA.md` com os textos dele. Nada disso falha: a configuração que ele
91
+ * não conhece some, e o histórico volta meses, em silêncio. Foi o que aconteceu quando uma cópia
92
+ * esquecida do Console, numa pasta da instalação, rodou o `unpack` — o bloco `login:` desapareceu, e o
93
+ * histórico do Console perdeu trinta versões.
94
+ *
95
+ * A recusa fica aqui porque este é o primeiro passo de todo comando que AGE sobre a instalação, antes de
96
+ * qualquer escrita. O `--version` lê o arquivo por outro caminho, e continua respondendo.
97
+ */
76
98
  export declare const readEnvironment: (path: string) => Promise<Environment>;
77
99
  export declare const writeEnvironment: (path: string, environment: Environment) => Promise<void>;
78
100
  export declare const slug: (value: string) => string;
@@ -1,4 +1,8 @@
1
1
  import { readFile, writeFile } from 'node:fs/promises';
2
+ import { dirname, join } from 'node:path';
3
+ import { compareVersions } from '../kernel/manifest.js';
4
+ import { CLI_VERSION } from '../shared/paths.js';
5
+ import { CONSOLE_CHANGELOG, changelogVersions } from '../vendor/changelogs.js';
2
6
  export const ENVIRONMENT_FILE = 'microteia.yaml';
3
7
  /**
4
8
  * O YAML aqui é um SUBCONJUNTO deliberado: mapas de dois níveis com valores escalares, que é a forma
@@ -140,9 +144,36 @@ login:
140
144
  # To USE an extension inside the organization, install it from the Extensions screen instead.
141
145
  extensions: ${quote(environment.login.extensions)}
142
146
  `;
147
+ /**
148
+ * O Console que deixou esta instalação como ela está, quando ele é MAIS NOVO que o que está rodando.
149
+ *
150
+ * O sinal é o topo do `CONSOLE-VERSIONS.md` da raiz, que o `unpack` sobrepõe com o histórico do próprio
151
+ * binário — é, portanto, o último Console que trocou o kernel ali. Nenhum campo novo foi inventado para
152
+ * isso, e é deliberado: um Console antigo apagaria o campo que ele não conhece, que é o defeito inteiro.
153
+ *
154
+ * Sem o arquivo (instalação anterior a ele, ou apagado) não há o que comparar, e nada é recusado.
155
+ */
156
+ export const newerConsoleThanThis = async (root) => {
157
+ const contents = await readFile(join(root, CONSOLE_CHANGELOG), 'utf8').catch(() => null);
158
+ const latest = contents === null ? undefined : changelogVersions(contents)[0];
159
+ return latest !== undefined && compareVersions(latest, CLI_VERSION) > 0 ? latest : null;
160
+ };
161
+ /**
162
+ * Lê o `microteia.yaml` — e RECUSA quando um Console mais novo já passou por aqui.
163
+ *
164
+ * Um Console antigo reescreve este arquivo só com as seções que conhece, e sobrepõe o
165
+ * `CONSOLE-VERSIONS.md` e o `MICROTEIA.md` com os textos dele. Nada disso falha: a configuração que ele
166
+ * não conhece some, e o histórico volta meses, em silêncio. Foi o que aconteceu quando uma cópia
167
+ * esquecida do Console, numa pasta da instalação, rodou o `unpack` — o bloco `login:` desapareceu, e o
168
+ * histórico do Console perdeu trinta versões.
169
+ *
170
+ * A recusa fica aqui porque este é o primeiro passo de todo comando que AGE sobre a instalação, antes de
171
+ * qualquer escrita. O `--version` lê o arquivo por outro caminho, e continua respondendo.
172
+ */
143
173
  export const readEnvironment = async (path) => {
174
+ let environment;
144
175
  try {
145
- return parseEnvironment(await readFile(path, 'utf8'));
176
+ environment = parseEnvironment(await readFile(path, 'utf8'));
146
177
  }
147
178
  catch (error) {
148
179
  if (error.code === 'ENOENT') {
@@ -150,6 +181,14 @@ export const readEnvironment = async (path) => {
150
181
  }
151
182
  throw error;
152
183
  }
184
+ const newer = await newerConsoleThanThis(dirname(path));
185
+ if (newer !== null) {
186
+ throw new Error(`This installation was last updated by Microteia Console ${newer}, and this is ${CLI_VERSION}.\n`
187
+ + `An older Console rewrites ${ENVIRONMENT_FILE} without the settings it does not know, and replaces\n`
188
+ + `${CONSOLE_CHANGELOG} with an older history, so it stops here. Update it with "npm i -g @microteia/console",\n`
189
+ + 'and check that the "microteia" you ran is that one, not a copy left in a folder.');
190
+ }
191
+ return environment;
153
192
  };
154
193
  export const writeEnvironment = async (path, environment) => {
155
194
  await writeFile(path, serializeEnvironment(environment));
@@ -1,7 +1,7 @@
1
1
  import { readFile } from 'node:fs/promises';
2
2
  import { join } from 'node:path';
3
3
  import { CLI_VERSION } from '../shared/paths.js';
4
- import { ENVIRONMENT_FILE, parseEnvironment } from './environment.js';
4
+ import { ENVIRONMENT_FILE, newerConsoleThanThis, parseEnvironment } from './environment.js';
5
5
  import { installPath } from './unpack.js';
6
6
  /**
7
7
  * O que `microteia --version` responde.
@@ -59,6 +59,15 @@ export const versionReport = async (directory, newer) => {
59
59
  lines.push('');
60
60
  lines.push(`${inPlace} is in place and ${deployed} is what the project runs. "microteia deploy" publishes it.`);
61
61
  }
62
+ /**
63
+ * É a pergunta que se faz quando um comando recusa por isto — e a resposta tem de estar aqui, porque
64
+ * o `--version` é o único que continua rodando com o Console velho.
65
+ */
66
+ const newerConsole = await newerConsoleThanThis(directory);
67
+ if (newerConsole !== null) {
68
+ lines.push('');
69
+ lines.push(`Console ${newerConsole} last updated this installation. This one is older and will not act on it.`);
70
+ }
62
71
  /**
63
72
  * E o que o canal oferece, quando há canal alcançável. Vem por último porque é a única linha que
64
73
  * depende de rede: se ela faltar, tudo acima continua sendo a resposta inteira.
@@ -63,7 +63,8 @@ export declare const entryOf: (manifest: KernelManifest, urls: {
63
63
  manifest: string;
64
64
  }, publishedAt: string) => ChannelEntry;
65
65
  /**
66
- * Acrescenta a entrada e promove o canal, RECUSANDO uma versão que já está lá.
66
+ * Acrescenta a entrada e promove o canal à MAIOR versão entre a atual e a nova, RECUSANDO uma versão
67
+ * que já está lá.
67
68
  *
68
69
  * Republicar é a origem de "a mesma versão com dois conteúdos", e ela não tem conserto depois: alguém
69
70
  * conferiu um hash contra o download de ontem e a partir dali as duas instalações discordam sobre o que
@@ -109,7 +109,8 @@ export const entryOf = (manifest, urls, publishedAt) => {
109
109
  };
110
110
  const byVersionDescending = (left, right) => compareVersions(right.version, left.version);
111
111
  /**
112
- * Acrescenta a entrada e promove o canal, RECUSANDO uma versão que já está lá.
112
+ * Acrescenta a entrada e promove o canal à MAIOR versão entre a atual e a nova, RECUSANDO uma versão
113
+ * que já está lá.
113
114
  *
114
115
  * Republicar é a origem de "a mesma versão com dois conteúdos", e ela não tem conserto depois: alguém
115
116
  * conferiu um hash contra o download de ontem e a partir dali as duas instalações discordam sobre o que
@@ -120,9 +121,13 @@ export const withEntry = (index, entry) => {
120
121
  throw new Error(`${entry.version} is already published, and a released version is never rewritten.\n`
121
122
  + 'Bump the version in package.json and package it again.');
122
123
  }
124
+ // Publicar fora de ordem é permitido (uma correção numa linha antiga), e o canal não pode andar para
125
+ // trás por isso: `latest` passaria a entregar uma versão MAIS VELHA a quem atualiza.
126
+ const current = index.channels[STABLE_CHANNEL];
127
+ const stable = current !== undefined && compareVersions(current, entry.version) > 0 ? current : entry.version;
123
128
  return {
124
129
  schema: INDEX_SCHEMA,
125
- channels: { ...index.channels, [STABLE_CHANNEL]: entry.version },
130
+ channels: { ...index.channels, [STABLE_CHANNEL]: stable },
126
131
  kernel: [entry, ...index.kernel].sort(byVersionDescending),
127
132
  };
128
133
  };
@@ -131,7 +131,7 @@ export const MANUAL_PREREQUISITES = [
131
131
  + 'longer. Grant the functions\' runtime service account roles/storage.admin on THAT bucket only (it '
132
132
  + 'reads the bucket\'s retention settings and sets each day\'s retention, which the object roles '
133
133
  + 'do not cover), write AUDIT_ARCHIVE_BUCKET=<bucket> in .env.<projectId> '
134
- + 'and run microteia provision. What it protects against, and what it does not: a locked bucket stops '
134
+ + 'and run microteia deploy --only functions (provision does not carry the platform\'s own settings). What it protects against, and what it does not: a locked bucket stops '
135
135
  + 'deletion and rewriting of what was already archived; the chain makes any change visible on '
136
136
  + 'verification. A project owner can still alter or delete an audit line in Firestore BEFORE its day '
137
137
  + 'is exported (the export runs hourly, about an hour after midnight UTC), turn the archive off (that '
@@ -147,7 +147,7 @@ export const MANUAL_PREREQUISITES = [
147
147
  detail: 'By default every new account gets an organization of its own, with that person as the owner. In an '
148
148
  + 'installation shared by several organizations — each one created on purpose — that lets anyone who '
149
149
  + 'can create an account open one. Write SELF_SERVICE_TENANTS=false in .env.<projectId> and run '
150
- + 'microteia provision: from then on a new account waits to be added to an organization, and only the '
150
+ + 'microteia deploy --only functions: from then on a new account waits to be added to an organization, and only the '
151
151
  + 'installation administrator (SYSTEM) opens organizations, in Access management > Organizations, '
152
152
  + 'naming an owner who already has an account. The first account of the installation gets one '
153
153
  + 'anyway. Any value other than empty, true or false is read as false. Two-step verification is '
@@ -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.33.0";
90
+ export declare const CLI_VERSION = "2.36.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.33.0';
97
+ export const CLI_VERSION = '2.36.0';
@@ -191,10 +191,15 @@ deliberately, because you asked.
191
191
  They live in \`.env.<projectId>\`, next to \`microteia.yaml\`. \`unpack\` creates the file and adds any
192
192
  setting a new version introduces, **never overwriting what you filled in**.
193
193
 
194
- After changing a value, run \`microteia provision\`. The value is stamped into each Cloud Function at
195
- *its* deploy, so a function already running keeps the previous one until it is deployed again; provision
196
- notices the change (by a salted digest in \`microteia.yaml\`) and requeues exactly the functions that
197
- carry that setting.
194
+ The value is stamped into each Cloud Function at *its* deploy, so a function already running keeps the
195
+ previous one until it is deployed again. Which command deploys it again depends on who declares the
196
+ setting:
197
+
198
+ - **A setting of an extension, or of your own \`functions/\`** — run \`microteia provision\`. It notices the
199
+ change (by a salted digest in \`microteia.yaml\`) and requeues exactly the functions that carry it.
200
+ - **A setting of the platform itself** (\`SELF_SERVICE_TENANTS\`, \`AUDIT_ARCHIVE_BUCKET\`, …) — run
201
+ \`microteia deploy --only functions\`. \`provision\` does not track them; the deploy compares each
202
+ function's stamp, which includes its environment, and redeploys the ones whose value changed.
198
203
 
199
204
  An empty line means "not configured". A feature that needs it stays off rather than failing halfway.
200
205
 
@@ -3,7 +3,7 @@ import { basename, join, relative } from 'node:path';
3
3
  import { flagValue, hasFlag } from '../shared/args.js';
4
4
  import { repoRoot } from '../shared/paths.js';
5
5
  import { run } from '../shared/run.js';
6
- import { blank, bullets, heading, info, step, success } from '../shared/ui.js';
6
+ import { blank, bullets, heading, info, step, success, warn } from '../shared/ui.js';
7
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';
@@ -140,7 +140,8 @@ export const pack = async (args) => {
140
140
  }
141
141
  const baselineFunctions = baselineFunctionsOf(functionNames, claims);
142
142
  const baselineIndexes = baselineIndexesOf(rootIndexes.indexes, claims);
143
- const outputDir = flagValue(args, 'output') ?? join(root, 'distributions', 'kernel', version);
143
+ const defaultOutputDir = join(root, 'distributions', 'kernel', version);
144
+ const outputDir = flagValue(args, 'output') ?? defaultOutputDir;
144
145
  const stageDir = join(outputDir, 'package');
145
146
  const tarball = join(outputDir, kernelTarballName(version));
146
147
  heading(`Packaging Microteia kernel ${version} (contract ${kernelVersion})`);
@@ -264,6 +265,13 @@ export const pack = async (args) => {
264
265
  `size: ${(digest.bytes / 1_000_000).toFixed(1)} MB, sha256 ${digest.sha256.slice(0, 12)}…`,
265
266
  ]);
266
267
  blank();
268
+ if (outputDir !== defaultOutputDir) {
269
+ // O `publish` lê só `distributions/kernel/<versão>/`: o pacote escrito em outro lugar não é achado,
270
+ // e sem este aviso a recusa dele parecia um pacote que nunca foi feito.
271
+ warn(`--output wrote it outside ${relative(root, defaultOutputDir)}/, and "microteia publish" reads only there.`);
272
+ info(' Copy the folder there to publish it, or install the file directly (below).');
273
+ blank();
274
+ }
267
275
  info('Publish it with:');
268
276
  bullets(['microteia publish --dry-run (says what would go up)', 'microteia publish']);
269
277
  blank();
@@ -178,6 +178,8 @@ export const consoleReadme = (version) => `# Microteia Console ${version}
178
178
  Installs and updates Microteia in a Firebase project you own. It has no npm dependencies of its own —
179
179
  what it needs are command-line tools that are already on the machine, listed at the bottom.
180
180
 
181
+ 📖 **Documentation: [doc.microteia.com](https://doc.microteia.com)**
182
+
181
183
  ## Installing it
182
184
 
183
185
  npm i -g ${CONSOLE_PACKAGE_NAME}
@@ -6,7 +6,7 @@ import { CLI_VERSION, repoRoot } from '../shared/paths.js';
6
6
  import { capture, captureOrThrow, run } from '../shared/run.js';
7
7
  import { blank, bullets, heading, info, pending, step, success, warn } from '../shared/ui.js';
8
8
  import { digestMismatch, digestOfFile } from '../shared/digest.js';
9
- import { emptyIndex, entryOf, kernelTarballName, parseIndex, publishedBefore, serializeIndex, withEntry, } from '../kernel/channel.js';
9
+ import { emptyIndex, entryOf, kernelTarballName, parseIndex, publishedBefore, serializeIndex, withEntry, STABLE_CHANNEL, } from '../kernel/channel.js';
10
10
  import { CONSOLE_CHANGELOG, KERNEL_CHANGELOG, changelogEntries, readChangelog } from './changelogs.js';
11
11
  import { CONSOLE_PACKAGE_NAME } from './packageTemplates.js';
12
12
  import { assertInsideRepository } from './repository.js';
@@ -72,16 +72,21 @@ const readPublishedIndex = async (repo) => {
72
72
  const text = Buffer.from(contents.content, 'base64').toString('utf8');
73
73
  return { index: parseIndex(text, `${repo}/${INDEX_PATH}`), sha: contents.sha, empty: false };
74
74
  };
75
+ /** O caminho do README DENTRO do repositório de releases. */
76
+ const README_PATH = 'README.md';
75
77
  /**
76
78
  * O que fica na raiz do repositório de releases, para quem chegar nele sem contexto.
77
79
  *
78
- * Ele é escrito UMA vez, ao semear — não é sobreposto depois. O que promete estar atual é o catálogo,
79
- * e é ele que a cada publicação é reescrito.
80
+ * É documento GERADO, e nenhuma pessoa escreve naquele repositório: por isso é sobreposto sempre que
81
+ * diverge deste texto. Escrito só ao semear, ele congelava no texto do primeiro `publish` — o link da
82
+ * documentação oficial nunca chegaria ao repositório que já existia.
80
83
  */
81
84
  const releasesReadme = () => `# Microteia — releases
82
85
 
83
86
  Distribution artifacts for the Microteia platform. **No source lives here**: what travels is compiled.
84
87
 
88
+ 📖 **Documentation: [doc.microteia.com](https://doc.microteia.com)**
89
+
85
90
  - \`kernel/index.json\` — the catalog the Microteia Console reads. It lists every published version with
86
91
  its size and sha256, and the absolute URLs of the files that carry it.
87
92
  - Each version is a release tagged \`kernel-<version>\`, carrying the package and its manifest.
@@ -94,6 +99,17 @@ Nobody downloads from here by hand:
94
99
  The Console reads the catalog, verifies what it downloads against the sha256 above, and only then puts
95
100
  it in place.
96
101
  `;
102
+ const writeReadme = async (repo, sha, message) => {
103
+ const content = Buffer.from(releasesReadme(), 'utf8').toString('base64');
104
+ const args = [
105
+ 'api', '--method', 'PUT', `repos/${repo}/contents/${README_PATH}`,
106
+ '-f', `message=${message}`,
107
+ '-f', `content=${content}`,
108
+ ];
109
+ if (sha !== null)
110
+ args.push('-f', `sha=${sha}`);
111
+ await captureOrThrow('gh', args);
112
+ };
97
113
  /**
98
114
  * Dá ao repositório o primeiro commit, porque sem ele não existe release.
99
115
  *
@@ -104,14 +120,20 @@ it in place.
104
120
  */
105
121
  const seedRepository = async (repo) => {
106
122
  pending(`${repo} has no commits yet — writing its README first`);
107
- const content = Buffer.from(releasesReadme(), 'utf8').toString('base64');
108
- await captureOrThrow('gh', [
109
- 'api', '--method', 'PUT', `repos/${repo}/contents/README.md`,
110
- '-f', 'message=the releases repository',
111
- '-f', `content=${content}`,
112
- ]);
123
+ await writeReadme(repo, null, 'the releases repository');
113
124
  success(`seeded ${repo}`);
114
125
  };
126
+ const staleReadme = async (repo) => {
127
+ const result = await capture('gh', ['api', `repos/${repo}/contents/${README_PATH}`]);
128
+ if (result.code !== 0) {
129
+ if (`${result.stderr}${result.stdout}`.includes('HTTP 404'))
130
+ return { sha: null };
131
+ throw new Error(`Could not read ${README_PATH} from ${repo}.\n${result.stderr.trim() || result.stdout.trim()}`);
132
+ }
133
+ const contents = JSON.parse(result.stdout);
134
+ const text = Buffer.from(contents.content, 'base64').toString('utf8');
135
+ return text === releasesReadme() ? null : { sha: contents.sha };
136
+ };
115
137
  const writePublishedIndex = async (repo, index, sha, version) => {
116
138
  const content = Buffer.from(serializeIndex(index), 'utf8').toString('base64');
117
139
  const args = [
@@ -178,6 +200,7 @@ const publishKernel = async (root, version, repo, dryRun) => {
178
200
  const directory = join(root, 'distributions', 'kernel', version);
179
201
  const manifestPath = join(directory, 'manifest.json');
180
202
  const published = await readPublishedIndex(repo);
203
+ const readme = published.empty ? null : await staleReadme(repo);
181
204
  /**
182
205
  * O corpo do release cobre o INTERVALO, e não só a versão que está saindo. Um pacote leva tudo o que
183
206
  * veio antes dele; um release que só cita a própria entrada esconde as versões que foram empacotadas e
@@ -190,13 +213,19 @@ const publishKernel = async (root, version, repo, dryRun) => {
190
213
  manifest: assetUrl(repo, version, 'manifest.json'),
191
214
  }, new Date().toISOString()));
192
215
  success(`kernel ${version} verified: ${manifest.tarball?.bytes} bytes, sha256 ${manifest.tarball?.sha256.slice(0, 12)}…`);
193
- info(` tag ${tagOf(version)} in ${repo}, and ${INDEX_PATH} promoting it to "latest"`);
216
+ const promotes = next.channels[STABLE_CHANNEL] === version;
217
+ info(promotes
218
+ ? ` tag ${tagOf(version)} in ${repo}, and ${INDEX_PATH} promoting it to "latest"`
219
+ : ` tag ${tagOf(version)} in ${repo}, and ${INDEX_PATH} listing it — "latest" stays at `
220
+ + `${next.channels[STABLE_CHANNEL]}, which is newer`);
194
221
  info(notes.versions.length === 1
195
222
  ? ` release notes: the ${version} entry of ${KERNEL_CHANGELOG}`
196
223
  : ` release notes: ${notes.versions.join(', ')} — every entry since ${since ?? 'the first one'}`);
197
224
  if (dryRun) {
198
225
  if (published.empty)
199
226
  info(` ${repo} has no commits, so it would be seeded with a README first`);
227
+ if (readme !== null)
228
+ info(` ${README_PATH} in ${repo} differs from the current text, so it would be rewritten`);
200
229
  info(` the catalog would go from ${published.index.kernel.length} to ${next.kernel.length} version(s)`);
201
230
  return;
202
231
  }
@@ -231,7 +260,19 @@ const publishKernel = async (root, version, repo, dryRun) => {
231
260
  success(`assets published for ${version}`);
232
261
  // O catálogo por último, sempre. Ver a nota de ordem no topo deste módulo.
233
262
  await writePublishedIndex(repo, next, published.sha, version);
234
- success(`${INDEX_PATH} updated — "microteia unpack --version latest" now takes ${version}`);
263
+ success(promotes
264
+ ? `${INDEX_PATH} updated — "microteia unpack --version latest" now takes ${version}`
265
+ : `${INDEX_PATH} updated — "microteia unpack --version ${version}" takes it; "latest" stays at `
266
+ + `${next.channels[STABLE_CHANNEL]}`);
267
+ /**
268
+ * Depois do catálogo, e sem derrubar a publicação: o README não é o que alguém instala, e uma
269
+ * falha nele não pode desfazer — nem fazer parecer desfeito — um release que já está no ar.
270
+ */
271
+ if (readme !== null) {
272
+ await writeReadme(repo, readme.sha, `README for kernel ${version}`)
273
+ .then(() => success(`${README_PATH} in ${repo} rewritten`))
274
+ .catch((error) => warn(`${README_PATH} in ${repo} could not be rewritten: ${error instanceof Error ? error.message : String(error)}`));
275
+ }
235
276
  }
236
277
  finally {
237
278
  await rm(notesRoot, { recursive: true, force: true });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@microteia/console",
3
- "version": "2.33.0",
3
+ "version": "2.36.0",
4
4
  "description": "Install and update Microteia in a Firebase project you own.",
5
5
  "license": "UNLICENSED",
6
6
  "type": "module",