omnispace 0.6.0 → 0.9.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 +26 -2
- package/dist/conflicts.js +6 -3
- package/dist/desvincular.js +148 -0
- package/dist/include.js +23 -0
- package/dist/index.js +21 -2
- package/dist/pull.js +11 -3
- package/dist/status.js +188 -0
- package/dist/vincular.js +107 -0
- package/package.json +1 -1
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`, `sync --push`, `sync --pull` e `
|
|
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
|
|
|
@@ -34,6 +34,18 @@ Só ler a pasta, sem vincular nem enviar nada:
|
|
|
34
34
|
npx -y omnispace@latest init . --scan-only
|
|
35
35
|
```
|
|
36
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
|
+
|
|
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
|
+
|
|
37
49
|
Publicar uma nova versão do espaço no servidor:
|
|
38
50
|
|
|
39
51
|
```
|
|
@@ -60,12 +72,24 @@ Baixar um espaço da sua conta em outra máquina (o código aparece no site, em
|
|
|
60
72
|
npx -y omnispace@latest clone <código> "nome-da-pasta"
|
|
61
73
|
```
|
|
62
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
|
+
|
|
63
87
|
No Windows, se o caminho tiver espaço ou apóstrofo, entre na pasta com aspas duplas: `cd "C:\caminho\da\pasta"`.
|
|
64
88
|
|
|
65
89
|
## O que o Omni lê e envia
|
|
66
90
|
|
|
67
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.
|
|
68
|
-
- Nunca sobem: `.env` e variações, chaves (`.pem`, `.key`, `id_rsa`), pastas `.git`, `node_modules` e de build, imagens e mídia.
|
|
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).
|
|
69
93
|
- Arquivos `.env` são lidos só para listar os nomes das chaves; nenhum valor é guardado ou enviado.
|
|
70
94
|
- Antes de enviar, a CLI mostra o que sobe e o que fica de fora e pede confirmação.
|
|
71
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.
|
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
|
-
/**
|
|
31
|
-
|
|
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
|
}
|
|
@@ -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/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
|
@@ -13,6 +13,9 @@ import { LockedError } from "./lock.js";
|
|
|
13
13
|
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
|
+
import { statusSpace } from "./status.js";
|
|
17
|
+
import { terminalAsk, unlinkMachine } from "./desvincular.js";
|
|
18
|
+
import { linkExistingSpace } from "./vincular.js";
|
|
16
19
|
const USAGE = `
|
|
17
20
|
Omni CLI
|
|
18
21
|
|
|
@@ -23,13 +26,16 @@ Uso:
|
|
|
23
26
|
omni init [pasta] [--nome <nome>] com a CLI logada, cria o espaço na sua conta e liga a pasta
|
|
24
27
|
omni init [pasta] --scan-only varre a pasta e mostra o que encontrou
|
|
25
28
|
omni init [pasta] --link <código> varre a pasta e envia pro código mostrado no navegador
|
|
29
|
+
omni status [pasta] [--resumo] o que mudou aqui e no servidor, e o próximo comando (só lê)
|
|
26
30
|
omni sync [pasta] --push [--yes] publica uma nova versão do espaço no servidor
|
|
27
31
|
omni sync [pasta] --pull traz para a pasta a versão mais nova do servidor
|
|
28
32
|
[--conflito=local|mesclar|backup] mesma resposta para todos os conflitos, sem perguntar
|
|
29
33
|
omni clone <código> <pasta> baixa um espaço da sua conta numa pasta nova desta máquina
|
|
34
|
+
omni vincular [pasta] [--espaco <nome>] liga uma pasta que já tem os arquivos a um espaço da conta
|
|
35
|
+
omni desvincular [pasta] tira esta máquina da conta e o .omni/ das pastas (pede 2 confirmações)
|
|
30
36
|
|
|
31
|
-
A CLI
|
|
32
|
-
é decidido na tela de revisão, no navegador.
|
|
37
|
+
A CLI não reorganiza a sua pasta. Com --link, o que fazer com o que foi
|
|
38
|
+
varrido é decidido na tela de revisão, no navegador.
|
|
33
39
|
`;
|
|
34
40
|
async function main(argv) {
|
|
35
41
|
const args = argv.slice(2);
|
|
@@ -53,6 +59,19 @@ async function main(argv) {
|
|
|
53
59
|
return runSync(args.slice(1));
|
|
54
60
|
if (command === "clone")
|
|
55
61
|
return runClone(args.slice(1));
|
|
62
|
+
if (command === "vincular") {
|
|
63
|
+
const rest = args.slice(1);
|
|
64
|
+
const espacoIndex = rest.indexOf("--espaco");
|
|
65
|
+
const espaco = rest.find((a) => a.startsWith("--espaco="))?.slice("--espaco=".length) ?? (espacoIndex >= 0 ? rest[espacoIndex + 1] : undefined);
|
|
66
|
+
const folder = rest.find((a, i) => !a.startsWith("-") && (espacoIndex < 0 || i !== espacoIndex + 1));
|
|
67
|
+
return linkExistingSpace(folder ?? process.cwd(), { espaco, yes: rest.includes("--yes") }, process.stdin.isTTY ? terminalAsk : null);
|
|
68
|
+
}
|
|
69
|
+
if (command === "desvincular")
|
|
70
|
+
return unlinkMachine(resolve(args.slice(1).find((a) => !a.startsWith("-")) ?? process.cwd()));
|
|
71
|
+
if (command === "status") {
|
|
72
|
+
const rest = args.slice(1);
|
|
73
|
+
return statusSpace(resolve(rest.find((a) => !a.startsWith("-")) ?? process.cwd()), { resumo: rest.includes("--resumo") });
|
|
74
|
+
}
|
|
56
75
|
if (command !== "init") {
|
|
57
76
|
console.error(`Comando desconhecido: ${command}`);
|
|
58
77
|
console.log(USAGE.trim());
|
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 =
|
|
327
|
-
const headMap =
|
|
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
ADDED
|
@@ -0,0 +1,188 @@
|
|
|
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, remoteMap } 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, 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
|
+
};
|
|
77
|
+
const config = await readSpaceConfig(root);
|
|
78
|
+
if (!config) {
|
|
79
|
+
if (opts.resumo)
|
|
80
|
+
return brief("esta pasta não é um espaço Omni.");
|
|
81
|
+
console.log(`\n${basename(root)} não é um espaço Omni (não tem .omni/space.json).`);
|
|
82
|
+
console.log("Para transformar esta pasta num espaço: omnispace init\n");
|
|
83
|
+
return 1;
|
|
84
|
+
}
|
|
85
|
+
if (!config.spaceId) {
|
|
86
|
+
if (opts.resumo)
|
|
87
|
+
return brief("pasta vinculada, espaço ainda não criado no navegador.");
|
|
88
|
+
console.log("\nEsta pasta foi vinculada pelo navegador, mas o espaço ainda não foi criado.");
|
|
89
|
+
console.log("Termine a conta no navegador e depois rode: omnispace sync --push\n");
|
|
90
|
+
return 1;
|
|
91
|
+
}
|
|
92
|
+
const token = await loadToken(config.apiUrl);
|
|
93
|
+
if (!token) {
|
|
94
|
+
if (opts.resumo)
|
|
95
|
+
return brief("CLI sem sessão nesta máquina; rode omnispace login.");
|
|
96
|
+
console.log(`\nNenhuma sessão da CLI nesta máquina para ${config.apiUrl}.`);
|
|
97
|
+
console.log("Para entrar: omnispace login\n");
|
|
98
|
+
return 1;
|
|
99
|
+
}
|
|
100
|
+
const [journal, markers] = await Promise.all([readJournal(root), unresolvedConflicts(root, { readOnly: true })]);
|
|
101
|
+
if (!opts.resumo)
|
|
102
|
+
console.log("\n→ Lendo a pasta...");
|
|
103
|
+
const manifest = await buildManifest(root);
|
|
104
|
+
if (manifest.truncated) {
|
|
105
|
+
if (opts.resumo)
|
|
106
|
+
return brief("pasta grande demais para comparar.");
|
|
107
|
+
console.error("Pasta grande ou funda demais para comparar (limite de 20.000 arquivos e 12 níveis).");
|
|
108
|
+
return 1;
|
|
109
|
+
}
|
|
110
|
+
const res = await callApi(config.apiUrl, token, "/api/cli/sync/status", { spaceId: config.spaceId, baseVersion: config.baseVersion }, { retryDelaysMs: RETRY_DELAYS_MS });
|
|
111
|
+
if (!res.ok) {
|
|
112
|
+
if (opts.resumo)
|
|
113
|
+
return brief(`servidor não consultado (${res.error}); pasta na versão ${config.baseVersion}.`);
|
|
114
|
+
console.log(`\nServidor não consultado: ${res.error}`);
|
|
115
|
+
if (res.expired || res.status === 401)
|
|
116
|
+
console.log("Para entrar de novo: omnispace login");
|
|
117
|
+
console.log(`Esta pasta está na versão ${config.baseVersion}. Sem o servidor não dá para dizer o que mudou.\n`);
|
|
118
|
+
return 1;
|
|
119
|
+
}
|
|
120
|
+
const head = res.head;
|
|
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;
|
|
124
|
+
const local = new Map(manifest.files.map((f) => [f.path, f.hash]));
|
|
125
|
+
// An unreadable file is missing from the manifest; left in, it would show up as deleted here.
|
|
126
|
+
const skip = (path) => manifest.unreadable.some((u) => path === u || path.startsWith(`${u}/`));
|
|
127
|
+
const readable = (map) => new Map([...map].filter(([path]) => !skip(path)));
|
|
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
|
+
}
|
|
147
|
+
console.log(`\nEspaço: ${res.name} (${res.slug})`);
|
|
148
|
+
console.log(`Nesta pasta: ${config.baseVersion === 0 ? "nenhuma versão ainda" : `versão ${config.baseVersion}`}`);
|
|
149
|
+
if (head === 0) {
|
|
150
|
+
console.log("No servidor: nenhuma versão publicada ainda");
|
|
151
|
+
}
|
|
152
|
+
else {
|
|
153
|
+
const when = res.publishedAt ? `, publicada ${timeAgo(res.publishedAt)}` : "";
|
|
154
|
+
const who = res.publishedBy ? ` por ${res.publishedBy}` : "";
|
|
155
|
+
console.log(`No servidor: versão ${head}${when}${who}`);
|
|
156
|
+
}
|
|
157
|
+
if (summary.local.length > 0) {
|
|
158
|
+
console.log(`\nMudanças nesta pasta ainda não enviadas: ${summary.local.length} (${countKinds(summary.local)})`);
|
|
159
|
+
listChanges(summary.local);
|
|
160
|
+
}
|
|
161
|
+
if (head > config.baseVersion) {
|
|
162
|
+
console.log(`\nNo servidor há ${summary.remote} arquivo(s) diferente(s) da versão desta pasta.`);
|
|
163
|
+
}
|
|
164
|
+
if (summary.both.length > 0) {
|
|
165
|
+
console.log(`\nMudados dos dois lados (o pull pergunta o que fazer):`);
|
|
166
|
+
for (const path of summary.both.slice(0, 20))
|
|
167
|
+
console.log(` ${path}`);
|
|
168
|
+
}
|
|
169
|
+
if (markers.length > 0) {
|
|
170
|
+
console.log("\nAinda com marcadores de conflito (<<<<<<< esta máquina), o push recusa até serem editados:");
|
|
171
|
+
for (const path of markers)
|
|
172
|
+
console.log(` ${path}`);
|
|
173
|
+
}
|
|
174
|
+
if (manifest.unreadable.length > 0) {
|
|
175
|
+
console.log(`\n${manifest.unreadable.length} arquivo(s) não puderam ser lidos (abertos em outro programa?); a comparação ignora esses.`);
|
|
176
|
+
}
|
|
177
|
+
if (journal)
|
|
178
|
+
console.log(`\nUm pull da versão ${journal.head} parou no meio.`);
|
|
179
|
+
if (steps.length === 0) {
|
|
180
|
+
console.log(head === 0 ? "\n✓ Pasta vazia e nada publicado ainda.\n" : `\n✓ Em dia com a versão ${head}.\n`);
|
|
181
|
+
}
|
|
182
|
+
else {
|
|
183
|
+
console.log("\nPróximo passo:");
|
|
184
|
+
steps.forEach((step, i) => console.log(` ${i + 1}. ${step}`));
|
|
185
|
+
console.log("");
|
|
186
|
+
}
|
|
187
|
+
return 0;
|
|
188
|
+
}
|
package/dist/vincular.js
ADDED
|
@@ -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
|
+
}
|