@ic-reactor/cli 0.0.0-dev3 → 0.1.3

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.
@@ -0,0 +1,224 @@
1
+ /**
2
+ * Init command
3
+ *
4
+ * Initializes ic-reactor configuration in the project.
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
+ CONFIG_FILE_NAME,
13
+ DEFAULT_CONFIG,
14
+ findConfigFile,
15
+ saveConfig,
16
+ getProjectRoot,
17
+ ensureDir,
18
+ } from "../utils/config.js"
19
+ import type { ReactorConfig, CanisterConfig } from "../types.js"
20
+
21
+ interface InitOptions {
22
+ yes?: boolean
23
+ outDir?: string
24
+ }
25
+
26
+ export async function initCommand(options: InitOptions) {
27
+ console.log()
28
+ p.intro(pc.cyan("🔧 ic-reactor CLI Setup"))
29
+
30
+ // Check if config already exists
31
+ const existingConfig = findConfigFile()
32
+ if (existingConfig) {
33
+ const shouldOverwrite = await p.confirm({
34
+ message: `Config file already exists at ${pc.yellow(existingConfig)}. Overwrite?`,
35
+ initialValue: false,
36
+ })
37
+
38
+ if (p.isCancel(shouldOverwrite) || !shouldOverwrite) {
39
+ p.cancel("Setup cancelled.")
40
+ process.exit(0)
41
+ }
42
+ }
43
+
44
+ const projectRoot = getProjectRoot()
45
+ let config: ReactorConfig
46
+
47
+ if (options.yes) {
48
+ // Use defaults
49
+ config = { ...DEFAULT_CONFIG }
50
+ if (options.outDir) {
51
+ config.outDir = options.outDir
52
+ }
53
+ } else {
54
+ // Interactive mode
55
+ const outDir = await p.text({
56
+ message: "Where should generated hooks be placed?",
57
+ placeholder: "src/canisters",
58
+ defaultValue: "src/canisters",
59
+ validate: (value) => {
60
+ if (!value) return "Output directory is required"
61
+ return undefined
62
+ },
63
+ })
64
+
65
+ if (p.isCancel(outDir)) {
66
+ p.cancel("Setup cancelled.")
67
+ process.exit(0)
68
+ }
69
+
70
+ // Ask if they want to add a canister now
71
+ const addCanister = await p.confirm({
72
+ message: "Would you like to add a canister now?",
73
+ initialValue: true,
74
+ })
75
+
76
+ if (p.isCancel(addCanister)) {
77
+ p.cancel("Setup cancelled.")
78
+ process.exit(0)
79
+ }
80
+
81
+ config = {
82
+ ...DEFAULT_CONFIG,
83
+ outDir: outDir as string,
84
+ }
85
+
86
+ if (addCanister) {
87
+ const canisterInfo = await promptForCanister(projectRoot)
88
+ if (canisterInfo) {
89
+ config.canisters[canisterInfo.name] = canisterInfo.config
90
+ }
91
+ }
92
+ }
93
+
94
+ // Save config file
95
+ const configPath = path.join(projectRoot, CONFIG_FILE_NAME)
96
+ saveConfig(config, configPath)
97
+
98
+ // Create output directory
99
+ const fullOutDir = path.join(projectRoot, config.outDir)
100
+ ensureDir(fullOutDir)
101
+
102
+ // Create a sample client manager if it doesn't exist
103
+ const clientManagerPath = path.join(projectRoot, "src/lib/client.ts")
104
+ if (!fs.existsSync(clientManagerPath)) {
105
+ const createClient = await p.confirm({
106
+ message: "Create a sample client manager at src/lib/client.ts?",
107
+ initialValue: true,
108
+ })
109
+
110
+ if (!p.isCancel(createClient) && createClient) {
111
+ ensureDir(path.dirname(clientManagerPath))
112
+ fs.writeFileSync(clientManagerPath, getClientManagerTemplate())
113
+ p.log.success(`Created ${pc.green("src/lib/client.ts")}`)
114
+ }
115
+ }
116
+
117
+ p.log.success(`Created ${pc.green(CONFIG_FILE_NAME)}`)
118
+ p.log.success(`Created ${pc.green(config.outDir)} directory`)
119
+
120
+ console.log()
121
+ p.note(
122
+ `Next steps:
123
+
124
+ 1. ${pc.cyan("Add a canister:")}
125
+ ${pc.dim("npx @ic-reactor/cli add")}
126
+
127
+ 2. ${pc.cyan("List available methods:")}
128
+ ${pc.dim("npx @ic-reactor/cli list -c <canister-name>")}
129
+
130
+ 3. ${pc.cyan("Add hooks for specific methods:")}
131
+ ${pc.dim("npx @ic-reactor/cli add -c <canister> -m <method>")}`,
132
+ "Getting Started"
133
+ )
134
+
135
+ p.outro(pc.green("✓ ic-reactor initialized successfully!"))
136
+ }
137
+
138
+ async function promptForCanister(
139
+ projectRoot: string
140
+ ): Promise<{ name: string; config: CanisterConfig } | null> {
141
+ const name = await p.text({
142
+ message: "Canister name",
143
+ placeholder: "backend",
144
+ validate: (value) => {
145
+ if (!value) return "Canister name is required"
146
+ if (!/^[a-zA-Z][a-zA-Z0-9_-]*$/.test(value)) {
147
+ return "Canister name must start with a letter and contain only letters, numbers, hyphens, and underscores"
148
+ }
149
+ return undefined
150
+ },
151
+ })
152
+
153
+ if (p.isCancel(name)) return null
154
+
155
+ const didFile = await p.text({
156
+ message: "Path to .did file",
157
+ placeholder: "./backend.did",
158
+ validate: (value) => {
159
+ if (!value) return "DID file path is required"
160
+ const fullPath = path.resolve(projectRoot, value)
161
+ if (!fs.existsSync(fullPath)) {
162
+ return `File not found: ${value}`
163
+ }
164
+ return undefined
165
+ },
166
+ })
167
+
168
+ if (p.isCancel(didFile)) return null
169
+
170
+ const clientManagerPath = await p.text({
171
+ message:
172
+ "Import path to your client manager (relative from generated hooks)",
173
+ placeholder: "../../lib/client",
174
+ defaultValue: "../../lib/client",
175
+ })
176
+
177
+ if (p.isCancel(clientManagerPath)) return null
178
+
179
+ const useDisplayReactor = await p.confirm({
180
+ message:
181
+ "Use DisplayReactor? (auto-converts bigint → string, Principal → string)",
182
+ initialValue: true,
183
+ })
184
+
185
+ if (p.isCancel(useDisplayReactor)) return null
186
+
187
+ return {
188
+ name: name as string,
189
+ config: {
190
+ didFile: didFile as string,
191
+ clientManagerPath: clientManagerPath as string,
192
+ useDisplayReactor: useDisplayReactor as boolean,
193
+ },
194
+ }
195
+ }
196
+
197
+ function getClientManagerTemplate(): string {
198
+ return `/**
199
+ * IC Client Manager
200
+ *
201
+ * This file configures the IC agent and client manager for your application.
202
+ * Customize the agent options based on your environment.
203
+ */
204
+
205
+ import { ClientManager } from "@ic-reactor/react"
206
+
207
+ /**
208
+ * The client manager handles agent lifecycle and authentication.
209
+ *
210
+ * Configuration options:
211
+ * - host: IC network host (defaults to process env or mainnet)
212
+ * - identity: Initial identity (optional, can be set later)
213
+ * - verifyQuerySignatures: Verify query signatures (recommended for production)
214
+ *
215
+ * For local development, the agent will automatically detect local replica.
216
+ */
217
+ export const clientManager = new ClientManager({
218
+ // Uncomment for explicit host configuration:
219
+ // host: process.env.DFX_NETWORK === "local"
220
+ // ? "http://localhost:4943"
221
+ // : "https://icp-api.io",
222
+ })
223
+ `
224
+ }
@@ -0,0 +1,144 @@
1
+ /**
2
+ * List command
3
+ *
4
+ * List available methods from a canister's DID file.
5
+ */
6
+
7
+ import * as p from "@clack/prompts"
8
+ import path from "node:path"
9
+ import pc from "picocolors"
10
+ import { loadConfig, getProjectRoot, findConfigFile } from "../utils/config.js"
11
+ import { parseDIDFile } from "@ic-reactor/codegen"
12
+
13
+ interface ListOptions {
14
+ canister?: string
15
+ }
16
+
17
+ export async function listCommand(options: ListOptions) {
18
+ console.log()
19
+ p.intro(pc.cyan("📋 List Canister Methods"))
20
+
21
+ // Load config
22
+ const configPath = findConfigFile()
23
+ if (!configPath) {
24
+ p.log.error(
25
+ `No ${pc.yellow("reactor.config.json")} found. Run ${pc.cyan("npx @ic-reactor/cli init")} first.`
26
+ )
27
+ process.exit(1)
28
+ }
29
+
30
+ const config = loadConfig(configPath)
31
+ if (!config) {
32
+ p.log.error(`Failed to load config from ${pc.yellow(configPath)}`)
33
+ process.exit(1)
34
+ }
35
+
36
+ const projectRoot = getProjectRoot()
37
+ const canisterNames = Object.keys(config.canisters)
38
+
39
+ if (canisterNames.length === 0) {
40
+ p.log.error(
41
+ `No canisters configured. Add a canister to ${pc.yellow("reactor.config.json")} first.`
42
+ )
43
+ process.exit(1)
44
+ }
45
+
46
+ // Select canister
47
+ let selectedCanister = options.canister
48
+
49
+ if (!selectedCanister) {
50
+ if (canisterNames.length === 1) {
51
+ selectedCanister = canisterNames[0]
52
+ } else {
53
+ const result = await p.select({
54
+ message: "Select a canister",
55
+ options: canisterNames.map((name) => ({
56
+ value: name,
57
+ label: name,
58
+ })),
59
+ })
60
+
61
+ if (p.isCancel(result)) {
62
+ p.cancel("Cancelled.")
63
+ process.exit(0)
64
+ }
65
+
66
+ selectedCanister = result as string
67
+ }
68
+ }
69
+
70
+ const canisterConfig = config.canisters[selectedCanister]
71
+ if (!canisterConfig) {
72
+ p.log.error(`Canister ${pc.yellow(selectedCanister)} not found in config.`)
73
+ process.exit(1)
74
+ }
75
+
76
+ // Parse DID file
77
+ const didFilePath = path.resolve(projectRoot, canisterConfig.didFile)
78
+
79
+ try {
80
+ const methods = parseDIDFile(didFilePath)
81
+
82
+ if (methods.length === 0) {
83
+ p.log.warn(`No methods found in ${pc.yellow(didFilePath)}`)
84
+ process.exit(0)
85
+ }
86
+
87
+ const queries = methods.filter((m) => m.type === "query")
88
+ const mutations = methods.filter((m) => m.type === "mutation")
89
+ const generatedMethods = config.generatedHooks[selectedCanister] ?? []
90
+
91
+ // Display queries
92
+ if (queries.length > 0) {
93
+ console.log()
94
+ console.log(pc.bold(pc.cyan(" Queries:")))
95
+ for (const method of queries) {
96
+ const isGenerated = generatedMethods.includes(method.name)
97
+ const status = isGenerated ? pc.green("✓") : pc.dim("○")
98
+ const argsHint = method.hasArgs ? pc.dim("(args)") : pc.dim("()")
99
+ console.log(` ${status} ${method.name} ${argsHint}`)
100
+ }
101
+ }
102
+
103
+ // Display mutations
104
+ if (mutations.length > 0) {
105
+ console.log()
106
+ console.log(pc.bold(pc.yellow(" Mutations (Updates):")))
107
+ for (const method of mutations) {
108
+ const isGenerated = generatedMethods.includes(method.name)
109
+ const status = isGenerated ? pc.green("✓") : pc.dim("○")
110
+ const argsHint = method.hasArgs ? pc.dim("(args)") : pc.dim("()")
111
+ console.log(` ${status} ${method.name} ${argsHint}`)
112
+ }
113
+ }
114
+
115
+ // Summary
116
+ console.log()
117
+ const generatedCount = generatedMethods.length
118
+ const totalCount = methods.length
119
+
120
+ p.note(
121
+ `Total: ${pc.bold(totalCount.toString())} methods\n` +
122
+ `Generated: ${pc.green(generatedCount.toString())} / ${totalCount}\n\n` +
123
+ `${pc.green("✓")} = hook generated\n` +
124
+ `${pc.dim("○")} = not yet generated`,
125
+ selectedCanister
126
+ )
127
+
128
+ if (generatedCount < totalCount) {
129
+ console.log()
130
+ console.log(
131
+ pc.dim(
132
+ ` Run ${pc.cyan(`npx @ic-reactor/cli add -c ${selectedCanister}`)} to add hooks`
133
+ )
134
+ )
135
+ }
136
+ } catch (error) {
137
+ p.log.error(
138
+ `Failed to parse DID file: ${pc.yellow(didFilePath)}\n${(error as Error).message}`
139
+ )
140
+ process.exit(1)
141
+ }
142
+
143
+ console.log()
144
+ }
@@ -0,0 +1,259 @@
1
+ /**
2
+ * Sync command
3
+ *
4
+ * Regenerate hooks when DID files change.
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
+ getProjectRoot,
14
+ findConfigFile,
15
+ ensureDir,
16
+ } from "../utils/config.js"
17
+ import { parseDIDFile } from "@ic-reactor/codegen"
18
+ import {
19
+ generateReactorFile,
20
+ generateQueryHook,
21
+ generateMutationHook,
22
+ } from "../generators/index.js"
23
+ import { getHookFileName } from "@ic-reactor/codegen"
24
+ import { generateDeclarations } from "@ic-reactor/codegen"
25
+ import type { MethodInfo } from "@ic-reactor/codegen"
26
+
27
+ interface SyncOptions {
28
+ canister?: string
29
+ }
30
+
31
+ export async function syncCommand(options: SyncOptions) {
32
+ console.log()
33
+ p.intro(pc.cyan("🔄 Sync Canister Hooks"))
34
+
35
+ // Load config
36
+ const configPath = findConfigFile()
37
+ if (!configPath) {
38
+ p.log.error(
39
+ `No ${pc.yellow("reactor.config.json")} found. Run ${pc.cyan("npx @ic-reactor/cli init")} first.`
40
+ )
41
+ process.exit(1)
42
+ }
43
+
44
+ const config = loadConfig(configPath)
45
+ if (!config) {
46
+ p.log.error(`Failed to load config from ${pc.yellow(configPath)}`)
47
+ process.exit(1)
48
+ }
49
+
50
+ const projectRoot = getProjectRoot()
51
+ const canisterNames = Object.keys(config.canisters)
52
+
53
+ if (canisterNames.length === 0) {
54
+ p.log.error("No canisters configured.")
55
+ process.exit(1)
56
+ }
57
+
58
+ // Select canisters to sync
59
+ let canistersToSync: string[]
60
+
61
+ if (options.canister) {
62
+ if (!config.canisters[options.canister]) {
63
+ p.log.error(
64
+ `Canister ${pc.yellow(options.canister)} not found in config.`
65
+ )
66
+ process.exit(1)
67
+ }
68
+ canistersToSync = [options.canister]
69
+ } else {
70
+ // Sync all canisters with generated hooks
71
+ canistersToSync = canisterNames.filter(
72
+ (name) => (config.generatedHooks[name]?.length ?? 0) > 0
73
+ )
74
+
75
+ if (canistersToSync.length === 0) {
76
+ p.log.warn("No hooks have been generated yet. Run `add` first.")
77
+ process.exit(0)
78
+ }
79
+ }
80
+
81
+ const spinner = p.spinner()
82
+ spinner.start("Syncing hooks...")
83
+
84
+ let totalUpdated = 0
85
+ let totalSkipped = 0
86
+ const errors: string[] = []
87
+
88
+ for (const canisterName of canistersToSync) {
89
+ const canisterConfig = config.canisters[canisterName]
90
+ const generatedMethods = config.generatedHooks[canisterName] ?? []
91
+
92
+ if (generatedMethods.length === 0) {
93
+ continue
94
+ }
95
+
96
+ // Parse DID file
97
+ const didFilePath = path.resolve(projectRoot, canisterConfig.didFile)
98
+ let methods: MethodInfo[]
99
+
100
+ try {
101
+ methods = parseDIDFile(didFilePath)
102
+ } catch (error) {
103
+ errors.push(
104
+ `${canisterName}: Failed to parse DID file - ${(error as Error).message}`
105
+ )
106
+ continue
107
+ }
108
+
109
+ // Normalize hooks to objects
110
+ const hooks = generatedMethods.map((h) =>
111
+ typeof h === "string" ? { name: h } : h
112
+ )
113
+
114
+ // Check for removed methods
115
+ const currentMethodNames = methods.map((m) => m.name)
116
+ const removedMethods = hooks
117
+ .filter((h) => !currentMethodNames.includes(h.name))
118
+ .map((h) => h.name)
119
+
120
+ if (removedMethods.length > 0) {
121
+ p.log.warn(
122
+ `${canisterName}: Methods removed from DID: ${pc.yellow(removedMethods.join(", "))}`
123
+ )
124
+ }
125
+
126
+ // Check for new methods
127
+ const generatedNames = hooks.map((h) => h.name)
128
+ const newMethods = methods.filter((m) => !generatedNames.includes(m.name))
129
+
130
+ if (newMethods.length > 0) {
131
+ p.log.info(
132
+ `${canisterName}: New methods available: ${pc.cyan(newMethods.map((m) => m.name).join(", "))}`
133
+ )
134
+ }
135
+
136
+ // Regenerate declarations if missing
137
+ const canisterOutDir = path.join(projectRoot, config.outDir, canisterName)
138
+ const declarationsDir = path.join(canisterOutDir, "declarations")
139
+
140
+ // Check if declarations exist and have files (not just nested empty dir)
141
+ const declarationsExist =
142
+ fs.existsSync(declarationsDir) &&
143
+ fs
144
+ .readdirSync(declarationsDir)
145
+ .some((f) => f.endsWith(".ts") || f.endsWith(".js"))
146
+
147
+ if (!declarationsExist) {
148
+ spinner.message(`Regenerating declarations for ${canisterName}...`)
149
+ const bindgenResult = await generateDeclarations({
150
+ didFile: didFilePath,
151
+ outDir: canisterOutDir,
152
+ canisterName,
153
+ })
154
+
155
+ if (bindgenResult.success) {
156
+ totalUpdated++
157
+ } else {
158
+ p.log.warn(
159
+ `Could not regenerate declarations for ${canisterName}: ${bindgenResult.error}`
160
+ )
161
+ }
162
+ }
163
+
164
+ // Regenerate reactor.ts
165
+ const reactorPath = path.join(canisterOutDir, "reactor.ts")
166
+
167
+ const reactorContent = generateReactorFile({
168
+ canisterName,
169
+ canisterConfig,
170
+ config,
171
+ outDir: canisterOutDir,
172
+ })
173
+ fs.writeFileSync(reactorPath, reactorContent)
174
+ totalUpdated++
175
+
176
+ // Regenerate existing hooks
177
+ const hooksOutDir = path.join(canisterOutDir, "hooks")
178
+ ensureDir(hooksOutDir)
179
+
180
+ for (const hookConfig of hooks) {
181
+ const methodName = hookConfig.name
182
+ const method = methods.find((m) => m.name === methodName)
183
+
184
+ if (!method) {
185
+ // Method was removed, skip but warn
186
+ totalSkipped++
187
+ continue
188
+ }
189
+
190
+ // Determine hook type
191
+ let hookType: string = hookConfig.type || (method.type as string)
192
+
193
+ // If no explicit type in config, try to infer from existing files (backward compat)
194
+ if (!hookConfig.type) {
195
+ const infiniteQueryFileName = getHookFileName(
196
+ methodName,
197
+ "infiniteQuery"
198
+ )
199
+ if (fs.existsSync(path.join(hooksOutDir, infiniteQueryFileName))) {
200
+ hookType = "infiniteQuery"
201
+ }
202
+ }
203
+
204
+ const fileName = getHookFileName(methodName, hookType)
205
+ let content: string
206
+
207
+ if (hookType.includes("Query")) {
208
+ content = generateQueryHook({
209
+ canisterName,
210
+ method,
211
+ config,
212
+ type: hookType as any,
213
+ })
214
+ } else {
215
+ content = generateMutationHook({
216
+ canisterName,
217
+ method,
218
+ config,
219
+ })
220
+ }
221
+
222
+ const filePath = path.join(hooksOutDir, fileName)
223
+
224
+ // Check if file exists and has been customized
225
+ if (fs.existsSync(filePath)) {
226
+ const existingContent = fs.readFileSync(filePath, "utf-8")
227
+
228
+ // If content is different from what we'd generate, skip (user customized)
229
+ if (existingContent !== content) {
230
+ totalSkipped++
231
+ continue
232
+ }
233
+ }
234
+
235
+ fs.writeFileSync(filePath, content)
236
+ totalUpdated++
237
+ }
238
+ }
239
+
240
+ spinner.stop("Sync complete!")
241
+
242
+ // Display results
243
+ if (errors.length > 0) {
244
+ console.log()
245
+ p.log.error("Errors encountered:")
246
+ for (const error of errors) {
247
+ console.log(` ${pc.red("•")} ${error}`)
248
+ }
249
+ }
250
+
251
+ console.log()
252
+ p.note(
253
+ `Updated: ${pc.green(totalUpdated.toString())} files\n` +
254
+ `Skipped: ${pc.dim(totalSkipped.toString())} files (preserved customizations)`,
255
+ "Summary"
256
+ )
257
+
258
+ p.outro(pc.green("✓ Sync complete!"))
259
+ }
@@ -0,0 +1,8 @@
1
+ /**
2
+ * Generators barrel export
3
+ */
4
+
5
+ export { generateReactorFile } from "./reactor.js"
6
+ export { generateQueryHook } from "./query.js"
7
+ export { generateMutationHook } from "./mutation.js"
8
+ export { generateInfiniteQueryHook } from "./infiniteQuery.js"
@@ -0,0 +1,34 @@
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
+ }
@@ -0,0 +1,29 @@
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
+ }