incremnt 0.1.4 → 0.1.7
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 +83 -1
- package/src/format.js +50 -2
- package/src/lib.js +31 -3
- package/src/queries.js +64 -0
- package/src/remote.js +120 -1
- package/src/sync-service.js +185 -3
- package/src/transport.js +5 -0
package/package.json
CHANGED
package/src/contract.js
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
export const contractVersion = 1;
|
|
2
2
|
|
|
3
3
|
export const capabilities = {
|
|
4
|
-
readOnly:
|
|
4
|
+
readOnly: false,
|
|
5
5
|
localSnapshots: true,
|
|
6
6
|
remoteReads: true,
|
|
7
|
+
remoteWrites: true,
|
|
7
8
|
remoteAuthShell: true,
|
|
8
9
|
remoteBootstrap: true
|
|
9
10
|
};
|
|
@@ -82,11 +83,92 @@ export const commandSchema = [
|
|
|
82
83
|
id: 'records',
|
|
83
84
|
description: 'Personal records',
|
|
84
85
|
options: []
|
|
86
|
+
},
|
|
87
|
+
{
|
|
88
|
+
command: 'goals list',
|
|
89
|
+
id: 'goals-list',
|
|
90
|
+
description: 'List strength plans and lift goals',
|
|
91
|
+
options: []
|
|
92
|
+
},
|
|
93
|
+
{
|
|
94
|
+
command: 'goals show',
|
|
95
|
+
id: 'goals-show',
|
|
96
|
+
description: 'Show strength plan goal details',
|
|
97
|
+
usage: 'goals show --id <plan-id>',
|
|
98
|
+
options: [
|
|
99
|
+
{ name: 'id', type: 'string', required: true, description: 'Plan ID' }
|
|
100
|
+
]
|
|
85
101
|
}
|
|
86
102
|
];
|
|
87
103
|
|
|
104
|
+
export const writeCommandSchema = [
|
|
105
|
+
{
|
|
106
|
+
command: 'programs propose',
|
|
107
|
+
id: 'programs-propose',
|
|
108
|
+
description: 'Submit a program proposal',
|
|
109
|
+
usage: 'programs propose --file <file>',
|
|
110
|
+
options: [
|
|
111
|
+
{ name: 'file', type: 'string', required: true, description: 'Path to proposal JSON file' }
|
|
112
|
+
]
|
|
113
|
+
},
|
|
114
|
+
{
|
|
115
|
+
command: 'programs proposals',
|
|
116
|
+
id: 'programs-proposals',
|
|
117
|
+
description: 'List program proposals',
|
|
118
|
+
options: [
|
|
119
|
+
{ name: 'status', type: 'string', required: false, description: 'Filter by status (pending, accepted, dismissed)' }
|
|
120
|
+
]
|
|
121
|
+
},
|
|
122
|
+
{
|
|
123
|
+
command: 'programs proposal dismiss',
|
|
124
|
+
id: 'proposal-dismiss',
|
|
125
|
+
description: 'Dismiss a program proposal',
|
|
126
|
+
usage: 'programs proposal dismiss --id <id>',
|
|
127
|
+
options: [
|
|
128
|
+
{ name: 'id', type: 'string', required: true, description: 'Proposal ID' }
|
|
129
|
+
]
|
|
130
|
+
}
|
|
131
|
+
];
|
|
132
|
+
|
|
133
|
+
export const writeCommands = new Set(writeCommandSchema.map((c) => c.id));
|
|
134
|
+
|
|
135
|
+
export const proposalSchema = {
|
|
136
|
+
description: 'JSON structure for programs propose --file. Weights are omitted — iOS calculates from history.',
|
|
137
|
+
required: ['name', 'equipmentTier', 'days'],
|
|
138
|
+
properties: {
|
|
139
|
+
name: { type: 'string', description: 'Program name' },
|
|
140
|
+
equipmentTier: { type: 'string', enum: ['fullGym', 'benchDumbbells', 'dumbbellsOnly', 'bodyweightOnly'] },
|
|
141
|
+
trainingWeekdays: { type: 'array', items: { type: 'integer', minimum: 0, maximum: 6 }, description: 'Days of week (0=Sun). Optional.' },
|
|
142
|
+
rationale: { type: 'string', description: 'Why this program was suggested. Optional.' },
|
|
143
|
+
days: {
|
|
144
|
+
type: 'array',
|
|
145
|
+
minItems: 1,
|
|
146
|
+
items: {
|
|
147
|
+
required: ['title', 'exercises'],
|
|
148
|
+
properties: {
|
|
149
|
+
title: { type: 'string', description: 'Day name, e.g. "Upper A"' },
|
|
150
|
+
exercises: {
|
|
151
|
+
type: 'array',
|
|
152
|
+
minItems: 1,
|
|
153
|
+
items: {
|
|
154
|
+
required: ['name', 'sets', 'reps'],
|
|
155
|
+
properties: {
|
|
156
|
+
name: { type: 'string', description: 'Exercise name (use canonical names from records or exercises history)' },
|
|
157
|
+
muscleGroup: { type: 'string', description: 'e.g. "Chest", "Back". Optional.' },
|
|
158
|
+
sets: { type: 'integer', minimum: 1 },
|
|
159
|
+
reps: { type: 'integer', minimum: 1 }
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
};
|
|
168
|
+
|
|
88
169
|
export const officialCommands = [
|
|
89
170
|
...commandSchema.map((c) => c.usage ?? c.command),
|
|
171
|
+
...writeCommandSchema.map((c) => c.usage ?? c.command),
|
|
90
172
|
'status',
|
|
91
173
|
'contract',
|
|
92
174
|
'login',
|
package/src/format.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import chalk from 'chalk';
|
|
2
|
-
import { commandSchema } from './contract.js';
|
|
2
|
+
import { commandSchema, writeCommandSchema } from './contract.js';
|
|
3
3
|
|
|
4
4
|
const shortMonths = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
|
|
5
5
|
const shortDays = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
|
|
@@ -366,6 +366,48 @@ function formatWhyDidThisChange(payload) {
|
|
|
366
366
|
return lines.join('\n');
|
|
367
367
|
}
|
|
368
368
|
|
|
369
|
+
function formatProposalCreated(payload) {
|
|
370
|
+
if (!payload) return 'No proposal data.';
|
|
371
|
+
const lines = [
|
|
372
|
+
header('PROPOSAL CREATED'),
|
|
373
|
+
'',
|
|
374
|
+
keyValue('ID', payload.id),
|
|
375
|
+
keyValue('Status', payload.status),
|
|
376
|
+
keyValue('Program', payload.proposal?.name ?? 'Untitled'),
|
|
377
|
+
keyValue('Days', String(payload.proposal?.days?.length ?? 0))
|
|
378
|
+
];
|
|
379
|
+
return lines.join('\n');
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
function formatProposalsList(payload) {
|
|
383
|
+
if (!Array.isArray(payload) || payload.length === 0) {
|
|
384
|
+
return 'No proposals found.';
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
const statusBadge = (status) => {
|
|
388
|
+
if (status === 'pending') return chalk.yellow(status);
|
|
389
|
+
if (status === 'accepted') return chalk.green(status);
|
|
390
|
+
if (status === 'dismissed') return chalk.dim(status);
|
|
391
|
+
return status;
|
|
392
|
+
};
|
|
393
|
+
|
|
394
|
+
const lines = [header('PROGRAM PROPOSALS'), ''];
|
|
395
|
+
|
|
396
|
+
for (const p of payload) {
|
|
397
|
+
const name = p.proposal?.name ?? 'Untitled';
|
|
398
|
+
const days = p.proposal?.days?.length ?? 0;
|
|
399
|
+
const date = formatShortDate(p.createdAt);
|
|
400
|
+
lines.push(` ${statusBadge(p.status)} ${chalk.bold(name)}${dimDot()}${chalk.dim(`${days} days`)}${dimDot()}${chalk.dim(date)}${dimDot()}${chalk.dim(p.id)}`);
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
return lines.join('\n');
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
function formatProposalDismissed(payload) {
|
|
407
|
+
if (!payload) return 'Proposal not found.';
|
|
408
|
+
return ` Proposal ${chalk.bold(payload.id)} dismissed.`;
|
|
409
|
+
}
|
|
410
|
+
|
|
369
411
|
// --- Main export ---
|
|
370
412
|
|
|
371
413
|
export function formatHelp() {
|
|
@@ -380,6 +422,9 @@ export function formatHelp() {
|
|
|
380
422
|
header('COMMANDS'),
|
|
381
423
|
...commandSchema.map((c) => cmd(c.usage ?? c.command, c.description)),
|
|
382
424
|
'',
|
|
425
|
+
header('WRITE COMMANDS'),
|
|
426
|
+
...writeCommandSchema.map((c) => cmd(c.usage ?? c.command, c.description)),
|
|
427
|
+
'',
|
|
383
428
|
header('AUTH'),
|
|
384
429
|
cmd('login', 'Sign in (opens browser)'),
|
|
385
430
|
cmd('login --base-url <url>', 'Sign in to a specific server'),
|
|
@@ -413,7 +458,10 @@ export function formatPretty(command, payload) {
|
|
|
413
458
|
'program-list': formatProgramList,
|
|
414
459
|
'program-detail': formatProgramDetail,
|
|
415
460
|
'planned-vs-actual': formatPlannedVsActual,
|
|
416
|
-
'why-did-this-change': formatWhyDidThisChange
|
|
461
|
+
'why-did-this-change': formatWhyDidThisChange,
|
|
462
|
+
'programs-propose': formatProposalCreated,
|
|
463
|
+
'programs-proposals': formatProposalsList,
|
|
464
|
+
'proposal-dismiss': formatProposalDismissed
|
|
417
465
|
}[command];
|
|
418
466
|
|
|
419
467
|
return formatter ? formatter(payload) : null;
|
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, commandSchema, contractVersion, officialCommands, readCommands } from './contract.js';
|
|
3
|
+
import { capabilities, commandSchema, contractVersion, officialCommands, proposalSchema, readCommands, writeCommands, writeCommandSchema } from './contract.js';
|
|
4
4
|
import {
|
|
5
5
|
bootstrapSessionFromRemoteBaseUrl,
|
|
6
6
|
bootstrapSessionFromRemoteBaseUrlWithDeviceFlow,
|
|
@@ -54,12 +54,16 @@ export async function runCli(argv, stdout, stderr) {
|
|
|
54
54
|
const { command, options } = parseArgs(argv);
|
|
55
55
|
const normalizedCommand = ({
|
|
56
56
|
...Object.fromEntries(commandSchema.map((c) => [c.command, c.id])),
|
|
57
|
+
...Object.fromEntries(writeCommandSchema.map((c) => [c.command, c.id])),
|
|
57
58
|
insights: 'session-insights',
|
|
58
59
|
history: 'exercise-history',
|
|
59
60
|
prs: 'records',
|
|
60
61
|
program: 'program-summary',
|
|
61
62
|
compare: 'planned-vs-actual',
|
|
62
|
-
explain: 'why-did-this-change'
|
|
63
|
+
explain: 'why-did-this-change',
|
|
64
|
+
propose: 'programs-propose',
|
|
65
|
+
proposals: 'programs-proposals',
|
|
66
|
+
dismiss: 'proposal-dismiss'
|
|
63
67
|
})[command] ?? command;
|
|
64
68
|
|
|
65
69
|
if (!command || options.help) {
|
|
@@ -109,7 +113,9 @@ export async function runCli(argv, stdout, stderr) {
|
|
|
109
113
|
binary: 'incremnt',
|
|
110
114
|
capabilities,
|
|
111
115
|
officialCommands,
|
|
112
|
-
schema: commandSchema
|
|
116
|
+
schema: commandSchema,
|
|
117
|
+
writeSchema: writeCommandSchema,
|
|
118
|
+
proposalSchema
|
|
113
119
|
}, null, 2)}\n`);
|
|
114
120
|
return 0;
|
|
115
121
|
}
|
|
@@ -263,6 +269,28 @@ export async function runCli(argv, stdout, stderr) {
|
|
|
263
269
|
const wantJson = options.json || !(stdout.isTTY ?? false);
|
|
264
270
|
const explicitJson = Boolean(options.json);
|
|
265
271
|
|
|
272
|
+
if (writeCommands.has(normalizedCommand)) {
|
|
273
|
+
try {
|
|
274
|
+
const payload = await transport.executeWriteCommand(normalizedCommand, options);
|
|
275
|
+
if (wantJson) {
|
|
276
|
+
stdout.write(`${JSON.stringify(payload, null, 2)}\n`);
|
|
277
|
+
} else {
|
|
278
|
+
const pretty = formatPretty(normalizedCommand, payload);
|
|
279
|
+
stdout.write(`${pretty ?? JSON.stringify(payload, null, 2)}\n`);
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
return 0;
|
|
283
|
+
} catch (error) {
|
|
284
|
+
if (explicitJson) {
|
|
285
|
+
stdout.write(`${JSON.stringify({ error: error.message, code: error.code ?? null }, null, 2)}\n`);
|
|
286
|
+
} else {
|
|
287
|
+
stderr.write(`${error.message}\n`);
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
return 1;
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
|
|
266
294
|
if (!readCommands.has(normalizedCommand)) {
|
|
267
295
|
const message = `Unknown command: ${command}`;
|
|
268
296
|
if (explicitJson) {
|
package/src/queries.js
CHANGED
|
@@ -296,6 +296,57 @@ export function programDetail(snapshot, programId) {
|
|
|
296
296
|
};
|
|
297
297
|
}
|
|
298
298
|
|
|
299
|
+
export function goalsList(snapshot) {
|
|
300
|
+
return (snapshot.strengthPlans ?? []).map(plan => ({
|
|
301
|
+
planId: plan.id,
|
|
302
|
+
status: plan.status,
|
|
303
|
+
strengthLevel: plan.strengthLevel,
|
|
304
|
+
programId: plan.programId ?? null,
|
|
305
|
+
startDate: plan.startDate,
|
|
306
|
+
finishDate: plan.finishDate ?? null,
|
|
307
|
+
durationWeeks: plan.durationWeeks,
|
|
308
|
+
goalCount: (plan.liftGoals ?? []).length,
|
|
309
|
+
goalsWithData: (plan.liftGoals ?? []).filter(g => g.hasLoggedData).length
|
|
310
|
+
}));
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
export function goalDetail(snapshot, planId) {
|
|
314
|
+
const plans = snapshot.strengthPlans ?? [];
|
|
315
|
+
const plan = planId
|
|
316
|
+
? plans.find(p => p.id === planId)
|
|
317
|
+
: plans.find(p => p.status === 'active') ?? plans[0];
|
|
318
|
+
if (!plan) return null;
|
|
319
|
+
|
|
320
|
+
return {
|
|
321
|
+
planId: plan.id,
|
|
322
|
+
status: plan.status,
|
|
323
|
+
strengthLevel: plan.strengthLevel,
|
|
324
|
+
programId: plan.programId ?? null,
|
|
325
|
+
startDate: plan.startDate,
|
|
326
|
+
finishDate: plan.finishDate ?? null,
|
|
327
|
+
durationWeeks: plan.durationWeeks,
|
|
328
|
+
liftGoals: (plan.liftGoals ?? []).map(g => {
|
|
329
|
+
const range = g.targetE1RM - g.startingE1RM;
|
|
330
|
+
const gained = g.currentBestE1RM - g.startingE1RM;
|
|
331
|
+
const progressPct = range > 0 ? Math.round((gained / range) * 100) : null;
|
|
332
|
+
return {
|
|
333
|
+
exerciseId: g.exerciseId,
|
|
334
|
+
exerciseName: g.exerciseDisplayName,
|
|
335
|
+
startingE1RM: g.startingE1RM,
|
|
336
|
+
targetE1RM: g.targetE1RM,
|
|
337
|
+
currentBestE1RM: g.currentBestE1RM,
|
|
338
|
+
currentBestWeight: g.currentBestWeight,
|
|
339
|
+
currentBestReps: g.currentBestReps,
|
|
340
|
+
progressPercent: progressPct,
|
|
341
|
+
targetLevel: g.targetLevel,
|
|
342
|
+
hasLoggedData: g.hasLoggedData,
|
|
343
|
+
lastUpdatedDate: g.lastUpdatedDate ?? null,
|
|
344
|
+
startingIsEstimated: g.startingE1RMIsEstimated
|
|
345
|
+
};
|
|
346
|
+
})
|
|
347
|
+
};
|
|
348
|
+
}
|
|
349
|
+
|
|
299
350
|
function requiredOption(options, primaryKey, legacyKey = null) {
|
|
300
351
|
return options[primaryKey] ?? (legacyKey ? options[legacyKey] : undefined);
|
|
301
352
|
}
|
|
@@ -385,5 +436,18 @@ export function executeReadCommand(snapshot, normalizedCommand, options = {}) {
|
|
|
385
436
|
return { ok: true, payload };
|
|
386
437
|
}
|
|
387
438
|
|
|
439
|
+
if (normalizedCommand === 'goals-list') {
|
|
440
|
+
return { ok: true, payload: goalsList(snapshot) };
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
if (normalizedCommand === 'goals-show') {
|
|
444
|
+
const planId = requiredOption(options, 'id');
|
|
445
|
+
const payload = goalDetail(snapshot, planId ?? null);
|
|
446
|
+
if (!payload) {
|
|
447
|
+
return { ok: false, error: planId ? `Plan not found: ${planId}` : 'No strength plans found' };
|
|
448
|
+
}
|
|
449
|
+
return { ok: true, payload };
|
|
450
|
+
}
|
|
451
|
+
|
|
388
452
|
return { ok: false, error: `Unknown read command: ${normalizedCommand}` };
|
|
389
453
|
}
|
package/src/remote.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import fs from 'node:fs/promises';
|
|
1
2
|
import { readSnapshot } from './local.js';
|
|
2
3
|
import { executeReadCommand } from './queries.js';
|
|
3
4
|
import { resolveServiceUrl } from './service-url.js';
|
|
@@ -27,8 +28,11 @@ const remoteCommandHandlers = {
|
|
|
27
28
|
records: executeRemoteRead,
|
|
28
29
|
'program-list': executeRemoteRead,
|
|
29
30
|
'program-summary': executeRemoteRead,
|
|
31
|
+
'program-detail': executeRemoteRead,
|
|
30
32
|
'planned-vs-actual': executeRemoteRead,
|
|
31
|
-
'why-did-this-change': executeRemoteRead
|
|
33
|
+
'why-did-this-change': executeRemoteRead,
|
|
34
|
+
'goals-list': executeRemoteRead,
|
|
35
|
+
'goals-show': executeRemoteRead
|
|
32
36
|
};
|
|
33
37
|
|
|
34
38
|
async function executeRemoteRead(options, sessionState, normalizedCommand) {
|
|
@@ -105,6 +109,8 @@ function endpointForCommand(baseUrl, normalizedCommand, options) {
|
|
|
105
109
|
return resolveServiceUrl(baseUrl, '/cli/programs');
|
|
106
110
|
case 'program-summary':
|
|
107
111
|
return resolveServiceUrl(baseUrl, '/cli/programs/current');
|
|
112
|
+
case 'program-detail':
|
|
113
|
+
return resolveServiceUrl(baseUrl, options.id ? `/cli/programs/${options.id}` : '/cli/programs/active');
|
|
108
114
|
case 'exercise-history': {
|
|
109
115
|
const historyUrl = resolveServiceUrl(baseUrl, '/cli/exercises/history');
|
|
110
116
|
historyUrl.searchParams.set('name', options.name ?? options.exercise);
|
|
@@ -129,6 +135,105 @@ function resourceNotFoundMessage(normalizedCommand, options) {
|
|
|
129
135
|
return 'Requested resource was not found.';
|
|
130
136
|
}
|
|
131
137
|
|
|
138
|
+
const remoteWriteCommandHandlers = {
|
|
139
|
+
'programs-propose': async (options, sessionState) => {
|
|
140
|
+
const baseUrl = sessionState.session?.transport?.baseUrl;
|
|
141
|
+
if (!baseUrl) throw notImplementedError();
|
|
142
|
+
|
|
143
|
+
const filePath = options.file;
|
|
144
|
+
if (!filePath) {
|
|
145
|
+
const error = new Error('--file is required for programs propose.');
|
|
146
|
+
error.code = 'MISSING_OPTION';
|
|
147
|
+
throw error;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const raw = await fs.readFile(filePath, 'utf8');
|
|
151
|
+
const proposal = JSON.parse(raw);
|
|
152
|
+
|
|
153
|
+
const endpoint = resolveServiceUrl(baseUrl, '/cli/programs/proposals');
|
|
154
|
+
const response = await fetch(endpoint, {
|
|
155
|
+
method: 'POST',
|
|
156
|
+
headers: {
|
|
157
|
+
'Content-Type': 'application/json',
|
|
158
|
+
Authorization: `Bearer ${sessionState.session?.auth?.accessToken ?? ''}`
|
|
159
|
+
},
|
|
160
|
+
body: JSON.stringify(proposal)
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
if (response.status === 401 || response.status === 403) throw authenticationFailedError();
|
|
164
|
+
if (!response.ok) {
|
|
165
|
+
const payload = await response.json().catch(() => null);
|
|
166
|
+
const error = new Error(payload?.error ?? `Unexpected error (HTTP ${response.status}).`);
|
|
167
|
+
error.code = 'REMOTE_HTTP_ERROR';
|
|
168
|
+
throw error;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
return response.json();
|
|
172
|
+
},
|
|
173
|
+
|
|
174
|
+
'programs-proposals': async (options, sessionState) => {
|
|
175
|
+
const baseUrl = sessionState.session?.transport?.baseUrl;
|
|
176
|
+
if (!baseUrl) throw notImplementedError();
|
|
177
|
+
|
|
178
|
+
const endpoint = resolveServiceUrl(baseUrl, '/cli/programs/proposals');
|
|
179
|
+
if (options.status) {
|
|
180
|
+
endpoint.searchParams.set('status', options.status);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
const response = await fetch(endpoint, {
|
|
184
|
+
headers: {
|
|
185
|
+
Authorization: `Bearer ${sessionState.session?.auth?.accessToken ?? ''}`
|
|
186
|
+
}
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
if (response.status === 401 || response.status === 403) throw authenticationFailedError();
|
|
190
|
+
if (!response.ok) {
|
|
191
|
+
const payload = await response.json().catch(() => null);
|
|
192
|
+
const error = new Error(payload?.error ?? `Unexpected error (HTTP ${response.status}).`);
|
|
193
|
+
error.code = 'REMOTE_HTTP_ERROR';
|
|
194
|
+
throw error;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
return response.json();
|
|
198
|
+
},
|
|
199
|
+
|
|
200
|
+
'proposal-dismiss': async (options, sessionState) => {
|
|
201
|
+
const baseUrl = sessionState.session?.transport?.baseUrl;
|
|
202
|
+
if (!baseUrl) throw notImplementedError();
|
|
203
|
+
|
|
204
|
+
if (!options.id) {
|
|
205
|
+
const error = new Error('--id is required for proposal dismiss.');
|
|
206
|
+
error.code = 'MISSING_OPTION';
|
|
207
|
+
throw error;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
const endpoint = resolveServiceUrl(baseUrl, `/cli/programs/proposals/${options.id}`);
|
|
211
|
+
const response = await fetch(endpoint, {
|
|
212
|
+
method: 'PATCH',
|
|
213
|
+
headers: {
|
|
214
|
+
'Content-Type': 'application/json',
|
|
215
|
+
Authorization: `Bearer ${sessionState.session?.auth?.accessToken ?? ''}`
|
|
216
|
+
},
|
|
217
|
+
body: JSON.stringify({ status: 'dismissed' })
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
if (response.status === 401 || response.status === 403) throw authenticationFailedError();
|
|
221
|
+
if (response.status === 404) {
|
|
222
|
+
const error = new Error(`Proposal not found: ${options.id}`);
|
|
223
|
+
error.code = 'REMOTE_NOT_FOUND';
|
|
224
|
+
throw error;
|
|
225
|
+
}
|
|
226
|
+
if (!response.ok) {
|
|
227
|
+
const payload = await response.json().catch(() => null);
|
|
228
|
+
const error = new Error(payload?.error ?? `Unexpected error (HTTP ${response.status}).`);
|
|
229
|
+
error.code = 'REMOTE_HTTP_ERROR';
|
|
230
|
+
throw error;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
return response.json();
|
|
234
|
+
}
|
|
235
|
+
};
|
|
236
|
+
|
|
132
237
|
export function createRemoteTransport(sessionState, transportOptions = {}) {
|
|
133
238
|
return {
|
|
134
239
|
kind: 'remote',
|
|
@@ -156,6 +261,20 @@ export function createRemoteTransport(sessionState, transportOptions = {}) {
|
|
|
156
261
|
}
|
|
157
262
|
|
|
158
263
|
return handler(options, sessionState, normalizedCommand);
|
|
264
|
+
},
|
|
265
|
+
async executeWriteCommand(normalizedCommand, options = {}) {
|
|
266
|
+
if (transportOptions.expired) {
|
|
267
|
+
throw expiredSessionError();
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
const handler = remoteWriteCommandHandlers[normalizedCommand];
|
|
271
|
+
if (!handler) {
|
|
272
|
+
const error = new Error(`Unknown remote write command: ${normalizedCommand}`);
|
|
273
|
+
error.code = 'UNKNOWN_REMOTE_COMMAND';
|
|
274
|
+
throw error;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
return handler(options, sessionState);
|
|
159
278
|
}
|
|
160
279
|
};
|
|
161
280
|
}
|
package/src/sync-service.js
CHANGED
|
@@ -16,7 +16,9 @@ const DEFAULT_RATE_LIMIT_RULES = {
|
|
|
16
16
|
'web-auth-start': 20,
|
|
17
17
|
'web-auth-callback': 20,
|
|
18
18
|
'session-login': 60,
|
|
19
|
-
'session-refresh': 30
|
|
19
|
+
'session-refresh': 30,
|
|
20
|
+
'proposals': 30,
|
|
21
|
+
'proposal-update': 30
|
|
20
22
|
};
|
|
21
23
|
|
|
22
24
|
function json(response, statusCode, payload) {
|
|
@@ -208,10 +210,23 @@ function routeRequest(url) {
|
|
|
208
210
|
return { command: 'program-list', options: {} };
|
|
209
211
|
}
|
|
210
212
|
|
|
213
|
+
if (pathname === '/cli/programs/proposals') {
|
|
214
|
+
return { command: 'proposals', options: { status: url.searchParams.get('status') ?? undefined } };
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
const proposalUpdateMatch = pathname.match(/^\/cli\/programs\/proposals\/([^/]+)$/);
|
|
218
|
+
if (proposalUpdateMatch) {
|
|
219
|
+
return { command: 'proposal-update', options: { id: proposalUpdateMatch[1] } };
|
|
220
|
+
}
|
|
221
|
+
|
|
211
222
|
if (pathname === '/cli/programs/current') {
|
|
212
223
|
return { command: 'program-summary', options: {} };
|
|
213
224
|
}
|
|
214
225
|
|
|
226
|
+
if (pathname === '/cli/programs/active') {
|
|
227
|
+
return { command: 'program-detail', options: {} };
|
|
228
|
+
}
|
|
229
|
+
|
|
215
230
|
const programShowMatch = pathname.match(/^\/cli\/programs\/([^/]+)$/);
|
|
216
231
|
if (programShowMatch) {
|
|
217
232
|
return { command: 'program-detail', options: { id: programShowMatch[1] } };
|
|
@@ -230,6 +245,15 @@ function routeRequest(url) {
|
|
|
230
245
|
return { command: 'records', options: {} };
|
|
231
246
|
}
|
|
232
247
|
|
|
248
|
+
if (pathname === '/cli/goals') {
|
|
249
|
+
return { command: 'goals-list', options: {} };
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
const goalsShowMatch = pathname.match(/^\/cli\/goals\/([^/]+)$/);
|
|
253
|
+
if (goalsShowMatch) {
|
|
254
|
+
return { command: 'goals-show', options: { id: decodeURIComponent(goalsShowMatch[1]) } };
|
|
255
|
+
}
|
|
256
|
+
|
|
233
257
|
const compareMatch = pathname.match(/^\/cli\/sessions\/([^/]+)\/compare$/);
|
|
234
258
|
if (compareMatch) {
|
|
235
259
|
return {
|
|
@@ -519,6 +543,55 @@ function escapeHtml(value) {
|
|
|
519
543
|
.replaceAll("'", ''');
|
|
520
544
|
}
|
|
521
545
|
|
|
546
|
+
const VALID_EQUIPMENT_TIERS = ['fullGym', 'benchDumbbells', 'dumbbellsOnly', 'bodyweightOnly'];
|
|
547
|
+
|
|
548
|
+
function validateProposal(proposal) {
|
|
549
|
+
if (!proposal || typeof proposal !== 'object') {
|
|
550
|
+
return 'Proposal must be a JSON object.';
|
|
551
|
+
}
|
|
552
|
+
if (typeof proposal.name !== 'string' || !proposal.name.trim()) {
|
|
553
|
+
return 'Proposal name is required.';
|
|
554
|
+
}
|
|
555
|
+
if (!VALID_EQUIPMENT_TIERS.includes(proposal.equipmentTier)) {
|
|
556
|
+
return `equipmentTier must be one of: ${VALID_EQUIPMENT_TIERS.join(', ')}`;
|
|
557
|
+
}
|
|
558
|
+
if (proposal.trainingWeekdays !== undefined) {
|
|
559
|
+
if (!Array.isArray(proposal.trainingWeekdays) || proposal.trainingWeekdays.length === 0) {
|
|
560
|
+
return 'trainingWeekdays must be a non-empty array of integers 0-6.';
|
|
561
|
+
}
|
|
562
|
+
for (const d of proposal.trainingWeekdays) {
|
|
563
|
+
if (!Number.isInteger(d) || d < 0 || d > 6) {
|
|
564
|
+
return 'trainingWeekdays values must be integers 0-6.';
|
|
565
|
+
}
|
|
566
|
+
}
|
|
567
|
+
}
|
|
568
|
+
if (!Array.isArray(proposal.days) || proposal.days.length === 0) {
|
|
569
|
+
return 'Proposal must have at least one day.';
|
|
570
|
+
}
|
|
571
|
+
for (let i = 0; i < proposal.days.length; i++) {
|
|
572
|
+
const day = proposal.days[i];
|
|
573
|
+
if (typeof day.title !== 'string' || !day.title.trim()) {
|
|
574
|
+
return `Day ${i + 1}: title is required.`;
|
|
575
|
+
}
|
|
576
|
+
if (!Array.isArray(day.exercises) || day.exercises.length === 0) {
|
|
577
|
+
return `Day ${i + 1}: must have at least one exercise.`;
|
|
578
|
+
}
|
|
579
|
+
for (let j = 0; j < day.exercises.length; j++) {
|
|
580
|
+
const ex = day.exercises[j];
|
|
581
|
+
if (typeof ex.name !== 'string' || !ex.name.trim()) {
|
|
582
|
+
return `Day ${i + 1}, exercise ${j + 1}: name is required.`;
|
|
583
|
+
}
|
|
584
|
+
if (typeof ex.sets !== 'number' || ex.sets < 1) {
|
|
585
|
+
return `Day ${i + 1}, exercise ${j + 1}: sets must be >= 1.`;
|
|
586
|
+
}
|
|
587
|
+
if (typeof ex.reps !== 'number' || ex.reps < 1) {
|
|
588
|
+
return `Day ${i + 1}, exercise ${j + 1}: reps must be >= 1.`;
|
|
589
|
+
}
|
|
590
|
+
}
|
|
591
|
+
}
|
|
592
|
+
return null;
|
|
593
|
+
}
|
|
594
|
+
|
|
522
595
|
export function syncServiceContractPayload({
|
|
523
596
|
auth = {
|
|
524
597
|
tokenBootstrap: true,
|
|
@@ -587,7 +660,10 @@ export function createSyncServiceRequestHandler({
|
|
|
587
660
|
refreshSession,
|
|
588
661
|
allowManualDeviceApproval = false,
|
|
589
662
|
rateLimitConfig = null,
|
|
590
|
-
corsOrigins = []
|
|
663
|
+
corsOrigins = [],
|
|
664
|
+
createProposalForAccount = null,
|
|
665
|
+
listProposalsForAccount = null,
|
|
666
|
+
updateProposalForAccount = null
|
|
591
667
|
}) {
|
|
592
668
|
const rateLimiter = createRateLimiter(rateLimitConfig ?? {});
|
|
593
669
|
|
|
@@ -603,7 +679,7 @@ export function createSyncServiceRequestHandler({
|
|
|
603
679
|
if (corsAllowed) {
|
|
604
680
|
response.setHeader('Access-Control-Allow-Origin', origin);
|
|
605
681
|
response.setHeader('Access-Control-Allow-Headers', 'Authorization, Content-Type');
|
|
606
|
-
response.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, OPTIONS');
|
|
682
|
+
response.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, PATCH, OPTIONS');
|
|
607
683
|
}
|
|
608
684
|
|
|
609
685
|
if (request.method === 'OPTIONS') {
|
|
@@ -1223,6 +1299,112 @@ export function createSyncServiceRequestHandler({
|
|
|
1223
1299
|
const readAuthenticator = authenticateReadToken ?? authenticateToken;
|
|
1224
1300
|
const writeAuthenticator = authenticateWriteToken ?? authenticateToken;
|
|
1225
1301
|
|
|
1302
|
+
if (route.command === 'proposals') {
|
|
1303
|
+
if (request.method === 'POST') {
|
|
1304
|
+
if (!createProposalForAccount) {
|
|
1305
|
+
methodNotAllowed(response, 'Proposal creation is not enabled for this service mode.');
|
|
1306
|
+
return;
|
|
1307
|
+
}
|
|
1308
|
+
|
|
1309
|
+
const proposalAccount = writeAuthenticator
|
|
1310
|
+
? await writeAuthenticator(requestToken)
|
|
1311
|
+
: requestToken === token
|
|
1312
|
+
? { id: 'remote-user', email: null }
|
|
1313
|
+
: null;
|
|
1314
|
+
if (!proposalAccount) {
|
|
1315
|
+
unauthorized(response, request);
|
|
1316
|
+
return;
|
|
1317
|
+
}
|
|
1318
|
+
|
|
1319
|
+
try {
|
|
1320
|
+
const body = await readJsonBody(request);
|
|
1321
|
+
const validationError = validateProposal(body);
|
|
1322
|
+
if (validationError) {
|
|
1323
|
+
badRequest(response, validationError);
|
|
1324
|
+
return;
|
|
1325
|
+
}
|
|
1326
|
+
|
|
1327
|
+
const result = await createProposalForAccount(proposalAccount, {
|
|
1328
|
+
source: body.source ?? {},
|
|
1329
|
+
proposal: body
|
|
1330
|
+
});
|
|
1331
|
+
json(response, 201, result);
|
|
1332
|
+
return;
|
|
1333
|
+
} catch (error) {
|
|
1334
|
+
badRequest(response, error.message);
|
|
1335
|
+
return;
|
|
1336
|
+
}
|
|
1337
|
+
}
|
|
1338
|
+
|
|
1339
|
+
if (request.method === 'GET') {
|
|
1340
|
+
if (!listProposalsForAccount) {
|
|
1341
|
+
methodNotAllowed(response, 'Proposal listing is not enabled for this service mode.');
|
|
1342
|
+
return;
|
|
1343
|
+
}
|
|
1344
|
+
|
|
1345
|
+
const listAccount = readAuthenticator
|
|
1346
|
+
? await readAuthenticator(requestToken)
|
|
1347
|
+
: requestToken === token
|
|
1348
|
+
? { id: 'remote-user', email: null }
|
|
1349
|
+
: null;
|
|
1350
|
+
if (!listAccount) {
|
|
1351
|
+
unauthorized(response, request);
|
|
1352
|
+
return;
|
|
1353
|
+
}
|
|
1354
|
+
|
|
1355
|
+
const proposals = await listProposalsForAccount(listAccount, {
|
|
1356
|
+
status: route.options.status
|
|
1357
|
+
});
|
|
1358
|
+
json(response, 200, proposals);
|
|
1359
|
+
return;
|
|
1360
|
+
}
|
|
1361
|
+
|
|
1362
|
+
methodNotAllowed(response, 'Use GET or POST for /cli/programs/proposals.');
|
|
1363
|
+
return;
|
|
1364
|
+
}
|
|
1365
|
+
|
|
1366
|
+
if (route.command === 'proposal-update') {
|
|
1367
|
+
if (request.method !== 'PATCH') {
|
|
1368
|
+
methodNotAllowed(response, 'Use PATCH for /cli/programs/proposals/:id.');
|
|
1369
|
+
return;
|
|
1370
|
+
}
|
|
1371
|
+
|
|
1372
|
+
if (!updateProposalForAccount) {
|
|
1373
|
+
methodNotAllowed(response, 'Proposal updates are not enabled for this service mode.');
|
|
1374
|
+
return;
|
|
1375
|
+
}
|
|
1376
|
+
|
|
1377
|
+
const proposalAccount = writeAuthenticator
|
|
1378
|
+
? await writeAuthenticator(requestToken)
|
|
1379
|
+
: requestToken === token
|
|
1380
|
+
? { id: 'remote-user', email: null }
|
|
1381
|
+
: null;
|
|
1382
|
+
if (!proposalAccount) {
|
|
1383
|
+
unauthorized(response, request);
|
|
1384
|
+
return;
|
|
1385
|
+
}
|
|
1386
|
+
|
|
1387
|
+
try {
|
|
1388
|
+
const body = await readJsonBody(request);
|
|
1389
|
+
if (body.status !== 'accepted' && body.status !== 'dismissed') {
|
|
1390
|
+
badRequest(response, 'status must be "accepted" or "dismissed".');
|
|
1391
|
+
return;
|
|
1392
|
+
}
|
|
1393
|
+
|
|
1394
|
+
const result = await updateProposalForAccount(proposalAccount, route.options.id, body.status);
|
|
1395
|
+
if (!result) {
|
|
1396
|
+
notFound(response, 'Proposal not found.');
|
|
1397
|
+
return;
|
|
1398
|
+
}
|
|
1399
|
+
|
|
1400
|
+
json(response, 200, result);
|
|
1401
|
+
return;
|
|
1402
|
+
} catch (error) {
|
|
1403
|
+
badRequest(response, error.message);
|
|
1404
|
+
return;
|
|
1405
|
+
}
|
|
1406
|
+
}
|
|
1407
|
+
|
|
1226
1408
|
if (route.command === 'contract') {
|
|
1227
1409
|
const account = readAuthenticator
|
|
1228
1410
|
? await readAuthenticator(requestToken)
|
package/src/transport.js
CHANGED
|
@@ -32,6 +32,11 @@ function createLocalTransport(snapshotSource) {
|
|
|
32
32
|
}
|
|
33
33
|
|
|
34
34
|
return result.payload;
|
|
35
|
+
},
|
|
36
|
+
async executeWriteCommand() {
|
|
37
|
+
const error = new Error('Write commands require a remote session. Run incremnt login first.');
|
|
38
|
+
error.code = 'WRITE_REQUIRES_REMOTE';
|
|
39
|
+
throw error;
|
|
35
40
|
}
|
|
36
41
|
};
|
|
37
42
|
}
|