@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 +37 -0
- package/package.json +30 -0
- package/src/commands/auth.js +143 -0
- package/src/commands/config.js +40 -0
- package/src/commands/diet.js +162 -0
- package/src/commands/exercise.js +158 -0
- package/src/commands/record.js +144 -0
- package/src/commands/sleep.js +157 -0
- package/src/commands/timeline.js +28 -0
- package/src/commands/weight.js +153 -0
- package/src/lib/api.js +53 -0
- package/src/lib/config.js +5 -0
- package/src/lib/version-check.js +73 -0
- package/test/e2e.sh +62 -0
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,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! ==="
|