@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.
- 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 +152 -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/dist/verify/validate.js +25 -1
- package/oclif.manifest.json +8 -8
- package/package.json +1 -1
package/dist/commands/login.js
CHANGED
|
@@ -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
|
-
//
|
|
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
|
-
//
|
|
72
|
-
|
|
73
|
-
const
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
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
|
-
//
|
|
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
|
-
//
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
let
|
|
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
|
|
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
|
-
|
|
103
|
+
body: JSON.stringify({ json: {} }),
|
|
107
104
|
headers: {
|
|
108
|
-
Authorization: `Bearer ${
|
|
105
|
+
Authorization: `Bearer ${defaultToken.access_token}`,
|
|
109
106
|
'Content-Type': 'application/json',
|
|
110
107
|
},
|
|
111
|
-
|
|
108
|
+
method: 'POST',
|
|
112
109
|
});
|
|
113
110
|
if (res.ok) {
|
|
114
111
|
const body = (await res.json());
|
|
115
|
-
const
|
|
116
|
-
const candidates = Array.isArray(
|
|
117
|
-
|
|
118
|
-
|
|
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('
|
|
118
|
+
this.verboseLog('userWorkspaces/list non-OK', { status: res.status });
|
|
143
119
|
}
|
|
144
120
|
}
|
|
145
121
|
catch (error) {
|
|
146
|
-
this.verboseLog('
|
|
122
|
+
this.verboseLog('userWorkspaces/list failed', { error: String(error) });
|
|
147
123
|
}
|
|
148
|
-
//
|
|
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
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
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 (
|
|
175
|
-
this.log(`Logged in as: ${
|
|
176
|
-
}
|
|
177
|
-
if (
|
|
178
|
-
const
|
|
179
|
-
this.log(`
|
|
180
|
-
|
|
181
|
-
|
|
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
|
|
185
|
-
this.log(
|
|
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
|
-
|
|
195
|
-
|
|
196
|
-
|
|
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:
|
|
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
|
-
|
|
8
|
-
|
|
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.
|
|
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
|
-
|
|
101
|
-
const
|
|
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
|
}
|
package/dist/commands/pull.d.ts
CHANGED
|
@@ -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;
|
package/dist/commands/pull.js
CHANGED
|
@@ -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 (
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
this.log(
|
|
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('
|
|
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).
|
|
129
|
-
//
|
|
130
|
-
//
|
|
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,
|
|
168
|
-
|
|
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 !==
|
|
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 "${
|
|
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');
|
package/dist/commands/push.js
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
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
|
}
|