offrouter-adapter-codex 0.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,283 @@
1
+ /**
2
+ * Rollback for profile-scoped Codex install state.
3
+ * Restores the profile's config.toml to its previous content (or removes the
4
+ * OffRouter-created file) and drops the install-state metadata.
5
+ */
6
+ import { access, mkdir, readFile, rmdir, unlink, writeFile } from "node:fs/promises";
7
+ import { constants } from "node:fs";
8
+ import { dirname, isAbsolute, join, relative, resolve } from "node:path";
9
+ import { homedir as osHomedir } from "node:os";
10
+ import {
11
+ CODEX_CONFIG_REL,
12
+ CODEX_INSTALL_STATE_REL,
13
+ CodexInstallStateSchema,
14
+ type CodexInstallState,
15
+ type InstallChangeKind,
16
+ type InstallFileChange,
17
+ } from "./install.js";
18
+ import { resolveCodexProfileDir } from "./profile.js";
19
+
20
+ export interface CodexRollbackOptions {
21
+ profile: string;
22
+ env?: NodeJS.ProcessEnv;
23
+ homedir?: () => string;
24
+ dryRun?: boolean;
25
+ /** Optional absolute override of the profile directory (tests). */
26
+ profileDir?: string;
27
+ }
28
+
29
+ export interface CodexRollbackResult {
30
+ ok: boolean;
31
+ dryRun: boolean;
32
+ profile: string;
33
+ profileDir: string;
34
+ changes: InstallFileChange[];
35
+ message: string;
36
+ errorCode?: "no_install_state" | "invalid_install_state" | "rollback_error";
37
+ }
38
+
39
+ async function pathExists(path: string): Promise<boolean> {
40
+ try {
41
+ await access(path, constants.F_OK);
42
+ return true;
43
+ } catch {
44
+ return false;
45
+ }
46
+ }
47
+
48
+ function changeSummary(kind: InstallChangeKind, relativePath: string): string {
49
+ switch (kind) {
50
+ case "create":
51
+ return `+ ${relativePath}`;
52
+ case "update":
53
+ return `~ ${relativePath}`;
54
+ case "unchanged":
55
+ return `= ${relativePath}`;
56
+ case "remove":
57
+ return `- ${relativePath}`;
58
+ default:
59
+ return `? ${relativePath}`;
60
+ }
61
+ }
62
+
63
+ async function loadState(statePath: string): Promise<
64
+ | { ok: true; state: CodexInstallState }
65
+ | {
66
+ ok: false;
67
+ errorCode: "no_install_state" | "invalid_install_state";
68
+ message: string;
69
+ }
70
+ > {
71
+ if (!(await pathExists(statePath))) {
72
+ return {
73
+ ok: false,
74
+ errorCode: "no_install_state",
75
+ message: `No install state at ${statePath}. Nothing to uninstall.`,
76
+ };
77
+ }
78
+ try {
79
+ const raw = await readFile(statePath, "utf8");
80
+ const state = CodexInstallStateSchema.parse(JSON.parse(raw));
81
+ return { ok: true, state };
82
+ } catch (err) {
83
+ const detail = err instanceof Error ? err.message : String(err);
84
+ return {
85
+ ok: false,
86
+ errorCode: "invalid_install_state",
87
+ message: `Invalid install state at ${statePath}: ${detail}`,
88
+ };
89
+ }
90
+ }
91
+
92
+ function isWindowsAbsolutePath(path: string): boolean {
93
+ return /^[A-Za-z]:[\\/]/.test(path);
94
+ }
95
+
96
+ function isInsideOrSame(root: string, candidate: string): boolean {
97
+ const rel = relative(root, candidate);
98
+ return rel === "" || (!rel.startsWith("..") && !isAbsolute(rel));
99
+ }
100
+
101
+ function resolveProfileRelativePath(
102
+ profileDir: string,
103
+ relativePath: string,
104
+ ): string | null {
105
+ if (
106
+ relativePath.trim() === "" ||
107
+ relativePath.includes("\0") ||
108
+ isAbsolute(relativePath) ||
109
+ isWindowsAbsolutePath(relativePath)
110
+ ) {
111
+ return null;
112
+ }
113
+
114
+ const parts = relativePath.split(/[\\/]+/);
115
+ if (parts.some((part) => part === "" || part === "." || part === "..")) {
116
+ return null;
117
+ }
118
+
119
+ const root = resolve(profileDir);
120
+ const candidate = resolve(root, ...parts);
121
+ if (!isInsideOrSame(root, candidate)) {
122
+ return null;
123
+ }
124
+ return candidate;
125
+ }
126
+
127
+ function validateRollbackState(
128
+ profileDir: string,
129
+ state: CodexInstallState,
130
+ ): { ok: true; configPath: string } | { ok: false; message: string } {
131
+ if (state.configRelative !== CODEX_CONFIG_REL) {
132
+ return {
133
+ ok: false,
134
+ message: `Invalid config relative path in install state: ${state.configRelative}`,
135
+ };
136
+ }
137
+
138
+ const configPath = resolveProfileRelativePath(
139
+ profileDir,
140
+ state.configRelative,
141
+ );
142
+ if (!configPath) {
143
+ return {
144
+ ok: false,
145
+ message: `Invalid config relative path in install state: ${state.configRelative}`,
146
+ };
147
+ }
148
+
149
+ for (const relativePath of Object.keys(state.files)) {
150
+ if (relativePath !== CODEX_CONFIG_REL) {
151
+ return {
152
+ ok: false,
153
+ message: `Install state path is outside config root: ${relativePath}`,
154
+ };
155
+ }
156
+ const abs = resolveProfileRelativePath(profileDir, relativePath);
157
+ if (!abs) {
158
+ return {
159
+ ok: false,
160
+ message: `Invalid install state path: ${relativePath}`,
161
+ };
162
+ }
163
+ }
164
+
165
+ return { ok: true, configPath };
166
+ }
167
+
168
+ /**
169
+ * Restore files tracked by codex-install-state.json for a profile.
170
+ */
171
+ export async function rollbackCodexProfile(
172
+ options: CodexRollbackOptions,
173
+ ): Promise<CodexRollbackResult> {
174
+ const {
175
+ profile,
176
+ dryRun = false,
177
+ env = process.env,
178
+ homedir = osHomedir,
179
+ } = options;
180
+ const profileDir =
181
+ options.profileDir ?? resolveCodexProfileDir(profile, { env, homedir });
182
+ const statePath = join(profileDir, CODEX_INSTALL_STATE_REL);
183
+
184
+ const loaded = await loadState(statePath);
185
+ if (!loaded.ok) {
186
+ return {
187
+ ok: false,
188
+ dryRun,
189
+ profile,
190
+ profileDir,
191
+ changes: [],
192
+ message: loaded.message,
193
+ errorCode: loaded.errorCode,
194
+ };
195
+ }
196
+
197
+ const { state } = loaded;
198
+ const validated = validateRollbackState(profileDir, state);
199
+ if (!validated.ok) {
200
+ return {
201
+ ok: false,
202
+ dryRun,
203
+ profile,
204
+ profileDir,
205
+ changes: [],
206
+ message: validated.message,
207
+ errorCode: "invalid_install_state",
208
+ };
209
+ }
210
+
211
+ const changes: InstallFileChange[] = [];
212
+ const configPath = validated.configPath;
213
+ const fileState = state.files[CODEX_CONFIG_REL];
214
+ const previous = fileState?.previous ?? null;
215
+
216
+ if (previous === null) {
217
+ // File was created by install; remove it on rollback.
218
+ const existed = await pathExists(configPath);
219
+ const kind: InstallChangeKind = existed ? "remove" : "unchanged";
220
+ changes.push({
221
+ path: configPath,
222
+ relativePath: CODEX_CONFIG_REL,
223
+ kind,
224
+ summary: changeSummary(kind, CODEX_CONFIG_REL),
225
+ });
226
+ if (!dryRun && existed) {
227
+ await unlink(configPath);
228
+ }
229
+ } else {
230
+ let current: string | null = null;
231
+ if (await pathExists(configPath)) {
232
+ current = await readFile(configPath, "utf8");
233
+ }
234
+ let kind: InstallChangeKind;
235
+ if (current === previous) {
236
+ kind = "unchanged";
237
+ } else if (current === null) {
238
+ kind = "create"; // restore missing prior content
239
+ } else {
240
+ kind = "update";
241
+ }
242
+ changes.push({
243
+ path: configPath,
244
+ relativePath: CODEX_CONFIG_REL,
245
+ kind,
246
+ summary: changeSummary(kind, CODEX_CONFIG_REL),
247
+ });
248
+ if (!dryRun && kind !== "unchanged") {
249
+ await mkdir(dirname(configPath), { recursive: true });
250
+ await writeFile(configPath, previous, "utf8");
251
+ }
252
+ }
253
+
254
+ // Drop install state last
255
+ const stateChangeKind: InstallChangeKind = "remove";
256
+ changes.push({
257
+ path: statePath,
258
+ relativePath: CODEX_INSTALL_STATE_REL,
259
+ kind: stateChangeKind,
260
+ summary: changeSummary(stateChangeKind, CODEX_INSTALL_STATE_REL),
261
+ });
262
+ if (!dryRun) {
263
+ await unlink(statePath);
264
+ // Best-effort remove only the empty install-state directory.
265
+ try {
266
+ await rmdir(join(profileDir, ".offrouter"));
267
+ } catch {
268
+ // ignore
269
+ }
270
+ }
271
+
272
+ const applied = changes.filter((c) => c.kind !== "unchanged");
273
+ return {
274
+ ok: true,
275
+ dryRun,
276
+ profile,
277
+ profileDir,
278
+ changes,
279
+ message: dryRun
280
+ ? `Dry run: would roll back ${applied.length} change(s) for profile ${profile}.`
281
+ : `Rolled back OffRouter Codex install for profile ${profile} (${applied.length} change(s)).`,
282
+ };
283
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,10 @@
1
+ {
2
+ "extends": "../../tsconfig.base.json",
3
+ "compilerOptions": {
4
+ "rootDir": "src",
5
+ "outDir": "dist"
6
+ },
7
+ "include": ["src/**/*.ts"],
8
+ "exclude": ["src/**/*.test.ts"],
9
+ "references": [{ "path": "../core" }]
10
+ }