@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/README.md +37 -0
- package/dist/cli.js +101 -1
- package/dist/cli.js.map +1 -1
- package/dist/publish.d.ts +66 -0
- package/dist/publish.d.ts.map +1 -0
- package/dist/publish.js +148 -0
- package/dist/publish.js.map +1 -0
- package/package.json +3 -2
- package/skill/SKILL.md +5 -0
- package/src/cli.ts +119 -2
- package/src/publish.ts +192 -0
- package/ui/dist/assets/index--vjOee4k.js +229 -0
- package/ui/dist/assets/index-CrgjEA0T.css +1 -0
- package/ui/dist/index.html +2 -2
- package/ui/dist/assets/index-LUrTo_N-.css +0 -1
- package/ui/dist/assets/index-M_JwP0ja.js +0 -229
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
|
-
|
|
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
|
-
|
|
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
|
+
}
|