@knpkv/clockify-api-client 0.3.0 → 1.0.1

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.
Files changed (43) hide show
  1. package/CHANGELOG.md +29 -0
  2. package/README.md +92 -80
  3. package/dist/ClockifyApiClient.d.ts +44 -45
  4. package/dist/ClockifyApiClient.d.ts.map +1 -1
  5. package/dist/ClockifyApiClient.js +52 -121
  6. package/dist/ClockifyApiClient.js.map +1 -1
  7. package/dist/generated/ClockifyApi.d.ts +14432 -0
  8. package/dist/generated/ClockifyApi.d.ts.map +1 -0
  9. package/dist/generated/ClockifyApi.js +1239 -0
  10. package/dist/generated/ClockifyApi.js.map +1 -0
  11. package/dist/index.d.ts +4 -6
  12. package/dist/index.d.ts.map +1 -1
  13. package/dist/index.js +3 -4
  14. package/dist/index.js.map +1 -1
  15. package/package.json +15 -21
  16. package/scripts/regenerate.ts +182 -201
  17. package/scripts/tsconfig.json +13 -0
  18. package/src/ClockifyApiClient.ts +125 -226
  19. package/src/generated/ClockifyApi.ts +3020 -0
  20. package/src/index.ts +10 -14
  21. package/test/ClockifyApiClient.test.ts +195 -0
  22. package/vitest.config.ts +7 -0
  23. package/.specs/VERSION +0 -1
  24. package/.specs/clockify-v1.json +0 -31670
  25. package/.specs/clockify-v1.patch.json +0 -48
  26. package/dist/ClockifyApiError.d.ts +0 -11
  27. package/dist/ClockifyApiError.d.ts.map +0 -1
  28. package/dist/ClockifyApiError.js +0 -14
  29. package/dist/ClockifyApiError.js.map +0 -1
  30. package/dist/OpenApiFetchClient.d.ts +0 -55
  31. package/dist/OpenApiFetchClient.d.ts.map +0 -1
  32. package/dist/OpenApiFetchClient.js +0 -63
  33. package/dist/OpenApiFetchClient.js.map +0 -1
  34. package/dist/generated/index.d.ts +0 -2
  35. package/dist/generated/index.d.ts.map +0 -1
  36. package/dist/generated/index.js +0 -2
  37. package/dist/generated/index.js.map +0 -1
  38. package/dist/generated/schema.d.ts +0 -16204
  39. package/src/ClockifyApiError.ts +0 -17
  40. package/src/OpenApiFetchClient.ts +0 -92
  41. package/src/generated/index.ts +0 -1
  42. package/src/generated/schema.d.ts +0 -16204
  43. package/tsconfig.tsbuildinfo +0 -1
package/dist/index.d.ts CHANGED
@@ -1,12 +1,10 @@
1
1
  /**
2
- * Root barrel export for `@knpkv/clockify-api-client` -- openapi-fetch + Effect Clockify REST client.
2
+ * Root barrel export for the Schema-validated Effect Clockify client.
3
3
  *
4
4
  * @packageDocumentation
5
5
  */
6
- export { ClockifyApiClient, type ClockifyApiClientShape, layer } from "./ClockifyApiClient.js";
7
- export type { CreateTimeEntryParams, GetTimeEntriesParams, Project, StopTimeEntryParams, Tag, TimeEntry, TimeInterval, UpdateTimeEntryParams, User, Workspace } from "./ClockifyApiClient.js";
6
+ export { ClockifyApiClient, type ClockifyApiClientShape, type ClockifyClientError, layer, make } from "./ClockifyApiClient.js";
7
+ export type { AuthenticatedClockifyApi, CreateTimeEntryParams, GetTimeEntriesParams, Project, StopTimeEntryParams, Tag, TimeEntry, TimeInterval, UpdateTimeEntryParams, User, Workspace } from "./ClockifyApiClient.js";
8
8
  export { ClockifyApiConfig, type ClockifyApiConfigShape } from "./ClockifyApiConfig.js";
