@quonfig/cli 0.0.25 → 0.0.26

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/README.md +47 -47
  2. package/dist/commands/create.d.ts +7 -0
  3. package/dist/commands/create.js +33 -0
  4. package/dist/commands/delete.js +4 -2
  5. package/dist/commands/init.js +6 -2
  6. package/dist/commands/login.js +72 -91
  7. package/dist/commands/migrate/doctor.js +13 -3
  8. package/dist/commands/override.js +4 -2
  9. package/dist/commands/pull.d.ts +11 -0
  10. package/dist/commands/pull.js +67 -20
  11. package/dist/commands/push.js +4 -7
  12. package/dist/commands/whoami.d.ts +13 -0
  13. package/dist/commands/whoami.js +119 -32
  14. package/dist/commands/workspace/bootstrap.js +27 -20
  15. package/dist/commands/workspace/create.d.ts +11 -0
  16. package/dist/commands/workspace/create.js +56 -16
  17. package/dist/commands/workspace/switch.d.ts +10 -0
  18. package/dist/commands/workspace/switch.js +98 -50
  19. package/dist/commands/workspace.d.ts +14 -0
  20. package/dist/commands/workspace.js +152 -40
  21. package/dist/migrate/local-write.js +16 -31
  22. package/dist/push/bare-path-diff.js +2 -4
  23. package/dist/push/config-push-client.js +4 -2
  24. package/dist/push/run-push.js +37 -8
  25. package/dist/util/clone-and-stack-push.js +14 -29
  26. package/dist/util/get-client.js +70 -38
  27. package/dist/util/get-valid-token.d.ts +16 -4
  28. package/dist/util/get-valid-token.js +59 -42
  29. package/dist/util/git-ops.d.ts +90 -0
  30. package/dist/util/git-ops.js +172 -24
  31. package/dist/util/gitea-api.js +4 -2
  32. package/dist/util/local-config-writer.d.ts +33 -0
  33. package/dist/util/local-config-writer.js +63 -0
  34. package/dist/util/login-orchestrator.d.ts +42 -0
  35. package/dist/util/login-orchestrator.js +79 -0
  36. package/dist/util/oauth-client.d.ts +2 -0
  37. package/dist/util/oauth-client.js +30 -0
  38. package/dist/util/quonfig-json.d.ts +58 -21
  39. package/dist/util/quonfig-json.js +151 -22
  40. package/dist/util/resolve-workspace.js +79 -17
  41. package/dist/util/token-storage.d.ts +39 -8
  42. package/dist/util/token-storage.js +34 -4
  43. package/oclif.manifest.json +8 -8
  44. package/package.json +1 -1
@@ -1,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 { getActiveProfile, loadAuthConfig, loadTokens } from '../util/token-storage.js';
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 authConfig = await loadAuthConfig();
9
- const tokens = await loadTokens();
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
- // Get email from stored user info or decode JWT
17
- let userEmail = tokens.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(tokens.accessToken);
20
+ const payload = decodeJWT(firstEntry.access_token);
21
21
  userEmail = payload.email;
22
22
  }
23
23
  catch {
24
- // If we can't decode the token, continue without email
24
+ // Continue without email if JWT decode fails.
25
25
  }
26
26
  }
27
- // Get the active profile
28
- const activeProfile = getActiveProfile();
29
- const profile = authConfig.profiles[activeProfile] || authConfig.profiles[authConfig.defaultProfile || 'default'];
30
- if (!profile) {
31
- this.log('Not logged in. Use `qfg login` to authenticate.');
32
- return {
33
- loggedIn: false,
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(`Workspace: ${workspaceDisplay}`);
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
- organizationName: profile.organizationName,
46
- userId: tokens.userId,
47
- workspace: profile.workspace,
48
- workspaceSlug,
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
- if (existingPin && existingPin !== backendSlug) {
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 "${existingPin}", but the backend says this workspace is "${backendSlug}".`);
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 === backendSlug) {
152
- this.verboseLog('WorkspaceBootstrap', `quonfig.json already pinned to ${backendSlug}; no-op.`);
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 "${backendSlug}"...`);
157
- await writeWorkspaceSlug(resolvedDir, backendSlug);
158
- await this.commitAndPushPin(resolvedDir, backendSlug, flags.force);
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 execFile('git', ['-C', dir, 'add', 'quonfig.json']);
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 execFile('git', ['-C', dir, 'diff', '--cached', '--name-only']);
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 execFile('git', ['-C', dir, 'commit', '-m', `chore: pin quonfig.json to workspace "${slug}"`]);
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 execFile('git', pushArgs);
211
+ await runGit(pushArgs);
202
212
  this.log(`Pushed pin commit.`);
203
213
  }
204
214
  async remoteHasCommits(repoUrl) {
205
- const { execFile: execFileCb } = await import('node:child_process');
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
- // Resolve auth via the same path every other command uses. We
48
- // don't require an active-workspace profile (AuthConfig) — the
49
- // whole point of this command is to create one — but we do need a
50
- // valid bearer token / API key.
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 (flags.org) {
63
- // Accept either a UUID (from the API) or a human-readable slug.
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 { loadAuthConfig, saveAuthConfig } from '../../util/token-storage.js';
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 slug (or UUID) to switch to. Omit for an interactive picker.',
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 %> my-workspace-slug'];
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
- // Get a valid token — refresh silently if needed, error if not logged in
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 authConfig = await loadAuthConfig();
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 = UUID_PATTERN.test(args.slug)
60
- ? candidates.find((w) => w.workspaceId === args.slug)
61
- : candidates.find((w) => w.workspaceSlug === args.slug);
62
- if (!found) {
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
- this.log(`Only one workspace available: ${match.organizationName} / ${match.workspaceSlug}`);
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
- // Save as the default profile
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
- this.log(`\nSwitched to: ${match.organizationName} / ${match.workspaceSlug}`);
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=${match.workspaceSlug}`);
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
  }