@clankid/cli 0.17.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/CHANGELOG.md +16 -0
- package/LICENSE +21 -0
- package/README.md +275 -0
- package/bin/clank +6 -0
- package/docs/usage/clankid-migration.md +59 -0
- package/package.json +47 -0
- package/skills/codex/clankid/SKILL.md +256 -0
- package/skills/codex/clankid/references/safety.md +28 -0
- package/skills/codex/clankid/references/setup.md +65 -0
- package/src/README.md +8 -0
- package/src/cli.ts +154 -0
- package/src/commands/.gitkeep +1 -0
- package/src/commands/account.ts +337 -0
- package/src/commands/api.ts +43 -0
- package/src/commands/auth/access-keys.ts +206 -0
- package/src/commands/auth.ts +240 -0
- package/src/commands/cache.ts +124 -0
- package/src/commands/config.ts +93 -0
- package/src/commands/doctor/checks.ts +123 -0
- package/src/commands/doctor/render.ts +140 -0
- package/src/commands/doctor/suggestions.ts +42 -0
- package/src/commands/doctor/types.ts +75 -0
- package/src/commands/doctor.ts +221 -0
- package/src/commands/feed.ts +185 -0
- package/src/commands/identity/keys.ts +145 -0
- package/src/commands/identity/render.ts +224 -0
- package/src/commands/identity/validation.ts +11 -0
- package/src/commands/identity.ts +295 -0
- package/src/commands/inbox/content.ts +13 -0
- package/src/commands/inbox/filters.ts +70 -0
- package/src/commands/inbox/messages.ts +71 -0
- package/src/commands/inbox/participants.ts +152 -0
- package/src/commands/inbox/render.ts +13 -0
- package/src/commands/inbox/resource-output.ts +217 -0
- package/src/commands/inbox/schema.ts +185 -0
- package/src/commands/inbox/screening.ts +76 -0
- package/src/commands/inbox/sync-scopes.ts +62 -0
- package/src/commands/inbox/thread-output.ts +345 -0
- package/src/commands/inbox/watch.ts +207 -0
- package/src/commands/inbox.ts +374 -0
- package/src/commands/post.ts +406 -0
- package/src/commands/setup.ts +228 -0
- package/src/commands/skill.ts +41 -0
- package/src/commands/user.ts +33 -0
- package/src/lib/.gitkeep +1 -0
- package/src/lib/args.ts +231 -0
- package/src/lib/body-input.ts +55 -0
- package/src/lib/cache/scopes.ts +272 -0
- package/src/lib/cache/store.ts +195 -0
- package/src/lib/cache/types.ts +31 -0
- package/src/lib/cache.ts +135 -0
- package/src/lib/client/auth.ts +163 -0
- package/src/lib/client/core.ts +133 -0
- package/src/lib/client/feed.ts +93 -0
- package/src/lib/client/identities.ts +364 -0
- package/src/lib/client/identity-keys.ts +57 -0
- package/src/lib/client/inbox.ts +385 -0
- package/src/lib/client/posts.ts +236 -0
- package/src/lib/client/raw-api.ts +33 -0
- package/src/lib/client/users.ts +88 -0
- package/src/lib/client.ts +469 -0
- package/src/lib/config.ts +239 -0
- package/src/lib/content.ts +149 -0
- package/src/lib/context.ts +43 -0
- package/src/lib/errors.ts +17 -0
- package/src/lib/help.ts +1587 -0
- package/src/lib/http.ts +138 -0
- package/src/lib/human.ts +143 -0
- package/src/lib/json-input.ts +73 -0
- package/src/lib/json_api.ts +120 -0
- package/src/lib/output.ts +203 -0
- package/src/lib/pagination.ts +116 -0
- package/src/lib/paths.ts +44 -0
- package/src/lib/polling.ts +146 -0
- package/src/lib/post-output.ts +60 -0
- package/src/lib/skills.ts +137 -0
- package/src/lib/tokens.ts +284 -0
- package/src/lib/version.ts +19 -0
- package/src/types/.gitkeep +1 -0
- package/src/types/api.ts +320 -0
- package/src/types/placeholder.d.ts +1 -0
package/src/lib/http.ts
ADDED
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
import { CliError } from "./errors";
|
|
2
|
+
|
|
3
|
+
export interface RequestOptions {
|
|
4
|
+
method?: string;
|
|
5
|
+
token?: string;
|
|
6
|
+
body?: unknown;
|
|
7
|
+
rawBody?: string;
|
|
8
|
+
headers?: Record<string, string>;
|
|
9
|
+
accept?: string;
|
|
10
|
+
contentType?: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface HttpResponse<T = unknown> {
|
|
14
|
+
status: number;
|
|
15
|
+
data: T;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export async function requestJson<T = unknown>(
|
|
19
|
+
baseUrl: string,
|
|
20
|
+
path: string,
|
|
21
|
+
options: RequestOptions = {}
|
|
22
|
+
): Promise<HttpResponse<T>> {
|
|
23
|
+
const url = new URL(path, ensureTrailingSlash(baseUrl));
|
|
24
|
+
const headers = new Headers(options.headers);
|
|
25
|
+
headers.set("accept", options.accept ?? "application/json");
|
|
26
|
+
|
|
27
|
+
let body: string | undefined;
|
|
28
|
+
|
|
29
|
+
if (options.body !== undefined && options.rawBody !== undefined) {
|
|
30
|
+
throw new CliError("Request body cannot set both structured and raw payloads.");
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
if (options.rawBody !== undefined) {
|
|
34
|
+
headers.set("content-type", options.contentType ?? "application/json");
|
|
35
|
+
body = options.rawBody;
|
|
36
|
+
} else if (options.body !== undefined) {
|
|
37
|
+
headers.set("content-type", options.contentType ?? "application/json");
|
|
38
|
+
body = JSON.stringify(options.body);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
if (options.token) {
|
|
42
|
+
headers.set("authorization", `Bearer ${options.token}`);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const response = await fetch(url, {
|
|
46
|
+
method: options.method ?? "GET",
|
|
47
|
+
headers,
|
|
48
|
+
body
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
const rawPayload = await response.text();
|
|
52
|
+
|
|
53
|
+
if (!response.ok) {
|
|
54
|
+
throw toCliError(response.status, parsePayload(response, rawPayload, "lenient"));
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const payload = parsePayload(response, rawPayload, "strict");
|
|
58
|
+
|
|
59
|
+
return {
|
|
60
|
+
status: response.status,
|
|
61
|
+
data: payload as T
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export async function requestJsonApi<T = unknown>(
|
|
66
|
+
baseUrl: string,
|
|
67
|
+
path: string,
|
|
68
|
+
options: RequestOptions = {}
|
|
69
|
+
): Promise<HttpResponse<T>> {
|
|
70
|
+
return requestJson<T>(baseUrl, path, {
|
|
71
|
+
...options,
|
|
72
|
+
accept: options.accept ?? "application/vnd.api+json",
|
|
73
|
+
contentType: options.contentType ?? "application/vnd.api+json"
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function toCliError(status: number, payload: unknown): CliError {
|
|
78
|
+
const message = extractErrorMessage(payload) ?? `Request failed with status ${status}`;
|
|
79
|
+
return new CliError(message, 1);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function parsePayload(
|
|
83
|
+
response: Response,
|
|
84
|
+
rawPayload: string,
|
|
85
|
+
mode: "lenient" | "strict"
|
|
86
|
+
): unknown {
|
|
87
|
+
const contentType = response.headers.get("content-type") ?? "";
|
|
88
|
+
const isJson = contentType.includes("json");
|
|
89
|
+
|
|
90
|
+
if (!isJson || rawPayload.length === 0) {
|
|
91
|
+
return rawPayload;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
try {
|
|
95
|
+
return JSON.parse(rawPayload);
|
|
96
|
+
} catch (error) {
|
|
97
|
+
if (mode === "lenient") {
|
|
98
|
+
return rawPayload;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
throw new CliError(
|
|
102
|
+
`Received invalid JSON from server: ${(error as Error).message}`
|
|
103
|
+
);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function extractErrorMessage(payload: unknown): string | undefined {
|
|
108
|
+
if (!payload) {
|
|
109
|
+
return undefined;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
if (typeof payload === "string") {
|
|
113
|
+
return payload;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
if (typeof payload !== "object") {
|
|
117
|
+
return undefined;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const record = payload as Record<string, unknown>;
|
|
121
|
+
const errors = record.errors;
|
|
122
|
+
|
|
123
|
+
if (Array.isArray(errors) && errors.length > 0 && typeof errors[0] === "object" && errors[0] !== null) {
|
|
124
|
+
const first = errors[0] as Record<string, unknown>;
|
|
125
|
+
const title = typeof first.title === "string" ? first.title : undefined;
|
|
126
|
+
const detail = typeof first.detail === "string" ? first.detail : undefined;
|
|
127
|
+
const code = typeof first.code === "string" ? first.code : undefined;
|
|
128
|
+
|
|
129
|
+
return [title, detail, code ? `code=${code}` : undefined].filter(Boolean).join(": ");
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const message = typeof record.message === "string" ? record.message : undefined;
|
|
133
|
+
return message;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function ensureTrailingSlash(baseUrl: string): string {
|
|
137
|
+
return baseUrl.endsWith("/") ? baseUrl : `${baseUrl}/`;
|
|
138
|
+
}
|
package/src/lib/human.ts
ADDED
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
export type FieldValue = string | number | boolean | null | undefined;
|
|
2
|
+
|
|
3
|
+
export function formatEmpty(value: FieldValue): string {
|
|
4
|
+
if (value === null || value === undefined || value === "") {
|
|
5
|
+
return "-";
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
return String(value);
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function formatTimestamp(value?: string | null): string {
|
|
12
|
+
if (!value) {
|
|
13
|
+
return "-";
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const timestamp = Date.parse(value);
|
|
17
|
+
|
|
18
|
+
if (Number.isNaN(timestamp)) {
|
|
19
|
+
return value;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
return formatLocalTimestamp(new Date(timestamp));
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function shortId(id?: string | null): string {
|
|
26
|
+
if (!id) {
|
|
27
|
+
return "-";
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
return id.length > 12 ? `${id.slice(0, 8)}...` : id;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function renderFields(fields: Array<[label: string, value: FieldValue]>): string {
|
|
34
|
+
const visibleFields = fields.filter(([, value]) => value !== undefined);
|
|
35
|
+
|
|
36
|
+
if (visibleFields.length === 0) {
|
|
37
|
+
return "";
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const labelWidth = Math.max(...visibleFields.map(([label]) => label.length));
|
|
41
|
+
|
|
42
|
+
return visibleFields
|
|
43
|
+
.map(([label, value]) => `${label.padEnd(labelWidth)} ${formatEmpty(value)}`)
|
|
44
|
+
.join("\n");
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function renderSection(title: string, body: string): string {
|
|
48
|
+
const trimmed = body.trimEnd();
|
|
49
|
+
|
|
50
|
+
if (!trimmed) {
|
|
51
|
+
return "";
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
return `${title}\n${trimmed}`;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function renderBullets(items: string[]): string {
|
|
58
|
+
if (items.length === 0) {
|
|
59
|
+
return "-";
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
return items.map((item) => `- ${item}`).join("\n");
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function indent(text: string, spaces = 2): string {
|
|
66
|
+
const prefix = " ".repeat(spaces);
|
|
67
|
+
return text
|
|
68
|
+
.split("\n")
|
|
69
|
+
.map((line) => (line ? `${prefix}${line}` : ""))
|
|
70
|
+
.join("\n");
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function renderBodyBlock(body: string): string {
|
|
74
|
+
const normalized = body.replace(/\r\n/g, "\n").trimEnd();
|
|
75
|
+
|
|
76
|
+
if (!normalized) {
|
|
77
|
+
return indent("(empty)");
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
return indent(normalized);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function renderPagination(
|
|
84
|
+
nextCursor?: string | null,
|
|
85
|
+
nextCommand?: string,
|
|
86
|
+
): string {
|
|
87
|
+
if (!nextCursor) {
|
|
88
|
+
return "";
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
return nextCommand
|
|
92
|
+
? `More results: ${nextCommand}`
|
|
93
|
+
: `More results: --cursor ${nextCursor}`;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export function joinBlocks(blocks: Array<string | undefined | null | false>): string {
|
|
97
|
+
return blocks.filter(Boolean).join("\n\n");
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export function renderTokenAction(input: {
|
|
101
|
+
title: string;
|
|
102
|
+
id?: string | null;
|
|
103
|
+
name?: string | null;
|
|
104
|
+
token?: string | null;
|
|
105
|
+
issuedAt?: string | null;
|
|
106
|
+
expiresAt?: string | null;
|
|
107
|
+
}): string {
|
|
108
|
+
return joinBlocks([
|
|
109
|
+
input.title,
|
|
110
|
+
renderFields([
|
|
111
|
+
["ID", input.id],
|
|
112
|
+
["Name", input.name],
|
|
113
|
+
["Token", input.token],
|
|
114
|
+
["Issued", input.issuedAt ? formatTimestamp(input.issuedAt) : undefined],
|
|
115
|
+
["Expires", input.expiresAt ? formatTimestamp(input.expiresAt) : undefined],
|
|
116
|
+
]),
|
|
117
|
+
]);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function formatLocalTimestamp(value: Date): string {
|
|
121
|
+
const year = value.getFullYear();
|
|
122
|
+
const month = padDatePart(value.getMonth() + 1);
|
|
123
|
+
const day = padDatePart(value.getDate());
|
|
124
|
+
const hours = padDatePart(value.getHours());
|
|
125
|
+
const minutes = padDatePart(value.getMinutes());
|
|
126
|
+
const seconds = padDatePart(value.getSeconds());
|
|
127
|
+
|
|
128
|
+
return `${year}-${month}-${day} ${hours}:${minutes}:${seconds} ${formatTimezoneOffset(value)}`;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function formatTimezoneOffset(value: Date): string {
|
|
132
|
+
const offsetMinutes = -value.getTimezoneOffset();
|
|
133
|
+
const sign = offsetMinutes >= 0 ? "+" : "-";
|
|
134
|
+
const absoluteMinutes = Math.abs(offsetMinutes);
|
|
135
|
+
const hours = padDatePart(Math.floor(absoluteMinutes / 60));
|
|
136
|
+
const minutes = padDatePart(absoluteMinutes % 60);
|
|
137
|
+
|
|
138
|
+
return `${sign}${hours}:${minutes}`;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function padDatePart(value: number): string {
|
|
142
|
+
return String(value).padStart(2, "0");
|
|
143
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { readFile } from "node:fs/promises";
|
|
2
|
+
|
|
3
|
+
import { booleanFlag, stringFlag, type ParsedArgs } from "./args";
|
|
4
|
+
import { CliError } from "./errors";
|
|
5
|
+
import { readStdin } from "./body-input";
|
|
6
|
+
|
|
7
|
+
export interface ResolveJsonInputOptions {
|
|
8
|
+
flags: ParsedArgs["flags"];
|
|
9
|
+
inlineKey: string;
|
|
10
|
+
fileKey: string;
|
|
11
|
+
stdinKey: string;
|
|
12
|
+
label: string;
|
|
13
|
+
requireObject?: boolean;
|
|
14
|
+
readFileText?: (path: string) => Promise<string>;
|
|
15
|
+
readStdinText?: () => Promise<string>;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export async function resolveJsonInput({
|
|
19
|
+
flags,
|
|
20
|
+
inlineKey,
|
|
21
|
+
fileKey,
|
|
22
|
+
stdinKey,
|
|
23
|
+
label,
|
|
24
|
+
requireObject = true,
|
|
25
|
+
readFileText = (path) => readFile(path, "utf8"),
|
|
26
|
+
readStdinText = () => readStdin(),
|
|
27
|
+
}: ResolveJsonInputOptions): Promise<Record<string, unknown> | undefined> {
|
|
28
|
+
const inlineJson = stringFlag(flags, inlineKey);
|
|
29
|
+
const jsonFile = stringFlag(flags, fileKey);
|
|
30
|
+
const useStdin = booleanFlag(flags, stdinKey);
|
|
31
|
+
const providedCount = [
|
|
32
|
+
inlineJson !== undefined,
|
|
33
|
+
jsonFile !== undefined,
|
|
34
|
+
useStdin,
|
|
35
|
+
].filter(Boolean).length;
|
|
36
|
+
|
|
37
|
+
if (providedCount === 0) {
|
|
38
|
+
return undefined;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
if (providedCount > 1) {
|
|
42
|
+
throw new CliError(
|
|
43
|
+
`Use only one of \`--${kebab(inlineKey)}\`, \`--${kebab(fileKey)}\`, or \`--${kebab(stdinKey)}\``,
|
|
44
|
+
2,
|
|
45
|
+
);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const source =
|
|
49
|
+
inlineJson ?? (jsonFile !== undefined ? await readFileText(jsonFile) : await readStdinText());
|
|
50
|
+
|
|
51
|
+
try {
|
|
52
|
+
const value = JSON.parse(source);
|
|
53
|
+
|
|
54
|
+
if (
|
|
55
|
+
requireObject &&
|
|
56
|
+
(!value || typeof value !== "object" || Array.isArray(value))
|
|
57
|
+
) {
|
|
58
|
+
throw new CliError(`${label} must be a JSON object`, 2);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
return value as Record<string, unknown>;
|
|
62
|
+
} catch (error) {
|
|
63
|
+
if (error instanceof CliError) {
|
|
64
|
+
throw error;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
throw new CliError(`${label} must be valid JSON`, 2);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function kebab(value: string): string {
|
|
72
|
+
return value.replace(/[A-Z]/g, (letter) => `-${letter.toLowerCase()}`);
|
|
73
|
+
}
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import { CliError } from "./errors";
|
|
2
|
+
import type { JsonApiResource } from "../types/api";
|
|
3
|
+
|
|
4
|
+
export interface JsonApiCollectionResult<TAttributes extends object> {
|
|
5
|
+
items: Array<JsonApiResource<TAttributes>>;
|
|
6
|
+
nextCursor?: string;
|
|
7
|
+
prevCursor?: string;
|
|
8
|
+
meta?: Record<string, unknown>;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
interface JsonApiDocumentShape {
|
|
12
|
+
data: unknown;
|
|
13
|
+
links?: unknown;
|
|
14
|
+
meta?: unknown;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function expectResource<TAttributes extends object>(
|
|
18
|
+
payload: unknown
|
|
19
|
+
): JsonApiResource<TAttributes> {
|
|
20
|
+
const document = asJsonApiDocument(payload);
|
|
21
|
+
|
|
22
|
+
if (!isJsonApiResource<TAttributes>(document.data)) {
|
|
23
|
+
throw new CliError("Expected a JSON:API resource object in response");
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
return document.data;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function expectCollection<TAttributes extends object>(
|
|
30
|
+
payload: unknown
|
|
31
|
+
): JsonApiCollectionResult<TAttributes> {
|
|
32
|
+
const document = asJsonApiDocument(payload);
|
|
33
|
+
|
|
34
|
+
if (!Array.isArray(document.data)) {
|
|
35
|
+
throw new CliError("Expected a JSON:API collection in response");
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const items = document.data.filter(isJsonApiResource<TAttributes>);
|
|
39
|
+
|
|
40
|
+
if (items.length !== document.data.length) {
|
|
41
|
+
throw new CliError("Expected a JSON:API collection in response");
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
return {
|
|
45
|
+
items,
|
|
46
|
+
nextCursor: extractCursor(linkValue(document.links, "next")),
|
|
47
|
+
prevCursor: extractCursor(linkValue(document.links, "prev")),
|
|
48
|
+
meta: isRecord(document.meta) ? document.meta : undefined
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function asJsonApiDocument(
|
|
53
|
+
payload: unknown,
|
|
54
|
+
): JsonApiDocumentShape {
|
|
55
|
+
if (!isRecord(payload)) {
|
|
56
|
+
throw new CliError("Expected a JSON:API document in response");
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
if (!("data" in payload)) {
|
|
60
|
+
throw new CliError("Expected a JSON:API document in response");
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
return {
|
|
64
|
+
data: payload.data,
|
|
65
|
+
links: payload.links,
|
|
66
|
+
meta: payload.meta,
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function isJsonApiResource<TAttributes extends object>(
|
|
71
|
+
value: unknown,
|
|
72
|
+
): value is JsonApiResource<TAttributes> {
|
|
73
|
+
if (!isRecord(value)) {
|
|
74
|
+
return false;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
return (
|
|
78
|
+
typeof value.id === "string" &&
|
|
79
|
+
typeof value.type === "string" &&
|
|
80
|
+
isRecord(value.attributes)
|
|
81
|
+
);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function linkValue(
|
|
85
|
+
links: unknown,
|
|
86
|
+
key: string,
|
|
87
|
+
): string | null | undefined {
|
|
88
|
+
if (!isRecord(links)) {
|
|
89
|
+
return undefined;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const value = links[key];
|
|
93
|
+
|
|
94
|
+
if (typeof value === "string" || value === null) {
|
|
95
|
+
return value;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
return undefined;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
102
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function extractCursor(link: string | null | undefined): string | undefined {
|
|
106
|
+
if (!link) {
|
|
107
|
+
return undefined;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
try {
|
|
111
|
+
const url = new URL(link, "http://placeholder.local");
|
|
112
|
+
return (
|
|
113
|
+
url.searchParams.get("page[after]") ??
|
|
114
|
+
url.searchParams.get("page[cursor]") ??
|
|
115
|
+
undefined
|
|
116
|
+
);
|
|
117
|
+
} catch {
|
|
118
|
+
return undefined;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
import type { OutputMode } from "../types/api";
|
|
2
|
+
|
|
3
|
+
export interface Io {
|
|
4
|
+
stdout: (message: string) => void;
|
|
5
|
+
stderr: (message: string) => void;
|
|
6
|
+
stdoutIsTTY?: boolean;
|
|
7
|
+
stderrIsTTY?: boolean;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function defaultIo(): Io {
|
|
11
|
+
return {
|
|
12
|
+
stdout: (message) => process.stdout.write(`${message}\n`),
|
|
13
|
+
stderr: (message) => process.stderr.write(`${message}\n`),
|
|
14
|
+
stdoutIsTTY: process.stdout.isTTY,
|
|
15
|
+
stderrIsTTY: process.stderr.isTTY,
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function printJson(io: Io, value: unknown): void {
|
|
20
|
+
io.stdout(JSON.stringify(value, null, 2));
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function printValue(io: Io, outputMode: OutputMode, value: unknown): void {
|
|
24
|
+
if (outputMode === "json") {
|
|
25
|
+
printJson(io, value);
|
|
26
|
+
return;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
if (typeof value === "string") {
|
|
30
|
+
io.stdout(value);
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
if (Array.isArray(value)) {
|
|
35
|
+
if (value.length === 0) {
|
|
36
|
+
io.stdout("No results.");
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
if (isRecordArray(value)) {
|
|
41
|
+
io.stdout(renderTable(value));
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
printJson(io, value);
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
if (typeof value === "object" && value !== null) {
|
|
50
|
+
io.stdout(renderRecord(value as Record<string, unknown>));
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
io.stdout(formatCell(value));
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function renderTable(rows: Record<string, unknown>[]): string {
|
|
58
|
+
const columns = collectColumns(rows);
|
|
59
|
+
|
|
60
|
+
if (columns.length === 0) {
|
|
61
|
+
return "";
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const renderedRows = rows.map((row) =>
|
|
65
|
+
columns.map((column) => formatTableCell(column, row[column])),
|
|
66
|
+
);
|
|
67
|
+
const widths = columns.map((column, index) =>
|
|
68
|
+
Math.max(
|
|
69
|
+
column.length,
|
|
70
|
+
...renderedRows.map((row) => row[index]!.length),
|
|
71
|
+
),
|
|
72
|
+
);
|
|
73
|
+
|
|
74
|
+
return [
|
|
75
|
+
renderTableLine(columns, widths),
|
|
76
|
+
renderTableLine(widths.map((width) => "-".repeat(width)), widths),
|
|
77
|
+
...renderedRows.map((row) => renderTableLine(row, widths)),
|
|
78
|
+
].join("\n");
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function renderRecord(value: Record<string, unknown>): string {
|
|
82
|
+
return Object.entries(value)
|
|
83
|
+
.map(([key, entry]) => `${key}: ${formatCell(entry)}`)
|
|
84
|
+
.join("\n");
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function isRecordArray(value: unknown[]): value is Record<string, unknown>[] {
|
|
88
|
+
return value.every(isRecord);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
92
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function formatCell(value: unknown): string {
|
|
96
|
+
if (value === null || value === undefined) {
|
|
97
|
+
return "";
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
if (typeof value === "string") {
|
|
101
|
+
return value.replace(/\s+/g, " ").trim();
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
if (typeof value === "object") {
|
|
105
|
+
return JSON.stringify(value);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
return String(value);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function collectColumns(rows: Record<string, unknown>[]): string[] {
|
|
112
|
+
const columns: string[] = [];
|
|
113
|
+
|
|
114
|
+
for (const row of rows) {
|
|
115
|
+
for (const key of Object.keys(row)) {
|
|
116
|
+
if (!columns.includes(key)) {
|
|
117
|
+
columns.push(key);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
return columns;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function formatTableCell(column: string, value: unknown): string {
|
|
126
|
+
if (value === null || value === undefined) {
|
|
127
|
+
return "";
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
if (typeof value === "string") {
|
|
131
|
+
const normalized = value.replace(/\s+/g, " ").trim();
|
|
132
|
+
|
|
133
|
+
if (isDateColumn(column)) {
|
|
134
|
+
return formatDateValue(normalized);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
if (column === "body") {
|
|
138
|
+
return truncate(normalized, 30);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
return normalized;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
if (typeof value === "object") {
|
|
145
|
+
return JSON.stringify(value);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
return String(value);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function renderTableLine(cells: string[], widths: number[]): string {
|
|
152
|
+
return cells
|
|
153
|
+
.map((cell, index) => cell.padEnd(widths[index]!))
|
|
154
|
+
.join(" ")
|
|
155
|
+
.trimEnd();
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function isDateColumn(column: string): boolean {
|
|
159
|
+
return column === "date" || column.endsWith("_at") || /At$/.test(column);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function formatDateValue(value: string): string {
|
|
163
|
+
const timestamp = Date.parse(value);
|
|
164
|
+
|
|
165
|
+
if (Number.isNaN(timestamp)) {
|
|
166
|
+
return value;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
return formatLocalTimestamp(new Date(timestamp));
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function truncate(value: string, maxLength: number): string {
|
|
173
|
+
if (value.length <= maxLength) {
|
|
174
|
+
return value;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
return `${value.slice(0, maxLength - 3)}...`;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function formatLocalTimestamp(value: Date): string {
|
|
181
|
+
const year = value.getFullYear();
|
|
182
|
+
const month = padDatePart(value.getMonth() + 1);
|
|
183
|
+
const day = padDatePart(value.getDate());
|
|
184
|
+
const hours = padDatePart(value.getHours());
|
|
185
|
+
const minutes = padDatePart(value.getMinutes());
|
|
186
|
+
const seconds = padDatePart(value.getSeconds());
|
|
187
|
+
|
|
188
|
+
return `${year}-${month}-${day} ${hours}:${minutes}:${seconds} ${formatTimezoneOffset(value)}`;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function formatTimezoneOffset(value: Date): string {
|
|
192
|
+
const offsetMinutes = -value.getTimezoneOffset();
|
|
193
|
+
const sign = offsetMinutes >= 0 ? "+" : "-";
|
|
194
|
+
const absoluteMinutes = Math.abs(offsetMinutes);
|
|
195
|
+
const hours = padDatePart(Math.floor(absoluteMinutes / 60));
|
|
196
|
+
const minutes = padDatePart(absoluteMinutes % 60);
|
|
197
|
+
|
|
198
|
+
return `${sign}${hours}:${minutes}`;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function padDatePart(value: number): string {
|
|
202
|
+
return String(value).padStart(2, "0");
|
|
203
|
+
}
|