@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,16 +1,34 @@
1
- import { execFile as execFileCb } from 'node:child_process';
1
+ import { execFile as execFileCb, spawn } from 'node:child_process';
2
2
  import * as util from 'node:util';
3
3
  const execFile = util.promisify(execFileCb);
4
+ /**
5
+ * Args prepended to every git invocation. Empty `credential.helper` resets the
6
+ * helper chain for this child process only — prevents macOS git's osxkeychain
7
+ * helper from popping a "Keychain Not Found" dialog when qfg-managed creds are
8
+ * embedded in the URL.
9
+ */
10
+ export const GIT_SAFE_ARGS = ['-c', 'credential.helper='];
11
+ /**
12
+ * Env additions for every git invocation. Suppress tty prompts and Git
13
+ * Credential Manager interactive flows.
14
+ */
15
+ export const GIT_SAFE_ENV = {
16
+ GIT_TERMINAL_PROMPT: '0',
17
+ GCM_INTERACTIVE: 'Never',
18
+ };
4
19
  /**
5
20
  * Redact a token from a URL string so it is safe to display to users.
6
21
  */
7
22
  export const redactToken = (url) => url.replace(/:([^/@]+)@/, ':***@');
8
23
  /**
9
- * Wrap execFile errors to strip tokens from the message.
24
+ * Canonical entry point for shelling out to git from the CLI. Always prepends
25
+ * `GIT_SAFE_ARGS` and merges `GIT_SAFE_ENV` so credential prompts never leak
26
+ * to the user. Errors are re-thrown with tokens redacted from message/stderr.
10
27
  */
