qiksy 1.0.0 → 1.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/bin/qiksy.mjs +271 -8
- package/package.json +2 -2
- package/tools/travel/vendor/lib/store.mjs +5 -0
- package/tools/travel/vendor/server.mjs +18 -0
package/bin/qiksy.mjs
CHANGED
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
* Everything runs on the user's machine, on their own Claude. Nothing is uploaded.
|
|
14
14
|
*/
|
|
15
15
|
import { spawn, spawnSync } from 'node:child_process';
|
|
16
|
-
import { existsSync, mkdirSync } from 'node:fs';
|
|
16
|
+
import { existsSync, mkdirSync, rmSync, writeFileSync } from 'node:fs';
|
|
17
17
|
import { homedir } from 'node:os';
|
|
18
18
|
import { dirname, join, resolve } from 'node:path';
|
|
19
19
|
import { platform } from 'node:process';
|
|
@@ -140,6 +140,28 @@ async function isUp(port) {
|
|
|
140
140
|
}
|
|
141
141
|
}
|
|
142
142
|
|
|
143
|
+
/**
|
|
144
|
+
* Who holds this port: nobody, our own tool, or a stranger.
|
|
145
|
+
*
|
|
146
|
+
* Running the installer twice is the most likely thing a non-technical person does —
|
|
147
|
+
* and before this check the second run printed green ticks (the FIRST engine was
|
|
148
|
+
* answering) followed by a raw EADDRINUSE stack trace, which reads as "it broke".
|
|
149
|
+
* All three tools answer /api/state with JSON, so that is the fingerprint.
|
|
150
|
+
*/
|
|
151
|
+
async function whoHolds(port) {
|
|
152
|
+
try {
|
|
153
|
+
const r = await fetch(`http://127.0.0.1:${port}/api/state`, { signal: AbortSignal.timeout(1500) });
|
|
154
|
+
if (r.ok) {
|
|
155
|
+
const ct = r.headers.get('content-type') || '';
|
|
156
|
+
if (ct.includes('json')) return 'ours';
|
|
157
|
+
}
|
|
158
|
+
return 'stranger';
|
|
159
|
+
} catch {
|
|
160
|
+
/* nothing answered on HTTP — but something may still hold the socket */
|
|
161
|
+
}
|
|
162
|
+
return pidsOn(port).length ? 'stranger' : 'free';
|
|
163
|
+
}
|
|
164
|
+
|
|
143
165
|
async function cmdStatus() {
|
|
144
166
|
console.log(`\n${c.blue(c.b(' ◆ Qiksy'))}${c.dim(' — status')}\n`);
|
|
145
167
|
let anyUp = false;
|
|
@@ -191,9 +213,178 @@ async function cmdDoctor() {
|
|
|
191
213
|
console.log(c.dim(' Your work is kept in the data folder above — restarting never deletes it.\n'));
|
|
192
214
|
}
|
|
193
215
|
|
|
216
|
+
/* ══ Autostart ════════════════════════════════════════════════════════════════
|
|
217
|
+
Without this the engine lives exactly as long as the window Claude started it in
|
|
218
|
+
— close the laptop lid, reboot, and the tools "stop working" for reasons the
|
|
219
|
+
person cannot see. `install` registers it with the OS so it comes back on its own;
|
|
220
|
+
`uninstall` takes it back out. Everything is per-user: no sudo, no system files. */
|
|
221
|
+
|
|
222
|
+
const AGENT_LABEL = 'app.qiksy.engine';
|
|
223
|
+
|
|
224
|
+
function autostartPaths() {
|
|
225
|
+
const home = homedir();
|
|
226
|
+
return {
|
|
227
|
+
mac: join(home, 'Library', 'LaunchAgents', `${AGENT_LABEL}.plist`),
|
|
228
|
+
linux: join(home, '.config', 'systemd', 'user', 'qiksy.service'),
|
|
229
|
+
win: join(process.env.APPDATA || home, 'Microsoft', 'Windows', 'Start Menu', 'Programs', 'Startup', 'qiksy.cmd'),
|
|
230
|
+
};
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/** The command the OS should run. Prefer this package's own entry over `npx`, which
|
|
234
|
+
* needs the network and a cache that gets cleaned. */
|
|
235
|
+
function selfCommand() {
|
|
236
|
+
return { node: process.execPath, script: join(PKG, 'bin', 'qiksy.mjs') };
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
function autostartInstalled() {
|
|
240
|
+
const p = autostartPaths();
|
|
241
|
+
try {
|
|
242
|
+
if (platform === 'darwin') return existsSync(p.mac);
|
|
243
|
+
if (isWin) return existsSync(p.win);
|
|
244
|
+
return existsSync(p.linux);
|
|
245
|
+
} catch {
|
|
246
|
+
return false;
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
function cmdInstall({ quiet = false } = {}) {
|
|
251
|
+
const p = autostartPaths();
|
|
252
|
+
const { node, script } = selfCommand();
|
|
253
|
+
const say = (line) => { if (!quiet) console.log(line); };
|
|
254
|
+
say(`\n${c.blue(c.b(' ◆ Qiksy'))}${c.dim(' — start automatically')}\n`);
|
|
255
|
+
try {
|
|
256
|
+
if (platform === 'darwin') {
|
|
257
|
+
mkdirSync(dirname(p.mac), { recursive: true });
|
|
258
|
+
writeFileSync(
|
|
259
|
+
p.mac,
|
|
260
|
+
`<?xml version="1.0" encoding="UTF-8"?>
|
|
261
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
262
|
+
<plist version="1.0"><dict>
|
|
263
|
+
<key>Label</key><string>${AGENT_LABEL}</string>
|
|
264
|
+
<key>ProgramArguments</key><array><string>${node}</string><string>${script}</string></array>
|
|
265
|
+
<key>EnvironmentVariables</key><dict><key>QIKSY_NO_OPEN</key><string>1</string></dict>
|
|
266
|
+
<key>RunAtLoad</key><true/>
|
|
267
|
+
<key>KeepAlive</key><true/>
|
|
268
|
+
<key>StandardOutPath</key><string>${join(DATA_HOME, 'engine.log')}</string>
|
|
269
|
+
<key>StandardErrorPath</key><string>${join(DATA_HOME, 'engine.log')}</string>
|
|
270
|
+
</dict></plist>\n`
|
|
271
|
+
);
|
|
272
|
+
spawnSync('launchctl', ['unload', p.mac], { stdio: 'ignore' });
|
|
273
|
+
spawnSync('launchctl', ['load', p.mac], { stdio: 'ignore' });
|
|
274
|
+
say(c.green(' ✓ Qiksy will start by itself when you log in.'));
|
|
275
|
+
} else if (isWin) {
|
|
276
|
+
mkdirSync(dirname(p.win), { recursive: true });
|
|
277
|
+
writeFileSync(p.win, `@echo off\r\nstart "" /min "${node}" "${script}"\r\n`);
|
|
278
|
+
say(c.green(' ✓ Qiksy will start by itself when you sign in.'));
|
|
279
|
+
} else {
|
|
280
|
+
mkdirSync(dirname(p.linux), { recursive: true });
|
|
281
|
+
writeFileSync(
|
|
282
|
+
p.linux,
|
|
283
|
+
`[Unit]\nDescription=Qiksy local engine\n\n[Service]\nExecStart=${node} ${script}\nEnvironment=QIKSY_NO_OPEN=1\nRestart=always\n\n[Install]\nWantedBy=default.target\n`
|
|
284
|
+
);
|
|
285
|
+
spawnSync('systemctl', ['--user', 'daemon-reload'], { stdio: 'ignore' });
|
|
286
|
+
spawnSync('systemctl', ['--user', 'enable', '--now', 'qiksy.service'], { stdio: 'ignore' });
|
|
287
|
+
say(c.green(' ✓ Qiksy will start by itself when you log in.'));
|
|
288
|
+
}
|
|
289
|
+
say(c.dim(' Undo any time with: npx qiksy uninstall\n'));
|
|
290
|
+
return true;
|
|
291
|
+
} catch (e) {
|
|
292
|
+
if (!quiet) {
|
|
293
|
+
console.log(c.amber(` ! Could not set that up: ${e.message}`));
|
|
294
|
+
console.log(c.dim(' You can still start it by hand with: npx qiksy\n'));
|
|
295
|
+
}
|
|
296
|
+
return false;
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
function cmdUninstall() {
|
|
301
|
+
const p = autostartPaths();
|
|
302
|
+
console.log(`\n${c.blue(c.b(' ◆ Qiksy'))}${c.dim(' — stop starting automatically')}\n`);
|
|
303
|
+
try {
|
|
304
|
+
if (platform === 'darwin') {
|
|
305
|
+
spawnSync('launchctl', ['unload', p.mac], { stdio: 'ignore' });
|
|
306
|
+
if (existsSync(p.mac)) rmSync(p.mac);
|
|
307
|
+
} else if (isWin) {
|
|
308
|
+
if (existsSync(p.win)) rmSync(p.win);
|
|
309
|
+
} else {
|
|
310
|
+
spawnSync('systemctl', ['--user', 'disable', '--now', 'qiksy.service'], { stdio: 'ignore' });
|
|
311
|
+
if (existsSync(p.linux)) rmSync(p.linux);
|
|
312
|
+
}
|
|
313
|
+
console.log(c.green(' ✓ It will no longer start on its own.'));
|
|
314
|
+
console.log(c.dim(' It may still be running now — turn it off with: npx qiksy stop\n'));
|
|
315
|
+
} catch (e) {
|
|
316
|
+
console.log(c.amber(` ! ${e.message}\n`));
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/* ══ Staying alive ════════════════════════════════════════════════════════════
|
|
321
|
+
The people this is built for (recruiters, agencies) will not read a log, will not
|
|
322
|
+
know what a process is, and will not reason about ports. So the engine has to look
|
|
323
|
+
after itself: a tool that dies is restarted, a tool that stops answering is
|
|
324
|
+
restarted, and both are backed off so a genuinely broken tool cannot spin forever.
|
|
325
|
+
Nothing here ever asks the person to do anything. */
|
|
326
|
+
|
|
194
327
|
const children = [];
|
|
328
|
+
/** id → { tool, child, restarts, downSince } */
|
|
329
|
+
const supervised = new Map();
|
|
330
|
+
const MAX_QUICK_RESTARTS = 5; // within RESTART_WINDOW, then we slow right down
|
|
331
|
+
const RESTART_WINDOW = 60_000;
|
|
332
|
+
|
|
333
|
+
function scheduleRestart(tool, why) {
|
|
334
|
+
const s = supervised.get(tool.id);
|
|
335
|
+
if (!s || s.stopping) return;
|
|
336
|
+
const now = Date.now();
|
|
337
|
+
if (!s.windowStart || now - s.windowStart > RESTART_WINDOW) {
|
|
338
|
+
s.windowStart = now;
|
|
339
|
+
s.restarts = 0;
|
|
340
|
+
}
|
|
341
|
+
s.restarts++;
|
|
342
|
+
// 1s, 2s, 4s, 8s… capped: a tool broken for a real reason must not burn the CPU.
|
|
343
|
+
const delay = Math.min(1000 * 2 ** Math.max(0, s.restarts - 1), 30_000);
|
|
344
|
+
if (s.restarts > MAX_QUICK_RESTARTS) {
|
|
345
|
+
if (!s.warned) {
|
|
346
|
+
console.log(c.amber(` ! ${tool.name} keeps stopping. It will keep retrying quietly in the background.`));
|
|
347
|
+
console.log(c.dim(` If you need it now: npx qiksy doctor`));
|
|
348
|
+
s.warned = true;
|
|
349
|
+
}
|
|
350
|
+
} else if (process.env.QIKSY_DEBUG) {
|
|
351
|
+
console.log(c.dim(` · restarting ${tool.name} (${why}) in ${Math.round(delay / 1000)}s`));
|
|
352
|
+
}
|
|
353
|
+
s.timer = setTimeout(() => {
|
|
354
|
+
if (!s.stopping) start(tool, true);
|
|
355
|
+
}, delay);
|
|
356
|
+
}
|
|
195
357
|
|
|
196
|
-
|
|
358
|
+
/** Every 30s: is each tool still answering? A hung process holds its port but serves
|
|
359
|
+
* nothing, which looks exactly like "the tool broke" from the browser. */
|
|
360
|
+
function startWatchdog() {
|
|
361
|
+
const timer = setInterval(async () => {
|
|
362
|
+
for (const [, s] of supervised) {
|
|
363
|
+
if (s.stopping || !s.child) continue;
|
|
364
|
+
const alive = await isUp(s.tool.port);
|
|
365
|
+
if (alive) {
|
|
366
|
+
s.downSince = 0;
|
|
367
|
+
continue;
|
|
368
|
+
}
|
|
369
|
+
// Two consecutive misses before acting — one slow moment is not a death.
|
|
370
|
+
if (!s.downSince) {
|
|
371
|
+
s.downSince = Date.now();
|
|
372
|
+
continue;
|
|
373
|
+
}
|
|
374
|
+
if (Date.now() - s.downSince < 25_000) continue;
|
|
375
|
+
s.downSince = 0;
|
|
376
|
+
try {
|
|
377
|
+
s.child.kill('SIGKILL');
|
|
378
|
+
} catch {
|
|
379
|
+
/* already gone */
|
|
380
|
+
}
|
|
381
|
+
// its 'exit' handler schedules the restart
|
|
382
|
+
}
|
|
383
|
+
}, 30_000);
|
|
384
|
+
timer.unref?.();
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
function start(tool, isRestart = false) {
|
|
197
388
|
const dir0 = join(PKG, 'tools', tool.id, 'vendor');
|
|
198
389
|
const entry = join(dir0, 'server.mjs');
|
|
199
390
|
if (!existsSync(entry)) return null;
|
|
@@ -212,15 +403,40 @@ function start(tool) {
|
|
|
212
403
|
// three-way tail stays readable.
|
|
213
404
|
const tag = c.dim(`[${tool.id}] `);
|
|
214
405
|
child.stdout?.on('data', (d) => process.stdout.write(String(d).replace(/^/gm, tag)));
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
406
|
+
// A Node stack trace tells the person nothing and reads as "it broke". Keep the
|
|
407
|
+
// detail for QIKSY_DEBUG, and say the one useful sentence instead.
|
|
408
|
+
child.stderr?.on('data', (d) => {
|
|
409
|
+
const s = String(d);
|
|
410
|
+
if (process.env.QIKSY_DEBUG) return process.stderr.write(s.replace(/^/gm, tag));
|
|
411
|
+
if (/EADDRINUSE/.test(s)) {
|
|
412
|
+
console.log(c.amber(` ! ${tool.name} could not start — port ${tool.port} is already in use.`));
|
|
413
|
+
console.log(c.dim(' Check with: npx qiksy status'));
|
|
414
|
+
return;
|
|
415
|
+
}
|
|
416
|
+
if (/^\s*(at |node:|throw |\^|Error:)/m.test(s) && !/warn/i.test(s)) return; // swallow the trace
|
|
417
|
+
process.stderr.write(s.replace(/^/gm, tag));
|
|
218
418
|
});
|
|
419
|
+
child.on('exit', (code, signal) => {
|
|
420
|
+
const s = supervised.get(tool.id);
|
|
421
|
+
if (!s || s.stopping) return;
|
|
422
|
+
s.child = null;
|
|
423
|
+
// Bring it back. This is the whole point: the person must never have to notice.
|
|
424
|
+
scheduleRestart(tool, signal || `exit ${code}`);
|
|
425
|
+
});
|
|
426
|
+
|
|
427
|
+
const rec = supervised.get(tool.id) || { tool, restarts: 0 };
|
|
428
|
+
rec.tool = tool;
|
|
429
|
+
rec.child = child;
|
|
430
|
+
supervised.set(tool.id, rec);
|
|
219
431
|
children.push(child);
|
|
220
432
|
return child;
|
|
221
433
|
}
|
|
222
434
|
|
|
223
435
|
function stopAll() {
|
|
436
|
+
for (const [, s] of supervised) {
|
|
437
|
+
s.stopping = true;
|
|
438
|
+
if (s.timer) clearTimeout(s.timer);
|
|
439
|
+
}
|
|
224
440
|
for (const ch of children) {
|
|
225
441
|
try {
|
|
226
442
|
ch.kill('SIGTERM');
|
|
@@ -245,13 +461,17 @@ process.on('SIGTERM', () => {
|
|
|
245
461
|
if (cmd === 'status') return cmdStatus();
|
|
246
462
|
if (cmd === 'stop') return cmdStop();
|
|
247
463
|
if (cmd === 'doctor' || cmd === 'check') return cmdDoctor();
|
|
464
|
+
if (cmd === 'install' || cmd === 'autostart') return cmdInstall();
|
|
465
|
+
if (cmd === 'uninstall') return cmdUninstall();
|
|
248
466
|
if (cmd === 'help' || cmd === '--help' || cmd === '-h') {
|
|
249
467
|
console.log(`\n${c.blue(c.b(' ◆ Qiksy'))}${c.dim(' — your local engine')}\n`);
|
|
250
468
|
console.log(' npx qiksy start everything (keep the window open)');
|
|
251
469
|
console.log(' npx qiksy status what is running right now');
|
|
252
470
|
console.log(' npx qiksy stop turn it off');
|
|
253
471
|
console.log(' npx qiksy restart turn it off and on again');
|
|
254
|
-
console.log(' npx qiksy doctor check what is wrong
|
|
472
|
+
console.log(' npx qiksy doctor check what is wrong');
|
|
473
|
+
console.log(' npx qiksy install start automatically when you log in');
|
|
474
|
+
console.log(' npx qiksy uninstall stop starting automatically\n');
|
|
255
475
|
return;
|
|
256
476
|
}
|
|
257
477
|
if (cmd === 'restart') {
|
|
@@ -270,8 +490,40 @@ process.on('SIGTERM', () => {
|
|
|
270
490
|
console.log(c.dim(' The tools still open and show you how to connect it.\n'));
|
|
271
491
|
}
|
|
272
492
|
|
|
273
|
-
|
|
493
|
+
// Look before binding: an already-running engine must be reported as good news, and
|
|
494
|
+
// a port held by someone else must be explained, not crashed on.
|
|
495
|
+
const plan = [];
|
|
274
496
|
for (const tool of TOOLS) {
|
|
497
|
+
plan.push({ tool, holder: await whoHolds(tool.port) });
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
const already = plan.filter((p) => p.holder === 'ours');
|
|
501
|
+
const blocked = plan.filter((p) => p.holder === 'stranger');
|
|
502
|
+
|
|
503
|
+
if (already.length === TOOLS.length) {
|
|
504
|
+
console.log(c.green(' ✓ Already running — nothing to do.\n'));
|
|
505
|
+
for (const { tool } of already) console.log(c.dim(` ${tool.name.padEnd(18)} http://localhost:${tool.port}`));
|
|
506
|
+
console.log(c.b('\n Go back to your Qiksy tab — it is connected.'));
|
|
507
|
+
console.log(c.dim(' Turn it off with: npx qiksy stop\n'));
|
|
508
|
+
return;
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
for (const { tool } of already) {
|
|
512
|
+
console.log(c.green(` ✓ ${tool.name.padEnd(18)} already running`));
|
|
513
|
+
}
|
|
514
|
+
for (const { tool } of blocked) {
|
|
515
|
+
console.log(c.amber(` ! ${tool.name.padEnd(18)} port ${tool.port} is used by another program`));
|
|
516
|
+
console.log(c.dim(` ${tool.name} will stay off until that program is closed. Everything else still works.`));
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
const toStart = plan.filter((p) => p.holder === 'free').map((p) => p.tool);
|
|
520
|
+
if (!toStart.length) {
|
|
521
|
+
console.log(c.dim('\n Nothing left to start.\n'));
|
|
522
|
+
return;
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
const started = [];
|
|
526
|
+
for (const tool of toStart) {
|
|
275
527
|
const ch = start(tool);
|
|
276
528
|
if (ch) started.push(tool);
|
|
277
529
|
else console.log(c.dim(` – ${tool.name} is not bundled in this build`));
|
|
@@ -294,9 +546,20 @@ process.on('SIGTERM', () => {
|
|
|
294
546
|
if (ok) up.push(tool);
|
|
295
547
|
}
|
|
296
548
|
|
|
549
|
+
startWatchdog();
|
|
550
|
+
|
|
297
551
|
console.log('');
|
|
298
552
|
console.log(c.b(' Everything is running. Go back to your Qiksy tab — it connects on its own.'));
|
|
299
|
-
|
|
553
|
+
|
|
554
|
+
// Register autostart on the FIRST successful run, without being asked. The target
|
|
555
|
+
// user will not run a second command, and an engine that dies with the window is
|
|
556
|
+
// an engine that "randomly stops working" — the single worst failure for them.
|
|
557
|
+
// Opt out with QIKSY_NO_AUTOSTART=1, undo with `npx qiksy uninstall`.
|
|
558
|
+
if (up.length && !process.env.QIKSY_NO_AUTOSTART && !autostartInstalled()) {
|
|
559
|
+
const ok = cmdInstall({ quiet: true });
|
|
560
|
+
if (ok) console.log(c.dim(' From now on it starts by itself when you log in (undo: npx qiksy uninstall).'));
|
|
561
|
+
}
|
|
562
|
+
console.log(c.dim(' You can close this window — it keeps running. Turn it off: npx qiksy stop\n'));
|
|
300
563
|
|
|
301
564
|
if (up.length && !process.env.QIKSY_NO_OPEN) openBrowser('https://qiksy.app/');
|
|
302
565
|
})();
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "qiksy",
|
|
3
|
-
"version": "1.
|
|
4
|
-
"description": "Qiksy
|
|
3
|
+
"version": "1.1.0",
|
|
4
|
+
"description": "Qiksy \u2014 one local engine for every Qiksy tool (Work Finder, Travel Assistant, Client Finder). Runs on your own Claude; nothing leaves your machine.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
7
7
|
"qiksy": "bin/qiksy.mjs"
|
|
@@ -25,6 +25,11 @@ const SETTINGS_DEFAULTS = {
|
|
|
25
25
|
model: 'sonnet', browserSearch: false, extraInstructions: '',
|
|
26
26
|
plan: 'free', license: '', licenseInfo: null,
|
|
27
27
|
brandName: '', brandContact: '', brandNote: '',
|
|
28
|
+
/* Renewal reminders are ON by default and can be turned OFF here — someone who
|
|
29
|
+
tried Pro for a month and went back to Free should not be nagged forever, and
|
|
30
|
+
the switch belongs next to the plan, not in the sidebar the badge appears in
|
|
31
|
+
(owner decision 2026-07-26). */
|
|
32
|
+
renewalNotice: true,
|
|
28
33
|
};
|
|
29
34
|
|
|
30
35
|
/** Своя группа — «я и моя семья». Есть всегда, создаётся молча: на free человек
|
|
@@ -93,6 +93,9 @@ function billingInfo() {
|
|
|
93
93
|
travelerLimit: isFinite(p.travelerLimit) ? p.travelerLimit : null,
|
|
94
94
|
teamLimit: isFinite(p.travelerLimit) ? p.travelerLimit : null,
|
|
95
95
|
manualPlan: db.settings.plan,
|
|
96
|
+
// Days until the paid term ends (null = nothing paid / no expiry). The UI warns
|
|
97
|
+
// from a week out, because a tool that goes quiet without notice feels broken.
|
|
98
|
+
daysLeft: li && li.paidUntil ? Math.ceil((li.paidUntil - Date.now()) / 86400000) : null,
|
|
96
99
|
license: {
|
|
97
100
|
set: !!db.settings.license,
|
|
98
101
|
key: db.settings.license ? db.settings.license.slice(0, 6) + '…' : '',
|
|
@@ -128,6 +131,18 @@ function rollback(trip, fromStatus, toStatus, activityText) {
|
|
|
128
131
|
|
|
129
132
|
const tripTravelers = trip => trip.travelerIds.map(id => db.team.find(m => m.id === id)).filter(Boolean);
|
|
130
133
|
|
|
134
|
+
/* Когда оплаченный срок кончился, план сам падает на free (lib/license.mjs) — а
|
|
135
|
+
значит НОВАЯ работа по чужим клиентам останавливается: запустить поиск или
|
|
136
|
+
оформление для группы, которая не своя, больше нельзя (решение владельца
|
|
137
|
+
26.07.2026). Уже найденное остаётся на месте: поездки, варианты, отчёты видны и
|
|
138
|
+
копируются. Данные — не заложник, платная — работа. Свои поездки идут как на free. */
|
|
139
|
+
const isOwnParty = trip => {
|
|
140
|
+
const self = db.parties[0];
|
|
141
|
+
return !trip.partyId || !self || trip.partyId === self.id;
|
|
142
|
+
};
|
|
143
|
+
const paidPlan = () => ['pro', 'dev'].includes(effectivePlanId());
|
|
144
|
+
const clientWorkAllowed = trip => paidPlan() || isOwnParty(trip);
|
|
145
|
+
|
|
131
146
|
/* Jobs live in memory, trip statuses live on disk — so a server restart (an edit, a
|
|
132
147
|
crash, a reboot) left trips frozen mid-flight: "searching" forever, with no job
|
|
133
148
|
behind it and nothing but "Cancel trip" to press. Roll those back at boot and say
|
|
@@ -562,6 +577,7 @@ const server = createServer(async (req, res) => {
|
|
|
562
577
|
for (const k of ['brandName', 'brandContact', 'brandNote']) {
|
|
563
578
|
if (k in body) body[k] = String(body[k] || '').slice(0, 400);
|
|
564
579
|
}
|
|
580
|
+
if ('renewalNotice' in body) body.renewalNotice = !!body.renewalNotice;
|
|
565
581
|
// Платные планы включаются лицензией: руками только free/dev.
|
|
566
582
|
if ('plan' in body && !SELF_SETTABLE_PLANS.includes(body.plan)) delete body.plan;
|
|
567
583
|
delete body.license;
|
|
@@ -670,6 +686,7 @@ const server = createServer(async (req, res) => {
|
|
|
670
686
|
if (!['draft', 'options'].includes(t.status)) {
|
|
671
687
|
return json(res, 400, { error: `поиск доступен из статусов draft/options (сейчас: ${t.status})` });
|
|
672
688
|
}
|
|
689
|
+
if (!clientWorkAllowed(t)) return json(res, 402, { error: 'PLAN_REQUIRED' });
|
|
673
690
|
if (!tripTravelers(t).length) return json(res, 400, { error: 'выберите хотя бы одного путника из команды' });
|
|
674
691
|
// повторный поиск перезаписывает прошлый подбор
|
|
675
692
|
t.options = [];
|
|
@@ -690,6 +707,7 @@ const server = createServer(async (req, res) => {
|
|
|
690
707
|
if (!['options', 'ready_to_pay'].includes(t.status)) {
|
|
691
708
|
return json(res, 400, { error: `оформление доступно из статуса options (сейчас: ${t.status})` });
|
|
692
709
|
}
|
|
710
|
+
if (!clientWorkAllowed(t)) return json(res, 402, { error: 'PLAN_REQUIRED' });
|
|
693
711
|
if (!t.options.some(o => o.n === num)) return json(res, 400, { error: `варианта №${num} нет в подборе` });
|
|
694
712
|
if (!db.team.some(m => m.id === t.travelerIds[0])) return json(res, 400, { error: 'у поездки нет путника с карточкой' });
|
|
695
713
|
const prev = t.status;
|