@savepoint/bridge 1.0.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/LICENSE +21 -0
- package/README.md +212 -0
- package/USAGE.md +366 -0
- package/capabilities/printers.json +56 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +73 -0
- package/dist/daemon.d.ts +10 -0
- package/dist/daemon.js +141 -0
- package/dist/discovery.d.ts +8 -0
- package/dist/discovery.js +54 -0
- package/dist/logger.d.ts +9 -0
- package/dist/logger.js +59 -0
- package/dist/logs.d.ts +1 -0
- package/dist/logs.js +30 -0
- package/dist/poller.d.ts +19 -0
- package/dist/poller.js +70 -0
- package/dist/printer.d.ts +18 -0
- package/dist/printer.js +82 -0
- package/dist/registry.d.ts +17 -0
- package/dist/registry.js +98 -0
- package/dist/service.d.ts +2 -0
- package/dist/service.js +114 -0
- package/dist/setup.d.ts +1 -0
- package/dist/setup.js +131 -0
- package/dist/status.d.ts +1 -0
- package/dist/status.js +115 -0
- package/dist/token.d.ts +3 -0
- package/dist/token.js +75 -0
- package/dist/types.d.ts +47 -0
- package/dist/types.js +1 -0
- package/package.json +74 -0
package/dist/registry.js
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import Database from 'better-sqlite3';
|
|
2
|
+
import { mkdirSync, readFileSync } from 'fs';
|
|
3
|
+
import { dirname } from 'path';
|
|
4
|
+
export class Registry {
|
|
5
|
+
db;
|
|
6
|
+
constructor(dbPath) {
|
|
7
|
+
mkdirSync(dirname(dbPath), { recursive: true });
|
|
8
|
+
this.db = new Database(dbPath);
|
|
9
|
+
}
|
|
10
|
+
init() {
|
|
11
|
+
this.db.exec(`
|
|
12
|
+
CREATE TABLE IF NOT EXISTS printers (
|
|
13
|
+
id TEXT PRIMARY KEY,
|
|
14
|
+
name TEXT NOT NULL,
|
|
15
|
+
model TEXT,
|
|
16
|
+
host TEXT,
|
|
17
|
+
port INTEGER,
|
|
18
|
+
connection_type TEXT NOT NULL,
|
|
19
|
+
is_default INTEGER NOT NULL DEFAULT 0
|
|
20
|
+
);
|
|
21
|
+
CREATE TABLE IF NOT EXISTS capabilities (
|
|
22
|
+
model TEXT PRIMARY KEY,
|
|
23
|
+
manufacturer TEXT NOT NULL,
|
|
24
|
+
expected_format TEXT NOT NULL,
|
|
25
|
+
default_port INTEGER,
|
|
26
|
+
vid INTEGER,
|
|
27
|
+
pid INTEGER
|
|
28
|
+
);
|
|
29
|
+
`);
|
|
30
|
+
}
|
|
31
|
+
seedCapabilities(jsonPath) {
|
|
32
|
+
const caps = JSON.parse(readFileSync(jsonPath, 'utf8'));
|
|
33
|
+
const upsert = this.db.prepare(`
|
|
34
|
+
INSERT INTO capabilities (model, manufacturer, expected_format, default_port, vid, pid)
|
|
35
|
+
VALUES (@model, @manufacturer, @expected_format, @default_port, @vid, @pid)
|
|
36
|
+
ON CONFLICT(model) DO UPDATE SET
|
|
37
|
+
manufacturer = excluded.manufacturer,
|
|
38
|
+
expected_format = excluded.expected_format,
|
|
39
|
+
default_port = excluded.default_port,
|
|
40
|
+
vid = excluded.vid,
|
|
41
|
+
pid = excluded.pid
|
|
42
|
+
`);
|
|
43
|
+
const seedAll = this.db.transaction((items) => {
|
|
44
|
+
for (const c of items)
|
|
45
|
+
upsert.run(c);
|
|
46
|
+
});
|
|
47
|
+
seedAll(caps);
|
|
48
|
+
}
|
|
49
|
+
upsertPrinters(printers) {
|
|
50
|
+
const upsert = this.db.prepare(`
|
|
51
|
+
INSERT INTO printers (id, name, model, host, port, connection_type, is_default)
|
|
52
|
+
VALUES (@id, @name, @model, @host, @port, @connectionType, @isDefault)
|
|
53
|
+
ON CONFLICT(id) DO UPDATE SET
|
|
54
|
+
name = excluded.name, model = excluded.model,
|
|
55
|
+
host = excluded.host, port = excluded.port,
|
|
56
|
+
connection_type = excluded.connection_type,
|
|
57
|
+
is_default = excluded.is_default
|
|
58
|
+
`);
|
|
59
|
+
const upsertAll = this.db.transaction((items) => {
|
|
60
|
+
for (const p of items)
|
|
61
|
+
upsert.run({ ...p, isDefault: p.isDefault ? 1 : 0 });
|
|
62
|
+
});
|
|
63
|
+
upsertAll(printers);
|
|
64
|
+
}
|
|
65
|
+
getPrinter(id) {
|
|
66
|
+
const row = this.db.prepare(`SELECT id, name, model, host, port, connection_type as connectionType, is_default as isDefault
|
|
67
|
+
FROM printers WHERE id = ?`).get(id);
|
|
68
|
+
return row ?? null;
|
|
69
|
+
}
|
|
70
|
+
getExpectedFormat(printerId) {
|
|
71
|
+
const printer = this.getPrinter(printerId);
|
|
72
|
+
if (!printer?.model)
|
|
73
|
+
return null;
|
|
74
|
+
const cap = this.db.prepare(`SELECT expected_format FROM capabilities WHERE model = ?`).get(printer.model);
|
|
75
|
+
return cap?.expected_format ?? null;
|
|
76
|
+
}
|
|
77
|
+
validateFormat(printerId, jobFormat) {
|
|
78
|
+
const printer = this.getPrinter(printerId);
|
|
79
|
+
if (!printer) {
|
|
80
|
+
return { code: 'PRINTER_NOT_FOUND', message: `Printer ${printerId} not found in local registry.` };
|
|
81
|
+
}
|
|
82
|
+
const expected = this.getExpectedFormat(printerId);
|
|
83
|
+
if (expected && expected !== jobFormat) {
|
|
84
|
+
return {
|
|
85
|
+
code: 'FORMAT_MISMATCH',
|
|
86
|
+
message: `Printer "${printer.name}" expects ${expected.toUpperCase()} but job format is ${jobFormat.toUpperCase()}. ` +
|
|
87
|
+
`Update the printer in SavePoint > Settings > Printers.`,
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
return null;
|
|
91
|
+
}
|
|
92
|
+
close() { this.db.close(); }
|
|
93
|
+
listPrinters() {
|
|
94
|
+
return this.db.prepare(`SELECT id, name, model, host, port, connection_type as connectionType, is_default as isDefault
|
|
95
|
+
FROM printers
|
|
96
|
+
ORDER BY is_default DESC, name ASC`).all();
|
|
97
|
+
}
|
|
98
|
+
}
|
package/dist/service.js
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import { writeFileSync, mkdirSync, unlinkSync, existsSync } from 'fs';
|
|
2
|
+
import { execFile, execFileSync } from 'child_process';
|
|
3
|
+
import { promisify } from 'util';
|
|
4
|
+
import { homedir, userInfo } from 'os';
|
|
5
|
+
import { join } from 'path';
|
|
6
|
+
import { log } from './logger.js';
|
|
7
|
+
const execFileAsync = promisify(execFile);
|
|
8
|
+
function getBinaryPath() {
|
|
9
|
+
const locator = process.platform === 'win32' ? 'where' : 'which';
|
|
10
|
+
try {
|
|
11
|
+
return execFileSync(locator, ['savepoint-bridge'], { encoding: 'utf8' }).trim().split(/\r?\n/)[0];
|
|
12
|
+
}
|
|
13
|
+
catch {
|
|
14
|
+
return 'savepoint-bridge';
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
async function installMacOS() {
|
|
18
|
+
const plistDir = join(homedir(), 'Library', 'LaunchAgents');
|
|
19
|
+
const plistPath = join(plistDir, 'com.savepoint.bridge.plist');
|
|
20
|
+
const binary = getBinaryPath();
|
|
21
|
+
const logDir = join(homedir(), '.savepoint-bridge', 'logs');
|
|
22
|
+
mkdirSync(plistDir, { recursive: true });
|
|
23
|
+
mkdirSync(logDir, { recursive: true });
|
|
24
|
+
writeFileSync(plistPath, `<?xml version="1.0" encoding="UTF-8"?>
|
|
25
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
26
|
+
<plist version="1.0">
|
|
27
|
+
<dict>
|
|
28
|
+
<key>Label</key><string>com.savepoint.bridge</string>
|
|
29
|
+
<key>ProgramArguments</key>
|
|
30
|
+
<array><string>${binary}</string><string>start</string></array>
|
|
31
|
+
<key>RunAtLoad</key><true/>
|
|
32
|
+
<key>KeepAlive</key><true/>
|
|
33
|
+
<key>StandardOutPath</key><string>${logDir}/stdout.log</string>
|
|
34
|
+
<key>StandardErrorPath</key><string>${logDir}/stderr.log</string>
|
|
35
|
+
</dict>
|
|
36
|
+
</plist>`);
|
|
37
|
+
// execFile with array args — no shell injection possible
|
|
38
|
+
await execFileAsync('launchctl', ['load', plistPath]);
|
|
39
|
+
log.success('LaunchAgent installed');
|
|
40
|
+
}
|
|
41
|
+
async function installLinux() {
|
|
42
|
+
const unitDir = join(homedir(), '.config', 'systemd', 'user');
|
|
43
|
+
const unitPath = join(unitDir, 'savepoint-bridge.service');
|
|
44
|
+
const binary = getBinaryPath();
|
|
45
|
+
mkdirSync(unitDir, { recursive: true });
|
|
46
|
+
writeFileSync(unitPath, `[Unit]
|
|
47
|
+
Description=SavePoint Bridge
|
|
48
|
+
After=network-online.target
|
|
49
|
+
|
|
50
|
+
[Service]
|
|
51
|
+
ExecStart="${binary}" start
|
|
52
|
+
Restart=on-failure
|
|
53
|
+
RestartSec=5s
|
|
54
|
+
|
|
55
|
+
[Install]
|
|
56
|
+
WantedBy=default.target
|
|
57
|
+
`);
|
|
58
|
+
await execFileAsync('systemctl', ['--user', 'daemon-reload']);
|
|
59
|
+
await execFileAsync('systemctl', ['--user', 'enable', '--now', 'savepoint-bridge']);
|
|
60
|
+
try {
|
|
61
|
+
const username = userInfo().username;
|
|
62
|
+
await execFileAsync('loginctl', ['enable-linger', username]);
|
|
63
|
+
log.info(`loginctl enable-linger applied for ${username} — bridge starts at boot`);
|
|
64
|
+
}
|
|
65
|
+
catch {
|
|
66
|
+
log.warn('Could not enable loginctl linger — bridge may not start at boot on headless systems');
|
|
67
|
+
}
|
|
68
|
+
log.success('systemd user service installed');
|
|
69
|
+
}
|
|
70
|
+
async function installWindows() {
|
|
71
|
+
const binary = getBinaryPath();
|
|
72
|
+
// schtasks args passed as array — binary path not shell-interpolated
|
|
73
|
+
await execFileAsync('schtasks', [
|
|
74
|
+
'/Create', '/F',
|
|
75
|
+
'/TN', 'SavePoint Bridge',
|
|
76
|
+
'/TR', `"${binary}" start`,
|
|
77
|
+
'/SC', 'ONLOGON',
|
|
78
|
+
'/RL', 'LIMITED',
|
|
79
|
+
]);
|
|
80
|
+
await execFileAsync('schtasks', ['/Run', '/TN', 'SavePoint Bridge']);
|
|
81
|
+
log.success('Scheduled Task installed');
|
|
82
|
+
}
|
|
83
|
+
export async function installService() {
|
|
84
|
+
const { platform } = process;
|
|
85
|
+
if (platform === 'darwin')
|
|
86
|
+
await installMacOS();
|
|
87
|
+
else if (platform === 'linux')
|
|
88
|
+
await installLinux();
|
|
89
|
+
else if (platform === 'win32')
|
|
90
|
+
await installWindows();
|
|
91
|
+
else
|
|
92
|
+
throw new Error(`Unsupported platform: ${platform}`);
|
|
93
|
+
}
|
|
94
|
+
export async function uninstallService() {
|
|
95
|
+
const { platform } = process;
|
|
96
|
+
if (platform === 'darwin') {
|
|
97
|
+
const plistPath = join(homedir(), 'Library', 'LaunchAgents', 'com.savepoint.bridge.plist');
|
|
98
|
+
if (existsSync(plistPath)) {
|
|
99
|
+
await execFileAsync('launchctl', ['unload', plistPath]);
|
|
100
|
+
unlinkSync(plistPath);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
else if (platform === 'linux') {
|
|
104
|
+
await execFileAsync('systemctl', ['--user', 'disable', '--now', 'savepoint-bridge']).catch(() => { });
|
|
105
|
+
const unitPath = join(homedir(), '.config', 'systemd', 'user', 'savepoint-bridge.service');
|
|
106
|
+
if (existsSync(unitPath))
|
|
107
|
+
unlinkSync(unitPath);
|
|
108
|
+
await execFileAsync('systemctl', ['--user', 'daemon-reload']).catch(() => { });
|
|
109
|
+
}
|
|
110
|
+
else if (platform === 'win32') {
|
|
111
|
+
await execFileAsync('schtasks', ['/Delete', '/TN', 'SavePoint Bridge', '/F']).catch(() => { });
|
|
112
|
+
}
|
|
113
|
+
log.success('Service uninstalled');
|
|
114
|
+
}
|
package/dist/setup.d.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function runSetup(args: string[]): Promise<void>;
|
package/dist/setup.js
ADDED
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import * as p from '@clack/prompts';
|
|
2
|
+
import pc from 'picocolors';
|
|
3
|
+
import { saveToken } from './token.js';
|
|
4
|
+
import { discoverPrinters } from './discovery.js';
|
|
5
|
+
import { installService } from './service.js';
|
|
6
|
+
import { log } from './logger.js';
|
|
7
|
+
const BASE_URL = process.env.SAVEPOINT_API_URL ?? 'https://api.savepointhq.com';
|
|
8
|
+
const VERSION = '1.0.0';
|
|
9
|
+
function parseFlags(args) {
|
|
10
|
+
const flags = {};
|
|
11
|
+
for (const arg of args) {
|
|
12
|
+
if (arg.startsWith('--')) {
|
|
13
|
+
const [key, val] = arg.slice(2).split('=');
|
|
14
|
+
flags[key] = val ?? true;
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
return flags;
|
|
18
|
+
}
|
|
19
|
+
export async function runSetup(args) {
|
|
20
|
+
const flags = parseFlags(args);
|
|
21
|
+
const nonInteractive = flags['non-interactive'] === true;
|
|
22
|
+
const noService = flags['no-service'] === true;
|
|
23
|
+
const tokenFlag = typeof flags['token'] === 'string' ? flags['token'] : null;
|
|
24
|
+
console.log();
|
|
25
|
+
console.log(pc.bold('╔══════════════════════════════════════════════════╗'));
|
|
26
|
+
console.log(pc.bold('║ SavePoint Bridge v' + VERSION.padEnd(22) + '║'));
|
|
27
|
+
console.log(pc.bold('║ Local hardware connector for SavePoint ║'));
|
|
28
|
+
console.log(pc.bold('╚══════════════════════════════════════════════════╝'));
|
|
29
|
+
console.log(`
|
|
30
|
+
What this does:
|
|
31
|
+
- Connects your local printers to SavePoint
|
|
32
|
+
- Runs quietly in the background -- starts automatically on login
|
|
33
|
+
- Communicates outbound only to api.savepointhq.com
|
|
34
|
+
(no ports are opened on your network or firewall)
|
|
35
|
+
|
|
36
|
+
What it stores on this computer:
|
|
37
|
+
- Your agent token (stored in your OS keychain -- not a plain text file)
|
|
38
|
+
- A local printer registry (SQLite in your app data folder)
|
|
39
|
+
- A rolling log file (last 7 days, local only)
|
|
40
|
+
|
|
41
|
+
You can revoke access at any time from:
|
|
42
|
+
SavePoint > Settings > Bridge > Revoke
|
|
43
|
+
`);
|
|
44
|
+
if (!nonInteractive) {
|
|
45
|
+
const proceed = await p.confirm({ message: 'Continue with setup?' });
|
|
46
|
+
if (p.isCancel(proceed) || !proceed) {
|
|
47
|
+
console.log('Setup cancelled.');
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
p.intro(pc.bold('Step 1 of 3 -- Agent Token'));
|
|
52
|
+
let token;
|
|
53
|
+
if (tokenFlag) {
|
|
54
|
+
token = tokenFlag;
|
|
55
|
+
log.info(`Using token from --token flag (prefix: ${token.slice(0, 8)}...)`);
|
|
56
|
+
}
|
|
57
|
+
else {
|
|
58
|
+
const input = await p.text({
|
|
59
|
+
message: 'Open SavePoint > Settings > Bridge > Add Bridge and paste your token:',
|
|
60
|
+
placeholder: 'Paste token here...',
|
|
61
|
+
validate: v => v.length < 32 ? 'Token looks too short -- check you copied the full value' : undefined,
|
|
62
|
+
});
|
|
63
|
+
if (p.isCancel(input)) {
|
|
64
|
+
console.log('Setup cancelled.');
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
token = input;
|
|
68
|
+
}
|
|
69
|
+
const spinner = p.spinner();
|
|
70
|
+
spinner.start('Verifying token...');
|
|
71
|
+
try {
|
|
72
|
+
const res = await fetch(`${BASE_URL}/api/agents/heartbeat`, {
|
|
73
|
+
method: 'POST',
|
|
74
|
+
headers: {
|
|
75
|
+
Authorization: `Bearer ${token}`,
|
|
76
|
+
'X-Agent-Version': VERSION,
|
|
77
|
+
'Content-Type': 'application/json',
|
|
78
|
+
},
|
|
79
|
+
body: JSON.stringify({ version: VERSION }),
|
|
80
|
+
});
|
|
81
|
+
if (!res.ok) {
|
|
82
|
+
spinner.stop(pc.red('Token verification failed -- check the token and try again'));
|
|
83
|
+
process.exit(1);
|
|
84
|
+
}
|
|
85
|
+
spinner.stop(pc.green('Token verified'));
|
|
86
|
+
}
|
|
87
|
+
catch (e) {
|
|
88
|
+
spinner.stop(pc.red(`Could not reach ${BASE_URL}`));
|
|
89
|
+
if (e instanceof Error && e.message.toLowerCase().includes('certificate')) {
|
|
90
|
+
console.log(pc.yellow('\nSSL error -- if your network uses an SSL proxy, set:'));
|
|
91
|
+
console.log(pc.yellow(' NODE_EXTRA_CA_CERTS=/path/to/proxy-ca.crt savepoint-bridge setup\n'));
|
|
92
|
+
}
|
|
93
|
+
process.exit(1);
|
|
94
|
+
}
|
|
95
|
+
await saveToken(token);
|
|
96
|
+
p.intro(pc.bold('Step 2 of 3 -- Printers'));
|
|
97
|
+
const discoverSpinner = p.spinner();
|
|
98
|
+
discoverSpinner.start('Scanning for printers on your network and local CUPS queues...');
|
|
99
|
+
const printers = await discoverPrinters();
|
|
100
|
+
if (printers.length === 0) {
|
|
101
|
+
discoverSpinner.stop('No printers found automatically');
|
|
102
|
+
console.log(pc.dim(' Add printers manually in SavePoint > Settings > Printers'));
|
|
103
|
+
}
|
|
104
|
+
else {
|
|
105
|
+
discoverSpinner.stop(`Found ${printers.length} printer${printers.length > 1 ? 's' : ''}:`);
|
|
106
|
+
for (const pr of printers) {
|
|
107
|
+
const loc = pr.host ? `${pr.host}:${pr.port}` : pr.name;
|
|
108
|
+
console.log(pc.green(' v'), pr.name, pc.dim(`(${loc})`));
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
if (!noService) {
|
|
112
|
+
p.intro(pc.bold('Step 3 of 3 -- Background Service'));
|
|
113
|
+
const svcSpinner = p.spinner();
|
|
114
|
+
svcSpinner.start('Installing as a background service...');
|
|
115
|
+
try {
|
|
116
|
+
await installService();
|
|
117
|
+
svcSpinner.stop(pc.green('Service installed and started'));
|
|
118
|
+
}
|
|
119
|
+
catch (e) {
|
|
120
|
+
svcSpinner.stop(pc.yellow('Service install failed -- run manually with: savepoint-bridge start'));
|
|
121
|
+
log.warn('Service install error', e);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
p.outro(pc.bold(pc.green('All done. SavePoint Bridge is running.')));
|
|
125
|
+
console.log(`
|
|
126
|
+
Next steps:
|
|
127
|
+
- Print a test label from SavePoint > Settings > Printers
|
|
128
|
+
- Run ${pc.cyan('savepoint-bridge status')} to check anytime
|
|
129
|
+
- Run ${pc.cyan('savepoint-bridge logs')} to see recent activity
|
|
130
|
+
`);
|
|
131
|
+
}
|
package/dist/status.d.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function showStatus(): Promise<void>;
|
package/dist/status.js
ADDED
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from 'fs';
|
|
2
|
+
import { execFile } from 'child_process';
|
|
3
|
+
import { promisify } from 'util';
|
|
4
|
+
import { homedir } from 'os';
|
|
5
|
+
import { join } from 'path';
|
|
6
|
+
import { loadToken } from './token.js';
|
|
7
|
+
import { DATA_PATH } from './logger.js';
|
|
8
|
+
import { Registry } from './registry.js';
|
|
9
|
+
const execFileAsync = promisify(execFile);
|
|
10
|
+
const REGISTRY_PATH = join(DATA_PATH, 'registry.db');
|
|
11
|
+
function getServiceArtifactPath() {
|
|
12
|
+
if (process.platform === 'darwin') {
|
|
13
|
+
return join(homedir(), 'Library', 'LaunchAgents', 'com.savepoint.bridge.plist');
|
|
14
|
+
}
|
|
15
|
+
if (process.platform === 'linux') {
|
|
16
|
+
return join(homedir(), '.config', 'systemd', 'user', 'savepoint-bridge.service');
|
|
17
|
+
}
|
|
18
|
+
return null;
|
|
19
|
+
}
|
|
20
|
+
async function getServiceState() {
|
|
21
|
+
if (process.platform === 'darwin') {
|
|
22
|
+
const path = getServiceArtifactPath();
|
|
23
|
+
const installed = existsSync(path);
|
|
24
|
+
if (!installed) {
|
|
25
|
+
return { installed: false, running: false, detail: 'LaunchAgent not installed' };
|
|
26
|
+
}
|
|
27
|
+
try {
|
|
28
|
+
await execFileAsync('launchctl', ['list', 'com.savepoint.bridge']);
|
|
29
|
+
return { installed: true, running: true, detail: 'launchd service loaded' };
|
|
30
|
+
}
|
|
31
|
+
catch {
|
|
32
|
+
return { installed: true, running: false, detail: 'LaunchAgent installed but not loaded' };
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
if (process.platform === 'linux') {
|
|
36
|
+
const path = getServiceArtifactPath();
|
|
37
|
+
const installed = existsSync(path);
|
|
38
|
+
if (!installed) {
|
|
39
|
+
return { installed: false, running: false, detail: 'systemd user unit not installed' };
|
|
40
|
+
}
|
|
41
|
+
try {
|
|
42
|
+
const { stdout } = await execFileAsync('systemctl', ['--user', 'is-active', 'savepoint-bridge']);
|
|
43
|
+
const running = stdout.trim() === 'active';
|
|
44
|
+
return {
|
|
45
|
+
installed: true,
|
|
46
|
+
running,
|
|
47
|
+
detail: running ? 'systemd user service active' : `systemd state: ${stdout.trim() || 'inactive'}`,
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
catch {
|
|
51
|
+
return { installed: true, running: false, detail: 'systemd user service inactive' };
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
if (process.platform === 'win32') {
|
|
55
|
+
try {
|
|
56
|
+
const { stdout } = await execFileAsync('schtasks', ['/Query', '/TN', 'SavePoint Bridge']);
|
|
57
|
+
return {
|
|
58
|
+
installed: true,
|
|
59
|
+
running: /SavePoint Bridge/i.test(stdout),
|
|
60
|
+
detail: 'Scheduled Task is registered',
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
catch {
|
|
64
|
+
return { installed: false, running: false, detail: 'Scheduled Task not installed' };
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
return { installed: false, running: false, detail: `Unsupported platform: ${process.platform}` };
|
|
68
|
+
}
|
|
69
|
+
function readPrinterSummary() {
|
|
70
|
+
if (!existsSync(REGISTRY_PATH)) {
|
|
71
|
+
return { printerCount: 0, defaultPrinter: null };
|
|
72
|
+
}
|
|
73
|
+
const registry = new Registry(REGISTRY_PATH);
|
|
74
|
+
try {
|
|
75
|
+
const printers = registry.listPrinters();
|
|
76
|
+
const defaultPrinter = printers.find((printer) => printer.isDefault)?.name ?? null;
|
|
77
|
+
return { printerCount: printers.length, defaultPrinter };
|
|
78
|
+
}
|
|
79
|
+
finally {
|
|
80
|
+
registry.close();
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
function readRecentErrorCount() {
|
|
84
|
+
const logDir = join(DATA_PATH, 'logs');
|
|
85
|
+
if (!existsSync(logDir))
|
|
86
|
+
return 0;
|
|
87
|
+
try {
|
|
88
|
+
const file = join(logDir, `${new Date().toISOString().slice(0, 10)}.log`);
|
|
89
|
+
if (!existsSync(file))
|
|
90
|
+
return 0;
|
|
91
|
+
return readFileSync(file, 'utf8')
|
|
92
|
+
.trim()
|
|
93
|
+
.split('\n')
|
|
94
|
+
.filter(Boolean)
|
|
95
|
+
.map((line) => JSON.parse(line))
|
|
96
|
+
.filter((entry) => entry.level === 'error')
|
|
97
|
+
.length;
|
|
98
|
+
}
|
|
99
|
+
catch {
|
|
100
|
+
return 0;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
export async function showStatus() {
|
|
104
|
+
const [service, token] = await Promise.all([getServiceState(), loadToken()]);
|
|
105
|
+
const { printerCount, defaultPrinter } = readPrinterSummary();
|
|
106
|
+
const errorCount = readRecentErrorCount();
|
|
107
|
+
const tokenPrefix = token ? `${token.slice(0, 10)}...` : null;
|
|
108
|
+
console.log('SavePoint Bridge status');
|
|
109
|
+
console.log(` service: ${service.running ? 'running' : service.installed ? 'installed' : 'not installed'}`);
|
|
110
|
+
console.log(` detail: ${service.detail}`);
|
|
111
|
+
console.log(` token: ${tokenPrefix ?? 'missing'}`);
|
|
112
|
+
console.log(` local printers: ${printerCount}`);
|
|
113
|
+
console.log(` default printer: ${defaultPrinter ?? 'none'}`);
|
|
114
|
+
console.log(` today errors: ${errorCount}`);
|
|
115
|
+
}
|
package/dist/token.d.ts
ADDED
package/dist/token.js
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { createCipheriv, createDecipheriv, randomBytes, createHash } from 'crypto';
|
|
2
|
+
import { readFileSync, writeFileSync, unlinkSync, mkdirSync } from 'fs';
|
|
3
|
+
import { join } from 'path';
|
|
4
|
+
import { DATA_PATH, log } from './logger.js';
|
|
5
|
+
const SERVICE = 'savepoint-bridge';
|
|
6
|
+
const ACCOUNT = 'agent-token';
|
|
7
|
+
const FALLBACK_PATH = join(DATA_PATH, '.token');
|
|
8
|
+
/** Derives a machine-stable AES-256 key from the home directory path */
|
|
9
|
+
function deriveFallbackKey() {
|
|
10
|
+
const seed = `${process.env.HOME ?? process.env.USERPROFILE ?? ''}:${process.platform}`;
|
|
11
|
+
return createHash('sha256').update(seed).digest();
|
|
12
|
+
}
|
|
13
|
+
async function trykeytar() {
|
|
14
|
+
try {
|
|
15
|
+
return await import('@postman/node-keytar');
|
|
16
|
+
}
|
|
17
|
+
catch {
|
|
18
|
+
return null;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
export async function saveToken(token) {
|
|
22
|
+
const keytar = await trykeytar();
|
|
23
|
+
if (keytar) {
|
|
24
|
+
try {
|
|
25
|
+
await keytar.setPassword(SERVICE, ACCOUNT, token);
|
|
26
|
+
return;
|
|
27
|
+
}
|
|
28
|
+
catch (e) {
|
|
29
|
+
log.warn('OS keychain unavailable — using encrypted file fallback', e);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
mkdirSync(DATA_PATH, { recursive: true });
|
|
33
|
+
const key = deriveFallbackKey();
|
|
34
|
+
const iv = randomBytes(16);
|
|
35
|
+
const cipher = createCipheriv('aes-256-gcm', key, iv);
|
|
36
|
+
const encrypted = Buffer.concat([cipher.update(token, 'utf8'), cipher.final()]);
|
|
37
|
+
const tag = cipher.getAuthTag();
|
|
38
|
+
writeFileSync(FALLBACK_PATH, Buffer.concat([iv, tag, encrypted]), { mode: 0o600 });
|
|
39
|
+
log.warn('Token stored in encrypted local file. Install gnome-keyring for better security.');
|
|
40
|
+
}
|
|
41
|
+
export async function loadToken() {
|
|
42
|
+
const keytar = await trykeytar();
|
|
43
|
+
if (keytar) {
|
|
44
|
+
try {
|
|
45
|
+
return await keytar.getPassword(SERVICE, ACCOUNT);
|
|
46
|
+
}
|
|
47
|
+
catch { /* fall through */ }
|
|
48
|
+
}
|
|
49
|
+
try {
|
|
50
|
+
const raw = readFileSync(FALLBACK_PATH);
|
|
51
|
+
const key = deriveFallbackKey();
|
|
52
|
+
const iv = raw.subarray(0, 16);
|
|
53
|
+
const tag = raw.subarray(16, 32);
|
|
54
|
+
const encrypted = raw.subarray(32);
|
|
55
|
+
const decipher = createDecipheriv('aes-256-gcm', key, iv);
|
|
56
|
+
decipher.setAuthTag(tag);
|
|
57
|
+
return decipher.update(encrypted) + decipher.final('utf8');
|
|
58
|
+
}
|
|
59
|
+
catch {
|
|
60
|
+
return null;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
export async function clearToken() {
|
|
64
|
+
const keytar = await trykeytar();
|
|
65
|
+
if (keytar) {
|
|
66
|
+
try {
|
|
67
|
+
await keytar.deletePassword(SERVICE, ACCOUNT);
|
|
68
|
+
}
|
|
69
|
+
catch { /* ignore */ }
|
|
70
|
+
}
|
|
71
|
+
try {
|
|
72
|
+
unlinkSync(FALLBACK_PATH);
|
|
73
|
+
}
|
|
74
|
+
catch { /* ignore */ }
|
|
75
|
+
}
|
package/dist/types.d.ts
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
export type JobType = 'label' | 'receipt' | 'document' | 'test';
|
|
2
|
+
export type JobStatus = 'queued' | 'processing' | 'completed' | 'failed' | 'cancelled';
|
|
3
|
+
export type ConnectionType = 'network' | 'usb' | 'system';
|
|
4
|
+
export interface JobResult {
|
|
5
|
+
code?: string;
|
|
6
|
+
message?: string;
|
|
7
|
+
attempts?: number;
|
|
8
|
+
http_status?: number;
|
|
9
|
+
[key: string]: unknown;
|
|
10
|
+
}
|
|
11
|
+
export interface PrintJobMetadata {
|
|
12
|
+
format?: 'zpl' | 'esc-p' | 'pdf' | 'raw';
|
|
13
|
+
copies?: number;
|
|
14
|
+
[key: string]: unknown;
|
|
15
|
+
}
|
|
16
|
+
export interface PrintJob {
|
|
17
|
+
id: string;
|
|
18
|
+
tenant_id: string;
|
|
19
|
+
printer_id: string | null;
|
|
20
|
+
job_type: JobType;
|
|
21
|
+
content_url: string | null;
|
|
22
|
+
metadata: PrintJobMetadata;
|
|
23
|
+
status: JobStatus;
|
|
24
|
+
claimed_by: string | null;
|
|
25
|
+
claimed_at: string | null;
|
|
26
|
+
created_at: string;
|
|
27
|
+
updated_at: string;
|
|
28
|
+
}
|
|
29
|
+
export interface Printer {
|
|
30
|
+
id: string;
|
|
31
|
+
name: string;
|
|
32
|
+
model: string | null;
|
|
33
|
+
host: string | null;
|
|
34
|
+
port: number | null;
|
|
35
|
+
connectionType: ConnectionType;
|
|
36
|
+
protocol: string | null;
|
|
37
|
+
isDefault: boolean;
|
|
38
|
+
}
|
|
39
|
+
export interface PrinterCapability {
|
|
40
|
+
model: string;
|
|
41
|
+
manufacturer: string;
|
|
42
|
+
expected_format: 'zpl' | 'esc-p' | 'pdf' | 'raw';
|
|
43
|
+
default_port: number;
|
|
44
|
+
connection_types: ConnectionType[];
|
|
45
|
+
vid?: number;
|
|
46
|
+
pid?: number;
|
|
47
|
+
}
|
package/dist/types.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/package.json
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@savepoint/bridge",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"private": false,
|
|
5
|
+
"description": "SavePoint Bridge hardware daemon",
|
|
6
|
+
"keywords": [
|
|
7
|
+
"savepoint",
|
|
8
|
+
"pos",
|
|
9
|
+
"bridge",
|
|
10
|
+
"hardware",
|
|
11
|
+
"daemon"
|
|
12
|
+
],
|
|
13
|
+
"homepage": "https://savepointhq.com",
|
|
14
|
+
"bugs": {
|
|
15
|
+
"url": "https://github.com/doublexl-digital/savepoint-hq/issues"
|
|
16
|
+
},
|
|
17
|
+
"repository": {
|
|
18
|
+
"type": "git",
|
|
19
|
+
"url": "git+https://github.com/doublexl-digital/savepoint-hq.git",
|
|
20
|
+
"directory": "apps/bridge"
|
|
21
|
+
},
|
|
22
|
+
"license": "MIT",
|
|
23
|
+
"author": "SavePoint",
|
|
24
|
+
"type": "module",
|
|
25
|
+
"main": "dist/cli.js",
|
|
26
|
+
"types": "./dist/cli.d.ts",
|
|
27
|
+
"bin": {
|
|
28
|
+
"savepoint-bridge": "dist/cli.js"
|
|
29
|
+
},
|
|
30
|
+
"files": [
|
|
31
|
+
"dist",
|
|
32
|
+
"capabilities",
|
|
33
|
+
"LICENSE",
|
|
34
|
+
"README.md",
|
|
35
|
+
"USAGE.md"
|
|
36
|
+
],
|
|
37
|
+
"scripts": {
|
|
38
|
+
"build": "tsc",
|
|
39
|
+
"dev": "tsx src/cli.js",
|
|
40
|
+
"test": "vitest run",
|
|
41
|
+
"test:watch": "vitest",
|
|
42
|
+
"type-check": "tsc --noEmit",
|
|
43
|
+
"check:release": "npm run type-check && npm run test",
|
|
44
|
+
"prepublishOnly": "npm run build && npm run check:release",
|
|
45
|
+
"build:binaries": "electron-builder -mwl"
|
|
46
|
+
},
|
|
47
|
+
"dependencies": {
|
|
48
|
+
"@clack/prompts": "^0.9.1",
|
|
49
|
+
"@postman/node-keytar": "^7.9.0",
|
|
50
|
+
"better-sqlite3": "^12.8.0",
|
|
51
|
+
"picocolors": "^1.1.1"
|
|
52
|
+
},
|
|
53
|
+
"devDependencies": {
|
|
54
|
+
"@types/better-sqlite3": "^7.6.12",
|
|
55
|
+
"@types/node": "^20.10.0",
|
|
56
|
+
"electron": "^41.0.4",
|
|
57
|
+
"electron-builder": "^26.8.1",
|
|
58
|
+
"tsx": "^4.7.0",
|
|
59
|
+
"typescript": "^5.3.0",
|
|
60
|
+
"vitest": "^2.0.0"
|
|
61
|
+
},
|
|
62
|
+
"engines": {
|
|
63
|
+
"node": ">=18.0.0"
|
|
64
|
+
},
|
|
65
|
+
"os": [
|
|
66
|
+
"darwin",
|
|
67
|
+
"linux",
|
|
68
|
+
"win32"
|
|
69
|
+
],
|
|
70
|
+
"publishConfig": {
|
|
71
|
+
"access": "public"
|
|
72
|
+
},
|
|
73
|
+
"preferGlobal": true
|
|
74
|
+
}
|