session-steward 0.1.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.
- package/LICENSE +21 -0
- package/README.md +217 -0
- package/bin/session-steward-cli.mjs +71 -0
- package/bin/session-steward.mjs +51 -0
- package/dist/assets/index-DGVNvKX8.js +9 -0
- package/dist/assets/index-DTDZWQb9.css +2 -0
- package/dist/index.html +3 -0
- package/docs/session-steward-overview.jpg +0 -0
- package/lib/cli.mjs +579 -0
- package/lib/providers/codex/index.mjs +35 -0
- package/lib/providers/codex/store.mjs +2300 -0
- package/lib/providers/index.mjs +18 -0
- package/lib/runtime.mjs +27 -0
- package/lib/server.mjs +753 -0
- package/lib/settings.mjs +229 -0
- package/lib/storage/jsonl.mjs +86 -0
- package/lib/storage/sqlite.mjs +66 -0
- package/lib/update-check.mjs +85 -0
- package/lib/version-support.mjs +103 -0
- package/package.json +70 -0
package/lib/settings.mjs
ADDED
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
import { randomBytes } from "node:crypto";
|
|
2
|
+
import { promises as fs } from "node:fs";
|
|
3
|
+
import os from "node:os";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
|
|
6
|
+
const CONFIG_VERSION = 1;
|
|
7
|
+
const PROVIDERS = {
|
|
8
|
+
codex: {
|
|
9
|
+
defaultHome: () => path.join(os.homedir(), ".codex"),
|
|
10
|
+
},
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
function expandHome(value) {
|
|
14
|
+
if (value === "~") {
|
|
15
|
+
return os.homedir();
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
if (value.startsWith("~/")) {
|
|
19
|
+
return path.join(os.homedir(), value.slice(2));
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
return value;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function normalizeHome(value) {
|
|
26
|
+
if (typeof value !== "string" || value.includes("\0")) {
|
|
27
|
+
throw new Error("Enter a valid folder path.");
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const expanded = expandHome(value.trim());
|
|
31
|
+
|
|
32
|
+
if (!expanded || !path.isAbsolute(expanded)) {
|
|
33
|
+
throw new Error("Enter a full folder path, such as ~/.codex.");
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
return path.resolve(expanded);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function getProviderDefinition(providerId) {
|
|
40
|
+
const definition = PROVIDERS[providerId];
|
|
41
|
+
|
|
42
|
+
if (!definition) {
|
|
43
|
+
throw new Error("That session provider is not available.");
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
return definition;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function getDefaultConfigDirectory() {
|
|
50
|
+
const xdgConfigHome = process.env.XDG_CONFIG_HOME;
|
|
51
|
+
|
|
52
|
+
if (xdgConfigHome && path.isAbsolute(xdgConfigHome)) {
|
|
53
|
+
return path.join(xdgConfigHome, "session-steward");
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
if (process.platform === "darwin") {
|
|
57
|
+
return path.join(os.homedir(), "Library", "Application Support", "session-steward");
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
return path.join(os.homedir(), ".config", "session-steward");
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
async function readConfig(configPath) {
|
|
64
|
+
try {
|
|
65
|
+
const parsed = JSON.parse(await fs.readFile(configPath, "utf8"));
|
|
66
|
+
|
|
67
|
+
if (parsed?.version !== CONFIG_VERSION || typeof parsed.providers !== "object" || !parsed.providers) {
|
|
68
|
+
return { providers: {}, version: CONFIG_VERSION };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
return parsed;
|
|
72
|
+
} catch (error) {
|
|
73
|
+
if (error?.code === "ENOENT" || error instanceof SyntaxError) {
|
|
74
|
+
return { providers: {}, version: CONFIG_VERSION };
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
throw new Error("Session Steward could not read its saved settings.", { cause: error });
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
async function writeConfig(configPath, config) {
|
|
82
|
+
const configDirectory = path.dirname(configPath);
|
|
83
|
+
const temporaryPath = path.join(
|
|
84
|
+
configDirectory,
|
|
85
|
+
`.config-${process.pid}-${randomBytes(8).toString("hex")}.tmp`,
|
|
86
|
+
);
|
|
87
|
+
await fs.mkdir(configDirectory, { mode: 0o700, recursive: true });
|
|
88
|
+
|
|
89
|
+
let handle;
|
|
90
|
+
|
|
91
|
+
try {
|
|
92
|
+
handle = await fs.open(temporaryPath, "wx", 0o600);
|
|
93
|
+
await handle.writeFile(`${JSON.stringify(config, null, 2)}\n`, "utf8");
|
|
94
|
+
await handle.sync();
|
|
95
|
+
await handle.close();
|
|
96
|
+
handle = null;
|
|
97
|
+
await fs.rename(temporaryPath, configPath);
|
|
98
|
+
} catch (error) {
|
|
99
|
+
await handle?.close().catch(() => {});
|
|
100
|
+
await fs.rm(temporaryPath, { force: true }).catch(() => {});
|
|
101
|
+
throw error;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
async function requireExistingDirectory(value) {
|
|
106
|
+
const home = normalizeHome(value);
|
|
107
|
+
let stats;
|
|
108
|
+
|
|
109
|
+
try {
|
|
110
|
+
stats = await fs.stat(home);
|
|
111
|
+
} catch (error) {
|
|
112
|
+
if (error?.code === "ENOENT") {
|
|
113
|
+
throw new Error("Choose an existing folder.");
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
if (error?.code === "EACCES" || error?.code === "EPERM") {
|
|
117
|
+
throw new Error("Session Steward cannot open this folder.");
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
throw new Error("Session Steward could not check this folder.", { cause: error });
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
if (!stats.isDirectory()) {
|
|
124
|
+
throw new Error("Choose a folder, not a file.");
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
return home;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export async function createProviderSettings({ configDirectory, providerHomeOverrides = {} } = {}) {
|
|
131
|
+
const resolvedConfigDirectory = configDirectory === undefined
|
|
132
|
+
? getDefaultConfigDirectory()
|
|
133
|
+
: normalizeHome(configDirectory);
|
|
134
|
+
const configPath = path.join(resolvedConfigDirectory, "config.json");
|
|
135
|
+
let config = await readConfig(configPath);
|
|
136
|
+
const startupHomes = {};
|
|
137
|
+
const savedHomes = {};
|
|
138
|
+
const activeHomes = {};
|
|
139
|
+
|
|
140
|
+
for (const [providerId, definition] of Object.entries(PROVIDERS)) {
|
|
141
|
+
const override = providerHomeOverrides[providerId];
|
|
142
|
+
const savedValue = config.providers?.[providerId]?.home;
|
|
143
|
+
let savedHome = null;
|
|
144
|
+
|
|
145
|
+
try {
|
|
146
|
+
savedHome = savedValue === undefined ? null : normalizeHome(savedValue);
|
|
147
|
+
} catch {
|
|
148
|
+
savedHome = null;
|
|
149
|
+
}
|
|
150
|
+
savedHomes[providerId] = savedHome;
|
|
151
|
+
|
|
152
|
+
if (override !== undefined) {
|
|
153
|
+
startupHomes[providerId] = await requireExistingDirectory(override);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
activeHomes[providerId] = startupHomes[providerId]
|
|
157
|
+
|| savedHome
|
|
158
|
+
|| definition.defaultHome();
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function getProvider(providerId) {
|
|
162
|
+
const definition = getProviderDefinition(providerId);
|
|
163
|
+
const defaultHome = definition.defaultHome();
|
|
164
|
+
const home = activeHomes[providerId];
|
|
165
|
+
|
|
166
|
+
return {
|
|
167
|
+
defaultHome,
|
|
168
|
+
home,
|
|
169
|
+
isDefault: home === defaultHome,
|
|
170
|
+
source: startupHomes[providerId] && home === startupHomes[providerId]
|
|
171
|
+
? "startup"
|
|
172
|
+
: savedHomes[providerId] && home === savedHomes[providerId]
|
|
173
|
+
? "saved"
|
|
174
|
+
: "default",
|
|
175
|
+
};
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
async function setProviderHome(providerId, value) {
|
|
179
|
+
const definition = getProviderDefinition(providerId);
|
|
180
|
+
const home = await requireExistingDirectory(value);
|
|
181
|
+
|
|
182
|
+
if (home === definition.defaultHome()) {
|
|
183
|
+
return resetProviderHome(providerId);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
const nextConfig = {
|
|
187
|
+
...config,
|
|
188
|
+
providers: {
|
|
189
|
+
...config.providers,
|
|
190
|
+
[providerId]: { home },
|
|
191
|
+
},
|
|
192
|
+
version: CONFIG_VERSION,
|
|
193
|
+
};
|
|
194
|
+
try {
|
|
195
|
+
await writeConfig(configPath, nextConfig);
|
|
196
|
+
} catch (error) {
|
|
197
|
+
throw new Error("Session Steward could not save this folder.", { cause: error });
|
|
198
|
+
}
|
|
199
|
+
config = nextConfig;
|
|
200
|
+
activeHomes[providerId] = home;
|
|
201
|
+
savedHomes[providerId] = home;
|
|
202
|
+
delete startupHomes[providerId];
|
|
203
|
+
return getProvider(providerId);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
async function resetProviderHome(providerId) {
|
|
207
|
+
const definition = getProviderDefinition(providerId);
|
|
208
|
+
const providers = { ...config.providers };
|
|
209
|
+
delete providers[providerId];
|
|
210
|
+
const nextConfig = { ...config, providers, version: CONFIG_VERSION };
|
|
211
|
+
try {
|
|
212
|
+
await writeConfig(configPath, nextConfig);
|
|
213
|
+
} catch (error) {
|
|
214
|
+
throw new Error("Session Steward could not restore the default folder.", { cause: error });
|
|
215
|
+
}
|
|
216
|
+
config = nextConfig;
|
|
217
|
+
activeHomes[providerId] = definition.defaultHome();
|
|
218
|
+
savedHomes[providerId] = null;
|
|
219
|
+
delete startupHomes[providerId];
|
|
220
|
+
return getProvider(providerId);
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
return {
|
|
224
|
+
getAll: () => Object.fromEntries(Object.keys(PROVIDERS).map((providerId) => [providerId, getProvider(providerId)])),
|
|
225
|
+
getHome: (providerId) => getProvider(providerId).home,
|
|
226
|
+
resetProviderHome,
|
|
227
|
+
setProviderHome,
|
|
228
|
+
};
|
|
229
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { once } from "node:events";
|
|
2
|
+
import { createReadStream, createWriteStream } from "node:fs";
|
|
3
|
+
import { promises as fs } from "node:fs";
|
|
4
|
+
import readline from "node:readline";
|
|
5
|
+
import { finished } from "node:stream/promises";
|
|
6
|
+
|
|
7
|
+
function parseLine(raw, index) {
|
|
8
|
+
try {
|
|
9
|
+
return { index, parsed: JSON.parse(raw), raw };
|
|
10
|
+
} catch {
|
|
11
|
+
return { index, parsed: null, raw };
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export async function* readJsonlEntries(filePath) {
|
|
16
|
+
const input = createReadStream(filePath, { encoding: "utf8" });
|
|
17
|
+
const lines = readline.createInterface({ crlfDelay: Infinity, input });
|
|
18
|
+
let index = 0;
|
|
19
|
+
|
|
20
|
+
try {
|
|
21
|
+
for await (const line of lines) {
|
|
22
|
+
if (line.length === 0) continue;
|
|
23
|
+
yield parseLine(line, index);
|
|
24
|
+
index += 1;
|
|
25
|
+
}
|
|
26
|
+
} catch (error) {
|
|
27
|
+
if (error && typeof error === "object" && "code" in error && error.code === "ENOENT") {
|
|
28
|
+
return;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
throw error;
|
|
32
|
+
} finally {
|
|
33
|
+
lines.close();
|
|
34
|
+
input.destroy();
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export async function inspectJsonlMatches(filePath, matches, { sampleLimit = 100 } = {}) {
|
|
39
|
+
let count = 0;
|
|
40
|
+
const samples = [];
|
|
41
|
+
|
|
42
|
+
for await (const entry of readJsonlEntries(filePath)) {
|
|
43
|
+
if (!matches(entry)) continue;
|
|
44
|
+
count += 1;
|
|
45
|
+
|
|
46
|
+
if (samples.length < sampleLimit) {
|
|
47
|
+
samples.push(entry);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
return { count, samples };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export async function rewriteJsonlFile(filePath, keep) {
|
|
55
|
+
const fileStats = await fs.stat(filePath);
|
|
56
|
+
const temporaryPath = `${filePath}.tmp-${process.pid}-${Date.now()}`;
|
|
57
|
+
const output = createWriteStream(temporaryPath, {
|
|
58
|
+
encoding: "utf8",
|
|
59
|
+
mode: fileStats.mode,
|
|
60
|
+
});
|
|
61
|
+
let retainedCount = 0;
|
|
62
|
+
let removedCount = 0;
|
|
63
|
+
|
|
64
|
+
try {
|
|
65
|
+
for await (const entry of readJsonlEntries(filePath)) {
|
|
66
|
+
if (!keep(entry)) {
|
|
67
|
+
removedCount += 1;
|
|
68
|
+
continue;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
retainedCount += 1;
|
|
72
|
+
if (!output.write(`${entry.raw}\n`)) {
|
|
73
|
+
await once(output, "drain");
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
output.end();
|
|
78
|
+
await finished(output);
|
|
79
|
+
await fs.rename(temporaryPath, filePath);
|
|
80
|
+
return { removedCount, retainedCount };
|
|
81
|
+
} catch (error) {
|
|
82
|
+
output.destroy();
|
|
83
|
+
await fs.rm(temporaryPath, { force: true });
|
|
84
|
+
throw error;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { DatabaseSync, backup } from "node:sqlite";
|
|
2
|
+
|
|
3
|
+
const SQLITE_BATCH_SIZE = 400;
|
|
4
|
+
|
|
5
|
+
function openDatabase(databasePath, { readOnly }) {
|
|
6
|
+
return new DatabaseSync(databasePath, {
|
|
7
|
+
readOnly,
|
|
8
|
+
timeout: 5_000,
|
|
9
|
+
});
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function queryRows(databasePath, sql, parameters = []) {
|
|
13
|
+
const database = openDatabase(databasePath, { readOnly: true });
|
|
14
|
+
|
|
15
|
+
try {
|
|
16
|
+
return database.prepare(sql).all(...parameters);
|
|
17
|
+
} finally {
|
|
18
|
+
database.close();
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function executeTransaction(databasePath, statements) {
|
|
23
|
+
const database = openDatabase(databasePath, { readOnly: false });
|
|
24
|
+
|
|
25
|
+
try {
|
|
26
|
+
database.exec("begin immediate");
|
|
27
|
+
|
|
28
|
+
for (const { parameters = [], sql } of statements) {
|
|
29
|
+
database.prepare(sql).run(...parameters);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
database.exec("commit");
|
|
33
|
+
} catch (error) {
|
|
34
|
+
if (database.isTransaction) {
|
|
35
|
+
database.exec("rollback");
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
throw error;
|
|
39
|
+
} finally {
|
|
40
|
+
database.close();
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export async function backupDatabase(databasePath, destinationPath) {
|
|
45
|
+
const database = openDatabase(databasePath, { readOnly: true });
|
|
46
|
+
|
|
47
|
+
try {
|
|
48
|
+
await backup(database, destinationPath);
|
|
49
|
+
} finally {
|
|
50
|
+
database.close();
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function placeholders(values) {
|
|
55
|
+
if (values.length === 0) {
|
|
56
|
+
throw new Error("At least one SQLite value is required.");
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
return values.map(() => "?").join(", ");
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function* batches(values, size = SQLITE_BATCH_SIZE) {
|
|
63
|
+
for (let index = 0; index < values.length; index += size) {
|
|
64
|
+
yield values.slice(index, index + size);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
const DEFAULT_TIMEOUT_MS = 1_200;
|
|
2
|
+
const STABLE_VERSION_PATTERN = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/u;
|
|
3
|
+
|
|
4
|
+
function parseStableVersion(value) {
|
|
5
|
+
if (typeof value !== "string") return null;
|
|
6
|
+
|
|
7
|
+
const match = STABLE_VERSION_PATTERN.exec(value);
|
|
8
|
+
return match ? match.slice(1, 4).map(BigInt) : null;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function compareStableVersions(left, right) {
|
|
12
|
+
const leftParts = parseStableVersion(left);
|
|
13
|
+
const rightParts = parseStableVersion(right);
|
|
14
|
+
|
|
15
|
+
if (!leftParts || !rightParts) return null;
|
|
16
|
+
|
|
17
|
+
for (let index = 0; index < leftParts.length; index += 1) {
|
|
18
|
+
if (leftParts[index] > rightParts[index]) return 1;
|
|
19
|
+
if (leftParts[index] < rightParts[index]) return -1;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
return 0;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export async function findAvailableUpdate({
|
|
26
|
+
fetchImpl = globalThis.fetch,
|
|
27
|
+
packageMetadata,
|
|
28
|
+
timeoutMs = DEFAULT_TIMEOUT_MS,
|
|
29
|
+
} = {}) {
|
|
30
|
+
const packageName = packageMetadata?.name;
|
|
31
|
+
const currentVersion = packageMetadata?.version;
|
|
32
|
+
|
|
33
|
+
if (
|
|
34
|
+
typeof fetchImpl !== "function"
|
|
35
|
+
|| typeof packageName !== "string"
|
|
36
|
+
|| packageName.length === 0
|
|
37
|
+
|| !parseStableVersion(currentVersion)
|
|
38
|
+
) {
|
|
39
|
+
return null;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const requestTimeoutMs = Number.isFinite(timeoutMs) && timeoutMs > 0
|
|
43
|
+
? timeoutMs
|
|
44
|
+
: DEFAULT_TIMEOUT_MS;
|
|
45
|
+
const controller = new AbortController();
|
|
46
|
+
let timeout;
|
|
47
|
+
const timedOut = new Promise((resolve) => {
|
|
48
|
+
timeout = setTimeout(() => {
|
|
49
|
+
controller.abort();
|
|
50
|
+
resolve(null);
|
|
51
|
+
}, requestTimeoutMs);
|
|
52
|
+
});
|
|
53
|
+
const request = (async () => {
|
|
54
|
+
try {
|
|
55
|
+
const response = await fetchImpl(
|
|
56
|
+
`https://registry.npmjs.org/${encodeURIComponent(packageName)}/latest`,
|
|
57
|
+
{
|
|
58
|
+
headers: { Accept: "application/json" },
|
|
59
|
+
signal: controller.signal,
|
|
60
|
+
},
|
|
61
|
+
);
|
|
62
|
+
|
|
63
|
+
if (!response?.ok) return null;
|
|
64
|
+
return await response.json();
|
|
65
|
+
} catch {
|
|
66
|
+
return null;
|
|
67
|
+
}
|
|
68
|
+
})();
|
|
69
|
+
|
|
70
|
+
try {
|
|
71
|
+
const result = await Promise.race([request, timedOut]);
|
|
72
|
+
const latestVersion = result?.version;
|
|
73
|
+
const comparison = compareStableVersions(latestVersion, currentVersion);
|
|
74
|
+
|
|
75
|
+
if (comparison !== 1) return null;
|
|
76
|
+
|
|
77
|
+
return { currentVersion, latestVersion, packageName };
|
|
78
|
+
} finally {
|
|
79
|
+
clearTimeout(timeout);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function formatUpdateNotice({ latestVersion, packageName }) {
|
|
84
|
+
return `Session Steward ${latestVersion} is available. Update with: npm install -g ${packageName}@latest`;
|
|
85
|
+
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
export const VERSION_SUPPORT_STATUS = Object.freeze({
|
|
2
|
+
EXACT_SUPPORTED: "exact-supported",
|
|
3
|
+
NEWER: "newer",
|
|
4
|
+
OLDER: "older",
|
|
5
|
+
UNAVAILABLE: "unavailable",
|
|
6
|
+
UNRECOGNIZED: "unrecognized",
|
|
7
|
+
});
|
|
8
|
+
|
|
9
|
+
const DOTTED_VERSION_PATTERN = /(?<![\d.])(\d+(?:\.\d+){2,})(?![\d.+-])/gu;
|
|
10
|
+
|
|
11
|
+
function extractVersion(value) {
|
|
12
|
+
if (typeof value !== "string") return null;
|
|
13
|
+
|
|
14
|
+
const matches = [...value.matchAll(DOTTED_VERSION_PATTERN)];
|
|
15
|
+
if (matches.length !== 1) return null;
|
|
16
|
+
|
|
17
|
+
return {
|
|
18
|
+
parts: matches[0][1].split(".").map(BigInt),
|
|
19
|
+
value: matches[0][1],
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function compareParts(left, right) {
|
|
24
|
+
const length = Math.max(left.length, right.length);
|
|
25
|
+
|
|
26
|
+
for (let index = 0; index < length; index += 1) {
|
|
27
|
+
const leftPart = left[index] ?? 0n;
|
|
28
|
+
const rightPart = right[index] ?? 0n;
|
|
29
|
+
|
|
30
|
+
if (leftPart > rightPart) return 1;
|
|
31
|
+
if (leftPart < rightPart) return -1;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
return 0;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function parseSupportedVersions(values) {
|
|
38
|
+
if (!Array.isArray(values) || values.length === 0) return null;
|
|
39
|
+
|
|
40
|
+
const parsed = values.map(extractVersion);
|
|
41
|
+
if (parsed.some((version) => version === null)) return null;
|
|
42
|
+
|
|
43
|
+
return parsed.sort((left, right) => compareParts(left.parts, right.parts));
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function result(status, {
|
|
47
|
+
installedVersion = null,
|
|
48
|
+
latestSupportedVersion = null,
|
|
49
|
+
matchedSupportedVersion = null,
|
|
50
|
+
} = {}) {
|
|
51
|
+
return {
|
|
52
|
+
installedVersion,
|
|
53
|
+
latestSupportedVersion,
|
|
54
|
+
matchedSupportedVersion,
|
|
55
|
+
status,
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function classifyInstalledVersion({ installedVersion, supportedVersions } = {}) {
|
|
60
|
+
const parsedSupportedVersions = parseSupportedVersions(supportedVersions);
|
|
61
|
+
const latestSupportedVersion = parsedSupportedVersions?.at(-1)?.value ?? null;
|
|
62
|
+
|
|
63
|
+
if (
|
|
64
|
+
installedVersion === null
|
|
65
|
+
|| installedVersion === undefined
|
|
66
|
+
|| (typeof installedVersion === "string" && installedVersion.trim() === "")
|
|
67
|
+
) {
|
|
68
|
+
return result(VERSION_SUPPORT_STATUS.UNAVAILABLE, { latestSupportedVersion });
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const installed = extractVersion(installedVersion);
|
|
72
|
+
if (!installed || !parsedSupportedVersions) {
|
|
73
|
+
return result(VERSION_SUPPORT_STATUS.UNRECOGNIZED, {
|
|
74
|
+
installedVersion: installed?.value ?? null,
|
|
75
|
+
latestSupportedVersion,
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const matched = parsedSupportedVersions.find(
|
|
80
|
+
(supported) => compareParts(installed.parts, supported.parts) === 0,
|
|
81
|
+
);
|
|
82
|
+
|
|
83
|
+
if (matched) {
|
|
84
|
+
return result(VERSION_SUPPORT_STATUS.EXACT_SUPPORTED, {
|
|
85
|
+
installedVersion: installed.value,
|
|
86
|
+
latestSupportedVersion,
|
|
87
|
+
matchedSupportedVersion: matched.value,
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const comparison = compareParts(
|
|
92
|
+
installed.parts,
|
|
93
|
+
parsedSupportedVersions.at(-1).parts,
|
|
94
|
+
);
|
|
95
|
+
|
|
96
|
+
return result(
|
|
97
|
+
comparison < 0 ? VERSION_SUPPORT_STATUS.OLDER : VERSION_SUPPORT_STATUS.NEWER,
|
|
98
|
+
{
|
|
99
|
+
installedVersion: installed.value,
|
|
100
|
+
latestSupportedVersion,
|
|
101
|
+
},
|
|
102
|
+
);
|
|
103
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "session-steward",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Safely review, back up, and remove local Codex sessions with a browser UI or terminal CLI.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"author": "Mallik Cheripally",
|
|
7
|
+
"type": "module",
|
|
8
|
+
"bin": {
|
|
9
|
+
"session-steward": "bin/session-steward.mjs",
|
|
10
|
+
"session-steward-cli": "bin/session-steward-cli.mjs"
|
|
11
|
+
},
|
|
12
|
+
"repository": {
|
|
13
|
+
"type": "git",
|
|
14
|
+
"url": "git+https://github.com/mallikcheripally/session-steward.git"
|
|
15
|
+
},
|
|
16
|
+
"homepage": "https://github.com/mallikcheripally/session-steward#readme",
|
|
17
|
+
"bugs": "https://github.com/mallikcheripally/session-steward/issues",
|
|
18
|
+
"keywords": [
|
|
19
|
+
"codex",
|
|
20
|
+
"codex-cli",
|
|
21
|
+
"codex-sessions",
|
|
22
|
+
"openai-codex",
|
|
23
|
+
"openai",
|
|
24
|
+
"chatgpt",
|
|
25
|
+
"session-manager",
|
|
26
|
+
"session-cleanup",
|
|
27
|
+
"ai-coding-agent",
|
|
28
|
+
"cli",
|
|
29
|
+
"backup",
|
|
30
|
+
"privacy",
|
|
31
|
+
"local-first"
|
|
32
|
+
],
|
|
33
|
+
"os": ["darwin", "linux"],
|
|
34
|
+
"files": [
|
|
35
|
+
"bin",
|
|
36
|
+
"dist",
|
|
37
|
+
"docs",
|
|
38
|
+
"lib",
|
|
39
|
+
"LICENSE",
|
|
40
|
+
"README.md"
|
|
41
|
+
],
|
|
42
|
+
"engines": {
|
|
43
|
+
"node": ">=24.15.0"
|
|
44
|
+
},
|
|
45
|
+
"publishConfig": {
|
|
46
|
+
"access": "public"
|
|
47
|
+
},
|
|
48
|
+
"scripts": {
|
|
49
|
+
"build": "vite build",
|
|
50
|
+
"benchmark:discovery": "node --expose-gc test/benchmarks/codex-discovery.mjs",
|
|
51
|
+
"benchmark:scale": "node --expose-gc test/benchmarks/codex-list.mjs",
|
|
52
|
+
"benchmark:transcripts": "node --expose-gc test/benchmarks/codex-transcripts.mjs",
|
|
53
|
+
"prepack": "npm run build",
|
|
54
|
+
"start": "node ./bin/session-steward.mjs",
|
|
55
|
+
"serve": "node ./bin/session-steward.mjs",
|
|
56
|
+
"check": "node ./bin/session-steward-cli.mjs --json --limit 5",
|
|
57
|
+
"test": "node --test test/*.test.mjs test/providers/*.test.mjs"
|
|
58
|
+
},
|
|
59
|
+
"dependencies": {
|
|
60
|
+
"lucide-react": "^1.28.0",
|
|
61
|
+
"react": "^19.2.8",
|
|
62
|
+
"react-dom": "^19.2.8"
|
|
63
|
+
},
|
|
64
|
+
"devDependencies": {
|
|
65
|
+
"@tailwindcss/vite": "^4.3.3",
|
|
66
|
+
"@vitejs/plugin-react": "^6.0.5",
|
|
67
|
+
"tailwindcss": "^4.3.3",
|
|
68
|
+
"vite": "^8.2.0"
|
|
69
|
+
}
|
|
70
|
+
}
|