amaprice 1.0.14 → 1.0.16

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/README.md CHANGED
@@ -29,13 +29,13 @@ npx amaprice price "https://www.amazon.de/dp/B0DZ5P7JD6"
29
29
  ## Quickstart
30
30
 
31
31
  ```bash
32
- # one-shot lookup
32
+ # one-shot lookup (no subscription)
33
33
  amaprice price "https://www.amazon.de/dp/B0DZ5P7JD6"
34
34
 
35
- # start tracking with a tier
35
+ # start tracking + subscribe current user + auto-start background collector
36
36
  amaprice track B0DZ5P7JD6 --tier daily
37
37
 
38
- # subscribe current user to shared catalog product
38
+ # or subscribe directly to an existing shared catalog product
39
39
  amaprice subscribe B0DZ5P7JD6
40
40
 
41
41
  # show history
@@ -43,6 +43,16 @@ amaprice history B0DZ5P7JD6 --limit 30
43
43
 
44
44
  # list tracked products
45
45
  amaprice list
46
+
47
+ # list all subscriptions for current user (including paused)
48
+ amaprice subscriptions --all
49
+
50
+ # stop one product subscription for current user
51
+ amaprice unsubscribe B0DZ5P7JD6
52
+
53
+ # stop/start background collector service
54
+ amaprice background off
55
+ amaprice background on
46
56
  ```
47
57
 
48
58
  ## Input Modes
@@ -59,19 +69,47 @@ Short links from Amazon apps (for example `amzn.eu`, `amzn.to`, `a.co`) are acce
59
69
  | Command | Description |
60
70
  |---|---|
61
71
  | `amaprice [url\|asin]` | Shortcut for `amaprice price [url\|asin]` |
62
- | `amaprice price [url\|asin]` | One-shot lookup and silent history insert |
63
- | `amaprice track [url\|asin]` | Track product + current price (`--tier`, `--manual-tier`, `--auto-tier`, `--inactive`) |
72
+ | `amaprice price [url\|asin]` | One-shot lookup (no subscription) and silent history insert |
73
+ | `amaprice track [url\|asin]` | Track product + subscribe current user + auto-start background (`--tier`, `--manual-tier`, `--auto-tier`, `--inactive`) |
64
74
  | `amaprice subscribe [url\|asin]` | Subscribe current user to shared product catalog entry |
65
75
  | `amaprice unsubscribe <url\|asin>` | Disable current user subscription |
66
76
  | `amaprice subscriptions` | List user subscriptions with latest known prices |
67
77
  | `amaprice history <url\|asin>` | Show history (`--limit N`) |
68
- | `amaprice list` | List tracked products + latest price |
78
+ | `amaprice list` | List current user subscriptions + latest price (default view) |
79
+ | `amaprice list --global` | List global shared catalog tracked products |
69
80
  | `amaprice sync --limit <n>` | Run background sync for due products |
70
81
  | `amaprice background <on\|off\|status>` | Manage true background collector service |
71
82
  | `amaprice tier <url\|asin> <hourly\|daily\|weekly>` | Set tier/status (`--auto`, `--manual`, `--activate`, `--deactivate`) |
72
83
 
73
84
  All commands support `--json`.
74
85
 
86
+ ## Most Common User Flows
87
+
88
+ ### One-time Price Check (No Subscription)
89
+
90
+ ```bash
91
+ amaprice price B0DZ5P7JD6
92
+ ```
93
+
94
+ This returns the current price immediately and does not create a user subscription.
95
+
96
+ ### Subscribe + Run in Background
97
+
98
+ ```bash
99
+ amaprice track B0DZ5P7JD6
100
+ amaprice background status --json
101
+ ```
102
+
103
+ `track` (and `subscribe`) auto-starts the background collector on macOS (`launchd`).
104
+ You can close your terminal after this; the service keeps running.
105
+
106
+ ### Stop Product + Stop Background Service
107
+
108
+ ```bash
109
+ amaprice unsubscribe B0DZ5P7JD6
110
+ amaprice background off
111
+ ```
112
+
75
113
  ## Background Service (Auto)
76
114
 
77
115
  `track` and `subscribe` automatically ensure a true background collector service is running.
@@ -145,7 +183,11 @@ Run this SQL in Supabase SQL Editor:
145
183
 
146
184
  `supabase/migrations/20260220_add_price_history_currency.sql`
147
185
 
