@upgraide/ui-notes-cli 0.1.3 → 0.2.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.
@@ -0,0 +1,116 @@
1
+ import type { Config } from '../config.js';
2
+ import { apiFetch } from '../api.js';
3
+ import { readFileSync } from 'fs';
4
+
5
+ interface BatchOp {
6
+ op: 'resolve' | 'wontfix' | 'comment' | 'reopen';
7
+ id: string;
8
+ message?: string;
9
+ text?: string;
10
+ }
11
+
12
+ interface BatchOptions {
13
+ project?: string;
14
+ concurrency?: number;
15
+ dryRun?: boolean;
16
+ }
17
+
18
+ export async function batch(config: Config, source: string, opts: BatchOptions): Promise<void> {
19
+ let input: string;
20
+ if (source === '-') {
21
+ input = await readStdin();
22
+ } else {
23
+ input = readFileSync(source, 'utf-8');
24
+ }
25
+
26
+ const lines = input.trim().split('\n').filter(l => l.trim());
27
+ const ops: BatchOp[] = [];
28
+
29
+ for (let i = 0; i < lines.length; i++) {
30
+ try {
31
+ ops.push(JSON.parse(lines[i]));
32
+ } catch {
33
+ console.error(`Line ${i + 1}: invalid JSON — skipped`);
34
+ }
35
+ }
36
+
37
+ if (ops.length === 0) {
38
+ console.log('No operations to execute.');
39
+ return;
40
+ }
41
+
42
+ if (opts.dryRun) {
43
+ console.log(`Dry run: ${ops.length} operations`);
44
+ for (let i = 0; i < ops.length; i++) {
45
+ console.log(`[${i + 1}/${ops.length}] ${ops[i].op} ${ops[i].id}`);
46
+ }
47
+ return;
48
+ }
49
+
50
+ const concurrency = opts.concurrency || 5;
51
+ let succeeded = 0;
52
+ let failed = 0;
53
+
54
+ for (let i = 0; i < ops.length; i += concurrency) {
55
+ const chunk = ops.slice(i, i + concurrency);
56
+ await Promise.allSettled(
57
+ chunk.map(async (op, idx) => {
58
+ const globalIdx = i + idx;
59
+ try {
60
+ await executeOp(config, op, opts.project);
61
+ succeeded++;
62
+ console.log(`[${globalIdx + 1}/${ops.length}] ${op.op} ${op.id} ✓`);
63
+ } catch (err: any) {
64
+ failed++;
65
+ console.log(`[${globalIdx + 1}/${ops.length}] ${op.op} ${op.id} ✗ ${err.message}`);
66
+ }
67
+ })
68
+ );
69
+ }
70
+
71
+ console.log(`Batch complete: ${succeeded}/${ops.length} succeeded${failed > 0 ? `, ${failed} failed` : ''}`);
72
+ }
73
+
74
+ async function executeOp(config: Config, op: BatchOp, project?: string): Promise<void> {
75
+ switch (op.op) {
76
+ case 'resolve':
77
+ await apiFetch(config, `/notes/${op.id}`, {
78
+ method: 'PATCH',
79
+ body: { status: 'resolved', resolution: op.message },
80
+ project,
81
+ });
82
+ break;
83
+ case 'wontfix':
84
+ await apiFetch(config, `/notes/${op.id}`, {
85
+ method: 'PATCH',
86
+ body: { status: 'wontfix', resolution: op.message },
87
+ project,
88
+ });
89
+ break;
90
+ case 'comment':
91
+ if (!op.text) throw new Error('Missing "text" field for comment operation');
92
+ await apiFetch(config, `/notes/${op.id}/comments`, {
93
+ method: 'POST',
94
+ body: { body: op.text },
95
+ project,
96
+ });
97
+ break;
98
+ case 'reopen':
99
+ await apiFetch(config, `/notes/${op.id}`, {
100
+ method: 'PATCH',
101
+ body: { status: 'open' },
102
+ project,
103
+ });
104
+ break;
105
+ default:
106
+ throw new Error(`Unknown operation: ${(op as any).op}`);
107
+ }
108
+ }
109
+
110
+ async function readStdin(): Promise<string> {
111
+ const chunks: Buffer[] = [];
112
+ for await (const chunk of process.stdin) {
113
+ chunks.push(Buffer.from(chunk));
114
+ }
115
+ return Buffer.concat(chunks).toString('utf-8');
116
+ }
@@ -0,0 +1,150 @@
1
+ import type { Config } from '../config.js';
2
+ import { apiFetch } from '../api.js';
3
+
4
+ interface ChangelogOptions {
5
+ project?: string;
6
+ since?: string;
7
+ format?: 'markdown' | 'json' | 'conventional';
8
+ }
9
+
10
+ interface Note {
11
+ id: string;
12
+ type: string;
13
+ body: string;
14
+ component: string | null;
15
+ resolved_at: string | null;
16
+ }
17
+
18
+ const TYPE_ORDER = ['bug', 'ux', 'feature', 'question'];
19
+
20
+ const TYPE_LABELS: Record<string, string> = {
21
+ bug: 'Bugs',
22
+ ux: 'UX',
23
+ feature: 'Features',
24
+ question: 'Questions',
25
+ };
26
+
27
+ const TYPE_CONVENTIONAL: Record<string, string> = {
28
+ bug: 'fix',
29
+ ux: 'style',
30
+ feature: 'feat',
31
+ question: 'docs',
32
+ };
33
+
34
+ function isISODate(value: string): boolean {
35
+ return /^\d{4}-\d{2}-\d{2}/.test(value);
36
+ }
37
+
38
+ function resolveTimestamp(since: string): string {
39
+ if (isISODate(since)) {
40
+ return since;
41
+ }
42
+
43
+ // Treat as git ref, resolve to ISO timestamp
44
+ const result = Bun.spawnSync(['git', 'log', '-1', '--format=%cI', since]);
45
+ const output = result.stdout.toString().trim();
46
+
47
+ if (result.exitCode !== 0 || !output) {
48
+ throw new Error(`Could not resolve git ref "${since}". Make sure it exists.`);
49
+ }
50
+
51
+ return output;
52
+ }
53
+
54
+ function formatMarkdown(notes: Note[]): string {
55
+ const grouped: Record<string, Note[]> = {};
56
+
57
+ for (const note of notes) {
58
+ if (!grouped[note.type]) grouped[note.type] = [];
59
+ grouped[note.type].push(note);
60
+ }
61
+
62
+ const lines: string[] = ['## UI Notes Resolved', ''];
63
+
64
+ for (const type of TYPE_ORDER) {
65
+ const group = grouped[type];
66
+ if (!group || group.length === 0) continue;
67
+
68
+ lines.push(`### ${TYPE_LABELS[type] || type}`);
69
+ for (const note of group) {
70
+ const shortId = note.id.slice(0, 8);
71
+ const body = note.body.split('\n')[0].trim();
72
+ lines.push(`- ${body} (#${shortId})`);
73
+ }
74
+ lines.push('');
75
+ }
76
+
77
+ return lines.join('\n');
78
+ }
79
+
80
+ function formatConventional(notes: Note[]): string {
81
+ const lines: string[] = [];
82
+
83
+ for (const type of TYPE_ORDER) {
84
+ const group = notes.filter((n) => n.type === type);
85
+ for (const note of group) {
86
+ const prefix = TYPE_CONVENTIONAL[note.type] || note.type;
87
+ const scope = note.component || 'ui';
88
+ const body = note.body.split('\n')[0].trim();
89
+ const shortId = note.id.slice(0, 8);
90
+ lines.push(`${prefix}(${scope}): ${body} (note:${shortId})`);
91
+ }
92
+ }
93
+
94
+ return lines.join('\n');
95
+ }
96
+
97
+ function formatJSON(notes: Note[]): string {
98
+ const items = notes.map((n) => ({
99
+ id: n.id,
100
+ type: n.type,
101
+ body: n.body,
102
+ component: n.component,
103
+ resolved_at: n.resolved_at,
104
+ }));
105
+ return JSON.stringify(items, null, 2);
106
+ }
107
+
108
+ export async function changelog(config: Config, opts: ChangelogOptions): Promise<void> {
109
+ const since = opts.since;
110
+ if (!since) {
111
+ console.error('--since is required. Provide a git ref (e.g., HEAD~5, v1.2.0) or ISO date (e.g., 2026-02-01).');
112
+ process.exit(1);
113
+ }
114
+
115
+ const timestamp = resolveTimestamp(since);
116
+ const format = opts.format || 'markdown';
117
+
118
+ const params = new URLSearchParams();
119
+ params.set('status', 'all');
120
+ params.set('resolved_after', timestamp);
121
+ params.set('limit', '500');
122
+
123
+ const path = `/notes?${params.toString()}`;
124
+ const response = (await apiFetch(config, path, { project: opts.project })) as {
125
+ ok: boolean;
126
+ data: { notes: Note[] };
127
+ };
128
+
129
+ const notes = response.data.notes
130
+ .filter((n) => n.resolved_at)
131
+ .sort((a, b) => (a.resolved_at! > b.resolved_at! ? 1 : -1));
132
+
133
+ if (notes.length === 0) {
134
+ console.log('No resolved notes found since ' + timestamp);
135
+ return;
136
+ }
137
+
138
+ switch (format) {
139
+ case 'json':
140
+ console.log(formatJSON(notes));
141
+ break;
142
+ case 'conventional':
143
+ console.log(formatConventional(notes));
144
+ break;
145
+ case 'markdown':
146
+ default:
147
+ console.log(formatMarkdown(notes));
148
+ break;
149
+ }
150
+ }
@@ -0,0 +1,67 @@
1
+ import type { Config } from '../config.js';
2
+ import { apiFetch } from '../api.js';
3
+
4
+ interface ExplainOptions {
5
+ format?: 'markdown' | 'json';
6
+ project?: string;
7
+ }
8
+
9
+ export async function explain(config: Config, noteId: string, opts: ExplainOptions): Promise<void> {
10
+ const response = await apiFetch(config, `/notes/${noteId}/explain`, {
11
+ project: opts.project,
12
+ }) as any;
13
+
14
+ const { note, comments, history, visual_diffs } = response.data;
15
+
16
+ if (opts.format === 'json') {
17
+ console.log(JSON.stringify(response.data, null, 2));
18
+ return;
19
+ }
20
+
21
+ // Default: markdown format
22
+ const lines: string[] = [];
23
+ lines.push(`# Note ${note.id} — ${note.type} (${note.status})`);
24
+ lines.push('');
25
+ if (note.component) lines.push(`**Component**: ${note.component}`);
26
+ if (note.selector) lines.push(`**Selector**: \`${note.selector}\``);
27
+ lines.push(`**URL**: ${note.url}`);
28
+ if (note.author) lines.push(`**Author**: ${note.author}`);
29
+ lines.push(`**Created**: ${note.created_at}`);
30
+ if (note.resolved_at) lines.push(`**Resolved**: ${note.resolved_at}`);
31
+ if (note.screenshot_url) lines.push(`**Screenshot**: ${note.screenshot_url}`);
32
+ lines.push('');
33
+ lines.push(`> ${note.body}`);
34
+
35
+ if (comments.length > 0) {
36
+ lines.push('');
37
+ lines.push(`## Comments (${comments.length})`);
38
+ lines.push('');
39
+ for (const c of comments) {
40
+ lines.push(`**${c.author}** (${c.created_at}):`);
41
+ lines.push(`> ${c.body}`);
42
+ lines.push('');
43
+ }
44
+ }
45
+
46
+ if (history.length > 0) {
47
+ lines.push('## History');
48
+ lines.push('');
49
+ for (const h of history) {
50
+ lines.push(`- ${h.changed_at}: ${h.field} ${h.old_value ?? '(none)'} → ${h.new_value ?? '(none)'}${h.changed_by ? ` (${h.changed_by})` : ''}`);
51
+ }
52
+ }
53
+
54
+ if (visual_diffs.length > 0) {
55
+ lines.push('');
56
+ lines.push(`## Visual Diffs (${visual_diffs.length})`);
57
+ lines.push('');
58
+ for (const vd of visual_diffs) {
59
+ lines.push(`- ${vd.created_at}: diff ${vd.diff_percentage != null ? `${vd.diff_percentage}%` : 'N/A'}`);
60
+ if (vd.before_url) lines.push(` Before: ${vd.before_url}`);
61
+ if (vd.after_url) lines.push(` After: ${vd.after_url}`);
62
+ if (vd.diff_url) lines.push(` Diff: ${vd.diff_url}`);
63
+ }
64
+ }
65
+
66
+ console.log(lines.join('\n'));
67
+ }
@@ -0,0 +1,33 @@
1
+ import type { Config } from '../config.js';
2
+ import { readFileSync } from 'fs';
3
+ import { join } from 'path';
4
+
5
+ const RESOURCES = ['notes', 'comments', 'projects', 'webhooks'];
6
+
7
+ export async function schema(_config: Config, resource?: string): Promise<void> {
8
+ if (!resource) {
9
+ console.log('Available schemas:');
10
+ for (const r of RESOURCES) {
11
+ console.log(` ${r}`);
12
+ }
13
+ console.log('\nUsage: uinotes schema --resource <name>');
14
+ return;
15
+ }
16
+
17
+ if (!RESOURCES.includes(resource)) {
18
+ console.error(`Unknown resource: ${resource}`);
19
+ console.error(`Available: ${RESOURCES.join(', ')}`);
20
+ process.exit(1);
21
+ }
22
+
23
+ const schemaDir = join(import.meta.dir, '..', 'schemas');
24
+ const schemaPath = join(schemaDir, `${resource}.json`);
25
+
26
+ try {
27
+ const content = readFileSync(schemaPath, 'utf-8');
28
+ console.log(content);
29
+ } catch {
30
+ console.error(`Schema file not found: ${schemaPath}`);
31
+ process.exit(1);
32
+ }
33
+ }
package/index.ts CHANGED
@@ -1,4 +1,4 @@
1
- #!/usr/bin/env bun
1
+ #!/usr/bin/env bun
2
2
 
