@scalepad/cli 0.1.0 → 0.1.2

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 ADDED
@@ -0,0 +1,86 @@
1
+ # @scalepad/cli
2
+
3
+ Command line interface for the public ScalePad Core API and Lifecycle Manager API.
4
+
5
+ This is the primary supported developer interface for this repo. The `@scalepad/sdk-core` and `@scalepad/sdk-lm` packages are intentionally thinner helper packages used by the CLI.
6
+
7
+ ## Install
8
+
9
+ ```bash
10
+ npm install -g @scalepad/cli
11
+ ```
12
+
13
+ ## Quick start
14
+
15
+ ```bash
16
+ scalepad auth login
17
+ scalepad auth whoami
18
+ scalepad core clients list --limit 5
19
+ scalepad lm action-items list --limit 5
20
+ ```
21
+
22
+ The CLI resolves credentials in this order:
23
+
24
+ 1. `--api-key`
25
+ 2. `SCALEPAD_API_KEY`
26
+ 3. Stored profile credential
27
+
28
+ When keychain access is available, credentials are stored with `keytar`. Otherwise the CLI falls back to a local credential file with locked permissions.
29
+
30
+ ## Commands
31
+
32
+ Authentication:
33
+
34
+ ```bash
35
+ scalepad auth login
36
+ scalepad auth whoami
37
+ scalepad auth logout
38
+ ```
39
+
40
+ Core API:
41
+
42
+ ```bash
43
+ scalepad core clients list
44
+ scalepad core clients get <id>
45
+ scalepad core integrations configurations list
46
+ scalepad core hardware-assets list
47
+ scalepad core tickets list
48
+ ```
49
+
50
+ Lifecycle Manager API:
51
+
52
+ ```bash
53
+ scalepad lm action-items list
54
+ scalepad lm action-items get <id>
55
+ ```
56
+
57
+ ## Common options
58
+
59
+ - `--profile <name>`: use a named credential profile
60
+ - `--api-key <key>`: override the stored credential for a single command
61
+ - `--json`, `--jsonl`, `--csv`, `--table`: choose the output format
62
+ - `--fields <a,b,c>`: project specific response fields
63
+ - `--limit <count>`, `--cursor <cursor>`, `--all`: pagination controls for list endpoints
64
+ - `--filter <field=expr>`: repeatable filter expression
65
+ - `--sort <field>` and `--desc`: sorting for list endpoints
66
+ - `--query <name=value>`: pass raw query parameters through to the API
67
+
68
+ ## Examples
69
+
70
+ ```bash
71
+ scalepad core clients list --limit 25 --table
72
+ scalepad core tickets list --filter status=open --sort created_at --desc --json
73
+ scalepad lm action-items get 12345 --fields id,title,status --table
74
+ ```
75
+
76
+ ## Development
77
+
78
+ From the monorepo root:
79
+
80
+ ```bash
81
+ pnpm install
82
+ pnpm fetch:specs
83
+ pnpm generate:sdk
84
+ pnpm build
85
+ node packages/cli/dist/index.js --help
86
+ ```
package/dist/index.js CHANGED
@@ -346,7 +346,7 @@ async function run() {
346
346
  program
347
347
  .name("scalepad")
348
348
  .description("ScalePad public CLI for Core API and Lifecycle Manager")
349
- .version("0.1.0");
349
+ .version("0.1.1");
350
350
  const auth = program.command("auth").description("manage API credentials");
351
351
  addSharedAuthOptions(auth.command("login")
352
352
  .description("authenticate with an API key and store it under a profile")
package/package.json CHANGED
@@ -1,17 +1,25 @@
1
1
  {
2
2
  "name": "@scalepad/cli",
3
- "version": "0.1.0",
3
+ "version": "0.1.2",
4
+ "description": "ScalePad command line interface for the Core API and Lifecycle Manager API.",
4
5
  "type": "module",
5
6
  "main": "dist/index.js",
6
7
  "types": "dist/index.d.ts",
7
8
  "bin": {
8
9
  "scalepad": "dist/index.js"
9
10
  },
11
+ "files": [
12
+ "dist",
13
+ "README.md"
14
+ ],
15
+ "publishConfig": {
16
+ "access": "public"
17
+ },
10
18
  "dependencies": {
11
19
  "@inquirer/prompts": "^7.8.4",
12
20
  "commander": "^14.0.1",
13
- "@scalepad/sdk-lm": "0.1.0",
14
- "@scalepad/sdk-core": "0.1.0"
21
+ "@scalepad/sdk-lm": "0.1.2",
22
+ "@scalepad/sdk-core": "0.1.2"
15
23
  },
16
24
  "optionalDependencies": {
17
25
  "keytar": "^7.9.0"
package/src/config.ts DELETED
@@ -1,66 +0,0 @@
1
- import { chmod, mkdir, readFile, writeFile } from "node:fs/promises";
2
- import { homedir } from "node:os";
3
- import { dirname, join } from "node:path";
4
-
5
- export interface ProfileMetadata {
6
- storage?: "keytar" | "file";
7
- }
8
-
9
- export interface CliConfig {
10
- currentProfile: string;
11
- profiles: Record<string, ProfileMetadata>;
12
- }
13
-
14
- const DEFAULT_CONFIG: CliConfig = {
15
- currentProfile: "default",
16
- profiles: {}
17
- };
18
-
19
- function configRoot(): string {
20
- const xdg = process.env.XDG_CONFIG_HOME;
21
- if (xdg) {
22
- return join(xdg, "scalepad-cli");
23
- }
24
-
25
- return join(homedir(), ".config", "scalepad-cli");
26
- }
27
-
28
- export function getConfigPath(): string {
29
- return join(configRoot(), "config.json");
30
- }
31
-
32
- export function getFallbackCredentialsPath(): string {
33
- return join(configRoot(), "credentials.json");
34
- }
35
-
36
- export async function ensureConfigDir(): Promise<void> {
37
- await mkdir(configRoot(), { recursive: true });
38
- }
39
-
40
- export async function loadConfig(): Promise<CliConfig> {
41
- try {
42
- const raw = await readFile(getConfigPath(), "utf8");
43
- const parsed = JSON.parse(raw) as Partial<CliConfig>;
44
- return {
45
- currentProfile: parsed.currentProfile ?? "default",
46
- profiles: parsed.profiles ?? {}
47
- };
48
- } catch {
49
- return structuredClone(DEFAULT_CONFIG);
50
- }
51
- }
52
-
53
- export async function saveConfig(config: CliConfig): Promise<void> {
54
- await ensureConfigDir();
55
- const path = getConfigPath();
56
- await writeFile(path, `${JSON.stringify(config, null, 2)}\n`, "utf8");
57
- await chmod(path, 0o600);
58
- }
59
-
60
- export async function writePrivateJson(path: string, value: unknown): Promise<void> {
61
- await ensureConfigDir();
62
- await mkdir(dirname(path), { recursive: true });
63
- await writeFile(path, `${JSON.stringify(value, null, 2)}\n`, "utf8");
64
- await chmod(path, 0o600);
65
- }
66
-
@@ -1,125 +0,0 @@
1
- import { chmod, readFile, rm } from "node:fs/promises";
2
- import { getFallbackCredentialsPath, loadConfig, saveConfig, writePrivateJson } from "./config.js";
3
-
4
- const SERVICE_NAME = "@scalepad/cli";
5
-
6
- interface KeytarModule {
7
- getPassword(service: string, account: string): Promise<string | null>;
8
- setPassword(service: string, account: string, password: string): Promise<void>;
9
- deletePassword(service: string, account: string): Promise<boolean>;
10
- }
11
-
12
- interface FallbackCredentialFile {
13
- profiles: Record<string, string>;
14
- }
15
-
16
- async function maybeLoadKeytar(): Promise<KeytarModule | null> {
17
- try {
18
- const module = await import("keytar");
19
- return module.default as KeytarModule;
20
- } catch {
21
- return null;
22
- }
23
- }
24
-
25
- function accountName(profile: string): string {
26
- return `profile:${profile}`;
27
- }
28
-
29
- async function readFallbackFile(): Promise<FallbackCredentialFile> {
30
- try {
31
- const raw = await readFile(getFallbackCredentialsPath(), "utf8");
32
- const parsed = JSON.parse(raw) as Partial<FallbackCredentialFile>;
33
- return { profiles: parsed.profiles ?? {} };
34
- } catch {
35
- return { profiles: {} };
36
- }
37
- }
38
-
39
- async function writeFallbackFile(value: FallbackCredentialFile): Promise<void> {
40
- await writePrivateJson(getFallbackCredentialsPath(), value);
41
- }
42
-
43
- export async function storeApiKey(profile: string, apiKey: string): Promise<"keytar" | "file"> {
44
- const config = await loadConfig();
45
- const keytar = await maybeLoadKeytar();
46
-
47
- if (keytar) {
48
- await keytar.setPassword(SERVICE_NAME, accountName(profile), apiKey);
49
- config.profiles[profile] = { storage: "keytar" };
50
- config.currentProfile = profile;
51
- await saveConfig(config);
52
- return "keytar";
53
- }
54
-
55
- const fallback = await readFallbackFile();
56
- fallback.profiles[profile] = apiKey;
57
- await writeFallbackFile(fallback);
58
- await chmod(getFallbackCredentialsPath(), 0o600);
59
-
60
- config.profiles[profile] = { storage: "file" };
61
- config.currentProfile = profile;
62
- await saveConfig(config);
63
- return "file";
64
- }
65
-
66
- export async function readStoredApiKey(profile: string): Promise<string | null> {
67
- const keytar = await maybeLoadKeytar();
68
- if (keytar) {
69
- const key = await keytar.getPassword(SERVICE_NAME, accountName(profile));
70
- if (key) {
71
- return key;
72
- }
73
- }
74
-
75
- const fallback = await readFallbackFile();
76
- return fallback.profiles[profile] ?? null;
77
- }
78
-
79
- export async function deleteStoredApiKey(profile: string): Promise<boolean> {
80
- const config = await loadConfig();
81
- let removed = false;
82
-
83
- const keytar = await maybeLoadKeytar();
84
- if (keytar) {
85
- removed = (await keytar.deletePassword(SERVICE_NAME, accountName(profile))) || removed;
86
- }
87
-
88
- const fallback = await readFallbackFile();
89
- if (profile in fallback.profiles) {
90
- delete fallback.profiles[profile];
91
- await writeFallbackFile(fallback);
92
- removed = true;
93
- }
94
-
95
- delete config.profiles[profile];
96
- if (config.currentProfile === profile) {
97
- config.currentProfile = "default";
98
- }
99
- await saveConfig(config);
100
-
101
- if (Object.keys(fallback.profiles).length === 0) {
102
- await rm(getFallbackCredentialsPath(), { force: true });
103
- }
104
-
105
- return removed;
106
- }
107
-
108
- export interface ResolveApiKeyOptions {
109
- explicitApiKey?: string;
110
- envApiKey?: string;
111
- profile: string;
112
- }
113
-
114
- export async function resolveApiKey(options: ResolveApiKeyOptions): Promise<string | null> {
115
- if (options.explicitApiKey) {
116
- return options.explicitApiKey;
117
- }
118
-
119
- if (options.envApiKey) {
120
- return options.envApiKey;
121
- }
122
-
123
- return readStoredApiKey(options.profile);
124
- }
125
-
package/src/filters.ts DELETED
@@ -1,73 +0,0 @@
1
- export function parseFilterArgs(values: string[]): Record<string, string> {
2
- const filters: Record<string, string> = {};
3
- for (const rawValue of values) {
4
- const separatorIndex = rawValue.indexOf("=");
5
- if (separatorIndex <= 0 || separatorIndex === rawValue.length - 1) {
6
- throw new Error(`Invalid filter '${rawValue}'. Expected field=expression.`);
7
- }
8
-
9
- const field = rawValue.slice(0, separatorIndex).trim();
10
- const expression = rawValue.slice(separatorIndex + 1).trim();
11
- if (!field || !expression) {
12
- throw new Error(`Invalid filter '${rawValue}'. Expected field=expression.`);
13
- }
14
-
15
- filters[field] = expression;
16
- }
17
-
18
- return filters;
19
- }
20
-
21
- export function buildSort(sortField: string | undefined, desc: boolean): string | undefined {
22
- if (!sortField) {
23
- return undefined;
24
- }
25
-
26
- return `${desc ? "-" : "+"}${sortField}`;
27
- }
28
-
29
- type JsonLike = Record<string, unknown>;
30
-
31
- function getNestedValue(record: JsonLike, path: string): unknown {
32
- const segments = path.split(".");
33
- let current: unknown = record;
34
- for (const segment of segments) {
35
- if (current == null || typeof current !== "object") {
36
- return undefined;
37
- }
38
- current = (current as JsonLike)[segment];
39
- }
40
-
41
- return current;
42
- }
43
-
44
- export function projectRecord(record: JsonLike, fields: string[] | undefined): JsonLike {
45
- if (!fields || fields.length === 0) {
46
- return record;
47
- }
48
-
49
- return Object.fromEntries(fields.map((field) => [field, getNestedValue(record, field)]));
50
- }
51
-
52
- export function flattenRecord(value: JsonLike, prefix = ""): Record<string, string> {
53
- const flattened: Record<string, string> = {};
54
-
55
- for (const [key, fieldValue] of Object.entries(value)) {
56
- const nextKey = prefix ? `${prefix}.${key}` : key;
57
-
58
- if (Array.isArray(fieldValue)) {
59
- flattened[nextKey] = JSON.stringify(fieldValue);
60
- continue;
61
- }
62
-
63
- if (fieldValue != null && typeof fieldValue === "object") {
64
- Object.assign(flattened, flattenRecord(fieldValue as JsonLike, nextKey));
65
- continue;
66
- }
67
-
68
- flattened[nextKey] = fieldValue == null ? "" : String(fieldValue);
69
- }
70
-
71
- return flattened;
72
- }
73
-
package/src/format.ts DELETED
@@ -1,210 +0,0 @@
1
- import { flattenRecord, projectRecord } from "./filters.js";
2
-
3
- export type JsonLike = Record<string, unknown>;
4
- export type OutputFormat = "table" | "json" | "jsonl" | "csv";
5
-
6
- export interface FormatFlags {
7
- json?: boolean;
8
- jsonl?: boolean;
9
- csv?: boolean;
10
- table?: boolean;
11
- }
12
-
13
- export interface ExtractedList {
14
- items: JsonLike[];
15
- nextCursor?: string | null;
16
- totalCount?: number | null;
17
- }
18
-
19
- export function resolveOutputFormat(flags: FormatFlags): OutputFormat {
20
- if (flags.json) {
21
- return "json";
22
- }
23
- if (flags.jsonl) {
24
- return "jsonl";
25
- }
26
- if (flags.csv) {
27
- return "csv";
28
- }
29
- return "table";
30
- }
31
-
32
- export function extractList(payload: unknown): ExtractedList {
33
- if (Array.isArray(payload)) {
34
- return { items: payload.filter(isJsonLike) };
35
- }
36
-
37
- if (!isJsonLike(payload)) {
38
- return { items: [] };
39
- }
40
-
41
- const dataCandidate = payload.data;
42
- const nextCursor = typeof payload.next_cursor === "string" ? payload.next_cursor : null;
43
- const totalCount = typeof payload.total_count === "number" ? payload.total_count : null;
44
-
45
- if (Array.isArray(dataCandidate)) {
46
- return {
47
- items: dataCandidate.filter(isJsonLike),
48
- nextCursor,
49
- totalCount
50
- };
51
- }
52
-
53
- return { items: [payload], nextCursor, totalCount };
54
- }
55
-
56
- function isJsonLike(value: unknown): value is JsonLike {
57
- return value != null && typeof value === "object" && !Array.isArray(value);
58
- }
59
-
60
- function formatScalar(value: unknown): string {
61
- if (value == null) {
62
- return "";
63
- }
64
- if (typeof value === "string") {
65
- return value;
66
- }
67
- if (typeof value === "number" || typeof value === "boolean") {
68
- return String(value);
69
- }
70
-
71
- return JSON.stringify(value);
72
- }
73
-
74
- function renderTable(records: JsonLike[]): string {
75
- if (records.length === 0) {
76
- return "No records found.";
77
- }
78
-
79
- const columnSet = new Set<string>();
80
- const rows = records.map((record) => flattenRecord(record));
81
- for (const row of rows) {
82
- for (const key of Object.keys(row)) {
83
- columnSet.add(key);
84
- }
85
- }
86
-
87
- const columns = [...columnSet];
88
- const widths = columns.map((column) => column.length);
89
-
90
- rows.forEach((row) => {
91
- columns.forEach((column, index) => {
92
- widths[index] = Math.max(widths[index] ?? 0, (row[column] ?? "").length);
93
- });
94
- });
95
-
96
- const header = columns.map((column, index) => column.padEnd(widths[index] ?? column.length)).join(" ");
97
- const separator = columns.map((column, index) => "-".repeat(widths[index] ?? column.length)).join(" ");
98
- const body = rows.map((row) => columns.map((column, index) => (row[column] ?? "").padEnd(widths[index] ?? column.length)).join(" "));
99
-
100
- return [header, separator, ...body].join("\n");
101
- }
102
-
103
- function renderCsv(records: JsonLike[]): string {
104
- if (records.length === 0) {
105
- return "";
106
- }
107
-
108
- const flattened = records.map((record) => flattenRecord(record));
109
- const columns = [...new Set(flattened.flatMap((record) => Object.keys(record)))];
110
- const escapeValue = (value: string): string => {
111
- if (value.includes(",") || value.includes("\"") || value.includes("\n")) {
112
- return `"${value.replaceAll("\"", "\"\"")}"`;
113
- }
114
- return value;
115
- };
116
-
117
- const lines = [
118
- columns.join(","),
119
- ...flattened.map((record) => columns.map((column) => escapeValue(record[column] ?? "")).join(","))
120
- ];
121
-
122
- return lines.join("\n");
123
- }
124
-
125
- export function printList(
126
- payload: unknown,
127
- format: OutputFormat,
128
- fields?: string[],
129
- metadataWriter: (message: string) => void = console.error
130
- ): void {
131
- if (format === "json") {
132
- console.log(JSON.stringify(payload, null, 2));
133
- return;
134
- }
135
-
136
- const extracted = extractList(payload);
137
- const projected = extracted.items.map((item) => projectRecord(item, fields));
138
-
139
- switch (format) {
140
- case "jsonl":
141
- projected.forEach((item) => {
142
- console.log(JSON.stringify(item));
143
- });
144
- break;
145
- case "csv":
146
- console.log(renderCsv(projected));
147
- break;
148
- case "table":
149
- console.log(renderTable(projected));
150
- break;
151
- default:
152
- break;
153
- }
154
-
155
- const parts: string[] = [];
156
- parts.push(`items=${projected.length}`);
157
- if (extracted.totalCount != null) {
158
- parts.push(`total_count=${extracted.totalCount}`);
159
- }
160
- if (extracted.nextCursor) {
161
- parts.push(`next_cursor=${extracted.nextCursor}`);
162
- }
163
- metadataWriter(parts.join(" "));
164
- }
165
-
166
- export function printObject(payload: unknown, format: OutputFormat, fields?: string[]): void {
167
- if (!isJsonLike(payload)) {
168
- console.log(JSON.stringify(payload, null, 2));
169
- return;
170
- }
171
-
172
- const projected = projectRecord(payload, fields);
173
-
174
- if (format === "json" || format === "jsonl") {
175
- console.log(JSON.stringify(projected, null, format === "json" ? 2 : 0));
176
- return;
177
- }
178
-
179
- if (format === "csv") {
180
- console.log(renderCsv([projected]));
181
- return;
182
- }
183
-
184
- console.log(renderTable([projected]));
185
- }
186
-
187
- export function parseFields(value: string | undefined): string[] | undefined {
188
- if (!value) {
189
- return undefined;
190
- }
191
-
192
- return value.split(",").map((field) => field.trim()).filter(Boolean);
193
- }
194
-
195
- export function formatApiError(error: unknown): string {
196
- if (error instanceof Error) {
197
- return error.message;
198
- }
199
-
200
- return String(error);
201
- }
202
-
203
- export function toPlainMessage(value: unknown): string {
204
- if (typeof value === "string") {
205
- return value;
206
- }
207
-
208
- return formatScalar(value);
209
- }
210
-
package/src/index.ts DELETED
@@ -1,603 +0,0 @@
1
- #!/usr/bin/env node
2
-
3
- import { readFile } from "node:fs/promises";
4
- import { confirm, password } from "@inquirer/prompts";
5
- import {
6
- coreOperations,
7
- createCoreClient,
8
- GeneratedCoreClient,
9
- ScalepadApiError,
10
- type GeneratedCoreOperation
11
- } from "@scalepad/sdk-core";
12
- import {
13
- createLifecycleManagerClient,
14
- GeneratedLifecycleManagerClient,
15
- lifecycleManagerOperations,
16
- ScalepadLmApiError,
17
- type GeneratedLifecycleManagerOperation
18
- } from "@scalepad/sdk-lm";
19
- import { Command } from "commander";
20
- import { loadConfig } from "./config.js";
21
- import { deleteStoredApiKey, readStoredApiKey, resolveApiKey, storeApiKey } from "./credentials.js";
22
- import { buildSort, parseFilterArgs } from "./filters.js";
23
- import { formatApiError, parseFields, printList, printObject, resolveOutputFormat } from "./format.js";
24
-
25
- type GeneratedOperation = GeneratedCoreOperation | GeneratedLifecycleManagerOperation;
26
- type OperationFamily = "core" | "lm";
27
-
28
- interface SharedOptions {
29
- profile?: string;
30
- apiKey?: string;
31
- }
32
-
33
- interface OperationOptions extends SharedOptions {
34
- json?: boolean;
35
- jsonl?: boolean;
36
- csv?: boolean;
37
- table?: boolean;
38
- all?: boolean;
39
- limit?: string;
40
- cursor?: string;
41
- filter?: string[];
42
- sort?: string;
43
- desc?: boolean;
44
- fields?: string;
45
- query?: string[];
46
- body?: string;
47
- bodyFile?: string;
48
- yes?: boolean;
49
- }
50
-
51
- function collect(value: string, previous: string[]): string[] {
52
- previous.push(value);
53
- return previous;
54
- }
55
-
56
- function humanizeSegment(segment: string): string {
57
- return segment.replaceAll("-", " ");
58
- }
59
-
60
- function listSubcommands(command: Command, prefix = ""): string[] {
61
- const lines: string[] = [];
62
-
63
- for (const subcommand of command.commands) {
64
- if (subcommand.name() === "help") {
65
- continue;
66
- }
67
-
68
- const fullName = [prefix, subcommand.name()].filter(Boolean).join(" ");
69
- const description = subcommand.description();
70
- const paddedName = fullName.padEnd(40, " ");
71
- lines.push(` ${paddedName} ${description}`);
72
- lines.push(...listSubcommands(subcommand, fullName));
73
- }
74
-
75
- return lines;
76
- }
77
-
78
- function addExpandedHelp(command: Command, prefix = command.name()): void {
79
- const lines = listSubcommands(command, prefix);
80
- if (lines.length === 0) {
81
- return;
82
- }
83
-
84
- command.addHelpText("after", `\nAvailable Commands:\n${lines.join("\n")}`);
85
- }
86
-
87
- async function resolveProfile(profile?: string): Promise<string> {
88
- if (profile) {
89
- return profile;
90
- }
91
-
92
- const config = await loadConfig();
93
- return config.currentProfile || "default";
94
- }
95
-
96
- async function getApiKeyForCommand(options: SharedOptions): Promise<{ apiKey: string; profile: string }> {
97
- const profile = await resolveProfile(options.profile);
98
- const apiKey = await resolveApiKey({
99
- explicitApiKey: options.apiKey,
100
- envApiKey: process.env.SCALEPAD_API_KEY,
101
- profile
102
- });
103
-
104
- if (!apiKey) {
105
- throw new Error(`No API key configured for profile '${profile}'. Run 'scalepad auth login'.`);
106
- }
107
-
108
- return { apiKey, profile };
109
- }
110
-
111
- async function probeCoreAccess(coreClient: ReturnType<typeof createCoreClient>): Promise<boolean> {
112
- try {
113
- await coreClient.listClients({ pageSize: 1 });
114
- return true;
115
- } catch {
116
- return false;
117
- }
118
- }
119
-
120
- async function probeLifecycleManagerAccess(apiKey: string): Promise<boolean> {
121
- const response = await fetch("https://api.scalepad.com/lifecycle-manager/v1/meeting-types", {
122
- headers: {
123
- accept: "application/json",
124
- "x-api-key": apiKey,
125
- "user-agent": "@scalepad/cli"
126
- }
127
- });
128
-
129
- return response.ok;
130
- }
131
-
132
- function addSharedAuthOptions(command: Command): Command {
133
- return command
134
- .option("--profile <name>", "credential profile name")
135
- .option("--api-key <key>", "override API key for this command");
136
- }
137
-
138
- function addOutputOptions(command: Command): Command {
139
- return command
140
- .option("--json", "print JSON")
141
- .option("--jsonl", "print newline-delimited JSON")
142
- .option("--csv", "print CSV")
143
- .option("--table", "print a table")
144
- .option("--fields <fields>", "comma-separated output fields");
145
- }
146
-
147
- function addOperationOptions(command: Command, operation: GeneratedOperation): Command {
148
- const configured = addOutputOptions(addSharedAuthOptions(command))
149
- .option("--query <name=value>", "raw query parameter", collect, []);
150
-
151
- if (operation.queryParams.length > 0) {
152
- configured
153
- .option("--all", "walk cursor pagination until exhaustion")
154
- .option("--limit <count>", "page size for list requests")
155
- .option("--cursor <cursor>", "starting cursor")
156
- .option("--filter <field=expr>", "filter expression", collect, [])
157
- .option("--sort <field>", "sortable field")
158
- .option("--desc", "sort descending");
159
- }
160
-
161
- if (operation.hasBody) {
162
- configured
163
- .option("--body <json-or-@file>", "inline JSON body or @path/to/file.json")
164
- .option("--body-file <path>", "path to a JSON body file");
165
- }
166
-
167
- if (operation.method !== "get") {
168
- configured.option("--yes", "skip interactive confirmation");
169
- }
170
-
171
- return configured;
172
- }
173
-
174
- function hasAdvancedQueryFlags(options: OperationOptions): boolean {
175
- return Boolean(
176
- options.limit
177
- || options.cursor
178
- || (options.filter?.length ?? 0) > 0
179
- || options.sort
180
- || options.desc
181
- );
182
- }
183
-
184
- function parseKeyValueArgs(values: string[]): Record<string, string> {
185
- const entries: Record<string, string> = {};
186
-
187
- for (const value of values) {
188
- const separatorIndex = value.indexOf("=");
189
- if (separatorIndex <= 0 || separatorIndex === value.length - 1) {
190
- throw new Error(`Invalid key/value argument '${value}'. Expected name=value.`);
191
- }
192
-
193
- const key = value.slice(0, separatorIndex).trim();
194
- const entryValue = value.slice(separatorIndex + 1).trim();
195
-
196
- if (!key || !entryValue) {
197
- throw new Error(`Invalid key/value argument '${value}'. Expected name=value.`);
198
- }
199
-
200
- entries[key] = entryValue;
201
- }
202
-
203
- return entries;
204
- }
205
-
206
- function buildQueryFromOptions(operation: GeneratedOperation, options: OperationOptions): Record<string, string> | undefined {
207
- const rawQuery = parseKeyValueArgs(options.query ?? []);
208
- const hasFriendlyQueryFlags = hasAdvancedQueryFlags(options);
209
-
210
- if (operation.queryParams.length === 0) {
211
- if (Object.keys(rawQuery).length > 0 || hasFriendlyQueryFlags) {
212
- throw new Error(`Command '${[...operation.commandPath, operation.action].join(" ")}' does not accept query parameters.`);
213
- }
214
-
215
- return undefined;
216
- }
217
-
218
- const query = { ...rawQuery };
219
-
220
- if (options.limit) {
221
- const pageSize = Number(options.limit);
222
- if (!Number.isInteger(pageSize) || pageSize <= 0) {
223
- throw new Error(`Invalid --limit value '${options.limit}'. Expected a positive integer.`);
224
- }
225
- query.page_size = String(pageSize);
226
- }
227
-
228
- if (options.cursor) {
229
- query.cursor = options.cursor;
230
- }
231
-
232
- const sort = buildSort(options.sort, Boolean(options.desc));
233
- if (sort) {
234
- query.sort = sort;
235
- }
236
-
237
- for (const [field, expression] of Object.entries(parseFilterArgs(options.filter ?? []))) {
238
- query[`filter[${field}]`] = expression;
239
- }
240
-
241
- return Object.keys(query).length === 0 ? undefined : query;
242
- }
243
-
244
- async function loadBodyFromOptions(operation: GeneratedOperation, options: OperationOptions): Promise<unknown> {
245
- if (!operation.hasBody) {
246
- if (options.body || options.bodyFile) {
247
- throw new Error(`Command '${[...operation.commandPath, operation.action].join(" ")}' does not accept a request body.`);
248
- }
249
-
250
- return undefined;
251
- }
252
-
253
- if (options.body && options.bodyFile) {
254
- throw new Error("Use either --body or --body-file, not both.");
255
- }
256
-
257
- let rawInput: string | undefined;
258
-
259
- if (options.bodyFile) {
260
- rawInput = await readFile(options.bodyFile, "utf8");
261
- } else if (options.body) {
262
- if (options.body.startsWith("@")) {
263
- rawInput = await readFile(options.body.slice(1), "utf8");
264
- } else {
265
- rawInput = options.body;
266
- }
267
- }
268
-
269
- if (rawInput == null) {
270
- throw new Error(`Command '${[...operation.commandPath, operation.action].join(" ")}' requires --body or --body-file.`);
271
- }
272
-
273
- try {
274
- return JSON.parse(rawInput);
275
- } catch (error) {
276
- throw new Error(`Invalid JSON body: ${formatApiError(error)}`);
277
- }
278
- }
279
-
280
- async function confirmMutation(operation: GeneratedOperation, options: OperationOptions): Promise<boolean> {
281
- if (operation.method === "get" || options.yes) {
282
- return true;
283
- }
284
-
285
- return confirm({
286
- message: `${operation.method.toUpperCase()} ${operation.path}?`,
287
- default: false
288
- });
289
- }
290
-
291
- function buildPathParams(operation: GeneratedOperation, pathValues: string[]): Record<string, string> {
292
- return Object.fromEntries(operation.pathParams.map((name, index) => [name, pathValues[index] ?? ""]));
293
- }
294
-
295
- function createGeneratedClient(family: OperationFamily, apiKey: string): GeneratedCoreClient | GeneratedLifecycleManagerClient {
296
- return family === "core"
297
- ? new GeneratedCoreClient({ apiKey })
298
- : new GeneratedLifecycleManagerClient({ apiKey });
299
- }
300
-
301
- async function invokeOperation(
302
- family: OperationFamily,
303
- operation: GeneratedOperation,
304
- options: OperationOptions,
305
- pathValues: string[],
306
- queryOverride?: Record<string, string>
307
- ): Promise<unknown> {
308
- const { apiKey } = await getApiKeyForCommand(options);
309
- const client = createGeneratedClient(family, apiKey) as unknown as Record<string, (...args: unknown[]) => Promise<unknown>>;
310
- const method = client[operation.methodName];
311
-
312
- if (typeof method !== "function") {
313
- throw new Error(`Generated client is missing method '${operation.methodName}'. Re-run 'pnpm generate:sdk'.`);
314
- }
315
-
316
- const query = queryOverride ?? buildQueryFromOptions(operation, options);
317
- const body = await loadBodyFromOptions(operation, options);
318
- const args: unknown[] = [];
319
-
320
- if (operation.pathParams.length > 0) {
321
- args.push(buildPathParams(operation, pathValues));
322
- }
323
-
324
- if (operation.queryParams.length > 0) {
325
- args.push(query);
326
- }
327
-
328
- if (operation.hasBody) {
329
- args.push(body);
330
- }
331
-
332
- return method.apply(client, args);
333
- }
334
-
335
- async function executeOperation(
336
- family: OperationFamily,
337
- operation: GeneratedOperation,
338
- options: OperationOptions,
339
- pathValues: string[]
340
- ): Promise<void> {
341
- const format = resolveOutputFormat(options);
342
- const fields = parseFields(options.fields);
343
-
344
- if (!(await confirmMutation(operation, options))) {
345
- console.log("Aborted.");
346
- return;
347
- }
348
-
349
- if (operation.output === "list") {
350
- const initialQuery = buildQueryFromOptions(operation, options) ?? {};
351
- let nextCursor = initialQuery.cursor;
352
-
353
- while (true) {
354
- const query = nextCursor ? { ...initialQuery, cursor: nextCursor } : initialQuery;
355
- const payload = await invokeOperation(family, operation, options, pathValues, query);
356
- printList(payload, format, fields);
357
-
358
- if (!options.all) {
359
- return;
360
- }
361
-
362
- const cursor = (payload as { next_cursor?: string | null })?.next_cursor;
363
- if (typeof cursor !== "string" || cursor.length === 0) {
364
- return;
365
- }
366
-
367
- nextCursor = cursor;
368
- }
369
- }
370
-
371
- const payload = await invokeOperation(family, operation, options, pathValues);
372
- printObject(payload, format, fields);
373
- }
374
-
375
- function getOperationSortKey(operation: GeneratedOperation): string {
376
- const actionOrder = ["list", "get", "create", "update", "delete"];
377
- return `${operation.commandPath.join("/")}:${actionOrder.indexOf(operation.action).toString().padStart(2, "0")}:${operation.pathParams.length}`;
378
- }
379
-
380
- function extractApiVersion(path: string): number {
381
- const match = path.match(/\/v(\d+)\//);
382
- return match ? Number(match[1]) : 0;
383
- }
384
-
385
- function stripLeadingVersion(commandPath: readonly string[]): string[] {
386
- if (commandPath.length > 0 && /^v\d+$/i.test(commandPath[0] ?? "")) {
387
- return commandPath.slice(1);
388
- }
389
-
390
- return [...commandPath];
391
- }
392
-
393
- function operationIdentity(operation: GeneratedOperation): string {
394
- return [
395
- operation.commandPath.join("/"),
396
- operation.action,
397
- operation.pathParams.join(",")
398
- ].join("|");
399
- }
400
-
401
- function buildLatestLifecycleManagerOperations(): GeneratedOperation[] {
402
- const preferred = new Map<string, GeneratedOperation>();
403
-
404
- for (const operation of lifecycleManagerOperations) {
405
- const normalized = {
406
- ...operation,
407
- commandPath: stripLeadingVersion(operation.commandPath)
408
- };
409
- const key = operationIdentity(normalized);
410
- const existing = preferred.get(key);
411
-
412
- if (!existing || extractApiVersion(normalized.path) > extractApiVersion(existing.path)) {
413
- preferred.set(key, normalized);
414
- }
415
- }
416
-
417
- return [...preferred.values()];
418
- }
419
-
420
- function registerOperations(
421
- root: Command,
422
- family: OperationFamily,
423
- operations: readonly GeneratedOperation[]
424
- ): void {
425
- const commandCache = new Map<string, Command>([["", root]]);
426
-
427
- for (const operation of [...operations].sort((left, right) => getOperationSortKey(left).localeCompare(getOperationSortKey(right)))) {
428
- let current = root;
429
- const pathSoFar: string[] = [];
430
-
431
- for (const segment of operation.commandPath) {
432
- pathSoFar.push(segment);
433
- const key = pathSoFar.join("/");
434
- const existing = commandCache.get(key);
435
-
436
- if (existing) {
437
- current = existing;
438
- continue;
439
- }
440
-
441
- const group = current.command(segment).description(`${humanizeSegment(segment)} commands`);
442
- commandCache.set(key, group);
443
- current = group;
444
- }
445
-
446
- const leaf = current.command(operation.action).description(operation.summary);
447
- for (const pathParam of operation.pathParams) {
448
- leaf.argument(`<${pathParam}>`, `${pathParam} path parameter`);
449
- }
450
-
451
- addOperationOptions(leaf, operation).action(async (...actionArgs: unknown[]) => {
452
- const options = actionArgs[actionArgs.length - 1] as OperationOptions;
453
- const pathValues = actionArgs.slice(0, -1).map((value) => String(value));
454
- await executeOperation(family, operation, options, pathValues);
455
- });
456
- }
457
- }
458
-
459
- function findOperation(
460
- operations: readonly GeneratedOperation[],
461
- path: string,
462
- method: GeneratedOperation["method"]
463
- ): GeneratedOperation {
464
- const operation = operations.find((entry) => entry.path === path && entry.method === method);
465
- if (!operation) {
466
- throw new Error(`Missing generated operation for ${method.toUpperCase()} ${path}.`);
467
- }
468
-
469
- return operation;
470
- }
471
-
472
- function buildCoreAliasOperations(): GeneratedOperation[] {
473
- return [
474
- { ...findOperation(coreOperations, "/core/v1/assets/hardware", "get"), commandPath: ["hardware-assets"] },
475
- { ...findOperation(coreOperations, "/core/v1/assets/hardware/{id}", "get"), commandPath: ["hardware-assets"] },
476
- { ...findOperation(coreOperations, "/core/v1/service/tickets", "get"), commandPath: ["tickets"] },
477
- { ...findOperation(coreOperations, "/core/v1/service/tickets/{id}", "get"), commandPath: ["tickets"] },
478
- { ...findOperation(coreOperations, "/core/v1/service/contracts", "get"), commandPath: ["contracts"] },
479
- { ...findOperation(coreOperations, "/core/v1/service/contracts/{id}", "get"), commandPath: ["contracts"] },
480
- { ...findOperation(coreOperations, "/core/v1/assets/saas", "get"), commandPath: ["saas-assets"] },
481
- { ...findOperation(coreOperations, "/core/v1/assets/saas/{id}", "get"), commandPath: ["saas-assets"] }
482
- ];
483
- }
484
-
485
- async function run(): Promise<void> {
486
- const program = new Command();
487
-
488
- program
489
- .name("scalepad")
490
- .description("ScalePad public CLI for Core API and Lifecycle Manager")
491
- .version("0.1.0");
492
-
493
- const auth = program.command("auth").description("manage API credentials");
494
-
495
- addSharedAuthOptions(
496
- auth.command("login")
497
- .description("authenticate with an API key and store it under a profile")
498
- .action(async (options: SharedOptions) => {
499
- const profile = await resolveProfile(options.profile);
500
- const suppliedApiKey = options.apiKey ?? await password({
501
- message: "Enter your ScalePad API key",
502
- mask: "*"
503
- });
504
-
505
- const coreClient = createCoreClient({ apiKey: suppliedApiKey });
506
- const coreAccess = await probeCoreAccess(coreClient);
507
- const lmAccess = await probeLifecycleManagerAccess(suppliedApiKey);
508
-
509
- if (!coreAccess && !lmAccess) {
510
- throw new Error("Unable to validate the API key against Core API or Lifecycle Manager.");
511
- }
512
-
513
- const storage = await storeApiKey(profile, suppliedApiKey);
514
- console.log(`Authenticated profile '${profile}'.`);
515
- console.log(`Stored credential using ${storage}.`);
516
- console.log(`Core access: ${coreAccess ? "yes" : "no"}`);
517
- console.log(`Lifecycle Manager access: ${lmAccess ? "yes" : "no"}`);
518
- })
519
- );
520
-
521
- addSharedAuthOptions(
522
- auth.command("whoami")
523
- .description("show the current profile and accessible API families")
524
- .action(async (options: SharedOptions) => {
525
- const profile = await resolveProfile(options.profile);
526
- const apiKey = await resolveApiKey({
527
- explicitApiKey: options.apiKey,
528
- envApiKey: process.env.SCALEPAD_API_KEY,
529
- profile
530
- });
531
-
532
- if (!apiKey) {
533
- throw new Error(`No API key configured for profile '${profile}'.`);
534
- }
535
-
536
- const stored = !options.apiKey && !process.env.SCALEPAD_API_KEY ? await readStoredApiKey(profile) : null;
537
- const coreClient = createCoreClient({ apiKey });
538
- const result = {
539
- profile,
540
- credential_source: options.apiKey ? "flag" : process.env.SCALEPAD_API_KEY ? "env" : stored ? "stored" : "unknown",
541
- core_access: false,
542
- lifecycle_manager_access: false
543
- };
544
-
545
- result.core_access = await probeCoreAccess(coreClient);
546
- result.lifecycle_manager_access = await probeLifecycleManagerAccess(apiKey);
547
-
548
- console.log(JSON.stringify(result, null, 2));
549
- })
550
- );
551
-
552
- addSharedAuthOptions(
553
- auth.command("logout")
554
- .description("remove the stored credential for a profile")
555
- .action(async (options: SharedOptions) => {
556
- const profile = await resolveProfile(options.profile);
557
- const confirmed = await confirm({
558
- message: `Remove the stored API key for profile '${profile}'?`,
559
- default: true
560
- });
561
-
562
- if (!confirmed) {
563
- console.log("Aborted.");
564
- return;
565
- }
566
-
567
- const removed = await deleteStoredApiKey(profile);
568
- if (!removed) {
569
- console.log(`No stored credential found for profile '${profile}'.`);
570
- return;
571
- }
572
-
573
- console.log(`Removed stored credential for profile '${profile}'.`);
574
- })
575
- );
576
-
577
- const core = program.command("core").description("ScalePad Core API commands");
578
- const lm = program.command("lm").description("Lifecycle Manager API commands");
579
-
580
- registerOperations(core, "core", [...coreOperations, ...buildCoreAliasOperations()]);
581
- registerOperations(lm, "lm", buildLatestLifecycleManagerOperations());
582
-
583
- addExpandedHelp(program, "");
584
- addExpandedHelp(auth);
585
- addExpandedHelp(core);
586
- addExpandedHelp(lm);
587
-
588
- try {
589
- await program.parseAsync(process.argv);
590
- } catch (error) {
591
- if (error instanceof ScalepadApiError || error instanceof ScalepadLmApiError) {
592
- console.error(formatApiError(error));
593
- console.error(JSON.stringify(error.payload, null, 2));
594
- process.exitCode = 1;
595
- return;
596
- }
597
-
598
- console.error(formatApiError(error));
599
- process.exitCode = 1;
600
- }
601
- }
602
-
603
- void run();
package/test/cli.test.ts DELETED
@@ -1,118 +0,0 @@
1
- import { describe, expect, it, vi } from "vitest";
2
- import { createCoreClient, ScalepadApiError } from "@scalepad/sdk-core";
3
- import { buildSort, flattenRecord, parseFilterArgs, projectRecord } from "../src/filters.js";
4
- import { extractList, parseFields, resolveOutputFormat } from "../src/format.js";
5
-
6
- describe("filter helpers", () => {
7
- it("parses filter arguments into API query expressions", () => {
8
- expect(parseFilterArgs([
9
- "name=cont:acme",
10
- "client_id=eq:2220324"
11
- ])).toEqual({
12
- name: "cont:acme",
13
- client_id: "eq:2220324"
14
- });
15
- });
16
-
17
- it("builds sort expressions", () => {
18
- expect(buildSort("name", false)).toBe("+name");
19
- expect(buildSort("name", true)).toBe("-name");
20
- });
21
-
22
- it("projects and flattens records", () => {
23
- const record = {
24
- id: "123",
25
- name: "Acme",
26
- nested: {
27
- count: 4
28
- }
29
- };
30
-
31
- expect(projectRecord(record, ["id", "nested.count"])).toEqual({
32
- id: "123",
33
- "nested.count": 4
34
- });
35
-
36
- expect(flattenRecord(record)).toEqual({
37
- id: "123",
38
- name: "Acme",
39
- "nested.count": "4"
40
- });
41
- });
42
- });
43
-
44
- describe("format helpers", () => {
45
- it("resolves output flags", () => {
46
- expect(resolveOutputFormat({ json: true })).toBe("json");
47
- expect(resolveOutputFormat({ jsonl: true })).toBe("jsonl");
48
- expect(resolveOutputFormat({ csv: true })).toBe("csv");
49
- expect(resolveOutputFormat({})).toBe("table");
50
- });
51
-
52
- it("extracts paginated list payloads", () => {
53
- expect(extractList({
54
- data: [{ id: "1" }],
55
- total_count: 10,
56
- next_cursor: "cursor-2"
57
- })).toEqual({
58
- items: [{ id: "1" }],
59
- totalCount: 10,
60
- nextCursor: "cursor-2"
61
- });
62
- });
63
-
64
- it("parses field selections", () => {
65
- expect(parseFields("id,name,nested.count")).toEqual(["id", "name", "nested.count"]);
66
- });
67
- });
68
-
69
- describe("core SDK retry behavior", () => {
70
- it("retries 429 responses before succeeding", async () => {
71
- const fetchImpl = vi.fn()
72
- .mockResolvedValueOnce(new Response(JSON.stringify({ error: "slow down" }), {
73
- status: 429,
74
- headers: {
75
- "content-type": "application/json",
76
- "retry-after": "0"
77
- }
78
- }))
79
- .mockResolvedValueOnce(new Response(JSON.stringify({
80
- data: [{ id: "1" }],
81
- total_count: 1,
82
- next_cursor: null
83
- }), {
84
- status: 200,
85
- headers: {
86
- "content-type": "application/json"
87
- }
88
- }));
89
-
90
- const client = createCoreClient({
91
- apiKey: "secret",
92
- fetchImpl: fetchImpl as typeof fetch,
93
- maxRetries: 1
94
- });
95
-
96
- await expect(client.listClients({ pageSize: 1 })).resolves.toMatchObject({
97
- data: [{ id: "1" }],
98
- total_count: 1
99
- });
100
- expect(fetchImpl).toHaveBeenCalledTimes(2);
101
- });
102
-
103
- it("throws a typed error on non-retriable failures", async () => {
104
- const fetchImpl = vi.fn().mockResolvedValue(new Response(JSON.stringify({ error: "nope" }), {
105
- status: 401,
106
- headers: {
107
- "content-type": "application/json"
108
- }
109
- }));
110
-
111
- const client = createCoreClient({
112
- apiKey: "secret",
113
- fetchImpl: fetchImpl as typeof fetch
114
- });
115
-
116
- await expect(client.listClients()).rejects.toBeInstanceOf(ScalepadApiError);
117
- });
118
- });
package/tsconfig.json DELETED
@@ -1,8 +0,0 @@
1
- {
2
- "extends": "../../tsconfig.base.json",
3
- "compilerOptions": {
4
- "rootDir": "src",
5
- "outDir": "dist"
6
- },
7
- "include": ["src/**/*.ts"]
8
- }