@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.
@@ -19,6 +19,7 @@ import { pendingCauseOf } from './backendPending.js';
19
19
  import { settlePackagedSources } from './packagedApproval.js';
20
20
  import { clearPackagedSources, packagedSourceOf, parseApprovals } from './packagedSources.js';
21
21
  import { writeBackendResidue } from './backendResidue.js';
22
+ import { appliedEntryOf, readBackendApplied, reduceBackendApplied, writeBackendApplied, } from './backendApplied.js';
22
23
  import { listTenants, readInstalledExtensions } from './installedExtensions.js';
23
24
  import { gatherCustomFunctions, readOfficeFunctions } from './customFunctions.js';
24
25
  import { describeMisgated, gateProblems, misgatedTriggers, singleClaimants, } from './functionGate.js';
@@ -107,7 +108,9 @@ export const provisionBackend = async (options) => {
107
108
  * rejeitada com "this kernel package does not contain".
108
109
  */
109
110
  const survey = await surveyInstallations(projectId);
110
- const claims = survey.claims;
111
+ const claims = await settleProjectApplied(projectId, survey.claims);
112
+ /** O que esta execução carimbou — é o que `backend_state/applied` passa a dizer no fim. */
113
+ const stamped = [];
111
114
  /**
112
115
  * A fonte que veio NO PACOTE é decidida antes de qualquer conta: o que for aprovado é extraído para
113
116
  * `.microteia/packaged-functions/` e passa a ser lido como qualquer fonte da instalação. A leitura
@@ -521,7 +524,7 @@ export const provisionBackend = async (options) => {
521
524
  // function que já estava aplicada continua satisfazendo a extensão mesmo que o portão dela tenha
522
525
  // saído da fila nesta execução.
523
526
  if (isValid && (claim.backend.functions ?? []).every((name) => deployedNow.has(name))) {
524
- await stampApplied(projectId, claims, claim, settlement.approvedNow.get(claim.pluginId));
527
+ stamped.push(await stampApplied(projectId, claims, claim, settlement.approvedNow.get(claim.pluginId)));
525
528
  continue;
526
529
  }
527
530
  if (isValid)
@@ -582,8 +585,76 @@ export const provisionBackend = async (options) => {
582
585
  }), settled.declared);
583
586
  reportOrphanIndexes(projectId, settled.orphans, options.prune);
584
587
  await publishResidue(projectId, remaining, settled.orphans, survey);
588
+ await publishApplied(projectId, stamped);
585
589
  return outcome;
586
590
  };
591
+ /**
592
+ * O documento do projeto, conciliado com as cópias ANTES de qualquer conta.
593
+ *
594
+ * Primeiro materializa: a cópia sem carimbo próprio cujo fingerprint o projeto já tem aplicado recebe o
595
+ * carimbo agora, com a aprovação que veio de lá — é o que este comando gravaria se tivesse rodado quando
596
+ * ela foi atualizada. A partir daí ela se comporta como qualquer cópia carimbada, e a redução logo
597
+ * abaixo pode tirar a entrada dela sem tirá-la do ar.
598
+ *
599
+ * Depois reduz, e a redução vem antes de deploy e de `--prune` de propósito: se a escrita falhar, o
600
+ * comando para AQUI, antes de mudar o projeto. Seguir adiante deixaria o documento anunciando no ar a
601
+ * function que o `--prune` estaria prestes a apagar.
602
+ */
603
+ const settleProjectApplied = async (projectId, surveyed) => {
604
+ let entries = [];
605
+ try {
606
+ entries = await readBackendApplied(projectId);
607
+ }
608
+ catch (error) {
609
+ warn('could not read what this project already has applied:');
610
+ info(` ${error instanceof Error ? error.message : String(error)}`);
611
+ info(' copies installed after the last run are not released early; this run stamps them as usual.');
612
+ }
613
+ const appliedAt = new Date();
614
+ const claims = [];
615
+ for (const claim of surveyed) {
616
+ const entry = claim.applied ? undefined : appliedEntryOf(entries, { pluginId: claim.pluginId, fingerprint: claimFingerprint(claim) });
617
+ if (entry === undefined) {
618
+ claims.push(claim);
619
+ continue;
620
+ }
621
+ await writeStamp(projectId, claim.documentPath, entry.fingerprint, appliedAt, approvalOf(entry.approvedHash, entry.approvedBy));
622
+ claims.push({
623
+ ...claim,
624
+ applied: true,
625
+ ...(entry.approvedHash !== undefined ? { approvedHash: entry.approvedHash } : {}),
626
+ ...(entry.approvedBy !== undefined ? { approvedBy: entry.approvedBy } : {}),
627
+ });
628
+ }
629
+ const newest = uniqueByPlugin(claims).map((claim) => ({ pluginId: claim.pluginId, fingerprint: claimFingerprint(claim) }));
630
+ try {
631
+ await writeBackendApplied(projectId, reduceBackendApplied(entries, newest));
632
+ }
633
+ catch (error) {
634
+ throw new Error('could not update what this project has applied before changing it, so nothing was changed.\n'
635
+ + ` ${error instanceof Error ? error.message : String(error)}`, { cause: error });
636
+ }
637
+ return claims;
638
+ };
639
+ /**
640
+ * O que ficou aplicado, regravado por inteiro — inclusive vazio, que é o que apaga a entrada da extensão
641
+ * que saiu.
642
+ *
643
+ * Falhar aqui não derruba o comando, pela mesma razão do resíduo: o trabalho já foi feito. O documento
644
+ * fica como a redução o deixou, que só tem o que continua verdadeiro — o que se perde é o atalho, não a
645
+ * segurança.
646
+ */
647
+ const publishApplied = async (projectId, stamped) => {
648
+ try {
649
+ await writeBackendApplied(projectId, stamped);
650
+ }
651
+ catch (error) {
652
+ warn('could not record what this project has applied where the application can read it:');
653
+ info(` ${error instanceof Error ? error.message : String(error)}`);
654
+ info(' every copy installed now is stamped. A copy installed later stays pending until the next');
655
+ info(' "microteia provision".');
656
+ }
657
+ };
587
658
  /**
588
659
  * O que sobrou, devolvido ao banco para a aplicação poder mostrá-lo.
589
660
  *
@@ -689,30 +760,44 @@ const stampApplied = async (projectId, all, claim, approvedNowBy) => {
689
760
  * cópia nova de um escritório que reinstalou, sem perguntar de novo pelo código que já roda.
690
761
  */
691
762
  const hash = claim.source?.hash;
692
- const approval = hash === undefined
693
- ? {}
694
- : {
695
- approvedHash: asString(hash),
696
- approvedBy: asString(approvedNowBy
697
- ?? all.find((entry) => entry.pluginId === claim.pluginId && entry.approvedHash === hash)?.approvedBy
698
- ?? 'unknown'),
699
- approvedAt: asTimestamp(appliedAt),
700
- };
763
+ const approvedBy = hash === undefined
764
+ ? undefined
765
+ : approvedNowBy
766
+ ?? all.find((entry) => entry.pluginId === claim.pluginId && entry.approvedHash === hash)?.approvedBy
767
+ ?? 'unknown';
701
768
  for (const copy of all.filter((entry) => entry.pluginId === claim.pluginId)) {
702
769
  if (claimFingerprint(copy) !== fingerprint)
703
770
  continue;
704
- // O motivo de uma recusa anterior sai na MESMA escrita: duas escritas abririam uma janela em que a
705
- // tela mostra a extensão no ar e, ao mesmo tempo, por que ela não estava.
706
- await patchFields(projectId, copy.documentPath, {
707
- backendState: asMap({
708
- fingerprint: asString(fingerprint),
709
- appliedAt: asTimestamp(appliedAt),
710
- ...approval,
711
- }),
712
- backendPending: asNull(),
713
- });
771
+ await writeStamp(projectId, copy.documentPath, fingerprint, appliedAt, approvalOf(hash, approvedBy));
714
772
  }
715
773
  success(`${claim.name} is ready in ${projectId}`);
774
+ return {
775
+ pluginId: claim.pluginId,
776
+ fingerprint,
777
+ ...(hash !== undefined ? { approvedHash: hash } : {}),
778
+ ...(approvedBy !== undefined ? { approvedBy } : {}),
779
+ };
780
+ };
781
+ const approvalOf = (hash, by) => hash === undefined ? undefined : { hash, by: by ?? 'unknown' };
782
+ /** O carimbo numa cópia — o mesmo vindo do deploy desta execução ou do que o projeto já tinha aplicado. */
783
+ const writeStamp = async (projectId, documentPath, fingerprint, appliedAt, approval) => {
784
+ const approvalFields = approval === undefined
785
+ ? {}
786
+ : {
787
+ approvedHash: asString(approval.hash),
788
+ approvedBy: asString(approval.by),
789
+ approvedAt: asTimestamp(appliedAt),
790
+ };
791
+ // O motivo de uma recusa anterior sai na MESMA escrita: duas escritas abririam uma janela em que a
792
+ // tela mostra a extensão no ar e, ao mesmo tempo, por que ela não estava.
793
+ await patchFields(projectId, documentPath, {
794
+ backendState: asMap({
795
+ fingerprint: asString(fingerprint),
796
+ appliedAt: asTimestamp(appliedAt),
797
+ ...approvalFields,
798
+ }),
799
+ backendPending: asNull(),
800
+ });
716
801
  };
