@quonfig/cli 0.0.25 → 0.0.26

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 (44) hide show
  1. package/README.md +47 -47
  2. package/dist/commands/create.d.ts +7 -0
  3. package/dist/commands/create.js +33 -0
  4. package/dist/commands/delete.js +4 -2
  5. package/dist/commands/init.js +6 -2
  6. package/dist/commands/login.js +72 -91
  7. package/dist/commands/migrate/doctor.js +13 -3
  8. package/dist/commands/override.js +4 -2
  9. package/dist/commands/pull.d.ts +11 -0
  10. package/dist/commands/pull.js +67 -20
  11. package/dist/commands/push.js +4 -7
  12. package/dist/commands/whoami.d.ts +13 -0
  13. package/dist/commands/whoami.js +119 -32
  14. package/dist/commands/workspace/bootstrap.js +27 -20
  15. package/dist/commands/workspace/create.d.ts +11 -0
  16. package/dist/commands/workspace/create.js +56 -16
  17. package/dist/commands/workspace/switch.d.ts +10 -0
  18. package/dist/commands/workspace/switch.js +98 -50
  19. package/dist/commands/workspace.d.ts +14 -0
  20. package/dist/commands/workspace.js +152 -40
  21. package/dist/migrate/local-write.js +16 -31
  22. package/dist/push/bare-path-diff.js +2 -4
  23. package/dist/push/config-push-client.js +4 -2
  24. package/dist/push/run-push.js +37 -8
  25. package/dist/util/clone-and-stack-push.js +14 -29
  26. package/dist/util/get-client.js +70 -38
  27. package/dist/util/get-valid-token.d.ts +16 -4
  28. package/dist/util/get-valid-token.js +59 -42
  29. package/dist/util/git-ops.d.ts +90 -0
  30. package/dist/util/git-ops.js +172 -24
  31. package/dist/util/gitea-api.js +4 -2
  32. package/dist/util/local-config-writer.d.ts +33 -0
  33. package/dist/util/local-config-writer.js +63 -0
  34. package/dist/util/login-orchestrator.d.ts +42 -0
  35. package/dist/util/login-orchestrator.js +79 -0
  36. package/dist/util/oauth-client.d.ts +2 -0
  37. package/dist/util/oauth-client.js +30 -0
  38. package/dist/util/quonfig-json.d.ts +58 -21
  39. package/dist/util/quonfig-json.js +151 -22
  40. package/dist/util/resolve-workspace.js +79 -17
  41. package/dist/util/token-storage.d.ts +39 -8
  42. package/dist/util/token-storage.js +34 -4
  43. package/oclif.manifest.json +8 -8
  44. package/package.json +1 -1
@@ -1,18 +1,24 @@
1
1
  /**
2
2
  * Reader/writer for the workspace's `quonfig.json` file.
3
3
  *
4
- * Today the file is `{ environments: string[] }`. We are adding an optional
5
- * `workspace: "<slug>"` pin (Guard 1 in `project/plans/cli-git-sync.md`)
6
- * so commands like `qfg push` can confirm that the local dir belongs to the
7
- * cloud workspace they think it does.
4
+ * The `workspace` pin is stored as `<org-slug>/<workspace-slug>` (Guard 1 in
5
+ * `project/plans/cli-git-sync.md`, multi-org form in
6
+ * `project/plans/multi-org-cli-auth.md`). `qfg push` uses it to confirm that
7
+ * the local dir belongs to the cloud workspace the user thinks it does.
8
8
  *
9
- * The reader is permissive: missing file or missing field returns `undefined`
10
- * (no throw). It only throws when the file exists but cannot be parsed.
9
+ * The reader returns `{orgSlug, workspaceSlug}` when the field is present.
10
+ * Missing file or missing field returns `undefined`. A bare slug (no `/`)
11
+ * throws with a migration message — bare slugs are no longer accepted.
11
12
  *
12
- * The writer preserves all unknown fields, sticks to 2-space indent, and
13
- * always ends with a trailing newline — matching how `qfg init` and the
14
- * migrate flow create the file today.
13
+ * The writer always emits the `<org>/<ws>` form. It preserves all unknown
14
+ * fields and, when the file already exists, does a format-stable in-place
15
+ * edit so the diff is just the pin line. Missing or unparseable files fall
16
+ * back to canonical 2-space JSON with a trailing newline.
15
17
  */
