omnispace 0.5.1 → 0.6.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,13 +2,27 @@
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`.
5
+ > Alpha. Existem `login`, `logout`, `whoami`, `init`, `sync --push`, `sync --pull` e `clone`.
6
6
 
7
7
  Precisa do Node 20 ou mais novo.
8
8
 
9
9
  ## Uso
10
10
 
11
- Comece pelo site: ele mostra o código de vínculo. Depois, dentro da pasta do espaço:
11
+ Já tem conta no Omni? Entre pelo navegador (a CLI mostra um link e um código; a senha nunca passa pelo terminal):
12
+
13
+ ```
14
+ npx -y omnispace@latest login
15
+ ```
16
+
17
+ A sessão vale 14 dias sem uso, e cada uso renova. `whoami` mostra a conta e até quando a sessão vale; `logout` encerra a sessão e apaga o token desta máquina.
18
+
19
+ Com a CLI logada, dentro da pasta, o `init` cria o espaço direto na sua conta (só pergunta o nome, que por padrão é o da pasta) e já oferece o primeiro envio:
20
+
21
+ ```
22
+ npx -y omnispace@latest init .
23
+ ```
24
+
25
+ Primeira vez no Omni? Comece pelo site: ele mostra o código de vínculo. Depois, dentro da pasta do espaço:
12
26
 
13
27
  ```
14
28
  npx -y omnispace@latest init . --link <código>
@@ -54,4 +68,5 @@ No Windows, se o caminho tiver espaço ou apóstrofo, entre na pasta com aspas d
54
68
  - Nunca sobem: `.env` e variações, chaves (`.pem`, `.key`, `id_rsa`), pastas `.git`, `node_modules` e de build, imagens e mídia.
55
69
  - Arquivos `.env` são lidos só para listar os nomes das chaves; nenhum valor é guardado ou enviado.
56
70
  - 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.
