@quonfig/cli 0.0.22 → 0.0.24
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 +36 -36
- package/dist/commands/log-level.js +1 -3
- package/dist/commands/login.js +30 -1
- package/dist/commands/pull.js +2 -2
- package/dist/commands/push.d.ts +6 -14
- package/dist/commands/push.js +54 -99
- package/dist/commands/workspace/switch.js +1 -4
- package/dist/init/schema.js +4 -2
- package/dist/migrate/push-to-cloud.js +3 -6
- package/dist/migrate/sources/launch/zod-to-json-schema.js +130 -132
- package/dist/migrate/sources/launch.js +1 -1
- package/dist/push/bare-path-diff.js +19 -17
- package/dist/push/config-push-client.d.ts +18 -0
- package/dist/push/config-push-client.js +82 -0
- package/dist/push/diff-summary.d.ts +11 -0
- package/dist/push/diff-summary.js +1 -8
- package/dist/push/run-push.d.ts +53 -13
- package/dist/push/run-push.js +44 -132
- package/dist/util/gitea-token-storage.js +2 -2
- package/dist/util/open-browser.d.ts +1 -0
- package/dist/util/open-browser.js +27 -0
- package/dist/util/quonfig-home.d.ts +6 -0
- package/dist/util/quonfig-home.js +8 -0
- package/dist/util/resolve-workspace.js +6 -2
- package/dist/util/token-storage.js +2 -2
- package/dist/verify/validate.js +2 -2
- package/oclif.manifest.json +1 -1
- package/package.json +1 -1
|
@@ -54,35 +54,37 @@ export async function computeBarePathDiff(localDir, remoteUrl) {
|
|
|
54
54
|
}
|
|
55
55
|
throw error;
|
|
56
56
|
}
|
|
57
|
-
const localFiles =
|
|
58
|
-
const remoteFiles =
|
|
57
|
+
const localFiles = collectFiles(localDir);
|
|
58
|
+
const remoteFiles = collectFiles(scratchDir);
|
|
59
59
|
const deltas = [];
|
|
60
|
-
for (const [rel,
|
|
61
|
-
const
|
|
62
|
-
if (
|
|
63
|
-
deltas.push({ kind: 'added', path: rel });
|
|
60
|
+
for (const [rel, local] of localFiles) {
|
|
61
|
+
const remote = remoteFiles.get(rel);
|
|
62
|
+
if (remote === undefined) {
|
|
63
|
+
deltas.push({ kind: 'added', path: rel, afterJson: local.content });
|
|
64
64
|
}
|
|
65
|
-
else if (
|
|
66
|
-
deltas.push({ kind: 'modified', path: rel });
|
|
65
|
+
else if (remote.hash !== local.hash) {
|
|
66
|
+
deltas.push({ kind: 'modified', path: rel, beforeJson: remote.content, afterJson: local.content });
|
|
67
67
|
}
|
|
68
68
|
}
|
|
69
|
-
for (const rel of remoteFiles
|
|
69
|
+
for (const [rel, remote] of remoteFiles) {
|
|
70
70
|
if (!localFiles.has(rel)) {
|
|
71
|
-
deltas.push({ kind: 'deleted', path: rel });
|
|
71
|
+
deltas.push({ kind: 'deleted', path: rel, beforeJson: remote.content });
|
|
72
72
|
}
|
|
73
73
|
}
|
|
74
74
|
return { deltas, scratchDir, totalRemoteFiles: remoteFiles.size };
|
|
75
75
|
}
|
|
76
76
|
/**
|
|
77
|
-
* Walk `root` and return a map of relative-path -> content
|
|
78
|
-
* non-dotfile file under it.
|
|
77
|
+
* Walk `root` and return a map of relative-path -> {content, hash} for every
|
|
78
|
+
* non-dotfile file under it. The content string is needed by qfg-azk.13's
|
|
79
|
+
* `configs.push` wire shape; the hash is used to short-circuit the
|
|
80
|
+
* unchanged-file case without comparing full bodies.
|
|
79
81
|
*
|
|
80
82
|
* We skip any entry whose name starts with a `.`, which covers `.git/`,
|
|
81
83
|
* `.DS_Store`, and the transient `.quonfig-push-clone-*` scratch dirs that
|
|
82
84
|
* `run-push.ts` creates next to the local workspace. Keeping the rule simple
|
|
83
85
|
* and symmetric on both sides prevents one-off false positives.
|
|
84
86
|
*/
|
|
85
|
-
function
|
|
87
|
+
function collectFiles(root) {
|
|
86
88
|
const out = new Map();
|
|
87
89
|
if (!fs.existsSync(root))
|
|
88
90
|
return out;
|
|
@@ -104,8 +106,8 @@ function collectFilesWithHash(root) {
|
|
|
104
106
|
}
|
|
105
107
|
else if (entry.isFile() || entry.isSymbolicLink()) {
|
|
106
108
|
try {
|
|
107
|
-
const
|
|
108
|
-
out.set(rel,
|
|
109
|
+
const content = fs.readFileSync(full, 'utf8');
|
|
110
|
+
out.set(rel, { content, hash: hashString(content) });
|
|
109
111
|
}
|
|
110
112
|
catch {
|
|
111
113
|
/* ignore unreadable files — they won't appear on either side */
|
|
@@ -121,6 +123,6 @@ function collectFilesWithHash(root) {
|
|
|
121
123
|
* fingerprint so we can say "file A on local matches file A on remote". The
|
|
122
124
|
* contents are small JSON in practice, so sha1 is plenty fast.
|
|
123
125
|
*/
|
|
124
|
-
function
|
|
125
|
-
return createHash('sha1').update(
|
|
126
|
+
function hashString(s) {
|
|
127
|
+
return createHash('sha1').update(s).digest('hex');
|
|
126
128
|
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* HTTP client for the `configs.push` oRPC procedure (qfg-azk.13).
|
|
3
|
+
*
|
|
4
|
+
* Server-side spec lives at
|
|
5
|
+
* `app-quonfig/src/lib/orpc/routes/config-push-handler.ts`. We translate the
|
|
6
|
+
* oRPC error shape into a tagged union the CLI can pattern-match on:
|
|
7
|
+
*
|
|
8
|
+
* - 200 → `{kind: 'success', commitSha}`
|
|
9
|
+
* - 403 with `data.denials` → `{kind: 'denied', denials}`
|
|
10
|
+
* - 409 → `{kind: 'conflict', message}`
|
|
11
|
+
* - 400 → `{kind: 'bad-request', message}`
|
|
12
|
+
* - anything else → throws so runPush surfaces a generic PushFatalError.
|
|
13
|
+
*
|
|
14
|
+
* The auth header is a WorkOS access token (or a `qf_uk_*` API key when
|
|
15
|
+
* `QUONFIG_API_KEY` is set in the environment); see `getValidAccessToken`.
|
|
16
|
+
*/
|
|
17
|
+
import type { ConfigPushInput, ConfigPushResult } from './run-push.js';
|
|
18
|
+
export declare const callConfigsPush: (input: ConfigPushInput) => Promise<ConfigPushResult>;
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* HTTP client for the `configs.push` oRPC procedure (qfg-azk.13).
|
|
3
|
+
*
|
|
4
|
+
* Server-side spec lives at
|
|
5
|
+
* `app-quonfig/src/lib/orpc/routes/config-push-handler.ts`. We translate the
|
|
6
|
+
* oRPC error shape into a tagged union the CLI can pattern-match on:
|
|
7
|
+
*
|
|
8
|
+
* - 200 → `{kind: 'success', commitSha}`
|
|
9
|
+
* - 403 with `data.denials` → `{kind: 'denied', denials}`
|
|
10
|
+
* - 409 → `{kind: 'conflict', message}`
|
|
11
|
+
* - 400 → `{kind: 'bad-request', message}`
|
|
12
|
+
* - anything else → throws so runPush surfaces a generic PushFatalError.
|
|
13
|
+
*
|
|
14
|
+
* The auth header is a WorkOS access token (or a `qf_uk_*` API key when
|
|
15
|
+
* `QUONFIG_API_KEY` is set in the environment); see `getValidAccessToken`.
|
|
16
|
+
*/
|
|
17
|
+
import { getApiUrl } from '../util/domain-urls.js';
|
|
18
|
+
import { getValidAccessToken } from '../util/get-valid-token.js';
|
|
19
|
+
export const callConfigsPush = async (input) => {
|
|
20
|
+
const accessToken = await getValidAccessToken();
|
|
21
|
+
const apiUrl = getApiUrl();
|
|
22
|
+
const res = await fetch(`${apiUrl}/api/v1/configs/push`, {
|
|
23
|
+
method: 'POST',
|
|
24
|
+
headers: {
|
|
25
|
+
Authorization: `Bearer ${accessToken}`,
|
|
26
|
+
'Content-Type': 'application/json',
|
|
27
|
+
},
|
|
28
|
+
body: JSON.stringify({ json: input }),
|
|
29
|
+
});
|
|
30
|
+
if (res.ok) {
|
|
31
|
+
const body = (await res.json());
|
|
32
|
+
const commitSha = body.json?.commitSha;
|
|
33
|
+
if (!commitSha) {
|
|
34
|
+
throw new Error(`configs.push returned 200 but no commitSha (body=${JSON.stringify(body)})`);
|
|
35
|
+
}
|
|
36
|
+
return { kind: 'success', commitSha };
|
|
37
|
+
}
|
|
38
|
+
const text = await res.text().catch(() => '');
|
|
39
|
+
let parsed;
|
|
40
|
+
try {
|
|
41
|
+
const body = JSON.parse(text);
|
|
42
|
+
parsed = body.json ?? body;
|
|
43
|
+
}
|
|
44
|
+
catch {
|
|
45
|
+
/* keep parsed undefined */
|
|
46
|
+
}
|
|
47
|
+
const message = parsed?.message ?? text ?? `HTTP ${res.status}`;
|
|
48
|
+
if (res.status === 403) {
|
|
49
|
+
const denials = extractDenials(parsed?.data);
|
|
50
|
+
if (denials.length > 0) {
|
|
51
|
+
return { kind: 'denied', denials };
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
if (res.status === 409) {
|
|
55
|
+
return { kind: 'conflict', message };
|
|
56
|
+
}
|
|
57
|
+
if (res.status === 400) {
|
|
58
|
+
return { kind: 'bad-request', message };
|
|
59
|
+
}
|
|
60
|
+
throw new Error(`configs.push failed (HTTP ${res.status}): ${message}`);
|
|
61
|
+
};
|
|
62
|
+
function extractDenials(data) {
|
|
63
|
+
if (!data || typeof data !== 'object')
|
|
64
|
+
return [];
|
|
65
|
+
const denials = data.denials;
|
|
66
|
+
if (!Array.isArray(denials))
|
|
67
|
+
return [];
|
|
68
|
+
const out = [];
|
|
69
|
+
for (const raw of denials) {
|
|
70
|
+
if (!raw || typeof raw !== 'object')
|
|
71
|
+
continue;
|
|
72
|
+
const r = raw;
|
|
73
|
+
if (typeof r.path !== 'string' || typeof r.requiredPermission !== 'string')
|
|
74
|
+
continue;
|
|
75
|
+
out.push({
|
|
76
|
+
path: r.path,
|
|
77
|
+
reason: typeof r.reason === 'string' ? r.reason : `Missing required permission: ${r.requiredPermission}`,
|
|
78
|
+
requiredPermission: r.requiredPermission,
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
return out;
|
|
82
|
+
}
|
|
@@ -14,6 +14,17 @@
|
|
|
14
14
|
*/
|
|
15
15
|
/** A single file-level change between the local working tree and remote HEAD. */
|
|
16
16
|
export interface FileDelta {
|
|
17
|
+
/**
|
|
18
|
+
* Local content for `added`/`modified`. Absent for `deleted`. Required by
|
|
19
|
+
* the `configs.push` server endpoint when sending the delta over the wire.
|
|
20
|
+
*/
|
|
21
|
+
afterJson?: string;
|
|
22
|
+
/**
|
|
23
|
+
* Remote content for `modified`/`deleted`. Absent for `added`. Required by
|
|
24
|
+
* the `configs.push` server endpoint to deep-diff per-environment rule
|
|
25
|
+
* changes for authorization.
|
|
26
|
+
*/
|
|
27
|
+
beforeJson?: string;
|
|
17
28
|
/**
|
|
18
29
|
* `added` — present locally, absent on remote
|
|
19
30
|
* `modified` — present in both, content differs
|
|
@@ -16,14 +16,7 @@
|
|
|
16
16
|
* Top-level directories we present as named rows. Anything else funnels into
|
|
17
17
|
* `other`. Keeping this closed-set avoids one-off typos silently looking fine.
|
|
18
18
|
*/
|
|
19
|
-
const KNOWN_GROUPS = [
|
|
20
|
-
'configs',
|
|
21
|
-
'feature-flags',
|
|
22
|
-
'segments',
|
|
23
|
-
'schemas',
|
|
24
|
-
'schemas-protected',
|
|
25
|
-
'log-levels',
|
|
26
|
-
];
|
|
19
|
+
const KNOWN_GROUPS = ['configs', 'feature-flags', 'segments', 'schemas', 'schemas-protected', 'log-levels'];
|
|
27
20
|
const DESTRUCTIVE_DELETE_COUNT = 10;
|
|
28
21
|
const DESTRUCTIVE_DELETE_RATIO = 0.25;
|
|
29
22
|
function emptyCounts() {
|
package/dist/push/run-push.d.ts
CHANGED
|
@@ -18,7 +18,6 @@
|
|
|
18
18
|
* `--yes` skips the normal Y/N confirmation. It NEVER skips a typed-slug
|
|
19
19
|
* confirmation — that prompt is the destructive-change brake.
|
|
20
20
|
*/
|
|
21
|
-
import { PushIdentity } from '../util/clone-and-stack-push.js';
|
|
22
21
|
import { FileDelta } from './diff-summary.js';
|
|
23
22
|
export interface GiteaTokenMintResult {
|
|
24
23
|
expiresAt: string | null;
|
|
@@ -32,23 +31,52 @@ export interface GitOps {
|
|
|
32
31
|
countFilesInRemote(dir: string): Promise<number>;
|
|
33
32
|
/**
|
|
34
33
|
* Produce the list of file deltas to render in the Guard 3 summary and
|
|
35
|
-
*
|
|
36
|
-
*
|
|
34
|
+
* send to the server. Each delta carries before/after JSON content per
|
|
35
|
+
* the `configs.push` wire shape (qfg-azk.13). For the clone path this is
|
|
36
|
+
* HEAD vs origin/main; for the bare path it's local vs probe-clone.
|
|
37
37
|
*/
|
|
38
38
|
diffHeadVsOrigin(dir: string): Promise<FileDelta[]>;
|
|
39
39
|
/** `git fetch origin` in the given dir. */
|
|
40
40
|
fetch(dir: string): Promise<void>;
|
|
41
|
-
/** Read local git config for author identity (used for bare-path commits). */
|
|
42
|
-
getLocalAuthor(dir: string): Promise<PushIdentity | undefined>;
|
|
43
41
|
/** Returns the `remote.origin.url` for the repo, or undefined if unset / not a repo. */
|
|
44
42
|
getRemoteOriginUrl(dir: string): Promise<string | undefined>;
|
|
45
43
|
/** Returns true if the dir has a `.git/` (worktree or repo). */
|
|
46
44
|
isGitRepo(dir: string): Promise<boolean>;
|
|
47
|
-
/** `git push origin main`. Throws with a PushConflict-ish message on non-ff. */
|
|
48
|
-
push(dir: string): Promise<void>;
|
|
49
45
|
/** Set origin to `url` (add if missing, set-url if present). */
|
|
50
46
|
setRemoteOrigin(dir: string, url: string): Promise<void>;
|
|
51
47
|
}
|
|
48
|
+
/** Server-side `kind` enum (matches `FileDeltaSchema` in app-quonfig). */
|
|
49
|
+
export type ServerFileKind = 'add' | 'delete' | 'modify';
|
|
50
|
+
export interface ServerFileDelta {
|
|
51
|
+
afterJson?: string;
|
|
52
|
+
beforeJson?: string;
|
|
53
|
+
kind: ServerFileKind;
|
|
54
|
+
path: string;
|
|
55
|
+
}
|
|
56
|
+
export interface ConfigPushInput {
|
|
57
|
+
expectedSha?: string;
|
|
58
|
+
files: ServerFileDelta[];
|
|
59
|
+
message?: string;
|
|
60
|
+
workspaceId: string;
|
|
61
|
+
}
|
|
62
|
+
export type ConfigPushResult = {
|
|
63
|
+
kind: 'bad-request';
|
|
64
|
+
message: string;
|
|
65
|
+
} | {
|
|
66
|
+
kind: 'conflict';
|
|
67
|
+
message: string;
|
|
68
|
+
} | {
|
|
69
|
+
commitSha: string;
|
|
70
|
+
kind: 'success';
|
|
71
|
+
} | {
|
|
72
|
+
denials: PushDenial[];
|
|
73
|
+
kind: 'denied';
|
|
74
|
+
};
|
|
75
|
+
export interface PushDenial {
|
|
76
|
+
path: string;
|
|
77
|
+
reason: string;
|
|
78
|
+
requiredPermission: string;
|
|
79
|
+
}
|
|
52
80
|
export type ConfirmIO = {
|
|
53
81
|
input?: NodeJS.ReadableStream;
|
|
54
82
|
output?: NodeJS.WritableStream;
|
|
@@ -70,18 +98,30 @@ export interface RunPushInput {
|
|
|
70
98
|
export interface RunPushDeps {
|
|
71
99
|
/** Optional io streams for confirmation prompts. Defaults to process stdin/stdout. */
|
|
72
100
|
confirmIO?: ConfirmIO;
|
|
73
|
-
/**
|
|
74
|
-
* Copy files from `sourceDir` into `destDir`, skipping `.git/`. Used by the
|
|
75
|
-
* bare path as the `applyDelta` callback handed to `cloneAndStackPush`.
|
|
76
|
-
*/
|
|
77
|
-
copyDirMirror(sourceDir: string, destDir: string): Promise<void>;
|
|
78
101
|
/** Error logger; defaults to console.error. */
|
|
79
102
|
errLog?: (line: string) => void;
|
|
80
103
|
gitOps: GitOps;
|
|
81
104
|
/** Logger; defaults to console.log. */
|
|
82
105
|
log?: (line: string) => void;
|
|
83
|
-
/**
|
|
106
|
+
/**
|
|
107
|
+
* Resolve the backend's identity for this workspace. Returns repoUrl,
|
|
108
|
+
* workspaceSlug, workspaceId — used by the identity check and (for
|
|
109
|
+
* read-only auth) by the bare-path probe-clone and clone-path fetch.
|
|
110
|
+
*
|
|
111
|
+
* Named `mintWriteToken` for historical reasons; as of qfg-azk.13 the
|
|
112
|
+
* push code path no longer mints a write-scoped Gitea token (server-side
|
|
113
|
+
* commit via `configs.push`), so the real implementation now mints a
|
|
114
|
+
* read-scope token. The name is preserved to keep the test harness shape
|
|
115
|
+
* stable across the qfg-azk.13 transition.
|
|
116
|
+
*/
|
|
84
117
|
mintWriteToken(requestedTarget: string): Promise<GiteaTokenMintResult>;
|
|
118
|
+
/**
|
|
119
|
+
* Call the server-side `configs.push` oRPC procedure. Returns a tagged
|
|
120
|
+
* union so callers can distinguish success / per-file denials / a stale
|
|
121
|
+
* expectedSha conflict / a path allow-list violation without parsing
|
|
122
|
+
* HTTP status codes.
|
|
123
|
+
*/
|
|
124
|
+
pushToServer(input: ConfigPushInput): Promise<ConfigPushResult>;
|
|
85
125
|
/** Run `qfg validate` semantics. Should throw on error. */
|
|
86
126
|
validate(dir: string): Promise<{
|
|
87
127
|
errors: string[];
|
package/dist/push/run-push.js
CHANGED
|
@@ -19,8 +19,6 @@
|
|
|
19
19
|
* confirmation — that prompt is the destructive-change brake.
|
|
20
20
|
*/
|
|
21
21
|
import * as fs from 'node:fs';
|
|
22
|
-
import * as path from 'node:path';
|
|
23
|
-
import { MIGRATOR_IDENTITY, cloneAndStackPush } from '../util/clone-and-stack-push.js';
|
|
24
22
|
import { readWorkspaceSlug, writeWorkspaceSlug } from '../util/quonfig-json.js';
|
|
25
23
|
import { checkIdentity } from './identity-check.js';
|
|
26
24
|
import { confirmTypedSlug, confirmYesNo } from './confirm.js';
|
|
@@ -118,19 +116,18 @@ export async function runPush(input, deps) {
|
|
|
118
116
|
// repo into a scratch dir inside `.quonfig-push-clone/` and stack a commit
|
|
119
117
|
// that mirrors the local dir's contents on top.
|
|
120
118
|
const isClonePath = hasGit && remoteOriginUrl !== undefined && sameRepo(remoteOriginUrl, backend.repoUrl);
|
|
119
|
+
const dispatchedAs = isClonePath ? 'clone-path' : 'bare-path';
|
|
120
|
+
// Clone path: align origin with the backend so the gitOps.fetch + diff
|
|
121
|
+
// walk authenticates against the read URL we minted. The bare path's
|
|
122
|
+
// probe-clone path bypasses local origin entirely.
|
|
121
123
|
if (isClonePath) {
|
|
122
|
-
|
|
124
|
+
await deps.gitOps.setRemoteOrigin(input.dir, backend.repoUrl);
|
|
125
|
+
log('Fetching from remote...');
|
|
126
|
+
await deps.gitOps.fetch(input.dir);
|
|
123
127
|
}
|
|
124
|
-
return doBarePath(input, deps, backend, requiresTypedSlug, repoPinSlug === undefined, log);
|
|
125
|
-
}
|
|
126
|
-
async function doClonePath(input, deps, backend, requiresTypedSlug, unpinned, log) {
|
|
127
|
-
// Point origin at the authenticated URL so `git fetch` / `git push` work
|
|
128
|
-
// without touching global credential helpers.
|
|
129
|
-
await deps.gitOps.setRemoteOrigin(input.dir, backend.repoUrl);
|
|
130
|
-
log('Fetching from remote...');
|
|
131
|
-
await deps.gitOps.fetch(input.dir);
|
|
132
128
|
const deltas = await deps.gitOps.diffHeadVsOrigin(input.dir);
|
|
133
129
|
const totalFilesInRemote = await deps.gitOps.countFilesInRemote(input.dir);
|
|
130
|
+
const unpinned = repoPinSlug === undefined;
|
|
134
131
|
const summary = summarizeDiff(deltas, { totalFilesInRemote, unpinned });
|
|
135
132
|
log('');
|
|
136
133
|
log(summary.renderText({
|
|
@@ -140,7 +137,6 @@ async function doClonePath(input, deps, backend, requiresTypedSlug, unpinned, lo
|
|
|
140
137
|
localDir: input.dir,
|
|
141
138
|
}));
|
|
142
139
|
log('');
|
|
143
|
-
// Short-circuit: literally nothing to push.
|
|
144
140
|
if (summary.totals.filesTouched === 0) {
|
|
145
141
|
return { kind: 'no-op', reason: 'Local tree matches remote HEAD. Nothing to push.' };
|
|
146
142
|
}
|
|
@@ -155,141 +151,57 @@ async function doClonePath(input, deps, backend, requiresTypedSlug, unpinned, lo
|
|
|
155
151
|
log('Aborted, nothing pushed.');
|
|
156
152
|
return { kind: 'aborted', reason: 'user declined at confirm prompt' };
|
|
157
153
|
}
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
154
|
+
const baseMessage = input.message ?? `qfg push: ${summary.totals.filesTouched} file change(s)`;
|
|
155
|
+
const commitMessage = withPushedViaTrailer(baseMessage);
|
|
156
|
+
const serverFiles = deltas.map((d) => ({
|
|
157
|
+
path: d.path,
|
|
158
|
+
kind: toServerKind(d.kind),
|
|
159
|
+
...(d.beforeJson === undefined ? {} : { beforeJson: d.beforeJson }),
|
|
160
|
+
...(d.afterJson === undefined ? {} : { afterJson: d.afterJson }),
|
|
161
|
+
}));
|
|
162
|
+
log('Sending push to Quonfig cloud...');
|
|
163
|
+
const result = await deps.pushToServer({
|
|
164
|
+
workspaceId: backend.workspaceId,
|
|
165
|
+
files: serverFiles,
|
|
166
|
+
message: commitMessage,
|
|
167
|
+
});
|
|
168
|
+
if (result.kind === 'denied') {
|
|
169
|
+
errLog(`Push denied for ${result.denials.length} file(s):`);
|
|
170
|
+
for (const d of result.denials) {
|
|
171
|
+
errLog(` ${d.path}: missing permission ${d.requiredPermission}`);
|
|
166
172
|
}
|
|
167
|
-
throw new PushFatalError(`Push
|
|
173
|
+
throw new PushFatalError(`Push denied for ${result.denials.length} file(s). See errors above.`, 'PUSH_DENIED');
|
|
174
|
+
}
|
|
175
|
+
if (result.kind === 'conflict') {
|
|
176
|
+
throw new PushFatalError(`Remote moved while preparing this push (${result.message}). Run \`qfg pull\` and retry.`, 'CONFLICT');
|
|
177
|
+
}
|
|
178
|
+
if (result.kind === 'bad-request') {
|
|
179
|
+
throw new PushFatalError(`Push rejected by server: ${result.message}`, 'BAD_REQUEST');
|
|
168
180
|
}
|
|
169
|
-
// Offer to pin the workspace slug in quonfig.json if missing. Non-fatal.
|
|
170
181
|
if (unpinned && !input.noPinWrite) {
|
|
171
182
|
try {
|
|
172
183
|
await writeWorkspaceSlug(input.dir, backend.workspaceSlug);
|
|
173
184
|
log(`Wrote workspace = "${backend.workspaceSlug}" into quonfig.json`);
|
|
174
185
|
}
|
|
175
186
|
catch {
|
|
176
|
-
|
|
187
|
+
/* non-fatal; pin is advisory */
|
|
177
188
|
}
|
|
178
189
|
}
|
|
179
|
-
return { kind: 'pushed', dispatchedAs:
|
|
190
|
+
return { kind: 'pushed', dispatchedAs, commitSha: result.commitSha };
|
|
180
191
|
}
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
// Local author for the migrated commit. Prefer the local git config in
|
|
186
|
-
// `input.dir` so attribution matches what the user has set. If it is not
|
|
187
|
-
// available, fall back to the migrator identity but tell the user — the plan
|
|
188
|
-
// requires this be loud.
|
|
189
|
-
let author = MIGRATOR_IDENTITY;
|
|
190
|
-
try {
|
|
191
|
-
const local = await deps.gitOps.getLocalAuthor(input.dir);
|
|
192
|
-
if (local && local.name && local.email) {
|
|
193
|
-
author = local;
|
|
194
|
-
}
|
|
195
|
-
else {
|
|
196
|
-
log(`Warning: no local git user.name/user.email in ${input.dir}; commits will be attributed to ${MIGRATOR_IDENTITY.name} <${MIGRATOR_IDENTITY.email}>.`);
|
|
197
|
-
}
|
|
198
|
-
}
|
|
199
|
-
catch {
|
|
200
|
-
log(`Warning: could not read local git user identity; commits will be attributed to ${MIGRATOR_IDENTITY.name} <${MIGRATOR_IDENTITY.email}>.`);
|
|
201
|
-
}
|
|
202
|
-
// We need the deltas for the Guard 3 summary BEFORE we decide to commit and
|
|
203
|
-
// push. To produce them we need the scratch clone in hand (so we can compare
|
|
204
|
-
// file-by-file). We clone into the scratch dir, take the diff, show the
|
|
205
|
-
// summary, confirm, then apply the delta via `cloneAndStackPush`.
|
|
206
|
-
//
|
|
207
|
-
// Rather than re-implement cloneAndStackPush's clone step here, we let it
|
|
208
|
-
// own the clone-commit-push pipeline and pass a deltas callback that runs
|
|
209
|
-
// AFTER the clone (it receives the localDir/scratchDir) but BEFORE the
|
|
210
|
-
// commit. `cloneAndStackPush` only exposes an `applyDelta` hook (write files
|
|
211
|
-
// then return), so we split the flow: first a probe clone to take the
|
|
212
|
-
// diff, then the real clone-and-stack-push. This is simpler than rewiring
|
|
213
|
-
// cloneAndStackPush's API.
|
|
214
|
-
// Probe clone.
|
|
215
|
-
await deps.gitOps.fetch(input.dir) // no-op for bare path if not a repo; deps.gitOps may short-circuit
|
|
216
|
-
.catch(() => {
|
|
217
|
-
// bare-path doesn't require a local fetch — swallow the error
|
|
218
|
-
});
|
|
219
|
-
// We don't have a probe clone helper; instead, ask the deps for a bare-path
|
|
220
|
-
// delta computation. The test double will produce this from a fixture; the
|
|
221
|
-
// real implementation walks both trees after ensuring a scratch clone exists.
|
|
222
|
-
// This is provided by the command glue layer.
|
|
223
|
-
const deltas = await deps.gitOps.diffHeadVsOrigin(input.dir);
|
|
224
|
-
const totalFilesInRemote = await deps.gitOps.countFilesInRemote(input.dir);
|
|
225
|
-
const summary = summarizeDiff(deltas, { totalFilesInRemote, unpinned });
|
|
226
|
-
log('');
|
|
227
|
-
log(summary.renderText({
|
|
228
|
-
workspaceSlug: backend.workspaceSlug,
|
|
229
|
-
repoUrl: stripAuth(backend.repoUrl),
|
|
230
|
-
branch: 'main',
|
|
231
|
-
localDir: input.dir,
|
|
232
|
-
}));
|
|
233
|
-
log('');
|
|
234
|
-
if (summary.totals.filesTouched === 0) {
|
|
235
|
-
return { kind: 'no-op', reason: 'Local tree matches remote HEAD. Nothing to push.' };
|
|
236
|
-
}
|
|
237
|
-
const ok = await decideConfirm({
|
|
238
|
-
destructive: summary.isDestructive,
|
|
239
|
-
requiresTypedSlug,
|
|
240
|
-
yes: input.yes,
|
|
241
|
-
workspaceSlug: backend.workspaceSlug,
|
|
242
|
-
confirmIO: deps.confirmIO,
|
|
243
|
-
});
|
|
244
|
-
if (!ok) {
|
|
245
|
-
log('Aborted, nothing pushed.');
|
|
246
|
-
return { kind: 'aborted', reason: 'user declined at confirm prompt' };
|
|
247
|
-
}
|
|
248
|
-
const baseMessage = input.message ?? `qfg push: ${summary.totals.filesTouched} file change(s)`;
|
|
249
|
-
const commitMessage = withPushedViaTrailer(baseMessage);
|
|
250
|
-
log('Pushing via clone-and-stack...');
|
|
251
|
-
try {
|
|
252
|
-
const result = await cloneAndStackPush({
|
|
253
|
-
remoteUrl: backend.repoUrl,
|
|
254
|
-
localDir: scratchDir,
|
|
255
|
-
author,
|
|
256
|
-
commitMessage,
|
|
257
|
-
async applyDelta(clonedDir) {
|
|
258
|
-
// The caller promised deps.copyDirMirror copies `input.dir` into
|
|
259
|
-
// `clonedDir`, skipping `.git/`. cloneAndStackPush will `git add --all`
|
|
260
|
-
// after this returns.
|
|
261
|
-
await deps.copyDirMirror(input.dir, clonedDir);
|
|
262
|
-
},
|
|
263
|
-
});
|
|
264
|
-
if (unpinned && !input.noPinWrite) {
|
|
265
|
-
try {
|
|
266
|
-
await writeWorkspaceSlug(input.dir, backend.workspaceSlug);
|
|
267
|
-
log(`Wrote workspace = "${backend.workspaceSlug}" into quonfig.json`);
|
|
268
|
-
}
|
|
269
|
-
catch {
|
|
270
|
-
// non-fatal
|
|
271
|
-
}
|
|
192
|
+
const toServerKind = (kind) => {
|
|
193
|
+
switch (kind) {
|
|
194
|
+
case 'added': {
|
|
195
|
+
return 'add';
|
|
272
196
|
}
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
catch (error) {
|
|
276
|
-
const msg = error instanceof Error ? error.message : String(error);
|
|
277
|
-
if (/non-fast-forward|fetch first|rejected/i.test(msg)) {
|
|
278
|
-
throw new PushFatalError('Remote has changes not in local — run `qfg pull` first, then re-run `qfg push`.', 'NON_FAST_FORWARD');
|
|
197
|
+
case 'deleted': {
|
|
198
|
+
return 'delete';
|
|
279
199
|
}
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
finally {
|
|
283
|
-
// Best-effort cleanup of the scratch clone. Ignore failures so a cleanup
|
|
284
|
-
// problem doesn't mask a real error.
|
|
285
|
-
try {
|
|
286
|
-
fs.rmSync(scratchDir, { recursive: true, force: true });
|
|
287
|
-
}
|
|
288
|
-
catch {
|
|
289
|
-
/* ignore */
|
|
200
|
+
case 'modified': {
|
|
201
|
+
return 'modify';
|
|
290
202
|
}
|
|
291
203
|
}
|
|
292
|
-
}
|
|
204
|
+
};
|
|
293
205
|
/**
|
|
294
206
|
* Map the guard outputs to the right prompt:
|
|
295
207
|
* - Typed-slug (always, never skipped by --yes) when identity demanded it
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
import * as fs from 'node:fs';
|
|
2
|
-
import * as os from 'node:os';
|
|
3
2
|
import * as path from 'node:path';
|
|
4
3
|
import { getDomain } from './domain-urls.js';
|
|
4
|
+
import { getQuonfigConfigHome } from './quonfig-home.js';
|
|
5
5
|
const BUFFER_MS = 5 * 60 * 1000; // 5 minutes
|
|
6
|
-
const getQuonfigDir = () =>
|
|
6
|
+
const getQuonfigDir = () => getQuonfigConfigHome();
|
|
7
7
|
const getGiteaTokenFile = () => {
|
|
8
8
|
const domain = getDomain();
|
|
9
9
|
const filename = domain === 'quonfig.com' ? 'gitea-tokens.json' : `gitea-tokens-${domain.replaceAll('.', '-')}.json`;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare const openBrowser: (url: string) => void;
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
export const openBrowser = (url) => {
|
|
3
|
+
let command;
|
|
4
|
+
let args;
|
|
5
|
+
if (process.platform === 'darwin') {
|
|
6
|
+
command = 'open';
|
|
7
|
+
args = [url];
|
|
8
|
+
}
|
|
9
|
+
else if (process.platform === 'win32') {
|
|
10
|
+
command = 'cmd';
|
|
11
|
+
args = ['/c', 'start', '""', url.replace(/&/g, '^&')];
|
|
12
|
+
}
|
|
13
|
+
else {
|
|
14
|
+
command = 'xdg-open';
|
|
15
|
+
args = [url];
|
|
16
|
+
}
|
|
17
|
+
try {
|
|
18
|
+
const child = spawn(command, args, { detached: true, stdio: 'ignore' });
|
|
19
|
+
child.on('error', () => {
|
|
20
|
+
// Swallow — user can still copy the URL manually.
|
|
21
|
+
});
|
|
22
|
+
child.unref();
|
|
23
|
+
}
|
|
24
|
+
catch {
|
|
25
|
+
// Swallow — user can still copy the URL manually.
|
|
26
|
+
}
|
|
27
|
+
};
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Resolve the directory the CLI uses for tokens, profile config, and gitea
|
|
3
|
+
* tokens. Honors `QUONFIG_CONFIG_HOME` so tests (and anyone who wants an
|
|
4
|
+
* isolated config dir) can redirect away from `~/.quonfig/`.
|
|
5
|
+
*/
|
|
6
|
+
export declare const getQuonfigConfigHome: () => string;
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import * as os from 'node:os';
|
|
2
|
+
import * as path from 'node:path';
|
|
3
|
+
/**
|
|
4
|
+
* Resolve the directory the CLI uses for tokens, profile config, and gitea
|
|
5
|
+
* tokens. Honors `QUONFIG_CONFIG_HOME` so tests (and anyone who wants an
|
|
6
|
+
* isolated config dir) can redirect away from `~/.quonfig/`.
|
|
7
|
+
*/
|
|
8
|
+
export const getQuonfigConfigHome = () => process.env.QUONFIG_CONFIG_HOME || path.join(os.homedir(), '.quonfig');
|
|
@@ -46,7 +46,9 @@ export async function resolveWorkspaceUuid(command, flagOverride) {
|
|
|
46
46
|
const match = entries.find((w) => w.workspaceSlug === override);
|
|
47
47
|
if (!match) {
|
|
48
48
|
const available = entries.map((w) => w.workspaceSlug).join(', ') || '(none)';
|
|
49
|
-
command.error(`No workspace with slug "${override}" is accessible with this API key. Available: ${available}.`, {
|
|
49
|
+
command.error(`No workspace with slug "${override}" is accessible with this API key. Available: ${available}.`, {
|
|
50
|
+
exit: 1,
|
|
51
|
+
});
|
|
50
52
|
}
|
|
51
53
|
command.verboseLog('ApiKey auth', { source: 'slug', slug: override, workspaceId: match.workspaceId });
|
|
52
54
|
return match.workspaceId;
|
|
@@ -111,7 +113,9 @@ async function recoverAuthConfigFromTokens(command) {
|
|
|
111
113
|
command.error(`Tokens found but failed to fetch workspaces: ${error instanceof Error ? error.message : String(error)}. Run \`qfg login\` to repopulate.`, { exit: 401 });
|
|
112
114
|
}
|
|
113
115
|
if (!res.ok) {
|
|
114
|
-
command.error(`Tokens found but workspace list returned HTTP ${res.status}. Run \`qfg login\` to repopulate.`, {
|
|
116
|
+
command.error(`Tokens found but workspace list returned HTTP ${res.status}. Run \`qfg login\` to repopulate.`, {
|
|
117
|
+
exit: 401,
|
|
118
|
+
});
|
|
115
119
|
}
|
|
116
120
|
const body = (await res.json());
|
|
117
121
|
const entries = (body.json ?? body);
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import * as fs from 'node:fs';
|
|
2
|
-
import * as os from 'node:os';
|
|
3
2
|
import * as path from 'node:path';
|
|
4
3
|
import { getDomain } from './domain-urls.js';
|
|
5
|
-
|
|
4
|
+
import { getQuonfigConfigHome } from './quonfig-home.js';
|
|
5
|
+
const getQuonfigDir = (options) => options?.quonfigDir || getQuonfigConfigHome();
|
|
6
6
|
const getTokenFile = (options) => {
|
|
7
7
|
const domain = getDomain();
|
|
8
8
|
const filename = domain === 'quonfig.com' ? 'tokens.json' : `tokens-${domain.replaceAll('.', '-')}.json`;
|
package/dist/verify/validate.js
CHANGED
|
@@ -118,6 +118,7 @@ const VariantSchema = z.object({
|
|
|
118
118
|
value: ValueSchema,
|
|
119
119
|
description: z.string().optional(),
|
|
120
120
|
});
|
|
121
|
+
const AccessSchema = z.enum(['support', 'standard', 'protected-env', 'protected-all-envs']);
|
|
121
122
|
const StoredConfigSchema = z
|
|
122
123
|
.object({
|
|
123
124
|
id: z.string().optional(),
|
|
@@ -129,8 +130,7 @@ const StoredConfigSchema = z
|
|
|
129
130
|
description: z.string().optional(),
|
|
130
131
|
sendToClientSdk: z.boolean().optional(),
|
|
131
132
|
schemaKey: z.string().optional(),
|
|
132
|
-
|
|
133
|
-
protection: z.string().optional(),
|
|
133
|
+
access: AccessSchema.optional(),
|
|
134
134
|
default: z.object({
|
|
135
135
|
rules: z.array(ConfigRuleSchema),
|
|
136
136
|
}),
|
package/oclif.manifest.json
CHANGED