@miosa/sdk 1.2.2 → 1.2.4

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@miosa/sdk",
3
- "version": "1.2.2",
3
+ "version": "1.2.4",
4
4
  "description": "TypeScript SDK for the MIOSA API — cloud VM desktop infrastructure for AI agents",
5
5
  "license": "MIT",
6
6
  "author": "MIOSA <hello@miosa.ai>",
package/src/client.ts CHANGED
@@ -15,6 +15,7 @@ import { CronJobs } from "./resources/cron-jobs.js";
15
15
  import { Dashboard } from "./resources/dashboard.js";
16
16
  import { Databases } from "./resources/databases.js";
17
17
  import { Deployments } from "./resources/deployments.js";
18
+ import { DockerDeploy } from "./resources/docker-deploy.js";
18
19
  import { EgressAudit } from "./resources/egressAudit.js";
19
20
  import { EgressNetwork } from "./resources/egressNetwork.js";
20
21
  import { EgressSecrets } from "./resources/egressSecrets.js";
@@ -130,6 +131,9 @@ export class Miosa {
130
131
  */
131
132
  readonly deployments: Deployments;
132
133
 
134
+ /** Docker Deploy appliance hosts — one always-on workspace host, many apps. */
135
+ readonly dockerDeploy: DockerDeploy;
136
+
133
137
  /** Credit balance and usage. */
134
138
  readonly credits: Credits;
135
139
 
@@ -257,6 +261,7 @@ export class Miosa {
257
261
  this.computers = new Computers(this.http);
258
262
  this.sandboxes = new Sandboxes(this.http);
259
263
  this.deployments = new Deployments(this.http);
264
+ this.dockerDeploy = new DockerDeploy(this.http);
260
265
  this.credits = new Credits(this.http);
261
266
  this.admin = new Admin(this.http);
262
267
  this.openComputers = new OpenComputers(this.http);
package/src/index.ts CHANGED
@@ -136,6 +136,10 @@ export type {
136
136
  DeploymentVersionKind,
137
137
  DeploymentVersionState,
138
138
  DockerDeployCreateParams,
139
+ DockerDeployDoctorCheck,
140
+ DockerDeployDoctorParams,
141
+ DockerDeployDoctorProbe,
142
+ DockerDeployDoctorResult,
139
143
  ExternalAttribution,
140
144
  PublishFromSandboxParams,
141
145
  PublishParams,
@@ -147,6 +151,17 @@ export type {
147
151
  RuntimeLogsResult,
148
152
  VersionListParams,
149
153
  } from "./resources/deployments.js";
154
+ export { DockerDeploy } from "./resources/docker-deploy.js";
155
+ export type {
156
+ DockerDeployApplianceStatus,
157
+ DockerDeployHostData,
158
+ DockerDeployHostEnsureParams,
159
+ DockerDeployHostId,
160
+ DockerDeployHostListParams,
161
+ DockerDeployHostListResponse,
162
+ DockerDeployHostResponse,
163
+ DockerDeployHostStatus,
164
+ } from "./resources/docker-deploy.js";
150
165
  export type {
151
166
  SnapshotData,
152
167
  SnapshotStatus,
@@ -3,15 +3,18 @@ import type { HttpClient } from "../http.js";
3
3
  import { Deployments } from "./deployments.js";
4
4
 
5
5
  const mockRequest = vi.fn();
6
+ const mockGet = vi.fn();
6
7
 
7
8
  function makeHttp(): HttpClient {
8
9
  const http = {} as HttpClient;
9
10
  http.request = mockRequest;
11
+ http.get = mockGet;
10
12
  return http;
11
13
  }
12
14
 
13
15
  beforeEach(() => {
14
16
  vi.clearAllMocks();
17
+ vi.unstubAllGlobals();
15
18
  });
16
19
 
17
20
  describe("Deployments", () => {
@@ -52,4 +55,140 @@ describe("Deployments", () => {
52
55
  expect(deployment.deployment_product).toBe("docker_deploy");
53
56
  expect(deployment.docker_deploy_host_id).toBe("ddh_123");
54
57
  });
58
+
59
+ it("doctorDockerDeploy verifies host, route metadata, and public URL", async () => {
60
+ mockGet.mockImplementation(async (path: string) => {
61
+ if (path === "/deployments/dep_123") {
62
+ return {
63
+ data: {
64
+ id: "dep_123",
65
+ tenant_id: "ten_123",
66
+ name: "Clinic Intake",
67
+ slug: "clinic-intake",
68
+ state: "running",
69
+ deployment_product: "docker_deploy",
70
+ docker_deploy_host_id: "ddh_123",
71
+ public_url: "https://clinic-intake.osa.miosa.app",
72
+ metadata: {
73
+ deployment_product: "docker_deploy",
74
+ runtime: { ip: "172.16.74.246", port: 23906 },
75
+ docker_deploy: {
76
+ app_id: "miosa-clinic-intake",
77
+ container_id: "container_123",
78
+ status: "running",
79
+ url: "http://127.0.0.1:23906",
80
+ },
81
+ },
82
+ },
83
+ };
84
+ }
85
+
86
+ if (path === "/docker-deploy/hosts/ddh_123") {
87
+ return {
88
+ data: {
89
+ id: "ddh_123",
90
+ tenant_id: "ten_123",
91
+ workspace_id: "ws_123",
92
+ status: "active",
93
+ size: "medium",
94
+ region: "us",
95
+ appliance_status: "healthy",
96
+ },
97
+ };
98
+ }
99
+
100
+ throw new Error(`unexpected path ${path}`);
101
+ });
102
+ vi.stubGlobal(
103
+ "fetch",
104
+ vi.fn().mockResolvedValue({ ok: true, status: 200 }),
105
+ );
106
+
107
+ const result = await new Deployments(makeHttp()).doctorDockerDeploy(
108
+ "dep_123",
109
+ { probePath: "/health" },
110
+ );
111
+
112
+ expect(result.ok).toBe(true);
113
+ expect(mockGet).toHaveBeenCalledWith("/deployments/dep_123");
114
+ expect(mockGet).toHaveBeenCalledWith("/docker-deploy/hosts/ddh_123");
115
+ expect(fetch).toHaveBeenCalledWith(
116
+ "https://clinic-intake.osa.miosa.app/health",
117
+ expect.objectContaining({ method: "GET" }),
118
+ );
119
+ expect(result.checks.map((check) => check.name)).toEqual([
120
+ "deployment_product",
121
+ "docker_deploy_host_id",
122
+ "docker_deploy_host_health",
123
+ "docker_deploy_app",
124
+ "runtime_route",
125
+ "public_url_probe",
126
+ ]);
127
+ });
128
+
129
+ it("doctorDockerDeploy fails when route metadata does not point at the Docker container port", async () => {
130
+ mockGet.mockImplementation(async (path: string) => {
131
+ if (path === "/deployments/dep_123") {
132
+ return {
133
+ data: {
134
+ id: "dep_123",
135
+ tenant_id: "ten_123",
136
+ name: "Clinic Intake",
137
+ slug: "clinic-intake",
138
+ state: "running",
139
+ deployment_product: "docker_deploy",
140
+ docker_deploy_host_id: "ddh_123",
141
+ public_url: "https://clinic-intake.osa.miosa.app",
142
+ metadata: {
143
+ deployment_product: "docker_deploy",
144
+ runtime: { ip: "172.16.74.246", port: 8080 },
145
+ docker_deploy: {
146
+ app_id: "miosa-clinic-intake",
147
+ container_id: "container_123",
148
+ status: "running",
149
+ url: "http://127.0.0.1:23906",
150
+ },
151
+ },
152
+ },
153
+ };
154
+ }
155
+
156
+ if (path === "/docker-deploy/hosts/ddh_123") {
157
+ return {
158
+ data: {
159
+ id: "ddh_123",
160
+ tenant_id: "ten_123",
161
+ workspace_id: "ws_123",
162
+ status: "active",
163
+ size: "medium",
164
+ region: "us",
165
+ appliance_status: "healthy",
166
+ },
167
+ };
168
+ }
169
+
170
+ throw new Error(`unexpected path ${path}`);
171
+ });
172
+ vi.stubGlobal(
173
+ "fetch",
174
+ vi.fn().mockResolvedValue({ ok: true, status: 200 }),
175
+ );
176
+
177
+ const result = await new Deployments(makeHttp()).doctorDockerDeploy(
178
+ "dep_123",
179
+ { probePath: "/health" },
180
+ );
181
+
182
+ expect(result.ok).toBe(false);
183
+ expect(result.checks).toEqual(
184
+ expect.arrayContaining([
185
+ expect.objectContaining({
186
+ name: "runtime_route",
187
+ ok: false,
188
+ message:
189
+ "Deployment route port 8080 does not match Docker container host port 23906.",
190
+ }),
191
+ ]),
192
+ );
193
+ });
55
194
  });
@@ -13,6 +13,7 @@
13
13
  import { randomUUID } from "node:crypto";
14
14
 
15
15
  import type { HttpClient } from "../http.js";
16
+ import type { DockerDeployHostData } from "./docker-deploy.js";
16
17
 
17
18
  // ── Branded IDs ─────────────────────────────────────────────────────────────
18
19
 
@@ -120,6 +121,35 @@ export interface DeploymentData {
120
121
  updated_at?: string;
121
122
  }
122
123
 
124
+ export interface DockerDeployDoctorCheck {
125
+ name: string;
126
+ ok: boolean;
127
+ message: string;
128
+ details?: Record<string, unknown>;
129
+ }
130
+
131
+ export interface DockerDeployDoctorProbe {
132
+ url: string;
133
+ ok: boolean;
134
+ status?: number;
135
+ error?: string;
136
+ }
137
+
138
+ export interface DockerDeployDoctorResult {
139
+ ok: boolean;
140
+ deployment: DeploymentData;
141
+ host?: DockerDeployHostData;
142
+ checks: DockerDeployDoctorCheck[];
143
+ probe?: DockerDeployDoctorProbe;
144
+ }
145
+
146
+ export interface DockerDeployDoctorParams {
147
+ probePath?: string;
148
+ probe_path?: string;
149
+ timeoutMs?: number;
150
+ timeout_ms?: number;
151
+ }
152
+
123
153
  export type DeploymentDatabaseRequest =
124
154
  | boolean
125
155
  | {
@@ -436,6 +466,62 @@ function dockerDeployMetadata(
436
466
  };
437
467
  }
438
468
 
469
+ function dockerDeployProduct(deployment: DeploymentData): unknown {
470
+ return (
471
+ deployment.deployment_product ??
472
+ deployment.metadata?.["deployment_product"]
473
+ );
474
+ }
475
+
476
+ function dockerDeployHostId(deployment: DeploymentData): string | null {
477
+ const metadataHost = deployment.metadata?.["docker_deploy_host_id"];
478
+ return (
479
+ deployment.docker_deploy_host_id ??
480
+ (typeof metadataHost === "string" ? metadataHost : null)
481
+ );
482
+ }
483
+
484
+ function dockerDeployApp(deployment: DeploymentData): Record<string, unknown> | null {
485
+ const app = deployment.metadata?.["docker_deploy"];
486
+ if (!app || typeof app !== "object" || Array.isArray(app)) return null;
487
+ return app as Record<string, unknown>;
488
+ }
489
+
490
+ function dockerDeployAppPort(app: Record<string, unknown> | null): number | null {
491
+ const url = app?.["url"];
492
+ if (typeof url !== "string") return null;
493
+
494
+ try {
495
+ const parsed = new URL(url);
496
+ const port = Number.parseInt(parsed.port, 10);
497
+ return Number.isInteger(port) ? port : null;
498
+ } catch {
499
+ return null;
500
+ }
501
+ }
502
+
503
+ function addDoctorCheck(
504
+ checks: DockerDeployDoctorCheck[],
505
+ name: string,
506
+ ok: boolean,
507
+ message: string,
508
+ details?: Record<string, unknown>,
509
+ ): void {
510
+ checks.push({ name, ok, message, ...(details ? { details } : {}) });
511
+ }
512
+
513
+ function hostHealthy(host: DockerDeployHostData | undefined): boolean {
514
+ return Boolean(
515
+ host && host.status === "active" && host.appliance_status === "healthy",
516
+ );
517
+ }
518
+
519
+ function probeUrl(publicUrl: string, probePath: string): string {
520
+ const url = new URL(publicUrl);
521
+ url.pathname = probePath.startsWith("/") ? probePath : `/${probePath}`;
522
+ return url.toString();
523
+ }
524
+
439
525
  // ── Sub-resources ──────────────────────────────────────────────────────────
440
526
 
441
527
  export class DeploymentVersions {
@@ -656,6 +742,174 @@ export class Deployments {
656
742
  });
657
743
  }
658
744
 
745
+ /**
746
+ * Verify a Docker Deploy deployment before telling a user or agent it is
747
+ * live. Checks product markers, appliance host health, route metadata, and
748
+ * optionally probes the public URL.
749
+ */
750
+ async doctorDockerDeploy(
751
+ deploymentId: string,
752
+ params: DockerDeployDoctorParams = {},
753
+ ): Promise<DockerDeployDoctorResult> {
754
+ const checks: DockerDeployDoctorCheck[] = [];
755
+ const deployment = await this.get(deploymentId);
756
+ const metadata = deployment.metadata ?? {};
757
+ const product = dockerDeployProduct(deployment);
758
+ const hostId = dockerDeployHostId(deployment);
759
+
760
+ addDoctorCheck(
761
+ checks,
762
+ "deployment_product",
763
+ product === "docker_deploy",
764
+ product === "docker_deploy"
765
+ ? "Deployment is marked for Docker Deploy."
766
+ : `Expected deployment_product=docker_deploy, got ${String(product ?? "missing")}.`,
767
+ { deployment_product: product ?? null },
768
+ );
769
+
770
+ addDoctorCheck(
771
+ checks,
772
+ "docker_deploy_host_id",
773
+ Boolean(hostId),
774
+ hostId
775
+ ? "Deployment has a Docker Deploy host id."
776
+ : "Deployment has no docker_deploy_host_id.",
777
+ { docker_deploy_host_id: hostId },
778
+ );
779
+
780
+ let host: DockerDeployHostData | undefined;
781
+ if (hostId) {
782
+ try {
783
+ const rawHost = await this.http.get<unknown>(
784
+ `/docker-deploy/hosts/${hostId}`,
785
+ );
786
+ host = unwrap(rawHost) as DockerDeployHostData;
787
+ addDoctorCheck(
788
+ checks,
789
+ "docker_deploy_host_health",
790
+ hostHealthy(host),
791
+ hostHealthy(host)
792
+ ? "Docker Deploy host is active and healthy."
793
+ : `Docker Deploy host status=${host.status} appliance=${host.appliance_status}.`,
794
+ {
795
+ status: host.status,
796
+ appliance_status: host.appliance_status,
797
+ },
798
+ );
799
+ } catch (error) {
800
+ addDoctorCheck(
801
+ checks,
802
+ "docker_deploy_host_health",
803
+ false,
804
+ error instanceof Error ? error.message : String(error),
805
+ );
806
+ }
807
+ }
808
+
809
+ const app = dockerDeployApp(deployment);
810
+ const appPort = dockerDeployAppPort(app);
811
+ const appRunning = app?.["status"] === "running" && appPort !== null;
812
+ addDoctorCheck(
813
+ checks,
814
+ "docker_deploy_app",
815
+ appRunning,
816
+ appRunning
817
+ ? "Docker Deploy app metadata points at a running container."
818
+ : "Deployment is missing running Docker Deploy app metadata.",
819
+ app
820
+ ? {
821
+ app_id: app["app_id"],
822
+ container_id: app["container_id"],
823
+ status: app["status"],
824
+ url: app["url"],
825
+ expected_port: appPort,
826
+ }
827
+ : undefined,
828
+ );
829
+
830
+ const runtime = metadata["runtime"];
831
+ const hasRuntimeRoute =
832
+ typeof runtime === "object" &&
833
+ runtime !== null &&
834
+ typeof (runtime as Record<string, unknown>)["ip"] === "string" &&
835
+ typeof (runtime as Record<string, unknown>)["port"] === "number";
836
+ const runtimeRecord =
837
+ hasRuntimeRoute && typeof runtime === "object"
838
+ ? (runtime as Record<string, unknown>)
839
+ : undefined;
840
+ const routeMatchesContainerPort =
841
+ hasRuntimeRoute &&
842
+ (appPort === null || runtimeRecord?.["port"] === appPort);
843
+ addDoctorCheck(
844
+ checks,
845
+ "runtime_route",
846
+ hasRuntimeRoute && routeMatchesContainerPort,
847
+ hasRuntimeRoute && routeMatchesContainerPort
848
+ ? "Deployment route points at the Docker container host port."
849
+ : hasRuntimeRoute && appPort !== null
850
+ ? `Deployment route port ${String(runtimeRecord?.["port"])} does not match Docker container host port ${appPort}.`
851
+ : "Deployment is missing appliance runtime route metadata.",
852
+ runtimeRecord
853
+ ? {
854
+ ...runtimeRecord,
855
+ expected_port: appPort,
856
+ docker_deploy_url: app?.["url"],
857
+ }
858
+ : undefined,
859
+ );
860
+
861
+ let probe: DockerDeployDoctorProbe | undefined;
862
+ const publicUrl = deployment.public_url;
863
+ const path = params.probePath ?? params.probe_path ?? "/";
864
+ if (publicUrl && typeof fetch === "function") {
865
+ const url = probeUrl(publicUrl, path);
866
+ const controller = new AbortController();
867
+ const timeout = setTimeout(
868
+ () => controller.abort(),
869
+ params.timeoutMs ?? params.timeout_ms ?? 10_000,
870
+ );
871
+ try {
872
+ const response = await fetch(url, {
873
+ method: "GET",
874
+ signal: controller.signal,
875
+ });
876
+ probe = { url, ok: response.ok, status: response.status };
877
+ addDoctorCheck(
878
+ checks,
879
+ "public_url_probe",
880
+ response.ok,
881
+ response.ok
882
+ ? `Public URL returned HTTP ${response.status}.`
883
+ : `Public URL returned HTTP ${response.status}.`,
884
+ { url, status: response.status },
885
+ );
886
+ } catch (error) {
887
+ probe = {
888
+ url,
889
+ ok: false,
890
+ error: error instanceof Error ? error.message : String(error),
891
+ };
892
+ addDoctorCheck(
893
+ checks,
894
+ "public_url_probe",
895
+ false,
896
+ probe.error ?? "Public URL probe failed.",
897
+ { url },
898
+ );
899
+ } finally {
900
+ clearTimeout(timeout);
901
+ }
902
+ }
903
+
904
+ return {
905
+ ok: checks.every((check) => check.ok),
906
+ deployment,
907
+ ...(host ? { host } : {}),
908
+ checks,
909
+ ...(probe ? { probe } : {}),
910
+ };
911
+ }
912
+
659
913
  async update(
660
914
  deploymentId: string,
661
915
  params: DeploymentUpdateParams,
@@ -0,0 +1,102 @@
1
+ import { beforeEach, describe, expect, it, vi } from "vitest";
2
+ import type { HttpClient } from "../http.js";
3
+ import { DockerDeploy } from "./docker-deploy.js";
4
+
5
+ const mockGet = vi.fn();
6
+ const mockPost = vi.fn();
7
+
8
+ function makeHttp(): HttpClient {
9
+ const http = {} as HttpClient;
10
+ http.get = mockGet;
11
+ http.post = mockPost;
12
+ return http;
13
+ }
14
+
15
+ beforeEach(() => {
16
+ vi.clearAllMocks();
17
+ });
18
+
19
+ describe("DockerDeploy", () => {
20
+ it("lists Docker Deploy hosts by workspace", async () => {
21
+ mockGet.mockResolvedValue({
22
+ data: [
23
+ {
24
+ id: "ddh_123",
25
+ tenant_id: "ten_123",
26
+ workspace_id: "ws_123",
27
+ status: "bootstrapping",
28
+ appliance_status: "starting",
29
+ size: "medium",
30
+ region: "us",
31
+ },
32
+ ],
33
+ });
34
+
35
+ const hosts = await new DockerDeploy(makeHttp()).listHosts({
36
+ workspaceId: "ws_123",
37
+ });
38
+
39
+ expect(mockGet).toHaveBeenCalledWith("/docker-deploy/hosts", {
40
+ workspace_id: "ws_123",
41
+ });
42
+ expect(hosts[0]?.id).toBe("ddh_123");
43
+ });
44
+
45
+ it("ensures a workspace Docker Deploy host", async () => {
46
+ mockPost.mockResolvedValue({
47
+ host: {
48
+ id: "ddh_123",
49
+ tenant_id: "ten_123",
50
+ workspace_id: "ws_123",
51
+ status: "pending",
52
+ appliance_status: "not_installed",
53
+ size: "medium",
54
+ region: "us",
55
+ },
56
+ queued: true,
57
+ });
58
+
59
+ const result = await new DockerDeploy(makeHttp()).ensureHost({
60
+ workspaceId: "ws_123",
61
+ });
62
+
63
+ expect(mockPost).toHaveBeenCalledWith("/docker-deploy/hosts/ensure", {
64
+ workspace_id: "ws_123",
65
+ });
66
+ expect(result.queued).toBe(true);
67
+ expect(result.host.status).toBe("pending");
68
+ });
69
+
70
+ it("lists Docker Deploy templates", async () => {
71
+ mockGet.mockResolvedValue({
72
+ data: [
73
+ {
74
+ id: "nextjs-app",
75
+ name: "Next.js app",
76
+ description: "App router starter",
77
+ },
78
+ ],
79
+ });
80
+
81
+ const templates = await new DockerDeploy(makeHttp()).listTemplates();
82
+
83
+ expect(mockGet).toHaveBeenCalledWith("/docker-deploy/templates");
84
+ expect(templates[0]?.id).toBe("nextjs-app");
85
+ });
86
+
87
+ it("gets a Docker Deploy template", async () => {
88
+ mockGet.mockResolvedValue({
89
+ template: {
90
+ id: "compose-full-stack",
91
+ name: "Compose full stack",
92
+ },
93
+ });
94
+
95
+ const template = await new DockerDeploy(makeHttp()).getTemplate("compose-full-stack");
96
+
97
+ expect(mockGet).toHaveBeenCalledWith(
98
+ "/docker-deploy/templates/compose-full-stack",
99
+ );
100
+ expect(template.id).toBe("compose-full-stack");
101
+ });
102
+ });