getownit 0.1.1 → 0.1.2

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 CHANGED
@@ -11,9 +11,9 @@ ownit key set # pick Nebius or NVIDIA, then paste your key; stored only in ~
11
11
  ownit init # in a repo: installs the pre-push hook
12
12
  ```
13
13
 
14
- On each push: `Debrief now? [Y/n/s=skip]`. Ownit never blocks a push. Pending debriefs can be
15
- finished any time with `ownit interview`.
14
+ On each push: `Debrief now? [Y/n]`. Ownit never blocks a push. Pending debriefs can be
15
+ finished any time with `ownit interview`, which lets you pick the push.
16
16
 
17
- Your code and answers are never stored, and your key is never stored or logged by the server.
17
+ Your code is never stored, your answers only if the repo's admins choose to show them on its page, and your key is never stored or logged by the server.
18
18
 
19
19
  Full documentation: [github.com/Yoosseph/ownit](https://github.com/Yoosseph/ownit).
@@ -38,22 +38,13 @@ async function readStdin() {
38
38
  text += chunk;
39
39
  return text;
40
40
  }
41
+ /** Yes (or Enter) debriefs now; anything else, including the old "s", leaves it for later. */
41
42
  export function interpretChoice(answer) {
42
- if (answer === null)
43
- return 'later';
44
- const value = answer.trim().toLowerCase();
45
- if (value === '' || value === 'y' || value === 'yes')
46
- return 'debrief';
47
- if (value === 's' || value === 'skip')
48
- return 'skip';
49
- return 'later';
50
- }
51
- async function record(api, repo, push, status) {
52
- const res = await api.request('POST', '/api/pushes', { repo, base_sha: push.base, head_sha: push.head, commits: push.commits.map((c) => ({ sha: c.sha })), status }, { timeoutMs: QUICK_TIMEOUT_MS });
53
- return res.status;
43
+ const value = answer?.trim().toLowerCase();
44
+ return value === '' || value === 'y' || value === 'yes' ? 'debrief' : 'later';
54
45
  }
55
46
  async function recordPending(api, repo, push, out) {
56
- await record(api, repo, push, 'pending');
47
+ await api.request('POST', '/api/pushes', { repo, base_sha: push.base, head_sha: push.head, commits: push.commits.map((c) => ({ sha: c.sha })), status: 'pending' }, { timeoutMs: QUICK_TIMEOUT_MS });
57
48
  out.write(`Ownit: push recorded as pending. ${RESUME_HINT}\n`);
58
49
  return 0;
59
50
  }
@@ -71,20 +62,14 @@ async function knownStatus(api, repo, push) {
71
62
  });
72
63
  return known.status;
73
64
  }
74
- /** Asks whether to debrief now, then debriefs, records a skip, or leaves the push pending. */
65
+ /** Asks whether to debrief now, then debriefs or leaves the push pending for `ownit interview`. */
75
66
  async function askAndDebrief(api, terminal, repo, push, out) {
76
67
  const c = paint(out);
77
68
  const prompter = createPrompter(terminal, out);
78
69
  try {
79
70
  const n = push.commits.length;
80
- const answer = await prompter.ask(`${c.bold('Ownit')} ${c.dim(`(${n} ${plural(n, 'commit')} to ${repo})`)} Debrief now? [Y/n/s=skip] `, PROMPT_TIMEOUT_MS);
81
- const choice = interpretChoice(answer);
82
- if (choice === 'skip') {
83
- await record(api, repo, push, 'skipped');
84
- out.write(c.dim('Ownit: skipped. Push continues.\n'));
85
- return 0;
86
- }
87
- if (choice === 'later')
71
+ const answer = await prompter.ask(`${c.bold('Ownit')} ${c.dim(`(${n} ${plural(n, 'commit')} to ${repo})`)} Debrief now? [Y/n] `, PROMPT_TIMEOUT_MS);
72
+ if (interpretChoice(answer) === 'later')
88
73
  return await recordPending(api, repo, push, out);
89
74
  await runDebrief(api, prompter, {
90
75
  repo,
@@ -1,14 +1,14 @@
1
1
  import { parseArgs } from 'node:util';
2
2
  import { createApi } from '../api.js';
3
3
  import { buildDiff } from '../diff.js';
4
- import { runDebrief } from '../debrief.js';
5
- import { githubRepo, listCommits, objectExists, resolveCommit } from '../git.js';
4
+ import { LATER, runDebrief } from '../debrief.js';
5
+ import { githubRepo, listCommits, objectExists, resolveCommit, tryGit } from '../git.js';
6
6
  import { rangeForRecordedPush } from '../pushRange.js';
7
7
  import { createPrompter, paint, plural } from '../ui.js';
8
8
  export async function interviewCommand(argv) {
9
9
  const { values } = parseArgs({
10
10
  args: argv,
11
- options: { range: { type: 'string' }, repo: { type: 'string' } },
11
+ options: { range: { type: 'string' }, repo: { type: 'string' }, all: { type: 'boolean' } },
12
12
  allowPositionals: false,
13
13
  });
14
14
  const out = process.stderr;
@@ -22,32 +22,83 @@ export async function interviewCommand(argv) {
22
22
  try {
23
23
  if (values.range)
24
24
  return exitCode(await debriefRange(api, prompter, repo, values.range));
25
- return await debriefOpenPushes(api, prompter, repo);
25
+ return await debriefOpenPushes(api, prompter, repo, Boolean(values.all));
26
26
  }
27
27
  finally {
28
28
  prompter.close();
29
29
  }
30
30
  }
31
31
  function exitCode(outcome) {
32
- return outcome && (outcome.status === 'verified' || outcome.status === 'skipped') ? 0 : 1;
32
+ return outcome && (outcome.status === 'verified' || outcome.reason === LATER) ? 0 : 1;
33
33
  }
34
- async function debriefRange(api, prompter, repo, range) {
34
+ /** A range "A..B", or a single commit, which means just that commit. */
35
+ function parseRange(range) {
35
36
  const match = range.match(/^(.+?)\.\.(.+)$/);
36
- if (!match) {
37
- prompter.out.write('Ownit: --range must look like A..B.\n');
38
- return null;
39
- }
40
- const base = resolveCommit(match[1]);
41
- const head = resolveCommit(match[2]);
42
- const commits = listCommits([`${base}..${head}`]);
37
+ if (match)
38
+ return { base: resolveCommit(match[1]), head: resolveCommit(match[2]) };
39
+ const head = resolveCommit(range);
40
+ return { base: tryGit(['rev-parse', '--verify', '--quiet', `${head}^`])?.trim() || null, head };
41
+ }
42
+ async function debriefRange(api, prompter, repo, range) {
43
+ const { base, head } = parseRange(range);
44
+ const commits = base ? listCommits([`${base}..${head}`]) : listCommits(['-1', head]);
43
45
  if (!commits.length) {
44
46
  prompter.out.write(`Ownit: no commits in ${range}.\n`);
45
47
  return null;
46
48
  }
47
49
  return runDebrief(api, prompter, { repo, baseSha: base, headSha: head, commits, diff: buildDiff([{ base, head }]) });
48
50
  }
49
- /** Resolves pending and provider_error pushes, newest first. */
50
- async function debriefOpenPushes(api, prompter, repo) {
51
+ function toChoice(push) {
52
+ if (![push.head_sha, ...push.commits].every((sha) => objectExists(sha)))
53
+ return { push, commits: [] };
54
+ const range = rangeForRecordedPush(push.base_sha, push.head_sha, push.commits);
55
+ const known = new Set(push.commits);
56
+ const commits = listCommits([range.base ? `${range.base}..${range.head}` : range.head]).filter((x) => known.has(x.sha));
57
+ return { push, commits: commits.length ? commits : listCommits(['-1', range.head]) };
58
+ }
59
+ const shortDate = (iso) => new Date(iso).toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
60
+ /** One line per push: number, status, head commit, date and what the commits say. */
61
+ function printChoices(out, choices) {
62
+ const c = paint(out);
63
+ const width = Math.min(process.stderr.columns || 80, 100);
64
+ choices.forEach(({ push, commits }, i) => {
65
+ const status = push.status === 'provider_error' ? 'error' : push.status;
66
+ const head = `${String(i + 1).padStart(3)} ${status.padEnd(7)} ${push.head_sha.slice(0, 7)} ${shortDate(push.created_at).padEnd(6)} `;
67
+ let title;
68
+ if (!commits.length)
69
+ title = c.yellow('not in this clone, run "git fetch"');
70
+ else {
71
+ const subject = commits.at(-1).message.split('\n')[0];
72
+ const more = commits.length > 1 ? ` (+${commits.length - 1} more)` : '';
73
+ const room = Math.max(20, width - head.length - more.length);
74
+ title = (subject.length > room ? `${subject.slice(0, room - 1)}…` : subject) + c.dim(more);
75
+ }
76
+ out.write(`${c.dim(head)}${title}\n`);
77
+ });
78
+ }
79
+ /** What the developer picked: a list index, every push, quit (also on end of input), or null when unclear. */
80
+ export function parsePick(answer, count) {
81
+ const text = answer?.trim().toLowerCase() ?? 'q';
82
+ if (text === '')
83
+ return 0;
84
+ if (text === 'q' || text === 'quit')
85
+ return 'quit';
86
+ if (text === 'a' || text === 'all')
87
+ return 'all';
88
+ const n = Number(text);
89
+ return Number.isInteger(n) && n >= 1 && n <= count ? n - 1 : null;
90
+ }
91
+ async function debriefChoice(api, prompter, repo, { push, commits }) {
92
+ const range = rangeForRecordedPush(push.base_sha, push.head_sha, push.commits);
93
+ return runDebrief(api, prompter, { repo, baseSha: range.base, headSha: range.head, commits, diff: buildDiff([range]), pushId: push.id });
94
+ }
95
+ /** The debrief was cut off or failed, so stop. "s" (later) is not a stop: it moves on to the next push. */
96
+ const stopped = (outcome) => outcome.reason !== LATER && (outcome.status === 'pending' || outcome.status === 'provider_error');
97
+ /**
98
+ * Lists the pushes still waiting for a debrief, newest first, and debriefs the
99
+ * ones the developer picks, one at a time. `all` keeps going without asking.
100
+ */
101
+ async function debriefOpenPushes(api, prompter, repo, all) {
51
102
  const out = prompter.out;
52
103
  const c = paint(out);
53
104
  const { pushes } = await api.request('GET', `/api/pushes/open?repo=${encodeURIComponent(repo)}`);
@@ -55,28 +106,46 @@ async function debriefOpenPushes(api, prompter, repo) {
55
106
  out.write(`${c.green('✓')} Nothing to debrief for ${repo}.\n`);
56
107
  return 0;
57
108
  }
58
- out.write(`${pushes.length} ${plural(pushes.length, 'push', 'pushes')} waiting for a debrief in ${repo}.\n`);
59
- let code = 0;
60
- for (const push of pushes) {
61
- const missing = [push.head_sha, ...push.commits].find((sha) => !objectExists(sha));
62
- if (missing) {
63
- out.write(c.yellow(`Ownit: commit ${missing.slice(0, 7)} is not in this clone. Run "git fetch" and try again.\n`));
64
- code = 1;
109
+ let choices = pushes.map(toChoice);
110
+ if (all)
111
+ return debriefInOrder(api, prompter, repo, choices);
112
+ while (choices.length) {
113
+ out.write(`\n${choices.length} ${plural(choices.length, 'push', 'pushes')} waiting for a debrief in ${repo}:\n\n`);
114
+ printChoices(out, choices);
115
+ const pick = parsePick(await prompter.ask(`\nDebrief which one? ${c.dim('[number, Enter for 1, a = all, q = quit]')} `), choices.length);
116
+ if (pick === 'quit') {
117
+ out.write(c.dim('Run "ownit interview" any time to come back.\n'));
118
+ return 0;
119
+ }
120
+ if (pick === null) {
121
+ out.write(c.yellow(`Pick a number from 1 to ${choices.length}, a or q.\n`));
122
+ continue;
123
+ }
124
+ if (pick === 'all')
125
+ return debriefInOrder(api, prompter, repo, choices);
126
+ const choice = choices[pick];
127
+ if (!choice.commits.length) {
128
+ out.write(c.yellow(`Commit ${choice.push.head_sha.slice(0, 7)} is not in this clone. Run "git fetch" and try again.\n`));
65
129
  continue;
66
130
  }
67
- const range = rangeForRecordedPush(push.base_sha, push.head_sha, push.commits);
68
- const known = new Set(push.commits);
69
- const commits = listCommits([range.base ? `${range.base}..${range.head}` : range.head]).filter((x) => known.has(x.sha));
70
- const outcome = await runDebrief(api, prompter, {
71
- repo,
72
- baseSha: range.base,
73
- headSha: range.head,
74
- commits: commits.length ? commits : listCommits(['-1', range.head]),
75
- diff: buildDiff([range]),
76
- pushId: push.id,
77
- });
78
- if (outcome.status === 'pending' || outcome.status === 'provider_error')
131
+ const outcome = await debriefChoice(api, prompter, repo, choice);
132
+ if (stopped(outcome))
79
133
  return 1;
134
+ if (outcome.reason !== LATER)
135
+ choices = choices.filter((x) => x !== choice);
136
+ }
137
+ out.write(`\n${c.green('✓')} Nothing left to debrief for ${repo}.\n`);
138
+ return 0;
139
+ }
140
+ /** Debriefs every push in the list, newest first, until one is paused or fails. */
141
+ async function debriefInOrder(api, prompter, repo, choices) {
142
+ for (const choice of choices.filter((x) => x.commits.length)) {
143
+ if (stopped(await debriefChoice(api, prompter, repo, choice)))
144
+ return 1;
145
+ }
146
+ const missing = choices.filter((x) => !x.commits.length).length;
147
+ if (missing) {
148
+ prompter.out.write(paint(prompter.out).yellow(`Ownit: ${missing} ${plural(missing, 'push', 'pushes')} not in this clone. Run "git fetch" and try again.\n`));
80
149
  }
81
- return code;
150
+ return missing ? 1 : 0;
82
151
  }
@@ -0,0 +1,54 @@
1
+ import { createApi } from '../api.js';
2
+ import { githubRepo } from '../git.js';
3
+ import { paint } from '../ui.js';
4
+ const USAGE = 'Usage: ownit settings [questions|answers|earlier] [on|off]\n';
5
+ /** The setting each name changes on the server. */
6
+ const SETTING = { questions: 'show_questions', answers: 'show_answers', earlier: 'allow_backfill' };
7
+ function describe(s) {
8
+ const page = s.private
9
+ ? 'Questions and answers: hidden (private repo)'
10
+ : `Questions: ${s.show_questions ? 'shown' : 'hidden'}\nAnswers: ${s.show_answers ? 'shown' : 'hidden'}`;
11
+ return `${page}\nEarlier commits: ${s.allow_backfill ? 'count once debriefed' : 'not counted'}`;
12
+ }
13
+ /** A line about what just changed, or how to change things. */
14
+ function note(s, changed, on) {
15
+ if (changed === 'answers' && on)
16
+ return 'From now on, passing answers are saved and shown. Developers see a note before they answer.';
17
+ if (changed === 'earlier' && on)
18
+ return 'Debrief older commits with "ownit interview --range A..B" (or a single commit SHA). They count once verified, never as unverified.';
19
+ if (changed === 'earlier')
20
+ return 'New debriefs of older commits no longer count. Ones already verified stay.';
21
+ if (changed && !s.show_answers)
22
+ return 'Saved answers, if any, are deleted.';
23
+ if (changed)
24
+ return '';
25
+ return 'Change with "ownit settings questions|answers|earlier on|off" (repo admins and maintainers).';
26
+ }
27
+ /**
28
+ * `ownit settings`: shows this repo's Ownit settings and page link.
29
+ * `ownit settings questions|answers|earlier on|off` changes one (admins and maintainers).
30
+ */
31
+ export async function settingsCommand(args) {
32
+ const out = process.stderr;
33
+ const [what, value, ...rest] = args;
34
+ const changed = what;
35
+ if (changed !== undefined && (!Object.hasOwn(SETTING, changed) || (value !== 'on' && value !== 'off') || rest.length)) {
36
+ out.write(USAGE);
37
+ return 1;
38
+ }
39
+ const repo = githubRepo();
40
+ if (!repo) {
41
+ out.write('Ownit: no GitHub remote found. Run this in a repo set up with "ownit init".\n');
42
+ return 1;
43
+ }
44
+ const api = createApi();
45
+ const settings = changed
46
+ ? await api.request('PUT', '/api/repos/settings', { repo, [SETTING[changed]]: value === 'on' })
47
+ : await api.request('GET', `/api/repos/settings?repo=${encodeURIComponent(repo)}`);
48
+ const c = paint(out);
49
+ out.write(`${c.bold(settings.page_url)}\n${describe(settings)}\n`);
50
+ const line = note(settings, changed, value === 'on');
51
+ if (line)
52
+ out.write(`${c.dim(line)}\n`);
53
+ return 0;
54
+ }
package/dist/debrief.js CHANGED
@@ -3,9 +3,12 @@ import { errorMessage, paint, plural, sentence, spinner, wrap } from './ui.js';
3
3
  const MAX_ANSWER_CHARS = 2000;
4
4
  export const RESUME_HINT = 'Run "ownit interview" when you\'re ready.';
5
5
  const KIND_LABEL = { why: 'why', how: 'how', next: "what's next" };
6
- function isSkip(answer) {
6
+ /** "s" (or the older "skip") stops the debrief for now. */
7
+ function isLater(answer) {
7
8
  return /^(s|skip|:skip)$/i.test(answer.trim());
8
9
  }
10
+ /** The reason on a debrief the developer stopped with "s", as opposed to one that was cut off. */
11
+ export const LATER = 'later';
9
12
  /** Shows a spinner while `work` runs. */
10
13
  async function withSpinner(out, text, work) {
11
14
  const spin = spinner(out, text);
@@ -48,8 +51,8 @@ async function askUntilPassed(debrief, first) {
48
51
  out.write(`\n${c.yellow('Debrief paused.')} ${RESUME_HINT}\n`);
49
52
  return { status: 'pending', reason: 'debrief paused' };
50
53
  }
51
- if (isSkip(answer))
52
- return skip(api, out, debrief.session);
54
+ if (isLater(answer))
55
+ return later(out);
53
56
  let result;
54
57
  try {
55
58
  result = await withSpinner(out, 'Checking your answer…', () => api.request('POST', '/api/interview/answer', {
@@ -63,6 +66,7 @@ async function askUntilPassed(debrief, first) {
63
66
  return failure(out, error);
64
67
  }
65
68
  debrief.session = result.session;
69
+ debrief.earlierCredited = result.earlier_credited ?? 0;
66
70
  if (result.pass) {
67
71
  out.write(`${c.green('✓ Passed.')}\n${c.dim(wrap(result.feedback))}\n`);
68
72
  return null;
@@ -100,25 +104,25 @@ export async function runDebrief(api, prompter, target) {
100
104
  catch (error) {
101
105
  return failure(out, error);
102
106
  }
103
- out.write(c.dim(`Answer in your own words, up to 5 sentences. Rough is fine. Type "s" to skip.\n`));
104
- const debrief = { api, prompter, total: start.total, payload, session: start.session };
107
+ out.write(c.dim(`Answer in your own words, up to 5 sentences. Rough is fine. Type "s" to stop and finish later.\n`));
108
+ if (start.answers_public)
109
+ out.write(c.yellow('Note: this repo shows passing answers on its public Ownit page.\n'));
110
+ const debrief = { api, prompter, total: start.total, payload, session: start.session, earlierCredited: 0 };
105
111
  for (const question of start.questions) {
106
112
  const ended = await askUntilPassed(debrief, question);
107
113
  if (ended)
108
114
  return ended;
109
115
  }
110
116
  out.write(`\n${c.green(c.bold('✓ Push verified.'))} ${c.dim(`${target.commits.length} ${commitWord} owned. Nice work.`)}\n`);
117
+ const earlier = debrief.earlierCredited;
118
+ if (earlier)
119
+ out.write(c.dim(`${earlier} earlier ${plural(earlier, 'commit')} now ${earlier === 1 ? 'counts' : 'count'} as verified.\n`));
111
120
  return { status: 'verified' };
112
121
  }
113
- async function skip(api, out, session) {
114
- try {
115
- await api.request('POST', '/api/interview/skip', { session });
116
- }
117
- catch (error) {
118
- return failure(out, error);
119
- }
120
- out.write(`${paint(out).dim('Skipped. This push is recorded as skipped.')}\n`);
121
- return { status: 'skipped' };
122
+ /** "s" stops for now. Nothing is recorded: the push stays pending, so it waits in `ownit interview`. */
123
+ function later(out) {
124
+ out.write(`${paint(out).dim(`Stopped. This push stays unverified until you debrief it. ${RESUME_HINT}`)}\n`);
125
+ return { status: 'pending', reason: LATER };
122
126
  }
123
127
  /** Prints a one-line reason. Provider problems stay out of the stats until retried. */
124
128
  function failure(out, error) {
package/dist/index.js CHANGED
@@ -6,7 +6,8 @@ import { initCommand, uninstallCommand } from './commands/init.js';
6
6
  import { interviewCommand } from './commands/interview.js';
7
7
  import { keyCommand } from './commands/key.js';
8
8
  import { loginCommand, logoutCommand, whoamiCommand } from './commands/login.js';
9
- import { errorMessage } from './ui.js';
9
+ import { settingsCommand } from './commands/settings.js';
10
+ import { closestMatch, errorMessage } from './ui.js';
10
11
  /** Every command, in the order `ownit help` lists them. */
11
12
  const COMMANDS = {
12
13
  login: { help: [['ownit login [--server URL]', 'Sign in with GitHub (device flow)']], run: loginCommand },
@@ -15,8 +16,9 @@ const COMMANDS = {
15
16
  init: { help: [['ownit init [--repo owner/name]', 'Install the pre-push hook in this repo']], run: initCommand },
16
17
  interview: {
17
18
  help: [
18
- ['ownit interview', 'Debrief pushes that are still pending'],
19
- ['ownit interview --range A..B', 'Debrief a commit range'],
19
+ ['ownit interview', 'List waiting pushes and debrief the one you pick'],
20
+ ['ownit interview --all', 'Debrief every waiting push in turn'],
21
+ ['ownit interview --range A..B', 'Debrief a commit range, or one commit (SHA)'],
20
22
  ],
21
23
  run: interviewCommand,
22
24
  },
@@ -28,13 +30,22 @@ const COMMANDS = {
28
30
  run: keyCommand,
29
31
  },
30
32
  badge: { help: [['ownit badge', "Print this repo's badge Markdown for your README"]], run: badgeCommand },
33
+ settings: {
34
+ help: [
35
+ ['ownit settings', "Show this repo's settings and page link"],
36
+ ['ownit settings questions on|off', 'Show debrief questions on the page (admins)'],
37
+ ['ownit settings answers on|off', 'Show passing answers on the page (admins)'],
38
+ ['ownit settings earlier on|off', 'Count older commits once debriefed (admins)'],
39
+ ],
40
+ run: settingsCommand,
41
+ },
31
42
  uninstall: { help: [['ownit uninstall', 'Remove the pre-push hook']], run: uninstallCommand },
32
43
  help: { help: [['ownit help', 'Show this help']], run: () => print(helpText()) },
33
44
  // Not listed: the installed git hook calls `ownit hook pre-push`.
34
45
  hook: { run: (args) => hookCommand(args) },
35
46
  version: { run: () => print(`${version()}\n`) },
36
47
  };
37
- const ALIASES = { status: 'whoami', '--help': 'help', '-h': 'help', '--version': 'version', '-v': 'version' };
48
+ const ALIASES = { page: 'settings', status: 'whoami', '--help': 'help', '-h': 'help', '--version': 'version', '-v': 'version' };
38
49
  function findCommand(name) {
39
50
  const key = Object.hasOwn(ALIASES, name) ? ALIASES[name] : name;
40
51
  return Object.hasOwn(COMMANDS, key) ? COMMANDS[key] : undefined;
@@ -64,7 +75,12 @@ async function main(argv) {
64
75
  const [name = 'help', ...args] = argv;
65
76
  const command = findCommand(name);
66
77
  if (!command) {
67
- process.stderr.write(`Unknown command "${name}".\n\n${helpText()}`);
78
+ // Suggest only listed commands, so a typo never points at the hidden hook command.
79
+ const listed = Object.keys(COMMANDS).filter((key) => COMMANDS[key].help);
80
+ const guess = closestMatch(name, listed);
81
+ process.stderr.write(guess
82
+ ? `Unknown command "${name}". Did you mean "ownit ${guess}"?\nRun "ownit help" to see every command.\n`
83
+ : `Unknown command "${name}".\n\n${helpText()}`);
68
84
  return 1;
69
85
  }
70
86
  return command.run(args);
package/dist/ui.js CHANGED
@@ -95,3 +95,26 @@ export function wrap(text, indent = ' ', width = Math.min(process.stderr.column
95
95
  lines.push(line);
96
96
  return lines.map((l) => indent + l).join('\n');
97
97
  }
98
+ /** Edit distance between two short strings (insert, delete, or replace one character). */
99
+ function editDistance(a, b) {
100
+ let previous = Array.from({ length: b.length + 1 }, (_, j) => j);
101
+ for (let i = 1; i <= a.length; i++) {
102
+ const current = [i];
103
+ for (let j = 1; j <= b.length; j++) {
104
+ current[j] = Math.min(previous[j] + 1, current[j - 1] + 1, previous[j - 1] + (a[i - 1] === b[j - 1] ? 0 : 1));
105
+ }
106
+ previous = current;
107
+ }
108
+ return previous[b.length];
109
+ }
110
+ /** The candidate a typo most likely meant, or null when none is close. */
111
+ export function closestMatch(typed, candidates) {
112
+ const word = typed.toLowerCase();
113
+ let best = null;
114
+ for (const name of candidates) {
115
+ const distance = editDistance(word, name);
116
+ if (distance <= 2 && distance < (best?.distance ?? Infinity))
117
+ best = { name, distance };
118
+ }
119
+ return best?.name ?? null;
120
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "getownit",
3
- "version": "0.1.1",
3
+ "version": "0.1.2",
4
4
  "description": "Debrief the code you just pushed, in your terminal, with NVIDIA Nemotron.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -31,6 +31,7 @@
31
31
  ],
32
32
  "scripts": {
33
33
  "build": "tsc -p tsconfig.json",
34
+ "prepublishOnly": "npm run build && npm test",
34
35
  "test": "vitest run",
35
36
  "typecheck": "tsc -p tsconfig.json --noEmit"
36
37
  },