@infrarates/cli 0.0.0 → 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 +248 -3
- package/browser-login.d.mts +14 -0
- package/browser-login.mjs +244 -0
- package/commands.mjs +183 -0
- package/format-history.mjs +162 -0
- package/oimo.d.mts +15 -0
- package/oimo.mjs +506 -0
- package/package.json +32 -6
- package/infrarates.js +0 -3
package/oimo.mjs
ADDED
|
@@ -0,0 +1,506 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { parseArgs } from "node:util";
|
|
3
|
+
import { pathToFileURL } from "node:url";
|
|
4
|
+
import {
|
|
5
|
+
readFile,
|
|
6
|
+
mkdir,
|
|
7
|
+
writeFile,
|
|
8
|
+
rename,
|
|
9
|
+
chmod,
|
|
10
|
+
open,
|
|
11
|
+
stat,
|
|
12
|
+
rm,
|
|
13
|
+
} from "node:fs/promises";
|
|
14
|
+
import { setTimeout as sleep } from "node:timers/promises";
|
|
15
|
+
import { dirname, join } from "node:path";
|
|
16
|
+
import { homedir } from "node:os";
|
|
17
|
+
import { randomUUID } from "node:crypto";
|
|
18
|
+
import { readFileSync, realpathSync } from "node:fs";
|
|
19
|
+
import { commands, resolveCommand } from "./commands.mjs";
|
|
20
|
+
import { browserLogin } from "./browser-login.mjs";
|
|
21
|
+
import { formatHistory, terminalText } from "./format-history.mjs";
|
|
22
|
+
|
|
23
|
+
// The installed package's own version, so a release only bumps package.json.
|
|
24
|
+
const { version } = JSON.parse(
|
|
25
|
+
readFileSync(new URL("./package.json", import.meta.url), "utf8"),
|
|
26
|
+
);
|
|
27
|
+
|
|
28
|
+
const help = `Usage: oimo <command> [options]
|
|
29
|
+
|
|
30
|
+
Commands (use --data @file.json for JSON request bodies):
|
|
31
|
+
${commands.map(([name, , , auth, opts]) => ` ${name}${opts.length ? ` [${opts.map((o) => `--${o}`).join(" ")}]` : ""}${auth === "session" ? " (session)" : ""}`).join("\n")}
|
|
32
|
+
login Sign in through your browser
|
|
33
|
+
logout Remove the locally saved session
|
|
34
|
+
|
|
35
|
+
Global options: --base-url <origin>, --config <file>, --help, --version
|
|
36
|
+
Login options: --no-browser, --timeout <seconds> (default: 300), --session-stdin
|
|
37
|
+
Environment: OIMO_API_KEY, OIMO_SESSION, OIMO_BASE_URL
|
|
38
|
+
Session commands retain the server's user roles and organization checks.
|
|
39
|
+
Price history: terminal table; JSON when piped. Override with --format table|json.
|
|
40
|
+
Other output: JSON (terms content: text); errors on stderr.
|
|
41
|
+
`;
|
|
42
|
+
const optionNames = [...new Set(commands.flatMap((c) => c[4]))];
|
|
43
|
+
const optionSpec = Object.fromEntries(
|
|
44
|
+
optionNames.map((name) => [
|
|
45
|
+
name,
|
|
46
|
+
{ type: name === "all" ? "boolean" : "string" },
|
|
47
|
+
]),
|
|
48
|
+
);
|
|
49
|
+
|
|
50
|
+
async function stdinText(hidden = false) {
|
|
51
|
+
if (hidden && process.stdin.isTTY) {
|
|
52
|
+
process.stderr.write("Session value (hidden): ");
|
|
53
|
+
process.stdin.setRawMode(true);
|
|
54
|
+
process.stdin.resume();
|
|
55
|
+
return new Promise((resolve, reject) => {
|
|
56
|
+
let value = "";
|
|
57
|
+
const finish = (error) => {
|
|
58
|
+
process.stdin.removeListener("data", onData);
|
|
59
|
+
process.stdin.setRawMode(false);
|
|
60
|
+
process.stdin.pause();
|
|
61
|
+
process.stderr.write("\n");
|
|
62
|
+
if (error) reject(error);
|
|
63
|
+
else resolve(value);
|
|
64
|
+
};
|
|
65
|
+
const onData = (chunk) => {
|
|
66
|
+
for (const char of chunk.toString()) {
|
|
67
|
+
if (char === "\x03") {
|
|
68
|
+
finish(new Error("Login cancelled."));
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
if (["\r", "\n", "\x04"].includes(char)) {
|
|
72
|
+
finish();
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
if (char === "\x7f" || char === "\b") value = value.slice(0, -1);
|
|
76
|
+
else value += char;
|
|
77
|
+
}
|
|
78
|
+
};
|
|
79
|
+
process.stdin.on("data", onData);
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
let text = "";
|
|
83
|
+
for await (const chunk of process.stdin) text += chunk;
|
|
84
|
+
return text;
|
|
85
|
+
}
|
|
86
|
+
async function loadConfig(file) {
|
|
87
|
+
try {
|
|
88
|
+
const value = JSON.parse(await readFile(file, "utf8"));
|
|
89
|
+
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
90
|
+
throw new Error();
|
|
91
|
+
return value;
|
|
92
|
+
} catch (error) {
|
|
93
|
+
if (error.code === "ENOENT") return {};
|
|
94
|
+
throw new Error("Cannot read session config.");
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
// An update holds the lock for one small read and write; an older lock was
|
|
98
|
+
// left by a command that died, and waiting commands take it over.
|
|
99
|
+
const LOCK_STALE_MS = 10_000;
|
|
100
|
+
const LOCK_WAIT_MS = 15_000;
|
|
101
|
+
/**
|
|
102
|
+
* Apply one change to the saved state as it is now, not as this command read
|
|
103
|
+
* it earlier: commands run side by side, each rotating its own origin, and a
|
|
104
|
+
* whole-file write of an old snapshot would undo the others.
|
|
105
|
+
*/
|
|
106
|
+
async function updateConfig(file, change) {
|
|
107
|
+
await mkdir(dirname(file), { recursive: true, mode: 0o700 });
|
|
108
|
+
const lock = `${file}.lock`;
|
|
109
|
+
const deadline = Date.now() + LOCK_WAIT_MS;
|
|
110
|
+
for (;;) {
|
|
111
|
+
try {
|
|
112
|
+
await (await open(lock, "wx", 0o600)).close();
|
|
113
|
+
break;
|
|
114
|
+
} catch (error) {
|
|
115
|
+
if (error.code !== "EEXIST")
|
|
116
|
+
throw new Error("Cannot write session config.");
|
|
117
|
+
const held = await stat(lock).catch(() => null);
|
|
118
|
+
if (held && Date.now() - held.mtimeMs > LOCK_STALE_MS)
|
|
119
|
+
await rm(lock, { force: true });
|
|
120
|
+
else if (Date.now() > deadline)
|
|
121
|
+
throw new Error(
|
|
122
|
+
"Session config is locked by another oimo command. Try again.",
|
|
123
|
+
);
|
|
124
|
+
else await sleep(10 + Math.random() * 20);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
try {
|
|
128
|
+
const config = await loadConfig(file);
|
|
129
|
+
change(config);
|
|
130
|
+
const temp = `${file}.${randomUUID()}.tmp`;
|
|
131
|
+
await writeFile(temp, `${JSON.stringify(config)}\n`, {
|
|
132
|
+
mode: 0o600,
|
|
133
|
+
flag: "wx",
|
|
134
|
+
});
|
|
135
|
+
await rename(temp, file);
|
|
136
|
+
await chmod(file, 0o600);
|
|
137
|
+
} finally {
|
|
138
|
+
await rm(lock, { force: true });
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
const SESSION_COOKIE = "__Host-ptu_session";
|
|
142
|
+
// The organization a staff user is looking into; the server keeps it beside the session.
|
|
143
|
+
const STAFF_ORG_COOKIE = "__Host-ptu_staff_org";
|
|
144
|
+
/** One origin's saved cookies; a bare string is the entry format that held only the session. */
|
|
145
|
+
function savedEntry(value) {
|
|
146
|
+
if (typeof value === "string") return { session: value };
|
|
147
|
+
return value && typeof value === "object" && !Array.isArray(value)
|
|
148
|
+
? value
|
|
149
|
+
: {};
|
|
150
|
+
}
|
|
151
|
+
const validOrganization = (value) =>
|
|
152
|
+
typeof value === "string" && /^org_[A-Za-z0-9]{1,64}$/.test(value);
|
|
153
|
+
function validSession(value) {
|
|
154
|
+
return (
|
|
155
|
+
typeof value === "string" &&
|
|
156
|
+
value.length > 0 &&
|
|
157
|
+
!/[\s;,\x00-\x1f\x7f]/.test(value)
|
|
158
|
+
);
|
|
159
|
+
}
|
|
160
|
+
/** What a response leaves in a cookie: its last Set-Cookie wins, "" is a deletion. */
|
|
161
|
+
function cookieUpdate(response, name) {
|
|
162
|
+
let value;
|
|
163
|
+
for (const cookie of response.headers.getSetCookie()) {
|
|
164
|
+
const [pair, ...attributes] = cookie.split(";");
|
|
165
|
+
if (!pair.startsWith(`${name}=`)) continue;
|
|
166
|
+
value = attributes.some((a) => /^\s*max-age\s*=\s*(0+|-\d+)\s*$/i.test(a))
|
|
167
|
+
? ""
|
|
168
|
+
: pair.slice(name.length + 1);
|
|
169
|
+
}
|
|
170
|
+
return value;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
export async function run(
|
|
174
|
+
args,
|
|
175
|
+
{
|
|
176
|
+
env = process.env,
|
|
177
|
+
fetcher = fetch,
|
|
178
|
+
write = (s) => process.stdout.write(s),
|
|
179
|
+
readInput = stdinText,
|
|
180
|
+
loginBrowser = browserLogin,
|
|
181
|
+
report = (text) => process.stderr.write(text),
|
|
182
|
+
isTTY = process.stdout.isTTY ?? false,
|
|
183
|
+
columns = process.stdout.columns ?? 100,
|
|
184
|
+
} = {},
|
|
185
|
+
) {
|
|
186
|
+
const { values, positionals } = parseArgs({
|
|
187
|
+
args,
|
|
188
|
+
allowPositionals: true,
|
|
189
|
+
options: {
|
|
190
|
+
...optionSpec,
|
|
191
|
+
"base-url": { type: "string" },
|
|
192
|
+
config: { type: "string" },
|
|
193
|
+
help: { type: "boolean", short: "h" },
|
|
194
|
+
version: { type: "boolean" },
|
|
195
|
+
"no-browser": { type: "boolean" },
|
|
196
|
+
"session-stdin": { type: "boolean" },
|
|
197
|
+
timeout: { type: "string" },
|
|
198
|
+
},
|
|
199
|
+
});
|
|
200
|
+
if (values.help || args.length === 0) {
|
|
201
|
+
write(help);
|
|
202
|
+
return;
|
|
203
|
+
}
|
|
204
|
+
if (values.version) {
|
|
205
|
+
write(`${version}\n`);
|
|
206
|
+
return;
|
|
207
|
+
}
|
|
208
|
+
const special =
|
|
209
|
+
positionals.length === 1 && ["login", "logout"].includes(positionals[0])
|
|
210
|
+
? positionals[0]
|
|
211
|
+
: null;
|
|
212
|
+
const command = special ? null : resolveCommand(positionals);
|
|
213
|
+
for (const option of ["no-browser", "session-stdin", "timeout"]) {
|
|
214
|
+
if (values[option] !== undefined && special !== "login")
|
|
215
|
+
throw new Error(`--${option} is only supported by login.`);
|
|
216
|
+
}
|
|
217
|
+
if (
|
|
218
|
+
values["session-stdin"] &&
|
|
219
|
+
(values["no-browser"] || values.timeout !== undefined)
|
|
220
|
+
)
|
|
221
|
+
throw new Error(
|
|
222
|
+
"--session-stdin cannot be combined with browser login options.",
|
|
223
|
+
);
|
|
224
|
+
const timeout = Number(values.timeout ?? 300);
|
|
225
|
+
if (!Number.isSafeInteger(timeout) || timeout < 1 || timeout > 600)
|
|
226
|
+
throw new Error("--timeout must be an integer from 1 to 600 seconds.");
|
|
227
|
+
for (const name of optionNames) {
|
|
228
|
+
if (values[name] !== undefined && !command?.options.includes(name))
|
|
229
|
+
throw new Error(`--${name} is not supported by this command.`);
|
|
230
|
+
}
|
|
231
|
+
if (
|
|
232
|
+
values.cursor !== undefined &&
|
|
233
|
+
(values.providers !== undefined ||
|
|
234
|
+
values["as-of"] !== undefined ||
|
|
235
|
+
values.alias !== undefined)
|
|
236
|
+
)
|
|
237
|
+
throw new Error(
|
|
238
|
+
"--cursor already pins providers, as-of and alias; send only cursor and limit.",
|
|
239
|
+
);
|
|
240
|
+
const maxLimit = command?.name === "prices latest" ? 1000 : 10000;
|
|
241
|
+
if (values.format !== undefined && !["table", "json"].includes(values.format))
|
|
242
|
+
throw new Error("--format must be table or json.");
|
|
243
|
+
if (
|
|
244
|
+
values.limit !== undefined &&
|
|
245
|
+
(!/^\d+$/.test(values.limit) ||
|
|
246
|
+
Number(values.limit) < 1 ||
|
|
247
|
+
Number(values.limit) > maxLimit)
|
|
248
|
+
)
|
|
249
|
+
throw new Error(`--limit must be an integer from 1 to ${maxLimit}.`);
|
|
250
|
+
if (
|
|
251
|
+
values.offset !== undefined &&
|
|
252
|
+
(!/^\d+$/.test(values.offset) ||
|
|
253
|
+
!Number.isSafeInteger(Number(values.offset)))
|
|
254
|
+
)
|
|
255
|
+
throw new Error("--offset must be a non-negative safe integer.");
|
|
256
|
+
if (values.dir !== undefined && !["asc", "desc"].includes(values.dir))
|
|
257
|
+
throw new Error("--dir must be asc or desc.");
|
|
258
|
+
if (
|
|
259
|
+
values["still-listed"] !== undefined &&
|
|
260
|
+
!["true", "false"].includes(values["still-listed"])
|
|
261
|
+
)
|
|
262
|
+
throw new Error("--still-listed must be true or false.");
|
|
263
|
+
const origin = new URL(
|
|
264
|
+
values["base-url"] ?? env.OIMO_BASE_URL ?? "https://oimo.com",
|
|
265
|
+
);
|
|
266
|
+
if (
|
|
267
|
+
origin.username ||
|
|
268
|
+
origin.password ||
|
|
269
|
+
origin.search ||
|
|
270
|
+
origin.hash ||
|
|
271
|
+
origin.pathname !== "/"
|
|
272
|
+
)
|
|
273
|
+
throw new Error(
|
|
274
|
+
"--base-url must be an origin without credentials, path, query or fragment.",
|
|
275
|
+
);
|
|
276
|
+
if (
|
|
277
|
+
origin.protocol !== "https:" &&
|
|
278
|
+
!(
|
|
279
|
+
origin.protocol === "http:" &&
|
|
280
|
+
["localhost", "127.0.0.1", "[::1]"].includes(origin.hostname)
|
|
281
|
+
)
|
|
282
|
+
)
|
|
283
|
+
throw new Error(
|
|
284
|
+
"--base-url requires HTTPS (HTTP is allowed for localhost).",
|
|
285
|
+
);
|
|
286
|
+
const file =
|
|
287
|
+
values.config ?? join(homedir(), ".config", "oimo", "sessions.json");
|
|
288
|
+
if (special === "logout") {
|
|
289
|
+
await updateConfig(file, (config) => {
|
|
290
|
+
delete config[origin.origin];
|
|
291
|
+
});
|
|
292
|
+
write("Local session removed.\n");
|
|
293
|
+
return;
|
|
294
|
+
}
|
|
295
|
+
const key = command?.auth === "key" ? env.OIMO_API_KEY : undefined;
|
|
296
|
+
let saved = false;
|
|
297
|
+
let session;
|
|
298
|
+
let staffOrganization;
|
|
299
|
+
if (!key) {
|
|
300
|
+
session = env.OIMO_SESSION;
|
|
301
|
+
if (special === "login" && !values["session-stdin"]) {
|
|
302
|
+
const controller = new AbortController();
|
|
303
|
+
const cancel = () => controller.abort();
|
|
304
|
+
process.once("SIGINT", cancel);
|
|
305
|
+
try {
|
|
306
|
+
session = await loginBrowser(origin.origin, {
|
|
307
|
+
noBrowser: values["no-browser"] ?? false,
|
|
308
|
+
timeoutMs: timeout * 1000,
|
|
309
|
+
signal: controller.signal,
|
|
310
|
+
report,
|
|
311
|
+
});
|
|
312
|
+
} finally {
|
|
313
|
+
process.removeListener("SIGINT", cancel);
|
|
314
|
+
}
|
|
315
|
+
} else if (special === "login") session ??= (await readInput(true)).trim();
|
|
316
|
+
else if (!session) {
|
|
317
|
+
saved = true;
|
|
318
|
+
({ session, staffOrganization } = savedEntry(
|
|
319
|
+
(await loadConfig(file))[origin.origin],
|
|
320
|
+
));
|
|
321
|
+
if (!validOrganization(staffOrganization)) staffOrganization = undefined;
|
|
322
|
+
}
|
|
323
|
+
if (!validSession(session))
|
|
324
|
+
throw new Error(
|
|
325
|
+
command?.auth === "key"
|
|
326
|
+
? "Set OIMO_API_KEY or sign in with oimo login."
|
|
327
|
+
: "A user session is required. Use oimo login or set OIMO_SESSION.",
|
|
328
|
+
);
|
|
329
|
+
} else if (/\s/.test(key))
|
|
330
|
+
throw new Error("Set OIMO_API_KEY to your organization API key.");
|
|
331
|
+
let body;
|
|
332
|
+
if (command?.options.includes("data")) {
|
|
333
|
+
if (!values.data)
|
|
334
|
+
throw new Error(
|
|
335
|
+
"This command requires --data <JSON>, --data @file.json, or --data - for stdin.",
|
|
336
|
+
);
|
|
337
|
+
const raw =
|
|
338
|
+
values.data === "-"
|
|
339
|
+
? await readInput()
|
|
340
|
+
: values.data.startsWith("@")
|
|
341
|
+
? await readFile(values.data.slice(1), "utf8")
|
|
342
|
+
: values.data;
|
|
343
|
+
let parsed;
|
|
344
|
+
try {
|
|
345
|
+
parsed = JSON.parse(raw);
|
|
346
|
+
} catch {
|
|
347
|
+
throw new Error("--data must contain valid JSON.");
|
|
348
|
+
}
|
|
349
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed))
|
|
350
|
+
throw new Error("--data must be a JSON object.");
|
|
351
|
+
body = JSON.stringify(parsed);
|
|
352
|
+
}
|
|
353
|
+
let query = new URLSearchParams();
|
|
354
|
+
for (const option of command?.options ?? []) {
|
|
355
|
+
if (
|
|
356
|
+
option !== "all" &&
|
|
357
|
+
option !== "data" &&
|
|
358
|
+
option !== "format" &&
|
|
359
|
+
values[option] !== undefined
|
|
360
|
+
)
|
|
361
|
+
query.set(
|
|
362
|
+
{ "as-of": "asOf", "still-listed": "stillListed" }[option] ?? option,
|
|
363
|
+
values[option],
|
|
364
|
+
);
|
|
365
|
+
}
|
|
366
|
+
let result;
|
|
367
|
+
const seen = new Set(values.cursor ? [values.cursor] : []);
|
|
368
|
+
do {
|
|
369
|
+
const url = new URL(special === "login" ? "/v1/me" : command.path, origin);
|
|
370
|
+
url.search = query.toString();
|
|
371
|
+
const headers = { Accept: "application/json" };
|
|
372
|
+
if (key) headers.Authorization = `Bearer ${key}`;
|
|
373
|
+
else {
|
|
374
|
+
headers.Cookie = `${SESSION_COOKIE}=${session}${staffOrganization ? `; ${STAFF_ORG_COOKIE}=${staffOrganization}` : ""}`;
|
|
375
|
+
headers.Origin = origin.origin;
|
|
376
|
+
}
|
|
377
|
+
if (body) headers["Content-Type"] = "application/json";
|
|
378
|
+
let response;
|
|
379
|
+
try {
|
|
380
|
+
response = await fetcher(url, {
|
|
381
|
+
method: command?.method ?? "GET",
|
|
382
|
+
headers,
|
|
383
|
+
...(body ? { body } : {}),
|
|
384
|
+
redirect: "error",
|
|
385
|
+
signal: AbortSignal.timeout(30000),
|
|
386
|
+
});
|
|
387
|
+
} catch {
|
|
388
|
+
throw new Error(
|
|
389
|
+
"API request could not complete (network error, redirect, or timeout).",
|
|
390
|
+
);
|
|
391
|
+
}
|
|
392
|
+
const rotated = cookieUpdate(response, SESSION_COOKIE);
|
|
393
|
+
const viewing = cookieUpdate(response, STAFF_ORG_COOKIE);
|
|
394
|
+
if (!key && (rotated !== undefined || viewing !== undefined)) {
|
|
395
|
+
if (
|
|
396
|
+
(rotated && !validSession(rotated)) ||
|
|
397
|
+
(viewing && !validOrganization(viewing))
|
|
398
|
+
)
|
|
399
|
+
throw new Error("Server returned an invalid session cookie.");
|
|
400
|
+
if (rotated !== undefined) session = rotated;
|
|
401
|
+
if (viewing !== undefined) staffOrganization = viewing || undefined;
|
|
402
|
+
if (saved)
|
|
403
|
+
await updateConfig(file, (config) => {
|
|
404
|
+
// An entry removed meanwhile was logged out; a rotation must not restore it.
|
|
405
|
+
if (!(origin.origin in config)) return;
|
|
406
|
+
if (session) config[origin.origin] = { session, staffOrganization };
|
|
407
|
+
else delete config[origin.origin];
|
|
408
|
+
});
|
|
409
|
+
}
|
|
410
|
+
if (!response.ok) {
|
|
411
|
+
let code = "";
|
|
412
|
+
try {
|
|
413
|
+
const b = await response.json();
|
|
414
|
+
if (
|
|
415
|
+
typeof b?.error?.code === "string" &&
|
|
416
|
+
/^[a-z_]+$/.test(b.error.code)
|
|
417
|
+
)
|
|
418
|
+
code = ` (${b.error.code})`;
|
|
419
|
+
} catch {}
|
|
420
|
+
throw new Error(`API request failed: HTTP ${response.status}${code}.`);
|
|
421
|
+
}
|
|
422
|
+
if (special === "login") {
|
|
423
|
+
if (!validSession(session))
|
|
424
|
+
throw new Error("Server did not return a usable session.");
|
|
425
|
+
const identity = await response.json();
|
|
426
|
+
await updateConfig(file, (config) => {
|
|
427
|
+
config[origin.origin] = { session };
|
|
428
|
+
});
|
|
429
|
+
if (!values["session-stdin"] && env.OIMO_SESSION)
|
|
430
|
+
report(
|
|
431
|
+
"Unset OIMO_SESSION to use the saved browser login for subsequent commands.\n",
|
|
432
|
+
);
|
|
433
|
+
write(
|
|
434
|
+
`${JSON.stringify({ user: identity.user, organizationId: identity.organizationId }, null, 2)}\n`,
|
|
435
|
+
);
|
|
436
|
+
return;
|
|
437
|
+
}
|
|
438
|
+
if (command.name === "terms content <key>") {
|
|
439
|
+
const content = await response.text();
|
|
440
|
+
// Vendor text is untrusted: a terminal gets visible controls, a pipe the text as served.
|
|
441
|
+
write(isTTY ? terminalText(content) : content);
|
|
442
|
+
return;
|
|
443
|
+
}
|
|
444
|
+
const text = await response.text();
|
|
445
|
+
let page;
|
|
446
|
+
try {
|
|
447
|
+
page = text ? JSON.parse(text) : null;
|
|
448
|
+
} catch {
|
|
449
|
+
throw new Error("API returned invalid JSON.");
|
|
450
|
+
}
|
|
451
|
+
if (command.name !== "prices history") {
|
|
452
|
+
write(`${JSON.stringify(page, null, 2)}\n`);
|
|
453
|
+
return;
|
|
454
|
+
}
|
|
455
|
+
if (
|
|
456
|
+
!page ||
|
|
457
|
+
typeof page.asOf !== "string" ||
|
|
458
|
+
!Array.isArray(page.providers) ||
|
|
459
|
+
!page.providers.every((p) => typeof p === "string") ||
|
|
460
|
+
!Array.isArray(page.prices) ||
|
|
461
|
+
!(
|
|
462
|
+
page.nextCursor === null ||
|
|
463
|
+
(typeof page.nextCursor === "string" && page.nextCursor.length > 0)
|
|
464
|
+
)
|
|
465
|
+
)
|
|
466
|
+
throw new Error("API returned an invalid price history response.");
|
|
467
|
+
if (!result) result = page;
|
|
468
|
+
else {
|
|
469
|
+
if (
|
|
470
|
+
page.asOf !== result.asOf ||
|
|
471
|
+
JSON.stringify(page.providers) !== JSON.stringify(result.providers) ||
|
|
472
|
+
JSON.stringify(page.alias) !== JSON.stringify(result.alias)
|
|
473
|
+
)
|
|
474
|
+
throw new Error("API changed the pinned history while paginating.");
|
|
475
|
+
result.prices.push(...page.prices);
|
|
476
|
+
result.nextCursor = page.nextCursor;
|
|
477
|
+
}
|
|
478
|
+
if (!values.all || page.nextCursor === null) break;
|
|
479
|
+
if (seen.has(page.nextCursor))
|
|
480
|
+
throw new Error("API returned a repeated pagination cursor.");
|
|
481
|
+
seen.add(page.nextCursor);
|
|
482
|
+
query = new URLSearchParams({ cursor: page.nextCursor });
|
|
483
|
+
if (values.limit !== undefined) query.set("limit", values.limit);
|
|
484
|
+
} while (true);
|
|
485
|
+
const format = values.format ?? (isTTY ? "table" : "json");
|
|
486
|
+
write(
|
|
487
|
+
format === "table"
|
|
488
|
+
? formatHistory(result, {
|
|
489
|
+
columns,
|
|
490
|
+
baseUrl: origin.origin,
|
|
491
|
+
limit: values.limit,
|
|
492
|
+
config: values.config,
|
|
493
|
+
})
|
|
494
|
+
: `${JSON.stringify(result, null, 2)}\n`,
|
|
495
|
+
);
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
if (
|
|
499
|
+
process.argv[1] &&
|
|
500
|
+
import.meta.url === pathToFileURL(realpathSync(process.argv[1])).href
|
|
501
|
+
) {
|
|
502
|
+
run(process.argv.slice(2)).catch((error) => {
|
|
503
|
+
process.stderr.write(`oimo: ${error.message}\n`);
|
|
504
|
+
process.exitCode = 1;
|
|
505
|
+
});
|
|
506
|
+
}
|
package/package.json
CHANGED
|
@@ -1,9 +1,35 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@infrarates/cli",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
"license": "
|
|
7
|
-
"
|
|
8
|
-
"
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"description": "CLI for the Oimo public API",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"homepage": "https://oimo.com",
|
|
8
|
+
"repository": {
|
|
9
|
+
"type": "git",
|
|
10
|
+
"url": "git+https://github.com/oimo-ai/oimo.git",
|
|
11
|
+
"directory": "services/providers-tracker-ui/cli"
|
|
12
|
+
},
|
|
13
|
+
"bin": {
|
|
14
|
+
"oimo": "oimo.mjs"
|
|
15
|
+
},
|
|
16
|
+
"engines": {
|
|
17
|
+
"node": ">=22.11.0"
|
|
18
|
+
},
|
|
19
|
+
"scripts": {
|
|
20
|
+
"test": "node --test cli-tests.mjs install-smoke.mjs"
|
|
21
|
+
},
|
|
22
|
+
"files": [
|
|
23
|
+
"oimo.mjs",
|
|
24
|
+
"oimo.d.mts",
|
|
25
|
+
"browser-login.mjs",
|
|
26
|
+
"browser-login.d.mts",
|
|
27
|
+
"commands.mjs",
|
|
28
|
+
"format-history.mjs",
|
|
29
|
+
"README.md",
|
|
30
|
+
"LICENSE"
|
|
31
|
+
],
|
|
32
|
+
"publishConfig": {
|
|
33
|
+
"access": "public"
|
|
34
|
+
}
|
|
9
35
|
}
|
package/infrarates.js
DELETED