@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.
@@ -1,18 +1,34 @@
1
1
  import { resolveOutputMode } from "../lib/context";
2
- import { requiredPositional, requiredStringFlag, stringFlag, type ParsedArgs } from "../lib/args";
2
+ import {
3
+ booleanFlag,
4
+ requiredPositional,
5
+ requiredStringFlag,
6
+ stringFlag,
7
+ type ParsedArgs,
8
+ } from "../lib/args";
3
9
  import {
4
10
  loadConfig,
5
11
  resolveBaseUrl,
6
12
  resolveProfile,
7
13
  resolveProfileName,
8
- updateProfile
14
+ updateProfile,
9
15
  } from "../lib/config";
10
16
  import { ClankmatesClient } from "../lib/client";
11
17
  import { CliError } from "../lib/errors";
12
- import { printValue, type Io } from "../lib/output";
18
+ import { printJson, printValue, type Io } from "../lib/output";
13
19
  import { getConfigPath } from "../lib/paths";
14
- import { resolveMasterToken, resolveOwnerReadToken, resolveReadOnlyToken } from "../lib/tokens";
15
- import type { ProfileConfig, WhoamiResponse } from "../types/api";
20
+ import {
21
+ resolveMasterToken,
22
+ resolveOwnerReadToken,
23
+ resolveReadOnlyToken,
24
+ } from "../lib/tokens";
25
+ import type {
26
+ AccessKeyAttributes,
27
+ AccessKeyIssueResponse,
28
+ AccessKeyScope,
29
+ ProfileConfig,
30
+ WhoamiResponse,
31
+ } from "../types/api";
16
32
 
17
33
  export async function runAuthCommand(args: ParsedArgs, io: Io): Promise<void> {
18
34
  const subcommand = args.positionals[0];
@@ -31,11 +47,13 @@ export async function runAuthCommand(args: ParsedArgs, io: Io): Promise<void> {
31
47
  const resolvedBaseUrl = resolveBaseUrl(baseUrl, profile.baseUrl);
32
48
  const validationProfile = {
33
49
  ...profile,
34
- baseUrl: resolvedBaseUrl
50
+ baseUrl: resolvedBaseUrl,
35
51
  };
36
52
 
37
53
  if (Boolean(masterToken) === Boolean(readOnlyToken)) {
38
- throw new CliError("Provide exactly one of `--master-token` or `--read-only-token`.");
54
+ throw new CliError(
55
+ "Provide exactly one of `--master-token` or `--read-only-token`.",
56
+ );
39
57
  }
40
58
 
41
59
  const client = new ClankmatesClient(validationProfile);
@@ -62,54 +80,88 @@ export async function runAuthCommand(args: ParsedArgs, io: Io): Promise<void> {
62
80
  storedProfile.baseUrl = baseUrl;
63
81
  }
64
82
  },
65
- configPath
83
+ configPath,
66
84
  );
67
85
 
68
86
  printValue(io, outputMode, {
69
87
  authenticated: true,
70
88
  profile: profileName,
71
89
  baseUrl: resolvedBaseUrl,
72
- tokenKind: masterToken ? "master" : "read_only"
90
+ tokenKind: masterToken ? "master" : "read_only",
73
91
  });
74
92
  return;
75
93
  }
76
94
 
