@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.
- package/README.md +157 -21
- package/bin/dev.js +5 -9
- package/bin/dev.mjs +6 -0
- package/dist/codegen/zod-introspection.js +13 -4
- package/dist/commands/init.d.ts +13 -0
- package/dist/commands/init.js +83 -0
- 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 +176 -0
- package/dist/commands/serve.js +3 -1
- 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.d.ts +25 -0
- package/dist/init/init-workspace.js +162 -0
- package/dist/init/samples.d.ts +11 -0
- package/dist/init/samples.js +118 -0
- package/dist/init/schema.d.ts +9 -0
- package/dist/init/schema.js +334 -0
- package/dist/init/templates.d.ts +12 -0
- package/dist/init/templates.js +161 -0
- 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 +30 -11
- package/dist/verify/validate.d.ts +14 -0
- package/dist/verify/validate.js +224 -19
- package/oclif.manifest.json +298 -2
- 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
|
+
}
|
package/dist/commands/serve.js
CHANGED
|
@@ -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) ||
|
|
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
|
+
}
|
package/dist/commands/whoami.js
CHANGED
|
@@ -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
|
-
|
|
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;
|