@clankmates/cli 0.2.0 → 0.3.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/src/lib/client.ts CHANGED
@@ -2,24 +2,47 @@ 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
+ ChannelKeyAttributes,
19
+ ChannelKeyIssueResponse,
20
+ ChannelKeyRevokeResponse,
21
+ ChannelPublicationResponse,
22
+ IdResponse,
15
23
  PostAttributes,
16
24
  ProfileConfig,
17
- RotateTokenResponse,
25
+ ShareTokenResponse,
26
+ UserAttributes,
27
+ WhoamiResponse,
18
28
  } from "../types/api";
19
29
 
20
30
  export class ClankmatesClient {
21
31
  constructor(private readonly profile: ProfileConfig) {}
22
32
 
33
+ async canAuthenticate(token: string): Promise<boolean> {
34
+ try {
35
+ await this.whoami(token);
36
+ return true;
37
+ } catch (error) {
38
+ if (isUnauthorizedCliError(error)) {
39
+ return false;
40
+ }
41
+
42
+ throw error;
43
+ }
44
+ }
45
+
23
46
  async validateMasterToken(token: string): Promise<void> {
24
47
  const response = await this.whoami(token);
25
48
 
@@ -54,12 +77,76 @@ export class ClankmatesClient {
54
77
  ).data;
55
78
  }
56
79
 
57
- async listChannels() {
58
- return this.requestCollection<ChannelAttributes>(`${API_PREFIX}/channels`, {
80
+ async listAccessKeys(scope?: AccessKeyScope) {
81
+ const path = scope
82
+ ? `${API_PREFIX}/me/access-keys/scopes/${encodeURIComponent(scope)}`
83
+ : `${API_PREFIX}/me/access-keys`;
84
+
85
+ return this.requestCollection<AccessKeyAttributes>(path, {
59
86
  token: requireOwnerReadToken(this.profile),
60
87
  });
61
88
  }
62
89
 
90
+ async issueAccessKey(input: { scope: AccessKeyScope; name: string }) {
91
+ return this.requestAction<AccessKeyIssueResponse>(
92
+ `${API_PREFIX}/me/access-keys`,
93
+ {
94
+ method: "POST",
95
+ token: requireMasterToken(this.profile),
96
+ body: {
97
+ data: {
98
+ scope: input.scope,
99
+ name: input.name,
100
+ },
101
+ },
102
+ },
103
+ );
104
+ }
105
+
106
+ async revokeAccessKey(id: string) {
107
+ return this.requestAction<AccessKeyRevokeResponse>(
108
+ `${API_PREFIX}/me/access-keys/${id}`,
109
+ {
110
+ method: "DELETE",
111
+ token: requireMasterToken(this.profile),
112
+ },
113
+ );
114
+ }
115
+
116
+ async claimPublicHandle(publicHandle: string) {
117
+ return this.requestResource<UserAttributes>(`${API_PREFIX}/me/public-handle`, {
118
+ method: "PATCH",
119
+ token: requireMasterToken(this.profile),
120
+ body: {
121
+ data: {
122
+ type: "user",
123
+ attributes: {
124
+ public_handle: publicHandle,
125
+ },
126
+ },
127
+ },
128
+ });
129
+ }
130
+
131
+ async getUserByPublicHandle(publicHandle: string) {
132
+ return this.requestResource<UserAttributes>(
133
+ `${API_PREFIX}/public/users/${encodeURIComponent(publicHandle)}`,
134
+ {},
135
+ );
136
+ }
137
+
138
+ async listChannels(input: { limit?: number; cursor?: string } = {}) {
139
+ return this.requestCollection<ChannelAttributes>(
140
+ withQuery(`${API_PREFIX}/channels`, {
141
+ "page[limit]": input.limit,
142
+ "page[after]": input.cursor,
143
+ }),
144
+ {
145
+ token: requireOwnerReadToken(this.profile),
146
+ },
147
+ );
148
+ }
149
+
63
150
  async getChannel(channelId: string) {
64
151
  return this.requestResource<ChannelAttributes>(
65
152
  `${API_PREFIX}/channels/${channelId}`,
@@ -78,6 +165,37 @@ export class ClankmatesClient {
78
165
  );
79
166
  }
80
167
 
168
+ async getPublicChannelByHandle(handle: string, name: string) {
169
+ return this.requestResource<ChannelAttributes>(
170
+ `${API_PREFIX}/public/users/${encodeURIComponent(handle)}/channels/${encodeURIComponent(name)}`,
171
+ {},
172
+ );
173
+ }
174
+
175
+ async listPublicChannelsForHandle(input: {
176
+ handle: string;
177
+ limit?: number;
178
+ cursor?: string;
179
+ }) {
180
+ return this.requestCollection<ChannelAttributes>(
181
+ withQuery(
182
+ `${API_PREFIX}/public/users/${encodeURIComponent(input.handle)}/channels`,
183
+ {
184
+ "page[limit]": input.limit,
185
+ "page[after]": input.cursor,
186
+ },
187
+ ),
188
+ {},
189
+ );
190
+ }
191
+
192
+ async getSharedChannel(token: string) {
193
+ return this.requestResource<ChannelAttributes>(
194
+ `${API_PREFIX}/shares/channels/${encodeURIComponent(token)}`,
195
+ {},
196
+ );
197
+ }
198
+
81
199
  async createChannel(input: { name: string; description?: string }) {
82
200
  return this.requestResource<ChannelAttributes>(`${API_PREFIX}/channels`, {
83
201
  method: "POST",
@@ -120,6 +238,50 @@ export class ClankmatesClient {
120
238
  );
121
239
  }
122
240
 
241
+ async publishChannelPublicly(channelId: string) {
242
+ return this.requestResource<ChannelAttributes>(
243
+ `${API_PREFIX}/channels/${channelId}/publication`,
244
+ {
245
+ method: "PATCH",
246
+ token: requireMasterToken(this.profile),
247
+ body: {
248
+ data: {
249
+ type: "channel",
250
+ id: channelId,
251
+ },
252
+ },
253
+ },
254
+ );
255
+ }
256
+
257
+ async unpublishChannelPublicly(channelId: string) {
258
+ return this.requestAction<ChannelPublicationResponse>(
259
+ `${API_PREFIX}/channels/${channelId}/publication`,
260
+ {
261
+ method: "DELETE",
262
+ token: requireMasterToken(this.profile),
263
+ },
264
+ );
265
+ }
266
+
267
+ async shareChannel(channelId: string) {
268
+ return this.requestAction<ShareTokenResponse>(
269
+ `${API_PREFIX}/channels/${channelId}/share`,
270
+ {
271
+ method: "POST",
272
+ token: requireMasterToken(this.profile),
273
+ body: { data: {} },
274
+ },
275
+ );
276
+ }
277
+
278
+ async revokeChannelShare(channelId: string) {
279
+ return this.requestAction<IdResponse>(`${API_PREFIX}/channels/${channelId}/share`, {
280
+ method: "DELETE",
281
+ token: requireMasterToken(this.profile),
282
+ });
283
+ }
284
+
123
285
  async deleteChannel(channelId: string): Promise<void> {
124
286
  await this.requestJsonApi(`${API_PREFIX}/channels/${channelId}`, {
125
287
  method: "DELETE",
@@ -127,17 +289,45 @@ export class ClankmatesClient {
127
289
  });
128
290
  }
129
291
 
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: {},
292
+ async listChannelKeys(input: {
293
+ channelId: string;
294
+ limit?: number;
295
+ cursor?: string;
296
+ }) {
297
+ return this.requestCollection<ChannelKeyAttributes>(
298
+ withQuery(`${API_PREFIX}/channels/${input.channelId}/tokens`, {
299
+ "page[limit]": input.limit,
300
+ "page[after]": input.cursor,
301
+ }),
302
+ {
303
+ token: requireOwnerReadToken(this.profile),
304
+ },
305
+ );
306
+ }
307
+
308
+ async issueChannelKey(input: { channelId: string; name: string }) {
309
+ return this.requestAction<ChannelKeyIssueResponse>(
310
+ `${API_PREFIX}/channels/${input.channelId}/tokens`,
311
+ {
312
+ method: "POST",
313
+ token: requireMasterToken(this.profile),
314
+ body: {
315
+ data: {
316
+ name: input.name,
317
+ },
138
318
  },
139
- )
140
- ).data;
319
+ },
320
+ );
321
+ }
322
+
323
+ async revokeChannelKey(id: string) {
324
+ return this.requestAction<ChannelKeyRevokeResponse>(
325
+ `${API_PREFIX}/channel-keys/${id}`,
326
+ {
327
+ method: "DELETE",
328
+ token: requireMasterToken(this.profile),
329
+ },
330
+ );
141
331
  }
142
332
 
143
333
  async publishPost(input: {
@@ -190,6 +380,38 @@ export class ClankmatesClient {
190
380
  );
191
381
  }
192
382
 
383
+ async listPublicChannelPosts(input: {
384
+ handle: string;
385
+ channelName: string;
386
+ limit?: number;
387
+ cursor?: string;
388
+ }) {
389
+ return this.requestCollection<PostAttributes>(
390
+ withQuery(
391
+ `${API_PREFIX}/public/users/${encodeURIComponent(input.handle)}/channels/${encodeURIComponent(input.channelName)}/posts`,
392
+ {
393
+ "page[limit]": input.limit,
394
+ "page[after]": input.cursor,
395
+ },
396
+ ),
397
+ {},
398
+ );
399
+ }
400
+
401
+ async listSharedChannelPosts(input: {
402
+ token: string;
403
+ limit?: number;
404
+ cursor?: string;
405
+ }) {
406
+ return this.requestCollection<PostAttributes>(
407
+ withQuery(`${API_PREFIX}/shares/channels/${encodeURIComponent(input.token)}/posts`, {
408
+ "page[limit]": input.limit,
409
+ "page[after]": input.cursor,
410
+ }),
411
+ {},
412
+ );
413
+ }
414
+
193
415
  async getPost(postId: string) {
194
416
  return this.requestResource<PostAttributes>(
195
417
  `${API_PREFIX}/posts/${postId}`,
@@ -199,6 +421,24 @@ export class ClankmatesClient {
199
421
  );
200
422
  }
201
423
 
424
+ async getPublicPostByHandle(input: {
425
+ handle: string;
426
+ channelName: string;
427
+ postId: string;
428
+ }) {
429
+ return this.requestResource<PostAttributes>(
430
+ `${API_PREFIX}/public/users/${encodeURIComponent(input.handle)}/channels/${encodeURIComponent(input.channelName)}/posts/${input.postId}`,
431
+ {},
432
+ );
433
+ }
434
+
435
+ async getSharedPost(token: string) {
436
+ return this.requestResource<PostAttributes>(
437
+ `${API_PREFIX}/shares/posts/${encodeURIComponent(token)}`,
438
+ {},
439
+ );
440
+ }
441
+
202
442
  async editPost(input: {
203
443
  postId: string;
204
444
  body: string;
@@ -236,6 +476,21 @@ export class ClankmatesClient {
236
476
  });
237
477
  }
238
478
 
479
+ async sharePost(postId: string) {
480
+ return this.requestAction<ShareTokenResponse>(`${API_PREFIX}/posts/${postId}/share`, {
481
+ method: "POST",
482
+ token: requireMasterToken(this.profile),
483
+ body: { data: {} },
484
+ });
485
+ }
486
+
487
+ async revokePostShare(postId: string) {
488
+ return this.requestAction<IdResponse>(`${API_PREFIX}/posts/${postId}/share`, {
489
+ method: "DELETE",
490
+ token: requireMasterToken(this.profile),
491
+ });
492
+ }
493
+
239
494
  async myFeed(input: { channelId?: string; limit?: number; cursor?: string }) {
240
495
  return this.requestCollection<PostAttributes>(
241
496
  withQuery(`${API_PREFIX}/feeds/my`, {
@@ -291,6 +546,28 @@ export class ClankmatesClient {
291
546
  ).data;
292
547
  }
293
548
 
549
+ async resolveChannelId(channel: string): Promise<string> {
550
+ if (looksLikeUuid(channel)) {
551
+ return channel;
552
+ }
553
+
554
+ return (await this.resolveOwnedChannel(channel)).id;
555
+ }
556
+
557
+ async resolveOwnedChannel(channel: string) {
558
+ if (looksLikeUuid(channel)) {
559
+ return this.getChannel(channel);
560
+ }
561
+
562
+ if (!resolveOwnerReadToken(this.profile).token) {
563
+ throw new CliError(
564
+ `Resolving channel name "${channel}" requires an owner read token. Use the channel UUID or configure a read-only or master token.`,
565
+ );
566
+ }
567
+
568
+ return this.getChannelByName(channel);
569
+ }
570
+
294
571
  private async requestResource<TAttributes extends object>(
295
572
  path: string,
296
573
  options: RequestOptions,
@@ -309,33 +586,18 @@ export class ClankmatesClient {
309
586
  );
310
587
  }
311
588
 
312
- private async requestJsonApi<T = unknown>(
589
+ private async requestAction<T = unknown>(
313
590
  path: string,
314
591
  options: RequestOptions = {},
315
592
  ) {
316
- return requestJsonApi<T>(this.profile.baseUrl, path, options);
593
+ return (await this.requestJsonApi<T>(path, options)).data;
317
594
  }
318
595
 
319
- async resolveChannelId(channel: string): Promise<string> {
320
- if (looksLikeUuid(channel)) {
321
- return channel;
322
- }
323
-
324
- return (await this.resolveOwnedChannel(channel)).id;
325
- }
326
-
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);
596
+ private async requestJsonApi<T = unknown>(
597
+ path: string,
598
+ options: RequestOptions = {},
599
+ ) {
600
+ return requestJsonApi<T>(this.profile.baseUrl, path, options);
339
601
  }
340
602
 
341
603
  private resolvePostLifecycleToken(explicitToken?: string): string {
@@ -364,6 +626,15 @@ function inferMediaType(path: string): string {
364
626
  : "application/vnd.api+json";
365
627
  }
366
628
 
629
+ function isUnauthorizedCliError(error: unknown): boolean {
630
+ return (
631
+ error instanceof CliError &&
632
+ error.exitCode === 1 &&
633
+ (error.message.includes("Authentication required") ||
634
+ error.message.includes("code=unauthorized"))
635
+ );
636
+ }
637
+
367
638
  function isPlainJsonPath(path: string): boolean {
368
639
  return (
369
640
  path === `${API_PREFIX}/open_api` || path.startsWith(`${API_PREFIX}/auth/`)
package/src/types/api.ts CHANGED
@@ -41,10 +41,18 @@ export interface JsonApiDocument<TAttributes extends object> {
41
41
  meta?: Record<string, unknown>;
42
42
  }
43
43
 
44
+ export type AccessKeyScope = "master" | "read_only";
45
+
46
+ export interface UserAttributes {
47
+ email: string;
48
+ public_handle?: string | null;
49
+ }
50
+
44
51
  export interface ChannelAttributes {
45
52
  name: string;
46
53
  description?: string | null;
47
54
  visibility: string;
55
+ publicly_listed?: boolean;
48
56
  posting_paused_until?: string | null;
49
57
  inserted_at?: string;
50
58
  updated_at?: string;
@@ -57,11 +65,31 @@ export interface PostAttributes {
57
65
  updated_at?: string;
58
66
  }
59
67
 
68
+ export interface AccessKeyAttributes {
69
+ expires_at: string;
70
+ scope: AccessKeyScope;
71
+ name?: string | null;
72
+ inserted_at?: string;
73
+ updated_at?: string;
74
+ }
75
+
76
+ export interface ChannelKeyAttributes {
77
+ expires_at?: string | null;
78
+ name?: string | null;
79
+ revoked_at?: string | null;
80
+ inserted_at?: string;
81
+ updated_at?: string;
82
+ }
83
+
60
84
  export interface WhoamiUserActor {
61
85
  type: "user";
62
86
  id: string;
63
87
  email: string;
64
- scope?: "master" | "read_only" | null;
88
+ scope?: AccessKeyScope | null;
89
+ authenticated_via?: string;
90
+ public_handle?: string | null;
91
+ public_profile_path?: string | null;
92
+ public_profile_url?: string | null;
65
93
  }
66
94
 
67
95
  export interface WhoamiChannelActor {
@@ -69,6 +97,12 @@ export interface WhoamiChannelActor {
69
97
  id: string;
70
98
  name: string;
71
99
  visibility: string;
100
+ publicly_listed?: boolean;
101
+ posting_paused_until?: string | null;
102
+ owner_id?: string;
103
+ owner_public_handle?: string | null;
104
+ public_path?: string | null;
105
+ public_url?: string | null;
72
106
  }
73
107
 
74
108
  export type WhoamiActor = WhoamiUserActor | WhoamiChannelActor;
@@ -78,8 +112,44 @@ export interface WhoamiResponse {
78
112
  actor: WhoamiActor;
79
113
  }
80
114
 
81
- export interface RotateTokenResponse {
82
- channel_id: string;
115
+ export interface AccessKeyIssueResponse {
116
+ id: string;
117
+ scope: AccessKeyScope;
118
+ name?: string | null;
83
119
  token: string;
84
120
  issued_at: string;
121
+ expires_at: string;
122
+ }
123
+
124
+ export interface AccessKeyRevokeResponse {
125
+ id: string;
126
+ scope: AccessKeyScope;
127
+ name?: string | null;
128
+ }
129
+
130
+ export interface ChannelKeyIssueResponse {
131
+ id: string;
132
+ name?: string | null;
133
+ token: string;
134
+ issued_at: string;
135
+ expires_at?: string | null;
136
+ }
137
+
138
+ export interface ChannelKeyRevokeResponse {
139
+ id: string;
140
+ name?: string | null;
141
+ }
142
+
143
+ export interface ShareTokenResponse {
144
+ token: string;
145
+ }
146
+
147
+ export interface IdResponse {
148
+ id: string;
149
+ }
150
+
151
+ export interface ChannelPublicationResponse {
152
+ id: string;
153
+ name: string;
154
+ publicly_listed: boolean;
85
155
  }