@quonfig/cli 0.0.2 → 0.0.5

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 (39) hide show
  1. package/README.md +306 -29
  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/config-schema.d.ts +10 -0
  6. package/dist/commands/config-schema.js +165 -0
  7. package/dist/commands/create.js +22 -1
  8. package/dist/commands/info.js +17 -2
  9. package/dist/commands/login.js +27 -6
  10. package/dist/commands/mcp.js +1 -1
  11. package/dist/commands/pull.d.ts +15 -0
  12. package/dist/commands/pull.js +191 -0
  13. package/dist/commands/serve.js +3 -1
  14. package/dist/commands/set-default.js +78 -15
  15. package/dist/commands/set-rollout.d.ts +19 -0
  16. package/dist/commands/set-rollout.js +260 -0
  17. package/dist/commands/sync.d.ts +18 -0
  18. package/dist/commands/sync.js +142 -0
  19. package/dist/commands/whoami.js +4 -1
  20. package/dist/commands/workspace/bootstrap.d.ts +13 -0
  21. package/dist/commands/workspace/bootstrap.js +155 -0
  22. package/dist/init/init-workspace.js +4 -4
  23. package/dist/init/templates.js +1 -1
  24. package/dist/util/coerce.js +2 -1
  25. package/dist/util/get-client.js +6 -44
  26. package/dist/util/get-valid-token.d.ts +5 -0
  27. package/dist/util/get-valid-token.js +44 -0
  28. package/dist/util/git-ops.d.ts +32 -0
  29. package/dist/util/git-ops.js +164 -0
  30. package/dist/util/gitea-api.d.ts +11 -0
  31. package/dist/util/gitea-api.js +45 -0
  32. package/dist/util/gitea-token-storage.d.ts +8 -0
  33. package/dist/util/gitea-token-storage.js +47 -0
  34. package/dist/util/token-storage.d.ts +1 -0
  35. package/dist/util/token-storage.js +24 -10
  36. package/dist/verify/validate.d.ts +1 -0
  37. package/dist/verify/validate.js +136 -6
  38. package/oclif.manifest.json +404 -9
  39. package/package.json +2 -2
