@quonfig/cli 0.0.25 → 0.0.27

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 +157 -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,54 +1,121 @@
1
1
  import { BaseCommand } from '../index.js';
2
2
  import { getApiUrl } from '../util/domain-urls.js';
3
3
  import { getValidAccessToken } from '../util/get-valid-token.js';
4
- import { getActiveProfile, loadAuthConfig } from '../util/token-storage.js';
4
+ import { tryParseWorkspacePin } from '../util/quonfig-json.js';
5
+ import { findOrgIdBySlug, getActiveProfile, loadAuthConfig, loadTokens } from '../util/token-storage.js';
5
6
  const UUID_PATTERN = /^[\da-f]{8}(?:-[\da-f]{4}){3}-[\da-f]{12}$/i;
6
7
  export default class Workspace extends BaseCommand {
7
- static description = 'Show the current workspace';
8
+ static description = 'Show the active workspace, all org tokens, and every (org, workspace) the user can reach';
8
9
  static examples = ['<%= config.bin %> <%= command.id %>'];
9
10
  async run() {
10
- // API-key mode: resolve the workspace from QUONFIG_WORKSPACE via the server
11
- // so we show the workspace the key is actually scoped to, not whatever
12
- // the local OAuth config happens to point at.
13
11
  if (process.env.QUONFIG_API_KEY) {
14
12
  return this.runApiKeyMode();
15
13
  }
16
- const authConfig = await loadAuthConfig();
17
- if (!authConfig || Object.keys(authConfig.profiles).length === 0) {
14
+ const store = await loadTokens();
15
+ if (!store || Object.keys(store.tokensByOrg).length === 0) {
18
16
  return this.err('Not logged in. Run `qfg login` first.');
19
17
  }
20
- const activeProfileName = getActiveProfile();
21
- const profile = authConfig.profiles[activeProfileName] || authConfig.profiles[authConfig.defaultProfile || 'default'];
22
- if (!profile) {
23
- return this.err('No workspace configured. Run `qfg login` first.');
24
- }
25
- const workspaceSlug = profile.workspaceSlug || profile.workspaceName || profile.workspace;
26
- const orgLine = profile.organizationName ? `${profile.organizationName} / ` : '';
27
- this.log(`Workspace: ${orgLine}${workspaceSlug}`);
28
- this.log(`ID: ${profile.workspace}`);
29
- this.log(`\nTo switch: qfg workspace switch`);
30
- this.log(`To pin in a project, add to .env:`);
31
- this.log(` QUONFIG_WORKSPACE=${workspaceSlug}`);
32
- return {
33
- organizationName: profile.organizationName,
34
- workspace: profile.workspace,
35
- workspaceSlug,
36
- };
37
- }
38
- async runApiKeyMode() {
39
- const override = process.env.QUONFIG_WORKSPACE;
40
- if (!override) {
41
- return this.err('QUONFIG_API_KEY is set but QUONFIG_WORKSPACE is not. Set QUONFIG_WORKSPACE=<workspace-slug> (UUIDs also work).');
42
- }
43
- // getValidAccessToken short-circuits when QUONFIG_API_KEY is set.
18
+ const authConfig = await loadAuthConfig();
19
+ const orgEntries = Object.entries(store.tokensByOrg);
20
+ const orgSlugsWithTokens = orgEntries
21
+ .map(([orgId, t]) => t.org_slug ?? orgId)
22
+ .sort((a, b) => a.localeCompare(b));
23
+ const firstOrgId = orgEntries[0][0];
44
24
  let jwt;
45
25
  try {
46
- jwt = await getValidAccessToken();
26
+ jwt = await getValidAccessToken(firstOrgId, this.verboseLog);
47
27
  }
48
28
  catch (error) {
49
29
  return this.err(error instanceof Error ? error.message : String(error));
50
30
  }
51
- let entries = [];
31
+ const allWorkspaces = await this.fetchWorkspaces(jwt);
32
+ const defaultProfilePin = this.profilePin(authConfig, allWorkspaces);
33
+ const activePin = this.activePin(store, authConfig, allWorkspaces, defaultProfilePin);
34
+ this.log(`Active workspace: ${activePin ?? '(none)'}`);
35
+ this.log(`Default profile: ${defaultProfilePin ?? '(none)'}`);
36
+ this.log(`Orgs with tokens: ${orgSlugsWithTokens.length > 0 ? orgSlugsWithTokens.join(', ') : '(none)'}`);
37
+ this.log('');
38
+ this.log('All token-minted orgs and workspaces:');
39
+ const grouped = groupByOrg(orgEntries, allWorkspaces);
40
+ if (grouped.length === 0) {
41
+ this.log(' (no workspaces visible — run `qfg login` to refresh)');
42
+ }
43
+ else {
44
+ for (const group of grouped) {
45
+ this.log(` ${group.orgSlug}:`);
46
+ if (group.workspaces.length === 0) {
47
+ this.log(' (no workspaces yet — `qfg workspace create` to add one)');
48
+ continue;
49
+ }
50
+ for (const w of group.workspaces) {
51
+ const pin = `${group.orgSlug}/${w.workspaceSlug}`;
52
+ const marker = pin === defaultProfilePin ? ' (default)' : '';
53
+ this.log(` - ${pin}${marker}`);
54
+ }
55
+ }
56
+ }
57
+ const totalWorkspaces = grouped.reduce((sum, g) => sum + g.workspaces.length, 0);
58
+ if (totalWorkspaces > 1) {
59
+ this.log('');
60
+ this.log('To switch the default workspace: qfg workspace switch');
61
+ }
62
+ return {
63
+ activeWorkspace: activePin,
64
+ defaultProfile: defaultProfilePin,
65
+ orgsWithTokens: orgSlugsWithTokens,
66
+ orgs: grouped.map((g) => ({
67
+ orgSlug: g.orgSlug,
68
+ workosOrgId: g.workosOrgId,
69
+ workspaces: g.workspaces.map((w) => ({
70
+ workspaceId: w.workspaceId,
71
+ workspaceSlug: w.workspaceSlug,
72
+ pin: `${g.orgSlug}/${w.workspaceSlug}`,
73
+ })),
74
+ })),
75
+ };
76
+ }
77
+ /**
78
+ * Pin the default-profile workspaceId to org/ws form by looking it up in
79
+ * the workspaces list. Returns undefined if the auth config is empty or
80
+ * the saved workspace doesn't appear in the listing.
81
+ */
82
+ profilePin(authConfig, allWorkspaces) {
83
+ if (!authConfig)
84
+ return undefined;
85
+ const activeProfileName = getActiveProfile();
86
+ const profile = authConfig.profiles[activeProfileName] || authConfig.profiles[authConfig.defaultProfile || 'default'];
87
+ if (!profile)
88
+ return undefined;
89
+ const match = allWorkspaces.find((w) => w.workspaceId === profile.workspace);
90
+ if (!match)
91
+ return undefined;
92
+ const orgSlug = match.organizationSlug ?? slugFromName(match.organizationName);
93
+ if (!orgSlug)
94
+ return undefined;
95
+ return `${orgSlug}/${match.workspaceSlug}`;
96
+ }
97
+ /**
98
+ * QUONFIG_WORKSPACE wins over the default profile (matches the resolution
99
+ * order in get-client.ts/resolve-workspace.ts). Bare slugs are ignored
100
+ * here — the status command does NOT throw on a stale env-var, just
101
+ * shows the default profile instead.
102
+ */
103
+ activePin(store, _authConfig, allWorkspaces, fallback) {
104
+ const env = process.env.QUONFIG_WORKSPACE;
105
+ if (env) {
106
+ const pin = tryParseWorkspacePin(env);
107
+ if (pin) {
108
+ const orgId = findOrgIdBySlug(store, pin.orgSlug);
109
+ if (orgId) {
110
+ const match = allWorkspaces.find((w) => w.workosOrgId === orgId && w.workspaceSlug === pin.workspaceSlug);
111
+ if (match)
112
+ return `${pin.orgSlug}/${pin.workspaceSlug}`;
113
+ }
114
+ }
115
+ }
116
+ return fallback;
117
+ }
118
+ async fetchWorkspaces(jwt) {
52
119
  try {
53
120
  const res = await fetch(`${getApiUrl()}/api/v1/userWorkspaces/list`, {
54
121
  method: 'POST',
@@ -56,13 +123,33 @@ export default class Workspace extends BaseCommand {
56
123
  body: JSON.stringify({ json: {} }),
57
124
  });
58
125
  if (!res.ok) {
59
- return this.err(`Failed to resolve workspace (HTTP ${res.status}). Check that QUONFIG_API_KEY is valid.`);
126
+ this.verboseLog('workspace: userWorkspaces/list non-OK', { status: res.status });
127
+ return [];
60
128
  }
61
129
  const body = (await res.json());
62
- entries = body.json ?? body ?? [];
130
+ const list = (body.json ?? body);
131
+ return Array.isArray(list) ? list : [];
63
132
  }
64
133
  catch (error) {
65
- return this.err(`Failed to resolve workspace "${override}": ${error instanceof Error ? error.message : String(error)}`);
134
+ this.verboseLog('workspace: userWorkspaces/list failed', { error: String(error) });
135
+ return [];
136
+ }
137
+ }
138
+ async runApiKeyMode() {
139
+ const override = process.env.QUONFIG_WORKSPACE;
140
+ if (!override) {
141
+ return this.err('QUONFIG_API_KEY is set but QUONFIG_WORKSPACE is not. Set QUONFIG_WORKSPACE=<workspace-slug> (UUIDs also work).');
142
+ }
143
+ let jwt;
144
+ try {
145
+ jwt = await getValidAccessToken('');
146
+ }
147
+ catch (error) {
148
+ return this.err(error instanceof Error ? error.message : String(error));
149
+ }
150
+ const entries = await this.fetchWorkspaces(jwt);
151
+ if (entries.length === 0) {
152
+ return this.err(`Failed to resolve workspace "${override}" — no entries returned.`);
66
153
  }
67
154
  const match = UUID_PATTERN.test(override)
68
155
  ? entries.find((w) => w.workspaceId === override)
@@ -71,14 +158,44 @@ export default class Workspace extends BaseCommand {
71
158
  const available = entries.map((w) => w.workspaceSlug).join(', ') || '(none)';
72
159
  return this.err(`Workspace "${override}" not accessible with this API key. Available: ${available}.`);
73
160
  }
74
- const orgLine = match.organizationName ? `${match.organizationName} / ` : '';
75
- this.log(`Workspace: ${orgLine}${match.workspaceSlug} (API key mode)`);
76
- this.log(`ID: ${match.workspaceId}`);
77
- this.log(`Source: QUONFIG_API_KEY + QUONFIG_WORKSPACE=${override}`);
161
+ const orgSlug = match.organizationSlug ?? slugFromName(match.organizationName) ?? '(unknown-org)';
162
+ const pin = `${orgSlug}/${match.workspaceSlug}`;
163
+ this.log(`Active workspace: ${pin} (API key mode)`);
164
+ this.log(`ID: ${match.workspaceId}`);
165
+ this.log(`Source: QUONFIG_API_KEY + QUONFIG_WORKSPACE=${override}`);
78
166
  return {
167
+ activeWorkspace: pin,
79
168
  organizationName: match.organizationName,
80
169
  workspace: match.workspaceId,
81
170
  workspaceSlug: match.workspaceSlug,
82
171
  };
83
172
  }
84
173
  }
174
+ /**
175
+ * Group server-side workspaces by the orgs we hold tokens for, preserving
176
+ * orgs with no workspaces (so the user sees that they're a member but
177
+ * haven't created anything there yet). Sorted alphabetically by org slug.
178
+ */
179
+ function groupByOrg(tokenEntries, allWorkspaces) {
180
+ const groups = [];
181
+ for (const [orgId, tokens] of tokenEntries) {
182
+ const slugFromToken = tokens.org_slug;
183
+ const matchingWorkspaces = allWorkspaces.filter((w) => w.workosOrgId === orgId);
184
+ const slugFromWs = matchingWorkspaces[0]?.organizationSlug;
185
+ const orgSlug = slugFromToken ?? slugFromWs ?? slugFromName(tokens.org_name) ?? orgId;
186
+ groups.push({
187
+ orgSlug,
188
+ workosOrgId: orgId,
189
+ workspaces: matchingWorkspaces.sort((a, b) => a.workspaceSlug.localeCompare(b.workspaceSlug)),
190
+ });
191
+ }
192
+ return groups.sort((a, b) => a.orgSlug.localeCompare(b.orgSlug));
193
+ }
194
+ function slugFromName(name) {
195
+ if (!name)
196
+ return undefined;
197
+ return name
198
+ .toLowerCase()
199
+ .replaceAll(/[^\da-z]+/g, '-')
200
+ .replaceAll(/^-|-$/g, '');
201
+ }
@@ -1,15 +1,13 @@
1
- import { execFile as execFileCb, spawn } 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 { removeQfFromGitignore, writeImportState } from './import-state.js';
6
4
  import { writeMigrationReport } from './migration-report.js';
7
5
  import { detectDuplicateKeys } from './sources/launch/translate.js';
8
6
  import { MIGRATOR_IDENTITY } from '../util/clone-and-stack-push.js';
9
- const execFile = util.promisify(execFileCb);
7
+ import { runGit, spawnGit } from '../util/git-ops.js';
10
8
  const isGitRepo = async (dir) => {
11
9
  try {
12
- await execFile('git', ['-C', dir, 'rev-parse', '--git-dir']);
10
+ await runGit(['-C', dir, 'rev-parse', '--git-dir']);
13
11
  return true;
14
12
  }
15
13
  catch {
@@ -17,24 +15,9 @@ const isGitRepo = async (dir) => {
17
15
  }
18
16
  };
19
17
  const hasStagedChanges = async (dir) => {
20
- const { stdout } = await execFile('git', ['-C', dir, 'status', '--porcelain']);
18
+ const { stdout } = await runGit(['-C', dir, 'status', '--porcelain']);
21
19
  return stdout.trim().length > 0;
22
20
  };
23
- const gitCommitFromStdin = (cwd, message, env) => new Promise((resolve, reject) => {
24
- const child = spawn('git', ['-C', cwd, 'commit', '-F', '-'], { env });
25
- let stderr = '';
26
- child.stderr.on('data', (d) => {
27
- stderr += d.toString();
28
- });
29
- child.on('error', reject);
30
- child.on('close', (code) => {
31
- if (code === 0)
32
- resolve();
33
- else
34
- reject(new Error(`git commit exited ${code}: ${stderr}`));
35
- });
36
- child.stdin.end(message);
37
- });
38
21
  const ensureQuonfigJson = (dir, environments) => {
39
22
  const filePath = path.join(dir, 'quonfig.json');
40
23
  if (fs.existsSync(filePath))
@@ -46,7 +29,7 @@ const ensureLocalRepo = async (localDir, branch) => {
46
29
  fs.mkdirSync(localDir, { recursive: true });
47
30
  if (await isGitRepo(localDir))
48
31
  return 'reused';
49
- await execFile('git', ['init', `--initial-branch=${branch}`, localDir]);
32
+ await runGit(['init', `--initial-branch=${branch}`, localDir]);
50
33
  return 'initialized';
51
34
  };
52
35
  const writeQuonfigFiles = (dir, changes, source) => {
@@ -94,8 +77,8 @@ export const applyLocalMigration = async (opts) => {
94
77
  const branch = opts.branch ?? 'main';
95
78
  const author = opts.author ?? MIGRATOR_IDENTITY;
96
79
  const action = await ensureLocalRepo(opts.localDir, branch);
97
- await execFile('git', ['-C', opts.localDir, 'config', 'user.name', author.name]);
98
- await execFile('git', ['-C', opts.localDir, 'config', 'user.email', author.email]);
80
+ await runGit(['-C', opts.localDir, 'config', 'user.name', author.name]);
81
+ await runGit(['-C', opts.localDir, 'config', 'user.email', author.email]);
99
82
  ensureQuonfigJson(opts.localDir, opts.environments);
100
83
  const resolutionEntries = writeQuonfigFiles(opts.localDir, opts.changes, opts.source);
101
84
  removeQfFromGitignore(opts.localDir);
@@ -110,18 +93,20 @@ export const applyLocalMigration = async (opts) => {
110
93
  ...(skippedConfigs ? { skippedConfigs } : {}),
111
94
  };
112
95
  writeMigrationReport(opts.localDir, reportData);
113
- await execFile('git', ['-C', opts.localDir, 'add', '--all']);
96
+ await runGit(['-C', opts.localDir, 'add', '--all']);
114
97
  if (!(await hasStagedChanges(opts.localDir))) {
115
98
  return { action, committed: false, commitSha: null, droppedOverrides, duplicateResolutions, skippedConfigs };
116
99
  }
117
- await gitCommitFromStdin(opts.localDir, opts.commitMessage, {
118
- ...process.env,
119
- GIT_AUTHOR_EMAIL: author.email,
120
- GIT_AUTHOR_NAME: author.name,
121
- GIT_COMMITTER_EMAIL: author.email,
122
- GIT_COMMITTER_NAME: author.name,
100
+ await spawnGit(['-C', opts.localDir, 'commit', '-F', '-'], {
101
+ stdin: opts.commitMessage,
102
+ env: {
103
+ GIT_AUTHOR_EMAIL: author.email,
104
+ GIT_AUTHOR_NAME: author.name,
105
+ GIT_COMMITTER_EMAIL: author.email,
106
+ GIT_COMMITTER_NAME: author.name,
107
+ },
123
108
  });
124
- const { stdout: sha } = await execFile('git', ['-C', opts.localDir, 'rev-parse', 'HEAD']);
109
+ const { stdout: sha } = await runGit(['-C', opts.localDir, 'rev-parse', 'HEAD']);
125
110
  return {
126
111
  action,
127
112
  commitSha: sha.trim(),
@@ -24,13 +24,11 @@
24
24
  * `copyDirMirror` in `commands/push.ts`, which mirrors every non-dotfile
25
25
  * into the real push clone.
26
26
  */
27
- import { execFile as execFileCb } from 'node:child_process';
28
27
  import { createHash } from 'node:crypto';
29
28
  import * as fs from 'node:fs';
30
29
  import * as os from 'node:os';
31
30
  import * as path from 'node:path';
32
- import * as util from 'node:util';
33
- const execFile = util.promisify(execFileCb);
31
+ import { runGit } from '../util/git-ops.js';
34
32
  /**
35
33
  * Clone `remoteUrl` into an OS tmp dir, then diff the working tree of `localDir`
36
34
  * against the scratch clone's working tree. Returns `FileDelta[]` plus the
@@ -42,7 +40,7 @@ export async function computeBarePathDiff(localDir, remoteUrl) {
42
40
  // --depth 1 would miss information needed for later identity checks, but
43
41
  // for the diff we only need the tip — shallow is fine and a lot faster on
44
42
  // workspaces with 2 years of history.
45
- await execFile('git', ['clone', '--branch', 'main', '--depth', '1', remoteUrl, scratchDir]);
43
+ await runGit(['clone', '--branch', 'main', '--depth', '1', remoteUrl, scratchDir]);
46
44
  }
47
45
  catch (error) {
48
46
  // Clean up before re-throwing so we never leak a scratch dir on failure.
@@ -15,9 +15,11 @@
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 } from '../util/get-valid-token.js';
18
+ import { getValidAccessToken, resolveDefaultOrgId } from '../util/get-valid-token.js';
19
19
  export const callConfigsPush = async (input) => {
20
- const accessToken = await getValidAccessToken();
20
+ // TODO(qfg-kr7.5): thread workosOrgId through from the resolved workspace address.
21
+ const orgId = await resolveDefaultOrgId();
22
+ const accessToken = await getValidAccessToken(orgId);
21
23
  const apiUrl = getApiUrl();
22
24
  const res = await fetch(`${apiUrl}/api/v1/configs/push`, {
23
25
  method: 'POST',
@@ -19,7 +19,8 @@
19
19
  * confirmation — that prompt is the destructive-change brake.
20
20
  */
21
21
  import * as fs from 'node:fs';
22
- import { readWorkspaceSlug, writeWorkspaceSlug } from '../util/quonfig-json.js';
22
+ import { commitPinFixIfPinOnly } from '../util/git-ops.js';
23
+ import { readWorkspaceSlug, tryParseWorkspacePin, writeWorkspaceSlug } from '../util/quonfig-json.js';
23
24
  import { checkIdentity } from './identity-check.js';
24
25
  import { confirmTypedSlug, confirmYesNo } from './confirm.js';
25
26
  import { summarizeDiff } from './diff-summary.js';
@@ -69,7 +70,10 @@ export async function runPush(input, deps) {
69
70
  // Guard 1 pre-work: read repo pin + current origin BEFORE we mint a write
70
71
  // token. The identity check happens after minting (so we can cross-reference
71
72
  // the backend's canonical identity) but we want the pin/origin up front.
72
- const repoPinSlug = await readWorkspaceSlug(input.dir);
73
+ // The pin is `<org>/<ws>`; identity-check still operates on the workspace
74
+ // component until backend responses carry the org slug too (qfg-kr7 epic).
75
+ const repoPin = await readWorkspaceSlug(input.dir);
76
+ const repoPinSlug = repoPin?.workspaceSlug;
73
77
  const hasGit = await deps.gitOps.isGitRepo(input.dir);
74
78
  const remoteOriginUrl = hasGit ? await deps.gitOps.getRemoteOriginUrl(input.dir) : undefined;
75
79
  // Mint the write token. The backend accepts a slug OR a UUID for workspaceId
@@ -124,6 +128,23 @@ export async function runPush(input, deps) {
124
128
  await deps.gitOps.setRemoteOrigin(input.dir, backend.repoUrl);
125
129
  log('Fetching from remote...');
126
130
  await deps.gitOps.fetch(input.dir);
131
+ // Migration cleanup (qfg-0fn): older `qfg pull` runs wrote the
132
+ // workspace pin to the working tree without committing. The next
133
+ // diff would miss it. Sweep up that legacy state IF the only dirty
134
+ // change to quonfig.json is the matching workspace pin — never
135
+ // silently commit other uncommitted edits the user has in flight.
136
+ try {
137
+ const pinFix = await commitPinFixIfPinOnly(input.dir, 'quonfig.json', backend.workspaceSlug);
138
+ if (pinFix.kind === 'committed') {
139
+ log(`Committed workspace pin "${pinFix.slug}" to quonfig.json (legacy backfill from prior qfg pull).`);
140
+ }
141
+ else if (pinFix.kind === 'skipped') {
142
+ log(`Note: quonfig.json has uncommitted changes (${pinFix.reason}). They will not be included in this push.`);
143
+ }
144
+ }
145
+ catch {
146
+ /* non-fatal */
147
+ }
127
148
  }
128
149
  const deltas = await deps.gitOps.diffHeadVsOrigin(input.dir);
129
150
  const totalFilesInRemote = await deps.gitOps.countFilesInRemote(input.dir);
@@ -179,12 +200,20 @@ export async function runPush(input, deps) {
179
200
  throw new PushFatalError(`Push rejected by server: ${result.message}`, 'BAD_REQUEST');
180
201
  }
181
202
  if (unpinned && !input.noPinWrite) {
182
- try {
183
- await writeWorkspaceSlug(input.dir, backend.workspaceSlug);
184
- log(`Wrote workspace = "${backend.workspaceSlug}" into quonfig.json`);
185
- }
186
- catch {
187
- /* non-fatal; pin is advisory */
203
+ // Backend `workspaceSlug` is just the workspace component today; until
204
+ // it returns the slash form, we can only write the pin when the
205
+ // response is already in `<org>/<ws>` form. Otherwise skip — the pin
206
+ // is advisory and will be backfilled by a later `qfg pull` once the
207
+ // backend learns the new shape (qfg-kr7 epic).
208
+ const backendPin = tryParseWorkspacePin(backend.workspaceSlug);
209
+ if (backendPin) {
210
+ try {
211
+ await writeWorkspaceSlug(input.dir, backendPin);
212
+ log(`Wrote workspace = "${backendPin.orgSlug}/${backendPin.workspaceSlug}" into quonfig.json`);
213
+ }
214
+ catch {
215
+ /* non-fatal; pin is advisory */
216
+ }
188
217
  }
189
218
  }
190
219
  return { kind: 'pushed', dispatchedAs, commitSha: result.commitSha };
@@ -1,23 +1,6 @@
1
- import { execFile as execFileCb, spawn } 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
- const execFile = util.promisify(execFileCb);
6
- const gitCommitFromStdin = (cwd, message, env) => new Promise((resolve, reject) => {
7
- const child = spawn('git', ['-C', cwd, 'commit', '-F', '-'], { env });
8
- let stderr = '';
9
- child.stderr.on('data', (d) => {
10
- stderr += d.toString();
11
- });
12
- child.on('error', reject);
13
- child.on('close', (code) => {
14
- if (code === 0)
15
- resolve();
16
- else
17
- reject(new Error(`git commit exited ${code}: ${stderr}`));
18
- });
19
- child.stdin.end(message);
20
- });
3
+ import { runGit as runGitSafe, spawnGit } from './git-ops.js';
21
4
  export const MIGRATOR_IDENTITY = {
22
5
  name: 'quonfig migrator',
23
6
  email: 'migrator@quonfig.com',
@@ -31,7 +14,7 @@ export class PushConflictError extends Error {
31
14
  }
32
15
  const runGit = async (cwd, args) => {
33
16
  try {
34
- return await execFile('git', args, { cwd });
17
+ return await runGitSafe(args, { cwd });
35
18
  }
36
19
  catch (error) {
37
20
  const e = error;
@@ -46,7 +29,7 @@ const runGit = async (cwd, args) => {
46
29
  };
47
30
  const isGitRepo = async (dir) => {
48
31
  try {
49
- await execFile('git', ['-C', dir, 'rev-parse', '--git-dir']);
32
+ await runGitSafe(['-C', dir, 'rev-parse', '--git-dir']);
50
33
  return true;
51
34
  }
52
35
  catch {
@@ -55,7 +38,7 @@ const isGitRepo = async (dir) => {
55
38
  };
56
39
  const getOriginUrl = async (dir) => {
57
40
  try {
58
- const { stdout } = await execFile('git', ['-C', dir, 'remote', 'get-url', 'origin']);
41
+ const { stdout } = await runGitSafe(['-C', dir, 'remote', 'get-url', 'origin']);
59
42
  return stdout.trim() || null;
60
43
  }
61
44
  catch {
@@ -86,7 +69,7 @@ const ensureCloneOrReuse = async (remoteUrl, localDir, branch) => {
86
69
  return 'cloned';
87
70
  };
88
71
  const hasStagedChanges = async (dir) => {
89
- const { stdout } = await execFile('git', ['-C', dir, 'status', '--porcelain']);
72
+ const { stdout } = await runGitSafe(['-C', dir, 'status', '--porcelain']);
90
73
  return stdout.trim().length > 0;
91
74
  };
92
75
  export const cloneAndStackPush = async (opts) => {
@@ -101,14 +84,16 @@ export const cloneAndStackPush = async (opts) => {
101
84
  if (!(await hasStagedChanges(opts.localDir))) {
102
85
  return { action, committed: false, commitSha: null };
103
86
  }
104
- await gitCommitFromStdin(opts.localDir, opts.commitMessage, {
105
- ...process.env,
106
- GIT_AUTHOR_NAME: author.name,
107
- GIT_AUTHOR_EMAIL: author.email,
108
- GIT_COMMITTER_NAME: author.name,
109
- GIT_COMMITTER_EMAIL: author.email,
87
+ await spawnGit(['-C', opts.localDir, 'commit', '-F', '-'], {
88
+ stdin: opts.commitMessage,
89
+ env: {
90
+ GIT_AUTHOR_NAME: author.name,
91
+ GIT_AUTHOR_EMAIL: author.email,
92
+ GIT_COMMITTER_NAME: author.name,
93
+ GIT_COMMITTER_EMAIL: author.email,
94
+ },
110
95
  });
111
- const { stdout: sha } = await execFile('git', ['-C', opts.localDir, 'rev-parse', 'HEAD']);
96
+ const { stdout: sha } = await runGitSafe(['-C', opts.localDir, 'rev-parse', 'HEAD']);
112
97
  try {
113
98
  // Plain push — no force, no force-with-lease. If the remote has advanced, we fail
114
99
  // and surface a conflict; we do NOT clobber UI edits.