project-cypher 0.10.0 → 0.12.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/bin/cypher.js +71 -12
- package/package.json +1 -1
package/bin/cypher.js
CHANGED
|
@@ -41,7 +41,7 @@ function ensureRoot(workspace) {
|
|
|
41
41
|
const c = rawCfg(); const ws = path.resolve(workspace || c.workspace || path.join(os.homedir(), 'cypher-projects'));
|
|
42
42
|
fs.mkdirSync(ROOT_DIR, { recursive: true }); fs.mkdirSync(ws, { recursive: true });
|
|
43
43
|
if (!fs.existsSync(REG)) writeRegistry([]);
|
|
44
|
-
if (!fs.existsSync(ROOT_MD)) fs.writeFileSync(ROOT_MD, ROOT_TEMPLATE(
|
|
44
|
+
if (!fs.existsSync(ROOT_MD)) fs.writeFileSync(ROOT_MD, ROOT_TEMPLATE(devName(), ws));
|
|
45
45
|
else { const t = fs.readFileSync(ROOT_MD, 'utf8'); const n = t.replace(/^Workspace: .*$/m, `Workspace: ${ws} (new projects are created inside it)`); if (n !== t) fs.writeFileSync(ROOT_MD, n); }
|
|
46
46
|
if (c.workspace !== ws) { c.workspace = ws; save(c); }
|
|
47
47
|
return ws;
|
|
@@ -51,11 +51,58 @@ const slug = p => path.basename(p).toLowerCase().replace(/[^a-z0-9]+/g, '-').rep
|
|
|
51
51
|
const httpOf = s => s.replace(/\/$/, ''), wsOf = s => httpOf(s).replace(/^http/, 'ws');
|
|
52
52
|
|
|
53
53
|
const ask = q => new Promise(r => { const rl = require('readline').createInterface({ input: process.stdin, output: process.stdout }); rl.question(q, a => { rl.close(); r(a.trim()); }); });
|
|
54
|
+
const devName = () => rawCfg().name || os.hostname();
|
|
55
|
+
|
|
56
|
+
// ── Background service (auto-start at boot/login, restarts on crash, waits for internet) ──
|
|
57
|
+
const LOG = path.join(CFG_DIR, 'agent.log'), SELF = [process.execPath, path.resolve(__filename), 'connect'];
|
|
58
|
+
const sh = (cmd, args) => require('child_process').spawnSync(cmd, args, { encoding: 'utf8' });
|
|
59
|
+
const SVC = {
|
|
60
|
+
kind: process.platform === 'darwin' ? 'launchd' : process.platform === 'win32' ? 'task' : 'systemd',
|
|
61
|
+
unit: path.join(os.homedir(), '.config/systemd/user/cypher.service'),
|
|
62
|
+
plist: path.join(os.homedir(), 'Library/LaunchAgents/com.cypher.agent.plist'),
|
|
63
|
+
install() {
|
|
64
|
+
fs.mkdirSync(CFG_DIR, { recursive: true });
|
|
65
|
+
if (this.kind === 'systemd') {
|
|
66
|
+
fs.mkdirSync(path.dirname(this.unit), { recursive: true });
|
|
67
|
+
fs.writeFileSync(this.unit, `[Unit]\nDescription=Project Cypher agent\nAfter=network-online.target\nWants=network-online.target\n\n[Service]\nExecStart=${SELF.map(x => `"${x}"`).join(' ')}\nRestart=on-failure\nRestartSec=5\nStandardOutput=append:${LOG}\nStandardError=append:${LOG}\n\n[Install]\nWantedBy=default.target\n`);
|
|
68
|
+
sh('systemctl', ['--user', 'daemon-reload']); sh('loginctl', ['enable-linger', os.userInfo().username]); // linger = start at boot even before login
|
|
69
|
+
return sh('systemctl', ['--user', 'enable', 'cypher.service']).status === 0;
|
|
70
|
+
}
|
|
71
|
+
if (this.kind === 'launchd') {
|
|
72
|
+
fs.mkdirSync(path.dirname(this.plist), { recursive: true });
|
|
73
|
+
fs.writeFileSync(this.plist, `<?xml version="1.0" encoding="UTF-8"?>\n<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">\n<plist version="1.0"><dict><key>Label</key><string>com.cypher.agent</string><key>ProgramArguments</key><array>${SELF.map(x => `<string>${x}</string>`).join('')}</array><key>RunAtLoad</key><true/><key>KeepAlive</key><dict><key>SuccessfulExit</key><false/></dict><key>StandardOutPath</key><string>${LOG}</string><key>StandardErrorPath</key><string>${LOG}</string></dict></plist>\n`);
|
|
74
|
+
return true;
|
|
75
|
+
}
|
|
76
|
+
return sh('schtasks', ['/create', '/f', '/tn', 'ProjectCypher', '/sc', 'onlogon', '/rl', 'limited', '/tr', `cmd /c ""${SELF[0]}" "${SELF[1]}" connect >> "${LOG}" 2>&1"`]).status === 0;
|
|
77
|
+
},
|
|
78
|
+
start() {
|
|
79
|
+
if (!this.install()) return false;
|
|
80
|
+
if (this.kind === 'systemd') return sh('systemctl', ['--user', 'restart', 'cypher.service']).status === 0;
|
|
81
|
+
if (this.kind === 'launchd') { sh('launchctl', ['unload', this.plist]); return sh('launchctl', ['load', '-w', this.plist]).status === 0; }
|
|
82
|
+
return sh('schtasks', ['/run', '/tn', 'ProjectCypher']).status === 0;
|
|
83
|
+
},
|
|
84
|
+
stop() {
|
|
85
|
+
if (this.kind === 'systemd') { sh('systemctl', ['--user', 'stop', 'cypher.service']); return sh('systemctl', ['--user', 'disable', 'cypher.service']).status === 0; }
|
|
86
|
+
if (this.kind === 'launchd') return sh('launchctl', ['unload', '-w', this.plist]).status === 0;
|
|
87
|
+
sh('schtasks', ['/end', '/tn', 'ProjectCypher']); return sh('schtasks', ['/change', '/tn', 'ProjectCypher', '/disable']).status === 0;
|
|
88
|
+
},
|
|
89
|
+
remove() { this.stop(); try { fs.rmSync(this.kind === 'systemd' ? this.unit : this.plist, { force: true }); } catch {} if (this.kind === 'task') sh('schtasks', ['/delete', '/f', '/tn', 'ProjectCypher']); },
|
|
90
|
+
running() {
|
|
91
|
+
if (this.kind === 'systemd') return sh('systemctl', ['--user', 'is-active', 'cypher.service']).stdout.trim() === 'active';
|
|
92
|
+
if (this.kind === 'launchd') return sh('launchctl', ['list', 'com.cypher.agent']).status === 0;
|
|
93
|
+
return /Running/i.test(sh('schtasks', ['/query', '/tn', 'ProjectCypher']).stdout || '');
|
|
94
|
+
},
|
|
95
|
+
enabled() {
|
|
96
|
+
if (this.kind === 'systemd') return sh('systemctl', ['--user', 'is-enabled', 'cypher.service']).stdout.trim() === 'enabled';
|
|
97
|
+
if (this.kind === 'launchd') return fs.existsSync(this.plist);
|
|
98
|
+
return sh('schtasks', ['/query', '/tn', 'ProjectCypher']).status === 0;
|
|
99
|
+
},
|
|
100
|
+
};
|
|
54
101
|
const yes = (a, d) => (a === '' ? d : /^y/i.test(a));
|
|
55
102
|
const has = (cmd, args = ['--version']) => { const r = require('child_process').spawnSync(cmd, args, { encoding: 'utf8' }); return r.error ? null : (r.stdout || r.stderr || '').trim().split('\n')[0]; };
|
|
56
103
|
async function doLink(server) {
|
|
57
104
|
const c = load(); const base = httpOf(server || c.server);
|
|
58
|
-
const r = await fetch(base + '/link/start', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ name:
|
|
105
|
+
const r = await fetch(base + '/link/start', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ name: devName() }) }).catch(() => null);
|
|
59
106
|
if (!r || !r.ok) { console.log('cannot reach ' + base); process.exit(1); }
|
|
60
107
|
const { code, linkId } = await r.json();
|
|
61
108
|
console.log('\nOpen Project Cypher on your phone and scan this, or type the code.\n');
|
|
@@ -75,7 +122,7 @@ const cmds = {
|
|
|
75
122
|
async disconnect() {
|
|
76
123
|
const c = load(); if (!c.token) return console.log('already disconnected.');
|
|
77
124
|
await fetch(httpOf(c.server) + '/unlink', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ token: c.token }) }).catch(() => null);
|
|
78
|
-
dropToken('disconnected. This computer is no longer linked to your app. Run cypher init to link again.');
|
|
125
|
+
SVC.remove(); dropToken('disconnected. This computer is no longer linked to your app. Run cypher init to link again.');
|
|
79
126
|
},
|
|
80
127
|
// First-run setup: checks Claude Code, reaches the server, shows the QR, adds this folder, goes online.
|
|
81
128
|
async init(...argv) {
|
|
@@ -98,8 +145,9 @@ const cmds = {
|
|
|
98
145
|
const h = await fetch(base + '/health').then(r => r.json()).catch(() => null);
|
|
99
146
|
if (!h?.ok) { console.log(`[!!] Cannot reach the server ${base}. Check your internet (outbound port may be blocked).`); process.exit(1); }
|
|
100
147
|
console.log(`[ok] Server ${base}`);
|
|
101
|
-
|
|
102
|
-
else await doLink(base);
|
|
148
|
+
const askName = async () => { let n = ''; while (!n) n = (await ask(`What should this computer be called in the app? [${devName()}] `)) || devName(); const k = load(); k.name = n.slice(0, 40); save(k); };
|
|
149
|
+
if (c.token) { const again = await ask('This computer is already linked. Link again? [y/N] '); if (!yes(again, false)) { console.log('Keeping the existing link.'); } else { await askName(); await doLink(base); } }
|
|
150
|
+
else { await askName(); await doLink(base); }
|
|
103
151
|
// Workspace = where Root creates new projects. Default: the folder you ran init in.
|
|
104
152
|
const cur = rawCfg().workspace; const cwd = process.cwd();
|
|
105
153
|
let ws = cur || cwd;
|
|
@@ -107,14 +155,14 @@ const cmds = {
|
|
|
107
155
|
ws = ensureRoot(ws);
|
|
108
156
|
console.log(`\nRoot is ready. New projects will be created in ${ws}`);
|
|
109
157
|
console.log('Open Project Cypher and chat with Root: add existing folders, create projects, ask for status.');
|
|
110
|
-
console.log('\
|
|
111
|
-
cmds.connect();
|
|
158
|
+
if (SVC.start()) console.log('\nCypher now runs in the background and starts automatically whenever this computer is on and online.\nManage it with: cypher stats | cypher stop | cypher start');
|
|
159
|
+
else { console.log('\nCould not install the background service. Running in this window (Ctrl+C to stop). Next time: cypher connect'); cmds.connect(); }
|
|
112
160
|
},
|
|
113
161
|
// One command to link this computer to the phone: shows a QR + code, waits for the app, adds the current folder, goes online.
|
|
114
162
|
async link(server) { await doLink(server); cmds.add('.'); cmds.connect(); },
|
|
115
163
|
async _unused(server) {
|
|
116
164
|
const c = load(); const base = httpOf(server || c.server);
|
|
117
|
-
const r = await fetch(base + '/link/start', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ name:
|
|
165
|
+
const r = await fetch(base + '/link/start', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ name: devName() }) }).catch(() => null);
|
|
118
166
|
if (!r || !r.ok) return console.log('cannot reach ' + base);
|
|
119
167
|
const { code, linkId } = await r.json();
|
|
120
168
|
console.log('\nOpen Project Cypher on your phone and scan this, or type the code.\n');
|
|
@@ -132,7 +180,7 @@ const cmds = {
|
|
|
132
180
|
},
|
|
133
181
|
async pair(server, code) {
|
|
134
182
|
if (!server || !code) return console.log('usage: cypher pair <server-url> <code> (get the code in the app: Projects > Add computer)');
|
|
135
|
-
const r = await fetch(httpOf(server) + '/pair', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ code, name:
|
|
183
|
+
const r = await fetch(httpOf(server) + '/pair', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ code, name: devName() }) });
|
|
136
184
|
const j = await r.json().catch(() => ({})); if (!r.ok) return console.log('pairing failed: ' + (j.error || r.status));
|
|
137
185
|
const c = load(); c.server = httpOf(server); c.token = j.token; c.machineId = j.machineId; save(c); console.log('paired. Next: cypher add <folder>, then cypher connect');
|
|
138
186
|
},
|
|
@@ -148,6 +196,15 @@ const cmds = {
|
|
|
148
196
|
},
|
|
149
197
|
remove(id) { writeRegistry(readRegistry().filter(x => x.id !== id)); console.log('removed ' + id); },
|
|
150
198
|
projects() { const c = load(); if (!c.projects.length) return console.log('none yet. cypher add <folder>'); c.projects.forEach(p => console.log(`${p.id.padEnd(20)} ${p.path}`)); },
|
|
199
|
+
// Service control
|
|
200
|
+
start() { const c = load(); if (!c.token) return console.log('Not linked yet. Run: cypher init'); console.log(SVC.start() ? 'Cypher is running in the background and will start automatically when this computer turns on.' : 'Could not start the background service. Run it in this window instead: cypher connect'); },
|
|
201
|
+
stop() { console.log(SVC.stop() ? 'Cypher stopped and auto-start turned off. Run cypher start to bring it back.' : 'Nothing to stop (or the service is not installed).'); },
|
|
202
|
+
async stats() {
|
|
203
|
+
const c = load(); const r = await fetch(httpOf(c.server) + '/health').then(r => r.json()).catch(() => null);
|
|
204
|
+
console.log(`service: ${SVC.running() ? 'running' : 'stopped'}${SVC.enabled() ? ' (starts automatically)' : ' (auto-start off)'}\ninternet/server: ${r?.ok ? 'reachable' : 'unreachable'} ${c.server}\nlinked: ${c.token ? 'yes, as "' + devName() + '"' : 'no (run cypher init)'}\nprojects: ${c.projects.length}\nlog: ${LOG}`);
|
|
205
|
+
try { const t = fs.readFileSync(LOG, 'utf8').trim().split('\n').slice(-3).join('\n'); if (t) console.log('\nrecent:\n' + t); } catch {}
|
|
206
|
+
},
|
|
207
|
+
logs() { try { console.log(fs.readFileSync(LOG, 'utf8').split('\n').slice(-40).join('\n')); } catch { console.log('no log yet'); } },
|
|
151
208
|
async status() { const c = load(); const r = await fetch(httpOf(c.server) + '/health').then(r => r.json()).catch(() => null); console.log(`server ${c.server}: ${r?.ok ? 'online' : 'unreachable'}\ntoken: ${c.token ? 'set' : 'missing (cypher login)'}\nprojects: ${c.projects.length}`); },
|
|
152
209
|
async latest() { const c = load(); const l = await (await fetch(httpOf(c.server) + '/latest')).json(); console.log(`${l.versionName} (code ${l.versionCode})\n${l.apkUrl}`); },
|
|
153
210
|
connect() {
|
|
@@ -159,10 +216,10 @@ const cmds = {
|
|
|
159
216
|
c = { ...c, ...load() };
|
|
160
217
|
const ws = new WebSocket(`${wsOf(c.server)}/agent?token=${encodeURIComponent(c.token)}`);
|
|
161
218
|
const tx = m => ws.readyState === 1 && ws.send(JSON.stringify(m));
|
|
162
|
-
ws.on('open', () => { ws.opened = true; retry = 1000; tx({ type: 'hello', name:
|
|
219
|
+
ws.on('open', () => { ws.opened = true; retry = 1000; tx({ type: 'hello', name: devName(), projects: announce() }); console.log(`connected as ${devName()} with Root + ${readRegistry().length} project(s). Ctrl+C to stop.`); });
|
|
163
220
|
// pick up `cypher add` / `remove` while running: re-announce projects when the config changes
|
|
164
221
|
let sig = JSON.stringify(announce());
|
|
165
|
-
const watch = setInterval(() => { const a = announce(), n = JSON.stringify(a); if (n !== sig) { sig = n; tx({ type: 'hello', name:
|
|
222
|
+
const watch = setInterval(() => { const a = announce(), n = JSON.stringify(a); if (n !== sig) { sig = n; tx({ type: 'hello', name: devName(), projects: a }); console.log(`projects updated: ${a.length - 1}`); } }, 2000);
|
|
166
223
|
ws.on('close', () => clearInterval(watch));
|
|
167
224
|
ws.on('unexpected-response', (req, res) => { if (res.statusCode === 401) { dropToken('This computer is not linked (removed from the app). Run cypher init to link again.'); process.exit(0); } else { req.destroy(); } });
|
|
168
225
|
ws.on('message', raw => {
|
|
@@ -211,7 +268,9 @@ const help = () => console.log(`Project Cypher
|
|
|
211
268
|
cypher add [folder] register a project folder (or just ask Root in the app)
|
|
212
269
|
cypher projects | remove <id> list / remove projects
|
|
213
270
|
cypher disconnect unlink this computer from the app
|
|
214
|
-
cypher connect go online
|
|
271
|
+
cypher connect go online in this window (no background service)
|
|
272
|
+
cypher start | stop start / stop the background service (auto-starts at boot)
|
|
273
|
+
cypher stats | logs service + connection status / recent log
|
|
215
274
|
cypher status | latest server check / latest app build
|
|
216
275
|
|
|
217
276
|
Config: ~/.cypher/config.json (permissionMode defaults to acceptEdits)`);
|