qualty 0.1.26 → 0.1.27
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/bin/qualty.js +296 -11
- package/package.json +1 -1
package/bin/qualty.js
CHANGED
|
@@ -30,6 +30,7 @@ import {
|
|
|
30
30
|
const DEFAULT_QUALTY_API_URL = "https://qualty-api-production.up.railway.app";
|
|
31
31
|
|
|
32
32
|
const PROJECT_CONFIG_FILENAME = ".qualty.json";
|
|
33
|
+
const DOTENV_FILENAME = ".env";
|
|
33
34
|
const QUALTY_SHARED_CREDS_DIR = join(homedir(), ".qualty");
|
|
34
35
|
const QUALTY_SHARED_CREDS_PATH = join(QUALTY_SHARED_CREDS_DIR, "credentials");
|
|
35
36
|
|
|
@@ -48,6 +49,47 @@ function getProjectConfigWritePath() {
|
|
|
48
49
|
return findProjectConfigPath() || join(process.cwd(), PROJECT_CONFIG_FILENAME);
|
|
49
50
|
}
|
|
50
51
|
|
|
52
|
+
function findProjectDotenvPath() {
|
|
53
|
+
let current = process.cwd();
|
|
54
|
+
for (;;) {
|
|
55
|
+
const candidate = join(current, DOTENV_FILENAME);
|
|
56
|
+
if (existsSync(candidate)) return candidate;
|
|
57
|
+
const parent = dirname(current);
|
|
58
|
+
if (parent === current) return null;
|
|
59
|
+
current = parent;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function parseDotenvText(text) {
|
|
64
|
+
const out = {};
|
|
65
|
+
for (const raw of String(text || "").split("\n")) {
|
|
66
|
+
const line = raw.trim();
|
|
67
|
+
if (!line || line.startsWith("#") || !line.includes("=")) continue;
|
|
68
|
+
const eq = line.indexOf("=");
|
|
69
|
+
const key = line.slice(0, eq).trim();
|
|
70
|
+
let value = line.slice(eq + 1).trim();
|
|
71
|
+
if (
|
|
72
|
+
(value.startsWith('"') && value.endsWith('"'))
|
|
73
|
+
|| (value.startsWith("'") && value.endsWith("'"))
|
|
74
|
+
) {
|
|
75
|
+
value = value.slice(1, -1);
|
|
76
|
+
}
|
|
77
|
+
if (key) out[key] = value;
|
|
78
|
+
}
|
|
79
|
+
return out;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Project .env walked up from cwd (same resolution as Qualty MCP). */
|
|
83
|
+
function loadProjectDotenv() {
|
|
84
|
+
const path = findProjectDotenvPath();
|
|
85
|
+
if (!path) return {};
|
|
86
|
+
try {
|
|
87
|
+
return parseDotenvText(readFileSync(path, "utf8"));
|
|
88
|
+
} catch {
|
|
89
|
+
return {};
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
51
93
|
function readSharedCredentials() {
|
|
52
94
|
if (!existsSync(QUALTY_SHARED_CREDS_PATH)) return {};
|
|
53
95
|
try {
|
|
@@ -124,7 +166,14 @@ function normalizeApiUrl(raw, { label = "API URL" } = {}) {
|
|
|
124
166
|
}
|
|
125
167
|
|
|
126
168
|
function qualtyBaseUrlFromEnv() {
|
|
127
|
-
|
|
169
|
+
const dotenv = loadProjectDotenv();
|
|
170
|
+
return String(
|
|
171
|
+
dotenv.QUALTY_BASE_URL
|
|
172
|
+
|| dotenv.QUALTY_API_URL
|
|
173
|
+
|| process.env.QUALTY_BASE_URL
|
|
174
|
+
|| process.env.QUALTY_API_URL
|
|
175
|
+
|| ""
|
|
176
|
+
).trim();
|
|
128
177
|
}
|
|
129
178
|
|
|
130
179
|
function resolveApiUrl(args = {}) {
|
|
@@ -142,8 +191,14 @@ function resolveApiUrl(args = {}) {
|
|
|
142
191
|
}
|
|
143
192
|
|
|
144
193
|
function resolveToken(args = {}) {
|
|
194
|
+
if (args.token) return String(args.token).trim();
|
|
195
|
+
const dotenv = loadProjectDotenv();
|
|
196
|
+
const fromDotenv = String(dotenv.QUALTY_API_TOKEN || "").trim();
|
|
197
|
+
if (fromDotenv) return fromDotenv;
|
|
198
|
+
const fromProcessEnv = String(process.env.QUALTY_API_TOKEN || "").trim();
|
|
199
|
+
if (fromProcessEnv) return fromProcessEnv;
|
|
145
200
|
const creds = readSharedCredentials();
|
|
146
|
-
return
|
|
201
|
+
return String(creds.api_token || "").trim();
|
|
147
202
|
}
|
|
148
203
|
|
|
149
204
|
function resolveOrgId(args = {}) {
|
|
@@ -187,6 +242,10 @@ function usage() {
|
|
|
187
242
|
"Usage:",
|
|
188
243
|
" qualty setup [--token <bearer-token>] [--org <org-id>]",
|
|
189
244
|
" qualty auth setup [--project <project-id>] [--profile <profile-name>] [--headed]",
|
|
245
|
+
" qualty auth export [--project <project-id>] [--profile <profile-name>] [--org <org-id>]",
|
|
246
|
+
" [--profiles <name1,name2>] [--repo <owner/repo>]",
|
|
247
|
+
" [--output <path>] [--no-file] [--copy] [--set-secret | --set-github]",
|
|
248
|
+
" Push login profile state to GitHub only (not API token, org, or project vars).",
|
|
190
249
|
" qualty ci setup [--project <project-id>] [--profile <profile-name>] [--org <org-id>]",
|
|
191
250
|
" [--all-profiles] [--profiles <name1,name2>]",
|
|
192
251
|
" [--suite-id <suite-uuid>] [--ids <id1,id2>] bundle login profiles required by tests",
|
|
@@ -206,7 +265,7 @@ function usage() {
|
|
|
206
265
|
" qualty resolve --project <project-id> [--suite-id <suite-id>] [--ids <id1,id2>] [--json] [--token <bearer-token>]",
|
|
207
266
|
"",
|
|
208
267
|
"Env vars:",
|
|
209
|
-
" QUALTY_API_TOKEN
|
|
268
|
+
" QUALTY_API_TOKEN Project .env first (same as MCP), then process env, then ~/.qualty/credentials",
|
|
210
269
|
" QUALTY_BASE_URL API base URL when --url is not set (default: production)",
|
|
211
270
|
" QUALTY_LOCAL_CONCURRENCY Local parallel workers for --local runs (default: 4)",
|
|
212
271
|
"",
|
|
@@ -393,9 +452,7 @@ async function runSetup(args) {
|
|
|
393
452
|
|
|
394
453
|
try {
|
|
395
454
|
const currentOrgId = String(args.org || projectCfg.default_org_id || "");
|
|
396
|
-
const currentToken =
|
|
397
|
-
args.token || process.env.QUALTY_API_TOKEN || sharedCreds.api_token || ""
|
|
398
|
-
);
|
|
455
|
+
const currentToken = resolveToken(args);
|
|
399
456
|
|
|
400
457
|
let token = "";
|
|
401
458
|
if (args.token) {
|
|
@@ -674,6 +731,37 @@ function syncGithubCiConfig({
|
|
|
674
731
|
return results;
|
|
675
732
|
}
|
|
676
733
|
|
|
734
|
+
/**
|
|
735
|
+
* Push only auth bundle secrets and auth-related repository variables (no API token or org/project).
|
|
736
|
+
*/
|
|
737
|
+
function syncGithubAuthProfileOnly({ repo, profile, profileNames, authShards }) {
|
|
738
|
+
const results = [];
|
|
739
|
+
const record = (kind, name, outcome) => {
|
|
740
|
+
results.push({ kind, name, ...outcome });
|
|
741
|
+
};
|
|
742
|
+
|
|
743
|
+
for (const shard of authShards || []) {
|
|
744
|
+
record("secret", shard.secretName, setGithubSecret(shard.secretName, shard.payload, { repo }));
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
const names = (profileNames || []).filter(Boolean);
|
|
748
|
+
if (names.length > 0) {
|
|
749
|
+
record("variable", AUTH_PROFILES_VARIABLE, setGithubVariable(AUTH_PROFILES_VARIABLE, names.join(","), { repo }));
|
|
750
|
+
}
|
|
751
|
+
if (profile) {
|
|
752
|
+
record("variable", "QUALTY_AUTH_PROFILE", setGithubVariable("QUALTY_AUTH_PROFILE", profile, { repo }));
|
|
753
|
+
}
|
|
754
|
+
if ((authShards || []).length > 1) {
|
|
755
|
+
record(
|
|
756
|
+
"variable",
|
|
757
|
+
AUTH_SHARDS_VARIABLE,
|
|
758
|
+
setGithubVariable(AUTH_SHARDS_VARIABLE, String(authShards.length), { repo })
|
|
759
|
+
);
|
|
760
|
+
}
|
|
761
|
+
|
|
762
|
+
return results;
|
|
763
|
+
}
|
|
764
|
+
|
|
677
765
|
function printGithubSyncResults(results, { repo }) {
|
|
678
766
|
const target = repo ? ` (repo ${repo})` : "";
|
|
679
767
|
const printGroup = (title, rows) => {
|
|
@@ -1033,7 +1121,7 @@ async function runAuthRestoreCi(args) {
|
|
|
1033
1121
|
const payloads = collectAuthShardPayloadsFromEnv(process.env);
|
|
1034
1122
|
if (payloads.length === 0) {
|
|
1035
1123
|
throw new Error(
|
|
1036
|
-
`Missing ${AUTH_SECRET_PRIMARY}. Run qualty ci setup --set-github
|
|
1124
|
+
`Missing ${AUTH_SECRET_PRIMARY}. Run qualty ci setup --set-github (first time) or qualty auth export --set-github (profile refresh).`
|
|
1037
1125
|
);
|
|
1038
1126
|
}
|
|
1039
1127
|
|
|
@@ -1052,6 +1140,197 @@ async function runAuthRestoreCi(args) {
|
|
|
1052
1140
|
console.log(`Restored ${restored.length} login profile(s).`);
|
|
1053
1141
|
}
|
|
1054
1142
|
|
|
1143
|
+
async function runAuthExport(args) {
|
|
1144
|
+
if (
|
|
1145
|
+
parseBoolean(args["all-profiles"], false) ||
|
|
1146
|
+
String(args["suite-id"] || args.suite || "").trim() ||
|
|
1147
|
+
String(args.ids || "")
|
|
1148
|
+
.split(",")
|
|
1149
|
+
.map((part) => part.trim())
|
|
1150
|
+
.filter(Boolean).length > 0
|
|
1151
|
+
) {
|
|
1152
|
+
throw new Error(
|
|
1153
|
+
"qualty auth export only updates login profile secrets. For full CI bootstrap use: qualty ci setup"
|
|
1154
|
+
);
|
|
1155
|
+
}
|
|
1156
|
+
|
|
1157
|
+
const profilesList = String(args.profiles || "")
|
|
1158
|
+
.split(",")
|
|
1159
|
+
.map((part) => part.trim())
|
|
1160
|
+
.filter(Boolean);
|
|
1161
|
+
const { orgId, projectId, profile, apiUrl, configPath } = resolveCiSetupConfig(args, {
|
|
1162
|
+
requireProfile: profilesList.length === 0,
|
|
1163
|
+
});
|
|
1164
|
+
const token = resolveToken(args);
|
|
1165
|
+
|
|
1166
|
+
const profileEntries = await resolveCiSetupProfileEntries({
|
|
1167
|
+
orgId,
|
|
1168
|
+
projectId,
|
|
1169
|
+
profile,
|
|
1170
|
+
allProfiles: false,
|
|
1171
|
+
profilesList,
|
|
1172
|
+
suiteId: "",
|
|
1173
|
+
explicitIds: [],
|
|
1174
|
+
apiUrl,
|
|
1175
|
+
token,
|
|
1176
|
+
});
|
|
1177
|
+
const profileNames = profileEntries.map((entry) => entry.name);
|
|
1178
|
+
const primaryProfile = profile || profileNames[0] || "";
|
|
1179
|
+
|
|
1180
|
+
const manifest = buildAuthBundleManifest({ orgId, projectId, profileEntries });
|
|
1181
|
+
const authShards = shardAuthBundleManifest(manifest);
|
|
1182
|
+
const primaryShard = authShards[0];
|
|
1183
|
+
|
|
1184
|
+
const setGithubFlag =
|
|
1185
|
+
parseBoolean(args["set-github"], false) || parseBoolean(args["set-secret"], false);
|
|
1186
|
+
const noFile = parseBoolean(args["no-file"], false);
|
|
1187
|
+
const outputPath = noFile
|
|
1188
|
+
? String(args.output || "").trim()
|
|
1189
|
+
: String(args.output || "").trim() ||
|
|
1190
|
+
ciSetupAuthBundleOutputPath(
|
|
1191
|
+
localAuthStatePath({ orgId, projectId, profile: profileEntries[0].localProfileName })
|
|
1192
|
+
);
|
|
1193
|
+
const copyToClipboardFlag = parseBoolean(args.copy, false);
|
|
1194
|
+
const githubRepo = String(args.repo || "").trim();
|
|
1195
|
+
|
|
1196
|
+
if (outputPath) {
|
|
1197
|
+
writeFileSync(outputPath, primaryShard.payload, "utf8");
|
|
1198
|
+
try {
|
|
1199
|
+
chmodSync(outputPath, 0o600);
|
|
1200
|
+
} catch {
|
|
1201
|
+
// ignore platforms that do not support chmod
|
|
1202
|
+
}
|
|
1203
|
+
for (let index = 1; index < authShards.length; index += 1) {
|
|
1204
|
+
const shard = authShards[index];
|
|
1205
|
+
const shardPath = outputPath.replace(/(\.[^./]+)?$/, `_${index + 1}$1`);
|
|
1206
|
+
writeFileSync(shardPath, shard.payload, "utf8");
|
|
1207
|
+
try {
|
|
1208
|
+
chmodSync(shardPath, 0o600);
|
|
1209
|
+
} catch {
|
|
1210
|
+
// ignore
|
|
1211
|
+
}
|
|
1212
|
+
}
|
|
1213
|
+
}
|
|
1214
|
+
|
|
1215
|
+
const skipPayloadPrint = Boolean(outputPath || copyToClipboardFlag || setGithubFlag);
|
|
1216
|
+
const totalSecretBytes = authShards.reduce(
|
|
1217
|
+
(sum, shard) => sum + Buffer.byteLength(shard.payload, "utf8"),
|
|
1218
|
+
0
|
|
1219
|
+
);
|
|
1220
|
+
|
|
1221
|
+
// eslint-disable-next-line no-console
|
|
1222
|
+
console.log("");
|
|
1223
|
+
// eslint-disable-next-line no-console
|
|
1224
|
+
console.log("=== Qualty → GitHub Actions (login profiles only) ===");
|
|
1225
|
+
// eslint-disable-next-line no-console
|
|
1226
|
+
console.log("");
|
|
1227
|
+
// eslint-disable-next-line no-console
|
|
1228
|
+
console.log(
|
|
1229
|
+
`Auth bundle: ${profileNames.length} profile(s), ${authShards.length} secret shard(s), ${totalSecretBytes} bytes total`
|
|
1230
|
+
);
|
|
1231
|
+
for (const shard of authShards) {
|
|
1232
|
+
// eslint-disable-next-line no-console
|
|
1233
|
+
console.log(
|
|
1234
|
+
` - ${shard.secretName}: ${shard.profileNames.join(", ")} (${Buffer.byteLength(shard.payload, "utf8")} bytes)`
|
|
1235
|
+
);
|
|
1236
|
+
}
|
|
1237
|
+
// eslint-disable-next-line no-console
|
|
1238
|
+
console.log("");
|
|
1239
|
+
if (outputPath) {
|
|
1240
|
+
// eslint-disable-next-line no-console
|
|
1241
|
+
console.log(`Wrote ${AUTH_SECRET_PRIMARY} to ${outputPath}`);
|
|
1242
|
+
for (let index = 1; index < authShards.length; index += 1) {
|
|
1243
|
+
const shard = authShards[index];
|
|
1244
|
+
const shardPath = outputPath.replace(/(\.[^./]+)?$/, `_${index + 1}$1`);
|
|
1245
|
+
// eslint-disable-next-line no-console
|
|
1246
|
+
console.log(`Wrote ${shard.secretName} to ${shardPath}`);
|
|
1247
|
+
}
|
|
1248
|
+
// eslint-disable-next-line no-console
|
|
1249
|
+
console.log("");
|
|
1250
|
+
}
|
|
1251
|
+
if (setGithubFlag) {
|
|
1252
|
+
const syncResults = syncGithubAuthProfileOnly({
|
|
1253
|
+
repo: githubRepo,
|
|
1254
|
+
profile: primaryProfile,
|
|
1255
|
+
profileNames,
|
|
1256
|
+
authShards,
|
|
1257
|
+
});
|
|
1258
|
+
printGithubSyncResults(syncResults, { repo: githubRepo });
|
|
1259
|
+
const failedAuth = syncResults.filter((row) => row.kind === "secret" && !row.ok);
|
|
1260
|
+
if (failedAuth.length > 0 && outputPath) {
|
|
1261
|
+
// eslint-disable-next-line no-console
|
|
1262
|
+
console.log(" Manual auth secrets:");
|
|
1263
|
+
authShards.forEach((shard, index) => {
|
|
1264
|
+
const shardPath =
|
|
1265
|
+
index === 0 ? outputPath : outputPath.replace(/(\.[^./]+)?$/, `_${index + 1}$1`);
|
|
1266
|
+
// eslint-disable-next-line no-console
|
|
1267
|
+
console.log(` gh secret set ${shard.secretName} < ${shardPath}`);
|
|
1268
|
+
});
|
|
1269
|
+
// eslint-disable-next-line no-console
|
|
1270
|
+
console.log("");
|
|
1271
|
+
}
|
|
1272
|
+
} else {
|
|
1273
|
+
// eslint-disable-next-line no-console
|
|
1274
|
+
console.log("GitHub secrets (login state only — does not update QUALTY_API_TOKEN or org/project):");
|
|
1275
|
+
// eslint-disable-next-line no-console
|
|
1276
|
+
console.log("");
|
|
1277
|
+
for (const shard of authShards) {
|
|
1278
|
+
// eslint-disable-next-line no-console
|
|
1279
|
+
console.log("---");
|
|
1280
|
+
// eslint-disable-next-line no-console
|
|
1281
|
+
console.log(`Name: ${shard.secretName}`);
|
|
1282
|
+
if (skipPayloadPrint) {
|
|
1283
|
+
// eslint-disable-next-line no-console
|
|
1284
|
+
console.log(
|
|
1285
|
+
`Value: (see file above${copyToClipboardFlag && shard.secretName === AUTH_SECRET_PRIMARY ? " or clipboard" : ""})`
|
|
1286
|
+
);
|
|
1287
|
+
} else {
|
|
1288
|
+
// eslint-disable-next-line no-console
|
|
1289
|
+
console.log("Value:");
|
|
1290
|
+
// eslint-disable-next-line no-console
|
|
1291
|
+
console.log(shard.payload);
|
|
1292
|
+
}
|
|
1293
|
+
}
|
|
1294
|
+
// eslint-disable-next-line no-console
|
|
1295
|
+
console.log("---");
|
|
1296
|
+
// eslint-disable-next-line no-console
|
|
1297
|
+
console.log("");
|
|
1298
|
+
// eslint-disable-next-line no-console
|
|
1299
|
+
console.log(
|
|
1300
|
+
"Re-run with --set-github from this repo's git root to push via gh (after qualty ci setup for first-time CI)."
|
|
1301
|
+
);
|
|
1302
|
+
// eslint-disable-next-line no-console
|
|
1303
|
+
console.log("");
|
|
1304
|
+
}
|
|
1305
|
+
if (copyToClipboardFlag) {
|
|
1306
|
+
if (copyToClipboard(primaryShard.payload)) {
|
|
1307
|
+
// eslint-disable-next-line no-console
|
|
1308
|
+
console.log(`✓ Copied ${AUTH_SECRET_PRIMARY} to clipboard.`);
|
|
1309
|
+
} else {
|
|
1310
|
+
// eslint-disable-next-line no-console
|
|
1311
|
+
console.log("Could not copy to clipboard on this machine.");
|
|
1312
|
+
}
|
|
1313
|
+
// eslint-disable-next-line no-console
|
|
1314
|
+
console.log("");
|
|
1315
|
+
}
|
|
1316
|
+
for (const entry of profileEntries) {
|
|
1317
|
+
// eslint-disable-next-line no-console
|
|
1318
|
+
console.log(`Profile "${entry.name}" → ${entry.statePath}`);
|
|
1319
|
+
}
|
|
1320
|
+
if (configPath) {
|
|
1321
|
+
// eslint-disable-next-line no-console
|
|
1322
|
+
console.log(`Config: ${configPath}`);
|
|
1323
|
+
}
|
|
1324
|
+
// eslint-disable-next-line no-console
|
|
1325
|
+
console.log("");
|
|
1326
|
+
// eslint-disable-next-line no-console
|
|
1327
|
+
console.log(
|
|
1328
|
+
"When login expires, re-run qualty auth setup then qualty auth export --set-github (or qualty ci setup for a full refresh)."
|
|
1329
|
+
);
|
|
1330
|
+
// eslint-disable-next-line no-console
|
|
1331
|
+
console.log("");
|
|
1332
|
+
}
|
|
1333
|
+
|
|
1055
1334
|
async function runCiSetup(args) {
|
|
1056
1335
|
const {
|
|
1057
1336
|
orgId,
|
|
@@ -1359,7 +1638,9 @@ async function runCiSetup(args) {
|
|
|
1359
1638
|
// eslint-disable-next-line no-console
|
|
1360
1639
|
console.log("");
|
|
1361
1640
|
// eslint-disable-next-line no-console
|
|
1362
|
-
console.log(
|
|
1641
|
+
console.log(
|
|
1642
|
+
"When login expires, re-run qualty auth setup and qualty auth export --set-github (or qualty ci setup --set-github for a full refresh)."
|
|
1643
|
+
);
|
|
1363
1644
|
// eslint-disable-next-line no-console
|
|
1364
1645
|
console.log("");
|
|
1365
1646
|
}
|
|
@@ -2472,8 +2753,8 @@ async function runAuthSetup(args) {
|
|
|
2472
2753
|
console.log("");
|
|
2473
2754
|
// eslint-disable-next-line no-console
|
|
2474
2755
|
console.log(
|
|
2475
|
-
`[qualty][auth]
|
|
2476
|
-
` qualty
|
|
2756
|
+
`[qualty][auth] Push this profile to GitHub Actions:\n` +
|
|
2757
|
+
` qualty auth export --project ${project.id} --profile "${profile}" --set-github`
|
|
2477
2758
|
);
|
|
2478
2759
|
} finally {
|
|
2479
2760
|
rl.close();
|
|
@@ -2509,11 +2790,15 @@ async function main() {
|
|
|
2509
2790
|
await runAuthSetup(args);
|
|
2510
2791
|
return;
|
|
2511
2792
|
}
|
|
2793
|
+
if (sub === "export") {
|
|
2794
|
+
await runAuthExport(args);
|
|
2795
|
+
return;
|
|
2796
|
+
}
|
|
2512
2797
|
if (sub === "restore-ci") {
|
|
2513
2798
|
await runAuthRestoreCi(args);
|
|
2514
2799
|
return;
|
|
2515
2800
|
}
|
|
2516
|
-
throw new Error("Unknown auth subcommand. Use: qualty auth setup | qualty auth restore-ci");
|
|
2801
|
+
throw new Error("Unknown auth subcommand. Use: qualty auth setup | qualty auth export | qualty auth restore-ci");
|
|
2517
2802
|
}
|
|
2518
2803
|
if (command === "run") {
|
|
2519
2804
|
if (parseBoolean(args.local, false)) {
|