@smartspec/cli 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/lib/install.js ADDED
@@ -0,0 +1,118 @@
1
+ import { execFile } from 'node:child_process';
2
+ import { createHash } from 'node:crypto';
3
+ import { createWriteStream, existsSync } from 'node:fs';
4
+ import { access, constants, mkdir, mkdtemp, rename, rm } from 'node:fs/promises';
5
+ import os from 'node:os';
6
+ import path from 'node:path';
7
+ import { Readable, Transform } from 'node:stream';
8
+ import { pipeline } from 'node:stream/promises';
9
+ import { promisify } from 'node:util';
10
+ import { APP_ID, APP_NAME, REPOSITORY } from './platform.js';
11
+
12
+ // Installing the app, for the smartspec command's install: the latest release's build for this
13
+ // Mac, as the app's own updater finds it (latest-mac.yml lists each zip with its SHA-512), checked
14
+ // and unpacked into Applications. Tests point it at a stand-in release (SMARTSPEC_DOWNLOADS) and
15
+ // another folder (SMARTSPEC_INSTALL_DIR).
16
+
17
+ const run = promisify(execFile);
18
+
19
+ export const downloadBase = (env = process.env) => (env.SMARTSPEC_DOWNLOADS ?? `https://github.com/${REPOSITORY}/releases/latest/download`).replace(/\/$/, '');
20
+
21
+ // The release's version and files, from latest-mac.yml (the few lines electron-builder writes).
22
+ export function parseLatest(text) {
23
+ const files = [];
24
+ let version = null;
25
+ for (const line of text.split(/\r?\n/)) {
26
+ const field = line.match(/^\s*(-\s+)?(\w+):\s*'?([^']*?)'?\s*$/);
27
+ if (!field) continue;
28
+ const [, item, key, value] = field;
29
+ if (!item && !line.startsWith(' ') && key === 'version') version = value;
30
+ else if (item && key === 'url') files.push({ url: value });
31
+ else if (line.startsWith(' ') && files.length && (key === 'sha512' || key === 'size')) files.at(-1)[key] = key === 'size' ? Number(value) : value;
32
+ }
33
+ return { version, files };
34
+ }
35
+
36
+ // The zip for this Mac: arm64 or x64 in its name.
37
+ export function zipFor(files, arch = process.arch) {
38
+ return files.find((f) => f.url.endsWith('.zip') && f.url.includes(`-mac-${arch}`)) ?? null;
39
+ }
40
+
41
+ // Where the app goes: Applications if the user can write there, else ~/Applications.
42
+ export async function installDir({ env = process.env, home = os.homedir() } = {}) {
43
+ if (env.SMARTSPEC_INSTALL_DIR) return path.resolve(env.SMARTSPEC_INSTALL_DIR);
44
+ try {
45
+ await access('/Applications', constants.W_OK);
46
+ return '/Applications';
47
+ } catch {
48
+ return path.join(home, 'Applications');
49
+ }
50
+ }
51
+
52
+ async function get(url, signal) {
53
+ const res = await fetch(url, { redirect: 'follow', signal });
54
+ if (!res.ok) throw new Error(`Couldn’t download ${url} (HTTP ${res.status}).`);
55
+ return res;
56
+ }
57
+
58
+ // What the latest release offers this Mac: { version, zip } (zip null if there's no build for it).
59
+ export async function latestRelease({ env = process.env, arch = process.arch } = {}) {
60
+ const { version, files } = parseLatest(await (await get(`${downloadBase(env)}/latest-mac.yml`)).text());
61
+ if (!version) throw new Error('The latest release doesn’t say its version.');
62
+ return { version, zip: zipFor(files, arch) };
63
+ }
64
+
65
+ // Downloads and checks the zip, unpacks it, and puts the app in `dir` (replacing one there). Calls
66
+ // `progress(received, total)` as it downloads. Resolves to the app's path.
67
+ export async function installRelease(zip, dir, { env = process.env, progress = () => {} } = {}) {
68
+ const work = await mkdtemp(path.join(os.tmpdir(), 'smartspec-install-'));
69
+ try {
70
+ const file = path.join(work, path.basename(zip.url));
71
+ const res = await get(`${downloadBase(env)}/${zip.url}`);
72
+ const total = Number(res.headers.get('content-length')) || zip.size || null;
73
+ const hash = createHash('sha512');
74
+ let received = 0;
75
+ const tap = new Transform({
76
+ transform(chunk, _encoding, done) {
77
+ hash.update(chunk);
78
+ received += chunk.length;
79
+ progress(received, total);
80
+ done(null, chunk);
81
+ },
82
+ });
83
+ await pipeline(Readable.fromWeb(res.body), tap, createWriteStream(file));
84
+ if (hash.digest('base64') !== zip.sha512) throw new Error(`The download of ${zip.url} didn’t match the release’s checksum, so it wasn’t used.`);
85
+
86
+ // ditto keeps the app's code signature, permissions and links intact.
87
+ const unpacked = path.join(work, 'unpacked');
88
+ await run('ditto', ['-x', '-k', file, unpacked]);
89
+ const built = path.join(unpacked, `${APP_NAME}.app`);
90
+ if (!existsSync(built)) throw new Error(`${zip.url} doesn’t contain ${APP_NAME}.app.`);
91
+
92
+ await mkdir(dir, { recursive: true });
93
+ const target = path.join(dir, `${APP_NAME}.app`);
94
+ await rm(target, { recursive: true, force: true });
95
+ try {
96
+ await rename(built, target);
97
+ } catch {
98
+ await run('ditto', [built, target]); // another disk: copied instead
99
+ }
100
+ return target;
101
+ } finally {
102
+ await rm(work, { recursive: true, force: true });
103
+ }
104
+ }
105
+
106
+ // Whether the app is open (macOS knows it by its ID), and quitting it.
107
+ export async function appRunning() {
108
+ try {
109
+ const { stdout } = await run('osascript', ['-e', `application id "${APP_ID}" is running`]);
110
+ return stdout.trim() === 'true';
111
+ } catch {
112
+ return false;
113
+ }
114
+ }
115
+ export async function quitApp() {
116
+ await run('osascript', ['-e', `tell application id "${APP_ID}" to quit`]);
117
+ for (let i = 0; i < 40 && (await appRunning()); i++) await new Promise((r) => setTimeout(r, 250));
118
+ }
package/lib/launch.js ADDED
@@ -0,0 +1,96 @@
1
+ import { execFile, spawn } from 'node:child_process';
2
+ import { existsSync, readFileSync, statSync } from 'node:fs';
3
+ import path from 'node:path';
4
+ import { promisify } from 'node:util';
5
+ import { APP_ID, APP_NAME, DOWNLOAD_PAGE, appEnv, mcpUrl, ownApp, userDataDir } from './platform.js';
6
+
7
+ // Makes sure the app is running with its MCP server up, opening it if not. A running app is
8
+ // shared: every launcher connects to the same one, and it stays open when they disconnect. The
9
+ // app isn't downloaded from here: the user installs it from the project's releases, or with `smartspec install`.
10
+
11
+ const run = promisify(execFile);
12
+
13
+ const START_TIMEOUT_MS = 90 * 1000;
14
+ const POLL_MS = 400;
15
+ const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
16
+
17
+ // The token the app's MCP server expects. It's kept in the app's data folder, so it's only
18
+ // readable by the user (and only exists once the app has run).
19
+ export function readToken({ env = process.env } = {}) {
20
+ try {
21
+ return readFileSync(path.join(userDataDir({ env }), 'mcp-token'), 'utf8').trim();
22
+ } catch {
23
+ return null;
24
+ }
25
+ }
26
+
27
+ // 'up' when the server answers with this token; 'down' when nothing is listening; 'locked' when
28
+ // something answers but refuses the token (it may not have been written yet).
29
+ export async function probe({ env = process.env } = {}) {
30
+ const token = readToken({ env });
31
+ try {
32
+ // The server only takes POST, so a GET that gets past the token check answers 405.
33
+ const res = await fetch(mcpUrl(env), { headers: token ? { authorization: `Bearer ${token}` } : {} });
34
+ await res.body?.cancel();
35
+ return res.status === 405 ? 'up' : 'locked';
36
+ } catch {
37
+ return 'down';
38
+ }
39
+ }
40
+
41
+ export class NotInstalled extends Error {
42
+ constructor() {
43
+ super(`${APP_NAME} isn't installed on this computer. Tell the user to download it from ${DOWNLOAD_PAGE}, open it once, then try again.`);
44
+ }
45
+ }
46
+
47
+ export async function ensureRunning({ env = process.env, log = () => {}, execPath = process.execPath, platform = process.platform } = {}) {
48
+ if ((await probe({ env })) === 'up') return false;
49
+ await openApp({ env, log, execPath, platform });
50
+ const deadline = Date.now() + START_TIMEOUT_MS;
51
+ while (Date.now() < deadline) {
52
+ if ((await probe({ env })) === 'up') return true; // started by this launcher
53
+ await sleep(POLL_MS);
54
+ }
55
+ throw new Error(`${APP_NAME} didn’t start in time. Open it yourself, then try again.`);
56
+ }
57
+
58
+ // Opens the app: the one given by SMARTSPEC_APP, else the app this launcher is part of, else the
59
+ // installed app, which macOS finds by its ID wherever it is.
60
+ async function openApp({ env, log, execPath, platform }) {
61
+ const override = env.SMARTSPEC_APP;
62
+ if (override) {
63
+ log(`Opening ${override}…`);
64
+ return start(appCommand(path.resolve(override)), appEnv(env));
65
+ }
66
+ if (platform !== 'darwin') throw new Error(`${APP_NAME} runs on macOS for now.`);
67
+ const own = ownApp(execPath);
68
+ log(`Opening ${own ?? APP_NAME}…`);
69
+ try {
70
+ await run('open', own ? [own] : ['-b', APP_ID], { env: appEnv(env) });
71
+ } catch {
72
+ throw new NotInstalled();
73
+ }
74
+ }
75
+
76
+ // How to start an app given by path: a macOS app bundle, a project folder (run with its own
77
+ // Electron, as in development: the nearest one installed, which in a workspace may be above it)
78
+ // or an executable.
79
+ export function appCommand(target) {
80
+ if (target.endsWith('.app')) return ['open', [target]];
81
+ if (existsSync(target) && statSync(target).isDirectory()) {
82
+ const name = process.platform === 'win32' ? 'electron.cmd' : 'electron';
83
+ for (let dir = target; ; dir = path.dirname(dir)) {
84
+ const electron = path.join(dir, 'node_modules', '.bin', name);
85
+ if (existsSync(electron) || dir === path.dirname(dir)) return [electron, [target]];
86
+ }
87
+ }
88
+ return [target, []];
89
+ }
90
+
91
+ // Detached, so the app outlives this launcher and the AI app that started it.
92
+ function start([command, args], env) {
93
+ const child = spawn(command, args, { detached: true, stdio: 'ignore', env });
94
+ child.on('error', () => {}); // reported as a timeout by ensureRunning
95
+ child.unref();
96
+ }
@@ -0,0 +1,42 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import os from 'node:os';
3
+ import path from 'node:path';
4
+
5
+ // Where things are on this computer. Everything here can be overridden with environment
6
+ // variables, which development and tests use to keep away from the user's real app and data:
7
+ // SMARTSPEC_APP the app to open: a SmartSpec.app, an executable, or a project
8
+ // folder (run with its own Electron), instead of the installed one
9
+ // SMARTSPEC_USER_DATA the app's data folder (passed on to a project-folder app)
10
+ // SMARTSPEC_PORT the port the app's MCP server listens on (likewise)
11
+
12
+ export const APP_NAME = 'SmartSpec';
13
+ export const APP_ID = 'com.tw050x.smartspec';
14
+ export const REPOSITORY = 'tw050x/smartspec';
15
+ export const DOWNLOAD_PAGE = 'https://github.com/tw050x/smartspec/releases/latest';
16
+ export const DEFAULT_PORT = 47825;
17
+ export const VERSION = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')).version;
18
+
19
+ export const port = (env = process.env) => Number(env.SMARTSPEC_PORT) || DEFAULT_PORT;
20
+ export const mcpUrl = (env = process.env) => `http://127.0.0.1:${port(env)}/mcp`;
21
+
22
+ // The folder Electron gives the app for its data (it holds the MCP server's token).
23
+ export function userDataDir({ env = process.env, platform = process.platform, home = os.homedir() } = {}) {
24
+ if (env.SMARTSPEC_USER_DATA) return path.resolve(env.SMARTSPEC_USER_DATA);
25
+ if (platform === 'darwin') return path.join(home, 'Library', 'Application Support', APP_NAME);
26
+ if (platform === 'win32') return path.join(env.APPDATA ?? path.join(home, 'AppData', 'Roaming'), APP_NAME);
27
+ return path.join(env.XDG_CONFIG_HOME ?? path.join(home, '.config'), APP_NAME);
28
+ }
29
+
30
+ // The app bundle this launcher runs from, when it's the copy inside the app (run by the app's own
31
+ // executable, …/SmartSpec.app/Contents/MacOS/SmartSpec, as Node); null when it runs from npm.
32
+ export function ownApp(execPath = process.execPath) {
33
+ const match = execPath.match(/^(.*?\.app)\/Contents\/MacOS\/[^/]+$/);
34
+ return match ? match[1] : null;
35
+ }
36
+
37
+ // The environment for opening the app: without ELECTRON_RUN_AS_NODE, which runs this launcher
38
+ // inside the app as Node and would make an Electron app it starts run as Node too.
39
+ export function appEnv(env = process.env) {
40
+ const { ELECTRON_RUN_AS_NODE, ...rest } = env;
41
+ return rest;
42
+ }