18
+ export interface WorkspacePin {
19
+ orgSlug: string;
20
+ workspaceSlug: string;
21
+ }
16
22
  /**
17
23
  * Shape of `quonfig.json`. Everything is optional from the reader's
18
24
  * perspective so a partially-written or in-flight file doesn't blow up
@@ -21,24 +27,55 @@
21
27
  export interface QuonfigJson {
22
28
  [key: string]: unknown;
23
29
  environments?: string[];
24
- /** Workspace slug (human-readable, not the UUID). Used as the repo pin. */
30
+ /** `<org-slug>/<workspace-slug>` pin string. */
25
31
  workspace?: string;
26
32
  }
27
33
  /**
28
- * Read the workspace slug pin from `<dir>/quonfig.json`.
34
+ * Non-throwing variant of `parsePinString`. Returns `undefined` when `raw`
35
+ * isn't in `<org>/<ws>` form (e.g. a bare slug from a transitional backend
36
+ * response). Callers that want the migration error should use
37
+ * `readWorkspaceSlug` instead.
38
+ */
39
+ export declare function tryParseWorkspacePin(raw: string): WorkspacePin | undefined;
40
+ /**
41
+ * Read the workspace pin from `<dir>/quonfig.json`.
29
42
  *
30
43
  * Returns `undefined` when the file is missing OR when the `workspace`
31
- * field is absent OR when the field is present but not a string.
32
- * Throws only when the file exists but contains malformed JSON, since
33
- * that is a real bug a caller should surface.
44
+ * field is absent OR when the field is present but not a string. Throws
45
+ * when the file is malformed JSON, OR when the workspace value is a bare
46
+ * slug (no `/`) — bare slugs are rejected with a migration message.
34
47
  */
35
- export declare function readWorkspaceSlug(dir: string): Promise<string | undefined>;
48
+ export declare function readWorkspaceSlug(dir: string): Promise<WorkspacePin | undefined>;
36
49
  /**
37
- * Write the workspace slug pin into `<dir>/quonfig.json`.
50
+ * Write the workspace pin into `<dir>/quonfig.json`, always in the
51
+ * `<org-slug>/<workspace-slug>` form.
52
+ *
53
+ * If the file exists and is parseable, the existing bytes are preserved
54
+ * verbatim except for the `workspace` key — either its value is replaced
55
+ * in place, or the new key is inserted just before the closing brace
56
+ * using the file's existing indentation. This keeps the git diff to the
57
+ * single pin line on first backfill (no reflow of `environments` or
58
+ * unrelated formatting).
59
+ *
60
+ * If the file is missing or cannot be parsed, fall back to a canonical
61
+ * 2-space JSON write with a trailing newline.
62
+ *
63
+ * Throws when either component is empty or contains a `/` — bare slugs
64
+ * are not accepted on the write path.
65
+ */
66
+ export declare function writeWorkspaceSlug(dir: string, pin: WorkspacePin): Promise<void>;
67
+ /**
68
+ * Format-stable in-place edit of `quonfig.json`.
69
+ *
70
+ * Returns the new file bytes, or `undefined` if the input isn't a shape we
71
+ * can safely edit (malformed JSON, non-object root, or no closing brace
72
+ * found). Caller should fall back to a canonical write in that case.
38
73
  *
39
- * If the file exists, all other fields are preserved and only `workspace`
40
- * is set/overwritten. If the file is missing, a minimal `{workspace: slug}`
41
- * file is created. Format is 2-space indent + trailing newline to match
42
- * the file shape produced by `qfg init` and `qfg migrate`.
74
+ * Two cases:
75
+ * 1. `workspace` key already present (with a string value) → replace just
76
+ * the value bytes via regex. Diff is one token.
77
+ * 2. `workspace` key absent → splice a new key before the closing brace,
78
+ * using indentation detected from the first existing key (or 2-space
79
+ * default for an empty object). Other keys are untouched.
43
80
  */
44
- export declare function writeWorkspaceSlug(dir: string, slug: string): Promise<void>;
81
+ export declare function upsertWorkspaceKey(raw: string, slug: string): string | undefined;
@@ -1,21 +1,25 @@
1
1
  /**
2
2
  * Reader/writer for the workspace's `quonfig.json` file.
3
3
  *
4
- * Today the file is `{ environments: string[] }`. We are adding an optional
5
- * `workspace: "<slug>"` pin (Guard 1 in `project/plans/cli-git-sync.md`)
6
- * so commands like `qfg push` can confirm that the local dir belongs to the
7
- * cloud workspace they think it does.
4
+ * The `workspace` pin is stored as `<org-slug>/<workspace-slug>` (Guard 1 in
5
+ * `project/plans/cli-git-sync.md`, multi-org form in
6
+ * `project/plans/multi-org-cli-auth.md`). `qfg push` uses it to confirm that
7
+ * the local dir belongs to the cloud workspace the user thinks it does.
8
8
  *
9
- * The reader is permissive: missing file or missing field returns `undefined`
10
- * (no throw). It only throws when the file exists but cannot be parsed.
9
+ * The reader returns `{orgSlug, workspaceSlug}` when the field is present.
10
+ * Missing file or missing field returns `undefined`. A bare slug (no `/`)
11
+ * throws with a migration message — bare slugs are no longer accepted.
11
12
  *
12
- * The writer preserves all unknown fields, sticks to 2-space indent, and
13
- * always ends with a trailing newline — matching how `qfg init` and the
14
- * migrate flow create the file today.
13
+ * The writer always emits the `<org>/<ws>` form. It preserves all unknown
14
+ * fields and, when the file already exists, does a format-stable in-place
15
+ * edit so the diff is just the pin line. Missing or unparseable files fall
16
+ * back to canonical 2-space JSON with a trailing newline.
15
17
  */
