copilot-hub 0.1.29 → 0.1.30

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.
@@ -1,8 +1,8 @@
1
- # Runtime state files
2
- BOT_DATA_DIR=./data
3
- BOT_REGISTRY_FILE=./data/bot-registry.json
4
- SECRET_STORE_FILE=./data/secrets.json
5
-
1
+ # Runtime state files are managed automatically by copilot-hub.
2
+ # Override only if you intentionally want a custom location.
3
+ # BOT_DATA_DIR=
4
+ # BOT_REGISTRY_FILE=
5
+ # SECRET_STORE_FILE=
6
6
 
7
7
  # Worker defaults
8
8
  # If empty, runtime uses Desktop/copilot_workspaces by default
@@ -27,7 +27,7 @@ SHARED_THREAD_ID=shared-main
27
27
  TELEGRAM_ALLOWED_CHAT_IDS=
28
28
 
29
29
  INSTANCE_LOCK_ENABLED=true
30
- INSTANCE_LOCK_FILE=./data/runtime.lock
30
+ # INSTANCE_LOCK_FILE=
31
31
 
32
32
  WEB_HOST=127.0.0.1
33
33
  WEB_PORT=8787
@@ -37,5 +37,3 @@ WEB_PORT_SEARCH_MAX=30
37
37
 
38
38
  # Optional worker token environment variables used in bot-registry.json
39
39
  TELEGRAM_TOKEN_AGENT_1=
