@scalepad/cli 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/config.d.ts +13 -0
- package/dist/config.js +49 -0
- package/dist/config.js.map +1 -0
- package/dist/credentials.d.ts +9 -0
- package/dist/credentials.js +91 -0
- package/dist/credentials.js.map +1 -0
- package/dist/filters.d.ts +6 -0
- package/dist/filters.js +56 -0
- package/dist/filters.js.map +1 -0
- package/dist/format.d.ts +20 -0
- package/dist/format.js +155 -0
- package/dist/format.js.map +1 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +437 -0
- package/dist/index.js.map +1 -0
- package/package.json +24 -0
- package/src/config.ts +66 -0
- package/src/credentials.ts +125 -0
- package/src/filters.ts +73 -0
- package/src/format.ts +210 -0
- package/src/index.ts +603 -0
- package/test/cli.test.ts +118 -0
- package/tsconfig.json +8 -0
|
@@ -0,0 +1,125 @@
|
|
|
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
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
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
ADDED
|
@@ -0,0 +1,210 @@
|
|
|
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
|
+
|