@quonfig/cli 0.0.15 → 0.0.16

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.
@@ -0,0 +1,10 @@
1
+ type StreamOpts = {
2
+ input?: NodeJS.ReadableStream;
3
+ output?: NodeJS.WritableStream;
4
+ };
5
+ type TypedSlugOpts = {
6
+ maxAttempts?: number;
7
+ } & StreamOpts;
8
+ export declare function confirmYesNo(prompt: string, opts?: StreamOpts): Promise<boolean>;
9
+ export declare function confirmTypedSlug(expectedSlug: string, prompt: string, opts?: TypedSlugOpts): Promise<boolean>;
10
+ export {};
@@ -0,0 +1,108 @@
1
+ // Thin prompt primitives for `qfg push` Guard 3.
2
+ //
3
+ // Two levels of confirmation:
4
+ //
5
+ // confirmYesNo -- standard y/N. Default NO. Bare Enter is a no.
6
+ // confirmTypedSlug -- user has to type the slug EXACTLY. Strict string
7
+ // equality (trailing whitespace trimmed). Case-sensitive.
8
+ //
9
+ // Both take injectable input/output streams so tests can drive them with
10
+ // in-memory PassThrough streams. Defaults are process.stdin / process.stdout.
11
+ //
12
+ // No business logic, no diff math, no network. Callers construct the prompt
13
+ // text (including the "[y/N]" hint); this module prints it verbatim.
14
+ import * as readline from 'node:readline';
15
+ const makeLineReader = (rl) => {
16
+ const lines = [];
17
+ let closed = false;
18
+ let pending = null;
19
+ rl.on('line', (line) => {
20
+ if (pending) {
21
+ const p = pending;
22
+ pending = null;
23
+ p.resolve(line);
24
+ return;
25
+ }
26
+ lines.push(line);
27
+ });
28
+ rl.on('close', () => {
29
+ closed = true;
30
+ if (pending) {
31
+ const p = pending;
32
+ pending = null;
33
+ // eslint-disable-next-line unicorn/no-useless-undefined
34
+ p.resolve(undefined);
35
+ }
36
+ });
37
+ return {
38
+ close() {
39
+ rl.close();
40
+ },
41
+ readLine() {
42
+ if (lines.length > 0) {
43
+ return Promise.resolve(lines.shift());
44
+ }
45
+ if (closed) {
46
+ // eslint-disable-next-line unicorn/no-useless-undefined
47
+ return Promise.resolve(undefined);
48
+ }
49
+ return new Promise((resolve) => {
50
+ pending = { resolve };
51
+ });
52
+ },
53
+ };
54
+ };
55
+ const YES_ANSWERS = new Set(['y', 'Y', 'yes']);
56
+ // Standard confirmation. Returns true only for 'y' / 'Y' / 'yes' (exact, after
57
+ // trimming trailing whitespace). Anything else -- bare Enter, 'n', garbage,
58
+ // EOF -- is false. Default is NO.
59
+ export async function confirmYesNo(prompt, opts = {}) {
60
+ const input = opts.input ?? process.stdin;
61
+ const output = opts.output ?? process.stdout;
62
+ const rl = readline.createInterface({ input, output, terminal: false });
63
+ const reader = makeLineReader(rl);
64
+ try {
65
+ output.write(prompt);
66
+ const line = await reader.readLine();
67
+ if (line === undefined)
68
+ return false;
69
+ // readline strips the trailing newline, but be defensive against CRLF and
70
+ // trailing spaces.
71
+ const trimmed = line.replace(/\s+$/, '');
72
+ return YES_ANSWERS.has(trimmed);
73
+ }
74
+ finally {
75
+ reader.close();
76
+ }
77
+ }
78
+ // Typed-slug confirmation. Returns true only if the user types expectedSlug
79
+ // EXACTLY (case-sensitive) within maxAttempts tries. Trailing whitespace is
80
+ // trimmed before comparison; leading whitespace is NOT -- the plan says "have
81
+ // to type the slug exactly". Empty input / EOF / mismatch all count as a
82
+ // failed attempt.
83
+ //
84
+ // Default maxAttempts = 1 (plan: "have to type the slug exactly" -- one try
85
+ // is sufficient).
86
+ export async function confirmTypedSlug(expectedSlug, prompt, opts = {}) {
87
+ const input = opts.input ?? process.stdin;
88
+ const output = opts.output ?? process.stdout;
89
+ const maxAttempts = opts.maxAttempts ?? 1;
90
+ const rl = readline.createInterface({ input, output, terminal: false });
91
+ const reader = makeLineReader(rl);
92
+ try {
93
+ for (let attempt = 0; attempt < maxAttempts; attempt++) {
94
+ output.write(prompt);
95
+ // eslint-disable-next-line no-await-in-loop
96
+ const line = await reader.readLine();
97
+ if (line === undefined)
98
+ return false;
99
+ const trimmed = line.replace(/\s+$/, '');
100
+ if (trimmed === expectedSlug)
101
+ return true;
102
+ }
103
+ return false;
104
+ }
105
+ finally {
106
+ reader.close();
107
+ }
108
+ }
@@ -0,0 +1,56 @@
1
+ /**
2
+ * Pure diff-summary + destructive-heuristic module for `qfg push`.
3
+ *
4
+ * Implements Guard 3 (pre-push diff summary + destructive-change detection)
5
+ * from `project/plans/cli-git-sync.md`. This module takes a pre-computed
6
+ * list of `FileDelta`s (the caller is responsible for walking local vs.
7
+ * remote trees after `git fetch`) and returns a grouped count + a plain-text
8
+ * rendering suitable for human confirmation.
9
+ *
10
+ * Intentional non-goals:
11
+ * - No fs / git / network I/O here. Keeps this pure and test-friendly.
12
+ * - No prompts / TTY handling. The caller owns confirmation UX.
13
+ * - No ANSI / unicode. Plain ASCII so CI transcripts stay readable.
14
+ */
15
+ /** A single file-level change between the local working tree and remote HEAD. */
16
+ export interface FileDelta {
17
+ /**
18
+ * `added` — present locally, absent on remote
19
+ * `modified` — present in both, content differs
20
+ * `deleted` — absent locally, present on remote
21
+ */
22
+ kind: 'added' | 'deleted' | 'modified';
23
+ /** Path relative to repo root, e.g. `configs/pricing.json`. */
24
+ path: string;
25
+ }
26
+ export interface GroupCounts {
27
+ added: number;
28
+ deleted: number;
29
+ modified: number;
30
+ }
31
+ export interface DiffSummary {
32
+ /** Per-top-level-dir counts. See `KNOWN_GROUPS`; anything else lands in `other`. */
33
+ byGroup: Record<string, GroupCounts>;
34
+ /** Whether any destructive heuristic fired. */
35
+ destructiveReasons: string[];
36
+ /** Human-readable reasons that each destructive heuristic fired (one per rule). */
37
+ isDestructive: boolean;
38
+ /**
39
+ * Render the plain-text summary shown before the user confirms.
40
+ * All opts are optional; missing string opts render as `<unknown>`.
41
+ */
42
+ renderText(opts?: {
43
+ branch?: string;
44
+ localDir?: string;
45
+ repoUrl?: string;
46
+ workspaceSlug?: string;
47
+ }): string;
48
+ /** Sum across all groups, plus `filesTouched = added + modified + deleted`. */
49
+ totals: {
50
+ filesTouched: number;
51
+ } & GroupCounts;
52
+ }
53
+ export declare function summarizeDiff(deltas: FileDelta[], opts?: {
54
+ totalFilesInRemote?: number;
55
+ unpinned?: boolean;
56
+ }): DiffSummary;
@@ -0,0 +1,115 @@
1
+ /**
2
+ * Pure diff-summary + destructive-heuristic module for `qfg push`.
3
+ *
4
+ * Implements Guard 3 (pre-push diff summary + destructive-change detection)
5
+ * from `project/plans/cli-git-sync.md`. This module takes a pre-computed
6
+ * list of `FileDelta`s (the caller is responsible for walking local vs.
7
+ * remote trees after `git fetch`) and returns a grouped count + a plain-text
8
+ * rendering suitable for human confirmation.
9
+ *
10
+ * Intentional non-goals:
11
+ * - No fs / git / network I/O here. Keeps this pure and test-friendly.
12
+ * - No prompts / TTY handling. The caller owns confirmation UX.
13
+ * - No ANSI / unicode. Plain ASCII so CI transcripts stay readable.
14
+ */
15
+ /**
16
+ * Top-level directories we present as named rows. Anything else funnels into
17
+ * `other`. Keeping this closed-set avoids one-off typos silently looking fine.
18
+ */
19
+ const KNOWN_GROUPS = [
20
+ 'configs',
21
+ 'feature-flags',
22
+ 'segments',
23
+ 'schemas',
24
+ 'schemas-protected',
25
+ 'log-levels',
26
+ ];
27
+ const DESTRUCTIVE_DELETE_COUNT = 10;
28
+ const DESTRUCTIVE_DELETE_RATIO = 0.25;
29
+ function emptyCounts() {
30
+ return { added: 0, deleted: 0, modified: 0 };
31
+ }
32
+ /**
33
+ * Derive the group label for a path. Top-level dir if it's known, `other`
34
+ * otherwise (including for files at the repo root).
35
+ */
36
+ function groupFor(relativePath) {
37
+ const slash = relativePath.indexOf('/');
38
+ if (slash === -1)
39
+ return 'other';
40
+ const top = relativePath.slice(0, slash);
41
+ return KNOWN_GROUPS.includes(top) ? top : 'other';
42
+ }
43
+ function pad(s, width) {
44
+ return s.length >= width ? s : s + ' '.repeat(width - s.length);
45
+ }
46
+ export function summarizeDiff(deltas, opts) {
47
+ const byGroup = {};
48
+ const totals = { added: 0, deleted: 0, filesTouched: 0, modified: 0 };
49
+ for (const delta of deltas) {
50
+ const group = groupFor(delta.path);
51
+ const counts = byGroup[group] ?? emptyCounts();
52
+ counts[delta.kind] += 1;
53
+ byGroup[group] = counts;
54
+ totals[delta.kind] += 1;
55
+ totals.filesTouched += 1;
56
+ }
57
+ const destructiveReasons = [];
58
+ if (totals.deleted >= DESTRUCTIVE_DELETE_COUNT) {
59
+ destructiveReasons.push(`10+ deletes: ${totals.deleted}`);
60
+ }
61
+ const remoteCount = Math.max(opts?.totalFilesInRemote ?? 0, 0);
62
+ if (remoteCount > 0) {
63
+ const ratio = totals.deleted / remoteCount;
64
+ if (ratio >= DESTRUCTIVE_DELETE_RATIO) {
65
+ // Strict `>=` — 24.9% does not trip this; 25.0% does.
66
+ destructiveReasons.push(`deletes >= 25% of total: ${totals.deleted}/${remoteCount}`);
67
+ }
68
+ }
69
+ if (opts?.unpinned === true) {
70
+ destructiveReasons.push('workspace unpinned (no `workspace` field in quonfig.json)');
71
+ }
72
+ const isDestructive = destructiveReasons.length > 0;
73
+ const renderText = (renderOpts) => {
74
+ const slug = renderOpts?.workspaceSlug ?? '<unknown>';
75
+ const repo = renderOpts?.repoUrl ?? '<unknown>';
76
+ const branch = renderOpts?.branch ?? 'main';
77
+ const localDir = renderOpts?.localDir ?? '.';
78
+ const lines = [];
79
+ lines.push(`Pushing to workspace: ${slug}`, ` Git repo: ${repo}`, ` Branch: ${branch}`, ` Local dir: ${localDir}`, '', 'Changes vs. remote HEAD:');
80
+ // Render groups in a stable order: known groups first (in declared order),
81
+ // then `other` if present. Skip rows with zero total activity.
82
+ const renderOrder = [...KNOWN_GROUPS];
83
+ if (byGroup.other)
84
+ renderOrder.push('other');
85
+ // Two-space indent, left-pad the label column to `labelWidth` so the
86
+ // +N ~N -N columns line up regardless of group name length.
87
+ const labelWidth = 18; // e.g. "schemas-protected/" = 18 chars
88
+ for (const group of renderOrder) {
89
+ const counts = byGroup[group];
90
+ if (!counts)
91
+ continue;
92
+ if (counts.added === 0 && counts.modified === 0 && counts.deleted === 0)
93
+ continue;
94
+ const label = `${group}/`;
95
+ lines.push(` ${pad(label, labelWidth)}+${counts.added} ~${counts.modified} -${counts.deleted}`);
96
+ }
97
+ lines.push('');
98
+ if (isDestructive) {
99
+ lines.push('WARNING:');
100
+ for (const reason of destructiveReasons) {
101
+ lines.push(` ${reason}`);
102
+ }
103
+ lines.push('');
104
+ }
105
+ lines.push(` Total: ${totals.added} new files, ${totals.modified} modified, ${totals.deleted} deleted`);
106
+ return lines.join('\n');
107
+ };
108
+ return {
109
+ byGroup,
110
+ destructiveReasons,
111
+ isDestructive,
112
+ renderText,
113
+ totals,
114
+ };
115
+ }
@@ -0,0 +1,53 @@
1
+ /**
2
+ * Guards 1 + 2 of `qfg push`, as a pure function.
3
+ *
4
+ * See `project/plans/cli-git-sync.md` — "Safety model (three independent guards)".
5
+ *
6
+ * The caller collects three identity signals about the local directory the user
7
+ * is about to push:
8
+ *
9
+ * A. Requested target — slug or UUID the user asked for (--workspace or profile default)
10
+ * B. Repo pin — `workspace` slug from `quonfig.json`, if present
11
+ * C. Remote origin — `remote.origin.url` on the local git repo, if present
12
+ *
13
+ * ...and the canonical `backend` identity (the workspace the gitea.token
14
+ * response was minted for). This module cross-checks them and returns one of:
15
+ *
16
+ * - ok all defined signals agree
17
+ * - abort any two defined signals disagree
18
+ * - requires-typed-slug-confirmation the repo is unpinned and has no
19
+ * origin, so only the requested
20
+ * target supports our identity —
21
+ * we demand the user type the slug
22
+ *
23
+ * The module is intentionally pure: no I/O, no prompting, no UI. The UI layer
24
+ * (confirm prompts, diff summary) and the data-fetch layer (reading quonfig.json,
25
+ * shelling out to git, calling gitea.token) live elsewhere.
26
+ */
27
+ export interface IdentityCheckInput {
28
+ /** The canonical workspace identity from the backend (gitea.token response). */
29
+ backend: {
30
+ workspaceSlug: string;
31
+ workspaceId: string;
32
+ repoUrl: string;
33
+ };
34
+ /** "C. Remote origin" — the origin URL on the local git repo, if present. */
35
+ remoteOriginUrl: string | undefined;
36
+ /** "B. Repo pin" — slug from `quonfig.json.workspace`, if present. */
37
+ repoPinSlug: string | undefined;
38
+ /** "A. Requested target" — what the user asked for. Slug or UUID. */
39
+ requestedTarget: string;
40
+ }
41
+ export type IdentityCheckOutcome = {
42
+ kind: 'ok';
43
+ canonicalSlug: string;
44
+ } | {
45
+ kind: 'abort';
46
+ reason: string;
47
+ details: Record<string, string>;
48
+ } | {
49
+ kind: 'requires-typed-slug-confirmation';
50
+ canonicalSlug: string;
51
+ reason: string;
52
+ };
53
+ export declare function checkIdentity(input: IdentityCheckInput): IdentityCheckOutcome;
@@ -0,0 +1,135 @@
1
+ /**
2
+ * Guards 1 + 2 of `qfg push`, as a pure function.
3
+ *
4
+ * See `project/plans/cli-git-sync.md` — "Safety model (three independent guards)".
5
+ *
6
+ * The caller collects three identity signals about the local directory the user
7
+ * is about to push:
8
+ *
9
+ * A. Requested target — slug or UUID the user asked for (--workspace or profile default)
10
+ * B. Repo pin — `workspace` slug from `quonfig.json`, if present
11
+ * C. Remote origin — `remote.origin.url` on the local git repo, if present
12
+ *
13
+ * ...and the canonical `backend` identity (the workspace the gitea.token
14
+ * response was minted for). This module cross-checks them and returns one of:
15
+ *
16
+ * - ok all defined signals agree
17
+ * - abort any two defined signals disagree
18
+ * - requires-typed-slug-confirmation the repo is unpinned and has no
19
+ * origin, so only the requested
20
+ * target supports our identity —
21
+ * we demand the user type the slug
22
+ *
23
+ * The module is intentionally pure: no I/O, no prompting, no UI. The UI layer
24
+ * (confirm prompts, diff summary) and the data-fetch layer (reading quonfig.json,
25
+ * shelling out to git, calling gitea.token) live elsewhere.
26
+ */
27
+ /**
28
+ * Normalize a git remote URL so we can compare two URLs that describe the
29
+ * same repo:
30
+ * - strip any `user:pw@` basic-auth prefix
31
+ * - strip a trailing `.git`
32
+ * - lowercase the host (not the path — repo slugs may be case-sensitive)
33
+ * - ignore trailing slash
34
+ *
35
+ * Returns `undefined` when the URL is too malformed to parse — callers treat
36
+ * that as the `malformed` resolution.
37
+ */
38
+ const normalizeRemoteUrl = (url) => {
39
+ const trimmed = url.trim();
40
+ if (!trimmed)
41
+ return undefined;
42
+ // Strip basic-auth: "https://user:pw@host/..." -> "https://host/..."
43
+ const stripped = trimmed.replace(/^([a-z][\d+.a-z-]*:\/\/)[^/@]+@/i, '$1');
44
+ let parsed;
45
+ try {
46
+ parsed = new URL(stripped);
47
+ }
48
+ catch {
49
+ return undefined;
50
+ }
51
+ const host = parsed.host.toLowerCase();
52
+ // Strip trailing "/" first so a trailing-slash URL still has its ".git" suffix recognized.
53
+ const pathname = parsed.pathname.replace(/\/+$/, '').replace(/\.git$/, '');
54
+ return `${parsed.protocol}//${host}${pathname}`;
55
+ };
56
+ const resolveRequested = (requested, backend) => {
57
+ if (requested === backend.workspaceSlug || requested === backend.workspaceId)
58
+ return 'match';
59
+ return 'mismatch';
60
+ };
61
+ const resolvePin = (pin, backend) => {
62
+ if (pin === backend.workspaceSlug)
63
+ return 'match';
64
+ return 'mismatch';
65
+ };
66
+ const resolveOrigin = (origin, backend) => {
67
+ const a = normalizeRemoteUrl(origin);
68
+ const b = normalizeRemoteUrl(backend.repoUrl);
69
+ if (!a || !b)
70
+ return 'malformed';
71
+ return a === b ? 'match' : 'mismatch';
72
+ };
73
+ export function checkIdentity(input) {
74
+ if (!input.requestedTarget || input.requestedTarget.length === 0) {
75
+ // Programming error: the caller is responsible for resolving the user's
76
+ // --workspace flag / profile default into a non-empty string before
77
+ // calling this function. If they didn't, crash loudly rather than
78
+ // producing a misleading abort.
79
+ throw new Error('checkIdentity: requestedTarget is required (slug or UUID)');
80
+ }
81
+ const backend = input.backend;
82
+ const requested = resolveRequested(input.requestedTarget, backend);
83
+ const pin = input.repoPinSlug === undefined ? undefined : resolvePin(input.repoPinSlug, backend);
84
+ const origin = input.remoteOriginUrl === undefined ? undefined : resolveOrigin(input.remoteOriginUrl, backend);
85
+ // Judgment call (flagged in tests): a malformed origin URL is a signal we
86
+ // cannot resolve. We treat it as `mismatch` rather than `missing` so the
87
+ // guard aborts — it is safer to refuse a push with an unreadable remote
88
+ // than to silently drop that signal and rely on the pin alone.
89
+ const signalLabels = {
90
+ requested,
91
+ pin,
92
+ origin,
93
+ };
94
+ // Collect which sources disagree with the backend (or are malformed).
95
+ const disagreeing = [];
96
+ for (const [name, res] of Object.entries(signalLabels)) {
97
+ if (res === 'mismatch' || res === 'malformed')
98
+ disagreeing.push(name);
99
+ }
100
+ // Build a details payload that shows each input's raw value and resolved status.
101
+ // Useful for the abort UI to render a concrete "this is why we stopped" message.
102
+ const details = {
103
+ requestedTarget: input.requestedTarget,
104
+ requestedResolution: requested,
105
+ backendSlug: backend.workspaceSlug,
106
+ backendId: backend.workspaceId,
107
+ backendRepoUrl: backend.repoUrl,
108
+ };
109
+ if (input.repoPinSlug !== undefined) {
110
+ details.repoPinSlug = input.repoPinSlug;
111
+ details.pinResolution = pin;
112
+ }
113
+ if (input.remoteOriginUrl !== undefined) {
114
+ details.remoteOriginUrl = input.remoteOriginUrl;
115
+ details.originResolution = origin;
116
+ }
117
+ if (disagreeing.length > 0) {
118
+ // Any single defined source pointing somewhere other than the backend is
119
+ // grounds to abort — the user has asked for A but has evidence of B.
120
+ const reason = disagreeing.length === 1
121
+ ? `${disagreeing[0]} disagrees with backend workspace "${backend.workspaceSlug}"`
122
+ : `${disagreeing.join('/')} disagree with backend workspace "${backend.workspaceSlug}"`;
123
+ return { kind: 'abort', reason, details };
124
+ }
125
+ // No disagreement among defined sources. Are we in the "bare dir" case
126
+ // where only the requested target speaks for identity?
127
+ if (input.repoPinSlug === undefined && input.remoteOriginUrl === undefined) {
128
+ return {
129
+ kind: 'requires-typed-slug-confirmation',
130
+ canonicalSlug: backend.workspaceSlug,
131
+ reason: 'Local directory has no workspace pin and no git origin — cannot cross-check the requested target. Confirm by typing the workspace slug.',
132
+ };
133
+ }
134
+ return { kind: 'ok', canonicalSlug: backend.workspaceSlug };
135
+ }
@@ -0,0 +1,107 @@
1
+ /**
2
+ * Core logic for `qfg push`. Pure-ish, dependency-injected, so tests can drive
3
+ * it without hitting the network, touching git, or running validation.
4
+ *
5
+ * The oclif command (`src/commands/push.ts`) is a thin wrapper that fills in
6
+ * real implementations of every dependency and calls `runPush`.
7
+ *
8
+ * The three guards from `project/plans/cli-git-sync.md` are all enforced here:
9
+ * - Guards 1 + 2: `checkIdentity` cross-checks the requested target, repo
10
+ * pin, and git origin against the backend-resolved workspace.
11
+ * - Guard 3: `summarizeDiff` + `confirmYesNo`/`confirmTypedSlug` force the
12
+ * user to acknowledge destructive changes.
13
+ *
14
+ * Dispatch between the "clone path" (the local dir is a clone of the cloud
15
+ * repo) and the "bare path" (anything else — no .git, or a mismatched origin)
16
+ * is decided after identity passes.
17
+ *
18
+ * `--yes` skips the normal Y/N confirmation. It NEVER skips a typed-slug
19
+ * confirmation — that prompt is the destructive-change brake.
20
+ */
21
+ import { PushIdentity } from '../util/clone-and-stack-push.js';
22
+ import { FileDelta } from './diff-summary.js';
23
+ export interface GiteaTokenMintResult {
24
+ expiresAt: string | null;
25
+ repoUrl: string;
26
+ token: string;
27
+ workspaceId: string;
28
+ workspaceSlug: string;
29
+ }
30
+ export interface GitOps {
31
+ /** Number of tracked files on origin/main (used for the destructive-ratio heuristic). */
32
+ countFilesInRemote(dir: string): Promise<number>;
33
+ /**
34
+ * Produce the list of file deltas to render in the Guard 3 summary and
35
+ * (for the bare path) commit. For the clone path this is HEAD vs origin/main.
36
+ * For the bare path the caller hands us the deltas itself.
37
+ */
38
+ diffHeadVsOrigin(dir: string): Promise<FileDelta[]>;
39
+ /** `git fetch origin` in the given dir. */
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
+ /** Returns the `remote.origin.url` for the repo, or undefined if unset / not a repo. */
44
+ getRemoteOriginUrl(dir: string): Promise<string | undefined>;
45
+ /** Returns true if the dir has a `.git/` (worktree or repo). */
46
+ 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
+ /** Set origin to `url` (add if missing, set-url if present). */
50
+ setRemoteOrigin(dir: string, url: string): Promise<void>;
51
+ }
52
+ export type ConfirmIO = {
53
+ input?: NodeJS.ReadableStream;
54
+ output?: NodeJS.WritableStream;
55
+ };
56
+ export interface RunPushInput {
57
+ /** Absolute path to the local dir the user is pushing. */
58
+ dir: string;
59
+ /** `--message` — optional commit message override (bare path only). */
60
+ message?: string;
61
+ /** `--no-pin-write` — do not offer to write the slug pin into quonfig.json. */
62
+ noPinWrite: boolean;
63
+ /** `--workspace` flag, or the active profile's workspace UUID. Must be non-empty. */
64
+ requestedTarget: string;
65
+ /** `--skip-validate` — suppress the validate step. */
66
+ skipValidate: boolean;
67
+ /** `--yes` — skip standard Y/N confirm. Never skips typed-slug. */
68
+ yes: boolean;
69
+ }
70
+ export interface RunPushDeps {
71
+ /** Optional io streams for confirmation prompts. Defaults to process stdin/stdout. */
72
+ 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
+ /** Error logger; defaults to console.error. */
79
+ errLog?: (line: string) => void;
80
+ gitOps: GitOps;
81
+ /** Logger; defaults to console.log. */
82
+ log?: (line: string) => void;
83
+ /** Mint a write token via the backend. */
84
+ mintWriteToken(requestedTarget: string): Promise<GiteaTokenMintResult>;
85
+ /** Run `qfg validate` semantics. Should throw on error. */
86
+ validate(dir: string): Promise<{
87
+ errors: string[];
88
+ }>;
89
+ }
90
+ export type RunPushResult = {
91
+ kind: 'aborted';
92
+ reason: string;
93
+ } | {
94
+ kind: 'pushed';
95
+ dispatchedAs: 'clone-path' | 'bare-path';
96
+ commitSha?: string | null;
97
+ } | {
98
+ kind: 'no-op';
99
+ reason: string;
100
+ };
101
+ export declare class PushFatalError extends Error {
102
+ code: string;
103
+ constructor(message: string, code?: string);
104
+ }
105
+ /** Append the Pushed-Via trailer to a commit message if it isn't already present. */
106
+ export declare const withPushedViaTrailer: (message: string) => string;
107
+ export declare function runPush(input: RunPushInput, deps: RunPushDeps): Promise<RunPushResult>;