@nickmeriano/task 0.3.0 → 0.4.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/src/cli.ts CHANGED
@@ -14,6 +14,7 @@ import type { Server } from "node:http"
14
14
  import { basename, join } from "node:path"
15
15
  import process from "node:process"
16
16
  import { resolveAuthor } from "./author.js"
17
+ import { detectRepo, parseSlug, publish, resolveHost } from "./publish.js"
17
18
  import { createTaskServer } from "./server.js"
18
19
  import { CONFIG_FILE, TASK_DIR, TaskStore, findBoards, findRoot, initProject } from "./store.js"
19
20
  import { STATUSES, isStatus, type Status, type Task, type TaskPatch } from "./types.js"
@@ -64,6 +65,9 @@ const BOOLEAN_FLAGS = new Set([
64
65
  "open",
65
66
  "no-open",
66
67
  "strict-port",
68
+ "public",
69
+ "private",
70
+ "no-wait",
67
71
  ])
68
72
 
69
73
  function parseArgs(argv: string[]): Args {
@@ -403,6 +407,100 @@ function cmdServe(args: Args): void {
403
407
  })
404
408
  }
405
409
 
410
+ /**
411
+ * `task publish` — the board, at a URL.
412
+ *
413
+ * Visibility is the only real decision here, and the default is the cautious
414
+ * one: **every board starts private, including a public repository's**. A repo
415
+ * being open source says its code is readable; it says nothing about whether
416
+ * its owner wants a live feed of what they're working on next. Going public is
417
+ * `--public`, said out loud, and it can be changed either way at any time by
418
+ * running this again.
419
+ */
420
+ async function cmdPublish(args: Args): Promise<void> {
421
+ if (args.flags.public && args.flags.private) {
422
+ fail("--public and --private contradict each other — pass one")
423
+ }
424
+ // `undefined`, not a default: an omitted flag means "leave it alone" so that
425
+ // re-publishing to reconnect a repo can't silently change who can see it.
426
+ const visibility = args.flags.public ? "public" : args.flags.private ? "private" : null
427
+
428
+ const repo = resolveRepoArg(args)
429
+ console.log(`Publishing ${repo.owner}/${repo.repo}${visibility ? ` (${visibility})` : ""}`)
430
+
431
+ const result = await runPublishFlow(args, repo, "publish", visibility)
432
+ if (!result) return
433
+
434
+ console.log("")
435
+ console.log(`✓ Published${result.approvedBy ? ` by ${result.approvedBy}` : ""}`)
436
+ console.log(` ${result.boardUrl}`)
437
+ console.log(
438
+ result.visibility === "public"
439
+ ? " Public — anyone with the link can view this board."
440
+ : " Private — only people who can see the repo on GitHub can view it.",
441
+ )
442
+ console.log("")
443
+ console.log("The board reads .task/ from your default branch, so commit and push to update it.")
444
+ }
445
+
446
+ /**
447
+ * `task unpublish` — take the URL away.
448
+ *
449
+ * Small on purpose, and worth saying so at the prompt: this removes a board,
450
+ * not a backlog. The tasks were never anywhere but `.task/` in your repo, so
451
+ * there is nothing here that can lose them — which is also why this doesn't ask
452
+ * for a `--yes`. The browser step already puts a confirmation in front of a
453
+ * human, and `task publish` puts it straight back.
454
+ */
455
+ async function cmdUnpublish(args: Args): Promise<void> {
456
+ const repo = resolveRepoArg(args)
457
+ console.log(`Unpublishing ${repo.owner}/${repo.repo}`)
458
+
459
+ const result = await runPublishFlow(args, repo, "unpublish", null)
460
+ if (!result) return
461
+
462
+ console.log("")
463
+ console.log(`✓ Unpublished${result.approvedBy ? ` by ${result.approvedBy}` : ""}`)
464
+ console.log(` ${result.boardUrl} now returns a 404.`)
465
+ console.log("")
466
+ console.log("Your tasks are untouched — .task/ is where they always were.")
467
+ console.log("The task app stays installed on the repo; remove it in GitHub settings")
468
+ console.log("to revoke its read access too. `task publish` puts the board back.")
469
+ }
470
+
471
+ /** The repo both commands operate on: `--repo owner/name`, else the remote. */
472
+ function resolveRepoArg(args: Args): { owner: string; repo: string } {
473
+ const slug = str(args.flags, "repo")
474
+ const repo = slug ? parseSlug(slug) : detectRepo(process.cwd())
475
+ if (!repo) {
476
+ fail(
477
+ slug
478
+ ? `--repo must look like owner/name, got "${slug}"`
479
+ : "couldn't work out the GitHub repo from your git remotes — pass --repo owner/name",
480
+ )
481
+ }
482
+ return repo
483
+ }
484
+
485
+ function runPublishFlow(
486
+ args: Args,
487
+ repo: { owner: string; repo: string },
488
+ action: "publish" | "unpublish",
489
+ visibility: "public" | "private" | null,
490
+ ) {
491
+ return publish({
492
+ host: resolveHost(str(args.flags, "host")),
493
+ repo,
494
+ action,
495
+ visibility,
496
+ noWait: Boolean(args.flags["no-wait"]),
497
+ open: (url) => {
498
+ if (!args.flags["no-open"] && !process.env.CI) openBrowser(url)
499
+ },
500
+ log: (line) => console.log(line),
501
+ })
502
+ }
503
+
406
504
  // ── Help + dispatch ──────────────────────────────────────────────────────────
