getownit 0.0.0-stage → 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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Yoseph Naoom
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,19 @@
1
- # Temporary Holding Version
1
+ # getownit
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ The `ownit` command. Right after you `git push`, it runs a short debrief in your terminal about
4
+ what you just shipped: why, how, and what's next. NVIDIA Nemotron (on Nebius or NVIDIA) writes the questions
5
+ and grades your answers generously.
6
+
7
+ ```sh
8
+ npm i -g getownit
9
+ ownit login # GitHub device flow
10
+ ownit key set # pick Nebius or NVIDIA, then paste your key; stored only in ~/.ownit/config.json
11
+ ownit init # in a repo: installs the pre-push hook
12
+ ```
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`.
16
+
17
+ Your code and answers are never stored, and your key is never stored or logged by the server.
18
+
19
+ Full documentation: [github.com/Yoosseph/ownit](https://github.com/Yoosseph/ownit).
package/dist/api.js ADDED
@@ -0,0 +1,57 @@
1
+ import { readConfig, serverUrl } from './config.js';
2
+ import { modelKey } from './providers.js';
3
+ export class ApiError extends Error {
4
+ status;
5
+ code;
6
+ body;
7
+ constructor(message,
8
+ /** HTTP status, or 0 when the server could not be reached. */
9
+ status, code, body = {}) {
10
+ super(message);
11
+ this.status = status;
12
+ this.code = code;
13
+ this.body = body;
14
+ this.name = 'ApiError';
15
+ }
16
+ get isProviderError() {
17
+ return this.code === 'provider_error';
18
+ }
19
+ }
20
+ export function createApi(config = readConfig(), env = process.env) {
21
+ const server = serverUrl(config, env);
22
+ const model = modelKey(config, env);
23
+ return {
24
+ server,
25
+ async request(method, path, body, options = {}) {
26
+ const headers = { accept: 'application/json', 'user-agent': 'getownit-cli' };
27
+ if (body !== undefined)
28
+ headers['content-type'] = 'application/json';
29
+ if (config.token)
30
+ headers.authorization = `Bearer ${config.token}`;
31
+ // Only debrief calls need the key; the worker uses it for that request and discards it.
32
+ if (model && path.startsWith('/api/interview/')) {
33
+ headers['x-model-key'] = model.key;
34
+ headers['x-model-provider'] = model.provider;
35
+ }
36
+ let response;
37
+ try {
38
+ response = await fetch(`${server}${path}`, {
39
+ method,
40
+ headers,
41
+ body: body === undefined ? undefined : JSON.stringify(body),
42
+ signal: AbortSignal.timeout(options.timeoutMs ?? 180_000),
43
+ });
44
+ }
45
+ catch (error) {
46
+ const reason = error instanceof Error && error.name === 'TimeoutError' ? 'timed out' : 'unreachable';
47
+ throw new ApiError(`Ownit server ${reason} (${server})`, 0, 'network');
48
+ }
49
+ const json = (await response.json().catch(() => ({})));
50
+ if (!response.ok) {
51
+ const message = typeof json.message === 'string' ? json.message : `Ownit server returned ${response.status}`;
52
+ throw new ApiError(message, response.status, typeof json.error === 'string' ? json.error : 'http', json);
53
+ }
54
+ return json;
55
+ },
56
+ };
57
+ }
@@ -0,0 +1,19 @@
1
+ import { readConfig, serverUrl } from '../config.js';
2
+ import { githubRepo } from '../git.js';
3
+ /** The README Markdown for a repo's badge: the badge image, linking to the repo's page. */
4
+ export function badgeMarkdown(origin, repo) {
5
+ const [owner = '', name = ''] = repo.split('/');
6
+ const path = `${encodeURIComponent(owner)}/${encodeURIComponent(name)}`;
7
+ return `[![Ownit](${origin}/badge/${path}.svg)](${origin}/${path})`;
8
+ }
9
+ /** `ownit badge`: prints the Markdown on stdout, so it can be piped (`ownit badge | clip`). */
10
+ export function badgeCommand() {
11
+ const repo = githubRepo();
12
+ if (!repo) {
13
+ process.stderr.write('Ownit: no GitHub remote found. Run this in a repo set up with "ownit init".\n');
14
+ return 1;
15
+ }
16
+ process.stderr.write(`Add this to your README to show ${repo}'s Ownit badge:\n\n`);
17
+ process.stdout.write(`${badgeMarkdown(serverUrl(readConfig()), repo)}\n`);
18
+ return 0;
19
+ }
@@ -0,0 +1,145 @@
1
+ import { openSync } from 'node:fs';
2
+ import { ReadStream } from 'node:tty';
3
+ import { ApiError, createApi } from '../api.js';
4
+ import { readConfig } from '../config.js';
5
+ import { RESUME_HINT, runDebrief } from '../debrief.js';
6
+ import { buildDiff } from '../diff.js';
7
+ import { githubRepo } from '../git.js';
8
+ import { computePush, parseRefs } from '../pushRange.js';
9
+ import { createPrompter, errorMessage, paint, plural, sentence } from '../ui.js';
10
+ const PROMPT_TIMEOUT_MS = 120_000;
11
+ const QUICK_TIMEOUT_MS = 8_000;
12
+ // Coding agents and CI run git without a person at the keyboard.
13
+ const AGENT_ENV = ['CI', 'CLAUDECODE', 'CODEX_SANDBOX', 'CODEX_CI', 'CURSOR_AGENT', 'GEMINI_CLI', 'AIDER_MODEL', 'OPENCODE'];
14
+ export function isAgentEnvironment(env) {
15
+ return AGENT_ENV.some((name) => env[name] && env[name] !== '0' && env[name] !== 'false');
16
+ }
17
+ /** The terminal to read answers from, or null when nobody can answer. */
18
+ function openTerminal(env) {
19
+ if (env.OWNIT_NO_TTY || isAgentEnvironment(env))
20
+ return null;
21
+ // The installed hook script redirects stdin from /dev/tty and says so.
22
+ if (env.OWNIT_TTY === '1')
23
+ return process.stdin;
24
+ if (process.platform === 'win32')
25
+ return null;
26
+ try {
27
+ return new ReadStream(openSync('/dev/tty', 'r'));
28
+ }
29
+ catch {
30
+ return null;
31
+ }
32
+ }
33
+ async function readStdin() {
34
+ if (process.stdin.isTTY)
35
+ return '';
36
+ let text = '';
37
+ for await (const chunk of process.stdin)
38
+ text += chunk;
39
+ return text;
40
+ }
41
+ export function interpretChoice(answer) {
42
+ if (answer === null)
43
+ return 'later';
44
+ const value = answer.trim().toLowerCase();
45
+ if (value === '' || value === 'y' || value === 'yes')
46
+ return 'debrief';
47
+ if (value === 's' || value === 'skip')
48
+ return 'skip';
49
+ return 'later';
50
+ }
51
+ async function record(api, repo, push, status) {
52
+ const res = await api.request('POST', '/api/pushes', { repo, base_sha: push.base, head_sha: push.head, commits: push.commits.map((c) => ({ sha: c.sha })), status }, { timeoutMs: QUICK_TIMEOUT_MS });
53
+ return res.status;
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
+ }
109
+ /** `ownit hook pre-push <remote> <url>`. Always exits 0: the debrief never blocks a push. */
110
+ export async function hookCommand(argv, env = process.env) {
111
+ const [event, remote = '', url = ''] = argv;
112
+ if (event !== 'pre-push')
113
+ return 0;
114
+ const out = process.stderr;
115
+ const c = paint(out);
116
+ try {
117
+ const refs = env.OWNIT_REFS ?? (await readStdin());
118
+ const repo = githubRepo(process.cwd(), url || remote);
119
+ if (!repo)
120
+ return 0;
121
+ const push = computePush(parseRefs(refs), remote);
122
+ if (!push)
123
+ return 0;
124
+ const config = readConfig(env);
125
+ if (!config.token) {
126
+ out.write(`Ownit: not logged in, so no debrief this time. Run "ownit login". The push continues.\n`);
127
+ return 0;
128
+ }
129
+ const api = createApi(config, env);
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`));
134
+ return 0;
135
+ }
136
+ const terminal = openTerminal(env);
137
+ if (!terminal)
138
+ return await recordPending(api, repo, push, out);
139
+ return await askAndDebrief(api, terminal, repo, push, out);
140
+ }
141
+ catch (error) {
142
+ out.write(`${c.yellow('Ownit:')} ${continueReason(error)} ${RESUME_HINT}\n`);
143
+ return 0;
144
+ }
145
+ }
@@ -0,0 +1,73 @@
1
+ import { parseArgs } from 'node:util';
2
+ import { createApi } from '../api.js';
3
+ import { git, githubRepo } from '../git.js';
4
+ import { hookLocation, installHook, MANAGED_SNIPPET, uninstallHook } from '../hooks.js';
5
+ import { errorMessage, paint, sentence } from '../ui.js';
6
+ import { badgeMarkdown } from './badge.js';
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) {
9
+ const c = paint(out);
10
+ const location = hookLocation();
11
+ if (location.kind === 'manual') {
12
+ out.write(`Ownit: ${location.reason}, so add these lines to the end of .husky/pre-push (create the file if needed):\n\n`);
13
+ out.write(`${MANAGED_SNIPPET}\n\n`);
14
+ return;
15
+ }
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);
25
+ try {
26
+ const api = createApi();
27
+ const registered = await api.request('POST', '/api/repos', { repo }, { timeoutMs: 15_000 });
28
+ out.write(`${c.green('✓')} Registered ${c.bold(registered.repo)} with Ownit (tracked since ${registered.tracked_since.slice(0, 10)}).\n`);
29
+ if (!registered.installed) {
30
+ out.write(`\nNext: install the Ownit GitHub App on ${registered.repo} so pushes get check runs and count on the badge:\n`);
31
+ out.write(` ${registered.install_url ?? 'see docs/github-app.md'}\n`);
32
+ }
33
+ if (registered.page_url)
34
+ out.write(`\nYour repo's page: ${registered.page_url}\n`);
35
+ // The page URL carries the server's public origin, which can differ from the API address.
36
+ const origin = registered.page_url ? new URL(registered.page_url).origin : api.server;
37
+ out.write(`\nAdd the badge to your README (run "ownit badge" to see this again):\n ${badgeMarkdown(origin, registered.repo)}\n`);
38
+ }
39
+ catch (error) {
40
+ out.write(`${c.yellow('Ownit:')} the hook is installed, but registering ${repo} failed: ${sentence(errorMessage(error))}\n`);
41
+ out.write('Run "ownit init" again once that is fixed.\n');
42
+ return 1;
43
+ }
44
+ return 0;
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
+ }
60
+ export async function uninstallCommand() {
61
+ const out = process.stderr;
62
+ const location = hookLocation();
63
+ if (location.kind === 'manual') {
64
+ out.write(`Ownit: remove the Ownit lines from your pre-push hook in ${location.dir}.\n`);
65
+ return 0;
66
+ }
67
+ const result = uninstallHook(location.dir);
68
+ if (!result.removed)
69
+ out.write('Ownit: no Ownit hook installed here.\n');
70
+ else
71
+ out.write(`Removed the Ownit pre-push hook.${result.restored ? ' Restored your previous hook.' : ''}\n`);
72
+ return 0;
73
+ }
@@ -0,0 +1,82 @@
1
+ import { parseArgs } from 'node:util';
2
+ import { createApi } from '../api.js';
3
+ import { buildDiff } from '../diff.js';
4
+ import { runDebrief } from '../debrief.js';
5
+ import { githubRepo, listCommits, objectExists, resolveCommit } from '../git.js';
6
+ import { rangeForRecordedPush } from '../pushRange.js';
7
+ import { createPrompter, paint, plural } from '../ui.js';
8
+ export async function interviewCommand(argv) {
9
+ const { values } = parseArgs({
10
+ args: argv,
11
+ options: { range: { type: 'string' }, repo: { type: 'string' } },
12
+ allowPositionals: false,
13
+ });
14
+ const out = process.stderr;
15
+ const repo = values.repo ?? githubRepo();
16
+ if (!repo) {
17
+ out.write('Ownit: cannot tell which GitHub repo this is. Run "ownit init" or pass --repo owner/name.\n');
18
+ return 1;
19
+ }
20
+ const api = createApi();
21
+ const prompter = createPrompter(process.stdin, out);
22
+ try {
23
+ if (values.range)
24
+ return exitCode(await debriefRange(api, prompter, repo, values.range));
25
+ return await debriefOpenPushes(api, prompter, repo);
26
+ }
27
+ finally {
28
+ prompter.close();
29
+ }
30
+ }
31
+ function exitCode(outcome) {
32
+ return outcome && (outcome.status === 'verified' || outcome.status === 'skipped') ? 0 : 1;
33
+ }
34
+ async function debriefRange(api, prompter, repo, range) {
35
+ 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}`]);
43
+ if (!commits.length) {
44
+ prompter.out.write(`Ownit: no commits in ${range}.\n`);
45
+ return null;
46
+ }
47
+ return runDebrief(api, prompter, { repo, baseSha: base, headSha: head, commits, diff: buildDiff([{ base, head }]) });
48
+ }
49
+ /** Resolves pending and provider_error pushes, newest first. */
50
+ async function debriefOpenPushes(api, prompter, repo) {
51
+ const out = prompter.out;
52
+ const c = paint(out);
53
+ const { pushes } = await api.request('GET', `/api/pushes/open?repo=${encodeURIComponent(repo)}`);
54
+ if (!pushes.length) {
55
+ out.write(`${c.green('✓')} Nothing to debrief for ${repo}.\n`);
56
+ return 0;
57
+ }
58
+ out.write(`${pushes.length} ${plural(pushes.length, 'push', 'pushes')} waiting for a debrief in ${repo}.\n`);
59
+ let code = 0;
60
+ for (const push of pushes) {
61
+ const missing = [push.head_sha, ...push.commits].find((sha) => !objectExists(sha));
62
+ if (missing) {
63
+ out.write(c.yellow(`Ownit: commit ${missing.slice(0, 7)} is not in this clone. Run "git fetch" and try again.\n`));
64
+ code = 1;
65
+ continue;
66
+ }
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')
79
+ return 1;
80
+ }
81
+ return code;
82
+ }
@@ -0,0 +1,79 @@
1
+ import { readConfig, updateConfig } from '../config.js';
2
+ import { describeKey, isProvider, modelKey, PROVIDER_IDS, PROVIDERS, providerByPrefix } from '../providers.js';
3
+ import { readSecret } from '../secret.js';
4
+ import { createPrompter } from '../ui.js';
5
+ const CHOICES = PROVIDER_IDS.join('|');
6
+ /** The provider named on the command line, else one picked from a menu. */
7
+ async function chooseProvider(arg) {
8
+ const out = process.stderr;
9
+ if (arg) {
10
+ const id = arg.toLowerCase();
11
+ if (isProvider(id))
12
+ return id;
13
+ // Never echo the argument: people paste the key itself here.
14
+ out.write(`Ownit: "ownit key set" takes a provider (${CHOICES}), not the key. Keys typed on the command line end up in your shell history, so run "ownit key set" and paste the key at the hidden prompt. If that was a real key, consider rotating it.\n`);
15
+ return undefined;
16
+ }
17
+ // Piped input is the key itself, so there is no line to spare for the menu.
18
+ if (!process.stdin.isTTY) {
19
+ out.write(`Ownit: name the provider when piping a key, e.g. "ownit key set ${PROVIDER_IDS[0]}". Use one of: ${CHOICES}.\n`);
20
+ return undefined;
21
+ }
22
+ out.write('Which provider is this key for?\n');
23
+ PROVIDER_IDS.forEach((id, i) => out.write(` ${i + 1}) ${PROVIDERS[id].name.padEnd(8)} keys at ${PROVIDERS[id].keyUrl}\n`));
24
+ const prompter = createPrompter(process.stdin, out);
25
+ const answer = ((await prompter.ask(`Choose 1-${PROVIDER_IDS.length} [1]: `)) ?? '').trim().toLowerCase();
26
+ prompter.close();
27
+ if (!answer)
28
+ return PROVIDER_IDS[0];
29
+ const picked = isProvider(answer) ? answer : PROVIDER_IDS[Number(answer) - 1];
30
+ if (!picked)
31
+ out.write(`Ownit: "${answer}" is not one of the choices; nothing changed.\n`);
32
+ return picked;
33
+ }
34
+ async function setKey(providerArg) {
35
+ const out = process.stderr;
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;
45
+ }
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;
50
+ }
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;
78
+ }
79
+ }
@@ -0,0 +1,118 @@
1
+ import { parseArgs } from 'node:util';
2
+ import { ApiError, createApi } from '../api.js';
3
+ import { readConfig, serverUrl, updateConfig } from '../config.js';
4
+ import { describeKey, modelKey } from '../providers.js';
5
+ import { readSecret } from '../secret.js';
6
+ import { paint } from '../ui.js';
7
+ const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
8
+ /** POSTs a form to github.com's OAuth endpoints. OWNIT_GITHUB_URL overrides the host in tests. */
9
+ async function githubOAuth(path, form) {
10
+ const base = (process.env.OWNIT_GITHUB_URL || 'https://github.com').replace(/\/+$/, '');
11
+ let response;
12
+ try {
13
+ response = await fetch(`${base}${path}`, {
14
+ method: 'POST',
15
+ headers: { accept: 'application/json', 'content-type': 'application/x-www-form-urlencoded', 'user-agent': 'getownit-cli' },
16
+ body: new URLSearchParams(form).toString(),
17
+ signal: AbortSignal.timeout(30_000),
18
+ });
19
+ }
20
+ catch {
21
+ throw new Error('could not reach github.com');
22
+ }
23
+ if (!response.ok)
24
+ throw new Error(`github.com returned ${response.status}`);
25
+ return (await response.json());
26
+ }
27
+ /**
28
+ * Polls GitHub until the user authorizes the device code. Returns the GitHub
29
+ * token, or the reason the login ended without one.
30
+ */
31
+ async function pollForAccessToken(clientId, start) {
32
+ let interval = Math.max(start.interval, 0) * 1000;
33
+ const deadline = Date.now() + start.expires_in * 1000;
34
+ while (Date.now() < deadline) {
35
+ await sleep(interval);
36
+ const reply = await githubOAuth('/login/oauth/access_token', {
37
+ client_id: clientId,
38
+ device_code: start.device_code,
39
+ grant_type: 'urn:ietf:params:oauth:grant-type:device_code',
40
+ });
41
+ if (reply.access_token)
42
+ return { token: reply.access_token };
43
+ if (reply.error === 'authorization_pending')
44
+ continue;
45
+ if (reply.error === 'slow_down') {
46
+ interval = Math.max(interval + 5000, (reply.interval ?? 0) * 1000);
47
+ continue;
48
+ }
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`);
81
+ return 1;
82
+ }
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();
95
+ }
96
+ export async function logoutCommand() {
97
+ const config = readConfig();
98
+ if (config.token) {
99
+ await createApi(config)
100
+ .request('DELETE', '/auth/session', undefined, { timeoutMs: 10_000 })
101
+ .catch((error) => {
102
+ if (!(error instanceof ApiError))
103
+ throw error;
104
+ });
105
+ }
106
+ updateConfig({ token: undefined, login: undefined });
107
+ process.stderr.write('Logged out. Your API key is still saved; remove it with "ownit key clear".\n');
108
+ return 0;
109
+ }
110
+ export async function whoamiCommand() {
111
+ const config = readConfig();
112
+ const out = process.stderr;
113
+ out.write(`Server: ${serverUrl(config)}\n`);
114
+ out.write(`GitHub: ${config.token ? (config.login ?? 'logged in') : 'not logged in (run "ownit login")'}\n`);
115
+ const model = modelKey(config);
116
+ out.write(`API key: ${model ? `set (${describeKey(model)})` : 'not set (run "ownit key set")'}\n`);
117
+ return 0;
118
+ }
package/dist/config.js ADDED
@@ -0,0 +1,46 @@
1
+ import { chmodSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
2
+ import { homedir } from 'node:os';
3
+ import { join } from 'node:path';
4
+ // The hosted Ownit server. Self-hosters point the CLI elsewhere with `ownit login --server`.
5
+ export const DEFAULT_SERVER = 'https://ownit.ownit-worker.workers.dev';
6
+ export function configDir(env = process.env) {
7
+ return env.OWNIT_HOME || join(homedir(), '.ownit');
8
+ }
9
+ export function configPath(env = process.env) {
10
+ return join(configDir(env), 'config.json');
11
+ }
12
+ export function readConfig(env = process.env) {
13
+ try {
14
+ const value = JSON.parse(readFileSync(configPath(env), 'utf8'));
15
+ return value && typeof value === 'object' && !Array.isArray(value) ? value : {};
16
+ }
17
+ catch {
18
+ return {};
19
+ }
20
+ }
21
+ /** Writes atomically with owner-only permissions, since the file holds secrets. */
22
+ export function writeConfig(config, env = process.env) {
23
+ const dir = configDir(env);
24
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
25
+ const file = configPath(env);
26
+ const temp = `${file}.${process.pid}.tmp`;
27
+ writeFileSync(temp, `${JSON.stringify(config, null, 2)}\n`, { mode: 0o600 });
28
+ renameSync(temp, file);
29
+ try {
30
+ chmodSync(file, 0o600);
31
+ }
32
+ catch {
33
+ // Windows ignores POSIX modes.
34
+ }
35
+ }
36
+ export function updateConfig(patch, env = process.env) {
37
+ const next = { ...readConfig(env), ...patch };
38
+ for (const key of Object.keys(next))
39
+ if (next[key] === undefined)
40
+ delete next[key];
41
+ writeConfig(next, env);
42
+ return next;
43
+ }
44
+ export function serverUrl(config, env = process.env) {
45
+ return (env.OWNIT_SERVER || config.server || DEFAULT_SERVER).replace(/\/+$/, '');
46
+ }