incremnt 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/README.md ADDED
@@ -0,0 +1,66 @@
1
+ # incremnt
2
+
3
+ Command-line tool for querying your [incremnt](https://incremnt.app) strength training data.
4
+
5
+ ## Install
6
+
7
+ ```bash
8
+ npm install -g incremnt
9
+ ```
10
+
11
+ ## Usage
12
+
13
+ ### Hosted sync (recommended)
14
+
15
+ After connecting with Apple in the iOS app (Settings > Workout Sync), your workouts sync automatically. To access them from the CLI:
16
+
17
+ ```bash
18
+ incremnt login
19
+ incremnt sessions list --limit 5
20
+ incremnt records
21
+ incremnt records --pretty
22
+ incremnt programs current
23
+ incremnt exercises history --name "Bench Press"
24
+ ```
25
+
26
+ ### Local snapshot
27
+
28
+ If you prefer to work offline, export a snapshot from the app and point the CLI at it:
29
+
30
+ ```bash
31
+ incremnt sessions list --input ~/Downloads/export.onemore.json --limit 5
32
+ incremnt records --input ~/Downloads/export.onemore.json --pretty
33
+ ```
34
+
35
+ If `--input` is omitted, the CLI checks `INCREMNT_SNAPSHOT`, then `ONEMORE_SNAPSHOT`, then common local paths, then the most recent `.onemore.json` in `~/Downloads`.
36
+
37
+ ## Commands
38
+
39
+ | Command | Description |
40
+ |---------|-------------|
41
+ | `sessions list` | Recent sessions with duration and exercise count |
42
+ | `sessions show --id <id>` | Details for a single session |
43
+ | `programs current` | Active program state |
44
+ | `programs list` | All programs |
45
+ | `exercises history --name <name>` | Set-by-set history for an exercise |
46
+ | `records` | Personal records (best e1RM per exercise) |
47
+ | `login` | Authenticate with the hosted sync service |
48
+ | `logout` | Clear stored session |
49
+ | `status` | Show current mode, auth state, and config paths |
50
+ | `contract` | Machine-readable command surface for scripts |
51
+
52
+ ## Flags
53
+
54
+ | Flag | Description |
55
+ |------|-------------|
56
+ | `--pretty` | Human-readable formatted output (default is JSON) |
57
+ | `--input <path>` | Path to a local `.onemore.json` snapshot |
58
+ | `--limit <n>` | Limit number of results (for `sessions list`) |
59
+
60
+ ## Exercise matching
61
+
62
+ `exercises history --name "Bench Press"` uses canonical synonym matching, so it finds `Barbell Bench Press` without pulling in incline, machine, or dumbbell variants.
63
+
64
+ ## License
65
+
66
+ MIT
package/package.json ADDED
@@ -0,0 +1,17 @@
1
+ {
2
+ "name": "incremnt",
3
+ "version": "0.1.0",
4
+ "description": "Command-line tool for querying your incremnt strength training data",
5
+ "license": "MIT",
6
+ "type": "module",
7
+ "bin": {
8
+ "incremnt": "./src/index.js"
9
+ },
10
+ "files": [
11
+ "src/"
12
+ ],
13
+ "scripts": {
14
+ "test": "node --test",
15
+ "dev:sync-fixture": "node ./scripts/dev-sync-fixture-server.js"
16
+ }
17
+ }
package/src/auth.js ADDED
@@ -0,0 +1,319 @@
1
+ import fs from 'node:fs/promises';
2
+ import { contractVersion } from './contract.js';
3
+ import { writeSessionState } from './state.js';
4
+ import { readSnapshot } from './local.js';
5
+ import { resolveServiceUrl } from './service-url.js';
6
+
7
+ export async function importSessionFile(sessionFilePath) {
8
+ const raw = await fs.readFile(sessionFilePath, 'utf8');
9
+ const session = JSON.parse(raw);
10
+ return writeSessionState(session);
11
+ }
12
+
13
+ export async function bootstrapSessionFromSnapshot(snapshotPath) {
14
+ await readSnapshot(snapshotPath);
15
+
16
+ return writeSessionState({
17
+ version: 1,
18
+ mode: 'remote',
19
+ account: {
20
+ id: 'bootstrap-user',
21
+ email: null
22
+ },
23
+ auth: {
24
+ accessToken: 'bootstrap-token',
25
+ refreshToken: null,
26
+ expiresAt: '2999-01-01T00:00:00Z'
27
+ },
28
+ transport: {
29
+ fixturePath: snapshotPath
30
+ }
31
+ });
32
+ }
33
+
34
+ export async function bootstrapSessionFromRemoteBaseUrl(baseUrl, token, account = null) {
35
+ const issuedSession = await issueRemoteSession(baseUrl, token);
36
+ const remoteContract = await fetchRemoteContract(baseUrl, issuedSession.session.accessToken);
37
+
38
+ return writeSessionState({
39
+ version: 1,
40
+ mode: 'remote',
41
+ account: account ?? issuedSession.account ?? {
42
+ id: 'remote-user',
43
+ email: null
44
+ },
45
+ auth: {
46
+ accessToken: issuedSession.session.accessToken,
47
+ refreshToken: null,
48
+ expiresAt: issuedSession.session.expiresAt
49
+ },
50
+ sync: {
51
+ verifiedAt: new Date().toISOString()
52
+ },
53
+ transport: {
54
+ baseUrl,
55
+ contractVersion: remoteContract.contractVersion,
56
+ capabilities: remoteContract.capabilities ?? null
57
+ }
58
+ });
59
+ }
60
+
61
+ export async function bootstrapSessionFromRemoteBaseUrlWithDeviceFlow(baseUrl, {
62
+ authConfig = null,
63
+ onChallenge = null,
64
+ timeoutMs = 60 * 1000
65
+ } = {}) {
66
+ const challenge = await startDeviceLogin(baseUrl);
67
+ if (onChallenge) {
68
+ await onChallenge(challenge, authConfig);
69
+ }
70
+
71
+ const deadline = Date.now() + timeoutMs;
72
+ while (Date.now() < deadline) {
73
+ const result = await pollDeviceLogin(baseUrl, challenge.deviceCode);
74
+ if (result.status === 'approved') {
75
+ const remoteContract = await fetchRemoteContract(baseUrl, result.session.accessToken);
76
+ return writeSessionState({
77
+ version: 1,
78
+ mode: 'remote',
79
+ account: result.account ?? {
80
+ id: 'remote-user',
81
+ email: null
82
+ },
83
+ auth: {
84
+ accessToken: result.session.accessToken,
85
+ refreshToken: null,
86
+ expiresAt: result.session.expiresAt
87
+ },
88
+ sync: {
89
+ verifiedAt: new Date().toISOString()
90
+ },
91
+ transport: {
92
+ baseUrl,
93
+ contractVersion: remoteContract.contractVersion,
94
+ capabilities: remoteContract.capabilities ?? null
95
+ }
96
+ });
97
+ }
98
+
99
+ if (result.status !== 'pending') {
100
+ const error = new Error(result.message ?? 'Unable to complete device login.');
101
+ error.code = result.code ?? 'REMOTE_AUTH_ERROR';
102
+ throw error;
103
+ }
104
+
105
+ await delay((result.intervalSeconds ?? challenge.intervalSeconds ?? 1) * 1000);
106
+ }
107
+
108
+ throw new Error('Timed out waiting for device login approval.');
109
+ }
110
+
111
+ export async function bootstrapSessionFromRemoteBaseUrlWithEmail(baseUrl, email, userId = null) {
112
+ const devLogin = await issueDevLogin(baseUrl, email, userId);
113
+ return bootstrapSessionFromRemoteBaseUrl(baseUrl, devLogin.token, devLogin.account);
114
+ }
115
+
116
+ export async function fetchRemoteAuthConfig(baseUrl) {
117
+ let response;
118
+
119
+ try {
120
+ const url = resolveServiceUrl(baseUrl, '/auth/config');
121
+ response = await fetch(url);
122
+ } catch {
123
+ return null;
124
+ }
125
+
126
+ if (response.status === 404) {
127
+ return null;
128
+ }
129
+
130
+ if (!response.ok) {
131
+ const payload = await response.json().catch(() => ({ error: null }));
132
+ const error = new Error(payload.error ?? 'Unable to fetch incremnt sync auth config.');
133
+ error.code = 'REMOTE_AUTH_ERROR';
134
+ throw error;
135
+ }
136
+
137
+ return response.json();
138
+ }
139
+
140
+ async function startDeviceLogin(baseUrl) {
141
+ let response;
142
+
143
+ try {
144
+ const url = resolveServiceUrl(baseUrl, '/auth/device/start');
145
+ response = await fetch(url, {
146
+ method: 'POST'
147
+ });
148
+ } catch {
149
+ const error = new Error('Unable to reach incremnt sync service.');
150
+ error.code = 'REMOTE_HTTP_ERROR';
151
+ throw error;
152
+ }
153
+
154
+ if (!response.ok) {
155
+ const payload = await response.json().catch(() => ({ error: null }));
156
+ const error = new Error(payload.error ?? 'Unable to start device login.');
157
+ error.code = 'REMOTE_AUTH_ERROR';
158
+ throw error;
159
+ }
160
+
161
+ return response.json();
162
+ }
163
+
164
+ async function pollDeviceLogin(baseUrl, deviceCode) {
165
+ let response;
166
+
167
+ try {
168
+ const url = resolveServiceUrl(baseUrl, '/auth/device/poll');
169
+ response = await fetch(url, {
170
+ method: 'POST',
171
+ headers: {
172
+ 'content-type': 'application/json'
173
+ },
174
+ body: JSON.stringify({ deviceCode })
175
+ });
176
+ } catch {
177
+ const error = new Error('Unable to reach incremnt sync service.');
178
+ error.code = 'REMOTE_HTTP_ERROR';
179
+ throw error;
180
+ }
181
+
182
+ if (response.status === 202) {
183
+ const payload = await response.json();
184
+ return {
185
+ status: 'pending',
186
+ intervalSeconds: payload.intervalSeconds ?? 1
187
+ };
188
+ }
189
+
190
+ if (response.status === 410) {
191
+ return {
192
+ status: 'failed',
193
+ code: 'REMOTE_AUTH_EXPIRED',
194
+ message: 'Device login expired. Run incremnt login again.'
195
+ };
196
+ }
197
+
198
+ if (!response.ok) {
199
+ const payload = await response.json().catch(() => ({ error: null }));
200
+ return {
201
+ status: 'failed',
202
+ code: 'REMOTE_AUTH_ERROR',
203
+ message: payload.error ?? 'Unable to complete device login.'
204
+ };
205
+ }
206
+
207
+ const payload = await response.json();
208
+ return {
209
+ status: 'approved',
210
+ session: payload.session,
211
+ account: payload.account
212
+ };
213
+ }
214
+
215
+ async function issueRemoteSession(baseUrl, token) {
216
+ let response;
217
+
218
+ try {
219
+ const url = resolveServiceUrl(baseUrl, '/auth/session');
220
+ response = await fetch(url, {
221
+ method: 'POST',
222
+ headers: {
223
+ Authorization: `Bearer ${token}`
224
+ }
225
+ });
226
+ } catch {
227
+ const error = new Error('Unable to reach incremnt sync service.');
228
+ error.code = 'REMOTE_HTTP_ERROR';
229
+ throw error;
230
+ }
231
+
232
+ if (response.status === 401 || response.status === 403) {
233
+ const error = new Error('Authentication failed. Check your token and run incremnt login again.');
234
+ error.code = 'REMOTE_AUTH_ERROR';
235
+ throw error;
236
+ }
237
+
238
+ if (!response.ok) {
239
+ const payload = await response.json().catch(() => ({ error: null }));
240
+ const error = new Error(payload.error ?? 'Unable to issue a remote session.');
241
+ error.code = 'REMOTE_AUTH_ERROR';
242
+ throw error;
243
+ }
244
+
245
+ return response.json();
246
+ }
247
+
248
+ async function fetchRemoteContract(baseUrl, token) {
249
+ let response;
250
+
251
+ try {
252
+ const url = resolveServiceUrl(baseUrl, '/cli/contract');
253
+ response = await fetch(url, {
254
+ headers: {
255
+ Authorization: `Bearer ${token}`
256
+ }
257
+ });
258
+ } catch {
259
+ const error = new Error('Unable to reach incremnt sync service.');
260
+ error.code = 'REMOTE_HTTP_ERROR';
261
+ throw error;
262
+ }
263
+
264
+ if (response.status === 401 || response.status === 403) {
265
+ const error = new Error('Authentication failed. Check your token and run incremnt login again.');
266
+ error.code = 'REMOTE_AUTH_ERROR';
267
+ throw error;
268
+ }
269
+
270
+ if (!response.ok) {
271
+ const error = new Error('Unable to reach incremnt sync service.');
272
+ error.code = 'REMOTE_HTTP_ERROR';
273
+ throw error;
274
+ }
275
+
276
+ const payload = await response.json();
277
+ if (payload.contractVersion !== contractVersion) {
278
+ const error = new Error(`Remote incremnt sync service is incompatible with this CLI. Expected contract v${contractVersion}, got v${payload.contractVersion}.`);
279
+ error.code = 'REMOTE_CONTRACT_MISMATCH';
280
+ throw error;
281
+ }
282
+
283
+ return payload;
284
+ }
285
+
286
+ async function issueDevLogin(baseUrl, email, userId) {
287
+ let response;
288
+
289
+ try {
290
+ const url = resolveServiceUrl(baseUrl, '/auth/dev-login');
291
+ response = await fetch(url, {
292
+ method: 'POST',
293
+ headers: {
294
+ 'content-type': 'application/json'
295
+ },
296
+ body: JSON.stringify({
297
+ email,
298
+ userId
299
+ })
300
+ });
301
+ } catch {
302
+ const error = new Error('Unable to reach incremnt sync service.');
303
+ error.code = 'REMOTE_HTTP_ERROR';
304
+ throw error;
305
+ }
306
+
307
+ if (!response.ok) {
308
+ const payload = await response.json().catch(() => ({ error: null }));
309
+ const error = new Error(payload.error ?? 'Unable to issue a dev login token.');
310
+ error.code = 'REMOTE_AUTH_ERROR';
311
+ throw error;
312
+ }
313
+
314
+ return response.json();
315
+ }
316
+
317
+ function delay(ms) {
318
+ return new Promise((resolve) => setTimeout(resolve, ms));
319
+ }
@@ -0,0 +1,40 @@
1
+ export const contractVersion = 1;
2
+
3
+ export const capabilities = {
4
+ readOnly: true,
5
+ localSnapshots: true,
6
+ remoteReads: true,
7
+ remoteAuthShell: true,
8
+ remoteBootstrap: true
9
+ };
10
+
11
+ export const officialCommands = [
12
+ 'sessions list',
13
+ 'sessions show --id <session-id>',
14
+ 'sessions compare --session-id <session-id>',
15
+ 'sessions explain --session-id <session-id>',
16
+ 'programs list',
17
+ 'programs current',
18
+ 'exercises history --name <exercise-name>',
19
+ 'records',
20
+ 'status',
21
+ 'contract',
22
+ 'login',
23
+ 'login --base-url <base-url>',
24
+ 'login --snapshot <snapshot-file>',
25
+ 'login --base-url <base-url> --token <token>',
26
+ 'login --base-url <base-url> --email <email>',
27
+ 'login --session-file <session-file>',
28
+ 'logout'
29
+ ];
30
+
31
+ export const readCommands = new Set([
32
+ 'session-insights',
33
+ 'session-show',
34
+ 'exercise-history',
35
+ 'records',
36
+ 'program-list',
37
+ 'program-summary',
38
+ 'planned-vs-actual',
39
+ 'why-did-this-change'
40
+ ]);
package/src/format.js ADDED
@@ -0,0 +1,72 @@
1
+ const shortMonths = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
2
+ const shortDays = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
3
+
4
+ function formatShortDate(dateString) {
5
+ const date = new Date(dateString);
6
+ if (Number.isNaN(date.getTime())) {
7
+ return dateString;
8
+ }
9
+
10
+ return `${date.getDate()} ${shortMonths[date.getMonth()]}`;
11
+ }
12
+
13
+ function formatDayAndDate(dateString) {
14
+ const date = new Date(dateString);
15
+ if (Number.isNaN(date.getTime())) {
16
+ return dateString;
17
+ }
18
+
19
+ return `${shortDays[date.getDay()]} ${date.getDate()} ${shortMonths[date.getMonth()]}`;
20
+ }
21
+
22
+ function formatRecords(payload) {
23
+ if (!Array.isArray(payload) || payload.length === 0) {
24
+ return 'No records found.';
25
+ }
26
+
27
+ const maxNameLength = Math.max(...payload.map((record) => record.exerciseName.length));
28
+
29
+ return payload.map((record) => {
30
+ const name = record.exerciseName.padEnd(maxNameLength);
31
+ const date = formatShortDate(record.sessionDate);
32
+ const isBodyweight = Number(record.weight) === 0;
33
+
34
+ if (isBodyweight) {
35
+ const reps = `${record.reps} reps`.padStart(12);
36
+ return `${name} ${reps} BW \u00b7 ${date}`;
37
+ }
38
+
39
+ const weight = `${Number(record.weight).toFixed(1)} kg`.padStart(12);
40
+ return `${name} ${weight} e1RM \u00b7 ${date}`;
41
+ }).join('\n');
42
+ }
43
+
44
+ function formatSessionInsights(payload) {
45
+ if (!Array.isArray(payload) || payload.length === 0) {
46
+ return 'No sessions found.';
47
+ }
48
+
49
+ return payload.map((session) => {
50
+ const date = formatDayAndDate(session.sessionDate);
51
+ const dayName = session.dayName ?? 'Workout';
52
+ const exercises = `${session.exerciseCount ?? '?'} exercises`;
53
+ const duration = session.durationSeconds
54
+ ? `${Math.round(session.durationSeconds / 60)} min`
55
+ : '';
56
+ const suffix = duration ? ` \u00b7 ${exercises} \u00b7 ${duration}` : ` \u00b7 ${exercises}`;
57
+
58
+ return `${date} ${dayName}${suffix}`;
59
+ }).join('\n');
60
+ }
61
+
62
+ export function formatPretty(command, payload) {
63
+ if (command === 'records') {
64
+ return formatRecords(payload);
65
+ }
66
+
67
+ if (command === 'session-insights') {
68
+ return formatSessionInsights(payload);
69
+ }
70
+
71
+ return null;
72
+ }
package/src/index.js ADDED
@@ -0,0 +1,6 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { runCli } from './lib.js';
4
+
5
+ const exitCode = await runCli(process.argv.slice(2), process.stdout, process.stderr);
6
+ process.exit(exitCode);