getownit 0.1.0 → 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).
package/dist/api.js CHANGED
@@ -1,4 +1,5 @@
1
- import { modelKey, readConfig, serverUrl } from './config.js';
1
+ import { readConfig, serverUrl } from './config.js';
2
+ import { modelKey } from './providers.js';
2
3
  export class ApiError extends Error {
3
4
  status;
4
5
  code;
@@ -6,7 +6,7 @@ import { RESUME_HINT, runDebrief } from '../debrief.js';
6
6
  import { buildDiff } from '../diff.js';
7
7
  import { githubRepo } from '../git.js';
8
8
  import { computePush, parseRefs } from '../pushRange.js';
9
- import { createPrompter, paint, sentence } from '../ui.js';
9
+ import { createPrompter, errorMessage, paint, plural, sentence } from '../ui.js';
10
10
  const PROMPT_TIMEOUT_MS = 120_000;
11
11
  const QUICK_TIMEOUT_MS = 8_000;
12
12
  // Coding agents and CI run git without a person at the keyboard.
@@ -38,19 +38,58 @@ 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';
43
+ const value = answer?.trim().toLowerCase();
44
+ return value === '' || value === 'y' || value === 'yes' ? 'debrief' : 'later';
50
45
  }
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;
46
+ async function recordPending(api, repo, push, out) {
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 });
48
+ out.write(`Ownit: push recorded as pending. ${RESUME_HINT}\n`);
49
+ return 0;
50
+ }
51
+ /**
52
+ * What the server already recorded for these commits, so a verified push is not
53
+ * asked again. Only an unreachable server is an error; anything else means unknown.
54
+ */
55
+ async function knownStatus(api, repo, push) {
56
+ const known = await api
57
+ .request('POST', '/api/pushes/lookup', { repo, head_sha: push.head, shas: push.commits.map((x) => x.sha) }, { timeoutMs: QUICK_TIMEOUT_MS })
58
+ .catch((error) => {
59
+ if (error instanceof ApiError && error.code === 'network')
60
+ throw error;
61
+ return { status: null };
62
+ });
63
+ return known.status;
64
+ }
65
+ /** Asks whether to debrief now, then debriefs or leaves the push pending for `ownit interview`. */
66
+ async function askAndDebrief(api, terminal, repo, push, out) {
67
+ const c = paint(out);
68
+ const prompter = createPrompter(terminal, out);
69
+ try {
70
+ const n = push.commits.length;
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')
73
+ return await recordPending(api, repo, push, out);
74
+ await runDebrief(api, prompter, {
75
+ repo,
76
+ baseSha: push.base,
77
+ headSha: push.head,
78
+ commits: push.commits,
79
+ diff: buildDiff(push.ranges),
80
+ });
81
+ return 0;
82
+ }
83
+ finally {
84
+ prompter.close();
85
+ if (terminal !== process.stdin)
86
+ terminal.destroy();
87
+ }
88
+ }
89
+ function continueReason(error) {
90
+ return error instanceof ApiError && error.code === 'network'
91
+ ? `${sentence(error.message)} The push continues and will show as pending.`
92
+ : `${sentence(errorMessage(error))} The push continues.`;
54
93
  }
55
94
  /** `ownit hook pre-push <remote> <url>`. Always exits 0: the debrief never blocks a push. */
56
95
  export async function hookCommand(argv, env = process.env) {
@@ -73,58 +112,19 @@ export async function hookCommand(argv, env = process.env) {
73
112
  return 0;
74
113
  }
75
114
  const api = createApi(config, env);
76
- const known = await api
77
- .request('POST', '/api/pushes/lookup', { repo, head_sha: push.head, shas: push.commits.map((x) => x.sha) }, { timeoutMs: QUICK_TIMEOUT_MS })
78
- .catch((error) => {
79
- if (error instanceof ApiError && error.code === 'network')
80
- throw error;
81
- return { status: null };
82
- });
83
- if (known.status === 'verified' || known.status === 'skipped') {
84
- out.write(c.dim(`Ownit: this push is already ${known.status}.\n`));
115
+ const known = await knownStatus(api, repo, push);
116
+ // Skipped pushes are asked again: they stay unverified until debriefed.
117
+ if (known === 'verified') {
118
+ out.write(c.dim(`Ownit: this push is already ${known}.\n`));
85
119
  return 0;
86
120
  }
87
121
  const terminal = openTerminal(env);
88
- if (!terminal) {
89
- await record(api, repo, push, 'pending');
90
- out.write(`Ownit: push recorded as pending. ${RESUME_HINT}\n`);
91
- return 0;
92
- }
93
- const prompter = createPrompter(terminal, out);
94
- try {
95
- const n = push.commits.length;
96
- const answer = await prompter.ask(`${c.bold('Ownit')} ${c.dim(`(${n} ${n === 1 ? 'commit' : 'commits'} to ${repo})`)} Debrief now? [Y/n/s=skip] `, PROMPT_TIMEOUT_MS);
97
- const choice = interpretChoice(answer);
98
- if (choice === 'skip') {
99
- await record(api, repo, push, 'skipped');
100
- out.write(c.dim('Ownit: skipped. Push continues.\n'));
101
- return 0;
102
- }
103
- if (choice === 'later') {
104
- await record(api, repo, push, 'pending');
105
- out.write(`Ownit: push recorded as pending. ${RESUME_HINT}\n`);
106
- return 0;
107
- }
108
- await runDebrief(api, prompter, {
109
- repo,
110
- baseSha: push.base,
111
- headSha: push.head,
112
- commits: push.commits,
113
- diff: buildDiff(push.ranges),
114
- });
115
- return 0;
116
- }
117
- finally {
118
- prompter.close();
119
- if (terminal !== process.stdin)
120
- terminal.destroy();
121
- }
122
+ if (!terminal)
123
+ return await recordPending(api, repo, push, out);
124
+ return await askAndDebrief(api, terminal, repo, push, out);
122
125
  }
123
126
  catch (error) {
124
- const reason = error instanceof ApiError && error.code === 'network'
125
- ? `${sentence(error.message)} The push continues and will show as pending.`
126
- : `${sentence(error instanceof Error ? error.message : String(error))} The push continues.`;
127
- out.write(`${c.yellow('Ownit:')} ${reason} ${RESUME_HINT}\n`);
127
+ out.write(`${c.yellow('Ownit:')} ${continueReason(error)} ${RESUME_HINT}\n`);
128
128
  return 0;
129
129
  }
130
130
  }
