@ic-reactor/cli 0.2.0 → 0.3.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.
@@ -1,600 +0,0 @@
1
- /**
2
- * Fetch command
3
- *
4
- * Fetch Candid interface from a live canister and generate hooks.
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
- findConfigFile,
17
- CONFIG_FILE_NAME,
18
- DEFAULT_CONFIG,
19
- } from "../utils/config.js"
20
- import { formatMethodForDisplay } from "@ic-reactor/codegen"
21
- import {
22
- generateQueryHook,
23
- generateMutationHook,
24
- generateInfiniteQueryHook,
25
- } from "../generators/index.js"
26
- import { getHookFileName, toCamelCase } from "@ic-reactor/codegen"
27
- import {
28
- fetchCandidFromCanister,
29
- isValidCanisterId,
30
- shortenCanisterId,
31
- type NetworkType,
32
- } from "../utils/network.js"
33
- import { generateDeclarations } from "@ic-reactor/codegen"
34
- import type { MethodInfo, HookType, CanisterConfig } from "@ic-reactor/codegen"
35
- import type { ReactorConfig } from "../types.js"
36
-
37
- interface FetchOptions {
38
- canisterId?: string
39
- network?: NetworkType
40
- name?: string
41
- methods?: string[]
42
- all?: boolean
43
- }
44
-
45
- export async function fetchCommand(options: FetchOptions) {
46
- console.log()
47
- p.intro(pc.cyan("🌐 Fetch from Live Canister"))
48
-
49
- const projectRoot = getProjectRoot()
50
-
51
- // Get canister ID
52
- let canisterId = options.canisterId
53
-
54
- if (!canisterId) {
55
- const input = await p.text({
56
- message: "Enter canister ID",
57
- placeholder: "ryjl3-tyaaa-aaaaa-aaaba-cai",
58
- validate: (value) => {
59
- if (!value) return "Canister ID is required"
60
- if (!isValidCanisterId(value)) {
61
- return "Invalid canister ID format"
62
- }
63
- return undefined
64
- },
65
- })
66
-
67
- if (p.isCancel(input)) {
68
- p.cancel("Cancelled.")
69
- process.exit(0)
70
- }
71
-
72
- canisterId = input as string
73
- }
74
-
75
- // Validate canister ID
76
- if (!isValidCanisterId(canisterId)) {
77
- p.log.error(`Invalid canister ID: ${pc.yellow(canisterId)}`)
78
- process.exit(1)
79
- }
80
-
81
- // Get network
82
- let network = options.network
83
-
84
- if (!network) {
85
- const result = await p.select({
86
- message: "Select network",
87
- options: [
88
- { value: "ic", label: "IC Mainnet", hint: "icp-api.io" },
89
- { value: "local", label: "Local Replica", hint: "localhost:4943" },
90
- ],
91
- })
92
-
93
- if (p.isCancel(result)) {
94
- p.cancel("Cancelled.")
95
- process.exit(0)
96
- }
97
-
98
- network = result as NetworkType
99
- }
100
-
101
- // Fetch Candid from canister
102
- const spinner = p.spinner()
103
- spinner.start(`Fetching Candid from ${shortenCanisterId(canisterId)}...`)
104
-
105
- let candidResult
106
- try {
107
- candidResult = await fetchCandidFromCanister({
108
- canisterId,
109
- network,
110
- })
111
- spinner.stop(
112
- `Found ${pc.green(candidResult.methods.length.toString())} methods`
113
- )
114
- } catch (error) {
115
- spinner.stop("Failed to fetch Candid")
116
- p.log.error((error as Error).message)
117
- process.exit(1)
118
- }
119
-
120
- const { methods, candidSource } = candidResult
121
-
122
- if (methods.length === 0) {
123
- p.log.warn("No methods found in canister interface")
124
- process.exit(0)
125
- }
126
-
127
- // Get canister name
128
- let canisterName = options.name
129
-
130
- if (!canisterName) {
131
- const input = await p.text({
132
- message: "Name for this canister (used in generated code)",
133
- placeholder: toCamelCase(canisterId.split("-")[0]),
134
- defaultValue: toCamelCase(canisterId.split("-")[0]),
135
- validate: (value) => {
136
- if (!value) return "Name is required"
137
- if (!/^[a-zA-Z][a-zA-Z0-9_-]*$/.test(value)) {
138
- return "Name must start with a letter and contain only letters, numbers, hyphens, and underscores"
139
- }
140
- return undefined
141
- },
142
- })
143
-
144
- if (p.isCancel(input)) {
145
- p.cancel("Cancelled.")
146
- process.exit(0)
147
- }
148
-
149
- canisterName = input as string
150
- }
151
-
152
- // Select methods
153
- let selectedMethods: MethodInfo[]
154
-
155
- if (options.all) {
156
- selectedMethods = methods
157
- } else if (options.methods && options.methods.length > 0) {
158
- // Parse comma-separated method names
159
- const requestedMethods = options.methods
160
- .flatMap((m) => m.split(","))
161
- .map((m) => m.trim())
162
- .filter((m) => m.length > 0)
163
-
164
- selectedMethods = methods.filter((m) => requestedMethods.includes(m.name))
165
- const notFound = requestedMethods.filter(
166
- (name) => !methods.some((m) => m.name === name)
167
- )
168
- if (notFound.length > 0) {
169
- p.log.warn(`Methods not found: ${pc.yellow(notFound.join(", "))}`)
170
- }
171
- } else {
172
- // Interactive selection
173
- const result = await p.multiselect({
174
- message: "Select methods to generate hooks for",
175
- options: methods.map((method) => ({
176
- value: method.name,
177
- label: formatMethodForDisplay(method),
178
- })),
179
- required: true,
180
- })
181
-
182
- if (p.isCancel(result)) {
183
- p.cancel("Cancelled.")
184
- process.exit(0)
185
- }
186
-
187
- selectedMethods = methods.filter((m) =>
188
- (result as string[]).includes(m.name)
189
- )
190
- }
191
-
192
- if (selectedMethods.length === 0) {
193
- p.log.warn("No methods selected.")
194
- process.exit(0)
195
- }
196
-
197
- // Prompt for hook types for query methods
198
- const methodsWithHookTypes: Array<{
199
- method: MethodInfo
200
- hookType: HookType
201
- }> = []
202
-
203
- for (const method of selectedMethods) {
204
- if (method.type === "query") {
205
- const hookType = await p.select({
206
- message: `Hook type for ${pc.cyan(method.name)}`,
207
- options: [
208
- {
209
- value: "skip",
210
- label: "Skip",
211
- hint: "Don't generate hook for this method",
212
- },
213
- { value: "query", label: "Query", hint: "Standard query hook" },
214
- {
215
- value: "suspenseQuery",
216
- label: "Suspense Query",
217
- hint: "For React Suspense",
218
- },
219
- {
220
- value: "infiniteQuery",
221
- label: "Infinite Query",
222
- hint: "Paginated/infinite scroll",
223
- },
224
- {
225
- value: "suspenseInfiniteQuery",
226
- label: "Suspense Infinite Query",
227
- hint: "Paginated with Suspense",
228
- },
229
- ],
230
- })
231
-
232
- if (p.isCancel(hookType)) {
233
- p.cancel("Cancelled.")
234
- process.exit(0)
235
- }
236
-
237
- // Skip this method if user chose "skip"
238
- if (hookType === "skip") {
239
- p.log.info(`Skipping ${pc.dim(method.name)}`)
240
- continue
241
- }
242
-
243
- methodsWithHookTypes.push({ method, hookType: hookType as HookType })
244
- } else {
245
- // For mutations, also allow skipping
246
- const hookType = await p.select({
247
- message: `Hook type for ${pc.yellow(method.name)} (mutation)`,
248
- options: [
249
- {
250
- value: "mutation",
251
- label: "Mutation",
252
- hint: "Standard mutation hook",
253
- },
254
- {
255
- value: "skip",
256
- label: "Skip",
257
- hint: "Don't generate hook for this method",
258
- },
259
- ],
260
- })
261
-
262
- if (p.isCancel(hookType)) {
263
- p.cancel("Cancelled.")
264
- process.exit(0)
265
- }
266
-
267
- if (hookType === "skip") {
268
- p.log.info(`Skipping ${pc.dim(method.name)}`)
269
- continue
270
- }
271
-
272
- methodsWithHookTypes.push({ method, hookType: "mutation" })
273
- }
274
- }
275
-
276
- // Check if any methods were selected after skipping
277
- if (methodsWithHookTypes.length === 0) {
278
- p.log.warn("All methods were skipped. Nothing to generate.")
279
- process.exit(0)
280
- }
281
-
282
- // Load or create config
283
- let configPath = findConfigFile()
284
- let config: ReactorConfig
285
-
286
- if (!configPath) {
287
- // Create default config
288
- configPath = path.join(projectRoot, CONFIG_FILE_NAME)
289
- config = { ...DEFAULT_CONFIG }
290
- p.log.info(`Creating ${pc.yellow(CONFIG_FILE_NAME)}`)
291
- } else {
292
- config = loadConfig(configPath) ?? { ...DEFAULT_CONFIG }
293
- }
294
-
295
- // Add canister to config
296
- const canisterConfig: CanisterConfig = {
297
- didFile: `./candid/${canisterName}.did`,
298
- useDisplayReactor: true,
299
- canisterId: canisterId,
300
- }
301
-
302
- config.canisters[canisterName] = canisterConfig
303
-
304
- // Generate files
305
- const canisterOutDir = path.join(projectRoot, config.outDir, canisterName)
306
- const hooksOutDir = path.join(canisterOutDir, "hooks")
307
- const candidDir = path.join(projectRoot, "candid")
308
-
309
- ensureDir(hooksOutDir)
310
- ensureDir(candidDir)
311
-
312
- const genSpinner = p.spinner()
313
- genSpinner.start("Generating hooks...")
314
-
315
- const generatedFiles: string[] = []
316
-
317
- // Save the Candid source file
318
- const candidPath = path.join(candidDir, `${canisterName}.did`)
319
- fs.writeFileSync(candidPath, candidSource)
320
- generatedFiles.push(`candid/${canisterName}.did`)
321
-
322
- // Generate TypeScript declarations using bindgen
323
- genSpinner.message("Generating TypeScript declarations...")
324
- const bindgenResult = await generateDeclarations({
325
- didFile: candidPath,
326
- outDir: canisterOutDir,
327
- canisterName,
328
- })
329
-
330
- if (bindgenResult.success) {
331
- generatedFiles.push("declarations/")
332
- } else {
333
- p.log.warn(`Could not generate declarations: ${bindgenResult.error}`)
334
- p.log.info(
335
- `You can manually run: npx @icp-sdk/bindgen --input ${candidPath} --output ${canisterOutDir}/declarations`
336
- )
337
- }
338
-
339
- // Generate reactor.ts
340
- genSpinner.message("Generating reactor...")
341
- const reactorPath = path.join(canisterOutDir, "reactor.ts")
342
- const reactorContent = generateReactorFileForFetch({
343
- canisterName,
344
- canisterConfig,
345
- config,
346
- canisterId,
347
- hasDeclarations: bindgenResult.success,
348
- })
349
- fs.writeFileSync(reactorPath, reactorContent)
350
- generatedFiles.push("reactor.ts")
351
-
352
- // Generate hooks for each method
353
- for (const { method, hookType } of methodsWithHookTypes) {
354
- const fileName = getHookFileName(method.name, hookType)
355
- const filePath = path.join(hooksOutDir, fileName)
356
-
357
- let content: string
358
-
359
- switch (hookType) {
360
- case "query":
361
- case "suspenseQuery":
362
- content = generateQueryHook({
363
- canisterName,
364
- method,
365
- config,
366
- })
367
- break
368
- case "infiniteQuery":
369
- case "suspenseInfiniteQuery":
370
- content = generateInfiniteQueryHook({
371
- canisterName,
372
- method,
373
- config,
374
- })
375
- break
376
- case "mutation":
377
- content = generateMutationHook({
378
- canisterName,
379
- method,
380
- config,
381
- })
382
- break
383
- }
384
-
385
- fs.writeFileSync(filePath, content)
386
- generatedFiles.push(path.join("hooks", fileName))
387
- }
388
-
389
- // Generate index.ts barrel export
390
- const indexPath = path.join(hooksOutDir, "index.ts")
391
- const indexContent = generateIndexFile(methodsWithHookTypes)
392
- fs.writeFileSync(indexPath, indexContent)
393
- generatedFiles.push("hooks/index.ts")
394
-
395
- // Update config with generated hooks
396
- config.generatedHooks[canisterName] = [
397
- ...new Set([
398
- ...(config.generatedHooks[canisterName] ?? []),
399
- ...selectedMethods.map((m) => m.name),
400
- ]),
401
- ]
402
- saveConfig(config, configPath)
403
-
404
- genSpinner.stop("Hooks generated!")
405
-
406
- // Display results
407
- console.log()
408
- p.note(
409
- generatedFiles.map((f) => pc.green(`✓ ${f}`)).join("\n"),
410
- `Generated in ${pc.dim(path.relative(projectRoot, canisterOutDir))}`
411
- )
412
-
413
- console.log()
414
- p.note(
415
- `Canister ID: ${pc.cyan(canisterId)}\n` +
416
- `Network: ${pc.yellow(network)}\n` +
417
- `Name: ${pc.green(canisterName)}\n` +
418
- `Methods: ${pc.dim(selectedMethods.map((m) => m.name).join(", "))}`,
419
- "Canister Info"
420
- )
421
-
422
- p.outro(pc.green("✓ Done!"))
423
- }
424
-
425
- /**
426
- * Generate reactor file specifically for fetched canisters
427
- * (with canister ID hardcoded or from environment)
428
- */
429
- function generateReactorFileForFetch(options: {
430
- canisterName: string
431
- canisterConfig: CanisterConfig
432
- config: ReactorConfig
433
- canisterId: string
434
- hasDeclarations: boolean
435
- }): string {
436
- const { canisterName, canisterConfig, config, canisterId, hasDeclarations } =
437
- options
438
-
439
- const pascalName =
440
- canisterName.charAt(0).toUpperCase() + canisterName.slice(1)
441
- const reactorName = `${canisterName}Reactor`
442
- const serviceName = `${pascalName}Service`
443
- const reactorType =
444
- canisterConfig.useDisplayReactor !== false ? "DisplayReactor" : "Reactor"
445
-
446
- // Calculate relative path to client manager (canister-specific → global → default)
447
- const clientManagerPath =
448
- canisterConfig.clientManagerPath ??
449
- config.clientManagerPath ??
450
- "../../lib/client"
451
-
452
- // Calculate relative path to declarations (use DID file name)
453
- const didFileName = path.basename(canisterConfig.didFile)
454
-
455
- // Generate different imports based on whether declarations were generated
456
- if (hasDeclarations) {
457
- return `/**
458
- * ${pascalName} Reactor
459
- *
460
- * Auto-generated by @ic-reactor/cli
461
- * Fetched from canister: ${canisterId}
462
- *
463
- * You can customize this file to add global configuration.
464
- */
465
-
466
- import { ${reactorType}, createActorHooks } from "@ic-reactor/react"
467
- import { clientManager } from "${clientManagerPath}"
468
-
469
- // Import generated declarations
470
- import { idlFactory, type _SERVICE as ${serviceName} } from "./declarations/${didFileName}"
471
-
472
- // ═══════════════════════════════════════════════════════════════════════════
473
- // REACTOR INSTANCE
474
- // ═══════════════════════════════════════════════════════════════════════════
475
-
476
- /**
477
- * ${pascalName} Reactor
478
- *
479
- * Canister ID: ${canisterId}
480
- */
481
- export const ${reactorName} = new ${reactorType}<${serviceName}>({
482
- clientManager,
483
- idlFactory,
484
- canisterId: "${canisterId}",
485
- name: "${canisterName}",
486
- })
487
-
488
- // ═══════════════════════════════════════════════════════════════════════════
489
- // ACTOR HOOKS
490
- // ═══════════════════════════════════════════════════════════════════════════
491
-
492
- /**
493
- * Actor hooks for ${canisterName} - use these directly or import method-specific hooks.
494
- */
495
- export const {
496
- useActorQuery,
497
- useActorMutation,
498
- useActorSuspenseQuery,
499
- useActorInfiniteQuery,
500
- useActorSuspenseInfiniteQuery,
501
- useActorMethod,
502
- } = createActorHooks(${reactorName})
503
-
504
- // ═══════════════════════════════════════════════════════════════════════════
505
- // RE-EXPORTS
506
- // ═══════════════════════════════════════════════════════════════════════════
507
-
508
- export { idlFactory }
509
- export type { ${serviceName} }
510
- `
511
- }
512
-
513
- // Fallback when declarations weren't generated
514
- return `/**
515
- * ${pascalName} Reactor
516
- *
517
- * Auto-generated by @ic-reactor/cli
518
- * Fetched from canister: ${canisterId}
519
- *
520
- * You can customize this file to add global configuration.
521
- */
522
-
523
- import { ${reactorType}, createActorHooks } from "@ic-reactor/react"
524
- import { clientManager } from "${clientManagerPath}"
525
-
526
- // ═══════════════════════════════════════════════════════════════════════════
527
- // DECLARATIONS
528
- // ═══════════════════════════════════════════════════════════════════════════
529
-
530
- // TODO: Generate proper types by running:
531
- // npx @icp-sdk/bindgen --input ./candid/${canisterName}.did --output ./${canisterName}/declarations
532
-
533
- // For now, import just the IDL factory (you may need to create this manually)
534
- // import { idlFactory, type _SERVICE as ${serviceName} } from "./declarations/${didFileName}"
535
-
536
- // Fallback generic type - replace with generated types
537
- type ${serviceName} = Record<string, (...args: unknown[]) => Promise<unknown>>
538
-
539
- // You'll need to define idlFactory here or import from declarations
540
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
541
- const idlFactory = ({ IDL }: { IDL: any }) => IDL.Service({})
542
-
543
- // ═══════════════════════════════════════════════════════════════════════════
544
- // REACTOR INSTANCE
545
- // ═══════════════════════════════════════════════════════════════════════════
546
-
547
- /**
548
- * ${pascalName} Reactor
549
- *
550
- * Canister ID: ${canisterId}
551
- */
552
- export const ${reactorName} = new ${reactorType}<${serviceName}>({
553
- clientManager,
554
- idlFactory,
555
- canisterId: "${canisterId}",
556
- name: "${canisterName}",
557
- })
558
-
559
- // ═══════════════════════════════════════════════════════════════════════════
560
- // ACTOR HOOKS
561
- // ═══════════════════════════════════════════════════════════════════════════
562
-
563
- /**
564
- * Actor hooks for ${canisterName} - use these directly or import method-specific hooks.
565
- */
566
- export const {
567
- useActorQuery,
568
- useActorMutation,
569
- useActorSuspenseQuery,
570
- useActorInfiniteQuery,
571
- useActorSuspenseInfiniteQuery,
572
- useActorMethod,
573
- } = createActorHooks(${reactorName})
574
-
575
- // ═══════════════════════════════════════════════════════════════════════════
576
- // RE-EXPORTS
577
- // ═══════════════════════════════════════════════════════════════════════════
578
-
579
- export { idlFactory }
580
- export type { ${serviceName} }
581
- `
582
- }
583
-
584
- function generateIndexFile(
585
- methods: Array<{ method: MethodInfo; hookType: HookType }>
586
- ): string {
587
- const exports = methods.map(({ method, hookType }) => {
588
- const fileName = getHookFileName(method.name, hookType).replace(".ts", "")
589
- return `export * from "./${fileName}"`
590
- })
591
-
592
- return `/**
593
- * Hook barrel exports
594
- *
595
- * Auto-generated by @ic-reactor/cli
596
- */
597
-
598
- ${exports.join("\n")}
599
- `
600
- }
@@ -1,34 +0,0 @@
1
- /**
2
- * Infinite Query hook generator
3
- *
4
- * Generates createInfiniteQuery-based hooks for paginated canister methods.
5
- */
6
-
7
- import {
8
- type MethodInfo,
9
- type HookType,
10
- generateInfiniteQueryHook as generateInfiniteQueryHookFromCodegen,
11
- } from "@ic-reactor/codegen"
12
- import type { ReactorConfig } from "../types.js"
13
-
14
- export interface InfiniteQueryHookOptions {
15
- canisterName: string
16
- method: MethodInfo
17
- type?: HookType
18
- config: ReactorConfig // Unused
19
- }
20
-
21
- /**
22
- * Generate an infinite query hook file content
23
- */
24
- export function generateInfiniteQueryHook(
25
- options: InfiniteQueryHookOptions
26
- ): string {
27
- const { canisterName, method, type } = options
28
-
29
- return generateInfiniteQueryHookFromCodegen({
30
- canisterName,
31
- method,
32
- type,
33
- })
34
- }
@@ -1,29 +0,0 @@
1
- /**
2
- * Mutation hook generator
3
- *
4
- * Generates createMutation-based hooks for canister update methods.
5
- */
6
-
7
- import {
8
- type MethodInfo,
9
- generateMutationHook as generateMutationHookFromCodegen,
10
- } from "@ic-reactor/codegen"
11
- import type { ReactorConfig } from "../types.js"
12
-
13
- export interface MutationHookOptions {
14
- canisterName: string
15
- method: MethodInfo
16
- config: ReactorConfig // Unused
17
- }
18
-
19
- /**
20
- * Generate a mutation hook file content
21
- */
22
- export function generateMutationHook(options: MutationHookOptions): string {
23
- const { canisterName, method } = options
24
-
25
- return generateMutationHookFromCodegen({
26
- canisterName,
27
- method,
28
- })
29
- }
@@ -1,32 +0,0 @@
1
- /**
2
- * Query hook generator
3
- *
4
- * Generates createQuery-based hooks for canister query methods.
5
- */
6
-
7
- import {
8
- type MethodInfo,
9
- type HookType,
10
- generateQueryHook as generateQueryHookFromCodegen,
11
- } from "@ic-reactor/codegen"
12
- import type { ReactorConfig } from "../types.js"
13
-
14
- export interface QueryHookOptions {
15
- canisterName: string
16
- method: MethodInfo
17
- config: ReactorConfig // Not used by codegen but kept for CLI consistency if needed
18
- type?: HookType
19
- }
20
-
21
- /**
22
- * Generate a query hook file content
23
- */
24
- export function generateQueryHook(options: QueryHookOptions): string {
25
- const { canisterName, method, type } = options
26
-
27
- return generateQueryHookFromCodegen({
28
- canisterName,
29
- method,
30
- type,
31
- })
32
- }