@miosa/sdk 1.2.4 → 1.2.5

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.
@@ -0,0 +1,92 @@
1
+ import { beforeEach, describe, expect, it, vi } from "vitest";
2
+ import { HttpClient } from "../http.js";
3
+ import { Devices } from "./devices.js";
4
+
5
+ const mockGet = vi.fn();
6
+
7
+ function makeHttp(): HttpClient {
8
+ const http = {} as HttpClient;
9
+ http.get = mockGet;
10
+ return http;
11
+ }
12
+
13
+ beforeEach(() => {
14
+ vi.clearAllMocks();
15
+ });
16
+
17
+ describe("Devices", () => {
18
+ it("returns the static agent device catalog", () => {
19
+ const catalog = new Devices(makeHttp()).catalog();
20
+
21
+ expect(catalog.map((device) => device.kind)).toEqual(
22
+ expect.arrayContaining([
23
+ "sandbox_worker",
24
+ "computer",
25
+ "local_device",
26
+ "docker_deploy_host",
27
+ ]),
28
+ );
29
+ expect(catalog.find((device) => device.kind === "sandbox_worker")
30
+ ?.primaryCommands).toEqual(
31
+ expect.arrayContaining([
32
+ expect.stringContaining("sandboxes.create"),
33
+ expect.stringContaining("sandbox.exec"),
34
+ ]),
35
+ );
36
+ });
37
+
38
+ it("normalizes sandboxes and computers into one device list", async () => {
39
+ mockGet
40
+ .mockResolvedValueOnce({
41
+ data: [
42
+ {
43
+ id: "sbx_123",
44
+ name: "builder",
45
+ state: "running",
46
+ ready: true,
47
+ persistent: true,
48
+ preview_url: "https://3000-sbx_123.sandbox.miosa.app",
49
+ },
50
+ ],
51
+ })
52
+ .mockResolvedValueOnce({
53
+ data: [
54
+ {
55
+ id: "cmp_123",
56
+ name: "desktop",
57
+ status: "running",
58
+ region: "us-nyc",
59
+ },
60
+ ],
61
+ });
62
+
63
+ const result = await new Devices(makeHttp()).list();
64
+
65
+ expect(mockGet).toHaveBeenNthCalledWith(1, "/sandboxes");
66
+ expect(mockGet).toHaveBeenNthCalledWith(2, "/computers");
67
+ expect(result.errors).toEqual([]);
68
+ expect(result.devices).toEqual(
69
+ expect.arrayContaining([
70
+ expect.objectContaining({ id: "sbx_123", kind: "sandbox_worker" }),
71
+ expect.objectContaining({ id: "cmp_123", kind: "computer" }),
72
+ ]),
73
+ );
74
+ });
75
+
76
+ it("returns partial device inventory when one backing endpoint fails", async () => {
77
+ mockGet
78
+ .mockResolvedValueOnce({
79
+ data: [{ id: "sbx_123", state: "running" }],
80
+ })
81
+ .mockRejectedValueOnce(new Error("HTTP 502"));
82
+
83
+ const result = await new Devices(makeHttp()).list();
84
+
85
+ expect(result.devices).toEqual([
86
+ expect.objectContaining({ id: "sbx_123", kind: "sandbox_worker" }),
87
+ ]);
88
+ expect(result.errors).toEqual([
89
+ expect.objectContaining({ source: "computers", retryable: true }),
90
+ ]);
91
+ });
92
+ });
@@ -0,0 +1,291 @@
1
+ import { HttpClient } from "../http.js";
2
+
3
+ export type DeviceKind =
4
+ | "sandbox_worker"
5
+ | "computer"
6
+ | "local_device"
7
+ | "docker_deploy_host";
8
+
9
+ export type DeviceSource = "sandboxes" | "computers";
10
+
11
+ export interface DeviceCatalogEntry {
12
+ kind: DeviceKind;
13
+ label: string;
14
+ purpose: string;
15
+ lifecycle: string;
16
+ persistence: string;
17
+ primaryCommands: string[];
18
+ useWhen: string[];
19
+ avoidWhen: string[];
20
+ }
21
+
22
+ export interface DeviceRecord {
23
+ id: string;
24
+ kind: DeviceKind;
25
+ source: DeviceSource;
26
+ name?: string;
27
+ state?: string;
28
+ ready?: boolean;
29
+ persistent?: boolean;
30
+ alwaysOn?: boolean;
31
+ region?: string;
32
+ template?: string;
33
+ previewUrl?: string;
34
+ timeoutRemainingMs?: number;
35
+ }
36
+
37
+ export interface DeviceListError {
38
+ source: DeviceSource;
39
+ message: string;
40
+ retryable: boolean;
41
+ }
42
+
43
+ export interface DeviceListParams {
44
+ kind?: "all" | "sandbox_worker" | "sandbox" | "computer";
45
+ }
46
+
47
+ export interface DeviceListResponse {
48
+ devices: DeviceRecord[];
49
+ errors: DeviceListError[];
50
+ }
51
+
52
+ const DEVICE_CATALOG: DeviceCatalogEntry[] = [
53
+ {
54
+ kind: "sandbox_worker",
55
+ label: "Sandbox Worker",
56
+ purpose:
57
+ "Isolated Linux workspace for agents to create files, run code, preview apps, snapshot, fork, and publish.",
58
+ lifecycle:
59
+ "Persistent by default; use stop/resume/snapshot/fork where the account backend supports saved state.",
60
+ persistence:
61
+ "Use one-hour timeouts for interactive builds and checkpoint before long pauses.",
62
+ primaryCommands: [
63
+ "miosa.sandboxes.create({ templateId: 'nextjs', timeoutSec: 3600 })",
64
+ "sandbox.exec.run('codex ...', { cwd: '/workspace' })",
65
+ "sandbox.files.write('/workspace/app/page.jsx', source)",
66
+ "miosa.deployments.publishFromSandbox(...)",
67
+ ],
68
+ useWhen: [
69
+ "Coding agents should build inside the remote filesystem.",
70
+ "You need command execution, file writes, package installs, previews, artifacts, or app publish.",
71
+ "You want virtual-device behavior without a GUI desktop.",
72
+ ],
73
+ avoidWhen: [
74
+ "The workflow requires full browser/desktop control.",
75
+ "The app is ready for production; publish it to a deployment runtime.",
76
+ ],
77
+ },
78
+ {
79
+ kind: "computer",
80
+ label: "Computer",
81
+ purpose:
82
+ "Durable VM/desktop device for browser automation, CUA sessions, SSH, tunnels, and persistent agent control.",
83
+ lifecycle:
84
+ "Managed as a Computer with desktop/browser and operator-style control surfaces.",
85
+ persistence:
86
+ "Use checkpoints, volumes, tunnels, and agent sessions for long-lived desktop workflows.",
87
+ primaryCommands: [
88
+ "miosa.computers.create({ name: 'browser-agent' })",
89
+ "computer.exec.run('npm test')",
90
+ "computer.desktop.open()",
91
+ ],
92
+ useWhen: [
93
+ "The agent needs Chromium or a full desktop.",
94
+ "The workflow logs into dashboards, fills forms, clicks buttons, or captures screenshots.",
95
+ "A human and agent share the same persistent machine state.",
96
+ ],
97
+ avoidWhen: [
98
+ "Simple code generation/build/test work fits a cheaper sandbox worker.",
99
+ "You only need durable app hosting.",
100
+ ],
101
+ },
102
+ {
103
+ kind: "local_device",
104
+ label: "Local Device",
105
+ purpose:
106
+ "Developer-owned machine connected through CLI/MCP for local discovery and private tooling.",
107
+ lifecycle: "Not hosted by MIOSA; the user owns uptime and state.",
108
+ persistence:
109
+ "State is local machine state. Do not assume cloud resume semantics.",
110
+ primaryCommands: ["miosa mcp install", "miosa doctor --json"],
111
+ useWhen: [
112
+ "The agent needs local repository discovery before cloud execution.",
113
+ "The user intentionally wants local private tools.",
114
+ ],
115
+ avoidWhen: [
116
+ "Customer code must stay isolated in MIOSA-hosted infrastructure.",
117
+ "The workflow needs reproducible shared cloud state.",
118
+ ],
119
+ },
120
+ {
121
+ kind: "docker_deploy_host",
122
+ label: "Docker Deploy Host",
123
+ purpose:
124
+ "Workspace appliance VM that runs Docker containers for durable apps published from sandboxes.",
125
+ lifecycle:
126
+ "Always-on deployment capacity; not an interactive coding workspace.",
127
+ persistence:
128
+ "Versioned releases and routing are durable; edits happen in sandboxes before publish.",
129
+ primaryCommands: [
130
+ "miosa sandbox publish <id> --docker-deploy",
131
+ "miosa deploy --docker-deploy",
132
+ ],
133
+ useWhen: [
134
+ "You need many small apps, APIs, funnels, or client sites in one workspace appliance.",
135
+ "You want stable public URLs backed by Docker containers.",
136
+ ],
137
+ avoidWhen: [
138
+ "Interactive agent work is still happening.",
139
+ "The app needs the standard MIOSA Deploy runtime.",
140
+ ],
141
+ },
142
+ ];
143
+
144
+ export class Devices {
145
+ private readonly http: HttpClient;
146
+
147
+ constructor(http: HttpClient) {
148
+ this.http = http;
149
+ }
150
+
151
+ /**
152
+ * Return the static device catalog used by orchestration apps to choose the
153
+ * right MIOSA execution surface before creating resources.
154
+ */
155
+ catalog(): DeviceCatalogEntry[] {
156
+ return DEVICE_CATALOG.map((entry) => ({ ...entry }));
157
+ }
158
+
159
+ /**
160
+ * List hosted devices by normalizing existing sandboxes and computers.
161
+ * Partial backend failures are returned in `errors` so orchestration UIs can
162
+ * still show usable inventory instead of failing the whole page.
163
+ */
164
+ async list(params: DeviceListParams = {}): Promise<DeviceListResponse> {
165
+ const kind = normalizeKind(params.kind ?? "all");
166
+ const devices: DeviceRecord[] = [];
167
+ const errors: DeviceListError[] = [];
168
+
169
+ if (kind === "all" || kind === "sandbox_worker") {
170
+ try {
171
+ const sandboxes = await this.http.get<unknown>("/sandboxes");
172
+ devices.push(...unwrapList(sandboxes, ["sandboxes"]).map(normalizeSandbox));
173
+ } catch (err) {
174
+ errors.push(toListError("sandboxes", err));
175
+ }
176
+ }
177
+
178
+ if (kind === "all" || kind === "computer") {
179
+ try {
180
+ const computers = await this.http.get<unknown>("/computers");
181
+ devices.push(...unwrapList(computers, ["computers"]).map(normalizeComputer));
182
+ } catch (err) {
183
+ errors.push(toListError("computers", err));
184
+ }
185
+ }
186
+
187
+ return { devices, errors };
188
+ }
189
+ }
190
+
191
+ function normalizeKind(
192
+ kind: NonNullable<DeviceListParams["kind"]>,
193
+ ): "all" | "sandbox_worker" | "computer" {
194
+ if (kind === "all") return "all";
195
+ if (kind === "sandbox" || kind === "sandbox_worker") {
196
+ return "sandbox_worker";
197
+ }
198
+ if (kind === "computer") return "computer";
199
+ throw new Error(`Unsupported device kind: ${kind}`);
200
+ }
201
+
202
+ function normalizeSandbox(row: Record<string, unknown>): DeviceRecord {
203
+ return compactRecord({
204
+ id: stringField(row, "id"),
205
+ kind: "sandbox_worker",
206
+ source: "sandboxes",
207
+ name: optionalString(row, "name"),
208
+ state: optionalString(row, "state") ?? optionalString(row, "status"),
209
+ ready: optionalBoolean(row, "ready"),
210
+ persistent: optionalBoolean(row, "persistent"),
211
+ alwaysOn: optionalBoolean(row, "always_on"),
212
+ template:
213
+ optionalString(row, "template_id") ?? optionalString(row, "template"),
214
+ previewUrl: optionalString(row, "preview_url"),
215
+ timeoutRemainingMs: optionalNumber(row, "timeout_remaining_ms"),
216
+ });
217
+ }
218
+
219
+ function normalizeComputer(row: Record<string, unknown>): DeviceRecord {
220
+ return compactRecord({
221
+ id: stringField(row, "id"),
222
+ kind: "computer",
223
+ source: "computers",
224
+ name: optionalString(row, "name"),
225
+ state: optionalString(row, "status") ?? optionalString(row, "state"),
226
+ ready: optionalBoolean(row, "ready"),
227
+ region: optionalString(row, "region"),
228
+ template:
229
+ optionalString(row, "template_type") ?? optionalString(row, "template"),
230
+ });
231
+ }
232
+
233
+ function compactRecord(record: Record<string, unknown>): DeviceRecord {
234
+ return Object.fromEntries(
235
+ Object.entries(record).filter(([, value]) => value !== undefined),
236
+ ) as unknown as DeviceRecord;
237
+ }
238
+
239
+ function unwrapList(payload: unknown, keys: string[]): Record<string, unknown>[] {
240
+ const value = isRecord(payload) && "data" in payload ? payload.data : payload;
241
+ if (Array.isArray(value)) return value.filter(isRecord);
242
+ if (isRecord(value)) {
243
+ for (const key of keys) {
244
+ const nested = value[key];
245
+ if (Array.isArray(nested)) return nested.filter(isRecord);
246
+ }
247
+ }
248
+ return [];
249
+ }
250
+
251
+ function toListError(source: DeviceSource, err: unknown): DeviceListError {
252
+ const message = err instanceof Error ? err.message : String(err);
253
+ return {
254
+ source,
255
+ message,
256
+ retryable: /fetch failed|ECONNRESET|HTTP 502|other side closed|socket hang up|bad gateway/i.test(
257
+ message,
258
+ ),
259
+ };
260
+ }
261
+
262
+ function stringField(row: Record<string, unknown>, key: string): string {
263
+ const value = row[key];
264
+ return typeof value === "string" ? value : String(value ?? "");
265
+ }
266
+
267
+ function optionalString(
268
+ row: Record<string, unknown>,
269
+ key: string,
270
+ ): string | undefined {
271
+ const value = row[key];
272
+ return typeof value === "string" && value.length > 0 ? value : undefined;
273
+ }
274
+
275
+ function optionalBoolean(
276
+ row: Record<string, unknown>,
277
+ key: string,
278
+ ): boolean | undefined {
279
+ return typeof row[key] === "boolean" ? row[key] : undefined;
280
+ }
281
+
282
+ function optionalNumber(
283
+ row: Record<string, unknown>,
284
+ key: string,
285
+ ): number | undefined {
286
+ return typeof row[key] === "number" ? row[key] : undefined;
287
+ }
288
+
289
+ function isRecord(value: unknown): value is Record<string, unknown> {
290
+ return value !== null && typeof value === "object" && !Array.isArray(value);
291
+ }
@@ -437,6 +437,7 @@ describe("Sandbox handle", () => {
437
437
  const deployment = await sandbox.deployDocker({
438
438
  name: "docker-site",
439
439
  port: 3000,
440
+ dockerDeployTemplateId: "nextjs-refero-design-pack",
440
441
  });
441
442
 
442
443
  expect(mockRequest).toHaveBeenCalledWith("/sandboxes/sbx_123/deploy", {
@@ -445,6 +446,7 @@ describe("Sandbox handle", () => {
445
446
  name: "docker-site",
446
447
  port: 3000,
447
448
  deployment_type: "docker_deploy",
449
+ docker_deploy_template_id: "nextjs-refero-design-pack",
448
450
  },
449
451
  });
450
452
  expect(deployment.deployment_product).toBe("docker_deploy");
@@ -329,6 +329,8 @@ export interface SandboxDeployParams {
329
329
  health_check_path?: string;
330
330
  deploymentType?: "miosa_deploy" | "docker_deploy" | "docker-deploy" | string;
331
331
  deployment_type?: "miosa_deploy" | "docker_deploy" | "docker-deploy" | string;
332
+ dockerDeployTemplateId?: string;
333
+ docker_deploy_template_id?: string;
332
334
  type?: "static" | "dynamic" | "server" | string;
333
335
  mode?: "static" | "dynamic" | "server" | string;
334
336
  database?: boolean | Record<string, unknown>;
@@ -1113,6 +1115,8 @@ export class Sandbox {
1113
1115
  port: params.port,
1114
1116
  health_check_path: params.healthCheckPath ?? params.health_check_path,
1115
1117
  deployment_type: params.deploymentType ?? params.deployment_type,
1118
+ docker_deploy_template_id:
1119
+ params.dockerDeployTemplateId ?? params.docker_deploy_template_id,
1116
1120
  type: params.type,
1117
1121
  mode: params.mode,
1118
1122
  database: params.database,
@@ -9,140 +9,58 @@ import type { HttpClient } from "../http.js";
9
9
  export interface TenantPlan {
10
10
  id?: string;
11
11
  name?: string;
12
+ preview_domain?: string | null;
13
+ deployment_domain?: string | null;
14
+ fallback_miosa_domain?: string | null;
12
15
  limits?: Record<string, unknown>;
13
16
  usage?: Record<string, unknown>;
14
17
  [key: string]: unknown;
15
18
  }
16
19
 
20
+ export interface BrandingData {
21
+ logo_url?: string | null;
22
+ primary_color?: string | null;
23
+ wordmark?: string | null;
24
+ favicon_url?: string | null;
25
+ [key: string]: unknown;
26
+ }
27
+
17
28
  export interface PreviewDomainData {
18
29
  preview_domain?: string | null;
19
- default_domain?: string;
30
+ deployment_domain?: string | null;
31
+ fallback_miosa_domain?: string | null;
20
32
  status?: string;
21
- dns_status?: string;
22
- cname_target?: string | null;
23
- dns_instructions?: unknown;
24
33
  [key: string]: unknown;
25
34
  }
26
35
 
27
36
  export interface TenantBrandingUpdateParams {
28
- product_name?: string;
29
- logo_url?: string;
30
- support_url?: string;
31
- support_email?: string;
32
- primary_color?: string;
33
- background_color?: string;
37
+ logo_url?: string | null;
38
+ primary_color?: string | null;
39
+ wordmark?: string | null;
40
+ favicon_url?: string | null;
34
41
  [key: string]: unknown;
35
42
  }
36
43
 
37
- export type BrandingData = TenantBrandingUpdateParams;
38
-
39
44
  // ── Helpers ───────────────────────────────────────────────────────────────────
40
45
 
41
46
  function unwrap<T>(payload: unknown): T {
42
47
  if (payload && typeof payload === "object") {
43
48
  const p = payload as Record<string, unknown>;
44
- for (const k of ["data", "tenant", "branding", "items"]) {
49
+ for (const k of ["data", "tenant", "items"]) {
45
50
  if (k in p) return p[k] as T;
46
51
  }
47
52
  }
48
53
  return payload as T;
49
54
  }
50
55
 
51
- // ── Sub-resources ────────────────────────────────────────────────────────────
52
-
53
- class PreviewDomain {
54
- constructor(private readonly http: HttpClient) {}
55
-
56
- /** Get the tenant's white-label preview domain settings. */
57
- async get(): Promise<PreviewDomainData> {
58
- const data = await this.http.get<unknown>("/tenant/preview-domain");
59
- return unwrap<PreviewDomainData>(data);
60
- }
61
-
62
- /** Set the tenant's white-label preview domain. */
63
- async set(domain: string): Promise<PreviewDomainData> {
64
- const data = await this.http.put<unknown>("/tenant/preview-domain", {
65
- preview_domain: domain,
66
- });
67
- return unwrap<PreviewDomainData>(data);
68
- }
69
-
70
- /** Re-run DNS verification for the configured preview domain. */
71
- async verify(): Promise<PreviewDomainData> {
72
- const data = await this.http.post<unknown>(
73
- "/tenant/preview-domain/verify",
74
- {},
75
- );
76
- return unwrap<PreviewDomainData>(data);
77
- }
78
-
79
- /** Remove the tenant's custom preview domain. */
80
- async delete(): Promise<void> {
81
- await this.http.delete<unknown>("/tenant/preview-domain");
82
- }
83
- }
84
-
85
- class Branding {
86
- constructor(private readonly http: HttpClient) {}
87
-
88
- /** Get tenant branding used by white-label hosted surfaces. */
89
- async get(): Promise<BrandingData> {
90
- const data = await this.http.get<unknown>("/tenant/branding");
91
- return unwrap<BrandingData>(data);
92
- }
93
-
94
- /** Update tenant branding used by white-label hosted surfaces. */
95
- async set(params: TenantBrandingUpdateParams): Promise<BrandingData> {
96
- const body = Object.fromEntries(
97
- Object.entries(params).filter(([, v]) => v !== undefined),
98
- );
99
- const data = await this.http.put<unknown>("/tenant/branding", {
100
- branding: body,
101
- });
102
- return unwrap<BrandingData>(data);
103
- }
104
-
105
- /** Reset tenant branding to platform defaults. */
106
- async delete(): Promise<void> {
107
- await this.http.delete<unknown>("/tenant/branding");
108
- }
109
- }
110
-
111
56
  // ── Main resource ─────────────────────────────────────────────────────────────
112
57
 
113
58
  export class Tenant {
114
- private readonly http: HttpClient;
115
- readonly preview_domain: PreviewDomain;
116
- readonly branding: Branding;
117
-
118
- /** camelCase alias for SDK consumers that avoid snake_case properties. */
119
- readonly previewDomain: PreviewDomain;
120
-
121
- constructor(http: HttpClient) {
122
- this.http = http;
123
- this.preview_domain = new PreviewDomain(http);
124
- this.previewDomain = this.preview_domain;
125
- this.branding = new Branding(http);
126
- }
59
+ constructor(private readonly http: HttpClient) {}
127
60
 
128
61
  /** Get the current tenant's plan, limits, and live usage counters. */
129
62
  async current(): Promise<TenantPlan> {
130
63
  const data = await this.http.get<unknown>("/tenant/plan");
131
64
  return unwrap<TenantPlan>(data);
132
65
  }
133
-
134
- /** Convenience alias for `tenant.branding.get()`. */
135
- async getBranding(): Promise<BrandingData> {
136
- return this.branding.get();
137
- }
138
-
139
- /** Convenience alias for `tenant.branding.set(...)`. */
140
- async setBranding(params: TenantBrandingUpdateParams): Promise<BrandingData> {
141
- return this.branding.set(params);
142
- }
143
-
144
- /** Convenience alias for `tenant.branding.delete()`. */
145
- async deleteBranding(): Promise<void> {
146
- await this.branding.delete();
147
- }
148
66
  }