@ic-reactor/vite-plugin 0.14.0 → 0.15.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.
package/llms.txt CHANGED
@@ -1,55 +1,156 @@
1
- # @ic-reactor/vite-plugin — AI Quick Guide
1
+ # @ic-reactor/vite-plugin
2
2
 
3
- Vite integration for automatic IC Reactor generation from `.did` files.
3
+ > Vite plugin that generates typed declarations, a reactor with bound React
4
+ > hooks, and optionally one query or mutation object per method from `.did`
5
+ > files, regenerates on change, and gives `vite dev` the local canister ids
6
+ > and an `/api` proxy to the local replica.
7
+
8
+ Applies to `@ic-reactor/vite-plugin` 0.15.1.
9
+
10
+ Read this file from `node_modules/@ic-reactor/vite-plugin/llms.txt` when
11
+ writing code against the installed version. The complete guide for every
12
+ package is https://ic-reactor.b3pay.net/llms-full.txt, and behaviour changes
13
+ are in https://github.com/B3Pay/ic-reactor/blob/main/CHANGELOG.md.
4
14
 
5
15
  ## Install
6
16
 
7
17
  ```bash
8
18
  pnpm add -D @ic-reactor/vite-plugin
9
- pnpm add @ic-reactor/react @tanstack/react-query @icp-sdk/core
19
+ pnpm add @ic-reactor/react @icp-sdk/core @tanstack/react-query
10
20
  ```
11
21
 
12
- ## Minimal Setup
22
+ ## Setup and Usage
13
23
 
14
24
  ```ts
25
+ // vite.config.ts
26
+ import { defineConfig, loadEnv } from "vite"
15
27
  import { icReactor } from "@ic-reactor/vite-plugin"
16
28
 
17
- icReactor({
18
- canisters: [{ name: "backend", didFile: "./backend/backend.did" }],
29
+ export default defineConfig(({ mode }) => {
30
+ const env = loadEnv(mode, process.cwd(), "")
31
+ return {
32
+ plugins: [
33
+ icReactor({
34
+ // outDir: "src/declarations" and clientManagerPath: "../../clients"
35
+ // by default, so src/clients.ts must export `clientManager`
36
+ canisters: [
37
+ {
38
+ name: "backend",
39
+ didFile: "./backend/backend.did",
40
+ factories: true, // also a query or mutation object per method
41
+ // Used under `vite dev` too, in place of the local id
42
+ canisterId: env.CANISTER_ID_BACKEND,
43
+ },
44
+ ],
45
+ }),
46
+ ],
47
+ }
19
48
  })
20
49
  ```
21
50
 
22
- ## Behavior
51
+ ```tsx
52
+ // src/App.tsx: the generated entry is src/declarations/backend/index.ts
53
+ import { getPostsQuery, useBackendMutation } from "./declarations/backend"
54
+
55
+ export function Posts() {
56
+ const { data: posts } = getPostsQuery.useQuery()
57
+ const create = useBackendMutation({
58
+ functionName: "create_post",
59
+ invalidateQueries: [getPostsQuery],
60
+ })
61
+ return (
62
+ <button onClick={() => create.mutate(["Hello"])}>
63
+ {posts?.length ?? 0} posts
64
+ </button>
65
+ )
66
+ }
67
+ ```
68
+
69
+ `src/clients.ts` is yours:
70
+ `export const clientManager = new ClientManager({ queryClient })`, with
71
+ `ClientManager` from `@ic-reactor/react` and a `QueryClient` from
72
+ `@tanstack/react-query` (set `retry: reactorRetry` in its query defaults).
73
+ For Internet Identity sign-in, build
74
+ `new AuthenticationManager({ clientManager })` in the same module and export
75
+ `useAuth` from `createAuthHooks(authentication)`; every generated reactor on
76
+ that `clientManager` then calls as the signed-in user.
77
+
78
+ ## When to Use What
79
+
80
+ | Need | Use |
81
+ | ------------------------------------------------- | ------------------------------------------------------ |
82
+ | Vite app, `.did` files in the repo | this plugin |
83
+ | No Vite, or generation as a CI step | `@ic-reactor/cli` |
84
+ | Query and mutation objects per method | `factories: true` on the canister entry |
85
+ | Raw Candid values instead of display values | `mode: "Reactor"` on the canister entry |
86
+ | No React | `target: "core"` |
87
+ | Local canister ids and `/api` proxy in `vite dev` | `injectEnvironment` (on by default) |
88
+ | A project without a local network | `injectEnvironment: false`, so page loads run no `icp` |
89
+
90
+ ## Options
91
+
92
+ - Plugin level: `canisters` (required), `outDir`, `clientManagerPath`,
93
+ `target`, `injectEnvironment`, `failOnError`.
94
+ - Per canister: `name` and `didFile` (required), `outDir`,
95
+ `clientManagerPath`, `target`, `mode`, `canisterId`, `factories`. Relative
96
+ paths resolve against Vite's `root`.
97
+ - `failOnError` defaults to `true` under `vite build` and `false` under
98
+ `vite dev`, where a failure goes to the terminal and the browser overlay.
99
+ - Each entry needs its own output directory: a later entry with the same
100
+ `name` and `outDir` as an earlier one fails.
101
+
102
+ ## Generated Files
23
103
 
