@harborppm/cli 0.6.0 → 0.7.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/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # Harbor CLI
2
2
 
3
- [Harbor](https://harborppm.com) in the terminal: a full-screen app with your whole harbor, and commands for scripts and agents.
3
+ [Harbor](https://harborppm.com/?utm_source=npm&utm_medium=readme&utm_campaign=cli) in the terminal: a full-screen app with your whole harbor, and commands for scripts and agents.
4
4
 
5
5
  ```sh
6
6
  npm install -g @harborppm/cli
@@ -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 { account, harbors } = await transport.harbors();
9117
- if (harbors.length === 0) throw new HarborError(`You don't belong to a harbor yet. Make one at ${opts.source.server} first.`);
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: (opts.harbor ? harborNamed(harbors, opts.harbor) : void 0)?.id,
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({ server, readOnly = false, open, fetcher = fetch, now = Date.now }) {
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 { values } = parseArgs(rest, { flags: ["read-only"], options: ["server"] });
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(`Opening your browser to sign in. If it doesn't open, go to:\n\n ${url}\n`);
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) throw new HarborError("Not signed in. Run harbor login.");
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.6.0",
3
+ "version": "0.7.1",
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"
@@ -20,7 +32,7 @@
20
32
  "test": "node --test test/*.test.mjs",
21
33
  "build": "node build.mjs"
22
34
  },
23
- "homepage": "https://harborppm.com/ai",
35
+ "homepage": "https://harborppm.com/ai?utm_source=npm&utm_medium=package&utm_campaign=cli",
24
36
  "license": "MIT",
25
37
  "dependencies": {
26
38
  "ink": "^7.1.1",