uptimer-cli 0.0.0-stage → 0.2.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/CHANGELOG.md +12 -0
- package/LICENSE +21 -0
- package/README.md +62 -2
- package/dist/commands.js +441 -0
- package/dist/errors.js +33 -0
- package/dist/help.js +161 -0
- package/dist/index.js +57 -0
- package/dist/input.js +49 -0
- package/dist/redact.js +49 -0
- package/dist/skill.js +42 -0
- package/dist/transport.js +171 -0
- package/package.json +42 -4
- package/skill/SKILL.md +57 -0
- package/skill/references/commands.md +187 -0
package/dist/help.js
ADDED
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
const example = (value) => JSON.stringify(value, null, 2)
|
|
2
|
+
.split('\n')
|
|
3
|
+
.map((line) => ` ${line}`)
|
|
4
|
+
.join('\n');
|
|
5
|
+
const HTTP_MONITOR = example({
|
|
6
|
+
name: 'Production API',
|
|
7
|
+
type: 'http',
|
|
8
|
+
target: 'https://api.example.com/health',
|
|
9
|
+
interval_sec: 60,
|
|
10
|
+
timeout_ms: 5000,
|
|
11
|
+
http_method: 'GET',
|
|
12
|
+
expected_status_json: [200],
|
|
13
|
+
});
|
|
14
|
+
const TCP_MONITOR = example({
|
|
15
|
+
name: 'Database Port',
|
|
16
|
+
type: 'tcp',
|
|
17
|
+
target: 'db.example.com:5432',
|
|
18
|
+
interval_sec: 60,
|
|
19
|
+
timeout_ms: 5000,
|
|
20
|
+
});
|
|
21
|
+
const WEBHOOK_CHANNEL = example({
|
|
22
|
+
name: 'Operations Webhook',
|
|
23
|
+
type: 'webhook',
|
|
24
|
+
config_json: {
|
|
25
|
+
preset: 'custom',
|
|
26
|
+
url: 'https://hooks.example.com/uptimer',
|
|
27
|
+
method: 'POST',
|
|
28
|
+
payload_type: 'json',
|
|
29
|
+
payload_template: { text: '{{message}}' },
|
|
30
|
+
},
|
|
31
|
+
});
|
|
32
|
+
const TELEGRAM_CHANNEL = example({
|
|
33
|
+
name: 'Telegram Alerts',
|
|
34
|
+
type: 'webhook',
|
|
35
|
+
config_json: {
|
|
36
|
+
preset: 'telegram',
|
|
37
|
+
chat_id: '@status_channel',
|
|
38
|
+
bot_token_secret_ref: 'UPTIMER_TELEGRAM_BOT_TOKEN',
|
|
39
|
+
},
|
|
40
|
+
});
|
|
41
|
+
const STATUS_PAGE = example({
|
|
42
|
+
slug: 'public-status',
|
|
43
|
+
name: 'Public Status',
|
|
44
|
+
title: 'Service status',
|
|
45
|
+
is_public: true,
|
|
46
|
+
monitor_ids: [1, 2],
|
|
47
|
+
});
|
|
48
|
+
const SETTINGS = example({
|
|
49
|
+
site_timezone: 'Asia/Shanghai',
|
|
50
|
+
site_locale: 'en',
|
|
51
|
+
retention_check_results_days: 7,
|
|
52
|
+
});
|
|
53
|
+
const UPTIME_RATING = example({ uptime_rating_level: 4 });
|
|
54
|
+
export function getHelpText() {
|
|
55
|
+
return `Uptimer Agent Configuration CLI (uptimer-cli)
|
|
56
|
+
|
|
57
|
+
A CLI for coding agents and terminal automation to manage Uptimer
|
|
58
|
+
configuration via existing Admin APIs.
|
|
59
|
+
|
|
60
|
+
USAGE:
|
|
61
|
+
uptimer <resource> <command> [arguments...] [options...]
|
|
62
|
+
|
|
63
|
+
ENVIRONMENT VARIABLES:
|
|
64
|
+
UPTIMER_URL Instance origin URL (e.g. https://uptimer.workers.dev or http://localhost:8787).
|
|
65
|
+
HTTPS required for remote origins. HTTP only allowed on loopback (localhost, 127.0.0.1, [::1]).
|
|
66
|
+
Must be a root path without query, fragment, or userinfo.
|
|
67
|
+
UPTIMER_ADMIN_TOKEN Bearer token for Admin API authentication.
|
|
68
|
+
|
|
69
|
+
COMMANDS:
|
|
70
|
+
monitors
|
|
71
|
+
list [--limit N] List monitors (limit 1-200, default 50).
|
|
72
|
+
create --file <file|-> Create a new monitor from JSON payload.
|
|
73
|
+
update <id> --file <file|-> Update an existing monitor (PATCH semantics).
|
|
74
|
+
delete <id> Delete a monitor by ID.
|
|
75
|
+
pause <id> Pause monitoring for a monitor.
|
|
76
|
+
resume <id> Resume monitoring for a monitor.
|
|
77
|
+
monitors groups
|
|
78
|
+
reorder --file <file|-> Reorder monitor groups.
|
|
79
|
+
assign --file <file|-> Assign monitors to a group.
|
|
80
|
+
|
|
81
|
+
notification-channels
|
|
82
|
+
list [--limit N] List notification channels (limit 1-200, default 50).
|
|
83
|
+
create --file <file|-> Create a notification channel.
|
|
84
|
+
update <id> --file <file|-> Update a notification channel (PATCH semantics).
|
|
85
|
+
delete <id> Delete a notification channel by ID.
|
|
86
|
+
|
|
87
|
+
status-pages
|
|
88
|
+
list List all status pages.
|
|
89
|
+
create --file <file|-> Create a status page.
|
|
90
|
+
update <id> --file <file|-> Update a status page (PATCH semantics).
|
|
91
|
+
delete <id> Delete a status page by ID.
|
|
92
|
+
|
|
93
|
+
settings
|
|
94
|
+
get Retrieve current global settings.
|
|
95
|
+
update --file <file|-> Update global settings (PATCH semantics).
|
|
96
|
+
settings uptime-rating
|
|
97
|
+
get Retrieve uptime rating level (1-5).
|
|
98
|
+
update --file <file|-> Update uptime rating level (PUT).
|
|
99
|
+
|
|
100
|
+
skill
|
|
101
|
+
install [--dir <path>] [--force] Install the agent skill locally (default dir: ./skills).
|
|
102
|
+
|
|
103
|
+
OPTIONS:
|
|
104
|
+
--file <path|-> Path to a UTF-8 JSON file containing the payload object, or '-' for stdin.
|
|
105
|
+
--limit <number> Max items to return for list operations (1-200, default 50).
|
|
106
|
+
--dir <path> Base directory for "skill install"; the skill is written to <path>/uptimer-cli.
|
|
107
|
+
--force Replace an existing "skill install" target.
|
|
108
|
+
--help, -h Show this help text without requiring credentials.
|
|
109
|
+
|
|
110
|
+
PAYLOAD FORMAT:
|
|
111
|
+
All payload inputs via --file must be valid UTF-8 JSON objects.
|
|
112
|
+
PATCH update operations preserve field values; explicit null, false, 0, [], and omissions are respected.
|
|
113
|
+
Each option may be given at most once.
|
|
114
|
+
|
|
115
|
+
BOUNDED LIST INVENTORY:
|
|
116
|
+
List commands return up to --limit items (1-200, default 50).
|
|
117
|
+
These listings are explicitly bounded slices and do not claim to represent a complete inventory.
|
|
118
|
+
|
|
119
|
+
PAYLOAD EXAMPLES:
|
|
120
|
+
HTTP Monitor:
|
|
121
|
+
${HTTP_MONITOR}
|
|
122
|
+
|
|
123
|
+
TCP Monitor (target is host:port):
|
|
124
|
+
${TCP_MONITOR}
|
|
125
|
+
|
|
126
|
+
Custom Webhook Channel:
|
|
127
|
+
${WEBHOOK_CHANNEL}
|
|
128
|
+
|
|
129
|
+
Telegram Channel (type stays "webhook"):
|
|
130
|
+
${TELEGRAM_CHANNEL}
|
|
131
|
+
|
|
132
|
+
Status Page:
|
|
133
|
+
${STATUS_PAGE}
|
|
134
|
+
|
|
135
|
+
Global Settings:
|
|
136
|
+
${SETTINGS}
|
|
137
|
+
|
|
138
|
+
Uptime Rating:
|
|
139
|
+
${UPTIME_RATING}
|
|
140
|
+
|
|
141
|
+
EXIT CODES & OUTPUT:
|
|
142
|
+
0: Success. Single JSON value printed to stdout followed by newline. (Help is the only human text).
|
|
143
|
+
1: Transport, HTTP, deadline, redirect, or response-contract failure. Emits JSON error envelope to stderr.
|
|
144
|
+
2: Usage, input, local validation, or environment configuration failure. Emits JSON error envelope to stderr.
|
|
145
|
+
|
|
146
|
+
SECURITY & REDACTION:
|
|
147
|
+
Output is a sanitized/redacted management view: credentials, header/body blobs, and
|
|
148
|
+
notification channel URLs, headers, and payload templates are replaced with [REDACTED].
|
|
149
|
+
Returned configuration is administrative data and should not be published.
|
|
150
|
+
`;
|
|
151
|
+
}
|
|
152
|
+
// Payload examples are contract surfaces; keep them valid against the server schemas.
|
|
153
|
+
export const HELP_PAYLOAD_EXAMPLES = {
|
|
154
|
+
httpMonitor: HTTP_MONITOR,
|
|
155
|
+
tcpMonitor: TCP_MONITOR,
|
|
156
|
+
webhookChannel: WEBHOOK_CHANNEL,
|
|
157
|
+
telegramChannel: TELEGRAM_CHANNEL,
|
|
158
|
+
statusPage: STATUS_PAGE,
|
|
159
|
+
settings: SETTINGS,
|
|
160
|
+
uptimeRating: UPTIME_RATING,
|
|
161
|
+
};
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { realpathSync } from 'node:fs';
|
|
3
|
+
import { fileURLToPath } from 'node:url';
|
|
4
|
+
import { parseArgs, executeCommand } from "./commands.js";
|
|
5
|
+
import { CliError, formatErrorEnvelope } from "./errors.js";
|
|
6
|
+
import { getHelpText } from "./help.js";
|
|
7
|
+
export async function run(argvOrOptions, envParam, ioParam, fetchParam, timeoutParam) {
|
|
8
|
+
let argv;
|
|
9
|
+
let env;
|
|
10
|
+
let stdout;
|
|
11
|
+
let stderr;
|
|
12
|
+
let stdin;
|
|
13
|
+
let fetchFn;
|
|
14
|
+
let timeoutMs;
|
|
15
|
+
if (Array.isArray(argvOrOptions)) {
|
|
16
|
+
argv = argvOrOptions;
|
|
17
|
+
env = envParam ?? process.env;
|
|
18
|
+
stdout = ioParam?.stdout ?? process.stdout;
|
|
19
|
+
stderr = ioParam?.stderr ?? process.stderr;
|
|
20
|
+
stdin = ioParam?.stdin ?? process.stdin;
|
|
21
|
+
fetchFn = fetchParam;
|
|
22
|
+
timeoutMs = timeoutParam;
|
|
23
|
+
}
|
|
24
|
+
else {
|
|
25
|
+
argv = argvOrOptions.argv;
|
|
26
|
+
env = argvOrOptions.env ?? process.env;
|
|
27
|
+
stdout = argvOrOptions.stdout ?? process.stdout;
|
|
28
|
+
stderr = argvOrOptions.stderr ?? process.stderr;
|
|
29
|
+
stdin = argvOrOptions.stdin ?? process.stdin;
|
|
30
|
+
fetchFn = argvOrOptions.fetchFn ?? argvOrOptions.fetch;
|
|
31
|
+
timeoutMs = argvOrOptions.timeoutMs;
|
|
32
|
+
}
|
|
33
|
+
try {
|
|
34
|
+
const parsed = parseArgs(argv);
|
|
35
|
+
if (parsed.isHelp || (parsed.positionals.length === 1 && parsed.positionals[0] === 'help')) {
|
|
36
|
+
stdout.write(getHelpText());
|
|
37
|
+
return 0;
|
|
38
|
+
}
|
|
39
|
+
const result = await executeCommand(parsed, { env, fetchFn, timeoutMs }, stdin);
|
|
40
|
+
stdout.write(JSON.stringify(result) + '\n');
|
|
41
|
+
return 0;
|
|
42
|
+
}
|
|
43
|
+
catch (err) {
|
|
44
|
+
const exitCode = err instanceof CliError ? err.exitCode : 1;
|
|
45
|
+
const envelope = formatErrorEnvelope(err, env.UPTIMER_ADMIN_TOKEN);
|
|
46
|
+
stderr.write(JSON.stringify(envelope) + '\n');
|
|
47
|
+
return exitCode;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
// Auto-run if executed directly as script
|
|
51
|
+
const currentFile = fileURLToPath(import.meta.url);
|
|
52
|
+
const entryFile = process.argv[1] ? realpathSync(process.argv[1]) : '';
|
|
53
|
+
if (entryFile === currentFile) {
|
|
54
|
+
run(process.argv.slice(2)).then((code) => {
|
|
55
|
+
process.exit(code);
|
|
56
|
+
});
|
|
57
|
+
}
|
package/dist/input.js
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import * as fs from 'node:fs/promises';
|
|
2
|
+
import { CliError } from "./errors.js";
|
|
3
|
+
export async function readJsonPayload(filePath, stdin) {
|
|
4
|
+
let content = '';
|
|
5
|
+
if (filePath === '-') {
|
|
6
|
+
if (!stdin) {
|
|
7
|
+
throw new CliError(2, 'INVALID_ARGUMENT', 'No stdin stream available to read payload');
|
|
8
|
+
}
|
|
9
|
+
const chunks = [];
|
|
10
|
+
try {
|
|
11
|
+
for await (const chunk of stdin) {
|
|
12
|
+
if (typeof chunk === 'string') {
|
|
13
|
+
chunks.push(Buffer.from(chunk, 'utf8'));
|
|
14
|
+
}
|
|
15
|
+
else {
|
|
16
|
+
chunks.push(chunk);
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
content = Buffer.concat(chunks).toString('utf8');
|
|
20
|
+
}
|
|
21
|
+
catch {
|
|
22
|
+
throw new CliError(2, 'INVALID_ARGUMENT', 'Failed to read payload from stdin');
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
else {
|
|
26
|
+
try {
|
|
27
|
+
content = await fs.readFile(filePath, 'utf8');
|
|
28
|
+
}
|
|
29
|
+
catch (err) {
|
|
30
|
+
// Report the OS error code only; messages can embed path or input details.
|
|
31
|
+
const code = err?.code;
|
|
32
|
+
const reason = typeof code === 'string' ? ` (${code})` : '';
|
|
33
|
+
throw new CliError(2, 'INVALID_ARGUMENT', `Failed to read payload file "${filePath}"${reason}`);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
let parsed;
|
|
37
|
+
try {
|
|
38
|
+
parsed = JSON.parse(content);
|
|
39
|
+
}
|
|
40
|
+
catch (err) {
|
|
41
|
+
// `JSON.parse` messages quote the offending input, which can hold secrets.
|
|
42
|
+
const position = /at position (\d+)/.exec(err instanceof Error ? err.message : '')?.[1];
|
|
43
|
+
throw new CliError(2, 'INVALID_ARGUMENT', `Malformed JSON in payload${position ? ` at position ${position}` : ''}`);
|
|
44
|
+
}
|
|
45
|
+
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
|
|
46
|
+
throw new CliError(2, 'INVALID_ARGUMENT', 'Payload body must be a JSON object');
|
|
47
|
+
}
|
|
48
|
+
return parsed;
|
|
49
|
+
}
|
package/dist/redact.js
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
const SENSITIVE_KEYS = new Set([
|
|
2
|
+
'authorization',
|
|
3
|
+
'proxy-authorization',
|
|
4
|
+
'cookie',
|
|
5
|
+
'set-cookie',
|
|
6
|
+
'bot_token',
|
|
7
|
+
'bot_token_encrypted',
|
|
8
|
+
'password',
|
|
9
|
+
'token',
|
|
10
|
+
'secret',
|
|
11
|
+
'api_key',
|
|
12
|
+
]);
|
|
13
|
+
// Monitor config blobs: arbitrary header/body values can carry credentials.
|
|
14
|
+
const REDACT_ENTIRE_VALUE_KEYS = new Set(['http_headers', 'http_headers_json', 'http_body']);
|
|
15
|
+
// Notification channel payloads arrive as `config_json` with the same shape as `config`.
|
|
16
|
+
const CONFIG_CONTAINER_KEYS = new Set(['config', 'config_json']);
|
|
17
|
+
const CONFIG_SENSITIVE_KEYS = new Set(['headers', 'url', 'payload_template']);
|
|
18
|
+
function withoutToken(value, adminToken) {
|
|
19
|
+
return adminToken && adminToken.length > 0 ? value.split(adminToken).join('[REDACTED]') : value;
|
|
20
|
+
}
|
|
21
|
+
export function redactCredentials(value, adminToken, inConfig = false) {
|
|
22
|
+
if (value === null || value === undefined) {
|
|
23
|
+
return value;
|
|
24
|
+
}
|
|
25
|
+
if (typeof value === 'string') {
|
|
26
|
+
return withoutToken(value, adminToken);
|
|
27
|
+
}
|
|
28
|
+
if (Array.isArray(value)) {
|
|
29
|
+
return value.map((item) => redactCredentials(item, adminToken, inConfig));
|
|
30
|
+
}
|
|
31
|
+
if (typeof value === 'object') {
|
|
32
|
+
const result = {};
|
|
33
|
+
for (const [rawKey, rawValue] of Object.entries(value)) {
|
|
34
|
+
// Keys are attacker-controlled too: a reflected token can arrive as a key.
|
|
35
|
+
const key = withoutToken(rawKey, adminToken);
|
|
36
|
+
const lower = key.toLowerCase();
|
|
37
|
+
if (REDACT_ENTIRE_VALUE_KEYS.has(lower) ||
|
|
38
|
+
SENSITIVE_KEYS.has(lower) ||
|
|
39
|
+
(inConfig && CONFIG_SENSITIVE_KEYS.has(lower))) {
|
|
40
|
+
result[key] = '[REDACTED]';
|
|
41
|
+
}
|
|
42
|
+
else {
|
|
43
|
+
result[key] = redactCredentials(rawValue, adminToken, CONFIG_CONTAINER_KEYS.has(lower));
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
return result;
|
|
47
|
+
}
|
|
48
|
+
return value;
|
|
49
|
+
}
|
package/dist/skill.js
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { copyFile, mkdir, readdir, stat } from 'node:fs/promises';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { fileURLToPath } from 'node:url';
|
|
4
|
+
import { CliError } from "./errors.js";
|
|
5
|
+
const SKILL_NAME = 'uptimer-cli';
|
|
6
|
+
async function isFile(path) {
|
|
7
|
+
try {
|
|
8
|
+
return (await stat(path)).isFile();
|
|
9
|
+
}
|
|
10
|
+
catch {
|
|
11
|
+
return false;
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
// A repository checkout owns the source at the repo root; packaged installs carry `skill/`.
|
|
15
|
+
// Checkout first so a stale build copy never shadows the edited skill.
|
|
16
|
+
async function resolveSkillSource() {
|
|
17
|
+
const candidates = [
|
|
18
|
+
fileURLToPath(new URL('../../../skills/uptimer-cli', import.meta.url)),
|
|
19
|
+
fileURLToPath(new URL('../skill', import.meta.url)),
|
|
20
|
+
];
|
|
21
|
+
for (const candidate of candidates) {
|
|
22
|
+
if (await isFile(join(candidate, 'SKILL.md')))
|
|
23
|
+
return candidate;
|
|
24
|
+
}
|
|
25
|
+
throw new CliError(1, 'SKILL_NOT_FOUND', 'Bundled uptimer-cli skill files are missing from this installation');
|
|
26
|
+
}
|
|
27
|
+
export async function installSkill(input) {
|
|
28
|
+
const source = await resolveSkillSource();
|
|
29
|
+
const target = join(input.dir, SKILL_NAME);
|
|
30
|
+
if (!input.force && (await isFile(join(target, 'SKILL.md')))) {
|
|
31
|
+
throw new CliError(2, 'SKILL_ALREADY_INSTALLED', `Skill already installed at ${target}; pass --force to replace it`);
|
|
32
|
+
}
|
|
33
|
+
const references = await readdir(join(source, 'references'))
|
|
34
|
+
.then((names) => names.filter((name) => name.endsWith('.md')))
|
|
35
|
+
.catch(() => []);
|
|
36
|
+
await mkdir(join(target, 'references'), { recursive: true });
|
|
37
|
+
const files = ['SKILL.md', ...references.map((name) => join('references', name))];
|
|
38
|
+
for (const file of files) {
|
|
39
|
+
await copyFile(join(source, file), join(target, file));
|
|
40
|
+
}
|
|
41
|
+
return { installed: true, skill: SKILL_NAME, path: target, files };
|
|
42
|
+
}
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { CliError } from "./errors.js";
|
|
3
|
+
import { redactCredentials } from "./redact.js";
|
|
4
|
+
export function validateEnvironment(env = process.env) {
|
|
5
|
+
const rawUrl = env.UPTIMER_URL?.trim();
|
|
6
|
+
if (!rawUrl) {
|
|
7
|
+
throw new CliError(2, 'CONFIGURATION_ERROR', 'UPTIMER_URL environment variable is required');
|
|
8
|
+
}
|
|
9
|
+
let parsedUrl;
|
|
10
|
+
try {
|
|
11
|
+
parsedUrl = new URL(rawUrl);
|
|
12
|
+
}
|
|
13
|
+
catch (err) {
|
|
14
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
15
|
+
throw new CliError(2, 'INVALID_ARGUMENT', `Invalid UPTIMER_URL: ${msg}`);
|
|
16
|
+
}
|
|
17
|
+
if (parsedUrl.username || parsedUrl.password) {
|
|
18
|
+
throw new CliError(2, 'INVALID_ARGUMENT', 'UPTIMER_URL must not contain userinfo');
|
|
19
|
+
}
|
|
20
|
+
if (parsedUrl.search) {
|
|
21
|
+
throw new CliError(2, 'INVALID_ARGUMENT', 'UPTIMER_URL must not contain query parameters');
|
|
22
|
+
}
|
|
23
|
+
if (parsedUrl.hash) {
|
|
24
|
+
throw new CliError(2, 'INVALID_ARGUMENT', 'UPTIMER_URL must not contain URL fragments');
|
|
25
|
+
}
|
|
26
|
+
if (parsedUrl.pathname !== '' && parsedUrl.pathname !== '/') {
|
|
27
|
+
throw new CliError(2, 'INVALID_ARGUMENT', 'UPTIMER_URL must be a root path without subpaths');
|
|
28
|
+
}
|
|
29
|
+
const hostname = parsedUrl.hostname.toLowerCase();
|
|
30
|
+
const protocol = parsedUrl.protocol.toLowerCase();
|
|
31
|
+
if (protocol === 'https:') {
|
|
32
|
+
// HTTPS is allowed everywhere
|
|
33
|
+
}
|
|
34
|
+
else if (protocol === 'http:') {
|
|
35
|
+
const isLoopback = hostname === 'localhost' ||
|
|
36
|
+
hostname === '127.0.0.1' ||
|
|
37
|
+
hostname === '[::1]' ||
|
|
38
|
+
hostname === '::1';
|
|
39
|
+
if (!isLoopback) {
|
|
40
|
+
throw new CliError(2, 'INVALID_ARGUMENT', 'Insecure HTTP origins are only permitted for loopback addresses (localhost, 127.0.0.1, [::1])');
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
else {
|
|
44
|
+
throw new CliError(2, 'INVALID_ARGUMENT', `Unsupported URL protocol: "${protocol}"`);
|
|
45
|
+
}
|
|
46
|
+
const adminToken = env.UPTIMER_ADMIN_TOKEN?.trim();
|
|
47
|
+
if (!adminToken) {
|
|
48
|
+
throw new CliError(2, 'CONFIGURATION_ERROR', 'UPTIMER_ADMIN_TOKEN environment variable is required');
|
|
49
|
+
}
|
|
50
|
+
return {
|
|
51
|
+
origin: parsedUrl.origin,
|
|
52
|
+
adminToken,
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
// Transport failures keep their stable error code and drop the message, which can
|
|
56
|
+
// embed request detail; the CLI must never echo transport text verbatim.
|
|
57
|
+
function networkError(err, action) {
|
|
58
|
+
const code = err?.code;
|
|
59
|
+
const reason = typeof code === 'string' ? ` (${code})` : '';
|
|
60
|
+
return new CliError(1, 'NETWORK_ERROR', `${action}${reason}`);
|
|
61
|
+
}
|
|
62
|
+
function discardBody(response) {
|
|
63
|
+
// Fire-and-forget: a stream whose cancellation never settles must not block exit.
|
|
64
|
+
void response.body?.cancel().catch(() => { });
|
|
65
|
+
}
|
|
66
|
+
// Read the body ourselves so a deadline can cancel the stream; `response.text()`
|
|
67
|
+
// would keep the reader locked and leak a stalled body.
|
|
68
|
+
async function readBody(response, deadline) {
|
|
69
|
+
if (!response.body) {
|
|
70
|
+
return await Promise.race([response.text(), deadline]);
|
|
71
|
+
}
|
|
72
|
+
const reader = response.body.getReader();
|
|
73
|
+
const decoder = new TextDecoder();
|
|
74
|
+
let text = '';
|
|
75
|
+
try {
|
|
76
|
+
for (;;) {
|
|
77
|
+
const { done, value } = await Promise.race([reader.read(), deadline]);
|
|
78
|
+
if (done)
|
|
79
|
+
break;
|
|
80
|
+
if (value)
|
|
81
|
+
text += decoder.decode(value, { stream: true });
|
|
82
|
+
}
|
|
83
|
+
return text + decoder.decode();
|
|
84
|
+
}
|
|
85
|
+
catch (err) {
|
|
86
|
+
void reader.cancel().catch(() => { });
|
|
87
|
+
throw err;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
export async function executeApiRequest(method, path, payload, envelopeSchema, options = {}) {
|
|
91
|
+
const { origin, adminToken } = validateEnvironment(options.env);
|
|
92
|
+
const fetchFn = options.fetchFn ?? globalThis.fetch;
|
|
93
|
+
const timeoutMs = options.timeoutMs ?? 30000;
|
|
94
|
+
const url = `${origin}/api/v1/admin${path}`;
|
|
95
|
+
const controller = new AbortController();
|
|
96
|
+
// One deadline covers the request and the whole response body; the injected
|
|
97
|
+
// fetch may ignore the signal, so the body read races this promise too.
|
|
98
|
+
let timer;
|
|
99
|
+
const deadline = new Promise((_resolve, reject) => {
|
|
100
|
+
timer = setTimeout(() => {
|
|
101
|
+
controller.abort();
|
|
102
|
+
reject(new CliError(1, 'TIMEOUT', 'Request deadline exceeded'));
|
|
103
|
+
}, timeoutMs);
|
|
104
|
+
});
|
|
105
|
+
deadline.catch(() => {
|
|
106
|
+
// The race may settle on another branch first; keep the deadline rejection handled.
|
|
107
|
+
});
|
|
108
|
+
try {
|
|
109
|
+
let response;
|
|
110
|
+
try {
|
|
111
|
+
const headers = {
|
|
112
|
+
Authorization: `Bearer ${adminToken}`,
|
|
113
|
+
};
|
|
114
|
+
const body = payload === undefined ? undefined : JSON.stringify(payload);
|
|
115
|
+
const init = { method, headers, redirect: 'manual', signal: controller.signal };
|
|
116
|
+
if (body !== undefined) {
|
|
117
|
+
headers['Content-Type'] = 'application/json';
|
|
118
|
+
init.body = body;
|
|
119
|
+
}
|
|
120
|
+
response = await Promise.race([fetchFn(url, init), deadline]);
|
|
121
|
+
}
|
|
122
|
+
catch (err) {
|
|
123
|
+
if (err instanceof CliError)
|
|
124
|
+
throw err;
|
|
125
|
+
throw networkError(err, 'Network request failed');
|
|
126
|
+
}
|
|
127
|
+
if (response.type === 'opaqueredirect' || (response.status >= 300 && response.status < 400)) {
|
|
128
|
+
discardBody(response);
|
|
129
|
+
throw new CliError(1, 'REDIRECT_REJECTED', 'HTTP redirects are not allowed', response.status);
|
|
130
|
+
}
|
|
131
|
+
let text;
|
|
132
|
+
try {
|
|
133
|
+
text = await readBody(response, deadline);
|
|
134
|
+
}
|
|
135
|
+
catch (err) {
|
|
136
|
+
if (err instanceof CliError)
|
|
137
|
+
throw err;
|
|
138
|
+
throw networkError(err, 'Failed reading response body');
|
|
139
|
+
}
|
|
140
|
+
let parsed;
|
|
141
|
+
try {
|
|
142
|
+
parsed = JSON.parse(text);
|
|
143
|
+
}
|
|
144
|
+
catch {
|
|
145
|
+
if (response.status >= 400) {
|
|
146
|
+
throw new CliError(1, 'HTTP_ERROR', response.statusText || `HTTP ${response.status}`, response.status);
|
|
147
|
+
}
|
|
148
|
+
throw new CliError(1, 'INVALID_RESPONSE', 'Response body is not valid JSON', response.status);
|
|
149
|
+
}
|
|
150
|
+
if (response.status >= 400) {
|
|
151
|
+
const errorBody = parsed?.error;
|
|
152
|
+
if (errorBody && typeof errorBody === 'object') {
|
|
153
|
+
const code = typeof errorBody.code === 'string' ? errorBody.code : 'HTTP_ERROR';
|
|
154
|
+
const message = typeof errorBody.message === 'string'
|
|
155
|
+
? errorBody.message
|
|
156
|
+
: response.statusText || `HTTP ${response.status}`;
|
|
157
|
+
throw new CliError(1, code, message, response.status);
|
|
158
|
+
}
|
|
159
|
+
throw new CliError(1, 'HTTP_ERROR', response.statusText || `HTTP ${response.status}`, response.status);
|
|
160
|
+
}
|
|
161
|
+
const envelopeResult = envelopeSchema.safeParse(parsed);
|
|
162
|
+
if (!envelopeResult.success) {
|
|
163
|
+
throw new CliError(1, 'INVALID_RESPONSE', 'Unexpected response envelope from server', response.status);
|
|
164
|
+
}
|
|
165
|
+
return redactCredentials(envelopeResult.data, adminToken);
|
|
166
|
+
}
|
|
167
|
+
finally {
|
|
168
|
+
if (timer !== undefined)
|
|
169
|
+
clearTimeout(timer);
|
|
170
|
+
}
|
|
171
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,44 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "uptimer-cli",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "Manage Uptimer configuration from coding agents and the terminal",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"engines": {
|
|
8
|
+
"node": ">=22.14.0"
|
|
9
|
+
},
|
|
10
|
+
"repository": {
|
|
11
|
+
"type": "git",
|
|
12
|
+
"url": "git+https://github.com/nexttylabs/Uptimer.git",
|
|
13
|
+
"directory": "apps/cli"
|
|
14
|
+
},
|
|
15
|
+
"bin": {
|
|
16
|
+
"uptimer": "./dist/index.js"
|
|
17
|
+
},
|
|
18
|
+
"files": [
|
|
19
|
+
"dist",
|
|
20
|
+
"skill",
|
|
21
|
+
"LICENSE",
|
|
22
|
+
"README.md",
|
|
23
|
+
"CHANGELOG.md"
|
|
24
|
+
],
|
|
25
|
+
"publishConfig": {
|
|
26
|
+
"access": "public"
|
|
27
|
+
},
|
|
28
|
+
"scripts": {
|
|
29
|
+
"start": "node --experimental-strip-types src/index.ts",
|
|
30
|
+
"build": "rm -rf dist && tsc -p tsconfig.build.json",
|
|
31
|
+
"prepack": "pnpm build && node scripts/prepare-skill.mjs",
|
|
32
|
+
"test:package": "pnpm prepack && node --experimental-strip-types --test test/package.test.ts",
|
|
33
|
+
"test": "node --experimental-strip-types --test test/entry.test.ts test/resources.test.ts test/transport.test.ts test/skill.test.ts",
|
|
34
|
+
"lint": "pnpm -w exec eslint --config eslint.config.mjs apps/cli",
|
|
35
|
+
"typecheck": "pnpm -w exec tsc -p apps/cli/tsconfig.json --noEmit"
|
|
36
|
+
},
|
|
37
|
+
"dependencies": {
|
|
38
|
+
"zod": "^3.0.0"
|
|
39
|
+
},
|
|
40
|
+
"devDependencies": {
|
|
41
|
+
"@types/node": "^22.0.0",
|
|
42
|
+
"typescript": "^5.0.0"
|
|
43
|
+
}
|
|
44
|
+
}
|
package/skill/SKILL.md
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: uptimer-cli
|
|
3
|
+
description: Install and use the Uptimer CLI to inspect or change monitors, notification channels, status pages, and global settings on an existing Uptimer instance.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Uptimer CLI
|
|
7
|
+
|
|
8
|
+
The npm package is `uptimer-cli`; the executable is `uptimer`. This skill configures an existing Uptimer instance through its Admin API.
|
|
9
|
+
|
|
10
|
+
## 1. Select a working CLI
|
|
11
|
+
|
|
12
|
+
Run `uptimer --help` if the executable is installed. If installation is requested and an npm release is available, use `npm install --global uptimer-cli`. For a requested pinned release, use `npx --yes uptimer-cli@<version>` in place of `uptimer`, substituting an actual published version.
|
|
13
|
+
|
|
14
|
+
If npm returns 404 before the first release, use a checkout of `nexttylabs/Uptimer` containing `apps/cli`:
|
|
15
|
+
|
|
16
|
+
```sh
|
|
17
|
+
pnpm install --frozen-lockfile
|
|
18
|
+
pnpm --silent --filter uptimer-cli start --help
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
For later commands in that checkout, replace `uptimer` with `pnpm --silent --filter uptimer-cli start`. CLI support starts at Node 22.14.0. The separate Skills installer requires Node 22.20 or newer; Node 24 supports both.
|
|
22
|
+
|
|
23
|
+
Continue when help exits 0 and lists the expected resource commands. Help covers command syntax and payload examples for every resource; [commands.md](references/commands.md) adds field detail and edge cases.
|
|
24
|
+
|
|
25
|
+
When this skill has been installed once, `uptimer skill install --dir <dir> --force` refreshes it from the installed CLI package; the default directory is `./skills`.
|
|
26
|
+
|
|
27
|
+
## 2. Connect to the intended instance
|
|
28
|
+
|
|
29
|
+
Use `UPTIMER_URL` and `UPTIMER_ADMIN_TOKEN` from the invoking process environment or the host's credential configuration. Ask for a missing instance URL; have the administrator inject a missing token through the host's secret mechanism, rather than posting it in chat. The CLI does not load `.env` automatically or store a login profile.
|
|
30
|
+
|
|
31
|
+
The URL is an HTTPS origin, without `/api/v1`, userinfo, query, fragment, or subpath. Explicit loopback HTTP origins, such as `http://localhost:8787`, are supported for development.
|
|
32
|
+
|
|
33
|
+
Run `uptimer settings get` to check connectivity and authentication. Output is redacted, but returned configuration is still administrative data: report only the non-secret fields relevant to the request instead of pasting whole resource payloads.
|
|
34
|
+
|
|
35
|
+
Continue when the configured instance returns a successful JSON response. An HTTP 401 requires correcting the supplied credential; it is not a reason to change the instance or authentication settings.
|
|
36
|
+
|
|
37
|
+
## 3. Perform the requested operation
|
|
38
|
+
|
|
39
|
+
Read the relevant resource section in [commands.md](references/commands.md) before constructing a payload. Use actual IDs returned by the selected instance, and keep changes within the user's authorized resource and fields. An unchanged authorization needs no second confirmation.
|
|
40
|
+
|
|
41
|
+
For a write, provide one UTF-8 JSON object through exactly one `--file path.json` or `--file -`. Keep omitted PATCH fields omitted; use `null` only where that field supports clearing. Validate JSON locally before invoking the CLI to catch syntax errors early; the CLI reports only a parse position. Keep credential-bearing payload files private and remove task-created temporary files when finished.
|
|
42
|
+
|
|
43
|
+
List commands for monitors and notification channels return at most 50 records by default, or 200 with `--limit 200`. A resource missing from a bounded list is not proof that it does not exist. The CLI has no cursor, per-ID get command, or complete export.
|
|
44
|
+
|
|
45
|
+
A 409 conflict is a request to explain the conflicting state. Unlink or delete other resources only when that additional change is authorized. This CLI does not provision Worker secrets, deploy Cloudflare resources, manage incidents or maintenance windows, or synchronize a declarative configuration file.
|
|
46
|
+
|
|
47
|
+
## 4. Verify and report
|
|
48
|
+
|
|
49
|
+
Check the exit status and JSON result, then query the affected list or settings resource and compare the requested fields. If the item is outside the bounded listing, report that readback could not be completed rather than claiming it was verified.
|
|
50
|
+
|
|
51
|
+
- Exit 0: success; stdout is JSON, except for help.
|
|
52
|
+
- Exit 2: local input, usage, or environment problem; correct it before retrying.
|
|
53
|
+
- Exit 1: HTTP, transport, deadline, or response-contract failure. A write may already have succeeded. Query its state before considering a retry, especially after interruption or timeout; never automatically repeat a mutation.
|
|
54
|
+
|
|
55
|
+
A response body that stops streaming is cut off at the deadline, and the CLI exits without retrying the write. Treat an externally interrupted write as uncertain even when the CLI reports a timeout.
|
|
56
|
+
|
|
57
|
+
Finish with the instance, operation, resource IDs, changed non-secret fields, and readback outcome. Name any conflict, incomplete verification, or uncertain write result explicitly.
|