@upgraide/ui-notes-cli 0.1.1 → 0.1.2

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.
@@ -0,0 +1,138 @@
1
+ import type { Config } from '../config.js';
2
+ import { apiFetch } from '../api.js';
3
+
4
+ interface BulkResolveOptions {
5
+ project?: string;
6
+ message?: string;
7
+ dryRun?: boolean;
8
+ force?: boolean;
9
+ status?: 'resolved' | 'wontfix';
10
+ }
11
+
12
+ export async function resolveByComponent(
13
+ config: Config,
14
+ componentName: string,
15
+ opts: BulkResolveOptions,
16
+ ): Promise<void> {
17
+ await bulkResolve(config, 'component', componentName, opts);
18
+ }
19
+
20
+ export async function resolveBySelector(
21
+ config: Config,
22
+ selector: string,
23
+ opts: BulkResolveOptions,
24
+ ): Promise<void> {
25
+ await bulkResolve(config, 'selector', selector, opts);
26
+ }
27
+
28
+ async function bulkResolve(
29
+ config: Config,
30
+ filterBy: 'component' | 'selector',
31
+ filterValue: string,
32
+ opts: BulkResolveOptions,
33
+ ): Promise<void> {
34
+ const project = opts.project || config.defaultProject;
35
+ if (!project) {
36
+ console.error('No project specified. Use --project or set defaultProject in config.');
37
+ process.exit(1);
38
+ }
39
+
40
+ const status = opts.status || 'resolved';
41
+ const resolution = opts.message || `Bulk ${status} (${filterBy}: ${filterValue})`;
42
+
43
+ if (opts.dryRun) {
44
+ // Fetch matching notes to show what would be resolved
45
+ const response = await apiFetch(config, `/notes?status=open`, { project }) as any;
46
+ const notes = response.data?.notes || [];
47
+
48
+ const matching = notes.filter((n: any) => {
49
+ if (filterBy === 'component') {
50
+ return n.component?.toLowerCase() === filterValue.toLowerCase();
51
+ }
52
+ if (filterBy === 'selector') {
53
+ return n.selector?.includes(filterValue);
54
+ }
55
+ return false;
56
+ });
57
+
58
+ if (matching.length === 0) {
59
+ console.log('No matching open notes found.');
60
+ return;
61
+ }
62
+
63
+ console.log(`[DRY RUN] Would resolve ${matching.length} notes:`);
64
+ for (const note of matching) {
65
+ console.log(` ${note.id} | ${note.type} | ${truncate(note.body, 60)}`);
66
+ }
67
+ return;
68
+ }
69
+
70
+ // If not forced, do a dry-run first to show count and confirm
71
+ if (!opts.force) {
72
+ const response = await apiFetch(config, `/notes?status=open`, { project }) as any;
73
+ const notes = response.data?.notes || [];
74
+
75
+ const matching = notes.filter((n: any) => {
76
+ if (filterBy === 'component') {
77
+ return n.component?.toLowerCase() === filterValue.toLowerCase();
78
+ }
79
+ if (filterBy === 'selector') {
80
+ return n.selector?.includes(filterValue);
81
+ }
82
+ return false;
83
+ });
84
+
85
+ if (matching.length === 0) {
86
+ console.log('No matching open notes found.');
87
+ return;
88
+ }
89
+
90
+ const label = filterBy === 'component' ? `Component=${filterValue}` : `Selector=${filterValue}`;
91
+ const answer = await confirm(`About to resolve ${matching.length} notes matching ${label}. Continue? y/n `);
92
+ if (!answer) {
93
+ console.log('Aborted.');
94
+ return;
95
+ }
96
+ }
97
+
98
+ // Call bulk endpoint
99
+ const response = await apiFetch(config, '/notes/bulk', {
100
+ method: 'PATCH',
101
+ body: {
102
+ filter: {
103
+ status: 'open',
104
+ ...(filterBy === 'component' ? { component: filterValue } : {}),
105
+ ...(filterBy === 'selector' ? { selector: filterValue } : {}),
106
+ },
107
+ update: {
108
+ status,
109
+ resolution,
110
+ },
111
+ },
112
+ project,
113
+ }) as any;
114
+
115
+ const count = response.data?.updated || 0;
116
+
117
+ if (count === 0) {
118
+ console.log('No matching open notes found.');
119
+ } else {
120
+ console.log(`Resolved ${count} notes.`);
121
+ }
122
+ }
123
+
124
+ async function confirm(question: string): Promise<boolean> {
125
+ const readline = await import('readline');
126
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
127
+
128
+ return new Promise((resolve) => {
129
+ rl.question(question, (answer) => {
130
+ rl.close();
131
+ resolve(answer.trim().toLowerCase() === 'y');
132
+ });
133
+ });
134
+ }
135
+
136
+ function truncate(s: string, max: number): string {
137
+ return s.length > max ? s.slice(0, max) + '...' : s;
138
+ }
@@ -0,0 +1,27 @@
1
+ import type { Config } from '../config.js';
2
+ import { apiFetch } from '../api.js';
3
+
4
+ export async function comment(
5
+ config: Config,
6
+ noteId: string,
7
+ body: string,
8
+ project?: string,
9
+ ): Promise<void> {
10
+ const data = await apiFetch(config, `/notes/${noteId}/comments`, {
11
+ method: 'POST',
12
+ body: { body },
13
+ project,
14
+ });
15
+ console.log(JSON.stringify(data, null, 2));
16
+ }
17
+
18
+ export async function listComments(
19
+ config: Config,
20
+ noteId: string,
21
+ project?: string,
22
+ ): Promise<void> {
23
+ const data = await apiFetch(config, `/notes/${noteId}/comments`, {
24
+ project,
25
+ });
26
+ console.log(JSON.stringify(data, null, 2));
27
+ }
@@ -0,0 +1,64 @@
1
+ import type { Config } from '../config.js';
2
+ import { apiFetch } from '../api.js';
3
+ import { formatNotesForAI } from '../formatters/ai-context.js';
4
+ import type { ResolvedNote } from '../resolvers/file-resolver.js';
5
+
6
+ interface ContextOptions {
7
+ project?: string;
8
+ type?: string;
9
+ status?: string;
10
+ component?: string;
11
+ format?: 'markdown' | 'xml' | 'json';
12
+ limit?: string;
13
+ resolveFiles?: boolean;
14
+ rootDir?: string;
15
+ }
16
+
17
+ export async function context(config: Config, opts: ContextOptions): Promise<void> {
18
+ const params = new URLSearchParams();
19
+ params.set('status', opts.status || 'open');
20
+ if (opts.type) params.set('type', opts.type);
21
+ if (opts.limit) params.set('limit', opts.limit);
22
+
23
+ const qs = params.toString();
24
+ const path = `/notes${qs ? `?${qs}` : ''}`;
25
+
26
+ const response = await apiFetch(config, path, { project: opts.project }) as any;
27
+ const notes = response.data?.notes || [];
28
+
29
+ // Optionally resolve notes to source files
30
+ let resolvedMap: Map<string, ResolvedNote> | undefined;
31
+ if (opts.resolveFiles) {
32
+ const { resolveNoteToFiles } = await import('../resolvers/file-resolver.js');
33
+ const resolveConfig = config.resolve || {};
34
+ const rootDir = opts.rootDir || resolveConfig.rootDir || process.cwd();
35
+ resolvedMap = new Map();
36
+
37
+ for (const note of notes) {
38
+ const resolved = await resolveNoteToFiles(
39
+ {
40
+ id: note.id,
41
+ component: note.component || null,
42
+ selector: note.selector || null,
43
+ tag: note.tag || null,
44
+ },
45
+ {
46
+ rootDir,
47
+ extensions: resolveConfig.extensions,
48
+ excludeDirs: resolveConfig.excludeDirs,
49
+ },
50
+ );
51
+ resolvedMap.set(note.id, resolved);
52
+ }
53
+ }
54
+
55
+ const output = formatNotesForAI(notes, {
56
+ format: opts.format || 'markdown',
57
+ componentFilter: opts.component,
58
+ projectName: opts.project || config.defaultProject || 'unknown',
59
+ resolvedNotes: resolvedMap,
60
+ });
61
+
62
+ // Write to stdout -- pure pipeable output, no JSON wrapper, no logging
63
+ process.stdout.write(output);
64
+ }
package/commands/pull.ts CHANGED
@@ -6,6 +6,8 @@ interface PullOptions {
6
6
  status?: string;
7
7
  type?: string;
8
8
  limit?: string;
9
+ resolveFiles?: boolean;
10
+ rootDir?: string;
9
11
  }
