@quonfig/cli 0.0.1 → 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.
Files changed (40) hide show
  1. package/README.md +157 -21
  2. package/bin/dev.js +5 -9
  3. package/bin/dev.mjs +6 -0
  4. package/dist/codegen/zod-introspection.js +13 -4
  5. package/dist/commands/init.d.ts +13 -0
  6. package/dist/commands/init.js +83 -0
  7. package/dist/commands/login.js +27 -6
  8. package/dist/commands/mcp.js +1 -1
  9. package/dist/commands/pull.d.ts +15 -0
  10. package/dist/commands/pull.js +176 -0
  11. package/dist/commands/serve.js +3 -1
  12. package/dist/commands/sync.d.ts +18 -0
  13. package/dist/commands/sync.js +142 -0
  14. package/dist/commands/whoami.js +4 -1
  15. package/dist/commands/workspace/bootstrap.d.ts +13 -0
  16. package/dist/commands/workspace/bootstrap.js +155 -0
  17. package/dist/init/init-workspace.d.ts +25 -0
  18. package/dist/init/init-workspace.js +162 -0
  19. package/dist/init/samples.d.ts +11 -0
  20. package/dist/init/samples.js +118 -0
  21. package/dist/init/schema.d.ts +9 -0
  22. package/dist/init/schema.js +334 -0
  23. package/dist/init/templates.d.ts +12 -0
  24. package/dist/init/templates.js +161 -0
  25. package/dist/util/coerce.js +2 -1
  26. package/dist/util/get-client.js +6 -44
  27. package/dist/util/get-valid-token.d.ts +5 -0
  28. package/dist/util/get-valid-token.js +44 -0
  29. package/dist/util/git-ops.d.ts +32 -0
  30. package/dist/util/git-ops.js +164 -0
  31. package/dist/util/gitea-api.d.ts +11 -0
  32. package/dist/util/gitea-api.js +45 -0
  33. package/dist/util/gitea-token-storage.d.ts +8 -0
  34. package/dist/util/gitea-token-storage.js +47 -0
  35. package/dist/util/token-storage.d.ts +1 -0
  36. package/dist/util/token-storage.js +30 -11
  37. package/dist/verify/validate.d.ts +14 -0
  38. package/dist/verify/validate.js +224 -19
  39. package/oclif.manifest.json +298 -2
  40. package/package.json +2 -2
