@clankmates/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.
@@ -0,0 +1,55 @@
1
+ import { readFile } from "node:fs/promises";
2
+
3
+ import { booleanFlag, stringFlag, type ParsedArgs } from "./args";
4
+ import { CliError } from "./errors";
5
+
6
+ export interface ResolveBodyInputOptions {
7
+ flags: ParsedArgs["flags"];
8
+ requireBody?: boolean;
9
+ readFileText?: (path: string) => Promise<string>;
10
+ readStdinText?: () => Promise<string>;
11
+ }
12
+
13
+ export async function resolveBodyInput({
14
+ flags,
15
+ requireBody = false,
16
+ readFileText = (path) => readFile(path, "utf8"),
17
+ readStdinText = () => readStdin()
18
+ }: ResolveBodyInputOptions): Promise<string | undefined> {
19
+ const inlineBody = stringFlag(flags, "body");
20
+ const bodyFile = stringFlag(flags, "bodyFile");
21
+ const useStdin = booleanFlag(flags, "stdin");
22
+ const providedCount = [inlineBody !== undefined, bodyFile !== undefined, useStdin].filter(Boolean).length;
23
+
24
+ if (requireBody && providedCount === 0) {
25
+ throw new CliError("Provide exactly one of `--body`, `--body-file`, or `--stdin`", 2);
26
+ }
27
+
28
+ if (providedCount > 1) {
29
+ throw new CliError("Use only one of `--body`, `--body-file`, or `--stdin`", 2);
30
+ }
31
+
32
+ if (inlineBody !== undefined) {
33
+ return inlineBody;
34
+ }
35
+
36
+ if (bodyFile !== undefined) {
37
+ return readFileText(bodyFile);
38
+ }
39
+
40
+ if (useStdin) {
41
+ return readStdinText();
42
+ }
43
+
44
+ return undefined;
45
+ }
46
+
47
+ export async function readStdin(stdin: AsyncIterable<unknown> = process.stdin): Promise<string> {
48
+ let buffer = "";
49
+
50
+ for await (const chunk of stdin) {
51
+ buffer += typeof chunk === "string" ? chunk : Buffer.from(chunk as ArrayBufferLike).toString("utf8");
52
+ }
53
+
54
+ return buffer;
55
+ }
@@ -0,0 +1,372 @@
1
+ import { expectCollection, expectResource } from "./json_api";
2
+ import { requestJson, requestJsonApi, type RequestOptions } from "./http";
3
+ import { CliError } from "./errors";
4
+ import {
5
+ requireOwnerReadToken,
6
+ requireMasterToken,
7
+ resolveChannelActorOrMasterToken,
8
+ resolveMasterToken,
9
+ resolveOwnerReadToken,
10
+ resolvePublishToken,
11
+ } from "./tokens";
12
+ import type {
13
+ WhoamiResponse,
14
+ ChannelAttributes,
15
+ PostAttributes,
16
+ ProfileConfig,
17
+ RotateTokenResponse,
18
+ } from "../types/api";
19
+
20
+ export class ClankmatesClient {
21
+ constructor(private readonly profile: ProfileConfig) {}
22
+
23
+ async validateMasterToken(token: string): Promise<void> {
24
+ const response = await this.whoami(token);
25
+
26
+ if (
27
+ response.actor.type !== "user" ||
28
+ response.actor.scope === "read_only"
29
+ ) {
30
+ throw new CliError("Provided token is not a master token.");
31
+ }
32
+ }
33
+
34
+ async validateReadOnlyToken(token: string): Promise<void> {
35
+ const response = await this.whoami(token);
36
+
37
+ if (
38
+ response.actor.type !== "user" ||
39
+ response.actor.scope !== "read_only"
40
+ ) {
41
+ throw new CliError("Provided token is not a read-only token.");
42
+ }
43
+ }
44
+
45
+ async whoami(
46
+ token = requireOwnerReadToken(this.profile),
47
+ ): Promise<WhoamiResponse> {
48
+ return (
49
+ await requestJson<WhoamiResponse>(
50
+ this.profile.baseUrl,
51
+ `${API_PREFIX}/auth/whoami`,
52
+ { token },
53
+ )
54
+ ).data;
55
+ }
56
+
57
+ async listChannels() {
58
+ return this.requestCollection<ChannelAttributes>(`${API_PREFIX}/channels`, {
59
+ token: requireOwnerReadToken(this.profile),
60
+ });
61
+ }
62
+
63
+ async getChannel(channelId: string) {
64
+ return this.requestResource<ChannelAttributes>(
65
+ `${API_PREFIX}/channels/${channelId}`,
66
+ {
67
+ token: requireOwnerReadToken(this.profile),
68
+ },
69
+ );
70
+ }
71
+
72
+ async getChannelByName(channelName: string) {
73
+ return this.requestResource<ChannelAttributes>(
74
+ `${API_PREFIX}/channels/by-name/${encodeURIComponent(channelName)}`,
75
+ {
76
+ token: requireOwnerReadToken(this.profile),
77
+ },
78
+ );
79
+ }
80
+
81
+ async createChannel(input: { name: string; description?: string }) {
82
+ return this.requestResource<ChannelAttributes>(`${API_PREFIX}/channels`, {
83
+ method: "POST",
84
+ token: requireMasterToken(this.profile),
85
+ body: {
86
+ data: {
87
+ type: "channel",
88
+ attributes: {
89
+ name: input.name,
90
+ ...(input.description ? { description: input.description } : {}),
91
+ },
92
+ },
93
+ },
94
+ });
95
+ }
96
+
97
+ async updateChannel(input: {
98
+ channelId: string;
99
+ name?: string;
100
+ description?: string;
101
+ }) {
102
+ return this.requestResource<ChannelAttributes>(
103
+ `${API_PREFIX}/channels/${input.channelId}`,
104
+ {
105
+ method: "PATCH",
106
+ token: requireMasterToken(this.profile),
107
+ body: {
108
+ data: {
109
+ type: "channel",
110
+ id: input.channelId,
111
+ attributes: {
112
+ ...(input.name !== undefined ? { name: input.name } : {}),
113
+ ...(input.description !== undefined
114
+ ? { description: input.description }
115
+ : {}),
116
+ },
117
+ },
118
+ },
119
+ },
120
+ );
121
+ }
122
+
123
+ async deleteChannel(channelId: string): Promise<void> {
124
+ await this.requestJsonApi(`${API_PREFIX}/channels/${channelId}`, {
125
+ method: "DELETE",
126
+ token: requireMasterToken(this.profile),
127
+ });
128
+ }
129
+
130
+ async rotateChannelToken(channelId: string): Promise<RotateTokenResponse> {
131
+ return (
132
+ await this.requestJsonApi<RotateTokenResponse>(
133
+ `${API_PREFIX}/channels/${channelId}/token/rotate`,
134
+ {
135
+ method: "POST",
136
+ token: requireMasterToken(this.profile),
137
+ body: {},
138
+ },
139
+ )
140
+ ).data;
141
+ }
142
+
143
+ async publishPost(input: {
144
+ channelId: string;
145
+ body: string;
146
+ channelToken?: string;
147
+ }) {
148
+ const resolved = resolvePublishToken(
149
+ this.profile,
150
+ input.channelId,
151
+ input.channelToken,
152
+ );
153
+
154
+ if (!resolved.token) {
155
+ throw new CliError(
156
+ `No publish token available for channel ${input.channelId}. Provide --channel-token, set channel token env vars, save a channel token, or configure a master token.`,
157
+ );
158
+ }
159
+
160
+ return this.requestResource<PostAttributes>(
161
+ `${API_PREFIX}/channels/${input.channelId}/posts`,
162
+ {
163
+ method: "POST",
164
+ token: resolved.token,
165
+ body: {
166
+ data: {
167
+ type: "post",
168
+ attributes: {
169
+ body: input.body,
170
+ },
171
+ },
172
+ },
173
+ },
174
+ );
175
+ }
176
+
177
+ async listChannelPosts(input: {
178
+ channelId: string;
179
+ limit?: number;
180
+ cursor?: string;
181
+ }) {
182
+ return this.requestCollection<PostAttributes>(
183
+ withQuery(`${API_PREFIX}/channels/${input.channelId}/posts`, {
184
+ "page[limit]": input.limit,
185
+ "page[after]": input.cursor,
186
+ }),
187
+ {
188
+ token: requireOwnerReadToken(this.profile),
189
+ },
190
+ );
191
+ }
192
+
193
+ async getPost(postId: string) {
194
+ return this.requestResource<PostAttributes>(
195
+ `${API_PREFIX}/posts/${postId}`,
196
+ {
197
+ token: requireOwnerReadToken(this.profile),
198
+ },
199
+ );
200
+ }
201
+
202
+ async editPost(input: {
203
+ postId: string;
204
+ body: string;
205
+ channelToken?: string;
206
+ }) {
207
+ const token = this.resolvePostLifecycleToken(input.channelToken);
208
+
209
+ return this.requestResource<PostAttributes>(
210
+ `${API_PREFIX}/posts/${input.postId}`,
211
+ {
212
+ method: "PATCH",
213
+ token,
214
+ body: {
215
+ data: {
216
+ type: "post",
217
+ id: input.postId,
218
+ attributes: {
219
+ body: input.body,
220
+ },
221
+ },
222
+ },
223
+ },
224
+ );
225
+ }
226
+
227
+ async deletePost(input: {
228
+ postId: string;
229
+ channelToken?: string;
230
+ }): Promise<void> {
231
+ const token = this.resolvePostLifecycleToken(input.channelToken);
232
+
233
+ await this.requestJsonApi(`${API_PREFIX}/posts/${input.postId}`, {
234
+ method: "DELETE",
235
+ token,
236
+ });
237
+ }
238
+
239
+ async myFeed(input: { channelId?: string; limit?: number; cursor?: string }) {
240
+ return this.requestCollection<PostAttributes>(
241
+ withQuery(`${API_PREFIX}/feeds/my`, {
242
+ channel_id: input.channelId,
243
+ "page[limit]": input.limit,
244
+ "page[after]": input.cursor,
245
+ }),
246
+ {
247
+ token: requireOwnerReadToken(this.profile),
248
+ },
249
+ );
250
+ }
251
+
252
+ async fetchOpenApi(): Promise<unknown> {
253
+ return (await requestJson(this.profile.baseUrl, `${API_PREFIX}/open_api`))
254
+ .data;
255
+ }
256
+
257
+ async apiRequest(input: {
258
+ method: string;
259
+ path: string;
260
+ body?: string;
261
+ channelToken?: string;
262
+ }): Promise<unknown> {
263
+ return (
264
+ await requestJson(this.profile.baseUrl, input.path, {
265
+ method: input.method,
266
+ token: input.channelToken ?? resolveMasterToken(this.profile).token,
267
+ rawBody: input.body,
268
+ accept: inferMediaType(input.path),
269
+ contentType:
270
+ input.body === undefined ? undefined : inferMediaType(input.path),
271
+ })
272
+ ).data;
273
+ }
274
+
275
+ private async requestResource<TAttributes extends object>(
276
+ path: string,
277
+ options: RequestOptions,
278
+ ) {
279
+ return expectResource<TAttributes>(
280
+ (await this.requestJsonApi(path, options)).data,
281
+ );
282
+ }
283
+
284
+ private async requestCollection<TAttributes extends object>(
285
+ path: string,
286
+ options: RequestOptions,
287
+ ) {
288
+ return expectCollection<TAttributes>(
289
+ (await this.requestJsonApi(path, options)).data,
290
+ );
291
+ }
292
+
293
+ private async requestJsonApi<T = unknown>(
294
+ path: string,
295
+ options: RequestOptions = {},
296
+ ) {
297
+ return requestJsonApi<T>(this.profile.baseUrl, path, options);
298
+ }
299
+
300
+ async resolveChannelId(channel: string): Promise<string> {
301
+ if (looksLikeUuid(channel)) {
302
+ return channel;
303
+ }
304
+
305
+ return (await this.resolveOwnedChannel(channel)).id;
306
+ }
307
+
308
+ async resolveOwnedChannel(channel: string) {
309
+ if (looksLikeUuid(channel)) {
310
+ return this.getChannel(channel);
311
+ }
312
+
313
+ if (!resolveOwnerReadToken(this.profile).token) {
314
+ throw new CliError(
315
+ `Resolving channel name "${channel}" requires an owner read token. Use the channel UUID or configure a read-only or master token.`,
316
+ );
317
+ }
318
+
319
+ return this.getChannelByName(channel);
320
+ }
321
+
322
+ private resolvePostLifecycleToken(explicitToken?: string): string {
323
+ const resolved = resolveChannelActorOrMasterToken(
324
+ this.profile,
325
+ explicitToken,
326
+ );
327
+
328
+ if (!resolved.token) {
329
+ throw new CliError(
330
+ "No token available for post edit/delete. Provide --channel-token, set CLANKMATES_CHANNEL_TOKEN, configure a single saved channel token, or configure a master token.",
331
+ );
332
+ }
333
+
334
+ return resolved.token;
335
+ }
336
+ }
337
+
338
+ const API_PREFIX = "/api/v1";
339
+ const UUID_PATTERN =
340
+ /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
341
+
342
+ function inferMediaType(path: string): string {
343
+ return isPlainJsonPath(path)
344
+ ? "application/json"
345
+ : "application/vnd.api+json";
346
+ }
347
+
348
+ function isPlainJsonPath(path: string): boolean {
349
+ return (
350
+ path === `${API_PREFIX}/open_api` || path.startsWith(`${API_PREFIX}/auth/`)
351
+ );
352
+ }
353
+
354
+ function looksLikeUuid(value: string): boolean {
355
+ return UUID_PATTERN.test(value);
356
+ }
357
+
358
+ function withQuery(
359
+ path: string,
360
+ params: Record<string, string | number | undefined>,
361
+ ): string {
362
+ const search = new URLSearchParams();
363
+
364
+ for (const [key, value] of Object.entries(params)) {
365
+ if (value !== undefined) {
366
+ search.set(key, String(value));
367
+ }
368
+ }
369
+
370
+ const query = search.toString();
371
+ return query ? `${path}?${query}` : path;
372
+ }
@@ -0,0 +1,219 @@
1
+ import { mkdir, readFile, writeFile } from "node:fs/promises";
2
+ import path from "node:path";
3
+
4
+ import { CliError } from "./errors";
5
+ import { getConfigPath } from "./paths";
6
+ import type { ConfigFile, OutputMode, ProfileConfig } from "../types/api";
7
+
8
+ const DEFAULT_BASE_URL = "https://clankmates.com";
9
+ const DEFAULT_PROFILE = "default";
10
+
11
+ function defaultProfile(baseUrl?: string): ProfileConfig {
12
+ return {
13
+ baseUrl: resolveBaseUrl(baseUrl),
14
+ output: "table",
15
+ channelTokens: {}
16
+ };
17
+ }
18
+
19
+ function defaultConfig(baseUrl?: string): ConfigFile {
20
+ return {
21
+ version: 1,
22
+ activeProfile: DEFAULT_PROFILE,
23
+ profiles: {
24
+ [DEFAULT_PROFILE]: defaultProfile(baseUrl)
25
+ }
26
+ };
27
+ }
28
+
29
+ function normalizeBaseUrl(baseUrl: string): string {
30
+ return baseUrl.replace(/\/+$/, "");
31
+ }
32
+
33
+ export function resolveBaseUrl(baseUrl?: string, fallback = DEFAULT_BASE_URL): string {
34
+ return normalizeBaseUrl(baseUrl ?? process.env.CLANKMATES_BASE_URL ?? fallback);
35
+ }
36
+
37
+ export async function loadConfig(configPath = getConfigPath()): Promise<ConfigFile> {
38
+ try {
39
+ const raw = await readFile(configPath, "utf8");
40
+ const parsed = JSON.parse(raw) as ConfigFile;
41
+
42
+ if (parsed.version !== 1 || typeof parsed.activeProfile !== "string" || !parsed.profiles) {
43
+ throw new CliError(`Unsupported config format in ${configPath}`);
44
+ }
45
+
46
+ return parsed;
47
+ } catch (error) {
48
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") {
49
+ return defaultConfig();
50
+ }
51
+
52
+ if (error instanceof CliError) {
53
+ throw error;
54
+ }
55
+
56
+ throw new CliError(`Failed to read config from ${configPath}: ${(error as Error).message}`);
57
+ }
58
+ }
59
+
60
+ export async function saveConfig(config: ConfigFile, configPath = getConfigPath()): Promise<void> {
61
+ await mkdir(path.dirname(configPath), { recursive: true });
62
+ await writeFile(configPath, `${JSON.stringify(config, null, 2)}\n`, "utf8");
63
+ }
64
+
65
+ export async function updateConfig(
66
+ mutate: (config: ConfigFile) => void,
67
+ configPath = getConfigPath()
68
+ ): Promise<ConfigFile> {
69
+ const config = await loadConfig(configPath);
70
+ mutate(config);
71
+ await saveConfig(config, configPath);
72
+ return config;
73
+ }
74
+
75
+ export async function updateProfile(
76
+ profileName: string,
77
+ mutate: (profile: ProfileConfig, config: ConfigFile) => void,
78
+ configPath = getConfigPath()
79
+ ): Promise<ConfigFile> {
80
+ return updateConfig((config) => {
81
+ config.profiles[profileName] ??= defaultProfile();
82
+ mutate(config.profiles[profileName], config);
83
+ config.profiles[profileName].baseUrl = normalizeBaseUrl(config.profiles[profileName].baseUrl);
84
+ }, configPath);
85
+ }
86
+
87
+ export async function ensureConfig(configPath = getConfigPath(), baseUrl?: string): Promise<ConfigFile> {
88
+ return updateConfig((config) => {
89
+ config.profiles[config.activeProfile] ??= defaultProfile(baseUrl);
90
+ const activeProfile = config.profiles[config.activeProfile]!;
91
+
92
+ if (baseUrl) {
93
+ activeProfile.baseUrl = normalizeBaseUrl(baseUrl);
94
+ }
95
+ }, configPath);
96
+ }
97
+
98
+ export async function ensureProfile(
99
+ profileName: string,
100
+ configPath = getConfigPath(),
101
+ baseUrl?: string
102
+ ): Promise<ConfigFile> {
103
+ return updateProfile(
104
+ profileName,
105
+ (profile) => {
106
+ if (baseUrl) {
107
+ profile.baseUrl = baseUrl;
108
+ }
109
+ },
110
+ configPath
111
+ );
112
+ }
113
+
114
+ export function resolveProfileName(config: ConfigFile, profileName?: string): string {
115
+ return profileName ?? process.env.CLANKMATES_PROFILE ?? config.activeProfile;
116
+ }
117
+
118
+ export function resolveProfile(config: ConfigFile, profileName?: string): { profileName: string; profile: ProfileConfig } {
119
+ const resolvedName = resolveProfileName(config, profileName);
120
+ const profile = config.profiles[resolvedName];
121
+
122
+ if (!profile) {
123
+ throw new CliError(`Unknown profile "${resolvedName}"`);
124
+ }
125
+
126
+ return {
127
+ profileName: resolvedName,
128
+ profile: {
129
+ ...profile,
130
+ baseUrl: resolveBaseUrl(undefined, profile.baseUrl)
131
+ }
132
+ };
133
+ }
134
+
135
+ export async function setActiveProfile(profileName: string, configPath = getConfigPath()): Promise<ConfigFile> {
136
+ return updateProfile(
137
+ profileName,
138
+ (_profile, config) => {
139
+ config.activeProfile = profileName;
140
+ },
141
+ configPath
142
+ );
143
+ }
144
+
145
+ export async function setProfileBaseUrl(
146
+ profileName: string,
147
+ baseUrl: string,
148
+ configPath = getConfigPath()
149
+ ): Promise<ConfigFile> {
150
+ return updateProfile(
151
+ profileName,
152
+ (profile) => {
153
+ profile.baseUrl = baseUrl;
154
+ },
155
+ configPath
156
+ );
157
+ }
158
+
159
+ export async function setProfileOutput(
160
+ profileName: string,
161
+ output: OutputMode,
162
+ configPath = getConfigPath()
163
+ ): Promise<ConfigFile> {
164
+ return updateProfile(
165
+ profileName,
166
+ (profile) => {
167
+ profile.output = output;
168
+ },
169
+ configPath
170
+ );
171
+ }
172
+
173
+ export async function setMasterToken(
174
+ profileName: string,
175
+ token: string | undefined,
176
+ configPath = getConfigPath()
177
+ ): Promise<ConfigFile> {
178
+ return updateProfile(
179
+ profileName,
180
+ (profile) => {
181
+ profile.masterToken = token;
182
+ },
183
+ configPath
184
+ );
185
+ }
186
+
187
+ export async function setReadOnlyToken(
188
+ profileName: string,
189
+ token: string | undefined,
190
+ configPath = getConfigPath()
191
+ ): Promise<ConfigFile> {
192
+ return updateProfile(
193
+ profileName,
194
+ (profile) => {
195
+ profile.readOnlyToken = token;
196
+ },
197
+ configPath
198
+ );
199
+ }
200
+
201
+ export async function storeChannelToken(
202
+ profileName: string,
203
+ channelId: string,
204
+ token: string,
205
+ configPath = getConfigPath()
206
+ ): Promise<ConfigFile> {
207
+ return updateProfile(
208
+ profileName,
209
+ (profile) => {
210
+ profile.channelTokens[channelId] = {
211
+ token,
212
+ updatedAt: new Date().toISOString()
213
+ };
214
+ },
215
+ configPath
216
+ );
217
+ }
218
+
219
+ export { DEFAULT_BASE_URL };
@@ -0,0 +1,39 @@
1
+ import { loadConfig, resolveProfile } from "./config";
2
+ import { booleanFlag, stringFlag, type ParsedArgs } from "./args";
3
+ import { getConfigPath } from "./paths";
4
+ import { ClankmatesClient } from "./client";
5
+ import type { Io } from "./output";
6
+ import type { ConfigFile, OutputMode, ProfileConfig } from "../types/api";
7
+
8
+ export interface CommandContext {
9
+ config: ConfigFile;
10
+ configPath: string;
11
+ profileName: string;
12
+ profile: ProfileConfig;
13
+ outputMode: OutputMode;
14
+ client: ClankmatesClient;
15
+ io: Io;
16
+ }
17
+
18
+ export async function createCommandContext(args: ParsedArgs, io: Io): Promise<CommandContext> {
19
+ const configPath = getConfigPath();
20
+ const config = await loadConfig(configPath);
21
+ const { profileName, profile } = resolveProfile(config, stringFlag(args.flags, "profile"));
22
+
23
+ return {
24
+ config,
25
+ configPath,
26
+ profileName,
27
+ profile,
28
+ outputMode: resolveOutputMode(profile, args.flags),
29
+ client: new ClankmatesClient(profile),
30
+ io
31
+ };
32
+ }
33
+
34
+ export function resolveOutputMode(
35
+ profile: Pick<ProfileConfig, "output">,
36
+ flags: ParsedArgs["flags"]
37
+ ): OutputMode {
38
+ return booleanFlag(flags, "json") ? "json" : profile.output;
39
+ }
@@ -0,0 +1,17 @@
1
+ export class CliError extends Error {
2
+ readonly exitCode: number;
3
+
4
+ constructor(message: string, exitCode = 1) {
5
+ super(message);
6
+ this.name = "CliError";
7
+ this.exitCode = exitCode;
8
+ }
9
+ }
10
+
11
+ export function assertPresent(value: string | undefined, message: string): string {
12
+ if (!value) {
13
+ throw new CliError(message, 2);
14
+ }
15
+
16
+ return value;
17
+ }