@harborppm/cli 0.6.0 → 0.7.0
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 +13 -1
- package/dist/app.mjs +98 -6
- package/lib/auth.mjs +11 -2
- package/lib/cli.mjs +23 -6
- package/lib/format.mjs +1 -1
- package/package.json +13 -1
package/README.md
CHANGED
|
@@ -10,6 +10,18 @@ harbor mine # or one command at a time
|
|
|
10
10
|
|
|
11
11
|
Needs Node 22 or later.
|
|
12
12
|
|
|
13
|
+
## New to Harbor?
|
|
14
|
+
|
|
15
|
+
You can start from the terminal. Harbor is free to start.
|
|
16
|
+
|
|
17
|
+
```sh
|
|
18
|
+
npm install -g @harborppm/cli
|
|
19
|
+
harbor signup # make your account in the browser (email or Google), then come back
|
|
20
|
+
harbor # name your harbor and its first project, and you're in
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
`harbor signup` opens Harbor's page on **Create**; confirm your email from the letter it sends when you get a moment. The first time you run `harbor` with no harbor yet, it asks for a name (Enter takes a suggestion) and, if you like, a first project, then opens on it. Joining a team's harbor instead? Open the invite they sent you, and `harbor` opens on their harbor.
|
|
24
|
+
|
|
13
25
|
## The full-screen app
|
|
14
26
|
|
|
15
27
|
`harbor` on its own takes over the terminal, like `htop` or `lazygit`, and gives it back when you press `q`. It opens on Harbor's mark (the teal tile, the H and three boats, drawn in half blocks) while your harbor loads; after that the sidebar holds just the screens. Then:
|
|
@@ -43,7 +55,7 @@ It runs on the same engine as the website: health, task keys, limits and undo wo
|
|
|
43
55
|
|
|
44
56
|
## Signing in
|
|
45
57
|
|
|
46
|
-
`harbor login` registers the CLI with Harbor and opens the same page Claude and other apps use: it names "Harbor CLI" and asks what to allow. Choose **Allow reading only** (or run `harbor login --read-only`) if it shouldn't change anything. The sign-in is saved in `~/.config/harbor/credentials.json`, readable only by you, and refreshes itself for 30 days.
|
|
58
|
+
`harbor signup` is for a first visit: the same sign-in, with the page opened on making an account. `harbor login` registers the CLI with Harbor and opens the same page Claude and other apps use: it names "Harbor CLI" and asks what to allow. Choose **Allow reading only** (or run `harbor login --read-only`) if it shouldn't change anything. The sign-in is saved in `~/.config/harbor/credentials.json`, readable only by you, and refreshes itself for 30 days.
|
|
47
59
|
|
|
48
60
|
`harbor logout` forgets it on this machine. To end its access everywhere, disconnect **Harbor CLI** under **Connected apps** in Harbor's menu.
|
|
49
61
|
|
package/dist/app.mjs
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
// Built from src/tui by cli/build.mjs. Edit the source, not this file.
|
|
2
|
+
import { createInterface } from "node:readline/promises";
|
|
2
3
|
import { Box, Text, render, useApp, useInput, useWindowSize } from "ink";
|
|
3
4
|
import React, { createContext, useContext, useEffect, useMemo, useState } from "react";
|
|
4
5
|
import { Fragment, jsx, jsxs } from "react/jsx-runtime";
|
|
@@ -8695,7 +8696,8 @@ function plainTransport(t) {
|
|
|
8695
8696
|
load: async (workspaceId) => plainDeep(await t.load(workspaceId)),
|
|
8696
8697
|
save: async (body) => plainDeep(await t.save(body)),
|
|
8697
8698
|
wait: async (workspaceId, since) => plainDeep(await t.wait(workspaceId, since)),
|
|
8698
|
-
history: async (query) => plainDeep(await t.history(query))
|
|
8699
|
+
history: async (query) => plainDeep(await t.history(query)),
|
|
8700
|
+
create: async (name) => plainDeep(await t.create(name))
|
|
8699
8701
|
};
|
|
8700
8702
|
}
|
|
8701
8703
|
/** Harbor said no, or couldn't be reached (`offline`). */
|
|
@@ -8753,6 +8755,10 @@ function httpTransport(source, fetcher = fetch) {
|
|
|
8753
8755
|
history: (history) => post({
|
|
8754
8756
|
op: "history",
|
|
8755
8757
|
history
|
|
8758
|
+
}),
|
|
8759
|
+
create: (name) => post({
|
|
8760
|
+
op: "create",
|
|
8761
|
+
name
|
|
8756
8762
|
})
|
|
8757
8763
|
};
|
|
8758
8764
|
}
|
|
@@ -9030,6 +9036,47 @@ function createUiStore() {
|
|
|
9030
9036
|
}));
|
|
9031
9037
|
}
|
|
9032
9038
|
|
|
9039
|
+
//#endregion
|
|
9040
|
+
//#region src/lib/empty.ts
|
|
9041
|
+
/** What a new harbor is called until its owner names it: "Sam's harbor". */
|
|
9042
|
+
function suggestedHarborName(displayName, email) {
|
|
9043
|
+
const first = (displayName || email || "").split(/[\s@]/)[0]?.trim();
|
|
9044
|
+
if (!first) return "My harbor";
|
|
9045
|
+
return `${first}'s harbor`;
|
|
9046
|
+
}
|
|
9047
|
+
|
|
9048
|
+
//#endregion
|
|
9049
|
+
//#region src/tui/first-run.ts
|
|
9050
|
+
/**
|
|
9051
|
+
* A new account's first `harbor`: it isn't in a harbor yet, so the terminal
|
|
9052
|
+
* asks for a name (and, if they like, a first project) and makes the same
|
|
9053
|
+
* empty harbor the website's launch page makes, instead of sending them off
|
|
9054
|
+
* to the website.
|
|
9055
|
+
*/
|
|
9056
|
+
async function firstHarbor(deps) {
|
|
9057
|
+
const fallback = suggestedHarborName(deps.account.name, deps.account.email);
|
|
9058
|
+
deps.say("You're signed in, but you aren't in a harbor yet, so let's make yours.");
|
|
9059
|
+
deps.say("Joining a team's harbor instead? Open the invite they sent you.\n");
|
|
9060
|
+
const name = (await deps.ask(`Name your harbor (Enter for "${fallback}"): `)).trim() || fallback;
|
|
9061
|
+
const project = (await deps.ask("Name your first project (Enter to skip): ")).trim() || null;
|
|
9062
|
+
const { harbor } = await deps.transport.create(name.slice(0, 120));
|
|
9063
|
+
return {
|
|
9064
|
+
harbor,
|
|
9065
|
+
project: project?.slice(0, 120) ?? null
|
|
9066
|
+
};
|
|
9067
|
+
}
|
|
9068
|
+
/** The first project, in the new harbor's starter program, led by its owner. */
|
|
9069
|
+
function addFirstProject(store, name, accountId, today) {
|
|
9070
|
+
const s = store.getState();
|
|
9071
|
+
const program = s.programs[0];
|
|
9072
|
+
if (!program) return null;
|
|
9073
|
+
const me = s.people.find((p) => p.accountId === accountId) ?? s.people[0];
|
|
9074
|
+
return commitCreate(store, "project", {
|
|
9075
|
+
name,
|
|
9076
|
+
ownerId: me?.id ?? ""
|
|
9077
|
+
}, { programId: program.id }, today);
|
|
9078
|
+
}
|
|
9079
|
+
|
|
9033
9080
|
//#endregion
|
|
9034
9081
|
//#region src/tui/main.tsx
|
|
9035
9082
|
/**
|
|
@@ -9072,6 +9119,11 @@ async function startApp(deps, options = {}) {
|
|
|
9072
9119
|
instance.unmount();
|
|
9073
9120
|
throw err;
|
|
9074
9121
|
}
|
|
9122
|
+
if (deps.firstRun) {
|
|
9123
|
+
const project = deps.firstRun.project;
|
|
9124
|
+
if (project) addFirstProject(store, project, account.id, today());
|
|
9125
|
+
ui.setState({ notice: project ? `Your harbor is ready, with ${project} in it. n adds more, ? lists the keys.` : "Your harbor is ready. n adds a project, ? lists the keys." });
|
|
9126
|
+
}
|
|
9075
9127
|
instance.rerender(/* @__PURE__ */ jsx(DepsProvider, {
|
|
9076
9128
|
value: {
|
|
9077
9129
|
store,
|
|
@@ -9113,18 +9165,58 @@ function harborNamed(harbors, key) {
|
|
|
9113
9165
|
async function runApp(opts) {
|
|
9114
9166
|
const theme = await pickTheme(opts.theme);
|
|
9115
9167
|
const transport = httpTransport(opts.source, opts.fetcher);
|
|
9116
|
-
const
|
|
9117
|
-
|
|
9168
|
+
const answer = await transport.harbors();
|
|
9169
|
+
const account = answer.account;
|
|
9170
|
+
let harbors = answer.harbors;
|
|
9171
|
+
let firstRun;
|
|
9172
|
+
let workspaceId;
|
|
9173
|
+
if (harbors.length === 0) {
|
|
9174
|
+
const terminal = terminalAsk();
|
|
9175
|
+
try {
|
|
9176
|
+
const made = await firstHarbor({
|
|
9177
|
+
ask: terminal.ask,
|
|
9178
|
+
say: (line) => process.stdout.write(`${line}\n`),
|
|
9179
|
+
transport: plainTransport(transport),
|
|
9180
|
+
account: plainDeep(account)
|
|
9181
|
+
});
|
|
9182
|
+
harbors = [made.harbor];
|
|
9183
|
+
workspaceId = made.harbor.id;
|
|
9184
|
+
firstRun = { project: made.project };
|
|
9185
|
+
} finally {
|
|
9186
|
+
terminal.close();
|
|
9187
|
+
}
|
|
9188
|
+
} else if (opts.harbor) workspaceId = harborNamed(harbors, opts.harbor).id;
|
|
9118
9189
|
const { instance } = await startApp({
|
|
9119
9190
|
transport,
|
|
9120
9191
|
account,
|
|
9121
9192
|
harbors,
|
|
9122
|
-
workspaceId
|
|
9193
|
+
workspaceId,
|
|
9123
9194
|
splashMs: SPLASH_MS,
|
|
9124
|
-
theme
|
|
9195
|
+
theme,
|
|
9196
|
+
firstRun
|
|
9125
9197
|
}, { alternateScreen: true });
|
|
9126
9198
|
await instance.waitUntilExit();
|
|
9127
9199
|
}
|
|
9200
|
+
/** Questions asked on the plain terminal, before the app takes it over. Ctrl+C stops. */
|
|
9201
|
+
function terminalAsk() {
|
|
9202
|
+
const rl = createInterface({
|
|
9203
|
+
input: process.stdin,
|
|
9204
|
+
output: process.stdout
|
|
9205
|
+
});
|
|
9206
|
+
let stop = () => {};
|
|
9207
|
+
const stopped = new Promise((_, reject) => stop = reject);
|
|
9208
|
+
stopped.catch(() => {});
|
|
9209
|
+
const nothing = () => stop(new HarborError("Stopped. Nothing was made."));
|
|
9210
|
+
rl.on("SIGINT", nothing);
|
|
9211
|
+
rl.on("close", nothing);
|
|
9212
|
+
return {
|
|
9213
|
+
ask: (question) => Promise.race([rl.question(question), stopped]),
|
|
9214
|
+
close: () => {
|
|
9215
|
+
rl.off("close", nothing);
|
|
9216
|
+
rl.close();
|
|
9217
|
+
}
|
|
9218
|
+
};
|
|
9219
|
+
}
|
|
9128
9220
|
|
|
9129
9221
|
//#endregion
|
|
9130
|
-
export { SPLASH_MS, TransportError, harborNamed, httpTransport, runApp, startApp };
|
|
9222
|
+
export { SPLASH_MS, TransportError, firstHarbor, harborNamed, httpTransport, runApp, startApp };
|
package/lib/auth.mjs
CHANGED
|
@@ -90,9 +90,16 @@ export async function listenForCode(state, { timeoutMs = 5 * 60_000 } = {}) {
|
|
|
90
90
|
|
|
91
91
|
/**
|
|
92
92
|
* The whole sign-in. `open(url)` shows the asking page (a browser, or a test
|
|
93
|
-
* driving one). Returns credentials to save.
|
|
93
|
+
* driving one). `signUp` opens it on making an account. Returns credentials to save.
|
|
94
94
|
*/
|
|
95
|
-
export async function login({
|
|
95
|
+
export async function login({
|
|
96
|
+
server,
|
|
97
|
+
readOnly = false,
|
|
98
|
+
signUp = false,
|
|
99
|
+
open,
|
|
100
|
+
fetcher = fetch,
|
|
101
|
+
now = Date.now,
|
|
102
|
+
}) {
|
|
96
103
|
const meta = await discover(server, fetcher);
|
|
97
104
|
const state = b64url(randomBytes(16));
|
|
98
105
|
const { verifier, challenge } = pkcePair();
|
|
@@ -122,6 +129,8 @@ export async function login({ server, readOnly = false, open, fetcher = fetch, n
|
|
|
122
129
|
code_challenge: challenge,
|
|
123
130
|
code_challenge_method: "S256",
|
|
124
131
|
scope: readOnly ? "openid offline_access" : `openid offline_access ${WRITE_SCOPE} ${APP_SCOPE}`,
|
|
132
|
+
// OpenID's way to say "new here": Harbor's page opens on Create.
|
|
133
|
+
...(signUp ? { prompt: "create" } : {}),
|
|
125
134
|
}).toString();
|
|
126
135
|
await open(authorize.toString());
|
|
127
136
|
|
package/lib/cli.mjs
CHANGED
|
@@ -24,6 +24,7 @@ import { jsonText, plain } from "./plain.mjs";
|
|
|
24
24
|
export const HELP = `harbor: Harbor from the terminal
|
|
25
25
|
|
|
26
26
|
harbor the full-screen app: everything, with the keyboard
|
|
27
|
+
harbor signup [--server URL] new to Harbor: make an account (opens your browser)
|
|
27
28
|
harbor login [--server URL] [--read-only] sign in (opens your browser once)
|
|
28
29
|
harbor logout forget the sign-in on this machine
|
|
29
30
|
harbor harbors the harbors you belong to
|
|
@@ -174,6 +175,10 @@ export function hint(message) {
|
|
|
174
175
|
.replace(
|
|
175
176
|
/^Say which project or program it belongs to\.$/,
|
|
176
177
|
"Say where it belongs: --project NAME or --program NAME.",
|
|
178
|
+
)
|
|
179
|
+
.replace(
|
|
180
|
+
/^You don't belong to a harbor yet\..*$/s,
|
|
181
|
+
"You aren't in a harbor yet. Run harbor to make one, or open the invite a team sent you.",
|
|
177
182
|
);
|
|
178
183
|
}
|
|
179
184
|
|
|
@@ -193,7 +198,7 @@ export function commandHelp(command) {
|
|
|
193
198
|
picked.push(lines[++i]);
|
|
194
199
|
}
|
|
195
200
|
if (picked.length === 0) return null;
|
|
196
|
-
const common = ["login", "logout", "use", "theme", "help", "app"].includes(command)
|
|
201
|
+
const common = ["signup", "login", "logout", "use", "theme", "help", "app"].includes(command)
|
|
197
202
|
? []
|
|
198
203
|
: ["", " Also takes --json, and --harbor NAME when you're in more than one harbor."];
|
|
199
204
|
return [...picked, ...common].join("\n");
|
|
@@ -300,8 +305,12 @@ export async function run(argv, io = {}) {
|
|
|
300
305
|
return 0;
|
|
301
306
|
}
|
|
302
307
|
|
|
303
|
-
if (command === "login") {
|
|
304
|
-
const
|
|
308
|
+
if (command === "login" || command === "signup") {
|
|
309
|
+
const signUp = command === "signup";
|
|
310
|
+
const { values } = parseArgs(rest, {
|
|
311
|
+
flags: signUp ? [] : ["read-only"],
|
|
312
|
+
options: ["server"],
|
|
313
|
+
});
|
|
305
314
|
const server = (values.server ?? env.HARBOR_SERVER ?? DEFAULT_SERVER).replace(/\/$/, "");
|
|
306
315
|
if (!/^https?:\/\/[^/]/.test(server) || !URL.canParse(server)) {
|
|
307
316
|
throw new HarborError(`--server needs a web address, like ${DEFAULT_SERVER}.`);
|
|
@@ -310,11 +319,16 @@ export async function run(argv, io = {}) {
|
|
|
310
319
|
const creds = await login({
|
|
311
320
|
server,
|
|
312
321
|
readOnly: Boolean(values["read-only"]),
|
|
322
|
+
signUp,
|
|
313
323
|
fetcher,
|
|
314
324
|
open:
|
|
315
325
|
io.open ??
|
|
316
326
|
((url) => {
|
|
317
|
-
out(
|
|
327
|
+
out(
|
|
328
|
+
signUp
|
|
329
|
+
? `Opening your browser to make your Harbor account. If it doesn't open, go to:\n\n ${url}\n`
|
|
330
|
+
: `Opening your browser to sign in (new to Harbor? choose Create there). If it doesn't open, go to:\n\n ${url}\n`,
|
|
331
|
+
);
|
|
318
332
|
openBrowser(url);
|
|
319
333
|
}),
|
|
320
334
|
});
|
|
@@ -326,14 +340,16 @@ export async function run(argv, io = {}) {
|
|
|
326
340
|
await writeCredentials(creds, env);
|
|
327
341
|
const canWrite = creds.scope.split(" ").includes("harbor:write");
|
|
328
342
|
const said = `Signed in to ${server}${canWrite ? "." : ", reading only."}`;
|
|
343
|
+
const next = signUp ? "Next: run harbor, and it helps you make your first harbor." : null;
|
|
329
344
|
const welcome = banner(io, env, ({ bold, dim }) => [
|
|
330
345
|
bold("Welcome aboard."),
|
|
331
346
|
said,
|
|
347
|
+
...(next ? [next] : []),
|
|
332
348
|
"",
|
|
333
349
|
dim("harbor the full-screen app"),
|
|
334
350
|
dim("harbor help every command"),
|
|
335
351
|
]);
|
|
336
|
-
out(welcome ?? said);
|
|
352
|
+
out(welcome ?? (next ? `${said}\n${next}` : said));
|
|
337
353
|
return 0;
|
|
338
354
|
}
|
|
339
355
|
|
|
@@ -367,7 +383,8 @@ export async function run(argv, io = {}) {
|
|
|
367
383
|
}
|
|
368
384
|
|
|
369
385
|
const creds = await readCredentials(env);
|
|
370
|
-
if (!creds)
|
|
386
|
+
if (!creds)
|
|
387
|
+
throw new HarborError("Not signed in. Run harbor login, or harbor signup if you're new.");
|
|
371
388
|
const client = createClient({ creds, save: (c) => writeCredentials(c, env), fetcher });
|
|
372
389
|
const opts = { today: todayLocal() };
|
|
373
390
|
|
package/lib/format.mjs
CHANGED
|
@@ -52,7 +52,7 @@ export function taskList(r, opts) {
|
|
|
52
52
|
|
|
53
53
|
export function harbors(r) {
|
|
54
54
|
const list = r.harbors ?? [];
|
|
55
|
-
if (!list.length) return "You aren't in any harbor yet.";
|
|
55
|
+
if (!list.length) return "You aren't in any harbor yet. Run harbor to make one.";
|
|
56
56
|
return list.map((h) => `${h.id} ${h.name}${h.role ? ` (${h.role})` : ""}`).join("\n");
|
|
57
57
|
}
|
|
58
58
|
|
package/package.json
CHANGED
|
@@ -1,7 +1,19 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@harborppm/cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.7.0",
|
|
4
4
|
"description": "Harbor from the terminal: a full-screen app with everything, plus commands for scripts and agents.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"harbor",
|
|
7
|
+
"project-management",
|
|
8
|
+
"portfolio-management",
|
|
9
|
+
"ppm",
|
|
10
|
+
"kanban",
|
|
11
|
+
"tasks",
|
|
12
|
+
"cli",
|
|
13
|
+
"tui",
|
|
14
|
+
"terminal",
|
|
15
|
+
"mcp"
|
|
16
|
+
],
|
|
5
17
|
"type": "module",
|
|
6
18
|
"bin": {
|
|
7
19
|
"harbor": "bin/harbor.mjs"
|