@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.
- package/README.md +91 -33
- package/dist/codegen/json-schema-to-zod.js +7 -5
- package/dist/codegen/language-mappers/zod-base-mapper.d.ts +2 -2
- package/dist/codegen/language-mappers/zod-to-string-mapper.d.ts +2 -2
- package/dist/codegen/language-mappers/zod-to-string-mapper.js +6 -6
- package/dist/codegen/language-mappers/zod-to-typescript-mapper.d.ts +2 -2
- package/dist/codegen/language-mappers/zod-to-typescript-mapper.js +6 -6
- package/dist/codegen/language-mappers/zod-to-typescript-return-value-mapper.d.ts +2 -2
- package/dist/codegen/language-mappers/zod-to-typescript-return-value-mapper.js +5 -5
- package/dist/codegen/local-config-reader.js +0 -1
- package/dist/codegen/schema-extractor.js +5 -2
- package/dist/commands/info.js +4 -1
- package/dist/commands/init.js +12 -2
- package/dist/commands/interactive.d.ts +1 -0
- package/dist/commands/interactive.js +8 -0
- package/dist/commands/login.js +9 -3
- package/dist/commands/migrate/doctor.js +1 -1
- package/dist/commands/migrate/my-code.d.ts +11 -0
- package/dist/commands/migrate/my-code.js +80 -0
- package/dist/commands/migrate/status.js +3 -16
- package/dist/commands/migrate.js +29 -8
- package/dist/commands/override.js +6 -3
- package/dist/commands/profile.d.ts +1 -1
- package/dist/commands/profile.js +1 -1
- package/dist/commands/pull.d.ts +2 -2
- package/dist/commands/pull.js +45 -47
- package/dist/commands/schema.d.ts +3 -3
- package/dist/commands/schema.js +37 -37
- package/dist/commands/sdk-key/create.js +12 -14
- package/dist/commands/sdk-key/revoke.d.ts +1 -1
- package/dist/commands/sdk-key/revoke.js +2 -4
- package/dist/commands/set-default.js +7 -5
- package/dist/commands/set-rollout.d.ts +1 -1
- package/dist/commands/set-rollout.js +16 -13
- package/dist/commands/sync.d.ts +3 -3
- package/dist/commands/sync.js +30 -26
- package/dist/commands/whoami.js +1 -5
- package/dist/commands/workspace/bootstrap.js +7 -7
- package/dist/commands/workspace/switch.js +2 -2
- package/dist/init/init-workspace.d.ts +2 -2
- package/dist/init/init-workspace.js +32 -18
- package/dist/init/samples.d.ts +1 -1
- package/dist/init/samples.js +2 -2
- package/dist/init/schema.js +24 -15
- package/dist/interactive-prompt.js +1 -10
- package/dist/migrate/doctor.js +3 -1
- package/dist/migrate/local-write.d.ts +26 -0
- package/dist/migrate/local-write.js +85 -0
- package/dist/migrate/migration-report.js +1 -9
- package/dist/migrate/push-strategy.js +1 -7
- package/dist/migrate/push-to-cloud.js +1 -1
- package/dist/migrate/sources/flagsmith.js +10 -0
- package/dist/quonfig.d.ts +1 -1
- package/dist/quonfig.js +5 -9
- package/dist/ui/get-value.js +1 -1
- package/dist/util/config-value-dto.js +17 -1
- package/dist/util/encryption.js +4 -1
- package/dist/util/friction-log.d.ts +1 -1
- package/dist/util/friction-log.js +5 -1
- package/dist/util/get-client.js +51 -3
- package/dist/util/get-valid-token.d.ts +4 -0
- package/dist/util/get-valid-token.js +17 -2
- package/dist/util/git-ops.js +7 -7
- package/dist/util/gitea-api.d.ts +2 -2
- package/dist/util/gitea-api.js +5 -3
- package/dist/util/gitea-token-storage.d.ts +2 -2
- package/dist/util/gitea-token-storage.js +1 -1
- package/dist/util/oauth-client.js +1 -1
- package/dist/util/token-storage.js +2 -2
- package/dist/verify/standalone.d.ts +0 -1
- package/dist/verify/standalone.js +22 -23
- package/dist/verify/validate.d.ts +8 -8
- package/dist/verify/validate.js +252 -246
- package/oclif.manifest.json +79 -3
- package/package.json +6 -6
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { execFile as execFileCb, spawn } from 'node:child_process';
|
|
2
|
+
import * as fs from 'node:fs';
|
|
3
|
+
import * as path from 'node:path';
|
|
4
|
+
import * as util from 'node:util';
|
|
5
|
+
import { removeQfFromGitignore, writeImportState } from './import-state.js';
|
|
6
|
+
import { writeMigrationReport } from './migration-report.js';
|
|
7
|
+
import { MIGRATOR_IDENTITY } from './push-strategy.js';
|
|
8
|
+
const execFile = util.promisify(execFileCb);
|
|
9
|
+
const isGitRepo = async (dir) => {
|
|
10
|
+
try {
|
|
11
|
+
await execFile('git', ['-C', dir, 'rev-parse', '--git-dir']);
|
|
12
|
+
return true;
|
|
13
|
+
}
|
|
14
|
+
catch {
|
|
15
|
+
return false;
|
|
16
|
+
}
|
|
17
|
+
};
|
|
18
|
+
const hasStagedChanges = async (dir) => {
|
|
19
|
+
const { stdout } = await execFile('git', ['-C', dir, 'status', '--porcelain']);
|
|
20
|
+
return stdout.trim().length > 0;
|
|
21
|
+
};
|
|
22
|
+
const gitCommitFromStdin = (cwd, message, env) => new Promise((resolve, reject) => {
|
|
23
|
+
const child = spawn('git', ['-C', cwd, 'commit', '-F', '-'], { env });
|
|
24
|
+
let stderr = '';
|
|
25
|
+
child.stderr.on('data', (d) => {
|
|
26
|
+
stderr += d.toString();
|
|
27
|
+
});
|
|
28
|
+
child.on('error', reject);
|
|
29
|
+
child.on('close', (code) => {
|
|
30
|
+
if (code === 0)
|
|
31
|
+
resolve();
|
|
32
|
+
else
|
|
33
|
+
reject(new Error(`git commit exited ${code}: ${stderr}`));
|
|
34
|
+
});
|
|
35
|
+
child.stdin.end(message);
|
|
36
|
+
});
|
|
37
|
+
const ensureQuonfigJson = (dir, environments) => {
|
|
38
|
+
const filePath = path.join(dir, 'quonfig.json');
|
|
39
|
+
if (fs.existsSync(filePath))
|
|
40
|
+
return;
|
|
41
|
+
const sorted = [...new Set(environments)].sort();
|
|
42
|
+
fs.writeFileSync(filePath, JSON.stringify({ environments: sorted }, null, 2) + '\n', 'utf8');
|
|
43
|
+
};
|
|
44
|
+
const ensureLocalRepo = async (localDir, branch) => {
|
|
45
|
+
fs.mkdirSync(localDir, { recursive: true });
|
|
46
|
+
if (await isGitRepo(localDir))
|
|
47
|
+
return 'reused';
|
|
48
|
+
await execFile('git', ['init', `--initial-branch=${branch}`, localDir]);
|
|
49
|
+
return 'initialized';
|
|
50
|
+
};
|
|
51
|
+
const writeQuonfigFiles = (dir, changes, source) => {
|
|
52
|
+
for (const change of changes) {
|
|
53
|
+
const files = source.translate(change);
|
|
54
|
+
for (const file of files) {
|
|
55
|
+
const full = path.join(dir, file.path);
|
|
56
|
+
fs.mkdirSync(path.dirname(full), { recursive: true });
|
|
57
|
+
fs.writeFileSync(full, file.contents);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
};
|
|
61
|
+
export const applyLocalMigration = async (opts) => {
|
|
62
|
+
const branch = opts.branch ?? 'main';
|
|
63
|
+
const author = opts.author ?? MIGRATOR_IDENTITY;
|
|
64
|
+
const action = await ensureLocalRepo(opts.localDir, branch);
|
|
65
|
+
await execFile('git', ['-C', opts.localDir, 'config', 'user.name', author.name]);
|
|
66
|
+
await execFile('git', ['-C', opts.localDir, 'config', 'user.email', author.email]);
|
|
67
|
+
ensureQuonfigJson(opts.localDir, opts.environments);
|
|
68
|
+
writeQuonfigFiles(opts.localDir, opts.changes, opts.source);
|
|
69
|
+
removeQfFromGitignore(opts.localDir);
|
|
70
|
+
writeImportState(opts.localDir, opts.importState);
|
|
71
|
+
writeMigrationReport(opts.localDir, opts.reportData);
|
|
72
|
+
await execFile('git', ['-C', opts.localDir, 'add', '--all']);
|
|
73
|
+
if (!(await hasStagedChanges(opts.localDir))) {
|
|
74
|
+
return { action, committed: false, commitSha: null };
|
|
75
|
+
}
|
|
76
|
+
await gitCommitFromStdin(opts.localDir, opts.commitMessage, {
|
|
77
|
+
...process.env,
|
|
78
|
+
GIT_AUTHOR_EMAIL: author.email,
|
|
79
|
+
GIT_AUTHOR_NAME: author.name,
|
|
80
|
+
GIT_COMMITTER_EMAIL: author.email,
|
|
81
|
+
GIT_COMMITTER_NAME: author.name,
|
|
82
|
+
});
|
|
83
|
+
const { stdout: sha } = await execFile('git', ['-C', opts.localDir, 'rev-parse', 'HEAD']);
|
|
84
|
+
return { action, commitSha: sha.trim(), committed: true };
|
|
85
|
+
};
|
|
@@ -73,15 +73,7 @@ export const buildMigrationReport = (data) => {
|
|
|
73
73
|
if (data.dryRun) {
|
|
74
74
|
sections.push('> **DRY RUN** — no files were written to the workspace during this run.');
|
|
75
75
|
}
|
|
76
|
-
sections.push(`# Migration Report (from ${data.source})`);
|
|
77
|
-
sections.push(`_Reflects only the changes produced by this run. Re-running overwrites this file._`);
|
|
78
|
-
sections.push(renderCounts(data.counts));
|
|
79
|
-
sections.push(renderCleanMappings(data.cleanMappings));
|
|
80
|
-
sections.push(renderLossyMappings(data.lossyMappings));
|
|
81
|
-
sections.push(renderUnsupported(data.unsupportedFeatures));
|
|
82
|
-
sections.push(renderEnvironmentMap(data.environmentMap));
|
|
83
|
-
sections.push(renderIdentifierMap(data.identifierMap));
|
|
84
|
-
sections.push(renderFollowUp(data.followUp));
|
|
76
|
+
sections.push(`# Migration Report (from ${data.source})`, `_Reflects only the changes produced by this run. Re-running overwrites this file._`, renderCounts(data.counts), renderCleanMappings(data.cleanMappings), renderLossyMappings(data.lossyMappings), renderUnsupported(data.unsupportedFeatures), renderEnvironmentMap(data.environmentMap), renderIdentifierMap(data.identifierMap), renderFollowUp(data.followUp));
|
|
85
77
|
return sections.join('\n\n') + '\n';
|
|
86
78
|
};
|
|
87
79
|
export const migrationReportPath = (outputDir) => path.join(outputDir, 'MIGRATION_REPORT.md');
|
|
@@ -82,13 +82,7 @@ const ensureCloneOrReuse = async (remoteUrl, localDir, branch) => {
|
|
|
82
82
|
}
|
|
83
83
|
}
|
|
84
84
|
fs.mkdirSync(path.dirname(localDir), { recursive: true });
|
|
85
|
-
await runGit(path.dirname(localDir) || '.', [
|
|
86
|
-
'clone',
|
|
87
|
-
'--branch',
|
|
88
|
-
branch,
|
|
89
|
-
remoteUrl,
|
|
90
|
-
localDir,
|
|
91
|
-
]);
|
|
85
|
+
await runGit(path.dirname(localDir) || '.', ['clone', '--branch', branch, remoteUrl, localDir]);
|
|
92
86
|
return 'cloned';
|
|
93
87
|
};
|
|
94
88
|
const hasStagedChanges = async (dir) => {
|
|
@@ -2,7 +2,7 @@ import * as fs from 'node:fs';
|
|
|
2
2
|
import * as path from 'node:path';
|
|
3
3
|
import { removeQfFromGitignore, writeImportState } from './import-state.js';
|
|
4
4
|
import { writeMigrationReport } from './migration-report.js';
|
|
5
|
-
import { cloneAndStackPush
|
|
5
|
+
import { cloneAndStackPush } from './push-strategy.js';
|
|
6
6
|
const writeQuonfigFiles = (dir, changes, source) => {
|
|
7
7
|
for (const change of changes) {
|
|
8
8
|
const files = source.translate(change);
|
|
@@ -1,3 +1,13 @@
|
|
|
1
|
+
// Stub — `qfg migrate --from flagsmith` is not yet wired up. Before replacing this with a real
|
|
2
|
+
// implementation, investigate:
|
|
3
|
+
// - Flagsmith API shape: what the admin/REST API returns for features, segments, identities,
|
|
4
|
+
// environments, and multivariate variations, and how that maps onto Quonfig's JSON schema.
|
|
5
|
+
// - Delta/cursor support: whether Flagsmith exposes an audit-log or change-feed endpoint with a
|
|
6
|
+
// stable cursor we can persist in `.qf/import-state.json` for re-runnable incremental
|
|
7
|
+
// imports, or whether we have to full-reimport + diff on every run (as is likely for LD).
|
|
8
|
+
// - Auth model: API key scopes (server-side vs. admin), org/project/environment hierarchy.
|
|
9
|
+
// - Schema gaps: Flagsmith's identity overrides and segment rules vs. Quonfig's rule operators
|
|
10
|
+
// — document any unsupported primitives the way `launchdarkly.README.md` does for LD.
|
|
1
11
|
import { NotYetImplementedError } from '../source.js';
|
|
2
12
|
const SOURCE_NAME = 'flagsmith';
|
|
3
13
|
export const flagsmithSource = {
|
package/dist/quonfig.d.ts
CHANGED
|
@@ -7,7 +7,7 @@ type Flags = {
|
|
|
7
7
|
type FlagsOrDatafile = Flags | string;
|
|
8
8
|
export declare const initQuonfig: (_ctx: CommandLike, flagsOrDatafile: FlagsOrDatafile) => Promise<Quonfig>;
|
|
9
9
|
export declare const getQuonfig: () => Quonfig;
|
|
10
|
-
export declare const overrideFor: ({ currentEnvironmentId, key, }: {
|
|
10
|
+
export declare const overrideFor: ({ currentEnvironmentId: _currentEnvironmentId, key, }: {
|
|
11
11
|
currentEnvironmentId: string;
|
|
12
12
|
key: string;
|
|
13
13
|
}) => Value | undefined;
|
package/dist/quonfig.js
CHANGED
|
@@ -32,16 +32,12 @@ export const getQuonfig = () => quonfig;
|
|
|
32
32
|
* Get the raw ConfigResponse for a key (new quonfig JSON format).
|
|
33
33
|
* The new format uses `default.rules` and `environment.rules` instead of `rows`.
|
|
34
34
|
*/
|
|
35
|
-
const getRawConfig = (key) =>
|
|
36
|
-
return quonfig.rawConfig?.(key);
|
|
37
|
-
};
|
|
35
|
+
const getRawConfig = (key) => quonfig.rawConfig?.(key);
|
|
38
36
|
/**
|
|
39
37
|
* Find rules in an environment that target a specific user via criteria.
|
|
40
38
|
*/
|
|
41
|
-
const getEnvironmentRules = (config) =>
|
|
42
|
-
|
|
43
|
-
};
|
|
44
|
-
export const overrideFor = ({ currentEnvironmentId, key, }) => {
|
|
39
|
+
const getEnvironmentRules = (config) => config.environment?.rules;
|
|
40
|
+
export const overrideFor = ({ currentEnvironmentId: _currentEnvironmentId, key, }) => {
|
|
45
41
|
const config = getRawConfig(key);
|
|
46
42
|
if (!config)
|
|
47
43
|
return undefined;
|
|
@@ -65,11 +61,11 @@ export const defaultValueFor = (_envId, key) => {
|
|
|
65
61
|
// In the new format, the default value is the last rule in environment rules, or default rules
|
|
66
62
|
const envRules = config.environment?.rules;
|
|
67
63
|
if (envRules && envRules.length > 0) {
|
|
68
|
-
return envRules
|
|
64
|
+
return envRules.at(-1)?.value;
|
|
69
65
|
}
|
|
70
66
|
const defaultRules = config.default?.rules;
|
|
71
67
|
if (defaultRules && defaultRules.length > 0) {
|
|
72
|
-
return defaultRules
|
|
68
|
+
return defaultRules.at(-1)?.value;
|
|
73
69
|
}
|
|
74
70
|
return undefined;
|
|
75
71
|
};
|
package/dist/ui/get-value.js
CHANGED
|
@@ -38,7 +38,7 @@ const valueToString = (v) => {
|
|
|
38
38
|
return '';
|
|
39
39
|
return String(v.value);
|
|
40
40
|
};
|
|
41
|
-
const promptForValue = async ({ allowBlank, config, currentDefault, message, }) => {
|
|
41
|
+
const promptForValue = async ({ allowBlank, config: _config, currentDefault, message, }) => {
|
|
42
42
|
// In the new quonfig format, allowable values aren't stored at the config level.
|
|
43
43
|
// Just prompt for a string input.
|
|
44
44
|
if (!currentDefault) {
|
|
@@ -38,7 +38,23 @@ export function mapConfigValueToDto(configValue, valueType) {
|
|
|
38
38
|
value = configValue.stringList.values;
|
|
39
39
|
}
|
|
40
40
|
else if (configValue.json !== undefined) {
|
|
41
|
-
|
|
41
|
+
// ConfigValue.json is wrapped as {json: "<raw JSON string>"} (see coerceIntoType).
|
|
42
|
+
// The API expects the parsed JSON value, not the wrapper or the raw string —
|
|
43
|
+
// otherwise the server stores the default as {} (see qfg-c0q).
|
|
44
|
+
const rawJson = configValue.json.json;
|
|
45
|
+
if (typeof rawJson === 'string') {
|
|
46
|
+
try {
|
|
47
|
+
value = JSON.parse(rawJson);
|
|
48
|
+
}
|
|
49
|
+
catch {
|
|
50
|
+
// Fall back to raw string if somehow it isn't valid JSON — coerceIntoType
|
|
51
|
+
// should have already rejected this case, so this is defensive only.
|
|
52
|
+
value = rawJson;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
else {
|
|
56
|
+
value = rawJson ?? configValue.json;
|
|
57
|
+
}
|
|
42
58
|
}
|
|
43
59
|
else if (configValue.duration !== undefined) {
|
|
44
60
|
value = configValue.duration;
|
package/dist/util/encryption.js
CHANGED
|
@@ -72,7 +72,10 @@ export async function makeConfidentialValue(command, value, secret, environmentI
|
|
|
72
72
|
});
|
|
73
73
|
}
|
|
74
74
|
// Fetch the encryption key config
|
|
75
|
-
const configRequest = await command.apiClient.post('/api/v1/metadata/getByKey', {
|
|
75
|
+
const configRequest = await command.apiClient.post('/api/v1/metadata/getByKey', {
|
|
76
|
+
workspaceId: command.workspaceId,
|
|
77
|
+
key: secret.keyName,
|
|
78
|
+
});
|
|
76
79
|
if (!configRequest.ok) {
|
|
77
80
|
const message = configRequest.status === 404
|
|
78
81
|
? `Failed to create secret: ${secret.keyName} not found`
|
|
@@ -4,7 +4,7 @@ export interface FrictionEntry {
|
|
|
4
4
|
exitCode: number;
|
|
5
5
|
ts: string;
|
|
6
6
|
}
|
|
7
|
-
export declare function getFrictionLogPath(envValue
|
|
7
|
+
export declare function getFrictionLogPath(envValue?: string | undefined, cwd?: string): null | string;
|
|
8
8
|
export declare function buildFrictionEntry(opts: {
|
|
9
9
|
argv?: string[];
|
|
10
10
|
binName?: string;
|
|
@@ -29,8 +29,12 @@ const MAX_STDERR_CAPTURE = 4000;
|
|
|
29
29
|
export function extractLastErrorLine(stderr) {
|
|
30
30
|
if (!stderr)
|
|
31
31
|
return undefined;
|
|
32
|
+
// eslint-disable-next-line no-control-regex
|
|
32
33
|
const stripped = stderr.replaceAll(/\u001B\[[\d;]*[A-Za-z]/g, '');
|
|
33
|
-
const lines = stripped
|
|
34
|
+
const lines = stripped
|
|
35
|
+
.split('\n')
|
|
36
|
+
.map((l) => l.trim())
|
|
37
|
+
.filter(Boolean);
|
|
34
38
|
if (lines.length === 0)
|
|
35
39
|
return undefined;
|
|
36
40
|
const errorLine = [...lines].reverse().find((l) => /^(error|»|×|✖)/i.test(l));
|
package/dist/util/get-client.js
CHANGED
|
@@ -20,10 +20,58 @@ const getClient = async (command, sdkKey, profile) => {
|
|
|
20
20
|
}
|
|
21
21
|
let jwt;
|
|
22
22
|
let workspaceId;
|
|
23
|
-
|
|
24
|
-
|
|
23
|
+
const apiKey = process.env.QUONFIG_API_KEY;
|
|
24
|
+
// Auth precedence: explicit SDK key → QUONFIG_API_KEY env (CI) → WorkOS OAuth tokens.
|
|
25
|
+
if (sdkKey) {
|
|
26
|
+
// No jwt / workspace resolution — SDK-key path talks to api-delivery.
|
|
27
|
+
}
|
|
28
|
+
else if (apiKey && apiKey.length > 0) {
|
|
29
|
+
// API-key (workspace-scoped bearer) path. getValidAccessToken short-circuits
|
|
30
|
+
// when QUONFIG_API_KEY is set, so this never touches disk.
|
|
31
|
+
try {
|
|
32
|
+
jwt = await getValidAccessToken();
|
|
33
|
+
}
|
|
34
|
+
catch (error) {
|
|
35
|
+
command.error(error instanceof Error ? error.message : String(error), { exit: 1 });
|
|
36
|
+
}
|
|
37
|
+
const workspaceOverride = profile ?? process.env.QUONFIG_WORKSPACE;
|
|
38
|
+
if (!workspaceOverride) {
|
|
39
|
+
command.error('QUONFIG_API_KEY is set but no workspace was specified. Set QUONFIG_WORKSPACE=<slug-or-uuid> or pass --workspace=<slug-or-uuid>.', { exit: 1 });
|
|
40
|
+
}
|
|
41
|
+
const uuidPattern = /^[\da-f]{8}(?:-[\da-f]{4}){3}-[\da-f]{12}$/i;
|
|
42
|
+
if (uuidPattern.test(workspaceOverride)) {
|
|
43
|
+
workspaceId = workspaceOverride;
|
|
44
|
+
command.verboseLog('ApiKey auth', { source: 'uuid', workspaceId });
|
|
45
|
+
}
|
|
46
|
+
else {
|
|
47
|
+
let res;
|
|
48
|
+
try {
|
|
49
|
+
res = await fetch(`${getApiUrl()}/api/v1/userWorkspaces/list`, {
|
|
50
|
+
method: 'POST',
|
|
51
|
+
headers: { Authorization: `Bearer ${jwt}`, 'Content-Type': 'application/json' },
|
|
52
|
+
body: JSON.stringify({ json: {} }),
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
catch (error) {
|
|
56
|
+
command.error(`Failed to resolve workspace slug "${workspaceOverride}": ${error instanceof Error ? error.message : String(error)}`, { exit: 1 });
|
|
57
|
+
}
|
|
58
|
+
if (!res.ok) {
|
|
59
|
+
command.error(`Failed to resolve workspace slug "${workspaceOverride}" (HTTP ${res.status}). Check that QUONFIG_API_KEY is valid.`, { exit: 1 });
|
|
60
|
+
}
|
|
61
|
+
const body = (await res.json());
|
|
62
|
+
const entries = body.json ?? body ?? [];
|
|
63
|
+
const match = entries.find((w) => w.workspaceSlug === workspaceOverride);
|
|
64
|
+
if (!match) {
|
|
65
|
+
const available = entries.map((w) => w.workspaceSlug).join(', ') || '(none)';
|
|
66
|
+
command.error(`No workspace with slug "${workspaceOverride}" is accessible with this API key. Available: ${available}.`, { exit: 1 });
|
|
67
|
+
}
|
|
68
|
+
workspaceId = match.workspaceId;
|
|
69
|
+
command.verboseLog('ApiKey auth', { source: 'slug', slug: workspaceOverride, workspaceId });
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
else {
|
|
25
73
|
const authConfig = await loadAuthConfig();
|
|
26
|
-
|
|
74
|
+
const tokens = await loadTokens();
|
|
27
75
|
command.verboseLog('OAuth auth', {
|
|
28
76
|
hasAuthConfig: Boolean(authConfig),
|
|
29
77
|
hasAccessToken: Boolean(tokens?.accessToken),
|
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Returns a valid (non-expired) WorkOS access token, refreshing if needed.
|
|
3
3
|
* Throws if not logged in or if refresh fails.
|
|
4
|
+
*
|
|
5
|
+
* If `QUONFIG_API_KEY` is set in the environment (non-empty), it is used
|
|
6
|
+
* directly as a bearer token and no disk reads/writes or refresh calls
|
|
7
|
+
* are made. This is the non-interactive (CI) path.
|
|
4
8
|
*/
|
|
5
9
|
export declare function getValidAccessToken(): Promise<string>;
|
|
@@ -1,10 +1,21 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Returns a valid (non-expired) WorkOS access token, refreshing if needed.
|
|
3
3
|
* Throws if not logged in or if refresh fails.
|
|
4
|
+
*
|
|
5
|
+
* If `QUONFIG_API_KEY` is set in the environment (non-empty), it is used
|
|
6
|
+
* directly as a bearer token and no disk reads/writes or refresh calls
|
|
7
|
+
* are made. This is the non-interactive (CI) path.
|
|
4
8
|
*/
|
|
5
9
|
import { decodeJWT, refreshAccessToken } from './oauth-client.js';
|
|
6
10
|
import { loadTokens, saveTokens } from './token-storage.js';
|
|
7
11
|
export async function getValidAccessToken() {
|
|
12
|
+
const envKey = process.env.QUONFIG_API_KEY;
|
|
13
|
+
if (envKey && envKey.length > 0) {
|
|
14
|
+
if (!envKey.startsWith('qf_uk_')) {
|
|
15
|
+
throw new Error(`QUONFIG_API_KEY must start with "qf_uk_" (got "${envKey.slice(0, 8)}..."). Generate one from the web UI under Settings → API Keys.`);
|
|
16
|
+
}
|
|
17
|
+
return envKey;
|
|
18
|
+
}
|
|
8
19
|
const tokens = await loadTokens();
|
|
9
20
|
if (!tokens?.accessToken) {
|
|
10
21
|
throw new Error('Not authenticated. Please run `qfg login` first.');
|
|
@@ -17,7 +28,9 @@ export async function getValidAccessToken() {
|
|
|
17
28
|
expired = payload.exp * 1000 < Date.now();
|
|
18
29
|
}
|
|
19
30
|
}
|
|
20
|
-
catch {
|
|
31
|
+
catch {
|
|
32
|
+
/* use stored expiresAt */
|
|
33
|
+
}
|
|
21
34
|
if (!expired) {
|
|
22
35
|
return tokens.accessToken;
|
|
23
36
|
}
|
|
@@ -32,7 +45,9 @@ export async function getValidAccessToken() {
|
|
|
32
45
|
expiresAt = payload.exp * 1000;
|
|
33
46
|
}
|
|
34
47
|
}
|
|
35
|
-
catch {
|
|
48
|
+
catch {
|
|
49
|
+
/* use fallback */
|
|
50
|
+
}
|
|
36
51
|
const updated = {
|
|
37
52
|
...tokens,
|
|
38
53
|
accessToken: refreshed.access_token,
|
package/dist/util/git-ops.js
CHANGED
|
@@ -4,7 +4,7 @@ const execFile = util.promisify(execFileCb);
|
|
|
4
4
|
/**
|
|
5
5
|
* Redact a token from a URL string so it is safe to display to users.
|
|
6
6
|
*/
|
|
7
|
-
export const redactToken = (url) => url.replace(/:([
|
|
7
|
+
export const redactToken = (url) => url.replace(/:([^/@]+)@/, ':***@');
|
|
8
8
|
/**
|
|
9
9
|
* Wrap execFile errors to strip tokens from the message.
|
|
10
10
|
*/
|
|
@@ -12,9 +12,9 @@ const safeExec = async (file, args, options) => {
|
|
|
12
12
|
try {
|
|
13
13
|
return await execFile(file, args, { cwd: options?.cwd });
|
|
14
14
|
}
|
|
15
|
-
catch (
|
|
16
|
-
const e =
|
|
17
|
-
const message = redactToken(e.message || String(
|
|
15
|
+
catch (error) {
|
|
16
|
+
const e = error;
|
|
17
|
+
const message = redactToken(e.message || String(error));
|
|
18
18
|
const stderr = redactToken(e.stderr ?? '');
|
|
19
19
|
const safeErr = new Error(message);
|
|
20
20
|
safeErr.stderr = stderr;
|
|
@@ -136,11 +136,11 @@ export const gitMergeFfOnly = async (dir) => {
|
|
|
136
136
|
*/
|
|
137
137
|
export const gitSetRemote = async (dir, url) => {
|
|
138
138
|
const existing = await getRemoteUrl(dir);
|
|
139
|
-
if (existing
|
|
140
|
-
await safeExec('git', ['-C', dir, 'remote', '
|
|
139
|
+
if (existing === null) {
|
|
140
|
+
await safeExec('git', ['-C', dir, 'remote', 'add', 'origin', url]);
|
|
141
141
|
}
|
|
142
142
|
else {
|
|
143
|
-
await safeExec('git', ['-C', dir, 'remote', '
|
|
143
|
+
await safeExec('git', ['-C', dir, 'remote', 'set-url', 'origin', url]);
|
|
144
144
|
}
|
|
145
145
|
};
|
|
146
146
|
export const gitPushForceLease = async (dir) => {
|
package/dist/util/gitea-api.d.ts
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { GiteaTokenEntry } from './gitea-token-storage.js';
|
|
2
2
|
export interface GiteaTokenResponse {
|
|
3
|
-
token: string;
|
|
4
|
-
repoUrl: string;
|
|
5
3
|
expiresAt: string | null;
|
|
4
|
+
repoUrl: string;
|
|
5
|
+
token: string;
|
|
6
6
|
}
|
|
7
7
|
export declare const mintGiteaToken: (workspaceId: string, scope: "read" | "write", purpose: "pull" | "bootstrap") => Promise<GiteaTokenResponse>;
|
|
8
8
|
/**
|
package/dist/util/gitea-api.js
CHANGED
|
@@ -7,7 +7,7 @@ export const mintGiteaToken = async (workspaceId, scope, purpose) => {
|
|
|
7
7
|
const res = await fetch(`${apiUrl}/api/v1/gitea/token`, {
|
|
8
8
|
method: 'POST',
|
|
9
9
|
headers: {
|
|
10
|
-
|
|
10
|
+
Authorization: `Bearer ${accessToken}`,
|
|
11
11
|
'Content-Type': 'application/json',
|
|
12
12
|
},
|
|
13
13
|
body: JSON.stringify({ json: { scope, workspaceId, purpose } }),
|
|
@@ -23,10 +23,12 @@ export const mintGiteaToken = async (workspaceId, scope, purpose) => {
|
|
|
23
23
|
else if (body?.message)
|
|
24
24
|
message = body.message;
|
|
25
25
|
}
|
|
26
|
-
catch {
|
|
26
|
+
catch {
|
|
27
|
+
/* use raw text */
|
|
28
|
+
}
|
|
27
29
|
throw new Error(message || text);
|
|
28
30
|
}
|
|
29
|
-
const body = await res.json();
|
|
31
|
+
const body = (await res.json());
|
|
30
32
|
const data = body.json ?? body;
|
|
31
33
|
return data;
|
|
32
34
|
};
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
export interface GiteaTokenEntry {
|
|
2
|
-
token: string;
|
|
3
|
-
repoUrl: string;
|
|
4
2
|
expiresAt: string | null;
|
|
3
|
+
repoUrl: string;
|
|
4
|
+
token: string;
|
|
5
5
|
}
|
|
6
6
|
export declare const loadGiteaToken: (workspaceId: string) => Promise<GiteaTokenEntry | null>;
|
|
7
7
|
export declare const saveGiteaToken: (workspaceId: string, entry: GiteaTokenEntry) => Promise<void>;
|
|
@@ -6,7 +6,7 @@ const BUFFER_MS = 5 * 60 * 1000; // 5 minutes
|
|
|
6
6
|
const getQuonfigDir = () => path.join(os.homedir(), '.quonfig');
|
|
7
7
|
const getGiteaTokenFile = () => {
|
|
8
8
|
const domain = getDomain();
|
|
9
|
-
const filename = domain
|
|
9
|
+
const filename = domain === 'quonfig.com' ? 'gitea-tokens.json' : `gitea-tokens-${domain.replaceAll('.', '-')}.json`;
|
|
10
10
|
return path.join(getQuonfigDir(), filename);
|
|
11
11
|
};
|
|
12
12
|
const ensureQuonfigDir = async () => {
|
|
@@ -53,7 +53,7 @@ export const pollForToken = async (deviceCode, interval, expiresIn, verbose) =>
|
|
|
53
53
|
return response.json();
|
|
54
54
|
}
|
|
55
55
|
// eslint-disable-next-line no-await-in-loop
|
|
56
|
-
const error = await response.json().catch(() => ({}));
|
|
56
|
+
const error = (await response.json().catch(() => ({})));
|
|
57
57
|
const errorCode = error.error || error.code;
|
|
58
58
|
if (errorCode === 'authorization_pending') {
|
|
59
59
|
continue;
|
|
@@ -5,7 +5,7 @@ import { getDomain } from './domain-urls.js';
|
|
|
5
5
|
const getQuonfigDir = (options) => options?.quonfigDir || path.join(os.homedir(), '.quonfig');
|
|
6
6
|
const getTokenFile = (options) => {
|
|
7
7
|
const domain = getDomain();
|
|
8
|
-
const filename = domain
|
|
8
|
+
const filename = domain === 'quonfig.com' ? 'tokens.json' : `tokens-${domain.replaceAll('.', '-')}.json`;
|
|
9
9
|
return path.join(getQuonfigDir(options), filename);
|
|
10
10
|
};
|
|
11
11
|
const getConfigFile = (options) => path.join(getQuonfigDir(options), 'config');
|
|
@@ -86,7 +86,7 @@ export const loadAuthConfig = async (options) => {
|
|
|
86
86
|
config.defaultProfile = defaultMatch[1].trim();
|
|
87
87
|
}
|
|
88
88
|
// Parse profiles — each block starts with [profile name] and contains key=value lines
|
|
89
|
-
const profileBlockRegex = /\[profile\s+(\w+)]\s*\n((?:[
|
|
89
|
+
const profileBlockRegex = /\[profile\s+(\w+)]\s*\n((?:[^[]*\n?)*)/g;
|
|
90
90
|
let match;
|
|
91
91
|
while ((match = profileBlockRegex.exec(data)) !== null) {
|
|
92
92
|
const profileName = match[1];
|
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
1
|
/**
|
|
3
2
|
* Standalone workspace validator.
|
|
4
3
|
*
|
|
@@ -11,19 +10,19 @@
|
|
|
11
10
|
* 0 All checks pass
|
|
12
11
|
* 1 Validation errors found
|
|
13
12
|
*/
|
|
14
|
-
import { execSync } from
|
|
15
|
-
import * as readline from
|
|
16
|
-
import { formatResult, validateFileMap, validateWorkspace } from
|
|
13
|
+
import { execSync } from 'node:child_process';
|
|
14
|
+
import * as readline from 'node:readline';
|
|
15
|
+
import { formatResult, validateFileMap, validateWorkspace } from './validate.js';
|
|
17
16
|
async function main() {
|
|
18
17
|
const args = process.argv.slice(2);
|
|
19
|
-
if (args.includes(
|
|
18
|
+
if (args.includes('--git-hook')) {
|
|
20
19
|
await runGitHook();
|
|
21
20
|
}
|
|
22
|
-
else if (args.includes(
|
|
21
|
+
else if (args.includes('--help') || args.includes('-h')) {
|
|
23
22
|
printUsage();
|
|
24
23
|
}
|
|
25
24
|
else {
|
|
26
|
-
const dir = args[0] ||
|
|
25
|
+
const dir = args[0] || '.';
|
|
27
26
|
runDiskValidation(dir);
|
|
28
27
|
}
|
|
29
28
|
}
|
|
@@ -55,23 +54,23 @@ async function runGitHook() {
|
|
|
55
54
|
// Read all of stdin (ref lines). Use callback API for bun compatibility.
|
|
56
55
|
await new Promise((resolve) => {
|
|
57
56
|
const rl = readline.createInterface({ input: process.stdin });
|
|
58
|
-
rl.on(
|
|
59
|
-
const parts = line.trim().split(
|
|
57
|
+
rl.on('line', (line) => {
|
|
58
|
+
const parts = line.trim().split(' ');
|
|
60
59
|
if (parts.length >= 3) {
|
|
61
60
|
refs.push({ oldOid: parts[0], newOid: parts[1], refName: parts[2] });
|
|
62
61
|
}
|
|
63
62
|
});
|
|
64
|
-
rl.on(
|
|
63
|
+
rl.on('close', resolve);
|
|
65
64
|
});
|
|
66
65
|
if (refs.length === 0) {
|
|
67
|
-
console.log(
|
|
66
|
+
console.log('qfg-verify: no refs received');
|
|
68
67
|
process.exit(0);
|
|
69
68
|
}
|
|
70
69
|
// Validate the latest pushed commit for each ref
|
|
71
70
|
let hasErrors = false;
|
|
72
71
|
for (const ref of refs) {
|
|
73
72
|
// Skip deletions
|
|
74
|
-
if (ref.newOid ===
|
|
73
|
+
if (ref.newOid === '0000000000000000000000000000000000000000')
|
|
75
74
|
continue;
|
|
76
75
|
console.log(`qfg-verify: validating ${ref.refName} (${ref.newOid.slice(0, 8)})`);
|
|
77
76
|
try {
|
|
@@ -82,8 +81,8 @@ async function runGitHook() {
|
|
|
82
81
|
hasErrors = true;
|
|
83
82
|
}
|
|
84
83
|
}
|
|
85
|
-
catch (
|
|
86
|
-
console.error(`qfg-verify: error reading commit ${ref.newOid}: ${
|
|
84
|
+
catch (error) {
|
|
85
|
+
console.error(`qfg-verify: error reading commit ${ref.newOid}: ${error.message}`);
|
|
87
86
|
hasErrors = true;
|
|
88
87
|
}
|
|
89
88
|
}
|
|
@@ -95,28 +94,28 @@ async function runGitHook() {
|
|
|
95
94
|
*/
|
|
96
95
|
function readFilesFromCommit(commitOid) {
|
|
97
96
|
const files = new Map();
|
|
98
|
-
const dirs = [
|
|
97
|
+
const dirs = ['configs', 'feature-flags', 'segments', 'log-levels', 'schemas', 'schemas-protected'];
|
|
99
98
|
for (const dir of dirs) {
|
|
100
99
|
// List files in this directory at the given commit
|
|
101
100
|
let listing;
|
|
102
101
|
try {
|
|
103
102
|
listing = execSync(`git ls-tree --name-only ${commitOid} ${dir}/`, {
|
|
104
|
-
encoding:
|
|
105
|
-
stdio: [
|
|
103
|
+
encoding: 'utf8',
|
|
104
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
106
105
|
});
|
|
107
106
|
}
|
|
108
107
|
catch {
|
|
109
108
|
// Directory doesn't exist in this commit
|
|
110
109
|
continue;
|
|
111
110
|
}
|
|
112
|
-
const filenames = listing.trim().split(
|
|
111
|
+
const filenames = listing.trim().split('\n').filter(Boolean);
|
|
113
112
|
for (const filePath of filenames) {
|
|
114
|
-
if (!filePath.endsWith(
|
|
113
|
+
if (!filePath.endsWith('.json') || filePath.includes('/.'))
|
|
115
114
|
continue;
|
|
116
115
|
try {
|
|
117
116
|
const content = execSync(`git show ${commitOid}:${filePath}`, {
|
|
118
|
-
encoding:
|
|
119
|
-
stdio: [
|
|
117
|
+
encoding: 'utf8',
|
|
118
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
120
119
|
});
|
|
121
120
|
files.set(filePath, content);
|
|
122
121
|
}
|
|
@@ -127,7 +126,7 @@ function readFilesFromCommit(commitOid) {
|
|
|
127
126
|
}
|
|
128
127
|
return files;
|
|
129
128
|
}
|
|
130
|
-
main().catch((
|
|
131
|
-
console.error(`qfg-verify: fatal: ${
|
|
129
|
+
main().catch((error) => {
|
|
130
|
+
console.error(`qfg-verify: fatal: ${error.message}`);
|
|
132
131
|
process.exit(1);
|
|
133
132
|
});
|