@ascendkit/cli 0.2.6 → 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.
@@ -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
+ }
@@ -13,6 +13,11 @@ interface JourneyData {
13
13
  nodes: Record<string, {
14
14
  action?: {
15
15
  type?: string;
16
+ templateSlug?: string;
17
+ surveySlug?: string;
18
+ tagName?: string;
19
+ stageName?: string;
20
+ fromIdentityEmail?: string;
16
21
  };
17
22
  terminal?: boolean;
18
23
  }>;
@@ -83,6 +88,7 @@ interface NodeListItem {
83
88
  surveySlug?: string;
84
89
  tagName?: string;
85
90
  stageName?: string;
91
+ fromIdentityEmail?: string;
86
92
  };
87
93
  terminal: boolean;
88
94
  isEntryNode: boolean;
@@ -32,7 +32,7 @@ export function formatJourneyWithGuidance(journey) {
32
32
  lines.push("");
33
33
  lines.push("Nodes:");
34
34
  for (const [name, node] of Object.entries(journey.nodes || {})) {
35
- const action = node.action?.type || "none";
35
+ const action = formatActionLabel(node.action || {});
36
36
  const terminal = node.terminal ? " (terminal)" : "";
37
37
  const isEntry = name === journey.entryNode ? " [entry]" : "";
38
38
  lines.push(` ${name}: ${action}${terminal}${isEntry}`);
@@ -70,7 +70,7 @@ export function formatJourneyWithGuidance(journey) {
70
70
  hints.push("Journey is active and enrolling users. Use journey_analytics to see user flow.");
71
71
  }
72
72
  else if (journey.status === "paused") {
73
- hints.push("Journey is paused. Actions are queued. Use journey_activate to resume.");
73
+ hints.push("Journey is paused. Actions are queued. Use journey_resume to continue delivery.");
74
74
  }
75
75
  else if (journey.status === "archived") {
76
76
  hints.push("Journey is archived. No further enrollment or transitions.");
@@ -113,6 +113,8 @@ function formatActionLabel(action) {
113
113
  const type = action?.type || "none";
114
114
  if (type === "send_email") {
115
115
  const parts = [`send_email (${action.templateSlug || "?"})`];
116
+ if (action.fromIdentityEmail)
117
+ parts.push(`from: ${action.fromIdentityEmail}`);
116
118
  if (action.surveySlug)
117
119
  parts.push(`+ survey: ${action.surveySlug}`);
118
120
  return parts.join(" ");
@@ -131,7 +133,7 @@ function formatTriggerLabel(trigger) {
131
133
  export function formatNodeList(data) {
132
134
  const nodes = data.nodes || [];
133
135
  if (nodes.length === 0) {
134
- return "No nodes. Use journey_add_node to add the first node.";
136
+ return "No nodes. Use journey_node_add to add the first node.";
135
137
  }
136
138
  const lines = [`${nodes.length} node(s):\n`];
137
139
  for (const n of nodes) {
@@ -150,7 +152,7 @@ export function formatSingleNode(data, action, nodeName) {
150
152
  export function formatTransitionList(data) {
151
153
  const transitions = data.transitions || [];
152
154
  if (transitions.length === 0) {
153
- return "No transitions. Use journey_add_transition to connect nodes.";
155
+ return "No transitions. Use journey_transition_add to connect nodes.";
154
156
  }
155
157
  const lines = [`${transitions.length} transition(s):\n`];
156
158
  for (const t of transitions) {
@@ -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
+ }
@@ -84,11 +84,11 @@ export function formatSurveyWithGuidance(survey) {
84
84
  // Next steps based on current state
85
85
  const hints = [];
86
86
  if (questionCount === 0) {
87
- hints.push("This survey has no questions yet. Use survey_add_question to add questions, or survey_list_questions to see supported question types.");
87
+ hints.push("This survey has no questions yet. Use survey_question_add to add questions, or survey_question_list to see supported question types.");
88
88
  }
89
89
  else if (survey.status === "draft") {
90
90
  hints.push(`Survey has ${questionCount} question(s) and is in draft. Use survey_update with status 'active' to activate it for distribution.`);
91
- hints.push("Use survey_list_questions to review the questions before activating.");
91
+ hints.push("Use survey_question_list to review the questions before activating.");
92
92
  }
93
93
  else if (survey.status === "active" && responseCount === 0) {
94
94
  hints.push("Survey is active and ready to distribute. Use survey_distribute with a list of user IDs to create personalized tracking links.");
@@ -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
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ascendkit/cli",
3
- "version": "0.2.6",
3
+ "version": "0.3.1",
4
4
  "description": "AscendKit CLI and MCP server",
5
5
  "author": "ascendkit.dev",
6
6
  "license": "MIT",
@@ -33,11 +33,11 @@
33
33
  "start": "node dist/cli.js"
34
34
  },
35
35
  "dependencies": {
36
- "@modelcontextprotocol/sdk": "^1.0.0",
37
- "zod": "^3.23.0"
36
+ "@modelcontextprotocol/sdk": "^1.27.1",
37
+ "zod": "^3.25.76"
38
38
  },
39
39
  "devDependencies": {
40
- "typescript": "^5.7.0",
41
- "@types/node": "^22.0.0"
40
+ "@types/node": "^25.5.0",
41
+ "typescript": "^5.7.0"
42
42
  }
43
43
  }