@algolia/wizard 0.32.0 → 0.33.0-rc.125.246

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/dist/main.js CHANGED
@@ -253,6 +253,7 @@ var useWizard = create((set, get) => ({
253
253
  cliOutput: [],
254
254
  targetIndex: null,
255
255
  writtenFiles: [],
256
+ approvedCommands: /* @__PURE__ */ new Set(),
256
257
  logs: [],
257
258
  error: null,
258
259
  inputReq: null,
@@ -348,6 +349,10 @@ var useWizard = create((set, get) => ({
348
349
  setTargetIndex: (index) => set({ targetIndex: index }),
349
350
  recordWrittenFile: (path) => set((s) => ({ writtenFiles: [...s.writtenFiles, path] })),
350
351
  clearWrittenFiles: () => set({ writtenFiles: [] }),
352
+ isCommandApproved: (command, cwd) => get().approvedCommands.has(`${cwd}\0${command}`),
353
+ approveCommand: (command, cwd) => set((s) => ({
354
+ approvedCommands: new Set(s.approvedCommands).add(`${cwd}\0${command}`)
355
+ })),
351
356
  logStart: (kind, name, input) => {
352
357
  const id = nanoid();
353
358
  set((s) => ({
@@ -396,6 +401,7 @@ var useWizard = create((set, get) => ({
396
401
  cliOutput: [],
397
402
  targetIndex: null,
398
403
  writtenFiles: [],
404
+ approvedCommands: /* @__PURE__ */ new Set(),
399
405
  logs: [],
400
406
  error: null,
401
407
  inputReq: null,
@@ -2640,7 +2646,7 @@ async function ensureApplication() {
2640
2646
  }
2641
2647
 
2642
2648
  // src/workflows/default.ts
2643
- import { z as z30 } from "zod";
2649
+ import { z as z29 } from "zod";
2644
2650
 
2645
2651
  // src/actions/listIndices.ts
2646
2652
  import { z as z5 } from "zod";
@@ -2710,8 +2716,8 @@ var selectIndexStep = async (ctx) => {
2710
2716
  };
2711
2717
 
2712
2718
  // src/lib/agent.ts
2713
- import { ToolLoopAgent, hasToolCall, Output as Output2 } from "ai";
2714
- import { createAnthropic as createAnthropic2 } from "@ai-sdk/anthropic";
2719
+ import { ToolLoopAgent, hasToolCall, Output as Output3 } from "ai";
2720
+ import { createAnthropic as createAnthropic3 } from "@ai-sdk/anthropic";
2715
2721
  import "zod";
2716
2722
 
2717
2723
  // src/lib/tools/index.ts
@@ -2754,17 +2760,23 @@ async function hasSymlinkParent(ctx, target) {
2754
2760
  // src/lib/tools/listFiles.ts
2755
2761
  function listFilesTool(ctx) {
2756
2762
  return tool({
2757
- description: "List files in the current working directory",
2758
- inputSchema: z6.object(),
2759
- execute: async () => {
2760
- logger.info("called listFiles tool");
2763
+ description: 'List files in a directory (default: the current working directory). Pass path to list a subdirectory directly \u2014 e.g. "packages/api" \u2014 without first changeDirectory-ing into it.',
2764
+ inputSchema: z6.object({
2765
+ path: z6.string().optional().describe("Directory to list, relative to cwd (default: cwd)")
2766
+ }),
2767
+ execute: async ({ path = "." }) => {
2768
+ logger.info({ path }, "called listFiles tool");
2761
2769
  if (++ctx.counts.list > ctx.limits.list) {
2762
2770
  return `Refused: list limit (${ctx.limits.list}) reached. Stop listing and proceed with the information you already have.`;
2763
2771
  }
2764
- const resolved2 = resolveInRoot(ctx, ".");
2772
+ const resolved2 = resolveInRoot(ctx, path);
2765
2773
  if (!resolved2.ok) return resolved2.error;
2766
- const entries = await readdir(resolved2.target, { withFileTypes: true });
2767
- return entries.map((e) => e.isDirectory() ? `${e.name}/` : e.name).join("\n");
2774
+ try {
2775
+ const entries = await readdir(resolved2.target, { withFileTypes: true });
2776
+ return entries.map((e) => e.isDirectory() ? `${e.name}/` : e.name).join("\n");
2777
+ } catch (err) {
2778
+ return `Error listing ${path}: ${err.message}`;
2779
+ }
2768
2780
  }
2769
2781
  });
2770
2782
  }
@@ -3074,8 +3086,7 @@ function resolveWriteKey(index, appId) {
3074
3086
  `Algolia Wizard write key for ${index} index`
3075
3087
  );
3076
3088
  }
3077
- async function resolveSearchOnlyKey(index, appId, envKey) {
3078
- if (envKey) return { key: envKey, source: "env" };
3089
+ async function resolveSearchOnlyKey(index, appId) {
3079
3090
  return resolveKey(
3080
3091
  "search",
3081
3092
  index,
@@ -3114,6 +3125,12 @@ function isIgnoredByRule(root, relPath) {
3114
3125
  function isTracked(root, relPath) {
3115
3126
  return gitSucceeds(root, ["ls-files", "--error-unmatch", "--", relPath]);
3116
3127
  }
3128
+ async function gitIgnoreStatus(root, target) {
3129
+ const { ignoredByRule, tracked } = await inspect(root, target);
3130
+ if (ignoredByRule === void 0) return "unknown";
3131
+ if (tracked) return "tracked";
3132
+ return ignoredByRule ? "covered" : "needsRule";
3133
+ }
3117
3134
  async function inspect(root, target) {
3118
3135
  const relPath = relative2(root, target);
3119
3136
  if (!relPath || relPath.startsWith("..")) {
@@ -3159,31 +3176,9 @@ async function ensureGitIgnored(root, target) {
3159
3176
  }
3160
3177
 
3161
3178
  // src/lib/tools/writeAlgoliaCredentials.ts
3162
- var APP_ID_VAR = "ALGOLIA_APPLICATION_ID";
3163
- var API_KEY_VAR = "ALGOLIA_WRITE_API_KEY";
3179
+ var APP_ID_VAR = "ALGOLIA_APP_ID";
3180
+ var API_KEY_VAR = "ALGOLIA_WRITE_KEY";
3164
3181
  var INDEX_NAME_VAR = "ALGOLIA_INDEX_NAME";
3165
- var PUBLIC_APP_ID_SUFFIX = "ALGOLIA_APP_ID";
3166
- var PUBLIC_SEARCH_KEY_SUFFIX = "ALGOLIA_SEARCH_KEY";
3167
- var PUBLIC_INDEX_NAME_SUFFIX = "ALGOLIA_INDEX_NAME";
3168
- function publicAppIdVar(prefix) {
3169
- return `${prefix}${PUBLIC_APP_ID_SUFFIX}`;
3170
- }
3171
- function publicSearchKeyVar(prefix) {
3172
- return `${prefix}${PUBLIC_SEARCH_KEY_SUFFIX}`;
3173
- }
3174
- function publicIndexNameVar(prefix) {
3175
- return `${prefix}${PUBLIC_INDEX_NAME_SUFFIX}`;
3176
- }
3177
- function publicSearchEnvVars(prefix, index, appId, searchKey) {
3178
- return [
3179
- { name: publicAppIdVar(prefix), value: appId ?? "<your-algolia-app-id>" },
3180
- {
3181
- name: publicSearchKeyVar(prefix),
3182
- value: searchKey ?? "<your-algolia-search-only-api-key>"
3183
- },
3184
- { name: publicIndexNameVar(prefix), value: index }
3185
- ];
3186
- }
3187
3182
  function appendEnv(content, entries) {
3188
3183
  const prefix = content.length > 0 && !content.endsWith("\n") ? "\n" : "";
3189
3184
  const lines = entries.map(([name, value]) => `${name}=${value}
@@ -3191,11 +3186,13 @@ function appendEnv(content, entries) {
3191
3186
  return content + prefix + lines;
3192
3187
  }
3193
3188
  function hasEnv(content, name) {
3194
- return new RegExp(`^(\\s*(?:export\\s+)?${name})\\s*=`, "m").test(content);
3189
+ return new RegExp(`^([ \\t]*(?:export[ \\t]+)?${name})[ \\t]*=`, "m").test(
3190
+ content
3191
+ );
3195
3192
  }
3196
3193
  function readEnv(content, name) {
3197
3194
  const found = content.match(
3198
- new RegExp(`^\\s*(?:export\\s+)?${name}\\s*=\\s*(.*)$`, "m")
3195
+ new RegExp(`^[ \\t]*(?:export[ \\t]+)?${name}[ \\t]*=[ \\t]*(.*)$`, "m")
3199
3196
  );
3200
3197
  if (!found) return null;
3201
3198
  const raw = found[1].trim();
@@ -3206,16 +3203,16 @@ function readEnv(content, name) {
3206
3203
  function upsertEnv(content, name, value) {
3207
3204
  if (!hasEnv(content, name)) return appendEnv(content, [[name, value]]);
3208
3205
  return content.replace(
3209
- new RegExp(`^\\s*(?:export\\s+)?${name}\\s*=.*$`, "gm"),
3206
+ new RegExp(`^[ \\t]*(?:export[ \\t]+)?${name}[ \\t]*=.*$`, "gm"),
3210
3207
  () => `${name}=${value}`
3211
3208
  );
3212
3209
  }
3213
3210
  function writeCredentialsTool(ctx) {
3214
3211
  return tool6({
3215
- description: `Write the active Algolia credentials (${APP_ID_VAR} and ${API_KEY_VAR}) and the target index name (${INDEX_NAME_VAR}) into the given env file. The credentials come from the selected Algolia application, with a write key scoped to the target index; you only pass the path to the env file. ${INDEX_NAME_VAR} is always set to this run's target index, replacing any value already there. An ${APP_ID_VAR} or ${API_KEY_VAR} the file already gives a value is left untouched; a missing or blank one is filled in when it can be paired with the selected application. The env file is added to .gitignore automatically; do not edit .gitignore yourself.`,
3212
+ description: `Write the active Algolia credentials (${APP_ID_VAR} and ${API_KEY_VAR}) and the target index name (${INDEX_NAME_VAR}) into the given env file. The credentials come from the selected Algolia application, with a write key scoped to the target index; you only pass the path to the env file. ${INDEX_NAME_VAR} is always set to this run's target index, replacing any value already there. An ${APP_ID_VAR} or ${API_KEY_VAR} the file already gives a value is left untouched; a missing or blank one is filled in when it can be paired with the selected application. The env file is added to .gitignore automatically; do not edit .gitignore yourself. If the script or app that reads these credentials lives in a subdirectory (e.g. a package in a monorepo), an env file at the repo root is the wrong default \u2014 a script only loads env vars from its own directory (or one it's explicitly configured to read), so check every directory from the script's own up to the repo root, not just those two: its own directory, each ancestor in between (a shared workspace-level directory above the immediate package is common), and the root. Use whichever of those already holds real credentials; only fall back to the repo root when none of them do. Never invent a brand-new file in one of those directories when a real one already exists in another \u2014 that leaves the real one stale and the new one wrong. Listing just the script's own directory and the very top-level root is not enough to find a workspace-level file in between; check the intermediate ones too. If instructions describe a location that doesn't match the project you actually find (e.g. a path outside the repo, or a convention the project doesn't follow), don't stop and ask before doing anything \u2014 call this tool on the real, in-repo file the script actually reads (that's always the safe default), then note the mismatch afterward. Ending your turn with only a question and no call to this tool leaves the project unconfigured.`,
3216
3213
  inputSchema: z13.object({
3217
3214
  filePath: z13.string().describe(
3218
- 'Path to the env file to write credentials into (e.g. ".env")'
3215
+ 'Path to the env file to write credentials into, relative to the repo root (e.g. ".env", or "packages/api/.env" when the consuming script lives in that package)'
3219
3216
  )
3220
3217
  }),
3221
3218
  execute: async ({ filePath }) => {
@@ -3393,7 +3390,8 @@ function searchFilesTool(ctx) {
3393
3390
  }
3394
3391
 
3395
3392
  // src/lib/tools/runShell.ts
3396
- import { tool as tool8 } from "ai";
3393
+ import { tool as tool8, generateText, Output } from "ai";
3394
+ import { createAnthropic } from "@ai-sdk/anthropic";
3397
3395
  import z15 from "zod";
3398
3396
  import { relative as relative4 } from "node:path";
3399
3397
 
@@ -3502,8 +3500,10 @@ function runShell(command, opts) {
3502
3500
  // src/lib/tools/runShell.ts
3503
3501
  function storeApproval(root) {
3504
3502
  return async (req) => {
3503
+ const store = useWizard.getState();
3504
+ if (store.isCommandApproved(req.command, req.cwd)) return "approve";
3505
3505
  const rel = relative4(root, req.cwd);
3506
- const answer = await useWizard.getState().requestUserInput({
3506
+ const answer = await store.requestUserInput({
3507
3507
  prompt: "Run this command?",
3508
3508
  promptType: "commandApproval",
3509
3509
  options: [],
@@ -3512,20 +3512,170 @@ function storeApproval(root) {
3512
3512
  cwd: rel === "" || rel.startsWith("..") ? req.cwd : rel
3513
3513
  }
3514
3514
  });
3515
- return answer === "approve" ? "approve" : "reject";
3515
+ if (answer !== "approve") return "reject";
3516
+ store.approveCommand(req.command, req.cwd);
3517
+ return "approve";
3516
3518
  };
3517
3519
  }
3518
3520
  var EXPLORATORY_COMMANDS = /* @__PURE__ */ new Set(["ls", "find", "tree", "dir"]);
3521
+ function commandSegments(command) {
3522
+ return command.split(/&&|;|\|/).map((segment) => segment.trim());
3523
+ }
3519
3524
  function isExploratoryCommand(command) {
3520
- return command.split(/&&|;|\|/).map((segment) => segment.trim().split(/\s+/)[0]).some((word) => word !== void 0 && EXPLORATORY_COMMANDS.has(word));
3525
+ return commandSegments(command).map((segment) => segment.split(/\s+/)[0]).some((word) => word !== void 0 && EXPLORATORY_COMMANDS.has(word));
3526
+ }
3527
+ var READ_ONLY_BINARIES = /* @__PURE__ */ new Set([
3528
+ "cat",
3529
+ "head",
3530
+ "tail",
3531
+ "wc",
3532
+ "pwd",
3533
+ "echo",
3534
+ "date",
3535
+ "whoami",
3536
+ "hostname",
3537
+ "uname",
3538
+ "which",
3539
+ "file",
3540
+ "stat",
3541
+ "grep",
3542
+ "egrep",
3543
+ "fgrep",
3544
+ "rg",
3545
+ "diff"
3546
+ ]);
3547
+ var READ_ONLY_NO_ARGS_BINARIES = /* @__PURE__ */ new Set(["env", "printenv"]);
3548
+ var GIT_READ_ONLY_SUBCOMMANDS = /* @__PURE__ */ new Set([
3549
+ "status",
3550
+ "log",
3551
+ "diff",
3552
+ "show",
3553
+ "describe",
3554
+ "blame",
3555
+ "ls-files",
3556
+ "rev-parse",
3557
+ "cat-file",
3558
+ "shortlog",
3559
+ "ls-remote"
3560
+ ]);
3561
+ var VERSION_CHECK_BINARIES = /* @__PURE__ */ new Set([
3562
+ "node",
3563
+ "tsc",
3564
+ "npm",
3565
+ "pnpm",
3566
+ "yarn",
3567
+ "python",
3568
+ "python3",
3569
+ "ruby",
3570
+ "go",
3571
+ "cargo",
3572
+ "rustc",
3573
+ "php",
3574
+ "composer",
3575
+ "java",
3576
+ "mvn",
3577
+ "gradle",
3578
+ "bundle",
3579
+ "git"
3580
+ ]);
3581
+ var VERSION_FLAGS = /* @__PURE__ */ new Set(["--version", "-v", "-V"]);
3582
+ function hasFileRedirect(segment) {
3583
+ return segment.replace(/\d>&\d/g, "").includes(">");
3521
3584
  }
3522
- async function approveAndRun(ctx, command, cwd, explanation) {
3523
- const decision = await ctx.shell.approve({ command, cwd, explanation });
3524
- if (decision === "reject") {
3525
- ctx.shell.executions.push({ command, cwd, approved: false });
3526
- logger.info({ command }, "runShell: user rejected the command");
3527
- return "The user rejected this command. Do not retry it. Propose a different command, or report the limitation via reportStatus.";
3585
+ function hasShellInjectionRisk(segment) {
3586
+ if (segment.includes("$(") || segment.includes("<(") || segment.includes("`")) {
3587
+ return true;
3588
+ }
3589
+ return segment.replace(/\d>&\d/g, "").includes("&");
3590
+ }
3591
+ function fastPathSegmentSafety(segment) {
3592
+ if (!segment) return true;
3593
+ if (hasFileRedirect(segment)) return false;
3594
+ if (hasShellInjectionRisk(segment)) return false;
3595
+ const [cmd0, ...rest] = segment.split(/\s+/);
3596
+ if (cmd0 === void 0) return true;
3597
+ if (VERSION_CHECK_BINARIES.has(cmd0) && rest.length === 1 && VERSION_FLAGS.has(rest[0])) {
3598
+ return true;
3599
+ }
3600
+ if (READ_ONLY_BINARIES.has(cmd0)) return true;
3601
+ if (READ_ONLY_NO_ARGS_BINARIES.has(cmd0)) {
3602
+ return rest.length === 0 ? true : void 0;
3603
+ }
3604
+ if (cmd0 === "git") {
3605
+ return GIT_READ_ONLY_SUBCOMMANDS.has(rest[0] ?? "") ? true : void 0;
3606
+ }
3607
+ return void 0;
3608
+ }
3609
+ function fastPathSafety(command) {
3610
+ const results = commandSegments(command).map(fastPathSegmentSafety);
3611
+ if (results.some((r) => r === false)) return false;
3612
+ if (results.every((r) => r === true)) return true;
3613
+ return void 0;
3614
+ }
3615
+ var CLASSIFIER_MODEL = "claude-haiku-4-5";
3616
+ var commandSafetySchema = z15.object({
3617
+ safe: z15.boolean(),
3618
+ reason: z15.string().describe("One short sentence explaining the verdict.")
3619
+ });
3620
+ function defaultCreateModel() {
3621
+ const token = getAuthToken();
3622
+ if (!token) {
3623
+ throw new Error("Not authenticated: no user token available");
3528
3624
  }
3625
+ return createAnthropic({
3626
+ apiKey: token,
3627
+ baseURL: PROXY_BASE_URL,
3628
+ fetch: proxyFetch
3629
+ });
3630
+ }
3631
+ function approvedCommandHistory(approvedCommands) {
3632
+ return Array.from(approvedCommands).map((entry) => {
3633
+ const sep2 = entry.indexOf("\0");
3634
+ return { cwd: entry.slice(0, sep2), command: entry.slice(sep2 + 1) };
3635
+ });
3636
+ }
3637
+ async function classifyCommandSafety(createModel, command, cwd, explanation, approvedHistory) {
3638
+ try {
3639
+ const anthropic = createModel();
3640
+ const { output } = await generateText({
3641
+ model: anthropic(CLASSIFIER_MODEL),
3642
+ temperature: 0,
3643
+ output: Output.object({ schema: commandSafetySchema }),
3644
+ prompt: [
3645
+ "A coding agent wants to run this shell command in a user's project without asking for approval first. The command may be in any programming language or ecosystem.",
3646
+ "Judge it SAFE only if it cannot modify, delete, move, rename, or install anything the project cares about, cannot push/publish/deploy/commit anything, and cannot make a network request that changes remote state.",
3647
+ "Three broad categories are safe, and most commands you will see fall into one of them:",
3648
+ "(1) Reporting or listing existing state, with no side effect \u2014 e.g. `git status`, `npm ls`, `npm outdated`, `pip show`, `pip freeze`, `cargo tree`, `docker ps`, `docker images`, `kubectl get pods`, `terraform state list`. This includes a read-only GET-style query to a remote registry or API that only fetches public metadata and changes nothing remote (e.g. `npm view <package>`, `pip index versions <package>`, `curl` with no -X/--request other than GET and no -d/--data) \u2014 safe because nothing changes, not because it stays local.",
3649
+ '(2) A "preview" or "check" mode that reports what a change WOULD do, or reports a problem, without making the change. This is a general pattern, not a fixed list \u2014 ANY command that takes a dry-run/check/plan/validate/diff/list-only flag is safe when that flag is present, regardless of whether the same flag would normally appear on a "safe" kind of command: `terraform plan`, `terraform validate`, `terraform fmt -check`, `black --check`, `prettier --check`, `isort --check`, `gofmt -l` (list-only), `stylelint` with no `--fix`, and equally `git push --dry-run`, `npm publish --dry-run`, `kubectl apply --dry-run=client` \u2014 these last three are otherwise-unsafe operations (push, publish, cluster changes) that the dry-run flag turns into a report. The corresponding command WITHOUT that flag (e.g. `terraform apply`, `black .`, `gofmt -w`, `git push`) is a different, unsafe command \u2014 the flag is what makes the difference, for any tool, not just the ones named here. This cuts both ways, so do not assume a bare invocation is the safe one: `cargo fmt`, `black`, `prettier`, `isort`, `rustfmt`, and `terraform fmt` all REWRITE FILES by default and need an explicit check/diff/dry-run flag to become safe, while `gofmt`, `eslint`, `stylelint`, `rubocop`, and `ruff check` default to a safe check-only mode and need an explicit fix/write flag to become unsafe \u2014 the same-looking bare command is safe for one group and unsafe for the other, so judge each by what its own flags actually say, not by resemblance to a tool you already judged.',
3650
+ "(3) Running the project's own tests, type checker, linter, or build/compile step (e.g. `npm run build`, `yarn build`, `vite build`, `webpack`, `tsc`, `cargo build`, `go build`, `mvn compile`), as long as it is not passed an autofix/write/update flag (e.g. --fix, -u, --write, rubocop -a) \u2014 this holds even though the point of a build step is to write compiled output to a build/dist/target directory inside the project (e.g. a target/, build/, dist/, or __pycache__ directory): writing that output does not make the command unsafe. It stops being safe the moment the command chain goes past building \u2014 a step that also deploys, publishes, uploads, or pushes the build (e.g. `next build && vercel deploy`, `npm run build && npm publish`) is unsafe for that later segment even though the build segment itself is fine; judge each chained segment on its own, same as elsewhere in this list.",
3651
+ "Deleting or removing anything is unsafe, even something in a cache or build directory (e.g. `rm -rf __pycache__`, `cargo clean`, `git clean`), and even if the command otherwise fits one of the three categories above. Installing, uninstalling, or upgrading a dependency, changing a database schema, or writing to a path outside the project is also unsafe. A command chained with && or ; is only safe if every part of the chain is safe on its own.",
3652
+ "One specific, narrow exception to all of the above: when the command literally invokes one of these runner programs BY NAME as the leading command \u2014 npx, bunx, pnpm dlx, yarn dlx, pipx run, uvx \u2014 it is unsafe regardless of what it then runs, even something that looks like a harmless test/lint/typecheck command (e.g. `npx tsc --noEmit`, `uvx ruff check .`), because that runner can fetch and execute a different, unreviewed version of a package from a registry each time. This exception is about that specific syntax, not a general doubt about whether a tool is installed: a plain `ruff check .`, `pytest`, or any other bare command name is NOT this exception merely because you cannot verify from the string alone that it is installed \u2014 judge those the same as any other already-installed project tool, per the categories above. If you are unsure, judge it unsafe.",
3653
+ ...approvedHistory.length > 0 ? [
3654
+ "The user has already explicitly approved these exact commands earlier in this session (working directory in parentheses, then the command):",
3655
+ approvedHistory.map((h) => `- (${h.cwd}) ${h.command}`).join("\n"),
3656
+ "If the new command performs the same action and side-effect profile as one of these \u2014 differing only in a trivial way, such as a different file path, package name, or argument value that does not change what kind of action it is \u2014 judge it SAFE on that precedent, even if it would not otherwise fit categories (1)-(3) above. Do not stretch this to a command that merely shares a binary name, or superficially resembles one on the list while doing something riskier or of a different kind (e.g. an approved `rm build/tmp.log` does not license a new `rm -rf src/`, and an approved `git push origin feature-x` does not license `git push --force`)."
3657
+ ] : [],
3658
+ `Command: ${command}`,
3659
+ `Working directory: ${cwd}`,
3660
+ `Stated purpose: ${explanation}`
3661
+ ].join("\n")
3662
+ });
3663
+ if (!output.safe) {
3664
+ logger.info(
3665
+ { command, reason: output.reason },
3666
+ "runShell: classifier judged command unsafe, requiring approval"
3667
+ );
3668
+ }
3669
+ return output.safe;
3670
+ } catch (err) {
3671
+ logger.warn(
3672
+ { err, command },
3673
+ "runShell: command safety classifier failed, requiring approval"
3674
+ );
3675
+ return false;
3676
+ }
3677
+ }
3678
+ async function runAndRecord(ctx, command, cwd) {
3529
3679
  useWizard.getState().pushNotice({ messages: [`Running: ${command}`] });
3530
3680
  const env = await ctx.shell.env().catch((err) => {
3531
3681
  logger.warn({ err, command }, "runShell: could not resolve command env");
@@ -3561,9 +3711,18 @@ async function approveAndRun(ctx, command, cwd, explanation) {
3561
3711
  output: run2.output
3562
3712
  };
3563
3713
  }
3564
- function runShellTool(ctx) {
3714
+ async function approveAndRun(ctx, command, cwd, explanation) {
3715
+ const decision = await ctx.shell.approve({ command, cwd, explanation });
3716
+ if (decision === "reject") {
3717
+ ctx.shell.executions.push({ command, cwd, approved: false });
3718
+ logger.info({ command }, "runShell: user rejected the command");
3719
+ return "The user rejected this command. Do not retry it. Propose a different command, or report the limitation via reportStatus.";
3720
+ }
3721
+ return runAndRecord(ctx, command, cwd);
3722
+ }
3723
+ function runShellTool(ctx, createModel = defaultCreateModel) {
3565
3724
  return tool8({
3566
- description: "Run a shell command in the project. Use this for anything the project needs done in its own ecosystem: installing dependencies, running a script you wrote, running the project's lint/typecheck/test commands. To inspect the project, use listFiles or searchFiles instead of ls/find \u2014 this tool refuses those. The user sees and approves every command before it runs, so write a clear `explanation`. If the user rejects a command, do not retry it \u2014 propose a different approach.",
3725
+ description: "Run a shell command in the project. Use this for anything the project needs done in its own ecosystem: installing dependencies, running a script you wrote, running the project's lint/typecheck/test commands. Do not use this to find or read files \u2014 use listFiles, searchFiles, and readFile instead of ls/find/cat/head/tail/grep/rg. This tool refuses ls/find/tree/dir outright; a read command like cat is not refused (some read-only commands run without approval, see below), but it's still the wrong tool for reading a file \u2014 the dedicated tools exist for that and won't count against this tool's command budget. The user approves any command that could change the project before it runs, so write a clear `explanation`. A command judged read-only (inspection, or running tests/typecheck/lint without an autofix flag, in any language) runs immediately without approval. If the user rejects a command, do not retry it \u2014 propose a different approach.",
3567
3726
  inputSchema: z15.object({
3568
3727
  command: z15.string().describe(
3569
3728
  "The command to run, exactly as it would be typed in a shell. Pipes, && and redirects are allowed."
@@ -3582,9 +3741,29 @@ function runShellTool(ctx) {
3582
3741
  const resolved2 = resolveInRoot(ctx, cwd ?? ".");
3583
3742
  if (!resolved2.ok) return resolved2.error;
3584
3743
  if (isExploratoryCommand(command)) {
3585
- return "Refused: use listFiles or searchFiles to inspect the project instead of ls/find/tree.";
3744
+ return "Refused: use listFiles or searchFiles to find files, and readFile to read one, instead of ls/find/tree/dir.";
3586
3745
  }
3587
3746
  logger.info({ command, cwd: resolved2.target }, "called runShell tool");
3747
+ const fast = fastPathSafety(command);
3748
+ let isSafe = fast;
3749
+ if (isSafe === void 0) {
3750
+ const store = useWizard.getState();
3751
+ isSafe = store.isCommandApproved(command, resolved2.target);
3752
+ if (!isSafe) {
3753
+ isSafe = await classifyCommandSafety(
3754
+ createModel,
3755
+ command,
3756
+ resolved2.target,
3757
+ explanation,
3758
+ approvedCommandHistory(store.approvedCommands)
3759
+ );
3760
+ }
3761
+ }
3762
+ if (isSafe) {
3763
+ return serializePrompt(
3764
+ () => runAndRecord(ctx, command, resolved2.target)
3765
+ );
3766
+ }
3588
3767
  return serializePrompt(
3589
3768
  () => approveAndRun(ctx, command, resolved2.target, explanation)
3590
3769
  );
@@ -3634,8 +3813,8 @@ function reviewScriptTool(ctx) {
3634
3813
  }
3635
3814
 
3636
3815
  // src/lib/tools/generateRecord.ts
3637
- import { tool as tool10, generateText, Output, NoObjectGeneratedError } from "ai";
3638
- import { createAnthropic } from "@ai-sdk/anthropic";
3816
+ import { tool as tool10, generateText as generateText2, Output as Output2, NoObjectGeneratedError } from "ai";
3817
+ import { createAnthropic as createAnthropic2 } from "@ai-sdk/anthropic";
3639
3818
  import { nanoid as nanoid2 } from "nanoid";
3640
3819
  import { mkdir as mkdir5, writeFile as writeFile6 } from "node:fs/promises";
3641
3820
  import { dirname as dirname6 } from "node:path";
@@ -3645,18 +3824,18 @@ var RECORD_MODEL = "claude-haiku-4-5";
3645
3824
  var MAX_RECORDS = 100;
3646
3825
  var BATCH_SIZE = 10;
3647
3826
  var MAX_BATCH_ATTEMPTS = 3;
3648
- function defaultCreateModel() {
3827
+ function defaultCreateModel2() {
3649
3828
  const token = getAuthToken();
3650
3829
  if (!token) {
3651
3830
  throw new Error("Not authenticated: no user token available");
3652
3831
  }
3653
- return createAnthropic({
3832
+ return createAnthropic2({
3654
3833
  apiKey: token,
3655
3834
  baseURL: PROXY_BASE_URL,
3656
3835
  fetch: proxyFetch
3657
3836
  });
3658
3837
  }
3659
- function generateRecordTool(ctx, createModel = defaultCreateModel) {
3838
+ function generateRecordTool(ctx, createModel = defaultCreateModel2) {
3660
3839
  return tool10({
3661
3840
  description: "Generate realistic sample records for an entity and write them to a JSON file. Provide the entity name and its attributes; this tool asks a model to invent varied, realistic values, each with a unique objectID, and returns the file path to read them from at runtime. Do not invent the record values or objectIDs yourself, and do not inline the returned records into the script \u2014 call this tool and read the file it writes.",
3662
3841
  inputSchema: z17.object({
@@ -3677,9 +3856,9 @@ function generateRecordTool(ctx, createModel = defaultCreateModel) {
3677
3856
  let lastError;
3678
3857
  for (let attempt = 1; attempt <= MAX_BATCH_ATTEMPTS; attempt++) {
3679
3858
  try {
3680
- const { output } = await generateText({
3859
+ const { output } = await generateText2({
3681
3860
  model: anthropic(RECORD_MODEL),
3682
- output: Output.object({
3861
+ output: Output2.object({
3683
3862
  schema: z17.object({
3684
3863
  records: z17.array(recordSchema).length(batchCount)
3685
3864
  })
@@ -3844,7 +4023,7 @@ async function runAgentAttempt(req, attempt) {
3844
4023
  if (!token) {
3845
4024
  throw new Error("Not authenticated: no user token available");
3846
4025
  }
3847
- const anthropic = createAnthropic2({
4026
+ const anthropic = createAnthropic3({
3848
4027
  apiKey: token,
3849
4028
  baseURL: PROXY_BASE_URL,
3850
4029
  fetch: proxyFetch
@@ -3881,7 +4060,7 @@ async function runAgentAttempt(req, attempt) {
3881
4060
  }
3882
4061
  };
3883
4062
  }),
3884
- output: Output2.object({ schema: req.outputSchema }),
4063
+ output: Output3.object({ schema: req.outputSchema }),
3885
4064
  tools: createTools(toolContext, {
3886
4065
  output: req.outputSchema,
3887
4066
  tools: req.tools
@@ -3969,6 +4148,7 @@ var detectLanguage = () => runAgent({
3969
4148
  "Return the exact version",
3970
4149
  "Exclude things like CSS frameworks, build tools, or testing frameworks",
3971
4150
  `Determine publicEnvVarPrefix: check the project's own env var usage first (e.g. names already referenced in code, .env/.env.example); if none exists, fall back to the detected framework's known convention for exposing env vars to client-side code; use "" when the project has no such convention (e.g. a backend-only project).`,
4151
+ "A brand-new project has no existing env var usage to find \u2014 one or two targeted checks (e.g. .env/.env.example, or a grep for the bundler's public-prefix convention) are enough to confirm that. Do not keep searching once those turn up nothing; fall back to the framework convention immediately.",
3972
4152
  'Use as few tools as possible, but do not guess. If you cant find the answer, say "unknown"',
3973
4153
  "When done, call reportStatus"
3974
4154
  ],
@@ -4064,7 +4244,7 @@ async function runAnalysis(mode, extraInstructions = []) {
4064
4244
  // package.json
4065
4245
  var package_default = {
4066
4246
  name: "@algolia/wizard",
4067
- version: "0.32.0",
4247
+ version: "0.33.0-rc.125.246",
4068
4248
  description: "Magically implement Algolia functionality in your codebase",
4069
4249
  type: "module",
4070
4250
  engines: {
@@ -4469,12 +4649,13 @@ ${formatCompletedSteps(ctx.completedSteps)}`,
4469
4649
  };
4470
4650
 
4471
4651
  // src/actions/implement.ts
4472
- import z29 from "zod";
4652
+ import z28 from "zod";
4653
+ import { mkdir as mkdir7 } from "node:fs/promises";
4473
4654
  import { join as join12, relative as relative6 } from "node:path";
4474
4655
 
4475
4656
  // src/lib/git.ts
4476
4657
  import { execFile as execFile2 } from "node:child_process";
4477
- import { copyFile, mkdir as mkdir6, readFile as readFile8, stat as stat3, writeFile as writeFile7 } from "node:fs/promises";
4658
+ import { copyFile, mkdir as mkdir6, stat as stat3 } from "node:fs/promises";
4478
4659
  import { basename as basename2, dirname as dirname7, isAbsolute as isAbsolute2, join as join10, resolve as resolve3 } from "node:path";
4479
4660
  var MAX_BUFFER = 32 * 1024 * 1024;
4480
4661
  function git(args) {
@@ -4528,42 +4709,6 @@ async function copyUploadIntoProject(repoRoot, ingestDir, sourcePath) {
4528
4709
  }
4529
4710
  return { ok: true, relPath };
4530
4711
  }
4531
- function hasEnvVar(content, name) {
4532
- return new RegExp(`^(\\s*(?:export\\s+)?${name})\\s*=`, "m").test(content);
4533
- }
4534
- async function readEnvVar(repoRoot, name) {
4535
- let content;
4536
- try {
4537
- content = await readFile8(join10(repoRoot, ".env"), "utf8");
4538
- } catch (err) {
4539
- if (err.code !== "ENOENT") throw err;
4540
- return void 0;
4541
- }
4542
- const match = new RegExp(
4543
- `^[ \\t]*(?:export[ \\t]+)?${name}[ \\t]*=[ \\t]*(.*)$`,
4544
- "m"
4545
- ).exec(content);
4546
- if (!match) return void 0;
4547
- const value = match[1].trim().replace(/^(['"])(.*)\1$/, "$2").trim();
4548
- if (!value || value.startsWith("<")) return void 0;
4549
- return value;
4550
- }
4551
- async function writeSearchEnvValues(repoRoot, vars) {
4552
- const target = join10(repoRoot, ".env");
4553
- let existing = "";
4554
- try {
4555
- existing = await readFile8(target, "utf8");
4556
- } catch (err) {
4557
- if (err.code !== "ENOENT") throw err;
4558
- }
4559
- const missing = vars.filter((v) => !hasEnvVar(existing, v.name));
4560
- if (missing.length === 0) return [];
4561
- const prefix = existing.length > 0 && !existing.endsWith("\n") ? "\n" : "";
4562
- const lines = missing.map(({ name, value }) => `${name}=${value}
4563
- `).join("");
4564
- await writeFile7(target, existing + prefix + lines, "utf8");
4565
- return missing.map((v) => v.name);
4566
- }
4567
4712
  function normalizeFindingPaths(findings) {
4568
4713
  return {
4569
4714
  ...findings,
@@ -4644,46 +4789,36 @@ function getFrameworkSpecificDoc(frameworks) {
4644
4789
  return loadAlgoliaDoc("js");
4645
4790
  }
4646
4791
 
4647
- // src/actions/resolveEnvVarPrefix.ts
4648
- import z28 from "zod";
4649
- var resolveEnvVarPrefixSchema = z28.object({
4650
- publicEnvVarPrefix: detectLanguageSchema.shape.publicEnvVarPrefix
4651
- });
4652
- var resolveEnvVarPrefix = (frameworkName) => runAgent({
4653
- instructions: [
4654
- `The developer corrected the project's framework to "${frameworkName}".`,
4655
- `Determine publicEnvVarPrefix for this framework: check the project's own env var usage first (e.g. names already referenced in code, .env/.env.example); if none exists, fall back to this framework's known convention for exposing env vars to client-side code; use "" when the framework has no such convention (e.g. a backend-only framework).`,
4656
- 'Use as few tools as possible, but do not guess. If you cant find the answer, say "unknown"',
4657
- "When done, call reportStatus"
4658
- ],
4659
- tools: ["listFiles", "changeDirectory", "readFile", "searchFiles"],
4660
- outputSchema: resolveEnvVarPrefixSchema,
4661
- modelSize: "small"
4662
- });
4663
-
4664
4792
  // src/actions/implement.ts
4665
- var implementSchema = z29.object({
4666
- summary: z29.string(),
4667
- ingestCommand: z29.string().optional(),
4668
- ingestScriptRan: z29.boolean().optional(),
4669
- ingestRecordCount: z29.number().optional(),
4670
- ingestDurationMs: z29.number().optional(),
4671
- ingestionSource: z29.enum(["local", "fileUpload", "generated"]),
4672
- searchEnvVars: z29.array(
4673
- z29.object({
4674
- name: z29.string(),
4675
- value: z29.string()
4676
- })
4677
- ).optional()
4793
+ var implementSchema = z28.object({
4794
+ summary: z28.string(),
4795
+ ingestCommand: z28.string().optional(),
4796
+ ingestScriptRan: z28.boolean().optional(),
4797
+ ingestRecordCount: z28.number().optional(),
4798
+ ingestDurationMs: z28.number().optional(),
4799
+ ingestionSource: z28.enum(["local", "fileUpload", "generated"]),
4800
+ searchConfig: z28.object({
4801
+ filePath: z28.string().optional(),
4802
+ vars: z28.array(
4803
+ z28.object({
4804
+ name: z28.string(),
4805
+ value: z28.string()
4806
+ })
4807
+ )
4808
+ }).optional()
4678
4809
  });
4679
- var implementationOutputSchema = z29.object({
4680
- summary: z29.string(),
4681
- ingestCommand: z29.string().optional()
4810
+ var implementationOutputSchema = z28.object({
4811
+ summary: z28.string(),
4812
+ ingestCommand: z28.string().optional(),
4813
+ // Only for the search use case: the path of whatever module the agent
4814
+ // defined the Algolia config constants in, so the wizard can check it
4815
+ // won't end up gitignored (it's public, meant to be committed).
4816
+ searchConfigFile: z28.string().optional()
4682
4817
  });
4683
- var verificationOutputSchema = z29.object({
4684
- summary: z29.string(),
4685
- sufficient: z29.boolean(),
4686
- additionalInstructions: z29.string().optional()
4818
+ var verificationOutputSchema = z28.object({
4819
+ summary: z28.string(),
4820
+ sufficient: z28.boolean(),
4821
+ additionalInstructions: z28.string().optional()
4687
4822
  });
4688
4823
  var MAX_IMPLEMENT_VERIFICATION_ATTEMPTS = 3;
4689
4824
  var DEFAULT_IMPLEMENT_USE_CASES = ["ingestion", "search"];
@@ -4697,6 +4832,10 @@ function isJsProject(language) {
4697
4832
  (name) => JS_LANGUAGES.some((js) => name.includes(js))
4698
4833
  );
4699
4834
  }
4835
+ var SEARCH_CONFIG_APP_ID = "ALGOLIA_APP_ID";
4836
+ var SEARCH_CONFIG_SEARCH_KEY = "ALGOLIA_SEARCH_API_KEY";
4837
+ var SEARCH_CONFIG_INDEX_NAME = "ALGOLIA_INDEX_NAME";
4838
+ var SEARCH_KEY_PLACEHOLDER = "<your-algolia-search-only-api-key>";
4700
4839
  var UI_FRAMEWORKS = [
4701
4840
  { match: ["vue", "nuxt"], target: "Vue", doc: "vue" },
4702
4841
  { match: ["react", "next"], target: "React", doc: "react" },
@@ -4759,7 +4898,7 @@ function algoliaClientDoc(input) {
4759
4898
  function ingestionInstructions(input) {
4760
4899
  return [
4761
4900
  ...input.confirmed && input.confirmed.length ? [
4762
- `Create an ingestion script under "${input.ingestDir}/" at the repo root.`,
4901
+ `Create an ingestion script under "${input.ingestDir}/" at the repo root. That directory already exists \u2014 writeFile creates any nested path itself, so never run a shell command just to create a directory.`,
4763
4902
  `Ingest only the confirmed entity (name, source paths, attributes): ${JSON.stringify(input.confirmed)}.`,
4764
4903
  `Ingesting writes to Algolia, so the script needs a write API key and App ID \u2014 read them from the ${API_KEY_VAR} and ${APP_ID_VAR} environment variables rather than hardcoding them. The wizard sets these when it runs the script.`,
4765
4904
  `Read the index name from the ${INDEX_NAME_VAR} environment variable, which the wizard sets to "${input.targetIndex}". Never hardcode an index name or derive one from the project, file, or entity name \u2014 the write key only works for that exact index. Exit with an error if ${INDEX_NAME_VAR} is unset.`,
@@ -4767,6 +4906,7 @@ function ingestionInstructions(input) {
4767
4906
  "After a successful ingest, the script must print exactly one line to stdout in the form `ALGOLIA_WIZARD_RECORD_COUNT=<n>`, where <n> is the total number of records pushed to Algolia. Print it last, on its own line, with no surrounding text.",
4768
4907
  ...algoliaClientDoc(input),
4769
4908
  "Install the Algolia client with the project's own package manager via runShell, declaring it in whatever manifest the project uses (e.g. package.json, requirements.txt, Gemfile, go.mod, composer.json) so the dependency is not just installed ad hoc.",
4909
+ `If the script loads its env vars from a file (e.g. via dotenv or an equivalent for its language) rather than the process environment directly, decide that up front and call writeCredentials on that file before you finish the script \u2014 do not wait to discover the need for it by having a writeFile call refused.`,
4770
4910
  "When the script is finished, call reviewScript with its path and wait: running it writes records to a live index, so the developer reads it first. Do not run it before that call returns.",
4771
4911
  'Then run the script yourself via runShell, and report the command you ran as "ingestCommand" so the developer can re-run it. Its explanation must say that running it writes records to Algolia.',
4772
4912
  "The summary should be extremely concise.",
@@ -4788,17 +4928,14 @@ function searchInstructions(input) {
4788
4928
  `Create the search experience as its own component in a new file, following the project's existing component conventions (location, naming, styling approach). Do not write it inline into an existing file.`,
4789
4929
  `Import and render that new component from ${input.searchLocation ? `"${input.searchLocation}"` : "the best shared, always-rendered layout location (e.g. a header/nav component)"} so it is reachable across the app \u2014 at least a working search input and results list against the target index.`,
4790
4930
  "If a search box already exists, replace its usage with an import and render of your new component; remove the old implementation.",
4791
- `Read the index name from the ${publicIndexNameVar(input.publicEnvVarPrefix)} env var, which the wizard sets to "${input.targetIndex}". Never hardcode an index name or derive one from the project, file, or component name.`,
4792
- "Read the App ID, the search-only API key, and the index name from env vars; never hardcode them. A search-only key is safe to expose client-side.",
4793
- // The key is provisioned only after verification passes, and the wizard
4794
- // reads .env to decide whether a key already exists an agent-invented
4795
- // value there would be reused as if it were real.
4796
- `Add Algolia App ID "${input.appId}"; leave the search-only key as a placeholder. Do not create or edit .env \u2014 the wizard writes the resolved key there itself.`,
4797
- // The wizard writes these exact names into .env right after this step.
4798
- `Use exactly these env var names in the code: ${input.searchEnvVars.map(({ name }) => name).join(", ")}.`,
4931
+ "When rendering results with an existing shared component (e.g. a card), import and reuse that component rather than inlining its markup \u2014 inlining silently drops the styles and behavior its own file provides.",
4932
+ `Define ${SEARCH_CONFIG_APP_ID}, ${SEARCH_CONFIG_SEARCH_KEY}, and ${SEARCH_CONFIG_INDEX_NAME} as exported constants in a module that fits this project's existing conventions for shared client-side config \u2014 reuse an existing one if it already holds config like this, or add a small new one otherwise. These are PUBLIC values, safe to commit and expose client-side: never read them from an environment variable or a .env* file, and never hardcode them anywhere except in that one module (import them wherever the search client needs them).`,
4933
+ `Set ${SEARCH_CONFIG_APP_ID} to "${input.appId}" and ${SEARCH_CONFIG_INDEX_NAME} to "${input.targetIndex}".`,
4934
+ input.searchKey ? `Set ${SEARCH_CONFIG_SEARCH_KEY} to "${input.searchKey}".` : `A real search-only key could not be provisioned${input.searchKeyError ? ` (${input.searchKeyError})` : ""} \u2014 set ${SEARCH_CONFIG_SEARCH_KEY} to the placeholder "${SEARCH_KEY_PLACEHOLDER}" and add a prominent TODO for the developer to fill in a real one.`,
4935
+ 'Report the repo-relative path of that module as "searchConfigFile" in your final status.',
4799
4936
  "Install any Algolia packages you import with the project's own package manager via runShell, and declare them in the project's dependency manifest.",
4800
4937
  "Match the styles of the application as closely as possible.",
4801
- "The summary should be extremely concise; do not mention env var setup or manual testing steps \u2014 the wizard writes the resolved credentials to .env and reports that separately."
4938
+ "The summary should be extremely concise; do not mention manual testing steps."
4802
4939
  ];
4803
4940
  }
4804
4941
  function verificationInstructions(input) {
@@ -4929,21 +5066,6 @@ async function implement(ctx, useCases = DEFAULT_IMPLEMENT_USE_CASES) {
4929
5066
  languages: ctx.getStepOutput("confirm-language")?.languages ?? scan.languages,
4930
5067
  frameworks: ctx.getStepOutput("confirm-framework")?.frameworks ?? scan.frameworks
4931
5068
  };
4932
- const normalizeFrameworkName = (name) => name.toLowerCase().replace(/[^a-z0-9]/g, "");
4933
- const confirmedPrimaryFramework = language.frameworks[0]?.name;
4934
- const frameworkWasCorrected = confirmedPrimaryFramework !== void 0 && !scan.frameworks.some(
4935
- (fw) => normalizeFrameworkName(fw.name) === normalizeFrameworkName(confirmedPrimaryFramework)
4936
- );
4937
- const publicEnvVarPrefixPromise = frameworkWasCorrected ? resolveEnvVarPrefix(confirmedPrimaryFramework).then(
4938
- (r) => r.publicEnvVarPrefix,
4939
- (err) => {
4940
- logger.warn(
4941
- { err, framework: confirmedPrimaryFramework },
4942
- "implement: could not re-resolve publicEnvVarPrefix after a framework correction; using the stale scan value"
4943
- );
4944
- return scan.publicEnvVarPrefix;
4945
- }
4946
- ) : Promise.resolve(scan.publicEnvVarPrefix);
4947
5069
  const selected = ctx.getStepOutput(
4948
5070
  "select-index"
4949
5071
  );
@@ -4994,6 +5116,9 @@ async function implement(ctx, useCases = DEFAULT_IMPLEMENT_USE_CASES) {
4994
5116
  const targetIndex = selected?.selection;
4995
5117
  useWizard.getState().setTargetIndex(targetIndex ?? null);
4996
5118
  await assertGitRepoWithHead(repoRoot);
5119
+ if (useCases.includes("ingestion")) {
5120
+ await mkdir7(join12(repoRoot, INGEST_DIR), { recursive: true });
5121
+ }
4997
5122
  const normalized = normalizeFindingPaths(findings);
4998
5123
  const confirmed2 = normalized.confirmedEntities;
4999
5124
  const searchLocation = normalized.searchImplementationAnalysis;
@@ -5024,49 +5149,42 @@ async function implement(ctx, useCases = DEFAULT_IMPLEMENT_USE_CASES) {
5024
5149
  );
5025
5150
  }
5026
5151
  }
5027
- const publicEnvVarPrefix = await publicEnvVarPrefixPromise;
5152
+ const summaries = [];
5153
+ if (uploadWarning) summaries.push(uploadWarning);
5154
+ let searchKey;
5155
+ let searchKeyError;
5156
+ if (useCases.includes("search") && appId) {
5157
+ try {
5158
+ const resolved2 = await resolveSearchOnlyKey(targetIndex, appId);
5159
+ searchKey = resolved2.key;
5160
+ summaries.push(
5161
+ resolved2.source === "created" ? `Created a new search-only Algolia API key for the "${targetIndex}" index in app ${appId} \u2014 safe to expose in frontend code.` : `Reused the existing search-only Algolia API key for the "${targetIndex}" index in app ${appId}.`
5162
+ );
5163
+ } catch (err) {
5164
+ searchKeyError = err.message;
5165
+ summaries.push(
5166
+ `Could not provision a search-only Algolia API key (${searchKeyError}) \u2014 the search agent will scaffold a placeholder with a TODO for you to fill in.`
5167
+ );
5168
+ logger.warn(
5169
+ { err: searchKeyError },
5170
+ "implement: could not provision a search-only API key; the agent will scaffold a placeholder"
5171
+ );
5172
+ }
5173
+ }
5028
5174
  const input = {
5029
5175
  findings: normalized,
5030
5176
  confirmed: confirmed2,
5031
5177
  searchLocation,
5032
5178
  targetIndex,
5033
5179
  language,
5034
- publicEnvVarPrefix,
5035
5180
  appId,
5036
- searchEnvVars: publicSearchEnvVars(publicEnvVarPrefix, targetIndex, appId),
5181
+ searchKey,
5182
+ searchKeyError,
5037
5183
  ingestDir: INGEST_DIR,
5038
5184
  ingestionSource,
5039
5185
  uploadFilePath,
5040
5186
  searchUiTarget: searchUiTarget(language)
5041
5187
  };
5042
- const summaries = [];
5043
- if (uploadWarning) summaries.push(uploadWarning);
5044
- let envSearchKey;
5045
- let envAppIdMismatch = false;
5046
- if (useCases.includes("search") && appId) {
5047
- const envAppId = await readEnvVar(
5048
- repoRoot,
5049
- publicAppIdVar(publicEnvVarPrefix)
5050
- );
5051
- if (envAppId === appId) {
5052
- envSearchKey = await readEnvVar(
5053
- repoRoot,
5054
- publicSearchKeyVar(publicEnvVarPrefix)
5055
- );
5056
- } else if (envAppId) {
5057
- envAppIdMismatch = true;
5058
- const appIdVarName = publicAppIdVar(publicEnvVarPrefix);
5059
- const searchKeyVarName = publicSearchKeyVar(publicEnvVarPrefix);
5060
- summaries.push(
5061
- `\u26A0\uFE0F .env already sets ${appIdVarName}=${envAppId}, but the active Algolia application is ${appId}. The wizard left those values alone \u2014 update ${appIdVarName} and ${searchKeyVarName} by hand, or searches will fail.`
5062
- );
5063
- logger.warn(
5064
- { envAppId, appId },
5065
- "implement: .env holds credentials for a different Algolia application; not reusing its search key"
5066
- );
5067
- }
5068
- }
5069
- let finalSearchEnvVars = input.searchEnvVars;
5070
5188
  let agentRuns = 0;
5071
5189
  let ingestCommand;
5072
5190
  let ingestScriptRan = false;
@@ -5176,6 +5294,7 @@ ${detail}` : ""}`
5176
5294
  ]
5177
5295
  });
5178
5296
  }
5297
+ let searchConfigFile;
5179
5298
  if (useCases.includes("search")) {
5180
5299
  let extraInstructions = [];
5181
5300
  useWizard.getState().clearWrittenFiles();
@@ -5190,11 +5309,14 @@ ${detail}` : ""}`
5190
5309
  "implement: retrying search implementation after failed verification"
5191
5310
  );
5192
5311
  }
5193
- const { summary } = await runImplementationUseCase(
5312
+ const searchResult = await runImplementationUseCase(
5194
5313
  "search",
5195
5314
  extraInstructions
5196
5315
  );
5197
- summaries.push(formatSummary("search", summary));
5316
+ summaries.push(formatSummary("search", searchResult.summary));
5317
+ if (searchResult.searchConfigFile) {
5318
+ searchConfigFile = searchResult.searchConfigFile;
5319
+ }
5198
5320
  const verification = await runVerificationUseCase();
5199
5321
  summaries.push(formatSummary("verification", verification.summary));
5200
5322
  if (verification.sufficient) {
@@ -5218,76 +5340,17 @@ ${detail}` : ""}`
5218
5340
  }
5219
5341
  extraInstructions = verificationRetryInstructions(verification);
5220
5342
  }
5221
- let searchKey;
5222
- let searchKeyError;
5223
- if (appId) {
5224
- try {
5225
- const resolved2 = await resolveSearchOnlyKey(
5226
- targetIndex,
5227
- appId,
5228
- envSearchKey
5229
- );
5230
- searchKey = resolved2.key;
5231
- summaries.push(
5232
- resolved2.source === "created" ? `Created a new search-only Algolia API key for the "${targetIndex}" index in app ${appId} \u2014 safe to expose in frontend code.` : `Reused the existing search-only Algolia API key for the "${targetIndex}" index in app ${appId}.`
5233
- );
5234
- } catch (err) {
5235
- searchKeyError = err.message;
5236
- logger.warn(
5237
- { err: searchKeyError },
5238
- "implement: could not provision a search-only API key; the .env value stays a placeholder"
5239
- );
5240
- }
5241
- }
5242
- finalSearchEnvVars = publicSearchEnvVars(
5243
- publicEnvVarPrefix,
5244
- targetIndex,
5245
- appId,
5246
- searchKey
5247
- );
5248
- const resolvedSearchEnvVars = finalSearchEnvVars.filter(
5249
- (v) => !v.value.startsWith("<")
5250
- );
5251
- if (resolvedSearchEnvVars.length > 0) {
5252
- const written = await writeSearchEnvValues(
5343
+ if (searchConfigFile) {
5344
+ const ignoreStatus = await gitIgnoreStatus(
5253
5345
  repoRoot,
5254
- resolvedSearchEnvVars
5346
+ join12(repoRoot, searchConfigFile)
5255
5347
  );
5256
- if (written.length > 0) {
5257
- summaries.push(`Wrote ${written.join(", ")} to .env.`);
5258
- }
5259
- const ignored = await ensureGitIgnored(repoRoot, join12(repoRoot, ".env"));
5260
- if (ignored === "added") {
5261
- summaries.push("Added .env to .gitignore.");
5262
- } else if (ignored === "tracked") {
5263
- summaries.push(
5264
- '\u26A0\uFE0F .env is tracked by git, so a .gitignore rule cannot un-stage it. Run "git rm --cached .env" before committing, or the credentials go into history.'
5265
- );
5266
- }
5267
- const stale = [];
5268
- for (const v of resolvedSearchEnvVars) {
5269
- if (written.includes(v.name)) continue;
5270
- const current = await readEnvVar(repoRoot, v.name);
5271
- if (current && current !== v.value) stale.push(v);
5272
- }
5273
- if (stale.length > 0 && !envAppIdMismatch) {
5348
+ if (ignoreStatus === "covered") {
5274
5349
  summaries.push(
5275
- `\u26A0\uFE0F .env already assigns a different value to ${stale.map((v) => `${v.name} (should be ${v.value})`).join(", ")} \u2014 the wizard left it alone. Fix it by hand, or searches will fail.`
5276
- );
5277
- logger.warn(
5278
- { vars: stale.map((v) => v.name) },
5279
- "implement: .env holds different values for the resolved search credentials; not overwriting them"
5350
+ `\u26A0\uFE0F ${searchConfigFile} is gitignored, so this public, safe-to-share search config won't reach teammates or CI. Remove whatever .gitignore rule covers it.`
5280
5351
  );
5281
5352
  }
5282
5353
  }
5283
- const unresolvedSearchEnvVars = finalSearchEnvVars.filter(
5284
- (v) => v.value.startsWith("<")
5285
- );
5286
- if (unresolvedSearchEnvVars.length > 0) {
5287
- summaries.push(
5288
- `Could not resolve a value for ${unresolvedSearchEnvVars.map((v) => v.name).join(", ")} \u2014 fill it in manually in .env.` + (searchKeyError ? ` Reason: ${searchKeyError}` : "")
5289
- );
5290
- }
5291
5354
  } else {
5292
5355
  ctx.setUserInput("implementation", "success");
5293
5356
  }
@@ -5300,7 +5363,19 @@ ${detail}` : ""}`
5300
5363
  ...ingestRecordCount != null ? { ingestRecordCount } : {},
5301
5364
  ...ingestDurationMs != null ? { ingestDurationMs } : {}
5302
5365
  } : {},
5303
- ...useCases.includes("search") ? { searchEnvVars: finalSearchEnvVars } : {}
5366
+ ...useCases.includes("search") ? {
5367
+ searchConfig: {
5368
+ filePath: searchConfigFile,
5369
+ vars: [
5370
+ { name: SEARCH_CONFIG_APP_ID, value: appId ?? "" },
5371
+ {
5372
+ name: SEARCH_CONFIG_SEARCH_KEY,
5373
+ value: searchKey ?? SEARCH_KEY_PLACEHOLDER
5374
+ },
5375
+ { name: SEARCH_CONFIG_INDEX_NAME, value: targetIndex }
5376
+ ]
5377
+ }
5378
+ } : {}
5304
5379
  };
5305
5380
  }
5306
5381
 
@@ -5340,8 +5415,8 @@ var defaultWorkflow = {
5340
5415
  defineStep({
5341
5416
  id: "select-index",
5342
5417
  title: "Set up index",
5343
- outputSchema: z30.object({
5344
- selection: z30.string()
5418
+ outputSchema: z29.object({
5419
+ selection: z29.string()
5345
5420
  }),
5346
5421
  run: (ctx) => selectIndexStep(ctx)
5347
5422
  }),
@@ -5449,10 +5524,14 @@ var confirmFramework2 = {
5449
5524
  var search = {
5450
5525
  summary: "Added an InstantSearch-powered search box and results list, mounted in the shared header component.",
5451
5526
  ingestionSource: "generated",
5452
- searchEnvVars: [
5453
- { name: "NEXT_PUBLIC_ALGOLIA_APP_ID", value: "SEEDAPPID" },
5454
- { name: "NEXT_PUBLIC_ALGOLIA_SEARCH_KEY", value: "seedsearchkey" }
5455
- ]
5527
+ searchConfig: {
5528
+ filePath: "src/algolia.config.ts",
5529
+ vars: [
5530
+ { name: "ALGOLIA_APP_ID", value: "SEEDAPPID" },
5531
+ { name: "ALGOLIA_SEARCH_API_KEY", value: "seedsearchkey" },
5532
+ { name: "ALGOLIA_INDEX_NAME", value: "wizard_seed_products" }
5533
+ ]
5534
+ }
5456
5535
  };
5457
5536
  var review = {
5458
5537
  summaryPoints: [
@@ -4,13 +4,18 @@ Install `instantsearch.js` (v4)
4
4
 
5
5
  ## Search client: always use the lite client
6
6
 
7
- Import the client from `algoliasearch/lite` and alias it to `algoliasearch`
7
+ Import the client from `algoliasearch/lite` and alias it to `algoliasearch`.
8
+ `ALGOLIA_APP_ID`, `ALGOLIA_SEARCH_API_KEY`, and `ALGOLIA_INDEX_NAME` below are
9
+ the constants exported from the project's shared client-side config module —
10
+ these are public values, safe to commit; never hardcode them inline or read
11
+ them from an env var.
8
12
 
9
13
  ```ts
10
14
  import { liteClient as algoliasearch } from 'algoliasearch/lite'
15
+ import { ALGOLIA_APP_ID, ALGOLIA_SEARCH_API_KEY } from '<the project's shared config module>'
11
16
 
12
17
  // Instantiate ONCE, outside components, with a stable reference.
13
- const searchClient = algoliasearch(APP_ID, SEARCH_ONLY_KEY)
18
+ const searchClient = algoliasearch(ALGOLIA_APP_ID, ALGOLIA_SEARCH_API_KEY)
14
19
  ```
15
20
 
16
21
  ## Vanilla (`instantsearch.js` v4)
@@ -19,11 +24,16 @@ const searchClient = algoliasearch(APP_ID, SEARCH_ONLY_KEY)
19
24
  import { liteClient as algoliasearch } from 'algoliasearch/lite'
20
25
  import instantsearch from 'instantsearch.js'
21
26
  import { searchBox, hits } from 'instantsearch.js/es/widgets'
27
+ import {
28
+ ALGOLIA_APP_ID,
29
+ ALGOLIA_SEARCH_API_KEY,
30
+ ALGOLIA_INDEX_NAME,
31
+ } from '<the project's shared config module>'
22
32
 
23
- const searchClient = algoliasearch(APP_ID, SEARCH_ONLY_KEY)
33
+ const searchClient = algoliasearch(ALGOLIA_APP_ID, ALGOLIA_SEARCH_API_KEY)
24
34
 
25
35
  const search = instantsearch({
26
- indexName: 'INDEX_NAME',
36
+ indexName: ALGOLIA_INDEX_NAME,
27
37
  searchClient,
28
38
  insights: true,
29
39
  })
@@ -53,7 +63,7 @@ case, install `search-insights` from npm and pass its default export as
53
63
  import aa from 'search-insights'
54
64
 
55
65
  const search = instantsearch({
56
- indexName: 'INDEX_NAME',
66
+ indexName: ALGOLIA_INDEX_NAME,
57
67
  searchClient,
58
68
  insights: { insightsClient: aa },
59
69
  })
@@ -4,13 +4,18 @@ Install `react-instantsearch` (v7)
4
4
 
5
5
  ## Search client: always use the lite client
6
6
 
7
- Import the client from `algoliasearch/lite` and alias it to `algoliasearch`
7
+ Import the client from `algoliasearch/lite` and alias it to `algoliasearch`.
8
+ `ALGOLIA_APP_ID`, `ALGOLIA_SEARCH_API_KEY`, and `ALGOLIA_INDEX_NAME` below are
9
+ the constants exported from the project's shared client-side config module —
10
+ these are public values, safe to commit; never hardcode them inline or read
11
+ them from an env var.
8
12
 
9
13
  ```ts
10
14
  import { liteClient as algoliasearch } from 'algoliasearch/lite'
15
+ import { ALGOLIA_APP_ID, ALGOLIA_SEARCH_API_KEY } from '<the project's shared config module>'
11
16
 
12
17
  // Instantiate ONCE, outside components, with a stable reference.
13
- const searchClient = algoliasearch(APP_ID, SEARCH_ONLY_KEY)
18
+ const searchClient = algoliasearch(ALGOLIA_APP_ID, ALGOLIA_SEARCH_API_KEY)
14
19
  ```
15
20
 
16
21
  ## React (`react-instantsearch` v7)
@@ -18,19 +23,19 @@ const searchClient = algoliasearch(APP_ID, SEARCH_ONLY_KEY)
18
23
  ```tsx
19
24
  import { liteClient as algoliasearch } from 'algoliasearch/lite'
20
25
  import { InstantSearch, SearchBox, Hits } from 'react-instantsearch'
26
+ import {
27
+ ALGOLIA_APP_ID,
28
+ ALGOLIA_SEARCH_API_KEY,
29
+ ALGOLIA_INDEX_NAME,
30
+ } from '<the project's shared config module>'
21
31
 
22
- // Read from PUBLIC env vars; never hardcode. (Vite shown; use the framework's
23
- // convention: NEXT_PUBLIC_* for Next.js, etc.)
24
- const searchClient = algoliasearch(
25
- import.meta.env.VITE_ALGOLIA_APP_ID,
26
- import.meta.env.VITE_ALGOLIA_SEARCH_API_KEY,
27
- )
32
+ const searchClient = algoliasearch(ALGOLIA_APP_ID, ALGOLIA_SEARCH_API_KEY)
28
33
 
29
34
  export function Search() {
30
35
  return (
31
36
  <InstantSearch
32
37
  searchClient={searchClient}
33
- indexName="INDEX_NAME"
38
+ indexName={ALGOLIA_INDEX_NAME}
34
39
  insights={true}
35
40
  >
36
41
  <SearchBox />
@@ -4,13 +4,18 @@ Install `vue-instantsearch` (v4)
4
4
 
5
5
  ## Search client: always use the lite client
6
6
 
7
- Import the client from `algoliasearch/lite` and alias it to `algoliasearch`
7
+ Import the client from `algoliasearch/lite` and alias it to `algoliasearch`.
8
+ `ALGOLIA_APP_ID`, `ALGOLIA_SEARCH_API_KEY`, and `ALGOLIA_INDEX_NAME` below are
9
+ the constants exported from the project's shared client-side config module —
10
+ these are public values, safe to commit; never hardcode them inline or read
11
+ them from an env var.
8
12
 
9
13
  ```ts
10
14
  import { liteClient as algoliasearch } from 'algoliasearch/lite'
15
+ import { ALGOLIA_APP_ID, ALGOLIA_SEARCH_API_KEY } from '<the project's shared config module>'
11
16
 
12
17
  // Instantiate ONCE, outside components, with a stable reference.
13
- const searchClient = algoliasearch(APP_ID, SEARCH_ONLY_KEY)
18
+ const searchClient = algoliasearch(ALGOLIA_APP_ID, ALGOLIA_SEARCH_API_KEY)
14
19
  ```
15
20
 
16
21
  ## Vue (`vue-instantsearch` v4)
@@ -19,7 +24,7 @@ const searchClient = algoliasearch(APP_ID, SEARCH_ONLY_KEY)
19
24
  <template>
20
25
  <ais-instant-search
21
26
  :search-client="searchClient"
22
- index-name="INDEX_NAME"
27
+ :index-name="ALGOLIA_INDEX_NAME"
23
28
  :insights="true"
24
29
  >
25
30
  <ais-search-box />
@@ -29,11 +34,13 @@ const searchClient = algoliasearch(APP_ID, SEARCH_ONLY_KEY)
29
34
 
30
35
  <script setup>
31
36
  import { liteClient as algoliasearch } from 'algoliasearch/lite'
37
+ import {
38
+ ALGOLIA_APP_ID,
39
+ ALGOLIA_SEARCH_API_KEY,
40
+ ALGOLIA_INDEX_NAME,
41
+ } from '<the project's shared config module>'
32
42
 
33
- const searchClient = algoliasearch(
34
- import.meta.env.VITE_ALGOLIA_APP_ID,
35
- import.meta.env.VITE_ALGOLIA_SEARCH_API_KEY,
36
- )
43
+ const searchClient = algoliasearch(ALGOLIA_APP_ID, ALGOLIA_SEARCH_API_KEY)
37
44
  </script>
38
45
  ```
39
46
 
@@ -5,10 +5,19 @@ route, or programmatic queries). For UI, prefer `instantsearch-setup-<framework>
5
5
 
6
6
  ## Client
7
7
 
8
+ `ALGOLIA_APP_ID`, `ALGOLIA_SEARCH_API_KEY`, and `ALGOLIA_INDEX_NAME` below are
9
+ the constants exported from the project's shared client-side config module —
10
+ these are public values, safe to commit; never hardcode them inline or read
11
+ them from an env var.
12
+
8
13
  ```ts
9
14
  import { algoliasearch } from 'algoliasearch'
15
+ import {
16
+ ALGOLIA_APP_ID,
17
+ ALGOLIA_SEARCH_API_KEY,
18
+ } from '<the project's shared config module>'
10
19
 
11
- const client = algoliasearch(APP_ID, SEARCH_ONLY_KEY)
20
+ const client = algoliasearch(ALGOLIA_APP_ID, ALGOLIA_SEARCH_API_KEY)
12
21
  ```
13
22
 
14
23
  In v5 there is no `client.initIndex(...)`. Index methods take the index name as a
@@ -18,7 +27,7 @@ parameter on the client. Every method takes a single options object.
18
27
 
19
28
  ```ts
20
29
  const { hits, nbHits } = await client.searchSingleIndex({
21
- indexName: 'INDEX_NAME',
30
+ indexName: ALGOLIA_INDEX_NAME,
22
31
  searchParams: { query: 'shoes', hitsPerPage: 20, page: 0 },
23
32
  })
24
33
  ```
@@ -28,7 +37,7 @@ const { hits, nbHits } = await client.searchSingleIndex({
28
37
  ```ts
29
38
  const { results } = await client.search({
30
39
  requests: [
31
- { indexName: 'INDEX_NAME', query: 'shoes' },
40
+ { indexName: ALGOLIA_INDEX_NAME, query: 'shoes' },
32
41
  { indexName: 'OTHER_INDEX', query: 'shoes' },
33
42
  ],
34
43
  })
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@algolia/wizard",
3
- "version": "0.32.0",
3
+ "version": "0.33.0-rc.125.246",
4
4
  "description": "Magically implement Algolia functionality in your codebase",
5
5
  "type": "module",
6
6
  "engines": {