@ukiyocompute/cli 0.3.0 → 0.3.1
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 +2 -2
- package/dist/bin.js +84 -33
- package/dist/library.js +84 -33
- package/package.json +2 -3
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
# Ukiyo CLI 0.3.
|
|
1
|
+
# Ukiyo CLI 0.3.1
|
|
2
2
|
|
|
3
|
-
Requires Node.js 24+. Install with `npm install -g @ukiyocompute/cli@0.3.
|
|
3
|
+
Requires Node.js 24+. Install with `npm install -g @ukiyocompute/cli@0.3.1`.
|
|
4
4
|
Packages are staged for owner-approved publication; availability is not implied.
|
|
5
5
|
|
|
6
6
|
Read `https://pay.u-kiyo.ai/api/v1/terms` and its linked document, then explicitly
|
package/dist/bin.js
CHANGED
|
@@ -444,8 +444,8 @@ var UkiyoClient = class {
|
|
|
444
444
|
offers() {
|
|
445
445
|
return this.request("/offers", { authenticated: false });
|
|
446
446
|
}
|
|
447
|
-
me() {
|
|
448
|
-
return this.request("/me");
|
|
447
|
+
me(signal) {
|
|
448
|
+
return this.request("/me", { signal: signal ?? null });
|
|
449
449
|
}
|
|
450
450
|
balance() {
|
|
451
451
|
return this.request("/billing/balance");
|
|
@@ -477,11 +477,11 @@ var UkiyoClient = class {
|
|
|
477
477
|
throw error;
|
|
478
478
|
}
|
|
479
479
|
}
|
|
480
|
-
rent(offerId, budgetMinor, idempotencyKey) {
|
|
481
|
-
return this.request("/rentals", { method: "POST", headers: { "idempotency-key": idempotencyKey }, body: JSON.stringify({ offerId, ...budgetMinor === void 0 ? {} : { budgetMinor } }) });
|
|
480
|
+
rent(offerId, budgetMinor, idempotencyKey, signal) {
|
|
481
|
+
return this.request("/rentals", { signal: signal ?? null, method: "POST", headers: { "idempotency-key": idempotencyKey }, body: JSON.stringify({ offerId, ...budgetMinor === void 0 ? {} : { budgetMinor } }) });
|
|
482
482
|
}
|
|
483
|
-
rentGpu(input, idempotencyKey) {
|
|
484
|
-
return this.request("/rentals", { method: "POST", headers: { "idempotency-key": idempotencyKey }, body: JSON.stringify(input) });
|
|
483
|
+
rentGpu(input, idempotencyKey, signal) {
|
|
484
|
+
return this.request("/rentals", { signal: signal ?? null, method: "POST", headers: { "idempotency-key": idempotencyKey }, body: JSON.stringify(input) });
|
|
485
485
|
}
|
|
486
486
|
rental(id, signal) {
|
|
487
487
|
return this.request(`/rentals/${encodeURIComponent(id)}`, signal ? { signal } : {});
|
|
@@ -495,8 +495,8 @@ var UkiyoClient = class {
|
|
|
495
495
|
credentials(id) {
|
|
496
496
|
return this.request(`/deployments/${encodeURIComponent(id)}/credentials/reveal`, { method: "POST" });
|
|
497
497
|
}
|
|
498
|
-
terminate(id) {
|
|
499
|
-
return this.request(`/deployments/${encodeURIComponent(id)}/terminate`, { method: "POST", body: JSON.stringify({ reason: "USER_REQUESTED" }) });
|
|
498
|
+
terminate(id, signal) {
|
|
499
|
+
return this.request(`/deployments/${encodeURIComponent(id)}/terminate`, { signal: signal ?? null, method: "POST", body: JSON.stringify({ reason: "USER_REQUESTED" }) });
|
|
500
500
|
}
|
|
501
501
|
checkout(offerId, budgetMinor, idempotencyKey) {
|
|
502
502
|
return this.request("/orders/checkout", { method: "POST", headers: { "idempotency-key": idempotencyKey }, body: JSON.stringify({ offerId, budgetMinor }) });
|
|
@@ -700,7 +700,7 @@ async function connectExistingAccount(printUrl, baseUrl = DEFAULT_API_URL, fetch
|
|
|
700
700
|
return result;
|
|
701
701
|
} catch (error) {
|
|
702
702
|
if (!(error instanceof Error) || !error.message.startsWith("Waiting for account-owner approval.")) throw error;
|
|
703
|
-
await new Promise((
|
|
703
|
+
await new Promise((resolve3) => setTimeout(resolve3, 2e3));
|
|
704
704
|
}
|
|
705
705
|
}
|
|
706
706
|
throw new Error("Authorization expired. Run ukiyo connect again.");
|
|
@@ -750,7 +750,7 @@ async function signupAgent(limits, baseUrl = DEFAULT_API_URL, fetcher = fetch, a
|
|
|
750
750
|
// apps/cli/src/ssh.ts
|
|
751
751
|
import { execFile } from "node:child_process";
|
|
752
752
|
import { constants as constants3, closeSync as closeSync3, mkdtempSync, unlinkSync as unlinkSync2, rmdirSync, writeFileSync as writeFileSync2 } from "node:fs";
|
|
753
|
-
import { join as join4 } from "node:path";
|
|
753
|
+
import { resolve as resolve2, join as join4 } from "node:path";
|
|
754
754
|
|
|
755
755
|
// apps/cli/src/output.ts
|
|
756
756
|
function redact(text, secrets = []) {
|
|
@@ -818,12 +818,12 @@ async function executeSsh(id, access, command, timeout = 60, runner = execFile)
|
|
|
818
818
|
if (isWindows()) {
|
|
819
819
|
for (const name of ["SystemRoot", "WINDIR", "USERPROFILE", "LOCALAPPDATA", "TEMP", "TMP"]) if (process.env[name]) env[name] = process.env[name];
|
|
820
820
|
}
|
|
821
|
-
return await new Promise((
|
|
821
|
+
return await new Promise((resolve3, reject) => runner("ssh", args, { timeout: timeout * 1e3, maxBuffer: 4 * 1024 * 1024, env, encoding: "utf8", windowsHide: true }, (error, stdout, stderr) => {
|
|
822
822
|
if (error && typeof error.code !== "number") {
|
|
823
823
|
reject(new Error(error.killed ? "SSH command timed out." : "SSH transport failed. Check SSH availability and the deployment state."));
|
|
824
824
|
return;
|
|
825
825
|
}
|
|
826
|
-
|
|
826
|
+
resolve3({ exitCode: error?.code ?? 0, stdout: redact(String(stdout), [access.privateKey, process.env.UKIYO_API_TOKEN ?? ""]), stderr: redact(String(stderr), [access.privateKey, process.env.UKIYO_API_TOKEN ?? ""]) });
|
|
827
827
|
}));
|
|
828
828
|
} finally {
|
|
829
829
|
try {
|
|
@@ -837,6 +837,29 @@ async function executeSsh(id, access, command, timeout = 60, runner = execFile)
|
|
|
837
837
|
}
|
|
838
838
|
}
|
|
839
839
|
}
|
|
840
|
+
function saveSshKey(path, access) {
|
|
841
|
+
if (!/^[a-zA-Z0-9][a-zA-Z0-9.:-]*$/.test(access.host) || !/^[a-zA-Z0-9_][a-zA-Z0-9_-]*$/.test(access.username) || !Number.isInteger(access.port) || access.port < 1 || access.port > 65535 || !access.privateKey?.includes("PRIVATE KEY")) throw new Error("Invalid SSH connection.");
|
|
842
|
+
const keyPath = resolve2(path);
|
|
843
|
+
const fd = openPrivateFile(keyPath, constants3.O_WRONLY, true);
|
|
844
|
+
try {
|
|
845
|
+
writeFileSync2(fd, access.privateKey);
|
|
846
|
+
} catch (error) {
|
|
847
|
+
unlinkSync2(keyPath);
|
|
848
|
+
throw error;
|
|
849
|
+
} finally {
|
|
850
|
+
closeSync3(fd);
|
|
851
|
+
}
|
|
852
|
+
const quoted = isWindows() ? "'" + keyPath.replaceAll("'", "''") + "'" : "'" + keyPath.replaceAll("'", "'\\''") + "'";
|
|
853
|
+
return {
|
|
854
|
+
deploymentId: access.deploymentId,
|
|
855
|
+
rentalId: access.rentalId,
|
|
856
|
+
host: access.host,
|
|
857
|
+
port: access.port,
|
|
858
|
+
username: access.username,
|
|
859
|
+
keyPath,
|
|
860
|
+
sshCommand: `ssh -i ${quoted} -p ${access.port} -o IdentitiesOnly=yes ${access.username}@${access.host}`
|
|
861
|
+
};
|
|
862
|
+
}
|
|
840
863
|
|
|
841
864
|
// apps/cli/src/wait.ts
|
|
842
865
|
function timeoutSeconds(value) {
|
|
@@ -844,28 +867,45 @@ function timeoutSeconds(value) {
|
|
|
844
867
|
if (!Number.isSafeInteger(seconds) || seconds < 1 || seconds > 3600) throw new ApiError(0, "INVALID_USAGE", "Timeout must be 1\u20133600 seconds.", null);
|
|
845
868
|
return seconds;
|
|
846
869
|
}
|
|
847
|
-
async function
|
|
848
|
-
const
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
const error = new ApiError(0, "WAIT_TIMEOUT", "Wait ended; the resource still exists. Inspect the known ID. Do not create a replacement rental.", null);
|
|
852
|
-
error.context = { ...context, ...latest ? { resource: latest } : {} };
|
|
870
|
+
async function beforeDeadline(read, deadline, context) {
|
|
871
|
+
const expired = () => {
|
|
872
|
+
const error = new ApiError(0, "WAIT_TIMEOUT", "Wait ended; inspect the known resource ID. For a write, retry only with the same Idempotency-Key and original parameters; never create a replacement rental.", null);
|
|
873
|
+
error.context = { ...context };
|
|
853
874
|
return error;
|
|
854
875
|
};
|
|
876
|
+
const remaining = deadline - Date.now();
|
|
877
|
+
if (remaining <= 0) throw expired();
|
|
878
|
+
const controller = new AbortController();
|
|
879
|
+
let timer;
|
|
880
|
+
try {
|
|
881
|
+
const value = await Promise.race([read(controller.signal), new Promise((_, reject) => {
|
|
882
|
+
timer = setTimeout(() => {
|
|
883
|
+
controller.abort();
|
|
884
|
+
reject(expired());
|
|
885
|
+
}, remaining);
|
|
886
|
+
})]);
|
|
887
|
+
if (Date.now() >= deadline) throw expired();
|
|
888
|
+
return value;
|
|
889
|
+
} catch (error) {
|
|
890
|
+
if (Date.now() >= deadline) throw expired();
|
|
891
|
+
throw error;
|
|
892
|
+
} finally {
|
|
893
|
+
if (timer) clearTimeout(timer);
|
|
894
|
+
}
|
|
895
|
+
}
|
|
896
|
+
async function waitFor(read, done, seconds, context, deadline = Date.now() + seconds * 1e3) {
|
|
897
|
+
let latest;
|
|
855
898
|
for (; ; ) {
|
|
856
|
-
const remaining = deadline - Date.now();
|
|
857
|
-
if (remaining <= 0) throw timedOut();
|
|
858
899
|
try {
|
|
859
|
-
latest = await read
|
|
900
|
+
latest = await beforeDeadline(read, deadline, { ...context, ...latest ? { resource: latest } : {} });
|
|
860
901
|
} catch (error) {
|
|
861
|
-
if (Date.now() >= deadline) throw timedOut();
|
|
862
902
|
if (error instanceof ApiError) error.context = { ...context, ...error.context };
|
|
863
903
|
throw error;
|
|
864
904
|
}
|
|
865
905
|
if (latest?.rentalId) context.rentalId = latest.rentalId;
|
|
866
906
|
if (latest?.deploymentId) context.deploymentId = latest.deploymentId;
|
|
867
907
|
if (done(latest)) return latest;
|
|
868
|
-
await new Promise((
|
|
908
|
+
await new Promise((resolve3) => setTimeout(resolve3, Math.max(0, Math.min(1e3, deadline - Date.now()))));
|
|
869
909
|
}
|
|
870
910
|
}
|
|
871
911
|
|
|
@@ -890,7 +930,7 @@ function buildProgram(options = {}) {
|
|
|
890
930
|
return new UkiyoClient({ baseUrl, ...token ? { token } : {} });
|
|
891
931
|
};
|
|
892
932
|
const print = (value) => out(JSON.stringify(safeOutput(value), null, 2));
|
|
893
|
-
const program = new Command().name("ukiyo").description("Manage Ukiyo compute through the public API").version("0.3.
|
|
933
|
+
const program = new Command().name("ukiyo").description("Manage Ukiyo compute through the public API").version("0.3.1").configureOutput({ writeErr: () => {
|
|
894
934
|
} }).exitOverride();
|
|
895
935
|
const machine = program.command("machine").description("Wallet-paid compute with no prior Ukiyo account; requires local signer config and explicit spend caps");
|
|
896
936
|
const machineClient = () => {
|
|
@@ -941,11 +981,12 @@ function buildProgram(options = {}) {
|
|
|
941
981
|
topup.command("status").requiredOption("--topup-id <id>").option("--json").description("Inspect funding; pending means do not pay again").action(async (opts) => print(await client().topupStatus(opts.topupId)));
|
|
942
982
|
program.command("rent").option("--offer <id>").option("--gpu <name>").option("--count <count>").option("--budget <usd>", "required for fixed-prepaid accounts; optional for account-balance billing").option("--max-hourly-price <usd>", "GPU/count accepted hourly ceiling; reuse the same ceiling on retries").option("--idempotency-key <key>", "unique per intended rental; reuse with original parameters on retries").option("--json").option("--wait").option("--timeout <seconds>", "maximum status wait", "600").action(async (opts) => {
|
|
943
983
|
const seconds = timeoutSeconds(opts.timeout);
|
|
984
|
+
const deadline = Date.now() + seconds * 1e3;
|
|
944
985
|
const budget = opts.budget === void 0 ? void 0 : dollarsToMinor(opts.budget);
|
|
945
986
|
if (Boolean(opts.offer) === Boolean(opts.gpu) || opts.offer && (opts.count || opts.maxHourlyPrice)) throw new ApiError(0, "INVALID_USAGE", "Choose --offer OR --gpu with optional --count and hourly ceiling.", null);
|
|
946
987
|
const api = client();
|
|
947
988
|
if (budget === void 0) {
|
|
948
|
-
const account = await api.me();
|
|
989
|
+
const account = await (opts.wait ? beforeDeadline((signal) => api.me(signal), deadline, {}) : api.me());
|
|
949
990
|
if (account.billing?.model !== "ACCOUNT_BALANCE" || account.billing.budgetRequired !== false) {
|
|
950
991
|
throw new ApiError(0, "INVALID_USAGE", "--budget is required for this account. Budget-free rentals require account-balance billing.", null);
|
|
951
992
|
}
|
|
@@ -965,9 +1006,10 @@ function buildProgram(options = {}) {
|
|
|
965
1006
|
const idempotencyKey = opts.idempotencyKey ?? randomUUID3();
|
|
966
1007
|
const context = { ...intent ?? { offerId: opts.offer, ...budgetFields }, idempotencyKey };
|
|
967
1008
|
try {
|
|
968
|
-
const
|
|
1009
|
+
const create = (signal) => intent ? api.rentGpu(intent, idempotencyKey, signal) : api.rent(opts.offer, budget, idempotencyKey, signal);
|
|
1010
|
+
const rental = await (opts.wait ? beforeDeadline(create, deadline, context) : create());
|
|
969
1011
|
Object.assign(context, { rentalId: rental.rentalId, deploymentId: rental.deploymentId });
|
|
970
|
-
const result = opts.wait && !["ACTIVE", "FAILED", "TERMINATED"].includes(rental.status) ? await waitFor((signal) => api.rental(rental.rentalId, signal), (value) => ["ACTIVE", "FAILED", "TERMINATED"].includes(value.status), seconds, context) : rental;
|
|
1012
|
+
const result = opts.wait && !["ACTIVE", "FAILED", "TERMINATED"].includes(rental.status) ? await waitFor((signal) => api.rental(rental.rentalId, signal), (value) => ["ACTIVE", "FAILED", "TERMINATED"].includes(value.status), seconds, context, deadline) : rental;
|
|
971
1013
|
print({ ...result, ...context });
|
|
972
1014
|
if (opts.wait && result.status !== "ACTIVE") process.exitCode = 1;
|
|
973
1015
|
} catch (error) {
|
|
@@ -984,23 +1026,32 @@ function buildProgram(options = {}) {
|
|
|
984
1026
|
program.command("exec <id> <command...>").option("--json").option("--timeout <seconds>", "command timeout", "60").description("Execute a command over authenticated SSH (first host key is pinned locally)").action(async (id, command, opts) => {
|
|
985
1027
|
const seconds = timeoutSeconds(opts.timeout);
|
|
986
1028
|
const access = await client().credentials(id);
|
|
987
|
-
const result = await executeSsh(id, access, command, seconds);
|
|
988
|
-
print({ deploymentId: id, ...result });
|
|
1029
|
+
const result = await executeSsh(access.deploymentId ?? id, access, command, seconds);
|
|
1030
|
+
print({ deploymentId: access.deploymentId ?? id, rentalId: access.rentalId, ...result });
|
|
989
1031
|
if (result.exitCode) process.exitCode = 1;
|
|
990
1032
|
});
|
|
991
1033
|
program.command("offers").option("--json").option("--gpu <name>").option("--count <count>").description("List available compute").action(async (opts) => print(filteredOffers(await client().offers(), opts.gpu, opts.count)));
|
|
992
1034
|
program.command("list").option("--json").description("List your deployments").action(async () => print(await client().deployments()));
|
|
993
1035
|
program.command("status <id>").option("--json").description("Show one deployment").action(async (id) => print(await client().deployment(id)));
|
|
994
|
-
program.command("credentials <id>").option("--json").
|
|
1036
|
+
program.command("credentials <id>").option("--json").option("--save-key <path>", "save a private SSH key securely without printing it").option("--reveal", "explicitly print the private key; audited and unsafe for shared transcripts").description("Audited SSH connection metadata; private keys are redacted by default. Prefer exec or --save-key.").action(async (id, opts) => {
|
|
1037
|
+
if (opts.saveKey && opts.reveal) throw new ApiError(0, "INVALID_USAGE", "Choose --save-key or --reveal, not both.", null);
|
|
1038
|
+
const access = await client().credentials(id);
|
|
1039
|
+
if (opts.saveKey) print(saveSshKey(opts.saveKey, access));
|
|
1040
|
+
else if (opts.reveal) {
|
|
1041
|
+
console.error("WARNING: credential reveal is audited. This output contains a private key; do not share or log it.");
|
|
1042
|
+
out(JSON.stringify(access, null, 2));
|
|
1043
|
+
} else print(access);
|
|
1044
|
+
});
|
|
995
1045
|
program.command("deploy").description("Rent an offer").requiredOption("--offer <id>").requiredOption("--budget <usd>", "budget in dollars, e.g. 20 or 19.99").action(async (opts) => print(await client().checkout(opts.offer, dollarsToMinor(opts.budget), randomUUID3())));
|
|
996
1046
|
program.command("terminate <id>").option("--json").option("--wait").option("--timeout <seconds>", "maximum termination wait", "600").description("Terminate a deployment. This is irreversible.").option("--yes", "skip the confirmation prompt").action(async (id, opts) => {
|
|
997
1047
|
const seconds = timeoutSeconds(opts.timeout);
|
|
998
1048
|
if (!opts.yes) throw new ApiError(0, "CONFIRMATION_REQUIRED", `Terminating ${id} is irreversible and destroys anything on the instance. Re-run with --yes to confirm.`, null);
|
|
999
1049
|
const api = client();
|
|
1000
1050
|
try {
|
|
1001
|
-
const
|
|
1002
|
-
const
|
|
1003
|
-
|
|
1051
|
+
const deadline = Date.now() + seconds * 1e3;
|
|
1052
|
+
const accepted = await (opts.wait ? beforeDeadline((signal) => api.terminate(id, signal), deadline, { deploymentId: id }) : api.terminate(id));
|
|
1053
|
+
const value = opts.wait ? await waitFor((signal) => api.deployment(id, signal), (value2) => value2.status === "TERMINATED", seconds, { deploymentId: id, ...accepted }, deadline) : accepted;
|
|
1054
|
+
print({ deploymentId: id, ...value });
|
|
1004
1055
|
} catch (error) {
|
|
1005
1056
|
contextError(error, { deploymentId: id });
|
|
1006
1057
|
}
|
package/dist/library.js
CHANGED
|
@@ -444,8 +444,8 @@ var UkiyoClient = class {
|
|
|
444
444
|
offers() {
|
|
445
445
|
return this.request("/offers", { authenticated: false });
|
|
446
446
|
}
|
|
447
|
-
me() {
|
|
448
|
-
return this.request("/me");
|
|
447
|
+
me(signal) {
|
|
448
|
+
return this.request("/me", { signal: signal ?? null });
|
|
449
449
|
}
|
|
450
450
|
balance() {
|
|
451
451
|
return this.request("/billing/balance");
|
|
@@ -477,11 +477,11 @@ var UkiyoClient = class {
|
|
|
477
477
|
throw error;
|
|
478
478
|
}
|
|
479
479
|
}
|
|
480
|
-
rent(offerId, budgetMinor, idempotencyKey) {
|
|
481
|
-
return this.request("/rentals", { method: "POST", headers: { "idempotency-key": idempotencyKey }, body: JSON.stringify({ offerId, ...budgetMinor === void 0 ? {} : { budgetMinor } }) });
|
|
480
|
+
rent(offerId, budgetMinor, idempotencyKey, signal) {
|
|
481
|
+
return this.request("/rentals", { signal: signal ?? null, method: "POST", headers: { "idempotency-key": idempotencyKey }, body: JSON.stringify({ offerId, ...budgetMinor === void 0 ? {} : { budgetMinor } }) });
|
|
482
482
|
}
|
|
483
|
-
rentGpu(input, idempotencyKey) {
|
|
484
|
-
return this.request("/rentals", { method: "POST", headers: { "idempotency-key": idempotencyKey }, body: JSON.stringify(input) });
|
|
483
|
+
rentGpu(input, idempotencyKey, signal) {
|
|
484
|
+
return this.request("/rentals", { signal: signal ?? null, method: "POST", headers: { "idempotency-key": idempotencyKey }, body: JSON.stringify(input) });
|
|
485
485
|
}
|
|
486
486
|
rental(id, signal) {
|
|
487
487
|
return this.request(`/rentals/${encodeURIComponent(id)}`, signal ? { signal } : {});
|
|
@@ -495,8 +495,8 @@ var UkiyoClient = class {
|
|
|
495
495
|
credentials(id) {
|
|
496
496
|
return this.request(`/deployments/${encodeURIComponent(id)}/credentials/reveal`, { method: "POST" });
|
|
497
497
|
}
|
|
498
|
-
terminate(id) {
|
|
499
|
-
return this.request(`/deployments/${encodeURIComponent(id)}/terminate`, { method: "POST", body: JSON.stringify({ reason: "USER_REQUESTED" }) });
|
|
498
|
+
terminate(id, signal) {
|
|
499
|
+
return this.request(`/deployments/${encodeURIComponent(id)}/terminate`, { signal: signal ?? null, method: "POST", body: JSON.stringify({ reason: "USER_REQUESTED" }) });
|
|
500
500
|
}
|
|
501
501
|
checkout(offerId, budgetMinor, idempotencyKey) {
|
|
502
502
|
return this.request("/orders/checkout", { method: "POST", headers: { "idempotency-key": idempotencyKey }, body: JSON.stringify({ offerId, budgetMinor }) });
|
|
@@ -700,7 +700,7 @@ async function connectExistingAccount(printUrl, baseUrl = DEFAULT_API_URL, fetch
|
|
|
700
700
|
return result;
|
|
701
701
|
} catch (error) {
|
|
702
702
|
if (!(error instanceof Error) || !error.message.startsWith("Waiting for account-owner approval.")) throw error;
|
|
703
|
-
await new Promise((
|
|
703
|
+
await new Promise((resolve3) => setTimeout(resolve3, 2e3));
|
|
704
704
|
}
|
|
705
705
|
}
|
|
706
706
|
throw new Error("Authorization expired. Run ukiyo connect again.");
|
|
@@ -750,7 +750,7 @@ async function signupAgent(limits, baseUrl = DEFAULT_API_URL, fetcher = fetch, a
|
|
|
750
750
|
// apps/cli/src/ssh.ts
|
|
751
751
|
import { execFile } from "node:child_process";
|
|
752
752
|
import { constants as constants3, closeSync as closeSync3, mkdtempSync, unlinkSync as unlinkSync2, rmdirSync, writeFileSync as writeFileSync2 } from "node:fs";
|
|
753
|
-
import { join as join4 } from "node:path";
|
|
753
|
+
import { resolve as resolve2, join as join4 } from "node:path";
|
|
754
754
|
|
|
755
755
|
// apps/cli/src/output.ts
|
|
756
756
|
function redact(text, secrets = []) {
|
|
@@ -818,12 +818,12 @@ async function executeSsh(id, access, command, timeout = 60, runner = execFile)
|
|
|
818
818
|
if (isWindows()) {
|
|
819
819
|
for (const name of ["SystemRoot", "WINDIR", "USERPROFILE", "LOCALAPPDATA", "TEMP", "TMP"]) if (process.env[name]) env[name] = process.env[name];
|
|
820
820
|
}
|
|
821
|
-
return await new Promise((
|
|
821
|
+
return await new Promise((resolve3, reject) => runner("ssh", args, { timeout: timeout * 1e3, maxBuffer: 4 * 1024 * 1024, env, encoding: "utf8", windowsHide: true }, (error, stdout, stderr) => {
|
|
822
822
|
if (error && typeof error.code !== "number") {
|
|
823
823
|
reject(new Error(error.killed ? "SSH command timed out." : "SSH transport failed. Check SSH availability and the deployment state."));
|
|
824
824
|
return;
|
|
825
825
|
}
|
|
826
|
-
|
|
826
|
+
resolve3({ exitCode: error?.code ?? 0, stdout: redact(String(stdout), [access.privateKey, process.env.UKIYO_API_TOKEN ?? ""]), stderr: redact(String(stderr), [access.privateKey, process.env.UKIYO_API_TOKEN ?? ""]) });
|
|
827
827
|
}));
|
|
828
828
|
} finally {
|
|
829
829
|
try {
|
|
@@ -837,6 +837,29 @@ async function executeSsh(id, access, command, timeout = 60, runner = execFile)
|
|
|
837
837
|
}
|
|
838
838
|
}
|
|
839
839
|
}
|
|
840
|
+
function saveSshKey(path, access) {
|
|
841
|
+
if (!/^[a-zA-Z0-9][a-zA-Z0-9.:-]*$/.test(access.host) || !/^[a-zA-Z0-9_][a-zA-Z0-9_-]*$/.test(access.username) || !Number.isInteger(access.port) || access.port < 1 || access.port > 65535 || !access.privateKey?.includes("PRIVATE KEY")) throw new Error("Invalid SSH connection.");
|
|
842
|
+
const keyPath = resolve2(path);
|
|
843
|
+
const fd = openPrivateFile(keyPath, constants3.O_WRONLY, true);
|
|
844
|
+
try {
|
|
845
|
+
writeFileSync2(fd, access.privateKey);
|
|
846
|
+
} catch (error) {
|
|
847
|
+
unlinkSync2(keyPath);
|
|
848
|
+
throw error;
|
|
849
|
+
} finally {
|
|
850
|
+
closeSync3(fd);
|
|
851
|
+
}
|
|
852
|
+
const quoted = isWindows() ? "'" + keyPath.replaceAll("'", "''") + "'" : "'" + keyPath.replaceAll("'", "'\\''") + "'";
|
|
853
|
+
return {
|
|
854
|
+
deploymentId: access.deploymentId,
|
|
855
|
+
rentalId: access.rentalId,
|
|
856
|
+
host: access.host,
|
|
857
|
+
port: access.port,
|
|
858
|
+
username: access.username,
|
|
859
|
+
keyPath,
|
|
860
|
+
sshCommand: `ssh -i ${quoted} -p ${access.port} -o IdentitiesOnly=yes ${access.username}@${access.host}`
|
|
861
|
+
};
|
|
862
|
+
}
|
|
840
863
|
|
|
841
864
|
// apps/cli/src/wait.ts
|
|
842
865
|
function timeoutSeconds(value) {
|
|
@@ -844,28 +867,45 @@ function timeoutSeconds(value) {
|
|
|
844
867
|
if (!Number.isSafeInteger(seconds) || seconds < 1 || seconds > 3600) throw new ApiError(0, "INVALID_USAGE", "Timeout must be 1\u20133600 seconds.", null);
|
|
845
868
|
return seconds;
|
|
846
869
|
}
|
|
847
|
-
async function
|
|
848
|
-
const
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
const error = new ApiError(0, "WAIT_TIMEOUT", "Wait ended; the resource still exists. Inspect the known ID. Do not create a replacement rental.", null);
|
|
852
|
-
error.context = { ...context, ...latest ? { resource: latest } : {} };
|
|
870
|
+
async function beforeDeadline(read, deadline, context) {
|
|
871
|
+
const expired = () => {
|
|
872
|
+
const error = new ApiError(0, "WAIT_TIMEOUT", "Wait ended; inspect the known resource ID. For a write, retry only with the same Idempotency-Key and original parameters; never create a replacement rental.", null);
|
|
873
|
+
error.context = { ...context };
|
|
853
874
|
return error;
|
|
854
875
|
};
|
|
876
|
+
const remaining = deadline - Date.now();
|
|
877
|
+
if (remaining <= 0) throw expired();
|
|
878
|
+
const controller = new AbortController();
|
|
879
|
+
let timer;
|
|
880
|
+
try {
|
|
881
|
+
const value = await Promise.race([read(controller.signal), new Promise((_, reject) => {
|
|
882
|
+
timer = setTimeout(() => {
|
|
883
|
+
controller.abort();
|
|
884
|
+
reject(expired());
|
|
885
|
+
}, remaining);
|
|
886
|
+
})]);
|
|
887
|
+
if (Date.now() >= deadline) throw expired();
|
|
888
|
+
return value;
|
|
889
|
+
} catch (error) {
|
|
890
|
+
if (Date.now() >= deadline) throw expired();
|
|
891
|
+
throw error;
|
|
892
|
+
} finally {
|
|
893
|
+
if (timer) clearTimeout(timer);
|
|
894
|
+
}
|
|
895
|
+
}
|
|
896
|
+
async function waitFor(read, done, seconds, context, deadline = Date.now() + seconds * 1e3) {
|
|
897
|
+
let latest;
|
|
855
898
|
for (; ; ) {
|
|
856
|
-
const remaining = deadline - Date.now();
|
|
857
|
-
if (remaining <= 0) throw timedOut();
|
|
858
899
|
try {
|
|
859
|
-
latest = await read
|
|
900
|
+
latest = await beforeDeadline(read, deadline, { ...context, ...latest ? { resource: latest } : {} });
|
|
860
901
|
} catch (error) {
|
|
861
|
-
if (Date.now() >= deadline) throw timedOut();
|
|
862
902
|
if (error instanceof ApiError) error.context = { ...context, ...error.context };
|
|
863
903
|
throw error;
|
|
864
904
|
}
|
|
865
905
|
if (latest?.rentalId) context.rentalId = latest.rentalId;
|
|
866
906
|
if (latest?.deploymentId) context.deploymentId = latest.deploymentId;
|
|
867
907
|
if (done(latest)) return latest;
|
|
868
|
-
await new Promise((
|
|
908
|
+
await new Promise((resolve3) => setTimeout(resolve3, Math.max(0, Math.min(1e3, deadline - Date.now()))));
|
|
869
909
|
}
|
|
870
910
|
}
|
|
871
911
|
|
|
@@ -890,7 +930,7 @@ function buildProgram(options = {}) {
|
|
|
890
930
|
return new UkiyoClient({ baseUrl, ...token ? { token } : {} });
|
|
891
931
|
};
|
|
892
932
|
const print = (value) => out(JSON.stringify(safeOutput(value), null, 2));
|
|
893
|
-
const program = new Command().name("ukiyo").description("Manage Ukiyo compute through the public API").version("0.3.
|
|
933
|
+
const program = new Command().name("ukiyo").description("Manage Ukiyo compute through the public API").version("0.3.1").configureOutput({ writeErr: () => {
|
|
894
934
|
} }).exitOverride();
|
|
895
935
|
const machine = program.command("machine").description("Wallet-paid compute with no prior Ukiyo account; requires local signer config and explicit spend caps");
|
|
896
936
|
const machineClient = () => {
|
|
@@ -941,11 +981,12 @@ function buildProgram(options = {}) {
|
|
|
941
981
|
topup.command("status").requiredOption("--topup-id <id>").option("--json").description("Inspect funding; pending means do not pay again").action(async (opts) => print(await client().topupStatus(opts.topupId)));
|
|
942
982
|
program.command("rent").option("--offer <id>").option("--gpu <name>").option("--count <count>").option("--budget <usd>", "required for fixed-prepaid accounts; optional for account-balance billing").option("--max-hourly-price <usd>", "GPU/count accepted hourly ceiling; reuse the same ceiling on retries").option("--idempotency-key <key>", "unique per intended rental; reuse with original parameters on retries").option("--json").option("--wait").option("--timeout <seconds>", "maximum status wait", "600").action(async (opts) => {
|
|
943
983
|
const seconds = timeoutSeconds(opts.timeout);
|
|
984
|
+
const deadline = Date.now() + seconds * 1e3;
|
|
944
985
|
const budget = opts.budget === void 0 ? void 0 : dollarsToMinor(opts.budget);
|
|
945
986
|
if (Boolean(opts.offer) === Boolean(opts.gpu) || opts.offer && (opts.count || opts.maxHourlyPrice)) throw new ApiError(0, "INVALID_USAGE", "Choose --offer OR --gpu with optional --count and hourly ceiling.", null);
|
|
946
987
|
const api = client();
|
|
947
988
|
if (budget === void 0) {
|
|
948
|
-
const account = await api.me();
|
|
989
|
+
const account = await (opts.wait ? beforeDeadline((signal) => api.me(signal), deadline, {}) : api.me());
|
|
949
990
|
if (account.billing?.model !== "ACCOUNT_BALANCE" || account.billing.budgetRequired !== false) {
|
|
950
991
|
throw new ApiError(0, "INVALID_USAGE", "--budget is required for this account. Budget-free rentals require account-balance billing.", null);
|
|
951
992
|
}
|
|
@@ -965,9 +1006,10 @@ function buildProgram(options = {}) {
|
|
|
965
1006
|
const idempotencyKey = opts.idempotencyKey ?? randomUUID3();
|
|
966
1007
|
const context = { ...intent ?? { offerId: opts.offer, ...budgetFields }, idempotencyKey };
|
|
967
1008
|
try {
|
|
968
|
-
const
|
|
1009
|
+
const create = (signal) => intent ? api.rentGpu(intent, idempotencyKey, signal) : api.rent(opts.offer, budget, idempotencyKey, signal);
|
|
1010
|
+
const rental = await (opts.wait ? beforeDeadline(create, deadline, context) : create());
|
|
969
1011
|
Object.assign(context, { rentalId: rental.rentalId, deploymentId: rental.deploymentId });
|
|
970
|
-
const result = opts.wait && !["ACTIVE", "FAILED", "TERMINATED"].includes(rental.status) ? await waitFor((signal) => api.rental(rental.rentalId, signal), (value) => ["ACTIVE", "FAILED", "TERMINATED"].includes(value.status), seconds, context) : rental;
|
|
1012
|
+
const result = opts.wait && !["ACTIVE", "FAILED", "TERMINATED"].includes(rental.status) ? await waitFor((signal) => api.rental(rental.rentalId, signal), (value) => ["ACTIVE", "FAILED", "TERMINATED"].includes(value.status), seconds, context, deadline) : rental;
|
|
971
1013
|
print({ ...result, ...context });
|
|
972
1014
|
if (opts.wait && result.status !== "ACTIVE") process.exitCode = 1;
|
|
973
1015
|
} catch (error) {
|
|
@@ -984,23 +1026,32 @@ function buildProgram(options = {}) {
|
|
|
984
1026
|
program.command("exec <id> <command...>").option("--json").option("--timeout <seconds>", "command timeout", "60").description("Execute a command over authenticated SSH (first host key is pinned locally)").action(async (id, command, opts) => {
|
|
985
1027
|
const seconds = timeoutSeconds(opts.timeout);
|
|
986
1028
|
const access = await client().credentials(id);
|
|
987
|
-
const result = await executeSsh(id, access, command, seconds);
|
|
988
|
-
print({ deploymentId: id, ...result });
|
|
1029
|
+
const result = await executeSsh(access.deploymentId ?? id, access, command, seconds);
|
|
1030
|
+
print({ deploymentId: access.deploymentId ?? id, rentalId: access.rentalId, ...result });
|
|
989
1031
|
if (result.exitCode) process.exitCode = 1;
|
|
990
1032
|
});
|
|
991
1033
|
program.command("offers").option("--json").option("--gpu <name>").option("--count <count>").description("List available compute").action(async (opts) => print(filteredOffers(await client().offers(), opts.gpu, opts.count)));
|
|
992
1034
|
program.command("list").option("--json").description("List your deployments").action(async () => print(await client().deployments()));
|
|
993
1035
|
program.command("status <id>").option("--json").description("Show one deployment").action(async (id) => print(await client().deployment(id)));
|
|
994
|
-
program.command("credentials <id>").option("--json").
|
|
1036
|
+
program.command("credentials <id>").option("--json").option("--save-key <path>", "save a private SSH key securely without printing it").option("--reveal", "explicitly print the private key; audited and unsafe for shared transcripts").description("Audited SSH connection metadata; private keys are redacted by default. Prefer exec or --save-key.").action(async (id, opts) => {
|
|
1037
|
+
if (opts.saveKey && opts.reveal) throw new ApiError(0, "INVALID_USAGE", "Choose --save-key or --reveal, not both.", null);
|
|
1038
|
+
const access = await client().credentials(id);
|
|
1039
|
+
if (opts.saveKey) print(saveSshKey(opts.saveKey, access));
|
|
1040
|
+
else if (opts.reveal) {
|
|
1041
|
+
console.error("WARNING: credential reveal is audited. This output contains a private key; do not share or log it.");
|
|
1042
|
+
out(JSON.stringify(access, null, 2));
|
|
1043
|
+
} else print(access);
|
|
1044
|
+
});
|
|
995
1045
|
program.command("deploy").description("Rent an offer").requiredOption("--offer <id>").requiredOption("--budget <usd>", "budget in dollars, e.g. 20 or 19.99").action(async (opts) => print(await client().checkout(opts.offer, dollarsToMinor(opts.budget), randomUUID3())));
|
|
996
1046
|
program.command("terminate <id>").option("--json").option("--wait").option("--timeout <seconds>", "maximum termination wait", "600").description("Terminate a deployment. This is irreversible.").option("--yes", "skip the confirmation prompt").action(async (id, opts) => {
|
|
997
1047
|
const seconds = timeoutSeconds(opts.timeout);
|
|
998
1048
|
if (!opts.yes) throw new ApiError(0, "CONFIRMATION_REQUIRED", `Terminating ${id} is irreversible and destroys anything on the instance. Re-run with --yes to confirm.`, null);
|
|
999
1049
|
const api = client();
|
|
1000
1050
|
try {
|
|
1001
|
-
const
|
|
1002
|
-
const
|
|
1003
|
-
|
|
1051
|
+
const deadline = Date.now() + seconds * 1e3;
|
|
1052
|
+
const accepted = await (opts.wait ? beforeDeadline((signal) => api.terminate(id, signal), deadline, { deploymentId: id }) : api.terminate(id));
|
|
1053
|
+
const value = opts.wait ? await waitFor((signal) => api.deployment(id, signal), (value2) => value2.status === "TERMINATED", seconds, { deploymentId: id, ...accepted }, deadline) : accepted;
|
|
1054
|
+
print({ deploymentId: id, ...value });
|
|
1004
1055
|
} catch (error) {
|
|
1005
1056
|
contextError(error, { deploymentId: id });
|
|
1006
1057
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ukiyocompute/cli",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.1",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"main": "./dist/library.js",
|
|
6
6
|
"exports": {
|
|
@@ -18,8 +18,7 @@
|
|
|
18
18
|
"node": ">=24"
|
|
19
19
|
},
|
|
20
20
|
"publishConfig": {
|
|
21
|
-
"access": "public"
|
|
22
|
-
"provenance": true
|
|
21
|
+
"access": "public"
|
|
23
22
|
},
|
|
24
23
|
"repository": {
|
|
25
24
|
"type": "git",
|