16
18
  import * as fs from 'node:fs';
17
19
  import * as path from 'node:path';
18
20
  const QUONFIG_JSON = 'quonfig.json';
21
+ const BARE_SLUG_MIGRATION_MESSAGE = 'quonfig.json workspace value must be in org/workspace form (e.g. acme/foo). ' +
22
+ 'Update your quonfig.json and run `qfg login` if you have not yet migrated.';
19
23
  async function readQuonfigJson(dir) {
20
24
  const filePath = path.join(dir, QUONFIG_JSON);
21
25
  let raw;
@@ -35,31 +39,156 @@ async function readQuonfigJson(dir) {
35
39
  return parsed;
36
40
  }
37
41
  /**
38
- * Read the workspace slug pin from `<dir>/quonfig.json`.
42
+ * Parse a `<org-slug>/<workspace-slug>` pin string. Throws with the migration
43
+ * message when the value is bare or malformed (empty parts, extra slashes).
44
+ */
45
+ function parsePinString(raw) {
46
+ const parsed = tryParseWorkspacePin(raw);
47
+ if (!parsed)
48
+ throw new Error(BARE_SLUG_MIGRATION_MESSAGE);
49
+ return parsed;
50
+ }
51
+ /**
52
+ * Non-throwing variant of `parsePinString`. Returns `undefined` when `raw`
53
+ * isn't in `<org>/<ws>` form (e.g. a bare slug from a transitional backend
54
+ * response). Callers that want the migration error should use
55
+ * `readWorkspaceSlug` instead.
56
+ */
57
+ export function tryParseWorkspacePin(raw) {
58
+ const parts = raw.split('/');
59
+ if (parts.length !== 2 || parts[0].length === 0 || parts[1].length === 0)
60
+ return undefined;
61
+ return { orgSlug: parts[0], workspaceSlug: parts[1] };
62
+ }
63
+ /**
64
+ * Read the workspace pin from `<dir>/quonfig.json`.
39
65
  *
40
66
  * Returns `undefined` when the file is missing OR when the `workspace`
41
- * field is absent OR when the field is present but not a string.
42
- * Throws only when the file exists but contains malformed JSON, since
43
- * that is a real bug a caller should surface.
67
+ * field is absent OR when the field is present but not a string. Throws
68
+ * when the file is malformed JSON, OR when the workspace value is a bare
69
+ * slug (no `/`) — bare slugs are rejected with a migration message.
44
70
  */
45
71
  export async function readWorkspaceSlug(dir) {
46
72
  const parsed = await readQuonfigJson(dir);
47
73
  if (!parsed)
48
74
  return undefined;
49
75
  const slug = parsed.workspace;
50
- return typeof slug === 'string' ? slug : undefined;
76
+ if (typeof slug !== 'string')
77
+ return undefined;
78
+ return parsePinString(slug);
51
79
  }
52
80
  /**
53
- * Write the workspace slug pin into `<dir>/quonfig.json`.
81
+ * Write the workspace pin into `<dir>/quonfig.json`, always in the
82
+ * `<org-slug>/<workspace-slug>` form.
54
83
  *
55
- * If the file exists, all other fields are preserved and only `workspace`
56
- * is set/overwritten. If the file is missing, a minimal `{workspace: slug}`
57
- * file is created. Format is 2-space indent + trailing newline to match
58
- * the file shape produced by `qfg init` and `qfg migrate`.
84
+ * If the file exists and is parseable, the existing bytes are preserved
85
+ * verbatim except for the `workspace` key — either its value is replaced
86
+ * in place, or the new key is inserted just before the closing brace
87
+ * using the file's existing indentation. This keeps the git diff to the
88
+ * single pin line on first backfill (no reflow of `environments` or
89
+ * unrelated formatting).
90
+ *
91
+ * If the file is missing or cannot be parsed, fall back to a canonical
92
+ * 2-space JSON write with a trailing newline.
93
+ *
94
+ * Throws when either component is empty or contains a `/` — bare slugs
95
+ * are not accepted on the write path.
59
96
  */
60
- export async function writeWorkspaceSlug(dir, slug) {
61
- const existing = (await readQuonfigJson(dir)) ?? {};
62
- const next = { ...existing, workspace: slug };
97
+ export async function writeWorkspaceSlug(dir, pin) {
98
+ const slug = formatPin(pin);
63
99
  const filePath = path.join(dir, QUONFIG_JSON);
100
+ let raw;
101
+ try {
102
+ raw = await fs.promises.readFile(filePath, 'utf8');
103
+ }
104
+ catch (error) {
105
+ if (error.code !== 'ENOENT')
106
+ throw error;
107
+ }
108
+ if (raw !== undefined) {
109
+ const updated = upsertWorkspaceKey(raw, slug);
110
+ if (updated !== undefined) {
111
+ await fs.promises.writeFile(filePath, updated, 'utf8');
112
+ return;
113
+ }
114
+ }
115
+ // Fallback: file missing, or existing file is unparseable / has a
116
+ // shape we can't safely edit in place. Write canonical JSON.
117
+ const existing = (raw === undefined ? undefined : safeParse(raw)) ?? {};
118
+ const next = { ...existing, workspace: slug };
64
119
  await fs.promises.writeFile(filePath, JSON.stringify(next, null, 2) + '\n', 'utf8');
65
120
  }
121
+ function formatPin(pin) {
122
+ if (pin.orgSlug.length === 0 ||
123
+ pin.workspaceSlug.length === 0 ||
124
+ pin.orgSlug.includes('/') ||
125
+ pin.workspaceSlug.includes('/')) {
126
+ throw new Error(`Invalid workspace pin: orgSlug=${JSON.stringify(pin.orgSlug)}, workspaceSlug=${JSON.stringify(pin.workspaceSlug)}. Both parts must be non-empty and must not contain '/'.`);
127
+ }
128
+ return `${pin.orgSlug}/${pin.workspaceSlug}`;
129
+ }
130
+ function safeParse(raw) {
131
+ try {
132
+ const parsed = JSON.parse(raw);
133
+ if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed))
134
+ return undefined;
135
+ return parsed;
136
+ }
137
+ catch {
138
+ return undefined;
139
+ }
140
+ }
141
+ /**
142
+ * Format-stable in-place edit of `quonfig.json`.
143
+ *
144
+ * Returns the new file bytes, or `undefined` if the input isn't a shape we
145
+ * can safely edit (malformed JSON, non-object root, or no closing brace
146
+ * found). Caller should fall back to a canonical write in that case.
147
+ *
148
+ * Two cases:
149
+ * 1. `workspace` key already present (with a string value) → replace just
150
+ * the value bytes via regex. Diff is one token.
151
+ * 2. `workspace` key absent → splice a new key before the closing brace,
152
+ * using indentation detected from the first existing key (or 2-space
153
+ * default for an empty object). Other keys are untouched.
154
+ */
155
+ export function upsertWorkspaceKey(raw, slug) {
156
+ const parsed = safeParse(raw);
157
+ if (parsed === undefined)
158
+ return undefined;
159
+ const slugJson = JSON.stringify(slug);
160
+ if (typeof parsed.workspace === 'string') {
161
+ // Replace the existing string value. The regex tolerates whitespace
162
+ // around the colon and any escaped chars in the current value.
163
+ const re = /("workspace"\s*:\s*)"(?:[^"\\]|\\.)*"/;
164
+ if (!re.test(raw))
165
+ return undefined;
166
+ return raw.replace(re, `$1${slugJson}`);
167
+ }
168
+ // Insert a new key. Find the closing brace and walk back over trailing
169
+ // whitespace to locate the end of the last value (if any).
170
+ const closingIdx = raw.lastIndexOf('}');
171
+ if (closingIdx === -1)
172
+ return undefined;
173
+ let endOfLastValue = closingIdx - 1;
174
+ while (endOfLastValue >= 0 && /\s/.test(raw[endOfLastValue]))
175
+ endOfLastValue--;
176
+ const before = raw.slice(0, endOfLastValue + 1);
177
+ const after = raw.slice(endOfLastValue + 1);
178
+ const hasOtherKeys = Object.keys(parsed).length > 0;
179
+ // Detect indent from the first nested key. Default to two spaces.
180
+ const indentMatch = raw.match(/\n([ \t]+)"/);
181
+ const indent = indentMatch ? indentMatch[1] : ' ';
182
+ // If the original used a multi-line object (closing brace on its own
183
+ // line), keep that shape. Otherwise the original was a single-line
184
+ // object like `{}` or `{"k":v}` — emit on one line.
185
+ const isMultiline = /\n\s*\}\s*$/.test(raw) || /\n/.test(raw.slice(0, closingIdx));
186
+ let insertion;
187
+ if (isMultiline) {
188
+ insertion = hasOtherKeys ? `,\n${indent}"workspace": ${slugJson}` : `\n${indent}"workspace": ${slugJson}`;
189
+ }
190
+ else {
191
+ insertion = hasOtherKeys ? `, "workspace": ${slugJson}` : `"workspace": ${slugJson}`;
192
+ }
193
+ return before + insertion + after;
194
+ }
@@ -1,7 +1,10 @@
1
1
  import { getApiUrl } from './domain-urls.js';
