@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,161 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Managed document templates for `qfg init`.
|
|
3
|
+
*
|
|
4
|
+
* These are overwritten on every run of `qfg init` — they are not
|
|
5
|
+
* user-editable. Each starts with a comment so users know not to
|
|
6
|
+
* hand-edit them.
|
|
7
|
+
*/
|
|
8
|
+
const MANAGED_HEADER = '<!-- Managed by `qfg init` — do not edit manually. Run `qfg init` to update. -->';
|
|
9
|
+
// ── README.md ──────────────────────────────────────────────────────────
|
|
10
|
+
export function readmeTemplate() {
|
|
11
|
+
return `${MANAGED_HEADER}
|
|
12
|
+
|
|
13
|
+
# Quonfig Workspace
|
|
14
|
+
|
|
15
|
+
This is a [Quonfig](https://quonfig.com) workspace repository. All configuration — feature flags, configs, segments, log levels, and schemas — is stored as JSON files tracked in git.
|
|
16
|
+
|
|
17
|
+
## Directory layout
|
|
18
|
+
|
|
19
|
+
| Directory | What goes here |
|
|
20
|
+
|-----------|---------------|
|
|
21
|
+
| \`configs/\` | Application configuration (strings, ints, JSON, etc.) |
|
|
22
|
+
| \`feature-flags/\` | Boolean or multi-variant feature flags |
|
|
23
|
+
| \`segments/\` | Audience segments for targeting rules |
|
|
24
|
+
| \`log-levels/\` | Dynamic log level configuration |
|
|
25
|
+
| \`schemas/\` | JSON Schemas that configs can reference for validation |
|
|
26
|
+
| \`schemas-protected/\` | Admin-only schemas |
|
|
27
|
+
|
|
28
|
+
## Quick start
|
|
29
|
+
|
|
30
|
+
\`\`\`bash
|
|
31
|
+
# Validate the workspace
|
|
32
|
+
qfg verify
|
|
33
|
+
|
|
34
|
+
# Create a new feature flag
|
|
35
|
+
qfg create my.new.flag --type=boolean-flag
|
|
36
|
+
|
|
37
|
+
# List all configs
|
|
38
|
+
qfg list
|
|
39
|
+
\`\`\`
|
|
40
|
+
|
|
41
|
+
## Schema
|
|
42
|
+
|
|
43
|
+
\`quonfig.schema.json\` in the repo root defines the config file format. Editors that support JSON Schema will provide autocomplete and validation automatically.
|
|
44
|
+
|
|
45
|
+
## Validation
|
|
46
|
+
|
|
47
|
+
Run \`qfg verify\` after every change. A git pre-commit hook is installed to do this automatically.
|
|
48
|
+
`;
|
|
49
|
+
}
|
|
50
|
+
// ── CLAUDE.md ──────────────────────────────────────────────────────────
|
|
51
|
+
export function claudeMdTemplate() {
|
|
52
|
+
return `${MANAGED_HEADER}
|
|
53
|
+
|
|
54
|
+
# Quonfig Workspace — Agent Reference
|
|
55
|
+
|
|
56
|
+
This is a Quonfig workspace repository. Configuration is stored as JSON files in git.
|
|
57
|
+
|
|
58
|
+
**After every change to this repo, run \`qfg verify\` to validate.**
|
|
59
|
+
|
|
60
|
+
## JSON Schema
|
|
61
|
+
|
|
62
|
+
The canonical schema for config files is \`quonfig.schema.json\` in the repo root. Use it as the source of truth for field names, types, enums, and constraints.
|
|
63
|
+
|
|
64
|
+
Config files can reference it for editor support:
|
|
65
|
+
|
|
66
|
+
\`\`\`json
|
|
67
|
+
{ "$schema": "./quonfig.schema.json", "key": "my.config", ... }
|
|
68
|
+
\`\`\`
|
|
69
|
+
|
|
70
|
+
## Directory Structure
|
|
71
|
+
|
|
72
|
+
Files must live in the directory matching their \`type\`. The filename (without \`.json\`) must exactly match the \`key\` field.
|
|
73
|
+
|
|
74
|
+
| Directory | Config \`type\` field | Description |
|
|
75
|
+
|-----------|---------------------|-------------|
|
|
76
|
+
| \`configs/\` | \`"config"\` | Application configuration values |
|
|
77
|
+
| \`feature-flags/\` | \`"feature_flag"\` | Boolean or multi-variant feature flags |
|
|
78
|
+
| \`segments/\` | \`"segment"\` | Audience segments for targeting rules |
|
|
79
|
+
| \`log-levels/\` | \`"log_level"\` | Dynamic log level configuration |
|
|
80
|
+
| \`schemas/\` | \`"schema"\` | JSON Schemas that configs can reference |
|
|
81
|
+
| \`schemas-protected/\` | \`"schema"\` | Protected schemas (admin-only) |
|
|
82
|
+
|
|
83
|
+
## Minimal Example
|
|
84
|
+
|
|
85
|
+
\`\`\`json
|
|
86
|
+
{
|
|
87
|
+
"$schema": "./quonfig.schema.json",
|
|
88
|
+
"key": "my-app.timeout",
|
|
89
|
+
"type": "config",
|
|
90
|
+
"valueType": "int",
|
|
91
|
+
"default": {
|
|
92
|
+
"rules": [
|
|
93
|
+
{
|
|
94
|
+
"criteria": [{ "operator": "ALWAYS_TRUE" }],
|
|
95
|
+
"value": { "type": "int", "value": 30 }
|
|
96
|
+
}
|
|
97
|
+
]
|
|
98
|
+
},
|
|
99
|
+
"environments": [],
|
|
100
|
+
"variants": []
|
|
101
|
+
}
|
|
102
|
+
\`\`\`
|
|
103
|
+
|
|
104
|
+
## Rules and Criteria
|
|
105
|
+
|
|
106
|
+
The \`default\` block (and each environment override) contains a \`rules\` array. Rules are evaluated in order; the first rule whose criteria all match wins. All criteria within one rule are ANDed. Use multiple rules for OR logic.
|
|
107
|
+
|
|
108
|
+
### Operator quick reference
|
|
109
|
+
|
|
110
|
+
| Category | Operators | Required fields |
|
|
111
|
+
|----------|-----------|----------------|
|
|
112
|
+
| Always match | \`ALWAYS_TRUE\` | (none) |
|
|
113
|
+
| Property string | \`PROP_IS_ONE_OF\`, \`PROP_IS_NOT_ONE_OF\`, \`PROP_STARTS_WITH_ONE_OF\`, \`PROP_ENDS_WITH_ONE_OF\`, \`PROP_CONTAINS_ONE_OF\` + negative variants | \`propertyName\`, \`valueToMatch\` |
|
|
114
|
+
| Property comparison | \`PROP_LESS_THAN\`, \`PROP_LESS_THAN_OR_EQUAL\`, \`PROP_GREATER_THAN\`, \`PROP_GREATER_THAN_OR_EQUAL\` | \`propertyName\`, \`valueToMatch\` |
|
|
115
|
+
| Property date | \`PROP_BEFORE\`, \`PROP_AFTER\` | \`propertyName\`, \`valueToMatch\` |
|
|
116
|
+
| Property regex | \`PROP_MATCHES\`, \`PROP_DOES_NOT_MATCH\` | \`propertyName\`, \`valueToMatch\` |
|
|
117
|
+
| Segment | \`IN_SEG\`, \`NOT_IN_SEG\` | \`valueToMatch\` (segment key) |
|
|
118
|
+
| Other | \`IN_INT_RANGE\`, \`LOOKUP_KEY_IN\`, \`LOOKUP_KEY_NOT_IN\` | \`valueToMatch\` |
|
|
119
|
+
|
|
120
|
+
See \`quonfig.schema.json\` for the full operator enum and value type definitions.
|
|
121
|
+
|
|
122
|
+
## Environments
|
|
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 \`quonfig.json\` at the repo root.
|
|
125
|
+
|
|
126
|
+
## Cross-File Validation Rules
|
|
127
|
+
|
|
128
|
+
These constraints span multiple files and are enforced by \`qfg verify\` (not expressible in the JSON Schema alone):
|
|
129
|
+
|
|
130
|
+
1. **Filename = key**: the JSON filename (without .json) must exactly match the \`key\` field
|
|
131
|
+
2. **No duplicate keys** across the entire workspace
|
|
132
|
+
3. **Segment references** (\`IN_SEG\`/\`NOT_IN_SEG\`) must point to existing segments in \`segments/\`
|
|
133
|
+
4. **Schema references** (\`schemaKey\`) must point to existing files in \`schemas/\` or \`schemas-protected/\`
|
|
134
|
+
`;
|
|
135
|
+
}
|
|
136
|
+
// ── AGENTS.md ──────────────────────────────────────────────────────────
|
|
137
|
+
export function agentsMdTemplate() {
|
|
138
|
+
return `${MANAGED_HEADER}
|
|
139
|
+
|
|
140
|
+
# Agent Instructions
|
|
141
|
+
|
|
142
|
+
This is a Quonfig workspace repository. See \`CLAUDE.md\` for the complete schema reference and validation rules.
|
|
143
|
+
|
|
144
|
+
## Key rules
|
|
145
|
+
|
|
146
|
+
1. **Always validate**: run \`qfg verify\` after any change to this repo.
|
|
147
|
+
2. Filename must match the \`key\` field (e.g. \`my.flag.json\` has \`"key": "my.flag"\`).
|
|
148
|
+
3. Files must be in the correct directory for their \`type\`.
|
|
149
|
+
4. See \`CLAUDE.md\` for the full list of value types, operators, and constraints.
|
|
150
|
+
`;
|
|
151
|
+
}
|
|
152
|
+
// ── Pre-commit hook ────────────────────────────────────────────────────
|
|
153
|
+
export const PRE_COMMIT_MARKER = '# --- qfg-verify ---';
|
|
154
|
+
export function preCommitHookContent() {
|
|
155
|
+
return `#!/bin/sh
|
|
156
|
+
${PRE_COMMIT_MARKER}
|
|
157
|
+
# Validate workspace before committing.
|
|
158
|
+
# Installed by \`qfg init\`. Safe to remove if you prefer manual validation.
|
|
159
|
+
qfg verify .
|
|
160
|
+
`;
|
|
161
|
+
}
|
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
|
+
};
|