@devicecloud.dev/dcd 5.8.0 → 5.9.0
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/dist/commands/artifacts.d.ts +4 -0
- package/dist/commands/artifacts.js +21 -6
- package/dist/commands/cloud.js +2 -4
- package/dist/commands/list.js +1 -2
- package/dist/commands/live.js +159 -56
- package/dist/commands/login.d.ts +8 -0
- package/dist/commands/login.js +74 -14
- package/dist/commands/logout.d.ts +6 -1
- package/dist/commands/logout.js +17 -5
- package/dist/commands/status.js +5 -8
- package/dist/commands/switch-org.d.ts +4 -0
- package/dist/commands/switch-org.js +26 -23
- package/dist/commands/upgrade.d.ts +6 -1
- package/dist/commands/upgrade.js +24 -3
- package/dist/commands/upload.js +1 -2
- package/dist/commands/whoami.d.ts +4 -0
- package/dist/commands/whoami.js +17 -0
- package/dist/config/environments.d.ts +6 -9
- package/dist/config/environments.js +4 -8
- package/dist/config/flags/output.flags.d.ts +8 -0
- package/dist/config/flags/output.flags.js +8 -0
- package/dist/config/supabase-key.d.ts +27 -0
- package/dist/config/supabase-key.js +54 -0
- package/dist/gateways/cli-auth-gateway.d.ts +3 -2
- package/dist/gateways/cli-auth-gateway.js +16 -6
- package/dist/gateways/realtime-gateway.js +48 -34
- package/dist/gateways/supabase-gateway.d.ts +1 -1
- package/dist/gateways/supabase-gateway.js +8 -6
- package/dist/index.js +6 -2
- package/dist/services/report-download.service.d.ts +12 -5
- package/dist/services/report-download.service.js +15 -10
- package/dist/utils/auth.js +1 -3
- package/dist/utils/ci.d.ts +9 -0
- package/dist/utils/ci.js +9 -0
- package/dist/utils/cli.d.ts +7 -0
- package/dist/utils/cli.js +23 -6
- package/dist/utils/orgs.d.ts +38 -1
- package/dist/utils/orgs.js +59 -3
- package/package.json +3 -3
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Supabase publishable key, fetched from the dcd API at runtime (dcd#1112).
|
|
3
|
+
*
|
|
4
|
+
* Releases up to this one compiled the anon key in, so the key could never be
|
|
5
|
+
* rotated without breaking every CLI version pinned in customer CI. The API now
|
|
6
|
+
* serves it from the public `GET /cli-config`, and rotating it is an env change
|
|
7
|
+
* on the API.
|
|
8
|
+
*
|
|
9
|
+
* Fetched once per process and kept in memory only. It is deliberately not
|
|
10
|
+
* cached on disk: a stale key makes GoTrue answer a refresh with a 4xx, which
|
|
11
|
+
* `resolveAuth` treats as a dead session and deletes the user's login.
|
|
12
|
+
*
|
|
13
|
+
* Only the key comes from the API. The Supabase URLs stay pinned in
|
|
14
|
+
* `ENVIRONMENTS`: prod goes through the `cloud.devicecloud.dev` custom domain,
|
|
15
|
+
* which customer firewalls may allow-list, not the API's raw `supabase.co` URL.
|
|
16
|
+
*/
|
|
17
|
+
import { ENVIRONMENTS } from './environments.js';
|
|
18
|
+
const FETCH_TIMEOUT_MS = 10_000;
|
|
19
|
+
const cache = new Map();
|
|
20
|
+
async function fetchSupabaseKey(env) {
|
|
21
|
+
const url = `${ENVIRONMENTS[env].apiUrl}/cli-config`;
|
|
22
|
+
const res = await fetch(url, { signal: AbortSignal.timeout(FETCH_TIMEOUT_MS) });
|
|
23
|
+
if (!res.ok) {
|
|
24
|
+
throw new Error(`Failed to fetch CLI config from ${url}: HTTP ${res.status}`);
|
|
25
|
+
}
|
|
26
|
+
const body = (await res.json());
|
|
27
|
+
const key = body.supabase_publishable_key;
|
|
28
|
+
if (typeof key !== 'string' || key.length === 0) {
|
|
29
|
+
throw new Error(`CLI config from ${url} has no supabase_publishable_key`);
|
|
30
|
+
}
|
|
31
|
+
return key;
|
|
32
|
+
}
|
|
33
|
+
/** The publishable key for `env`. A failed fetch is not cached, so the next call retries. */
|
|
34
|
+
export function getSupabaseKey(env) {
|
|
35
|
+
let pending = cache.get(env);
|
|
36
|
+
if (!pending) {
|
|
37
|
+
pending = fetchSupabaseKey(env);
|
|
38
|
+
cache.set(env, pending);
|
|
39
|
+
pending.catch(() => cache.delete(env));
|
|
40
|
+
}
|
|
41
|
+
return pending;
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Headers that authenticate a raw Storage/Auth request with `key` alone. A
|
|
45
|
+
* legacy anon key is a JWT and also goes in `Authorization`; an
|
|
46
|
+
* `sb_publishable_` key is not a JWT and must only be sent as `apikey`.
|
|
47
|
+
*/
|
|
48
|
+
export function supabaseKeyHeaders(key) {
|
|
49
|
+
return key.startsWith('sb_') ? { apikey: key } : { apikey: key, authorization: `Bearer ${key}` };
|
|
50
|
+
}
|
|
51
|
+
/** Test seam: forget fetched keys. */
|
|
52
|
+
export function resetSupabaseKeyCache() {
|
|
53
|
+
cache.clear();
|
|
54
|
+
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { DcdEnvName } from '../config/environments.js';
|
|
1
2
|
import type { StoredSession } from '../utils/config-store.js';
|
|
2
3
|
/**
|
|
3
4
|
* Thrown when a session refresh fails. `definitive` distinguishes "Supabase
|
|
@@ -18,6 +19,6 @@ export interface RefreshedSession {
|
|
|
18
19
|
user_id: string;
|
|
19
20
|
}
|
|
20
21
|
export declare const CliAuthGateway: {
|
|
21
|
-
refresh(supabaseUrl: string,
|
|
22
|
-
signOut(supabaseUrl: string,
|
|
22
|
+
refresh(supabaseUrl: string, env: DcdEnvName, session: StoredSession): Promise<RefreshedSession>;
|
|
23
|
+
signOut(supabaseUrl: string, env: DcdEnvName, session: StoredSession): Promise<void>;
|
|
23
24
|
};
|
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
* login command's PKCE rendezvous flow (see src/commands/login.ts).
|
|
7
7
|
*/
|
|
8
8
|
import { createClient, isAuthApiError } from '@supabase/supabase-js';
|
|
9
|
+
import { getSupabaseKey } from '../config/supabase-key.js';
|
|
9
10
|
/**
|
|
10
11
|
* Thrown when a session refresh fails. `definitive` distinguishes "Supabase
|
|
11
12
|
* rejected this refresh token" (revoked, already used, malformed — re-login
|
|
@@ -20,14 +21,23 @@ export class SessionRefreshError extends Error {
|
|
|
20
21
|
this.name = 'SessionRefreshError';
|
|
21
22
|
}
|
|
22
23
|
}
|
|
23
|
-
function client(supabaseUrl,
|
|
24
|
-
return createClient(supabaseUrl,
|
|
24
|
+
function client(supabaseUrl, supabaseKey) {
|
|
25
|
+
return createClient(supabaseUrl, supabaseKey, {
|
|
25
26
|
auth: { persistSession: false, autoRefreshToken: false, detectSessionInUrl: false },
|
|
26
27
|
});
|
|
27
28
|
}
|
|
28
29
|
export const CliAuthGateway = {
|
|
29
|
-
async refresh(supabaseUrl,
|
|
30
|
-
|
|
30
|
+
async refresh(supabaseUrl, env, session) {
|
|
31
|
+
let supabaseKey;
|
|
32
|
+
try {
|
|
33
|
+
supabaseKey = await getSupabaseKey(env);
|
|
34
|
+
}
|
|
35
|
+
catch (error) {
|
|
36
|
+
// Never definitive: the refresh token was not even tried, so the stored
|
|
37
|
+
// session must survive for the next attempt.
|
|
38
|
+
throw new SessionRefreshError(`Failed to refresh session: ${error.message}`, false);
|
|
39
|
+
}
|
|
40
|
+
const sb = client(supabaseUrl, supabaseKey);
|
|
31
41
|
const { data, error } = await sb.auth.refreshSession({
|
|
32
42
|
refresh_token: session.refresh_token,
|
|
33
43
|
});
|
|
@@ -53,14 +63,14 @@ export const CliAuthGateway = {
|
|
|
53
63
|
user_id: u.id,
|
|
54
64
|
};
|
|
55
65
|
},
|
|
56
|
-
async signOut(supabaseUrl,
|
|
66
|
+
async signOut(supabaseUrl, env, session) {
|
|
57
67
|
// `scope: 'local'` clears this client's SDK state only — no server call,
|
|
58
68
|
// no revocation. supabase-js defaults to `scope: 'global'`, which revokes
|
|
59
69
|
// EVERY session for the user (browser, mobile, other CLIs), so
|
|
60
70
|
// `dcd logout` would kick the user out of the web app. We don't want
|
|
61
71
|
// that — logging out locally should be local.
|
|
62
|
-
const sb = client(supabaseUrl, supabaseAnonKey);
|
|
63
72
|
try {
|
|
73
|
+
const sb = client(supabaseUrl, await getSupabaseKey(env));
|
|
64
74
|
await sb.auth.setSession({
|
|
65
75
|
access_token: session.access_token,
|
|
66
76
|
refresh_token: session.refresh_token,
|
|
@@ -15,6 +15,7 @@
|
|
|
15
15
|
*/
|
|
16
16
|
import { createClient, REALTIME_SUBSCRIBE_STATES, } from '@supabase/supabase-js';
|
|
17
17
|
import { ENVIRONMENTS } from '../config/environments.js';
|
|
18
|
+
import { getSupabaseKey } from '../config/supabase-key.js';
|
|
18
19
|
export class RealtimeResultsGateway {
|
|
19
20
|
/**
|
|
20
21
|
* Open a realtime subscription to `results` changes for the given upload.
|
|
@@ -34,17 +35,28 @@ export class RealtimeResultsGateway {
|
|
|
34
35
|
onConnectionChange?.(next);
|
|
35
36
|
};
|
|
36
37
|
let client;
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
38
|
+
let channel;
|
|
39
|
+
let closed = false;
|
|
40
|
+
// The latest JWT, so a refresh that lands while the key is still being
|
|
41
|
+
// fetched is the one the socket joins with.
|
|
42
|
+
let token = accessToken;
|
|
43
|
+
// The publishable key comes from the API (dcd#1112), so the socket opens
|
|
44
|
+
// asynchronously. `subscribe` stays synchronous for the caller: until it
|
|
45
|
+
// connects, `isConnected` is false and the backstop poll covers the gap.
|
|
46
|
+
const ready = getSupabaseKey(env)
|
|
47
|
+
.then((supabaseKey) => {
|
|
48
|
+
if (closed)
|
|
49
|
+
return;
|
|
50
|
+
const sb = createClient(ENVIRONMENTS[env].supabase.url, supabaseKey, {
|
|
40
51
|
// Match SupabaseClientGateway / the frontend; no session persistence —
|
|
41
52
|
// we set the token explicitly below.
|
|
42
53
|
auth: { autoRefreshToken: false, persistSession: false },
|
|
43
54
|
realtime: { params: { eventsPerSecond: 10 } },
|
|
44
55
|
});
|
|
56
|
+
client = sb;
|
|
45
57
|
// Attach the user's JWT to the socket so RLS is enforced on the channel.
|
|
46
|
-
|
|
47
|
-
|
|
58
|
+
sb.realtime.setAuth(token);
|
|
59
|
+
channel = sb
|
|
48
60
|
.channel(`results-cli-${uploadId}`)
|
|
49
61
|
.on(
|
|
50
62
|
// The typings don't cover the postgres_changes overload cleanly.
|
|
@@ -73,29 +85,8 @@ export class RealtimeResultsGateway {
|
|
|
73
85
|
setConnected(false);
|
|
74
86
|
}
|
|
75
87
|
});
|
|
76
|
-
|
|
77
|
-
|
|
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
|
-
updateAccessToken(nextToken) {
|
|
90
|
-
// setAuth updates the join payload and pushes the token to joined
|
|
91
|
-
// channels. A failure only costs realtime; the backstop poll stays.
|
|
92
|
-
activeClient.realtime.setAuth(nextToken).catch((error) => {
|
|
93
|
-
dbg(`failed to update the socket's token: ${error instanceof Error ? error.message : String(error)}`);
|
|
94
|
-
});
|
|
95
|
-
},
|
|
96
|
-
};
|
|
97
|
-
}
|
|
98
|
-
catch (error) {
|
|
88
|
+
})
|
|
89
|
+
.catch((error) => {
|
|
99
90
|
dbg(`failed to subscribe: ${error instanceof Error ? error.message : String(error)}`);
|
|
100
91
|
// Best-effort cleanup of a half-built client, then degrade to polling.
|
|
101
92
|
try {
|
|
@@ -104,11 +95,34 @@ export class RealtimeResultsGateway {
|
|
|
104
95
|
catch {
|
|
105
96
|
/* ignore */
|
|
106
97
|
}
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
98
|
+
client = undefined;
|
|
99
|
+
channel = undefined;
|
|
100
|
+
});
|
|
101
|
+
return {
|
|
102
|
+
isConnected: () => connected,
|
|
103
|
+
async unsubscribe() {
|
|
104
|
+
closed = true;
|
|
105
|
+
setConnected(false);
|
|
106
|
+
await ready;
|
|
107
|
+
if (!client)
|
|
108
|
+
return;
|
|
109
|
+
try {
|
|
110
|
+
if (channel)
|
|
111
|
+
await client.removeChannel(channel);
|
|
112
|
+
await client.realtime.disconnect();
|
|
113
|
+
}
|
|
114
|
+
catch {
|
|
115
|
+
/* best effort — process is exiting anyway */
|
|
116
|
+
}
|
|
117
|
+
},
|
|
118
|
+
updateAccessToken(nextToken) {
|
|
119
|
+
token = nextToken;
|
|
120
|
+
// setAuth updates the join payload and pushes the token to joined
|
|
121
|
+
// channels. A failure only costs realtime; the backstop poll stays.
|
|
122
|
+
client?.realtime.setAuth(nextToken).catch((error) => {
|
|
123
|
+
dbg(`failed to update the socket's token: ${error instanceof Error ? error.message : String(error)}`);
|
|
124
|
+
});
|
|
125
|
+
},
|
|
126
|
+
};
|
|
113
127
|
}
|
|
114
128
|
}
|
|
@@ -9,7 +9,7 @@ export interface ResumableUploadSource {
|
|
|
9
9
|
export declare class SupabaseGateway {
|
|
10
10
|
/**
|
|
11
11
|
* Upload to Supabase using resumable uploads (TUS protocol)
|
|
12
|
-
* Uploads to staging location (uploads/{id}/) using
|
|
12
|
+
* Uploads to staging location (uploads/{id}/) using the publishable 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)
|
|
@@ -2,10 +2,11 @@ import { createReadStream } from 'node:fs';
|
|
|
2
2
|
import { createClient } from '@supabase/supabase-js';
|
|
3
3
|
import * as tus from 'tus-js-client';
|
|
4
4
|
import { ENVIRONMENTS } from '../config/environments.js';
|
|
5
|
+
import { getSupabaseKey, supabaseKeyHeaders } from '../config/supabase-key.js';
|
|
5
6
|
export class SupabaseGateway {
|
|
6
7
|
/**
|
|
7
8
|
* Upload to Supabase using resumable uploads (TUS protocol)
|
|
8
|
-
* Uploads to staging location (uploads/{id}/) using
|
|
9
|
+
* Uploads to staging location (uploads/{id}/) using the publishable key
|
|
9
10
|
* File is later moved to final location by API after finalization
|
|
10
11
|
* @param env - Environment (dev or prod)
|
|
11
12
|
* @param path - Staging storage path (uploads/{id}/file.ext)
|
|
@@ -15,7 +16,8 @@ export class SupabaseGateway {
|
|
|
15
16
|
* @returns Promise that resolves when upload completes
|
|
16
17
|
*/
|
|
17
18
|
static async uploadResumable(env, path, source, debug = false, onProgress) {
|
|
18
|
-
const {
|
|
19
|
+
const { projectRef } = ENVIRONMENTS[env].supabase;
|
|
20
|
+
const supabaseKey = await getSupabaseKey(env);
|
|
19
21
|
const storageUrl = `https://${projectRef}.storage.supabase.co`;
|
|
20
22
|
if (debug) {
|
|
21
23
|
console.log(`[DEBUG] Resumable upload starting...`);
|
|
@@ -35,9 +37,9 @@ export class SupabaseGateway {
|
|
|
35
37
|
// Retry configuration - will retry 5 times with increasing delays
|
|
36
38
|
retryDelays: [0, 3000, 5000, 10_000, 20_000],
|
|
37
39
|
// Authentication and headers
|
|
38
|
-
//
|
|
40
|
+
// Publishable key for staging uploads to uploads/* folder
|
|
39
41
|
headers: {
|
|
40
|
-
|
|
42
|
+
...supabaseKeyHeaders(supabaseKey),
|
|
41
43
|
'x-upsert': 'true', // Allow overwriting existing files
|
|
42
44
|
},
|
|
43
45
|
// Upload metadata
|
|
@@ -93,7 +95,7 @@ export class SupabaseGateway {
|
|
|
93
95
|
});
|
|
94
96
|
}
|
|
95
97
|
static async uploadToSignedUrl(env, path, token, file, debug = false) {
|
|
96
|
-
const { url: SUPABASE_URL
|
|
98
|
+
const { url: SUPABASE_URL } = ENVIRONMENTS[env].supabase;
|
|
97
99
|
if (debug) {
|
|
98
100
|
console.log(`[DEBUG] Supabase upload starting...`);
|
|
99
101
|
console.log(`[DEBUG] Supabase URL: ${SUPABASE_URL}`);
|
|
@@ -102,7 +104,7 @@ export class SupabaseGateway {
|
|
|
102
104
|
console.log(`[DEBUG] File type: ${file.type}`);
|
|
103
105
|
}
|
|
104
106
|
try {
|
|
105
|
-
const supabase = createClient(SUPABASE_URL,
|
|
107
|
+
const supabase = createClient(SUPABASE_URL, await getSupabaseKey(env));
|
|
106
108
|
const uploadToUrl = await supabase.storage
|
|
107
109
|
.from('organizations')
|
|
108
110
|
.uploadToSignedUrl(path, token, file);
|
package/dist/index.js
CHANGED
|
@@ -13,7 +13,7 @@ import { upgradeCommand } from './commands/upgrade.js';
|
|
|
13
13
|
import { uploadCommand } from './commands/upload.js';
|
|
14
14
|
import { whoamiCommand } from './commands/whoami.js';
|
|
15
15
|
import { telemetry } from './services/telemetry.service.js';
|
|
16
|
-
import { CliError, getCliVersion, logger } from './utils/cli.js';
|
|
16
|
+
import { CliError, getCliVersion, logger, wantsJson } from './utils/cli.js';
|
|
17
17
|
import { withoutHiddenArgs } from './utils/help.js';
|
|
18
18
|
// @clack/prompts ships the US spelling ("Canceled") for its built-in
|
|
19
19
|
// spinner/prompt cancellation message; align it with the British spelling
|
|
@@ -88,9 +88,12 @@ async function run() {
|
|
|
88
88
|
await telemetry.flush();
|
|
89
89
|
}
|
|
90
90
|
catch (error) {
|
|
91
|
+
// Under --json, stdout must stay a single JSON object: no help text, and
|
|
92
|
+
// the error itself is printed as `{ status: 'FAILED', error }`.
|
|
93
|
+
const json = wantsJson(rawArgs);
|
|
91
94
|
// citty throws CLIError (by name — the class isn't exported) for usage
|
|
92
95
|
// problems like unknown commands or missing required args.
|
|
93
|
-
if (error instanceof Error && error.name === 'CLIError') {
|
|
96
|
+
if (!json && error instanceof Error && error.name === 'CLIError') {
|
|
94
97
|
await showHelp(rawArgs);
|
|
95
98
|
}
|
|
96
99
|
const exitCode = error instanceof CliError ? error.exitCode : 1;
|
|
@@ -98,6 +101,7 @@ async function run() {
|
|
|
98
101
|
// synchronously, and exits with the given code.
|
|
99
102
|
logger.error(error instanceof Error ? error : String(error), {
|
|
100
103
|
exit: exitCode,
|
|
104
|
+
json,
|
|
101
105
|
});
|
|
102
106
|
}
|
|
103
107
|
}
|
|
@@ -17,6 +17,13 @@ export interface ReportDownloadOptions extends DownloadOptions {
|
|
|
17
17
|
junitPath?: string;
|
|
18
18
|
reportType: 'allure' | 'html' | 'html-detailed' | 'junit';
|
|
19
19
|
}
|
|
20
|
+
/** What one download attempt did; failures are warned about, not thrown. */
|
|
21
|
+
export interface DownloadResult {
|
|
22
|
+
error?: string;
|
|
23
|
+
ok: boolean;
|
|
24
|
+
path: string;
|
|
25
|
+
type: 'allure' | 'artifacts' | 'html' | 'junit';
|
|
26
|
+
}
|
|
20
27
|
/**
|
|
21
28
|
* Service for downloading test artifacts and reports
|
|
22
29
|
*/
|
|
@@ -24,21 +31,21 @@ export declare class ReportDownloadService {
|
|
|
24
31
|
/**
|
|
25
32
|
* Download test artifacts as a zip file
|
|
26
33
|
* @param options Download configuration
|
|
27
|
-
* @returns
|
|
34
|
+
* @returns What the download did
|
|
28
35
|
*/
|
|
29
|
-
downloadArtifacts(options: ArtifactsDownloadOptions): Promise<
|
|
36
|
+
downloadArtifacts(options: ArtifactsDownloadOptions): Promise<DownloadResult>;
|
|
30
37
|
/**
|
|
31
38
|
* Handle downloading reports based on the report type specified
|
|
32
39
|
* @param options Report download configuration
|
|
33
|
-
* @returns
|
|
40
|
+
* @returns What the download did, or undefined for an unknown report type
|
|
34
41
|
*/
|
|
35
|
-
downloadReports(options: ReportDownloadOptions): Promise<
|
|
42
|
+
downloadReports(options: ReportDownloadOptions): Promise<DownloadResult | undefined>;
|
|
36
43
|
/**
|
|
37
44
|
* Download a specific report type
|
|
38
45
|
* @param type Report type to download
|
|
39
46
|
* @param filePath Path where report should be saved
|
|
40
47
|
* @param options Download configuration
|
|
41
|
-
* @returns
|
|
48
|
+
* @returns What the download did
|
|
42
49
|
*/
|
|
43
50
|
private downloadReport;
|
|
44
51
|
/**
|
|
@@ -8,7 +8,7 @@ export class ReportDownloadService {
|
|
|
8
8
|
/**
|
|
9
9
|
* Download test artifacts as a zip file
|
|
10
10
|
* @param options Download configuration
|
|
11
|
-
* @returns
|
|
11
|
+
* @returns What the download did
|
|
12
12
|
*/
|
|
13
13
|
async downloadArtifacts(options) {
|
|
14
14
|
const { apiUrl, auth, uploadId, downloadType, artifactsPath = './artifacts.zip', debug = false, logger, warnLogger, } = options;
|
|
@@ -23,53 +23,53 @@ export class ReportDownloadService {
|
|
|
23
23
|
logger('\n');
|
|
24
24
|
logger(`Test artifacts have been downloaded to ${artifactsPath}`);
|
|
25
25
|
}
|
|
26
|
+
return { ok: true, path: artifactsPath, type: 'artifacts' };
|
|
26
27
|
}
|
|
27
28
|
catch (error) {
|
|
28
29
|
if (debug && logger) {
|
|
29
30
|
logger(`[DEBUG] Error downloading artifacts: ${error}`);
|
|
30
31
|
}
|
|
31
32
|
this.warnDownloadFailure(warnLogger, 'artifacts', 'No artifacts found for this upload. Make sure your tests generated results.', error);
|
|
33
|
+
return { error: errorText(error), ok: false, path: artifactsPath, type: 'artifacts' };
|
|
32
34
|
}
|
|
33
35
|
}
|
|
34
36
|
/**
|
|
35
37
|
* Handle downloading reports based on the report type specified
|
|
36
38
|
* @param options Report download configuration
|
|
37
|
-
* @returns
|
|
39
|
+
* @returns What the download did, or undefined for an unknown report type
|
|
38
40
|
*/
|
|
39
41
|
async downloadReports(options) {
|
|
40
42
|
const { reportType, junitPath, allurePath, htmlPath, warnLogger, ...downloadOptions } = options;
|
|
41
43
|
switch (reportType) {
|
|
42
44
|
case 'junit': {
|
|
43
45
|
const reportPath = path.resolve(process.cwd(), junitPath || 'report.xml');
|
|
44
|
-
|
|
46
|
+
return this.downloadReport('junit', reportPath, {
|
|
45
47
|
...downloadOptions,
|
|
46
48
|
warnLogger,
|
|
47
49
|
});
|
|
48
|
-
break;
|
|
49
50
|
}
|
|
50
51
|
case 'allure': {
|
|
51
52
|
const reportPath = path.resolve(process.cwd(), allurePath || 'report.html');
|
|
52
|
-
|
|
53
|
+
return this.downloadReport('allure', reportPath, {
|
|
53
54
|
...downloadOptions,
|
|
54
55
|
warnLogger,
|
|
55
56
|
});
|
|
56
|
-
break;
|
|
57
57
|
}
|
|
58
58
|
case 'html':
|
|
59
59
|
case 'html-detailed': {
|
|
60
60
|
// The HTML report is a ZIP (report.html plus its screenshots and
|
|
61
61
|
// assets), so the default name says so. An explicit path is kept as-is.
|
|
62
62
|
const htmlReportPath = path.resolve(process.cwd(), htmlPath || 'report.zip');
|
|
63
|
-
|
|
63
|
+
return this.downloadReport('html', htmlReportPath, {
|
|
64
64
|
...downloadOptions,
|
|
65
65
|
warnLogger,
|
|
66
66
|
});
|
|
67
|
-
break;
|
|
68
67
|
}
|
|
69
68
|
default: {
|
|
70
69
|
if (warnLogger) {
|
|
71
70
|
warnLogger(`Unknown report type: ${reportType}`);
|
|
72
71
|
}
|
|
72
|
+
return undefined;
|
|
73
73
|
}
|
|
74
74
|
}
|
|
75
75
|
}
|
|
@@ -78,7 +78,7 @@ export class ReportDownloadService {
|
|
|
78
78
|
* @param type Report type to download
|
|
79
79
|
* @param filePath Path where report should be saved
|
|
80
80
|
* @param options Download configuration
|
|
81
|
-
* @returns
|
|
81
|
+
* @returns What the download did
|
|
82
82
|
*/
|
|
83
83
|
async downloadReport(type, filePath, options) {
|
|
84
84
|
const { apiUrl, auth, uploadId, debug = false, logger, warnLogger } = options;
|
|
@@ -91,12 +91,14 @@ export class ReportDownloadService {
|
|
|
91
91
|
if (logger) {
|
|
92
92
|
logger(`${type.toUpperCase()} test report has been downloaded to ${filePath}`);
|
|
93
93
|
}
|
|
94
|
+
return { ok: true, path: filePath, type };
|
|
94
95
|
}
|
|
95
96
|
catch (error) {
|
|
96
97
|
if (debug && logger) {
|
|
97
98
|
logger(`[DEBUG] Error downloading ${type.toUpperCase()} report: ${error}`);
|
|
98
99
|
}
|
|
99
100
|
this.warnDownloadFailure(warnLogger, `${type.toUpperCase()} report`, `No ${type.toUpperCase()} reports found for this upload. Make sure your tests generated results.`, error);
|
|
101
|
+
return { error: errorText(error), ok: false, path: filePath, type };
|
|
100
102
|
}
|
|
101
103
|
}
|
|
102
104
|
/**
|
|
@@ -112,7 +114,7 @@ export class ReportDownloadService {
|
|
|
112
114
|
if (!warnLogger) {
|
|
113
115
|
return;
|
|
114
116
|
}
|
|
115
|
-
const errorMessage =
|
|
117
|
+
const errorMessage = errorText(error);
|
|
116
118
|
warnLogger(`Failed to download ${subject}: ${errorMessage}`);
|
|
117
119
|
if (errorMessage.includes('404')) {
|
|
118
120
|
warnLogger(notFoundHint);
|
|
@@ -125,3 +127,6 @@ export class ReportDownloadService {
|
|
|
125
127
|
}
|
|
126
128
|
}
|
|
127
129
|
}
|
|
130
|
+
function errorText(error) {
|
|
131
|
+
return error instanceof Error ? error.message : String(error);
|
|
132
|
+
}
|
package/dist/utils/auth.js
CHANGED
|
@@ -9,7 +9,6 @@
|
|
|
9
9
|
* poll), which re-checks with isAuthExpiring / refreshAuth below.
|
|
10
10
|
*/
|
|
11
11
|
import { closeSync, openSync, rmSync, statSync } from 'node:fs';
|
|
12
|
-
import { ENVIRONMENTS } from '../config/environments.js';
|
|
13
12
|
import { CliAuthGateway, SessionRefreshError, } from '../gateways/cli-auth-gateway.js';
|
|
14
13
|
import { telemetry } from '../services/telemetry.service.js';
|
|
15
14
|
import { CliError } from './cli.js';
|
|
@@ -144,10 +143,9 @@ async function refreshSessionWithLock(initial) {
|
|
|
144
143
|
if (session.expires_at > now + REFRESH_SKEW_SECONDS) {
|
|
145
144
|
return { config: current, session };
|
|
146
145
|
}
|
|
147
|
-
const { anonKey } = ENVIRONMENTS[current.env].supabase;
|
|
148
146
|
let refreshed;
|
|
149
147
|
try {
|
|
150
|
-
refreshed = await CliAuthGateway.refresh(current.supabase_url,
|
|
148
|
+
refreshed = await CliAuthGateway.refresh(current.supabase_url, current.env, session);
|
|
151
149
|
}
|
|
152
150
|
catch (error) {
|
|
153
151
|
if (error instanceof SessionRefreshError && error.definitive) {
|
package/dist/utils/ci.d.ts
CHANGED
|
@@ -10,6 +10,15 @@
|
|
|
10
10
|
* counts — providers set `CI=true`, but a bare presence check is the safe net.
|
|
11
11
|
*/
|
|
12
12
|
export declare function isCI(): boolean;
|
|
13
|
+
/**
|
|
14
|
+
* Whether a prompt drawn on `output` can be answered: stdin and `output` must
|
|
15
|
+
* both be terminals. Without that (a script, CI, a shell an agent runs) a clack
|
|
16
|
+
* prompt waits for a keypress that never comes. Unlike isCI(), CI env vars
|
|
17
|
+
* don't count: a terminal with `CI=true` exported can still answer a prompt.
|
|
18
|
+
*/
|
|
19
|
+
export declare function canPrompt(output?: {
|
|
20
|
+
isTTY?: boolean;
|
|
21
|
+
}): boolean;
|
|
13
22
|
/** Which DCD CI integration (if any) is wrapping this CLI invocation, and its version. */
|
|
14
23
|
export interface CiContext {
|
|
15
24
|
/** e.g. 'github' | 'bitrise' | 'bitbucket' | 'eas' | 'gitlab' | 'circleci'. */
|
package/dist/utils/ci.js
CHANGED
|
@@ -37,6 +37,15 @@ export function isCI() {
|
|
|
37
37
|
}
|
|
38
38
|
return !process.stdout.isTTY;
|
|
39
39
|
}
|
|
40
|
+
/**
|
|
41
|
+
* Whether a prompt drawn on `output` can be answered: stdin and `output` must
|
|
42
|
+
* both be terminals. Without that (a script, CI, a shell an agent runs) a clack
|
|
43
|
+
* prompt waits for a keypress that never comes. Unlike isCI(), CI env vars
|
|
44
|
+
* don't count: a terminal with `CI=true` exported can still answer a prompt.
|
|
45
|
+
*/
|
|
46
|
+
export function canPrompt(output = process.stdout) {
|
|
47
|
+
return process.stdin.isTTY === true && output.isTTY === true;
|
|
48
|
+
}
|
|
40
49
|
/** Infer the CI provider from the env vars each platform sets natively. */
|
|
41
50
|
function inferProvider() {
|
|
42
51
|
const env = process.env;
|
package/dist/utils/cli.d.ts
CHANGED
|
@@ -9,13 +9,20 @@ export declare class CliError extends Error {
|
|
|
9
9
|
export interface Logger {
|
|
10
10
|
log(message: string): void;
|
|
11
11
|
warn(message: string): void;
|
|
12
|
+
json(value: unknown): void;
|
|
12
13
|
error(message: Error | string, opts?: {
|
|
13
14
|
exit?: number;
|
|
14
15
|
json?: boolean;
|
|
15
16
|
}): never;
|
|
16
17
|
exit(code?: number): never;
|
|
18
|
+
fail(message: Error | string, exitCode: number): never;
|
|
17
19
|
}
|
|
18
20
|
export declare const logger: Logger;
|
|
21
|
+
/**
|
|
22
|
+
* Whether the invocation asked for JSON output. Read from raw argv so the
|
|
23
|
+
* top-level error handler can honour it even when citty never parsed args.
|
|
24
|
+
*/
|
|
25
|
+
export declare function wantsJson(rawArgs: string[]): boolean;
|
|
19
26
|
/**
|
|
20
27
|
* Validate that a string flag value is one of the allowed options.
|
|
21
28
|
* Returns the value untouched on success; throws CliError otherwise.
|
package/dist/utils/cli.js
CHANGED
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
* sites ported from oclif keep working.
|
|
8
8
|
*/
|
|
9
9
|
import { readFileSync } from 'node:fs';
|
|
10
|
+
import { stripVTControlCharacters } from 'node:util';
|
|
10
11
|
import { telemetry } from '../services/telemetry.service.js';
|
|
11
12
|
import { symbols } from './styling.js';
|
|
12
13
|
// Resolve version at runtime. The bun-compiled binary can't read package.json
|
|
@@ -57,13 +58,18 @@ export const logger = {
|
|
|
57
58
|
// eslint-disable-next-line no-console
|
|
58
59
|
console.warn(symbols.warning + ' ' + message);
|
|
59
60
|
},
|
|
61
|
+
json(value) {
|
|
62
|
+
// The one stdout write under --json; everything else is gated off.
|
|
63
|
+
// eslint-disable-next-line no-console
|
|
64
|
+
console.log(JSON.stringify(value, null, 2));
|
|
65
|
+
},
|
|
60
66
|
error(message, opts = {}) {
|
|
61
67
|
const text = message instanceof Error ? message.message : message;
|
|
62
68
|
if (opts.json) {
|
|
63
69
|
// When --json is active, emit failures as JSON on stdout so that
|
|
64
70
|
// scripts consuming the output never need to parse stderr too.
|
|
65
|
-
//
|
|
66
|
-
|
|
71
|
+
// citty colours some of its messages; JSON consumers want plain text.
|
|
72
|
+
logger.json({ status: 'FAILED', error: stripVTControlCharacters(text) });
|
|
67
73
|
}
|
|
68
74
|
else {
|
|
69
75
|
// The literal "Error:" prefix is important for tests and for grep-friendly
|
|
@@ -71,18 +77,29 @@ export const logger = {
|
|
|
71
77
|
// eslint-disable-next-line no-console
|
|
72
78
|
console.error(symbols.error + ' Error: ' + text);
|
|
73
79
|
}
|
|
80
|
+
return logger.fail(message, opts.exit ?? 1);
|
|
81
|
+
},
|
|
82
|
+
exit(code = 0) {
|
|
83
|
+
process.exit(code);
|
|
84
|
+
},
|
|
85
|
+
// Exit as a failure without printing anything — for --json paths that have
|
|
86
|
+
// already written their result object and must not print a second one.
|
|
87
|
+
fail(message, exitCode) {
|
|
74
88
|
// process.exit bypasses beforeExit, so async fetch in telemetry.flush()
|
|
75
89
|
// would be killed mid-flight. flushSync uses curl to ship synchronously
|
|
76
90
|
// before we exit; it's a no-op if telemetry never reached configure().
|
|
77
|
-
const exitCode = opts.exit ?? 1;
|
|
78
91
|
telemetry.recordCommandFailure({ error: message, exitCode });
|
|
79
92
|
telemetry.flushSync();
|
|
80
93
|
process.exit(exitCode);
|
|
81
94
|
},
|
|
82
|
-
exit(code = 0) {
|
|
83
|
-
process.exit(code);
|
|
84
|
-
},
|
|
85
95
|
};
|
|
96
|
+
/**
|
|
97
|
+
* Whether the invocation asked for JSON output. Read from raw argv so the
|
|
98
|
+
* top-level error handler can honour it even when citty never parsed args.
|
|
99
|
+
*/
|
|
100
|
+
export function wantsJson(rawArgs) {
|
|
101
|
+
return rawArgs.some((a) => a === '--json' || a === '--json=true');
|
|
102
|
+
}
|
|
86
103
|
/**
|
|
87
104
|
* Validate that a string flag value is one of the allowed options.
|
|
88
105
|
* Returns the value untouched on success; throws CliError otherwise.
|