3
3
  import { getConfig } from './config.js';
4
4
  import { configShow, configSet } from './commands/config.js';
@@ -25,13 +25,17 @@ Commands:
25
25
  login Login and create API key
26
26
  pull Fetch open notes
27
27
  context AI-optimized digest of open notes
28
+ explain <id> Full context for a single note
28
29
  resolve <id> Mark note as resolved
29
30
  wontfix <id> Mark note as won't fix
30
31
  comment <id> "text" Add a comment to a note
31
32
  comments <id> List comments for a note
32
33
  resolve-by-component <name> [msg] Bulk resolve notes by component
33
34
  resolve-by-selector <sel> [msg] Bulk resolve notes by selector
35
+ changelog Generate changelog from resolved notes
34
36
  visual-diff Capture & compare page screenshots
37
+ batch <file|-> Execute NDJSON batch operations
38
+ schema List or print API resource schemas
35
39
  config View/edit configuration
36
40
 
37
41
  Options:
@@ -47,7 +51,11 @@ Options:
47
51
  --root-dir <dir> Source root for file resolution (default: cwd)
48
52
  --compare Compare current screenshots against previous (visual-diff)
49
53
  --width <px> Viewport width for screenshots (default: 1280)
50
- --full-page Capture full page height (default: true)`;
54
+ --full-page Capture full page height (default: true)
55
+ --since <ref> Git ref or ISO date for changelog start
56
+ --resource <name> Resource name for schema command
57
+ --concurrency <n> Concurrent batch operations (default: 5)
58
+ --dry-run Preview batch operations without executing`;
51
59
 
