omnispace 0.5.2 → 0.7.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`, `status`, `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>
@@ -20,6 +34,12 @@ Só ler a pasta, sem vincular nem enviar nada:
20
34
  npx -y omnispace@latest init . --scan-only
21
35
  ```
22
36
 
37
+ Ver o que mudou nesta pasta e no servidor, e qual o próximo comando (só lê, não altera nada):
38
+
39
+ ```
40
+ npx -y omnispace@latest status
41
+ ```
42
+
23
43
  Publicar uma nova versão do espaço no servidor:
24
44
 
25
45
  ```
@@ -54,4 +74,5 @@ No Windows, se o caminho tiver espaço ou apóstrofo, entre na pasta com aspas d
54
74
  - Nunca sobem: `.env` e variações, chaves (`.pem`, `.key`, `id_rsa`), pastas `.git`, `node_modules` e de build, imagens e mídia.
55
75
  - Arquivos `.env` são lidos só para listar os nomes das chaves; nenhum valor é guardado ou enviado.
56
76
  - 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.
77
+ - 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.
78
+ - A CLI só fala com o servidor por HTTPS (`http` só vale para um servidor na própria máquina, em desenvolvimento).
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/conflicts.js CHANGED
@@ -27,8 +27,11 @@ export async function addConflicts(root, paths) {
27
27
  if (paths.length > 0)
28
28
  await writeList(root, [...(await readList(root)), ...paths]);
29
29
  }
