getownit 0.1.0 → 0.1.1

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/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.
@@ -52,6 +52,60 @@ async function record(api, repo, push, status) {
52
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
53
  return res.status;
54
54
  }
55
+ async function recordPending(api, repo, push, out) {
56
+ await record(api, repo, push, 'pending');
57
+ out.write(`Ownit: push recorded as pending. ${RESUME_HINT}\n`);
58
+ return 0;
59
+ }
60
+ /**
61
+ * What the server already recorded for these commits, so a verified push is not
62
+ * asked again. Only an unreachable server is an error; anything else means unknown.
63
+ */
64
+ async function knownStatus(api, repo, push) {
65
+ const known = await api
66
+ .request('POST', '/api/pushes/lookup', { repo, head_sha: push.head, shas: push.commits.map((x) => x.sha) }, { timeoutMs: QUICK_TIMEOUT_MS })
67
+ .catch((error) => {
68
+ if (error instanceof ApiError && error.code === 'network')
69
+ throw error;
70
+ return { status: null };
71
+ });
72
+ return known.status;
73
+ }
74
+ /** Asks whether to debrief now, then debriefs, records a skip, or leaves the push pending. */
75
+ async function askAndDebrief(api, terminal, repo, push, out) {
76
+ const c = paint(out);
77
+ const prompter = createPrompter(terminal, out);
78
+ try {
79
+ 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')
88
+ return await recordPending(api, repo, push, out);
89
+ await runDebrief(api, prompter, {
90
+ repo,
91
+ baseSha: push.base,
92
+ headSha: push.head,
93
+ commits: push.commits,
94
+ diff: buildDiff(push.ranges),
95
+ });
96
+ return 0;
97
+ }
98
+ finally {
99
+ prompter.close();
100
+ if (terminal !== process.stdin)
101
+ terminal.destroy();
102
+ }
103
+ }
104
+ function continueReason(error) {
105
+ return error instanceof ApiError && error.code === 'network'
106
+ ? `${sentence(error.message)} The push continues and will show as pending.`
107
+ : `${sentence(errorMessage(error))} The push continues.`;
108
+ }
55
109
  /** `ownit hook pre-push <remote> <url>`. Always exits 0: the debrief never blocks a push. */
56
110
  export async function hookCommand(argv, env = process.env) {
57
111
  const [event, remote = '', url = ''] = argv;
@@ -73,58 +127,19 @@ export async function hookCommand(argv, env = process.env) {
73
127
  return 0;
74
128
  }
75
129
  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`));
130
+ const known = await knownStatus(api, repo, push);
131
+ // Skipped pushes are asked again: they stay unverified until debriefed.
132
+ if (known === 'verified') {
133
+ out.write(c.dim(`Ownit: this push is already ${known}.\n`));
85
134
  return 0;
86
135
  }
87
136
  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
- }
137
+ if (!terminal)
138
+ return await recordPending(api, repo, push, out);
139
+ return await askAndDebrief(api, terminal, repo, push, out);
122
140
  }
123
141
  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`);
142
+ out.write(`${c.yellow('Ownit:')} ${continueReason(error)} ${RESUME_HINT}\n`);
128
143
  return 0;
129
144
  }
130
145
  }
@@ -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();
@@ -4,7 +4,7 @@ import { buildDiff } from '../diff.js';
4
4
  import { runDebrief } from '../debrief.js';
5
5
  import { githubRepo, listCommits, objectExists, resolveCommit } 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,
@@ -55,7 +55,7 @@ async function debriefOpenPushes(api, prompter, repo) {
55
55
  out.write(`${c.green('✓')} Nothing to debrief for ${repo}.\n`);
56
56
  return 0;
57
57
  }
58
- out.write(`${pushes.length} push${pushes.length === 1 ? '' : 'es'} waiting for a debrief in ${repo}.\n`);
58
+ out.write(`${pushes.length} ${plural(pushes.length, 'push', 'pushes')} waiting for a debrief in ${repo}.\n`);
59
59
  let code = 0;
