@quonfig/cli 0.0.29 → 0.0.31

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 (37) hide show
  1. package/README.md +35 -35
  2. package/dist/codegen/fetch-workspace-snapshot.js +3 -3
  3. package/dist/commands/delete.js +2 -1
  4. package/dist/commands/login.js +0 -1
  5. package/dist/commands/migrate/doctor.js +3 -1
  6. package/dist/commands/migrate.js +10 -13
  7. package/dist/commands/override.js +1 -1
  8. package/dist/commands/pull.js +12 -12
  9. package/dist/commands/push.d.ts +1 -1
  10. package/dist/commands/push.js +5 -5
  11. package/dist/commands/sync.d.ts +1 -0
  12. package/dist/commands/sync.js +6 -13
  13. package/dist/commands/whoami.js +65 -6
  14. package/dist/commands/workspace/bootstrap.js +8 -12
  15. package/dist/commands/workspace/create.d.ts +5 -1
  16. package/dist/commands/workspace/create.js +27 -10
  17. package/dist/commands/workspace/switch.d.ts +7 -1
  18. package/dist/commands/workspace/switch.js +45 -27
  19. package/dist/commands/workspace.d.ts +5 -0
  20. package/dist/commands/workspace.js +28 -9
  21. package/dist/migrate/sources/launch/translate.js +6 -3
  22. package/dist/migrate/sources/launch/zod-to-json-schema.js +4 -0
  23. package/dist/push/config-push-client.d.ts +1 -1
  24. package/dist/push/config-push-client.js +3 -5
  25. package/dist/util/get-client.js +12 -12
  26. package/dist/util/get-valid-token.d.ts +7 -7
  27. package/dist/util/get-valid-token.js +19 -13
  28. package/dist/util/gitea-api.d.ts +2 -2
  29. package/dist/util/gitea-api.js +5 -7
  30. package/dist/util/resolve-workspace.d.ts +14 -4
  31. package/dist/util/resolve-workspace.js +32 -20
  32. package/dist/util/token-storage.d.ts +10 -5
  33. package/dist/util/token-storage.js +14 -7
  34. package/dist/version.d.ts +1 -1
  35. package/dist/version.js +1 -1
  36. package/oclif.manifest.json +1 -1
  37. package/package.json +1 -1
@@ -1,6 +1,6 @@
1
1
  import { BaseCommand } from '../index.js';
2
2
  import { getApiUrl } from '../util/domain-urls.js';
3
- import { getValidAccessToken, resolveDefaultOrgId } from '../util/get-valid-token.js';
3
+ import { getValidAccessToken } from '../util/get-valid-token.js';
4
4
  import { decodeJWT } from '../util/oauth-client.js';
5
5
  import { tryParseWorkspacePin } from '../util/quonfig-json.js';
6
6
  import { findOrgIdBySlug, loadTokens } from '../util/token-storage.js';
@@ -31,6 +31,7 @@ export default class Whoami extends BaseCommand {
31
31
  slug: t.org_slug ?? orgId,
32
32
  name: t.org_name ?? t.org_slug ?? orgId,
33
33
  role: '—',
34
+ sessionStatus: sessionStatusFor(t),
34
35
  }));
35
36
  const merged = mergeMemberships(localOrgs, memberships);
36
37
  this.log(`Logged in as: ${userEmail || 'Unknown'}`);
@@ -44,7 +45,13 @@ export default class Whoami extends BaseCommand {
44
45
  const marker = org.workosOrgId === activeOrgId ? '*' : ' ';
45
46
  const label = `${org.slug}${org.workosOrgId === activeOrgId ? ' (active)' : ''}`;
46
47
  const padded = label.padEnd(labelWidth, ' ');
47
- this.log(` ${marker} ${padded}— ${org.role}`);
48
+ const statusSuffix = org.sessionStatus ? ` (${org.sessionStatus.description})` : '';
49
+ this.log(` ${marker} ${padded}— ${org.role}${statusSuffix}`);
50
+ }
51
+ const expiredCount = merged.filter((o) => o.sessionStatus?.expired).length;
52
+ if (expiredCount > 0) {
53
+ this.log('');
54
+ this.log(`${expiredCount} of ${merged.length} session(s) expired. Run \`qfg login\` to refresh.`);
48
55
  }
49
56
  }
50
57
  return {
@@ -58,6 +65,7 @@ export default class Whoami extends BaseCommand {
58
65
  name: o.name,
59
66
  role: o.role,
60
67
  active: o.workosOrgId === activeOrgId,
68
+ sessionExpired: o.sessionStatus?.expired ?? null,
61
69
  })),
