@ascendkit/cli 0.3.0 → 0.3.1
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/dist/api/client.js +5 -1
- package/dist/cli.js +185 -119
- package/dist/commands/platform.js +8 -2
- package/dist/utils/correlation.d.ts +11 -0
- package/dist/utils/correlation.js +67 -0
- package/dist/utils/exit.d.ts +23 -0
- package/dist/utils/exit.js +64 -0
- package/dist/utils/redaction.d.ts +16 -0
- package/dist/utils/redaction.js +114 -0
- package/dist/utils/telemetry.d.ts +32 -0
- package/dist/utils/telemetry.js +47 -0
- package/package.json +1 -1
|
@@ -3,6 +3,8 @@ import { readdir, readFile, writeFile } from "node:fs/promises";
|
|
|
3
3
|
import path from "node:path";
|
|
4
4
|
import { loadAuth, loadEnvContext, saveAuth, deleteAuth, saveEnvContext, ensureGitignore, } from "../utils/credentials.js";
|
|
5
5
|
import { DEFAULT_API_URL, DEFAULT_PORTAL_URL } from "../constants.js";
|
|
6
|
+
import { exitCli } from "../utils/exit.js";
|
|
7
|
+
import { correlationHeaders } from "../utils/correlation.js";
|
|
6
8
|
const POLL_INTERVAL_MS = 2000;
|
|
7
9
|
const DEVICE_CODE_EXPIRY_MS = 300_000; // 5 minutes
|
|
8
10
|
const IGNORE_DIRS = new Set([".git", "node_modules", ".next", "dist", "build"]);
|
|
@@ -339,7 +341,10 @@ function generateDeviceCode() {
|
|
|
339
341
|
return code;
|
|
340
342
|
}
|
|
341
343
|
async function apiRequest(apiUrl, method, path, body, token, publicKey) {
|
|
342
|
-
const headers = {
|
|
344
|
+
const headers = {
|
|
345
|
+
"Content-Type": "application/json",
|
|
346
|
+
...correlationHeaders(),
|
|
347
|
+
};
|
|
343
348
|
if (token)
|
|
344
349
|
headers["Authorization"] = `Bearer ${token}`;
|
|
345
350
|
if (publicKey)
|
|
@@ -471,7 +476,8 @@ function requireAuth() {
|
|
|
471
476
|
const auth = loadAuth();
|
|
472
477
|
if (!auth?.token) {
|
|
473
478
|
console.error("Not initialized. Run: ascendkit init");
|
|
474
|
-
|
|
479
|
+
exitCli(1);
|
|
480
|
+
throw new Error("unreachable"); // exitCli terminates the process
|
|
475
481
|
}
|
|
476
482
|
return auth;
|
|
477
483
|
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Correlation IDs for request tracing.
|
|
3
|
+
*
|
|
4
|
+
* - `invocationId` — one UUID per CLI process (lazy-initialized)
|
|
5
|
+
* - `clientRequestId` — fresh UUID per outbound HTTP request
|
|
6
|
+
* - `machineId` — persisted at ~/.ascendkit/machine-id, generated once
|
|
7
|
+
*/
|
|
8
|
+
export declare function getInvocationId(): string;
|
|
9
|
+
export declare function generateClientRequestId(): string;
|
|
10
|
+
export declare function correlationHeaders(): Record<string, string>;
|
|
11
|
+
export declare function getMachineId(): string;
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Correlation IDs for request tracing.
|
|
3
|
+
*
|
|
4
|
+
* - `invocationId` — one UUID per CLI process (lazy-initialized)
|
|
5
|
+
* - `clientRequestId` — fresh UUID per outbound HTTP request
|
|
6
|
+
* - `machineId` — persisted at ~/.ascendkit/machine-id, generated once
|
|
7
|
+
*/
|
|
8
|
+
import { randomUUID } from "node:crypto";
|
|
9
|
+
import { readFileSync, writeFileSync, mkdirSync, existsSync } from "node:fs";
|
|
10
|
+
import { join } from "node:path";
|
|
11
|
+
import { homedir } from "node:os";
|
|
12
|
+
// ---------------------------------------------------------------------------
|
|
13
|
+
// Invocation ID — one per process
|
|
14
|
+
// ---------------------------------------------------------------------------
|
|
15
|
+
let _invocationId;
|
|
16
|
+
export function getInvocationId() {
|
|
17
|
+
if (!_invocationId) {
|
|
18
|
+
_invocationId = randomUUID();
|
|
19
|
+
}
|
|
20
|
+
return _invocationId;
|
|
21
|
+
}
|
|
22
|
+
// ---------------------------------------------------------------------------
|
|
23
|
+
// Client Request ID — one per outbound request
|
|
24
|
+
// ---------------------------------------------------------------------------
|
|
25
|
+
export function generateClientRequestId() {
|
|
26
|
+
return randomUUID();
|
|
27
|
+
}
|
|
28
|
+
// ---------------------------------------------------------------------------
|
|
29
|
+
// Correlation headers (added to every AscendKit API call)
|
|
30
|
+
// ---------------------------------------------------------------------------
|
|
31
|
+
export function correlationHeaders() {
|
|
32
|
+
return {
|
|
33
|
+
"X-AscendKit-Invocation-Id": getInvocationId(),
|
|
34
|
+
"X-AscendKit-Client-Request-Id": generateClientRequestId(),
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
// ---------------------------------------------------------------------------
|
|
38
|
+
// Machine ID — persisted in ~/.ascendkit/machine-id
|
|
39
|
+
// ---------------------------------------------------------------------------
|
|
40
|
+
const MACHINE_ID_DIR = join(homedir(), ".ascendkit");
|
|
41
|
+
const MACHINE_ID_PATH = join(MACHINE_ID_DIR, "machine-id");
|
|
42
|
+
let _machineId;
|
|
43
|
+
export function getMachineId() {
|
|
44
|
+
if (_machineId)
|
|
45
|
+
return _machineId;
|
|
46
|
+
try {
|
|
47
|
+
const stored = readFileSync(MACHINE_ID_PATH, "utf-8").trim();
|
|
48
|
+
if (stored) {
|
|
49
|
+
_machineId = stored;
|
|
50
|
+
return _machineId;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
catch {
|
|
54
|
+
// File doesn't exist yet — will create below.
|
|
55
|
+
}
|
|
56
|
+
_machineId = randomUUID();
|
|
57
|
+
try {
|
|
58
|
+
if (!existsSync(MACHINE_ID_DIR)) {
|
|
59
|
+
mkdirSync(MACHINE_ID_DIR, { recursive: true });
|
|
60
|
+
}
|
|
61
|
+
writeFileSync(MACHINE_ID_PATH, _machineId, "utf-8");
|
|
62
|
+
}
|
|
63
|
+
catch {
|
|
64
|
+
// Non-fatal — we still have the in-memory value for this session.
|
|
65
|
+
}
|
|
66
|
+
return _machineId;
|
|
67
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Centralized CLI exit handler.
|
|
3
|
+
*
|
|
4
|
+
* All CLI termination routes through `exitCli()` so that telemetry hooks
|
|
5
|
+
* (and any future cleanup) can run before the process exits.
|
|
6
|
+
*/
|
|
7
|
+
type ExitHook = (code: number, error?: Error) => void | Promise<void>;
|
|
8
|
+
/**
|
|
9
|
+
* Register a callback that runs before the process exits.
|
|
10
|
+
* Hooks execute in registration order with a bounded timeout.
|
|
11
|
+
*/
|
|
12
|
+
export declare function onExit(callback: ExitHook): void;
|
|
13
|
+
/**
|
|
14
|
+
* Central exit point for the CLI.
|
|
15
|
+
* Runs all registered hooks (with timeout), then calls `process.exit(code)`.
|
|
16
|
+
*/
|
|
17
|
+
export declare function exitCli(code: number, error?: Error): Promise<never>;
|
|
18
|
+
/**
|
|
19
|
+
* Install global handlers for uncaught exceptions and unhandled rejections.
|
|
20
|
+
* Routes them through the central exit handler.
|
|
21
|
+
*/
|
|
22
|
+
export declare function installGlobalHandlers(): void;
|
|
23
|
+
export {};
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Centralized CLI exit handler.
|
|
3
|
+
*
|
|
4
|
+
* All CLI termination routes through `exitCli()` so that telemetry hooks
|
|
5
|
+
* (and any future cleanup) can run before the process exits.
|
|
6
|
+
*/
|
|
7
|
+
const hooks = [];
|
|
8
|
+
let exiting = false;
|
|
9
|
+
const EXIT_HOOK_TIMEOUT_MS = 3_000;
|
|
10
|
+
/**
|
|
11
|
+
* Register a callback that runs before the process exits.
|
|
12
|
+
* Hooks execute in registration order with a bounded timeout.
|
|
13
|
+
*/
|
|
14
|
+
export function onExit(callback) {
|
|
15
|
+
hooks.push(callback);
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* Central exit point for the CLI.
|
|
19
|
+
* Runs all registered hooks (with timeout), then calls `process.exit(code)`.
|
|
20
|
+
*/
|
|
21
|
+
export async function exitCli(code, error) {
|
|
22
|
+
// Guard against re-entrant calls (e.g. hook triggers another exit)
|
|
23
|
+
if (exiting) {
|
|
24
|
+
process.exit(code);
|
|
25
|
+
}
|
|
26
|
+
exiting = true;
|
|
27
|
+
if (hooks.length > 0) {
|
|
28
|
+
try {
|
|
29
|
+
await Promise.race([
|
|
30
|
+
runHooks(code, error),
|
|
31
|
+
new Promise((resolve) => setTimeout(resolve, EXIT_HOOK_TIMEOUT_MS)),
|
|
32
|
+
]);
|
|
33
|
+
}
|
|
34
|
+
catch {
|
|
35
|
+
// Hooks must never prevent exit
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
process.exit(code);
|
|
39
|
+
}
|
|
40
|
+
async function runHooks(code, error) {
|
|
41
|
+
for (const hook of hooks) {
|
|
42
|
+
try {
|
|
43
|
+
await hook(code, error);
|
|
44
|
+
}
|
|
45
|
+
catch {
|
|
46
|
+
// Individual hook failures are silently ignored
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* Install global handlers for uncaught exceptions and unhandled rejections.
|
|
52
|
+
* Routes them through the central exit handler.
|
|
53
|
+
*/
|
|
54
|
+
export function installGlobalHandlers() {
|
|
55
|
+
process.on("uncaughtException", (err) => {
|
|
56
|
+
console.error(err.message ?? err);
|
|
57
|
+
exitCli(1, err instanceof Error ? err : new Error(String(err)));
|
|
58
|
+
});
|
|
59
|
+
process.on("unhandledRejection", (reason) => {
|
|
60
|
+
const msg = reason instanceof Error ? reason.message : String(reason);
|
|
61
|
+
console.error(msg);
|
|
62
|
+
exitCli(1, reason instanceof Error ? reason : new Error(String(reason)));
|
|
63
|
+
});
|
|
64
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Argument redaction for CLI telemetry.
|
|
3
|
+
*
|
|
4
|
+
* Strips secret-bearing flags and large payloads from argv before
|
|
5
|
+
* telemetry transport, while preserving AscendKit entity IDs that
|
|
6
|
+
* are useful for debugging.
|
|
7
|
+
*/
|
|
8
|
+
/**
|
|
9
|
+
* Redact command arguments for safe telemetry transport.
|
|
10
|
+
*
|
|
11
|
+
* - `--secret-flag value` → `--secret-flag [REDACTED]`
|
|
12
|
+
* - `--secret-flag=value` → `--secret-flag=[REDACTED]`
|
|
13
|
+
* - Long values (>100 chars) → `[REDACTED]`
|
|
14
|
+
* - AscendKit entity IDs are preserved regardless of length
|
|
15
|
+
*/
|
|
16
|
+
export declare function redactArgs(argv: string[]): string[];
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Argument redaction for CLI telemetry.
|
|
3
|
+
*
|
|
4
|
+
* Strips secret-bearing flags and large payloads from argv before
|
|
5
|
+
* telemetry transport, while preserving AscendKit entity IDs that
|
|
6
|
+
* are useful for debugging.
|
|
7
|
+
*/
|
|
8
|
+
/** Flags whose values must always be redacted. */
|
|
9
|
+
const SECRET_FLAGS = new Set([
|
|
10
|
+
"secret-key",
|
|
11
|
+
"token",
|
|
12
|
+
"password",
|
|
13
|
+
"api-key",
|
|
14
|
+
"client-secret",
|
|
15
|
+
"client-secret-stdin",
|
|
16
|
+
"authorization",
|
|
17
|
+
"bearer",
|
|
18
|
+
"cookie",
|
|
19
|
+
]);
|
|
20
|
+
/** AscendKit entity ID prefixes — values matching these are safe to keep. */
|
|
21
|
+
const ENTITY_ID_RE = /^(cli|mem|prj|usr|tpl|srv|inv|ses|env|whk|cpn)_[a-zA-Z0-9]+$/;
|
|
22
|
+
const MAX_VALUE_LENGTH = 100;
|
|
23
|
+
/**
|
|
24
|
+
* Returns true if a value looks like an AscendKit entity ID and is safe to keep.
|
|
25
|
+
*/
|
|
26
|
+
function isEntityId(value) {
|
|
27
|
+
return ENTITY_ID_RE.test(value);
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Determines whether a plain (non-flag) value should be redacted.
|
|
31
|
+
* Entity IDs are preserved; long values are redacted.
|
|
32
|
+
*/
|
|
33
|
+
function shouldRedactValue(value) {
|
|
34
|
+
if (isEntityId(value))
|
|
35
|
+
return false;
|
|
36
|
+
return value.length > MAX_VALUE_LENGTH;
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Extract the flag name from a `--flag` or `--flag=value` argument.
|
|
40
|
+
*/
|
|
41
|
+
function extractFlagName(arg) {
|
|
42
|
+
if (!arg.startsWith("--"))
|
|
43
|
+
return null;
|
|
44
|
+
const eqIdx = arg.indexOf("=");
|
|
45
|
+
return eqIdx === -1 ? arg.slice(2) : arg.slice(2, eqIdx);
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* Redact command arguments for safe telemetry transport.
|
|
49
|
+
*
|
|
50
|
+
* - `--secret-flag value` → `--secret-flag [REDACTED]`
|
|
51
|
+
* - `--secret-flag=value` → `--secret-flag=[REDACTED]`
|
|
52
|
+
* - Long values (>100 chars) → `[REDACTED]`
|
|
53
|
+
* - AscendKit entity IDs are preserved regardless of length
|
|
54
|
+
*/
|
|
55
|
+
export function redactArgs(argv) {
|
|
56
|
+
const result = [];
|
|
57
|
+
let redactNext = false;
|
|
58
|
+
for (let i = 0; i < argv.length; i++) {
|
|
59
|
+
const arg = argv[i];
|
|
60
|
+
// If the previous flag was a secret flag, redact this positional value
|
|
61
|
+
if (redactNext) {
|
|
62
|
+
redactNext = false;
|
|
63
|
+
result.push("[REDACTED]");
|
|
64
|
+
continue;
|
|
65
|
+
}
|
|
66
|
+
// Handle --flag=value form
|
|
67
|
+
const eqIdx = arg.indexOf("=");
|
|
68
|
+
if (arg.startsWith("--") && eqIdx !== -1) {
|
|
69
|
+
const flagName = arg.slice(2, eqIdx);
|
|
70
|
+
if (SECRET_FLAGS.has(flagName)) {
|
|
71
|
+
result.push(`--${flagName}=[REDACTED]`);
|
|
72
|
+
}
|
|
73
|
+
else {
|
|
74
|
+
const value = arg.slice(eqIdx + 1);
|
|
75
|
+
if (shouldRedactValue(value)) {
|
|
76
|
+
result.push(`--${flagName}=[REDACTED]`);
|
|
77
|
+
}
|
|
78
|
+
else {
|
|
79
|
+
result.push(arg);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
continue;
|
|
83
|
+
}
|
|
84
|
+
// Handle --flag (next arg is the value)
|
|
85
|
+
if (arg.startsWith("--")) {
|
|
86
|
+
const flagName = extractFlagName(arg);
|
|
87
|
+
if (flagName && SECRET_FLAGS.has(flagName)) {
|
|
88
|
+
result.push(arg);
|
|
89
|
+
// Check if next arg is a value (not another flag)
|
|
90
|
+
if (i + 1 < argv.length && !argv[i + 1].startsWith("--")) {
|
|
91
|
+
redactNext = true;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
else {
|
|
95
|
+
result.push(arg);
|
|
96
|
+
// Check if next arg is a long value that needs redaction
|
|
97
|
+
if (i + 1 < argv.length &&
|
|
98
|
+
!argv[i + 1].startsWith("--") &&
|
|
99
|
+
shouldRedactValue(argv[i + 1])) {
|
|
100
|
+
redactNext = true;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
continue;
|
|
104
|
+
}
|
|
105
|
+
// Plain positional argument — redact if too long (but not entity IDs)
|
|
106
|
+
if (shouldRedactValue(arg)) {
|
|
107
|
+
result.push("[REDACTED]");
|
|
108
|
+
}
|
|
109
|
+
else {
|
|
110
|
+
result.push(arg);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
return result;
|
|
114
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CLI command telemetry.
|
|
3
|
+
*
|
|
4
|
+
* Best-effort delivery — telemetry failures are silently swallowed and
|
|
5
|
+
* never affect command outcome.
|
|
6
|
+
*/
|
|
7
|
+
export interface TelemetryRecord {
|
|
8
|
+
invocationId: string;
|
|
9
|
+
machineId: string;
|
|
10
|
+
clientType: "cli";
|
|
11
|
+
clientVersion: string;
|
|
12
|
+
command: string;
|
|
13
|
+
domain: string | null;
|
|
14
|
+
action: string | null;
|
|
15
|
+
args: string[];
|
|
16
|
+
dtEntered: string;
|
|
17
|
+
dtCompleted: string;
|
|
18
|
+
durationMs: number;
|
|
19
|
+
success: boolean;
|
|
20
|
+
errorMessage: string | null;
|
|
21
|
+
hostname: string;
|
|
22
|
+
os: string;
|
|
23
|
+
nodeVersion: string;
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* Send a telemetry record to the backend.
|
|
27
|
+
*
|
|
28
|
+
* Best-effort: uses a short timeout and catches all errors silently.
|
|
29
|
+
* Includes the bearer token when available so the server can attribute
|
|
30
|
+
* the record to a user, but works without authentication.
|
|
31
|
+
*/
|
|
32
|
+
export declare function captureTelemetry(record: TelemetryRecord): Promise<void>;
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CLI command telemetry.
|
|
3
|
+
*
|
|
4
|
+
* Best-effort delivery — telemetry failures are silently swallowed and
|
|
5
|
+
* never affect command outcome.
|
|
6
|
+
*/
|
|
7
|
+
import { DEFAULT_API_URL } from "../constants.js";
|
|
8
|
+
import { loadAuth } from "./credentials.js";
|
|
9
|
+
import { correlationHeaders } from "./correlation.js";
|
|
10
|
+
const TELEMETRY_TIMEOUT_MS = 5_000;
|
|
11
|
+
const TELEMETRY_PATH = "/api/telemetry/cli-command";
|
|
12
|
+
/**
|
|
13
|
+
* Send a telemetry record to the backend.
|
|
14
|
+
*
|
|
15
|
+
* Best-effort: uses a short timeout and catches all errors silently.
|
|
16
|
+
* Includes the bearer token when available so the server can attribute
|
|
17
|
+
* the record to a user, but works without authentication.
|
|
18
|
+
*/
|
|
19
|
+
export async function captureTelemetry(record) {
|
|
20
|
+
try {
|
|
21
|
+
const auth = loadAuth();
|
|
22
|
+
const baseUrl = auth?.apiUrl ?? DEFAULT_API_URL;
|
|
23
|
+
const headers = {
|
|
24
|
+
"Content-Type": "application/json",
|
|
25
|
+
...correlationHeaders(),
|
|
26
|
+
};
|
|
27
|
+
if (auth?.token) {
|
|
28
|
+
headers["Authorization"] = `Bearer ${auth.token}`;
|
|
29
|
+
}
|
|
30
|
+
const controller = new AbortController();
|
|
31
|
+
const timeout = setTimeout(() => controller.abort(), TELEMETRY_TIMEOUT_MS);
|
|
32
|
+
try {
|
|
33
|
+
await fetch(`${baseUrl}${TELEMETRY_PATH}`, {
|
|
34
|
+
method: "POST",
|
|
35
|
+
headers,
|
|
36
|
+
body: JSON.stringify(record),
|
|
37
|
+
signal: controller.signal,
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
finally {
|
|
41
|
+
clearTimeout(timeout);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
catch {
|
|
45
|
+
// Best-effort — silently ignore all errors
|
|
46
|
+
}
|
|
47
|
+
}
|