10
12
 
11
13
  export async function pull(config: Config, opts: PullOptions): Promise<void> {
@@ -17,6 +19,31 @@ export async function pull(config: Config, opts: PullOptions): Promise<void> {
17
19
  const qs = params.toString();
18
20
  const path = `/notes${qs ? `?${qs}` : ''}`;
19
21
 
20
- const data = await apiFetch(config, path, { project: opts.project });
22
+ const data = (await apiFetch(config, path, { project: opts.project })) as any;
23
+
24
+ if (opts.resolveFiles) {
25
+ const { resolveNoteToFiles } = await import('../resolvers/file-resolver.js');
26
+ const resolveConfig = config.resolve || {};
27
+ const rootDir = opts.rootDir || resolveConfig.rootDir || process.cwd();
28
+ const notes = data.data?.notes || data.notes || [];
29
+
30
+ for (const note of notes) {
31
+ const resolved = await resolveNoteToFiles(
32
+ {
33
+ id: note.id,
34
+ component: note.component || null,
35
+ selector: note.selector || null,
36
+ tag: note.tag || null,
37
+ },
38
+ {
39
+ rootDir,
40
+ extensions: resolveConfig.extensions,
41
+ excludeDirs: resolveConfig.excludeDirs,
42
+ },
43
+ );
44
+ note.resolved_files = resolved.files;
45
+ }
46
+ }
47
+
21
48
  console.log(JSON.stringify(data));
22
49
  }
@@ -0,0 +1,366 @@
1
+ import type { Config } from '../config.js';
2
+ import { apiFetch } from '../api.js';
3
+ import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from 'fs';
4
+ import { join } from 'path';
5
+ import { createHash } from 'crypto';
6
+
7
+ interface VisualDiffOptions {
8
+ project?: string;
9
+ url?: string;
10
+ compare?: boolean;
11
+ width?: number;
12
+ fullPage?: boolean;
13
+ }
14
+
15
+ const SNAPSHOT_DIR = '.uinotes-snapshots';
16
+
17
+ function hashUrl(url: string): string {
18
+ return createHash('md5').update(url).digest('hex');
19
+ }
20
+
21
+ function ensureDir(dir: string): void {
22
+ if (!existsSync(dir)) {
23
+ mkdirSync(dir, { recursive: true });
24
+ }
25
+ }
26
+
27
+ async function loadPlaywright() {
28
+ try {
29
+ const pw = await import('playwright');
30
+ return pw;
31
+ } catch {
32
+ console.error('Playwright is not installed.');
33
+ console.error('Install it with: bun add playwright');
34
+ console.error('Then install the browser: bunx playwright install chromium');
35
+ process.exit(1);
36
+ }
37
+ }
38
+
39
+ export async function visualDiff(config: Config, opts: VisualDiffOptions): Promise<void> {
40
+ const project = opts.project || config.defaultProject;
41
+ if (!project) {
42
+ console.error('Error: --project is required (or set defaultProject in config)');
43
+ process.exit(1);
44
+ }
45
+
46
+ const width = opts.width || 1280;
47
+ const fullPage = opts.fullPage !== false;
48
+
49
+ // Step 1: Fetch open notes to collect URLs
50
+ console.log(`Fetching open notes for project "${project}"...`);
51
+ const data = (await apiFetch(config, '/notes?status=open&limit=500', { project })) as any;
52
+ const notes = data.data?.notes || [];
53
+
54
+ if (notes.length === 0) {
55
+ console.log('No open notes found. Nothing to capture.');
56
+ return;
57
+ }
58
+
59
+ // Step 2: Extract unique URLs
60
+ const urlSet = new Set<string>();
61
+ for (const note of notes) {
62
+ if (note.url) urlSet.add(note.url);
63
+ }
64
+
65
+ let urls = Array.from(urlSet);
66
+
67
+ // Filter to specific URL if provided
68
+ if (opts.url) {
69
+ urls = urls.filter((u) => u.includes(opts.url!));
70
+ if (urls.length === 0) {
71
+ console.log(`No notes found matching URL "${opts.url}".`);
72
+ return;
73
+ }
74
+ }
75
+
76
+ console.log(`Found ${urls.length} unique URL(s) across ${notes.length} open notes\n`);
77
+
78
+ if (opts.compare) {
79
+ await runComparison(config, project, notes, urls, width, fullPage);
80
+ } else {
81
+ await captureSnapshots(urls, width, fullPage);
82
+ }
83
+ }
84
+
85
+ async function captureSnapshots(urls: string[], width: number, fullPage: boolean): Promise<void> {
86
+ const pw = await loadPlaywright();
87
+
88
+ const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
89
+ const snapshotDir = join(SNAPSHOT_DIR, timestamp);
90
+ ensureDir(snapshotDir);
91
+
92
+ let browser;
93
+ try {
94
+ browser = await pw.chromium.launch({ headless: true });
95
+ } catch {
96
+ console.error('Chromium browser not found.');
97
+ console.error('Run: bunx playwright install chromium');
98
+ process.exit(1);
99
+ }
100
+
101
+ try {
102
+ for (const url of urls) {
103
+ console.log(`Capturing: ${url}`);
104
+ try {
105
+ const context = await browser.newContext({ viewport: { width, height: 800 } });
106
+ const page = await context.newPage();
107
+ await page.goto(url, { waitUntil: 'networkidle', timeout: 30_000 });
108
+ await page.waitForTimeout(1000);
109
+
110
+ const filename = `${hashUrl(url)}.png`;
111
+ const filepath = join(snapshotDir, filename);
112
+ await page.screenshot({ fullPage, path: filepath, type: 'png' });
113
+ await context.close();
114
+
115
+ console.log(` Saved: ${filepath}`);
116
+ } catch (err: any) {
117
+ console.error(` Failed: ${err.message}`);
118
+ }
119
+ }
120
+ } finally {
121
+ await browser.close();
122
+ }
123
+
124
+ // Save a manifest mapping URL hashes to URLs
125
+ const manifest: Record<string, string> = {};
126
+ for (const url of urls) {
127
+ manifest[hashUrl(url)] = url;
128
+ }
129
+ writeFileSync(join(snapshotDir, 'manifest.json'), JSON.stringify(manifest, null, 2));
130
+
131
+ console.log(`\nSnapshots saved to ${snapshotDir}`);
132
+ console.log(`Run with --compare to diff against this snapshot set.`);
133
+ }
134
+
135
+ async function runComparison(
136
+ config: Config,
137
+ project: string,
138
+ notes: any[],
139
+ urls: string[],
140
+ width: number,
141
+ fullPage: boolean,
142
+ ): Promise<void> {
143
+ // Find the most recent previous snapshot set
144
+ if (!existsSync(SNAPSHOT_DIR)) {
145
+ console.error(`No previous snapshots found in ${SNAPSHOT_DIR}. Run without --compare first.`);
146
+ process.exit(1);
147
+ }
148
+
149
+ const snapshotDirs = readdirSync(SNAPSHOT_DIR)
150
+ .filter((d) => !d.startsWith('.'))
151
+ .sort()
152
+ .reverse();
153
+
154
+ if (snapshotDirs.length === 0) {
155
+ console.error(`No previous snapshots found in ${SNAPSHOT_DIR}. Run without --compare first.`);
156
+ process.exit(1);
157
+ }
158
+
159
+ const previousDir = join(SNAPSHOT_DIR, snapshotDirs[0]);
160
+ console.log(`Comparing against previous snapshot: ${snapshotDirs[0]}\n`);
161
+
162
+ // Load previous manifest
163
+ const manifestPath = join(previousDir, 'manifest.json');
164
+ if (!existsSync(manifestPath)) {
165
+ console.error(`No manifest.json found in ${previousDir}. Cannot compare.`);
166
+ process.exit(1);
167
+ }
168
+ const previousManifest: Record<string, string> = JSON.parse(readFileSync(manifestPath, 'utf-8'));
169
+
170
+ // Dynamically import pixelmatch and pngjs
171
+ let pixelmatch: any;
172
+ let PNG: any;
173
+ try {
174
+ pixelmatch = (await import('pixelmatch')).default;
175
+ PNG = (await import('pngjs')).PNG;
176
+ } catch {
177
+ console.error('pixelmatch or pngjs not installed.');
178
+ console.error('Install with: bun add pixelmatch pngjs');
179
+ process.exit(1);
180
+ }
181
+
182
+ // Capture current state
183
+ const pw = await loadPlaywright();
184
+ let browser;
185
+ try {
186
+ browser = await pw.chromium.launch({ headless: true });
187
+ } catch {
188
+ console.error('Chromium browser not found.');
189
+ console.error('Run: bunx playwright install chromium');
190
+ process.exit(1);
191
+ }
192
+
193
+ const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
194
+ const currentDir = join(SNAPSHOT_DIR, timestamp);
195
+ const diffDir = join(SNAPSHOT_DIR, `${timestamp}-diffs`);
196
+ ensureDir(currentDir);
197
+ ensureDir(diffDir);
198
+
199
+ interface DiffResult {
200
+ url: string;
201
+ diffPercentage: number;
202
+ affectedNotes: number;
203
+ diffPath: string | null;
204
+ beforePath: string;
205
+ afterPath: string;
206
+ }
207
+
208
+ const results: DiffResult[] = [];
209
+
210
+ try {
211
+ for (const url of urls) {
212
+ const urlHash = hashUrl(url);
213
+ const previousScreenshot = join(previousDir, `${urlHash}.png`);
214
+
215
+ if (!existsSync(previousScreenshot)) {
216
+ console.log(`Skipping ${url} - no previous screenshot found`);
217
+ continue;
218
+ }
219
+
220
+ console.log(`Processing: ${url}`);
221
+
222
+ // Take current screenshot
223
+ let currentScreenshotPath: string;
224
+ try {
225
+ const context = await browser.newContext({ viewport: { width, height: 800 } });
226
+ const page = await context.newPage();
227
+ await page.goto(url, { waitUntil: 'networkidle', timeout: 30_000 });
228
+ await page.waitForTimeout(1000);
229
+
230
+ currentScreenshotPath = join(currentDir, `${urlHash}.png`);
231
+ await page.screenshot({ fullPage, path: currentScreenshotPath, type: 'png' });
232
+ await context.close();
233
+ } catch (err: any) {
234
+ console.error(` Failed to capture: ${err.message}`);
235
+ continue;
236
+ }
237
+
238
+ // Run pixelmatch comparison
239
+ try {
240
+ const beforeBuf = readFileSync(previousScreenshot);
241
+ const afterBuf = readFileSync(currentScreenshotPath);
242
+
243
+ const beforePng = PNG.sync.read(beforeBuf);
244
+ const afterPng = PNG.sync.read(afterBuf);
245
+
246
+ // Ensure same dimensions - use the smaller of the two
247
+ const minWidth = Math.min(beforePng.width, afterPng.width);
248
+ const minHeight = Math.min(beforePng.height, afterPng.height);
249
+
250
+ // If dimensions differ, we need to crop to common area
251
+ let beforeData = beforePng.data;
252
+ let afterData = afterPng.data;
253
+
254
+ if (beforePng.width !== afterPng.width || beforePng.height !== afterPng.height) {
255
+ console.log(` Note: Image sizes differ (before: ${beforePng.width}x${beforePng.height}, after: ${afterPng.width}x${afterPng.height})`);
256
+ console.log(` Comparing common area: ${minWidth}x${minHeight}`);
257
+
258
+ // Crop both to the minimum dimensions
259
+ beforeData = cropImageData(beforePng.data, beforePng.width, minWidth, minHeight);
260
+ afterData = cropImageData(afterPng.data, afterPng.width, minWidth, minHeight);
261
+ }
262
+
263
+ const diffPng = new PNG({ width: minWidth, height: minHeight });
264
+
265
+ const numDiffPixels = pixelmatch(
266
+ beforeData,
267
+ afterData,
268
+ diffPng.data,
269
+ minWidth,
270
+ minHeight,
271
+ { threshold: 0.1 },
272
+ );
273
+
274
+ const totalPixels = minWidth * minHeight;
275
+ const diffPercentage = (numDiffPixels / totalPixels) * 100;
276
+
277
+ // Save diff image
278
+ const diffPath = join(diffDir, `${urlHash}-diff.png`);
279
+ writeFileSync(diffPath, PNG.sync.write(diffPng));
280
+
281
+ const affectedNotes = notes.filter((n: any) => n.url === url).length;
282
+
283
+ console.log(` Diff: ${diffPercentage.toFixed(2)}% (${numDiffPixels} pixels changed)`);
284
+ console.log(` Affected notes: ${affectedNotes}`);
285
+
286
+ results.push({
287
+ url,
288
+ diffPercentage,
289
+ affectedNotes,
290
+ diffPath,
291
+ beforePath: previousScreenshot,
292
+ afterPath: currentScreenshotPath,
293
+ });
294
+
295
+ // Upload diff records to API for each affected note
296
+ for (const note of notes.filter((n: any) => n.url === url)) {
297
+ try {
298
+ await apiFetch(config, `/notes/${note.id}/diffs`, {
299
+ method: 'POST',
300
+ project,
301
+ body: {
302
+ before_url: previousScreenshot,
303
+ after_url: currentScreenshotPath,
304
+ diff_url: diffPath,
305
+ diff_percentage: diffPercentage,
306
+ },
307
+ });
308
+ } catch (err: any) {
309
+ // Non-fatal: diff record upload failed
310
+ console.error(` Warning: Could not save diff record for note ${note.id}: ${err.message}`);
311
+ }
312
+ }
313
+ } catch (err: any) {
314
+ console.error(` Diff failed: ${err.message}`);
315
+ }
316
+ }
317
+ } finally {
318
+ await browser.close();
319
+ }
320
+
321
+ // Save manifest for current snapshot
322
+ const manifest: Record<string, string> = {};
323
+ for (const url of urls) {
324
+ manifest[hashUrl(url)] = url;
325
+ }
326
+ writeFileSync(join(currentDir, 'manifest.json'), JSON.stringify(manifest, null, 2));
327
+
328
+ // Print summary
329
+ console.log('\n--- Visual Diff Summary ---');
330
+ console.log(`URLs compared: ${results.length}`);
331
+
332
+ const changed = results.filter((r) => r.diffPercentage > 0);
333
+ if (changed.length === 0) {
334
+ console.log('No visual changes detected.');
335
+ } else {
336
+ console.log(`\nVisual changes on ${changed.length} URL(s):\n`);
337
+ console.log(padRight('URL', 50) + padRight('Notes', 8) + padRight('Change %', 12) + 'Status');
338
+ console.log('-'.repeat(80));
339
+ for (const r of results) {
340
+ const status = r.diffPercentage === 0 ? 'No change' : r.diffPercentage < 1 ? 'Minor' : r.diffPercentage < 5 ? 'Moderate' : 'Significant';
341
+ const urlDisplay = r.url.length > 48 ? r.url.slice(0, 45) + '...' : r.url;
342
+ console.log(
343
+ padRight(urlDisplay, 50) +
344
+ padRight(String(r.affectedNotes), 8) +
345
+ padRight(r.diffPercentage.toFixed(2) + '%', 12) +
346
+ status,
347
+ );
348
+ }
349
+ }
350
+
351
+ console.log(`\nDiff images saved to: ${diffDir}`);
352
+ }
353
+
354
+ function cropImageData(data: Uint8Array, srcWidth: number, targetWidth: number, targetHeight: number): Uint8Array {
355
+ const cropped = new Uint8Array(targetWidth * targetHeight * 4);
356
+ for (let y = 0; y < targetHeight; y++) {
357
+ const srcOffset = y * srcWidth * 4;
358
+ const dstOffset = y * targetWidth * 4;
359
+ cropped.set(data.subarray(srcOffset, srcOffset + targetWidth * 4), dstOffset);
360
+ }
361
+ return cropped;
362
+ }
363
+
364
+ function padRight(str: string, len: number): string {
365
+ return str.length >= len ? str : str + ' '.repeat(len - str.length);
366
+ }
package/config.ts CHANGED
@@ -2,10 +2,17 @@ import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'fs';
2
2
  import { join, dirname } from 'path';
3
3
  import { homedir } from 'os';
4
4
 
5
+ export interface ResolveConfig {
6
+ rootDir?: string;
7
+ extensions?: string[];
8
+ excludeDirs?: string[];
9
+ }
10
+
5
11
  export interface Config {
6
12
  apiUrl: string;
7
13
  apiKey: string;
8
14
  defaultProject?: string;
15
+ resolve?: ResolveConfig;
9
16
  }
10
17
 
11
18
  const GLOBAL_PATH = join(homedir(), '.uinotes.json');
package/index.ts CHANGED
@@ -6,6 +6,8 @@ import { listProjects, createProject, updateProject, deleteProject, getProject,
6
6
  import { pull } from './commands/pull.js';
7
7
  import { resolve, wontfix } from './commands/resolve.js';
8
8
  import { login } from './commands/login.js';
9
+ import { comment, listComments } from './commands/comment.js';
10
+ import { context } from './commands/context.js';
9
11
 
10
12
  const HELP = `uinotes - CLI for UI Notes
11
13
 
@@ -22,15 +24,30 @@ Commands:
22
24
  projects delete <slug> Archive a project
23
25
  login Login and create API key
24
26
  pull Fetch open notes
27
+ context AI-optimized digest of open notes
25
28
  resolve <id> Mark note as resolved
26
29
  wontfix <id> Mark note as won't fix
30
+ comment <id> "text" Add a comment to a note
31
+ comments <id> List comments for a note
32
+ resolve-by-component <name> [msg] Bulk resolve notes by component
33
+ resolve-by-selector <sel> [msg] Bulk resolve notes by selector
34
+ visual-diff Capture & compare page screenshots
27
35
  config View/edit configuration
28
36
 
29
37
  Options:
30
38
  --help, -h Show help
31
39
  --project <name> Override project for this request
32
40
  --archived Include archived projects in list
33
- --url <pattern> URL pattern for project create (repeatable)`;
41
+ --url <pattern> URL pattern for project create (repeatable)
42
+ --format <fmt> Output format for context: markdown (default), xml, json
43
+ --component <n> Filter notes by component name (context command)
44
+ --status <s> Filter notes by status (default: open)
45
+ --type <t> Filter notes by type
46
+ --resolve-files Resolve notes to source files (pull, context)
47
+ --root-dir <dir> Source root for file resolution (default: cwd)
48
+ --compare Compare current screenshots against previous (visual-diff)
49
+ --width <px> Viewport width for screenshots (default: 1280)
50
+ --full-page Capture full page height (default: true)`;
34
51
 
35
52
  function parseFlags(args: string[]): { flags: Record<string, string>; arrays: Record<string, string[]>; positional: string[] } {
36
53
  const flags: Record<string, string> = {};
@@ -195,6 +212,21 @@ async function main() {
195
212
  status: flags.status,
196
213
  type: flags.type,
197
214
  limit: flags.limit,
215
+ resolveFiles: flags['resolve-files'] === 'true',
216
+ rootDir: flags['root-dir'],
217
+ });
218
+ break;
219
+
220
+ case 'context':
221
+ await context(config, {
222
+ project,
223
+ type: flags.type,
224
+ status: flags.status,
225
+ component: flags.component,
226
+ format: (flags.format as 'markdown' | 'xml' | 'json') || undefined,
227
+ limit: flags.limit,
228
+ resolveFiles: flags['resolve-files'] === 'true',
229
+ rootDir: flags['root-dir'],
198
230
  });
199
231
  break;
200
232
 
@@ -218,6 +250,73 @@ async function main() {
218
250
  break;
219
251
  }
220
252
 
253
+ case 'comment': {
254
+ const id = positional[1];
255
+ const body = positional.slice(2).join(' ');
256
+ if (!id || !body) {
257
+ console.error('Usage: uinotes comment <note-id> "comment text"');
258
+ process.exit(1);
259
+ }
260
+ await comment(config, id, body, project);
261
+ break;
262
+ }
263
+
264
+ case 'comments': {
265
+ const id = positional[1];
266
+ if (!id) {
267
+ console.error('Usage: uinotes comments <note-id>');
268
+ process.exit(1);
269
+ }
270
+ await listComments(config, id, project);
271
+ break;
272
+ }
273
+
274
+ case 'resolve-by-component': {
275
+ const name = positional[1];
276
+ if (!name) {
277
+ console.error('Usage: uinotes resolve-by-component <component-name> [--message "text"]');
278
+ process.exit(1);
279
+ }
280
+ const { resolveByComponent } = await import('./commands/bulk-resolve.js');
281
+ await resolveByComponent(config, name, {
282
+ project,
283
+ message: flags.message || positional.slice(2).join(' ') || undefined,
284
+ dryRun: flags['dry-run'] === 'true',
285
+ force: flags.force === 'true',
286
+ status: flags.wontfix === 'true' ? 'wontfix' : 'resolved',
287
+ });
288
+ break;
289
+ }
290
+
291
+ case 'visual-diff': {
292
+ const { visualDiff } = await import('./commands/visual-diff.js');
293
+ await visualDiff(config, {
294
+ project,
295
+ url: flags.url,
296
+ compare: flags.compare === 'true',
297
+ width: flags.width ? parseInt(flags.width) : undefined,
298
+ fullPage: flags['full-page'] !== 'false',
299
+ });
300
+ break;
301
+ }
302
+
303
+ case 'resolve-by-selector': {
304
+ const sel = positional[1];
305
+ if (!sel) {
306
+ console.error('Usage: uinotes resolve-by-selector <selector> [--message "text"]');
307
+ process.exit(1);
308
+ }
309
+ const { resolveBySelector } = await import('./commands/bulk-resolve.js');
310
+ await resolveBySelector(config, sel, {
311
+ project,
312
+ message: flags.message || positional.slice(2).join(' ') || undefined,
313
+ dryRun: flags['dry-run'] === 'true',
314
+ force: flags.force === 'true',
315
+ status: flags.wontfix === 'true' ? 'wontfix' : 'resolved',
316
+ });
317
+ break;
318
+ }
319
+
221
320
  default:
222
321
  console.error(`Unknown command: ${command}`);
223
322
  console.error('Run uinotes --help for usage');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@upgraide/ui-notes-cli",
3
- "version": "0.1.1",
3
+ "version": "0.1.2",
4
4
  "description": "CLI for UI Notes - pull and manage UI feedback notes",
5
5
  "license": "MIT",
6
6
  "keywords": ["cli", "ui", "notes", "feedback", "ui-notes"],