@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,206 @@
1
+ import type { Soda3ExportFormat } from "@nyc-transit-kit/contracts/soda3"
2
+ import { buildExportUrl } from "@nyc-transit-kit/soda3/endpoints"
3
+ import { exportResponse } from "@nyc-transit-kit/soda3/export"
4
+ import { queryRows } from "@nyc-transit-kit/soda3/query"
5
+ import * as Effect from "effect/Effect"
6
+ import * as Command from "effect/unstable/cli/Command"
7
+ import * as Flag from "effect/unstable/cli/Flag"
8
+ import { runEffect, soqlFromSelect, writeSuccess } from "./shared"
9
+ import { writeResponseToFile, writeSoda3ExportDryRun, writeSoda3QueryDryRun } from "./soda3-shared"
10
+ import { CliCommandContext } from "./types"
11
+
12
+ export const socrataCommandPaths: ReadonlyArray<ReadonlyArray<string>> = [
13
+ ["socrata", "query"],
14
+ ["socrata", "export"],
15
+ ["socrata", "range-probe"]
16
+ ]
17
+
18
+ export const handleSocrataQuery = (config: {
19
+ readonly domain: string
20
+ readonly datasetId: string
21
+ readonly select: string
22
+ readonly dryRun: boolean
23
+ }) =>
24
+ Effect.gen(function* () {
25
+ const context = yield* CliCommandContext
26
+ const tokens = ["socrata", "query"]
27
+ const request = {
28
+ domain: config.domain,
29
+ datasetId: config.datasetId,
30
+ query: soqlFromSelect(config.select)
31
+ }
32
+
33
+ if (config.dryRun) {
34
+ return yield* writeSoda3QueryDryRun({
35
+ context,
36
+ apiFamily: "socrata",
37
+ tokens,
38
+ ...request
39
+ })
40
+ }
41
+
42
+ return yield* runEffect(
43
+ context,
44
+ "socrata",
45
+ tokens,
46
+ queryRows(request).pipe(Effect.provide(context.runtime.soda3Layer))
47
+ )
48
+ })
49
+
50
+ export const handleSocrataExport = (config: {
51
+ readonly domain: string
52
+ readonly datasetId: string
53
+ readonly format: Soda3ExportFormat
54
+ readonly output: string
55
+ readonly dryRun: boolean
56
+ }) =>
57
+ Effect.gen(function* () {
58
+ const context = yield* CliCommandContext
59
+ const tokens = ["socrata", "export"]
60
+ const request = {
61
+ domain: config.domain,
62
+ datasetId: config.datasetId,
63
+ format: config.format
64
+ }
65
+
66
+ if (config.dryRun) {
67
+ return yield* writeSoda3ExportDryRun({
68
+ context,
69
+ apiFamily: "socrata",
70
+ tokens,
71
+ output: config.output,
72
+ ...request
73
+ })
74
+ }
75
+
76
+ return yield* runEffect(
77
+ context,
78
+ "socrata",
79
+ tokens,
80
+ Effect.gen(function* () {
81
+ const response = yield* exportResponse(request).pipe(
82
+ Effect.provide(context.runtime.soda3Layer)
83
+ )
84
+ return yield* writeResponseToFile(config.output, response)
85
+ })
86
+ )
87
+ })
88
+
89
+ export const handleSocrataRangeProbe = (config: {
90
+ readonly domain: string
91
+ readonly datasetId: string
92
+ readonly format: Soda3ExportFormat
93
+ readonly rangeEnd: number
94
+ readonly dryRun: boolean
95
+ }) =>
96
+ Effect.gen(function* () {
97
+ const context = yield* CliCommandContext
98
+ const tokens = ["socrata", "range-probe"]
99
+ const request = {
100
+ domain: config.domain,
101
+ datasetId: config.datasetId,
102
+ format: config.format,
103
+ range: {
104
+ start: 0,
105
+ end: config.rangeEnd
106
+ }
107
+ }
108
+ const url = yield* buildExportUrl(request)
109
+
110
+ if (config.dryRun) {
111
+ return yield* writeSuccess(context, "socrata", {
112
+ dryRun: true,
113
+ method: "POST",
114
+ url: url.toString(),
115
+ headers: {
116
+ range: `bytes=0-${config.rangeEnd}`
117
+ }
118
+ })
119
+ }
120
+
121
+ return yield* runEffect(
122
+ context,
123
+ "socrata",
124
+ tokens,
125
+ Effect.gen(function* () {
126
+ const response = yield* exportResponse(request).pipe(
127
+ Effect.provide(context.runtime.soda3Layer)
128
+ )
129
+ const body = yield* Effect.promise(() => response.arrayBuffer())
130
+ return {
131
+ status: response.status,
132
+ byteLength: body.byteLength
133
+ }
134
+ })
135
+ )
136
+ })
137
+
138
+ const socrataQueryCommand = Command.make(
139
+ "query",
140
+ {
141
+ domain: Flag.string("domain").pipe(
142
+ Flag.withDescription("Socrata domain without protocol, such as data.cityofnewyork.us.")
143
+ ),
144
+ datasetId: Flag.string("dataset").pipe(
145
+ Flag.withDescription("Socrata dataset id, such as ycrg-ses3.")
146
+ ),
147
+ select: Flag.string("select").pipe(
148
+ Flag.withDescription("SoQL select expression or full SELECT query.")
149
+ ),
150
+ dryRun: Flag.boolean("dry-run").pipe(
151
+ Flag.withDescription("Print the planned request without contacting the provider.")
152
+ )
153
+ },
154
+ handleSocrataQuery
155
+ ).pipe(Command.withShortDescription("Run a SODA3 query."))
156
+
157
+ const socrataExportCommand = Command.make(
158
+ "export",
159
+ {
160
+ domain: Flag.string("domain").pipe(
161
+ Flag.withDescription("Socrata domain without protocol, such as data.cityofnewyork.us.")
162
+ ),
163
+ datasetId: Flag.string("dataset").pipe(
164
+ Flag.withDescription("Socrata dataset id, such as ycrg-ses3.")
165
+ ),
166
+ format: Flag.choice("format", ["csv", "json", "geojson"]).pipe(
167
+ Flag.withDefault("csv"),
168
+ Flag.withDescription("Export format. Defaults to csv.")
169
+ ),
170
+ output: Flag.string("output").pipe(
171
+ Flag.withDescription("Destination file path for the downloaded response body.")
172
+ ),
173
+ dryRun: Flag.boolean("dry-run").pipe(
174
+ Flag.withDescription("Print the planned request without contacting the provider.")
175
+ )
176
+ },
177
+ handleSocrataExport
178
+ ).pipe(Command.withShortDescription("Export a SODA3 dataset."))
179
+
180
+ const socrataRangeProbeCommand = Command.make(
181
+ "range-probe",
182
+ {
183
+ domain: Flag.string("domain").pipe(
184
+ Flag.withDescription("Socrata domain without protocol, such as data.cityofnewyork.us.")
185
+ ),
186
+ datasetId: Flag.string("dataset").pipe(
187
+ Flag.withDescription("Socrata dataset id, such as ycrg-ses3.")
188
+ ),
189
+ format: Flag.choice("format", ["csv", "json", "geojson"]).pipe(
190
+ Flag.withDefault("csv"),
191
+ Flag.withDescription("Export format. Defaults to csv.")
192
+ ),
193
+ rangeEnd: Flag.integer("range-end").pipe(
194
+ Flag.withDescription("Inclusive byte offset for a bytes=0-N range probe.")
195
+ ),
196
+ dryRun: Flag.boolean("dry-run").pipe(
197
+ Flag.withDescription("Print the planned request without contacting the provider.")
198
+ )
199
+ },
200
+ handleSocrataRangeProbe
201
+ ).pipe(Command.withShortDescription("Probe SODA3 export range support."))
202
+
203
+ export const socrataCommand = Command.make("socrata").pipe(
204
+ Command.withShortDescription("Socrata SODA3 commands."),
205
+ Command.withSubcommands([socrataQueryCommand, socrataExportCommand, socrataRangeProbeCommand])
206
+ )
@@ -0,0 +1,103 @@
1
+ import type { Soda3ExportFormat } from "@nyc-transit-kit/contracts/soda3"
2
+ import { buildExportUrl, buildQueryUrl } from "@nyc-transit-kit/soda3/endpoints"
3
+ import * as Effect from "effect/Effect"
4
+ import { atomicWrite } from "../files"
5
+ import { runEffect } from "./shared"
6
+ import type { ApiFamily, CommandContext } from "./types"
7
+
8
+ type DryRunExtra = Readonly<Record<string, unknown>>
9
+
10
+ export const fileWriteError = (cause: unknown) => ({
11
+ _tag: "FileWriteError",
12
+ message: cause instanceof Error ? cause.message : "File write failed."
13
+ })
14
+
15
+ const optionalHeader = (headers: Headers, name: string) => headers.get(name) ?? undefined
16
+
17
+ const parseContentLength = (value: string | undefined) => {
18
+ if (value === undefined) {
19
+ return undefined
20
+ }
21
+
22
+ const parsed = Number(value)
23
+ return Number.isInteger(parsed) && parsed >= 0 ? parsed : undefined
24
+ }
25
+
26
+ export const writeSoda3QueryDryRun = (config: {
27
+ readonly context: CommandContext
28
+ readonly apiFamily: ApiFamily
29
+ readonly tokens: ReadonlyArray<string>
30
+ readonly domain: string
31
+ readonly datasetId: string
32
+ readonly query: string
33
+ readonly extra?: DryRunExtra
34
+ }) =>
35
+ runEffect(
36
+ config.context,
37
+ config.apiFamily,
38
+ config.tokens,
39
+ buildQueryUrl({
40
+ domain: config.domain,
41
+ datasetId: config.datasetId,
42
+ query: config.query
43
+ }).pipe(
44
+ Effect.map((url) => ({
45
+ dryRun: true,
46
+ method: "POST",
47
+ url: url.toString(),
48
+ ...config.extra,
49
+ body: {
50
+ query: config.query
51
+ }
52
+ }))
53
+ )
54
+ )
55
+
56
+ export const writeSoda3ExportDryRun = (config: {
57
+ readonly context: CommandContext
58
+ readonly apiFamily: ApiFamily
59
+ readonly tokens: ReadonlyArray<string>
60
+ readonly domain: string
61
+ readonly datasetId: string
62
+ readonly format: Soda3ExportFormat
63
+ readonly output: string
64
+ readonly extra?: DryRunExtra
65
+ }) =>
66
+ runEffect(
67
+ config.context,
68
+ config.apiFamily,
69
+ config.tokens,
70
+ buildExportUrl({
71
+ domain: config.domain,
72
+ datasetId: config.datasetId,
73
+ format: config.format
74
+ }).pipe(
75
+ Effect.map((url) => ({
76
+ dryRun: true,
77
+ method: "POST",
78
+ url: url.toString(),
79
+ ...config.extra,
80
+ output: config.output
81
+ }))
82
+ )
83
+ )
84
+
85
+ export const writeResponseToFile = (output: string, response: Response) =>
86
+ Effect.gen(function* () {
87
+ const contentType = optionalHeader(response.headers, "content-type")
88
+ const contentLength = parseContentLength(optionalHeader(response.headers, "content-length"))
89
+
90
+ yield* Effect.tryPromise({
91
+ try: () => atomicWrite(output, response),
92
+ catch: fileWriteError
93
+ })
94
+ const byteLength = yield* Effect.sync(() => Bun.file(output).size)
95
+
96
+ return {
97
+ output,
98
+ status: response.status,
99
+ byteLength,
100
+ ...(contentType === undefined ? {} : { contentType }),
101
+ ...(contentLength === undefined ? {} : { contentLength })
102
+ }
103
+ })
@@ -0,0 +1,33 @@
1
+ import type { GtfsRealtimeDecoder } from "@nyc-transit-kit/mta/gtfs-realtime"
2
+ import type { Soda3ClientConfig } from "@nyc-transit-kit/soda3/client"
3
+ import * as Context from "effect/Context"
4
+ import type * as Layer from "effect/Layer"
5
+ import type * as HttpClient from "effect/unstable/http/HttpClient"
6
+ import type { CliMetaApiFamily } from "../output"
7
+
8
+ export type ApiFamily = CliMetaApiFamily
9
+
10
+ export type CommandResult<A> =
11
+ | {
12
+ readonly _tag: "Failure"
13
+ readonly error: unknown
14
+ }
15
+ | {
16
+ readonly _tag: "Success"
17
+ readonly data: A
18
+ }
19
+
20
+ export type CliRuntime = {
21
+ readonly soda3Layer: Layer.Layer<Soda3ClientConfig | HttpClient.HttpClient>
22
+ readonly mtaLayer: Layer.Layer<HttpClient.HttpClient>
23
+ readonly mtaRealtimeLayer: Layer.Layer<HttpClient.HttpClient | GtfsRealtimeDecoder>
24
+ }
25
+
26
+ export type CommandContext = {
27
+ readonly runtime: CliRuntime
28
+ readonly writeJson: (output: unknown) => void
29
+ }
30
+
31
+ export class CliCommandContext extends Context.Service<CliCommandContext, CommandContext>()(
32
+ "@nyc-transit-kit/cli/CliCommandContext"
33
+ ) {}
package/src/config.ts ADDED
@@ -0,0 +1,21 @@
1
+ import { GtfsRealtimeDecoder } from "@nyc-transit-kit/mta/gtfs-realtime"
2
+ import { MtaHttpLive } from "@nyc-transit-kit/mta/gtfs-static"
3
+ import { Soda3ClientConfig, Soda3HttpLive } from "@nyc-transit-kit/soda3/client"
4
+ import * as Layer from "effect/Layer"
5
+
6
+ export interface Soda3CliLayerConfig {
7
+ readonly appToken?: string
8
+ }
9
+
10
+ export const soda3CliLayer = (config: Soda3CliLayerConfig) =>
11
+ Layer.mergeAll(
12
+ Layer.succeed(Soda3ClientConfig)({
13
+ appToken: config.appToken,
14
+ retryTimes: 2
15
+ }),
16
+ Soda3HttpLive
17
+ )
18
+
19
+ export const mtaCliLayer = MtaHttpLive
20
+
21
+ export const mtaRealtimeCliLayer = Layer.mergeAll(MtaHttpLive, GtfsRealtimeDecoder.Live)
package/src/files.ts ADDED
@@ -0,0 +1,27 @@
1
+ import { rename } from "node:fs/promises"
2
+
3
+ export type AtomicWriteBody =
4
+ | Blob
5
+ | NodeJS.TypedArray
6
+ | ArrayBufferLike
7
+ | string
8
+ | Bun.Archive
9
+ | Response
10
+
11
+ export const atomicWrite = async (path: string, body: AtomicWriteBody) => {
12
+ const tempPath = `${path}.tmp-${process.pid}`
13
+
14
+ try {
15
+ if (body instanceof Response) {
16
+ await Bun.file(tempPath).write(body)
17
+ } else {
18
+ await Bun.write(tempPath, body)
19
+ }
20
+ await rename(tempPath, path)
21
+ } catch (cause) {
22
+ await Bun.file(tempPath)
23
+ .delete()
24
+ .catch(() => undefined)
25
+ throw cause
26
+ }
27
+ }
package/src/index.ts ADDED
@@ -0,0 +1,2 @@
1
+ export const packageName = "@nyc-transit-kit/cli"
2
+ export const localBinaryName = "ntk"
package/src/main.ts ADDED
@@ -0,0 +1,205 @@
1
+ import * as BunServices from "@effect/platform-bun/BunServices"
2
+ import { schemaVersion } from "@nyc-transit-kit/contracts/metadata"
3
+ import * as Console from "effect/Console"
4
+ import * as Effect from "effect/Effect"
5
+ import * as CliError from "effect/unstable/cli/CliError"
6
+ import * as Command from "effect/unstable/cli/Command"
7
+ import { rootCommand } from "./commands/root"
8
+ import { apiFamilyFor, commandLabel, writeJson } from "./commands/shared"
9
+ import { CliCommandContext } from "./commands/types"
10
+ import { localBinaryName } from "./index"
11
+ import {
12
+ errorEnvelope,
13
+ packageBuildCommit,
14
+ packageBuildTarget,
15
+ packageVersion,
16
+ successEnvelope
17
+ } from "./output"
18
+ import { makeCliRuntime } from "./runtime"
19
+
20
+ type CommandRunResult =
21
+ | {
22
+ readonly _tag: "Failure"
23
+ readonly error: unknown
24
+ }
25
+ | {
26
+ readonly _tag: "Success"
27
+ }
28
+
29
+ type JsonErrorDetails = {
30
+ readonly code: string
31
+ readonly message: string
32
+ readonly tokens: ReadonlyArray<string>
33
+ }
34
+
35
+ const noop = () => undefined
36
+
37
+ const quietConsole: Console.Console = {
38
+ assert: noop,
39
+ clear: noop,
40
+ count: noop,
41
+ countReset: noop,
42
+ debug: noop,
43
+ dir: noop,
44
+ dirxml: noop,
45
+ error: noop,
46
+ group: noop,
47
+ groupCollapsed: noop,
48
+ groupEnd: noop,
49
+ info: noop,
50
+ log: noop,
51
+ table: noop,
52
+ time: noop,
53
+ timeEnd: noop,
54
+ timeLog: noop,
55
+ trace: noop,
56
+ warn: noop
57
+ }
58
+
59
+ const stripRootCommand = (path: ReadonlyArray<string>) =>
60
+ path[0] === localBinaryName ? path.slice(1) : path
61
+
62
+ const messageOf = (error: CliError.CliError) =>
63
+ "message" in error && typeof error.message === "string" ? error.message : "Command failed."
64
+
65
+ const jsonErrorDetails = (
66
+ error: CliError.CliError,
67
+ fallbackPath: ReadonlyArray<string> = []
68
+ ): JsonErrorDetails => {
69
+ switch (error._tag) {
70
+ case "ShowHelp": {
71
+ const firstError = error.errors[0]
72
+ if (firstError !== undefined) {
73
+ return jsonErrorDetails(firstError, error.commandPath)
74
+ }
75
+
76
+ return {
77
+ code: "command-failed",
78
+ message: "Command failed.",
79
+ tokens: stripRootCommand(error.commandPath)
80
+ }
81
+ }
82
+ case "MissingOption":
83
+ return {
84
+ code: "missing-option",
85
+ message: error.message,
86
+ tokens: stripRootCommand(fallbackPath)
87
+ }
88
+ case "MissingArgument":
89
+ return {
90
+ code: "missing-argument",
91
+ message: error.message,
92
+ tokens: stripRootCommand(fallbackPath)
93
+ }
94
+ case "UnknownSubcommand": {
95
+ const parent = error.parent ?? fallbackPath
96
+ return {
97
+ code: "unknown-command",
98
+ message: error.message,
99
+ tokens: stripRootCommand([...parent, error.subcommand])
100
+ }
101
+ }
102
+ case "UnrecognizedOption":
103
+ return {
104
+ code: "unknown-option",
105
+ message: error.message,
106
+ tokens: stripRootCommand(error.command ?? fallbackPath)
107
+ }
108
+ case "InvalidValue":
109
+ return {
110
+ code: "invalid-option",
111
+ message: error.message,
112
+ tokens: stripRootCommand(fallbackPath)
113
+ }
114
+ case "DuplicateOption":
115
+ case "UserError":
116
+ return {
117
+ code: "command-failed",
118
+ message: messageOf(error),
119
+ tokens: stripRootCommand(fallbackPath)
120
+ }
121
+ }
122
+ }
123
+
124
+ const writeJsonFailure = (error: unknown) => {
125
+ const details = CliError.isCliError(error)
126
+ ? jsonErrorDetails(error)
127
+ : {
128
+ code: "command-failed",
129
+ message: "Command failed.",
130
+ tokens: []
131
+ }
132
+
133
+ process.exitCode = 1
134
+ writeJson(
135
+ errorEnvelope(apiFamilyFor(details.tokens), {
136
+ code: details.code,
137
+ message: details.message,
138
+ command: commandLabel(details.tokens)
139
+ })
140
+ )
141
+ }
142
+
143
+ const versionJson = () =>
144
+ successEnvelope("cli", {
145
+ binary: localBinaryName,
146
+ version: packageVersion,
147
+ schemaVersion,
148
+ commit: packageBuildCommit,
149
+ buildTarget: packageBuildTarget
150
+ })
151
+
152
+ export const main = async () => {
153
+ const args = Bun.argv.slice(2)
154
+ if (args.includes("--version")) {
155
+ if (args.includes("--json")) {
156
+ writeJson(versionJson())
157
+ return
158
+ }
159
+
160
+ console.log(`${localBinaryName} ${packageVersion}`)
161
+ return
162
+ }
163
+
164
+ const context = {
165
+ runtime: makeCliRuntime(process.env),
166
+ writeJson
167
+ }
168
+ const baseProgram = Command.runWith(rootCommand, { version: packageVersion })(args).pipe(
169
+ Effect.provideService(CliCommandContext, context),
170
+ Effect.provide(BunServices.layer)
171
+ )
172
+ const shouldKeepJsonClean = args.includes("--json") && !args.includes("--help")
173
+ const program = shouldKeepJsonClean
174
+ ? baseProgram.pipe(Effect.provideService(Console.Console, quietConsole))
175
+ : baseProgram
176
+
177
+ const result = await Effect.runPromise(
178
+ program.pipe(
179
+ Effect.match({
180
+ onFailure: (error): CommandRunResult => ({
181
+ _tag: "Failure",
182
+ error
183
+ }),
184
+ onSuccess: (): CommandRunResult => ({
185
+ _tag: "Success"
186
+ })
187
+ })
188
+ )
189
+ )
190
+
191
+ if (result._tag === "Success") {
192
+ return
193
+ }
194
+
195
+ if (args.includes("--json")) {
196
+ writeJsonFailure(result.error)
197
+ return
198
+ }
199
+
200
+ process.exitCode = 1
201
+ }
202
+
203
+ if (import.meta.main) {
204
+ await main()
205
+ }
package/src/output.ts ADDED
@@ -0,0 +1,39 @@
1
+ import { releaseVersion, schemaVersion } from "@nyc-transit-kit/contracts/metadata"
2
+ import { buildCommit, buildTarget } from "./build-info"
3
+
4
+ export const packageVersion = releaseVersion
5
+ export const packageBuildCommit = buildCommit
6
+ export const packageBuildTarget = buildTarget
7
+
8
+ const currentGeneratedAt = () => new Date().toISOString()
9
+
10
+ export type CliMetaApiFamily = "socrata" | "mta" | "nyc-open-data" | "nyc-dot" | "cli"
11
+
12
+ export const successEnvelope = (apiFamily: CliMetaApiFamily, data: unknown) => ({
13
+ ok: true,
14
+ data,
15
+ meta: {
16
+ apiFamily,
17
+ generatedAt: currentGeneratedAt(),
18
+ schemaVersion
19
+ }
20
+ })
21
+
22
+ export const errorEnvelope = (
23
+ apiFamily: CliMetaApiFamily,
24
+ error: {
25
+ readonly code: string
26
+ readonly message: string
27
+ readonly provider?: string
28
+ readonly retryable?: boolean
29
+ readonly command?: string
30
+ }
31
+ ) => ({
32
+ ok: false,
33
+ error,
34
+ meta: {
35
+ apiFamily,
36
+ generatedAt: currentGeneratedAt(),
37
+ schemaVersion
38
+ }
39
+ })
package/src/runtime.ts ADDED
@@ -0,0 +1,14 @@
1
+ import type { CliRuntime } from "./commands/types"
2
+ import { mtaCliLayer, mtaRealtimeCliLayer, soda3CliLayer } from "./config"
3
+
4
+ export const makeCliRuntime = (env: Readonly<Record<string, string | undefined>>): CliRuntime => {
5
+ const soda3Layer = soda3CliLayer({
6
+ appToken: env.SOCRATA_APP_TOKEN
7
+ })
8
+
9
+ return {
10
+ soda3Layer,
11
+ mtaLayer: mtaCliLayer,
12
+ mtaRealtimeLayer: mtaRealtimeCliLayer
13
+ }
14
+ }