24
- - Runs codegen on startup and `.did` changes
25
- - Regenerates declarations + `index.generated.ts`
26
- - Preserves user-facing `index.ts` wrapper after initial creation
27
- - Relative paths (`didFile`, `outDir`) resolve against Vite's `config.root`
28
- - A failed canister fails `vite build`; in `vite dev` it goes to the terminal
29
- and the browser error overlay instead
104
+ Per canister, under `<outDir>/<name>/`: `declarations/` (`idlFactory` and
105
+ `_SERVICE`), `index.generated.ts` (`<name>Reactor` and six hooks
106
+ `use<Name>Query`, `use<Name>Mutation`, ...), `index.factories.generated.ts`
107
+ with `factories: true` (`<method>Query`, `<method>Mutation`), and `index.ts`,
108
+ which is created once and then yours. Generation runs on startup and on every
109
+ `.did` change. An edited `index.ts` gets a terminal warning until it
110
+ re-exports `./index.factories.generated`; a warning never fails the build.
30
111
 
31
- ## Key Options
112
+ ## Local Environment (`injectEnvironment`)
32
113
 
33
- - Plugin level: `canisters`, `outDir`, `clientManagerPath`, `target`,
34
- `injectEnvironment`, `failOnError`
35
- - Per canister (inside `canisters[]`): `name`, `didFile`, `outDir`,
36
- `clientManagerPath`, `target`, `mode`, `canisterId`
37
- - `injectEnvironment` for local `ic_env` cookie + `/api` proxy behavior. The
38
- consumer side is a positive allowlist: `ClientManager` trusts the cookie — its
39
- root key, its Internet Identity provider, and the canister IDs a reactor
40
- resolves by name — only for loopback, `localhost`, and dev-container hosts.
41
- Serving a dev build over a tunnel or custom domain needs
42
- `allowEnvConfig: true` (formerly `allowEnvRootKey`); without it a generated
43
- reactor that carries no `canisterId` throws instead of reading one from the
44
- cookie — see `packages/core/llms.txt`
45
- - `failOnError` overrides the default (`true` for build, `false` for dev)
114
+ - Under `vite dev` and `vite preview` the plugin sets the `ic_env` cookie
115
+ (root key, Internet Identity provider, `PUBLIC_CANISTER_ID:<name>`) on each
116
+ response and proxies `/api` to the network `icp` reports.
117
+ - While `icp` reports no network or a configured canister has no id, each page
118
+ load asks `icp` again, so deploying after `vite dev` started needs only a
119
+ reload. Once every canister resolves, page loads run no `icp` command, and a
120
+ redeploy into a fresh network needs a restart.
121
+ - An `/api` proxy set by the Vite config or another plugin is left alone.
122
+ - `ClientManager` trusts the cookie only on loopback and `localhost` hosts. A
123
+ dev build served over a tunnel, Codespaces, Gitpod or a custom domain needs
124
+ `allowEnvConfig: true` on the `ClientManager`; without it a generated
125
+ reactor with no `canisterId` throws instead of reading one from the cookie.
126
+ - The cookie does not exist in production: set `canisterId` on every entry
127
+ whose build is served anywhere else. A set `canisterId` is written into the
128
+ generated reactor and wins over the local id under `vite dev` too, so take
129
+ it from the environment (`loadEnv`, as above) when the local and production
130
+ ids differ, rather than hard-coding the production id.
131
+ - Vitest runs `vite.config.ts` unless a `vitest.config.ts` replaces it, so
132
+ the plugin generates again when tests run, in mode `test`. Set the
133
+ `canisterId` variable for that mode too (for example in `.env.test`), or
134
+ the regenerated reactor has no id and importing it in a test throws
135
+ `canisterId is required`.
46
136
 