71
+ - 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, legível só pelo seu usuário.
72
+ - A CLI só fala com o servidor por HTTPS (`http` só vale para um servidor na própria máquina, em desenvolvimento).
package/dist/api.js CHANGED
@@ -1,11 +1,35 @@
1
- /** POST to the Omni server. `token` is the CLI bearer token from ~/.omni/credentials.json, if any. */
2
- export async function callApi(apiUrl, token, path, body) {
1
+ import { isRetryableStatus, RetryableError, warnRetry, withRetry } from "./retry.js";
2
+ /**
3
+ * POST to the Omni server. `token` is the CLI bearer token from ~/.omni/credentials.json, if any.
4
+ * With `retryDelaysMs`, a 5xx, a 429 or a dropped connection is tried again; only pass it for calls
5
+ * that are safe to repeat. A dropped connection that is not retried comes back as status 0.
6
+ */
7
+ export async function callApi(apiUrl, token, path, body, opts = {}) {
3
8
  const headers = { "content-type": "application/json" };
4
9
  if (token)
5
10
  headers.authorization = `Bearer ${token}`;
6
- const response = await fetch(`${apiUrl}${path}`, { method: "POST", headers, body: JSON.stringify(body) });
7
- const json = (await response.json().catch(() => ({})));
8
- if (!response.ok)
9
- return { ok: false, status: response.status, error: json.error ?? `HTTP ${response.status}`, ...json };
10
- return { ok: true, status: response.status, ...json };
11
+ async function once() {
12
+ let response;
13
+ try {
14
+ response = await fetch(`${apiUrl}${path}`, { method: "POST", headers, body: JSON.stringify(body) });
15
+ }
16
+ catch (err) {
17
+ throw new RetryableError(`conexão com o servidor caiu (${err.message})`);
18
+ }
19
+ const json = (await response.json().catch(() => ({})));
20
+ if (response.ok)
21
+ return { ok: true, status: response.status, ...json };
22
+ const result = { ok: false, status: response.status, error: json.error ?? `HTTP ${response.status}`, ...json };
23
+ if (isRetryableStatus(response.status))
24
+ throw new RetryableError(result.error, result);
25
+ return result;
26
+ }
27
+ try {
28
+ return await withRetry(once, opts.retryDelaysMs ?? [], warnRetry);
29
+ }
30
+ catch (err) {
31
+ if (!(err instanceof RetryableError))
32
+ throw err;
33
+ return err.result ?? { ok: false, status: 0, error: err.message };
34
+ }
11
35
  }
package/dist/config.js CHANGED
@@ -1,4 +1,4 @@
1
- import { mkdir, readFile, writeFile } from "node:fs/promises";
1
+ import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
2
2
  import { homedir, hostname } from "node:os";
3
3
  import { dirname, join } from "node:path";
4
4
  // Two places, on purpose:
@@ -10,6 +10,25 @@ const DEFAULT_API_URL = "https://omni.tzolkin.cloud";
10
10
  export function defaultApiUrl() {
11
11
  return process.env.OMNI_API_URL?.replace(/\/$/, "") || DEFAULT_API_URL;
12
12
  }
13
+ /**
14
+ * Barrier 1 of pos-marco-conectores.md: tokens (and, later, credentials) only travel over HTTPS.
15
+ * Plain http is accepted only for a server on this machine (development). Returns the problem, or null.
16
+ */
17
+ export function insecureApiUrl(url = defaultApiUrl()) {
18
+ let parsed;
19
+ try {
20
+ parsed = new URL(url);
21
+ }
22
+ catch {
23
+ return `OMNI_API_URL não é um endereço válido: ${url}`;
24
+ }
25
+ if (parsed.protocol === "https:")
26
+ return null;
27
+ const local = ["localhost", "127.0.0.1", "[::1]"].includes(parsed.hostname) || parsed.hostname.endsWith(".localhost");
28
+ if (parsed.protocol === "http:" && local)
29
+ return null;
30
+ return `OMNI_API_URL precisa usar https (recebido ${parsed.protocol}//${parsed.host}). http só vale para um servidor nesta máquina.`;
31
+ }
13
32
  // OMNI_HOME and OMNI_MACHINE_NAME exist to simulate two machines on one PC (fatia 3): each gets
14
33
  // its own credentials and its own row in `machines` (unique per account and hostname).
15
34
  export function machineHostname() {
@@ -29,18 +48,37 @@ async function readJson(path) {
29
48
  return null;
30
49
  }
31
50
  }
32
- async function writeJson(path, value) {
51
+ /**
52
+ * Written to a temporary file next to the target and renamed over it: a kill in the middle never
53
+ * leaves a half-written credentials.json or space.json. `mode` 0o600 keeps the token readable only
54
+ * by the user (ignored on Windows, where the home folder is already private).
55
+ */
56
+ async function writeJson(path, value, mode) {
33
57
  await mkdir(dirname(path), { recursive: true });
34
- await writeFile(path, `${JSON.stringify(value, null, 2)}\n`, "utf8");
58
+ const tmp = `${path}.${process.pid}.tmp`;
59
+ await writeFile(tmp, `${JSON.stringify(value, null, 2)}\n`, { encoding: "utf8", mode });
60
+ await rename(tmp, path);
35
61
  }
36
- export async function saveToken(apiUrl, token, machineId) {
62
+ export async function saveToken(apiUrl, token, machineId, email) {
37
63
  const all = (await readJson(credentialsPath())) ?? {};
38
- all[apiUrl] = { token, machineId };
39
- await writeJson(credentialsPath(), all);
64
+ all[apiUrl] = email ? { token, machineId, email } : { token, machineId };
65
+ await writeJson(credentialsPath(), all, 0o600);
66
+ }
67
+ export async function loadCredential(apiUrl) {
68
+ const all = await readJson(credentialsPath());
69
+ return all?.[apiUrl] ?? null;
40
70
  }
41
71
  export async function loadToken(apiUrl) {
72
+ return (await loadCredential(apiUrl))?.token ?? null;
73
+ }
74
+ /** `omnispace logout`: forgets this server's token (the other servers' entries stay). */
75
+ export async function removeToken(apiUrl) {
42
76
  const all = await readJson(credentialsPath());
43
- return all?.[apiUrl]?.token ?? null;
77
+ if (!all?.[apiUrl])
78
+ return false;
79
+ delete all[apiUrl];
80
+ await writeJson(credentialsPath(), all, 0o600);
81
+ return true;
44
82
  }
45
83
  export function readSpaceConfig(root) {
46
84
  return readJson(spaceConfigPath(root));
package/dist/index.js CHANGED
@@ -10,11 +10,17 @@ 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
+ import { defaultApiUrl, insecureApiUrl } from "./config.js";
14
+ import { login, logout, printWhoami, whoami } from "./login.js";
15
+ import { initWithAccount } from "./init-account.js";
14
16
  const USAGE = `
15
17
  Omni CLI
16
18
 
17
19
  Uso:
20
+ omni login entra na sua conta pelo navegador (a sessão vale 14 dias sem uso)
21
+ omni logout encerra a sessão da CLI nesta máquina
22
+ omni whoami mostra a conta, a máquina e até quando a sessão vale
23
+ omni init [pasta] [--nome <nome>] com a CLI logada, cria o espaço na sua conta e liga a pasta
18
24
  omni init [pasta] --scan-only varre a pasta e mostra o que encontrou
19
25
  omni init [pasta] --link <código> varre a pasta e envia pro código mostrado no navegador
20
26
  omni sync [pasta] --push [--yes] publica uma nova versão do espaço no servidor
@@ -32,6 +38,17 @@ async function main(argv) {
32
38
  console.log(USAGE.trim());
33
39
  return 0;
34
40
  }
41
+ const insecure = insecureApiUrl();
42
+ if (insecure) {
43
+ console.error(insecure);
44
+ return 1;
45
+ }
46
+ if (command === "login")
47
+ return login({ openBrowser: !args.includes("--sem-navegador") && process.stdout.isTTY });
48
+ if (command === "logout")
49
+ return logout();
50
+ if (command === "whoami")
51
+ return printWhoami();
35
52
  if (command === "sync")
36
53
  return runSync(args.slice(1));
37
54
  if (command === "clone")
@@ -44,18 +61,31 @@ async function main(argv) {
44
61
  const rest = args.slice(1);
45
62
  const linkIndex = rest.indexOf("--link");
46
63
  const linkCode = linkIndex >= 0 ? rest[linkIndex + 1] : undefined;
47
- const positional = rest.filter((a, i) => !a.startsWith("-") && (linkIndex < 0 || i !== linkIndex + 1));
64
+ const nameIndex = rest.indexOf("--nome");
65
+ const nameArg = rest.find((a) => a.startsWith("--nome="))?.slice("--nome=".length) ?? (nameIndex >= 0 ? rest[nameIndex + 1] : undefined);
66
+ const positional = rest.filter((a, i) => !a.startsWith("-") && (linkIndex < 0 || i !== linkIndex + 1) && (nameIndex < 0 || i !== nameIndex + 1));
48
67
  const hasScanOnly = rest.includes("--scan-only");
49
68
  const target = resolve(positional[0] ?? process.cwd());
50
- // Writing to disk is not implemented yet (Etapa técnica 1 segue em aberto), so a run that
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.
53
- if (!hasScanOnly && !linkCode) {
54
- const site = defaultApiUrl();
55
- console.log(`
69
+ // Neither mode asked: with the CLI logged in to an account, the space is created straight there
70
+ // (fatia 1b). Without it, a bare `npx omnispace init` (what the LP shows) points to the funnel.
71
+ const accountMode = !hasScanOnly && !linkCode;
72
+ let me = null;
73
+ if (accountMode) {
74
+ const who = await whoami();
75
+ if (who.ok && who.me.hasAccount) {
76
+ me = who.me;
77
+ }
78
+ else {
79
+ if (!who.ok && !who.noToken)
80
+ console.error(`\n${who.error}`);
81
+ const site = defaultApiUrl();
82
+ console.log(`
56
83
  Omni CLI
57
84
 
58
- Para ligar uma pasta ao Omni, comece pelo navegador:
85
+ Já tem conta no Omni? Entre por aqui e rode o init de novo:
86
+ npx -y omnispace@latest login
87
+
88
+ Primeira vez? Comece pelo navegador:
59
89
  ${site}/omnispace/escolha
60
90
 
61
91
  Lá aparece um código (tipo ABCD-2345). Com ele, dentro da pasta do espaço, rode:
@@ -64,7 +94,8 @@ Lá aparece um código (tipo ABCD-2345). Com ele, dentro da pasta do espaço, ro
64
94
  Só quer ver o que o Omni leria nesta pasta, sem enviar nada?
65
95
  npx -y omnispace@latest init . --scan-only
66
96
  `);
67
- return 0;
97
+ return who.ok || who.noToken ? 0 : 1;
98
+ }
68
99
  }
69
100
  try {
70
101
  const info = await stat(target);
@@ -79,11 +110,13 @@ Só quer ver o que o Omni leria nesta pasta, sem enviar nada?
79
110
  }
80
111
  // Linking the user's whole home folder happened for real on 2026-09-23 (a missing cd); a space
81
112
  // is a work folder inside it, never the home itself.
82
- if (linkCode && resolve(target).toLowerCase() === resolve(homedir()).toLowerCase()) {
113
+ if ((linkCode || me) && resolve(target).toLowerCase() === resolve(homedir()).toLowerCase()) {
83
114
  console.error(`Essa é a pasta do seu usuário (${target}), não a de um projeto.`);
84
115
  console.error('Entre na pasta do espaço primeiro, com o caminho entre aspas duplas: cd "<caminho da pasta>"');
85
116
  return 1;
86
117
  }
118
+ if (me)
119
+ return initWithAccount(target, me, { name: nameArg, yes: rest.includes("--yes") });
87
120
  const scan = await scanFolder(target);
88
121
  printScanReport(scan, { linking: Boolean(linkCode) });
89
122
  if (linkCode && scan.truncated) {
@@ -0,0 +1,83 @@
1
+ import { basename } from "node:path";
2
+ import { createInterface } from "node:readline/promises";
3
+ import { callApi } from "./api.js";
4
+ import { defaultApiUrl, loadToken, readSpaceConfig, writeSpaceConfig } from "./config.js";
5
+ import { printScanReport } from "./report.js";
6
+ import { scanFolder } from "./scan.js";
7
+ import { pushSpace } from "./sync.js";
8
+ async function ask(question) {
9
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
10
+ try {
11
+ return (await rl.question(question)).trim();
12
+ }
13
+ finally {
14
+ rl.close();
15
+ }
16
+ }
17
+ /**
18
+ * `omnispace init` with the CLI logged in (pós-marco, fatia 1b; decision of Lucas, 2026-09-25):
19
+ * the space is created straight in the account, no web funnel. Only the name is asked (the folder
20
+ * name is the default); icon, type and categories keep their defaults and are edited on the web.
21
+ * Writes only .omni/space.json in the folder, then offers the first push.
22
+ */
23
+ export async function initWithAccount(root, me, opts) {
24
+ const existing = await readSpaceConfig(root);
25
+ if (existing?.spaceId) {
26
+ console.error("Esta pasta já é um espaço Omni. Para enviar mudanças: omnispace sync . --push");
27
+ return 1;
28
+ }
29
+ if (existing?.sessionId) {
30
+ console.error("Esta pasta está no meio da criação pelo navegador. Termine lá, ou apague .omni/space.json para começar de novo.");
31
+ return 1;
32
+ }
33
+ const scan = await scanFolder(root);
34
+ printScanReport(scan, { linking: true });
35
+ if (scan.truncated) {
36
+ console.error("A leitura parou antes do fim, então o espaço não foi criado. Aponte a pasta do espaço, não uma pasta acima dela.");
37
+ return 1;
38
+ }
39
+ const suggested = basename(root) || "Meu espaço";
40
+ let name = opts.name?.trim();
41
+ if (!name) {
42
+ if (opts.yes || !process.stdin.isTTY) {
43
+ name = suggested;
44
+ }
45
+ else {
46
+ name = (await ask(`Nome do espaço [${suggested}]: `)) || suggested;
47
+ }
48
+ }
49
+ if (name.length > 80) {
50
+ console.error("O nome do espaço pode ter até 80 caracteres.");
51
+ return 1;
52
+ }
53
+ if (!opts.yes) {
54
+ if (!process.stdin.isTTY) {
55
+ console.error('Sem terminal para confirmar. Rode de novo com --yes (e --nome "<nome>" se quiser outro nome).');
56
+ return 1;
57
+ }
58
+ const answer = (await ask(`Criar o espaço "${name}" na conta ${me.email ?? ""} e ligar esta pasta a ele? (s/n) `)).toLowerCase();
59
+ if (answer !== "s" && answer !== "sim") {
60
+ console.log("Nada foi criado.");
61
+ return 1;
62
+ }
63
+ }
64
+ const apiUrl = defaultApiUrl();
65
+ const token = await loadToken(apiUrl);
66
+ if (!token) {
67
+ console.error("A sessão da CLI sumiu no meio do caminho. Rode omnispace login.");
68
+ return 1;
69
+ }
70
+ // Not retried: creating a space twice would leave a duplicate in the account.
71
+ const created = await callApi(apiUrl, token, "/api/cli/spaces", { name, localPath: root, detectedStacks: scan.stacks });
72
+ if (!created.ok) {
73
+ console.error(`Falha ao criar o espaço: ${created.error}`);
74
+ return 1;
75
+ }
76
+ await writeSpaceConfig(root, { apiUrl, machineId: me.machineId, spaceId: created.spaceId, baseVersion: 0 });
77
+ console.log(`✓ Espaço "${created.name}" criado na sua conta. Vínculo gravado em .omni/space.json.`);
78
+ if (scan.stacks.length > 0) {
79
+ console.log(` ${scan.stacks.length} conector(es) preparado(s), desligado(s) até você ligar as credenciais.`);
80
+ }
81
+ console.log("\n→ Primeiro envio desta pasta para o espaço:");
82
+ return pushSpace(root, { yes: opts.yes });
83
+ }
package/dist/login.js ADDED
@@ -0,0 +1,113 @@
1
+ import { spawn } from "node:child_process";
2
+ import { platform } from "node:os";
3
+ import { callApi } from "./api.js";
4
+ import { defaultApiUrl, loadCredential, machineHostname, removeToken, saveToken } from "./config.js";
5
+ import { RETRY_DELAYS_MS } from "./retry.js";
6
+ // `omnispace login`, `logout` e `whoami` (pós-marco, fatia 1b; docs/plan/pos-marco-conectores.md).
7
+ // Login pelo código no navegador: a senha nunca passa pelo terminal. A sessão vale 14 dias contados
8
+ // do último uso; parada mais que isso, o servidor recusa e pede login de novo.
9
+ /**
10
+ * Opens the page in the default browser. Best effort: the URL is always printed too. Only http(s)
11
+ * URLs, and never through a shell (on Windows `cmd /c start` would parse `&` and `|` in the URL).
12
+ */
13
+ function openBrowser(url) {
14
+ if (!/^https?:\/\//.test(url))
15
+ return;
16
+ const os = platform();
17
+ const [cmd, args] = os === "win32"
18
+ ? ["rundll32", ["url.dll,FileProtocolHandler", url]]
19
+ : os === "darwin"
20
+ ? ["open", [url]]
21
+ : ["xdg-open", [url]];
22
+ try {
23
+ const child = spawn(cmd, args, { stdio: "ignore", detached: true, windowsHide: true });
24
+ child.on("error", () => { });
25
+ child.unref();
26
+ }
27
+ catch {
28
+ // No browser here (server, container): the printed URL is enough.
29
+ }
30
+ }
31
+ export async function login(opts = {}) {
32
+ const log = opts.log ?? ((line) => console.log(line));
33
+ const apiUrl = defaultApiUrl();
34
+ const previous = await loadCredential(apiUrl);
35
+ const start = await callApi(apiUrl, null, "/api/cli/login/start", { hostname: machineHostname(), os: platform(), machineId: previous?.machineId }, { retryDelaysMs: RETRY_DELAYS_MS });
36
+ if (!start.ok) {
37
+ console.error(`Falha ao começar o login: ${start.error}`);
38
+ return 1;
39
+ }
40
+ const deviceCode = start.deviceCode;
41
+ const userCode = start.userCode;
42
+ const verifyUrl = start.verifyUrl;
43
+ const expiresAt = Date.now() + start.expiresIn * 1000;
44
+ const waitMs = opts.pollMs ?? start.interval * 1000;
45
+ log("");
46
+ log("Para entrar, abra no navegador:");
47
+ log(` ${verifyUrl}`);
48
+ log("e digite este código:");
49
+ log(` ${userCode}`);
50
+ log("");
51
+ if (opts.openBrowser)
52
+ openBrowser(verifyUrl);
53
+ log("→ Esperando a autorização no navegador (Ctrl+C cancela)...");
54
+ while (Date.now() < expiresAt) {
55
+ await new Promise((r) => setTimeout(r, waitMs));
56
+ const poll = await callApi(apiUrl, null, "/api/cli/login/poll", { deviceCode }, { retryDelaysMs: RETRY_DELAYS_MS });
57
+ if (poll.ok && poll.state === "pending")
58
+ continue;
59
+ if (!poll.ok) {
60
+ console.error(poll.denied ? "Login recusado no navegador. Nada foi liberado." : `Falha no login: ${poll.error}`);
61
+ return 1;
62
+ }
63
+ await saveToken(apiUrl, poll.token, poll.machineId, poll.email ?? undefined);
64
+ log(`✓ Entrou como ${poll.email ?? "sua conta"}.`);
65
+ log(" A sessão vale 14 dias sem uso, e cada uso renova. Para sair: omnispace logout\n");
66
+ return 0;
67
+ }
68
+ console.error("O código expirou antes da autorização. Rode omnispace login de novo.");
69
+ return 1;
70
+ }
71
+ export async function logout() {
72
+ const apiUrl = defaultApiUrl();
73
+ const credential = await loadCredential(apiUrl);
74
+ if (!credential) {
75
+ console.log("Nenhuma sessão da CLI nesta máquina.");
76
+ return 0;
77
+ }
78
+ // Revoked on the server first; the local copy goes either way (an expired or revoked token is
79
+ // useless anyway, and keeping it would only confuse the next command).
80
+ const res = await callApi(apiUrl, credential.token, "/api/cli/logout", {});
81
+ await removeToken(apiUrl);
82
+ if (res.ok)
83
+ console.log("✓ Sessão encerrada: o token foi revogado no servidor e apagado desta máquina.");
84
+ else
85
+ console.log(`✓ Token apagado desta máquina. (O servidor respondeu: ${res.error})`);
86
+ return 0;
87
+ }
88
+ /** The account behind this machine's token, or the server's refusal (expired, revoked, none). */
89
+ export async function whoami() {
90
+ const apiUrl = defaultApiUrl();
91
+ const credential = await loadCredential(apiUrl);
92
+ if (!credential)
93
+ return { ok: false, error: "Nenhuma sessão da CLI nesta máquina.", noToken: true };
94
+ const res = await callApi(apiUrl, credential.token, "/api/cli/whoami", {}, { retryDelaysMs: RETRY_DELAYS_MS });
95
+ if (!res.ok)
96
+ return { ok: false, error: res.error ?? "falha", expired: Boolean(res.expired) };
97
+ return { ok: true, me: res };
98
+ }
99
+ export async function printWhoami() {
100
+ const result = await whoami();
101
+ if (!result.ok) {
102
+ console.log(result.error);
103
+ if (result.noToken || result.expired)
104
+ console.log("Para entrar: npx -y omnispace@latest login");
105
+ return 1;
106
+ }
107
+ const { me } = result;
108
+ const until = new Date(me.expiresAt).toLocaleDateString("pt-BR");
109
+ console.log(me.hasAccount ? `Conta: ${me.email ?? "(sem email)"}` : "Máquina vinculada pelo navegador, conta ainda não criada.");
110
+ console.log(`Máquina: ${me.machineName ?? me.machineId}`);
111
+ console.log(`Sessão: vale até ${until} se ficar sem uso (cada uso renova).`);
112
+ return 0;
113
+ }
package/dist/pull.js CHANGED
@@ -4,6 +4,7 @@ import { createInterface } from "node:readline/promises";
4
4
  import { homedir, platform } from "node:os";
5
5
  import { basename, dirname, join, resolve } from "node:path";
6
6
  import { callApi } from "./api.js";
7
+ import { isRetryableStatus, RETRY_DELAYS_MS, RetryableError, withRetry } from "./retry.js";
7
8
  import { buildManifest, hashFile, LOCAL_BACKUPS_DIR, LOCAL_TMP_DIR } from "./include.js";
8
9
  import { mergeText } from "./merge.js";
9
10
  import { addConflicts } from "./conflicts.js";
@@ -70,38 +71,28 @@ async function cachedObjects(tmpDir) {
70
71
  }
71
72
  return ok;
72
73
  }
73
- class RetryableError extends Error {
74
- }
75
74
  /**
76
- * A 5xx, a 429 or a dropped connection is usually the Storage having a bad second: the first real
77
- * clone (Novo Dash, 2026-09-23) stopped at 995/1020 on a single 504. Those are retried with a short
78
- * backoff before giving up; a 4xx or a wrong hash is not, since trying again cannot fix it.
75
+ * A 5xx, a 429 or a dropped connection is retried (see retry.ts), quietly: downloads run in
76
+ * parallel under a progress line. A 4xx or a wrong hash is not, since trying again cannot fix it.
79
77
  */
80
- async function fetchBytes(url, hash, retryDelaysMs) {
81
- for (let attempt = 0;; attempt++) {
78
+ function fetchBytes(url, hash, retryDelaysMs) {
79
+ return withRetry(async () => {
80
+ let response;
82
81
  try {
83
- let response;
84
- try {
85
- response = await fetch(url);
86
- }
87
- catch (err) {
88
- throw new RetryableError(`conexão caiu ao baixar ${hash.slice(0, 12)} (${err.message})`);
89
- }
90
- if (response.status >= 500 || response.status === 429) {
91
- throw new RetryableError(`HTTP ${response.status} ao baixar ${hash.slice(0, 12)}`);
92
- }
93
- if (!response.ok)
94
- throw new Error(`HTTP ${response.status} ao baixar ${hash.slice(0, 12)}`);
95
- return new Uint8Array(await response.arrayBuffer());
82
+ response = await fetch(url);
96
83
  }
97
84
  catch (err) {
98
- if (!(err instanceof RetryableError) || attempt >= retryDelaysMs.length)
99
- throw err;
100
- await new Promise((r) => setTimeout(r, retryDelaysMs[attempt]));
85
+ throw new RetryableError(`conexão caiu ao baixar ${hash.slice(0, 12)} (${err.message})`);
101
86
  }
102
- }
87
+ const message = `HTTP ${response.status} ao baixar ${hash.slice(0, 12)}`;
88
+ if (isRetryableStatus(response.status))
89
+ throw new RetryableError(message);
90
+ if (!response.ok)
91
+ throw new Error(message);
92
+ return new Uint8Array(await response.arrayBuffer());
93
+ }, retryDelaysMs);
103
94
  }
104
- export async function download(url, hash, tmpDir, retryDelaysMs = [1000, 3000, 8000]) {
95
+ export async function download(url, hash, tmpDir, retryDelaysMs = RETRY_DELAYS_MS) {
105
96
  const bytes = await fetchBytes(url, hash, retryDelaysMs);
106
97
  if (sha256(bytes) !== hash)
107
98
  throw new Error(`conteúdo baixado não confere com o hash ${hash.slice(0, 12)}`);
@@ -240,7 +231,7 @@ async function pullInto(root, config, token, opts = {}) {
240
231
  }
241
232
  /** Tells the server this folder is on `version` (what the /home machine panel shows). */
242
233
  async function ack(config, token, version) {
243
- const res = await callApi(config.apiUrl, token, "/api/cli/sync/ack", { spaceId: config.spaceId, version });
234
+ const res = await callApi(config.apiUrl, token, "/api/cli/sync/ack", { spaceId: config.spaceId, version }, { retryDelaysMs: RETRY_DELAYS_MS });
244
235
  if (!res.ok)
245
236
  console.error(`(aviso) A pasta está na versão ${version}, mas o servidor não registrou: ${res.error}`);
246
237
  }
@@ -325,11 +316,7 @@ async function pullWith(root, config, token, opts, asker) {
325
316
  const tmpDir = toAbs(root, LOCAL_TMP_DIR);
326
317
  const cached = await cachedObjects(tmpDir);
327
318
  const have = [...new Set([...manifest.files.map((f) => f.hash), ...cached])];
328
- const pull = await callApi(config.apiUrl, token, "/api/cli/sync/pull", {
329
- spaceId: config.spaceId,
330
- baseVersion: config.baseVersion,
331
- have,
332
- });
319
+ const pull = await callApi(config.apiUrl, token, "/api/cli/sync/pull", { spaceId: config.spaceId, baseVersion: config.baseVersion, have }, { retryDelaysMs: RETRY_DELAYS_MS });
333
320
  if (!pull.ok) {
334
321
  console.error(`Falha ao consultar o servidor: ${pull.error}`);
335
322
  return 1;
@@ -553,6 +540,8 @@ export async function cloneSpace(code, target) {
553
540
  else {
554
541
  const apiUrl = defaultApiUrl();
555
542
  console.log(`\n→ Usando o código ${code}...`);
543
+ // Never retried: the code is single use, so a second try after a lost answer would only say
544
+ // "código já usado". The staging folder above is what makes the rest resumable.
556
545
  const claim = await callApi(apiUrl, await loadToken(apiUrl), "/api/cli/clone", {
557
546
  code,
558
547
  hostname: machineHostname(),
package/dist/retry.js ADDED
@@ -0,0 +1,38 @@
1
+ /**
2
+ * Retry for calls that can safely run twice. A 5xx, a 429 or a dropped connection is usually the
3
+ * server or the Storage having a bad second: the first real clone (Novo Dash, 2026-09-23) stopped
4
+ * at 995/1020 on a single 504, and the closing test of the Proof of Core Sync (2026-09-25) had a
5
+ * `prepare` fail once with "Falha ao gerar URL de envio". A 4xx is never retried, since trying
6
+ * again cannot fix it. Calls that are not safe to repeat (commit, clone) never go through here.
7
+ */
8
+ /** Waits before the 2nd, 3rd and 4th attempts. */
9
+ export const RETRY_DELAYS_MS = [1000, 3000, 8000];
10
+ /** A failure worth trying again. `result` keeps the last server answer, if there was one. */
11
+ export class RetryableError extends Error {
12
+ result;
13
+ constructor(message, result) {
14
+ super(message);
15
+ this.result = result;
16
+ }
17
+ }
18
+ export function isRetryableStatus(status) {
19
+ return status >= 500 || status === 429;
20
+ }
21
+ /** Warning shown before each new attempt, on stderr so it never mixes with command output. */
22
+ export function warnRetry(err, waitMs) {
23
+ console.error(` (servidor instável: ${err.message}; tentando de novo em ${waitMs / 1000}s)`);
24
+ }
25
+ /** Runs `fn`, repeating it after each RetryableError with the given delays; the last one is thrown. */
26
+ export async function withRetry(fn, delaysMs, onRetry) {
27
+ for (let attempt = 0;; attempt++) {
28
+ try {
29
+ return await fn();
30
+ }
31
+ catch (err) {
32
+ if (!(err instanceof RetryableError) || attempt >= delaysMs.length)
33
+ throw err;
34
+ onRetry?.(err, delaysMs[attempt]);
35
+ await new Promise((r) => setTimeout(r, delaysMs[attempt]));
36
+ }
37
+ }
38
+ }
package/dist/sync.js CHANGED
@@ -7,6 +7,7 @@ import { loadToken, readSpaceConfig, writeSpaceConfig } from "./config.js";
7
7
  import { callApi } from "./api.js";
8
8
  import { unresolvedConflicts } from "./conflicts.js";
9
9
  import { withLock } from "./lock.js";
10
+ import { isRetryableStatus, RETRY_DELAYS_MS, RetryableError, warnRetry, withRetry } from "./retry.js";
10
11
  async function confirm(question) {
11
12
  if (!process.stdin.isTTY)
12
13
  return false;
@@ -19,19 +20,31 @@ async function confirm(question) {
19
20
  * Uploads one object straight to Storage through the signed URL (never through the Omni server).
20
21
  * The bytes are hashed again right before sending: a file edited after the manifest was built would
21
22
  * otherwise land under the old hash, and every later version would reuse that wrong object.
23
+ * A 5xx, a 429 or a dropped connection is sent again: the PUT is an upsert of the same bytes, and
24
+ * the commit checks every object anyway.
22
25
  */
23
- export async function uploadObject(url, abs, path, hash) {
26
+ export async function uploadObject(url, abs, path, hash, retryDelaysMs = RETRY_DELAYS_MS) {
24
27
  const bytes = await readFile(abs);
25
28
  if (createHash("sha256").update(bytes).digest("hex") !== hash) {
26
29
  throw new Error(`o arquivo ${path} mudou durante o envio`);
27
30
  }
28
- const response = await fetch(url, {
29
- method: "PUT",
30
- headers: { "content-type": "application/octet-stream", "x-upsert": "true" },
31
- body: bytes,
32
- });
33
- if (!response.ok)
34
- throw new Error(`HTTP ${response.status} ${await response.text().catch(() => "")}`);
31
+ await withRetry(async () => {
32
+ let response;
33
+ try {
34
+ response = await fetch(url, {
35
+ method: "PUT",
36
+ headers: { "content-type": "application/octet-stream", "x-upsert": "true" },
37
+ body: bytes,
38
+ });
39
+ }
40
+ catch (err) {
41
+ throw new RetryableError(`conexão caiu ao enviar ${path} (${err.message})`);
42
+ }
43
+ if (response.ok)
44
+ return;
45
+ const message = `HTTP ${response.status} ao enviar ${path} ${await response.text().catch(() => "")}`.trim();
46
+ throw isRetryableStatus(response.status) ? new RetryableError(message) : new Error(message);
47
+ }, retryDelaysMs, warnRetry);
35
48
  }
36
49
  /**
37
50
  * `omni sync --push` (Proof of Core Sync, fatia 2; docs/plan/adr/002-protocolo-sync.md).
@@ -83,11 +96,7 @@ async function pushLocked(root, opts) {
83
96
  }
84
97
  printSyncPreview(manifest);
85
98
  const files = manifest.files.map(({ path, hash, size }) => ({ path, hash, size }));
86
- const prepare = await callApi(config.apiUrl, token, "/api/cli/sync/prepare", {
87
- spaceId: config.spaceId,
88
- sessionId: config.spaceId ? undefined : config.sessionId,
89
- files,
90
- });
99
+ const prepare = await callApi(config.apiUrl, token, "/api/cli/sync/prepare", { spaceId: config.spaceId, sessionId: config.spaceId ? undefined : config.sessionId, files }, { retryDelaysMs: RETRY_DELAYS_MS });
91
100
  if (!prepare.ok) {
92
101
  console.error(`Falha ao preparar o envio: ${prepare.error}`);
93
102
  return 1;
@@ -144,11 +153,16 @@ async function pushLocked(root, opts) {
144
153
  if (uploads.length > 0)
145
154
  console.log(process.stdout.isTTY ? "" : ` Enviados ${done}/${uploads.length}`);
146
155
  console.log(`→ Publicando a versão ${head + 1} (o servidor confere ${files.length} arquivos, pode levar alguns segundos)...`);
156
+ // Never retried here: if the server published and only the answer got lost, a second commit would
157
+ // come back as "stale". The next run recovers instead (prepare sees `unchanged` and adopts the head).
147
158
  const commit = await callApi(config.apiUrl, token, "/api/cli/sync/commit", { spaceId, parentVersion: head, files });
148
159
  if (!commit.ok) {
149
160
  console.error(`Falha ao publicar a versão: ${commit.error}`);
150
161
  if (commit.stale)
151
162
  console.error("Rode omni sync --pull e depois envie de novo.");
163
+ else if (commit.status === 0 || isRetryableStatus(commit.status)) {
164
+ console.error("Rode de novo: se a versão chegou a ser publicada, o próximo envio reconhece e não duplica.");
165
+ }
152
166
  return 1;
153
167
  }
154
168
  const version = commit.version;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "omnispace",
3
- "version": "0.5.1",
3
+ "version": "0.6.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": {