@quonfig/cli 0.0.12 → 0.0.14

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 (75) hide show
  1. package/README.md +91 -33
  2. package/dist/codegen/json-schema-to-zod.js +7 -5
  3. package/dist/codegen/language-mappers/zod-base-mapper.d.ts +2 -2
  4. package/dist/codegen/language-mappers/zod-to-string-mapper.d.ts +2 -2
  5. package/dist/codegen/language-mappers/zod-to-string-mapper.js +6 -6
  6. package/dist/codegen/language-mappers/zod-to-typescript-mapper.d.ts +2 -2
  7. package/dist/codegen/language-mappers/zod-to-typescript-mapper.js +6 -6
  8. package/dist/codegen/language-mappers/zod-to-typescript-return-value-mapper.d.ts +2 -2
  9. package/dist/codegen/language-mappers/zod-to-typescript-return-value-mapper.js +5 -5
  10. package/dist/codegen/local-config-reader.js +0 -1
  11. package/dist/codegen/schema-extractor.js +5 -2
  12. package/dist/commands/info.js +4 -1
  13. package/dist/commands/init.js +12 -2
  14. package/dist/commands/interactive.d.ts +1 -0
  15. package/dist/commands/interactive.js +8 -0
  16. package/dist/commands/login.js +9 -3
  17. package/dist/commands/migrate/doctor.js +1 -1
  18. package/dist/commands/migrate/my-code.d.ts +11 -0
  19. package/dist/commands/migrate/my-code.js +80 -0
  20. package/dist/commands/migrate/status.js +3 -16
  21. package/dist/commands/migrate.js +29 -8
  22. package/dist/commands/override.js +6 -3
  23. package/dist/commands/profile.d.ts +1 -1
  24. package/dist/commands/profile.js +1 -1
  25. package/dist/commands/pull.d.ts +2 -2
  26. package/dist/commands/pull.js +45 -47
  27. package/dist/commands/schema.d.ts +3 -3
  28. package/dist/commands/schema.js +37 -37
  29. package/dist/commands/sdk-key/create.js +12 -14
  30. package/dist/commands/sdk-key/revoke.d.ts +1 -1
  31. package/dist/commands/sdk-key/revoke.js +2 -4
  32. package/dist/commands/set-default.js +7 -5
  33. package/dist/commands/set-rollout.d.ts +1 -1
  34. package/dist/commands/set-rollout.js +16 -13
  35. package/dist/commands/sync.d.ts +3 -3
  36. package/dist/commands/sync.js +30 -26
  37. package/dist/commands/whoami.js +1 -5
  38. package/dist/commands/workspace/bootstrap.js +7 -7
  39. package/dist/commands/workspace/switch.js +2 -2
  40. package/dist/init/init-workspace.d.ts +2 -2
  41. package/dist/init/init-workspace.js +32 -18
  42. package/dist/init/samples.d.ts +1 -1
  43. package/dist/init/samples.js +2 -2
  44. package/dist/init/schema.js +24 -15
  45. package/dist/interactive-prompt.js +1 -10
  46. package/dist/migrate/doctor.js +3 -1
  47. package/dist/migrate/local-write.d.ts +26 -0
  48. package/dist/migrate/local-write.js +85 -0
  49. package/dist/migrate/migration-report.js +1 -9
  50. package/dist/migrate/push-strategy.js +1 -7
  51. package/dist/migrate/push-to-cloud.js +1 -1
  52. package/dist/migrate/sources/flagsmith.js +10 -0
  53. package/dist/quonfig.d.ts +1 -1
  54. package/dist/quonfig.js +5 -9
  55. package/dist/ui/get-value.js +1 -1
  56. package/dist/util/config-value-dto.js +17 -1
  57. package/dist/util/encryption.js +4 -1
  58. package/dist/util/friction-log.d.ts +1 -1
  59. package/dist/util/friction-log.js +5 -1
  60. package/dist/util/get-client.js +51 -3
  61. package/dist/util/get-valid-token.d.ts +4 -0
  62. package/dist/util/get-valid-token.js +17 -2
  63. package/dist/util/git-ops.js +7 -7
  64. package/dist/util/gitea-api.d.ts +2 -2
  65. package/dist/util/gitea-api.js +5 -3
  66. package/dist/util/gitea-token-storage.d.ts +2 -2
  67. package/dist/util/gitea-token-storage.js +1 -1
  68. package/dist/util/oauth-client.js +1 -1
  69. package/dist/util/token-storage.js +2 -2
  70. package/dist/verify/standalone.d.ts +0 -1
  71. package/dist/verify/standalone.js +22 -23
  72. package/dist/verify/validate.d.ts +8 -8
  73. package/dist/verify/validate.js +252 -246
  74. package/oclif.manifest.json +79 -3
  75. package/package.json +6 -6
