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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "incremnt",
3
- "version": "0.1.4",
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';
@@ -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
  }
@@ -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("'", '&#39;');
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
  }