feedbackbasket-cli 3.0.0 → 3.1.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,8 +1,9 @@
1
1
  interface LoginResult {
2
2
  token: string;
3
- scope: 'read' | 'full';
3
+ scope: "read" | "full";
4
4
  }
5
+ export declare function validateReturnedScope(value: string | null, maximum: "read" | "full"): "read" | "full";
5
6
  export declare function isCliTokenFormat(token: string): boolean;
6
- export declare function browserLogin(baseUrl: string, scope?: 'read' | 'full'): Promise<LoginResult>;
7
- export declare function manualLogin(baseUrl: string, scope?: 'read' | 'full'): Promise<LoginResult>;
7
+ export declare function browserLogin(baseUrl: string, scope?: "read" | "full"): Promise<LoginResult>;
8
+ export declare function manualLogin(baseUrl: string, scope?: "read" | "full"): Promise<LoginResult>;
8
9
  export {};
@@ -1,10 +1,19 @@
1
- import { createInterface } from 'node:readline/promises';
2
- import { stdin as input, stdout as output } from 'node:process';
3
- import { createServer } from 'node:http';
4
- import { randomUUID } from 'node:crypto';
5
- import { URL } from 'node:url';
6
- import open from 'open';
7
- import { brand, logo } from '../output/theme.js';
1
+ import { createInterface } from "node:readline/promises";
2
+ import { stdin as input, stdout as output } from "node:process";
3
+ import { createServer, } from "node:http";
4
+ import { randomUUID } from "node:crypto";
5
+ import { URL } from "node:url";
6
+ import open from "open";
7
+ import { brand, logo } from "../output/theme.js";
8
+ export function validateReturnedScope(value, maximum) {
9
+ if (value !== "read" && value !== "full") {
10
+ throw new Error("The authorization response did not include a valid access level");
11
+ }
12
+ if (maximum === "read" && value === "full") {
13
+ throw new Error("The authorization response exceeds the requested access maximum");
14
+ }
15
+ return value;
16
+ }
8
17
  const TIMEOUT_MS = 120_000;
9
18
  const CLI_TOKEN_PATTERN = /^fb_cli_[a-f0-9]{64}$/;
