omnispace 0.4.0 → 0.5.1

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
@@ -50,7 +50,7 @@ No Windows, se o caminho tiver espaço ou apóstrofo, entre na pasta com aspas d
50
50
 
51
51
  ## O que o Omni lê e envia
52
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.
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
54
  - Nunca sobem: `.env` e variações, chaves (`.pem`, `.key`, `id_rsa`), pastas `.git`, `node_modules` e de build, imagens e mídia.
55
55
  - Arquivos `.env` são lidos só para listar os nomes das chaves; nenhum valor é guardado ou enviado.
56
56
  - Antes de enviar, a CLI mostra o que sobe e o que fica de fora e pede confirmação.
package/dist/include.js CHANGED
@@ -37,6 +37,19 @@ export const MAX_FILE_BYTES = 25 * 1024 * 1024;
37
37
  export const MAX_SPACE_BYTES = 100 * 1024 * 1024;
38
38
  const MAX_FILES = 20000;
39
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
+ }
40
53
  /** Files that never leave the machine, with no option to turn them on (ADR 003 item 3, ADR 004). */
41
54
  export function isSecret(name) {
42
55
  const lower = name.toLowerCase();
@@ -85,6 +98,7 @@ export async function buildManifest(root) {
85
98
  const files = [];
86
99
  const excluded = [];
87
100
  const repos = [];
101
+ const unreadable = [];
88
102
  let totalBytes = 0;
89
103
  let seen = 0;
90
104
  let truncated = false;
@@ -95,7 +109,14 @@ export async function buildManifest(root) {
95
109
  truncated = true;
96
110
  return;
97
111
  }
98
- 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
+ }
99
120
  entries.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
100
121
  for (const entry of entries) {
101
122
  if (entry.isSymbolicLink())
@@ -133,6 +154,10 @@ export async function buildManifest(root) {
133
154
  excluded.push({ path, reason: "binário ou mídia" });
134
155
  continue;
135
156
  }
157
+ if (!relParts.every(isPortableName)) {
158
+ excluded.push({ path, reason: "nome que o Windows não aceita" });
159
+ continue;
160
+ }
136
161
  let size;
137
162
  try {
138
163
  size = (await stat(abs)).size;
@@ -144,7 +169,17 @@ export async function buildManifest(root) {
144
169
  excluded.push({ path, reason: "maior que 25 MB" });
145
170
  continue;
146
171
  }
147
- 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 });
148
183
  totalBytes += size;
149
184
  }
150
185
  }
@@ -155,6 +190,7 @@ export async function buildManifest(root) {
155
190
  repos,
156
191
  totalBytes,
157
192
  collisions: findCaseCollisions(files.map((f) => f.path)),
193
+ unreadable,
158
194
  truncated,
159
195
  };
160
196
  }
package/dist/index.js CHANGED
@@ -10,6 +10,7 @@ import { cloneSpace, CONFLICT_CHOICES, pullSpace } from "./pull.js";
10
10
  import { buildManifest } from "./include.js";
11
11
  import { printSyncPreview } from "./report.js";
12
12
  import { LockedError } from "./lock.js";