9
- export { ClockifyApiError } from "./ClockifyApiError.js";
10
- export { FetchClientError, makeOpenApiFetchClient, type OpenApiFetchClient, type SuccessData, toEffect } from "./OpenApiFetchClient.js";
11
- export type * as V1 from "./generated/index.js";
9
+ export * as ClockifyApi from "./generated/ClockifyApi.js";
12
10
  //# sourceMappingURL=index.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAEH,OAAO,EAAE,iBAAiB,EAAE,KAAK,sBAAsB,EAAE,KAAK,EAAE,MAAM,wBAAwB,CAAA;AAE9F,YAAY,EACV,qBAAqB,EACrB,oBAAoB,EACpB,OAAO,EACP,mBAAmB,EACnB,GAAG,EACH,SAAS,EACT,YAAY,EACZ,qBAAqB,EACrB,IAAI,EACJ,SAAS,EACV,MAAM,wBAAwB,CAAA;AAE/B,OAAO,EAAE,iBAAiB,EAAE,KAAK,sBAAsB,EAAE,MAAM,wBAAwB,CAAA;AAEvF,OAAO,EAAE,gBAAgB,EAAE,MAAM,uBAAuB,CAAA;AAExD,OAAO,EACL,gBAAgB,EAChB,sBAAsB,EACtB,KAAK,kBAAkB,EACvB,KAAK,WAAW,EAChB,QAAQ,EACT,MAAM,yBAAyB,CAAA;AAGhC,YAAY,KAAK,EAAE,MAAM,sBAAsB,CAAA"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAEH,OAAO,EACL,iBAAiB,EACjB,KAAK,sBAAsB,EAC3B,KAAK,mBAAmB,EACxB,KAAK,EACL,IAAI,EACL,MAAM,wBAAwB,CAAA;AAE/B,YAAY,EACV,wBAAwB,EACxB,qBAAqB,EACrB,oBAAoB,EACpB,OAAO,EACP,mBAAmB,EACnB,GAAG,EACH,SAAS,EACT,YAAY,EACZ,qBAAqB,EACrB,IAAI,EACJ,SAAS,EACV,MAAM,wBAAwB,CAAA;AAE/B,OAAO,EAAE,iBAAiB,EAAE,KAAK,sBAAsB,EAAE,MAAM,wBAAwB,CAAA;AAEvF,OAAO,KAAK,WAAW,MAAM,4BAA4B,CAAA"}
package/dist/index.js CHANGED
@@ -1,10 +1,9 @@
1
1
  /**
2
- * Root barrel export for `@knpkv/clockify-api-client` -- openapi-fetch + Effect Clockify REST client.
2
+ * Root barrel export for the Schema-validated Effect Clockify client.
3
3
  *
4
4
  * @packageDocumentation
5
5
  */
6
- export { ClockifyApiClient, layer } from "./ClockifyApiClient.js";
6
+ export { ClockifyApiClient, layer, make } from "./ClockifyApiClient.js";
7
7
  export { ClockifyApiConfig } from "./ClockifyApiConfig.js";
8
- export { ClockifyApiError } from "./ClockifyApiError.js";
9
- export { FetchClientError, makeOpenApiFetchClient, toEffect } from "./OpenApiFetchClient.js";
8
+ export * as ClockifyApi from "./generated/ClockifyApi.js";
10
9
  //# sourceMappingURL=index.js.map
package/dist/index.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAEH,OAAO,EAAE,iBAAiB,EAA+B,KAAK,EAAE,MAAM,wBAAwB,CAAA;AAe9F,OAAO,EAAE,iBAAiB,EAA+B,MAAM,wBAAwB,CAAA;AAEvF,OAAO,EAAE,gBAAgB,EAAE,MAAM,uBAAuB,CAAA;AAExD,OAAO,EACL,gBAAgB,EAChB,sBAAsB,EAGtB,QAAQ,EACT,MAAM,yBAAyB,CAAA"}
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAEH,OAAO,EACL,iBAAiB,EAGjB,KAAK,EACL,IAAI,EACL,MAAM,wBAAwB,CAAA;AAgB/B,OAAO,EAAE,iBAAiB,EAA+B,MAAM,wBAAwB,CAAA;AAEvF,OAAO,KAAK,WAAW,MAAM,4BAA4B,CAAA"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@knpkv/clockify-api-client",
3
- "version": "0.3.0",
3
+ "version": "1.0.1",
4
4
  "description": "Effect-based Clockify REST API client",
