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.
Files changed (2) hide show
  1. package/bin/cypher.js +71 -12
  2. 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(os.hostname(), ws));
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: os.hostname() }) }).catch(() => null);
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
- 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 doLink(base); }
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('\nOnline soon. Keep this window open (Ctrl+C to stop). Next time just run: cypher connect');
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: os.hostname() }) }).catch(() => null);
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: os.hostname() }) });
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: os.hostname(), projects: announce() }); console.log(`connected as ${os.hostname()} with Root + ${readRegistry().length} project(s). Ctrl+C to stop.`); });
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: os.hostname(), projects: a }); console.log(`projects updated: ${a.length - 1}`); } }, 2000);
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, run Claude Code when the app asks
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)`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "project-cypher",
3
- "version": "0.10.0",
3
+ "version": "0.12.0",
4
4
  "description": "Connect your machine's Claude Code projects to the Project Cypher phone app.",
5
5
  "bin": {
6
6
  "cypher": "bin/cypher.js"