omnispace 0.3.0 → 0.4.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 +57 -57
- package/dist/include.js +5 -2
- package/dist/index.js +21 -14
- package/dist/journal.js +61 -0
- package/dist/lock.js +63 -0
- package/dist/pull.js +136 -80
- package/dist/sync.js +18 -5
- package/package.json +39 -39
package/README.md
CHANGED
|
@@ -1,57 +1,57 @@
|
|
|
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`.
|
|
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
|
-
Se o mesmo arquivo mudou aqui e na outra máquina, o Omni junta sozinho quando as mudanças estão em trechos diferentes. Quando o mesmo trecho mudou dos dois lados, ele pergunta, arquivo por arquivo:
|
|
36
|
-
|
|
37
|
-
- **manter local**: fica a versão desta máquina, que sobe no próximo envio;
|
|
38
|
-
- **mesclar**: o arquivo é gravado com as duas versões entre marcadores `<<<<<<<` e `>>>>>>>` para você editar; o envio fica bloqueado até os marcadores sumirem;
|
|
39
|
-
- **backup**: entra a versão do servidor e a desta máquina fica guardada em `.omni/backups/`.
|
|
40
|
-
|
|
41
|
-
Fora de um terminal interativo, escolha para todos com `--conflito=local`, `--conflito=mesclar` ou `--conflito=backup`.
|
|
42
|
-
|
|
43
|
-
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):
|
|
44
|
-
|
|
45
|
-
```
|
|
46
|
-
npx -y omnispace@latest clone <código> "nome-da-pasta"
|
|
47
|
-
```
|
|
48
|
-
|
|
49
|
-
No Windows, se o caminho tiver espaço ou apóstrofo, entre na pasta com aspas duplas: `cd "C:\caminho\da\pasta"`.
|
|
50
|
-
|
|
51
|
-
## O que o Omni lê e envia
|
|
52
|
-
|
|
53
|
-
- 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.
|
|
54
|
-
- Nunca sobem: `.env` e variações, chaves (`.pem`, `.key`, `id_rsa`), pastas `.git`, `node_modules` e de build, imagens e mídia.
|
|
55
|
-
- Arquivos `.env` são lidos só para listar os nomes das chaves; nenhum valor é guardado ou enviado.
|
|
56
|
-
- Antes de enviar, a CLI mostra o que sobe e o que fica de fora e pede confirmação.
|
|
57
|
-
- Na pasta, o Omni grava só `.omni/space.json` (e `.omni/tmp/`
|
|
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`.
|
|
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
|
+
Se o mesmo arquivo mudou aqui e na outra máquina, o Omni junta sozinho quando as mudanças estão em trechos diferentes. Quando o mesmo trecho mudou dos dois lados, ele pergunta, arquivo por arquivo:
|
|
36
|
+
|
|
37
|
+
- **manter local**: fica a versão desta máquina, que sobe no próximo envio;
|
|
38
|
+
- **mesclar**: o arquivo é gravado com as duas versões entre marcadores `<<<<<<<` e `>>>>>>>` para você editar; o envio fica bloqueado até os marcadores sumirem;
|
|
39
|
+
- **backup**: entra a versão do servidor e a desta máquina fica guardada em `.omni/backups/`.
|
|
40
|
+
|
|
41
|
+
Fora de um terminal interativo, escolha para todos com `--conflito=local`, `--conflito=mesclar` ou `--conflito=backup`.
|
|
42
|
+
|
|
43
|
+
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):
|
|
44
|
+
|
|
45
|
+
```
|
|
46
|
+
npx -y omnispace@latest clone <código> "nome-da-pasta"
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
No Windows, se o caminho tiver espaço ou apóstrofo, entre na pasta com aspas duplas: `cd "C:\caminho\da\pasta"`.
|
|
50
|
+
|
|
51
|
+
## O que o Omni lê e envia
|
|
52
|
+
|
|
53
|
+
- 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.
|
|
54
|
+
- Nunca sobem: `.env` e variações, chaves (`.pem`, `.key`, `id_rsa`), pastas `.git`, `node_modules` e de build, imagens e mídia.
|
|
55
|
+
- Arquivos `.env` são lidos só para listar os nomes das chaves; nenhum valor é guardado ou enviado.
|
|
56
|
+
- Antes de enviar, a CLI mostra o que sobe e o que fica de fora e pede confirmação.
|
|
57
|
+
- Na pasta, o Omni grava só `.omni/space.json` (e, só enquanto um sync roda, `.omni/lock`, `.omni/tmp/` e `.omni/pull-pending.json`, apagados no fim; `.omni/backups/` e `.omni/conflicts.json` quando há conflito). Se um sync for interrompido, rodar o mesmo comando de novo continua de onde parou. Nada disso sincroniza. O token de acesso fica em `~/.omni/credentials.json`, fora de qualquer projeto.
|
package/dist/include.js
CHANGED
|
@@ -11,8 +11,11 @@ export const SKIP_DIRS = new Set([
|
|
|
11
11
|
]);
|
|
12
12
|
// Local state of the Omni link itself: machine specific, never part of the space. `.omni/tmp/`
|
|
13
13
|
// holds downloads of an unfinished pull (fatia 3); `.omni/conflicts.json` and `.omni/backups/`
|
|
14
|
-
// are the conflict resolution of this machine (fatia 4).
|
|
15
|
-
|
|
14
|
+
// are the conflict resolution of this machine (fatia 4); `.omni/pull-pending.json` and
|
|
15
|
+
// `.omni/lock` keep an interrupted pull resumable and two runs from overlapping (fatia 5).
|
|
16
|
+
const LOCAL_ONLY = new Set([
|
|
17
|
+
".omni/space.json", ".omni/conflicts.json", ".omni/pull-pending.json", ".omni/pull-pending.json.part", ".omni/lock",
|
|
18
|
+
]);
|
|
16
19
|
export const LOCAL_TMP_DIR = ".omni/tmp";
|
|
17
20
|
export const LOCAL_BACKUPS_DIR = ".omni/backups";
|
|
18
21
|
function isLocalOnly(path) {
|
package/dist/index.js
CHANGED
|
@@ -9,19 +9,20 @@ import { pushSpace } from "./sync.js";
|
|
|
9
9
|
import { cloneSpace, CONFLICT_CHOICES, pullSpace } from "./pull.js";
|
|
10
10
|
import { buildManifest } from "./include.js";
|
|
11
11
|
import { printSyncPreview } from "./report.js";
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
omni init [pasta] --
|
|
18
|
-
omni
|
|
19
|
-
omni sync [pasta] --
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
12
|
+
import { LockedError } from "./lock.js";
|
|
13
|
+
const USAGE = `
|
|
14
|
+
Omni CLI
|
|
15
|
+
|
|
16
|
+
Uso:
|
|
17
|
+
omni init [pasta] --scan-only varre a pasta e mostra o que encontrou
|
|
18
|
+
omni init [pasta] --link <código> varre a pasta e envia pro código mostrado no navegador
|
|
19
|
+
omni sync [pasta] --push [--yes] publica uma nova versão do espaço no servidor
|
|
20
|
+
omni sync [pasta] --pull traz para a pasta a versão mais nova do servidor
|
|
21
|
+
[--conflito=local|mesclar|backup] mesma resposta para todos os conflitos, sem perguntar
|
|
22
|
+
omni clone <código> <pasta> baixa um espaço da sua conta numa pasta nova desta máquina
|
|
23
|
+
|
|
24
|
+
A CLI ainda não reorganiza a sua pasta. O que fazer com o que foi varrido
|
|
25
|
+
é decidido na tela de revisão, no navegador.
|
|
25
26
|
`;
|
|
26
27
|
async function main(argv) {
|
|
27
28
|
const args = argv.slice(2);
|
|
@@ -112,4 +113,10 @@ async function runClone(rest) {
|
|
|
112
113
|
}
|
|
113
114
|
return cloneSpace(code, folder);
|
|
114
115
|
}
|
|
115
|
-
process.exitCode = await main(process.argv)
|
|
116
|
+
process.exitCode = await main(process.argv).catch((err) => {
|
|
117
|
+
if (err instanceof LockedError) {
|
|
118
|
+
console.error(err.message);
|
|
119
|
+
return 1;
|
|
120
|
+
}
|
|
121
|
+
throw err;
|
|
122
|
+
});
|
package/dist/journal.js
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { copyFile, mkdir, readFile, rename, rm, rmdir, writeFile } from "node:fs/promises";
|
|
2
|
+
import { dirname, join } from "node:path";
|
|
3
|
+
import { LOCAL_TMP_DIR } from "./include.js";
|
|
4
|
+
function journalPath(root) {
|
|
5
|
+
return join(root, ".omni", "pull-pending.json");
|
|
6
|
+
}
|
|
7
|
+
function toAbs(root, path) {
|
|
8
|
+
return join(root, ...path.split("/"));
|
|
9
|
+
}
|
|
10
|
+
export async function readJournal(root) {
|
|
11
|
+
try {
|
|
12
|
+
const journal = JSON.parse(await readFile(journalPath(root), "utf8"));
|
|
13
|
+
return Array.isArray(journal?.ops) && typeof journal.head === "number" ? journal : null;
|
|
14
|
+
}
|
|
15
|
+
catch {
|
|
16
|
+
return null;
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
export async function writeJournal(root, journal) {
|
|
20
|
+
await mkdir(join(root, ".omni"), { recursive: true });
|
|
21
|
+
const part = `${journalPath(root)}.part`;
|
|
22
|
+
await writeFile(part, JSON.stringify(journal), "utf8");
|
|
23
|
+
// Rename is atomic: a journal on disk is always complete.
|
|
24
|
+
await rename(part, journalPath(root));
|
|
25
|
+
}
|
|
26
|
+
export async function clearJournal(root) {
|
|
27
|
+
await rm(journalPath(root), { force: true });
|
|
28
|
+
}
|
|
29
|
+
/** Removes the folders a deletion left empty, stopping at the space root. */
|
|
30
|
+
export async function pruneEmptyDirs(root, path) {
|
|
31
|
+
const parts = path.split("/").slice(0, -1);
|
|
32
|
+
while (parts.length > 0) {
|
|
33
|
+
try {
|
|
34
|
+
await rmdir(toAbs(root, parts.join("/")));
|
|
35
|
+
}
|
|
36
|
+
catch {
|
|
37
|
+
return; // not empty (or already gone)
|
|
38
|
+
}
|
|
39
|
+
parts.pop();
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Carries out every op. `afterOp` exists for tests (simulating a kill after N ops). Throws on the
|
|
44
|
+
* first file that cannot be written; whatever was done stays done and the journal stays on disk.
|
|
45
|
+
*/
|
|
46
|
+
export async function applyJournal(root, journal, afterOp) {
|
|
47
|
+
const tmpDir = toAbs(root, LOCAL_TMP_DIR);
|
|
48
|
+
for (let i = 0; i < journal.ops.length; i++) {
|
|
49
|
+
const op = journal.ops[i];
|
|
50
|
+
const abs = toAbs(root, op.path);
|
|
51
|
+
if ("remove" in op) {
|
|
52
|
+
await rm(abs, { force: true });
|
|
53
|
+
await pruneEmptyDirs(root, op.path);
|
|
54
|
+
}
|
|
55
|
+
else {
|
|
56
|
+
await mkdir(dirname(abs), { recursive: true });
|
|
57
|
+
await copyFile(join(tmpDir, op.hash), abs);
|
|
58
|
+
}
|
|
59
|
+
afterOp?.(i);
|
|
60
|
+
}
|
|
61
|
+
}
|
package/dist/lock.js
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { readFile, rm, writeFile } from "node:fs/promises";
|
|
2
|
+
import { hostname } from "node:os";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
const held = new Set();
|
|
5
|
+
function lockPath(root) {
|
|
6
|
+
return join(root, ".omni", "lock");
|
|
7
|
+
}
|
|
8
|
+
function isAlive(pid) {
|
|
9
|
+
try {
|
|
10
|
+
process.kill(pid, 0);
|
|
11
|
+
return true;
|
|
12
|
+
}
|
|
13
|
+
catch (err) {
|
|
14
|
+
// EPERM: the process exists but belongs to someone else.
|
|
15
|
+
return err.code === "EPERM";
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
async function readLock(root) {
|
|
19
|
+
try {
|
|
20
|
+
const info = JSON.parse(await readFile(lockPath(root), "utf8"));
|
|
21
|
+
return typeof info?.pid === "number" ? info : null;
|
|
22
|
+
}
|
|
23
|
+
catch {
|
|
24
|
+
return null;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
export class LockedError extends Error {
|
|
28
|
+
}
|
|
29
|
+
/** Runs `fn` holding the folder lock. Reentrant within one process (pull offering a push). */
|
|
30
|
+
export async function withLock(root, fn) {
|
|
31
|
+
const key = root.toLowerCase();
|
|
32
|
+
if (held.has(key))
|
|
33
|
+
return fn();
|
|
34
|
+
const info = { pid: process.pid, host: hostname(), startedAt: new Date().toISOString() };
|
|
35
|
+
for (let attempt = 0;; attempt++) {
|
|
36
|
+
try {
|
|
37
|
+
await writeFile(lockPath(root), JSON.stringify(info), { flag: "wx" });
|
|
38
|
+
break;
|
|
39
|
+
}
|
|
40
|
+
catch (err) {
|
|
41
|
+
// No .omni/ at all: not a space, nothing to protect (fn itself reports it). Never create it here.
|
|
42
|
+
if (err.code === "ENOENT")
|
|
43
|
+
return fn();
|
|
44
|
+
if (err.code !== "EEXIST" || attempt > 0)
|
|
45
|
+
throw err;
|
|
46
|
+
const current = await readLock(root);
|
|
47
|
+
// A lock from another host (a synced or shared folder) cannot be checked: trust it.
|
|
48
|
+
if (current && (current.host !== info.host || isAlive(current.pid))) {
|
|
49
|
+
throw new LockedError(`Outro omni sync está rodando nesta pasta (processo ${current.pid}, desde ${current.startedAt}). ` +
|
|
50
|
+
"Espere ele terminar e rode de novo. Se nenhum estiver rodando, apague .omni/lock.");
|
|
51
|
+
}
|
|
52
|
+
await rm(lockPath(root), { force: true }); // stale: its process is gone
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
held.add(key);
|
|
56
|
+
try {
|
|
57
|
+
return await fn();
|
|
58
|
+
}
|
|
59
|
+
finally {
|
|
60
|
+
held.delete(key);
|
|
61
|
+
await rm(lockPath(root), { force: true });
|
|
62
|
+
}
|
|
63
|
+
}
|
package/dist/pull.js
CHANGED
|
@@ -7,6 +7,8 @@ import { callApi } from "./api.js";
|
|
|
7
7
|
import { buildManifest, hashFile, LOCAL_BACKUPS_DIR, LOCAL_TMP_DIR } from "./include.js";
|
|
8
8
|
import { mergeText } from "./merge.js";
|
|
9
9
|
import { addConflicts } from "./conflicts.js";
|
|
10
|
+
import { applyJournal, clearJournal, readJournal, writeJournal } from "./journal.js";
|
|
11
|
+
import { withLock } from "./lock.js";
|
|
10
12
|
import { pushSpace } from "./sync.js";
|
|
11
13
|
import { defaultApiUrl, loadToken, machineHostname, readSpaceConfig, saveToken, writeSpaceConfig, } from "./config.js";
|
|
12
14
|
/**
|
|
@@ -108,24 +110,15 @@ export async function download(url, hash, tmpDir, retryDelaysMs = [1000, 3000, 8
|
|
|
108
110
|
await writeFile(part, bytes);
|
|
109
111
|
await rename(part, join(tmpDir, hash));
|
|
110
112
|
}
|
|
111
|
-
|
|
112
|
-
async function pruneEmptyDirs(root, path) {
|
|
113
|
-
const parts = path.split("/").slice(0, -1);
|
|
114
|
-
while (parts.length > 0) {
|
|
115
|
-
try {
|
|
116
|
-
await rmdir(toAbs(root, parts.join("/")));
|
|
117
|
-
}
|
|
118
|
-
catch {
|
|
119
|
-
return; // not empty (or already gone)
|
|
120
|
-
}
|
|
121
|
-
parts.pop();
|
|
122
|
-
}
|
|
123
|
-
}
|
|
124
|
-
function listSome(paths, max = 20) {
|
|
113
|
+
function listSome(paths, max = 20, out = console.error) {
|
|
125
114
|
for (const path of paths.slice(0, max))
|
|
126
|
-
|
|
115
|
+
out(` ${path}`);
|
|
127
116
|
if (paths.length > max)
|
|
128
|
-
|
|
117
|
+
out(` ... e mais ${paths.length - max}`);
|
|
118
|
+
}
|
|
119
|
+
/** Local changes not on the server yet, one per line, marking the ones deleted here. */
|
|
120
|
+
function listPending(pending) {
|
|
121
|
+
listSome(pending.map((p) => (p.deletedHere ? ` ${p.path} (apagado aqui)` : ` ${p.path}`)), 20, console.log);
|
|
129
122
|
}
|
|
130
123
|
export const CONFLICT_CHOICES = ["local", "mesclar", "backup"];
|
|
131
124
|
/** The question for each kind of conflict, with one label per choice (local, mesclar, backup). */
|
|
@@ -225,27 +218,96 @@ function backupStamp() {
|
|
|
225
218
|
/**
|
|
226
219
|
* Brings the folder to the server's head (Proof of Core Sync, fatias 3 e 4). Order matters for
|
|
227
220
|
* interruptions: every object is downloaded and verified into .omni/tmp first, every conflict is
|
|
228
|
-
* decided before anything is written, and
|
|
229
|
-
*
|
|
221
|
+
* decided before anything is written, and the writes themselves go through a journal (journal.ts):
|
|
222
|
+
* a run killed while writing finishes them on the next run. Only at the very end .omni/space.json
|
|
223
|
+
* gets the new base. One run per folder at a time (lock.ts).
|
|
230
224
|
*
|
|
231
225
|
* Files changed on both sides are merged line by line against the base; what still collides is
|
|
232
226
|
* settled with one of the three options of ADR 002 (manter local, mesclar, backup), asked per file
|
|
233
227
|
* in a terminal or given once with --conflito.
|
|
234
228
|
*/
|
|
235
229
|
async function pullInto(root, config, token, opts = {}) {
|
|
236
|
-
|
|
230
|
+
return withLock(root, async () => {
|
|
231
|
+
const asker = makeAsker();
|
|
232
|
+
try {
|
|
233
|
+
return await pullWith(root, config, token, opts, asker);
|
|
234
|
+
}
|
|
235
|
+
finally {
|
|
236
|
+
asker.close();
|
|
237
|
+
}
|
|
238
|
+
});
|
|
239
|
+
}
|
|
240
|
+
/** Tells the server this folder is on `version` (what the /home machine panel shows). */
|
|
241
|
+
async function ack(config, token, version) {
|
|
242
|
+
const res = await callApi(config.apiUrl, token, "/api/cli/sync/ack", { spaceId: config.spaceId, version });
|
|
243
|
+
if (!res.ok)
|
|
244
|
+
console.error(`(aviso) A pasta está na versão ${version}, mas o servidor não registrou: ${res.error}`);
|
|
245
|
+
}
|
|
246
|
+
/**
|
|
247
|
+
* Carries out a journal and closes the pull: new base in .omni/space.json, journal and .omni/tmp
|
|
248
|
+
* removed, server told. Used right after planning and, after an interruption, on the next run.
|
|
249
|
+
*/
|
|
250
|
+
async function finishPull(root, config, token, journal, opts, asker) {
|
|
251
|
+
const tmpDir = toAbs(root, LOCAL_TMP_DIR);
|
|
252
|
+
const crashAfter = Number(process.env.OMNI_TEST_CRASH_AFTER_OPS); // tests only: simulated kill
|
|
237
253
|
try {
|
|
238
|
-
|
|
254
|
+
await applyJournal(root, journal, (i) => {
|
|
255
|
+
if (i + 1 === crashAfter)
|
|
256
|
+
process.exit(137);
|
|
257
|
+
});
|
|
258
|
+
}
|
|
259
|
+
catch (err) {
|
|
260
|
+
console.error(`\nNão deu para gravar um arquivo: ${err.message}`);
|
|
261
|
+
console.error("Se ele estiver aberto em outro programa, feche e rode de novo: o pull continua de onde parou.");
|
|
262
|
+
return 1;
|
|
239
263
|
}
|
|
240
|
-
|
|
241
|
-
|
|
264
|
+
// Again after the writes: a push run during the interruption may have dropped a path from the
|
|
265
|
+
// list because its markers were not on disk yet.
|
|
266
|
+
await addConflicts(root, journal.marked);
|
|
267
|
+
await writeSpaceConfig(root, { ...config, baseVersion: journal.head });
|
|
268
|
+
await clearJournal(root);
|
|
269
|
+
await rm(tmpDir, { recursive: true, force: true });
|
|
270
|
+
await ack(config, token, journal.head);
|
|
271
|
+
console.log(`✓ Pasta na versão ${journal.head}: ${journal.written} arquivo(s) gravado(s), ${journal.deleted} apagado(s).`);
|
|
272
|
+
if (journal.autoMerged > 0)
|
|
273
|
+
console.log(` ${journal.autoMerged} arquivo(s) mudaram dos dois lados e foram juntados sozinhos.`);
|
|
274
|
+
if (journal.backupDir) {
|
|
275
|
+
console.log(` Versão desta máquina guardada em ${journal.backupDir} (${journal.backedUp} arquivo(s)).`);
|
|
276
|
+
}
|
|
277
|
+
if (journal.marked.length > 0) {
|
|
278
|
+
console.log(" Arquivos com marcadores para editar à mão (o envio fica bloqueado até eles sumirem):");
|
|
279
|
+
for (const path of journal.marked)
|
|
280
|
+
console.log(` ${path}`);
|
|
281
|
+
}
|
|
282
|
+
if (journal.pendingPush.length > 0 && journal.marked.length === 0) {
|
|
283
|
+
console.log(` ${journal.pendingPush.length} mudança(s) desta máquina ainda não estão no servidor:`);
|
|
284
|
+
listPending(journal.pendingPush);
|
|
285
|
+
if (process.stdin.isTTY && !opts.conflito) {
|
|
286
|
+
const answer = await asker.ask("Enviar agora? (s/n) ");
|
|
287
|
+
if (answer === "s" || answer === "sim")
|
|
288
|
+
return pushSpace(root, { yes: true });
|
|
289
|
+
}
|
|
290
|
+
console.log(" Envie com omni sync --push.");
|
|
242
291
|
}
|
|
292
|
+
console.log("");
|
|
293
|
+
return 0;
|
|
243
294
|
}
|
|
244
295
|
async function pullWith(root, config, token, opts, asker) {
|
|
245
296
|
if (!config.spaceId) {
|
|
246
297
|
console.error("Esta pasta ainda não tem espaço publicado. Termine a conta no navegador e rode omni sync --push.");
|
|
247
298
|
return 1;
|
|
248
299
|
}
|
|
300
|
+
// A pull that stopped while writing files: finish exactly what it had decided, nothing new.
|
|
301
|
+
const pending = await readJournal(root);
|
|
302
|
+
if (pending) {
|
|
303
|
+
const staged = await cachedObjects(toAbs(root, LOCAL_TMP_DIR));
|
|
304
|
+
if (pending.ops.every((op) => "remove" in op || staged.has(op.hash))) {
|
|
305
|
+
console.log(`\n→ Terminando de aplicar a versão ${pending.head} (um pull anterior parou no meio)...`);
|
|
306
|
+
return finishPull(root, config, token, pending, opts, asker);
|
|
307
|
+
}
|
|
308
|
+
// Staged content gone (someone cleaned .omni/tmp): plan again from scratch, the base is unchanged.
|
|
309
|
+
await clearJournal(root);
|
|
310
|
+
}
|
|
249
311
|
console.log("\n→ Lendo a pasta...");
|
|
250
312
|
const manifest = await buildManifest(root);
|
|
251
313
|
if (manifest.truncated) {
|
|
@@ -275,8 +337,13 @@ async function pullWith(root, config, token, opts, asker) {
|
|
|
275
337
|
else
|
|
276
338
|
console.log(`✓ Esta pasta já está na versão ${head}, a mais nova do servidor.\n`);
|
|
277
339
|
if (plan.localOnly.length > 0) {
|
|
278
|
-
console.log(` ${plan.localOnly.length} mudança(s) local(is) ainda não enviada(s)
|
|
340
|
+
console.log(` ${plan.localOnly.length} mudança(s) local(is) ainda não enviada(s):`);
|
|
341
|
+
listPending(plan.localOnly.map((path) => ({ path, deletedHere: !local.has(path) })));
|
|
342
|
+
console.log(" Envie com omni sync --push.\n");
|
|
279
343
|
}
|
|
344
|
+
// A run cut off between writing the base and telling the server leaves the server behind.
|
|
345
|
+
if (head > 0 && pull.synced !== head)
|
|
346
|
+
await ack(config, token, head);
|
|
280
347
|
return 0;
|
|
281
348
|
}
|
|
282
349
|
// Every object needed ends up in .omni/tmp: the head content of what gets written, plus base and
|
|
@@ -366,75 +433,64 @@ async function pullWith(root, config, token, opts, asker) {
|
|
|
366
433
|
resolutions.push(resolveConflict(conflict, choice));
|
|
367
434
|
}
|
|
368
435
|
}
|
|
436
|
+
// Everything is decided. Before touching any file of the space: take the backups, stage the final
|
|
437
|
+
// content of every path in .omni/tmp (by hash) and write the journal. From here on a kill only
|
|
438
|
+
// means the next run replays the journal.
|
|
439
|
+
const journal = await buildJournal(root, head, plan, resolutions, local, autoMerged);
|
|
440
|
+
if (!journal)
|
|
441
|
+
return 1;
|
|
442
|
+
await writeJournal(root, journal);
|
|
443
|
+
await addConflicts(root, journal.marked);
|
|
444
|
+
return finishPull(root, config, token, journal, opts, asker);
|
|
445
|
+
}
|
|
446
|
+
async function buildJournal(root, head, plan, resolutions, local, autoMerged) {
|
|
447
|
+
const tmpDir = toAbs(root, LOCAL_TMP_DIR);
|
|
448
|
+
const ops = [...plan.writes, ...plan.deletes.map((path) => ({ path, remove: true }))];
|
|
369
449
|
const stamp = backupStamp();
|
|
370
|
-
|
|
450
|
+
let backedUp = 0;
|
|
371
451
|
try {
|
|
372
|
-
for (const { path, hash } of plan.writes) {
|
|
373
|
-
const abs = toAbs(root, path);
|
|
374
|
-
await mkdir(dirname(abs), { recursive: true });
|
|
375
|
-
await copyFile(join(tmpDir, hash), abs);
|
|
376
|
-
}
|
|
377
|
-
for (const path of plan.deletes) {
|
|
378
|
-
await rm(toAbs(root, path), { force: true });
|
|
379
|
-
await pruneEmptyDirs(root, path);
|
|
380
|
-
}
|
|
381
452
|
for (const r of resolutions) {
|
|
382
|
-
const abs = toAbs(root, r.path);
|
|
383
453
|
if (r.backup) {
|
|
384
454
|
const copy = toAbs(root, `${LOCAL_BACKUPS_DIR}/${stamp}/${r.path}`);
|
|
385
455
|
await mkdir(dirname(copy), { recursive: true });
|
|
386
|
-
await copyFile(
|
|
387
|
-
backedUp
|
|
388
|
-
}
|
|
389
|
-
if (r.remove) {
|
|
390
|
-
await rm(abs, { force: true });
|
|
391
|
-
await pruneEmptyDirs(root, r.path);
|
|
392
|
-
}
|
|
393
|
-
else if (r.hash) {
|
|
394
|
-
await mkdir(dirname(abs), { recursive: true });
|
|
395
|
-
await copyFile(join(tmpDir, r.hash), abs);
|
|
456
|
+
await copyFile(toAbs(root, r.path), copy);
|
|
457
|
+
backedUp++;
|
|
396
458
|
}
|
|
459
|
+
if (r.remove)
|
|
460
|
+
ops.push({ path: r.path, remove: true });
|
|
461
|
+
else if (r.hash)
|
|
462
|
+
ops.push({ path: r.path, hash: r.hash });
|
|
397
463
|
else if (r.text !== undefined) {
|
|
398
|
-
|
|
399
|
-
|
|
464
|
+
const bytes = Buffer.from(r.text, "utf8");
|
|
465
|
+
const hash = sha256(bytes);
|
|
466
|
+
await writeFile(join(tmpDir, hash), bytes);
|
|
467
|
+
ops.push({ path: r.path, hash });
|
|
400
468
|
}
|
|
401
469
|
}
|
|
402
470
|
}
|
|
403
471
|
catch (err) {
|
|
404
|
-
console.error(`\nNão deu para
|
|
405
|
-
console.error("
|
|
406
|
-
return
|
|
407
|
-
}
|
|
408
|
-
const
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
const
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
}
|
|
426
|
-
const pendingPush = plan.localOnly.length + resolutions.filter((r) => r.pendingPush && !r.markers).length;
|
|
427
|
-
if (pendingPush > 0 && marked.length === 0) {
|
|
428
|
-
console.log(` ${pendingPush} mudança(s) desta máquina ainda não estão no servidor.`);
|
|
429
|
-
if (process.stdin.isTTY && !opts.conflito) {
|
|
430
|
-
const answer = await asker.ask("Enviar agora? (s/n) ");
|
|
431
|
-
if (answer === "s" || answer === "sim")
|
|
432
|
-
return pushSpace(root, { yes: true });
|
|
433
|
-
}
|
|
434
|
-
console.log(" Envie com omni sync --push.");
|
|
435
|
-
}
|
|
436
|
-
console.log("");
|
|
437
|
-
return 0;
|
|
472
|
+
console.error(`\nNão deu para preparar a gravação: ${err.message}`);
|
|
473
|
+
console.error("Nada foi alterado na pasta. Rode de novo.");
|
|
474
|
+
return null;
|
|
475
|
+
}
|
|
476
|
+
const afterPull = new Map(local);
|
|
477
|
+
for (const r of resolutions)
|
|
478
|
+
if (r.remove)
|
|
479
|
+
afterPull.delete(r.path);
|
|
480
|
+
const pendingPush = [...plan.localOnly, ...resolutions.filter((r) => r.pendingPush && !r.markers).map((r) => r.path)]
|
|
481
|
+
.sort()
|
|
482
|
+
.map((path) => ({ path, deletedHere: !afterPull.has(path) }));
|
|
483
|
+
return {
|
|
484
|
+
head,
|
|
485
|
+
ops,
|
|
486
|
+
written: ops.filter((op) => !("remove" in op)).length,
|
|
487
|
+
deleted: ops.filter((op) => "remove" in op).length,
|
|
488
|
+
autoMerged,
|
|
489
|
+
marked: resolutions.filter((r) => r.markers).map((r) => r.path),
|
|
490
|
+
backupDir: backedUp > 0 ? `${LOCAL_BACKUPS_DIR}/${stamp}/` : null,
|
|
491
|
+
backedUp,
|
|
492
|
+
pendingPush,
|
|
493
|
+
};
|
|
438
494
|
}
|
|
439
495
|
/** `omni sync --pull` in a folder that is already a space. */
|
|
440
496
|
export async function pullSpace(root, opts = {}) {
|
package/dist/sync.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
1
2
|
import { readFile } from "node:fs/promises";
|
|
2
3
|
import { createInterface } from "node:readline/promises";
|
|
3
4
|
import { buildManifest, MAX_SPACE_BYTES } from "./include.js";
|
|
@@ -5,6 +6,7 @@ import { humanSize, printSyncPreview } from "./report.js";
|
|
|
5
6
|
import { loadToken, readSpaceConfig, writeSpaceConfig } from "./config.js";
|
|
6
7
|
import { callApi } from "./api.js";
|
|
7
8
|
import { unresolvedConflicts } from "./conflicts.js";
|
|
9
|
+
import { withLock } from "./lock.js";
|
|
8
10
|
async function confirm(question) {
|
|
9
11
|
if (!process.stdin.isTTY)
|
|
10
12
|
return false;
|
|
@@ -13,9 +15,16 @@ async function confirm(question) {
|
|
|
13
15
|
rl.close();
|
|
14
16
|
return answer === "s" || answer === "sim";
|
|
15
17
|
}
|
|
16
|
-
/**
|
|
17
|
-
|
|
18
|
+
/**
|
|
19
|
+
* Uploads one object straight to Storage through the signed URL (never through the Omni server).
|
|
20
|
+
* The bytes are hashed again right before sending: a file edited after the manifest was built would
|
|
21
|
+
* otherwise land under the old hash, and every later version would reuse that wrong object.
|
|
22
|
+
*/
|
|
23
|
+
export async function uploadObject(url, abs, path, hash) {
|
|
18
24
|
const bytes = await readFile(abs);
|
|
25
|
+
if (createHash("sha256").update(bytes).digest("hex") !== hash) {
|
|
26
|
+
throw new Error(`o arquivo ${path} mudou durante o envio`);
|
|
27
|
+
}
|
|
19
28
|
const response = await fetch(url, {
|
|
20
29
|
method: "PUT",
|
|
21
30
|
headers: { "content-type": "application/octet-stream", "x-upsert": "true" },
|
|
@@ -31,6 +40,9 @@ async function uploadObject(url, abs) {
|
|
|
31
40
|
* the version only exists once all of them are confirmed.
|
|
32
41
|
*/
|
|
33
42
|
export async function pushSpace(root, opts) {
|
|
43
|
+
return withLock(root, () => pushLocked(root, opts));
|
|
44
|
+
}
|
|
45
|
+
async function pushLocked(root, opts) {
|
|
34
46
|
const config = await readSpaceConfig(root);
|
|
35
47
|
if (!config) {
|
|
36
48
|
console.error("Esta pasta ainda não está vinculada. Rode omni init --link <código> antes.");
|
|
@@ -98,8 +110,8 @@ export async function pushSpace(root, opts) {
|
|
|
98
110
|
return 1;
|
|
99
111
|
}
|
|
100
112
|
const uploads = prepare.uploads;
|
|
101
|
-
const
|
|
102
|
-
const sizeToSend = uploads.reduce((sum, u) => sum + (
|
|
113
|
+
const fileByHash = new Map(manifest.files.map((f) => [f.hash, f]));
|
|
114
|
+
const sizeToSend = uploads.reduce((sum, u) => sum + (fileByHash.get(u.hash)?.size ?? 0), 0);
|
|
103
115
|
if (!opts.yes) {
|
|
104
116
|
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) `);
|
|
105
117
|
if (!ok) {
|
|
@@ -111,7 +123,8 @@ export async function pushSpace(root, opts) {
|
|
|
111
123
|
const queue = [...uploads];
|
|
112
124
|
async function worker() {
|
|
113
125
|
for (let next = queue.shift(); next; next = queue.shift()) {
|
|
114
|
-
|
|
126
|
+
const file = fileByHash.get(next.hash);
|
|
127
|
+
await uploadObject(next.url, file.abs, file.path, next.hash);
|
|
115
128
|
done++;
|
|
116
129
|
if (process.stdout.isTTY)
|
|
117
130
|
process.stdout.write(`\r Enviados ${done}/${uploads.length}`);
|
package/package.json
CHANGED
|
@@ -1,39 +1,39 @@
|
|
|
1
|
-
{
|
|
2
|
-
"name": "omnispace",
|
|
3
|
-
"version": "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
|
-
}
|
|
1
|
+
{
|
|
2
|
+
"name": "omnispace",
|
|
3
|
+
"version": "0.4.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
|
+
}
|