@clankmates/cli 0.12.0 → 0.13.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.
Files changed (46) hide show
  1. package/README.md +4 -1
  2. package/package.json +1 -1
  3. package/src/commands/auth/access-keys.ts +206 -0
  4. package/src/commands/auth.ts +3 -196
  5. package/src/commands/channel/render.ts +224 -0
  6. package/src/commands/channel/tokens.ts +145 -0
  7. package/src/commands/channel/validation.ts +11 -0
  8. package/src/commands/channel.ts +11 -340
  9. package/src/commands/doctor/checks.ts +123 -0
  10. package/src/commands/doctor/render.ts +140 -0
  11. package/src/commands/doctor/suggestions.ts +42 -0
  12. package/src/commands/doctor/types.ts +75 -0
  13. package/src/commands/doctor.ts +12 -371
  14. package/src/commands/feed.ts +15 -178
  15. package/src/commands/inbox/content.ts +31 -0
  16. package/src/commands/inbox/filters.ts +70 -0
  17. package/src/commands/inbox/messages.ts +69 -0
  18. package/src/commands/inbox/participants.ts +152 -0
  19. package/src/commands/inbox/render.ts +13 -0
  20. package/src/commands/inbox/resource-output.ts +217 -0
  21. package/src/commands/inbox/schema.ts +185 -0
  22. package/src/commands/inbox/screening.ts +76 -0
  23. package/src/commands/inbox/sync-scopes.ts +59 -0
  24. package/src/commands/inbox/thread-output.ts +344 -0
  25. package/src/commands/inbox/watch.ts +203 -0
  26. package/src/commands/inbox.ts +37 -1243
  27. package/src/commands/post.ts +9 -114
  28. package/src/lib/args.ts +1 -0
  29. package/src/lib/cache/scopes.ts +216 -0
  30. package/src/lib/cache/store.ts +195 -0
  31. package/src/lib/cache/types.ts +31 -0
  32. package/src/lib/cache.ts +18 -436
  33. package/src/lib/client/auth.ts +122 -0
  34. package/src/lib/client/channel-keys.ts +57 -0
  35. package/src/lib/client/channels.ts +364 -0
  36. package/src/lib/client/core.ts +133 -0
  37. package/src/lib/client/feed.ts +76 -0
  38. package/src/lib/client/inbox.ts +361 -0
  39. package/src/lib/client/posts.ts +213 -0
  40. package/src/lib/client/raw-api.ts +33 -0
  41. package/src/lib/client/users.ts +88 -0
  42. package/src/lib/client.ts +177 -913
  43. package/src/lib/help.ts +26 -0
  44. package/src/lib/json_api.ts +74 -9
  45. package/src/lib/polling.ts +146 -0
  46. package/src/lib/post-output.ts +55 -0
package/src/lib/cache.ts CHANGED
@@ -1,214 +1,24 @@
1
- import { Database } from "bun:sqlite";
2
- import { mkdir } from "node:fs/promises";
3
- import path from "node:path";
4
- import { createHash } from "node:crypto";
5
-
6
1
  import { booleanFlag, stringFlag, type ParsedArgs } from "./args";
7
2
  import type { CommandContext } from "./context";
8
3
  import { CliError } from "./errors";