2
- import { getValidAccessToken } from './get-valid-token.js';
3
- import { getActiveProfile, loadAuthConfig, loadTokens, resolveWorkspaceId as resolveOAuthWorkspaceId, saveAuthConfig, } from './token-storage.js';
2
+ import { getValidAccessToken, resolveDefaultOrgId } from './get-valid-token.js';
3
+ import { tryParseWorkspacePin } from './quonfig-json.js';
4
+ import { findOrgIdBySlug, getActiveProfile, loadAuthConfig, loadTokens, saveAuthConfig, } from './token-storage.js';
4
5
  const UUID_PATTERN = /^[\da-f]{8}(?:-[\da-f]{4}){3}-[\da-f]{12}$/i;
6
+ const BARE_SLUG_ENV_MIGRATION_MESSAGE = 'QUONFIG_WORKSPACE must be in org/workspace form (e.g. acme/foo). ' +
7
+ 'Bare workspace slugs are no longer accepted. Update your .env and run `qfg login` if you have not yet migrated.';
5
8
  /**
6
9
  * Resolve the target workspace UUID for commands that don't extend APICommand
7
10
  * (e.g. pull, push — which talk to Gitea, not the oRPC API, but still need to
@@ -26,7 +29,8 @@ export async function resolveWorkspaceUuid(command, flagOverride) {
26
29
  return override;
27
30
  }
28
31
  // Slug — resolve via the server since there's no local auth config in CI mode.
29
- const jwt = await getValidAccessToken(command.verboseLog);
32
+ // API-key path short-circuits before consulting the orgId.
33
+ const jwt = await getValidAccessToken('', command.verboseLog);
30
34
  let res;
31
35
  try {
32
36
  res = await fetch(`${getApiUrl()}/api/v1/userWorkspaces/list`, {
@@ -53,7 +57,15 @@ export async function resolveWorkspaceUuid(command, flagOverride) {
53
57
  command.verboseLog('ApiKey auth', { source: 'slug', slug: override, workspaceId: match.workspaceId });
54
58
  return match.workspaceId;
55
59
  }
56
- // OAuth path.
60
+ // OAuth path with an explicit override → must be in org/ws form. Parse,
61
+ // look up the workosOrgId for that org slug in the local token store, and
62
+ // resolve the workspaceSlug *within that org*. Bare slug is rejected with
63
+ // a migration message (the API-key branch above is the one exception).
64
+ if (override) {
65
+ return resolveOrgScopedWorkspace(command, override);
66
+ }
67
+ // No override → fall back to the saved default profile, recovering the
68
+ // auth config from tokens if the config file is absent.
57
69
  let authConfig = await loadAuthConfig();
58
70
  if (!authConfig) {
59
71
  // Auth config can be missing while tokens.json still holds a valid
@@ -61,22 +73,13 @@ export async function resolveWorkspaceUuid(command, flagOverride) {
61
73
  // 0f8bee6) or a partial login that wrote tokens but never the config
62
74
  // (qfg-2qj). getValidAccessToken already knows how to refresh, so try
63
75
  // that path before declaring "Not logged in".
64
- const tokens = await loadTokens();
65
- if (!tokens?.refreshToken) {
76
+ const store = await loadTokens();
77
+ const tokens = store ? Object.values(store.tokensByOrg)[0] : undefined;
78
+ if (!tokens?.refresh_token) {
66
79
  command.error('Not logged in. Run `qfg login` first (or set QUONFIG_API_KEY for CI).', { exit: 401 });
67
80
  }
68
81
  authConfig = await recoverAuthConfigFromTokens(command);
69
82
  }
70
- if (override) {
71
- const resolved = resolveOAuthWorkspaceId(authConfig, override);
72
- if (resolved)
73
- return resolved;
74
- // Fall back to treating the override as a profile name.
75
- const profileData = authConfig.profiles[override];
76
- if (profileData?.workspace)
77
- return profileData.workspace;
78
- command.error(`Workspace "${override}" not found. Run \`qfg workspace switch\` to pick one.`, { exit: 1 });
79
- }
80
83
  const activeProfile = getActiveProfile();
81
84
  const profileData = authConfig.profiles[activeProfile] || authConfig.profiles[authConfig.defaultProfile || 'default'];
82
85
  if (!profileData?.workspace) {
@@ -84,6 +87,63 @@ export async function resolveWorkspaceUuid(command, flagOverride) {
84
87
  }
85
88
  return profileData.workspace;
86
89
  }
90
+ /**
91
+ * OAuth-mode resolver for an explicit `<org-slug>/<workspace-slug>` override.
92
+ *
93
+ * 1. Parse the override; bare slug → migration error.
94
+ * 2. Look up workosOrgId via the token store's org_slug index. If the user
95
+ * hasn't logged in to that org, error with `qfg login` instructions.
96
+ * 3. Mint/refresh an org-scoped JWT for that org, call /userWorkspaces/list,
97
+ * and pick the entry whose (workosOrgId, workspaceSlug) matches.
98
+ */
99
+ async function resolveOrgScopedWorkspace(command, override) {
100
+ const pin = tryParseWorkspacePin(override);
101
+ if (!pin) {
102
+ command.error(BARE_SLUG_ENV_MIGRATION_MESSAGE, { exit: 1 });
103
+ }
104
+ const { orgSlug, workspaceSlug } = pin;
105
+ const store = await loadTokens();
106
+ if (!store) {
107
+ command.error('Not logged in. Run `qfg login` first (or set QUONFIG_API_KEY for CI).', { exit: 401 });
108
+ }
109
+ const workosOrgId = findOrgIdBySlug(store, orgSlug);
110
+ if (!workosOrgId) {
111
+ command.error(`No token found for org \`${orgSlug}\`. Run \`qfg login\` to add this org.`, { exit: 401 });
112
+ }
113
+ let jwt;
114
+ try {
115
+ jwt = await getValidAccessToken(workosOrgId, command.verboseLog);
116
+ }
117
+ catch (error) {
118
+ const detail = error instanceof Error ? error.message : String(error);
119
+ command.error(`Session expired for org ${orgSlug}: ${detail}. Run \`qfg login\` to re-authenticate.`, { exit: 401 });
120
+ }
121
+ let res;
122
+ try {
123
+ res = await fetch(`${getApiUrl()}/api/v1/userWorkspaces/list`, {
124
+ method: 'POST',
125
+ headers: { Authorization: `Bearer ${jwt}`, 'Content-Type': 'application/json' },
126
+ body: JSON.stringify({ json: {} }),
127
+ });
128
+ }
129
+ catch (error) {
130
+ command.error(`Failed to resolve workspace "${override}": ${error instanceof Error ? error.message : String(error)}`, { exit: 1 });
131
+ }
132
+ if (!res.ok) {
133
+ command.error(`Failed to resolve workspace "${override}" (HTTP ${res.status}).`, { exit: 1 });
134
+ }
135
+ const body = (await res.json());
136
+ const entries = (body.json ?? body);
137
+ const candidates = Array.isArray(entries) ? entries : [];
138
+ const match = candidates.find((w) => w.workosOrgId === workosOrgId && w.workspaceSlug === workspaceSlug);
139
+ if (!match) {
140
+ const inOrg = candidates.filter((w) => w.workosOrgId === workosOrgId).map((w) => w.workspaceSlug);
141
+ const available = inOrg.length > 0 ? inOrg.join(', ') : '(none)';
142
+ command.error(`Workspace "${workspaceSlug}" not found in org "${orgSlug}". Available: ${available}.`, { exit: 1 });
143
+ }
144
+ command.verboseLog('OAuth workspace lookup', { orgSlug, workspaceSlug, workosOrgId, workspaceId: match.workspaceId });
145
+ return match.workspaceId;
146
+ }
87
147
  /**
88
148
  * Best-effort rebuild of the on-disk auth config from a working refresh_token.
89
149
  * Calls /api/v1/userWorkspaces/list to discover the user's workspaces, persists
@@ -95,7 +155,9 @@ export async function resolveWorkspaceUuid(command, flagOverride) {
95
155
  async function recoverAuthConfigFromTokens(command) {
96
156
  let jwt;
97
157
  try {
98
- jwt = await getValidAccessToken(command.verboseLog);
158
+ // TODO(qfg-kr7.5): pick the orgId resolved from the workspace address.
159
+ const orgId = await resolveDefaultOrgId();
160
+ jwt = await getValidAccessToken(orgId, command.verboseLog);
99
161
  }
100
162
  catch (error) {
101
163
  const detail = error instanceof Error ? error.message : String(error);
@@ -1,13 +1,36 @@
1
1
  export interface TokenStorageOptions {
2
2
  quonfigDir?: string;
3
3
  }
4
- export interface TokenData {
5
- accessToken: string;
6
- expiresAt: number;
7
- refreshToken: string;
8
- userEmail?: string;
9
- userId?: string;
4
+ export interface TokenSet {
5
+ access_token: string;
6
+ expires_at: number;
7
+ refresh_token: string;
8
+ user_email?: string;
9
+ user_id?: string;
10
+ /**
11
+ * Human-readable org slug (e.g. "acme") — populated by the per-org login
12
+ * flow so the CLI can resolve `acme/foo`-form workspace addresses against
13
+ * the local store without round-tripping `me.organizations`. See
14
+ * project/plans/multi-org-cli-auth.md (qfg-kr7).
15
+ */
16
+ org_slug?: string;
17
+ /** Org display name for `qfg whoami` / `qfg workspace`. */
18
+ org_name?: string;
10
19
  }
