cortexterm 0.0.0-stage → 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/README.md CHANGED
@@ -1,3 +1,14 @@
1
- # Temporary Holding Version
1
+ # cortexterm
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ ```sh
4
+ npx cortexterm
5
+ ```
6
+
7
+ Starts [CortexTerm](https://github.com/agentbusiness/CortexTerm), an AI terminal with a local model. The first
8
+ run downloads the app for this machine (Apple Silicon Macs; Linux x64 and arm64) from the official
9
+ releases, checks it against the checksum in the release catalog and unpacks it into `~/.cortexterm/app/`.
10
+ Later runs start the installed app at once; the app updates its frame and plugins itself. The app
11
+ runs on its own: you can close the terminal (its start output goes to `~/.cortexterm/logs/start.log`);
12
+ running the command again while it runs only says so.
13
+
14
+ Arguments go to the app: `npx cortexterm --headless`, `npx cortexterm --safe`.
@@ -0,0 +1,208 @@
1
+ #!/usr/bin/env node
2
+ // `npx cortexterm`: start CortexTerm. The first run downloads the app for this machine from the
3
+ // official releases (the catalog), checks its checksum and unpacks it into ~/.cortexterm/app/<version>/;
4
+ // later runs start the installed app at once, without looking at the catalog: the app updates its
5
+ // frame and plugins itself, so a newer app release is only for machines that have none. The app runs
6
+ // on its own: closing the terminal does not quit it (its start output goes to ~/.cortexterm/logs/start.log).
7
+ // Arguments go to the app (`--headless`, `--safe`).
8
+
9
+ import { spawn } from 'node:child_process';
10
+ import { createHash } from 'node:crypto';
11
+ import { closeSync, createWriteStream, existsSync, mkdirSync, openSync, readFileSync, readdirSync, renameSync, rmSync, writeFileSync } from 'node:fs';
12
+ import http from 'node:http';
13
+ import https from 'node:https';
14
+ import { homedir } from 'node:os';
15
+ import { join } from 'node:path';
16
+ import { pipeline } from 'node:stream/promises';
17
+
18
+ const REPO = process.env.CORTEXTERM_REPO || 'agentbusiness/CortexTerm';
19
+ // CORTEXTERM_CATALOG: another catalog (a mirror, or a release under test).
20
+ const CATALOG_URL = process.env.CORTEXTERM_CATALOG || `https://github.com/${REPO}/releases/download/catalog/catalog.json`;
21
+ const HOME = process.env.CORTEX_HOME || join(homedir(), '.cortexterm');
22
+ const APPS = join(HOME, 'app');
23
+ const READY = '.ready';
24
+ const START_LOG = join(HOME, 'logs', 'start.log');
25
+ const KEEP = 2;
26
+
27
+ const say = (text) => process.stderr.write(`cortexterm: ${text}\n`);
28
+
29
+ function platform() {
30
+ const key = `${process.platform}-${process.arch}`;
31
+ const known = { 'darwin-arm64': 'darwin-arm64', 'linux-x64': 'linux-x64', 'linux-arm64': 'linux-arm64' };
32
+ if (!known[key]) throw new Error(`CortexTerm runs on Apple Silicon Macs and on Linux (x64, arm64), not on ${key}`);
33
+ return known[key];
34
+ }
35
+
36
+ /** `0.3.7` as a comparable triple, or null. */
37
+ function version(text) {
38
+ const match = /^v?(\d+)\.(\d+)\.(\d+)(\+.*)?$/.exec(String(text ?? '').trim());
39
+ return match ? [Number(match[1]), Number(match[2]), Number(match[3])] : null;
40
+ }
41
+
42
+ function compare(a, b) {
43
+ for (let i = 0; i < 3; i += 1) if (a[i] !== b[i]) return a[i] - b[i];
44
+ return 0;
45
+ }
46
+
47
+ /** The response to a GET of `url`, after redirects (GitHub sends release files from another host). */
48
+ function get(url, redirects = 5) {
49
+ return new Promise((resolve, reject) => {
50
+ const client = url.startsWith('https:') ? https : http;
51
+ const request = client.get(url, { headers: { 'user-agent': 'cortexterm-npx' } }, (response) => {
52
+ const status = response.statusCode ?? 0;
53
+ if (status >= 300 && status < 400 && response.headers.location && redirects > 0) {
54
+ response.resume();
55
+ resolve(get(new URL(response.headers.location, url).toString(), redirects - 1));
56
+ return;
57
+ }
58
+ if (status !== 200) {
59
+ response.resume();
60
+ reject(new Error(`${url} answered ${status}`));
61
+ return;
62
+ }
63
+ resolve(response);
64
+ });
65
+ request.on('error', reject);
66
+ });
67
+ }
68
+
69
+ /** The release catalog: the current version of the app and every part, with their files. */
70
+ async function catalog() {
71
+ const response = await fetch(CATALOG_URL, { headers: { 'user-agent': 'cortexterm-npx' } });
72
+ if (!response.ok) throw new Error(`${CATALOG_URL} answered ${response.status}`);
73
+ const parsed = await response.json();
74
+ if (parsed.format !== 'cortexterm-catalog/1') throw new Error(`unknown catalog format ${parsed.format}`);
75
+ return parsed;
76
+ }
77
+
78
+ /** Installed app versions, newest first. */
79
+ function installed() {
80
+ if (!existsSync(APPS)) return [];
81
+ return readdirSync(APPS)
82
+ .filter((name) => version(name) && existsSync(join(APPS, name, READY)))
83
+ .sort((a, b) => compare(version(b), version(a)));
84
+ }
85
+
86
+ function executable(dir) {
87
+ return process.platform === 'darwin' ? join(dir, 'CortexTerm.app', 'Contents', 'MacOS', 'CortexTerm') : join(dir, 'cortexterm', 'CortexTerm');
88
+ }
89
+
90
+ /** Download the app archive, check size and SHA-256, unpack it beside the others. */
91
+ async function install(app, asset) {
92
+ mkdirSync(APPS, { recursive: true });
93
+ const archive = join(APPS, `.download-${app.version}.tar.gz`);
94
+ const staging = join(APPS, `.staging-${app.version}`);
95
+ say(`downloading CortexTerm ${app.version} (${Math.round(asset.size / 1e6)} MB)`);
96
+ // node:http rather than fetch: fetch's stream fails when a server closes the connection while the
97
+ // file write is behind.
98
+ const body = await get(asset.url);
99
+ const hash = createHash('sha256');
100
+ let received = 0;
101
+ let reported = 0;
102
+ body.on('data', (chunk) => {
103
+ hash.update(chunk);
104
+ received += chunk.length;
105
+ const percent = asset.size ? Math.floor((received * 100) / asset.size) : 0;
106
+ if (percent >= reported + 10) {
107
+ reported = percent;
108
+ process.stderr.write(`\rcortexterm: ${percent}%`);
109
+ }
110
+ });
111
+ await pipeline(body, createWriteStream(archive));
112
+ process.stderr.write('\n');
113
+ if (received !== asset.size || hash.digest('hex') !== String(asset.sha256).toLowerCase()) {
114
+ rmSync(archive, { force: true });
115
+ throw new Error(`${asset.name} does not match the catalog (size or checksum); it was not used`);
116
+ }
117
+ rmSync(staging, { recursive: true, force: true });
118
+ mkdirSync(staging, { recursive: true });
119
+ await run('tar', ['-xzf', archive, '-C', staging]);
120
+ rmSync(archive, { force: true });
121
+ const target = join(APPS, app.version);
122
+ rmSync(target, { recursive: true, force: true });
123
+ renameSync(staging, target);
124
+ writeFileSync(join(target, READY), '');
125
+ say(`CortexTerm ${app.version} is installed in ${target}`);
126
+ }
127
+
128
+ function run(command, args) {
129
+ return new Promise((resolve, reject) => {
130
+ const child = spawn(command, args, { stdio: ['ignore', 'ignore', 'inherit'] });
131
+ child.on('error', reject);
132
+ child.on('exit', (code) => (code === 0 ? resolve() : reject(new Error(`${command} failed (${code})`))));
133
+ });
134
+ }
135
+
136
+ /** The pid of a CortexTerm running for this home, if any (the frame writes run/frame.json). */
137
+ function running() {
138
+ try {
139
+ const { pid } = JSON.parse(readFileSync(join(HOME, 'run', 'frame.json'), 'utf8'));
140
+ process.kill(pid, 0);
141
+ return pid;
142
+ } catch {
143
+ return null;
144
+ }
145
+ }
146
+
147
+ /** Keep the newest versions (and the one starting now). */
148
+ function prune(keep) {
149
+ installed()
150
+ .slice(KEEP)
151
+ .filter((name) => name !== keep)
152
+ .forEach((name) => rmSync(join(APPS, name), { recursive: true, force: true }));
153
+ }
154
+
155
+ async function main() {
156
+ const args = process.argv.slice(2);
157
+ if (args.includes('--help') || args.includes('-h')) {
158
+ process.stdout.write('npx cortexterm [--headless] [--safe]\n\nStarts CortexTerm; the first run downloads it into ~/.cortexterm/app.\n');
159
+ return 0;
160
+ }
161
+ const pid = running();
162
+ if (pid) {
163
+ say(`CortexTerm is already running (pid ${pid})`);
164
+ return 0;
165
+ }
166
+ const key = platform();
167
+ // The installed app updates itself (frame and plugins): only a machine without one downloads.
168
+ if (installed().length === 0) {
169
+ try {
170
+ const listed = await catalog();
171
+ const files = listed.app?.files?.[key];
172
+ if (listed.app && files?.archive) await install(listed.app, files.archive);
173
+ else say(`no CortexTerm app is released for ${key} yet`);
174
+ } catch (error) {
175
+ say(`cannot download CortexTerm (${error.message})`);
176
+ }
177
+ }
178
+ const chosen = installed()[0];
179
+ if (!chosen) {
180
+ say('CortexTerm is not installed and could not be downloaded');
181
+ return 1;
182
+ }
183
+ prune(chosen);
184
+ // Its own session, detached from this terminal: closing the terminal does not quit it.
185
+ mkdirSync(join(HOME, 'logs'), { recursive: true });
186
+ const log = openSync(START_LOG, 'a');
187
+ const child = spawn(executable(join(APPS, chosen)), args, { detached: true, stdio: ['ignore', log, log] });
188
+ closeSync(log);
189
+ return await new Promise((resolve) => {
190
+ child.on('error', (error) => {
191
+ say(`cannot start CortexTerm: ${error.message}`);
192
+ resolve(1);
193
+ });
194
+ child.on('spawn', () => {
195
+ child.unref();
196
+ say(`CortexTerm ${chosen} is starting; you can close this terminal (start output: ${START_LOG})`);
197
+ resolve(0);
198
+ });
199
+ });
200
+ }
201
+
202
+ main().then(
203
+ (code) => process.exit(code),
204
+ (error) => {
205
+ say(error.message);
206
+ process.exit(1);
207
+ },
208
+ );
package/package.json CHANGED
@@ -1,6 +1,30 @@
1
1
  {
2
2
  "name": "cortexterm",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.0",
4
+ "description": "CortexTerm, an AI terminal with a local model: npx cortexterm downloads the app for this machine from the official releases and starts it.",
5
+ "type": "module",
6
+ "bin": {
7
+ "cortexterm": "bin/cortexterm.mjs"
8
+ },
9
+ "files": [
10
+ "bin",
11
+ "README.md"
12
+ ],
13
+ "engines": {
14
+ "node": ">=18.17"
15
+ },
16
+ "os": [
17
+ "darwin",
18
+ "linux"
19
+ ],
20
+ "cpu": [
21
+ "arm64",
22
+ "x64"
23
+ ],
24
+ "license": "Apache-2.0",
25
+ "repository": {
26
+ "type": "git",
27
+ "url": "git+https://github.com/agentbusiness/CortexTerm.git"
28
+ },
29
+ "homepage": "https://github.com/agentbusiness/CortexTerm"
30
+ }