@ic-reactor/cli 0.2.0 → 0.3.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,447 +0,0 @@
1
- /**
2
- * Add command
3
- *
4
- * Interactively add hooks for canister methods.
5
- */
6
-
7
- import * as p from "@clack/prompts"
8
- import fs from "node:fs"
9
- import path from "node:path"
10
- import pc from "picocolors"
11
- import {
12
- loadConfig,
13
- saveConfig,
14
- getProjectRoot,
15
- ensureDir,
16
- fileExists,
17
- findConfigFile,
18
- } from "../utils/config.js"
19
- import { parseDIDFile, formatMethodForDisplay } from "@ic-reactor/codegen"
20
- import {
21
- generateReactorFile,
22
- generateQueryHook,
23
- generateMutationHook,
24
- generateInfiniteQueryHook,
25
- } from "../generators/index.js"
26
- import { getHookFileName } from "@ic-reactor/codegen"
27
- import { generateDeclarations } from "@ic-reactor/codegen"
28
- import type { MethodInfo, HookType } from "@ic-reactor/codegen"
29
-
30
- interface AddOptions {
31
- canister?: string
32
- methods?: string[]
33
- all?: boolean
34
- }
35
-
36
- export async function addCommand(options: AddOptions) {
37
- console.log()
38
- p.intro(pc.cyan("🔧 Add Canister Hooks"))
39
-
40
- // Load config
41
- const configPath = findConfigFile()
42
- if (!configPath) {
43
- p.log.error(
44
- `No ${pc.yellow("reactor.config.json")} found. Run ${pc.cyan("npx @ic-reactor/cli init")} first.`
45
- )
46
- process.exit(1)
47
- }
48
-
49
- const config = loadConfig(configPath)
50
- if (!config) {
51
- p.log.error(`Failed to load config from ${pc.yellow(configPath)}`)
52
- process.exit(1)
53
- }
54
-
55
- const projectRoot = getProjectRoot()
56
- const canisterNames = Object.keys(config.canisters)
57
-
58
- // Check if we have any canisters configured
59
- if (canisterNames.length === 0) {
60
- p.log.error(
61
- `No canisters configured. Add a canister to ${pc.yellow("reactor.config.json")} first.`
62
- )
63
-
64
- const addNow = await p.confirm({
65
- message: "Would you like to add a canister now?",
66
- initialValue: true,
67
- })
68
-
69
- if (p.isCancel(addNow) || !addNow) {
70
- process.exit(1)
71
- }
72
-
73
- // Prompt for canister info
74
- const canisterInfo = await promptForNewCanister(projectRoot)
75
- if (!canisterInfo) {
76
- p.cancel("Cancelled.")
77
- process.exit(0)
78
- }
79
-
80
- config.canisters[canisterInfo.name] = canisterInfo.config
81
- saveConfig(config, configPath)
82
- canisterNames.push(canisterInfo.name)
83
- }
84
-
85
- // Select canister
86
- let selectedCanister = options.canister
87
-
88
- if (!selectedCanister) {
89
- if (canisterNames.length === 1) {
90
- selectedCanister = canisterNames[0]
91
- } else {
92
- const result = await p.select({
93
- message: "Select a canister",
94
- options: canisterNames.map((name) => ({
95
- value: name,
96
- label: name,
97
- })),
98
- })
99
-
100
- if (p.isCancel(result)) {
101
- p.cancel("Cancelled.")
102
- process.exit(0)
103
- }
104
-
105
- selectedCanister = result as string
106
- }
107
- }
108
-
109
- const canisterConfig = config.canisters[selectedCanister]
110
- if (!canisterConfig) {
111
- p.log.error(`Canister ${pc.yellow(selectedCanister)} not found in config.`)
112
- process.exit(1)
113
- }
114
-
115
- // Parse DID file
116
- const didFilePath = path.resolve(projectRoot, canisterConfig.didFile)
117
- let methods: MethodInfo[]
118
-
119
- try {
120
- methods = parseDIDFile(didFilePath)
121
- } catch (error) {
122
- p.log.error(
123
- `Failed to parse DID file: ${pc.yellow(didFilePath)}\n${(error as Error).message}`
124
- )
125
- process.exit(1)
126
- }
127
-
128
- if (methods.length === 0) {
129
- p.log.warn(`No methods found in ${pc.yellow(didFilePath)}`)
130
- process.exit(0)
131
- }
132
-
133
- p.log.info(
134
- `Found ${pc.green(methods.length.toString())} methods in ${pc.dim(selectedCanister)}`
135
- )
136
-
137
- // Select methods
138
- let selectedMethods: MethodInfo[]
139
-
140
- if (options.all) {
141
- selectedMethods = methods
142
- } else if (options.methods && options.methods.length > 0) {
143
- // Parse comma-separated method names
144
- const requestedMethods = options.methods
145
- .flatMap((m) => m.split(","))
146
- .map((m) => m.trim())
147
- .filter((m) => m.length > 0)
148
-
149
- selectedMethods = methods.filter((m) => requestedMethods.includes(m.name))
150
- const notFound = requestedMethods.filter(
151
- (name) => !methods.some((m) => m.name === name)
152
- )
153
- if (notFound.length > 0) {
154
- p.log.warn(`Methods not found: ${pc.yellow(notFound.join(", "))}`)
155
- }
156
- } else {
157
- // Interactive selection
158
- const alreadyGenerated = config.generatedHooks[selectedCanister] ?? []
159
-
160
- const result = await p.multiselect({
161
- message: "Select methods to add hooks for",
162
- options: methods.map((method) => {
163
- const isGenerated = alreadyGenerated.includes(method.name)
164
- return {
165
- value: method.name,
166
- label: formatMethodForDisplay(method),
167
- hint: isGenerated ? pc.dim("(already generated)") : undefined,
168
- }
169
- }),
170
- required: true,
171
- })
172
-
173
- if (p.isCancel(result)) {
174
- p.cancel("Cancelled.")
175
- process.exit(0)
176
- }
177
-
178
- selectedMethods = methods.filter((m) =>
179
- (result as string[]).includes(m.name)
180
- )
181
- }
182
-
183
- if (selectedMethods.length === 0) {
184
- p.log.warn("No methods selected.")
185
- process.exit(0)
186
- }
187
-
188
- // Prompt for hook types for query methods
189
- const methodsWithHookTypes: Array<{
190
- method: MethodInfo
191
- hookType: HookType
192
- }> = []
193
-
194
- for (const method of selectedMethods) {
195
- if (method.type === "query") {
196
- const hookType = await p.select({
197
- message: `Hook type for ${pc.cyan(method.name)}`,
198
- options: [
199
- { value: "query", label: "Query", hint: "Standard query hook" },
200
- {
201
- value: "suspenseQuery",
202
- label: "Suspense Query",
203
- hint: "For React Suspense",
204
- },
205
- {
206
- value: "infiniteQuery",
207
- label: "Infinite Query",
208
- hint: "Paginated/infinite scroll",
209
- },
210
- {
211
- value: "suspenseInfiniteQuery",
212
- label: "Suspense Infinite Query",
213
- hint: "Paginated with Suspense",
214
- },
215
- {
216
- value: "skip",
217
- label: "Skip",
218
- hint: "Don't generate hook for this method",
219
- },
220
- ],
221
- })
222
-
223
- if (p.isCancel(hookType)) {
224
- p.cancel("Cancelled.")
225
- process.exit(0)
226
- }
227
-
228
- // Skip this method if user chose "skip"
229
- if (hookType === "skip") {
230
- p.log.info(`Skipping ${pc.dim(method.name)}`)
231
- continue
232
- }
233
-
234
- methodsWithHookTypes.push({ method, hookType: hookType as HookType })
235
- } else {
236
- // For mutations, also allow skipping
237
- const hookType = await p.select({
238
- message: `Hook type for ${pc.yellow(method.name)} (mutation)`,
239
- options: [
240
- {
241
- value: "mutation",
242
- label: "Mutation",
243
- hint: "Standard mutation hook",
244
- },
245
- {
246
- value: "skip",
247
- label: "Skip",
248
- hint: "Don't generate hook for this method",
249
- },
250
- ],
251
- })
252
-
253
- if (p.isCancel(hookType)) {
254
- p.cancel("Cancelled.")
255
- process.exit(0)
256
- }
257
-
258
- if (hookType === "skip") {
259
- p.log.info(`Skipping ${pc.dim(method.name)}`)
260
- continue
261
- }
262
-
263
- methodsWithHookTypes.push({ method, hookType: "mutation" })
264
- }
265
- }
266
-
267
- // Check if any methods were selected after skipping
268
- if (methodsWithHookTypes.length === 0) {
269
- p.log.warn("All methods were skipped. Nothing to generate.")
270
- process.exit(0)
271
- }
272
-
273
- // Generate files
274
- const canisterOutDir = path.join(projectRoot, config.outDir, selectedCanister)
275
- const hooksOutDir = path.join(canisterOutDir, "hooks")
276
- ensureDir(hooksOutDir)
277
-
278
- const spinner = p.spinner()
279
- spinner.start("Generating hooks...")
280
-
281
- const generatedFiles: string[] = []
282
-
283
- // Generate reactor.ts if it doesn't exist
284
- const reactorPath = path.join(canisterOutDir, "reactor.ts")
285
- if (!fileExists(reactorPath)) {
286
- // Generate TypeScript declarations using bindgen
287
- spinner.message("Generating TypeScript declarations...")
288
- const bindgenResult = await generateDeclarations({
289
- didFile: didFilePath,
290
- outDir: canisterOutDir,
291
- canisterName: selectedCanister,
292
- })
293
-
294
- if (bindgenResult.success) {
295
- generatedFiles.push("declarations/")
296
- } else {
297
- p.log.warn(`Could not generate declarations: ${bindgenResult.error}`)
298
- p.log.info(
299
- `You can manually run: npx @icp-sdk/bindgen --input ${didFilePath} --output ${canisterOutDir}/declarations`
300
- )
301
- }
302
-
303
- spinner.message("Generating reactor...")
304
- const reactorContent = generateReactorFile({
305
- canisterName: selectedCanister,
306
- canisterConfig: canisterConfig,
307
- config: config,
308
- outDir: canisterOutDir,
309
- hasDeclarations: bindgenResult.success,
310
- })
311
- fs.writeFileSync(reactorPath, reactorContent)
312
- generatedFiles.push("reactor.ts")
313
- }
314
-
315
- // Generate hooks for each method
316
- for (const { method, hookType } of methodsWithHookTypes) {
317
- const fileName = getHookFileName(method.name, hookType)
318
- const filePath = path.join(hooksOutDir, fileName)
319
-
320
- let content: string
321
-
322
- switch (hookType) {
323
- case "query":
324
- case "suspenseQuery":
325
- content = generateQueryHook({
326
- canisterName: selectedCanister,
327
- method,
328
- config,
329
- })
330
- break
331
- case "infiniteQuery":
332
- case "suspenseInfiniteQuery":
333
- content = generateInfiniteQueryHook({
334
- canisterName: selectedCanister,
335
- method,
336
- config,
337
- })
338
- break
339
- case "mutation":
340
- content = generateMutationHook({
341
- canisterName: selectedCanister,
342
- method,
343
- config,
344
- })
345
- break
346
- }
347
-
348
- fs.writeFileSync(filePath, content)
349
- generatedFiles.push(path.join("hooks", fileName))
350
- }
351
-
352
- // Generate index.ts barrel export
353
- const indexPath = path.join(hooksOutDir, "index.ts")
354
- let existingExports: string[] = []
355
-
356
- if (fs.existsSync(indexPath)) {
357
- const content = fs.readFileSync(indexPath, "utf-8")
358
- existingExports = content
359
- .split("\n")
360
- .filter((line) => line.trim().startsWith("export * from"))
361
- .map((line) => line.trim())
362
- }
363
-
364
- const newExports = methodsWithHookTypes.map(({ method, hookType }) => {
365
- const fileName = getHookFileName(method.name, hookType).replace(".ts", "")
366
- return `export * from "./${fileName}"`
367
- })
368
-
369
- const allExports = [...new Set([...existingExports, ...newExports])]
370
-
371
- const indexContent = `/**
372
- * Hook barrel exports
373
- *
374
- * Auto-generated by @ic-reactor/cli
375
- */
376
-
377
- ${allExports.join("\n")}
378
- `
379
- fs.writeFileSync(indexPath, indexContent)
380
- generatedFiles.push("hooks/index.ts")
381
-
382
- // Update config with generated hooks
383
- const existingHooks = config.generatedHooks[selectedCanister] ?? []
384
-
385
- const newHookConfigs = methodsWithHookTypes.map(({ method, hookType }) => ({
386
- name: method.name,
387
- type: hookType,
388
- }))
389
-
390
- const filteredExisting = existingHooks.filter((h) => {
391
- const name = typeof h === "string" ? h : h.name
392
- return !newHookConfigs.some((n) => n.name === name)
393
- })
394
-
395
- config.generatedHooks[selectedCanister] = [
396
- ...filteredExisting,
397
- ...newHookConfigs,
398
- ]
399
- saveConfig(config, configPath)
400
-
401
- spinner.stop("Hooks generated!")
402
-
403
- // Display results
404
- console.log()
405
- p.note(
406
- generatedFiles.map((f) => pc.green(`✓ ${f}`)).join("\n"),
407
- `Generated in ${pc.dim(path.relative(projectRoot, canisterOutDir))}`
408
- )
409
-
410
- p.outro(pc.green("✓ Done!"))
411
- }
412
-
413
- async function promptForNewCanister(projectRoot: string) {
414
- const name = await p.text({
415
- message: "Canister name",
416
- placeholder: "backend",
417
- validate: (value) => {
418
- if (!value) return "Canister name is required"
419
- return undefined
420
- },
421
- })
422
-
423
- if (p.isCancel(name)) return null
424
-
425
- const didFile = await p.text({
426
- message: "Path to .did file",
427
- placeholder: "./backend.did",
428
- validate: (value) => {
429
- if (!value) return "DID file path is required"
430
- const fullPath = path.resolve(projectRoot, value)
431
- if (!fs.existsSync(fullPath)) {
432
- return `File not found: ${value}`
433
- }
434
- return undefined
435
- },
436
- })
437
-
438
- if (p.isCancel(didFile)) return null
439
-
440
- return {
441
- name: name as string,
442
- config: {
443
- didFile: didFile as string,
444
- useDisplayReactor: true,
445
- },
446
- }
447
- }