@quonfig/cli 0.0.1 → 0.0.4

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 (40) hide show
  1. package/README.md +157 -21
  2. package/bin/dev.js +5 -9
  3. package/bin/dev.mjs +6 -0
  4. package/dist/codegen/zod-introspection.js +13 -4
  5. package/dist/commands/init.d.ts +13 -0
  6. package/dist/commands/init.js +83 -0
  7. package/dist/commands/login.js +27 -6
  8. package/dist/commands/mcp.js +1 -1
  9. package/dist/commands/pull.d.ts +15 -0
  10. package/dist/commands/pull.js +176 -0
  11. package/dist/commands/serve.js +3 -1
  12. package/dist/commands/sync.d.ts +18 -0
  13. package/dist/commands/sync.js +142 -0
  14. package/dist/commands/whoami.js +4 -1
  15. package/dist/commands/workspace/bootstrap.d.ts +13 -0
  16. package/dist/commands/workspace/bootstrap.js +155 -0
  17. package/dist/init/init-workspace.d.ts +25 -0
  18. package/dist/init/init-workspace.js +162 -0
  19. package/dist/init/samples.d.ts +11 -0
  20. package/dist/init/samples.js +118 -0
  21. package/dist/init/schema.d.ts +9 -0
  22. package/dist/init/schema.js +334 -0
  23. package/dist/init/templates.d.ts +12 -0
  24. package/dist/init/templates.js +161 -0
  25. package/dist/util/coerce.js +2 -1
  26. package/dist/util/get-client.js +6 -44
  27. package/dist/util/get-valid-token.d.ts +5 -0
  28. package/dist/util/get-valid-token.js +44 -0
  29. package/dist/util/git-ops.d.ts +32 -0
  30. package/dist/util/git-ops.js +164 -0
  31. package/dist/util/gitea-api.d.ts +11 -0
  32. package/dist/util/gitea-api.js +45 -0
  33. package/dist/util/gitea-token-storage.d.ts +8 -0
  34. package/dist/util/gitea-token-storage.js +47 -0
  35. package/dist/util/token-storage.d.ts +1 -0
  36. package/dist/util/token-storage.js +30 -11
  37. package/dist/verify/validate.d.ts +14 -0
  38. package/dist/verify/validate.js +224 -19
  39. package/oclif.manifest.json +298 -2
  40. package/package.json +2 -2
@@ -1,8 +1,13 @@
1
1
  import * as fs from 'node:fs';
2
2
  import * as os from 'node:os';
3
3
  import * as path from 'node:path';
4
+ import { getDomain } from './domain-urls.js';
4
5
  const getQuonfigDir = (options) => options?.quonfigDir || path.join(os.homedir(), '.quonfig');
5
- const getTokenFile = (options) => path.join(getQuonfigDir(options), 'tokens.json');
6
+ const getTokenFile = (options) => {
7
+ const domain = getDomain();
8
+ const filename = domain !== 'quonfig.com' ? `tokens-${domain.replaceAll('.', '-')}.json` : 'tokens.json';
9
+ return path.join(getQuonfigDir(options), filename);
10
+ };
6
11
  const getConfigFile = (options) => path.join(getQuonfigDir(options), 'config');
