@ascdong/nexus 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/README.md ADDED
@@ -0,0 +1,181 @@
1
+ # nexus
2
+
3
+ Unified Azure data access CLI for **Log Analytics**, **Kusto** (Azure Data Explorer), and **Azure SQL Database**. One command surface, named connectors, and Agent-friendly output.
4
+
5
+ ## Why
6
+
7
+ `nexus` connects multiple Azure data resources through named *connectors* and exposes a single way to query them and inspect their schema. It is built primarily for **Agent consumption** (stable parsing, token-efficient output) while staying usable by humans.
8
+
9
+ ## Install
10
+
11
+ End users install from npm — no Bun required, it runs on Node:
12
+
13
+ ```bash
14
+ npm install -g nexus # or: npm install nexus
15
+ ```
16
+
17
+ Requires Node 20+.
18
+
19
+ ## Authentication
20
+
21
+ `nexus` authenticates to Azure with **Microsoft Entra ID (AAD)** via `DefaultAzureCredential`. Before running queries, sign in with one of the standard mechanisms it understands:
22
+
23
+ ```bash
24
+ az login # most common for local use
25
+ ```
26
+
27
+ `DefaultAzureCredential` also picks up environment variables, Managed Identity, and VS Code sign-in automatically.
28
+
29
+ ### Token cache
30
+
31
+ Acquiring an AAD token cold is slow (~2–3s, because `DefaultAzureCredential` spawns the `az` CLI). To avoid paying that on every command, `nexus` caches the acquired token to `~/.nexus/token-cache.json` (file mode `0600`), keyed by scope, and refreshes it 1 minute before expiry. The first query in ~an hour is cold; subsequent queries reuse the cached token (token lookup drops from ~2s to ~0ms).
32
+
33
+ > **Security note:** unlike `config.json`, the token cache **does contain a live access token** — anyone who can read the file can act as you against those resources until the token expires (typically ~1 hour). It is written `0600` (owner-only) and never committed to git. Clear it any time with:
34
+
35
+ ```bash
36
+ nexus auth clear-cache
37
+ ```
38
+
39
+ The query round-trip and process startup are not affected by the cache; only token acquisition is.
40
+
41
+ ## Connectors
42
+
43
+ Connectors are named aliases stored in `~/.nexus/config.json` (override the directory with `NEXUS_CONFIG_DIR`). The config holds **connection coordinates only — never tokens or secrets**.
44
+
45
+ ```bash
46
+ # Log Analytics
47
+ nexus connector add prod-logs --type log-analytics --workspace-id <workspace-guid>
48
+
49
+ # Kusto / Azure Data Explorer
50
+ nexus connector add telemetry --type kusto \
51
+ --cluster-uri https://help.kusto.windows.net --database Samples
52
+
53
+ # Azure SQL Database
54
+ nexus connector add warehouse --type azure-sql \
55
+ --server myserver.database.windows.net --database Sales
56
+
57
+ # Storage Account (Table + Blob + Queue)
58
+ nexus connector add mydata --type storage-account --account <storageacct>
59
+
60
+ nexus connector list # list all connectors (JSON)
61
+ nexus connector test prod-logs # verify connectivity
62
+ nexus connector remove prod-logs
63
+ ```
64
+
65
+ ## Query
66
+
67
+ ```bash
68
+ nexus query <alias> "<statement>" [--output <fmt>] [--max-rows N] [--timeout MS]
69
+ ```
70
+
71
+ The statement is KQL for Log Analytics / Kusto, and SQL for Azure SQL:
72
+
73
+ ```bash
74
+ nexus query telemetry "StormEvents | take 5"
75
+ nexus query warehouse "SELECT TOP 5 * FROM Orders"
76
+ ```
77
+
78
+ `--max-rows` caps the returned rows (default 1000) to protect downstream context; when the cap is hit, the result is flagged `truncated`.
79
+
80
+ ## Schema introspection
81
+
82
+ ```bash
83
+ nexus schema tables <alias> # list tables
84
+ nexus schema describe <alias> <table> # columns + normalized types
85
+ ```
86
+
87
+ ## Storage Account
88
+
89
+ A `storage-account` connector exposes three services under `nexus sa` (alias of
90
+ `storageaccount`). It is **not** used with `query`/`schema` — those are for the query
91
+ languages. All operations are read-only.
92
+
93
+ ```bash
94
+ # Table Storage — query is table + OData filter + select (not a single-string language)
95
+ nexus sa table list mydata
96
+ nexus sa table query mydata MyTable --filter "PartitionKey eq 'orders'" --select RowKey,Amount
97
+
98
+ # Blob
99
+ nexus sa blob containers mydata
100
+ nexus sa blob list mydata mycontainer --prefix logs/
101
+ nexus sa blob read mydata mycontainer report.csv # bytes -> stdout
102
+ nexus sa blob read mydata mycontainer image.png -o image.png # binary -> file
103
+
104
+ # Queue
105
+ nexus sa queue list mydata
106
+ nexus sa queue peek mydata myqueue --count 10 # does not dequeue
107
+ nexus sa queue count mydata myqueue
108
+ ```
109
+
110
+ The tabular commands (`table list/query`, `blob containers/list`, `queue list/peek`) honor
111
+ `--output table|json|envelope`. `blob read` returns raw bytes and ignores output formatting
112
+ by design — use `-o <file>` for binary blobs. All three services authenticate with the same
113
+ AAD identity (scope `https://storage.azure.com/.default`); data access requires a data-plane
114
+ role such as *Storage Blob/Table/Queue Data Reader* (control-plane roles like *Owner* do not
115
+ grant data access).
116
+
117
+ ## Output formats
118
+
119
+ `--output` selects the format. Default is **table** (human-readable). Agents should use `json` or `envelope`.
120
+
121
+ | Format | Shape | Best for |
122
+ |---|---|---|
123
+ | `table` (default) | aligned columns | humans |
124
+ | `json` | array of objects keyed by column name | quick scripting |
125
+ | `envelope` | columns declared once + rows as arrays + metadata | Agents (token-efficient) |
126
+
127
+ Example `envelope` output:
128
+
129
+ ```json
130
+ {
131
+ "status": "ok",
132
+ "columns": [{ "name": "Name", "type": "string" }, { "name": "Count", "type": "long" }],
133
+ "rows": [["a", 1], ["b", 2]],
134
+ "row_count": 2,
135
+ "truncated": false
136
+ }
137
+ ```
138
+
139
+ The `envelope` form declares column names once (instead of repeating them on every row), which keeps large result sets compact in an Agent's context window. Column types are normalized to a unified vocabulary (`string`, `long`, `real`, `datetime`, `bool`, `dynamic`) across all three resource types.
140
+
141
+ ## Output channels & exit codes
142
+
143
+ - **Results** go to **stdout**; **logs and errors** go to **stderr** — so an Agent can parse stdout cleanly.
144
+ - Exit codes: `0` success, `1` query/connection/auth error, `2` usage/config error.
145
+
146
+ On error, `json`/`envelope` output a structured `{ "status": "error", "code": "...", "message": "..." }`.
147
+
148
+ ## Extending
149
+
150
+ - **New resource type:** implement the `Connector` interface in `src/connectors/`, then register it in `src/cli.ts` — no changes to commands or core.
151
+ - **New auth method:** implement `CredentialProvider` in `src/core/credential.ts` and select it in `createCredential` — connectors are unaffected, since each connector supplies its own token scope.
152
+
153
+ ## Development
154
+
155
+ Development uses [Bun](https://bun.sh) for fast installs, running, and tests. The published package is plain Node — Bun is a dev-time tool only.
156
+
157
+ ```bash
158
+ bun install # install deps (generates bun.lock)
159
+ bun test # run the test suite
160
+ bun run dev <args> # run from source, no build step
161
+ bun run typecheck # tsc --noEmit
162
+ bun run build # tsc → dist/ (the Node-runnable artifact that ships)
163
+ ```
164
+
165
+ **Publishing:** `bun run build` (via `prepublishOnly`) compiles `src/` to standard Node ESM in `dist/` with a `#!/usr/bin/env node` shebang, then `bun publish` (or `npm publish`) uploads it. End users `npm install` and run it on Node — they never need Bun.
166
+
167
+ ### Known limitation: Azure SQL under the Bun runtime
168
+
169
+ Querying **Azure SQL** fails under `bun run` (`ESOCKET — Connection lost / socket hang up`). This is an open Bun regression in its `node:net` compatibility layer that breaks the `tedious` (mssql) TDS-over-TLS handshake on some kernels (including WSL2); it is **not** a bug in this project — the same `mssql` call works under Node. Kusto and Log Analytics use plain HTTPS and are unaffected.
170
+
171
+ The fix is to run Azure SQL queries on **Node** during development:
172
+
173
+ ```bash
174
+ bun run build # produce dist/ once
175
+ node dist/cli.js query <sql-alias> "SELECT ..." # Azure SQL: use node
176
+ bun run dev query <kusto-or-la-alias> "..." # Kusto / LA: bun is fine
177
+ ```
178
+
179
+ The published package runs on Node, so **end users are never affected** — all three resource types work normally via `npm install`.
180
+
181
+ See `docs/superpowers/specs/` and `docs/superpowers/plans/` for the full design and implementation plan.
package/dist/cli.js ADDED
@@ -0,0 +1,46 @@
1
+ #!/usr/bin/env node
2
+ import { Command } from "commander";
3
+ import { register } from "./core/registry.js";
4
+ import { ConnectorError, exitCodeFor } from "./core/errors.js";
5
+ import { makeLogAnalyticsConnector } from "./connectors/logAnalytics.js";
6
+ import { makeKustoConnector } from "./connectors/kusto.js";
7
+ import { makeAzureSqlConnector } from "./connectors/azureSql.js";
8
+ import { registerConnectorCommand } from "./commands/connector.js";
9
+ import { registerQueryCommand } from "./commands/query.js";
10
+ import { registerSchemaCommand } from "./commands/schema.js";
11
+ import { registerAuthCommand } from "./commands/auth.js";
12
+ import { registerStorageAccountCommand } from "./commands/storageaccount.js";
13
+ export function registerConnectors() {
14
+ register("log-analytics", makeLogAnalyticsConnector);
15
+ register("kusto", makeKustoConnector);
16
+ register("azure-sql", makeAzureSqlConnector);
17
+ }
18
+ export function buildProgram() {
19
+ const program = new Command();
20
+ program.name("nexus").description("Unified Azure data access CLI").version("0.1.0");
21
+ registerConnectorCommand(program);
22
+ registerQueryCommand(program);
23
+ registerSchemaCommand(program);
24
+ registerAuthCommand(program);
25
+ registerStorageAccountCommand(program);
26
+ return program;
27
+ }
28
+ async function main() {
29
+ registerConnectors();
30
+ const program = buildProgram();
31
+ try {
32
+ await program.parseAsync(process.argv);
33
+ }
34
+ catch (e) {
35
+ if (e instanceof ConnectorError) {
36
+ process.stderr.write(`error [${e.code}]: ${e.message}\n`);
37
+ process.exit(exitCodeFor(e.code));
38
+ }
39
+ process.stderr.write(`error: ${e.message}\n`);
40
+ process.exit(1);
41
+ }
42
+ }
43
+ const invokedDirectly = process.argv[1] && import.meta.url === `file://${process.argv[1]}`;
44
+ if (invokedDirectly) {
45
+ main();
46
+ }
@@ -0,0 +1,10 @@
1
+ import { clearTokenCache } from "../core/tokenCache.js";
2
+ export function registerAuthCommand(program) {
3
+ const cmd = program.command("auth").description("Manage authentication state");
4
+ cmd.command("clear-cache")
5
+ .description("Delete the on-disk AAD token cache")
6
+ .action(() => {
7
+ const removed = clearTokenCache();
8
+ process.stderr.write(removed ? "Token cache cleared.\n" : "No token cache to clear.\n");
9
+ });
10
+ }
@@ -0,0 +1,59 @@
1
+ import { addConnector, listConnectors, removeConnector, getConnector } from "../core/config.js";
2
+ import { build } from "../core/registry.js";
3
+ import { createCredential } from "../core/credential.js";
4
+ import { ConnectorError } from "../core/errors.js";
5
+ export function buildConnectorConfig(type, opts) {
6
+ switch (type) {
7
+ case "log-analytics":
8
+ if (!opts.workspaceId)
9
+ throw new ConnectorError("USAGE_ERROR", "--workspace-id is required for log-analytics");
10
+ return { type, workspaceId: opts.workspaceId };
11
+ case "kusto":
12
+ if (!opts.clusterUri)
13
+ throw new ConnectorError("USAGE_ERROR", "--cluster-uri is required for kusto");
14
+ if (!opts.database)
15
+ throw new ConnectorError("USAGE_ERROR", "--database is required for kusto");
16
+ return { type, clusterUri: opts.clusterUri, database: opts.database };
17
+ case "azure-sql":
18
+ if (!opts.server)
19
+ throw new ConnectorError("USAGE_ERROR", "--server is required for azure-sql");
20
+ if (!opts.database)
21
+ throw new ConnectorError("USAGE_ERROR", "--database is required for azure-sql");
22
+ return { type, server: opts.server, database: opts.database };
23
+ case "storage-account":
24
+ if (!opts.account)
25
+ throw new ConnectorError("USAGE_ERROR", "--account is required for storage-account");
26
+ return { type, account: opts.account };
27
+ default:
28
+ throw new ConnectorError("USAGE_ERROR", `Unknown connector type: ${type}`);
29
+ }
30
+ }
31
+ export function registerConnectorCommand(program) {
32
+ const cmd = program.command("connector").description("Manage named connectors");
33
+ cmd.command("add <alias>")
34
+ .requiredOption("--type <type>", "log-analytics | kusto | azure-sql | storage-account")
35
+ .option("--workspace-id <id>")
36
+ .option("--cluster-uri <uri>")
37
+ .option("--database <db>")
38
+ .option("--server <host>")
39
+ .option("--account <name>", "storage account name (storage-account)")
40
+ .action((alias, opts) => {
41
+ const config = buildConnectorConfig(opts.type, opts);
42
+ addConnector(alias, config);
43
+ process.stderr.write(`Added connector '${alias}' (${opts.type})\n`);
44
+ });
45
+ cmd.command("list").action(() => {
46
+ const rows = listConnectors();
47
+ process.stdout.write(JSON.stringify(rows) + "\n");
48
+ });
49
+ cmd.command("remove <alias>").action((alias) => {
50
+ removeConnector(alias);
51
+ process.stderr.write(`Removed connector '${alias}'\n`);
52
+ });
53
+ cmd.command("test <alias>").action(async (alias) => {
54
+ const config = getConnector(alias);
55
+ const connector = build(config, createCredential("aad"));
56
+ await connector.testConnection();
57
+ process.stderr.write(`Connector '${alias}' OK\n`);
58
+ });
59
+ }
@@ -0,0 +1,27 @@
1
+ import { render } from "../core/output.js";
2
+ import { getConnector } from "../core/config.js";
3
+ import { build } from "../core/registry.js";
4
+ import { createCredential } from "../core/credential.js";
5
+ import { ConnectorError } from "../core/errors.js";
6
+ export function resolveFormat(value) {
7
+ const v = value ?? "table";
8
+ if (v === "table" || v === "json" || v === "envelope")
9
+ return v;
10
+ throw new ConnectorError("USAGE_ERROR", `Unknown --output format: ${v} (use table|json|envelope)`);
11
+ }
12
+ export function registerQueryCommand(program) {
13
+ program.command("query <alias> <statement>")
14
+ .option("--output <fmt>", "table | json | envelope", "table")
15
+ .option("--max-rows <n>", "max rows to return")
16
+ .option("--timeout <ms>", "query timeout in ms")
17
+ .action(async (alias, statement, opts) => {
18
+ const format = resolveFormat(opts.output);
19
+ const config = getConnector(alias);
20
+ const connector = build(config, createCredential("aad"));
21
+ const rs = await connector.query(statement, {
22
+ maxRows: opts.maxRows ? Number(opts.maxRows) : undefined,
23
+ timeoutMs: opts.timeout ? Number(opts.timeout) : undefined,
24
+ });
25
+ process.stdout.write(render(rs, format) + "\n");
26
+ });
27
+ }
@@ -0,0 +1,40 @@
1
+ import { render } from "../core/output.js";
2
+ import { getConnector } from "../core/config.js";
3
+ import { build } from "../core/registry.js";
4
+ import { createCredential } from "../core/credential.js";
5
+ import { resolveFormat } from "./query.js";
6
+ export function tablesToResultSet(tables) {
7
+ return {
8
+ columns: [{ name: "schema", type: "string" }, { name: "name", type: "string" }],
9
+ rows: tables.map((t) => [t.schema ?? "", t.name]),
10
+ rowCount: tables.length,
11
+ truncated: false,
12
+ };
13
+ }
14
+ export function columnsToResultSet(cols) {
15
+ return {
16
+ columns: [{ name: "name", type: "string" }, { name: "type", type: "string" }],
17
+ rows: cols.map((c) => [c.name, c.type]),
18
+ rowCount: cols.length,
19
+ truncated: false,
20
+ };
21
+ }
22
+ export function registerSchemaCommand(program) {
23
+ const cmd = program.command("schema").description("Inspect connector schema");
24
+ cmd.command("tables <alias>")
25
+ .option("--output <fmt>", "table | json | envelope", "table")
26
+ .action(async (alias, opts) => {
27
+ const format = resolveFormat(opts.output);
28
+ const connector = build(getConnector(alias), createCredential("aad"));
29
+ const rs = tablesToResultSet(await connector.listTables());
30
+ process.stdout.write(render(rs, format) + "\n");
31
+ });
32
+ cmd.command("describe <alias> <table>")
33
+ .option("--output <fmt>", "table | json | envelope", "table")
34
+ .action(async (alias, table, opts) => {
35
+ const format = resolveFormat(opts.output);
36
+ const connector = build(getConnector(alias), createCredential("aad"));
37
+ const rs = columnsToResultSet(await connector.describeTable(table));
38
+ process.stdout.write(render(rs, format) + "\n");
39
+ });
40
+ }
@@ -0,0 +1,112 @@
1
+ import { getConnector } from "../core/config.js";
2
+ import { createCredential } from "../core/credential.js";
3
+ import { ConnectorError } from "../core/errors.js";
4
+ import { render } from "../core/output.js";
5
+ import { resolveFormat } from "./query.js";
6
+ import { storageEndpoints } from "../connectors/storage/endpoints.js";
7
+ import { makeTableService } from "../connectors/storage/table.js";
8
+ import { makeBlobService } from "../connectors/storage/blob.js";
9
+ import { makeQueueService } from "../connectors/storage/queue.js";
10
+ import { DEFAULT_MAX_ROWS } from "../core/connector.js";
11
+ export function parseSelect(value) {
12
+ if (!value)
13
+ return undefined;
14
+ const parts = value.split(",").map((s) => s.trim()).filter((s) => s.length > 0);
15
+ return parts.length > 0 ? parts : undefined;
16
+ }
17
+ export function buildStorageServices(alias) {
18
+ const config = getConnector(alias);
19
+ if (config.type !== "storage-account" || !config.account) {
20
+ throw new ConnectorError("CONFIG_ERROR", `Connector '${alias}' is not a storage-account`);
21
+ }
22
+ const ep = storageEndpoints(config.account);
23
+ const cred = createCredential("aad");
24
+ return {
25
+ table: makeTableService(ep.table, cred),
26
+ blob: makeBlobService(ep.blob, cred),
27
+ queue: makeQueueService(ep.queue, cred),
28
+ };
29
+ }
30
+ export function registerStorageAccountCommand(program) {
31
+ const sa = program.command("storageaccount").alias("sa")
32
+ .description("Access an Azure Storage Account (table | blob | queue)");
33
+ // ---- table ----
34
+ const table = sa.command("table").description("Table Storage");
35
+ table.command("list <alias>")
36
+ .option("--output <fmt>", "table | json | envelope", "table")
37
+ .action(async (alias, opts) => {
38
+ const fmt = resolveFormat(opts.output);
39
+ const rs = await buildStorageServices(alias).table.listTables();
40
+ process.stdout.write(render(rs, fmt) + "\n");
41
+ });
42
+ table.command("query <alias> <table>")
43
+ .option("--filter <odata>", "OData $filter predicate")
44
+ .option("--select <cols>", "comma-separated property names")
45
+ .option("--max-rows <n>", "max rows")
46
+ .option("--output <fmt>", "table | json | envelope", "table")
47
+ .action(async (alias, tableName, opts) => {
48
+ const fmt = resolveFormat(opts.output);
49
+ const rs = await buildStorageServices(alias).table.queryTable(tableName, {
50
+ filter: opts.filter,
51
+ select: parseSelect(opts.select),
52
+ maxRows: opts.maxRows ? Number(opts.maxRows) : DEFAULT_MAX_ROWS,
53
+ });
54
+ process.stdout.write(render(rs, fmt) + "\n");
55
+ });
56
+ // ---- blob ----
57
+ const blob = sa.command("blob").description("Blob Storage");
58
+ blob.command("containers <alias>")
59
+ .option("--output <fmt>", "table | json | envelope", "table")
60
+ .action(async (alias, opts) => {
61
+ const fmt = resolveFormat(opts.output);
62
+ const rs = await buildStorageServices(alias).blob.listContainers();
63
+ process.stdout.write(render(rs, fmt) + "\n");
64
+ });
65
+ blob.command("list <alias> <container>")
66
+ .option("--prefix <p>", "blob name prefix")
67
+ .option("--max-rows <n>", "max rows")
68
+ .option("--output <fmt>", "table | json | envelope", "table")
69
+ .action(async (alias, container, opts) => {
70
+ const fmt = resolveFormat(opts.output);
71
+ const rs = await buildStorageServices(alias).blob.listObjects(container, {
72
+ prefix: opts.prefix,
73
+ maxRows: opts.maxRows ? Number(opts.maxRows) : DEFAULT_MAX_ROWS,
74
+ });
75
+ process.stdout.write(render(rs, fmt) + "\n");
76
+ });
77
+ blob.command("read <alias> <container> <path>")
78
+ .option("-o, --out <file>", "write bytes to a file instead of stdout")
79
+ .action(async (alias, container, path, opts) => {
80
+ const payload = await buildStorageServices(alias).blob.readObject(container, path);
81
+ if (opts.out) {
82
+ const { writeFileSync } = await import("node:fs");
83
+ writeFileSync(opts.out, payload.content);
84
+ process.stderr.write(`wrote ${payload.contentLength} bytes to ${opts.out}\n`);
85
+ }
86
+ else {
87
+ process.stdout.write(payload.content);
88
+ }
89
+ });
90
+ // ---- queue ----
91
+ const queue = sa.command("queue").description("Queue Storage");
92
+ queue.command("list <alias>")
93
+ .option("--output <fmt>", "table | json | envelope", "table")
94
+ .action(async (alias, opts) => {
95
+ const fmt = resolveFormat(opts.output);
96
+ const rs = await buildStorageServices(alias).queue.listQueues();
97
+ process.stdout.write(render(rs, fmt) + "\n");
98
+ });
99
+ queue.command("peek <alias> <queue>")
100
+ .option("--count <n>", "messages to peek (max 32)", "1")
101
+ .option("--output <fmt>", "table | json | envelope", "table")
102
+ .action(async (alias, queueName, opts) => {
103
+ const fmt = resolveFormat(opts.output);
104
+ const rs = await buildStorageServices(alias).queue.peek(queueName, opts.count ? Number(opts.count) : 1);
105
+ process.stdout.write(render(rs, fmt) + "\n");
106
+ });
107
+ queue.command("count <alias> <queue>")
108
+ .action(async (alias, queueName) => {
109
+ const n = await buildStorageServices(alias).queue.count(queueName);
110
+ process.stdout.write(String(n) + "\n");
111
+ });
112
+ }
@@ -0,0 +1,84 @@
1
+ import sql from "mssql";
2
+ import { DEFAULT_MAX_ROWS } from "../core/connector.js";
3
+ import { ConnectorError } from "../core/errors.js";
4
+ const SCOPE = "https://database.windows.net/.default";
5
+ export function normalizeSqlType(t) {
6
+ const s = t.toLowerCase();
7
+ if (s.includes("bigint") || s === "int" || s.includes("smallint") || s.includes("tinyint"))
8
+ return "long";
9
+ if (s.includes("decimal") || s.includes("float") || s.includes("real") || s.includes("numeric") || s.includes("money"))
10
+ return "real";
11
+ if (s.includes("date") || s.includes("time"))
12
+ return "datetime";
13
+ if (s.includes("bit"))
14
+ return "bool";
15
+ return "string";
16
+ }
17
+ export function toResultSet(recordset, maxRows) {
18
+ const colsMeta = recordset.columns ?? {};
19
+ const ordered = Object.entries(colsMeta)
20
+ .map(([name, meta]) => ({ name, index: meta.index ?? 0, typeName: meta.type?.name ?? meta.type ?? "nvarchar" }))
21
+ .sort((a, b) => a.index - b.index);
22
+ const columns = ordered.map((c) => ({ name: c.name, type: normalizeSqlType(String(c.typeName)) }));
23
+ const all = recordset.map((rowObj) => ordered.map((c) => rowObj[c.name]));
24
+ const rows = all.slice(0, maxRows);
25
+ return { columns, rows, rowCount: rows.length, truncated: all.length > maxRows };
26
+ }
27
+ export function makeAzureSqlConnector(config, cred) {
28
+ if (!config.server)
29
+ throw new ConnectorError("CONFIG_ERROR", "azure-sql requires server");
30
+ if (!config.database)
31
+ throw new ConnectorError("CONFIG_ERROR", "azure-sql requires database");
32
+ const server = config.server;
33
+ const database = config.database;
34
+ async function withPool(fn) {
35
+ const token = (await cred.getToken(SCOPE)).token;
36
+ const pool = new sql.ConnectionPool({
37
+ server, database,
38
+ authentication: { type: "azure-active-directory-access-token", options: { token } },
39
+ options: { encrypt: true },
40
+ });
41
+ try {
42
+ await pool.connect();
43
+ return await fn(pool);
44
+ }
45
+ catch (e) {
46
+ if (e instanceof ConnectorError)
47
+ throw e;
48
+ throw new ConnectorError("CONNECTION_FAILED", `Azure SQL error: ${e.message}`, e);
49
+ }
50
+ finally {
51
+ await pool.close().catch(() => { });
52
+ }
53
+ }
54
+ async function run(query, maxRows) {
55
+ return withPool(async (pool) => {
56
+ let result;
57
+ try {
58
+ result = await pool.request().query(query);
59
+ }
60
+ catch (e) {
61
+ throw new ConnectorError("QUERY_FAILED", `Azure SQL query failed: ${e.message}`, e);
62
+ }
63
+ return toResultSet(result.recordset, maxRows);
64
+ });
65
+ }
66
+ return {
67
+ async query(statement, options) {
68
+ return run(statement, options?.maxRows ?? DEFAULT_MAX_ROWS);
69
+ },
70
+ async listTables() {
71
+ const rs = await run("SELECT TABLE_SCHEMA, TABLE_NAME FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_TYPE='BASE TABLE' ORDER BY TABLE_SCHEMA, TABLE_NAME", 5000);
72
+ return rs.rows.map((r) => ({ schema: String(r[0]), name: String(r[1]) }));
73
+ },
74
+ async describeTable(table) {
75
+ const bare = table.includes(".") ? table.split(".").pop() : table;
76
+ const safe = bare.replace(/'/g, "''");
77
+ const rs = await run(`SELECT COLUMN_NAME, DATA_TYPE FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_NAME='${safe}' ORDER BY ORDINAL_POSITION`, 5000);
78
+ return rs.rows.map((r) => ({ name: String(r[0]), type: normalizeSqlType(String(r[1])) }));
79
+ },
80
+ async testConnection() {
81
+ await run("SELECT 1 AS x", 1);
82
+ },
83
+ };
84
+ }
@@ -0,0 +1,86 @@
1
+ import { Client, KustoConnectionStringBuilder } from "azure-kusto-data";
2
+ import { DEFAULT_MAX_ROWS } from "../core/connector.js";
3
+ import { ConnectorError } from "../core/errors.js";
4
+ export function normalizeKustoType(t) {
5
+ const s = t.toLowerCase();
6
+ if (s.includes("int") || s === "long")
7
+ return "long";
8
+ if (s.includes("real") || s.includes("double") || s.includes("decimal"))
9
+ return "real";
10
+ if (s.includes("datetime") || s.includes("date"))
11
+ return "datetime";
12
+ if (s.includes("bool"))
13
+ return "bool";
14
+ if (s.includes("dynamic"))
15
+ return "dynamic";
16
+ return "string";
17
+ }
18
+ function scopeFor(clusterUri) {
19
+ return `${clusterUri.replace(/\/+$/, "")}/.default`;
20
+ }
21
+ export function toResultSet(primary, maxRows) {
22
+ const columns = (primary.columns ?? []).map((c) => ({
23
+ name: c.name ?? c.columnName,
24
+ type: normalizeKustoType(String(c.type ?? c.columnType)),
25
+ }));
26
+ const all = [];
27
+ // The Kusto SDK exposes rows via a `rows()` generator yielding KustoResultRow
28
+ // objects; each row's ordered cell values are on `.raw` (fallback: `values()`).
29
+ const rowIterable = typeof primary.rows === "function" ? primary.rows() : primary;
30
+ for (const row of rowIterable) {
31
+ if (Array.isArray(row)) {
32
+ all.push(row);
33
+ }
34
+ else if (Array.isArray(row?.raw)) {
35
+ all.push(row.raw);
36
+ }
37
+ else if (typeof row?.values === "function") {
38
+ all.push([...row.values()]);
39
+ }
40
+ else {
41
+ all.push(Object.values(row));
42
+ }
43
+ }
44
+ const rows = all.slice(0, maxRows);
45
+ return { columns, rows, rowCount: rows.length, truncated: all.length > maxRows };
46
+ }
47
+ export function makeKustoConnector(config, cred) {
48
+ if (!config.clusterUri)
49
+ throw new ConnectorError("CONFIG_ERROR", "kusto requires clusterUri");
50
+ if (!config.database)
51
+ throw new ConnectorError("CONFIG_ERROR", "kusto requires database");
52
+ const clusterUri = config.clusterUri;
53
+ const database = config.database;
54
+ const scope = scopeFor(clusterUri);
55
+ const kcsb = KustoConnectionStringBuilder.withTokenProvider(clusterUri, async () => (await cred.getToken(scope)).token);
56
+ const client = new Client(kcsb);
57
+ async function run(query, maxRows) {
58
+ let res;
59
+ try {
60
+ res = await client.execute(database, query);
61
+ }
62
+ catch (e) {
63
+ throw new ConnectorError("QUERY_FAILED", `Kusto query failed: ${e.message}`, e);
64
+ }
65
+ const primary = res.primaryResults?.[0];
66
+ if (!primary)
67
+ return { columns: [], rows: [], rowCount: 0, truncated: false };
68
+ return toResultSet(primary, maxRows);
69
+ }
70
+ return {
71
+ async query(statement, options) {
72
+ return run(statement, options?.maxRows ?? DEFAULT_MAX_ROWS);
73
+ },
74
+ async listTables() {
75
+ const rs = await run(".show tables | project TableName", 5000);
76
+ return rs.rows.map((r) => ({ name: String(r[0]) }));
77
+ },
78
+ async describeTable(table) {
79
+ const rs = await run(`${table} | getschema | project ColumnName, ColumnType`, 5000);
80
+ return rs.rows.map((r) => ({ name: String(r[0]), type: normalizeKustoType(String(r[1])) }));
81
+ },
82
+ async testConnection() {
83
+ await run("print x=1", 1);
84
+ },
85
+ };
86
+ }
@@ -0,0 +1,70 @@
1
+ import { LogsQueryClient } from "@azure/monitor-query";
2
+ import { DEFAULT_MAX_ROWS } from "../core/connector.js";
3
+ import { ConnectorError } from "../core/errors.js";
4
+ const SCOPE = "https://api.loganalytics.io/.default";
5
+ export function normalizeLaType(t) {
6
+ switch (t) {
7
+ case "long":
8
+ case "int": return "long";
9
+ case "real":
10
+ case "decimal": return "real";
11
+ case "datetime": return "datetime";
12
+ case "bool":
13
+ case "boolean": return "bool";
14
+ case "dynamic": return "dynamic";
15
+ default: return "string";
16
+ }
17
+ }
18
+ export function toResultSet(table, maxRows) {
19
+ const descriptors = table.columnDescriptors ?? table.columns ?? [];
20
+ const columns = descriptors.map((c) => ({
21
+ name: c.name, type: normalizeLaType(String(c.type)),
22
+ }));
23
+ const allRows = (table.rows ?? []);
24
+ const rows = allRows.slice(0, maxRows);
25
+ return { columns, rows, rowCount: rows.length, truncated: allRows.length > maxRows };
26
+ }
27
+ function tokenAdapter(cred) {
28
+ return {
29
+ getToken: async (_scopes) => {
30
+ const t = await cred.getToken(SCOPE);
31
+ return { token: t.token, expiresOnTimestamp: t.expiresOnTimestamp };
32
+ },
33
+ };
34
+ }
35
+ export function makeLogAnalyticsConnector(config, cred) {
36
+ if (!config.workspaceId)
37
+ throw new ConnectorError("CONFIG_ERROR", "log-analytics requires workspaceId");
38
+ const workspaceId = config.workspaceId;
39
+ const client = new LogsQueryClient(tokenAdapter(cred));
40
+ async function runQuery(kql, maxRows) {
41
+ let res;
42
+ try {
43
+ res = await client.queryWorkspace(workspaceId, kql, { duration: "P1D" });
44
+ }
45
+ catch (e) {
46
+ throw new ConnectorError("QUERY_FAILED", `Log Analytics query failed: ${e.message}`, e);
47
+ }
48
+ const tables = res.tables;
49
+ if (!tables || tables.length === 0) {
50
+ return { columns: [], rows: [], rowCount: 0, truncated: false };
51
+ }
52
+ return toResultSet(tables[0], maxRows);
53
+ }
54
+ return {
55
+ async query(statement, options) {
56
+ return runQuery(statement, options?.maxRows ?? DEFAULT_MAX_ROWS);
57
+ },
58
+ async listTables() {
59
+ const rs = await runQuery("union withsource=TableName * | distinct TableName | sort by TableName asc", 5000);
60
+ return rs.rows.map((r) => ({ name: String(r[0]) }));
61
+ },
62
+ async describeTable(table) {
63
+ const rs = await runQuery(`${table} | getschema | project ColumnName, ColumnType`, 5000);
64
+ return rs.rows.map((r) => ({ name: String(r[0]), type: normalizeLaType(String(r[1])) }));
65
+ },
66
+ async testConnection() {
67
+ await runQuery("print x=1", 1);
68
+ },
69
+ };
70
+ }
@@ -0,0 +1,72 @@
1
+ import { BlobServiceClient } from "@azure/storage-blob";
2
+ import { ConnectorError } from "../../core/errors.js";
3
+ import { storageTokenCredential } from "./credentialAdapter.js";
4
+ import { storageErrorMessage } from "./errors.js";
5
+ function isoOrEmpty(d) {
6
+ return d instanceof Date ? d.toISOString() : (d ? String(d) : "");
7
+ }
8
+ export function makeBlobService(endpoint, cred, deps) {
9
+ const sdkCred = storageTokenCredential(cred);
10
+ const serviceClient = deps?.serviceClient ?? new BlobServiceClient(endpoint, sdkCred);
11
+ return {
12
+ async listContainers() {
13
+ const rows = [];
14
+ try {
15
+ for await (const c of serviceClient.listContainers()) {
16
+ rows.push([c.name ?? "", isoOrEmpty(c.properties?.lastModified)]);
17
+ }
18
+ }
19
+ catch (e) {
20
+ throw new ConnectorError("CONNECTION_FAILED", `Storage container list failed: ${storageErrorMessage(e)}`, e);
21
+ }
22
+ return {
23
+ columns: [{ name: "name", type: "string" }, { name: "lastModified", type: "datetime" }],
24
+ rows, rowCount: rows.length, truncated: false,
25
+ };
26
+ },
27
+ async listObjects(container, opts) {
28
+ const rows = [];
29
+ let truncated = false;
30
+ try {
31
+ const cc = serviceClient.getContainerClient(container);
32
+ const listOpts = opts.prefix ? { prefix: opts.prefix } : {};
33
+ for await (const b of cc.listBlobsFlat(listOpts)) {
34
+ if (rows.length >= opts.maxRows) {
35
+ truncated = true;
36
+ break;
37
+ }
38
+ rows.push([
39
+ b.name ?? "",
40
+ b.properties?.contentLength ?? 0,
41
+ b.properties?.contentType ?? "",
42
+ isoOrEmpty(b.properties?.lastModified),
43
+ ]);
44
+ }
45
+ }
46
+ catch (e) {
47
+ throw new ConnectorError("QUERY_FAILED", `Storage blob list failed: ${storageErrorMessage(e)}`, e);
48
+ }
49
+ return {
50
+ columns: [
51
+ { name: "name", type: "string" }, { name: "size", type: "long" },
52
+ { name: "contentType", type: "string" }, { name: "lastModified", type: "datetime" },
53
+ ],
54
+ rows, rowCount: rows.length, truncated,
55
+ };
56
+ },
57
+ async readObject(container, path) {
58
+ try {
59
+ const blob = serviceClient.getContainerClient(container).getBlockBlobClient(path);
60
+ const [content, props] = await Promise.all([blob.downloadToBuffer(), blob.getProperties()]);
61
+ return {
62
+ content,
63
+ contentType: props.contentType ?? "application/octet-stream",
64
+ contentLength: props.contentLength ?? content.length,
65
+ };
66
+ }
67
+ catch (e) {
68
+ throw new ConnectorError("QUERY_FAILED", `Storage blob read failed: ${storageErrorMessage(e)}`, e);
69
+ }
70
+ },
71
+ };
72
+ }
@@ -0,0 +1,9 @@
1
+ import { STORAGE_SCOPE } from "./endpoints.js";
2
+ export function storageTokenCredential(cred) {
3
+ return {
4
+ async getToken(_scopes) {
5
+ const t = await cred.getToken(STORAGE_SCOPE);
6
+ return { token: t.token, expiresOnTimestamp: t.expiresOnTimestamp };
7
+ },
8
+ };
9
+ }
@@ -0,0 +1,9 @@
1
+ export const STORAGE_SCOPE = "https://storage.azure.com/.default";
2
+ export function storageEndpoints(account) {
3
+ const suffix = "core.windows.net";
4
+ return {
5
+ table: `https://${account}.table.${suffix}`,
6
+ blob: `https://${account}.blob.${suffix}`,
7
+ queue: `https://${account}.queue.${suffix}`,
8
+ };
9
+ }
@@ -0,0 +1,10 @@
1
+ export function storageErrorMessage(e) {
2
+ const err = e;
3
+ const base = (err?.message && err.message.trim().length > 0)
4
+ ? err.message
5
+ : (err?.code ?? (err?.statusCode !== undefined ? `HTTP ${err.statusCode}` : "unknown error"));
6
+ if (err?.statusCode === 403) {
7
+ return `${base} (403 — missing a data-plane role such as Storage Blob/Table/Queue Data Reader; control-plane roles like Owner do not grant data access)`;
8
+ }
9
+ return base;
10
+ }
@@ -0,0 +1,54 @@
1
+ import { QueueServiceClient } from "@azure/storage-queue";
2
+ import { ConnectorError } from "../../core/errors.js";
3
+ import { storageTokenCredential } from "./credentialAdapter.js";
4
+ import { storageErrorMessage } from "./errors.js";
5
+ export const PEEK_MAX = 32;
6
+ function isoOrEmpty(d) {
7
+ return d instanceof Date ? d.toISOString() : (d ? String(d) : "");
8
+ }
9
+ export function makeQueueService(endpoint, cred, deps) {
10
+ const sdkCred = storageTokenCredential(cred);
11
+ const serviceClient = deps?.serviceClient ?? new QueueServiceClient(endpoint, sdkCred);
12
+ return {
13
+ async listQueues() {
14
+ const rows = [];
15
+ try {
16
+ for await (const q of serviceClient.listQueues())
17
+ rows.push([q.name ?? ""]);
18
+ }
19
+ catch (e) {
20
+ throw new ConnectorError("CONNECTION_FAILED", `Storage queue list failed: ${storageErrorMessage(e)}`, e);
21
+ }
22
+ return { columns: [{ name: "name", type: "string" }], rows, rowCount: rows.length, truncated: false };
23
+ },
24
+ async peek(queue, count) {
25
+ const n = Math.min(Math.max(1, count), PEEK_MAX);
26
+ let res;
27
+ try {
28
+ res = await serviceClient.getQueueClient(queue).peekMessages({ numberOfMessages: n });
29
+ }
30
+ catch (e) {
31
+ throw new ConnectorError("QUERY_FAILED", `Storage queue peek failed: ${storageErrorMessage(e)}`, e);
32
+ }
33
+ const rows = (res.peekedMessageItems ?? []).map((m) => [
34
+ m.messageId ?? "", isoOrEmpty(m.insertedOn), m.dequeueCount ?? 0, m.messageText ?? "",
35
+ ]);
36
+ return {
37
+ columns: [
38
+ { name: "messageId", type: "string" }, { name: "insertedOn", type: "datetime" },
39
+ { name: "dequeueCount", type: "long" }, { name: "text", type: "string" },
40
+ ],
41
+ rows, rowCount: rows.length, truncated: false,
42
+ };
43
+ },
44
+ async count(queue) {
45
+ try {
46
+ const props = await serviceClient.getQueueClient(queue).getProperties();
47
+ return props.approximateMessagesCount ?? 0;
48
+ }
49
+ catch (e) {
50
+ throw new ConnectorError("QUERY_FAILED", `Storage queue count failed: ${storageErrorMessage(e)}`, e);
51
+ }
52
+ },
53
+ };
54
+ }
@@ -0,0 +1,84 @@
1
+ import { TableServiceClient, TableClient } from "@azure/data-tables";
2
+ import { ConnectorError } from "../../core/errors.js";
3
+ import { storageTokenCredential } from "./credentialAdapter.js";
4
+ import { storageErrorMessage } from "./errors.js";
5
+ const SYSTEM_FIRST = ["partitionKey", "rowKey", "timestamp"];
6
+ export function normalizeEdmValue(v) {
7
+ if (typeof v === "number")
8
+ return { type: Number.isInteger(v) ? "long" : "real" };
9
+ if (typeof v === "boolean")
10
+ return { type: "bool" };
11
+ if (v instanceof Date)
12
+ return { type: "datetime" };
13
+ return { type: "string" };
14
+ }
15
+ export function entitiesToResultSet(entities, select, maxRows) {
16
+ const capped = entities.slice(0, maxRows);
17
+ const truncated = entities.length > maxRows;
18
+ let names;
19
+ if (select && select.length > 0) {
20
+ names = select;
21
+ }
22
+ else {
23
+ const seen = new Set();
24
+ const rest = [];
25
+ for (const e of capped) {
26
+ for (const k of Object.keys(e)) {
27
+ if (k === "etag")
28
+ continue;
29
+ if (SYSTEM_FIRST.includes(k))
30
+ continue;
31
+ if (!seen.has(k)) {
32
+ seen.add(k);
33
+ rest.push(k);
34
+ }
35
+ }
36
+ }
37
+ const present = SYSTEM_FIRST.filter((s) => capped.some((e) => s in e));
38
+ names = [...present, ...rest];
39
+ }
40
+ const columns = names.map((name) => {
41
+ const sample = capped.find((e) => e[name] !== undefined);
42
+ return { name, type: sample ? normalizeEdmValue(sample[name]).type : "string" };
43
+ });
44
+ const rows = capped.map((e) => names.map((n) => (n in e ? e[n] : undefined)));
45
+ return { columns, rows, rowCount: rows.length, truncated };
46
+ }
47
+ export function makeTableService(endpoint, cred, deps) {
48
+ const sdkCred = storageTokenCredential(cred);
49
+ const serviceClient = deps?.serviceClient ?? new TableServiceClient(endpoint, sdkCred);
50
+ const tableClientFor = deps?.tableClientFor ?? ((t) => new TableClient(endpoint, t, sdkCred));
51
+ return {
52
+ async listTables() {
53
+ const names = [];
54
+ try {
55
+ for await (const t of serviceClient.listTables())
56
+ names.push([t.name ?? ""]);
57
+ }
58
+ catch (e) {
59
+ throw new ConnectorError("CONNECTION_FAILED", `Storage table list failed: ${storageErrorMessage(e)}`, e);
60
+ }
61
+ return { columns: [{ name: "name", type: "string" }], rows: names, rowCount: names.length, truncated: false };
62
+ },
63
+ async queryTable(table, opts) {
64
+ const queryOptions = {};
65
+ if (opts.filter)
66
+ queryOptions.filter = opts.filter;
67
+ if (opts.select && opts.select.length > 0)
68
+ queryOptions.select = opts.select;
69
+ const collected = [];
70
+ try {
71
+ const client = tableClientFor(table);
72
+ for await (const ent of client.listEntities({ queryOptions })) {
73
+ collected.push(ent);
74
+ if (collected.length > opts.maxRows)
75
+ break;
76
+ }
77
+ }
78
+ catch (e) {
79
+ throw new ConnectorError("QUERY_FAILED", `Storage table query failed: ${storageErrorMessage(e)}`, e);
80
+ }
81
+ return entitiesToResultSet(collected, opts.select, opts.maxRows);
82
+ },
83
+ };
84
+ }
@@ -0,0 +1,48 @@
1
+ import { mkdirSync, readFileSync, writeFileSync, existsSync } from "node:fs";
2
+ import { homedir } from "node:os";
3
+ import { join } from "node:path";
4
+ import { ConnectorError } from "./errors.js";
5
+ function configDir() {
6
+ return process.env.NEXUS_CONFIG_DIR ?? join(homedir(), ".nexus");
7
+ }
8
+ function configPath() {
9
+ return join(configDir(), "config.json");
10
+ }
11
+ function load() {
12
+ const p = configPath();
13
+ if (!existsSync(p))
14
+ return { version: 1, connectors: {} };
15
+ try {
16
+ return JSON.parse(readFileSync(p, "utf8"));
17
+ }
18
+ catch (e) {
19
+ throw new ConnectorError("CONFIG_ERROR", `Corrupt config at ${p}`, e);
20
+ }
21
+ }
22
+ function save(c) {
23
+ mkdirSync(configDir(), { recursive: true });
24
+ writeFileSync(configPath(), JSON.stringify(c, null, 2), "utf8");
25
+ }
26
+ export function addConnector(alias, config) {
27
+ const c = load();
28
+ c.connectors[alias] = config;
29
+ save(c);
30
+ }
31
+ export function getConnector(alias) {
32
+ const c = load();
33
+ const found = c.connectors[alias];
34
+ if (!found)
35
+ throw new ConnectorError("CONFIG_ERROR", `Unknown connector: '${alias}'`);
36
+ return found;
37
+ }
38
+ export function removeConnector(alias) {
39
+ const c = load();
40
+ if (!c.connectors[alias])
41
+ throw new ConnectorError("CONFIG_ERROR", `Unknown connector: '${alias}'`);
42
+ delete c.connectors[alias];
43
+ save(c);
44
+ }
45
+ export function listConnectors() {
46
+ const c = load();
47
+ return Object.entries(c.connectors).map(([alias, config]) => ({ alias, config }));
48
+ }
@@ -0,0 +1,8 @@
1
+ export const DEFAULT_MAX_ROWS = 1000;
2
+ export function isResultSet(v) {
3
+ if (typeof v !== "object" || v === null)
4
+ return false;
5
+ const r = v;
6
+ return Array.isArray(r.columns) && Array.isArray(r.rows)
7
+ && typeof r.rowCount === "number" && typeof r.truncated === "boolean";
8
+ }
@@ -0,0 +1,28 @@
1
+ import { DefaultAzureCredential } from "@azure/identity";
2
+ import { ConnectorError } from "./errors.js";
3
+ import { CachingCredentialProvider } from "./tokenCache.js";
4
+ export class AadCredentialProvider {
5
+ cred;
6
+ kind = "aad";
7
+ constructor(cred = new DefaultAzureCredential()) {
8
+ this.cred = cred;
9
+ }
10
+ async getToken(scope) {
11
+ let t;
12
+ try {
13
+ t = await this.cred.getToken(scope);
14
+ }
15
+ catch (e) {
16
+ throw new ConnectorError("AUTH_FAILED", `Failed to acquire AAD token for ${scope}. Try 'az login' or check credentials.`, e);
17
+ }
18
+ if (!t) {
19
+ throw new ConnectorError("AUTH_FAILED", `No AAD token returned for ${scope}. Try 'az login'.`);
20
+ }
21
+ return { token: t.token, expiresOnTimestamp: t.expiresOnTimestamp };
22
+ }
23
+ }
24
+ export function createCredential(kind = "aad") {
25
+ if (kind === "aad")
26
+ return new CachingCredentialProvider(new AadCredentialProvider());
27
+ throw new ConnectorError("USAGE_ERROR", `Unsupported auth kind: ${kind}`);
28
+ }
@@ -0,0 +1,19 @@
1
+ export class ConnectorError extends Error {
2
+ code;
3
+ cause;
4
+ constructor(code, message, cause) {
5
+ super(message);
6
+ this.code = code;
7
+ this.cause = cause;
8
+ this.name = "ConnectorError";
9
+ }
10
+ }
11
+ export function exitCodeFor(code) {
12
+ switch (code) {
13
+ case "CONFIG_ERROR":
14
+ case "USAGE_ERROR":
15
+ return 2;
16
+ default:
17
+ return 1;
18
+ }
19
+ }
@@ -0,0 +1,37 @@
1
+ export function render(rs, format) {
2
+ switch (format) {
3
+ case "envelope":
4
+ return JSON.stringify({
5
+ status: "ok",
6
+ columns: rs.columns,
7
+ rows: rs.rows.map((row) => row.map((cell) => (cell === undefined ? "" : cell))),
8
+ row_count: rs.rowCount,
9
+ truncated: rs.truncated,
10
+ });
11
+ case "json":
12
+ return JSON.stringify(rs.rows.map((row) => {
13
+ const obj = {};
14
+ rs.columns.forEach((c, i) => { obj[c.name] = row[i]; });
15
+ return obj;
16
+ }));
17
+ case "table":
18
+ return renderTable(rs);
19
+ }
20
+ }
21
+ function renderTable(rs) {
22
+ const headers = rs.columns.map((c) => c.name);
23
+ const widths = headers.map((h, i) => Math.max(h.length, ...rs.rows.map((r) => String(r[i] ?? "").length)));
24
+ const pad = (s, w) => s.padEnd(w);
25
+ const line = (cells) => cells.map((c, i) => pad(c, widths[i])).join(" ");
26
+ const out = [line(headers), line(widths.map((w) => "-".repeat(w)))];
27
+ for (const r of rs.rows)
28
+ out.push(line(r.map((c) => String(c ?? ""))));
29
+ if (rs.truncated)
30
+ out.push(`... (truncated at ${rs.rowCount} rows)`);
31
+ return out.join("\n");
32
+ }
33
+ export function renderError(code, message, format) {
34
+ if (format === "table")
35
+ return `error [${code}]: ${message}`;
36
+ return JSON.stringify({ status: "error", code, message });
37
+ }
@@ -0,0 +1,18 @@
1
+ import { ConnectorError } from "./errors.js";
2
+ const factories = new Map();
3
+ export function register(type, factory) {
4
+ factories.set(type, factory);
5
+ }
6
+ export function clearRegistry() {
7
+ factories.clear();
8
+ }
9
+ export function build(config, credential) {
10
+ const f = factories.get(config.type);
11
+ if (!f) {
12
+ if (config.type === "storage-account") {
13
+ throw new ConnectorError("CONFIG_ERROR", "'storage-account' connectors are accessed via 'nexus sa ...', not 'query'/'schema'");
14
+ }
15
+ throw new ConnectorError("CONFIG_ERROR", `No connector registered for type '${config.type}'`);
16
+ }
17
+ return f(config, credential);
18
+ }
@@ -0,0 +1,68 @@
1
+ import { mkdirSync, readFileSync, writeFileSync, existsSync, rmSync, chmodSync } from "node:fs";
2
+ import { homedir } from "node:os";
3
+ import { join } from "node:path";
4
+ /** Refresh a token this long before its real expiry, so a query never dies mid-flight. */
5
+ export const EXPIRY_SKEW_MS = 60_000;
6
+ function configDir() {
7
+ return process.env.NEXUS_CONFIG_DIR ?? join(homedir(), ".nexus");
8
+ }
9
+ function cachePath() {
10
+ return join(configDir(), "token-cache.json");
11
+ }
12
+ function loadCache() {
13
+ const p = cachePath();
14
+ if (!existsSync(p))
15
+ return {};
16
+ try {
17
+ return JSON.parse(readFileSync(p, "utf8"));
18
+ }
19
+ catch {
20
+ // A corrupt cache is non-fatal — treat as empty and overwrite on next write.
21
+ return {};
22
+ }
23
+ }
24
+ function saveCache(cache) {
25
+ const dir = configDir();
26
+ mkdirSync(dir, { recursive: true });
27
+ const p = cachePath();
28
+ writeFileSync(p, JSON.stringify(cache, null, 2), { encoding: "utf8", mode: 0o600 });
29
+ // Ensure 0600 even if the file pre-existed with looser perms.
30
+ chmodSync(p, 0o600);
31
+ }
32
+ function isValid(entry) {
33
+ return entry.expiresOnTimestamp - Date.now() > EXPIRY_SKEW_MS;
34
+ }
35
+ /**
36
+ * Wraps another CredentialProvider and persists acquired tokens to
37
+ * ~/.nexus/token-cache.json (0600), keyed by scope. On a cache hit with a
38
+ * still-valid token, returns it without delegating — skipping the slow
39
+ * `az`-spawn path inside DefaultAzureCredential.
40
+ */
41
+ export class CachingCredentialProvider {
42
+ inner;
43
+ constructor(inner) {
44
+ this.inner = inner;
45
+ }
46
+ get kind() {
47
+ return this.inner.kind;
48
+ }
49
+ async getToken(scope) {
50
+ const cache = loadCache();
51
+ const hit = cache[scope];
52
+ if (hit && isValid(hit)) {
53
+ return { token: hit.token, expiresOnTimestamp: hit.expiresOnTimestamp };
54
+ }
55
+ const fresh = await this.inner.getToken(scope);
56
+ cache[scope] = { token: fresh.token, expiresOnTimestamp: fresh.expiresOnTimestamp };
57
+ saveCache(cache);
58
+ return fresh;
59
+ }
60
+ }
61
+ /** Delete the on-disk token cache. Returns true if a file was removed. */
62
+ export function clearTokenCache() {
63
+ const p = cachePath();
64
+ if (!existsSync(p))
65
+ return false;
66
+ rmSync(p, { force: true });
67
+ return true;
68
+ }
package/package.json ADDED
@@ -0,0 +1,37 @@
1
+ {
2
+ "name": "@ascdong/nexus",
3
+ "version": "0.1.0",
4
+ "description": "Unified Azure data access CLI for Log Analytics, Kusto, Azure SQL, and Storage Account",
5
+ "type": "module",
6
+ "bin": {
7
+ "nexus": "dist/cli.js"
8
+ },
9
+ "files": ["dist", "README.md"],
10
+ "scripts": {
11
+ "build": "tsc",
12
+ "dev": "bun run src/cli.ts",
13
+ "test": "bun test",
14
+ "test:watch": "bun test --watch",
15
+ "typecheck": "tsc --noEmit",
16
+ "prepublishOnly": "bun run build"
17
+ },
18
+ "engines": {
19
+ "node": ">=20"
20
+ },
21
+ "dependencies": {
22
+ "@azure/data-tables": "^13.3.2",
23
+ "@azure/identity": "^4.5.0",
24
+ "@azure/monitor-query": "^1.3.0",
25
+ "@azure/storage-blob": "^12.32.0",
26
+ "@azure/storage-queue": "^12.30.0",
27
+ "azure-kusto-data": "^6.0.0",
28
+ "commander": "^12.1.0",
29
+ "mssql": "^11.0.1"
30
+ },
31
+ "devDependencies": {
32
+ "@types/bun": "^1.1.0",
33
+ "@types/mssql": "^9.1.5",
34
+ "@types/node": "^22.0.0",
35
+ "typescript": "^5.6.0"
36
+ }
37
+ }