11
- const safeExec = async (file, args, options) => {
28
+ export const runGit = async (args, options) => {
29
+ const env = { ...process.env, ...GIT_SAFE_ENV, ...options?.env };
12
30
  try {
13
- return await execFile(file, args, { cwd: options?.cwd });
31
+ return await execFile('git', [...GIT_SAFE_ARGS, ...args], { cwd: options?.cwd, env });
14
32
  }
15
33
  catch (error) {
16
34
  const e = error;
@@ -21,12 +39,33 @@ const safeExec = async (file, args, options) => {
21
39
  throw safeErr;
22
40
  }
23
41
  };
42
+ /**
43
+ * Spawn-based git invocation for cases where stdin needs to be piped (e.g.
44
+ * `git commit -F -`). Same safe-args/env injection as `runGit`.
45
+ */
46
+ export const spawnGit = (args, options) => new Promise((resolve, reject) => {
47
+ const env = { ...process.env, ...GIT_SAFE_ENV, ...options?.env };
48
+ const child = spawn('git', [...GIT_SAFE_ARGS, ...args], { cwd: options?.cwd, env });
49
+ let stderr = '';
50
+ child.stderr.on('data', (d) => {
51
+ stderr += d.toString();
52
+ });
53
+ child.on('error', reject);
54
+ child.on('close', (code) => {
55
+ if (code === 0)
56
+ resolve();
57
+ else
58
+ reject(new Error(`git ${args[0] ?? ''} exited ${code}: ${redactToken(stderr)}`));
59
+ });
60
+ if (options?.stdin !== undefined)
61
+ child.stdin.end(options.stdin);
62
+ });
24
63
  export const gitClone = async (repoUrl, dir) => {
25
- await safeExec('git', ['clone', repoUrl, dir]);
64
+ await runGit(['clone', repoUrl, dir]);
26
65
  };
27
66
  export const isGitRepo = async (dir) => {
28
67
  try {
29
- await execFile('git', ['-C', dir, 'rev-parse', '--git-dir']);
68
+ await runGit(['-C', dir, 'rev-parse', '--git-dir']);
30
69
  return true;
31
70
  }
32
71
  catch {
@@ -35,7 +74,7 @@ export const isGitRepo = async (dir) => {
35
74
  };
36
75
  export const getRemoteUrl = async (dir) => {
37
76
  try {
38
- const { stdout } = await execFile('git', ['-C', dir, 'remote', 'get-url', 'origin']);
77
+ const { stdout } = await runGit(['-C', dir, 'remote', 'get-url', 'origin']);
39
78
  return stdout.trim() || null;
40
79
  }
41
80
  catch {
@@ -43,11 +82,120 @@ export const getRemoteUrl = async (dir) => {
43
82
  }
44
83
  };
45
84
  export const isWorkingTreeClean = async (dir) => {
46
- const { stdout } = await execFile('git', ['-C', dir, 'status', '--porcelain']);
85
+ const { stdout } = await runGit(['-C', dir, 'status', '--porcelain']);
47
86
  return stdout.trim() === '';
48
87
  };
88
+ /**
89
+ * Returns true if `file` (relative to `dir`) has any working-tree or
90
+ * staged change, false if its tracked content matches HEAD.
91
+ */
92
+ export const hasFileChanges = async (dir, file) => {
93
+ const { stdout } = await runGit(['-C', dir, 'status', '--porcelain', '--', file]);
94
+ return stdout.trim() !== '';
95
+ };
96
+ /**
97
+ * Returns the list of tracked files (relative paths) with working-tree
98
+ * or staged modifications. Untracked files (`??`) are excluded — they
99
+ * are not considered "dirty" by callers that want to know whether the
100
+ * user has work-in-progress that should not be swept into a commit.
101
+ */
102
+ export const dirtyTrackedFiles = async (dir) => {
103
+ const { stdout } = await runGit(['-C', dir, 'status', '--porcelain']);
104
+ return stdout
105
+ .split('\n')
106
+ .filter(Boolean)
107
+ .filter((line) => !line.startsWith('??'))
108
+ .map((line) => line.slice(3).trim());
109
+ };
110
+ /**
111
+ * Stage and commit a single file with the given message. The path argument
112
+ * to `git commit` ensures only this file is committed even if other files
113
+ * are already staged in the index.
114
+ *
115
+ * Returns true if a commit was created, false if there was nothing to
116
+ * commit (file matches HEAD already). Throws on real git errors.
117
+ */
118
+ export const addAndCommitFile = async (dir, file, message) => {
119
+ if (!(await hasFileChanges(dir, file)))
120
+ return false;
121
+ await runGit(['-C', dir, 'add', '--', file]);
122
+ await runGit(['-C', dir, 'commit', '-m', message, '--', file]);
123
+ return true;
124
+ };
125
+ /**
126
+ * Read the contents of `file` (relative to `dir`) at HEAD. Returns
127
+ * `undefined` if the file does not exist at HEAD or if HEAD itself is
128
+ * unset (empty repo).
129
+ */
130
+ export const readFileAtHead = async (dir, file) => {
131
+ try {
132
+ const { stdout } = await runGit(['-C', dir, 'show', `HEAD:${file}`]);
133
+ return stdout;
134
+ }
135
+ catch {
136
+ return undefined;
137
+ }
138
+ };
139
+ /**
140
+ * Migration helper for legacy state where `qfg pull` wrote the workspace
141
+ * pin to the working tree but never committed it (qfg-0fn). If
142
+ * `quonfig.json` is dirty AND its only diff vs HEAD is an added or
143
+ * changed `workspace` key matching `expectedSlug`, stage and commit the
144
+ * file so push's HEAD-vs-origin delta picks it up.
145
+ *
146
+ * Skips (returns `kind: 'skipped'`) when the dirty file has any other
147
+ * changes, when the pin doesn't match the backend slug, or when JSON
148
+ * parsing fails — leaving the user's working tree alone.
149
+ */
150
+ export const commitPinFixIfPinOnly = async (dir, file, expectedSlug) => {
151
+ if (!(await hasFileChanges(dir, file)))
152
+ return { kind: 'clean' };
153
+ let workingTreeRaw;
154
+ try {
155
+ const { readFile } = await import('node:fs/promises');
156
+ const { join } = await import('node:path');
157
+ workingTreeRaw = await readFile(join(dir, file), 'utf8');
158
+ }
159
+ catch {
160
+ return { kind: 'skipped', reason: 'could not read working-tree file' };
161
+ }
162
+ const headRaw = await readFileAtHead(dir, file);
163
+ let workingParsed;
164
+ try {
165
+ workingParsed = JSON.parse(workingTreeRaw);
166
+ }
167
+ catch {
168
+ return { kind: 'skipped', reason: 'working-tree file is not valid JSON' };
169
+ }
170
+ if (typeof workingParsed.workspace !== 'string' || workingParsed.workspace !== expectedSlug) {
171
+ return { kind: 'skipped', reason: 'working-tree workspace pin does not match backend slug' };
172
+ }
173
+ let headParsed = {};
174
+ if (headRaw !== undefined) {
175
+ try {
176
+ headParsed = JSON.parse(headRaw);
177
+ }
178
+ catch {
179
+ return { kind: 'skipped', reason: 'HEAD file is not valid JSON' };
180
+ }
181
+ }
182
+ // Compare working tree vs HEAD ignoring `workspace`. If anything else
183
+ // differs, the user has additional uncommitted edits — leave alone.
184
+ const stripped = (o) => {
185
+ const { workspace: _ignored, ...rest } = o;
186
+ return rest;
187
+ };
188
+ const a = JSON.stringify(stripped(workingParsed), Object.keys(stripped(workingParsed)).sort());
189
+ const b = JSON.stringify(stripped(headParsed), Object.keys(stripped(headParsed)).sort());
190
+ if (a !== b) {
191
+ return { kind: 'skipped', reason: 'working-tree file has changes beyond the workspace pin' };
192
+ }
193
+ await runGit(['-C', dir, 'add', '--', file]);
194
+ await runGit(['-C', dir, 'commit', '-m', `qfg: pin workspace = ${expectedSlug}`, '--', file]);
195
+ return { kind: 'committed', slug: expectedSlug };
196
+ };
49
197
  export const gitFetch = async (dir) => {
50
- await safeExec('git', ['-C', dir, 'fetch', 'origin']);
198
+ await runGit(['-C', dir, 'fetch', 'origin']);
51
199
  };
52
200
  /**
53
201
  * Returns true if origin/main has commits that can be fast-forwarded into the local branch.
@@ -55,15 +203,15 @@ export const gitFetch = async (dir) => {
55
203
  export const canFastForward = async (dir) => {
56
204
  try {
57
205
  // Get the local HEAD and origin/main SHAs
58
- const { stdout: localSha } = await execFile('git', ['-C', dir, 'rev-parse', 'HEAD']);
59
- const { stdout: remoteSha } = await execFile('git', ['-C', dir, 'rev-parse', 'origin/main']);
206
+ const { stdout: localSha } = await runGit(['-C', dir, 'rev-parse', 'HEAD']);
207
+ const { stdout: remoteSha } = await runGit(['-C', dir, 'rev-parse', 'origin/main']);
60
208
  const local = localSha.trim();
61
209
  const remote = remoteSha.trim();
62
210
  if (local === remote)
63
211
  return false; // already up to date
64
212
  // Check if local is an ancestor of remote (i.e. ff is possible)
65
213
  try {
66
- await execFile('git', ['-C', dir, 'merge-base', '--is-ancestor', local, remote]);
214
+ await runGit(['-C', dir, 'merge-base', '--is-ancestor', local, remote]);
67
215
  return true; // exit code 0 means local is an ancestor of remote
68
216
  }
69
217
  catch {
@@ -79,21 +227,21 @@ export const canFastForward = async (dir) => {
79
227
  */
80
228
  export const hasDivergedFromRemote = async (dir) => {
81
229
  try {
82
- const { stdout: localSha } = await execFile('git', ['-C', dir, 'rev-parse', 'HEAD']);
83
- const { stdout: remoteSha } = await execFile('git', ['-C', dir, 'rev-parse', 'origin/main']);
230
+ const { stdout: localSha } = await runGit(['-C', dir, 'rev-parse', 'HEAD']);
231
+ const { stdout: remoteSha } = await runGit(['-C', dir, 'rev-parse', 'origin/main']);
84
232
  const local = localSha.trim();
85
233
  const remote = remoteSha.trim();
86
234
  if (local === remote)
87
235
  return false;
88
236
  // If remote is an ancestor of local, local is ahead (diverged for our purposes)
89
237
  try {
90
- await execFile('git', ['-C', dir, 'merge-base', '--is-ancestor', remote, local]);
238
+ await runGit(['-C', dir, 'merge-base', '--is-ancestor', remote, local]);
91
239
  return true;
92
240
  }
93
241
  catch {
94
242
  // Check if truly diverged (neither is ancestor of the other)
95
243
  try {
96
- await execFile('git', ['-C', dir, 'merge-base', '--is-ancestor', local, remote]);
244
+ await runGit(['-C', dir, 'merge-base', '--is-ancestor', local, remote]);
97
245
  return false; // ff possible, not diverged
98
246
  }
99
247
  catch {
@@ -112,8 +260,8 @@ export const gitMergeFfOnly = async (dir) => {
112
260
  // Get commits that will be merged (before merge)
113
261
  let newCommits = [];
114
262
  try {
115
- const { stdout: localSha } = await execFile('git', ['-C', dir, 'rev-parse', 'HEAD']);
116
- const { stdout: log } = await execFile('git', [
263
+ const { stdout: localSha } = await runGit(['-C', dir, 'rev-parse', 'HEAD']);
264
+ const { stdout: log } = await runGit([
117
265
  '-C',
118
266
  dir,
119
267
  'log',
@@ -128,7 +276,7 @@ export const gitMergeFfOnly = async (dir) => {
128
276
  catch {
129
277
  // Non-fatal — we'll still attempt the merge
130
278
  }
131
- await safeExec('git', ['-C', dir, 'merge', '--ff-only', 'origin/main']);
279
+ await runGit(['-C', dir, 'merge', '--ff-only', 'origin/main']);
132
280
  return newCommits;
133
281
  };
134
282
  /**
@@ -137,21 +285,21 @@ export const gitMergeFfOnly = async (dir) => {
137
285
  export const gitSetRemote = async (dir, url) => {
138
286
  const existing = await getRemoteUrl(dir);
139
287
  if (existing === null) {
140
- await safeExec('git', ['-C', dir, 'remote', 'add', 'origin', url]);
288
+ await runGit(['-C', dir, 'remote', 'add', 'origin', url]);
141
289
  }
142
290
  else {
143
- await safeExec('git', ['-C', dir, 'remote', 'set-url', 'origin', url]);
291
+ await runGit(['-C', dir, 'remote', 'set-url', 'origin', url]);
144
292
  }
145
293
  };
146
294
  export const gitPushForceLease = async (dir) => {
147
- await safeExec('git', ['-C', dir, 'push', 'origin', 'main', '--force-with-lease']);
295
+ await runGit(['-C', dir, 'push', 'origin', 'main', '--force-with-lease']);
148
296
  };
149
297
  export const gitPushForce = async (dir) => {
150
- await safeExec('git', ['-C', dir, 'push', 'origin', 'main', '--force']);
298
+ await runGit(['-C', dir, 'push', 'origin', 'main', '--force']);
151
299
  };
152
300
  export const hasAtLeastOneCommit = async (dir) => {
153
301
  try {
154
- await execFile('git', ['-C', dir, 'rev-parse', 'HEAD']);
302
+ await runGit(['-C', dir, 'rev-parse', 'HEAD']);
155
303
  return true;
156
304
  }
157
305
  catch {
@@ -1,8 +1,10 @@
1
1
  import { getApiUrl } from './domain-urls.js';
2
- import { getValidAccessToken } from './get-valid-token.js';
2
+ import { getValidAccessToken, resolveDefaultOrgId } from './get-valid-token.js';
3
3
  import { saveGiteaToken } from './gitea-token-storage.js';
4
4
  export const mintGiteaToken = async (workspaceId, scope, purpose) => {
5
- const accessToken = await getValidAccessToken();
5
+ // TODO(qfg-kr7.5): thread workosOrgId through from the resolved workspace address.
6
+ const orgId = await resolveDefaultOrgId();
7
+ const accessToken = await getValidAccessToken(orgId);
6
8
  const apiUrl = getApiUrl();
7
9
  const res = await fetch(`${apiUrl}/api/v1/gitea/token`, {
8
10
  method: 'POST',
@@ -0,0 +1,33 @@
1
+ /**
2
+ * Subdirectory under QUONFIG_DIR where each entity type lives. Mirrors the
3
+ * `directory` field on app-quonfig's CRUD configs (see
4
+ * `app-quonfig/src/lib/orpc/routes/{flags,configs,log-levels}.ts`). Keep these
5
+ * in lock-step or `qfg pull` and `qfg create` will disagree on file paths.
6
+ */
7
+ export type EntitySubdir = 'feature-flags' | 'configs' | 'log-levels';
8
+ export interface WriteResult {
9
+ /** True when the file was committed to local git too. */
10
+ committed: boolean;
11
+ /** Absolute path of the file written. */
12
+ filePath: string;
13
+ /** Reason for skipping git commit, when applicable (verbose-log only). */
14
+ skippedCommitReason?: string;
15
+ }
16
+ /**
17
+ * Write the StoredConfig the server returned to
18
+ * `${QUONFIG_DIR}/<subdir>/<key>.json` so users don't need to run `qfg pull`
19
+ * after every `qfg create`. Closes qfg-d5t.
20
+ *
21
+ * Best-effort: returns null when QUONFIG_DIR is unset, the directory does not
22
+ * exist, or the workspace dir lacks a `quonfig.json`. Throws only on real
23
+ * filesystem write errors — callers should treat those as fatal because
24
+ * silent corruption (e.g., unwriteable workspace dir) is worse than a noisy
25
+ * failure.
26
+ */
27
+ export declare const writeStoredConfigToWorkspace: (opts: {
28
+ subdir: EntitySubdir;
29
+ key: string;
30
+ storedConfig: Record<string, unknown>;
31
+ /** Override QUONFIG_DIR for testing. */
32
+ quonfigDir?: string;
33
+ }) => Promise<WriteResult | null>;
@@ -0,0 +1,63 @@
1
+ import * as fs from 'node:fs';
2
+ import * as path from 'node:path';
3
+ import { addAndCommitFile, isGitRepo } from './git-ops.js';
4
+ /**
5
+ * Strip server-only metadata fields from the API response so the on-disk file
6
+ * matches what app-quonfig itself stores in git (see
7
+ * `config-crud-handlers.ts:writeAndCommit` — it serializes the StoredConfig
8
+ * shape, not the FlagDetail shape).
9
+ */
10
+ const stripServerMetadata = (storedConfig) => {
11
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
12
+ const { commitSha: _commitSha, ...rest } = storedConfig;
13
+ return rest;
14
+ };
15
+ /**
16
+ * Write the StoredConfig the server returned to
17
+ * `${QUONFIG_DIR}/<subdir>/<key>.json` so users don't need to run `qfg pull`
18
+ * after every `qfg create`. Closes qfg-d5t.
19
+ *
20
+ * Best-effort: returns null when QUONFIG_DIR is unset, the directory does not
21
+ * exist, or the workspace dir lacks a `quonfig.json`. Throws only on real
22
+ * filesystem write errors — callers should treat those as fatal because
23
+ * silent corruption (e.g., unwriteable workspace dir) is worse than a noisy
24
+ * failure.
25
+ */
26
+ export const writeStoredConfigToWorkspace = async (opts) => {
27
+ const dir = opts.quonfigDir ?? process.env.QUONFIG_DIR;
28
+ if (!dir)
29
+ return null;
30
+ const resolvedDir = path.resolve(dir);
31
+ // Skip silently if the workspace dir isn't there yet — first-time users
32
+ // who have run `qfg login` but not `qfg pull` shouldn't get a hard error.
33
+ if (!fs.existsSync(resolvedDir))
34
+ return null;
35
+ // Refuse to write into a directory that doesn't look like a Quonfig
36
+ // workspace. This avoids accidentally seeding `feature-flags/` files into
37
+ // an unrelated directory the user happened to point QUONFIG_DIR at.
38
+ if (!fs.existsSync(path.join(resolvedDir, 'quonfig.json')))
39
+ return null;
40
+ const subdirPath = path.join(resolvedDir, opts.subdir);
41
+ await fs.promises.mkdir(subdirPath, { recursive: true });
42
+ const filePath = path.join(subdirPath, `${opts.key}.json`);
43
+ const cleaned = stripServerMetadata(opts.storedConfig);
44
+ await fs.promises.writeFile(filePath, JSON.stringify(cleaned, null, 2) + '\n', 'utf8');
45
+ // If the workspace is a git repo, commit the new file with the same
46
+ // message format app-quonfig's createEntity uses. This keeps the next
47
+ // `qfg pull` clean: when origin has the equivalent server-side commit,
48
+ // a fast-forward reconciles cleanly because both histories share the
49
+ // pre-create base. (If they diverge, pull will say so — better than an
50
+ // "untracked file would be overwritten" trap.)
51
+ if (await isGitRepo(resolvedDir)) {
52
+ const relPath = path.relative(resolvedDir, filePath);
53
+ const entityLabel = opts.subdir === 'feature-flags' ? 'flag' : opts.subdir === 'log-levels' ? 'log level' : 'config';
54
+ try {
55
+ const committed = await addAndCommitFile(resolvedDir, relPath, `Create ${entityLabel}: ${opts.key}`);
56
+ return { filePath, committed };
57
+ }
58
+ catch (error) {
59
+ return { filePath, committed: false, skippedCommitReason: String(error) };
60
+ }
61
+ }
62
+ return { filePath, committed: false, skippedCommitReason: 'not a git repo' };
63
+ };
@@ -0,0 +1,42 @@
1
+ import type { TokenStore } from './token-storage.js';
2
+ export interface OrgMembership {
3
+ name: string;
4
+ role: string;
5
+ slug: string;
6
+ workosOrgId: string;
7
+ }
8
+ export interface OrchestrateLoginInput {
9
+ apiUrl: string;
10
+ initialAccessToken: string;
11
+ initialRefreshToken: string;
12
+ user: {
13
+ email?: string;
14
+ id: string;
15
+ };
16
+ }
17
+ export interface OrchestrateLoginResult {
18
+ defaultOrg: OrgMembership;
19
+ defaultOrgId: string;
20
+ email?: string;
21
+ mintedOrgSlugs: string[];
22
+ organizations: OrgMembership[];
23
+ skippedOrgSlugs: string[];
24
+ tokenStore: TokenStore;
25
+ userId: string;
26
+ }
27
+ /**
28
+ * Multi-org token minting orchestration (qfg-mol-k27, plan item 7).
29
+ *
30
+ * Given the device-auth result, this:
31
+ * 1. Calls /api/v1/me/organizations to enumerate all orgs the user belongs to.
32
+ * 2. For each org (capped at 10 alphabetically — see plan rate-limit guard),
33
+ * calls WorkOS /user_management/authenticate with grant_type=refresh_token
34
+ * + organization_id in parallel to mint an org-scoped TokenSet.
35
+ * 3. Builds the per-org TokenStore with a defaultOrgId set to the only org
36
+ * (single-org user) or the first slug alphabetically (multi-org).
37
+ *
38
+ * Returns the in-memory TokenStore plus enough metadata for login.ts to print
39
+ * the user-facing summary; persistence is the caller's responsibility so we
40
+ * keep this helper free of filesystem side effects (testable in isolation).
41
+ */
42
+ export declare const orchestrateMultiOrgLogin: (input: OrchestrateLoginInput) => Promise<OrchestrateLoginResult>;
@@ -0,0 +1,79 @@
1
+ import { authenticateWithOrg } from './oauth-client.js';
2
+ const MAX_EAGER_ORGS = 10;
3
+ /**
4
+ * Fetch all org memberships for the authenticated user from app-quonfig.
5
+ * Uses the initial user-scoped access token (no org_id) so the call works
6
+ * before any per-org tokens have been minted.
7
+ */
8
+ const fetchOrganizations = async (apiUrl, accessToken) => {
9
+ const res = await fetch(`${apiUrl}/api/v1/me/organizations`, {
10
+ body: JSON.stringify({ json: {} }),
11
+ headers: {
12
+ Authorization: `Bearer ${accessToken}`,
13
+ 'Content-Type': 'application/json',
14
+ },
15
+ method: 'POST',
16
+ });
17
+ if (!res.ok) {
18
+ throw new Error(`Failed to fetch organizations: ${res.status} ${await res.text()}`);
19
+ }
20
+ const body = (await res.json());
21
+ const list = (body.json ?? body);
22
+ if (!Array.isArray(list)) {
23
+ throw new TypeError('Failed to fetch organizations: response was not a list');
24
+ }
25
+ return list;
26
+ };
27
+ /**
28
+ * Multi-org token minting orchestration (qfg-mol-k27, plan item 7).
29
+ *
30
+ * Given the device-auth result, this:
31
+ * 1. Calls /api/v1/me/organizations to enumerate all orgs the user belongs to.
32
+ * 2. For each org (capped at 10 alphabetically — see plan rate-limit guard),
33
+ * calls WorkOS /user_management/authenticate with grant_type=refresh_token
34
+ * + organization_id in parallel to mint an org-scoped TokenSet.
35
+ * 3. Builds the per-org TokenStore with a defaultOrgId set to the only org
36
+ * (single-org user) or the first slug alphabetically (multi-org).
37
+ *
38
+ * Returns the in-memory TokenStore plus enough metadata for login.ts to print
39
+ * the user-facing summary; persistence is the caller's responsibility so we
40
+ * keep this helper free of filesystem side effects (testable in isolation).
41
+ */
42
+ export const orchestrateMultiOrgLogin = async (input) => {
43
+ const allOrgs = await fetchOrganizations(input.apiUrl, input.initialAccessToken);
44
+ if (allOrgs.length === 0) {
45
+ throw new Error('Login failed: this user is not a member of any organization. Have an admin invite you, then re-run `qfg login`.');
46
+ }
47
+ const sorted = [...allOrgs].sort((a, b) => a.slug.localeCompare(b.slug));
48
+ const minted = sorted.slice(0, MAX_EAGER_ORGS);
49
+ const skipped = sorted.slice(MAX_EAGER_ORGS);
50
+ const tokenSets = await Promise.all(minted.map(async (org) => {
51
+ const set = await authenticateWithOrg(input.initialRefreshToken, org.workosOrgId);
52
+ return [
53
+ org.workosOrgId,
54
+ {
55
+ ...set,
56
+ org_name: org.name,
57
+ org_slug: org.slug,
58
+ },
59
+ ];
60
+ }));
61
+ const tokensByOrg = {};
62
+ for (const [orgId, set] of tokenSets) {
63
+ tokensByOrg[orgId] = set;
64
+ }
65
+ const defaultOrg = minted[0];
66
+ return {
67
+ defaultOrg,
68
+ defaultOrgId: defaultOrg.workosOrgId,
69
+ email: input.user.email,
70
+ mintedOrgSlugs: minted.map((o) => o.slug),
71
+ organizations: sorted,
72
+ skippedOrgSlugs: skipped.map((o) => o.slug),
73
+ tokenStore: {
74
+ defaultOrgId: defaultOrg.workosOrgId,
75
+ tokensByOrg,
76
+ },
77
+ userId: input.user.id,
78
+ };
79
+ };
@@ -1,3 +1,4 @@
1
+ import type { TokenSet } from './token-storage.js';
1
2
  export interface DeviceCodeResponse {
2
3
  device_code: string;
3
4
  expires_in: number;
@@ -27,4 +28,5 @@ export interface TokenRefreshResponse {
27
28
  export declare const requestDeviceCode: () => Promise<DeviceCodeResponse>;
28
29
  export declare const pollForToken: (deviceCode: string, interval: number, expiresIn: number, verbose?: boolean) => Promise<DeviceTokenResponse>;
29
30
  export declare const refreshAccessToken: (refreshToken: string) => Promise<TokenRefreshResponse>;
31
+ export declare const authenticateWithOrg: (refreshToken: string, organizationId: string) => Promise<TokenSet>;
30
32
  export declare const decodeJWT: (token: string) => Record<string, unknown>;
@@ -89,6 +89,36 @@ export const refreshAccessToken = async (refreshToken) => {
89
89
  }
90
90
  return response.json();
91
91
  };
92
+ // --- Org-Scoped Authentication ---
93
+ // WorkOS access tokens are org-scoped: there is no single token that authorizes
94
+ // work across two orgs. Exchange a refresh_token for a per-org TokenSet so the
95
+ // CLI can hold one token per organization the user belongs to.
96
+ export const authenticateWithOrg = async (refreshToken, organizationId) => {
97
+ const response = await fetch(`${WORKOS_BASE_URL}/user_management/authenticate`, {
98
+ body: new URLSearchParams({
99
+ client_id: getClientId(),
100
+ grant_type: 'refresh_token',
101
+ organization_id: organizationId,
102
+ refresh_token: refreshToken,
103
+ }),
104
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
105
+ method: 'POST',
106
+ });
107
+ if (!response.ok) {
108
+ const errorText = await response.text();
109
+ throw new Error(`Failed to authenticate with org ${organizationId}: ${errorText}`);
110
+ }
111
+ const data = (await response.json());
112
+ const payload = decodeJWT(data.access_token);
113
+ const expiresAt = typeof payload.exp === 'number' ? payload.exp * 1000 : Date.now() + 300 * 1000;
114
+ return {
115
+ access_token: data.access_token,
116
+ expires_at: expiresAt,
117
+ refresh_token: data.refresh_token,
118
+ user_email: data.user?.email,
119
+ user_id: data.user?.id,
120
+ };
121
+ };
92
122
  // --- JWT Utilities ---
93
123
  export const decodeJWT = (token) => {
94
124
  const parts = token.split('.');