52
60
  function parseFlags(args: string[]): { flags: Record<string, string>; arrays: Record<string, string[]>; positional: string[] } {
53
61
  const flags: Record<string, string> = {};
@@ -116,6 +124,13 @@ async function main() {
116
124
  return;
117
125
  }
118
126
 
127
+ // Schema command is local-only, no API key needed
128
+ if (command === 'schema') {
129
+ const { schema } = await import('./commands/schema.js');
130
+ await schema(getConfig(), flags.resource);
131
+ return;
132
+ }
133
+
119
134
  const config = getConfig();
120
135
  if (!config.apiKey) {
121
136
  console.error('No API key configured. Run: uinotes config set apiKey <your-key>');
@@ -230,6 +245,20 @@ async function main() {
230
245
  });
231
246
  break;
232
247
 
248
+ case 'explain': {
249
+ const id = positional[1];
250
+ if (!id) {
251
+ console.error('Usage: uinotes explain <note-id> [--format markdown|json]');
252
+ process.exit(1);
253
+ }
254
+ const { explain } = await import('./commands/explain.js');
255
+ await explain(config, id, {
256
+ project,
257
+ format: (flags.format as 'markdown' | 'json') || undefined,
258
+ });
259
+ break;
260
+ }
261
+
233
262
  case 'resolve': {
234
263
  const id = positional[1];
235
264
  if (!id) {
@@ -288,6 +317,16 @@ async function main() {
288
317
  break;
289
318
  }
290
319
 
320
+ case 'changelog': {
321
+ const { changelog } = await import('./commands/changelog.js');
322
+ await changelog(config, {
323
+ project,
324
+ since: flags.since,
325
+ format: (flags.format as 'markdown' | 'json' | 'conventional') || undefined,
326
+ });
327
+ break;
328
+ }
329
+
291
330
  case 'visual-diff': {
292
331
  const { visualDiff } = await import('./commands/visual-diff.js');
293
332
  await visualDiff(config, {
@@ -300,6 +339,21 @@ async function main() {
300
339
  break;
301
340
  }
302
341
 
342
+ case 'batch': {
343
+ const source = positional[1];
344
+ if (!source) {
345
+ console.error('Usage: uinotes batch <file|-> [--concurrency 5] [--dry-run]');
346
+ process.exit(1);
347
+ }
348
+ const { batch } = await import('./commands/batch.js');
349
+ await batch(config, source, {
350
+ project,
351
+ concurrency: flags.concurrency ? parseInt(flags.concurrency) : undefined,
352
+ dryRun: flags['dry-run'] === 'true',
353
+ });
354
+ break;
355
+ }
356
+
303
357
  case 'resolve-by-selector': {
304
358
  const sel = positional[1];
305
359
  if (!sel) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@upgraide/ui-notes-cli",
3
- "version": "0.1.3",
3
+ "version": "0.2.0",
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"],
@@ -11,7 +11,8 @@
11
11
  "*.ts",
12
12
  "commands/*.ts",
13
13
  "formatters/*.ts",
14
- "resolvers/*.ts"
14
+ "resolvers/*.ts",
15
+ "schemas/*.json"
15
16
  ],
16
17
  "engines": {
17
18
  "bun": ">=1.0.0"
@@ -0,0 +1,14 @@
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "title": "Comment",
4
+ "description": "A comment on a note",
5
+ "type": "object",
6
+ "properties": {
7
+ "id": { "type": "string", "description": "Unique comment identifier" },
8
+ "note_id": { "type": "string", "description": "ID of the parent note" },
9
+ "author": { "type": "string", "description": "Comment author" },
10
+ "body": { "type": "string", "description": "Comment text" },
11
+ "created_at": { "type": "string", "format": "date-time", "description": "Creation timestamp" }
12
+ },
13
+ "required": ["id", "note_id", "author", "body", "created_at"]
14
+ }
@@ -0,0 +1,24 @@
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "title": "Note",
4
+ "description": "A UI feedback note attached to a page element",
5
+ "type": "object",
6
+ "properties": {
7
+ "id": { "type": "string", "description": "Unique note identifier" },
8
+ "project": { "type": "string", "description": "Project slug this note belongs to" },
9
+ "type": { "type": "string", "enum": ["bug", "ux", "feature", "question"], "description": "Type of feedback" },
10
+ "status": { "type": "string", "enum": ["open", "resolved", "wontfix"], "description": "Current status" },
11
+ "body": { "type": "string", "description": "Note content/description" },
12
+ "url": { "type": "string", "description": "Page URL where the note was created" },
13
+ "component": { "type": ["string", "null"], "description": "Component name from data attributes" },
14
+ "text_content": { "type": ["string", "null"], "description": "Text content of the annotated element" },
15
+ "selector": { "type": ["string", "null"], "description": "CSS selector for the annotated element" },
16
+ "tag": { "type": ["string", "null"], "description": "HTML tag of the annotated element" },
17
+ "author": { "type": ["string", "null"], "description": "Author email or name" },
18
+ "screenshot_url": { "type": ["string", "null"], "description": "URL to the screenshot capture" },
19
+ "resolution": { "type": ["string", "null"], "description": "Resolution message when resolved" },
20
+ "resolved_at": { "type": ["string", "null"], "format": "date-time", "description": "When the note was resolved" },
21
+ "created_at": { "type": "string", "format": "date-time", "description": "Creation timestamp" }
22
+ },
23
+ "required": ["id", "project", "type", "status", "body", "url", "created_at"]
24
+ }
@@ -0,0 +1,15 @@
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "title": "Project",
4
+ "description": "A UI Notes project that groups notes",
5
+ "type": "object",
6
+ "properties": {
7
+ "id": { "type": "string", "description": "Unique project identifier" },
8
+ "slug": { "type": "string", "description": "URL-friendly project identifier" },
9
+ "name": { "type": "string", "description": "Display name" },
10
+ "url_patterns": { "type": "array", "items": { "type": "string" }, "description": "URL glob patterns for auto-matching" },
11
+ "archived": { "type": "boolean", "description": "Whether the project is archived" },
12
+ "created_at": { "type": "string", "format": "date-time", "description": "Creation timestamp" }
13
+ },
14
+ "required": ["id", "slug", "name", "created_at"]
15
+ }
@@ -0,0 +1,16 @@
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "title": "Webhook",
4
+ "description": "A webhook endpoint for receiving note events",
5
+ "type": "object",
6
+ "properties": {
7
+ "id": { "type": "string", "description": "Unique webhook identifier" },
8
+ "project_id": { "type": "string", "description": "Project this webhook belongs to" },
9
+ "url": { "type": "string", "description": "Webhook delivery URL" },
10
+ "secret": { "type": ["string", "null"], "description": "HMAC signing secret" },
11
+ "events": { "type": "array", "items": { "type": "string" }, "description": "Events to subscribe to" },
12
+ "active": { "type": "boolean", "description": "Whether the webhook is active" },
13
+ "created_at": { "type": "string", "format": "date-time", "description": "Creation timestamp" }
14
+ },
15
+ "required": ["id", "project_id", "url", "events", "active", "created_at"]
16
+ }