@devicecloud.dev/dcd 4.4.9 → 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.
- package/README.md +75 -2
- package/dist/commands/artifacts.d.ts +47 -18
- package/dist/commands/artifacts.js +69 -64
- package/dist/commands/cloud.d.ts +228 -88
- package/dist/commands/cloud.js +430 -342
- package/dist/commands/list.d.ts +39 -38
- package/dist/commands/list.js +124 -131
- package/dist/commands/live.d.ts +2 -0
- package/dist/commands/live.js +520 -0
- package/dist/commands/login.d.ts +17 -0
- package/dist/commands/login.js +252 -0
- package/dist/commands/logout.d.ts +2 -0
- package/dist/commands/logout.js +30 -0
- package/dist/commands/status.d.ts +23 -42
- package/dist/commands/status.js +170 -179
- package/dist/commands/switch-org.d.ts +12 -0
- package/dist/commands/switch-org.js +76 -0
- package/dist/commands/upgrade.d.ts +2 -0
- package/dist/commands/upgrade.js +120 -0
- package/dist/commands/upload.d.ts +33 -18
- package/dist/commands/upload.js +72 -78
- package/dist/commands/whoami.d.ts +2 -0
- package/dist/commands/whoami.js +31 -0
- package/dist/config/environments.d.ts +31 -0
- package/dist/config/environments.js +52 -0
- package/dist/config/flags/api.flags.d.ts +10 -2
- package/dist/config/flags/api.flags.js +13 -14
- package/dist/config/flags/binary.flags.d.ts +17 -4
- package/dist/config/flags/binary.flags.js +14 -18
- package/dist/config/flags/device.flags.d.ts +49 -11
- package/dist/config/flags/device.flags.js +43 -38
- package/dist/config/flags/environment.flags.d.ts +27 -6
- package/dist/config/flags/environment.flags.js +24 -29
- package/dist/config/flags/execution.flags.d.ts +35 -8
- package/dist/config/flags/execution.flags.js +31 -41
- package/dist/config/flags/github.flags.d.ts +23 -5
- package/dist/config/flags/github.flags.js +19 -15
- package/dist/config/flags/output.flags.d.ts +57 -13
- package/dist/config/flags/output.flags.js +48 -47
- package/dist/constants.d.ts +218 -51
- package/dist/constants.js +17 -20
- package/dist/gateways/api-gateway.d.ts +72 -16
- package/dist/gateways/api-gateway.js +298 -104
- package/dist/gateways/cli-auth-gateway.d.ts +13 -0
- package/dist/gateways/cli-auth-gateway.js +54 -0
- package/dist/gateways/realtime-gateway.d.ts +32 -0
- package/dist/gateways/realtime-gateway.js +103 -0
- package/dist/gateways/supabase-gateway.d.ts +11 -11
- package/dist/gateways/supabase-gateway.js +20 -48
- package/dist/index.d.ts +2 -1
- package/dist/index.js +98 -4
- 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 +34 -5
- package/dist/methods.js +266 -215
- package/dist/services/device-validation.service.d.ts +9 -1
- package/dist/services/device-validation.service.js +56 -40
- package/dist/services/execution-plan.service.js +40 -31
- package/dist/services/execution-plan.utils.d.ts +3 -0
- package/dist/services/execution-plan.utils.js +25 -55
- package/dist/services/flow-paths.d.ts +17 -0
- package/dist/services/flow-paths.js +52 -0
- package/dist/services/metadata-extractor.service.d.ts +0 -2
- package/dist/services/metadata-extractor.service.js +75 -78
- package/dist/services/moropo.service.js +33 -34
- package/dist/services/report-download.service.d.ts +12 -1
- package/dist/services/report-download.service.js +34 -27
- package/dist/services/results-polling.service.d.ts +23 -9
- package/dist/services/results-polling.service.js +257 -123
- package/dist/services/telemetry.service.d.ts +49 -0
- package/dist/services/telemetry.service.js +252 -0
- package/dist/services/test-submission.service.d.ts +21 -4
- package/dist/services/test-submission.service.js +51 -33
- package/dist/services/version.service.d.ts +4 -3
- package/dist/services/version.service.js +28 -16
- package/dist/types/domain/auth.types.d.ts +20 -0
- package/dist/types/domain/auth.types.js +1 -0
- package/dist/types/domain/device.types.js +8 -11
- package/dist/types/domain/live.types.d.ts +76 -0
- package/dist/types/domain/live.types.js +3 -0
- 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 +13 -0
- package/dist/utils/auth.js +141 -0
- package/dist/utils/ci.d.ts +12 -0
- package/dist/utils/ci.js +39 -0
- package/dist/utils/cli.d.ts +35 -0
- package/dist/utils/cli.js +118 -0
- package/dist/utils/compatibility.d.ts +2 -1
- package/dist/utils/compatibility.js +6 -8
- package/dist/utils/config-store.d.ts +35 -0
- package/dist/utils/config-store.js +115 -0
- package/dist/utils/connectivity.js +8 -7
- package/dist/utils/expo.js +29 -24
- package/dist/utils/orgs.d.ts +11 -0
- package/dist/utils/orgs.js +36 -0
- package/dist/utils/paths.d.ts +11 -0
- package/dist/utils/paths.js +21 -0
- package/dist/utils/progress.d.ts +13 -0
- package/dist/utils/progress.js +47 -0
- package/dist/utils/styling.d.ts +42 -36
- package/dist/utils/styling.js +78 -82
- package/dist/utils/ui.d.ts +41 -0
- package/dist/utils/ui.js +95 -0
- package/package.json +36 -45
- package/bin/dev.cmd +0 -3
- package/bin/dev.js +0 -6
- package/bin/run.cmd +0 -3
- package/bin/run.js +0 -7
- package/dist/types/schema.types.d.ts +0 -2702
- package/dist/types/schema.types.js +0 -3
- package/oclif.manifest.json +0 -884
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Supabase session operations for the CLI: refresh an expired access token
|
|
3
|
+
* and sign-out (best-effort revocation on the Supabase side).
|
|
4
|
+
*
|
|
5
|
+
* Does not talk to the dcd API — the dcd-side session exchange lives in the
|
|
6
|
+
* login command's loopback flow, where the frontend POSTs ciphertext back.
|
|
7
|
+
*/
|
|
8
|
+
import { createClient } from '@supabase/supabase-js';
|
|
9
|
+
function client(supabaseUrl, supabaseAnonKey) {
|
|
10
|
+
return createClient(supabaseUrl, supabaseAnonKey, {
|
|
11
|
+
auth: { persistSession: false, autoRefreshToken: false, detectSessionInUrl: false },
|
|
12
|
+
});
|
|
13
|
+
}
|
|
14
|
+
export const CliAuthGateway = {
|
|
15
|
+
async refresh(supabaseUrl, supabaseAnonKey, session) {
|
|
16
|
+
const sb = client(supabaseUrl, supabaseAnonKey);
|
|
17
|
+
const { data, error } = await sb.auth.refreshSession({
|
|
18
|
+
refresh_token: session.refresh_token,
|
|
19
|
+
});
|
|
20
|
+
if (error || !data.session || !data.user) {
|
|
21
|
+
throw new Error(`Failed to refresh session: ${error?.message ?? 'no session returned'}. ` +
|
|
22
|
+
`Run \`dcd login\` again.`);
|
|
23
|
+
}
|
|
24
|
+
const s = data.session;
|
|
25
|
+
const u = data.user;
|
|
26
|
+
if (!s.expires_at)
|
|
27
|
+
throw new Error('Refreshed session is missing expires_at');
|
|
28
|
+
return {
|
|
29
|
+
access_token: s.access_token,
|
|
30
|
+
refresh_token: s.refresh_token,
|
|
31
|
+
expires_at: s.expires_at,
|
|
32
|
+
user_email: u.email ?? session.user_email,
|
|
33
|
+
user_id: u.id,
|
|
34
|
+
};
|
|
35
|
+
},
|
|
36
|
+
async signOut(supabaseUrl, supabaseAnonKey, session) {
|
|
37
|
+
// `scope: 'local'` clears this client's SDK state only — no server call,
|
|
38
|
+
// no revocation. supabase-js defaults to `scope: 'global'`, which revokes
|
|
39
|
+
// EVERY session for the user (browser, mobile, other CLIs), so
|
|
40
|
+
// `dcd logout` would kick the user out of the web app. We don't want
|
|
41
|
+
// that — logging out locally should be local.
|
|
42
|
+
const sb = client(supabaseUrl, supabaseAnonKey);
|
|
43
|
+
try {
|
|
44
|
+
await sb.auth.setSession({
|
|
45
|
+
access_token: session.access_token,
|
|
46
|
+
refresh_token: session.refresh_token,
|
|
47
|
+
});
|
|
48
|
+
await sb.auth.signOut({ scope: 'local' });
|
|
49
|
+
}
|
|
50
|
+
catch {
|
|
51
|
+
// Best effort — the local config will be wiped regardless.
|
|
52
|
+
}
|
|
53
|
+
},
|
|
54
|
+
};
|
|
@@ -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,25 +1,25 @@
|
|
|
1
|
+
import { type DcdEnvName } from '../config/environments.js';
|
|
2
|
+
/** Disk-backed upload descriptor — see UploadSource in src/methods.ts. */
|
|
3
|
+
export interface ResumableUploadSource {
|
|
4
|
+
contentType: string;
|
|
5
|
+
diskPath: string;
|
|
6
|
+
name: string;
|
|
7
|
+
size: number;
|
|
8
|
+
}
|
|
1
9
|
export declare class SupabaseGateway {
|
|
2
|
-
private static SB;
|
|
3
|
-
static getSupabaseKeys(env: 'dev' | 'prod'): {
|
|
4
|
-
SUPABASE_PUBLIC_KEY: string;
|
|
5
|
-
SUPABASE_URL: string;
|
|
6
|
-
} | {
|
|
7
|
-
SUPABASE_PUBLIC_KEY: string;
|
|
8
|
-
SUPABASE_URL: string;
|
|
9
|
-
};
|
|
10
10
|
/**
|
|
11
11
|
* Upload to Supabase using resumable uploads (TUS protocol)
|
|
12
12
|
* Uploads to staging location (uploads/{id}/) using anon key
|
|
13
13
|
* File is later moved to final location by API after finalization
|
|
14
14
|
* @param env - Environment (dev or prod)
|
|
15
15
|
* @param path - Staging storage path (uploads/{id}/file.ext)
|
|
16
|
-
* @param
|
|
16
|
+
* @param source - Upload source descriptor; the file is streamed from disk
|
|
17
17
|
* @param debug - Enable debug logging
|
|
18
18
|
* @param onProgress - Optional callback for upload progress (bytesUploaded, bytesTotal)
|
|
19
19
|
* @returns Promise that resolves when upload completes
|
|
20
20
|
*/
|
|
21
|
-
static uploadResumable(env:
|
|
22
|
-
static uploadToSignedUrl(env:
|
|
21
|
+
static uploadResumable(env: DcdEnvName, path: string, source: ResumableUploadSource, debug?: boolean, onProgress?: (bytesUploaded: number, bytesTotal: number) => void): Promise<void>;
|
|
22
|
+
static uploadToSignedUrl(env: DcdEnvName, path: string, token: string, file: File, debug?: boolean): Promise<void>;
|
|
23
23
|
/**
|
|
24
24
|
* Logs network error details for debugging
|
|
25
25
|
* @param error - Error object to analyze for network-related issues
|
|
@@ -1,62 +1,35 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
class SupabaseGateway {
|
|
7
|
-
static SB = {
|
|
8
|
-
dev: {
|
|
9
|
-
SUPABASE_PUBLIC_KEY: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6ImxibXNvd2VodGp3bnFsdXJwZW1iIiwicm9sZSI6ImFub24iLCJpYXQiOjE3MDkyMTg0ODcsImV4cCI6MjAyNDc5NDQ4N30.zeLTMAuZ_WwYvGdeP0kdvL_Zrs-RQee5APPyxmWq7qQ',
|
|
10
|
-
SUPABASE_URL: 'https://lbmsowehtjwnqlurpemb.supabase.co',
|
|
11
|
-
},
|
|
12
|
-
prod: {
|
|
13
|
-
SUPABASE_PUBLIC_KEY: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6InBneWRucGhiaW1ldGluc2dma2JvIiwicm9sZSI6ImFub24iLCJpYXQiOjE3MDc1OTQzNDYsImV4cCI6MjAyMzE3MDM0Nn0.hAYOMFxxwX1exkQkY9xyQJGC_GhGnyogkj2N-kBkMI8',
|
|
14
|
-
SUPABASE_URL: 'https://cloud.devicecloud.dev',
|
|
15
|
-
},
|
|
16
|
-
};
|
|
17
|
-
static getSupabaseKeys(env) {
|
|
18
|
-
return this.SB[env];
|
|
19
|
-
}
|
|
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 {
|
|
20
6
|
/**
|
|
21
7
|
* Upload to Supabase using resumable uploads (TUS protocol)
|
|
22
8
|
* Uploads to staging location (uploads/{id}/) using anon key
|
|
23
9
|
* File is later moved to final location by API after finalization
|
|
24
10
|
* @param env - Environment (dev or prod)
|
|
25
11
|
* @param path - Staging storage path (uploads/{id}/file.ext)
|
|
26
|
-
* @param
|
|
12
|
+
* @param source - Upload source descriptor; the file is streamed from disk
|
|
27
13
|
* @param debug - Enable debug logging
|
|
28
14
|
* @param onProgress - Optional callback for upload progress (bytesUploaded, bytesTotal)
|
|
29
15
|
* @returns Promise that resolves when upload completes
|
|
30
16
|
*/
|
|
31
|
-
static async uploadResumable(env, path,
|
|
32
|
-
const { SUPABASE_PUBLIC_KEY,
|
|
33
|
-
|
|
34
|
-
// Format: https://project-id.supabase.co or https://cloud.devicecloud.dev (custom domain)
|
|
35
|
-
let projectId;
|
|
36
|
-
if (SUPABASE_URL.includes('.supabase.co')) {
|
|
37
|
-
projectId = SUPABASE_URL.replace('https://', '').split('.')[0];
|
|
38
|
-
}
|
|
39
|
-
else {
|
|
40
|
-
// For custom domains like cloud.devicecloud.dev, we need the project ref
|
|
41
|
-
// This is the dev environment project ref
|
|
42
|
-
projectId = env === 'dev' ? 'lbmsowehtjwnqlurpemb' : 'pgydnphbimetinsgfkbo';
|
|
43
|
-
}
|
|
44
|
-
const storageUrl = `https://${projectId}.storage.supabase.co`;
|
|
17
|
+
static async uploadResumable(env, path, source, debug = false, onProgress) {
|
|
18
|
+
const { anonKey: SUPABASE_PUBLIC_KEY, projectRef } = ENVIRONMENTS[env].supabase;
|
|
19
|
+
const storageUrl = `https://${projectRef}.storage.supabase.co`;
|
|
45
20
|
if (debug) {
|
|
46
21
|
console.log(`[DEBUG] Resumable upload starting...`);
|
|
47
22
|
console.log(`[DEBUG] Storage URL: ${storageUrl}`);
|
|
48
23
|
console.log(`[DEBUG] Upload path: ${path}`);
|
|
49
|
-
console.log(`[DEBUG] File name: ${
|
|
50
|
-
console.log(`[DEBUG] File size: ${(
|
|
51
|
-
|
|
52
|
-
// Convert File to Buffer for Node.js tus-js-client
|
|
53
|
-
// In Node.js environment, tus-js-client expects Buffer or Readable stream, not File
|
|
54
|
-
const fileBuffer = Buffer.from(await file.arrayBuffer());
|
|
55
|
-
if (debug) {
|
|
56
|
-
console.log(`[DEBUG] Converted File to Buffer (${fileBuffer.length} bytes)`);
|
|
24
|
+
console.log(`[DEBUG] File name: ${source.name}`);
|
|
25
|
+
console.log(`[DEBUG] File size: ${(source.size / 1024 / 1024).toFixed(2)} MB`);
|
|
26
|
+
console.log(`[DEBUG] Streaming from disk: ${source.diskPath}`);
|
|
57
27
|
}
|
|
58
28
|
return new Promise((resolve, reject) => {
|
|
59
|
-
|
|
29
|
+
// Stream from disk — tus only buffers one chunk at a time. uploadSize
|
|
30
|
+
// is required because a stream's length can't be derived.
|
|
31
|
+
const upload = new tus.Upload(createReadStream(source.diskPath), {
|
|
32
|
+
uploadSize: source.size,
|
|
60
33
|
// TUS endpoint for Supabase Storage
|
|
61
34
|
endpoint: `${storageUrl}/storage/v1/upload/resumable`,
|
|
62
35
|
// Retry configuration - will retry 5 times with increasing delays
|
|
@@ -71,7 +44,7 @@ class SupabaseGateway {
|
|
|
71
44
|
metadata: {
|
|
72
45
|
bucketName: 'organizations',
|
|
73
46
|
objectName: path,
|
|
74
|
-
contentType:
|
|
47
|
+
contentType: source.contentType || 'application/octet-stream',
|
|
75
48
|
cacheControl: '3600',
|
|
76
49
|
},
|
|
77
50
|
// Chunk size must be 6MB for Supabase
|
|
@@ -120,7 +93,7 @@ class SupabaseGateway {
|
|
|
120
93
|
});
|
|
121
94
|
}
|
|
122
95
|
static async uploadToSignedUrl(env, path, token, file, debug = false) {
|
|
123
|
-
const {
|
|
96
|
+
const { url: SUPABASE_URL, anonKey: SUPABASE_PUBLIC_KEY } = ENVIRONMENTS[env].supabase;
|
|
124
97
|
if (debug) {
|
|
125
98
|
console.log(`[DEBUG] Supabase upload starting...`);
|
|
126
99
|
console.log(`[DEBUG] Supabase URL: ${SUPABASE_URL}`);
|
|
@@ -129,7 +102,7 @@ class SupabaseGateway {
|
|
|
129
102
|
console.log(`[DEBUG] File type: ${file.type}`);
|
|
130
103
|
}
|
|
131
104
|
try {
|
|
132
|
-
const supabase =
|
|
105
|
+
const supabase = createClient(SUPABASE_URL, SUPABASE_PUBLIC_KEY);
|
|
133
106
|
const uploadToUrl = await supabase.storage
|
|
134
107
|
.from('organizations')
|
|
135
108
|
.uploadToSignedUrl(path, token, file);
|
|
@@ -155,7 +128,7 @@ class SupabaseGateway {
|
|
|
155
128
|
}
|
|
156
129
|
// Re-throw with additional context
|
|
157
130
|
const errorMsg = error instanceof Error ? error.message : String(error);
|
|
158
|
-
throw new Error(`Supabase upload error: ${errorMsg}
|
|
131
|
+
throw new Error(`Supabase upload error: ${errorMsg}`, { cause: error });
|
|
159
132
|
}
|
|
160
133
|
}
|
|
161
134
|
/**
|
|
@@ -211,4 +184,3 @@ class SupabaseGateway {
|
|
|
211
184
|
}
|
|
212
185
|
}
|
|
213
186
|
}
|
|
214
|
-
exports.SupabaseGateway = SupabaseGateway;
|
package/dist/index.d.ts
CHANGED
|
@@ -1 +1,2 @@
|
|
|
1
|
-
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
export {};
|
package/dist/index.js
CHANGED
|
@@ -1,4 +1,98 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
1
|
+
#!/usr/bin/env node
|
|
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({
|
|
22
|
+
meta: {
|
|
23
|
+
name: 'dcd',
|
|
24
|
+
version: getCliVersion(),
|
|
25
|
+
description: 'devicecloud.dev CLI — a drop-in replacement for `maestro cloud`',
|
|
26
|
+
},
|
|
27
|
+
subCommands: {
|
|
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,
|
|
39
|
+
},
|
|
40
|
+
});
|
|
41
|
+
// citty's runMain catches every error internally and calls process.exit(1),
|
|
42
|
+
// so its returned promise never rejects — failure telemetry hooked onto it
|
|
43
|
+
// would be dead code, CliError.exitCode would be ignored, and raw stack
|
|
44
|
+
// traces would be printed for usage errors. This replicates runMain's
|
|
45
|
+
// help/version/usage behaviour with those three problems fixed. Telemetry
|
|
46
|
+
// configure is deferred to `resolveAuth` (only authenticated commands ship
|
|
47
|
+
// telemetry — see telemetry.service.ts); unauthenticated invocations buffer
|
|
48
|
+
// in memory and drop on exit, which is the desired behaviour.
|
|
49
|
+
async function resolveValue(input) {
|
|
50
|
+
return typeof input === 'function'
|
|
51
|
+
? input()
|
|
52
|
+
: input;
|
|
53
|
+
}
|
|
54
|
+
// Mirrors citty's internal (unexported) resolveSubCommand so usage errors can
|
|
55
|
+
// show the help of the subcommand that failed rather than the root command.
|
|
56
|
+
async function resolveSubCommand(cmd, rawArgs, parent) {
|
|
57
|
+
const subCommands = await resolveValue(cmd.subCommands);
|
|
58
|
+
if (subCommands && Object.keys(subCommands).length > 0) {
|
|
59
|
+
const subCommandArgIndex = rawArgs.findIndex((arg) => !arg.startsWith('-'));
|
|
60
|
+
const subCommandName = rawArgs[subCommandArgIndex];
|
|
61
|
+
const subCommand = await resolveValue(subCommands[subCommandName]);
|
|
62
|
+
if (subCommand) {
|
|
63
|
+
return resolveSubCommand(subCommand, rawArgs.slice(subCommandArgIndex + 1), cmd);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
return [cmd, parent];
|
|
67
|
+
}
|
|
68
|
+
async function run() {
|
|
69
|
+
const rawArgs = process.argv.slice(2);
|
|
70
|
+
telemetry.recordCommandStart();
|
|
71
|
+
try {
|
|
72
|
+
if (rawArgs.includes('--help') || rawArgs.includes('-h')) {
|
|
73
|
+
await showUsage(...(await resolveSubCommand(main, rawArgs)));
|
|
74
|
+
}
|
|
75
|
+
else if (rawArgs.length === 1 && rawArgs[0] === '--version') {
|
|
76
|
+
logger.log(getCliVersion());
|
|
77
|
+
}
|
|
78
|
+
else {
|
|
79
|
+
await runCommand(main, { rawArgs });
|
|
80
|
+
}
|
|
81
|
+
telemetry.recordCommandSuccess();
|
|
82
|
+
await telemetry.flush();
|
|
83
|
+
}
|
|
84
|
+
catch (error) {
|
|
85
|
+
// citty throws CLIError (by name — the class isn't exported) for usage
|
|
86
|
+
// problems like unknown commands or missing required args.
|
|
87
|
+
if (error instanceof Error && error.name === 'CLIError') {
|
|
88
|
+
await showUsage(...(await resolveSubCommand(main, rawArgs)));
|
|
89
|
+
}
|
|
90
|
+
const exitCode = error instanceof CliError ? error.exitCode : 1;
|
|
91
|
+
// logger.error prints the message, records failure telemetry, flushes
|
|
92
|
+
// synchronously, and exits with the given code.
|
|
93
|
+
logger.error(error instanceof Error ? error : String(error), {
|
|
94
|
+
exit: exitCode,
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
void run();
|
|
@@ -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;
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
2
|
+
import { getCliVersion } from '../utils/cli.js';
|
|
3
|
+
import { isReadOnly } from './context.js';
|
|
4
|
+
import { registerDownloadArtifacts } from './tools/download-artifacts.js';
|
|
5
|
+
import { registerGetStatus } from './tools/get-status.js';
|
|
6
|
+
import { registerListDevices } from './tools/list-devices.js';
|
|
7
|
+
import { registerListRuns } from './tools/list-runs.js';
|
|
8
|
+
import { registerRunCloudTest } from './tools/run-cloud-test.js';
|
|
9
|
+
/**
|
|
10
|
+
* Build the devicecloud.dev MCP server with its tool set registered. Read-only
|
|
11
|
+
* tools are always present; the billable `dcd_run_cloud_test` is omitted in
|
|
12
|
+
* read-only mode (`DCD_MCP_READONLY=1` or `--read-only`).
|
|
13
|
+
*/
|
|
14
|
+
export function createServer() {
|
|
15
|
+
const server = new McpServer({
|
|
16
|
+
name: 'devicecloud',
|
|
17
|
+
version: getCliVersion(),
|
|
18
|
+
});
|
|
19
|
+
registerListDevices(server);
|
|
20
|
+
registerListRuns(server);
|
|
21
|
+
registerGetStatus(server);
|
|
22
|
+
registerDownloadArtifacts(server);
|
|
23
|
+
if (!isReadOnly()) {
|
|
24
|
+
registerRunCloudTest(server);
|
|
25
|
+
}
|
|
26
|
+
return server;
|
|
27
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
2
|
+
/**
|
|
3
|
+
* Download a completed run's artifacts (zip) and/or a formatted report to local
|
|
4
|
+
* disk. This reads cloud state and writes files locally — it does not change
|
|
5
|
+
* anything cloud-side, so it stays available in read-only mode.
|
|
6
|
+
*
|
|
7
|
+
* The underlying service swallows download failures into `warnLogger` rather
|
|
8
|
+
* than throwing (a missing-artifacts 404 isn't fatal), so we collect those
|
|
9
|
+
* warnings and return them — an empty `warnings` array means success.
|
|
10
|
+
*/
|
|
11
|
+
export declare function registerDownloadArtifacts(server: McpServer): void;
|