omnispace 0.2.1 → 0.3.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
@@ -2,7 +2,7 @@
2
2
 
3
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
4
 
5
- > Alpha. Existem `init`, `sync --push`, `sync --pull` e `clone`. Quando a mesma parte de um arquivo muda nas duas máquinas, o `sync --pull` para sem mexer na pasta; juntar as duas versões automaticamente vem numa próxima versão.
5
+ > Alpha. Existem `init`, `sync --push`, `sync --pull` e `clone`.
6
6
 
7
7
  Precisa do Node 20 ou mais novo.
8
8
 
@@ -32,6 +32,14 @@ Trazer para a pasta a versão mais nova do servidor:
32
32
  npx -y omnispace@latest sync . --pull
33
33
  ```
34
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
+
35
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):
36
44
 
37
45
  ```
@@ -46,4 +54,4 @@ No Windows, se o caminho tiver espaço ou apóstrofo, entre na pasta com aspas d
46
54
  - Nunca sobem: `.env` e variações, chaves (`.pem`, `.key`, `id_rsa`), pastas `.git`, `node_modules` e de build, imagens e mídia.
47
55
  - Arquivos `.env` são lidos só para listar os nomes das chaves; nenhum valor é guardado ou enviado.
48
56
  - Antes de enviar, a CLI mostra o que sobe e o que fica de fora e pede confirmação.
49
- - Na pasta, o Omni grava só `.omni/space.json` (e `.omni/tmp/` durante um download, apagada no fim). O token de acesso fica em `~/.omni/credentials.json`, fora de qualquer projeto.
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.
@@ -0,0 +1,46 @@
1
+ import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
2
+ import { join } from "node:path";
3
+ import { hasConflictMarkers } from "./merge.js";
4
+ // Files a pull left with conflict markers (option "mesclar"), so push can refuse to publish them
5
+ // until they are edited. Machine specific, never synced (include.ts, LOCAL_ONLY).
6
+ function conflictsPath(root) {
7
+ return join(root, ".omni", "conflicts.json");
8
+ }
9
+ async function readList(root) {
10
+ try {
11
+ const list = JSON.parse(await readFile(conflictsPath(root), "utf8"));
12
+ return Array.isArray(list) ? list.filter((p) => typeof p === "string") : [];
13
+ }
14
+ catch {
15
+ return [];
16
+ }
17
+ }
18
+ async function writeList(root, list) {
19
+ if (list.length === 0) {
20
+ await rm(conflictsPath(root), { force: true });
21
+ return;
22
+ }
23
+ await mkdir(join(root, ".omni"), { recursive: true });
24
+ await writeFile(conflictsPath(root), `${JSON.stringify([...new Set(list)].sort(), null, 2)}\n`, "utf8");
25
+ }
26
+ export async function addConflicts(root, paths) {
27
+ if (paths.length > 0)
28
+ await writeList(root, [...(await readList(root)), ...paths]);
29
+ }
30
+ /** Paths still holding markers. Resolved (or deleted) files leave the list as a side effect. */
31
+ export async function unresolvedConflicts(root) {
32
+ const list = await readList(root);
33
+ const pending = [];
34
+ for (const path of list) {
35
+ try {
36
+ if (hasConflictMarkers(await readFile(join(root, ...path.split("/")), "utf8")))
37
+ pending.push(path);
38
+ }
39
+ catch {
40
+ // file gone: nothing left to resolve
41
+ }
42
+ }
43
+ if (pending.length !== list.length)
44
+ await writeList(root, pending);
45
+ return pending;
46
+ }
package/dist/include.js CHANGED
@@ -10,11 +10,13 @@ export const SKIP_DIRS = new Set([
10
10
  ".turbo", ".cache", "venv", ".venv", "__pycache__", ".idea", ".vscode", "target",
11
11
  ]);
12
12
  // Local state of the Omni link itself: machine specific, never part of the space. `.omni/tmp/`
13
- // holds downloads of an unfinished pull (fatia 3).
14
- const LOCAL_ONLY = new Set([".omni/space.json"]);
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"]);
15
16
  export const LOCAL_TMP_DIR = ".omni/tmp";