77
95
  case "whoami": {
78
- const { profileName, profile } = resolveProfile(config, stringFlag(args.flags, "profile"));
96
+ const { profileName, profile } = resolveProfile(
97
+ config,
98
+ stringFlag(args.flags, "profile"),
99
+ );
79
100
  const outputMode = resolveOutputMode(profile, args.flags);
101
+ const explicitChannelToken = stringFlag(args.flags, "channelToken");
80
102
  const resolvedOwnerReadToken = resolveOwnerReadToken(profile);
103
+ const resolvedToken = explicitChannelToken
104
+ ? {
105
+ token: explicitChannelToken,
106
+ source: "flag",
107
+ tokenKind: "channel" as const,
108
+ }
109
+ : {
110
+ token: resolvedOwnerReadToken.token,
111
+ source: resolvedOwnerReadToken.source,
112
+ tokenKind: "owner_read" as const,
113
+ };
81
114
 
82
- if (!resolvedOwnerReadToken.token) {
83
- throw new CliError("No owner read token configured. Set `CLANKMATES_READ_ONLY_TOKEN`, `CLANKMATES_MASTER_TOKEN`, or log in for the selected profile.");
115
+ if (!resolvedToken.token) {
116
+ throw new CliError(
117
+ "No token configured for `auth whoami`. Provide `--channel-token`, set `CLANKMATES_READ_ONLY_TOKEN`, `CLANKMATES_MASTER_TOKEN`, or log in for the selected profile.",
118
+ );
84
119
  }
85
120
 
86
- const whoami = await new ClankmatesClient(profile).whoami(resolvedOwnerReadToken.token);
87
- printValue(io, outputMode, formatWhoamiOutput(profileName, profile.baseUrl, resolvedOwnerReadToken.source, whoami));
121
+ const whoami = await new ClankmatesClient(profile).whoami(
122
+ resolvedToken.token,
123
+ );
124
+
125
+ printValue(
126
+ io,
127
+ outputMode,
128
+ formatWhoamiOutput(profileName, profile.baseUrl, resolvedToken, whoami),
129
+ );
88
130
  return;
89
131
  }
90
132
 
91
133
  case "logout": {
92
- const { profileName } = resolveProfile(config, stringFlag(args.flags, "profile"));
134
+ const { profileName } = resolveProfile(
135
+ config,
136
+ stringFlag(args.flags, "profile"),
137
+ );
93
138
  await updateProfile(
94
139
  profileName,
95
140
  (profile) => {
96
141
  profile.masterToken = undefined;
97
142
  profile.readOnlyToken = undefined;
98
143
  },
99
- configPath
144
+ configPath,
100
145
  );
101
146
  io.stdout(`Cleared owner tokens for profile ${profileName}`);
102
147
  return;
103
148
  }
104
149
 
105
150
  case "token": {
106
- const tokenCommand = requiredPositional(args.positionals, 1, "Missing auth token subcommand");
151
+ const tokenCommand = requiredPositional(
152
+ args.positionals,
153
+ 1,
154
+ "Missing auth token subcommand",
155
+ );
107
156
 
108
157
  if (tokenCommand !== "inspect") {
109
158
  throw new CliError("Unknown auth token subcommand", 2);
110
159
  }
111
160
 
112
- const { profileName, profile } = resolveProfile(config, stringFlag(args.flags, "profile"));
161
+ const { profileName, profile } = resolveProfile(
162
+ config,
163
+ stringFlag(args.flags, "profile"),
164
+ );
113
165
  const outputMode = resolveOutputMode(profile, args.flags);
114
166
  const resolvedMasterToken = resolveMasterToken(profile);
115
167
  const resolvedReadOnlyToken = resolveReadOnlyToken(profile);
@@ -124,50 +176,213 @@ export async function runAuthCommand(args: ParsedArgs, io: Io): Promise<void> {
124
176
  readOnlyTokenSource: resolvedReadOnlyToken.source,
125
177
  ownerReadTokenAvailable: Boolean(resolvedOwnerReadToken.token),
126
178
  ownerReadTokenSource: resolvedOwnerReadToken.source,
127
- storedChannelTokens: Object.keys(profile.channelTokens).length
179
+ storedChannelTokens: Object.keys(profile.channelTokens).length,
128
180
  });
129
181
  return;
130
182
  }
131
183
 
184
+ case "key":
185
+ case "access-key": {
186
+ await runAccessKeyCommand(args, config, io);
187
+ return;
188
+ }
189
+
132
190
  default:
133
191
  throw new CliError("Unknown auth subcommand", 2);
134
192
  }
135
193
  }
136
194
 
195
+ async function runAccessKeyCommand(
196
+ args: ParsedArgs,
197
+ config: Awaited<ReturnType<typeof loadConfig>>,
198
+ io: Io,
199
+ ): Promise<void> {
200
+ const keyCommand = requiredPositional(
201
+ args.positionals,
202
+ 1,
203
+ "Missing auth key subcommand",
204
+ );
205
+ const { profileName, profile } = resolveProfile(
206
+ config,
207
+ stringFlag(args.flags, "profile"),
208
+ );
209
+ const outputMode = resolveOutputMode(profile, args.flags);
210
+ const client = new ClankmatesClient(profile);
211
+ const configPath = getConfigPath();
212
+
213
+ switch (keyCommand) {
214
+ case "list": {
215
+ const scope = parseAccessKeyScope(stringFlag(args.flags, "scope"), false);
216
+ const response = await client.listAccessKeys(scope);
217
+
218
+ if (outputMode === "json") {
219
+ printJson(io, { items: response.items });
220
+ return;
221
+ }
222
+
223
+ printValue(
224
+ io,
225
+ outputMode,
226
+ response.items.map((item) => formatAccessKeyRow(item)),
227
+ );
228
+ return;
229
+ }
230
+
231
+ case "issue": {
232
+ const scope = requireAccessKeyScope(
233
+ requiredStringFlag(args.flags, "scope"),
234
+ );
235
+ const response = await client.issueAccessKey({
236
+ scope,
237
+ name: requiredStringFlag(args.flags, "name"),
238
+ });
239
+
240
+ if (booleanFlag(args.flags, "tokenOnly")) {
241
+ io.stdout(response.token);
242
+ return;
243
+ }
244
+
245
+ printValue(io, outputMode, response);
246
+ return;
247
+ }
248
+
249
+ case "revoke": {
250
+ const response = await client.revokeAccessKey(
251
+ requiredPositional(args.positionals, 2, "Missing access key id"),
252
+ );
253
+ await pruneInvalidStoredOwnerTokens(
254
+ profileName,
255
+ profile,
256
+ client,
257
+ configPath,
258
+ );
259
+ printValue(io, outputMode, response);
260
+ return;
261
+ }
262
+
263
+ default:
264
+ throw new CliError("Unknown auth key subcommand", 2);
265
+ }
266
+ }
267
+
137
268
  function defaultProfileConfig(): ProfileConfig {
138
269
  return {
139
270
  baseUrl: resolveBaseUrl(),
140
271
  output: "table",
141
- channelTokens: {}
272
+ channelTokens: {},
142
273
  };
143
274
  }
