@devicecloud.dev/dcd 5.0.0-beta.0 → 5.0.0-beta.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.
Files changed (101) hide show
  1. package/README.md +35 -0
  2. package/dist/commands/artifacts.d.ts +28 -28
  3. package/dist/commands/artifacts.js +20 -23
  4. package/dist/commands/cloud.d.ts +57 -57
  5. package/dist/commands/cloud.js +173 -186
  6. package/dist/commands/list.d.ts +22 -22
  7. package/dist/commands/list.js +36 -38
  8. package/dist/commands/live.js +134 -127
  9. package/dist/commands/login.d.ts +11 -11
  10. package/dist/commands/login.js +46 -44
  11. package/dist/commands/logout.js +16 -18
  12. package/dist/commands/status.d.ts +11 -11
  13. package/dist/commands/status.js +45 -43
  14. package/dist/commands/switch-org.d.ts +7 -7
  15. package/dist/commands/switch-org.js +19 -21
  16. package/dist/commands/upgrade.js +29 -31
  17. package/dist/commands/upload.d.ts +10 -10
  18. package/dist/commands/upload.js +42 -43
  19. package/dist/commands/whoami.js +17 -20
  20. package/dist/config/environments.js +6 -12
  21. package/dist/config/flags/api.flags.js +1 -4
  22. package/dist/config/flags/binary.flags.js +1 -4
  23. package/dist/config/flags/device.flags.js +6 -9
  24. package/dist/config/flags/environment.flags.js +1 -4
  25. package/dist/config/flags/execution.flags.js +1 -4
  26. package/dist/config/flags/github.flags.js +1 -4
  27. package/dist/config/flags/output.flags.js +1 -4
  28. package/dist/constants.js +15 -18
  29. package/dist/gateways/api-gateway.d.ts +31 -6
  30. package/dist/gateways/api-gateway.js +70 -16
  31. package/dist/gateways/cli-auth-gateway.d.ts +1 -1
  32. package/dist/gateways/cli-auth-gateway.js +3 -6
  33. package/dist/gateways/realtime-gateway.d.ts +32 -0
  34. package/dist/gateways/realtime-gateway.js +103 -0
  35. package/dist/gateways/supabase-gateway.d.ts +1 -1
  36. package/dist/gateways/supabase-gateway.js +10 -14
  37. package/dist/index.js +41 -38
  38. package/dist/mcp/context.d.ts +33 -0
  39. package/dist/mcp/context.js +33 -0
  40. package/dist/mcp/helpers.d.ts +16 -0
  41. package/dist/mcp/helpers.js +34 -0
  42. package/dist/mcp/index.d.ts +2 -0
  43. package/dist/mcp/index.js +24 -0
  44. package/dist/mcp/server.d.ts +7 -0
  45. package/dist/mcp/server.js +27 -0
  46. package/dist/mcp/tools/download-artifacts.d.ts +11 -0
  47. package/dist/mcp/tools/download-artifacts.js +84 -0
  48. package/dist/mcp/tools/get-status.d.ts +7 -0
  49. package/dist/mcp/tools/get-status.js +39 -0
  50. package/dist/mcp/tools/list-devices.d.ts +7 -0
  51. package/dist/mcp/tools/list-devices.js +27 -0
  52. package/dist/mcp/tools/list-runs.d.ts +3 -0
  53. package/dist/mcp/tools/list-runs.js +60 -0
  54. package/dist/mcp/tools/run-cloud-test.d.ts +14 -0
  55. package/dist/mcp/tools/run-cloud-test.js +233 -0
  56. package/dist/methods.d.ts +32 -1
  57. package/dist/methods.js +125 -66
  58. package/dist/services/device-validation.service.d.ts +1 -1
  59. package/dist/services/device-validation.service.js +1 -5
  60. package/dist/services/execution-plan.service.js +14 -17
  61. package/dist/services/execution-plan.utils.js +15 -23
  62. package/dist/services/flow-paths.d.ts +17 -0
  63. package/dist/services/flow-paths.js +52 -0
  64. package/dist/services/metadata-extractor.service.js +22 -25
  65. package/dist/services/moropo.service.js +18 -20
  66. package/dist/services/report-download.service.d.ts +1 -1
  67. package/dist/services/report-download.service.js +5 -9
  68. package/dist/services/results-polling.service.d.ts +18 -3
  69. package/dist/services/results-polling.service.js +195 -108
  70. package/dist/services/telemetry.service.d.ts +10 -1
  71. package/dist/services/telemetry.service.js +40 -18
  72. package/dist/services/test-submission.service.d.ts +21 -4
  73. package/dist/services/test-submission.service.js +51 -34
  74. package/dist/services/version.service.d.ts +1 -1
  75. package/dist/services/version.service.js +1 -5
  76. package/dist/types/domain/auth.types.d.ts +8 -0
  77. package/dist/types/domain/auth.types.js +1 -2
  78. package/dist/types/domain/device.types.js +8 -11
  79. package/dist/types/domain/live.types.js +1 -2
  80. package/dist/types/generated/schema.types.js +1 -2
  81. package/dist/types/index.d.ts +2 -2
  82. package/dist/types/index.js +2 -18
  83. package/dist/types.js +1 -2
  84. package/dist/utils/auth.d.ts +1 -1
  85. package/dist/utils/auth.js +27 -28
  86. package/dist/utils/ci.d.ts +12 -0
  87. package/dist/utils/ci.js +39 -0
  88. package/dist/utils/cli.js +18 -27
  89. package/dist/utils/compatibility.d.ts +1 -1
  90. package/dist/utils/compatibility.js +5 -7
  91. package/dist/utils/config-store.js +33 -43
  92. package/dist/utils/connectivity.js +1 -4
  93. package/dist/utils/expo.js +15 -21
  94. package/dist/utils/orgs.js +8 -12
  95. package/dist/utils/paths.js +2 -5
  96. package/dist/utils/progress.js +2 -5
  97. package/dist/utils/styling.d.ts +35 -37
  98. package/dist/utils/styling.js +52 -86
  99. package/dist/utils/ui.d.ts +41 -0
  100. package/dist/utils/ui.js +95 -0
  101. package/package.json +27 -24
