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 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
- * `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.
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 askSpace(args) {
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 opts = { baseVersion: config.baseVersion, sourcesOnly: args.sourcesOnly, details: args.details };
111
- for (const line of formatAsk(res, opts))
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");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "omnispace",
3
- "version": "0.12.0",
3
+ "version": "0.13.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": {