editmamei 1.4.0 → 1.5.1

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.
@@ -3,12 +3,18 @@ import { pathToFileURL } from 'node:url';
3
3
  import { existsSync } from 'node:fs';
4
4
  import { EDITION } from '../edition.js';
5
5
  import { resolveProBinaryPath } from '../api/snippet-client.js';
6
- import { isProEntitled } from '../license/entitlement.js';
6
+ import { isProEntitled, REFRESH_AFTER_MS } from '../license/entitlement.js';
7
+ import { licenseAdvisory, PRO_RESTART_TO_LOAD } from '../license/advisory.js';
8
+ import { toolsInTier } from '../core/tool-tiers.js';
7
9
  import { loadVerifiedModule, readInstalledModule, installedPath, PRO_SKU, } from '../delivery/store.js';
8
10
  import { provisionModules, compareVersions, VERSION_RE, } from '../delivery/provision.js';
9
- import { readLicense } from '../license/store.js';
11
+ import { readLicense, readCheckState, updateCheckState, } from '../license/store.js';
10
12
  import { HOST_MIN_ABI, KERNEL_ABI } from './host-api.js';
11
13
  import { VERSION } from '../version.js';
14
+ export const MODULE_FRESH_AFTER_MS = REFRESH_AFTER_MS;
15
+ export const MODULE_RETRY_AFTER_FAILURE_MS = 15 * 60_000;
16
+ const PRO_STUB_DESCRIPTION = 'Unavailable right now: this Editmamei Pro tool needs an active license on this machine. ' +
17
+ 'Call it to get the reason and how to restore it, then relay that to the user.';
12
18
  export function classifyModuleOutcome(inputs) {
13
19
  if (inputs.proModuleLoaded && inputs.skipReason === null)
14
20
  return 'loaded';
@@ -26,6 +32,9 @@ export class ModuleLifecycle {
26
32
  constructor(deps) {
27
33
  this.deps = deps;
28
34
  }
35
+ get store() {
36
+ return this.deps.licenseStore ?? {};
37
+ }
29
38
  get proModule() {
30
39
  return this._proModule;
31
40
  }
@@ -36,7 +45,7 @@ export class ModuleLifecycle {
36
45
  this.kernel = kernel;
37
46
  }
38
47
  resolveProModule() {
39
- if (isProEntitled()) {
48
+ if (isProEntitled(this.store)) {
40
49
  const verified = loadVerifiedModule(PRO_SKU);
41
50
  if (verified) {
42
51
  const installed = readInstalledModule(PRO_SKU);
@@ -71,8 +80,10 @@ export class ModuleLifecycle {
71
80
  return null;
72
81
  }
73
82
  async loadModules() {
74
- if (!this._proModule)
83
+ if (!this._proModule) {
84
+ this.registerLapsedProStubs();
75
85
  return;
86
+ }
76
87
  if (this._proModule.abi !== null && this._proModule.abi < HOST_MIN_ABI) {
77
88
  this.deps.logger.warn(`Pro module (abi ${this._proModule.abi}) is older than this host requires ` +
78
89
  `(min abi ${HOST_MIN_ABI}) — booting Community; will re-provision in the background.`);
@@ -128,8 +139,46 @@ export class ModuleLifecycle {
128
139
  this._moduleSkipReason = 'incompatible';
129
140
  }
130
141
  }
142
+ registerLapsedProStubs() {
143
+ if (readLicense(this.store) === null)
144
+ return;
145
+ if (isProEntitled(this.store))
146
+ return;
147
+ const names = [];
148
+ for (const name of toolsInTier('pro')) {
149
+ try {
150
+ this.deps.classifyTool(name);
151
+ }
152
+ catch {
153
+ this.deps.logger.warn(`Skipping Pro stub for unclassified tool '${name}'.`);
154
+ continue;
155
+ }
156
+ names.push(name);
157
+ this.deps.toolRegistry.register(name, {
158
+ tool: {
159
+ name,
160
+ description: PRO_STUB_DESCRIPTION,
161
+ inputSchema: { type: 'object', properties: {} },
162
+ annotations: { title: name, readOnlyHint: true, idempotentHint: true },
163
+ },
164
+ handler: async () => ({
165
+ content: [
166
+ {
167
+ type: 'text',
168
+ text: isProEntitled(this.store)
169
+ ? PRO_RESTART_TO_LOAD
170
+ : (licenseAdvisory(this.store) ?? PRO_RESTART_TO_LOAD),
171
+ },
172
+ ],
173
+ isError: true,
174
+ }),
175
+ });
176
+ }
177
+ this.deps.logger.info(`Pro is not unlocked on this machine — registered ${names.length} Pro tool name(s) ` +
178
+ `as explain-only stubs so the reason is answerable.`);
179
+ }
131
180
  computeModuleStatus() {
132
- if (!readLicense())
181
+ if (!readLicense(this.store))
133
182
  return null;
134
183
  const installed = readInstalledModule(PRO_SKU);
135
184
  return {
@@ -137,7 +186,7 @@ export class ModuleLifecycle {
137
186
  outcome: classifyModuleOutcome({
138
187
  proModuleLoaded: this._proModule !== null,
139
188
  skipReason: this._moduleSkipReason,
140
- entitled: isProEntitled(),
189
+ entitled: isProEntitled(this.store),
141
190
  }),
142
191
  module_version: installed?.version ?? null,
143
192
  abi: installed?.abi ?? null,
@@ -147,12 +196,13 @@ export class ModuleLifecycle {
147
196
  const reason = this._moduleSkipReason;
148
197
  if (reason === null)
149
198
  return;
150
- const license = readLicense();
199
+ const license = readLicense(this.store);
151
200
  if (!license) {
152
201
  this.deps.logger.warn('A Pro module was skipped but no cached license was found — staying Community. ' +
153
202
  'Run `editmamei activate <key>` to restore Pro.');
154
203
  return;
155
204
  }
205
+ let outcome = null;
156
206
  try {
157
207
  const prov = await provisionModules(license.key, {
158
208
  force: reason === 'corrupt',
@@ -165,46 +215,66 @@ export class ModuleLifecycle {
165
215
  for (const m of prov.installed) {
166
216
  this.deps.logger.info(`Pro module updated to v${m.version}, restart to load.`);
167
217
  }
168
- return;
218
+ outcome = 'updated';
169
219
  }
170
- if (prov.notConfigured) {
220
+ else if (prov.notConfigured) {
171
221
  this.deps.logger.warn('Module delivery is not configured — staying Community.');
172
- return;
173
- }
174
- const abiTooNew = prov.errors.some((e) => e.code === 'abi_too_new');
175
- for (const e of prov.errors) {
176
- if (e.code === 'abi_too_new')
177
- continue;
178
- this.deps.logger.warn(`Could not re-provision the ${e.sku} module (staying Community): ${e.message}`);
179
222
  }
180
- if (prov.errors.length === 0 || abiTooNew) {
181
- if (reason === 'incompatible' || abiTooNew) {
182
- this.deps.logger.warn('The published Pro module does not yet support this host version — staying ' +
183
- 'Community. Update Editmamei when a compatible release ships; ' +
184
- '`editmamei report` files a diagnostic.');
223
+ else {
224
+ const abiTooNew = prov.errors.some((e) => e.code === 'abi_too_new');
225
+ for (const e of prov.errors) {
226
+ if (e.code === 'abi_too_new')
227
+ continue;
228
+ this.deps.logger.warn(`Could not re-provision the ${e.sku} module (staying Community): ${e.message}`);
185
229
  }
186
- else {
187
- this.deps.logger.warn('Re-provision installed nothing for the corrupt Pro module — staying ' +
188
- 'Community. Try `editmamei repair`; `editmamei report` if it persists.');
230
+ if (prov.errors.length === 0 || abiTooNew) {
231
+ if (reason === 'incompatible' || abiTooNew) {
232
+ this.deps.logger.warn('The published Pro module does not yet support this host version — staying ' +
233
+ 'Community. Update Editmamei when a compatible release ships; ' +
234
+ '`editmamei report` files a diagnostic.');
235
+ }
236
+ else {
237
+ this.deps.logger.warn('Re-provision installed nothing for the corrupt Pro module — staying ' +
238
+ 'Community. Try `editmamei repair`; `editmamei report` if it persists.');
239
+ }
189
240
  }
241
+ if (prov.errors.length > 0)
242
+ outcome = 'failed';
190
243
  }
191
244
  }
192
245
  catch (err) {
193
246
  this.deps.logger.warn(`Background Pro-module re-provision failed (staying Community): ` +
194
247
  `${err instanceof Error ? err.message : String(err)}`);
248
+ outcome = 'failed';
249
+ }
250
+ if (outcome !== null) {
251
+ try {
252
+ this.deps.onModuleUpdate?.(outcome);
253
+ }
254
+ catch {
255
+ }
195
256
  }
196
257
  }
197
258
  async ensureEntitledModuleFresh(delivery = {}) {
198
259
  if (this._moduleSkipReason !== null)
199
260
  return;
200
- if (!isProEntitled())
261
+ if (!isProEntitled(this.store))
201
262
  return;
202
263
  const underTest = process.env.VITEST !== undefined || process.env.NODE_ENV === 'test';
203
264
  if (underTest && !delivery.fetchImpl)
204
265
  return;
205
- const license = readLicense();
266
+ const license = readLicense(this.store);
206
267
  if (!license)
207
268
  return;
269
+ const now = (delivery.now ?? Date.now)();
270
+ const freshAfterMs = delivery.freshAfterMs ?? MODULE_FRESH_AFTER_MS;
271
+ const retryAfter = readCheckState(this.store).module_retry_after;
272
+ if (retryAfter !== undefined && now < retryAfter && retryAfter - now <= freshAfterMs)
273
+ return;
274
+ const alreadyInstalled = readInstalledModule(PRO_SKU) !== null;
275
+ const failureWaitMs = alreadyInstalled ? freshAfterMs : MODULE_RETRY_AFTER_FAILURE_MS;
276
+ updateCheckState({ module_retry_after: now + failureWaitMs }, this.store);
277
+ let outcome = null;
208
278
  try {
209
279
  const prov = await provisionModules(license.key, {
210
280
  config: delivery.config,
@@ -215,6 +285,12 @@ export class ModuleLifecycle {
215
285
  for (const m of prov.installed) {
216
286
  this.deps.logger.info(`Pro module updated to v${m.version} — restart to load.`);
217
287
  }
288
+ if (prov.installed.length > 0) {
289
+ outcome = 'updated';
290
+ }
291
+ else if (prov.errors.length > 0) {
292
+ outcome = 'failed';
293
+ }
218
294
  for (const e of prov.errors) {
219
295
  if (e.code === 'abi_too_new') {
220
296
  this.deps.logger.info(`A newer Pro module is published but needs a newer Editmamei than this ` +
@@ -228,6 +304,17 @@ export class ModuleLifecycle {
228
304
  catch (err) {
229
305
  this.deps.logger.warn(`Background Pro-module freshness check failed (staying on the installed version): ` +
230
306
  `${err instanceof Error ? err.message : String(err)}`);
307
+ outcome = 'failed';
308
+ }
309
+ if (outcome !== 'failed') {
310
+ updateCheckState({ module_retry_after: now + freshAfterMs }, this.store);
311
+ }
312
+ if (outcome !== null) {
313
+ try {
314
+ this.deps.onModuleUpdate?.(outcome);
315
+ }
316
+ catch {
317
+ }
231
318
  }
232
319
  }
233
320
  }
@@ -0,0 +1,109 @@
1
+ import { readLicense, readCheckState, } from './store.js';
2
+ import { evaluateEntitlement, GRACE_MS, REFRESH_AFTER_MS, } from './entitlement.js';
3
+ export const PRO_RESTART_TO_LOAD = 'Your Pro license is active again, but this session started without it. ' +
4
+ 'Restart your MCP client to load the Pro tools.';
5
+ function isoDay(ms) {
6
+ if (!Number.isFinite(ms))
7
+ return null;
8
+ let iso;
9
+ try {
10
+ iso = new Date(ms).toISOString();
11
+ }
12
+ catch {
13
+ return null;
14
+ }
15
+ return /^\d{4}-/.test(iso) ? iso.slice(0, 10) : null;
16
+ }
17
+ export function licenseAdvisory(opts = {}) {
18
+ const now = (opts.now ?? Date.now)();
19
+ const rec = readLicense(opts);
20
+ if (!rec)
21
+ return null;
22
+ const entitlement = evaluateEntitlement(rec, now);
23
+ const last = Date.parse(rec.last_validated_at);
24
+ const stale = !Number.isFinite(last) || now - last > REFRESH_AFTER_MS;
25
+ const retryAfter = readCheckState(opts).validate_retry_after;
26
+ const checksFailing = retryAfter !== undefined && retryAfter > now;
27
+ if (entitlement.entitled && !(stale && checksFailing) && opts.moduleUpdateFailed !== true) {
28
+ return null;
29
+ }
30
+ return advisoryText(rec, entitlement, now, {
31
+ checksFailing,
32
+ checksNotCompleting: stale && checksFailing,
33
+ });
34
+ }
35
+ const NOT_UNLOCKING = 'Pro is not unlocking.';
36
+ const RESTART_LOOP_CAUSE_AND_FIX = 'Most likely cause: this client is restarting faster than the license check can finish. ' +
37
+ 'Fix: quit the client fully, wait a minute, then start it once and leave it running.';
38
+ const GIVE_IT_TIME = 'Pro may take a few hours to come back rather than returning at the next start, so leave ' +
39
+ 'the client alone instead of restarting it again.';
40
+ function waitCaveat(facts) {
41
+ return facts.checksFailing ? ` ${GIVE_IT_TIME}` : '';
42
+ }
43
+ const RESTART_TO_LOAD = 'restart your MCP client, because the Pro tools only load when it starts';
44
+ const MODULE_UPDATE_FAILED = 'Pro is unlocked, but an update to its module did not finish. The version already ' +
45
+ 'installed keeps working and the update is tried again on its own, so there is nothing to ' +
46
+ 'do now. If the Pro tools do stop working, run `editmamei repair` in a terminal and then ' +
47
+ 'restart your MCP client.';
48
+ const SUPPORT_TAIL = 'If that does not bring Pro back, run `editmamei license` in a terminal and send the output ' +
49
+ 'to support@editmamei.com.';
50
+ function checkInWindow(rec, now, entitled) {
51
+ const last = Date.parse(rec.last_validated_at);
52
+ const graceEndsAt = last + GRACE_MS;
53
+ const lastDay = isoDay(last);
54
+ const graceDay = isoDay(graceEndsAt);
55
+ if (lastDay === null || graceDay === null)
56
+ return '';
57
+ return entitled
58
+ ? ` Your license last checked in on ${lastDay}, and Pro locks on ` +
59
+ `${graceDay} if it cannot check in before then.`
60
+ : ` Your license last checked in on ${lastDay} and the offline grace window ` +
61
+ `${graceEndsAt <= now ? 'closed' : 'closes'} on ${graceDay}.`;
62
+ }
63
+ function endedSentence(rec) {
64
+ const day = rec.expires_at === null ? null : isoDay(Date.parse(rec.expires_at));
65
+ return day === null
66
+ ? 'This license has an end date that has passed, and restarting on its own will not extend it.'
67
+ : `This license ended on ${day}, and restarting on its own will not extend it.`;
68
+ }
69
+ function clockIsBehind(rec, now) {
70
+ const last = Date.parse(rec.last_validated_at);
71
+ return Number.isFinite(last) && last + GRACE_MS >= now;
72
+ }
73
+ const CLOCK_BEHIND = `${NOT_UNLOCKING} This machine's clock is set earlier than a date Editmamei has already ` +
74
+ 'recorded here, usually because it was once set ahead, so the license stored on it cannot be ' +
75
+ 'read as current. Fix: correct the system clock, ' +
76
+ 'then, while connected to the internet, run `editmamei deactivate` followed by ' +
77
+ `\`editmamei activate YOUR-KEY\` in a terminal and ${RESTART_TO_LOAD}. Running activate on ` +
78
+ `its own is not enough here, because it keeps the stored record. ${SUPPORT_TAIL}`;
79
+ function advisoryText(rec, entitlement, now, facts) {
80
+ if (entitlement.entitled) {
81
+ if (!facts.checksNotCompleting)
82
+ return MODULE_UPDATE_FAILED;
83
+ return ('Pro is unlocked, but its background license and update checks are not completing.' +
84
+ `${checkInWindow(rec, now, true)} ${RESTART_LOOP_CAUSE_AND_FIX}` +
85
+ `${waitCaveat(facts)} ${SUPPORT_TAIL}`);
86
+ }
87
+ switch (entitlement.reason) {
88
+ case 'revoked':
89
+ case 'disabled':
90
+ return (`${NOT_UNLOCKING} This license is no longer active, and restarting on its own will not ` +
91
+ 'bring it back. Fix: check your subscription status. Once it is running again, run ' +
92
+ '`editmamei license` in a terminal to re-check the license and update this machine, ' +
93
+ `then ${RESTART_TO_LOAD}. If it should be running already, send that output to ` +
94
+ 'support@editmamei.com.');
95
+ case 'expired':
96
+ return (`${NOT_UNLOCKING} ${endedSentence(rec)} Fix: renew it, then run ` +
97
+ `\`editmamei activate YOUR-KEY\` in a terminal and ${RESTART_TO_LOAD}. If the renewal ` +
98
+ 'has already gone through, run `editmamei license` instead to re-check the license and ' +
99
+ 'update this machine, then restart the client; if Pro is still missing, send that ' +
100
+ 'output to support@editmamei.com.');
101
+ case 'grace-expired':
102
+ case 'granted':
103
+ case 'no-license':
104
+ if (clockIsBehind(rec, now))
105
+ return CLOCK_BEHIND;
106
+ return (`${NOT_UNLOCKING}${checkInWindow(rec, now, false)} ${RESTART_LOOP_CAUSE_AND_FIX}` +
107
+ `${waitCaveat(facts)} ${SUPPORT_TAIL}`);
108
+ }
109
+ }
@@ -1,13 +1,16 @@
1
1
  import { resolvePolarConfig } from './config.js';
2
2
  import { computeDeviceHash } from './device-hash.js';
3
- import { readLicense, writeLicense, clearLicense, nextHighWaterMark, } from './store.js';
4
- import { PolarLicenseClient, PolarLicenseError } from './polar-client.js';
3
+ import { readLicense, writeLicense, clearLicense, readCheckState, updateCheckState, nextHighWaterMark, } from './store.js';
4
+ import { PolarLicenseClient, PolarLicenseError, unrefSleep, } from './polar-client.js';
5
5
  import { Logger } from '../utils/logger.js';
6
6
  const logger = new Logger('License');
7
7
  export const GRACE_MS = 7 * 24 * 60 * 60 * 1000;
8
8
  export const REFRESH_AFTER_MS = 24 * 60 * 60 * 1000;
9
9
  export const EXPIRED_REFRESH_TIMEOUT_MS = 5_000;
10
10
  export const CLOCK_SKEW_TOLERANCE_MS = 24 * 60 * 60 * 1000;
11
+ export const VALIDATE_BACKOFF_MIN_MS = 60_000;
12
+ export const VALIDATE_BACKOFF_DEFAULT_MS = 15 * 60_000;
13
+ export const VALIDATE_BACKOFF_MAX_MS = 6 * 60 * 60 * 1000;
11
14
  export function evaluateEntitlement(rec, now) {
12
15
  if (!rec)
13
16
  return { entitled: false, reason: 'no-license' };
@@ -37,7 +40,18 @@ function makeClient(ops) {
37
40
  throw new PolarLicenseError(`Licensing is not configured for the '${cfg.env}' environment. ` +
38
41
  `Set EDITMAMEI_POLAR_ENV=sandbox to test against the sandbox org.`, 0, 'not_configured');
39
42
  }
40
- return { polar: new PolarLicenseClient(cfg, ops.fetchImpl ?? defaultFetch), cfg };
43
+ return {
44
+ polar: new PolarLicenseClient(cfg, ops.fetchImpl ?? defaultFetch, ops.client ?? {}),
45
+ cfg,
46
+ };
47
+ }
48
+ function noteValidateFailure(err, ops) {
49
+ if (err instanceof PolarLicenseError && err.code === 'not_configured')
50
+ return;
51
+ const now = (ops.now ?? Date.now)();
52
+ const supplied = err instanceof PolarLicenseError ? err.retryAfterMs : undefined;
53
+ const waitMs = Math.min(Math.max(supplied ?? VALIDATE_BACKOFF_DEFAULT_MS, VALIDATE_BACKOFF_MIN_MS), VALIDATE_BACKOFF_MAX_MS);
54
+ updateCheckState({ validate_retry_after: now + waitMs }, ops);
41
55
  }
42
56
  export function isProEntitled(ops = {}) {
43
57
  return evaluateEntitlement(readLicense(ops), Date.now()).entitled;
@@ -70,6 +84,7 @@ export async function activate(key, ops = {}) {
70
84
  high_water_mark: nextHighWaterMark(existing, nowMs),
71
85
  };
72
86
  writeLicense(rec, ops);
87
+ updateCheckState({ validate_retry_after: null }, ops);
73
88
  return rec;
74
89
  }
75
90
  export async function refreshIfStale(ops = {}) {
@@ -89,16 +104,28 @@ export async function refreshIfStale(ops = {}) {
89
104
  const clockSkewed = age < -CLOCK_SKEW_TOLERANCE_MS;
90
105
  if (!clockSkewed && age <= REFRESH_AFTER_MS)
91
106
  return;
107
+ const backoffUntil = readCheckState(ops).validate_retry_after;
108
+ if (backoffUntil !== undefined) {
109
+ const waitMs = backoffUntil - now;
110
+ if (waitMs > 0 && waitMs <= VALIDATE_BACKOFF_MAX_MS)
111
+ return;
112
+ updateCheckState({ validate_retry_after: null }, ops);
113
+ }
114
+ const bootOps = {
115
+ ...ops,
116
+ client: { sleep: unrefSleep, ...ops.client },
117
+ };
92
118
  if (clockSkewed || age <= GRACE_MS) {
93
- refresh(ops).catch((err) => {
119
+ refresh(bootOps).catch((err) => {
120
+ noteValidateFailure(err, ops);
94
121
  logger.warn(`Background license refresh failed (grace covers offline use): ` +
95
122
  `${err instanceof Error ? err.message : String(err)}`);
96
123
  });
97
124
  return;
98
125
  }
99
126
  const timeoutMs = ops.expiredRefreshTimeoutMs ?? EXPIRED_REFRESH_TIMEOUT_MS;
100
- const attempt = refresh(ops);
101
- attempt.catch(() => { });
127
+ const attempt = refresh(bootOps);
128
+ attempt.catch((err) => noteValidateFailure(err, ops));
102
129
  let timer;
103
130
  try {
104
131
  const winner = await Promise.race([
@@ -138,6 +165,7 @@ export async function refresh(ops = {}) {
138
165
  high_water_mark: nextHighWaterMark(rec, nowMs),
139
166
  };
140
167
  writeLicense(updated, ops);
168
+ updateCheckState({ validate_retry_after: null }, ops);
141
169
  return updated;
142
170
  }
143
171
  export async function deactivate(ops = {}) {
@@ -1,26 +1,35 @@
1
1
  export class PolarLicenseError extends Error {
2
2
  httpStatus;
3
3
  code;
4
- constructor(message, httpStatus, code) {
4
+ retryAfterMs;
5
+ constructor(message, httpStatus, code, retryAfterMs) {
5
6
  super(message);
6
7
  this.httpStatus = httpStatus;
7
8
  this.code = code;
9
+ this.retryAfterMs = retryAfterMs;
8
10
  this.name = 'PolarLicenseError';
9
11
  }
10
12
  }
11
13
  const USER_AGENT = 'editmamei-license-client/1';
14
+ const DEFAULT_RETRY = { attempts: 3, baseDelayMs: 1000, maxDelayMs: 65_000 };
15
+ const defaultSleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
16
+ export const unrefSleep = (ms) => new Promise((resolve) => {
17
+ const timer = setTimeout(resolve, ms);
18
+ timer.unref?.();
19
+ });
12
20
  export class PolarLicenseClient {
13
21
  cfg;
14
22
  fetchImpl;
15
- constructor(cfg, fetchImpl) {
23
+ retry;
24
+ sleep;
25
+ constructor(cfg, fetchImpl, opts = {}) {
16
26
  this.cfg = cfg;
17
27
  this.fetchImpl = fetchImpl;
28
+ this.retry = { ...DEFAULT_RETRY, ...opts.retry };
29
+ this.sleep = opts.sleep ?? defaultSleep;
18
30
  }
19
31
  async validate(key) {
20
- return this.post('/customer-portal/license-keys/validate', {
21
- key,
22
- organization_id: this.cfg.organizationId,
23
- });
32
+ return this.post('/customer-portal/license-keys/validate', { key, organization_id: this.cfg.organizationId }, { retry: true });
24
33
  }
25
34
  async activate(key, label) {
26
35
  return this.post('/customer-portal/license-keys/activate', {
@@ -36,7 +45,38 @@ export class PolarLicenseClient {
36
45
  activation_id: activationId,
37
46
  });
38
47
  }
39
- async post(path, body) {
48
+ async post(path, body, opts = {}) {
49
+ const once = () => this.postOnce(path, body);
50
+ return opts.retry === true ? this.withRetry(once) : once();
51
+ }
52
+ async withRetry(fn) {
53
+ const attempts = Math.max(1, this.retry.attempts);
54
+ let lastErr;
55
+ for (let attempt = 1; attempt <= attempts; attempt++) {
56
+ try {
57
+ return await fn();
58
+ }
59
+ catch (err) {
60
+ lastErr = err;
61
+ const transient = err instanceof PolarLicenseError && (err.code === 'network' || err.code === 'transient');
62
+ if (!transient || attempt === attempts)
63
+ throw err;
64
+ const retryAfter = err instanceof PolarLicenseError ? err.retryAfterMs : undefined;
65
+ const delay = retryAfter ?? this.retry.baseDelayMs * 2 ** (attempt - 1);
66
+ const wait = Math.min(Math.max(delay, this.retry.baseDelayMs), this.retry.maxDelayMs);
67
+ await this.sleep(wait);
68
+ }
69
+ }
70
+ throw lastErr;
71
+ }
72
+ retryAfterMsOf(res) {
73
+ const raw = res.headers?.get('retry-after');
74
+ if (!raw)
75
+ return undefined;
76
+ const secs = Number(raw);
77
+ return Number.isFinite(secs) && secs >= 0 ? Math.round(secs * 1000) : undefined;
78
+ }
79
+ async postOnce(path, body) {
40
80
  let res;
41
81
  try {
42
82
  res = await this.fetchImpl(`${this.cfg.baseUrl}${path}`, {
@@ -57,6 +97,9 @@ export class PolarLicenseClient {
57
97
  '`editmamei deactivate` on one of them, or remove a device in your account portal ' +
58
98
  "(the 'Manage' link in your purchase email).", 403, 'seat_limit_reached');
59
99
  }
100
+ if (res.status === 429 || res.status >= 500) {
101
+ throw new PolarLicenseError(`License check could not complete (HTTP ${res.status}).`, res.status, 'transient', this.retryAfterMsOf(res));
102
+ }
60
103
  throw new PolarLicenseError(`License check failed (HTTP ${res.status}).`, res.status, 'invalid_license');
61
104
  }
62
105
  }
@@ -4,6 +4,7 @@ import { settingsDir } from '../core/settings.js';
4
4
  import { Logger } from '../utils/logger.js';
5
5
  const logger = new Logger('License');
6
6
  const LICENSE_FILENAME = 'license.json';
7
+ const CHECK_STATE_FILENAME = 'license-check.json';
7
8
  export function licensePath(opts = {}) {
8
9
  return join(settingsDir(opts), LICENSE_FILENAME);
9
10
  }
@@ -56,4 +57,62 @@ export function clearLicense(opts = {}) {
56
57
  const path = licensePath(opts);
57
58
  if (existsSync(path))
58
59
  rmSync(path, { force: true });
60
+ clearCheckState(opts);
61
+ }
62
+ export function checkStatePath(opts = {}) {
63
+ return join(settingsDir(opts), CHECK_STATE_FILENAME);
64
+ }
65
+ export function readCheckState(opts = {}) {
66
+ const path = checkStatePath(opts);
67
+ if (!existsSync(path))
68
+ return {};
69
+ let parsed;
70
+ try {
71
+ parsed = JSON.parse(readFileSync(path, 'utf8'));
72
+ }
73
+ catch (err) {
74
+ logger.debug(`license-check.json unreadable (ignoring): ${err instanceof Error ? err.message : String(err)}`);
75
+ return {};
76
+ }
77
+ if (typeof parsed !== 'object' || parsed === null)
78
+ return {};
79
+ const raw = parsed;
80
+ const out = {};
81
+ if (typeof raw.validate_retry_after === 'number' && Number.isFinite(raw.validate_retry_after)) {
82
+ out.validate_retry_after = raw.validate_retry_after;
83
+ }
84
+ if (typeof raw.module_retry_after === 'number' && Number.isFinite(raw.module_retry_after)) {
85
+ out.module_retry_after = raw.module_retry_after;
86
+ }
87
+ return out;
88
+ }
89
+ export function updateCheckState(patch, opts = {}) {
90
+ const next = { ...readCheckState(opts) };
91
+ for (const [key, value] of Object.entries(patch)) {
92
+ if (value === null)
93
+ delete next[key];
94
+ else
95
+ next[key] = value;
96
+ }
97
+ const path = checkStatePath(opts);
98
+ const dir = dirname(path);
99
+ try {
100
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
101
+ const tmp = join(dir, `.license-check.${process.pid}.tmp`);
102
+ writeFileSync(tmp, JSON.stringify(next, null, 2) + '\n', { encoding: 'utf8', mode: 0o600 });
103
+ renameSync(tmp, path);
104
+ }
105
+ catch (err) {
106
+ logger.debug(`license-check.json not written (ignoring): ${err instanceof Error ? err.message : String(err)}`);
107
+ }
108
+ }
109
+ export function clearCheckState(opts = {}) {
110
+ const path = checkStatePath(opts);
111
+ try {
112
+ if (existsSync(path))
113
+ rmSync(path, { force: true });
114
+ }
115
+ catch (err) {
116
+ logger.debug(`license-check.json not removed (ignoring): ${err instanceof Error ? err.message : String(err)}`);
117
+ }
59
118
  }
Binary file