@clankmates/cli 0.2.0 → 0.3.1

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.
@@ -1,4 +1,5 @@
1
1
  import {
2
+ booleanFlag,
2
3
  integerFlag,
3
4
  requiredChannelFlag,
4
5
  requiredPositional,
@@ -9,6 +10,7 @@ import { resolveBodyInput } from "../lib/body-input";
9
10
  import { createCommandContext } from "../lib/context";
10
11
  import { CliError } from "../lib/errors";
11
12
  import { printJson, printValue, type Io } from "../lib/output";
13
+ import type { PostAttributes } from "../types/api";
12
14
 
13
15
  export async function runPostCommand(args: ParsedArgs, io: Io): Promise<void> {
14
16
  const subcommand = args.positionals[0];
@@ -53,24 +55,34 @@ export async function runPostCommand(args: ParsedArgs, io: Io): Promise<void> {
53
55
  cursor: stringFlag(args.flags, "cursor"),
54
56
  });
55
57
 
56
- if (context.outputMode === "json") {
57
- printJson(io, {
58
- items: response.items,
59
- nextCursor: response.nextCursor,
60
- });
61
- return;
62
- }
58
+ printPostCollection(context.outputMode, io, response);
59
+ return;
60
+ }
63
61
 
64
- printValue(
65
- io,
66
- context.outputMode,
67
- response.items.map((item) => ({
68
- id: item.id,
69
- source: item.attributes.source,
70
- date: item.attributes.updated_at ?? item.attributes.inserted_at ?? "",
71
- body: item.attributes.body,
72
- })),
73
- );
62
+ case "public-list": {
63
+ const response = await context.client.listPublicChannelPosts({
64
+ handle: requiredPositional(args.positionals, 1, "Missing public handle"),
65
+ channelName: requiredPositional(
66
+ args.positionals,
67
+ 2,
68
+ "Missing public channel name",
69
+ ),
70
+ limit: integerFlag(args.flags, "limit", { label: "--limit" }),
71
+ cursor: stringFlag(args.flags, "cursor"),
72
+ });
73
+
74
+ printPostCollection(context.outputMode, io, response);
75
+ return;
76
+ }
77
+
78
+ case "shared-list": {
79
+ const response = await context.client.listSharedChannelPosts({
80
+ token: requiredPositional(args.positionals, 1, "Missing share token"),
81
+ limit: integerFlag(args.flags, "limit", { label: "--limit" }),
82
+ cursor: stringFlag(args.flags, "cursor"),
83
+ });
84
+
85
+ printPostCollection(context.outputMode, io, response);
74
86
  return;
75
87
  }
76
88
 
@@ -134,7 +146,102 @@ export async function runPostCommand(args: ParsedArgs, io: Io): Promise<void> {
134
146
  return;
135
147
  }
136
148
 
149
+ case "public-get": {
150
+ const post = await context.client.getPublicPostByHandle({
151
+ handle: requiredPositional(args.positionals, 1, "Missing public handle"),
152
+ channelName: requiredPositional(
153
+ args.positionals,
154
+ 2,
155
+ "Missing public channel name",
156
+ ),
157
+ postId: requiredPositional(args.positionals, 3, "Missing post id"),
158
+ });
159
+
160
+ printValue(
161
+ io,
162
+ context.outputMode,
163
+ context.outputMode === "json"
164
+ ? post
165
+ : {
166
+ id: post.id,
167
+ source: post.attributes.source,
168
+ body: post.attributes.body,
169
+ },
170
+ );
171
+ return;
172
+ }
173
+
174
+ case "shared-get": {
175
+ const post = await context.client.getSharedPost(
176
+ requiredPositional(args.positionals, 1, "Missing share token"),
177
+ );
178
+
179
+ printValue(
180
+ io,
181
+ context.outputMode,
182
+ context.outputMode === "json"
183
+ ? post
184
+ : {
185
+ id: post.id,
186
+ source: post.attributes.source,
187
+ body: post.attributes.body,
188
+ },
189
+ );
190
+ return;
191
+ }
192
+
193
+ case "share": {
194
+ const response = await context.client.sharePost(
195
+ requiredPositional(args.positionals, 1, "Missing post id"),
196
+ );
197
+
198
+ if (booleanFlag(args.flags, "tokenOnly")) {
199
+ io.stdout(response.token);
200
+ return;
201
+ }
202
+
203
+ printValue(io, context.outputMode, response);
204
+ return;
205
+ }
206
+
207
+ case "revoke-share": {
208
+ const response = await context.client.revokePostShare(
209
+ requiredPositional(args.positionals, 1, "Missing post id"),
210
+ );
211
+
212
+ printValue(io, context.outputMode, response);
213
+ return;
214
+ }
215
+
137
216
  default:
