@quonfig/cli 0.0.15 → 0.0.17
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 +85 -34
- package/dist/commands/init.d.ts +1 -0
- package/dist/commands/init.js +18 -0
- package/dist/commands/migrate.js +48 -2
- package/dist/commands/pull.d.ts +14 -1
- package/dist/commands/pull.js +64 -4
- package/dist/commands/push.d.ts +53 -0
- package/dist/commands/push.js +370 -0
- package/dist/commands/workspace/bootstrap.d.ts +7 -0
- package/dist/commands/workspace/bootstrap.js +58 -2
- package/dist/migrate/local-write.d.ts +8 -2
- package/dist/migrate/local-write.js +55 -7
- package/dist/migrate/migration-report.d.ts +18 -0
- package/dist/migrate/migration-report.js +64 -1
- package/dist/migrate/push-to-cloud.d.ts +14 -3
- package/dist/migrate/push-to-cloud.js +77 -6
- package/dist/migrate/source.d.ts +50 -1
- package/dist/migrate/sources/launch/translate.d.ts +27 -1
- package/dist/migrate/sources/launch/translate.js +123 -6
- package/dist/migrate/sources/launch/types.d.ts +3 -0
- package/dist/migrate/sources/launch/zod-to-json-schema.d.ts +9 -0
- package/dist/migrate/sources/launch/zod-to-json-schema.js +521 -0
- package/dist/migrate/sources/launch.js +52 -7
- package/dist/push/bare-path-diff.d.ts +40 -0
- package/dist/push/bare-path-diff.js +126 -0
- package/dist/push/confirm.d.ts +10 -0
- package/dist/push/confirm.js +108 -0
- package/dist/push/diff-summary.d.ts +56 -0
- package/dist/push/diff-summary.js +115 -0
- package/dist/push/identity-check.d.ts +53 -0
- package/dist/push/identity-check.js +135 -0
- package/dist/push/run-push.d.ts +107 -0
- package/dist/push/run-push.js +318 -0
- package/dist/util/get-client.js +5 -4
- package/dist/util/get-valid-token.d.ts +3 -1
- package/dist/util/get-valid-token.js +28 -3
- package/dist/util/gitea-api.d.ts +15 -1
- package/dist/util/gitea-api.js +1 -0
- package/dist/util/gitea-token-storage.d.ts +7 -0
- package/dist/util/quonfig-json.d.ts +44 -0
- package/dist/util/quonfig-json.js +65 -0
- package/dist/verify/validate.js +6 -2
- package/oclif.manifest.json +104 -1
- package/package.json +3 -3
- /package/dist/{migrate/push-strategy.d.ts → util/clone-and-stack-push.d.ts} +0 -0
- /package/dist/{migrate/push-strategy.js → util/clone-and-stack-push.js} +0 -0
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Bare-path diff helper for `qfg push`.
|
|
3
|
+
*
|
|
4
|
+
* When the user's local dir is NOT a clone of the cloud repo (no `.git/`, or
|
|
5
|
+
* a mismatched origin) we still need to show a Guard 3 diff summary BEFORE
|
|
6
|
+
* asking them to confirm the push. The clone-path implementation uses
|
|
7
|
+
* `git log origin/main..HEAD` — that doesn't work here because there is no
|
|
8
|
+
* origin in the local dir.
|
|
9
|
+
*
|
|
10
|
+
* Strategy: probe-clone the cloud repo into an OS tmpdir, then walk both
|
|
11
|
+
* trees file-by-file and classify each as added / modified / deleted.
|
|
12
|
+
*
|
|
13
|
+
* The caller owns cleanup of `scratchDir`. The real caller (buildRealDeps in
|
|
14
|
+
* `commands/push.ts`) stashes the scratch clone path on a closure so it can
|
|
15
|
+
* delete it in a finally-block after runPush resolves — matching the bare
|
|
16
|
+
* path's existing scratch-clone lifecycle in `run-push.ts`.
|
|
17
|
+
*
|
|
18
|
+
* Intentional non-goals:
|
|
19
|
+
* - No token handling. Caller passes a fully-authenticated `remoteUrl`.
|
|
20
|
+
* - No filtering to "known" workspace dirs (configs/, feature-flags/, ...).
|
|
21
|
+
* We exclude dotfiles/dotdirs (`.git`, `.DS_Store`, `.quonfig-*`) so the
|
|
22
|
+
* scratch clone's own `.git/` does not leak into the diff, but otherwise
|
|
23
|
+
* diff whatever is on disk. This keeps the diff surface consistent with
|
|
24
|
+
* `copyDirMirror` in `commands/push.ts`, which mirrors every non-dotfile
|
|
25
|
+
* into the real push clone.
|
|
26
|
+
*/
|
|
27
|
+
import { execFile as execFileCb } from 'node:child_process';
|
|
28
|
+
import { createHash } from 'node:crypto';
|
|
29
|
+
import * as fs from 'node:fs';
|
|
30
|
+
import * as os from 'node:os';
|
|
31
|
+
import * as path from 'node:path';
|
|
32
|
+
import * as util from 'node:util';
|
|
33
|
+
const execFile = util.promisify(execFileCb);
|
|
34
|
+
/**
|
|
35
|
+
* Clone `remoteUrl` into an OS tmp dir, then diff the working tree of `localDir`
|
|
36
|
+
* against the scratch clone's working tree. Returns `FileDelta[]` plus the
|
|
37
|
+
* scratch clone's file count.
|
|
38
|
+
*/
|
|
39
|
+
export async function computeBarePathDiff(localDir, remoteUrl) {
|
|
40
|
+
const scratchDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'qfg-push-probe-'));
|
|
41
|
+
try {
|
|
42
|
+
// --depth 1 would miss information needed for later identity checks, but
|
|
43
|
+
// for the diff we only need the tip — shallow is fine and a lot faster on
|
|
44
|
+
// workspaces with 2 years of history.
|
|
45
|
+
await execFile('git', ['clone', '--branch', 'main', '--depth', '1', remoteUrl, scratchDir]);
|
|
46
|
+
}
|
|
47
|
+
catch (error) {
|
|
48
|
+
// Clean up before re-throwing so we never leak a scratch dir on failure.
|
|
49
|
+
try {
|
|
50
|
+
fs.rmSync(scratchDir, { force: true, recursive: true });
|
|
51
|
+
}
|
|
52
|
+
catch {
|
|
53
|
+
/* ignore */
|
|
54
|
+
}
|
|
55
|
+
throw error;
|
|
56
|
+
}
|
|
57
|
+
const localFiles = collectFilesWithHash(localDir);
|
|
58
|
+
const remoteFiles = collectFilesWithHash(scratchDir);
|
|
59
|
+
const deltas = [];
|
|
60
|
+
for (const [rel, localHash] of localFiles) {
|
|
61
|
+
const remoteHash = remoteFiles.get(rel);
|
|
62
|
+
if (remoteHash === undefined) {
|
|
63
|
+
deltas.push({ kind: 'added', path: rel });
|
|
64
|
+
}
|
|
65
|
+
else if (remoteHash !== localHash) {
|
|
66
|
+
deltas.push({ kind: 'modified', path: rel });
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
for (const rel of remoteFiles.keys()) {
|
|
70
|
+
if (!localFiles.has(rel)) {
|
|
71
|
+
deltas.push({ kind: 'deleted', path: rel });
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
return { deltas, scratchDir, totalRemoteFiles: remoteFiles.size };
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* Walk `root` and return a map of relative-path -> content-hash for every
|
|
78
|
+
* non-dotfile file under it.
|
|
79
|
+
*
|
|
80
|
+
* We skip any entry whose name starts with a `.`, which covers `.git/`,
|
|
81
|
+
* `.DS_Store`, and the transient `.quonfig-push-clone-*` scratch dirs that
|
|
82
|
+
* `run-push.ts` creates next to the local workspace. Keeping the rule simple
|
|
83
|
+
* and symmetric on both sides prevents one-off false positives.
|
|
84
|
+
*/
|
|
85
|
+
function collectFilesWithHash(root) {
|
|
86
|
+
const out = new Map();
|
|
87
|
+
if (!fs.existsSync(root))
|
|
88
|
+
return out;
|
|
89
|
+
const walk = (dir, prefix) => {
|
|
90
|
+
let entries;
|
|
91
|
+
try {
|
|
92
|
+
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
93
|
+
}
|
|
94
|
+
catch {
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
for (const entry of entries) {
|
|
98
|
+
if (entry.name.startsWith('.'))
|
|
99
|
+
continue;
|
|
100
|
+
const rel = prefix ? `${prefix}/${entry.name}` : entry.name;
|
|
101
|
+
const full = path.join(dir, entry.name);
|
|
102
|
+
if (entry.isDirectory()) {
|
|
103
|
+
walk(full, rel);
|
|
104
|
+
}
|
|
105
|
+
else if (entry.isFile() || entry.isSymbolicLink()) {
|
|
106
|
+
try {
|
|
107
|
+
const buf = fs.readFileSync(full);
|
|
108
|
+
out.set(rel, hashBytes(buf));
|
|
109
|
+
}
|
|
110
|
+
catch {
|
|
111
|
+
/* ignore unreadable files — they won't appear on either side */
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
};
|
|
116
|
+
walk(root, '');
|
|
117
|
+
return out;
|
|
118
|
+
}
|
|
119
|
+
/**
|
|
120
|
+
* Content hash. We don't need cryptographic strength — just a stable
|
|
121
|
+
* fingerprint so we can say "file A on local matches file A on remote". The
|
|
122
|
+
* contents are small JSON in practice, so sha1 is plenty fast.
|
|
123
|
+
*/
|
|
124
|
+
function hashBytes(buf) {
|
|
125
|
+
return createHash('sha1').update(buf).digest('hex');
|
|
126
|
+
}
|
|
@@ -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
|
+
}
|