@khangal.j/fireside-cli 0.0.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.
Files changed (3) hide show
  1. package/README.md +33 -0
  2. package/dist/index.js +480 -0
  3. package/package.json +34 -0
package/README.md ADDED
@@ -0,0 +1,33 @@
1
+ # Fireside CLI
2
+
3
+ Node.js CLI for Fireside.
4
+
5
+ ## Install
6
+
7
+ ```sh
8
+ npm install -g @khangal.j/fireside-cli
9
+ ```
10
+
11
+ ## Usage
12
+
13
+ ```sh
14
+ fireside login --base-url http://localhost:3000
15
+ fireside status
16
+ fireside hello
17
+ fireside my-stuff
18
+ fireside projects list
19
+ ```
20
+
21
+ ## Local State
22
+
23
+ The CLI stores local files under the `fireside` config directory.
24
+
25
+ - macOS: `~/Library/Application Support/fireside/{config,auth}.json`
26
+ - Linux: `$XDG_CONFIG_HOME/fireside/{config,auth}.json` or `~/.config/fireside/{config,auth}.json`
27
+ - Windows: `%APPDATA%\\fireside\\{config,auth}.json`
28
+
29
+ `config.json` stores settings like `baseUrl`. `auth.json` stores the local session token.
30
+
31
+ ## Requirements
32
+
33
+ - Node.js 20+
package/dist/index.js ADDED
@@ -0,0 +1,480 @@
1
+ #!/usr/bin/env node
2
+ "use strict";
3
+
4
+ // src/index.ts
5
+ var import_commander = require("commander");
6
+
7
+ // src/lib/api.ts
8
+ var DEVICE_CODE_GRANT_TYPE = "urn:ietf:params:oauth:grant-type:device_code";
9
+ var CLI_CLIENT_ID = "fireside-cli";
10
+ async function readResponseBody(response) {
11
+ const contentType = response.headers.get("content-type") || "";
12
+ if (contentType.includes("application/json")) {
13
+ return await response.json();
14
+ }
15
+ return await response.text();
16
+ }
17
+ function getErrorMessage(body, fallbackMessage) {
18
+ if (typeof body === "string" && body.trim()) {
19
+ return body;
20
+ }
21
+ if (body && typeof body === "object") {
22
+ const candidate = body;
23
+ return candidate.error_description || candidate.message || candidate.error || fallbackMessage;
24
+ }
25
+ return fallbackMessage;
26
+ }
27
+ async function requestJson(url, init) {
28
+ const response = await fetch(url, init);
29
+ const body = await readResponseBody(response);
30
+ if (!response.ok) {
31
+ throw new Error(
32
+ getErrorMessage(body, `Request failed with status ${response.status}.`)
33
+ );
34
+ }
35
+ if (!body || typeof body !== "object") {
36
+ throw new Error("Expected a JSON response.");
37
+ }
38
+ return body;
39
+ }
40
+ function getAuthHeaders(accessToken) {
41
+ return {
42
+ Authorization: `Bearer ${accessToken}`
43
+ };
44
+ }
45
+ function delay(milliseconds) {
46
+ return new Promise((resolve) => setTimeout(resolve, milliseconds));
47
+ }
48
+ async function createDeviceCode(baseUrl) {
49
+ return requestJson(`${baseUrl}/api/auth/device/code`, {
50
+ method: "POST",
51
+ headers: {
52
+ "content-type": "application/json"
53
+ },
54
+ body: JSON.stringify({
55
+ client_id: CLI_CLIENT_ID,
56
+ scope: "openid profile email"
57
+ })
58
+ });
59
+ }
60
+ async function pollForAccessToken(baseUrl, deviceCode, intervalSeconds = 5) {
61
+ let pollingIntervalSeconds = intervalSeconds;
62
+ for (; ; ) {
63
+ await delay(pollingIntervalSeconds * 1e3);
64
+ const response = await fetch(`${baseUrl}/api/auth/device/token`, {
65
+ method: "POST",
66
+ headers: {
67
+ "content-type": "application/json",
68
+ "user-agent": "fireside-cli"
69
+ },
70
+ body: JSON.stringify({
71
+ grant_type: DEVICE_CODE_GRANT_TYPE,
72
+ device_code: deviceCode,
73
+ client_id: CLI_CLIENT_ID
74
+ })
75
+ });
76
+ const body = await readResponseBody(response);
77
+ if (response.ok && typeof body === "object" && body.access_token) {
78
+ return body.access_token;
79
+ }
80
+ const errorCode = typeof body === "object" && body && typeof body.error === "string" ? body.error : void 0;
81
+ switch (errorCode) {
82
+ case "authorization_pending":
83
+ continue;
84
+ case "slow_down":
85
+ pollingIntervalSeconds += 5;
86
+ continue;
87
+ case "access_denied":
88
+ throw new Error("Access denied by user.");
89
+ case "expired_token":
90
+ throw new Error("Device code expired. Run the login command again.");
91
+ default:
92
+ throw new Error(
93
+ getErrorMessage(
94
+ body,
95
+ `Token request failed with status ${response.status}.`
96
+ )
97
+ );
98
+ }
99
+ }
100
+ }
101
+ async function getCurrentUser(baseUrl, accessToken) {
102
+ const data = await requestJson(`${baseUrl}/api/me`, {
103
+ headers: getAuthHeaders(accessToken)
104
+ });
105
+ return data.user;
106
+ }
107
+ async function listProjects(baseUrl, accessToken) {
108
+ return requestJson(`${baseUrl}/api/projects`, {
109
+ headers: getAuthHeaders(accessToken)
110
+ });
111
+ }
112
+ async function listAssignedTasks(baseUrl, accessToken) {
113
+ return requestJson(`${baseUrl}/api/my-stuff`, {
114
+ headers: getAuthHeaders(accessToken)
115
+ });
116
+ }
117
+ async function getHello(baseUrl, accessToken) {
118
+ const response = await fetch(`${baseUrl}/api/hello`, {
119
+ headers: getAuthHeaders(accessToken)
120
+ });
121
+ const body = await readResponseBody(response);
122
+ if (!response.ok) {
123
+ throw new Error(
124
+ getErrorMessage(body, `Request failed with status ${response.status}.`)
125
+ );
126
+ }
127
+ if (typeof body !== "string") {
128
+ throw new Error("Expected a text response.");
129
+ }
130
+ return body;
131
+ }
132
+
133
+ // src/lib/auth-state.ts
134
+ var import_promises = require("fs/promises");
135
+ var import_node_os = require("os");
136
+ var import_node_path = require("path");
137
+ var DEFAULT_BASE_URL = "http://localhost:3000";
138
+ var legacyMigrationPromise = null;
139
+ function normalizeBaseUrl(baseUrl) {
140
+ return baseUrl.trim().replace(/\/+$/, "");
141
+ }
142
+ function getConfigDirectory(appName = "fireside") {
143
+ switch (process.platform) {
144
+ case "darwin":
145
+ return (0, import_node_path.join)((0, import_node_os.homedir)(), "Library", "Application Support", appName);
146
+ case "win32":
147
+ return (0, import_node_path.join)(
148
+ process.env.APPDATA || (0, import_node_path.join)((0, import_node_os.homedir)(), "AppData", "Roaming"),
149
+ appName
150
+ );
151
+ default:
152
+ return (0, import_node_path.join)(
153
+ process.env.XDG_CONFIG_HOME || (0, import_node_path.join)((0, import_node_os.homedir)(), ".config"),
154
+ appName
155
+ );
156
+ }
157
+ }
158
+ function getAuthStateFilePath() {
159
+ return (0, import_node_path.join)(getConfigDirectory(), "auth.json");
160
+ }
161
+ function getConfigStateFilePath() {
162
+ return (0, import_node_path.join)(getConfigDirectory(), "config.json");
163
+ }
164
+ function getLegacyAuthStateFilePath() {
165
+ return (0, import_node_path.join)(getConfigDirectory("fireside-cli"), "auth.json");
166
+ }
167
+ async function fileExists(filePath) {
168
+ try {
169
+ await (0, import_promises.access)(filePath);
170
+ return true;
171
+ } catch {
172
+ return false;
173
+ }
174
+ }
175
+ async function ensureConfigDirectory(filePath) {
176
+ await (0, import_promises.mkdir)((0, import_node_path.dirname)(filePath), {
177
+ recursive: true,
178
+ mode: 448
179
+ });
180
+ }
181
+ async function writeStateFile(filePath, value) {
182
+ await ensureConfigDirectory(filePath);
183
+ await (0, import_promises.writeFile)(filePath, `${JSON.stringify(value, null, 2)}
184
+ `, {
185
+ mode: 384
186
+ });
187
+ await (0, import_promises.chmod)(filePath, 384);
188
+ }
189
+ async function readStateFile(filePath) {
190
+ try {
191
+ const raw = await (0, import_promises.readFile)(filePath, "utf8");
192
+ return JSON.parse(raw);
193
+ } catch (error) {
194
+ if (error.code === "ENOENT") {
195
+ return null;
196
+ }
197
+ throw error;
198
+ }
199
+ }
200
+ function isAuthState(value) {
201
+ if (!value || typeof value !== "object") {
202
+ return false;
203
+ }
204
+ const candidate = value;
205
+ return typeof candidate.accessToken === "string" && typeof candidate.createdAt === "string";
206
+ }
207
+ function isConfigState(value) {
208
+ if (!value || typeof value !== "object") {
209
+ return false;
210
+ }
211
+ const candidate = value;
212
+ return typeof candidate.baseUrl === "string";
213
+ }
214
+ function isLegacyAuthState(value) {
215
+ return isAuthState(value) && isConfigState(value);
216
+ }
217
+ async function migrateLegacyState() {
218
+ const legacyState = await readStateFile(getLegacyAuthStateFilePath());
219
+ if (legacyState === null) {
220
+ return;
221
+ }
222
+ if (!isLegacyAuthState(legacyState)) {
223
+ throw new Error("Invalid legacy auth state file format.");
224
+ }
225
+ const authStateFilePath = getAuthStateFilePath();
226
+ const configStateFilePath = getConfigStateFilePath();
227
+ if (!await fileExists(authStateFilePath)) {
228
+ await writeStateFile(authStateFilePath, {
229
+ accessToken: legacyState.accessToken,
230
+ createdAt: legacyState.createdAt
231
+ });
232
+ }
233
+ if (!await fileExists(configStateFilePath)) {
234
+ await writeStateFile(configStateFilePath, {
235
+ baseUrl: normalizeBaseUrl(legacyState.baseUrl)
236
+ });
237
+ }
238
+ await (0, import_promises.rm)(getLegacyAuthStateFilePath(), { force: true });
239
+ }
240
+ async function ensureLegacyStateMigrated() {
241
+ legacyMigrationPromise ??= migrateLegacyState();
242
+ await legacyMigrationPromise;
243
+ }
244
+ async function resolveBaseUrl(baseUrl, configState) {
245
+ const resolvedConfigState = configState === void 0 ? await loadConfigState() : configState;
246
+ return normalizeBaseUrl(
247
+ baseUrl || process.env.FIRESIDE_BASE_URL || resolvedConfigState?.baseUrl || DEFAULT_BASE_URL
248
+ );
249
+ }
250
+ async function loadAuthState() {
251
+ await ensureLegacyStateMigrated();
252
+ const parsed = await readStateFile(getAuthStateFilePath());
253
+ if (parsed === null) {
254
+ return null;
255
+ }
256
+ if (!isAuthState(parsed)) {
257
+ throw new Error("Invalid auth state file format.");
258
+ }
259
+ return parsed;
260
+ }
261
+ async function loadConfigState() {
262
+ await ensureLegacyStateMigrated();
263
+ const parsed = await readStateFile(getConfigStateFilePath());
264
+ if (parsed === null) {
265
+ return null;
266
+ }
267
+ if (!isConfigState(parsed)) {
268
+ throw new Error("Invalid config state file format.");
269
+ }
270
+ return {
271
+ baseUrl: normalizeBaseUrl(parsed.baseUrl)
272
+ };
273
+ }
274
+ async function saveAuthState(state) {
275
+ await ensureLegacyStateMigrated();
276
+ await writeStateFile(getAuthStateFilePath(), state);
277
+ }
278
+ async function saveConfigState(state) {
279
+ await ensureLegacyStateMigrated();
280
+ await writeStateFile(getConfigStateFilePath(), {
281
+ baseUrl: normalizeBaseUrl(state.baseUrl)
282
+ });
283
+ }
284
+ async function clearAuthState() {
285
+ await ensureLegacyStateMigrated();
286
+ await (0, import_promises.rm)(getAuthStateFilePath(), { force: true });
287
+ }
288
+
289
+ // src/lib/browser.ts
290
+ var import_node_child_process = require("child_process");
291
+ function getBrowserOpenCommand(url) {
292
+ switch (process.platform) {
293
+ case "darwin":
294
+ return {
295
+ command: "open",
296
+ args: [url]
297
+ };
298
+ case "win32":
299
+ return {
300
+ command: "cmd",
301
+ args: ["/c", "start", "", url]
302
+ };
303
+ default:
304
+ return {
305
+ command: "xdg-open",
306
+ args: [url]
307
+ };
308
+ }
309
+ }
310
+ function openBrowser(url) {
311
+ const { command, args } = getBrowserOpenCommand(url);
312
+ try {
313
+ const child = (0, import_node_child_process.spawn)(command, args, {
314
+ detached: process.platform !== "win32",
315
+ stdio: "ignore"
316
+ });
317
+ child.unref();
318
+ return true;
319
+ } catch {
320
+ return false;
321
+ }
322
+ }
323
+
324
+ // src/index.ts
325
+ function formatUserCodeForDisplay(userCode) {
326
+ return userCode.match(/.{1,4}/g)?.join("-") || userCode;
327
+ }
328
+ function addBaseUrlOption(command) {
329
+ return command.option(
330
+ "-b, --base-url <url>",
331
+ "Fireside base URL",
332
+ process.env.FIRESIDE_BASE_URL
333
+ );
334
+ }
335
+ async function requireAuthState() {
336
+ const state = await loadAuthState();
337
+ if (!state) {
338
+ throw new Error("Not signed in. Run `fireside login` first.");
339
+ }
340
+ return state;
341
+ }
342
+ function printProjects(projects) {
343
+ if (!projects.length) {
344
+ console.log("No projects found.");
345
+ return;
346
+ }
347
+ for (const project of projects) {
348
+ console.log(`${project.title} (${project.id})`);
349
+ console.log(` Color: ${project.color}`);
350
+ console.log(` Members: ${project.members.length}`);
351
+ console.log(` ${project.description}`);
352
+ console.log("");
353
+ }
354
+ }
355
+ function formatDueDate(dueDate) {
356
+ if (!dueDate) {
357
+ return "No due date";
358
+ }
359
+ return new Intl.DateTimeFormat(void 0, {
360
+ month: "short",
361
+ day: "numeric",
362
+ year: "numeric"
363
+ }).format(/* @__PURE__ */ new Date(`${dueDate}T00:00:00`));
364
+ }
365
+ function printAssignedTasks(baseUrl, tasks) {
366
+ if (!tasks.length) {
367
+ console.log("Nothing assigned right now.");
368
+ return;
369
+ }
370
+ for (const task of tasks) {
371
+ console.log(`${task.title} (${task.id})`);
372
+ console.log(
373
+ ` ${task.projectTitle} / ${task.boardTitle} / ${task.columnTitle}`
374
+ );
375
+ console.log(` Due: ${formatDueDate(task.dueDate)}`);
376
+ console.log(
377
+ ` Assignees: ${task.assignees.map((assignee) => assignee.name).join(", ") || "None"}`
378
+ );
379
+ if (task.description) {
380
+ console.log(` ${task.description}`);
381
+ }
382
+ console.log(
383
+ ` ${baseUrl}/projects/${encodeURIComponent(task.projectId)}/boards/tasks/${encodeURIComponent(task.id)}`
384
+ );
385
+ console.log("");
386
+ }
387
+ }
388
+ var program = new import_commander.Command();
389
+ program.name("fireside").description("Fireside CLI").version("0.0.1").showHelpAfterError();
390
+ addBaseUrlOption(
391
+ program.command("login").description("Authenticate with Fireside using device authorization").option("--no-open", "Do not open the browser automatically").action(async (options) => {
392
+ const configState = await loadConfigState();
393
+ const baseUrl = await resolveBaseUrl(options.baseUrl, configState);
394
+ const deviceCode = await createDeviceCode(baseUrl);
395
+ const verificationUrl = deviceCode.verification_uri;
396
+ console.log(`Base URL: ${baseUrl}`);
397
+ console.log(`Open this URL: ${verificationUrl}`);
398
+ console.log(
399
+ `Enter this code in the browser: ${formatUserCodeForDisplay(deviceCode.user_code)}`
400
+ );
401
+ if (options.open) {
402
+ const opened = openBrowser(verificationUrl);
403
+ if (opened) {
404
+ console.log("Opened the browser for approval.");
405
+ }
406
+ }
407
+ console.log("Waiting for approval...");
408
+ const accessToken = await pollForAccessToken(
409
+ baseUrl,
410
+ deviceCode.device_code,
411
+ deviceCode.interval
412
+ );
413
+ await saveConfigState({ baseUrl });
414
+ await saveAuthState({
415
+ accessToken,
416
+ createdAt: (/* @__PURE__ */ new Date()).toISOString()
417
+ });
418
+ const user = await getCurrentUser(baseUrl, accessToken);
419
+ console.log(`Signed in as ${user.email}.`);
420
+ })
421
+ );
422
+ program.command("logout").description("Remove the local CLI session").action(async () => {
423
+ const state = await loadAuthState();
424
+ if (!state) {
425
+ console.log("Already signed out.");
426
+ return;
427
+ }
428
+ await clearAuthState();
429
+ console.log("Removed the local CLI session.");
430
+ });
431
+ addBaseUrlOption(
432
+ program.command("status").description("Show the current authenticated CLI user").action(async (options) => {
433
+ const state = await requireAuthState();
434
+ const configState = await loadConfigState();
435
+ const baseUrl = await resolveBaseUrl(options.baseUrl, configState);
436
+ const user = await getCurrentUser(baseUrl, state.accessToken);
437
+ console.log(`Base URL: ${baseUrl}`);
438
+ console.log(`Signed in as: ${user.name} <${user.email}>`);
439
+ })
440
+ );
441
+ addBaseUrlOption(
442
+ program.command("hello").description("Call the sample authenticated API endpoint").action(async (options) => {
443
+ const state = await requireAuthState();
444
+ const configState = await loadConfigState();
445
+ const baseUrl = await resolveBaseUrl(options.baseUrl, configState);
446
+ const message = await getHello(baseUrl, state.accessToken);
447
+ console.log(message);
448
+ })
449
+ );
450
+ addBaseUrlOption(
451
+ program.command("my-stuff").description("List tasks currently assigned to you").action(async (options) => {
452
+ const state = await requireAuthState();
453
+ const configState = await loadConfigState();
454
+ const baseUrl = await resolveBaseUrl(options.baseUrl, configState);
455
+ const tasks = await listAssignedTasks(baseUrl, state.accessToken);
456
+ printAssignedTasks(baseUrl, tasks);
457
+ })
458
+ );
459
+ addBaseUrlOption(
460
+ program.command("projects").description("Interact with project APIs").command("list").description("List accessible projects").action(async function() {
461
+ const state = await requireAuthState();
462
+ const configState = await loadConfigState();
463
+ const baseUrl = await resolveBaseUrl(
464
+ this.optsWithGlobals().baseUrl,
465
+ configState
466
+ );
467
+ const projects = await listProjects(baseUrl, state.accessToken);
468
+ printProjects(projects);
469
+ })
470
+ );
471
+ async function main() {
472
+ try {
473
+ await program.parseAsync(process.argv);
474
+ } catch (error) {
475
+ const message = error instanceof Error ? error.message : "Unknown CLI error.";
476
+ console.error(message);
477
+ process.exitCode = 1;
478
+ }
479
+ }
480
+ void main();
package/package.json ADDED
@@ -0,0 +1,34 @@
1
+ {
2
+ "name": "@khangal.j/fireside-cli",
3
+ "version": "0.0.1",
4
+ "description": "Fireside CLI",
5
+ "license": "MIT",
6
+ "private": false,
7
+ "type": "commonjs",
8
+ "main": "dist/index.js",
9
+ "bin": {
10
+ "fireside": "dist/index.js"
11
+ },
12
+ "files": [
13
+ "dist",
14
+ "README.md"
15
+ ],
16
+ "engines": {
17
+ "node": ">=20"
18
+ },
19
+ "publishConfig": {
20
+ "access": "public"
21
+ },
22
+ "scripts": {
23
+ "build": "tsup src/index.ts --format cjs --platform node --target node20 --out-dir dist --clean",
24
+ "prepublishOnly": "pnpm build",
25
+ "typecheck": "tsc -p tsconfig.json --noEmit",
26
+ "start": "node dist/index.js"
27
+ },
28
+ "dependencies": {
29
+ "commander": "^14.0.1"
30
+ },
31
+ "devDependencies": {
32
+ "tsup": "^8.5.0"
33
+ }
34
+ }