@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.
- 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/oclif.manifest.json +8 -8
- package/package.json +1 -1
package/dist/commands/whoami.js
CHANGED
|
@@ -1,51 +1,138 @@
|
|
|
1
1
|
import { BaseCommand } from '../index.js';
|
|
2
|
+
import { getApiUrl } from '../util/domain-urls.js';
|
|
3
|
+
import { getValidAccessToken, resolveDefaultOrgId } from '../util/get-valid-token.js';
|
|
2
4
|
import { decodeJWT } from '../util/oauth-client.js';
|
|
3
|
-
import {
|
|
5
|
+
import { tryParseWorkspacePin } from '../util/quonfig-json.js';
|
|
6
|
+
import { findOrgIdBySlug, loadTokens } from '../util/token-storage.js';
|
|
4
7
|
export default class Whoami extends BaseCommand {
|
|
5
|
-
static description = 'Display information about the currently logged in user';
|
|
8
|
+
static description = 'Display information about the currently logged in user and all org memberships';
|
|
6
9
|
static examples = ['<%= config.bin %> <%= command.id %>'];
|
|
7
10
|
async run() {
|
|
8
|
-
const
|
|
9
|
-
|
|
10
|
-
if (!authConfig || !tokens?.accessToken) {
|
|
11
|
+
const store = await loadTokens();
|
|
12
|
+
if (!store || Object.keys(store.tokensByOrg).length === 0) {
|
|
11
13
|
this.log('Not logged in. Use `qfg login` to authenticate.');
|
|
12
|
-
return {
|
|
13
|
-
loggedIn: false,
|
|
14
|
-
};
|
|
14
|
+
return { loggedIn: false };
|
|
15
15
|
}
|
|
16
|
-
|
|
17
|
-
let userEmail =
|
|
18
|
-
if (!userEmail) {
|
|
16
|
+
const firstEntry = Object.values(store.tokensByOrg)[0];
|
|
17
|
+
let userEmail = firstEntry.user_email;
|
|
18
|
+
if (!userEmail && firstEntry.access_token) {
|
|
19
19
|
try {
|
|
20
|
-
const payload = decodeJWT(
|
|
20
|
+
const payload = decodeJWT(firstEntry.access_token);
|
|
21
21
|
userEmail = payload.email;
|
|
22
22
|
}
|
|
23
23
|
catch {
|
|
24
|
-
//
|
|
24
|
+
// Continue without email if JWT decode fails.
|
|
25
25
|
}
|
|
26
26
|
}
|
|
27
|
-
|
|
28
|
-
const
|
|
29
|
-
const
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
// Display user information
|
|
37
|
-
const workspaceSlug = profile.workspaceSlug || profile.workspaceName;
|
|
38
|
-
const orgPrefix = profile.organizationName ? `${profile.organizationName} / ` : '';
|
|
39
|
-
const workspaceDisplay = workspaceSlug ? `${orgPrefix}${workspaceSlug} (${profile.workspace})` : profile.workspace;
|
|
27
|
+
const activeOrgId = await this.resolveActiveOrgId(store.defaultOrgId);
|
|
28
|
+
const memberships = await this.fetchOrganizations();
|
|
29
|
+
const localOrgs = Object.entries(store.tokensByOrg).map(([orgId, t]) => ({
|
|
30
|
+
workosOrgId: orgId,
|
|
31
|
+
slug: t.org_slug ?? orgId,
|
|
32
|
+
name: t.org_name ?? t.org_slug ?? orgId,
|
|
33
|
+
role: '—',
|
|
34
|
+
}));
|
|
35
|
+
const merged = mergeMemberships(localOrgs, memberships);
|
|
40
36
|
this.log(`Logged in as: ${userEmail || 'Unknown'}`);
|
|
41
|
-
this.log(
|
|
37
|
+
this.log('Orgs:');
|
|
38
|
+
if (merged.length === 0) {
|
|
39
|
+
this.log(' (no orgs cached — run `qfg login` to mint per-org tokens)');
|
|
40
|
+
}
|
|
41
|
+
else {
|
|
42
|
+
const labelWidth = Math.max(...merged.map((o) => o.slug.length + (o.workosOrgId === activeOrgId ? 9 : 0))) + 1;
|
|
43
|
+
for (const org of merged) {
|
|
44
|
+
const marker = org.workosOrgId === activeOrgId ? '*' : ' ';
|
|
45
|
+
const label = `${org.slug}${org.workosOrgId === activeOrgId ? ' (active)' : ''}`;
|
|
46
|
+
const padded = label.padEnd(labelWidth, ' ');
|
|
47
|
+
this.log(` ${marker} ${padded}— ${org.role}`);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
42
50
|
return {
|
|
43
|
-
email: userEmail,
|
|
44
51
|
loggedIn: true,
|
|
45
|
-
|
|
46
|
-
userId:
|
|
47
|
-
|
|
48
|
-
|
|
52
|
+
email: userEmail,
|
|
53
|
+
userId: firstEntry.user_id,
|
|
54
|
+
activeOrgId,
|
|
55
|
+
orgs: merged.map((o) => ({
|
|
56
|
+
workosOrgId: o.workosOrgId,
|
|
57
|
+
slug: o.slug,
|
|
58
|
+
name: o.name,
|
|
59
|
+
role: o.role,
|
|
60
|
+
active: o.workosOrgId === activeOrgId,
|
|
61
|
+
})),
|
|
49
62
|
};
|
|
50
63
|
}
|
|
64
|
+
/**
|
|
65
|
+
* Pick the "active" org for display. Order:
|
|
66
|
+
* 1. QUONFIG_WORKSPACE in `org/ws` form → look up org by slug in the store.
|
|
67
|
+
* 2. The token store's defaultOrgId, if it points at an entry we have.
|
|
68
|
+
* 3. The first token entry — gives a stable marker rather than nothing.
|
|
69
|
+
*/
|
|
70
|
+
async resolveActiveOrgId(defaultOrgId) {
|
|
71
|
+
const env = process.env.QUONFIG_WORKSPACE;
|
|
72
|
+
if (env) {
|
|
73
|
+
const pin = tryParseWorkspacePin(env);
|
|
74
|
+
if (pin) {
|
|
75
|
+
const store = await loadTokens();
|
|
76
|
+
if (store) {
|
|
77
|
+
const matched = findOrgIdBySlug(store, pin.orgSlug);
|
|
78
|
+
if (matched)
|
|
79
|
+
return matched;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
if (defaultOrgId)
|
|
84
|
+
return defaultOrgId;
|
|
85
|
+
return resolveDefaultOrgId().then((id) => id || undefined);
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* Best-effort fetch of `me.organizations` for role enrichment. Failure
|
|
89
|
+
* (network, unauthenticated) is non-fatal — whoami still works from the
|
|
90
|
+
* local store, just without role labels.
|
|
91
|
+
*/
|
|
92
|
+
async fetchOrganizations() {
|
|
93
|
+
let jwt;
|
|
94
|
+
try {
|
|
95
|
+
const orgId = await resolveDefaultOrgId();
|
|
96
|
+
if (!orgId)
|
|
97
|
+
return [];
|
|
98
|
+
jwt = await getValidAccessToken(orgId, this.verboseLog);
|
|
99
|
+
}
|
|
100
|
+
catch (error) {
|
|
101
|
+
this.verboseLog('whoami: failed to mint JWT for me.organizations', { error: String(error) });
|
|
102
|
+
return [];
|
|
103
|
+
}
|
|
104
|
+
try {
|
|
105
|
+
const res = await fetch(`${getApiUrl()}/api/v1/me/organizations`, {
|
|
106
|
+
method: 'POST',
|
|
107
|
+
headers: { Authorization: `Bearer ${jwt}`, 'Content-Type': 'application/json' },
|
|
108
|
+
body: JSON.stringify({ json: {} }),
|
|
109
|
+
});
|
|
110
|
+
if (!res.ok) {
|
|
111
|
+
this.verboseLog('whoami: me.organizations returned non-OK', { status: res.status });
|
|
112
|
+
return [];
|
|
113
|
+
}
|
|
114
|
+
const body = (await res.json());
|
|
115
|
+
const list = (body.json ?? body);
|
|
116
|
+
return Array.isArray(list) ? list : [];
|
|
117
|
+
}
|
|
118
|
+
catch (error) {
|
|
119
|
+
this.verboseLog('whoami: me.organizations fetch failed', { error: String(error) });
|
|
120
|
+
return [];
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
/**
|
|
125
|
+
* Combine the locally-cached orgs (one entry per token) with the server's
|
|
126
|
+
* me.organizations response. Server data wins for slug/name/role; entries
|
|
127
|
+
* present locally but missing from the server are kept (a token cached for
|
|
128
|
+
* an org the user has since been removed from). Server-only entries are
|
|
129
|
+
* also included so the user sees all current memberships.
|
|
130
|
+
*/
|
|
131
|
+
function mergeMemberships(local, server) {
|
|
132
|
+
const merged = new Map();
|
|
133
|
+
for (const o of local)
|
|
134
|
+
merged.set(o.workosOrgId, o);
|
|
135
|
+
for (const o of server)
|
|
136
|
+
merged.set(o.workosOrgId, o);
|
|
137
|
+
return [...merged.values()].sort((a, b) => a.slug.localeCompare(b.slug));
|
|
51
138
|
}
|
|
@@ -4,8 +4,8 @@ import { Flags } from '@oclif/core';
|
|
|
4
4
|
import { BaseCommand } from '../../index.js';
|
|
5
5
|
import { getActiveProfile, loadAuthConfig } from '../../util/token-storage.js';
|
|
6
6
|
import { mintGiteaToken } from '../../util/gitea-api.js';
|
|
7
|
-
import { readWorkspaceSlug, writeWorkspaceSlug } from '../../util/quonfig-json.js';
|
|
8
|
-
import { isGitRepo, hasAtLeastOneCommit, gitSetRemote, gitPushForceLease, gitPushForce, getRemoteUrl, displayUrl, } from '../../util/git-ops.js';
|
|
7
|
+
import { readWorkspaceSlug, tryParseWorkspacePin, writeWorkspaceSlug } from '../../util/quonfig-json.js';
|
|
8
|
+
import { isGitRepo, hasAtLeastOneCommit, gitSetRemote, gitPushForceLease, gitPushForce, getRemoteUrl, displayUrl, runGit, } from '../../util/git-ops.js';
|
|
9
9
|
export default class WorkspaceBootstrap extends BaseCommand {
|
|
10
10
|
static description = "Push a local git repo to Gitea as this workspace's config repository";
|
|
11
11
|
static examples = [
|
|
@@ -141,21 +141,34 @@ export default class WorkspaceBootstrap extends BaseCommand {
|
|
|
141
141
|
// we keep what is there — bootstrap is for fresh cases, not re-pinning.
|
|
142
142
|
// If we do write it, commit and push so the bootstrap leaves no
|
|
143
143
|
// uncommitted changes behind.
|
|
144
|
+
//
|
|
145
|
+
// The pin is stored as `<org-slug>/<workspace-slug>`. The backend's
|
|
146
|
+
// `workspaceSlug` is just the workspace component today; until the
|
|
147
|
+
// server returns the slash form, we skip the write rather than
|
|
148
|
+
// emitting a bare slug.
|
|
149
|
+
const backendPin = tryParseWorkspacePin(backendSlug);
|
|
144
150
|
try {
|
|
145
151
|
const existingPin = await readWorkspaceSlug(resolvedDir);
|
|
146
|
-
|
|
152
|
+
const existingFormatted = existingPin ? `${existingPin.orgSlug}/${existingPin.workspaceSlug}` : undefined;
|
|
153
|
+
if (!backendPin) {
|
|
154
|
+
this.verboseLog('WorkspaceBootstrap', `Backend workspaceSlug "${backendSlug}" is not in <org>/<ws> form; skipping pin write.`);
|
|
155
|
+
}
|
|
156
|
+
else if (existingPin &&
|
|
157
|
+
(existingPin.orgSlug !== backendPin.orgSlug || existingPin.workspaceSlug !== backendPin.workspaceSlug)) {
|
|
158
|
+
const backendFormatted = `${backendPin.orgSlug}/${backendPin.workspaceSlug}`;
|
|
147
159
|
this.log('');
|
|
148
|
-
this.log(`Warning: quonfig.json already pins workspace "${
|
|
160
|
+
this.log(`Warning: quonfig.json already pins workspace "${existingFormatted}", but the backend says this workspace is "${backendFormatted}".`);
|
|
149
161
|
this.log('Leaving the existing pin in place. If this is wrong, edit quonfig.json manually and re-run.');
|
|
150
162
|
}
|
|
151
|
-
else if (existingPin
|
|
152
|
-
this.verboseLog('WorkspaceBootstrap', `quonfig.json already pinned to ${
|
|
163
|
+
else if (existingPin) {
|
|
164
|
+
this.verboseLog('WorkspaceBootstrap', `quonfig.json already pinned to ${existingFormatted}; no-op.`);
|
|
153
165
|
}
|
|
154
166
|
else {
|
|
167
|
+
const backendFormatted = `${backendPin.orgSlug}/${backendPin.workspaceSlug}`;
|
|
155
168
|
this.log('');
|
|
156
|
-
this.log(`Pinning quonfig.json to workspace "${
|
|
157
|
-
await writeWorkspaceSlug(resolvedDir,
|
|
158
|
-
await this.commitAndPushPin(resolvedDir,
|
|
169
|
+
this.log(`Pinning quonfig.json to workspace "${backendFormatted}"...`);
|
|
170
|
+
await writeWorkspaceSlug(resolvedDir, backendPin);
|
|
171
|
+
await this.commitAndPushPin(resolvedDir, backendFormatted, flags.force);
|
|
159
172
|
}
|
|
160
173
|
}
|
|
161
174
|
catch (error) {
|
|
@@ -182,30 +195,24 @@ export default class WorkspaceBootstrap extends BaseCommand {
|
|
|
182
195
|
* don't accidentally sweep in unrelated user edits.
|
|
183
196
|
*/
|
|
184
197
|
async commitAndPushPin(dir, slug, force) {
|
|
185
|
-
const { execFile: execFileCb } = await import('node:child_process');
|
|
186
|
-
const util = await import('node:util');
|
|
187
|
-
const execFile = util.promisify(execFileCb);
|
|
188
198
|
// Stage just quonfig.json so we don't accidentally commit other dirty files.
|
|
189
|
-
await
|
|
199
|
+
await runGit(['-C', dir, 'add', 'quonfig.json']);
|
|
190
200
|
// If `git add` produced no staged change (e.g. content matched an earlier
|
|
191
201
|
// version on disk), `git commit` would fail. Check the index first.
|
|
192
|
-
const { stdout: diffStat } = await
|
|
202
|
+
const { stdout: diffStat } = await runGit(['-C', dir, 'diff', '--cached', '--name-only']);
|
|
193
203
|
if (!diffStat.trim()) {
|
|
194
204
|
this.verboseLog('WorkspaceBootstrap', 'quonfig.json pin matches HEAD; no commit needed.');
|
|
195
205
|
return;
|
|
196
206
|
}
|
|
197
|
-
await
|
|
207
|
+
await runGit(['-C', dir, 'commit', '-m', `chore: pin quonfig.json to workspace "${slug}"`]);
|
|
198
208
|
// Push the new commit using the same force semantics as the main push.
|
|
199
209
|
const pushArgs = ['-C', dir, 'push', 'origin', 'main'];
|
|
200
210
|
pushArgs.push(force ? '--force' : '--force-with-lease');
|
|
201
|
-
await
|
|
211
|
+
await runGit(pushArgs);
|
|
202
212
|
this.log(`Pushed pin commit.`);
|
|
203
213
|
}
|
|
204
214
|
async remoteHasCommits(repoUrl) {
|
|
205
|
-
const {
|
|
206
|
-
const util = await import('node:util');
|
|
207
|
-
const execFile = util.promisify(execFileCb);
|
|
208
|
-
const { stdout } = await execFile('git', ['ls-remote', '--heads', repoUrl]);
|
|
215
|
+
const { stdout } = await runGit(['ls-remote', '--heads', repoUrl]);
|
|
209
216
|
return stdout.trim().length > 0;
|
|
210
217
|
}
|
|
211
218
|
}
|
|
@@ -11,6 +11,17 @@ export default class WorkspaceCreate extends BaseCommand {
|
|
|
11
11
|
org: import("@oclif/core/lib/interfaces/parser.js").OptionFlag<string | undefined, import("@oclif/core/lib/interfaces/parser.js").CustomOptions>;
|
|
12
12
|
};
|
|
13
13
|
run(): Promise<JsonObj | void>;
|
|
14
|
+
/**
|
|
15
|
+
* Pick the org for the new workspace from the per-org token store.
|
|
16
|
+
*
|
|
17
|
+
* - 0 orgs → "No orgs found. Run `qfg login` first."
|
|
18
|
+
* - 1 org → auto-select that org's workosOrgId.
|
|
19
|
+
* - 2+ orgs → require `--org <slug-or-uuid>`. Slug is matched against
|
|
20
|
+
* `org_slug` in the token store; UUID is matched against the token
|
|
21
|
+
* store key. Either way the org must exist locally — we never invent
|
|
22
|
+
* an orgId we don't have a token for.
|
|
23
|
+
*/
|
|
24
|
+
private resolveOrgFromTokens;
|
|
14
25
|
/**
|
|
15
26
|
* oRPC surfaces structured errors as {json: {code, message, ...}} or
|
|
16
27
|
* {error: {...}}. Extract the message for display; fall back to raw
|
|
@@ -2,6 +2,8 @@ import { Args, Flags } from '@oclif/core';
|
|
|
2
2
|
import { BaseCommand } from '../../index.js';
|
|
3
3
|
import { getApiUrl } from '../../util/domain-urls.js';
|
|
4
4
|
import { getValidAccessToken } from '../../util/get-valid-token.js';
|
|
5
|
+
import { findOrgIdBySlug, loadTokens } from '../../util/token-storage.js';
|
|
6
|
+
const UUID_PATTERN = /^[\da-f]{8}(?:-[\da-f]{4}){3}-[\da-f]{12}$/i;
|
|
5
7
|
/**
|
|
6
8
|
* Slugify matches the UI's rules exactly (see
|
|
7
9
|
* app-quonfig/src/app/(app)/workspaces/new/page.tsx): lowercase, any
|
|
@@ -44,31 +46,26 @@ export default class WorkspaceCreate extends BaseCommand {
|
|
|
44
46
|
if (!slug) {
|
|
45
47
|
return this.err(`"${args.slug}" does not produce a valid slug. Use letters, numbers, or hyphens.`);
|
|
46
48
|
}
|
|
47
|
-
//
|
|
48
|
-
//
|
|
49
|
-
//
|
|
50
|
-
|
|
49
|
+
// workspace create has no workspace context to lean on, so it can't go
|
|
50
|
+
// through the org/ws resolver. Pick the org directly from the token
|
|
51
|
+
// store: 1 org → auto, 2+ orgs → require --org, 0 orgs → not logged in.
|
|
52
|
+
const orgResolution = await this.resolveOrgFromTokens(flags.org);
|
|
53
|
+
if ('error' in orgResolution)
|
|
54
|
+
return this.err(orgResolution.error);
|
|
55
|
+
const { workosOrgId, orgSlug } = orgResolution;
|
|
51
56
|
let accessToken;
|
|
52
57
|
try {
|
|
53
|
-
accessToken = await getValidAccessToken();
|
|
58
|
+
accessToken = await getValidAccessToken(workosOrgId);
|
|
54
59
|
}
|
|
55
60
|
catch {
|
|
56
61
|
return this.err('Not logged in. Run `qfg login` first.');
|
|
57
62
|
}
|
|
58
63
|
const apiUrl = getApiUrl();
|
|
59
|
-
const body = { slug };
|
|
64
|
+
const body = { slug, organizationId: workosOrgId };
|
|
60
65
|
if (flags.name)
|
|
61
66
|
body.name = flags.name;
|
|
62
|
-
if (
|
|
63
|
-
|
|
64
|
-
// The server resolves the slug to the local org UUID.
|
|
65
|
-
const uuidPattern = /^[\da-f]{8}(?:-[\da-f]{4}){3}-[\da-f]{12}$/i;
|
|
66
|
-
if (uuidPattern.test(flags.org)) {
|
|
67
|
-
body.organizationId = flags.org;
|
|
68
|
-
}
|
|
69
|
-
else {
|
|
70
|
-
body.organizationSlug = flags.org;
|
|
71
|
-
}
|
|
67
|
+
if (orgSlug) {
|
|
68
|
+
this.log(`Creating workspace in org: ${orgSlug}`);
|
|
72
69
|
}
|
|
73
70
|
this.verboseLog('WorkspaceCreate', { apiUrl, body });
|
|
74
71
|
let res;
|
|
@@ -134,6 +131,49 @@ export default class WorkspaceCreate extends BaseCommand {
|
|
|
134
131
|
environments: created.environments,
|
|
135
132
|
};
|
|
136
133
|
}
|
|
134
|
+
/**
|
|
135
|
+
* Pick the org for the new workspace from the per-org token store.
|
|
136
|
+
*
|
|
137
|
+
* - 0 orgs → "No orgs found. Run `qfg login` first."
|
|
138
|
+
* - 1 org → auto-select that org's workosOrgId.
|
|
139
|
+
* - 2+ orgs → require `--org <slug-or-uuid>`. Slug is matched against
|
|
140
|
+
* `org_slug` in the token store; UUID is matched against the token
|
|
141
|
+
* store key. Either way the org must exist locally — we never invent
|
|
142
|
+
* an orgId we don't have a token for.
|
|
143
|
+
*/
|
|
144
|
+
async resolveOrgFromTokens(flagOrg) {
|
|
145
|
+
const store = await loadTokens();
|
|
146
|
+
if (!store || Object.keys(store.tokensByOrg).length === 0) {
|
|
147
|
+
return { error: 'No orgs found. Run `qfg login` first.' };
|
|
148
|
+
}
|
|
149
|
+
const entries = Object.entries(store.tokensByOrg);
|
|
150
|
+
if (flagOrg) {
|
|
151
|
+
if (UUID_PATTERN.test(flagOrg)) {
|
|
152
|
+
const tokens = store.tokensByOrg[flagOrg];
|
|
153
|
+
if (!tokens) {
|
|
154
|
+
return { error: `Org \`${flagOrg}\` not found in your token store. Run \`qfg login\` to refresh your org list.` };
|
|
155
|
+
}
|
|
156
|
+
return { workosOrgId: flagOrg, orgSlug: tokens.org_slug };
|
|
157
|
+
}
|
|
158
|
+
const matchedId = findOrgIdBySlug(store, flagOrg);
|
|
159
|
+
if (!matchedId) {
|
|
160
|
+
return { error: `Org \`${flagOrg}\` not found in your token store. Run \`qfg login\` to refresh your org list.` };
|
|
161
|
+
}
|
|
162
|
+
return { workosOrgId: matchedId, orgSlug: flagOrg };
|
|
163
|
+
}
|
|
164
|
+
if (entries.length === 1) {
|
|
165
|
+
const [orgId, tokens] = entries[0];
|
|
166
|
+
return { workosOrgId: orgId, orgSlug: tokens.org_slug };
|
|
167
|
+
}
|
|
168
|
+
const slugList = entries
|
|
169
|
+
.map(([, tokens]) => tokens.org_slug)
|
|
170
|
+
.filter((s) => Boolean(s))
|
|
171
|
+
.join(', ');
|
|
172
|
+
const orgList = slugList || entries.map(([orgId]) => orgId).join(', ');
|
|
173
|
+
return {
|
|
174
|
+
error: `You are a member of multiple orgs. Specify --org <org-slug> to indicate which org to create the workspace in. Your orgs: ${orgList}.`,
|
|
175
|
+
};
|
|
176
|
+
}
|
|
137
177
|
/**
|
|
138
178
|
* oRPC surfaces structured errors as {json: {code, message, ...}} or
|
|
139
179
|
* {error: {...}}. Extract the message for display; fall back to raw
|
|
@@ -7,4 +7,14 @@ export default class WorkspaceSwitch extends BaseCommand {
|
|
|
7
7
|
static description: string;
|
|
8
8
|
static examples: string[];
|
|
9
9
|
run(): Promise<JsonObj | void>;
|
|
10
|
+
/**
|
|
11
|
+
* Resolve the user's positional arg against the candidate list.
|
|
12
|
+
*
|
|
13
|
+
* - `org/ws` form (preferred) → match by both org slug and workspace slug.
|
|
14
|
+
* - UUID → workspaceId match (kept for back-compat with scripts that
|
|
15
|
+
* pass IDs straight through). Bare slug is rejected with the same
|
|
16
|
+
* migration message used elsewhere in the CLI.
|
|
17
|
+
*/
|
|
18
|
+
private matchByArg;
|
|
19
|
+
private fetchAllWorkspaces;
|
|
10
20
|
}
|
|
@@ -3,78 +3,52 @@ import { select } from '@inquirer/prompts';
|
|
|
3
3
|
import { BaseCommand } from '../../index.js';
|
|
4
4
|
import { getApiUrl } from '../../util/domain-urls.js';
|
|
5
5
|
import { getValidAccessToken } from '../../util/get-valid-token.js';
|
|
6
|
-
import {
|
|
6
|
+
import { tryParseWorkspacePin } from '../../util/quonfig-json.js';
|
|
7
|
+
import { findOrgIdBySlug, loadAuthConfig, loadTokens, saveAuthConfig } from '../../util/token-storage.js';
|
|
7
8
|
const UUID_PATTERN = /^[\da-f]{8}(?:-[\da-f]{4}){3}-[\da-f]{12}$/i;
|
|
8
9
|
export default class WorkspaceSwitch extends BaseCommand {
|
|
9
10
|
static args = {
|
|
10
11
|
slug: Args.string({
|
|
11
|
-
description: 'Workspace
|
|
12
|
+
description: 'Workspace pin in `<org-slug>/<workspace-slug>` form. Omit for an interactive picker.',
|
|
12
13
|
required: false,
|
|
13
14
|
}),
|
|
14
15
|
};
|
|
15
|
-
static description = 'Switch to a different workspace';
|
|
16
|
-
static examples = ['<%= config.bin %> <%= command.id %>', '<%= config.bin %> <%= command.id %>
|
|
16
|
+
static description = 'Switch the saved default profile to a different (org, workspace) pair';
|
|
17
|
+
static examples = ['<%= config.bin %> <%= command.id %>', '<%= config.bin %> <%= command.id %> acme/production'];
|
|
17
18
|
async run() {
|
|
18
19
|
const { args } = await this.parse(WorkspaceSwitch);
|
|
19
|
-
|
|
20
|
+
const store = await loadTokens();
|
|
21
|
+
if (!store || Object.keys(store.tokensByOrg).length === 0) {
|
|
22
|
+
return this.err('Not logged in. Run `qfg login` first.');
|
|
23
|
+
}
|
|
24
|
+
const orgEntries = Object.entries(store.tokensByOrg);
|
|
25
|
+
const firstOrgId = orgEntries[0][0];
|
|
20
26
|
let accessToken;
|
|
21
27
|
try {
|
|
22
|
-
accessToken = await getValidAccessToken();
|
|
28
|
+
accessToken = await getValidAccessToken(firstOrgId, this.verboseLog);
|
|
23
29
|
}
|
|
24
30
|
catch {
|
|
25
31
|
return this.err('Not logged in. Run `qfg login` first.');
|
|
26
32
|
}
|
|
27
|
-
const
|
|
28
|
-
if (!authConfig) {
|
|
29
|
-
return this.err('Not logged in. Run `qfg login` first.');
|
|
30
|
-
}
|
|
31
|
-
// Fetch all workspaces the user has access to
|
|
32
|
-
const apiUrl = getApiUrl();
|
|
33
|
-
let candidates = [];
|
|
34
|
-
try {
|
|
35
|
-
const res = await fetch(`${apiUrl}/api/v1/userWorkspaces/list`, {
|
|
36
|
-
method: 'POST',
|
|
37
|
-
headers: {
|
|
38
|
-
Authorization: `Bearer ${accessToken}`,
|
|
39
|
-
'Content-Type': 'application/json',
|
|
40
|
-
},
|
|
41
|
-
body: JSON.stringify({ json: {} }),
|
|
42
|
-
});
|
|
43
|
-
if (!res.ok) {
|
|
44
|
-
return this.err(`Failed to fetch workspaces (${res.status}). Try \`qfg login\` to re-authenticate.`);
|
|
45
|
-
}
|
|
46
|
-
const body = (await res.json());
|
|
47
|
-
const all = (body.json ?? body);
|
|
48
|
-
candidates = Array.isArray(all) ? all : [];
|
|
49
|
-
}
|
|
50
|
-
catch (error) {
|
|
51
|
-
return this.err(`Failed to fetch workspaces: ${String(error)}`);
|
|
52
|
-
}
|
|
33
|
+
const candidates = await this.fetchAllWorkspaces(accessToken);
|
|
53
34
|
if (candidates.length === 0) {
|
|
54
35
|
return this.err('No workspaces found for your account.');
|
|
55
36
|
}
|
|
56
|
-
// Pick workspace: positional arg → interactive picker → auto-select if only one
|
|
57
37
|
let match;
|
|
58
38
|
if (args.slug) {
|
|
59
|
-
const found =
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
const available = candidates.map((w) => w.workspaceSlug).join(', ') || '(none)';
|
|
64
|
-
return this.err(`Workspace "${args.slug}" not found. Available: ${available}.`);
|
|
65
|
-
}
|
|
66
|
-
match = found;
|
|
39
|
+
const found = this.matchByArg(args.slug, candidates, store);
|
|
40
|
+
if ('error' in found)
|
|
41
|
+
return this.err(found.error);
|
|
42
|
+
match = found.match;
|
|
67
43
|
}
|
|
68
44
|
else if (candidates.length === 1) {
|
|
69
45
|
match = candidates[0];
|
|
70
|
-
|
|
46
|
+
const pin = pinFor(match);
|
|
47
|
+
this.log(`Only one workspace available: ${pin}`);
|
|
71
48
|
}
|
|
72
49
|
else {
|
|
73
50
|
const chosen = await select({
|
|
74
|
-
choices: candidates.map((w) => ({
|
|
75
|
-
name: `${w.organizationName} / ${w.workspaceSlug}`,
|
|
76
|
-
value: w.workspaceId,
|
|
77
|
-
})),
|
|
51
|
+
choices: candidates.map((w) => ({ name: pinFor(w), value: w.workspaceId })),
|
|
78
52
|
message: 'Select workspace:',
|
|
79
53
|
});
|
|
80
54
|
const found = candidates.find((w) => w.workspaceId === chosen);
|
|
@@ -82,10 +56,12 @@ export default class WorkspaceSwitch extends BaseCommand {
|
|
|
82
56
|
return this.err('No workspace selected.');
|
|
83
57
|
match = found;
|
|
84
58
|
}
|
|
85
|
-
|
|
59
|
+
const authConfig = (await loadAuthConfig()) ?? { profiles: {}, defaultProfile: 'default' };
|
|
86
60
|
const defaultProfile = authConfig.defaultProfile || 'default';
|
|
61
|
+
const orgSlug = match.organizationSlug ?? slugFromName(match.organizationName) ?? match.workosOrgId;
|
|
87
62
|
await saveAuthConfig({
|
|
88
63
|
...authConfig,
|
|
64
|
+
defaultProfile,
|
|
89
65
|
profiles: {
|
|
90
66
|
...authConfig.profiles,
|
|
91
67
|
[defaultProfile]: {
|
|
@@ -93,17 +69,89 @@ export default class WorkspaceSwitch extends BaseCommand {
|
|
|
93
69
|
workspaceName: match.workspaceSlug,
|
|
94
70
|
workspaceSlug: match.workspaceSlug,
|
|
95
71
|
organizationName: match.organizationName,
|
|
72
|
+
organizationSlug: orgSlug,
|
|
73
|
+
workosOrgId: match.workosOrgId,
|
|
96
74
|
},
|
|
97
75
|
},
|
|
98
76
|
});
|
|
99
|
-
|
|
77
|
+
const pin = `${orgSlug}/${match.workspaceSlug}`;
|
|
78
|
+
this.log(`\nSwitched to: ${pin}`);
|
|
100
79
|
this.log(`\nTo use this workspace in a project, add to your .env:`);
|
|
101
|
-
this.log(` QUONFIG_WORKSPACE=${
|
|
80
|
+
this.log(` QUONFIG_WORKSPACE=${pin}`);
|
|
102
81
|
return {
|
|
103
|
-
organizationName: match.organizationName,
|
|
104
82
|
success: true,
|
|
83
|
+
activeWorkspace: pin,
|
|
84
|
+
organizationName: match.organizationName,
|
|
85
|
+
organizationSlug: orgSlug,
|
|
86
|
+
workosOrgId: match.workosOrgId,
|
|
105
87
|
workspaceId: match.workspaceId,
|
|
106
88
|
workspaceSlug: match.workspaceSlug,
|
|
107
89
|
};
|
|
108
90
|
}
|
|
91
|
+
/**
|
|
92
|
+
* Resolve the user's positional arg against the candidate list.
|
|
93
|
+
*
|
|
94
|
+
* - `org/ws` form (preferred) → match by both org slug and workspace slug.
|
|
95
|
+
* - UUID → workspaceId match (kept for back-compat with scripts that
|
|
96
|
+
* pass IDs straight through). Bare slug is rejected with the same
|
|
97
|
+
* migration message used elsewhere in the CLI.
|
|
98
|
+
*/
|
|
99
|
+
matchByArg(arg, candidates, store) {
|
|
100
|
+
if (UUID_PATTERN.test(arg)) {
|
|
101
|
+
const found = candidates.find((w) => w.workspaceId === arg);
|
|
102
|
+
if (!found)
|
|
103
|
+
return { error: `Workspace UUID "${arg}" not found among your accessible workspaces.` };
|
|
104
|
+
return { match: found };
|
|
105
|
+
}
|
|
106
|
+
const pin = tryParseWorkspacePin(arg);
|
|
107
|
+
if (!pin) {
|
|
108
|
+
return {
|
|
109
|
+
error: 'Workspace argument must be in `<org-slug>/<workspace-slug>` form (e.g. acme/production). ' +
|
|
110
|
+
'Bare workspace slugs are no longer accepted.',
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
const orgId = findOrgIdBySlug(store, pin.orgSlug);
|
|
114
|
+
if (!orgId) {
|
|
115
|
+
return { error: `No token found for org \`${pin.orgSlug}\`. Run \`qfg login\` to add this org.` };
|
|
116
|
+
}
|
|
117
|
+
const found = candidates.find((w) => w.workosOrgId === orgId && w.workspaceSlug === pin.workspaceSlug);
|
|
118
|
+
if (!found) {
|
|
119
|
+
const inOrg = candidates.filter((w) => w.workosOrgId === orgId).map((w) => w.workspaceSlug);
|
|
120
|
+
const available = inOrg.length > 0 ? inOrg.join(', ') : '(none)';
|
|
121
|
+
return {
|
|
122
|
+
error: `Workspace "${pin.workspaceSlug}" not found in org "${pin.orgSlug}". Available: ${available}.`,
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
return { match: found };
|
|
126
|
+
}
|
|
127
|
+
async fetchAllWorkspaces(accessToken) {
|
|
128
|
+
try {
|
|
129
|
+
const res = await fetch(`${getApiUrl()}/api/v1/userWorkspaces/list`, {
|
|
130
|
+
method: 'POST',
|
|
131
|
+
headers: { Authorization: `Bearer ${accessToken}`, 'Content-Type': 'application/json' },
|
|
132
|
+
body: JSON.stringify({ json: {} }),
|
|
133
|
+
});
|
|
134
|
+
if (!res.ok) {
|
|
135
|
+
throw new Error(`Failed to fetch workspaces (${res.status}). Try \`qfg login\` to re-authenticate.`);
|
|
136
|
+
}
|
|
137
|
+
const body = (await res.json());
|
|
138
|
+
const all = (body.json ?? body);
|
|
139
|
+
return Array.isArray(all) ? all : [];
|
|
140
|
+
}
|
|
141
|
+
catch (error) {
|
|
142
|
+
throw new Error(`Failed to fetch workspaces: ${error instanceof Error ? error.message : String(error)}`);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
function pinFor(w) {
|
|
147
|
+
const orgSlug = w.organizationSlug ?? slugFromName(w.organizationName) ?? w.workosOrgId;
|
|
148
|
+
return `${orgSlug}/${w.workspaceSlug}`;
|
|
149
|
+
}
|
|
150
|
+
function slugFromName(name) {
|
|
151
|
+
if (!name)
|
|
152
|
+
return undefined;
|
|
153
|
+
return name
|
|
154
|
+
.toLowerCase()
|
|
155
|
+
.replaceAll(/[^\da-z]+/g, '-')
|
|
156
|
+
.replaceAll(/^-|-$/g, '');
|
|
109
157
|
}
|
|
@@ -4,5 +4,19 @@ export default class Workspace extends BaseCommand {
|
|
|
4
4
|
static description: string;
|
|
5
5
|
static examples: string[];
|
|
6
6
|
run(): Promise<JsonObj | void>;
|
|
7
|
+
/**
|
|
8
|
+
* Pin the default-profile workspaceId to org/ws form by looking it up in
|
|
9
|
+
* the workspaces list. Returns undefined if the auth config is empty or
|
|
10
|
+
* the saved workspace doesn't appear in the listing.
|
|
11
|
+
*/
|
|
12
|
+
private profilePin;
|
|
13
|
+
/**
|
|
14
|
+
* QUONFIG_WORKSPACE wins over the default profile (matches the resolution
|
|
15
|
+
* order in get-client.ts/resolve-workspace.ts). Bare slugs are ignored
|
|
16
|
+
* here — the status command does NOT throw on a stale env-var, just
|
|
17
|
+
* shows the default profile instead.
|
|
18
|
+
*/
|
|
19
|
+
private activePin;
|
|
20
|
+
private fetchWorkspaces;
|
|
7
21
|
private runApiKeyMode;
|
|
8
22
|
}
|