10
19
  export function isCliTokenFormat(token) {
@@ -38,30 +47,31 @@ const ERROR_HTML = (message) => `<!DOCTYPE html>
38
47
  <h1>Authentication Failed</h1>
39
48
  <p>${message}</p>
40
49
  </div></body></html>`;
41
- export async function browserLogin(baseUrl, scope = 'read') {
50
+ export async function browserLogin(baseUrl, scope = "read") {
42
51
  const state = randomUUID();
43
52
  return new Promise((resolve, reject) => {
44
53
  const server = createServer((req, res) => {
45
- const url = new URL(req.url ?? '/', `http://127.0.0.1`);
46
- if (url.pathname !== '/callback') {
54
+ const url = new URL(req.url ?? "/", `http://127.0.0.1`);
55
+ if (url.pathname !== "/callback") {
47
56
  res.writeHead(404);
48
- res.end('Not found');
57
+ res.end("Not found");
49
58
  return;
50
59
  }
51
- const receivedState = url.searchParams.get('state');
52
- const token = url.searchParams.get('token');
53
- const error = url.searchParams.get('error');
60
+ const receivedState = url.searchParams.get("state");
61
+ const token = url.searchParams.get("token");
62
+ const returnedScope = url.searchParams.get("scope");
63
+ const error = url.searchParams.get("error");
54
64
  // CSRF validation
55
65
  if (receivedState !== state) {
56
- res.writeHead(400, { 'Content-Type': 'text/html' });
57
- res.end(ERROR_HTML('State mismatch — possible CSRF attack. Please try again.'));
66
+ res.writeHead(400, { "Content-Type": "text/html" });
67
+ res.end(ERROR_HTML("State mismatch — possible CSRF attack. Please try again."));
58
68
  cleanup();
59
- reject(new Error('State mismatch during authentication'));
69
+ reject(new Error("State mismatch during authentication"));
60
70
  return;
61
71
  }
62
72
  // User denied
63
73
  if (error) {
64
- res.writeHead(200, { 'Content-Type': 'text/html' });
74
+ res.writeHead(200, { "Content-Type": "text/html" });
65
75
  res.end(ERROR_HTML(`Authorization was denied: ${error}`));
66
76
  cleanup();
67
77
  reject(new Error(`Authorization denied: ${error}`));
@@ -69,32 +79,44 @@ export async function browserLogin(baseUrl, scope = 'read') {
69
79
  }
70
80
  // Missing token
71
81
  if (!token) {
72
- res.writeHead(400, { 'Content-Type': 'text/html' });
73
- res.end(ERROR_HTML('No token received. Please try again.'));
82
+ res.writeHead(400, { "Content-Type": "text/html" });
83
+ res.end(ERROR_HTML("No token received. Please try again."));
84
+ cleanup();
85
+ reject(new Error("No token received in callback"));
86
+ return;
87
+ }
88
+ let selectedScope;
89
+ try {
90
+ selectedScope = validateReturnedScope(returnedScope, scope);
91
+ }
92
+ catch (cause) {
93
+ const message = cause instanceof Error ? cause.message : "Invalid access level";
94
+ res.writeHead(400, { "Content-Type": "text/html" });
95
+ res.end(ERROR_HTML(message));
74
96
  cleanup();
75
- reject(new Error('No token received in callback'));
97
+ reject(new Error(message));
76
98
  return;
77
99
  }
78
100
  // Success
79
- res.writeHead(200, { 'Content-Type': 'text/html' });
101
+ res.writeHead(200, { "Content-Type": "text/html" });
80
102
  res.end(SUCCESS_HTML);
81
103
  cleanup();
82
- resolve({ token, scope });
104
+ resolve({ token, scope: selectedScope });
83
105
  });
84
106
  const timeout = setTimeout(() => {
85
107
  cleanup();
86
- reject(new Error('Authentication timed out after 2 minutes'));
108
+ reject(new Error("Authentication timed out after 2 minutes"));
87
109
  }, TIMEOUT_MS);
88
110
  function cleanup() {
89
111
  clearTimeout(timeout);
90
112
  server.close();
91
113
  }
92
114
  // Listen on random port on loopback
93
- server.listen(0, '127.0.0.1', () => {
115
+ server.listen(0, "127.0.0.1", () => {
94
116
  const addr = server.address();
95
- if (!addr || typeof addr === 'string') {
117
+ if (!addr || typeof addr === "string") {
96
118
  cleanup();
97
- reject(new Error('Failed to start local auth server'));
119
+ reject(new Error("Failed to start local auth server"));
98
120
  return;
99
121
  }
100
122
  const port = addr.port;
@@ -102,14 +124,14 @@ export async function browserLogin(baseUrl, scope = 'read') {
102
124
  console.log();
103
125
  console.log(` ${logo()} CLI`);
104
126
  console.log();
105
- console.log(` ${brand.primary('Opening browser for authentication...')}`);
127
+ console.log(` ${brand.primary("Opening browser for authentication...")}`);
106
128
  console.log();
107
- console.log(` ${brand.muted('If the browser doesn\'t open, visit:')}`);
129
+ console.log(` ${brand.muted("If the browser doesn't open, visit:")}`);
108
130
  console.log(` ${brand.primary(authorizeUrl)}`);
109
131
  console.log();
110
- console.log(` ${brand.muted('Waiting for authentication...')}`);
111
- console.log(` ${brand.muted('If this machine cannot receive the localhost browser callback, use:')}`);
112
- console.log(` ${brand.command('feedbackbasket login --manual')}`);
132
+ console.log(` ${brand.muted("Waiting for authentication...")}`);
133
+ console.log(` ${brand.muted("If this machine cannot receive the localhost browser callback, use:")}`);
134
+ console.log(` ${brand.command("feedbackbasket login --manual")}`);
113
135
  console.log();
114
136
  open(authorizeUrl).catch(() => {
115
137
  // Browser open failed; user will use the URL manually.
@@ -117,33 +139,38 @@ export async function browserLogin(baseUrl, scope = 'read') {
117
139
  });
118
140
  });
119
141
  }
120
- export async function manualLogin(baseUrl, scope = 'read') {
142
+ export async function manualLogin(baseUrl, scope = "read") {
121
143
  const authorizeUrl = `${baseUrl}/cli/authorize?mode=manual&scope=${scope}`;
122
144
  console.log();
123
145
  console.log(` ${logo()} CLI`);
124
146
  console.log();
125
- console.log(` ${brand.primary('Open this URL on any machine with a browser:')}`);
147
+ console.log(` ${brand.primary("Open this URL on any machine with a browser:")}`);
126
148
  console.log();
127
149
  console.log(` ${brand.primary(authorizeUrl)}`);
128
150
  console.log();
129
- console.log(` ${brand.muted('Use this when a remote server cannot receive the localhost browser callback.')}`);
130
- console.log(` ${brand.muted('The CLI still needs outbound HTTPS access to verify and use the token.')}`);
151
+ console.log(` ${brand.muted("Use this when a remote server cannot receive the localhost browser callback.")}`);
152
+ console.log(` ${brand.muted("The CLI still needs outbound HTTPS access to verify and use the token.")}`);
131
153
  console.log();
132
- console.log(` ${brand.muted('After approving access, paste the token shown in your browser.')}`);
154
+ console.log(` ${brand.muted("After approving access, paste the token shown in your browser.")}`);
133
155
  console.log();
134
156
  open(authorizeUrl).catch(() => {
135
157
  // Browser open failed; user will use the URL manually.
136
158
  });
137
159
  const rl = createInterface({ input, output });
138
160
  try {
139
- const token = (await rl.question(' Paste token: ')).trim();
140
- if (!token) {
141
- throw new Error('No token provided');
161
+ const rawResult = (await rl.question(" Paste authorization result: ")).trim();
162
+ let parsed;
163
+ try {
164
+ parsed = JSON.parse(rawResult);
165
+ }
166
+ catch {
167
+ throw new Error("Expected the authorization result copied from the browser");
142
168
  }
143
- if (!isCliTokenFormat(token)) {
144
- throw new Error('Expected a FeedbackBasket CLI token beginning with fb_cli_');
169
+ if (typeof parsed.token !== "string" || !isCliTokenFormat(parsed.token)) {
170
+ throw new Error("Expected a FeedbackBasket CLI token beginning with fb_cli_");
145
171
  }
146
- return { token, scope };
172
+ const selectedScope = validateReturnedScope(typeof parsed.scope === "string" ? parsed.scope : null, scope);
173
+ return { token: parsed.token, scope: selectedScope };
147
174
  }
148
175
  finally {
149
176
  rl.close();
@@ -4,6 +4,10 @@ export declare const CLI_CAPABILITIES: readonly {
4
4
  commands: readonly string[];
5
5
  }[];
6
6
  export declare const CLI_EXEMPTIONS: ({
7
+ readonly id: "browser-oauth";
8
+ readonly surface: "mcp-http";
9
+ readonly reason: "Browser OAuth is a Streamable HTTP transport capability; STDIO uses environment credentials and the CLI keeps its own login flow.";
10
+ } | {
7
11
  readonly id: "auth";
8
12
  readonly surface: "cli";
9
13
  readonly reason: "Authentication is local CLI credential management, not a product operation.";
@@ -1,6 +1,12 @@
1
- import { Command } from 'commander';
2
- import type { OutputWriter } from '../output/writer.js';
1
+ import { Command } from "commander";
2
+ import type { OutputWriter } from "../output/writer.js";
3
+ export declare function authLoginResult(email: string | undefined, scope: "read" | "full", defaultProject: string | undefined): {
4
+ authenticated: boolean;
5
+ email: string | undefined;
6
+ scope: "read" | "full";
7
+ defaultProject: string | undefined;
8
+ };
3
9
  export declare function createAuthCommand(getWriter: () => OutputWriter): Command;
4
- export declare function resolveAuthScope(value: string): 'read' | 'full';
10
+ export declare function resolveAuthScope(value: string): "read" | "full";
5
11
  export declare function createLoginCommand(getWriter: () => OutputWriter): Command;
6
12
  export declare function createLogoutCommand(getWriter: () => OutputWriter): Command;
@@ -1,25 +1,27 @@
1
- import { Command } from 'commander';
2
- import { existsSync } from 'node:fs';
3
- import { join } from 'node:path';
4
- import { homedir } from 'node:os';
5
- import { AuthManager } from '../auth/manager.js';
6
- import { browserLogin, isCliTokenFormat, manualLogin } from '../auth/login.js';
7
- import { saveCredentials } from '../config/credentials.js';
8
- import { loadConfig, saveConfig } from '../config/config.js';
9
- import { FeedbackBasketClient } from '../client.js';
10
- import { errAuth, errUsage } from '../output/errors.js';
11
- import { brand, divider, logo } from '../output/theme.js';
12
- import { select, confirm } from '../prompt.js';
1
+ import { Command } from "commander";
2
+ import { existsSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ import { homedir } from "node:os";
5
+ import { AuthManager } from "../auth/manager.js";
6
+ import { browserLogin, isCliTokenFormat, manualLogin } from "../auth/login.js";
7
+ import { saveCredentials } from "../config/credentials.js";
8
+ import { loadConfig, saveConfig } from "../config/config.js";
9
+ import { FeedbackBasketClient } from "../client.js";
10
+ import { errAuth, errUsage } from "../output/errors.js";
11
+ import { brand, divider, logo } from "../output/theme.js";
12
+ import { select, confirm } from "../prompt.js";
13
+ export function authLoginResult(email, scope, defaultProject) {
14
+ return { authenticated: true, email, scope, defaultProject };
15
+ }
13
16
  export function createAuthCommand(getWriter) {
14
- const auth = new Command('auth')
15
- .description('Manage authentication');
17
+ const auth = new Command("auth").description("Manage authentication");
16
18
  // --- auth login ---
17
19
  auth
18
- .command('login')
19
- .description('Authenticate with FeedbackBasket')
20
- .option('--token <token>', 'Use an API token directly (for CI/headless)')
21
- .option('--manual', 'Authenticate without a localhost browser callback')
22
- .option('--scope <scope>', 'Access scope: read or full', 'full')
20
+ .command("login")
21
+ .description("Authenticate with FeedbackBasket")
22
+ .option("--token <token>", "Use an API token directly (for CI/headless)")
23
+ .option("--manual", "Authenticate without a localhost browser callback")
24
+ .option("--scope <scope>", "Maximum browser access: read or full", "full")
23
25
  .action(async (opts) => {
24
26
  const writer = getWriter();
25
27
  const config = loadConfig();
@@ -34,19 +36,22 @@ export function createAuthCommand(getWriter) {
34
36
  console.log();
35
37
  }
36
38
  let token;
39
+ let selectedScope = scope;
37
40
  if (opts.token) {
38
41
  token = opts.token;
39
42
  }
40
43
  else if (opts.manual) {
41
44
  const result = await manualLogin(config.baseUrl, scope);
42
45
  token = result.token;
46
+ selectedScope = result.scope;
43
47
  }
44
48
  else {
45
49
  const result = await browserLogin(config.baseUrl, scope);
46
50
  token = result.token;
51
+ selectedScope = result.scope;
47
52
  }
48
53
  if (!isCliTokenFormat(token)) {
49
- throw errUsage('Expected a FeedbackBasket CLI token beginning with fb_cli_', 'MCP API keys begin with fb_key_ and are only for MCP server configuration');
54
+ throw errUsage("Expected a FeedbackBasket CLI token beginning with fb_cli_", "MCP API keys begin with fb_key_ and are only for MCP server configuration");
50
55
  }
51
56
  // Verify token + get profile
52
57
  const client = new FeedbackBasketClient(token, config.baseUrl);
@@ -63,22 +68,22 @@ export function createAuthCommand(getWriter) {
63
68
  if (isInteractive) {
64
69
  const msg = err instanceof Error ? err.message : String(err);
65
70
  console.log(brand.warning(` Could not verify token: ${msg}`));
66
- console.log(brand.muted(' Token saved — run feedbackbasket doctor to diagnose'));
71
+ console.log(brand.muted(" Token saved — run feedbackbasket doctor to diagnose"));
67
72
  console.log();
68
73
  }
69
74
  }
70
75
  saveCredentials({
71
76
  token,
72
- scope,
77
+ scope: selectedScope,
73
78
  userId,
74
79
  email,
75
80
  organizationId,
76
81
  createdAt: new Date().toISOString(),
77
82
  });
78
83
  if (isInteractive) {
79
- console.log(` ${brand.success('')} Authentication successful`);
84
+ console.log(` ${brand.success("")} Authentication successful`);
80
85
  if (email)
81
- console.log(` ${brand.muted('Logged in as')} ${brand.bold(email)}`);
86
+ console.log(` ${brand.muted("Logged in as")} ${brand.bold(email)}`);
82
87
  console.log();
83
88
  }
84
89
  // ── Step 2: Default Project (interactive only, first login) ──
@@ -91,7 +96,7 @@ export function createAuthCommand(getWriter) {
91
96
  const projectsRes = await client.listProjects();
92
97
  const projects = projectsRes.projects;
93
98
  if (projects.length === 0) {
94
- console.log(brand.muted(' No projects found. Create one from the dashboard.'));
99
+ console.log(brand.muted(" No projects found. Create one from the dashboard."));
95
100
  console.log();
96
101
  }
97
102
  else if (projects.length === 1) {
@@ -100,13 +105,13 @@ export function createAuthCommand(getWriter) {
100
105
  config.defaultProject = p.id;
101
106
  saveConfig(config);
102
107
  defaultProjectName = p.name;
103
- console.log(` ${brand.success('')} Default project: ${brand.bold(p.name)}`);
104
- console.log(` ${brand.muted('Only project in your organization')}`);
108
+ console.log(` ${brand.success("")} Default project: ${brand.bold(p.name)}`);
109
+ console.log(` ${brand.muted("Only project in your organization")}`);
105
110
  console.log();
106
111
  }
107
112
  else {
108
113
  console.log(` You have ${brand.bold(String(projects.length))} projects:\n`);
109
- const choice = await select('Select default project', projects.map(p => {
114
+ const choice = await select("Select default project", projects.map((p) => {
110
115
  const count = brand.muted(`(${p.totalFeedback} feedback)`);
111
116
  return `${p.name} ${count}`;
112
117
  }));
@@ -116,12 +121,12 @@ export function createAuthCommand(getWriter) {
116
121
  saveConfig(config);
117
122
  defaultProjectName = p.name;
118
123
  console.log();
119
- console.log(` ${brand.success('')} Default project: ${brand.bold(p.name)}`);
120
- console.log(` ${brand.muted('Use --project to override per-command')}`);
124
+ console.log(` ${brand.success("")} Default project: ${brand.bold(p.name)}`);
125
+ console.log(` ${brand.muted("Use --project to override per-command")}`);
121
126
  }
122
127
  else {
123
128
  console.log();
124
- console.log(` ${brand.muted('Skipped. Use --project or: feedbackbasket config set defaultProject <id>')}`);
129
+ console.log(` ${brand.muted("Skipped. Use --project or: feedbackbasket config set defaultProject <id>")}`);
125
130
  }
126
131
  console.log();
127
132
  }
@@ -129,55 +134,55 @@ export function createAuthCommand(getWriter) {
129
134
  catch (err) {
130
135
  const msg = err instanceof Error ? err.message : String(err);
131
136
  console.log(brand.muted(` Could not fetch projects: ${msg}`));
132
- console.log(brand.muted(' You can set a default later: feedbackbasket config set defaultProject <id>'));
137
+ console.log(brand.muted(" You can set a default later: feedbackbasket config set defaultProject <id>"));
133
138
  console.log();
134
139
  }
135
140
  }
136
141
  // ── Step 3: Agent Setup (interactive only) ──
137
142
  let agentInstalled = false;
138
143
  if (isInteractive) {
139
- const claudeDir = join(homedir(), '.claude');
140
- const skillPath = join(claudeDir, 'skills', 'feedbackbasket', 'SKILL.md');
144
+ const claudeDir = join(homedir(), ".claude");
145
+ const skillPath = join(claudeDir, "skills", "feedbackbasket", "SKILL.md");
141
146
  const claudeExists = existsSync(claudeDir);
142
147
  const skillExists = existsSync(skillPath);
143
148
  if (claudeExists && !skillExists) {
144
149
  console.log();
145
150
  console.log(brand.muted(` Step 3: Agent Setup`));
146
151
  console.log();
147
- console.log(` ${brand.primary('Detected:')} Claude Code`);
152
+ console.log(` ${brand.primary("Detected:")} Claude Code`);
148
153
  console.log();
149
154
  console.log(` This will:`);
150
155
  console.log(` 1. Install FeedbackBasket skill to ~/.claude/skills/`);
151
156
  console.log();
152
- const doSetup = await confirm(' Set up for Claude Code?');
157
+ const doSetup = await confirm(" Set up for Claude Code?");
153
158
  if (doSetup) {
154
159
  try {
155
- const { mkdirSync, copyFileSync } = await import('node:fs');
156
- const { dirname } = await import('node:path');
157
- const { fileURLToPath } = await import('node:url');
160
+ const { mkdirSync, copyFileSync } = await import("node:fs");
161
+ const { dirname } = await import("node:path");
162
+ const { fileURLToPath } = await import("node:url");
158
163
  const thisDir = dirname(fileURLToPath(import.meta.url));
159
- const projectRoot = join(thisDir, '..', '..', '..');
160
- let skillSrc = join(projectRoot, 'skills', 'feedbackbasket', 'SKILL.md');
164
+ const projectRoot = join(thisDir, "..", "..", "..");
165
+ let skillSrc = join(projectRoot, "skills", "feedbackbasket", "SKILL.md");
161
166
  if (!existsSync(skillSrc)) {
162
- skillSrc = join(thisDir, '..', '..', 'skills', 'feedbackbasket', 'SKILL.md');
167
+ skillSrc = join(thisDir, "..", "..", "skills", "feedbackbasket", "SKILL.md");
163
168
  }
164
169
  if (existsSync(skillSrc)) {
165
- const skillDir = join(claudeDir, 'skills', 'feedbackbasket');
170
+ const skillDir = join(claudeDir, "skills", "feedbackbasket");
166
171
  mkdirSync(skillDir, { recursive: true });
167
172
  copyFileSync(skillSrc, skillPath);
168
173
  agentInstalled = true;
169
- console.log(` ${brand.success('')} Agent skill installed`);
174
+ console.log(` ${brand.success("")} Agent skill installed`);
170
175
  }
171
176
  else {
172
- console.log(` ${brand.error('')} SKILL.md not found in package`);
177
+ console.log(` ${brand.error("")} SKILL.md not found in package`);
173
178
  }
174
179
  }
175
180
  catch {
176
- console.log(` ${brand.error('')} Failed to install skill`);
181
+ console.log(` ${brand.error("")} Failed to install skill`);
177
182
  }
178
183
  }
179
184
  else {
180
- console.log(brand.muted(' Skipped. Run later: feedbackbasket setup claude'));
185
+ console.log(brand.muted(" Skipped. Run later: feedbackbasket setup claude"));
181
186
  }
182
187
  console.log();
183
188
  }
@@ -189,78 +194,80 @@ export function createAuthCommand(getWriter) {
189
194
  if (isInteractive) {
190
195
  console.log();
191
196
  console.log(divider(35));
192
- console.log(brand.bold(' Setup complete!'));
197
+ console.log(brand.bold(" Setup complete!"));
193
198
  console.log(divider(35));
194
199
  console.log();
195
- console.log(` ${brand.success('')} Authenticated${email ? ` as ${email}` : ''}`);
200
+ console.log(` ${brand.success("")} Authenticated${email ? ` as ${email}` : ""}`);
196
201
  if (defaultProjectName) {
197
- console.log(` ${brand.success('')} Default project: ${defaultProjectName}`);
202
+ console.log(` ${brand.success("")} Default project: ${defaultProjectName}`);
198
203
  }
199
204
  else if (config.defaultProject) {
200
- console.log(` ${brand.success('')} Default project: ${config.defaultProject}`);
205
+ console.log(` ${brand.success("")} Default project: ${config.defaultProject}`);
201
206
  }
202
207
  else {
203
- console.log(` ${brand.muted('-')} No default project`);
208
+ console.log(` ${brand.muted("-")} No default project`);
204
209
  }
205
210
  if (agentInstalled) {
206
- console.log(` ${brand.success('')} Claude Code skill`);
211
+ console.log(` ${brand.success("")} Claude Code skill`);
207
212
  }
208
213
  console.log();
209
- console.log(' Try these commands:');
214
+ console.log(" Try these commands:");
210
215
  console.log();
211
- console.log(` ${brand.command('feedbackbasket projects list')} List your projects`);
212
- console.log(` ${brand.command('feedbackbasket feedback list')} View recent feedback`);
213
- console.log(` ${brand.command('feedbackbasket bugs list')} View bug reports`);
214
- console.log(` ${brand.command('feedbackbasket doctor')} Run diagnostics`);
216
+ console.log(` ${brand.command("feedbackbasket projects list")} List your projects`);
217
+ console.log(` ${brand.command("feedbackbasket feedback list")} View recent feedback`);
218
+ console.log(` ${brand.command("feedbackbasket bugs list")} View bug reports`);
219
+ console.log(` ${brand.command("feedbackbasket doctor")} Run diagnostics`);
215
220
  console.log();
216
221
  return; // Skip the JSON output in interactive wizard mode
217
222
  }
218
223
  // Machine output
219
- writer.ok({ authenticated: true, email, scope, defaultProject: config.defaultProject }, {
220
- summary: email ? `Logged in as ${email}` : 'Authenticated successfully',
224
+ writer.ok(authLoginResult(email, selectedScope, config.defaultProject), {
225
+ summary: email
226
+ ? `Logged in as ${email}`
227
+ : "Authenticated successfully",
221
228
  breadcrumbs: [
222
- { action: 'List projects', cmd: 'feedbackbasket projects list' },
223
- { action: 'Check status', cmd: 'feedbackbasket auth status' },
224
- { action: 'Run diagnostics', cmd: 'feedbackbasket doctor' },
229
+ { action: "List projects", cmd: "feedbackbasket projects list" },
230
+ { action: "Check status", cmd: "feedbackbasket auth status" },
231
+ { action: "Run diagnostics", cmd: "feedbackbasket doctor" },
225
232
  ],
226
233
  });
227
234
  });
228
235
  // --- auth logout ---
229
236
  auth
230
- .command('logout')
231
- .description('Clear stored credentials')
237
+ .command("logout")
238
+ .description("Clear stored credentials")
232
239
  .action(() => {
233
240
  const writer = getWriter();
234
241
  const manager = new AuthManager();
235
242
  if (!manager.isAuthenticated()) {
236
- writer.ok({ authenticated: false }, { summary: 'Already logged out' });
243
+ writer.ok({ authenticated: false }, { summary: "Already logged out" });
237
244
  return;
238
245
  }
239
- if (manager.getSource() === 'env') {
240
- throw errUsage('Credentials are set via FEEDBACKBASKET_TOKEN environment variable', 'Unset the variable: unset FEEDBACKBASKET_TOKEN');
246
+ if (manager.getSource() === "env") {
247
+ throw errUsage("Credentials are set via FEEDBACKBASKET_TOKEN environment variable", "Unset the variable: unset FEEDBACKBASKET_TOKEN");
241
248
  }
242
249
  manager.logout();
243
250
  if (!writer.isMachineOutput()) {
244
- console.log(` ${brand.success('')} Logged out`);
251
+ console.log(` ${brand.success("")} Logged out`);
245
252
  console.log();
246
253
  }
247
254
  writer.ok({ authenticated: false }, {
248
- summary: 'Logged out',
255
+ summary: "Logged out",
249
256
  breadcrumbs: [
250
- { action: 'Log back in', cmd: 'feedbackbasket auth login' },
257
+ { action: "Log back in", cmd: "feedbackbasket auth login" },
251
258
  ],
252
259
  });
253
260
  });
254
261
  // --- auth status ---
255
262
  auth
256
- .command('status')
257
- .description('Show authentication status')
263
+ .command("status")
264
+ .description("Show authentication status")
258
265
  .action(() => {
259
266
  const writer = getWriter();
260
267
  const manager = new AuthManager();
261
268
  const creds = manager.getCredentials();
262
269
  if (!creds) {
263
- throw errAuth('Not authenticated');
270
+ throw errAuth("Not authenticated");
264
271
  }
265
272
  const config = loadConfig();
266
273
  const data = {
@@ -273,57 +280,57 @@ export function createAuthCommand(getWriter) {
273
280
  defaultProject: config.defaultProject ?? null,
274
281
  };
275
282
  writer.ok(data, {
276
- summary: `Authenticated${creds.email ? ` as ${creds.email}` : ''}`,
283
+ summary: `Authenticated${creds.email ? ` as ${creds.email}` : ""}`,
277
284
  breadcrumbs: [
278
- { action: 'List projects', cmd: 'feedbackbasket projects list' },
279
- { action: 'Log out', cmd: 'feedbackbasket auth logout' },
285
+ { action: "List projects", cmd: "feedbackbasket projects list" },
286
+ { action: "Log out", cmd: "feedbackbasket auth logout" },
280
287
  ],
281
288
  });
282
289
  });
283
290
  // --- auth token ---
284
291
  auth
285
- .command('token')
286
- .description('Print the current access token (for scripting)')
292
+ .command("token")
293
+ .description("Print the current access token (for scripting)")
287
294
  .action(() => {
288
295
  const manager = new AuthManager();
289
296
  const token = manager.resolveToken();
290
297
  if (!token) {
291
- throw errAuth('Not authenticated');
298
+ throw errAuth("Not authenticated");
292
299
  }
293
300
  process.stdout.write(token);
294
301
  });
295
302
  return auth;
296
303
  }
297
304
  export function resolveAuthScope(value) {
298
- if (value === 'read' || value === 'full')
305
+ if (value === "read" || value === "full")
299
306
  return value;
300
307
  throw errUsage('Scope must be "read" or "full"');
301
308
  }
302
309
  // Top-level aliases: `feedbackbasket login` and `feedbackbasket logout`
303
310
  export function createLoginCommand(getWriter) {
304
- return new Command('login')
305
- .description('Authenticate with FeedbackBasket (alias for auth login)')
306
- .option('--token <token>', 'Use an API token directly (for CI/headless)')
307
- .option('--manual', 'Authenticate without a localhost browser callback')
308
- .option('--scope <scope>', 'Access scope: read or full', 'full')
311
+ return new Command("login")
312
+ .description("Authenticate with FeedbackBasket (alias for auth login)")
313
+ .option("--token <token>", "Use an API token directly (for CI/headless)")
314
+ .option("--manual", "Authenticate without a localhost browser callback")
315
+ .option("--scope <scope>", "Maximum browser access: read or full", "full")
309
316
  .action(async (opts) => {
310
317
  // Delegate to auth login by re-parsing
311
318
  const authCmd = createAuthCommand(getWriter);
312
- const args = ['node', 'feedbackbasket', 'login'];
319
+ const args = ["node", "feedbackbasket", "login"];
313
320
  if (opts.token)
314
- args.push('--token', opts.token);
321
+ args.push("--token", opts.token);
315
322
  if (opts.manual)
316
- args.push('--manual');
323
+ args.push("--manual");
317
324
  if (opts.scope)
318
- args.push('--scope', opts.scope);
325
+ args.push("--scope", opts.scope);
319
326
  await authCmd.parseAsync(args);
320
327
  });
321
328
  }
322
329
  export function createLogoutCommand(getWriter) {
323
- return new Command('logout')
324
- .description('Clear stored credentials (alias for auth logout)')
330
+ return new Command("logout")
331
+ .description("Clear stored credentials (alias for auth logout)")
325
332
  .action(async () => {
326
333
  const authCmd = createAuthCommand(getWriter);
327
- await authCmd.parseAsync(['node', 'feedbackbasket', 'logout']);
334
+ await authCmd.parseAsync(["node", "feedbackbasket", "logout"]);
328
335
  });
329
336
  }