@nyc-transit-kit/cli 0.1.0

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.
@@ -0,0 +1,610 @@
1
+ import { type GtfsFeedKind, MtaGtfsRealtimeCaptureManifest } from "@nyc-transit-kit/contracts/mta"
2
+ import {
3
+ findMtaOpenDataDataset,
4
+ mtaOpenDataDatasets,
5
+ mtaOpenDataDomain
6
+ } from "@nyc-transit-kit/mta/datasets"
7
+ import {
8
+ findMtaGtfsRealtimeFeed,
9
+ findMtaGtfsStaticFeed,
10
+ type MtaGtfsRealtimeFeedDescriptor,
11
+ type MtaGtfsStaticFeedDescriptor,
12
+ mtaGtfsRealtimeFeeds,
13
+ mtaGtfsStaticFeeds
14
+ } from "@nyc-transit-kit/mta/feeds"
15
+ import { captureGtfsRealtime, probeGtfsRealtime } from "@nyc-transit-kit/mta/gtfs-realtime"
16
+ import { fetchGtfsStaticResponse, probeGtfsStatic } from "@nyc-transit-kit/mta/gtfs-static"
17
+ import { queryMtaOpenData } from "@nyc-transit-kit/mta/open-data"
18
+ import * as Effect from "effect/Effect"
19
+ import * as Option from "effect/Option"
20
+ import * as Command from "effect/unstable/cli/Command"
21
+ import * as Flag from "effect/unstable/cli/Flag"
22
+ import { atomicWrite } from "../files"
23
+ import { failCommand, missingOption, runEffect, soqlFromSelect, writeSuccess } from "./shared"
24
+ import { fileWriteError, writeResponseToFile, writeSoda3QueryDryRun } from "./soda3-shared"
25
+ import { CliCommandContext, type CommandContext } from "./types"
26
+
27
+ export const mtaCommandPaths: ReadonlyArray<ReadonlyArray<string>> = [
28
+ ["mta", "open-data", "dataset", "list"],
29
+ ["mta", "open-data", "dataset", "info"],
30
+ ["mta", "open-data", "dataset", "query"],
31
+ ["mta", "gtfs-static", "list"],
32
+ ["mta", "gtfs-static", "probe"],
33
+ ["mta", "gtfs-static", "fetch"],
34
+ ["mta", "gtfs-rt", "list"],
35
+ ["mta", "gtfs-rt", "probe"],
36
+ ["mta", "gtfs-rt", "decode"],
37
+ ["mta", "gtfs-rt", "capture"]
38
+ ]
39
+
40
+ type StaticFeedTarget =
41
+ | {
42
+ readonly _tag: "Resolved"
43
+ readonly url: string
44
+ readonly source: "descriptor" | "url"
45
+ readonly descriptor?: MtaGtfsStaticFeedDescriptor
46
+ }
47
+ | {
48
+ readonly _tag: "Missing"
49
+ }
50
+ | {
51
+ readonly _tag: "Unsupported"
52
+ readonly feed: string
53
+ }
54
+
55
+ type RealtimeFeedTarget =
56
+ | {
57
+ readonly _tag: "Resolved"
58
+ readonly feed: GtfsFeedKind
59
+ readonly url: string
60
+ readonly source: "descriptor" | "url"
61
+ readonly descriptor?: MtaGtfsRealtimeFeedDescriptor
62
+ }
63
+ | {
64
+ readonly _tag: "MissingUrl"
65
+ readonly feed: GtfsFeedKind
66
+ }
67
+ | {
68
+ readonly _tag: "Unsupported"
69
+ readonly feed: string
70
+ }
71
+
72
+ const gtfsFeedKinds = new Set(["vehicle-positions", "trip-updates", "alerts"])
73
+
74
+ const optionalString = (value: Option.Option<string>) => Option.getOrUndefined(value)
75
+
76
+ const isGtfsFeedKind = (value: string): value is GtfsFeedKind => gtfsFeedKinds.has(value)
77
+
78
+ const resolveStaticFeedTarget = (
79
+ feed: Option.Option<string>,
80
+ url: Option.Option<string>
81
+ ): StaticFeedTarget => {
82
+ const feedKey = optionalString(feed)
83
+ const urlOverride = optionalString(url)
84
+ const descriptor = feedKey === undefined ? undefined : findMtaGtfsStaticFeed(feedKey)
85
+
86
+ if (urlOverride !== undefined) {
87
+ return {
88
+ _tag: "Resolved",
89
+ url: urlOverride,
90
+ source: "url",
91
+ ...(descriptor === undefined ? {} : { descriptor })
92
+ }
93
+ }
94
+
95
+ if (feedKey === undefined) {
96
+ return { _tag: "Missing" }
97
+ }
98
+
99
+ if (descriptor === undefined) {
100
+ return {
101
+ _tag: "Unsupported",
102
+ feed: feedKey
103
+ }
104
+ }
105
+
106
+ return {
107
+ _tag: "Resolved",
108
+ url: descriptor.url,
109
+ source: "descriptor",
110
+ descriptor
111
+ }
112
+ }
113
+
114
+ const resolveRealtimeFeedTarget = (
115
+ feedKey: string,
116
+ url: Option.Option<string>
117
+ ): RealtimeFeedTarget => {
118
+ const urlOverride = optionalString(url)
119
+ const descriptor = findMtaGtfsRealtimeFeed(feedKey)
120
+
121
+ if (descriptor !== undefined) {
122
+ return {
123
+ _tag: "Resolved",
124
+ feed: descriptor.feed,
125
+ url: urlOverride ?? descriptor.url,
126
+ source: urlOverride === undefined ? "descriptor" : "url",
127
+ descriptor
128
+ }
129
+ }
130
+
131
+ if (isGtfsFeedKind(feedKey)) {
132
+ if (urlOverride === undefined) {
133
+ return {
134
+ _tag: "MissingUrl",
135
+ feed: feedKey
136
+ }
137
+ }
138
+
139
+ return {
140
+ _tag: "Resolved",
141
+ feed: feedKey,
142
+ url: urlOverride,
143
+ source: "url"
144
+ }
145
+ }
146
+
147
+ return {
148
+ _tag: "Unsupported",
149
+ feed: feedKey
150
+ }
151
+ }
152
+
153
+ const failStaticTarget = (
154
+ context: CommandContext,
155
+ tokens: ReadonlyArray<string>,
156
+ target: StaticFeedTarget
157
+ ) => {
158
+ switch (target._tag) {
159
+ case "Missing":
160
+ return missingOption(context, tokens, "feed")
161
+ case "Unsupported":
162
+ return failCommand(
163
+ context,
164
+ "mta",
165
+ "unsupported-feed",
166
+ `Unsupported MTA GTFS static feed: ${target.feed}`,
167
+ tokens
168
+ )
169
+ case "Resolved":
170
+ return Effect.void
171
+ }
172
+ }
173
+
174
+ const failRealtimeTarget = (
175
+ context: CommandContext,
176
+ tokens: ReadonlyArray<string>,
177
+ target: RealtimeFeedTarget
178
+ ) => {
179
+ switch (target._tag) {
180
+ case "MissingUrl":
181
+ return missingOption(context, tokens, "url")
182
+ case "Unsupported":
183
+ return failCommand(
184
+ context,
185
+ "mta",
186
+ "unsupported-feed",
187
+ `Unsupported MTA GTFS realtime feed: ${target.feed}`,
188
+ tokens
189
+ )
190
+ case "Resolved":
191
+ return Effect.void
192
+ }
193
+ }
194
+
195
+ export const handleMtaOpenDataList = () =>
196
+ Effect.gen(function* () {
197
+ const context = yield* CliCommandContext
198
+ return yield* writeSuccess(context, "mta", {
199
+ datasets: mtaOpenDataDatasets
200
+ })
201
+ })
202
+
203
+ export const handleMtaOpenDataInfo = (config: { readonly datasetId: string }) =>
204
+ Effect.gen(function* () {
205
+ const context = yield* CliCommandContext
206
+ const descriptor = findMtaOpenDataDataset(config.datasetId)
207
+
208
+ return yield* writeSuccess(context, "mta", {
209
+ known: descriptor !== undefined,
210
+ descriptor:
211
+ descriptor === undefined
212
+ ? {
213
+ id: config.datasetId,
214
+ domain: mtaOpenDataDomain,
215
+ backing: "socrata"
216
+ }
217
+ : descriptor
218
+ })
219
+ })
220
+
221
+ export const handleMtaOpenDataQuery = (config: {
222
+ readonly datasetId: string
223
+ readonly select: string
224
+ readonly dryRun: boolean
225
+ }) =>
226
+ Effect.gen(function* () {
227
+ const context = yield* CliCommandContext
228
+ const tokens = ["mta", "open-data", "dataset", "query"]
229
+ const request = {
230
+ datasetId: config.datasetId,
231
+ query: soqlFromSelect(config.select)
232
+ }
233
+
234
+ if (config.dryRun) {
235
+ return yield* writeSoda3QueryDryRun({
236
+ context,
237
+ apiFamily: "mta",
238
+ tokens,
239
+ domain: mtaOpenDataDomain,
240
+ ...request,
241
+ extra: {
242
+ known: findMtaOpenDataDataset(config.datasetId) !== undefined
243
+ }
244
+ })
245
+ }
246
+
247
+ return yield* runEffect(
248
+ context,
249
+ "mta",
250
+ tokens,
251
+ queryMtaOpenData(request).pipe(Effect.provide(context.runtime.soda3Layer))
252
+ )
253
+ })
254
+
255
+ export const handleMtaGtfsStaticList = () =>
256
+ Effect.gen(function* () {
257
+ const context = yield* CliCommandContext
258
+ return yield* writeSuccess(context, "mta", {
259
+ feeds: mtaGtfsStaticFeeds
260
+ })
261
+ })
262
+
263
+ export const handleMtaGtfsStaticProbe = (config: {
264
+ readonly feed: Option.Option<string>
265
+ readonly url: Option.Option<string>
266
+ readonly dryRun: boolean
267
+ }) =>
268
+ Effect.gen(function* () {
269
+ const context = yield* CliCommandContext
270
+ const tokens = ["mta", "gtfs-static", "probe"]
271
+ const target = resolveStaticFeedTarget(config.feed, config.url)
272
+
273
+ if (target._tag !== "Resolved") {
274
+ return yield* failStaticTarget(context, tokens, target)
275
+ }
276
+
277
+ if (config.dryRun) {
278
+ return yield* writeSuccess(context, "mta", {
279
+ dryRun: true,
280
+ method: "HEAD",
281
+ url: target.url,
282
+ source: target.source,
283
+ descriptor: target.descriptor
284
+ })
285
+ }
286
+
287
+ return yield* runEffect(
288
+ context,
289
+ "mta",
290
+ tokens,
291
+ probeGtfsStatic({ url: target.url }).pipe(Effect.provide(context.runtime.mtaLayer))
292
+ )
293
+ })
294
+
295
+ export const handleMtaGtfsStaticFetch = (config: {
296
+ readonly feed: Option.Option<string>
297
+ readonly url: Option.Option<string>
298
+ readonly output: string
299
+ readonly dryRun: boolean
300
+ }) =>
301
+ Effect.gen(function* () {
302
+ const context = yield* CliCommandContext
303
+ const tokens = ["mta", "gtfs-static", "fetch"]
304
+ const target = resolveStaticFeedTarget(config.feed, config.url)
305
+
306
+ if (target._tag !== "Resolved") {
307
+ return yield* failStaticTarget(context, tokens, target)
308
+ }
309
+
310
+ if (config.dryRun) {
311
+ return yield* writeSuccess(context, "mta", {
312
+ dryRun: true,
313
+ method: "GET",
314
+ url: target.url,
315
+ output: config.output,
316
+ source: target.source,
317
+ descriptor: target.descriptor
318
+ })
319
+ }
320
+
321
+ return yield* runEffect(
322
+ context,
323
+ "mta",
324
+ tokens,
325
+ Effect.gen(function* () {
326
+ const response = yield* fetchGtfsStaticResponse({ url: target.url }).pipe(
327
+ Effect.provide(context.runtime.mtaLayer)
328
+ )
329
+ return yield* writeResponseToFile(config.output, response)
330
+ })
331
+ )
332
+ })
333
+
334
+ export const handleMtaGtfsRealtimeList = () =>
335
+ Effect.gen(function* () {
336
+ const context = yield* CliCommandContext
337
+ return yield* writeSuccess(context, "mta", {
338
+ feeds: mtaGtfsRealtimeFeeds
339
+ })
340
+ })
341
+
342
+ const runMtaGtfsRealtimeRequest = (
343
+ tokens: ReadonlyArray<string>,
344
+ config: {
345
+ readonly feed: string
346
+ readonly url: Option.Option<string>
347
+ readonly dryRun: boolean
348
+ }
349
+ ) =>
350
+ Effect.gen(function* () {
351
+ const context = yield* CliCommandContext
352
+ const target = resolveRealtimeFeedTarget(config.feed, config.url)
353
+
354
+ if (target._tag !== "Resolved") {
355
+ return yield* failRealtimeTarget(context, tokens, target)
356
+ }
357
+
358
+ if (config.dryRun) {
359
+ return yield* writeSuccess(context, "mta", {
360
+ dryRun: true,
361
+ method: "GET",
362
+ feed: target.feed,
363
+ url: target.url,
364
+ source: target.source,
365
+ descriptor: target.descriptor
366
+ })
367
+ }
368
+
369
+ return yield* runEffect(
370
+ context,
371
+ "mta",
372
+ tokens,
373
+ probeGtfsRealtime({ feed: target.feed, url: target.url }).pipe(
374
+ Effect.provide(context.runtime.mtaRealtimeLayer)
375
+ )
376
+ )
377
+ })
378
+
379
+ export const handleMtaGtfsRealtimeProbe = (config: {
380
+ readonly feed: string
381
+ readonly url: Option.Option<string>
382
+ readonly dryRun: boolean
383
+ }) => runMtaGtfsRealtimeRequest(["mta", "gtfs-rt", "probe"], config)
384
+
385
+ export const handleMtaGtfsRealtimeDecode = (config: {
386
+ readonly feed: string
387
+ readonly url: Option.Option<string>
388
+ readonly dryRun: boolean
389
+ }) => runMtaGtfsRealtimeRequest(["mta", "gtfs-rt", "decode"], config)
390
+
391
+ export const handleMtaGtfsRealtimeCapture = (config: {
392
+ readonly feed: string
393
+ readonly url: Option.Option<string>
394
+ readonly output: string
395
+ readonly manifestOutput: string
396
+ readonly dryRun: boolean
397
+ }) =>
398
+ Effect.gen(function* () {
399
+ const context = yield* CliCommandContext
400
+ const tokens = ["mta", "gtfs-rt", "capture"]
401
+ const target = resolveRealtimeFeedTarget(config.feed, config.url)
402
+
403
+ if (target._tag !== "Resolved") {
404
+ return yield* failRealtimeTarget(context, tokens, target)
405
+ }
406
+
407
+ if (config.dryRun) {
408
+ return yield* writeSuccess(context, "mta", {
409
+ dryRun: true,
410
+ method: "GET",
411
+ feed: target.feed,
412
+ url: target.url,
413
+ output: config.output,
414
+ manifestOutput: config.manifestOutput,
415
+ source: target.source,
416
+ descriptor: target.descriptor
417
+ })
418
+ }
419
+
420
+ return yield* runEffect(
421
+ context,
422
+ "mta",
423
+ tokens,
424
+ Effect.gen(function* () {
425
+ const result = yield* captureGtfsRealtime({
426
+ feed: target.feed,
427
+ url: target.url
428
+ }).pipe(Effect.provide(context.runtime.mtaRealtimeLayer))
429
+ const manifest = MtaGtfsRealtimeCaptureManifest.make({
430
+ feed: result.feed,
431
+ status: result.status,
432
+ byteLength: result.byteLength,
433
+ sha256: result.sha256,
434
+ capturedAt: result.capturedAt,
435
+ url: result.url
436
+ })
437
+
438
+ yield* Effect.tryPromise({
439
+ try: () => atomicWrite(config.output, result.bytes),
440
+ catch: fileWriteError
441
+ })
442
+ yield* Effect.tryPromise({
443
+ try: () => atomicWrite(config.manifestOutput, `${JSON.stringify(manifest, null, 2)}\n`),
444
+ catch: fileWriteError
445
+ })
446
+
447
+ return {
448
+ output: config.output,
449
+ manifestOutput: config.manifestOutput,
450
+ manifest
451
+ }
452
+ })
453
+ )
454
+ })
455
+
456
+ const feedFlagDescription =
457
+ "Known feed id/name. Use --url with vehicle-positions, trip-updates, or alerts for manual URLs."
458
+
459
+ const optionalFeedFlag = Flag.optional(
460
+ Flag.string("feed").pipe(Flag.withDescription("Known feed id/name."))
461
+ )
462
+
463
+ const optionalUrlFlag = Flag.optional(
464
+ Flag.string("url").pipe(Flag.withDescription("Manual provider URL override."))
465
+ )
466
+
467
+ const mtaOpenDataDatasetListCommand = Command.make("list", {}, handleMtaOpenDataList).pipe(
468
+ Command.withShortDescription("List curated MTA Open Data descriptors.")
469
+ )
470
+
471
+ const mtaOpenDataDatasetInfoCommand = Command.make(
472
+ "info",
473
+ {
474
+ datasetId: Flag.string("dataset").pipe(
475
+ Flag.withDescription("MTA Open Data Socrata dataset id.")
476
+ )
477
+ },
478
+ handleMtaOpenDataInfo
479
+ ).pipe(Command.withShortDescription("Show MTA Open Data dataset metadata."))
480
+
481
+ const mtaOpenDataDatasetQueryCommand = Command.make(
482
+ "query",
483
+ {
484
+ datasetId: Flag.string("dataset").pipe(
485
+ Flag.withDescription("MTA Open Data Socrata dataset id.")
486
+ ),
487
+ select: Flag.string("select").pipe(
488
+ Flag.withDescription("SoQL select expression or full SELECT query.")
489
+ ),
490
+ dryRun: Flag.boolean("dry-run").pipe(
491
+ Flag.withDescription("Print the planned request without contacting the provider.")
492
+ )
493
+ },
494
+ handleMtaOpenDataQuery
495
+ ).pipe(Command.withShortDescription("Run an MTA Open Data SODA3 query."))
496
+
497
+ const mtaOpenDataDatasetCommand = Command.make("dataset").pipe(
498
+ Command.withShortDescription("MTA Open Data dataset commands."),
499
+ Command.withSubcommands([
500
+ mtaOpenDataDatasetListCommand,
501
+ mtaOpenDataDatasetInfoCommand,
502
+ mtaOpenDataDatasetQueryCommand
503
+ ])
504
+ )
505
+
506
+ const mtaOpenDataCommand = Command.make("open-data").pipe(
507
+ Command.withShortDescription("MTA Open Data commands."),
508
+ Command.withSubcommands([mtaOpenDataDatasetCommand])
509
+ )
510
+
511
+ const mtaGtfsStaticListCommand = Command.make("list", {}, handleMtaGtfsStaticList).pipe(
512
+ Command.withShortDescription("List known MTA GTFS static feed descriptors.")
513
+ )
514
+
515
+ const mtaGtfsStaticProbeCommand = Command.make(
516
+ "probe",
517
+ {
518
+ feed: optionalFeedFlag,
519
+ url: optionalUrlFlag,
520
+ dryRun: Flag.boolean("dry-run").pipe(
521
+ Flag.withDescription("Print the planned request without contacting the provider.")
522
+ )
523
+ },
524
+ handleMtaGtfsStaticProbe
525
+ ).pipe(Command.withShortDescription("Probe an MTA GTFS static feed."))
526
+
527
+ const mtaGtfsStaticFetchCommand = Command.make(
528
+ "fetch",
529
+ {
530
+ feed: optionalFeedFlag,
531
+ url: optionalUrlFlag,
532
+ output: Flag.string("output").pipe(
533
+ Flag.withDescription("Destination file path for the downloaded response body.")
534
+ ),
535
+ dryRun: Flag.boolean("dry-run").pipe(
536
+ Flag.withDescription("Print the planned request without contacting the provider.")
537
+ )
538
+ },
539
+ handleMtaGtfsStaticFetch
540
+ ).pipe(Command.withShortDescription("Fetch an MTA GTFS static file."))
541
+
542
+ const mtaGtfsStaticCommand = Command.make("gtfs-static").pipe(
543
+ Command.withShortDescription("MTA GTFS static commands."),
544
+ Command.withSubcommands([
545
+ mtaGtfsStaticListCommand,
546
+ mtaGtfsStaticProbeCommand,
547
+ mtaGtfsStaticFetchCommand
548
+ ])
549
+ )
550
+
551
+ const mtaGtfsRealtimeListCommand = Command.make("list", {}, handleMtaGtfsRealtimeList).pipe(
552
+ Command.withShortDescription("List known MTA GTFS realtime feed descriptors.")
553
+ )
554
+
555
+ const mtaGtfsRealtimeProbeCommand = Command.make(
556
+ "probe",
557
+ {
558
+ feed: Flag.string("feed").pipe(Flag.withDescription(feedFlagDescription)),
559
+ url: optionalUrlFlag,
560
+ dryRun: Flag.boolean("dry-run").pipe(
561
+ Flag.withDescription("Print the planned request without contacting the provider.")
562
+ )
563
+ },
564
+ handleMtaGtfsRealtimeProbe
565
+ ).pipe(Command.withShortDescription("Probe an MTA GTFS realtime feed."))
566
+
567
+ const mtaGtfsRealtimeDecodeCommand = Command.make(
568
+ "decode",
569
+ {
570
+ feed: Flag.string("feed").pipe(Flag.withDescription(feedFlagDescription)),
571
+ url: optionalUrlFlag,
572
+ dryRun: Flag.boolean("dry-run").pipe(
573
+ Flag.withDescription("Print the planned request without contacting the provider.")
574
+ )
575
+ },
576
+ handleMtaGtfsRealtimeDecode
577
+ ).pipe(Command.withShortDescription("Fetch and decode an MTA GTFS realtime feed."))
578
+
579
+ const mtaGtfsRealtimeCaptureCommand = Command.make(
580
+ "capture",
581
+ {
582
+ feed: Flag.string("feed").pipe(Flag.withDescription(feedFlagDescription)),
583
+ url: optionalUrlFlag,
584
+ output: Flag.string("output").pipe(
585
+ Flag.withDescription("Destination file path for the raw GTFS realtime protobuf bytes.")
586
+ ),
587
+ manifestOutput: Flag.string("manifest-output").pipe(
588
+ Flag.withDescription("Destination file path for the capture manifest JSON.")
589
+ ),
590
+ dryRun: Flag.boolean("dry-run").pipe(
591
+ Flag.withDescription("Print the planned request without contacting the provider.")
592
+ )
593
+ },
594
+ handleMtaGtfsRealtimeCapture
595
+ ).pipe(Command.withShortDescription("Capture raw MTA GTFS realtime bytes and a manifest."))
596
+
597
+ const mtaGtfsRealtimeCommand = Command.make("gtfs-rt").pipe(
598
+ Command.withShortDescription("MTA GTFS realtime commands."),
599
+ Command.withSubcommands([
600
+ mtaGtfsRealtimeListCommand,
601
+ mtaGtfsRealtimeProbeCommand,
602
+ mtaGtfsRealtimeDecodeCommand,
603
+ mtaGtfsRealtimeCaptureCommand
604
+ ])
605
+ )
606
+
607
+ export const mtaCommand = Command.make("mta").pipe(
608
+ Command.withShortDescription("MTA data commands."),
609
+ Command.withSubcommands([mtaOpenDataCommand, mtaGtfsStaticCommand, mtaGtfsRealtimeCommand])
610
+ )