@quonfig/cli 0.0.11 → 0.0.13

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 (106) hide show
  1. package/README.md +214 -30
  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.d.ts +12 -0
  18. package/dist/commands/migrate/doctor.js +63 -0
  19. package/dist/commands/migrate/my-code.d.ts +11 -0
  20. package/dist/commands/migrate/my-code.js +80 -0
  21. package/dist/commands/migrate/status.d.ts +10 -0
  22. package/dist/commands/migrate/status.js +99 -0
  23. package/dist/commands/migrate.d.ts +20 -0
  24. package/dist/commands/migrate.js +277 -0
  25. package/dist/commands/override.js +6 -3
  26. package/dist/commands/profile.d.ts +1 -1
  27. package/dist/commands/profile.js +1 -1
  28. package/dist/commands/pull.d.ts +2 -2
  29. package/dist/commands/pull.js +38 -41
  30. package/dist/commands/schema.d.ts +3 -3
  31. package/dist/commands/schema.js +37 -37
  32. package/dist/commands/sdk-key/create.js +12 -14
  33. package/dist/commands/sdk-key/revoke.d.ts +1 -1
  34. package/dist/commands/sdk-key/revoke.js +2 -4
  35. package/dist/commands/set-default.js +7 -5
  36. package/dist/commands/set-rollout.d.ts +1 -1
  37. package/dist/commands/set-rollout.js +16 -13
  38. package/dist/commands/sync.d.ts +3 -3
  39. package/dist/commands/sync.js +30 -26
  40. package/dist/commands/whoami.js +1 -5
  41. package/dist/commands/workspace/bootstrap.js +7 -7
  42. package/dist/commands/workspace/switch.js +2 -2
  43. package/dist/init/init-workspace.d.ts +2 -2
  44. package/dist/init/init-workspace.js +32 -18
  45. package/dist/init/samples.d.ts +1 -1
  46. package/dist/init/samples.js +2 -2
  47. package/dist/init/schema.js +24 -15
  48. package/dist/interactive-prompt.js +1 -10
  49. package/dist/migrate/doctor.d.ts +27 -0
  50. package/dist/migrate/doctor.js +222 -0
  51. package/dist/migrate/identifier-map.d.ts +14 -0
  52. package/dist/migrate/identifier-map.js +55 -0
  53. package/dist/migrate/import-state.d.ts +14 -0
  54. package/dist/migrate/import-state.js +60 -0
  55. package/dist/migrate/local-write.d.ts +26 -0
  56. package/dist/migrate/local-write.js +85 -0
  57. package/dist/migrate/migration-report.d.ts +42 -0
  58. package/dist/migrate/migration-report.js +85 -0
  59. package/dist/migrate/push-strategy.d.ts +34 -0
  60. package/dist/migrate/push-strategy.js +127 -0
  61. package/dist/migrate/push-to-cloud.d.ts +15 -0
  62. package/dist/migrate/push-to-cloud.js +31 -0
  63. package/dist/migrate/registry.d.ts +6 -0
  64. package/dist/migrate/registry.js +25 -0
  65. package/dist/migrate/source.d.ts +22 -0
  66. package/dist/migrate/source.js +12 -0
  67. package/dist/migrate/sources/flagsmith.d.ts +2 -0
  68. package/dist/migrate/sources/flagsmith.js +28 -0
  69. package/dist/migrate/sources/launch/api.d.ts +7 -0
  70. package/dist/migrate/sources/launch/api.js +75 -0
  71. package/dist/migrate/sources/launch/translate.d.ts +6 -0
  72. package/dist/migrate/sources/launch/translate.js +190 -0
  73. package/dist/migrate/sources/launch/types.d.ts +68 -0
  74. package/dist/migrate/sources/launch/types.js +1 -0
  75. package/dist/migrate/sources/launch.d.ts +3 -0
  76. package/dist/migrate/sources/launch.js +70 -0
  77. package/dist/migrate/sources/launchdarkly.d.ts +2 -0
  78. package/dist/migrate/sources/launchdarkly.js +18 -0
  79. package/dist/quonfig.d.ts +1 -1
  80. package/dist/quonfig.js +5 -9
  81. package/dist/ui/get-confirmation.d.ts +2 -1
  82. package/dist/ui/get-confirmation.js +9 -1
  83. package/dist/ui/get-value.d.ts +1 -1
  84. package/dist/ui/get-value.js +4 -3
  85. package/dist/util/config-value-dto.js +17 -1
  86. package/dist/util/encryption.js +4 -1
  87. package/dist/util/friction-log.d.ts +1 -1
  88. package/dist/util/friction-log.js +5 -1
  89. package/dist/util/get-client.js +51 -3
  90. package/dist/util/get-valid-token.d.ts +4 -0
  91. package/dist/util/get-valid-token.js +17 -2
  92. package/dist/util/git-ops.js +7 -7
  93. package/dist/util/gitea-api.d.ts +2 -2
  94. package/dist/util/gitea-api.js +5 -3
  95. package/dist/util/gitea-token-storage.d.ts +2 -2
  96. package/dist/util/gitea-token-storage.js +1 -1
  97. package/dist/util/is-interactive.d.ts +21 -1
  98. package/dist/util/is-interactive.js +21 -1
  99. package/dist/util/oauth-client.js +1 -1
  100. package/dist/util/token-storage.js +2 -2
  101. package/dist/verify/standalone.d.ts +0 -1
  102. package/dist/verify/standalone.js +22 -23
  103. package/dist/verify/validate.d.ts +8 -8
  104. package/dist/verify/validate.js +256 -245
  105. package/oclif.manifest.json +365 -3
  106. package/package.json +6 -6