148
- These migrations add tier fields, indexes, telemetry, worker health rollups, and `price_history.currency`.
186
+ `supabase/migrations/20260222_add_hybrid_orchestration.sql`
187
+
188
+ `supabase/migrations/20260223_enforce_collector_first_claiming.sql`
189
+
190
+ These migrations add tier fields, indexes, telemetry, worker health rollups, `price_history.currency`, collector orchestration tables/functions, and strict collector-first claim policy.
149
191
 
150
192
  Note: these files are additive migrations and expect existing `products` + `price_history` tables.
151
193
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "amaprice",
3
- "version": "1.0.14",
3
+ "version": "1.0.16",
4
4
  "description": "CLI tool to scrape and track Amazon product prices",
5
5
  "main": "src/scraper.js",
6
6
  "type": "commonjs",
@@ -114,15 +114,17 @@ function isLaunchdSupported(platform = process.platform) {
114
114
  return platform === 'darwin';
115
115
  }
116
116
 
117
- function getLaunchdDomain() {
117
+ function getLaunchdDomains() {
118
118
  if (typeof process.getuid !== 'function') {
119
119
  throw new Error('launchd requires a POSIX uid');
120
120
  }
121
- return `gui/${process.getuid()}`;
121
+ const uid = process.getuid();
122
+ return [`gui/${uid}`, `user/${uid}`];
122
123
  }
123
124
 
124
- function buildServiceTarget(label) {
125
- return `${getLaunchdDomain()}/${label}`;
125
+ function buildServiceTarget(label, domain = null) {
126
+ const safeDomain = domain || getLaunchdDomains()[0];
127
+ return `${safeDomain}/${label}`;
126
128
  }
127
129
 
128
130
  async function runLaunchctl(args, { allowFailure = false } = {}) {
@@ -233,24 +235,38 @@ async function getLaunchdServiceStatus({ label }) {
233
235
  backend: 'launchd',
234
236
  label,
235
237
  plistPath,
238
+ domain: null,
236
239
  installed: false,
237
240
  loaded: false,
238
241
  running: false,
239
242
  };
240
243
  }
241
244
 
242
- const print = await runLaunchctl(['print', buildServiceTarget(label)], { allowFailure: true });
243
- const output = `${print.stdout}\n${print.stderr}`;
244
- const loaded = print.ok;
245
- const running = loaded && (/state = running/i.test(output) || /pid = \d+/i.test(output));
245
+ const domains = getLaunchdDomains();
246
+ for (const domain of domains) {
247
+ const print = await runLaunchctl(['print', buildServiceTarget(label, domain)], { allowFailure: true });
248
+ if (!print.ok) continue;
249
+ const output = `${print.stdout}\n${print.stderr}`;
250
+ const running = /state = running/i.test(output) || /pid = \d+/i.test(output);
251
+ return {
252
+ backend: 'launchd',
253
+ label,
254
+ plistPath,
255
+ domain,
256
+ installed: true,
257
+ loaded: true,
258
+ running,
259
+ };
260
+ }
246
261
 
247
262
  return {
248
263
  backend: 'launchd',
249
264
  label,
250
265
  plistPath,
266
+ domain: domains[0] || null,
251
267
  installed: true,
252
- loaded,
253
- running,
268
+ loaded: false,
269
+ running: false,
254
270
  };
255
271
  }
256
272
 
@@ -263,7 +279,6 @@ async function enableLaunchdService({
263
279
  const plistPath = getLaunchdPlistPath(label);
264
280
  const logPath = getDaemonLogPath();
265
281
  const daemonEntry = getDaemonEntryPath();
266
- const target = buildServiceTarget(label);
267
282
 
268
283
  await fs.mkdir(path.dirname(plistPath), { recursive: true });
269
284
  await fs.mkdir(path.dirname(logPath), { recursive: true });
@@ -284,33 +299,53 @@ async function enableLaunchdService({
284
299
  });
285
300
  await fs.writeFile(plistPath, plist, 'utf8');
286
301
 
287
- // If service was previously disabled via `background off`, re-enable first.
288
- await runLaunchctl(['enable', target], { allowFailure: true });
302
+ const domains = getLaunchdDomains();
303
+ const errors = [];
289
304
 
290
- let bootstrap = await runLaunchctl(['bootstrap', getLaunchdDomain(), plistPath], { allowFailure: true });
291
- if (!bootstrap.ok && !isAlreadyLoadedError(bootstrap)) {
292
- // Recover from stale loaded/disabled state by clearing then bootstrapping once more.
293
- await runLaunchctl(['bootout', target], { allowFailure: true });
294
- await runLaunchctl(['enable', target], { allowFailure: true });
295
- bootstrap = await runLaunchctl(['bootstrap', getLaunchdDomain(), plistPath], { allowFailure: true });
296
- }
297
- if (!bootstrap.ok && !isAlreadyLoadedError(bootstrap)) {
298
- throw new Error(`Could not bootstrap launchd service: ${bootstrap.stderr || bootstrap.stdout || 'unknown error'}`);
299
- }
305
+ for (const domain of domains) {
306
+ const domainTarget = buildServiceTarget(label, domain);
307
+
308
+ // Clean stale state first, then bootstrap fresh.
309
+ await runLaunchctl(['bootout', domainTarget], { allowFailure: true });
310
+ await runLaunchctl(['disable', domainTarget], { allowFailure: true });
311
+ await runLaunchctl(['enable', domainTarget], { allowFailure: true });
300
312
 
301
- await runLaunchctl(['enable', target], { allowFailure: true });
302
- const kick = await runLaunchctl(['kickstart', '-k', target], { allowFailure: true });
303
- if (!kick.ok) {
304
- await runLaunchctl(['start', label], { allowFailure: true });
313
+ let bootstrap = await runLaunchctl(['bootstrap', domain, plistPath], { allowFailure: true });
314
+ if (!bootstrap.ok && !isAlreadyLoadedError(bootstrap)) {
315
+ await runLaunchctl(['bootout', domainTarget], { allowFailure: true });
316
+ await runLaunchctl(['enable', domainTarget], { allowFailure: true });
317
+ bootstrap = await runLaunchctl(['bootstrap', domain, plistPath], { allowFailure: true });
318
+ }
319
+
320
+ if (!bootstrap.ok && !isAlreadyLoadedError(bootstrap)) {
321
+ errors.push(`${domain}: ${bootstrap.stderr || bootstrap.stdout || 'unknown error'}`);
322
+ continue;
323
+ }
324
+
325
+ await runLaunchctl(['enable', domainTarget], { allowFailure: true });
326
+ const kick = await runLaunchctl(['kickstart', '-k', domainTarget], { allowFailure: true });
327
+ if (!kick.ok) {
328
+ await runLaunchctl(['start', label], { allowFailure: true });
329
+ }
330
+
331
+ const status = await getLaunchdServiceStatus({ label });
332
+ if (status.loaded) {
333
+ return status;
334
+ }
335
+ errors.push(`${domain}: bootstrapped but service not loaded`);
305
336
  }
306
337
 
307
- return getLaunchdServiceStatus({ label });
338
+ const msg = errors.length > 0 ? errors.join(' | ') : 'unknown error';
339
+ throw new Error(`Could not bootstrap launchd service: ${msg}`);
308
340
  }
309
341
 
310
342
  async function disableLaunchdService({ label }) {
311
343
  const plistPath = getLaunchdPlistPath(label);
312
- await runLaunchctl(['bootout', buildServiceTarget(label)], { allowFailure: true });
313
- await runLaunchctl(['disable', buildServiceTarget(label)], { allowFailure: true });
344
+ for (const domain of getLaunchdDomains()) {
345
+ const target = buildServiceTarget(label, domain);
346
+ await runLaunchctl(['bootout', target], { allowFailure: true });
347
+ await runLaunchctl(['disable', target], { allowFailure: true });
348
+ }
314
349
  try {
315
350
  await fs.unlink(plistPath);
316
351
  } catch (err) {
@@ -342,7 +377,7 @@ async function ensureBackgroundOn({
342
377
  const { collector, statePath } = await ensureCollectorEnabled({
343
378
  userId,
344
379
  collectorName,
345
- status: 'active',
380
+ status: 'paused',
346
381
  });
347
382
 
348
383
  const service = await enableLaunchdService({
@@ -352,6 +387,10 @@ async function ensureBackgroundOn({
352
387
  userId,
353
388
  });
354
389
 
390
+ if (!service.loaded) {
391
+ throw new Error('launchd service did not load');
392
+ }
393
+
355
394
  await heartbeatCollector({
356
395
  collectorId: collector.id,
357
396
  status: 'active',
@@ -510,6 +549,8 @@ module.exports.__test = {
510
549
  resolvePollSeconds,
511
550
  getLaunchdLabel,
512
551
  getLaunchdPlistPath,
552
+ getLaunchdDomains,
553
+ buildServiceTarget,
513
554
  renderLaunchdPlist,
514
555
  isLaunchdSupported,
515
556
  };