@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.
- package/README.md +12 -5
- package/assets/sell-preview/index.html +6 -0
- package/assets/sell-preview/preview.js +9 -0
- package/bin/darwin-amd64/itpay-sell +0 -0
- package/bin/darwin-arm64/itpay-sell +0 -0
- package/bin/linux-amd64/itpay-sell +0 -0
- package/bin/linux-arm64/itpay-sell +0 -0
- package/bin/windows-amd64/itpay-sell.exe +0 -0
- package/dist/src/client/backend.js +38 -2
- package/dist/src/client/http.js +2 -0
- package/dist/src/commands/aftercare.js +12 -0
- package/dist/src/commands/buy.js +17 -58
- package/dist/src/commands/cart.js +17 -15
- package/dist/src/commands/catalog.js +39 -14
- package/dist/src/commands/checkout.js +146 -5
- package/dist/src/commands/checkout_handoff.js +11 -61
- package/dist/src/commands/docs.js +7 -18
- package/dist/src/commands/guidance.js +1 -134
- package/dist/src/commands/install.js +6 -3
- package/dist/src/commands/order.js +34 -3
- package/dist/src/commands/orders.js +4 -9
- package/dist/src/commands/pay.js +1 -1
- package/dist/src/commands/presentation.js +153 -0
- package/dist/src/commands/rail_catalog.js +1 -0
- package/dist/src/commands/readyz.js +1 -1
- package/dist/src/commands/refund.js +13 -57
- package/dist/src/commands/services/actions.js +133 -0
- package/dist/src/commands/services/commerce.js +197 -0
- package/dist/src/commands/services/lifecycle.js +418 -0
- package/dist/src/commands/services/results.js +130 -0
- package/dist/src/commands/services.js +14 -1068
- package/dist/src/commands/skill.js +8 -5
- package/dist/src/commands/vault.js +27 -20
- package/dist/src/commands/vault_handoff.js +18 -0
- package/dist/src/main.js +332 -46
- package/dist/src/render/browser.js +65 -0
- package/dist/src/render/checkout_handoff.js +101 -0
- package/dist/src/render/checkout_plan.js +47 -0
- package/dist/src/render/markdown.js +0 -3
- package/dist/src/render/qr.js +1 -1
- package/dist/src/sell/commands.js +80 -0
- package/dist/src/sell/contract.js +86 -0
- package/dist/src/sell/local.js +260 -0
- package/dist/src/sell/mcp.js +49 -0
- package/dist/src/sell/preview.js +70 -0
- package/dist/src/sell/sync.js +145 -0
- package/dist/src/services/adapter.js +1 -0
- package/dist/src/services/presentation.js +198 -0
- package/dist/src/services/rail/actions.js +51 -0
- package/dist/src/services/rail/adapter.js +36 -0
- package/dist/src/services/rail/aftercare.js +45 -0
- package/dist/src/services/rail/catalog.js +261 -0
- package/dist/src/services/rail/checkout.js +19 -0
- package/dist/src/services/rail/fulfillment.js +76 -0
- package/dist/src/services/rail/guide.js +110 -0
- package/dist/src/services/rail/invocation.js +54 -0
- package/dist/src/services/rail/page.js +76 -0
- package/dist/src/services/rail/pagination.js +6 -0
- package/dist/src/services/rail/query.js +71 -0
- package/dist/src/services/rail/readers.js +107 -0
- package/dist/src/services/rail/reading.js +125 -0
- package/dist/src/services/rail/recovery.js +51 -0
- package/dist/src/services/rail/results.js +378 -0
- package/dist/src/services/rail/review.js +268 -0
- package/dist/src/services/rail/saved.js +49 -0
- package/dist/src/services/rail/wait.js +11 -0
- package/dist/src/services/registry.js +31 -0
- package/dist/src/services/schema.js +21 -0
- package/dist/src/services/shared/envelope.js +184 -0
- package/dist/src/services/shared/input.js +39 -0
- package/dist/src/services/shared/pagination.js +28 -0
- package/dist/src/services/shared/platform.js +365 -0
- package/dist/src/services/shared/refund.js +40 -0
- package/dist/src/services/shared/workflow.js +28 -0
- package/dist/src/state/account_auth.js +354 -0
- package/dist/src/state/cart_session.js +4 -1
- package/dist/src/state/client_context.js +72 -0
- package/dist/src/state/config.js +44 -14
- package/dist/src/state/device_authority.js +402 -72
- package/dist/src/state/task_journal.js +90 -0
- package/docs/agent/buyer/cart-checkout.json +8 -6
- package/docs/agent/buyer/catalog-list.json +29 -9
- package/docs/agent/buyer/identity-and-sessions.json +26 -9
- package/docs/agent/buyer/install-and-setup.json +17 -15
- package/docs/agent/buyer/orders-refunds.json +25 -9
- package/docs/agent/buyer/payment-flow.json +6 -4
- package/docs/agent/buyer/purchased-content.json +18 -4
- package/docs/agent/buyer/quickstart.json +26 -17
- package/docs/agent/buyer/rail-booking.json +176 -0
- package/docs/agent/buyer/rail-fast-checkout.json +48 -0
- package/docs/agent/buyer/render-hosts.json +23 -12
- package/docs/agent/buyer/service-feedback.json +4 -3
- package/docs/cli-reference/agent-types.md +7 -2
- package/docs/cli-reference/commands/aftercare.md +15 -0
- package/docs/cli-reference/commands/auth.md +96 -0
- package/docs/cli-reference/commands/buy.md +2 -1
- package/docs/cli-reference/commands/cart/add.md +5 -1
- package/docs/cli-reference/commands/cart/clear.md +1 -1
- package/docs/cli-reference/commands/cart/index.md +1 -1
- package/docs/cli-reference/commands/cart/next.md +5 -1
- package/docs/cli-reference/commands/cart/remove.md +1 -1
- package/docs/cli-reference/commands/cart/show.md +1 -1
- package/docs/cli-reference/commands/catalog/index.md +1 -1
- package/docs/cli-reference/commands/catalog/list.md +2 -2
- package/docs/cli-reference/commands/checkout.md +12 -2
- package/docs/cli-reference/commands/device.md +18 -11
- package/docs/cli-reference/commands/docs/index.md +1 -1
- package/docs/cli-reference/commands/docs/list.md +1 -1
- package/docs/cli-reference/commands/docs/search.md +1 -1
- package/docs/cli-reference/commands/docs/show.md +1 -1
- package/docs/cli-reference/commands/install.md +8 -6
- package/docs/cli-reference/commands/next.md +1 -1
- package/docs/cli-reference/commands/order.md +1 -1
- package/docs/cli-reference/commands/pay.md +1 -1
- package/docs/cli-reference/commands/readyz.md +11 -11
- package/docs/cli-reference/commands/refund/cancel.md +1 -1
- package/docs/cli-reference/commands/refund/create.md +1 -1
- package/docs/cli-reference/commands/refund/get.md +2 -2
- package/docs/cli-reference/commands/refund/index.md +1 -1
- package/docs/cli-reference/commands/refund/list.md +1 -1
- package/docs/cli-reference/commands/refund/watch.md +1 -1
- package/docs/cli-reference/commands/sell.md +1364 -0
- package/docs/cli-reference/commands/services/action.md +24 -3
- package/docs/cli-reference/commands/services/checkout.md +17 -2
- package/docs/cli-reference/commands/services/events.md +1 -1
- package/docs/cli-reference/commands/services/get.md +4 -2
- package/docs/cli-reference/commands/services/index.md +4 -3
- package/docs/cli-reference/commands/services/invoke.md +6 -4
- package/docs/cli-reference/commands/services/list.md +1 -1
- package/docs/cli-reference/commands/services/next.md +78 -12
- package/docs/cli-reference/commands/services/page.md +71 -0
- package/docs/cli-reference/commands/services/quote.md +4 -4
- package/docs/cli-reference/commands/services/read-result.md +17 -6
- package/docs/cli-reference/commands/services/run.md +152 -0
- package/docs/cli-reference/commands/services/start.md +5 -3
- package/docs/cli-reference/commands/vault/access.md +5 -1
- package/docs/cli-reference/conventions.md +4 -6
- package/docs/cli-reference/index.md +10 -1
- package/docs/sell.md +84 -0
- package/package.json +20 -12
- package/skills/itpay/SKILL.md +112 -102
- package/docs/skill-bundle-rollout/01-mcp-authentication.md +0 -134
- package/docs/skill-bundle-rollout/02-platform-bundle-repositories.md +0 -210
- package/docs/skill-bundle-rollout/03-platform-publishing.md +0 -161
- package/docs/skill-bundle-rollout/04-first-wave-platforms.md +0 -69
- package/docs/skill-bundle-rollout/05-sync-operations.md +0 -62
- package/docs/skill-bundle-rollout/README.md +0 -151
- /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(
|
|
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())
|
|
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
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
257
|
-
|
|
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)
|
|
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")
|
|
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 <
|
|
371
|
+
for (let attempt = 0; attempt < 800; attempt += 1) {
|
|
308
372
|
try {
|
|
309
|
-
|
|
310
|
-
|
|
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")
|
|
318
|
-
|
|
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
|
|
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
|
-
|
|
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")
|
|
484
|
+
throw asDeviceStateError(error, "release_lock");
|
|
348
485
|
}
|
|
349
486
|
}
|
|
350
|
-
|
|
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
|
-
|
|
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
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
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
|
-
|
|
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
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
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";
|