13
+ import { defaultApiUrl } from "./config.js";
13
14
  const USAGE = `
14
15
  Omni CLI
15
16
 
@@ -47,10 +48,23 @@ async function main(argv) {
47
48
  const hasScanOnly = rest.includes("--scan-only");
48
49
  const target = resolve(positional[0] ?? process.cwd());
49
50
  // Writing to disk is not implemented yet (Etapa técnica 1 segue em aberto), so a run that
50
- // asked for neither mode must stop instead of pretending the full flow happened.
51
+ // asked for neither mode must stop instead of pretending the full flow happened. A bare
52
+ // `npx omnispace init` is exactly what the LP shows, so it points to where the flow starts.
51
53
  if (!hasScanOnly && !linkCode) {
52
- console.error("Rode com --scan-only (só ler) ou --link <código> (ler e enviar pra revisão).");
53
- return 1;
54
+ const site = defaultApiUrl();
55
+ console.log(`
56
+ Omni CLI
57
+
58
+ Para ligar uma pasta ao Omni, comece pelo navegador:
59
+ ${site}/omnispace/escolha
60
+
61
+ Lá aparece um código (tipo ABCD-2345). Com ele, dentro da pasta do espaço, rode:
62
+ npx -y omnispace@latest init . --link <código>
63
+
64
+ Só quer ver o que o Omni leria nesta pasta, sem enviar nada?
65
+ npx -y omnispace@latest init . --scan-only
66
+ `);
67
+ return 0;
54
68
  }
55
69
  try {
56
70
  const info = await stat(target);
package/dist/journal.js CHANGED
@@ -1,6 +1,21 @@
1
1
  import { copyFile, mkdir, readFile, rename, rm, rmdir, writeFile } from "node:fs/promises";
2
2
  import { dirname, join } from "node:path";
3
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
+ }
4
19
  function journalPath(root) {
5
20
  return join(root, ".omni", "pull-pending.json");
6
21
  }
@@ -48,13 +63,18 @@ export async function applyJournal(root, journal, afterOp) {
48
63
  for (let i = 0; i < journal.ops.length; i++) {
49
64
  const op = journal.ops[i];
50
65
  const abs = toAbs(root, op.path);
51
- if ("remove" in op) {
52
- await rm(abs, { force: true });
53
- await pruneEmptyDirs(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
+ }
54
75
  }
55
- else {
56
- await mkdir(dirname(abs), { recursive: true });
57
- await copyFile(join(tmpDir, op.hash), abs);
76
+ catch (err) {
77
+ throw new ApplyError(op.path, err.code);
58
78
  }
59
79
  afterOp?.(i);
60
80
  }
package/dist/pull.js CHANGED
@@ -7,9 +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 { applyJournal, clearJournal, readJournal, writeJournal } from "./journal.js";
10
+ import { ApplyError, applyJournal, clearJournal, readJournal, writeJournal } from "./journal.js";
11
11
  import { withLock } from "./lock.js";
12
12
  import { pushSpace } from "./sync.js";
13
+ import { printUnreadable } from "./report.js";
13
14
  import { defaultApiUrl, loadToken, machineHostname, readSpaceConfig, saveToken, writeSpaceConfig, } from "./config.js";
14
15
  /**
15
16
  * Three-way comparison per path (ADR 002): local, base (the version this folder last synced) and
@@ -257,8 +258,11 @@ async function finishPull(root, config, token, journal, opts, asker) {
257
258
  });
258
259
  }
259
260
  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.");
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.");
262
266
  return 1;
263
267
  }
264
268
  // Again after the writes: a push run during the interruption may have dropped a path from the
@@ -314,6 +318,10 @@ async function pullWith(root, config, token, opts, asker) {
314
318
  console.error("Pasta grande ou funda demais para sincronizar (limite de 20.000 arquivos e 12 níveis).");
315
319
  return 1;
316
320
  }
321
+ if (manifest.unreadable.length > 0) {
322
+ printUnreadable(manifest.unreadable, "Nada foi alterado na pasta.");
323
+ return 1;
324
+ }
317
325
  const tmpDir = toAbs(root, LOCAL_TMP_DIR);
318
326
  const cached = await cachedObjects(tmpDir);
319
327
  const have = [...new Set([...manifest.files.map((f) => f.hash), ...cached])];
@@ -443,7 +451,7 @@ async function pullWith(root, config, token, opts, asker) {
443
451
  await addConflicts(root, journal.marked);
444
452
  return finishPull(root, config, token, journal, opts, asker);
445
453
  }
446
- async function buildJournal(root, head, plan, resolutions, local, autoMerged) {
454
+ export async function buildJournal(root, head, plan, resolutions, local, autoMerged) {
447
455
  const tmpDir = toAbs(root, LOCAL_TMP_DIR);
448
456
  const ops = [...plan.writes, ...plan.deletes.map((path) => ({ path, remove: true }))];
449
457
  const stamp = backupStamp();
@@ -480,9 +488,13 @@ async function buildJournal(root, head, plan, resolutions, local, autoMerged) {
480
488
  const pendingPush = [...plan.localOnly, ...resolutions.filter((r) => r.pendingPush && !r.markers).map((r) => r.path)]
481
489
  .sort()
482
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))];
483
495
  return {
484
496
  head,
485
- ops,
497
+ ops: ordered,
486
498
  written: ops.filter((op) => !("remove" in op)).length,
487
499
  deleted: ops.filter((op) => "remove" in op).length,
488
500
  autoMerged,
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
@@ -2,7 +2,7 @@ import { createHash } from "node:crypto";
2
2
  import { readFile } from "node:fs/promises";
3
3
  import { createInterface } from "node:readline/promises";
4
4
  import { buildManifest, MAX_SPACE_BYTES } from "./include.js";
5
- import { humanSize, printSyncPreview } from "./report.js";
5
+ import { humanSize, printSyncPreview, printUnreadable } from "./report.js";
6
6
  import { loadToken, readSpaceConfig, writeSpaceConfig } from "./config.js";
7
7
  import { callApi } from "./api.js";
8
8
  import { unresolvedConflicts } from "./conflicts.js";
@@ -67,6 +67,10 @@ async function pushLocked(root, opts) {
67
67
  console.error("Pasta grande ou funda demais para sincronizar (limite de 20.000 arquivos e 12 níveis).");
68
68
  return 1;
69
69
  }
70
+ if (manifest.unreadable.length > 0) {
71
+ printUnreadable(manifest.unreadable, "Nada foi enviado.");
72
+ return 1;
73
+ }
70
74
  if (manifest.collisions.length > 0) {
71
75
  console.error("Há arquivos cujo nome só difere por maiúsculas/minúsculas; no Windows eles colidem:");
72
76
  for (const group of manifest.collisions)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "omnispace",
3
- "version": "0.4.0",
3
+ "version": "0.5.1",
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": {
@@ -15,7 +15,7 @@
15
15
  "build": "tsc -p tsconfig.json",
16
16
  "typecheck": "tsc -p tsconfig.json --noEmit",
17
17
  "omni": "node dist/index.js",
18
- "test": "tsc -p tsconfig.test.json && node --test \".test-build/test/**/*.test.js\""
18
+ "test": "tsc -p tsconfig.test.json && node scripts/run-tests.mjs"
19
19
  },
20
20
  "engines": {
21
21
  "node": ">=20"