@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 +181 -0
- package/dist/cli.js +46 -0
- package/dist/commands/auth.js +10 -0
- package/dist/commands/connector.js +59 -0
- package/dist/commands/query.js +27 -0
- package/dist/commands/schema.js +40 -0
- package/dist/commands/storageaccount.js +112 -0
- package/dist/connectors/azureSql.js +84 -0
- package/dist/connectors/kusto.js +86 -0
- package/dist/connectors/logAnalytics.js +70 -0
- package/dist/connectors/storage/blob.js +72 -0
- package/dist/connectors/storage/credentialAdapter.js +9 -0
- package/dist/connectors/storage/endpoints.js +9 -0
- package/dist/connectors/storage/errors.js +10 -0
- package/dist/connectors/storage/queue.js +54 -0
- package/dist/connectors/storage/table.js +84 -0
- package/dist/core/config.js +48 -0
- package/dist/core/connector.js +8 -0
- package/dist/core/credential.js +28 -0
- package/dist/core/errors.js +19 -0
- package/dist/core/output.js +37 -0
- package/dist/core/registry.js +18 -0
- package/dist/core/tokenCache.js +68 -0
- package/package.json +37 -0
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
|
+
}
|