omnispace 0.12.0 → 0.13.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 +9 -0
- package/dist/ask.js +82 -8
- package/dist/env-connectors.js +88 -0
- package/dist/env.js +28 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -91,6 +91,14 @@ npx -y omnispace@latest permissoes
|
|
|
91
91
|
npx -y omnispace@latest permissoes set slack send_message bloqueado
|
|
92
92
|
```
|
|
93
93
|
|
|
94
|
+
Perguntar sobre o espaço. A resposta usa a versão publicada, com as fontes (arquivo e versão) e os limites da consulta:
|
|
95
|
+
|
|
96
|
+
```
|
|
97
|
+
npx -y omnispace@latest ask "Qual foi a última decisão deste projeto?"
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
Com conectores ligados no espaço, o agente também consulta os conectores: leitura automática roda na hora e aparece como `[C1]`, com o horário da consulta. Uma ação que pede confirmação (como criar uma task ou mandar uma mensagem) não roda sozinha: a CLI mostra o pedido inteiro e pergunta `Executar? [s/n]`. Só `s` executa, uma vez; o pedido vale 10 minutos. Sem terminal, nada é executado.
|
|
101
|
+
|
|
94
102
|
No Windows, se o caminho tiver espaço ou apóstrofo, entre na pasta com aspas duplas: `cd "C:\caminho\da\pasta"`.
|
|
95
103
|
|
|
96
104
|
## O que o Omni lê e envia
|
|
@@ -110,5 +118,6 @@ npx -y omnispace@latest env push
|
|
|
110
118
|
|
|
111
119
|
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.
|
|
112
120
|
|
|
121
|
+
- Chaves que formam um conector já ligam o conector do espaço, depois de um teste de conexão: Supabase com `<PREFIXO>_URL` e `<PREFIXO>_SERVICE_ROLE_KEY` (ou `_SERVICE_KEY`), como `SUPABASE_URL` ou `SUPABASE_TOOLS_URL`; ClickUp com o token (`CLICKUP_API_KEY` ou `CLICKUP_<NOME>_TOKEN`) e o `CLICKUP_TEAM_ID`. Se houver mais de um projeto para o mesmo conector, a CLI pergunta qual liga.
|
|
113
122
|
- `env list` mostra as chaves guardadas (só nomes).
|
|
114
123
|
- `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
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { basename, resolve } from "node:path";
|
|
2
|
+
import { createInterface } from "node:readline/promises";
|
|
2
3
|
import { callApi } from "./api.js";
|
|
3
4
|
import { loadToken, readSpaceConfig } from "./config.js";
|
|
4
5
|
export const ASK_USAGE = 'Uso: omnispace ask "<pergunta>" [pasta] [--fontes] [--detalhes]';
|
|
@@ -19,6 +20,32 @@ export function parseAskArgs(rest, cwd = process.cwd()) {
|
|
|
19
20
|
details: rest.includes("--detalhes"),
|
|
20
21
|
};
|
|
21
22
|
}
|
|
23
|
+
/**
|
|
24
|
+
* Text that came from the model or a connector, made safe for the terminal: no escape sequences or
|
|
25
|
+
* control characters (an ANSI code could hide part of what the person is about to confirm).
|
|
26
|
+
*/
|
|
27
|
+
export function terminalSafe(text) {
|
|
28
|
+
return text.replace(/\x1b\[[0-9;?]*[ -/]*[@-~]/g, "").replace(/[\x00-\x08\x0b-\x1f\x7f-\x9f]/g, "");
|
|
29
|
+
}
|
|
30
|
+
const hhmm = (iso) => {
|
|
31
|
+
const d = new Date(iso);
|
|
32
|
+
return Number.isNaN(d.getTime()) ? iso : d.toLocaleTimeString("pt-BR", { hour: "2-digit", minute: "2-digit" });
|
|
33
|
+
};
|
|
34
|
+
/** A pending request shown in full: connector, action and every field of the input, as it will run. */
|
|
35
|
+
export function formatPending(p) {
|
|
36
|
+
const out = [`Pedido ${terminalSafe(p.ref)}, aguardando sua decisão (vale até ${hhmm(p.expiresAt)}):`, ` ${terminalSafe(p.connector)}: ${terminalSafe(p.actionName)} (${terminalSafe(p.action)})`];
|
|
37
|
+
const input = p.input && typeof p.input === "object" && !Array.isArray(p.input) ? p.input : { entrada: p.input };
|
|
38
|
+
for (const [key, value] of Object.entries(input)) {
|
|
39
|
+
const text = typeof value === "string" ? value : JSON.stringify(value);
|
|
40
|
+
const lines = terminalSafe(text ?? "").split("\n");
|
|
41
|
+
out.push(` ${terminalSafe(key)}: ${lines[0]}`);
|
|
42
|
+
for (const line of lines.slice(1))
|
|
43
|
+
out.push(` ${line}`);
|
|
44
|
+
}
|
|
45
|
+
if (Object.keys(input).length === 0)
|
|
46
|
+
out.push(" (sem entrada)");
|
|
47
|
+
return out;
|
|
48
|
+
}
|
|
22
49
|
const REASON = { index: "index.md", log: "log.md", link: "apontado no index", termo: "busca por termo" };
|
|
23
50
|
/** What `omnispace ask` prints, as lines. `baseVersion`: the version this folder is on. */
|
|
24
51
|
export function formatAsk(res, opts) {
|
|
@@ -35,16 +62,24 @@ export function formatAsk(res, opts) {
|
|
|
35
62
|
consultedList();
|
|
36
63
|
}
|
|
37
64
|
else {
|
|
38
|
-
out.push("Resposta:", res.answer ?? "", "");
|
|
65
|
+
out.push("Resposta:", terminalSafe(res.answer ?? ""), "");
|
|
39
66
|
const sources = res.sources ?? [];
|
|
67
|
+
const calls = res.connectorCalls ?? [];
|
|
40
68
|
if (sources.length > 0) {
|
|
41
69
|
out.push("Fontes:");
|
|
42
70
|
for (const s of sources)
|
|
43
71
|
out.push(` [${s.id}] ${s.path} (versão ${s.version})`);
|
|
44
72
|
}
|
|
45
|
-
else {
|
|
73
|
+
else if (!calls.some((c) => c.cited)) {
|
|
46
74
|
out.push("Fontes: nenhuma citada. Esta resposta não se apoia em arquivo do espaço.");
|
|
47
75
|
}
|
|
76
|
+
if (calls.length > 0) {
|
|
77
|
+
out.push("Conectores consultados:");
|
|
78
|
+
for (const c of calls) {
|
|
79
|
+
const status = c.ok ? "" : `, falhou: ${terminalSafe(c.error ?? "sem detalhe")}`;
|
|
80
|
+
out.push(` [${c.id}] ${terminalSafe(c.connector)}, ${terminalSafe(c.actionName)} (${hhmm(c.at)}${c.cited ? "" : ", não citado"}${status})`);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
48
83
|
}
|
|
49
84
|
out.push("", "Limites:");
|
|
50
85
|
for (const limit of res.limits)
|
|
@@ -70,12 +105,50 @@ export function formatAsk(res, opts) {
|
|
|
70
105
|
}
|
|
71
106
|
return out;
|
|
72
107
|
}
|
|
108
|
+
async function askTerminal(question) {
|
|
109
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
110
|
+
try {
|
|
111
|
+
return (await rl.question(question)).trim();
|
|
112
|
+
}
|
|
113
|
+
finally {
|
|
114
|
+
rl.close();
|
|
115
|
+
}
|
|
116
|
+
}
|
|
73
117
|
/**
|
|
74
|
-
*
|
|
75
|
-
*
|
|
76
|
-
*
|
|
118
|
+
* The s/n for each pending request (C4): the person sees it in full and decides. Only "s" runs it;
|
|
119
|
+
* anything else is a refusal. Without a terminal nothing is asked and nothing runs: the request
|
|
120
|
+
* just expires. The confirmation is never retried: it runs an action.
|
|
77
121
|
*/
|
|
78
|
-
export async function
|
|
122
|
+
export async function decidePending(pending, ctx, opts) {
|
|
123
|
+
if (pending.length === 0)
|
|
124
|
+
return;
|
|
125
|
+
const interactive = opts.interactive ?? Boolean(process.stdin.isTTY);
|
|
126
|
+
for (const p of pending) {
|
|
127
|
+
for (const line of formatPending(p))
|
|
128
|
+
console.log(line);
|
|
129
|
+
if (!interactive) {
|
|
130
|
+
console.log(" Nada foi executado: sem terminal para confirmar. O pedido vence sozinho; para fazer, rode a pergunta de novo num terminal.\n");
|
|
131
|
+
continue;
|
|
132
|
+
}
|
|
133
|
+
const answer = (await (opts.ask ?? askTerminal)(" Executar? [s/n] ")).trim().toLowerCase();
|
|
134
|
+
const decision = answer === "s" || answer === "sim" ? "s" : "n";
|
|
135
|
+
const res = await callApi(ctx.apiUrl, ctx.token, "/api/cli/actions/confirm", { spaceId: ctx.spaceId, requestId: p.id, decision });
|
|
136
|
+
if (decision === "n") {
|
|
137
|
+
console.log(res.ok ? " Recusado. Nada foi executado.\n" : ` ${terminalSafe(String(res.error))}\n`);
|
|
138
|
+
continue;
|
|
139
|
+
}
|
|
140
|
+
if (res.ok) {
|
|
141
|
+
console.log(" ✓ Executado.");
|
|
142
|
+
if (typeof res.output === "string")
|
|
143
|
+
console.log(` Resultado: ${terminalSafe(res.output)}`);
|
|
144
|
+
console.log("");
|
|
145
|
+
}
|
|
146
|
+
else {
|
|
147
|
+
console.log(` ✗ Não executado: ${terminalSafe(String(res.error))}\n`);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
export async function askSpace(args, opts = {}) {
|
|
79
152
|
const config = await readSpaceConfig(args.folder);
|
|
80
153
|
if (!config) {
|
|
81
154
|
console.log(`\n${basename(args.folder)} não é um espaço Omni (não tem .omni/space.json).`);
|
|
@@ -107,9 +180,10 @@ export async function askSpace(args) {
|
|
|
107
180
|
return 1;
|
|
108
181
|
}
|
|
109
182
|
console.log("");
|
|
110
|
-
const
|
|
111
|
-
for (const line of formatAsk(
|
|
183
|
+
const answer = res;
|
|
184
|
+
for (const line of formatAsk(answer, { baseVersion: config.baseVersion, sourcesOnly: args.sourcesOnly, details: args.details }))
|
|
112
185
|
console.log(line);
|
|
113
186
|
console.log("");
|
|
187
|
+
await decidePending(answer.pending ?? [], { apiUrl: config.apiUrl, token, spaceId: config.spaceId }, opts);
|
|
114
188
|
return 0;
|
|
115
189
|
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
// Copy of apps/omni/src/lib/env-connectors.ts (C4 item 1, docs/plan/c4-ferramentas-no-ask.md): the
|
|
2
|
+
// server decides what it binds; the CLI uses the same rules only to ask, before sending, which
|
|
3
|
+
// group turns on the connector when the chosen keys have more than one. Change both together; the
|
|
4
|
+
// tests on each side use the same cases.
|
|
5
|
+
const SUPABASE_KEY_SUFFIXES = ["_SERVICE_ROLE_KEY", "_SERVICE_KEY"];
|
|
6
|
+
const CLICKUP_TOKEN = /^CLICKUP(?:_[A-Z0-9]+)*_(?:API_KEY|API_TOKEN|TOKEN)$/;
|
|
7
|
+
const CLICKUP_TEAM_KEYS = ["CLICKUP_TEAM_ID", "CLICKUP_WORKSPACE_ID"];
|
|
8
|
+
/**
|
|
9
|
+
* Supabase: `<P>_URL` with `<P>_SERVICE_ROLE_KEY` or `<P>_SERVICE_KEY`, where P has SUPABASE in it
|
|
10
|
+
* (SUPABASE, SUPABASE_TOOLS, NOVODASH_SUPABASE...). `NEXT_PUBLIC_<P>_URL` counts as `<P>_URL`, and
|
|
11
|
+
* `<P>_URL` wins when both exist. ClickUp: a token key (`CLICKUP_API_KEY`, `CLICKUP_API_TOKEN`,
|
|
12
|
+
* `CLICKUP_TOKEN`, `CLICKUP_<X>_TOKEN`) with `CLICKUP_TEAM_ID` (or `CLICKUP_WORKSPACE_ID`).
|
|
13
|
+
*/
|
|
14
|
+
export function detectEnvGroups(keyNames) {
|
|
15
|
+
const keys = new Set(keyNames);
|
|
16
|
+
const groups = [];
|
|
17
|
+
const partial = [];
|
|
18
|
+
// Supabase
|
|
19
|
+
const urlByPrefix = new Map();
|
|
20
|
+
for (const key of keys) {
|
|
21
|
+
const m = /^(?:NEXT_PUBLIC_)?(.*SUPABASE.*)_URL$/.exec(key);
|
|
22
|
+
if (!m)
|
|
23
|
+
continue;
|
|
24
|
+
const prefix = m[1];
|
|
25
|
+
const current = urlByPrefix.get(prefix);
|
|
26
|
+
if (!current || current.startsWith("NEXT_PUBLIC_"))
|
|
27
|
+
urlByPrefix.set(prefix, key);
|
|
28
|
+
}
|
|
29
|
+
for (const [prefix, urlKey] of [...urlByPrefix].sort(([a], [b]) => a.localeCompare(b))) {
|
|
30
|
+
const serviceKey = SUPABASE_KEY_SUFFIXES.map((s) => `${prefix}${s}`).find((k) => keys.has(k));
|
|
31
|
+
if (!serviceKey)
|
|
32
|
+
continue;
|
|
33
|
+
groups.push({ connectorId: "supabase", label: prefix, credential: { service_role_key: serviceKey }, config: { project_url: urlKey } });
|
|
34
|
+
}
|
|
35
|
+
if (!groups.some((g) => g.connectorId === "supabase")) {
|
|
36
|
+
const loose = [...keys]
|
|
37
|
+
.filter((k) => /SUPABASE/.test(k) && (/_URL$/.test(k) || SUPABASE_KEY_SUFFIXES.some((s) => k.endsWith(s))))
|
|
38
|
+
.sort();
|
|
39
|
+
// A public URL alone is what every Supabase front end has: not a half-done connector.
|
|
40
|
+
if (loose.some((k) => !k.startsWith("NEXT_PUBLIC_")))
|
|
41
|
+
partial.push({ connectorId: "supabase", keys: loose });
|
|
42
|
+
}
|
|
43
|
+
// ClickUp
|
|
44
|
+
const tokens = [...keys].filter((k) => CLICKUP_TOKEN.test(k)).sort();
|
|
45
|
+
const team = CLICKUP_TEAM_KEYS.find((k) => keys.has(k));
|
|
46
|
+
if (tokens.length > 0 && team) {
|
|
47
|
+
for (const token of tokens) {
|
|
48
|
+
groups.push({ connectorId: "clickup", label: token, credential: { api_key: token }, config: { team_id: team } });
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
else if (tokens.length > 0 || team) {
|
|
52
|
+
partial.push({ connectorId: "clickup", keys: [...tokens, ...(team ? [team] : [])] });
|
|
53
|
+
}
|
|
54
|
+
return { groups, partial };
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* The group that turns on each connector: the only one, or the one the person picked. `null` in
|
|
58
|
+
* `choice` means the person said none. More than one and no valid pick: ambiguous, nothing is bound.
|
|
59
|
+
*/
|
|
60
|
+
export function chooseGroups(groups, choice = {}) {
|
|
61
|
+
const chosen = [];
|
|
62
|
+
const ambiguous = [];
|
|
63
|
+
const byConnector = new Map();
|
|
64
|
+
for (const g of groups)
|
|
65
|
+
byConnector.set(g.connectorId, [...(byConnector.get(g.connectorId) ?? []), g]);
|
|
66
|
+
for (const [connectorId, list] of byConnector) {
|
|
67
|
+
if (connectorId in choice) {
|
|
68
|
+
const picked = choice[connectorId];
|
|
69
|
+
if (picked === null)
|
|
70
|
+
continue;
|
|
71
|
+
const match = list.find((g) => g.label === picked);
|
|
72
|
+
if (match)
|
|
73
|
+
chosen.push(match);
|
|
74
|
+
else
|
|
75
|
+
ambiguous.push({ connectorId, labels: list.map((g) => g.label) });
|
|
76
|
+
continue;
|
|
77
|
+
}
|
|
78
|
+
if (list.length === 1)
|
|
79
|
+
chosen.push(list[0]);
|
|
80
|
+
else
|
|
81
|
+
ambiguous.push({ connectorId, labels: list.map((g) => g.label) });
|
|
82
|
+
}
|
|
83
|
+
return { chosen, ambiguous };
|
|
84
|
+
}
|
|
85
|
+
/** A ClickUp team (workspace) id: digits only. */
|
|
86
|
+
export function isClickUpTeamId(value) {
|
|
87
|
+
return /^\d{1,20}$/.test(value.trim());
|
|
88
|
+
}
|
package/dist/env.js
CHANGED
|
@@ -3,6 +3,7 @@ import { createInterface } from "node:readline/promises";
|
|
|
3
3
|
import { callApi } from "./api.js";
|
|
4
4
|
import { insecureApiUrl, loadToken, readSpaceConfig } from "./config.js";
|
|
5
5
|
import { maskValue, readEnvVars } from "./dotenv.js";
|
|
6
|
+
import { chooseGroups, detectEnvGroups } from "./env-connectors.js";
|
|
6
7
|
import { openBrowser } from "./login.js";
|
|
7
8
|
import { RETRY_DELAYS_MS } from "./retry.js";
|
|
8
9
|
const STATUS_TEXT = {
|
|
@@ -93,6 +94,31 @@ async function readVault(ctx) {
|
|
|
93
94
|
}
|
|
94
95
|
return { keys: res.keys, connectors: (res.connectors ?? []) };
|
|
95
96
|
}
|
|
97
|
+
const CONNECTOR_NAME = { supabase: "Supabase", clickup: "ClickUp" };
|
|
98
|
+
/**
|
|
99
|
+
* When the chosen keys hold more than one set for the same connector (the Novo Dash has two Supabase
|
|
100
|
+
* projects), the person picks which one turns it on; the others stay only in the vault (C4 item 1).
|
|
101
|
+
* An answer that is not on the list counts as none: nothing is bound by guess.
|
|
102
|
+
*/
|
|
103
|
+
export async function chooseConnectorGroups(accepted, ask, log) {
|
|
104
|
+
const { groups } = detectEnvGroups(accepted.map((v) => v.key));
|
|
105
|
+
const { ambiguous } = chooseGroups(groups);
|
|
106
|
+
const connect = {};
|
|
107
|
+
for (const { connectorId, labels } of ambiguous) {
|
|
108
|
+
const name = CONNECTOR_NAME[connectorId];
|
|
109
|
+
log("");
|
|
110
|
+
log(`Há mais de um conjunto de chaves para o ${name}. Qual liga o conector ${name} deste espaço?`);
|
|
111
|
+
labels.forEach((label, i) => log(` ${i + 1}) ${label}`));
|
|
112
|
+
log(" n) nenhum (as chaves ficam só no cofre)");
|
|
113
|
+
const answer = (await ask(`Escolha [1-${labels.length}/n] `)).trim().toLowerCase();
|
|
114
|
+
const index = Number.parseInt(answer, 10);
|
|
115
|
+
const picked = /^\d+$/.test(answer) && index >= 1 && index <= labels.length ? labels[index - 1] : null;
|
|
116
|
+
if (!picked && answer !== "n")
|
|
117
|
+
log(`Resposta fora da lista: o ${name} não será ligado agora.`);
|
|
118
|
+
connect[connectorId] = picked;
|
|
119
|
+
}
|
|
120
|
+
return connect;
|
|
121
|
+
}
|
|
96
122
|
async function push(root, opts) {
|
|
97
123
|
const log = opts.log ?? ((line) => console.log(line));
|
|
98
124
|
const ask = opts.ask ?? askTerminal;
|
|
@@ -133,6 +159,7 @@ async function push(root, opts) {
|
|
|
133
159
|
log("\nNada escolhido. Nada foi enviado.");
|
|
134
160
|
return 0;
|
|
135
161
|
}
|
|
162
|
+
const connect = await chooseConnectorGroups(accepted, ask, log);
|
|
136
163
|
const keys = accepted.map(({ file, key }) => ({ file, key }));
|
|
137
164
|
const deviceCode = await verify(ctx, "env_push", keys, opts);
|
|
138
165
|
if (!deviceCode)
|
|
@@ -141,6 +168,7 @@ async function push(root, opts) {
|
|
|
141
168
|
spaceId: ctx.spaceId,
|
|
142
169
|
verification: deviceCode,
|
|
143
170
|
entries: accepted.map(({ file, key, value }) => ({ file, key, value })),
|
|
171
|
+
...(Object.keys(connect).length > 0 ? { connect } : {}),
|
|
144
172
|
});
|
|
145
173
|
if (!res.ok) {
|
|
146
174
|
refused(res, "O cofre recusou o envio");
|