20
+ export interface TokenStore {
21
+ defaultOrgId?: string;
22
+ tokensByOrg: {
23
+ [workosOrgId: string]: TokenSet;
24
+ };
25
+ }
26
+ export declare const getTokenForOrg: (store: TokenStore, workosOrgId: string) => TokenSet | undefined;
27
+ /**
28
+ * Find the workosOrgId in the per-org token store whose TokenSet has the
29
+ * given org_slug. Returns undefined when no matching slug is present (either
30
+ * because the user has never logged in to that org, or because tokens were
31
+ * minted by a pre-qfg-kr7 login flow that didn't persist org_slug).
32
+ */
33
+ export declare const findOrgIdBySlug: (store: TokenStore, orgSlug: string) => string | undefined;
11
34
  export interface AuthConfig {
12
35
  defaultProfile?: string;
13
36
  profiles: {
@@ -16,6 +39,14 @@ export interface AuthConfig {
16
39
  workspaceName?: string;
17
40
  workspaceSlug?: string;
18
41
  organizationName?: string;
42
+ /**
43
+ * WorkOS organization UUID for the saved workspace. Persisted by
44
+ * `qfg workspace switch` (qfg-kr7.9) so other commands can pick the
45
+ * matching per-org token without round-tripping `me.organizations`.
46
+ */
47
+ workosOrgId?: string;
48
+ /** Org URL slug — used when rendering the org/ws pin form. */
49
+ organizationSlug?: string;
19
50
  };
20
51
  };
21
52
  }