717
802
  /** O fingerprint que a cópia exige — o mesmo que a tela calcula, com o hash da fonte quando ela existe. */
718
803
  const claimFingerprint = (claim) => backendFingerprint(claim.backend, claim.source?.hash);
package/lib/index.js CHANGED
@@ -89,7 +89,7 @@ const COMMANDS = [
89
89
  {
90
90
  name: 'publish',
91
91
  usage: 'microteia publish [--kernel-only|--console-only] [--repo <owner/name>] [--otp <code>] [--dry-run]',
92
- summary: 'Release what pack and pack-console built, so an office can download it.',
92
+ summary: 'Release what pack and pack-console built, so an office can download it. Push first: the kernel is tagged on its commit.',
93
93
  audience: 'publisher',
94
94
  execute: publish,
95
95
  },
@@ -63,6 +63,14 @@ export interface KernelManifest {
63
63
  * para um arquivo que não existe.
64
64
  */
65
65
  sdk?: KernelSdk;
66
+ /**
67
+ * De que commit do repositório-fonte o pacote saiu. É lido pelo `pack` ANTES de qualquer build, e é
68
+ * o que o `publish` marca com a tag `kernel-<versão>` no repositório-fonte — sem ele, saber de que
69
+ * código veio um kernel publicado exigia adivinhar pelo histórico do `package.json`.
70
+ *
71
+ * Ausente nos pacotes anteriores a este campo e nos montados fora de um checkout git.
72
+ */
73
+ source?: KernelSource;
66
74
  /**
67
75
  * O tamanho e a impressão digital do tarball, presentes SÓ na cópia externa do manifesto — a que fica
68
76
  * ao lado do `.tgz` e é publicada com ele. A cópia de dentro do tarball não pode carregá-los: ela
@@ -74,6 +82,15 @@ export interface KernelManifest {
74
82
  */
75
83
  tarball?: KernelTarball;
76
84
  }
85
+ export interface KernelSource {
86
+ commit: string;
87
+ /**
88
+ * Havia alteração não commitada quando o pacote foi montado. Nesse caso nenhum commit é a fonte do
89
+ * pacote, e o `publish` não marca nenhum: uma tag que aponta para código diferente do publicado é
90
+ * pior que tag nenhuma.
91
+ */
92
+ dirty: boolean;
93
+ }
77
94
  export interface KernelTarball {
78
95
  /** O nome do arquivo, não um caminho: onde ele fica é decisão de quem publica. */
79
96
  name: string;
@@ -141,20 +141,6 @@ export const MANUAL_PREREQUISITES = [
141
141
  + 'changes for anyone.',
142
142
  optional: true,
143
143
  },
144
- {
145
- id: 'self-service-tenants',
146
- title: 'Stop sign-up from opening an organization, if this installation is shared',
147
- detail: 'By default every new account gets an organization of its own, with that person as the owner. In an '
148
- + 'installation shared by several organizations — each one created on purpose — that lets anyone who '
149
- + 'can create an account open one. Write SELF_SERVICE_TENANTS=false in .env.<projectId> and run '
150
- + 'microteia deploy --only functions: from then on a new account waits to be added to an organization, and only the '
151
- + 'installation administrator (SYSTEM) opens organizations, in Access management > Organizations, '
152
- + 'naming an owner who already has an account. The first account of the installation gets one '
153
- + 'anyway. Any value other than empty, true or false is read as false. Two-step verification is '
154
- + 'required per organization and does not reach the installation administrator: protect SYSTEM '
155
- + 'accounts with a sign-in provider that enforces its own second factor.',
156
- optional: true,
157
- },
158
144
  ];
159
145
  /**
160
146
  * Os nomes das Cloud Functions saem de `functions/src/index.ts`, a mesma fonte e o mesmo parse que
@@ -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.38.0";
90
+ export declare const CLI_VERSION = "2.42.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.38.0';
97
+ export const CLI_VERSION = '2.42.0';
@@ -197,7 +197,7 @@ setting:
197
197
 
198
198
  - **A setting of an extension, or of your own \`functions/\`** — run \`microteia provision\`. It notices the
199
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
200
+ - **A setting of the platform itself** (\`AUDIT_ARCHIVE_BUCKET\`, …) — run
201
201
  \`microteia deploy --only functions\`. \`provision\` does not track them; the deploy compares each
202
202
  function's stamp, which includes its environment, and redeploys the ones whose value changed.
203
203
 
@@ -2,7 +2,7 @@ import { cp, mkdir, readdir, readFile, rename, rm, writeFile } from 'node:fs/pro
2
2
  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
- import { run } from '../shared/run.js';
5
+ import { capture, run } from '../shared/run.js';
6
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';
@@ -111,6 +111,18 @@ const stageSdk = async (root, stageDir, version) => {
111
111
  info(` ${examples.length} first-party extension(s) staged as source examples`);
112
112
  return { version, kernelVersion, path: `sdk/${SDK_TARBALL}` };
113
113
  };
114
+ /**
115
+ * Lido ANTES do gate e dos builds: eles geram arquivos, e o que importa é o estado da árvore que o
116
+ * pacote vai refletir, não o que o próprio `pack` deixou nela. Fora de um checkout git não há commit a
117
+ * registrar, e isso não é motivo para recusar o pacote.
118
+ */
119
+ const sourceOf = async (root) => {
120
+ const head = await capture('git', ['rev-parse', 'HEAD'], { cwd: root }).catch(() => null);
121
+ if (head === null || head.code !== 0)
122
+ return undefined;
123
+ const status = await capture('git', ['status', '--porcelain'], { cwd: root });
124
+ return { commit: head.stdout.trim(), dirty: status.code !== 0 || status.stdout.trim() !== '' };
125
+ };
114
126
  export const pack = async (args) => {
115
127
  await assertInsideRepository('pack');
116
128
  const root = repoRoot;
@@ -145,6 +157,22 @@ export const pack = async (args) => {
145
157
  const stageDir = join(outputDir, 'package');
146
158
  const tarball = join(outputDir, kernelTarballName(version));
147
159
  heading(`Packaging Microteia kernel ${version} (contract ${kernelVersion})`);
160
+ const source = await sourceOf(root);
161
+ if (source === undefined) {
162
+ warn('Not a git checkout: the package will not say which commit it came from, and publishing will not tag one.');
163
+ }
164
+ else if (source.dirty) {
165
+ warn(`Uncommitted changes: the package is not commit ${source.commit.slice(0, 12)}, and publishing will not `
166
+ + 'tag the source repository. Commit first to have it tagged.');
167
+ }
168
+ else {
169
+ info(` from commit ${source.commit.slice(0, 12)}`);
170
+ // Pelas referências remotas LOCAIS, sem rede: é só um lembrete. Quem cobra é o `publish`, contra o GitHub.
171
+ const remote = await capture('git', ['branch', '-r', '--contains', source.commit], { cwd: root });
172
+ if (remote.code === 0 && remote.stdout.trim() === '') {
173
+ warn(`Commit ${source.commit.slice(0, 12)} has not been pushed: "microteia publish" will refuse until it is.`);
174
+ }
175
+ }
148
176
  const total = 7;
149
177
  if (skipVerify) {
150
178
  info('Quality gate skipped (--skip-verify).');
@@ -201,6 +229,11 @@ export const pack = async (args) => {
201
229
  // O histórico do kernel viaja DENTRO do pacote, e é de lá que o `unpack` o copia para a raiz da
202
230
  // instalação. Escrito do conteúdo já conferido, para não ler o arquivo duas vezes.
203
231
  await writeFile(join(stageDir, KERNEL_CHANGELOG), changelog);
232
+ // A AGPL exige que o texto da licença acompanhe cada cópia distribuída do compilado, e o NOTICE
233
+ // carrega os termos da seção 7 — atribuição e exceção para extensões — que só valem se viajarem juntos.
234
+ for (const file of ['LICENSE', 'NOTICE']) {
235
+ await cp(join(root, file), join(stageDir, file));
236
+ }
204
237
  // O `firestore.indexes.json` do pacote é ESCRITO, não copiado: a raiz é o conjunto completo (é o que
205
238
  // roda em `microteia-dev` e `curatino`), e o que uma extensão reivindica sai daqui para ser criado
206
239
  // pelo `provision` quando ela for instalada. Os `fieldOverrides` são todos do kernel e vão inteiros.
@@ -219,6 +252,7 @@ export const pack = async (args) => {
219
252
  kernelVersion,
220
253
  minimumCliVersion: MINIMUM_CLI_VERSION,
221
254
  packagedAt: new Date().toISOString(),
255
+ source,
222
256
  components: KERNEL_COMPONENTS,
223
257
  requires: {
224
258
  functionsRegion: DEFAULT_FUNCTIONS_REGION,
@@ -48,6 +48,11 @@ export const packConsole = async (args) => {
48
48
  await writeFile(join(outputDir, 'package.json'), consolePackageJson(CLI_VERSION));
49
49
  await writeFile(join(outputDir, 'README.md'), consoleReadme(CLI_VERSION));
50
50
  await writeFile(join(outputDir, CONSOLE_CHANGELOG), changelog);
51
+ // A AGPL exige que o texto da licença acompanhe cada cópia distribuída — e esta é a que vai ao npm.
52
+ // O NOTICE carrega os termos da seção 7 (atribuição, exceção para extensões), que viajam junto.
53
+ for (const file of ['LICENSE', 'NOTICE']) {
54
+ await cp(join(repoRoot, file), join(outputDir, file));
55
+ }
51
56
  // Executável com o shebang que o entrypoint já tem: é o que permite apontar um link para ele em
52
57
  // qualquer diretório do PATH, sem npm e sem sudo. O `tsc` não carimba a permissão, e no repositório
53
58
  // ela vem do `npm install` (que a aplica no alvo do `bin`) — o que não existe numa pasta copiada.
@@ -166,10 +166,11 @@ export const consolePackageJson = (version) => JSON.stringify({
166
166
  name: CONSOLE_PACKAGE_NAME,
167
167
  version,
168
168
  description: 'Install and update Microteia in a Firebase project you own.',
169
- license: 'UNLICENSED',
169
+ license: 'AGPL-3.0-or-later',
170
+ repository: { type: 'git', url: 'git+https://github.com/microteia/microteia.git' },
170
171
  type: 'module',
171
172
  bin: { microteia: 'lib/index.js' },
172
- files: ['lib', 'README.md', 'CONSOLE-VERSIONS.md'],
173
+ files: ['lib', 'README.md', 'CONSOLE-VERSIONS.md', 'LICENSE', 'NOTICE'],
173
174
  engines: { node: '>=22' },
174
175
  publishConfig: { access: 'public' },
175
176
  }, null, 2) + '\n';
@@ -239,4 +240,11 @@ the Microteia repository.
239
240
 
240
241
  \`CONSOLE-VERSIONS.md\`, in this folder. \`unpack\` copies it into the installation it manages, beside the
241
242
  platform's own \`KERNEL-VERSIONS.md\` — each history travels with the artifact it describes.
243
+
244
+ ## License
245
+
246
+ Free software, licensed under the GNU Affero General Public License, version 3 or (at your option) any
247
+ later version — the full text is in \`LICENSE\`, and the additional terms in \`NOTICE\`, in this folder.
248
+ The source code of this Console and of the platform it installs is at
249
+ https://github.com/microteia/microteia.
242
250
  `;
@@ -161,6 +161,57 @@ const releaseExists = async (repo, version) => {
161
161
  const result = await capture('gh', ['release', 'view', tagOf(version), '--repo', repo]);
162
162
  return result.code === 0;
163
163
  };
164
+ /** O que impede marcar o repositório-fonte, já dito em palavras; `null` quando dá para marcar. */
165
+ const sourceReleaseBlocker = (manifest) => {
166
+ if (manifest.source === undefined) {
167
+ return 'its manifest does not say which commit it came from (packed before this was recorded, or outside git)';
168
+ }
169
+ if (manifest.source.dirty) {
170
+ return `it was packed with uncommitted changes, so commit ${manifest.source.commit.slice(0, 12)} is not its source`;
171
+ }
172
+ return null;
173
+ };
174
+ const sourceRepository = async (root) => (await captureOrThrow('gh', ['repo', 'view', '--json', 'nameWithOwner', '--jq', '.nameWithOwner'], { cwd: root }))
175
+ .trim();
176
+ const sourceReleaseNotes = (releasesRepo, version, notes) => `Packaged from this commit. The installable artifacts are published in `
177
+ + `[${releasesRepo}](https://github.com/${releasesRepo}/releases/tag/${tagOf(version)}).\n\n${notes}`;
178
+ /**
179
+ * O commit de origem tem de estar no GitHub ANTES de qualquer coisa subir. Conferido só no fim, ele
180
+ * deixava o kernel publicado e a tag por fazer à mão — e rodar o `publish` de novo não conserta,
181
+ * porque o catálogo já recusa a versão publicada. Recusado aqui, nada saiu, e o mesmo comando
182
+ * funciona depois do `git push`.
183
+ *
184
+ * Um 422 é o GitHub dizendo que não conhece o commit; qualquer outra falha é dita como é, para não
185
+ * mandar dar push quem tem um problema de rede ou de credencial.
186
+ */
187
+ const assertSourcePushed = async (target, version) => {
188
+ const result = await capture('gh', ['api', `repos/${target.repo}/commits/${target.commit}`]);
189
+ if (result.code === 0)
190
+ return;
191
+ const detail = `${result.stderr}${result.stdout}`;
192
+ if (detail.includes('HTTP 422') || detail.includes('No commit found')) {
193
+ throw new Error(`Kernel ${version} was packed from commit ${target.commit.slice(0, 12)}, which is not on GitHub yet.\n`
194
+ + `Push it first ("git push"), then publish again — nothing was published.\n`
195
+ + `The tag ${tagOf(version)} goes on that commit in ${target.repo}, and GitHub cannot tag a commit it does not have.`);
196
+ }
197
+ throw new Error(`Could not check whether commit ${target.commit.slice(0, 12)} is on GitHub (${target.repo}).\n`
198
+ + `${result.stderr.trim() || result.stdout.trim()}`);
199
+ };
200
+ const createSourceRelease = async (target, version, notesFile, latest) => {
201
+ if (await releaseExists(target.repo, version)) {
202
+ info(` ${tagOf(version)} already exists in ${target.repo}`);
203
+ return;
204
+ }
205
+ await captureOrThrow('gh', [
206
+ 'release', 'create', tagOf(version),
207
+ '--repo', target.repo,
208
+ '--target', target.commit,
209
+ '--title', `Microteia kernel ${version}`,
210
+ '--notes-file', notesFile,
211
+ `--latest=${latest}`,
212
+ ]);
213
+ success(`${tagOf(version)} marks commit ${target.commit.slice(0, 12)} in ${target.repo}`);
214
+ };
164
215
  /**
165
216
  * O pacote do kernel, conferido antes de qualquer coisa subir.
166
217
  *
@@ -221,6 +272,30 @@ const publishKernel = async (root, version, repo, dryRun) => {
221
272
  info(notes.versions.length === 1
222
273
  ? ` release notes: the ${version} entry of ${KERNEL_CHANGELOG}`
223
274
  : ` release notes: ${notes.versions.join(', ')} — every entry since ${since ?? 'the first one'}`);
275
+ /**
276
+ * Pacote sem commit que o represente (antigo, ou montado com árvore suja) só avisa: recusar travaria a
277
+ * publicação por um registro que não tem como existir. Já o commit que existe e não subiu RECUSA — o
278
+ * conserto é um `git push`, e ele vem antes de qualquer coisa ir para o ar.
279
+ */
280
+ let source = null;
281
+ const blocker = sourceReleaseBlocker(manifest);
282
+ if (blocker !== null) {
283
+ warn(`the source repository will not be tagged: ${blocker}`);
284
+ }
285
+ else if (manifest.source !== undefined) {
286
+ const sourceRepo = await sourceRepository(root).catch(() => null);
287
+ if (sourceRepo === null) {
288
+ warn('the source repository will not be tagged: "gh repo view" could not name the repository of this checkout');
289
+ }
290
+ else if (sourceRepo === repo) {
291
+ warn(`the source repository will not be tagged: it is ${repo} itself, which already gets ${tagOf(version)}`);
292
+ }
293
+ else {
294
+ source = { repo: sourceRepo, commit: manifest.source.commit };
295
+ await assertSourcePushed(source, version);
296
+ info(` and ${tagOf(version)} on commit ${source.commit.slice(0, 12)} in ${sourceRepo}, pointing here`);
297
+ }
298
+ }
224
299
  if (dryRun) {
225
300
  if (published.empty)
226
301
  info(` ${repo} has no commits, so it would be seeded with a README first`);
@@ -280,6 +355,14 @@ const publishKernel = async (root, version, repo, dryRun) => {
280
355
  .then(() => success(`${README_PATH} in ${repo} rewritten`))
281
356
  .catch((error) => warn(`${README_PATH} in ${repo} could not be rewritten: ${error instanceof Error ? error.message : String(error)}`));
282
357
  }
358
+ // Pela mesma razão do README: depois do catálogo, e a falha só avisa.
359
+ if (source !== null) {
360
+ const target = source;
361
+ const sourceNotesFile = join(notesRoot, 'source-notes.md');
362
+ await writeFile(sourceNotesFile, sourceReleaseNotes(repo, version, notes.body));
363
+ await createSourceRelease(target, version, sourceNotesFile, promotes)
364
+ .catch((error) => warn(`${tagOf(version)} could not be created in ${target.repo}: ${error instanceof Error ? error.message : String(error)}`));
365
+ }
283
366
  }
284
367
  finally {
285
368
  await rm(notesRoot, { recursive: true, force: true });
package/package.json CHANGED
@@ -1,8 +1,12 @@
1
1
  {
2
2
  "name": "@microteia/console",
3
- "version": "2.38.0",
3
+ "version": "2.42.0",
4
4
  "description": "Install and update Microteia in a Firebase project you own.",
5
- "license": "UNLICENSED",
5
+ "license": "AGPL-3.0-or-later",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "git+https://github.com/microteia/microteia.git"
9
+ },
6
10
  "type": "module",
7
11
  "bin": {
8
12
  "microteia": "lib/index.js"
@@ -10,7 +14,9 @@
10
14
  "files": [
11
15
  "lib",
12
16
  "README.md",
13
- "CONSOLE-VERSIONS.md"
17
+ "CONSOLE-VERSIONS.md",
18
+ "LICENSE",
19
+ "NOTICE"
14
20
  ],
15
21
  "engines": {
16
22
  "node": ">=22"