@devicecloud.dev/dcd 5.0.0-beta.0 → 5.0.0-beta.2
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/LICENSE +21 -0
- package/README.md +52 -0
- package/dist/commands/artifacts.d.ts +28 -28
- package/dist/commands/artifacts.js +20 -23
- package/dist/commands/cloud.d.ts +57 -57
- package/dist/commands/cloud.js +224 -192
- package/dist/commands/list.d.ts +22 -22
- package/dist/commands/list.js +43 -40
- package/dist/commands/live.js +134 -127
- package/dist/commands/login.d.ts +11 -11
- package/dist/commands/login.js +46 -44
- package/dist/commands/logout.js +16 -18
- package/dist/commands/status.d.ts +11 -11
- package/dist/commands/status.js +53 -44
- package/dist/commands/switch-org.d.ts +7 -7
- package/dist/commands/switch-org.js +19 -21
- package/dist/commands/upgrade.js +41 -33
- package/dist/commands/upload.d.ts +10 -10
- package/dist/commands/upload.js +42 -43
- package/dist/commands/whoami.js +17 -20
- package/dist/config/environments.js +6 -12
- package/dist/config/flags/api.flags.js +1 -4
- package/dist/config/flags/binary.flags.js +1 -4
- package/dist/config/flags/device.flags.js +6 -9
- package/dist/config/flags/environment.flags.js +1 -4
- package/dist/config/flags/execution.flags.js +1 -4
- package/dist/config/flags/github.flags.js +1 -4
- package/dist/config/flags/output.flags.js +1 -4
- package/dist/constants.js +15 -18
- package/dist/gateways/api-gateway.d.ts +31 -6
- package/dist/gateways/api-gateway.js +70 -16
- package/dist/gateways/cli-auth-gateway.d.ts +1 -1
- package/dist/gateways/cli-auth-gateway.js +3 -6
- package/dist/gateways/realtime-gateway.d.ts +32 -0
- package/dist/gateways/realtime-gateway.js +103 -0
- package/dist/gateways/supabase-gateway.d.ts +1 -1
- package/dist/gateways/supabase-gateway.js +10 -14
- package/dist/index.js +41 -38
- package/dist/mcp/context.d.ts +33 -0
- package/dist/mcp/context.js +33 -0
- package/dist/mcp/helpers.d.ts +16 -0
- package/dist/mcp/helpers.js +34 -0
- package/dist/mcp/index.d.ts +2 -0
- package/dist/mcp/index.js +24 -0
- package/dist/mcp/server.d.ts +7 -0
- package/dist/mcp/server.js +27 -0
- package/dist/mcp/tools/download-artifacts.d.ts +11 -0
- package/dist/mcp/tools/download-artifacts.js +84 -0
- package/dist/mcp/tools/get-status.d.ts +7 -0
- package/dist/mcp/tools/get-status.js +39 -0
- package/dist/mcp/tools/list-devices.d.ts +7 -0
- package/dist/mcp/tools/list-devices.js +27 -0
- package/dist/mcp/tools/list-runs.d.ts +3 -0
- package/dist/mcp/tools/list-runs.js +60 -0
- package/dist/mcp/tools/run-cloud-test.d.ts +14 -0
- package/dist/mcp/tools/run-cloud-test.js +233 -0
- package/dist/methods.d.ts +32 -1
- package/dist/methods.js +133 -79
- package/dist/services/device-validation.service.d.ts +1 -1
- package/dist/services/device-validation.service.js +1 -5
- package/dist/services/execution-plan.service.js +14 -17
- package/dist/services/execution-plan.utils.js +15 -23
- package/dist/services/flow-paths.d.ts +17 -0
- package/dist/services/flow-paths.js +52 -0
- package/dist/services/metadata-extractor.service.js +22 -25
- package/dist/services/moropo.service.js +18 -20
- package/dist/services/report-download.service.d.ts +1 -1
- package/dist/services/report-download.service.js +5 -9
- package/dist/services/results-polling.service.d.ts +18 -3
- package/dist/services/results-polling.service.js +211 -108
- package/dist/services/telemetry.service.d.ts +10 -1
- package/dist/services/telemetry.service.js +40 -18
- package/dist/services/test-submission.service.d.ts +21 -4
- package/dist/services/test-submission.service.js +51 -34
- package/dist/services/version.service.d.ts +30 -7
- package/dist/services/version.service.js +88 -32
- package/dist/types/domain/auth.types.d.ts +8 -0
- package/dist/types/domain/auth.types.js +1 -2
- package/dist/types/domain/device.types.js +8 -11
- package/dist/types/domain/live.types.js +1 -2
- package/dist/types/generated/schema.types.js +1 -2
- package/dist/types/index.d.ts +2 -2
- package/dist/types/index.js +2 -18
- package/dist/types.js +1 -2
- package/dist/utils/auth.d.ts +1 -1
- package/dist/utils/auth.js +27 -28
- package/dist/utils/ci.d.ts +12 -0
- package/dist/utils/ci.js +39 -0
- package/dist/utils/cli.d.ts +16 -2
- package/dist/utils/cli.js +57 -29
- package/dist/utils/compatibility.d.ts +1 -1
- package/dist/utils/compatibility.js +5 -7
- package/dist/utils/config-store.js +33 -43
- package/dist/utils/connectivity.js +1 -4
- package/dist/utils/expo.js +15 -21
- package/dist/utils/orgs.js +8 -12
- package/dist/utils/paths.js +2 -5
- package/dist/utils/progress.d.ts +3 -0
- package/dist/utils/progress.js +47 -8
- package/dist/utils/styling.d.ts +35 -37
- package/dist/utils/styling.js +52 -86
- package/dist/utils/ui.d.ts +41 -0
- package/dist/utils/ui.js +95 -0
- package/package.json +27 -24
|
@@ -1,16 +1,13 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
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
|
-
|
|
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(/^~(?=$|\/|\\)/,
|
|
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
|
-
|
|
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 =
|
|
144
|
-
await
|
|
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,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
|
-
|
|
8
|
+
import { createClient } from '@supabase/supabase-js';
|
|
12
9
|
function client(supabaseUrl, supabaseAnonKey) {
|
|
13
|
-
return
|
|
10
|
+
return createClient(supabaseUrl, supabaseAnonKey, {
|
|
14
11
|
auth: { persistSession: false, autoRefreshToken: false, detectSessionInUrl: false },
|
|
15
12
|
});
|
|
16
13
|
}
|
|
17
|
-
|
|
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,11 +1,8 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
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 } =
|
|
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(
|
|
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 } =
|
|
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 =
|
|
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
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
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:
|
|
24
|
+
version: getCliVersion(),
|
|
22
25
|
description: 'devicecloud.dev CLI — a drop-in replacement for `maestro cloud`',
|
|
23
26
|
},
|
|
24
27
|
subCommands: {
|
|
25
|
-
cloud:
|
|
26
|
-
upload:
|
|
27
|
-
list:
|
|
28
|
-
status:
|
|
29
|
-
artifacts:
|
|
30
|
-
live:
|
|
31
|
-
login:
|
|
32
|
-
logout:
|
|
33
|
-
whoami:
|
|
34
|
-
'switch-org':
|
|
35
|
-
upgrade:
|
|
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
|
-
|
|
70
|
+
telemetry.recordCommandStart();
|
|
68
71
|
try {
|
|
69
72
|
if (rawArgs.includes('--help') || rawArgs.includes('-h')) {
|
|
70
|
-
await
|
|
73
|
+
await showUsage(...(await resolveSubCommand(main, rawArgs)));
|
|
71
74
|
}
|
|
72
75
|
else if (rawArgs.length === 1 && rawArgs[0] === '--version') {
|
|
73
|
-
|
|
76
|
+
logger.log(getCliVersion());
|
|
74
77
|
}
|
|
75
78
|
else {
|
|
76
|
-
await
|
|
79
|
+
await runCommand(main, { rawArgs });
|
|
77
80
|
}
|
|
78
|
-
|
|
79
|
-
await
|
|
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
|
|
88
|
+
await showUsage(...(await resolveSubCommand(main, rawArgs)));
|
|
86
89
|
}
|
|
87
|
-
const exitCode = error instanceof
|
|
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
|
-
|
|
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,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;
|