138
217
  throw new CliError("Unknown post subcommand", 2);
139
218
  }
140
219
  }
220
+
221
+ function printPostCollection(
222
+ outputMode: "json" | "table",
223
+ io: Io,
224
+ response: {
225
+ items: Array<{ id: string; attributes: PostAttributes }>;
226
+ nextCursor?: string;
227
+ },
228
+ ): void {
229
+ if (outputMode === "json") {
230
+ printJson(io, {
231
+ items: response.items,
232
+ nextCursor: response.nextCursor,
233
+ });
234
+ return;
235
+ }
236
+
237
+ printValue(
238
+ io,
239
+ outputMode,
240
+ response.items.map((item) => ({
241
+ id: item.id,
242
+ source: item.attributes.source,
243
+ date: item.attributes.updated_at ?? item.attributes.inserted_at ?? "",
244
+ body: item.attributes.body,
245
+ })),
246
+ );
247
+ }
@@ -0,0 +1,52 @@
1
+ import { requiredPositional, type ParsedArgs } from "../lib/args";
2
+ import { createCommandContext } from "../lib/context";
3
+ import { CliError } from "../lib/errors";
4
+ import { printValue, type Io } from "../lib/output";
5
+
6
+ export async function runUserCommand(args: ParsedArgs, io: Io): Promise<void> {
7
+ const subcommand = args.positionals[0];
8
+ const context = await createCommandContext(args, io);
9
+
10
+ switch (subcommand) {
11
+ case "get": {
12
+ const user = await context.client.getUserByPublicHandle(
13
+ requiredPositional(args.positionals, 1, "Missing public handle"),
14
+ );
15
+
16
+ printValue(
17
+ io,
18
+ context.outputMode,
19
+ context.outputMode === "json"
20
+ ? user
21
+ : {
22
+ id: user.id,
23
+ email: user.attributes.email,
24
+ publicHandle: user.attributes.public_handle ?? "",
25
+ },
26
+ );
27
+ return;
28
+ }
29
+
30
+ case "claim-handle": {
31
+ const user = await context.client.claimPublicHandle(
32
+ requiredPositional(args.positionals, 1, "Missing public handle"),
33
+ );
34
+
35
+ printValue(
36
+ io,
37
+ context.outputMode,
38
+ context.outputMode === "json"
39
+ ? user
40
+ : {
41
+ id: user.id,
42
+ email: user.attributes.email,
43
+ publicHandle: user.attributes.public_handle ?? "",
44
+ },
45
+ );
46
+ return;
47
+ }
48
+
49
+ default:
50
+ throw new CliError("Unknown user subcommand", 2);
51
+ }
52
+ }
package/src/lib/args.ts CHANGED
@@ -15,6 +15,7 @@ const CLI_OPTIONS = {
15
15
  "master-token": { type: "string" },
16
16
  readOnlyToken: { type: "string" },
17
17
  "read-only-token": { type: "string" },
18
+ scope: { type: "string" },
18
19
  name: { type: "string" },
19
20
  description: { type: "string" },
20
21
  save: { type: "boolean" },
package/src/lib/client.ts CHANGED
@@ -2,24 +2,48 @@ import { expectCollection, expectResource } from "./json_api";
2
2
  import { requestJson, requestJsonApi, type RequestOptions } from "./http";
3
3
  import { CliError } from "./errors";
4
4
  import {
5
- requireOwnerReadToken,
6
5
  requireMasterToken,
6
+ requireOwnerReadToken,
7
7
  resolveChannelActorOrMasterToken,
8
8
  resolveMasterToken,
9
9
  resolveOwnerReadToken,
10
10
  resolvePublishToken,
11
11
  } from "./tokens";
12
12
  import type {
13
- WhoamiResponse,
13
+ AccessKeyAttributes,
14
+ AccessKeyIssueResponse,
15
+ AccessKeyRevokeResponse,
16
+ AccessKeyScope,
14
17
  ChannelAttributes,
18
+ ChannelDiagnosticsResponse,
19
+ ChannelKeyAttributes,
20
+ ChannelKeyIssueResponse,
21
+ ChannelKeyRevokeResponse,
22
+ ChannelPublicationResponse,
23
+ IdResponse,
15
24
  PostAttributes,
16
25
  ProfileConfig,
17
- RotateTokenResponse,
26
+ ShareTokenResponse,
27
+ UserAttributes,
28
+ WhoamiResponse,
18
29
  } from "../types/api";
19
30
 
20
31
  export class ClankmatesClient {
21
32
  constructor(private readonly profile: ProfileConfig) {}
22
33
 
34
+ async canAuthenticate(token: string): Promise<boolean> {
35
+ try {
36
+ await this.whoami(token);
37
+ return true;
38
+ } catch (error) {
39
+ if (isUnauthorizedCliError(error)) {
40
+ return false;
41
+ }
42
+
43
+ throw error;
44
+ }
45
+ }
46
+
23
47
  async validateMasterToken(token: string): Promise<void> {
24
48
  const response = await this.whoami(token);
25
49
 
@@ -54,12 +78,76 @@ export class ClankmatesClient {
54
78
  ).data;
55
79
  }
56
80
 
57
- async listChannels() {
58
- return this.requestCollection<ChannelAttributes>(`${API_PREFIX}/channels`, {
81
+ async listAccessKeys(scope?: AccessKeyScope) {
82
+ const path = scope
83
+ ? `${API_PREFIX}/me/access-keys/scopes/${encodeURIComponent(scope)}`
84
+ : `${API_PREFIX}/me/access-keys`;
85
+
86
+ return this.requestCollection<AccessKeyAttributes>(path, {
59
87
  token: requireOwnerReadToken(this.profile),
60
88
  });
61
89
  }
62
90
 
91
+ async issueAccessKey(input: { scope: AccessKeyScope; name: string }) {
92
+ return this.requestAction<AccessKeyIssueResponse>(
93
+ `${API_PREFIX}/me/access-keys`,
94
+ {
95
+ method: "POST",
96
+ token: requireMasterToken(this.profile),
97
+ body: {
98
+ data: {
99
+ scope: input.scope,
100
+ name: input.name,
101
+ },
102
+ },
103
+ },
104
+ );
105
+ }
106
+
107
+ async revokeAccessKey(id: string) {
108
+ return this.requestAction<AccessKeyRevokeResponse>(
109
+ `${API_PREFIX}/me/access-keys/${id}`,
110
+ {
111
+ method: "DELETE",
112
+ token: requireMasterToken(this.profile),
113
+ },
114
+ );
115
+ }
116
+
117
+ async claimPublicHandle(publicHandle: string) {
118
+ return this.requestResource<UserAttributes>(`${API_PREFIX}/me/public-handle`, {
119
+ method: "PATCH",
120
+ token: requireMasterToken(this.profile),
121
+ body: {
122
+ data: {
123
+ type: "user",
124
+ attributes: {
125
+ public_handle: publicHandle,
126
+ },
127
+ },
128
+ },
129
+ });
130
+ }
131
+
132
+ async getUserByPublicHandle(publicHandle: string) {
133
+ return this.requestResource<UserAttributes>(
134
+ `${API_PREFIX}/public/users/${encodeURIComponent(publicHandle)}`,
135
+ {},
136
+ );
137
+ }
138
+
139
+ async listChannels(input: { limit?: number; cursor?: string } = {}) {
140
+ return this.requestCollection<ChannelAttributes>(
141
+ withQuery(`${API_PREFIX}/channels`, {
142
+ "page[limit]": input.limit,
143
+ "page[after]": input.cursor,
144
+ }),
145
+ {
146
+ token: requireOwnerReadToken(this.profile),
147
+ },
148
+ );
149
+ }
150
+
63
151
  async getChannel(channelId: string) {
64
152
  return this.requestResource<ChannelAttributes>(
65
153
  `${API_PREFIX}/channels/${channelId}`,
@@ -69,6 +157,15 @@ export class ClankmatesClient {
69
157
  );
70
158
  }
71
159
 
160
+ async getChannelDiagnostics(channelId: string) {
161
+ return this.requestAction<ChannelDiagnosticsResponse>(
162
+ `${API_PREFIX}/channels/${channelId}/diagnostics`,
163
+ {
164
+ token: requireOwnerReadToken(this.profile),
165
+ },
166
+ );
167
+ }
168
+
72
169
  async getChannelByName(channelName: string) {
73
170
  return this.requestResource<ChannelAttributes>(
74
171
  `${API_PREFIX}/channels/by-name/${encodeURIComponent(channelName)}`,
@@ -78,6 +175,37 @@ export class ClankmatesClient {
78
175
  );
79
176
  }
80
177
 
178
+ async getPublicChannelByHandle(handle: string, name: string) {
179
+ return this.requestResource<ChannelAttributes>(
180
+ `${API_PREFIX}/public/users/${encodeURIComponent(handle)}/channels/${encodeURIComponent(name)}`,
181
+ {},
182
+ );
183
+ }
184
+
185
+ async listPublicChannelsForHandle(input: {
186
+ handle: string;
187
+ limit?: number;
188
+ cursor?: string;
189
+ }) {
190
+ return this.requestCollection<ChannelAttributes>(
191
+ withQuery(
192
+ `${API_PREFIX}/public/users/${encodeURIComponent(input.handle)}/channels`,
193
+ {
194
+ "page[limit]": input.limit,
195
+ "page[after]": input.cursor,
196
+ },
197
+ ),
198
+ {},
199
+ );
200
+ }
201
+
202
+ async getSharedChannel(token: string) {
203
+ return this.requestResource<ChannelAttributes>(
204
+ `${API_PREFIX}/shares/channels/${encodeURIComponent(token)}`,
205
+ {},
206
+ );
207
+ }
208
+
81
209
  async createChannel(input: { name: string; description?: string }) {
82
210
  return this.requestResource<ChannelAttributes>(`${API_PREFIX}/channels`, {
83
211
  method: "POST",
@@ -120,6 +248,50 @@ export class ClankmatesClient {
120
248
  );
121
249
  }
122
250
 
251
+ async publishChannelPublicly(channelId: string) {
252
+ return this.requestResource<ChannelAttributes>(
253
+ `${API_PREFIX}/channels/${channelId}/publication`,
254
+ {
255
+ method: "PATCH",
256
+ token: requireMasterToken(this.profile),
257
+ body: {
258
+ data: {
259
+ type: "channel",
260
+ id: channelId,
261
+ },
262
+ },
263
+ },
264
+ );
265
+ }
266
+
267
+ async unpublishChannelPublicly(channelId: string) {
268
+ return this.requestAction<ChannelPublicationResponse>(
269
+ `${API_PREFIX}/channels/${channelId}/publication`,
270
+ {
271
+ method: "DELETE",
272
+ token: requireMasterToken(this.profile),
273
+ },
274
+ );
275
+ }
276
+
277
+ async shareChannel(channelId: string) {
278
+ return this.requestAction<ShareTokenResponse>(
279
+ `${API_PREFIX}/channels/${channelId}/share`,
280
+ {
281
+ method: "POST",
282
+ token: requireMasterToken(this.profile),
283
+ body: { data: {} },
284
+ },
285
+ );
286
+ }
287
+
288
+ async revokeChannelShare(channelId: string) {
289
+ return this.requestAction<IdResponse>(`${API_PREFIX}/channels/${channelId}/share`, {
290
+ method: "DELETE",
291
+ token: requireMasterToken(this.profile),
292
+ });
293
+ }
294
+
123
295
  async deleteChannel(channelId: string): Promise<void> {
124
296
  await this.requestJsonApi(`${API_PREFIX}/channels/${channelId}`, {
125
297
  method: "DELETE",
@@ -127,17 +299,45 @@ export class ClankmatesClient {
127
299
  });
128
300
  }
129
301
 
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: {},
302
+ async listChannelKeys(input: {
303
+ channelId: string;
304
+ limit?: number;
305
+ cursor?: string;
306
+ }) {
307
+ return this.requestCollection<ChannelKeyAttributes>(
308
+ withQuery(`${API_PREFIX}/channels/${input.channelId}/tokens`, {
309
+ "page[limit]": input.limit,
310
+ "page[after]": input.cursor,
311
+ }),
312
+ {
313
+ token: requireOwnerReadToken(this.profile),
314
+ },
315
+ );
316
+ }
317
+
318
+ async issueChannelKey(input: { channelId: string; name: string }) {
319
+ return this.requestAction<ChannelKeyIssueResponse>(
320
+ `${API_PREFIX}/channels/${input.channelId}/tokens`,
321
+ {
322
+ method: "POST",
323
+ token: requireMasterToken(this.profile),
324
+ body: {
325
+ data: {
326
+ name: input.name,
327
+ },
138
328
  },
139
- )
140
- ).data;
329
+ },
330
+ );
331
+ }
332
+
333
+ async revokeChannelKey(id: string) {
334
+ return this.requestAction<ChannelKeyRevokeResponse>(
335
+ `${API_PREFIX}/channel-keys/${id}`,
336
+ {
337
+ method: "DELETE",
338
+ token: requireMasterToken(this.profile),
339
+ },
340
+ );
141
341
  }
142
342
 
143
343
  async publishPost(input: {
@@ -190,6 +390,38 @@ export class ClankmatesClient {
190
390
  );
191
391
  }
192
392
 
393
+ async listPublicChannelPosts(input: {
394
+ handle: string;
395
+ channelName: string;
396
+ limit?: number;
397
+ cursor?: string;
398
+ }) {
399
+ return this.requestCollection<PostAttributes>(
400
+ withQuery(
401
+ `${API_PREFIX}/public/users/${encodeURIComponent(input.handle)}/channels/${encodeURIComponent(input.channelName)}/posts`,
402
+ {
403
+ "page[limit]": input.limit,
404
+ "page[after]": input.cursor,
405
+ },
406
+ ),
407
+ {},
408
+ );
409
+ }
410
+
411
+ async listSharedChannelPosts(input: {
412
+ token: string;
413
+ limit?: number;
414
+ cursor?: string;
415
+ }) {
416
+ return this.requestCollection<PostAttributes>(
417
+ withQuery(`${API_PREFIX}/shares/channels/${encodeURIComponent(input.token)}/posts`, {
418
+ "page[limit]": input.limit,
419
+ "page[after]": input.cursor,
420
+ }),
421
+ {},
422
+ );
423
+ }
424
+
193
425
  async getPost(postId: string) {
194
426
  return this.requestResource<PostAttributes>(
195
427
  `${API_PREFIX}/posts/${postId}`,
@@ -199,6 +431,24 @@ export class ClankmatesClient {
199
431
  );
200
432
  }
201
433
 
434
+ async getPublicPostByHandle(input: {
435
+ handle: string;
436
+ channelName: string;
437
+ postId: string;
438
+ }) {
439
+ return this.requestResource<PostAttributes>(
440
+ `${API_PREFIX}/public/users/${encodeURIComponent(input.handle)}/channels/${encodeURIComponent(input.channelName)}/posts/${input.postId}`,
441
+ {},
442
+ );
443
+ }
444
+
445
+ async getSharedPost(token: string) {
446
+ return this.requestResource<PostAttributes>(
447
+ `${API_PREFIX}/shares/posts/${encodeURIComponent(token)}`,
448
+ {},
449
+ );
450
+ }
451
+
202
452
  async editPost(input: {
203
453
  postId: string;
204
454
  body: string;
@@ -236,6 +486,21 @@ export class ClankmatesClient {
236
486
  });
237
487
  }
238
488
 
489
+ async sharePost(postId: string) {
490
+ return this.requestAction<ShareTokenResponse>(`${API_PREFIX}/posts/${postId}/share`, {
491
+ method: "POST",
492
+ token: requireMasterToken(this.profile),
493
+ body: { data: {} },
494
+ });
495
+ }
496
+
497
+ async revokePostShare(postId: string) {
498
+ return this.requestAction<IdResponse>(`${API_PREFIX}/posts/${postId}/share`, {
499
+ method: "DELETE",
500
+ token: requireMasterToken(this.profile),
501
+ });
502
+ }
503
+
239
504
  async myFeed(input: { channelId?: string; limit?: number; cursor?: string }) {
240
505
  return this.requestCollection<PostAttributes>(
241
506
  withQuery(`${API_PREFIX}/feeds/my`, {
@@ -291,6 +556,28 @@ export class ClankmatesClient {
291
556
  ).data;
292
557
  }
293
558
 
559
+ async resolveChannelId(channel: string): Promise<string> {
560
+ if (looksLikeUuid(channel)) {
561
+ return channel;
562
+ }
563
+
564
+ return (await this.resolveOwnedChannel(channel)).id;
565
+ }
566
+
567
+ async resolveOwnedChannel(channel: string) {
568
+ if (looksLikeUuid(channel)) {
569
+ return this.getChannel(channel);
570
+ }
571
+
572
+ if (!resolveOwnerReadToken(this.profile).token) {
573
+ throw new CliError(
574
+ `Resolving channel name "${channel}" requires an owner read token. Use the channel UUID or configure a read-only or master token.`,
575
+ );
576
+ }
577
+
578
+ return this.getChannelByName(channel);
579
+ }
580
+
294
581
  private async requestResource<TAttributes extends object>(
295
582
  path: string,
296
583
  options: RequestOptions,
@@ -309,33 +596,18 @@ export class ClankmatesClient {
309
596
  );
310
597
  }
311
598
 
312
- private async requestJsonApi<T = unknown>(
599
+ private async requestAction<T = unknown>(
313
600
  path: string,
314
601
  options: RequestOptions = {},
315
602
  ) {
316
- return requestJsonApi<T>(this.profile.baseUrl, path, options);
317
- }
318
-
319
- async resolveChannelId(channel: string): Promise<string> {
320
- if (looksLikeUuid(channel)) {
321
- return channel;
322
- }
323
-
324
- return (await this.resolveOwnedChannel(channel)).id;
603
+ return (await this.requestJsonApi<T>(path, options)).data;
325
604
  }
326
605
 
327
- async resolveOwnedChannel(channel: string) {
328
- if (looksLikeUuid(channel)) {
329
- return this.getChannel(channel);
330
- }
331
-
332
- if (!resolveOwnerReadToken(this.profile).token) {
333
- throw new CliError(
334
- `Resolving channel name "${channel}" requires an owner read token. Use the channel UUID or configure a read-only or master token.`,
335
- );
336
- }
337
-
338
- return this.getChannelByName(channel);
606
+ private async requestJsonApi<T = unknown>(
607
+ path: string,
608
+ options: RequestOptions = {},
609
+ ) {
610
+ return requestJsonApi<T>(this.profile.baseUrl, path, options);
339
611
  }
340
612
 
341
613
  private resolvePostLifecycleToken(explicitToken?: string): string {
@@ -364,6 +636,15 @@ function inferMediaType(path: string): string {
364
636
  : "application/vnd.api+json";
365
637
  }
366
638
 
639
+ function isUnauthorizedCliError(error: unknown): boolean {
640
+ return (
641
+ error instanceof CliError &&
642
+ error.exitCode === 1 &&
643
+ (error.message.includes("Authentication required") ||
644
+ error.message.includes("code=unauthorized"))
645
+ );
646
+ }
647
+
367
648
  function isPlainJsonPath(path: string): boolean {
368
649
  return (
369
650
  path === `${API_PREFIX}/open_api` || path.startsWith(`${API_PREFIX}/auth/`)