60
60
  for (const push of pushes) {
61
61
  const missing = [push.head_sha, ...push.commits].find((sha) => !objectExists(sha));
@@ -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();
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,85 +1,111 @@
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
6
  function isSkip(answer) {
7
7
  return /^(s|skip|:skip)$/i.test(answer.trim());
8
8
  }
9
+ /** Shows a spinner while `work` runs. */
10
+ async function withSpinner(out, text, work) {
11
+ const spin = spinner(out, text);
12
+ try {
13
+ return await work();
14
+ }
15
+ finally {
16
+ spin.stop();
17
+ }
18
+ }
19
+ function printQuestion(out, question, total, followUp) {
20
+ const c = paint(out);
21
+ const label = `Question ${question.idx + 1}/${total}`;
22
+ out.write(`\n${c.bold(c.cyan(label))} ${c.dim(`(${KIND_LABEL[question.kind]}${followUp ? ', follow-up' : ''})`)}\n`);
23
+ out.write(`${wrap(question.text)}\n`);
24
+ }
25
+ /** The next non-blank answer, or null when the input ends. */
26
+ async function readAnswer(prompter) {
27
+ const c = paint(prompter.out);
28
+ let answer;
29
+ do
30
+ answer = await prompter.ask(c.bold('> '));
31
+ while (answer !== null && !answer.trim());
32
+ return answer;
33
+ }
34
+ /**
35
+ * Asks a question, and follow-ups after a miss, until an answer passes (null)
36
+ * or the debrief ends early (its outcome).
37
+ */
38
+ async function askUntilPassed(debrief, first) {
39
+ const { api, prompter } = debrief;
40
+ const out = prompter.out;
41
+ const c = paint(out);
42
+ let question = first;
43
+ let followUp = false;
44
+ for (;;) {
45
+ printQuestion(out, question, debrief.total, followUp);
46
+ const answer = await readAnswer(prompter);
47
+ if (answer === null) {
48
+ out.write(`\n${c.yellow('Debrief paused.')} ${RESUME_HINT}\n`);
49
+ return { status: 'pending', reason: 'debrief paused' };
50
+ }
51
+ if (isSkip(answer))
52
+ return skip(api, out, debrief.session);
53
+ let result;
54
+ try {
55
+ result = await withSpinner(out, 'Checking your answer…', () => api.request('POST', '/api/interview/answer', {
56
+ session: debrief.session,
57
+ idx: question.idx,
58
+ answer: answer.slice(0, MAX_ANSWER_CHARS),
59
+ ...debrief.payload,
60
+ }));
61
+ }
62
+ catch (error) {
63
+ return failure(out, error);
64
+ }
65
+ debrief.session = result.session;
66
+ if (result.pass) {
67
+ out.write(`${c.green('✓ Passed.')}\n${c.dim(wrap(result.feedback))}\n`);
68
+ return null;
69
+ }
70
+ out.write(`${c.yellow('Not quite yet.')}\n${wrap(result.feedback)}\n`);
71
+ if (!result.followup)
72
+ return { status: 'pending', reason: 'no follow-up question' };
73
+ out.write(c.dim("Here's another angle on the same topic:\n"));
74
+ question = result.followup;
75
+ followUp = true;
76
+ }
77
+ }
9
78
  /** Runs one debrief: asks each question, grades answers, and offers follow-ups until every question passes. */
10
79
  export async function runDebrief(api, prompter, target) {
11
80
  const out = prompter.out;
12
81
  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`);
82
+ const commitWord = plural(target.commits.length, 'commit');
83
+ 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
84
  const payload = {
16
85
  commits: target.commits,
17
86
  diff: target.diff.diff,
18
87
  diff_truncated: target.diff.truncated,
19
88
  };
20
89
  let start;
21
- const spin = spinner(out, 'Nemotron is reading your push…');
22
90
  try {
23
- start = await api.request('POST', '/api/interview/start', {
91
+ start = await withSpinner(out, 'Nemotron is reading your push…', () => api.request('POST', '/api/interview/start', {
24
92
  repo: target.repo,
25
93
  base_sha: target.baseSha,
26
94
  head_sha: target.headSha,
27
95
  changed_lines: target.diff.changedLines,
28
96
  push_id: target.pushId,
29
97
  ...payload,
30
- });
98
+ }));
31
99
  }
32
100
  catch (error) {
33
- spin.stop();
34
101
  return failure(out, error);
35
102
  }
36
- spin.stop();
37
- let session = start.session;
38
103
  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
- }
104
+ const debrief = { api, prompter, total: start.total, payload, session: start.session };
105
+ for (const question of start.questions) {
106
+ const ended = await askUntilPassed(debrief, question);
107
+ if (ended)
108
+ return ended;
83
109
  }
84
110
  out.write(`\n${c.green(c.bold('✓ Push verified.'))} ${c.dim(`${target.commits.length} ${commitWord} owned. Nice work.`)}\n`);
85
111
  return { status: 'verified' };
@@ -99,7 +125,7 @@ function failure(out, error) {
99
125
  const c = paint(out);
100
126
  if (error instanceof ApiError) {
101
127
  if (error.isProviderError) {
102
- out.write(`${c.yellow('Ownit:')} ${error.message} This push won't count until you retry. ${RESUME_HINT}\n`);
128
+ out.write(`${c.yellow('Ownit:')} ${error.message} These commits stay unverified until you retry. ${RESUME_HINT}\n`);
103
129
  return { status: 'provider_error', reason: error.message };
104
130
  }
105
131
  if (error.code === 'already_resolved') {
@@ -110,7 +136,7 @@ function failure(out, error) {
110
136
  out.write(`${c.yellow('Ownit:')} ${sentence(error.message)} ${RESUME_HINT}\n`);
111
137
  return { status: 'pending', reason: error.message };
112
138
  }
113
- const message = error instanceof Error ? error.message : String(error);
139
+ const message = errorMessage(error);
114
140
  out.write(`${c.yellow('Ownit:')} ${sentence(message)} ${RESUME_HINT}\n`);
115
141
  return { status: 'pending', reason: message };
116
142
  }
package/dist/index.js CHANGED
@@ -1,72 +1,77 @@
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 { errorMessage } from './ui.js';
10
+ /** Every command, in the order `ownit help` lists them. */
11
+ const COMMANDS = {
12
+ login: { help: [['ownit login [--server URL]', 'Sign in with GitHub (device flow)']], run: loginCommand },
13
+ logout: { help: [['ownit logout', 'Sign out']], run: logoutCommand },
14
+ whoami: { help: [['ownit whoami', 'Show server, login, and key status']], run: whoamiCommand },
15
+ init: { help: [['ownit init [--repo owner/name]', 'Install the pre-push hook in this repo']], run: initCommand },
16
+ interview: {
17
+ help: [
18
+ ['ownit interview', 'Debrief pushes that are still pending'],
19
+ ['ownit interview --range A..B', 'Debrief a commit range'],
20
+ ],
21
+ run: interviewCommand,
22
+ },
23
+ key: {
24
+ help: [
25
+ ['ownit key set [nebius|nvidia]', 'Save your model API key (stored locally)'],
26
+ ['ownit key clear|status', 'Remove or show it'],
27
+ ],
28
+ run: keyCommand,
29
+ },
30
+ badge: { help: [['ownit badge', "Print this repo's badge Markdown for your README"]], run: badgeCommand },
31
+ uninstall: { help: [['ownit uninstall', 'Remove the pre-push hook']], run: uninstallCommand },
32
+ help: { help: [['ownit help', 'Show this help']], run: () => print(helpText()) },
33
+ // Not listed: the installed git hook calls `ownit hook pre-push`.
34
+ hook: { run: (args) => hookCommand(args) },
35
+ version: { run: () => print(`${version()}\n`) },
36
+ };
37
+ const ALIASES = { status: 'whoami', '--help': 'help', '-h': 'help', '--version': 'version', '-v': 'version' };
38
+ function findCommand(name) {
39
+ const key = Object.hasOwn(ALIASES, name) ? ALIASES[name] : name;
40
+ return Object.hasOwn(COMMANDS, key) ? COMMANDS[key] : undefined;
41
+ }
42
+ function helpText() {
43
+ const usage = Object.values(COMMANDS)
44
+ .flatMap((command) => command.help ?? [])
45
+ .map(([line, description]) => ` ${line.padEnd(33)}${description}`)
46
+ .join('\n');
47
+ return `ownit: debrief the code you just pushed.
10
48
 
11
49
  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
50
+ ${usage}
23
51
 
24
52
  Set OWNIT_SKIP=1 to push once without a debrief prompt (the push is pending).
25
53
  `;
54
+ }
26
55
  function version() {
27
56
  const pkg = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8'));
28
57
  return pkg.version;
29
58
  }
59
+ function print(text) {
60
+ process.stdout.write(text);
61
+ return 0;
62
+ }
30
63
  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;
64
+ const [name = 'help', ...args] = argv;
65
+ const command = findCommand(name);
66
+ if (!command) {
67
+ process.stderr.write(`Unknown command "${name}".\n\n${helpText()}`);
68
+ return 1;
65
69
  }
70
+ return command.run(args);
66
71
  }
67
72
  main(process.argv.slice(2)).then((code) => {
68
73
  process.exitCode = code;
69
74
  }, (error) => {
70
- process.stderr.write(`Ownit: ${error instanceof Error ? error.message : String(error)}\n`);
75
+ process.stderr.write(`Ownit: ${errorMessage(error)}\n`);
71
76
  process.exitCode = 1;
72
77
  });
@@ -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()}.`;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "getownit",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
4
4
  "description": "Debrief the code you just pushed, in your terminal, with NVIDIA Nemotron.",
5
5
  "license": "MIT",
6
6
  "repository": {