@itpay/cli 2.1.41 → 2.1.44

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.
Files changed (148) hide show
  1. package/README.md +12 -5
  2. package/assets/sell-preview/index.html +6 -0
  3. package/assets/sell-preview/preview.js +9 -0
  4. package/bin/darwin-amd64/itpay-sell +0 -0
  5. package/bin/darwin-arm64/itpay-sell +0 -0
  6. package/bin/linux-amd64/itpay-sell +0 -0
  7. package/bin/linux-arm64/itpay-sell +0 -0
  8. package/bin/windows-amd64/itpay-sell.exe +0 -0
  9. package/dist/src/client/backend.js +38 -2
  10. package/dist/src/client/http.js +2 -0
  11. package/dist/src/commands/aftercare.js +12 -0
  12. package/dist/src/commands/buy.js +17 -58
  13. package/dist/src/commands/cart.js +17 -15
  14. package/dist/src/commands/catalog.js +39 -14
  15. package/dist/src/commands/checkout.js +146 -5
  16. package/dist/src/commands/checkout_handoff.js +11 -61
  17. package/dist/src/commands/docs.js +7 -18
  18. package/dist/src/commands/guidance.js +1 -134
  19. package/dist/src/commands/install.js +6 -3
  20. package/dist/src/commands/order.js +34 -3
  21. package/dist/src/commands/orders.js +4 -9
  22. package/dist/src/commands/pay.js +1 -1
  23. package/dist/src/commands/presentation.js +153 -0
  24. package/dist/src/commands/rail_catalog.js +1 -0
  25. package/dist/src/commands/readyz.js +1 -1
  26. package/dist/src/commands/refund.js +13 -57
  27. package/dist/src/commands/services/actions.js +133 -0
  28. package/dist/src/commands/services/commerce.js +197 -0
  29. package/dist/src/commands/services/lifecycle.js +418 -0
  30. package/dist/src/commands/services/results.js +130 -0
  31. package/dist/src/commands/services.js +14 -1068
  32. package/dist/src/commands/skill.js +8 -5
  33. package/dist/src/commands/vault.js +27 -20
  34. package/dist/src/commands/vault_handoff.js +18 -0
  35. package/dist/src/main.js +332 -46
  36. package/dist/src/render/browser.js +65 -0
  37. package/dist/src/render/checkout_handoff.js +101 -0
  38. package/dist/src/render/checkout_plan.js +47 -0
  39. package/dist/src/render/markdown.js +0 -3
  40. package/dist/src/render/qr.js +1 -1
  41. package/dist/src/sell/commands.js +80 -0
  42. package/dist/src/sell/contract.js +86 -0
  43. package/dist/src/sell/local.js +260 -0
  44. package/dist/src/sell/mcp.js +49 -0
  45. package/dist/src/sell/preview.js +70 -0
  46. package/dist/src/sell/sync.js +145 -0
  47. package/dist/src/services/adapter.js +1 -0
  48. package/dist/src/services/presentation.js +198 -0
  49. package/dist/src/services/rail/actions.js +51 -0
  50. package/dist/src/services/rail/adapter.js +36 -0
  51. package/dist/src/services/rail/aftercare.js +45 -0
  52. package/dist/src/services/rail/catalog.js +261 -0
  53. package/dist/src/services/rail/checkout.js +19 -0
  54. package/dist/src/services/rail/fulfillment.js +76 -0
  55. package/dist/src/services/rail/guide.js +110 -0
  56. package/dist/src/services/rail/invocation.js +54 -0
  57. package/dist/src/services/rail/page.js +76 -0
  58. package/dist/src/services/rail/pagination.js +6 -0
  59. package/dist/src/services/rail/query.js +71 -0
  60. package/dist/src/services/rail/readers.js +107 -0
  61. package/dist/src/services/rail/reading.js +125 -0
  62. package/dist/src/services/rail/recovery.js +51 -0
  63. package/dist/src/services/rail/results.js +378 -0
  64. package/dist/src/services/rail/review.js +268 -0
  65. package/dist/src/services/rail/saved.js +49 -0
  66. package/dist/src/services/rail/wait.js +11 -0
  67. package/dist/src/services/registry.js +31 -0
  68. package/dist/src/services/schema.js +21 -0
  69. package/dist/src/services/shared/envelope.js +184 -0
  70. package/dist/src/services/shared/input.js +39 -0
  71. package/dist/src/services/shared/pagination.js +28 -0
  72. package/dist/src/services/shared/platform.js +365 -0
  73. package/dist/src/services/shared/refund.js +40 -0
  74. package/dist/src/services/shared/workflow.js +28 -0
  75. package/dist/src/state/account_auth.js +354 -0
  76. package/dist/src/state/cart_session.js +4 -1
  77. package/dist/src/state/client_context.js +72 -0
  78. package/dist/src/state/config.js +44 -14
  79. package/dist/src/state/device_authority.js +402 -72
  80. package/dist/src/state/task_journal.js +90 -0
  81. package/docs/agent/buyer/cart-checkout.json +8 -6
  82. package/docs/agent/buyer/catalog-list.json +29 -9
  83. package/docs/agent/buyer/identity-and-sessions.json +26 -9
  84. package/docs/agent/buyer/install-and-setup.json +17 -15
  85. package/docs/agent/buyer/orders-refunds.json +25 -9
  86. package/docs/agent/buyer/payment-flow.json +6 -4
  87. package/docs/agent/buyer/purchased-content.json +18 -4
  88. package/docs/agent/buyer/quickstart.json +26 -17
  89. package/docs/agent/buyer/rail-booking.json +176 -0
  90. package/docs/agent/buyer/rail-fast-checkout.json +48 -0
  91. package/docs/agent/buyer/render-hosts.json +23 -12
  92. package/docs/agent/buyer/service-feedback.json +4 -3
  93. package/docs/cli-reference/agent-types.md +7 -2
  94. package/docs/cli-reference/commands/aftercare.md +15 -0
  95. package/docs/cli-reference/commands/auth.md +96 -0
  96. package/docs/cli-reference/commands/buy.md +2 -1
  97. package/docs/cli-reference/commands/cart/add.md +5 -1
  98. package/docs/cli-reference/commands/cart/clear.md +1 -1
  99. package/docs/cli-reference/commands/cart/index.md +1 -1
  100. package/docs/cli-reference/commands/cart/next.md +5 -1
  101. package/docs/cli-reference/commands/cart/remove.md +1 -1
  102. package/docs/cli-reference/commands/cart/show.md +1 -1
  103. package/docs/cli-reference/commands/catalog/index.md +1 -1
  104. package/docs/cli-reference/commands/catalog/list.md +2 -2
  105. package/docs/cli-reference/commands/checkout.md +12 -2
  106. package/docs/cli-reference/commands/device.md +18 -11
  107. package/docs/cli-reference/commands/docs/index.md +1 -1
  108. package/docs/cli-reference/commands/docs/list.md +1 -1
  109. package/docs/cli-reference/commands/docs/search.md +1 -1
  110. package/docs/cli-reference/commands/docs/show.md +1 -1
  111. package/docs/cli-reference/commands/install.md +8 -6
  112. package/docs/cli-reference/commands/next.md +1 -1
  113. package/docs/cli-reference/commands/order.md +1 -1
  114. package/docs/cli-reference/commands/pay.md +1 -1
  115. package/docs/cli-reference/commands/readyz.md +11 -11
  116. package/docs/cli-reference/commands/refund/cancel.md +1 -1
  117. package/docs/cli-reference/commands/refund/create.md +1 -1
  118. package/docs/cli-reference/commands/refund/get.md +2 -2
  119. package/docs/cli-reference/commands/refund/index.md +1 -1
  120. package/docs/cli-reference/commands/refund/list.md +1 -1
  121. package/docs/cli-reference/commands/refund/watch.md +1 -1
  122. package/docs/cli-reference/commands/sell.md +1364 -0
  123. package/docs/cli-reference/commands/services/action.md +24 -3
  124. package/docs/cli-reference/commands/services/checkout.md +17 -2
  125. package/docs/cli-reference/commands/services/events.md +1 -1
  126. package/docs/cli-reference/commands/services/get.md +4 -2
  127. package/docs/cli-reference/commands/services/index.md +4 -3
  128. package/docs/cli-reference/commands/services/invoke.md +6 -4
  129. package/docs/cli-reference/commands/services/list.md +1 -1
  130. package/docs/cli-reference/commands/services/next.md +78 -12
  131. package/docs/cli-reference/commands/services/page.md +71 -0
  132. package/docs/cli-reference/commands/services/quote.md +4 -4
  133. package/docs/cli-reference/commands/services/read-result.md +17 -6
  134. package/docs/cli-reference/commands/services/run.md +152 -0
  135. package/docs/cli-reference/commands/services/start.md +5 -3
  136. package/docs/cli-reference/commands/vault/access.md +5 -1
  137. package/docs/cli-reference/conventions.md +4 -6
  138. package/docs/cli-reference/index.md +10 -1
  139. package/docs/sell.md +84 -0
  140. package/package.json +20 -12
  141. package/skills/itpay/SKILL.md +112 -102
  142. package/docs/skill-bundle-rollout/01-mcp-authentication.md +0 -134
  143. package/docs/skill-bundle-rollout/02-platform-bundle-repositories.md +0 -210
  144. package/docs/skill-bundle-rollout/03-platform-publishing.md +0 -161
  145. package/docs/skill-bundle-rollout/04-first-wave-platforms.md +0 -69
  146. package/docs/skill-bundle-rollout/05-sync-operations.md +0 -62
  147. package/docs/skill-bundle-rollout/README.md +0 -151
  148. /package/bin/{itp → itp.mjs} +0 -0
