@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,212 @@
1
+ import type { Soda3ExportFormat } from "@nyc-transit-kit/contracts/soda3"
2
+ import { exportNycDotDataset, queryNycDotDataset } from "@nyc-transit-kit/nyc-dot/client"
3
+ import {
4
+ findNycDotDataset,
5
+ nycDotDatasets,
6
+ nycDotOpenDataDomain
7
+ } from "@nyc-transit-kit/nyc-dot/datasets"
8
+ import * as Effect from "effect/Effect"
9
+ import * as Command from "effect/unstable/cli/Command"
10
+ import * as Flag from "effect/unstable/cli/Flag"
11
+ import { failCommand, runEffect, soqlFromSelect, writeSuccess } from "./shared"
12
+ import { writeResponseToFile, writeSoda3ExportDryRun, writeSoda3QueryDryRun } from "./soda3-shared"
13
+ import { CliCommandContext } from "./types"
14
+
15
+ export const nycDotCommandPaths: ReadonlyArray<ReadonlyArray<string>> = [
16
+ ["nyc-dot", "dataset", "list"],
17
+ ["nyc-dot", "dataset", "info"],
18
+ ["nyc-dot", "dataset", "query"],
19
+ ["nyc-dot", "dataset", "export"]
20
+ ]
21
+
22
+ export const handleNycDotList = () =>
23
+ Effect.gen(function* () {
24
+ const context = yield* CliCommandContext
25
+ return yield* writeSuccess(context, "nyc-dot", {
26
+ datasets: nycDotDatasets
27
+ })
28
+ })
29
+
30
+ export const handleNycDotInfo = (config: { readonly name: string }) =>
31
+ Effect.gen(function* () {
32
+ const context = yield* CliCommandContext
33
+ const descriptor = findNycDotDataset(config.name)
34
+
35
+ if (descriptor === undefined) {
36
+ return yield* failCommand(
37
+ context,
38
+ "nyc-dot",
39
+ "unsupported-dataset",
40
+ `Unsupported NYC DOT dataset: ${config.name}`,
41
+ ["nyc-dot", "dataset", "info"]
42
+ )
43
+ }
44
+
45
+ return yield* writeSuccess(context, "nyc-dot", descriptor)
46
+ })
47
+
48
+ export const handleNycDotQuery = (config: {
49
+ readonly name: string
50
+ readonly select: string
51
+ readonly dryRun: boolean
52
+ }) =>
53
+ Effect.gen(function* () {
54
+ const context = yield* CliCommandContext
55
+ const tokens = ["nyc-dot", "dataset", "query"]
56
+ const descriptor = findNycDotDataset(config.name)
57
+
58
+ if (descriptor === undefined) {
59
+ return yield* failCommand(
60
+ context,
61
+ "nyc-dot",
62
+ "unsupported-dataset",
63
+ `Unsupported NYC DOT dataset: ${config.name}`,
64
+ tokens
65
+ )
66
+ }
67
+
68
+ const request = {
69
+ name: config.name,
70
+ query: soqlFromSelect(config.select)
71
+ }
72
+
73
+ if (config.dryRun) {
74
+ return yield* writeSoda3QueryDryRun({
75
+ context,
76
+ apiFamily: "nyc-dot",
77
+ tokens,
78
+ domain: nycDotOpenDataDomain,
79
+ datasetId: String(descriptor.id),
80
+ query: request.query,
81
+ extra: {
82
+ dataset: descriptor
83
+ }
84
+ })
85
+ }
86
+
87
+ return yield* runEffect(
88
+ context,
89
+ "nyc-dot",
90
+ tokens,
91
+ queryNycDotDataset(request).pipe(Effect.provide(context.runtime.soda3Layer))
92
+ )
93
+ })
94
+
95
+ export const handleNycDotExport = (config: {
96
+ readonly name: string
97
+ readonly format: Soda3ExportFormat
98
+ readonly output: string
99
+ readonly dryRun: boolean
100
+ }) =>
101
+ Effect.gen(function* () {
102
+ const context = yield* CliCommandContext
103
+ const tokens = ["nyc-dot", "dataset", "export"]
104
+ const descriptor = findNycDotDataset(config.name)
105
+
106
+ if (descriptor === undefined) {
107
+ return yield* failCommand(
108
+ context,
109
+ "nyc-dot",
110
+ "unsupported-dataset",
111
+ `Unsupported NYC DOT dataset: ${config.name}`,
112
+ tokens
113
+ )
114
+ }
115
+
116
+ const request = {
117
+ name: config.name,
118
+ format: config.format
119
+ }
120
+
121
+ if (config.dryRun) {
122
+ return yield* writeSoda3ExportDryRun({
123
+ context,
124
+ apiFamily: "nyc-dot",
125
+ tokens,
126
+ domain: nycDotOpenDataDomain,
127
+ datasetId: String(descriptor.id),
128
+ format: request.format,
129
+ output: config.output,
130
+ extra: {
131
+ dataset: descriptor
132
+ }
133
+ })
134
+ }
135
+
136
+ return yield* runEffect(
137
+ context,
138
+ "nyc-dot",
139
+ tokens,
140
+ Effect.gen(function* () {
141
+ const response = yield* exportNycDotDataset(request).pipe(
142
+ Effect.provide(context.runtime.soda3Layer)
143
+ )
144
+ return yield* writeResponseToFile(config.output, response)
145
+ })
146
+ )
147
+ })
148
+
149
+ const nycDotDatasetListCommand = Command.make("list", {}, handleNycDotList).pipe(
150
+ Command.withShortDescription("List curated NYC DOT dataset descriptors.")
151
+ )
152
+
153
+ const nycDotDatasetInfoCommand = Command.make(
154
+ "info",
155
+ {
156
+ name: Flag.string("name").pipe(
157
+ Flag.withDescription("Known NYC DOT dataset name, such as traffic-speeds.")
158
+ )
159
+ },
160
+ handleNycDotInfo
161
+ ).pipe(Command.withShortDescription("Show NYC DOT dataset metadata."))
162
+
163
+ const nycDotDatasetQueryCommand = Command.make(
164
+ "query",
165
+ {
166
+ name: Flag.string("name").pipe(
167
+ Flag.withDescription("Known NYC DOT dataset name, such as traffic-speeds.")
168
+ ),
169
+ select: Flag.string("select").pipe(
170
+ Flag.withDescription("SoQL select expression or full SELECT query.")
171
+ ),
172
+ dryRun: Flag.boolean("dry-run").pipe(
173
+ Flag.withDescription("Print the planned request without contacting the provider.")
174
+ )
175
+ },
176
+ handleNycDotQuery
177
+ ).pipe(Command.withShortDescription("Run a NYC DOT SODA3 query."))
178
+
179
+ const nycDotDatasetExportCommand = Command.make(
180
+ "export",
181
+ {
182
+ name: Flag.string("name").pipe(
183
+ Flag.withDescription("Known NYC DOT dataset name, such as traffic-speeds.")
184
+ ),
185
+ format: Flag.choice("format", ["csv", "json", "geojson"]).pipe(
186
+ Flag.withDefault("csv"),
187
+ Flag.withDescription("Export format. Defaults to csv.")
188
+ ),
189
+ output: Flag.string("output").pipe(
190
+ Flag.withDescription("Destination file path for the downloaded response body.")
191
+ ),
192
+ dryRun: Flag.boolean("dry-run").pipe(
193
+ Flag.withDescription("Print the planned request without contacting the provider.")
194
+ )
195
+ },
196
+ handleNycDotExport
197
+ ).pipe(Command.withShortDescription("Export a NYC DOT dataset."))
198
+
199
+ const nycDotDatasetCommand = Command.make("dataset").pipe(
200
+ Command.withShortDescription("NYC DOT dataset commands."),
201
+ Command.withSubcommands([
202
+ nycDotDatasetListCommand,
203
+ nycDotDatasetInfoCommand,
204
+ nycDotDatasetQueryCommand,
205
+ nycDotDatasetExportCommand
206
+ ])
207
+ )
208
+
209
+ export const nycDotCommand = Command.make("nyc-dot").pipe(
210
+ Command.withShortDescription("NYC DOT commands."),
211
+ Command.withSubcommands([nycDotDatasetCommand])
212
+ )
@@ -0,0 +1,243 @@
1
+ import type { Soda3ExportFormat } from "@nyc-transit-kit/contracts/soda3"
2
+ import { searchNycOpenDataCatalog } from "@nyc-transit-kit/nyc-open-data/catalog"
3
+ import { exportNycOpenDataDataset } from "@nyc-transit-kit/nyc-open-data/client"
4
+ import {
5
+ defaultDomain,
6
+ findNycOpenDataDataset,
7
+ knownNycOpenDataDatasets
8
+ } from "@nyc-transit-kit/nyc-open-data/descriptors"
9
+ import { queryNycOpenDataDataset } from "@nyc-transit-kit/nyc-open-data/query"
10
+ import { buildCatalogSearchUrl } from "@nyc-transit-kit/soda3/endpoints"
11
+ import * as Effect from "effect/Effect"
12
+ import * as Command from "effect/unstable/cli/Command"
13
+ import * as Flag from "effect/unstable/cli/Flag"
14
+ import { runEffect, soqlFromSelect, writeSuccess } from "./shared"
15
+ import { writeResponseToFile, writeSoda3ExportDryRun, writeSoda3QueryDryRun } from "./soda3-shared"
16
+ import { CliCommandContext } from "./types"
17
+
18
+ export const nycOpenDataCommandPaths: ReadonlyArray<ReadonlyArray<string>> = [
19
+ ["nyc-open-data", "catalog", "search"],
20
+ ["nyc-open-data", "dataset", "list"],
21
+ ["nyc-open-data", "dataset", "info"],
22
+ ["nyc-open-data", "dataset", "query"],
23
+ ["nyc-open-data", "dataset", "export"]
24
+ ]
25
+
26
+ export const handleNycOpenDataCatalogSearch = (config: {
27
+ readonly query: string
28
+ readonly limit: number
29
+ readonly dryRun: boolean
30
+ }) =>
31
+ Effect.gen(function* () {
32
+ const context = yield* CliCommandContext
33
+ const tokens = ["nyc-open-data", "catalog", "search"]
34
+ const request = {
35
+ query: config.query,
36
+ limit: config.limit
37
+ }
38
+
39
+ if (config.dryRun) {
40
+ return yield* runEffect(
41
+ context,
42
+ "nyc-open-data",
43
+ tokens,
44
+ buildCatalogSearchUrl({
45
+ domain: defaultDomain,
46
+ ...request
47
+ }).pipe(
48
+ Effect.map((url) => ({
49
+ dryRun: true,
50
+ method: "GET",
51
+ url: url.toString()
52
+ }))
53
+ )
54
+ )
55
+ }
56
+
57
+ return yield* runEffect(
58
+ context,
59
+ "nyc-open-data",
60
+ tokens,
61
+ searchNycOpenDataCatalog(request).pipe(Effect.provide(context.runtime.soda3Layer))
62
+ )
63
+ })
64
+
65
+ export const handleNycOpenDataList = () =>
66
+ Effect.gen(function* () {
67
+ const context = yield* CliCommandContext
68
+ return yield* writeSuccess(context, "nyc-open-data", {
69
+ datasets: knownNycOpenDataDatasets
70
+ })
71
+ })
72
+
73
+ export const handleNycOpenDataInfo = (config: { readonly datasetId: string }) =>
74
+ Effect.gen(function* () {
75
+ const context = yield* CliCommandContext
76
+ const descriptor = findNycOpenDataDataset(config.datasetId)
77
+
78
+ return yield* writeSuccess(context, "nyc-open-data", {
79
+ known: descriptor !== undefined,
80
+ descriptor:
81
+ descriptor === undefined
82
+ ? {
83
+ id: config.datasetId,
84
+ domain: defaultDomain,
85
+ backing: "socrata"
86
+ }
87
+ : descriptor
88
+ })
89
+ })
90
+
91
+ export const handleNycOpenDataQuery = (config: {
92
+ readonly datasetId: string
93
+ readonly select: string
94
+ readonly dryRun: boolean
95
+ }) =>
96
+ Effect.gen(function* () {
97
+ const context = yield* CliCommandContext
98
+ const tokens = ["nyc-open-data", "dataset", "query"]
99
+ const request = {
100
+ datasetId: config.datasetId,
101
+ query: soqlFromSelect(config.select)
102
+ }
103
+
104
+ if (config.dryRun) {
105
+ return yield* writeSoda3QueryDryRun({
106
+ context,
107
+ apiFamily: "nyc-open-data",
108
+ tokens,
109
+ domain: defaultDomain,
110
+ ...request
111
+ })
112
+ }
113
+
114
+ return yield* runEffect(
115
+ context,
116
+ "nyc-open-data",
117
+ tokens,
118
+ queryNycOpenDataDataset(request).pipe(Effect.provide(context.runtime.soda3Layer))
119
+ )
120
+ })
121
+
122
+ export const handleNycOpenDataExport = (config: {
123
+ readonly datasetId: string
124
+ readonly format: Soda3ExportFormat
125
+ readonly output: string
126
+ readonly dryRun: boolean
127
+ }) =>
128
+ Effect.gen(function* () {
129
+ const context = yield* CliCommandContext
130
+ const tokens = ["nyc-open-data", "dataset", "export"]
131
+ const request = {
132
+ datasetId: config.datasetId,
133
+ format: config.format
134
+ }
135
+
136
+ if (config.dryRun) {
137
+ return yield* writeSoda3ExportDryRun({
138
+ context,
139
+ apiFamily: "nyc-open-data",
140
+ tokens,
141
+ domain: defaultDomain,
142
+ output: config.output,
143
+ ...request
144
+ })
145
+ }
146
+
147
+ return yield* runEffect(
148
+ context,
149
+ "nyc-open-data",
150
+ tokens,
151
+ Effect.gen(function* () {
152
+ const response = yield* exportNycOpenDataDataset(request).pipe(
153
+ Effect.provide(context.runtime.soda3Layer)
154
+ )
155
+ return yield* writeResponseToFile(config.output, response)
156
+ })
157
+ )
158
+ })
159
+
160
+ const nycOpenDataCatalogSearchCommand = Command.make(
161
+ "search",
162
+ {
163
+ query: Flag.string("query").pipe(Flag.withDescription("Search text for NYC Open Data.")),
164
+ limit: Flag.integer("limit").pipe(
165
+ Flag.withDefault(10),
166
+ Flag.withDescription("Maximum catalog results to request. Defaults to 10.")
167
+ ),
168
+ dryRun: Flag.boolean("dry-run").pipe(
169
+ Flag.withDescription("Print the planned request without contacting the provider.")
170
+ )
171
+ },
172
+ handleNycOpenDataCatalogSearch
173
+ ).pipe(Command.withShortDescription("Search the NYC Open Data catalog."))
174
+
175
+ const nycOpenDataCatalogCommand = Command.make("catalog").pipe(
176
+ Command.withShortDescription("NYC Open Data catalog commands."),
177
+ Command.withSubcommands([nycOpenDataCatalogSearchCommand])
178
+ )
179
+
180
+ const nycOpenDataDatasetListCommand = Command.make("list", {}, handleNycOpenDataList).pipe(
181
+ Command.withShortDescription("List curated NYC Open Data descriptors.")
182
+ )
183
+
184
+ const nycOpenDataDatasetInfoCommand = Command.make(
185
+ "info",
186
+ {
187
+ datasetId: Flag.string("dataset").pipe(
188
+ Flag.withDescription("NYC Open Data Socrata dataset id.")
189
+ )
190
+ },
191
+ handleNycOpenDataInfo
192
+ ).pipe(Command.withShortDescription("Show NYC Open Data dataset metadata."))
193
+
194
+ const nycOpenDataDatasetQueryCommand = Command.make(
195
+ "query",
196
+ {
197
+ datasetId: Flag.string("dataset").pipe(
198
+ Flag.withDescription("NYC Open Data Socrata dataset id.")
199
+ ),
200
+ select: Flag.string("select").pipe(
201
+ Flag.withDescription("SoQL select expression or full SELECT query.")
202
+ ),
203
+ dryRun: Flag.boolean("dry-run").pipe(
204
+ Flag.withDescription("Print the planned request without contacting the provider.")
205
+ )
206
+ },
207
+ handleNycOpenDataQuery
208
+ ).pipe(Command.withShortDescription("Run a NYC Open Data SODA3 query."))
209
+
210
+ const nycOpenDataDatasetExportCommand = Command.make(
211
+ "export",
212
+ {
213
+ datasetId: Flag.string("dataset").pipe(
214
+ Flag.withDescription("NYC Open Data Socrata dataset id.")
215
+ ),
216
+ format: Flag.choice("format", ["csv", "json", "geojson"]).pipe(
217
+ Flag.withDefault("csv"),
218
+ Flag.withDescription("Export format. Defaults to csv.")
219
+ ),
220
+ output: Flag.string("output").pipe(
221
+ Flag.withDescription("Destination file path for the downloaded response body.")
222
+ ),
223
+ dryRun: Flag.boolean("dry-run").pipe(
224
+ Flag.withDescription("Print the planned request without contacting the provider.")
225
+ )
226
+ },
227
+ handleNycOpenDataExport
228
+ ).pipe(Command.withShortDescription("Export a NYC Open Data dataset."))
229
+
230
+ const nycOpenDataDatasetCommand = Command.make("dataset").pipe(
231
+ Command.withShortDescription("NYC Open Data dataset commands."),
232
+ Command.withSubcommands([
233
+ nycOpenDataDatasetListCommand,
234
+ nycOpenDataDatasetInfoCommand,
235
+ nycOpenDataDatasetQueryCommand,
236
+ nycOpenDataDatasetExportCommand
237
+ ])
238
+ )
239
+
240
+ export const nycOpenDataCommand = Command.make("nyc-open-data").pipe(
241
+ Command.withShortDescription("NYC Open Data commands."),
242
+ Command.withSubcommands([nycOpenDataCatalogCommand, nycOpenDataDatasetCommand])
243
+ )
@@ -0,0 +1,38 @@
1
+ import * as Effect from "effect/Effect"
2
+ import * as Command from "effect/unstable/cli/Command"
3
+ import * as Flag from "effect/unstable/cli/Flag"
4
+ import { localBinaryName } from "../index"
5
+ import { catalogCommand } from "./catalog"
6
+ import { mtaCommand } from "./mta"
7
+ import { nycDotCommand } from "./nyc-dot"
8
+ import { nycOpenDataCommand } from "./nyc-open-data"
9
+ import { failCommand } from "./shared"
10
+ import { socrataCommand } from "./socrata"
11
+ import { CliCommandContext } from "./types"
12
+
13
+ const handleMissingCommand = () =>
14
+ Effect.gen(function* () {
15
+ const context = yield* CliCommandContext
16
+ return yield* failCommand(
17
+ context,
18
+ "cli",
19
+ "missing-command",
20
+ `Run ${localBinaryName} --version --json for the current scaffold contract.`,
21
+ []
22
+ )
23
+ })
24
+
25
+ export const rootCommand = Command.make("ntk", {}, handleMissingCommand).pipe(
26
+ Command.withSharedFlags({
27
+ json: Flag.boolean("json").pipe(
28
+ Flag.withDescription("Write the stable JSON envelope instead of human output.")
29
+ )
30
+ }),
31
+ Command.withSubcommands([
32
+ socrataCommand,
33
+ catalogCommand,
34
+ mtaCommand,
35
+ nycOpenDataCommand,
36
+ nycDotCommand
37
+ ])
38
+ )
@@ -0,0 +1,144 @@
1
+ import * as Effect from "effect/Effect"
2
+ import { errorEnvelope, successEnvelope } from "../output"
3
+ import type { ApiFamily, CommandContext, CommandResult } from "./types"
4
+
5
+ export const writeJson = (output: unknown) => {
6
+ console.log(JSON.stringify(output))
7
+ }
8
+
9
+ export const commandLabel = (tokens: ReadonlyArray<string>) =>
10
+ tokens.length > 0 ? tokens.join(" ") : "(missing command)"
11
+
12
+ export const apiFamilyFor = (tokens: ReadonlyArray<string>): ApiFamily => {
13
+ switch (tokens[0]) {
14
+ case "socrata":
15
+ case "catalog":
16
+ return "socrata"
17
+ case "mta":
18
+ return "mta"
19
+ case "nyc-open-data":
20
+ return "nyc-open-data"
21
+ case "nyc-dot":
22
+ return "nyc-dot"
23
+ default:
24
+ return "cli"
25
+ }
26
+ }
27
+
28
+ export const failCommand = (
29
+ context: CommandContext,
30
+ apiFamily: ApiFamily,
31
+ code: string,
32
+ message: string,
33
+ tokens: ReadonlyArray<string>,
34
+ options?: {
35
+ readonly provider?: string
36
+ readonly retryable?: boolean
37
+ }
38
+ ) =>
39
+ Effect.sync(() => {
40
+ process.exitCode = 1
41
+ context.writeJson(
42
+ errorEnvelope(apiFamily, {
43
+ code,
44
+ message,
45
+ provider: options?.provider,
46
+ retryable: options?.retryable,
47
+ command: commandLabel(tokens)
48
+ })
49
+ )
50
+ })
51
+
52
+ export const writeSuccess = (context: CommandContext, apiFamily: ApiFamily, data: unknown) =>
53
+ Effect.sync(() => {
54
+ context.writeJson(successEnvelope(apiFamily, data))
55
+ })
56
+
57
+ const writeFailure = (
58
+ context: CommandContext,
59
+ apiFamily: ApiFamily,
60
+ code: string,
61
+ message: string,
62
+ tokens: ReadonlyArray<string>
63
+ ) =>
64
+ Effect.sync(() => {
65
+ process.exitCode = 1
66
+ context.writeJson(
67
+ errorEnvelope(apiFamily, {
68
+ code,
69
+ message,
70
+ command: commandLabel(tokens)
71
+ })
72
+ )
73
+ })
74
+
75
+ export const missingOption = (
76
+ context: CommandContext,
77
+ tokens: ReadonlyArray<string>,
78
+ option: string
79
+ ) =>
80
+ failCommand(
81
+ context,
82
+ apiFamilyFor(tokens),
83
+ "missing-option",
84
+ `Missing required option --${option}.`,
85
+ tokens
86
+ )
87
+
88
+ export const soqlFromSelect = (select: string) =>
89
+ select.trim().toUpperCase().startsWith("SELECT ") ? select : `SELECT ${select}`
90
+
91
+ const taggedMessage = (error: unknown) => {
92
+ if (typeof error === "object" && error !== null && "message" in error) {
93
+ const message = error.message
94
+ if (typeof message === "string") {
95
+ return message
96
+ }
97
+ }
98
+
99
+ return "Command failed."
100
+ }
101
+
102
+ const taggedCode = (error: unknown) => {
103
+ if (typeof error === "object" && error !== null && "_tag" in error) {
104
+ const tag = error._tag
105
+ if (typeof tag === "string") {
106
+ return tag
107
+ .replace(/([a-z0-9])([A-Z])/g, "$1-$2")
108
+ .replace(/_/g, "-")
109
+ .toLowerCase()
110
+ }
111
+ }
112
+
113
+ return "command-failed"
114
+ }
115
+
116
+ export const runEffect = <A>(
117
+ context: CommandContext,
118
+ apiFamily: ApiFamily,
119
+ tokens: ReadonlyArray<string>,
120
+ effect: Effect.Effect<A, unknown>
121
+ ) =>
122
+ effect.pipe(
123
+ Effect.match({
124
+ onFailure: (error): CommandResult<A> => ({
125
+ _tag: "Failure",
126
+ error
127
+ }),
128
+ onSuccess: (data): CommandResult<A> => ({
129
+ _tag: "Success",
130
+ data
131
+ })
132
+ }),
133
+ Effect.flatMap((result) =>
134
+ result._tag === "Success"
135
+ ? writeSuccess(context, apiFamily, result.data)
136
+ : writeFailure(
137
+ context,
138
+ apiFamily,
139
+ taggedCode(result.error),
140
+ taggedMessage(result.error),
141
+ tokens
142
+ )
143
+ )
144
+ )