omnispace 0.7.0 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  CLI do [Omni](https://omni.tzolkin.cloud): transforma uma pasta da sua máquina em um espaço Omni e sincroniza esse espaço entre máquinas.
4
4
 
5
- > Alpha. Existem `login`, `logout`, `whoami`, `init`, `status`, `sync --push`, `sync --pull` e `clone`.
5
+ > Alpha. Existem `login`, `logout`, `whoami`, `init`, `status`, `sync --push`, `sync --pull`, `clone`, `vincular` e `desvincular`.
6
6
 
7
7
  Precisa do Node 20 ou mais novo.
8
8
 
@@ -40,6 +40,12 @@ Ver o que mudou nesta pasta e no servidor, e qual o próximo comando (só lê, n
40
40
  npx -y omnispace@latest status
41
41
  ```
42
42
 
43
+ Em uma linha só (feito para o hook de início do Claude Code; sai sempre com 0):
44
+
45
+ ```
46
+ npx -y omnispace@latest status --resumo
47
+ ```
48
+
43
49
  Publicar uma nova versão do espaço no servidor:
44
50
 
45
51
  ```
@@ -66,13 +72,36 @@ Baixar um espaço da sua conta em outra máquina (o código aparece no site, em
66
72
  npx -y omnispace@latest clone <código> "nome-da-pasta"
67
73
  ```
68
74
 
75
+ Ligar uma pasta que já tem os arquivos a um espaço da sua conta (criado no site, ou para voltar depois de desvincular). Arquivos iguais ficam, os diferentes perguntam o que fazer:
76
+
77
+ ```
78
+ npx -y omnispace@latest vincular
79
+ ```
80
+
81
+ Tirar esta máquina da conta e o `.omni/` das pastas dela (pede duas confirmações; seus arquivos ficam, e cópias de conflito vão para `omni-backups-<data>/`):
82
+
83
+ ```
84
+ npx -y omnispace@latest desvincular
85
+ ```
86
+
69
87
  No Windows, se o caminho tiver espaço ou apóstrofo, entre na pasta com aspas duplas: `cd "C:\caminho\da\pasta"`.
70
88
 
71
89
  ## O que o Omni lê e envia
72
90
 
73
91
  - 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.
74
- - Nunca sobem: `.env` e variações, chaves (`.pem`, `.key`, `id_rsa`), pastas `.git`, `node_modules` e de build, imagens e mídia.
75
- - Arquivos `.env` são lidos só para listar os nomes das chaves; nenhum valor é guardado ou enviado.
92
+ - Nunca sobem: `.env` e variações, chaves (`.pem`, `.key`, `id_rsa`), pastas `.git`, `node_modules` e de build, imagens e mídia, e o `.claude/settings.local.json` do Claude Code (configuração de cada máquina; o `.claude/settings.json` sincroniza) e o `.claude/worktrees/` (cópias do repositório das sessões paralelas do Claude Code).
93
+ - Arquivos `.env` são lidos só para listar os nomes das chaves; nenhum valor é guardado ou enviado. A exceção é `omnispace env push` (abaixo), quando você escolhe chave por chave.
76
94
  - Antes de enviar, a CLI mostra o que sobe e o que fica de fora e pede confirmação.
77
95
  - 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
96
  - A CLI só fala com o servidor por HTTPS (`http` só vale para um servidor na própria máquina, em desenvolvimento).
97
+
98
+ ## Credenciais do `.env` no cofre do espaço
99
+
100
+ ```
101
+ npx -y omnispace@latest env push
102
+ ```
103
+
104
+ Com a CLI logada, dentro da pasta do espaço: a CLI lê os arquivos `.env` da raiz (menos `.env.example`, `.env.sample` e `.env.template`) e pergunta chave por chave se ela vai para o cofre do espaço. Mostra o nome, o arquivo e no máximo os 4 últimos caracteres do valor. Antes de enviar, pede que você confirme no navegador, logado na sua conta, digitando o código do terminal. O valor vai cifrado para o cofre (Vault) e não aparece em tela, log nem resposta. Sem terminal, nada é enviado.
105
+
106
+ - `env list` mostra as chaves guardadas (só nomes).
107
+ - `env rm <CHAVE> [--arquivo .env.local]` apaga do cofre de vez, também com confirmação no navegador. Se um conector usava a chave, ele fica desligado.
package/dist/ask.js ADDED
@@ -0,0 +1,115 @@
1
+ import { basename, resolve } from "node:path";
2
+ import { callApi } from "./api.js";
3
+ import { loadToken, readSpaceConfig } from "./config.js";
4
+ export const ASK_USAGE = 'Uso: omnispace ask "<pergunta>" [pasta] [--fontes] [--detalhes]';
5
+ /** `ask "<pergunta>" [pasta] [--fontes] [--detalhes]`. The question must come quoted: one argument. */
6
+ export function parseAskArgs(rest, cwd = process.cwd()) {
7
+ const positional = rest.filter((a) => !a.startsWith("--"));
8
+ const unknown = rest.filter((a) => a.startsWith("--") && a !== "--fontes" && a !== "--detalhes");
9
+ if (unknown.length > 0)
10
+ return { error: `Opção desconhecida: ${unknown[0]}\n${ASK_USAGE}` };
11
+ if (positional.length === 0 || !positional[0].trim())
12
+ return { error: ASK_USAGE };
13
+ if (positional.length > 2)
14
+ return { error: `Coloque a pergunta entre aspas.\n${ASK_USAGE}` };
15
+ return {
16
+ question: positional[0].trim(),
17
+ folder: resolve(cwd, positional[1] ?? "."),
18
+ sourcesOnly: rest.includes("--fontes"),
19
+ details: rest.includes("--detalhes"),
20
+ };
21
+ }
22
+ const REASON = { index: "index.md", log: "log.md", link: "apontado no index", termo: "busca por termo" };
23
+ /** What `omnispace ask` prints, as lines. `baseVersion`: the version this folder is on. */
24
+ export function formatAsk(res, opts) {
25
+ const out = [];
26
+ const consultedList = () => {
27
+ for (const c of res.consulted) {
28
+ out.push(` [${c.id}] ${c.path} (${REASON[c.reason] ?? c.reason}, ${c.chars.toLocaleString("pt-BR")} caracteres${c.truncated ? ", em parte" : ""})`);
29
+ }
30
+ };
31
+ if (opts.sourcesOnly) {
32
+ out.push(`Arquivos que entrariam na resposta (versão ${res.version}, sem chamar o modelo):`);
33
+ if (res.consulted.length === 0)
34
+ out.push(" nenhum");
35
+ consultedList();
36
+ }
37
+ else {
38
+ out.push("Resposta:", res.answer ?? "", "");
39
+ const sources = res.sources ?? [];
40
+ if (sources.length > 0) {
41
+ out.push("Fontes:");
42
+ for (const s of sources)
43
+ out.push(` [${s.id}] ${s.path} (versão ${s.version})`);
44
+ }
45
+ else {
46
+ out.push("Fontes: nenhuma citada. Esta resposta não se apoia em arquivo do espaço.");
47
+ }
48
+ }
49
+ out.push("", "Limites:");
50
+ for (const limit of res.limits)
51
+ out.push(` - ${limit}`);
52
+ if (res.version > 0 && opts.baseVersion < res.version) {
53
+ out.push(` - Esta pasta está na versão ${opts.baseVersion}; a resposta usa a versão ${res.version} do servidor.`);
54
+ }
55
+ if (opts.details) {
56
+ const s = res.stats;
57
+ if (!opts.sourcesOnly) {
58
+ out.push("", "Consultados:");
59
+ consultedList();
60
+ }
61
+ out.push("", "Custo desta pergunta:");
62
+ out.push(` arquivos: ${s.filesUsed ?? 0} usados, ${s.filesScanned ?? 0} lidos na busca, ${s.filesInVersion ?? 0} na versão`);
63
+ out.push(` contexto: ${(s.contextChars ?? 0).toLocaleString("pt-BR")} caracteres`);
64
+ if (s.model) {
65
+ out.push(` modelo: ${s.model}, ${s.inputTokens ?? 0} tokens de entrada, ${s.outputTokens ?? 0} de saída`);
66
+ out.push(` custo estimado: US$ ${(s.costUsd ?? 0).toFixed(5)}`);
67
+ }
68
+ if (s.ms !== undefined)
69
+ out.push(` tempo: ${(s.ms / 1000).toFixed(1)} s`);
70
+ }
71
+ return out;
72
+ }
73
+ /**
74
+ * `omnispace ask`: a question about this folder's space, answered by the server from the latest
75
+ * published version. Reads nothing from the folder besides .omni/space.json, and writes nothing.
76
+ * Never retried: an answer costs tokens.
77
+ */
78
+ export async function askSpace(args) {
79
+ const config = await readSpaceConfig(args.folder);
80
+ if (!config) {
81
+ console.log(`\n${basename(args.folder)} não é um espaço Omni (não tem .omni/space.json).`);
82
+ console.log("Rode o ask de dentro da pasta de um espaço, ou passe o caminho dela.\n");
83
+ return 1;
84
+ }
85
+ if (!config.spaceId) {
86
+ console.log("\nEsta pasta foi vinculada pelo navegador, mas o espaço ainda não foi criado.\n");
87
+ return 1;
88
+ }
89
+ const token = await loadToken(config.apiUrl);
90
+ if (!token) {
91
+ console.log(`\nNenhuma sessão da CLI nesta máquina para ${config.apiUrl}.`);
92
+ console.log("Para entrar: omnispace login\n");
93
+ return 1;
94
+ }
95
+ if (!args.sourcesOnly)
96
+ console.log("\n→ Consultando a versão publicada...");
97
+ const res = await callApi(config.apiUrl, token, "/api/cli/ask", {
98
+ spaceId: config.spaceId,
99
+ question: args.question,
100
+ sourcesOnly: args.sourcesOnly,
101
+ });
102
+ if (!res.ok) {
103
+ console.log(`\nSem resposta: ${res.error}`);
104
+ if (res.expired || res.status === 401)
105
+ console.log("Para entrar de novo: omnispace login");
106
+ console.log("");
107
+ return 1;
108
+ }
109
+ console.log("");
110
+ const opts = { baseVersion: config.baseVersion, sourcesOnly: args.sourcesOnly, details: args.details };
111
+ for (const line of formatAsk(res, opts))
112
+ console.log(line);
113
+ console.log("");
114
+ return 0;
115
+ }
@@ -0,0 +1,148 @@
1
+ import { readdir, rename, rm, stat } from "node:fs/promises";
2
+ import { createInterface } from "node:readline/promises";
3
+ import { join, resolve } from "node:path";
4
+ import { callApi } from "./api.js";
5
+ import { RETRY_DELAYS_MS } from "./retry.js";
6
+ import { readJournal } from "./journal.js";
7
+ import { defaultApiUrl, loadCredential, readSpaceConfig, removeToken } from "./config.js";
8
+ import { LOCAL_BACKUPS_DIR } from "./include.js";
9
+ export async function terminalAsk(question) {
10
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
11
+ try {
12
+ return (await rl.question(question)).trim();
13
+ }
14
+ finally {
15
+ rl.close();
16
+ }
17
+ }
18
+ async function exists(path) {
19
+ try {
20
+ await stat(path);
21
+ return true;
22
+ }
23
+ catch {
24
+ return false;
25
+ }
26
+ }
27
+ async function freeName(base) {
28
+ for (let n = 1;; n++) {
29
+ const candidate = n === 1 ? base : `${base}-${n}`;
30
+ if (!(await exists(candidate)))
31
+ return candidate;
32
+ }
33
+ }
34
+ /**
35
+ * Takes the Omni out of one folder: `.omni/` goes, every other file stays. Conflict backups are
36
+ * the person's work, so a non-empty `.omni/backups/` is moved to `omni-backups-<data>` in the same
37
+ * folder first. Only touches a folder whose `.omni/space.json` is of `spaceId` (when given), so a
38
+ * stale path from the server can never clean the wrong folder.
39
+ */
40
+ export async function cleanFolder(root, spaceId, today = new Date()) {
41
+ const config = await readSpaceConfig(root);
42
+ if (!config)
43
+ return { cleaned: false, skipped: "não tem .omni/space.json" };
44
+ if (spaceId && config.spaceId !== spaceId)
45
+ return { cleaned: false, skipped: "é de outro espaço" };
46
+ let backupsMovedTo;
47
+ const backups = join(root, ...LOCAL_BACKUPS_DIR.split("/"));
48
+ if ((await exists(backups)) && (await readdir(backups)).length > 0) {
49
+ backupsMovedTo = await freeName(join(root, `omni-backups-${today.toISOString().slice(0, 10)}`));
50
+ await rename(backups, backupsMovedTo);
51
+ }
52
+ await rm(join(root, ".omni"), { recursive: true, force: true });
53
+ return { cleaned: true, backupsMovedTo };
54
+ }
55
+ /**
56
+ * `omnispace desvincular [pasta]` (P1, Lucas 2026-09-27): this machine leaves the account and the
57
+ * Omni leaves its folders. Asks twice. Works when the machine was already removed on the web
58
+ * (then only the disk is cleaned: the given folder and the CLI session).
59
+ */
60
+ export async function unlinkMachine(root, ask = process.stdin.isTTY ? terminalAsk : null) {
61
+ if (!ask) {
62
+ console.error("desvincular pede duas confirmações e precisa de um terminal. Rode direto no terminal, sem pipe.");
63
+ return 1;
64
+ }
65
+ const apiUrl = defaultApiUrl();
66
+ const credential = await loadCredential(apiUrl);
67
+ let machineName = null;
68
+ let serverKnows = false;
69
+ const folders = [];
70
+ const seen = new Set();
71
+ const addFolder = async (path, spaceId, name) => {
72
+ const abs = resolve(path);
73
+ const key = abs.toLowerCase();
74
+ if (seen.has(key))
75
+ return;
76
+ const config = await readSpaceConfig(abs);
77
+ if (!config || (spaceId && config.spaceId !== spaceId))
78
+ return;
79
+ seen.add(key);
80
+ folders.push({ root: abs, spaceId: config.spaceId ?? spaceId, name, pullInterrupted: (await readJournal(abs)) !== null });
81
+ };
82
+ if (credential) {
83
+ const res = await callApi(apiUrl, credential.token, "/api/cli/machine", {}, { retryDelaysMs: RETRY_DELAYS_MS });
84
+ if (res.ok) {
85
+ serverKnows = true;
86
+ machineName = res.name ?? null;
87
+ for (const i of res.installations) {
88
+ if (i.localPath)
89
+ await addFolder(i.localPath, i.spaceId, i.name);
90
+ }
91
+ }
92
+ else if (res.status !== 401) {
93
+ console.error(`Não deu para falar com o servidor: ${res.error}. Nada foi alterado; tente de novo.`);
94
+ return 1;
95
+ }
96
+ }
97
+ await addFolder(root, null, "esta pasta");
98
+ if (!serverKnows && !credential && folders.length === 0) {
99
+ console.log("Nada para desvincular: esta máquina não tem sessão da CLI e esta pasta não é um espaço Omni.");
100
+ return 0;
101
+ }
102
+ console.log("\nO que vai acontecer:");
103
+ if (serverKnows)
104
+ console.log(` • A máquina ${machineName ?? "desta sessão"} sai da sua conta (as versões que ela publicou ficam).`);
105
+ else if (credential)
106
+ console.log(" • A sessão da CLI já não vale no servidor (máquina removida ou sessão encerrada); só o disco é limpo.");
107
+ if (credential)
108
+ console.log(" • A sessão da CLI desta máquina é apagada (~/.omni).");
109
+ for (const f of folders) {
110
+ console.log(` • ${f.root}: sai a pasta .omni/ (${f.name}); seus arquivos ficam.`);
111
+ if (f.pullInterrupted)
112
+ console.log(" atenção: um pull parou no meio nesta pasta e não vai poder ser retomado.");
113
+ }
114
+ if (folders.length === 0)
115
+ console.log(" • Nenhuma pasta com .omni/ encontrada nesta máquina.");
116
+ console.log(" • Cópias de conflito em .omni/backups/, se houver, vão para omni-backups-<data>/ na mesma pasta.");
117
+ console.log("\nPara voltar depois: omnispace login e, dentro da pasta, omnispace vincular.\n");
118
+ const first = (await ask("Continuar? (s/n) ")).toLowerCase();
119
+ if (first !== "s" && first !== "sim") {
120
+ console.log("Nada foi alterado.");
121
+ return 1;
122
+ }
123
+ const second = await ask('Para confirmar, digite "remover": ');
124
+ if (second.toLowerCase() !== "remover") {
125
+ console.log("Confirmação não bateu. Nada foi alterado.");
126
+ return 1;
127
+ }
128
+ if (serverKnows && credential) {
129
+ // Not retried: a repeat after a cut connection would answer 401, which is handled next time.
130
+ const left = await callApi(apiUrl, credential.token, "/api/cli/machine/leave", {});
131
+ if (!left.ok && left.status !== 401) {
132
+ console.error(`O servidor não removeu a máquina: ${left.error}. Nada foi alterado no disco; tente de novo.`);
133
+ return 1;
134
+ }
135
+ }
136
+ for (const f of folders) {
137
+ const result = await cleanFolder(f.root, f.spaceId);
138
+ if (!result.cleaned)
139
+ console.log(` ${f.root}: ficou como estava (${result.skipped}).`);
140
+ else
141
+ console.log(`✓ ${f.root}: .omni/ removida${result.backupsMovedTo ? `; cópias de conflito em ${result.backupsMovedTo}` : ""}.`);
142
+ }
143
+ if (credential)
144
+ await removeToken(apiUrl);
145
+ console.log("✓ Esta máquina não está mais ligada ao Omni.");
146
+ console.log(" Para voltar: omnispace login e, dentro da pasta, omnispace vincular.\n");
147
+ return 0;
148
+ }
package/dist/dotenv.js ADDED
@@ -0,0 +1,100 @@
1
+ import { readdir, readFile, stat } from "node:fs/promises";
2
+ import { join } from "node:path";
3
+ // Leitura dos `.env` da pasta para `omnispace env push` (pós-marco C1; ADR 004). Diferente da
4
+ // varredura (scan.ts), aqui o valor é lido de propósito, porque a pessoa vai decidir chave por chave
5
+ // o que sobe para o cofre. O valor fica só na memória deste processo: nunca é impresso (só os 4
6
+ // últimos caracteres, e só de valor longo), nunca vai para arquivo nem para log.
7
+ const MAX_ENV_BYTES = 256 * 1024;
8
+ /** Same rule as the server (lib/cli-verify.ts): `.env`, `.env.local`, `.env.production.local`. */
9
+ export const ENV_FILE_RE = /^\.env(\.[A-Za-z0-9_-]{1,32}){0,3}$/;
10
+ const ENV_KEY_RE = /^[A-Za-z_][A-Za-z0-9_]{0,127}$/;
11
+ /** Files that hold placeholders, not the real values. */
12
+ const TEMPLATE_RE = /(^|\.)(example|sample|template|dist|defaults?)(\.|$)/i;
13
+ /**
14
+ * Parses a `.env` the way dotenv does for the common cases: `export KEY=v`, spaces around `=`,
15
+ * unquoted values with a trailing ` # comment`, single quotes (literal), double quotes (with \n and
16
+ * multi-line). A later line with the same key wins. Lines that are not assignments are skipped.
17
+ */
18
+ export function parseDotenv(text, file = ".env") {
19
+ const lines = text.replace(/^/, "").split(/\r?\n/);
20
+ const byKey = new Map();
21
+ for (let i = 0; i < lines.length; i++) {
22
+ const line = lines[i].trim();
23
+ if (!line || line.startsWith("#"))
24
+ continue;
25
+ const eq = line.indexOf("=");
26
+ if (eq <= 0)
27
+ continue;
28
+ const key = line.slice(0, eq).replace(/^export\s+/, "").trim();
29
+ if (!ENV_KEY_RE.test(key))
30
+ continue;
31
+ let rest = line.slice(eq + 1).trim();
32
+ let value;
33
+ const quote = rest[0];
34
+ if (quote === '"' || quote === "'" || quote === "`") {
35
+ // Multi-line quoted value: keep reading lines until the closing quote.
36
+ let body = rest.slice(1);
37
+ let close = findClosing(body, quote);
38
+ while (close < 0 && i + 1 < lines.length) {
39
+ i++;
40
+ body += `\n${lines[i]}`;
41
+ close = findClosing(body, quote);
42
+ }
43
+ if (close < 0)
44
+ continue; // never closed: not a value we can trust
45
+ value = body.slice(0, close);
46
+ if (quote === '"')
47
+ value = value.replace(/\\n/g, "\n").replace(/\\r/g, "\r").replace(/\\t/g, "\t").replace(/\\"/g, '"').replace(/\\\\/g, "\\");
48
+ }
49
+ else {
50
+ const hash = rest.search(/\s#/);
51
+ if (hash >= 0)
52
+ rest = rest.slice(0, hash);
53
+ value = rest.trim();
54
+ }
55
+ byKey.set(key, { file, key, value });
56
+ }
57
+ return [...byKey.values()];
58
+ }
59
+ function findClosing(body, quote) {
60
+ for (let i = 0; i < body.length; i++) {
61
+ if (body[i] === "\\" && quote === '"') {
62
+ i++;
63
+ continue;
64
+ }
65
+ if (body[i] === quote)
66
+ return i;
67
+ }
68
+ return -1;
69
+ }
70
+ /** The `.env` files at the root of the folder, without templates, in a stable order. */
71
+ export async function listEnvFiles(root) {
72
+ const entries = await readdir(root, { withFileTypes: true });
73
+ return entries
74
+ .filter((e) => e.isFile() && ENV_FILE_RE.test(e.name) && !TEMPLATE_RE.test(e.name.slice(4)))
75
+ .map((e) => e.name)
76
+ .sort((a, b) => (a === ".env" ? -1 : b === ".env" ? 1 : a.localeCompare(b)));
77
+ }
78
+ /** Every variable of every `.env` at the root. Empty values are skipped (nothing to keep). */
79
+ export async function readEnvVars(root) {
80
+ const vars = [];
81
+ const skipped = [];
82
+ for (const file of await listEnvFiles(root)) {
83
+ const path = join(root, file);
84
+ if ((await stat(path)).size > MAX_ENV_BYTES) {
85
+ skipped.push(file);
86
+ continue;
87
+ }
88
+ vars.push(...parseDotenv(await readFile(path, "utf8"), file).filter((v) => v.value !== ""));
89
+ }
90
+ return { vars, skipped };
91
+ }
92
+ /**
93
+ * What the terminal shows of a value: the last 4 characters, and only when the value is long enough
94
+ * for 4 characters to say nothing useful about it (ADR 004 and barrier 4).
95
+ */
96
+ export function maskValue(value) {
97
+ if (value.length < 12 || /\s/.test(value.slice(-4)))
98
+ return "(valor não mostrado)";
99
+ return `••••${value.slice(-4)}`;
100
+ }
package/dist/env.js ADDED
@@ -0,0 +1,243 @@
1
+ import { resolve } from "node:path";
2
+ import { createInterface } from "node:readline/promises";
3
+ import { callApi } from "./api.js";
4
+ import { insecureApiUrl, loadToken, readSpaceConfig } from "./config.js";
5
+ import { maskValue, readEnvVars } from "./dotenv.js";
6
+ import { openBrowser } from "./login.js";
7
+ import { RETRY_DELAYS_MS } from "./retry.js";
8
+ const STATUS_TEXT = {
9
+ active: "conectado",
10
+ active_limited: "conectado com limitação",
11
+ error: "com erro",
12
+ disabled: "desligado",
13
+ };
14
+ const USAGE = `Uso:
15
+ omnispace env push [pasta] pergunta chave por chave o que vai para o cofre do espaço
16
+ omnispace env list [pasta] mostra as chaves guardadas (só nomes)
17
+ omnispace env rm <CHAVE>... [--arquivo .env] [--pasta <pasta>] apaga do cofre, de vez`;
18
+ async function askTerminal(question) {
19
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
20
+ try {
21
+ return (await rl.question(question)).trim();
22
+ }
23
+ finally {
24
+ rl.close();
25
+ }
26
+ }
27
+ /** The space of this folder and this machine's session, or a printed reason why not. */
28
+ async function context(root) {
29
+ const config = await readSpaceConfig(root);
30
+ if (!config?.spaceId) {
31
+ console.error(config ? "O espaço desta pasta ainda não foi criado. Termine a conta no navegador." : "Esta pasta não é um espaço Omni. Rode omnispace init primeiro.");
32
+ return null;
33
+ }
34
+ // Barrier 1: credentials only travel over https, whatever the folder's config says.
35
+ const insecure = insecureApiUrl(config.apiUrl);
36
+ if (insecure) {
37
+ console.error(insecure);
38
+ return null;
39
+ }
40
+ const token = await loadToken(config.apiUrl);
41
+ if (!token) {
42
+ console.error("Nenhuma sessão da CLI nesta máquina. Para entrar: omnispace login");
43
+ return null;
44
+ }
45
+ return { apiUrl: config.apiUrl, token, spaceId: config.spaceId };
46
+ }
47
+ function refused(res, what) {
48
+ console.error(`${what}: ${res.error}`);
49
+ if (res.expired || res.status === 401)
50
+ console.error("Para entrar de novo: omnispace login");
51
+ }
52
+ /**
53
+ * Asks for the on-the-spot verification and waits for the browser. Returns the device code to send
54
+ * with the action, or null (refused, expired, failed).
55
+ */
56
+ async function verify(ctx, action, keys, opts) {
57
+ const log = opts.log ?? ((line) => console.log(line));
58
+ const start = await callApi(ctx.apiUrl, ctx.token, "/api/cli/verify/start", { spaceId: ctx.spaceId, action, keys }, { retryDelaysMs: RETRY_DELAYS_MS });
59
+ if (!start.ok) {
60
+ refused(start, "Falha ao pedir a verificação");
61
+ return null;
62
+ }
63
+ const deviceCode = start.deviceCode;
64
+ const expiresAt = Date.now() + start.expiresIn * 1000;
65
+ const waitMs = opts.pollMs ?? start.interval * 1000;
66
+ log("");
67
+ log("Confirme no navegador, logado na sua conta:");
68
+ log(` ${start.verifyUrl}`);
69
+ log("e digite este código:");
70
+ log(` ${start.userCode}`);
71
+ log("");
72
+ if (opts.openBrowser)
73
+ openBrowser(start.verifyUrl);
74
+ log("→ Esperando a confirmação (Ctrl+C cancela, nada é enviado)...");
75
+ while (Date.now() < expiresAt) {
76
+ await new Promise((r) => setTimeout(r, waitMs));
77
+ const poll = await callApi(ctx.apiUrl, ctx.token, "/api/cli/verify/poll", { deviceCode }, { retryDelaysMs: RETRY_DELAYS_MS });
78
+ if (poll.ok && poll.state === "pending")
79
+ continue;
80
+ if (poll.ok && poll.state === "approved")
81
+ return deviceCode;
82
+ console.error(poll.denied ? "Recusado no navegador. Nada foi enviado." : `Verificação não concluída: ${poll.error}`);
83
+ return null;
84
+ }
85
+ console.error("O código expirou antes da confirmação. Nada foi enviado.");
86
+ return null;
87
+ }
88
+ async function readVault(ctx) {
89
+ const res = await callApi(ctx.apiUrl, ctx.token, "/api/cli/env/list", { spaceId: ctx.spaceId }, { retryDelaysMs: RETRY_DELAYS_MS });
90
+ if (!res.ok) {
91
+ refused(res, "Falha ao ler o cofre do espaço");
92
+ return null;
93
+ }
94
+ return { keys: res.keys, connectors: (res.connectors ?? []) };
95
+ }
96
+ async function push(root, opts) {
97
+ const log = opts.log ?? ((line) => console.log(line));
98
+ const ask = opts.ask ?? askTerminal;
99
+ const interactive = opts.interactive ?? Boolean(process.stdin.isTTY);
100
+ if (!interactive) {
101
+ console.error("omnispace env push só roda num terminal, porque cada chave é decidida por você. Nada foi enviado.");
102
+ return 1;
103
+ }
104
+ const ctx = await context(root);
105
+ if (!ctx)
106
+ return 1;
107
+ const { vars, skipped } = await readEnvVars(root);
108
+ for (const file of skipped)
109
+ console.error(`${file} ficou de fora: maior que 256 KB.`);
110
+ if (vars.length === 0) {
111
+ log("Nenhuma variável com valor nos arquivos .env da raiz desta pasta. Nada a enviar.");
112
+ return 0;
113
+ }
114
+ const vault = await readVault(ctx);
115
+ if (!vault)
116
+ return 1;
117
+ const already = new Set(vault.keys.map((s) => `${s.file}\n${s.key}`));
118
+ log("");
119
+ log(`${vars.length} variáveis nos arquivos .env desta pasta. Para cada uma, responda:`);
120
+ log(" s = guardar no cofre do espaço n = pular q = parar de perguntar");
121
+ log("O valor vai cifrado para o cofre (Vault) e nunca aparece na tela nem em log.");
122
+ log("");
123
+ const accepted = [];
124
+ for (const v of vars) {
125
+ const note = already.has(`${v.file}\n${v.key}`) ? " [já está no cofre, será substituída]" : "";
126
+ const answer = (await ask(` ${v.key} (${v.file}) ${maskValue(v.value)}${note} Guardar? [s/n/q] `)).toLowerCase();
127
+ if (answer === "q")
128
+ break;
129
+ if (answer === "s" || answer === "sim" || answer === "y")
130
+ accepted.push(v);
131
+ }
132
+ if (accepted.length === 0) {
133
+ log("\nNada escolhido. Nada foi enviado.");
134
+ return 0;
135
+ }
136
+ const keys = accepted.map(({ file, key }) => ({ file, key }));
137
+ const deviceCode = await verify(ctx, "env_push", keys, opts);
138
+ if (!deviceCode)
139
+ return 1;
140
+ const res = await callApi(ctx.apiUrl, ctx.token, "/api/cli/env/store", {
141
+ spaceId: ctx.spaceId,
142
+ verification: deviceCode,
143
+ entries: accepted.map(({ file, key, value }) => ({ file, key, value })),
144
+ });
145
+ if (!res.ok) {
146
+ refused(res, "O cofre recusou o envio");
147
+ return 1;
148
+ }
149
+ log("");
150
+ for (const s of res.stored) {
151
+ log(`✓ ${s.key} (${s.file}) ${s.replaced ? "substituída" : "guardada"} no cofre`);
152
+ }
153
+ for (const c of (res.connectors ?? [])) {
154
+ log(`${c.state === "linked" ? "✓" : "!"} ${c.message}`);
155
+ if (c.test?.ok)
156
+ log(" ✓ Teste de conexão passou: o conector está ligado para o Omni neste espaço.");
157
+ else if (c.test)
158
+ log(` ✗ Teste de conexão falhou: ${c.test.message ?? "sem detalhe"}. O conector fica desligado até passar.`);
159
+ }
160
+ log("");
161
+ return 0;
162
+ }
163
+ async function list(root, opts) {
164
+ const log = opts.log ?? ((line) => console.log(line));
165
+ const ctx = await context(root);
166
+ if (!ctx)
167
+ return 1;
168
+ const vault = await readVault(ctx);
169
+ if (!vault)
170
+ return 1;
171
+ if (vault.keys.length === 0) {
172
+ log("O cofre deste espaço está vazio. Para guardar chaves do .env: omnispace env push");
173
+ }
174
+ else {
175
+ log(`${vault.keys.length} chave(s) no cofre deste espaço (só nomes, nenhum valor sai do cofre):`);
176
+ for (const s of vault.keys)
177
+ log(` ${s.key} (${s.file}) atualizada em ${new Date(s.updatedAt).toLocaleString("pt-BR")}`);
178
+ }
179
+ if (vault.connectors.length > 0) {
180
+ log("");
181
+ log("Conectores do espaço:");
182
+ for (const c of vault.connectors) {
183
+ const state = !c.hasCredential ? "sem credencial" : (STATUS_TEXT[c.status] ?? c.status);
184
+ log(` ${c.name}: ${state}${c.status === "error" && c.message ? ` (${c.message})` : ""}`);
185
+ }
186
+ }
187
+ return 0;
188
+ }
189
+ async function remove(root, names, file, opts) {
190
+ const log = opts.log ?? ((line) => console.log(line));
191
+ if (names.length === 0) {
192
+ console.error(USAGE);
193
+ return 1;
194
+ }
195
+ const ctx = await context(root);
196
+ if (!ctx)
197
+ return 1;
198
+ const vault = await readVault(ctx);
199
+ if (!vault)
200
+ return 1;
201
+ const targets = vault.keys.filter((s) => names.includes(s.key) && (!file || s.file === file)).map(({ file: f, key }) => ({ file: f, key }));
202
+ const unknown = names.filter((n) => !targets.some((t) => t.key === n));
203
+ for (const n of unknown)
204
+ console.error(`${n}${file ? ` (${file})` : ""} não está no cofre deste espaço.`);
205
+ if (targets.length === 0)
206
+ return 1;
207
+ log(`Vai apagar do cofre, de vez: ${targets.map((t) => `${t.key} (${t.file})`).join(", ")}`);
208
+ const deviceCode = await verify(ctx, "env_rm", targets, opts);
209
+ if (!deviceCode)
210
+ return 1;
211
+ const res = await callApi(ctx.apiUrl, ctx.token, "/api/cli/env/remove", { spaceId: ctx.spaceId, verification: deviceCode, keys: targets });
212
+ if (!res.ok) {
213
+ refused(res, "O cofre recusou");
214
+ return 1;
215
+ }
216
+ for (const r of res.removed)
217
+ log(`✓ ${r.key} (${r.file}) apagada do cofre`);
218
+ for (const c of (res.disconnected ?? []))
219
+ log(`! O conector ${c} usava essa credencial: a cópia dele foi apagada e ele ficou desligado.`);
220
+ return unknown.length > 0 ? 1 : 0;
221
+ }
222
+ /** `omnispace env <push|list|rm> ...`, the one entry in the dispatcher (index.ts). */
223
+ export async function runEnv(args, opts = {}) {
224
+ const [sub, ...rest] = args;
225
+ const valueOf = (flag) => {
226
+ const eq = rest.find((a) => a.startsWith(`${flag}=`));
227
+ if (eq)
228
+ return eq.slice(flag.length + 1);
229
+ const i = rest.indexOf(flag);
230
+ return i >= 0 ? rest[i + 1] : undefined;
231
+ };
232
+ // The word after `--arquivo` or `--pasta` is that flag's value, not a key name.
233
+ const flagValues = new Set(["--arquivo", "--pasta"].map((f) => rest.indexOf(f)).filter((i) => i >= 0).map((i) => rest[i + 1]));
234
+ const positional = rest.filter((a) => !a.startsWith("-") && !flagValues.has(a));
235
+ if (sub === "push")
236
+ return push(resolve(positional[0] ?? process.cwd()), opts);
237
+ if (sub === "list")
238
+ return list(resolve(positional[0] ?? process.cwd()), opts);
239
+ if (sub === "rm")
240
+ return remove(resolve(valueOf("--pasta") ?? process.cwd()), positional, valueOf("--arquivo"), opts);
241
+ console.error(USAGE);
242
+ return 1;
243
+ }
package/dist/include.js CHANGED
@@ -57,6 +57,25 @@ export function isSecret(name) {
57
57
  /^id_(rsa|ed25519|ecdsa)/.test(lower) ||
58
58
  /\.(pem|key|p12|pfx)$/.test(lower));
59
59
  }
60
+ /**
61
+ * Settings of a tool on this machine that happen to live in the folder: Claude Code keeps
62
+ * permissions and local choices in `.claude/settings.local.json` (the shared `settings.json`
63
+ * does sync). Out of the sync since 2026-09-27 (Lucas, P1 fatia 2); it can carry values
64
+ * specific to one machine.
65
+ */
66
+ const MACHINE_CONFIG_RE = /(^|\/)\.claude\/settings\.local\.json$/i;
67
+ export function isMachineConfig(path) {
68
+ return MACHINE_CONFIG_RE.test(path);
69
+ }
70
+ /**
71
+ * A path the policy keeps out by its name alone. Older versions on the server may still hold
72
+ * such a path (settings.local.json synced until 2026-09-27): pull and status drop it from the
73
+ * comparison, so it is never deleted or overwritten here because the server stopped having it.
74
+ */
75
+ export function neverSyncs(path) {
76
+ const name = path.slice(path.lastIndexOf("/") + 1);
77
+ return isSecret(name) || isMachineConfig(path) || isLocalOnly(path);
78
+ }
60
79
  export function isText(name) {
61
80
  const lower = name.toLowerCase();
62
81
  if (TEXT_NAMES.has(lower))
@@ -150,6 +169,10 @@ export async function buildManifest(root) {
150
169
  excluded.push({ path, reason: "segredo" });
151
170
  continue;
152
171
  }
172
+ if (isMachineConfig(path)) {
173
+ excluded.push({ path, reason: "configuração desta máquina" });
174
+ continue;
175
+ }
153
176
  if (!isText(entry.name)) {
154
177
  excluded.push({ path, reason: "binário ou mídia" });
155
178
  continue;
package/dist/index.js CHANGED
@@ -14,24 +14,34 @@ import { defaultApiUrl, insecureApiUrl } from "./config.js";
14
14
  import { login, logout, printWhoami, whoami } from "./login.js";
15
15
  import { initWithAccount } from "./init-account.js";
16
16
  import { statusSpace } from "./status.js";
17
- const USAGE = `
18
- Omni CLI
19
-
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
25
- omni init [pasta] --scan-only varre a pasta e mostra o que encontrou
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ê)
28
- omni sync [pasta] --push [--yes] publica uma nova versão do espaço no servidor
29
- omni sync [pasta] --pull traz para a pasta a versão mais nova do servidor
30
- [--conflito=local|mesclar|backup] mesma resposta para todos os conflitos, sem perguntar
31
- omni clone <código> <pasta> baixa um espaço da sua conta numa pasta nova desta máquina
32
-
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.
17
+ import { terminalAsk, unlinkMachine } from "./desvincular.js";
18
+ import { linkExistingSpace } from "./vincular.js";
19
+ import { askSpace, parseAskArgs } from "./ask.js";
20
+ import { runEnv } from "./env.js";
21
+ const USAGE = `
22
+ Omni CLI
23
+
24
+ Uso:
25
+ omni login entra na sua conta pelo navegador (a sessão vale 14 dias sem uso)
26
+ omni logout encerra a sessão da CLI nesta máquina
27
+ omni whoami mostra a conta, a máquina e até quando a sessão vale
28
+ omni init [pasta] [--nome <nome>] com a CLI logada, cria o espaço na sua conta e liga a pasta
29
+ omni init [pasta] --scan-only varre a pasta e mostra o que encontrou
30
+ omni init [pasta] --link <código> varre a pasta e envia pro código mostrado no navegador
31
+ omni status [pasta] [--resumo] o que mudou aqui e no servidor, e o próximo comando (só lê)
32
+ omni ask "<pergunta>" [pasta] responde com base na versão publicada do espaço, com as fontes
33
+ [--fontes] só mostra quais arquivos seriam lidos, sem chamar o modelo
34
+ [--detalhes] mostra também custo, contexto e arquivos consultados
35
+ omni sync [pasta] --push [--yes] publica uma nova versão do espaço no servidor
36
+ omni sync [pasta] --pull traz para a pasta a versão mais nova do servidor
37
+ [--conflito=local|mesclar|backup] mesma resposta para todos os conflitos, sem perguntar
38
+ omni clone <código> <pasta> baixa um espaço da sua conta numa pasta nova desta máquina
39
+ omni vincular [pasta] [--espaco <nome>] liga uma pasta que já tem os arquivos a um espaço da conta
40
+ omni desvincular [pasta] tira esta máquina da conta e o .omni/ das pastas (pede 2 confirmações)
41
+ omni env push|list|rm [pasta] guarda chaves do .env no cofre do espaço, uma a uma (confirma no navegador)
42
+
43
+ A CLI não reorganiza a sua pasta. Com --link, o que fazer com o que foi
44
+ varrido é decidido na tela de revisão, no navegador.
35
45
  `;
36
46
  async function main(argv) {
37
47
  const args = argv.slice(2);
@@ -55,8 +65,29 @@ async function main(argv) {
55
65
  return runSync(args.slice(1));
56
66
  if (command === "clone")
57
67
  return runClone(args.slice(1));
58
- if (command === "status")
59
- return statusSpace(resolve(args.slice(1).find((a) => !a.startsWith("-")) ?? process.cwd()));
68
+ if (command === "env")
69
+ return runEnv(args.slice(1), { openBrowser: process.stdout.isTTY });
70
+ if (command === "vincular") {
71
+ const rest = args.slice(1);
72
+ const espacoIndex = rest.indexOf("--espaco");
73
+ const espaco = rest.find((a) => a.startsWith("--espaco="))?.slice("--espaco=".length) ?? (espacoIndex >= 0 ? rest[espacoIndex + 1] : undefined);
74
+ const folder = rest.find((a, i) => !a.startsWith("-") && (espacoIndex < 0 || i !== espacoIndex + 1));
75
+ return linkExistingSpace(folder ?? process.cwd(), { espaco, yes: rest.includes("--yes") }, process.stdin.isTTY ? terminalAsk : null);
76
+ }
77
+ if (command === "desvincular")
78
+ return unlinkMachine(resolve(args.slice(1).find((a) => !a.startsWith("-")) ?? process.cwd()));
79
+ if (command === "status") {
80
+ const rest = args.slice(1);
81
+ return statusSpace(resolve(rest.find((a) => !a.startsWith("-")) ?? process.cwd()), { resumo: rest.includes("--resumo") });
82
+ }
83
+ if (command === "ask") {
84
+ const ask = parseAskArgs(args.slice(1));
85
+ if ("error" in ask) {
86
+ console.error(ask.error);
87
+ return 1;
88
+ }
89
+ return askSpace(ask);
90
+ }
60
91
  if (command !== "init") {
61
92
  console.error(`Comando desconhecido: ${command}`);
62
93
  console.log(USAGE.trim());
@@ -83,20 +114,20 @@ async function main(argv) {
83
114
  if (!who.ok && !who.noToken)
84
115
  console.error(`\n${who.error}`);
85
116
  const site = defaultApiUrl();
86
- console.log(`
87
- Omni CLI
88
-
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:
93
- ${site}/omnispace/escolha
94
-
95
- Lá aparece um código (tipo ABCD-2345). Com ele, dentro da pasta do espaço, rode:
96
- npx -y omnispace@latest init . --link <código>
97
-
98
- Só quer ver o que o Omni leria nesta pasta, sem enviar nada?
99
- npx -y omnispace@latest init . --scan-only
117
+ console.log(`
118
+ Omni CLI
119
+
120
+ Já tem conta no Omni? Entre por aqui e rode o init de novo:
121
+ npx -y omnispace@latest login
122
+
123
+ Primeira vez? Comece pelo navegador:
124
+ ${site}/omnispace/escolha
125
+
126
+ Lá aparece um código (tipo ABCD-2345). Com ele, dentro da pasta do espaço, rode:
127
+ npx -y omnispace@latest init . --link <código>
128
+
129
+ Só quer ver o que o Omni leria nesta pasta, sem enviar nada?
130
+ npx -y omnispace@latest init . --scan-only
100
131
  `);
101
132
  return who.ok || who.noToken ? 0 : 1;
102
133
  }
package/dist/login.js CHANGED
@@ -10,7 +10,7 @@ import { RETRY_DELAYS_MS } from "./retry.js";
10
10
  * Opens the page in the default browser. Best effort: the URL is always printed too. Only http(s)
11
11
  * URLs, and never through a shell (on Windows `cmd /c start` would parse `&` and `|` in the URL).
12
12
  */
13
- function openBrowser(url) {
13
+ export function openBrowser(url) {
14
14
  if (!/^https?:\/\//.test(url))
15
15
  return;
16
16
  const os = platform();
package/dist/pull.js CHANGED
@@ -5,7 +5,7 @@ import { homedir, platform } from "node:os";
5
5
  import { basename, dirname, join, resolve } from "node:path";
6
6
  import { callApi } from "./api.js";
7
7
  import { isRetryableStatus, RETRY_DELAYS_MS, RetryableError, withRetry } from "./retry.js";
8
- import { buildManifest, hashFile, LOCAL_BACKUPS_DIR, LOCAL_TMP_DIR } from "./include.js";
8
+ import { buildManifest, hashFile, LOCAL_BACKUPS_DIR, LOCAL_TMP_DIR, neverSyncs } from "./include.js";
9
9
  import { mergeText } from "./merge.js";
10
10
  import { addConflicts } from "./conflicts.js";
11
11
  import { ApplyError, applyJournal, clearJournal, readJournal, writeJournal } from "./journal.js";
@@ -13,6 +13,14 @@ import { withLock } from "./lock.js";
13
13
  import { pushSpace } from "./sync.js";
14
14
  import { printUnreadable } from "./report.js";
15
15
  import { defaultApiUrl, loadToken, machineHostname, readSpaceConfig, saveToken, writeSpaceConfig, } from "./config.js";
16
+ /**
17
+ * A server manifest as path → hash, minus the paths the policy keeps out. A path excluded after
18
+ * it had synced (settings.local.json, 2026-09-27) is still in old versions: left in, the pull on
19
+ * another machine would delete that machine's own copy. Used by pull and status.
20
+ */
21
+ export function remoteMap(list) {
22
+ return new Map(list.filter((f) => !neverSyncs(f.path)).map((f) => [f.path, f.hash]));
23
+ }
16
24
  /**
17
25
  * Three-way comparison per path (ADR 002): local, base (the version this folder last synced) and
18
26
  * head. Maps are path → hash; a missing path means "does not exist".
@@ -323,8 +331,8 @@ async function pullWith(root, config, token, opts, asker) {
323
331
  }
324
332
  const head = pull.head;
325
333
  const local = new Map(manifest.files.map((f) => [f.path, f.hash]));
326
- const base = new Map(pull.base.map((f) => [f.path, f.hash]));
327
- const headMap = new Map(pull.files.map((f) => [f.path, f.hash]));
334
+ const base = remoteMap(pull.base);
335
+ const headMap = remoteMap(pull.files);
328
336
  const plan = planPull(local, base, headMap);
329
337
  if (head === config.baseVersion) {
330
338
  if (head === 0)
package/dist/status.js CHANGED
@@ -4,7 +4,7 @@ import { RETRY_DELAYS_MS } from "./retry.js";
4
4
  import { buildManifest } from "./include.js";
5
5
  import { unresolvedConflicts } from "./conflicts.js";
6
6
  import { readJournal } from "./journal.js";
7
- import { planPull } from "./pull.js";
7
+ import { planPull, remoteMap } from "./pull.js";
8
8
  import { loadToken, readSpaceConfig } from "./config.js";
9
9
  /**
10
10
  * What `omnispace status` reports, from the same three-way comparison the pull uses (ADR 002).
@@ -67,33 +67,50 @@ function countKinds(changes) {
67
67
  * server does not, what the server has that the folder does not, and the next command. Read only:
68
68
  * no lock, nothing written to the folder or to `.omni/`.
69
69
  */
70
- export async function statusSpace(root) {
70
+ export async function statusSpace(root, opts = {}) {
71
+ // --resumo: one line, always exit 0. Made for the Claude Code SessionStart hook, whose output
72
+ // becomes context for the agent: it must never break the session start.
73
+ const brief = (line) => {
74
+ console.log(`Omni: ${line}`);
75
+ return 0;
76
+ };
71
77
  const config = await readSpaceConfig(root);
72
78
  if (!config) {
79
+ if (opts.resumo)
80
+ return brief("esta pasta não é um espaço Omni.");
73
81
  console.log(`\n${basename(root)} não é um espaço Omni (não tem .omni/space.json).`);
74
82
  console.log("Para transformar esta pasta num espaço: omnispace init\n");
75
83
  return 1;
76
84
  }
77
85
  if (!config.spaceId) {
86
+ if (opts.resumo)
87
+ return brief("pasta vinculada, espaço ainda não criado no navegador.");
78
88
  console.log("\nEsta pasta foi vinculada pelo navegador, mas o espaço ainda não foi criado.");
79
89
  console.log("Termine a conta no navegador e depois rode: omnispace sync --push\n");
80
90
  return 1;
81
91
  }
82
92
  const token = await loadToken(config.apiUrl);
83
93
  if (!token) {
94
+ if (opts.resumo)
95
+ return brief("CLI sem sessão nesta máquina; rode omnispace login.");
84
96
  console.log(`\nNenhuma sessão da CLI nesta máquina para ${config.apiUrl}.`);
85
97
  console.log("Para entrar: omnispace login\n");
86
98
  return 1;
87
99
  }
88
100
  const [journal, markers] = await Promise.all([readJournal(root), unresolvedConflicts(root, { readOnly: true })]);
89
- console.log("\n→ Lendo a pasta...");
101
+ if (!opts.resumo)
102
+ console.log("\n→ Lendo a pasta...");
90
103
  const manifest = await buildManifest(root);
91
104
  if (manifest.truncated) {
105
+ if (opts.resumo)
106
+ return brief("pasta grande demais para comparar.");
92
107
  console.error("Pasta grande ou funda demais para comparar (limite de 20.000 arquivos e 12 níveis).");
93
108
  return 1;
94
109
  }
95
110
  const res = await callApi(config.apiUrl, token, "/api/cli/sync/status", { spaceId: config.spaceId, baseVersion: config.baseVersion }, { retryDelaysMs: RETRY_DELAYS_MS });
96
111
  if (!res.ok) {
112
+ if (opts.resumo)
113
+ return brief(`servidor não consultado (${res.error}); pasta na versão ${config.baseVersion}.`);
97
114
  console.log(`\nServidor não consultado: ${res.error}`);
98
115
  if (res.expired || res.status === 401)
99
116
  console.log("Para entrar de novo: omnispace login");
@@ -101,13 +118,32 @@ export async function statusSpace(root) {
101
118
  return 1;
102
119
  }
103
120
  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;
121
+ // Same filter as the pull: a path the policy keeps out is not a pending change on either side.
122
+ const headMap = remoteMap(res.files);
123
+ const baseMap = res.base ? remoteMap(res.base) : headMap;
106
124
  const local = new Map(manifest.files.map((f) => [f.path, f.hash]));
107
125
  // An unreadable file is missing from the manifest; left in, it would show up as deleted here.
108
126
  const skip = (path) => manifest.unreadable.some((u) => path === u || path.startsWith(`${u}/`));
109
127
  const readable = (map) => new Map([...map].filter(([path]) => !skip(path)));
110
128
  const summary = summarizeStatus(local, readable(baseMap), readable(headMap));
129
+ const steps = nextSteps({
130
+ head,
131
+ baseVersion: config.baseVersion,
132
+ summary,
133
+ markers,
134
+ pullInterrupted: journal !== null,
135
+ });
136
+ if (opts.resumo) {
137
+ const parts = [`${res.name}: pasta na versão ${config.baseVersion}, servidor na ${head}`];
138
+ if (summary.local.length > 0)
139
+ parts.push(`${summary.local.length} mudança(s) local(is) não enviada(s)`);
140
+ if (markers.length > 0)
141
+ parts.push(`${markers.length} arquivo(s) com marcador de conflito`);
142
+ if (journal)
143
+ parts.push("pull interrompido");
144
+ parts.push(steps.length === 0 ? "em dia" : `próximo: ${steps.map((s) => s.replace(/\s+\(.*\)$/, "")).join(", depois ")}`);
145
+ return brief(`${parts.join("; ")}.`);
146
+ }
111
147
  console.log(`\nEspaço: ${res.name} (${res.slug})`);
112
148
  console.log(`Nesta pasta: ${config.baseVersion === 0 ? "nenhuma versão ainda" : `versão ${config.baseVersion}`}`);
113
149
  if (head === 0) {
@@ -138,13 +174,6 @@ export async function statusSpace(root) {
138
174
  if (manifest.unreadable.length > 0) {
139
175
  console.log(`\n${manifest.unreadable.length} arquivo(s) não puderam ser lidos (abertos em outro programa?); a comparação ignora esses.`);
140
176
  }
141
- const steps = nextSteps({
142
- head,
143
- baseVersion: config.baseVersion,
144
- summary,
145
- markers,
146
- pullInterrupted: journal !== null,
147
- });
148
177
  if (journal)
149
178
  console.log(`\nUm pull da versão ${journal.head} parou no meio.`);
150
179
  if (steps.length === 0) {
@@ -0,0 +1,107 @@
1
+ import { homedir } from "node:os";
2
+ import { resolve } from "node:path";
3
+ import { callApi } from "./api.js";
4
+ import { RETRY_DELAYS_MS } from "./retry.js";
5
+ import { defaultApiUrl, loadCredential, readSpaceConfig, writeSpaceConfig } from "./config.js";
6
+ import { whoami } from "./login.js";
7
+ import { pullSpace } from "./pull.js";
8
+ import { pushSpace } from "./sync.js";
9
+ /**
10
+ * `omnispace vincular [pasta] [--espaco <slug>]` (P1, Lucas 2026-09-27): links a folder that
11
+ * already has the files to a space of the account. The way back after `desvincular` or
12
+ * "Remover máquina", and the step after "Criar espaço" on the web. The folder starts at version 0,
13
+ * so the first pull compares file by file: equal files stay as they are, files only here stay to be
14
+ * pushed, files only on the server come down, and different ones ask (manter local, mesclar, backup).
15
+ */
16
+ export async function linkExistingSpace(target, opts, ask) {
17
+ const root = resolve(target);
18
+ if (root.toLowerCase() === resolve(homedir()).toLowerCase()) {
19
+ console.error(`Essa é a pasta do seu usuário (${root}), não a de um projeto.`);
20
+ return 1;
21
+ }
22
+ const existing = await readSpaceConfig(root);
23
+ if (existing?.spaceId) {
24
+ console.error("Esta pasta já está ligada a um espaço. Para ver o estado: omnispace status");
25
+ return 1;
26
+ }
27
+ if (existing?.sessionId) {
28
+ console.error("Esta pasta está no meio da criação pelo navegador. Termine lá antes.");
29
+ return 1;
30
+ }
31
+ const who = await whoami();
32
+ if (!who.ok || !who.me.hasAccount) {
33
+ console.error(who.ok ? "Esta máquina ainda não está numa conta." : who.error);
34
+ console.error("Para entrar: omnispace login");
35
+ return 1;
36
+ }
37
+ const apiUrl = defaultApiUrl();
38
+ const credential = await loadCredential(apiUrl);
39
+ if (!credential) {
40
+ console.error("Sem sessão da CLI. Rode omnispace login.");
41
+ return 1;
42
+ }
43
+ const list = await callApi(apiUrl, credential.token, "/api/cli/spaces/list", {}, { retryDelaysMs: RETRY_DELAYS_MS });
44
+ if (!list.ok) {
45
+ console.error(`Falha ao listar os espaços: ${list.error}`);
46
+ return 1;
47
+ }
48
+ const spaces = list.spaces;
49
+ if (spaces.length === 0) {
50
+ console.error("A conta ainda não tem espaço. Para criar um a partir desta pasta: omnispace init");
51
+ return 1;
52
+ }
53
+ let chosen;
54
+ if (opts.espaco) {
55
+ chosen = spaces.find((s) => s.slug === opts.espaco || s.name === opts.espaco);
56
+ if (!chosen) {
57
+ console.error(`Nenhum espaço "${opts.espaco}" na conta. Os que existem: ${spaces.map((s) => s.slug).join(", ")}`);
58
+ return 1;
59
+ }
60
+ }
61
+ else {
62
+ if (!ask) {
63
+ console.error(`Sem terminal para escolher. Use --espaco <nome>: ${spaces.map((s) => s.slug).join(", ")}`);
64
+ return 1;
65
+ }
66
+ console.log("\nEspaços da sua conta:");
67
+ spaces.forEach((s, i) => {
68
+ const version = s.head === 0 ? "sem versão ainda" : `versão ${s.head}`;
69
+ console.log(` ${i + 1}. ${s.name} (${s.slug}) · ${version}${s.installedHere ? " · já ligado a outra pasta desta máquina" : ""}`);
70
+ });
71
+ const answer = await ask("\nNúmero do espaço desta pasta: ");
72
+ chosen = spaces[Number(answer) - 1];
73
+ if (!chosen) {
74
+ console.log("Nenhum espaço escolhido. Nada foi alterado.");
75
+ return 1;
76
+ }
77
+ }
78
+ if (chosen.installedHere) {
79
+ console.log(`\nAtenção: ${chosen.name} já está ligado a outra pasta desta máquina. A ligação passa para esta.`);
80
+ }
81
+ if (!opts.yes) {
82
+ if (!ask) {
83
+ console.error("Sem terminal para confirmar. Rode de novo com --yes.");
84
+ return 1;
85
+ }
86
+ const ok = (await ask(`Ligar ${root} ao espaço ${chosen.name}? (s/n) `)).toLowerCase();
87
+ if (ok !== "s" && ok !== "sim") {
88
+ console.log("Nada foi alterado.");
89
+ return 1;
90
+ }
91
+ }
92
+ // Not retried: a second call would just link again, but a cut connection is better reported.
93
+ const attached = await callApi(apiUrl, credential.token, "/api/cli/spaces/attach", { spaceId: chosen.spaceId, localPath: root });
94
+ if (!attached.ok) {
95
+ console.error(`Falha ao ligar a pasta: ${attached.error}`);
96
+ return 1;
97
+ }
98
+ await writeSpaceConfig(root, { apiUrl, machineId: credential.machineId, spaceId: chosen.spaceId, baseVersion: 0 });
99
+ console.log(`✓ Pasta ligada ao espaço ${chosen.name}.`);
100
+ const head = attached.head;
101
+ if (head === 0) {
102
+ console.log("O espaço ainda não tem versão. Publicando o conteúdo desta pasta como a primeira:");
103
+ return pushSpace(root, { yes: Boolean(opts.yes) });
104
+ }
105
+ console.log(`\nComparando esta pasta com a versão ${head}: arquivos iguais ficam, os diferentes perguntam o que fazer.`);
106
+ return pullSpace(root, {});
107
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "omnispace",
3
- "version": "0.7.0",
3
+ "version": "0.11.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": {