@neutrome-labs/merchantduo 0.0.2
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 +19 -0
- package/dist/index.js +457 -0
- package/package.json +29 -0
- package/src/commands/clone.ts +40 -0
- package/src/commands/ping.ts +17 -0
- package/src/commands/sandbox.ts +201 -0
- package/src/http.ts +123 -0
- package/src/index.ts +16 -0
- package/src/logging.ts +35 -0
- package/src/program.ts +25 -0
- package/tsconfig.json +13 -0
package/README.md
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# MerchantDuo CLI
|
|
2
|
+
Simple terminal interface to deploy Magento 2 stores onto the Cloudflare Edge.
|
|
3
|
+
|
|
4
|
+
# How To Use
|
|
5
|
+
|
|
6
|
+
pnpx @neutrome-labs/merchantduo help
|
|
7
|
+
|
|
8
|
+
```
|
|
9
|
+
MerchantDuo developer CLI
|
|
10
|
+
|
|
11
|
+
Options:
|
|
12
|
+
-h, --help display help for command
|
|
13
|
+
|
|
14
|
+
Commands:
|
|
15
|
+
ping Ping the API to check if it's available
|
|
16
|
+
sandbox [options] Provision fresh Magento instance in the Cloudflare Cloud
|
|
17
|
+
clone [options] Clone current Magento installation into Cloudflare Cloud
|
|
18
|
+
help [command] display help for command
|
|
19
|
+
```
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,457 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// src/index.ts
|
|
4
|
+
import { config } from "dotenv";
|
|
5
|
+
|
|
6
|
+
// src/logging.ts
|
|
7
|
+
import {
|
|
8
|
+
configureSync,
|
|
9
|
+
getConsoleSink,
|
|
10
|
+
getLogger,
|
|
11
|
+
getTextFormatter
|
|
12
|
+
} from "@logtape/logtape";
|
|
13
|
+
function configureCliLogging() {
|
|
14
|
+
configureSync({
|
|
15
|
+
sinks: {
|
|
16
|
+
console: getConsoleSink({
|
|
17
|
+
formatter: getTextFormatter({
|
|
18
|
+
timestamp: "time",
|
|
19
|
+
level: "full",
|
|
20
|
+
category: "."
|
|
21
|
+
})
|
|
22
|
+
})
|
|
23
|
+
},
|
|
24
|
+
loggers: [
|
|
25
|
+
{
|
|
26
|
+
category: ["merchantduo", "cli"],
|
|
27
|
+
sinks: ["console"],
|
|
28
|
+
lowestLevel: "info"
|
|
29
|
+
}
|
|
30
|
+
]
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
function cliLogger(category) {
|
|
34
|
+
return getLogger([
|
|
35
|
+
"merchantduo",
|
|
36
|
+
"cli",
|
|
37
|
+
...Array.isArray(category) ? category : [category]
|
|
38
|
+
]);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// src/program.ts
|
|
42
|
+
import { Command } from "commander";
|
|
43
|
+
|
|
44
|
+
// src/http.ts
|
|
45
|
+
var apiBaseUrl = void 0;
|
|
46
|
+
var logger = cliLogger("http");
|
|
47
|
+
function getApiBaseUrl() {
|
|
48
|
+
if (!apiBaseUrl) {
|
|
49
|
+
apiBaseUrl = process.env["API_BASE_URL"];
|
|
50
|
+
if (!apiBaseUrl) {
|
|
51
|
+
return "https://api.merchantduo.com";
|
|
52
|
+
}
|
|
53
|
+
apiBaseUrl = apiBaseUrl.replace(/\/$/, "");
|
|
54
|
+
logger.info("configured API base URL", { apiBaseUrl });
|
|
55
|
+
}
|
|
56
|
+
return apiBaseUrl;
|
|
57
|
+
}
|
|
58
|
+
async function getText(url3) {
|
|
59
|
+
return requestText(url3, { method: "GET" });
|
|
60
|
+
}
|
|
61
|
+
async function requestText(url3, init) {
|
|
62
|
+
const requestUrl = getApiBaseUrl() + url3;
|
|
63
|
+
logger.debug("sending text request", { url: requestUrl, init });
|
|
64
|
+
const startedAt = Date.now();
|
|
65
|
+
const response = await fetch(requestUrl, {
|
|
66
|
+
...init,
|
|
67
|
+
headers: {
|
|
68
|
+
accept: "text/plain",
|
|
69
|
+
...init.headers
|
|
70
|
+
}
|
|
71
|
+
});
|
|
72
|
+
logger.debug("received text response", {
|
|
73
|
+
url: requestUrl,
|
|
74
|
+
status: response.status,
|
|
75
|
+
statusText: response.statusText,
|
|
76
|
+
durationMs: Date.now() - startedAt
|
|
77
|
+
});
|
|
78
|
+
if (!response.ok) {
|
|
79
|
+
logger.warn("text request failed", {
|
|
80
|
+
url: requestUrl,
|
|
81
|
+
status: response.status,
|
|
82
|
+
statusText: response.statusText
|
|
83
|
+
});
|
|
84
|
+
throw new Error(
|
|
85
|
+
`Request failed with ${response.status} ${response.statusText}`
|
|
86
|
+
);
|
|
87
|
+
}
|
|
88
|
+
return await response.text();
|
|
89
|
+
}
|
|
90
|
+
async function getJson(url3) {
|
|
91
|
+
return requestJson(url3, { method: "GET" });
|
|
92
|
+
}
|
|
93
|
+
async function postJson(url3, payload) {
|
|
94
|
+
return requestJson(url3, {
|
|
95
|
+
method: "POST",
|
|
96
|
+
headers: {
|
|
97
|
+
"content-type": "application/json"
|
|
98
|
+
},
|
|
99
|
+
body: JSON.stringify(payload)
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
async function requestJson(url3, init) {
|
|
103
|
+
const requestUrl = getApiBaseUrl() + url3;
|
|
104
|
+
logger.debug("sending JSON request", { url: requestUrl, init });
|
|
105
|
+
const startedAt = Date.now();
|
|
106
|
+
const response = await fetch(requestUrl, {
|
|
107
|
+
...init,
|
|
108
|
+
headers: {
|
|
109
|
+
accept: "application/json",
|
|
110
|
+
...init.headers
|
|
111
|
+
}
|
|
112
|
+
});
|
|
113
|
+
const responseText = await response.text();
|
|
114
|
+
const responseBody = parseJsonResponse(responseText);
|
|
115
|
+
logger.debug("received JSON response", {
|
|
116
|
+
url: requestUrl,
|
|
117
|
+
status: response.status,
|
|
118
|
+
statusText: response.statusText,
|
|
119
|
+
durationMs: Date.now() - startedAt,
|
|
120
|
+
responseText
|
|
121
|
+
});
|
|
122
|
+
if (!response.ok) {
|
|
123
|
+
const details = typeof responseBody === "object" && responseBody !== null ? JSON.stringify(responseBody) : responseText;
|
|
124
|
+
logger.warn("JSON request failed", {
|
|
125
|
+
url: requestUrl,
|
|
126
|
+
status: response.status,
|
|
127
|
+
statusText: response.statusText,
|
|
128
|
+
details
|
|
129
|
+
});
|
|
130
|
+
throw new Error(
|
|
131
|
+
`Request failed with ${response.status} ${response.statusText}${details ? `: ${details}` : ""}`
|
|
132
|
+
);
|
|
133
|
+
}
|
|
134
|
+
return responseBody ?? responseText;
|
|
135
|
+
}
|
|
136
|
+
function parseJsonResponse(text) {
|
|
137
|
+
if (!text) {
|
|
138
|
+
return void 0;
|
|
139
|
+
}
|
|
140
|
+
try {
|
|
141
|
+
return JSON.parse(text);
|
|
142
|
+
} catch {
|
|
143
|
+
return void 0;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// src/commands/ping.ts
|
|
148
|
+
var logger2 = cliLogger(["command", "ping"]);
|
|
149
|
+
function registerPingCommand(program) {
|
|
150
|
+
program.command("ping").description("Ping the API to check if it's available").action(async () => {
|
|
151
|
+
logger2.info("ping command started");
|
|
152
|
+
const response = await getText("/");
|
|
153
|
+
logger2.info("ping command completed");
|
|
154
|
+
console.log(response);
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// src/commands/sandbox.ts
|
|
159
|
+
import { appendFile, writeFile } from "node:fs/promises";
|
|
160
|
+
import { tmpdir } from "node:os";
|
|
161
|
+
import { join } from "node:path";
|
|
162
|
+
import { z as z6 } from "zod";
|
|
163
|
+
|
|
164
|
+
// ../provisioner/types/provision.ts
|
|
165
|
+
import * as z5 from "zod";
|
|
166
|
+
|
|
167
|
+
// ../../src/templates/index.ts
|
|
168
|
+
import * as z from "zod";
|
|
169
|
+
var TemplatesSchema = z.enum(["magento24"]);
|
|
170
|
+
|
|
171
|
+
// ../../src/types/instance.ts
|
|
172
|
+
import * as z4 from "zod";
|
|
173
|
+
|
|
174
|
+
// ../../src/types/service-mysql.ts
|
|
175
|
+
import * as z2 from "zod";
|
|
176
|
+
var BundledMysqlServiceConfigSchema = z2.object({
|
|
177
|
+
kind: z2.literal("bundled")
|
|
178
|
+
});
|
|
179
|
+
var HyperdriveMysqlConfigSchema = z2.object({
|
|
180
|
+
kind: z2.literal("hyperdrive"),
|
|
181
|
+
connectionString: z2.string()
|
|
182
|
+
});
|
|
183
|
+
var MysqlServiceConfigSchema = z2.union([
|
|
184
|
+
BundledMysqlServiceConfigSchema,
|
|
185
|
+
HyperdriveMysqlConfigSchema
|
|
186
|
+
]);
|
|
187
|
+
|
|
188
|
+
// ../../src/types/service-opensearch.ts
|
|
189
|
+
import * as z3 from "zod";
|
|
190
|
+
var BundledOpensearchServiceConfigSchema = z3.object({
|
|
191
|
+
kind: z3.literal("bundled")
|
|
192
|
+
});
|
|
193
|
+
var RemoteOpensearchConfigSchema = z3.object({
|
|
194
|
+
kind: z3.literal("remote"),
|
|
195
|
+
url: z3.url(),
|
|
196
|
+
port: z3.number().optional(),
|
|
197
|
+
username: z3.string().optional(),
|
|
198
|
+
password: z3.string().optional()
|
|
199
|
+
}).transform((x) => {
|
|
200
|
+
const url3 = new URL(x.url);
|
|
201
|
+
return {
|
|
202
|
+
...x,
|
|
203
|
+
port: x.port ?? Number(url3.port || (url3.protocol === "https:" ? 443 : 80))
|
|
204
|
+
};
|
|
205
|
+
});
|
|
206
|
+
var OpensearchServiceConfigSchema = z3.union([
|
|
207
|
+
BundledOpensearchServiceConfigSchema,
|
|
208
|
+
RemoteOpensearchConfigSchema
|
|
209
|
+
]);
|
|
210
|
+
|
|
211
|
+
// ../../src/types/instance.ts
|
|
212
|
+
var InstanceGradeSchema = z4.enum(["sandbox", "instance"]);
|
|
213
|
+
var InstanceConfigSchema = z4.object({
|
|
214
|
+
instanceId: z4.string(),
|
|
215
|
+
baseUrl: z4.url().optional(),
|
|
216
|
+
grade: InstanceGradeSchema.default("sandbox"),
|
|
217
|
+
template: TemplatesSchema,
|
|
218
|
+
deadline: z4.date().optional().describe("deadline for the instance"),
|
|
219
|
+
services: z4.object({
|
|
220
|
+
mysql: MysqlServiceConfigSchema,
|
|
221
|
+
opensearch: OpensearchServiceConfigSchema
|
|
222
|
+
}),
|
|
223
|
+
snapshots: z4.object({
|
|
224
|
+
fs: z4.string().describe("filesystem (src) snapshot reference").optional(),
|
|
225
|
+
app: z4.string().describe("app (db) snapshot reference").optional()
|
|
226
|
+
}).optional(),
|
|
227
|
+
mounts: z4.object({
|
|
228
|
+
media: z4.string().describe("path to the pub/media r2 mount")
|
|
229
|
+
}).optional(),
|
|
230
|
+
tools: z4.object({
|
|
231
|
+
codeserver: z4.boolean().default(false),
|
|
232
|
+
phpMyAdmin: z4.boolean().default(false),
|
|
233
|
+
opencode: z4.boolean().default(false)
|
|
234
|
+
}).optional()
|
|
235
|
+
});
|
|
236
|
+
var InstanceStateSchema = z4.enum([
|
|
237
|
+
"stopped",
|
|
238
|
+
"scheduled",
|
|
239
|
+
"booting",
|
|
240
|
+
"provision/deps",
|
|
241
|
+
"provision/deploy",
|
|
242
|
+
"provision/install",
|
|
243
|
+
"starting",
|
|
244
|
+
"ready",
|
|
245
|
+
"hibernating",
|
|
246
|
+
"failed"
|
|
247
|
+
]);
|
|
248
|
+
|
|
249
|
+
// ../provisioner/types/provision.ts
|
|
250
|
+
var ProvisionRequestSchema = z5.object({
|
|
251
|
+
grade: InstanceGradeSchema.default("sandbox"),
|
|
252
|
+
template: TemplatesSchema,
|
|
253
|
+
base64AuthJson: z5.string().optional()
|
|
254
|
+
});
|
|
255
|
+
var ProvisionResponseSchema = z5.object({
|
|
256
|
+
jobId: z5.string()
|
|
257
|
+
});
|
|
258
|
+
var JobStatusResponseSchema = z5.object({
|
|
259
|
+
jobId: z5.string(),
|
|
260
|
+
status: z5.enum([
|
|
261
|
+
"queued",
|
|
262
|
+
"running",
|
|
263
|
+
"paused",
|
|
264
|
+
"errored",
|
|
265
|
+
"terminated",
|
|
266
|
+
"complete",
|
|
267
|
+
"waiting",
|
|
268
|
+
"waitingForPause",
|
|
269
|
+
"unknown"
|
|
270
|
+
]),
|
|
271
|
+
logs: z5.array(z5.string()),
|
|
272
|
+
error: z5.string().optional(),
|
|
273
|
+
instanceId: z5.string(),
|
|
274
|
+
instanceState: InstanceStateSchema.optional(),
|
|
275
|
+
urls: z5.record(z5.string(), z5.string()).optional()
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
// src/commands/sandbox.ts
|
|
279
|
+
var TERMINAL_STATUSES = /* @__PURE__ */ new Set([
|
|
280
|
+
"complete",
|
|
281
|
+
"errored",
|
|
282
|
+
"terminated"
|
|
283
|
+
]);
|
|
284
|
+
var logger3 = cliLogger(["command", "sandbox"]);
|
|
285
|
+
function registerSandboxCommand(program) {
|
|
286
|
+
program.command("sandbox").description("Provision fresh Magento instance in the Cloudflare Cloud").option("--template <name>", "Template to provision", "magento24").option("--base64AuthJson <value>", "Base64-encoded auth.json content").option(
|
|
287
|
+
"--poll-interval <ms>",
|
|
288
|
+
"Job polling interval in milliseconds",
|
|
289
|
+
"30000"
|
|
290
|
+
).action(async (options) => {
|
|
291
|
+
logger3.info("sandbox command started", { options });
|
|
292
|
+
await provision(options);
|
|
293
|
+
});
|
|
294
|
+
}
|
|
295
|
+
async function provision(options) {
|
|
296
|
+
logger3.debug("parsing sandbox command options", { options });
|
|
297
|
+
const pollIntervalMs = parsePollInterval(options.pollInterval);
|
|
298
|
+
const payload = ProvisionRequestSchema.parse({
|
|
299
|
+
grade: "sandbox",
|
|
300
|
+
template: options.template,
|
|
301
|
+
...options.base64AuthJson ? { base64AuthJson: options.base64AuthJson } : {}
|
|
302
|
+
});
|
|
303
|
+
logger3.debug("provision request payload prepared", { payload });
|
|
304
|
+
const responseBody = ProvisionResponseSchema.parse(
|
|
305
|
+
await postJson("/v1/provision", payload)
|
|
306
|
+
);
|
|
307
|
+
logger3.info("provision job created", { jobId: responseBody.jobId });
|
|
308
|
+
const logFile = join(
|
|
309
|
+
tmpdir(),
|
|
310
|
+
`merchantduo-provision-${responseBody.jobId}.log`
|
|
311
|
+
);
|
|
312
|
+
await writeFile(logFile, "");
|
|
313
|
+
logger3.info("provision log file initialized", { logFile });
|
|
314
|
+
console.log(`Provision job started: ${responseBody.jobId}`);
|
|
315
|
+
let previousJob;
|
|
316
|
+
while (!previousJob || !TERMINAL_STATUSES.has(previousJob.status)) {
|
|
317
|
+
logger3.debug("polling provision job", {
|
|
318
|
+
jobId: responseBody.jobId,
|
|
319
|
+
pollIntervalMs
|
|
320
|
+
});
|
|
321
|
+
const job = JobStatusResponseSchema.parse(
|
|
322
|
+
await getJson(`/v1/job/${encodeURIComponent(responseBody.jobId)}`)
|
|
323
|
+
);
|
|
324
|
+
await writeJobDiff(previousJob, job, logFile);
|
|
325
|
+
previousJob = job;
|
|
326
|
+
if (!TERMINAL_STATUSES.has(previousJob.status)) {
|
|
327
|
+
logger3.debug("provision job still running", {
|
|
328
|
+
jobId: previousJob.jobId,
|
|
329
|
+
status: previousJob.status,
|
|
330
|
+
instanceState: previousJob.instanceState
|
|
331
|
+
});
|
|
332
|
+
await sleep(pollIntervalMs);
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
console.log(logFile);
|
|
336
|
+
logger3.info("provision job reached terminal status", {
|
|
337
|
+
jobId: previousJob.jobId,
|
|
338
|
+
status: previousJob.status,
|
|
339
|
+
instanceState: previousJob.instanceState,
|
|
340
|
+
logFile
|
|
341
|
+
});
|
|
342
|
+
if (previousJob.status !== "complete") {
|
|
343
|
+
logger3.error("provision job failed", {
|
|
344
|
+
jobId: previousJob.jobId,
|
|
345
|
+
status: previousJob.status,
|
|
346
|
+
error: previousJob.error
|
|
347
|
+
});
|
|
348
|
+
throw new Error(
|
|
349
|
+
`Provision job ${previousJob.jobId} ended with ${previousJob.status}${previousJob.error ? `: ${previousJob.error}` : ""}`
|
|
350
|
+
);
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
function parsePollInterval(value) {
|
|
354
|
+
const pollIntervalMs = z6.coerce.number().int().positive().parse(value);
|
|
355
|
+
logger3.debug("parsed poll interval", { value, pollIntervalMs });
|
|
356
|
+
return pollIntervalMs;
|
|
357
|
+
}
|
|
358
|
+
async function writeJobDiff(previousJob, job, logFile) {
|
|
359
|
+
if (!previousJob || previousJob.status !== job.status) {
|
|
360
|
+
logger3.info("job status changed", {
|
|
361
|
+
previous: previousJob?.status,
|
|
362
|
+
current: job.status
|
|
363
|
+
});
|
|
364
|
+
console.log(`Job status: ${job.status}`);
|
|
365
|
+
}
|
|
366
|
+
if (!previousJob || previousJob.instanceState !== job.instanceState) {
|
|
367
|
+
logger3.info("instance state changed", {
|
|
368
|
+
previous: previousJob?.instanceState,
|
|
369
|
+
current: job.instanceState
|
|
370
|
+
});
|
|
371
|
+
console.log(`Instance state: ${job.instanceState ?? "unknown"}`);
|
|
372
|
+
}
|
|
373
|
+
if (job.error && previousJob?.error !== job.error) {
|
|
374
|
+
logger3.error("job reported error", {
|
|
375
|
+
jobId: job.jobId,
|
|
376
|
+
error: job.error
|
|
377
|
+
});
|
|
378
|
+
console.error(`Job error: ${job.error}`);
|
|
379
|
+
}
|
|
380
|
+
printUrlDiff(previousJob?.urls, job.urls);
|
|
381
|
+
await appendLogDiff(previousJob?.logs ?? [], job.logs, logFile);
|
|
382
|
+
}
|
|
383
|
+
function printUrlDiff(previousUrls, currentUrls) {
|
|
384
|
+
if (!currentUrls) {
|
|
385
|
+
return;
|
|
386
|
+
}
|
|
387
|
+
for (const [port, url3] of Object.entries(currentUrls)) {
|
|
388
|
+
if (previousUrls?.[port] !== url3) {
|
|
389
|
+
logger3.info("job URL changed", { port, url: url3 });
|
|
390
|
+
console.log(`URL ${port}: ${url3}`);
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
async function appendLogDiff(previousLogs, currentLogs, logFile) {
|
|
395
|
+
const firstChangedIndex = currentLogs.findIndex(
|
|
396
|
+
(log, index) => previousLogs[index] !== log
|
|
397
|
+
);
|
|
398
|
+
if (firstChangedIndex === -1) {
|
|
399
|
+
logger3.debug("no new provision logs to append", { logFile });
|
|
400
|
+
return;
|
|
401
|
+
}
|
|
402
|
+
const text = currentLogs.slice(firstChangedIndex).map((log) => log.endsWith("\n") ? log : `${log}
|
|
403
|
+
`).join("");
|
|
404
|
+
await appendFile(logFile, text);
|
|
405
|
+
logger3.debug("appended provision logs", {
|
|
406
|
+
logFile,
|
|
407
|
+
firstChangedIndex,
|
|
408
|
+
appendedCount: currentLogs.length - firstChangedIndex,
|
|
409
|
+
bytes: text.length
|
|
410
|
+
});
|
|
411
|
+
}
|
|
412
|
+
async function sleep(ms) {
|
|
413
|
+
logger3.debug("sleeping before next poll", { ms });
|
|
414
|
+
await new Promise((resolve) => setTimeout(resolve, ms));
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
// src/commands/clone.ts
|
|
418
|
+
var logger4 = cliLogger(["command", "clone"]);
|
|
419
|
+
function registerCloneCommand(program) {
|
|
420
|
+
program.command("clone").description("Clone current Magento installation into Cloudflare Cloud").option("--with-database", "Clone database", true).option("--with-media", "Clone media", false).option("--with-catalog", "Clone catalog", true).option("--with-customers", "Clone customers", false).option("--with-sales", "Clone sales", false).option("--ttl <TTL>", "Instance deadline: 1h, 1d, 3d, 7d, infinite", "1h").option(
|
|
421
|
+
"--poll-interval <ms>",
|
|
422
|
+
"Job polling interval in milliseconds",
|
|
423
|
+
"2000"
|
|
424
|
+
).action(async (options) => {
|
|
425
|
+
logger4.info("clone command started", { options });
|
|
426
|
+
await clone(options);
|
|
427
|
+
});
|
|
428
|
+
}
|
|
429
|
+
async function clone(options) {
|
|
430
|
+
logger4.warn("clone command is not implemented", { options });
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
// src/program.ts
|
|
434
|
+
function createCli() {
|
|
435
|
+
cliLogger("program").debug("creating command tree");
|
|
436
|
+
const program = new Command();
|
|
437
|
+
program.name("merchantduo").description("MerchantDuo developer CLI").showHelpAfterError().showSuggestionAfterError();
|
|
438
|
+
registerPingCommand(program);
|
|
439
|
+
registerSandboxCommand(program);
|
|
440
|
+
registerCloneCommand(program);
|
|
441
|
+
cliLogger("program").debug("registered commands", {
|
|
442
|
+
commands: program.commands.map((command) => command.name())
|
|
443
|
+
});
|
|
444
|
+
return program;
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
// src/index.ts
|
|
448
|
+
try {
|
|
449
|
+
config({ quiet: true });
|
|
450
|
+
configureCliLogging();
|
|
451
|
+
cliLogger("startup").debug("starting CLI", { argv: process.argv });
|
|
452
|
+
await createCli().parseAsync(process.argv);
|
|
453
|
+
} catch (error) {
|
|
454
|
+
cliLogger("startup").error("CLI command failed", { error });
|
|
455
|
+
console.error(error instanceof Error ? error.message : String(error));
|
|
456
|
+
process.exitCode = 1;
|
|
457
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@neutrome-labs/merchantduo",
|
|
3
|
+
"version": "0.0.2",
|
|
4
|
+
"description": "",
|
|
5
|
+
"main": "dist/index.js",
|
|
6
|
+
"bin": {
|
|
7
|
+
"merchantduo": "dist/index.js"
|
|
8
|
+
},
|
|
9
|
+
"keywords": [],
|
|
10
|
+
"author": "",
|
|
11
|
+
"license": "proprietary",
|
|
12
|
+
"type": "module",
|
|
13
|
+
"dependencies": {
|
|
14
|
+
"@logtape/logtape": "2.1.1",
|
|
15
|
+
"commander": "14.0.3",
|
|
16
|
+
"dotenv": "17.4.2",
|
|
17
|
+
"zod": "4.4.3"
|
|
18
|
+
},
|
|
19
|
+
"devDependencies": {
|
|
20
|
+
"@types/node": "25.9.1",
|
|
21
|
+
"esbuild": "0.27.3",
|
|
22
|
+
"typescript": "6.0.3"
|
|
23
|
+
},
|
|
24
|
+
"scripts": {
|
|
25
|
+
"build": "tsc -p tsconfig.json --noEmit && esbuild src/index.ts --bundle --packages=external --platform=node --format=esm --target=node22 --outfile=dist/index.js && chmod +x dist/index.js",
|
|
26
|
+
"dev": "pnpm run build && node dist/index.js",
|
|
27
|
+
"typecheck": "tsc -p tsconfig.json --noEmit"
|
|
28
|
+
}
|
|
29
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import type { Command } from "commander";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import { getJson, postJson } from "../http";
|
|
4
|
+
import { cliLogger } from "../logging";
|
|
5
|
+
|
|
6
|
+
const logger = cliLogger(["command", "clone"]);
|
|
7
|
+
|
|
8
|
+
interface CloneOptions {
|
|
9
|
+
withDatabase: boolean;
|
|
10
|
+
withMedia: boolean;
|
|
11
|
+
withCatalog: boolean;
|
|
12
|
+
withCustomers: boolean;
|
|
13
|
+
withSales: boolean;
|
|
14
|
+
pollInterval: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function registerCloneCommand(program: Command): void {
|
|
18
|
+
program
|
|
19
|
+
.command("clone")
|
|
20
|
+
.description("Clone current Magento installation into Cloudflare Cloud")
|
|
21
|
+
.option("--with-database", "Clone database", true)
|
|
22
|
+
.option("--with-media", "Clone media", false)
|
|
23
|
+
.option("--with-catalog", "Clone catalog", true)
|
|
24
|
+
.option("--with-customers", "Clone customers", false)
|
|
25
|
+
.option("--with-sales", "Clone sales", false)
|
|
26
|
+
.option("--ttl <TTL>", "Instance deadline: 1h, 1d, 3d, 7d, infinite", "1h")
|
|
27
|
+
.option(
|
|
28
|
+
"--poll-interval <ms>",
|
|
29
|
+
"Job polling interval in milliseconds",
|
|
30
|
+
"2000",
|
|
31
|
+
)
|
|
32
|
+
.action(async (options: CloneOptions) => {
|
|
33
|
+
logger.info("clone command started", { options });
|
|
34
|
+
await clone(options);
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
async function clone(options: CloneOptions): Promise<void> {
|
|
39
|
+
logger.warn("clone command is not implemented", { options });
|
|
40
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { Command } from "commander";
|
|
2
|
+
import { getText } from "../http";
|
|
3
|
+
import { cliLogger } from "../logging";
|
|
4
|
+
|
|
5
|
+
const logger = cliLogger(["command", "ping"]);
|
|
6
|
+
|
|
7
|
+
export function registerPingCommand(program: Command): void {
|
|
8
|
+
program
|
|
9
|
+
.command("ping")
|
|
10
|
+
.description("Ping the API to check if it's available")
|
|
11
|
+
.action(async () => {
|
|
12
|
+
logger.info("ping command started");
|
|
13
|
+
const response = await getText("/");
|
|
14
|
+
logger.info("ping command completed");
|
|
15
|
+
console.log(response);
|
|
16
|
+
});
|
|
17
|
+
}
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
import { appendFile, writeFile } from "node:fs/promises";
|
|
2
|
+
import { tmpdir } from "node:os";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import type { Command } from "commander";
|
|
5
|
+
import { z } from "zod";
|
|
6
|
+
import {
|
|
7
|
+
JobStatusResponseSchema,
|
|
8
|
+
ProvisionRequestSchema,
|
|
9
|
+
ProvisionResponseSchema,
|
|
10
|
+
type JobStatusResponse,
|
|
11
|
+
} from "../../../provisioner/types/provision";
|
|
12
|
+
import { getJson, postJson } from "../http";
|
|
13
|
+
import { cliLogger } from "../logging";
|
|
14
|
+
|
|
15
|
+
const TERMINAL_STATUSES = new Set<JobStatusResponse["status"]>([
|
|
16
|
+
"complete",
|
|
17
|
+
"errored",
|
|
18
|
+
"terminated",
|
|
19
|
+
]);
|
|
20
|
+
|
|
21
|
+
interface SandboxOptions {
|
|
22
|
+
template?: string;
|
|
23
|
+
base64AuthJson?: string;
|
|
24
|
+
pollInterval: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const logger = cliLogger(["command", "sandbox"]);
|
|
28
|
+
|
|
29
|
+
export function registerSandboxCommand(program: Command): void {
|
|
30
|
+
program
|
|
31
|
+
.command("sandbox")
|
|
32
|
+
.description("Provision fresh Magento instance in the Cloudflare Cloud")
|
|
33
|
+
.option("--template <name>", "Template to provision", "magento24")
|
|
34
|
+
.option("--base64AuthJson <value>", "Base64-encoded auth.json content")
|
|
35
|
+
.option(
|
|
36
|
+
"--poll-interval <ms>",
|
|
37
|
+
"Job polling interval in milliseconds",
|
|
38
|
+
"30000",
|
|
39
|
+
)
|
|
40
|
+
.action(async (options: SandboxOptions) => {
|
|
41
|
+
logger.info("sandbox command started", { options });
|
|
42
|
+
await provision(options);
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
async function provision(options: SandboxOptions): Promise<void> {
|
|
47
|
+
logger.debug("parsing sandbox command options", { options });
|
|
48
|
+
const pollIntervalMs = parsePollInterval(options.pollInterval);
|
|
49
|
+
const payload = ProvisionRequestSchema.parse({
|
|
50
|
+
grade: "sandbox",
|
|
51
|
+
template: options.template,
|
|
52
|
+
...(options.base64AuthJson
|
|
53
|
+
? { base64AuthJson: options.base64AuthJson }
|
|
54
|
+
: {}),
|
|
55
|
+
});
|
|
56
|
+
logger.debug("provision request payload prepared", { payload });
|
|
57
|
+
|
|
58
|
+
const responseBody = ProvisionResponseSchema.parse(
|
|
59
|
+
await postJson("/v1/provision", payload),
|
|
60
|
+
);
|
|
61
|
+
logger.info("provision job created", { jobId: responseBody.jobId });
|
|
62
|
+
const logFile = join(
|
|
63
|
+
tmpdir(),
|
|
64
|
+
`merchantduo-provision-${responseBody.jobId}.log`,
|
|
65
|
+
);
|
|
66
|
+
await writeFile(logFile, "");
|
|
67
|
+
logger.info("provision log file initialized", { logFile });
|
|
68
|
+
|
|
69
|
+
console.log(`Provision job started: ${responseBody.jobId}`);
|
|
70
|
+
|
|
71
|
+
let previousJob: JobStatusResponse | undefined;
|
|
72
|
+
while (!previousJob || !TERMINAL_STATUSES.has(previousJob.status)) {
|
|
73
|
+
logger.debug("polling provision job", {
|
|
74
|
+
jobId: responseBody.jobId,
|
|
75
|
+
pollIntervalMs,
|
|
76
|
+
});
|
|
77
|
+
const job = JobStatusResponseSchema.parse(
|
|
78
|
+
await getJson(`/v1/job/${encodeURIComponent(responseBody.jobId)}`),
|
|
79
|
+
);
|
|
80
|
+
|
|
81
|
+
await writeJobDiff(previousJob, job, logFile);
|
|
82
|
+
previousJob = job;
|
|
83
|
+
|
|
84
|
+
if (!TERMINAL_STATUSES.has(previousJob.status)) {
|
|
85
|
+
logger.debug("provision job still running", {
|
|
86
|
+
jobId: previousJob.jobId,
|
|
87
|
+
status: previousJob.status,
|
|
88
|
+
instanceState: previousJob.instanceState,
|
|
89
|
+
});
|
|
90
|
+
await sleep(pollIntervalMs);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
console.log(logFile);
|
|
95
|
+
logger.info("provision job reached terminal status", {
|
|
96
|
+
jobId: previousJob.jobId,
|
|
97
|
+
status: previousJob.status,
|
|
98
|
+
instanceState: previousJob.instanceState,
|
|
99
|
+
logFile,
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
if (previousJob.status !== "complete") {
|
|
103
|
+
logger.error("provision job failed", {
|
|
104
|
+
jobId: previousJob.jobId,
|
|
105
|
+
status: previousJob.status,
|
|
106
|
+
error: previousJob.error,
|
|
107
|
+
});
|
|
108
|
+
throw new Error(
|
|
109
|
+
`Provision job ${previousJob.jobId} ended with ${previousJob.status}${
|
|
110
|
+
previousJob.error ? `: ${previousJob.error}` : ""
|
|
111
|
+
}`,
|
|
112
|
+
);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function parsePollInterval(value: string): number {
|
|
117
|
+
const pollIntervalMs = z.coerce.number().int().positive().parse(value);
|
|
118
|
+
logger.debug("parsed poll interval", { value, pollIntervalMs });
|
|
119
|
+
return pollIntervalMs;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
async function writeJobDiff(
|
|
123
|
+
previousJob: JobStatusResponse | undefined,
|
|
124
|
+
job: JobStatusResponse,
|
|
125
|
+
logFile: string,
|
|
126
|
+
): Promise<void> {
|
|
127
|
+
if (!previousJob || previousJob.status !== job.status) {
|
|
128
|
+
logger.info("job status changed", {
|
|
129
|
+
previous: previousJob?.status,
|
|
130
|
+
current: job.status,
|
|
131
|
+
});
|
|
132
|
+
console.log(`Job status: ${job.status}`);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
if (!previousJob || previousJob.instanceState !== job.instanceState) {
|
|
136
|
+
logger.info("instance state changed", {
|
|
137
|
+
previous: previousJob?.instanceState,
|
|
138
|
+
current: job.instanceState,
|
|
139
|
+
});
|
|
140
|
+
console.log(`Instance state: ${job.instanceState ?? "unknown"}`);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
if (job.error && previousJob?.error !== job.error) {
|
|
144
|
+
logger.error("job reported error", {
|
|
145
|
+
jobId: job.jobId,
|
|
146
|
+
error: job.error,
|
|
147
|
+
});
|
|
148
|
+
console.error(`Job error: ${job.error}`);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
printUrlDiff(previousJob?.urls, job.urls);
|
|
152
|
+
await appendLogDiff(previousJob?.logs ?? [], job.logs, logFile);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function printUrlDiff(
|
|
156
|
+
previousUrls: JobStatusResponse["urls"] | undefined,
|
|
157
|
+
currentUrls: JobStatusResponse["urls"] | undefined,
|
|
158
|
+
): void {
|
|
159
|
+
if (!currentUrls) {
|
|
160
|
+
return;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
for (const [port, url] of Object.entries(currentUrls)) {
|
|
164
|
+
if (previousUrls?.[port] !== url) {
|
|
165
|
+
logger.info("job URL changed", { port, url });
|
|
166
|
+
console.log(`URL ${port}: ${url}`);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
async function appendLogDiff(
|
|
172
|
+
previousLogs: string[],
|
|
173
|
+
currentLogs: string[],
|
|
174
|
+
logFile: string,
|
|
175
|
+
): Promise<void> {
|
|
176
|
+
const firstChangedIndex = currentLogs.findIndex(
|
|
177
|
+
(log, index) => previousLogs[index] !== log,
|
|
178
|
+
);
|
|
179
|
+
|
|
180
|
+
if (firstChangedIndex === -1) {
|
|
181
|
+
logger.debug("no new provision logs to append", { logFile });
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
const text = currentLogs
|
|
186
|
+
.slice(firstChangedIndex)
|
|
187
|
+
.map((log) => (log.endsWith("\n") ? log : `${log}\n`))
|
|
188
|
+
.join("");
|
|
189
|
+
await appendFile(logFile, text);
|
|
190
|
+
logger.debug("appended provision logs", {
|
|
191
|
+
logFile,
|
|
192
|
+
firstChangedIndex,
|
|
193
|
+
appendedCount: currentLogs.length - firstChangedIndex,
|
|
194
|
+
bytes: text.length,
|
|
195
|
+
});
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
async function sleep(ms: number): Promise<void> {
|
|
199
|
+
logger.debug("sleeping before next poll", { ms });
|
|
200
|
+
await new Promise((resolve) => setTimeout(resolve, ms));
|
|
201
|
+
}
|
package/src/http.ts
ADDED
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import { cliLogger } from "./logging";
|
|
2
|
+
|
|
3
|
+
let apiBaseUrl: string | undefined = undefined;
|
|
4
|
+
const logger = cliLogger("http");
|
|
5
|
+
|
|
6
|
+
function getApiBaseUrl(): string {
|
|
7
|
+
if (!apiBaseUrl) {
|
|
8
|
+
apiBaseUrl = process.env["API_BASE_URL"];
|
|
9
|
+
if (!apiBaseUrl) {
|
|
10
|
+
return "https://api.merchantduo.com";
|
|
11
|
+
}
|
|
12
|
+
apiBaseUrl = apiBaseUrl.replace(/\/$/, "");
|
|
13
|
+
logger.info("configured API base URL", { apiBaseUrl });
|
|
14
|
+
}
|
|
15
|
+
return apiBaseUrl;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export async function getText(url: string): Promise<string> {
|
|
19
|
+
return requestText(url, { method: "GET" });
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
async function requestText(url: string, init: RequestInit): Promise<string> {
|
|
23
|
+
const requestUrl = getApiBaseUrl() + url;
|
|
24
|
+
logger.debug("sending text request", { url: requestUrl, init });
|
|
25
|
+
const startedAt = Date.now();
|
|
26
|
+
const response = await fetch(requestUrl, {
|
|
27
|
+
...init,
|
|
28
|
+
headers: {
|
|
29
|
+
accept: "text/plain",
|
|
30
|
+
...init.headers,
|
|
31
|
+
},
|
|
32
|
+
});
|
|
33
|
+
logger.debug("received text response", {
|
|
34
|
+
url: requestUrl,
|
|
35
|
+
status: response.status,
|
|
36
|
+
statusText: response.statusText,
|
|
37
|
+
durationMs: Date.now() - startedAt,
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
if (!response.ok) {
|
|
41
|
+
logger.warn("text request failed", {
|
|
42
|
+
url: requestUrl,
|
|
43
|
+
status: response.status,
|
|
44
|
+
statusText: response.statusText,
|
|
45
|
+
});
|
|
46
|
+
throw new Error(
|
|
47
|
+
`Request failed with ${response.status} ${response.statusText}`,
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
return await response.text();
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export async function getJson(url: string): Promise<unknown> {
|
|
55
|
+
return requestJson(url, { method: "GET" });
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export async function postJson(
|
|
59
|
+
url: string,
|
|
60
|
+
payload: unknown,
|
|
61
|
+
): Promise<unknown> {
|
|
62
|
+
return requestJson(url, {
|
|
63
|
+
method: "POST",
|
|
64
|
+
headers: {
|
|
65
|
+
"content-type": "application/json",
|
|
66
|
+
},
|
|
67
|
+
body: JSON.stringify(payload),
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
async function requestJson(url: string, init: RequestInit): Promise<unknown> {
|
|
72
|
+
const requestUrl = getApiBaseUrl() + url;
|
|
73
|
+
logger.debug("sending JSON request", { url: requestUrl, init });
|
|
74
|
+
const startedAt = Date.now();
|
|
75
|
+
const response = await fetch(requestUrl, {
|
|
76
|
+
...init,
|
|
77
|
+
headers: {
|
|
78
|
+
accept: "application/json",
|
|
79
|
+
...init.headers,
|
|
80
|
+
},
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
const responseText = await response.text();
|
|
84
|
+
const responseBody = parseJsonResponse(responseText);
|
|
85
|
+
logger.debug("received JSON response", {
|
|
86
|
+
url: requestUrl,
|
|
87
|
+
status: response.status,
|
|
88
|
+
statusText: response.statusText,
|
|
89
|
+
durationMs: Date.now() - startedAt,
|
|
90
|
+
responseText,
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
if (!response.ok) {
|
|
94
|
+
const details =
|
|
95
|
+
typeof responseBody === "object" && responseBody !== null
|
|
96
|
+
? JSON.stringify(responseBody)
|
|
97
|
+
: responseText;
|
|
98
|
+
|
|
99
|
+
logger.warn("JSON request failed", {
|
|
100
|
+
url: requestUrl,
|
|
101
|
+
status: response.status,
|
|
102
|
+
statusText: response.statusText,
|
|
103
|
+
details,
|
|
104
|
+
});
|
|
105
|
+
throw new Error(
|
|
106
|
+
`Request failed with ${response.status} ${response.statusText}${details ? `: ${details}` : ""}`,
|
|
107
|
+
);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
return responseBody ?? responseText;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function parseJsonResponse(text: string): unknown {
|
|
114
|
+
if (!text) {
|
|
115
|
+
return undefined;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
try {
|
|
119
|
+
return JSON.parse(text);
|
|
120
|
+
} catch {
|
|
121
|
+
return undefined;
|
|
122
|
+
}
|
|
123
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import { config } from "dotenv";
|
|
4
|
+
import { configureCliLogging, cliLogger } from "./logging";
|
|
5
|
+
import { createCli } from "./program";
|
|
6
|
+
|
|
7
|
+
try {
|
|
8
|
+
config({ quiet: true });
|
|
9
|
+
configureCliLogging();
|
|
10
|
+
cliLogger("startup").debug("starting CLI", { argv: process.argv });
|
|
11
|
+
await createCli().parseAsync(process.argv);
|
|
12
|
+
} catch (error) {
|
|
13
|
+
cliLogger("startup").error("CLI command failed", { error });
|
|
14
|
+
console.error(error instanceof Error ? error.message : String(error));
|
|
15
|
+
process.exitCode = 1;
|
|
16
|
+
}
|
package/src/logging.ts
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import {
|
|
2
|
+
configureSync,
|
|
3
|
+
getConsoleSink,
|
|
4
|
+
getLogger,
|
|
5
|
+
getTextFormatter,
|
|
6
|
+
} from "@logtape/logtape";
|
|
7
|
+
|
|
8
|
+
export function configureCliLogging(): void {
|
|
9
|
+
configureSync({
|
|
10
|
+
sinks: {
|
|
11
|
+
console: getConsoleSink({
|
|
12
|
+
formatter: getTextFormatter({
|
|
13
|
+
timestamp: "time",
|
|
14
|
+
level: "full",
|
|
15
|
+
category: ".",
|
|
16
|
+
}),
|
|
17
|
+
}),
|
|
18
|
+
},
|
|
19
|
+
loggers: [
|
|
20
|
+
{
|
|
21
|
+
category: ["merchantduo", "cli"],
|
|
22
|
+
sinks: ["console"],
|
|
23
|
+
lowestLevel: "info",
|
|
24
|
+
},
|
|
25
|
+
],
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function cliLogger(category: string | string[]) {
|
|
30
|
+
return getLogger([
|
|
31
|
+
"merchantduo",
|
|
32
|
+
"cli",
|
|
33
|
+
...(Array.isArray(category) ? category : [category]),
|
|
34
|
+
]);
|
|
35
|
+
}
|
package/src/program.ts
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { Command } from "commander";
|
|
2
|
+
import { registerPingCommand } from "./commands/ping";
|
|
3
|
+
import { registerSandboxCommand } from "./commands/sandbox";
|
|
4
|
+
import { registerCloneCommand } from "./commands/clone";
|
|
5
|
+
import { cliLogger } from "./logging";
|
|
6
|
+
|
|
7
|
+
export function createCli(): Command {
|
|
8
|
+
cliLogger("program").debug("creating command tree");
|
|
9
|
+
const program = new Command();
|
|
10
|
+
|
|
11
|
+
program
|
|
12
|
+
.name("merchantduo")
|
|
13
|
+
.description("MerchantDuo developer CLI")
|
|
14
|
+
.showHelpAfterError()
|
|
15
|
+
.showSuggestionAfterError();
|
|
16
|
+
|
|
17
|
+
registerPingCommand(program);
|
|
18
|
+
registerSandboxCommand(program);
|
|
19
|
+
registerCloneCommand(program);
|
|
20
|
+
cliLogger("program").debug("registered commands", {
|
|
21
|
+
commands: program.commands.map((command) => command.name()),
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
return program;
|
|
25
|
+
}
|
package/tsconfig.json
ADDED