@zoobbe/cli 1.0.0 → 1.1.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.
package/README.md ADDED
@@ -0,0 +1,172 @@
1
+ # @zoobbe/cli
2
+
3
+ Manage your [Zoobbe](https://zoobbe.com) boards, cards, and projects from the terminal.
4
+
5
+ ## Installation
6
+
7
+ ```bash
8
+ npm install -g @zoobbe/cli
9
+ ```
10
+
11
+ Requires Node.js 18 or later.
12
+
13
+ ## Quick Start
14
+
15
+ ```bash
16
+ # Authenticate with your API key
17
+ zoobbe auth login --token zb_live_xxxxx
18
+
19
+ # Or point to a self-hosted instance
20
+ zoobbe config set apiUrl https://your-instance.com
21
+ zoobbe auth login --token zb_live_xxxxx
22
+
23
+ # Set your active workspace
24
+ zoobbe workspace list
25
+ zoobbe workspace switch <workspace-id>
26
+
27
+ # Start managing your work
28
+ zoobbe board list
29
+ zoobbe card list --board <board-id>
30
+ ```
31
+
32
+ ## Authentication
33
+
34
+ Generate an API key from your workspace settings at **Settings > API Keys**, then:
35
+
36
+ ```bash
37
+ zoobbe auth login --token zb_live_your_api_key_here
38
+ ```
39
+
40
+ Check your login status:
41
+
42
+ ```bash
43
+ zoobbe auth whoami # Show current user
44
+ zoobbe auth status # Show auth details
45
+ zoobbe auth logout # Clear credentials
46
+ ```
47
+
48
+ ## Commands
49
+
50
+ ### Workspaces
51
+
52
+ ```bash
53
+ zoobbe workspace list # List your workspaces
54
+ zoobbe workspace switch <id> # Set active workspace
55
+ zoobbe workspace info [id] # Show workspace details
56
+ zoobbe workspace members # List workspace members
57
+ ```
58
+
59
+ ### Boards
60
+
61
+ ```bash
62
+ zoobbe board list # List boards in active workspace
63
+ zoobbe board list --all # Include archived boards
64
+ zoobbe board create <name> # Create a new board
65
+ zoobbe board create <name> -v Public # Create with visibility
66
+ zoobbe board info <id> # Show board details
67
+ zoobbe board open <id> # Open board in browser
68
+ zoobbe board archive <id> # Archive a board
69
+ ```
70
+
71
+ ### Cards
72
+
73
+ ```bash
74
+ zoobbe card list --board <id> # List cards on a board
75
+ zoobbe card create <title> -b <board-id> # Create card in first list
76
+ zoobbe card create <title> -b <id> -l <list-id> # Create card in specific list
77
+ zoobbe card create <title> -b <id> --due 2026-04-01 --priority high
78
+ zoobbe card info <card-id> # Show card details
79
+ zoobbe card move <card-id> -l <list-name> # Move card to another list
80
+ zoobbe card assign <card-id> -u <user-id> # Assign a user
81
+ zoobbe card comment <card-id> "your message" # Add a comment
82
+ zoobbe card done <card-id> # Mark card as complete
83
+ ```
84
+
85
+ ### Pages
86
+
87
+ ```bash
88
+ zoobbe page list # List pages
89
+ zoobbe page create <title> # Create a new page
90
+ zoobbe page info <page-id> # Show page details
91
+ zoobbe page open <page-id> # Open page in browser
92
+ ```
93
+
94
+ ### Search
95
+
96
+ ```bash
97
+ zoobbe search "query" # Search across boards, cards, and pages
98
+ ```
99
+
100
+ ### Status
101
+
102
+ ```bash
103
+ zoobbe status # Show your cards across all boards
104
+ ```
105
+
106
+ ### AI (Experimental)
107
+
108
+ Requires an AI provider key configured on the backend.
109
+
110
+ ```bash
111
+ zoobbe ask "what cards are due this week?"
112
+ zoobbe ask "move all overdue cards to Review"
113
+ zoobbe ask "create a sprint board with 3 lists" --provider openai
114
+ ```
115
+
116
+ ### Configuration
117
+
118
+ ```bash
119
+ zoobbe config set apiUrl https://api.zoobbe.com # Set API URL
120
+ zoobbe config set format json # Default output format
121
+ zoobbe config get apiUrl # Get a config value
122
+ zoobbe config list # Show all config
123
+ zoobbe config path # Show config file location
124
+ ```
125
+
126
+ ## Output Formats
127
+
128
+ Use the `--format` flag or set a default:
129
+
130
+ ```bash
131
+ zoobbe board list --format json # JSON output
132
+ zoobbe board list --format plain # Plain text
133
+ zoobbe board list --format table # Table (default)
134
+ ```
135
+
136
+ ## Global Options
137
+
138
+ ```
139
+ -f, --format <format> Output format: table | json | plain
140
+ --workspace <id> Override active workspace for this command
141
+ -V, --version Show version
142
+ -h, --help Show help
143
+ ```
144
+
145
+ ## Aliases
146
+
147
+ Most commands have short aliases:
148
+
149
+ | Command | Alias |
150
+ |-------------|-------|
151
+ | `board` | `b` |
152
+ | `card` | `c` |
153
+ | `page` | `p` |
154
+ | `list` | `ls` |
155
+
156
+ ```bash
157
+ zoobbe b ls # Same as: zoobbe board list
158
+ zoobbe c ls -b <id> # Same as: zoobbe card list --board <id>
159
+ ```
160
+
161
+ ## Self-Hosted
162
+
163
+ For self-hosted Zoobbe instances, set your API URL before logging in:
164
+
165
+ ```bash
166
+ zoobbe config set apiUrl https://your-instance.com
167
+ zoobbe auth login --token zb_live_xxxxx
168
+ ```
169
+
170
+ ## License
171
+
172
+ MIT
package/package.json CHANGED
@@ -1,10 +1,10 @@
1
1
  {
2
2
  "name": "@zoobbe/cli",
3
- "version": "1.0.0",
3
+ "version": "1.1.1",
4
4
  "description": "Zoobbe CLI - Manage boards, cards, and projects from the terminal",
5
5
  "main": "src/index.js",
6
6
  "bin": {
7
- "zoobbe": "./bin/zoobbe"
7
+ "zoobbe": "bin/zoobbe"
8
8
  },
9
9
  "scripts": {
10
10
  "start": "node bin/zoobbe",
@@ -29,14 +29,15 @@
29
29
  "homepage": "https://zoobbe.com/cli",
30
30
  "repository": {
31
31
  "type": "git",
32
- "url": "https://github.com/zoobbe/zoobbe-cli"
32
+ "url": "git+https://github.com/zoobbe/zoobbe-cli.git"
33
33
  },
34
34
  "bugs": {
35
35
  "url": "https://github.com/zoobbe/zoobbe-cli/issues"
36
36
  },
37
37
  "files": [
38
38
  "bin/",
39
- "src/"
39
+ "src/",
40
+ "README.md"
40
41
  ],
41
42
  "dependencies": {
42
43
  "chalk": "^4.1.2",
@@ -73,7 +73,8 @@ const ai = new Command('ask')
73
73
  console.log(JSON.stringify(result, null, 2));
74
74
  }
75
75
  } catch (err) {
76
- error(`AI command failed: ${err.message}`);
76
+ const detail = err.data?.message || err.message;
77
+ error(`AI command failed: ${detail}`);
77
78
  }
78
79
  });
