dualisia-cli 0.1.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Dualisia Tecnologia
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,74 @@
1
+ # dualisia-cli
2
+
3
+ CLI oficial do [Dualisia](https://dualisia.com.br): apura CBS e IBS a partir de
4
+ XMLs de NF-e e consulta os dados do escritório pela API REST.
5
+
6
+ > **Status**: o pacote está pronto e testado, mas **ainda não foi publicado** em
7
+ > registro público. Publicar exige credencial de npm — ver
8
+ > "Publicação" abaixo.
9
+
10
+ ## Instalação
11
+
12
+ Enquanto não há publicação, use direto do repositório:
13
+
14
+ ```bash
15
+ node ./packages/cli/bin/dualisia.mjs --help
16
+ ```
17
+
18
+ Depois de publicado:
19
+
20
+ ```bash
21
+ npm install -g dualisia-cli
22
+ ```
23
+
24
+ ## Uso
25
+
26
+ ```bash
27
+ # Apura CBS e IBS de uma nota (público, sem chave)
28
+ dualisia apurar nota.xml --ano 2027
29
+
30
+ # Projeta a carga ano a ano
31
+ dualisia simular nota.xml --anos 2026,2029,2033
32
+
33
+ # Dados do escritório (exige API key)
34
+ export DUALISIA_API_KEY=dlai_live_xxx
35
+ dualisia clientes --limit 10
36
+ dualisia documentos --from 2026-01-01 --to 2026-12-31
37
+ ```
38
+
39
+ `--json` imprime a resposta bruta da API, útil para encadear com `jq`.
40
+
41
+ ## Variáveis de ambiente
42
+
43
+ | Variável | Para que serve |
44
+ | --- | --- |
45
+ | `DUALISIA_API_KEY` | API key da organização (`dlai_live_...`). |
46
+ | `DUALISIA_BASE_URL` | Origem da API. Padrão: `https://dualisia.com.br`. |
47
+
48
+ ## Códigos de saída
49
+
50
+ | Código | Significado |
51
+ | --- | --- |
52
+ | `0` | Sucesso. |
53
+ | `1` | Erro de uso ou erro 4xx da API. |
54
+ | `2` | Erro 5xx da API. |
55
+
56
+ ## Publicação
57
+
58
+ O pacote não tem etapa de build: publica os arquivos como estão.
59
+
60
+ ```bash
61
+ cd packages/cli
62
+ npm publish
63
+ ```
64
+
65
+ Requer um token de npm com permissão de publicação. O pacote não tem escopo,
66
+ então qualquer conta autenticada que seja dona do nome publica.
67
+
68
+ ## Documentação da API
69
+
70
+ <https://dualisia.com.br/developers> · <https://dualisia.com.br/openapi.json>
71
+
72
+ ## Licença
73
+
74
+ MIT — ver [LICENSE](./LICENSE).
@@ -0,0 +1,155 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Binário do CLI. Só faz I/O: lê argv e variáveis de ambiente, chama o núcleo
4
+ * em `src/index.mjs`, escreve em stdout/stderr e define o código de saída.
5
+ *
6
+ * Toda a lógica testável vive no núcleo — este arquivo não tem regra de
7
+ * negócio de propósito.
8
+ */
9
+ import { readFile } from "node:fs/promises";
10
+ import { basename } from "node:path";
11
+ import { createRequire } from "node:module";
12
+
13
+ import {
14
+ CliError,
15
+ COMMANDS,
16
+ USAGE,
17
+ apurar,
18
+ formatApuracao,
19
+ formatLista,
20
+ listarClientes,
21
+ listarDocumentos,
22
+ parseArgs,
23
+ simular,
24
+ } from "../src/index.mjs";
25
+
26
+ const require = createRequire(import.meta.url);
27
+ const { version } = require("../package.json");
28
+
29
+ function fail(message, hint) {
30
+ process.stderr.write(`erro: ${message}\n`);
31
+ if (hint) process.stderr.write(`${hint}\n`);
32
+ }
33
+
34
+ async function readXml(path) {
35
+ if (!path) {
36
+ throw new CliError("Informe o caminho do arquivo XML.", {
37
+ hint: "Exemplo: dualisia apurar nota.xml",
38
+ });
39
+ }
40
+ try {
41
+ const buffer = await readFile(path);
42
+ return { file: new Blob([buffer]), filename: basename(path) };
43
+ } catch {
44
+ throw new CliError(`Não foi possível ler o arquivo: ${path}`, {
45
+ hint: "Confira o caminho e a permissão de leitura.",
46
+ });
47
+ }
48
+ }
49
+
50
+ async function main(argv) {
51
+ const { command, positionals, flags } = parseArgs(argv);
52
+
53
+ if (flags.version) {
54
+ process.stdout.write(`${version}\n`);
55
+ return 0;
56
+ }
57
+
58
+ if (!command || flags.help) {
59
+ process.stdout.write(USAGE);
60
+ return command ? 0 : 1;
61
+ }
62
+
63
+ const spec = COMMANDS[command];
64
+ if (!spec) {
65
+ fail(
66
+ `Comando desconhecido: ${command}`,
67
+ "Rode `dualisia --help` para ver os comandos disponíveis.",
68
+ );
69
+ return 1;
70
+ }
71
+
72
+ const apiKey = flags["api-key"] ?? process.env.DUALISIA_API_KEY ?? null;
73
+ if (spec.needsKey && !apiKey) {
74
+ fail(
75
+ `O comando \`${command}\` exige uma API key.`,
76
+ "Defina DUALISIA_API_KEY ou passe --api-key. Gere a chave em /dashboard/settings/api-keys.",
77
+ );
78
+ return 1;
79
+ }
80
+
81
+ const baseUrl = flags["base-url"] ?? process.env.DUALISIA_BASE_URL;
82
+ const raw = flags.json === true;
83
+
84
+ let payload;
85
+
86
+ switch (command) {
87
+ case "apurar": {
88
+ const { file, filename } = await readXml(positionals[0]);
89
+ payload = await apurar({ baseUrl, file, filename, ano: flags.ano });
90
+ process.stdout.write(
91
+ (raw ? JSON.stringify(payload, null, 2) : formatApuracao(payload)) + "\n",
92
+ );
93
+ return 0;
94
+ }
95
+
96
+ case "simular": {
97
+ const { file, filename } = await readXml(positionals[0]);
98
+ payload = await simular({ baseUrl, file, filename, anos: flags.anos });
99
+ process.stdout.write(JSON.stringify(payload, null, 2) + "\n");
100
+ return 0;
101
+ }
102
+
103
+ case "clientes": {
104
+ payload = await listarClientes({
105
+ baseUrl,
106
+ apiKey,
107
+ limit: flags.limit,
108
+ offset: flags.offset,
109
+ });
110
+ process.stdout.write(
111
+ (raw
112
+ ? JSON.stringify(payload, null, 2)
113
+ : formatLista(payload, ["cnpj", "razao_social", "uf"])) + "\n",
114
+ );
115
+ return 0;
116
+ }
117
+
118
+ case "documentos": {
119
+ payload = await listarDocumentos({
120
+ baseUrl,
121
+ apiKey,
122
+ clientId: flags["client-id"],
123
+ from: flags.from,
124
+ to: flags.to,
125
+ limit: flags.limit,
126
+ offset: flags.offset,
127
+ });
128
+ process.stdout.write(
129
+ (raw
130
+ ? JSON.stringify(payload, null, 2)
131
+ : formatLista(payload, [
132
+ "chave_acesso",
133
+ "data_emissao",
134
+ "valor_total",
135
+ ])) + "\n",
136
+ );
137
+ return 0;
138
+ }
139
+
140
+ default:
141
+ return 1;
142
+ }
143
+ }
144
+
145
+ try {
146
+ process.exitCode = await main(process.argv.slice(2));
147
+ } catch (error) {
148
+ if (error instanceof CliError) {
149
+ fail(error.message, error.hint);
150
+ process.exitCode = error.exitCode;
151
+ } else {
152
+ fail(error?.message ?? String(error));
153
+ process.exitCode = 1;
154
+ }
155
+ }
package/package.json ADDED
@@ -0,0 +1,37 @@
1
+ {
2
+ "name": "dualisia-cli",
3
+ "version": "0.1.0",
4
+ "description": "CLI oficial do Dualisia: apura CBS e IBS de XMLs de NF-e e consulta os dados do escritório pela API REST.",
5
+ "keywords": [
6
+ "cbs",
7
+ "ibs",
8
+ "reforma-tributaria",
9
+ "nfe",
10
+ "fiscal",
11
+ "brasil",
12
+ "dualisia"
13
+ ],
14
+ "homepage": "https://dualisia.com.br/developers",
15
+ "bugs": "https://dualisia.com.br/contato",
16
+ "license": "MIT",
17
+ "author": "Dualisia Tecnologia",
18
+ "type": "module",
19
+ "bin": {
20
+ "dualisia": "bin/dualisia.mjs"
21
+ },
22
+ "exports": {
23
+ ".": "./src/index.mjs"
24
+ },
25
+ "files": [
26
+ "bin",
27
+ "src/index.mjs",
28
+ "README.md",
29
+ "LICENSE"
30
+ ],
31
+ "engines": {
32
+ "node": ">=20"
33
+ },
34
+ "scripts": {
35
+ "smoke": "node ./bin/dualisia.mjs --help"
36
+ }
37
+ }
package/src/index.mjs ADDED
@@ -0,0 +1,285 @@
1
+ /**
2
+ * Núcleo do CLI do Dualisia.
3
+ *
4
+ * Sem dependências e sem etapa de build: o pacote publica exatamente estes
5
+ * arquivos. Para um wrapper de quatro endpoints, um pipeline de bundle
6
+ * custaria mais manutenção do que entrega.
7
+ *
8
+ * A separação entre este módulo e `bin/dualisia.mjs` é o que torna o CLI
9
+ * testável: aqui só há funções puras e chamadas de `fetch`; process.argv,
10
+ * stdout e process.exit ficam no binário.
11
+ */
12
+
13
+ export const DEFAULT_BASE_URL = "https://dualisia.com.br";
14
+
15
+ export const USAGE = `dualisia — CLI da API do Dualisia (apuração CBS/IBS)
16
+
17
+ USO
18
+ dualisia apurar <arquivo.xml> [--ano 2027]
19
+ dualisia simular <arquivo.xml> [--anos 2026,2029,2033]
20
+ dualisia clientes [--limit 50] [--offset 0]
21
+ dualisia documentos [--client-id UUID] [--from YYYY-MM-DD] [--to YYYY-MM-DD]
22
+
23
+ COMANDOS
24
+ apurar Apura CBS e IBS de uma NF-e. Não exige API key.
25
+ simular Projeta a carga ano a ano de 2026 a 2033. Não exige API key.
26
+ clientes Lista os clientes PJ do escritório. Exige API key.
27
+ documentos Lista as NF-e já processadas. Exige API key.
28
+
29
+ OPÇÕES GLOBAIS
30
+ --api-key <chave> API key (ou variável de ambiente DUALISIA_API_KEY).
31
+ --base-url <url> Origem da API (padrão: ${DEFAULT_BASE_URL}).
32
+ --json Imprime a resposta bruta, sem formatação.
33
+ -h, --help Mostra esta ajuda.
34
+ -v, --version Mostra a versão do CLI.
35
+
36
+ EXEMPLOS
37
+ dualisia apurar nota.xml --ano 2027
38
+ DUALISIA_API_KEY=dlai_live_xxx dualisia clientes --limit 10
39
+
40
+ Documentação: ${DEFAULT_BASE_URL}/developers
41
+ `;
42
+
43
+ /** Comandos aceitos e se exigem API key. */
44
+ export const COMMANDS = {
45
+ apurar: { needsKey: false },
46
+ simular: { needsKey: false },
47
+ clientes: { needsKey: true },
48
+ documentos: { needsKey: true },
49
+ };
50
+
51
+ export class CliError extends Error {
52
+ constructor(message, { hint = null, exitCode = 1 } = {}) {
53
+ super(message);
54
+ this.name = "CliError";
55
+ this.hint = hint;
56
+ this.exitCode = exitCode;
57
+ }
58
+ }
59
+
60
+ /**
61
+ * Faz o parse de `argv` (sem `node` nem o caminho do script).
62
+ *
63
+ * Flags no formato `--nome valor` e `--nome` booleana. Um `--` encerra o
64
+ * parse de flags, e tudo depois vira posicional.
65
+ */
66
+ export function parseArgs(argv) {
67
+ const flags = {};
68
+ const positionals = [];
69
+ let onlyPositionals = false;
70
+
71
+ for (let i = 0; i < argv.length; i += 1) {
72
+ const token = argv[i];
73
+
74
+ if (onlyPositionals || !token.startsWith("-")) {
75
+ positionals.push(token);
76
+ continue;
77
+ }
78
+
79
+ if (token === "--") {
80
+ onlyPositionals = true;
81
+ continue;
82
+ }
83
+
84
+ if (token === "-h") {
85
+ flags.help = true;
86
+ continue;
87
+ }
88
+ if (token === "-v") {
89
+ flags.version = true;
90
+ continue;
91
+ }
92
+
93
+ const name = token.replace(/^--/, "");
94
+ const next = argv[i + 1];
95
+
96
+ // Booleana quando não há próximo token ou o próximo é outra flag.
97
+ if (next === undefined || next.startsWith("--")) {
98
+ flags[name] = true;
99
+ continue;
100
+ }
101
+
102
+ flags[name] = next;
103
+ i += 1;
104
+ }
105
+
106
+ return { command: positionals[0] ?? null, positionals: positionals.slice(1), flags };
107
+ }
108
+
109
+ /** Normaliza a base URL removendo a barra final. */
110
+ export function normalizeBaseUrl(url) {
111
+ const value = (url ?? DEFAULT_BASE_URL).trim();
112
+ return value.endsWith("/") ? value.slice(0, -1) : value;
113
+ }
114
+
115
+ /** Monta a URL de um endpoint com query string, omitindo valores vazios. */
116
+ export function buildUrl(baseUrl, path, query = {}) {
117
+ const url = new URL(path, normalizeBaseUrl(baseUrl) + "/");
118
+ for (const [key, value] of Object.entries(query)) {
119
+ if (value === undefined || value === null || value === "") continue;
120
+ url.searchParams.set(key, String(value));
121
+ }
122
+ return url.toString();
123
+ }
124
+
125
+ /**
126
+ * Transforma a resposta de erro da API na mensagem que o usuário vê.
127
+ *
128
+ * A API devolve `code`, `message` e `hint`; quando algum falta (proxy no
129
+ * caminho, resposta não-JSON), cai num texto genérico em vez de quebrar.
130
+ */
131
+ export function formatApiError(status, body) {
132
+ const message =
133
+ (body && (body.message || body.error)) || `Erro HTTP ${status}`;
134
+ const code = body && body.code ? ` [${body.code}]` : "";
135
+ const hint = body && body.hint ? `\n → ${body.hint}` : "";
136
+ return `${message}${code}${hint}`;
137
+ }
138
+
139
+ /** Lê a resposta como JSON, tolerando corpo vazio ou não-JSON. */
140
+ async function readJson(response) {
141
+ const text = await response.text();
142
+ if (!text) return null;
143
+ try {
144
+ return JSON.parse(text);
145
+ } catch {
146
+ return null;
147
+ }
148
+ }
149
+
150
+ /** Executa a chamada e devolve o JSON, ou lança `CliError` formatado. */
151
+ async function request(url, init, fetchImpl) {
152
+ const doFetch = fetchImpl ?? globalThis.fetch;
153
+ const response = await doFetch(url, init);
154
+ const body = await readJson(response);
155
+
156
+ if (!response.ok) {
157
+ throw new CliError(formatApiError(response.status, body), {
158
+ hint: body?.docs ? `Documentação: ${body.docs}` : null,
159
+ exitCode: response.status >= 500 ? 2 : 1,
160
+ });
161
+ }
162
+
163
+ return body;
164
+ }
165
+
166
+ /** Cabeçalho de autorização, ou `{}` quando o comando é público. */
167
+ export function authHeaders(apiKey) {
168
+ return apiKey ? { authorization: `Bearer ${apiKey}` } : {};
169
+ }
170
+
171
+ /** `POST /api/calculadora` — apura CBS e IBS de uma NF-e. */
172
+ export async function apurar({ baseUrl, file, filename, ano, fetchImpl }) {
173
+ const form = new FormData();
174
+ form.set("xml", file, filename);
175
+ if (ano) form.set("ano", String(ano));
176
+
177
+ return request(
178
+ buildUrl(baseUrl, "api/calculadora"),
179
+ { method: "POST", body: form },
180
+ fetchImpl,
181
+ );
182
+ }
183
+
184
+ /** `POST /api/simulador` — projeta a carga ano a ano. */
185
+ export async function simular({ baseUrl, file, filename, anos, fetchImpl }) {
186
+ const form = new FormData();
187
+ form.set("xml", file, filename);
188
+ if (anos) form.set("anos", String(anos));
189
+
190
+ return request(
191
+ buildUrl(baseUrl, "api/simulador"),
192
+ { method: "POST", body: form },
193
+ fetchImpl,
194
+ );
195
+ }
196
+
197
+ /** `GET /api/v1/clients` — lista os clientes do escritório. */
198
+ export async function listarClientes({
199
+ baseUrl,
200
+ apiKey,
201
+ limit,
202
+ offset,
203
+ fetchImpl,
204
+ }) {
205
+ return request(
206
+ buildUrl(baseUrl, "api/v1/clients", { limit, offset }),
207
+ { headers: authHeaders(apiKey) },
208
+ fetchImpl,
209
+ );
210
+ }
211
+
212
+ /** `GET /api/v1/documents` — lista as NF-e processadas. */
213
+ export async function listarDocumentos({
214
+ baseUrl,
215
+ apiKey,
216
+ clientId,
217
+ from,
218
+ to,
219
+ limit,
220
+ offset,
221
+ fetchImpl,
222
+ }) {
223
+ return request(
224
+ buildUrl(baseUrl, "api/v1/documents", {
225
+ client_id: clientId,
226
+ from,
227
+ to,
228
+ limit,
229
+ offset,
230
+ }),
231
+ { headers: authHeaders(apiKey) },
232
+ fetchImpl,
233
+ );
234
+ }
235
+
236
+ /** Resumo legível de uma apuração, para quem não pediu `--json`. */
237
+ export function formatApuracao(payload) {
238
+ const { nfe, resultado, anoReferencia, aliquotasEstimadas } = payload ?? {};
239
+ if (!nfe || !resultado) return JSON.stringify(payload, null, 2);
240
+
241
+ const money = (value) =>
242
+ Number(value).toLocaleString("pt-BR", {
243
+ style: "currency",
244
+ currency: "BRL",
245
+ });
246
+
247
+ const linhas = [
248
+ `Nota ${nfe.chaveAcesso}`,
249
+ ` Emitente ${nfe.emitente} (${nfe.uf})`,
250
+ ` Valor total ${money(nfe.valorTotal)}`,
251
+ ` Itens ${nfe.itensCount}`,
252
+ "",
253
+ `Ano de referência ${anoReferencia} · modo ${resultado.modo}`,
254
+ ` Regime atual ${money(resultado.regimeAtual)} (${resultado.cargaAtualPercentual}%)`,
255
+ ` Regime novo ${money(resultado.regimeNovo)} (${resultado.cargaNovaPercentual}%)`,
256
+ ];
257
+
258
+ if (aliquotasEstimadas) {
259
+ linhas.push(
260
+ "",
261
+ "Atenção: este ano usa alíquotas ESTIMADAS — dependem de Resolução do Senado ainda não publicada.",
262
+ );
263
+ }
264
+
265
+ return linhas.join("\n");
266
+ }
267
+
268
+ /** Resumo legível de uma listagem paginada. */
269
+ export function formatLista(payload, colunas) {
270
+ const { data, pagination } = payload ?? {};
271
+ if (!Array.isArray(data)) return JSON.stringify(payload, null, 2);
272
+
273
+ if (data.length === 0) return "Nenhum registro encontrado.";
274
+
275
+ const linhas = data.map((row) =>
276
+ colunas.map((col) => String(row[col] ?? "—")).join(" "),
277
+ );
278
+
279
+ linhas.push(
280
+ "",
281
+ `${data.length} de ${pagination?.total ?? "?"} registro(s) · offset ${pagination?.offset ?? 0}`,
282
+ );
283
+
284
+ return linhas.join("\n");
285
+ }