ework-aio 0.2.1 → 0.2.2

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ework-aio",
3
- "version": "0.2.1",
3
+ "version": "0.2.2",
4
4
  "description": "All-in-one installer for ework (issue tracker) + ework-daemon (AI bridge) + opencode-ework (plugin). One command: npm i -g ework-aio && ework-aio install.",
5
5
  "type": "module",
6
6
  "license": "MIT",
package/src/cli.ts CHANGED
@@ -35,7 +35,7 @@ import {
35
35
  type ConfigArgs,
36
36
  } from "./commands/config.ts";
37
37
 
38
- const VERSION = "0.2.0-dev";
38
+ const VERSION = "0.2.2-dev";
39
39
 
40
40
  const USAGE = `ework-aio <command> [options]
41
41
 
@@ -505,23 +505,30 @@ interface BootstrapBotOpts {
505
505
  fetchImpl?: FetchLike;
506
506
  }
507
507
 
508
- // bootstrapBot: faithful port of install.sh's bot user + PAT flow.
509
- // Uses the operator's token-derived cookie to drive ework-web's admin API,
510
- // then logs in as the bot to mint a PAT. Step-by-step idempotence:
511
- // - create user: HTTP 303 = created (password is what we sent)
512
- // HTTP 400/409 = already exists force-reset password via admin API
513
- // so the subsequent /login is guaranteed to work. Without this reset,
514
- // a bot user left over from a previous failed install keeps its OLD
515
- // random password (which we don't know), and /login returns 401 —
516
- // the install can never recover without manual DB surgery.
517
- // - login as bot: required to get a session cookie for /me/tokens/create
518
- // - mint PAT: scrape the token out of the HTML response (the only way
519
- // ework-web exposes clear-text tokens, matching install.sh)
508
+ // bootstrapBot: faithful port of install.sh's bot user + PAT flow, with
509
+ // one critical correctness fix the bash version got wrong too.
510
+ //
511
+ // ework-web's /admin/users/create and /admin/users/<login>/reset-password
512
+ // both follow the PRG (Post/Redirect/Get) pattern: they return HTTP 303
513
+ // for BOTH success and failure, with the outcome encoded in the Location
514
+ // URL's query string (?ok=1 vs ?err=<msg>). Status-code checks are
515
+ // useless; we MUST inspect Location.
516
+ //
517
+ // Idempotence contract:
518
+ // 1. POST /admin/users/create best-effort. May fail with
519
+ // "已存在" / "exists" if bot user was left over from a prior install.
520
+ // Any OTHER error (invalid login, weak password, etc) → abort.
521
+ // 2. POST /admin/users/<login>/reset-password — always. If the user
522
+ // was just created, this is a redundant no-op. If the user already
523
+ // existed, this overwrites the OLD random password (which we don't
524
+ // know) with our fresh one. Either way, /login below is guaranteed.
525
+ // 3. POST /login — uses the password we just set.
526
+ // 4. POST /me/tokens/create — scrape clear-text PAT from HTML.
520
527
  async function bootstrapBot(opts: BootstrapBotOpts): Promise<string> {
521
528
  const fetchImpl = opts.fetchImpl ?? fetch;
522
529
  const botPassword = randomBytes(24).toString("hex");
523
530
 
524
- // 1. Create bot user via admin API.
531
+ // 1. Create bot user (best-effort).
525
532
  const createRes = await fetchImpl(`${opts.baseUrl}/admin/users/create`, {
526
533
  method: "POST",
527
534
  headers: { Cookie: opts.adminCookie, "Content-Type": "application/x-www-form-urlencoded" },
@@ -533,30 +540,35 @@ async function bootstrapBot(opts: BootstrapBotOpts): Promise<string> {
533
540
  }).toString(),
534
541
  redirect: "manual",
535
542
  });
536
- if (createRes.status === 400 || createRes.status === 409) {
537
- // Bot user already exists from a previous install attempt. The
538
- // password we just sent was IGNORED by ework-web (create only writes
539
- // it on first creation). Without resetting, /login below uses the
540
- // fresh botPassword which doesn't match the stored hash → 401.
541
- // Force-reset via admin endpoint so the password we hold is real.
542
- const resetRes = await fetchImpl(
543
- `${opts.baseUrl}/admin/users/${encodeURIComponent(opts.botLogin)}/reset-password`,
544
- {
545
- method: "POST",
546
- headers: { Cookie: opts.adminCookie, "Content-Type": "application/x-www-form-urlencoded" },
547
- body: new URLSearchParams({ password: botPassword }).toString(),
548
- redirect: "manual",
549
- },
550
- );
551
- if (resetRes.status !== 303) {
552
- const body = await resetRes.text();
553
- throw new InstallError(
554
- `bot user exists but password reset failed (HTTP ${resetRes.status}): ${body.slice(0, 200)}`,
555
- );
556
- }
557
- } else if (createRes.status !== 303) {
543
+ if (createRes.status !== 303) {
558
544
  const body = await createRes.text();
559
- throw new InstallError(`create bot user failed (HTTP ${createRes.status}): ${body.slice(0, 200)}`);
545
+ throw new InstallError(`create bot user HTTP ${createRes.status}: ${body.slice(0, 200)}`);
546
+ }
547
+ const createErr = locationErrParam(createRes.headers.get("location"));
548
+ if (createErr && !isAlreadyExistsError(createErr)) {
549
+ // Real create failure (invalid login, weak password, etc). Don't try
550
+ // reset — the same problem would surface there too.
551
+ throw new InstallError(`create bot user rejected by ework-web: ${createErr}`);
552
+ }
553
+
554
+ // 2. Force-reset the password. Idempotent: sets the password to our
555
+ // fresh value whether the user was just created or already existed.
556
+ const resetRes = await fetchImpl(
557
+ `${opts.baseUrl}/admin/users/${encodeURIComponent(opts.botLogin)}/reset-password`,
558
+ {
559
+ method: "POST",
560
+ headers: { Cookie: opts.adminCookie, "Content-Type": "application/x-www-form-urlencoded" },
561
+ body: new URLSearchParams({ password: botPassword }).toString(),
562
+ redirect: "manual",
563
+ },
564
+ );
565
+ if (resetRes.status !== 303) {
566
+ const body = await resetRes.text();
567
+ throw new InstallError(`bot password reset HTTP ${resetRes.status}: ${body.slice(0, 200)}`);
568
+ }
569
+ const resetErr = locationErrParam(resetRes.headers.get("location"));
570
+ if (resetErr) {
571
+ throw new InstallError(`bot password reset rejected by ework-web: ${resetErr}`);
560
572
  }
561
573
 
562
574
  // 2. Login as bot to get its session cookie.
@@ -635,6 +647,33 @@ function parseFirstCookie(headers: Headers): string | null {
635
647
  return null;
636
648
  }
637
649
 
650
+ // locationErrParam: ework-web's admin POST endpoints use the PRG pattern,
651
+ // returning HTTP 303 with the outcome in the Location URL's query string
652
+ // (?ok=1 on success, ?err=<encoded msg> on failure). Extracts the err
653
+ // value (decoded) or null if absent. Used by bootstrapBot to tell
654
+ // "duplicate user" (recoverable via reset) from real failures (abort).
655
+ function locationErrParam(location: string | null): string | null {
656
+ if (!location) return null;
657
+ try {
658
+ const url = new URL(location, "http://placeholder.invalid");
659
+ const err = url.searchParams.get("err");
660
+ return err ?? null;
661
+ } catch {
662
+ // Location wasn't a parseable URL — defensive fallback via regex.
663
+ const m = location.match(/[?&]err=([^&]+)/);
664
+ return m ? decodeURIComponent(m[1]!) : null;
665
+ }
666
+ }
667
+
668
+ // isAlreadyExistsError: ework-web's createUser throws StoreError(409,
669
+ // "用户 X 已存在"). The Location redirect will carry this exact string
670
+ // (URL-encoded). We accept both Chinese and English variants in case
671
+ // ework-web changes its message later.
672
+ function isAlreadyExistsError(err: string): boolean {
673
+ const lower = err.toLowerCase();
674
+ return lower.includes("已存在") || lower.includes("already exists") || lower.includes("duplicate");
675
+ }
676
+
638
677
  function printSummary(logger: Logger, o: InstallOutcome): void {
639
678
  logger.hr();
640
679
  logger.ok(`install complete (${o.mode} mode)`);