@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.
- package/README.md +47 -47
- package/dist/commands/create.d.ts +7 -0
- package/dist/commands/create.js +33 -0
- package/dist/commands/delete.js +4 -2
- package/dist/commands/init.js +6 -2
- package/dist/commands/login.js +72 -91
- package/dist/commands/migrate/doctor.js +13 -3
- package/dist/commands/override.js +4 -2
- package/dist/commands/pull.d.ts +11 -0
- package/dist/commands/pull.js +67 -20
- package/dist/commands/push.js +4 -7
- package/dist/commands/whoami.d.ts +13 -0
- package/dist/commands/whoami.js +119 -32
- package/dist/commands/workspace/bootstrap.js +27 -20
- package/dist/commands/workspace/create.d.ts +11 -0
- package/dist/commands/workspace/create.js +56 -16
- package/dist/commands/workspace/switch.d.ts +10 -0
- package/dist/commands/workspace/switch.js +98 -50
- package/dist/commands/workspace.d.ts +14 -0
- package/dist/commands/workspace.js +157 -40
- package/dist/migrate/local-write.js +16 -31
- package/dist/push/bare-path-diff.js +2 -4
- package/dist/push/config-push-client.js +4 -2
- package/dist/push/run-push.js +37 -8
- package/dist/util/clone-and-stack-push.js +14 -29
- package/dist/util/get-client.js +70 -38
- package/dist/util/get-valid-token.d.ts +16 -4
- package/dist/util/get-valid-token.js +59 -42
- package/dist/util/git-ops.d.ts +90 -0
- package/dist/util/git-ops.js +172 -24
- package/dist/util/gitea-api.js +4 -2
- package/dist/util/local-config-writer.d.ts +33 -0
- package/dist/util/local-config-writer.js +63 -0
- package/dist/util/login-orchestrator.d.ts +42 -0
- package/dist/util/login-orchestrator.js +79 -0
- package/dist/util/oauth-client.d.ts +2 -0
- package/dist/util/oauth-client.js +30 -0
- package/dist/util/quonfig-json.d.ts +58 -21
- package/dist/util/quonfig-json.js +151 -22
- package/dist/util/resolve-workspace.js +79 -17
- package/dist/util/token-storage.d.ts +39 -8
- package/dist/util/token-storage.js +34 -4
- package/oclif.manifest.json +8 -8
- package/package.json +1 -1
|
@@ -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 {
|
|
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
|
|
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
|
|
17
|
-
if (!
|
|
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
|
|
21
|
-
const
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
const
|
|
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
|
-
|
|
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
|
-
|
|
126
|
+
this.verboseLog('workspace: userWorkspaces/list non-OK', { status: res.status });
|
|
127
|
+
return [];
|
|
60
128
|
}
|
|
61
129
|
const body = (await res.json());
|
|
62
|
-
|
|
130
|
+
const list = (body.json ?? body);
|
|
131
|
+
return Array.isArray(list) ? list : [];
|
|
63
132
|
}
|
|
64
133
|
catch (error) {
|
|
65
|
-
|
|
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
|
|
75
|
-
|
|
76
|
-
this.log(`
|
|
77
|
-
this.log(`
|
|
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
|
-
|
|
7
|
+
import { runGit, spawnGit } from '../util/git-ops.js';
|
|
10
8
|
const isGitRepo = async (dir) => {
|
|
11
9
|
try {
|
|
12
|
-
await
|
|
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
|
|
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
|
|
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
|
|
98
|
-
await
|
|
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
|
|
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
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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',
|
package/dist/push/run-push.js
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
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
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
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
|
|
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.
|