5
5
  "license": "MIT",
6
6
  "author": "knpkv",
@@ -26,34 +26,27 @@
26
26
  "import": "./dist/ClockifyApiConfig.js",
27
27
  "types": "./dist/ClockifyApiConfig.d.ts"
28
28
  },
29
- "./ClockifyApiError": {
30
- "import": "./dist/ClockifyApiError.js",
31
- "types": "./dist/ClockifyApiError.d.ts"
32
- },
33
29
  "./generated": {
34
- "import": "./dist/generated/index.js",
35
- "types": "./dist/generated/index.d.ts"
30
+ "import": "./dist/generated/ClockifyApi.js",
31
+ "types": "./dist/generated/ClockifyApi.d.ts"
36
32
  }
37
33
  },
38
34
  "publishConfig": {
39
35
  "access": "public"
40
36
  },
41
37
  "peerDependencies": {
42
- "effect": "4.0.0-beta.87"
43
- },
44
- "dependencies": {
45
- "openapi-fetch": "^0.17.0"
38
+ "effect": "4.0.0-beta.98"
46
39
  },
47
40
  "devDependencies": {
48
- "@effect/platform-node": "4.0.0-beta.87",
49
- "@effect/vitest": "4.0.0-beta.87",
41
+ "@effect/openapi-generator": "4.0.0-beta.98",
42
+ "@effect/platform-node": "4.0.0-beta.98",
43
+ "@effect/vitest": "4.0.0-beta.98",
50
44
  "@types/node": "latest",
51
- "effect": "4.0.0-beta.87",
52
- "openapi-typescript": "^7.13.0",
53
- "openapi-typescript-helpers": "^0.1.0",
54
- "tsx": "^4.22.4",
45
+ "effect": "4.0.0-beta.98",
46
+ "prettier": "^3.9.5",
47
+ "tsx": "^4.23.0",
55
48
  "typescript": "~6.0.3",
56
- "vitest": "^4.1.9"
49
+ "vitest": "^4.1.10"
57
50
  },
58
51
  "keywords": [
59
52
  "effect",
@@ -64,11 +57,12 @@
64
57
  "openapi"
65
58
  ],
66
59
  "scripts": {
67
- "build": "tsc && cp src/generated/schema.d.ts dist/generated/schema.d.ts",
68
- "check": "tsc --noEmit",
60
+ "build": "tsc",
61
+ "check": "tsc --noEmit && tsc -p scripts/tsconfig.json",
69
62
  "test": "vitest run",
70
63
  "test:watch": "vitest",
71
64
  "lint": "eslint src",
72
- "regenerate": "tsx scripts/regenerate.ts"
65
+ "regenerate": "tsx scripts/regenerate.ts",
66
+ "regenerate:check": "tsx scripts/regenerate.ts --check"
73
67
  }
74
68
  }
@@ -1,249 +1,230 @@
1
1
  #!/usr/bin/env tsx
2
2
  /**
3
- * Regeneration script for Clockify API client.
3
+ * Fetch, compare, patch, and regenerate the Clockify API client.
4
4
  *
5
- * 1. Fetches latest OpenAPI spec from Clockify docs
6
- * 2. Applies local patches (.specs/clockify-v1.patch.json) to fix incomplete DTOs
7
- * 3. Generates TypeScript types via openapi-typescript
5
+ * The committed OpenAPI document is the unmodified upstream document. RFC 6902
6
+ * patches are applied in memory before Effect's OpenAPI generator runs.
8
7
  */
9
- import { NodeRuntime, NodeServices } from "@effect/platform-node"
10
- import * as NodeHttpClient from "@effect/platform-node/NodeHttpClient"
8
+ import * as OpenApiGenerator from "@effect/openapi-generator/OpenApiGenerator"
9
+ import * as OpenApiPatch from "@effect/openapi-generator/OpenApiPatch"
10
+ import { NodeHttpClient, NodeRuntime, NodeServices } from "@effect/platform-node"
11
11
  import * as Console from "effect/Console"