9
- import { getCachePath } from "./paths";
10
- import { CLI_VERSION } from "./version";
11
- import type { WhoamiActor } from "../types/api";
12
-
13
- const MIGRATION_VERSION = 1;
14
- const PUBLIC_ACTOR_KEY = "public";
15
- const SHARED_ACTOR_KEY = "shared";
16
-
17
- export interface SyncScopeRow {
18
- scope_key: string;
19
- base_url: string;
20
- profile: string;
21
- actor_key: string;
22
- resource: string;
23
- params_json: string;
24
- server_timestamp?: string | null;
25
- cached_at: string;
26
- cli_version: string;
27
- }
28
-
29
- export interface CacheScope {
30
- scopeKey: string;
31
- resource: string;
32
- params: Record<string, unknown>;
33
- actorKey: string;
34
- }
35
-
36
- export interface CachePlan {
37
- scope: CacheScope;
38
- previousServerTimestamp?: string;
39
- hit: boolean;
40
- }
41
-
42
- export interface CacheResult {
43
- scopeKey: string;
44
- hit: boolean;
45
- previousServerTimestamp?: string;
46
- savedServerTimestamp?: string;
47
- }
48
-
49
- export class SyncCache {
50
- private readonly db: Database;
51
-
52
- constructor(private readonly dbPath = getCachePath()) {
53
- this.db = new Database(dbPath, { create: true });
54
- this.db.exec("PRAGMA journal_mode = WAL");
55
- this.db.exec("PRAGMA foreign_keys = ON");
56
- this.migrate();
57
- }
58
-
59
- path(): string {
60
- return this.dbPath;
61
- }
62
-
63
- get(scopeKey: string): SyncScopeRow | undefined {
64
- return this.db
65
- .query<SyncScopeRow, [string]>(
66
- `select scope_key, base_url, profile, actor_key, resource, params_json,
67
- server_timestamp, cached_at, cli_version
68
- from sync_scopes
69
- where scope_key = ?`,
70
- )
71
- .get(scopeKey) ?? undefined;
72
- }
73
-
74
- list(input: { baseUrl?: string; profile?: string } = {}): SyncScopeRow[] {
75
- if (input.baseUrl && input.profile) {
76
- return this.db
77
- .query<SyncScopeRow, [string, string]>(
78
- `select scope_key, base_url, profile, actor_key, resource, params_json,
79
- server_timestamp, cached_at, cli_version
80
- from sync_scopes
81
- where base_url = ? and profile = ?
82
- order by resource, scope_key`,
83
- )
84
- .all(input.baseUrl, input.profile);
85
- }
86
-
87
- return this.db
88
- .query<SyncScopeRow, []>(
89
- `select scope_key, base_url, profile, actor_key, resource, params_json,
90
- server_timestamp, cached_at, cli_version
91
- from sync_scopes
92
- order by base_url, profile, resource, scope_key`,
93
- )
94
- .all();
95
- }
96
-
97
- upsert(input: {
98
- scope: CacheScope;
99
- baseUrl: string;
100
- profile: string;
101
- serverTimestamp: string;
102
- }): void {
103
- this.db
104
- .query<
105
- unknown,
106
- [string, string, string, string, string, string, string, string, string]
107
- >(
108
- `insert into sync_scopes (
109
- scope_key, base_url, profile, actor_key, resource, params_json,
110
- server_timestamp, cached_at, cli_version
111
- )
112
- values (?, ?, ?, ?, ?, ?, ?, ?, ?)
113
- on conflict(scope_key) do update set
114
- base_url = excluded.base_url,
115
- profile = excluded.profile,
116
- actor_key = excluded.actor_key,
117
- resource = excluded.resource,
118
- params_json = excluded.params_json,
119
- server_timestamp = excluded.server_timestamp,
120
- cached_at = excluded.cached_at,
121
- cli_version = excluded.cli_version`,
122
- )
123
- .run(
124
- input.scope.scopeKey,
125
- input.baseUrl,
126
- input.profile,
127
- input.scope.actorKey,
128
- input.scope.resource,
129
- stableJson(input.scope.params),
130
- input.serverTimestamp,
131
- new Date().toISOString(),
132
- CLI_VERSION,
133
- );
134
- }
135
-
136
- clear(input: { scopeKey?: string; baseUrl?: string; profile?: string } = {}): number {
137
- if (input.scopeKey) {
138
- const result = this.db
139
- .query<unknown, [string]>("delete from sync_scopes where scope_key = ?")
140
- .run(input.scopeKey);
141
- return result.changes;
142
- }
143
-
144
- if (input.baseUrl && input.profile) {
145
- const result = this.db
146
- .query<unknown, [string, string]>(
147
- "delete from sync_scopes where base_url = ? and profile = ?",
148
- )
149
- .run(input.baseUrl, input.profile);
150
- return result.changes;
151
- }
152
-
153
- const result = this.db.query<unknown, []>("delete from sync_scopes").run();
154
- return result.changes;
155
- }
156
-
157
- close(): void {
158
- this.db.close();
159
- }
160
-
161
- private migrate(): void {
162
- this.db.exec(`
163
- create table if not exists schema_migrations (
164
- version integer primary key,
165
- applied_at text not null
166
- );
167
- `);
168
-
169
- const applied = this.db
170
- .query<{ version: number }, [number]>(
171
- "select version from schema_migrations where version = ?",
172
- )
173
- .get(MIGRATION_VERSION);
174
-
175
- if (applied) {
176
- return;
177
- }
178
-
179
- this.db.exec(`
180
- create table if not exists sync_scopes (
181
- scope_key text primary key,
182
- base_url text not null,
183
- profile text not null,
184
- actor_key text not null,
185
- resource text not null,
186
- params_json text not null,
187
- server_timestamp text,
188
- cached_at text not null,
189
- cli_version text not null
190
- );
191
-
192
- create index if not exists sync_scopes_profile_idx
193
- on sync_scopes(base_url, profile);
194
- `);
195
-
196
- this.db
197
- .query<unknown, [number, string]>(
198
- "insert into schema_migrations(version, applied_at) values (?, ?)",
199
- )
200
- .run(MIGRATION_VERSION, new Date().toISOString());
201
- }
202
- }
203
-
204
- export async function ensureCacheParentDirectory(cachePath = getCachePath()): Promise<void> {
205
- await mkdir(path.dirname(cachePath), { recursive: true });
206
- }
207
-
208
- export async function openSyncCache(cachePath = getCachePath()): Promise<SyncCache> {
209
- await ensureCacheParentDirectory(cachePath);
210
- return new SyncCache(cachePath);
211
- }
4
+ import { actorKey } from "./cache/scopes";
5
+ import { openSyncCache } from "./cache/store";
6
+ import type { CachePlan, CacheResult, CacheScope } from "./cache/types";
7
+
8
+ export type { CachePlan, CacheResult, CacheScope, SyncScopeRow } from "./cache/types";
9
+ export { SyncCache, ensureCacheParentDirectory, openSyncCache } from "./cache/store";
10
+ export {
11
+ feedMyScope,
12
+ feedSearchScope,
13
+ hashValue,
14
+ inboxMessagesScope,
15
+ inboxThreadsScope,
16
+ ownedPostsScope,
17
+ publicActorKey,
18
+ publicPostsScope,
19
+ sharedActorKey,
20
+ sharedPostsScope,
21
+ } from "./cache/scopes";
212
22
 