144
275
 
145
276
  function formatWhoamiOutput(
146
277
  profileName: string,
147
278
  baseUrl: string,
148
- ownerTokenSource: string,
149
- whoami: WhoamiResponse
279
+ resolvedToken: { source: string; tokenKind: "owner_read" | "channel" },
280
+ whoami: WhoamiResponse,
150
281
  ) {
151
282
  const base = {
152
283
  authenticated: whoami.authenticated,
153
284
  profile: profileName,
154
285
  baseUrl,
155
- ownerTokenSource,
286
+ tokenKind: resolvedToken.tokenKind,
287
+ tokenSource: resolvedToken.source,
288
+ ownerTokenSource:
289
+ resolvedToken.tokenKind === "owner_read" ? resolvedToken.source : undefined,
156
290
  actorType: whoami.actor.type,
157
- actorId: whoami.actor.id
291
+ actorId: whoami.actor.id,
158
292
  };
159
293
 
160
294
  if (whoami.actor.type === "user") {
161
295
  return {
162
296
  ...base,
163
297
  email: whoami.actor.email,
164
- actorScope: whoami.actor.scope ?? "master"
298
+ actorScope: whoami.actor.scope ?? "master",
299
+ authenticatedVia: whoami.actor.authenticated_via ?? "",
300
+ publicHandle: whoami.actor.public_handle ?? "",
301
+ publicProfilePath: whoami.actor.public_profile_path ?? "",
302
+ publicProfileUrl: whoami.actor.public_profile_url ?? "",
165
303
  };
166
304
  }
167
305
 
168
306
  return {
169
307
  ...base,
170
308
  name: whoami.actor.name,
171
- visibility: whoami.actor.visibility
309
+ visibility: whoami.actor.visibility,
310
+ publiclyListed: whoami.actor.publicly_listed ?? false,
311
+ postingPausedUntil: whoami.actor.posting_paused_until ?? "",
312
+ ownerId: whoami.actor.owner_id ?? "",
313
+ ownerPublicHandle: whoami.actor.owner_public_handle ?? "",
314
+ publicPath: whoami.actor.public_path ?? "",
315
+ publicUrl: whoami.actor.public_url ?? "",
172
316
  };
173
317
  }
318
+
319
+ function parseAccessKeyScope(
320
+ scope: string | undefined,
321
+ required: boolean,
322
+ ): AccessKeyScope | undefined {
323
+ if (scope === undefined) {
324
+ if (required) {
325
+ throw new CliError("Missing `--scope`", 2);
326
+ }
327
+
328
+ return undefined;
329
+ }
330
+
331
+ if (scope !== "master" && scope !== "read_only") {
332
+ throw new CliError("`--scope` must be either `master` or `read_only`.", 2);
333
+ }
334
+
335
+ return scope;
336
+ }
337
+
338
+ function requireAccessKeyScope(scope: string): AccessKeyScope {
339
+ const parsed = parseAccessKeyScope(scope, true);
340
+
341
+ if (!parsed) {
342
+ throw new CliError("Missing `--scope`", 2);
343
+ }
344
+
345
+ return parsed;
346
+ }
347
+
348
+ function formatAccessKeyRow(item: { id: string; attributes: AccessKeyAttributes }) {
349
+ return {
350
+ id: item.id,
351
+ scope: item.attributes.scope,
352
+ name: item.attributes.name ?? "",
353
+ expiresAt: item.attributes.expires_at,
354
+ issuedAt: item.attributes.inserted_at ?? "",
355
+ };
356
+ }
357
+
358
+ async function pruneInvalidStoredOwnerTokens(
359
+ profileName: string,
360
+ profile: ProfileConfig,
361
+ client: ClankmatesClient,
362
+ configPath: string,
363
+ ): Promise<void> {
364
+ const invalidMasterToken = profile.masterToken
365
+ ? !(await client.canAuthenticate(profile.masterToken))
366
+ : false;
367
+ const invalidReadOnlyToken = profile.readOnlyToken
368
+ ? !(await client.canAuthenticate(profile.readOnlyToken))
369
+ : false;
370
+
371
+ if (!invalidMasterToken && !invalidReadOnlyToken) {
372
+ return;
373
+ }
374
+
375
+ await updateProfile(
376
+ profileName,
377
+ (storedProfile) => {
378
+ if (invalidMasterToken) {
379
+ storedProfile.masterToken = undefined;
380
+ }
381
+
382
+ if (invalidReadOnlyToken) {
383
+ storedProfile.readOnlyToken = undefined;
384
+ }
385
+ },
386
+ configPath,
387
+ );
388
+ }