@quonfig/cli 0.0.39 → 0.0.41

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.
@@ -80,42 +80,6 @@ export default class WorkspaceSwitch extends BaseCommand {
80
80
  workspaceSlug: match.workspaceSlug,
81
81
  };
82
82
  }
83
- /**
84
- * Resolve the user's positional arg against the candidate list.
85
- *
86
- * - `org/ws` form (preferred) → match by both org slug and workspace slug.
87
- * - UUID → workspaceId match (kept for back-compat with scripts that
88
- * pass IDs straight through). Bare slug is rejected with the same
89
- * migration message used elsewhere in the CLI.
90
- */
91
- matchByArg(arg, candidates, store) {
92
- if (UUID_PATTERN.test(arg)) {
93
- const found = candidates.find((w) => w.workspaceId === arg);
94
- if (!found)
95
- return { error: `Workspace UUID "${arg}" not found among your accessible workspaces.` };
96
- return { match: found };
97
- }
98
- const pin = tryParseWorkspacePin(arg);
99
- if (!pin) {
100
- return {
101
- error: 'Workspace argument must be in `<org-slug>/<workspace-slug>` form (e.g. acme/production). ' +
102
- 'Bare workspace slugs are no longer accepted.',
103
- };
104
- }
105
- const orgId = findOrgIdBySlug(store, pin.orgSlug);
106
- if (!orgId) {
107
- return { error: `No token found for org \`${pin.orgSlug}\`. Run \`qfg login\` to add this org.` };
108
- }
109
- const found = candidates.find((w) => w.workosOrgId === orgId && w.workspaceSlug === pin.workspaceSlug);
110
- if (!found) {
111
- const inOrg = candidates.filter((w) => w.workosOrgId === orgId).map((w) => w.workspaceSlug);
112
- const available = inOrg.length > 0 ? inOrg.join(', ') : '(none)';
113
- return {
114
- error: `Workspace "${pin.workspaceSlug}" not found in org "${pin.orgSlug}". Available: ${available}.`,
115
- };
116
- }
117
- return { match: found };
118
- }
119
83
  /**
120
84
  * Iterate every cached org and merge its workspace list. Each org has its
121
85
  * own access token; one token only sees one org's workspaces. We tolerate
@@ -160,6 +124,42 @@ export default class WorkspaceSwitch extends BaseCommand {
160
124
  }
161
125
  return [...merged.values()];
162
126
  }
127
+ /**
128
+ * Resolve the user's positional arg against the candidate list.
129
+ *
130
+ * - `org/ws` form (preferred) → match by both org slug and workspace slug.
131
+ * - UUID → workspaceId match (kept for back-compat with scripts that
132
+ * pass IDs straight through). Bare slug is rejected with the same
133
+ * migration message used elsewhere in the CLI.
134
+ */
135
+ matchByArg(arg, candidates, store) {
136
+ if (UUID_PATTERN.test(arg)) {
137
+ const found = candidates.find((w) => w.workspaceId === arg);
138
+ if (!found)
139
+ return { error: `Workspace UUID "${arg}" not found among your accessible workspaces.` };
140
+ return { match: found };
141
+ }
142
+ const pin = tryParseWorkspacePin(arg);
143
+ if (!pin) {
144
+ return {
145
+ error: 'Workspace argument must be in `<org-slug>/<workspace-slug>` form (e.g. acme/production). ' +
146
+ 'Bare workspace slugs are no longer accepted.',
147
+ };
148
+ }
149
+ const orgId = findOrgIdBySlug(store, pin.orgSlug);
150
+ if (!orgId) {
151
+ return { error: `No token found for org \`${pin.orgSlug}\`. Run \`qfg login\` to add this org.` };
152
+ }
153
+ const found = candidates.find((w) => w.workosOrgId === orgId && w.workspaceSlug === pin.workspaceSlug);
154
+ if (!found) {
155
+ const inOrg = candidates.filter((w) => w.workosOrgId === orgId).map((w) => w.workspaceSlug);
156
+ const available = inOrg.length > 0 ? inOrg.join(', ') : '(none)';
157
+ return {
158
+ error: `Workspace "${pin.workspaceSlug}" not found in org "${pin.orgSlug}". Available: ${available}.`,
159
+ };
160
+ }
161
+ return { match: found };
162
+ }
163
163
  }
