incremnt 0.1.5 → 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 +117 -1
- package/src/sync-service.js +181 -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';
|
|
@@ -29,7 +30,9 @@ const remoteCommandHandlers = {
|
|
|
29
30
|
'program-summary': executeRemoteRead,
|
|
30
31
|
'program-detail': executeRemoteRead,
|
|
31
32
|
'planned-vs-actual': executeRemoteRead,
|
|
32
|
-
'why-did-this-change': executeRemoteRead
|
|
33
|
+
'why-did-this-change': executeRemoteRead,
|
|
34
|
+
'goals-list': executeRemoteRead,
|
|
35
|
+
'goals-show': executeRemoteRead
|
|
33
36
|
};
|
|
34
37
|
|
|
35
38
|
async function executeRemoteRead(options, sessionState, normalizedCommand) {
|
|
@@ -132,6 +135,105 @@ function resourceNotFoundMessage(normalizedCommand, options) {
|
|
|
132
135
|
return 'Requested resource was not found.';
|
|
133
136
|
}
|
|
134
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
|
+
|
|
135
237
|
export function createRemoteTransport(sessionState, transportOptions = {}) {
|
|
136
238
|
return {
|
|
137
239
|
kind: 'remote',
|
|
@@ -159,6 +261,20 @@ export function createRemoteTransport(sessionState, transportOptions = {}) {
|
|
|
159
261
|
}
|
|
160
262
|
|
|
161
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);
|
|
162
278
|
}
|
|
163
279
|
};
|
|
164
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,6 +210,15 @@ 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
|
}
|
|
@@ -234,6 +245,15 @@ function routeRequest(url) {
|
|
|
234
245
|
return { command: 'records', options: {} };
|
|
235
246
|
}
|
|
236
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
|
+
|
|
237
257
|
const compareMatch = pathname.match(/^\/cli\/sessions\/([^/]+)\/compare$/);
|
|
238
258
|
if (compareMatch) {
|
|
239
259
|
return {
|
|
@@ -523,6 +543,55 @@ function escapeHtml(value) {
|
|
|
523
543
|
.replaceAll("'", ''');
|
|
524
544
|
}
|
|
525
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
|
+
|
|
526
595
|
export function syncServiceContractPayload({
|
|
527
596
|
auth = {
|
|
528
597
|
tokenBootstrap: true,
|
|
@@ -591,7 +660,10 @@ export function createSyncServiceRequestHandler({
|
|
|
591
660
|
refreshSession,
|
|
592
661
|
allowManualDeviceApproval = false,
|
|
593
662
|
rateLimitConfig = null,
|
|
594
|
-
corsOrigins = []
|
|
663
|
+
corsOrigins = [],
|
|
664
|
+
createProposalForAccount = null,
|
|
665
|
+
listProposalsForAccount = null,
|
|
666
|
+
updateProposalForAccount = null
|
|
595
667
|
}) {
|
|
596
668
|
const rateLimiter = createRateLimiter(rateLimitConfig ?? {});
|
|
597
669
|
|
|
@@ -607,7 +679,7 @@ export function createSyncServiceRequestHandler({
|
|
|
607
679
|
if (corsAllowed) {
|
|
608
680
|
response.setHeader('Access-Control-Allow-Origin', origin);
|
|
609
681
|
response.setHeader('Access-Control-Allow-Headers', 'Authorization, Content-Type');
|
|
610
|
-
response.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, OPTIONS');
|
|
682
|
+
response.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, PATCH, OPTIONS');
|
|
611
683
|
}
|
|
612
684
|
|
|
613
685
|
if (request.method === 'OPTIONS') {
|
|
@@ -1227,6 +1299,112 @@ export function createSyncServiceRequestHandler({
|
|
|
1227
1299
|
const readAuthenticator = authenticateReadToken ?? authenticateToken;
|
|
1228
1300
|
const writeAuthenticator = authenticateWriteToken ?? authenticateToken;
|
|
1229
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
|
+
|
|
1230
1408
|
if (route.command === 'contract') {
|
|
1231
1409
|
const account = readAuthenticator
|
|
1232
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
|
}
|