@quonfig/cli 0.0.2 → 0.0.4

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.
@@ -0,0 +1,142 @@
1
+ import * as path from 'node:path';
2
+ import { Flags } from '@oclif/core';
3
+ import { BaseCommand } from '../index.js';
4
+ import { getActiveProfile, loadAuthConfig } from '../util/token-storage.js';
5
+ import { loadGiteaToken, isGiteaTokenExpired, saveGiteaToken } from '../util/gitea-token-storage.js';
6
+ import { mintGiteaToken } from '../util/gitea-api.js';
7
+ import { isGitRepo, isWorkingTreeClean, gitFetch, canFastForward, hasDivergedFromRemote, gitMergeFfOnly, gitSetRemote, } from '../util/git-ops.js';
8
+ const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
9
+ export default class Sync extends BaseCommand {
10
+ static description = 'Continuously poll for workspace config updates and apply them locally';
11
+ static examples = [
12
+ '<%= config.bin %> sync --watch',
13
+ '<%= config.bin %> sync --watch --interval 10',
14
+ '<%= config.bin %> sync --watch --dir ./our-config --interval 30',
15
+ ];
16
+ static flags = {
17
+ dir: Flags.string({
18
+ description: 'Local directory to sync (defaults to QUONFIG_DIR env var)',
19
+ }),
20
+ interval: Flags.integer({
21
+ default: 60,
22
+ description: 'Poll interval in seconds',
23
+ }),
24
+ watch: Flags.boolean({
25
+ description: 'Run as a continuous polling daemon (required)',
26
+ required: true,
27
+ }),
28
+ };
29
+ workspaceId;
30
+ resolvedDir;
31
+ async run() {
32
+ const { flags } = await this.parse(Sync);
33
+ // Resolve directory
34
+ const dir = flags.dir || process.env.QUONFIG_DIR;
35
+ if (!dir) {
36
+ return this.err('No directory specified. Use --dir <path> or set QUONFIG_DIR.');
37
+ }
38
+ this.resolvedDir = path.resolve(dir);
39
+ // Resolve workspace ID
40
+ const authConfig = await loadAuthConfig();
41
+ if (!authConfig) {
42
+ return this.err('Not logged in. Please run `qfg login` first.');
43
+ }
44
+ const activeProfile = getActiveProfile();
45
+ const profile = authConfig.profiles[activeProfile] || authConfig.profiles[authConfig.defaultProfile || 'default'];
46
+ if (!profile) {
47
+ return this.err('No active profile found. Please run `qfg login` first.');
48
+ }
49
+ this.workspaceId = profile.workspace;
50
+ // Validate the directory is a git repo
51
+ const isRepo = await isGitRepo(this.resolvedDir);
52
+ if (!isRepo) {
53
+ return this.err(`${this.resolvedDir} is not a git repository. Run \`qfg pull --dir ${this.resolvedDir}\` first.`);
54
+ }
55
+ const intervalSec = flags.interval;
56
+ this.log(`Watching ${this.resolvedDir} — polling every ${intervalSec}s. Press Ctrl+C to stop.`);
57
+ // Ensure remote URL is up to date before starting
58
+ await this.ensureRemoteUrl();
59
+ // Run initial sync
60
+ await this.runSync();
61
+ // Poll loop
62
+ // eslint-disable-next-line no-constant-condition
63
+ while (true) {
64
+ await sleep(intervalSec * 1000);
65
+ await this.runSync();
66
+ }
67
+ }
68
+ async runSync() {
69
+ const timestamp = new Date().toISOString();
70
+ try {
71
+ // Ensure token is valid
72
+ await this.ensureRemoteUrl();
73
+ // Fetch
74
+ this.verboseLog('Sync', 'Fetching...');
75
+ await gitFetch(this.resolvedDir);
76
+ // Check if there's anything to do
77
+ const ff = await canFastForward(this.resolvedDir);
78
+ if (!ff) {
79
+ // Check if diverged
80
+ const diverged = await hasDivergedFromRemote(this.resolvedDir);
81
+ if (diverged) {
82
+ this.log(`[${timestamp}] Skipped: local commits diverge from remote. Resolve manually.`);
83
+ return;
84
+ }
85
+ this.log(`[${timestamp}] Already up to date.`);
86
+ return;
87
+ }
88
+ // Check working tree
89
+ const clean = await isWorkingTreeClean(this.resolvedDir);
90
+ if (!clean) {
91
+ this.log(`[${timestamp}] Skipped: local changes present. Commit or stash first.`);
92
+ return;
93
+ }
94
+ // Merge
95
+ const newCommits = await gitMergeFfOnly(this.resolvedDir);
96
+ if (newCommits.length > 0) {
97
+ this.log(`[${timestamp}] Pulled ${newCommits.length} new commit(s):`);
98
+ for (const msg of newCommits) {
99
+ this.log(` - ${msg}`);
100
+ }
101
+ }
102
+ else {
103
+ this.log(`[${timestamp}] Updated.`);
104
+ }
105
+ }
106
+ catch (err) {
107
+ const msg = err instanceof Error ? err.message : String(err);
108
+ if (this.looksLike401(err)) {
109
+ this.log(`[${timestamp}] Token expired, refreshing...`);
110
+ try {
111
+ await this.refreshToken();
112
+ await this.ensureRemoteUrl();
113
+ }
114
+ catch (refreshErr) {
115
+ this.log(`[${timestamp}] Warning: could not refresh token: ${String(refreshErr)}`);
116
+ }
117
+ }
118
+ else {
119
+ this.log(`[${timestamp}] Warning: sync failed: ${msg}`);
120
+ }
121
+ }
122
+ }
123
+ async ensureRemoteUrl() {
124
+ const entry = await loadGiteaToken(this.workspaceId);
125
+ if (!entry || isGiteaTokenExpired(entry)) {
126
+ await this.refreshToken();
127
+ return;
128
+ }
129
+ await gitSetRemote(this.resolvedDir, entry.repoUrl);
130
+ }
131
+ async refreshToken() {
132
+ const data = await mintGiteaToken(this.workspaceId, 'read', 'pull');
133
+ const entry = { token: data.token, repoUrl: data.repoUrl, expiresAt: data.expiresAt };
134
+ await saveGiteaToken(this.workspaceId, entry);
135
+ await gitSetRemote(this.resolvedDir, entry.repoUrl);
136
+ this.verboseLog('Sync', 'Token refreshed.');
137
+ }
138
+ looksLike401(err) {
139
+ const msg = err instanceof Error ? err.message : String(err);
140
+ return msg.includes('401') || msg.toLowerCase().includes('authentication failed');
141
+ }
142
+ }
@@ -38,7 +38,10 @@ export default class Whoami extends BaseCommand {
38
38
  // Display user information
39
39
  this.log(`Logged in as: ${userEmail || 'Unknown'}`);
40
40
  this.log(`Active profile: ${activeProfile}`);
41
- this.log(`Active workspace: ${profile.workspaceName || profile.workspace}`);
41
+ const workspaceDisplay = profile.workspaceName
42
+ ? `${profile.workspaceName} (${profile.workspace})`
43
+ : profile.workspace;
44
+ this.log(`Active workspace: ${workspaceDisplay}`);
42
45
  return {
43
46
  email: userEmail,
44
47
  loggedIn: true,
@@ -0,0 +1,13 @@
1
+ import type { JsonObj } from '../../result.js';
2
+ import { BaseCommand } from '../../index.js';
3
+ export default class WorkspaceBootstrap extends BaseCommand {
4
+ static description: string;
5
+ static examples: string[];
6
+ static flags: {
7
+ dir: import("@oclif/core/lib/interfaces/parser.js").OptionFlag<string | undefined, import("@oclif/core/lib/interfaces/parser.js").CustomOptions>;
8
+ force: import("@oclif/core/lib/interfaces/parser.js").BooleanFlag<boolean>;
9
+ 'skip-validate': import("@oclif/core/lib/interfaces/parser.js").BooleanFlag<boolean>;
10
+ };
11
+ run(): Promise<JsonObj | void>;
12
+ private remoteHasCommits;
13
+ }
@@ -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. environments.json — create if missing
58
- const envsPath = path.join(dir, 'environments.json');
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: 'environments.json', description: 'Create environments.json' });
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
- 'environments.json': JSON.stringify({ environments: [] }, null, 2) + '\n',
111
+ 'quonfig.json': JSON.stringify({ environments: [] }, null, 2) + '\n',
112
112
  };
113
113
  // Build a lookup for sample content
114
114
  const sampleContent = {};
@@ -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 \`environments.json\` at the repo root.
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
 
@@ -17,7 +17,8 @@ export const coerceIntoType = (type, value) => {
17
17
  }
18
18
  case 'int': {
19
19
  try {
20
- const int = BigInt(value);
20
+ const bigInt = BigInt(value);
21
+ const int = Number(bigInt);
21
22
  return [{ int }, TYPE_MAPPING[type]];
22
23
  }
23
24
  catch {
@@ -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 { decodeJWT, refreshAccessToken } from '../util/oauth-client.js';
5
- import { getActiveProfile, loadAuthConfig, loadTokens, saveTokens } from '../util/token-storage.js';
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
- // Check if token is expired and refresh if needed.
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
- const payload = decodeJWT(tokens.accessToken);
39
- if (typeof payload.exp === 'number') {
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
- // If we can't decode, fall back to stored expiresAt
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,5 @@
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
+ export declare function getValidAccessToken(): Promise<string>;
@@ -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;