@eyeauras/opensearch-cli 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +115 -2
- package/dist/src/auth.d.ts +2 -0
- package/dist/src/auth.js +86 -0
- package/dist/src/bin.d.ts +2 -0
- package/dist/src/bin.js +10 -0
- package/dist/src/cli.d.ts +2 -0
- package/dist/src/cli.js +137 -0
- package/dist/src/client.d.ts +154 -0
- package/dist/src/client.js +246 -0
- package/dist/src/index.d.ts +1 -0
- package/dist/src/index.js +1 -0
- package/dist/src/query.d.ts +3 -0
- package/dist/src/query.js +33 -0
- package/dist/src/version.d.ts +1 -0
- package/dist/src/version.js +1 -0
- package/package.json +50 -4
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 opensearch-cli contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,116 @@
|
|
|
1
|
-
#
|
|
1
|
+
# opensearch-cli
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
A general-purpose command-line client for [OpenSearch](https://opensearch.org/), built on
|
|
4
|
+
[CliFactory](https://github.com/iXab3r/EyeAuras.CliFactory) (`@eyeauras/cli-factory`).
|
|
5
|
+
|
|
6
|
+
> **M2 Explore data:** profiles, cluster info/health, indices, aliases, data streams, fields and count are available.
|
|
7
|
+
> Further OpenSearch commands are being delivered in [approved milestones](https://github.com/iXab3r/opensearch-cli/issues/7).
|
|
8
|
+
> The package has not been published.
|
|
9
|
+
|
|
10
|
+
## Development
|
|
11
|
+
|
|
12
|
+
Use Node 22 or 24 and the pinned npm version. Run `npm ci && npm test` for offline CLI tests,
|
|
13
|
+
`npm run test:package` for an isolated installation check, or
|
|
14
|
+
`dotnet run --project build -- --target Verify` for the complete CI check.
|
|
15
|
+
See [testing](docs/testing.md) and [release preparation](docs/release.md).
|
|
16
|
+
|
|
17
|
+
## Connect and inspect
|
|
18
|
+
|
|
19
|
+
```text
|
|
20
|
+
opensearch-cli profile configure dev --url https://cluster.example.com:9200 --auth basic --username operator --password-stdin
|
|
21
|
+
opensearch-cli profile configure prod --url https://search.example.com --auth bearer --token-stdin
|
|
22
|
+
opensearch-cli profile configure local --url http://localhost:9200 --auth none
|
|
23
|
+
opensearch-cli auth status --profile dev
|
|
24
|
+
opensearch-cli cluster info --profile dev
|
|
25
|
+
opensearch-cli cluster health --profile dev --index 'sample-*' --level indices
|
|
26
|
+
opensearch-cli cluster health --profile dev --wait-for-status green --timeout 30s --json
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
Pass a password or bare bearer token on stdin; credentials stay in the platform secret store.
|
|
30
|
+
Basic passwords preserve whitespace, removing one final line ending. Basic login requires both
|
|
31
|
+
`--username` and `--password-stdin`. Open profiles use no credential.
|
|
32
|
+
Cluster info reports unsupported distributions/versions with a stderr warning and retains their metadata.
|
|
33
|
+
Cluster health reads red state successfully; a wait timeout fails with the observed health on stdout.
|
|
34
|
+
`ReadOnly` is enabled by default. `Update` and `Delete` start disabled.
|
|
35
|
+
|
|
36
|
+
## List indices
|
|
37
|
+
|
|
38
|
+
```text
|
|
39
|
+
opensearch-cli indices list
|
|
40
|
+
opensearch-cli indices list 'sample-*' --health yellow
|
|
41
|
+
opensearch-cli indices list --include-hidden --json
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
Rows are sorted by name and show health, open/close status, primary and replica shard counts,
|
|
45
|
+
document count and total store size. Hidden and system indices are excluded by metadata classification,
|
|
46
|
+
even when named explicitly; `--include-hidden` includes both kinds visible to your profile.
|
|
47
|
+
The default read uses CAT statistics plus a narrow cluster-state metadata projection, compatible
|
|
48
|
+
with OpenSearch 2.x and 3.x. Server permissions must allow `indices:monitor/stats`,
|
|
49
|
+
`indices:monitor/settings/get`, `cluster:monitor/state` and `cluster:monitor/health`.
|
|
50
|
+
No index documents, mappings or unrelated settings are returned.
|
|
51
|
+
|
|
52
|
+
JSON and RPC return the same array of `{name, health, status, primaries, replicas, docsCount, storeBytes}`.
|
|
53
|
+
Counts are numbers and sizes are numbers in bytes; unavailable statistics and health (for example on a closed
|
|
54
|
+
index) are `null`. Human sizes use binary units. No matches return `[]` successfully.
|
|
55
|
+
|
|
56
|
+
## Explore definitions and count documents
|
|
57
|
+
|
|
58
|
+
```text
|
|
59
|
+
opensearch-cli aliases list 'sample-*'
|
|
60
|
+
opensearch-cli data-streams list 'sample-*'
|
|
61
|
+
opensearch-cli indices show sample-a
|
|
62
|
+
opensearch-cli indices show sample-a --section mappings --json
|
|
63
|
+
opensearch-cli fields list 'sample-*' --filter 'user.*'
|
|
64
|
+
opensearch-cli fields list 'sample-*' --type keyword --include-meta --json
|
|
65
|
+
opensearch-cli count 'sample-*'
|
|
66
|
+
opensearch-cli count sample-a --q 'status:500'
|
|
67
|
+
opensearch-cli count sample-a --query-file query.json --fail-on-partial --json
|
|
68
|
+
opensearch-cli count sample-a --query-file -
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
Alias rows show each target index, the nullable write-index flag and filter/routing presence.
|
|
72
|
+
Data streams show their timestamp field, generation, backing-index count and health. Empty lists
|
|
73
|
+
are successful. Index inspection returns `{name, settings, aliases, mappings}`; settings normalize
|
|
74
|
+
shard counts and creation time, while mapping/alias definitions retain OpenSearch's structure.
|
|
75
|
+
`--section settings|mappings|aliases` returns only that section. A missing index reports
|
|
76
|
+
`index.notFound` and suggests `indices list`. Arbitrary definitions use Core's structured human view.
|
|
77
|
+
|
|
78
|
+
Fields include object, nested and multi-field dotted paths. Each row retains `types`, `conflict`
|
|
79
|
+
and `indicesByType`; `--type` selects matching fields without hiding their other conflicting types.
|
|
80
|
+
`--filter` matches full paths with `*` and `?`. Reserved metadata fields require `--include-meta`;
|
|
81
|
+
ordinary underscore-prefixed user fields stay visible.
|
|
82
|
+
|
|
83
|
+
Query files contain the Query DSL **query object**, for example `{"term":{"status":500}}`, rather
|
|
84
|
+
than a full search body. Relative paths resolve from the command's working directory. `-` reads
|
|
85
|
+
fresh CLI stdin; JSON-RPC callers use a file because stdin carries the RPC protocol. Choose either
|
|
86
|
+
`--q` or `--query-file`. Count returns numeric `count`, normalized `_shards` statistics, and
|
|
87
|
+
`partial: {failedShards, totalShards, failures}`. Shard failures warn human callers on stderr;
|
|
88
|
+
`--fail-on-partial` exits with `search.partial` and retains the result on stdout/RPC error result.
|
|
89
|
+
All these commands are ReadOnly and use the selected profile's server permissions.
|
|
90
|
+
Shard failure details retain index, shard, node and type/reason/cause chains. Diagnostic text
|
|
91
|
+
uses Core's credential redaction before the domain result reaches any output or caller.
|
|
92
|
+
|
|
93
|
+
## Agreed direction
|
|
94
|
+
|
|
95
|
+
- **Universal product.** The CLI speaks OpenSearch concepts: indices, mappings, Query DSL,
|
|
96
|
+
search, aggregations, pagination, documents and cluster state. It carries no knowledge of any
|
|
97
|
+
particular deployment, application, log schema or business process.
|
|
98
|
+
- **Supported versions:** OpenSearch 2.x and 3.x. Amazon OpenSearch Service is tracked separately
|
|
99
|
+
and is not a commitment.
|
|
100
|
+
- **CliFactory owns the common mechanics:** command declarations, help, human/JSON output,
|
|
101
|
+
JSON-RPC, profiles, secret storage and permission gates. This repository owns only the
|
|
102
|
+
OpenSearch API and its semantics.
|
|
103
|
+
- **Standalone process, no IPC.** Calls are cheap and the CLI keeps no state between invocations.
|
|
104
|
+
- **Build and release run on TeamCity**, not GitHub Actions.
|
|
105
|
+
|
|
106
|
+
Maintainers: follow the [staged release flow](docs/release.md).
|
|
107
|
+
|
|
108
|
+
## Non-goals
|
|
109
|
+
|
|
110
|
+
- A UI, an MCP server or a hosted platform.
|
|
111
|
+
- Elasticsearch compatibility.
|
|
112
|
+
- Copying CliFactory sources; the framework is consumed as a package.
|
|
113
|
+
|
|
114
|
+
## License
|
|
115
|
+
|
|
116
|
+
[MIT](LICENSE)
|
package/dist/src/auth.js
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { CliError, diagnosticCause, tokenAuth } from "@eyeauras/cli-factory";
|
|
2
|
+
import { clientFor, OpenSearchClient } from "./client.js";
|
|
3
|
+
// Passwords are arbitrary secrets: preserve whitespace, removing only one terminal line ending.
|
|
4
|
+
async function readPassword(context) {
|
|
5
|
+
const chunks = [];
|
|
6
|
+
const abort = () => context.io.input.destroy(new CliError("Password input interrupted.", { code: "interrupted", exitCode: 130 }));
|
|
7
|
+
context.signal.throwIfAborted();
|
|
8
|
+
context.signal.addEventListener("abort", abort, { once: true });
|
|
9
|
+
try {
|
|
10
|
+
for await (const chunk of context.io.input)
|
|
11
|
+
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
|
|
12
|
+
context.signal.throwIfAborted();
|
|
13
|
+
return Buffer.concat(chunks).toString("utf8").replace(/\r?\n$/, "");
|
|
14
|
+
}
|
|
15
|
+
finally {
|
|
16
|
+
context.signal.removeEventListener("abort", abort);
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
export function openSearchAuth() {
|
|
20
|
+
const bearer = tokenAuth({
|
|
21
|
+
validate: context => new OpenSearchClient({ url: String(context.profile.values.url), fetch: context.fetch, signal: context.signal, authorization: "Bearer " + context.token }).identity(),
|
|
22
|
+
});
|
|
23
|
+
return {
|
|
24
|
+
// Every scheme validates the connection at configure/login/status, including open clusters.
|
|
25
|
+
required: () => true,
|
|
26
|
+
isReady: async (context) => context.profile.values.auth === "none" || !!await context.secrets.get(context.profile.values.auth === "bearer" ? "token" : "basic"),
|
|
27
|
+
loginOptions: [
|
|
28
|
+
{ flags: "--username <name>", description: "Set the Basic authentication username" },
|
|
29
|
+
{ flags: "--password-stdin", description: "Read the Basic password from stdin" },
|
|
30
|
+
...(bearer.loginOptions ?? []),
|
|
31
|
+
],
|
|
32
|
+
async login(context, options) {
|
|
33
|
+
if (context.profile.values.auth === "none") {
|
|
34
|
+
await new OpenSearchClient({ url: String(context.profile.values.url), fetch: context.fetch, signal: context.signal }).info();
|
|
35
|
+
await context.secrets.delete("basic");
|
|
36
|
+
await context.secrets.delete("token");
|
|
37
|
+
return { authenticated: true, identity: { url: context.profile.values.url, reachable: true } };
|
|
38
|
+
}
|
|
39
|
+
if (context.profile.values.auth === "bearer") {
|
|
40
|
+
try {
|
|
41
|
+
const status = await bearer.login(context, options);
|
|
42
|
+
await context.secrets.delete("basic");
|
|
43
|
+
return status;
|
|
44
|
+
}
|
|
45
|
+
catch (error) {
|
|
46
|
+
const code = error instanceof CliError && error.code === "auth.validationFailed"
|
|
47
|
+
? error.cause?.code : undefined;
|
|
48
|
+
if (code?.startsWith("http."))
|
|
49
|
+
throw new CliError("OpenSearch rejected the authentication check.", {
|
|
50
|
+
code, cause: error, next: [["profile", "configure", context.profile.name, "--url", String(context.profile.values.url), "--auth", "bearer", "--token-stdin"]],
|
|
51
|
+
});
|
|
52
|
+
throw error;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
if (!options.passwordStdin || !context.stdinAvailable || typeof options.username !== "string" || !options.username || options.username.includes(":"))
|
|
56
|
+
throw new CliError("Provide --username and --password-stdin for Basic authentication.", { code: "auth.missing", next: [["auth", "login", "--help"]] });
|
|
57
|
+
const password = await readPassword(context);
|
|
58
|
+
if (!password)
|
|
59
|
+
throw new CliError("The Basic password is empty.", { code: "auth.missing", next: [["auth", "login", "--help"]] });
|
|
60
|
+
const authorization = "Basic " + Buffer.from(`${options.username}:${password}`).toString("base64");
|
|
61
|
+
const client = new OpenSearchClient({ url: String(context.profile.values.url), fetch: context.fetch, signal: context.signal, authorization });
|
|
62
|
+
const identity = await client.identity();
|
|
63
|
+
context.signal.throwIfAborted();
|
|
64
|
+
try {
|
|
65
|
+
await context.secrets.set("basic", JSON.stringify({ username: options.username, password }));
|
|
66
|
+
}
|
|
67
|
+
catch (cause) {
|
|
68
|
+
throw new CliError("Could not save Basic credentials in secure storage.", { code: "auth.storeFailed", cause: diagnosticCause(cause, [password, authorization]), next: [["auth", "login", "--help"]] });
|
|
69
|
+
}
|
|
70
|
+
await context.secrets.delete("token");
|
|
71
|
+
return { authenticated: true, identity };
|
|
72
|
+
},
|
|
73
|
+
async status(context) {
|
|
74
|
+
if (context.profile.values.auth === "none")
|
|
75
|
+
return { authenticated: true, identity: await (await clientFor(context)).identity() };
|
|
76
|
+
const name = context.profile.values.auth === "bearer" ? "token" : "basic";
|
|
77
|
+
if (!await context.secrets.get(name))
|
|
78
|
+
return { authenticated: false };
|
|
79
|
+
return { authenticated: true, identity: await (await clientFor(context)).identity() };
|
|
80
|
+
},
|
|
81
|
+
async logout(context) {
|
|
82
|
+
await context.secrets.delete("basic");
|
|
83
|
+
await bearer.logout(context);
|
|
84
|
+
},
|
|
85
|
+
};
|
|
86
|
+
}
|
package/dist/src/bin.js
ADDED
package/dist/src/cli.js
ADDED
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import { CliError, command, parseServerUrl, recordView, tableView } from "@eyeauras/cli-factory";
|
|
2
|
+
import { openSearchAuth } from "./auth.js";
|
|
3
|
+
import { clientFor } from "./client.js";
|
|
4
|
+
import { readQuery } from "./query.js";
|
|
5
|
+
import { version } from "./version.js";
|
|
6
|
+
export function createDefinition() {
|
|
7
|
+
return {
|
|
8
|
+
name: "opensearch-cli",
|
|
9
|
+
description: "Explore and manage OpenSearch clusters",
|
|
10
|
+
version,
|
|
11
|
+
permissions: { categories: [{ name: "Delete", description: "Delete OpenSearch data", enabledByDefault: false }] },
|
|
12
|
+
profile: {
|
|
13
|
+
defaults: { auth: "basic" },
|
|
14
|
+
fields: [
|
|
15
|
+
{ name: "url", flags: "--url <url>", description: "Set the OpenSearch cluster URL", required: true, normalize: value => parseServerUrl(String(value), "OpenSearch URL must be http(s)://host[:port][/context].").href },
|
|
16
|
+
{ name: "auth", flags: "--auth <scheme>", description: "Choose basic, bearer or none", normalize: value => {
|
|
17
|
+
if (!["basic", "bearer", "none"].includes(String(value)))
|
|
18
|
+
throw new CliError("Authentication must be basic, bearer or none.", { code: "usage" });
|
|
19
|
+
return value;
|
|
20
|
+
} },
|
|
21
|
+
],
|
|
22
|
+
},
|
|
23
|
+
auth: openSearchAuth(),
|
|
24
|
+
commands: [command("cluster", "Inspect the OpenSearch cluster", [
|
|
25
|
+
command("info", "Show cluster version and identity", async (_, context) => {
|
|
26
|
+
const info = await (await clientFor(context)).info();
|
|
27
|
+
if (info.version.distribution !== "opensearch" || !/^[23]\./.test(info.version.number))
|
|
28
|
+
context.io.error.write("Warning: unsupported distribution or version; supported versions are OpenSearch 2.x and 3.x.\n");
|
|
29
|
+
return info;
|
|
30
|
+
}, { permission: "ReadOnly", view: recordView({ fields: [
|
|
31
|
+
{ label: "Cluster", value: value => value.cluster_name },
|
|
32
|
+
{ label: "UUID", value: value => value.cluster_uuid },
|
|
33
|
+
{ label: "Distribution", value: value => value.version.distribution },
|
|
34
|
+
{ label: "Version", value: value => value.version.number },
|
|
35
|
+
{ label: "Build date", value: value => value.version.build_date },
|
|
36
|
+
{ label: "Build hash", value: value => value.version.build_hash },
|
|
37
|
+
{ label: "Lucene", value: value => value.version.lucene_version },
|
|
38
|
+
] }) }),
|
|
39
|
+
command("health", "Show cluster health and shard allocation", async ({ options }, context) => (await clientFor(context)).health(options), {
|
|
40
|
+
permission: "ReadOnly",
|
|
41
|
+
options: [
|
|
42
|
+
{ flags: "--index <pattern>", description: "Select indices or data streams" },
|
|
43
|
+
{ flags: "--level <level>", description: "Choose cluster, indices or shards" },
|
|
44
|
+
{ flags: "--wait-for-status <status>", description: "Wait for green, yellow or red" },
|
|
45
|
+
{ flags: "--timeout <duration>", description: "Set the OpenSearch wait timeout" },
|
|
46
|
+
],
|
|
47
|
+
view: recordView({ fields: [
|
|
48
|
+
{ label: "Cluster", value: value => value.cluster_name },
|
|
49
|
+
{ label: "Status", value: value => value.status },
|
|
50
|
+
{ label: "Timed out", value: value => value.timed_out },
|
|
51
|
+
{ label: "Nodes", value: value => value.number_of_nodes },
|
|
52
|
+
{ label: "Data nodes", value: value => value.number_of_data_nodes },
|
|
53
|
+
{ label: "Primary shards", value: value => value.active_primary_shards },
|
|
54
|
+
{ label: "Active shards", value: value => value.active_shards },
|
|
55
|
+
{ label: "Relocating", value: value => value.relocating_shards },
|
|
56
|
+
{ label: "Initializing", value: value => value.initializing_shards },
|
|
57
|
+
{ label: "Unassigned", value: value => value.unassigned_shards },
|
|
58
|
+
{ label: "Active %", value: value => value.active_shards_percent_as_number },
|
|
59
|
+
], sections: [{ title: () => "Indices", lines: value => value.indices ? Object.entries(value.indices).map(([name, index]) => `${name}: ${index.status}`) : undefined }] }),
|
|
60
|
+
}),
|
|
61
|
+
]), command("indices", "Explore OpenSearch indices", [
|
|
62
|
+
command("list [pattern]", "List index health, counts and sizes", async ({ args, options }, context) => (await clientFor(context)).indices({ ...(args.pattern === undefined ? {} : { pattern: String(args.pattern) }), ...options }), {
|
|
63
|
+
permission: "ReadOnly",
|
|
64
|
+
options: [
|
|
65
|
+
{ flags: "--health <status>", description: "Select green, yellow or red indices" },
|
|
66
|
+
{ flags: "--include-hidden", description: "Include hidden and system indices" },
|
|
67
|
+
],
|
|
68
|
+
view: tableView({ columns: [
|
|
69
|
+
{ header: "Name", value: row => row.name },
|
|
70
|
+
{ header: "Health", value: row => row.health },
|
|
71
|
+
{ header: "Status", value: row => row.status },
|
|
72
|
+
{ header: "Primaries", value: row => row.primaries },
|
|
73
|
+
{ header: "Replicas", value: row => row.replicas },
|
|
74
|
+
{ header: "Docs", value: row => row.docsCount },
|
|
75
|
+
{ header: "Store", value: row => row.storeBytes, format: "bytes" },
|
|
76
|
+
], empty: "No indices found." }),
|
|
77
|
+
}),
|
|
78
|
+
command("show <index>", "Show index settings, mappings and aliases", async ({ args, options }, context) => (await clientFor(context)).index(args.index, options.section), {
|
|
79
|
+
permission: "ReadOnly", options: [{ flags: "--section <section>", description: "Select settings, mappings or aliases", parse: value => {
|
|
80
|
+
if (!["settings", "mappings", "aliases"].includes(value))
|
|
81
|
+
throw new CliError("Section must be settings, mappings or aliases.", { code: "usage" });
|
|
82
|
+
return value;
|
|
83
|
+
} }],
|
|
84
|
+
}),
|
|
85
|
+
]), command("aliases", "Explore index aliases", [
|
|
86
|
+
command("list [pattern]", "List alias targets and flags", async ({ args }, context) => (await clientFor(context)).aliases(args.pattern === undefined ? undefined : String(args.pattern)), {
|
|
87
|
+
permission: "ReadOnly", view: tableView({ columns: [
|
|
88
|
+
{ header: "Alias", value: row => row.alias }, { header: "Index", value: row => row.index },
|
|
89
|
+
{ header: "Write", value: row => row.writeIndex }, { header: "Filter", value: row => row.hasFilter },
|
|
90
|
+
{ header: "Routing", value: row => row.hasRouting },
|
|
91
|
+
], empty: "No aliases found." }),
|
|
92
|
+
}),
|
|
93
|
+
]), command("data-streams", "Explore data streams", [
|
|
94
|
+
command("list [pattern]", "List data stream metadata", async ({ args }, context) => (await clientFor(context)).dataStreams(args.pattern === undefined ? undefined : String(args.pattern)), {
|
|
95
|
+
permission: "ReadOnly", view: tableView({ columns: [
|
|
96
|
+
{ header: "Name", value: row => row.name }, { header: "Timestamp", value: row => row.timestampField },
|
|
97
|
+
{ header: "Generation", value: row => row.generation }, { header: "Backing indices", value: row => row.backingIndexCount },
|
|
98
|
+
{ header: "Health", value: row => row.health },
|
|
99
|
+
], empty: "No data streams found." }),
|
|
100
|
+
}),
|
|
101
|
+
]), command("fields", "Explore index fields", [
|
|
102
|
+
command("list <pattern>", "List field types and conflicts", async ({ args, options }, context) => (await clientFor(context)).fields(args.pattern, options), {
|
|
103
|
+
permission: "ReadOnly", options: [
|
|
104
|
+
{ flags: "--type <type>", description: "Select fields containing this type" },
|
|
105
|
+
{ flags: "--filter <glob>", description: "Match field paths with * and ?" },
|
|
106
|
+
{ flags: "--include-meta", description: "Include reserved metadata fields" },
|
|
107
|
+
], view: tableView({ columns: [
|
|
108
|
+
{ header: "Field", value: row => row.name }, { header: "Types", value: row => row.types.join(", ") },
|
|
109
|
+
{ header: "Conflict", value: row => row.conflict },
|
|
110
|
+
{ header: "Indices by type", value: row => Object.entries(row.indicesByType).map(([type, indices]) => `${type}: ${indices.join(", ")}`).join("; ") },
|
|
111
|
+
], empty: "No fields found." }),
|
|
112
|
+
}),
|
|
113
|
+
]), command("count <index>", "Count matching documents", async ({ args, options }, context) => {
|
|
114
|
+
if (options.q !== undefined && options.queryFile !== undefined)
|
|
115
|
+
throw new CliError("Choose --q or --query-file.", { code: "usage" });
|
|
116
|
+
const result = await (await clientFor(context)).count(args.index, {
|
|
117
|
+
...(options.q === undefined ? {} : { q: String(options.q) }),
|
|
118
|
+
...(options.queryFile === undefined ? {} : { query: await readQuery(String(options.queryFile), context) }),
|
|
119
|
+
});
|
|
120
|
+
if (result.partial.failedShards) {
|
|
121
|
+
const reasons = result.partial.failures.slice(0, 3).map(failure => failure.reason?.reason).filter(Boolean).join("; ").replace(/[\r\n\t]+/g, " ");
|
|
122
|
+
const details = `${result.partial.failedShards}/${result.partial.totalShards} shards failed.${reasons ? " " + reasons : ""}`;
|
|
123
|
+
if (options.failOnPartial)
|
|
124
|
+
throw new CliError("The document count is partial.", { code: "search.partial", result, cause: new Error(details) });
|
|
125
|
+
context.progress(`Warning: ${details}`);
|
|
126
|
+
}
|
|
127
|
+
return result;
|
|
128
|
+
}, { permission: "ReadOnly", options: [
|
|
129
|
+
{ flags: "--q <query>", description: "Match a Lucene query string" },
|
|
130
|
+
{ flags: "--query-file <path>", description: "Read Query DSL from file or -" },
|
|
131
|
+
{ flags: "--fail-on-partial", description: "Fail when any shard fails" },
|
|
132
|
+
], view: recordView({ fields: [
|
|
133
|
+
{ label: "Count", value: value => value.count },
|
|
134
|
+
{ label: "Partial", value: value => value.partial.failedShards > 0 },
|
|
135
|
+
] }) })],
|
|
136
|
+
};
|
|
137
|
+
}
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
import { type CommandContext } from "@eyeauras/cli-factory";
|
|
2
|
+
/** Wire metadata returned by the OpenSearch cluster information API. */
|
|
3
|
+
export interface ClusterInfo {
|
|
4
|
+
name: string;
|
|
5
|
+
cluster_name: string;
|
|
6
|
+
cluster_uuid: string;
|
|
7
|
+
version: {
|
|
8
|
+
distribution?: string;
|
|
9
|
+
number: string;
|
|
10
|
+
build_hash?: string;
|
|
11
|
+
build_date?: string;
|
|
12
|
+
lucene_version?: string;
|
|
13
|
+
};
|
|
14
|
+
}
|
|
15
|
+
/** An observed health state; red health remains a successful read. */
|
|
16
|
+
export interface ClusterHealth {
|
|
17
|
+
cluster_name: string;
|
|
18
|
+
status: string;
|
|
19
|
+
timed_out: boolean;
|
|
20
|
+
number_of_nodes: number;
|
|
21
|
+
number_of_data_nodes: number;
|
|
22
|
+
active_primary_shards: number;
|
|
23
|
+
active_shards: number;
|
|
24
|
+
relocating_shards: number;
|
|
25
|
+
initializing_shards: number;
|
|
26
|
+
unassigned_shards: number;
|
|
27
|
+
active_shards_percent_as_number: number;
|
|
28
|
+
indices?: Record<string, {
|
|
29
|
+
status: string;
|
|
30
|
+
}>;
|
|
31
|
+
}
|
|
32
|
+
/** Index statistics normalized from CAT cells; unavailable observations remain null. */
|
|
33
|
+
export interface IndexSummary {
|
|
34
|
+
name: string;
|
|
35
|
+
health: string | null;
|
|
36
|
+
status: string;
|
|
37
|
+
primaries: number | null;
|
|
38
|
+
replicas: number | null;
|
|
39
|
+
docsCount: number | null;
|
|
40
|
+
/** Total primary and replica store size in bytes, never a human size string. */
|
|
41
|
+
storeBytes: number | null;
|
|
42
|
+
}
|
|
43
|
+
/** One alias-to-index relationship; routing and filtering expose presence only. */
|
|
44
|
+
export interface AliasSummary {
|
|
45
|
+
alias: string;
|
|
46
|
+
index: string;
|
|
47
|
+
writeIndex: boolean | null;
|
|
48
|
+
hasFilter: boolean;
|
|
49
|
+
hasRouting: boolean;
|
|
50
|
+
}
|
|
51
|
+
/** A data stream's observed metadata, without document contents. */
|
|
52
|
+
export interface DataStreamSummary {
|
|
53
|
+
name: string;
|
|
54
|
+
timestampField: string;
|
|
55
|
+
generation: number;
|
|
56
|
+
backingIndexCount: number;
|
|
57
|
+
health: string;
|
|
58
|
+
}
|
|
59
|
+
/** Selected index configuration; unavailable server defaults remain null. */
|
|
60
|
+
export interface IndexSettings {
|
|
61
|
+
primaries: number | null;
|
|
62
|
+
replicas: number | null;
|
|
63
|
+
refreshInterval: string | null;
|
|
64
|
+
createdAt: string | null;
|
|
65
|
+
uuid: string | null;
|
|
66
|
+
}
|
|
67
|
+
/** Complete mapping and alias definitions belong to OpenSearch, not a CLI-specific schema. */
|
|
68
|
+
export interface IndexInspection {
|
|
69
|
+
name: string;
|
|
70
|
+
settings: IndexSettings;
|
|
71
|
+
aliases: Record<string, unknown>;
|
|
72
|
+
mappings: Record<string, unknown>;
|
|
73
|
+
}
|
|
74
|
+
export type IndexSection = "settings" | "mappings" | "aliases";
|
|
75
|
+
/** Field types and their concrete indices; a conflict retains every observed type. */
|
|
76
|
+
export interface FieldSummary {
|
|
77
|
+
name: string;
|
|
78
|
+
types: string[];
|
|
79
|
+
conflict: boolean;
|
|
80
|
+
indicesByType: Record<string, string[]>;
|
|
81
|
+
}
|
|
82
|
+
/** Count observations retain shard evidence so incomplete counts cannot look exact. */
|
|
83
|
+
export interface CountResult {
|
|
84
|
+
count: number;
|
|
85
|
+
_shards: {
|
|
86
|
+
total: number;
|
|
87
|
+
successful: number;
|
|
88
|
+
skipped?: number;
|
|
89
|
+
failed: number;
|
|
90
|
+
failures?: ShardFailure[];
|
|
91
|
+
};
|
|
92
|
+
partial: {
|
|
93
|
+
failedShards: number;
|
|
94
|
+
totalShards: number;
|
|
95
|
+
failures: ShardFailure[];
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
/** Selected shard identity and redacted diagnostics shared by every result/output path. */
|
|
99
|
+
export interface ShardFailure {
|
|
100
|
+
shard?: number;
|
|
101
|
+
index?: string;
|
|
102
|
+
node?: string;
|
|
103
|
+
reason?: ShardFailureReason;
|
|
104
|
+
}
|
|
105
|
+
/** Supported OpenSearch cause structure; diagnostic strings use Core's redaction policy. */
|
|
106
|
+
export interface ShardFailureReason {
|
|
107
|
+
type?: string;
|
|
108
|
+
reason?: string;
|
|
109
|
+
caused_by?: ShardFailureReason;
|
|
110
|
+
root_cause?: ShardFailureReason[];
|
|
111
|
+
suppressed?: ShardFailureReason[];
|
|
112
|
+
}
|
|
113
|
+
/** Invocation-owned transport; paths and status semantics belong to OpenSearch. */
|
|
114
|
+
export declare class OpenSearchClient {
|
|
115
|
+
private readonly connection;
|
|
116
|
+
constructor(connection: {
|
|
117
|
+
url: string;
|
|
118
|
+
fetch: typeof fetch;
|
|
119
|
+
signal: AbortSignal;
|
|
120
|
+
authorization?: string;
|
|
121
|
+
});
|
|
122
|
+
private request;
|
|
123
|
+
info(): Promise<ClusterInfo>;
|
|
124
|
+
identity(): Promise<{
|
|
125
|
+
url: string;
|
|
126
|
+
reachable: true;
|
|
127
|
+
user?: string;
|
|
128
|
+
roles?: string[];
|
|
129
|
+
}>;
|
|
130
|
+
health(options: {
|
|
131
|
+
index?: string;
|
|
132
|
+
level?: string;
|
|
133
|
+
waitForStatus?: string;
|
|
134
|
+
timeout?: string;
|
|
135
|
+
}): Promise<ClusterHealth>;
|
|
136
|
+
indices(options: {
|
|
137
|
+
pattern?: string;
|
|
138
|
+
health?: string;
|
|
139
|
+
includeHidden?: boolean;
|
|
140
|
+
}): Promise<IndexSummary[]>;
|
|
141
|
+
aliases(pattern?: string): Promise<AliasSummary[]>;
|
|
142
|
+
dataStreams(pattern?: string): Promise<DataStreamSummary[]>;
|
|
143
|
+
index(index: string, section?: IndexSection): Promise<IndexInspection | IndexSettings | Record<string, unknown>>;
|
|
144
|
+
fields(pattern: string, options: {
|
|
145
|
+
type?: string;
|
|
146
|
+
filter?: string;
|
|
147
|
+
includeMeta?: boolean;
|
|
148
|
+
}): Promise<FieldSummary[]>;
|
|
149
|
+
count(index: string, options: {
|
|
150
|
+
q?: string;
|
|
151
|
+
query?: Record<string, unknown>;
|
|
152
|
+
}): Promise<CountResult>;
|
|
153
|
+
}
|
|
154
|
+
export declare function clientFor(context: CommandContext): Promise<OpenSearchClient>;
|
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
import { CliError, HttpError, diagnosticCause, fetchWithRedirects, readResponseBody } from "@eyeauras/cli-factory";
|
|
2
|
+
function catCount(value) {
|
|
3
|
+
if (value === null)
|
|
4
|
+
return null;
|
|
5
|
+
if (typeof value === "string" && /^\d+$/.test(value) && Number.isSafeInteger(Number(value)))
|
|
6
|
+
return Number(value);
|
|
7
|
+
throw new CliError("OpenSearch returned an invalid index count or size.", { code: "opensearch.invalidResponse" });
|
|
8
|
+
}
|
|
9
|
+
function safeShardReason(reason) {
|
|
10
|
+
const safe = diagnosticCause(Object.assign(new Error(reason.reason ?? ""), { name: reason.type ?? "Error" }));
|
|
11
|
+
return {
|
|
12
|
+
...(reason.type === undefined ? {} : { type: safe.name }),
|
|
13
|
+
...(reason.reason === undefined ? {} : { reason: safe.message }),
|
|
14
|
+
...(reason.caused_by === undefined ? {} : { caused_by: safeShardReason(reason.caused_by) }),
|
|
15
|
+
...(reason.root_cause === undefined ? {} : { root_cause: reason.root_cause.map(safeShardReason) }),
|
|
16
|
+
...(reason.suppressed === undefined ? {} : { suppressed: reason.suppressed.map(safeShardReason) }),
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
// Reserved mapper metadata, not a blanket underscore-prefix rule for user fields.
|
|
20
|
+
const metadataFields = new Set(["_id", "_index", "_source", "_routing", "_ignored", "_field_names", "_version", "_seq_no", "_primary_term", "_size", "_doc_count", "_nested_path", "_tombstone", "_parent_join", "_data_stream_timestamp", "_feature"]);
|
|
21
|
+
/** Invocation-owned transport; paths and status semantics belong to OpenSearch. */
|
|
22
|
+
export class OpenSearchClient {
|
|
23
|
+
connection;
|
|
24
|
+
constructor(connection) {
|
|
25
|
+
this.connection = connection;
|
|
26
|
+
}
|
|
27
|
+
async request(path, accepted = [], body) {
|
|
28
|
+
const { url, fetch, signal, authorization } = this.connection;
|
|
29
|
+
const response = await fetchWithRedirects(fetch, new URL(path, url), {
|
|
30
|
+
signal, ...(body === undefined ? {} : { method: "POST", body: JSON.stringify(body) }),
|
|
31
|
+
headers: { accept: "application/json", ...(body === undefined ? {} : { "content-type": "application/json" }), ...(authorization ? { authorization } : {}) },
|
|
32
|
+
});
|
|
33
|
+
if (!response.ok && !accepted.includes(response.status)) {
|
|
34
|
+
await response.body?.cancel();
|
|
35
|
+
throw new HttpError(response.status, "OpenSearch rejected the request.");
|
|
36
|
+
}
|
|
37
|
+
const bytes = await readResponseBody(response, { signal });
|
|
38
|
+
let value;
|
|
39
|
+
try {
|
|
40
|
+
value = JSON.parse(new TextDecoder().decode(bytes));
|
|
41
|
+
}
|
|
42
|
+
catch (cause) {
|
|
43
|
+
throw new CliError("OpenSearch returned an invalid JSON response.", { code: "opensearch.invalidResponse", cause: diagnosticCause(cause, [], "Response JSON could not be decoded.") });
|
|
44
|
+
}
|
|
45
|
+
return { status: response.status, value };
|
|
46
|
+
}
|
|
47
|
+
async info() {
|
|
48
|
+
const { value } = await this.request("");
|
|
49
|
+
if (!value || typeof value.cluster_name !== "string" || typeof value.cluster_uuid !== "string" || typeof value.version?.number !== "string")
|
|
50
|
+
throw new CliError("OpenSearch returned incomplete cluster metadata.", { code: "opensearch.invalidResponse" });
|
|
51
|
+
return value;
|
|
52
|
+
}
|
|
53
|
+
async identity() {
|
|
54
|
+
await this.info();
|
|
55
|
+
const base = { url: this.connection.url, reachable: true };
|
|
56
|
+
try {
|
|
57
|
+
const { value } = await this.request("_plugins/_security/authinfo");
|
|
58
|
+
return {
|
|
59
|
+
...base,
|
|
60
|
+
...(typeof value?.user_name === "string" ? { user: value.user_name } : {}),
|
|
61
|
+
...(Array.isArray(value?.roles) ? { roles: value.roles } : {}),
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
catch (error) {
|
|
65
|
+
if (error instanceof HttpError && [403, 404].includes(error.status))
|
|
66
|
+
return base;
|
|
67
|
+
throw error;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
async health(options) {
|
|
71
|
+
const query = new URLSearchParams();
|
|
72
|
+
for (const [key, value] of [["level", options.level], ["wait_for_status", options.waitForStatus], ["timeout", options.timeout]])
|
|
73
|
+
if (value !== undefined)
|
|
74
|
+
query.set(key, value);
|
|
75
|
+
const path = "_cluster/health" + (options.index === undefined ? "" : "/" + encodeURIComponent(options.index)) + (query.size ? "?" + query : "");
|
|
76
|
+
const { status, value } = await this.request(path, [408]);
|
|
77
|
+
if (!value || typeof value.status !== "string" || typeof value.timed_out !== "boolean")
|
|
78
|
+
throw new CliError("OpenSearch returned incomplete cluster health.", { code: "opensearch.invalidResponse" });
|
|
79
|
+
if (value.timed_out)
|
|
80
|
+
throw new CliError("OpenSearch timed out waiting for cluster health.", { code: "opensearch.healthTimeout", result: value });
|
|
81
|
+
if (status === 408)
|
|
82
|
+
throw new HttpError(status, "OpenSearch rejected the health request.");
|
|
83
|
+
return value;
|
|
84
|
+
}
|
|
85
|
+
async indices(options) {
|
|
86
|
+
const pattern = encodeURIComponent(options.pattern ?? "*");
|
|
87
|
+
const query = new URLSearchParams({
|
|
88
|
+
format: "json", bytes: "b", h: "index,health,status,pri,rep,docs.count,store.size",
|
|
89
|
+
expand_wildcards: options.includeHidden ? "all" : "open,closed", allow_no_indices: "true",
|
|
90
|
+
});
|
|
91
|
+
if (options.health !== undefined)
|
|
92
|
+
query.set("health", options.health);
|
|
93
|
+
const { value } = await this.request(`_cat/indices/${pattern}?${query}`);
|
|
94
|
+
if (!Array.isArray(value))
|
|
95
|
+
throw new CliError("OpenSearch returned an invalid index list.", { code: "opensearch.invalidResponse" });
|
|
96
|
+
let rows = value.map(row => {
|
|
97
|
+
if (!row || typeof row.index !== "string" || typeof row.health !== "string" || typeof row.status !== "string")
|
|
98
|
+
throw new CliError("OpenSearch returned incomplete index metadata.", { code: "opensearch.invalidResponse" });
|
|
99
|
+
return {
|
|
100
|
+
name: row.index, health: row.health || null, status: row.status,
|
|
101
|
+
primaries: catCount(row.pri), replicas: catCount(row.rep), docsCount: catCount(row["docs.count"]), storeBytes: catCount(row["store.size"]),
|
|
102
|
+
};
|
|
103
|
+
});
|
|
104
|
+
if (!options.includeHidden && rows.length) {
|
|
105
|
+
// CAT has no system classification before 3.9. Metadata also handles explicit hidden names.
|
|
106
|
+
const classificationQuery = new URLSearchParams({
|
|
107
|
+
filter_path: "metadata.indices.*.system,metadata.indices.*.settings.index.hidden",
|
|
108
|
+
expand_wildcards: "all", allow_no_indices: "true",
|
|
109
|
+
});
|
|
110
|
+
const { value: classification } = await this.request(`_cluster/state/metadata/${pattern}?${classificationQuery}`);
|
|
111
|
+
rows = rows.filter(row => {
|
|
112
|
+
const metadata = classification?.metadata?.indices?.[row.name];
|
|
113
|
+
if (typeof metadata?.system !== "boolean")
|
|
114
|
+
throw new CliError("OpenSearch returned incomplete index classification.", { code: "opensearch.invalidResponse" });
|
|
115
|
+
return !metadata.system && metadata.settings?.index?.hidden !== "true";
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
return rows.sort((a, b) => a.name < b.name ? -1 : a.name > b.name ? 1 : 0);
|
|
119
|
+
}
|
|
120
|
+
async aliases(pattern = "*") {
|
|
121
|
+
const query = new URLSearchParams({ format: "json", h: "alias,index,is_write_index,filter,routing.index,routing.search" });
|
|
122
|
+
const { value } = await this.request(`_cat/aliases/${encodeURIComponent(pattern)}?${query}`);
|
|
123
|
+
return value.map(row => ({ alias: row.alias, index: row.index, writeIndex: row.is_write_index === "-" ? null : row.is_write_index === "true",
|
|
124
|
+
hasFilter: row.filter === "*", hasRouting: row["routing.index"] !== "-" || row["routing.search"] !== "-" }))
|
|
125
|
+
.sort((a, b) => a.alias.localeCompare(b.alias) || a.index.localeCompare(b.index));
|
|
126
|
+
}
|
|
127
|
+
async dataStreams(pattern = "*") {
|
|
128
|
+
const { status, value } = await this.request(`_data_stream/${encodeURIComponent(pattern)}`, [404]);
|
|
129
|
+
if (status === 404) {
|
|
130
|
+
if (value.error?.type === "resource_not_found_exception")
|
|
131
|
+
return [];
|
|
132
|
+
throw new HttpError(status, "OpenSearch rejected the data stream request.");
|
|
133
|
+
}
|
|
134
|
+
return value.data_streams.map(stream => ({ name: stream.name, timestampField: stream.timestamp_field.name,
|
|
135
|
+
generation: stream.generation, backingIndexCount: stream.indices.length, health: stream.status.toLowerCase() }))
|
|
136
|
+
.sort((a, b) => a.name.localeCompare(b.name));
|
|
137
|
+
}
|
|
138
|
+
async index(index, section) {
|
|
139
|
+
const path = encodeURIComponent(index) + (section === "settings" ? "/_settings" : section === "mappings" ? "/_mapping" : "");
|
|
140
|
+
let value;
|
|
141
|
+
try {
|
|
142
|
+
({ value } = await this.request(path + (section === "mappings" ? "" : "?flat_settings=false&include_defaults=true")));
|
|
143
|
+
}
|
|
144
|
+
catch (cause) {
|
|
145
|
+
if (cause instanceof HttpError && cause.status === 404)
|
|
146
|
+
throw new CliError("The index was not found.", { code: "index.notFound", cause, next: [["indices", "list"]] });
|
|
147
|
+
throw cause;
|
|
148
|
+
}
|
|
149
|
+
const names = Object.keys(value);
|
|
150
|
+
if (names.length !== 1)
|
|
151
|
+
throw new CliError("Select one concrete index.", { code: "usage", next: [["indices", "list"]] });
|
|
152
|
+
const name = names[0];
|
|
153
|
+
const item = value[name];
|
|
154
|
+
if (section === "mappings")
|
|
155
|
+
return item.mappings ?? {};
|
|
156
|
+
if (section === "aliases")
|
|
157
|
+
return item.aliases ?? {};
|
|
158
|
+
const wire = { ...item.defaults?.index, ...item.settings?.index };
|
|
159
|
+
const settings = {
|
|
160
|
+
primaries: catCount(wire.number_of_shards ?? null), replicas: catCount(wire.number_of_replicas ?? null),
|
|
161
|
+
refreshInterval: wire.refresh_interval ?? null, uuid: wire.uuid ?? null,
|
|
162
|
+
createdAt: wire.creation_date === undefined ? null : new Date(Number(wire.creation_date)).toISOString(),
|
|
163
|
+
};
|
|
164
|
+
return section === "settings" ? settings : { name, settings, aliases: item.aliases ?? {}, mappings: item.mappings ?? {} };
|
|
165
|
+
}
|
|
166
|
+
async fields(pattern, options) {
|
|
167
|
+
const path = encodeURIComponent(pattern);
|
|
168
|
+
const { value: mappings } = await this.request(`${path}/_mapping?allow_no_indices=true`);
|
|
169
|
+
const { value: capabilities } = await this.request(`${path}/_field_caps?fields=*&allow_no_indices=true&include_unmapped=true`);
|
|
170
|
+
const grouped = new Map();
|
|
171
|
+
const walk = (properties, prefix, index) => {
|
|
172
|
+
for (const [key, node] of Object.entries(properties)) {
|
|
173
|
+
const name = prefix + key;
|
|
174
|
+
const type = node.type ?? "object";
|
|
175
|
+
const types = grouped.get(name) ?? new Map();
|
|
176
|
+
const indices = types.get(type) ?? new Set();
|
|
177
|
+
indices.add(index);
|
|
178
|
+
types.set(type, indices);
|
|
179
|
+
grouped.set(name, types);
|
|
180
|
+
if (node.properties)
|
|
181
|
+
walk(node.properties, name + ".", index);
|
|
182
|
+
if (node.fields)
|
|
183
|
+
walk(node.fields, name + ".", index);
|
|
184
|
+
}
|
|
185
|
+
};
|
|
186
|
+
for (const [index, mapping] of Object.entries(mappings))
|
|
187
|
+
walk(mapping.mappings.properties ?? {}, "", index);
|
|
188
|
+
const declared = new Set(grouped.keys());
|
|
189
|
+
for (const [name, caps] of Object.entries(capabilities.fields)) {
|
|
190
|
+
const mapped = grouped.get(name);
|
|
191
|
+
const types = Object.keys(caps).filter(type => type !== "unmapped");
|
|
192
|
+
const allMapped = mapped ? [...new Set([...mapped.values()].flatMap(indices => [...indices]))] : capabilities.indices;
|
|
193
|
+
const merged = new Map(types.map(type => [type, new Set(caps[type].indices ?? (types.length === 1 ? allMapped : [...mapped?.get(type) ?? []]))]));
|
|
194
|
+
// Capabilities discover object parents from indexed children; disabled/empty objects need mapping evidence.
|
|
195
|
+
for (const [type, indices] of mapped ?? [])
|
|
196
|
+
if (type === "object" || type === "nested") {
|
|
197
|
+
const members = merged.get(type) ?? new Set();
|
|
198
|
+
for (const index of indices)
|
|
199
|
+
members.add(index);
|
|
200
|
+
merged.set(type, members);
|
|
201
|
+
}
|
|
202
|
+
grouped.set(name, merged);
|
|
203
|
+
}
|
|
204
|
+
const filter = options.filter === undefined ? undefined : new RegExp("^" + options.filter.replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*/g, ".*").replace(/\?/g, ".") + "$");
|
|
205
|
+
return [...grouped].sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0)
|
|
206
|
+
.filter(([name, types]) => (options.includeMeta || declared.has(name) || !metadataFields.has(name)) && (!options.type || types.has(options.type)) && (!filter || filter.test(name)))
|
|
207
|
+
.map(([name, groups]) => {
|
|
208
|
+
const types = [...groups.keys()].sort();
|
|
209
|
+
return { name, types, conflict: types.length > 1, indicesByType: Object.fromEntries(types.map(type => [type, [...groups.get(type)].sort()])) };
|
|
210
|
+
});
|
|
211
|
+
}
|
|
212
|
+
async count(index, options) {
|
|
213
|
+
if (options.q !== undefined && options.query !== undefined)
|
|
214
|
+
throw new CliError("Choose --q or --query-file.", { code: "usage" });
|
|
215
|
+
const query = options.q === undefined ? "" : "?" + new URLSearchParams({ q: options.q });
|
|
216
|
+
const { value } = await this.request(`${encodeURIComponent(index)}/_count${query}`, [], options.query === undefined ? undefined : { query: options.query });
|
|
217
|
+
if (!Number.isSafeInteger(value?.count) || value.count < 0 || !Number.isSafeInteger(value._shards?.total) || !Number.isSafeInteger(value._shards?.failed))
|
|
218
|
+
throw new CliError("OpenSearch returned incomplete count metadata.", { code: "opensearch.invalidResponse" });
|
|
219
|
+
const failures = (value._shards.failures ?? []).map(failure => ({
|
|
220
|
+
...(failure.shard === undefined ? {} : { shard: failure.shard }),
|
|
221
|
+
...(failure.index === undefined ? {} : { index: failure.index }),
|
|
222
|
+
...(failure.node === undefined ? {} : { node: failure.node }),
|
|
223
|
+
...(failure.reason === undefined ? {} : { reason: safeShardReason(failure.reason) }),
|
|
224
|
+
}));
|
|
225
|
+
const shards = { total: value._shards.total, successful: value._shards.successful, failed: value._shards.failed,
|
|
226
|
+
...(value._shards.skipped === undefined ? {} : { skipped: value._shards.skipped }),
|
|
227
|
+
...(value._shards.failures === undefined ? {} : { failures }) };
|
|
228
|
+
return { count: value.count, _shards: shards, partial: { failedShards: shards.failed, totalShards: shards.total, failures } };
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
export async function clientFor(context) {
|
|
232
|
+
const scheme = context.profile.values.auth;
|
|
233
|
+
let authorization;
|
|
234
|
+
if (scheme !== "none") {
|
|
235
|
+
const credential = await context.secrets.get(scheme === "bearer" ? "token" : "basic");
|
|
236
|
+
if (!credential)
|
|
237
|
+
throw new CliError("Sign in before querying OpenSearch.", { code: "auth.missing", next: [["auth", "login", "--help"]] });
|
|
238
|
+
if (scheme === "bearer")
|
|
239
|
+
authorization = "Bearer " + credential;
|
|
240
|
+
else {
|
|
241
|
+
const basic = JSON.parse(credential);
|
|
242
|
+
authorization = "Basic " + Buffer.from(`${basic.username}:${basic.password}`).toString("base64");
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
return new OpenSearchClient({ url: String(context.profile.values.url), fetch: context.fetch, signal: context.signal, ...(authorization ? { authorization } : {}) });
|
|
246
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { createDefinition } from "./cli.js";
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { createDefinition } from "./cli.js";
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { readFile } from "node:fs/promises";
|
|
2
|
+
import { resolve } from "node:path";
|
|
3
|
+
import { addAbortSignal } from "node:stream";
|
|
4
|
+
import { text } from "node:stream/consumers";
|
|
5
|
+
import { CliError, diagnosticCause, jsonParser } from "@eyeauras/cli-factory";
|
|
6
|
+
/** Reads a Query DSL query object; stdin must be fresh and is reserved by RPC sessions. */
|
|
7
|
+
export async function readQuery(file, context) {
|
|
8
|
+
let source;
|
|
9
|
+
try {
|
|
10
|
+
if (file === "-") {
|
|
11
|
+
if (context.io.input.readableDidRead)
|
|
12
|
+
throw new CliError("Use a query file when stdin is already in use.", { code: "usage" });
|
|
13
|
+
source = await text(addAbortSignal(context.signal, context.io.input));
|
|
14
|
+
}
|
|
15
|
+
else
|
|
16
|
+
source = await readFile(resolve(context.cwd, file), { encoding: "utf8", signal: context.signal });
|
|
17
|
+
}
|
|
18
|
+
catch (cause) {
|
|
19
|
+
if (cause instanceof CliError)
|
|
20
|
+
throw cause;
|
|
21
|
+
throw new CliError("Could not read the query input.", { code: "query.readFailed", cause: diagnosticCause(cause) });
|
|
22
|
+
}
|
|
23
|
+
let query;
|
|
24
|
+
try {
|
|
25
|
+
query = jsonParser("Query input must contain valid JSON.")(source);
|
|
26
|
+
}
|
|
27
|
+
catch (cause) {
|
|
28
|
+
throw new CliError("Could not parse the query input.", { code: "query.invalid", cause });
|
|
29
|
+
}
|
|
30
|
+
if (!query || typeof query !== "object" || Array.isArray(query))
|
|
31
|
+
throw new CliError("Query input must contain a Query DSL query object.", { code: "query.invalid" });
|
|
32
|
+
return query;
|
|
33
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare const version = "0.1.0";
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export const version = "0.1.0";
|
package/package.json
CHANGED
|
@@ -1,6 +1,52 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@eyeauras/opensearch-cli",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Explore and manage OpenSearch clusters",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"engines": {
|
|
8
|
+
"node": ">=22"
|
|
9
|
+
},
|
|
10
|
+
"packageManager": "npm@11.21.1",
|
|
11
|
+
"bin": {
|
|
12
|
+
"opensearch-cli": "./dist/src/bin.js"
|
|
13
|
+
},
|
|
14
|
+
"exports": {
|
|
15
|
+
".": {
|
|
16
|
+
"types": "./dist/src/index.d.ts",
|
|
17
|
+
"import": "./dist/src/index.js"
|
|
18
|
+
}
|
|
19
|
+
},
|
|
20
|
+
"files": [
|
|
21
|
+
"dist/src",
|
|
22
|
+
"README.md",
|
|
23
|
+
"LICENSE"
|
|
24
|
+
],
|
|
25
|
+
"publishConfig": {
|
|
26
|
+
"access": "public"
|
|
27
|
+
},
|
|
28
|
+
"repository": {
|
|
29
|
+
"type": "git",
|
|
30
|
+
"url": "git+https://github.com/iXab3r/opensearch-cli.git"
|
|
31
|
+
},
|
|
32
|
+
"homepage": "https://github.com/iXab3r/opensearch-cli#readme",
|
|
33
|
+
"bugs": {
|
|
34
|
+
"url": "https://github.com/iXab3r/opensearch-cli/issues"
|
|
35
|
+
},
|
|
36
|
+
"scripts": {
|
|
37
|
+
"build": "tsc --project tsconfig.json",
|
|
38
|
+
"test": "npm run build && node --test dist/tests/*.test.js",
|
|
39
|
+
"start": "node dist/src/bin.js",
|
|
40
|
+
"test:package": "node scripts/test-package.mjs",
|
|
41
|
+
"test:registry": "node scripts/test-registry.mjs",
|
|
42
|
+
"proof": "npm run build && node scripts/proof.mjs"
|
|
43
|
+
},
|
|
44
|
+
"dependencies": {
|
|
45
|
+
"@eyeauras/cli-factory": "0.5.3"
|
|
46
|
+
},
|
|
47
|
+
"devDependencies": {
|
|
48
|
+
"typescript": "7.0.2",
|
|
49
|
+
"@types/node": "24.19.2",
|
|
50
|
+
"msw": "3.0.3"
|
|
51
|
+
}
|
|
52
|
+
}
|