213
23
  export function cacheFlags(args: ParsedArgs): {
214
24
  sinceCache: boolean;
@@ -323,231 +133,3 @@ export async function authenticatedActorKey(
323
133
  ): Promise<string> {
324
134
  return actorKey((await context.client.whoami(channelToken)).actor);
325
135
  }
326
-
327
- export function publicActorKey(): string {
328
- return PUBLIC_ACTOR_KEY;
329
- }
330
-
331
- export function sharedActorKey(): string {
332
- return SHARED_ACTOR_KEY;
333
- }
334
-
335
- export function feedMyScope(input: {
336
- context: CommandContext;
337
- actorKey: string;
338
- channelId?: string;
339
- before?: string;
340
- }): CacheScope {
341
- const channel = input.channelId ?? "all";
342
- const before = input.before ?? "default";
343
- return scoped({
344
- context: input.context,
345
- actorKey: input.actorKey,
346
- resource: "feed:my",
347
- parts: ["feed", "my", channel, before],
348
- params: { channel, before },
349
- });
350
- }
351
-
352
- export function feedSearchScope(input: {
353
- context: CommandContext;
354
- actorKey: string;
355
- query: string;
356
- channelId?: string;
357
- before?: string;
358
- }): CacheScope {
359
- const channel = input.channelId ?? "all";
360
- const before = input.before ?? "default";
361
- return scoped({
362
- context: input.context,
363
- actorKey: input.actorKey,
364
- resource: "feed:search",
365
- parts: ["feed", "search", input.query, channel, before],
366
- params: { query: input.query, channel, before },
367
- });
368
- }
369
-
370
- export function inboxThreadsScope(input: {
371
- context: CommandContext;
372
- actorKey: string;
373
- status?: string;
374
- mailbox?: string;
375
- participant?: string;
376
- participantScope?: string;
377
- query?: string;
378
- hasAttachment?: boolean;
379
- before?: string;
380
- }): CacheScope {
381
- const status = input.status ?? "default";
382
- const mailbox = input.mailbox ?? "default";
383
- const participant = input.participant ?? "default";
384
- const participantScope = input.participantScope ?? "default";
385
- const query = input.query ?? "default";
386
- const hasAttachment = input.hasAttachment ?? false;
387
- const before = input.before ?? "default";
388
- return scoped({
389
- context: input.context,
390
- actorKey: input.actorKey,
391
- resource: "inbox:threads",
392
- parts: [
393
- "inbox",
394
- "threads",
395
- status,
396
- mailbox,
397
- participant,
398
- participantScope,
399
- query,
400
- String(hasAttachment),
401
- before,
402
- input.actorKey,
403
- ],
404
- params: {
405
- status,
406
- mailbox,
407
- participant,
408
- participantScope,
409
- query,
410
- hasAttachment,
411
- before,
412
- },
413
- });
414
- }
415
-
416
- export function inboxMessagesScope(input: {
417
- context: CommandContext;
418
- actorKey: string;
419
- threadId: string;
420
- query?: string;
421
- hasAttachment?: boolean;
422
- before?: string;
423
- }): CacheScope {
424
- const query = input.query ?? "default";
425
- const hasAttachment = input.hasAttachment ?? false;
426
- const before = input.before ?? "default";
427
- return scoped({
428
- context: input.context,
429
- actorKey: input.actorKey,
430
- resource: "inbox:messages",
431
- parts: [
432
- "inbox",
433
- "messages",
434
- input.threadId,
435
- query,
436
- String(hasAttachment),
437
- before,
438
- input.actorKey,
439
- ],
440
- params: { threadId: input.threadId, query, hasAttachment, before },
441
- });
442
- }
443
-
444
- export function ownedPostsScope(input: {
445
- context: CommandContext;
446
- actorKey: string;
447
- channelId: string;
448
- before?: string;
449
- }): CacheScope {
450
- const before = input.before ?? "default";
451
- return scoped({
452
- context: input.context,
453
- actorKey: input.actorKey,
454
- resource: "posts:owned",
455
- parts: ["posts", "owned", input.channelId, before],
456
- params: { channelId: input.channelId, before },
457
- });
458
- }
459
-
460
- export function publicPostsScope(input: {
461
- context: CommandContext;
462
- publicHandle: string;
463
- channelName: string;
464
- before?: string;
465
- }): CacheScope {
466
- const before = input.before ?? "default";
467
- return scoped({
468
- context: input.context,
469
- actorKey: PUBLIC_ACTOR_KEY,
470
- resource: "posts:public",
471
- parts: ["posts", "public", input.publicHandle, input.channelName, before],
472
- params: {
473
- publicHandle: input.publicHandle,
474
- channelName: input.channelName,
475
- before,
476
- },
477
- });
478
- }
479
-
480
- export function sharedPostsScope(input: {
481
- context: CommandContext;
482
- shareToken: string;
483
- before?: string;
484
- }): CacheScope {
485
- const shareTokenHash = hashValue(input.shareToken);
486
- const before = input.before ?? "default";
487
- return scoped({
488
- context: input.context,
489
- actorKey: SHARED_ACTOR_KEY,
490
- resource: "posts:shared",
491
- parts: ["posts", "shared", shareTokenHash, before],
492
- params: { shareTokenHash, before },
493
- });
494
- }
495
-
496
- export function hashValue(value: string): string {
497
- return createHash("sha256").update(value).digest("hex");
498
- }
499
-
500
- function actorKey(actor: WhoamiActor): string {
501
- if (actor.type === "user") {
502
- return `user:${actor.id}:${actor.scope ?? "master"}`;
503
- }
504
-
505
- return `channel:${actor.id}`;
506
- }
507
-
508
- function scoped(input: {
509
- context: CommandContext;
510
- actorKey: string;
511
- resource: string;
512
- parts: string[];
513
- params: Record<string, unknown>;
514
- }): CacheScope {
515
- const prefix = [
516
- "v1",
517
- normalizePart(input.context.profile.baseUrl),
518
- normalizePart(input.context.profileName),
519
- normalizePart(input.actorKey),
520
- ];
521
- const scopeKey = [...prefix, ...input.parts.map(normalizePart)].join(":");
522
-
523
- return {
524
- scopeKey,
525
- resource: input.resource,
526
- params: input.params,
527
- actorKey: input.actorKey,
528
- };
529
- }
530
-
531
- function normalizePart(value: string): string {
532
- return encodeURIComponent(value);
533
- }
534
-
535
- function stableJson(value: Record<string, unknown>): string {
536
- return JSON.stringify(sortObject(value));
537
- }
538
-
539
- function sortObject(value: unknown): unknown {
540
- if (Array.isArray(value)) {
541
- return value.map(sortObject);
542
- }
543
-
544
- if (typeof value !== "object" || value === null) {
545
- return value;
546
- }
547
-
548
- return Object.fromEntries(
549
- Object.entries(value as Record<string, unknown>)
550
- .sort(([left], [right]) => left.localeCompare(right))
551
- .map(([key, entry]) => [key, sortObject(entry)]),
552
- );
553
- }
@@ -0,0 +1,122 @@
1
+ import { requestJson } from "../http";
2
+ import { CliError } from "../errors";
3
+ import {
4
+ requireMasterToken,
5
+ requireOwnerReadToken,
6
+ } from "../tokens";
7
+ import type {
8
+ AccessKeyAttributes,
9
+ AccessKeyIssueResponse,
10
+ AccessKeyRevokeResponse,
11
+ AccessKeyScope,
12
+ WhoamiResponse,
13
+ } from "../../types/api";
14
+ import { API_PREFIX, type ApiClientCore } from "./core";
15
+
16
+ export async function canAuthenticate(
17
+ core: ApiClientCore,
18
+ token: string,
19
+ ): Promise<boolean> {
20
+ try {
21
+ await whoami(core, token);
22
+ return true;
23
+ } catch (error) {
24
+ if (isUnauthorizedCliError(error)) {
25
+ return false;
26
+ }
27
+
28
+ throw error;
29
+ }
30
+ }
31
+
32
+ export async function validateMasterToken(
33
+ core: ApiClientCore,
34
+ token: string,
35
+ ): Promise<void> {
36
+ const response = await whoami(core, token);
37
+
38
+ if (
39
+ response.actor.type !== "user" ||
40
+ response.actor.scope === "read_only"
41
+ ) {
42
+ throw new CliError("Provided token is not a master token.");
43
+ }
44
+ }
45
+
46
+ export async function validateReadOnlyToken(
47
+ core: ApiClientCore,
48
+ token: string,
49
+ ): Promise<void> {
50
+ const response = await whoami(core, token);
51
+
52
+ if (
53
+ response.actor.type !== "user" ||
54
+ response.actor.scope !== "read_only"
55
+ ) {
56
+ throw new CliError("Provided token is not a read-only token.");
57
+ }
58
+ }
59
+
60
+ export async function whoami(
61
+ core: ApiClientCore,
62
+ token = requireOwnerReadToken(core.profile),
63
+ ): Promise<WhoamiResponse> {
64
+ return (
65
+ await requestJson<WhoamiResponse>(
66
+ core.profile.baseUrl,
67
+ `${API_PREFIX}/auth/whoami`,
68
+ { token },
69
+ )
70
+ ).data;
71
+ }
72
+
73
+ export async function listAccessKeys(
74
+ core: ApiClientCore,
75
+ scope?: AccessKeyScope,
76
+ ) {
77
+ const path = scope
78
+ ? `${API_PREFIX}/me/access-keys/scopes/${encodeURIComponent(scope)}`
79
+ : `${API_PREFIX}/me/access-keys`;
80
+
81
+ return core.requestCollection<AccessKeyAttributes>(path, {
82
+ token: requireOwnerReadToken(core.profile),
83
+ });
84
+ }
85
+
86
+ export async function issueAccessKey(
87
+ core: ApiClientCore,
88
+ input: { scope: AccessKeyScope; name: string },
89
+ ) {
90
+ return core.requestAction<AccessKeyIssueResponse>(
91
+ `${API_PREFIX}/me/access-keys`,
92
+ {
93
+ method: "POST",
94
+ token: requireMasterToken(core.profile),
95
+ body: {
96
+ data: {
97
+ scope: input.scope,
98
+ name: input.name,
99
+ },
100
+ },
101
+ },
102
+ );
103
+ }
104
+
105
+ export async function revokeAccessKey(core: ApiClientCore, id: string) {
106
+ return core.requestAction<AccessKeyRevokeResponse>(
107
+ `${API_PREFIX}/me/access-keys/${id}`,
108
+ {
109
+ method: "DELETE",
110
+ token: requireMasterToken(core.profile),
111
+ },
112
+ );
113
+ }
114
+
115
+ function isUnauthorizedCliError(error: unknown): boolean {
116
+ return (
117
+ error instanceof CliError &&
118
+ error.exitCode === 1 &&
119
+ (error.message.includes("Authentication required") ||
120
+ error.message.includes("code=unauthorized"))
121
+ );
122
+ }
@@ -0,0 +1,57 @@
1
+ import {
2
+ requireMasterToken,
3
+ requireOwnerReadToken,
4
+ } from "../tokens";
5
+ import type {
6
+ ChannelKeyAttributes,
7
+ ChannelKeyIssueResponse,
8
+ ChannelKeyRevokeResponse,
9
+ } from "../../types/api";
10
+ import { API_PREFIX, withQuery, type ApiClientCore } from "./core";
11
+
12
+ export async function listChannelKeys(
13
+ core: ApiClientCore,
14
+ input: {
15
+ channelId: string;
16
+ limit?: number;
17
+ cursor?: string;
18
+ },
19
+ ) {
20
+ return core.requestCollection<ChannelKeyAttributes>(
21
+ withQuery(`${API_PREFIX}/channels/${input.channelId}/tokens`, {
22
+ "page[limit]": input.limit,
23
+ "page[after]": input.cursor,
24
+ }),
25
+ {
26
+ token: requireOwnerReadToken(core.profile),
27
+ },
28
+ );
29
+ }
30
+
31
+ export async function issueChannelKey(
32
+ core: ApiClientCore,
33
+ input: { channelId: string; name: string },
34
+ ) {
35
+ return core.requestAction<ChannelKeyIssueResponse>(
36
+ `${API_PREFIX}/channels/${input.channelId}/tokens`,
37
+ {
38
+ method: "POST",
39
+ token: requireMasterToken(core.profile),
40
+ body: {
41
+ data: {
42
+ name: input.name,
43
+ },
44
+ },
45
+ },
46
+ );
47
+ }
48
+
49
+ export async function revokeChannelKey(core: ApiClientCore, id: string) {
50
+ return core.requestAction<ChannelKeyRevokeResponse>(
51
+ `${API_PREFIX}/channel-keys/${id}`,
52
+ {
53
+ method: "DELETE",
54
+ token: requireMasterToken(core.profile),
55
+ },
56
+ );
57
+ }