@@ -0,0 +1,165 @@
1
+ import { Flags } from '@oclif/core';
2
+ import { BaseCommand } from '../index.js';
3
+ import { storedConfigJsonSchema } from '../init/schema.js';
4
+ const REFERENCE = `
5
+ ────────────────────────────────────────────────────────────
6
+ FILE LAYOUT
7
+ ────────────────────────────────────────────────────────────
8
+ Config files live in your workspace (clone with: qfg pull --dir ./my-config):
9
+
10
+ feature-flags/ Boolean feature flags (on/off, targeting, percentage rollouts)
11
+ configs/ Typed config values (string, int, double, json, string-list)
12
+ segments/ User segments for targeting rules
13
+ log-levels/ Dynamic log-level configs
14
+
15
+ ────────────────────────────────────────────────────────────
16
+ RULE EVALUATION
17
+ ────────────────────────────────────────────────────────────
18
+ Rules are evaluated in ORDER — the FIRST matching rule wins.
19
+ Put specific rules (user lists, segments) BEFORE catch-all rules.
20
+ criteria: [] means "match everyone" (unconditional / catch-all)
21
+ criteria: [..] multiple entries in one rule are ANDed together
22
+
23
+ ────────────────────────────────────────────────────────────
24
+ EXAMPLE: complex targeting + rollout in production
25
+ ────────────────────────────────────────────────────────────
26
+ Four rules in order:
27
+ 1. Always ON for users in an allowlist
28
+ 2. Always ON for users in the "beta" segment
29
+ 3. Always OFF for users in the "holdout" segment
30
+ 4. 50/50 split for everyone else
31
+
32
+ {
33
+ "key": "my.feature.flag",
34
+ "type": "feature_flag",
35
+ "valueType": "bool",
36
+ "default": {
37
+ "rules": [{ "criteria": [], "value": { "type": "bool", "value": false } }]
38
+ },
39
+ "environments": [{
40
+ "id": "production",
41
+ "rules": [
42
+ {
43
+ "criteria": [{
44
+ "operator": "PROP_IS_ONE_OF",
45
+ "propertyName": "user.key",
46
+ "valueToMatch": { "type": "string_list", "value": ["user-123", "user-456"] }
47
+ }],
48
+ "value": { "type": "bool", "value": true }
49
+ },
50
+ {
51
+ "criteria": [{
52
+ "operator": "IN_SEG",
53
+ "valueToMatch": { "type": "string", "value": "beta" }
54
+ }],
55
+ "value": { "type": "bool", "value": true }
56
+ },
57
+ {
58
+ "criteria": [{
59
+ "operator": "IN_SEG",
60
+ "valueToMatch": { "type": "string", "value": "holdout" }
61
+ }],
62
+ "value": { "type": "bool", "value": false }
63
+ },
64
+ {
65
+ "criteria": [],
66
+ "value": {
67
+ "type": "weighted_values",
68
+ "value": {
69
+ "hashByPropertyName": "user.key",
70
+ "weightedValues": [
71
+ { "value": { "type": "bool", "value": true }, "weight": 50000 },
72
+ { "value": { "type": "bool", "value": false }, "weight": 50000 }
73
+ ]
74
+ }
75
+ }
76
+ }
77
+ ]
78
+ }]
79
+ }
80
+
81
+ ────────────────────────────────────────────────────────────
82
+ CRITERION FIELDS
83
+ ────────────────────────────────────────────────────────────
84
+ Each criterion object has:
85
+ operator (required) — see operator table below
86
+ propertyName (required for PROP_* operators) — user property, e.g. "user.key", "user.plan"
87
+ valueToMatch (required for all except ALWAYS_TRUE) — typed value to compare against
88
+
89
+ ────────────────────────────────────────────────────────────
90
+ OPERATOR REFERENCE
91
+ ────────────────────────────────────────────────────────────
92
+ Operator valueToMatch type Notes
93
+ ────────────────────────────────────────────────────────
94
+ ALWAYS_TRUE (none) Unconditional match; use for catch-all rules
95
+ PROP_IS_ONE_OF string_list propertyName value is in the list
96
+ PROP_IS_NOT_ONE_OF string_list propertyName value is NOT in the list
97
+ PROP_STARTS_WITH_ONE_OF string_list string prefix match
98
+ PROP_DOES_NOT_START_WITH_ONE_OF string_list
99
+ PROP_ENDS_WITH_ONE_OF string_list string suffix match
100
+ PROP_DOES_NOT_END_WITH_ONE_OF string_list
101
+ PROP_CONTAINS_ONE_OF string_list substring match
102
+ PROP_DOES_NOT_CONTAIN_ONE_OF string_list
103
+ PROP_LESS_THAN int or double numeric comparison
104
+ PROP_LESS_THAN_OR_EQUAL int or double
105
+ PROP_GREATER_THAN int or double
106
+ PROP_GREATER_THAN_OR_EQUAL int or double
107
+ PROP_BEFORE string (ISO 8601) date/time comparison
108
+ PROP_AFTER string (ISO 8601)
109
+ PROP_MATCHES string regex match
110
+ PROP_DOES_NOT_MATCH string
111
+ IN_SEG string value = segment key (no propertyName needed)
112
+ NOT_IN_SEG string value = segment key (no propertyName needed)
113
+ IN_INT_RANGE int_range value = {min, max}
114
+ LOOKUP_KEY_IN string_list matches if SDK lookup key is in the list
115
+ LOOKUP_KEY_NOT_IN string_list
116
+
117
+ valueToMatch format: { "type": "<type>", "value": <value> }
118
+ string_list → { "type": "string_list", "value": ["a", "b"] }
119
+ string → { "type": "string", "value": "beta" }
120
+ int → { "type": "int", "value": 42 }
121
+ double → { "type": "double", "value": 3.14 }
122
+
123
+ ────────────────────────────────────────────────────────────
124
+ PERCENTAGE ROLLOUT (weighted_values)
125
+ ────────────────────────────────────────────────────────────
126
+ Weights are integers out of 100,000 (not percentages):
127
+ 5%=5000 10%=10000 20%=20000 50%=50000 100%=100000
128
+ All weights in one rule MUST sum to exactly 100,000.
129
+
130
+ hashByPropertyName controls the bucket assignment property (default: "user.key").
131
+
132
+ ────────────────────────────────────────────────────────────
133
+ RELATED COMMANDS
134
+ ────────────────────────────────────────────────────────────
135
+ qfg create my.flag --type boolean-flag create a new flag
136
+ qfg set-default my.flag --environment prod ... set a scalar fallback value
137
+ qfg set-rollout my.flag --environment prod ... set a simple percentage rollout
138
+ qfg pull --dir ./config clone workspace to edit JSON directly
139
+ qfg verify ./config validate all JSON files before pushing
140
+ qfg info my.flag inspect current values and rules
141
+ qfg config-schema --json-schema print the machine-readable JSON Schema
142
+ `.trim();
143
+ export default class ConfigSchema extends BaseCommand {
144
+ static description = 'Print the config file format reference: rules engine, operators, targeting criteria, segments, and weighted rollouts. ' +
145
+ 'Use --json-schema for the machine-readable JSON Schema document.';
146
+ static examples = [
147
+ '<%= config.bin %> <%= command.id %> # human-readable operator reference + examples',
148
+ '<%= config.bin %> <%= command.id %> --json-schema # full JSON Schema document (copy into your editor)',
149
+ ];
150
+ static flags = {
151
+ 'json-schema': Flags.boolean({
152
+ default: false,
153
+ description: 'Output the full JSON Schema document for config files instead of the human-readable reference',
154
+ }),
155
+ };
156
+ async run() {
157
+ const { flags } = await this.parse(ConfigSchema);
158
+ if (flags['json-schema']) {
159
+ const schema = storedConfigJsonSchema();
160
+ this.log(JSON.stringify(schema, null, 2));
161
+ return schema;
162
+ }
163
+ this.log(REFERENCE);
164
+ }
165
+ }
@@ -11,7 +11,26 @@ export default class Create extends APICommand {
11
11
  static args = {
12
12
  name: Args.string({ description: 'name for your new item (e.g. my.new.flag)', required: true }),
13
13
  };
14
- static description = 'Create a new item in Quonfig';
14
+ static description = `Create a new feature flag, config value, or other item.
15
+
16
+ Use --type to specify the kind of item:
17
+ boolean-flag On/off feature flag (sendToClientSdk defaults to true; best for gradual rollouts)
18
+ string A string configuration value
19
+ int An integer configuration value
20
+ double A floating-point configuration value
21
+ string-list A comma-separated list of strings
22
+ json An arbitrary JSON blob
23
+ boolean A plain boolean (not a feature flag)
24
+
25
+ This sets the global default value. Override per-environment with:
26
+ qfg set-default my.flag --environment production --value true
27
+
28
+ For a percentage rollout (gradual rollout / A/B test / canary deploy), use:
29
+ qfg set-rollout my.flag --environment production --true-percent 20
30
+
31
+ Or edit the JSON config file directly for complex targeting rules:
32
+ qfg config-schema # full operator reference + examples
33
+ qfg pull --dir ./config # clone workspace, then edit JSON and git push`;
15
34
  static examples = [
16
35
  '<%= config.bin %> <%= command.id %> my.new.flag --type boolean-flag',
17
36
  '<%= config.bin %> <%= command.id %> my.new.flag --type boolean-flag --value=true',
@@ -19,6 +38,8 @@ export default class Create extends APICommand {
19
38
  '<%= config.bin %> <%= command.id %> my.new.string --type string --value="hello world" --secret',
20
39
  '<%= config.bin %> <%= command.id %> my.new.string --type string --env-var=MY_ENV_VAR_NAME',
21
40
  '<%= config.bin %> <%= command.id %> my.new.string --type json --value="{\\"key\\": \\"value\\"}"',
41
+ '# After creating a flag, set a 20% rollout in production:',
42
+ '<%= config.bin %> set-rollout my.new.flag --environment production --true-percent 20',
22
43
  ];
23
44
  static flags = {
24
45
  confidential: Flags.boolean({ default: false, description: 'mark the value as confidential' }),
@@ -7,8 +7,23 @@ import isInteractive from '../util/is-interactive.js';
7
7
  import nameArg from '../util/name-arg.js';
8
8
  export default class Info extends APICommand {
9
9
  static args = { ...nameArg };
10
- static description = 'Show details about the provided config/feature-flag/etc.';
11
- static examples = ['<%= config.bin %> <%= command.id %> my.config.name'];
10
+ static description = `Show current values, rules, and evaluation stats for a flag or config.
11
+
12
+ Output shows the value for each environment:
13
+ - A simple value (true / false / "hello") means a single unconditional rule.
14
+ - "[see rules]" means the environment has targeting rules or a percentage rollout.
15
+ - "[inherit]" means the environment falls back to the Default value.
16
+
17
+ Percentage rollouts are displayed as "20.0% true, 80.0% false".
18
+ Evaluation counts for the past 24 hours are included by default.
19
+
20
+ Related commands:
21
+ qfg set-default my.flag --environment production --value true # set a scalar fallback
22
+ qfg set-rollout my.flag --environment production --true-percent 20 # set a % rollout`;
23
+ static examples = [
24
+ '<%= config.bin %> <%= command.id %> my.config.name',
25
+ '<%= config.bin %> <%= command.id %> my.config.name --exclude-evaluations # skip 24h stats',
26
+ ];
12
27
  static flags = {
13
28
  'exclude-evaluations': Flags.boolean({ default: false, description: 'Exclude evaluation data' }),
14
29
  };
@@ -1,4 +1,5 @@
1
1
  import { Flags } from '@oclif/core';
2
+ import { select } from '@inquirer/prompts';
2
3
  import { BaseCommand } from '../index.js';
3
4
  import { decodeJWT, pollForToken, requestDeviceCode } from '../util/oauth-client.js';
4
5
  import { getApiUrl } from '../util/domain-urls.js';
@@ -53,6 +54,8 @@ export default class Login extends BaseCommand {
53
54
  // Resolve org_id → workspace UUID via the API
54
55
  let workspaceId;
55
56
  let workspaceName;
57
+ let workspaceSlug;
58
+ let organizationName;
56
59
  try {
57
60
  const apiUrl = getApiUrl();
58
61
  this.verboseLog('Resolving workspace...', { apiUrl, orgId });
@@ -66,14 +69,28 @@ export default class Login extends BaseCommand {
66
69
  });
67
70
  if (res.ok) {
68
71
  const body = await res.json();
69
- const workspaces = body.json ?? body;
70
- const match = Array.isArray(workspaces)
71
- ? workspaces.find((w) => w.workosOrgId === orgId) ?? workspaces[0]
72
- : undefined;
72
+ const allWorkspaces = (body.json ?? body);
73
+ const orgWorkspaces = Array.isArray(allWorkspaces)
74
+ ? allWorkspaces.filter((w) => w.workosOrgId === orgId)
75
+ : [];
76
+ const candidates = orgWorkspaces.length > 0 ? orgWorkspaces : (Array.isArray(allWorkspaces) ? allWorkspaces : []);
77
+ let match;
78
+ if (candidates.length === 1) {
79
+ match = candidates[0];
80
+ }
81
+ else if (candidates.length > 1) {
82
+ const chosen = await select({
83
+ choices: candidates.map((w) => ({ name: w.workspaceSlug, value: w.workspaceId })),
84
+ message: 'Select workspace:',
85
+ });
86
+ match = candidates.find((w) => w.workspaceId === chosen);
87
+ }
73
88
  if (match) {
74
89
  workspaceId = match.workspaceId;
75
- workspaceName = match.organizationName;
76
- this.verboseLog('Resolved workspace', { workspaceId, workspaceName });
90
+ workspaceName = match.workspaceSlug;
91
+ workspaceSlug = match.workspaceSlug;
92
+ organizationName = match.organizationName;
93
+ this.verboseLog('Resolved workspace', { workspaceId, workspaceName, workspaceSlug, organizationName });
77
94
  }
78
95
  }
79
96
  else {
@@ -95,6 +112,7 @@ export default class Login extends BaseCommand {
95
112
  [profileName]: {
96
113
  workspace: workspaceId || orgId || 'unknown',
97
114
  workspaceName: workspaceName || (orgId ? `org:${orgId}` : undefined),
115
+ workspaceSlug,
98
116
  },
99
117
  },
100
118
  });
@@ -108,6 +126,9 @@ export default class Login extends BaseCommand {
108
126
  }
109
127
  if (workspaceName) {
110
128
  this.log(`Workspace: ${workspaceName} (${workspaceId})`);
129
+ if (organizationName) {
130
+ this.log(`Organization: ${organizationName}`);
131
+ }
111
132
  }
112
133
  else if (orgId) {
113
134
  this.log(`Organization: ${orgId}`);
@@ -12,7 +12,7 @@ export default class Mcp extends BaseCommand {
12
12
  static examples = [
13
13
  '<%= config.bin %> <%= command.id %>',
14
14
  '<%= config.bin %> <%= command.id %> --editor cursor',
15
- '<%= config.bin %> <%= command.id %> --url http://local-app.goatsofquonfig.com:3003/api/v1/mcp',
15
+ '<%= config.bin %> <%= command.id %> --url http://local-app.quonfig-staging.com:3003/api/v1/mcp',
16
16
  ];
17
17
  static flags = {
18
18
  ...BaseCommand.baseFlags,
@@ -0,0 +1,15 @@
1
+ import type { JsonObj } from '../result.js';
2
+ import { BaseCommand } from '../index.js';
3
+ export default class Pull extends BaseCommand {
4
+ static description: string;
5
+ static examples: string[];
6
+ static flags: {
7
+ dir: import("@oclif/core/lib/interfaces/parser.js").OptionFlag<string | undefined, import("@oclif/core/lib/interfaces/parser.js").CustomOptions>;
8
+ workspace: import("@oclif/core/lib/interfaces/parser.js").OptionFlag<string | undefined, import("@oclif/core/lib/interfaces/parser.js").CustomOptions>;
9
+ };
10
+ run(): Promise<JsonObj | void>;
11
+ private resolveRepoUrl;
12
+ private refreshAndGetUrl;
13
+ private looksLike401;
14
+ private maybeWriteQuonfigDir;
15
+ }
@@ -0,0 +1,191 @@
1
+ import * as path from 'node:path';
2
+ import * as fs from 'node:fs';
3
+ import { Flags } from '@oclif/core';
4
+ import { BaseCommand } from '../index.js';
5
+ import { getActiveProfile, loadAuthConfig } from '../util/token-storage.js';
6
+ import { loadGiteaToken, isGiteaTokenExpired, saveGiteaToken } from '../util/gitea-token-storage.js';
7
+ import { mintAndStoreGiteaReadToken, mintGiteaToken } from '../util/gitea-api.js';
8
+ import { isGitRepo, getRemoteUrl, isWorkingTreeClean, gitFetch, gitSetRemote, canFastForward, hasDivergedFromRemote, gitMergeFfOnly, gitClone, displayUrl, } from '../util/git-ops.js';
9
+ export default class Pull extends BaseCommand {
10
+ static description = `Clone or update a local copy of your workspace config files.
11
+
12
+ Use this when you need to edit flag JSON directly — for complex targeting rules,
13
+ multi-rule configs, or anything beyond a single scalar value.
14
+
15
+ For the config file format, operator reference, and examples:
16
+ qfg config-schema # human-readable reference
17
+ qfg config-schema --json-schema # machine-readable JSON Schema
18
+
19
+ After editing files:
20
+ qfg verify <dir> # validate JSON before pushing
21
+ git -C <dir> add -A && git -C <dir> commit -m "feat: ..." && git -C <dir> push
22
+
23
+ CLI shortcuts (no JSON editing needed for simple cases):
24
+ qfg set-rollout my.flag --environment production --true-percent 20
25
+ qfg set-default my.flag --environment production --value true`;
26
+ static examples = [
27
+ '<%= config.bin %> pull --dir ./our-config',
28
+ '<%= config.bin %> pull # uses QUONFIG_DIR env var',
29
+ ];
30
+ static flags = {
31
+ dir: Flags.string({
32
+ description: 'Local directory to clone/update (defaults to QUONFIG_DIR env var)',
33
+ }),
34
+ workspace: Flags.string({
35
+ description: 'Workspace ID (defaults to active profile)',
36
+ }),
37
+ };
38
+ async run() {
39
+ const { flags } = await this.parse(Pull);
40
+ // Resolve target directory
41
+ const dir = flags.dir || process.env.QUONFIG_DIR;
42
+ if (!dir) {
43
+ return this.err('No directory specified. Use --dir <path> or set QUONFIG_DIR.');
44
+ }
45
+ const resolvedDir = path.resolve(dir);
46
+ // Resolve workspace ID
47
+ let workspaceId = flags.workspace;
48
+ if (!workspaceId) {
49
+ const authConfig = await loadAuthConfig();
50
+ if (!authConfig) {
51
+ return this.err('Not logged in. Please run `qfg login` first.');
52
+ }
53
+ const activeProfile = getActiveProfile();
54
+ const profile = authConfig.profiles[activeProfile] || authConfig.profiles[authConfig.defaultProfile || 'default'];
55
+ if (!profile) {
56
+ return this.err('No active profile found. Please run `qfg login` first.');
57
+ }
58
+ workspaceId = profile.workspace;
59
+ }
60
+ this.verboseLog('Pull', { workspaceId, dir: resolvedDir });
61
+ // Get or mint Gitea read token
62
+ const repoUrl = await this.resolveRepoUrl(workspaceId);
63
+ // Perform git operation
64
+ const isRepo = await isGitRepo(resolvedDir);
65
+ if (!isRepo) {
66
+ // Clone fresh
67
+ this.log(`Cloning workspace into ${resolvedDir}...`);
68
+ try {
69
+ await gitClone(repoUrl, resolvedDir);
70
+ }
71
+ catch (err) {
72
+ // On 401, refresh token and retry once
73
+ if (this.looksLike401(err)) {
74
+ this.verboseLog('Pull', 'Got 401, refreshing Gitea token...');
75
+ const freshUrl = await this.refreshAndGetUrl(workspaceId);
76
+ await gitClone(freshUrl, resolvedDir);
77
+ }
78
+ else {
79
+ throw err;
80
+ }
81
+ }
82
+ this.log(`Cloned successfully into ${resolvedDir}`);
83
+ }
84
+ else {
85
+ // Existing repo — check remote
86
+ const existingRemote = await getRemoteUrl(resolvedDir);
87
+ const expectedUrlBase = stripAuth(repoUrl);
88
+ if (existingRemote && stripAuth(existingRemote) !== expectedUrlBase) {
89
+ return this.err(`Directory has a different git remote:\n existing: ${displayUrl(existingRemote)}\n expected: ${expectedUrlBase}\n\nResolve manually or use a different --dir.`);
90
+ }
91
+ // Update the remote URL to use current (possibly refreshed) token
92
+ await gitSetRemote(resolvedDir, repoUrl);
93
+ // Check working tree
94
+ const clean = await isWorkingTreeClean(resolvedDir);
95
+ if (!clean) {
96
+ return this.err('Local changes present — commit, stash, or run `git reset --hard origin/main` to discard.');
97
+ }
98
+ // Fetch
99
+ this.log('Fetching from remote...');
100
+ try {
101
+ await gitFetch(resolvedDir);
102
+ }
103
+ catch (err) {
104
+ if (this.looksLike401(err)) {
105
+ this.verboseLog('Pull', 'Got 401 on fetch, refreshing Gitea token...');
106
+ const freshUrl = await this.refreshAndGetUrl(workspaceId);
107
+ await gitSetRemote(resolvedDir, freshUrl);
108
+ await gitFetch(resolvedDir);
109
+ }
110
+ else {
111
+ throw err;
112
+ }
113
+ }
114
+ const ff = await canFastForward(resolvedDir);
115
+ const diverged = !ff && (await hasDivergedFromRemote(resolvedDir));
116
+ if (diverged) {
117
+ return this.err('Local commits diverge from remote — resolve manually.');
118
+ }
119
+ if (!ff) {
120
+ this.log('Already up to date.');
121
+ return { dir: resolvedDir, newCommits: [], workspaceId };
122
+ }
123
+ // Merge
124
+ const newCommits = await gitMergeFfOnly(resolvedDir);
125
+ if (newCommits.length > 0) {
126
+ this.log(`Pulled ${newCommits.length} new commit(s):`);
127
+ for (const msg of newCommits) {
128
+ this.log(` - ${msg}`);
129
+ }
130
+ }
131
+ else {
132
+ this.log('Updated successfully.');
133
+ }
134
+ }
135
+ // Write QUONFIG_DIR to ~/.quonfig/config if not set
136
+ await this.maybeWriteQuonfigDir(resolvedDir);
137
+ return { dir: resolvedDir, workspaceId };
138
+ }
139
+ async resolveRepoUrl(workspaceId) {
140
+ let entry = await loadGiteaToken(workspaceId);
141
+ if (!entry || isGiteaTokenExpired(entry)) {
142
+ this.verboseLog('Pull', 'Minting new Gitea read token...');
143
+ entry = await mintAndStoreGiteaReadToken(workspaceId);
144
+ }
145
+ return entry.repoUrl;
146
+ }
147
+ async refreshAndGetUrl(workspaceId) {
148
+ const data = await mintGiteaToken(workspaceId, 'read', 'pull');
149
+ const entry = { token: data.token, repoUrl: data.repoUrl, expiresAt: data.expiresAt };
150
+ await saveGiteaToken(workspaceId, entry);
151
+ return entry.repoUrl;
152
+ }
153
+ looksLike401(err) {
154
+ const msg = err instanceof Error ? err.message : String(err);
155
+ return msg.includes('401') || msg.toLowerCase().includes('authentication failed');
156
+ }
157
+ async maybeWriteQuonfigDir(dir) {
158
+ if (process.env.QUONFIG_DIR)
159
+ return; // already set via env
160
+ const os = await import('node:os');
161
+ const pathMod = await import('node:path');
162
+ const configFilePath = pathMod.join(os.homedir(), '.quonfig', 'config');
163
+ try {
164
+ const existing = await fs.promises.readFile(configFilePath, 'utf8').catch(() => '');
165
+ if (existing.includes('QUONFIG_DIR') || existing.includes('quonfig_dir'))
166
+ return;
167
+ const line = `\nQUONFIG_DIR = ${dir}\n`;
168
+ await fs.promises.appendFile(configFilePath, line, 'utf8');
169
+ this.verboseLog('Pull', `Wrote QUONFIG_DIR to ${configFilePath}`);
170
+ }
171
+ catch {
172
+ // Non-fatal
173
+ }
174
+ }
175
+ }
176
+ /**
177
+ * Strip auth credentials from a git URL for comparison purposes.
178
+ * Converts `https://user:token@host/path` to `https://host/path`.
179
+ */
180
+ function stripAuth(url) {
181
+ try {
182
+ const u = new URL(url);
183
+ u.username = '';
184
+ u.password = '';
185
+ return u.toString();
186
+ }
187
+ catch {
188
+ // Not a valid URL (e.g. SSH) — return as-is
189
+ return url;
190
+ }
191
+ }
@@ -47,7 +47,9 @@ export default class Serve extends BaseCommand {
47
47
  }
48
48
  catch (error_) {
49
49
  const error = error_;
50
- if (/No projectEnvId found in config/.test(error.message) || /Unexpected end of JSON input/.test(error.message)) {
50
+ if (/No projectEnvId found in config/.test(error.message) ||
51
+ /is not iterable/.test(error.message) ||
52
+ /Unexpected end of JSON input/.test(error.message)) {
51
53
  return this.error(`${error.message}\nYour download file seems invalid or corrupt. Please redownload your datafile.`);
52
54
  }
53
55
  return this.error(error);