@nyc-transit-kit/cli 0.1.3 → 0.2.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/README.md +25 -2
- package/package.json +7 -7
- package/src/commands/mta.ts +74 -592
- package/src/commands/nyc-dot.ts +53 -196
- package/src/commands/nyc-open-data.ts +51 -167
- package/src/commands/root.ts +2 -4
- package/src/commands/shared.ts +92 -132
- package/src/commands/socrata.ts +72 -41
- package/src/files.ts +23 -1
- package/src/main.ts +15 -26
- package/src/runtime.ts +28 -2
- package/src/commands/catalog.ts +0 -59
- package/src/commands/soda3-shared.ts +0 -103
- package/src/commands/types.ts +0 -33
- package/src/config.ts +0 -21
- package/src/output.ts +0 -39
package/src/commands/shared.ts
CHANGED
|
@@ -1,144 +1,104 @@
|
|
|
1
|
+
// biome-ignore-all format: first-wave CLI compression budget
|
|
2
|
+
import { releaseVersion, schemaVersion } from "@nyc-transit-kit/contracts/metadata"
|
|
3
|
+
import type { Soda3ExportFormat } from "@nyc-transit-kit/contracts/soda3"
|
|
4
|
+
import type { GtfsRealtimeDecoder } from "@nyc-transit-kit/mta/gtfs-realtime"
|
|
5
|
+
import type { Soda3ClientConfig } from "@nyc-transit-kit/soda3/client"
|
|
6
|
+
import { buildExportUrl, buildQueryUrl } from "@nyc-transit-kit/soda3/endpoints"
|
|
7
|
+
import * as Context from "effect/Context"
|
|
1
8
|
import * as Effect from "effect/Effect"
|
|
2
|
-
import
|
|
3
|
-
import
|
|
9
|
+
import type * as Layer from "effect/Layer"
|
|
10
|
+
import * as Command from "effect/unstable/cli/Command"
|
|
11
|
+
import * as Flag from "effect/unstable/cli/Flag"
|
|
12
|
+
import type * as HttpClient from "effect/unstable/http/HttpClient"
|
|
13
|
+
import { buildCommit, buildTarget } from "../build-info"
|
|
14
|
+
import { atomicWrite } from "../files"
|
|
4
15
|
|
|
5
|
-
export const
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
export
|
|
13
|
-
|
|
14
|
-
|
|
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
|
-
})
|
|
16
|
+
export const packageVersion = releaseVersion
|
|
17
|
+
export const packageBuildCommit = buildCommit
|
|
18
|
+
export const packageBuildTarget = buildTarget
|
|
19
|
+
export type ApiFamily = "socrata" | "mta" | "nyc-open-data" | "nyc-dot" | "cli"
|
|
20
|
+
const meta = (apiFamily: ApiFamily) => ({ apiFamily, generatedAt: new Date().toISOString(), schemaVersion })
|
|
21
|
+
export const successEnvelope = (apiFamily: ApiFamily, data: unknown) => ({ ok: true, data, meta: meta(apiFamily) })
|
|
22
|
+
export const errorEnvelope = (apiFamily: ApiFamily, error: { readonly code: string; readonly message: string; readonly provider?: string; readonly retryable?: boolean; readonly command?: string }) => ({ ok: false, error, meta: meta(apiFamily) })
|
|
23
|
+
export type CliRuntime = { readonly soda3Layer: Layer.Layer<Soda3ClientConfig | HttpClient.HttpClient>; readonly mtaLayer: Layer.Layer<HttpClient.HttpClient>; readonly mtaRealtimeLayer: Layer.Layer<HttpClient.HttpClient | GtfsRealtimeDecoder> }
|
|
24
|
+
export type CommandContext = { readonly runtime: CliRuntime; readonly writeJson: (output: unknown) => void }
|
|
25
|
+
export class CliCommandContext extends Context.Service<CliCommandContext, CommandContext>()("@nyc-transit-kit/cli/CliCommandContext") {}
|
|
56
26
|
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
)
|
|
27
|
+
type DryRunExtra = Readonly<Record<string, unknown>>
|
|
28
|
+
const flag = (name: string, description: string) => Flag.string(name).pipe(Flag.withDescription(description))
|
|
29
|
+
export const dryRunFlag = Flag.boolean("dry-run").pipe(Flag.withDescription("Print the planned request without contacting the provider."))
|
|
30
|
+
export const outputFlag = flag("output", "Destination file path for the downloaded response body.")
|
|
31
|
+
export const selectFlag = flag("select", "SoQL select expression or full SELECT query.")
|
|
32
|
+
export const domainFlag = flag("domain", "Socrata domain without protocol, such as data.cityofnewyork.us.")
|
|
33
|
+
export const formatFlag = Flag.choice("format", ["csv", "json", "geojson"]).pipe(Flag.withDefault("csv"), Flag.withDescription("Export format. Defaults to csv."))
|
|
34
|
+
export const writeJson = (output: unknown) => console.log(JSON.stringify(output))
|
|
35
|
+
export const commandLabel = (tokens: ReadonlyArray<string>) => (tokens.length > 0 ? tokens.join(" ") : "(missing command)")
|
|
36
|
+
export const apiFamilyFor = (tokens: ReadonlyArray<string>): ApiFamily => tokens[0] === "socrata" || tokens[0] === "catalog" ? "socrata" : tokens[0] === "mta" || tokens[0] === "nyc-open-data" || tokens[0] === "nyc-dot" ? tokens[0] : "cli"
|
|
37
|
+
export const failCommand = (context: CommandContext, apiFamily: ApiFamily, code: string, message: string, tokens: ReadonlyArray<string>, options?: { readonly provider?: string; readonly retryable?: boolean }) =>
|
|
64
38
|
Effect.sync(() => {
|
|
65
39
|
process.exitCode = 1
|
|
66
|
-
context.writeJson(
|
|
67
|
-
errorEnvelope(apiFamily, {
|
|
68
|
-
code,
|
|
69
|
-
message,
|
|
70
|
-
command: commandLabel(tokens)
|
|
71
|
-
})
|
|
72
|
-
)
|
|
40
|
+
context.writeJson(errorEnvelope(apiFamily, { code, message, provider: options?.provider, retryable: options?.retryable, command: commandLabel(tokens) }))
|
|
73
41
|
})
|
|
42
|
+
export const writeSuccess = (context: CommandContext, apiFamily: ApiFamily, data: unknown) => Effect.sync(() => context.writeJson(successEnvelope(apiFamily, data)))
|
|
43
|
+
export const missingOption = (context: CommandContext, tokens: ReadonlyArray<string>, option: string) => failCommand(context, apiFamilyFor(tokens), "missing-option", `Missing required option --${option}.`, tokens)
|
|
44
|
+
export const soqlFromSelect = (select: string) => select.trim().toUpperCase().startsWith("SELECT ") ? select : `SELECT ${select}`
|
|
45
|
+
export const fileWriteError = (cause: unknown) => ({ _tag: "FileWriteError", message: cause instanceof Error ? cause.message : "File write failed." })
|
|
46
|
+
const field = (error: unknown, name: "_tag" | "message") => {
|
|
47
|
+
if (typeof error !== "object" || error === null) return undefined
|
|
48
|
+
if (name === "_tag" && "_tag" in error && typeof error._tag === "string") return error._tag
|
|
49
|
+
if (name === "message" && "message" in error && typeof error.message === "string") return error.message
|
|
50
|
+
return undefined
|
|
51
|
+
}
|
|
52
|
+
const taggedMessage = (error: unknown) => field(error, "message") ?? "Command failed."
|
|
53
|
+
const taggedCode = (error: unknown) => field(error, "_tag")?.replace(/([a-z0-9])([A-Z])/g, "$1-$2").replace(/_/g, "-").toLowerCase() ?? "command-failed"
|
|
54
|
+
export const runEffect = <A>(context: CommandContext, apiFamily: ApiFamily, tokens: ReadonlyArray<string>, effect: Effect.Effect<A, unknown>) =>
|
|
55
|
+
effect.pipe(Effect.matchEffect({ onFailure: (error) => failCommand(context, apiFamily, taggedCode(error), taggedMessage(error), tokens), onSuccess: (data) => writeSuccess(context, apiFamily, data) }))
|
|
74
56
|
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
) =>
|
|
80
|
-
|
|
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
|
-
}
|
|
57
|
+
type QueryDryRun = { readonly context: CommandContext; readonly apiFamily: ApiFamily; readonly tokens: ReadonlyArray<string>; readonly domain: string; readonly datasetId: string; readonly query: string; readonly extra?: DryRunExtra }
|
|
58
|
+
type ExportDryRun = { readonly context: CommandContext; readonly apiFamily: ApiFamily; readonly tokens: ReadonlyArray<string>; readonly domain: string; readonly datasetId: string; readonly format: Soda3ExportFormat; readonly output: string; readonly extra?: DryRunExtra }
|
|
59
|
+
export const writeSoda3QueryDryRun = (config: QueryDryRun) =>
|
|
60
|
+
runEffect(config.context, config.apiFamily, config.tokens, buildQueryUrl({ domain: config.domain, datasetId: config.datasetId, query: config.query }).pipe(Effect.map((url) => ({ dryRun: true, method: "POST", url: url.toString(), ...config.extra, body: { query: config.query } }))))
|
|
61
|
+
export const writeSoda3ExportDryRun = (config: ExportDryRun) =>
|
|
62
|
+
runEffect(config.context, config.apiFamily, config.tokens, buildExportUrl({ domain: config.domain, datasetId: config.datasetId, format: config.format }).pipe(Effect.map((url) => ({ dryRun: true, method: "POST", url: url.toString(), ...config.extra, output: config.output }))))
|
|
98
63
|
|
|
99
|
-
|
|
64
|
+
const contentLengthOf = (headers: Headers) => {
|
|
65
|
+
const parsed = Number(headers.get("content-length") ?? undefined)
|
|
66
|
+
return Number.isInteger(parsed) && parsed >= 0 ? parsed : undefined
|
|
100
67
|
}
|
|
68
|
+
export const writeResponseToFile = (output: string, response: Response) =>
|
|
69
|
+
Effect.gen(function* () {
|
|
70
|
+
const contentType = response.headers.get("content-type") ?? undefined
|
|
71
|
+
const contentLength = contentLengthOf(response.headers)
|
|
72
|
+
yield* Effect.tryPromise({ try: () => atomicWrite(output, response), catch: fileWriteError })
|
|
73
|
+
return { output, status: response.status, byteLength: yield* Effect.sync(() => Bun.file(output).size), ...(contentType === undefined ? {} : { contentType }), ...(contentLength === undefined ? {} : { contentLength }) }
|
|
74
|
+
})
|
|
101
75
|
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
.toLowerCase()
|
|
110
|
-
}
|
|
111
|
-
}
|
|
76
|
+
type DatasetIdConfig = { readonly datasetId: string } | { readonly name: string }
|
|
77
|
+
type DatasetLookup<A> = { readonly payload: A } | { readonly failure: { readonly code: string; readonly message: string } }
|
|
78
|
+
type DatasetResolution = DatasetLookup<{ readonly datasetId: string; readonly extra?: DryRunExtra }>
|
|
79
|
+
type DatasetEffect<A> = Effect.Effect<A, unknown, Soda3ClientConfig | HttpClient.HttpClient>
|
|
80
|
+
export type DatasetCliFamily = { readonly family: ApiFamily; readonly tokens: ReadonlyArray<string>; readonly idConfigKey: "datasetId" | "name"; readonly idFlagName: string; readonly idFlagDescription: string; readonly domain: string; readonly listShortDescription: string; readonly infoShortDescription: string; readonly queryShortDescription: string; readonly exportShortDescription?: string; readonly listPayload: () => unknown; readonly infoPayload: (id: string) => DatasetLookup<unknown>; readonly resolveDataset: (id: string) => DatasetResolution; readonly query: (input: { readonly id: string; readonly query: string }) => DatasetEffect<unknown>; readonly exportResponse?: (input: { readonly id: string; readonly format: Soda3ExportFormat }) => DatasetEffect<Response> }
|
|
81
|
+
const datasetIdentifier = (config: DatasetIdConfig) => ("datasetId" in config ? config.datasetId : config.name)
|
|
82
|
+
const withShort = <C extends Command.Command.Any>(command: C, description: string) => command.pipe(Command.withShortDescription(description))
|
|
112
83
|
|
|
113
|
-
|
|
84
|
+
export const makeDatasetCommands = (spec: DatasetCliFamily): ReadonlyArray<Command.Command.Any> => {
|
|
85
|
+
const listCommand = withShort(Command.make("list", {}, () => Effect.gen(function* () { const context = yield* CliCommandContext; return yield* writeSuccess(context, spec.family, spec.listPayload()) })), spec.listShortDescription)
|
|
86
|
+
const handleInfo = (config: DatasetIdConfig) => Effect.gen(function* () { const context = yield* CliCommandContext; const result = spec.infoPayload(datasetIdentifier(config)); return yield* "failure" in result ? failCommand(context, spec.family, result.failure.code, result.failure.message, [...spec.tokens, "info"]) : writeSuccess(context, spec.family, result.payload) })
|
|
87
|
+
const idFlag = flag(spec.idFlagName, spec.idFlagDescription)
|
|
88
|
+
const infoCommand = spec.idConfigKey === "datasetId" ? withShort(Command.make("info", { datasetId: idFlag }, handleInfo), spec.infoShortDescription) : withShort(Command.make("info", { name: idFlag }, handleInfo), spec.infoShortDescription)
|
|
89
|
+
const handleQuery = (config: DatasetIdConfig & { readonly select: string; readonly dryRun: boolean }) => Effect.gen(function* () {
|
|
90
|
+
const context = yield* CliCommandContext; const id = datasetIdentifier(config); const tokens = [...spec.tokens, "query"]; const query = soqlFromSelect(config.select); const resolution = spec.resolveDataset(id)
|
|
91
|
+
if ("failure" in resolution) return yield* failCommand(context, spec.family, resolution.failure.code, resolution.failure.message, tokens)
|
|
92
|
+
return yield* config.dryRun ? writeSoda3QueryDryRun({ context, apiFamily: spec.family, tokens, domain: spec.domain, datasetId: resolution.payload.datasetId, query, extra: resolution.payload.extra }) : runEffect(context, spec.family, tokens, spec.query({ id, query }).pipe(Effect.provide(context.runtime.soda3Layer)))
|
|
93
|
+
})
|
|
94
|
+
const queryCommand = spec.idConfigKey === "datasetId" ? withShort(Command.make("query", { datasetId: idFlag, select: selectFlag, dryRun: dryRunFlag }, handleQuery), spec.queryShortDescription) : withShort(Command.make("query", { name: idFlag, select: selectFlag, dryRun: dryRunFlag }, handleQuery), spec.queryShortDescription)
|
|
95
|
+
const exportResponse = spec.exportResponse
|
|
96
|
+
if (exportResponse === undefined) return [listCommand, infoCommand, queryCommand]
|
|
97
|
+
const handleExport = (config: DatasetIdConfig & { readonly format: Soda3ExportFormat; readonly output: string; readonly dryRun: boolean }) => Effect.gen(function* () {
|
|
98
|
+
const context = yield* CliCommandContext; const id = datasetIdentifier(config); const tokens = [...spec.tokens, "export"]; const resolution = spec.resolveDataset(id)
|
|
99
|
+
if ("failure" in resolution) return yield* failCommand(context, spec.family, resolution.failure.code, resolution.failure.message, tokens)
|
|
100
|
+
return yield* config.dryRun ? writeSoda3ExportDryRun({ context, apiFamily: spec.family, tokens, domain: spec.domain, datasetId: resolution.payload.datasetId, format: config.format, output: config.output, extra: resolution.payload.extra }) : runEffect(context, spec.family, tokens, exportResponse({ id, format: config.format }).pipe(Effect.provide(context.runtime.soda3Layer), Effect.flatMap((response) => writeResponseToFile(config.output, response))))
|
|
101
|
+
})
|
|
102
|
+
const exportCommand = spec.idConfigKey === "datasetId" ? withShort(Command.make("export", { datasetId: idFlag, format: formatFlag, output: outputFlag, dryRun: dryRunFlag }, handleExport), spec.exportShortDescription ?? "Export a dataset.") : withShort(Command.make("export", { name: idFlag, format: formatFlag, output: outputFlag, dryRun: dryRunFlag }, handleExport), spec.exportShortDescription ?? "Export a dataset.")
|
|
103
|
+
return [listCommand, infoCommand, queryCommand, exportCommand]
|
|
114
104
|
}
|
|
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
|
-
)
|
package/src/commands/socrata.ts
CHANGED
|
@@ -1,19 +1,25 @@
|
|
|
1
1
|
import type { Soda3ExportFormat } from "@nyc-transit-kit/contracts/soda3"
|
|
2
|
+
import { buildCatalogSearchUrl, catalogSearch } from "@nyc-transit-kit/soda3/catalog"
|
|
2
3
|
import { buildExportUrl } from "@nyc-transit-kit/soda3/endpoints"
|
|
3
4
|
import { exportResponse } from "@nyc-transit-kit/soda3/export"
|
|
4
5
|
import { queryRows } from "@nyc-transit-kit/soda3/query"
|
|
5
6
|
import * as Effect from "effect/Effect"
|
|
6
7
|
import * as Command from "effect/unstable/cli/Command"
|
|
7
8
|
import * as Flag from "effect/unstable/cli/Flag"
|
|
8
|
-
import {
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
9
|
+
import {
|
|
10
|
+
CliCommandContext,
|
|
11
|
+
domainFlag,
|
|
12
|
+
dryRunFlag,
|
|
13
|
+
formatFlag,
|
|
14
|
+
outputFlag,
|
|
15
|
+
runEffect,
|
|
16
|
+
selectFlag,
|
|
17
|
+
soqlFromSelect,
|
|
18
|
+
writeResponseToFile,
|
|
19
|
+
writeSoda3ExportDryRun,
|
|
20
|
+
writeSoda3QueryDryRun,
|
|
21
|
+
writeSuccess
|
|
22
|
+
} from "./shared"
|
|
17
23
|
|
|
18
24
|
export const handleSocrataQuery = (config: {
|
|
19
25
|
readonly domain: string
|
|
@@ -135,21 +141,45 @@ export const handleSocrataRangeProbe = (config: {
|
|
|
135
141
|
)
|
|
136
142
|
})
|
|
137
143
|
|
|
144
|
+
const handleCatalogSearch = (config: {
|
|
145
|
+
readonly domain: string
|
|
146
|
+
readonly query: string
|
|
147
|
+
readonly dryRun: boolean
|
|
148
|
+
}) =>
|
|
149
|
+
Effect.gen(function* () {
|
|
150
|
+
const context = yield* CliCommandContext
|
|
151
|
+
const tokens = ["catalog", "search"]
|
|
152
|
+
const request = {
|
|
153
|
+
domain: config.domain,
|
|
154
|
+
query: config.query
|
|
155
|
+
}
|
|
156
|
+
const url = yield* buildCatalogSearchUrl(request)
|
|
157
|
+
|
|
158
|
+
if (config.dryRun) {
|
|
159
|
+
return yield* writeSuccess(context, "socrata", {
|
|
160
|
+
dryRun: true,
|
|
161
|
+
method: "GET",
|
|
162
|
+
url: url.toString()
|
|
163
|
+
})
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
return yield* runEffect(
|
|
167
|
+
context,
|
|
168
|
+
"socrata",
|
|
169
|
+
tokens,
|
|
170
|
+
catalogSearch(request).pipe(Effect.provide(context.runtime.soda3Layer))
|
|
171
|
+
)
|
|
172
|
+
})
|
|
173
|
+
|
|
138
174
|
const socrataQueryCommand = Command.make(
|
|
139
175
|
"query",
|
|
140
176
|
{
|
|
141
|
-
domain:
|
|
142
|
-
Flag.withDescription("Socrata domain without protocol, such as data.cityofnewyork.us.")
|
|
143
|
-
),
|
|
177
|
+
domain: domainFlag,
|
|
144
178
|
datasetId: Flag.string("dataset").pipe(
|
|
145
179
|
Flag.withDescription("Socrata dataset id, such as ycrg-ses3.")
|
|
146
180
|
),
|
|
147
|
-
select:
|
|
148
|
-
|
|
149
|
-
),
|
|
150
|
-
dryRun: Flag.boolean("dry-run").pipe(
|
|
151
|
-
Flag.withDescription("Print the planned request without contacting the provider.")
|
|
152
|
-
)
|
|
181
|
+
select: selectFlag,
|
|
182
|
+
dryRun: dryRunFlag
|
|
153
183
|
},
|
|
154
184
|
handleSocrataQuery
|
|
155
185
|
).pipe(Command.withShortDescription("Run a SODA3 query."))
|
|
@@ -157,22 +187,13 @@ const socrataQueryCommand = Command.make(
|
|
|
157
187
|
const socrataExportCommand = Command.make(
|
|
158
188
|
"export",
|
|
159
189
|
{
|
|
160
|
-
domain:
|
|
161
|
-
Flag.withDescription("Socrata domain without protocol, such as data.cityofnewyork.us.")
|
|
162
|
-
),
|
|
190
|
+
domain: domainFlag,
|
|
163
191
|
datasetId: Flag.string("dataset").pipe(
|
|
164
192
|
Flag.withDescription("Socrata dataset id, such as ycrg-ses3.")
|
|
165
193
|
),
|
|
166
|
-
format:
|
|
167
|
-
|
|
168
|
-
|
|
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
|
-
)
|
|
194
|
+
format: formatFlag,
|
|
195
|
+
output: outputFlag,
|
|
196
|
+
dryRun: dryRunFlag
|
|
176
197
|
},
|
|
177
198
|
handleSocrataExport
|
|
178
199
|
).pipe(Command.withShortDescription("Export a SODA3 dataset."))
|
|
@@ -180,22 +201,15 @@ const socrataExportCommand = Command.make(
|
|
|
180
201
|
const socrataRangeProbeCommand = Command.make(
|
|
181
202
|
"range-probe",
|
|
182
203
|
{
|
|
183
|
-
domain:
|
|
184
|
-
Flag.withDescription("Socrata domain without protocol, such as data.cityofnewyork.us.")
|
|
185
|
-
),
|
|
204
|
+
domain: domainFlag,
|
|
186
205
|
datasetId: Flag.string("dataset").pipe(
|
|
187
206
|
Flag.withDescription("Socrata dataset id, such as ycrg-ses3.")
|
|
188
207
|
),
|
|
189
|
-
format:
|
|
190
|
-
Flag.withDefault("csv"),
|
|
191
|
-
Flag.withDescription("Export format. Defaults to csv.")
|
|
192
|
-
),
|
|
208
|
+
format: formatFlag,
|
|
193
209
|
rangeEnd: Flag.integer("range-end").pipe(
|
|
194
210
|
Flag.withDescription("Inclusive byte offset for a bytes=0-N range probe.")
|
|
195
211
|
),
|
|
196
|
-
dryRun:
|
|
197
|
-
Flag.withDescription("Print the planned request without contacting the provider.")
|
|
198
|
-
)
|
|
212
|
+
dryRun: dryRunFlag
|
|
199
213
|
},
|
|
200
214
|
handleSocrataRangeProbe
|
|
201
215
|
).pipe(Command.withShortDescription("Probe SODA3 export range support."))
|
|
@@ -204,3 +218,20 @@ export const socrataCommand = Command.make("socrata").pipe(
|
|
|
204
218
|
Command.withShortDescription("Socrata SODA3 commands."),
|
|
205
219
|
Command.withSubcommands([socrataQueryCommand, socrataExportCommand, socrataRangeProbeCommand])
|
|
206
220
|
)
|
|
221
|
+
|
|
222
|
+
const catalogSearchCommand = Command.make(
|
|
223
|
+
"search",
|
|
224
|
+
{
|
|
225
|
+
domain: domainFlag,
|
|
226
|
+
query: Flag.string("query").pipe(
|
|
227
|
+
Flag.withDescription("Search text for Socrata Discovery API.")
|
|
228
|
+
),
|
|
229
|
+
dryRun: dryRunFlag
|
|
230
|
+
},
|
|
231
|
+
handleCatalogSearch
|
|
232
|
+
).pipe(Command.withShortDescription("Search a Socrata catalog."))
|
|
233
|
+
|
|
234
|
+
export const catalogCommand = Command.make("catalog").pipe(
|
|
235
|
+
Command.withShortDescription("Socrata catalog commands."),
|
|
236
|
+
Command.withSubcommands([catalogSearchCommand])
|
|
237
|
+
)
|
package/src/files.ts
CHANGED
|
@@ -22,7 +22,29 @@ type StagedWrite = AtomicWriteEntry & {
|
|
|
22
22
|
|
|
23
23
|
const writeBody = async (path: string, body: AtomicWriteBody) => {
|
|
24
24
|
if (body instanceof Response) {
|
|
25
|
-
|
|
25
|
+
if (body.body === null) {
|
|
26
|
+
await Bun.write(path, new Uint8Array())
|
|
27
|
+
return
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const reader = body.body.getReader()
|
|
31
|
+
const writer = Bun.file(path).writer()
|
|
32
|
+
|
|
33
|
+
try {
|
|
34
|
+
while (true) {
|
|
35
|
+
const chunk = await reader.read()
|
|
36
|
+
if (chunk.done) {
|
|
37
|
+
break
|
|
38
|
+
}
|
|
39
|
+
writer.write(chunk.value)
|
|
40
|
+
}
|
|
41
|
+
writer.end()
|
|
42
|
+
} catch (cause) {
|
|
43
|
+
writer.end()
|
|
44
|
+
throw cause
|
|
45
|
+
} finally {
|
|
46
|
+
reader.releaseLock()
|
|
47
|
+
}
|
|
26
48
|
} else {
|
|
27
49
|
await Bun.write(path, body)
|
|
28
50
|
}
|
package/src/main.ts
CHANGED
|
@@ -5,16 +5,18 @@ import * as Effect from "effect/Effect"
|
|
|
5
5
|
import * as CliError from "effect/unstable/cli/CliError"
|
|
6
6
|
import * as Command from "effect/unstable/cli/Command"
|
|
7
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
8
|
import {
|
|
9
|
+
apiFamilyFor,
|
|
10
|
+
CliCommandContext,
|
|
11
|
+
commandLabel,
|
|
12
12
|
errorEnvelope,
|
|
13
13
|
packageBuildCommit,
|
|
14
14
|
packageBuildTarget,
|
|
15
15
|
packageVersion,
|
|
16
|
-
successEnvelope
|
|
17
|
-
|
|
16
|
+
successEnvelope,
|
|
17
|
+
writeJson
|
|
18
|
+
} from "./commands/shared"
|
|
19
|
+
import { localBinaryName } from "./index"
|
|
18
20
|
import { makeCliRuntime } from "./runtime"
|
|
19
21
|
|
|
20
22
|
type CommandRunResult =
|
|
@@ -34,27 +36,14 @@ type JsonErrorDetails = {
|
|
|
34
36
|
|
|
35
37
|
const noop = () => undefined
|
|
36
38
|
|
|
37
|
-
const
|
|
38
|
-
assert
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
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
|
-
}
|
|
39
|
+
const consoleMethodNames =
|
|
40
|
+
"assert clear count countReset debug dir dirxml error group groupCollapsed groupEnd info log table time timeEnd timeLog trace warn".split(
|
|
41
|
+
" "
|
|
42
|
+
) as ReadonlyArray<keyof Console.Console>
|
|
43
|
+
|
|
44
|
+
const quietConsole = Object.fromEntries(
|
|
45
|
+
consoleMethodNames.map((name) => [name, noop])
|
|
46
|
+
) as unknown as Console.Console
|
|
58
47
|
|
|
59
48
|
const stripRootCommand = (path: ReadonlyArray<string>) =>
|
|
60
49
|
path[0] === localBinaryName ? path.slice(1) : path
|
package/src/runtime.ts
CHANGED
|
@@ -1,5 +1,31 @@
|
|
|
1
|
-
import
|
|
2
|
-
import {
|
|
1
|
+
import { MtaHttpLive, mtaConfigLayer } from "@nyc-transit-kit/mta/client"
|
|
2
|
+
import { GtfsRealtimeDecoder } from "@nyc-transit-kit/mta/gtfs-realtime"
|
|
3
|
+
import { Soda3ClientConfig, Soda3HttpLive } from "@nyc-transit-kit/soda3/client"
|
|
4
|
+
import * as Layer from "effect/Layer"
|
|
5
|
+
import type { CliRuntime } from "./commands/shared"
|
|
6
|
+
|
|
7
|
+
interface Soda3CliLayerConfig {
|
|
8
|
+
readonly appToken?: string
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
const soda3CliLayer = (config: Soda3CliLayerConfig) =>
|
|
12
|
+
Layer.mergeAll(
|
|
13
|
+
Layer.succeed(Soda3ClientConfig)({
|
|
14
|
+
appToken: config.appToken,
|
|
15
|
+
retryTimes: 2,
|
|
16
|
+
retryDelayMs: 250
|
|
17
|
+
}),
|
|
18
|
+
Soda3HttpLive
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
const mtaCliConfigLayer = mtaConfigLayer({
|
|
22
|
+
retryTimes: 2,
|
|
23
|
+
retryDelayMs: 250
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
const mtaCliLayer = Layer.mergeAll(MtaHttpLive, mtaCliConfigLayer)
|
|
27
|
+
|
|
28
|
+
const mtaRealtimeCliLayer = Layer.mergeAll(MtaHttpLive, mtaCliConfigLayer, GtfsRealtimeDecoder.Live)
|
|
3
29
|
|
|
4
30
|
export const makeCliRuntime = (env: Readonly<Record<string, string | undefined>>): CliRuntime => {
|
|
5
31
|
const soda3Layer = soda3CliLayer({
|
package/src/commands/catalog.ts
DELETED
|
@@ -1,59 +0,0 @@
|
|
|
1
|
-
import { buildCatalogSearchUrl, catalogSearch } from "@nyc-transit-kit/soda3/catalog"
|
|
2
|
-
import * as Effect from "effect/Effect"
|
|
3
|
-
import * as Command from "effect/unstable/cli/Command"
|
|
4
|
-
import * as Flag from "effect/unstable/cli/Flag"
|
|
5
|
-
import { runEffect, writeSuccess } from "./shared"
|
|
6
|
-
import { CliCommandContext } from "./types"
|
|
7
|
-
|
|
8
|
-
export const catalogCommandPaths: ReadonlyArray<ReadonlyArray<string>> = [["catalog", "search"]]
|
|
9
|
-
|
|
10
|
-
export const handleCatalogSearch = (config: {
|
|
11
|
-
readonly domain: string
|
|
12
|
-
readonly query: string
|
|
13
|
-
readonly dryRun: boolean
|
|
14
|
-
}) =>
|
|
15
|
-
Effect.gen(function* () {
|
|
16
|
-
const context = yield* CliCommandContext
|
|
17
|
-
const tokens = ["catalog", "search"]
|
|
18
|
-
const request = {
|
|
19
|
-
domain: config.domain,
|
|
20
|
-
query: config.query
|
|
21
|
-
}
|
|
22
|
-
const url = yield* buildCatalogSearchUrl(request)
|
|
23
|
-
|
|
24
|
-
if (config.dryRun) {
|
|
25
|
-
return yield* writeSuccess(context, "socrata", {
|
|
26
|
-
dryRun: true,
|
|
27
|
-
method: "GET",
|
|
28
|
-
url: url.toString()
|
|
29
|
-
})
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
return yield* runEffect(
|
|
33
|
-
context,
|
|
34
|
-
"socrata",
|
|
35
|
-
tokens,
|
|
36
|
-
catalogSearch(request).pipe(Effect.provide(context.runtime.soda3Layer))
|
|
37
|
-
)
|
|
38
|
-
})
|
|
39
|
-
|
|
40
|
-
const catalogSearchCommand = Command.make(
|
|
41
|
-
"search",
|
|
42
|
-
{
|
|
43
|
-
domain: Flag.string("domain").pipe(
|
|
44
|
-
Flag.withDescription("Socrata domain without protocol, such as data.cityofnewyork.us.")
|
|
45
|
-
),
|
|
46
|
-
query: Flag.string("query").pipe(
|
|
47
|
-
Flag.withDescription("Search text for Socrata Discovery API.")
|
|
48
|
-
),
|
|
49
|
-
dryRun: Flag.boolean("dry-run").pipe(
|
|
50
|
-
Flag.withDescription("Print the planned request without contacting the provider.")
|
|
51
|
-
)
|
|
52
|
-
},
|
|
53
|
-
handleCatalogSearch
|
|
54
|
-
).pipe(Command.withShortDescription("Search a Socrata catalog."))
|
|
55
|
-
|
|
56
|
-
export const catalogCommand = Command.make("catalog").pipe(
|
|
57
|
-
Command.withShortDescription("Socrata catalog commands."),
|
|
58
|
-
Command.withSubcommands([catalogSearchCommand])
|
|
59
|
-
)
|