@@ -1,16 +1,13 @@
1
- "use strict";
2
- Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.ApiGateway = exports.ApiError = void 0;
4
- const node_fs_1 = require("node:fs");
5
- const node_os_1 = require("node:os");
6
- const path = require("node:path");
7
- const node_stream_1 = require("node:stream");
8
- const promises_1 = require("node:stream/promises");
1
+ import { createWriteStream, mkdirSync } from 'node:fs';
2
+ import { homedir } from 'node:os';
3
+ import * as path from 'node:path';
4
+ import { Readable } from 'node:stream';
5
+ import { pipeline } from 'node:stream/promises';
9
6
  /**
10
7
  * Error thrown for non-OK API responses, carrying the HTTP status so callers
11
8
  * can branch on auth failures etc. without string matching.
12
9
  */
13
- class ApiError extends Error {
10
+ export class ApiError extends Error {
14
11
  status;
15
12
  constructor(message, status) {
16
13
  super(message);
@@ -18,7 +15,6 @@ class ApiError extends Error {
18
15
  this.status = status;
19
16
  }
20
17
  }
21
- exports.ApiError = ApiError;
22
18
  /**
23
19
  * Parses a successful response body as JSON, wrapping parse failures in a
24
20
  * descriptive error (a 200 with an HTML body otherwise surfaces as a bare
@@ -29,10 +25,10 @@ async function parseJsonResponse(res, operation) {
29
25
  return (await res.json());
30
26
  }
31
27
  catch (error) {
32
- throw new Error(`${operation}: API returned an invalid JSON response (${error instanceof Error ? error.message : String(error)})`);
28
+ throw new Error(`${operation}: API returned an invalid JSON response (${error instanceof Error ? error.message : String(error)})`, { cause: error });
33
29
  }
34
30
  }
35
- exports.ApiGateway = {
31
+ export const ApiGateway = {
36
32
  /**
37
33
  * Enhances generic "fetch failed" errors with more specific diagnostic information
38
34
  * @param error - The original TypeError from fetch
@@ -131,17 +127,17 @@ exports.ApiGateway = {
131
127
  throw new Error(`${operation}: server response contained no body to download`);
132
128
  }
133
129
  // Handle tilde expansion for home directory
134
- const expandedPath = destinationPath.replace(/^~(?=$|\/|\\)/, (0, node_os_1.homedir)());
130
+ const expandedPath = destinationPath.replace(/^~(?=$|\/|\\)/, homedir());
135
131
  // Create directory structure if it doesn't exist
136
132
  const directory = path.dirname(expandedPath);
137
133
  if (directory !== '.') {
138
- (0, node_fs_1.mkdirSync)(directory, { recursive: true });
134
+ mkdirSync(directory, { recursive: true });
139
135
  }
140
136
  // Use 'w' flag to overwrite existing files instead of failing.
141
137
  // pipeline (unlike .pipe) propagates source-stream errors, so a
142
138
  // mid-download network failure rejects instead of hanging forever.
143
- const fileStream = (0, node_fs_1.createWriteStream)(expandedPath, { flags: 'w' });
144
- await (0, promises_1.pipeline)(node_stream_1.Readable.fromWeb(res.body), fileStream);
139
+ const fileStream = createWriteStream(expandedPath, { flags: 'w' });
140
+ await pipeline(Readable.fromWeb(res.body), fileStream);
145
141
  },
146
142
  async checkForExistingUpload(baseUrl, auth, sha) {
147
143
  try {
@@ -364,6 +360,64 @@ exports.ApiGateway = {
364
360
  throw error;
365
361
  }
366
362
  },
363
+ /**
364
+ * Requests a storage URL for a client-direct flow zip upload. Mirrors
365
+ * `getBinaryUploadUrl` (same response shape) but stages the zip under
366
+ * `<orgId>/tests/` instead of the app-binary path. A 404 here means the API
367
+ * predates the client-direct flow path — callers fall back to `uploadFlow`.
368
+ */
369
+ async getFlowUploadUrl(baseUrl, auth, fileSize) {
370
+ try {
371
+ const res = await fetch(`${baseUrl}/uploads/getFlowUploadUrl`, {
372
+ body: JSON.stringify({ fileSize, useTus: true }),
373
+ headers: {
374
+ 'content-type': 'application/json',
375
+ ...auth.headers,
376
+ },
377
+ method: 'POST',
378
+ });
379
+ if (!res.ok) {
380
+ await this.handleApiError(res, 'Failed to get flow upload URL');
381
+ }
382
+ // Same response shape as getBinaryUploadUrl: { id, tempPath, finalPath, path, b2?, token? }.
383
+ return await parseJsonResponse(res, 'Failed to get flow upload URL');
384
+ }
385
+ catch (error) {
386
+ if (error instanceof TypeError && error.message === 'fetch failed') {
387
+ throw this.enhanceFetchError(error, `${baseUrl}/uploads/getFlowUploadUrl`);
388
+ }
389
+ throw error;
390
+ }
391
+ },
392
+ /**
393
+ * Submits a flow test that references an already-uploaded zip (JSON body, no
394
+ * multipart). The response is identical to the legacy `POST /uploads/flow`.
395
+ * A 404 means the API predates this endpoint — callers fall back to
396
+ * `uploadFlow`.
397
+ */
398
+ async submitFlowTest(baseUrl, auth, body) {
399
+ try {
400
+ const res = await fetch(`${baseUrl}/uploads/submitFlowTest`, {
401
+ body: JSON.stringify(body),
402
+ headers: {
403
+ 'content-type': 'application/json',
404
+ ...auth.headers,
405
+ },
406
+ method: 'POST',
407
+ });
408
+ if (!res.ok) {
409
+ await this.handleApiError(res, 'Failed to submit test flows');
410
+ }
411
+ // Identical response to the legacy multipart POST /uploads/flow.
412
+ return await parseJsonResponse(res, 'Failed to submit test flows');
413
+ }
414
+ catch (error) {
415
+ if (error instanceof TypeError && error.message === 'fetch failed') {
416
+ throw this.enhanceFetchError(error, `${baseUrl}/uploads/submitFlowTest`);
417
+ }
418
+ throw error;
419
+ }
420
+ },
367
421
  /**
368
422
  * Generic report download method that handles both junit and allure reports
369
423
  * @param baseUrl - API base URL
@@ -1,4 +1,4 @@
1
- import type { StoredSession } from '../utils/config-store';
1
+ import type { StoredSession } from '../utils/config-store.js';
2
2
  export interface RefreshedSession {
3
3
  access_token: string;
4
4
  refresh_token: string;
@@ -1,6 +1,3 @@
1
- "use strict";
2
- Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.CliAuthGateway = void 0;
4
1
  /**
5
2
  * Supabase session operations for the CLI: refresh an expired access token
6
3
  * and sign-out (best-effort revocation on the Supabase side).
@@ -8,13 +5,13 @@ exports.CliAuthGateway = void 0;
8
5
  * Does not talk to the dcd API — the dcd-side session exchange lives in the
9
6
  * login command's loopback flow, where the frontend POSTs ciphertext back.
10
7
  */
11
- const supabase_js_1 = require("@supabase/supabase-js");
8
+ import { createClient } from '@supabase/supabase-js';
12
9
  function client(supabaseUrl, supabaseAnonKey) {
13
- return (0, supabase_js_1.createClient)(supabaseUrl, supabaseAnonKey, {
10
+ return createClient(supabaseUrl, supabaseAnonKey, {
14
11
  auth: { persistSession: false, autoRefreshToken: false, detectSessionInUrl: false },
15
12
  });
16
13
  }
17
- exports.CliAuthGateway = {
14
+ export const CliAuthGateway = {
18
15
  async refresh(supabaseUrl, supabaseAnonKey, session) {
19
16
  const sb = client(supabaseUrl, supabaseAnonKey);
20
17
  const { data, error } = await sb.auth.refreshSession({
@@ -0,0 +1,32 @@
1
+ import { type DcdEnvName } from '../config/environments.js';
2
+ export interface RealtimeResultsSubscription {
3
+ /**
4
+ * Whether the channel is currently subscribed and receiving pushes. False
5
+ * until the socket subscribes, and again if it errors/closes — the backstop
6
+ * poll always covers the gap.
7
+ */
8
+ isConnected(): boolean;
9
+ /** Tear down the channel and close the socket. Best-effort, never throws. */
10
+ unsubscribe(): Promise<void>;
11
+ }
12
+ export interface RealtimeSubscribeOptions {
13
+ /** Supabase JWT (AuthContext.accessToken) — authorises the socket for RLS. */
14
+ accessToken: string;
15
+ debug?: boolean;
16
+ env: DcdEnvName;
17
+ /** Stderr-safe logger; stdout is reserved by some callers (MCP). */
18
+ log?: (message: string) => void;
19
+ /** Fired when a result row for this upload changes. */
20
+ onChange: () => void;
21
+ /** Fired whenever the connection state flips (subscribed ↔ disconnected). */
22
+ onConnectionChange?: (connected: boolean) => void;
23
+ orgId: string;
24
+ uploadId: string;
25
+ }
26
+ export declare class RealtimeResultsGateway {
27
+ /**
28
+ * Open a realtime subscription to `results` changes for the given upload.
29
+ * Construction never throws — on any failure the caller simply keeps polling.
30
+ */
31
+ static subscribe(options: RealtimeSubscribeOptions): RealtimeResultsSubscription;
32
+ }
@@ -0,0 +1,103 @@
1
+ /**
2
+ * Supabase Realtime subscription to test-result status changes.
3
+ *
4
+ * This is a *latency optimisation* layered on top of HTTP polling, not a
5
+ * replacement for it. A logged-in (bearer) user's JWT authorises a
6
+ * `postgres_changes` subscription on the `results` table (RLS scopes rows to
7
+ * the user's org via `app_metadata.org_ids`). Each relevant change fires
8
+ * `onChange`, which the polling loop uses to fetch immediately instead of
9
+ * waiting out the full backstop interval.
10
+ *
11
+ * We deliberately only read the `new` row's `test_upload_id` — Postgres always
12
+ * ships the full new tuple on INSERT/UPDATE regardless of REPLICA IDENTITY, so
13
+ * no DB change is required. Mirrors the frontend pattern in
14
+ * dcd/frontend/app/stores/Results.store.ts.
15
+ */
16
+ import { createClient, REALTIME_SUBSCRIBE_STATES, } from '@supabase/supabase-js';
17
+ import { ENVIRONMENTS } from '../config/environments.js';
18
+ export class RealtimeResultsGateway {
19
+ /**
20
+ * Open a realtime subscription to `results` changes for the given upload.
21
+ * Construction never throws — on any failure the caller simply keeps polling.
22
+ */
23
+ static subscribe(options) {
24
+ const { accessToken, debug, env, log, onChange, onConnectionChange, orgId, uploadId } = options;
25
+ const dbg = (message) => {
26
+ if (debug && log)
27
+ log(`[DEBUG] [realtime] ${message}`);
28
+ };
29
+ let connected = false;
30
+ const setConnected = (next) => {
31
+ if (next === connected)
32
+ return;
33
+ connected = next;
34
+ onConnectionChange?.(next);
35
+ };
36
+ let client;
37
+ try {
38
+ const { url, anonKey } = ENVIRONMENTS[env].supabase;
39
+ client = createClient(url, anonKey, {
40
+ // Match SupabaseClientGateway / the frontend; no session persistence —
41
+ // we set the token explicitly below.
42
+ auth: { autoRefreshToken: false, persistSession: false },
43
+ realtime: { params: { eventsPerSecond: 10 } },
44
+ });
45
+ // Attach the user's JWT to the socket so RLS is enforced on the channel.
46
+ client.realtime.setAuth(accessToken);
47
+ const channel = client
48
+ .channel(`results-cli-${uploadId}`)
49
+ .on(
50
+ // The typings don't cover the postgres_changes overload cleanly.
51
+ 'postgres_changes', {
52
+ event: '*',
53
+ schema: 'public',
54
+ table: 'results',
55
+ filter: `org_id=eq.${orgId}`,
56
+ }, (payload) => {
57
+ if (payload.new?.test_upload_id === uploadId) {
58
+ dbg(`change for upload ${uploadId}`);
59
+ onChange();
60
+ }
61
+ })
62
+ .subscribe((status) => {
63
+ if (status === REALTIME_SUBSCRIBE_STATES.SUBSCRIBED) {
64
+ dbg('subscribed');
65
+ setConnected(true);
66
+ }
67
+ else if (status === REALTIME_SUBSCRIBE_STATES.CHANNEL_ERROR ||
68
+ status === REALTIME_SUBSCRIBE_STATES.TIMED_OUT ||
69
+ status === REALTIME_SUBSCRIBE_STATES.CLOSED) {
70
+ // Don't try to recover — the backstop poll covers us. Surface in
71
+ // debug so a network-blocked websocket is diagnosable.
72
+ dbg(`channel ${status}; relying on backstop poll`);
73
+ setConnected(false);
74
+ }
75
+ });
76
+ const activeClient = client;
77
+ return {
78
+ isConnected: () => connected,
79
+ async unsubscribe() {
80
+ setConnected(false);
81
+ try {
82
+ await activeClient.removeChannel(channel);
83
+ await activeClient.realtime.disconnect();
84
+ }
85
+ catch {
86
+ /* best effort — process is exiting anyway */
87
+ }
88
+ },
89
+ };
90
+ }
91
+ catch (error) {
92
+ dbg(`failed to subscribe: ${error instanceof Error ? error.message : String(error)}`);
93
+ // Best-effort cleanup of a half-built client, then degrade to polling.
94
+ try {
95
+ client?.realtime.disconnect();
96
+ }
97
+ catch {
98
+ /* ignore */
99
+ }
100
+ return { isConnected: () => false, async unsubscribe() { } };
101
+ }
102
+ }
103
+ }
@@ -1,4 +1,4 @@
1
- import { type DcdEnvName } from '../config/environments';
1
+ import { type DcdEnvName } from '../config/environments.js';
2
2
  /** Disk-backed upload descriptor — see UploadSource in src/methods.ts. */
3
3
  export interface ResumableUploadSource {
4
4
  contentType: string;
@@ -1,11 +1,8 @@
1
- "use strict";
2
- Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.SupabaseGateway = void 0;
4
- const node_fs_1 = require("node:fs");
5
- const supabase_js_1 = require("@supabase/supabase-js");
6
- const tus = require("tus-js-client");
7
- const environments_1 = require("../config/environments");
8
- class SupabaseGateway {
1
+ import { createReadStream } from 'node:fs';
2
+ import { createClient } from '@supabase/supabase-js';
3
+ import * as tus from 'tus-js-client';
4
+ import { ENVIRONMENTS } from '../config/environments.js';
5
+ export class SupabaseGateway {
9
6
  /**
10
7
  * Upload to Supabase using resumable uploads (TUS protocol)
11
8
  * Uploads to staging location (uploads/{id}/) using anon key
@@ -18,7 +15,7 @@ class SupabaseGateway {
18
15
  * @returns Promise that resolves when upload completes
19
16
  */
20
17
  static async uploadResumable(env, path, source, debug = false, onProgress) {
21
- const { anonKey: SUPABASE_PUBLIC_KEY, projectRef } = environments_1.ENVIRONMENTS[env].supabase;
18
+ const { anonKey: SUPABASE_PUBLIC_KEY, projectRef } = ENVIRONMENTS[env].supabase;
22
19
  const storageUrl = `https://${projectRef}.storage.supabase.co`;
23
20
  if (debug) {
24
21
  console.log(`[DEBUG] Resumable upload starting...`);
@@ -31,7 +28,7 @@ class SupabaseGateway {
31
28
  return new Promise((resolve, reject) => {
32
29
  // Stream from disk — tus only buffers one chunk at a time. uploadSize
33
30
  // is required because a stream's length can't be derived.
34
- const upload = new tus.Upload((0, node_fs_1.createReadStream)(source.diskPath), {
31
+ const upload = new tus.Upload(createReadStream(source.diskPath), {
35
32
  uploadSize: source.size,
36
33
  // TUS endpoint for Supabase Storage
37
34
  endpoint: `${storageUrl}/storage/v1/upload/resumable`,
@@ -96,7 +93,7 @@ class SupabaseGateway {
96
93
  });
97
94
  }
98
95
  static async uploadToSignedUrl(env, path, token, file, debug = false) {
99
- const { url: SUPABASE_URL, anonKey: SUPABASE_PUBLIC_KEY } = environments_1.ENVIRONMENTS[env].supabase;
96
+ const { url: SUPABASE_URL, anonKey: SUPABASE_PUBLIC_KEY } = ENVIRONMENTS[env].supabase;
100
97
  if (debug) {
101
98
  console.log(`[DEBUG] Supabase upload starting...`);
102
99
  console.log(`[DEBUG] Supabase URL: ${SUPABASE_URL}`);
@@ -105,7 +102,7 @@ class SupabaseGateway {
105
102
  console.log(`[DEBUG] File type: ${file.type}`);
106
103
  }
107
104
  try {
108
- const supabase = (0, supabase_js_1.createClient)(SUPABASE_URL, SUPABASE_PUBLIC_KEY);
105
+ const supabase = createClient(SUPABASE_URL, SUPABASE_PUBLIC_KEY);
109
106
  const uploadToUrl = await supabase.storage
110
107
  .from('organizations')
111
108
  .uploadToSignedUrl(path, token, file);
@@ -131,7 +128,7 @@ class SupabaseGateway {
131
128
  }
132
129
  // Re-throw with additional context
133
130
  const errorMsg = error instanceof Error ? error.message : String(error);
134
- throw new Error(`Supabase upload error: ${errorMsg}`);
131
+ throw new Error(`Supabase upload error: ${errorMsg}`, { cause: error });
135
132
  }
136
133
  }
137
134
  /**
@@ -187,4 +184,3 @@ class SupabaseGateway {
187
184
  }
188
185
  }
189
186
  }
190
- exports.SupabaseGateway = SupabaseGateway;
package/dist/index.js CHANGED
@@ -1,38 +1,41 @@
1
1
  #!/usr/bin/env node
2
- "use strict";
3
- Object.defineProperty(exports, "__esModule", { value: true });
4
- const citty_1 = require("citty");
5
- const artifacts_1 = require("./commands/artifacts");
6
- const cloud_1 = require("./commands/cloud");
7
- const list_1 = require("./commands/list");
8
- const live_1 = require("./commands/live");
9
- const login_1 = require("./commands/login");
10
- const logout_1 = require("./commands/logout");
11
- const status_1 = require("./commands/status");
12
- const switch_org_1 = require("./commands/switch-org");
13
- const upgrade_1 = require("./commands/upgrade");
14
- const upload_1 = require("./commands/upload");
15
- const whoami_1 = require("./commands/whoami");
16
- const telemetry_service_1 = require("./services/telemetry.service");
17
- const cli_1 = require("./utils/cli");
18
- const main = (0, citty_1.defineCommand)({
2
+ import { updateSettings } from '@clack/prompts';
3
+ import { defineCommand, runCommand, showUsage } from 'citty';
4
+ import { artifactsCommand } from './commands/artifacts.js';
5
+ import { cloudCommand } from './commands/cloud.js';
6
+ import { listCommand } from './commands/list.js';
7
+ import { liveCommand } from './commands/live.js';
8
+ import { loginCommand } from './commands/login.js';
9
+ import { logoutCommand } from './commands/logout.js';
10
+ import { statusCommand } from './commands/status.js';
11
+ import { switchOrgCommand } from './commands/switch-org.js';
12
+ import { upgradeCommand } from './commands/upgrade.js';
13
+ import { uploadCommand } from './commands/upload.js';
14
+ import { whoamiCommand } from './commands/whoami.js';
15
+ import { telemetry } from './services/telemetry.service.js';
16
+ import { CliError, getCliVersion, logger } from './utils/cli.js';
17
+ // @clack/prompts ships the US spelling ("Canceled") for its built-in
18
+ // spinner/prompt cancellation message; align it with the British spelling
19
+ // ("Cancelled") used everywhere else in the CLI.
20
+ updateSettings({ messages: { cancel: 'Cancelled' } });
21
+ const main = defineCommand({
19
22
  meta: {
20
23
  name: 'dcd',
21
- version: (0, cli_1.getCliVersion)(),
24
+ version: getCliVersion(),
22
25
  description: 'devicecloud.dev CLI — a drop-in replacement for `maestro cloud`',
23
26
  },
24
27
  subCommands: {
25
- cloud: cloud_1.cloudCommand,
26
- upload: upload_1.uploadCommand,
27
- list: list_1.listCommand,
28
- status: status_1.statusCommand,
29
- artifacts: artifacts_1.artifactsCommand,
30
- live: live_1.liveCommand,
31
- login: login_1.loginCommand,
32
- logout: logout_1.logoutCommand,
33
- whoami: whoami_1.whoamiCommand,
34
- 'switch-org': switch_org_1.switchOrgCommand,
35
- upgrade: upgrade_1.upgradeCommand,
28
+ cloud: cloudCommand,
29
+ upload: uploadCommand,
30
+ list: listCommand,
31
+ status: statusCommand,
32
+ artifacts: artifactsCommand,
33
+ live: liveCommand,
34
+ login: loginCommand,
35
+ logout: logoutCommand,
36
+ whoami: whoamiCommand,
37
+ 'switch-org': switchOrgCommand,
38
+ upgrade: upgradeCommand,
36
39
  },
37
40
  });
38
41
  // citty's runMain catches every error internally and calls process.exit(1),
@@ -64,30 +67,30 @@ async function resolveSubCommand(cmd, rawArgs, parent) {
64
67
  }
65
68
  async function run() {
66
69
  const rawArgs = process.argv.slice(2);
67
- telemetry_service_1.telemetry.recordCommandStart();
70
+ telemetry.recordCommandStart();
68
71
  try {
69
72
  if (rawArgs.includes('--help') || rawArgs.includes('-h')) {
70
- await (0, citty_1.showUsage)(...(await resolveSubCommand(main, rawArgs)));
73
+ await showUsage(...(await resolveSubCommand(main, rawArgs)));
71
74
  }
72
75
  else if (rawArgs.length === 1 && rawArgs[0] === '--version') {
73
- cli_1.logger.log((0, cli_1.getCliVersion)());
76
+ logger.log(getCliVersion());
74
77
  }
75
78
  else {
76
- await (0, citty_1.runCommand)(main, { rawArgs });
79
+ await runCommand(main, { rawArgs });
77
80
  }
78
- telemetry_service_1.telemetry.recordCommandSuccess();
79
- await telemetry_service_1.telemetry.flush();
81
+ telemetry.recordCommandSuccess();
82
+ await telemetry.flush();
80
83
  }
81
84
  catch (error) {
82
85
  // citty throws CLIError (by name — the class isn't exported) for usage
83
86
  // problems like unknown commands or missing required args.
84
87
  if (error instanceof Error && error.name === 'CLIError') {
85
- await (0, citty_1.showUsage)(...(await resolveSubCommand(main, rawArgs)));
88
+ await showUsage(...(await resolveSubCommand(main, rawArgs)));
86
89
  }
87
- const exitCode = error instanceof cli_1.CliError ? error.exitCode : 1;
90
+ const exitCode = error instanceof CliError ? error.exitCode : 1;
88
91
  // logger.error prints the message, records failure telemetry, flushes
89
92
  // synchronously, and exits with the given code.
90
- cli_1.logger.error(error instanceof Error ? error : String(error), {
93
+ logger.error(error instanceof Error ? error : String(error), {
91
94
  exit: exitCode,
92
95
  });
93
96
  }
@@ -0,0 +1,33 @@
1
+ /**
2
+ * Per-process MCP context: a single resolved AuthContext + API URL, plus the
3
+ * stderr logger every tool must use.
4
+ *
5
+ * stdio transport reserves **stdout** for the JSON-RPC frame stream — anything
6
+ * a tool prints there corrupts the protocol. Tools therefore call the services
7
+ * with `logStderr` (never the `utils/cli` `logger`, which writes to stdout and
8
+ * can `process.exit`).
9
+ */
10
+ import type { AuthContext } from '../types/domain/auth.types.js';
11
+ /** Write a line to stderr. Safe under stdio transport; stdout is reserved. */
12
+ export declare function logStderr(message: string): void;
13
+ export interface McpContext {
14
+ apiUrl: string;
15
+ auth: AuthContext;
16
+ }
17
+ /**
18
+ * Resolve auth + API URL once per process, lazily on first tool invocation.
19
+ *
20
+ * Lazy so `initialize` / `tools/list` succeed before the user has supplied a
21
+ * credential (clients enumerate tools on connect), and so an auth failure
22
+ * surfaces as a tool error rather than crashing the server at boot.
23
+ *
24
+ * Precedence matches the CLI: `DEVICE_CLOUD_API_KEY` env > stored `dcd login`
25
+ * session. The API URL honors `DCD_API_URL` (handy for pointing the server at
26
+ * dev/staging), then the logged-in env, then the prod default.
27
+ */
28
+ export declare function getContext(): Promise<McpContext>;
29
+ /**
30
+ * Read-only mode hides the only state-changing / billable tool
31
+ * (`dcd_run_cloud_test`). Recommended for autonomous or untrusted agents.
32
+ */
33
+ export declare function isReadOnly(): boolean;
@@ -0,0 +1,33 @@
1
+ import { resolveAuth } from '../utils/auth.js';
2
+ import { resolveApiUrl } from '../utils/config-store.js';
3
+ /** Write a line to stderr. Safe under stdio transport; stdout is reserved. */
4
+ export function logStderr(message) {
5
+ process.stderr.write(`${message}\n`);
6
+ }
7
+ let cached = null;
8
+ /**
9
+ * Resolve auth + API URL once per process, lazily on first tool invocation.
10
+ *
11
+ * Lazy so `initialize` / `tools/list` succeed before the user has supplied a
12
+ * credential (clients enumerate tools on connect), and so an auth failure
13
+ * surfaces as a tool error rather than crashing the server at boot.
14
+ *
15
+ * Precedence matches the CLI: `DEVICE_CLOUD_API_KEY` env > stored `dcd login`
16
+ * session. The API URL honors `DCD_API_URL` (handy for pointing the server at
17
+ * dev/staging), then the logged-in env, then the prod default.
18
+ */
19
+ export async function getContext() {
20
+ if (cached)
21
+ return cached;
22
+ const auth = await resolveAuth({ apiKeyFlag: undefined });
23
+ const apiUrl = resolveApiUrl(process.env.DCD_API_URL);
24
+ cached = { apiUrl, auth };
25
+ return cached;
26
+ }
27
+ /**
28
+ * Read-only mode hides the only state-changing / billable tool
29
+ * (`dcd_run_cloud_test`). Recommended for autonomous or untrusted agents.
30
+ */
31
+ export function isReadOnly() {
32
+ return (process.env.DCD_MCP_READONLY === '1' || process.argv.includes('--read-only'));
33
+ }
@@ -0,0 +1,16 @@
1
+ /**
2
+ * Shared tool plumbing: structured results and a wrapper that records
3
+ * telemetry and converts any thrown error into an `isError` tool result (so a
4
+ * single failing tool never tears down the long-lived server).
5
+ */
6
+ import type { CallToolResult } from '@modelcontextprotocol/sdk/types.js';
7
+ /** A tool result whose text payload is pretty-printed JSON. */
8
+ export declare function jsonResult(data: unknown): CallToolResult;
9
+ /** An error tool result the model can read and react to. */
10
+ export declare function errorResult(message: string): CallToolResult;
11
+ /**
12
+ * Wrap a tool handler: emit start/success/failure telemetry, flush it
13
+ * (fire-and-forget — the process outlives the call), and translate thrown
14
+ * errors into an `isError` result instead of rejecting.
15
+ */
16
+ export declare function runTool(name: string, fn: () => Promise<CallToolResult>): Promise<CallToolResult>;
@@ -0,0 +1,34 @@
1
+ import { telemetry } from '../services/telemetry.service.js';
2
+ /** A tool result whose text payload is pretty-printed JSON. */
3
+ export function jsonResult(data) {
4
+ return {
5
+ content: [{ type: 'text', text: JSON.stringify(data, null, 2) }],
6
+ };
7
+ }
8
+ /** An error tool result the model can read and react to. */
9
+ export function errorResult(message) {
10
+ return {
11
+ content: [{ type: 'text', text: `Error: ${message}` }],
12
+ isError: true,
13
+ };
14
+ }
15
+ /**
16
+ * Wrap a tool handler: emit start/success/failure telemetry, flush it
17
+ * (fire-and-forget — the process outlives the call), and translate thrown
18
+ * errors into an `isError` result instead of rejecting.
19
+ */
20
+ export async function runTool(name, fn) {
21
+ const start = Date.now();
22
+ telemetry.recordMcpToolStart(name);
23
+ try {
24
+ const result = await fn();
25
+ telemetry.recordMcpToolSuccess(name, Date.now() - start);
26
+ void telemetry.flush();
27
+ return result;
28
+ }
29
+ catch (error) {
30
+ telemetry.recordMcpToolFailure(name, error, Date.now() - start);
31
+ void telemetry.flush();
32
+ return errorResult(error instanceof Error ? error.message : String(error));
33
+ }
34
+ }
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
@@ -0,0 +1,24 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * devicecloud.dev MCP server (stdio transport).
4
+ *
5
+ * Exposes the CLI's service layer to MCP clients (Claude, Cursor, …) as tools.
6
+ * Auth is inherited from the CLI: `DEVICE_CLOUD_API_KEY` env or a stored
7
+ * `dcd login` session (see src/mcp/context.ts). All diagnostics go to stderr —
8
+ * stdout carries only JSON-RPC frames.
9
+ */
10
+ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
11
+ import { telemetry } from '../services/telemetry.service.js';
12
+ import { isReadOnly, logStderr } from './context.js';
13
+ import { createServer } from './server.js';
14
+ async function main() {
15
+ telemetry.setCommand('mcp');
16
+ const server = createServer();
17
+ const transport = new StdioServerTransport();
18
+ await server.connect(transport);
19
+ logStderr(`devicecloud.dev MCP server running on stdio${isReadOnly() ? ' (read-only)' : ''}`);
20
+ }
21
+ main().catch((error) => {
22
+ logStderr(`Fatal: ${error instanceof Error ? error.message : String(error)}`);
23
+ process.exit(1);
24
+ });
@@ -0,0 +1,7 @@
1
+ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
2
+ /**
3
+ * Build the devicecloud.dev MCP server with its tool set registered. Read-only
4
+ * tools are always present; the billable `dcd_run_cloud_test` is omitted in
5
+ * read-only mode (`DCD_MCP_READONLY=1` or `--read-only`).
6
+ */
7
+ export declare function createServer(): McpServer;