incremnt 0.1.2 → 0.1.4
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/package.json +1 -1
- package/src/contract.js +79 -18
- package/src/format.js +56 -9
- package/src/lib.js +61 -48
- package/src/queries.js +55 -1
- package/src/sync-service.js +181 -16
package/package.json
CHANGED
package/src/contract.js
CHANGED
|
@@ -8,15 +8,85 @@ export const capabilities = {
|
|
|
8
8
|
remoteBootstrap: true
|
|
9
9
|
};
|
|
10
10
|
|
|
11
|
+
// Single source of truth for all read commands.
|
|
12
|
+
// Drives: officialCommands, readCommands, help text, and the contract JSON schema.
|
|
13
|
+
// When adding a command: add one entry here — everything else updates automatically.
|
|
14
|
+
export const commandSchema = [
|
|
15
|
+
{
|
|
16
|
+
command: 'sessions list',
|
|
17
|
+
id: 'session-insights',
|
|
18
|
+
description: 'List recent sessions',
|
|
19
|
+
options: [
|
|
20
|
+
{ name: 'limit', type: 'number', required: false, description: 'Max sessions to return (default: 10)' }
|
|
21
|
+
]
|
|
22
|
+
},
|
|
23
|
+
{
|
|
24
|
+
command: 'sessions show',
|
|
25
|
+
id: 'session-show',
|
|
26
|
+
description: 'Show session details',
|
|
27
|
+
usage: 'sessions show --id <session-id>',
|
|
28
|
+
options: [
|
|
29
|
+
{ name: 'id', type: 'string', required: true, description: 'Session ID' }
|
|
30
|
+
]
|
|
31
|
+
},
|
|
32
|
+
{
|
|
33
|
+
command: 'sessions compare',
|
|
34
|
+
id: 'planned-vs-actual',
|
|
35
|
+
description: 'Compare planned vs actual',
|
|
36
|
+
usage: 'sessions compare --session-id <session-id>',
|
|
37
|
+
options: [
|
|
38
|
+
{ name: 'session-id', type: 'string', required: true, description: 'Session ID' }
|
|
39
|
+
]
|
|
40
|
+
},
|
|
41
|
+
{
|
|
42
|
+
command: 'sessions explain',
|
|
43
|
+
id: 'why-did-this-change',
|
|
44
|
+
description: 'Explain session context',
|
|
45
|
+
usage: 'sessions explain --session-id <session-id>',
|
|
46
|
+
options: [
|
|
47
|
+
{ name: 'session-id', type: 'string', required: true, description: 'Session ID' }
|
|
48
|
+
]
|
|
49
|
+
},
|
|
50
|
+
{
|
|
51
|
+
command: 'programs list',
|
|
52
|
+
id: 'program-list',
|
|
53
|
+
description: 'List all programs',
|
|
54
|
+
options: []
|
|
55
|
+
},
|
|
56
|
+
{
|
|
57
|
+
command: 'programs current',
|
|
58
|
+
id: 'program-summary',
|
|
59
|
+
description: 'Show active program',
|
|
60
|
+
options: []
|
|
61
|
+
},
|
|
62
|
+
{
|
|
63
|
+
command: 'programs show',
|
|
64
|
+
id: 'program-detail',
|
|
65
|
+
description: 'Show program with all exercises',
|
|
66
|
+
usage: 'programs show --id <program-id>',
|
|
67
|
+
options: [
|
|
68
|
+
{ name: 'id', type: 'string', required: false, description: 'Program ID (defaults to active program)' }
|
|
69
|
+
]
|
|
70
|
+
},
|
|
71
|
+
{
|
|
72
|
+
command: 'exercises history',
|
|
73
|
+
id: 'exercise-history',
|
|
74
|
+
description: 'Exercise history',
|
|
75
|
+
usage: 'exercises history --name <exercise-name>',
|
|
76
|
+
options: [
|
|
77
|
+
{ name: 'name', type: 'string', required: true, description: 'Exercise name' }
|
|
78
|
+
]
|
|
79
|
+
},
|
|
80
|
+
{
|
|
81
|
+
command: 'records',
|
|
82
|
+
id: 'records',
|
|
83
|
+
description: 'Personal records',
|
|
84
|
+
options: []
|
|
85
|
+
}
|
|
86
|
+
];
|
|
87
|
+
|
|
11
88
|
export const officialCommands = [
|
|
12
|
-
|
|
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',
|
|
89
|
+
...commandSchema.map((c) => c.usage ?? c.command),
|
|
20
90
|
'status',
|
|
21
91
|
'contract',
|
|
22
92
|
'login',
|
|
@@ -28,13 +98,4 @@ export const officialCommands = [
|
|
|
28
98
|
'logout'
|
|
29
99
|
];
|
|
30
100
|
|
|
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
|
-
]);
|
|
101
|
+
export const readCommands = new Set(commandSchema.map((c) => c.id));
|
package/src/format.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import chalk from 'chalk';
|
|
2
|
+
import { commandSchema } from './contract.js';
|
|
2
3
|
|
|
3
4
|
const shortMonths = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
|
|
4
5
|
const shortDays = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
|
|
@@ -212,6 +213,57 @@ function formatProgramList(payload) {
|
|
|
212
213
|
return lines.join('\n');
|
|
213
214
|
}
|
|
214
215
|
|
|
216
|
+
function formatProgramDetail(payload) {
|
|
217
|
+
if (!payload) {
|
|
218
|
+
return 'Program not found.';
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
const dot = payload.isActive ? chalk.green('\u25cf') : chalk.dim('\u25cb');
|
|
222
|
+
const lines = [` ${dot} ${chalk.bold(payload.programName)}${dimDot()}${chalk.dim(`Week ${payload.currentWeek}`)}`, ''];
|
|
223
|
+
|
|
224
|
+
if (payload.trainingWeekdays?.length > 0) {
|
|
225
|
+
const dayNames = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
|
|
226
|
+
const schedule = payload.trainingWeekdays.map((d) => dayNames[d] ?? d).join(', ');
|
|
227
|
+
lines.push(keyValue('Schedule', schedule));
|
|
228
|
+
lines.push('');
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
for (const day of payload.days ?? []) {
|
|
232
|
+
lines.push(` ${chalk.bold(`Day ${day.dayIndex + 1}`)}${dimDot()}${chalk.bold(day.title ?? 'Untitled')}`);
|
|
233
|
+
|
|
234
|
+
if (day.exercises.length === 0) {
|
|
235
|
+
lines.push(` ${chalk.dim('No exercises')}`);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
for (const exercise of day.exercises) {
|
|
239
|
+
const setCount = exercise.sets.length;
|
|
240
|
+
const weights = [...new Set(exercise.sets.map((s) => s.weight).filter((w) => w != null))];
|
|
241
|
+
const reps = [...new Set(exercise.sets.map((s) => s.reps).filter((r) => r != null))];
|
|
242
|
+
|
|
243
|
+
let setStr = `${setCount} sets`;
|
|
244
|
+
if (reps.length === 1) {
|
|
245
|
+
setStr = `${setCount}\u00d7${reps[0]}`;
|
|
246
|
+
} else if (reps.length > 1) {
|
|
247
|
+
setStr = `${setCount}\u00d7${reps.join('/')}`;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
const weightStr = weights.length === 1
|
|
251
|
+
? `@ ${weights[0]} kg`
|
|
252
|
+
: weights.length > 1
|
|
253
|
+
? `@ ${Math.min(...weights)}-${Math.max(...weights)} kg`
|
|
254
|
+
: '';
|
|
255
|
+
|
|
256
|
+
const muscle = exercise.muscleGroup ? chalk.dim(` (${exercise.muscleGroup})`) : '';
|
|
257
|
+
lines.push(` ${exercise.name}${muscle}${dimDot()}${chalk.dim([setStr, weightStr].filter(Boolean).join(' '))}`);
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
lines.push('');
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
if (lines.at(-1) === '') lines.pop();
|
|
264
|
+
return lines.join('\n');
|
|
265
|
+
}
|
|
266
|
+
|
|
215
267
|
function formatPlannedVsActual(payload) {
|
|
216
268
|
if (!payload) {
|
|
217
269
|
return 'No comparison data found.';
|
|
@@ -326,17 +378,11 @@ export function formatHelp() {
|
|
|
326
378
|
` incremnt <command> [options]`,
|
|
327
379
|
'',
|
|
328
380
|
header('COMMANDS'),
|
|
329
|
-
cmd(
|
|
330
|
-
cmd('sessions show --id <id>', 'Show session details'),
|
|
331
|
-
cmd('sessions compare --id <id>', 'Compare planned vs actual'),
|
|
332
|
-
cmd('sessions explain --id <id>', 'Explain session context'),
|
|
333
|
-
cmd('programs list', 'List all programs'),
|
|
334
|
-
cmd('programs current', 'Show active program'),
|
|
335
|
-
cmd('exercises history --name <name>', 'Exercise history'),
|
|
336
|
-
cmd('records', 'Personal records'),
|
|
381
|
+
...commandSchema.map((c) => cmd(c.usage ?? c.command, c.description)),
|
|
337
382
|
'',
|
|
338
383
|
header('AUTH'),
|
|
339
|
-
cmd('login
|
|
384
|
+
cmd('login', 'Sign in (opens browser)'),
|
|
385
|
+
cmd('login --base-url <url>', 'Sign in to a specific server'),
|
|
340
386
|
cmd('login --base-url <url> --token <t>', 'Sign in with token'),
|
|
341
387
|
cmd('login --snapshot <file>', 'Use local snapshot'),
|
|
342
388
|
cmd('login --session-file <file>', 'Import session file'),
|
|
@@ -365,6 +411,7 @@ export function formatPretty(command, payload) {
|
|
|
365
411
|
'exercise-history': formatExerciseHistory,
|
|
366
412
|
'program-summary': formatProgramSummary,
|
|
367
413
|
'program-list': formatProgramList,
|
|
414
|
+
'program-detail': formatProgramDetail,
|
|
368
415
|
'planned-vs-actual': formatPlannedVsActual,
|
|
369
416
|
'why-did-this-change': formatWhyDidThisChange
|
|
370
417
|
}[command];
|
package/src/lib.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import fs from 'node:fs/promises';
|
|
2
2
|
import { spawn } from 'node:child_process';
|
|
3
|
-
import { capabilities, contractVersion, officialCommands, readCommands } from './contract.js';
|
|
3
|
+
import { capabilities, commandSchema, contractVersion, officialCommands, readCommands } from './contract.js';
|
|
4
4
|
import {
|
|
5
5
|
bootstrapSessionFromRemoteBaseUrl,
|
|
6
6
|
bootstrapSessionFromRemoteBaseUrlWithDeviceFlow,
|
|
@@ -53,13 +53,7 @@ function parseArgs(argv) {
|
|
|
53
53
|
export async function runCli(argv, stdout, stderr) {
|
|
54
54
|
const { command, options } = parseArgs(argv);
|
|
55
55
|
const normalizedCommand = ({
|
|
56
|
-
|
|
57
|
-
'sessions show': 'session-show',
|
|
58
|
-
'programs list': 'program-list',
|
|
59
|
-
'programs current': 'program-summary',
|
|
60
|
-
'exercises history': 'exercise-history',
|
|
61
|
-
'sessions compare': 'planned-vs-actual',
|
|
62
|
-
'sessions explain': 'why-did-this-change',
|
|
56
|
+
...Object.fromEntries(commandSchema.map((c) => [c.command, c.id])),
|
|
63
57
|
insights: 'session-insights',
|
|
64
58
|
history: 'exercise-history',
|
|
65
59
|
prs: 'records',
|
|
@@ -114,7 +108,8 @@ export async function runCli(argv, stdout, stderr) {
|
|
|
114
108
|
contractVersion,
|
|
115
109
|
binary: 'incremnt',
|
|
116
110
|
capabilities,
|
|
117
|
-
officialCommands
|
|
111
|
+
officialCommands,
|
|
112
|
+
schema: commandSchema
|
|
118
113
|
}, null, 2)}\n`);
|
|
119
114
|
return 0;
|
|
120
115
|
}
|
|
@@ -135,6 +130,37 @@ export async function runCli(argv, stdout, stderr) {
|
|
|
135
130
|
}
|
|
136
131
|
|
|
137
132
|
if (normalizedCommand === 'login') {
|
|
133
|
+
if (options.snapshot) {
|
|
134
|
+
try {
|
|
135
|
+
const result = await bootstrapSessionFromSnapshot(options.snapshot);
|
|
136
|
+
stdout.write(`${JSON.stringify({
|
|
137
|
+
ok: true,
|
|
138
|
+
sessionPath: result.path,
|
|
139
|
+
account: result.session.account,
|
|
140
|
+
transport: result.session.transport
|
|
141
|
+
}, null, 2)}\n`);
|
|
142
|
+
return 0;
|
|
143
|
+
} catch (error) {
|
|
144
|
+
stderr.write(`${error.message}\n`);
|
|
145
|
+
return 1;
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
if (options['session-file']) {
|
|
150
|
+
try {
|
|
151
|
+
const result = await importSessionFile(options['session-file']);
|
|
152
|
+
stdout.write(`${JSON.stringify({
|
|
153
|
+
ok: true,
|
|
154
|
+
sessionPath: result.path,
|
|
155
|
+
account: result.session.account
|
|
156
|
+
}, null, 2)}\n`);
|
|
157
|
+
return 0;
|
|
158
|
+
} catch (error) {
|
|
159
|
+
stderr.write(`${error.message}\n`);
|
|
160
|
+
return 1;
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
138
164
|
const loginBaseUrl = resolveLoginBaseUrl(options, sessionState);
|
|
139
165
|
|
|
140
166
|
if (loginBaseUrl) {
|
|
@@ -228,49 +254,28 @@ export async function runCli(argv, stdout, stderr) {
|
|
|
228
254
|
}
|
|
229
255
|
}
|
|
230
256
|
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
stdout.write(`${JSON.stringify({
|
|
235
|
-
ok: true,
|
|
236
|
-
sessionPath: result.path,
|
|
237
|
-
account: result.session.account,
|
|
238
|
-
transport: result.session.transport
|
|
239
|
-
}, null, 2)}\n`);
|
|
240
|
-
return 0;
|
|
241
|
-
} catch (error) {
|
|
242
|
-
stderr.write(`${error.message}\n`);
|
|
243
|
-
return 1;
|
|
244
|
-
}
|
|
245
|
-
}
|
|
246
|
-
|
|
247
|
-
if (options['session-file']) {
|
|
248
|
-
try {
|
|
249
|
-
const result = await importSessionFile(options['session-file']);
|
|
250
|
-
stdout.write(`${JSON.stringify({
|
|
251
|
-
ok: true,
|
|
252
|
-
sessionPath: result.path,
|
|
253
|
-
account: result.session.account
|
|
254
|
-
}, null, 2)}\n`);
|
|
255
|
-
return 0;
|
|
256
|
-
} catch (error) {
|
|
257
|
-
stderr.write(`${error.message}\n`);
|
|
258
|
-
return 1;
|
|
259
|
-
}
|
|
260
|
-
}
|
|
261
|
-
|
|
262
|
-
stderr.write('Pass --base-url, --snapshot, or --session-file to login, or set INCREMNT_BASE_URL.\n');
|
|
257
|
+
// loginBaseUrl always resolves (falls back to DEFAULT_BASE_URL) so this is unreachable
|
|
258
|
+
// unless INCREMNT_BASE_URL is explicitly set to empty string to disable the default
|
|
259
|
+
stderr.write('Pass --snapshot or --session-file, or run incremnt login to sign in.\n');
|
|
263
260
|
return 1;
|
|
264
261
|
}
|
|
265
262
|
|
|
263
|
+
const wantJson = options.json || !(stdout.isTTY ?? false);
|
|
264
|
+
const explicitJson = Boolean(options.json);
|
|
265
|
+
|
|
266
266
|
if (!readCommands.has(normalizedCommand)) {
|
|
267
|
-
|
|
267
|
+
const message = `Unknown command: ${command}`;
|
|
268
|
+
if (explicitJson) {
|
|
269
|
+
stdout.write(`${JSON.stringify({ error: message, code: 'UNKNOWN_COMMAND' }, null, 2)}\n`);
|
|
270
|
+
} else {
|
|
271
|
+
stderr.write(`${message}\n`);
|
|
272
|
+
}
|
|
273
|
+
|
|
268
274
|
return 1;
|
|
269
275
|
}
|
|
270
276
|
|
|
271
277
|
try {
|
|
272
278
|
const payload = await transport.executeReadCommand(normalizedCommand, options);
|
|
273
|
-
const wantJson = options.json || !(stdout.isTTY ?? false);
|
|
274
279
|
if (wantJson) {
|
|
275
280
|
stdout.write(`${JSON.stringify(payload, null, 2)}\n`);
|
|
276
281
|
} else {
|
|
@@ -280,16 +285,24 @@ export async function runCli(argv, stdout, stderr) {
|
|
|
280
285
|
|
|
281
286
|
return 0;
|
|
282
287
|
} catch (error) {
|
|
283
|
-
|
|
288
|
+
if (explicitJson) {
|
|
289
|
+
stdout.write(`${JSON.stringify({ error: error.message, code: error.code ?? null }, null, 2)}\n`);
|
|
290
|
+
} else {
|
|
291
|
+
stderr.write(`${error.message}\n`);
|
|
292
|
+
}
|
|
293
|
+
|
|
284
294
|
return 1;
|
|
285
295
|
}
|
|
286
296
|
}
|
|
287
297
|
|
|
298
|
+
const DEFAULT_BASE_URL = 'https://incremnt-sync.onrender.com';
|
|
299
|
+
|
|
288
300
|
function resolveLoginBaseUrl(options, sessionState) {
|
|
289
|
-
return options['base-url']
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
301
|
+
if (options['base-url']) return options['base-url'];
|
|
302
|
+
// If INCREMNT_BASE_URL is explicitly set (even to empty string), respect it — empty = no URL
|
|
303
|
+
if ('INCREMNT_BASE_URL' in process.env) return process.env.INCREMNT_BASE_URL || null;
|
|
304
|
+
if (sessionState.session?.transport?.baseUrl) return sessionState.session.transport.baseUrl;
|
|
305
|
+
return DEFAULT_BASE_URL;
|
|
293
306
|
}
|
|
294
307
|
|
|
295
308
|
async function maybeOpenBrowser(url) {
|
package/src/queries.js
CHANGED
|
@@ -101,6 +101,7 @@ export function exerciseHistory(snapshot, exerciseName) {
|
|
|
101
101
|
muscleGroup: exercise.muscleGroup,
|
|
102
102
|
weight: set.weight,
|
|
103
103
|
reps: set.reps,
|
|
104
|
+
estimatedOneRM: Number(set.weight) * (1 + Number(set.reps) / 30),
|
|
104
105
|
rir: exercise.rir ?? null,
|
|
105
106
|
recommendation: session.recommendations?.[exercise.name] ?? null,
|
|
106
107
|
historicalContext: session.historicalContext ?? null
|
|
@@ -200,7 +201,19 @@ export function findSession(snapshot, sessionId) {
|
|
|
200
201
|
|
|
201
202
|
export function sessionDetails(snapshot, sessionId) {
|
|
202
203
|
const session = findSession(snapshot, sessionId);
|
|
203
|
-
|
|
204
|
+
if (!session) return null;
|
|
205
|
+
|
|
206
|
+
const summary = sessionSummary(session);
|
|
207
|
+
summary.exercises = (session.exercises ?? []).map((exercise) => ({
|
|
208
|
+
name: exercise.name,
|
|
209
|
+
muscleGroup: exercise.muscleGroup ?? null,
|
|
210
|
+
sets: (exercise.sets ?? []).filter((s) => s.isComplete).map((s) => ({
|
|
211
|
+
weight: s.weight ?? null,
|
|
212
|
+
reps: s.reps ?? null,
|
|
213
|
+
rpe: s.rpe ?? null
|
|
214
|
+
}))
|
|
215
|
+
}));
|
|
216
|
+
return summary;
|
|
204
217
|
}
|
|
205
218
|
|
|
206
219
|
export function plannedVsActual(snapshot, sessionId) {
|
|
@@ -252,6 +265,37 @@ export function whyDidThisChange(snapshot, sessionId) {
|
|
|
252
265
|
};
|
|
253
266
|
}
|
|
254
267
|
|
|
268
|
+
export function programDetail(snapshot, programId) {
|
|
269
|
+
const programs = snapshot.programs ?? [];
|
|
270
|
+
const program = programId
|
|
271
|
+
? programs.find((p) => p.id === programId)
|
|
272
|
+
: programs.find((p) => p.id === snapshot.activeProgramId) ?? programs[0];
|
|
273
|
+
|
|
274
|
+
if (!program) return null;
|
|
275
|
+
|
|
276
|
+
return {
|
|
277
|
+
programId: program.id,
|
|
278
|
+
programName: program.name,
|
|
279
|
+
isActive: program.id === snapshot.activeProgramId,
|
|
280
|
+
currentWeek: Number(program.completedCyclesCount ?? 0) + 1,
|
|
281
|
+
currentDayIndex: program.currentDayIndex ?? 0,
|
|
282
|
+
trainingWeekdays: program.trainingWeekdays ?? [],
|
|
283
|
+
completedCyclesCount: program.completedCyclesCount ?? 0,
|
|
284
|
+
days: (program.days ?? []).map((day, index) => ({
|
|
285
|
+
dayIndex: index,
|
|
286
|
+
title: day.title ?? null,
|
|
287
|
+
exercises: (day.exercises ?? []).map((exercise) => ({
|
|
288
|
+
name: exercise.name,
|
|
289
|
+
muscleGroup: exercise.muscleGroup ?? null,
|
|
290
|
+
sets: (exercise.sets ?? []).map((set) => ({
|
|
291
|
+
reps: set.reps ?? null,
|
|
292
|
+
weight: set.weight ?? null
|
|
293
|
+
}))
|
|
294
|
+
}))
|
|
295
|
+
}))
|
|
296
|
+
};
|
|
297
|
+
}
|
|
298
|
+
|
|
255
299
|
function requiredOption(options, primaryKey, legacyKey = null) {
|
|
256
300
|
return options[primaryKey] ?? (legacyKey ? options[legacyKey] : undefined);
|
|
257
301
|
}
|
|
@@ -303,6 +347,16 @@ export function executeReadCommand(snapshot, normalizedCommand, options = {}) {
|
|
|
303
347
|
return { ok: true, payload: programSummary(snapshot) };
|
|
304
348
|
}
|
|
305
349
|
|
|
350
|
+
if (normalizedCommand === 'program-detail') {
|
|
351
|
+
const programId = requiredOption(options, 'id');
|
|
352
|
+
const payload = programDetail(snapshot, programId ?? null);
|
|
353
|
+
if (!payload) {
|
|
354
|
+
return { ok: false, error: programId ? `Program not found: ${programId}` : 'No programs found' };
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
return { ok: true, payload };
|
|
358
|
+
}
|
|
359
|
+
|
|
306
360
|
if (normalizedCommand === 'planned-vs-actual') {
|
|
307
361
|
const sessionId = requiredOption(options, 'session-id');
|
|
308
362
|
if (!sessionId) {
|
package/src/sync-service.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { timingSafeEqual } from 'node:crypto';
|
|
1
|
+
import { createHash, randomUUID, timingSafeEqual } from 'node:crypto';
|
|
2
2
|
import { capabilities as cliCapabilities, contractVersion, officialCommands } from './contract.js';
|
|
3
3
|
import { executeReadCommand } from './queries.js';
|
|
4
4
|
|
|
@@ -13,6 +13,8 @@ const DEFAULT_RATE_LIMIT_RULES = {
|
|
|
13
13
|
'google-callback': 20,
|
|
14
14
|
'apple-start': 20,
|
|
15
15
|
'apple-callback': 20,
|
|
16
|
+
'web-auth-start': 20,
|
|
17
|
+
'web-auth-callback': 20,
|
|
16
18
|
'session-login': 60,
|
|
17
19
|
'session-refresh': 30
|
|
18
20
|
};
|
|
@@ -54,10 +56,10 @@ function internalError(response, error) {
|
|
|
54
56
|
|
|
55
57
|
function constantTimeEqual(a, b) {
|
|
56
58
|
if (!a || !b) return false;
|
|
57
|
-
|
|
58
|
-
const
|
|
59
|
-
|
|
60
|
-
return timingSafeEqual(
|
|
59
|
+
// Hash both values to fixed length to avoid leaking length information
|
|
60
|
+
const hashA = createHash('sha256').update(a).digest();
|
|
61
|
+
const hashB = createHash('sha256').update(b).digest();
|
|
62
|
+
return timingSafeEqual(hashA, hashB);
|
|
61
63
|
}
|
|
62
64
|
|
|
63
65
|
function bearerToken(request) {
|
|
@@ -88,6 +90,17 @@ function createRateLimiter({
|
|
|
88
90
|
const mergedRules = { ...DEFAULT_RATE_LIMIT_RULES, ...rules };
|
|
89
91
|
const buckets = new Map();
|
|
90
92
|
|
|
93
|
+
// Periodically evict expired buckets to prevent memory leak
|
|
94
|
+
const cleanupInterval = setInterval(() => {
|
|
95
|
+
const now = Date.now();
|
|
96
|
+
for (const [key, bucket] of buckets) {
|
|
97
|
+
if (bucket.resetAt <= now) {
|
|
98
|
+
buckets.delete(key);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
}, 5 * 60_000);
|
|
102
|
+
if (cleanupInterval.unref) cleanupInterval.unref();
|
|
103
|
+
|
|
91
104
|
return {
|
|
92
105
|
check(request, command) {
|
|
93
106
|
const limit = mergedRules[command];
|
|
@@ -166,6 +179,10 @@ function routeRequest(url) {
|
|
|
166
179
|
return { command: 'apple-callback', options: {} };
|
|
167
180
|
}
|
|
168
181
|
|
|
182
|
+
if (pathname === '/auth/web/start') {
|
|
183
|
+
return { command: 'web-auth-start', options: {} };
|
|
184
|
+
}
|
|
185
|
+
|
|
169
186
|
if (pathname === '/admin/bootstrap-user') {
|
|
170
187
|
return { command: 'admin-bootstrap-user', options: {} };
|
|
171
188
|
}
|
|
@@ -195,6 +212,11 @@ function routeRequest(url) {
|
|
|
195
212
|
return { command: 'program-summary', options: {} };
|
|
196
213
|
}
|
|
197
214
|
|
|
215
|
+
const programShowMatch = pathname.match(/^\/cli\/programs\/([^/]+)$/);
|
|
216
|
+
if (programShowMatch) {
|
|
217
|
+
return { command: 'program-detail', options: { id: programShowMatch[1] } };
|
|
218
|
+
}
|
|
219
|
+
|
|
198
220
|
if (pathname === '/cli/exercises/history') {
|
|
199
221
|
return {
|
|
200
222
|
command: 'exercise-history',
|
|
@@ -558,14 +580,38 @@ export function createSyncServiceRequestHandler({
|
|
|
558
580
|
configured: false
|
|
559
581
|
},
|
|
560
582
|
buildGoogleAuthUrl = null,
|
|
583
|
+
buildAppleWebAuthUrl = null,
|
|
584
|
+
buildGoogleWebAuthUrl = null,
|
|
585
|
+
completeAppleWebAuth = null,
|
|
586
|
+
completeGoogleWebAuth = null,
|
|
561
587
|
refreshSession,
|
|
562
588
|
allowManualDeviceApproval = false,
|
|
563
|
-
rateLimitConfig = null
|
|
589
|
+
rateLimitConfig = null,
|
|
590
|
+
corsOrigins = []
|
|
564
591
|
}) {
|
|
565
592
|
const rateLimiter = createRateLimiter(rateLimitConfig ?? {});
|
|
566
593
|
|
|
567
594
|
return async function handle(request, response) {
|
|
568
595
|
try {
|
|
596
|
+
// Security headers on all responses
|
|
597
|
+
response.setHeader('X-Content-Type-Options', 'nosniff');
|
|
598
|
+
response.setHeader('X-Frame-Options', 'DENY');
|
|
599
|
+
response.setHeader('Cache-Control', 'no-store');
|
|
600
|
+
|
|
601
|
+
const origin = request.headers.origin ?? '';
|
|
602
|
+
const corsAllowed = corsOrigins.length > 0 && corsOrigins.includes(origin);
|
|
603
|
+
if (corsAllowed) {
|
|
604
|
+
response.setHeader('Access-Control-Allow-Origin', origin);
|
|
605
|
+
response.setHeader('Access-Control-Allow-Headers', 'Authorization, Content-Type');
|
|
606
|
+
response.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, OPTIONS');
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
if (request.method === 'OPTIONS') {
|
|
610
|
+
response.writeHead(corsAllowed ? 204 : 404);
|
|
611
|
+
response.end();
|
|
612
|
+
return;
|
|
613
|
+
}
|
|
614
|
+
|
|
569
615
|
const url = new URL(request.url ?? '/', `http://${request.headers.host ?? '127.0.0.1'}`);
|
|
570
616
|
const route = routeRequest(url);
|
|
571
617
|
if (!route) {
|
|
@@ -755,11 +801,6 @@ export function createSyncServiceRequestHandler({
|
|
|
755
801
|
return;
|
|
756
802
|
}
|
|
757
803
|
|
|
758
|
-
if (!googleAuth?.configured || !completeGoogleDeviceApproval) {
|
|
759
|
-
methodNotAllowed(response, 'Google auth is not enabled for this service mode.');
|
|
760
|
-
return;
|
|
761
|
-
}
|
|
762
|
-
|
|
763
804
|
const code = url.searchParams.get('code') ?? '';
|
|
764
805
|
const state = url.searchParams.get('state') ?? '';
|
|
765
806
|
if (!code || !state) {
|
|
@@ -767,6 +808,42 @@ export function createSyncServiceRequestHandler({
|
|
|
767
808
|
return;
|
|
768
809
|
}
|
|
769
810
|
|
|
811
|
+
let parsedState;
|
|
812
|
+
try {
|
|
813
|
+
parsedState = JSON.parse(state);
|
|
814
|
+
} catch {
|
|
815
|
+
badRequest(response, 'Invalid state parameter.');
|
|
816
|
+
return;
|
|
817
|
+
}
|
|
818
|
+
|
|
819
|
+
if (parsedState?.flow === 'web') {
|
|
820
|
+
if (!completeGoogleWebAuth) {
|
|
821
|
+
methodNotAllowed(response, 'Web auth is not enabled for this service mode.');
|
|
822
|
+
return;
|
|
823
|
+
}
|
|
824
|
+
|
|
825
|
+
try {
|
|
826
|
+
const result = await completeGoogleWebAuth({ code, state });
|
|
827
|
+
const returnUrl = new URL(result.returnUrl);
|
|
828
|
+
returnUrl.hash = `token=${result.session.accessToken}&expires=${result.session.expiresAt}`;
|
|
829
|
+
response.writeHead(302, { location: returnUrl.toString() });
|
|
830
|
+
response.end();
|
|
831
|
+
return;
|
|
832
|
+
} catch (error) {
|
|
833
|
+
html(response, 400, deviceApprovalPage({
|
|
834
|
+
title: 'Login failed',
|
|
835
|
+
message: error.message,
|
|
836
|
+
isError: true
|
|
837
|
+
}));
|
|
838
|
+
return;
|
|
839
|
+
}
|
|
840
|
+
}
|
|
841
|
+
|
|
842
|
+
if (!googleAuth?.configured || !completeGoogleDeviceApproval) {
|
|
843
|
+
methodNotAllowed(response, 'Google auth is not enabled for this service mode.');
|
|
844
|
+
return;
|
|
845
|
+
}
|
|
846
|
+
|
|
770
847
|
try {
|
|
771
848
|
const result = await completeGoogleDeviceApproval({ code, state });
|
|
772
849
|
html(response, 200, deviceApprovalSuccessPage({
|
|
@@ -818,11 +895,6 @@ export function createSyncServiceRequestHandler({
|
|
|
818
895
|
return;
|
|
819
896
|
}
|
|
820
897
|
|
|
821
|
-
if (!appleAuth?.configured || !completeAppleDeviceApproval) {
|
|
822
|
-
methodNotAllowed(response, 'Apple auth is not enabled for this service mode.');
|
|
823
|
-
return;
|
|
824
|
-
}
|
|
825
|
-
|
|
826
898
|
let code = url.searchParams.get('code') ?? '';
|
|
827
899
|
let state = url.searchParams.get('state') ?? '';
|
|
828
900
|
if (request.method === 'POST') {
|
|
@@ -836,6 +908,42 @@ export function createSyncServiceRequestHandler({
|
|
|
836
908
|
return;
|
|
837
909
|
}
|
|
838
910
|
|
|
911
|
+
let parsedState;
|
|
912
|
+
try {
|
|
913
|
+
parsedState = JSON.parse(state);
|
|
914
|
+
} catch {
|
|
915
|
+
badRequest(response, 'Invalid state parameter.');
|
|
916
|
+
return;
|
|
917
|
+
}
|
|
918
|
+
|
|
919
|
+
if (parsedState?.flow === 'web') {
|
|
920
|
+
if (!completeAppleWebAuth) {
|
|
921
|
+
methodNotAllowed(response, 'Web auth is not enabled for this service mode.');
|
|
922
|
+
return;
|
|
923
|
+
}
|
|
924
|
+
|
|
925
|
+
try {
|
|
926
|
+
const result = await completeAppleWebAuth({ code, state });
|
|
927
|
+
const returnUrl = new URL(result.returnUrl);
|
|
928
|
+
returnUrl.hash = `token=${result.session.accessToken}&expires=${result.session.expiresAt}`;
|
|
929
|
+
response.writeHead(302, { location: returnUrl.toString() });
|
|
930
|
+
response.end();
|
|
931
|
+
return;
|
|
932
|
+
} catch (error) {
|
|
933
|
+
html(response, 400, deviceApprovalPage({
|
|
934
|
+
title: 'Login failed',
|
|
935
|
+
message: error.message,
|
|
936
|
+
isError: true
|
|
937
|
+
}));
|
|
938
|
+
return;
|
|
939
|
+
}
|
|
940
|
+
}
|
|
941
|
+
|
|
942
|
+
if (!appleAuth?.configured || !completeAppleDeviceApproval) {
|
|
943
|
+
methodNotAllowed(response, 'Apple auth is not enabled for this service mode.');
|
|
944
|
+
return;
|
|
945
|
+
}
|
|
946
|
+
|
|
839
947
|
try {
|
|
840
948
|
const result = await completeAppleDeviceApproval({ code, state });
|
|
841
949
|
html(response, 200, deviceApprovalSuccessPage({
|
|
@@ -859,6 +967,63 @@ export function createSyncServiceRequestHandler({
|
|
|
859
967
|
}
|
|
860
968
|
}
|
|
861
969
|
|
|
970
|
+
if (route.command === 'web-auth-start') {
|
|
971
|
+
if (request.method !== 'GET') {
|
|
972
|
+
methodNotAllowed(response, 'Use GET for /auth/web/start.');
|
|
973
|
+
return;
|
|
974
|
+
}
|
|
975
|
+
|
|
976
|
+
const provider = url.searchParams.get('provider') ?? '';
|
|
977
|
+
const returnUrl = url.searchParams.get('returnUrl') ?? '';
|
|
978
|
+
if (!provider || !returnUrl) {
|
|
979
|
+
badRequest(response, 'provider and returnUrl are required.');
|
|
980
|
+
return;
|
|
981
|
+
}
|
|
982
|
+
|
|
983
|
+
if (provider !== 'apple' && provider !== 'google') {
|
|
984
|
+
badRequest(response, 'provider must be apple or google.');
|
|
985
|
+
return;
|
|
986
|
+
}
|
|
987
|
+
|
|
988
|
+
// Validate returnUrl against allowed CORS origins to prevent open redirect token theft.
|
|
989
|
+
// Fail closed: if no origins are configured, reject web auth entirely.
|
|
990
|
+
try {
|
|
991
|
+
const parsedReturnUrl = new URL(returnUrl);
|
|
992
|
+
const returnOrigin = parsedReturnUrl.origin;
|
|
993
|
+
if (corsOrigins.length === 0 || !corsOrigins.includes(returnOrigin)) {
|
|
994
|
+
badRequest(response, 'returnUrl origin is not in the allowed origins list.');
|
|
995
|
+
return;
|
|
996
|
+
}
|
|
997
|
+
} catch {
|
|
998
|
+
badRequest(response, 'returnUrl must be a valid URL.');
|
|
999
|
+
return;
|
|
1000
|
+
}
|
|
1001
|
+
|
|
1002
|
+
const nonce = randomUUID();
|
|
1003
|
+
|
|
1004
|
+
if (provider === 'apple') {
|
|
1005
|
+
if (!buildAppleWebAuthUrl || !appleAuth?.configured) {
|
|
1006
|
+
methodNotAllowed(response, 'Apple web auth is not enabled for this service mode.');
|
|
1007
|
+
return;
|
|
1008
|
+
}
|
|
1009
|
+
const authUrl = buildAppleWebAuthUrl(appleAuth, { nonce, returnUrl });
|
|
1010
|
+
response.writeHead(302, { location: authUrl });
|
|
1011
|
+
response.end();
|
|
1012
|
+
return;
|
|
1013
|
+
}
|
|
1014
|
+
|
|
1015
|
+
if (provider === 'google') {
|
|
1016
|
+
if (!buildGoogleWebAuthUrl || !googleAuth?.configured) {
|
|
1017
|
+
methodNotAllowed(response, 'Google web auth is not enabled for this service mode.');
|
|
1018
|
+
return;
|
|
1019
|
+
}
|
|
1020
|
+
const authUrl = buildGoogleWebAuthUrl(googleAuth, { nonce, returnUrl });
|
|
1021
|
+
response.writeHead(302, { location: authUrl });
|
|
1022
|
+
response.end();
|
|
1023
|
+
return;
|
|
1024
|
+
}
|
|
1025
|
+
}
|
|
1026
|
+
|
|
862
1027
|
if (route.command === 'device-approve') {
|
|
863
1028
|
if (!approveDeviceChallenge) {
|
|
864
1029
|
methodNotAllowed(response, 'Device approval is not enabled for this service mode.');
|