@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
|
@@ -1,18 +1,24 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Reader/writer for the workspace's `quonfig.json` file.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
* `
|
|
6
|
-
*
|
|
7
|
-
* cloud workspace
|
|
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
|
|
10
|
-
*
|
|
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
|
|
13
|
-
*
|
|
14
|
-
*
|
|
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
|
-
/**
|
|
30
|
+
/** `<org-slug>/<workspace-slug>` pin string. */
|
|
25
31
|
workspace?: string;
|
|
26
32
|
}
|
|
27
33
|
/**
|
|
28
|
-
*
|
|
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
|
-
*
|
|
33
|
-
*
|
|
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<
|
|
48
|
+
export declare function readWorkspaceSlug(dir: string): Promise<WorkspacePin | undefined>;
|
|
36
49
|
/**
|
|
37
|
-
* Write the workspace
|
|
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
|
-
*
|
|
40
|
-
*
|
|
41
|
-
*
|
|
42
|
-
*
|
|
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
|
|
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
|
-
*
|
|
5
|
-
* `
|
|
6
|
-
*
|
|
7
|
-
* cloud workspace
|
|
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
|
|
10
|
-
*
|
|
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
|
|
13
|
-
*
|
|
14
|
-
*
|
|
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
|
-
*
|
|
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
|
-
*
|
|
43
|
-
*
|
|
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
|
-
|
|
76
|
+
if (typeof slug !== 'string')
|
|
77
|
+
return undefined;
|
|
78
|
+
return parsePinString(slug);
|
|
51
79
|
}
|
|
52
80
|
/**
|
|
53
|
-
* Write the workspace
|
|
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,
|
|
56
|
-
*
|
|
57
|
-
*
|
|
58
|
-
* the file
|
|
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,
|
|
61
|
-
const
|
|
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 {
|
|
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
|
-
|
|
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
|
|
65
|
-
|
|
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
|
-
|
|
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
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
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: (
|
|
30
|
-
export declare const loadTokens: (options?: TokenStorageOptions) => Promise<
|
|
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 (
|
|
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(
|
|
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
|
-
|
|
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;
|
package/dist/verify/validate.js
CHANGED
|
@@ -20,7 +20,11 @@
|
|
|
20
20
|
* - Weighted values non-empty and consistent
|
|
21
21
|
*/
|
|
22
22
|
import { z } from 'zod';
|
|
23
|
-
|
|
23
|
+
// Inlined to keep this directory self-contained for the standalone bun-compile
|
|
24
|
+
// build that runs as the qfg-verify pre-receive hook in app-gitea — that build
|
|
25
|
+
// only copies cli/src/verify/, so any out-of-tree imports break it. Source of
|
|
26
|
+
// truth: cli/src/util/log-levels.ts.
|
|
27
|
+
const LOG_LEVELS = ['TRACE', 'DEBUG', 'INFO', 'WARN', 'ERROR', 'FATAL'];
|
|
24
28
|
// ── Schemas (subset of app-quonfig config-schemas.ts) ───────────────────
|
|
25
29
|
const ConfigTypeSchema = z.enum(['feature_flag', 'config', 'log_level', 'segment', 'schema']);
|
|
26
30
|
const ValueTypeSchema = z.enum(['bool', 'string', 'int', 'double', 'json', 'string_list', 'duration', 'log_level']);
|
|
@@ -408,6 +412,17 @@ export function validateWorkspace(workspaceDir) {
|
|
|
408
412
|
severity: 'error',
|
|
409
413
|
});
|
|
410
414
|
}
|
|
415
|
+
// Segments are cross-environment (no per-env values), so `protected-env`
|
|
416
|
+
// has no meaning — there is no environment to protect. Allow only
|
|
417
|
+
// support / standard / protected-all-envs. See protecting-access.md §11.
|
|
418
|
+
if (config.access === 'protected-env') {
|
|
419
|
+
issues.push({
|
|
420
|
+
file: relPath,
|
|
421
|
+
message: `Segment cannot have access "protected-env" — segments are cross-environment. Use "standard" or "protected-all-envs".`,
|
|
422
|
+
severity: 'error',
|
|
423
|
+
suggestion: `Change access to "standard" or "protected-all-envs"`,
|
|
424
|
+
});
|
|
425
|
+
}
|
|
411
426
|
segmentKeys.add(config.key);
|
|
412
427
|
}
|
|
413
428
|
if (config.type === 'log_level') {
|
|
@@ -707,6 +722,15 @@ export function validateFileMap(files) {
|
|
|
707
722
|
if (config.sendToClientSdk) {
|
|
708
723
|
issues.push({ file: relPath, message: `Segment must have sendToClientSdk=false`, severity: 'error' });
|
|
709
724
|
}
|
|
725
|
+
// Segments are cross-environment — `protected-env` has no meaning. See protecting-access.md §11.
|
|
726
|
+
if (config.access === 'protected-env') {
|
|
727
|
+
issues.push({
|
|
728
|
+
file: relPath,
|
|
729
|
+
message: `Segment cannot have access "protected-env" — segments are cross-environment. Use "standard" or "protected-all-envs".`,
|
|
730
|
+
severity: 'error',
|
|
731
|
+
suggestion: `Change access to "standard" or "protected-all-envs"`,
|
|
732
|
+
});
|
|
733
|
+
}
|
|
710
734
|
segmentKeys.add(config.key);
|
|
711
735
|
}
|
|
712
736
|
if (config.type === 'log_level') {
|