@@ -26,8 +57,8 @@ export interface AuthConfig {
26
57
  export declare const resolveWorkspaceId: (config: AuthConfig, slug: string) => string | undefined;
27
58
  export declare const getTokenFilePath: (options?: TokenStorageOptions) => string;
28
59
  export declare const getAuthConfigFilePath: (options?: TokenStorageOptions) => string;
29
- export declare const saveTokens: (tokens: TokenData, options?: TokenStorageOptions) => Promise<string>;
30
- export declare const loadTokens: (options?: TokenStorageOptions) => Promise<TokenData | null>;
60
+ export declare const saveTokens: (store: TokenStore, options?: TokenStorageOptions) => Promise<string>;
61
+ export declare const loadTokens: (options?: TokenStorageOptions) => Promise<TokenStore | null>;
31
62
  export declare const saveAuthConfig: (config: AuthConfig, options?: TokenStorageOptions) => Promise<string>;
32
63
  export declare const loadAuthConfig: (options?: TokenStorageOptions) => Promise<AuthConfig | null>;
33
64
  export declare const getActiveProfile: (profileArg?: string) => string;
@@ -13,6 +13,20 @@ const getConfigFile = (options) => {
13
13
  const filename = domain === 'quonfig.com' ? 'config' : `config-${domain.replaceAll('.', '-')}`;
14
14
  return path.join(getQuonfigDir(options), filename);
15
15
  };
16
+ export const getTokenForOrg = (store, workosOrgId) => store.tokensByOrg[workosOrgId];
17
+ /**
18
+ * Find the workosOrgId in the per-org token store whose TokenSet has the
19
+ * given org_slug. Returns undefined when no matching slug is present (either
20
+ * because the user has never logged in to that org, or because tokens were
21
+ * minted by a pre-qfg-kr7 login flow that didn't persist org_slug).
22
+ */
23
+ export const findOrgIdBySlug = (store, orgSlug) => {
24
+ for (const [orgId, tokens] of Object.entries(store.tokensByOrg)) {
25
+ if (tokens.org_slug === orgSlug)
26
+ return orgId;
27
+ }
28
+ return undefined;
29
+ };
16
30
  /**
17
31
  * Find a workspace ID by slug across all saved profiles.
18
32
  * Used to resolve QUONFIG_WORKSPACE and --workspace flag values.
@@ -49,11 +63,11 @@ const verifyWritten = async (filePath, label) => {
49
63
  throw new Error(`Failed to persist ${label} to ${filePath}: file is empty or not a regular file after write.`);
50
64
  }
51
65
  };
52
- export const saveTokens = async (tokens, options) => {
66
+ export const saveTokens = async (store, options) => {
53
67
  await ensureQuonfigDir(options);
54
68
  const targetFile = getTokenFile(options);
55
69
  const tmpFile = `${targetFile}.tmp`;
56
- await fs.promises.writeFile(tmpFile, JSON.stringify(tokens, null, 2), { encoding: 'utf8', mode: 0o600 });
70
+ await fs.promises.writeFile(tmpFile, JSON.stringify(store, null, 2), { encoding: 'utf8', mode: 0o600 });
57
71
  await fs.promises.rename(tmpFile, targetFile);
58
72
  await verifyWritten(targetFile, 'tokens');
59
73
  // Round-trip parse to confirm the file on disk is the JSON we just wrote.
@@ -66,14 +80,20 @@ export const saveTokens = async (tokens, options) => {
66
80
  }
67
81
  return targetFile;
68
82
  };
83
+ const isTokenStore = (value) => typeof value === 'object' && value !== null && 'tokensByOrg' in value;
69
84
  export const loadTokens = async (options) => {
85
+ let data;
70
86
  try {
71
- const data = await fs.promises.readFile(getTokenFile(options), 'utf8');
72
- return JSON.parse(data);
87
+ data = await fs.promises.readFile(getTokenFile(options), 'utf8');
73
88
  }
74
89
  catch {
75
90
  return null;
76
91
  }
92
+ const parsed = JSON.parse(data);
93
+ if (!isTokenStore(parsed)) {
94
+ throw new Error('Token store format has changed. Run `qfg login` to re-authenticate.');
95
+ }
96
+ return parsed;
77
97
  };
78
98
  export const saveAuthConfig = async (config, options) => {
79
99
  await ensureQuonfigDir(options);
@@ -96,6 +116,12 @@ export const saveAuthConfig = async (config, options) => {
96
116
  if (profileData.organizationName) {
97
117
  configContent += `organization_name = ${profileData.organizationName}\n`;
98
118
  }
119
+ if (profileData.organizationSlug) {
120
+ configContent += `organization_slug = ${profileData.organizationSlug}\n`;
121
+ }
122
+ if (profileData.workosOrgId) {
123
+ configContent += `workos_org_id = ${profileData.workosOrgId}\n`;
124
+ }
99
125
  configContent += '\n';
100
126
  }
101
127
  const configTarget = getConfigFile(options);
@@ -125,6 +151,8 @@ export const loadAuthConfig = async (options) => {
125
151
  const workspaceMatch = block.match(/workspace\s*=\s*([^\s#]+)(?:\s*#\s*(.+))?/);
126
152
  const slugMatch = block.match(/workspace_slug\s*=\s*(\S+)/);
127
153
  const orgMatch = block.match(/organization_name\s*=\s*(.+)/);
154
+ const orgSlugMatch = block.match(/organization_slug\s*=\s*(\S+)/);
155
+ const workosOrgIdMatch = block.match(/workos_org_id\s*=\s*(\S+)/);
128
156
  if (!workspaceMatch)
129
157
  continue;
130
158
  config.profiles[profileName] = {
@@ -132,6 +160,8 @@ export const loadAuthConfig = async (options) => {
132
160
  workspaceName: workspaceMatch[2]?.trim(),
133
161
  workspaceSlug: slugMatch?.[1]?.trim(),
134
162
  organizationName: orgMatch?.[1]?.trim(),
163
+ organizationSlug: orgSlugMatch?.[1]?.trim(),
164
+ workosOrgId: workosOrgIdMatch?.[1]?.trim(),
135
165
  };
136
166
  }
137
167
  return Object.keys(config.profiles).length > 0 ? config : null;