164
164
  function pinFor(w) {
165
165
  const orgSlug = w.organizationSlug ?? slugFromName(w.organizationName) ?? w.workosOrgId;
@@ -4,12 +4,6 @@ export default class Workspace extends BaseCommand {
4
4
  static description: string;
5
5
  static examples: string[];
6
6
  run(): Promise<JsonObj | void>;
7
- /**
8
- * Pin the default-profile workspaceId to org/ws form by looking it up in
9
- * the workspaces list. Returns undefined if the auth config is empty or
10
- * the saved workspace doesn't appear in the listing.
11
- */
12
- private profilePin;
13
7
  /**
14
8
  * QUONFIG_WORKSPACE wins over the default profile (matches the resolution
15
9
  * order in get-client.ts/resolve-workspace.ts). Bare slugs are ignored
@@ -17,11 +11,17 @@ export default class Workspace extends BaseCommand {
17
11
  * shows the default profile instead.
18
12
  */
19
13
  private activePin;
20
- private fetchWorkspaces;
21
14
  /**
22
15
  * Fan out across every cached org's token and merge the results. One org's
23
16
  * stale token doesn't block the others — we verbose-log and continue.
24
17
  */
25
18
  private fetchAllOrgsWorkspaces;
19
+ private fetchWorkspaces;
20
+ /**
21
+ * Pin the default-profile workspaceId to org/ws form by looking it up in
22
+ * the workspaces list. Returns undefined if the auth config is empty or
23
+ * the saved workspace doesn't appear in the listing.
24
+ */
25
+ private profilePin;
26
26
  private runApiKeyMode;
27
27
  }
@@ -17,9 +17,7 @@ export default class Workspace extends BaseCommand {
17
17
  }
18
18
  const authConfig = await loadAuthConfig();
19
19
  const orgEntries = Object.entries(store.tokensByOrg);
20
- const orgSlugsWithTokens = orgEntries
21
- .map(([orgId, t]) => t.org_slug ?? orgId)
22
- .sort((a, b) => a.localeCompare(b));
20
+ const orgSlugsWithTokens = orgEntries.map(([orgId, t]) => t.org_slug ?? orgId).sort((a, b) => a.localeCompare(b));
23
21
  // /userWorkspaces/list is per-token-org-scoped — a single org's JWT
24
22
  // only sees that org's workspaces. Iterate every cached org so the
25
23
  // listing is comprehensive in multi-org accounts.
@@ -69,26 +67,6 @@ export default class Workspace extends BaseCommand {
69
67
  })),
70
68
  };
71
69
  }
