omnispace 0.5.2 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +24 -3
- package/dist/config.js +45 -7
- package/dist/conflicts.js +6 -3
- package/dist/index.js +50 -13
- package/dist/init-account.js +83 -0
- package/dist/login.js +113 -0
- package/dist/status.js +159 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -2,13 +2,27 @@
|
|
|
2
2
|
|
|
3
3
|
CLI do [Omni](https://omni.tzolkin.cloud): transforma uma pasta da sua máquina em um espaço Omni e sincroniza esse espaço entre máquinas.
|
|
4
4
|
|
|
5
|
-
> Alpha. Existem `init`, `sync --push`, `sync --pull` e `clone`.
|
|
5
|
+
> Alpha. Existem `login`, `logout`, `whoami`, `init`, `status`, `sync --push`, `sync --pull` e `clone`.
|
|
6
6
|
|
|
7
7
|
Precisa do Node 20 ou mais novo.
|
|
8
8
|
|
|
9
9
|
## Uso
|
|
10
10
|
|
|
11
|
-
|
|
11
|
+
Já tem conta no Omni? Entre pelo navegador (a CLI mostra um link e um código; a senha nunca passa pelo terminal):
|
|
12
|
+
|
|
13
|
+
```
|
|
14
|
+
npx -y omnispace@latest login
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
A sessão vale 14 dias sem uso, e cada uso renova. `whoami` mostra a conta e até quando a sessão vale; `logout` encerra a sessão e apaga o token desta máquina.
|
|
18
|
+
|
|
19
|
+
Com a CLI logada, dentro da pasta, o `init` cria o espaço direto na sua conta (só pergunta o nome, que por padrão é o da pasta) e já oferece o primeiro envio:
|
|
20
|
+
|
|
21
|
+
```
|
|
22
|
+
npx -y omnispace@latest init .
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
Primeira vez no Omni? Comece pelo site: ele mostra o código de vínculo. Depois, dentro da pasta do espaço:
|
|
12
26
|
|
|
13
27
|
```
|
|
14
28
|
npx -y omnispace@latest init . --link <código>
|
|
@@ -20,6 +34,12 @@ Só ler a pasta, sem vincular nem enviar nada:
|
|
|
20
34
|
npx -y omnispace@latest init . --scan-only
|
|
21
35
|
```
|
|
22
36
|
|
|
37
|
+
Ver o que mudou nesta pasta e no servidor, e qual o próximo comando (só lê, não altera nada):
|
|
38
|
+
|
|
39
|
+
```
|
|
40
|
+
npx -y omnispace@latest status
|
|
41
|
+
```
|
|
42
|
+
|
|
23
43
|
Publicar uma nova versão do espaço no servidor:
|
|
24
44
|
|
|
25
45
|
```
|
|
@@ -54,4 +74,5 @@ No Windows, se o caminho tiver espaço ou apóstrofo, entre na pasta com aspas d
|
|
|
54
74
|
- Nunca sobem: `.env` e variações, chaves (`.pem`, `.key`, `id_rsa`), pastas `.git`, `node_modules` e de build, imagens e mídia.
|
|
55
75
|
- Arquivos `.env` são lidos só para listar os nomes das chaves; nenhum valor é guardado ou enviado.
|
|
56
76
|
- Antes de enviar, a CLI mostra o que sobe e o que fica de fora e pede confirmação.
|
|
57
|
-
- Na pasta, o Omni grava só `.omni/space.json` (e, só enquanto um sync roda, `.omni/lock`, `.omni/tmp/` e `.omni/pull-pending.json`, apagados no fim; `.omni/backups/` e `.omni/conflicts.json` quando há conflito). Se um sync for interrompido, rodar o mesmo comando de novo continua de onde parou. Nada disso sincroniza. O token de acesso fica em `~/.omni/credentials.json`, fora de qualquer projeto.
|
|
77
|
+
- Na pasta, o Omni grava só `.omni/space.json` (e, só enquanto um sync roda, `.omni/lock`, `.omni/tmp/` e `.omni/pull-pending.json`, apagados no fim; `.omni/backups/` e `.omni/conflicts.json` quando há conflito). Se um sync for interrompido, rodar o mesmo comando de novo continua de onde parou. Nada disso sincroniza. O token de acesso fica em `~/.omni/credentials.json`, fora de qualquer projeto, legível só pelo seu usuário.
|
|
78
|
+
- A CLI só fala com o servidor por HTTPS (`http` só vale para um servidor na própria máquina, em desenvolvimento).
|
package/dist/config.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
1
|
+
import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
|
|
2
2
|
import { homedir, hostname } from "node:os";
|
|
3
3
|
import { dirname, join } from "node:path";
|
|
4
4
|
// Two places, on purpose:
|
|
@@ -10,6 +10,25 @@ const DEFAULT_API_URL = "https://omni.tzolkin.cloud";
|
|
|
10
10
|
export function defaultApiUrl() {
|
|
11
11
|
return process.env.OMNI_API_URL?.replace(/\/$/, "") || DEFAULT_API_URL;
|
|
12
12
|
}
|
|
13
|
+
/**
|
|
14
|
+
* Barrier 1 of pos-marco-conectores.md: tokens (and, later, credentials) only travel over HTTPS.
|
|
15
|
+
* Plain http is accepted only for a server on this machine (development). Returns the problem, or null.
|
|
16
|
+
*/
|
|
17
|
+
export function insecureApiUrl(url = defaultApiUrl()) {
|
|
18
|
+
let parsed;
|
|
19
|
+
try {
|
|
20
|
+
parsed = new URL(url);
|
|
21
|
+
}
|
|
22
|
+
catch {
|
|
23
|
+
return `OMNI_API_URL não é um endereço válido: ${url}`;
|
|
24
|
+
}
|
|
25
|
+
if (parsed.protocol === "https:")
|
|
26
|
+
return null;
|
|
27
|
+
const local = ["localhost", "127.0.0.1", "[::1]"].includes(parsed.hostname) || parsed.hostname.endsWith(".localhost");
|
|
28
|
+
if (parsed.protocol === "http:" && local)
|
|
29
|
+
return null;
|
|
30
|
+
return `OMNI_API_URL precisa usar https (recebido ${parsed.protocol}//${parsed.host}). http só vale para um servidor nesta máquina.`;
|
|
31
|
+
}
|
|
13
32
|
// OMNI_HOME and OMNI_MACHINE_NAME exist to simulate two machines on one PC (fatia 3): each gets
|
|
14
33
|
// its own credentials and its own row in `machines` (unique per account and hostname).
|
|
15
34
|
export function machineHostname() {
|
|
@@ -29,18 +48,37 @@ async function readJson(path) {
|
|
|
29
48
|
return null;
|
|
30
49
|
}
|
|
31
50
|
}
|
|
32
|
-
|
|
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
|
-
|
|
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
|
-
|
|
77
|
+
if (!all?.[apiUrl])
|
|
78
|
+
return false;
|
|
79
|
+
delete all[apiUrl];
|
|
80
|
+
await writeJson(credentialsPath(), all, 0o600);
|
|
81
|
+
return true;
|
|
44
82
|
}
|
|
45
83
|
export function readSpaceConfig(root) {
|
|
46
84
|
return readJson(spaceConfigPath(root));
|
package/dist/conflicts.js
CHANGED
|
@@ -27,8 +27,11 @@ export async function addConflicts(root, paths) {
|
|
|
27
27
|
if (paths.length > 0)
|
|
28
28
|
await writeList(root, [...(await readList(root)), ...paths]);
|
|
29
29
|
}
|
|
30
|
-
/**
|
|
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
|
}
|
package/dist/index.js
CHANGED
|
@@ -10,20 +10,28 @@ import { cloneSpace, CONFLICT_CHOICES, pullSpace } from "./pull.js";
|
|
|
10
10
|
import { buildManifest } from "./include.js";
|
|
11
11
|
import { printSyncPreview } from "./report.js";
|
|
12
12
|
import { LockedError } from "./lock.js";
|
|
13
|
-
import { defaultApiUrl } from "./config.js";
|
|
13
|
+
import { defaultApiUrl, insecureApiUrl } from "./config.js";
|
|
14
|
+
import { login, logout, printWhoami, whoami } from "./login.js";
|
|
15
|
+
import { initWithAccount } from "./init-account.js";
|
|
16
|
+
import { statusSpace } from "./status.js";
|
|
14
17
|
const USAGE = `
|
|
15
18
|
Omni CLI
|
|
16
19
|
|
|
17
20
|
Uso:
|
|
21
|
+
omni login entra na sua conta pelo navegador (a sessão vale 14 dias sem uso)
|
|
22
|
+
omni logout encerra a sessão da CLI nesta máquina
|
|
23
|
+
omni whoami mostra a conta, a máquina e até quando a sessão vale
|
|
24
|
+
omni init [pasta] [--nome <nome>] com a CLI logada, cria o espaço na sua conta e liga a pasta
|
|
18
25
|
omni init [pasta] --scan-only varre a pasta e mostra o que encontrou
|
|
19
26
|
omni init [pasta] --link <código> varre a pasta e envia pro código mostrado no navegador
|
|
27
|
+
omni status [pasta] o que mudou aqui e no servidor, e o próximo comando (só lê)
|
|
20
28
|
omni sync [pasta] --push [--yes] publica uma nova versão do espaço no servidor
|
|
21
29
|
omni sync [pasta] --pull traz para a pasta a versão mais nova do servidor
|
|
22
30
|
[--conflito=local|mesclar|backup] mesma resposta para todos os conflitos, sem perguntar
|
|
23
31
|
omni clone <código> <pasta> baixa um espaço da sua conta numa pasta nova desta máquina
|
|
24
32
|
|
|
25
|
-
A CLI
|
|
26
|
-
é decidido na tela de revisão, no navegador.
|
|
33
|
+
A CLI não reorganiza a sua pasta. Com --link, o que fazer com o que foi
|
|
34
|
+
varrido é decidido na tela de revisão, no navegador.
|
|
27
35
|
`;
|
|
28
36
|
async function main(argv) {
|
|
29
37
|
const args = argv.slice(2);
|
|
@@ -32,10 +40,23 @@ async function main(argv) {
|
|
|
32
40
|
console.log(USAGE.trim());
|
|
33
41
|
return 0;
|
|
34
42
|
}
|
|
43
|
+
const insecure = insecureApiUrl();
|
|
44
|
+
if (insecure) {
|
|
45
|
+
console.error(insecure);
|
|
46
|
+
return 1;
|
|
47
|
+
}
|
|
48
|
+
if (command === "login")
|
|
49
|
+
return login({ openBrowser: !args.includes("--sem-navegador") && process.stdout.isTTY });
|
|
50
|
+
if (command === "logout")
|
|
51
|
+
return logout();
|
|
52
|
+
if (command === "whoami")
|
|
53
|
+
return printWhoami();
|
|
35
54
|
if (command === "sync")
|
|
36
55
|
return runSync(args.slice(1));
|
|
37
56
|
if (command === "clone")
|
|
38
57
|
return runClone(args.slice(1));
|
|
58
|
+
if (command === "status")
|
|
59
|
+
return statusSpace(resolve(args.slice(1).find((a) => !a.startsWith("-")) ?? process.cwd()));
|
|
39
60
|
if (command !== "init") {
|
|
40
61
|
console.error(`Comando desconhecido: ${command}`);
|
|
41
62
|
console.log(USAGE.trim());
|
|
@@ -44,18 +65,31 @@ async function main(argv) {
|
|
|
44
65
|
const rest = args.slice(1);
|
|
45
66
|
const linkIndex = rest.indexOf("--link");
|
|
46
67
|
const linkCode = linkIndex >= 0 ? rest[linkIndex + 1] : undefined;
|
|
47
|
-
const
|
|
68
|
+
const nameIndex = rest.indexOf("--nome");
|
|
69
|
+
const nameArg = rest.find((a) => a.startsWith("--nome="))?.slice("--nome=".length) ?? (nameIndex >= 0 ? rest[nameIndex + 1] : undefined);
|
|
70
|
+
const positional = rest.filter((a, i) => !a.startsWith("-") && (linkIndex < 0 || i !== linkIndex + 1) && (nameIndex < 0 || i !== nameIndex + 1));
|
|
48
71
|
const hasScanOnly = rest.includes("--scan-only");
|
|
49
72
|
const target = resolve(positional[0] ?? process.cwd());
|
|
50
|
-
//
|
|
51
|
-
//
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
73
|
+
// Neither mode asked: with the CLI logged in to an account, the space is created straight there
|
|
74
|
+
// (fatia 1b). Without it, a bare `npx omnispace init` (what the LP shows) points to the funnel.
|
|
75
|
+
const accountMode = !hasScanOnly && !linkCode;
|
|
76
|
+
let me = null;
|
|
77
|
+
if (accountMode) {
|
|
78
|
+
const who = await whoami();
|
|
79
|
+
if (who.ok && who.me.hasAccount) {
|
|
80
|
+
me = who.me;
|
|
81
|
+
}
|
|
82
|
+
else {
|
|
83
|
+
if (!who.ok && !who.noToken)
|
|
84
|
+
console.error(`\n${who.error}`);
|
|
85
|
+
const site = defaultApiUrl();
|
|
86
|
+
console.log(`
|
|
56
87
|
Omni CLI
|
|
57
88
|
|
|
58
|
-
|
|
89
|
+
Já tem conta no Omni? Entre por aqui e rode o init de novo:
|
|
90
|
+
npx -y omnispace@latest login
|
|
91
|
+
|
|
92
|
+
Primeira vez? Comece pelo navegador:
|
|
59
93
|
${site}/omnispace/escolha
|
|
60
94
|
|
|
61
95
|
Lá aparece um código (tipo ABCD-2345). Com ele, dentro da pasta do espaço, rode:
|
|
@@ -64,7 +98,8 @@ Lá aparece um código (tipo ABCD-2345). Com ele, dentro da pasta do espaço, ro
|
|
|
64
98
|
Só quer ver o que o Omni leria nesta pasta, sem enviar nada?
|
|
65
99
|
npx -y omnispace@latest init . --scan-only
|
|
66
100
|
`);
|
|
67
|
-
|
|
101
|
+
return who.ok || who.noToken ? 0 : 1;
|
|
102
|
+
}
|
|
68
103
|
}
|
|
69
104
|
try {
|
|
70
105
|
const info = await stat(target);
|
|
@@ -79,11 +114,13 @@ Só quer ver o que o Omni leria nesta pasta, sem enviar nada?
|
|
|
79
114
|
}
|
|
80
115
|
// Linking the user's whole home folder happened for real on 2026-09-23 (a missing cd); a space
|
|
81
116
|
// is a work folder inside it, never the home itself.
|
|
82
|
-
if (linkCode && resolve(target).toLowerCase() === resolve(homedir()).toLowerCase()) {
|
|
117
|
+
if ((linkCode || me) && resolve(target).toLowerCase() === resolve(homedir()).toLowerCase()) {
|
|
83
118
|
console.error(`Essa é a pasta do seu usuário (${target}), não a de um projeto.`);
|
|
84
119
|
console.error('Entre na pasta do espaço primeiro, com o caminho entre aspas duplas: cd "<caminho da pasta>"');
|
|
85
120
|
return 1;
|
|
86
121
|
}
|
|
122
|
+
if (me)
|
|
123
|
+
return initWithAccount(target, me, { name: nameArg, yes: rest.includes("--yes") });
|
|
87
124
|
const scan = await scanFolder(target);
|
|
88
125
|
printScanReport(scan, { linking: Boolean(linkCode) });
|
|
89
126
|
if (linkCode && scan.truncated) {
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { basename } from "node:path";
|
|
2
|
+
import { createInterface } from "node:readline/promises";
|
|
3
|
+
import { callApi } from "./api.js";
|
|
4
|
+
import { defaultApiUrl, loadToken, readSpaceConfig, writeSpaceConfig } from "./config.js";
|
|
5
|
+
import { printScanReport } from "./report.js";
|
|
6
|
+
import { scanFolder } from "./scan.js";
|
|
7
|
+
import { pushSpace } from "./sync.js";
|
|
8
|
+
async function ask(question) {
|
|
9
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
10
|
+
try {
|
|
11
|
+
return (await rl.question(question)).trim();
|
|
12
|
+
}
|
|
13
|
+
finally {
|
|
14
|
+
rl.close();
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* `omnispace init` with the CLI logged in (pós-marco, fatia 1b; decision of Lucas, 2026-09-25):
|
|
19
|
+
* the space is created straight in the account, no web funnel. Only the name is asked (the folder
|
|
20
|
+
* name is the default); icon, type and categories keep their defaults and are edited on the web.
|
|
21
|
+
* Writes only .omni/space.json in the folder, then offers the first push.
|
|
22
|
+
*/
|
|
23
|
+
export async function initWithAccount(root, me, opts) {
|
|
24
|
+
const existing = await readSpaceConfig(root);
|
|
25
|
+
if (existing?.spaceId) {
|
|
26
|
+
console.error("Esta pasta já é um espaço Omni. Para enviar mudanças: omnispace sync . --push");
|
|
27
|
+
return 1;
|
|
28
|
+
}
|
|
29
|
+
if (existing?.sessionId) {
|
|
30
|
+
console.error("Esta pasta está no meio da criação pelo navegador. Termine lá, ou apague .omni/space.json para começar de novo.");
|
|
31
|
+
return 1;
|
|
32
|
+
}
|
|
33
|
+
const scan = await scanFolder(root);
|
|
34
|
+
printScanReport(scan, { linking: true });
|
|
35
|
+
if (scan.truncated) {
|
|
36
|
+
console.error("A leitura parou antes do fim, então o espaço não foi criado. Aponte a pasta do espaço, não uma pasta acima dela.");
|
|
37
|
+
return 1;
|
|
38
|
+
}
|
|
39
|
+
const suggested = basename(root) || "Meu espaço";
|
|
40
|
+
let name = opts.name?.trim();
|
|
41
|
+
if (!name) {
|
|
42
|
+
if (opts.yes || !process.stdin.isTTY) {
|
|
43
|
+
name = suggested;
|
|
44
|
+
}
|
|
45
|
+
else {
|
|
46
|
+
name = (await ask(`Nome do espaço [${suggested}]: `)) || suggested;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
if (name.length > 80) {
|
|
50
|
+
console.error("O nome do espaço pode ter até 80 caracteres.");
|
|
51
|
+
return 1;
|
|
52
|
+
}
|
|
53
|
+
if (!opts.yes) {
|
|
54
|
+
if (!process.stdin.isTTY) {
|
|
55
|
+
console.error('Sem terminal para confirmar. Rode de novo com --yes (e --nome "<nome>" se quiser outro nome).');
|
|
56
|
+
return 1;
|
|
57
|
+
}
|
|
58
|
+
const answer = (await ask(`Criar o espaço "${name}" na conta ${me.email ?? ""} e ligar esta pasta a ele? (s/n) `)).toLowerCase();
|
|
59
|
+
if (answer !== "s" && answer !== "sim") {
|
|
60
|
+
console.log("Nada foi criado.");
|
|
61
|
+
return 1;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
const apiUrl = defaultApiUrl();
|
|
65
|
+
const token = await loadToken(apiUrl);
|
|
66
|
+
if (!token) {
|
|
67
|
+
console.error("A sessão da CLI sumiu no meio do caminho. Rode omnispace login.");
|
|
68
|
+
return 1;
|
|
69
|
+
}
|
|
70
|
+
// Not retried: creating a space twice would leave a duplicate in the account.
|
|
71
|
+
const created = await callApi(apiUrl, token, "/api/cli/spaces", { name, localPath: root, detectedStacks: scan.stacks });
|
|
72
|
+
if (!created.ok) {
|
|
73
|
+
console.error(`Falha ao criar o espaço: ${created.error}`);
|
|
74
|
+
return 1;
|
|
75
|
+
}
|
|
76
|
+
await writeSpaceConfig(root, { apiUrl, machineId: me.machineId, spaceId: created.spaceId, baseVersion: 0 });
|
|
77
|
+
console.log(`✓ Espaço "${created.name}" criado na sua conta. Vínculo gravado em .omni/space.json.`);
|
|
78
|
+
if (scan.stacks.length > 0) {
|
|
79
|
+
console.log(` ${scan.stacks.length} conector(es) preparado(s), desligado(s) até você ligar as credenciais.`);
|
|
80
|
+
}
|
|
81
|
+
console.log("\n→ Primeiro envio desta pasta para o espaço:");
|
|
82
|
+
return pushSpace(root, { yes: opts.yes });
|
|
83
|
+
}
|
package/dist/login.js
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { platform } from "node:os";
|
|
3
|
+
import { callApi } from "./api.js";
|
|
4
|
+
import { defaultApiUrl, loadCredential, machineHostname, removeToken, saveToken } from "./config.js";
|
|
5
|
+
import { RETRY_DELAYS_MS } from "./retry.js";
|
|
6
|
+
// `omnispace login`, `logout` e `whoami` (pós-marco, fatia 1b; docs/plan/pos-marco-conectores.md).
|
|
7
|
+
// Login pelo código no navegador: a senha nunca passa pelo terminal. A sessão vale 14 dias contados
|
|
8
|
+
// do último uso; parada mais que isso, o servidor recusa e pede login de novo.
|
|
9
|
+
/**
|
|
10
|
+
* Opens the page in the default browser. Best effort: the URL is always printed too. Only http(s)
|
|
11
|
+
* URLs, and never through a shell (on Windows `cmd /c start` would parse `&` and `|` in the URL).
|
|
12
|
+
*/
|
|
13
|
+
function openBrowser(url) {
|
|
14
|
+
if (!/^https?:\/\//.test(url))
|
|
15
|
+
return;
|
|
16
|
+
const os = platform();
|
|
17
|
+
const [cmd, args] = os === "win32"
|
|
18
|
+
? ["rundll32", ["url.dll,FileProtocolHandler", url]]
|
|
19
|
+
: os === "darwin"
|
|
20
|
+
? ["open", [url]]
|
|
21
|
+
: ["xdg-open", [url]];
|
|
22
|
+
try {
|
|
23
|
+
const child = spawn(cmd, args, { stdio: "ignore", detached: true, windowsHide: true });
|
|
24
|
+
child.on("error", () => { });
|
|
25
|
+
child.unref();
|
|
26
|
+
}
|
|
27
|
+
catch {
|
|
28
|
+
// No browser here (server, container): the printed URL is enough.
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
export async function login(opts = {}) {
|
|
32
|
+
const log = opts.log ?? ((line) => console.log(line));
|
|
33
|
+
const apiUrl = defaultApiUrl();
|
|
34
|
+
const previous = await loadCredential(apiUrl);
|
|
35
|
+
const start = await callApi(apiUrl, null, "/api/cli/login/start", { hostname: machineHostname(), os: platform(), machineId: previous?.machineId }, { retryDelaysMs: RETRY_DELAYS_MS });
|
|
36
|
+
if (!start.ok) {
|
|
37
|
+
console.error(`Falha ao começar o login: ${start.error}`);
|
|
38
|
+
return 1;
|
|
39
|
+
}
|
|
40
|
+
const deviceCode = start.deviceCode;
|
|
41
|
+
const userCode = start.userCode;
|
|
42
|
+
const verifyUrl = start.verifyUrl;
|
|
43
|
+
const expiresAt = Date.now() + start.expiresIn * 1000;
|
|
44
|
+
const waitMs = opts.pollMs ?? start.interval * 1000;
|
|
45
|
+
log("");
|
|
46
|
+
log("Para entrar, abra no navegador:");
|
|
47
|
+
log(` ${verifyUrl}`);
|
|
48
|
+
log("e digite este código:");
|
|
49
|
+
log(` ${userCode}`);
|
|
50
|
+
log("");
|
|
51
|
+
if (opts.openBrowser)
|
|
52
|
+
openBrowser(verifyUrl);
|
|
53
|
+
log("→ Esperando a autorização no navegador (Ctrl+C cancela)...");
|
|
54
|
+
while (Date.now() < expiresAt) {
|
|
55
|
+
await new Promise((r) => setTimeout(r, waitMs));
|
|
56
|
+
const poll = await callApi(apiUrl, null, "/api/cli/login/poll", { deviceCode }, { retryDelaysMs: RETRY_DELAYS_MS });
|
|
57
|
+
if (poll.ok && poll.state === "pending")
|
|
58
|
+
continue;
|
|
59
|
+
if (!poll.ok) {
|
|
60
|
+
console.error(poll.denied ? "Login recusado no navegador. Nada foi liberado." : `Falha no login: ${poll.error}`);
|
|
61
|
+
return 1;
|
|
62
|
+
}
|
|
63
|
+
await saveToken(apiUrl, poll.token, poll.machineId, poll.email ?? undefined);
|
|
64
|
+
log(`✓ Entrou como ${poll.email ?? "sua conta"}.`);
|
|
65
|
+
log(" A sessão vale 14 dias sem uso, e cada uso renova. Para sair: omnispace logout\n");
|
|
66
|
+
return 0;
|
|
67
|
+
}
|
|
68
|
+
console.error("O código expirou antes da autorização. Rode omnispace login de novo.");
|
|
69
|
+
return 1;
|
|
70
|
+
}
|
|
71
|
+
export async function logout() {
|
|
72
|
+
const apiUrl = defaultApiUrl();
|
|
73
|
+
const credential = await loadCredential(apiUrl);
|
|
74
|
+
if (!credential) {
|
|
75
|
+
console.log("Nenhuma sessão da CLI nesta máquina.");
|
|
76
|
+
return 0;
|
|
77
|
+
}
|
|
78
|
+
// Revoked on the server first; the local copy goes either way (an expired or revoked token is
|
|
79
|
+
// useless anyway, and keeping it would only confuse the next command).
|
|
80
|
+
const res = await callApi(apiUrl, credential.token, "/api/cli/logout", {});
|
|
81
|
+
await removeToken(apiUrl);
|
|
82
|
+
if (res.ok)
|
|
83
|
+
console.log("✓ Sessão encerrada: o token foi revogado no servidor e apagado desta máquina.");
|
|
84
|
+
else
|
|
85
|
+
console.log(`✓ Token apagado desta máquina. (O servidor respondeu: ${res.error})`);
|
|
86
|
+
return 0;
|
|
87
|
+
}
|
|
88
|
+
/** The account behind this machine's token, or the server's refusal (expired, revoked, none). */
|
|
89
|
+
export async function whoami() {
|
|
90
|
+
const apiUrl = defaultApiUrl();
|
|
91
|
+
const credential = await loadCredential(apiUrl);
|
|
92
|
+
if (!credential)
|
|
93
|
+
return { ok: false, error: "Nenhuma sessão da CLI nesta máquina.", noToken: true };
|
|
94
|
+
const res = await callApi(apiUrl, credential.token, "/api/cli/whoami", {}, { retryDelaysMs: RETRY_DELAYS_MS });
|
|
95
|
+
if (!res.ok)
|
|
96
|
+
return { ok: false, error: res.error ?? "falha", expired: Boolean(res.expired) };
|
|
97
|
+
return { ok: true, me: res };
|
|
98
|
+
}
|
|
99
|
+
export async function printWhoami() {
|
|
100
|
+
const result = await whoami();
|
|
101
|
+
if (!result.ok) {
|
|
102
|
+
console.log(result.error);
|
|
103
|
+
if (result.noToken || result.expired)
|
|
104
|
+
console.log("Para entrar: npx -y omnispace@latest login");
|
|
105
|
+
return 1;
|
|
106
|
+
}
|
|
107
|
+
const { me } = result;
|
|
108
|
+
const until = new Date(me.expiresAt).toLocaleDateString("pt-BR");
|
|
109
|
+
console.log(me.hasAccount ? `Conta: ${me.email ?? "(sem email)"}` : "Máquina vinculada pelo navegador, conta ainda não criada.");
|
|
110
|
+
console.log(`Máquina: ${me.machineName ?? me.machineId}`);
|
|
111
|
+
console.log(`Sessão: vale até ${until} se ficar sem uso (cada uso renova).`);
|
|
112
|
+
return 0;
|
|
113
|
+
}
|
package/dist/status.js
ADDED
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
import { basename } from "node:path";
|
|
2
|
+
import { callApi } from "./api.js";
|
|
3
|
+
import { RETRY_DELAYS_MS } from "./retry.js";
|
|
4
|
+
import { buildManifest } from "./include.js";
|
|
5
|
+
import { unresolvedConflicts } from "./conflicts.js";
|
|
6
|
+
import { readJournal } from "./journal.js";
|
|
7
|
+
import { planPull } from "./pull.js";
|
|
8
|
+
import { loadToken, readSpaceConfig } from "./config.js";
|
|
9
|
+
/**
|
|
10
|
+
* What `omnispace status` reports, from the same three-way comparison the pull uses (ADR 002).
|
|
11
|
+
* A path equal on both sides (same edit made twice) counts as nothing to do.
|
|
12
|
+
*/
|
|
13
|
+
export function summarizeStatus(local, base, head) {
|
|
14
|
+
const plan = planPull(local, base, head);
|
|
15
|
+
const kind = (path) => !base.has(path) ? "nova" : !local.has(path) ? "apagada" : "editada";
|
|
16
|
+
return {
|
|
17
|
+
local: [...plan.localOnly, ...plan.conflicts].sort().map((path) => ({ path, kind: kind(path) })),
|
|
18
|
+
remote: plan.writes.length + plan.deletes.length + plan.conflicts.length,
|
|
19
|
+
both: plan.conflicts,
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
/** The commands to run next, in order. Empty when the folder and the server agree. */
|
|
23
|
+
export function nextSteps(opts) {
|
|
24
|
+
const { head, baseVersion, summary, markers, pullInterrupted } = opts;
|
|
25
|
+
if (pullInterrupted)
|
|
26
|
+
return ["omnispace sync --pull (termina o pull que parou no meio)"];
|
|
27
|
+
if (markers.length > 0) {
|
|
28
|
+
return ["resolva os marcadores de conflito nos arquivos acima", "omnispace sync --push"];
|
|
29
|
+
}
|
|
30
|
+
const steps = [];
|
|
31
|
+
if (head > baseVersion) {
|
|
32
|
+
steps.push(summary.both.length > 0
|
|
33
|
+
? `omnispace sync --pull (vai pedir decisão em ${summary.both.length} arquivo(s) mudado(s) dos dois lados)`
|
|
34
|
+
: "omnispace sync --pull");
|
|
35
|
+
}
|
|
36
|
+
if (summary.local.length > 0)
|
|
37
|
+
steps.push("omnispace sync --push");
|
|
38
|
+
return steps;
|
|
39
|
+
}
|
|
40
|
+
export function timeAgo(iso, now = Date.now()) {
|
|
41
|
+
const minutes = Math.round((now - new Date(iso).getTime()) / 60000);
|
|
42
|
+
if (minutes < 1)
|
|
43
|
+
return "agora";
|
|
44
|
+
if (minutes < 60)
|
|
45
|
+
return `há ${minutes} min`;
|
|
46
|
+
const hours = Math.round(minutes / 60);
|
|
47
|
+
if (hours < 24)
|
|
48
|
+
return `há ${hours} h`;
|
|
49
|
+
return `há ${Math.round(hours / 24)} d`;
|
|
50
|
+
}
|
|
51
|
+
const KIND_MARK = { nova: "+", editada: "~", apagada: "-" };
|
|
52
|
+
function listChanges(changes, max = 20) {
|
|
53
|
+
for (const change of changes.slice(0, max))
|
|
54
|
+
console.log(` ${KIND_MARK[change.kind]} ${change.path}`);
|
|
55
|
+
if (changes.length > max)
|
|
56
|
+
console.log(` ... e mais ${changes.length - max}`);
|
|
57
|
+
}
|
|
58
|
+
function countKinds(changes) {
|
|
59
|
+
const parts = ["editada", "nova", "apagada"]
|
|
60
|
+
.map((k) => [k, changes.filter((c) => c.kind === k).length])
|
|
61
|
+
.filter(([, n]) => n > 0)
|
|
62
|
+
.map(([k, n]) => `${n} ${n === 1 ? k : `${k}s`}`);
|
|
63
|
+
return parts.join(", ");
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* `omnispace status` (P1, docs/plan/p1-continuidade-visivel.md): what this folder has that the
|
|
67
|
+
* server does not, what the server has that the folder does not, and the next command. Read only:
|
|
68
|
+
* no lock, nothing written to the folder or to `.omni/`.
|
|
69
|
+
*/
|
|
70
|
+
export async function statusSpace(root) {
|
|
71
|
+
const config = await readSpaceConfig(root);
|
|
72
|
+
if (!config) {
|
|
73
|
+
console.log(`\n${basename(root)} não é um espaço Omni (não tem .omni/space.json).`);
|
|
74
|
+
console.log("Para transformar esta pasta num espaço: omnispace init\n");
|
|
75
|
+
return 1;
|
|
76
|
+
}
|
|
77
|
+
if (!config.spaceId) {
|
|
78
|
+
console.log("\nEsta pasta foi vinculada pelo navegador, mas o espaço ainda não foi criado.");
|
|
79
|
+
console.log("Termine a conta no navegador e depois rode: omnispace sync --push\n");
|
|
80
|
+
return 1;
|
|
81
|
+
}
|
|
82
|
+
const token = await loadToken(config.apiUrl);
|
|
83
|
+
if (!token) {
|
|
84
|
+
console.log(`\nNenhuma sessão da CLI nesta máquina para ${config.apiUrl}.`);
|
|
85
|
+
console.log("Para entrar: omnispace login\n");
|
|
86
|
+
return 1;
|
|
87
|
+
}
|
|
88
|
+
const [journal, markers] = await Promise.all([readJournal(root), unresolvedConflicts(root, { readOnly: true })]);
|
|
89
|
+
console.log("\n→ Lendo a pasta...");
|
|
90
|
+
const manifest = await buildManifest(root);
|
|
91
|
+
if (manifest.truncated) {
|
|
92
|
+
console.error("Pasta grande ou funda demais para comparar (limite de 20.000 arquivos e 12 níveis).");
|
|
93
|
+
return 1;
|
|
94
|
+
}
|
|
95
|
+
const res = await callApi(config.apiUrl, token, "/api/cli/sync/status", { spaceId: config.spaceId, baseVersion: config.baseVersion }, { retryDelaysMs: RETRY_DELAYS_MS });
|
|
96
|
+
if (!res.ok) {
|
|
97
|
+
console.log(`\nServidor não consultado: ${res.error}`);
|
|
98
|
+
if (res.expired || res.status === 401)
|
|
99
|
+
console.log("Para entrar de novo: omnispace login");
|
|
100
|
+
console.log(`Esta pasta está na versão ${config.baseVersion}. Sem o servidor não dá para dizer o que mudou.\n`);
|
|
101
|
+
return 1;
|
|
102
|
+
}
|
|
103
|
+
const head = res.head;
|
|
104
|
+
const headMap = new Map(res.files.map((f) => [f.path, f.hash]));
|
|
105
|
+
const baseMap = res.base ? new Map(res.base.map((f) => [f.path, f.hash])) : headMap;
|
|
106
|
+
const local = new Map(manifest.files.map((f) => [f.path, f.hash]));
|
|
107
|
+
// An unreadable file is missing from the manifest; left in, it would show up as deleted here.
|
|
108
|
+
const skip = (path) => manifest.unreadable.some((u) => path === u || path.startsWith(`${u}/`));
|
|
109
|
+
const readable = (map) => new Map([...map].filter(([path]) => !skip(path)));
|
|
110
|
+
const summary = summarizeStatus(local, readable(baseMap), readable(headMap));
|
|
111
|
+
console.log(`\nEspaço: ${res.name} (${res.slug})`);
|
|
112
|
+
console.log(`Nesta pasta: ${config.baseVersion === 0 ? "nenhuma versão ainda" : `versão ${config.baseVersion}`}`);
|
|
113
|
+
if (head === 0) {
|
|
114
|
+
console.log("No servidor: nenhuma versão publicada ainda");
|
|
115
|
+
}
|
|
116
|
+
else {
|
|
117
|
+
const when = res.publishedAt ? `, publicada ${timeAgo(res.publishedAt)}` : "";
|
|
118
|
+
const who = res.publishedBy ? ` por ${res.publishedBy}` : "";
|
|
119
|
+
console.log(`No servidor: versão ${head}${when}${who}`);
|
|
120
|
+
}
|
|
121
|
+
if (summary.local.length > 0) {
|
|
122
|
+
console.log(`\nMudanças nesta pasta ainda não enviadas: ${summary.local.length} (${countKinds(summary.local)})`);
|
|
123
|
+
listChanges(summary.local);
|
|
124
|
+
}
|
|
125
|
+
if (head > config.baseVersion) {
|
|
126
|
+
console.log(`\nNo servidor há ${summary.remote} arquivo(s) diferente(s) da versão desta pasta.`);
|
|
127
|
+
}
|
|
128
|
+
if (summary.both.length > 0) {
|
|
129
|
+
console.log(`\nMudados dos dois lados (o pull pergunta o que fazer):`);
|
|
130
|
+
for (const path of summary.both.slice(0, 20))
|
|
131
|
+
console.log(` ${path}`);
|
|
132
|
+
}
|
|
133
|
+
if (markers.length > 0) {
|
|
134
|
+
console.log("\nAinda com marcadores de conflito (<<<<<<< esta máquina), o push recusa até serem editados:");
|
|
135
|
+
for (const path of markers)
|
|
136
|
+
console.log(` ${path}`);
|
|
137
|
+
}
|
|
138
|
+
if (manifest.unreadable.length > 0) {
|
|
139
|
+
console.log(`\n${manifest.unreadable.length} arquivo(s) não puderam ser lidos (abertos em outro programa?); a comparação ignora esses.`);
|
|
140
|
+
}
|
|
141
|
+
const steps = nextSteps({
|
|
142
|
+
head,
|
|
143
|
+
baseVersion: config.baseVersion,
|
|
144
|
+
summary,
|
|
145
|
+
markers,
|
|
146
|
+
pullInterrupted: journal !== null,
|
|
147
|
+
});
|
|
148
|
+
if (journal)
|
|
149
|
+
console.log(`\nUm pull da versão ${journal.head} parou no meio.`);
|
|
150
|
+
if (steps.length === 0) {
|
|
151
|
+
console.log(head === 0 ? "\n✓ Pasta vazia e nada publicado ainda.\n" : `\n✓ Em dia com a versão ${head}.\n`);
|
|
152
|
+
}
|
|
153
|
+
else {
|
|
154
|
+
console.log("\nPróximo passo:");
|
|
155
|
+
steps.forEach((step, i) => console.log(` ${i + 1}. ${step}`));
|
|
156
|
+
console.log("");
|
|
157
|
+
}
|
|
158
|
+
return 0;
|
|
159
|
+
}
|