omnispace 0.3.1 → 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 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/` durante um download, apagada no fim; `.omni/backups/` e `.omni/conflicts.json` quando há conflito). Nada disso sincroniza. O token de acesso fica em `~/.omni/credentials.json`, fora de qualquer projeto.
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
- const LOCAL_ONLY = new Set([".omni/space.json", ".omni/conflicts.json"]);
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
- 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
- [--conflito=local|mesclar|backup] mesma resposta para todos os conflitos, sem perguntar
21
- omni clone <código> <pasta> baixa um espaço da sua conta numa pasta nova desta máquina
22
-
23
- A CLI ainda não reorganiza a sua pasta. O que fazer com o que foi varrido
24
- é decidido na tela de revisão, no navegador.
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
+ });
@@ -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,19 +110,6 @@ 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
- /** Removes the folders a deletion left empty, stopping at the space root. */
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
113
  function listSome(paths, max = 20, out = console.error) {
125
114
  for (const path of paths.slice(0, max))
126
115
  out(` ${path}`);
@@ -128,17 +117,10 @@ function listSome(paths, max = 20, out = console.error) {
128
117
  out(` ... e mais ${paths.length - max}`);
129
118
  }
130
119
  /** Local changes not on the server yet, one per line, marking the ones deleted here. */
131
- function listPending(paths, local) {
132
- listSome(paths.map((p) => (local.has(p) ? ` ${p}` : ` ${p} (apagado aqui)`)), 20, console.log);
120
+ function listPending(pending) {
121
+ listSome(pending.map((p) => (p.deletedHere ? ` ${p.path} (apagado aqui)` : ` ${p.path}`)), 20, console.log);
133
122
  }
134
123
  export const CONFLICT_CHOICES = ["local", "mesclar", "backup"];
135
- /** Files a pull wrote or removed, counting the ones settled by merge, backup or a conflict choice. */
136
- export function countApplied(plan, resolutions) {
137
- return {
138
- written: plan.writes.length + resolutions.filter((r) => !r.remove && (r.hash !== undefined || r.text !== undefined)).length,
139
- deleted: plan.deletes.length + resolutions.filter((r) => r.remove).length,
140
- };
141
- }
142
124
  /** The question for each kind of conflict, with one label per choice (local, mesclar, backup). */
143
125
  function promptFor(conflict) {
144
126
  switch (conflict.kind) {
@@ -236,27 +218,96 @@ function backupStamp() {
236
218
  /**
237
219
  * Brings the folder to the server's head (Proof of Core Sync, fatias 3 e 4). Order matters for
238
220
  * interruptions: every object is downloaded and verified into .omni/tmp first, every conflict is
239
- * decided before anything is written, and only at the very end .omni/space.json gets the new base.
240
- * A run killed anywhere before that simply runs again.
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).
241
224
  *
242
225
  * Files changed on both sides are merged line by line against the base; what still collides is
243
226
  * settled with one of the three options of ADR 002 (manter local, mesclar, backup), asked per file
244
227
  * in a terminal or given once with --conflito.
245
228
  */
246
229
  async function pullInto(root, config, token, opts = {}) {
247
- const asker = makeAsker();
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
248
253
  try {
249
- return await pullWith(root, config, token, opts, asker);
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;
263
+ }
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}`);
250
281
  }
251
- finally {
252
- asker.close();
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.");
253
291
  }
292
+ console.log("");
293
+ return 0;
254
294
  }
255
295
  async function pullWith(root, config, token, opts, asker) {
256
296
  if (!config.spaceId) {
257
297
  console.error("Esta pasta ainda não tem espaço publicado. Termine a conta no navegador e rode omni sync --push.");
258
298
  return 1;
259
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
+ }
260
311
  console.log("\n→ Lendo a pasta...");
261
312
  const manifest = await buildManifest(root);
262
313
  if (manifest.truncated) {
@@ -287,9 +338,12 @@ async function pullWith(root, config, token, opts, asker) {
287
338
  console.log(`✓ Esta pasta já está na versão ${head}, a mais nova do servidor.\n`);
288
339
  if (plan.localOnly.length > 0) {
289
340
  console.log(` ${plan.localOnly.length} mudança(s) local(is) ainda não enviada(s):`);
290
- listPending(plan.localOnly, local);
341
+ listPending(plan.localOnly.map((path) => ({ path, deletedHere: !local.has(path) })));
291
342
  console.log(" Envie com omni sync --push.\n");
292
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);
293
347
  return 0;
294
348
  }
295
349
  // Every object needed ends up in .omni/tmp: the head content of what gets written, plus base and
@@ -379,84 +433,64 @@ async function pullWith(root, config, token, opts, asker) {
379
433
  resolutions.push(resolveConflict(conflict, choice));
380
434
  }
381
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 }))];
382
449
  const stamp = backupStamp();
