omnispace 0.1.0 → 0.2.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 +49 -37
- package/dist/api.js +11 -0
- package/dist/config.js +7 -2
- package/dist/include.js +7 -2
- package/dist/index.js +28 -13
- package/dist/link.js +3 -3
- package/dist/pull.js +292 -0
- package/dist/sync.js +6 -14
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,37 +1,49 @@
|
|
|
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.
|
|
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
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
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. Existem `init`, `sync --push`, `sync --pull` e `clone`. Quando a mesma parte de um arquivo muda nas duas máquinas, o `sync --pull` para sem mexer na pasta; juntar as duas versões automaticamente vem numa 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
|
+
Trazer para a pasta a versão mais nova do servidor:
|
|
30
|
+
|
|
31
|
+
```
|
|
32
|
+
npx -y omnispace@latest sync . --pull
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
Baixar um espaço da sua conta em outra máquina (o código aparece no site, em "Baixar em outra máquina"; a pasta é criada):
|
|
36
|
+
|
|
37
|
+
```
|
|
38
|
+
npx -y omnispace@latest clone <código> "nome-da-pasta"
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
No Windows, se o caminho tiver espaço ou apóstrofo, entre na pasta com aspas duplas: `cd "C:\caminho\da\pasta"`.
|
|
42
|
+
|
|
43
|
+
## O que o Omni lê e envia
|
|
44
|
+
|
|
45
|
+
- 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.
|
|
46
|
+
- Nunca sobem: `.env` e variações, chaves (`.pem`, `.key`, `id_rsa`), pastas `.git`, `node_modules` e de build, imagens e mídia.
|
|
47
|
+
- Arquivos `.env` são lidos só para listar os nomes das chaves; nenhum valor é guardado ou enviado.
|
|
48
|
+
- Antes de enviar, a CLI mostra o que sobe e o que fica de fora e pede confirmação.
|
|
49
|
+
- Na pasta, o Omni grava só `.omni/space.json` (e `.omni/tmp/` durante um download, apagada no fim). O token de acesso fica em `~/.omni/credentials.json`, fora de qualquer projeto.
|
package/dist/api.js
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/** POST to the Omni server. `token` is the CLI bearer token from ~/.omni/credentials.json, if any. */
|
|
2
|
+
export async function callApi(apiUrl, token, path, body) {
|
|
3
|
+
const headers = { "content-type": "application/json" };
|
|
4
|
+
if (token)
|
|
5
|
+
headers.authorization = `Bearer ${token}`;
|
|
6
|
+
const response = await fetch(`${apiUrl}${path}`, { method: "POST", headers, body: JSON.stringify(body) });
|
|
7
|
+
const json = (await response.json().catch(() => ({})));
|
|
8
|
+
if (!response.ok)
|
|
9
|
+
return { ok: false, status: response.status, error: json.error ?? `HTTP ${response.status}`, ...json };
|
|
10
|
+
return { ok: true, status: response.status, ...json };
|
|
11
|
+
}
|
package/dist/config.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
2
|
-
import { homedir } from "node:os";
|
|
2
|
+
import { homedir, hostname } from "node:os";
|
|
3
3
|
import { dirname, join } from "node:path";
|
|
4
4
|
// Two places, on purpose:
|
|
5
5
|
// - ~/.omni/credentials.json: the CLI token, per server. Lives in the user's home, outside every
|
|
@@ -10,8 +10,13 @@ const DEFAULT_API_URL = "https://omni.tzolkin.cloud";
|
|
|
10
10
|
export function defaultApiUrl() {
|
|
11
11
|
return process.env.OMNI_API_URL?.replace(/\/$/, "") || DEFAULT_API_URL;
|
|
12
12
|
}
|
|
13
|
+
// OMNI_HOME and OMNI_MACHINE_NAME exist to simulate two machines on one PC (fatia 3): each gets
|
|
14
|
+
// its own credentials and its own row in `machines` (unique per account and hostname).
|
|
15
|
+
export function machineHostname() {
|
|
16
|
+
return process.env.OMNI_MACHINE_NAME?.trim() || hostname();
|
|
17
|
+
}
|
|
13
18
|
function credentialsPath() {
|
|
14
|
-
return join(homedir(), ".omni", "credentials.json");
|
|
19
|
+
return join(process.env.OMNI_HOME?.trim() || join(homedir(), ".omni"), "credentials.json");
|
|
15
20
|
}
|
|
16
21
|
export function spaceConfigPath(root) {
|
|
17
22
|
return join(root, ".omni", "space.json");
|
package/dist/include.js
CHANGED
|
@@ -9,8 +9,13 @@ export const SKIP_DIRS = new Set([
|
|
|
9
9
|
"node_modules", ".git", ".next", ".vercel", "dist", "build", "out", "coverage",
|
|
10
10
|
".turbo", ".cache", "venv", ".venv", "__pycache__", ".idea", ".vscode", "target",
|
|
11
11
|
]);
|
|
12
|
-
// Local state of the Omni link itself: machine specific, never part of the space.
|
|
12
|
+
// Local state of the Omni link itself: machine specific, never part of the space. `.omni/tmp/`
|
|
13
|
+
// holds downloads of an unfinished pull (fatia 3).
|
|
13
14
|
const LOCAL_ONLY = new Set([".omni/space.json"]);
|
|
15
|
+
export const LOCAL_TMP_DIR = ".omni/tmp";
|
|
16
|
+
function isLocalOnly(path) {
|
|
17
|
+
return LOCAL_ONLY.has(path) || path.startsWith(`${LOCAL_TMP_DIR}/`);
|
|
18
|
+
}
|
|
14
19
|
const TEXT_EXTENSIONS = new Set([
|
|
15
20
|
".md", ".mdx", ".txt", ".csv", ".tsv", ".json", ".jsonc", ".yaml", ".yml", ".toml", ".xml",
|
|
16
21
|
".html", ".htm", ".css", ".scss", ".sass", ".less", ".svg",
|
|
@@ -113,7 +118,7 @@ export async function buildManifest(root) {
|
|
|
113
118
|
return;
|
|
114
119
|
}
|
|
115
120
|
const path = normalizePath(relParts);
|
|
116
|
-
if (
|
|
121
|
+
if (isLocalOnly(path))
|
|
117
122
|
continue;
|
|
118
123
|
if (isSecret(entry.name)) {
|
|
119
124
|
excluded.push({ path, reason: "segredo" });
|
package/dist/index.js
CHANGED
|
@@ -6,18 +6,21 @@ import { scanFolder } from "./scan.js";
|
|
|
6
6
|
import { printScanReport } from "./report.js";
|
|
7
7
|
import { linkAndSubmit } from "./link.js";
|
|
8
8
|
import { pushSpace } from "./sync.js";
|
|
9
|
+
import { cloneSpace, pullSpace } from "./pull.js";
|
|
9
10
|
import { buildManifest } from "./include.js";
|
|
10
11
|
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
|
-
|
|
20
|
-
|
|
12
|
+
const USAGE = `
|
|
13
|
+
Omni CLI
|
|
14
|
+
|
|
15
|
+
Uso:
|
|
16
|
+
omni init [pasta] --scan-only varre a pasta e mostra o que encontrou
|
|
17
|
+
omni init [pasta] --link <código> varre a pasta e envia pro código mostrado no navegador
|
|
18
|
+
omni sync [pasta] --push [--yes] publica uma nova versão do espaço no servidor
|
|
19
|
+
omni sync [pasta] --pull traz para a pasta a versão mais nova do servidor
|
|
20
|
+
omni clone <código> <pasta> baixa um espaço da sua conta numa pasta nova desta máquina
|
|
21
|
+
|
|
22
|
+
Gravar a estrutura padrão na pasta entra num próximo passo da Etapa técnica 1 —
|
|
23
|
+
por enquanto quem decide o que fazer com o que foi varrido é a tela de revisão.
|
|
21
24
|
`;
|
|
22
25
|
async function main(argv) {
|
|
23
26
|
const args = argv.slice(2);
|
|
@@ -28,6 +31,8 @@ async function main(argv) {
|
|
|
28
31
|
}
|
|
29
32
|
if (command === "sync")
|
|
30
33
|
return runSync(args.slice(1));
|
|
34
|
+
if (command === "clone")
|
|
35
|
+
return runClone(args.slice(1));
|
|
31
36
|
if (command !== "init") {
|
|
32
37
|
console.error(`Comando desconhecido: ${command}`);
|
|
33
38
|
console.log(USAGE.trim());
|
|
@@ -79,11 +84,21 @@ async function main(argv) {
|
|
|
79
84
|
return 0;
|
|
80
85
|
}
|
|
81
86
|
async function runSync(rest) {
|
|
82
|
-
|
|
83
|
-
|
|
87
|
+
const push = rest.includes("--push");
|
|
88
|
+
const pull = rest.includes("--pull");
|
|
89
|
+
if (push === pull) {
|
|
90
|
+
console.error("Use omni sync --push (enviar) ou omni sync --pull (baixar).");
|
|
84
91
|
return 1;
|
|
85
92
|
}
|
|
86
93
|
const target = resolve(rest.find((a) => !a.startsWith("-")) ?? process.cwd());
|
|
87
|
-
return pushSpace(target, { yes: rest.includes("--yes") });
|
|
94
|
+
return push ? pushSpace(target, { yes: rest.includes("--yes") }) : pullSpace(target);
|
|
95
|
+
}
|
|
96
|
+
async function runClone(rest) {
|
|
97
|
+
const [code, folder] = rest.filter((a) => !a.startsWith("-"));
|
|
98
|
+
if (!code || !folder) {
|
|
99
|
+
console.error("Uso: omni clone <código> <pasta> (o código aparece no navegador, em Baixar em outra máquina)");
|
|
100
|
+
return 1;
|
|
101
|
+
}
|
|
102
|
+
return cloneSpace(code, folder);
|
|
88
103
|
}
|
|
89
104
|
process.exitCode = await main(process.argv);
|
package/dist/link.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { defaultApiUrl, saveToken, writeSpaceConfig } from "./config.js";
|
|
1
|
+
import { platform } from "node:os";
|
|
2
|
+
import { defaultApiUrl, machineHostname, saveToken, writeSpaceConfig } from "./config.js";
|
|
3
3
|
function apiUrl(path) {
|
|
4
4
|
return `${defaultApiUrl()}${path}`;
|
|
5
5
|
}
|
|
@@ -21,7 +21,7 @@ async function postJson(path, body) {
|
|
|
21
21
|
*/
|
|
22
22
|
export async function linkAndSubmit(code, root, scan) {
|
|
23
23
|
console.log(`\n→ Vinculando com o código ${code}...`);
|
|
24
|
-
const link = await postJson("/api/cli/link", { code, hostname:
|
|
24
|
+
const link = await postJson("/api/cli/link", { code, hostname: machineHostname(), os: platform() });
|
|
25
25
|
if (!link.ok) {
|
|
26
26
|
console.error(`Falha ao vincular: ${link.error}`);
|
|
27
27
|
process.exitCode = 1;
|
package/dist/pull.js
ADDED
|
@@ -0,0 +1,292 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { copyFile, mkdir, readdir, rename, rm, rmdir, stat, writeFile } from "node:fs/promises";
|
|
3
|
+
import { homedir, platform } from "node:os";
|
|
4
|
+
import { basename, dirname, join, resolve } from "node:path";
|
|
5
|
+
import { callApi } from "./api.js";
|
|
6
|
+
import { buildManifest, hashFile, LOCAL_TMP_DIR } from "./include.js";
|
|
7
|
+
import { defaultApiUrl, loadToken, machineHostname, readSpaceConfig, saveToken, writeSpaceConfig, } from "./config.js";
|
|
8
|
+
/**
|
|
9
|
+
* Three-way comparison per path (ADR 002): local, base (the version this folder last synced) and
|
|
10
|
+
* head. Maps are path → hash; a missing path means "does not exist".
|
|
11
|
+
*
|
|
12
|
+
* Local equal to head is already applied, which is what makes a re-run after an interruption safe:
|
|
13
|
+
* files written before the crash are simply skipped the second time.
|
|
14
|
+
*/
|
|
15
|
+
export function planPull(local, base, head) {
|
|
16
|
+
const plan = { writes: [], deletes: [], localOnly: [], conflicts: [] };
|
|
17
|
+
const paths = [...new Set([...local.keys(), ...base.keys(), ...head.keys()])].sort();
|
|
18
|
+
for (const path of paths) {
|
|
19
|
+
const l = local.get(path);
|
|
20
|
+
const b = base.get(path);
|
|
21
|
+
const h = head.get(path);
|
|
22
|
+
if (l === h)
|
|
23
|
+
continue;
|
|
24
|
+
if (l === b) {
|
|
25
|
+
if (h === undefined)
|
|
26
|
+
plan.deletes.push(path);
|
|
27
|
+
else
|
|
28
|
+
plan.writes.push({ path, hash: h });
|
|
29
|
+
}
|
|
30
|
+
else if (h === b) {
|
|
31
|
+
plan.localOnly.push(path);
|
|
32
|
+
}
|
|
33
|
+
else {
|
|
34
|
+
plan.conflicts.push(path);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
return plan;
|
|
38
|
+
}
|
|
39
|
+
function sha256(bytes) {
|
|
40
|
+
return createHash("sha256").update(bytes).digest("hex");
|
|
41
|
+
}
|
|
42
|
+
function toAbs(root, path) {
|
|
43
|
+
return join(root, ...path.split("/"));
|
|
44
|
+
}
|
|
45
|
+
/** Objects already downloaded by an interrupted pull, re-verified (a half-written file never counts). */
|
|
46
|
+
async function cachedObjects(tmpDir) {
|
|
47
|
+
const ok = new Set();
|
|
48
|
+
let names = [];
|
|
49
|
+
try {
|
|
50
|
+
names = await readdir(tmpDir);
|
|
51
|
+
}
|
|
52
|
+
catch {
|
|
53
|
+
return ok;
|
|
54
|
+
}
|
|
55
|
+
for (const name of names) {
|
|
56
|
+
if (!/^[0-9a-f]{64}$/.test(name))
|
|
57
|
+
continue;
|
|
58
|
+
const abs = join(tmpDir, name);
|
|
59
|
+
if ((await hashFile(abs)) === name)
|
|
60
|
+
ok.add(name);
|
|
61
|
+
else
|
|
62
|
+
await rm(abs, { force: true });
|
|
63
|
+
}
|
|
64
|
+
return ok;
|
|
65
|
+
}
|
|
66
|
+
async function download(url, hash, tmpDir) {
|
|
67
|
+
const response = await fetch(url);
|
|
68
|
+
if (!response.ok)
|
|
69
|
+
throw new Error(`HTTP ${response.status} ao baixar ${hash.slice(0, 12)}`);
|
|
70
|
+
const bytes = new Uint8Array(await response.arrayBuffer());
|
|
71
|
+
if (sha256(bytes) !== hash)
|
|
72
|
+
throw new Error(`conteúdo baixado não confere com o hash ${hash.slice(0, 12)}`);
|
|
73
|
+
// Written under a temporary name and renamed: a kill in the middle never leaves a file that
|
|
74
|
+
// looks complete.
|
|
75
|
+
const part = join(tmpDir, `${hash}.part`);
|
|
76
|
+
await writeFile(part, bytes);
|
|
77
|
+
await rename(part, join(tmpDir, hash));
|
|
78
|
+
}
|
|
79
|
+
/** Removes the folders a deletion left empty, stopping at the space root. */
|
|
80
|
+
async function pruneEmptyDirs(root, path) {
|
|
81
|
+
const parts = path.split("/").slice(0, -1);
|
|
82
|
+
while (parts.length > 0) {
|
|
83
|
+
try {
|
|
84
|
+
await rmdir(toAbs(root, parts.join("/")));
|
|
85
|
+
}
|
|
86
|
+
catch {
|
|
87
|
+
return; // not empty (or already gone)
|
|
88
|
+
}
|
|
89
|
+
parts.pop();
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
function listSome(paths, max = 20) {
|
|
93
|
+
for (const path of paths.slice(0, max))
|
|
94
|
+
console.error(` ${path}`);
|
|
95
|
+
if (paths.length > max)
|
|
96
|
+
console.error(` ... e mais ${paths.length - max}`);
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* Brings the folder to the server's head (Proof of Core Sync, fatia 3). Order matters for
|
|
100
|
+
* interruptions: every object is downloaded and verified into .omni/tmp first, then files are
|
|
101
|
+
* written, and only at the very end .omni/space.json gets the new base. A run killed anywhere
|
|
102
|
+
* before that simply runs again.
|
|
103
|
+
*/
|
|
104
|
+
async function pullInto(root, config, token) {
|
|
105
|
+
if (!config.spaceId) {
|
|
106
|
+
console.error("Esta pasta ainda não tem espaço publicado. Termine a conta no navegador e rode omni sync --push.");
|
|
107
|
+
return 1;
|
|
108
|
+
}
|
|
109
|
+
console.log("\n→ Lendo a pasta...");
|
|
110
|
+
const manifest = await buildManifest(root);
|
|
111
|
+
if (manifest.truncated) {
|
|
112
|
+
console.error("Pasta grande ou funda demais para sincronizar (limite de 20.000 arquivos e 12 níveis).");
|
|
113
|
+
return 1;
|
|
114
|
+
}
|
|
115
|
+
const tmpDir = toAbs(root, LOCAL_TMP_DIR);
|
|
116
|
+
const cached = await cachedObjects(tmpDir);
|
|
117
|
+
const have = [...new Set([...manifest.files.map((f) => f.hash), ...cached])];
|
|
118
|
+
const pull = await callApi(config.apiUrl, token, "/api/cli/sync/pull", {
|
|
119
|
+
spaceId: config.spaceId,
|
|
120
|
+
baseVersion: config.baseVersion,
|
|
121
|
+
have,
|
|
122
|
+
});
|
|
123
|
+
if (!pull.ok) {
|
|
124
|
+
console.error(`Falha ao consultar o servidor: ${pull.error}`);
|
|
125
|
+
return 1;
|
|
126
|
+
}
|
|
127
|
+
const head = pull.head;
|
|
128
|
+
const local = new Map(manifest.files.map((f) => [f.path, f.hash]));
|
|
129
|
+
const base = new Map(pull.base.map((f) => [f.path, f.hash]));
|
|
130
|
+
const headMap = new Map(pull.files.map((f) => [f.path, f.hash]));
|
|
131
|
+
const plan = planPull(local, base, headMap);
|
|
132
|
+
if (head === config.baseVersion) {
|
|
133
|
+
if (head === 0)
|
|
134
|
+
console.log("✓ O espaço ainda não tem nenhuma versão publicada. Nada para baixar.\n");
|
|
135
|
+
else
|
|
136
|
+
console.log(`✓ Esta pasta já está na versão ${head}, a mais nova do servidor.\n`);
|
|
137
|
+
if (plan.localOnly.length > 0) {
|
|
138
|
+
console.log(` ${plan.localOnly.length} mudança(s) local(is) ainda não enviada(s). Envie com omni sync --push.\n`);
|
|
139
|
+
}
|
|
140
|
+
return 0;
|
|
141
|
+
}
|
|
142
|
+
if (plan.conflicts.length > 0) {
|
|
143
|
+
console.error(`\nO servidor está na versão ${head} e estes arquivos mudaram aqui e lá desde a versão ${config.baseVersion}:`);
|
|
144
|
+
listSome(plan.conflicts);
|
|
145
|
+
console.error("\nNada foi alterado na pasta. Juntar as duas mudanças automaticamente ainda não existe nesta versão da CLI.");
|
|
146
|
+
return 1;
|
|
147
|
+
}
|
|
148
|
+
// Every object the writes need ends up in .omni/tmp: downloaded, or copied from a local file that
|
|
149
|
+
// already has that content (a rename on the other machine costs no download).
|
|
150
|
+
await mkdir(tmpDir, { recursive: true });
|
|
151
|
+
const localByHash = new Map(manifest.files.map((f) => [f.hash, f.abs]));
|
|
152
|
+
const urlByHash = new Map(pull.downloads.map((d) => [d.hash, d.url]));
|
|
153
|
+
const needed = [...new Set(plan.writes.map((w) => w.hash))].filter((h) => !cached.has(h));
|
|
154
|
+
const toDownload = [];
|
|
155
|
+
for (const hash of needed) {
|
|
156
|
+
const localAbs = localByHash.get(hash);
|
|
157
|
+
if (localAbs)
|
|
158
|
+
await copyFile(localAbs, join(tmpDir, hash));
|
|
159
|
+
else if (urlByHash.has(hash))
|
|
160
|
+
toDownload.push(hash);
|
|
161
|
+
else {
|
|
162
|
+
console.error("O servidor não mandou um dos arquivos. Rode de novo.");
|
|
163
|
+
return 1;
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
if (toDownload.length > 0) {
|
|
167
|
+
let done = 0;
|
|
168
|
+
const queue = [...toDownload];
|
|
169
|
+
async function worker() {
|
|
170
|
+
for (let next = queue.shift(); next; next = queue.shift()) {
|
|
171
|
+
await download(urlByHash.get(next), next, tmpDir);
|
|
172
|
+
done++;
|
|
173
|
+
if (process.stdout.isTTY)
|
|
174
|
+
process.stdout.write(`\r Baixados ${done}/${toDownload.length}`);
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
try {
|
|
178
|
+
await Promise.all(Array.from({ length: Math.min(4, toDownload.length) }, worker));
|
|
179
|
+
}
|
|
180
|
+
catch (err) {
|
|
181
|
+
console.error(`\nDownload interrompido: ${err.message}. Rode de novo: o que já chegou não é baixado outra vez.`);
|
|
182
|
+
return 1;
|
|
183
|
+
}
|
|
184
|
+
console.log(process.stdout.isTTY ? "" : ` Baixados ${done}/${toDownload.length}`);
|
|
185
|
+
}
|
|
186
|
+
try {
|
|
187
|
+
for (const { path, hash } of plan.writes) {
|
|
188
|
+
const abs = toAbs(root, path);
|
|
189
|
+
await mkdir(dirname(abs), { recursive: true });
|
|
190
|
+
await copyFile(join(tmpDir, hash), abs);
|
|
191
|
+
}
|
|
192
|
+
for (const path of plan.deletes) {
|
|
193
|
+
await rm(toAbs(root, path), { force: true });
|
|
194
|
+
await pruneEmptyDirs(root, path);
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
catch (err) {
|
|
198
|
+
console.error(`\nNão deu para gravar um arquivo: ${err.message}`);
|
|
199
|
+
console.error("Se ele estiver aberto em outro programa, feche e rode de novo. O que já foi gravado não se perde.");
|
|
200
|
+
return 1;
|
|
201
|
+
}
|
|
202
|
+
await writeSpaceConfig(root, { ...config, baseVersion: head });
|
|
203
|
+
await rm(tmpDir, { recursive: true, force: true });
|
|
204
|
+
const ack = await callApi(config.apiUrl, token, "/api/cli/sync/ack", { spaceId: config.spaceId, version: head });
|
|
205
|
+
if (!ack.ok)
|
|
206
|
+
console.error(`(aviso) A pasta está na versão ${head}, mas o servidor não registrou: ${ack.error}`);
|
|
207
|
+
console.log(`✓ Pasta na versão ${head}: ${plan.writes.length} arquivo(s) gravado(s), ${plan.deletes.length} apagado(s).`);
|
|
208
|
+
if (plan.localOnly.length > 0) {
|
|
209
|
+
console.log(` ${plan.localOnly.length} mudança(s) local(is) mantida(s). Envie com omni sync --push.`);
|
|
210
|
+
}
|
|
211
|
+
console.log("");
|
|
212
|
+
return 0;
|
|
213
|
+
}
|
|
214
|
+
/** `omni sync --pull` in a folder that is already a space. */
|
|
215
|
+
export async function pullSpace(root) {
|
|
216
|
+
const config = await readSpaceConfig(root);
|
|
217
|
+
if (!config) {
|
|
218
|
+
console.error("Esta pasta não está vinculada a um espaço. Para baixar um espaço, use omni clone <código> <pasta>.");
|
|
219
|
+
return 1;
|
|
220
|
+
}
|
|
221
|
+
const token = await loadToken(config.apiUrl);
|
|
222
|
+
if (!token) {
|
|
223
|
+
console.error(`Nenhum token da CLI para ${config.apiUrl}. Vincule esta máquina de novo.`);
|
|
224
|
+
return 1;
|
|
225
|
+
}
|
|
226
|
+
return pullInto(root, config, token);
|
|
227
|
+
}
|
|
228
|
+
async function isEmptyDir(path) {
|
|
229
|
+
try {
|
|
230
|
+
if (!(await stat(path)).isDirectory())
|
|
231
|
+
return false;
|
|
232
|
+
return (await readdir(path)).length === 0;
|
|
233
|
+
}
|
|
234
|
+
catch {
|
|
235
|
+
return null; // does not exist
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
/**
|
|
239
|
+
* `omni clone <código> <pasta>` (fatia 3): installs a space of an existing account on this machine.
|
|
240
|
+
* Everything happens in a hidden sibling folder, moved to the target only once complete, so a
|
|
241
|
+
* half-cloned space never looks like a real one. Re-running the same command resumes it, even
|
|
242
|
+
* though the code is already used: the sibling folder already holds the link.
|
|
243
|
+
*/
|
|
244
|
+
export async function cloneSpace(code, target) {
|
|
245
|
+
const dest = resolve(target);
|
|
246
|
+
if (dest.toLowerCase() === resolve(homedir()).toLowerCase()) {
|
|
247
|
+
console.error("Escolha uma pasta nova para o espaço, não a pasta do seu usuário.");
|
|
248
|
+
return 1;
|
|
249
|
+
}
|
|
250
|
+
const destState = await isEmptyDir(dest);
|
|
251
|
+
if (destState === false) {
|
|
252
|
+
console.error(`A pasta ${dest} já existe e não está vazia. Escolha outro nome.`);
|
|
253
|
+
return 1;
|
|
254
|
+
}
|
|
255
|
+
const staging = join(dirname(dest), `.${basename(dest)}.omni-clone`);
|
|
256
|
+
let config = await readSpaceConfig(staging);
|
|
257
|
+
if (config) {
|
|
258
|
+
console.log(`\n→ Retomando o download interrompido em ${staging}...`);
|
|
259
|
+
}
|
|
260
|
+
else {
|
|
261
|
+
const apiUrl = defaultApiUrl();
|
|
262
|
+
console.log(`\n→ Usando o código ${code}...`);
|
|
263
|
+
const claim = await callApi(apiUrl, await loadToken(apiUrl), "/api/cli/clone", {
|
|
264
|
+
code,
|
|
265
|
+
hostname: machineHostname(),
|
|
266
|
+
os: platform(),
|
|
267
|
+
localPath: dest,
|
|
268
|
+
});
|
|
269
|
+
if (!claim.ok) {
|
|
270
|
+
console.error(`Falha ao usar o código: ${claim.error}`);
|
|
271
|
+
return 1;
|
|
272
|
+
}
|
|
273
|
+
if (typeof claim.token === "string")
|
|
274
|
+
await saveToken(apiUrl, claim.token, claim.machineId);
|
|
275
|
+
config = { apiUrl, machineId: claim.machineId, spaceId: claim.spaceId, baseVersion: 0 };
|
|
276
|
+
await writeSpaceConfig(staging, config);
|
|
277
|
+
console.log(`✓ Máquina vinculada ao espaço ${claim.spaceName}.`);
|
|
278
|
+
}
|
|
279
|
+
const token = await loadToken(config.apiUrl);
|
|
280
|
+
if (!token) {
|
|
281
|
+
console.error(`Nenhum token da CLI para ${config.apiUrl}. Gere um código novo no navegador e rode de novo.`);
|
|
282
|
+
return 1;
|
|
283
|
+
}
|
|
284
|
+
const result = await pullInto(staging, config, token);
|
|
285
|
+
if (result !== 0)
|
|
286
|
+
return result;
|
|
287
|
+
if (destState === true)
|
|
288
|
+
await rmdir(dest);
|
|
289
|
+
await rename(staging, dest);
|
|
290
|
+
console.log(`✓ Espaço pronto em ${dest}\n`);
|
|
291
|
+
return 0;
|
|
292
|
+
}
|
package/dist/sync.js
CHANGED
|
@@ -3,17 +3,7 @@ import { createInterface } from "node:readline/promises";
|
|
|
3
3
|
import { buildManifest, MAX_SPACE_BYTES } from "./include.js";
|
|
4
4
|
import { humanSize, printSyncPreview } from "./report.js";
|
|
5
5
|
import { loadToken, readSpaceConfig, writeSpaceConfig } from "./config.js";
|
|
6
|
-
|
|
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
|
-
}
|
|
6
|
+
import { callApi } from "./api.js";
|
|
17
7
|
async function confirm(question) {
|
|
18
8
|
if (!process.stdin.isTTY)
|
|
19
9
|
return false;
|
|
@@ -68,7 +58,7 @@ export async function pushSpace(root, opts) {
|
|
|
68
58
|
}
|
|
69
59
|
printSyncPreview(manifest);
|
|
70
60
|
const files = manifest.files.map(({ path, hash, size }) => ({ path, hash, size }));
|
|
71
|
-
const prepare = await callApi(config, token, "/api/cli/sync/prepare", {
|
|
61
|
+
const prepare = await callApi(config.apiUrl, token, "/api/cli/sync/prepare", {
|
|
72
62
|
spaceId: config.spaceId,
|
|
73
63
|
sessionId: config.spaceId ? undefined : config.sessionId,
|
|
74
64
|
files,
|
|
@@ -95,7 +85,7 @@ export async function pushSpace(root, opts) {
|
|
|
95
85
|
}
|
|
96
86
|
if (head !== config.baseVersion) {
|
|
97
87
|
console.error(`O servidor está na versão ${head} e esta pasta partiu da ${config.baseVersion}. ` +
|
|
98
|
-
"Baixe as mudanças antes de enviar
|
|
88
|
+
"Baixe as mudanças antes de enviar: omni sync --pull");
|
|
99
89
|
return 1;
|
|
100
90
|
}
|
|
101
91
|
const uploads = prepare.uploads;
|
|
@@ -128,9 +118,11 @@ export async function pushSpace(root, opts) {
|
|
|
128
118
|
if (uploads.length > 0)
|
|
129
119
|
console.log(process.stdout.isTTY ? "" : ` Enviados ${done}/${uploads.length}`);
|
|
130
120
|
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 });
|
|
121
|
+
const commit = await callApi(config.apiUrl, token, "/api/cli/sync/commit", { spaceId, parentVersion: head, files });
|
|
132
122
|
if (!commit.ok) {
|
|
133
123
|
console.error(`Falha ao publicar a versão: ${commit.error}`);
|
|
124
|
+
if (commit.stale)
|
|
125
|
+
console.error("Rode omni sync --pull e depois envie de novo.");
|
|
134
126
|
return 1;
|
|
135
127
|
}
|
|
136
128
|
const version = commit.version;
|