40
-
41
-
@@ -134,6 +134,13 @@ export const config = {
134
134
  function loadEnvironment() {
135
135
  return loadCopilotHubEnvironment({
136
136
  cwd: process.cwd(),
137
+ preserveExistingKeys: [
138
+ "BOT_DATA_DIR",
139
+ "BOT_REGISTRY_FILE",
140
+ "SECRET_STORE_FILE",
141
+ "INSTANCE_LOCK_FILE",
142
+ "COPILOT_HUB_HOME_DIR",
143
+ ],
137
144
  });
138
145
  }
139
146
  function resolveCodexBin(rawValue) {
@@ -4,7 +4,8 @@ HUB_ID=copilot_hub
4
4
  HUB_NAME=Copilot Hub
5
5
  # Optional. If empty, defaults to Desktop/copilot_workspaces (Windows/macOS/Linux)
6
6
  HUB_WORKSPACE_ROOT=
7
- HUB_DATA_DIR=./data/copilot_hub
7
+ # Runtime state paths are managed automatically by copilot-hub.
8
+ # HUB_DATA_DIR=
8
9
  HUB_THREAD_MODE=per_chat
9
10
  HUB_SHARED_THREAD_ID=shared-copilot-hub
10
11
  HUB_ALLOWED_CHAT_IDS=
@@ -141,6 +141,14 @@ export const config = {
141
141
  function loadEnvironment() {
142
142
  return loadCopilotHubEnvironment({
143
143
  cwd: process.cwd(),
144
+ preserveExistingKeys: [
145
+ "BOT_DATA_DIR",
146
+ "BOT_REGISTRY_FILE",
147
+ "SECRET_STORE_FILE",
148
+ "INSTANCE_LOCK_FILE",
149
+ "HUB_DATA_DIR",
150
+ "COPILOT_HUB_HOME_DIR",
151
+ ],
144
152
  });
145
153
  }
146
154
  function resolveCodexBin(rawValue) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "copilot-hub",
3
- "version": "0.1.29",
3
+ "version": "0.1.30",
4
4
  "description": "Copilot Hub CLI and runtime bundle",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -58,6 +58,7 @@
58
58
  "status": "npm run build:scripts --silent && node scripts/dist/cli.mjs status",
59
59
  "logs": "npm run build:scripts --silent && node scripts/dist/cli.mjs logs",
60
60
  "configure": "npm run build:scripts --silent && node scripts/dist/cli.mjs configure",
61
+ "reset-config": "npm run build:scripts --silent && node scripts/dist/cli.mjs reset-config",
61
62
  "test:scripts": "npm run build:scripts --silent && node --test scripts/test/*.test.mjs",
62
63
  "test": "npm run test:scripts && npm run test --workspaces --if-present",
63
64
  "lint": "eslint .",
@@ -4,7 +4,8 @@ export interface LoadedCopilotHubEnvironment {
4
4
  fileValues: Record<string, string>;
5
5
  overriddenKeys: string[];
6
6
  }
7
- export declare function loadCopilotHubEnvironment({ env, cwd, }?: {
7
+ export declare function loadCopilotHubEnvironment({ env, cwd, preserveExistingKeys, }?: {
8
8
  env?: NodeJS.ProcessEnv;
9
9
  cwd?: string;
10
+ preserveExistingKeys?: Iterable<string>;
10
11
  }): LoadedCopilotHubEnvironment;
@@ -3,7 +3,7 @@ import path from "node:path";
3
3
  import process from "node:process";
4
4
  import dotenv from "dotenv";
5
5
  import { resolveConfigBaseDir } from "./config-paths.js";
6
- export function loadCopilotHubEnvironment({ env = process.env, cwd = process.cwd(), } = {}) {
6
+ export function loadCopilotHubEnvironment({ env = process.env, cwd = process.cwd(), preserveExistingKeys = [], } = {}) {
7
7
  const configuredEnvPath = String(env.COPILOT_HUB_ENV_PATH ?? "").trim();
8
8
  const resolvedEnvPath = configuredEnvPath ? path.resolve(configuredEnvPath) : "";
9
9
  const discoveredEnvPath = resolvedEnvPath || resolveDefaultEnvPath(cwd);
@@ -13,7 +13,7 @@ export function loadCopilotHubEnvironment({ env = process.env, cwd = process.cwd
13
13
  cwd,
14
14
  });
15
15
  const fileValues = loadEnvFileValues(discoveredEnvPath);
16
- const overriddenKeys = applyEnvFileValues(env, fileValues);
16
+ const overriddenKeys = applyEnvFileValues(env, fileValues, preserveExistingKeys);
17
17
  if (resolvedEnvPath) {
18
18
  env.COPILOT_HUB_ENV_PATH = resolvedEnvPath;
19
19
  }
@@ -41,10 +41,14 @@ function loadEnvFileValues(filePath) {
41
41
  return {};
42
42
  }
43
43
  }
44
- function applyEnvFileValues(env, fileValues) {
44
+ function applyEnvFileValues(env, fileValues, preserveExistingKeys) {
45
45
  const overriddenKeys = [];
46
+ const preservedKeys = new Set([...preserveExistingKeys].map((key) => String(key ?? "").trim()).filter(Boolean));
46
47
  for (const [key, value] of Object.entries(fileValues)) {
47
48
  const previousValue = String(env[key] ?? "");
49
+ if (previousValue && preservedKeys.has(key)) {
50
+ continue;
51
+ }
48
52
  if (previousValue && previousValue !== value) {
49
53
  overriddenKeys.push(key);
50
54
  }
@@ -1 +1 @@
1
- {"version":3,"file":"env-config.js","sourceRoot":"","sources":["../src/env-config.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"env-config.js","sourceRoot":"","sources":["../src/env-config.ts"],"names":[],"mappings":"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"}
@@ -7,7 +7,7 @@ import { createInterface } from "node:readline/promises";
7
7
  import { fileURLToPath } from "node:url";
8
8
  import { spawnCodexSync } from "./codex-spawn.mjs";
9
9
  import { codexInstallPackageSpec } from "./codex-version.mjs";
10
- import { initializeCopilotHubLayout, resolveCopilotHubLayout } from "./install-layout.mjs";
10
+ import { initializeCopilotHubLayout, resetCopilotHubConfig, resolveCopilotHubLayout, } from "./install-layout.mjs";
11
11
  import { buildCodexCompatibilityError, buildCodexCompatibilityNotice, probeCodexVersion, resolveCodexBinForStart, resolveCompatibleInstalledCodexBin, } from "./codex-runtime.mjs";
12
12
  const __filename = fileURLToPath(import.meta.url);
13
13
  const __dirname = path.dirname(__filename);
@@ -27,6 +27,7 @@ const rawArgs = process.argv
27
27
  .filter(Boolean);
28
28
  const wantsVersion = rawArgs.includes("--version") || rawArgs.includes("-v");
29
29
  const wantsHelp = rawArgs.includes("--help") || rawArgs.includes("-h");
30
+ const wantsYes = rawArgs.includes("--yes") || rawArgs.includes("-y");
30
31
  const action = String(rawArgs[0] ?? "start")
31
32
  .trim()
32
33
  .toLowerCase();
@@ -92,6 +93,13 @@ async function main() {
92
93
  runNode(["scripts/dist/configure.mjs"]);
93
94
  return;
94
95
  }
96
+ case "reset-config":
97
+ case "reset_config": {
98
+ await resetConfig({
99
+ force: wantsYes,
100
+ });
101
+ return;
102
+ }
95
103
  case "service": {
96
104
  const serviceAction = String(rawArgs[1] ?? "")
97
105
  .trim()
@@ -111,6 +119,47 @@ async function main() {
111
119
  }
112
120
  }
113
121
  }
122
+ async function resetConfig({ force }) {
123
+ if (!force) {
124
+ if (!process.stdin.isTTY) {
125
+ throw new Error("reset-config requires confirmation. Re-run with '--yes' in non-interactive mode.");
126
+ }
127
+ const rl = createInterface({ input, output });
128
+ try {
129
+ const confirmed = await askYesNo(rl, [
130
+ "Reset Copilot Hub config and runtime state?",
131
+ "This removes persisted config, bot registry, secrets, logs, and runtime state.",
132
+ "Agent workspaces are kept.",
133
+ ].join(" "), false);
134
+ if (!confirmed) {
135
+ console.log("Reset canceled.");
136
+ return;
137
+ }
138
+ }
139
+ finally {
140
+ rl.close();
141
+ }
142
+ }
143
+ if (isServiceAlreadyInstalled()) {
144
+ runNodeCapture(["scripts/dist/service.mjs", "stop"], "inherit");
145
+ }
146
+ else {
147
+ runNodeCapture(["scripts/dist/supervisor.mjs", "down"], "inherit");
148
+ }
149
+ const reset = resetCopilotHubConfig({ layout });
150
+ initializeCopilotHubLayout({ repoRoot, layout });
151
+ console.log("Copilot Hub config reset completed.");
152
+ if (reset.removedPaths.length > 0) {
153
+ console.log("Removed:");
154
+ for (const removedPath of reset.removedPaths) {
155
+ console.log(`- ${removedPath}`);
156
+ }
157
+ }
158
+ console.log("Kept:");
159
+ console.log("- package installation");
160
+ console.log("- external workspaces (for example Desktop/copilot_workspaces)");
161
+ console.log("Next step: run 'copilot-hub configure' then 'copilot-hub start'.");
162
+ }
114
163
  function runNode(scriptArgs) {
115
164
  const result = runNodeCapture(scriptArgs, "inherit");
116
165
  const code = Number.isInteger(result.status) ? result.status : 1;
@@ -485,6 +534,8 @@ function spawnNpm(args, options) {
485
534
  function printUsage() {
486
535
  console.log([
487
536
  "Usage: node scripts/dist/cli.mjs <start|stop|restart|status|logs|configure|service|version|help>",
537
+ "Reset persistent state:",
538
+ " node scripts/dist/cli.mjs reset-config [--yes]",
488
539
  "Service management:",
489
540
  " node scripts/dist/cli.mjs service <install|uninstall|status|start|stop|help>",
490
541
  ].join("\n"));
@@ -4,6 +4,7 @@ import path from "node:path";
4
4
  import process, { stdin as input, stdout as output } from "node:process";
5
5
  import { fileURLToPath } from "node:url";
6
6
  import { createInterface } from "node:readline/promises";
7
+ import { parseEnvMap, readEnvLines, setEnvValue, writeEnvLines } from "./env-file-utils.mjs";
7
8
  import { initializeCopilotHubLayout, resolveCopilotHubLayout } from "./install-layout.mjs";
8
9
  const __filename = fileURLToPath(import.meta.url);
9
10
  const __dirname = path.dirname(__filename);
@@ -23,8 +24,8 @@ await main();
23
24
  async function main() {
24
25
  ensureEnvFile(engineEnvPath, engineExamplePath);
25
26
  ensureEnvFile(controlPlaneEnvPath, controlPlaneExamplePath);
26
- const engineLines = readLines(engineEnvPath);
27
- const controlPlaneLines = readLines(controlPlaneEnvPath);
27
+ const engineLines = readEnvLines(engineEnvPath);
28
+ const controlPlaneLines = readEnvLines(controlPlaneEnvPath);
28
29
  const rl = createInterface({ input, output });
29
30
  try {
30
31
  if (requiredOnly) {
@@ -41,8 +42,8 @@ async function main() {
41
42
  finally {
42
43
  rl.close();
43
44
  }
44
- writeLines(engineEnvPath, engineLines);
45
- writeLines(controlPlaneEnvPath, controlPlaneLines);
45
+ writeEnvLines(engineEnvPath, engineLines);
46
+ writeEnvLines(controlPlaneEnvPath, controlPlaneLines);
46
47
  }
47
48
  async function configureRequiredTokens({ rl, controlPlaneLines }) {
48
49
  const controlPlaneTokenEnvName = migrateControlPlaneTokenEnv(controlPlaneLines);
@@ -102,67 +103,10 @@ function ensureEnvFile(envPath, examplePath) {
102
103
  }
103
104
  fs.writeFileSync(envPath, "", "utf8");
104
105
  }
105
- function readLines(filePath) {
106
- const content = fs.readFileSync(filePath, "utf8");
107
- return content.split(/\r?\n/);
108
- }
109
- function writeLines(filePath, lines) {
110
- const normalized = [...lines];
111
- if (normalized.length === 0 || normalized[normalized.length - 1] !== "") {
112
- normalized.push("");
113
- }
114
- fs.writeFileSync(filePath, normalized.join("\n"), "utf8");
115
- }
116
- function parseEnvMap(lines) {
117
- const map = {};
118
- for (const line of lines) {
119
- const match = line.match(/^\s*([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*)\s*$/);
120
- if (!match) {
121
- continue;
122
- }
123
- const key = match[1];
124
- const value = unquote(match[2] ?? "");
125
- map[key] = value;
126
- }
127
- return map;
128
- }
129
- function setEnvValue(lines, key, value) {
130
- const safeValue = sanitizeValue(value);
131
- const pattern = new RegExp(`^\\s*${escapeRegex(key)}\\s*=`);
132
- for (let index = 0; index < lines.length; index += 1) {
133
- if (!pattern.test(lines[index])) {
134
- continue;
135
- }
136
- lines[index] = `${key}=${safeValue}`;
137
- return;
138
- }
139
- if (lines.length > 0 && lines[lines.length - 1] !== "") {
140
- lines.push("");
141
- }
142
- lines.push(`${key}=${safeValue}`);
143
- }
144
- function sanitizeValue(value) {
145
- return String(value ?? "")
146
- .replace(/[\r\n]/g, "")
147
- .trim();
148
- }
149
- function unquote(value) {
150
- const raw = String(value ?? "").trim();
151
- if (!raw) {
152
- return "";
153
- }
154
- if ((raw.startsWith('"') && raw.endsWith('"')) || (raw.startsWith("'") && raw.endsWith("'"))) {
155
- return raw.slice(1, -1);
156
- }
157
- return raw;
158
- }
159
106
  function nonEmpty(value, fallback) {
160
107
  const normalized = String(value ?? "").trim();
161
108
  return normalized || fallback;
162
109
  }
163
- function escapeRegex(value) {
164
- return String(value).replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
165
- }
166
110
  async function askRequired(rl, label) {
167
111
  while (true) {
168
112
  const value = await rl.question(`${label}: `);
@@ -0,0 +1,83 @@
1
+ import fs from "node:fs";
2
+ export function ensureEnvTextFile(filePath) {
3
+ if (fs.existsSync(filePath)) {
4
+ return;
5
+ }
6
+ fs.mkdirSync(requireParentDir(filePath), { recursive: true });
7
+ fs.writeFileSync(filePath, "", "utf8");
8
+ }
9
+ export function readEnvLines(filePath) {
10
+ const content = fs.readFileSync(filePath, "utf8");
11
+ return content.split(/\r?\n/);
12
+ }
13
+ export function writeEnvLines(filePath, lines) {
14
+ const normalized = [...lines];
15
+ if (normalized.length === 0 || normalized[normalized.length - 1] !== "") {
16
+ normalized.push("");
17
+ }
18
+ fs.writeFileSync(filePath, normalized.join("\n"), "utf8");
19
+ }
20
+ export function parseEnvMap(lines) {
21
+ const map = {};
22
+ for (const line of lines) {
23
+ const match = line.match(/^\s*([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*)\s*$/);
24
+ if (!match) {
25
+ continue;
26
+ }
27
+ const key = match[1];
28
+ const value = unquote(match[2] ?? "");
29
+ map[key] = value;
30
+ }
31
+ return map;
32
+ }
33
+ export function setEnvValue(lines, key, value) {
34
+ const safeValue = sanitizeEnvValue(value);
35
+ const pattern = new RegExp(`^\\s*${escapeRegex(key)}\\s*=`);
36
+ for (let index = 0; index < lines.length; index += 1) {
37
+ if (!pattern.test(lines[index])) {
38
+ continue;
39
+ }
40
+ lines[index] = `${key}=${safeValue}`;
41
+ return;
42
+ }
43
+ if (lines.length > 0 && lines[lines.length - 1] !== "") {
44
+ lines.push("");
45
+ }
46
+ lines.push(`${key}=${safeValue}`);
47
+ }
48
+ export function removeEnvKeys(lines, keys) {
49
+ const patterns = keys.map((key) => new RegExp(`^\\s*${escapeRegex(key)}\\s*=`));
50
+ const originalLength = lines.length;
51
+ const kept = lines.filter((line) => !patterns.some((pattern) => pattern.test(line)));
52
+ if (kept.length === originalLength) {
53
+ return false;
54
+ }
55
+ lines.splice(0, lines.length, ...kept);
56
+ return true;
57
+ }
58
+ export function sanitizeEnvValue(value) {
59
+ return String(value ?? "")
60
+ .replace(/[\r\n]/g, "")
61
+ .trim();
62
+ }
63
+ function unquote(value) {
64
+ const raw = String(value ?? "").trim();
65
+ if (!raw) {
66
+ return "";
67
+ }
68
+ if ((raw.startsWith('"') && raw.endsWith('"')) || (raw.startsWith("'") && raw.endsWith("'"))) {
69
+ return raw.slice(1, -1);
70
+ }
71
+ return raw;
72
+ }
73
+ function escapeRegex(value) {
74
+ return String(value).replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
75
+ }
76
+ function requireParentDir(filePath) {
77
+ const parts = String(filePath ?? "").split(/[\\/]/);
78
+ if (parts.length <= 1) {
79
+ return ".";
80
+ }
81
+ parts.pop();
82
+ return parts.join("/") || ".";
83
+ }
@@ -2,6 +2,8 @@
2
2
  import fs from "node:fs";
3
3
  import os from "node:os";
4
4
  import path from "node:path";
5
+ import process from "node:process";
6
+ import { parseEnvMap, readEnvLines, removeEnvKeys, writeEnvLines } from "./env-file-utils.mjs";
5
7
  export function resolveCopilotHubLayout({ repoRoot, env = process.env, platform = process.platform, homeDirectory = os.homedir(), }) {
6
8
  const pathApi = getPathApi(platform);
7
9
  const homeDir = resolveCopilotHubHomeDir({
@@ -30,7 +32,35 @@ export function resolveCopilotHubLayout({ repoRoot, env = process.env, platform
30
32
  export function initializeCopilotHubLayout({ repoRoot, layout, }) {
31
33
  ensureCopilotHubLayout(layout);
32
34
  const migratedPaths = migrateLegacyLayout({ repoRoot, layout });
33
- return { migratedPaths };
35
+ const normalizedEnvPaths = normalizePersistentEnvFiles(layout);
36
+ return { migratedPaths, normalizedEnvPaths };
37
+ }
38
+ export function resetCopilotHubConfig({ layout }) {
39
+ const removedPaths = [];
40
+ for (const target of [layout.configDir, layout.dataDir, layout.logsDir]) {
41
+ if (!fs.existsSync(target)) {
42
+ continue;
43
+ }
44
+ fs.rmSync(target, { recursive: true, force: true });
45
+ removedPaths.push(target);
46
+ }
47
+ const runtimeTargets = [
48
+ path.join(layout.runtimeDir, "pids"),
49
+ path.join(layout.runtimeDir, "services"),
50
+ path.join(layout.runtimeDir, "last-startup-error.json"),
51
+ layout.servicePromptStatePath,
52
+ ];
53
+ for (const target of runtimeTargets) {
54
+ if (!fs.existsSync(target)) {
55
+ continue;
56
+ }
57
+ fs.rmSync(target, { recursive: true, force: true });
58
+ removedPaths.push(target);
59
+ }
60
+ ensureCopilotHubLayout(layout);
61
+ return {
62
+ removedPaths: removedPaths.sort(),
63
+ };
34
64
  }
35
65
  export function ensureCopilotHubLayout(layout) {
36
66
  fs.mkdirSync(layout.homeDir, { recursive: true });
@@ -81,6 +111,98 @@ function migrateLegacyLayout({ repoRoot, layout, }) {
81
111
  }
82
112
  return migratedPaths;
83
113
  }
114
+ function normalizePersistentEnvFiles(layout) {
115
+ const normalizedPaths = [];
116
+ if (normalizePersistentEnvFile(layout.agentEngineEnvPath, [
117
+ {
118
+ key: "BOT_DATA_DIR",
119
+ legacyValues: ["./data"],
120
+ wrongResolvedPath: path.join(layout.configDir, "data"),
121
+ },
122
+ {
123
+ key: "BOT_REGISTRY_FILE",
124
+ legacyValues: ["./data/bot-registry.json"],
125
+ wrongResolvedPath: path.join(layout.configDir, "data", "bot-registry.json"),
126
+ },
127
+ {
128
+ key: "SECRET_STORE_FILE",
129
+ legacyValues: ["./data/secrets.json"],
130
+ wrongResolvedPath: path.join(layout.configDir, "data", "secrets.json"),
131
+ },
132
+ {
133
+ key: "INSTANCE_LOCK_FILE",
134
+ legacyValues: ["./data/runtime.lock"],
135
+ wrongResolvedPath: path.join(layout.configDir, "data", "runtime.lock"),
136
+ },
137
+ ])) {
138
+ normalizedPaths.push(layout.agentEngineEnvPath);
139
+ }
140
+ if (normalizePersistentEnvFile(layout.controlPlaneEnvPath, [
141
+ {
142
+ key: "BOT_DATA_DIR",
143
+ legacyValues: ["./data"],
144
+ wrongResolvedPath: path.join(layout.configDir, "data"),
145
+ },
146
+ {
147
+ key: "BOT_REGISTRY_FILE",
148
+ legacyValues: ["./data/bot-registry.json"],
149
+ wrongResolvedPath: path.join(layout.configDir, "data", "bot-registry.json"),
150
+ },
151
+ {
152
+ key: "SECRET_STORE_FILE",
153
+ legacyValues: ["./data/secrets.json"],
154
+ wrongResolvedPath: path.join(layout.configDir, "data", "secrets.json"),
155
+ },
156
+ {
157
+ key: "INSTANCE_LOCK_FILE",
158
+ legacyValues: ["./data/runtime.lock"],
159
+ wrongResolvedPath: path.join(layout.configDir, "data", "runtime.lock"),
160
+ },
161
+ {
162
+ key: "HUB_DATA_DIR",
163
+ legacyValues: ["./data/copilot_hub"],
164
+ wrongResolvedPath: path.join(layout.configDir, "data", "copilot_hub"),
165
+ },
166
+ ])) {
167
+ normalizedPaths.push(layout.controlPlaneEnvPath);
168
+ }
169
+ return normalizedPaths.sort();
170
+ }
171
+ function normalizePersistentEnvFile(filePath, rules) {
172
+ if (!fs.existsSync(filePath)) {
173
+ return false;
174
+ }
175
+ const lines = readEnvLines(filePath);
176
+ const envMap = parseEnvMap(lines);
177
+ const keysToRemove = rules
178
+ .filter((rule) => shouldRemoveLegacyManagedPath(envMap[rule.key], {
179
+ legacyValues: rule.legacyValues,
180
+ wrongResolvedPath: rule.wrongResolvedPath,
181
+ configBaseDir: path.dirname(filePath),
182
+ }))
183
+ .map((rule) => rule.key);
184
+ if (keysToRemove.length === 0) {
185
+ return false;
186
+ }
187
+ removeEnvKeys(lines, keysToRemove);
188
+ writeEnvLines(filePath, lines);
189
+ return true;
190
+ }
191
+ function shouldRemoveLegacyManagedPath(rawValue, { legacyValues, wrongResolvedPath, configBaseDir, }) {
192
+ const value = String(rawValue ?? "").trim();
193
+ if (!value) {
194
+ return false;
195
+ }
196
+ const normalizedValue = normalizeForCompare(value);
197
+ if (legacyValues.some((entry) => normalizeForCompare(entry) === normalizedValue)) {
198
+ return true;
199
+ }
200
+ if (path.isAbsolute(value)) {
201
+ return normalizeForCompare(value) === normalizeForCompare(wrongResolvedPath);
202
+ }
203
+ return (normalizeForCompare(path.resolve(configBaseDir, value)) ===
204
+ normalizeForCompare(wrongResolvedPath));
205
+ }
84
206
  function resolveLegacyPaths(repoRoot) {
85
207
  return {
86
208
  agentEngineEnvPath: path.join(repoRoot, "apps", "agent-engine", ".env"),
@@ -129,6 +251,13 @@ function normalizePath(value, pathApi) {
129
251
  const normalized = String(value ?? "").trim();
130
252
  return normalized ? pathApi.resolve(normalized) : "";
131
253
  }
254
+ function normalizeForCompare(value) {
255
+ const normalized = String(value ?? "").trim();
256
+ if (!normalized) {
257
+ return "";
258
+ }
259
+ return process.platform === "win32" ? normalized.toLowerCase() : normalized;
260
+ }
132
261
  function getPathApi(platform) {
133
262
  return platform === "win32" ? path.win32 : path.posix;
134
263
  }
@@ -307,6 +307,11 @@ function buildServiceEnvironment(service) {
307
307
  BOT_REGISTRY_FILE: service.botRegistryFilePath,
308
308
  SECRET_STORE_FILE: service.secretStoreFilePath,
309
309
  INSTANCE_LOCK_FILE: service.instanceLockFilePath,
310
+ ...(service.id === "control-plane"
311
+ ? {
312
+ HUB_DATA_DIR: path.join(service.dataDir, "copilot_hub"),
313
+ }
314
+ : {}),
310
315
  };
311
316
  }
312
317
  function printTail(filePath, lines) {
@@ -7,7 +7,11 @@ import { createInterface } from "node:readline/promises";
7
7
  import { fileURLToPath } from "node:url";
8
8
  import { spawnCodexSync } from "./codex-spawn.mjs";
9
9
  import { codexInstallPackageSpec } from "./codex-version.mjs";
10
- import { initializeCopilotHubLayout, resolveCopilotHubLayout } from "./install-layout.mjs";
10
+ import {
11
+ initializeCopilotHubLayout,
12
+ resetCopilotHubConfig,
13
+ resolveCopilotHubLayout,
14
+ } from "./install-layout.mjs";
11
15
  import {
12
16
  buildCodexCompatibilityError,
13
17
  buildCodexCompatibilityNotice,
@@ -35,6 +39,7 @@ const rawArgs = process.argv
35
39
  .filter(Boolean);
36
40
  const wantsVersion = rawArgs.includes("--version") || rawArgs.includes("-v");
37
41
  const wantsHelp = rawArgs.includes("--help") || rawArgs.includes("-h");
42
+ const wantsYes = rawArgs.includes("--yes") || rawArgs.includes("-y");
38
43
 
39
44
  const action = String(rawArgs[0] ?? "start")
40
45
  .trim()
@@ -106,6 +111,13 @@ async function main() {
106
111
  runNode(["scripts/dist/configure.mjs"]);
107
112
  return;
108
113
  }
114
+ case "reset-config":
115
+ case "reset_config": {
116
+ await resetConfig({
117
+ force: wantsYes,
118
+ });
119
+ return;
120
+ }
109
121
  case "service": {
110
122
  const serviceAction = String(rawArgs[1] ?? "")
111
123
  .trim()
@@ -126,6 +138,56 @@ async function main() {
126
138
  }
127
139
  }
128
140
 
141
+ async function resetConfig({ force }: { force: boolean }): Promise<void> {
142
+ if (!force) {
143
+ if (!process.stdin.isTTY) {
144
+ throw new Error(
145
+ "reset-config requires confirmation. Re-run with '--yes' in non-interactive mode.",
146
+ );
147
+ }
148
+
149
+ const rl = createInterface({ input, output });
150
+ try {
151
+ const confirmed = await askYesNo(
152
+ rl,
153
+ [
154
+ "Reset Copilot Hub config and runtime state?",
155
+ "This removes persisted config, bot registry, secrets, logs, and runtime state.",
156
+ "Agent workspaces are kept.",
157
+ ].join(" "),
158
+ false,
159
+ );
160
+ if (!confirmed) {
161
+ console.log("Reset canceled.");
162
+ return;
163
+ }
164
+ } finally {
165
+ rl.close();
166
+ }
167
+ }
168
+
169
+ if (isServiceAlreadyInstalled()) {
170
+ runNodeCapture(["scripts/dist/service.mjs", "stop"], "inherit");
171
+ } else {
172
+ runNodeCapture(["scripts/dist/supervisor.mjs", "down"], "inherit");
173
+ }
174
+
175
+ const reset = resetCopilotHubConfig({ layout });
176
+ initializeCopilotHubLayout({ repoRoot, layout });
177
+
178
+ console.log("Copilot Hub config reset completed.");
179
+ if (reset.removedPaths.length > 0) {
180
+ console.log("Removed:");
181
+ for (const removedPath of reset.removedPaths) {
182
+ console.log(`- ${removedPath}`);
183
+ }
184
+ }
185
+ console.log("Kept:");
186
+ console.log("- package installation");
187
+ console.log("- external workspaces (for example Desktop/copilot_workspaces)");
188
+ console.log("Next step: run 'copilot-hub configure' then 'copilot-hub start'.");
189
+ }
190
+
129
191
  function runNode(scriptArgs) {
130
192
  const result = runNodeCapture(scriptArgs, "inherit");
131
193
  const code = Number.isInteger(result.status) ? result.status : 1;
@@ -577,6 +639,8 @@ function printUsage() {
577
639
  console.log(
578
640
  [
579
641
  "Usage: node scripts/dist/cli.mjs <start|stop|restart|status|logs|configure|service|version|help>",
642
+ "Reset persistent state:",
643
+ " node scripts/dist/cli.mjs reset-config [--yes]",
580
644
  "Service management:",
581
645
  " node scripts/dist/cli.mjs service <install|uninstall|status|start|stop|help>",
582
646
  ].join("\n"),
@@ -4,6 +4,7 @@ import path from "node:path";
4
4
  import process, { stdin as input, stdout as output } from "node:process";
5
5
  import { fileURLToPath } from "node:url";
6
6
  import { createInterface } from "node:readline/promises";
7
+ import { parseEnvMap, readEnvLines, setEnvValue, writeEnvLines } from "./env-file-utils.mjs";
7
8
  import { initializeCopilotHubLayout, resolveCopilotHubLayout } from "./install-layout.mjs";
8
9
 
9
10
  const __filename = fileURLToPath(import.meta.url);
@@ -30,8 +31,8 @@ async function main() {
30
31
  ensureEnvFile(engineEnvPath, engineExamplePath);
31
32
  ensureEnvFile(controlPlaneEnvPath, controlPlaneExamplePath);
32
33
 
33
- const engineLines = readLines(engineEnvPath);
34
- const controlPlaneLines = readLines(controlPlaneEnvPath);
34
+ const engineLines = readEnvLines(engineEnvPath);
35
+ const controlPlaneLines = readEnvLines(controlPlaneEnvPath);
35
36
 
36
37
  const rl = createInterface({ input, output });
37
38
 
@@ -49,8 +50,8 @@ async function main() {
49
50
  rl.close();
50
51
  }
51
52
 
52
- writeLines(engineEnvPath, engineLines);
53
- writeLines(controlPlaneEnvPath, controlPlaneLines);
53
+ writeEnvLines(engineEnvPath, engineLines);
54
+ writeEnvLines(controlPlaneEnvPath, controlPlaneLines);
54
55
  }
55
56
 
56
57
  async function configureRequiredTokens({ rl, controlPlaneLines }) {
@@ -132,80 +133,11 @@ function ensureEnvFile(envPath, examplePath) {
132
133
  fs.writeFileSync(envPath, "", "utf8");
133
134
  }
134
135
 
135
- function readLines(filePath) {
136
- const content = fs.readFileSync(filePath, "utf8");
137
- return content.split(/\r?\n/);
138
- }
139
-
140
- function writeLines(filePath, lines) {
141
- const normalized = [...lines];
142
- if (normalized.length === 0 || normalized[normalized.length - 1] !== "") {
143
- normalized.push("");
144
- }
145
- fs.writeFileSync(filePath, normalized.join("\n"), "utf8");
146
- }
147
-
148
- function parseEnvMap(lines) {
149
- const map: Record<string, string> = {};
150
- for (const line of lines) {
151
- const match = line.match(/^\s*([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*)\s*$/);
152
- if (!match) {
153
- continue;
154
- }
155
-
156
- const key = match[1];
157
- const value = unquote(match[2] ?? "");
158
- map[key] = value;
159
- }
160
- return map;
161
- }
162
-
163
- function setEnvValue(lines, key, value) {
164
- const safeValue = sanitizeValue(value);
165
- const pattern = new RegExp(`^\\s*${escapeRegex(key)}\\s*=`);
166
- for (let index = 0; index < lines.length; index += 1) {
167
- if (!pattern.test(lines[index])) {
168
- continue;
169
- }
170
-
171
- lines[index] = `${key}=${safeValue}`;
172
- return;
173
- }
174
-
175
- if (lines.length > 0 && lines[lines.length - 1] !== "") {
176
- lines.push("");
177
- }
178
- lines.push(`${key}=${safeValue}`);
179
- }
180
-
181
- function sanitizeValue(value) {
182
- return String(value ?? "")
183
- .replace(/[\r\n]/g, "")
184
- .trim();
185
- }
186
-
187
- function unquote(value) {
188
- const raw = String(value ?? "").trim();
189
- if (!raw) {
190
- return "";
191
- }
192
-
193
- if ((raw.startsWith('"') && raw.endsWith('"')) || (raw.startsWith("'") && raw.endsWith("'"))) {
194
- return raw.slice(1, -1);
195
- }
196
-
197
- return raw;
198
- }
199
-
200
136
  function nonEmpty(value, fallback) {
201
137
  const normalized = String(value ?? "").trim();
202
138
  return normalized || fallback;
203
139
  }
204
140
 
205
- function escapeRegex(value) {
206
- return String(value).replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
207
- }
208
-
209
141
  async function askRequired(rl, label) {
210
142
  while (true) {
211
143
  const value = await rl.question(`${label}: `);
@@ -0,0 +1,98 @@
1
+ import fs from "node:fs";
2
+
3
+ export function ensureEnvTextFile(filePath: string): void {
4
+ if (fs.existsSync(filePath)) {
5
+ return;
6
+ }
7
+ fs.mkdirSync(requireParentDir(filePath), { recursive: true });
8
+ fs.writeFileSync(filePath, "", "utf8");
9
+ }
10
+
11
+ export function readEnvLines(filePath: string): string[] {
12
+ const content = fs.readFileSync(filePath, "utf8");
13
+ return content.split(/\r?\n/);
14
+ }
15
+
16
+ export function writeEnvLines(filePath: string, lines: string[]): void {
17
+ const normalized = [...lines];
18
+ if (normalized.length === 0 || normalized[normalized.length - 1] !== "") {
19
+ normalized.push("");
20
+ }
21
+ fs.writeFileSync(filePath, normalized.join("\n"), "utf8");
22
+ }
23
+
24
+ export function parseEnvMap(lines: string[]): Record<string, string> {
25
+ const map: Record<string, string> = {};
26
+ for (const line of lines) {
27
+ const match = line.match(/^\s*([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*)\s*$/);
28
+ if (!match) {
29
+ continue;
30
+ }
31
+
32
+ const key = match[1];
33
+ const value = unquote(match[2] ?? "");
34
+ map[key] = value;
35
+ }
36
+ return map;
37
+ }
38
+
39
+ export function setEnvValue(lines: string[], key: string, value: unknown): void {
40
+ const safeValue = sanitizeEnvValue(value);
41
+ const pattern = new RegExp(`^\\s*${escapeRegex(key)}\\s*=`);
42
+ for (let index = 0; index < lines.length; index += 1) {
43
+ if (!pattern.test(lines[index])) {
44
+ continue;
45
+ }
46
+
47
+ lines[index] = `${key}=${safeValue}`;
48
+ return;
49
+ }
50
+
51
+ if (lines.length > 0 && lines[lines.length - 1] !== "") {
52
+ lines.push("");
53
+ }
54
+ lines.push(`${key}=${safeValue}`);
55
+ }
56
+
57
+ export function removeEnvKeys(lines: string[], keys: readonly string[]): boolean {
58
+ const patterns = keys.map((key) => new RegExp(`^\\s*${escapeRegex(key)}\\s*=`));
59
+ const originalLength = lines.length;
60
+ const kept = lines.filter((line) => !patterns.some((pattern) => pattern.test(line)));
61
+ if (kept.length === originalLength) {
62
+ return false;
63
+ }
64
+ lines.splice(0, lines.length, ...kept);
65
+ return true;
66
+ }
67
+
68
+ export function sanitizeEnvValue(value: unknown): string {
69
+ return String(value ?? "")
70
+ .replace(/[\r\n]/g, "")
71
+ .trim();
72
+ }
73
+
74
+ function unquote(value: string): string {
75
+ const raw = String(value ?? "").trim();
76
+ if (!raw) {
77
+ return "";
78
+ }
79
+
80
+ if ((raw.startsWith('"') && raw.endsWith('"')) || (raw.startsWith("'") && raw.endsWith("'"))) {
81
+ return raw.slice(1, -1);
82
+ }
83
+
84
+ return raw;
85
+ }
86
+
87
+ function escapeRegex(value: string): string {
88
+ return String(value).replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
89
+ }
90
+
91
+ function requireParentDir(filePath: string): string {
92
+ const parts = String(filePath ?? "").split(/[\\/]/);
93
+ if (parts.length <= 1) {
94
+ return ".";
95
+ }
96
+ parts.pop();
97
+ return parts.join("/") || ".";
98
+ }
@@ -2,6 +2,8 @@
2
2
  import fs from "node:fs";
3
3
  import os from "node:os";
4
4
  import path from "node:path";
5
+ import process from "node:process";
6
+ import { parseEnvMap, readEnvLines, removeEnvKeys, writeEnvLines } from "./env-file-utils.mjs";
5
7
 
6
8
  export type CopilotHubLayout = {
7
9
  homeDir: string;
@@ -60,10 +62,44 @@ export function initializeCopilotHubLayout({
60
62
  }: {
61
63
  repoRoot: string;
62
64
  layout: CopilotHubLayout;
63
- }): { migratedPaths: string[] } {
65
+ }): { migratedPaths: string[]; normalizedEnvPaths: string[] } {
64
66
  ensureCopilotHubLayout(layout);
65
67
  const migratedPaths = migrateLegacyLayout({ repoRoot, layout });
66
- return { migratedPaths };
68
+ const normalizedEnvPaths = normalizePersistentEnvFiles(layout);
69
+ return { migratedPaths, normalizedEnvPaths };
70
+ }
71
+
72
+ export function resetCopilotHubConfig({ layout }: { layout: CopilotHubLayout }): {
73
+ removedPaths: string[];
74
+ } {
75
+ const removedPaths: string[] = [];
76
+
77
+ for (const target of [layout.configDir, layout.dataDir, layout.logsDir]) {
78
+ if (!fs.existsSync(target)) {
79
+ continue;
80
+ }
81
+ fs.rmSync(target, { recursive: true, force: true });
82
+ removedPaths.push(target);
83
+ }
84
+
85
+ const runtimeTargets = [
86
+ path.join(layout.runtimeDir, "pids"),
87
+ path.join(layout.runtimeDir, "services"),
88
+ path.join(layout.runtimeDir, "last-startup-error.json"),
89
+ layout.servicePromptStatePath,
90
+ ];
91
+ for (const target of runtimeTargets) {
92
+ if (!fs.existsSync(target)) {
93
+ continue;
94
+ }
95
+ fs.rmSync(target, { recursive: true, force: true });
96
+ removedPaths.push(target);
97
+ }
98
+
99
+ ensureCopilotHubLayout(layout);
100
+ return {
101
+ removedPaths: removedPaths.sort(),
102
+ };
67
103
  }
68
104
 
69
105
  export function ensureCopilotHubLayout(layout: CopilotHubLayout): void {
@@ -137,6 +173,132 @@ function migrateLegacyLayout({
137
173
  return migratedPaths;
138
174
  }
139
175
 
176
+ function normalizePersistentEnvFiles(layout: CopilotHubLayout): string[] {
177
+ const normalizedPaths: string[] = [];
178
+
179
+ if (
180
+ normalizePersistentEnvFile(layout.agentEngineEnvPath, [
181
+ {
182
+ key: "BOT_DATA_DIR",
183
+ legacyValues: ["./data"],
184
+ wrongResolvedPath: path.join(layout.configDir, "data"),
185
+ },
186
+ {
187
+ key: "BOT_REGISTRY_FILE",
188
+ legacyValues: ["./data/bot-registry.json"],
189
+ wrongResolvedPath: path.join(layout.configDir, "data", "bot-registry.json"),
190
+ },
191
+ {
192
+ key: "SECRET_STORE_FILE",
193
+ legacyValues: ["./data/secrets.json"],
194
+ wrongResolvedPath: path.join(layout.configDir, "data", "secrets.json"),
195
+ },
196
+ {
197
+ key: "INSTANCE_LOCK_FILE",
198
+ legacyValues: ["./data/runtime.lock"],
199
+ wrongResolvedPath: path.join(layout.configDir, "data", "runtime.lock"),
200
+ },
201
+ ])
202
+ ) {
203
+ normalizedPaths.push(layout.agentEngineEnvPath);
204
+ }
205
+
206
+ if (
207
+ normalizePersistentEnvFile(layout.controlPlaneEnvPath, [
208
+ {
209
+ key: "BOT_DATA_DIR",
210
+ legacyValues: ["./data"],
211
+ wrongResolvedPath: path.join(layout.configDir, "data"),
212
+ },
213
+ {
214
+ key: "BOT_REGISTRY_FILE",
215
+ legacyValues: ["./data/bot-registry.json"],
216
+ wrongResolvedPath: path.join(layout.configDir, "data", "bot-registry.json"),
217
+ },
218
+ {
219
+ key: "SECRET_STORE_FILE",
220
+ legacyValues: ["./data/secrets.json"],
221
+ wrongResolvedPath: path.join(layout.configDir, "data", "secrets.json"),
222
+ },
223
+ {
224
+ key: "INSTANCE_LOCK_FILE",
225
+ legacyValues: ["./data/runtime.lock"],
226
+ wrongResolvedPath: path.join(layout.configDir, "data", "runtime.lock"),
227
+ },
228
+ {
229
+ key: "HUB_DATA_DIR",
230
+ legacyValues: ["./data/copilot_hub"],
231
+ wrongResolvedPath: path.join(layout.configDir, "data", "copilot_hub"),
232
+ },
233
+ ])
234
+ ) {
235
+ normalizedPaths.push(layout.controlPlaneEnvPath);
236
+ }
237
+
238
+ return normalizedPaths.sort();
239
+ }
240
+
241
+ function normalizePersistentEnvFile(
242
+ filePath: string,
243
+ rules: Array<{ key: string; legacyValues: string[]; wrongResolvedPath: string }>,
244
+ ): boolean {
245
+ if (!fs.existsSync(filePath)) {
246
+ return false;
247
+ }
248
+
249
+ const lines = readEnvLines(filePath);
250
+ const envMap = parseEnvMap(lines);
251
+ const keysToRemove = rules
252
+ .filter((rule) =>
253
+ shouldRemoveLegacyManagedPath(envMap[rule.key], {
254
+ legacyValues: rule.legacyValues,
255
+ wrongResolvedPath: rule.wrongResolvedPath,
256
+ configBaseDir: path.dirname(filePath),
257
+ }),
258
+ )
259
+ .map((rule) => rule.key);
260
+
261
+ if (keysToRemove.length === 0) {
262
+ return false;
263
+ }
264
+
265
+ removeEnvKeys(lines, keysToRemove);
266
+ writeEnvLines(filePath, lines);
267
+ return true;
268
+ }
269
+
270
+ function shouldRemoveLegacyManagedPath(
271
+ rawValue: string | undefined,
272
+ {
273
+ legacyValues,
274
+ wrongResolvedPath,
275
+ configBaseDir,
276
+ }: {
277
+ legacyValues: string[];
278
+ wrongResolvedPath: string;
279
+ configBaseDir: string;
280
+ },
281
+ ): boolean {
282
+ const value = String(rawValue ?? "").trim();
283
+ if (!value) {
284
+ return false;
285
+ }
286
+
287
+ const normalizedValue = normalizeForCompare(value);
288
+ if (legacyValues.some((entry) => normalizeForCompare(entry) === normalizedValue)) {
289
+ return true;
290
+ }
291
+
292
+ if (path.isAbsolute(value)) {
293
+ return normalizeForCompare(value) === normalizeForCompare(wrongResolvedPath);
294
+ }
295
+
296
+ return (
297
+ normalizeForCompare(path.resolve(configBaseDir, value)) ===
298
+ normalizeForCompare(wrongResolvedPath)
299
+ );
300
+ }
301
+
140
302
  function resolveLegacyPaths(repoRoot: string): {
141
303
  agentEngineEnvPath: string;
142
304
  controlPlaneEnvPath: string;
@@ -195,6 +357,14 @@ function normalizePath(value: unknown, pathApi: typeof path.posix | typeof path.
195
357
  return normalized ? pathApi.resolve(normalized) : "";
196
358
  }
197
359
 
360
+ function normalizeForCompare(value: unknown): string {
361
+ const normalized = String(value ?? "").trim();
362
+ if (!normalized) {
363
+ return "";
364
+ }
365
+ return process.platform === "win32" ? normalized.toLowerCase() : normalized;
366
+ }
367
+
198
368
  function getPathApi(platform: NodeJS.Platform): typeof path.posix | typeof path.win32 {
199
369
  return platform === "win32" ? path.win32 : path.posix;
200
370
  }
@@ -354,6 +354,11 @@ function buildServiceEnvironment(service) {
354
354
  BOT_REGISTRY_FILE: service.botRegistryFilePath,
355
355
  SECRET_STORE_FILE: service.secretStoreFilePath,
356
356
  INSTANCE_LOCK_FILE: service.instanceLockFilePath,
357
+ ...(service.id === "control-plane"
358
+ ? {
359
+ HUB_DATA_DIR: path.join(service.dataDir, "copilot_hub"),
360
+ }
361
+ : {}),
357
362
  };
358
363
  }
359
364
 
@@ -5,6 +5,7 @@ import path from "node:path";
5
5
  import test from "node:test";
6
6
  import {
7
7
  initializeCopilotHubLayout,
8
+ resetCopilotHubConfig,
8
9
  resolveCopilotHubHomeDir,
9
10
  resolveCopilotHubLayout,
10
11
  } from "../dist/install-layout.mjs";
@@ -45,8 +46,16 @@ test("initializeCopilotHubLayout migrates legacy env and data files once", () =>
45
46
  fs.mkdirSync(path.dirname(legacyControlEnvPath), { recursive: true });
46
47
  fs.mkdirSync(path.dirname(legacyEngineDataFile), { recursive: true });
47
48
  fs.mkdirSync(path.dirname(legacyPromptStatePath), { recursive: true });
48
- fs.writeFileSync(legacyEngineEnvPath, "TELEGRAM_TOKEN_AGENT_1=123:abc\n", "utf8");
49
- fs.writeFileSync(legacyControlEnvPath, "HUB_TELEGRAM_TOKEN=456:def\n", "utf8");
49
+ fs.writeFileSync(
50
+ legacyEngineEnvPath,
51
+ ["TELEGRAM_TOKEN_AGENT_1=123:abc", "BOT_REGISTRY_FILE=./data/bot-registry.json", ""].join("\n"),
52
+ "utf8",
53
+ );
54
+ fs.writeFileSync(
55
+ legacyControlEnvPath,
56
+ ["HUB_TELEGRAM_TOKEN=456:def", "HUB_DATA_DIR=./data/copilot_hub", ""].join("\n"),
57
+ "utf8",
58
+ );
50
59
  fs.writeFileSync(legacyEngineDataFile, '{"ok":true}\n', "utf8");
51
60
  fs.writeFileSync(legacyEngineLockPath, "stale-lock\n", "utf8");
52
61
  fs.writeFileSync(legacyPromptStatePath, '{"decision":"accepted"}\n', "utf8");
@@ -79,4 +88,47 @@ test("initializeCopilotHubLayout migrates legacy env and data files once", () =>
79
88
 
80
89
  const secondPass = initializeCopilotHubLayout({ repoRoot, layout });
81
90
  assert.deepEqual(secondPass.migratedPaths, []);
91
+ assert.deepEqual(secondPass.normalizedEnvPaths, []);
92
+ });
93
+
94
+ test("resetCopilotHubConfig removes persisted state but keeps the layout shell", () => {
95
+ const repoRoot = fs.mkdtempSync(path.join(os.tmpdir(), "copilot-hub-reset-"));
96
+ const layout = resolveCopilotHubLayout({
97
+ repoRoot,
98
+ env: {
99
+ COPILOT_HUB_HOME_DIR: path.join(repoRoot, "user-home"),
100
+ },
101
+ homeDirectory: repoRoot,
102
+ });
103
+
104
+ initializeCopilotHubLayout({ repoRoot, layout });
105
+ fs.writeFileSync(layout.agentEngineEnvPath, "TELEGRAM_TOKEN_AGENT_1=123:abc\n", "utf8");
106
+ fs.mkdirSync(layout.agentEngineDataDir, { recursive: true });
107
+ fs.writeFileSync(
108
+ path.join(layout.agentEngineDataDir, "bot-registry.json"),
109
+ '{"version":3}\n',
110
+ "utf8",
111
+ );
112
+ fs.mkdirSync(path.join(layout.runtimeDir, "pids"), { recursive: true });
113
+ fs.writeFileSync(path.join(layout.runtimeDir, "pids", "daemon.json"), '{"pid":1}\n', "utf8");
114
+ fs.writeFileSync(layout.servicePromptStatePath, '{"decision":"accepted"}\n', "utf8");
115
+ fs.writeFileSync(
116
+ path.join(layout.runtimeDir, "windows-daemon-launcher.vbs"),
117
+ "' launcher\n",
118
+ "utf8",
119
+ );
120
+
121
+ const reset = resetCopilotHubConfig({ layout });
122
+
123
+ assert.ok(reset.removedPaths.includes(layout.configDir));
124
+ assert.ok(reset.removedPaths.includes(layout.dataDir));
125
+ assert.ok(reset.removedPaths.includes(layout.logsDir));
126
+ assert.ok(fs.existsSync(layout.configDir));
127
+ assert.ok(fs.existsSync(layout.dataDir));
128
+ assert.ok(fs.existsSync(layout.logsDir));
129
+ assert.equal(fs.existsSync(layout.agentEngineEnvPath), false);
130
+ assert.equal(fs.existsSync(path.join(layout.agentEngineDataDir, "bot-registry.json")), false);
131
+ assert.equal(fs.existsSync(path.join(layout.runtimeDir, "pids")), false);
132
+ assert.equal(fs.existsSync(layout.servicePromptStatePath), false);
133
+ assert.equal(fs.existsSync(path.join(layout.runtimeDir, "windows-daemon-launcher.vbs")), true);
82
134
  });