@@ -0,0 +1,176 @@
1
+ import * as path from 'node:path';
2
+ import * as fs from 'node:fs';
3
+ import { Flags } from '@oclif/core';
4
+ import { BaseCommand } from '../index.js';
5
+ import { getActiveProfile, loadAuthConfig } from '../util/token-storage.js';
6
+ import { loadGiteaToken, isGiteaTokenExpired, saveGiteaToken } from '../util/gitea-token-storage.js';
7
+ import { mintAndStoreGiteaReadToken, mintGiteaToken } from '../util/gitea-api.js';
8
+ import { isGitRepo, getRemoteUrl, isWorkingTreeClean, gitFetch, gitSetRemote, canFastForward, hasDivergedFromRemote, gitMergeFfOnly, gitClone, displayUrl, } from '../util/git-ops.js';
9
+ export default class Pull extends BaseCommand {
10
+ static description = 'Clone or update a local directory from the workspace Gitea repo';
11
+ static examples = [
12
+ '<%= config.bin %> pull --dir ./our-config',
13
+ '<%= config.bin %> pull # uses QUONFIG_DIR env var',
14
+ ];
15
+ static flags = {
16
+ dir: Flags.string({
17
+ description: 'Local directory to clone/update (defaults to QUONFIG_DIR env var)',
18
+ }),
19
+ workspace: Flags.string({
20
+ description: 'Workspace ID (defaults to active profile)',
21
+ }),
22
+ };
23
+ async run() {
24
+ const { flags } = await this.parse(Pull);
25
+ // Resolve target directory
26
+ const dir = flags.dir || process.env.QUONFIG_DIR;
27
+ if (!dir) {
28
+ return this.err('No directory specified. Use --dir <path> or set QUONFIG_DIR.');
29
+ }
30
+ const resolvedDir = path.resolve(dir);
31
+ // Resolve workspace ID
32
+ let workspaceId = flags.workspace;
33
+ if (!workspaceId) {
34
+ const authConfig = await loadAuthConfig();
35
+ if (!authConfig) {
36
+ return this.err('Not logged in. Please run `qfg login` first.');
37
+ }
38
+ const activeProfile = getActiveProfile();
39
+ const profile = authConfig.profiles[activeProfile] || authConfig.profiles[authConfig.defaultProfile || 'default'];
40
+ if (!profile) {
41
+ return this.err('No active profile found. Please run `qfg login` first.');
42
+ }
43
+ workspaceId = profile.workspace;
44
+ }
45
+ this.verboseLog('Pull', { workspaceId, dir: resolvedDir });
46
+ // Get or mint Gitea read token
47
+ const repoUrl = await this.resolveRepoUrl(workspaceId);
48
+ // Perform git operation
49
+ const isRepo = await isGitRepo(resolvedDir);
50
+ if (!isRepo) {
51
+ // Clone fresh
52
+ this.log(`Cloning workspace into ${resolvedDir}...`);
53
+ try {
54
+ await gitClone(repoUrl, resolvedDir);
55
+ }
56
+ catch (err) {
57
+ // On 401, refresh token and retry once
58
+ if (this.looksLike401(err)) {
59
+ this.verboseLog('Pull', 'Got 401, refreshing Gitea token...');
60
+ const freshUrl = await this.refreshAndGetUrl(workspaceId);
61
+ await gitClone(freshUrl, resolvedDir);
62
+ }
63
+ else {
64
+ throw err;
65
+ }
66
+ }
67
+ this.log(`Cloned successfully into ${resolvedDir}`);
68
+ }
69
+ else {
70
+ // Existing repo — check remote
71
+ const existingRemote = await getRemoteUrl(resolvedDir);
72
+ const expectedUrlBase = stripAuth(repoUrl);
73
+ if (existingRemote && stripAuth(existingRemote) !== expectedUrlBase) {
74
+ return this.err(`Directory has a different git remote:\n existing: ${displayUrl(existingRemote)}\n expected: ${expectedUrlBase}\n\nResolve manually or use a different --dir.`);
75
+ }
76
+ // Update the remote URL to use current (possibly refreshed) token
77
+ await gitSetRemote(resolvedDir, repoUrl);
78
+ // Check working tree
79
+ const clean = await isWorkingTreeClean(resolvedDir);
80
+ if (!clean) {
81
+ return this.err('Local changes present — commit, stash, or run `git reset --hard origin/main` to discard.');
82
+ }
83
+ // Fetch
84
+ this.log('Fetching from remote...');
85
+ try {
86
+ await gitFetch(resolvedDir);
87
+ }
88
+ catch (err) {
89
+ if (this.looksLike401(err)) {
90
+ this.verboseLog('Pull', 'Got 401 on fetch, refreshing Gitea token...');
91
+ const freshUrl = await this.refreshAndGetUrl(workspaceId);
92
+ await gitSetRemote(resolvedDir, freshUrl);
93
+ await gitFetch(resolvedDir);
94
+ }
95
+ else {
96
+ throw err;
97
+ }
98
+ }
99
+ const ff = await canFastForward(resolvedDir);
100
+ const diverged = !ff && (await hasDivergedFromRemote(resolvedDir));
101
+ if (diverged) {
102
+ return this.err('Local commits diverge from remote — resolve manually.');
103
+ }
104
+ if (!ff) {
105
+ this.log('Already up to date.');
106
+ return { dir: resolvedDir, newCommits: [], workspaceId };
107
+ }
108
+ // Merge
109
+ const newCommits = await gitMergeFfOnly(resolvedDir);
110
+ if (newCommits.length > 0) {
111
+ this.log(`Pulled ${newCommits.length} new commit(s):`);
112
+ for (const msg of newCommits) {
113
+ this.log(` - ${msg}`);
114
+ }
115
+ }
116
+ else {
117
+ this.log('Updated successfully.');
118
+ }
119
+ }
120
+ // Write QUONFIG_DIR to ~/.quonfig/config if not set
121
+ await this.maybeWriteQuonfigDir(resolvedDir);
122
+ return { dir: resolvedDir, workspaceId };
123
+ }
124
+ async resolveRepoUrl(workspaceId) {
125
+ let entry = await loadGiteaToken(workspaceId);
126
+ if (!entry || isGiteaTokenExpired(entry)) {
127
+ this.verboseLog('Pull', 'Minting new Gitea read token...');
128
+ entry = await mintAndStoreGiteaReadToken(workspaceId);
129
+ }
130
+ return entry.repoUrl;
131
+ }
132
+ async refreshAndGetUrl(workspaceId) {
133
+ const data = await mintGiteaToken(workspaceId, 'read', 'pull');
134
+ const entry = { token: data.token, repoUrl: data.repoUrl, expiresAt: data.expiresAt };
135
+ await saveGiteaToken(workspaceId, entry);
136
+ return entry.repoUrl;
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
+ async maybeWriteQuonfigDir(dir) {
143
+ if (process.env.QUONFIG_DIR)
144
+ return; // already set via env
145
+ const os = await import('node:os');
146
+ const pathMod = await import('node:path');
147
+ const configFilePath = pathMod.join(os.homedir(), '.quonfig', 'config');
148
+ try {
149
+ const existing = await fs.promises.readFile(configFilePath, 'utf8').catch(() => '');
150
+ if (existing.includes('QUONFIG_DIR') || existing.includes('quonfig_dir'))
151
+ return;
152
+ const line = `\nQUONFIG_DIR = ${dir}\n`;
153
+ await fs.promises.appendFile(configFilePath, line, 'utf8');
154
+ this.verboseLog('Pull', `Wrote QUONFIG_DIR to ${configFilePath}`);
155
+ }
156
+ catch {
157
+ // Non-fatal
158
+ }
159
+ }
160
+ }
161
+ /**
162
+ * Strip auth credentials from a git URL for comparison purposes.
163
+ * Converts `https://user:token@host/path` to `https://host/path`.
164
+ */
165
+ function stripAuth(url) {
166
+ try {
167
+ const u = new URL(url);
168
+ u.username = '';
169
+ u.password = '';
170
+ return u.toString();
171
+ }
172
+ catch {
173
+ // Not a valid URL (e.g. SSH) — return as-is
174
+ return url;
175
+ }
176
+ }
@@ -47,7 +47,9 @@ export default class Serve extends BaseCommand {
47
47
  }
48
48
  catch (error_) {
49
49
  const error = error_;
50
- if (/No projectEnvId found in config/.test(error.message) || /Unexpected end of JSON input/.test(error.message)) {
50
+ if (/No projectEnvId found in config/.test(error.message) ||
51
+ /is not iterable/.test(error.message) ||
52
+ /Unexpected end of JSON input/.test(error.message)) {
51
53
  return this.error(`${error.message}\nYour download file seems invalid or corrupt. Please redownload your datafile.`);
52
54
  }
53
55
  return this.error(error);
@@ -0,0 +1,18 @@
1
+ import type { JsonObj } from '../result.js';
2
+ import { BaseCommand } from '../index.js';
3
+ export default class Sync 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
+ interval: import("@oclif/core/lib/interfaces/parser.js").OptionFlag<number, import("@oclif/core/lib/interfaces/parser.js").CustomOptions>;
9
+ watch: import("@oclif/core/lib/interfaces/parser.js").BooleanFlag<boolean>;
10
+ };
11
+ private workspaceId;
12
+ private resolvedDir;
13
+ run(): Promise<JsonObj | void>;
14
+ private runSync;
15
+ private ensureRemoteUrl;
16
+ private refreshToken;
17
+ private looksLike401;
18
+ }
@@ -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
+ }
@@ -0,0 +1,25 @@
1
+ /**
2
+ * Core logic for `qfg init`.
3
+ *
4
+ * Uses a plan/execute pattern so --dry-run can show what *would* happen
5
+ * without writing anything.
6
+ */
7
+ export interface InitOptions {
8
+ dir: string;
9
+ /** true = force include samples, false = force skip, undefined = auto-detect */
10
+ samples: boolean | undefined;
11
+ dryRun: boolean;
12
+ }
13
+ export type ActionKind = 'git-init' | 'create-dir' | 'write-file' | 'skip-file' | 'create-hook' | 'update-hook' | 'skip-hook';
14
+ export interface InitAction {
15
+ kind: ActionKind;
16
+ path: string;
17
+ description: string;
18
+ }
19
+ export interface InitPlan {
20
+ actions: InitAction[];
21
+ isFirstTime: boolean;
22
+ samplesIncluded: boolean;
23
+ }
24
+ export declare function planInit(options: InitOptions): InitPlan;
25
+ export declare function executeInit(plan: InitPlan, dir: string): void;