17
+ export const LOCAL_BACKUPS_DIR = ".omni/backups";
16
18
  function isLocalOnly(path) {
17
- return LOCAL_ONLY.has(path) || path.startsWith(`${LOCAL_TMP_DIR}/`);
19
+ return LOCAL_ONLY.has(path) || path.startsWith(`${LOCAL_TMP_DIR}/`) || path.startsWith(`${LOCAL_BACKUPS_DIR}/`);
18
20
  }
19
21
  const TEXT_EXTENSIONS = new Set([
20
22
  ".md", ".mdx", ".txt", ".csv", ".tsv", ".json", ".jsonc", ".yaml", ".yml", ".toml", ".xml",
package/dist/index.js CHANGED
@@ -6,7 +6,7 @@ import { scanFolder } from "./scan.js";
6
6
  import { printScanReport } from "./report.js";
7
7
  import { linkAndSubmit } from "./link.js";
8
8
  import { pushSpace } from "./sync.js";
9
- import { cloneSpace, pullSpace } from "./pull.js";
9
+ import { cloneSpace, CONFLICT_CHOICES, pullSpace } from "./pull.js";
10
10
  import { buildManifest } from "./include.js";
11
11
  import { printSyncPreview } from "./report.js";
12
12
  const USAGE = `
@@ -17,10 +17,11 @@ Uso:
17
17
  omni init [pasta] --link <código> varre a pasta e envia pro código mostrado no navegador
18
18
  omni sync [pasta] --push [--yes] publica uma nova versão do espaço no servidor
19
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
20
21
  omni clone <código> <pasta> baixa um espaço da sua conta numa pasta nova desta máquina
21
22
 
22
- Gravar a estrutura padrão na pasta entra num próximo passo da Etapa técnica 1 —
23
- por enquanto quem decide o que fazer com o que foi varrido é a tela de revisão.
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.
24
25
  `;
25
26
  async function main(argv) {
26
27
  const args = argv.slice(2);
@@ -90,8 +91,18 @@ async function runSync(rest) {
90
91
  console.error("Use omni sync --push (enviar) ou omni sync --pull (baixar).");
91
92
  return 1;
92
93
  }
94
+ let conflito;
95
+ const conflictArg = rest.find((a) => a.startsWith("--conflito"));
96
+ if (conflictArg) {
97
+ const value = conflictArg.slice("--conflito=".length);
98
+ if (!conflictArg.startsWith("--conflito=") || !CONFLICT_CHOICES.includes(value)) {
99
+ console.error("Use --conflito=local, --conflito=mesclar ou --conflito=backup.");
100
+ return 1;
101
+ }
102
+ conflito = value;
103
+ }
93
104
  const target = resolve(rest.find((a) => !a.startsWith("-")) ?? process.cwd());
94
- return push ? pushSpace(target, { yes: rest.includes("--yes") }) : pullSpace(target);
105
+ return push ? pushSpace(target, { yes: rest.includes("--yes") }) : pullSpace(target, { conflito });
95
106
  }
96
107
  async function runClone(rest) {
97
108
  const [code, folder] = rest.filter((a) => !a.startsWith("-"));
package/dist/link.js CHANGED
@@ -64,5 +64,5 @@ export async function linkAndSubmit(code, root, scan) {
64
64
  process.exitCode = 1;
65
65
  return;
66
66
  }
67
- console.log("✓ Enviado. Volte pro navegador — a próxima tela é a de revisão.\n");
67
+ console.log("✓ Enviado. Volte pro navegador: a próxima tela é a de revisão.\n");
68
68
  }
package/dist/merge.js ADDED
@@ -0,0 +1,144 @@
1
+ // Three-way merge of text files (Proof of Core Sync, fatia 4; ADR 002). Line based, the same idea
2
+ // as diff3: each side is compared with the base, regions only one side touched are taken from
3
+ // that side, and regions both sides touched differently become a conflict. No dependency on
4
+ // purpose (ADR 005: the CLI brings nothing into the person's project).
5
+ export const MARK_LOCAL = "<<<<<<< esta máquina";
6
+ export const MARK_SEP = "=======";
7
+ export const MARK_REMOTE_PREFIX = ">>>>>>> servidor";
8
+ export function markRemote(version) {
9
+ return `${MARK_REMOTE_PREFIX} (versão ${version})`;
10
+ }
11
+ /** True when the text still has a conflict block written by `mesclar`. */
12
+ export function hasConflictMarkers(text) {
13
+ const plain = text.replace(/\r/g, "");
14
+ return /^<<<<<<< esta máquina$/m.test(plain) || /^>>>>>>> servidor/m.test(plain);
15
+ }
16
+ /**
17
+ * Lines without their endings, so a file saved with CRLF on one machine and LF on the other
18
+ * still lines up. The endings come back on output (local style wins).
19
+ */
20
+ export function splitLines(text) {
21
+ const crlf = (text.match(/\r\n/g) ?? []).length;
22
+ const lf = (text.match(/\n/g) ?? []).length - crlf;
23
+ const eol = crlf > lf ? "\r\n" : "\n";
24
+ if (text === "")
25
+ return { lines: [], eol, finalNewline: false };
26
+ const lines = text.split(/\r?\n/);
27
+ const finalNewline = lines[lines.length - 1] === "";
28
+ if (finalNewline)
29
+ lines.pop();
30
+ return { lines, eol, finalNewline };
31
+ }
32
+ // Above this many cells the full LCS table is not built; only the common prefix and suffix are
33
+ // matched, which is still a correct (just coarser) merge: more of the middle becomes one region.
34
+ const MAX_LCS_CELLS = 4_000_000;
35
+ /** base index → other index for the lines kept in common, strictly increasing on both sides. */
36
+ export function matchLines(base, other) {
37
+ const matches = new Map();
38
+ let start = 0;
39
+ while (start < base.length && start < other.length && base[start] === other[start]) {
40
+ matches.set(start, start);
41
+ start++;
42
+ }
43
+ let endBase = base.length;
44
+ let endOther = other.length;
45
+ const suffix = [];
46
+ while (endBase > start && endOther > start && base[endBase - 1] === other[endOther - 1]) {
47
+ endBase--;
48
+ endOther--;
49
+ suffix.push([endBase, endOther]);
50
+ }
51
+ const n = endBase - start;
52
+ const m = endOther - start;
53
+ if (n > 0 && m > 0 && (n + 1) * (m + 1) <= MAX_LCS_CELLS) {
54
+ // lcs[i][j] = LCS length of base[start+i..endBase) and other[start+j..endOther). Uint16 is
55
+ // enough: with the cell cap, the shorter side has at most 2000 lines.
56
+ const width = m + 1;
57
+ const lcs = new Uint16Array((n + 1) * width);
58
+ for (let i = n - 1; i >= 0; i--) {
59
+ for (let j = m - 1; j >= 0; j--) {
60
+ lcs[i * width + j] =
61
+ base[start + i] === other[start + j]
62
+ ? lcs[(i + 1) * width + j + 1] + 1
63
+ : Math.max(lcs[(i + 1) * width + j], lcs[i * width + j + 1]);
64
+ }
65
+ }
66
+ let i = 0;
67
+ let j = 0;
68
+ while (i < n && j < m) {
69
+ if (base[start + i] === other[start + j]) {
70
+ matches.set(start + i, start + j);
71
+ i++;
72
+ j++;
73
+ }
74
+ else if (lcs[(i + 1) * width + j] >= lcs[i * width + j + 1]) {
75
+ i++;
76
+ }
77
+ else {
78
+ j++;
79
+ }
80
+ }
81
+ }
82
+ for (const [b, o] of suffix)
83
+ matches.set(b, o);
84
+ return matches;
85
+ }
86
+ function sameLines(a, b) {
87
+ return a.length === b.length && a.every((line, i) => line === b[i]);
88
+ }
89
+ /**
90
+ * Merges `local` and `remote`, both descended from `base`. `conflicts` counts the regions both
91
+ * sides changed differently; those are written between markers, local first.
92
+ */
93
+ export function mergeText(base, local, remote, remoteVersion) {
94
+ const b = splitLines(base);
95
+ const l = splitLines(local);
96
+ const r = splitLines(remote);
97
+ const toLocal = matchLines(b.lines, l.lines);
98
+ const toRemote = matchLines(b.lines, r.lines);
99
+ const out = [];
100
+ let conflicts = 0;
101
+ let i = 0;
102
+ let j = 0;
103
+ let k = 0;
104
+ for (;;) {
105
+ // Next base line both sides kept: the end of the current region.
106
+ let o = i;
107
+ while (o < b.lines.length && !(toLocal.has(o) && toRemote.has(o)))
108
+ o++;
109
+ const localEnd = o < b.lines.length ? toLocal.get(o) : l.lines.length;
110
+ const remoteEnd = o < b.lines.length ? toRemote.get(o) : r.lines.length;
111
+ if (o === i && localEnd === j && remoteEnd === k) {
112
+ if (o === b.lines.length)
113
+ break;
114
+ out.push(l.lines[j]);
115
+ i++;
116
+ j++;
117
+ k++;
118
+ continue;
119
+ }
120
+ const basePart = b.lines.slice(i, o);
121
+ const localPart = l.lines.slice(j, localEnd);
122
+ const remotePart = r.lines.slice(k, remoteEnd);
123
+ if (sameLines(localPart, basePart))
124
+ out.push(...remotePart);
125
+ else if (sameLines(remotePart, basePart) || sameLines(localPart, remotePart))
126
+ out.push(...localPart);
127
+ else {
128
+ conflicts++;
129
+ out.push(MARK_LOCAL, ...localPart, MARK_SEP, ...remotePart, markRemote(remoteVersion));
130
+ }
131
+ i = o;
132
+ j = localEnd;
133
+ k = remoteEnd;
134
+ if (o === b.lines.length)
135
+ break;
136
+ }
137
+ // Trailing newline: same three-way rule as the lines.
138
+ const finalNewline = l.finalNewline === r.finalNewline ? l.finalNewline : l.finalNewline === b.finalNewline ? r.finalNewline : l.finalNewline;
139
+ const eol = local === "" ? r.eol : l.eol;
140
+ // A file ending in a conflict block always gets a newline after the closing marker.
141
+ const endsInMarker = out[out.length - 1]?.startsWith(MARK_REMOTE_PREFIX) ?? false;
142
+ const text = out.length === 0 ? "" : out.join(eol) + (finalNewline || endsInMarker ? eol : "");
143
+ return { text, conflicts };
144
+ }
package/dist/pull.js CHANGED
@@ -1,9 +1,13 @@
1
1
  import { createHash } from "node:crypto";
2
- import { copyFile, mkdir, readdir, rename, rm, rmdir, stat, writeFile } from "node:fs/promises";
2
+ import { copyFile, mkdir, readdir, readFile, rename, rm, rmdir, stat, writeFile } from "node:fs/promises";
3
+ import { createInterface } from "node:readline/promises";
3
4
  import { homedir, platform } from "node:os";
4
5
  import { basename, dirname, join, resolve } from "node:path";
5
6
  import { callApi } from "./api.js";
6
- import { buildManifest, hashFile, LOCAL_TMP_DIR } from "./include.js";
7
+ import { buildManifest, hashFile, LOCAL_BACKUPS_DIR, LOCAL_TMP_DIR } from "./include.js";
8
+ import { mergeText } from "./merge.js";
9
+ import { addConflicts } from "./conflicts.js";
10
+ import { pushSpace } from "./sync.js";
7
11
  import { defaultApiUrl, loadToken, machineHostname, readSpaceConfig, saveToken, writeSpaceConfig, } from "./config.js";
8
12
  /**
9
13
  * Three-way comparison per path (ADR 002): local, base (the version this folder last synced) and
@@ -123,13 +127,121 @@ function listSome(paths, max = 20) {
123
127
  if (paths.length > max)
124
128
  console.error(` ... e mais ${paths.length - max}`);
125
129
  }
130
+ export const CONFLICT_CHOICES = ["local", "mesclar", "backup"];
131
+ /** The question for each kind of conflict, with one label per choice (local, mesclar, backup). */
132
+ function promptFor(conflict) {
133
+ switch (conflict.kind) {
134
+ case "both":
135
+ return {
136
+ title: `${conflict.path}: ${conflict.regions} trecho(s) mudaram aqui e no servidor.`,
137
+ labels: [
138
+ "manter a versão desta máquina (ela sobe no próximo envio)",
139
+ "mesclar: gravar o arquivo com marcadores <<<<<<< e >>>>>>> para você editar",
140
+ "backup: usar a versão do servidor e guardar a desta máquina em .omni/backups",
141
+ ],
142
+ };
143
+ case "deletedHere":
144
+ return {
145
+ title: `${conflict.path}: foi apagado aqui e alterado no servidor.`,
146
+ labels: [
147
+ "manter apagado (a remoção sobe no próximo envio)",
148
+ "trazer de volta a versão do servidor",
149
+ "trazer de volta a versão do servidor",
150
+ ],
151
+ };
152
+ case "deletedThere":
153
+ return {
154
+ title: `${conflict.path}: foi alterado aqui e apagado no servidor.`,
155
+ labels: [
156
+ "manter o arquivo (ele volta para o servidor no próximo envio)",
157
+ "manter o arquivo (ele volta para o servidor no próximo envio)",
158
+ "apagar e guardar a versão desta máquina em .omni/backups",
159
+ ],
160
+ };
161
+ }
162
+ }
163
+ export function resolveConflict(conflict, choice) {
164
+ const path = conflict.path;
165
+ switch (conflict.kind) {
166
+ case "both":
167
+ if (choice === "local")
168
+ return { path, pendingPush: true };
169
+ if (choice === "mesclar")
170
+ return { path, text: conflict.marked, markers: true, pendingPush: true };
171
+ return { path, hash: conflict.headHash, backup: true, pendingPush: false };
172
+ case "deletedHere":
173
+ if (choice === "local")
174
+ return { path, pendingPush: true };
175
+ return { path, hash: conflict.headHash, pendingPush: false };
176
+ case "deletedThere":
177
+ if (choice === "backup")
178
+ return { path, remove: true, backup: true, pendingPush: false };
179
+ return { path, pendingPush: true };
180
+ }
181
+ }
126
182
  /**
127
- * Brings the folder to the server's head (Proof of Core Sync, fatia 3). Order matters for
128
- * interruptions: every object is downloaded and verified into .omni/tmp first, then files are
129
- * written, and only at the very end .omni/space.json gets the new base. A run killed anywhere
130
- * before that simply runs again.
183
+ * One reader for every question of a run. Lines come from an async iterator, which buffers them:
184
+ * `rl.question` drops answers that arrive before it is called (pasted or piped input). A closed
185
+ * input answers "c", which every question treats as cancel/no.
131
186
  */
132
- async function pullInto(root, config, token) {
187
+ function makeAsker() {
188
+ let rl = null;
189
+ let lines = null;
190
+ return {
191
+ async ask(question) {
192
+ if (!rl) {
193
+ rl = createInterface({ input: process.stdin, terminal: false });
194
+ lines = rl[Symbol.asyncIterator]();
195
+ }
196
+ process.stdout.write(question);
197
+ const next = await lines.next();
198
+ return next.done ? "c" : next.value.trim().toLowerCase();
199
+ },
200
+ close() {
201
+ rl?.close();
202
+ },
203
+ };
204
+ }
205
+ async function chooseFor(conflict, asker) {
206
+ const { title, labels } = promptFor(conflict);
207
+ // Kinds where two choices mean the same thing show that option once.
208
+ const options = labels
209
+ .map((label, i) => ({ label, choice: CONFLICT_CHOICES[i] }))
210
+ .filter((o, i) => labels.indexOf(o.label) === i);
211
+ console.log(`\n${title}`);
212
+ options.forEach((o, i) => console.log(` [${i + 1}] ${o.label}`));
213
+ for (;;) {
214
+ const answer = await asker.ask(`Escolha (${options.map((_, i) => i + 1).join("/")}, ou c para cancelar): `);
215
+ if (answer === "c")
216
+ return null;
217
+ const picked = options[Number(answer) - 1];
218
+ if (picked)
219
+ return picked.choice;
220
+ }
221
+ }
222
+ function backupStamp() {
223
+ return new Date().toISOString().slice(0, 19).replace(/:/g, "-");
224
+ }
225
+ /**
226
+ * Brings the folder to the server's head (Proof of Core Sync, fatias 3 e 4). Order matters for
227
+ * interruptions: every object is downloaded and verified into .omni/tmp first, every conflict is
228
+ * decided before anything is written, and only at the very end .omni/space.json gets the new base.
229
+ * A run killed anywhere before that simply runs again.
230
+ *
231
+ * Files changed on both sides are merged line by line against the base; what still collides is
232
+ * settled with one of the three options of ADR 002 (manter local, mesclar, backup), asked per file
233
+ * in a terminal or given once with --conflito.
234
+ */
235
+ async function pullInto(root, config, token, opts = {}) {
236
+ const asker = makeAsker();
237
+ try {
238
+ return await pullWith(root, config, token, opts, asker);
239
+ }
240
+ finally {
241
+ asker.close();
242
+ }
243
+ }
244
+ async function pullWith(root, config, token, opts, asker) {
133
245
  if (!config.spaceId) {
134
246
  console.error("Esta pasta ainda não tem espaço publicado. Termine a conta no navegador e rode omni sync --push.");
135
247
  return 1;
@@ -167,20 +279,20 @@ async function pullInto(root, config, token) {
167
279
  }
168
280
  return 0;
169
281
  }
170
- if (plan.conflicts.length > 0) {
171
- console.error(`\nO servidor está na versão ${head} e estes arquivos mudaram aqui e lá desde a versão ${config.baseVersion}:`);
172
- listSome(plan.conflicts);
173
- console.error("\nNada foi alterado na pasta. Juntar as duas mudanças automaticamente ainda não existe nesta versão da CLI.");
174
- return 1;
282
+ // Every object needed ends up in .omni/tmp: the head content of what gets written, plus base and
283
+ // head of each conflicting file (the merge needs both). Downloaded, or copied from a local file
284
+ // that already has that content (a rename on the other machine costs no download).
285
+ const neededSet = new Set(plan.writes.map((w) => w.hash));
286
+ for (const path of plan.conflicts) {
287
+ for (const hash of [base.get(path), headMap.get(path)])
288
+ if (hash)
289
+ neededSet.add(hash);
175
290
  }
176
- // Every object the writes need ends up in .omni/tmp: downloaded, or copied from a local file that
177
- // already has that content (a rename on the other machine costs no download).
178
291
  await mkdir(tmpDir, { recursive: true });
179
292
  const localByHash = new Map(manifest.files.map((f) => [f.hash, f.abs]));
180
293
  const urlByHash = new Map(pull.downloads.map((d) => [d.hash, d.url]));
181
- const needed = [...new Set(plan.writes.map((w) => w.hash))].filter((h) => !cached.has(h));
182
294
  const toDownload = [];
183
- for (const hash of needed) {
295
+ for (const hash of [...neededSet].filter((h) => !cached.has(h))) {
184
296
  const localAbs = localByHash.get(hash);
185
297
  if (localAbs)
186
298
  await copyFile(localAbs, join(tmpDir, hash));
@@ -211,6 +323,51 @@ async function pullInto(root, config, token) {
211
323
  }
212
324
  console.log(process.stdout.isTTY ? "" : ` Baixados ${done}/${toDownload.length}`);
213
325
  }
326
+ // Conflicts: automatic merge first, then one decision per file that still collides. Nothing is
327
+ // written until every decision is made, so cancelling leaves the folder as it was.
328
+ const resolutions = [];
329
+ const open = [];
330
+ const readObject = (hash) => (hash ? readFile(join(tmpDir, hash), "utf8") : Promise.resolve(""));
331
+ for (const path of plan.conflicts) {
332
+ const l = local.get(path);
333
+ const h = headMap.get(path);
334
+ if (l && h) {
335
+ const merged = mergeText(await readObject(base.get(path)), await readFile(toAbs(root, path), "utf8"), await readObject(h), head);
336
+ if (merged.conflicts === 0)
337
+ resolutions.push({ path, text: merged.text, pendingPush: true });
338
+ else
339
+ open.push({ path, kind: "both", marked: merged.text, regions: merged.conflicts, headHash: h });
340
+ }
341
+ else if (h) {
342
+ open.push({ path, kind: "deletedHere", headHash: h });
343
+ }
344
+ else {
345
+ open.push({ path, kind: "deletedThere" });
346
+ }
347
+ }
348
+ const autoMerged = resolutions.length;
349
+ if (open.length > 0) {
350
+ if (!opts.conflito && !process.stdin.isTTY) {
351
+ console.error(`\nO servidor está na versão ${head} e estes arquivos mudaram aqui e lá no mesmo trecho:`);
352
+ listSome(open.map((c) => c.path));
353
+ console.error("\nNada foi alterado na pasta. Rode num terminal para escolher arquivo por arquivo,");
354
+ console.error("ou escolha para todos com --conflito=local, --conflito=mesclar ou --conflito=backup.");
355
+ return 1;
356
+ }
357
+ if (!opts.conflito) {
358
+ console.log(`\nO servidor está na versão ${head}. ${open.length} arquivo(s) mudaram aqui e lá no mesmo trecho.`);
359
+ }
360
+ for (const conflict of open) {
361
+ const choice = opts.conflito ?? (await chooseFor(conflict, asker));
362
+ if (!choice) {
363
+ console.log("\nCancelado. Nada foi alterado na pasta.");
364
+ return 1;
365
+ }
366
+ resolutions.push(resolveConflict(conflict, choice));
367
+ }
368
+ }
369
+ const stamp = backupStamp();
370
+ const backedUp = [];
214
371
  try {
215
372
  for (const { path, hash } of plan.writes) {
216
373
  const abs = toAbs(root, path);
@@ -221,26 +378,66 @@ async function pullInto(root, config, token) {
221
378
  await rm(toAbs(root, path), { force: true });
222
379
  await pruneEmptyDirs(root, path);
223
380
  }
381
+ for (const r of resolutions) {
382
+ const abs = toAbs(root, r.path);
383
+ if (r.backup) {
384
+ const copy = toAbs(root, `${LOCAL_BACKUPS_DIR}/${stamp}/${r.path}`);
385
+ await mkdir(dirname(copy), { recursive: true });
386
+ await copyFile(abs, copy);
387
+ backedUp.push(r.path);
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);
396
+ }
397
+ else if (r.text !== undefined) {
398
+ await mkdir(dirname(abs), { recursive: true });
399
+ await writeFile(abs, r.text, "utf8");
400
+ }
401
+ }
224
402
  }
225
403
  catch (err) {
226
404
  console.error(`\nNão deu para gravar um arquivo: ${err.message}`);
227
405
  console.error("Se ele estiver aberto em outro programa, feche e rode de novo. O que já foi gravado não se perde.");
228
406
  return 1;
229
407
  }
408
+ const marked = resolutions.filter((r) => r.markers).map((r) => r.path);
409
+ await addConflicts(root, marked);
230
410
  await writeSpaceConfig(root, { ...config, baseVersion: head });
231
411
  await rm(tmpDir, { recursive: true, force: true });
232
412
  const ack = await callApi(config.apiUrl, token, "/api/cli/sync/ack", { spaceId: config.spaceId, version: head });
233
413
  if (!ack.ok)
234
414
  console.error(`(aviso) A pasta está na versão ${head}, mas o servidor não registrou: ${ack.error}`);
235
415
  console.log(`✓ Pasta na versão ${head}: ${plan.writes.length} arquivo(s) gravado(s), ${plan.deletes.length} apagado(s).`);
236
- if (plan.localOnly.length > 0) {
237
- console.log(` ${plan.localOnly.length} mudança(s) local(is) mantida(s). Envie com omni sync --push.`);
416
+ if (autoMerged > 0)
417
+ console.log(` ${autoMerged} arquivo(s) mudaram dos dois lados e foram juntados sozinhos.`);
418
+ if (backedUp.length > 0) {
419
+ console.log(` Versão desta máquina guardada em ${LOCAL_BACKUPS_DIR}/${stamp}/ (${backedUp.length} arquivo(s)).`);
420
+ }
421
+ if (marked.length > 0) {
422
+ console.log(" Arquivos com marcadores para editar à mão (o envio fica bloqueado até eles sumirem):");
423
+ for (const path of marked)
424
+ console.log(` ${path}`);
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.");
238
435
  }
239
436
  console.log("");
240
437
  return 0;
241
438
  }
242
439
  /** `omni sync --pull` in a folder that is already a space. */
243
- export async function pullSpace(root) {
440
+ export async function pullSpace(root, opts = {}) {
244
441
  const config = await readSpaceConfig(root);
245
442
  if (!config) {
246
443
  console.error("Esta pasta não está vinculada a um espaço. Para baixar um espaço, use omni clone <código> <pasta>.");
@@ -251,7 +448,7 @@ export async function pullSpace(root) {
251
448
  console.error(`Nenhum token da CLI para ${config.apiUrl}. Vincule esta máquina de novo.`);
252
449
  return 1;
253
450
  }
254
- return pullInto(root, config, token);
451
+ return pullInto(root, config, token, opts);
255
452
  }
256
453
  async function isEmptyDir(path) {
257
454
  try {
package/dist/report.js CHANGED
@@ -1,9 +1,9 @@
1
1
  import { basename } from "node:path";
2
2
  import { MAX_SPACE_BYTES } from "./include.js";
3
3
  const TRUNCATION_MESSAGES = {
4
- "max-files": "pasta grande demais (mais de 20.000 arquivos) — aponte uma subpasta",
5
- "max-depth": "pasta funda demais (mais de 12 níveis) — aponte uma subpasta mais interna",
6
- timeout: "a varredura passou de 60s e foi interrompida — aponte uma subpasta",
4
+ "max-files": "pasta grande demais (mais de 20.000 arquivos), aponte uma subpasta",
5
+ "max-depth": "pasta funda demais (mais de 12 níveis), aponte uma subpasta mais interna",
6
+ timeout: "a varredura passou de 60s e foi interrompida, aponte uma subpasta",
7
7
  };
8
8
  export function humanSize(bytes) {
9
9
  const units = ["B", "KB", "MB", "GB"];
@@ -46,7 +46,7 @@ export function printSyncPreview(manifest) {
46
46
  export function printScanReport(scan, opts = {}) {
47
47
  const lines = [];
48
48
  lines.push("");
49
- lines.push(`Omni CLI v0.1.0 — leitura, nada foi alterado na pasta`);
49
+ lines.push(`Omni CLI: leitura, nada foi alterado na pasta`);
50
50
  lines.push("");
51
51
  lines.push(`→ Varrendo "${basename(scan.root)}"...`);
52
52
  lines.push(` ${scan.fileCount} arquivos${scan.truncated ? " (varredura parcial)" : ""}` +
@@ -66,7 +66,7 @@ export function printScanReport(scan, opts = {}) {
66
66
  lines.push("→ Nenhuma estrutura de cérebro encontrada na raiz (CLAUDE.md, index.md, log.md, wiki/, raw/)");
67
67
  }
68
68
  if (scan.alreadyInstalled) {
69
- lines.push(" Esta pasta já tem .omni/ — é um espaço Omni existente.");
69
+ lines.push(" Esta pasta já tem .omni/, é um espaço Omni existente.");
70
70
  }
71
71
  lines.push("");
72
72
  if (scan.envFiles.length === 0) {
package/dist/sync.js CHANGED
@@ -4,6 +4,7 @@ import { buildManifest, MAX_SPACE_BYTES } from "./include.js";
4
4
  import { humanSize, printSyncPreview } from "./report.js";
5
5
  import { loadToken, readSpaceConfig, writeSpaceConfig } from "./config.js";
6
6
  import { callApi } from "./api.js";
7
+ import { unresolvedConflicts } from "./conflicts.js";
7
8
  async function confirm(question) {
8
9
  if (!process.stdin.isTTY)
9
10
  return false;
@@ -40,6 +41,14 @@ export async function pushSpace(root, opts) {
40
41
  console.error(`Nenhum token da CLI para ${config.apiUrl}. Rode omni init --link <código> de novo.`);
41
42
  return 1;
42
43
  }
44
+ // A file left with conflict markers by `sync --pull` (option mesclar) is not publishable yet.
45
+ const pendingConflicts = await unresolvedConflicts(root);
46
+ if (pendingConflicts.length > 0) {
47
+ console.error("Estes arquivos ainda têm marcadores de conflito (<<<<<<< esta máquina). Edite e rode de novo:");
48
+ for (const path of pendingConflicts)
49
+ console.error(` ${path}`);
50
+ return 1;
51
+ }
43
52
  console.log("\n→ Montando o manifesto...");
44
53
  const manifest = await buildManifest(root);
45
54
  if (manifest.truncated) {
package/package.json CHANGED
@@ -1,39 +1,39 @@
1
- {
2
- "name": "omnispace",
3
- "version": "0.2.1",
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.3.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
+ }