@clankid/cli 0.17.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 (81) hide show
  1. package/CHANGELOG.md +16 -0
  2. package/LICENSE +21 -0
  3. package/README.md +275 -0
  4. package/bin/clank +6 -0
  5. package/docs/usage/clankid-migration.md +59 -0
  6. package/package.json +47 -0
  7. package/skills/codex/clankid/SKILL.md +256 -0
  8. package/skills/codex/clankid/references/safety.md +28 -0
  9. package/skills/codex/clankid/references/setup.md +65 -0
  10. package/src/README.md +8 -0
  11. package/src/cli.ts +154 -0
  12. package/src/commands/.gitkeep +1 -0
  13. package/src/commands/account.ts +337 -0
  14. package/src/commands/api.ts +43 -0
  15. package/src/commands/auth/access-keys.ts +206 -0
  16. package/src/commands/auth.ts +240 -0
  17. package/src/commands/cache.ts +124 -0
  18. package/src/commands/config.ts +93 -0
  19. package/src/commands/doctor/checks.ts +123 -0
  20. package/src/commands/doctor/render.ts +140 -0
  21. package/src/commands/doctor/suggestions.ts +42 -0
  22. package/src/commands/doctor/types.ts +75 -0
  23. package/src/commands/doctor.ts +221 -0
  24. package/src/commands/feed.ts +185 -0
  25. package/src/commands/identity/keys.ts +145 -0
  26. package/src/commands/identity/render.ts +224 -0
  27. package/src/commands/identity/validation.ts +11 -0
  28. package/src/commands/identity.ts +295 -0
  29. package/src/commands/inbox/content.ts +13 -0
  30. package/src/commands/inbox/filters.ts +70 -0
  31. package/src/commands/inbox/messages.ts +71 -0
  32. package/src/commands/inbox/participants.ts +152 -0
  33. package/src/commands/inbox/render.ts +13 -0
  34. package/src/commands/inbox/resource-output.ts +217 -0
  35. package/src/commands/inbox/schema.ts +185 -0
  36. package/src/commands/inbox/screening.ts +76 -0
  37. package/src/commands/inbox/sync-scopes.ts +62 -0
  38. package/src/commands/inbox/thread-output.ts +345 -0
  39. package/src/commands/inbox/watch.ts +207 -0
  40. package/src/commands/inbox.ts +374 -0
  41. package/src/commands/post.ts +406 -0
  42. package/src/commands/setup.ts +228 -0
  43. package/src/commands/skill.ts +41 -0
  44. package/src/commands/user.ts +33 -0
  45. package/src/lib/.gitkeep +1 -0
  46. package/src/lib/args.ts +231 -0
  47. package/src/lib/body-input.ts +55 -0
  48. package/src/lib/cache/scopes.ts +272 -0
  49. package/src/lib/cache/store.ts +195 -0
  50. package/src/lib/cache/types.ts +31 -0
  51. package/src/lib/cache.ts +135 -0
  52. package/src/lib/client/auth.ts +163 -0
  53. package/src/lib/client/core.ts +133 -0
  54. package/src/lib/client/feed.ts +93 -0
  55. package/src/lib/client/identities.ts +364 -0
  56. package/src/lib/client/identity-keys.ts +57 -0
  57. package/src/lib/client/inbox.ts +385 -0
  58. package/src/lib/client/posts.ts +236 -0
  59. package/src/lib/client/raw-api.ts +33 -0
  60. package/src/lib/client/users.ts +88 -0
  61. package/src/lib/client.ts +469 -0
  62. package/src/lib/config.ts +239 -0
  63. package/src/lib/content.ts +149 -0
  64. package/src/lib/context.ts +43 -0
  65. package/src/lib/errors.ts +17 -0
  66. package/src/lib/help.ts +1587 -0
  67. package/src/lib/http.ts +138 -0
  68. package/src/lib/human.ts +143 -0
  69. package/src/lib/json-input.ts +73 -0
  70. package/src/lib/json_api.ts +120 -0
  71. package/src/lib/output.ts +203 -0
  72. package/src/lib/pagination.ts +116 -0
  73. package/src/lib/paths.ts +44 -0
  74. package/src/lib/polling.ts +146 -0
  75. package/src/lib/post-output.ts +60 -0
  76. package/src/lib/skills.ts +137 -0
  77. package/src/lib/tokens.ts +284 -0
  78. package/src/lib/version.ts +19 -0
  79. package/src/types/.gitkeep +1 -0
  80. package/src/types/api.ts +320 -0
  81. package/src/types/placeholder.d.ts +1 -0
