@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,14 +1,20 @@
1
1
  import {
2
2
  booleanFlag,
3
+ integerFlag,
3
4
  requiredPositional,
4
5
  requiredStringFlag,
5
6
  stringFlag,
6
7
  type ParsedArgs,
7
8
  } from "../lib/args";
8
- import { storeChannelToken } from "../lib/config";
9
+ import { storeChannelToken, updateProfile } from "../lib/config";
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 {
14
+ ChannelAttributes,
15
+ ChannelKeyAttributes,
16
+ ChannelKeyIssueResponse,
17
+ } from "../types/api";
12
18
 
13
19
  const CHANNEL_NAME_PATTERN = /^[a-z0-9][a-z0-9_-]*$/;
14
20
  const CHANNEL_NAME_ERROR =
@@ -23,23 +29,12 @@ export async function runChannelCommand(
23
29
 
24
30
  switch (subcommand) {
25
31
  case "list": {
26
- const response = await context.client.listChannels();
27
- const rows = response.items.map((item) => ({
28
- id: item.id,
29
- name: item.attributes.name,
30
- visibility: item.attributes.visibility,
31
- description: item.attributes.description ?? "",
32
- }));
33
-
34
- if (context.outputMode === "json") {
35
- printJson(io, {
36
- items: response.items,
37
- nextCursor: response.nextCursor,
38
- });
39
- return;
40
- }
32
+ const response = await context.client.listChannels({
33
+ limit: integerFlag(args.flags, "limit", { label: "--limit" }),
34
+ cursor: stringFlag(args.flags, "cursor"),
35
+ });
41
36
 
42
- printValue(io, context.outputMode, rows);
37
+ printChannelCollection(context.outputMode, io, response);
43
38
  return;
44
39
  }
45
40
 
@@ -51,15 +46,45 @@ export async function runChannelCommand(
51
46
  printValue(
52
47
  io,
53
48
  context.outputMode,
54
- context.outputMode === "json"
55
- ? channel
56
- : {
57
- id: channel.id,
58
- name: channel.attributes.name,
59
- visibility: channel.attributes.visibility,
60
- description: channel.attributes.description ?? "",
61
- postingPausedUntil: channel.attributes.posting_paused_until ?? "",
62
- },
49
+ context.outputMode === "json" ? channel : formatChannelRecord(channel),
50
+ );
51
+ return;
52
+ }
53
+
54
+ case "public-list": {
55
+ const response = await context.client.listPublicChannelsForHandle({
56
+ handle: requiredPositional(args.positionals, 1, "Missing public handle"),
57
+ limit: integerFlag(args.flags, "limit", { label: "--limit" }),
58
+ cursor: stringFlag(args.flags, "cursor"),
59
+ });
60
+
61
+ printChannelCollection(context.outputMode, io, response);
62
+ return;
63
+ }
64
+
65
+ case "public-get": {
66
+ const channel = await context.client.getPublicChannelByHandle(
67
+ requiredPositional(args.positionals, 1, "Missing public handle"),
68
+ requiredPositional(args.positionals, 2, "Missing public channel name"),
69
+ );
70
+
71
+ printValue(
72
+ io,
73
+ context.outputMode,
74
+ context.outputMode === "json" ? channel : formatChannelRecord(channel),
75
+ );
76
+ return;
77
+ }
78
+
79
+ case "shared-get": {
80
+ const channel = await context.client.getSharedChannel(
81
+ requiredPositional(args.positionals, 1, "Missing share token"),
82
+ );
83
+
84
+ printValue(
85
+ io,
86
+ context.outputMode,
87
+ context.outputMode === "json" ? channel : formatChannelRecord(channel),
63
88
  );
64
89
  return;
65
90
  }
@@ -76,14 +101,7 @@ export async function runChannelCommand(
76
101
  printValue(
77
102
  io,
78
103
  context.outputMode,
79
- context.outputMode === "json"
80
- ? channel
81
- : {
82
- id: channel.id,
83
- name: channel.attributes.name,
84
- visibility: channel.attributes.visibility,
85
- description: channel.attributes.description ?? "",
86
- },
104
+ context.outputMode === "json" ? channel : formatChannelRecord(channel),
87
105
  );
88
106
  return;
89
107
  }
@@ -118,15 +136,57 @@ export async function runChannelCommand(
118
136
  printValue(
119
137
  io,
120
138
  context.outputMode,
121
- context.outputMode === "json"
122
- ? channel
123
- : {
124
- id: channel.id,
125
- name: channel.attributes.name,
126
- visibility: channel.attributes.visibility,
127
- description: channel.attributes.description ?? "",
128
- },
139
+ context.outputMode === "json" ? channel : formatChannelRecord(channel),
140
+ );
141
+ return;
142
+ }
143
+
144
+ case "publish-public": {
145
+ const channelId = await context.client.resolveChannelId(
146
+ requiredPositional(args.positionals, 1, "Missing channel"),
147
+ );
148
+ const channel = await context.client.publishChannelPublicly(channelId);
149
+
150
+ printValue(
151
+ io,
152
+ context.outputMode,
153
+ context.outputMode === "json" ? channel : formatChannelRecord(channel),
154
+ );
155
+ return;
156
+ }
157
+
158
+ case "unpublish-public": {
159
+ const channelId = await context.client.resolveChannelId(
160
+ requiredPositional(args.positionals, 1, "Missing channel"),
161
+ );
162
+ const response = await context.client.unpublishChannelPublicly(channelId);
163
+
164
+ printValue(io, context.outputMode, response);
165
+ return;
166
+ }
167
+
168
+ case "share": {
169
+ const channelId = await context.client.resolveChannelId(
170
+ requiredPositional(args.positionals, 1, "Missing channel"),
171
+ );
172
+ const response = await context.client.shareChannel(channelId);
173
+
174
+ if (booleanFlag(args.flags, "tokenOnly")) {
175
+ io.stdout(response.token);
176
+ return;
177
+ }
178
+
179
+ printValue(io, context.outputMode, response);
180
+ return;
181
+ }
182
+
183
+ case "revoke-share": {
184
+ const channelId = await context.client.resolveChannelId(
185
+ requiredPositional(args.positionals, 1, "Missing channel"),
129
186
  );
187
+ const response = await context.client.revokeChannelShare(channelId);
188
+
189
+ printValue(io, context.outputMode, response);
130
190
  return;
131
191
  }
132
192
 
@@ -146,21 +206,93 @@ export async function runChannelCommand(
146
206
  return;
147
207
  }
148
208
 
209
+ case "token": {
210
+ await runChannelTokenCommand(args, io);
211
+ return;
212
+ }
213
+
149
214
  case "rotate-token": {
215
+ const channelRef = requiredPositional(args.positionals, 1, "Missing channel");
216
+ const channelId = await context.client.resolveChannelId(channelRef);
217
+ const response = await context.client.issueChannelKey({
218
+ channelId,
219
+ name: stringFlag(args.flags, "name") ?? legacyKeyName(),
220
+ });
221
+
222
+ await maybeStoreChannelToken(
223
+ args,
224
+ response,
225
+ channelId,
226
+ context.profileName,
227
+ context.configPath,
228
+ );
229
+
230
+ if (booleanFlag(args.flags, "tokenOnly")) {
231
+ io.stdout(response.token);
232
+ return;
233
+ }
234
+
235
+ printValue(io, context.outputMode, response);
236
+ return;
237
+ }
238
+
239
+ default:
240
+ throw new CliError("Unknown channel subcommand", 2);
241
+ }
242
+ }
243
+
244
+ async function runChannelTokenCommand(
245
+ args: ParsedArgs,
246
+ io: Io,
247
+ ): Promise<void> {
248
+ const context = await createCommandContext(args, io);
249
+ const tokenCommand = requiredPositional(
250
+ args.positionals,
251
+ 1,
252
+ "Missing channel token subcommand",
253
+ );
254
+
255
+ switch (tokenCommand) {
256
+ case "list": {
150
257
  const channelId = await context.client.resolveChannelId(
151
- requiredPositional(args.positionals, 1, "Missing channel"),
258
+ requiredPositional(args.positionals, 2, "Missing channel"),
152
259
  );
153
- const response = await context.client.rotateChannelToken(channelId);
260
+ const response = await context.client.listChannelKeys({
261
+ channelId,
262
+ limit: integerFlag(args.flags, "limit", { label: "--limit" }),
263
+ cursor: stringFlag(args.flags, "cursor"),
264
+ });
154
265
 
155
- if (booleanFlag(args.flags, "save")) {
156
- await storeChannelToken(
157
- context.profileName,
158
- channelId,
159
- response.token,
160
- context.configPath,
161
- );
266
+ if (context.outputMode === "json") {
267
+ printJson(io, { items: response.items, nextCursor: response.nextCursor });
268
+ return;
162
269
  }
163
270
 
271
+ printValue(
272
+ io,
273
+ context.outputMode,
274
+ response.items.map((item) => formatChannelKeyRow(item)),
275
+ );
276
+ return;
277
+ }
278
+
279
+ case "issue": {
280
+ const channelId = await context.client.resolveChannelId(
281
+ requiredPositional(args.positionals, 2, "Missing channel"),
282
+ );
283
+ const response = await context.client.issueChannelKey({
284
+ channelId,
285
+ name: requiredStringFlag(args.flags, "name"),
286
+ });
287
+
288
+ await maybeStoreChannelToken(
289
+ args,
290
+ response,
291
+ channelId,
292
+ context.profileName,
293
+ context.configPath,
294
+ );
295
+
164
296
  if (booleanFlag(args.flags, "tokenOnly")) {
165
297
  io.stdout(response.token);
166
298
  return;
@@ -170,9 +302,127 @@ export async function runChannelCommand(
170
302
  return;
171
303
  }
172
304
 
305
+ case "revoke": {
306
+ const response = await context.client.revokeChannelKey(
307
+ requiredPositional(args.positionals, 2, "Missing channel key id"),
308
+ );
309
+ await pruneInvalidStoredChannelTokens(
310
+ context.profileName,
311
+ context.profile.channelTokens,
312
+ context.client,
313
+ context.configPath,
314
+ );
315
+
316
+ printValue(io, context.outputMode, response);
317
+ return;
318
+ }
319
+
173
320
  default:
174
- throw new CliError("Unknown channel subcommand", 2);
321
+ throw new CliError("Unknown channel token subcommand", 2);
322
+ }
323
+ }
324
+
325
+ async function maybeStoreChannelToken(
326
+ args: ParsedArgs,
327
+ response: ChannelKeyIssueResponse,
328
+ channelId: string,
329
+ profileName: string,
330
+ configPath: string,
331
+ ): Promise<void> {
332
+ if (!booleanFlag(args.flags, "save")) {
333
+ return;
334
+ }
335
+
336
+ await storeChannelToken(profileName, channelId, response.token, configPath);
337
+ }
338
+
339
+ function printChannelCollection(
340
+ outputMode: "json" | "table",
341
+ io: Io,
342
+ response: {
343
+ items: Array<{ id: string; attributes: ChannelAttributes }>;
344
+ nextCursor?: string;
345
+ },
346
+ ): void {
347
+ if (outputMode === "json") {
348
+ printJson(io, {
349
+ items: response.items,
350
+ nextCursor: response.nextCursor,
351
+ });
352
+ return;
175
353
  }
354
+
355
+ printValue(
356
+ io,
357
+ outputMode,
358
+ response.items.map((item) => formatChannelRow(item)),
359
+ );
360
+ }
361
+
362
+ function formatChannelRecord(channel: { id: string; attributes: ChannelAttributes }) {
363
+ return {
364
+ id: channel.id,
365
+ name: channel.attributes.name,
366
+ visibility: channel.attributes.visibility,
367
+ publiclyListed: channel.attributes.publicly_listed ?? false,
368
+ description: channel.attributes.description ?? "",
369
+ postingPausedUntil: channel.attributes.posting_paused_until ?? "",
370
+ insertedAt: channel.attributes.inserted_at ?? "",
371
+ updatedAt: channel.attributes.updated_at ?? "",
372
+ };
373
+ }
374
+
375
+ function formatChannelRow(channel: { id: string; attributes: ChannelAttributes }) {
376
+ return {
377
+ id: channel.id,
378
+ name: channel.attributes.name,
379
+ visibility: channel.attributes.visibility,
380
+ publiclyListed: channel.attributes.publicly_listed ?? false,
381
+ description: channel.attributes.description ?? "",
382
+ };
383
+ }
384
+
385
+ function formatChannelKeyRow(item: { id: string; attributes: ChannelKeyAttributes }) {
386
+ return {
387
+ id: item.id,
388
+ name: item.attributes.name ?? "",
389
+ expiresAt: item.attributes.expires_at ?? "",
390
+ revokedAt: item.attributes.revoked_at ?? "",
391
+ issuedAt: item.attributes.inserted_at ?? "",
392
+ };
393
+ }
394
+
395
+ function legacyKeyName(): string {
396
+ return `legacy-rotate-${new Date().toISOString()}`;
397
+ }
398
+
399
+ async function pruneInvalidStoredChannelTokens(
400
+ profileName: string,
401
+ storedTokens: Record<string, { token: string }>,
402
+ client: Awaited<ReturnType<typeof createCommandContext>>["client"],
403
+ configPath: string,
404
+ ): Promise<void> {
405
+ const invalidChannelIds: string[] = [];
406
+
407
+ for (const [channelId, storedToken] of Object.entries(storedTokens)) {
408
+ if (!(await client.canAuthenticate(storedToken.token))) {
409
+ invalidChannelIds.push(channelId);
410
+ }
411
+ }
412
+
413
+ if (invalidChannelIds.length === 0) {
414
+ return;
415
+ }
416
+
417
+ await updateProfile(
418
+ profileName,
419
+ (profile) => {
420
+ for (const channelId of invalidChannelIds) {
421
+ delete profile.channelTokens[channelId];
422
+ }
423
+ },
424
+ configPath,
425
+ );
176
426
  }
177
427
 
178
428
  function assertValidChannelName(name: string): void {
@@ -61,6 +61,13 @@ export async function runDoctorCommand(
61
61
  ),
62
62
  ]);
63
63
 
64
+ const channelDiagnostics = await maybeFetchChannelDiagnostics({
65
+ context,
66
+ requestedChannel,
67
+ channelResolution,
68
+ ownerReadReady: ownerReadTokenCheck.ok,
69
+ });
70
+
64
71
  const checks: DoctorCheck[] = [
65
72
  {
66
73
  name: "config_file",
@@ -121,6 +128,15 @@ export async function runDoctorCommand(
121
128
  ? "A publish-capable token is available for the requested channel."
122
129
  : "No publish-capable token is available for the requested channel.",
123
130
  });
131
+ checks.push({
132
+ name: "channel_diagnostics",
133
+ ok: channelDiagnostics.ok,
134
+ required: false,
135
+ source: channelResolution.channelId ?? requestedChannel,
136
+ detail:
137
+ channelDiagnostics.error ??
138
+ formatChannelDiagnosticsDetail(channelDiagnostics.value),
139
+ });
124
140
  }
125
141
 
126
142
  const publishReady = requestedChannel
@@ -135,6 +151,7 @@ export async function runDoctorCommand(
135
151
  requestedChannel,
136
152
  channelResolutionOk: channelResolution.ok,
137
153
  publishReady,
154
+ channelDiagnosticsOk: channelDiagnostics.ok,
138
155
  });
139
156
 
140
157
  printValue(io, context.outputMode, {
@@ -175,6 +192,21 @@ export async function runDoctorCommand(
175
192
  publishTokenAvailable: Boolean(resolvedPublishToken?.token),
176
193
  publishTokenSource: resolvedPublishToken?.source ?? "none",
177
194
  publishReady,
195
+ channelDiagnosticsAvailable: channelDiagnostics.ok,
196
+ channelDiagnosticsError: channelDiagnostics.error ?? "",
197
+ channelSummary: formatChannelSummary(channelDiagnostics.value),
198
+ channelStateCodes: channelDiagnostics.value?.state_codes ?? [],
199
+ channelStateLabels: channelDiagnostics.value?.state_labels ?? [],
200
+ channelActivePublishKeyCount:
201
+ channelDiagnostics.value?.active_publish_key_count ?? 0,
202
+ channelLastPostedAt: channelDiagnostics.value?.last_posted_at ?? "",
203
+ channelPostingPausedUntil: channelDiagnostics.value?.posting_paused_until ?? "",
204
+ channelLatestBlockedWriteAt:
205
+ channelDiagnostics.value?.latest_blocked_write_at ?? "",
206
+ channelLatestBlockedWriteReason:
207
+ channelDiagnostics.value?.latest_blocked_write_reason ?? "",
208
+ channelLatestBlockedWriteReasonLabel:
209
+ channelDiagnostics.value?.latest_blocked_write_reason_label ?? "",
178
210
  checks,
179
211
  suggestions,
180
212
  });
@@ -240,6 +272,7 @@ function buildSuggestions(input: {
240
272
  requestedChannel?: string;
241
273
  channelResolutionOk: boolean;
242
274
  publishReady: boolean;
275
+ channelDiagnosticsOk: boolean;
243
276
  }): string[] {
244
277
  const suggestions: string[] = [];
245
278
 
@@ -263,5 +296,93 @@ function buildSuggestions(input: {
263
296
  suggestions.push("Provide `--channel-token`, `CLANKMATES_CHANNEL_TOKEN`, `CLANKMATES_CHANNEL_TOKENS_JSON`, `CLANKMATES_CHANNEL_TOKENS_FILE`, or a master token for publish.");
264
297
  }
265
298
 
299
+ if (
300
+ input.requestedChannel &&
301
+ input.channelResolutionOk &&
302
+ input.ownerReadReady &&
303
+ !input.channelDiagnosticsOk
304
+ ) {
305
+ suggestions.push("Retry the channel diagnostics with an owner-read token that can read the requested channel.");
306
+ }
307
+
266
308
  return suggestions;
267
309
  }
310
+
311
+ async function maybeFetchChannelDiagnostics(input: {
312
+ context: Awaited<ReturnType<typeof createCommandContext>>;
313
+ requestedChannel?: string;
314
+ channelResolution: { ok: boolean; channelId?: string; error?: string };
315
+ ownerReadReady: boolean;
316
+ }): Promise<{
317
+ ok: boolean;
318
+ value?: Awaited<
319
+ ReturnType<Awaited<ReturnType<typeof createCommandContext>>["client"]["getChannelDiagnostics"]>
320
+ >;
321
+ error?: string;
322
+ }> {
323
+ if (!input.requestedChannel) {
324
+ return { ok: false };
325
+ }
326
+
327
+ if (!input.channelResolution.ok || !input.channelResolution.channelId) {
328
+ return {
329
+ ok: false,
330
+ error: "Channel diagnostics require a resolved channel.",
331
+ };
332
+ }
333
+
334
+ if (!input.ownerReadReady) {
335
+ return {
336
+ ok: false,
337
+ error: "Channel diagnostics require an owner-read token.",
338
+ };
339
+ }
340
+
341
+ try {
342
+ return {
343
+ ok: true,
344
+ value: await input.context.client.getChannelDiagnostics(
345
+ input.channelResolution.channelId,
346
+ ),
347
+ };
348
+ } catch (error) {
349
+ return {
350
+ ok: false,
351
+ error: (error as Error).message,
352
+ };
353
+ }
354
+ }
355
+
356
+ function formatChannelSummary(
357
+ diagnostics:
358
+ | Awaited<
359
+ ReturnType<
360
+ Awaited<ReturnType<typeof createCommandContext>>["client"]["getChannelDiagnostics"]
361
+ >
362
+ >
363
+ | undefined,
364
+ ): string {
365
+ if (!diagnostics || diagnostics.state_labels.length === 0) {
366
+ return "";
367
+ }
368
+
369
+ return diagnostics.state_labels.join(", ");
370
+ }
371
+
372
+ function formatChannelDiagnosticsDetail(
373
+ diagnostics:
374
+ | Awaited<
375
+ ReturnType<
376
+ Awaited<ReturnType<typeof createCommandContext>>["client"]["getChannelDiagnostics"]
377
+ >
378
+ >
379
+ | undefined,
380
+ ): string {
381
+ const summary = formatChannelSummary(diagnostics);
382
+
383
+ if (!summary) {
384
+ return "Channel diagnostics are unavailable.";
385
+ }
386
+
387
+ return `Channel diagnostics loaded successfully: ${summary}.`;
388
+ }