@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 +86 -0
- package/dist/index.js +1 -1
- package/package.json +11 -3
- package/src/config.ts +0 -66
- package/src/credentials.ts +0 -125
- package/src/filters.ts +0 -73
- package/src/format.ts +0 -210
- package/src/index.ts +0 -603
- package/test/cli.test.ts +0 -118
- package/tsconfig.json +0 -8
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.
|
|
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.
|
|
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.
|
|
14
|
-
"@scalepad/sdk-core": "0.1.
|
|
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
|
-
|
package/src/credentials.ts
DELETED
|
@@ -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
|
-
});
|