@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,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
+ }
@@ -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;
@@ -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
+ };
@@ -14,6 +14,7 @@ export interface AuthConfig {
14
14
  [profileName: string]: {
15
15
  workspace: string;
16
16
  workspaceName?: string;
17
+ workspaceSlug?: string;
17
18
  };
18
19
  };
19
20
  }