@eeymoo/hum 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/bin/index.js ADDED
@@ -0,0 +1,37 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { Command } from 'commander'
4
+ import auth from '../src/commands/auth.js'
5
+ import config from '../src/commands/config.js'
6
+ import record from '../src/commands/record.js'
7
+ import timeline from '../src/commands/timeline.js'
8
+ import weight from '../src/commands/weight.js'
9
+ import exercise from '../src/commands/exercise.js'
10
+ import diet from '../src/commands/diet.js'
11
+ import sleep from '../src/commands/sleep.js'
12
+ import { checkVersion, getCliVersion } from '../src/lib/version-check.js'
13
+
14
+ const program = new Command()
15
+
16
+ program
17
+ .name('hum')
18
+ .description('Health tracking CLI')
19
+ .version(getCliVersion())
20
+
21
+ program.hook('preAction', async (thisCommand) => {
22
+ const commandName = thisCommand.name()
23
+ if (commandName !== 'auth') {
24
+ await checkVersion()
25
+ }
26
+ })
27
+
28
+ program.addCommand(auth)
29
+ program.addCommand(config)
30
+ program.addCommand(record)
31
+ program.addCommand(timeline)
32
+ program.addCommand(weight)
33
+ program.addCommand(exercise)
34
+ program.addCommand(diet)
35
+ program.addCommand(sleep)
36
+
37
+ program.parse()
package/package.json ADDED
@@ -0,0 +1,30 @@
1
+ {
2
+ "name": "@eeymoo/hum",
3
+ "version": "0.1.0",
4
+ "description": "Hum CLI - A command line tool for Hum API",
5
+ "repository": {
6
+ "type": "git",
7
+ "url": "https://github.com/Eeymoo/hum.git",
8
+ "directory": "packages/cli"
9
+ },
10
+ "bugs": {
11
+ "url": "https://github.com/Eeymoo/hum/issues"
12
+ },
13
+ "homepage": "https://github.com/Eeymoo/hum#readme",
14
+ "bin": {
15
+ "hum": "./bin/index.js"
16
+ },
17
+ "type": "module",
18
+ "dependencies": {
19
+ "commander": "^12.1.0",
20
+ "conf": "^13.0.1",
21
+ "node-fetch": "^3.3.2"
22
+ },
23
+ "scripts": {
24
+ "build": "node bin/index.js --version",
25
+ "test:e2e": "bash test/e2e.sh"
26
+ },
27
+ "devDependencies": {
28
+ "typescript": "^5.6.0"
29
+ }
30
+ }
@@ -0,0 +1,143 @@
1
+ import { Command } from 'commander'
2
+ import config from '../lib/config.js'
3
+ import { request } from '../lib/api.js'
4
+ import { getCliVersion } from '../lib/version-check.js'
5
+
6
+ const auth = new Command('auth')
7
+
8
+ auth
9
+ .command('login')
10
+ .option('--api-key <key>', 'API key to authenticate with')
11
+ .option('--device', 'Use device code flow')
12
+ .action(async (options) => {
13
+ try {
14
+ if (options.device) {
15
+ const deviceData = await request('/auth/device', { method: 'POST' })
16
+ console.log('Please visit:', deviceData.verificationUriComplete)
17
+ console.log('Code:', deviceData.userCode)
18
+ console.log('Waiting for authorization...')
19
+
20
+ const maxAttempts = 60
21
+ for (let i = 0; i < maxAttempts; i++) {
22
+ await new Promise(resolve => setTimeout(resolve, deviceData.interval * 1000))
23
+ try {
24
+ const tokenData = await request('/auth/device/token', {
25
+ method: 'POST',
26
+ body: JSON.stringify({
27
+ deviceCode: deviceData.deviceCode,
28
+ grantType: 'urn:ietf:params:oauth:grant-type:device_code'
29
+ })
30
+ })
31
+
32
+ if (tokenData.accessToken) {
33
+ config.set('accessToken', tokenData.accessToken)
34
+ config.set('refreshToken', tokenData.refreshToken)
35
+ console.log('Successfully logged in!')
36
+ return
37
+ }
38
+ } catch {
39
+ continue
40
+ }
41
+ }
42
+ console.error('Authorization timeout')
43
+ } else if (options.apiKey) {
44
+ const result = await request('/auth/verify', {
45
+ method: 'POST',
46
+ body: JSON.stringify({ apiKey: options.apiKey })
47
+ })
48
+
49
+ if (result.valid) {
50
+ config.set('apiKey', options.apiKey)
51
+ const parts = []
52
+ if (result.user) parts.push(result.user)
53
+ if (result.keyName) parts.push(`key: ${result.keyName}`)
54
+ console.log('Successfully logged in' + (parts.length ? ` as ${parts.join(' / ')}` : '') + '!')
55
+ } else {
56
+ console.error('Invalid API key')
57
+ }
58
+ } else {
59
+ console.error('Please provide --api-key or --device option')
60
+ }
61
+ } catch (error) {
62
+ console.error('Login failed:', error.message)
63
+ }
64
+ })
65
+
66
+ auth
67
+ .command('status')
68
+ .action(async () => {
69
+ const apiKey = config.get('apiKey')
70
+ const accessToken = config.get('accessToken')
71
+ const apiUrl = config.get('apiUrl') || 'http://localhost:3000'
72
+ const cliVersion = getCliVersion()
73
+
74
+ if (apiKey || accessToken) {
75
+ console.log('Logged in')
76
+ console.log('API URL:', apiUrl)
77
+ } else {
78
+ console.log('Not logged in')
79
+ }
80
+
81
+ try {
82
+ const response = await fetch(`${apiUrl}/api/v1/health`)
83
+ if (response.ok) {
84
+ const data = await response.json()
85
+ console.log(`CLI: v${cliVersion} | API: v${data.version}`)
86
+ } else {
87
+ console.log(`CLI: v${cliVersion} | API: unreachable`)
88
+ }
89
+ } catch {
90
+ console.log(`CLI: v${cliVersion} | API: unreachable`)
91
+ }
92
+ })
93
+
94
+ auth
95
+ .command('logout')
96
+ .action(() => {
97
+ config.delete('apiKey')
98
+ config.delete('accessToken')
99
+ config.delete('refreshToken')
100
+ console.log('Logged out')
101
+ })
102
+
103
+ auth
104
+ .command('keys')
105
+ .description('Manage API keys')
106
+ .addCommand(new Command('list')
107
+ .action(async () => {
108
+ try {
109
+ const result = await request('/auth/keys')
110
+ console.log(JSON.stringify(result, null, 2))
111
+ } catch (error) {
112
+ console.error('Failed to list keys:', error.message)
113
+ }
114
+ })
115
+ )
116
+ .addCommand(new Command('create')
117
+ .requiredOption('--name <name>', 'Key name')
118
+ .action(async (options) => {
119
+ try {
120
+ const result = await request('/auth/keys', {
121
+ method: 'POST',
122
+ body: JSON.stringify({ name: options.name })
123
+ })
124
+ console.log('API key created:', result.key)
125
+ console.log('Save this key, it will not be shown again.')
126
+ } catch (error) {
127
+ console.error('Failed to create key:', error.message)
128
+ }
129
+ })
130
+ )
131
+ .addCommand(new Command('revoke')
132
+ .requiredOption('--id <id>', 'Key ID')
133
+ .action(async (options) => {
134
+ try {
135
+ await request(`/auth/keys/${options.id}`, { method: 'DELETE' })
136
+ console.log('API key revoked')
137
+ } catch (error) {
138
+ console.error('Failed to revoke key:', error.message)
139
+ }
140
+ })
141
+ )
142
+
143
+ export default auth
@@ -0,0 +1,40 @@
1
+ import { Command } from 'commander'
2
+ import config from '../lib/config.js'
3
+
4
+ const configCmd = new Command('config')
5
+
6
+ configCmd
7
+ .command('set')
8
+ .argument('<key>', 'Config key (api-url)')
9
+ .argument('<value>', 'Config value')
10
+ .action((key, value) => {
11
+ const configKey = key === 'api-url' ? 'apiUrl' : key
12
+ config.set(configKey, value)
13
+ console.log(`Set ${key} to ${value}`)
14
+ })
15
+
16
+ configCmd
17
+ .command('get')
18
+ .argument('<key>', 'Config key (api-url)')
19
+ .action((key) => {
20
+ const configKey = key === 'api-url' ? 'apiUrl' : key
21
+ const value = config.get(configKey)
22
+ if (value !== undefined) {
23
+ console.log(value)
24
+ } else {
25
+ console.log('Not set')
26
+ }
27
+ })
28
+
29
+ configCmd
30
+ .command('list')
31
+ .action(() => {
32
+ const allConfig = config.store
33
+ console.log('Configuration:')
34
+ for (const [key, value] of Object.entries(allConfig)) {
35
+ const displayKey = key === 'apiUrl' ? 'api-url' : key
36
+ console.log(` ${displayKey}: ${value}`)
37
+ }
38
+ })
39
+
40
+ export default configCmd
@@ -0,0 +1,162 @@
1
+ import { Command } from 'commander'
2
+ import { request, createFormData } from '../lib/api.js'
3
+
4
+ const diet = new Command('diet')
5
+
6
+ diet
7
+ .command('add')
8
+ .requiredOption('--meal <type>', 'Meal type (breakfast/lunch/dinner/snack)')
9
+ .option('--calories <value>', 'Calories')
10
+ .option('--protein <value>', 'Protein (g)')
11
+ .option('--carbs <value>', 'Carbs (g)')
12
+ .option('--fat <value>', 'Fat (g)')
13
+ .option('--fiber <value>', 'Fiber (g)')
14
+ .option('--sodium <value>', 'Sodium (mg)')
15
+ .option('--foods <string>', 'Foods in format: "name:amount,name2:amount2"')
16
+ .option('--water <value>', 'Water (ml)')
17
+ .option('--note <note>', 'Note')
18
+ .option('--date <date>', 'Date (YYYY-MM-DD)')
19
+ .option('--file <paths...>', 'File paths to attach')
20
+ .action(async (options) => {
21
+ try {
22
+ const formData = createFormData({
23
+ mealType: options.meal,
24
+ calories: options.calories,
25
+ protein: options.protein,
26
+ carbs: options.carbs,
27
+ fat: options.fat,
28
+ fiber: options.fiber,
29
+ sodium: options.sodium,
30
+ foods: options.foods,
31
+ water: options.water,
32
+ note: options.note,
33
+ date: options.date
34
+ }, options.file || [])
35
+
36
+ const result = await request('/diets', {
37
+ method: 'POST',
38
+ body: formData,
39
+ isFormData: true
40
+ })
41
+
42
+ console.log('Diet record added:', result.id)
43
+ } catch (error) {
44
+ console.error('Failed to add diet record:', error.message)
45
+ }
46
+ })
47
+
48
+ diet
49
+ .command('list')
50
+ .option('--meal <type>', 'Filter by meal type')
51
+ .option('--last <period>', 'Last N days/weeks/months/years')
52
+ .option('--start <date>', 'Start date (YYYY-MM-DD)')
53
+ .option('--end <date>', 'End date (YYYY-MM-DD)')
54
+ .option('--include-deleted', 'Include deleted records')
55
+ .action(async (options) => {
56
+ try {
57
+ const params = new URLSearchParams()
58
+ Object.entries(options).forEach(([key, value]) => {
59
+ if (value) {
60
+ const paramKey = key === 'meal' ? 'mealType' : key === 'includeDeleted' ? 'includeDeleted' : key
61
+ params.append(paramKey, value === true ? 'true' : value)
62
+ }
63
+ })
64
+
65
+ const result = await request(`/diets?${params.toString()}`)
66
+ console.log(JSON.stringify(result, null, 2))
67
+ } catch (error) {
68
+ console.error('Failed to list diet records:', error.message)
69
+ }
70
+ })
71
+
72
+ diet
73
+ .command('stats')
74
+ .option('--last <period>', 'Last N days/weeks/months/years')
75
+ .option('--start <date>', 'Start date (YYYY-MM-DD)')
76
+ .option('--end <date>', 'End date (YYYY-MM-DD)')
77
+ .action(async (options) => {
78
+ try {
79
+ const params = new URLSearchParams()
80
+ Object.entries(options).forEach(([key, value]) => {
81
+ if (value) {
82
+ params.append(key, value)
83
+ }
84
+ })
85
+
86
+ const result = await request(`/diets/stats?${params.toString()}`)
87
+ console.log(JSON.stringify(result, null, 2))
88
+ } catch (error) {
89
+ console.error('Failed to get diet stats:', error.message)
90
+ }
91
+ })
92
+
93
+ diet
94
+ .command('get')
95
+ .requiredOption('--id <id>', 'Diet record ID')
96
+ .action(async (options) => {
97
+ try {
98
+ const result = await request(`/diets/${options.id}`)
99
+ console.log(JSON.stringify(result, null, 2))
100
+ } catch (error) {
101
+ console.error('Failed to get diet record:', error.message)
102
+ }
103
+ })
104
+
105
+ diet
106
+ .command('update')
107
+ .requiredOption('--id <id>', 'Diet record ID')
108
+ .option('--meal <type>', 'Updated meal type')
109
+ .option('--calories <value>', 'Updated calories')
110
+ .option('--protein <value>', 'Updated protein')
111
+ .option('--carbs <value>', 'Updated carbs')
112
+ .option('--fat <value>', 'Updated fat')
113
+ .option('--fiber <value>', 'Updated fiber')
114
+ .option('--sodium <value>', 'Updated sodium')
115
+ .option('--foods <string>', 'Updated foods')
116
+ .option('--water <value>', 'Updated water')
117
+ .option('--note <note>', 'Updated note')
118
+ .option('--date <date>', 'Updated date (YYYY-MM-DD)')
119
+ .option('--file <paths...>', 'File paths to attach')
120
+ .option('--replace-attachments', 'Replace existing attachments instead of adding')
121
+ .action(async (options) => {
122
+ try {
123
+ const formData = createFormData({
124
+ mealType: options.meal,
125
+ calories: options.calories,
126
+ protein: options.protein,
127
+ carbs: options.carbs,
128
+ fat: options.fat,
129
+ fiber: options.fiber,
130
+ sodium: options.sodium,
131
+ foods: options.foods,
132
+ water: options.water,
133
+ note: options.note,
134
+ date: options.date,
135
+ replaceAttachments: options.replaceAttachments ? 'true' : undefined
136
+ }, options.file || [])
137
+
138
+ const result = await request(`/diets/${options.id}`, {
139
+ method: 'PATCH',
140
+ body: formData,
141
+ isFormData: true
142
+ })
143
+
144
+ console.log('Diet record updated:', result.id)
145
+ } catch (error) {
146
+ console.error('Failed to update diet record:', error.message)
147
+ }
148
+ })
149
+
150
+ diet
151
+ .command('delete')
152
+ .requiredOption('--id <id>', 'Diet record ID')
153
+ .action(async (options) => {
154
+ try {
155
+ await request(`/diets/${options.id}`, { method: 'DELETE' })
156
+ console.log('Diet record deleted')
157
+ } catch (error) {
158
+ console.error('Failed to delete diet record:', error.message)
159
+ }
160
+ })
161
+
162
+ export default diet
@@ -0,0 +1,158 @@
1
+ import { Command } from 'commander'
2
+ import { request, createFormData } from '../lib/api.js'
3
+
4
+ const exercise = new Command('exercise')
5
+
6
+ exercise
7
+ .command('add')
8
+ .requiredOption('--type <type>', 'Exercise type (running/strength/cycling/swimming/other)')
9
+ .requiredOption('--duration <value>', 'Duration in minutes')
10
+ .option('--calories <value>', 'Calories burned')
11
+ .option('--activities <string>', 'Activities in format: "name:prop1=val1,prop2=val2;name2:prop1=val1"')
12
+ .option('--heart-rate-avg <value>', 'Average heart rate')
13
+ .option('--heart-rate-max <value>', 'Max heart rate')
14
+ .option('--feeling <value>', 'Feeling 1-10')
15
+ .option('--location <location>', 'Location')
16
+ .option('--note <note>', 'Note')
17
+ .option('--date <date>', 'Date (YYYY-MM-DD)')
18
+ .option('--file <paths...>', 'File paths to attach')
19
+ .action(async (options) => {
20
+ try {
21
+ const formData = createFormData({
22
+ type: options.type,
23
+ duration: options.duration,
24
+ caloriesBurned: options.calories,
25
+ activities: options.activities,
26
+ heartRateAvg: options.heartRateAvg,
27
+ heartRateMax: options.heartRateMax,
28
+ feeling: options.feeling,
29
+ location: options.location,
30
+ note: options.note,
31
+ date: options.date
32
+ }, options.file || [])
33
+
34
+ const result = await request('/exercises', {
35
+ method: 'POST',
36
+ body: formData,
37
+ isFormData: true
38
+ })
39
+
40
+ console.log('Exercise record added:', result.id)
41
+ } catch (error) {
42
+ console.error('Failed to add exercise record:', error.message)
43
+ }
44
+ })
45
+
46
+ exercise
47
+ .command('list')
48
+ .option('--type <type>', 'Filter by type')
49
+ .option('--last <period>', 'Last N days/weeks/months/years')
50
+ .option('--start <date>', 'Start date (YYYY-MM-DD)')
51
+ .option('--end <date>', 'End date (YYYY-MM-DD)')
52
+ .option('--include-deleted', 'Include deleted records')
53
+ .action(async (options) => {
54
+ try {
55
+ const params = new URLSearchParams()
56
+ Object.entries(options).forEach(([key, value]) => {
57
+ if (value) {
58
+ const paramKey = key === 'includeDeleted' ? 'includeDeleted' : key
59
+ params.append(paramKey, value === true ? 'true' : value)
60
+ }
61
+ })
62
+
63
+ const result = await request(`/exercises?${params.toString()}`)
64
+ console.log(JSON.stringify(result, null, 2))
65
+ } catch (error) {
66
+ console.error('Failed to list exercise records:', error.message)
67
+ }
68
+ })
69
+
70
+ exercise
71
+ .command('stats')
72
+ .option('--last <period>', 'Last N days/weeks/months/years')
73
+ .option('--start <date>', 'Start date (YYYY-MM-DD)')
74
+ .option('--end <date>', 'End date (YYYY-MM-DD)')
75
+ .action(async (options) => {
76
+ try {
77
+ const params = new URLSearchParams()
78
+ Object.entries(options).forEach(([key, value]) => {
79
+ if (value) {
80
+ params.append(key, value)
81
+ }
82
+ })
83
+
84
+ const result = await request(`/exercises/stats?${params.toString()}`)
85
+ console.log(JSON.stringify(result, null, 2))
86
+ } catch (error) {
87
+ console.error('Failed to get exercise stats:', error.message)
88
+ }
89
+ })
90
+
91
+ exercise
92
+ .command('get')
93
+ .requiredOption('--id <id>', 'Exercise record ID')
94
+ .action(async (options) => {
95
+ try {
96
+ const result = await request(`/exercises/${options.id}`)
97
+ console.log(JSON.stringify(result, null, 2))
98
+ } catch (error) {
99
+ console.error('Failed to get exercise record:', error.message)
100
+ }
101
+ })
102
+
103
+ exercise
104
+ .command('update')
105
+ .requiredOption('--id <id>', 'Exercise record ID')
106
+ .option('--type <type>', 'Updated type')
107
+ .option('--duration <value>', 'Updated duration')
108
+ .option('--calories <value>', 'Updated calories burned')
109
+ .option('--activities <string>', 'Updated activities')
110
+ .option('--heart-rate-avg <value>', 'Updated average heart rate')
111
+ .option('--heart-rate-max <value>', 'Updated max heart rate')
112
+ .option('--feeling <value>', 'Updated feeling')
113
+ .option('--location <location>', 'Updated location')
114
+ .option('--note <note>', 'Updated note')
115
+ .option('--date <date>', 'Updated date (YYYY-MM-DD)')
116
+ .option('--file <paths...>', 'File paths to attach')
117
+ .option('--replace-attachments', 'Replace existing attachments instead of adding')
118
+ .action(async (options) => {
119
+ try {
120
+ const formData = createFormData({
121
+ type: options.type,
122
+ duration: options.duration,
123
+ caloriesBurned: options.calories,
124
+ activities: options.activities,
125
+ heartRateAvg: options.heartRateAvg,
126
+ heartRateMax: options.heartRateMax,
127
+ feeling: options.feeling,
128
+ location: options.location,
129
+ note: options.note,
130
+ date: options.date,
131
+ replaceAttachments: options.replaceAttachments ? 'true' : undefined
132
+ }, options.file || [])
133
+
134
+ const result = await request(`/exercises/${options.id}`, {
135
+ method: 'PATCH',
136
+ body: formData,
137
+ isFormData: true
138
+ })
139
+
140
+ console.log('Exercise record updated:', result.id)
141
+ } catch (error) {
142
+ console.error('Failed to update exercise record:', error.message)
143
+ }
144
+ })
145
+
146
+ exercise
147
+ .command('delete')
148
+ .requiredOption('--id <id>', 'Exercise record ID')
149
+ .action(async (options) => {
150
+ try {
151
+ await request(`/exercises/${options.id}`, { method: 'DELETE' })
152
+ console.log('Exercise record deleted')
153
+ } catch (error) {
154
+ console.error('Failed to delete exercise record:', error.message)
155
+ }
156
+ })
157
+
158
+ export default exercise
@@ -0,0 +1,144 @@
1
+ import { Command } from 'commander'
2
+ import { request } from '../lib/api.js'
3
+
4
+ const record = new Command('record')
5
+
6
+ record
7
+ .command('add')
8
+ .requiredOption('--type <type>', 'Record type (custom|medical|supplement|symptom|other)')
9
+ .requiredOption('--data <json>', 'JSON data for the record')
10
+ .option('--tags <tags>', 'Comma-separated tags')
11
+ .option('--note <note>', 'Note for the record')
12
+ .option('--attachments <urls>', 'Comma-separated attachment URLs')
13
+ .option('--date <date>', 'Date for the record (YYYY-MM-DD)')
14
+ .action(async (options) => {
15
+ try {
16
+ const recordData = {
17
+ type: options.type,
18
+ data: JSON.parse(options.data),
19
+ tags: options.tags ? options.tags.split(',') : undefined,
20
+ note: options.note,
21
+ attachments: options.attachments ? options.attachments.split(',') : undefined,
22
+ date: options.date
23
+ }
24
+
25
+ const result = await request('/records', {
26
+ method: 'POST',
27
+ body: JSON.stringify(recordData)
28
+ })
29
+
30
+ console.log('Record added:', result.id)
31
+ } catch (error) {
32
+ console.error('Failed to add record:', error.message)
33
+ }
34
+ })
35
+
36
+ record
37
+ .command('list')
38
+ .option('--type <type>', 'Filter by type')
39
+ .option('--tag <tag>', 'Filter by tag')
40
+ .option('--last <period>', 'Last N days/weeks/months/years (e.g., 10, 7d, 2w, 6m, 1y)')
41
+ .option('--start <date>', 'Start date (YYYY-MM-DD)')
42
+ .option('--end <date>', 'End date (YYYY-MM-DD)')
43
+ .option('--date <date>', 'Specific date (YYYY-MM-DD)')
44
+ .option('--include-deleted', 'Include deleted records')
45
+ .action(async (options) => {
46
+ try {
47
+ const params = new URLSearchParams()
48
+ Object.entries(options).forEach(([key, value]) => {
49
+ if (value) {
50
+ const paramKey = key === 'includeDeleted' ? 'includeDeleted' : key
51
+ params.append(paramKey, value === true ? 'true' : value)
52
+ }
53
+ })
54
+
55
+ const result = await request(`/records?${params.toString()}`)
56
+ console.log(JSON.stringify(result, null, 2))
57
+ } catch (error) {
58
+ console.error('Failed to list records:', error.message)
59
+ }
60
+ })
61
+
62
+ record
63
+ .command('get')
64
+ .requiredOption('--id <id>', 'Record ID')
65
+ .option('--include-deleted', 'Include deleted records')
66
+ .action(async (options) => {
67
+ try {
68
+ const params = new URLSearchParams()
69
+ if (options.includeDeleted) {
70
+ params.append('includeDeleted', 'true')
71
+ }
72
+ const queryString = params.toString()
73
+ const result = await request(`/records/${options.id}${queryString ? '?' + queryString : ''}`)
74
+ console.log(JSON.stringify(result, null, 2))
75
+ } catch (error) {
76
+ console.error('Failed to get record:', error.message)
77
+ }
78
+ })
79
+
80
+ record
81
+ .command('update')
82
+ .requiredOption('--id <id>', 'Record ID')
83
+ .option('--data <json>', 'Updated JSON data')
84
+ .option('--tags <tags>', 'Updated comma-separated tags')
85
+ .option('--note <note>', 'Updated note')
86
+ .option('--attachments <urls>', 'Updated comma-separated attachment URLs')
87
+ .option('--date <date>', 'Updated date (YYYY-MM-DD)')
88
+ .action(async (options) => {
89
+ try {
90
+ const updateData = {}
91
+ if (options.data) updateData.data = JSON.parse(options.data)
92
+ if (options.tags) updateData.tags = options.tags.split(',')
93
+ if (options.note) updateData.note = options.note
94
+ if (options.attachments) updateData.attachments = options.attachments.split(',')
95
+ if (options.date) updateData.date = options.date
96
+
97
+ const result = await request(`/records/${options.id}`, {
98
+ method: 'PATCH',
99
+ body: JSON.stringify(updateData)
100
+ })
101
+
102
+ console.log('Record updated:', result.id)
103
+ } catch (error) {
104
+ console.error('Failed to update record:', error.message)
105
+ }
106
+ })
107
+
108
+ record
109
+ .command('delete')
110
+ .requiredOption('--id <id>', 'Record ID')
111
+ .action(async (options) => {
112
+ try {
113
+ await request(`/records/${options.id}`, {
114
+ method: 'DELETE'
115
+ })
116
+
117
+ console.log('Record deleted')
118
+ } catch (error) {
119
+ console.error('Failed to delete record:', error.message)
120
+ }
121
+ })
122
+
123
+ record
124
+ .command('search')
125
+ .requiredOption('--query <text>', 'Search query')
126
+ .option('--type <type>', 'Filter by type')
127
+ .option('--last <period>', 'Last N days/weeks/months/years')
128
+ .option('--include-deleted', 'Include deleted records')
129
+ .action(async (options) => {
130
+ try {
131
+ const params = new URLSearchParams()
132
+ params.append('q', options.query)
133
+ if (options.type) params.append('type', options.type)
134
+ if (options.last) params.append('last', options.last)
135
+ if (options.includeDeleted) params.append('includeDeleted', 'true')
136
+
137
+ const result = await request(`/records/search?${params.toString()}`)
138
+ console.log(JSON.stringify(result, null, 2))
139
+ } catch (error) {
140
+ console.error('Failed to search records:', error.message)
141
+ }
142
+ })
143
+
144
+ export default record
@@ -0,0 +1,157 @@
1
+ import { Command } from 'commander'
2
+ import { request, createFormData } from '../lib/api.js'
3
+
4
+ const sleep = new Command('sleep')
5
+
6
+ sleep
7
+ .command('add')
8
+ .requiredOption('--duration <value>', 'Sleep duration in hours')
9
+ .requiredOption('--bedtime <time>', 'Bedtime (HH:mm)')
10
+ .requiredOption('--waketime <time>', 'Wake time (HH:mm)')
11
+ .requiredOption('--quality <value>', 'Sleep quality 1-10')
12
+ .option('--deep-sleep <value>', 'Deep sleep duration in hours')
13
+ .option('--rem-sleep <value>', 'REM sleep duration in hours')
14
+ .option('--awakenings <value>', 'Number of awakenings')
15
+ .option('--feeling <value>', 'Feeling 1-10')
16
+ .option('--note <note>', 'Note')
17
+ .option('--date <date>', 'Date (YYYY-MM-DD)')
18
+ .option('--file <paths...>', 'File paths to attach')
19
+ .action(async (options) => {
20
+ try {
21
+ const formData = createFormData({
22
+ duration: options.duration,
23
+ bedTime: options.bedtime,
24
+ wakeTime: options.waketime,
25
+ quality: options.quality,
26
+ deepSleep: options.deepSleep,
27
+ remSleep: options.remSleep,
28
+ awakenings: options.awakenings,
29
+ feeling: options.feeling,
30
+ note: options.note,
31
+ date: options.date
32
+ }, options.file || [])
33
+
34
+ const result = await request('/sleeps', {
35
+ method: 'POST',
36
+ body: formData,
37
+ isFormData: true
38
+ })
39
+
40
+ console.log('Sleep record added:', result.id)
41
+ } catch (error) {
42
+ console.error('Failed to add sleep record:', error.message)
43
+ }
44
+ })
45
+
46
+ sleep
47
+ .command('list')
48
+ .option('--last <period>', 'Last N days/weeks/months/years')
49
+ .option('--start <date>', 'Start date (YYYY-MM-DD)')
50
+ .option('--end <date>', 'End date (YYYY-MM-DD)')
51
+ .option('--include-deleted', 'Include deleted records')
52
+ .action(async (options) => {
53
+ try {
54
+ const params = new URLSearchParams()
55
+ Object.entries(options).forEach(([key, value]) => {
56
+ if (value) {
57
+ const paramKey = key === 'includeDeleted' ? 'includeDeleted' : key
58
+ params.append(paramKey, value === true ? 'true' : value)
59
+ }
60
+ })
61
+
62
+ const result = await request(`/sleeps?${params.toString()}`)
63
+ console.log(JSON.stringify(result, null, 2))
64
+ } catch (error) {
65
+ console.error('Failed to list sleep records:', error.message)
66
+ }
67
+ })
68
+
69
+ sleep
70
+ .command('stats')
71
+ .option('--last <period>', 'Last N days/weeks/months/years')
72
+ .option('--start <date>', 'Start date (YYYY-MM-DD)')
73
+ .option('--end <date>', 'End date (YYYY-MM-DD)')
74
+ .action(async (options) => {
75
+ try {
76
+ const params = new URLSearchParams()
77
+ Object.entries(options).forEach(([key, value]) => {
78
+ if (value) {
79
+ params.append(key, value)
80
+ }
81
+ })
82
+
83
+ const result = await request(`/sleeps/stats?${params.toString()}`)
84
+ console.log(JSON.stringify(result, null, 2))
85
+ } catch (error) {
86
+ console.error('Failed to get sleep stats:', error.message)
87
+ }
88
+ })
89
+
90
+ sleep
91
+ .command('get')
92
+ .requiredOption('--id <id>', 'Sleep record ID')
93
+ .action(async (options) => {
94
+ try {
95
+ const result = await request(`/sleeps/${options.id}`)
96
+ console.log(JSON.stringify(result, null, 2))
97
+ } catch (error) {
98
+ console.error('Failed to get sleep record:', error.message)
99
+ }
100
+ })
101
+
102
+ sleep
103
+ .command('update')
104
+ .requiredOption('--id <id>', 'Sleep record ID')
105
+ .option('--duration <value>', 'Updated duration')
106
+ .option('--bedtime <time>', 'Updated bedtime')
107
+ .option('--waketime <time>', 'Updated wake time')
108
+ .option('--quality <value>', 'Updated quality')
109
+ .option('--deep-sleep <value>', 'Updated deep sleep')
110
+ .option('--rem-sleep <value>', 'Updated REM sleep')
111
+ .option('--awakenings <value>', 'Updated number of awakenings')
112
+ .option('--feeling <value>', 'Updated feeling')
113
+ .option('--note <note>', 'Updated note')
114
+ .option('--date <date>', 'Updated date (YYYY-MM-DD)')
115
+ .option('--file <paths...>', 'File paths to attach')
116
+ .option('--replace-attachments', 'Replace existing attachments instead of adding')
117
+ .action(async (options) => {
118
+ try {
119
+ const formData = createFormData({
120
+ duration: options.duration,
121
+ bedTime: options.bedtime,
122
+ wakeTime: options.waketime,
123
+ quality: options.quality,
124
+ deepSleep: options.deepSleep,
125
+ remSleep: options.remSleep,
126
+ awakenings: options.awakenings,
127
+ feeling: options.feeling,
128
+ note: options.note,
129
+ date: options.date,
130
+ replaceAttachments: options.replaceAttachments ? 'true' : undefined
131
+ }, options.file || [])
132
+
133
+ const result = await request(`/sleeps/${options.id}`, {
134
+ method: 'PATCH',
135
+ body: formData,
136
+ isFormData: true
137
+ })
138
+
139
+ console.log('Sleep record updated:', result.id)
140
+ } catch (error) {
141
+ console.error('Failed to update sleep record:', error.message)
142
+ }
143
+ })
144
+
145
+ sleep
146
+ .command('delete')
147
+ .requiredOption('--id <id>', 'Sleep record ID')
148
+ .action(async (options) => {
149
+ try {
150
+ await request(`/sleeps/${options.id}`, { method: 'DELETE' })
151
+ console.log('Sleep record deleted')
152
+ } catch (error) {
153
+ console.error('Failed to delete sleep record:', error.message)
154
+ }
155
+ })
156
+
157
+ export default sleep
@@ -0,0 +1,28 @@
1
+ import { Command } from 'commander'
2
+ import { request } from '../lib/api.js'
3
+
4
+ const timeline = new Command('timeline')
5
+
6
+ timeline
7
+ .option('--last <period>', 'Last N days/weeks/months/years (e.g., 7d, 2w, 1m)')
8
+ .option('--start <date>', 'Start date (YYYY-MM-DD)')
9
+ .option('--end <date>', 'End date (YYYY-MM-DD)')
10
+ .option('--include-deleted', 'Include deleted records')
11
+ .action(async (options) => {
12
+ try {
13
+ const params = new URLSearchParams()
14
+ Object.entries(options).forEach(([key, value]) => {
15
+ if (value) {
16
+ const paramKey = key === 'includeDeleted' ? 'includeDeleted' : key
17
+ params.append(paramKey, value === true ? 'true' : value)
18
+ }
19
+ })
20
+
21
+ const result = await request(`/timeline?${params.toString()}`)
22
+ console.log(JSON.stringify(result, null, 2))
23
+ } catch (error) {
24
+ console.error('Failed to get timeline:', error.message)
25
+ }
26
+ })
27
+
28
+ export default timeline
@@ -0,0 +1,153 @@
1
+ import { Command } from 'commander'
2
+ import { request, createFormData } from '../lib/api.js'
3
+
4
+ const weight = new Command('weight')
5
+
6
+ weight
7
+ .command('add')
8
+ .requiredOption('--value <value>', 'Weight value (kg)')
9
+ .option('--body-fat <value>', 'Body fat percentage')
10
+ .option('--muscle-mass <value>', 'Muscle mass (kg)')
11
+ .option('--bmi <value>', 'BMI')
12
+ .option('--water <value>', 'Water percentage')
13
+ .option('--bone-mass <value>', 'Bone mass (kg)')
14
+ .option('--visceral-fat <value>', 'Visceral fat level')
15
+ .option('--note <note>', 'Note for the record')
16
+ .option('--date <date>', 'Date for the record (YYYY-MM-DD)')
17
+ .option('--file <paths...>', 'File paths to attach')
18
+ .action(async (options) => {
19
+ try {
20
+ const formData = createFormData({
21
+ weight: options.value,
22
+ bodyFat: options.bodyFat,
23
+ muscleMass: options.muscleMass,
24
+ bmi: options.bmi,
25
+ water: options.water,
26
+ boneMass: options.boneMass,
27
+ visceralFat: options.visceralFat,
28
+ note: options.note,
29
+ date: options.date
30
+ }, options.file || [])
31
+
32
+ const result = await request('/weights', {
33
+ method: 'POST',
34
+ body: formData,
35
+ isFormData: true
36
+ })
37
+
38
+ console.log('Weight record added:', result.id)
39
+ } catch (error) {
40
+ console.error('Failed to add weight record:', error.message)
41
+ }
42
+ })
43
+
44
+ weight
45
+ .command('list')
46
+ .option('--last <period>', 'Last N days/weeks/months/years (e.g., 10, 7d, 2w, 6m, 1y)')
47
+ .option('--start <date>', 'Start date (YYYY-MM-DD)')
48
+ .option('--end <date>', 'End date (YYYY-MM-DD)')
49
+ .option('--include-deleted', 'Include deleted records')
50
+ .action(async (options) => {
51
+ try {
52
+ const params = new URLSearchParams()
53
+ Object.entries(options).forEach(([key, value]) => {
54
+ if (value) {
55
+ const paramKey = key === 'includeDeleted' ? 'includeDeleted' : key
56
+ params.append(paramKey, value === true ? 'true' : value)
57
+ }
58
+ })
59
+
60
+ const result = await request(`/weights?${params.toString()}`)
61
+ console.log(JSON.stringify(result, null, 2))
62
+ } catch (error) {
63
+ console.error('Failed to list weight records:', error.message)
64
+ }
65
+ })
66
+
67
+ weight
68
+ .command('stats')
69
+ .option('--last <period>', 'Last N days/weeks/months/years (e.g., 10, 7d, 2w, 6m, 1y)')
70
+ .option('--start <date>', 'Start date (YYYY-MM-DD)')
71
+ .option('--end <date>', 'End date (YYYY-MM-DD)')
72
+ .action(async (options) => {
73
+ try {
74
+ const params = new URLSearchParams()
75
+ Object.entries(options).forEach(([key, value]) => {
76
+ if (value) {
77
+ params.append(key, value)
78
+ }
79
+ })
80
+
81
+ const result = await request(`/weights/stats?${params.toString()}`)
82
+ console.log(JSON.stringify(result, null, 2))
83
+ } catch (error) {
84
+ console.error('Failed to get weight stats:', error.message)
85
+ }
86
+ })
87
+
88
+ weight
89
+ .command('get')
90
+ .requiredOption('--id <id>', 'Weight record ID')
91
+ .action(async (options) => {
92
+ try {
93
+ const result = await request(`/weights/${options.id}`)
94
+ console.log(JSON.stringify(result, null, 2))
95
+ } catch (error) {
96
+ console.error('Failed to get weight record:', error.message)
97
+ }
98
+ })
99
+
100
+ weight
101
+ .command('update')
102
+ .requiredOption('--id <id>', 'Weight record ID')
103
+ .option('--value <value>', 'Updated weight value (kg)')
104
+ .option('--body-fat <value>', 'Updated body fat percentage')
105
+ .option('--muscle-mass <value>', 'Updated muscle mass (kg)')
106
+ .option('--bmi <value>', 'Updated BMI')
107
+ .option('--water <value>', 'Updated water percentage')
108
+ .option('--bone-mass <value>', 'Updated bone mass (kg)')
109
+ .option('--visceral-fat <value>', 'Updated visceral fat level')
110
+ .option('--note <note>', 'Updated note')
111
+ .option('--date <date>', 'Updated date (YYYY-MM-DD)')
112
+ .option('--file <paths...>', 'File paths to attach')
113
+ .option('--replace-attachments', 'Replace existing attachments instead of adding')
114
+ .action(async (options) => {
115
+ try {
116
+ const formData = createFormData({
117
+ weight: options.value,
118
+ bodyFat: options.bodyFat,
119
+ muscleMass: options.muscleMass,
120
+ bmi: options.bmi,
121
+ water: options.water,
122
+ boneMass: options.boneMass,
123
+ visceralFat: options.visceralFat,
124
+ note: options.note,
125
+ date: options.date,
126
+ replaceAttachments: options.replaceAttachments ? 'true' : undefined
127
+ }, options.file || [])
128
+
129
+ const result = await request(`/weights/${options.id}`, {
130
+ method: 'PATCH',
131
+ body: formData,
132
+ isFormData: true
133
+ })
134
+
135
+ console.log('Weight record updated:', result.id)
136
+ } catch (error) {
137
+ console.error('Failed to update weight record:', error.message)
138
+ }
139
+ })
140
+
141
+ weight
142
+ .command('delete')
143
+ .requiredOption('--id <id>', 'Weight record ID')
144
+ .action(async (options) => {
145
+ try {
146
+ await request(`/weights/${options.id}`, { method: 'DELETE' })
147
+ console.log('Weight record deleted')
148
+ } catch (error) {
149
+ console.error('Failed to delete weight record:', error.message)
150
+ }
151
+ })
152
+
153
+ export default weight
package/src/lib/api.js ADDED
@@ -0,0 +1,53 @@
1
+ import fetch from 'node-fetch'
2
+ import { FormData, File } from 'node-fetch'
3
+ import { readFileSync } from 'fs'
4
+ import config from './config.js'
5
+
6
+ export async function request(endpoint, options = {}) {
7
+ const apiUrl = config.get('apiUrl') || 'http://localhost:3000'
8
+ const apiKey = config.get('apiKey')
9
+
10
+ const url = `${apiUrl}/api/v1${endpoint}`
11
+ const headers = {
12
+ ...options.headers
13
+ }
14
+
15
+ if (!options.isFormData) {
16
+ headers['Content-Type'] = 'application/json'
17
+ }
18
+
19
+ if (apiKey) {
20
+ headers['Authorization'] = `Bearer ${apiKey}`
21
+ }
22
+
23
+ const response = await fetch(url, {
24
+ ...options,
25
+ headers
26
+ })
27
+
28
+ if (!response.ok) {
29
+ const error = await response.json().catch(() => ({}))
30
+ throw new Error(error.message || `HTTP error! status: ${response.status}`)
31
+ }
32
+
33
+ return response.json()
34
+ }
35
+
36
+ export function createFormData(fields, files = []) {
37
+ const formData = new FormData()
38
+
39
+ Object.entries(fields).forEach(([key, value]) => {
40
+ if (value !== undefined && value !== null) {
41
+ formData.append(key, value.toString())
42
+ }
43
+ })
44
+
45
+ files.forEach(filePath => {
46
+ const buffer = readFileSync(filePath)
47
+ const fileName = filePath.split('/').pop()
48
+ const file = new File([buffer], fileName)
49
+ formData.append('file', file)
50
+ })
51
+
52
+ return formData
53
+ }
@@ -0,0 +1,5 @@
1
+ import Conf from 'conf'
2
+
3
+ const config = new Conf({ projectName: 'hum' })
4
+
5
+ export default config
@@ -0,0 +1,73 @@
1
+ import { readFileSync } from 'fs'
2
+ import { resolve, dirname } from 'path'
3
+ import { fileURLToPath } from 'url'
4
+ import config from './config.js'
5
+
6
+ const __filename = fileURLToPath(import.meta.url)
7
+ const __dirname = dirname(__filename)
8
+
9
+ export const EXIT_VERSION_MISMATCH = 3
10
+
11
+ export function getCliVersion() {
12
+ try {
13
+ const packageJson = JSON.parse(
14
+ readFileSync(resolve(__dirname, '../../package.json'), 'utf-8')
15
+ )
16
+ return packageJson.version
17
+ } catch {
18
+ return '0.0.0'
19
+ }
20
+ }
21
+
22
+ export function parseVersion(version) {
23
+ const parts = version.split('.').map(Number)
24
+ return {
25
+ major: parts[0] || 0,
26
+ minor: parts[1] || 0,
27
+ patch: parts[2] || 0
28
+ }
29
+ }
30
+
31
+ export async function checkVersion() {
32
+ const cliVersion = getCliVersion()
33
+ const apiUrl = config.get('apiUrl') || 'http://localhost:3000'
34
+
35
+ try {
36
+ const response = await fetch(`${apiUrl}/api/v1/health`)
37
+
38
+ if (!response.ok) {
39
+ console.error('[提示] 无法连接到 API,跳过版本检查。')
40
+ return true
41
+ }
42
+
43
+ const data = await response.json()
44
+ const apiVersion = data.version
45
+
46
+ if (!apiVersion) {
47
+ console.error('[提示] API 未返回版本信息,跳过版本检查。')
48
+ return true
49
+ }
50
+
51
+ const cli = parseVersion(cliVersion)
52
+ const api = parseVersion(apiVersion)
53
+
54
+ if (cli.major !== api.major) {
55
+ console.error(`[错误] CLI (v${cliVersion}) 与 API (v${apiVersion}) 主版本不兼容。`)
56
+ console.error('请执行以下命令升级:')
57
+ console.error(' npm install -g hum-cli@latest')
58
+ console.error('或访问:')
59
+ process.exit(EXIT_VERSION_MISMATCH)
60
+ }
61
+
62
+ if (cli.minor !== api.minor) {
63
+ console.warn(`[警告] CLI (v${cliVersion}) 与 API (v${apiVersion}) 次版本不一致。`)
64
+ console.warn('建议升级以获得完整功能:npm install -g hum-cli@latest')
65
+ return true
66
+ }
67
+
68
+ return true
69
+ } catch (error) {
70
+ console.error('[提示] 无法连接到 API,跳过版本检查。')
71
+ return true
72
+ }
73
+ }
package/test/e2e.sh ADDED
@@ -0,0 +1,62 @@
1
+ #!/bin/bash
2
+ set -e
3
+
4
+ CLI="node $(dirname "$0")/../bin/index.js"
5
+ API_URL="http://localhost:3001"
6
+ API_KEY="abc123"
7
+
8
+ echo "=== Hum CLI E2E Test ==="
9
+ echo ""
10
+
11
+ # 1. Config
12
+ echo "1. Testing config set..."
13
+ $CLI config set apiUrl $API_URL
14
+ echo " ✓ apiUrl set"
15
+
16
+ # 2. Auth
17
+ echo "2. Testing auth login..."
18
+ $CLI auth login --api-key $API_KEY
19
+ echo " ✓ logged in"
20
+
21
+ echo "3. Testing auth status..."
22
+ $CLI auth status
23
+ echo " ✓ status ok"
24
+
25
+ # 3. Record CRUD
26
+ echo "4. Testing record add..."
27
+ RESULT=$($CLI record add --type custom --data '{"test":true}' --tags e2e --note "auto test" 2>&1)
28
+ echo " $RESULT"
29
+ RECORD_ID=$(echo "$RESULT" | grep -o '[0-9a-f]\{8\}-[0-9a-f]\{4\}-[0-9a-f]\{4\}-[0-9a-f]\{4\}-[0-9a-f]\{12\}')
30
+ if [ -z "$RECORD_ID" ]; then
31
+ echo " ✗ failed to get record id"
32
+ exit 1
33
+ fi
34
+ echo " ✓ record added: $RECORD_ID"
35
+
36
+ echo "5. Testing record get..."
37
+ $CLI record get --id $RECORD_ID > /dev/null
38
+ echo " ✓ record get ok"
39
+
40
+ echo "6. Testing record list..."
41
+ $CLI record list --tag e2e > /dev/null
42
+ echo " ✓ record list ok"
43
+
44
+ echo "7. Testing record update..."
45
+ $CLI record update --id $RECORD_ID --data '{"test":false}' > /dev/null
46
+ echo " ✓ record update ok"
47
+
48
+ echo "8. Testing record search..."
49
+ $CLI record search --query "auto test" > /dev/null
50
+ echo " ✓ record search ok"
51
+
52
+ echo "9. Testing record delete..."
53
+ $CLI record delete --id $RECORD_ID > /dev/null
54
+ echo " ✓ record delete ok"
55
+
56
+ # 4. Timeline
57
+ echo "10. Testing timeline..."
58
+ $CLI timeline --last 7d > /dev/null
59
+ echo " ✓ timeline ok"
60
+
61
+ echo ""
62
+ echo "=== All tests passed! ==="