@quonfig/cli 0.0.24 → 0.0.26

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) 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/dist/verify/validate.js +25 -1
  44. package/oclif.manifest.json +8 -8
  45. package/package.json +1 -1
@@ -1,12 +1,12 @@
1
1
  import { Flags } from '@oclif/core';
2
- import { select } from '@inquirer/prompts';
3
2
  import { BaseCommand } from '../index.js';
4
- import { decodeJWT, pollForToken, requestDeviceCode } from '../util/oauth-client.js';
5
3
  import { getApiUrl } from '../util/domain-urls.js';
4
+ import { orchestrateMultiOrgLogin } from '../util/login-orchestrator.js';
5
+ import { decodeJWT, pollForToken, requestDeviceCode } from '../util/oauth-client.js';
6
6
  import { openBrowser } from '../util/open-browser.js';
7
7
  import { getAuthConfigFilePath, getTokenFilePath, loadAuthConfig, saveAuthConfig, saveTokens, } from '../util/token-storage.js';
8
8
  export default class Login extends BaseCommand {
9
- static description = 'Log in to Quonfig via WorkOS device authorization';
9
+ static description = 'Log in to Quonfig via WorkOS device authorization (mints one token per org)';
10
10
  static examples = ['<%= config.bin %> <%= command.id %>', '<%= config.bin %> <%= command.id %> --profile myprofile'];
11
11
  static flags = {
12
12
  profile: Flags.string({
@@ -28,9 +28,8 @@ export default class Login extends BaseCommand {
28
28
  this.log(`\nTo authenticate, visit:\n`);
29
29
  this.log(` ${deviceAuth.verification_uri_complete}\n`);
30
30
  this.log(`Or go to ${deviceAuth.verification_uri} and enter code: ${deviceAuth.user_code}\n`);
31
- // Listen for Enter to open the URL in the user's browser.
32
- // Runs concurrently with token polling so the user can also authenticate
33
- // by pasting the URL manually without having to press Enter first.
31
+ // Listen for Enter to open the URL in the user's browser. Runs alongside
32
+ // token polling so the user can also paste the URL manually.
34
33
  const stdinIsTTY = Boolean(process.stdin.isTTY);
35
34
  let browserOpened = false;
36
35
  const onEnter = (chunk) => {
@@ -59,95 +58,71 @@ export default class Login extends BaseCommand {
59
58
  process.stdin.pause();
60
59
  }
61
60
  }
62
- const user = tokenResponse.user;
63
- const userEmail = user.email;
64
- // Decode JWT to inspect claims
65
61
  if (this.isVerbose) {
66
62
  const payload = decodeJWT(tokenResponse.access_token);
67
63
  this.verboseLog('\n=== Decoded JWT Payload ===');
68
64
  this.verboseLog(JSON.stringify(payload, null, 2));
69
65
  this.verboseLog('===========================\n');
70
66
  }
71
- // Extract org_id from JWT (WorkOS scopes the token to the selected org)
72
- const jwtPayload = decodeJWT(tokenResponse.access_token);
73
- const orgId = jwtPayload.org_id || user.organization_id;
74
- // Use the JWT's actual exp claim for expiry
75
- let expiresAt = Date.now() + 300 * 1000; // fallback: 5 minutes
76
- if (typeof jwtPayload.exp === 'number') {
77
- expiresAt = jwtPayload.exp * 1000;
67
+ // Step 4: Multi-org orchestration — fetch /me/organizations, mint one
68
+ // org-scoped TokenSet per org in parallel, build the per-org TokenStore.
69
+ const apiUrl = getApiUrl();
70
+ let result;
71
+ try {
72
+ result = await orchestrateMultiOrgLogin({
73
+ apiUrl,
74
+ initialAccessToken: tokenResponse.access_token,
75
+ initialRefreshToken: tokenResponse.refresh_token,
76
+ user: tokenResponse.user,
77
+ });
78
+ }
79
+ catch (error) {
80
+ return this.err(`Login failed: ${error.message}`);
81
+ }
82
+ if (result.skippedOrgSlugs.length > 0) {
83
+ this.log(`\nNote: you belong to ${result.organizations.length} orgs; minting tokens for the first 10 alphabetically. ` +
84
+ `Skipped: ${result.skippedOrgSlugs.join(', ')}.`);
78
85
  }
79
- // Save tokens — verifies the file actually persisted before returning.
86
+ // Step 5: Persist token store.
80
87
  this.verboseLog('Saving tokens to', getTokenFilePath());
81
88
  let tokensPath;
82
89
  try {
83
- tokensPath = await saveTokens({
84
- accessToken: tokenResponse.access_token,
85
- expiresAt,
86
- refreshToken: tokenResponse.refresh_token,
87
- userEmail,
88
- userId: user.id,
89
- });
90
+ tokensPath = await saveTokens(result.tokenStore);
90
91
  }
91
92
  catch (error) {
92
93
  return this.err(`Login failed: could not save tokens. ${error.message}`);
93
94
  }
94
95
  this.verboseLog('Tokens saved at', tokensPath);
95
- // Resolve org_id → workspace UUID via the API
96
- let workspaceId;
97
- let workspaceName;
98
- let workspaceSlug;
99
- let organizationName;
100
- let organizationId;
101
- let multipleWorkspacesAvailable = false;
96
+ // Step 6: Pick a default workspace from the default org for the saved
97
+ // profile. The default org is the only one for single-org users, or the
98
+ // first slug alphabetically for multi-org users (no interactive prompt).
99
+ let defaultWorkspace;
102
100
  try {
103
- const apiUrl = getApiUrl();
104
- this.verboseLog('Resolving workspace...', { apiUrl, orgId });
101
+ const defaultToken = result.tokenStore.tokensByOrg[result.defaultOrgId];
105
102
  const res = await fetch(`${apiUrl}/api/v1/userWorkspaces/list`, {
106
- method: 'POST',
103
+ body: JSON.stringify({ json: {} }),
107
104
  headers: {
108
- Authorization: `Bearer ${tokenResponse.access_token}`,
105
+ Authorization: `Bearer ${defaultToken.access_token}`,
109
106
  'Content-Type': 'application/json',
110
107
  },
111
- body: JSON.stringify({ json: {} }),
108
+ method: 'POST',
112
109
  });
113
110
  if (res.ok) {
114
111
  const body = (await res.json());
115
- const allWorkspaces = (body.json ?? body);
116
- const candidates = Array.isArray(allWorkspaces) ? allWorkspaces : [];
117
- multipleWorkspacesAvailable = candidates.length > 1;
118
- let match;
119
- if (candidates.length === 1) {
120
- match = candidates[0];
121
- }
122
- else if (candidates.length > 1) {
123
- const chosen = await select({
124
- choices: candidates.map((w) => ({
125
- name: `${w.organizationName} / ${w.workspaceSlug}`,
126
- value: w.workspaceId,
127
- })),
128
- message: 'Select workspace:',
129
- });
130
- match = candidates.find((w) => w.workspaceId === chosen);
131
- }
132
- if (match) {
133
- workspaceId = match.workspaceId;
134
- workspaceName = match.workspaceSlug;
135
- workspaceSlug = match.workspaceSlug;
136
- organizationName = match.organizationName;
137
- organizationId = match.workosOrgId;
138
- this.verboseLog('Resolved workspace', { workspaceId, workspaceName, workspaceSlug, organizationName });
139
- }
112
+ const list = (body.json ?? body);
113
+ const candidates = (Array.isArray(list) ? list : []).filter((w) => w.workosOrgId === result.defaultOrgId);
114
+ candidates.sort((a, b) => a.workspaceSlug.localeCompare(b.workspaceSlug));
115
+ defaultWorkspace = candidates[0];
140
116
  }
141
117
  else {
142
- this.verboseLog('Failed to resolve workspace', { status: res.status });
118
+ this.verboseLog('userWorkspaces/list non-OK', { status: res.status });
143
119
  }
144
120
  }
145
121
  catch (error) {
146
- this.verboseLog('Failed to resolve workspace', { error: String(error) });
122
+ this.verboseLog('userWorkspaces/list failed', { error: String(error) });
147
123
  }
148
- // Get or create config with profile
124
+ // Step 7: Save auth config with the picked workspace.
149
125
  const existingConfig = await loadAuthConfig();
150
- // Save auth config
151
126
  const isFirstProfile = !existingConfig || Object.keys(existingConfig.profiles).length === 0;
152
127
  const shouldSetDefault = isFirstProfile || profileName === 'default' || !existingConfig?.defaultProfile;
153
128
  this.verboseLog('Saving auth config to', getAuthConfigFilePath());
@@ -158,10 +133,12 @@ export default class Login extends BaseCommand {
158
133
  profiles: {
159
134
  ...existingConfig?.profiles,
160
135
  [profileName]: {
161
- workspace: workspaceId || orgId || 'unknown',
162
- workspaceName: workspaceName || (orgId ? `org:${orgId}` : undefined),
163
- workspaceSlug,
164
- organizationName,
136
+ organizationName: result.defaultOrg.name,
137
+ organizationSlug: result.defaultOrg.slug,
138
+ workosOrgId: result.defaultOrgId,
139
+ workspace: defaultWorkspace?.workspaceId ?? result.defaultOrgId,
140
+ workspaceName: defaultWorkspace?.workspaceSlug,
141
+ workspaceSlug: defaultWorkspace?.workspaceSlug,
165
142
  },
166
143
  },
167
144
  });
@@ -170,34 +147,38 @@ export default class Login extends BaseCommand {
170
147
  return this.err(`Login failed: could not save auth config. ${error.message}`);
171
148
  }
172
149
  this.verboseLog('Auth config saved at', configPath);
150
+ // Step 8: User-facing summary.
173
151
  this.log('\nSuccessfully logged in!');
174
- if (userEmail) {
175
- this.log(`Logged in as: ${userEmail}`);
176
- }
177
- if (workspaceName) {
178
- const orgPrefix = organizationName ? `${organizationName} / ` : '';
179
- this.log(`Workspace: ${orgPrefix}${workspaceName}`);
180
- if (workspaceId) {
181
- this.log(` (${workspaceId})`);
152
+ if (result.email)
153
+ this.log(`Logged in as: ${result.email}`);
154
+ this.log(`Tokens stored for orgs: ${result.mintedOrgSlugs.join(', ')}`);
155
+ if (defaultWorkspace) {
156
+ const pin = `${result.defaultOrg.slug}/${defaultWorkspace.workspaceSlug}`;
157
+ this.log(`Default workspace: ${pin} (${defaultWorkspace.workspaceId})`);
158
+ this.log(`\nTo pin a project to this workspace, add to your .env:`);
159
+ this.log(` QUONFIG_WORKSPACE=${pin}`);
160
+ if (result.organizations.length > 1) {
161
+ this.log(`\nYou belong to multiple orgs. To switch: qfg workspace switch`);
182
162
  }
183
163
  }
184
- else if (orgId) {
185
- this.log(`Organization: ${orgId}`);
186
- }
187
- if (multipleWorkspacesAvailable && workspaceSlug) {
188
- this.log(`\nYou have multiple workspaces. To pin this project to ${workspaceSlug},`);
189
- this.log(`add this to your project's .env file:`);
190
- this.log(` QUONFIG_WORKSPACE=${workspaceSlug}`);
191
- this.log(`\nTo switch your default workspace: qfg workspace switch`);
164
+ else {
165
+ this.log(`\nNo workspaces found in ${result.defaultOrg.slug}. Create one with: qfg workspace create <slug> --org ${result.defaultOrg.slug}`);
192
166
  }
193
167
  return {
194
- email: userEmail,
195
- organizationId: organizationId || orgId,
196
- organizationName,
168
+ defaultOrg: {
169
+ organizationName: result.defaultOrg.name,
170
+ organizationSlug: result.defaultOrg.slug,
171
+ workosOrgId: result.defaultOrgId,
172
+ },
173
+ email: result.email,
174
+ mintedOrgSlugs: result.mintedOrgSlugs,
175
+ organizationId: result.defaultOrgId,
176
+ organizationName: result.defaultOrg.name,
177
+ skippedOrgSlugs: result.skippedOrgSlugs,
197
178
  success: true,
198
- userId: user.id,
199
- workspaceId,
200
- workspaceSlug,
179
+ userId: result.userId,
180
+ workspaceId: defaultWorkspace?.workspaceId,
181
+ workspaceSlug: defaultWorkspace?.workspaceSlug,
201
182
  };
202
183
  }
203
184
  }
@@ -4,10 +4,20 @@ import { formatHumanReport, runDoctor } from '../../migrate/doctor.js';
4
4
  import { loadTokens } from '../../util/token-storage.js';
5
5
  const SUPPORTED_SOURCES = new Set(['launch']);
6
6
  const defaultLoadSession = async () => {
7
- const tokens = await loadTokens();
8
- if (!tokens?.accessToken)
7
+ // TODO(qfg-kr7.5): pick the token set keyed by the resolved workosOrgId.
8
+ let store;
9
+ try {
10
+ store = await loadTokens();
11
+ }
12
+ catch {
13
+ // Legacy on-disk shape (pre-qfg-kr7.3) — treat as "not logged in" so the
14
+ // doctor can tell the user to re-run `qfg login` rather than crashing.
15
+ return null;
16
+ }
17
+ const tokens = store ? Object.values(store.tokensByOrg)[0] : undefined;
18
+ if (!tokens?.access_token)
9
19
  return null;
10
- return { expiresAt: tokens.expiresAt ?? 0 };
20
+ return { expiresAt: tokens.expires_at ?? 0 };
11
21
  };
12
22
  export default class MigrateDoctor extends BaseCommand {
13
23
  static description = 'Preflight health checks for `qfg migrate`. Verifies auth, workspace provisioning, working tree, and identifier collisions.';
@@ -97,8 +97,10 @@ Examples
97
97
  };
98
98
  async run() {
99
99
  const { args, flags } = await this.parse(Override);
100
- const tokens = await loadTokens();
101
- const userEmail = tokens?.userEmail;
100
+ // TODO(qfg-kr7.5): pick the token set keyed by the resolved workosOrgId.
101
+ const store = await loadTokens();
102
+ const tokens = store ? Object.values(store.tokensByOrg)[0] : undefined;
103
+ const userEmail = tokens?.user_email;
102
104
  if (!userEmail) {
103
105
  return this.err('Not logged in. Run `qfg login` first.');
104
106
  }
@@ -21,6 +21,17 @@ export default class Pull extends BaseCommand {
21
21
  * This never throws — a broken `quonfig.json` shouldn't fail `qfg pull`.
22
22
  */
23
23
  private backfillWorkspacePin;
24
+ /**
25
+ * Commit the freshly-written pin if `dir` is a git repo. Only commits
26
+ * when `quonfig.json` is the only thing dirty in the working tree —
27
+ * we don't want to sweep up the user's unrelated work-in-progress
28
+ * (untracked files are fine; tracked modifications are not).
29
+ *
30
+ * Skips silently when not in a git repo, when other tracked files are
31
+ * dirty, or when git itself errors. Pull already succeeded; this is
32
+ * cleanup that the next push can also do (qfg-0fn).
33
+ */
34
+ private commitPinIfRepo;
24
35
  private looksLike401;
25
36
  private maybeWriteQuonfigDir;
26
37
  private refreshAndGetUrl;
@@ -4,9 +4,9 @@ import { Flags } from '@oclif/core';
4
4
  import { BaseCommand } from '../index.js';
5
5
  import { loadGiteaToken, isGiteaTokenExpired, saveGiteaToken } from '../util/gitea-token-storage.js';
6
6
  import { mintAndStoreGiteaReadToken, mintGiteaToken } from '../util/gitea-api.js';
7
- import { readWorkspaceSlug, writeWorkspaceSlug } from '../util/quonfig-json.js';
7
+ import { readWorkspaceSlug, tryParseWorkspacePin, writeWorkspaceSlug } from '../util/quonfig-json.js';
8
8
  import { resolveWorkspaceUuid } from '../util/resolve-workspace.js';
9
- import { isGitRepo, getRemoteUrl, gitFetch, gitSetRemote, canFastForward, hasDivergedFromRemote, gitMergeFfOnly, gitClone, displayUrl, } from '../util/git-ops.js';
9
+ import { isGitRepo, getRemoteUrl, gitFetch, gitSetRemote, canFastForward, hasDivergedFromRemote, gitMergeFfOnly, gitClone, displayUrl, addAndCommitFile, dirtyTrackedFiles, } from '../util/git-ops.js';
10
10
  export default class Pull extends BaseCommand {
11
11
  static description = `Clone or update a local copy of your workspace config files.
12
12
 
@@ -87,20 +87,22 @@ CLI shortcuts (no JSON editing needed for simple cases):
87
87
  if (diverged) {
88
88
  return this.err('Local commits diverge from remote — resolve manually.');
89
89
  }
90
- if (!ff) {
91
- this.log('Already up to date.');
92
- return { dir: resolvedDir, newCommits: [], workspaceId };
93
- }
94
- // Merge
95
- const newCommits = await gitMergeFfOnly(resolvedDir);
96
- if (newCommits.length > 0) {
97
- this.log(`Pulled ${newCommits.length} new commit(s):`);
98
- for (const msg of newCommits) {
99
- this.log(` - ${msg}`);
90
+ if (ff) {
91
+ const newCommits = await gitMergeFfOnly(resolvedDir);
92
+ if (newCommits.length > 0) {
93
+ this.log(`Pulled ${newCommits.length} new commit(s):`);
94
+ for (const msg of newCommits) {
95
+ this.log(` - ${msg}`);
96
+ }
97
+ }
98
+ else {
99
+ this.log('Updated successfully.');
100
100
  }
101
101
  }
102
102
  else {
103
- this.log('Updated successfully.');
103
+ this.log('Already up to date.');
104
+ // Don't early-return: we still want to run the post-pull work
105
+ // below (QUONFIG_DIR write, workspace pin backfill+commit).
104
106
  }
105
107
  }
106
108
  else {
@@ -125,9 +127,10 @@ CLI shortcuts (no JSON editing needed for simple cases):
125
127
  // Write QUONFIG_DIR to ~/.quonfig/config if not set
126
128
  await this.maybeWriteQuonfigDir(resolvedDir);
127
129
  // Backfill the `workspace` pin in `quonfig.json` if missing
128
- // (Guard 1 in project/plans/cli-git-sync.md). Local-only write — we
129
- // intentionally do NOT commit or push here; the user's next `qfg push`
130
- // will sweep it up naturally.
130
+ // (Guard 1 in project/plans/cli-git-sync.md). When the workspace dir
131
+ // is a git repo, we also commit the new pin so it's included in the
132
+ // user's next `qfg push` delta — push diffs HEAD vs origin, so a
133
+ // working-tree-only write would never reach the server (qfg-0fn).
131
134
  await this.backfillWorkspacePin(resolvedDir, tokenEntry, workspaceId);
132
135
  return { dir: resolvedDir, workspaceId };
133
136
  }
@@ -161,16 +164,31 @@ CLI shortcuts (no JSON editing needed for simple cases):
161
164
  this.verboseLog('Pull', 'Backend did not return a workspaceSlug; skipping pin backfill.');
162
165
  return;
163
166
  }
167
+ // The pin is stored as `<org-slug>/<workspace-slug>`. The backend's
168
+ // `workspaceSlug` is just the workspace component today; until the
169
+ // server learns to return the org slug, we can only backfill / compare
170
+ // when the backend response itself already carries the slash form
171
+ // (e.g. when later beads in this epic update the API). Otherwise,
172
+ // skip with a verbose log — pull itself already succeeded.
173
+ const backendPin = tryParseWorkspacePin(backendSlug);
174
+ if (!backendPin) {
175
+ this.verboseLog('Pull', `Backend workspaceSlug "${backendSlug}" is not in <org>/<ws> form; skipping pin backfill.`);
176
+ return;
177
+ }
164
178
  try {
165
179
  const existingPin = await readWorkspaceSlug(dir);
166
180
  if (!existingPin) {
167
- await writeWorkspaceSlug(dir, backendSlug);
168
- this.verboseLog('Pull', `Backfilled workspace pin "${backendSlug}" into quonfig.json.`);
181
+ await writeWorkspaceSlug(dir, backendPin);
182
+ const formatted = `${backendPin.orgSlug}/${backendPin.workspaceSlug}`;
183
+ this.verboseLog('Pull', `Backfilled workspace pin "${formatted}" into quonfig.json.`);
184
+ await this.commitPinIfRepo(dir, formatted);
169
185
  return;
170
186
  }
171
- if (existingPin !== backendSlug) {
187
+ if (existingPin.orgSlug !== backendPin.orgSlug || existingPin.workspaceSlug !== backendPin.workspaceSlug) {
188
+ const existingFormatted = `${existingPin.orgSlug}/${existingPin.workspaceSlug}`;
189
+ const backendFormatted = `${backendPin.orgSlug}/${backendPin.workspaceSlug}`;
172
190
  this.log('');
173
- this.log(`Warning: quonfig.json pins workspace "${existingPin}", but the backend says this workspace is "${backendSlug}".`);
191
+ this.log(`Warning: quonfig.json pins workspace "${existingFormatted}", but the backend says this workspace is "${backendFormatted}".`);
174
192
  this.log('Leaving the existing pin in place. Run `qfg push` to see the full identity check.');
175
193
  }
176
194
  }
@@ -179,6 +197,35 @@ CLI shortcuts (no JSON editing needed for simple cases):
179
197
  this.verboseLog('Pull', `Could not backfill workspace pin: ${String(error)}`);
180
198
  }
181
199
  }
200
+ /**
201
+ * Commit the freshly-written pin if `dir` is a git repo. Only commits
202
+ * when `quonfig.json` is the only thing dirty in the working tree —
203
+ * we don't want to sweep up the user's unrelated work-in-progress
204
+ * (untracked files are fine; tracked modifications are not).
205
+ *
206
+ * Skips silently when not in a git repo, when other tracked files are
207
+ * dirty, or when git itself errors. Pull already succeeded; this is
208
+ * cleanup that the next push can also do (qfg-0fn).
209
+ */
210
+ async commitPinIfRepo(dir, slug) {
211
+ try {
212
+ if (!(await isGitRepo(dir)))
213
+ return;
214
+ const dirty = await dirtyTrackedFiles(dir);
215
+ const otherDirty = dirty.filter((p) => p !== 'quonfig.json');
216
+ if (otherDirty.length > 0) {
217
+ this.verboseLog('Pull', `Other tracked files dirty (${otherDirty.join(', ')}); skipping pin commit.`);
218
+ return;
219
+ }
220
+ const committed = await addAndCommitFile(dir, 'quonfig.json', `qfg: pin workspace = ${slug}`);
221
+ if (committed) {
222
+ this.log(`Committed workspace pin "${slug}" to quonfig.json.`);
223
+ }
224
+ }
225
+ catch (error) {
226
+ this.verboseLog('Pull', `Could not commit workspace pin: ${String(error)}`);
227
+ }
228
+ }
182
229
  looksLike401(err) {
183
230
  const msg = err instanceof Error ? err.message : String(err);
184
231
  return msg.includes('401') || msg.toLowerCase().includes('authentication failed');
@@ -1,16 +1,13 @@
1
- import { execFile as execFileCb } from 'node:child_process';
2
1
  import * as fs from 'node:fs';
3
2
  import * as path from 'node:path';
4
- import * as util from 'node:util';
5
3
  import { Flags } from '@oclif/core';
6
4
  import { BaseCommand } from '../index.js';
7
5
  import { mintGiteaToken } from '../util/gitea-api.js';
8
- import { getRemoteUrl, gitFetch, gitSetRemote, isGitRepo } from '../util/git-ops.js';
6
+ import { getRemoteUrl, gitFetch, gitSetRemote, isGitRepo, runGit } from '../util/git-ops.js';
9
7
  import { PushFatalError, runPush, } from '../push/run-push.js';
10
8
  import { computeBarePathDiff } from '../push/bare-path-diff.js';
11
9
  import { callConfigsPush } from '../push/config-push-client.js';
12
10
  import { resolveWorkspaceUuid } from '../util/resolve-workspace.js';
13
- const execFile = util.promisify(execFileCb);
14
11
  export default class Push extends BaseCommand {
15
12
  static description = `Push local config changes up to your workspace on Quonfig cloud.
16
13
 
@@ -252,7 +249,7 @@ export function buildRealDeps(cmd) {
252
249
  * baseline — i.e. origin/main..HEAD.
253
250
  */
254
251
  async function diffHeadVsOrigin(dir) {
255
- const { stdout } = await execFile('git', ['-C', dir, 'diff', '--name-status', 'origin/main..HEAD']);
252
+ const { stdout } = await runGit(['-C', dir, 'diff', '--name-status', 'origin/main..HEAD']);
256
253
  const deltas = [];
257
254
  for (const raw of stdout.split('\n')) {
258
255
  const line = raw.trim();
@@ -289,7 +286,7 @@ async function diffHeadVsOrigin(dir) {
289
286
  }
290
287
  async function showAtRef(dir, ref, relPath) {
291
288
  try {
292
- const { stdout } = await execFile('git', ['-C', dir, 'show', `${ref}:${relPath}`]);
289
+ const { stdout } = await runGit(['-C', dir, 'show', `${ref}:${relPath}`]);
293
290
  return stdout;
294
291
  }
295
292
  catch {
@@ -306,7 +303,7 @@ async function readWorkingTreeFile(dir, relPath) {
306
303
  }
307
304
  async function countTrackedFilesAtRef(dir, ref) {
308
305
  try {
309
- const { stdout } = await execFile('git', ['-C', dir, 'ls-tree', '-r', '--name-only', ref]);
306
+ const { stdout } = await runGit(['-C', dir, 'ls-tree', '-r', '--name-only', ref]);
310
307
  return stdout.split('\n').filter((l) => l.trim().length > 0).length;
311
308
  }
312
309
  catch {
@@ -4,4 +4,17 @@ export default class Whoami extends BaseCommand {
4
4
  static description: string;
5
5
  static examples: string[];
6
6
  run(): Promise<JsonObj | void>;
7
+ /**
8
+ * Pick the "active" org for display. Order:
9
+ * 1. QUONFIG_WORKSPACE in `org/ws` form → look up org by slug in the store.
10
+ * 2. The token store's defaultOrgId, if it points at an entry we have.
11
+ * 3. The first token entry — gives a stable marker rather than nothing.
12
+ */
13
+ private resolveActiveOrgId;
14
+ /**
15
+ * Best-effort fetch of `me.organizations` for role enrichment. Failure
16
+ * (network, unauthenticated) is non-fatal — whoami still works from the
17
+ * local store, just without role labels.
18
+ */
19
+ private fetchOrganizations;
7
20
  }