@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 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
@@ -0,0 +1,13 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ESNext",
4
+ "module": "ESNext",
5
+ "moduleResolution": "Bundler",
6
+ "strict": true,
7
+ "skipLibCheck": true,
8
+ "lib": ["ESNext"],
9
+ "types": ["node"],
10
+ "noEmit": true
11
+ },
12
+ "include": ["src/**/*.ts"]
13
+ }