@@ -1,16 +1,14 @@
1
1
  import { Args } from '@oclif/core';
2
2
  import { APICommand } from '../../index.js';
3
3
  export default class SdkKeyRevoke extends APICommand {
4
- static description = 'Revoke an SDK key by its ID';
5
4
  static args = {
6
5
  keyId: Args.string({
7
6
  description: 'ID of the SDK key to revoke',
8
7
  required: true,
9
8
  }),
10
9
  };
11
- static examples = [
12
- '<%= config.bin %> <%= command.id %> a1b2c3d4-e5f6-7890-abcd-ef1234567890',
13
- ];
10
+ static description = 'Revoke an SDK key by its ID';
11
+ static examples = ['<%= config.bin %> <%= command.id %> a1b2c3d4-e5f6-7890-abcd-ef1234567890'];
14
12
  async run() {
15
13
  const { args } = await this.parse(SdkKeyRevoke);
16
14
  const request = await this.apiClient.post('/api/v1/sdkKeys/delete', {
@@ -1,7 +1,6 @@
1
1
  import { Flags } from '@oclif/core';
2
- import { ProvidedSource } from '@quonfig/node';
2
+ import { ProvidedSource, ConfigValueType } from '@quonfig/node';
3
3
  import { APICommand } from '../index.js';
4
- import { ConfigValueType } from '@quonfig/node';
5
4
  import getConfirmation, { confirmFlag } from '../ui/get-confirmation.js';
6
5
  import getEnvironment from '../ui/get-environment.js';
7
6
  import getString from '../ui/get-string.js';
@@ -38,6 +37,8 @@ To see all current values and rules for a flag:
38
37
  '<%= config.bin %> <%= command.id %> my.config.name --env-var=MY_ENV_VAR_NAME --environment=production',
39
38
  '# For a percentage rollout, use set-rollout instead:',
40
39
  '<%= config.bin %> set-rollout my.flag.name --environment production --true-percent 20',
40
+ '# For per-user / per-property targeting (e.g. user.email == X), edit JSON directly:',
41
+ '<%= config.bin %> pull && <%= config.bin %> config-schema',
41
42
  ];
42
43
  static flags = {
43
44
  confidential: Flags.boolean({ default: false, description: 'mark the value as confidential' }),
@@ -96,7 +97,10 @@ To see all current values and rules for a flag:
96
97
  this.verboseLog('Selected config:', config);
97
98
  // Check if existing config has encrypted values
98
99
  if (!secret.selected) {
99
- const configDetailsRequest = await this.apiClient.post('/api/v1/metadata/getByKey', { workspaceId: this.workspaceId, key });
100
+ const configDetailsRequest = await this.apiClient.post('/api/v1/metadata/getByKey', {
101
+ workspaceId: this.workspaceId,
102
+ key,
103
+ });
100
104
  if (configDetailsRequest.ok) {
101
105
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
102
106
  const configDetails = configDetailsRequest.json;
@@ -170,7 +174,6 @@ To see all current values and rules for a flag:
170
174
  });
171
175
  }
172
176
  async submitChange({ confidential, config, envVar, environment, key, secret, value, }) {
173
- /* eslint-disable camelcase */
174
177
  const typeMapping = {
175
178
  bool: 'bool',
176
179
  string: 'string',
@@ -182,7 +185,6 @@ To see all current values and rules for a flag:
182
185
  duration: 'duration',
183
186
  int_range: 'intRange',
184
187
  };
185
- /* eslint-enable camelcase */
186
188
  const type = typeMapping[config.valueType.toLowerCase()] || config.valueType;
187
189
  let configValue;
188
190
  let successMessage;
@@ -14,8 +14,8 @@ export default class SetRollout extends APICommand {
14
14
  confirm: import("@oclif/core/lib/interfaces/parser.js").BooleanFlag<boolean>;
15
15
  };
16
16
  run(): Promise<JsonObj | void>;
17
- private parseWeights;
18
17
  private coerceValue;
18
+ private parseWeights;
19
19
  }
20
20
  /**
21
21
  * Pick a short, stable variant name from a rollout value. Uses the first
@@ -115,15 +115,7 @@ To inspect the current rollout:
115
115
  return;
116
116
  // Build weighted values
117
117
  let weightedValues;
118
- if (flags['true-percent'] !== undefined) {
119
- const truePercent = flags['true-percent'];
120
- const falsePercent = 100 - truePercent;
121
- weightedValues = [
122
- { value: { type: 'bool', value: true }, weight: truePercent * (WEIGHT_TOTAL / 100) },
123
- { value: { type: 'bool', value: false }, weight: falsePercent * (WEIGHT_TOTAL / 100) },
124
- ];
125
- }
126
- else {
118
+ if (flags['true-percent'] === undefined) {
127
119
  // Parse --weights "value1:N,value2:M,..."
128
120
  const parsed = this.parseWeights(flags.weights, config.valueType);
129
121
  if (!parsed.ok) {
@@ -131,6 +123,14 @@ To inspect the current rollout:
131
123
  }
132
124
  weightedValues = parsed.weights;
133
125
  }
126
+ else {
127
+ const truePercent = flags['true-percent'];
128
+ const falsePercent = 100 - truePercent;
129
+ weightedValues = [
130
+ { value: { type: 'bool', value: true }, weight: truePercent * (WEIGHT_TOTAL / 100) },
131
+ { value: { type: 'bool', value: false }, weight: falsePercent * (WEIGHT_TOTAL / 100) },
132
+ ];
133
+ }
134
134
  const totalWeight = weightedValues.reduce((sum, wv) => sum + wv.weight, 0);
135
135
  if (totalWeight !== WEIGHT_TOTAL) {
136
136
  return this.err(`Weights must sum to 100. Got: ${weightedValues.map((wv) => `${wv.weight / 1000}%`).join(' + ')} = ${totalWeight / 1000}%`);
@@ -209,7 +209,13 @@ To inspect the current rollout:
209
209
  };
210
210
  }
211
211
  this.verboseLog(request.error);
212
- return this.err(`Failed to set rollout: ${request.status} | ${JSON.stringify(request.error)}`, { key, serverError: request.error });
212
+ return this.err(`Failed to set rollout: ${request.status} | ${JSON.stringify(request.error)}`, {
213
+ key,
214
+ serverError: request.error,
215
+ });
216
+ }
217
+ coerceValue(raw, valueType) {
218
+ return coerceValue(raw, valueType);
213
219
  }
214
220
  parseWeights(weightsStr, valueType) {
215
221
  const pairs = weightsStr.split(',').map((s) => s.trim());
@@ -241,9 +247,6 @@ To inspect the current rollout:
241
247
  }
242
248
  return { ok: true, weights };
243
249
  }
244
- coerceValue(raw, valueType) {
245
- return coerceValue(raw, valueType);
246
- }
247
250
  }
248
251
  /**
249
252
  * Pick a short, stable variant name from a rollout value. Uses the first
@@ -8,11 +8,11 @@ export default class Sync extends BaseCommand {
8
8
  interval: import("@oclif/core/lib/interfaces/parser.js").OptionFlag<number, import("@oclif/core/lib/interfaces/parser.js").CustomOptions>;
9
9
  watch: import("@oclif/core/lib/interfaces/parser.js").BooleanFlag<boolean>;
10
10
  };
11
- private workspaceId;
12
11
  private resolvedDir;
12
+ private workspaceId;
13
13
  run(): Promise<JsonObj | void>;
14
- private runSync;
15
14
  private ensureRemoteUrl;
16
- private refreshToken;
17
15
  private looksLike401;
16
+ private refreshToken;
17
+ private runSync;
18
18
  }
@@ -5,7 +5,9 @@ import { getActiveProfile, loadAuthConfig } from '../util/token-storage.js';
5
5
  import { loadGiteaToken, isGiteaTokenExpired, saveGiteaToken } from '../util/gitea-token-storage.js';
6
6
  import { mintGiteaToken } from '../util/gitea-api.js';
7
7
  import { isGitRepo, isWorkingTreeClean, gitFetch, canFastForward, hasDivergedFromRemote, gitMergeFfOnly, gitSetRemote, } from '../util/git-ops.js';
8
- const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
8
+ const sleep = (ms) => new Promise((resolve) => {
9
+ setTimeout(resolve, ms);
10
+ });
9
11
  export default class Sync extends BaseCommand {
10
12
  static description = 'Continuously poll for workspace config updates and apply them locally';
11
13
  static examples = [
@@ -26,8 +28,8 @@ export default class Sync extends BaseCommand {
26
28
  required: true,
27
29
  }),
28
30
  };
29
- workspaceId;
30
31
  resolvedDir;
32
+ workspaceId;
31
33
  async run() {
32
34
  const { flags } = await this.parse(Sync);
33
35
  // Resolve directory
@@ -61,10 +63,31 @@ export default class Sync extends BaseCommand {
61
63
  // Poll loop
62
64
  // eslint-disable-next-line no-constant-condition
63
65
  while (true) {
66
+ // eslint-disable-next-line no-await-in-loop
64
67
  await sleep(intervalSec * 1000);
68
+ // eslint-disable-next-line no-await-in-loop
65
69
  await this.runSync();
66
70
  }
67
71
  }
72
+ async ensureRemoteUrl() {
73
+ const entry = await loadGiteaToken(this.workspaceId);
74
+ if (!entry || isGiteaTokenExpired(entry)) {
75
+ await this.refreshToken();
76
+ return;
77
+ }
78
+ await gitSetRemote(this.resolvedDir, entry.repoUrl);
79
+ }
80
+ looksLike401(err) {
81
+ const msg = err instanceof Error ? err.message : String(err);
82
+ return msg.includes('401') || msg.toLowerCase().includes('authentication failed');
83
+ }
84
+ async refreshToken() {
85
+ const data = await mintGiteaToken(this.workspaceId, 'read', 'pull');
86
+ const entry = { token: data.token, repoUrl: data.repoUrl, expiresAt: data.expiresAt };
87
+ await saveGiteaToken(this.workspaceId, entry);
88
+ await gitSetRemote(this.resolvedDir, entry.repoUrl);
89
+ this.verboseLog('Sync', 'Token refreshed.');
90
+ }
68
91
  async runSync() {
69
92
  const timestamp = new Date().toISOString();
70
93
  try {
@@ -103,16 +126,16 @@ export default class Sync extends BaseCommand {
103
126
  this.log(`[${timestamp}] Updated.`);
104
127
  }
105
128
  }
106
- catch (err) {
107
- const msg = err instanceof Error ? err.message : String(err);
108
- if (this.looksLike401(err)) {
129
+ catch (error) {
130
+ const msg = error instanceof Error ? error.message : String(error);
131
+ if (this.looksLike401(error)) {
109
132
  this.log(`[${timestamp}] Token expired, refreshing...`);
110
133
  try {
111
134
  await this.refreshToken();
112
135
  await this.ensureRemoteUrl();
113
136
  }
114
- catch (refreshErr) {
115
- this.log(`[${timestamp}] Warning: could not refresh token: ${String(refreshErr)}`);
137
+ catch (error) {
138
+ this.log(`[${timestamp}] Warning: could not refresh token: ${String(error)}`);
116
139
  }
117
140
  }
118
141
  else {
@@ -120,23 +143,4 @@ export default class Sync extends BaseCommand {
120
143
  }
121
144
  }
122
145
  }
123
- async ensureRemoteUrl() {
124
- const entry = await loadGiteaToken(this.workspaceId);
125
- if (!entry || isGiteaTokenExpired(entry)) {
126
- await this.refreshToken();
127
- return;
128
- }
129
- await gitSetRemote(this.resolvedDir, entry.repoUrl);
130
- }
131
- async refreshToken() {
132
- const data = await mintGiteaToken(this.workspaceId, 'read', 'pull');
133
- const entry = { token: data.token, repoUrl: data.repoUrl, expiresAt: data.expiresAt };
134
- await saveGiteaToken(this.workspaceId, entry);
135
- await gitSetRemote(this.resolvedDir, entry.repoUrl);
136
- this.verboseLog('Sync', 'Token refreshed.');
137
- }
138
- looksLike401(err) {
139
- const msg = err instanceof Error ? err.message : String(err);
140
- return msg.includes('401') || msg.toLowerCase().includes('authentication failed');
141
- }
142
146
  }
@@ -15,12 +15,10 @@ export default class Whoami extends BaseCommand {
15
15
  }
16
16
  // Get email from stored user info or decode JWT
17
17
  let userEmail = tokens.userEmail;
18
- let orgId;
19
18
  if (!userEmail) {
20
19
  try {
21
20
  const payload = decodeJWT(tokens.accessToken);
22
21
  userEmail = payload.email;
23
- orgId = payload.org_id;
24
22
  }
25
23
  catch {
26
24
  // If we can't decode the token, continue without email
@@ -38,9 +36,7 @@ export default class Whoami extends BaseCommand {
38
36
  // Display user information
39
37
  const workspaceSlug = profile.workspaceSlug || profile.workspaceName;
40
38
  const orgPrefix = profile.organizationName ? `${profile.organizationName} / ` : '';
41
- const workspaceDisplay = workspaceSlug
42
- ? `${orgPrefix}${workspaceSlug} (${profile.workspace})`
43
- : profile.workspace;
39
+ const workspaceDisplay = workspaceSlug ? `${orgPrefix}${workspaceSlug} (${profile.workspace})` : profile.workspace;
44
40
  this.log(`Logged in as: ${userEmail || 'Unknown'}`);
45
41
  this.log(`Workspace: ${workspaceDisplay}`);
46
42
  return {
@@ -6,7 +6,7 @@ import { getActiveProfile, loadAuthConfig } from '../../util/token-storage.js';
6
6
  import { mintGiteaToken } from '../../util/gitea-api.js';
7
7
  import { isGitRepo, hasAtLeastOneCommit, gitSetRemote, gitPushForceLease, gitPushForce, getRemoteUrl, displayUrl, } from '../../util/git-ops.js';
8
8
  export default class WorkspaceBootstrap extends BaseCommand {
9
- static description = 'Push a local git repo to Gitea as this workspace\'s config repository';
9
+ static description = "Push a local git repo to Gitea as this workspace's config repository";
10
10
  static examples = [
11
11
  '<%= config.bin %> workspace bootstrap --dir ./our-config',
12
12
  '<%= config.bin %> workspace bootstrap --dir ./launch-migrator/output',
@@ -70,8 +70,8 @@ export default class WorkspaceBootstrap extends BaseCommand {
70
70
  }
71
71
  this.log('Validation passed.\n');
72
72
  }
73
- catch (err) {
74
- this.verboseLog('Validation error', String(err));
73
+ catch (error) {
74
+ this.verboseLog('Validation error', String(error));
75
75
  this.log('Warning: could not run validation. Proceeding anyway.');
76
76
  this.log('Use --skip-validate to suppress this warning.\n');
77
77
  }
@@ -99,8 +99,8 @@ export default class WorkspaceBootstrap extends BaseCommand {
99
99
  try {
100
100
  tokenData = await mintGiteaToken(workspaceId, 'write', 'bootstrap');
101
101
  }
102
- catch (err) {
103
- return this.err(`Could not get Gitea credentials: ${String(err)}\n\nMake sure the workspace is fully provisioned in the Quonfig app before bootstrapping.`);
102
+ catch (error) {
103
+ return this.err(`Could not get Gitea credentials: ${String(error)}\n\nMake sure the workspace is fully provisioned in the Quonfig app before bootstrapping.`);
104
104
  }
105
105
  const { repoUrl } = tokenData;
106
106
  this.verboseLog('WorkspaceBootstrap', { repoUrl: displayUrl(repoUrl) });
@@ -132,8 +132,8 @@ export default class WorkspaceBootstrap extends BaseCommand {
132
132
  await gitPushForceLease(resolvedDir);
133
133
  }
134
134
  }
135
- catch (err) {
136
- return this.err(`Push failed: ${String(err)}`);
135
+ catch (error) {
136
+ return this.err(`Push failed: ${String(error)}`);
137
137
  }
138
138
  this.log(`\nBootstrap complete.`);
139
139
  this.log(`Workspace "${workspaceName}" is now connected to your local directory.`);
@@ -26,7 +26,7 @@ export default class WorkspaceSwitch extends BaseCommand {
26
26
  const res = await fetch(`${apiUrl}/api/v1/userWorkspaces/list`, {
27
27
  method: 'POST',
28
28
  headers: {
29
- 'Authorization': `Bearer ${accessToken}`,
29
+ Authorization: `Bearer ${accessToken}`,
30
30
  'Content-Type': 'application/json',
31
31
  },
32
32
  body: JSON.stringify({ json: {} }),
@@ -34,7 +34,7 @@ export default class WorkspaceSwitch extends BaseCommand {
34
34
  if (!res.ok) {
35
35
  return this.err(`Failed to fetch workspaces (${res.status}). Try \`qfg login\` to re-authenticate.`);
36
36
  }
37
- const body = await res.json();
37
+ const body = (await res.json());
38
38
  const all = (body.json ?? body);
39
39
  candidates = Array.isArray(all) ? all : [];
40
40
  }
@@ -6,15 +6,15 @@
6
6
  */
7
7
  export interface InitOptions {
8
8
  dir: string;
9
+ dryRun: boolean;
9
10
  /** true = force include samples, false = force skip, undefined = auto-detect */
10
11
  samples: boolean | undefined;
11
- dryRun: boolean;
12
12
  }
13
13
  export type ActionKind = 'git-init' | 'create-dir' | 'write-file' | 'skip-file' | 'create-hook' | 'update-hook' | 'skip-hook';
14
14
  export interface InitAction {
15
+ description: string;
15
16
  kind: ActionKind;
16
17
  path: string;
17
- description: string;
18
18
  }
19
19
  export interface InitPlan {
20
20
  actions: InitAction[];
@@ -11,13 +11,7 @@ import { SAMPLE_FILES } from './samples.js';
11
11
  import { storedConfigJsonSchema } from './schema.js';
12
12
  import { PRE_COMMIT_MARKER, agentsMdTemplate, claudeMdTemplate, preCommitHookContent, readmeTemplate, } from './templates.js';
13
13
  // ── Directories that define a workspace ────────────────────────────────
14
- const WORKSPACE_DIRS = [
15
- 'configs',
16
- 'feature-flags',
17
- 'segments',
18
- 'log-levels',
19
- 'schemas',
20
- ];
14
+ const WORKSPACE_DIRS = ['configs', 'feature-flags', 'segments', 'log-levels', 'schemas'];
21
15
  // ── Plan ───────────────────────────────────────────────────────────────
22
16
  export function planInit(options) {
23
17
  const { dir } = options;
@@ -49,7 +43,7 @@ export function planInit(options) {
49
43
  { file: 'CLAUDE.md', content: claudeMdTemplate() },
50
44
  { file: 'AGENTS.md', content: agentsMdTemplate() },
51
45
  ];
52
- for (const { file, content: _content } of managedFiles) {
46
+ for (const { file } of managedFiles) {
53
47
  const filePath = path.join(dir, file);
54
48
  const verb = fs.existsSync(filePath) ? 'Update' : 'Create';
55
49
  actions.push({ kind: 'write-file', path: file, description: `${verb} ${file}` });
@@ -77,25 +71,45 @@ export function planInit(options) {
77
71
  // 5. Git pre-commit hook (always runs — git repo guaranteed by step 0)
78
72
  if (needsGitInit) {
79
73
  // Fresh git init — hook will be created
80
- actions.push({ kind: 'create-hook', path: '.git/hooks/pre-commit', description: 'Install pre-commit hook (qfg verify)' });
74
+ actions.push({
75
+ kind: 'create-hook',
76
+ path: '.git/hooks/pre-commit',
77
+ description: 'Install pre-commit hook (qfg verify)',
78
+ });
81
79
  }
82
80
  else {
83
81
  const hooksDir = path.join(gitDir, 'hooks');
84
82
  const hookPath = path.join(hooksDir, 'pre-commit');
85
83
  if (fs.existsSync(hookPath)) {
86
- const existing = fs.readFileSync(hookPath, 'utf-8');
84
+ const existing = fs.readFileSync(hookPath, 'utf8');
87
85
  if (existing.includes(PRE_COMMIT_MARKER)) {
88
- actions.push({ kind: 'skip-hook', path: '.git/hooks/pre-commit', description: 'Pre-commit hook already installed' });
86
+ actions.push({
87
+ kind: 'skip-hook',
88
+ path: '.git/hooks/pre-commit',
89
+ description: 'Pre-commit hook already installed',
90
+ });
89
91
  }
90
92
  else if (existing.includes('qfg verify')) {
91
- actions.push({ kind: 'skip-hook', path: '.git/hooks/pre-commit', description: 'Pre-commit hook already runs qfg verify' });
93
+ actions.push({
94
+ kind: 'skip-hook',
95
+ path: '.git/hooks/pre-commit',
96
+ description: 'Pre-commit hook already runs qfg verify',
97
+ });
92
98
  }
93
99
  else {
94
- actions.push({ kind: 'update-hook', path: '.git/hooks/pre-commit', description: 'Append qfg verify to existing pre-commit hook' });
100
+ actions.push({
101
+ kind: 'update-hook',
102
+ path: '.git/hooks/pre-commit',
103
+ description: 'Append qfg verify to existing pre-commit hook',
104
+ });
95
105
  }
96
106
  }
97
107
  else {
98
- actions.push({ kind: 'create-hook', path: '.git/hooks/pre-commit', description: 'Install pre-commit hook (qfg verify)' });
108
+ actions.push({
109
+ kind: 'create-hook',
110
+ path: '.git/hooks/pre-commit',
111
+ description: 'Install pre-commit hook (qfg verify)',
112
+ });
99
113
  }
100
114
  }
101
115
  return { actions, isFirstTime, samplesIncluded: includeSamples };
@@ -134,7 +148,7 @@ export function executeInit(plan, dir) {
134
148
  }
135
149
  const content = managedContent[action.path] ?? sampleContent[action.path];
136
150
  if (content) {
137
- fs.writeFileSync(fullPath, content, 'utf-8');
151
+ fs.writeFileSync(fullPath, content, 'utf8');
138
152
  }
139
153
  break;
140
154
  }
@@ -143,14 +157,14 @@ export function executeInit(plan, dir) {
143
157
  if (!fs.existsSync(hooksDir)) {
144
158
  fs.mkdirSync(hooksDir, { recursive: true });
145
159
  }
146
- fs.writeFileSync(fullPath, preCommitHookContent(), 'utf-8');
160
+ fs.writeFileSync(fullPath, preCommitHookContent(), 'utf8');
147
161
  fs.chmodSync(fullPath, 0o755);
148
162
  break;
149
163
  }
150
164
  case 'update-hook': {
151
- const existing = fs.readFileSync(fullPath, 'utf-8');
165
+ const existing = fs.readFileSync(fullPath, 'utf8');
152
166
  const appended = existing.trimEnd() + '\n\n' + preCommitHookContent().split('\n').slice(1).join('\n');
153
- fs.writeFileSync(fullPath, appended, 'utf-8');
167
+ fs.writeFileSync(fullPath, appended, 'utf8');
154
168
  break;
155
169
  }
156
170
  case 'skip-file':
@@ -5,7 +5,7 @@
5
5
  * to the JSON object that should be written there.
6
6
  */
7
7
  export interface SampleFile {
8
- path: string;
9
8
  content: object;
9
+ path: string;
10
10
  }
11
11
  export declare const SAMPLE_FILES: SampleFile[];
@@ -95,9 +95,9 @@ export const SAMPLE_FILES = [
95
95
  },
96
96
  },
97
97
  {
98
- path: 'log-levels/example.app.json',
98
+ path: 'log-levels/log-level.example.app.json',
99
99
  content: {
100
- key: 'example.app',
100
+ key: 'log-level.example.app',
101
101
  type: 'log_level',
102
102
  valueType: 'log_level',
103
103
  sendToClientSdk: false,
@@ -11,17 +11,27 @@ const valueTypes = ['bool', 'string', 'int', 'double', 'json', 'string_list', 'd
11
11
  const logLevels = ['TRACE', 'DEBUG', 'INFO', 'WARN', 'ERROR', 'FATAL'];
12
12
  const operators = [
13
13
  'ALWAYS_TRUE',
14
- 'PROP_IS_ONE_OF', 'PROP_IS_NOT_ONE_OF',
15
- 'PROP_STARTS_WITH_ONE_OF', 'PROP_DOES_NOT_START_WITH_ONE_OF',
16
- 'PROP_ENDS_WITH_ONE_OF', 'PROP_DOES_NOT_END_WITH_ONE_OF',
17
- 'PROP_CONTAINS_ONE_OF', 'PROP_DOES_NOT_CONTAIN_ONE_OF',
18
- 'PROP_LESS_THAN', 'PROP_LESS_THAN_OR_EQUAL',
19
- 'PROP_GREATER_THAN', 'PROP_GREATER_THAN_OR_EQUAL',
20
- 'PROP_BEFORE', 'PROP_AFTER',
21
- 'PROP_MATCHES', 'PROP_DOES_NOT_MATCH',
22
- 'IN_SEG', 'NOT_IN_SEG',
14
+ 'PROP_IS_ONE_OF',
15
+ 'PROP_IS_NOT_ONE_OF',
16
+ 'PROP_STARTS_WITH_ONE_OF',
17
+ 'PROP_DOES_NOT_START_WITH_ONE_OF',
18
+ 'PROP_ENDS_WITH_ONE_OF',
19
+ 'PROP_DOES_NOT_END_WITH_ONE_OF',
20
+ 'PROP_CONTAINS_ONE_OF',
21
+ 'PROP_DOES_NOT_CONTAIN_ONE_OF',
22
+ 'PROP_LESS_THAN',
23
+ 'PROP_LESS_THAN_OR_EQUAL',
24
+ 'PROP_GREATER_THAN',
25
+ 'PROP_GREATER_THAN_OR_EQUAL',
26
+ 'PROP_BEFORE',
27
+ 'PROP_AFTER',
28
+ 'PROP_MATCHES',
29
+ 'PROP_DOES_NOT_MATCH',
30
+ 'IN_SEG',
31
+ 'NOT_IN_SEG',
23
32
  'IN_INT_RANGE',
24
- 'LOOKUP_KEY_IN', 'LOOKUP_KEY_NOT_IN',
33
+ 'LOOKUP_KEY_IN',
34
+ 'LOOKUP_KEY_NOT_IN',
25
35
  ];
26
36
  // ── Value sub-schemas ──────────────────────────────────────────────────
27
37
  const boolValue = {
@@ -173,10 +183,7 @@ const weightedValuesValue = {
173
183
  additionalProperties: false,
174
184
  };
175
185
  const ruleValue = {
176
- oneOf: [
177
- ...simpleValue.oneOf,
178
- weightedValuesValue,
179
- ],
186
+ oneOf: [...simpleValue.oneOf, weightedValuesValue],
180
187
  };
181
188
  // ── Criterion ──────────────────────────────────────────────────────────
182
189
  const criterion = {
@@ -310,10 +317,11 @@ export function storedConfigJsonSchema() {
310
317
  },
311
318
  required: ['key', 'type', 'valueType', 'default'],
312
319
  additionalProperties: true,
313
- // Type-specific constraints
320
+ // Type-specific constraints (JSON Schema if/then/else — not Promise thenables)
314
321
  allOf: [
315
322
  {
316
323
  if: { properties: { type: { const: 'segment' } } },
324
+ // eslint-disable-next-line unicorn/no-thenable
317
325
  then: {
318
326
  properties: {
319
327
  valueType: { const: 'bool' },
@@ -323,6 +331,7 @@ export function storedConfigJsonSchema() {
323
331
  },
324
332
  {
325
333
  if: { properties: { type: { const: 'log_level' } } },
334
+ // eslint-disable-next-line unicorn/no-thenable
326
335
  then: {
327
336
  properties: {
328
337
  valueType: { const: 'log_level' },
@@ -15,16 +15,7 @@ const convertToKebabCase = (text) => text
15
15
  .trim()
16
16
  .replaceAll(' ', '-')
17
17
  .toLowerCase();
18
- const commands = [
19
- SetDefault,
20
- GenerateNewHexKey,
21
- Create,
22
- Get,
23
- Info,
24
- Mcp,
25
- Override,
26
- List,
27
- ].map((command) => {
18
+ const commands = [SetDefault, GenerateNewHexKey, Create, Get, Info, Mcp, Override, List].map((command) => {
28
19
  const id = convertToKebabCase(command.name);
29
20
  return {
30
21
  command,
@@ -124,7 +124,9 @@ const checkSdkDatadirSupport = (ctx) => {
124
124
  };
125
125
  }
126
126
  return {
127
- message: language ? `SDK language "${language}" supports datadir mode.` : 'No SDK language specified; assuming datadir-capable.',
127
+ message: language
128
+ ? `SDK language "${language}" supports datadir mode.`
129
+ : 'No SDK language specified; assuming datadir-capable.',
128
130
  name: 'sdk-datadir-support',
129
131
  passed: true,
130
132
  };
@@ -0,0 +1,26 @@
1
+ import { type ImportState } from './import-state.js';
2
+ import { type MigrationReportData } from './migration-report.js';
3
+ import { type PushIdentity } from './push-strategy.js';
4
+ import type { LegacyChange, MigrationSource } from './source.js';
5
+ export interface ApplyLocalMigrationOptions {
6
+ /** Commit author. Defaults to the migrator identity. */
7
+ author?: PushIdentity;
8
+ /** Initial branch name when initializing a fresh repo. Defaults to `main`. */
9
+ branch?: string;
10
+ changes: LegacyChange[];
11
+ commitMessage: string;
12
+ /** Environments discovered from the source (slugified). Written to `quonfig.json` if missing. */
13
+ environments: string[];
14
+ importState: ImportState;
15
+ localDir: string;
16
+ reportData: MigrationReportData;
17
+ source: MigrationSource;
18
+ }
19
+ export interface ApplyLocalMigrationResult {
20
+ /** `'initialized'` if we had to `git init` the target, `'reused'` if it was already a repo. */
21
+ action: 'initialized' | 'reused';
22
+ commitSha: string | null;
23
+ /** `false` if translate produced no net changes — nothing was committed. */
24
+ committed: boolean;
25
+ }
26
+ export declare const applyLocalMigration: (opts: ApplyLocalMigrationOptions) => Promise<ApplyLocalMigrationResult>;