@ic-reactor/codegen 0.12.0 → 0.13.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.
@@ -14,6 +14,7 @@ import { didToJs, didToTs } from "@ic-reactor/parser"
14
14
  import path from "node:path"
15
15
  import fs from "node:fs"
16
16
  import type { GeneratorResult } from "../types.js"
17
+ import { CodegenConfigError, resolveDeclarationsBaseName } from "../validate.js"
17
18
 
18
19
  export interface DeclarationsGeneratorOptions {
19
20
  /** Absolute path to the .did file */
@@ -31,18 +32,95 @@ export interface DeclarationsGeneratorResult {
31
32
  error?: string
32
33
  }
33
34
 
35
+ /**
36
+ * Move `from` onto `to`, replacing whatever is already there.
37
+ *
38
+ * `rename(2)` refuses to replace a non-empty directory, so the previous output
39
+ * is moved aside first and only removed once the new directory is in place. If
40
+ * the second rename fails the old directory is put back, so the observable
41
+ * states are "old output" and "new output" — never "no output".
42
+ */
43
+ function replaceDirectory(from: string, to: string): void {
44
+ if (!fs.existsSync(to)) {
45
+ fs.renameSync(from, to)
46
+ return
47
+ }
48
+
49
+ // Same parent as `to`, so this is a rename within one filesystem. Dot-prefixed
50
+ // because a crash between the two renames below strands this directory next
51
+ // to the real one: `declarations.old-XYZ` sits inside the project's source
52
+ // tree and TypeScript would compile it, producing duplicate declarations on
53
+ // top of whatever the crash already caused.
54
+ const displaced = fs.mkdtempSync(
55
+ path.join(path.dirname(to), `.${path.basename(to)}.old-`)
56
+ )
57
+ // mkdtemp created the directory; rename needs the name to be free.
58
+ fs.rmdirSync(displaced)
59
+ fs.renameSync(to, displaced)
60
+
61
+ try {
62
+ fs.renameSync(from, to)
63
+ } catch (error) {
64
+ fs.renameSync(displaced, to)
65
+ throw error
66
+ }
67
+
68
+ fs.rmSync(displaced, { recursive: true, force: true })
69
+ }
70
+
34
71
  /**
35
72
  * Generate TypeScript declarations from a Candid file.
36
73
  *
37
- * Always cleans and regenerates the declarations directory to ensure
38
- * it's in sync with the source .did file.
74
+ * Generation happens in a staging directory that is swapped over the existing
75
+ * `declarations/` only after every file has been written. The parser runs on
76
+ * user-authored Candid and throws on a syntax error — under the vite plugin
77
+ * that is a *normal* watch-mode event, one keystroke in a .did file — so
78
+ * deleting the previous output before parsing turned every typo into a broken
79
+ * build with no declarations at all. A failure now leaves the previous
80
+ * declarations byte-identical.
81
+ */
82
+ /**
83
+ * The export the generated reactor imports. Its presence in `didToJs` output is
84
+ * the authoritative "this .did describes a service" test — see the note in
85
+ * generateDeclarations.
86
+ */
87
+ const HAS_IDL_FACTORY = /\bexport\s+const\s+idlFactory\b/
88
+
89
+ /**
90
+ * Marker naming the canister an output directory belongs to. Dot-prefixed so
91
+ * it stays out of the generated surface consumers import.
39
92
  */
93
+ export const OWNER_FILE = ".ic-reactor-owner"
94
+
40
95
  export async function generateDeclarations(
41
96
  options: DeclarationsGeneratorOptions
42
97
  ): Promise<DeclarationsGeneratorResult> {
43
98
  const { didFile, outDir, canisterName } = options
44
99
 
45
- if (!fs.existsSync(didFile)) {
100
+ const declarationsDir = path.join(outDir, "declarations")
101
+
102
+ // Checked before the file itself: an absolute `didFile` ending in ".." is not
103
+ // normalized by the caller and does exist, so without this the diagnostic
104
+ // would be about the file type rather than about the path being a directory.
105
+ let baseName: string
106
+ try {
107
+ baseName = resolveDeclarationsBaseName(didFile)
108
+ } catch (error) {
109
+ if (error instanceof CodegenConfigError) {
110
+ return {
111
+ success: false,
112
+ declarationsDir: "",
113
+ files: [],
114
+ error: `[${canisterName}] ${error.message}`,
115
+ }
116
+ }
117
+ throw error
118
+ }
119
+
120
+ let didStat: fs.Stats
121
+ try {
122
+ didStat = fs.statSync(didFile)
123
+ } catch {
46
124
  return {
47
125
  success: false,
48
126
  declarationsDir: "",
@@ -51,34 +129,72 @@ export async function generateDeclarations(
51
129
  }
52
130
  }
53
131
 
54
- const declarationsDir = path.join(outDir, "declarations")
55
- const baseName = path.basename(didFile, ".did") // e.g. "backend" from "backend.did"
132
+ // A fifo or character device reads forever. Refusing anything that is not a
133
+ // regular file turns an indefinite hang into a diagnosable error.
134
+ if (!didStat.isFile()) {
135
+ return {
136
+ success: false,
137
+ declarationsDir: "",
138
+ files: [],
139
+ error: `[${canisterName}] DID path is not a regular file: ${didFile}`,
140
+ }
141
+ }
142
+
143
+ let staging: string | undefined
56
144
 
57
145
  try {
58
146
  // Read the DID content before any directory manipulation
59
147
  const didContent = fs.readFileSync(didFile, "utf-8")
60
148
 
61
- // Ensure output dir exists
62
- if (!fs.existsSync(outDir)) {
63
- fs.mkdirSync(outDir, { recursive: true })
64
- }
149
+ const jsContent = didToJs(didContent)
150
+ const tsContent = didToTs(didContent)
65
151
 
66
- // Clean and recreate declarations dir for a fresh generation
67
- if (fs.existsSync(declarationsDir)) {
68
- fs.rmSync(declarationsDir, { recursive: true, force: true })
152
+ // A .did that parses but declares no service is a normal thing to have — a
153
+ // shared types file, say — but it compiles to a module with no `idlFactory`
154
+ // and no `_SERVICE`, which is exactly what the generated reactor imports.
155
+ // Caught here the caller gets the .did path; not caught, the consumer's
156
+ // build fails on a file we just reported as generated.
157
+ //
158
+ // The test is on the GENERATED OUTPUT, not on `parseDid(...).service`. That
159
+ // AST field is only populated for an inline body; it is null for every
160
+ // alias form — `service : S;`, `service : (args) -> S;`, `service name : S;`
161
+ // — which the parser nonetheless compiles to a complete idlFactory. Gating
162
+ // on the AST field rejected those valid files outright.
163
+ if (!HAS_IDL_FACTORY.test(jsContent)) {
164
+ return {
165
+ success: false,
166
+ declarationsDir,
167
+ files: [],
168
+ error:
169
+ `[${canisterName}] ${didFile} produces no idlFactory, so there is no service ` +
170
+ `to generate against. Point this canister at the .did file that declares its ` +
171
+ `service.`,
172
+ }
69
173
  }
70
- fs.mkdirSync(declarationsDir, { recursive: true })
71
174
 
72
- const jsContent = didToJs(didContent)
73
- const tsContent = didToTs(didContent)
175
+ // Ensure output dir exists — it is also the staging directory's parent, so
176
+ // the swap below stays a same-filesystem rename.
177
+ fs.mkdirSync(outDir, { recursive: true })
178
+
179
+ // Record which canister owns this directory. The pipeline reads it to stop
180
+ // a second canister generating into the same outDir and wiping the first
181
+ // one's declarations. It lives here rather than in index.generated.ts
182
+ // because `--bindgen-only` never writes that file, and the declarations
183
+ // replacement it skips past is exactly the destructive step.
184
+ fs.writeFileSync(path.join(outDir, OWNER_FILE), `${canisterName}\n`)
185
+
186
+ staging = fs.mkdtempSync(path.join(outDir, ".declarations.tmp-"))
74
187
 
75
188
  const jsPath = path.join(declarationsDir, `${baseName}.js`)
76
189
  const dtsPath = path.join(declarationsDir, `${baseName}.d.ts`)
77
190
  const didCopyPath = path.join(declarationsDir, `${baseName}.did`)
78
191
 
79
- fs.writeFileSync(jsPath, jsContent)
80
- fs.writeFileSync(dtsPath, tsContent)
81
- fs.writeFileSync(didCopyPath, didContent)
192
+ fs.writeFileSync(path.join(staging, `${baseName}.js`), jsContent)
193
+ fs.writeFileSync(path.join(staging, `${baseName}.d.ts`), tsContent)
194
+ fs.writeFileSync(path.join(staging, `${baseName}.did`), didContent)
195
+
196
+ replaceDirectory(staging, declarationsDir)
197
+ staging = undefined
82
198
 
83
199
  return {
84
200
  success: true,
@@ -97,16 +213,43 @@ export async function generateDeclarations(
97
213
  files: [],
98
214
  error: `[${canisterName}] Failed to generate declarations: ${message}`,
99
215
  }
216
+ } finally {
217
+ // Reached on the failure path and on an interrupted swap; a completed swap
218
+ // has already cleared `staging`.
219
+ if (staging) fs.rmSync(staging, { recursive: true, force: true })
100
220
  }
101
221
  }
102
222
 
103
223
  /**
104
224
  * Check if declarations already exist for a canister.
225
+ *
226
+ * Declarations are written under the *.did basename*, which need not equal the
227
+ * canister name — `{ name: "backend", didFile: "service.did" }` writes
228
+ * `declarations/service.d.ts`. Pass `didFile` for an exact answer; without it
229
+ * this falls back to any `.d.ts` in the directory, because a bare
230
+ * `<canisterName>.d.ts` check reports "missing" for perfectly good output.
105
231
  */
106
232
  export function declarationsExist(
107
233
  outDir: string,
108
- canisterName: string
234
+ canisterName: string,
235
+ didFile?: string
109
236
  ): boolean {
110
- const dtsPath = path.join(outDir, "declarations", `${canisterName}.d.ts`)
111
- return fs.existsSync(dtsPath)
237
+ const declarationsDir = path.join(outDir, "declarations")
238
+
239
+ if (didFile !== undefined) {
240
+ const baseName = path.basename(didFile, ".did")
241
+ return fs.existsSync(path.join(declarationsDir, `${baseName}.d.ts`))
242
+ }
243
+
244
+ if (fs.existsSync(path.join(declarationsDir, `${canisterName}.d.ts`))) {
245
+ return true
246
+ }
247
+
248
+ try {
249
+ return fs
250
+ .readdirSync(declarationsDir)
251
+ .some((entry) => entry.endsWith(".d.ts"))
252
+ } catch {
253
+ return false
254
+ }
112
255
  }
@@ -20,9 +20,9 @@
20
20
  * } = createActorHooks(backendReactor)
21
21
  */
22
22
 
23
- import path from "node:path"
24
23
  import { toPascalCase, getReactorName, getServiceTypeName } from "../naming.js"
25
24
  import type { CodegenTarget, ReactorClassName } from "../types.js"
25
+ import { resolveDeclarationsBaseName } from "../validate.js"
26
26
 
27
27
  export interface ReactorGeneratorOptions {
28
28
  /** Canister name (e.g. "backend") */
@@ -60,6 +60,14 @@ function getReactorClassImportSource(
60
60
  case "CandidDisplayReactor":
61
61
  case "MetadataDisplayReactor":
62
62
  return "@ic-reactor/candid"
63
+ default:
64
+ // The pipeline validates `mode` against a closed set before we are
65
+ // reached. Failing closed here means a caller that skips validation gets
66
+ // an error rather than an unknown class name interpolated into source.
67
+ throw new Error(
68
+ `Unknown reactor class ${JSON.stringify(reactorClass)}. Expected one of: ` +
69
+ `Reactor, DisplayReactor, CandidReactor, CandidDisplayReactor, MetadataDisplayReactor.`
70
+ )
63
71
  }
64
72
  }
65
73
 
@@ -80,8 +88,10 @@ export function generateReactorFile(options: ReactorGeneratorOptions): string {
80
88
  const reactorName = getReactorName(canisterName)
81
89
  const serviceName = getServiceTypeName(canisterName)
82
90
 
83
- // Derive the declarations import path from the .did filename
84
- const baseName = path.basename(didFile, ".did")
91
+ // Derive the declarations import path from the .did filename. The helper
92
+ // rejects stems like "." and ".." rather than emitting an import of a
93
+ // directory — this runs even for callers that bypass the pipeline.
94
+ const baseName = resolveDeclarationsBaseName(didFile)
85
95
  const declarationsPath = `./declarations/${baseName}`
86
96
  const reactorImportSource = getReactorClassImportSource(
87
97
  reactorClass,
@@ -105,9 +115,12 @@ export const {
105
115
  `
106
116
  : ""
107
117
 
108
- return `${runtimeTarget === "react" ? 'import { createActorHooks } from "@ic-reactor/react"\n' : ""}import { ${reactorClass} } from "${reactorImportSource}"
109
- import { clientManager } from "${clientManagerPath}"
110
- import { idlFactory, type _SERVICE } from "${declarationsPath}"
118
+ // Every interpolation into emitted source is JSON.stringify'd. The pipeline
119
+ // validates these values before we are called; quoting them here as well
120
+ // means a future caller that skips validation cannot inject source text.
121
+ return `${runtimeTarget === "react" ? 'import { createActorHooks } from "@ic-reactor/react"\n' : ""}import { ${reactorClass} } from ${JSON.stringify(reactorImportSource)}
122
+ import { clientManager } from ${JSON.stringify(clientManagerPath)}
123
+ import { idlFactory, type _SERVICE } from ${JSON.stringify(declarationsPath)}
111
124
 
112
125
  export type ${serviceName} = _SERVICE
113
126
 
@@ -123,7 +136,7 @@ export type ${serviceName} = _SERVICE
123
136
  export const ${reactorName} = new ${reactorClass}<${serviceName}>({
124
137
  clientManager,
125
138
  idlFactory,
126
- ${canisterIdLine} name: "${canisterName}",
139
+ ${canisterIdLine} name: ${JSON.stringify(canisterName)},
127
140
  })${hookExports || "\n"}`
128
141
  }
129
142
 
package/src/index.ts CHANGED
@@ -21,11 +21,25 @@ export type { PipelineOptions, PipelineResult } from "./pipeline.js"
21
21
  // Utilities
22
22
  export { toPascalCase, getReactorName, getServiceTypeName } from "./naming.js"
23
23
 
24
- export { parseDIDFile, extractMethods } from "./parser.js"
25
- export type { MethodInfo, MethodType } from "./parser.js"
24
+ // Config validation (run by the pipeline; exported for callers that want to
25
+ // validate a config before invoking generation)
26
+ export {
27
+ assertSafeCanisterConfig,
28
+ assertSafeCanisterName,
29
+ assertSafeModuleSpecifier,
30
+ assertContainedPath,
31
+ assertOneOf,
32
+ resolveContainedOutDir,
33
+ resolveDeclarationsBaseName,
34
+ CodegenConfigError,
35
+ CANISTER_NAME_PATTERN,
36
+ REACTOR_CLASS_NAMES,
37
+ CODEGEN_TARGETS,
38
+ } from "./validate.js"
39
+ export type {
40
+ ValidatedCanisterPaths,
41
+ ValidateCanisterConfigOptions,
42
+ } from "./validate.js"
26
43
 
27
44
  // Individual Generators (Advanced Usage)
28
45
  export * from "./generators/index.js"
29
-
30
- // Renderer (Candid Codec)
31
- export { generateCodecDeclarations } from "./renderer.js"
package/src/pipeline.ts CHANGED
@@ -20,11 +20,17 @@ import type {
20
20
  GeneratorResult,
21
21
  ReactorClassName,
22
22
  } from "./types.js"
23
- import { generateDeclarations } from "./generators/declarations.js"
23
+ import { generateDeclarations, OWNER_FILE } from "./generators/declarations.js"
24
24
  import {
25
25
  generateReactorEntryFile,
26
26
  generateReactorFile,
27
27
  } from "./generators/reactor.js"
28
+ import {
29
+ assertSafeCanisterConfig,
30
+ CodegenConfigError,
31
+ resolveDeclarationsBaseName,
32
+ } from "./validate.js"
33
+ import { getReactorName, getServiceTypeName } from "./naming.js"
28
34
 
29
35
  export interface PipelineOptions {
30
36
  /** Canister name and config */
@@ -60,13 +66,114 @@ function normalizeFileContent(content: string): string {
60
66
  return content.replace(/\r\n/g, "\n").trim()
61
67
  }
62
68
 
63
- function isLegacyGeneratedIndexFile(content: string): boolean {
64
- return (
65
- content.includes("Auto-generated by @ic-reactor/codegen") &&
66
- content.includes("createActorHooks(")
69
+ /** Header line every version of this generator has written into its output. */
70
+ const GENERATED_MARKER = "Auto-generated by @ic-reactor/codegen"
71
+
72
+ /**
73
+ * The reactor's `name:` property as this generator emits it. Canister names are
74
+ * restricted to `CANISTER_NAME_PATTERN`, so the emitted line is exactly this.
75
+ */
76
+ const GENERATED_CANISTER_NAME = /^ {2}name: "([A-Za-z0-9_.-]+)",$/m
77
+
78
+ /** Every `export …` statement, one per line, as the generator writes them. */
79
+ const EXPORT_STATEMENT = /^export\b.*$/gm
80
+
81
+ /**
82
+ * Decide whether an existing `index.ts` is still the pre-split generated
83
+ * reactor file, which this pipeline migrates to the thin wrapper.
84
+ *
85
+ * The old test — the header marker plus the substring `createActorHooks(` —
86
+ * matched any file that had *ever* been generated, including one the user has
87
+ * since filled with their own factories and hooks. Migration would overwrite it
88
+ * with six lines of boilerplate and report the write as an ordinary success.
89
+ *
90
+ * So the shape is checked, not just the header: the file must still declare the
91
+ * reactor and hooks for *this* canister and must export nothing the old
92
+ * generator did not write. Anything the user added — another `export const`, an
93
+ * `export * from`, a re-export — takes the file out of scope and it is
94
+ * preserved. Edits that add no export are still possible, which is why the
95
+ * caller keeps a backup before writing.
96
+ */
97
+ function isLegacyGeneratedIndexFile(
98
+ content: string,
99
+ canisterName: string
100
+ ): boolean {
101
+ if (!content.includes(GENERATED_MARKER)) return false
102
+
103
+ const reactorName = getReactorName(canisterName)
104
+ const serviceName = getServiceTypeName(canisterName)
105
+
106
+ // The two lines that make this the generated reactor for this canister.
107
+ if (!content.includes(`export const ${reactorName} = new `)) return false
108
+ if (!content.includes(`= createActorHooks(${reactorName})`)) return false
109
+
110
+ const allowed = [
111
+ `export type ${serviceName} = _SERVICE`,
112
+ `export const ${reactorName} = new `,
113
+ // Opens the destructured hook block; its members are not export statements.
114
+ "export const {",
115
+ ]
116
+
117
+ const statements = normalizeFileContent(content).match(EXPORT_STATEMENT) ?? []
118
+
119
+ return statements.every((statement) =>
120
+ allowed.some((prefix) => statement.startsWith(prefix))
67
121
  )
68
122
  }
69
123
 
124
+ /**
125
+ * Copy `filePath` next to itself before it is overwritten, and return the
126
+ * backup path.
127
+ *
128
+ * Migration decides from the file's contents that it is safe to replace. That
129
+ * inference can be wrong for an edit that adds no export, and the file being
130
+ * replaced may be the only copy of work the user has not committed, so the
131
+ * original is kept rather than deleted. An existing backup is never clobbered.
132
+ */
133
+ function backUpFile(filePath: string): string {
134
+ let backupPath = `${filePath}.bak`
135
+ for (let n = 2; fs.existsSync(backupPath); n += 1) {
136
+ backupPath = `${filePath}.bak.${n}`
137
+ }
138
+ fs.copyFileSync(filePath, backupPath)
139
+ return backupPath
140
+ }
141
+
142
+ /**
143
+ * Read the canister name out of an existing `index.generated.ts`, if the file
144
+ * exists and this package wrote it.
145
+ *
146
+ * Two canisters pointed at the same `outDir` overwrite each other's
147
+ * `index.generated.ts` and — since the declarations directory is replaced
148
+ * wholesale — each other's declarations too, with both runs reporting success.
149
+ * The generated file records which canister owns the directory, so the second
150
+ * canister can be stopped before it destroys the first one's output.
151
+ */
152
+ function findOutDirOwner(outDir: string): string | undefined {
153
+ // Written by generateDeclarations, so it is present for `--bindgen-only`
154
+ // runs too — those skip index.generated.ts but still replace declarations/,
155
+ // which is the step that destroys the other canister's output.
156
+ try {
157
+ const owner = fs.readFileSync(path.join(outDir, OWNER_FILE), "utf-8").trim()
158
+ if (owner) return owner
159
+ } catch {
160
+ // fall through to the legacy marker
161
+ }
162
+
163
+ // Directories generated before the marker existed only carry the canister
164
+ // name inside index.generated.ts.
165
+ let content: string
166
+ try {
167
+ content = fs.readFileSync(path.join(outDir, "index.generated.ts"), "utf-8")
168
+ } catch {
169
+ return undefined
170
+ }
171
+
172
+ if (!content.includes(GENERATED_MARKER)) return undefined
173
+
174
+ return GENERATED_CANISTER_NAME.exec(content)?.[1]
175
+ }
176
+
70
177
  function isManagedEntryWrapper(
71
178
  content: string,
72
179
  expectedEntryContent: string
@@ -102,6 +209,37 @@ export async function runCanisterPipeline(
102
209
 
103
210
  const files: GeneratorResult[] = []
104
211
 
212
+ // ── Validate config ────────────────────────────────────────────────────────
213
+ //
214
+ // Runs before any filesystem work. `name`, `outDir` and `clientManagerPath`
215
+ // come from a checked-in config file, and downstream they become a directory
216
+ // this pipeline recursively deletes and source text it writes into the user's
217
+ // bundle — so they are validated here, at the choke point every entry path
218
+ // (CLI, vite plugin, direct API) goes through.
219
+ let validated: ReturnType<typeof assertSafeCanisterConfig>
220
+ try {
221
+ validated = assertSafeCanisterConfig({
222
+ name,
223
+ canisterOutDir: canisterConfig.outDir,
224
+ globalOutDir: globalConfig.outDir,
225
+ clientManagerPath:
226
+ clientManagerPath ?? globalConfig.clientManagerPath ?? "../../clients",
227
+ projectRoot,
228
+ mode: canisterConfig.mode,
229
+ target: canisterConfig.target ?? globalConfig.target,
230
+ })
231
+ } catch (err) {
232
+ if (err instanceof CodegenConfigError) {
233
+ return {
234
+ canisterName: typeof name === "string" ? name : String(name),
235
+ success: false,
236
+ files,
237
+ error: err.message,
238
+ }
239
+ }
240
+ throw err
241
+ }
242
+
105
243
  // ── Resolve paths ──────────────────────────────────────────────────────────
106
244
 
107
245
  const resolvedDidFile = path.isAbsolute(didFile)
@@ -117,17 +255,47 @@ export async function runCanisterPipeline(
117
255
  }
118
256
  }
119
257
 
120
- // Per-canister outDir overrides global outDir
121
- const canisterOutDir =
122
- canisterConfig.outDir != null
123
- ? path.isAbsolute(canisterConfig.outDir)
124
- ? canisterConfig.outDir
125
- : path.resolve(projectRoot, canisterConfig.outDir)
126
- : path.resolve(projectRoot, globalConfig.outDir, name)
258
+ // The .did basename names the generated declaration files and is the tail of
259
+ // the import specifier the reactor emits, so it is validated here, before any
260
+ // filesystem work, rather than surfacing as a broken import later.
261
+ try {
262
+ resolveDeclarationsBaseName(resolvedDidFile)
263
+ } catch (err) {
264
+ if (err instanceof CodegenConfigError) {
265
+ return {
266
+ canisterName: name,
267
+ success: false,
268
+ files,
269
+ error: `[${name}] ${err.message}`,
270
+ }
271
+ }
272
+ throw err
273
+ }
274
+
275
+ // Per-canister outDir overrides global outDir; both are resolved and
276
+ // containment-checked by assertSafeCanisterConfig above.
277
+ const canisterOutDir = validated.outDir
278
+
279
+ // Refuse to generate into a directory another canister already owns. This has
280
+ // to happen before the declarations step, which replaces `declarations/`
281
+ // wholesale and would otherwise take the other canister's output with it.
282
+ const owner = findOutDirOwner(canisterOutDir)
283
+ if (owner !== undefined && owner !== name) {
284
+ return {
285
+ canisterName: name,
286
+ success: false,
287
+ files,
288
+ error:
289
+ `[${name}] Output directory ${canisterOutDir} was generated for canister ` +
290
+ `"${owner}". Two canisters cannot share an outDir — each run replaces the ` +
291
+ `declarations directory and index.generated.ts, so they would overwrite each ` +
292
+ `other. Give each canister its own "outDir". If you renamed "${owner}" to ` +
293
+ `"${name}", delete that directory and regenerate.`,
294
+ }
295
+ }
127
296
 
128
297
  // clientManagerPath falls back to global, then a safe default
129
- const resolvedClientManagerPath =
130
- clientManagerPath ?? globalConfig.clientManagerPath ?? "../../clients"
298
+ const resolvedClientManagerPath = validated.clientManagerPath
131
299
 
132
300
  // ── Step 1: Declarations ───────────────────────────────────────────────────
133
301
 
@@ -193,11 +361,15 @@ export async function runCanisterPipeline(
193
361
  } else {
194
362
  const existingEntryContent = fs.readFileSync(entryPath, "utf-8")
195
363
 
196
- if (
197
- isLegacyGeneratedIndexFile(existingEntryContent) ||
198
- isManagedEntryWrapper(existingEntryContent, entryContent)
199
- ) {
364
+ if (isManagedEntryWrapper(existingEntryContent, entryContent)) {
365
+ fs.writeFileSync(entryPath, entryContent)
366
+ files.push({ success: true, filePath: entryPath })
367
+ } else if (isLegacyGeneratedIndexFile(existingEntryContent, name)) {
368
+ // Migration replaces a file the user owns, on the strength of a
369
+ // content match. Keep the original so a wrong match is recoverable.
370
+ const backupPath = backUpFile(entryPath)
200
371
  fs.writeFileSync(entryPath, entryContent)
372
+ files.push({ success: true, filePath: backupPath })
201
373
  files.push({ success: true, filePath: entryPath })
202
374
  } else {
203
375
  files.push({ success: true, filePath: entryPath, skipped: true })