omnispace 0.3.1 → 0.5.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. Nome de arquivo que o Windows não aceita (como `CON`, `a:b.md` ou terminado em ponto) fica de fora, para o espaço abrir em qualquer sistema.
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) {
@@ -34,6 +37,19 @@ export const MAX_FILE_BYTES = 25 * 1024 * 1024;
34
37
  export const MAX_SPACE_BYTES = 100 * 1024 * 1024;
35
38
  const MAX_FILES = 20000;
36
39
  const MAX_DEPTH = 12;
40
+ const WINDOWS_RESERVED = /^(con|prn|aux|nul|com[1-9]|lpt[1-9])(\..*)?$/i;
41
+ /**
42
+ * A file or folder name every OS can create (fatia 6). Windows refuses reserved device names,
43
+ * <>:"|?* and control characters, and names ending in a dot or space; NTFS stops at 255. A name
44
+ * like that made on a Mac would break the pull on Windows, so it never goes up (the server checks
45
+ * the same rule, apps/omni/src/lib/sync-manifest.ts).
46
+ */
47
+ export function isPortableName(name) {
48
+ return (name.length <= 255 &&
49
+ !/[<>:"|?*\\\x00-\x1f]/.test(name) &&
50
+ !/[. ]$/.test(name) &&
51
+ !WINDOWS_RESERVED.test(name));
52
+ }
37
53
  /** Files that never leave the machine, with no option to turn them on (ADR 003 item 3, ADR 004). */
38
54
  export function isSecret(name) {
39
55
  const lower = name.toLowerCase();
@@ -82,6 +98,7 @@ export async function buildManifest(root) {
82
98
  const files = [];
83
99
  const excluded = [];
84
100
  const repos = [];
101
+ const unreadable = [];
85
102
  let totalBytes = 0;
86
103
  let seen = 0;
87
104
  let truncated = false;
@@ -92,7 +109,14 @@ export async function buildManifest(root) {
92
109
  truncated = true;
93
110
  return;
94
111
  }
95
- const entries = await readdir(dir, { withFileTypes: true });
112
+ let entries;
113
+ try {
114
+ entries = await readdir(dir, { withFileTypes: true });
115
+ }
116
+ catch {
117
+ unreadable.push(normalizePath(parts) || ".");
118
+ return;
119
+ }
96
120
  entries.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
97
121
  for (const entry of entries) {
98
122
  if (entry.isSymbolicLink())
@@ -130,6 +154,10 @@ export async function buildManifest(root) {
130
154
  excluded.push({ path, reason: "binário ou mídia" });
131
155
  continue;
132
156
  }
157
+ if (!relParts.every(isPortableName)) {
158
+ excluded.push({ path, reason: "nome que o Windows não aceita" });
159
+ continue;
160
+ }
133
161
  let size;
134
162
  try {
135
163
  size = (await stat(abs)).size;
@@ -141,7 +169,17 @@ export async function buildManifest(root) {
141
169
  excluded.push({ path, reason: "maior que 25 MB" });
142
170
  continue;
143
171
  }
144
- files.push({ path, hash: await hashFile(abs), size, abs });
172
+ let hash;
173
+ try {
174
+ hash = await hashFile(abs);
175
+ }
176
+ catch (err) {
177
+ if (err.code === "ENOENT")
178
+ continue; // vanished meanwhile
179
+ unreadable.push(path);
180
+ continue;
181
+ }
182
+ files.push({ path, hash, size, abs });
145
183
  totalBytes += size;
146
184
  }
147
185
  }
@@ -152,6 +190,7 @@ export async function buildManifest(root) {
152
190
  repos,
153
191
  totalBytes,
154
192
  collisions: findCaseCollisions(files.map((f) => f.path)),
193
+ unreadable,
155
194
  truncated,
156
195
  };
157
196
  }
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,81 @@
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
+ // The apply step of a pull, written down before the first file is touched (fatia 5). Every
5
+ // decision (merge, conflict choice, backup) is already made and every final content already sits in
6
+ // .omni/tmp by hash, so the journal is just "put this content at this path" or "remove this path".
7
+ // Replaying it is idempotent: a pull killed halfway through writing finishes on the next run,
8
+ // without asking again and without merging a file that already holds the merge.
9
+ /** A write or removal that failed, with the space path (not the long absolute one) and the cause. */
10
+ export class ApplyError extends Error {
11
+ path;
12
+ code;
13
+ constructor(path, code) {
14
+ super(`${path}: ${code ?? "erro"}`);
15
+ this.path = path;
16
+ this.code = code;
17
+ }
18
+ }
19
+ function journalPath(root) {
20
+ return join(root, ".omni", "pull-pending.json");
21
+ }
22
+ function toAbs(root, path) {
23
+ return join(root, ...path.split("/"));
24
+ }
25
+ export async function readJournal(root) {
26
+ try {
27
+ const journal = JSON.parse(await readFile(journalPath(root), "utf8"));
28
+ return Array.isArray(journal?.ops) && typeof journal.head === "number" ? journal : null;
29
+ }
30
+ catch {
31
+ return null;
32
+ }
33
+ }
34
+ export async function writeJournal(root, journal) {
35
+ await mkdir(join(root, ".omni"), { recursive: true });
36
+ const part = `${journalPath(root)}.part`;
37
+ await writeFile(part, JSON.stringify(journal), "utf8");
38
+ // Rename is atomic: a journal on disk is always complete.
39
+ await rename(part, journalPath(root));
40
+ }
41
+ export async function clearJournal(root) {
42
+ await rm(journalPath(root), { force: true });
43
+ }
44
+ /** Removes the folders a deletion left empty, stopping at the space root. */
45
+ export async function pruneEmptyDirs(root, path) {
46
+ const parts = path.split("/").slice(0, -1);
47
+ while (parts.length > 0) {
48
+ try {
49
+ await rmdir(toAbs(root, parts.join("/")));
50
+ }
51
+ catch {
52
+ return; // not empty (or already gone)
53
+ }
54
+ parts.pop();
55
+ }
56
+ }
57
+ /**
58
+ * Carries out every op. `afterOp` exists for tests (simulating a kill after N ops). Throws on the
59
+ * first file that cannot be written; whatever was done stays done and the journal stays on disk.
60
+ */
61
+ export async function applyJournal(root, journal, afterOp) {
62
+ const tmpDir = toAbs(root, LOCAL_TMP_DIR);
63
+ for (let i = 0; i < journal.ops.length; i++) {
64
+ const op = journal.ops[i];
65
+ const abs = toAbs(root, op.path);
66
+ try {
67
+ if ("remove" in op) {
68
+ await rm(abs, { force: true });
69
+ await pruneEmptyDirs(root, op.path);
70
+ }
71
+ else {
72
+ await mkdir(dirname(abs), { recursive: true });
73
+ await copyFile(join(tmpDir, op.hash), abs);
74
+ }
75
+ }
76
+ catch (err) {
77
+ throw new ApplyError(op.path, err.code);
78
+ }
79
+ afterOp?.(i);
80
+ }
81
+ }
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,7 +7,10 @@ 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 { ApplyError, applyJournal, clearJournal, readJournal, writeJournal } from "./journal.js";
11
+ import { withLock } from "./lock.js";
10
12
  import { pushSpace } from "./sync.js";
13
+ import { printUnreadable } from "./report.js";
11
14
  import { defaultApiUrl, loadToken, machineHostname, readSpaceConfig, saveToken, writeSpaceConfig, } from "./config.js";
12
15
  /**
13
16
  * Three-way comparison per path (ADR 002): local, base (the version this folder last synced) and
@@ -108,19 +111,6 @@ export async function download(url, hash, tmpDir, retryDelaysMs = [1000, 3000, 8
108
111
  await writeFile(part, bytes);
109
112
  await rename(part, join(tmpDir, hash));
110
113
  }
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
114
  function listSome(paths, max = 20, out = console.error) {
125
115
  for (const path of paths.slice(0, max))
126
116
  out(` ${path}`);
@@ -128,17 +118,10 @@ function listSome(paths, max = 20, out = console.error) {
128
118
  out(` ... e mais ${paths.length - max}`);
129
119
  }
130
120
  /** 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);
121
+ function listPending(pending) {
122
+ listSome(pending.map((p) => (p.deletedHere ? ` ${p.path} (apagado aqui)` : ` ${p.path}`)), 20, console.log);
133
123
  }
134
124
  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
125
  /** The question for each kind of conflict, with one label per choice (local, mesclar, backup). */
143
126
  function promptFor(conflict) {
144
127
  switch (conflict.kind) {
@@ -236,33 +219,109 @@ function backupStamp() {
236
219
  /**
237
220
  * Brings the folder to the server's head (Proof of Core Sync, fatias 3 e 4). Order matters for
238
221
  * 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.
222
+ * decided before anything is written, and the writes themselves go through a journal (journal.ts):
223
+ * a run killed while writing finishes them on the next run. Only at the very end .omni/space.json
224
+ * gets the new base. One run per folder at a time (lock.ts).
241
225
  *
242
226
  * Files changed on both sides are merged line by line against the base; what still collides is
243
227
  * settled with one of the three options of ADR 002 (manter local, mesclar, backup), asked per file
244
228
  * in a terminal or given once with --conflito.
245
229
  */
246
230
  async function pullInto(root, config, token, opts = {}) {
247
- const asker = makeAsker();
231
+ return withLock(root, async () => {
232
+ const asker = makeAsker();
233
+ try {
234
+ return await pullWith(root, config, token, opts, asker);
235
+ }
236
+ finally {
237
+ asker.close();
238
+ }
239
+ });
240
+ }
241
+ /** Tells the server this folder is on `version` (what the /home machine panel shows). */
242
+ async function ack(config, token, version) {
243
+ const res = await callApi(config.apiUrl, token, "/api/cli/sync/ack", { spaceId: config.spaceId, version });
244
+ if (!res.ok)
245
+ console.error(`(aviso) A pasta está na versão ${version}, mas o servidor não registrou: ${res.error}`);
246
+ }
247
+ /**
248
+ * Carries out a journal and closes the pull: new base in .omni/space.json, journal and .omni/tmp
249
+ * removed, server told. Used right after planning and, after an interruption, on the next run.
250
+ */
251
+ async function finishPull(root, config, token, journal, opts, asker) {
252
+ const tmpDir = toAbs(root, LOCAL_TMP_DIR);
253
+ const crashAfter = Number(process.env.OMNI_TEST_CRASH_AFTER_OPS); // tests only: simulated kill
248
254
  try {
249
- return await pullWith(root, config, token, opts, asker);
255
+ await applyJournal(root, journal, (i) => {
256
+ if (i + 1 === crashAfter)
257
+ process.exit(137);
258
+ });
259
+ }
260
+ catch (err) {
261
+ const busy = err instanceof ApplyError && ["EBUSY", "EPERM", "EACCES"].includes(err.code ?? "");
262
+ const what = err instanceof ApplyError ? err.path : "um arquivo";
263
+ const why = busy ? "está aberto em outro programa ou sem permissão" : err.message;
264
+ console.error(`\nNão deu para gravar ${what}: ${why}.`);
265
+ console.error("Feche o que estiver usando o arquivo e rode de novo: o pull continua de onde parou.");
266
+ return 1;
267
+ }
268
+ // Again after the writes: a push run during the interruption may have dropped a path from the
269
+ // list because its markers were not on disk yet.
270
+ await addConflicts(root, journal.marked);
271
+ await writeSpaceConfig(root, { ...config, baseVersion: journal.head });
272
+ await clearJournal(root);
273
+ await rm(tmpDir, { recursive: true, force: true });
274
+ await ack(config, token, journal.head);
275
+ console.log(`✓ Pasta na versão ${journal.head}: ${journal.written} arquivo(s) gravado(s), ${journal.deleted} apagado(s).`);
276
+ if (journal.autoMerged > 0)
277
+ console.log(` ${journal.autoMerged} arquivo(s) mudaram dos dois lados e foram juntados sozinhos.`);
278
+ if (journal.backupDir) {
279
+ console.log(` Versão desta máquina guardada em ${journal.backupDir} (${journal.backedUp} arquivo(s)).`);
280
+ }
281
+ if (journal.marked.length > 0) {
282
+ console.log(" Arquivos com marcadores para editar à mão (o envio fica bloqueado até eles sumirem):");
283
+ for (const path of journal.marked)
284
+ console.log(` ${path}`);
250
285
  }
251
- finally {
252
- asker.close();
286
+ if (journal.pendingPush.length > 0 && journal.marked.length === 0) {
287
+ console.log(` ${journal.pendingPush.length} mudança(s) desta máquina ainda não estão no servidor:`);
288
+ listPending(journal.pendingPush);
289
+ if (process.stdin.isTTY && !opts.conflito) {
290
+ const answer = await asker.ask("Enviar agora? (s/n) ");
291
+ if (answer === "s" || answer === "sim")
292
+ return pushSpace(root, { yes: true });
293
+ }
294
+ console.log(" Envie com omni sync --push.");
253
295
  }
296
+ console.log("");
297
+ return 0;
254
298
  }
255
299
  async function pullWith(root, config, token, opts, asker) {
256
300
  if (!config.spaceId) {
257
301
  console.error("Esta pasta ainda não tem espaço publicado. Termine a conta no navegador e rode omni sync --push.");
258
302
  return 1;
259
303
  }
304
+ // A pull that stopped while writing files: finish exactly what it had decided, nothing new.
305
+ const pending = await readJournal(root);
306
+ if (pending) {
307
+ const staged = await cachedObjects(toAbs(root, LOCAL_TMP_DIR));
308
+ if (pending.ops.every((op) => "remove" in op || staged.has(op.hash))) {
309
+ console.log(`\n→ Terminando de aplicar a versão ${pending.head} (um pull anterior parou no meio)...`);
310
+ return finishPull(root, config, token, pending, opts, asker);
311
+ }
312
+ // Staged content gone (someone cleaned .omni/tmp): plan again from scratch, the base is unchanged.
313
+ await clearJournal(root);
314
+ }
260
315
  console.log("\n→ Lendo a pasta...");
261
316
  const manifest = await buildManifest(root);
262
317
  if (manifest.truncated) {
263
318
  console.error("Pasta grande ou funda demais para sincronizar (limite de 20.000 arquivos e 12 níveis).");
264
319
  return 1;
265
320
  }
321
+ if (manifest.unreadable.length > 0) {
322
+ printUnreadable(manifest.unreadable, "Nada foi alterado na pasta.");
323
+ return 1;
324
+ }
266
325
  const tmpDir = toAbs(root, LOCAL_TMP_DIR);
267
326
  const cached = await cachedObjects(tmpDir);
268
327
  const have = [...new Set([...manifest.files.map((f) => f.hash), ...cached])];
@@ -287,9 +346,12 @@ async function pullWith(root, config, token, opts, asker) {
287
346
  console.log(`✓ Esta pasta já está na versão ${head}, a mais nova do servidor.\n`);
288
347
  if (plan.localOnly.length > 0) {
289
348
  console.log(` ${plan.localOnly.length} mudança(s) local(is) ainda não enviada(s):`);
290
- listPending(plan.localOnly, local);
349
+ listPending(plan.localOnly.map((path) => ({ path, deletedHere: !local.has(path) })));
291
350
  console.log(" Envie com omni sync --push.\n");
292
351
  }
352
+ // A run cut off between writing the base and telling the server leaves the server behind.
353
+ if (head > 0 && pull.synced !== head)
354
+ await ack(config, token, head);
293
355
  return 0;
294
356
  }
295
357
  // Every object needed ends up in .omni/tmp: the head content of what gets written, plus base and
@@ -379,84 +441,68 @@ async function pullWith(root, config, token, opts, asker) {
379
441
  resolutions.push(resolveConflict(conflict, choice));
380
442
  }
381
443
  }
444
+ // Everything is decided. Before touching any file of the space: take the backups, stage the final
445
+ // content of every path in .omni/tmp (by hash) and write the journal. From here on a kill only
446
+ // means the next run replays the journal.
447
+ const journal = await buildJournal(root, head, plan, resolutions, local, autoMerged);
448
+ if (!journal)
449
+ return 1;
450
+ await writeJournal(root, journal);
451
+ await addConflicts(root, journal.marked);
452
+ return finishPull(root, config, token, journal, opts, asker);
453
+ }
454
+ export async function buildJournal(root, head, plan, resolutions, local, autoMerged) {
455
+ const tmpDir = toAbs(root, LOCAL_TMP_DIR);
456
+ const ops = [...plan.writes, ...plan.deletes.map((path) => ({ path, remove: true }))];
382
457
  const stamp = backupStamp();
383
- const backedUp = [];
458
+ let backedUp = 0;
384
459
  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
460
  for (const r of resolutions) {
395
- const abs = toAbs(root, r.path);
396
461
  if (r.backup) {
397
462
  const copy = toAbs(root, `${LOCAL_BACKUPS_DIR}/${stamp}/${r.path}`);
398
463
  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);
464
+ await copyFile(toAbs(root, r.path), copy);
465
+ backedUp++;
409
466
  }
467
+ if (r.remove)
468
+ ops.push({ path: r.path, remove: true });
469
+ else if (r.hash)
470
+ ops.push({ path: r.path, hash: r.hash });
410
471
  else if (r.text !== undefined) {
411
- await mkdir(dirname(abs), { recursive: true });
412
- await writeFile(abs, r.text, "utf8");
472
+ const bytes = Buffer.from(r.text, "utf8");
473
+ const hash = sha256(bytes);
474
+ await writeFile(join(tmpDir, hash), bytes);
475
+ ops.push({ path: r.path, hash });
413
476
  }
414
477
  }
415
478
  }
416
479
  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;
480
+ console.error(`\nNão deu para preparar a gravação: ${err.message}`);
481
+ console.error("Nada foi alterado na pasta. Rode de novo.");
482
+ return null;
483
+ }
484
+ const afterPull = new Map(local);
485
+ for (const r of resolutions)
486
+ if (r.remove)
487
+ afterPull.delete(r.path);
488
+ const pendingPush = [...plan.localOnly, ...resolutions.filter((r) => r.pendingPush && !r.markers).map((r) => r.path)]
489
+ .sort()
490
+ .map((path) => ({ path, deletedHere: !afterPull.has(path) }));
491
+ // Removals first. A rename that only changes letter case (Readme.md to README.md) arrives as
492
+ // "write README.md" + "remove Readme.md"; on Windows both are the same file, so writing first
493
+ // and removing after would delete the file (fatia 6). Removing first also lets the new name win.
494
+ const ordered = [...ops.filter((op) => "remove" in op), ...ops.filter((op) => !("remove" in op))];
495
+ return {
496
+ head,
497
+ ops: ordered,
498
+ written: ops.filter((op) => !("remove" in op)).length,
499
+ deleted: ops.filter((op) => "remove" in op).length,
500
+ autoMerged,
501
+ marked: resolutions.filter((r) => r.markers).map((r) => r.path),
502
+ backupDir: backedUp > 0 ? `${LOCAL_BACKUPS_DIR}/${stamp}/` : null,
503
+ backedUp,
504
+ pendingPush,
505
+ };
460
506
  }
461
507
  /** `omni sync --pull` in a folder that is already a space. */
462
508
  export async function pullSpace(root, opts = {}) {
package/dist/report.js CHANGED
@@ -19,6 +19,15 @@ export function humanSize(bytes) {
19
19
  * What `omni sync --push` would send (ADR 003 item 6: show what goes up and what stays out, with
20
20
  * the reason, before anything leaves the machine). Used by the scan and by the push itself.
21
21
  */
22
+ /** Files the manifest could not read. Push and pull stop on them: unread is not the same as deleted. */
23
+ export function printUnreadable(paths, outcome) {
24
+ console.error("Não deu para ler estes arquivos (abertos em outro programa ou sem permissão):");
25
+ for (const path of paths.slice(0, 20))
26
+ console.error(` ${path}`);
27
+ if (paths.length > 20)
28
+ console.error(` ... e mais ${paths.length - 20}`);
29
+ console.error(`Feche o programa que está com eles e rode de novo. ${outcome}`);
30
+ }
22
31
  export function printSyncPreview(manifest) {
23
32
  console.log(` Sobem: ${manifest.files.length} arquivos de texto · ${humanSize(manifest.totalBytes)}`);
24
33
  if (manifest.totalBytes > MAX_SPACE_BYTES) {
package/dist/sync.js CHANGED
@@ -1,10 +1,12 @@
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";
4
- import { humanSize, printSyncPreview } from "./report.js";
5
+ import { humanSize, printSyncPreview, printUnreadable } 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.");
@@ -55,6 +67,10 @@ export async function pushSpace(root, opts) {
55
67
  console.error("Pasta grande ou funda demais para sincronizar (limite de 20.000 arquivos e 12 níveis).");
56
68
  return 1;
57
69
  }
70
+ if (manifest.unreadable.length > 0) {
71
+ printUnreadable(manifest.unreadable, "Nada foi enviado.");
72
+ return 1;
73
+ }
58
74
  if (manifest.collisions.length > 0) {
59
75
  console.error("Há arquivos cujo nome só difere por maiúsculas/minúsculas; no Windows eles colidem:");
60
76
  for (const group of manifest.collisions)
@@ -98,8 +114,8 @@ export async function pushSpace(root, opts) {
98
114
  return 1;
99
115
  }
100
116
  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);
117
+ const fileByHash = new Map(manifest.files.map((f) => [f.hash, f]));
118
+ const sizeToSend = uploads.reduce((sum, u) => sum + (fileByHash.get(u.hash)?.size ?? 0), 0);
103
119
  if (!opts.yes) {
104
120
  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
121
  if (!ok) {
@@ -111,7 +127,8 @@ export async function pushSpace(root, opts) {
111
127
  const queue = [...uploads];
112
128
  async function worker() {
113
129
  for (let next = queue.shift(); next; next = queue.shift()) {
114
- await uploadObject(next.url, absByHash.get(next.hash));
130
+ const file = fileByHash.get(next.hash);
131
+ await uploadObject(next.url, file.abs, file.path, next.hash);
115
132
  done++;
116
133
  if (process.stdout.isTTY)
117
134
  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.5.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": {