62
70
  };
63
71
  }
@@ -82,7 +90,11 @@ export default class Whoami extends BaseCommand {
82
90
  }
83
91
  if (defaultOrgId)
84
92
  return defaultOrgId;
85
- return resolveDefaultOrgId().then((id) => id || undefined);
93
+ // Fall back to the first token entry — gives a stable marker rather than nothing.
94
+ const store = await loadTokens();
95
+ if (!store)
96
+ return undefined;
97
+ return Object.keys(store.tokensByOrg)[0];
86
98
  }
87
99
  /**
88
100
  * Best-effort fetch of `me.organizations` for role enrichment. Failure
@@ -92,7 +104,10 @@ export default class Whoami extends BaseCommand {
92
104
  async fetchOrganizations() {
93
105
  let jwt;
94
106
  try {
95
- const orgId = await resolveDefaultOrgId();
107
+ const store = await loadTokens();
108
+ if (!store)
109
+ return [];
110
+ const orgId = pickAnyOrgId(store);
96
111
  if (!orgId)
97
112
  return [];
98
113
  jwt = await getValidAccessToken(orgId, this.verboseLog);
@@ -132,7 +147,51 @@ function mergeMemberships(local, server) {
132
147
  const merged = new Map();
133
148
  for (const o of local)
134
149
  merged.set(o.workosOrgId, o);
135
- for (const o of server)
136
- merged.set(o.workosOrgId, o);
150
+ for (const o of server) {
151
+ // Server data wins for slug/name/role but lacks sessionStatus — that's
152
+ // a property of the locally-cached token, so preserve it.
153
+ const existing = merged.get(o.workosOrgId);
154
+ merged.set(o.workosOrgId, { ...o, sessionStatus: existing?.sessionStatus });
155
+ }
137
156
  return [...merged.values()].sort((a, b) => a.slug.localeCompare(b.slug));
138
157
  }
158
+ /**
159
+ * Read-only check of a TokenSet's expiry. Prefers the JWT's `exp` claim
160
+ * (matches getValidAccessToken's logic), falls back to the stored
161
+ * `expires_at` timestamp if the JWT can't be decoded. No network calls —
162
+ * whoami stays fast even with many cached orgs.
163
+ */
164
+ function sessionStatusFor(tokens) {
165
+ let expMs = tokens.expires_at;
166
+ try {
167
+ const payload = decodeJWT(tokens.access_token);
168
+ if (typeof payload.exp === 'number')
169
+ expMs = payload.exp * 1000;
170
+ }
171
+ catch {
172
+ /* keep stored expires_at */
173
+ }
174
+ const delta = expMs - Date.now();
175
+ if (delta <= 0)
176
+ return { expired: true, description: 'expired — run `qfg login`' };
177
+ return { expired: false, description: `expires in ${formatRelative(delta)}` };
178
+ }
179
+ function formatRelative(deltaMs) {
180
+ const minutes = Math.round(deltaMs / 60_000);
181
+ if (minutes < 1)
182
+ return '<1m';
183
+ if (minutes < 60)
184
+ return `${minutes}m`;
185
+ const hours = Math.floor(minutes / 60);
186
+ const remMin = minutes % 60;
187
+ if (hours < 24)
188
+ return remMin > 0 ? `${hours}h ${remMin}m` : `${hours}h`;
189
+ const days = Math.floor(hours / 24);
190
+ const remHours = hours % 24;
191
+ return remHours > 0 ? `${days}d ${remHours}h` : `${days}d`;
192
+ }
193
+ function pickAnyOrgId(store) {
194
+ if (store.defaultOrgId && store.tokensByOrg[store.defaultOrgId])
195
+ return store.defaultOrgId;
196
+ return Object.keys(store.tokensByOrg)[0];
197
+ }
@@ -5,6 +5,7 @@ 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
7
  import { readWorkspaceSlug, tryParseWorkspacePin, writeWorkspaceSlug } from '../../util/quonfig-json.js';
8
+ import { resolveWorkspaceUuid } from '../../util/resolve-workspace.js';
8
9
  import { isGitRepo, hasAtLeastOneCommit, gitSetRemote, gitPushForceLease, gitPushForce, getRemoteUrl, displayUrl, runGit, } from '../../util/git-ops.js';