47
- ## Best Practices
137
+ ## Do Not
48
138
 
49
- - Keep custom code in stable wrapper/factory modules
50
- - Do not hand-edit generated `index.generated.ts`
139
+ - Edit `index.generated.ts`, `index.factories.generated.ts` or
140
+ `declarations/`; edit the `.did` or the plugin options.
141
+ - Hand-write per-method `createQuery` / `createMutation` modules; set
142
+ `factories: true` and customize in `index.ts`.
143
+ - Rely on the `ic_env` cookie outside local development.
144
+ - Point two entries at one output directory.
145
+ - Import generated modules into a React Server Component, or use them per
146
+ request on a server: they are module scope and import hooks.
51
147
 
52
- ## Full References
148
+ ## Docs
53
149
 
54
- - Full AI guide: https://ic-reactor.b3pay.net/llms-full.txt
55
- - Vite plugin docs: https://ic-reactor.b3pay.net/v3/packages/vite-plugin
150
+ - Vite plugin: https://ic-reactor.b3pay.net/v3/packages/vite-plugin.md
151
+ - Quick start: https://ic-reactor.b3pay.net/v3/getting-started/quick-start.md
152
+ - Local development: https://ic-reactor.b3pay.net/v3/getting-started/local-development.md
153
+ - Codegen (factory naming rules): https://ic-reactor.b3pay.net/v3/packages/codegen.md
154
+ - Vite plugin example: https://ic-reactor.b3pay.net/v3/examples/vite-plugin.md
155
+ - Index of all docs: https://ic-reactor.b3pay.net/llms.txt
156
+ - Full guide: https://ic-reactor.b3pay.net/llms-full.txt
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ic-reactor/vite-plugin",
3
- "version": "0.14.0",
3
+ "version": "0.15.1",
4
4
  "description": "Vite plugin for zero-config IC reactor generation from Candid files",
5
5
  "type": "module",
6
6
  "main": "./dist/index.cjs",
@@ -50,7 +50,7 @@
50
50
  },
51
51
  "homepage": "https://ic-reactor.b3pay.net/v3/packages/vite-plugin",
52
52
  "dependencies": {
53
- "@ic-reactor/codegen": "0.14.0"
53
+ "@ic-reactor/codegen": "0.15.1"
54
54
  },