@@ -0,0 +1,70 @@
1
+ import { fetchAllChangeHistory, fetchEnvironments } from './launch/api.js';
2
+ import { getOutputPath, isLegacyLogLevel, slugify, transformConfig } from './launch/translate.js';
3
+ const SOURCE_NAME = 'launch';
4
+ const state = { apiKey: null, envIdMap: null };
5
+ class MissingAuthError extends Error {
6
+ constructor(operation) {
7
+ super(`launch source ${operation} requires validateAuth(apiKey) to be called first (no API key configured).`);
8
+ this.name = 'MissingAuthError';
9
+ }
10
+ }
11
+ function requireApiKey(operation) {
12
+ if (!state.apiKey)
13
+ throw new MissingAuthError(operation);
14
+ return state.apiKey;
15
+ }
16
+ async function* fetchChangesImpl(sinceEpochMs) {
17
+ const apiKey = requireApiKey('fetchChanges');
18
+ const since = sinceEpochMs === null ? undefined : sinceEpochMs;
19
+ const changes = await fetchAllChangeHistory(apiKey, since);
20
+ for (const change of changes) {
21
+ yield {
22
+ changedAt: change.changedAt,
23
+ key: change.key,
24
+ raw: change,
25
+ source: SOURCE_NAME,
26
+ };
27
+ }
28
+ }
29
+ async function listEnvironmentsImpl() {
30
+ const apiKey = requireApiKey('listEnvironments');
31
+ const rawEnvIdMap = await fetchEnvironments(apiKey);
32
+ const envIdMap = {};
33
+ for (const [id, name] of Object.entries(rawEnvIdMap)) {
34
+ envIdMap[id] = slugify(name);
35
+ }
36
+ state.envIdMap = envIdMap;
37
+ return [...new Set(Object.values(envIdMap))];
38
+ }
39
+ function translateImpl(change) {
40
+ const raw = change.raw;
41
+ if (!raw || typeof raw !== 'object')
42
+ return [];
43
+ if (typeof raw.type === 'string' && isLegacyLogLevel(raw.type))
44
+ return [];
45
+ if (raw.deleted)
46
+ return [];
47
+ if (!raw.newConfig)
48
+ return [];
49
+ const envIdMap = state.envIdMap ?? {};
50
+ const transformed = transformConfig(raw.newConfig, envIdMap);
51
+ const path = getOutputPath(raw.type, raw.key);
52
+ return [{ contents: JSON.stringify(transformed, null, 2), path }];
53
+ }
54
+ async function validateAuthImpl(apiKey) {
55
+ await fetchEnvironments(apiKey);
56
+ state.apiKey = apiKey;
57
+ }
58
+ export const launchSource = {
59
+ fetchChanges(sinceEpochMs) {
60
+ return fetchChangesImpl(sinceEpochMs);
61
+ },
62
+ listEnvironments: listEnvironmentsImpl,
63
+ name: SOURCE_NAME,
64
+ translate: translateImpl,
65
+ validateAuth: validateAuthImpl,
66
+ };
67
+ export function __resetLaunchSourceForTests() {
68
+ state.apiKey = null;
69
+ state.envIdMap = null;
70
+ }
@@ -0,0 +1,2 @@
1
+ import { type MigrationSource } from '../source.js';
2
+ export declare const launchdarklySource: MigrationSource;
@@ -0,0 +1,18 @@
1
+ import { NotYetImplementedError } from '../source.js';
2
+ const SOURCE_NAME = 'launchdarkly';
3
+ export const launchdarklySource = {
4
+ // eslint-disable-next-line require-yield
5
+ async *fetchChanges() {
6
+ throw new NotYetImplementedError(SOURCE_NAME, 'fetchChanges');
7
+ },
8
+ listEnvironments() {
9
+ throw new NotYetImplementedError(SOURCE_NAME, 'listEnvironments');
10
+ },
11
+ name: SOURCE_NAME,
12
+ translate() {
13
+ throw new NotYetImplementedError(SOURCE_NAME, 'translate');
14
+ },
15
+ validateAuth() {
16
+ throw new NotYetImplementedError(SOURCE_NAME, 'validateAuth');
17
+ },
18
+ };
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
- return config.environment?.rules;
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[envRules.length - 1]?.value;
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[defaultRules.length - 1]?.value;
68
+ return defaultRules.at(-1)?.value;
73
69
  }