12
+ import * as Data from "effect/Data"
12
13
  import * as Effect from "effect/Effect"
13
14
  import * as FileSystem from "effect/FileSystem"
15
+ import type * as JsonSchema from "effect/JsonSchema"
16
+ import * as Layer from "effect/Layer"
14
17
  import * as Path from "effect/Path"
15
- import * as PlatformError from "effect/PlatformError"
16
- import { HttpClient } from "effect/unstable/http"
17
- import * as ChildProcess from "effect/unstable/process/ChildProcess"
18
- import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner"
18
+ import * as Predicate from "effect/Predicate"
19
+ import * as Schema from "effect/Schema"
20
+ import * as Command from "effect/unstable/cli/Command"
21
+ import * as Flag from "effect/unstable/cli/Flag"
22
+ import * as HttpClient from "effect/unstable/http/HttpClient"
23
+ import type { OpenAPISpec } from "effect/unstable/httpapi/OpenApi"
24
+ import { format as formatSource } from "prettier"
19
25
 
20
26
  const SPEC_URL = "https://docs.clockify.me/openapi.json"
21
27
 
22
- interface SpecInfo {
23
- version: string
24
- title: string
25
- }
28
+ class RegenerateError extends Data.TaggedError("RegenerateError")<{
29
+ readonly message: string
30
+ readonly cause?: unknown
31
+ }> {}
32
+
33
+ const JsonString = Schema.fromJsonString(Schema.Json)
34
+
35
+ const OpenApiDocument = Schema.declare<OpenAPISpec>(
36
+ (value): value is OpenAPISpec =>
37
+ Predicate.hasProperty(value, "paths") &&
38
+ Predicate.isObject(value.paths) &&
39
+ (Predicate.hasProperty(value, "openapi") || Predicate.hasProperty(value, "swagger")),
40
+ { identifier: "OpenApiDocument" }
41
+ )
42
+
43
+ const SpecInfo = Schema.Struct({
44
+ info: Schema.Struct({ version: Schema.String })
45
+ })
26
46
 
27
47
  interface ScriptPaths {
28
48
  readonly packageDir: string
29
- readonly specsDir: string
30
49
  readonly specFile: string
31
50
  readonly patchFile: string
32
51
  readonly outputFile: string
33
52
  readonly versionFile: string
34
53
  }
35
54
 