@@ -1,8 +1,7 @@
1
1
  import { createHash, createPrivateKey, createPublicKey, generateKeyPairSync, randomUUID, sign, } from "node:crypto";
2
- import { chmodSync, existsSync, mkdirSync, readFileSync, renameSync, statSync, unlinkSync, writeFileSync, } from "node:fs";
3
- import { homedir } from "node:os";
2
+ import { chmodSync, existsSync, mkdirSync, lstatSync, linkSync, readFileSync, readdirSync, renameSync, statSync, unlinkSync, writeFileSync, } from "node:fs";
4
3
  import { dirname, resolve } from "node:path";
5
- const PROTECTED_PATHS = ["/v1/carts", "/v1/service-executions", "/v1/agent-instances", "/v1/orders", "/v1/refunds", "/v1/me", "/v1/vault"];
4
+ const PROTECTED_PATHS = ["/v1/agent-device-account-bindings", "/v1/carts", "/v1/service-executions", "/v1/agent-instances", "/v1/orders", "/v1/refunds", "/v1/me", "/v1/vault", "/v1/rail/phone-links", "/v1/rail-aftercare"];
6
5
  export class DeviceAuthority {
7
6
  baseURL;
8
7
  backendKey;
@@ -17,7 +16,7 @@ export class DeviceAuthority {
17
16
  this.backendKey = normalizeBackendKey(options.baseURL);
18
17
  this.requestedAgentType = options.requestedAgentType;
19
18
  this.compatibilityHeaders = options.compatibilityHeaders;
20
- const root = resolve(homedir(), ".itpay-v3", "device");
19
+ const root = resolve(stateDir(), "device");
21
20
  this.statePath = options.statePath ?? resolve(root, "identity.json");
22
21
  this.privateKeyPath = options.privateKeyPath ?? resolve(root, "device-private.pem");
23
22
  this.fetchImpl = (options.fetchImpl ?? globalThis.fetch).bind(globalThis);
@@ -43,21 +42,24 @@ export class DeviceAuthority {
43
42
  }
44
43
  async ensureAuthorization() {
45
44
  if (!this.pending) {
46
- this.pending = withFileLock(`${this.statePath}.lock`, () => this.prepareAuthorization()).finally(() => {
45
+ this.pending = this.cachedAuthorization() ?? withFileLock(`${this.statePath}.lock`, () => this.prepareAuthorization());
46
+ this.pending = this.pending.finally(() => {
47
47
  this.pending = undefined;
48
48
  });
49
49
  }
50
50
  return this.pending;
51
51
  }
52
- async status() {
53
- // Readiness needs a current server proof, not an unexpired cached token.
54
- return withFileLock(`${this.statePath}.lock`, async () => {
55
- const auth = await this.prepareAuthorization(true);
56
- if (!auth.session.token || !(Date.parse(auth.session.expiresAt) > Date.now())) {
57
- throw new Error('Device verification returned an invalid session');
58
- }
59
- return { agent_type: auth.agentType, expires_at: auth.session.expiresAt };
60
- });
52
+ cachedAuthorization() {
53
+ const agentType = this.requestedAgentType;
54
+ if (!agentType)
55
+ return undefined;
56
+ const state = this.readState();
57
+ const registration = state?.registrations[this.backendKey];
58
+ const session = registration?.sessions[agentType];
59
+ if (!registration?.agentInstances[agentType] || !session || Date.parse(session.expiresAt) <= Date.now() + 60_000)
60
+ return undefined;
61
+ const privateKey = this.readPrivateKey();
62
+ return privateKey ? Promise.resolve({ state: registration, agentType, session, privateKey }) : undefined;
61
63
  }
62
64
  async recoverAuthorization() {
63
65
  await withFileLock(`${this.statePath}.lock`, async () => {
@@ -71,6 +73,9 @@ export class DeviceAuthority {
71
73
  this.writeState(state);
72
74
  });
73
75
  }
76
+ repairLock() {
77
+ return inspectAndRecoverLock(`${this.statePath}.lock`);
78
+ }
74
79
  async recoverBackendReset() {
75
80
  return withFileLock(`${this.statePath}.lock`, async () => {
76
81
  const state = this.readState();
@@ -83,7 +88,28 @@ export class DeviceAuthority {
83
88
  return { removed: true, agentTypes };
84
89
  });
85
90
  }
86
- async prepareAuthorization(forceSession = false) {
91
+ async resetDeviceKey() {
92
+ return withFileLock(`${this.statePath}.lock`, async () => {
93
+ const state = this.readState();
94
+ const removedBackends = state ? Object.keys(state.registrations).sort() : [];
95
+ // Delete the key first: if the process dies before the state write, the
96
+ // next run treats the missing key as a fresh install and completes the
97
+ // reset itself; it can never pair a new key with stale registrations.
98
+ if (existsSync(this.privateKeyPath)) {
99
+ try {
100
+ unlinkSync(this.privateKeyPath);
101
+ }
102
+ catch (error) {
103
+ if (error.code !== "ENOENT") {
104
+ throw asDeviceStateError(error, "remove_private_key");
105
+ }
106
+ }
107
+ }
108
+ this.writeState(emptyDeviceState());
109
+ return { removedBackends };
110
+ });
111
+ }
112
+ async prepareAuthorization() {
87
113
  let state = this.readState() ?? emptyDeviceState();
88
114
  const agentType = this.requestedAgentType;
89
115
  if (!agentType) {
@@ -93,8 +119,14 @@ export class DeviceAuthority {
93
119
  if (!privateKey) {
94
120
  const pair = generateKeyPairSync("ed25519");
95
121
  privateKey = pair.privateKey;
96
- this.writePrivateKey(pair.privateKey.export({ format: "pem", type: "pkcs8" }).toString());
122
+ // Persist an empty state before the new key ever reaches disk so a crash
123
+ // can never pair the new key with registrations bound to the old one.
124
+ // A crash between the two writes leaves either no key (fresh retry) or a
125
+ // key with no registrations (the backend re-attaches to the existing
126
+ // device instead of creating a duplicate).
97
127
  state = emptyDeviceState();
128
+ this.writeState(state);
129
+ this.writePrivateKey(pair.privateKey.export({ format: "pem", type: "pkcs8" }).toString());
98
130
  }
99
131
  let registration = state.registrations[this.backendKey];
100
132
  if (!registration && state.legacyRegistration) {
@@ -112,7 +144,21 @@ export class DeviceAuthority {
112
144
  registration = await this.enroll(agentType, privateKey);
113
145
  }
114
146
  state.registrations[this.backendKey] = registration;
115
- const session = await this.ensureRegistrationAgentType(registration, agentType, privateKey, forceSession);
147
+ let session;
148
+ try {
149
+ session = await this.ensureRegistrationAgentType(registration, agentType, privateKey, false);
150
+ }
151
+ catch (error) {
152
+ if (!isUnknownDeviceRegistration(error))
153
+ throw error;
154
+ // The backend lost this registration (e.g. its identity store was
155
+ // rebuilt). Drop the stale record and enroll once: the same key either
156
+ // re-attaches to the surviving device or creates a fresh one.
157
+ delete state.registrations[this.backendKey];
158
+ registration = await this.enroll(agentType, privateKey);
159
+ state.registrations[this.backendKey] = registration;
160
+ session = await this.ensureRegistrationAgentType(registration, agentType, privateKey, false);
161
+ }
116
162
  this.writeState(state);
117
163
  return { state: registration, agentType, session, privateKey };
118
164
  }
@@ -146,16 +192,23 @@ export class DeviceAuthority {
146
192
  if (!publicJWK.x)
147
193
  throw new Error("unable to export Ed25519 public key");
148
194
  const publicKey = Buffer.from(publicJWK.x, "base64url").toString("base64");
149
- const started = await this.publicJSON("/v1/agent-device-enrollments", { public_key: publicKey, agent_type: agentType });
150
- const proof = enrollmentProofMessage(started.agent_device_enrollment_id, started.challenge);
151
- const verified = await this.publicJSON(`/v1/agent-device-enrollments/${encodeURIComponent(started.agent_device_enrollment_id)}/verify`, { challenge: started.challenge, signature: sign(null, Buffer.from(proof), privateKey).toString("base64") });
152
- return {
153
- deviceID: verified.agent_device_id,
154
- deviceKeyID: verified.agent_device_key_id,
155
- quotaLineageID: verified.quota_lineage_id,
156
- agentInstances: { [verified.agent_type]: verified.agent_instance_id },
157
- sessions: {},
158
- };
195
+ try {
196
+ const started = await this.publicJSON("/v1/agent-device-enrollments", { public_key: publicKey, agent_type: agentType });
197
+ const proof = enrollmentProofMessage(started.agent_device_enrollment_id, started.challenge);
198
+ const verified = await this.publicJSON(`/v1/agent-device-enrollments/${encodeURIComponent(started.agent_device_enrollment_id)}/verify`, { challenge: started.challenge, signature: sign(null, Buffer.from(proof), privateKey).toString("base64") });
199
+ return {
200
+ deviceID: verified.agent_device_id,
201
+ deviceKeyID: verified.agent_device_key_id,
202
+ quotaLineageID: verified.quota_lineage_id,
203
+ agentInstances: { [verified.agent_type]: verified.agent_instance_id },
204
+ sessions: {},
205
+ };
206
+ }
207
+ catch (error) {
208
+ if (error instanceof DeviceAuthorizationError)
209
+ error.enrollmentFailed = true;
210
+ throw error;
211
+ }
159
212
  }
160
213
  async ensureSession(state, agentType, privateKey, force = false) {
161
214
  const existing = state.sessions[agentType];
@@ -198,6 +251,7 @@ export class DeviceAuthority {
198
251
  method: "POST",
199
252
  headers: { "Content-Type": "application/json", Accept: "application/json", ...this.compatibilityHeaders, ...extraHeaders },
200
253
  body,
254
+ signal: AbortSignal.timeout(15_000),
201
255
  });
202
256
  const payload = await response.json().catch(() => ({}));
203
257
  if (!response.ok)
@@ -218,10 +272,7 @@ export class DeviceAuthority {
218
272
  return undefined;
219
273
  }
220
274
  catch (error) {
221
- const stateError = asDeviceStateError(error, "read_state");
222
- if (stateError)
223
- throw stateError;
224
- return undefined;
275
+ throw asDeviceStateError(error, "read_state");
225
276
  }
226
277
  }
227
278
  readPrivateKey() {
@@ -231,10 +282,7 @@ export class DeviceAuthority {
231
282
  return createPrivateKey(readFileSync(this.privateKeyPath, "utf8"));
232
283
  }
233
284
  catch (error) {
234
- const stateError = asDeviceStateError(error, "read_private_key");
235
- if (stateError)
236
- throw stateError;
237
- return undefined;
285
+ throw asDeviceStateError(error, "read_private_key");
238
286
  }
239
287
  }
240
288
  writeState(state) { atomicOwnerOnlyWrite(this.statePath, JSON.stringify(state, null, 2), "write_state"); }
@@ -243,6 +291,7 @@ export class DeviceAuthority {
243
291
  export class DeviceAuthorizationError extends Error {
244
292
  status;
245
293
  code;
294
+ enrollmentFailed = false;
246
295
  constructor(status, code, message) {
247
296
  super(message);
248
297
  this.status = status;
@@ -253,12 +302,24 @@ export class DeviceAuthorizationError extends Error {
253
302
  export class DeviceStateError extends Error {
254
303
  operation;
255
304
  causeCode;
256
- code = "device_state_unwritable";
257
- constructor(operation, causeCode) {
305
+ blockedPath;
306
+ code = "device_state_unavailable";
307
+ source;
308
+ constructor(operation, causeCode, blockedPath) {
258
309
  super(`ItPay device state operation failed: ${operation} (${causeCode})`);
259
310
  this.operation = operation;
260
311
  this.causeCode = causeCode;
312
+ this.blockedPath = blockedPath;
261
313
  this.name = "DeviceStateError";
314
+ this.source = causeCode === "UNCLASSIFIED_LOCAL_FAILURE" ? "unclassified_local_operation" : causeCode.startsWith("CODEBUDDY_BROKER_") ? "host_broker"
315
+ : ["LEGACY_LOCK_OWNER_UNKNOWN", "SYMLINK_LOCK", "LOCK_SOURCE_CHANGED", "LOCK_TRANSITION_BUSY", "LOCK_OWNER_PROBE_UNAVAILABLE"].includes(causeCode) ? "client_state_validation" : "native_filesystem";
316
+ }
317
+ }
318
+ export class DeviceLockBusyError extends Error {
319
+ code = "device_lock_busy";
320
+ constructor() {
321
+ super("ItPay device lock is occupied or its transition cannot be safely completed");
322
+ this.name = "DeviceLockBusyError";
262
323
  }
263
324
  }
264
325
  function emptyDeviceState() {
@@ -267,6 +328,9 @@ function emptyDeviceState() {
267
328
  function canMovePastLegacyRegistration(error) {
268
329
  return error instanceof DeviceAuthorizationError && (error.code === "agent_device_revoked" || error.status === 404);
269
330
  }
331
+ function isUnknownDeviceRegistration(error) {
332
+ return error instanceof DeviceAuthorizationError && (error.code === "agent_device_not_found" || error.status === 404);
333
+ }
270
334
  function normalizeBackendKey(value) {
271
335
  const url = new URL(value);
272
336
  url.search = "";
@@ -292,7 +356,7 @@ function atomicOwnerOnlyWrite(path, value, operation) {
292
356
  unlinkSync(temporary);
293
357
  }
294
358
  catch { /* best-effort cleanup */ }
295
- throw asDeviceStatePathError(error, operation) ?? error;
359
+ throw asDeviceStatePathError(error, operation);
296
360
  }
297
361
  }
298
362
  async function withFileLock(path, run) {
@@ -300,75 +364,341 @@ async function withFileLock(path, run) {
300
364
  mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
301
365
  }
302
366
  catch (error) {
303
- throw asDeviceStatePathError(error, "prepare_lock") ?? error;
367
+ throw asDeviceStatePathError(error, "prepare_lock");
304
368
  }
305
- const ownerToken = randomUUID();
369
+ const ownerToken = `${process.pid}:${randomUUID()}`;
306
370
  let acquired = false;
307
- for (let attempt = 0; attempt < 200; attempt += 1) {
371
+ for (let attempt = 0; attempt < 800; attempt += 1) {
308
372
  try {
309
- writeFileSync(path, ownerToken, { encoding: "utf8", flag: "wx", mode: 0o600 });
310
- chmodSync(path, 0o600);
373
+ if (!lockTransition(path, () => {
374
+ writeFileSync(path, ownerToken, { encoding: "utf8", flag: "wx", mode: 0o600 });
375
+ chmodSync(path, 0o600);
376
+ })) {
377
+ await new Promise((resolve) => setTimeout(resolve, 25));
378
+ continue;
379
+ }
311
380
  acquired = true;
312
381
  break;
313
382
  }
314
383
  catch (error) {
315
384
  const code = error.code;
316
385
  if (code !== "EEXIST")
317
- throw asDeviceStateError(error, "acquire_lock") ?? error;
318
- try {
319
- if (Date.now() - statSync(path).mtimeMs > 30_000)
320
- moveLockAside(path, "stale", "remove_stale_lock");
321
- }
322
- catch (statError) {
323
- if (statError.code !== "ENOENT") {
324
- throw asDeviceStateError(statError, "inspect_lock") ?? statError;
325
- }
326
- }
386
+ throw asDeviceStateError(error, "acquire_lock");
387
+ inspectAndRecoverLock(path);
327
388
  await new Promise((resolve) => setTimeout(resolve, 25));
328
389
  }
329
390
  }
330
391
  if (!acquired)
331
- throw new Error("timed out waiting for ItPay device identity lock");
392
+ throw new DeviceLockBusyError();
393
+ let operationFailed = false;
332
394
  try {
333
395
  return await run();
334
396
  }
397
+ catch (error) {
398
+ operationFailed = true;
399
+ throw error;
400
+ }
335
401
  finally {
336
- releaseLock(path, ownerToken);
402
+ try {
403
+ await releaseLock(path, ownerToken);
404
+ }
405
+ catch (error) {
406
+ if (!operationFailed)
407
+ throw error;
408
+ }
409
+ }
410
+ }
411
+ function inspectAndRecoverLock(path) {
412
+ let result = "active";
413
+ lockTransition(path, () => { result = inspectLockOwner(path); });
414
+ return result;
415
+ }
416
+ function inspectLockOwner(path) {
417
+ let token;
418
+ let age;
419
+ let inspected;
420
+ try {
421
+ const lockStat = lstatSync(path);
422
+ if (lockStat.isSymbolicLink())
423
+ throw new DeviceStateError("inspect_lock", "SYMLINK_LOCK");
424
+ inspected = lockStat;
425
+ age = Date.now() - lockStat.mtimeMs;
426
+ token = lockStat.isDirectory() ? "" : readFileSync(path, "utf8");
427
+ }
428
+ catch (error) {
429
+ if (error.code === "ENOENT")
430
+ return "absent";
431
+ throw asDeviceStateError(error, "inspect_lock");
432
+ }
433
+ const ownerPID = /^(\d+):[0-9a-f-]+$/.exec(token)?.[1];
434
+ if (ownerPID) {
435
+ try {
436
+ process.kill(Number(ownerPID), 0);
437
+ return "active";
438
+ }
439
+ catch (error) {
440
+ if (error.code !== "ESRCH")
441
+ return "active";
442
+ }
443
+ }
444
+ else {
445
+ if (age <= 30_000)
446
+ return "active";
447
+ // Legacy ownerless locks cannot prove that an old caller has exited.
448
+ throw new DeviceStateError("inspect_lock", "LEGACY_LOCK_OWNER_UNKNOWN");
337
449
  }
450
+ // Recheck the exact owner before moving the lock; another process may have renewed it.
451
+ try {
452
+ if (token ? readFileSync(path, "utf8") !== token : !statSync(path).isDirectory())
453
+ return "active";
454
+ }
455
+ catch (error) {
456
+ if (error.code === "ENOENT")
457
+ return "absent";
458
+ throw asDeviceStateError(error, "inspect_lock");
459
+ }
460
+ moveLockAside(path, "stale", "remove_stale_lock", inspected);
461
+ return "recovered";
338
462
  }
339
- function releaseLock(path, ownerToken) {
463
+ async function releaseLock(path, ownerToken) {
464
+ // Another caller may be inspecting our live owner under the same short
465
+ // transition. Wait for that critical section, without reclaiming its guard.
466
+ for (let attempt = 0; attempt < 20; attempt += 1) {
467
+ if (lockTransition(path, () => releaseOwnedLock(path, ownerToken)))
468
+ return;
469
+ await new Promise((resolve) => setTimeout(resolve, 25));
470
+ }
471
+ throw new DeviceStateError("release_lock", "LOCK_TRANSITION_BUSY");
472
+ }
473
+ function releaseOwnedLock(path, ownerToken) {
340
474
  try {
475
+ const inspected = lstatSync(path);
476
+ if (!inspected.isFile())
477
+ throw new DeviceStateError("release_lock", "LOCK_SOURCE_CHANGED");
341
478
  if (readFileSync(path, "utf8") !== ownerToken)
342
479
  return;
343
- moveLockAside(path, "released", "release_lock");
480
+ moveLockAside(path, "released", "release_lock", inspected);
344
481
  }
345
482
  catch (error) {
346
483
  if (error.code !== "ENOENT")
347
- throw asDeviceStateError(error, "release_lock") ?? error;
484
+ throw asDeviceStateError(error, "release_lock");
348
485
  }
349
486
  }
350
- function moveLockAside(path, suffix, operation) {
487
+ // Per-process tickets publish ownership in the directory name before any write.
488
+ // The pool itself is not a lock. An interrupted choosing/ready ticket is ignored
489
+ // only after its PID is proven dead; no caller removes another caller's ticket.
490
+ // New arrivals choose a larger ticket, and simultaneous choices use name order.
491
+ function lockTransition(path, run) {
492
+ const legacy = `${path}.transition`;
493
+ const pool = `${path}.transition-owners`;
494
+ mkdirSync(pool, { recursive: true, mode: 0o700 });
495
+ if (lstatSync(pool).isSymbolicLink())
496
+ throw new DeviceStateError("inspect_lock", "SYMLINK_LOCK");
497
+ const incarnation = processIncarnation(process.pid);
498
+ if (!incarnation)
499
+ throw new DeviceStateError("prepare_lock", "LOCK_OWNER_PROBE_UNAVAILABLE");
500
+ // mkdir publishes the choosing owner and its incarnation in one atomic step.
501
+ const name = `${process.pid}-${sha256(incarnation).slice(7)}-${randomUUID()}`;
502
+ const own = resolve(pool, name);
503
+ mkdirSync(own, { mode: 0o700 });
504
+ let failed = false;
505
+ let linked = false;
506
+ const token = JSON.stringify({ pid: process.pid, incarnation, token: randomUUID() });
351
507
  try {
352
- renameSync(path, `${path}.${suffix}`);
508
+ let ticket = 1;
509
+ for (const other of readdirSync(pool)) {
510
+ if (other === name || other === "archives")
511
+ continue;
512
+ const value = transitionTicket(pool, other);
513
+ if (value !== undefined)
514
+ ticket = Math.max(ticket, (value ?? 0) + 1);
515
+ }
516
+ if (!Number.isSafeInteger(ticket))
517
+ throw new DeviceStateError("inspect_lock", "LOCK_TRANSITION_BUSY");
518
+ writeFileSync(resolve(own, "ticket"), JSON.stringify({ sequence: ticket, incarnation }), { flag: "wx", mode: 0o600 });
519
+ for (const other of readdirSync(pool)) {
520
+ if (other === name || other === "archives")
521
+ continue;
522
+ const value = transitionTicket(pool, other);
523
+ if (value === null || (value !== undefined && (value < ticket || (value === ticket && other < name))))
524
+ return false;
525
+ }
526
+ // All new binaries hold the ticket mutex during stale recovery. Old binaries
527
+ // see EEXIST on this same path, so cannot race acquisition with recovery.
528
+ if (existsSync(legacy)) {
529
+ const stat = lstatSync(legacy);
530
+ if (!stat.isFile())
531
+ return false; // Unknown old directory owner stays protected.
532
+ const text = readFileSync(legacy, "utf8");
533
+ const old = /^(\d+):[0-9a-f-]+$/.exec(text);
534
+ let ownerPID;
535
+ let birth;
536
+ if (old)
537
+ ownerPID = Number(old[1]);
538
+ else {
539
+ let value;
540
+ try {
541
+ value = JSON.parse(text);
542
+ }
543
+ catch {
544
+ return false;
545
+ }
546
+ if (!value || typeof value !== "object" || !("pid" in value) || typeof value.pid !== "number" || !("incarnation" in value) || typeof value.incarnation !== "string")
547
+ return false;
548
+ ownerPID = value.pid;
549
+ birth = value.incarnation;
550
+ }
551
+ if (!Number.isSafeInteger(ownerPID) || ownerPID <= 0)
552
+ return false;
553
+ try {
554
+ process.kill(ownerPID, 0);
555
+ const current = birth && processIncarnation(ownerPID);
556
+ if (!current || current === birth)
557
+ return false;
558
+ }
559
+ catch (error) {
560
+ if (error.code !== "ESRCH")
561
+ return false;
562
+ }
563
+ moveLockAside(legacy, "stale", "remove_stale_lock", stat);
564
+ }
565
+ const owner = resolve(own, "owner");
566
+ writeFileSync(owner, token, { flag: "wx", mode: 0o600 });
567
+ // Hard-link a fully written owner: no empty/unowned publication crash gap.
568
+ try {
569
+ linkSync(owner, legacy);
570
+ }
571
+ catch (error) {
572
+ if (error.code === "EEXIST")
573
+ return false;
574
+ throw error;
575
+ }
576
+ linked = true;
577
+ run();
578
+ return true;
353
579
  }
354
580
  catch (error) {
355
- const code = error.code;
356
- if (code === "ENOENT")
357
- return;
358
- if (code === "EEXIST" || code === "ENOTEMPTY") {
359
- renameSync(path, `${path}.${suffix}.${randomUUID()}`);
360
- return;
581
+ failed = true;
582
+ throw error;
583
+ }
584
+ finally {
585
+ try {
586
+ if (linked) {
587
+ if (readFileSync(legacy, "utf8") !== token)
588
+ throw new DeviceStateError("release_lock", "LOCK_SOURCE_CHANGED");
589
+ renameSync(legacy, resolve(own, "transition-released"));
590
+ }
591
+ const archive = resolve(pool, "archives");
592
+ mkdirSync(archive, { recursive: true, mode: 0o700 });
593
+ if (lstatSync(archive).isSymbolicLink())
594
+ throw new DeviceStateError("release_lock", "SYMLINK_LOCK");
595
+ const destination = resolve(archive, randomUUID());
596
+ mkdirSync(destination, { mode: 0o700 });
597
+ renameSync(own, resolve(destination, "entry"));
598
+ }
599
+ catch (error) {
600
+ if (!failed)
601
+ throw asDeviceStateError(error, "release_lock");
361
602
  }
362
- throw asDeviceStateError(error, operation) ?? error;
603
+ }
604
+ }
605
+ // undefined = absent/dead, null = a live process still choosing its ticket.
606
+ function transitionTicket(pool, name) {
607
+ const owner = /^(\d+)-(?:([0-9a-f]{64})-)?[0-9a-f-]+$/.exec(name);
608
+ if (!owner || Number(owner[1]) <= 0)
609
+ throw new DeviceStateError("inspect_lock", "LOCK_SOURCE_CHANGED");
610
+ try {
611
+ process.kill(Number(owner[1]), 0);
612
+ }
613
+ catch (error) {
614
+ if (error.code === "ESRCH")
615
+ return undefined;
616
+ return null;
617
+ }
618
+ if (owner[2]) {
619
+ const birth = processIncarnation(Number(owner[1]));
620
+ if (!birth)
621
+ return null;
622
+ if (sha256(birth).slice(7) !== owner[2])
623
+ return undefined;
624
+ }
625
+ try {
626
+ const entry = resolve(pool, name);
627
+ if (!lstatSync(entry).isDirectory())
628
+ throw new DeviceStateError("inspect_lock", "SYMLINK_LOCK");
629
+ const file = resolve(entry, "ticket");
630
+ if (lstatSync(file).isSymbolicLink())
631
+ throw new DeviceStateError("inspect_lock", "SYMLINK_LOCK");
632
+ let value;
633
+ try {
634
+ value = JSON.parse(readFileSync(file, "utf8"));
635
+ }
636
+ catch {
637
+ return null;
638
+ }
639
+ if (!value || typeof value !== "object" || !("sequence" in value) || typeof value.sequence !== "number" || !("incarnation" in value) || typeof value.incarnation !== "string")
640
+ return null;
641
+ const birth = processIncarnation(Number(owner[1]));
642
+ if (!birth)
643
+ return null;
644
+ if (birth !== value.incarnation)
645
+ return undefined; // PID belongs to a different process.
646
+ return Number.isSafeInteger(value.sequence) && value.sequence > 0 ? value.sequence : null;
647
+ }
648
+ catch (error) {
649
+ if (error.code === "ENOENT") {
650
+ return existsSync(resolve(pool, name)) ? null : undefined;
651
+ }
652
+ throw asDeviceStateError(error, "inspect_lock");
653
+ }
654
+ }
655
+ // Built-in OS process metadata distinguishes reused PIDs without an age timeout.
656
+ // If probing is unavailable, ownership stays conservative rather than stolen.
657
+ function processIncarnation(pid) {
658
+ try {
659
+ if (process.platform === "linux") {
660
+ const stat = readFileSync(`/proc/${pid}/stat`, "utf8");
661
+ const started = stat.slice(stat.lastIndexOf(")") + 2).split(" ")[19];
662
+ return started ? `${readFileSync("/proc/sys/kernel/random/boot_id", "utf8").trim()}:${started}` : undefined;
663
+ }
664
+ const output = process.platform === "win32"
665
+ ? execFileSync("powershell.exe", ["-NoProfile", "-NonInteractive", "-Command", `(Get-Process -Id ${pid}).StartTime.ToUniversalTime().ToString('o')`], { encoding: "utf8", timeout: 1000, stdio: ["ignore", "pipe", "ignore"] })
666
+ : execFileSync("ps", ["-p", String(pid), "-o", "lstart="], { encoding: "utf8", timeout: 1000, stdio: ["ignore", "pipe", "ignore"], env: { ...process.env, LC_ALL: "C", TZ: "UTC" } });
667
+ return output.trim() || undefined;
668
+ }
669
+ catch {
670
+ return undefined;
671
+ }
672
+ }
673
+ function moveLockAside(path, suffix, operation, source) {
674
+ try {
675
+ if (source.isSymbolicLink())
676
+ throw new DeviceStateError(operation, "SYMLINK_LOCK");
677
+ // Reserve a private, unique destination; never rename over shared archives.
678
+ const archive = `${path}.${suffix}.${randomUUID()}`;
679
+ mkdirSync(archive, { mode: 0o700 });
680
+ const current = lstatSync(path);
681
+ if (source.dev !== current.dev || source.ino !== current.ino || source.mtimeMs !== current.mtimeMs || source.size !== current.size) {
682
+ throw new DeviceStateError(operation, "LOCK_SOURCE_CHANGED");
683
+ }
684
+ renameSync(path, `${archive}/lock`);
685
+ }
686
+ catch (error) {
687
+ if (error.code === "ENOENT")
688
+ return;
689
+ throw asDeviceStateError(error, operation);
363
690
  }
364
691
  }
365
692
  function asDeviceStateError(error, operation) {
366
- const code = error.code;
367
- return code === "EACCES" || code === "EPERM" || code === "EROFS" || code === "ENOTDIR" || code === "EISDIR"
368
- ? new DeviceStateError(operation, code)
369
- : undefined;
693
+ if (error instanceof DeviceStateError)
694
+ return error;
695
+ const code = error?.code;
696
+ const known = ["EACCES", "EPERM", "EROFS", "ENOTDIR", "EISDIR", "EEXIST", "ENOENT", "ENOTEMPTY", "ENOSPC", "EMFILE", "EIO", "CODEBUDDY_BROKER_DENY", "CODEBUDDY_BROKER_OUTCOME_UNKNOWN"];
697
+ return new DeviceStateError(operation, typeof code === "string" && known.includes(code) ? code : "UNCLASSIFIED_LOCAL_FAILURE");
370
698
  }
371
699
  function asDeviceStatePathError(error, operation) {
372
700
  const code = error.code;
373
- return code === "EEXIST" ? new DeviceStateError(operation, code) : asDeviceStateError(error, operation);
701
+ return code === "EEXIST" ? new DeviceStateError(operation, code, error.path) : asDeviceStateError(error, operation);
374
702
  }
703
+ import { stateDir } from "./config.js";
704
+ import { execFileSync } from "node:child_process";