9
10
  export default class WorkspaceBootstrap extends BaseCommand {
10
11
  static description = "Push a local git repo to Gitea as this workspace's config repository";
@@ -32,19 +33,14 @@ export default class WorkspaceBootstrap extends BaseCommand {
32
33
  // Resolve target directory
33
34
  const dir = flags.dir || process.env.QUONFIG_DIR || process.cwd();
34
35
  const resolvedDir = path.resolve(dir);
35
- // Load auth
36
+ const { workspaceId, orgSlug } = await resolveWorkspaceUuid(this);
37
+ // The display name is best-effort; we still want it for the confirmation
38
+ // prompt. Fall back through saved profile → workspaceId UUID.
36
39
  const authConfig = await loadAuthConfig();
37
- if (!authConfig) {
38
- return this.err('Not logged in. Run `qfg login` first.');
39
- }
40
40
  const activeProfile = getActiveProfile();
41
- const profile = authConfig.profiles[activeProfile] || authConfig.profiles[authConfig.defaultProfile || 'default'];
42
- if (!profile) {
43
- return this.err('No active profile found. Run `qfg login` first.');
44
- }
45
- const workspaceId = profile.workspace;
46
- const workspaceName = profile.workspaceSlug || profile.workspaceName || workspaceId;
47
- this.verboseLog('WorkspaceBootstrap', { workspaceId, dir: resolvedDir });
41
+ const profile = authConfig?.profiles[activeProfile] || authConfig?.profiles[authConfig?.defaultProfile || 'default'];
42
+ const workspaceName = profile?.workspaceSlug || profile?.workspaceName || workspaceId;
43
+ this.verboseLog('WorkspaceBootstrap', { workspaceId, orgSlug, dir: resolvedDir });
48
44
  // Validate: must be a git repo with at least one commit
49
45
  const isRepo = await isGitRepo(resolvedDir);
50
46
  if (!isRepo) {
@@ -98,7 +94,7 @@ export default class WorkspaceBootstrap extends BaseCommand {
98
94
  this.log('Connecting to Gitea...');
99
95
  let tokenData;
100
96
  try {
101
- tokenData = await mintGiteaToken(workspaceId, 'write', 'bootstrap');
97
+ tokenData = await mintGiteaToken(workspaceId, orgSlug, 'write', 'bootstrap');
102
98
  }
103
99
  catch (error) {
104
100
  return this.err(`Could not get Gitea credentials: ${String(error)}\n\nMake sure the workspace is fully provisioned in the Quonfig app before bootstrapping.`);
@@ -15,11 +15,15 @@ export default class WorkspaceCreate extends BaseCommand {
15
15
  * Pick the org for the new workspace from the per-org token store.
16
16
  *
17
17
  * - 0 orgs → "No orgs found. Run `qfg login` first."
18
- * - 1 org → auto-select that org's workosOrgId.
18
+ * - 1 org → auto-select that org.
19
19
  * - 2+ orgs → require `--org <slug-or-uuid>`. Slug is matched against
20
20
  * `org_slug` in the token store; UUID is matched against the token
21
21
  * store key. Either way the org must exist locally — we never invent
22
22
  * an orgId we don't have a token for.
23
+ *
24
+ * Always returns BOTH the WorkOS org id (needed to mint the JWT) and the
25
+ * org slug (needed for the create-workspace request body). If the token
26
+ * lacks `org_slug`, errors with a `qfg login` hint.
23
27
  */
24
28
  private resolveOrgFromTokens;
25
29
  /**
@@ -55,18 +55,25 @@ export default class WorkspaceCreate extends BaseCommand {
55
55
  const { workosOrgId, orgSlug } = orgResolution;
56
56
  let accessToken;
57
57
  try {
58
- accessToken = await getValidAccessToken(workosOrgId);
58
+ accessToken = await getValidAccessToken(workosOrgId, this.verboseLog);
59
59
  }
60
- catch {
61
- return this.err('Not logged in. Run `qfg login` first.');
60
+ catch (error) {
61
+ // Surface the underlying token error (e.g. "Token refresh failed for
62
+ // org X: invalid_grant — Refresh token already exchanged") so users
63
+ // and agents can distinguish "never logged in" from "session went
64
+ // stale and refresh was rejected" — both used to read "Not logged in".
65
+ const detail = error instanceof Error ? error.message : String(error);
66
+ const hint = detail.includes('qfg login') ? '' : ' Run `qfg login` to re-authenticate.';
67
+ return this.err(`${detail}${hint}`);
62
68
  }
63
69
  const apiUrl = getApiUrl();
64
- const body = { slug, organizationId: workosOrgId };
70
+ // The server expects organizationSlug (it resolves to the local org
71
+ // UUID) — not the WorkOS org id, which is what we have keyed in the
72
+ // token store. resolveOrgFromTokens guarantees orgSlug is populated.
73
+ const body = { slug, organizationSlug: orgSlug };
65
74
  if (flags.name)
66
75
  body.name = flags.name;
67
- if (orgSlug) {
68
- this.log(`Creating workspace in org: ${orgSlug}`);
69
- }
76
+ this.log(`Creating workspace in org: ${orgSlug}`);
70
77
  this.verboseLog('WorkspaceCreate', { apiUrl, body });
71
78
  let res;
72
79
  try {
@@ -135,11 +142,15 @@ export default class WorkspaceCreate extends BaseCommand {
135
142
  * Pick the org for the new workspace from the per-org token store.
136
143
  *
137
144
  * - 0 orgs → "No orgs found. Run `qfg login` first."
138
- * - 1 org → auto-select that org's workosOrgId.
145
+ * - 1 org → auto-select that org.
139
146
  * - 2+ orgs → require `--org <slug-or-uuid>`. Slug is matched against
140
147
  * `org_slug` in the token store; UUID is matched against the token
141
148
  * store key. Either way the org must exist locally — we never invent
142
149
  * an orgId we don't have a token for.
150
+ *
151
+ * Always returns BOTH the WorkOS org id (needed to mint the JWT) and the
152
+ * org slug (needed for the create-workspace request body). If the token
153
+ * lacks `org_slug`, errors with a `qfg login` hint.
143
154
  */
144
155
  async resolveOrgFromTokens(flagOrg) {
145
156
  const store = await loadTokens();
@@ -147,13 +158,19 @@ export default class WorkspaceCreate extends BaseCommand {
147
158
  return { error: 'No orgs found. Run `qfg login` first.' };
148
159
  }
149
160
  const entries = Object.entries(store.tokensByOrg);
161
+ const requireSlug = (workosOrgId, orgSlug) => {
162
+ if (!orgSlug) {
163
+ return { error: `Org \`${workosOrgId}\` is missing its slug locally. Run \`qfg login\` to refresh your org list.` };
164
+ }
165
+ return { workosOrgId, orgSlug };
166
+ };
150
167
  if (flagOrg) {
151
168
  if (UUID_PATTERN.test(flagOrg)) {
152
169
  const tokens = store.tokensByOrg[flagOrg];
153
170
  if (!tokens) {
154
171
  return { error: `Org \`${flagOrg}\` not found in your token store. Run \`qfg login\` to refresh your org list.` };
155
172
  }
156
- return { workosOrgId: flagOrg, orgSlug: tokens.org_slug };
173
+ return requireSlug(flagOrg, tokens.org_slug);
157
174
  }
158
175
  const matchedId = findOrgIdBySlug(store, flagOrg);
159
176
  if (!matchedId) {
@@ -163,7 +180,7 @@ export default class WorkspaceCreate extends BaseCommand {
163
180
  }
164
181
  if (entries.length === 1) {
165
182
  const [orgId, tokens] = entries[0];
166
- return { workosOrgId: orgId, orgSlug: tokens.org_slug };
183
+ return requireSlug(orgId, tokens.org_slug);
167
184
  }
168
185
  const slugList = entries
169
186
  .map(([, tokens]) => tokens.org_slug)
@@ -16,5 +16,11 @@ export default class WorkspaceSwitch extends BaseCommand {
16
16
  * migration message used elsewhere in the CLI.
17
17
  */
18
18
  private matchByArg;
19
- private fetchAllWorkspaces;
19
+ /**
20
+ * Iterate every cached org and merge its workspace list. Each org has its
21
+ * own access token; one token only sees one org's workspaces. We tolerate
22
+ * a single org's failure (verbose-log it) so a stale token in one org
23
+ * doesn't block switching into a working one.
24
+ */
25
+ private fetchAllOrgsWorkspaces;
20
26
  }
@@ -21,16 +21,10 @@ export default class WorkspaceSwitch extends BaseCommand {
21
21
  if (!store || Object.keys(store.tokensByOrg).length === 0) {
22
22
  return this.err('Not logged in. Run `qfg login` first.');
23
23
  }
24
- const orgEntries = Object.entries(store.tokensByOrg);
25
- const firstOrgId = orgEntries[0][0];
26
- let accessToken;
27
- try {
28
- accessToken = await getValidAccessToken(firstOrgId, this.verboseLog);
29
- }
30
- catch {
31
- return this.err('Not logged in. Run `qfg login` first.');
32
- }
33
- const candidates = await this.fetchAllWorkspaces(accessToken);
24
+ // /userWorkspaces/list is per-token-org-scoped — a single org's JWT
25
+ // only sees that org's workspaces. To support multi-org pickers we
26
+ // must iterate every cached org and merge.
27
+ const candidates = await this.fetchAllOrgsWorkspaces(store);
34
28
  if (candidates.length === 0) {
35
29
  return this.err('No workspaces found for your account.');
36
30
  }
@@ -70,7 +64,6 @@ export default class WorkspaceSwitch extends BaseCommand {
70
64
  workspaceSlug: match.workspaceSlug,
71
65
  organizationName: match.organizationName,
72
66
  organizationSlug: orgSlug,
73
- workosOrgId: match.workosOrgId,
74
67
  },
75
68
  },
76
69
  });
@@ -83,7 +76,6 @@ export default class WorkspaceSwitch extends BaseCommand {
83
76
  activeWorkspace: pin,
84
77
  organizationName: match.organizationName,
85
78
  organizationSlug: orgSlug,
86
- workosOrgId: match.workosOrgId,
87
79
  workspaceId: match.workspaceId,
88
80
  workspaceSlug: match.workspaceSlug,
89
81
  };
@@ -124,23 +116,49 @@ export default class WorkspaceSwitch extends BaseCommand {
124
116
  }
125
117
  return { match: found };
126
118
  }
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.`);
119
+ /**
120
+ * Iterate every cached org and merge its workspace list. Each org has its
121
+ * own access token; one token only sees one org's workspaces. We tolerate
122
+ * a single org's failure (verbose-log it) so a stale token in one org
123
+ * doesn't block switching into a working one.
124
+ */
125
+ async fetchAllOrgsWorkspaces(store) {
126
+ const merged = new Map();
127
+ for (const orgId of Object.keys(store.tokensByOrg)) {
128
+ let token;
129
+ try {
130
+ // eslint-disable-next-line no-await-in-loop
131
+ token = await getValidAccessToken(orgId, this.verboseLog);
132
+ }
133
+ catch (error) {
134
+ this.verboseLog('workspace switch: token refresh failed', { orgId, error: String(error) });
135
+ continue;
136
+ }
137
+ try {
138
+ // eslint-disable-next-line no-await-in-loop
139
+ const res = await fetch(`${getApiUrl()}/api/v1/userWorkspaces/list`, {
140
+ method: 'POST',
141
+ headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
142
+ body: JSON.stringify({ json: {} }),
143
+ });
144
+ if (!res.ok) {
145
+ this.verboseLog('workspace switch: list returned non-OK', { orgId, status: res.status });
146
+ continue;
147
+ }
148
+ // eslint-disable-next-line no-await-in-loop
149
+ const body = (await res.json());
150
+ const list = (body.json ?? body);
151
+ if (!Array.isArray(list))
152
+ continue;
153
+ for (const w of list) {
154
+ merged.set(w.workspaceId, w);
155
+ }
156
+ }
157
+ catch (error) {
158
+ this.verboseLog('workspace switch: fetch failed', { orgId, error: String(error) });
136
159
  }
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
160
  }
161
+ return [...merged.values()];
144
162
  }
145
163
  }
146
164
  function pinFor(w) {
@@ -18,5 +18,10 @@ export default class Workspace extends BaseCommand {
18
18
  */
19
19
  private activePin;
20
20
  private fetchWorkspaces;
21
+ /**
22
+ * Fan out across every cached org's token and merge the results. One org's
23
+ * stale token doesn't block the others — we verbose-log and continue.
24
+ */
25
+ private fetchAllOrgsWorkspaces;
21
26
  private runApiKeyMode;
22
27
  }
@@ -20,15 +20,10 @@ export default class Workspace extends BaseCommand {
20
20
  const orgSlugsWithTokens = orgEntries
21
21
  .map(([orgId, t]) => t.org_slug ?? orgId)
22
22
  .sort((a, b) => a.localeCompare(b));
23
- const firstOrgId = orgEntries[0][0];
24
- let jwt;
25
- try {
26
- jwt = await getValidAccessToken(firstOrgId, this.verboseLog);
27
- }
28
- catch (error) {
29
- return this.err(error instanceof Error ? error.message : String(error));
30
- }
31
- const allWorkspaces = await this.fetchWorkspaces(jwt);
23
+ // /userWorkspaces/list is per-token-org-scoped — a single org's JWT
24
+ // only sees that org's workspaces. Iterate every cached org so the
25
+ // listing is comprehensive in multi-org accounts.
26
+ const allWorkspaces = await this.fetchAllOrgsWorkspaces(orgEntries);
32
27
  const defaultProfilePin = this.profilePin(authConfig, allWorkspaces);
33
28
  const activePin = this.activePin(store, authConfig, allWorkspaces, defaultProfilePin);
34
29
  this.log(`Active workspace: ${activePin ?? '(none)'}`);
@@ -135,6 +130,30 @@ export default class Workspace extends BaseCommand {
135
130
  return [];
136
131
  }
137
132
  }
133
+ /**
134
+ * Fan out across every cached org's token and merge the results. One org's
135
+ * stale token doesn't block the others — we verbose-log and continue.
136
+ */
137
+ async fetchAllOrgsWorkspaces(orgEntries) {
138
+ const merged = new Map();
139
+ for (const [orgId] of orgEntries) {
140
+ let jwt;
141
+ try {
142
+ // eslint-disable-next-line no-await-in-loop
143
+ jwt = await getValidAccessToken(orgId, this.verboseLog);
144
+ }
145
+ catch (error) {
146
+ this.verboseLog('workspace: token refresh failed for org', { orgId, error: String(error) });
147
+ continue;
148
+ }
149
+ // eslint-disable-next-line no-await-in-loop
150
+ const list = await this.fetchWorkspaces(jwt);
151
+ for (const w of list) {
152
+ merged.set(w.workspaceId, w);
153
+ }
154
+ }
155
+ return [...merged.values()];
156
+ }
138
157
  async runApiKeyMode() {
139
158
  const override = process.env.QUONFIG_WORKSPACE;
140
159
  if (!override) {
@@ -144,17 +144,20 @@ export function transformConfig(config, envIdMap, onDroppedEnv) {
144
144
  const defaultSection = out.default;
145
145
  const firstRule = defaultSection?.rules?.[0];
146
146
  const innerValue = firstRule?.value?.value;
147
+ const key = String(out.key ?? 'unknown');
147
148
  if (!innerValue || innerValue.schemaType !== 'ZOD' || typeof innerValue.schema !== 'string') {
148
- throw new Error(`Unsupported schema payload for ${String(out.key ?? 'unknown')}`);
149
+ // qfg-p74d: surface as a per-config skip so one bad source config doesn't
150
+ // abort the whole push. Surfaces in MIGRATION_REPORT.md "Skipped invalid
151
+ // configs" alongside variant mismatches.
152
+ throw new InvalidSourceConfigError(`Unsupported schema payload for "${key}"`);
149
153
  }
150
- const key = String(out.key ?? 'unknown');
151
154
  let converted;
152
155
  try {
153
156
  converted = zodToJsonSchema(innerValue.schema);
154
157
  }
155
158
  catch (error) {
156
159
  const msg = error instanceof Error ? error.message : String(error);
157
- throw new Error(`Schema conversion failed for "${key}": ${msg}\n--- Zod source ---\n${innerValue.schema}\n--- end ---`);
160
+ throw new InvalidSourceConfigError(`Schema conversion failed for "${key}": ${msg}\n--- Zod source ---\n${innerValue.schema}\n--- end ---`);
158
161
  }
159
162
  return converted.schema;
160
163
  }
@@ -352,10 +352,14 @@ function convertObjectShape(objectLiteral, ctx) {
352
352
  required.push(key);
353
353
  }
354
354
  }
355
+ // qfg-mrab: Quonfig's authoring validator requires additionalProperties:false on
356
+ // every object with properties; default zod is open, so we close it here. Users
357
+ // who genuinely want an open object can edit the emitted schema post-migrate.
355
358
  return {
356
359
  type: 'object',
357
360
  properties,
358
361
  required,
362
+ additionalProperties: false,
359
363
  };
360
364
  }
361
365
  function convertBaseZodCall(method, args, ctx) {
@@ -15,4 +15,4 @@
15
15
  * `QUONFIG_API_KEY` is set in the environment); see `getValidAccessToken`.
16
16
  */
17
17
  import type { ConfigPushInput, ConfigPushResult } from './run-push.js';
18
- export declare const callConfigsPush: (input: ConfigPushInput) => Promise<ConfigPushResult>;
18
+ export declare const callConfigsPush: (input: ConfigPushInput, orgSlug: string) => Promise<ConfigPushResult>;
@@ -15,11 +15,9 @@
15
15
  * `QUONFIG_API_KEY` is set in the environment); see `getValidAccessToken`.
16
16
  */
17
17
  import { getApiUrl } from '../util/domain-urls.js';
18
- import { getValidAccessToken, resolveDefaultOrgId } from '../util/get-valid-token.js';
19
- export const callConfigsPush = async (input) => {
20
- // TODO(qfg-kr7.5): thread workosOrgId through from the resolved workspace address.
21
- const orgId = await resolveDefaultOrgId();
22
- const accessToken = await getValidAccessToken(orgId);
18
+ import { getValidAccessTokenForOrgSlug } from '../util/get-valid-token.js';
19
+ export const callConfigsPush = async (input, orgSlug) => {
20
+ const accessToken = await getValidAccessTokenForOrgSlug(orgSlug);
23
21
  const apiUrl = getApiUrl();
24
22
  const res = await fetch(`${apiUrl}/api/v1/configs/push`, {
25
23
  method: 'POST',
@@ -3,7 +3,7 @@ import { getApiUrl } from '../util/domain-urls.js';
3
3
  import jsonMaybe from '../util/json-maybe.js';
4
4
  import { tryParseWorkspacePin } from '../util/quonfig-json.js';
5
5
  import { findOrgIdBySlug, getActiveProfile, loadAuthConfig, loadTokens } from '../util/token-storage.js';
6
- import { getValidAccessToken, resolveDefaultOrgId } from '../util/get-valid-token.js';
6
+ import { getValidAccessToken, getValidAccessTokenForOrgSlug } from '../util/get-valid-token.js';
7
7
  import version from '../version.js';
8
8
  const BARE_SLUG_ENV_MIGRATION_MESSAGE = 'QUONFIG_WORKSPACE must be in org/workspace form (e.g. acme/foo). ' +
9
9
  'Bare workspace slugs are no longer accepted. Update your .env and run `qfg login` if you have not yet migrated.';
@@ -133,15 +133,6 @@ const getClient = async (command, sdkKey, profile) => {
133
133
  command.error('No authentication found. Please run `qfg login`.', { exit: 401 });
134
134
  }
135
135
  command.verboseLog('OAuth auth', { hasAuthConfig: true });
136
- try {
137
- const orgId = await resolveDefaultOrgId();
138
- jwt = await getValidAccessToken(orgId, command.verboseLog);
139
- command.verboseLog('Token valid (or refreshed)');
140
- }
141
- catch (error) {
142
- const detail = error instanceof Error ? error.message : String(error);
143
- command.error(`Session expired. Please run \`qfg login\` to re-authenticate. (${detail})`, { exit: 401 });
144
- }
145
136
  const activeProfile = getActiveProfile(profileNameOverride);
146
137
  const profileData = authConfig.profiles[activeProfile] || authConfig.profiles[authConfig.defaultProfile || 'default'];
147
138
  command.verboseLog('Workspace lookup', {
@@ -150,9 +141,18 @@ const getClient = async (command, sdkKey, profile) => {
150
141
  hasProfileData: Boolean(profileData),
151
142
  workspaceId: profileData?.workspace,
152
143
  });
153
- if (profileData) {
154
- workspaceId = profileData.workspace;
144
+ if (!profileData?.organizationSlug) {
145
+ command.error('Saved profile is missing organization_slug. Run `qfg login` to refresh, or `qfg workspace switch <org>/<ws>`.', { exit: 1 });
146
+ }
147
+ try {
148
+ jwt = await getValidAccessTokenForOrgSlug(profileData.organizationSlug, command.verboseLog);
149
+ command.verboseLog('Token valid (or refreshed)');
150
+ }
151
+ catch (error) {
152
+ const detail = error instanceof Error ? error.message : String(error);
153
+ command.error(`Session expired. Please run \`qfg login\` to re-authenticate. (${detail})`, { exit: 401 });
155
154
  }
155
+ workspaceId = profileData.workspace;
156
156
  }
157
157
  }
158
158
  // Store workspaceId on the command for use in building URLs and cache it
@@ -10,14 +10,14 @@
10
10
  */
11
11
  type Logger = (msg: string, data?: unknown) => void;
12
12
  /**
13
- * Transitional helper: pick a default workosOrgId from the token store.
14
- * Returns '' if no store exists (fine for the QUONFIG_API_KEY short-circuit
15
- * path, which ignores the orgId argument).
13
+ * Slug-based wrapper around getValidAccessToken. Commands talk in
14
+ * `<org-slug>` (the user-vocabulary form); the slug → workosOrgId
15
+ * mapping is contained here so the rest of the CLI doesn't see WorkOS
16
+ * identifiers.
16
17
  *
17
- * TODO(qfg-kr7.5/7/8/9): callers should pass through the workosOrgId resolved
18
- * from the workspace address (org-slug/workspace-slug parsing). This helper
19
- * is the stopgap until each caller's resolution lands in its own bead.
18
+ * The `QUONFIG_API_KEY` short-circuit fires before any disk lookup, so
19
+ * an empty/unknown slug is fine in CI mode.
20
20
  */
21
- export declare function resolveDefaultOrgId(): Promise<string>;
21
+ export declare function getValidAccessTokenForOrgSlug(orgSlug: string, log?: Logger): Promise<string>;
22
22
  export declare function getValidAccessToken(workosOrgId: string, log?: Logger): Promise<string>;
23
23
  export {};
@@ -9,24 +9,30 @@
9
9
  * ignored because API keys are workspace-scoped.
10
10
  */
11
11
  import { authenticateWithOrg, decodeJWT } from './oauth-client.js';
12
- import { getTokenForOrg, loadTokens, saveTokens } from './token-storage.js';
12
+ import { findOrgIdBySlug, getTokenForOrg, loadTokens, saveTokens } from './token-storage.js';
13
13
  const noopLog = () => { };
14
14
  /**
15
- * Transitional helper: pick a default workosOrgId from the token store.
16
- * Returns '' if no store exists (fine for the QUONFIG_API_KEY short-circuit
17
- * path, which ignores the orgId argument).
15
+ * Slug-based wrapper around getValidAccessToken. Commands talk in
16
+ * `<org-slug>` (the user-vocabulary form); the slug → workosOrgId
17
+ * mapping is contained here so the rest of the CLI doesn't see WorkOS
18
+ * identifiers.
18
19
  *
19
- * TODO(qfg-kr7.5/7/8/9): callers should pass through the workosOrgId resolved
20
- * from the workspace address (org-slug/workspace-slug parsing). This helper
21
- * is the stopgap until each caller's resolution lands in its own bead.
20
+ * The `QUONFIG_API_KEY` short-circuit fires before any disk lookup, so
21
+ * an empty/unknown slug is fine in CI mode.
22
22
  */
23
- export async function resolveDefaultOrgId() {
23
+ export async function getValidAccessTokenForOrgSlug(orgSlug, log = noopLog) {
24
+ const envKey = process.env.QUONFIG_API_KEY;
25
+ if (envKey && envKey.length > 0)
26
+ return getValidAccessToken('', log);
24
27
  const store = await loadTokens();
25
- if (!store)
26
- return '';
27
- if (store.defaultOrgId && store.tokensByOrg[store.defaultOrgId])
28
- return store.defaultOrgId;
29
- return Object.keys(store.tokensByOrg)[0] ?? '';
28
+ if (!store) {
29
+ throw new Error('Not logged in. Run `qfg login` first.');
30
+ }
31
+ const workosOrgId = findOrgIdBySlug(store, orgSlug);
32
+ if (!workosOrgId) {
33
+ throw new Error(`No token for org \`${orgSlug}\`. Run \`qfg login\` to add this org.`);
34
+ }
35
+ return getValidAccessToken(workosOrgId, log);
30
36
  }
31
37
  export async function getValidAccessToken(workosOrgId, log = noopLog) {
32
38
  const envKey = process.env.QUONFIG_API_KEY;
@@ -18,8 +18,8 @@ export interface GiteaTokenResponse {
18
18
  */
19
19
  workspaceSlug: string;
20
20
  }
21
- export declare const mintGiteaToken: (workspaceId: string, scope: "read" | "write", purpose: "pull" | "bootstrap" | "push") => Promise<GiteaTokenResponse>;
21
+ export declare const mintGiteaToken: (workspaceId: string, orgSlug: string, scope: "read" | "write", purpose: "pull" | "bootstrap" | "push") => Promise<GiteaTokenResponse>;
22
22
  /**
23
23
  * Mint a Gitea read token, store it, and return the entry.
24
24
  */
25
- export declare const mintAndStoreGiteaReadToken: (workspaceId: string) => Promise<GiteaTokenEntry>;
25
+ export declare const mintAndStoreGiteaReadToken: (workspaceId: string, orgSlug: string) => Promise<GiteaTokenEntry>;