@upgraide/ui-notes-cli 0.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.
package/api.ts ADDED
@@ -0,0 +1,36 @@
1
+ import type { Config } from './config.js';
2
+
3
+ export async function apiFetch(
4
+ config: Config,
5
+ path: string,
6
+ options: { method?: string; body?: unknown; project?: string } = {},
7
+ ): Promise<unknown> {
8
+ const { method = 'GET', body, project } = options;
9
+
10
+ const headers: Record<string, string> = {
11
+ 'x-api-key': config.apiKey,
12
+ };
13
+
14
+ const targetProject = project || config.defaultProject;
15
+ if (targetProject) {
16
+ headers['x-project'] = targetProject;
17
+ }
18
+
19
+ if (body) {
20
+ headers['Content-Type'] = 'application/json';
21
+ }
22
+
23
+ const res = await fetch(`${config.apiUrl}${path}`, {
24
+ method,
25
+ headers,
26
+ body: body ? (typeof body === 'string' ? body : JSON.stringify(body)) : undefined,
27
+ });
28
+
29
+ const json = (await res.json()) as { ok: boolean; error?: string; data?: unknown };
30
+
31
+ if (!json.ok) {
32
+ throw new Error(json.error || `API error (${res.status})`);
33
+ }
34
+
35
+ return json;
36
+ }
@@ -0,0 +1,31 @@
1
+ import { getConfig, getConfigPath, setConfigValue } from '../config.js';
2
+
3
+ const VALID_KEYS = ['apiUrl', 'apiKey', 'defaultProject'];
4
+
5
+ function redactKey(value: string): string {
6
+ if (!value || value.length <= 4) return '****';
7
+ return value.slice(0, 4) + '****';
8
+ }
9
+
10
+ export function configShow(): void {
11
+ const config = getConfig();
12
+ const path = getConfigPath();
13
+ console.log(
14
+ JSON.stringify({
15
+ path,
16
+ config: {
17
+ ...config,
18
+ apiKey: redactKey(config.apiKey),
19
+ },
20
+ }),
21
+ );
22
+ }
23
+
24
+ export function configSet(key: string, value: string): void {
25
+ if (!VALID_KEYS.includes(key)) {
26
+ console.error(`Invalid config key: ${key}. Valid keys: ${VALID_KEYS.join(', ')}`);
27
+ process.exit(1);
28
+ }
29
+ const updated = setConfigValue(key, value);
30
+ console.log(JSON.stringify({ ok: true, key, value: key === 'apiKey' ? redactKey(value) : value }));
31
+ }
@@ -0,0 +1,76 @@
1
+ import type { Config } from '../config.js';
2
+ import { setConfigValue } from '../config.js';
3
+
4
+ export async function login(config: Config) {
5
+ const readline = await import('readline');
6
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
7
+
8
+ const ask = (q: string): Promise<string> =>
9
+ new Promise((resolve) => rl.question(q, resolve));
10
+
11
+ try {
12
+ const apiUrl = config.apiUrl || (await ask('API URL (default: http://localhost:3000): ')) || 'http://localhost:3000';
13
+ const email = await ask('Email: ');
14
+ const password = await ask('Password: ');
15
+
16
+ if (!email || !password) {
17
+ console.error('Email and password are required.');
18
+ process.exit(1);
19
+ }
20
+
21
+ // Sign in
22
+ const signInRes = await fetch(`${apiUrl}/api/auth/sign-in/email`, {
23
+ method: 'POST',
24
+ headers: { 'Content-Type': 'application/json' },
25
+ body: JSON.stringify({ email, password }),
26
+ });
27
+
28
+ if (!signInRes.ok) {
29
+ const err = await signInRes.json().catch(() => ({}));
30
+ console.error('Login failed:', (err as any).message || signInRes.statusText);
31
+ process.exit(1);
32
+ }
33
+
34
+ // Extract session cookie
35
+ const cookies = signInRes.headers.getSetCookie?.() || [];
36
+ const sessionCookie = cookies.find((c) => c.startsWith('better-auth.session_token='));
37
+
38
+ if (!sessionCookie) {
39
+ console.error('Login succeeded but no session token received.');
40
+ process.exit(1);
41
+ }
42
+
43
+ const cookieValue = sessionCookie.split(';')[0];
44
+
45
+ // Create an API key using the session
46
+ const keyName = `cli-${new Date().toISOString().slice(0, 10)}`;
47
+ const createKeyRes = await fetch(`${apiUrl}/api/auth/api-key/create`, {
48
+ method: 'POST',
49
+ headers: {
50
+ 'Content-Type': 'application/json',
51
+ Cookie: cookieValue,
52
+ },
53
+ body: JSON.stringify({ name: keyName, prefix: 'uin_' }),
54
+ });
55
+
56
+ if (!createKeyRes.ok) {
57
+ const err = await createKeyRes.json().catch(() => ({}));
58
+ console.error('Failed to create API key:', (err as any).message || createKeyRes.statusText);
59
+ process.exit(1);
60
+ }
61
+
62
+ const keyData = await createKeyRes.json() as { key?: string };
63
+ if (!keyData.key) {
64
+ console.error('Failed to create API key: no key in response');
65
+ process.exit(1);
66
+ }
67
+
68
+ // Save config
69
+ setConfigValue('apiUrl', apiUrl);
70
+ setConfigValue('apiKey', keyData.key);
71
+
72
+ console.log(JSON.stringify({ ok: true, message: 'Logged in and API key saved', keyName }));
73
+ } finally {
74
+ rl.close();
75
+ }
76
+ }
@@ -0,0 +1,135 @@
1
+ import type { Config } from '../config.js';
2
+ import { apiFetch } from '../api.js';
3
+
4
+ interface Project {
5
+ slug: string;
6
+ name: string;
7
+ urlPatterns: string[];
8
+ createdAt: string;
9
+ archived?: boolean;
10
+ }
11
+
12
+ interface ProjectsResponse {
13
+ ok: boolean;
14
+ data: { projects: Project[] };
15
+ }
16
+
17
+ interface ProjectResponse {
18
+ ok: boolean;
19
+ data: Project;
20
+ }
21
+
22
+ export async function listProjects(config: Config, includeArchived = false): Promise<void> {
23
+ const url = includeArchived ? '/projects?archived=true' : '/projects';
24
+ const data = await apiFetch(config, url) as ProjectsResponse;
25
+
26
+ if (!data.data?.projects?.length) {
27
+ console.log('No projects found. Create one with: uinotes projects create <slug> <name> --url <pattern>');
28
+ return;
29
+ }
30
+
31
+ console.log('\nProjects:\n');
32
+ for (const p of data.data.projects) {
33
+ const archived = p.archived ? ' (archived)' : '';
34
+ console.log(` ${p.slug}${archived}`);
35
+ console.log(` Name: ${p.name}`);
36
+ if (p.urlPatterns?.length) {
37
+ console.log(` URLs: ${p.urlPatterns.join(', ')}`);
38
+ }
39
+ console.log(` Created: ${p.createdAt}\n`);
40
+ }
41
+ }
42
+
43
+ export async function createProject(config: Config, slug: string, name: string, urlPatterns: string[] = []): Promise<void> {
44
+ const data = await apiFetch(config, '/projects', {
45
+ method: 'POST',
46
+ body: JSON.stringify({ slug, name, urlPatterns }),
47
+ }) as ProjectResponse;
48
+
49
+ console.log(`✓ Created project: ${data.data.slug}`);
50
+ console.log(` Name: ${data.data.name}`);
51
+ if (data.data.urlPatterns?.length) {
52
+ console.log(` URLs: ${data.data.urlPatterns.join(', ')}`);
53
+ }
54
+ }
55
+
56
+ export async function updateProject(
57
+ config: Config,
58
+ slug: string,
59
+ updates: { name?: string; archived?: boolean }
60
+ ): Promise<void> {
61
+ const data = await apiFetch(config, `/projects/${slug}`, {
62
+ method: 'PATCH',
63
+ body: JSON.stringify(updates),
64
+ }) as ProjectResponse;
65
+
66
+ console.log(`✓ Updated project: ${data.data.slug}`);
67
+ if (updates.name) console.log(` Name: ${data.data.name}`);
68
+ if (updates.archived !== undefined) {
69
+ console.log(` Archived: ${data.data.archived}`);
70
+ }
71
+ }
72
+
73
+ export async function deleteProject(config: Config, slug: string): Promise<void> {
74
+ await apiFetch(config, `/projects/${slug}`, {
75
+ method: 'DELETE',
76
+ });
77
+
78
+ console.log(`✓ Archived project: ${slug}`);
79
+ }
80
+
81
+ export async function getProject(config: Config, slug: string): Promise<void> {
82
+ const data = await apiFetch(config, `/projects/${slug}`) as ProjectResponse;
83
+
84
+ console.log(`\nProject: ${data.data.slug}\n`);
85
+ console.log(` Name: ${data.data.name}`);
86
+ if (data.data.urlPatterns?.length) {
87
+ console.log(` URL patterns:`);
88
+ for (const p of data.data.urlPatterns) {
89
+ console.log(` - ${p}`);
90
+ }
91
+ } else {
92
+ console.log(` URL patterns: (none)`);
93
+ }
94
+ console.log(` Created: ${data.data.createdAt}`);
95
+ console.log(` Archived: ${data.data.archived || false}`);
96
+ }
97
+
98
+ export async function addUrlPattern(config: Config, slug: string, pattern: string): Promise<void> {
99
+ // First get current patterns
100
+ const current = await apiFetch(config, `/projects/${slug}`) as ProjectResponse;
101
+ const patterns = current.data.urlPatterns || [];
102
+
103
+ if (patterns.includes(pattern)) {
104
+ console.log(`Pattern already exists: ${pattern}`);
105
+ return;
106
+ }
107
+
108
+ patterns.push(pattern);
109
+
110
+ const data = await apiFetch(config, `/projects/${slug}`, {
111
+ method: 'PATCH',
112
+ body: JSON.stringify({ urlPatterns: patterns }),
113
+ }) as ProjectResponse;
114
+
115
+ console.log(`✓ Added URL pattern to ${slug}: ${pattern}`);
116
+ console.log(` All patterns: ${data.data.urlPatterns?.join(', ')}`);
117
+ }
118
+
119
+ export async function removeUrlPattern(config: Config, slug: string, pattern: string): Promise<void> {
120
+ // First get current patterns
121
+ const current = await apiFetch(config, `/projects/${slug}`) as ProjectResponse;
122
+ const patterns = (current.data.urlPatterns || []).filter((p: string) => p !== pattern);
123
+
124
+ const data = await apiFetch(config, `/projects/${slug}`, {
125
+ method: 'PATCH',
126
+ body: JSON.stringify({ urlPatterns: patterns }),
127
+ }) as ProjectResponse;
128
+
129
+ console.log(`✓ Removed URL pattern from ${slug}: ${pattern}`);
130
+ if (data.data.urlPatterns?.length) {
131
+ console.log(` Remaining: ${data.data.urlPatterns.join(', ')}`);
132
+ } else {
133
+ console.log(` No patterns remaining`);
134
+ }
135
+ }
@@ -0,0 +1,22 @@
1
+ import type { Config } from '../config.js';
2
+ import { apiFetch } from '../api.js';
3
+
4
+ interface PullOptions {
5
+ project?: string;
6
+ status?: string;
7
+ type?: string;
8
+ limit?: string;
9
+ }
10
+
11
+ export async function pull(config: Config, opts: PullOptions): Promise<void> {
12
+ const params = new URLSearchParams();
13
+ if (opts.status) params.set('status', opts.status);
14
+ if (opts.type) params.set('type', opts.type);
15
+ if (opts.limit) params.set('limit', opts.limit);
16
+
17
+ const qs = params.toString();
18
+ const path = `/notes${qs ? `?${qs}` : ''}`;
19
+
20
+ const data = await apiFetch(config, path, { project: opts.project });
21
+ console.log(JSON.stringify(data));
22
+ }
@@ -0,0 +1,30 @@
1
+ import type { Config } from '../config.js';
2
+ import { apiFetch } from '../api.js';
3
+
4
+ export async function resolve(
5
+ config: Config,
6
+ id: string,
7
+ message: string | undefined,
8
+ project?: string,
9
+ ): Promise<void> {
10
+ const data = await apiFetch(config, `/notes/${id}`, {
11
+ method: 'PATCH',
12
+ body: { status: 'resolved', resolution: message },
13
+ project,
14
+ });
15
+ console.log(JSON.stringify(data));
16
+ }
17
+
18
+ export async function wontfix(
19
+ config: Config,
20
+ id: string,
21
+ message: string | undefined,
22
+ project?: string,
23
+ ): Promise<void> {
24
+ const data = await apiFetch(config, `/notes/${id}`, {
25
+ method: 'PATCH',
26
+ body: { status: 'wontfix', resolution: message },
27
+ project,
28
+ });
29
+ console.log(JSON.stringify(data));
30
+ }
package/config.ts ADDED
@@ -0,0 +1,52 @@
1
+ import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'fs';
2
+ import { join, dirname } from 'path';
3
+ import { homedir } from 'os';
4
+
5
+ export interface Config {
6
+ apiUrl: string;
7
+ apiKey: string;
8
+ defaultProject?: string;
9
+ }
10
+
11
+ const GLOBAL_PATH = join(homedir(), '.uinotes.json');
12
+ const LOCAL_PATH = join(process.cwd(), '.uinotes.json');
13
+
14
+ const DEFAULT_CONFIG: Config = {
15
+ apiUrl: 'http://localhost:3000',
16
+ apiKey: '',
17
+ };
18
+
19
+ function readJsonFile(path: string): Config | null {
20
+ if (!existsSync(path)) return null;
21
+ return JSON.parse(readFileSync(path, 'utf-8'));
22
+ }
23
+
24
+ export function getConfigPath(): string {
25
+ if (existsSync(LOCAL_PATH)) return LOCAL_PATH;
26
+ return GLOBAL_PATH;
27
+ }
28
+
29
+ export function getConfig(): Config {
30
+ // Per-repo config takes priority
31
+ const local = readJsonFile(LOCAL_PATH);
32
+ if (local) return { ...DEFAULT_CONFIG, ...local };
33
+
34
+ const global = readJsonFile(GLOBAL_PATH);
35
+ if (global) return { ...DEFAULT_CONFIG, ...global };
36
+
37
+ // Create default global config
38
+ writeFileSync(GLOBAL_PATH, JSON.stringify(DEFAULT_CONFIG, null, 2) + '\n');
39
+ return DEFAULT_CONFIG;
40
+ }
41
+
42
+ export function setConfigValue(key: string, value: string): Config {
43
+ const path = getConfigPath();
44
+ const config = existsSync(path)
45
+ ? JSON.parse(readFileSync(path, 'utf-8'))
46
+ : { ...DEFAULT_CONFIG };
47
+
48
+ (config as any)[key] = value;
49
+ mkdirSync(dirname(path), { recursive: true });
50
+ writeFileSync(path, JSON.stringify(config, null, 2) + '\n');
51
+ return config;
52
+ }
package/index.ts ADDED
@@ -0,0 +1,231 @@
1
+ #!/usr/bin/env bun
2
+
3
+ import { getConfig } from './config.js';
4
+ import { configShow, configSet } from './commands/config.js';
5
+ import { listProjects, createProject, updateProject, deleteProject, getProject, addUrlPattern, removeUrlPattern } from './commands/projects.js';
6
+ import { pull } from './commands/pull.js';
7
+ import { resolve, wontfix } from './commands/resolve.js';
8
+ import { login } from './commands/login.js';
9
+
10
+ const HELP = `uinotes - CLI for UI Notes
11
+
12
+ Usage:
13
+ uinotes <command> [options]
14
+
15
+ Commands:
16
+ projects List all projects
17
+ projects create <slug> <name> Create a new project
18
+ projects get <slug> Get project details
19
+ projects rename <slug> <name> Rename a project
20
+ projects add-url <slug> <pattern> Add URL pattern to project
21
+ projects remove-url <slug> <pattern> Remove URL pattern
22
+ projects delete <slug> Archive a project
23
+ login Login and create API key
24
+ pull Fetch open notes
25
+ resolve <id> Mark note as resolved
26
+ wontfix <id> Mark note as won't fix
27
+ config View/edit configuration
28
+
29
+ Options:
30
+ --help, -h Show help
31
+ --project <name> Override project for this request
32
+ --archived Include archived projects in list
33
+ --url <pattern> URL pattern for project create (repeatable)`;
34
+
35
+ function parseFlags(args: string[]): { flags: Record<string, string>; arrays: Record<string, string[]>; positional: string[] } {
36
+ const flags: Record<string, string> = {};
37
+ const arrays: Record<string, string[]> = { url: [] };
38
+ const positional: string[] = [];
39
+
40
+ for (let i = 0; i < args.length; i++) {
41
+ const arg = args[i];
42
+ if (arg.startsWith('--')) {
43
+ const key = arg.slice(2);
44
+ const next = args[i + 1];
45
+ if (next && !next.startsWith('--')) {
46
+ // Repeatable flags go into arrays
47
+ if (key === 'url') {
48
+ arrays.url.push(next);
49
+ } else {
50
+ flags[key] = next;
51
+ }
52
+ i++;
53
+ } else {
54
+ flags[key] = 'true';
55
+ }
56
+ } else if (arg === '-h') {
57
+ flags['help'] = 'true';
58
+ } else {
59
+ positional.push(arg);
60
+ }
61
+ }
62
+
63
+ return { flags, arrays, positional };
64
+ }
65
+
66
+ async function main() {
67
+ const args = process.argv.slice(2);
68
+ const { flags, arrays, positional } = parseFlags(args);
69
+
70
+ if (flags.help || positional.length === 0) {
71
+ console.log(HELP);
72
+ process.exit(0);
73
+ }
74
+
75
+ const command = positional[0];
76
+
77
+ // Login command doesn't need API key validation
78
+ if (command === 'login') {
79
+ const config = getConfig();
80
+ await login(config);
81
+ return;
82
+ }
83
+
84
+ // Config command doesn't need API key validation
85
+ if (command === 'config') {
86
+ const sub = positional[1];
87
+ if (sub === 'set') {
88
+ const key = positional[2];
89
+ const value = positional[3];
90
+ if (!key || !value) {
91
+ console.error('Usage: uinotes config set <key> <value>');
92
+ process.exit(1);
93
+ }
94
+ configSet(key, value);
95
+ return;
96
+ }
97
+ // Default to show
98
+ configShow();
99
+ return;
100
+ }
101
+
102
+ const config = getConfig();
103
+ if (!config.apiKey) {
104
+ console.error('No API key configured. Run: uinotes config set apiKey <your-key>');
105
+ process.exit(1);
106
+ }
107
+
108
+ const project = flags.project;
109
+
110
+ switch (command) {
111
+ case 'projects': {
112
+ const sub = positional[1];
113
+
114
+ if (!sub) {
115
+ // List projects
116
+ await listProjects(config, flags.archived === 'true');
117
+ break;
118
+ }
119
+
120
+ switch (sub) {
121
+ case 'create': {
122
+ const slug = positional[2];
123
+ const name = positional.slice(3).join(' ') || positional[3];
124
+ if (!slug || !name) {
125
+ console.error('Usage: uinotes projects create <slug> <name> [--url <pattern>...]');
126
+ process.exit(1);
127
+ }
128
+ await createProject(config, slug, name, arrays.url);
129
+ break;
130
+ }
131
+
132
+ case 'get': {
133
+ const slug = positional[2];
134
+ if (!slug) {
135
+ console.error('Usage: uinotes projects get <slug>');
136
+ process.exit(1);
137
+ }
138
+ await getProject(config, slug);
139
+ break;
140
+ }
141
+
142
+ case 'rename': {
143
+ const slug = positional[2];
144
+ const name = positional.slice(3).join(' ') || positional[3];
145
+ if (!slug || !name) {
146
+ console.error('Usage: uinotes projects rename <slug> <new-name>');
147
+ process.exit(1);
148
+ }
149
+ await updateProject(config, slug, { name });
150
+ break;
151
+ }
152
+
153
+ case 'add-url': {
154
+ const slug = positional[2];
155
+ const pattern = positional[3];
156
+ if (!slug || !pattern) {
157
+ console.error('Usage: uinotes projects add-url <slug> <pattern>');
158
+ process.exit(1);
159
+ }
160
+ await addUrlPattern(config, slug, pattern);
161
+ break;
162
+ }
163
+
164
+ case 'remove-url': {
165
+ const slug = positional[2];
166
+ const pattern = positional[3];
167
+ if (!slug || !pattern) {
168
+ console.error('Usage: uinotes projects remove-url <slug> <pattern>');
169
+ process.exit(1);
170
+ }
171
+ await removeUrlPattern(config, slug, pattern);
172
+ break;
173
+ }
174
+
175
+ case 'delete': {
176
+ const slug = positional[2];
177
+ if (!slug) {
178
+ console.error('Usage: uinotes projects delete <slug>');
179
+ process.exit(1);
180
+ }
181
+ await deleteProject(config, slug);
182
+ break;
183
+ }
184
+
185
+ default:
186
+ console.error(`Unknown projects subcommand: ${sub}`);
187
+ process.exit(1);
188
+ }
189
+ break;
190
+ }
191
+
192
+ case 'pull':
193
+ await pull(config, {
194
+ project,
195
+ status: flags.status,
196
+ type: flags.type,
197
+ limit: flags.limit,
198
+ });
199
+ break;
200
+
201
+ case 'resolve': {
202
+ const id = positional[1];
203
+ if (!id) {
204
+ console.error('Usage: uinotes resolve <id> [message]');
205
+ process.exit(1);
206
+ }
207
+ await resolve(config, id, positional[2], project);
208
+ break;
209
+ }
210
+
211
+ case 'wontfix': {
212
+ const id = positional[1];
213
+ if (!id) {
214
+ console.error('Usage: uinotes wontfix <id> [message]');
215
+ process.exit(1);
216
+ }
217
+ await wontfix(config, id, positional[2], project);
218
+ break;
219
+ }
220
+
221
+ default:
222
+ console.error(`Unknown command: ${command}`);
223
+ console.error('Run uinotes --help for usage');
224
+ process.exit(1);
225
+ }
226
+ }
227
+
228
+ main().catch((err) => {
229
+ console.error(err.message);
230
+ process.exit(1);
231
+ });
package/package.json ADDED
@@ -0,0 +1,17 @@
1
+ {
2
+ "name": "@upgraide/ui-notes-cli",
3
+ "version": "0.1.0",
4
+ "description": "CLI for UI Notes - pull and manage UI feedback notes",
5
+ "license": "MIT",
6
+ "keywords": ["cli", "ui", "notes", "feedback", "ui-notes"],
7
+ "bin": {
8
+ "uinotes": "./index.ts"
9
+ },
10
+ "files": [
11
+ "*.ts",
12
+ "commands/*.ts"
13
+ ],
14
+ "engines": {
15
+ "bun": ">=1.0.0"
16
+ }
17
+ }