@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 CHANGED
@@ -1,6 +1,6 @@
1
- # Ukiyo CLI 0.3.0
1
+ # Ukiyo CLI 0.3.1
2
2
 
3
- Requires Node.js 24+. Install with `npm install -g @ukiyocompute/cli@0.3.0`.
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((resolve2) => setTimeout(resolve2, 2e3));
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((resolve2, reject) => runner("ssh", args, { timeout: timeout * 1e3, maxBuffer: 4 * 1024 * 1024, env, encoding: "utf8", windowsHide: true }, (error, stdout, stderr) => {
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
- resolve2({ 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 ?? ""]) });
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 waitFor(read, done, seconds, context) {
848
- const deadline = Date.now() + seconds * 1e3;
849
- let latest;
850
- const timedOut = () => {
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(AbortSignal.timeout(Math.max(1, Math.min(15e3, remaining))));
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((resolve2) => setTimeout(resolve2, Math.max(0, Math.min(1e3, deadline - Date.now()))));
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.0").configureOutput({ writeErr: () => {
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 rental = await (intent ? api.rentGpu(intent, idempotencyKey) : api.rent(opts.offer, budget, idempotencyKey));
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").description("Reveal SSH access; response contains a private key and is audited server-side. Prefer ukiyo exec, which uses the key without printing it. Do not redirect into a file or shared transcript. CLI output redacts private keys.").addHelpText("after", "\nThe API response contains a private key and each reveal is audited server-side. Prefer ukiyo exec, which uses the key without printing it. Do not redirect this command into a file or shared transcript.").action(async (id) => print(await client().credentials(id)));
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 accepted = await api.terminate(id);
1002
- const value = opts.wait ? await waitFor((signal) => api.deployment(id, signal), (value2) => value2.status === "TERMINATED", seconds, { deploymentId: id }) : accepted;
1003
- print({ ...value, deploymentId: id });
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((resolve2) => setTimeout(resolve2, 2e3));
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((resolve2, reject) => runner("ssh", args, { timeout: timeout * 1e3, maxBuffer: 4 * 1024 * 1024, env, encoding: "utf8", windowsHide: true }, (error, stdout, stderr) => {
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
- resolve2({ 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 ?? ""]) });
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 waitFor(read, done, seconds, context) {
848
- const deadline = Date.now() + seconds * 1e3;
849
- let latest;
850
- const timedOut = () => {
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(AbortSignal.timeout(Math.max(1, Math.min(15e3, remaining))));
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((resolve2) => setTimeout(resolve2, Math.max(0, Math.min(1e3, deadline - Date.now()))));
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.0").configureOutput({ writeErr: () => {
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 rental = await (intent ? api.rentGpu(intent, idempotencyKey) : api.rent(opts.offer, budget, idempotencyKey));
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").description("Reveal SSH access; response contains a private key and is audited server-side. Prefer ukiyo exec, which uses the key without printing it. Do not redirect into a file or shared transcript. CLI output redacts private keys.").addHelpText("after", "\nThe API response contains a private key and each reveal is audited server-side. Prefer ukiyo exec, which uses the key without printing it. Do not redirect this command into a file or shared transcript.").action(async (id) => print(await client().credentials(id)));
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 accepted = await api.terminate(id);
1002
- const value = opts.wait ? await waitFor((signal) => api.deployment(id, signal), (value2) => value2.status === "TERMINATED", seconds, { deploymentId: id }) : accepted;
1003
- print({ ...value, deploymentId: id });
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.0",
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",