55
55
  "peerDependencies": {
56
56
  "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0"
@@ -0,0 +1,268 @@
1
+ /**
2
+ * The local IC environment that `vite dev` and `vite preview` inject, kept up
3
+ * to date while detection is incomplete.
4
+ *
5
+ * The dev server usually starts before the canisters are deployed, and
6
+ * sometimes before the local network is up. Detection used to run once, in the
7
+ * `config` hook, and its answer was fixed into `server.headers` and
8
+ * `server.proxy` for the server's lifetime. A canister deployed afterwards
9
+ * never reached the `ic_env` cookie, and a network started afterwards never
10
+ * received `/api`, until the dev server was restarted.
11
+ *
12
+ * Detection now runs again for each page load while it is incomplete, that is
13
+ * while `icp` reports no network or a configured canister has no ID. Once
14
+ * every configured canister has an ID on a detected network it stops, and page
15
+ * loads run no `icp` command. A redeploy into a fresh network, with new IDs and
16
+ * a new root key, still needs a restart.
17
+ */
18
+
19
+ import type { IncomingMessage, ServerResponse } from "node:http"
20
+ import {
21
+ buildIcEnvCookie,
22
+ getIcEnvironmentInfo,
23
+ type IcEnvironment,
24
+ } from "./env.js"
25
+
26
+ /** Where `/api` goes while `icp` reports no network. */
27
+ export const DEFAULT_LOCAL_REPLICA = "http://127.0.0.1:4943"
28
+
29
+ /**
30
+ * The Internet Identity provider the cookie names when no canister is
31
+ * configured and `icp` reports no network: icp-cli's built-in one, on its
32
+ * default port.
33
+ */
34
+ const DEFAULT_INTERNET_IDENTITY_PROVIDER =
35
+ "http://id.ai.localhost:8000/authorize"
36
+
37
+ /** What the dev server injects, from the latest detection. */
38
+ export interface LocalEnvironmentState {
39
+ /** What `icp` reported, or `null` while it has reported no network. */
40
+ environment: IcEnvironment | null
41
+ /** The `ic_env` cookie's value, or `undefined` when no cookie is set. */
42
+ cookie: string | undefined
43
+ /** Where the plugin's `/api` proxy sends requests. */
44
+ proxyTarget: string
45
+ /** Configured canisters with neither a detected nor a configured ID. */
46
+ missingCanisterIds: string[]
47
+ /**
48
+ * `icp` reported a network and every configured canister has an ID, so
49
+ * detection does not run again.
50
+ */
51
+ complete: boolean
52
+ }
53
+
54
+ export interface LocalEnvironmentOptions {
55
+ /** The configured canisters' names. */
56
+ canisterNames: string[]
57
+ /** IDs set in the plugin config. They win over detected ones. */
58
+ configuredCanisterIds: Record<string, string>
59
+ /** The directory `icp` runs in. */
60
+ projectRoot: string
61
+ /** Receives each line that explains why an `icp` command failed. */
62
+ onDiagnostic: (message: string) => void
63
+ /**
64
+ * Called after each detection with the state before it, `undefined` for the
65
+ * first, and the state after it.
66
+ */
67
+ onUpdate?: (
68
+ previous: LocalEnvironmentState | undefined,
69
+ next: LocalEnvironmentState
70
+ ) => void
71
+ }
72
+
73
+ export interface LocalEnvironment {
74
+ /** The latest detection's result, once one has finished. */
75
+ readonly state: LocalEnvironmentState | undefined
76
+ /**
77
+ * Ask `icp` again and resolve with the new state. A call while a detection
78
+ * runs shares it. Never rejects.
79
+ */
80
+ detect(): Promise<LocalEnvironmentState>
81
+ }
82
+
83
+ export function createLocalEnvironment(
84
+ options: LocalEnvironmentOptions
85
+ ): LocalEnvironment {
86
+ const {
87
+ canisterNames,
88
+ configuredCanisterIds,
89
+ projectRoot,
90
+ onDiagnostic,
91
+ onUpdate,
92
+ } = options
93
+
94
+ // Two entries can share a name. internet_identity is looked up as well, but
95
+ // it is routinely not deployed, so it is not needed for detection to be
96
+ // complete.
97
+ const requiredNames = [...new Set(canisterNames)]
98
+ const lookupNames = requiredNames.includes("internet_identity")
99
+ ? requiredNames
100
+ : [...requiredNames, "internet_identity"]
101
+
102
+ let state: LocalEnvironmentState | undefined
103
+ let running: Promise<LocalEnvironmentState> | undefined
104
+
105
+ const toState = (
106
+ environment: IcEnvironment | null
107
+ ): LocalEnvironmentState => {
108
+ const canisterIds = {
109
+ ...environment?.canisterIds,
110
+ ...configuredCanisterIds,
111
+ }
112
+ const missingCanisterIds = requiredNames.filter(
113
+ (name) => !canisterIds[name]
114
+ )
115
+
116
+ let cookie: string | undefined
117
+ if (environment) {
118
+ cookie = buildIcEnvCookie(
119
+ canisterIds,
120
+ environment.rootKey,
121
+ environment.internetIdentityProvider
122
+ )
123
+ } else if (requiredNames.length === 0) {
124
+ // Env-only mode: there are no canister IDs to carry, but the app can
125
+ // still sign in with icp-cli's built-in Internet Identity.
126
+ cookie = buildIcEnvCookie(
127
+ {},
128
+ undefined,
129
+ DEFAULT_INTERNET_IDENTITY_PROVIDER
130
+ )
131
+ }
132
+
133
+ return {
134
+ environment,
135
+ cookie,
136
+ proxyTarget: environment?.proxyTarget ?? DEFAULT_LOCAL_REPLICA,
137
+ missingCanisterIds,
138
+ complete: environment !== null && missingCanisterIds.length === 0,
139
+ }
140
+ }
141
+
142
+ const run = async (): Promise<LocalEnvironmentState> => {
143
+ const previous = state
144
+ try {
145
+ const { environment, diagnostics } = await getIcEnvironmentInfo(
146
+ lookupNames,
147
+ projectRoot
148
+ )
149
+ diagnostics.forEach(onDiagnostic)
150
+ state = toState(
151
+ mergeDetections(previous?.environment ?? null, environment)
152
+ )
153
+ } catch (error) {
154
+ // getIcEnvironmentInfo reports failures in its result. This is for
155
+ // anything else, which must not fail the page request that waits here.
156
+ onDiagnostic(
157
+ `Detecting the local IC environment failed: ${
158
+ error instanceof Error ? error.message : String(error)
159
+ }`
160
+ )
161
+ state = previous ?? toState(null)
162
+ }
163
+
164
+ try {
165
+ onUpdate?.(previous, state)
166
+ } catch (error) {
167
+ onDiagnostic(
168
+ `Applying the detected IC environment failed: ${
169
+ error instanceof Error ? error.message : String(error)
170
+ }`
171
+ )
172
+ }
173
+ return state
174
+ }
175
+
176
+ return {
177
+ get state() {
178
+ return state
179
+ },
180
+ detect() {
181
+ running ??= run().finally(() => {
182
+ running = undefined
183
+ })
184
+ return running
185
+ },
186
+ }
187
+ }
188
+
189
+ /**
190
+ * Combine a detection with the one before it.
191
+ *
192
+ * `icp` can fail for a moment, during a deploy say. A detection that finds no
193
+ * network keeps the last one that did, and on the same network, which the
194
+ * root key identifies, a canister keeps the ID it had. A cookie that works is
195
+ * not taken away by a command that failed once.
196
+ */
197
+ function mergeDetections(
198
+ previous: IcEnvironment | null,
199
+ next: IcEnvironment | null
200
+ ): IcEnvironment | null {
201
+ if (!next) return previous
202
+ if (!previous || previous.rootKey !== next.rootKey) return next
203
+ const canisterIds = { ...previous.canisterIds, ...next.canisterIds }
204
+ return {
205
+ ...next,
206
+ canisterIds,
207
+ // The built-in provider stands in only for a project that has no
208
+ // internet_identity canister, as getIcEnvironmentInfo decides it.
209
+ internetIdentityProvider: canisterIds.internet_identity
210
+ ? undefined
211
+ : next.internetIdentityProvider,
212
+ }
213
+ }
214
+
215
+ /**
216
+ * Whether `req` loads a page, which is when the app reads the `ic_env` cookie.
217
+ *
218
+ * Browsers put `text/html` in the Accept header of a navigation, and not in
219
+ * that of a script, a stylesheet, a `fetch` call or an HMR request.
220
+ */
221
+ export function isDocumentRequest(req: IncomingMessage): boolean {
222
+ if (req.method !== "GET" && req.method !== "HEAD") return false
223
+ const { accept } = req.headers
224
+ return typeof accept === "string" && accept.includes("text/html")
225
+ }
226
+
227
+ /** Add the `ic_env` cookie to `res`, keeping any other cookie set on it. */
228
+ function setIcEnvCookie(res: ServerResponse, value: string): void {
229
+ const existing = res.getHeader("Set-Cookie")
230
+ const others = (
231
+ existing === undefined
232
+ ? []
233
+ : Array.isArray(existing)
234
+ ? existing
235
+ : [String(existing)]
236
+ ).filter((cookie) => !cookie.startsWith("ic_env="))
237
+ const cookie = `ic_env=${value}; Path=/; SameSite=Lax;`
238
+ res.setHeader("Set-Cookie", others.length > 0 ? [...others, cookie] : cookie)
239
+ }
240
+
241
+ /**
242
+ * Connect middleware that sets the `ic_env` cookie on each response.
243
+ *
244
+ * For a page load while detection is incomplete, it first asks `icp` again and
245
+ * waits for the answer, so the page that loads after a deploy already carries
246
+ * the new ID. Other requests, and every request once detection is complete,
247
+ * get the cookie from the latest detection without running `icp`.
248
+ */
249
+ export function icEnvMiddleware(environment: LocalEnvironment) {
250
+ return (
251
+ req: IncomingMessage,
252
+ res: ServerResponse,
253
+ next: (error?: unknown) => void
254
+ ): void => {
255
+ const respond = (state: LocalEnvironmentState | undefined) => {
256
+ if (state?.cookie !== undefined) setIcEnvCookie(res, state.cookie)
257
+ next()
258
+ }
259
+
260
+ const { state } = environment
261
+ if (state?.complete || !isDocumentRequest(req)) {
262
+ respond(state)
263
+ return
264
+ }
265
+
266
+ void environment.detect().then(respond)
267
+ }
268
+ }
package/src/env.ts CHANGED
@@ -5,7 +5,7 @@
5
5
  * and building the `ic_env` cookie for the browser.
6
6
  */
7
7
 
8
- import { execFileSync } from "child_process"
8
+ import { execFile } from "child_process"
9
9
 
10
10
  export interface IcEnvironment {
11
11
  environment: string
@@ -27,6 +27,45 @@ export interface IcEnvironmentDetection {
27
27
  diagnostics: string[]
28
28
  }
29
29
 
30
+ /**
31
+ * How long one `icp` command may run. The dev server runs detection while a
32
+ * page request waits for it, so a command that never exits must not hold the
33
+ * page forever.
34
+ */
35
+ const ICP_TIMEOUT_MS = 10_000
36
+
37
+ /**
38
+ * Run `icp` with `args` in `cwd` and resolve with its stdout.
39
+ *
40
+ * Asynchronous, so a page request that waits for detection does not block the
41
+ * dev server's other requests. stderr is captured rather than shown, so a
42
+ * failure can explain itself: it only reaches the terminal if the caller prints
43
+ * the diagnostics. stdin is closed, so a command that asks a question fails
44
+ * instead of waiting for an answer.
45
+ */
46
+ function runIcp(args: string[], cwd: string): Promise<string> {
47
+ return new Promise((resolve, reject) => {
48
+ const child = execFile(
49
+ "icp",
50
+ args,
51
+ {
52
+ cwd,
53
+ encoding: "utf-8",
54
+ timeout: ICP_TIMEOUT_MS,
55
+ windowsHide: true,
56
+ },
57
+ (error, stdout, stderr) => {
58
+ if (error) {
59
+ reject(Object.assign(error, { stderr }))
60
+ } else {
61
+ resolve(stdout)
62
+ }
63
+ }
64
+ )
65
+ child.stdin?.end()
66
+ })
67
+ }
68
+
30
69
  /**
31
70
  * Detect the IC environment using the `icp` CLI.
32
71
  *
@@ -34,23 +73,19 @@ export interface IcEnvironmentDetection {
34
73
  * looking for `icp.yaml` there and in each parent directory, so this has to be
35
74
  * the app's root and not wherever the process happened to start.
36
75
  */
37
- export function getIcEnvironmentInfo(
76
+ export async function getIcEnvironmentInfo(
38
77
  canisterNames: string[],
39
78
  projectRoot: string = process.cwd()
40
- ): IcEnvironmentDetection {
79
+ ): Promise<IcEnvironmentDetection> {
41
80
  const networkName = process.env.ICP_ENVIRONMENT || "local"
42
81
  const diagnostics: string[] = []
43
82
 
44
83
  try {
45
84
  const networkStatus = JSON.parse(
46
- execFileSync("icp", ["network", "status", "-e", networkName, "--json"], {
47
- cwd: projectRoot,
48
- encoding: "utf-8",
49
- // stderr is piped rather than ignored so a failure can explain itself.
50
- // Piping still keeps it off the terminal — it only reaches the user if
51
- // the caller decides to print the diagnostics we collect below.
52
- stdio: ["ignore", "pipe", "pipe"],
53
- })
85
+ await runIcp(
86
+ ["network", "status", "-e", networkName, "--json"],
87
+ projectRoot
88
+ )
54
89
  )
55
90
 
56
91
  const rootKey = networkStatus.root_key
@@ -70,16 +105,15 @@ export function getIcEnvironmentInfo(
70
105
 
71
106
  const canisterIds: Record<string, string> = {}
72
107
 
108
+ // One at a time, as icp is run from a terminal. Each command reads the
109
+ // project's state, and these are not known to be safe to run at once.
73
110
  for (const name of canisterNames) {
74
111
  try {
75
- const canisterId = execFileSync(
76
- "icp",
77
- ["canister", "status", name, "-e", networkName, "-i"],
78
- {
79
- cwd: projectRoot,
80
- encoding: "utf-8",
81
- stdio: ["ignore", "pipe", "pipe"],
82
- }
112
+ const canisterId = (
113
+ await runIcp(
114
+ ["canister", "status", name, "-e", networkName, "-i"],
115
+ projectRoot
116
+ )
83
117
  ).trim()
84
118
 
85
119
  if (canisterId) {
@@ -143,7 +177,7 @@ export function buildIcEnvCookie(
143
177
  }
144
178
 
145
179
  /**
146
- * Turn whatever `execFileSync` threw into one readable line.
180
+ * Turn whatever `icp` failed with into one readable line.
147
181
  *
148
182
  * The interesting part is almost always the captured stderr — the thrown
149
183
  * Error's own message is just "Command failed: icp ..." — but stderr is absent