79
80
 
@@ -16,7 +16,16 @@ auth
16
16
  .action(async (options) => {
17
17
  try {
18
18
  if (options.url) {
19
- config.set('apiUrl', options.url);
19
+ // Validate URL format and enforce HTTPS
20
+ try {
21
+ const parsed = new URL(options.url);
22
+ if (parsed.protocol !== 'https:' && !parsed.hostname.match(/^(localhost|127\.0\.0\.1)$/)) {
23
+ return error('API URL must use HTTPS. Only localhost is allowed over HTTP.');
24
+ }
25
+ config.set('apiUrl', options.url);
26
+ } catch {
27
+ return error('Invalid URL format.');
28
+ }
20
29
  }
21
30
 
22
31
  let apiKey = options.token;
@@ -107,7 +116,7 @@ auth
107
116
  .action(() => {
108
117
  const apiKey = config.get('apiKey');
109
118
  if (apiKey) {
110
- success(`Authenticated (key: ${apiKey.substring(0, 16)}...)`);
119
+ success(`Authenticated (key: ${apiKey.substring(0, 10)}...)`);
111
120
  info(`API URL: ${config.get('apiUrl')}`);
112
121
  if (config.get('activeWorkspaceName')) {
113
122
  info(`Workspace: ${config.get('activeWorkspaceName')}`);
@@ -60,16 +60,24 @@ board
60
60
  return error('No active workspace. Run "zoobbe workspace switch <name>".');
61
61
  }
62
62
 
63
+ // Resolve workspace ObjectId from shortId
64
+ const wsData = await client.get('/workspaces/me');
65
+ const workspaces = wsData.data || wsData;
66
+ const ws = workspaces.find(w => w.shortId === workspaceId);
67
+ if (!ws) {
68
+ return error('Active workspace not found.');
69
+ }
70
+
63
71
  const data = await withSpinner('Creating board...', () =>
64
72
  client.post('/boards', {
65
- name,
66
- workspaceId,
73
+ title: name,
74
+ workspaceId: ws._id,
67
75
  visibility: options.visibility,
68
76
  })
69
77
  );
70
78
 
71
- const b = data.data || data;
72
- success(`Created board: ${chalk.bold(b.name || name)} (${b.shortId})`);
79
+ const b = data.board || data.data || data;
80
+ success(`Created board: ${chalk.bold(b.title || b.name || name)} (${b.shortId})`);
73
81
  } catch (err) {
74
82
  error(`Failed to create board: ${err.message}`);
75
83
  }
@@ -91,7 +99,7 @@ board
91
99
  const boards = data.data || data;
92
100
  const match = boards.find(b =>
93
101
  b.shortId === nameOrId ||
94
- (b.name || '').toLowerCase() === nameOrId.toLowerCase()
102
+ (b.title || b.name || '').toLowerCase() === nameOrId.toLowerCase()
95
103
  );
96
104
 
97
105
  const boardId = match ? match.shortId : nameOrId;
@@ -113,7 +121,7 @@ board
113
121
  const boards = data.data || data;
114
122
  const match = boards.find(b =>
115
123
  b.shortId === nameOrId ||
116
- (b.name || '').toLowerCase() === nameOrId.toLowerCase()
124
+ (b.title || b.name || '').toLowerCase() === nameOrId.toLowerCase()
117
125
  );
118
126
 
119
127
  if (!match) {
@@ -124,7 +132,7 @@ board
124
132
  client.post(`/boards/archive/${match.shortId}`)
125
133
  );
126
134
 
127
- success(`Archived board: ${chalk.bold(match.name)}`);
135
+ success(`Archived board: ${chalk.bold(match.title || match.name)}`);
128
136
  } catch (err) {
129
137
  error(`Failed to archive board: ${err.message}`);
130
138
  }
@@ -139,9 +147,9 @@ board
139
147
  client.get(`/boards/${nameOrId}`)
140
148
  );
141
149
 
142
- const b = data.data || data;
150
+ const b = data.board || data.data || data;
143
151
  console.log();
144
- console.log(chalk.bold(' Name: '), b.name);
152
+ console.log(chalk.bold(' Name: '), b.title || b.name);
145
153
  console.log(chalk.bold(' ID: '), b.shortId);
146
154
  console.log(chalk.bold(' Visibility: '), b.visibility || 'Private');
147
155
  console.log(chalk.bold(' Members: '), b.members?.length || 0);
@@ -20,30 +20,43 @@ card
20
20
  .option('-f, --format <format>', 'Output format')
21
21
  .action(async (options) => {
22
22
  try {
23
- let path;
24
-
25
- if (options.assignee === 'me') {
26
- const userName = config.get('userName');
27
- path = `/${userName}/cards`;
28
- } else if (options.board) {
29
- path = `/boards/${options.board}/cards`;
30
- } else if (options.list) {
31
- path = `/actionLists/${options.list}/cards/`;
23
+ let cards = [];
24
+
25
+ if (options.board) {
26
+ // Get lists with populated cards for this board
27
+ const listsData = await withSpinner('Fetching cards...', () =>
28
+ client.get(`/boards/${options.board}/lists`)
29
+ );
30
+ const lists = listsData.lists || listsData.data || listsData;
31
+ const listArr = Array.isArray(lists) ? lists : Object.values(lists);
32
+
33
+ // Collect card shortIds per list
34
+ const cardRefs = [];
35
+ for (const list of listArr) {
36
+ if (list.cards && Array.isArray(list.cards)) {
37
+ for (const c of list.cards) {
38
+ cardRefs.push({ id: c.shortId || c._id, listName: list.title || '' });
39
+ }
40
+ }
41
+ }
42
+
43
+ // Fetch full card details in parallel
44
+ const results = await Promise.allSettled(
45
+ cardRefs.map(ref => client.get(`/cards/${ref.id}`).then(d => ({ ...d, _listName: ref.listName })))
46
+ );
47
+ for (const r of results) {
48
+ if (r.status === 'fulfilled') cards.push(r.value);
49
+ }
32
50
  } else {
33
51
  // Get cards for current user
34
- const userName = config.get('userName');
35
- if (!userName) {
36
- return error('No user context. Run "zoobbe auth login" first.');
37
- }
38
- path = `/${userName}/cards`;
52
+ const userId = config.get('userId');
53
+ const data = await withSpinner('Fetching cards...', () =>
54
+ client.get(`/${userId}/cards`)
55
+ );
56
+ cards = data.data || data;
57
+ if (!Array.isArray(cards)) cards = [];
39
58
  }
40
59
 
41
- const data = await withSpinner('Fetching cards...', () =>
42
- client.get(path)
43
- );
44
-
45
- let cards = data.data || data;
46
-
47
60
  // Apply due date filter
48
61
  if (options.due && Array.isArray(cards)) {
49
62
  const now = new Date();
@@ -62,9 +75,9 @@ card
62
75
  const rows = (Array.isArray(cards) ? cards : []).map(c => ({
63
76
  title: (c.title || c.name || '').substring(0, 50),
64
77
  id: c.shortId || c._id,
65
- list: c.actionList?.name || c.actionListName || '',
66
- due: c.dueDate ? new Date(c.dueDate).toLocaleDateString() : '-',
67
- members: String(c.members?.length || 0),
78
+ list: c._listName || c.actionListTitle || c.actionList?.name || '',
79
+ due: c.dueDate?.date ? new Date(c.dueDate.date).toLocaleDateString() : (typeof c.dueDate === 'string' && c.dueDate ? new Date(c.dueDate).toLocaleDateString() : '-'),
80
+ members: String(c.members?.length || c.users?.length || 0),
68
81
  priority: c.priority || '-',
69
82
  }));
70
83
 
@@ -96,29 +109,41 @@ card
96
109
  // If no list specified, get the first list from the board
97
110
  if (!listId) {
98
111
  const listsData = await client.get(`/boards/${options.board}/lists`);
99
- const lists = listsData.data || listsData;
100
- if (!lists || lists.length === 0) {
112
+ const lists = listsData.lists || listsData.data || listsData;
113
+ const listArr = Array.isArray(lists) ? lists : Object.values(lists);
114
+ if (listArr.length === 0) {
101
115
  return error('Board has no lists. Create one first.');
102
116
  }
103
- listId = lists[0].shortId || lists[0]._id;
117
+ listId = listArr[0]._id;
104
118
  }
105
119
 
106
120
  const body = {
107
121
  title,
108
- boardId: options.board,
109
122
  actionListId: listId,
110
123
  };
111
124
 
112
125
  if (options.description) body.description = options.description;
113
- if (options.due) body.dueDate = options.due;
114
- if (options.priority) body.priority = options.priority;
115
126
 
116
127
  const data = await withSpinner('Creating card...', () =>
117
128
  client.post('/cards', body)
118
129
  );
119
130
 
120
- const c = data.data || data;
131
+ const c = data.card || data.data || data;
121
132
  success(`Created card: ${chalk.bold(title)} (${c.shortId || c._id})`);
133
+
134
+ // Set due date if provided
135
+ if (options.due && (c.shortId || c._id)) {
136
+ await client.post(`/cards/${c.shortId || c._id}/duedate`, {
137
+ dueDate: options.due,
138
+ });
139
+ }
140
+
141
+ // Set priority if provided
142
+ if (options.priority && (c.shortId || c._id)) {
143
+ await client.post(`/cards/${c.shortId || c._id}/priority`, {
144
+ priority: options.priority,
145
+ });
146
+ }
122
147
  } catch (err) {
123
148
  error(`Failed to create card: ${err.message}`);
124
149
  }
@@ -127,20 +152,62 @@ card
127
152
  card
128
153
  .command('move <cardId>')
129
154
  .description('Move a card to a different list')
130
- .option('-l, --list <listId>', 'Target list ID (required)')
155
+ .option('-l, --list <listName>', 'Target list name or ID (required)')
156
+ .option('-b, --board <boardId>', 'Board ID (auto-detected from card if not specified)')
131
157
  .action(async (cardId, options) => {
132
158
  try {
133
159
  if (!options.list) {
134
- return error('Target list ID is required. Use -l <listId>.');
160
+ return error('Target list is required. Use -l <listName>.');
161
+ }
162
+
163
+ // Fetch card to get board and current list info
164
+ const cardData = await withSpinner('Fetching card...', () =>
165
+ client.get(`/cards/${cardId}`)
166
+ );
167
+ const c = cardData.card || cardData.data || cardData;
168
+ const boardId = options.board || c.boardShortId || c.board;
169
+
170
+ if (!boardId) {
171
+ return error('Could not determine board. Use -b <boardId>.');
172
+ }
173
+
174
+ // Get all lists for the board
175
+ const listsData = await client.get(`/boards/${boardId}/lists`);
176
+ const lists = listsData.lists || listsData.data || listsData;
177
+ const listArr = Array.isArray(lists) ? lists : Object.values(lists);
178
+
179
+ // Find source list (the list the card is currently in)
180
+ const sourceActionListId = c.actionList?._id || c.actionListId;
181
+ const sourceList = listArr.find(l =>
182
+ l._id === sourceActionListId || l._id?.toString() === sourceActionListId?.toString()
183
+ );
184
+
185
+ // Find target list by name or ID
186
+ const targetList = listArr.find(l =>
187
+ l._id === options.list ||
188
+ l._id?.toString() === options.list ||
189
+ (l.title || '').toLowerCase() === options.list.toLowerCase()
190
+ );
191
+
192
+ if (!targetList) {
193
+ const available = listArr.map(l => ` - ${l.title} (${l._id})`).join('\n');
194
+ return error(`List "${options.list}" not found. Available lists:\n${available}`);
195
+ }
196
+
197
+ if (!sourceList) {
198
+ return error('Could not determine the card\'s current list.');
135
199
  }
136
200
 
137
201
  await withSpinner('Moving card...', () =>
138
- client.post(`/cards/update/${cardId}`, {
139
- actionListId: options.list,
202
+ client.put(`/boards/${boardId}/cards/order`, {
203
+ sourceListId: sourceList._id,
204
+ cardId: c._id,
205
+ targetListId: targetList._id,
206
+ targetPosition: 0,
140
207
  })
141
208
  );
142
209
 
143
- success(`Moved card ${cardId} to list ${options.list}`);
210
+ success(`Moved card ${cardId} to ${chalk.bold(targetList.title)}`);
144
211
  } catch (err) {
145
212
  error(`Failed to move card: ${err.message}`);
146
213
  }
@@ -173,8 +240,13 @@ card
173
240
  .description('Add a comment to a card')
174
241
  .action(async (cardId, message) => {
175
242
  try {
243
+ const userId = config.get('userId');
176
244
  await withSpinner('Adding comment...', () =>
177
- client.post(`/cards/${cardId}/comments`, { text: message })
245
+ client.post(`/cards/${cardId}/comments`, {
246
+ comment: message,
247
+ member: userId,
248
+ mentionedIds: [],
249
+ })
178
250
  );
179
251
 
180
252
  success(`Comment added to card ${cardId}`);
@@ -207,15 +279,17 @@ card
207
279
  client.get(`/cards/${cardId}`)
208
280
  );
209
281
 
210
- const c = data.data || data;
282
+ const c = data.card || data.data || data;
283
+ const dueStr = c.dueDate?.date ? new Date(c.dueDate.date).toLocaleDateString() : 'None';
211
284
  console.log();
212
285
  console.log(chalk.bold(' Title: '), c.title || c.name);
213
286
  console.log(chalk.bold(' ID: '), c.shortId || c._id);
214
- console.log(chalk.bold(' List: '), c.actionList?.name || 'N/A');
215
- console.log(chalk.bold(' Members: '), c.members?.length || 0);
216
- console.log(chalk.bold(' Due Date: '), c.dueDate ? new Date(c.dueDate).toLocaleDateString() : 'None');
287
+ console.log(chalk.bold(' Board: '), c.boardTitle || c.board || 'N/A');
288
+ console.log(chalk.bold(' List: '), c.actionListTitle || c.actionList?.name || 'N/A');
289
+ console.log(chalk.bold(' Members: '), c.members?.length || c.users?.length || 0);
290
+ console.log(chalk.bold(' Due Date: '), dueStr);
217
291
  console.log(chalk.bold(' Priority: '), c.priority || 'None');
218
- console.log(chalk.bold(' Complete: '), c.isComplete ? 'Yes' : 'No');
292
+ console.log(chalk.bold(' Complete: '), c.completed ? 'Yes' : 'No');
219
293
  if (c.description) {
220
294
  console.log(chalk.bold(' Description:'), c.description.substring(0, 100));
221
295
  }
@@ -54,7 +54,7 @@ page
54
54
  client.post('/pages', { title, workspaceId })
55
55
  );
56
56
 
57
- const p = data.data || data;
57
+ const p = data.page || data.data || data;
58
58
  success(`Created page: ${chalk.bold(title)} (${p.shortId || p._id})`);
59
59
  } catch (err) {
60
60
  error(`Failed to create page: ${err.message}`);
@@ -85,7 +85,7 @@ page
85
85
  client.get(`/pages/${pageId}`)
86
86
  );
87
87
 
88
- const p = data.data || data;
88
+ const p = data.page || data.data || data;
89
89
  console.log();
90
90
  console.log(chalk.bold(' Title: '), p.title || 'Untitled');
91
91
  console.log(chalk.bold(' ID: '), p.shortId || p._id);
@@ -15,25 +15,28 @@ const search = new Command('search')
15
15
  const workspaceId = config.get('activeWorkspace');
16
16
 
17
17
  const data = await withSpinner(`Searching for "${query}"...`, () =>
18
- client.post('/global/search', {
19
- query,
20
- workspaceId,
21
- })
18
+ client.post(`/global/search?query=${encodeURIComponent(query)}`)
22
19
  );
23
20
 
24
- const results = data.data || data;
21
+ const results = data.data || data.results || data;
25
22
 
26
- if (!results || (Array.isArray(results) && results.length === 0)) {
23
+ // Flatten different result types into a single list
24
+ const items = [];
25
+ if (results.boards) results.boards.forEach(b => items.push({ type: 'Board', title: b.title || b.name, id: b.shortId || b._id }));
26
+ if (results.cards) results.cards.forEach(c => items.push({ type: 'Card', title: c.title || c.name, id: c.shortId || c._id, context: c.boardTitle || '' }));
27
+ if (results.pages) results.pages.forEach(p => items.push({ type: 'Page', title: p.title || p.name, id: p.shortId || p._id }));
28
+ if (Array.isArray(results)) results.forEach(r => items.push({ type: r.type || 'item', title: r.title || r.name || '', id: r.shortId || r._id || '' }));
29
+
30
+ if (items.length === 0) {
27
31
  console.log(chalk.yellow(' No results found.'));
28
32
  return;
29
33
  }
30
34
 
31
- // Format results based on type
32
- const rows = (Array.isArray(results) ? results : [results]).map(r => ({
33
- type: r.type || 'item',
34
- title: (r.title || r.name || '').substring(0, 50),
35
- id: r.shortId || r._id || '',
36
- context: r.boardName || r.workspaceName || '',
35
+ const rows = items.map(r => ({
36
+ type: r.type,
37
+ title: (r.title || '').substring(0, 50),
38
+ id: r.id || '',
39
+ context: r.context || '',
37
40
  }));
38
41
 
39
42
  output(rows, {
@@ -12,13 +12,13 @@ const status = new Command('status')
12
12
  .option('--overdue', 'Only show overdue cards')
13
13
  .action(async (options) => {
14
14
  try {
15
- const userName = config.get('userName');
16
- if (!userName) {
15
+ const userId = config.get('userId');
16
+ if (!userId) {
17
17
  return error('Not logged in. Run "zoobbe auth login" first.');
18
18
  }
19
19
 
20
20
  const data = await withSpinner('Fetching your cards...', () =>
21
- client.get(`/${userName}/cards`)
21
+ client.get(`/${userId}/cards`)
22
22
  );
23
23
 
24
24
  let cards = data.data || data;
@@ -129,10 +129,14 @@ workspace
129
129
  }
130
130
 
131
131
  const data = await withSpinner('Fetching workspace info...', () =>
132
- client.get(`/workspaces/${workspaceId}`)
132
+ client.get('/workspaces/me')
133
133
  );
134
134
 
135
- const ws = data.data || data;
135
+ const workspaces = data.data || data;
136
+ const ws = workspaces.find(w => w.shortId === workspaceId);
137
+ if (!ws) {
138
+ return error('Workspace not found.');
139
+ }
136
140
  console.log();
137
141
  console.log(chalk.bold(' Name: '), ws.name);
138
142
  console.log(chalk.bold(' ID: '), ws.shortId);
package/src/lib/client.js CHANGED
@@ -26,6 +26,11 @@ class ZoobbeClient {
26
26
  async request(method, path, body = null) {
27
27
  this.ensureAuth();
28
28
 
29
+ // Reject path traversal attempts
30
+ if (path.includes('..') || path.includes('//')) {
31
+ throw new Error('Invalid request path');
32
+ }
33
+
29
34
  const url = `${this.baseUrl}/v1${path}`;
30
35
  const options = {
31
36
  method,
package/src/lib/config.js CHANGED
@@ -1,10 +1,18 @@
1
1
  const Conf = require('conf');
2
2
  const path = require('path');
3
3
  const os = require('os');
4
+ const fs = require('fs');
5
+
6
+ const configDir = path.join(os.homedir(), '.zoobbe');
7
+
8
+ // Ensure config directory exists with restricted permissions (owner-only)
9
+ if (!fs.existsSync(configDir)) {
10
+ fs.mkdirSync(configDir, { mode: 0o700, recursive: true });
11
+ }
4
12
 
5
13
  const config = new Conf({
6
14
  projectName: 'zoobbe',
7
- cwd: path.join(os.homedir(), '.zoobbe'),
15
+ cwd: configDir,
8
16
  schema: {
9
17
  apiKey: { type: 'string', default: '' },
10
18
  apiUrl: { type: 'string', default: 'https://api.zoobbe.com' },
@@ -17,4 +25,11 @@ const config = new Conf({
17
25
  },
18
26
  });
19
27
 
28
+ // Lock down the config file (owner read/write only)
29
+ try {
30
+ fs.chmodSync(config.path, 0o600);
31
+ } catch {
32
+ // Ignore if chmod fails (e.g., Windows)
33
+ }
34
+
20
35
  module.exports = config;