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.
Files changed (2) hide show
  1. package/bin/qualty.js +296 -11
  2. 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
- return String(process.env.QUALTY_BASE_URL || process.env.QUALTY_API_URL || "").trim();
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 args.token || process.env.QUALTY_API_TOKEN || creds.api_token || "";
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 Bearer token used for auth",
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 = String(
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 from your app repo.`
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("When login expires, re-run qualty auth setup and qualty ci setup --set-github to refresh secrets.");
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] For GitHub Actions secrets, run:\n` +
2476
- ` qualty ci setup --project ${project.id} --profile "${profile}"`
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)) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "qualty",
3
- "version": "0.1.26",
3
+ "version": "0.1.27",
4
4
  "description": "Qualty CLI for localhost and CI test runs",
5
5
  "bin": {
6
6
  "qualty": "bin/qualty.js"