Skip to content

Commit 2c66e45

Browse files
authored
fix(cli): make top-level cp json-safe (#7)
1 parent f338983 commit 2c66e45

4 files changed

Lines changed: 167 additions & 36 deletions

File tree

package-lock.json

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@miosa/cli",
3-
"version": "1.0.41",
3+
"version": "1.0.42",
44
"description": "MIOSA platform CLI — projects, sandboxes, deploys, databases, more",
55
"type": "module",
66
"bin": {

src/commands/cp.ts

Lines changed: 80 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import { spawnSync } from "node:child_process";
66
import chalk from "chalk";
77
import { loadConfig } from "../config.js";
88
import { MiosaClient } from "../client.js";
9-
import { handleError, parseHostPath } from "./util.js";
9+
import { handleError, isJsonMode, parseHostPath } from "./util.js";
1010
import { ProgressBar } from "../ui/progress.js";
1111
import { spin } from "../ui/spinner.js";
1212
import { UserError } from "../errors.js";
@@ -18,10 +18,12 @@ export function register(program: Command): void {
1818
"Copy files between local and host (host:/path syntax for remote)",
1919
)
2020
.option("-r, --recursive", "Copy directories recursively")
21-
.action(async (src: string, dst: string, opts: { recursive?: boolean }) => {
21+
.option("--json", "Output as JSON")
22+
.action(async (src: string, dst: string, opts: { recursive?: boolean; json?: boolean }) => {
2223
try {
2324
const config = loadConfig();
2425
const client = new MiosaClient(config);
26+
const json = isJsonMode(opts);
2527

2628
const srcIsRemote = src.includes(":");
2729
const dstIsRemote = dst.includes(":");
@@ -45,43 +47,46 @@ export function register(program: Command): void {
4547
if (!srcIsRemote && dstIsRemote) {
4648
// Upload: local → host
4749
const { host: hostName, path: remotePath } = parseHostPath(dst);
48-
const spinner = spin(`Resolving host ${hostName}...`);
50+
const spinner = json ? null : spin(`Resolving host ${hostName}...`);
4951
try {
5052
const host = await client.getHost(hostName);
51-
spinner.stop();
53+
spinner?.stop();
5254
await uploadPath(
5355
client,
5456
host.id,
5557
src,
5658
remotePath,
5759
opts.recursive ?? false,
60+
json,
5861
);
5962
} catch (err) {
60-
spinner.stop();
63+
spinner?.stop();
6164
await uploadPathToSandbox(
6265
client,
6366
hostName,
6467
src,
6568
remotePath,
6669
opts.recursive ?? false,
70+
json,
6771
err,
6872
);
6973
}
7074
} else {
7175
// Download: host → local
7276
const { host: hostName, path: remotePath } = parseHostPath(src);
73-
const spinner = spin(`Resolving host ${hostName}...`);
77+
const spinner = json ? null : spin(`Resolving host ${hostName}...`);
7478
try {
7579
const host = await client.getHost(hostName);
76-
spinner.stop();
77-
await downloadFile(client, host.id, remotePath, dst);
80+
spinner?.stop();
81+
await downloadFile(client, host.id, remotePath, dst, json);
7882
} catch (err) {
79-
spinner.stop();
83+
spinner?.stop();
8084
await downloadPathFromSandbox(
8185
client,
8286
hostName,
8387
remotePath,
8488
dst,
89+
json,
8590
err,
8691
);
8792
}
@@ -98,6 +103,7 @@ async function uploadPath(
98103
localPath: string,
99104
remotePath: string,
100105
recursive: boolean,
106+
json: boolean,
101107
): Promise<void> {
102108
const stat = fs.statSync(localPath);
103109

@@ -112,7 +118,7 @@ async function uploadPath(
112118
for (const entry of entries) {
113119
const entryLocal = path.join(localPath, entry);
114120
const entryRemote = remotePath.replace(/\/$/, "") + "/" + entry;
115-
await uploadPath(client, hostId, entryLocal, entryRemote, recursive);
121+
await uploadPath(client, hostId, entryLocal, entryRemote, recursive, json);
116122
}
117123
return;
118124
}
@@ -123,8 +129,8 @@ async function uploadPath(
123129
: remotePath;
124130

125131
const data = fs.readFileSync(localPath);
126-
const bar = new ProgressBar(`Uploading ${filename}`);
127-
bar.update(0, data.length);
132+
const bar = json ? null : new ProgressBar(`Uploading ${filename}`);
133+
bar?.update(0, data.length);
128134

129135
await client.uploadFile(
130136
hostId as Parameters<typeof client.uploadFile>[0],
@@ -133,9 +139,13 @@ async function uploadPath(
133139
filename,
134140
);
135141

136-
bar.update(data.length, data.length);
137-
bar.done();
138-
console.log(chalk.green(`Uploaded ${localPath}${remotePath}`));
142+
bar?.update(data.length, data.length);
143+
bar?.done();
144+
if (json) {
145+
console.log(JSON.stringify({ ok: true, data: { source: localPath, target: remotePath } }, null, 2));
146+
} else {
147+
console.log(chalk.green(`Uploaded ${localPath}${remotePath}`));
148+
}
139149
}
140150

141151
async function uploadPathToSandbox(
@@ -144,6 +154,7 @@ async function uploadPathToSandbox(
144154
localPath: string,
145155
remotePath: string,
146156
recursive: boolean,
157+
json: boolean,
147158
hostError: unknown,
148159
): Promise<void> {
149160
await assertSandboxExists(client, sandboxId, hostError);
@@ -169,22 +180,43 @@ async function uploadPathToSandbox(
169180
} finally {
170181
fs.rmSync(archivePath, { force: true });
171182
}
172-
console.log(chalk.green(`Uploaded ${sourceDir}${sandboxId}:${remotePath}`));
183+
if (json) {
184+
console.log(
185+
JSON.stringify(
186+
{ ok: true, data: { sandbox_id: sandboxId, source: sourceDir, target: remotePath, type: "directory" } },
187+
null,
188+
2,
189+
),
190+
);
191+
} else {
192+
console.log(chalk.green(`Uploaded ${sourceDir}${sandboxId}:${remotePath}`));
193+
}
173194
return;
174195
}
175196

176197
const finalRemotePath = remotePath.endsWith("/")
177198
? `${remotePath}${path.basename(localPath)}`
178199
: remotePath;
179200
await writeSandboxFile(client, sandboxId, finalRemotePath, localPath);
180-
console.log(chalk.green(`Uploaded ${localPath}${sandboxId}:${finalRemotePath}`));
201+
if (json) {
202+
console.log(
203+
JSON.stringify(
204+
{ ok: true, data: { sandbox_id: sandboxId, source: localPath, target: finalRemotePath, type: "file" } },
205+
null,
206+
2,
207+
),
208+
);
209+
} else {
210+
console.log(chalk.green(`Uploaded ${localPath}${sandboxId}:${finalRemotePath}`));
211+
}
181212
}
182213

183214
async function downloadPathFromSandbox(
184215
client: MiosaClient,
185216
sandboxId: string,
186217
remotePath: string,
187218
localDst: string,
219+
json: boolean,
188220
hostError: unknown,
189221
): Promise<void> {
190222
await assertSandboxExists(client, sandboxId, hostError);
@@ -216,7 +248,17 @@ async function downloadPathFromSandbox(
216248
() => ({}),
217249
);
218250
}
219-
console.log(chalk.green(`Downloaded ${sandboxId}:${remotePath}${localDir}`));
251+
if (json) {
252+
console.log(
253+
JSON.stringify(
254+
{ ok: true, data: { sandbox_id: sandboxId, source: remotePath, target: localDir, type: "directory" } },
255+
null,
256+
2,
257+
),
258+
);
259+
} else {
260+
console.log(chalk.green(`Downloaded ${sandboxId}:${remotePath}${localDir}`));
261+
}
220262
return;
221263
}
222264

@@ -227,7 +269,17 @@ async function downloadPathFromSandbox(
227269
: localDst;
228270
fs.mkdirSync(path.dirname(path.resolve(localPath)), { recursive: true });
229271
fs.writeFileSync(localPath, bytes);
230-
console.log(chalk.green(`Downloaded ${sandboxId}:${remotePath}${localPath}`));
272+
if (json) {
273+
console.log(
274+
JSON.stringify(
275+
{ ok: true, data: { sandbox_id: sandboxId, source: remotePath, target: localPath, type: "file" } },
276+
null,
277+
2,
278+
),
279+
);
280+
} else {
281+
console.log(chalk.green(`Downloaded ${sandboxId}:${remotePath}${localPath}`));
282+
}
231283
}
232284

233285
async function assertSandboxExists(
@@ -358,6 +410,7 @@ async function downloadFile(
358410
hostId: string,
359411
remotePath: string,
360412
localDst: string,
413+
json: boolean,
361414
): Promise<void> {
362415
const filename = path.basename(remotePath);
363416
let localPath = localDst;
@@ -376,7 +429,7 @@ async function downloadFile(
376429
10,
377430
);
378431

379-
const bar = new ProgressBar(`Downloading ${filename}`);
432+
const bar = json ? null : new ProgressBar(`Downloading ${filename}`);
380433
const out = fs.createWriteStream(localPath);
381434
let received = 0;
382435

@@ -385,14 +438,18 @@ async function downloadFile(
385438
out.write(buf);
386439
received += buf.length;
387440
if (contentLength > 0) {
388-
bar.update(received, contentLength);
441+
bar?.update(received, contentLength);
389442
}
390443
}
391444

392445
await new Promise<void>((resolve, reject) => {
393446
out.end((err?: Error | null) => (err ? reject(err) : resolve()));
394447
});
395448

396-
bar.done();
397-
console.log(chalk.green(`Downloaded ${remotePath}${localPath}`));
449+
bar?.done();
450+
if (json) {
451+
console.log(JSON.stringify({ ok: true, data: { source: remotePath, target: localPath } }, null, 2));
452+
} else {
453+
console.log(chalk.green(`Downloaded ${remotePath}${localPath}`));
454+
}
398455
}

test/commands/cp-ls-sandbox-alias.test.ts

Lines changed: 84 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,16 @@ describe("top-level sandbox cp/ls aliases", () => {
4545
const file = path.join(dir, "file.txt");
4646
fs.writeFileSync(file, "hello");
4747

48+
mock
49+
.get("https://api.miosa.ai")
50+
.intercept({
51+
path: "/api/v1/sandboxes/sbx_123",
52+
method: "GET",
53+
})
54+
.reply(200, JSON.stringify({ data: { id: "sbx_123", state: "running" } }), {
55+
headers: { "content-type": "application/json" },
56+
});
57+
4858
mock
4959
.get("https://api.miosa.ai")
5060
.intercept({
@@ -71,6 +81,67 @@ describe("top-level sandbox cp/ls aliases", () => {
7181
expect(process.exit).not.toHaveBeenCalledWith(1);
7282
});
7383

84+
it("prints valid JSON only for top-level sandbox cp --json", async () => {
85+
const mock = new MockAgent();
86+
mock.disableNetConnect();
87+
setGlobalDispatcher(mock);
88+
89+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "miosa-cp-json-test-"));
90+
const file = path.join(dir, "file.txt");
91+
fs.writeFileSync(file, "hello");
92+
93+
mock
94+
.get("https://api.miosa.ai")
95+
.intercept({
96+
path: "/api/v1/sandboxes/sbx_123",
97+
method: "GET",
98+
})
99+
.reply(200, JSON.stringify({ data: { id: "sbx_123", state: "running" } }), {
100+
headers: { "content-type": "application/json" },
101+
});
102+
103+
mock
104+
.get("https://api.miosa.ai")
105+
.intercept({
106+
path: "/api/v1/sandboxes/sbx_123/files",
107+
method: "POST",
108+
body: JSON.stringify({
109+
path: "/workspace/file.txt",
110+
content: Buffer.from("hello").toString("base64"),
111+
}),
112+
})
113+
.reply(200, JSON.stringify({ data: { ok: true } }), {
114+
headers: { "content-type": "application/json" },
115+
});
116+
117+
const logged: string[] = [];
118+
vi.spyOn(console, "log").mockImplementation((...args: unknown[]) => {
119+
logged.push(args.map(String).join(" "));
120+
});
121+
122+
const program = buildProgram();
123+
await program.parseAsync([
124+
"node",
125+
"miosa",
126+
"cp",
127+
file,
128+
"sbx_123:/workspace/file.txt",
129+
"--json",
130+
]);
131+
132+
expect(process.stdout.write).not.toHaveBeenCalled();
133+
expect(logged).toHaveLength(1);
134+
expect(JSON.parse(logged[0] ?? "{}")).toMatchObject({
135+
ok: true,
136+
data: {
137+
sandbox_id: "sbx_123",
138+
target: "/workspace/file.txt",
139+
type: "file",
140+
},
141+
});
142+
expect(process.exit).not.toHaveBeenCalledWith(1);
143+
});
144+
74145
it("lists sandbox-id:/path with top-level miosa ls", async () => {
75146
const mock = new MockAgent();
76147
mock.disableNetConnect();
@@ -79,22 +150,25 @@ describe("top-level sandbox cp/ls aliases", () => {
79150
mock
80151
.get("https://api.miosa.ai")
81152
.intercept({
82-
path: "/api/v1/sandboxes/sbx_123/files?path=%2Fworkspace",
153+
path: "/api/v1/sandboxes/sbx_123",
83154
method: "GET",
84155
})
156+
.reply(200, JSON.stringify({ data: { id: "sbx_123", state: "running" } }), {
157+
headers: { "content-type": "application/json" },
158+
});
159+
160+
mock
161+
.get("https://api.miosa.ai")
162+
.intercept({
163+
path: "/api/v1/sandboxes/sbx_123/exec",
164+
method: "POST",
165+
})
85166
.reply(
86167
200,
87168
JSON.stringify({
88169
data: {
89-
entries: [
90-
{
91-
name: "package.json",
92-
path: "/workspace/package.json",
93-
type: "file",
94-
size: 42,
95-
modified_at: null,
96-
},
97-
],
170+
stdout:
171+
"package.json\tf\t42\t-rw-r--r--\t1780500000\n",
98172
},
99173
}),
100174
{ headers: { "content-type": "application/json" } },

0 commit comments

Comments
 (0)