@@ -2,31 +2,26 @@ import { parseArgs } from 'node:util';
2
2
  import { createApi } from '../api.js';
3
3
  import { git, githubRepo } from '../git.js';
4
4
  import { hookLocation, installHook, MANAGED_SNIPPET, uninstallHook } from '../hooks.js';
5
- import { paint, sentence } from '../ui.js';
5
+ import { errorMessage, paint, sentence } from '../ui.js';
6
6
  import { badgeMarkdown } from './badge.js';
7
- export async function initCommand(argv) {
8
- const { values } = parseArgs({ args: argv, options: { repo: { type: 'string' } } });
9
- const out = process.stderr;
7
+ /** Installs the pre-push hook where git looks for it, or explains how to add it to a hook manager. */
8
+ function installHookHere(out) {
10
9
  const c = paint(out);
11
- if (values.repo)
12
- git(['config', 'ownit.repo', values.repo]);
13
- const repo = githubRepo();
14
- if (!repo) {
15
- out.write('Ownit: no GitHub remote found. Add one, or run "ownit init --repo owner/name".\n');
16
- return 1;
17
- }
18
10
  const location = hookLocation();
19
11
  if (location.kind === 'manual') {
20
12
  out.write(`Ownit: ${location.reason}, so add these lines to the end of .husky/pre-push (create the file if needed):\n\n`);
21
13
  out.write(`${MANAGED_SNIPPET}\n\n`);
14
+ return;
22
15
  }
23
- else {
24
- const result = installHook(location.dir);
25
- out.write(`${c.green('✓')} ${result.updated ? 'Updated' : 'Installed'} the pre-push hook in ${location.dir}`);
26
- out.write(location.via === 'core.hooksPath' ? ' (from core.hooksPath).\n' : '.\n');
27
- if (result.chained)
28
- out.write(` Your existing hook is now pre-push.local and still runs first.\n`);
29
- }
16
+ const result = installHook(location.dir);
17
+ out.write(`${c.green('✓')} ${result.updated ? 'Updated' : 'Installed'} the pre-push hook in ${location.dir}`);
18
+ out.write(location.via === 'core.hooksPath' ? ' (from core.hooksPath).\n' : '.\n');
19
+ if (result.chained)
20
+ out.write(` Your existing hook is now pre-push.local and still runs first.\n`);
21
+ }
22
+ /** Registers the repo with the Ownit server and shows what to do next. */
23
+ async function registerRepo(repo, out) {
24
+ const c = paint(out);
30
25
  try {
31
26
  const api = createApi();
32
27
  const registered = await api.request('POST', '/api/repos', { repo }, { timeoutMs: 15_000 });
@@ -42,12 +37,26 @@ export async function initCommand(argv) {
42
37
  out.write(`\nAdd the badge to your README (run "ownit badge" to see this again):\n ${badgeMarkdown(origin, registered.repo)}\n`);
43
38
  }
44
39
  catch (error) {
45
- out.write(`${c.yellow('Ownit:')} the hook is installed, but registering ${repo} failed: ${sentence(error instanceof Error ? error.message : String(error))}\n`);
40
+ out.write(`${c.yellow('Ownit:')} the hook is installed, but registering ${repo} failed: ${sentence(errorMessage(error))}\n`);
46
41
  out.write('Run "ownit init" again once that is fixed.\n');
47
42
  return 1;
48
43
  }
49
44
  return 0;
50
45
  }
46
+ /** `ownit init [--repo owner/name]` */
47
+ export async function initCommand(argv) {
48
+ const { values } = parseArgs({ args: argv, options: { repo: { type: 'string' } } });
49
+ const out = process.stderr;
50
+ if (values.repo)
51
+ git(['config', 'ownit.repo', values.repo]);
52
+ const repo = githubRepo();
53
+ if (!repo) {
54
+ out.write('Ownit: no GitHub remote found. Add one, or run "ownit init --repo owner/name".\n');
55
+ return 1;
56
+ }
57
+ installHookHere(out);
58
+ return registerRepo(repo, out);
59
+ }
51
60
  export async function uninstallCommand() {
52
61
  const out = process.stderr;
53
62
  const location = hookLocation();
@@ -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
- import { createPrompter, paint } from '../ui.js';
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} push${pushes.length === 1 ? '' : 'es'} 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
  }
@@ -1,4 +1,5 @@
1
- import { describeKey, isProvider, modelKey, PROVIDER_IDS, PROVIDERS, providerByPrefix, readConfig, updateConfig } from '../config.js';
1
+ import { readConfig, updateConfig } from '../config.js';
2
+ import { describeKey, isProvider, modelKey, PROVIDER_IDS, PROVIDERS, providerByPrefix } from '../providers.js';
2
3
  import { readSecret } from '../secret.js';
3
4
  import { createPrompter } from '../ui.js';
4
5
  const CHOICES = PROVIDER_IDS.join('|');
@@ -30,39 +31,49 @@ async function chooseProvider(arg) {
30
31
  out.write(`Ownit: "${answer}" is not one of the choices; nothing changed.\n`);
31
32
  return picked;
32
33
  }
33
- export async function keyCommand(argv) {
34
+ async function setKey(providerArg) {
34
35
  const out = process.stderr;
35
- const [action = 'status', providerArg] = argv;
36
- if (action === 'set') {
37
- const provider = await chooseProvider(providerArg);
38
- if (!provider)
39
- return 1;
40
- const { name } = PROVIDERS[provider];
41
- // Read from a prompt or stdin, never argv, so the key stays out of shell history.
42
- const key = (await readSecret(`${name} API key (input hidden): `)).trim();
43
- if (!key) {
44
- out.write('Ownit: no key entered; nothing changed.\n');
45
- return 1;
46
- }
47
- const looksLike = providerByPrefix(key);
48
- if (looksLike && looksLike !== provider) {
49
- out.write(`Ownit: that key looks like it is for ${PROVIDERS[looksLike].name}. Run "ownit key set ${looksLike}" instead; nothing changed.\n`);
50
- return 1;
51
- }
52
- updateConfig({ provider, apiKey: key, nebiusKey: undefined });
53
- out.write(`Saved your ${name} key to ~/.ownit/config.json. It is sent only with debrief requests and never stored by Ownit.\n`);
54
- return 0;
36
+ const provider = await chooseProvider(providerArg);
37
+ if (!provider)
38
+ return 1;
39
+ const { name } = PROVIDERS[provider];
40
+ // Read from a prompt or stdin, never argv, so the key stays out of shell history.
41
+ const key = (await readSecret(`${name} API key (input hidden): `)).trim();
42
+ if (!key) {
43
+ out.write('Ownit: no key entered; nothing changed.\n');
44
+ return 1;
55
45
  }
56
- if (action === 'clear') {
57
- updateConfig({ provider: undefined, apiKey: undefined, nebiusKey: undefined });
58
- out.write('Removed your API key.\n');
59
- return 0;
46
+ const looksLike = providerByPrefix(key);
47
+ if (looksLike && looksLike !== provider) {
48
+ out.write(`Ownit: that key looks like it is for ${PROVIDERS[looksLike].name}. Run "ownit key set ${looksLike}" instead; nothing changed.\n`);
49
+ return 1;
60
50
  }
61
- if (action === 'status') {
62
- const model = modelKey(readConfig());
63
- out.write(model ? `API key set (${describeKey(model)}).\n` : 'No API key set. Run "ownit key set" with a Nebius or NVIDIA key.\n');
64
- return 0;
51
+ updateConfig({ provider, apiKey: key, nebiusKey: undefined });
52
+ out.write(`Saved your ${name} key to ~/.ownit/config.json. It is sent only with debrief requests and never stored by Ownit.\n`);
53
+ return 0;
54
+ }
55
+ function clearKey() {
56
+ updateConfig({ provider: undefined, apiKey: undefined, nebiusKey: undefined });
57
+ process.stderr.write('Removed your API key.\n');
58
+ return 0;
59
+ }
60
+ function showKey() {
61
+ const model = modelKey(readConfig());
62
+ process.stderr.write(model ? `API key set (${describeKey(model)}).\n` : 'No API key set. Run "ownit key set" with a Nebius or NVIDIA key.\n');
63
+ return 0;
64
+ }
65
+ /** `ownit key set [provider] | clear | status` */
66
+ export async function keyCommand(argv) {
67
+ const [action = 'status', providerArg] = argv;
68
+ switch (action) {
69
+ case 'set':
70
+ return setKey(providerArg);
71
+ case 'clear':
72
+ return clearKey();
73
+ case 'status':
74
+ return showKey();
75
+ default:
76
+ process.stderr.write(`Usage: ownit key set [${CHOICES}] | clear | status\n`);
77
+ return 1;
65
78
  }
66
- out.write(`Usage: ownit key set [${CHOICES}] | clear | status\n`);
67
- return 1;
68
79
  }
@@ -1,6 +1,7 @@
1
1
  import { parseArgs } from 'node:util';
2
2
  import { ApiError, createApi } from '../api.js';
3
- import { describeKey, modelKey, readConfig, serverUrl, updateConfig } from '../config.js';
3
+ import { readConfig, serverUrl, updateConfig } from '../config.js';
4
+ import { describeKey, modelKey } from '../providers.js';
4
5
  import { readSecret } from '../secret.js';
5
6
  import { paint } from '../ui.js';
6
7
  const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
@@ -24,33 +25,10 @@ async function githubOAuth(path, form) {
24
25
  return (await response.json());
25
26
  }
26
27
  /**
27
- * `ownit login`: the GitHub device flow runs here, from the user's own IP
28
- * (GitHub rate-limits its login endpoints per IP, which shared server IPs
29
- * hit). The server then verifies the identity, drops the GitHub token, and
30
- * issues an Ownit session token.
28
+ * Polls GitHub until the user authorizes the device code. Returns the GitHub
29
+ * token, or the reason the login ended without one.
31
30
  */
32
- export async function loginCommand(argv) {
33
- const { values } = parseArgs({ args: argv, options: { server: { type: 'string' }, 'with-token': { type: 'boolean' } } });
34
- const out = process.stderr;
35
- const c = paint(out);
36
- if (values.server)
37
- updateConfig({ server: values.server.replace(/\/+$/, '') });
38
- if (values['with-token']) {
39
- // For self-hosted or local servers: read a session token from stdin.
40
- const token = (await readSecret('Ownit session token: ')).trim();
41
- if (!/^ownit_[\w-]{20,}$/.test(token)) {
42
- out.write('Ownit: that does not look like an Ownit session token.\n');
43
- return 1;
44
- }
45
- updateConfig({ token, login: undefined });
46
- out.write(`${c.green('✓')} Saved the session token.\n`);
47
- return 0;
48
- }
49
- const api = createApi({ ...readConfig(), token: undefined });
50
- const { github_client_id: clientId } = await api.request('GET', '/auth/config');
51
- const start = await githubOAuth('/login/device/code', { client_id: clientId });
52
- out.write(`\nOpen ${c.bold(start.verification_uri)} and enter the code ${c.bold(c.cyan(start.user_code))}\n`);
53
- out.write(c.dim('Waiting for you to authorize Ownit on GitHub…\n'));
31
+ async function pollForAccessToken(clientId, start) {
54
32
  let interval = Math.max(start.interval, 0) * 1000;
55
33
  const deadline = Date.now() + start.expires_in * 1000;
56
34
  while (Date.now() < deadline) {
@@ -60,24 +38,60 @@ export async function loginCommand(argv) {
60
38
  device_code: start.device_code,
61
39
  grant_type: 'urn:ietf:params:oauth:grant-type:device_code',
62
40
  });
63
- if (reply.access_token) {
64
- // The GitHub token goes to the Ownit server once, to prove who you are, and is not kept anywhere.
65
- const session = await api.request('POST', '/auth/github', { access_token: reply.access_token });
66
- updateConfig({ token: session.token, login: session.login });
67
- out.write(`${c.green('✓')} Logged in as ${c.bold(session.login)} on ${serverUrl(readConfig())}.\n`);
68
- return 0;
69
- }
41
+ if (reply.access_token)
42
+ return { token: reply.access_token };
70
43
  if (reply.error === 'authorization_pending')
71
44
  continue;
72
45
  if (reply.error === 'slow_down') {
73
46
  interval = Math.max(interval + 5000, (reply.interval ?? 0) * 1000);
74
47
  continue;
75
48
  }
76
- out.write(`Ownit: login ${reply.error === 'access_denied' ? 'was cancelled' : `failed (${reply.error ?? 'unknown error'})`}. Run "ownit login" to try again.\n`);
49
+ return { failure: `login ${reply.error === 'access_denied' ? 'was cancelled' : `failed (${reply.error ?? 'unknown error'})`}.` };
50
+ }
51
+ return { failure: 'the login code expired.' };
52
+ }
53
+ /** For self-hosted or local servers: reads a session token from stdin. */
54
+ async function loginWithToken() {
55
+ const out = process.stderr;
56
+ const token = (await readSecret('Ownit session token: ')).trim();
57
+ if (!/^ownit_[\w-]{20,}$/.test(token)) {
58
+ out.write('Ownit: that does not look like an Ownit session token.\n');
59
+ return 1;
60
+ }
61
+ updateConfig({ token, login: undefined });
62
+ out.write(`${paint(out).green('✓')} Saved the session token.\n`);
63
+ return 0;
64
+ }
65
+ /**
66
+ * The GitHub device flow runs here, from the user's own IP (GitHub rate-limits
67
+ * its login endpoints per IP, which shared server IPs hit). The server then
68
+ * verifies the identity, drops the GitHub token, and issues an Ownit session token.
69
+ */
70
+ async function deviceFlowLogin() {
71
+ const out = process.stderr;
72
+ const c = paint(out);
73
+ const api = createApi({ ...readConfig(), token: undefined });
74
+ const { github_client_id: clientId } = await api.request('GET', '/auth/config');
75
+ const start = await githubOAuth('/login/device/code', { client_id: clientId });
76
+ out.write(`\nOpen ${c.bold(start.verification_uri)} and enter the code ${c.bold(c.cyan(start.user_code))}\n`);
77
+ out.write(c.dim('Waiting for you to authorize Ownit on GitHub…\n'));
78
+ const result = await pollForAccessToken(clientId, start);
79
+ if ('failure' in result) {
80
+ out.write(`Ownit: ${result.failure} Run "ownit login" to try again.\n`);
77
81
  return 1;
78
82
  }
79
- out.write('Ownit: the login code expired. Run "ownit login" to try again.\n');
80
- return 1;
83
+ // The GitHub token goes to the Ownit server once, to prove who you are, and is not kept anywhere.
84
+ const session = await api.request('POST', '/auth/github', { access_token: result.token });
85
+ updateConfig({ token: session.token, login: session.login });
86
+ out.write(`${c.green('✓')} Logged in as ${c.bold(session.login)} on ${serverUrl(readConfig())}.\n`);
87
+ return 0;
88
+ }
89
+ /** `ownit login [--server URL] [--with-token]` */
90
+ export async function loginCommand(argv) {
91
+ const { values } = parseArgs({ args: argv, options: { server: { type: 'string' }, 'with-token': { type: 'boolean' } } });
92
+ if (values.server)
93
+ updateConfig({ server: values.server.replace(/\/+$/, '') });
94
+ return values['with-token'] ? loginWithToken() : deviceFlowLogin();
81
95
  }
82
96
  export async function logoutCommand() {
83
97
  const config = readConfig();
@@ -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/config.js CHANGED
@@ -1,18 +1,6 @@
1
1
  import { chmodSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
2
2
  import { homedir } from 'node:os';
3
3
  import { join } from 'node:path';
4
- /**
5
- * Model providers the worker accepts, in the order `ownit key set` lists them.
6
- * Add a provider here and in the worker's PROVIDERS.
7
- */
8
- export const PROVIDERS = {
9
- nebius: { name: 'Nebius', envVar: 'NEBIUS_API_KEY', keyUrl: 'https://tokenfactory.nebius.com/', keyPrefix: undefined },
10
- nvidia: { name: 'NVIDIA', envVar: 'NVIDIA_API_KEY', keyUrl: 'https://build.nvidia.com/', keyPrefix: 'nvapi-' },
11
- };
12
- export const PROVIDER_IDS = Object.keys(PROVIDERS);
13
- export function isProvider(value) {
14
- return Object.hasOwn(PROVIDERS, value);
15
- }
16
4
  // The hosted Ownit server. Self-hosters point the CLI elsewhere with `ownit login --server`.
17
5
  export const DEFAULT_SERVER = 'https://ownit.ownit-worker.workers.dev';
18
6
  export function configDir(env = process.env) {
@@ -53,30 +41,6 @@ export function updateConfig(patch, env = process.env) {
53
41
  writeConfig(next, env);
54
42
  return next;
55
43
  }
56
- /** The key to send with debriefs and its provider. A provider's env var wins over the saved key. */
57
- export function modelKey(config, env = process.env) {
58
- for (const provider of PROVIDER_IDS) {
59
- const key = env[PROVIDERS[provider].envVar];
60
- if (key)
61
- return { provider, key };
62
- }
63
- if (config.apiKey)
64
- return { provider: config.provider ?? 'nebius', key: config.apiKey };
65
- if (config.nebiusKey)
66
- return { provider: 'nebius', key: config.nebiusKey };
67
- return undefined;
68
- }
69
- /** The provider whose keys have this key's prefix, if any. */
70
- export function providerByPrefix(key) {
71
- return PROVIDER_IDS.find((id) => {
72
- const prefix = PROVIDERS[id].keyPrefix;
73
- return prefix !== undefined && key.startsWith(prefix);
74
- });
75
- }
76
- /** e.g. "NVIDIA, …1a2b" */
77
- export function describeKey({ provider, key }) {
78
- return `${PROVIDERS[provider]?.name ?? provider}, …${key.slice(-4)}`;
79
- }
80
44
  export function serverUrl(config, env = process.env) {
81
45
  return (env.OWNIT_SERVER || config.server || DEFAULT_SERVER).replace(/\/+$/, '');
82
46
  }
package/dist/debrief.js CHANGED
@@ -1,105 +1,135 @@
1
1
  import { ApiError } from './api.js';
2
- import { paint, sentence, spinner, wrap } from './ui.js';
2
+ 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';
12
+ /** Shows a spinner while `work` runs. */
13
+ async function withSpinner(out, text, work) {
14
+ const spin = spinner(out, text);
15
+ try {
16
+ return await work();
17
+ }
18
+ finally {
19
+ spin.stop();
20
+ }
21
+ }
22
+ function printQuestion(out, question, total, followUp) {
23
+ const c = paint(out);
24
+ const label = `Question ${question.idx + 1}/${total}`;
25
+ out.write(`\n${c.bold(c.cyan(label))} ${c.dim(`(${KIND_LABEL[question.kind]}${followUp ? ', follow-up' : ''})`)}\n`);
26
+ out.write(`${wrap(question.text)}\n`);
27
+ }
28
+ /** The next non-blank answer, or null when the input ends. */
29
+ async function readAnswer(prompter) {
30
+ const c = paint(prompter.out);
31
+ let answer;
32
+ do
33
+ answer = await prompter.ask(c.bold('> '));
34
+ while (answer !== null && !answer.trim());
35
+ return answer;
36
+ }
37
+ /**
38
+ * Asks a question, and follow-ups after a miss, until an answer passes (null)
39
+ * or the debrief ends early (its outcome).
40
+ */
41
+ async function askUntilPassed(debrief, first) {
42
+ const { api, prompter } = debrief;
43
+ const out = prompter.out;
44
+ const c = paint(out);
45
+ let question = first;
46
+ let followUp = false;
47
+ for (;;) {
48
+ printQuestion(out, question, debrief.total, followUp);
49
+ const answer = await readAnswer(prompter);
50
+ if (answer === null) {
51
+ out.write(`\n${c.yellow('Debrief paused.')} ${RESUME_HINT}\n`);
52
+ return { status: 'pending', reason: 'debrief paused' };
53
+ }
54
+ if (isLater(answer))
55
+ return later(out);
56
+ let result;
57
+ try {
58
+ result = await withSpinner(out, 'Checking your answer…', () => api.request('POST', '/api/interview/answer', {
59
+ session: debrief.session,
60
+ idx: question.idx,
61
+ answer: answer.slice(0, MAX_ANSWER_CHARS),
62
+ ...debrief.payload,
63
+ }));
64
+ }
65
+ catch (error) {
66
+ return failure(out, error);
67
+ }
68
+ debrief.session = result.session;
69
+ debrief.earlierCredited = result.earlier_credited ?? 0;
70
+ if (result.pass) {
71
+ out.write(`${c.green('✓ Passed.')}\n${c.dim(wrap(result.feedback))}\n`);
72
+ return null;
73
+ }
74
+ out.write(`${c.yellow('Not quite yet.')}\n${wrap(result.feedback)}\n`);
75
+ if (!result.followup)
76
+ return { status: 'pending', reason: 'no follow-up question' };
77
+ out.write(c.dim("Here's another angle on the same topic:\n"));
78
+ question = result.followup;
79
+ followUp = true;
80
+ }
81
+ }
9
82
  /** Runs one debrief: asks each question, grades answers, and offers follow-ups until every question passes. */
10
83
  export async function runDebrief(api, prompter, target) {
11
84
  const out = prompter.out;
12
85
  const c = paint(out);
13
- const commitWord = target.commits.length === 1 ? 'commit' : 'commits';
14
- out.write(`\n${c.bold('Ownit debrief')} ${c.dim(`· ${target.repo} · ${target.commits.length} ${commitWord} · ${target.diff.changedLines} changed ${target.diff.changedLines === 1 ? 'line' : 'lines'}`)}\n`);
86
+ const commitWord = plural(target.commits.length, 'commit');
87
+ out.write(`\n${c.bold('Ownit debrief')} ${c.dim(`· ${target.repo} · ${target.commits.length} ${commitWord} · ${target.diff.changedLines} changed ${plural(target.diff.changedLines, 'line')}`)}\n`);
15
88
  const payload = {
16
89
  commits: target.commits,
17
90
  diff: target.diff.diff,
18
91
  diff_truncated: target.diff.truncated,
19
92
  };
20
93
  let start;
21
- const spin = spinner(out, 'Nemotron is reading your push…');
22
94
  try {
23
- start = await api.request('POST', '/api/interview/start', {
95
+ start = await withSpinner(out, 'Nemotron is reading your push…', () => api.request('POST', '/api/interview/start', {
24
96
  repo: target.repo,
25
97
  base_sha: target.baseSha,
26
98
  head_sha: target.headSha,
27
99
  changed_lines: target.diff.changedLines,
28
100
  push_id: target.pushId,
29
101
  ...payload,
30
- });
102
+ }));
31
103
  }
32
104
  catch (error) {
33
- spin.stop();
34
105
  return failure(out, error);
35
106
  }
36
- spin.stop();
37
- let session = start.session;
38
- out.write(c.dim(`Answer in your own words, up to 5 sentences. Rough is fine. Type "s" to skip.\n`));
39
- for (const first of start.questions) {
40
- let question = first;
41
- let followUp = false;
42
- for (;;) {
43
- const label = `Question ${question.idx + 1}/${start.total}`;
44
- out.write(`\n${c.bold(c.cyan(label))} ${c.dim(`(${KIND_LABEL[question.kind]}${followUp ? ', follow-up' : ''})`)}\n`);
45
- out.write(`${wrap(question.text)}\n`);
46
- let answer;
47
- do
48
- answer = await prompter.ask(c.bold('> '));
49
- while (answer !== null && !answer.trim());
50
- if (answer === null) {
51
- out.write(`\n${c.yellow('Debrief paused.')} ${RESUME_HINT}\n`);
52
- return { status: 'pending', reason: 'debrief paused' };
53
- }
54
- if (isSkip(answer))
55
- return skip(api, out, session);
56
- const grading = spinner(out, 'Checking your answer…');
57
- let result;
58
- try {
59
- result = await api.request('POST', '/api/interview/answer', {
60
- session,
61
- idx: question.idx,
62
- answer: answer.slice(0, MAX_ANSWER_CHARS),
63
- ...payload,
64
- });
65
- }
66
- catch (error) {
67
- grading.stop();
68
- return failure(out, error);
69
- }
70
- grading.stop();
71
- session = result.session;
72
- if (result.pass) {
73
- out.write(`${c.green('✓ Passed.')}\n${c.dim(wrap(result.feedback))}\n`);
74
- break;
75
- }
76
- out.write(`${c.yellow('Not quite yet.')}\n${wrap(result.feedback)}\n`);
77
- if (!result.followup)
78
- return { status: 'pending', reason: 'no follow-up question' };
79
- out.write(c.dim("Here's another angle on the same topic:\n"));
80
- question = result.followup;
81
- followUp = true;
82
- }
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 };
111
+ for (const question of start.questions) {
112
+ const ended = await askUntilPassed(debrief, question);
113
+ if (ended)
114
+ return ended;
83
115
  }
84
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`));
85
120
  return { status: 'verified' };
86
121
  }
87
- async function skip(api, out, session) {
88
- try {
89
- await api.request('POST', '/api/interview/skip', { session });
90
- }
91
- catch (error) {
92
- return failure(out, error);
93
- }
94
- out.write(`${paint(out).dim('Skipped. This push is recorded as skipped.')}\n`);
95
- 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 };
96
126
  }
97
127
  /** Prints a one-line reason. Provider problems stay out of the stats until retried. */
98
128
  function failure(out, error) {
99
129
  const c = paint(out);
100
130
  if (error instanceof ApiError) {
101
131
  if (error.isProviderError) {
102
- out.write(`${c.yellow('Ownit:')} ${error.message} This push won't count until you retry. ${RESUME_HINT}\n`);
132
+ out.write(`${c.yellow('Ownit:')} ${error.message} These commits stay unverified until you retry. ${RESUME_HINT}\n`);
103
133
  return { status: 'provider_error', reason: error.message };
104
134
  }
105
135
  if (error.code === 'already_resolved') {
@@ -110,7 +140,7 @@ function failure(out, error) {
110
140
  out.write(`${c.yellow('Ownit:')} ${sentence(error.message)} ${RESUME_HINT}\n`);
111
141
  return { status: 'pending', reason: error.message };
112
142
  }
113
- const message = error instanceof Error ? error.message : String(error);
143
+ const message = errorMessage(error);
114
144
  out.write(`${c.yellow('Ownit:')} ${sentence(message)} ${RESUME_HINT}\n`);
115
145
  return { status: 'pending', reason: message };
116
146
  }
package/dist/index.js CHANGED
@@ -1,72 +1,93 @@
1
1
  #!/usr/bin/env node
2
2
  import { readFileSync } from 'node:fs';
3
+ import { badgeCommand } from './commands/badge.js';
3
4
  import { hookCommand } from './commands/hook.js';
4
5
  import { initCommand, uninstallCommand } from './commands/init.js';
5
6
  import { interviewCommand } from './commands/interview.js';
6
- import { badgeCommand } from './commands/badge.js';
7
7
  import { keyCommand } from './commands/key.js';
8
8
  import { loginCommand, logoutCommand, whoamiCommand } from './commands/login.js';
9
- const HELP = `ownit: debrief the code you just pushed.
9
+ import { settingsCommand } from './commands/settings.js';
10
+ import { closestMatch, errorMessage } from './ui.js';
11
+ /** Every command, in the order `ownit help` lists them. */
12
+ const COMMANDS = {
13
+ login: { help: [['ownit login [--server URL]', 'Sign in with GitHub (device flow)']], run: loginCommand },
14
+ logout: { help: [['ownit logout', 'Sign out']], run: logoutCommand },
15
+ whoami: { help: [['ownit whoami', 'Show server, login, and key status']], run: whoamiCommand },
16
+ init: { help: [['ownit init [--repo owner/name]', 'Install the pre-push hook in this repo']], run: initCommand },
17
+ interview: {
18
+ help: [
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)'],
22
+ ],
23
+ run: interviewCommand,
24
+ },
25
+ key: {
26
+ help: [
27
+ ['ownit key set [nebius|nvidia]', 'Save your model API key (stored locally)'],
28
+ ['ownit key clear|status', 'Remove or show it'],
29
+ ],
30
+ run: keyCommand,
31
+ },
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
+ },
42
+ uninstall: { help: [['ownit uninstall', 'Remove the pre-push hook']], run: uninstallCommand },
43
+ help: { help: [['ownit help', 'Show this help']], run: () => print(helpText()) },
44
+ // Not listed: the installed git hook calls `ownit hook pre-push`.
45
+ hook: { run: (args) => hookCommand(args) },
46
+ version: { run: () => print(`${version()}\n`) },
47
+ };
48
+ const ALIASES = { page: 'settings', status: 'whoami', '--help': 'help', '-h': 'help', '--version': 'version', '-v': 'version' };
49
+ function findCommand(name) {
50
+ const key = Object.hasOwn(ALIASES, name) ? ALIASES[name] : name;
51
+ return Object.hasOwn(COMMANDS, key) ? COMMANDS[key] : undefined;
52
+ }
53
+ function helpText() {
54
+ const usage = Object.values(COMMANDS)
55
+ .flatMap((command) => command.help ?? [])
56
+ .map(([line, description]) => ` ${line.padEnd(33)}${description}`)
57
+ .join('\n');
58
+ return `ownit: debrief the code you just pushed.
10
59
 
11
60
  Usage:
12
- ownit login [--server URL] Sign in with GitHub (device flow)
13
- ownit logout Sign out
14
- ownit whoami Show server, login, and key status
15
- ownit init [--repo owner/name] Install the pre-push hook in this repo
16
- ownit interview Debrief pushes that are still pending
17
- ownit interview --range A..B Debrief a commit range
18
- ownit key set [nebius|nvidia] Save your model API key (stored locally)
19
- ownit key clear|status Remove or show it
20
- ownit badge Print this repo's badge Markdown for your README
21
- ownit uninstall Remove the pre-push hook
22
- ownit help Show this help
61
+ ${usage}
23
62
 
24
63
  Set OWNIT_SKIP=1 to push once without a debrief prompt (the push is pending).
25
64
  `;
65
+ }
26
66
  function version() {
27
67
  const pkg = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8'));
28
68
  return pkg.version;
29
69
  }
70
+ function print(text) {
71
+ process.stdout.write(text);
72
+ return 0;
73
+ }
30
74
  async function main(argv) {
31
- const [command, ...rest] = argv;
32
- switch (command) {
33
- case 'login':
34
- return loginCommand(rest);
35
- case 'logout':
36
- return logoutCommand();
37
- case 'whoami':
38
- case 'status':
39
- return whoamiCommand();
40
- case 'init':
41
- return initCommand(rest);
42
- case 'uninstall':
43
- return uninstallCommand();
44
- case 'hook':
45
- return hookCommand(rest);
46
- case 'interview':
47
- return interviewCommand(rest);
48
- case 'key':
49
- return keyCommand(rest);
50
- case 'badge':
51
- return badgeCommand();
52
- case '--version':
53
- case '-v':
54
- process.stdout.write(`${version()}\n`);
55
- return 0;
56
- case undefined:
57
- case 'help':
58
- case '--help':
59
- case '-h':
60
- process.stdout.write(HELP);
61
- return 0;
62
- default:
63
- process.stderr.write(`Unknown command "${command}".\n\n${HELP}`);
64
- return 1;
75
+ const [name = 'help', ...args] = argv;
76
+ const command = findCommand(name);
77
+ if (!command) {
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()}`);
84
+ return 1;
65
85
  }
86
+ return command.run(args);
66
87
  }
67
88
  main(process.argv.slice(2)).then((code) => {
68
89
  process.exitCode = code;
69
90
  }, (error) => {
70
- process.stderr.write(`Ownit: ${error instanceof Error ? error.message : String(error)}\n`);
91
+ process.stderr.write(`Ownit: ${errorMessage(error)}\n`);
71
92
  process.exitCode = 1;
72
93
  });
@@ -0,0 +1,36 @@
1
+ /**
2
+ * Model providers the worker accepts, in the order `ownit key set` lists them.
3
+ * Add a provider here and in the worker's PROVIDERS (worker/src/model.ts).
4
+ */
5
+ export const PROVIDERS = {
6
+ nebius: { name: 'Nebius', envVar: 'NEBIUS_API_KEY', keyUrl: 'https://tokenfactory.nebius.com/', keyPrefix: undefined },
7
+ nvidia: { name: 'NVIDIA', envVar: 'NVIDIA_API_KEY', keyUrl: 'https://build.nvidia.com/', keyPrefix: 'nvapi-' },
8
+ };
9
+ export const PROVIDER_IDS = Object.keys(PROVIDERS);
10
+ export function isProvider(value) {
11
+ return Object.hasOwn(PROVIDERS, value);
12
+ }
13
+ /** The key to send with debriefs and its provider. A provider's env var wins over the saved key. */
14
+ export function modelKey(config, env = process.env) {
15
+ for (const provider of PROVIDER_IDS) {
16
+ const key = env[PROVIDERS[provider].envVar];
17
+ if (key)
18
+ return { provider, key };
19
+ }
20
+ if (config.apiKey)
21
+ return { provider: config.provider ?? 'nebius', key: config.apiKey };
22
+ if (config.nebiusKey)
23
+ return { provider: 'nebius', key: config.nebiusKey };
24
+ return undefined;
25
+ }
26
+ /** The provider whose keys have this key's prefix, if any. */
27
+ export function providerByPrefix(key) {
28
+ return PROVIDER_IDS.find((id) => {
29
+ const prefix = PROVIDERS[id].keyPrefix;
30
+ return prefix !== undefined && key.startsWith(prefix);
31
+ });
32
+ }
33
+ /** e.g. "NVIDIA, …1a2b" */
34
+ export function describeKey({ provider, key }) {
35
+ return `${PROVIDERS[provider]?.name ?? provider}, …${key.slice(-4)}`;
36
+ }
package/dist/ui.js CHANGED
@@ -5,6 +5,10 @@ export function paint(out) {
5
5
  const wrap = (code) => (text) => (on ? `\x1b[${code}m${text}\x1b[0m` : text);
6
6
  return { bold: wrap('1'), dim: wrap('2'), green: wrap('32'), red: wrap('31'), yellow: wrap('33'), cyan: wrap('36') };
7
7
  }
8
+ /** "1 commit", "2 commits". Pass the plural form for irregular words. */
9
+ export const plural = (n, singular, pluralForm = `${singular}s`) => (n === 1 ? singular : pluralForm);
10
+ /** The message of whatever was thrown. */
11
+ export const errorMessage = (error) => (error instanceof Error ? error.message : String(error));
8
12
  /** Ends a message with a period unless it already ends a sentence (server messages usually do). */
9
13
  export function sentence(text) {
10
14
  return /[.!?]$/.test(text.trimEnd()) ? text.trimEnd() : `${text.trimEnd()}.`;
@@ -91,3 +95,26 @@ export function wrap(text, indent = ' ', width = Math.min(process.stderr.column
91
95
  lines.push(line);
92
96
  return lines.map((l) => indent + l).join('\n');
93
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.0",
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
  },