30
- /** Paths still holding markers. Resolved (or deleted) files leave the list as a side effect. */
31
- export async function unresolvedConflicts(root) {
30
+ /**
31
+ * Paths still holding markers. Resolved (or deleted) files leave the list as a side effect,
32
+ * unless `readOnly` (omnispace status never writes to the folder).
33
+ */
34
+ export async function unresolvedConflicts(root, opts = {}) {
32
35
  const list = await readList(root);
33
36
  const pending = [];
34
37
  for (const path of list) {
@@ -40,7 +43,7 @@ export async function unresolvedConflicts(root) {
40
43
  // file gone: nothing left to resolve
41
44
  }
42
45
  }
43
- if (pending.length !== list.length)
46
+ if (!opts.readOnly && pending.length !== list.length)
44
47
  await writeList(root, pending);
45
48
  return pending;
46
49
  }
package/dist/index.js CHANGED
@@ -10,20 +10,28 @@ 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";
16
+ import { statusSpace } from "./status.js";
14
17
  const USAGE = `
15
18
  Omni CLI
16
19
 
17
20
  Uso:
21
+ omni login entra na sua conta pelo navegador (a sessão vale 14 dias sem uso)
22
+ omni logout encerra a sessão da CLI nesta máquina
23
+ omni whoami mostra a conta, a máquina e até quando a sessão vale
24
+ omni init [pasta] [--nome <nome>] com a CLI logada, cria o espaço na sua conta e liga a pasta
18
25
  omni init [pasta] --scan-only varre a pasta e mostra o que encontrou
19
26
  omni init [pasta] --link <código> varre a pasta e envia pro código mostrado no navegador
27
+ omni status [pasta] o que mudou aqui e no servidor, e o próximo comando (só lê)
20
28
  omni sync [pasta] --push [--yes] publica uma nova versão do espaço no servidor
21
29
  omni sync [pasta] --pull traz para a pasta a versão mais nova do servidor
22
30
  [--conflito=local|mesclar|backup] mesma resposta para todos os conflitos, sem perguntar
23
31
  omni clone <código> <pasta> baixa um espaço da sua conta numa pasta nova desta máquina
24
32
 
25
- A CLI ainda não reorganiza a sua pasta. O que fazer com o que foi varrido
26
- é decidido na tela de revisão, no navegador.
33
+ A CLI não reorganiza a sua pasta. Com --link, o que fazer com o que foi
34
+ varrido é decidido na tela de revisão, no navegador.
27
35
  `;
28
36
  async function main(argv) {
29
37
  const args = argv.slice(2);
@@ -32,10 +40,23 @@ async function main(argv) {
32
40
  console.log(USAGE.trim());
33
41
  return 0;
34
42
  }
43
+ const insecure = insecureApiUrl();
44
+ if (insecure) {
45
+ console.error(insecure);
46
+ return 1;
47
+ }
48
+ if (command === "login")
49
+ return login({ openBrowser: !args.includes("--sem-navegador") && process.stdout.isTTY });
50
+ if (command === "logout")
51
+ return logout();
52
+ if (command === "whoami")
53
+ return printWhoami();
35
54
  if (command === "sync")
36
55
  return runSync(args.slice(1));
37
56
  if (command === "clone")
38
57
  return runClone(args.slice(1));
58
+ if (command === "status")
59
+ return statusSpace(resolve(args.slice(1).find((a) => !a.startsWith("-")) ?? process.cwd()));
39
60
  if (command !== "init") {
40
61
  console.error(`Comando desconhecido: ${command}`);
41
62
  console.log(USAGE.trim());
@@ -44,18 +65,31 @@ async function main(argv) {
44
65
  const rest = args.slice(1);
45
66
  const linkIndex = rest.indexOf("--link");
46
67
  const linkCode = linkIndex >= 0 ? rest[linkIndex + 1] : undefined;
47
- const positional = rest.filter((a, i) => !a.startsWith("-") && (linkIndex < 0 || i !== linkIndex + 1));
68
+ const nameIndex = rest.indexOf("--nome");
69
+ const nameArg = rest.find((a) => a.startsWith("--nome="))?.slice("--nome=".length) ?? (nameIndex >= 0 ? rest[nameIndex + 1] : undefined);
70
+ const positional = rest.filter((a, i) => !a.startsWith("-") && (linkIndex < 0 || i !== linkIndex + 1) && (nameIndex < 0 || i !== nameIndex + 1));
48
71
  const hasScanOnly = rest.includes("--scan-only");
49
72
  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(`
73
+ // Neither mode asked: with the CLI logged in to an account, the space is created straight there
74
+ // (fatia 1b). Without it, a bare `npx omnispace init` (what the LP shows) points to the funnel.
75
+ const accountMode = !hasScanOnly && !linkCode;
76
+ let me = null;
77
+ if (accountMode) {
78
+ const who = await whoami();
79
+ if (who.ok && who.me.hasAccount) {
80
+ me = who.me;
81
+ }
82
+ else {
83
+ if (!who.ok && !who.noToken)
84
+ console.error(`\n${who.error}`);
85
+ const site = defaultApiUrl();
86
+ console.log(`
56
87
  Omni CLI
57
88
 
58
- Para ligar uma pasta ao Omni, comece pelo navegador:
89
+ Já tem conta no Omni? Entre por aqui e rode o init de novo:
90
+ npx -y omnispace@latest login
91
+
92
+ Primeira vez? Comece pelo navegador:
59
93
  ${site}/omnispace/escolha
60
94
 
61
95
  Lá aparece um código (tipo ABCD-2345). Com ele, dentro da pasta do espaço, rode:
@@ -64,7 +98,8 @@ Lá aparece um código (tipo ABCD-2345). Com ele, dentro da pasta do espaço, ro
64
98
  Só quer ver o que o Omni leria nesta pasta, sem enviar nada?
65
99
  npx -y omnispace@latest init . --scan-only
66
100
  `);
67
- return 0;
101
+ return who.ok || who.noToken ? 0 : 1;
102
+ }
68
103
  }
69
104
  try {
70
105
  const info = await stat(target);
@@ -79,11 +114,13 @@ Só quer ver o que o Omni leria nesta pasta, sem enviar nada?
79
114
  }
80
115
  // Linking the user's whole home folder happened for real on 2026-09-23 (a missing cd); a space
81
116
  // is a work folder inside it, never the home itself.
82
- if (linkCode && resolve(target).toLowerCase() === resolve(homedir()).toLowerCase()) {
117
+ if ((linkCode || me) && resolve(target).toLowerCase() === resolve(homedir()).toLowerCase()) {
83
118
  console.error(`Essa é a pasta do seu usuário (${target}), não a de um projeto.`);
84
119
  console.error('Entre na pasta do espaço primeiro, com o caminho entre aspas duplas: cd "<caminho da pasta>"');
85
120
  return 1;
86
121
  }
122
+ if (me)
123
+ return initWithAccount(target, me, { name: nameArg, yes: rest.includes("--yes") });
87
124
  const scan = await scanFolder(target);
88
125
  printScanReport(scan, { linking: Boolean(linkCode) });
89
126
  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/status.js ADDED
@@ -0,0 +1,159 @@
1
+ import { basename } from "node:path";
2
+ import { callApi } from "./api.js";
3
+ import { RETRY_DELAYS_MS } from "./retry.js";
4
+ import { buildManifest } from "./include.js";
5
+ import { unresolvedConflicts } from "./conflicts.js";
6
+ import { readJournal } from "./journal.js";
7
+ import { planPull } from "./pull.js";
8
+ import { loadToken, readSpaceConfig } from "./config.js";
9
+ /**
10
+ * What `omnispace status` reports, from the same three-way comparison the pull uses (ADR 002).
11
+ * A path equal on both sides (same edit made twice) counts as nothing to do.
12
+ */
13
+ export function summarizeStatus(local, base, head) {
14
+ const plan = planPull(local, base, head);
15
+ const kind = (path) => !base.has(path) ? "nova" : !local.has(path) ? "apagada" : "editada";
16
+ return {
17
+ local: [...plan.localOnly, ...plan.conflicts].sort().map((path) => ({ path, kind: kind(path) })),
18
+ remote: plan.writes.length + plan.deletes.length + plan.conflicts.length,
19
+ both: plan.conflicts,
20
+ };
21
+ }
22
+ /** The commands to run next, in order. Empty when the folder and the server agree. */
23
+ export function nextSteps(opts) {
24
+ const { head, baseVersion, summary, markers, pullInterrupted } = opts;
25
+ if (pullInterrupted)
26
+ return ["omnispace sync --pull (termina o pull que parou no meio)"];
27
+ if (markers.length > 0) {
28
+ return ["resolva os marcadores de conflito nos arquivos acima", "omnispace sync --push"];
29
+ }
30
+ const steps = [];
31
+ if (head > baseVersion) {
32
+ steps.push(summary.both.length > 0
33
+ ? `omnispace sync --pull (vai pedir decisão em ${summary.both.length} arquivo(s) mudado(s) dos dois lados)`
34
+ : "omnispace sync --pull");
35
+ }
36
+ if (summary.local.length > 0)
37
+ steps.push("omnispace sync --push");
38
+ return steps;
39
+ }
40
+ export function timeAgo(iso, now = Date.now()) {
41
+ const minutes = Math.round((now - new Date(iso).getTime()) / 60000);
42
+ if (minutes < 1)
43
+ return "agora";
44
+ if (minutes < 60)
45
+ return `há ${minutes} min`;
46
+ const hours = Math.round(minutes / 60);
47
+ if (hours < 24)
48
+ return `há ${hours} h`;
49
+ return `há ${Math.round(hours / 24)} d`;
50
+ }
51
+ const KIND_MARK = { nova: "+", editada: "~", apagada: "-" };
52
+ function listChanges(changes, max = 20) {
53
+ for (const change of changes.slice(0, max))
54
+ console.log(` ${KIND_MARK[change.kind]} ${change.path}`);
55
+ if (changes.length > max)
56
+ console.log(` ... e mais ${changes.length - max}`);
57
+ }
58
+ function countKinds(changes) {
59
+ const parts = ["editada", "nova", "apagada"]
60
+ .map((k) => [k, changes.filter((c) => c.kind === k).length])
61
+ .filter(([, n]) => n > 0)
62
+ .map(([k, n]) => `${n} ${n === 1 ? k : `${k}s`}`);
63
+ return parts.join(", ");
64
+ }
65
+ /**
66
+ * `omnispace status` (P1, docs/plan/p1-continuidade-visivel.md): what this folder has that the
67
+ * server does not, what the server has that the folder does not, and the next command. Read only:
68
+ * no lock, nothing written to the folder or to `.omni/`.
69
+ */
70
+ export async function statusSpace(root) {
71
+ const config = await readSpaceConfig(root);
72
+ if (!config) {
73
+ console.log(`\n${basename(root)} não é um espaço Omni (não tem .omni/space.json).`);
74
+ console.log("Para transformar esta pasta num espaço: omnispace init\n");
75
+ return 1;
76
+ }
77
+ if (!config.spaceId) {
78
+ console.log("\nEsta pasta foi vinculada pelo navegador, mas o espaço ainda não foi criado.");
79
+ console.log("Termine a conta no navegador e depois rode: omnispace sync --push\n");
80
+ return 1;
81
+ }
82
+ const token = await loadToken(config.apiUrl);
83
+ if (!token) {
84
+ console.log(`\nNenhuma sessão da CLI nesta máquina para ${config.apiUrl}.`);
85
+ console.log("Para entrar: omnispace login\n");
86
+ return 1;
87
+ }
88
+ const [journal, markers] = await Promise.all([readJournal(root), unresolvedConflicts(root, { readOnly: true })]);
89
+ console.log("\n→ Lendo a pasta...");
90
+ const manifest = await buildManifest(root);
91
+ if (manifest.truncated) {
92
+ console.error("Pasta grande ou funda demais para comparar (limite de 20.000 arquivos e 12 níveis).");
93
+ return 1;
94
+ }
95
+ const res = await callApi(config.apiUrl, token, "/api/cli/sync/status", { spaceId: config.spaceId, baseVersion: config.baseVersion }, { retryDelaysMs: RETRY_DELAYS_MS });
96
+ if (!res.ok) {
97
+ console.log(`\nServidor não consultado: ${res.error}`);
98
+ if (res.expired || res.status === 401)
99
+ console.log("Para entrar de novo: omnispace login");
100
+ console.log(`Esta pasta está na versão ${config.baseVersion}. Sem o servidor não dá para dizer o que mudou.\n`);
101
+ return 1;
102
+ }
103
+ const head = res.head;
104
+ const headMap = new Map(res.files.map((f) => [f.path, f.hash]));
105
+ const baseMap = res.base ? new Map(res.base.map((f) => [f.path, f.hash])) : headMap;
106
+ const local = new Map(manifest.files.map((f) => [f.path, f.hash]));
107
+ // An unreadable file is missing from the manifest; left in, it would show up as deleted here.
108
+ const skip = (path) => manifest.unreadable.some((u) => path === u || path.startsWith(`${u}/`));
109
+ const readable = (map) => new Map([...map].filter(([path]) => !skip(path)));
110
+ const summary = summarizeStatus(local, readable(baseMap), readable(headMap));
111
+ console.log(`\nEspaço: ${res.name} (${res.slug})`);
112
+ console.log(`Nesta pasta: ${config.baseVersion === 0 ? "nenhuma versão ainda" : `versão ${config.baseVersion}`}`);
113
+ if (head === 0) {
114
+ console.log("No servidor: nenhuma versão publicada ainda");
115
+ }
116
+ else {
117
+ const when = res.publishedAt ? `, publicada ${timeAgo(res.publishedAt)}` : "";
118
+ const who = res.publishedBy ? ` por ${res.publishedBy}` : "";
119
+ console.log(`No servidor: versão ${head}${when}${who}`);
120
+ }
121
+ if (summary.local.length > 0) {
122
+ console.log(`\nMudanças nesta pasta ainda não enviadas: ${summary.local.length} (${countKinds(summary.local)})`);
123
+ listChanges(summary.local);
124
+ }
125
+ if (head > config.baseVersion) {
126
+ console.log(`\nNo servidor há ${summary.remote} arquivo(s) diferente(s) da versão desta pasta.`);
127
+ }
128
+ if (summary.both.length > 0) {
129
+ console.log(`\nMudados dos dois lados (o pull pergunta o que fazer):`);
130
+ for (const path of summary.both.slice(0, 20))
131
+ console.log(` ${path}`);
132
+ }
133
+ if (markers.length > 0) {
134
+ console.log("\nAinda com marcadores de conflito (<<<<<<< esta máquina), o push recusa até serem editados:");
135
+ for (const path of markers)
136
+ console.log(` ${path}`);
137
+ }
138
+ if (manifest.unreadable.length > 0) {
139
+ console.log(`\n${manifest.unreadable.length} arquivo(s) não puderam ser lidos (abertos em outro programa?); a comparação ignora esses.`);
140
+ }
141
+ const steps = nextSteps({
142
+ head,
143
+ baseVersion: config.baseVersion,
144
+ summary,
145
+ markers,
146
+ pullInterrupted: journal !== null,
147
+ });
148
+ if (journal)
149
+ console.log(`\nUm pull da versão ${journal.head} parou no meio.`);
150
+ if (steps.length === 0) {
151
+ console.log(head === 0 ? "\n✓ Pasta vazia e nada publicado ainda.\n" : `\n✓ Em dia com a versão ${head}.\n`);
152
+ }
153
+ else {
154
+ console.log("\nPróximo passo:");
155
+ steps.forEach((step, i) => console.log(` ${i + 1}. ${step}`));
156
+ console.log("");
157
+ }
158
+ return 0;
159
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "omnispace",
3
- "version": "0.5.2",
3
+ "version": "0.7.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": {