omnispace 0.1.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/README.md +37 -0
- package/dist/config.js +45 -0
- package/dist/include.js +150 -0
- package/dist/index.js +89 -0
- package/dist/link.js +68 -0
- package/dist/report.js +101 -0
- package/dist/scan.js +124 -0
- package/dist/stacks.js +46 -0
- package/dist/sync.js +140 -0
- package/package.json +39 -0
package/README.md
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# omnispace (alpha)
|
|
2
|
+
|
|
3
|
+
CLI do [Omni](https://omni.tzolkin.cloud): transforma uma pasta da sua máquina em um espaço Omni e sincroniza esse espaço entre máquinas.
|
|
4
|
+
|
|
5
|
+
> Alpha. Hoje existem `init` e `sync --push`. Baixar em outra máquina (`clone` e `sync --pull`) vem na próxima versão.
|
|
6
|
+
|
|
7
|
+
Precisa do Node 20 ou mais novo.
|
|
8
|
+
|
|
9
|
+
## Uso
|
|
10
|
+
|
|
11
|
+
Comece pelo site: ele mostra o código de vínculo. Depois, dentro da pasta do espaço:
|
|
12
|
+
|
|
13
|
+
```
|
|
14
|
+
npx -y omnispace@latest init . --link <código>
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
Só ler a pasta, sem vincular nem enviar nada:
|
|
18
|
+
|
|
19
|
+
```
|
|
20
|
+
npx -y omnispace@latest init . --scan-only
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
Publicar uma nova versão do espaço no servidor:
|
|
24
|
+
|
|
25
|
+
```
|
|
26
|
+
npx -y omnispace@latest sync . --push
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
No Windows, se o caminho tiver espaço ou apóstrofo, entre na pasta com aspas duplas: `cd "C:\caminho\da\pasta"`.
|
|
30
|
+
|
|
31
|
+
## O que o Omni lê e envia
|
|
32
|
+
|
|
33
|
+
- A pasta é lida para montar a lista de arquivos. Sobem só arquivos de texto (Markdown, código, JSON e afins), até 25 MB por arquivo e 100 MB por espaço.
|
|
34
|
+
- Nunca sobem: `.env` e variações, chaves (`.pem`, `.key`, `id_rsa`), pastas `.git`, `node_modules` e de build, imagens e mídia.
|
|
35
|
+
- Arquivos `.env` são lidos só para listar os nomes das chaves; nenhum valor é guardado ou enviado.
|
|
36
|
+
- Antes de enviar, a CLI mostra o que sobe e o que fica de fora e pede confirmação.
|
|
37
|
+
- Na pasta, o Omni grava só `.omni/space.json`. O token de acesso fica em `~/.omni/credentials.json`, fora de qualquer projeto.
|
package/dist/config.js
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { dirname, join } from "node:path";
|
|
4
|
+
// Two places, on purpose:
|
|
5
|
+
// - ~/.omni/credentials.json: the CLI token, per server. Lives in the user's home, outside every
|
|
6
|
+
// project folder, so it can never be synced (ADR 004).
|
|
7
|
+
// - <pasta>/.omni/space.json: which space this folder is and the base version for the next sync.
|
|
8
|
+
// Machine specific, excluded from the sync (include.ts, LOCAL_ONLY).
|
|
9
|
+
const DEFAULT_API_URL = "https://omni.tzolkin.cloud";
|
|
10
|
+
export function defaultApiUrl() {
|
|
11
|
+
return process.env.OMNI_API_URL?.replace(/\/$/, "") || DEFAULT_API_URL;
|
|
12
|
+
}
|
|
13
|
+
function credentialsPath() {
|
|
14
|
+
return join(homedir(), ".omni", "credentials.json");
|
|
15
|
+
}
|
|
16
|
+
export function spaceConfigPath(root) {
|
|
17
|
+
return join(root, ".omni", "space.json");
|
|
18
|
+
}
|
|
19
|
+
async function readJson(path) {
|
|
20
|
+
try {
|
|
21
|
+
return JSON.parse(await readFile(path, "utf8"));
|
|
22
|
+
}
|
|
23
|
+
catch {
|
|
24
|
+
return null;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
async function writeJson(path, value) {
|
|
28
|
+
await mkdir(dirname(path), { recursive: true });
|
|
29
|
+
await writeFile(path, `${JSON.stringify(value, null, 2)}\n`, "utf8");
|
|
30
|
+
}
|
|
31
|
+
export async function saveToken(apiUrl, token, machineId) {
|
|
32
|
+
const all = (await readJson(credentialsPath())) ?? {};
|
|
33
|
+
all[apiUrl] = { token, machineId };
|
|
34
|
+
await writeJson(credentialsPath(), all);
|
|
35
|
+
}
|
|
36
|
+
export async function loadToken(apiUrl) {
|
|
37
|
+
const all = await readJson(credentialsPath());
|
|
38
|
+
return all?.[apiUrl]?.token ?? null;
|
|
39
|
+
}
|
|
40
|
+
export function readSpaceConfig(root) {
|
|
41
|
+
return readJson(spaceConfigPath(root));
|
|
42
|
+
}
|
|
43
|
+
export function writeSpaceConfig(root, config) {
|
|
44
|
+
return writeJson(spaceConfigPath(root), config);
|
|
45
|
+
}
|
package/dist/include.js
ADDED
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { createReadStream } from "node:fs";
|
|
3
|
+
import { access, readdir, stat } from "node:fs/promises";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
// Inclusion policy of the sync (docs/plan/adr/003-politica-inclusao.md). Shared by the scan and
|
|
6
|
+
// by `omni sync`, so what the scan reports is what the sync would send.
|
|
7
|
+
// Never walked into: heavy, generated, or private to another tool.
|
|
8
|
+
export const SKIP_DIRS = new Set([
|
|
9
|
+
"node_modules", ".git", ".next", ".vercel", "dist", "build", "out", "coverage",
|
|
10
|
+
".turbo", ".cache", "venv", ".venv", "__pycache__", ".idea", ".vscode", "target",
|
|
11
|
+
]);
|
|
12
|
+
// Local state of the Omni link itself: machine specific, never part of the space.
|
|
13
|
+
const LOCAL_ONLY = new Set([".omni/space.json"]);
|
|
14
|
+
const TEXT_EXTENSIONS = new Set([
|
|
15
|
+
".md", ".mdx", ".txt", ".csv", ".tsv", ".json", ".jsonc", ".yaml", ".yml", ".toml", ".xml",
|
|
16
|
+
".html", ".htm", ".css", ".scss", ".sass", ".less", ".svg",
|
|
17
|
+
".js", ".jsx", ".mjs", ".cjs", ".ts", ".tsx", ".mts", ".cts", ".vue", ".svelte", ".astro",
|
|
18
|
+
".py", ".rb", ".go", ".rs", ".java", ".kt", ".swift", ".c", ".h", ".cpp", ".hpp", ".cs",
|
|
19
|
+
".php", ".sql", ".sh", ".bash", ".zsh", ".ps1", ".bat", ".cmd", ".ini", ".cfg", ".conf",
|
|
20
|
+
".properties", ".tex", ".rst", ".graphql", ".gql", ".prisma", ".log",
|
|
21
|
+
]);
|
|
22
|
+
const TEXT_NAMES = new Set([
|
|
23
|
+
"readme", "license", "changelog", "dockerfile", "makefile", "procfile", ".gitkeep",
|
|
24
|
+
".gitignore", ".gitattributes", ".editorconfig", ".prettierrc", ".eslintrc", ".npmrc", ".nvmrc",
|
|
25
|
+
]);
|
|
26
|
+
export const MAX_FILE_BYTES = 25 * 1024 * 1024;
|
|
27
|
+
export const MAX_SPACE_BYTES = 100 * 1024 * 1024;
|
|
28
|
+
const MAX_FILES = 20000;
|
|
29
|
+
const MAX_DEPTH = 12;
|
|
30
|
+
/** Files that never leave the machine, with no option to turn them on (ADR 003 item 3, ADR 004). */
|
|
31
|
+
export function isSecret(name) {
|
|
32
|
+
const lower = name.toLowerCase();
|
|
33
|
+
return (/^\.env($|\.)/.test(lower) ||
|
|
34
|
+
/^id_(rsa|ed25519|ecdsa)/.test(lower) ||
|
|
35
|
+
/\.(pem|key|p12|pfx)$/.test(lower));
|
|
36
|
+
}
|
|
37
|
+
export function isText(name) {
|
|
38
|
+
const lower = name.toLowerCase();
|
|
39
|
+
if (TEXT_NAMES.has(lower))
|
|
40
|
+
return true;
|
|
41
|
+
const dot = lower.lastIndexOf(".");
|
|
42
|
+
return dot > 0 && TEXT_EXTENSIONS.has(lower.slice(dot));
|
|
43
|
+
}
|
|
44
|
+
/** Relative, "/" separated, NFC, so the same file gets the same path on every OS. */
|
|
45
|
+
export function normalizePath(parts) {
|
|
46
|
+
return parts.join("/").normalize("NFC");
|
|
47
|
+
}
|
|
48
|
+
export function findCaseCollisions(paths) {
|
|
49
|
+
const groups = new Map();
|
|
50
|
+
for (const p of paths) {
|
|
51
|
+
const key = p.toLowerCase();
|
|
52
|
+
groups.set(key, [...(groups.get(key) ?? []), p]);
|
|
53
|
+
}
|
|
54
|
+
return [...groups.values()].filter((g) => g.length > 1);
|
|
55
|
+
}
|
|
56
|
+
export function hashFile(abs) {
|
|
57
|
+
return new Promise((resolvePromise, reject) => {
|
|
58
|
+
const hash = createHash("sha256");
|
|
59
|
+
createReadStream(abs)
|
|
60
|
+
.on("data", (chunk) => hash.update(chunk))
|
|
61
|
+
.on("error", reject)
|
|
62
|
+
.on("end", () => resolvePromise(hash.digest("hex")));
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
async function hasOwnGit(dir) {
|
|
66
|
+
try {
|
|
67
|
+
await access(join(dir, ".git"));
|
|
68
|
+
return true;
|
|
69
|
+
}
|
|
70
|
+
catch {
|
|
71
|
+
return false;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
export async function buildManifest(root) {
|
|
75
|
+
const files = [];
|
|
76
|
+
const excluded = [];
|
|
77
|
+
const repos = [];
|
|
78
|
+
let totalBytes = 0;
|
|
79
|
+
let seen = 0;
|
|
80
|
+
let truncated = false;
|
|
81
|
+
async function walk(dir, parts, depth) {
|
|
82
|
+
if (truncated)
|
|
83
|
+
return;
|
|
84
|
+
if (depth > MAX_DEPTH) {
|
|
85
|
+
truncated = true;
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
const entries = await readdir(dir, { withFileTypes: true });
|
|
89
|
+
entries.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
|
|
90
|
+
for (const entry of entries) {
|
|
91
|
+
if (entry.isSymbolicLink())
|
|
92
|
+
continue;
|
|
93
|
+
const abs = join(dir, entry.name);
|
|
94
|
+
const relParts = [...parts, entry.name];
|
|
95
|
+
if (entry.isDirectory()) {
|
|
96
|
+
if (SKIP_DIRS.has(entry.name))
|
|
97
|
+
continue;
|
|
98
|
+
// A subfolder with its own .git (repo, submodule or worktree) DOES sync, text only: the
|
|
99
|
+
// agent needs the code to act on it (find a bug, fix it, report back). Lucas, 2026-09-23,
|
|
100
|
+
// reversing the first call of the same day (ADR 003). Its .git folder is still skipped.
|
|
101
|
+
if (await hasOwnGit(abs))
|
|
102
|
+
repos.push(normalizePath(relParts));
|
|
103
|
+
await walk(abs, relParts, depth + 1);
|
|
104
|
+
if (truncated)
|
|
105
|
+
return;
|
|
106
|
+
continue;
|
|
107
|
+
}
|
|
108
|
+
// A .git FILE (worktree or submodule pointer) is Git internals, like the .git folder.
|
|
109
|
+
if (!entry.isFile() || entry.name === ".git")
|
|
110
|
+
continue;
|
|
111
|
+
if (++seen > MAX_FILES) {
|
|
112
|
+
truncated = true;
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
const path = normalizePath(relParts);
|
|
116
|
+
if (LOCAL_ONLY.has(path))
|
|
117
|
+
continue;
|
|
118
|
+
if (isSecret(entry.name)) {
|
|
119
|
+
excluded.push({ path, reason: "segredo" });
|
|
120
|
+
continue;
|
|
121
|
+
}
|
|
122
|
+
if (!isText(entry.name)) {
|
|
123
|
+
excluded.push({ path, reason: "binário ou mídia" });
|
|
124
|
+
continue;
|
|
125
|
+
}
|
|
126
|
+
let size;
|
|
127
|
+
try {
|
|
128
|
+
size = (await stat(abs)).size;
|
|
129
|
+
}
|
|
130
|
+
catch {
|
|
131
|
+
continue; // vanished between readdir and stat
|
|
132
|
+
}
|
|
133
|
+
if (size > MAX_FILE_BYTES) {
|
|
134
|
+
excluded.push({ path, reason: "maior que 25 MB" });
|
|
135
|
+
continue;
|
|
136
|
+
}
|
|
137
|
+
files.push({ path, hash: await hashFile(abs), size, abs });
|
|
138
|
+
totalBytes += size;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
await walk(root, [], 0);
|
|
142
|
+
return {
|
|
143
|
+
files,
|
|
144
|
+
excluded,
|
|
145
|
+
repos,
|
|
146
|
+
totalBytes,
|
|
147
|
+
collisions: findCaseCollisions(files.map((f) => f.path)),
|
|
148
|
+
truncated,
|
|
149
|
+
};
|
|
150
|
+
}
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { resolve } from "node:path";
|
|
3
|
+
import { homedir } from "node:os";
|
|
4
|
+
import { stat } from "node:fs/promises";
|
|
5
|
+
import { scanFolder } from "./scan.js";
|
|
6
|
+
import { printScanReport } from "./report.js";
|
|
7
|
+
import { linkAndSubmit } from "./link.js";
|
|
8
|
+
import { pushSpace } from "./sync.js";
|
|
9
|
+
import { buildManifest } from "./include.js";
|
|
10
|
+
import { printSyncPreview } from "./report.js";
|
|
11
|
+
const USAGE = `
|
|
12
|
+
Omni CLI
|
|
13
|
+
|
|
14
|
+
Uso:
|
|
15
|
+
omni init [pasta] --scan-only varre a pasta e mostra o que encontrou
|
|
16
|
+
omni init [pasta] --link <código> varre a pasta e envia pro código mostrado no navegador
|
|
17
|
+
omni sync [pasta] --push [--yes] publica uma nova versão do espaço no servidor
|
|
18
|
+
|
|
19
|
+
Gravar a estrutura padrão na pasta entra num próximo passo da Etapa técnica 1 —
|
|
20
|
+
por enquanto quem decide o que fazer com o que foi varrido é a tela de revisão.
|
|
21
|
+
`;
|
|
22
|
+
async function main(argv) {
|
|
23
|
+
const args = argv.slice(2);
|
|
24
|
+
const command = args[0];
|
|
25
|
+
if (!command || command === "--help" || command === "-h") {
|
|
26
|
+
console.log(USAGE.trim());
|
|
27
|
+
return 0;
|
|
28
|
+
}
|
|
29
|
+
if (command === "sync")
|
|
30
|
+
return runSync(args.slice(1));
|
|
31
|
+
if (command !== "init") {
|
|
32
|
+
console.error(`Comando desconhecido: ${command}`);
|
|
33
|
+
console.log(USAGE.trim());
|
|
34
|
+
return 1;
|
|
35
|
+
}
|
|
36
|
+
const rest = args.slice(1);
|
|
37
|
+
const linkIndex = rest.indexOf("--link");
|
|
38
|
+
const linkCode = linkIndex >= 0 ? rest[linkIndex + 1] : undefined;
|
|
39
|
+
const positional = rest.filter((a, i) => !a.startsWith("-") && (linkIndex < 0 || i !== linkIndex + 1));
|
|
40
|
+
const hasScanOnly = rest.includes("--scan-only");
|
|
41
|
+
const target = resolve(positional[0] ?? process.cwd());
|
|
42
|
+
// Writing to disk is not implemented yet (Etapa técnica 1 segue em aberto), so a run that
|
|
43
|
+
// asked for neither mode must stop instead of pretending the full flow happened.
|
|
44
|
+
if (!hasScanOnly && !linkCode) {
|
|
45
|
+
console.error("Rode com --scan-only (só ler) ou --link <código> (ler e enviar pra revisão).");
|
|
46
|
+
return 1;
|
|
47
|
+
}
|
|
48
|
+
try {
|
|
49
|
+
const info = await stat(target);
|
|
50
|
+
if (!info.isDirectory()) {
|
|
51
|
+
console.error(`Não é uma pasta: ${target}`);
|
|
52
|
+
return 1;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
catch {
|
|
56
|
+
console.error(`Pasta não encontrada: ${target}`);
|
|
57
|
+
return 1;
|
|
58
|
+
}
|
|
59
|
+
// Linking the user's whole home folder happened for real on 2026-09-23 (a missing cd); a space
|
|
60
|
+
// is a work folder inside it, never the home itself.
|
|
61
|
+
if (linkCode && resolve(target).toLowerCase() === resolve(homedir()).toLowerCase()) {
|
|
62
|
+
console.error(`Essa é a pasta do seu usuário (${target}), não a de um projeto.`);
|
|
63
|
+
console.error('Entre na pasta do espaço primeiro, com o caminho entre aspas duplas: cd "<caminho da pasta>"');
|
|
64
|
+
return 1;
|
|
65
|
+
}
|
|
66
|
+
const scan = await scanFolder(target);
|
|
67
|
+
printScanReport(scan, { linking: Boolean(linkCode) });
|
|
68
|
+
if (linkCode && scan.truncated) {
|
|
69
|
+
console.error("A leitura parou antes do fim, então o vínculo não foi feito. Aponte a pasta do espaço, não uma pasta acima dela.");
|
|
70
|
+
return 1;
|
|
71
|
+
}
|
|
72
|
+
if (hasScanOnly) {
|
|
73
|
+
console.log("→ Prévia do sync (o que omni sync --push enviaria):");
|
|
74
|
+
printSyncPreview(await buildManifest(target));
|
|
75
|
+
console.log("");
|
|
76
|
+
}
|
|
77
|
+
if (linkCode)
|
|
78
|
+
await linkAndSubmit(linkCode, target, scan);
|
|
79
|
+
return 0;
|
|
80
|
+
}
|
|
81
|
+
async function runSync(rest) {
|
|
82
|
+
if (!rest.includes("--push")) {
|
|
83
|
+
console.error("Por enquanto só existe omni sync --push (baixar vem na próxima fatia).");
|
|
84
|
+
return 1;
|
|
85
|
+
}
|
|
86
|
+
const target = resolve(rest.find((a) => !a.startsWith("-")) ?? process.cwd());
|
|
87
|
+
return pushSpace(target, { yes: rest.includes("--yes") });
|
|
88
|
+
}
|
|
89
|
+
process.exitCode = await main(process.argv);
|
package/dist/link.js
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { hostname, platform } from "node:os";
|
|
2
|
+
import { defaultApiUrl, saveToken, writeSpaceConfig } from "./config.js";
|
|
3
|
+
function apiUrl(path) {
|
|
4
|
+
return `${defaultApiUrl()}${path}`;
|
|
5
|
+
}
|
|
6
|
+
async function postJson(path, body) {
|
|
7
|
+
const response = await fetch(apiUrl(path), {
|
|
8
|
+
method: "POST",
|
|
9
|
+
headers: { "content-type": "application/json" },
|
|
10
|
+
body: JSON.stringify(body),
|
|
11
|
+
});
|
|
12
|
+
const json = (await response.json().catch(() => ({})));
|
|
13
|
+
if (!response.ok)
|
|
14
|
+
return { ok: false, error: json.error ?? `HTTP ${response.status}` };
|
|
15
|
+
return { ok: true, ...json };
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* Passos 4-5 do funil (docs/plan/onboarding-web-fluxo.md): reivindica o código que o navegador
|
|
19
|
+
* mostrou, depois manda o resultado da varredura. Nada é escrito na pasta — a tela de revisão,
|
|
20
|
+
* no navegador, decide o que acontece com a proposta.
|
|
21
|
+
*/
|
|
22
|
+
export async function linkAndSubmit(code, root, scan) {
|
|
23
|
+
console.log(`\n→ Vinculando com o código ${code}...`);
|
|
24
|
+
const link = await postJson("/api/cli/link", { code, hostname: hostname(), os: platform() });
|
|
25
|
+
if (!link.ok) {
|
|
26
|
+
console.error(`Falha ao vincular: ${link.error}`);
|
|
27
|
+
process.exitCode = 1;
|
|
28
|
+
return;
|
|
29
|
+
}
|
|
30
|
+
console.log("✓ Máquina vinculada.");
|
|
31
|
+
// Credential for `omni sync` (fatia 2): the token goes to the user's home, never into the folder;
|
|
32
|
+
// the folder only gets .omni/space.json, the link itself (ADR 005: nothing else is touched).
|
|
33
|
+
if (typeof link.token === "string" && typeof link.machineId === "string" && typeof link.sessionId === "string") {
|
|
34
|
+
await saveToken(defaultApiUrl(), link.token, link.machineId);
|
|
35
|
+
await writeSpaceConfig(root, {
|
|
36
|
+
apiUrl: defaultApiUrl(),
|
|
37
|
+
sessionId: link.sessionId,
|
|
38
|
+
machineId: link.machineId,
|
|
39
|
+
baseVersion: 0,
|
|
40
|
+
});
|
|
41
|
+
console.log("✓ Vínculo gravado em .omni/space.json (o token fica fora da pasta, em ~/.omni).");
|
|
42
|
+
}
|
|
43
|
+
console.log("→ Enviando o resultado da varredura...");
|
|
44
|
+
const submit = await postJson("/api/cli/scan", {
|
|
45
|
+
code,
|
|
46
|
+
localPath: root,
|
|
47
|
+
scanSummary: {
|
|
48
|
+
fileCount: scan.fileCount,
|
|
49
|
+
dirCount: scan.dirCount,
|
|
50
|
+
totalBytes: scan.totalBytes,
|
|
51
|
+
truncated: scan.truncated,
|
|
52
|
+
truncationReason: scan.truncationReason,
|
|
53
|
+
topExtensions: scan.topExtensions,
|
|
54
|
+
brainMarkers: scan.brainMarkers,
|
|
55
|
+
},
|
|
56
|
+
// A árvore proposta do default v0 ainda não é gerada pela CLI (Etapa técnica 1 do
|
|
57
|
+
// plano-execucao segue em aberto) — a revisão recebe um resumo, não uma estrutura pronta.
|
|
58
|
+
structure: {},
|
|
59
|
+
detectedStacks: scan.stacks,
|
|
60
|
+
detectedEnvKeys: scan.envKeys,
|
|
61
|
+
});
|
|
62
|
+
if (!submit.ok) {
|
|
63
|
+
console.error(`Falha ao enviar a varredura: ${submit.error}`);
|
|
64
|
+
process.exitCode = 1;
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
console.log("✓ Enviado. Volte pro navegador — a próxima tela é a de revisão.\n");
|
|
68
|
+
}
|
package/dist/report.js
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import { basename } from "node:path";
|
|
2
|
+
import { MAX_SPACE_BYTES } from "./include.js";
|
|
3
|
+
const TRUNCATION_MESSAGES = {
|
|
4
|
+
"max-files": "pasta grande demais (mais de 20.000 arquivos) — aponte uma subpasta",
|
|
5
|
+
"max-depth": "pasta funda demais (mais de 12 níveis) — aponte uma subpasta mais interna",
|
|
6
|
+
timeout: "a varredura passou de 60s e foi interrompida — aponte uma subpasta",
|
|
7
|
+
};
|
|
8
|
+
export function humanSize(bytes) {
|
|
9
|
+
const units = ["B", "KB", "MB", "GB"];
|
|
10
|
+
let value = bytes;
|
|
11
|
+
let unit = 0;
|
|
12
|
+
while (value >= 1024 && unit < units.length - 1) {
|
|
13
|
+
value /= 1024;
|
|
14
|
+
unit++;
|
|
15
|
+
}
|
|
16
|
+
return `${value.toFixed(value >= 10 || unit === 0 ? 0 : 1)} ${units[unit]}`;
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* What `omni sync --push` would send (ADR 003 item 6: show what goes up and what stays out, with
|
|
20
|
+
* the reason, before anything leaves the machine). Used by the scan and by the push itself.
|
|
21
|
+
*/
|
|
22
|
+
export function printSyncPreview(manifest) {
|
|
23
|
+
console.log(` Sobem: ${manifest.files.length} arquivos de texto · ${humanSize(manifest.totalBytes)}`);
|
|
24
|
+
if (manifest.totalBytes > MAX_SPACE_BYTES) {
|
|
25
|
+
console.log(` → Passa do limite de ${humanSize(MAX_SPACE_BYTES)} por espaço do alpha.`);
|
|
26
|
+
}
|
|
27
|
+
if (manifest.repos.length > 0) {
|
|
28
|
+
console.log(` Repositórios Git dentro do espaço (entram só com texto, sem .git): ${manifest.repos.join(", ")}`);
|
|
29
|
+
}
|
|
30
|
+
if (manifest.collisions.length > 0) {
|
|
31
|
+
console.log(` → ${manifest.collisions.length} nome(s) que só diferem por maiúsculas/minúsculas (bloqueia o envio).`);
|
|
32
|
+
}
|
|
33
|
+
if (manifest.excluded.length === 0)
|
|
34
|
+
return;
|
|
35
|
+
const byReason = new Map();
|
|
36
|
+
for (const e of manifest.excluded)
|
|
37
|
+
byReason.set(e.reason, [...(byReason.get(e.reason) ?? []), e.path]);
|
|
38
|
+
console.log(" Ficam de fora:");
|
|
39
|
+
for (const [reason, paths] of byReason) {
|
|
40
|
+
const sample = paths.slice(0, 5).join(", ");
|
|
41
|
+
const more = paths.length > 5 ? ` e mais ${paths.length - 5}` : "";
|
|
42
|
+
console.log(` ${reason} (${paths.length}): ${sample}${more}`);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
/** Terminal output of `omni init --scan-only`, following docs/plan/default-v0-e-terminal-ux.md. */
|
|
46
|
+
export function printScanReport(scan, opts = {}) {
|
|
47
|
+
const lines = [];
|
|
48
|
+
lines.push("");
|
|
49
|
+
lines.push(`Omni CLI v0.1.0 — leitura, nada foi alterado na pasta`);
|
|
50
|
+
lines.push("");
|
|
51
|
+
lines.push(`→ Varrendo "${basename(scan.root)}"...`);
|
|
52
|
+
lines.push(` ${scan.fileCount} arquivos${scan.truncated ? " (varredura parcial)" : ""}` +
|
|
53
|
+
` · ${scan.dirCount} pastas · ${humanSize(scan.totalBytes)}`);
|
|
54
|
+
if (scan.truncated && scan.truncationReason) {
|
|
55
|
+
lines.push(` → Parou: ${TRUNCATION_MESSAGES[scan.truncationReason]}`);
|
|
56
|
+
}
|
|
57
|
+
if (scan.topExtensions.length > 0) {
|
|
58
|
+
const top = scan.topExtensions.map((e) => `${e.ext} ${e.count}`).join(" ");
|
|
59
|
+
lines.push(` ${top}`);
|
|
60
|
+
}
|
|
61
|
+
lines.push("");
|
|
62
|
+
if (scan.brainMarkers.length > 0) {
|
|
63
|
+
lines.push(`→ Estrutura de cérebro já presente: ${scan.brainMarkers.join(", ")}`);
|
|
64
|
+
}
|
|
65
|
+
else {
|
|
66
|
+
lines.push("→ Nenhuma estrutura de cérebro encontrada na raiz (CLAUDE.md, index.md, log.md, wiki/, raw/)");
|
|
67
|
+
}
|
|
68
|
+
if (scan.alreadyInstalled) {
|
|
69
|
+
lines.push(" Esta pasta já tem .omni/ — é um espaço Omni existente.");
|
|
70
|
+
}
|
|
71
|
+
lines.push("");
|
|
72
|
+
if (scan.envFiles.length === 0) {
|
|
73
|
+
lines.push("→ Nenhum arquivo .env encontrado");
|
|
74
|
+
}
|
|
75
|
+
else {
|
|
76
|
+
lines.push(`→ ${scan.envFiles.length} arquivo(s) .env encontrado(s): ${scan.envFiles.join(", ")}`);
|
|
77
|
+
lines.push(` ${scan.envKeys.length} chaves encontradas (o arquivo foi lido só para listar os nomes; nenhum valor foi guardado ou enviado)`);
|
|
78
|
+
}
|
|
79
|
+
lines.push("");
|
|
80
|
+
if (scan.stacks.length === 0) {
|
|
81
|
+
lines.push("→ Nenhuma stack conhecida detectada");
|
|
82
|
+
}
|
|
83
|
+
else {
|
|
84
|
+
lines.push(`→ ${scan.stacks.length} stack(s) detectada(s):`);
|
|
85
|
+
for (const stack of scan.stacks) {
|
|
86
|
+
lines.push(` ${stack.label.padEnd(14)} ${stack.envKeys.join(", ")}`);
|
|
87
|
+
}
|
|
88
|
+
lines.push(" Conexão ainda não testada: isso depende da sua autorização, num passo separado.");
|
|
89
|
+
}
|
|
90
|
+
if (scan.unmatchedEnvKeys.length > 0) {
|
|
91
|
+
lines.push("");
|
|
92
|
+
lines.push(`→ ${scan.unmatchedEnvKeys.length} chave(s) sem stack conhecida:`);
|
|
93
|
+
lines.push(` ${scan.unmatchedEnvKeys.join(", ")}`);
|
|
94
|
+
}
|
|
95
|
+
lines.push("");
|
|
96
|
+
lines.push(opts.linking
|
|
97
|
+
? "Até aqui nada foi alterado. O vínculo a seguir grava só .omni/space.json nesta pasta."
|
|
98
|
+
: "Nenhum arquivo foi criado, alterado ou enviado para o servidor.");
|
|
99
|
+
lines.push("");
|
|
100
|
+
console.log(lines.join("\n"));
|
|
101
|
+
}
|
package/dist/scan.js
ADDED
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import { readdir, readFile, stat } from "node:fs/promises";
|
|
2
|
+
import { join, relative, sep } from "node:path";
|
|
3
|
+
import { detectStacks, unmatchedKeys } from "./stacks.js";
|
|
4
|
+
import { SKIP_DIRS } from "./include.js";
|
|
5
|
+
// A folder that already follows the "cérebro do João" layout (docs/plan/kb-architecture.md).
|
|
6
|
+
const BRAIN_MARKERS = ["CLAUDE.md", "index.md", "log.md", "wiki", "raw"];
|
|
7
|
+
const MAX_FILES = 20000;
|
|
8
|
+
const MAX_ENV_BYTES = 256 * 1024;
|
|
9
|
+
// docs/plan/plano-execucao.md, Etapa técnica 1: limites travados em 2026-09-22 contra uma pasta
|
|
10
|
+
// de "possibilidade infinita" (nicho nunca previsto, symlink recursivo, biblioteca de mídia).
|
|
11
|
+
const MAX_DEPTH = 12;
|
|
12
|
+
const MAX_DURATION_MS = 60_000;
|
|
13
|
+
/**
|
|
14
|
+
* Lists the KEY names of a dotenv-style file. The file is read to find the names; values are
|
|
15
|
+
* discarded right here and never stored, printed or sent (docs/plan/adr/004-segredos.md).
|
|
16
|
+
*/
|
|
17
|
+
async function readEnvKeys(path) {
|
|
18
|
+
const info = await stat(path);
|
|
19
|
+
if (info.size > MAX_ENV_BYTES)
|
|
20
|
+
return [];
|
|
21
|
+
const text = await readFile(path, "utf8");
|
|
22
|
+
const keys = [];
|
|
23
|
+
for (const rawLine of text.split(/\r?\n/)) {
|
|
24
|
+
const line = rawLine.trim();
|
|
25
|
+
if (!line || line.startsWith("#"))
|
|
26
|
+
continue;
|
|
27
|
+
const eq = line.indexOf("=");
|
|
28
|
+
if (eq <= 0)
|
|
29
|
+
continue;
|
|
30
|
+
const key = line.slice(0, eq).replace(/^export\s+/, "").trim();
|
|
31
|
+
if (/^[A-Za-z_][A-Za-z0-9_]*$/.test(key))
|
|
32
|
+
keys.push(key);
|
|
33
|
+
}
|
|
34
|
+
return keys;
|
|
35
|
+
}
|
|
36
|
+
export async function scanFolder(root) {
|
|
37
|
+
const extensions = new Map();
|
|
38
|
+
const envFiles = [];
|
|
39
|
+
const envKeySet = new Set();
|
|
40
|
+
let fileCount = 0;
|
|
41
|
+
let dirCount = 0;
|
|
42
|
+
let totalBytes = 0;
|
|
43
|
+
let truncated = false;
|
|
44
|
+
let truncationReason = null;
|
|
45
|
+
const startedAt = Date.now();
|
|
46
|
+
function truncate(reason) {
|
|
47
|
+
truncated = true;
|
|
48
|
+
truncationReason = reason;
|
|
49
|
+
}
|
|
50
|
+
async function walk(dir, depth) {
|
|
51
|
+
if (truncated)
|
|
52
|
+
return;
|
|
53
|
+
if (Date.now() - startedAt > MAX_DURATION_MS) {
|
|
54
|
+
truncate("timeout");
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
if (depth > MAX_DEPTH) {
|
|
58
|
+
truncate("max-depth");
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
const entries = await readdir(dir, { withFileTypes: true });
|
|
62
|
+
for (const entry of entries) {
|
|
63
|
+
// Never follow a symlink: it can point outside the scanned folder or loop back into it.
|
|
64
|
+
if (entry.isSymbolicLink())
|
|
65
|
+
continue;
|
|
66
|
+
const full = join(dir, entry.name);
|
|
67
|
+
if (entry.isDirectory()) {
|
|
68
|
+
if (SKIP_DIRS.has(entry.name))
|
|
69
|
+
continue;
|
|
70
|
+
dirCount++;
|
|
71
|
+
await walk(full, depth + 1);
|
|
72
|
+
if (truncated)
|
|
73
|
+
return;
|
|
74
|
+
continue;
|
|
75
|
+
}
|
|
76
|
+
if (!entry.isFile())
|
|
77
|
+
continue;
|
|
78
|
+
if (fileCount >= MAX_FILES) {
|
|
79
|
+
truncate("max-files");
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
fileCount++;
|
|
83
|
+
try {
|
|
84
|
+
totalBytes += (await stat(full)).size;
|
|
85
|
+
}
|
|
86
|
+
catch {
|
|
87
|
+
// A file can vanish between readdir and stat; size is a summary, not a guarantee.
|
|
88
|
+
}
|
|
89
|
+
const dot = entry.name.lastIndexOf(".");
|
|
90
|
+
const ext = dot > 0 ? entry.name.slice(dot).toLowerCase() : "(sem extensão)";
|
|
91
|
+
extensions.set(ext, (extensions.get(ext) ?? 0) + 1);
|
|
92
|
+
if (/^\.env($|\.)/.test(entry.name)) {
|
|
93
|
+
const rel = relative(root, full) || entry.name;
|
|
94
|
+
// .env.example carries key names with no values by convention, but reading keys from it
|
|
95
|
+
// is still useful: it says what the project expects to be configured.
|
|
96
|
+
envFiles.push(rel.split(sep).join("/"));
|
|
97
|
+
for (const key of await readEnvKeys(full))
|
|
98
|
+
envKeySet.add(key);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
await walk(root, 0);
|
|
103
|
+
const rootEntries = await readdir(root, { withFileTypes: true });
|
|
104
|
+
const rootNames = new Set(rootEntries.map((e) => e.name));
|
|
105
|
+
const envKeys = [...envKeySet].sort();
|
|
106
|
+
return {
|
|
107
|
+
root,
|
|
108
|
+
fileCount,
|
|
109
|
+
dirCount,
|
|
110
|
+
totalBytes,
|
|
111
|
+
truncated,
|
|
112
|
+
truncationReason,
|
|
113
|
+
topExtensions: [...extensions.entries()]
|
|
114
|
+
.map(([ext, count]) => ({ ext, count }))
|
|
115
|
+
.sort((a, b) => b.count - a.count)
|
|
116
|
+
.slice(0, 8),
|
|
117
|
+
envFiles: envFiles.sort(),
|
|
118
|
+
envKeys,
|
|
119
|
+
stacks: detectStacks(envKeys),
|
|
120
|
+
unmatchedEnvKeys: unmatchedKeys(envKeys),
|
|
121
|
+
brainMarkers: BRAIN_MARKERS.filter((marker) => rootNames.has(marker)),
|
|
122
|
+
alreadyInstalled: rootNames.has(".omni"),
|
|
123
|
+
};
|
|
124
|
+
}
|
package/dist/stacks.js
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Maps an environment variable name to the connector it belongs to. Names only — the CLI never
|
|
3
|
+
* reads a credential's value during a scan, and only stores one after explicit consent.
|
|
4
|
+
* Connector ids match the registry in apps/omni/src/connectors.
|
|
5
|
+
*/
|
|
6
|
+
const RULES = [
|
|
7
|
+
{ connectorId: "anthropic", label: "Anthropic", match: /^ANTHROPIC_|^CLAUDE_/ },
|
|
8
|
+
{ connectorId: "supabase", label: "Supabase", match: /^(NEXT_PUBLIC_)?SUPABASE_|^POSTGRES_/ },
|
|
9
|
+
{ connectorId: "gohighlevel", label: "GoHighLevel", match: /^(GHL|HIGHLEVEL|GO_?HIGH_?LEVEL)_/ },
|
|
10
|
+
{ connectorId: "slack", label: "Slack", match: /^SLACK_/ },
|
|
11
|
+
{ connectorId: "github", label: "GitHub", match: /^(GITHUB|GH)_/ },
|
|
12
|
+
{ connectorId: "clickup", label: "ClickUp", match: /^CLICKUP_/ },
|
|
13
|
+
{ connectorId: "ollama", label: "Ollama", match: /^OLLAMA_/ },
|
|
14
|
+
{ connectorId: "mcp", label: "MCP", match: /^MCP_/ },
|
|
15
|
+
{ connectorId: "scraping", label: "Scraping", match: /^(SCRAPLING|SCRAPING)_/ },
|
|
16
|
+
// Template connectors (credentials only for now), see apps/omni/src/connectors/templates.ts.
|
|
17
|
+
{ connectorId: "meta-ads", label: "Meta Ads", match: /^(META|FB|FACEBOOK)_/ },
|
|
18
|
+
{ connectorId: "google-ads", label: "Google Ads", match: /^GOOGLE_ADS_/ },
|
|
19
|
+
{ connectorId: "ga4", label: "GA4", match: /^GA4_/ },
|
|
20
|
+
{ connectorId: "n8n", label: "n8n", match: /^(ND_)?N8N_/ },
|
|
21
|
+
{ connectorId: "stripe", label: "Stripe", match: /^STRIPE_/ },
|
|
22
|
+
{ connectorId: "paypal", label: "PayPal", match: /^PAYPAL_/ },
|
|
23
|
+
{ connectorId: "resend", label: "Resend", match: /^RESEND_/ },
|
|
24
|
+
{ connectorId: "cloudflare", label: "Cloudflare", match: /^(CLOUDFLARE|CF)_/ },
|
|
25
|
+
{ connectorId: "retell", label: "Retell AI", match: /^RETELL_/ },
|
|
26
|
+
];
|
|
27
|
+
export function detectStacks(envKeys) {
|
|
28
|
+
const found = new Map();
|
|
29
|
+
for (const key of envKeys) {
|
|
30
|
+
const rule = RULES.find((r) => r.match.test(key));
|
|
31
|
+
if (!rule)
|
|
32
|
+
continue;
|
|
33
|
+
const entry = found.get(rule.connectorId) ?? {
|
|
34
|
+
connectorId: rule.connectorId,
|
|
35
|
+
label: rule.label,
|
|
36
|
+
envKeys: [],
|
|
37
|
+
};
|
|
38
|
+
entry.envKeys.push(key);
|
|
39
|
+
found.set(rule.connectorId, entry);
|
|
40
|
+
}
|
|
41
|
+
return [...found.values()].sort((a, b) => a.label.localeCompare(b.label));
|
|
42
|
+
}
|
|
43
|
+
/** Env keys that matched no connector — shown separately so nothing is silently ignored. */
|
|
44
|
+
export function unmatchedKeys(envKeys) {
|
|
45
|
+
return envKeys.filter((key) => !RULES.some((r) => r.match.test(key)));
|
|
46
|
+
}
|
package/dist/sync.js
ADDED
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import { readFile } from "node:fs/promises";
|
|
2
|
+
import { createInterface } from "node:readline/promises";
|
|
3
|
+
import { buildManifest, MAX_SPACE_BYTES } from "./include.js";
|
|
4
|
+
import { humanSize, printSyncPreview } from "./report.js";
|
|
5
|
+
import { loadToken, readSpaceConfig, writeSpaceConfig } from "./config.js";
|
|
6
|
+
async function callApi(config, token, path, body) {
|
|
7
|
+
const response = await fetch(`${config.apiUrl}${path}`, {
|
|
8
|
+
method: "POST",
|
|
9
|
+
headers: { "content-type": "application/json", authorization: `Bearer ${token}` },
|
|
10
|
+
body: JSON.stringify(body),
|
|
11
|
+
});
|
|
12
|
+
const json = (await response.json().catch(() => ({})));
|
|
13
|
+
if (!response.ok)
|
|
14
|
+
return { ok: false, status: response.status, error: json.error ?? `HTTP ${response.status}`, ...json };
|
|
15
|
+
return { ok: true, status: response.status, ...json };
|
|
16
|
+
}
|
|
17
|
+
async function confirm(question) {
|
|
18
|
+
if (!process.stdin.isTTY)
|
|
19
|
+
return false;
|
|
20
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
21
|
+
const answer = (await rl.question(question)).trim().toLowerCase();
|
|
22
|
+
rl.close();
|
|
23
|
+
return answer === "s" || answer === "sim";
|
|
24
|
+
}
|
|
25
|
+
/** Uploads one object straight to Storage through the signed URL (never through the Omni server). */
|
|
26
|
+
async function uploadObject(url, abs) {
|
|
27
|
+
const bytes = await readFile(abs);
|
|
28
|
+
const response = await fetch(url, {
|
|
29
|
+
method: "PUT",
|
|
30
|
+
headers: { "content-type": "application/octet-stream", "x-upsert": "true" },
|
|
31
|
+
body: bytes,
|
|
32
|
+
});
|
|
33
|
+
if (!response.ok)
|
|
34
|
+
throw new Error(`HTTP ${response.status} ${await response.text().catch(() => "")}`);
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* `omni sync --push` (Proof of Core Sync, fatia 2; docs/plan/adr/002-protocolo-sync.md).
|
|
38
|
+
* Manifest → prepare (which objects are missing) → direct uploads → commit (publish version).
|
|
39
|
+
* Every step can be re-run after an interruption: objects already sent are skipped by hash, and
|
|
40
|
+
* the version only exists once all of them are confirmed.
|
|
41
|
+
*/
|
|
42
|
+
export async function pushSpace(root, opts) {
|
|
43
|
+
const config = await readSpaceConfig(root);
|
|
44
|
+
if (!config) {
|
|
45
|
+
console.error("Esta pasta ainda não está vinculada. Rode omni init --link <código> antes.");
|
|
46
|
+
return 1;
|
|
47
|
+
}
|
|
48
|
+
const token = await loadToken(config.apiUrl);
|
|
49
|
+
if (!token) {
|
|
50
|
+
console.error(`Nenhum token da CLI para ${config.apiUrl}. Rode omni init --link <código> de novo.`);
|
|
51
|
+
return 1;
|
|
52
|
+
}
|
|
53
|
+
console.log("\n→ Montando o manifesto...");
|
|
54
|
+
const manifest = await buildManifest(root);
|
|
55
|
+
if (manifest.truncated) {
|
|
56
|
+
console.error("Pasta grande ou funda demais para sincronizar (limite de 20.000 arquivos e 12 níveis).");
|
|
57
|
+
return 1;
|
|
58
|
+
}
|
|
59
|
+
if (manifest.collisions.length > 0) {
|
|
60
|
+
console.error("Há arquivos cujo nome só difere por maiúsculas/minúsculas; no Windows eles colidem:");
|
|
61
|
+
for (const group of manifest.collisions)
|
|
62
|
+
console.error(` ${group.join(" × ")}`);
|
|
63
|
+
return 1;
|
|
64
|
+
}
|
|
65
|
+
if (manifest.totalBytes > MAX_SPACE_BYTES) {
|
|
66
|
+
console.error(`O espaço passa de 100 MB de texto (${humanSize(manifest.totalBytes)}), limite do alpha.`);
|
|
67
|
+
return 1;
|
|
68
|
+
}
|
|
69
|
+
printSyncPreview(manifest);
|
|
70
|
+
const files = manifest.files.map(({ path, hash, size }) => ({ path, hash, size }));
|
|
71
|
+
const prepare = await callApi(config, token, "/api/cli/sync/prepare", {
|
|
72
|
+
spaceId: config.spaceId,
|
|
73
|
+
sessionId: config.spaceId ? undefined : config.sessionId,
|
|
74
|
+
files,
|
|
75
|
+
});
|
|
76
|
+
if (!prepare.ok) {
|
|
77
|
+
console.error(`Falha ao preparar o envio: ${prepare.error}`);
|
|
78
|
+
return 1;
|
|
79
|
+
}
|
|
80
|
+
const spaceId = prepare.spaceId;
|
|
81
|
+
const head = prepare.head;
|
|
82
|
+
if (!config.spaceId)
|
|
83
|
+
await writeSpaceConfig(root, { ...config, spaceId });
|
|
84
|
+
// The folder is identical to the head. Either nothing changed, or a previous push was cut off
|
|
85
|
+
// after the server published but before .omni/space.json got the new base: adopt the head.
|
|
86
|
+
if (prepare.unchanged) {
|
|
87
|
+
if (head !== config.baseVersion) {
|
|
88
|
+
await writeSpaceConfig(root, { ...config, spaceId, baseVersion: head });
|
|
89
|
+
console.log(`✓ Esta pasta já é a versão ${head} do servidor (um envio anterior terminou lá). Vínculo atualizado.\n`);
|
|
90
|
+
}
|
|
91
|
+
else {
|
|
92
|
+
console.log(`✓ Nada mudou desde a versão ${head}. Nada foi enviado.\n`);
|
|
93
|
+
}
|
|
94
|
+
return 0;
|
|
95
|
+
}
|
|
96
|
+
if (head !== config.baseVersion) {
|
|
97
|
+
console.error(`O servidor está na versão ${head} e esta pasta partiu da ${config.baseVersion}. ` +
|
|
98
|
+
"Baixe as mudanças antes de enviar (omni sync --pull, próxima fatia).");
|
|
99
|
+
return 1;
|
|
100
|
+
}
|
|
101
|
+
const uploads = prepare.uploads;
|
|
102
|
+
const absByHash = new Map(manifest.files.map((f) => [f.hash, f.abs]));
|
|
103
|
+
const sizeToSend = uploads.reduce((sum, u) => sum + (manifest.files.find((f) => f.hash === u.hash)?.size ?? 0), 0);
|
|
104
|
+
if (!opts.yes) {
|
|
105
|
+
const ok = await confirm(`\nEnviar ${uploads.length} arquivo(s) novo(s) ou alterado(s) (${humanSize(sizeToSend)}) e publicar a versão ${head + 1}? (s/n) `);
|
|
106
|
+
if (!ok) {
|
|
107
|
+
console.log("Nada foi enviado. (Use --yes para pular esta pergunta.)");
|
|
108
|
+
return 1;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
let done = 0;
|
|
112
|
+
const queue = [...uploads];
|
|
113
|
+
async function worker() {
|
|
114
|
+
for (let next = queue.shift(); next; next = queue.shift()) {
|
|
115
|
+
await uploadObject(next.url, absByHash.get(next.hash));
|
|
116
|
+
done++;
|
|
117
|
+
if (process.stdout.isTTY)
|
|
118
|
+
process.stdout.write(`\r Enviados ${done}/${uploads.length}`);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
try {
|
|
122
|
+
await Promise.all(Array.from({ length: Math.min(4, uploads.length) }, worker));
|
|
123
|
+
}
|
|
124
|
+
catch (err) {
|
|
125
|
+
console.error(`\nEnvio interrompido: ${err.message}. Rode de novo: o que já subiu não é reenviado.`);
|
|
126
|
+
return 1;
|
|
127
|
+
}
|
|
128
|
+
if (uploads.length > 0)
|
|
129
|
+
console.log(process.stdout.isTTY ? "" : ` Enviados ${done}/${uploads.length}`);
|
|
130
|
+
console.log(`→ Publicando a versão ${head + 1} (o servidor confere ${files.length} arquivos, pode levar alguns segundos)...`);
|
|
131
|
+
const commit = await callApi(config, token, "/api/cli/sync/commit", { spaceId, parentVersion: head, files });
|
|
132
|
+
if (!commit.ok) {
|
|
133
|
+
console.error(`Falha ao publicar a versão: ${commit.error}`);
|
|
134
|
+
return 1;
|
|
135
|
+
}
|
|
136
|
+
const version = commit.version;
|
|
137
|
+
await writeSpaceConfig(root, { ...config, spaceId, baseVersion: version });
|
|
138
|
+
console.log(`✓ Versão ${version} publicada. ${uploads.length} arquivo(s) enviado(s).\n`);
|
|
139
|
+
return 0;
|
|
140
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "omnispace",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Omni CLI (alpha): transforma uma pasta da sua máquina em um espaço Omni e sincroniza entre máquinas.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"omni": "dist/index.js",
|
|
8
|
+
"omnispace": "dist/index.js"
|
|
9
|
+
},
|
|
10
|
+
"files": [
|
|
11
|
+
"dist",
|
|
12
|
+
"README.md"
|
|
13
|
+
],
|
|
14
|
+
"scripts": {
|
|
15
|
+
"build": "tsc -p tsconfig.json",
|
|
16
|
+
"typecheck": "tsc -p tsconfig.json --noEmit",
|
|
17
|
+
"omni": "node dist/index.js",
|
|
18
|
+
"test": "tsc -p tsconfig.test.json && node --test \".test-build/test/**/*.test.js\""
|
|
19
|
+
},
|
|
20
|
+
"engines": {
|
|
21
|
+
"node": ">=20"
|
|
22
|
+
},
|
|
23
|
+
"keywords": [
|
|
24
|
+
"omni",
|
|
25
|
+
"tzolkin",
|
|
26
|
+
"ai",
|
|
27
|
+
"agent",
|
|
28
|
+
"sync",
|
|
29
|
+
"knowledge-base",
|
|
30
|
+
"cli"
|
|
31
|
+
],
|
|
32
|
+
"homepage": "https://omni.tzolkin.cloud",
|
|
33
|
+
"license": "UNLICENSED",
|
|
34
|
+
"author": "Tzolkin",
|
|
35
|
+
"devDependencies": {
|
|
36
|
+
"@types/node": "^20",
|
|
37
|
+
"typescript": "^5"
|
|
38
|
+
}
|
|
39
|
+
}
|