diff --git a/README.md b/README.md index 9b35460..93550f1 100644 --- a/README.md +++ b/README.md @@ -50,9 +50,14 @@ git clone https://github.com/mynextadventure/mna-cli cd mna-cli bun install bun run codegen +bun run build bun link ``` +`bun run build` is required before `bun link` — it produces `dist/mna.js`, the bin +target. `bun link` only creates the `mna` symlink if that file already exists, so +skipping the build leaves you with `command not found: mna`. + `mna` should then be on your PATH. See [DEVELOPMENT.md](./DEVELOPMENT.md). ## Quickstart @@ -110,8 +115,8 @@ Every command supports `--json` for piping into `jq` or Claude. | Command | Description | |---|---| -| `mna destinations add --place [--notes=...] [--return-to-home]` | Add a destination. | -| `mna destinations edit [--place=...] [--notes=...] [--no-return-to-home]` | Update destination. | +| `mna destinations add --place [--notes=...] [--start-date=YYYY-MM-DD] [--end-date=YYYY-MM-DD] [--return-to-home]` | Add a destination. | +| `mna destinations edit [--place=...] [--notes=...] [--start-date=YYYY-MM-DD] [--end-date=YYYY-MM-DD] [--no-return-to-home]` | Update destination. | | `mna destinations reorder --order=key1,key2,key3` | Reorder destinations. | | `mna destinations delete [--yes]` | Delete a destination. | @@ -119,8 +124,8 @@ Every command supports `--json` for piping into `jq` or Claude. | Command | Description | |---|---| -| `mna options add --from-json=` | Add an option from JSON. | -| `mna options edit --from-json=` | Edit an option. | +| `mna options add --from-json= [--free-cancellation-until=]` | Add an option from JSON. `--free-cancellation-until` (accommodation only) merges into the body. | +| `mna options edit [--from-json=] [--free-cancellation-until=]` | Edit an option. For accommodation, `--free-cancellation-until` alone updates the date without a JSON file. | | `mna options delete [--yes]` | Delete an option. | | `mna options select ` | Select an option. | | `mna options deselect ` | Deselect the current option. | diff --git a/openapi.json b/openapi.json index 1e2ff4d..df0a2c8 100644 --- a/openapi.json +++ b/openapi.json @@ -239,7 +239,14 @@ }, "responses": { "201": { - "description": "Option created." + "description": "Option created.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateOptionResultV1Dto" + } + } + } } }, "tags": [ @@ -294,7 +301,14 @@ }, "responses": { "201": { - "description": "Option created." + "description": "Option created.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateOptionResultV1Dto" + } + } + } } }, "tags": [ @@ -349,7 +363,14 @@ }, "responses": { "201": { - "description": "Option created." + "description": "Option created.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateOptionResultV1Dto" + } + } + } } }, "tags": [ @@ -1030,6 +1051,27 @@ } } ], + "requestBody": { + "required": true, + "description": "Fields to update. The shape depends on the :kind path segment.", + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/UpdateAccommodationOptionV1Dto" + }, + { + "$ref": "#/components/schemas/UpdateTransportOptionV1Dto" + }, + { + "$ref": "#/components/schemas/UpdateGettingAroundOptionV1Dto" + } + ] + } + } + } + }, "responses": { "200": { "description": "Option updated." @@ -2952,6 +2994,10 @@ "type": "string", "format": "date-time" }, + "freeCancellationUntil": { + "type": "string", + "format": "date-time" + }, "checkInTime": { "type": "string" }, @@ -3100,6 +3146,16 @@ "isReturnToHome": { "type": "boolean" }, + "startDate": { + "type": "string", + "format": "date-time", + "description": "Arrival date at this destination." + }, + "endDate": { + "type": "string", + "format": "date-time", + "description": "Departure date from this destination." + }, "accommodationOptions": { "type": "array", "items": { @@ -3411,6 +3467,29 @@ "location": { "$ref": "#/components/schemas/CreateOptionLocationV1Dto" }, + "checkIn": { + "type": "string", + "format": "date-time", + "description": "Check-in date." + }, + "checkOut": { + "type": "string", + "format": "date-time", + "description": "Check-out date." + }, + "checkInTime": { + "type": "string", + "description": "Check-in time of day (e.g. \"15:00\")." + }, + "checkOutTime": { + "type": "string", + "description": "Check-out time of day (e.g. \"11:00\")." + }, + "freeCancellationUntil": { + "type": "string", + "format": "date-time", + "description": "Free cancellation deadline." + }, "roomDetails": { "$ref": "#/components/schemas/RoomDetailsV1Dto" }, @@ -3430,6 +3509,18 @@ "roomDetails" ] }, + "CreateOptionResultV1Dto": { + "type": "object", + "properties": { + "key": { + "type": "string", + "description": "Server-assigned key for the newly-created option." + } + }, + "required": [ + "key" + ] + }, "CreateTransportOptionV1Dto": { "type": "object", "properties": { @@ -3788,6 +3879,16 @@ "type": "boolean", "description": "Marks this destination as the return-to-home leg. Server keeps it pinned to the end." }, + "startDate": { + "type": "string", + "format": "date-time", + "description": "Arrival date at this destination." + }, + "endDate": { + "type": "string", + "format": "date-time", + "description": "Departure date from this destination." + }, "notes": { "type": "string", "description": "Free-form destination notes." @@ -3820,6 +3921,16 @@ "type": "boolean", "description": "Toggle return-to-home semantics." }, + "startDate": { + "type": "string", + "format": "date-time", + "description": "Arrival date at this destination. Pass null to clear." + }, + "endDate": { + "type": "string", + "format": "date-time", + "description": "Departure date from this destination. Pass null to clear." + }, "notes": { "type": "string", "description": "New free-form notes." @@ -3857,6 +3968,164 @@ "destinationKey" ] }, + "UpdateAccommodationOptionV1Dto": { + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "Name of the accommodation." + }, + "type": { + "type": "string", + "enum": [ + "hotel", + "hostel", + "apartment", + "house", + "camping", + "other" + ], + "description": "Type of accommodation." + }, + "totalCost": { + "type": "number", + "description": "Total cost for the stay." + }, + "currency": { + "type": "string", + "description": "Currency code (e.g. \"USD\")." + }, + "location": { + "$ref": "#/components/schemas/CreateOptionLocationV1Dto" + }, + "checkIn": { + "type": "string", + "format": "date-time", + "description": "Check-in date." + }, + "checkOut": { + "type": "string", + "format": "date-time", + "description": "Check-out date." + }, + "checkInTime": { + "type": "string", + "description": "Check-in time of day (e.g. \"15:00\")." + }, + "checkOutTime": { + "type": "string", + "description": "Check-out time of day (e.g. \"11:00\")." + }, + "freeCancellationUntil": { + "type": "string", + "format": "date-time", + "description": "Free cancellation deadline. Pass null to clear." + }, + "roomDetails": { + "$ref": "#/components/schemas/RoomDetailsV1Dto" + }, + "notes": { + "type": "string" + }, + "sourceUrl": { + "type": "string" + } + } + }, + "UpdateTransportOptionV1Dto": { + "type": "object", + "properties": { + "totalCost": { + "type": "number" + }, + "currency": { + "type": "string" + }, + "from": { + "$ref": "#/components/schemas/CreateOptionLocationV1Dto" + }, + "to": { + "$ref": "#/components/schemas/CreateOptionLocationV1Dto" + }, + "departAt": { + "type": "string", + "format": "date-time" + }, + "arriveAt": { + "type": "string", + "format": "date-time" + }, + "durationHours": { + "type": "number" + }, + "durationMinutes": { + "type": "number" + }, + "transportType": { + "type": "string", + "enum": [ + "car", + "plane", + "train", + "bus", + "ship", + "motorbike", + "bike", + "other" + ] + }, + "roundTrip": { + "type": "boolean" + }, + "outboundIncludesReturnCost": { + "type": "boolean" + }, + "roundTripReturn": { + "type": "boolean" + }, + "returnTransportOptionKey": { + "type": "string" + }, + "notes": { + "type": "string" + }, + "link": { + "type": "string" + } + } + }, + "UpdateGettingAroundOptionV1Dto": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "walk", + "publicTransport", + "rideShare", + "ownCar", + "rentalCar", + "ownMotor", + "rentalMotor", + "bike", + "rentalBike", + "other" + ] + }, + "totalCost": { + "type": "number" + }, + "currency": { + "type": "string" + }, + "notes": { + "type": "string" + }, + "link": { + "type": "string" + } + } + }, "DeleteOptionResultV1Dto": { "type": "object", "properties": { diff --git a/src/commands/destinations/add.ts b/src/commands/destinations/add.ts index 852cb6e..7288fc2 100644 --- a/src/commands/destinations/add.ts +++ b/src/commands/destinations/add.ts @@ -4,6 +4,7 @@ import { loadCredentials, resolveApiKey, resolveBaseUrl } from '../../auth/crede import { renderJson } from '../../render/json' import { colors } from '../../render/colors' import { reportAndExit, requireApiKey } from '../../util/errors' +import { normalizeToIsoDateTime } from '../../util/dates' export const destinationsAddCommand = defineCommand({ meta: { name: 'add', description: 'Add a new destination to a variant.' }, @@ -12,6 +13,8 @@ export const destinationsAddCommand = defineCommand({ variantId: { type: 'positional', description: 'Variant ID.' }, place: { type: 'string', required: true, description: 'Place name (e.g. "Lisbon, Portugal").' }, notes: { type: 'string', description: 'Free-form destination notes.' }, + 'start-date': { type: 'string', description: 'Arrival date (YYYY-MM-DD or ISO date-time).' }, + 'end-date': { type: 'string', description: 'Departure date (YYYY-MM-DD or ISO date-time).' }, 'return-to-home': { type: 'boolean', default: false, @@ -31,6 +34,12 @@ export const destinationsAddCommand = defineCommand({ isReturnToHome: args['return-to-home'] ?? false, } if (args.notes !== undefined) body.notes = args.notes + if (args['start-date'] !== undefined) { + body.startDate = normalizeToIsoDateTime(args['start-date'], '--start-date') + } + if (args['end-date'] !== undefined) { + body.endDate = normalizeToIsoDateTime(args['end-date'], '--end-date') + } const { data, error } = await client.POST( '/v1/trips/{id}/variants/{variantId}/destinations', diff --git a/src/commands/destinations/edit.test.ts b/src/commands/destinations/edit.test.ts new file mode 100644 index 0000000..b00fc0d --- /dev/null +++ b/src/commands/destinations/edit.test.ts @@ -0,0 +1,60 @@ +import { describe, expect, test, beforeEach, afterEach } from 'bun:test' +import { destinationsEditCommand } from './edit' + +type RunFn = (ctx: { args: Record }) => Promise +const run = (args: Record) => + (destinationsEditCommand.run as unknown as RunFn)({ args }) + +let originalFetch: typeof fetch +let originalKey: string | undefined +let captured: { method: string; body: unknown } | undefined + +beforeEach(() => { + originalFetch = global.fetch + originalKey = process.env.MNA_API_KEY + process.env.MNA_API_KEY = 'mna_test' + captured = undefined + global.fetch = (async (input: Request | URL | string, init?: RequestInit) => { + const req = input instanceof Request ? input : new Request(String(input), init) + const text = await req.text() + captured = { method: req.method, body: text ? JSON.parse(text) : undefined } + return new Response(null, { status: 200 }) + }) as unknown as typeof fetch +}) + +afterEach(() => { + global.fetch = originalFetch + process.env.MNA_API_KEY = originalKey + if (originalKey === undefined) Reflect.deleteProperty(process.env, 'MNA_API_KEY') +}) + +describe('destinations edit — date flags', () => { + test('maps --start-date / --end-date to ISO body fields and nothing else', async () => { + await run({ + tripId: 't', + variantId: 'v', + destinationKey: 'd', + 'start-date': '2026-07-07', + 'end-date': '2026-07-15', + json: false, + }) + + expect(captured?.method).toBe('PATCH') + expect(captured?.body).toEqual({ + startDate: '2026-07-07T00:00:00.000Z', + endDate: '2026-07-15T00:00:00.000Z', + }) + }) + + test('omits date keys when the flags are absent', async () => { + await run({ + tripId: 't', + variantId: 'v', + destinationKey: 'd', + place: 'Lisbon', + json: false, + }) + + expect(captured?.body).toEqual({ destination: 'Lisbon' }) + }) +}) diff --git a/src/commands/destinations/edit.ts b/src/commands/destinations/edit.ts index f126fe1..383469f 100644 --- a/src/commands/destinations/edit.ts +++ b/src/commands/destinations/edit.ts @@ -4,6 +4,7 @@ import { loadCredentials, resolveApiKey, resolveBaseUrl } from '../../auth/crede import { renderJson } from '../../render/json' import { colors } from '../../render/colors' import { reportAndExit, requireApiKey } from '../../util/errors' +import { normalizeToIsoDateTime } from '../../util/dates' export const destinationsEditCommand = defineCommand({ meta: { name: 'edit', description: 'Update fields on a destination.' }, @@ -13,6 +14,8 @@ export const destinationsEditCommand = defineCommand({ destinationKey: { type: 'positional', description: 'Destination key.' }, place: { type: 'string', description: 'New place name.' }, notes: { type: 'string', description: 'New free-form notes.' }, + 'start-date': { type: 'string', description: 'Arrival date (YYYY-MM-DD or ISO date-time).' }, + 'end-date': { type: 'string', description: 'Departure date (YYYY-MM-DD or ISO date-time).' }, 'return-to-home': { type: 'boolean', description: 'Toggle return-to-home semantics. Supports --no-return-to-home.', @@ -27,8 +30,16 @@ export const destinationsEditCommand = defineCommand({ if (args['return-to-home'] !== undefined) { updates.isReturnToHome = args['return-to-home'] } + if (args['start-date'] !== undefined) { + updates.startDate = normalizeToIsoDateTime(args['start-date'], '--start-date') + } + if (args['end-date'] !== undefined) { + updates.endDate = normalizeToIsoDateTime(args['end-date'], '--end-date') + } if (Object.keys(updates).length === 0) { - throw new Error('Specify at least one of --place, --notes, --return-to-home.') + throw new Error( + 'Specify at least one of --place, --notes, --start-date, --end-date, --return-to-home.', + ) } const creds = await loadCredentials() diff --git a/src/commands/options/add.ts b/src/commands/options/add.ts index 686988f..0c6b68b 100644 --- a/src/commands/options/add.ts +++ b/src/commands/options/add.ts @@ -5,6 +5,7 @@ import { renderJson } from '../../render/json' import { colors } from '../../render/colors' import { reportAndExit, requireApiKey } from '../../util/errors' import { readJsonObject } from '../../util/json-file' +import { applyFreeCancellation } from './free-cancellation' const KINDS = ['accommodation', 'transport', 'getting-around'] as const type Kind = (typeof KINDS)[number] @@ -34,12 +35,18 @@ export const optionsAddCommand = defineCommand({ required: true, description: 'Path to JSON file describing the option body.', }, + 'free-cancellation-until': { + type: 'string', + description: + 'Accommodation only: free-cancellation deadline (YYYY-MM-DD or ISO date-time). Merged into the body.', + }, json: { type: 'boolean', default: false, description: 'Output as JSON.' }, }, async run({ args }) { try { assertKind(args.kind) const body = await readJsonObject(args['from-json']) + applyFreeCancellation(body, args['free-cancellation-until'], args.kind) const creds = await loadCredentials() const apiKey = resolveApiKey(creds) diff --git a/src/commands/options/edit.test.ts b/src/commands/options/edit.test.ts new file mode 100644 index 0000000..4f2927f --- /dev/null +++ b/src/commands/options/edit.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, test, beforeEach, afterEach } from 'bun:test' +import { optionsEditCommand } from './edit' + +type RunFn = (ctx: { args: Record }) => Promise +const run = (args: Record) => + (optionsEditCommand.run as unknown as RunFn)({ args }) + +let originalFetch: typeof fetch +let originalKey: string | undefined +let captured: { method: string; url: string; body: unknown } | undefined + +beforeEach(() => { + originalFetch = global.fetch + originalKey = process.env.MNA_API_KEY + process.env.MNA_API_KEY = 'mna_test' + captured = undefined + global.fetch = (async (input: Request | URL | string, init?: RequestInit) => { + const req = input instanceof Request ? input : new Request(String(input), init) + const text = await req.text() + captured = { method: req.method, url: req.url, body: text ? JSON.parse(text) : undefined } + return new Response(null, { status: 200 }) + }) as unknown as typeof fetch +}) + +afterEach(() => { + global.fetch = originalFetch + process.env.MNA_API_KEY = originalKey + if (originalKey === undefined) Reflect.deleteProperty(process.env, 'MNA_API_KEY') +}) + +describe('options edit — free-cancellation flag', () => { + test('builds the body from --free-cancellation-until alone (no --from-json needed)', async () => { + await run({ + tripId: 't', + variantId: 'v', + destinationKey: 'd', + kind: 'accommodation', + optionKey: 'o1', + 'free-cancellation-until': '2026-07-02', + json: false, + }) + + expect(captured?.method).toBe('PATCH') + expect(captured?.url).toContain('/options/accommodation/o1') + expect(captured?.body).toEqual({ freeCancellationUntil: '2026-07-02T00:00:00.000Z' }) + }) +}) diff --git a/src/commands/options/edit.ts b/src/commands/options/edit.ts index ad727e9..4801a0a 100644 --- a/src/commands/options/edit.ts +++ b/src/commands/options/edit.ts @@ -5,6 +5,7 @@ import { renderJson } from '../../render/json' import { colors } from '../../render/colors' import { reportAndExit, requireApiKey } from '../../util/errors' import { readJsonObject } from '../../util/json-file' +import { applyFreeCancellation } from './free-cancellation' const KINDS = ['accommodation', 'transport', 'getting-around'] as const type Kind = (typeof KINDS)[number] @@ -32,15 +33,24 @@ export const optionsEditCommand = defineCommand({ optionKey: { type: 'positional', description: 'Option key.' }, 'from-json': { type: 'string', - required: true, description: 'Path to JSON file describing the partial update.', }, + 'free-cancellation-until': { + type: 'string', + description: + 'Accommodation only: free-cancellation deadline (YYYY-MM-DD or ISO date-time). Lets you skip --from-json.', + }, json: { type: 'boolean', default: false, description: 'Output as JSON.' }, }, async run({ args }) { try { assertKind(args.kind) - const body = await readJsonObject(args['from-json']) + if (args['from-json'] === undefined && args['free-cancellation-until'] === undefined) { + throw new Error('Specify --from-json and/or --free-cancellation-until.') + } + const body = + args['from-json'] !== undefined ? await readJsonObject(args['from-json']) : {} + applyFreeCancellation(body, args['free-cancellation-until'], args.kind) const creds = await loadCredentials() const apiKey = resolveApiKey(creds) diff --git a/src/commands/options/free-cancellation.test.ts b/src/commands/options/free-cancellation.test.ts new file mode 100644 index 0000000..5ceea2a --- /dev/null +++ b/src/commands/options/free-cancellation.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, test } from 'bun:test' +import { applyFreeCancellation } from './free-cancellation' + +describe('applyFreeCancellation', () => { + test('merges a normalized freeCancellationUntil for accommodation', () => { + const body: Record = { name: 'Hotel' } + applyFreeCancellation(body, '2026-07-02', 'accommodation') + expect(body).toEqual({ name: 'Hotel', freeCancellationUntil: '2026-07-02T00:00:00.000Z' }) + }) + + test('passes a full ISO date-time through', () => { + const body: Record = {} + applyFreeCancellation(body, '2026-07-02T12:00:00.000Z', 'accommodation') + expect(body.freeCancellationUntil).toBe('2026-07-02T12:00:00.000Z') + }) + + test('is a no-op when the flag is absent', () => { + const body: Record = { name: 'Train' } + applyFreeCancellation(body, undefined, 'transport') + expect(body).toEqual({ name: 'Train' }) + }) + + test('rejects non-accommodation kinds', () => { + expect(() => applyFreeCancellation({}, '2026-07-02', 'transport')).toThrow( + /only valid for the "accommodation" kind/, + ) + }) +}) diff --git a/src/commands/options/free-cancellation.ts b/src/commands/options/free-cancellation.ts new file mode 100644 index 0000000..47f98b8 --- /dev/null +++ b/src/commands/options/free-cancellation.ts @@ -0,0 +1,17 @@ +import { normalizeToIsoDateTime } from '../../util/dates' + +/** + * Merge the ergonomic `--free-cancellation-until` flag into an option body. + * Only accommodation options carry this field, so any other kind is rejected. + */ +export function applyFreeCancellation( + body: Record, + value: string | undefined, + kind: string, +): void { + if (value === undefined) return + if (kind !== 'accommodation') { + throw new Error('--free-cancellation-until is only valid for the "accommodation" kind.') + } + body.freeCancellationUntil = normalizeToIsoDateTime(value, '--free-cancellation-until') +} diff --git a/src/util/dates.test.ts b/src/util/dates.test.ts new file mode 100644 index 0000000..845d5e1 --- /dev/null +++ b/src/util/dates.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, test } from 'bun:test' +import { normalizeToIsoDateTime } from './dates' + +describe('normalizeToIsoDateTime', () => { + test('expands a YYYY-MM-DD date to UTC start-of-day ISO', () => { + expect(normalizeToIsoDateTime('2026-07-07', '--start-date')).toBe('2026-07-07T00:00:00.000Z') + }) + + test('passes through a full ISO date-time, normalizing to millis + Z', () => { + expect(normalizeToIsoDateTime('2026-07-02T12:00:00Z', '--free-cancellation-until')).toBe( + '2026-07-02T12:00:00.000Z', + ) + }) + + test('trims surrounding whitespace', () => { + expect(normalizeToIsoDateTime(' 2026-07-15 ', '--end-date')).toBe('2026-07-15T00:00:00.000Z') + }) + + test('rejects an impossible calendar date', () => { + expect(() => normalizeToIsoDateTime('2026-02-31', '--start-date')).toThrow(/Invalid date for --start-date/) + }) + + test('rejects garbage with a flag-named message', () => { + expect(() => normalizeToIsoDateTime('not-a-date', '--end-date')).toThrow(/Invalid date for --end-date/) + }) +}) diff --git a/src/util/dates.ts b/src/util/dates.ts new file mode 100644 index 0000000..3a7fd52 --- /dev/null +++ b/src/util/dates.ts @@ -0,0 +1,23 @@ +const DATE_ONLY = /^\d{4}-\d{2}-\d{2}$/ + +/** + * Normalize a user-supplied date into an ISO 8601 date-time string. Accepts a + * bare `YYYY-MM-DD` (expanded to UTC start-of-day) or a full ISO date-time. + * Throws a flag-named error on anything that isn't a real date. + */ +export function normalizeToIsoDateTime(value: string, flag: string): string { + const trimmed = value.trim() + const isDateOnly = DATE_ONLY.test(trimmed) + const date = new Date(isDateOnly ? `${trimmed}T00:00:00.000Z` : trimmed) + + // `new Date` rolls impossible dates over (Feb 31 -> Mar 3), so confirm a + // date-only input survives the round-trip unchanged. + const invalid = + Number.isNaN(date.getTime()) || (isDateOnly && date.toISOString().slice(0, 10) !== trimmed) + if (invalid) { + throw new Error( + `Invalid date for ${flag}: "${value}". Use YYYY-MM-DD or an ISO 8601 date-time.`, + ) + } + return date.toISOString() +}