omnispace 0.5.2 → 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/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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "omnispace",
3
- "version": "0.5.2",
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": {