@quonfig/cli 0.0.24 → 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.
- package/README.md +47 -47
- package/dist/commands/create.d.ts +7 -0
- package/dist/commands/create.js +33 -0
- package/dist/commands/delete.js +4 -2
- package/dist/commands/init.js +6 -2
- package/dist/commands/login.js +72 -91
- package/dist/commands/migrate/doctor.js +13 -3
- package/dist/commands/override.js +4 -2
- package/dist/commands/pull.d.ts +11 -0
- package/dist/commands/pull.js +67 -20
- package/dist/commands/push.js +4 -7
- package/dist/commands/whoami.d.ts +13 -0
- package/dist/commands/whoami.js +119 -32
- package/dist/commands/workspace/bootstrap.js +27 -20
- package/dist/commands/workspace/create.d.ts +11 -0
- package/dist/commands/workspace/create.js +56 -16
- package/dist/commands/workspace/switch.d.ts +10 -0
- package/dist/commands/workspace/switch.js +98 -50
- package/dist/commands/workspace.d.ts +14 -0
- package/dist/commands/workspace.js +152 -40
- package/dist/migrate/local-write.js +16 -31
- package/dist/push/bare-path-diff.js +2 -4
- package/dist/push/config-push-client.js +4 -2
- package/dist/push/run-push.js +37 -8
- package/dist/util/clone-and-stack-push.js +14 -29
- package/dist/util/get-client.js +70 -38
- package/dist/util/get-valid-token.d.ts +16 -4
- package/dist/util/get-valid-token.js +59 -42
- package/dist/util/git-ops.d.ts +90 -0
- package/dist/util/git-ops.js +172 -24
- package/dist/util/gitea-api.js +4 -2
- package/dist/util/local-config-writer.d.ts +33 -0
- package/dist/util/local-config-writer.js +63 -0
- package/dist/util/login-orchestrator.d.ts +42 -0
- package/dist/util/login-orchestrator.js +79 -0
- package/dist/util/oauth-client.d.ts +2 -0
- package/dist/util/oauth-client.js +30 -0
- package/dist/util/quonfig-json.d.ts +58 -21
- package/dist/util/quonfig-json.js +151 -22
- package/dist/util/resolve-workspace.js +79 -17
- package/dist/util/token-storage.d.ts +39 -8
- package/dist/util/token-storage.js +34 -4
- package/dist/verify/validate.js +25 -1
- package/oclif.manifest.json +8 -8
- package/package.json +1 -1
package/dist/util/git-ops.js
CHANGED
|
@@ -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
|
-
*
|
|
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
|
|
28
|
+
export const runGit = async (args, options) => {
|
|
29
|
+
const env = { ...process.env, ...GIT_SAFE_ENV, ...options?.env };
|
|
12
30
|
try {
|
|
13
|
-
return await execFile(
|
|
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
|
|
64
|
+
await runGit(['clone', repoUrl, dir]);
|
|
26
65
|
};
|
|
27
66
|
export const isGitRepo = async (dir) => {
|
|
28
67
|
try {
|
|
29
|
-
await
|
|
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
|
|
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
|
|
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
|
|
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
|
|
59
|
-
const { stdout: remoteSha } = await
|
|
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
|
|
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
|
|
83
|
-
const { stdout: remoteSha } = await
|
|
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
|
|
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
|
|
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
|
|
116
|
-
const { stdout: log } = await
|
|
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
|
|
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
|
|
288
|
+
await runGit(['-C', dir, 'remote', 'add', 'origin', url]);
|
|
141
289
|
}
|
|
142
290
|
else {
|
|
143
|
-
await
|
|
291
|
+
await runGit(['-C', dir, 'remote', 'set-url', 'origin', url]);
|
|
144
292
|
}
|
|
145
293
|
};
|
|
146
294
|
export const gitPushForceLease = async (dir) => {
|
|
147
|
-
await
|
|
295
|
+
await runGit(['-C', dir, 'push', 'origin', 'main', '--force-with-lease']);
|
|
148
296
|
};
|
|
149
297
|
export const gitPushForce = async (dir) => {
|
|
150
|
-
await
|
|
298
|
+
await runGit(['-C', dir, 'push', 'origin', 'main', '--force']);
|
|
151
299
|
};
|
|
152
300
|
export const hasAtLeastOneCommit = async (dir) => {
|
|
153
301
|
try {
|
|
154
|
-
await
|
|
302
|
+
await runGit(['-C', dir, 'rev-parse', 'HEAD']);
|
|
155
303
|
return true;
|
|
156
304
|
}
|
|
157
305
|
catch {
|
package/dist/util/gitea-api.js
CHANGED
|
@@ -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
|
-
|
|
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('.');
|