wildash-cli 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +53 -0
- package/bin/wildash.js +2 -0
- package/dist/index.js +850 -0
- package/package.json +40 -0
package/README.md
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# WiLDash CLI
|
|
2
|
+
|
|
3
|
+
Run analyses, check progress, and export results from the terminal.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npm install -g wildash-cli
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
Or run without installing:
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
npx wildash-cli --help
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
## Quick Start
|
|
18
|
+
|
|
19
|
+
```bash
|
|
20
|
+
wildash login
|
|
21
|
+
wildash run --log ./sample.csv --location ./locations.csv --wait
|
|
22
|
+
wildash summary --latest
|
|
23
|
+
wildash export --latest --target overview --out ./exports/
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
## Commands
|
|
27
|
+
|
|
28
|
+
```
|
|
29
|
+
wildash login Sign in and store credentials
|
|
30
|
+
wildash whoami Show current account
|
|
31
|
+
wildash logout Remove local credentials
|
|
32
|
+
wildash run Upload files and start analysis
|
|
33
|
+
wildash list List recent analyses
|
|
34
|
+
wildash status Show analysis progress
|
|
35
|
+
wildash cancel Stop a running analysis
|
|
36
|
+
wildash summary Show analysis summary
|
|
37
|
+
wildash export Download results as ZIP
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
## Common Flags
|
|
41
|
+
|
|
42
|
+
- `--latest` use your most recent analysis
|
|
43
|
+
- `--json` print JSON instead of text
|
|
44
|
+
- `--api-url` use a different WiLDash server
|
|
45
|
+
|
|
46
|
+
## Environment Variables
|
|
47
|
+
|
|
48
|
+
- `WILDASH_API_URL` sets the default server
|
|
49
|
+
- `WILDASH_PASSWORD` lets `wildash login` run without a password prompt
|
|
50
|
+
|
|
51
|
+
## Requirements
|
|
52
|
+
|
|
53
|
+
- Node.js >= 18
|
package/bin/wildash.js
ADDED
package/dist/index.js
ADDED
|
@@ -0,0 +1,850 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __create = Object.create;
|
|
3
|
+
var __defProp = Object.defineProperty;
|
|
4
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
5
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
6
|
+
var __getProtoOf = Object.getPrototypeOf;
|
|
7
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
8
|
+
var __copyProps = (to, from, except, desc) => {
|
|
9
|
+
if (from && typeof from === "object" || typeof from === "function") {
|
|
10
|
+
for (let key of __getOwnPropNames(from))
|
|
11
|
+
if (!__hasOwnProp.call(to, key) && key !== except)
|
|
12
|
+
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
|
13
|
+
}
|
|
14
|
+
return to;
|
|
15
|
+
};
|
|
16
|
+
var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
|
|
17
|
+
// If the importer is in node compatibility mode or this is not an ESM
|
|
18
|
+
// file that has been converted to a CommonJS file using a Babel-
|
|
19
|
+
// compatible transform (i.e. "__esModule" has not been set), then set
|
|
20
|
+
// "default" to the CommonJS "module.exports" for node compatibility.
|
|
21
|
+
isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
|
|
22
|
+
mod
|
|
23
|
+
));
|
|
24
|
+
|
|
25
|
+
// src/index.ts
|
|
26
|
+
var import_commander = require("commander");
|
|
27
|
+
|
|
28
|
+
// src/lib/errors.ts
|
|
29
|
+
var CLIError = class extends Error {
|
|
30
|
+
hint;
|
|
31
|
+
exitCode;
|
|
32
|
+
constructor(message, opts) {
|
|
33
|
+
super(message);
|
|
34
|
+
this.name = "CLIError";
|
|
35
|
+
this.hint = opts?.hint;
|
|
36
|
+
this.exitCode = opts?.exitCode ?? 1;
|
|
37
|
+
}
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
// src/lib/output.ts
|
|
41
|
+
function emit(opts) {
|
|
42
|
+
if (opts.jsonMode) {
|
|
43
|
+
console.log(
|
|
44
|
+
JSON.stringify(
|
|
45
|
+
{ ok: true, command: opts.command, data: opts.data },
|
|
46
|
+
null,
|
|
47
|
+
2
|
|
48
|
+
)
|
|
49
|
+
);
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
for (const line of opts.lines ?? []) {
|
|
53
|
+
console.log(line);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
function emitError(opts) {
|
|
57
|
+
const exitCode = opts.exitCode ?? 1;
|
|
58
|
+
if (opts.jsonMode) {
|
|
59
|
+
process.stderr.write(
|
|
60
|
+
JSON.stringify(
|
|
61
|
+
{
|
|
62
|
+
ok: false,
|
|
63
|
+
command: opts.command,
|
|
64
|
+
error: { message: opts.message, hint: opts.hint ?? null, exit_code: exitCode }
|
|
65
|
+
},
|
|
66
|
+
null,
|
|
67
|
+
2
|
|
68
|
+
) + "\n"
|
|
69
|
+
);
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
process.stderr.write(`Error: ${opts.message}
|
|
73
|
+
`);
|
|
74
|
+
if (opts.hint) {
|
|
75
|
+
process.stderr.write(`Hint: ${opts.hint}
|
|
76
|
+
`);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// src/commands/login.ts
|
|
81
|
+
var import_prompts = require("@inquirer/prompts");
|
|
82
|
+
|
|
83
|
+
// src/lib/operations.ts
|
|
84
|
+
var import_node_fs3 = __toESM(require("fs"));
|
|
85
|
+
var import_node_os2 = __toESM(require("os"));
|
|
86
|
+
var import_node_path3 = __toESM(require("path"));
|
|
87
|
+
|
|
88
|
+
// src/lib/client.ts
|
|
89
|
+
var import_node_fs2 = __toESM(require("fs"));
|
|
90
|
+
var import_node_path2 = __toESM(require("path"));
|
|
91
|
+
var import_node_stream = require("stream");
|
|
92
|
+
|
|
93
|
+
// src/lib/constants.ts
|
|
94
|
+
var DEFAULT_API_URL = "http://127.0.0.1:8000/api";
|
|
95
|
+
var DEFAULT_TIMEOUT_MS = 6e4;
|
|
96
|
+
var UPLOAD_TIMEOUT_MS = 30 * 6e4;
|
|
97
|
+
var EXPORT_TARGET_ALIASES = {
|
|
98
|
+
overview: "overview",
|
|
99
|
+
map: "flow_map",
|
|
100
|
+
flow_map: "flow_map",
|
|
101
|
+
network: "flow_network",
|
|
102
|
+
flow_network: "flow_network",
|
|
103
|
+
temporal: "temporal",
|
|
104
|
+
all: "all"
|
|
105
|
+
};
|
|
106
|
+
var TERMINAL_SESSION_STATUSES = /* @__PURE__ */ new Set([
|
|
107
|
+
"completed",
|
|
108
|
+
"failed",
|
|
109
|
+
"cancelled",
|
|
110
|
+
"paused"
|
|
111
|
+
]);
|
|
112
|
+
|
|
113
|
+
// src/lib/state.ts
|
|
114
|
+
var import_node_fs = __toESM(require("fs"));
|
|
115
|
+
var import_node_path = __toESM(require("path"));
|
|
116
|
+
var import_node_os = __toESM(require("os"));
|
|
117
|
+
function configDir() {
|
|
118
|
+
return import_node_path.default.join(import_node_os.default.homedir(), ".config", "wildash");
|
|
119
|
+
}
|
|
120
|
+
function authStorePath() {
|
|
121
|
+
return import_node_path.default.join(configDir(), "auth.json");
|
|
122
|
+
}
|
|
123
|
+
function legacyAuthStorePath() {
|
|
124
|
+
return import_node_path.default.join(import_node_os.default.homedir(), ".wildash", "credentials.json");
|
|
125
|
+
}
|
|
126
|
+
function readJsonFile(filePath) {
|
|
127
|
+
try {
|
|
128
|
+
return import_node_fs.default.readFileSync(filePath, "utf-8");
|
|
129
|
+
} catch {
|
|
130
|
+
return null;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
function loadAuthState() {
|
|
134
|
+
for (const filePath of [authStorePath(), legacyAuthStorePath()]) {
|
|
135
|
+
const raw = readJsonFile(filePath);
|
|
136
|
+
if (!raw) continue;
|
|
137
|
+
try {
|
|
138
|
+
const data = JSON.parse(raw);
|
|
139
|
+
if (typeof data === "object" && data !== null && !Array.isArray(data)) return data;
|
|
140
|
+
} catch {
|
|
141
|
+
throw new CLIError(`State file contains invalid JSON: ${filePath}`, {
|
|
142
|
+
hint: `Fix or remove ${filePath} and retry.`
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
return {};
|
|
147
|
+
}
|
|
148
|
+
function saveAuthState(payload) {
|
|
149
|
+
const filePath = authStorePath();
|
|
150
|
+
import_node_fs.default.mkdirSync(import_node_path.default.dirname(filePath), { recursive: true });
|
|
151
|
+
import_node_fs.default.writeFileSync(
|
|
152
|
+
filePath,
|
|
153
|
+
JSON.stringify(payload, null, 2) + "\n",
|
|
154
|
+
"utf-8"
|
|
155
|
+
);
|
|
156
|
+
}
|
|
157
|
+
function unlinkIfExists(filePath) {
|
|
158
|
+
try {
|
|
159
|
+
import_node_fs.default.unlinkSync(filePath);
|
|
160
|
+
} catch (err) {
|
|
161
|
+
if (err.code !== "ENOENT") throw err;
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
function clearAuthState() {
|
|
165
|
+
unlinkIfExists(authStorePath());
|
|
166
|
+
unlinkIfExists(legacyAuthStorePath());
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// src/lib/client.ts
|
|
170
|
+
function normalizeApiRoot(value) {
|
|
171
|
+
let text = (value ?? "").trim() || process.env.WILDASH_API_URL || DEFAULT_API_URL;
|
|
172
|
+
text = text.replace(/\/+$/, "");
|
|
173
|
+
if (text.endsWith("/api/v2")) return text;
|
|
174
|
+
if (text.endsWith("/api")) return `${text}/v2`;
|
|
175
|
+
if (text.endsWith("/v2")) return text;
|
|
176
|
+
return `${text}/api/v2`;
|
|
177
|
+
}
|
|
178
|
+
function extractErrorDetail(body) {
|
|
179
|
+
if (body && typeof body === "object" && !Array.isArray(body)) {
|
|
180
|
+
const obj = body;
|
|
181
|
+
const detail = String(obj.detail ?? obj.error ?? "").trim();
|
|
182
|
+
if (detail) return detail;
|
|
183
|
+
const nonField = obj.non_field_errors;
|
|
184
|
+
if (Array.isArray(nonField) && nonField.length) return String(nonField[0]).trim();
|
|
185
|
+
}
|
|
186
|
+
if (Array.isArray(body) && body.length) return String(body[0]).trim();
|
|
187
|
+
return "Request failed.";
|
|
188
|
+
}
|
|
189
|
+
var MIME_TYPES = {
|
|
190
|
+
".csv": "text/csv",
|
|
191
|
+
".json": "application/json",
|
|
192
|
+
".txt": "text/plain",
|
|
193
|
+
".log": "text/plain",
|
|
194
|
+
".zip": "application/zip"
|
|
195
|
+
};
|
|
196
|
+
var APIClient = class {
|
|
197
|
+
authState;
|
|
198
|
+
apiRoot;
|
|
199
|
+
constructor(apiRoot) {
|
|
200
|
+
this.authState = loadAuthState();
|
|
201
|
+
this.apiRoot = normalizeApiRoot(apiRoot || this.authState.api_root);
|
|
202
|
+
}
|
|
203
|
+
url(p) {
|
|
204
|
+
return `${this.apiRoot}/${p.replace(/^\/+/, "")}`;
|
|
205
|
+
}
|
|
206
|
+
getAccessToken() {
|
|
207
|
+
const token = this.authState.access?.trim();
|
|
208
|
+
if (token) return token;
|
|
209
|
+
throw new CLIError("Not logged in.", { hint: "Run 'wildash login'.", exitCode: 2 });
|
|
210
|
+
}
|
|
211
|
+
async refreshAccessToken() {
|
|
212
|
+
const refresh = this.authState.refresh?.trim();
|
|
213
|
+
if (!refresh) {
|
|
214
|
+
throw new CLIError("Session expired.", { hint: "Run 'wildash login'.", exitCode: 2 });
|
|
215
|
+
}
|
|
216
|
+
const res = await fetch(this.url("auth/refresh/"), {
|
|
217
|
+
method: "POST",
|
|
218
|
+
headers: { "Content-Type": "application/json" },
|
|
219
|
+
body: JSON.stringify({ refresh }),
|
|
220
|
+
signal: AbortSignal.timeout(DEFAULT_TIMEOUT_MS)
|
|
221
|
+
});
|
|
222
|
+
if (res.status !== 200) {
|
|
223
|
+
clearAuthState();
|
|
224
|
+
this.authState = {};
|
|
225
|
+
throw new CLIError("Session expired.", { hint: "Run 'wildash login'.", exitCode: 2 });
|
|
226
|
+
}
|
|
227
|
+
const payload = await res.json();
|
|
228
|
+
const access = String(payload.access ?? "").trim();
|
|
229
|
+
if (!access) {
|
|
230
|
+
clearAuthState();
|
|
231
|
+
this.authState = {};
|
|
232
|
+
throw new CLIError("Session refresh failed.", { hint: "Run 'wildash login'.", exitCode: 2 });
|
|
233
|
+
}
|
|
234
|
+
this.authState.access = access;
|
|
235
|
+
this.authState.api_root = this.apiRoot;
|
|
236
|
+
saveAuthState(this.authState);
|
|
237
|
+
}
|
|
238
|
+
async request(method, urlPath, opts) {
|
|
239
|
+
const auth = opts?.auth ?? true;
|
|
240
|
+
const allowRefresh = opts?.allowRefresh ?? true;
|
|
241
|
+
const timeout = opts?.timeout ?? DEFAULT_TIMEOUT_MS;
|
|
242
|
+
const headers = {};
|
|
243
|
+
if (auth) headers["Authorization"] = `Bearer ${this.getAccessToken()}`;
|
|
244
|
+
let body;
|
|
245
|
+
if (opts?.json !== void 0) {
|
|
246
|
+
headers["Content-Type"] = "application/json";
|
|
247
|
+
body = JSON.stringify(opts.json);
|
|
248
|
+
}
|
|
249
|
+
const doFetch = () => fetch(this.url(urlPath), { method, headers, body, signal: AbortSignal.timeout(timeout) });
|
|
250
|
+
let res = await doFetch();
|
|
251
|
+
if (res.status === 401 && auth && allowRefresh) {
|
|
252
|
+
await this.refreshAccessToken();
|
|
253
|
+
headers["Authorization"] = `Bearer ${this.getAccessToken()}`;
|
|
254
|
+
res = await doFetch();
|
|
255
|
+
}
|
|
256
|
+
return res;
|
|
257
|
+
}
|
|
258
|
+
async requestJson(method, urlPath, opts) {
|
|
259
|
+
const res = await this.request(method, urlPath, opts);
|
|
260
|
+
if (res.status >= 400) await this.raiseForError(res);
|
|
261
|
+
const text = await res.text();
|
|
262
|
+
if (!text) return {};
|
|
263
|
+
try {
|
|
264
|
+
return JSON.parse(text);
|
|
265
|
+
} catch (e) {
|
|
266
|
+
throw new CLIError(`Invalid JSON response from server: ${e}`);
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
async raiseForError(res) {
|
|
270
|
+
const text = await res.text().catch(() => "");
|
|
271
|
+
let body;
|
|
272
|
+
try {
|
|
273
|
+
body = JSON.parse(text);
|
|
274
|
+
} catch {
|
|
275
|
+
throw new CLIError(`${res.status} ${res.statusText}: ${text || "Request failed."}`);
|
|
276
|
+
}
|
|
277
|
+
let hint;
|
|
278
|
+
let exitCode = 1;
|
|
279
|
+
if (body && typeof body === "object" && !Array.isArray(body)) {
|
|
280
|
+
const obj = body;
|
|
281
|
+
if (String(obj.error_code ?? "").toUpperCase() === "LEGAL_CONSENT_REQUIRED") {
|
|
282
|
+
const url = String(obj.legal_url ?? "").trim();
|
|
283
|
+
hint = url ? `You need to accept the latest terms before continuing. Open ${url} and accept them, then try again.` : "You need to accept the latest terms in WiLDash before continuing.";
|
|
284
|
+
exitCode = 3;
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
throw new CLIError(`${res.status} ${res.statusText}: ${extractErrorDetail(body)}`, { hint, exitCode });
|
|
288
|
+
}
|
|
289
|
+
async uploadToPresignedUrl(url, filePath) {
|
|
290
|
+
const ext = import_node_path2.default.extname(filePath).toLowerCase();
|
|
291
|
+
const contentType = MIME_TYPES[ext] ?? "application/octet-stream";
|
|
292
|
+
const stat = import_node_fs2.default.statSync(filePath);
|
|
293
|
+
const body = import_node_stream.Readable.toWeb(import_node_fs2.default.createReadStream(filePath));
|
|
294
|
+
const res = await fetch(url, {
|
|
295
|
+
method: "PUT",
|
|
296
|
+
headers: { "Content-Type": contentType, "Content-Length": String(stat.size) },
|
|
297
|
+
body,
|
|
298
|
+
// @ts-expect-error -- Node fetch requires duplex for streaming bodies
|
|
299
|
+
duplex: "half",
|
|
300
|
+
signal: AbortSignal.timeout(UPLOAD_TIMEOUT_MS)
|
|
301
|
+
});
|
|
302
|
+
if (res.status < 200 || res.status >= 300) {
|
|
303
|
+
const detail = (await res.text()).trim();
|
|
304
|
+
throw new CLIError(detail || `Upload failed (${res.status}).`);
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
};
|
|
308
|
+
|
|
309
|
+
// src/lib/operations.ts
|
|
310
|
+
function sleep(ms) {
|
|
311
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
312
|
+
}
|
|
313
|
+
function expandHome(p) {
|
|
314
|
+
if (p === "~") return import_node_os2.default.homedir();
|
|
315
|
+
return p.startsWith("~/") ? import_node_path3.default.join(import_node_os2.default.homedir(), p.slice(2)) : p;
|
|
316
|
+
}
|
|
317
|
+
function ensureFile(filePath, label) {
|
|
318
|
+
const resolved = import_node_path3.default.resolve(expandHome(filePath));
|
|
319
|
+
if (!import_node_fs3.default.existsSync(resolved) || !import_node_fs3.default.statSync(resolved).isFile()) {
|
|
320
|
+
throw new CLIError(`${label} file not found: ${resolved}`);
|
|
321
|
+
}
|
|
322
|
+
return resolved;
|
|
323
|
+
}
|
|
324
|
+
async function login(opts) {
|
|
325
|
+
if (!opts.email?.trim()) throw new CLIError("Email is required.");
|
|
326
|
+
if (!opts.password) throw new CLIError("Password is required.");
|
|
327
|
+
const client = new APIClient(normalizeApiRoot(opts.apiRoot));
|
|
328
|
+
const payload = await client.requestJson("POST", "auth/sign-in/", {
|
|
329
|
+
auth: false,
|
|
330
|
+
allowRefresh: false,
|
|
331
|
+
json: { email: opts.email.trim(), password: opts.password }
|
|
332
|
+
});
|
|
333
|
+
const access = payload.tokens?.access?.trim();
|
|
334
|
+
const refresh = payload.tokens?.refresh?.trim();
|
|
335
|
+
if (!access || !refresh) {
|
|
336
|
+
throw new CLIError("Login succeeded but tokens are missing from the response.");
|
|
337
|
+
}
|
|
338
|
+
const authState = { api_root: client.apiRoot, access, refresh, user: payload.user ?? {} };
|
|
339
|
+
saveAuthState(authState);
|
|
340
|
+
return { api_root: client.apiRoot, user: authState.user };
|
|
341
|
+
}
|
|
342
|
+
async function whoami(opts) {
|
|
343
|
+
return new APIClient(opts.apiRoot).requestJson("GET", "auth/me/");
|
|
344
|
+
}
|
|
345
|
+
async function createSession(opts) {
|
|
346
|
+
const name = opts.name?.trim();
|
|
347
|
+
if (!name) throw new CLIError("Session name is required.");
|
|
348
|
+
const body = { name };
|
|
349
|
+
if (opts.description) body.description = opts.description;
|
|
350
|
+
return new APIClient(opts.apiRoot).requestJson("POST", "sessions/", { json: body });
|
|
351
|
+
}
|
|
352
|
+
async function listSessions(opts) {
|
|
353
|
+
const payload = await new APIClient(opts.apiRoot).requestJson("GET", "sessions/");
|
|
354
|
+
let rows = Array.isArray(payload) ? payload : payload.results ?? [];
|
|
355
|
+
const wantedStatus = opts.status?.trim().toLowerCase();
|
|
356
|
+
if (wantedStatus) {
|
|
357
|
+
rows = rows.filter((r) => String(r.status ?? "").toLowerCase() === wantedStatus);
|
|
358
|
+
}
|
|
359
|
+
const limit = Number.isFinite(opts.limit) ? Math.max(1, opts.limit) : 20;
|
|
360
|
+
const sliced = rows.slice(0, limit);
|
|
361
|
+
return { count: sliced.length, sessions: sliced };
|
|
362
|
+
}
|
|
363
|
+
async function resolveLatestSessionId(apiRoot) {
|
|
364
|
+
const { sessions } = await listSessions({ apiRoot, limit: 1 });
|
|
365
|
+
if (!sessions.length) {
|
|
366
|
+
throw new CLIError("No analyses found.", {
|
|
367
|
+
hint: "Run 'wildash run --log <file>' to start your first analysis."
|
|
368
|
+
});
|
|
369
|
+
}
|
|
370
|
+
return String(sessions[0].id);
|
|
371
|
+
}
|
|
372
|
+
async function resolveSessionId(opts) {
|
|
373
|
+
if (opts.sessionId?.trim()) return opts.sessionId.trim();
|
|
374
|
+
if (opts.latest) return resolveLatestSessionId(opts.apiRoot);
|
|
375
|
+
throw new CLIError("Analysis ID is required.", {
|
|
376
|
+
hint: "Run 'wildash list' to choose one, or use --latest."
|
|
377
|
+
});
|
|
378
|
+
}
|
|
379
|
+
async function getSessionStatus(opts) {
|
|
380
|
+
return new APIClient(opts.apiRoot).requestJson(
|
|
381
|
+
"GET",
|
|
382
|
+
`sessions/${opts.sessionId}/status/`
|
|
383
|
+
);
|
|
384
|
+
}
|
|
385
|
+
function statusFingerprint(p) {
|
|
386
|
+
const stages = (p.stages ?? []).map((s) => `${s.code}:${s.status}:${s.progress ?? 0}`).join("|");
|
|
387
|
+
return `${p.status}:${p.progress ?? 0}:${stages}`;
|
|
388
|
+
}
|
|
389
|
+
async function waitForSession(opts) {
|
|
390
|
+
let lastFp = "";
|
|
391
|
+
const pollMs = Math.max(500, (Number.isFinite(opts.pollSeconds) ? opts.pollSeconds : 4) * 1e3);
|
|
392
|
+
while (true) {
|
|
393
|
+
const payload = await getSessionStatus({ apiRoot: opts.apiRoot, sessionId: opts.sessionId });
|
|
394
|
+
const fp = statusFingerprint(payload);
|
|
395
|
+
if (fp !== lastFp) opts.onUpdate?.(payload);
|
|
396
|
+
lastFp = fp;
|
|
397
|
+
if (TERMINAL_SESSION_STATUSES.has((payload.status ?? "").toLowerCase())) return payload;
|
|
398
|
+
await sleep(pollMs);
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
async function cancelSession(opts) {
|
|
402
|
+
const body = opts.reason ? { reason: opts.reason.trim() } : {};
|
|
403
|
+
return new APIClient(opts.apiRoot).requestJson("POST", `sessions/${opts.sessionId}/stop/`, { json: body });
|
|
404
|
+
}
|
|
405
|
+
async function runSession(opts) {
|
|
406
|
+
const client = new APIClient(opts.apiRoot);
|
|
407
|
+
const logFile = ensureFile(opts.logPath, "Log");
|
|
408
|
+
const locationFile = opts.locationPath ? ensureFile(opts.locationPath, "Location") : void 0;
|
|
409
|
+
const sessionName = opts.name?.trim() || import_node_path3.default.basename(logFile);
|
|
410
|
+
const sessionDescription = opts.description?.trim() || `CLI run created at ${(/* @__PURE__ */ new Date()).toISOString().replace(/\.\d{3}Z$/, "Z")}`;
|
|
411
|
+
const session = await createSession({
|
|
412
|
+
apiRoot: opts.apiRoot,
|
|
413
|
+
name: sessionName,
|
|
414
|
+
description: sessionDescription
|
|
415
|
+
});
|
|
416
|
+
const sessionId = session.id;
|
|
417
|
+
if (!sessionId) throw new CLIError("Failed to create session: missing session ID in response.");
|
|
418
|
+
const files = [{ kind: "log", filename: import_node_path3.default.basename(logFile) }];
|
|
419
|
+
if (locationFile) files.push({ kind: "location", filename: import_node_path3.default.basename(locationFile) });
|
|
420
|
+
let presign;
|
|
421
|
+
try {
|
|
422
|
+
presign = await client.requestJson("POST", `sessions/${sessionId}/uploads-presign/`, {
|
|
423
|
+
json: { files }
|
|
424
|
+
});
|
|
425
|
+
} catch (e) {
|
|
426
|
+
if (e instanceof CLIError && /Upload service is (unavailable|not configured)/.test(e.message)) {
|
|
427
|
+
throw new CLIError(e.message, {
|
|
428
|
+
hint: "This WiLDash server is not ready for uploads yet. Use the preview stack or ask for uploads to be enabled, then try 'wildash run' again.",
|
|
429
|
+
exitCode: e.exitCode
|
|
430
|
+
});
|
|
431
|
+
}
|
|
432
|
+
throw e;
|
|
433
|
+
}
|
|
434
|
+
const uploads = presign.uploads ?? [];
|
|
435
|
+
const uploadMap = new Map(uploads.map((u) => [u.kind, u]));
|
|
436
|
+
const logUpload = uploadMap.get("log");
|
|
437
|
+
if (!logUpload?.url) throw new CLIError("Upload target missing for log file.");
|
|
438
|
+
await client.uploadToPresignedUrl(logUpload.url, logFile);
|
|
439
|
+
const completePayload = {
|
|
440
|
+
log_key: logUpload.key ?? "",
|
|
441
|
+
log_name: import_node_path3.default.basename(logFile)
|
|
442
|
+
};
|
|
443
|
+
if (locationFile) {
|
|
444
|
+
const locUpload = uploadMap.get("location");
|
|
445
|
+
if (!locUpload?.url) throw new CLIError("Upload target missing for location file.");
|
|
446
|
+
await client.uploadToPresignedUrl(locUpload.url, locationFile);
|
|
447
|
+
completePayload.location_key = locUpload.key ?? "";
|
|
448
|
+
completePayload.location_name = import_node_path3.default.basename(locationFile);
|
|
449
|
+
}
|
|
450
|
+
await client.requestJson("POST", `sessions/${sessionId}/uploads-complete/`, { json: completePayload });
|
|
451
|
+
await client.requestJson("POST", `sessions/${sessionId}/run/`, { json: {} });
|
|
452
|
+
const status = opts.wait ? await waitForSession({ apiRoot: opts.apiRoot, sessionId, pollSeconds: opts.pollSeconds, onUpdate: opts.onUpdate }) : await getSessionStatus({ apiRoot: opts.apiRoot, sessionId });
|
|
453
|
+
return { session_id: sessionId, name: sessionName, description: sessionDescription, wait: !!opts.wait, status };
|
|
454
|
+
}
|
|
455
|
+
async function getSummary(opts) {
|
|
456
|
+
const client = new APIClient(opts.apiRoot);
|
|
457
|
+
const res = await client.request("GET", `analytics/${opts.sessionId}/summary/`);
|
|
458
|
+
if (res.status >= 400) {
|
|
459
|
+
if (res.status === 404) {
|
|
460
|
+
let detail;
|
|
461
|
+
try {
|
|
462
|
+
const body = await res.json();
|
|
463
|
+
detail = body.detail ?? body.error;
|
|
464
|
+
} catch {
|
|
465
|
+
}
|
|
466
|
+
throw new CLIError(detail || "Summary not found.", {
|
|
467
|
+
hint: `Check the analysis ID, or run 'wildash status ${opts.sessionId} --watch' and wait for completion.`
|
|
468
|
+
});
|
|
469
|
+
}
|
|
470
|
+
await client.raiseForError(res);
|
|
471
|
+
}
|
|
472
|
+
const text = await res.text();
|
|
473
|
+
if (!text) return {};
|
|
474
|
+
try {
|
|
475
|
+
return JSON.parse(text);
|
|
476
|
+
} catch (e) {
|
|
477
|
+
throw new CLIError(`Invalid JSON response from summary endpoint: ${e}`);
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
function resolveExportTarget(target) {
|
|
481
|
+
const backend = EXPORT_TARGET_ALIASES[target.toLowerCase()];
|
|
482
|
+
if (!backend) {
|
|
483
|
+
throw new CLIError(`Unsupported export target: ${target}`, {
|
|
484
|
+
hint: "Supported targets: overview, map, network, temporal, all"
|
|
485
|
+
});
|
|
486
|
+
}
|
|
487
|
+
const display = backend === "flow_map" ? "map" : backend === "flow_network" ? "network" : backend;
|
|
488
|
+
return { display, backend };
|
|
489
|
+
}
|
|
490
|
+
function filenameFromDisposition(disposition) {
|
|
491
|
+
for (const part of disposition.split(";")) {
|
|
492
|
+
const seg = part.trim();
|
|
493
|
+
if (seg.toLowerCase().startsWith("filename=")) {
|
|
494
|
+
return seg.split("=")[1]?.trim().replace(/"/g, "") ?? "";
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
return "";
|
|
498
|
+
}
|
|
499
|
+
async function downloadExport(opts) {
|
|
500
|
+
const { display, backend } = resolveExportTarget(opts.target);
|
|
501
|
+
const client = new APIClient(opts.apiRoot);
|
|
502
|
+
const res = await client.request("POST", `results/${opts.sessionId}/export/`, { json: { target: backend } });
|
|
503
|
+
if (res.status >= 400) await client.raiseForError(res);
|
|
504
|
+
const buffer = Buffer.from(await res.arrayBuffer());
|
|
505
|
+
const outRaw = opts.out.trim();
|
|
506
|
+
if (!outRaw) throw new CLIError("Output path is required.");
|
|
507
|
+
const resolved = import_node_path3.default.resolve(expandHome(outRaw));
|
|
508
|
+
const isDir = outRaw.endsWith("/") || outRaw.endsWith(import_node_path3.default.sep) || import_node_fs3.default.existsSync(resolved) && import_node_fs3.default.statSync(resolved).isDirectory();
|
|
509
|
+
const outPath = isDir ? import_node_path3.default.join(resolved, filenameFromDisposition(res.headers.get("Content-Disposition") ?? "") || `analytics-${opts.sessionId}-${backend}.zip`) : resolved;
|
|
510
|
+
import_node_fs3.default.mkdirSync(import_node_path3.default.dirname(outPath), { recursive: true });
|
|
511
|
+
import_node_fs3.default.writeFileSync(outPath, buffer);
|
|
512
|
+
return { session_id: opts.sessionId, target: display, backend_target: backend, output: import_node_path3.default.resolve(outPath), bytes: buffer.byteLength };
|
|
513
|
+
}
|
|
514
|
+
function clearCredentials() {
|
|
515
|
+
clearAuthState();
|
|
516
|
+
return { logged_out: true };
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
// src/commands/login.ts
|
|
520
|
+
function registerLogin(program2) {
|
|
521
|
+
program2.command("login").description("Sign in to WiLDash and store local credentials.").option("--email <email>", "Email address.").option("--password <password>", "Password.").option("--api-url <url>", "Use a different WiLDash server.").action(async (opts) => {
|
|
522
|
+
const jsonMode = program2.opts().json ?? false;
|
|
523
|
+
const email = opts.email?.trim() || await (0, import_prompts.input)({ message: "Email:" });
|
|
524
|
+
const password = opts.password || process.env.WILDASH_PASSWORD || await (0, import_prompts.password)({ message: "Password:" });
|
|
525
|
+
const result = await login({ apiRoot: opts.apiUrl, email, password });
|
|
526
|
+
emit({
|
|
527
|
+
jsonMode,
|
|
528
|
+
command: "login",
|
|
529
|
+
data: result,
|
|
530
|
+
lines: [`Signed In: ${result.user.email ?? ""}`, `Server: ${result.api_root}`]
|
|
531
|
+
});
|
|
532
|
+
});
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
// src/commands/logout.ts
|
|
536
|
+
function registerLogout(program2) {
|
|
537
|
+
program2.command("logout").description("Remove local credentials.").action(() => {
|
|
538
|
+
const jsonMode = program2.opts().json ?? false;
|
|
539
|
+
const result = clearCredentials();
|
|
540
|
+
emit({
|
|
541
|
+
jsonMode,
|
|
542
|
+
command: "logout",
|
|
543
|
+
data: result,
|
|
544
|
+
lines: ["Logout: success"]
|
|
545
|
+
});
|
|
546
|
+
});
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
// src/commands/whoami.ts
|
|
550
|
+
function registerWhoami(program2) {
|
|
551
|
+
program2.command("whoami").description("Show the currently signed-in account.").option("--api-url <url>", "Use a different WiLDash server.").action(async (opts) => {
|
|
552
|
+
const jsonMode = program2.opts().json ?? false;
|
|
553
|
+
const result = await whoami({ apiRoot: opts.apiUrl });
|
|
554
|
+
emit({
|
|
555
|
+
jsonMode,
|
|
556
|
+
command: "whoami",
|
|
557
|
+
data: result,
|
|
558
|
+
lines: [
|
|
559
|
+
`Email: ${result.email ?? ""}`,
|
|
560
|
+
`Organization: ${result.organization_name ?? ""}`,
|
|
561
|
+
`Username: ${result.username ?? ""}`,
|
|
562
|
+
`User ID: ${result.id ?? ""}`
|
|
563
|
+
]
|
|
564
|
+
});
|
|
565
|
+
});
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
// src/commands/run.ts
|
|
569
|
+
function registerRun(program2) {
|
|
570
|
+
program2.command("run").description("Create a session, upload files, and start analysis.").requiredOption("--log <path>", "Path to the Wi-Fi log file.").option("--location <path>", "Optional locations CSV file.").option("--name <name>", "Name for the analysis.").option("--description <text>", "Short note for this analysis.").option("--wait", "Stay until the analysis finishes.", false).option("--poll <seconds>", "Seconds between checks while waiting.", "4").option("--api-url <url>", "Use a different WiLDash server.").action(async (opts) => {
|
|
571
|
+
const jsonMode = program2.opts().json ?? false;
|
|
572
|
+
const onUpdate = opts.wait ? (payload) => {
|
|
573
|
+
if (!jsonMode) {
|
|
574
|
+
process.stderr.write(`Status: ${payload.status ?? ""} (${payload.progress ?? 0}%)
|
|
575
|
+
`);
|
|
576
|
+
}
|
|
577
|
+
} : void 0;
|
|
578
|
+
const result = await runSession({
|
|
579
|
+
apiRoot: opts.apiUrl,
|
|
580
|
+
logPath: opts.log,
|
|
581
|
+
locationPath: opts.location,
|
|
582
|
+
name: opts.name,
|
|
583
|
+
description: opts.description,
|
|
584
|
+
wait: opts.wait,
|
|
585
|
+
pollSeconds: parseFloat(opts.poll),
|
|
586
|
+
onUpdate
|
|
587
|
+
});
|
|
588
|
+
const { status } = result;
|
|
589
|
+
const finalStatus = (status.status ?? "").toLowerCase();
|
|
590
|
+
if (opts.wait && finalStatus !== "completed") {
|
|
591
|
+
throw new CLIError(`Analysis ended with status '${finalStatus}'.`, {
|
|
592
|
+
hint: status.last_error?.trim() || void 0
|
|
593
|
+
});
|
|
594
|
+
}
|
|
595
|
+
const lines = [
|
|
596
|
+
`Analysis ID: ${result.session_id}`,
|
|
597
|
+
`Name: ${result.name}`,
|
|
598
|
+
`Status: ${status.status ?? ""}`,
|
|
599
|
+
`Progress: ${status.progress ?? 0}%`
|
|
600
|
+
];
|
|
601
|
+
lines.push(opts.wait ? "Finished: yes" : `Next: wildash status ${result.session_id} --watch`);
|
|
602
|
+
emit({ jsonMode, command: "run", data: result, lines });
|
|
603
|
+
});
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
// src/commands/list.ts
|
|
607
|
+
function formatListLines(rows) {
|
|
608
|
+
if (!rows.length) return ["No analyses found."];
|
|
609
|
+
const headers = ["ID", "STATUS", "PROGRESS", "UPDATED", "NAME"];
|
|
610
|
+
const table = rows.map((item) => [
|
|
611
|
+
String(item.id ?? ""),
|
|
612
|
+
String(item.status ?? ""),
|
|
613
|
+
`${Number(item.progress ?? 0)}%`,
|
|
614
|
+
String(item.updated_at ?? ""),
|
|
615
|
+
String(item.name ?? "").replace(/\t/g, " ")
|
|
616
|
+
]);
|
|
617
|
+
const widths = headers.map((h) => h.length);
|
|
618
|
+
for (const row of table) {
|
|
619
|
+
for (let i = 0; i < row.length; i++) {
|
|
620
|
+
widths[i] = Math.max(widths[i], row[i].length);
|
|
621
|
+
}
|
|
622
|
+
}
|
|
623
|
+
const render = (row) => row.map((val, i) => val.padEnd(widths[i])).join(" ");
|
|
624
|
+
return [
|
|
625
|
+
render(headers),
|
|
626
|
+
render(widths.map((w) => "-".repeat(w))),
|
|
627
|
+
...table.map(render)
|
|
628
|
+
];
|
|
629
|
+
}
|
|
630
|
+
function registerList(program2) {
|
|
631
|
+
program2.command("list").description("List recent analysis sessions.").option("--status <status>", "Only show analyses with this status.").option("--limit <n>", "Maximum number of analyses to show.", "20").option("--api-url <url>", "Use a different WiLDash server.").action(async (opts) => {
|
|
632
|
+
const jsonMode = program2.opts().json ?? false;
|
|
633
|
+
const result = await listSessions({
|
|
634
|
+
apiRoot: opts.apiUrl,
|
|
635
|
+
status: opts.status,
|
|
636
|
+
limit: parseInt(opts.limit, 10)
|
|
637
|
+
});
|
|
638
|
+
emit({
|
|
639
|
+
jsonMode,
|
|
640
|
+
command: "list",
|
|
641
|
+
data: result,
|
|
642
|
+
lines: formatListLines(result.sessions)
|
|
643
|
+
});
|
|
644
|
+
});
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
// src/commands/status.ts
|
|
648
|
+
function statusLines(payload) {
|
|
649
|
+
const lines = [
|
|
650
|
+
`Analysis ID: ${payload.id ?? ""}`,
|
|
651
|
+
`Name: ${payload.name ?? ""}`,
|
|
652
|
+
`Status: ${payload.status ?? ""}`,
|
|
653
|
+
`Progress: ${payload.progress ?? 0}%`
|
|
654
|
+
];
|
|
655
|
+
if (payload.total_records != null) {
|
|
656
|
+
lines.push(`Total Records: ${payload.total_records}`);
|
|
657
|
+
}
|
|
658
|
+
if (payload.total_flows != null) {
|
|
659
|
+
lines.push(`Total Flows: ${payload.total_flows}`);
|
|
660
|
+
}
|
|
661
|
+
const lastError = (payload.last_error ?? "").trim();
|
|
662
|
+
if (lastError) lines.push(`Last Error: ${lastError}`);
|
|
663
|
+
const stages = Array.isArray(payload.stages) ? payload.stages : [];
|
|
664
|
+
for (const stage of stages) {
|
|
665
|
+
if (typeof stage === "object" && stage !== null) {
|
|
666
|
+
lines.push(
|
|
667
|
+
`Stage ${stage.code ?? ""}: ${stage.status ?? ""} (${stage.progress ?? 0}%)`
|
|
668
|
+
);
|
|
669
|
+
}
|
|
670
|
+
}
|
|
671
|
+
return lines;
|
|
672
|
+
}
|
|
673
|
+
function registerStatus(program2) {
|
|
674
|
+
program2.command("status [analysis-id]").description("Show progress for an analysis.").option("--latest", "Use your most recent analysis.", false).option("--watch", "Keep checking until the analysis finishes.", false).option("--poll <seconds>", "Seconds between checks while watching.", "4").option("--api-url <url>", "Use a different WiLDash server.").action(async (sessionIdArg, opts) => {
|
|
675
|
+
const jsonMode = program2.opts().json ?? false;
|
|
676
|
+
const resolvedId = await resolveSessionId({
|
|
677
|
+
apiRoot: opts.apiUrl,
|
|
678
|
+
sessionId: sessionIdArg,
|
|
679
|
+
latest: opts.latest
|
|
680
|
+
});
|
|
681
|
+
let result;
|
|
682
|
+
if (opts.watch) {
|
|
683
|
+
result = await waitForSession({
|
|
684
|
+
apiRoot: opts.apiUrl,
|
|
685
|
+
sessionId: resolvedId,
|
|
686
|
+
pollSeconds: parseFloat(opts.poll),
|
|
687
|
+
onUpdate: (payload) => {
|
|
688
|
+
if (!jsonMode) {
|
|
689
|
+
process.stderr.write(
|
|
690
|
+
`Status: ${payload.status ?? ""} (${payload.progress ?? 0}%)
|
|
691
|
+
`
|
|
692
|
+
);
|
|
693
|
+
}
|
|
694
|
+
}
|
|
695
|
+
});
|
|
696
|
+
} else {
|
|
697
|
+
result = await getSessionStatus({
|
|
698
|
+
apiRoot: opts.apiUrl,
|
|
699
|
+
sessionId: resolvedId
|
|
700
|
+
});
|
|
701
|
+
}
|
|
702
|
+
const lines = statusLines(result);
|
|
703
|
+
if (!opts.watch && !TERMINAL_SESSION_STATUSES.has((result.status ?? "").trim().toLowerCase())) {
|
|
704
|
+
lines.push(`Next: wildash status ${resolvedId} --watch`);
|
|
705
|
+
}
|
|
706
|
+
emit({
|
|
707
|
+
jsonMode,
|
|
708
|
+
command: "status",
|
|
709
|
+
data: result,
|
|
710
|
+
lines
|
|
711
|
+
});
|
|
712
|
+
});
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
// src/commands/cancel.ts
|
|
716
|
+
function registerCancel(program2) {
|
|
717
|
+
program2.command("cancel [analysis-id]").description("Stop a running analysis.").option("--latest", "Use your most recent analysis.", false).option("--reason <text>", "Optional reason.").option("--api-url <url>", "Use a different WiLDash server.").action(async (sessionIdArg, opts) => {
|
|
718
|
+
const jsonMode = program2.opts().json ?? false;
|
|
719
|
+
const resolvedId = await resolveSessionId({
|
|
720
|
+
apiRoot: opts.apiUrl,
|
|
721
|
+
sessionId: sessionIdArg,
|
|
722
|
+
latest: opts.latest
|
|
723
|
+
});
|
|
724
|
+
const result = await cancelSession({
|
|
725
|
+
apiRoot: opts.apiUrl,
|
|
726
|
+
sessionId: resolvedId,
|
|
727
|
+
reason: opts.reason
|
|
728
|
+
});
|
|
729
|
+
emit({
|
|
730
|
+
jsonMode,
|
|
731
|
+
command: "cancel",
|
|
732
|
+
data: result,
|
|
733
|
+
lines: [
|
|
734
|
+
`Analysis ID: ${result.id ?? resolvedId}`,
|
|
735
|
+
`Status: ${result.status ?? ""}`,
|
|
736
|
+
"Cancellation: requested"
|
|
737
|
+
]
|
|
738
|
+
});
|
|
739
|
+
});
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
// src/commands/summary.ts
|
|
743
|
+
function summaryLines(payload, session) {
|
|
744
|
+
const lines = [];
|
|
745
|
+
if (session) {
|
|
746
|
+
lines.push(`Analysis ID: ${session.id ?? ""}`, `Name: ${session.name ?? ""}`);
|
|
747
|
+
}
|
|
748
|
+
lines.push(
|
|
749
|
+
`Total Unique Users: ${payload.total_unique_users ?? 0}`,
|
|
750
|
+
`Total Movements: ${payload.total_movements ?? 0}`,
|
|
751
|
+
`Date Range: ${payload.date_range_start ?? ""} -> ${payload.date_range_end ?? ""}`
|
|
752
|
+
);
|
|
753
|
+
const preview = (payload.top_locations ?? []).slice(0, 3).map((loc) => `${loc.building ?? ""} (${loc.visit_count ?? 0})`);
|
|
754
|
+
if (preview.length) lines.push(`Top Locations: ${preview.join(", ")}`);
|
|
755
|
+
if (payload.ai_insights?.trim()) lines.push(`AI Insights: ${payload.ai_insights.trim()}`);
|
|
756
|
+
return lines;
|
|
757
|
+
}
|
|
758
|
+
function registerSummary(program2) {
|
|
759
|
+
program2.command("summary [analysis-id]").description("Show the summary for an analysis.").option("--latest", "Use your most recent analysis.", false).option("--api-url <url>", "Use a different WiLDash server.").action(async (sessionIdArg, opts) => {
|
|
760
|
+
const jsonMode = program2.opts().json ?? false;
|
|
761
|
+
const resolvedId = await resolveSessionId({
|
|
762
|
+
apiRoot: opts.apiUrl,
|
|
763
|
+
sessionId: sessionIdArg,
|
|
764
|
+
latest: opts.latest
|
|
765
|
+
});
|
|
766
|
+
const session = await getSessionStatus({ apiRoot: opts.apiUrl, sessionId: resolvedId });
|
|
767
|
+
const summary = await getSummary({ apiRoot: opts.apiUrl, sessionId: resolvedId });
|
|
768
|
+
emit({
|
|
769
|
+
jsonMode,
|
|
770
|
+
command: "summary",
|
|
771
|
+
data: { session, summary },
|
|
772
|
+
lines: summaryLines(summary, session)
|
|
773
|
+
});
|
|
774
|
+
});
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
// src/commands/export.ts
|
|
778
|
+
function registerExport(program2) {
|
|
779
|
+
program2.command("export [analysis-id]").description("Download a results bundle as a ZIP file.").option("--latest", "Use your most recent analysis.", false).requiredOption(
|
|
780
|
+
"--target <target>",
|
|
781
|
+
"Which results bundle to download (overview, map, network, temporal, all)."
|
|
782
|
+
).requiredOption(
|
|
783
|
+
"--out <path>",
|
|
784
|
+
"ZIP file or folder path. End with / to save into a folder."
|
|
785
|
+
).option("--api-url <url>", "Use a different WiLDash server.").action(async (sessionIdArg, opts) => {
|
|
786
|
+
const jsonMode = program2.opts().json ?? false;
|
|
787
|
+
const resolvedId = await resolveSessionId({
|
|
788
|
+
apiRoot: opts.apiUrl,
|
|
789
|
+
sessionId: sessionIdArg,
|
|
790
|
+
latest: opts.latest
|
|
791
|
+
});
|
|
792
|
+
const result = await downloadExport({
|
|
793
|
+
apiRoot: opts.apiUrl,
|
|
794
|
+
sessionId: resolvedId,
|
|
795
|
+
target: opts.target,
|
|
796
|
+
out: opts.out
|
|
797
|
+
});
|
|
798
|
+
emit({
|
|
799
|
+
jsonMode,
|
|
800
|
+
command: "export",
|
|
801
|
+
data: result,
|
|
802
|
+
lines: [
|
|
803
|
+
`Analysis ID: ${result.session_id}`,
|
|
804
|
+
`Target: ${result.target}`,
|
|
805
|
+
`Saved To: ${result.output}`,
|
|
806
|
+
`Size: ${result.bytes} bytes`
|
|
807
|
+
]
|
|
808
|
+
});
|
|
809
|
+
});
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
// src/index.ts
|
|
813
|
+
var program = new import_commander.Command();
|
|
814
|
+
program.name("wildash").version("0.1.0").description("Run WiLDash analyses from the terminal.\n\nTypical flow: login, run, status, summary, export.").option("--json", "Print JSON instead of text.", false).helpOption("-h, --help").exitOverride().configureOutput({
|
|
815
|
+
writeOut: (str) => process.stdout.write(str),
|
|
816
|
+
writeErr: (str) => {
|
|
817
|
+
if (!process.argv.includes("--json")) process.stderr.write(str);
|
|
818
|
+
}
|
|
819
|
+
});
|
|
820
|
+
registerLogin(program);
|
|
821
|
+
registerLogout(program);
|
|
822
|
+
registerWhoami(program);
|
|
823
|
+
registerRun(program);
|
|
824
|
+
registerList(program);
|
|
825
|
+
registerStatus(program);
|
|
826
|
+
registerCancel(program);
|
|
827
|
+
registerSummary(program);
|
|
828
|
+
registerExport(program);
|
|
829
|
+
function isJsonRequested() {
|
|
830
|
+
return program.opts().json || process.argv.includes("--json");
|
|
831
|
+
}
|
|
832
|
+
async function main() {
|
|
833
|
+
try {
|
|
834
|
+
await program.parseAsync(process.argv);
|
|
835
|
+
} catch (err) {
|
|
836
|
+
const jsonMode = isJsonRequested();
|
|
837
|
+
const command = `wildash ${program.args[0] ?? ""}`.trim();
|
|
838
|
+
if (err instanceof import_commander.CommanderError && err.exitCode === 0) process.exit(0);
|
|
839
|
+
if (err instanceof CLIError) {
|
|
840
|
+
emitError({ jsonMode, command, message: err.message, hint: err.hint, exitCode: err.exitCode });
|
|
841
|
+
process.exit(err.exitCode);
|
|
842
|
+
}
|
|
843
|
+
if (err instanceof Error) {
|
|
844
|
+
emitError({ jsonMode, command, message: err.message });
|
|
845
|
+
process.exit(1);
|
|
846
|
+
}
|
|
847
|
+
throw err;
|
|
848
|
+
}
|
|
849
|
+
}
|
|
850
|
+
main();
|
package/package.json
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "wildash-cli",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Command-line interface for WiLDash",
|
|
5
|
+
"license": "Proprietary",
|
|
6
|
+
"homepage": "https://wildash.space",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "https://github.com/QingrongY/WilDash_webapp.git",
|
|
10
|
+
"directory": "packages/wildash-cli"
|
|
11
|
+
},
|
|
12
|
+
"bin": {
|
|
13
|
+
"wildash": "./bin/wildash.js",
|
|
14
|
+
"wildash-cli": "./bin/wildash.js"
|
|
15
|
+
},
|
|
16
|
+
"files": [
|
|
17
|
+
"bin/",
|
|
18
|
+
"dist/",
|
|
19
|
+
"README.md"
|
|
20
|
+
],
|
|
21
|
+
"engines": {
|
|
22
|
+
"node": ">=18"
|
|
23
|
+
},
|
|
24
|
+
"scripts": {
|
|
25
|
+
"build": "tsup",
|
|
26
|
+
"test": "vitest run",
|
|
27
|
+
"lint": "eslint src/",
|
|
28
|
+
"prepublishOnly": "npm run build"
|
|
29
|
+
},
|
|
30
|
+
"dependencies": {
|
|
31
|
+
"commander": "^13.0.0",
|
|
32
|
+
"@inquirer/prompts": "^7.0.0"
|
|
33
|
+
},
|
|
34
|
+
"devDependencies": {
|
|
35
|
+
"@types/node": "^20.0.0",
|
|
36
|
+
"tsup": "^8.0.0",
|
|
37
|
+
"typescript": "^5.7.0",
|
|
38
|
+
"vitest": "^3.0.0"
|
|
39
|
+
}
|
|
40
|
+
}
|