72
- /**
73
- * Pin the default-profile workspaceId to org/ws form by looking it up in
74
- * the workspaces list. Returns undefined if the auth config is empty or
75
- * the saved workspace doesn't appear in the listing.
76
- */
77
- profilePin(authConfig, allWorkspaces) {
78
- if (!authConfig)
79
- return undefined;
80
- const activeProfileName = getActiveProfile();
81
- const profile = authConfig.profiles[activeProfileName] || authConfig.profiles[authConfig.defaultProfile || 'default'];
82
- if (!profile)
83
- return undefined;
84
- const match = allWorkspaces.find((w) => w.workspaceId === profile.workspace);
85
- if (!match)
86
- return undefined;
87
- const orgSlug = match.organizationSlug ?? slugFromName(match.organizationName);
88
- if (!orgSlug)
89
- return undefined;
90
- return `${orgSlug}/${match.workspaceSlug}`;
91
- }
92
70
  /**
93
71
  * QUONFIG_WORKSPACE wins over the default profile (matches the resolution
94
72
  * order in get-client.ts/resolve-workspace.ts). Bare slugs are ignored
@@ -110,26 +88,6 @@ export default class Workspace extends BaseCommand {
110
88
  }
111
89
  return fallback;
112
90
  }
113
- async fetchWorkspaces(jwt) {
114
- try {
115
- const res = await fetch(`${getApiUrl()}/api/v1/userWorkspaces/list`, {
116
- method: 'POST',
117
- headers: { Authorization: `Bearer ${jwt}`, 'Content-Type': 'application/json' },
118
- body: JSON.stringify({ json: {} }),
119
- });
120
- if (!res.ok) {
121
- this.verboseLog('workspace: userWorkspaces/list non-OK', { status: res.status });
122
- return [];
123
- }
124
- const body = (await res.json());
125
- const list = (body.json ?? body);
126
- return Array.isArray(list) ? list : [];
127
- }
128
- catch (error) {
129
- this.verboseLog('workspace: userWorkspaces/list failed', { error: String(error) });
130
- return [];
131
- }
132
- }
133
91
  /**
134
92
  * Fan out across every cached org's token and merge the results. One org's
135
93
  * stale token doesn't block the others — we verbose-log and continue.
@@ -154,6 +112,46 @@ export default class Workspace extends BaseCommand {
154
112
  }
155
113
  return [...merged.values()];
156
114
  }
115
+ async fetchWorkspaces(jwt) {
116
+ try {
117
+ const res = await fetch(`${getApiUrl()}/api/v1/userWorkspaces/list`, {
118
+ method: 'POST',
119
+ headers: { Authorization: `Bearer ${jwt}`, 'Content-Type': 'application/json' },
120
+ body: JSON.stringify({ json: {} }),
121
+ });
122
+ if (!res.ok) {
123
+ this.verboseLog('workspace: userWorkspaces/list non-OK', { status: res.status });
124
+ return [];
125
+ }
126
+ const body = (await res.json());
127
+ const list = (body.json ?? body);
128
+ return Array.isArray(list) ? list : [];
129
+ }
130
+ catch (error) {
131
+ this.verboseLog('workspace: userWorkspaces/list failed', { error: String(error) });
132
+ return [];
133
+ }
134
+ }
135
+ /**
136
+ * Pin the default-profile workspaceId to org/ws form by looking it up in
137
+ * the workspaces list. Returns undefined if the auth config is empty or
138
+ * the saved workspace doesn't appear in the listing.
139
+ */
140
+ profilePin(authConfig, allWorkspaces) {
141
+ if (!authConfig)
142
+ return undefined;
143
+ const activeProfileName = getActiveProfile();
144
+ const profile = authConfig.profiles[activeProfileName] || authConfig.profiles[authConfig.defaultProfile || 'default'];
145
+ if (!profile)
146
+ return undefined;
147
+ const match = allWorkspaces.find((w) => w.workspaceId === profile.workspace);
148
+ if (!match)
149
+ return undefined;
150
+ const orgSlug = match.organizationSlug ?? slugFromName(match.organizationName);
151
+ if (!orgSlug)
152
+ return undefined;
153
+ return `${orgSlug}/${match.workspaceSlug}`;
154
+ }
157
155
  async runApiKeyMode() {
158
156
  const override = process.env.QUONFIG_WORKSPACE;
159
157
  if (!override) {
@@ -36,22 +36,6 @@ export interface GitOps {
36
36
  * HEAD vs origin/main; for the bare path it's local vs probe-clone.
37
37
  */
38
38
  diffHeadVsOrigin(dir: string): Promise<FileDelta[]>;
39
- /** `git fetch origin` in the given dir. */
40
- fetch(dir: string): Promise<void>;
41
- /** Returns the `remote.origin.url` for the repo, or undefined if unset / not a repo. */
42
- getRemoteOriginUrl(dir: string): Promise<string | undefined>;
43
- /** Returns true if the dir has a `.git/` (worktree or repo). */
44
- isGitRepo(dir: string): Promise<boolean>;
45
- /**
46
- * Returns true if origin/main has commits the local HEAD does not have
47
- * (local strictly behind, or diverged). False when local is up-to-date
48
- * or strictly ahead of origin/main, or when origin/main is unknown.
49
- *
50
- * The clone-path stale-HEAD guard (qfg-fboj) refuses to push in either
51
- * "behind" or "diverged" state because both produce a diff that ships
52
- * REVERSAL deltas to the server, silently undoing remote-newer commits.
53
- */
54
- isLocalBehindRemote(dir: string): Promise<boolean>;
55
39
  /**
56
40
  * Tracked files (relative paths) with working-tree or staged changes.
57
41
  * Used by the clone-path dirty-tree warning to surface uncommitted
@@ -59,6 +43,8 @@ export interface GitOps {
59
43
  * files are excluded — same rule as `dirtyTrackedFiles` in git-ops.ts.
60
44
  */
61
45
  dirtyTrackedFiles(dir: string): Promise<string[]>;
46
+ /** `git fetch origin` in the given dir. */
47
+ fetch(dir: string): Promise<void>;
62
48
  /**
63
49
  * Returns the local SHA of `origin/main` after fetch — the remote tip
64
50
  * the diff was computed against — or undefined if no `origin/main` ref
@@ -69,6 +55,20 @@ export interface GitOps {
69
55
  * origin moved between fetch and push.
70
56
  */
71
57
  getOriginMainSha(dir: string): Promise<string | undefined>;
58
+ /** Returns the `remote.origin.url` for the repo, or undefined if unset / not a repo. */
59
+ getRemoteOriginUrl(dir: string): Promise<string | undefined>;
60
+ /** Returns true if the dir has a `.git/` (worktree or repo). */
61
+ isGitRepo(dir: string): Promise<boolean>;
62
+ /**
63
+ * Returns true if origin/main has commits the local HEAD does not have
64
+ * (local strictly behind, or diverged). False when local is up-to-date
65
+ * or strictly ahead of origin/main, or when origin/main is unknown.
66
+ *
67
+ * The clone-path stale-HEAD guard (qfg-fboj) refuses to push in either
68
+ * "behind" or "diverged" state because both produce a diff that ships
69
+ * REVERSAL deltas to the server, silently undoing remote-newer commits.
70
+ */
71
+ isLocalBehindRemote(dir: string): Promise<boolean>;
72
72
  /** Set origin to `url` (add if missing, set-url if present). */
73
73
  setRemoteOrigin(dir: string, url: string): Promise<void>;
74
74
  }
@@ -7,19 +7,23 @@ import { ConfigValueType } from '@quonfig/node';
7
7
  * @returns The DTO representation of the config value
8
8
  */
9
9
  export function mapConfigValueToDto(configValue, valueType) {
10
- const dto = {
11
- type: mapValueTypeToString(valueType),
12
- };
13
- // Handle provided (env-var) values
10
+ // Handle provided (env-var) values. The API's ValueSchema discriminates on
11
+ // `type`, so a provided value is `{type: 'provided', value: {source, lookup}}`
12
+ // — NOT `{type: '<scalar>', provided: {…}}`. The outer config.valueType still
13
+ // describes the resolved type (e.g. "string"); only the defaultValue flips
14
+ // to `provided`. See qfg-84df for the original repro.
14
15
  if (configValue.provided) {
15
16
  return {
16
- ...dto,
17
- provided: {
17
+ type: 'provided',
18
+ value: {
18
19
  source: configValue.provided.source,
19
20
  lookup: configValue.provided.lookup,
20
21
  },
21
22
  };
22
23
  }
24
+ const dto = {
25
+ type: mapValueTypeToString(valueType),
26
+ };
23
27
  // Extract the actual value based on type
24
28
  let value;
25
29
  if (configValue.bool !== undefined) {
@@ -182,7 +182,8 @@ export const commitPinFixIfPinOnly = async (dir, file, expectedSlug) => {
182
182
  // Compare working tree vs HEAD ignoring `workspace`. If anything else
183
183
  // differs, the user has additional uncommitted edits — leave alone.
184
184
  const stripped = (o) => {
185
- const { workspace: _ignored, ...rest } = o;
185
+ const rest = { ...o };
186
+ delete rest.workspace;
186
187
  return rest;
187
188
  };
188
189
  const a = JSON.stringify(stripped(workingParsed), Object.keys(stripped(workingParsed)).sort());
@@ -296,13 +297,7 @@ export const gitMergeFfOnly = async (dir) => {
296
297
  let newCommits = [];
297
298
  try {
298
299
  const { stdout: localSha } = await runGit(['-C', dir, 'rev-parse', 'HEAD']);
299
- const { stdout: log } = await runGit([
300
- '-C',
301
- dir,
302
- 'log',
303
- '--pretty=format:%s',
304
- `${localSha.trim()}..origin/main`,
305
- ]);
300
+ const { stdout: log } = await runGit(['-C', dir, 'log', '--pretty=format:%s', `${localSha.trim()}..origin/main`]);
306
301
  newCommits = log
307
302
  .split('\n')
308
303
  .map((s) => s.trim())
@@ -371,7 +366,7 @@ export const gitPullRebase = async (dir) => {
371
366
  await runGit(['-C', dir, 'pull', '--rebase', 'origin', 'main']);
372
367
  return { kind: 'clean', commitsRebased };
373
368
  }
374
- catch (err) {
369
+ catch (error) {
375
370
  // Distinguish "rebase paused on conflicts" from "rebase never started".
376
371
  // Conflicts: `diff --diff-filter=U` lists the unmerged paths and
377
372
  // `.git/rebase-merge/` is present.
@@ -388,7 +383,7 @@ export const gitPullRebase = async (dir) => {
388
383
  catch {
389
384
  // Fall through to 'failed' — diff itself errored.
390
385
  }
391
- const reason = err instanceof Error ? err.message : String(err);
386
+ const reason = error instanceof Error ? error.message : String(error);
392
387
  return { kind: 'failed', reason };
393
388
  }
394
389
  };
@@ -8,7 +8,7 @@ export const openBrowser = (url) => {
8
8
  }
9
9
  else if (process.platform === 'win32') {
10
10
  command = 'cmd';
11
- args = ['/c', 'start', '""', url.replace(/&/g, '^&')];
11
+ args = ['/c', 'start', '""', url.replaceAll('&', '^&')];
12
12
  }
13
13
  else {
14
14
  command = 'xdg-open';
@@ -0,0 +1,23 @@
1
+ /**
2
+ * Parsers for `qfg run` env-var → config-key mappings.
3
+ *
4
+ * Two callers, one grammar:
5
+ * - `--env VAR=key.path` (inline)
6
+ * - `--env-file=PATH` (one VAR=key.path per line)
7
+ *
8
+ * Grammar choices that are intentionally narrow:
9
+ * - Separator is `=`, not `:`. Matches `docker -e VAR=value` muscle memory.
10
+ * A line like `VAR:key.path` is rejected, not silently re-interpreted.
11
+ * - Split on the FIRST `=` only, so future config keys can theoretically
12
+ * contain `=` (rare, but cheap to support).
13
+ * - VAR side trimmed; key side trimmed once (whitespace inside the key
14
+ * is preserved if it's somehow legal — but we don't go out of our way).
15
+ * - Env files: blank lines and `#` comments are skipped. Errors include
16
+ * the 1-based line number so users can `vim +N file.env` to the spot.
17
+ */
18
+ export interface RunEnvSpec {
19
+ configKey: string;
20
+ varName: string;
21
+ }
22
+ export declare const parseInlineEnvSpec: (raw: string) => RunEnvSpec;
23
+ export declare const parseEnvFileContents: (contents: string) => RunEnvSpec[];
@@ -0,0 +1,56 @@
1
+ /**
2
+ * Parsers for `qfg run` env-var → config-key mappings.
3
+ *
4
+ * Two callers, one grammar:
5
+ * - `--env VAR=key.path` (inline)
6
+ * - `--env-file=PATH` (one VAR=key.path per line)
7
+ *
8
+ * Grammar choices that are intentionally narrow:
9
+ * - Separator is `=`, not `:`. Matches `docker -e VAR=value` muscle memory.
10
+ * A line like `VAR:key.path` is rejected, not silently re-interpreted.
11
+ * - Split on the FIRST `=` only, so future config keys can theoretically
12
+ * contain `=` (rare, but cheap to support).
13
+ * - VAR side trimmed; key side trimmed once (whitespace inside the key
14
+ * is preserved if it's somehow legal — but we don't go out of our way).
15
+ * - Env files: blank lines and `#` comments are skipped. Errors include
16
+ * the 1-based line number so users can `vim +N file.env` to the spot.
17
+ */
18
+ const splitOnFirstEquals = (raw) => {
19
+ const idx = raw.indexOf('=');
20
+ if (idx === -1)
21
+ return undefined;
22
+ return [raw.slice(0, idx), raw.slice(idx + 1)];
23
+ };
24
+ export const parseInlineEnvSpec = (raw) => {
25
+ const split = splitOnFirstEquals(raw);
26
+ if (!split) {
27
+ throw new Error(`Invalid --env value "${raw}": expected VAR=key.path`);
28
+ }
29
+ const varName = split[0].trim();
30
+ const configKey = split[1].trim();
31
+ if (varName === '') {
32
+ throw new Error(`Invalid --env value "${raw}": empty VAR name (left of =)`);
33
+ }
34
+ if (configKey === '') {
35
+ throw new Error(`Invalid --env value "${raw}": empty config key (right of =)`);
36
+ }
37
+ return { varName, configKey };
38
+ };
39
+ export const parseEnvFileContents = (contents) => {
40
+ const out = [];
41
+ let lineNumber = 0;
42
+ for (const raw of contents.split('\n')) {
43
+ lineNumber += 1;
44
+ const trimmed = raw.trim();
45
+ if (trimmed === '' || trimmed.startsWith('#'))
46
+ continue;
47
+ try {
48
+ out.push(parseInlineEnvSpec(trimmed));
49
+ }
50
+ catch (error) {
51
+ const detail = error instanceof Error ? error.message : String(error);
52
+ throw new Error(`${detail} (line ${lineNumber})`);
53
+ }
54
+ }
55
+ return out;
56
+ };
@@ -177,12 +177,12 @@ export function upsertWorkspaceKey(raw, slug) {
177
177
  const after = raw.slice(endOfLastValue + 1);
178
178
  const hasOtherKeys = Object.keys(parsed).length > 0;
179
179
  // Detect indent from the first nested key. Default to two spaces.
180
- const indentMatch = raw.match(/\n([ \t]+)"/);
180
+ const indentMatch = raw.match(/\n([\t ]+)"/);
181
181
  const indent = indentMatch ? indentMatch[1] : ' ';
182
182
  // If the original used a multi-line object (closing brace on its own
183
183
  // line), keep that shape. Otherwise the original was a single-line
184
184
  // object like `{}` or `{"k":v}` — emit on one line.
185
- const isMultiline = /\n\s*\}\s*$/.test(raw) || /\n/.test(raw.slice(0, closingIdx));
185
+ const isMultiline = /\n\s*}\s*$/.test(raw) || /\n/.test(raw.slice(0, closingIdx));
186
186
  let insertion;
187
187
  if (isMultiline) {
188
188
  insertion = hasOtherKeys ? `,\n${indent}"workspace": ${slugJson}` : `\n${indent}"workspace": ${slugJson}`;
@@ -0,0 +1,46 @@
1
+ /**
2
+ * Resolve the auth/environment mode for `qfg run`.
3
+ *
4
+ * The rule is **binary and mutually exclusive** — never resolve in
5
+ * precedence order, because a "match-only" exception drifts silently and
6
+ * masks misconfiguration in CI.
7
+ *
8
+ * Mode A — `QUONFIG_BACKEND_SDK_KEY` set:
9
+ * The SDK key encodes both project and environment. If `--environment`
10
+ * OR `QUONFIG_ENVIRONMENT` is also present, return an `error` — even
11
+ * if they would agree, because tolerating that case lets a stale env
12
+ * var quietly drift past us until the day it disagrees.
13
+ *
14
+ * Mode B — no SDK key:
15
+ * Require EXACTLY ONE of `--environment` flag or `QUONFIG_ENVIRONMENT`
16
+ * env var. Both → error. Neither → error. There is no interactive
17
+ * prompt — `qfg run` is scripted, and prompting mid-`&&`-chain is
18
+ * worse UX than failing loud.
19
+ *
20
+ * Empty strings are treated as "unset" because shells routinely emit
21
+ * `EXPORT_FOO=` when a var is declared but has no value, and silently
22
+ * picking Mode A with an empty key would surface as a confusing 401 from
23
+ * api-delivery much later.
24
+ */
25
+ export interface ResolveRunEnvironmentInput {
26
+ /** Value of the `--environment` flag (or undefined). */
27
+ envFlag: string | undefined;
28
+ /** Value of `QUONFIG_ENVIRONMENT` env var (or undefined). */
29
+ envFromEnvironment: string | undefined;
30
+ /** Value of `QUONFIG_BACKEND_SDK_KEY` (or undefined). */
31
+ sdkKey: string | undefined;
32
+ }
33
+ export type ResolveRunEnvironmentResult = {
34
+ mode: 'sdk-key';
35
+ sdkKey: string;
36
+ } | {
37
+ mode: 'user';
38
+ environmentName: string;
39
+ } | {
40
+ mode: 'error';
41
+ message: string;
42
+ };
43
+ export declare const RUN_MODE_AMBIGUOUS_ERROR: string;
44
+ export declare const RUN_MODE_NO_ENV_ERROR: string;
45
+ export declare const RUN_MODE_BOTH_ENV_ERROR = "qfg run: pass exactly one of --environment or QUONFIG_ENVIRONMENT (both are set).";
46
+ export declare const resolveRunEnvironmentMode: (input: ResolveRunEnvironmentInput) => ResolveRunEnvironmentResult;
@@ -0,0 +1,52 @@
1
+ /**
2
+ * Resolve the auth/environment mode for `qfg run`.
3
+ *
4
+ * The rule is **binary and mutually exclusive** — never resolve in
5
+ * precedence order, because a "match-only" exception drifts silently and
6
+ * masks misconfiguration in CI.
7
+ *
8
+ * Mode A — `QUONFIG_BACKEND_SDK_KEY` set:
9
+ * The SDK key encodes both project and environment. If `--environment`
10
+ * OR `QUONFIG_ENVIRONMENT` is also present, return an `error` — even
11
+ * if they would agree, because tolerating that case lets a stale env
12
+ * var quietly drift past us until the day it disagrees.
13
+ *
14
+ * Mode B — no SDK key:
15
+ * Require EXACTLY ONE of `--environment` flag or `QUONFIG_ENVIRONMENT`
16
+ * env var. Both → error. Neither → error. There is no interactive
17
+ * prompt — `qfg run` is scripted, and prompting mid-`&&`-chain is
18
+ * worse UX than failing loud.
19
+ *
20
+ * Empty strings are treated as "unset" because shells routinely emit
21
+ * `EXPORT_FOO=` when a var is declared but has no value, and silently
22
+ * picking Mode A with an empty key would surface as a confusing 401 from
23
+ * api-delivery much later.
24
+ */
25
+ export const RUN_MODE_AMBIGUOUS_ERROR = 'qfg run: QUONFIG_BACKEND_SDK_KEY is set, which encodes the environment.\n' +
26
+ 'Remove --environment and unset QUONFIG_ENVIRONMENT, or remove the SDK key.';
27
+ export const RUN_MODE_NO_ENV_ERROR = 'qfg run: no environment specified.\n' +
28
+ 'Either set QUONFIG_BACKEND_SDK_KEY (which encodes env) or\n' +
29
+ 'set QUONFIG_ENVIRONMENT / pass --environment after `qfg login`.';
30
+ export const RUN_MODE_BOTH_ENV_ERROR = 'qfg run: pass exactly one of --environment or QUONFIG_ENVIRONMENT (both are set).';
31
+ const blank = (s) => s === undefined || s === '';
32
+ export const resolveRunEnvironmentMode = (input) => {
33
+ const hasSdkKey = !blank(input.sdkKey);
34
+ const hasFlag = !blank(input.envFlag);
35
+ const hasEnvVar = !blank(input.envFromEnvironment);
36
+ if (hasSdkKey) {
37
+ if (hasFlag || hasEnvVar) {
38
+ return { mode: 'error', message: RUN_MODE_AMBIGUOUS_ERROR };
39
+ }
40
+ return { mode: 'sdk-key', sdkKey: input.sdkKey };
41
+ }
42
+ if (hasFlag && hasEnvVar) {
43
+ return { mode: 'error', message: RUN_MODE_BOTH_ENV_ERROR };
44
+ }
45
+ if (hasFlag) {
46
+ return { mode: 'user', environmentName: input.envFlag };
47
+ }
48
+ if (hasEnvVar) {
49
+ return { mode: 'user', environmentName: input.envFromEnvironment };
50
+ }
51
+ return { mode: 'error', message: RUN_MODE_NO_ENV_ERROR };
52
+ };
@@ -1,6 +1,5 @@
1
1
  import type { BaseCommand } from '../index.js';
2
2
  export interface ResolvedWorkspace {
3
- workspaceId: string;
4
3
  /**
5
4
  * Org slug that owns workspaceId. Empty string in the QUONFIG_API_KEY
6
5
  * path because API keys are workspace-scoped and the slug is never
@@ -8,6 +7,7 @@ export interface ResolvedWorkspace {
8
7
  * on the env key before consulting it.
9
8
  */
10
9
  orgSlug: string;
10
+ workspaceId: string;
11
11
  }
12
12
  /**
13
13
  * Resolve the target workspace UUID + owning org slug for commands that
@@ -4,9 +4,8 @@ export interface TokenStorageOptions {
4
4
  export interface TokenSet {
5
5
  access_token: string;
6
6
  expires_at: number;
7
- refresh_token: string;
8
- user_email?: string;
9
- user_id?: string;
7
+ /** Org display name for `qfg whoami` / `qfg workspace`. */
8
+ org_name?: string;
10
9
  /**
11
10
  * Human-readable org slug (e.g. "acme") — populated by the per-org login
12
11
  * flow so the CLI can resolve `acme/foo`-form workspace addresses against
@@ -14,8 +13,9 @@ export interface TokenSet {
14
13
  * project/plans/multi-org-cli-auth.md (qfg-kr7).
15
14
  */
16
15
  org_slug?: string;
17
- /** Org display name for `qfg whoami` / `qfg workspace`. */
18
- org_name?: string;
16
+ refresh_token: string;
17
+ user_email?: string;
18
+ user_id?: string;
19
19
  }
20
20
  /**
21
21
  * Schema version of tokens.json. Stamped on every saveTokens write so a
@@ -24,11 +24,11 @@ export interface TokenSet {
24
24
  */
25
25
  export declare const TOKEN_STORE_VERSION = 2;
26
26
  export interface TokenStore {
27
- version?: number;
28
27
  defaultOrgId?: string;
29
28
  tokensByOrg: {
30
29
  [workosOrgId: string]: TokenSet;
31
30
  };
31
+ version?: number;
32
32
  }
33
33
  export declare const getTokenForOrg: (store: TokenStore, workosOrgId: string) => TokenSet | undefined;
34
34
  /**
package/dist/version.d.ts CHANGED
@@ -1,2 +1,2 @@
1
- declare const _default: "0.0.39";
1
+ declare const _default: "0.0.41";
2
2
  export default _default;
package/dist/version.js CHANGED
@@ -1,2 +1,2 @@
1
1
  // AUTO-GENERATED from package.json by scripts/generate-version.mjs — do not edit.
2
- export default '0.0.39';
2
+ export default '0.0.41';