74
70
  return undefined;
75
71
  };
@@ -1,9 +1,10 @@
1
1
  export declare const confirmFlag: {
2
2
  confirm: import("@oclif/core/lib/interfaces/parser.js").BooleanFlag<boolean>;
3
3
  };
4
- declare const getConfirmation: ({ flags, message }: {
4
+ declare const getConfirmation: ({ flags, message, }: {
5
5
  flags: {
6
6
  confirm?: boolean;
7
+ interactive?: boolean;
7
8
  };
8
9
  message: string;
9
10
  }) => Promise<boolean>;
@@ -1,4 +1,12 @@
1
1
  import { Flags, ux } from '@oclif/core';
2
+ import isInteractive from '../util/is-interactive.js';
2
3
  export const confirmFlag = { confirm: Flags.boolean({ description: 'confirm without prompt' }) };
3
- const getConfirmation = async ({ flags, message }) => flags.confirm || ux.confirm(message);
4
+ const getConfirmation = async ({ flags, message, }) => {
5
+ if (flags.confirm)
6
+ return true;
7
+ if (!isInteractive(flags)) {
8
+ throw new Error('Cannot prompt for confirmation in a non-interactive context. Pass --confirm to skip the confirmation prompt.');
9
+ }
10
+ return ux.confirm(message);
11
+ };
4
12
  export default getConfirmation;
@@ -9,7 +9,7 @@ declare const getValue: ({ allowBlank, desiredValue, environment, flags, key, me
9
9
  desiredValue: string | undefined;
10
10
  environment?: Environment;
11
11
  flags: {
12
- interactive: boolean;
12
+ interactive?: boolean;
13
13
  };
14
14
  key?: string;
15
15
  message: string;
@@ -1,10 +1,11 @@
1
1
  import { defaultValueFor } from '../quonfig.js';
2
2
  import { failure, noop, success } from '../result.js';
3
+ import isInteractive from '../util/is-interactive.js';
3
4
  import validateValue from '../validations/value.js';
4
5
  import getString from './get-string.js';
5
6
  const getValue = async ({ allowBlank = true, desiredValue, environment, flags, key, message, quonfig, }) => {
6
- if (desiredValue === undefined && !flags.interactive) {
7
- return failure(`No value provided for ${key}`);
7
+ if (desiredValue === undefined && !isInteractive(flags)) {
8
+ return failure(`No value provided for ${key ?? message}. Pass --value=<value> (or use --env-var) when running non-interactively.`);
8
9
  }
9
10
  if (!key) {
10
11
  if (desiredValue !== undefined) {
@@ -37,7 +38,7 @@ const valueToString = (v) => {
37
38
  return '';
38
39
  return String(v.value);
39
40
  };
40
- const promptForValue = async ({ allowBlank, config, currentDefault, message, }) => {
41
+ const promptForValue = async ({ allowBlank, config: _config, currentDefault, message, }) => {
41
42
  // In the new quonfig format, allowable values aren't stored at the config level.
42
43
  // Just prompt for a string input.
43
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
- value = configValue.json;
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;
@@ -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', { workspaceId: command.workspaceId, key: secret.keyName });
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: string | undefined, cwd?: string): null | string;
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.split('\n').map((l) => l.trim()).filter(Boolean);
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));
@@ -20,10 +20,58 @@ const getClient = async (command, sdkKey, profile) => {
20
20
  }
21
21
  let jwt;
22
22
  let workspaceId;
23
- // If no SDK key provided, use WorkOS OAuth tokens
24
- if (!sdkKey) {
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
- let tokens = await loadTokens();
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 { /* use stored expiresAt */ }
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 { /* use fallback */ }
48
+ catch {
49
+ /* use fallback */
50
+ }
36
51
  const updated = {
37
52
  ...tokens,
38
53
  accessToken: refreshed.access_token,
@@ -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 (err) {
16
- const e = err;
17
- const message = redactToken(e.message || String(err));
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 !== null) {
140
- await safeExec('git', ['-C', dir, 'remote', 'set-url', 'origin', url]);
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', 'add', 'origin', url]);
143
+ await safeExec('git', ['-C', dir, 'remote', 'set-url', 'origin', url]);
144
144
  }
145
145
  };
146
146
  export const gitPushForceLease = async (dir) => {
@@ -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
  /**
@@ -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
- 'Authorization': `Bearer ${accessToken}`,
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 { /* use raw text */ }
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 !== 'quonfig.com' ? `gitea-tokens-${domain.replaceAll('.', '-')}.json` : 'gitea-tokens.json';
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 () => {
@@ -1,5 +1,25 @@
1
1
  type Flags = {
2
2
  interactive?: boolean;
3
3
  };
4
- declare const isInteractive: (flags: Flags) => boolean | undefined;
4
+ /**
5
+ * Returns true when the CLI can safely prompt the user.
6
+ *
7
+ * Rules, in order:
8
+ * 1. `--no-interactive` (flags.interactive === false) always wins → non-interactive.
9
+ * 2. Otherwise require both stdin and stdout to be a TTY.
10
+ *
11
+ * Historical bug: this used to return `true` whenever `flags.interactive` was
12
+ * truthy, which effectively bypassed the TTY check because the global
13
+ * `--interactive` flag defaults to `true`. Under non-TTY stdio (CI, piped
14
+ * stdin, `nohup`) the prompt helpers (`ux.prompt`, inquirer autocomplete)
15
+ * would be invoked with no way to read input, leaving an unsettled
16
+ * top-level await and emitting a raw Node warning (`Detected unsettled
17
+ * top-level await`) with exit code 13. Now we always AND with the TTY check
18
+ * so non-TTY contexts fall through to the command-level "pass --value and
19
+ * --no-interactive" error message instead of hanging.
20
+ *
21
+ * @param flags - the parsed oclif flags (at minimum `interactive` is inspected)
22
+ * @returns true if prompting is safe, false otherwise
23
+ */
24
+ declare const isInteractive: (flags: Flags) => boolean;
5
25
  export default isInteractive;
@@ -1,7 +1,27 @@
1
+ /**
2
+ * Returns true when the CLI can safely prompt the user.
3
+ *
4
+ * Rules, in order:
5
+ * 1. `--no-interactive` (flags.interactive === false) always wins → non-interactive.
6
+ * 2. Otherwise require both stdin and stdout to be a TTY.
7
+ *
8
+ * Historical bug: this used to return `true` whenever `flags.interactive` was
9
+ * truthy, which effectively bypassed the TTY check because the global
10
+ * `--interactive` flag defaults to `true`. Under non-TTY stdio (CI, piped
11
+ * stdin, `nohup`) the prompt helpers (`ux.prompt`, inquirer autocomplete)
12
+ * would be invoked with no way to read input, leaving an unsettled
13
+ * top-level await and emitting a raw Node warning (`Detected unsettled
14
+ * top-level await`) with exit code 13. Now we always AND with the TTY check
15
+ * so non-TTY contexts fall through to the command-level "pass --value and
16
+ * --no-interactive" error message instead of hanging.
17
+ *
18
+ * @param flags - the parsed oclif flags (at minimum `interactive` is inspected)
19
+ * @returns true if prompting is safe, false otherwise
20
+ */
1
21
  const isInteractive = (flags) => {
2
22
  if (flags.interactive === false) {
3
23
  return false;
4
24
  }
5
- return (process.stdout.isTTY && process.stdin.isTTY) || flags.interactive;
25
+ return Boolean(process.stdout.isTTY && process.stdin.isTTY);
6
26
  };
7
27
  export default isInteractive;
@@ -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 !== 'quonfig.com' ? `tokens-${domain.replaceAll('.', '-')}.json` : 'tokens.json';
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((?:[^\[]*\n?)*)/g;
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
  *