36
- interface ClockifySchema {
37
- properties?: Record<string, unknown>
38
- required?: unknown
39
- }
40
-
41
- interface ClockifySpec {
42
- components?: {
43
- schemas?: Record<string, ClockifySchema>
44
- }
45
- }
46
-
47
- interface ClockifySchemaPatch {
48
- readonly renameProperties?: Record<string, string>
49
- readonly addProperties?: Record<string, unknown>
50
- readonly patchProperties?: Record<string, unknown>
51
- readonly required?: unknown
52
- }
53
-
54
- interface ClockifyPatches {
55
- readonly schemas?: Record<string, ClockifySchemaPatch>
56
- }
57
-
58
- const fetchText = (url: string): Effect.Effect<string, Error, HttpClient.HttpClient> =>
59
- HttpClient.get(url).pipe(
60
- Effect.flatMap((response) =>
61
- response.status >= 200 && response.status < 300
62
- ? response.text
63
- : Effect.fail(new Error(`Failed to fetch ${url}: ${response.status}`))
64
- ),
65
- Effect.mapError((e) => e instanceof Error ? e : new Error(`Fetch failed: ${e}`))
66
- )
67
-
68
- const fetchJson = <A>(url: string): Effect.Effect<A, Error, HttpClient.HttpClient> =>
69
- fetchText(url).pipe(
70
- Effect.flatMap((text) =>
71
- Effect.try({
72
- try: () => JSON.parse(text) as A,
73
- catch: (e) => new Error(`JSON parse failed: ${e}`)
74
- })
75
- )
76
- )
77
-
78
- const scriptPaths: Effect.Effect<ScriptPaths, Error, Path.Path> = Effect.gen(function*() {
55
+ const paths = Effect.gen(function*() {
79
56
  const path = yield* Path.Path
80
57
  const scriptFile = yield* path.fromFileUrl(new URL(import.meta.url)).pipe(
81
- Effect.mapError((e) => new Error(`Script path resolution failed: ${e.message}`))
58
+ Effect.mapError((cause) => new RegenerateError({ message: "Could not resolve script path", cause }))
82
59
  )
83
- const scriptDir = path.dirname(scriptFile)
84
- const packageDir = path.join(scriptDir, "..")
60
+ const packageDir = path.join(path.dirname(scriptFile), "..")
85
61
  const specsDir = path.join(packageDir, ".specs")
86
-
87
62
  return {
88
63
  packageDir,
89
- specsDir,
90
64
  specFile: path.join(specsDir, "clockify-v1.json"),
91
65
  patchFile: path.join(specsDir, "clockify-v1.patch.json"),
92
- outputFile: path.join(packageDir, "src", "generated", "schema.d.ts"),
66
+ outputFile: path.join(packageDir, "src", "generated", "ClockifyApi.ts"),
93
67
  versionFile: path.join(specsDir, "VERSION")
94
- }
68
+ } satisfies ScriptPaths
95
69
  })
96
70
 
97
- const fetchSpecInfo: Effect.Effect<SpecInfo, Error, HttpClient.HttpClient> =
98
- fetchJson<{ info: { version: string; title: string } }>(SPEC_URL).pipe(
99
- Effect.map((spec) => ({ version: spec.info.version, title: spec.info.title }))
71
+ const fetchUpstream = HttpClient.get(SPEC_URL).pipe(
72
+ Effect.flatMap((response) =>
73
+ response.status >= 200 && response.status < 300
74
+ ? response.text.pipe(
75
+ Effect.mapError((cause) => new RegenerateError({ message: "Could not read Clockify response", cause }))
76
+ )
77
+ : Effect.fail(
78
+ new RegenerateError({
79
+ message: `Clockify spec request failed with status ${response.status}`
80
+ })
81
+ )
82
+ ),
83
+ Effect.flatMap(Schema.decodeUnknownEffect(JsonString)),
84
+ Effect.mapError((cause) => new RegenerateError({ message: "Could not fetch or decode Clockify spec", cause }))
85
+ )
86
+
87
+ const canonicalJson = (value: Schema.Json) =>
88
+ Schema.encodeEffect(JsonString)(value).pipe(
89
+ Effect.map((json) => `${json}\n`),
90
+ Effect.mapError((cause) => new RegenerateError({ message: "Could not encode Clockify spec", cause }))
100
91
  )
101
92
 
102
- const fetchAndSaveSpec = (outputFile: string): Effect.Effect<void, Error, FileSystem.FileSystem | HttpClient.HttpClient> =>
103
- Effect.gen(function*() {
104
- const fs = yield* FileSystem.FileSystem
105
- const spec = yield* fetchText(SPEC_URL)
106
- yield* fs.writeFileString(outputFile, spec).pipe(
107
- Effect.mapError((e) => new Error(`Save failed: ${e.message}`))
108
- )
109
- })
93
+ const formattedJson = (value: Schema.Json) =>
94
+ Schema.encodeEffect(JsonString)(value).pipe(
95
+ Effect.flatMap((source) =>
96
+ Effect.tryPromise({
97
+ try: () => formatSource(source, { parser: "json", printWidth: 120, trailingComma: "none", semi: false }),
98
+ catch: (cause) => new RegenerateError({ message: "Could not format Clockify spec", cause })
99
+ })
100
+ ),
101
+ Effect.mapError((cause) => new RegenerateError({ message: "Could not format Clockify spec", cause }))
102
+ )
103
+
104
+ const stripUnreliableMetadata = (schema: JsonSchema.JsonSchema): JsonSchema.JsonSchema => {
105
+ const { default: _default, examples: _examples, ...normalized } = schema
106
+ return normalized
107
+ }
108
+
109
+ const stripTrailingWhitespace = (source: string): string => source.replace(/[ \t]+$/gm, "")
110
110
 
111
111
  /**
112
- * Apply patches from clockify-v1.patch.json to the spec in-place.
113
- * Keeps original spec clean — patches add missing fields and required arrays.
112
+ * The pinned Effect generator emits an unused decoder when a spec declares no typed error bodies.
113
+ * Remove only that exact unused block; fail if the generator shape changes.
114
114
  */
115
- const applyPatches = (specFile: string, patchFile: string): Effect.Effect<void, Error, FileSystem.FileSystem> =>
116
- Effect.gen(function*() {
117
- const fs = yield* FileSystem.FileSystem
118
- const patchExists = yield* fs.exists(patchFile).pipe(
119
- Effect.mapError((e) => new Error(`Patch lookup failed: ${e.message}`))
120
- )
121
- if (!patchExists) return
122
-
123
- const [specText, patchText] = yield* Effect.all([
124
- fs.readFileString(specFile),
125
- fs.readFileString(patchFile)
126
- ]).pipe(
127
- Effect.mapError((e) => new Error(`Patch read failed: ${e.message}`))
128
- )
129
-
130
- const { patches, spec } = yield* Effect.try({
131
- try: () => ({
132
- spec: JSON.parse(specText) as ClockifySpec,
133
- patches: JSON.parse(patchText) as ClockifyPatches
134
- }),
135
- catch: (e) => new Error(`Patch parse failed: ${e}`)
136
- })
137
-
138
- yield* Effect.try({
139
- try: () => {
140
- const schemas = spec.components?.schemas ?? {}
141
-
142
- for (const [schemaName, patch] of Object.entries(patches.schemas ?? {})) {
143
- const schema = schemas[schemaName]
144
- if (!schema) continue
145
-
146
- // Rename properties (e.g. get_id -> id)
147
- if (patch.renameProperties) {
148
- for (const [from, to] of Object.entries(patch.renameProperties)) {
149
- if (schema.properties?.[from]) {
150
- schema.properties[to] = schema.properties[from]
151
- delete schema.properties[from]
152
- }
153
- }
154
- }
155
-
156
- // Add new properties
157
- if (patch.addProperties) {
158
- schema.properties = { ...schema.properties, ...patch.addProperties }
159
- }
160
-
161
- // Patch existing properties (overwrite)
162
- if (patch.patchProperties) {
163
- schema.properties = { ...schema.properties }
164
- for (const [name, value] of Object.entries(patch.patchProperties)) {
165
- schema.properties[name] = value
166
- }
167
- }
168
-
169
- // Set required fields
170
- if (patch.required) {
171
- schema.required = patch.required
172
- }
173
- }
174
- },
175
- catch: (e) => new Error(`Patch failed: ${e}`)
176
- })
115
+ const removeUnusedErrorDecoder = (source: string): string => {
116
+ const marker = " const decodeError ="
117
+ if (!source.includes(marker)) return source
118
+ const cleaned = source.replace(/\n {2}const decodeError =[\s\S]*?\n {2}return \{/m, "\n return {")
119
+ if (cleaned === source || cleaned.includes(marker)) {
120
+ throw new RegenerateError({ message: "Effect generator output changed around decodeError" })
121
+ }
122
+ return cleaned
123
+ }
177
124
 
178
- yield* fs.writeFileString(specFile, JSON.stringify(spec, null, 2)).pipe(
179
- Effect.mapError((e) => new Error(`Patch write failed: ${e.message}`))
180
- )
181
- })
125
+ /**
126
+ * The generator currently passes record-shaped multipart payloads to
127
+ * `bodyFormData`, which expects an already-built Web FormData value. Use the
128
+ * record-aware Effect helper so strings and Blob/File values are serialized
129
+ * with a real multipart boundary by the runtime.
130
+ */
131
+ const normalizeMultipartBodies = (source: string): string =>
132
+ source.replaceAll("HttpClientRequest.bodyFormData(", "HttpClientRequest.bodyFormDataRecord(")
182
133
 
183
- const commandExitCode = (
184
- command: ChildProcess.Command
185
- ): Effect.Effect<ChildProcessSpawner.ExitCode, PlatformError.PlatformError, ChildProcessSpawner.ChildProcessSpawner> =>
186
- ChildProcessSpawner.ChildProcessSpawner.pipe(
187
- Effect.flatMap((spawner) => spawner.exitCode(command))
134
+ const generate = Effect.fn("Clockify.regenerate.generate")(function*(
135
+ scriptPaths: ScriptPaths,
136
+ upstream: Schema.Json
137
+ ) {
138
+ const fs = yield* FileSystem.FileSystem
139
+ const patch = yield* OpenApiPatch.parsePatchInput(scriptPaths.patchFile).pipe(
140
+ Effect.mapError((cause) => new RegenerateError({ message: "Could not parse Clockify JSON patch", cause }))
188
141
  )
189
-
190
- const generateTypes = (paths: ScriptPaths): Effect.Effect<void, Error, ChildProcessSpawner.ChildProcessSpawner> =>
191
- Effect.gen(function*() {
192
- const command = ChildProcess.make("pnpm", ["exec", "openapi-typescript", paths.specFile, "-o", paths.outputFile], {
193
- cwd: paths.packageDir,
194
- stdin: "inherit",
195
- stdout: "inherit",
196
- stderr: "inherit"
197
- })
198
- const exitCode = yield* commandExitCode(command).pipe(
199
- Effect.mapError((e) => new Error(`Generation failed: ${e.message}`))
200
- )
201
-
202
- if (exitCode !== 0) {
203
- return yield* Effect.fail(new Error(`Generation failed with exit code ${exitCode}`))
204
- }
142
+ const patched = yield* OpenApiPatch.applyPatches(
143
+ [{ source: scriptPaths.patchFile, patch }],
144
+ upstream
145
+ ).pipe(
146
+ Effect.mapError((cause) => new RegenerateError({ message: "Could not apply Clockify JSON patch", cause }))
147
+ )
148
+ const document = yield* Schema.decodeUnknownEffect(OpenApiDocument)(patched).pipe(
149
+ Effect.mapError((cause) => new RegenerateError({ message: "Patched Clockify document is not OpenAPI", cause }))
150
+ )
151
+ const generator = yield* OpenApiGenerator.OpenApiGenerator
152
+ const warnings: Array<OpenApiGenerator.OpenApiGeneratorWarning> = []
153
+ const generated = yield* generator.generate(document, {
154
+ name: "ClockifyApi",
155
+ format: "httpclient",
156
+ onEnter: stripUnreliableMetadata,
157
+ onWarning: (warning) => warnings.push(warning)
205
158
  })
206
-
207
- const program = Effect.gen(function*() {
208
- const fs = yield* FileSystem.FileSystem
209
- const paths = yield* scriptPaths
210
-
211
- yield* Console.log("Fetching Clockify API spec version...")
212
-
213
- const info = yield* fetchSpecInfo
214
- const current = yield* fs.exists(paths.versionFile).pipe(
215
- Effect.flatMap((exists) => exists ? fs.readFileString(paths.versionFile) : Effect.succeed(null)),
216
- Effect.map((content) => content?.trim() ?? null),
217
- Effect.mapError((e) => new Error(`Version read failed: ${e.message}`))
159
+ yield* Effect.forEach(
160
+ warnings,
161
+ (warning) => Console.warn(`[${warning.code}] ${warning.method ?? ""} ${warning.path ?? ""}: ${warning.message}`),
162
+ { discard: true }
218
163
  )
164
+ yield* fs.writeFileString(
165
+ scriptPaths.outputFile,
166
+ stripTrailingWhitespace(normalizeMultipartBodies(removeUnusedErrorDecoder(generated)))
167
+ ).pipe(
168
+ Effect.mapError((cause) => new RegenerateError({ message: "Could not write generated Clockify client", cause }))
169
+ )
170
+ })
219
171
 
220
- yield* Console.log(`Current: ${current ?? "none"}, Remote: ${info.version}`)
221
-
222
- if (current === info.version) {
223
- yield* Console.log("Spec up to date, regenerating types...")
224
- } else {
225
- yield* fs.makeDirectory(paths.specsDir, { recursive: true }).pipe(
226
- Effect.mapError((e) => new Error(`Spec directory creation failed: ${e.message}`))
227
- )
228
-
229
- yield* Console.log(`Fetching spec (${info.version})...`)
230
- yield* fetchAndSaveSpec(paths.specFile)
231
- yield* fs.writeFileString(paths.versionFile, info.version).pipe(
232
- Effect.mapError((e) => new Error(`Version write failed: ${e.message}`))
233
- )
234
- }
172
+ const check = Flag.boolean("check").pipe(
173
+ Flag.withDescription("Exit non-zero when the committed upstream spec differs from Clockify")
174
+ )
235
175
 
236
- yield* Console.log("Applying patches...")
237
- yield* applyPatches(paths.specFile, paths.patchFile)
176
+ const local = Flag.boolean("local").pipe(
177
+ Flag.withDescription("Regenerate from the committed spec without contacting Clockify")
178
+ )
238
179
 
239
- yield* Console.log("Generating types...")
240
- yield* generateTypes(paths)
180
+ const root = Command.make("clockify-api-regenerate", { check, local }).pipe(
181
+ Command.withHandler(
182
+ Effect.fn("Clockify.regenerate")(function*({ check, local }) {
183
+ const fs = yield* FileSystem.FileSystem
184
+ const scriptPaths = yield* paths
185
+ const upstream = local
186
+ ? yield* fs.readFileString(scriptPaths.specFile).pipe(
187
+ Effect.flatMap(Schema.decodeUnknownEffect(JsonString)),
188
+ Effect.mapError((cause) => new RegenerateError({ message: "Could not read committed Clockify spec", cause }))
189
+ )
190
+ : yield* fetchUpstream
191
+ const encoded = yield* canonicalJson(upstream)
192
+ const current = yield* fs.readFileString(scriptPaths.specFile).pipe(
193
+ Effect.flatMap(Schema.decodeUnknownEffect(JsonString)),
194
+ Effect.flatMap(canonicalJson),
195
+ Effect.orElseSucceed(() => "")
196
+ )
197
+
198
+ if (check) {
199
+ if (local) {
200
+ return yield* new RegenerateError({ message: "--check and --local cannot be combined" })
201
+ }
202
+ if (current === encoded) {
203
+ return yield* Console.log("Clockify OpenAPI spec is current.")
204
+ }
205
+ return yield* new RegenerateError({
206
+ message: "Clockify OpenAPI spec changed; run `pnpm --filter @knpkv/clockify-api-client regenerate`"
207
+ })
208
+ }
209
+
210
+ const { info } = yield* Schema.decodeUnknownEffect(SpecInfo)(upstream).pipe(
211
+ Effect.mapError((cause) => new RegenerateError({ message: "Clockify spec has no version", cause }))
212
+ )
213
+ yield* fs.writeFileString(scriptPaths.specFile, yield* formattedJson(upstream))
214
+ yield* fs.writeFileString(scriptPaths.versionFile, `${info.version}\n`)
215
+ yield* generate(scriptPaths, upstream)
216
+ yield* Console.log(`Generated Clockify ${info.version} client from ${SPEC_URL}`)
217
+ })
218
+ )
219
+ )
241
220
 
242
- yield* Console.log("Done.")
243
- })
221
+ const MainLayer = Layer.mergeAll(
222
+ OpenApiGenerator.layerTransformerSchema,
223
+ NodeHttpClient.layerFetch,
224
+ NodeServices.layer
225
+ )
244
226
 
245
- program.pipe(
246
- Effect.provide(NodeHttpClient.layerFetch),
247
- Effect.provide(NodeServices.layer),
227
+ Command.run(root, { version: "1.0.0" }).pipe(
228
+ Effect.provide(MainLayer),
248
229
  NodeRuntime.runMain
249
230
  )
@@ -0,0 +1,13 @@
1
+ {
2
+ "extends": "../../../tsconfig.base.jsonc",
3
+ "compilerOptions": {
4
+ "composite": false,
5
+ "declaration": false,
6
+ "declarationMap": false,
7
+ "incremental": false,
8
+ "noEmit": true,
9
+ "rootDir": "..",
10
+ "types": ["node"]
11
+ },
12
+ "include": ["./**/*.ts"]
13
+ }