@itpay/cli 2.0.40 → 2.1.0
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/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 +6 -0
- package/dist/src/commands/services.js +6 -4
- package/dist/src/main.js +2 -0
- package/dist/src/sell/auth.js +70 -0
- package/dist/src/sell/commands.js +80 -0
- package/dist/src/sell/contract.js +84 -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/state/config.js +31 -12
- package/docs/cli-reference/commands/sell.md +1342 -0
- package/docs/cli-reference/index.md +4 -0
- package/docs/sell.md +56 -0
- package/package.json +10 -6
- package/skills/itpay/SKILL.md +9 -2
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="zh-CN"><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>ItPay 工作流预览</title>
|
|
3
|
+
<style>body{max-width:1000px;margin:40px auto;padding:0 24px;font:16px/1.6 system-ui;color:#17212b;background:#fafbfc}pre{padding:20px;background:white;border:1px solid #dde3e8;border-radius:8px;overflow:auto;white-space:pre-wrap}small{color:#52616e}h2{margin-top:32px}</style>
|
|
4
|
+
<h1>工作流预览</h1><p id="status" role="status">正在读取本地项目…</p><small>只读本地预览。正式发布仍需平台验证、审核和 Canary。</small>
|
|
5
|
+
<h2>工作流</h2><pre id="document"></pre><h2>价格配置</h2><pre id="pricing"></pre><h2>最近测试</h2><pre id="run"></pre>
|
|
6
|
+
<script src="preview.js"></script></html>
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
fetch('document', {cache:'no-store'}).then(response => {
|
|
2
|
+
if (!response.ok) throw new Error('请先初始化项目并保存工作流版本。');
|
|
3
|
+
return response.json();
|
|
4
|
+
}).then(data => {
|
|
5
|
+
document.getElementById('status').textContent = `版本:${data.version} · ${data.hash}`;
|
|
6
|
+
document.getElementById('document').textContent = data.document;
|
|
7
|
+
document.getElementById('pricing').textContent = JSON.stringify(data.pricing, null, 2);
|
|
8
|
+
document.getElementById('run').textContent = data.run ? JSON.stringify(data.run, null, 2) : '尚无本地测试记录';
|
|
9
|
+
}).catch(error => { document.getElementById('status').textContent = error.message; });
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
@@ -5,6 +5,12 @@ export class BackendClient {
|
|
|
5
5
|
constructor(http) {
|
|
6
6
|
this.http = http;
|
|
7
7
|
}
|
|
8
|
+
// Called only through the fixed Sell operation contract, never arbitrary Agent URLs.
|
|
9
|
+
sellRequest(request) {
|
|
10
|
+
if (!request.path.startsWith("/v1/seller/organizations") && !request.path.startsWith("/v1/library/"))
|
|
11
|
+
throw new Error("Invalid Sell route");
|
|
12
|
+
return this.http.request(request.path, { method: request.method, ...(request.body !== undefined ? { body: request.body } : {}) });
|
|
13
|
+
}
|
|
8
14
|
readyz() {
|
|
9
15
|
return this.http.get("/v1/readyz");
|
|
10
16
|
}
|
|
@@ -654,7 +654,8 @@ function servicesNextEnvelope(model) {
|
|
|
654
654
|
const lockedRefund = model.refunds.find((refund) => refund.access_locked);
|
|
655
655
|
if (model.workflow_entry && !lockedRefund && !["completed", "delivery"].includes(model.workflow?.status ?? "")) {
|
|
656
656
|
const id = execution.service_execution_id;
|
|
657
|
-
const
|
|
657
|
+
const paymentVerified = model.payment_bindings.some((binding) => binding.status === "payment_verified") || model.checkout_bindings.some((binding) => binding.status === "payment_verified");
|
|
658
|
+
const state = model.workflow?.status === "payment" && paymentVerified ? "running" : model.workflow?.status ?? "input_required";
|
|
658
659
|
const recovery = state === "recovery_required" || state === "failed";
|
|
659
660
|
let command = `itpay services next ${id} --json`;
|
|
660
661
|
if (state === "payment")
|
|
@@ -700,7 +701,7 @@ function servicesNextEnvelope(model) {
|
|
|
700
701
|
recovery: [],
|
|
701
702
|
};
|
|
702
703
|
}
|
|
703
|
-
if (isTerminalServiceExecutionStatus(execution.status)) {
|
|
704
|
+
if (isTerminalServiceExecutionStatus(execution.status) && !(model.workflow_entry && currentDelivery && ["completed", "delivery_completed"].includes(execution.status))) {
|
|
704
705
|
const paid = model.checkout_bindings.some((binding) => binding.status === "payment_verified") || Boolean(currentDelivery?.order_id);
|
|
705
706
|
const paidFailure = execution.status === "failed" && paid;
|
|
706
707
|
return {
|
|
@@ -1128,7 +1129,8 @@ export async function runServicesRun(backend, config, serviceID, input, options
|
|
|
1128
1129
|
}
|
|
1129
1130
|
const until = Date.now() + (options.timeoutSeconds ?? 120) * 1000;
|
|
1130
1131
|
const sleep = options.sleep ?? ((milliseconds) => new Promise(resolve => setTimeout(resolve, milliseconds)));
|
|
1131
|
-
|
|
1132
|
+
const paid = () => model.payment_bindings.some((binding) => binding.status === "payment_verified") || model.checkout_bindings.some((binding) => binding.status === "payment_verified");
|
|
1133
|
+
while ((["queued", "running", "delivery"].includes(model.workflow?.status ?? "") || (model.workflow?.status === "payment" && paid())) && !model.current_delivery && Date.now() < until) {
|
|
1132
1134
|
await sleep(options.pollIntervalMS ?? 1500);
|
|
1133
1135
|
model = await backend.getServiceExecution(id);
|
|
1134
1136
|
}
|
|
@@ -1136,7 +1138,7 @@ export async function runServicesRun(backend, config, serviceID, input, options
|
|
|
1136
1138
|
await runServicesNext(backend, id, options);
|
|
1137
1139
|
return;
|
|
1138
1140
|
}
|
|
1139
|
-
if (model.workflow?.status === "payment") {
|
|
1141
|
+
if (model.workflow?.status === "payment" && !paid()) {
|
|
1140
1142
|
await runServicesCheckout(backend, config, id, model.workflow_entry?.capability_id, {
|
|
1141
1143
|
...options,
|
|
1142
1144
|
...(config.agentType ? { agentType: config.agentType } : {}),
|
package/dist/src/main.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { registerSell } from "./sell/commands.js";
|
|
1
2
|
import { readFileSync as readWorkflowInputFile } from "node:fs";
|
|
2
3
|
import { runServicesRun } from "./commands/services.js";
|
|
3
4
|
// V3 CLI entrypoint. Each command maps 1:1 to a route family in
|
|
@@ -1533,6 +1534,7 @@ services
|
|
|
1533
1534
|
});
|
|
1534
1535
|
}
|
|
1535
1536
|
});
|
|
1537
|
+
registerSell(program);
|
|
1536
1538
|
program.parseAsync(process.argv).catch((error) => {
|
|
1537
1539
|
reportCLIError(error);
|
|
1538
1540
|
});
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { homedir } from 'node:os';
|
|
3
|
+
import { dirname, resolve } from 'node:path';
|
|
4
|
+
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync, rmSync } from 'node:fs';
|
|
5
|
+
export function sellerAuthPath(baseURL, env = process.env) {
|
|
6
|
+
return resolve(env.HOME || homedir(), '.itpay-v3', `seller-${createHash('sha256').update(baseURL).digest('hex').slice(0, 16)}.json`);
|
|
7
|
+
}
|
|
8
|
+
function read(baseURL, env = process.env) {
|
|
9
|
+
const path = sellerAuthPath(baseURL, env);
|
|
10
|
+
if (!existsSync(path))
|
|
11
|
+
return;
|
|
12
|
+
const state = JSON.parse(readFileSync(path, 'utf8'));
|
|
13
|
+
if (state.baseURL !== baseURL)
|
|
14
|
+
throw new Error('Seller session backend mismatch');
|
|
15
|
+
return state;
|
|
16
|
+
}
|
|
17
|
+
function save(state, env = process.env) {
|
|
18
|
+
const path = sellerAuthPath(state.baseURL, env);
|
|
19
|
+
mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
|
|
20
|
+
const tmp = `${path}.${process.pid}.tmp`;
|
|
21
|
+
writeFileSync(tmp, JSON.stringify(state), { mode: 0o600 });
|
|
22
|
+
renameSync(tmp, path);
|
|
23
|
+
}
|
|
24
|
+
export function sellerSessionToken(baseURL, env = process.env) {
|
|
25
|
+
const state = read(baseURL, env);
|
|
26
|
+
return state?.expiresAt && Date.parse(state.expiresAt) > Date.now() ? state.sessionToken : undefined;
|
|
27
|
+
}
|
|
28
|
+
export async function sellerAuth(action, baseURL, env = process.env, fetcher = fetch) {
|
|
29
|
+
async function request(path, init = {}) {
|
|
30
|
+
const response = await fetcher(baseURL + path, { ...init, redirect: 'error', signal: AbortSignal.timeout(15000) });
|
|
31
|
+
if (!response.ok)
|
|
32
|
+
throw new Error(`Seller authorization failed (${response.status}); retry login if expired`);
|
|
33
|
+
return response;
|
|
34
|
+
}
|
|
35
|
+
if (action === 'login') {
|
|
36
|
+
const response = await request('/v1/dashboard/auth-sessions', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ provider: 'alipay', return_to: '/seller' }) });
|
|
37
|
+
const result = await response.json();
|
|
38
|
+
const url = new URL(result.start_url, baseURL);
|
|
39
|
+
if (url.origin !== new URL(baseURL).origin)
|
|
40
|
+
throw new Error('Unexpected authorization origin');
|
|
41
|
+
const startToken = url.searchParams.get('start_token');
|
|
42
|
+
if (!startToken || !result.poll_token || !result.dashboard_auth_session_id)
|
|
43
|
+
throw new Error('Incomplete authorization response');
|
|
44
|
+
save({ baseURL, sessionID: result.dashboard_auth_session_id, pollToken: result.poll_token, startToken }, env);
|
|
45
|
+
return { status: 'authorization_required', authorization_url: url.href, instruction: 'Complete ItPay login in the browser, then run itpay sell auth status.' };
|
|
46
|
+
}
|
|
47
|
+
const state = read(baseURL, env);
|
|
48
|
+
if (action === 'logout') {
|
|
49
|
+
const token = sellerSessionToken(baseURL, env);
|
|
50
|
+
if (token)
|
|
51
|
+
await request('/v1/me/logout', { method: 'POST', headers: { Authorization: `Bearer ${token}` } });
|
|
52
|
+
rmSync(sellerAuthPath(baseURL, env), { force: true });
|
|
53
|
+
return { status: 'logged_out' };
|
|
54
|
+
}
|
|
55
|
+
if (sellerSessionToken(baseURL, env))
|
|
56
|
+
return { status: 'authenticated', base_url: baseURL, expires_at: state?.expiresAt };
|
|
57
|
+
if (!state?.sessionID || !state.pollToken || !state.startToken)
|
|
58
|
+
return { status: 'login_required' };
|
|
59
|
+
const path = `/v1/dashboard/auth-sessions/${encodeURIComponent(state.sessionID)}`;
|
|
60
|
+
const progress = await (await request(`${path}?poll_token=${encodeURIComponent(state.pollToken)}`)).json();
|
|
61
|
+
if (progress.status !== 'completed')
|
|
62
|
+
return { status: progress.status, instruction: 'Finish login and email verification in the browser.' };
|
|
63
|
+
const claimed = await request(`${path}/claim?start_token=${encodeURIComponent(state.startToken)}`, { method: 'POST' });
|
|
64
|
+
const token = /(?:^|[, ]+)itpay_buyer_session=([^;]+)/.exec(claimed.headers.get('set-cookie') ?? '')?.[1];
|
|
65
|
+
const session = await claimed.json();
|
|
66
|
+
if (!token || !session.expires_at || !(Date.parse(session.expires_at) > Date.now()))
|
|
67
|
+
throw new Error('Incomplete Seller session');
|
|
68
|
+
save({ baseURL, sessionToken: token, expiresAt: session.expires_at }, env);
|
|
69
|
+
return { status: 'authenticated', base_url: baseURL, expires_at: session.expires_at };
|
|
70
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { sellerAuth } from "./auth.js";
|
|
2
|
+
import { registerSync } from "./sync.js";
|
|
3
|
+
import { preview } from "./preview.js";
|
|
4
|
+
import { serveSellMCP } from "./mcp.js";
|
|
5
|
+
import { registerLocal } from "./local.js";
|
|
6
|
+
import { readFileSync } from 'node:fs';
|
|
7
|
+
import { loadConfig, newBackendClient } from '../state/config.js';
|
|
8
|
+
import { SELL_GUIDE, SELL_OPERATIONS, sellRequest } from './contract.js';
|
|
9
|
+
export function registerSell(program) {
|
|
10
|
+
const sell = program.command('sell').description('Create, test and submit your service for review');
|
|
11
|
+
const auth = sell.command('auth').description('Authorize this CLI using normal ItPay account login');
|
|
12
|
+
for (const action of ['login', 'status', 'logout'])
|
|
13
|
+
auth.command(action).action(async () => {
|
|
14
|
+
process.stdout.write(JSON.stringify(await sellerAuth(action, loadConfig().baseURL)) + '\n');
|
|
15
|
+
});
|
|
16
|
+
const groups = new Map([['', sell]]);
|
|
17
|
+
for (const operation of SELL_OPERATIONS) {
|
|
18
|
+
const parts = operation.command.split(' ');
|
|
19
|
+
const leaf = parts.pop();
|
|
20
|
+
let parent = sell, key = '';
|
|
21
|
+
for (const part of parts) {
|
|
22
|
+
key = key ? `${key} ${part}` : part;
|
|
23
|
+
let group = groups.get(key);
|
|
24
|
+
if (!group) {
|
|
25
|
+
group = parent.command(part);
|
|
26
|
+
groups.set(key, group);
|
|
27
|
+
}
|
|
28
|
+
parent = group;
|
|
29
|
+
}
|
|
30
|
+
const command = parent.command(leaf).description(operation.description).option('--json', 'Structured output').option('--input-json <file>', 'Request fields as JSON');
|
|
31
|
+
const required = [...operation.path.matchAll(/\{(\w+)\}/g)].map(m => m[1]);
|
|
32
|
+
for (const field of required)
|
|
33
|
+
command.option(`--${field.replaceAll('_', '-')} <value>`, field);
|
|
34
|
+
if (operation.confirmation)
|
|
35
|
+
command.option('--confirm', 'User explicitly confirmed the disclosed version/action');
|
|
36
|
+
command.action(async (options) => {
|
|
37
|
+
try {
|
|
38
|
+
if (operation.command === 'guide' && !options.merchantId) {
|
|
39
|
+
process.stdout.write(JSON.stringify({ ...SELL_GUIDE, operations: SELL_OPERATIONS }, null, 2) + '\n');
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
if (operation.confirmation && !options.confirm)
|
|
43
|
+
throw new Error('Explicit user confirmation required after reviewing this action; pass --confirm only after user approval');
|
|
44
|
+
const input = options.inputJson ? JSON.parse(readFileSync(String(options.inputJson), 'utf8')) : {};
|
|
45
|
+
if (!input || typeof input !== 'object' || Array.isArray(input))
|
|
46
|
+
throw new Error('Input JSON must be an object');
|
|
47
|
+
const params = {};
|
|
48
|
+
for (const field of required)
|
|
49
|
+
params[field] = options[field.replace(/_([a-z])/g, (_, c) => c.toUpperCase())];
|
|
50
|
+
const request = sellRequest(operation, params, input);
|
|
51
|
+
const result = await newBackendClient(loadConfig()).sellRequest(request);
|
|
52
|
+
process.stdout.write(JSON.stringify({ status: 'ok', operation: operation.command, result: result ?? null }, null, 2) + '\n');
|
|
53
|
+
}
|
|
54
|
+
catch (error) {
|
|
55
|
+
process.exitCode = 1;
|
|
56
|
+
process.stdout.write(JSON.stringify({ status: 'error', operation: operation.command, message: error instanceof Error ? error.message : 'Sell operation failed', next: 'itpay sell guide --json' }, null, 2) + '\n');
|
|
57
|
+
}
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
registerLocal(sell);
|
|
61
|
+
registerSync(sell);
|
|
62
|
+
sell.commands.find(command => command.name() === "workflow").command("preview").option("--project <directory>", "Project directory", ".").action(async (options) => { const result = await preview(options.project); process.stdout.write(JSON.stringify({ url: result.url, instruction: "Open this local URL to inspect the saved workflow" }) + "\n"); });
|
|
63
|
+
sell.command("mcp").description("Serve local Seller MCP over stdio").requiredOption("--stdio").option("--project <directory>", "Project directory", ".").action(async (options) => { await serveSellMCP(options.project); });
|
|
64
|
+
const submission = sell.commands.find(command => command.name() === "submission");
|
|
65
|
+
submission.command("watch").requiredOption("--merchant-id <id>").requiredOption("--submission-id <id>").option("--timeout <seconds>", "Maximum wait", "120").option("--json").action(async (options) => {
|
|
66
|
+
const timeout = Number(options.timeout);
|
|
67
|
+
if (!Number.isFinite(timeout) || timeout < 0 || timeout > 600)
|
|
68
|
+
throw new Error("timeout must be between 0 and 600");
|
|
69
|
+
const backend = newBackendClient(loadConfig()), until = Date.now() + timeout * 1000;
|
|
70
|
+
for (;;) {
|
|
71
|
+
const result = await backend.sellRequest({ method: "GET", path: "/v1/seller/organizations/" + encodeURIComponent(options.merchantId) + "/service-submissions/" + encodeURIComponent(options.submissionId) });
|
|
72
|
+
if (!["submitted", "pending", "under_review"].includes(result.status) || Date.now() >= until) {
|
|
73
|
+
process.stdout.write(JSON.stringify({ status: result.status, result }) + "\n");
|
|
74
|
+
break;
|
|
75
|
+
}
|
|
76
|
+
await new Promise(resolve => setTimeout(resolve, 2000));
|
|
77
|
+
}
|
|
78
|
+
});
|
|
79
|
+
return sell;
|
|
80
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
const org = "/v1/seller/organizations/{merchant_id}";
|
|
2
|
+
const draft = `${org}/service-drafts/{draft_id}`;
|
|
3
|
+
const read = "itpay.seller.read", write = "itpay.seller.write", test = "itpay.seller.test", submit = "itpay.seller.submit";
|
|
4
|
+
export const SELL_GUIDE = {
|
|
5
|
+
version: "itpay.sell.v1", command: "itpay sell", truth: "workflow.yaml",
|
|
6
|
+
steps: ["auth", "setup", "sources", "workflow", "local_test", "confirm", "push", "platform_verification", "pricing", "submission", "publication"],
|
|
7
|
+
rules: [
|
|
8
|
+
"Use the authenticated user's existing merchant. KYB and payout changes remain in the dashboard.",
|
|
9
|
+
"Resolve every API against its declared method, Content-Type, parameters, response schema and locked version. Ask for missing inputs; never guess credentials or change contracts.",
|
|
10
|
+
"Only saved versions may run. A run is not a saved version. Local evidence cannot grant platform test approval.",
|
|
11
|
+
"Public workflows are acyclic, synchronous, per-call; include exactly one payment step and delivery on every success path. Unsupported nodes block publication.",
|
|
12
|
+
"Use own credentials for local requests. Platform-managed credentials stay on the platform. Mark execution location honestly.",
|
|
13
|
+
"Present side effects before live tests. Unknown results stop automatic retries. Local payment/refund/delivery gates are simulated, never real funds.",
|
|
14
|
+
"Show the exact workflow and version before user confirmation. Preview price, delivery and current terms before explicit submission approval.",
|
|
15
|
+
"Use platform guide for next actions. Submission is not publication: only an actual admin approval publishes the service."
|
|
16
|
+
]
|
|
17
|
+
};
|
|
18
|
+
export const SELL_OPERATIONS = [
|
|
19
|
+
{ command: "status", method: "GET", path: "/v1/seller/organizations", scope: read, fields: {}, description: "List existing merchant memberships" },
|
|
20
|
+
{ command: "guide", method: "GET", path: `${org}/guide`, scope: read, fields: { draft_id: "string" }, optional: ["draft_id"], description: "Read authoritative publication blockers and next actions" },
|
|
21
|
+
{ command: "library search", method: "GET", path: "/v1/library/apis", scope: read, fields: { q: "string" }, optional: ["q"], description: "Search available API contracts" },
|
|
22
|
+
{ command: "library get", method: "GET", path: "/v1/library/apis/{library_api_id}", scope: read, fields: {}, description: "Inspect an API contract" },
|
|
23
|
+
{ command: "sources list", method: "GET", path: `${org}/api-intakes`, scope: read, fields: {}, description: "List imported API sources" },
|
|
24
|
+
{ command: "sources inspect", method: "GET", path: `${org}/api-intakes/{intake_id}`, scope: read, fields: {}, description: "Inspect imported operations and verification results" },
|
|
25
|
+
{ command: "sources import", method: "POST", path: `${org}/api-intakes`, scope: write, fields: { provider_key: "string", source_url: "string", document: "document", media_type: "string" }, optional: ["source_url", "document", "media_type"], description: "Import an OpenAPI source without inventing request contracts" },
|
|
26
|
+
{ command: "sources library", method: "POST", path: `${org}/api-intakes/from-library`, scope: write, fields: { library_api_id: "string" }, description: "Bind a platform library source" },
|
|
27
|
+
{ command: "sources probe", method: "POST", path: `${org}/api-intakes/{intake_id}/probe`, scope: test, fields: { credential_profile_id: "string", operation_inputs: "object" }, description: "Verify selected API requests on the platform", confirmation: true },
|
|
28
|
+
{ command: "credentials status", method: "GET", path: `${org}/provider-credential-profiles`, scope: read, fields: {}, description: "Read credential bindings without secret values" },
|
|
29
|
+
{ command: "services list", method: "GET", path: `${org}/service-drafts`, scope: read, fields: {}, description: "List own service drafts and publication state" },
|
|
30
|
+
{ command: "services create", method: "POST", path: `${org}/service-drafts`, scope: write, fields: { service_id: "string", public_name: "string" }, description: "Create a platform service draft" },
|
|
31
|
+
{ command: "services icon", method: "PUT", path: `${draft}/icon/content`, scope: write, fields: { content_base64: "string" }, description: "Upload a PNG/JPEG service icon, max 512 KiB, as signed JSON" },
|
|
32
|
+
{ command: "services get", method: "GET", path: `${draft}/workflow`, scope: read, fields: {}, description: "Read the current workflow and service settings" },
|
|
33
|
+
{ command: "workflow catalog", method: "GET", path: `${org}/node-catalog`, scope: read, fields: {}, description: "Read exact supported node templates and required configuration" },
|
|
34
|
+
{ command: "workflow plan", method: "POST", path: `${draft}/orchestration-runs`, scope: test, fields: { expected_revision: "number", instruction: "string", api_intake_ids: "array", credential_profile_bindings: "object" }, description: "Explicitly use ItPay AI planning and platform verification", confirmation: true },
|
|
35
|
+
{ command: "workflow plan-get", method: "GET", path: `${draft}/orchestration-runs/{run_id}`, scope: read, fields: {}, description: "Read the AI candidate and diagnostics" },
|
|
36
|
+
{ command: "workflow plan-apply", method: "POST", path: `${draft}/orchestration-runs/{run_id}/apply`, scope: write, fields: { expected_revision: "number" }, description: "Apply a reviewed AI candidate to the draft", confirmation: true },
|
|
37
|
+
{ command: "workflow upload", method: "PUT", path: `${draft}/workflow`, scope: write, fields: { expected_revision: "number", public_name: "string", arazzo: "object" }, optional: ["public_name"], description: "Update the platform workflow with optimistic concurrency" },
|
|
38
|
+
{ command: "workflow validate-platform", method: "POST", path: `${draft}/workflow/validate`, scope: write, fields: {}, description: "Validate using the same public runtime rules as approval" },
|
|
39
|
+
{ command: "workflow versions list-platform", method: "GET", path: `${draft}/workflow/versions`, scope: read, fields: {}, description: "List explicitly saved platform versions" },
|
|
40
|
+
{ command: "workflow versions save-platform", method: "POST", path: `${draft}/workflow/versions`, scope: write, fields: { name: "string", source: "string", arazzo_document: "string", expected_revision: "number" }, description: "Save an explicit immutable platform version" },
|
|
41
|
+
{ command: "workflow versions delete-platform", method: "DELETE", path: `${draft}/workflow/versions/{version_id}`, scope: write, fields: { expected_revision: "number" }, description: "Delete an eligible saved version and its runs", confirmation: true },
|
|
42
|
+
{ command: "fixtures get", method: "GET", path: `${draft}/fixtures`, scope: read, fields: {}, description: "Read test inputs" },
|
|
43
|
+
{ command: "fixtures set", method: "PUT", path: `${draft}/fixtures`, scope: write, fields: { expected_revision: "number", fixtures: "array" }, description: "Save explicit test inputs" },
|
|
44
|
+
{ command: "pricing get", method: "GET", path: `${draft}/workflow`, scope: read, fields: {}, description: "Read the current price and refund policy" },
|
|
45
|
+
{ command: "pricing set", method: "PUT", path: `${draft}/pricing`, scope: write, fields: { expected_revision: "number", pricing: "object", policy: "object" }, description: "Set per-call price and platform refund policy" },
|
|
46
|
+
{ command: "verify", method: "POST", path: `${draft}/validation-runs`, scope: test, fields: { workflow_version_id: "string", expected_semantic_revision: "number", expected_fixture_revision: "number" }, description: "Start platform verification for an explicitly saved version", confirmation: true },
|
|
47
|
+
{ command: "runs get", method: "GET", path: `${draft}/validation-runs/{run_id}`, scope: read, fields: {}, description: "Inspect platform test nodes and diagnostics" },
|
|
48
|
+
{ command: "runs confirm", method: "POST", path: `${draft}/validation-runs/{run_id}/confirm`, scope: test, fields: { risk_hash: "string" }, description: "Confirm the exact disclosed platform test side effects", confirmation: true },
|
|
49
|
+
{ command: "submission preview", method: "GET", path: `${org}/guide`, scope: read, fields: { draft_id: "string" }, description: "Read publication readiness and required agreements" },
|
|
50
|
+
{ command: "submission submit", method: "POST", path: `${draft}/submit`, scope: submit, fields: { expected_revision: "number", terms_version: "string", confirmations: "array" }, description: "Submit the confirmed version for review, never approve it", confirmation: true },
|
|
51
|
+
{ command: "submission get", method: "GET", path: `${org}/service-submissions/{submission_id}`, scope: read, fields: {}, description: "Read the actual review/publication result" },
|
|
52
|
+
{ command: "submission withdraw", method: "POST", path: `${org}/service-submissions/{submission_id}/withdraw`, scope: submit, fields: {}, description: "Withdraw own pending submission", confirmation: true },
|
|
53
|
+
];
|
|
54
|
+
export function sellRequest(operation, params, input = {}) {
|
|
55
|
+
let path = operation.path.replace(/\{(\w+)\}/g, (_, key) => {
|
|
56
|
+
const value = params[key];
|
|
57
|
+
if (typeof value !== "string" || !value.trim())
|
|
58
|
+
throw new Error(`${key} is required`);
|
|
59
|
+
return encodeURIComponent(value);
|
|
60
|
+
});
|
|
61
|
+
for (const key of Object.keys(input))
|
|
62
|
+
if (!operation.fields[key])
|
|
63
|
+
throw new Error(`Unsupported field: ${key}`);
|
|
64
|
+
for (const [key, type] of Object.entries(operation.fields)) {
|
|
65
|
+
const value = input[key];
|
|
66
|
+
if (value === undefined && operation.optional?.includes(key))
|
|
67
|
+
continue;
|
|
68
|
+
const valid = type === "document" ? (typeof value === "string" || (!!value && typeof value === "object" && !Array.isArray(value))) : type === "array" ? Array.isArray(value) : type === "object" ? !!value && typeof value === "object" && !Array.isArray(value) : typeof value === type;
|
|
69
|
+
if (!valid)
|
|
70
|
+
throw new Error(`${key} must be ${type}`);
|
|
71
|
+
if (type === "number" && (!Number.isSafeInteger(value) || Number(value) < 0))
|
|
72
|
+
throw new Error(`${key} must be a nonnegative integer`);
|
|
73
|
+
}
|
|
74
|
+
if (operation.method === "GET") {
|
|
75
|
+
const query = [];
|
|
76
|
+
for (const [key, value] of Object.entries(input))
|
|
77
|
+
if (value !== undefined)
|
|
78
|
+
query.push(`${encodeURIComponent(key)}=${encodeURIComponent(String(value))}`);
|
|
79
|
+
if (query.length)
|
|
80
|
+
path += `?${query.join("&")}`;
|
|
81
|
+
return { path, method: operation.method };
|
|
82
|
+
}
|
|
83
|
+
return { path, method: operation.method, body: input };
|
|
84
|
+
}
|
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
import { newBackendClient, loadConfig } from "../state/config.js";
|
|
2
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
3
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync, renameSync, rmSync, readdirSync } from 'node:fs';
|
|
4
|
+
import { resolve, join, dirname } from 'node:path';
|
|
5
|
+
import { fileURLToPath } from 'node:url';
|
|
6
|
+
import { spawn } from 'node:child_process';
|
|
7
|
+
const hash = (value) => 'sha256:' + createHash('sha256').update(value).digest('hex');
|
|
8
|
+
function read(path) { return JSON.parse(readFileSync(path, 'utf8')); }
|
|
9
|
+
function save(path, value) { mkdirSync(dirname(path), { recursive: true, mode: 0o700 }); const tmp = path + '.' + randomUUID() + '.tmp'; writeFileSync(tmp, JSON.stringify(value, null, 2) + '\n', { mode: 0o600 }); renameSync(tmp, path); }
|
|
10
|
+
export function project(directory) { const root = resolve(directory); return { root, state: join(root, '.itpay-sell'), config: read(join(root, 'service.json')), yaml: readFileSync(join(root, 'workflow.yaml'), 'utf8') }; }
|
|
11
|
+
export function localDependencies(state) {
|
|
12
|
+
const platformPath = join(state, 'platform-dependencies.json');
|
|
13
|
+
const dependencies = existsSync(platformPath) ? read(platformPath) : {};
|
|
14
|
+
const sources = join(state, 'sources');
|
|
15
|
+
if (existsSync(sources))
|
|
16
|
+
for (const file of readdirSync(sources)) {
|
|
17
|
+
const source = read(join(sources, file));
|
|
18
|
+
for (const operation of source.operations)
|
|
19
|
+
dependencies[operation.provider_operation_version_id] = {
|
|
20
|
+
provider_operation_version_id: operation.provider_operation_version_id,
|
|
21
|
+
provider_key: source.provider_key, operation_key: operation.operation_key,
|
|
22
|
+
operation_hash: operation.operation_hash, operation: operation.operation,
|
|
23
|
+
request_schema: operation.request_schema, response_schema: operation.response_schema,
|
|
24
|
+
retry_policy: operation.retry_policy ?? {},
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
return dependencies;
|
|
28
|
+
}
|
|
29
|
+
export async function companion(input, onEvent) {
|
|
30
|
+
const target = `${process.platform === 'win32' ? 'windows' : process.platform}-${process.arch === 'x64' ? 'amd64' : process.arch}/itpay-sell${process.platform === 'win32' ? '.exe' : ''}`;
|
|
31
|
+
const packaged = fileURLToPath(new URL(`../../../bin/${target}`, import.meta.url));
|
|
32
|
+
const binary = process.env.ITPAY_SELL_RUNNER ?? (existsSync(packaged) ? packaged : fileURLToPath(new URL(`../../bin/${target}`, import.meta.url)));
|
|
33
|
+
if (!existsSync(binary))
|
|
34
|
+
throw new Error(`Local runner missing for ${process.platform}-${process.arch}; install a CLI package including its native runner`);
|
|
35
|
+
return new Promise((done, fail) => {
|
|
36
|
+
const child = spawn(binary, [], { stdio: ['pipe', 'pipe', 'pipe'] });
|
|
37
|
+
let buffer = '', stderr = '';
|
|
38
|
+
const events = [];
|
|
39
|
+
child.on('error', fail);
|
|
40
|
+
child.stderr.on('data', chunk => { stderr += String(chunk).slice(0, 2000); });
|
|
41
|
+
child.stdout.on('data', chunk => {
|
|
42
|
+
buffer += String(chunk);
|
|
43
|
+
let index;
|
|
44
|
+
while ((index = buffer.indexOf('\n')) >= 0) {
|
|
45
|
+
const line = buffer.slice(0, index);
|
|
46
|
+
buffer = buffer.slice(index + 1);
|
|
47
|
+
try {
|
|
48
|
+
const event = JSON.parse(line);
|
|
49
|
+
events.push(event);
|
|
50
|
+
onEvent?.(event);
|
|
51
|
+
}
|
|
52
|
+
catch {
|
|
53
|
+
child.kill();
|
|
54
|
+
fail(new Error('Invalid local runner response'));
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
});
|
|
58
|
+
child.on('close', code => {
|
|
59
|
+
if (code !== 0)
|
|
60
|
+
fail(new Error(events.find(event => event.type === 'error')?.value?.message ?? stderr ?? 'Local runner failed'));
|
|
61
|
+
else
|
|
62
|
+
done(events);
|
|
63
|
+
});
|
|
64
|
+
child.stdin.on('error', () => { });
|
|
65
|
+
child.stdin.end(JSON.stringify(input));
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
function last(events, type) { return [...events].reverse().find(event => event.type === type)?.value; }
|
|
69
|
+
export async function localAction(action, directory, options) {
|
|
70
|
+
const root = resolve(directory), state = join(root, '.itpay-sell');
|
|
71
|
+
if (action === 'init') {
|
|
72
|
+
if (existsSync(join(root, 'service.json')) || existsSync(join(root, 'workflow.yaml')) || existsSync(state))
|
|
73
|
+
throw new Error('Project already exists; existing files were not replaced');
|
|
74
|
+
mkdirSync(root, { recursive: true });
|
|
75
|
+
const config = { format_version: 1, service_id: options.serviceId ?? '', public_name: options.name ?? '', pricing: { billing_mode: 'per_call' }, policy: {}, fixtures: [] };
|
|
76
|
+
save(join(root, 'service.json'), config);
|
|
77
|
+
writeFileSync(join(root, 'workflow.yaml'), '# Generate the workflow from locked API contracts using itpay sell guide.\n', { flag: 'wx' });
|
|
78
|
+
save(join(state, 'versions.json'), { versions: [] });
|
|
79
|
+
return { root, next: 'itpay sell sources add --file <openapi> --provider-key <key>', missing: ['workflow', 'price', 'fixtures'] };
|
|
80
|
+
}
|
|
81
|
+
const p = project(root);
|
|
82
|
+
if (action === 'workflow import') {
|
|
83
|
+
if (!options.file)
|
|
84
|
+
throw new Error('--file is required');
|
|
85
|
+
const yaml = readFileSync(resolve(options.file), 'utf8');
|
|
86
|
+
const validation = last(await companion({ action: 'validate', dependencies: localDependencies(state), document: yaml, pricing: p.config.pricing, policy: p.config.policy }), 'validation');
|
|
87
|
+
const backup = join(state, 'imports', hash(p.yaml).slice(7) + '.yaml');
|
|
88
|
+
mkdirSync(dirname(backup), { recursive: true, mode: 0o700 });
|
|
89
|
+
writeFileSync(backup, p.yaml, { mode: 0o600 });
|
|
90
|
+
writeFileSync(join(root, 'workflow.yaml'), yaml);
|
|
91
|
+
return { imported: true, validation, next: 'Resolve all imported API references and save a new workflow version' };
|
|
92
|
+
}
|
|
93
|
+
if (action === 'config') {
|
|
94
|
+
if (options.file) {
|
|
95
|
+
const value = read(resolve(options.file));
|
|
96
|
+
for (const key of Object.keys(value))
|
|
97
|
+
if (!['service_id', 'public_name', 'pricing', 'policy', 'fixtures', 'merchant_id', 'draft_id', 'expected_revision'].includes(key))
|
|
98
|
+
throw new Error('Unsupported setting: ' + key);
|
|
99
|
+
save(join(root, 'service.json'), { ...p.config, ...value });
|
|
100
|
+
}
|
|
101
|
+
return read(join(root, 'service.json'));
|
|
102
|
+
}
|
|
103
|
+
if (action === 'sources add') {
|
|
104
|
+
if ((!options.file && !options.url) || !options.providerKey)
|
|
105
|
+
throw new Error('--file or --url, and --provider-key are required');
|
|
106
|
+
if (options.file && options.url)
|
|
107
|
+
throw new Error('Choose file or URL, not both');
|
|
108
|
+
const raw = options.file ? readFileSync(resolve(options.file), 'utf8') : last(await companion({ action: 'fetch', source_url: options.url }), 'source').document;
|
|
109
|
+
const id = hash(raw).slice(7);
|
|
110
|
+
const operations = last(await companion({ action: 'compile', document: raw, provider_key: options.providerKey }), 'compiled');
|
|
111
|
+
const path = join(state, 'sources', id + '.json');
|
|
112
|
+
save(path, { provider_key: options.providerKey, source_hash: hash(raw), original: raw, operations });
|
|
113
|
+
return { source_hash: hash(raw), operations, instruction: 'Use these exact contracts when constructing workflow.yaml; bind local credentials by environment reference' };
|
|
114
|
+
}
|
|
115
|
+
if (action === 'credentials bind') {
|
|
116
|
+
if (!options.profile || !options.file)
|
|
117
|
+
throw new Error('--profile and --file are required; file must map credential fields to environment variable names, not secret values');
|
|
118
|
+
const bindings = read(resolve(options.file));
|
|
119
|
+
for (const value of Object.values(bindings))
|
|
120
|
+
if (typeof value !== 'string' || !/^[A-Za-z_][A-Za-z0-9_]*$/.test(value))
|
|
121
|
+
throw new Error('Credential bindings must be environment variable names');
|
|
122
|
+
const path = join(state, 'credentials.json');
|
|
123
|
+
save(path, { ...(existsSync(path) ? read(path) : {}), [options.profile]: bindings });
|
|
124
|
+
return { profile: options.profile, bound_fields: Object.keys(bindings) };
|
|
125
|
+
}
|
|
126
|
+
if (action === 'credentials upload') {
|
|
127
|
+
if (!options.confirm || !options.profile || !options.providerKey || !options.merchantId)
|
|
128
|
+
throw new Error('Merchant, provider, local profile and explicit confirmation are required');
|
|
129
|
+
const bindings = read(join(state, 'credentials.json'))[options.profile];
|
|
130
|
+
if (!bindings)
|
|
131
|
+
throw new Error('Local profile not bound');
|
|
132
|
+
const secrets = {};
|
|
133
|
+
for (const [field, env] of Object.entries(bindings)) {
|
|
134
|
+
const value = process.env[String(env)];
|
|
135
|
+
if (!value)
|
|
136
|
+
throw new Error('Missing credential environment for ' + field);
|
|
137
|
+
secrets[field] = value;
|
|
138
|
+
}
|
|
139
|
+
const result = await newBackendClient(loadConfig()).sellRequest({ method: 'POST', path: '/v1/seller/organizations/' + encodeURIComponent(options.merchantId) + '/provider-credential-profiles', body: { provider_key: options.providerKey, profile_key: options.profile, display_name: options.name ?? options.profile, environment: options.environment ?? 'sandbox', secrets, metadata: options.file ? read(resolve(options.file)) : {} } });
|
|
140
|
+
return result;
|
|
141
|
+
}
|
|
142
|
+
const versionsPath = join(state, 'versions.json');
|
|
143
|
+
const versions = read(versionsPath);
|
|
144
|
+
if (action === 'workflow versions save') {
|
|
145
|
+
const validation = last(await companion({ action: 'validate', dependencies: localDependencies(state), document: p.yaml, pricing: p.config.pricing, policy: p.config.policy }), 'validation');
|
|
146
|
+
const version = { version_id: randomUUID(), name: options.name ?? 'Saved version', hash: hash(p.yaml), config_hash: hash(JSON.stringify(p.config)), created_at: new Date().toISOString() };
|
|
147
|
+
save(join(state, 'versions', version.version_id + '.json'), { ...version, document: p.yaml, config: p.config });
|
|
148
|
+
versions.versions.push(version);
|
|
149
|
+
versions.selected = version.version_id;
|
|
150
|
+
save(versionsPath, versions);
|
|
151
|
+
return { ...version, validation };
|
|
152
|
+
}
|
|
153
|
+
if (action === 'workflow versions list')
|
|
154
|
+
return versions;
|
|
155
|
+
if (action === 'workflow versions use' || action === 'workflow versions delete') {
|
|
156
|
+
const version = versions.versions.find((v) => v.version_id === options.version);
|
|
157
|
+
if (!version)
|
|
158
|
+
throw new Error('Saved version not found');
|
|
159
|
+
if (action.endsWith('use')) {
|
|
160
|
+
const saved = read(join(state, 'versions', version.version_id + '.json'));
|
|
161
|
+
writeFileSync(join(root, 'workflow.yaml'), saved.document);
|
|
162
|
+
save(join(root, 'service.json'), saved.config);
|
|
163
|
+
versions.selected = version.version_id;
|
|
164
|
+
}
|
|
165
|
+
else {
|
|
166
|
+
if (!options.confirm)
|
|
167
|
+
throw new Error('Confirm version deletion with --confirm');
|
|
168
|
+
versions.versions = versions.versions.filter((v) => v.version_id !== version.version_id);
|
|
169
|
+
if (versions.selected === version.version_id)
|
|
170
|
+
delete versions.selected;
|
|
171
|
+
rmSync(join(state, 'versions', version.version_id + '.json'));
|
|
172
|
+
const runs = join(state, 'runs');
|
|
173
|
+
if (existsSync(runs))
|
|
174
|
+
for (const run of readdirSync(runs)) {
|
|
175
|
+
const meta = join(runs, run, 'meta.json');
|
|
176
|
+
if (existsSync(meta) && read(meta).version_id === version.version_id)
|
|
177
|
+
rmSync(join(runs, run), { recursive: true });
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
save(versionsPath, versions);
|
|
181
|
+
return versions;
|
|
182
|
+
}
|
|
183
|
+
if (action === 'workflow validate')
|
|
184
|
+
return last(await companion({ action: 'validate', dependencies: localDependencies(state), document: p.yaml, pricing: p.config.pricing, policy: p.config.policy }), 'validation');
|
|
185
|
+
if (action === 'test get') {
|
|
186
|
+
if (!options.run)
|
|
187
|
+
throw new Error('--run is required');
|
|
188
|
+
return read(join(state, 'runs', safeID(options.run), 'report.json'));
|
|
189
|
+
}
|
|
190
|
+
const selected = versions.versions.find((v) => v.version_id === versions.selected);
|
|
191
|
+
if (!selected || selected.hash !== hash(p.yaml) || selected.config_hash !== hash(JSON.stringify(p.config)))
|
|
192
|
+
throw new Error('Save the current workflow and configuration before confirming or running');
|
|
193
|
+
if (action === 'workflow confirm') {
|
|
194
|
+
if (!options.confirm)
|
|
195
|
+
return { version: selected, config: p.config, workflow: p.yaml, instruction: 'Review this exact workflow and service configuration with the user, then confirm' };
|
|
196
|
+
save(join(state, 'confirmation.json'), { version_id: selected.version_id, hash: selected.hash, config_hash: selected.config_hash, confirmed_at: new Date().toISOString() });
|
|
197
|
+
return { confirmed: selected };
|
|
198
|
+
}
|
|
199
|
+
if (action === 'test run' || action === 'test resume') {
|
|
200
|
+
if (!options.confirm)
|
|
201
|
+
throw new Error('Live Provider calls may cost money or change data. Review dependencies and confirm with --confirm');
|
|
202
|
+
const dependencies = localDependencies(state);
|
|
203
|
+
const credentials = {};
|
|
204
|
+
const credentialPath = join(state, 'credentials.json');
|
|
205
|
+
if (existsSync(credentialPath))
|
|
206
|
+
for (const [profile, fields] of Object.entries(read(credentialPath))) {
|
|
207
|
+
const secrets = {};
|
|
208
|
+
for (const [field, env] of Object.entries(fields)) {
|
|
209
|
+
if (!process.env[String(env)])
|
|
210
|
+
throw new Error(`Missing local environment variable for credential field ${field}`);
|
|
211
|
+
secrets[field] = process.env[String(env)];
|
|
212
|
+
}
|
|
213
|
+
credentials[profile] = { ID: profile, Secrets: secrets, Metadata: {} };
|
|
214
|
+
}
|
|
215
|
+
if (action === 'test resume' && !options.run)
|
|
216
|
+
throw new Error('--run is required for resume');
|
|
217
|
+
const runID = options.run ? safeID(options.run) : randomUUID(), runDirectory = join(state, 'runs', runID);
|
|
218
|
+
const metaPath = join(runDirectory, 'meta.json');
|
|
219
|
+
if (action === 'test resume' && !existsSync(metaPath))
|
|
220
|
+
throw new Error('Run does not exist');
|
|
221
|
+
if (existsSync(metaPath) && read(metaPath).version_id !== selected.version_id)
|
|
222
|
+
throw new Error('Run belongs to a different version');
|
|
223
|
+
save(metaPath, { run_id: runID, version_id: selected.version_id, hash: selected.hash, config_hash: selected.config_hash });
|
|
224
|
+
save(join(state, "last-run.json"), { run_id: runID });
|
|
225
|
+
const events = await companion({ action: 'run', document: p.yaml, pricing: p.config.pricing, policy: p.config.policy, fixtures: p.config.fixtures, dependencies, credentials, run_directory: runDirectory, confirmed: true }, event => {
|
|
226
|
+
if (event.type === 'nodes')
|
|
227
|
+
save(join(runDirectory, 'nodes.json'), event.value);
|
|
228
|
+
});
|
|
229
|
+
const result = last(events, 'result');
|
|
230
|
+
save(join(runDirectory, 'report.json'), result);
|
|
231
|
+
return { run_id: runID, ...result };
|
|
232
|
+
}
|
|
233
|
+
throw new Error('Unsupported local action: ' + action);
|
|
234
|
+
}
|
|
235
|
+
function safeID(value) {
|
|
236
|
+
if (!/^[A-Za-z0-9_-]+$/.test(value))
|
|
237
|
+
throw new Error('Invalid local run ID');
|
|
238
|
+
return value;
|
|
239
|
+
}
|
|
240
|
+
export const LOCAL_ACTIONS = ['init', 'config', 'workflow import', 'sources add', 'credentials bind', 'credentials upload', 'workflow validate', 'workflow confirm', 'workflow versions save', 'workflow versions list', 'workflow versions use', 'workflow versions delete', 'test run', 'test resume', 'test get'];
|
|
241
|
+
export function registerLocal(sell) {
|
|
242
|
+
for (const name of LOCAL_ACTIONS) {
|
|
243
|
+
let parent = sell;
|
|
244
|
+
const parts = name.split(' ');
|
|
245
|
+
const leaf = parts.pop();
|
|
246
|
+
for (const part of parts) {
|
|
247
|
+
parent = parent.commands.find(cmd => cmd.name() === part) ?? parent.command(part);
|
|
248
|
+
}
|
|
249
|
+
parent.command(leaf).description('Local Sell: ' + name).option('--project <directory>', 'Project directory', '.').option('--file <file>').option('--url <https-url>').option('--name <name>').option('--service-id <id>').option('--provider-key <key>').option('--profile <id>').option('--merchant-id <id>').option('--environment <environment>').option('--version-id <id>').option('--run <id>').option('--confirm').option('--json').action(async (options) => {
|
|
250
|
+
try {
|
|
251
|
+
const result = await localAction(name.replace('list-local', 'list'), options.project, { ...options, version: options.versionId });
|
|
252
|
+
process.stdout.write(JSON.stringify({ status: 'ok', result }, null, 2) + '\n');
|
|
253
|
+
}
|
|
254
|
+
catch (error) {
|
|
255
|
+
process.exitCode = 1;
|
|
256
|
+
process.stdout.write(JSON.stringify({ status: 'error', message: error instanceof Error ? error.message : 'Local action failed' }) + '\n');
|
|
257
|
+
}
|
|
258
|
+
});
|
|
259
|
+
}
|
|
260
|
+
}
|