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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "incremnt",
3
- "version": "0.1.5",
3
+ "version": "0.1.7",
4
4
  "description": "Command-line tool for querying your incremnt strength training data",
5
5
  "license": "MIT",
6
6
  "type": "module",
package/src/contract.js CHANGED
@@ -1,9 +1,10 @@
1
1
  export const contractVersion = 1;
2
2
 
3
3
  export const capabilities = {
4
- readOnly: true,
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
  }
@@ -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("'", '&#39;');
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
  }