feat(coding-agent): add experimental first-time setup flow (#5587)
Behind PI_EXPERIMENTAL=1, show a first-time setup dialog on interactive startup when the default agent directory is used and settings.json does not exist. The dialog preselects the detected terminal appearance with an explicit dark/light choice (live preview) and asks for opt-in analytics data sharing. Submitting writes settings.json, which serves as the completion marker; opting in stores a generated trackingId. Escape at any point skips out of setup.
This commit is contained in:
@@ -2,6 +2,10 @@
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Added an experimental first-time setup flow behind `PI_EXPERIMENTAL=1` that asks for a dark/light theme choice (preselecting the detected appearance) and opt-in analytics data sharing on first launch with the default agent directory; opting in stores a `trackingId` in `settings.json`.
|
||||||
|
|
||||||
## [0.79.1] - 2026-06-09
|
## [0.79.1] - 2026-06-09
|
||||||
|
|
||||||
### New Features
|
### New Features
|
||||||
|
|||||||
@@ -55,6 +55,8 @@ Use `/trust` in interactive mode to save a project trust decision for future ses
|
|||||||
| `defaultProjectTrust` | string | `"ask"` | Fallback project trust behavior: `"ask"`, `"always"`, or `"never"`. Global setting only |
|
| `defaultProjectTrust` | string | `"ask"` | Fallback project trust behavior: `"ask"`, `"always"`, or `"never"`. Global setting only |
|
||||||
| `collapseChangelog` | boolean | `false` | Show condensed changelog after updates |
|
| `collapseChangelog` | boolean | `false` | Show condensed changelog after updates |
|
||||||
| `enableInstallTelemetry` | boolean | `true` | Send an anonymous install/update version ping after first install or changelog-detected updates. This does not control update checks |
|
| `enableInstallTelemetry` | boolean | `true` | Send an anonymous install/update version ping after first install or changelog-detected updates. This does not control update checks |
|
||||||
|
| `enableAnalytics` | boolean | `false` | Opt-in analytics data sharing. Currently only asked for during the experimental first-time setup (`PI_EXPERIMENTAL=1`) |
|
||||||
|
| `trackingId` | string | - | Analytics tracking identifier, generated when `enableAnalytics` is turned on |
|
||||||
| `doubleEscapeAction` | string | `"tree"` | Action for double-escape: `"tree"`, `"fork"`, or `"none"` |
|
| `doubleEscapeAction` | string | `"tree"` | Action for double-escape: `"tree"`, `"fork"`, or `"none"` |
|
||||||
| `treeFilterMode` | string | `"default"` | Default filter for `/tree`: `"default"`, `"no-tools"`, `"user-only"`, `"labeled-only"`, `"all"` |
|
| `treeFilterMode` | string | `"default"` | Default filter for `/tree`: `"default"`, `"no-tools"`, `"user-only"`, `"labeled-only"`, `"all"` |
|
||||||
| `editorPaddingX` | number | `0` | Horizontal padding for input editor (0-3) |
|
| `editorPaddingX` | number | `0` | Horizontal padding for input editor (0-3) |
|
||||||
|
|||||||
@@ -1,9 +1,16 @@
|
|||||||
import { ProcessTerminal, setKeybindings, TUI } from "@earendil-works/pi-tui";
|
import { ProcessTerminal, setKeybindings, TUI } from "@earendil-works/pi-tui";
|
||||||
|
import { existsSync } from "fs";
|
||||||
|
import { ENV_AGENT_DIR, getSettingsPath } from "../config.ts";
|
||||||
|
import { areExperimentalFeaturesEnabled } from "../core/experimental.ts";
|
||||||
import { KeybindingsManager } from "../core/keybindings.ts";
|
import { KeybindingsManager } from "../core/keybindings.ts";
|
||||||
import type { SettingsManager } from "../core/settings-manager.ts";
|
import type { SettingsManager } from "../core/settings-manager.ts";
|
||||||
import { ExtensionInputComponent } from "../modes/interactive/components/extension-input.ts";
|
import { ExtensionInputComponent } from "../modes/interactive/components/extension-input.ts";
|
||||||
import { ExtensionSelectorComponent } from "../modes/interactive/components/extension-selector.ts";
|
import { ExtensionSelectorComponent } from "../modes/interactive/components/extension-selector.ts";
|
||||||
import { initTheme } from "../modes/interactive/theme/theme.ts";
|
import {
|
||||||
|
FirstTimeSetupComponent,
|
||||||
|
type FirstTimeSetupResult,
|
||||||
|
} from "../modes/interactive/components/first-time-setup.ts";
|
||||||
|
import { detectTerminalBackground, initTheme, setTheme } from "../modes/interactive/theme/theme.ts";
|
||||||
|
|
||||||
function createStartupTui(settingsManager: SettingsManager): TUI {
|
function createStartupTui(settingsManager: SettingsManager): TUI {
|
||||||
initTheme(settingsManager.getTheme());
|
initTheme(settingsManager.getTheme());
|
||||||
@@ -19,6 +26,22 @@ async function clearStartupTui(ui: TUI): Promise<void> {
|
|||||||
await new Promise((resolve) => setTimeout(resolve, 25));
|
await new Promise((resolve) => setTimeout(resolve, 25));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* First-time setup runs when all of these hold:
|
||||||
|
* - experimental features are enabled (PI_EXPERIMENTAL=1)
|
||||||
|
* - the default agent directory is used (no custom agent dir override)
|
||||||
|
* - setup was not completed before (settings.json does not exist)
|
||||||
|
*/
|
||||||
|
export function shouldRunFirstTimeSetup(settingsPath: string = getSettingsPath()): boolean {
|
||||||
|
if (!areExperimentalFeaturesEnabled()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (process.env[ENV_AGENT_DIR]) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return !existsSync(settingsPath);
|
||||||
|
}
|
||||||
|
|
||||||
export async function showStartupSelector<T>(
|
export async function showStartupSelector<T>(
|
||||||
settingsManager: SettingsManager,
|
settingsManager: SettingsManager,
|
||||||
title: string,
|
title: string,
|
||||||
@@ -51,6 +74,43 @@ export async function showStartupSelector<T>(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Show the first-time setup dialog and persist the result */
|
||||||
|
export async function showFirstTimeSetup(settingsManager: SettingsManager): Promise<void> {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const ui = createStartupTui(settingsManager);
|
||||||
|
|
||||||
|
let settled = false;
|
||||||
|
const finish = async (result: FirstTimeSetupResult | undefined) => {
|
||||||
|
if (settled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
settled = true;
|
||||||
|
if (result) {
|
||||||
|
settingsManager.setTheme(result.theme);
|
||||||
|
settingsManager.setEnableAnalytics(result.shareAnalytics);
|
||||||
|
await settingsManager.flush();
|
||||||
|
}
|
||||||
|
await clearStartupTui(ui);
|
||||||
|
ui.stop();
|
||||||
|
resolve();
|
||||||
|
};
|
||||||
|
|
||||||
|
const component = new FirstTimeSetupComponent({
|
||||||
|
detectedTheme: detectTerminalBackground().theme,
|
||||||
|
onThemePreview: (themeName) => {
|
||||||
|
setTheme(themeName);
|
||||||
|
ui.invalidate();
|
||||||
|
ui.requestRender();
|
||||||
|
},
|
||||||
|
onSubmit: (result) => void finish(result),
|
||||||
|
onCancel: () => void finish(undefined),
|
||||||
|
});
|
||||||
|
ui.addChild(component);
|
||||||
|
ui.setFocus(component);
|
||||||
|
ui.start();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export async function showStartupInput(
|
export async function showStartupInput(
|
||||||
settingsManager: SettingsManager,
|
settingsManager: SettingsManager,
|
||||||
title: string,
|
title: string,
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { Transport } from "@earendil-works/pi-ai";
|
import type { Transport } from "@earendil-works/pi-ai";
|
||||||
|
import { randomUUID } from "crypto";
|
||||||
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "fs";
|
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "fs";
|
||||||
import { dirname, join } from "path";
|
import { dirname, join } from "path";
|
||||||
import lockfile from "proper-lockfile";
|
import lockfile from "proper-lockfile";
|
||||||
@@ -96,6 +97,8 @@ export interface Settings {
|
|||||||
npmCommand?: string[]; // Command used for npm package lookup/install operations, argv-style (e.g., ["mise", "exec", "node@20", "--", "npm"])
|
npmCommand?: string[]; // Command used for npm package lookup/install operations, argv-style (e.g., ["mise", "exec", "node@20", "--", "npm"])
|
||||||
collapseChangelog?: boolean; // Show condensed changelog after update (use /changelog for full)
|
collapseChangelog?: boolean; // Show condensed changelog after update (use /changelog for full)
|
||||||
enableInstallTelemetry?: boolean; // default: true - anonymous version/update ping after changelog-detected updates
|
enableInstallTelemetry?: boolean; // default: true - anonymous version/update ping after changelog-detected updates
|
||||||
|
enableAnalytics?: boolean; // default: false - opt-in analytics data sharing
|
||||||
|
trackingId?: string; // analytics tracking identifier, generated when analytics is enabled
|
||||||
packages?: PackageSource[]; // Array of npm/git package sources (string or object with filtering)
|
packages?: PackageSource[]; // Array of npm/git package sources (string or object with filtering)
|
||||||
extensions?: string[]; // Array of local extension file paths or directories
|
extensions?: string[]; // Array of local extension file paths or directories
|
||||||
skills?: string[]; // Array of local skill file paths or directories
|
skills?: string[]; // Array of local skill file paths or directories
|
||||||
@@ -907,6 +910,25 @@ export class SettingsManager {
|
|||||||
this.save();
|
this.save();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
getEnableAnalytics(): boolean {
|
||||||
|
return this.settings.enableAnalytics ?? false;
|
||||||
|
}
|
||||||
|
|
||||||
|
getTrackingId(): string | undefined {
|
||||||
|
return this.settings.trackingId;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Set the analytics opt-in preference; generates a tracking identifier on first opt-in */
|
||||||
|
setEnableAnalytics(enabled: boolean): void {
|
||||||
|
this.globalSettings.enableAnalytics = enabled;
|
||||||
|
this.markModified("enableAnalytics");
|
||||||
|
if (enabled && !this.globalSettings.trackingId) {
|
||||||
|
this.globalSettings.trackingId = randomUUID();
|
||||||
|
this.markModified("trackingId");
|
||||||
|
}
|
||||||
|
this.save();
|
||||||
|
}
|
||||||
|
|
||||||
getPackages(): PackageSource[] {
|
getPackages(): PackageSource[] {
|
||||||
return [...(this.settings.packages ?? [])];
|
return [...(this.settings.packages ?? [])];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import { buildInitialMessage } from "./cli/initial-message.ts";
|
|||||||
import { listModels } from "./cli/list-models.ts";
|
import { listModels } from "./cli/list-models.ts";
|
||||||
import { createProjectTrustContext } from "./cli/project-trust.ts";
|
import { createProjectTrustContext } from "./cli/project-trust.ts";
|
||||||
import { selectSession } from "./cli/session-picker.ts";
|
import { selectSession } from "./cli/session-picker.ts";
|
||||||
import { showStartupSelector } from "./cli/startup-ui.ts";
|
import { shouldRunFirstTimeSetup, showFirstTimeSetup, showStartupSelector } from "./cli/startup-ui.ts";
|
||||||
import { ENV_SESSION_DIR, expandTildePath, getAgentDir, getPackageDir, VERSION } from "./config.ts";
|
import { ENV_SESSION_DIR, expandTildePath, getAgentDir, getPackageDir, VERSION } from "./config.ts";
|
||||||
import { type CreateAgentSessionRuntimeFactory, createAgentSessionRuntime } from "./core/agent-session-runtime.ts";
|
import { type CreateAgentSessionRuntimeFactory, createAgentSessionRuntime } from "./core/agent-session-runtime.ts";
|
||||||
import {
|
import {
|
||||||
@@ -528,6 +528,13 @@ export async function main(args: string[], options?: MainOptions) {
|
|||||||
const startupSettingsManager = SettingsManager.create(cwd, agentDir);
|
const startupSettingsManager = SettingsManager.create(cwd, agentDir);
|
||||||
reportDiagnostics(collectSettingsDiagnostics(startupSettingsManager, "startup session lookup"));
|
reportDiagnostics(collectSettingsDiagnostics(startupSettingsManager, "startup session lookup"));
|
||||||
|
|
||||||
|
// Experimental first-time setup: theme choice and analytics opt-in.
|
||||||
|
// Runs before any runtime services are created so the chosen settings apply everywhere.
|
||||||
|
if (appMode === "interactive" && !parsed.help && parsed.listModels === undefined && shouldRunFirstTimeSetup()) {
|
||||||
|
await showFirstTimeSetup(startupSettingsManager);
|
||||||
|
time("firstTimeSetup");
|
||||||
|
}
|
||||||
|
|
||||||
// Decide the final runtime cwd before creating cwd-bound runtime services.
|
// Decide the final runtime cwd before creating cwd-bound runtime services.
|
||||||
// --session and --resume may select a session from another project, so project-local
|
// --session and --resume may select a session from another project, so project-local
|
||||||
// settings, resources, provider registrations, and models must be resolved only after
|
// settings, resources, provider registrations, and models must be resolved only after
|
||||||
|
|||||||
@@ -0,0 +1,145 @@
|
|||||||
|
import { Container, getKeybindings, Spacer, Text } from "@earendil-works/pi-tui";
|
||||||
|
import { APP_NAME } from "../../../config.ts";
|
||||||
|
import { type TerminalTheme, theme } from "../theme/theme.ts";
|
||||||
|
import { DynamicBorder } from "./dynamic-border.ts";
|
||||||
|
import { keyHint, rawKeyHint } from "./keybinding-hints.ts";
|
||||||
|
|
||||||
|
export interface FirstTimeSetupResult {
|
||||||
|
theme: TerminalTheme;
|
||||||
|
shareAnalytics: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FirstTimeSetupOptions {
|
||||||
|
detectedTheme: TerminalTheme;
|
||||||
|
onThemePreview: (themeName: TerminalTheme) => void;
|
||||||
|
onSubmit: (result: FirstTimeSetupResult) => void;
|
||||||
|
onCancel: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const THEME_OPTIONS: Array<{ value: TerminalTheme; label: string }> = [
|
||||||
|
{ value: "dark", label: "Dark" },
|
||||||
|
{ value: "light", label: "Light" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const ANALYTICS_OPTIONS: Array<{ value: boolean; label: string }> = [
|
||||||
|
{ value: true, label: "Share anonymous usage data" },
|
||||||
|
{ value: false, label: "Don't share" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const SETUP_LOGO_LINES = ["██████", "██ ██", "████ ██", "██ ██"];
|
||||||
|
|
||||||
|
/** First-time setup dialog: theme choice and analytics opt-in. */
|
||||||
|
export class FirstTimeSetupComponent extends Container {
|
||||||
|
private step: "theme" | "analytics" = "theme";
|
||||||
|
private themeIndex: number;
|
||||||
|
private analyticsIndex = 0;
|
||||||
|
private readonly options: FirstTimeSetupOptions;
|
||||||
|
|
||||||
|
constructor(options: FirstTimeSetupOptions) {
|
||||||
|
super();
|
||||||
|
this.options = options;
|
||||||
|
this.themeIndex = Math.max(
|
||||||
|
0,
|
||||||
|
THEME_OPTIONS.findIndex((option) => option.value === options.detectedTheme),
|
||||||
|
);
|
||||||
|
this.update();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rebuild the whole dialog on every change so theme previews recolor all text.
|
||||||
|
private update(): void {
|
||||||
|
this.clear();
|
||||||
|
this.addChild(new DynamicBorder());
|
||||||
|
this.addChild(new Spacer(1));
|
||||||
|
this.addChild(new Text(theme.fg("accent", SETUP_LOGO_LINES.join("\n")), 1, 0));
|
||||||
|
this.addChild(new Spacer(1));
|
||||||
|
this.addChild(
|
||||||
|
new Text(theme.fg("accent", theme.bold(`Welcome to ${APP_NAME}, the minimal coding agent.`)), 1, 0),
|
||||||
|
);
|
||||||
|
this.addChild(new Spacer(1));
|
||||||
|
|
||||||
|
if (this.step === "theme") {
|
||||||
|
this.addChild(new Text(theme.fg("text", "Pick a theme."), 1, 0));
|
||||||
|
this.addChild(new Text(theme.fg("muted", `Detected system appearance: ${this.options.detectedTheme}`), 1, 0));
|
||||||
|
this.addChild(new Spacer(1));
|
||||||
|
this.addOptionList(
|
||||||
|
THEME_OPTIONS.map((option) => option.label),
|
||||||
|
this.themeIndex,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
this.addChild(new Text(theme.fg("text", `Help improve ${APP_NAME} by sharing anonymous usage data?`), 1, 0));
|
||||||
|
this.addChild(
|
||||||
|
new Text(
|
||||||
|
theme.fg(
|
||||||
|
"muted",
|
||||||
|
"Opting in stores a tracking identifier in settings.json and enables anonymous\nusage analytics. You can change this at any time in settings.json.",
|
||||||
|
),
|
||||||
|
1,
|
||||||
|
0,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
this.addChild(new Spacer(1));
|
||||||
|
this.addOptionList(
|
||||||
|
ANALYTICS_OPTIONS.map((option) => option.label),
|
||||||
|
this.analyticsIndex,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.addChild(new Spacer(1));
|
||||||
|
this.addChild(
|
||||||
|
new Text(
|
||||||
|
rawKeyHint("↑↓", "navigate") +
|
||||||
|
" " +
|
||||||
|
keyHint("tui.select.confirm", this.step === "theme" ? "continue" : "finish") +
|
||||||
|
" " +
|
||||||
|
keyHint("tui.select.cancel", "skip setup"),
|
||||||
|
1,
|
||||||
|
0,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
this.addChild(new Spacer(1));
|
||||||
|
this.addChild(new DynamicBorder());
|
||||||
|
}
|
||||||
|
|
||||||
|
private addOptionList(labels: string[], selectedIndex: number): void {
|
||||||
|
for (let i = 0; i < labels.length; i++) {
|
||||||
|
const isSelected = i === selectedIndex;
|
||||||
|
const prefix = isSelected ? theme.fg("accent", "→ ") : " ";
|
||||||
|
const label = isSelected ? theme.fg("accent", labels[i]) : theme.fg("text", labels[i]);
|
||||||
|
this.addChild(new Text(`${prefix}${label}`, 1, 0));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private moveSelection(delta: number): void {
|
||||||
|
if (this.step === "theme") {
|
||||||
|
const next = Math.max(0, Math.min(THEME_OPTIONS.length - 1, this.themeIndex + delta));
|
||||||
|
if (next !== this.themeIndex) {
|
||||||
|
this.themeIndex = next;
|
||||||
|
this.options.onThemePreview(THEME_OPTIONS[this.themeIndex].value);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
this.analyticsIndex = Math.max(0, Math.min(ANALYTICS_OPTIONS.length - 1, this.analyticsIndex + delta));
|
||||||
|
}
|
||||||
|
this.update();
|
||||||
|
}
|
||||||
|
|
||||||
|
handleInput(keyData: string): void {
|
||||||
|
const kb = getKeybindings();
|
||||||
|
if (kb.matches(keyData, "tui.select.up") || keyData === "k") {
|
||||||
|
this.moveSelection(-1);
|
||||||
|
} else if (kb.matches(keyData, "tui.select.down") || keyData === "j") {
|
||||||
|
this.moveSelection(1);
|
||||||
|
} else if (kb.matches(keyData, "tui.select.confirm") || keyData === "\n") {
|
||||||
|
if (this.step === "theme") {
|
||||||
|
this.step = "analytics";
|
||||||
|
this.update();
|
||||||
|
} else {
|
||||||
|
this.options.onSubmit({
|
||||||
|
theme: THEME_OPTIONS[this.themeIndex].value,
|
||||||
|
shareAnalytics: ANALYTICS_OPTIONS[this.analyticsIndex].value,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else if (kb.matches(keyData, "tui.select.cancel")) {
|
||||||
|
this.options.onCancel();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -13,6 +13,11 @@ export { DynamicBorder } from "./dynamic-border.ts";
|
|||||||
export { ExtensionEditorComponent } from "./extension-editor.ts";
|
export { ExtensionEditorComponent } from "./extension-editor.ts";
|
||||||
export { ExtensionInputComponent } from "./extension-input.ts";
|
export { ExtensionInputComponent } from "./extension-input.ts";
|
||||||
export { ExtensionSelectorComponent } from "./extension-selector.ts";
|
export { ExtensionSelectorComponent } from "./extension-selector.ts";
|
||||||
|
export {
|
||||||
|
FirstTimeSetupComponent,
|
||||||
|
type FirstTimeSetupOptions,
|
||||||
|
type FirstTimeSetupResult,
|
||||||
|
} from "./first-time-setup.ts";
|
||||||
export { FooterComponent } from "./footer.ts";
|
export { FooterComponent } from "./footer.ts";
|
||||||
export { keyHint, keyText, rawKeyHint } from "./keybinding-hints.ts";
|
export { keyHint, keyText, rawKeyHint } from "./keybinding-hints.ts";
|
||||||
export { LoginDialogComponent } from "./login-dialog.ts";
|
export { LoginDialogComponent } from "./login-dialog.ts";
|
||||||
|
|||||||
95
packages/coding-agent/test/first-time-setup.test.ts
Normal file
95
packages/coding-agent/test/first-time-setup.test.ts
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
import { mkdtempSync, rmSync, writeFileSync } from "fs";
|
||||||
|
import { tmpdir } from "os";
|
||||||
|
import { join } from "path";
|
||||||
|
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||||
|
import { shouldRunFirstTimeSetup } from "../src/cli/startup-ui.ts";
|
||||||
|
import { ENV_AGENT_DIR } from "../src/config.ts";
|
||||||
|
import { SettingsManager } from "../src/core/settings-manager.ts";
|
||||||
|
|
||||||
|
describe("shouldRunFirstTimeSetup", () => {
|
||||||
|
const originalPiExperimental = process.env.PI_EXPERIMENTAL;
|
||||||
|
const originalAgentDir = process.env[ENV_AGENT_DIR];
|
||||||
|
let tempDir: string;
|
||||||
|
let settingsPath: string;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
tempDir = mkdtempSync(join(tmpdir(), "pi-first-time-setup-"));
|
||||||
|
settingsPath = join(tempDir, "settings.json");
|
||||||
|
process.env.PI_EXPERIMENTAL = "1";
|
||||||
|
delete process.env[ENV_AGENT_DIR];
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
rmSync(tempDir, { recursive: true, force: true });
|
||||||
|
if (originalPiExperimental === undefined) {
|
||||||
|
delete process.env.PI_EXPERIMENTAL;
|
||||||
|
} else {
|
||||||
|
process.env.PI_EXPERIMENTAL = originalPiExperimental;
|
||||||
|
}
|
||||||
|
if (originalAgentDir === undefined) {
|
||||||
|
delete process.env[ENV_AGENT_DIR];
|
||||||
|
} else {
|
||||||
|
process.env[ENV_AGENT_DIR] = originalAgentDir;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns true when experimental, default agent dir, and no settings.json", () => {
|
||||||
|
expect(shouldRunFirstTimeSetup(settingsPath)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns false when experimental features are disabled", () => {
|
||||||
|
delete process.env.PI_EXPERIMENTAL;
|
||||||
|
|
||||||
|
expect(shouldRunFirstTimeSetup(settingsPath)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns false when a custom agent dir is set", () => {
|
||||||
|
process.env[ENV_AGENT_DIR] = tempDir;
|
||||||
|
|
||||||
|
expect(shouldRunFirstTimeSetup(settingsPath)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns false when settings.json already exists", () => {
|
||||||
|
writeFileSync(settingsPath, "{}", "utf-8");
|
||||||
|
|
||||||
|
expect(shouldRunFirstTimeSetup(settingsPath)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("analytics settings", () => {
|
||||||
|
it("defaults to disabled with no tracking identifier", () => {
|
||||||
|
const manager = SettingsManager.inMemory();
|
||||||
|
|
||||||
|
expect(manager.getEnableAnalytics()).toBe(false);
|
||||||
|
expect(manager.getTrackingId()).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("generates a tracking identifier on opt-in", () => {
|
||||||
|
const manager = SettingsManager.inMemory();
|
||||||
|
|
||||||
|
manager.setEnableAnalytics(true);
|
||||||
|
|
||||||
|
expect(manager.getEnableAnalytics()).toBe(true);
|
||||||
|
expect(manager.getTrackingId()).toMatch(/^[0-9a-f-]{36}$/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not generate a tracking identifier on opt-out", () => {
|
||||||
|
const manager = SettingsManager.inMemory();
|
||||||
|
|
||||||
|
manager.setEnableAnalytics(false);
|
||||||
|
|
||||||
|
expect(manager.getEnableAnalytics()).toBe(false);
|
||||||
|
expect(manager.getTrackingId()).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps the tracking identifier when toggling analytics", () => {
|
||||||
|
const manager = SettingsManager.inMemory();
|
||||||
|
|
||||||
|
manager.setEnableAnalytics(true);
|
||||||
|
const trackingId = manager.getTrackingId();
|
||||||
|
manager.setEnableAnalytics(false);
|
||||||
|
manager.setEnableAnalytics(true);
|
||||||
|
|
||||||
|
expect(manager.getTrackingId()).toBe(trackingId);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user