@nyc-transit-kit/cli 0.1.4 → 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.
@@ -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 { errorEnvelope, successEnvelope } from "../output"
3
- import type { ApiFamily, CommandContext, CommandResult } from "./types"
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 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
- })
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
- const writeFailure = (
58
- context: CommandContext,
59
- apiFamily: ApiFamily,
60
- code: string,
61
- message: string,
62
- tokens: ReadonlyArray<string>
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
- 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
- }
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
- return "Command failed."
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
- 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
- }
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
- return "command-failed"
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
- )
@@ -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 { 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
- ]
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: Flag.string("domain").pipe(
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: 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
- )
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: Flag.string("domain").pipe(
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: 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
- )
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: Flag.string("domain").pipe(
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: Flag.choice("format", ["csv", "json", "geojson"]).pipe(
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: Flag.boolean("dry-run").pipe(
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
- await Bun.file(path).write(body)
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
- } from "./output"
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 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
- }
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 type { CliRuntime } from "./commands/types"
2
- import { mtaCliLayer, mtaRealtimeCliLayer, soda3CliLayer } from "./config"
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({
@@ -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
- )