@quonfig/cli 0.0.2 → 0.0.5
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 +306 -29
- package/bin/dev.js +5 -9
- package/bin/dev.mjs +6 -0
- package/dist/codegen/zod-introspection.js +13 -4
- package/dist/commands/config-schema.d.ts +10 -0
- package/dist/commands/config-schema.js +165 -0
- package/dist/commands/create.js +22 -1
- package/dist/commands/info.js +17 -2
- package/dist/commands/login.js +27 -6
- package/dist/commands/mcp.js +1 -1
- package/dist/commands/pull.d.ts +15 -0
- package/dist/commands/pull.js +191 -0
- package/dist/commands/serve.js +3 -1
- package/dist/commands/set-default.js +78 -15
- package/dist/commands/set-rollout.d.ts +19 -0
- package/dist/commands/set-rollout.js +260 -0
- package/dist/commands/sync.d.ts +18 -0
- package/dist/commands/sync.js +142 -0
- package/dist/commands/whoami.js +4 -1
- package/dist/commands/workspace/bootstrap.d.ts +13 -0
- package/dist/commands/workspace/bootstrap.js +155 -0
- package/dist/init/init-workspace.js +4 -4
- package/dist/init/templates.js +1 -1
- package/dist/util/coerce.js +2 -1
- package/dist/util/get-client.js +6 -44
- package/dist/util/get-valid-token.d.ts +5 -0
- package/dist/util/get-valid-token.js +44 -0
- package/dist/util/git-ops.d.ts +32 -0
- package/dist/util/git-ops.js +164 -0
- package/dist/util/gitea-api.d.ts +11 -0
- package/dist/util/gitea-api.js +45 -0
- package/dist/util/gitea-token-storage.d.ts +8 -0
- package/dist/util/gitea-token-storage.js +47 -0
- package/dist/util/token-storage.d.ts +1 -0
- package/dist/util/token-storage.js +24 -10
- package/dist/verify/validate.d.ts +1 -0
- package/dist/verify/validate.js +136 -6
- package/oclif.manifest.json +404 -9
- package/package.json +2 -2
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
import * as path from 'node:path';
|
|
2
|
+
import { confirm } from '@inquirer/prompts';
|
|
3
|
+
import { Flags } from '@oclif/core';
|
|
4
|
+
import { BaseCommand } from '../../index.js';
|
|
5
|
+
import { getActiveProfile, loadAuthConfig } from '../../util/token-storage.js';
|
|
6
|
+
import { mintGiteaToken } from '../../util/gitea-api.js';
|
|
7
|
+
import { isGitRepo, hasAtLeastOneCommit, gitSetRemote, gitPushForceLease, gitPushForce, getRemoteUrl, displayUrl, } from '../../util/git-ops.js';
|
|
8
|
+
export default class WorkspaceBootstrap extends BaseCommand {
|
|
9
|
+
static description = 'Push a local git repo to Gitea as this workspace\'s config repository';
|
|
10
|
+
static examples = [
|
|
11
|
+
'<%= config.bin %> workspace bootstrap --dir ./our-config',
|
|
12
|
+
'<%= config.bin %> workspace bootstrap --dir ./launch-migrator/output',
|
|
13
|
+
'<%= config.bin %> workspace bootstrap --dir ./our-config --skip-validate',
|
|
14
|
+
];
|
|
15
|
+
static flags = {
|
|
16
|
+
dir: Flags.string({
|
|
17
|
+
description: 'Local directory to push (defaults to current directory)',
|
|
18
|
+
required: false,
|
|
19
|
+
}),
|
|
20
|
+
force: Flags.boolean({
|
|
21
|
+
default: false,
|
|
22
|
+
description: 'Force push even if remote already has commits',
|
|
23
|
+
}),
|
|
24
|
+
'skip-validate': Flags.boolean({
|
|
25
|
+
default: false,
|
|
26
|
+
description: 'Skip config validation before pushing',
|
|
27
|
+
}),
|
|
28
|
+
};
|
|
29
|
+
async run() {
|
|
30
|
+
const { flags } = await this.parse(WorkspaceBootstrap);
|
|
31
|
+
// Resolve target directory
|
|
32
|
+
const dir = flags.dir || process.env.QUONFIG_DIR || process.cwd();
|
|
33
|
+
const resolvedDir = path.resolve(dir);
|
|
34
|
+
// Load auth
|
|
35
|
+
const authConfig = await loadAuthConfig();
|
|
36
|
+
if (!authConfig) {
|
|
37
|
+
return this.err('Not logged in. Run `qfg login` first.');
|
|
38
|
+
}
|
|
39
|
+
const activeProfile = getActiveProfile();
|
|
40
|
+
const profile = authConfig.profiles[activeProfile] || authConfig.profiles[authConfig.defaultProfile || 'default'];
|
|
41
|
+
if (!profile) {
|
|
42
|
+
return this.err('No active profile found. Run `qfg login` first.');
|
|
43
|
+
}
|
|
44
|
+
const workspaceId = profile.workspace;
|
|
45
|
+
const workspaceName = profile.workspaceSlug || profile.workspaceName || workspaceId;
|
|
46
|
+
this.verboseLog('WorkspaceBootstrap', { workspaceId, dir: resolvedDir });
|
|
47
|
+
// Validate: must be a git repo with at least one commit
|
|
48
|
+
const isRepo = await isGitRepo(resolvedDir);
|
|
49
|
+
if (!isRepo) {
|
|
50
|
+
return this.err(`${resolvedDir} is not a git repository.\nInitialize it with \`git init\` and commit your config files first.`);
|
|
51
|
+
}
|
|
52
|
+
const hasCommit = await hasAtLeastOneCommit(resolvedDir);
|
|
53
|
+
if (!hasCommit) {
|
|
54
|
+
return this.err(`${resolvedDir} has no commits. Commit your config files first.`);
|
|
55
|
+
}
|
|
56
|
+
// Verify config before doing anything
|
|
57
|
+
if (!flags['skip-validate']) {
|
|
58
|
+
this.log('Verifying config files...');
|
|
59
|
+
try {
|
|
60
|
+
const { validateWorkspace } = await import('../../verify/validate.js');
|
|
61
|
+
const result = validateWorkspace(resolvedDir);
|
|
62
|
+
const errors = result.issues.filter((i) => i.severity === 'error');
|
|
63
|
+
if (errors.length > 0) {
|
|
64
|
+
this.log(`\nFound ${errors.length} validation error(s):\n`);
|
|
65
|
+
for (const issue of errors) {
|
|
66
|
+
this.log(` ${issue.message}`);
|
|
67
|
+
}
|
|
68
|
+
this.log('\nFix the errors above or run with --skip-validate to bypass.');
|
|
69
|
+
return this.err('Validation failed.');
|
|
70
|
+
}
|
|
71
|
+
this.log('Validation passed.\n');
|
|
72
|
+
}
|
|
73
|
+
catch (err) {
|
|
74
|
+
this.verboseLog('Validation error', String(err));
|
|
75
|
+
this.log('Warning: could not run validation. Proceeding anyway.');
|
|
76
|
+
this.log('Use --skip-validate to suppress this warning.\n');
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
// Confirmation prompt
|
|
80
|
+
const existingRemote = await getRemoteUrl(resolvedDir);
|
|
81
|
+
this.log(`Directory: ${resolvedDir}`);
|
|
82
|
+
this.log(`Workspace: ${workspaceName}`);
|
|
83
|
+
if (existingRemote) {
|
|
84
|
+
this.log(`Remote: ${displayUrl(existingRemote)} (will be updated)`);
|
|
85
|
+
}
|
|
86
|
+
this.log('');
|
|
87
|
+
const confirmed = await confirm({
|
|
88
|
+
message: `Push ${path.basename(resolvedDir)} to workspace "${workspaceName}"?`,
|
|
89
|
+
default: false,
|
|
90
|
+
});
|
|
91
|
+
if (!confirmed) {
|
|
92
|
+
this.log('Aborted.');
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
this.log('');
|
|
96
|
+
// Mint write token (backend provisions Gitea repo + bot account)
|
|
97
|
+
this.log('Connecting to Gitea...');
|
|
98
|
+
let tokenData;
|
|
99
|
+
try {
|
|
100
|
+
tokenData = await mintGiteaToken(workspaceId, 'write', 'bootstrap');
|
|
101
|
+
}
|
|
102
|
+
catch (err) {
|
|
103
|
+
return this.err(`Could not get Gitea credentials: ${String(err)}\n\nMake sure the workspace is fully provisioned in the Quonfig app before bootstrapping.`);
|
|
104
|
+
}
|
|
105
|
+
const { repoUrl } = tokenData;
|
|
106
|
+
this.verboseLog('WorkspaceBootstrap', { repoUrl: displayUrl(repoUrl) });
|
|
107
|
+
// Idempotency: check if remote already has commits
|
|
108
|
+
if (!flags.force) {
|
|
109
|
+
const remoteHasCommits = await this.remoteHasCommits(repoUrl);
|
|
110
|
+
if (remoteHasCommits) {
|
|
111
|
+
this.log(`\nThe remote repository already has commits.`);
|
|
112
|
+
this.log(`Run \`qfg pull --dir ${resolvedDir}\` to sync, then re-run bootstrap.`);
|
|
113
|
+
this.log(`Or run with --force to overwrite the remote.\n`);
|
|
114
|
+
return this.err('Remote is not empty.');
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
// Set remote
|
|
118
|
+
if (existingRemote) {
|
|
119
|
+
this.log(`Updating remote origin...`);
|
|
120
|
+
}
|
|
121
|
+
else {
|
|
122
|
+
this.log(`Setting remote origin...`);
|
|
123
|
+
}
|
|
124
|
+
await gitSetRemote(resolvedDir, repoUrl);
|
|
125
|
+
// Push
|
|
126
|
+
this.log('Pushing to Gitea...');
|
|
127
|
+
try {
|
|
128
|
+
if (flags.force) {
|
|
129
|
+
await gitPushForce(resolvedDir);
|
|
130
|
+
}
|
|
131
|
+
else {
|
|
132
|
+
await gitPushForceLease(resolvedDir);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
catch (err) {
|
|
136
|
+
return this.err(`Push failed: ${String(err)}`);
|
|
137
|
+
}
|
|
138
|
+
this.log(`\nBootstrap complete.`);
|
|
139
|
+
this.log(`Workspace "${workspaceName}" is now connected to your local directory.`);
|
|
140
|
+
this.log(`\nTo keep it in sync locally, run:`);
|
|
141
|
+
this.log(` qfg sync --watch --dir ${resolvedDir}`);
|
|
142
|
+
return {
|
|
143
|
+
dir: resolvedDir,
|
|
144
|
+
repoUrl: displayUrl(repoUrl),
|
|
145
|
+
workspaceId,
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
async remoteHasCommits(repoUrl) {
|
|
149
|
+
const { execFile: execFileCb } = await import('node:child_process');
|
|
150
|
+
const util = await import('node:util');
|
|
151
|
+
const execFile = util.promisify(execFileCb);
|
|
152
|
+
const { stdout } = await execFile('git', ['ls-remote', '--heads', repoUrl]);
|
|
153
|
+
return stdout.trim().length > 0;
|
|
154
|
+
}
|
|
155
|
+
}
|
|
@@ -54,13 +54,13 @@ export function planInit(options) {
|
|
|
54
54
|
const verb = fs.existsSync(filePath) ? 'Update' : 'Create';
|
|
55
55
|
actions.push({ kind: 'write-file', path: file, description: `${verb} ${file}` });
|
|
56
56
|
}
|
|
57
|
-
// 3.
|
|
58
|
-
const envsPath = path.join(dir, '
|
|
57
|
+
// 3. quonfig.json — create if missing
|
|
58
|
+
const envsPath = path.join(dir, 'quonfig.json');
|
|
59
59
|
if (fs.existsSync(envsPath)) {
|
|
60
60
|
// leave it alone
|
|
61
61
|
}
|
|
62
62
|
else {
|
|
63
|
-
actions.push({ kind: 'write-file', path: '
|
|
63
|
+
actions.push({ kind: 'write-file', path: 'quonfig.json', description: 'Create quonfig.json' });
|
|
64
64
|
}
|
|
65
65
|
// 4. Sample data
|
|
66
66
|
if (includeSamples) {
|
|
@@ -108,7 +108,7 @@ export function executeInit(plan, dir) {
|
|
|
108
108
|
'README.md': readmeTemplate(),
|
|
109
109
|
'CLAUDE.md': claudeMdTemplate(),
|
|
110
110
|
'AGENTS.md': agentsMdTemplate(),
|
|
111
|
-
'
|
|
111
|
+
'quonfig.json': JSON.stringify({ environments: [] }, null, 2) + '\n',
|
|
112
112
|
};
|
|
113
113
|
// Build a lookup for sample content
|
|
114
114
|
const sampleContent = {};
|
package/dist/init/templates.js
CHANGED
|
@@ -121,7 +121,7 @@ See \`quonfig.schema.json\` for the full operator enum and value type definition
|
|
|
121
121
|
|
|
122
122
|
## Environments
|
|
123
123
|
|
|
124
|
-
Environment overrides go in the \`environments\` array. Each has an \`id\` (lowercase slug like \`production\`, never a UUID) and its own \`rules\` array. Environment IDs are defined in \`
|
|
124
|
+
Environment overrides go in the \`environments\` array. Each has an \`id\` (lowercase slug like \`production\`, never a UUID) and its own \`rules\` array. Environment IDs are defined in \`quonfig.json\` at the repo root.
|
|
125
125
|
|
|
126
126
|
## Cross-File Validation Rules
|
|
127
127
|
|
package/dist/util/coerce.js
CHANGED
package/dist/util/get-client.js
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { Client } from '@quonfig/node';
|
|
2
2
|
import { getApiUrl } from '../util/domain-urls.js';
|
|
3
3
|
import jsonMaybe from '../util/json-maybe.js';
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
4
|
+
import { getActiveProfile, loadAuthConfig, loadTokens } from '../util/token-storage.js';
|
|
5
|
+
import { getValidAccessToken } from '../util/get-valid-token.js';
|
|
6
6
|
import version from '../version.js';
|
|
7
7
|
let clientInstance;
|
|
8
8
|
let cachedWorkspaceId;
|
|
@@ -31,45 +31,13 @@ const getClient = async (command, sdkKey, profile) => {
|
|
|
31
31
|
if (!authConfig || !tokens?.accessToken) {
|
|
32
32
|
command.error('No authentication found. Please run `qfg login`.', { exit: 401 });
|
|
33
33
|
}
|
|
34
|
-
|
|
35
|
-
// Prefer the JWT's actual exp claim over the stored expiresAt.
|
|
36
|
-
let tokenExpired = tokens.expiresAt ? tokens.expiresAt < Date.now() : false;
|
|
34
|
+
command.verboseLog('Checking token validity...');
|
|
37
35
|
try {
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
tokenExpired = payload.exp * 1000 < Date.now();
|
|
41
|
-
}
|
|
36
|
+
jwt = await getValidAccessToken();
|
|
37
|
+
command.verboseLog('Token valid (or refreshed)');
|
|
42
38
|
}
|
|
43
39
|
catch {
|
|
44
|
-
|
|
45
|
-
}
|
|
46
|
-
if (tokenExpired && tokens.refreshToken) {
|
|
47
|
-
command.verboseLog('Token expired, refreshing...');
|
|
48
|
-
try {
|
|
49
|
-
const refreshed = await refreshAccessToken(tokens.refreshToken);
|
|
50
|
-
// Use the JWT's actual exp claim for expiry, not a hardcoded duration
|
|
51
|
-
let expiresAt = Date.now() + 300 * 1000; // fallback: 5 minutes
|
|
52
|
-
try {
|
|
53
|
-
const payload = decodeJWT(refreshed.access_token);
|
|
54
|
-
if (typeof payload.exp === 'number') {
|
|
55
|
-
expiresAt = payload.exp * 1000; // convert seconds to ms
|
|
56
|
-
}
|
|
57
|
-
}
|
|
58
|
-
catch {
|
|
59
|
-
// Use fallback
|
|
60
|
-
}
|
|
61
|
-
tokens = {
|
|
62
|
-
...tokens,
|
|
63
|
-
accessToken: refreshed.access_token,
|
|
64
|
-
expiresAt,
|
|
65
|
-
refreshToken: refreshed.refresh_token,
|
|
66
|
-
};
|
|
67
|
-
await saveTokens(tokens);
|
|
68
|
-
command.verboseLog('Token refreshed successfully');
|
|
69
|
-
}
|
|
70
|
-
catch (error) {
|
|
71
|
-
command.error('Session expired. Please run `qfg login` to re-authenticate.', { exit: 401 });
|
|
72
|
-
}
|
|
40
|
+
command.error('Session expired. Please run `qfg login` to re-authenticate.', { exit: 401 });
|
|
73
41
|
}
|
|
74
42
|
// Get the active profile to find the workspace
|
|
75
43
|
const activeProfile = getActiveProfile(profile);
|
|
@@ -82,12 +50,6 @@ const getClient = async (command, sdkKey, profile) => {
|
|
|
82
50
|
if (profileData) {
|
|
83
51
|
workspaceId = profileData.workspace;
|
|
84
52
|
}
|
|
85
|
-
// Use the WorkOS access token directly as the JWT
|
|
86
|
-
jwt = tokens.accessToken;
|
|
87
|
-
// If still no JWT, user needs to login
|
|
88
|
-
if (!jwt) {
|
|
89
|
-
command.error('No authentication found. Please run `qfg login`.', { exit: 401 });
|
|
90
|
-
}
|
|
91
53
|
}
|
|
92
54
|
// Store workspaceId on the command for use in building URLs and cache it
|
|
93
55
|
if (workspaceId) {
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Returns a valid (non-expired) WorkOS access token, refreshing if needed.
|
|
3
|
+
* Throws if not logged in or if refresh fails.
|
|
4
|
+
*/
|
|
5
|
+
import { decodeJWT, refreshAccessToken } from './oauth-client.js';
|
|
6
|
+
import { loadTokens, saveTokens } from './token-storage.js';
|
|
7
|
+
export async function getValidAccessToken() {
|
|
8
|
+
const tokens = await loadTokens();
|
|
9
|
+
if (!tokens?.accessToken) {
|
|
10
|
+
throw new Error('Not authenticated. Please run `qfg login` first.');
|
|
11
|
+
}
|
|
12
|
+
// Check expiry using the JWT's exp claim, falling back to stored expiresAt
|
|
13
|
+
let expired = tokens.expiresAt ? tokens.expiresAt < Date.now() : false;
|
|
14
|
+
try {
|
|
15
|
+
const payload = decodeJWT(tokens.accessToken);
|
|
16
|
+
if (typeof payload.exp === 'number') {
|
|
17
|
+
expired = payload.exp * 1000 < Date.now();
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
catch { /* use stored expiresAt */ }
|
|
21
|
+
if (!expired) {
|
|
22
|
+
return tokens.accessToken;
|
|
23
|
+
}
|
|
24
|
+
if (!tokens.refreshToken) {
|
|
25
|
+
throw new Error('Session expired. Please run `qfg login` to re-authenticate.');
|
|
26
|
+
}
|
|
27
|
+
const refreshed = await refreshAccessToken(tokens.refreshToken);
|
|
28
|
+
let expiresAt = Date.now() + 300 * 1000;
|
|
29
|
+
try {
|
|
30
|
+
const payload = decodeJWT(refreshed.access_token);
|
|
31
|
+
if (typeof payload.exp === 'number') {
|
|
32
|
+
expiresAt = payload.exp * 1000;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
catch { /* use fallback */ }
|
|
36
|
+
const updated = {
|
|
37
|
+
...tokens,
|
|
38
|
+
accessToken: refreshed.access_token,
|
|
39
|
+
expiresAt,
|
|
40
|
+
refreshToken: refreshed.refresh_token,
|
|
41
|
+
};
|
|
42
|
+
await saveTokens(updated);
|
|
43
|
+
return refreshed.access_token;
|
|
44
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Redact a token from a URL string so it is safe to display to users.
|
|
3
|
+
*/
|
|
4
|
+
export declare const redactToken: (url: string) => string;
|
|
5
|
+
export declare const gitClone: (repoUrl: string, dir: string) => Promise<void>;
|
|
6
|
+
export declare const isGitRepo: (dir: string) => Promise<boolean>;
|
|
7
|
+
export declare const getRemoteUrl: (dir: string) => Promise<string | null>;
|
|
8
|
+
export declare const isWorkingTreeClean: (dir: string) => Promise<boolean>;
|
|
9
|
+
export declare const gitFetch: (dir: string) => Promise<void>;
|
|
10
|
+
/**
|
|
11
|
+
* Returns true if origin/main has commits that can be fast-forwarded into the local branch.
|
|
12
|
+
*/
|
|
13
|
+
export declare const canFastForward: (dir: string) => Promise<boolean>;
|
|
14
|
+
/**
|
|
15
|
+
* Returns true if local has commits not reachable from origin/main (diverged).
|
|
16
|
+
*/
|
|
17
|
+
export declare const hasDivergedFromRemote: (dir: string) => Promise<boolean>;
|
|
18
|
+
/**
|
|
19
|
+
* Performs a fast-forward-only merge of origin/main. Returns list of new commit subjects.
|
|
20
|
+
*/
|
|
21
|
+
export declare const gitMergeFfOnly: (dir: string) => Promise<string[]>;
|
|
22
|
+
/**
|
|
23
|
+
* Adds or updates the origin remote for a repo.
|
|
24
|
+
*/
|
|
25
|
+
export declare const gitSetRemote: (dir: string, url: string) => Promise<void>;
|
|
26
|
+
export declare const gitPushForceLease: (dir: string) => Promise<void>;
|
|
27
|
+
export declare const gitPushForce: (dir: string) => Promise<void>;
|
|
28
|
+
export declare const hasAtLeastOneCommit: (dir: string) => Promise<boolean>;
|
|
29
|
+
/**
|
|
30
|
+
* Get the URL stripped of credentials for display purposes.
|
|
31
|
+
*/
|
|
32
|
+
export declare const displayUrl: (url: string) => string;
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
import { execFile as execFileCb } from 'node:child_process';
|
|
2
|
+
import * as util from 'node:util';
|
|
3
|
+
const execFile = util.promisify(execFileCb);
|
|
4
|
+
/**
|
|
5
|
+
* Redact a token from a URL string so it is safe to display to users.
|
|
6
|
+
*/
|
|
7
|
+
export const redactToken = (url) => url.replace(/:([^@/]+)@/, ':***@');
|
|
8
|
+
/**
|
|
9
|
+
* Wrap execFile errors to strip tokens from the message.
|
|
10
|
+
*/
|
|
11
|
+
const safeExec = async (file, args, options) => {
|
|
12
|
+
try {
|
|
13
|
+
return await execFile(file, args, { cwd: options?.cwd });
|
|
14
|
+
}
|
|
15
|
+
catch (err) {
|
|
16
|
+
const e = err;
|
|
17
|
+
const message = redactToken(e.message || String(err));
|
|
18
|
+
const stderr = redactToken(e.stderr ?? '');
|
|
19
|
+
const safeErr = new Error(message);
|
|
20
|
+
safeErr.stderr = stderr;
|
|
21
|
+
throw safeErr;
|
|
22
|
+
}
|
|
23
|
+
};
|
|
24
|
+
export const gitClone = async (repoUrl, dir) => {
|
|
25
|
+
await safeExec('git', ['clone', repoUrl, dir]);
|
|
26
|
+
};
|
|
27
|
+
export const isGitRepo = async (dir) => {
|
|
28
|
+
try {
|
|
29
|
+
await execFile('git', ['-C', dir, 'rev-parse', '--git-dir']);
|
|
30
|
+
return true;
|
|
31
|
+
}
|
|
32
|
+
catch {
|
|
33
|
+
return false;
|
|
34
|
+
}
|
|
35
|
+
};
|
|
36
|
+
export const getRemoteUrl = async (dir) => {
|
|
37
|
+
try {
|
|
38
|
+
const { stdout } = await execFile('git', ['-C', dir, 'remote', 'get-url', 'origin']);
|
|
39
|
+
return stdout.trim() || null;
|
|
40
|
+
}
|
|
41
|
+
catch {
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
44
|
+
};
|
|
45
|
+
export const isWorkingTreeClean = async (dir) => {
|
|
46
|
+
const { stdout } = await execFile('git', ['-C', dir, 'status', '--porcelain']);
|
|
47
|
+
return stdout.trim() === '';
|
|
48
|
+
};
|
|
49
|
+
export const gitFetch = async (dir) => {
|
|
50
|
+
await safeExec('git', ['-C', dir, 'fetch', 'origin']);
|
|
51
|
+
};
|
|
52
|
+
/**
|
|
53
|
+
* Returns true if origin/main has commits that can be fast-forwarded into the local branch.
|
|
54
|
+
*/
|
|
55
|
+
export const canFastForward = async (dir) => {
|
|
56
|
+
try {
|
|
57
|
+
// Get the local HEAD and origin/main SHAs
|
|
58
|
+
const { stdout: localSha } = await execFile('git', ['-C', dir, 'rev-parse', 'HEAD']);
|
|
59
|
+
const { stdout: remoteSha } = await execFile('git', ['-C', dir, 'rev-parse', 'origin/main']);
|
|
60
|
+
const local = localSha.trim();
|
|
61
|
+
const remote = remoteSha.trim();
|
|
62
|
+
if (local === remote)
|
|
63
|
+
return false; // already up to date
|
|
64
|
+
// Check if local is an ancestor of remote (i.e. ff is possible)
|
|
65
|
+
try {
|
|
66
|
+
await execFile('git', ['-C', dir, 'merge-base', '--is-ancestor', local, remote]);
|
|
67
|
+
return true; // exit code 0 means local is an ancestor of remote
|
|
68
|
+
}
|
|
69
|
+
catch {
|
|
70
|
+
return false; // local has diverged
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
catch {
|
|
74
|
+
return false;
|
|
75
|
+
}
|
|
76
|
+
};
|
|
77
|
+
/**
|
|
78
|
+
* Returns true if local has commits not reachable from origin/main (diverged).
|
|
79
|
+
*/
|
|
80
|
+
export const hasDivergedFromRemote = async (dir) => {
|
|
81
|
+
try {
|
|
82
|
+
const { stdout: localSha } = await execFile('git', ['-C', dir, 'rev-parse', 'HEAD']);
|
|
83
|
+
const { stdout: remoteSha } = await execFile('git', ['-C', dir, 'rev-parse', 'origin/main']);
|
|
84
|
+
const local = localSha.trim();
|
|
85
|
+
const remote = remoteSha.trim();
|
|
86
|
+
if (local === remote)
|
|
87
|
+
return false;
|
|
88
|
+
// If remote is an ancestor of local, local is ahead (diverged for our purposes)
|
|
89
|
+
try {
|
|
90
|
+
await execFile('git', ['-C', dir, 'merge-base', '--is-ancestor', remote, local]);
|
|
91
|
+
return true;
|
|
92
|
+
}
|
|
93
|
+
catch {
|
|
94
|
+
// Check if truly diverged (neither is ancestor of the other)
|
|
95
|
+
try {
|
|
96
|
+
await execFile('git', ['-C', dir, 'merge-base', '--is-ancestor', local, remote]);
|
|
97
|
+
return false; // ff possible, not diverged
|
|
98
|
+
}
|
|
99
|
+
catch {
|
|
100
|
+
return true; // truly diverged
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
catch {
|
|
105
|
+
return false;
|
|
106
|
+
}
|
|
107
|
+
};
|
|
108
|
+
/**
|
|
109
|
+
* Performs a fast-forward-only merge of origin/main. Returns list of new commit subjects.
|
|
110
|
+
*/
|
|
111
|
+
export const gitMergeFfOnly = async (dir) => {
|
|
112
|
+
// Get commits that will be merged (before merge)
|
|
113
|
+
let newCommits = [];
|
|
114
|
+
try {
|
|
115
|
+
const { stdout: localSha } = await execFile('git', ['-C', dir, 'rev-parse', 'HEAD']);
|
|
116
|
+
const { stdout: log } = await execFile('git', [
|
|
117
|
+
'-C',
|
|
118
|
+
dir,
|
|
119
|
+
'log',
|
|
120
|
+
'--pretty=format:%s',
|
|
121
|
+
`${localSha.trim()}..origin/main`,
|
|
122
|
+
]);
|
|
123
|
+
newCommits = log
|
|
124
|
+
.split('\n')
|
|
125
|
+
.map((s) => s.trim())
|
|
126
|
+
.filter(Boolean);
|
|
127
|
+
}
|
|
128
|
+
catch {
|
|
129
|
+
// Non-fatal — we'll still attempt the merge
|
|
130
|
+
}
|
|
131
|
+
await safeExec('git', ['-C', dir, 'merge', '--ff-only', 'origin/main']);
|
|
132
|
+
return newCommits;
|
|
133
|
+
};
|
|
134
|
+
/**
|
|
135
|
+
* Adds or updates the origin remote for a repo.
|
|
136
|
+
*/
|
|
137
|
+
export const gitSetRemote = async (dir, url) => {
|
|
138
|
+
const existing = await getRemoteUrl(dir);
|
|
139
|
+
if (existing !== null) {
|
|
140
|
+
await safeExec('git', ['-C', dir, 'remote', 'set-url', 'origin', url]);
|
|
141
|
+
}
|
|
142
|
+
else {
|
|
143
|
+
await safeExec('git', ['-C', dir, 'remote', 'add', 'origin', url]);
|
|
144
|
+
}
|
|
145
|
+
};
|
|
146
|
+
export const gitPushForceLease = async (dir) => {
|
|
147
|
+
await safeExec('git', ['-C', dir, 'push', 'origin', 'main', '--force-with-lease']);
|
|
148
|
+
};
|
|
149
|
+
export const gitPushForce = async (dir) => {
|
|
150
|
+
await safeExec('git', ['-C', dir, 'push', 'origin', 'main', '--force']);
|
|
151
|
+
};
|
|
152
|
+
export const hasAtLeastOneCommit = async (dir) => {
|
|
153
|
+
try {
|
|
154
|
+
await execFile('git', ['-C', dir, 'rev-parse', 'HEAD']);
|
|
155
|
+
return true;
|
|
156
|
+
}
|
|
157
|
+
catch {
|
|
158
|
+
return false;
|
|
159
|
+
}
|
|
160
|
+
};
|
|
161
|
+
/**
|
|
162
|
+
* Get the URL stripped of credentials for display purposes.
|
|
163
|
+
*/
|
|
164
|
+
export const displayUrl = (url) => redactToken(url);
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { GiteaTokenEntry } from './gitea-token-storage.js';
|
|
2
|
+
export interface GiteaTokenResponse {
|
|
3
|
+
token: string;
|
|
4
|
+
repoUrl: string;
|
|
5
|
+
expiresAt: string | null;
|
|
6
|
+
}
|
|
7
|
+
export declare const mintGiteaToken: (workspaceId: string, scope: "read" | "write", purpose: "pull" | "bootstrap") => Promise<GiteaTokenResponse>;
|
|
8
|
+
/**
|
|
9
|
+
* Mint a Gitea read token, store it, and return the entry.
|
|
10
|
+
*/
|
|
11
|
+
export declare const mintAndStoreGiteaReadToken: (workspaceId: string) => Promise<GiteaTokenEntry>;
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { getApiUrl } from './domain-urls.js';
|
|
2
|
+
import { getValidAccessToken } from './get-valid-token.js';
|
|
3
|
+
import { saveGiteaToken } from './gitea-token-storage.js';
|
|
4
|
+
export const mintGiteaToken = async (workspaceId, scope, purpose) => {
|
|
5
|
+
const accessToken = await getValidAccessToken();
|
|
6
|
+
const apiUrl = getApiUrl();
|
|
7
|
+
const res = await fetch(`${apiUrl}/api/v1/gitea/token`, {
|
|
8
|
+
method: 'POST',
|
|
9
|
+
headers: {
|
|
10
|
+
'Authorization': `Bearer ${accessToken}`,
|
|
11
|
+
'Content-Type': 'application/json',
|
|
12
|
+
},
|
|
13
|
+
body: JSON.stringify({ json: { scope, workspaceId, purpose } }),
|
|
14
|
+
});
|
|
15
|
+
if (!res.ok) {
|
|
16
|
+
const text = await res.text().catch(() => '');
|
|
17
|
+
let message = `HTTP ${res.status}`;
|
|
18
|
+
try {
|
|
19
|
+
const body = JSON.parse(text);
|
|
20
|
+
// oRPC error shape
|
|
21
|
+
if (body?.json?.message)
|
|
22
|
+
message = body.json.message;
|
|
23
|
+
else if (body?.message)
|
|
24
|
+
message = body.message;
|
|
25
|
+
}
|
|
26
|
+
catch { /* use raw text */ }
|
|
27
|
+
throw new Error(message || text);
|
|
28
|
+
}
|
|
29
|
+
const body = await res.json();
|
|
30
|
+
const data = body.json ?? body;
|
|
31
|
+
return data;
|
|
32
|
+
};
|
|
33
|
+
/**
|
|
34
|
+
* Mint a Gitea read token, store it, and return the entry.
|
|
35
|
+
*/
|
|
36
|
+
export const mintAndStoreGiteaReadToken = async (workspaceId) => {
|
|
37
|
+
const data = await mintGiteaToken(workspaceId, 'read', 'pull');
|
|
38
|
+
const entry = {
|
|
39
|
+
token: data.token,
|
|
40
|
+
repoUrl: data.repoUrl,
|
|
41
|
+
expiresAt: data.expiresAt,
|
|
42
|
+
};
|
|
43
|
+
await saveGiteaToken(workspaceId, entry);
|
|
44
|
+
return entry;
|
|
45
|
+
};
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
export interface GiteaTokenEntry {
|
|
2
|
+
token: string;
|
|
3
|
+
repoUrl: string;
|
|
4
|
+
expiresAt: string | null;
|
|
5
|
+
}
|
|
6
|
+
export declare const loadGiteaToken: (workspaceId: string) => Promise<GiteaTokenEntry | null>;
|
|
7
|
+
export declare const saveGiteaToken: (workspaceId: string, entry: GiteaTokenEntry) => Promise<void>;
|
|
8
|
+
export declare const isGiteaTokenExpired: (entry: GiteaTokenEntry) => boolean;
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import * as fs from 'node:fs';
|
|
2
|
+
import * as os from 'node:os';
|
|
3
|
+
import * as path from 'node:path';
|
|
4
|
+
import { getDomain } from './domain-urls.js';
|
|
5
|
+
const BUFFER_MS = 5 * 60 * 1000; // 5 minutes
|
|
6
|
+
const getQuonfigDir = () => path.join(os.homedir(), '.quonfig');
|
|
7
|
+
const getGiteaTokenFile = () => {
|
|
8
|
+
const domain = getDomain();
|
|
9
|
+
const filename = domain !== 'quonfig.com' ? `gitea-tokens-${domain.replaceAll('.', '-')}.json` : 'gitea-tokens.json';
|
|
10
|
+
return path.join(getQuonfigDir(), filename);
|
|
11
|
+
};
|
|
12
|
+
const ensureQuonfigDir = async () => {
|
|
13
|
+
const dir = getQuonfigDir();
|
|
14
|
+
try {
|
|
15
|
+
await fs.promises.access(dir, fs.constants.F_OK);
|
|
16
|
+
}
|
|
17
|
+
catch {
|
|
18
|
+
await fs.promises.mkdir(dir, { recursive: true });
|
|
19
|
+
}
|
|
20
|
+
};
|
|
21
|
+
const loadStore = async () => {
|
|
22
|
+
try {
|
|
23
|
+
const data = await fs.promises.readFile(getGiteaTokenFile(), 'utf8');
|
|
24
|
+
return JSON.parse(data);
|
|
25
|
+
}
|
|
26
|
+
catch {
|
|
27
|
+
return {};
|
|
28
|
+
}
|
|
29
|
+
};
|
|
30
|
+
export const loadGiteaToken = async (workspaceId) => {
|
|
31
|
+
const store = await loadStore();
|
|
32
|
+
return store[workspaceId] ?? null;
|
|
33
|
+
};
|
|
34
|
+
export const saveGiteaToken = async (workspaceId, entry) => {
|
|
35
|
+
await ensureQuonfigDir();
|
|
36
|
+
const store = await loadStore();
|
|
37
|
+
store[workspaceId] = entry;
|
|
38
|
+
const targetFile = getGiteaTokenFile();
|
|
39
|
+
const tmpFile = `${targetFile}.tmp`;
|
|
40
|
+
await fs.promises.writeFile(tmpFile, JSON.stringify(store, null, 2), { encoding: 'utf8', mode: 0o600 });
|
|
41
|
+
await fs.promises.rename(tmpFile, targetFile);
|
|
42
|
+
};
|
|
43
|
+
export const isGiteaTokenExpired = (entry) => {
|
|
44
|
+
if (!entry.expiresAt)
|
|
45
|
+
return false;
|
|
46
|
+
return new Date(entry.expiresAt).getTime() - BUFFER_MS < Date.now();
|
|
47
|
+
};
|