@microteia/console 2.7.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.
- package/CONSOLE-VERSIONS.md +85 -0
- package/README.md +64 -0
- package/lib/console/backendEnv.d.ts +52 -0
- package/lib/console/backendEnv.js +118 -0
- package/lib/console/backendIndexes.d.ts +40 -0
- package/lib/console/backendIndexes.js +178 -0
- package/lib/console/backendParameters.d.ts +48 -0
- package/lib/console/backendParameters.js +100 -0
- package/lib/console/callableAccess.d.ts +39 -0
- package/lib/console/callableAccess.js +61 -0
- package/lib/console/channel.d.ts +49 -0
- package/lib/console/channel.js +145 -0
- package/lib/console/connect.d.ts +2 -0
- package/lib/console/connect.js +74 -0
- package/lib/console/customFunctions.d.ts +120 -0
- package/lib/console/customFunctions.js +298 -0
- package/lib/console/deploy.d.ts +2 -0
- package/lib/console/deploy.js +261 -0
- package/lib/console/deployFunctions.d.ts +45 -0
- package/lib/console/deployFunctions.js +204 -0
- package/lib/console/environment.d.ts +62 -0
- package/lib/console/environment.js +151 -0
- package/lib/console/firebaseCli.d.ts +15 -0
- package/lib/console/firebaseCli.js +90 -0
- package/lib/console/firestoreRest.d.ts +39 -0
- package/lib/console/firestoreRest.js +131 -0
- package/lib/console/hostingCheck.d.ts +29 -0
- package/lib/console/hostingCheck.js +44 -0
- package/lib/console/init.d.ts +2 -0
- package/lib/console/init.js +60 -0
- package/lib/console/pluginWorkspace.d.ts +48 -0
- package/lib/console/pluginWorkspace.js +148 -0
- package/lib/console/provision.d.ts +70 -0
- package/lib/console/provision.js +478 -0
- package/lib/console/regionOrphans.d.ts +43 -0
- package/lib/console/regionOrphans.js +99 -0
- package/lib/console/runtimeConfigShape.d.ts +25 -0
- package/lib/console/runtimeConfigShape.js +35 -0
- package/lib/console/unpack.d.ts +9 -0
- package/lib/console/unpack.js +333 -0
- package/lib/console/versions.d.ts +10 -0
- package/lib/console/versions.js +72 -0
- package/lib/console/workspace.d.ts +19 -0
- package/lib/console/workspace.js +33 -0
- package/lib/index.d.ts +2 -0
- package/lib/index.js +136 -0
- package/lib/kernel/backendShape.d.ts +74 -0
- package/lib/kernel/backendShape.js +91 -0
- package/lib/kernel/channel.d.ts +79 -0
- package/lib/kernel/channel.js +157 -0
- package/lib/kernel/manifest.d.ts +138 -0
- package/lib/kernel/manifest.js +145 -0
- package/lib/shared/args.d.ts +14 -0
- package/lib/shared/args.js +44 -0
- package/lib/shared/digest.d.ts +30 -0
- package/lib/shared/digest.js +34 -0
- package/lib/shared/paths.d.ts +90 -0
- package/lib/shared/paths.js +97 -0
- package/lib/shared/run.d.ts +28 -0
- package/lib/shared/run.js +49 -0
- package/lib/shared/ui.d.ts +23 -0
- package/lib/shared/ui.js +43 -0
- package/lib/vendor/authoringGuide.d.ts +19 -0
- package/lib/vendor/authoringGuide.js +284 -0
- package/lib/vendor/bundleAudit.d.ts +19 -0
- package/lib/vendor/bundleAudit.js +95 -0
- package/lib/vendor/changelogs.d.ts +62 -0
- package/lib/vendor/changelogs.js +131 -0
- package/lib/vendor/exampleSources.d.ts +29 -0
- package/lib/vendor/exampleSources.js +126 -0
- package/lib/vendor/installationDocs.d.ts +35 -0
- package/lib/vendor/installationDocs.js +243 -0
- package/lib/vendor/pack.d.ts +2 -0
- package/lib/vendor/pack.js +276 -0
- package/lib/vendor/packConsole.d.ts +13 -0
- package/lib/vendor/packConsole.js +63 -0
- package/lib/vendor/packageTemplates.d.ts +66 -0
- package/lib/vendor/packageTemplates.js +200 -0
- package/lib/vendor/pluginWorkspaceTemplates.d.ts +19 -0
- package/lib/vendor/pluginWorkspaceTemplates.js +29 -0
- package/lib/vendor/publish.d.ts +2 -0
- package/lib/vendor/publish.js +277 -0
- package/lib/vendor/repository.d.ts +9 -0
- package/lib/vendor/repository.js +26 -0
- package/package.json +21 -0
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import { captureOrThrow } from '../shared/run.js';
|
|
2
|
+
/**
|
|
3
|
+
* Leitura e escrita de documento pela REST do Firestore.
|
|
4
|
+
*
|
|
5
|
+
* Por que existe: o `provision` precisa saber o que o escritório INSTALOU, e isso só está no banco do
|
|
6
|
+
* escritório. O `firebase` não tem comando para ler nem gravar um documento, então o caminho é a API
|
|
7
|
+
* com o token de quem está na máquina.
|
|
8
|
+
*
|
|
9
|
+
* O Console continua sem autenticação própria e sem ver credencial: o token vem do `gcloud`, que já é
|
|
10
|
+
* requisito de quem instala (é ele que cria o App Engine). Nada é guardado — o token é pedido a cada
|
|
11
|
+
* execução e vive só na memória do processo.
|
|
12
|
+
*/
|
|
13
|
+
const HOST = 'https://firestore.googleapis.com/v1';
|
|
14
|
+
const GCLOUD_BIN = 'gcloud';
|
|
15
|
+
let cachedToken = null;
|
|
16
|
+
export const accessToken = async () => {
|
|
17
|
+
if (cachedToken !== null)
|
|
18
|
+
return cachedToken;
|
|
19
|
+
try {
|
|
20
|
+
cachedToken = (await captureOrThrow(GCLOUD_BIN, ['auth', 'print-access-token'])).trim();
|
|
21
|
+
}
|
|
22
|
+
catch {
|
|
23
|
+
throw new Error('Could not get a Google access token. Sign in once with: gcloud auth login\n'
|
|
24
|
+
+ ' (the Console never stores credentials — it asks gcloud every time)');
|
|
25
|
+
}
|
|
26
|
+
if (cachedToken === '')
|
|
27
|
+
throw new Error('gcloud returned an empty access token. Run: gcloud auth login');
|
|
28
|
+
return cachedToken;
|
|
29
|
+
};
|
|
30
|
+
/**
|
|
31
|
+
* `X-Goog-User-Project` diz de qual projeto é a cota. Sem ele, uma credencial de usuário é recusada
|
|
32
|
+
* com uma mensagem sobre "quota project" que não menciona o que fazer.
|
|
33
|
+
*/
|
|
34
|
+
const request = async (projectId, path, init = { method: 'GET' }) => {
|
|
35
|
+
const token = await accessToken();
|
|
36
|
+
const url = new URL(`${HOST}/projects/${projectId}/databases/(default)/documents${path}`);
|
|
37
|
+
for (const [key, value] of Object.entries(init.query ?? {})) {
|
|
38
|
+
for (const item of Array.isArray(value) ? value : [value])
|
|
39
|
+
url.searchParams.append(key, item);
|
|
40
|
+
}
|
|
41
|
+
const response = await fetch(url, {
|
|
42
|
+
method: init.method,
|
|
43
|
+
headers: {
|
|
44
|
+
authorization: `Bearer ${token}`,
|
|
45
|
+
'x-goog-user-project': projectId,
|
|
46
|
+
...(init.body === undefined ? {} : { 'content-type': 'application/json' }),
|
|
47
|
+
},
|
|
48
|
+
...(init.body === undefined ? {} : { body: JSON.stringify(init.body) }),
|
|
49
|
+
});
|
|
50
|
+
const text = await response.text();
|
|
51
|
+
if (!response.ok) {
|
|
52
|
+
const detail = text.slice(0, 400);
|
|
53
|
+
if (response.status === 403) {
|
|
54
|
+
throw new Error(`Firestore refused the request for ${projectId} (403). The signed-in gcloud account needs `
|
|
55
|
+
+ `access to the project's database.\n ${detail}`);
|
|
56
|
+
}
|
|
57
|
+
throw new Error(`Firestore answered ${response.status} for ${path}.\n ${detail}`);
|
|
58
|
+
}
|
|
59
|
+
return text === '' ? {} : JSON.parse(text);
|
|
60
|
+
};
|
|
61
|
+
/** Um valor tipado da REST virando valor de JavaScript. Timestamp vira string ISO, que é o que basta. */
|
|
62
|
+
export const decodeValue = (value) => {
|
|
63
|
+
if (typeof value !== 'object' || value === null)
|
|
64
|
+
return undefined;
|
|
65
|
+
const typed = value;
|
|
66
|
+
if ('stringValue' in typed)
|
|
67
|
+
return typed.stringValue;
|
|
68
|
+
if ('booleanValue' in typed)
|
|
69
|
+
return typed.booleanValue;
|
|
70
|
+
if ('integerValue' in typed)
|
|
71
|
+
return Number(typed.integerValue);
|
|
72
|
+
if ('doubleValue' in typed)
|
|
73
|
+
return typed.doubleValue;
|
|
74
|
+
if ('timestampValue' in typed)
|
|
75
|
+
return typed.timestampValue;
|
|
76
|
+
if ('nullValue' in typed)
|
|
77
|
+
return null;
|
|
78
|
+
if ('arrayValue' in typed) {
|
|
79
|
+
const values = typed.arrayValue?.values ?? [];
|
|
80
|
+
return values.map(decodeValue);
|
|
81
|
+
}
|
|
82
|
+
if ('mapValue' in typed)
|
|
83
|
+
return decodeFields(typed.mapValue?.fields ?? {});
|
|
84
|
+
return undefined;
|
|
85
|
+
};
|
|
86
|
+
export const decodeFields = (fields) => Object.fromEntries(Object.entries(fields).map(([key, value]) => [key, decodeValue(value)]));
|
|
87
|
+
/**
|
|
88
|
+
* Os documentos de uma coleção, seguindo a paginação até o fim.
|
|
89
|
+
*
|
|
90
|
+
* Sem máscara de campo, e de propósito: mascarar um caminho aninhado (`package.manifest`) funciona até
|
|
91
|
+
* uma versão da API discordar da forma, e o sintoma seria uma extensão sumindo do provisionamento sem
|
|
92
|
+
* erro. Documento de extensão é grande, mas são poucos por escritório, e isto roda uma vez por comando.
|
|
93
|
+
*/
|
|
94
|
+
export const listDocuments = async (projectId, path) => {
|
|
95
|
+
const documents = [];
|
|
96
|
+
let pageToken;
|
|
97
|
+
do {
|
|
98
|
+
const page = await request(projectId, path, {
|
|
99
|
+
method: 'GET',
|
|
100
|
+
query: { pageSize: '300', ...(pageToken === undefined ? {} : { pageToken }) },
|
|
101
|
+
});
|
|
102
|
+
for (const entry of page.documents ?? []) {
|
|
103
|
+
documents.push({ name: entry.name, fields: entry.fields ?? {} });
|
|
104
|
+
}
|
|
105
|
+
pageToken = page.nextPageToken;
|
|
106
|
+
} while (pageToken !== undefined);
|
|
107
|
+
return documents;
|
|
108
|
+
};
|
|
109
|
+
export const documentId = (document) => document.name.slice(document.name.lastIndexOf('/') + 1);
|
|
110
|
+
export const asString = (value) => ({ stringValue: value });
|
|
111
|
+
/**
|
|
112
|
+
* Data como DATA, e não como texto que se parece com uma.
|
|
113
|
+
*
|
|
114
|
+
* O tipo do valor gravado é escolhido aqui e lido do outro lado do produto, meses depois, por quem não
|
|
115
|
+
* viu esta linha. Gravar uma data como `stringValue` custou um botão que não funcionava: a tela lia o
|
|
116
|
+
* texto, exibia certo, e só falhava ao gravar de volta. O `timestampValue` fecha essa porta — e é
|
|
117
|
+
* também o que permite ordenar e comparar o campo no banco. A REST exige RFC 3339 em UTC, que é
|
|
118
|
+
* exatamente o que `toISOString` devolve.
|
|
119
|
+
*/
|
|
120
|
+
export const asTimestamp = (value) => ({ timestampValue: value.toISOString() });
|
|
121
|
+
/**
|
|
122
|
+
* Grava UM campo de mapa, com `updateMask` — o resto do documento não é tocado. É o que permite o
|
|
123
|
+
* Console carimbar o estado sem conhecer (nem arriscar reescrever) o pacote inteiro que vive ao lado.
|
|
124
|
+
*/
|
|
125
|
+
export const patchMapField = async (projectId, path, field, entries) => {
|
|
126
|
+
await request(projectId, path, {
|
|
127
|
+
method: 'PATCH',
|
|
128
|
+
query: { 'updateMask.fieldPaths': field },
|
|
129
|
+
body: { fields: { [field]: { mapValue: { fields: entries } } } },
|
|
130
|
+
});
|
|
131
|
+
};
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Publicar não é o mesmo que SERVIR.
|
|
3
|
+
*
|
|
4
|
+
* O `index.html` de um SPA aponta para um bundle com hash no nome, e o Firebase o serve com
|
|
5
|
+
* `max-age=3600` quando a configuração não diz nada — então, por uma hora depois de um deploy, um
|
|
6
|
+
* navegador que já esteve no site recebe o HTML ANTIGO, que aponta para o bundle antigo, que está
|
|
7
|
+
* cacheado como `immutable`. O resultado é a aplicação continuar rodando a versão anterior, com o
|
|
8
|
+
* deploy tendo terminado em sucesso: o rodapé mostra uma versão que não é a instalada, e não há nada
|
|
9
|
+
* na saída do deploy que explique isso.
|
|
10
|
+
*
|
|
11
|
+
* A verificação compara o bundle que o pacote instalou com o que a URL de verdade devolve, pelo mesmo
|
|
12
|
+
* caminho do navegador. É a irmã de `callableAccess`: uma confere se o servidor responde, esta confere
|
|
13
|
+
* se o cliente recebe.
|
|
14
|
+
*/
|
|
15
|
+
export type HostingServed = 'current' | 'stale' | 'unknown';
|
|
16
|
+
export interface HostingCheck {
|
|
17
|
+
served: HostingServed;
|
|
18
|
+
packagedBundle: string | null;
|
|
19
|
+
servedBundle: string | null;
|
|
20
|
+
/** `Cache-Control` do HTML. Um `max-age` longo aqui é o que faz um deploy demorar a aparecer. */
|
|
21
|
+
htmlCacheControl: string | null;
|
|
22
|
+
}
|
|
23
|
+
export declare const bundleReferenceIn: (html: string) => string | null;
|
|
24
|
+
/**
|
|
25
|
+
* Um `max-age` maior que zero no HTML é o bastante para atrasar a versão nova. `no-cache` e
|
|
26
|
+
* `no-store` são as duas formas de dizer "revalide sempre", e qualquer uma delas está correta.
|
|
27
|
+
*/
|
|
28
|
+
export declare const htmlIsRevalidated: (cacheControl: string | null) => boolean;
|
|
29
|
+
export declare const checkHostingServed: (install: string, origin: string) => Promise<HostingCheck>;
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { readFile } from 'node:fs/promises';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
const BUNDLE_PATTERN = /assets\/index-[A-Za-z0-9_-]+\.js/;
|
|
4
|
+
export const bundleReferenceIn = (html) => html.match(BUNDLE_PATTERN)?.[0] ?? null;
|
|
5
|
+
/**
|
|
6
|
+
* Um `max-age` maior que zero no HTML é o bastante para atrasar a versão nova. `no-cache` e
|
|
7
|
+
* `no-store` são as duas formas de dizer "revalide sempre", e qualquer uma delas está correta.
|
|
8
|
+
*/
|
|
9
|
+
export const htmlIsRevalidated = (cacheControl) => {
|
|
10
|
+
if (cacheControl === null)
|
|
11
|
+
return false;
|
|
12
|
+
const value = cacheControl.toLowerCase();
|
|
13
|
+
if (value.includes('no-cache') || value.includes('no-store'))
|
|
14
|
+
return true;
|
|
15
|
+
const maxAge = value.match(/max-age=(\d+)/);
|
|
16
|
+
return maxAge !== null && Number.parseInt(maxAge[1], 10) === 0;
|
|
17
|
+
};
|
|
18
|
+
export const checkHostingServed = async (install, origin) => {
|
|
19
|
+
const packagedBundle = await readFile(join(install, 'hosting', 'index.html'), 'utf8')
|
|
20
|
+
.then(bundleReferenceIn)
|
|
21
|
+
.catch(() => null);
|
|
22
|
+
try {
|
|
23
|
+
const response = await fetch(`${origin}/index.html`, {
|
|
24
|
+
// O objetivo é ver o que a origem serve, então nada de cache intermediário nosso.
|
|
25
|
+
headers: { 'cache-control': 'no-cache' },
|
|
26
|
+
signal: AbortSignal.timeout(10_000),
|
|
27
|
+
});
|
|
28
|
+
const html = await response.text();
|
|
29
|
+
const servedBundle = bundleReferenceIn(html);
|
|
30
|
+
const htmlCacheControl = response.headers.get('cache-control');
|
|
31
|
+
return {
|
|
32
|
+
served: servedBundle === null || packagedBundle === null
|
|
33
|
+
? 'unknown'
|
|
34
|
+
: servedBundle === packagedBundle ? 'current' : 'stale',
|
|
35
|
+
packagedBundle,
|
|
36
|
+
servedBundle,
|
|
37
|
+
htmlCacheControl,
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
catch {
|
|
41
|
+
// Rede indisponível ou proxy no caminho: avisa sem acusar, como a verificação das callables.
|
|
42
|
+
return { served: 'unknown', packagedBundle, servedBundle: null, htmlCacheControl: null };
|
|
43
|
+
}
|
|
44
|
+
};
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { access, mkdir, writeFile } from 'node:fs/promises';
|
|
2
|
+
import { basename, dirname, join, resolve } from 'node:path';
|
|
3
|
+
import { flagValue, hasFlag } from '../shared/args.js';
|
|
4
|
+
import { blank, bullets, heading, info, success } from '../shared/ui.js';
|
|
5
|
+
import { DEFAULT_FUNCTIONS_REGION } from '../kernel/manifest.js';
|
|
6
|
+
import { ENVIRONMENT_FILE, slug, writeEnvironment } from './environment.js';
|
|
7
|
+
import { WORKSPACE_DIR } from './workspace.js';
|
|
8
|
+
import { ensureRootGitignore } from './backendEnv.js';
|
|
9
|
+
const exists = async (path) => {
|
|
10
|
+
try {
|
|
11
|
+
await access(path);
|
|
12
|
+
return true;
|
|
13
|
+
}
|
|
14
|
+
catch {
|
|
15
|
+
return false;
|
|
16
|
+
}
|
|
17
|
+
};
|
|
18
|
+
export const init = async (args) => {
|
|
19
|
+
const file = resolve(flagValue(args, 'file') ?? join(process.cwd(), ENVIRONMENT_FILE));
|
|
20
|
+
const directory = dirname(file);
|
|
21
|
+
if (await exists(file) && !hasFlag(args, 'force')) {
|
|
22
|
+
throw new Error(`${file} already exists. Use --force to overwrite it.`);
|
|
23
|
+
}
|
|
24
|
+
const name = flagValue(args, 'name') ?? basename(directory);
|
|
25
|
+
const id = flagValue(args, 'id') ?? slug(name);
|
|
26
|
+
if (id === '')
|
|
27
|
+
throw new Error('Could not derive an environment id. Pass --id explicitly.');
|
|
28
|
+
const environment = {
|
|
29
|
+
project: { name },
|
|
30
|
+
environment: { id, name },
|
|
31
|
+
firebase: { projectId: null, region: DEFAULT_FUNCTIONS_REGION },
|
|
32
|
+
kernel: { version: null, deployed: null },
|
|
33
|
+
backend: { functions: null, parameters: null, indexes: null },
|
|
34
|
+
};
|
|
35
|
+
await writeEnvironment(file, environment);
|
|
36
|
+
// O diretório de trabalho guarda o pacote desempacotado e a configuração resolvida. Ele é local à
|
|
37
|
+
// instalação e não deve ir para repositório nenhum.
|
|
38
|
+
const workspace = join(directory, WORKSPACE_DIR);
|
|
39
|
+
await mkdir(workspace, { recursive: true });
|
|
40
|
+
await writeFile(join(workspace, '.gitignore'), '*\n');
|
|
41
|
+
// Este diretório é o REPOSITÓRIO de quem mantém a instalação: é onde ficam o registro do ambiente,
|
|
42
|
+
// as extensões que ele escreve e as configurações de backend. O `.gitignore` nasce com ele porque a
|
|
43
|
+
// configuração de backend é credencial, e uma credencial commitada por omissão nossa é o pior
|
|
44
|
+
// desfecho possível.
|
|
45
|
+
const gitignore = await ensureRootGitignore(directory);
|
|
46
|
+
heading(`Microteia installation "${name}"`);
|
|
47
|
+
success(`created ${ENVIRONMENT_FILE}`);
|
|
48
|
+
if (gitignore === 'created')
|
|
49
|
+
success('created .gitignore');
|
|
50
|
+
else if (gitignore === 'appended')
|
|
51
|
+
success('added ".env.*" to .gitignore');
|
|
52
|
+
blank();
|
|
53
|
+
info('Next:');
|
|
54
|
+
bullets([
|
|
55
|
+
'microteia connect --project <your-firebase-project-id>',
|
|
56
|
+
'microteia unpack --version latest',
|
|
57
|
+
'microteia deploy',
|
|
58
|
+
]);
|
|
59
|
+
blank();
|
|
60
|
+
};
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import type { ParsedArgs } from '../shared/args.js';
|
|
2
|
+
/**
|
|
3
|
+
* `microteia plugin-init` — prepara `extensions/`, onde o escritório escreve extensão em TypeScript.
|
|
4
|
+
*
|
|
5
|
+
* Não é um deploy e não toca no projeto Firebase: escreve uma semente, chama o `npm` e delega o resto
|
|
6
|
+
* ao SDK. É deliberadamente o mesmo desenho de `functions/custom/`: o diretório fica FORA de
|
|
7
|
+
* `.microteia/`, é do escritório, e o upgrade não o toca.
|
|
8
|
+
*
|
|
9
|
+
* O que este comando NÃO faz mais é escrever documentação. `AGENTS.md`, `AUTHORING.md`, `tsconfig.json`
|
|
10
|
+
* e `.mcp.json` descrevem o que o kernel sabe fazer, e por isso saem do SDK — que viaja no mesmo pacote
|
|
11
|
+
* que aquelas capacidades. Enquanto saíam daqui, ficavam parados na versão do Console que rodou uma vez,
|
|
12
|
+
* meses antes, e ninguém tinha como notar.
|
|
13
|
+
*/
|
|
14
|
+
export declare const EXTENSIONS_DIR = "extensions";
|
|
15
|
+
/**
|
|
16
|
+
* Traz o SDK do pacote instalado e manda ELE reescrever o que é gerado no diretório.
|
|
17
|
+
*
|
|
18
|
+
* Os três passos são um só movimento, e a ordem é o que faz cada um valer:
|
|
19
|
+
*
|
|
20
|
+
* 1. `npm install <tarball>` pelo CAMINHO. Um `npm install` puro diria "up to date": o caminho não leva
|
|
21
|
+
* versão e o lock guarda a impressão digital do anterior — era assim que um escritório passava meses
|
|
22
|
+
* compilando contra um contrato que a instalação já não tinha.
|
|
23
|
+
* 2. `microteia-plugin sync`, do SDK que acabou de chegar. É ele que sabe o que o kernel faz.
|
|
24
|
+
* 3. `npm install`, porque o passo 2 pode ter acrescentado uma devDependency que o kernel novo exige.
|
|
25
|
+
*
|
|
26
|
+
* Duas decisões do passo 1 vêm de teste, não de leitura, e sem elas o movimento se desfaz sozinho:
|
|
27
|
+
*
|
|
28
|
+
* - **Sem `--no-save`.** Com ele o pacote NOVO é extraído e o `package-lock.json` fica no anterior — e
|
|
29
|
+
* aí o passo 3 reinstala o antigo, silenciosamente, desfazendo o passo 1. Pior: mesmo sem o passo 3,
|
|
30
|
+
* o próximo `npm install` que alguém rodasse faria a mesma reversão. Salvar é o que casa lock e
|
|
31
|
+
* `node_modules`.
|
|
32
|
+
* - **Caminho RELATIVO ao diretório.** O `npm` reescreve a dependência com o caminho que recebeu; um
|
|
33
|
+
* absoluto entraria no `package.json`, e o motivo de ele ser relativo é a instalação poder ser movida
|
|
34
|
+
* ou versionada. Sendo relativo, a linha sai idêntica à que já estava lá.
|
|
35
|
+
*
|
|
36
|
+
* Usado pelo `plugin-init` e pelo `unpack` — o mesmo caminho, para que não exista um "init" que faz uma
|
|
37
|
+
* coisa e um "upgrade" que faz outra.
|
|
38
|
+
*/
|
|
39
|
+
export declare const syncAuthoringWorkspace: (directory: string, sdkPath: string, installDir: string, officeName: string) => Promise<void>;
|
|
40
|
+
/**
|
|
41
|
+
* Depois de trocar de kernel, põe `extensions/` em dia — se ele existir. Chamado pelo `unpack`.
|
|
42
|
+
*
|
|
43
|
+
* A falha aqui NÃO derruba o comando. Trocar de kernel é a operação principal, e um `npm` sem rede não
|
|
44
|
+
* pode impedi-la: o pior resultado possível é uma instalação pela metade porque a documentação de um
|
|
45
|
+
* diretório opcional não pôde ser reescrita. O aviso diz o comando que resolve.
|
|
46
|
+
*/
|
|
47
|
+
export declare const refreshAuthoringWorkspace: (root: string, officeName: string) => Promise<boolean>;
|
|
48
|
+
export declare const pluginInit: (args: ParsedArgs) => Promise<void>;
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
import { access, mkdir, readFile, writeFile } from 'node:fs/promises';
|
|
2
|
+
import { dirname, join, relative, resolve } from 'node:path';
|
|
3
|
+
import { flagValue, hasFlag } from '../shared/args.js';
|
|
4
|
+
import { blank, bullets, heading, info, success, warn } from '../shared/ui.js';
|
|
5
|
+
import { run } from '../shared/run.js';
|
|
6
|
+
import { ENVIRONMENT_FILE, readEnvironment } from './environment.js';
|
|
7
|
+
import { WORKSPACE_DIR } from './workspace.js';
|
|
8
|
+
import { SDK_TARBALL, workspacePackageJson } from '../vendor/pluginWorkspaceTemplates.js';
|
|
9
|
+
/**
|
|
10
|
+
* `microteia plugin-init` — prepara `extensions/`, onde o escritório escreve extensão em TypeScript.
|
|
11
|
+
*
|
|
12
|
+
* Não é um deploy e não toca no projeto Firebase: escreve uma semente, chama o `npm` e delega o resto
|
|
13
|
+
* ao SDK. É deliberadamente o mesmo desenho de `functions/custom/`: o diretório fica FORA de
|
|
14
|
+
* `.microteia/`, é do escritório, e o upgrade não o toca.
|
|
15
|
+
*
|
|
16
|
+
* O que este comando NÃO faz mais é escrever documentação. `AGENTS.md`, `AUTHORING.md`, `tsconfig.json`
|
|
17
|
+
* e `.mcp.json` descrevem o que o kernel sabe fazer, e por isso saem do SDK — que viaja no mesmo pacote
|
|
18
|
+
* que aquelas capacidades. Enquanto saíam daqui, ficavam parados na versão do Console que rodou uma vez,
|
|
19
|
+
* meses antes, e ninguém tinha como notar.
|
|
20
|
+
*/
|
|
21
|
+
export const EXTENSIONS_DIR = 'extensions';
|
|
22
|
+
/** O binário do SDK, dentro do próprio workspace. Ver `workspaceMcpJson` para o porquê de não ser `npx`. */
|
|
23
|
+
const SDK_CLI = 'node_modules/@microteia/sdk/lib/sdk/node/cli.js';
|
|
24
|
+
const exists = async (path) => {
|
|
25
|
+
try {
|
|
26
|
+
await access(path);
|
|
27
|
+
return true;
|
|
28
|
+
}
|
|
29
|
+
catch {
|
|
30
|
+
return false;
|
|
31
|
+
}
|
|
32
|
+
};
|
|
33
|
+
/**
|
|
34
|
+
* Acha o SDK no pacote desempacotado. O manifesto o declara desde a versão que passou a carregá-lo; um
|
|
35
|
+
* pacote anterior a ela não tem SDK, e dizer isso é melhor que escrever um `package.json` apontando
|
|
36
|
+
* para um caminho que não existe.
|
|
37
|
+
*/
|
|
38
|
+
const locateSdk = async (root) => {
|
|
39
|
+
const installDir = join(root, WORKSPACE_DIR, 'install');
|
|
40
|
+
const manifestPath = join(installDir, 'manifest.json');
|
|
41
|
+
if (!await exists(manifestPath)) {
|
|
42
|
+
throw new Error('No installed Microteia package found. Run "microteia unpack --version latest" first.');
|
|
43
|
+
}
|
|
44
|
+
const manifest = JSON.parse(await readFile(manifestPath, 'utf8'));
|
|
45
|
+
const sdkDir = join(installDir, 'sdk');
|
|
46
|
+
if (manifest.sdk === undefined || !await exists(join(sdkDir, SDK_TARBALL))) {
|
|
47
|
+
throw new Error(`The installed package (${manifest.version}) does not carry the authoring SDK.\n`
|
|
48
|
+
+ 'Install a newer Microteia package, then run this again.');
|
|
49
|
+
}
|
|
50
|
+
return {
|
|
51
|
+
version: manifest.sdk.version,
|
|
52
|
+
kernelVersion: manifest.sdk.kernelVersion,
|
|
53
|
+
path: join(sdkDir, SDK_TARBALL),
|
|
54
|
+
};
|
|
55
|
+
};
|
|
56
|
+
/**
|
|
57
|
+
* Traz o SDK do pacote instalado e manda ELE reescrever o que é gerado no diretório.
|
|
58
|
+
*
|
|
59
|
+
* Os três passos são um só movimento, e a ordem é o que faz cada um valer:
|
|
60
|
+
*
|
|
61
|
+
* 1. `npm install <tarball>` pelo CAMINHO. Um `npm install` puro diria "up to date": o caminho não leva
|
|
62
|
+
* versão e o lock guarda a impressão digital do anterior — era assim que um escritório passava meses
|
|
63
|
+
* compilando contra um contrato que a instalação já não tinha.
|
|
64
|
+
* 2. `microteia-plugin sync`, do SDK que acabou de chegar. É ele que sabe o que o kernel faz.
|
|
65
|
+
* 3. `npm install`, porque o passo 2 pode ter acrescentado uma devDependency que o kernel novo exige.
|
|
66
|
+
*
|
|
67
|
+
* Duas decisões do passo 1 vêm de teste, não de leitura, e sem elas o movimento se desfaz sozinho:
|
|
68
|
+
*
|
|
69
|
+
* - **Sem `--no-save`.** Com ele o pacote NOVO é extraído e o `package-lock.json` fica no anterior — e
|
|
70
|
+
* aí o passo 3 reinstala o antigo, silenciosamente, desfazendo o passo 1. Pior: mesmo sem o passo 3,
|
|
71
|
+
* o próximo `npm install` que alguém rodasse faria a mesma reversão. Salvar é o que casa lock e
|
|
72
|
+
* `node_modules`.
|
|
73
|
+
* - **Caminho RELATIVO ao diretório.** O `npm` reescreve a dependência com o caminho que recebeu; um
|
|
74
|
+
* absoluto entraria no `package.json`, e o motivo de ele ser relativo é a instalação poder ser movida
|
|
75
|
+
* ou versionada. Sendo relativo, a linha sai idêntica à que já estava lá.
|
|
76
|
+
*
|
|
77
|
+
* Usado pelo `plugin-init` e pelo `unpack` — o mesmo caminho, para que não exista um "init" que faz uma
|
|
78
|
+
* coisa e um "upgrade" que faz outra.
|
|
79
|
+
*/
|
|
80
|
+
export const syncAuthoringWorkspace = async (directory, sdkPath, installDir, officeName) => {
|
|
81
|
+
const tarball = relative(directory, sdkPath);
|
|
82
|
+
await run('npm', ['install', '--no-audit', '--no-fund', tarball], { cwd: directory });
|
|
83
|
+
await run('node', [SDK_CLI, 'sync', '--install', installDir, '--office', officeName], { cwd: directory });
|
|
84
|
+
await run('npm', ['install', '--no-audit', '--no-fund'], { cwd: directory });
|
|
85
|
+
};
|
|
86
|
+
/**
|
|
87
|
+
* Depois de trocar de kernel, põe `extensions/` em dia — se ele existir. Chamado pelo `unpack`.
|
|
88
|
+
*
|
|
89
|
+
* A falha aqui NÃO derruba o comando. Trocar de kernel é a operação principal, e um `npm` sem rede não
|
|
90
|
+
* pode impedi-la: o pior resultado possível é uma instalação pela metade porque a documentação de um
|
|
91
|
+
* diretório opcional não pôde ser reescrita. O aviso diz o comando que resolve.
|
|
92
|
+
*/
|
|
93
|
+
export const refreshAuthoringWorkspace = async (root, officeName) => {
|
|
94
|
+
const directory = join(root, EXTENSIONS_DIR);
|
|
95
|
+
if (!await exists(join(directory, 'package.json')))
|
|
96
|
+
return false;
|
|
97
|
+
const installDir = join(root, WORKSPACE_DIR, 'install');
|
|
98
|
+
try {
|
|
99
|
+
const sdk = await locateSdk(root);
|
|
100
|
+
await syncAuthoringWorkspace(directory, sdk.path, installDir, officeName);
|
|
101
|
+
success(`${EXTENSIONS_DIR}/ is on the new contract (${sdk.kernelVersion})`);
|
|
102
|
+
return true;
|
|
103
|
+
}
|
|
104
|
+
catch (error) {
|
|
105
|
+
warn(`could not update ${EXTENSIONS_DIR}/: ${error instanceof Error ? error.message.split('\n')[0] : String(error)}`);
|
|
106
|
+
info(` run "npm run sdk:refresh" in ${EXTENSIONS_DIR}/ — until then its guides describe the previous contract.`);
|
|
107
|
+
return false;
|
|
108
|
+
}
|
|
109
|
+
};
|
|
110
|
+
export const pluginInit = async (args) => {
|
|
111
|
+
const file = resolve(flagValue(args, 'file') ?? join(process.cwd(), ENVIRONMENT_FILE));
|
|
112
|
+
const root = dirname(file);
|
|
113
|
+
const environment = await readEnvironment(file);
|
|
114
|
+
const directory = resolve(flagValue(args, 'dir') ?? join(root, EXTENSIONS_DIR));
|
|
115
|
+
const force = hasFlag(args, 'force');
|
|
116
|
+
if (await exists(join(directory, 'package.json')) && !force) {
|
|
117
|
+
throw new Error(`${relative(root, directory)}/package.json already exists.\n`
|
|
118
|
+
+ 'Nothing to do: "microteia unpack" keeps its generated files up to date, and "npm run sync"\n'
|
|
119
|
+
+ 'in that directory does it by hand. Use --force only to rewrite the seed itself.');
|
|
120
|
+
}
|
|
121
|
+
const sdk = await locateSdk(root);
|
|
122
|
+
const installDir = join(root, WORKSPACE_DIR, 'install');
|
|
123
|
+
heading(`Extension authoring for "${environment.project.name}"`);
|
|
124
|
+
await mkdir(directory, { recursive: true });
|
|
125
|
+
// O caminho do tarball é relativo, para que o diretório continue válido se a instalação inteira for
|
|
126
|
+
// movida — o que acontece assim que alguém a versiona num repositório do escritório.
|
|
127
|
+
await writeFile(join(directory, 'package.json'), workspacePackageJson(environment.environment.id, relative(directory, sdk.path)));
|
|
128
|
+
success('wrote the seed package.json');
|
|
129
|
+
blank();
|
|
130
|
+
info('Installing @microteia/sdk and writing the guides...');
|
|
131
|
+
await syncAuthoringWorkspace(directory, sdk.path, installDir, environment.project.name);
|
|
132
|
+
blank();
|
|
133
|
+
info(`Extension authoring ready in ${relative(root, directory)}/ (kernel contract ${sdk.kernelVersion}).`);
|
|
134
|
+
bullets([
|
|
135
|
+
`cd ${relative(process.cwd(), directory) || '.'}`,
|
|
136
|
+
'npm run create',
|
|
137
|
+
'npx tsc --noEmit && npm run check -- hello/plugin.ts',
|
|
138
|
+
'npm run build -- hello/plugin.ts',
|
|
139
|
+
]);
|
|
140
|
+
blank();
|
|
141
|
+
info('Read AUTHORING.md first. An AI assistant in this directory reads AGENTS.md and CLAUDE.md, and');
|
|
142
|
+
info('can query this installation through the MCP server registered in .mcp.json.');
|
|
143
|
+
blank();
|
|
144
|
+
info('CLAUDE.md and README.md are yours — nothing rewrites them. Everything else in there is');
|
|
145
|
+
info('generated, and "microteia unpack" rewrites it whenever this installation takes a new version,');
|
|
146
|
+
info('so the guides never describe a contract the platform no longer has.');
|
|
147
|
+
blank();
|
|
148
|
+
};
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import type { ParsedArgs } from '../shared/args.js';
|
|
2
|
+
import type { KernelManifest } from '../kernel/manifest.js';
|
|
3
|
+
import type { BackendClaim } from '../kernel/backendShape.js';
|
|
4
|
+
import type { Environment } from './environment.js';
|
|
5
|
+
/** Uma exigência, o escritório que a instalou e o documento que carrega o estado dela. */
|
|
6
|
+
export interface InstalledClaim extends BackendClaim {
|
|
7
|
+
tenantId: string;
|
|
8
|
+
documentPath: string;
|
|
9
|
+
applied: boolean;
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* O que cada escritório do projeto instalou e exige do backend.
|
|
13
|
+
*
|
|
14
|
+
* A exigência vem do banco, então ela é DADO e não código — é por isso que `claimProblems` a confere
|
|
15
|
+
* contra `manifest.functions` antes de qualquer deploy. Nome de function fora do pacote é recusado com
|
|
16
|
+
* explicação; não há injeção possível, porque o código é sempre o do pacote.
|
|
17
|
+
*/
|
|
18
|
+
export declare const readInstalledClaims: (projectId: string) => Promise<InstalledClaim[]>;
|
|
19
|
+
export declare const parseAppliedFunctions: (value: string | null) => string[];
|
|
20
|
+
export interface ProvisionOptions {
|
|
21
|
+
projectId: string;
|
|
22
|
+
region: string;
|
|
23
|
+
install: string;
|
|
24
|
+
manifest: KernelManifest;
|
|
25
|
+
delaySeconds: number;
|
|
26
|
+
prune: boolean;
|
|
27
|
+
environmentFile: string;
|
|
28
|
+
environment: Environment;
|
|
29
|
+
}
|
|
30
|
+
export interface ProvisionOutcome {
|
|
31
|
+
deployed: string[];
|
|
32
|
+
failed: string[];
|
|
33
|
+
orphans: string[];
|
|
34
|
+
pending: InstalledClaim[];
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* O motor: alinha o projeto Firebase com o que as extensões instaladas exigem, e carimba nelas o
|
|
38
|
+
* estado. Um motor só, chamado pelo `deploy` no fim e pelo `provision` sozinho — duas implementações
|
|
39
|
+
* divergiriam exatamente no ponto em que ninguém olha.
|
|
40
|
+
*/
|
|
41
|
+
export declare const provisionBackend: (options: ProvisionOptions) => Promise<ProvisionOutcome>;
|
|
42
|
+
/**
|
|
43
|
+
* A lista aplicada CONVERGE para o que ainda é exigido: o que já estava e continua sendo pedido, mais
|
|
44
|
+
* o que subiu agora, mais o órfão que não foi removido. Reescrevê-la só com o que subiu NESTA execução
|
|
45
|
+
* a esvaziava na segunda passada — e a terceira redeployava tudo, achando que nada havia sido feito.
|
|
46
|
+
*/
|
|
47
|
+
export declare const appliedAfterRun: (input: {
|
|
48
|
+
alreadyApplied: string[];
|
|
49
|
+
wanted: string[];
|
|
50
|
+
deployed: string[];
|
|
51
|
+
keptOrphans: string[];
|
|
52
|
+
}) => string[];
|
|
53
|
+
/**
|
|
54
|
+
* A impressão digital do parâmetro só avança quando TODAS as functions que o carregam subiram. Gravar
|
|
55
|
+
* antes disso é o que transformaria uma falha de deploy em silêncio permanente: o valor novo ficaria
|
|
56
|
+
* registrado como aplicado, e nenhuma execução seguinte tentaria de novo.
|
|
57
|
+
*
|
|
58
|
+
* O que não é mais reivindicado por extensão nenhuma fica registrado em vez de ser removido: apagar
|
|
59
|
+
* faria uma extensão reinstalada redeployar functions que já carregam o valor certo, e uma linha a mais
|
|
60
|
+
* no arquivo não decide nada.
|
|
61
|
+
*/
|
|
62
|
+
export declare const appliedParameterState: (input: {
|
|
63
|
+
recorded: Map<string, string>;
|
|
64
|
+
current: Map<string, string>;
|
|
65
|
+
changed: string[];
|
|
66
|
+
failed: string[];
|
|
67
|
+
claims: BackendClaim[];
|
|
68
|
+
}) => Map<string, string>;
|
|
69
|
+
/** O comando: provisiona sem reenviar o kernel. É o que se roda depois de instalar uma extensão. */
|
|
70
|
+
export declare const provision: (args: ParsedArgs) => Promise<void>;
|