7
12
  const ensureQuonfigDir = async (options) => {
8
13
  const quonfigDir = getQuonfigDir(options);
@@ -15,7 +20,10 @@ const ensureQuonfigDir = async (options) => {
15
20
  };
16
21
  export const saveTokens = async (tokens, options) => {
17
22
  await ensureQuonfigDir(options);
18
- await fs.promises.writeFile(getTokenFile(options), JSON.stringify(tokens, null, 2), 'utf8');
23
+ const targetFile = getTokenFile(options);
24
+ const tmpFile = `${targetFile}.tmp`;
25
+ await fs.promises.writeFile(tmpFile, JSON.stringify(tokens, null, 2), { encoding: 'utf8', mode: 0o600 });
26
+ await fs.promises.rename(tmpFile, targetFile);
19
27
  };
20
28
  export const loadTokens = async (options) => {
21
29
  try {
@@ -40,9 +48,16 @@ export const saveAuthConfig = async (config, options) => {
40
48
  if (profileData.workspaceName) {
41
49
  configContent += ` # ${profileData.workspaceName}`;
42
50
  }
43
- configContent += '\n\n';
51
+ configContent += '\n';
52
+ if (profileData.workspaceSlug) {
53
+ configContent += `workspace_slug = ${profileData.workspaceSlug}\n`;
54
+ }
55
+ configContent += '\n';
44
56
  }
45
- await fs.promises.writeFile(getConfigFile(options), configContent, 'utf8');
57
+ const configTarget = getConfigFile(options);
58
+ const configTmp = `${configTarget}.tmp`;
59
+ await fs.promises.writeFile(configTmp, configContent, { encoding: 'utf8', mode: 0o600 });
60
+ await fs.promises.rename(configTmp, configTarget);
46
61
  };
47
62
  export const loadAuthConfig = async (options) => {
48
63
  try {
@@ -55,16 +70,20 @@ export const loadAuthConfig = async (options) => {
55
70
  if (defaultMatch && defaultMatch[1]) {
56
71
  config.defaultProfile = defaultMatch[1].trim();
57
72
  }
58
- // Parse profiles
59
- const profileRegex = /\[profile\s+(\w+)]\s*\n\s*workspace\s*=\s*([^\s#]+)(?:\s*#\s*(.+))?/g;
73
+ // Parse profiles — each block starts with [profile name] and contains key=value lines
74
+ const profileBlockRegex = /\[profile\s+(\w+)]\s*\n((?:[^\[]*\n?)*)/g;
60
75
  let match;
61
- while ((match = profileRegex.exec(data)) !== null) {
76
+ while ((match = profileBlockRegex.exec(data)) !== null) {
62
77
  const profileName = match[1];
63
- const workspace = match[2];
64
- const workspaceName = match[3]?.trim();
78
+ const block = match[2];
79
+ const workspaceMatch = block.match(/workspace\s*=\s*([^\s#]+)(?:\s*#\s*(.+))?/);
80
+ const slugMatch = block.match(/workspace_slug\s*=\s*(\S+)/);
81
+ if (!workspaceMatch)
82
+ continue;
65
83
  config.profiles[profileName] = {
66
- workspace,
67
- workspaceName,
84
+ workspace: workspaceMatch[1],
85
+ workspaceName: workspaceMatch[2]?.trim(),
86
+ workspaceSlug: slugMatch?.[1]?.trim(),
68
87
  };
69
88
  }
70
89
  return Object.keys(config.profiles).length > 0 ? config : null;
@@ -26,10 +26,24 @@ export interface ValidationIssue {
26
26
  severity: Severity;
27
27
  suggestion?: string;
28
28
  }
29
+ export interface ValidationStats {
30
+ configs: number;
31
+ featureFlags: number;
32
+ segments: number;
33
+ logLevels: number;
34
+ schemas: number;
35
+ environmentOverrides: number;
36
+ rules: number;
37
+ segmentRefsChecked: number;
38
+ schemaRefsChecked: number;
39
+ envRefsChecked: number;
40
+ uniqueKeysVerified: number;
41
+ }
29
42
  export interface ValidationResult {
30
43
  issues: ValidationIssue[];
31
44
  filesChecked: number;
32
45
  valid: boolean;
46
+ stats: ValidationStats;
33
47
  }
34
48
  /**
35
49
  * Validate an entire workspace directory on disk.
@@ -192,11 +192,25 @@ import * as path from "node:path";
192
192
  export function validateWorkspace(workspaceDir) {
193
193
  const issues = [];
194
194
  let filesChecked = 0;
195
+ const stats = {
196
+ configs: 0,
197
+ featureFlags: 0,
198
+ segments: 0,
199
+ logLevels: 0,
200
+ schemas: 0,
201
+ environmentOverrides: 0,
202
+ rules: 0,
203
+ segmentRefsChecked: 0,
204
+ schemaRefsChecked: 0,
205
+ envRefsChecked: 0,
206
+ uniqueKeysVerified: 0,
207
+ };
195
208
  // Collect all configs for cross-reference checks
196
209
  const allConfigs = [];
197
210
  const allSchemaFiles = [];
198
211
  const segmentKeys = new Set();
199
212
  const schemaKeys = new Set();
213
+ const declaredEnvIds = new Set();
200
214
  // Check for unexpected top-level entries
201
215
  let entries;
202
216
  try {
@@ -211,6 +225,7 @@ export function validateWorkspace(workspaceDir) {
211
225
  }],
212
226
  filesChecked: 0,
213
227
  valid: false,
228
+ stats,
214
229
  };
215
230
  }
216
231
  for (const entry of entries) {
@@ -225,6 +240,50 @@ export function validateWorkspace(workspaceDir) {
225
240
  });
226
241
  }
227
242
  }
243
+ // Validate quonfig.json at workspace root
244
+ const quonfigPath = path.join(workspaceDir, "quonfig.json");
245
+ if (!fs.existsSync(quonfigPath)) {
246
+ issues.push({
247
+ file: "quonfig.json",
248
+ message: `quonfig.json is missing from workspace root`,
249
+ severity: "error",
250
+ suggestion: `Create quonfig.json with format: {"environments": ["production", "staging"]}`,
251
+ });
252
+ }
253
+ else {
254
+ let quonfigRaw;
255
+ try {
256
+ quonfigRaw = fs.readFileSync(quonfigPath, "utf-8");
257
+ }
258
+ catch (err) {
259
+ quonfigRaw = "";
260
+ issues.push({ file: "quonfig.json", message: `Cannot read quonfig.json: ${err.message}`, severity: "error" });
261
+ }
262
+ if (quonfigRaw) {
263
+ let quonfigParsed;
264
+ try {
265
+ quonfigParsed = JSON.parse(quonfigRaw);
266
+ }
267
+ catch (err) {
268
+ quonfigParsed = null;
269
+ issues.push({ file: "quonfig.json", message: `Invalid JSON: ${err.message}`, severity: "error" });
270
+ }
271
+ const quonfigResult = z.object({ environments: z.array(z.string()) }).safeParse(quonfigParsed);
272
+ if (!quonfigResult.success) {
273
+ issues.push({
274
+ file: "quonfig.json",
275
+ message: `quonfig.json must have an "environments" array of strings`,
276
+ severity: "error",
277
+ suggestion: `Format: {"environments": ["production", "staging"]}`,
278
+ });
279
+ }
280
+ else {
281
+ for (const envId of quonfigResult.data.environments) {
282
+ declaredEnvIds.add(envId);
283
+ }
284
+ }
285
+ }
286
+ }
228
287
  // First pass: parse all files and collect keys
229
288
  for (const dir of KNOWN_DIRS) {
230
289
  const dirPath = path.join(workspaceDir, dir);
@@ -272,6 +331,7 @@ export function validateWorkspace(workspaceDir) {
272
331
  schemaKeys.add(schemaKey);
273
332
  validateKey(schemaKey, relPath, issues);
274
333
  allSchemaFiles.push({ key: schemaKey, file: relPath });
334
+ stats.schemas++;
275
335
  continue;
276
336
  }
277
337
  // Validate against StoredConfigSchema
@@ -337,16 +397,33 @@ export function validateWorkspace(workspaceDir) {
337
397
  // Validate environment IDs are slugs, not UUIDs
338
398
  validateEnvironmentIds(config.environments, relPath, issues);
339
399
  // Validate rules
340
- validateRules(config.default.rules, relPath, "default", config.valueType, issues);
400
+ validateRules(config.default.rules, relPath, "default", config.valueType, issues, config.variants);
401
+ for (const env of config.environments) {
402
+ validateRules(env.rules, relPath, `environments[${env.id}]`, config.valueType, issues, config.variants);
403
+ }
404
+ // Count by type
405
+ switch (config.type) {
406
+ case "config":
407
+ stats.configs++;
408
+ break;
409
+ case "feature_flag":
410
+ stats.featureFlags++;
411
+ break;
412
+ case "segment":
413
+ stats.segments++;
414
+ break;
415
+ case "log_level":
416
+ stats.logLevels++;
417
+ break;
418
+ }
419
+ // Count rules and environment overrides
420
+ stats.rules += config.default.rules.length;
421
+ stats.environmentOverrides += config.environments.length;
341
422
  for (const env of config.environments) {
342
- validateRules(env.rules, relPath, `environments[${env.id}]`, config.valueType, issues);
423
+ stats.rules += env.rules.length;
343
424
  }
344
425
  // Collect for cross-reference
345
426
  allConfigs.push({ key: config.key, type: config.type, dir, file: relPath });
346
- // Check schemaKey reference (collected, validated in second pass)
347
- if (config.schemaKey) {
348
- // Will be checked after all files are parsed
349
- }
350
427
  }
351
428
  }
352
429
  // Second pass: referential integrity
@@ -382,6 +459,7 @@ export function validateWorkspace(workspaceDir) {
382
459
  // Check segment references in criteria
383
460
  const segRefs = collectSegmentReferences(config);
384
461
  for (const ref of segRefs) {
462
+ stats.segmentRefsChecked++;
385
463
  if (!segmentKeys.has(ref)) {
386
464
  issues.push({
387
465
  file: relPath,
@@ -392,13 +470,30 @@ export function validateWorkspace(workspaceDir) {
392
470
  }
393
471
  }
394
472
  // Check schemaKey references
395
- if (config.schemaKey && !schemaKeys.has(config.schemaKey)) {
396
- issues.push({
397
- file: relPath,
398
- message: `References schema "${config.schemaKey}" which does not exist`,
399
- severity: "error",
400
- suggestion: `Create schemas/${config.schemaKey}.json or schemas-protected/${config.schemaKey}.json, or remove schemaKey`,
401
- });
473
+ if (config.schemaKey) {
474
+ stats.schemaRefsChecked++;
475
+ if (!schemaKeys.has(config.schemaKey)) {
476
+ issues.push({
477
+ file: relPath,
478
+ message: `References schema "${config.schemaKey}" which does not exist`,
479
+ severity: "error",
480
+ suggestion: `Create schemas/${config.schemaKey}.json or schemas-protected/${config.schemaKey}.json, or remove schemaKey`,
481
+ });
482
+ }
483
+ }
484
+ // Check environment IDs referenced in config exist in quonfig.json
485
+ if (declaredEnvIds.size > 0) {
486
+ for (const env of config.environments) {
487
+ stats.envRefsChecked++;
488
+ if (!declaredEnvIds.has(env.id)) {
489
+ issues.push({
490
+ file: relPath,
491
+ message: `References environment "${env.id}" which is not declared in quonfig.json`,
492
+ severity: "error",
493
+ suggestion: `Add "${env.id}" to the environments array in quonfig.json, or remove this environment override`,
494
+ });
495
+ }
496
+ }
402
497
  }
403
498
  }
404
499
  }
@@ -435,8 +530,17 @@ export function validateWorkspace(workspaceDir) {
435
530
  });
436
531
  }
437
532
  }
533
+ stats.uniqueKeysVerified = keyToFiles.size + schemaKeyToFiles.size;
438
534
  const hasErrors = issues.some(i => i.severity === "error");
439
- return { issues, filesChecked, valid: !hasErrors };
535
+ return { issues, filesChecked, valid: !hasErrors, stats };
536
+ }
537
+ // Default empty stats for validateFileMap (which doesn't track detailed stats)
538
+ function emptyStats() {
539
+ return {
540
+ configs: 0, featureFlags: 0, segments: 0, logLevels: 0, schemas: 0,
541
+ environmentOverrides: 0, rules: 0, segmentRefsChecked: 0,
542
+ schemaRefsChecked: 0, envRefsChecked: 0, uniqueKeysVerified: 0,
543
+ };
440
544
  }
441
545
  // ── Validate from in-memory file map (for git hook) ─────────────────────
442
546
  /**
@@ -451,6 +555,35 @@ export function validateFileMap(files) {
451
555
  const allSchemaFiles = [];
452
556
  const allConfigs = [];
453
557
  const parsedConfigs = [];
558
+ const declaredEnvIds = new Set();
559
+ // Validate quonfig.json if it's included in the file map (it may not be in every commit)
560
+ const quonfigContent = files.get("quonfig.json");
561
+ if (quonfigContent !== undefined) {
562
+ let quonfigParsed;
563
+ try {
564
+ quonfigParsed = JSON.parse(quonfigContent);
565
+ }
566
+ catch (err) {
567
+ quonfigParsed = null;
568
+ issues.push({ file: "quonfig.json", message: `Invalid JSON: ${err.message}`, severity: "error" });
569
+ }
570
+ if (quonfigParsed !== null) {
571
+ const quonfigResult = z.object({ environments: z.array(z.string()) }).safeParse(quonfigParsed);
572
+ if (!quonfigResult.success) {
573
+ issues.push({
574
+ file: "quonfig.json",
575
+ message: `quonfig.json must have an "environments" array of strings`,
576
+ severity: "error",
577
+ suggestion: `Format: {"environments": ["production", "staging"]}`,
578
+ });
579
+ }
580
+ else {
581
+ for (const envId of quonfigResult.data.environments) {
582
+ declaredEnvIds.add(envId);
583
+ }
584
+ }
585
+ }
586
+ }
454
587
  for (const [relPath, content] of files) {
455
588
  const parts = relPath.split("/");
456
589
  if (parts.length < 2)
@@ -532,9 +665,9 @@ export function validateFileMap(files) {
532
665
  issues.push({ file: relPath, message: `Log level must have valueType "log_level", got "${config.valueType}"`, severity: "error" });
533
666
  }
534
667
  validateEnvironmentIds(config.environments, relPath, issues);
535
- validateRules(config.default.rules, relPath, "default", config.valueType, issues);
668
+ validateRules(config.default.rules, relPath, "default", config.valueType, issues, config.variants);
536
669
  for (const env of config.environments) {
537
- validateRules(env.rules, relPath, `environments[${env.id}]`, config.valueType, issues);
670
+ validateRules(env.rules, relPath, `environments[${env.id}]`, config.valueType, issues, config.variants);
538
671
  }
539
672
  allConfigs.push({ key: config.key, file: relPath });
540
673
  parsedConfigs.push({ relPath, config, dir });
@@ -560,6 +693,19 @@ export function validateFileMap(files) {
560
693
  suggestion: `Create schemas/${config.schemaKey}.json or schemas-protected/${config.schemaKey}.json, or remove schemaKey`,
561
694
  });
562
695
  }
696
+ // Check environment IDs referenced in config exist in quonfig.json
697
+ if (declaredEnvIds.size > 0) {
698
+ for (const env of config.environments) {
699
+ if (!declaredEnvIds.has(env.id)) {
700
+ issues.push({
701
+ file: relPath,
702
+ message: `References environment "${env.id}" which is not declared in quonfig.json`,
703
+ severity: "error",
704
+ suggestion: `Add "${env.id}" to the environments array in quonfig.json, or remove this environment override`,
705
+ });
706
+ }
707
+ }
708
+ }
563
709
  }
564
710
  // Duplicate keys
565
711
  const keyToFiles = new Map();
@@ -593,7 +739,7 @@ export function validateFileMap(files) {
593
739
  }
594
740
  }
595
741
  const hasErrors = issues.some(i => i.severity === "error");
596
- return { issues, filesChecked, valid: !hasErrors };
742
+ return { issues, filesChecked, valid: !hasErrors, stats: emptyStats() };
597
743
  }
598
744
  // ── Helpers ─────────────────────────────────────────────────────────────
599
745
  function validateEnvironmentIds(environments, file, issues) {
@@ -630,10 +776,12 @@ function validateKey(key, file, issues) {
630
776
  issues.push({ file, message: `Key contains slash or backslash`, severity: "error" });
631
777
  }
632
778
  }
633
- function validateRules(rules, file, context, expectedValueType, issues) {
779
+ function validateRules(rules, file, context, expectedValueType, issues, variants = []) {
634
780
  if (rules.length === 0) {
635
781
  issues.push({ file, message: `${context}: no rules defined`, severity: "warning" });
636
782
  }
783
+ // Pre-compute serialized variant values for matching
784
+ const variantValues = variants.map(v => JSON.stringify(v.value));
637
785
  for (let i = 0; i < rules.length; i++) {
638
786
  const rule = rules[i];
639
787
  const ruleCtx = `${context}.rules[${i}]`;
@@ -648,6 +796,18 @@ function validateRules(rules, file, context, expectedValueType, issues) {
648
796
  severity: "error",
649
797
  });
650
798
  }
799
+ // Check that value matches one of the defined variants
800
+ if (variants.length > 0) {
801
+ const serialized = JSON.stringify(rule.value);
802
+ if (!variantValues.includes(serialized)) {
803
+ issues.push({
804
+ file,
805
+ message: `${ruleCtx}: value does not match any defined variant`,
806
+ severity: "error",
807
+ suggestion: `Rule value must be one of the defined variants`,
808
+ });
809
+ }
810
+ }
651
811
  }
652
812
  else if (rule.value.type === "weighted_values") {
653
813
  // Validate weighted values
@@ -663,6 +823,18 @@ function validateRules(rules, file, context, expectedValueType, issues) {
663
823
  severity: "error",
664
824
  });
665
825
  }
826
+ // Check that each weighted value matches one of the defined variants
827
+ if (variants.length > 0) {
828
+ const serialized = JSON.stringify(wv.weightedValues[j].value);
829
+ if (!variantValues.includes(serialized)) {
830
+ issues.push({
831
+ file,
832
+ message: `${ruleCtx}.weightedValues[${j}]: value does not match any defined variant`,
833
+ severity: "error",
834
+ suggestion: `Weighted value must be one of the defined variants`,
835
+ });
836
+ }
837
+ }
666
838
  }
667
839
  }
668
840
  // Check criteria
@@ -720,14 +892,44 @@ function typeToDir(type) {
720
892
  // ── Output formatting ───────────────────────────────────────────────────
721
893
  export function formatResult(result) {
722
894
  const lines = [];
895
+ const s = result.stats;
723
896
  if (result.valid) {
724
- lines.push(`OK: ${result.filesChecked} files checked, no errors found.`);
897
+ lines.push(`OK: ${result.filesChecked} files checked, no errors found.\n`);
725
898
  }
726
899
  else {
727
900
  const errors = result.issues.filter(i => i.severity === "error");
728
901
  const warnings = result.issues.filter(i => i.severity === "warning");
729
902
  lines.push(`FAILED: ${errors.length} error(s), ${warnings.length} warning(s) in ${result.filesChecked} files.\n`);
730
903
  }
904
+ // Show what was checked
905
+ const fileCounts = [];
906
+ if (s.configs > 0)
907
+ fileCounts.push(`${s.configs} configs`);
908
+ if (s.featureFlags > 0)
909
+ fileCounts.push(`${s.featureFlags} feature flags`);
910
+ if (s.segments > 0)
911
+ fileCounts.push(`${s.segments} segments`);
912
+ if (s.logLevels > 0)
913
+ fileCounts.push(`${s.logLevels} log levels`);
914
+ if (s.schemas > 0)
915
+ fileCounts.push(`${s.schemas} schemas`);
916
+ if (fileCounts.length > 0) {
917
+ lines.push(` Files: ${fileCounts.join(", ")}`);
918
+ }
919
+ const checks = [];
920
+ checks.push(`${result.filesChecked} JSON parsed`);
921
+ checks.push(`${result.filesChecked} schema-validated`);
922
+ checks.push(`${s.uniqueKeysVerified} unique keys`);
923
+ checks.push(`${s.rules} rules checked`);
924
+ if (s.environmentOverrides > 0)
925
+ checks.push(`${s.environmentOverrides} env overrides`);
926
+ if (s.segmentRefsChecked > 0)
927
+ checks.push(`${s.segmentRefsChecked} segment refs`);
928
+ if (s.schemaRefsChecked > 0)
929
+ checks.push(`${s.schemaRefsChecked} schema refs`);
930
+ if (s.envRefsChecked > 0)
931
+ checks.push(`${s.envRefsChecked} env refs`);
932
+ lines.push(` Checks: ${checks.join(", ")}`);
731
933
  // Group issues by file
732
934
  const byFile = new Map();
733
935
  for (const issue of result.issues) {
@@ -735,6 +937,9 @@ export function formatResult(result) {
735
937
  existing.push(issue);
736
938
  byFile.set(issue.file, existing);
737
939
  }
940
+ if (byFile.size > 0) {
941
+ lines.push("");
942
+ }
738
943
  for (const [file, fileIssues] of byFile) {
739
944
  lines.push(` ${file}:`);
740
945
  for (const issue of fileIssues) {