@@ -0,0 +1,195 @@
1
+ import { Database } from "bun:sqlite";
2
+ import { mkdir } from "node:fs/promises";
3
+ import path from "node:path";
4
+
5
+ import { getCachePath } from "../paths";
6
+ import { CLI_VERSION } from "../version";
7
+ import type { CacheScope, SyncScopeRow } from "./types";
8
+
9
+ const MIGRATION_VERSION = 1;
10
+
11
+ export class SyncCache {
12
+ private readonly db: Database;
13
+
14
+ constructor(private readonly dbPath = getCachePath()) {
15
+ this.db = new Database(dbPath, { create: true });
16
+ this.db.exec("PRAGMA journal_mode = WAL");
17
+ this.db.exec("PRAGMA foreign_keys = ON");
18
+ this.migrate();
19
+ }
20
+
21
+ path(): string {
22
+ return this.dbPath;
23
+ }
24
+
25
+ get(scopeKey: string): SyncScopeRow | undefined {
26
+ return this.db
27
+ .query<SyncScopeRow, [string]>(
28
+ `select scope_key, base_url, profile, actor_key, resource, params_json,
29
+ server_timestamp, cached_at, cli_version
30
+ from sync_scopes
31
+ where scope_key = ?`,
32
+ )
33
+ .get(scopeKey) ?? undefined;
34
+ }
35
+
36
+ list(input: { baseUrl?: string; profile?: string } = {}): SyncScopeRow[] {
37
+ if (input.baseUrl && input.profile) {
38
+ return this.db
39
+ .query<SyncScopeRow, [string, string]>(
40
+ `select scope_key, base_url, profile, actor_key, resource, params_json,
41
+ server_timestamp, cached_at, cli_version
42
+ from sync_scopes
43
+ where base_url = ? and profile = ?
44
+ order by resource, scope_key`,
45
+ )
46
+ .all(input.baseUrl, input.profile);
47
+ }
48
+
49
+ return this.db
50
+ .query<SyncScopeRow, []>(
51
+ `select scope_key, base_url, profile, actor_key, resource, params_json,
52
+ server_timestamp, cached_at, cli_version
53
+ from sync_scopes
54
+ order by base_url, profile, resource, scope_key`,
55
+ )
56
+ .all();
57
+ }
58
+
59
+ upsert(input: {
60
+ scope: CacheScope;
61
+ baseUrl: string;
62
+ profile: string;
63
+ serverTimestamp: string;
64
+ }): void {
65
+ this.db
66
+ .query<
67
+ unknown,
68
+ [string, string, string, string, string, string, string, string, string]
69
+ >(
70
+ `insert into sync_scopes (
71
+ scope_key, base_url, profile, actor_key, resource, params_json,
72
+ server_timestamp, cached_at, cli_version
73
+ )
74
+ values (?, ?, ?, ?, ?, ?, ?, ?, ?)
75
+ on conflict(scope_key) do update set
76
+ base_url = excluded.base_url,
77
+ profile = excluded.profile,
78
+ actor_key = excluded.actor_key,
79
+ resource = excluded.resource,
80
+ params_json = excluded.params_json,
81
+ server_timestamp = excluded.server_timestamp,
82
+ cached_at = excluded.cached_at,
83
+ cli_version = excluded.cli_version`,
84
+ )
85
+ .run(
86
+ input.scope.scopeKey,
87
+ input.baseUrl,
88
+ input.profile,
89
+ input.scope.actorKey,
90
+ input.scope.resource,
91
+ stableJson(input.scope.params),
92
+ input.serverTimestamp,
93
+ new Date().toISOString(),
94
+ CLI_VERSION,
95
+ );
96
+ }
97
+
98
+ clear(input: { scopeKey?: string; baseUrl?: string; profile?: string } = {}): number {
99
+ if (input.scopeKey) {
100
+ const result = this.db
101
+ .query<unknown, [string]>("delete from sync_scopes where scope_key = ?")
102
+ .run(input.scopeKey);
103
+ return result.changes;
104
+ }
105
+
106
+ if (input.baseUrl && input.profile) {
107
+ const result = this.db
108
+ .query<unknown, [string, string]>(
109
+ "delete from sync_scopes where base_url = ? and profile = ?",
110
+ )
111
+ .run(input.baseUrl, input.profile);
112
+ return result.changes;
113
+ }
114
+
115
+ const result = this.db.query<unknown, []>("delete from sync_scopes").run();
116
+ return result.changes;
117
+ }
118
+
119
+ close(): void {
120
+ this.db.close();
121
+ }
122
+
123
+ private migrate(): void {
124
+ this.db.exec(`
125
+ create table if not exists schema_migrations (
126
+ version integer primary key,
127
+ applied_at text not null
128
+ );
129
+ `);
130
+
131
+ const applied = this.db
132
+ .query<{ version: number }, [number]>(
133
+ "select version from schema_migrations where version = ?",
134
+ )
135
+ .get(MIGRATION_VERSION);
136
+
137
+ if (applied) {
138
+ return;
139
+ }
140
+
141
+ this.db.exec(`
142
+ create table if not exists sync_scopes (
143
+ scope_key text primary key,
144
+ base_url text not null,
145
+ profile text not null,
146
+ actor_key text not null,
147
+ resource text not null,
148
+ params_json text not null,
149
+ server_timestamp text,
150
+ cached_at text not null,
151
+ cli_version text not null
152
+ );
153
+
154
+ create index if not exists sync_scopes_profile_idx
155
+ on sync_scopes(base_url, profile);
156
+ `);
157
+
158
+ this.db
159
+ .query<unknown, [number, string]>(
160
+ "insert into schema_migrations(version, applied_at) values (?, ?)",
161
+ )
162
+ .run(MIGRATION_VERSION, new Date().toISOString());
163
+ }
164
+ }
165
+
166
+ export async function ensureCacheParentDirectory(
167
+ cachePath = getCachePath(),
168
+ ): Promise<void> {
169
+ await mkdir(path.dirname(cachePath), { recursive: true });
170
+ }
171
+
172
+ export async function openSyncCache(cachePath = getCachePath()): Promise<SyncCache> {
173
+ await ensureCacheParentDirectory(cachePath);
174
+ return new SyncCache(cachePath);
175
+ }
176
+
177
+ function stableJson(value: Record<string, unknown>): string {
178
+ return JSON.stringify(sortObject(value));
179
+ }
180
+
181
+ function sortObject(value: unknown): unknown {
182
+ if (Array.isArray(value)) {
183
+ return value.map(sortObject);
184
+ }
185
+
186
+ if (typeof value !== "object" || value === null) {
187
+ return value;
188
+ }
189
+
190
+ return Object.fromEntries(
191
+ Object.entries(value as Record<string, unknown>)
192
+ .sort(([left], [right]) => left.localeCompare(right))
193
+ .map(([key, entry]) => [key, sortObject(entry)]),
194
+ );
195
+ }
@@ -0,0 +1,31 @@
1
+ export interface SyncScopeRow {
2
+ scope_key: string;
3
+ base_url: string;
4
+ profile: string;
5
+ actor_key: string;
6
+ resource: string;
7
+ params_json: string;
8
+ server_timestamp?: string | null;
9
+ cached_at: string;
10
+ cli_version: string;
11
+ }
12
+
13
+ export interface CacheScope {
14
+ scopeKey: string;
15
+ resource: string;
16
+ params: Record<string, unknown>;
17
+ actorKey: string;
18
+ }
19
+
20
+ export interface CachePlan {
21
+ scope: CacheScope;
22
+ previousServerTimestamp?: string;
23
+ hit: boolean;
24
+ }
25
+
26
+ export interface CacheResult {
27
+ scopeKey: string;
28
+ hit: boolean;
29
+ previousServerTimestamp?: string;
30
+ savedServerTimestamp?: string;
31
+ }
@@ -0,0 +1,135 @@
1
+ import { booleanFlag, stringFlag, type ParsedArgs } from "./args";
2
+ import type { CommandContext } from "./context";
3
+ import { CliError } from "./errors";
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";
22
+
23
+ export function cacheFlags(args: ParsedArgs): {
24
+ sinceCache: boolean;
25
+ saveCache: boolean;
26
+ } {
27
+ return {
28
+ sinceCache: booleanFlag(args.flags, "sinceCache"),
29
+ saveCache: booleanFlag(args.flags, "saveCache"),
30
+ };
31
+ }
32
+
33
+ export function assertSinceFlags(args: ParsedArgs): void {
34
+ if (stringFlag(args.flags, "since") && booleanFlag(args.flags, "sinceCache")) {
35
+ throw new CliError("Use only one of `--since` or `--since-cache`", 2);
36
+ }
37
+ }
38
+
39
+ export async function prepareCachePlan(
40
+ context: CommandContext,
41
+ scope: CacheScope,
42
+ ): Promise<CachePlan> {
43
+ const cache = await openSyncCache();
44
+
45
+ try {
46
+ const row = cache.get(scope.scopeKey);
47
+ return {
48
+ scope,
49
+ previousServerTimestamp: row?.server_timestamp ?? undefined,
50
+ hit: Boolean(row?.server_timestamp),
51
+ };
52
+ } finally {
53
+ cache.close();
54
+ }
55
+ }
56
+
57
+ export async function saveCacheTimestamp(
58
+ context: CommandContext,
59
+ scope: CacheScope,
60
+ meta: Record<string, unknown> | undefined,
61
+ ): Promise<string | undefined> {
62
+ const serverTimestamp = extractServerTimestamp(meta);
63
+
64
+ if (!serverTimestamp) {
65
+ return undefined;
66
+ }
67
+
68
+ const cache = await openSyncCache();
69
+
70
+ try {
71
+ cache.upsert({
72
+ scope,
73
+ baseUrl: context.profile.baseUrl,
74
+ profile: context.profileName,
75
+ serverTimestamp,
76
+ });
77
+ } finally {
78
+ cache.close();
79
+ }
80
+
81
+ return serverTimestamp;
82
+ }
83
+
84
+ export function cacheResult(
85
+ plan: CachePlan | undefined,
86
+ savedServerTimestamp?: string,
87
+ ): CacheResult | undefined {
88
+ if (!plan && !savedServerTimestamp) {
89
+ return undefined;
90
+ }
91
+
92
+ return {
93
+ scopeKey: plan?.scope.scopeKey ?? "",
94
+ hit: plan?.hit ?? false,
95
+ previousServerTimestamp: plan?.previousServerTimestamp,
96
+ savedServerTimestamp,
97
+ };
98
+ }
99
+
100
+ export function extractServerTimestamp(
101
+ meta: Record<string, unknown> | undefined,
102
+ ): string | undefined {
103
+ if (!meta) {
104
+ return undefined;
105
+ }
106
+
107
+ for (const key of [
108
+ "server_time",
109
+ "server_timestamp",
110
+ "serverTimestamp",
111
+ "latest_server_timestamp",
112
+ "latestServerTimestamp",
113
+ ]) {
114
+ const value = meta[key];
115
+
116
+ if (typeof value === "string" && value.length > 0) {
117
+ return value;
118
+ }
119
+ }
120
+
121
+ return undefined;
122
+ }
123
+
124
+ export function changeResponseMeta(response: {
125
+ server_time?: string;
126
+ }): Record<string, unknown> {
127
+ return response.server_time ? { server_time: response.server_time } : {};
128
+ }
129
+
130
+ export async function authenticatedActorKey(
131
+ context: CommandContext,
132
+ identityKey?: string,
133
+ ): Promise<string> {
134
+ return actorKey((await context.client.whoami(identityKey)).actor);
135
+ }
@@ -0,0 +1,163 @@
1
+ import { requestJson } from "../http";
2
+ import { CliError } from "../errors";
3
+ import {
4
+ requireMasterToken,
5
+ requireOwnerReadToken,
6
+ } from "../tokens";
7
+ import type {
8
+ AccountBootstrapCompleteResponse,
9
+ AccountBootstrapRequestResponse,
10
+ AccessKeyAttributes,
11
+ AccessKeyIssueResponse,
12
+ AccessKeyRevokeResponse,
13
+ AccessKeyScope,
14
+ WhoamiResponse,
15
+ } from "../../types/api";
16
+ import { API_PREFIX, type ApiClientCore } from "./core";
17
+
18
+ export async function canAuthenticate(
19
+ core: ApiClientCore,
20
+ token: string,
21
+ ): Promise<boolean> {
22
+ try {
23
+ await whoami(core, token);
24
+ return true;
25
+ } catch (error) {
26
+ if (isUnauthorizedCliError(error)) {
27
+ return false;
28
+ }
29
+
30
+ throw error;
31
+ }
32
+ }
33
+
34
+ export async function validateMasterToken(
35
+ core: ApiClientCore,
36
+ token: string,
37
+ ): Promise<void> {
38
+ const response = await whoami(core, token);
39
+
40
+ if (
41
+ response.actor.type !== "user" ||
42
+ response.actor.scope === "read_only"
43
+ ) {
44
+ throw new CliError("Provided token is not a master token.");
45
+ }
46
+ }
47
+
48
+ export async function validateReadOnlyToken(
49
+ core: ApiClientCore,
50
+ token: string,
51
+ ): Promise<void> {
52
+ const response = await whoami(core, token);
53
+
54
+ if (
55
+ response.actor.type !== "user" ||
56
+ response.actor.scope !== "read_only"
57
+ ) {
58
+ throw new CliError("Provided token is not a read-only token.");
59
+ }
60
+ }
61
+
62
+ export async function whoami(
63
+ core: ApiClientCore,
64
+ token = requireOwnerReadToken(core.profile),
65
+ ): Promise<WhoamiResponse> {
66
+ return (
67
+ await requestJson<WhoamiResponse>(
68
+ core.profile.baseUrl,
69
+ `${API_PREFIX}/auth/whoami`,
70
+ { token },
71
+ )
72
+ ).data;
73
+ }
74
+
75
+ export async function requestAccountBootstrap(
76
+ core: ApiClientCore,
77
+ input: { email: string; publicHandle: string },
78
+ ): Promise<AccountBootstrapRequestResponse> {
79
+ return (
80
+ await requestJson<AccountBootstrapRequestResponse>(
81
+ core.profile.baseUrl,
82
+ `${API_PREFIX}/auth/bootstrap/request`,
83
+ {
84
+ method: "POST",
85
+ body: {
86
+ email: input.email,
87
+ public_handle: input.publicHandle,
88
+ },
89
+ },
90
+ )
91
+ ).data;
92
+ }
93
+
94
+ export async function completeAccountBootstrap(
95
+ core: ApiClientCore,
96
+ input: { bootstrapId: string; code: string; keyName?: string },
97
+ ): Promise<AccountBootstrapCompleteResponse> {
98
+ return (
99
+ await requestJson<AccountBootstrapCompleteResponse>(
100
+ core.profile.baseUrl,
101
+ `${API_PREFIX}/auth/bootstrap/complete`,
102
+ {
103
+ method: "POST",
104
+ body: {
105
+ bootstrap_id: input.bootstrapId,
106
+ code: input.code,
107
+ key_name: input.keyName,
108
+ },
109
+ },
110
+ )
111
+ ).data;
112
+ }
113
+
114
+ export async function listAccessKeys(
115
+ core: ApiClientCore,
116
+ scope?: AccessKeyScope,
117
+ ) {
118
+ const path = scope
119
+ ? `${API_PREFIX}/me/access-keys/scopes/${encodeURIComponent(scope)}`
120
+ : `${API_PREFIX}/me/access-keys`;
121
+
122
+ return core.requestCollection<AccessKeyAttributes>(path, {
123
+ token: requireOwnerReadToken(core.profile),
124
+ });
125
+ }
126
+
127
+ export async function issueAccessKey(
128
+ core: ApiClientCore,
129
+ input: { scope: AccessKeyScope; name: string },
130
+ ) {
131
+ return core.requestAction<AccessKeyIssueResponse>(
132
+ `${API_PREFIX}/me/access-keys`,
133
+ {
134
+ method: "POST",
135
+ token: requireMasterToken(core.profile),
136
+ body: {
137
+ data: {
138
+ scope: input.scope,
139
+ name: input.name,
140
+ },
141
+ },
142
+ },
143
+ );
144
+ }
145
+
146
+ export async function revokeAccessKey(core: ApiClientCore, id: string) {
147
+ return core.requestAction<AccessKeyRevokeResponse>(
148
+ `${API_PREFIX}/me/access-keys/${id}`,
149
+ {
150
+ method: "DELETE",
151
+ token: requireMasterToken(core.profile),
152
+ },
153
+ );
154
+ }
155
+
156
+ function isUnauthorizedCliError(error: unknown): boolean {
157
+ return (
158
+ error instanceof CliError &&
159
+ error.exitCode === 1 &&
160
+ (error.message.includes("Authentication required") ||
161
+ error.message.includes("code=unauthorized"))
162
+ );
163
+ }
@@ -0,0 +1,133 @@
1
+ import { expectCollection, expectResource } from "../json_api";
2
+ import { requestJsonApi, type RequestOptions } from "../http";
3
+ import { CliError } from "../errors";
4
+ import {
5
+ requireMasterToken,
6
+ requireOwnerReadToken,
7
+ resolveIdentityActorOrMasterToken,
8
+ resolveIdentityCredential,
9
+ } from "../tokens";
10
+ import type { ProfileConfig } from "../../types/api";
11
+
12
+ export const API_PREFIX = "/api/v1";
13
+
14
+ export class ApiClientCore {
15
+ constructor(readonly profile: ProfileConfig) {}
16
+
17
+ async requestResource<TAttributes extends object>(
18
+ path: string,
19
+ options: RequestOptions,
20
+ ) {
21
+ return expectResource<TAttributes>(
22
+ (await this.requestJsonApi(path, options)).data,
23
+ );
24
+ }
25
+
26
+ async requestCollection<TAttributes extends object>(
27
+ path: string,
28
+ options: RequestOptions,
29
+ ) {
30
+ return expectCollection<TAttributes>(
31
+ (await this.requestJsonApi(path, options)).data,
32
+ );
33
+ }
34
+
35
+ async requestAction<T = unknown>(
36
+ path: string,
37
+ options: RequestOptions = {},
38
+ ) {
39
+ return (await this.requestJsonApi<T>(path, options)).data;
40
+ }
41
+
42
+ async requestJsonApi<T = unknown>(
43
+ path: string,
44
+ options: RequestOptions = {},
45
+ ) {
46
+ return requestJsonApi<T>(this.profile.baseUrl, path, options);
47
+ }
48
+
49
+ resolvePostLifecycleToken(explicitToken?: string): string {
50
+ const resolved = resolveIdentityActorOrMasterToken(
51
+ this.profile,
52
+ explicitToken,
53
+ );
54
+
55
+ if (!resolved.token) {
56
+ throw new CliError(
57
+ "No identity credential available for post edit/delete. Provide --identity-key, set CLANKID_IDENTITY_KEY, configure a single saved identity key, or configure a master token.",
58
+ );
59
+ }
60
+
61
+ return resolved.token;
62
+ }
63
+
64
+ resolveIdentityConfigKey(identityId: string, explicitToken?: string): string {
65
+ const resolved = resolveIdentityCredential(this.profile, identityId, explicitToken);
66
+
67
+ if (!resolved.token) {
68
+ throw new CliError(
69
+ "No identity credential available for identity configuration. Provide --identity-key, save an identity key for this identity, or configure a master token.",
70
+ );
71
+ }
72
+
73
+ return resolved.token;
74
+ }
75
+
76
+ resolveInboxReadToken(explicitToken?: string): string {
77
+ return explicitToken ?? requireOwnerReadToken(this.profile);
78
+ }
79
+
80
+ resolveInboxWriteToken(explicitToken?: string): string {
81
+ return explicitToken ?? requireMasterToken(this.profile);
82
+ }
83
+ }
84
+
85
+ const UUID_PATTERN =
86
+ /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
87
+
88
+ export function inferMediaType(path: string): string {
89
+ return isPlainJsonPath(path)
90
+ ? "application/json"
91
+ : "application/vnd.api+json";
92
+ }
93
+
94
+ export function isPlainJsonPath(path: string): boolean {
95
+ return (
96
+ path === `${API_PREFIX}/open_api` || path.startsWith(`${API_PREFIX}/auth/`)
97
+ );
98
+ }
99
+
100
+ export function looksLikeUuid(value: string): boolean {
101
+ return UUID_PATTERN.test(value);
102
+ }
103
+
104
+ export function withQuery(
105
+ path: string,
106
+ params: Record<string, string | number | boolean | undefined>,
107
+ ): string {
108
+ const search = new URLSearchParams();
109
+
110
+ for (const [key, value] of Object.entries(params)) {
111
+ if (value !== undefined) {
112
+ search.set(key, String(value));
113
+ }
114
+ }
115
+
116
+ const query = search.toString();
117
+ return query ? `${path}?${query}` : path;
118
+ }
119
+
120
+ export function withRepeatedQuery(
121
+ path: string,
122
+ key: string,
123
+ values: string[],
124
+ ): string {
125
+ const search = new URLSearchParams();
126
+
127
+ for (const value of values) {
128
+ search.append(key, value);
129
+ }
130
+
131
+ const query = search.toString();
132
+ return query ? `${path}?${query}` : path;
133
+ }