@quonfig/cli 0.0.18 → 0.0.20
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 +148 -37
- package/dist/codegen/code-generators/node-typescript-generator.js +2 -2
- package/dist/codegen/code-generators/react-typescript-generator.js +2 -2
- package/dist/commands/create.d.ts +1 -0
- package/dist/commands/create.js +103 -20
- package/dist/commands/log-level.d.ts +27 -0
- package/dist/commands/log-level.js +197 -0
- package/dist/commands/pull.js +5 -15
- package/dist/commands/push.js +5 -15
- package/dist/commands/set-default.js +1 -1
- package/dist/commands/workspace/create.d.ts +20 -0
- package/dist/commands/workspace/create.js +156 -0
- package/dist/util/encryption.d.ts +5 -2
- package/dist/util/encryption.js +11 -6
- package/dist/util/log-levels.d.ts +4 -0
- package/dist/util/log-levels.js +5 -0
- package/dist/util/resolve-workspace.d.ts +14 -0
- package/dist/util/resolve-workspace.js +75 -0
- package/dist/verify/validate.js +2 -1
- package/oclif.manifest.json +196 -3
- package/package.json +1 -1
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { APICommand } from '../index.js';
|
|
2
|
+
import { JsonObj } from '../result.js';
|
|
3
|
+
/**
|
|
4
|
+
* Convenience command for log-level configs.
|
|
5
|
+
*
|
|
6
|
+
* qfg log-level log-level.my-app --value=WARN
|
|
7
|
+
* → creates the config (delegates to `qfg create --type log_level`)
|
|
8
|
+
*
|
|
9
|
+
* qfg log-level log-level.my-app --target=MyPackage.Service --value=INFO
|
|
10
|
+
* → adds a targeting rule on "${SDK_LOGGER_CONTEXT}" to an existing
|
|
11
|
+
* config, using PROP_STARTS_WITH_ONE_OF. Lets agents dial a single
|
|
12
|
+
* logger up/down without pull+edit+push.
|
|
13
|
+
*/
|
|
14
|
+
export default class LogLevel extends APICommand {
|
|
15
|
+
static args: {
|
|
16
|
+
name: import("@oclif/core/lib/interfaces/parser.js").Arg<string, Record<string, unknown>>;
|
|
17
|
+
};
|
|
18
|
+
static description: string;
|
|
19
|
+
static examples: string[];
|
|
20
|
+
static flags: {
|
|
21
|
+
environment: import("@oclif/core/lib/interfaces/parser.js").OptionFlag<string | undefined, import("@oclif/core/lib/interfaces/parser.js").CustomOptions>;
|
|
22
|
+
target: import("@oclif/core/lib/interfaces/parser.js").OptionFlag<string[] | undefined, import("@oclif/core/lib/interfaces/parser.js").CustomOptions>;
|
|
23
|
+
value: import("@oclif/core/lib/interfaces/parser.js").OptionFlag<string | undefined, import("@oclif/core/lib/interfaces/parser.js").CustomOptions>;
|
|
24
|
+
};
|
|
25
|
+
run(): Promise<JsonObj | void>;
|
|
26
|
+
private addTargetingRule;
|
|
27
|
+
}
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
import { Args, Flags } from '@oclif/core';
|
|
2
|
+
import { APICommand } from '../index.js';
|
|
3
|
+
import { checkmark } from '../util/color.js';
|
|
4
|
+
import { LOG_LEVELS, LOG_LEVEL_KEY_PREFIX, isLogLevel } from '../util/log-levels.js';
|
|
5
|
+
const SDK_LOGGER_CONTEXT = 'quonfig-sdk-logging.key';
|
|
6
|
+
/**
|
|
7
|
+
* Convenience command for log-level configs.
|
|
8
|
+
*
|
|
9
|
+
* qfg log-level log-level.my-app --value=WARN
|
|
10
|
+
* → creates the config (delegates to `qfg create --type log_level`)
|
|
11
|
+
*
|
|
12
|
+
* qfg log-level log-level.my-app --target=MyPackage.Service --value=INFO
|
|
13
|
+
* → adds a targeting rule on "${SDK_LOGGER_CONTEXT}" to an existing
|
|
14
|
+
* config, using PROP_STARTS_WITH_ONE_OF. Lets agents dial a single
|
|
15
|
+
* logger up/down without pull+edit+push.
|
|
16
|
+
*/
|
|
17
|
+
export default class LogLevel extends APICommand {
|
|
18
|
+
static args = {
|
|
19
|
+
name: Args.string({
|
|
20
|
+
description: `log-level key (must start with "${LOG_LEVEL_KEY_PREFIX}", e.g. ${LOG_LEVEL_KEY_PREFIX}my-app)`,
|
|
21
|
+
required: true,
|
|
22
|
+
}),
|
|
23
|
+
};
|
|
24
|
+
static description = `Create or add targeting rules to a log-level config.
|
|
25
|
+
|
|
26
|
+
Log-level keys must start with "${LOG_LEVEL_KEY_PREFIX}" (e.g. ${LOG_LEVEL_KEY_PREFIX}my-app). The
|
|
27
|
+
default value applies to every logger in that service unless a targeting
|
|
28
|
+
rule matches first.
|
|
29
|
+
|
|
30
|
+
Two modes:
|
|
31
|
+
|
|
32
|
+
1. Create a new log-level config with a default value:
|
|
33
|
+
qfg log-level ${LOG_LEVEL_KEY_PREFIX}my-app --value=WARN
|
|
34
|
+
|
|
35
|
+
2. Add a per-logger targeting rule to an existing config. SDKs populate
|
|
36
|
+
the "${SDK_LOGGER_CONTEXT}" context automatically from the logger path,
|
|
37
|
+
so this rule fires for any logger whose name starts with --target:
|
|
38
|
+
qfg log-level ${LOG_LEVEL_KEY_PREFIX}my-app --target=MyPackage.Noisy --value=ERROR
|
|
39
|
+
|
|
40
|
+
Pass --target multiple times to match any of several prefixes (OR).
|
|
41
|
+
Pass --environment=<env> to scope the rule to a single environment.
|
|
42
|
+
|
|
43
|
+
To update the catch-all default for an existing log level, use set-default:
|
|
44
|
+
qfg set-default ${LOG_LEVEL_KEY_PREFIX}my-app --value=DEBUG --environment=production
|
|
45
|
+
|
|
46
|
+
For more complex rules (regex, multi-criterion, exact match) edit the JSON:
|
|
47
|
+
qfg pull && $EDITOR log-levels/${LOG_LEVEL_KEY_PREFIX}my-app.json
|
|
48
|
+
qfg config-schema # full operator reference`;
|
|
49
|
+
static examples = [
|
|
50
|
+
`<%= config.bin %> <%= command.id %> ${LOG_LEVEL_KEY_PREFIX}my-app --value=WARN`,
|
|
51
|
+
`<%= config.bin %> <%= command.id %> ${LOG_LEVEL_KEY_PREFIX}my-app --target=MyPackage.Noisy --value=ERROR`,
|
|
52
|
+
`<%= config.bin %> <%= command.id %> ${LOG_LEVEL_KEY_PREFIX}my-app --target=A --target=B --value=DEBUG`,
|
|
53
|
+
`<%= config.bin %> <%= command.id %> ${LOG_LEVEL_KEY_PREFIX}my-app --target=Chatty --value=INFO --environment=production`,
|
|
54
|
+
];
|
|
55
|
+
static flags = {
|
|
56
|
+
environment: Flags.string({
|
|
57
|
+
description: 'when used with --target, scope the rule to this environment instead of the default',
|
|
58
|
+
}),
|
|
59
|
+
target: Flags.string({
|
|
60
|
+
description: `logger path prefix (matched via PROP_STARTS_WITH_ONE_OF on "${SDK_LOGGER_CONTEXT}"); repeatable`,
|
|
61
|
+
multiple: true,
|
|
62
|
+
}),
|
|
63
|
+
value: Flags.string({
|
|
64
|
+
description: 'log level — either the new default (create mode) or the level for the targeted loggers',
|
|
65
|
+
options: [...LOG_LEVELS],
|
|
66
|
+
required: false,
|
|
67
|
+
}),
|
|
68
|
+
};
|
|
69
|
+
async run() {
|
|
70
|
+
const { args, flags } = await this.parse(LogLevel);
|
|
71
|
+
if (!args.name.startsWith(LOG_LEVEL_KEY_PREFIX)) {
|
|
72
|
+
return this.err(`Log level key "${args.name}" must start with "${LOG_LEVEL_KEY_PREFIX}". Try: ${LOG_LEVEL_KEY_PREFIX}${args.name}`);
|
|
73
|
+
}
|
|
74
|
+
if (!flags.target || flags.target.length === 0) {
|
|
75
|
+
// No targeting — delegate to create.
|
|
76
|
+
if (flags.environment) {
|
|
77
|
+
return this.err('--environment requires --target. To change the default for an existing log level in an environment, use: qfg set-default');
|
|
78
|
+
}
|
|
79
|
+
const argv = [args.name, '--type=log_level'];
|
|
80
|
+
if (flags.value)
|
|
81
|
+
argv.push(`--value=${flags.value}`);
|
|
82
|
+
if (this.jsonEnabled())
|
|
83
|
+
argv.push('--json');
|
|
84
|
+
return this.config.runCommand('create', argv);
|
|
85
|
+
}
|
|
86
|
+
// Targeting mode — add a rule on quonfig-sdk-logging.key.
|
|
87
|
+
if (!flags.value) {
|
|
88
|
+
return this.err('--value is required with --target (the level the targeted loggers should use)');
|
|
89
|
+
}
|
|
90
|
+
return this.addTargetingRule(args.name, flags.target, flags.value, flags.environment);
|
|
91
|
+
}
|
|
92
|
+
async addTargetingRule(key, targets, levelRaw, environment) {
|
|
93
|
+
const level = levelRaw.toUpperCase();
|
|
94
|
+
if (!isLogLevel(level)) {
|
|
95
|
+
return this.err(`Invalid log level "${levelRaw}". Must be one of: ${LOG_LEVELS.join(', ')}`);
|
|
96
|
+
}
|
|
97
|
+
// Fetch current config — we need the commitSha for optimistic concurrency
|
|
98
|
+
// plus the existing rules so we can merge rather than replace.
|
|
99
|
+
const detailRequest = await this.apiClient.post('/api/v1/metadata/getByKey', {
|
|
100
|
+
workspaceId: this.workspaceId,
|
|
101
|
+
key,
|
|
102
|
+
});
|
|
103
|
+
if (!detailRequest.ok) {
|
|
104
|
+
if (detailRequest.status === 404) {
|
|
105
|
+
return this.err(`Log level "${key}" does not exist. Create it first:\n qfg log-level ${key} --value=INFO`);
|
|
106
|
+
}
|
|
107
|
+
return this.err(`Failed to fetch log level: ${detailRequest.status}`, { serverError: detailRequest.error });
|
|
108
|
+
}
|
|
109
|
+
const current = detailRequest.json;
|
|
110
|
+
const newRule = {
|
|
111
|
+
criteria: [
|
|
112
|
+
{
|
|
113
|
+
operator: 'PROP_STARTS_WITH_ONE_OF',
|
|
114
|
+
propertyName: SDK_LOGGER_CONTEXT,
|
|
115
|
+
// Schema: CriterionSchema.valueToMatch is a Value, i.e. { type, value }.
|
|
116
|
+
// For string-list operators like PROP_STARTS_WITH_ONE_OF this is a string_list.
|
|
117
|
+
valueToMatch: { type: 'string_list', value: [...targets] },
|
|
118
|
+
},
|
|
119
|
+
],
|
|
120
|
+
value: { type: 'log_level', value: level },
|
|
121
|
+
};
|
|
122
|
+
// Merge semantics: if an existing rule has the same exact targeting
|
|
123
|
+
// criterion (same propertyName, same valueToMatch set), replace its value.
|
|
124
|
+
// Otherwise insert the new rule before the catch-all so it takes priority.
|
|
125
|
+
const existingRules = environment
|
|
126
|
+
? findEnvRules(current, environment)
|
|
127
|
+
: current.default?.rules ?? [];
|
|
128
|
+
const mergedRules = mergeTargetingRule(existingRules, newRule);
|
|
129
|
+
const logLevelPatch = environment
|
|
130
|
+
? { environments: replaceEnvRules(current.environments ?? [], environment, mergedRules) }
|
|
131
|
+
: { default: { rules: mergedRules } };
|
|
132
|
+
const updateRequest = await this.apiClient.post('/api/v1/logLevels/update', {
|
|
133
|
+
workspaceId: this.workspaceId,
|
|
134
|
+
logLevelKey: key,
|
|
135
|
+
logLevel: logLevelPatch,
|
|
136
|
+
expectedCommitSha: current.commitSha,
|
|
137
|
+
});
|
|
138
|
+
if (!updateRequest.ok) {
|
|
139
|
+
return this.err(`Failed to update log level: ${updateRequest.status}`, { serverError: updateRequest.error });
|
|
140
|
+
}
|
|
141
|
+
const scope = environment ? `environment=${environment}` : 'default';
|
|
142
|
+
const targetList = targets.join(', ');
|
|
143
|
+
return this.ok(`${checkmark} Set log level ${level} for loggers starting with [${targetList}] on ${key} (${scope})`, {
|
|
144
|
+
key,
|
|
145
|
+
targets,
|
|
146
|
+
value: level,
|
|
147
|
+
environment: environment ?? null,
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
function findEnvRules(config, environment) {
|
|
152
|
+
const env = (config.environments ?? []).find((e) => e.id === environment);
|
|
153
|
+
return env?.rules ?? [];
|
|
154
|
+
}
|
|
155
|
+
function replaceEnvRules(envs, environment, rules) {
|
|
156
|
+
const hasEnv = envs.some((e) => e.id === environment);
|
|
157
|
+
return hasEnv ? envs.map((e) => (e.id === environment ? { ...e, rules } : e)) : [...envs, { id: environment, rules }];
|
|
158
|
+
}
|
|
159
|
+
function mergeTargetingRule(existing, incoming) {
|
|
160
|
+
const incomingTargets = new Set(stringListTargets(incoming.criteria[0].valueToMatch));
|
|
161
|
+
const incomingPropertyName = incoming.criteria[0].propertyName;
|
|
162
|
+
let replaced = false;
|
|
163
|
+
const next = existing.map((rule) => {
|
|
164
|
+
if (rule.criteria.length !== 1)
|
|
165
|
+
return rule;
|
|
166
|
+
const c = rule.criteria[0];
|
|
167
|
+
if (c.operator !== 'PROP_STARTS_WITH_ONE_OF')
|
|
168
|
+
return rule;
|
|
169
|
+
if (c.propertyName !== incomingPropertyName)
|
|
170
|
+
return rule;
|
|
171
|
+
const existingTargets = new Set(stringListTargets(c.valueToMatch));
|
|
172
|
+
if (existingTargets.size !== incomingTargets.size)
|
|
173
|
+
return rule;
|
|
174
|
+
for (const t of existingTargets)
|
|
175
|
+
if (!incomingTargets.has(t))
|
|
176
|
+
return rule;
|
|
177
|
+
replaced = true;
|
|
178
|
+
return { ...rule, value: incoming.value };
|
|
179
|
+
});
|
|
180
|
+
if (replaced)
|
|
181
|
+
return next;
|
|
182
|
+
// Prepend — it must win against any catch-all rule that's already there.
|
|
183
|
+
return [incoming, ...existing];
|
|
184
|
+
}
|
|
185
|
+
/**
|
|
186
|
+
* Pull the string array out of a `valueToMatch` value. Handles the wrapped
|
|
187
|
+
* `{type: 'string_list', value: [...]}` envelope that the schema requires,
|
|
188
|
+
* plus a defensive fallback for the bare-array shape (older data or tests).
|
|
189
|
+
*/
|
|
190
|
+
function stringListTargets(v) {
|
|
191
|
+
if (Array.isArray(v))
|
|
192
|
+
return v;
|
|
193
|
+
if (v && typeof v === 'object' && Array.isArray(v.value)) {
|
|
194
|
+
return v.value;
|
|
195
|
+
}
|
|
196
|
+
return [];
|
|
197
|
+
}
|
package/dist/commands/pull.js
CHANGED
|
@@ -2,10 +2,10 @@ import * as path from 'node:path';
|
|
|
2
2
|
import * as fs from 'node:fs';
|
|
3
3
|
import { Flags } from '@oclif/core';
|
|
4
4
|
import { BaseCommand } from '../index.js';
|
|
5
|
-
import { getActiveProfile, loadAuthConfig } from '../util/token-storage.js';
|
|
6
5
|
import { loadGiteaToken, isGiteaTokenExpired, saveGiteaToken } from '../util/gitea-token-storage.js';
|
|
7
6
|
import { mintAndStoreGiteaReadToken, mintGiteaToken } from '../util/gitea-api.js';
|
|
8
7
|
import { readWorkspaceSlug, writeWorkspaceSlug } from '../util/quonfig-json.js';
|
|
8
|
+
import { resolveWorkspaceUuid } from '../util/resolve-workspace.js';
|
|
9
9
|
import { isGitRepo, getRemoteUrl, gitFetch, gitSetRemote, canFastForward, hasDivergedFromRemote, gitMergeFfOnly, gitClone, displayUrl, } from '../util/git-ops.js';
|
|
10
10
|
export default class Pull extends BaseCommand {
|
|
11
11
|
static description = `Clone or update a local copy of your workspace config files.
|
|
@@ -41,20 +41,10 @@ CLI shortcuts (no JSON editing needed for simple cases):
|
|
|
41
41
|
return this.err('No directory specified. Use --dir <path> or set QUONFIG_DIR.');
|
|
42
42
|
}
|
|
43
43
|
const resolvedDir = path.resolve(dir);
|
|
44
|
-
// Resolve workspace ID
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
if (!authConfig) {
|
|
49
|
-
return this.err('Not logged in. Please run `qfg login` first.');
|
|
50
|
-
}
|
|
51
|
-
const activeProfile = getActiveProfile();
|
|
52
|
-
const profile = authConfig.profiles[activeProfile] || authConfig.profiles[authConfig.defaultProfile || 'default'];
|
|
53
|
-
if (!profile) {
|
|
54
|
-
return this.err('No active profile found. Please run `qfg login` first.');
|
|
55
|
-
}
|
|
56
|
-
workspaceId = profile.workspace;
|
|
57
|
-
}
|
|
44
|
+
// Resolve workspace ID — supports both OAuth and QUONFIG_API_KEY paths,
|
|
45
|
+
// mirroring what APICommand does via get-client.ts so `qfg pull` behaves
|
|
46
|
+
// the same in CI as `qfg create` or `qfg push`.
|
|
47
|
+
const workspaceId = await resolveWorkspaceUuid(this, flags.workspace);
|
|
58
48
|
this.verboseLog('Pull', { workspaceId, dir: resolvedDir });
|
|
59
49
|
// Get or mint Gitea read token
|
|
60
50
|
const tokenEntry = await this.resolveTokenEntry(workspaceId);
|
package/dist/commands/push.js
CHANGED
|
@@ -8,7 +8,7 @@ import { mintGiteaToken } from '../util/gitea-api.js';
|
|
|
8
8
|
import { getRemoteUrl, gitFetch, gitSetRemote, isGitRepo, } from '../util/git-ops.js';
|
|
9
9
|
import { PushFatalError, runPush } from '../push/run-push.js';
|
|
10
10
|
import { computeBarePathDiff } from '../push/bare-path-diff.js';
|
|
11
|
-
import {
|
|
11
|
+
import { resolveWorkspaceUuid } from '../util/resolve-workspace.js';
|
|
12
12
|
const execFile = util.promisify(execFileCb);
|
|
13
13
|
export default class Push extends BaseCommand {
|
|
14
14
|
static description = `Push local config changes up to your workspace on Quonfig cloud.
|
|
@@ -60,20 +60,10 @@ Enforces three guards before touching the remote:
|
|
|
60
60
|
return this.err('No directory specified. Use --dir <path> or set QUONFIG_DIR.');
|
|
61
61
|
}
|
|
62
62
|
const resolvedDir = path.resolve(dir);
|
|
63
|
-
// Resolve requested target
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
if (!authConfig) {
|
|
68
|
-
return this.err('No active profile workspace; pass --workspace or run `qfg login`.');
|
|
69
|
-
}
|
|
70
|
-
const activeProfile = getActiveProfile();
|
|
71
|
-
const profile = authConfig.profiles[activeProfile] || authConfig.profiles[authConfig.defaultProfile || 'default'];
|
|
72
|
-
if (!profile || !profile.workspace) {
|
|
73
|
-
return this.err('No active profile workspace; pass --workspace or run `qfg login`.');
|
|
74
|
-
}
|
|
75
|
-
requestedTarget = profile.workspace;
|
|
76
|
-
}
|
|
63
|
+
// Resolve requested target. --workspace > QUONFIG_WORKSPACE > active OAuth
|
|
64
|
+
// profile. Supports API-key mode for headless runs so `qfg push` behaves
|
|
65
|
+
// the same way `qfg create` does (both resolve via resolve-workspace.ts).
|
|
66
|
+
const requestedTarget = await resolveWorkspaceUuid(this, flags.workspace);
|
|
77
67
|
const { deps, cleanup } = buildRealDeps(this);
|
|
78
68
|
try {
|
|
79
69
|
const result = await runPush({
|
|
@@ -192,7 +192,7 @@ To see all current values and rules for a flag:
|
|
|
192
192
|
successMessage = `Successfully changed default to \`${value}\``;
|
|
193
193
|
if (secret.selected) {
|
|
194
194
|
// Handle encrypted values using shared utility
|
|
195
|
-
const encryptedValueResult = await makeConfidentialValue(this, value, secret, environment.
|
|
195
|
+
const encryptedValueResult = await makeConfidentialValue(this, value, secret, environment.name);
|
|
196
196
|
if (!encryptedValueResult.ok) {
|
|
197
197
|
return this.err(encryptedValueResult.message || 'Failed to encrypt value');
|
|
198
198
|
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type { JsonObj } from '../../result.js';
|
|
2
|
+
import { BaseCommand } from '../../index.js';
|
|
3
|
+
export default class WorkspaceCreate extends BaseCommand {
|
|
4
|
+
static args: {
|
|
5
|
+
slug: import("@oclif/core/lib/interfaces/parser.js").Arg<string, Record<string, unknown>>;
|
|
6
|
+
};
|
|
7
|
+
static description: string;
|
|
8
|
+
static examples: string[];
|
|
9
|
+
static flags: {
|
|
10
|
+
name: import("@oclif/core/lib/interfaces/parser.js").OptionFlag<string | undefined, import("@oclif/core/lib/interfaces/parser.js").CustomOptions>;
|
|
11
|
+
org: import("@oclif/core/lib/interfaces/parser.js").OptionFlag<string | undefined, import("@oclif/core/lib/interfaces/parser.js").CustomOptions>;
|
|
12
|
+
};
|
|
13
|
+
run(): Promise<JsonObj | void>;
|
|
14
|
+
/**
|
|
15
|
+
* oRPC surfaces structured errors as {json: {code, message, ...}} or
|
|
16
|
+
* {error: {...}}. Extract the message for display; fall back to raw
|
|
17
|
+
* text when the body isn't JSON.
|
|
18
|
+
*/
|
|
19
|
+
private extractErrorMessage;
|
|
20
|
+
}
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
import { Args, Flags } from '@oclif/core';
|
|
2
|
+
import { BaseCommand } from '../../index.js';
|
|
3
|
+
import { getApiUrl } from '../../util/domain-urls.js';
|
|
4
|
+
import { getValidAccessToken } from '../../util/get-valid-token.js';
|
|
5
|
+
/**
|
|
6
|
+
* Slugify matches the UI's rules exactly (see
|
|
7
|
+
* app-quonfig/src/app/(app)/workspaces/new/page.tsx): lowercase, any
|
|
8
|
+
* non-alphanumeric run → single hyphen, strip leading/trailing hyphens.
|
|
9
|
+
* The server re-runs this for safety, but we run it here so the user
|
|
10
|
+
* sees the canonical slug in confirmation output.
|
|
11
|
+
*/
|
|
12
|
+
function slugify(value) {
|
|
13
|
+
return value
|
|
14
|
+
.toLowerCase()
|
|
15
|
+
.replaceAll(/[^\da-z]+/g, '-')
|
|
16
|
+
.replaceAll(/^-|-$/g, '');
|
|
17
|
+
}
|
|
18
|
+
export default class WorkspaceCreate extends BaseCommand {
|
|
19
|
+
static args = {
|
|
20
|
+
slug: Args.string({
|
|
21
|
+
description: 'Workspace slug (lowercase letters, numbers, hyphens)',
|
|
22
|
+
required: true,
|
|
23
|
+
}),
|
|
24
|
+
};
|
|
25
|
+
static description = "Create a new workspace. Provisions the Gitea repo and default environments, identical to the app's New Workspace UI.";
|
|
26
|
+
static examples = [
|
|
27
|
+
'<%= config.bin %> <%= command.id %> my-team',
|
|
28
|
+
'<%= config.bin %> <%= command.id %> lt-21-smoke --name "Load Test 21"',
|
|
29
|
+
'<%= config.bin %> <%= command.id %> my-team --org 11111111-1111-1111-1111-111111111111',
|
|
30
|
+
];
|
|
31
|
+
static flags = {
|
|
32
|
+
name: Flags.string({
|
|
33
|
+
description: 'Human-readable display name (defaults to the slug)',
|
|
34
|
+
required: false,
|
|
35
|
+
}),
|
|
36
|
+
org: Flags.string({
|
|
37
|
+
description: 'Organization UUID or slug. Required when you belong to more than one organization; inferred otherwise.',
|
|
38
|
+
required: false,
|
|
39
|
+
}),
|
|
40
|
+
};
|
|
41
|
+
async run() {
|
|
42
|
+
const { args, flags } = await this.parse(WorkspaceCreate);
|
|
43
|
+
const slug = slugify(args.slug);
|
|
44
|
+
if (!slug) {
|
|
45
|
+
return this.err(`"${args.slug}" does not produce a valid slug. Use letters, numbers, or hyphens.`);
|
|
46
|
+
}
|
|
47
|
+
// Resolve auth via the same path every other command uses. We
|
|
48
|
+
// don't require an active-workspace profile (AuthConfig) — the
|
|
49
|
+
// whole point of this command is to create one — but we do need a
|
|
50
|
+
// valid bearer token / API key.
|
|
51
|
+
let accessToken;
|
|
52
|
+
try {
|
|
53
|
+
accessToken = await getValidAccessToken();
|
|
54
|
+
}
|
|
55
|
+
catch {
|
|
56
|
+
return this.err('Not logged in. Run `qfg login` first.');
|
|
57
|
+
}
|
|
58
|
+
const apiUrl = getApiUrl();
|
|
59
|
+
const body = { slug };
|
|
60
|
+
if (flags.name)
|
|
61
|
+
body.name = flags.name;
|
|
62
|
+
if (flags.org) {
|
|
63
|
+
// Accept either a UUID (from the API) or a human-readable slug.
|
|
64
|
+
// The server resolves the slug to the local org UUID.
|
|
65
|
+
const uuidPattern = /^[\da-f]{8}(?:-[\da-f]{4}){3}-[\da-f]{12}$/i;
|
|
66
|
+
if (uuidPattern.test(flags.org)) {
|
|
67
|
+
body.organizationId = flags.org;
|
|
68
|
+
}
|
|
69
|
+
else {
|
|
70
|
+
body.organizationSlug = flags.org;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
this.verboseLog('WorkspaceCreate', { apiUrl, body });
|
|
74
|
+
let res;
|
|
75
|
+
try {
|
|
76
|
+
res = await fetch(`${apiUrl}/api/v1/workspaces/create`, {
|
|
77
|
+
method: 'POST',
|
|
78
|
+
headers: {
|
|
79
|
+
Authorization: `Bearer ${accessToken}`,
|
|
80
|
+
'Content-Type': 'application/json',
|
|
81
|
+
},
|
|
82
|
+
body: JSON.stringify({ json: body }),
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
catch (error) {
|
|
86
|
+
return this.err(`Request failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
87
|
+
}
|
|
88
|
+
if (res.status === 401 || res.status === 403) {
|
|
89
|
+
return this.err(`Authentication/authorization failed (HTTP ${res.status}). Run \`qfg login\` to re-authenticate.`);
|
|
90
|
+
}
|
|
91
|
+
if (res.status === 409) {
|
|
92
|
+
const detail = await this.extractErrorMessage(res);
|
|
93
|
+
// Prefer the server's message when present (already clear); fall
|
|
94
|
+
// back to a generated hint otherwise.
|
|
95
|
+
return this.err(detail || `Workspace "${slug}" already exists. Pick a different slug (e.g. ${slug}-2).`);
|
|
96
|
+
}
|
|
97
|
+
if (res.status === 400) {
|
|
98
|
+
const detail = await this.extractErrorMessage(res);
|
|
99
|
+
if (detail.includes('more than one organization')) {
|
|
100
|
+
return this.err(`${detail} Re-run with --org <uuid>. You can list available orgs from the app UI.`);
|
|
101
|
+
}
|
|
102
|
+
return this.err(`Bad request (HTTP 400): ${detail}`);
|
|
103
|
+
}
|
|
104
|
+
if (!res.ok) {
|
|
105
|
+
const detail = await this.extractErrorMessage(res);
|
|
106
|
+
return this.err(`Workspace creation failed (HTTP ${res.status}): ${detail}`);
|
|
107
|
+
}
|
|
108
|
+
const payload = (await res.json());
|
|
109
|
+
const created = (payload.json ?? payload);
|
|
110
|
+
if (!created?.workspaceId) {
|
|
111
|
+
return this.err('Unexpected response from server — no workspaceId returned.');
|
|
112
|
+
}
|
|
113
|
+
this.log('');
|
|
114
|
+
this.log(`Workspace created.`);
|
|
115
|
+
this.log(` ID: ${created.workspaceId}`);
|
|
116
|
+
this.log(` Slug: ${created.workspaceSlug}`);
|
|
117
|
+
if (created.organizationSlug) {
|
|
118
|
+
this.log(` Org: ${created.organizationSlug}`);
|
|
119
|
+
}
|
|
120
|
+
this.log(` Git repo: ${created.gitRepoUrl}`);
|
|
121
|
+
if (created.gitRepoFullName) {
|
|
122
|
+
this.log(` Gitea: ${created.gitRepoFullName}`);
|
|
123
|
+
}
|
|
124
|
+
this.log(` Envs: ${created.environments.map((e) => e.name).join(', ')}`);
|
|
125
|
+
this.log('');
|
|
126
|
+
this.log(`To use this workspace locally:`);
|
|
127
|
+
this.log(` qfg workspace switch ${created.workspaceSlug}`);
|
|
128
|
+
return {
|
|
129
|
+
workspaceId: created.workspaceId,
|
|
130
|
+
workspaceSlug: created.workspaceSlug,
|
|
131
|
+
organizationSlug: created.organizationSlug,
|
|
132
|
+
gitRepoUrl: created.gitRepoUrl,
|
|
133
|
+
gitRepoFullName: created.gitRepoFullName ?? null,
|
|
134
|
+
environments: created.environments,
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
/**
|
|
138
|
+
* oRPC surfaces structured errors as {json: {code, message, ...}} or
|
|
139
|
+
* {error: {...}}. Extract the message for display; fall back to raw
|
|
140
|
+
* text when the body isn't JSON.
|
|
141
|
+
*/
|
|
142
|
+
async extractErrorMessage(res) {
|
|
143
|
+
try {
|
|
144
|
+
const body = (await res.clone().json());
|
|
145
|
+
return body.json?.message || body.error?.message || body.message || '';
|
|
146
|
+
}
|
|
147
|
+
catch {
|
|
148
|
+
try {
|
|
149
|
+
return await res.text();
|
|
150
|
+
}
|
|
151
|
+
catch {
|
|
152
|
+
return '';
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
}
|
|
@@ -14,7 +14,10 @@ export declare function decrypt(encryptedString: string, keyStringHex: string):
|
|
|
14
14
|
* @param command - The APICommand instance for API calls and logging
|
|
15
15
|
* @param value - The plaintext value to encrypt
|
|
16
16
|
* @param secret - Secret configuration containing key name
|
|
17
|
-
* @param
|
|
17
|
+
* @param environmentName - Environment slug to fetch encryption key for (empty string for default).
|
|
18
|
+
* Must be the slug stored in config files (e.g. "production"), not the DB UUID — the encryption
|
|
19
|
+
* key config's `environments[].id` holds slugs, and matching against UUIDs silently falls back
|
|
20
|
+
* to the default key and produces ciphertext that won't decrypt at runtime.
|
|
18
21
|
* @returns Result<ConfigValue> with encrypted value or error
|
|
19
22
|
*/
|
|
20
|
-
export declare function makeConfidentialValue(command: APICommand, value: string, secret: Secret,
|
|
23
|
+
export declare function makeConfidentialValue(command: APICommand, value: string, secret: Secret, environmentName: string): Promise<Result<any>>;
|
package/dist/util/encryption.js
CHANGED
|
@@ -53,10 +53,13 @@ export function decrypt(encryptedString, keyStringHex) {
|
|
|
53
53
|
* @param command - The APICommand instance for API calls and logging
|
|
54
54
|
* @param value - The plaintext value to encrypt
|
|
55
55
|
* @param secret - Secret configuration containing key name
|
|
56
|
-
* @param
|
|
56
|
+
* @param environmentName - Environment slug to fetch encryption key for (empty string for default).
|
|
57
|
+
* Must be the slug stored in config files (e.g. "production"), not the DB UUID — the encryption
|
|
58
|
+
* key config's `environments[].id` holds slugs, and matching against UUIDs silently falls back
|
|
59
|
+
* to the default key and produces ciphertext that won't decrypt at runtime.
|
|
57
60
|
* @returns Result<ConfigValue> with encrypted value or error
|
|
58
61
|
*/
|
|
59
|
-
export async function makeConfidentialValue(command, value, secret,
|
|
62
|
+
export async function makeConfidentialValue(command, value, secret, environmentName) {
|
|
60
63
|
// First check if the encryption key exists in metadata
|
|
61
64
|
const metadataRequest = await command.apiClient.post('/api/v1/metadata/list', { workspaceId: command.workspaceId });
|
|
62
65
|
if (!metadataRequest.ok) {
|
|
@@ -89,10 +92,12 @@ export async function makeConfidentialValue(command, value, secret, environmentI
|
|
|
89
92
|
// Find the encryption key for this environment (or default)
|
|
90
93
|
let secretKey;
|
|
91
94
|
let envVar;
|
|
92
|
-
// Check environment-specific config first
|
|
93
|
-
|
|
95
|
+
// Check environment-specific config first. environments[].id is the env slug
|
|
96
|
+
// (e.g. "production") as stored in the git file — match by slug, not UUID.
|
|
97
|
+
if (keyConfig.environments && environmentName) {
|
|
98
|
+
const targetSlug = String(environmentName).toLowerCase();
|
|
94
99
|
/* eslint-disable @typescript-eslint/no-explicit-any */
|
|
95
|
-
const envConfig = keyConfig.environments.find((env) => String(env.id) ===
|
|
100
|
+
const envConfig = keyConfig.environments.find((env) => String(env.id).toLowerCase() === targetSlug);
|
|
96
101
|
/* eslint-enable @typescript-eslint/no-explicit-any */
|
|
97
102
|
const ruleValue = envConfig?.rules?.[0]?.value;
|
|
98
103
|
// Check if it's a provided value (env var)
|
|
@@ -139,7 +144,7 @@ export async function makeConfidentialValue(command, value, secret, environmentI
|
|
|
139
144
|
command.verboseLog(`Using literal value from ${secret.keyName} to encrypt secret`);
|
|
140
145
|
}
|
|
141
146
|
if (!secretKey) {
|
|
142
|
-
return failure(`Failed to create secret: ${secret.keyName} does not have a value configured for environment ${
|
|
147
|
+
return failure(`Failed to create secret: ${secret.keyName} does not have a value configured for environment ${environmentName || 'default'} or default env`, {
|
|
143
148
|
phase: 'finding-secret',
|
|
144
149
|
});
|
|
145
150
|
}
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
export declare const LOG_LEVELS: readonly ["TRACE", "DEBUG", "INFO", "WARN", "ERROR", "FATAL"];
|
|
2
|
+
export type LogLevel = (typeof LOG_LEVELS)[number];
|
|
3
|
+
export declare const LOG_LEVEL_KEY_PREFIX = "log-level.";
|
|
4
|
+
export declare function isLogLevel(value: string): value is LogLevel;
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { BaseCommand } from '../index.js';
|
|
2
|
+
/**
|
|
3
|
+
* Resolve the target workspace UUID for commands that don't extend APICommand
|
|
4
|
+
* (e.g. pull, push — which talk to Gitea, not the oRPC API, but still need to
|
|
5
|
+
* know which workspace to mint a Gitea token for).
|
|
6
|
+
*
|
|
7
|
+
* Handles all three auth paths in the same order as util/get-client.ts:
|
|
8
|
+
* 1. QUONFIG_API_KEY + QUONFIG_WORKSPACE (or --workspace flag) — CI/headless.
|
|
9
|
+
* 2. OAuth profile — the default interactive path.
|
|
10
|
+
*
|
|
11
|
+
* Mirrors the error messages from get-client.ts so behavior feels identical
|
|
12
|
+
* whether you're hitting the API directly or going through Gitea.
|
|
13
|
+
*/
|
|
14
|
+
export declare function resolveWorkspaceUuid(command: BaseCommand, flagOverride?: string): Promise<string>;
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { getApiUrl } from './domain-urls.js';
|
|
2
|
+
import { getValidAccessToken } from './get-valid-token.js';
|
|
3
|
+
import { getActiveProfile, loadAuthConfig, resolveWorkspaceId as resolveOAuthWorkspaceId, } from './token-storage.js';
|
|
4
|
+
const UUID_PATTERN = /^[\da-f]{8}(?:-[\da-f]{4}){3}-[\da-f]{12}$/i;
|
|
5
|
+
/**
|
|
6
|
+
* Resolve the target workspace UUID for commands that don't extend APICommand
|
|
7
|
+
* (e.g. pull, push — which talk to Gitea, not the oRPC API, but still need to
|
|
8
|
+
* know which workspace to mint a Gitea token for).
|
|
9
|
+
*
|
|
10
|
+
* Handles all three auth paths in the same order as util/get-client.ts:
|
|
11
|
+
* 1. QUONFIG_API_KEY + QUONFIG_WORKSPACE (or --workspace flag) — CI/headless.
|
|
12
|
+
* 2. OAuth profile — the default interactive path.
|
|
13
|
+
*
|
|
14
|
+
* Mirrors the error messages from get-client.ts so behavior feels identical
|
|
15
|
+
* whether you're hitting the API directly or going through Gitea.
|
|
16
|
+
*/
|
|
17
|
+
export async function resolveWorkspaceUuid(command, flagOverride) {
|
|
18
|
+
const override = flagOverride ?? process.env.QUONFIG_WORKSPACE;
|
|
19
|
+
const apiKey = process.env.QUONFIG_API_KEY;
|
|
20
|
+
if (apiKey && apiKey.length > 0) {
|
|
21
|
+
if (!override) {
|
|
22
|
+
command.error('QUONFIG_API_KEY is set but QUONFIG_WORKSPACE is not. Set QUONFIG_WORKSPACE=<workspace-slug> or pass --workspace=<slug>. (UUIDs also work.)', { exit: 1 });
|
|
23
|
+
}
|
|
24
|
+
if (UUID_PATTERN.test(override)) {
|
|
25
|
+
command.verboseLog('ApiKey auth', { source: 'uuid', workspaceId: override });
|
|
26
|
+
return override;
|
|
27
|
+
}
|
|
28
|
+
// Slug — resolve via the server since there's no local auth config in CI mode.
|
|
29
|
+
const jwt = await getValidAccessToken(command.verboseLog);
|
|
30
|
+
let res;
|
|
31
|
+
try {
|
|
32
|
+
res = await fetch(`${getApiUrl()}/api/v1/userWorkspaces/list`, {
|
|
33
|
+
method: 'POST',
|
|
34
|
+
headers: { Authorization: `Bearer ${jwt}`, 'Content-Type': 'application/json' },
|
|
35
|
+
body: JSON.stringify({ json: {} }),
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
catch (error) {
|
|
39
|
+
command.error(`Failed to resolve workspace slug "${override}": ${error instanceof Error ? error.message : String(error)}`, { exit: 1 });
|
|
40
|
+
}
|
|
41
|
+
if (!res.ok) {
|
|
42
|
+
command.error(`Failed to resolve workspace slug "${override}" (HTTP ${res.status}). Check that QUONFIG_API_KEY is valid.`, { exit: 1 });
|
|
43
|
+
}
|
|
44
|
+
const body = (await res.json());
|
|
45
|
+
const entries = body.json ?? body ?? [];
|
|
46
|
+
const match = entries.find((w) => w.workspaceSlug === override);
|
|
47
|
+
if (!match) {
|
|
48
|
+
const available = entries.map((w) => w.workspaceSlug).join(', ') || '(none)';
|
|
49
|
+
command.error(`No workspace with slug "${override}" is accessible with this API key. Available: ${available}.`, { exit: 1 });
|
|
50
|
+
}
|
|
51
|
+
command.verboseLog('ApiKey auth', { source: 'slug', slug: override, workspaceId: match.workspaceId });
|
|
52
|
+
return match.workspaceId;
|
|
53
|
+
}
|
|
54
|
+
// OAuth path.
|
|
55
|
+
const authConfig = await loadAuthConfig();
|
|
56
|
+
if (!authConfig) {
|
|
57
|
+
command.error('Not logged in. Run `qfg login` first (or set QUONFIG_API_KEY for CI).', { exit: 401 });
|
|
58
|
+
}
|
|
59
|
+
if (override) {
|
|
60
|
+
const resolved = resolveOAuthWorkspaceId(authConfig, override);
|
|
61
|
+
if (resolved)
|
|
62
|
+
return resolved;
|
|
63
|
+
// Fall back to treating the override as a profile name.
|
|
64
|
+
const profileData = authConfig.profiles[override];
|
|
65
|
+
if (profileData?.workspace)
|
|
66
|
+
return profileData.workspace;
|
|
67
|
+
command.error(`Workspace "${override}" not found. Run \`qfg workspace switch\` to pick one.`, { exit: 1 });
|
|
68
|
+
}
|
|
69
|
+
const activeProfile = getActiveProfile();
|
|
70
|
+
const profileData = authConfig.profiles[activeProfile] || authConfig.profiles[authConfig.defaultProfile || 'default'];
|
|
71
|
+
if (!profileData?.workspace) {
|
|
72
|
+
command.error('No active workspace. Run `qfg login` / `qfg workspace switch`, or set QUONFIG_API_KEY + QUONFIG_WORKSPACE.', { exit: 1 });
|
|
73
|
+
}
|
|
74
|
+
return profileData.workspace;
|
|
75
|
+
}
|