@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.
- package/LICENSE +21 -0
- package/README.md +5 -0
- package/bin/ntk +3 -0
- package/bin/nyc-transit +3 -0
- package/package.json +58 -0
- package/scripts/build-binary.ts +113 -0
- package/scripts/build.ts +1 -0
- package/src/build-info.ts +2 -0
- package/src/commands/catalog.ts +59 -0
- package/src/commands/mta.ts +610 -0
- package/src/commands/nyc-dot.ts +212 -0
- package/src/commands/nyc-open-data.ts +243 -0
- package/src/commands/root.ts +38 -0
- package/src/commands/shared.ts +144 -0
- package/src/commands/socrata.ts +206 -0
- package/src/commands/soda3-shared.ts +103 -0
- package/src/commands/types.ts +33 -0
- package/src/config.ts +21 -0
- package/src/files.ts +27 -0
- package/src/index.ts +2 -0
- package/src/main.ts +205 -0
- package/src/output.ts +39 -0
- package/src/runtime.ts +14 -0
|
@@ -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
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
|
+
}
|