383
- const backedUp = [];
450
+ let backedUp = 0;
384
451
  try {
385
- for (const { path, hash } of plan.writes) {
386
- const abs = toAbs(root, path);
387
- await mkdir(dirname(abs), { recursive: true });
388
- await copyFile(join(tmpDir, hash), abs);
389
- }
390
- for (const path of plan.deletes) {
391
- await rm(toAbs(root, path), { force: true });
392
- await pruneEmptyDirs(root, path);
393
- }
394
452
  for (const r of resolutions) {
395
- const abs = toAbs(root, r.path);
396
453
  if (r.backup) {
397
454
  const copy = toAbs(root, `${LOCAL_BACKUPS_DIR}/${stamp}/${r.path}`);
398
455
  await mkdir(dirname(copy), { recursive: true });
399
- await copyFile(abs, copy);
400
- backedUp.push(r.path);
401
- }
402
- if (r.remove) {
403
- await rm(abs, { force: true });
404
- await pruneEmptyDirs(root, r.path);
405
- }
406
- else if (r.hash) {
407
- await mkdir(dirname(abs), { recursive: true });
408
- await copyFile(join(tmpDir, r.hash), abs);
456
+ await copyFile(toAbs(root, r.path), copy);
457
+ backedUp++;
409
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 });
410
463
  else if (r.text !== undefined) {
411
- await mkdir(dirname(abs), { recursive: true });
412
- await writeFile(abs, r.text, "utf8");
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 });
413
468
  }
414
469
  }
415
470
  }
416
471
  catch (err) {
417
- console.error(`\nNão deu para gravar um arquivo: ${err.message}`);
418
- console.error("Se ele estiver aberto em outro programa, feche e rode de novo. O que já foi gravado não se perde.");
419
- return 1;
420
- }
421
- const marked = resolutions.filter((r) => r.markers).map((r) => r.path);
422
- await addConflicts(root, marked);
423
- await writeSpaceConfig(root, { ...config, baseVersion: head });
424
- await rm(tmpDir, { recursive: true, force: true });
425
- const ack = await callApi(config.apiUrl, token, "/api/cli/sync/ack", { spaceId: config.spaceId, version: head });
426
- if (!ack.ok)
427
- console.error(`(aviso) A pasta está na versão ${head}, mas o servidor não registrou: ${ack.error}`);
428
- const applied = countApplied(plan, resolutions);
429
- console.log(`✓ Pasta na versão ${head}: ${applied.written} arquivo(s) gravado(s), ${applied.deleted} apagado(s).`);
430
- if (autoMerged > 0)
431
- console.log(` ${autoMerged} arquivo(s) mudaram dos dois lados e foram juntados sozinhos.`);
432
- if (backedUp.length > 0) {
433
- console.log(` Versão desta máquina guardada em ${LOCAL_BACKUPS_DIR}/${stamp}/ (${backedUp.length} arquivo(s)).`);
434
- }
435
- if (marked.length > 0) {
436
- console.log(" Arquivos com marcadores para editar à mão (o envio fica bloqueado até eles sumirem):");
437
- for (const path of marked)
438
- console.log(` ${path}`);
439
- }
440
- const pendingPush = [
441
- ...plan.localOnly,
442
- ...resolutions.filter((r) => r.pendingPush && !r.markers).map((r) => r.path),
443
- ].sort();
444
- if (pendingPush.length > 0 && marked.length === 0) {
445
- const afterPull = new Map(local);
446
- for (const r of resolutions)
447
- if (r.remove)
448
- afterPull.delete(r.path);
449
- console.log(` ${pendingPush.length} mudança(s) desta máquina ainda não estão no servidor:`);
450
- listPending(pendingPush, afterPull);
451
- if (process.stdin.isTTY && !opts.conflito) {
452
- const answer = await asker.ask("Enviar agora? (s/n) ");
453
- if (answer === "s" || answer === "sim")
454
- return pushSpace(root, { yes: true });
455
- }
456
- console.log(" Envie com omni sync --push.");
457
- }
458
- console.log("");
459
- 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
+ };
460
494
  }
461
495
  /** `omni sync --pull` in a folder that is already a space. */
462
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
- /** Uploads one object straight to Storage through the signed URL (never through the Omni server). */
17
- async function uploadObject(url, abs) {
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 absByHash = new Map(manifest.files.map((f) => [f.hash, f.abs]));
102
- const sizeToSend = uploads.reduce((sum, u) => sum + (manifest.files.find((f) => f.hash === u.hash)?.size ?? 0), 0);
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
- await uploadObject(next.url, absByHash.get(next.hash));
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,6 +1,6 @@
1
1
  {
2
2
  "name": "omnispace",
3
- "version": "0.3.1",
3
+ "version": "0.4.0",
4
4
  "description": "Omni CLI (alpha): transforma uma pasta da sua máquina em um espaço Omni e sincroniza entre máquinas.",
5
5
  "type": "module",
6
6
  "bin": {