@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.
Files changed (46) hide show
  1. package/README.md +85 -34
  2. package/dist/commands/init.d.ts +1 -0
  3. package/dist/commands/init.js +18 -0
  4. package/dist/commands/migrate.js +48 -2
  5. package/dist/commands/pull.d.ts +14 -1
  6. package/dist/commands/pull.js +64 -4
  7. package/dist/commands/push.d.ts +53 -0
  8. package/dist/commands/push.js +370 -0
  9. package/dist/commands/workspace/bootstrap.d.ts +7 -0
  10. package/dist/commands/workspace/bootstrap.js +58 -2
  11. package/dist/migrate/local-write.d.ts +8 -2
  12. package/dist/migrate/local-write.js +55 -7
  13. package/dist/migrate/migration-report.d.ts +18 -0
  14. package/dist/migrate/migration-report.js +64 -1
  15. package/dist/migrate/push-to-cloud.d.ts +14 -3
  16. package/dist/migrate/push-to-cloud.js +77 -6
  17. package/dist/migrate/source.d.ts +50 -1
  18. package/dist/migrate/sources/launch/translate.d.ts +27 -1
  19. package/dist/migrate/sources/launch/translate.js +123 -6
  20. package/dist/migrate/sources/launch/types.d.ts +3 -0
  21. package/dist/migrate/sources/launch/zod-to-json-schema.d.ts +9 -0
  22. package/dist/migrate/sources/launch/zod-to-json-schema.js +521 -0
  23. package/dist/migrate/sources/launch.js +52 -7
  24. package/dist/push/bare-path-diff.d.ts +40 -0
  25. package/dist/push/bare-path-diff.js +126 -0
  26. package/dist/push/confirm.d.ts +10 -0
  27. package/dist/push/confirm.js +108 -0
  28. package/dist/push/diff-summary.d.ts +56 -0
  29. package/dist/push/diff-summary.js +115 -0
  30. package/dist/push/identity-check.d.ts +53 -0
  31. package/dist/push/identity-check.js +135 -0
  32. package/dist/push/run-push.d.ts +107 -0
  33. package/dist/push/run-push.js +318 -0
  34. package/dist/util/get-client.js +5 -4
  35. package/dist/util/get-valid-token.d.ts +3 -1
  36. package/dist/util/get-valid-token.js +28 -3
  37. package/dist/util/gitea-api.d.ts +15 -1
  38. package/dist/util/gitea-api.js +1 -0
  39. package/dist/util/gitea-token-storage.d.ts +7 -0
  40. package/dist/util/quonfig-json.d.ts +44 -0
  41. package/dist/util/quonfig-json.js +65 -0
  42. package/dist/verify/validate.js +6 -2
  43. package/oclif.manifest.json +104 -1
  44. package/package.json +3 -3
  45. /package/dist/{migrate/push-strategy.d.ts → util/clone-and-stack-push.d.ts} +0 -0
  46. /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
+ }