@savepoint/bridge 1.0.0 → 1.0.4

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/dist/cli.js CHANGED
@@ -1,6 +1,6 @@
1
1
  #!/usr/bin/env node
2
2
  import pc from 'picocolors';
3
- const VERSION = '1.0.0';
3
+ const VERSION = '1.0.4';
4
4
  const [, , command, ...args] = process.argv;
5
5
  const COMMANDS = {
6
6
  setup: 'Interactive guided setup wizard',
package/dist/daemon.js CHANGED
@@ -7,8 +7,8 @@ import { discoverPrinters } from './discovery.js';
7
7
  import { join } from 'path';
8
8
  import { fileURLToPath } from 'url';
9
9
  import { DATA_PATH } from './logger.js';
10
- const BASE_URL = process.env.SAVEPOINT_API_URL ?? 'https://api.savepointhq.com';
11
- const VERSION = '1.0.0';
10
+ const BASE_URL = process.env.SAVEPOINT_APP_URL ?? process.env.SAVEPOINT_API_URL ?? 'https://app.savepointhq.com';
11
+ const VERSION = '1.0.4';
12
12
  const DB_PATH = join(DATA_PATH, 'registry.db');
13
13
  const CAPS_PATH = fileURLToPath(new URL('../../capabilities/printers.json', import.meta.url));
14
14
  const REGISTRY_REFRESH_MS = 5 * 60 * 1000;
package/dist/discovery.js CHANGED
@@ -3,6 +3,8 @@ import { networkInterfaces } from 'os';
3
3
  import { execFile } from 'child_process';
4
4
  import { promisify } from 'util';
5
5
  const execFileAsync = promisify(execFile);
6
+ // Ports probed per host — ordered by likelihood for label/receipt printers
7
+ const PRINTER_PORTS = [9100, 631, 515];
6
8
  function probePort(host, port, timeoutMs = 1500) {
7
9
  return new Promise(resolve => {
8
10
  const socket = createConnection({ host, port });
@@ -23,15 +25,67 @@ function getSubnetHosts() {
23
25
  }
24
26
  return [];
25
27
  }
26
- async function scanNetwork(concurrency = 50) {
28
+ /** Probe a single host on all printer ports; returns first open port found or null */
29
+ async function probeHost(host) {
30
+ for (const port of PRINTER_PORTS) {
31
+ if (await probePort(host, port)) {
32
+ return { name: host, host, port, connection_type: 'network' };
33
+ }
34
+ }
35
+ return null;
36
+ }
37
+ async function scanNetwork(concurrency = 30) {
27
38
  const hosts = getSubnetHosts();
28
39
  const found = [];
29
40
  for (let i = 0; i < hosts.length; i += concurrency) {
30
- await Promise.allSettled(hosts.slice(i, i + concurrency).map(async (host) => {
31
- if (await probePort(host, 9100)) {
32
- found.push({ name: host, host, port: 9100, connection_type: 'network' });
41
+ const batch = await Promise.allSettled(hosts.slice(i, i + concurrency).map(host => probeHost(host)));
42
+ for (const result of batch) {
43
+ if (result.status === 'fulfilled' && result.value) {
44
+ found.push(result.value);
33
45
  }
34
- }));
46
+ }
47
+ }
48
+ return found;
49
+ }
50
+ /**
51
+ * mDNS/Bonjour discovery — resolves printer names and models on the LAN.
52
+ * Uses `dns-sd` on macOS and `avahi-browse` on Linux; no-ops silently on Windows.
53
+ */
54
+ async function scanMdns() {
55
+ const found = [];
56
+ if (process.platform === 'darwin') {
57
+ try {
58
+ // Browse for IPP and socket (raw) printer services for 5 seconds
59
+ const { stdout } = await execFileAsync('dns-sd', ['-B', '_ipp._tcp,_pdl-datastream._tcp', 'local.'], { timeout: 5000 }).catch(() => execFileAsync('dns-sd', ['-B', '_ipp._tcp', 'local.'], { timeout: 5000 }));
60
+ for (const line of stdout.split('\n')) {
61
+ // Lines look like: "Add 2 1 local. _ipp._tcp. My Printer"
62
+ const match = line.match(/Add\s+\d+\s+\d+\s+\S+\s+\S+\s+(.+)/);
63
+ if (match) {
64
+ const name = match[1].trim();
65
+ if (name)
66
+ found.push({ name, connection_type: 'network' });
67
+ }
68
+ }
69
+ }
70
+ catch { /* dns-sd timeout or unavailable */ }
71
+ }
72
+ else if (process.platform === 'linux') {
73
+ try {
74
+ const { stdout } = await execFileAsync('avahi-browse', ['-t', '-r', '-p', '_ipp._tcp'], { timeout: 5000 });
75
+ for (const line of stdout.split('\n')) {
76
+ // Resolved lines: "=;eth0;IPv4;My Printer;_ipp._tcp;local;host.local;192.168.1.x;631;..."
77
+ const parts = line.split(';');
78
+ if (parts[0] === '=' && parts.length >= 8) {
79
+ const name = parts[3];
80
+ const host = parts[7];
81
+ const port = parseInt(parts[8], 10) || 631;
82
+ if (name && host) {
83
+ found.push({ name, host, port, connection_type: 'network' });
84
+ }
85
+ }
86
+ }
87
+ }
88
+ catch { /* avahi-browse unavailable */ }
35
89
  }
36
90
  return found;
37
91
  }
@@ -48,7 +102,30 @@ async function scanCups() {
48
102
  return []; // lpstat unavailable (Windows or CUPS not installed)
49
103
  }
50
104
  }
105
+ /** Merge mDNS results into port-scan results — mDNS names win over raw IP labels */
106
+ function merge(portScan, mdns) {
107
+ const byHost = new Map();
108
+ for (const p of portScan) {
109
+ if (p.host)
110
+ byHost.set(p.host, p);
111
+ }
112
+ for (const m of mdns) {
113
+ if (m.host && byHost.has(m.host)) {
114
+ // Enrich the port-scan entry with the friendly name from mDNS
115
+ byHost.set(m.host, { ...byHost.get(m.host), name: m.name });
116
+ }
117
+ else if (m.host) {
118
+ byHost.set(m.host, m);
119
+ }
120
+ else {
121
+ // mDNS-only (no IP resolved yet) — include as-is
122
+ byHost.set(m.name, m);
123
+ }
124
+ }
125
+ return Array.from(byHost.values());
126
+ }
51
127
  export async function discoverPrinters() {
52
- const [network, cups] = await Promise.all([scanNetwork(), scanCups()]);
128
+ const [portScan, mdns, cups] = await Promise.all([scanNetwork(), scanMdns(), scanCups()]);
129
+ const network = merge(portScan, mdns);
53
130
  return [...network, ...cups];
54
131
  }
package/dist/logs.js CHANGED
@@ -6,7 +6,7 @@ function getRecentLogFiles(limit = 2) {
6
6
  if (!existsSync(logDir))
7
7
  return [];
8
8
  return readdirSync(logDir)
9
- .filter((file) => file.endsWith('.log'))
9
+ .filter((file) => /^\d{4}-\d{2}-\d{2}\.log$/.test(file))
10
10
  .sort()
11
11
  .slice(-limit)
12
12
  .map((file) => join(logDir, file));
package/dist/service.js CHANGED
@@ -2,7 +2,7 @@ import { writeFileSync, mkdirSync, unlinkSync, existsSync } from 'fs';
2
2
  import { execFile, execFileSync } from 'child_process';
3
3
  import { promisify } from 'util';
4
4
  import { homedir, userInfo } from 'os';
5
- import { join } from 'path';
5
+ import { join, dirname } from 'path';
6
6
  import { log } from './logger.js';
7
7
  const execFileAsync = promisify(execFile);
8
8
  function getBinaryPath() {
@@ -21,6 +21,11 @@ async function installMacOS() {
21
21
  const logDir = join(homedir(), '.savepoint-bridge', 'logs');
22
22
  mkdirSync(plistDir, { recursive: true });
23
23
  mkdirSync(logDir, { recursive: true });
24
+ const home = homedir();
25
+ // Capture the node binary directory at install time so launchd's minimal
26
+ // PATH doesn't break nvm / Homebrew / Volta / system installs
27
+ const nodeBinDir = dirname(process.execPath);
28
+ const launchdPath = `/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin:${nodeBinDir}`;
24
29
  writeFileSync(plistPath, `<?xml version="1.0" encoding="UTF-8"?>
25
30
  <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
26
31
  <plist version="1.0">
@@ -28,6 +33,11 @@ async function installMacOS() {
28
33
  <key>Label</key><string>com.savepoint.bridge</string>
29
34
  <key>ProgramArguments</key>
30
35
  <array><string>${binary}</string><string>start</string></array>
36
+ <key>EnvironmentVariables</key>
37
+ <dict>
38
+ <key>HOME</key><string>${home}</string>
39
+ <key>PATH</key><string>${launchdPath}</string>
40
+ </dict>
31
41
  <key>RunAtLoad</key><true/>
32
42
  <key>KeepAlive</key><true/>
33
43
  <key>StandardOutPath</key><string>${logDir}/stdout.log</string>
package/dist/setup.js CHANGED
@@ -4,8 +4,8 @@ import { saveToken } from './token.js';
4
4
  import { discoverPrinters } from './discovery.js';
5
5
  import { installService } from './service.js';
6
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';
7
+ const BASE_URL = process.env.SAVEPOINT_APP_URL ?? process.env.SAVEPOINT_API_URL ?? 'https://app.savepointhq.com';
8
+ const VERSION = '1.0.4';
9
9
  function parseFlags(args) {
10
10
  const flags = {};
11
11
  for (const arg of args) {
@@ -21,6 +21,7 @@ export async function runSetup(args) {
21
21
  const nonInteractive = flags['non-interactive'] === true;
22
22
  const noService = flags['no-service'] === true;
23
23
  const tokenFlag = typeof flags['token'] === 'string' ? flags['token'] : null;
24
+ const isWindows = process.platform === 'win32';
24
25
  console.log();
25
26
  console.log(pc.bold('╔══════════════════════════════════════════════════╗'));
26
27
  console.log(pc.bold('║ SavePoint Bridge v' + VERSION.padEnd(22) + '║'));
@@ -40,6 +41,13 @@ What it stores on this computer:
40
41
 
41
42
  You can revoke access at any time from:
42
43
  SavePoint > Settings > Bridge > Revoke
44
+
45
+ ${isWindows ? `Windows setup notes:
46
+ - Open Windows PowerShell as Administrator
47
+ - Do not use Command Prompt
48
+ - If PowerShell blocks scripts, run:
49
+ Set-ExecutionPolicy -Scope Process Bypass -Force
50
+ ` : ''}
43
51
  `);
44
52
  if (!nonInteractive) {
45
53
  const proceed = await p.confirm({ message: 'Continue with setup?' });
@@ -56,7 +64,9 @@ You can revoke access at any time from:
56
64
  }
57
65
  else {
58
66
  const input = await p.text({
59
- message: 'Open SavePoint > Settings > Bridge > Add Bridge and paste your token:',
67
+ message: isWindows
68
+ ? 'Open SavePoint > Settings > Bridge > Add Bridge, then paste your token here (PowerShell only):'
69
+ : 'Open SavePoint > Settings > Bridge > Add Bridge and paste your token:',
60
70
  placeholder: 'Paste token here...',
61
71
  validate: v => v.length < 32 ? 'Token looks too short -- check you copied the full value' : undefined,
62
72
  });
@@ -79,8 +89,14 @@ You can revoke access at any time from:
79
89
  body: JSON.stringify({ version: VERSION }),
80
90
  });
81
91
  if (!res.ok) {
82
- spinner.stop(pc.red('Token verification failed -- check the token and try again'));
83
- process.exit(1);
92
+ const details = await res.text().catch(() => '');
93
+ const suffix = res.status === 401
94
+ ? 'The token was not recognized. Generate a fresh token in SavePoint > Settings > Bridge > Add Bridge and copy the full value.'
95
+ : details
96
+ ? details.trim()
97
+ : `HTTP ${res.status}`;
98
+ spinner.stop(pc.red(`Token verification failed -- ${suffix}`));
99
+ throw new Error(suffix);
84
100
  }
85
101
  spinner.stop(pc.green('Token verified'));
86
102
  }
@@ -90,7 +106,7 @@ You can revoke access at any time from:
90
106
  console.log(pc.yellow('\nSSL error -- if your network uses an SSL proxy, set:'));
91
107
  console.log(pc.yellow(' NODE_EXTRA_CA_CERTS=/path/to/proxy-ca.crt savepoint-bridge setup\n'));
92
108
  }
93
- process.exit(1);
109
+ throw e instanceof Error ? e : new Error('Token verification failed');
94
110
  }
95
111
  await saveToken(token);
96
112
  p.intro(pc.bold('Step 2 of 3 -- Printers'));
package/dist/token.js CHANGED
@@ -42,9 +42,12 @@ export async function loadToken() {
42
42
  const keytar = await trykeytar();
43
43
  if (keytar) {
44
44
  try {
45
- return await keytar.getPassword(SERVICE, ACCOUNT);
45
+ const keytarToken = await keytar.getPassword(SERVICE, ACCOUNT);
46
+ if (keytarToken)
47
+ return keytarToken;
48
+ // null means "not in keychain" — fall through to file fallback
46
49
  }
47
- catch { /* fall through */ }
50
+ catch { /* keychain inaccessible — fall through */ }
48
51
  }
49
52
  try {
50
53
  const raw = readFileSync(FALLBACK_PATH);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@savepoint/bridge",
3
- "version": "1.0.0",
3
+ "version": "1.0.4",
4
4
  "private": false,
5
5
  "description": "SavePoint Bridge hardware daemon",
6
6
  "keywords": [