407
505
 
408
506
  const HELP = `task — a task manager that lives in your repo
@@ -434,6 +532,16 @@ Usage
434
532
  fails instead). Serves every board at or below
435
533
  here — in a monorepo the header becomes a
436
534
  board switcher
535
+ task publish [--public | --private] [--repo <owner/name>]
536
+ give this repo's board a URL at
537
+ task.nickmeriano.com, read-only, updated from
538
+ your default branch. Private by default —
539
+ *including for public repos* — so only people
540
+ who can see the repo can see the board. Run it
541
+ again with --public or --private to change that
542
+ task unpublish [--repo <owner/name>]
543
+ take that URL down. Removes the board, not the
544
+ tasks — those are in .task/ either way
437
545
 
438
546
  Values
439
547
  <id> TASK-12, or just 12
@@ -449,7 +557,8 @@ Every read/write command accepts --json for machine-readable output — that's
449
557
  the interface AI agents should use.
450
558
  `
451
559
 
452
- function main(): void {
560
+ /** Sync but for `publish`, which waits on a browser — hence the union. */
561
+ function main(): void | Promise<void> {
453
562
  const [command, ...rest] = process.argv.slice(2)
454
563
  const args = parseArgs(rest)
455
564
 
@@ -487,13 +596,21 @@ function main(): void {
487
596
  return cmdDelete(args)
488
597
  case "serve":
489
598
  return cmdServe(args)
599
+ case "publish":
600
+ return cmdPublish(args)
601
+ case "unpublish":
602
+ return cmdUnpublish(args)
490
603
  default:
491
604
  fail(`unknown command "${command}" — run \`task help\``)
492
605
  }
493
606
  }
494
607
 
495
608
  try {
496
- main()
609
+ // `publish` is the only async command; everything else has already finished
610
+ // by the time this returns, and its errors are caught below.
611
+ void Promise.resolve(main()).catch((error: unknown) =>
612
+ fail(error instanceof Error ? error.message : String(error)),
613
+ )
497
614
  } catch (error) {
498
615
  fail(error instanceof Error ? error.message : String(error))
499
616
  }
package/src/publish.ts ADDED
@@ -0,0 +1,192 @@
1
+ /**
2
+ * `task publish` / `task unpublish` — give this repo's board a URL, or take it
3
+ * away.
4
+ *
5
+ * The CLI cannot install a GitHub App or sign anyone in; both are browser
6
+ * flows. So it does the one thing it is well placed to do — work out *which*
7
+ * repository you mean — opens a ticket for it, sends you to a browser, and
8
+ * waits. The credential it holds is a single short-lived ticket id that is good
9
+ * for nothing but asking "is this done yet".
10
+ *
11
+ * Both directions are the same handshake with a different verb, deliberately:
12
+ * taking a board down needs exactly the permission putting one up does, and one
13
+ * code path is one place for that check to be right.
14
+ *
15
+ * Nothing is written to `.task/` either way. The link between a repo and its
16
+ * board lives on the host, keyed by the GitHub remote, so there is no state to
17
+ * commit, no token in the repo, and re-running this from a fresh clone is the
18
+ * same operation as re-running it here.
19
+ */
20
+
21
+ import { execFileSync } from "node:child_process"
22
+ import process from "node:process"
23
+
24
+ /** Overridable so the flow can be exercised against a local `vite dev`. */
25
+ export const DEFAULT_HOST = "https://task.nickmeriano.com"
26
+
27
+ export type Visibility = "private" | "public"
28
+
29
+ export type Action = "publish" | "unpublish"
30
+
31
+ export interface RepoRef {
32
+ owner: string
33
+ repo: string
34
+ }
35
+
36
+ /**
37
+ * The GitHub repo this working copy pushes to.
38
+ *
39
+ * `origin` first, then the only remote if there's exactly one — a clone with
40
+ * `upstream` and a fork remote should not silently publish the wrong one, so
41
+ * ambiguity is an error the user resolves with `--repo`.
42
+ */
43
+ export function detectRepo(cwd: string): RepoRef | null {
44
+ const remotes = gitRemotes(cwd)
45
+ const url = remotes.get("origin") ?? (remotes.size === 1 ? [...remotes.values()][0] : null)
46
+ return url ? parseRemote(url) : null
47
+ }
48
+
49
+ function gitRemotes(cwd: string): Map<string, string> {
50
+ const out = new Map<string, string>()
51
+ let raw: string
52
+ try {
53
+ raw = execFileSync("git", ["remote", "-v"], {
54
+ cwd,
55
+ encoding: "utf8",
56
+ stdio: ["ignore", "pipe", "ignore"],
57
+ })
58
+ } catch {
59
+ return out
60
+ }
61
+ for (const line of raw.split("\n")) {
62
+ const match = /^(\S+)\s+(\S+)\s+\(fetch\)$/.exec(line.trim())
63
+ if (match) out.set(match[1], match[2])
64
+ }
65
+ return out
66
+ }
67
+
68
+ /**
69
+ * Every shape git writes a GitHub remote in: https, ssh, `git@`-scp, with or
70
+ * without `.git`, and with or without credentials embedded in the host.
71
+ */
72
+ export function parseRemote(url: string): RepoRef | null {
73
+ const cleaned = url.trim().replace(/\.git$/, "")
74
+ const match =
75
+ /^(?:https?:\/\/|ssh:\/\/)?(?:[^@/]+@)?github\.com[:/]([^/]+)\/([^/]+?)\/?$/.exec(cleaned)
76
+ if (!match) return null
77
+ const [, owner, repo] = match
78
+ if (!/^[A-Za-z0-9](?:[A-Za-z0-9-]{0,38})$/.test(owner)) return null
79
+ if (!/^[A-Za-z0-9._-]{1,100}$/.test(repo)) return null
80
+ return { owner, repo }
81
+ }
82
+
83
+ /** `owner/repo`, for `--repo`. */
84
+ export function parseSlug(slug: string): RepoRef | null {
85
+ const [owner, repo, ...rest] = slug.split("/")
86
+ if (!owner || !repo || rest.length > 0) return null
87
+ return parseRemote(`https://github.com/${owner}/${repo}`)
88
+ }
89
+
90
+ interface StartResponse {
91
+ ticket: string
92
+ url: string
93
+ boardUrl: string
94
+ expiresAt: string
95
+ }
96
+
97
+ interface StatusResponse {
98
+ status: "pending" | "complete" | "denied" | "expired"
99
+ action: Action
100
+ approvedBy: string | null
101
+ error: string | null
102
+ boardUrl: string
103
+ visibility: Visibility | null
104
+ }
105
+
106
+ async function postJson<T>(url: string, body: unknown): Promise<T> {
107
+ const res = await fetch(url, {
108
+ method: "POST",
109
+ headers: { "Content-Type": "application/json" },
110
+ body: JSON.stringify(body),
111
+ })
112
+ const data = (await res.json().catch(() => ({}))) as T & { error?: string }
113
+ if (!res.ok) throw new Error(data.error ?? `${res.status} from ${url}`)
114
+ return data
115
+ }
116
+
117
+ export interface PublishOptions {
118
+ host: string
119
+ repo: RepoRef
120
+ action: Action
121
+ /** Ignored for `unpublish`, which has no visibility to state. */
122
+ visibility: Visibility | null
123
+ /** Print the URL and exit instead of waiting for the browser. */
124
+ noWait: boolean
125
+ open: (url: string) => void
126
+ log: (line: string) => void
127
+ }
128
+
129
+ export interface PublishResult {
130
+ boardUrl: string
131
+ approvedBy: string | null
132
+ visibility: Visibility | null
133
+ }
134
+
135
+ /** Poll interval — fast enough to feel immediate, slow enough to be polite. */
136
+ const POLL_MS = 1500
137
+
138
+ /**
139
+ * Runs the whole handshake. Resolves when the browser half completes; rejects
140
+ * with a message meant to be printed as-is.
141
+ */
142
+ export async function publish(options: PublishOptions): Promise<PublishResult | null> {
143
+ const { host, repo, action, visibility } = options
144
+ const start = await postJson<StartResponse>(`${host}/publish/start`, {
145
+ owner: repo.owner,
146
+ repo: repo.repo,
147
+ action,
148
+ visibility,
149
+ })
150
+
151
+ options.log(`Approve in your browser:\n ${start.url}`)
152
+ if (options.noWait) return null
153
+ options.open(start.url)
154
+ options.log("")
155
+ options.log("Waiting… (Ctrl-C to stop — the link above stays valid for 10 minutes)")
156
+
157
+ const deadline = Date.parse(start.expiresAt)
158
+ for (;;) {
159
+ await new Promise((resolve) => setTimeout(resolve, POLL_MS))
160
+ if (Date.now() > deadline) throw new Error("that link expired — run `task publish` again")
161
+
162
+ let status: StatusResponse
163
+ try {
164
+ const res = await fetch(`${host}/publish/status?ticket=${encodeURIComponent(start.ticket)}`)
165
+ if (res.status === 404) throw new Error("that link expired — run `task publish` again")
166
+ status = (await res.json()) as StatusResponse
167
+ } catch (error) {
168
+ // A blip between here and the host is not a failed publish. Keep waiting
169
+ // until the ticket's own deadline says otherwise.
170
+ if (error instanceof Error && error.message.includes("expired")) throw error
171
+ continue
172
+ }
173
+
174
+ if (status.status === "complete") {
175
+ return {
176
+ boardUrl: status.boardUrl,
177
+ approvedBy: status.approvedBy,
178
+ visibility: status.visibility,
179
+ }
180
+ }
181
+ if (status.status === "denied") {
182
+ throw new Error(status.error ?? `${action === "unpublish" ? "unpublishing" : "publishing"} was canceled`)
183
+ }
184
+ if (status.status === "expired") throw new Error("that link expired — run `task publish` again")
185
+ }
186
+ }
187
+
188
+ /** `TASK_HOST` exists so the flow can be pointed at a local dev server. */
189
+ export function resolveHost(flag: string | undefined): string {
190
+ const host = flag ?? process.env.TASK_HOST ?? DEFAULT_HOST
191
+ return host.replace(/\/+$/, "")
192
+ }