ework-aio 0.2.1 → 0.2.3

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.3",
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.3-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.
@@ -580,14 +592,18 @@ async function bootstrapBot(opts: BootstrapBotOpts): Promise<string> {
580
592
  }
581
593
 
582
594
  // 3. Mint PAT via /me/tokens/create. Response is HTML containing the
583
- // clear-text token in `<input id="t" value="<40-hex>">`. We only get one
584
- // chance to see the clear-text must scrape it now.
595
+ // clear-text token. ework-web renders it inside `<code id="t">VALUE</code>`
596
+ // (verified at src/views/tokens.ts:141 in ework-web at time of writing);
597
+ // the legacy install.sh-era HTML used `<input id="t" value="VALUE">`. We
598
+ // scrape both shapes so an ework-web template change doesn't silently
599
+ // break install.
585
600
  //
586
601
  // S-3: pin redirect:"manual" so a PRG-style 303→GET doesn't land us on
587
602
  // a "token list" page where the clear-text value isn't present.
588
603
  //
589
- // S-2: scrape regex is attribute-order-independent accepts both
590
- // `<input id="t" value="...">` and `<input value="..." id="t">`.
604
+ // Token shape: 40 lowercase hex chars (ework-web createPat uses
605
+ // randomHex(20) 20 bytes 40 hex). Case-insensitive `i` flag for
606
+ // XHTML-style `<INPUT VALUE="...">` and uppercase hex (defensive).
591
607
  const patRes = await fetchImpl(`${opts.baseUrl}/me/tokens/create`, {
592
608
  method: "POST",
593
609
  headers: { Cookie: botCookie, "Content-Type": "application/x-www-form-urlencoded" },
@@ -600,14 +616,14 @@ async function bootstrapBot(opts: BootstrapBotOpts): Promise<string> {
600
616
  );
601
617
  }
602
618
  const patBody = await patRes.text();
603
- // S-2: scrape regex is attribute-order-independent — accepts both
604
- // `<input id="t" value="...">` and `<input value="..." id="t">`.
605
- // Case-insensitive (i flag) so XHTML-style `<INPUT VALUE="...">` from
606
- // ework-web template tweaks doesn't silently break scraping.
607
- const match = patBody.match(/<input[^>]*id="t"[^>]*value="([a-f0-9]{40})"/i)
608
- ?? patBody.match(/<input[^>]*value="([a-f0-9]{40})"[^>]*id="t"/i);
619
+ const match =
620
+ patBody.match(/<code[^>]*id="t"[^>]*>\s*([a-f0-9]{40})\s*<\/code>/i) ??
621
+ patBody.match(/<input[^>]*id="t"[^>]*value="([a-f0-9]{40})"/i) ??
622
+ patBody.match(/<input[^>]*value="([a-f0-9]{40})"[^>]*id="t"/i);
609
623
  if (!match || !match[1]) {
610
- throw new InstallError(`could not extract PAT from token-create response`);
624
+ throw new InstallError(
625
+ `could not extract PAT from token-create response (first 300 chars): ${patBody.slice(0, 300)}`,
626
+ );
611
627
  }
612
628
  return match[1];
613
629
  }
@@ -635,6 +651,33 @@ function parseFirstCookie(headers: Headers): string | null {
635
651
  return null;
636
652
  }
637
653
 
654
+ // locationErrParam: ework-web's admin POST endpoints use the PRG pattern,
655
+ // returning HTTP 303 with the outcome in the Location URL's query string
656
+ // (?ok=1 on success, ?err=<encoded msg> on failure). Extracts the err
657
+ // value (decoded) or null if absent. Used by bootstrapBot to tell
658
+ // "duplicate user" (recoverable via reset) from real failures (abort).
659
+ function locationErrParam(location: string | null): string | null {
660
+ if (!location) return null;
661
+ try {
662
+ const url = new URL(location, "http://placeholder.invalid");
663
+ const err = url.searchParams.get("err");
664
+ return err ?? null;
665
+ } catch {
666
+ // Location wasn't a parseable URL — defensive fallback via regex.
667
+ const m = location.match(/[?&]err=([^&]+)/);
668
+ return m ? decodeURIComponent(m[1]!) : null;
669
+ }
670
+ }
671
+
672
+ // isAlreadyExistsError: ework-web's createUser throws StoreError(409,
673
+ // "用户 X 已存在"). The Location redirect will carry this exact string
674
+ // (URL-encoded). We accept both Chinese and English variants in case
675
+ // ework-web changes its message later.
676
+ function isAlreadyExistsError(err: string): boolean {
677
+ const lower = err.toLowerCase();
678
+ return lower.includes("已存在") || lower.includes("already exists") || lower.includes("duplicate");
679
+ }
680
+
638
681
  function printSummary(logger: Logger, o: InstallOutcome): void {
639
682
  logger.hr();
640
683
  logger.ok(`install complete (${o.mode} mode)`);