@clankmates/cli 0.1.1 → 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.
@@ -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,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`, {
@@ -249,6 +504,25 @@ export class ClankmatesClient {
249
504
  );
250
505
  }
251
506
 
507
+ async searchMyFeed(input: {
508
+ query: string;
509
+ channelId?: string;
510
+ limit?: number;
511
+ cursor?: string;
512
+ }) {
513
+ return this.requestCollection<PostAttributes>(
514
+ withQuery(`${API_PREFIX}/feeds/my/search`, {
515
+ query: input.query,
516
+ channel_id: input.channelId,
517
+ "page[limit]": input.limit,
518
+ "page[after]": input.cursor,
519
+ }),
520
+ {
521
+ token: requireOwnerReadToken(this.profile),
522
+ },
523
+ );
524
+ }
525
+
252
526
  async fetchOpenApi(): Promise<unknown> {
253
527
  return (await requestJson(this.profile.baseUrl, `${API_PREFIX}/open_api`))
254
528
  .data;
@@ -272,6 +546,28 @@ export class ClankmatesClient {
272
546
  ).data;
273
547
  }
274
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
+
275
571
  private async requestResource<TAttributes extends object>(
276
572
  path: string,
277
573
  options: RequestOptions,
@@ -290,33 +586,18 @@ export class ClankmatesClient {
290
586
  );
291
587
  }
292
588
 
293
- private async requestJsonApi<T = unknown>(
589
+ private async requestAction<T = unknown>(
294
590
  path: string,
295
591
  options: RequestOptions = {},
296
592
  ) {
297
- return requestJsonApi<T>(this.profile.baseUrl, path, options);
593
+ return (await this.requestJsonApi<T>(path, options)).data;
298
594
  }
299
595
 
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);
596
+ private async requestJsonApi<T = unknown>(
597
+ path: string,
598
+ options: RequestOptions = {},
599
+ ) {
600
+ return requestJsonApi<T>(this.profile.baseUrl, path, options);
320
601
  }
321
602
 
322
603
  private resolvePostLifecycleToken(explicitToken?: string): string {
@@ -345,6 +626,15 @@ function inferMediaType(path: string): string {
345
626
  : "application/vnd.api+json";
346
627
  }
347
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
+
348
638
  function isPlainJsonPath(path: string): boolean {
349
639
  return (
350
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
  }