ework-aio 0.2.0 → 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.0",
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
 
@@ -110,6 +110,22 @@ function defaultOpts(): GlobalOptions {
110
110
  // on unknown flags / missing values. The shape is stable so tests can
111
111
  // exercise it without touching process.argv.
112
112
  export function parseArgs(argv: string[]): ParsedArgs {
113
+ // G1: normalize `--flag=value` (Unix-conventional equals form) into
114
+ // `--flag value` so the rest of the parser only handles the space
115
+ // form. Short flags (-h, -v, -y) don't get this treatment (they never
116
+ // take `=` syntax). Plain `--` (end-of-options marker) and tokens
117
+ // without `--` prefix are left alone.
118
+ const expanded: string[] = [];
119
+ for (const tok of argv) {
120
+ if (tok.length > 3 && tok.startsWith("--") && tok.indexOf("=") > 2) {
121
+ const eqIdx = tok.indexOf("=");
122
+ expanded.push(tok.slice(0, eqIdx), tok.slice(eqIdx + 1));
123
+ } else {
124
+ expanded.push(tok);
125
+ }
126
+ }
127
+ argv = expanded;
128
+
113
129
  const opts = defaultOpts();
114
130
  const positionals: string[] = [];
115
131
  const configArgs: ConfigArgs = { subcommand: "list" };
@@ -286,6 +302,27 @@ function parseServiceTarget(arg: string | undefined): ServiceTarget {
286
302
  );
287
303
  }
288
304
 
305
+ // stripAsUserFlag: return argv without `--as-user <login>` (the flag and
306
+ // its value), so the sudo'd child doesn't re-enter handleAsUser and loop.
307
+ //
308
+ // Iterates by index, NOT Array.filter on value. If the username happens to
309
+ // appear in another argument (path component, common word like "install",
310
+ // or a --bot-name matching the operator login), filter-on-value would
311
+ // silently drop that token and corrupt the child's argv.
312
+ export function stripAsUserFlag(argv: readonly string[]): string[] {
313
+ const out: string[] = [];
314
+ for (let i = 0; i < argv.length; i++) {
315
+ const tok = argv[i];
316
+ if (tok === undefined) break;
317
+ if (tok === "--as-user") {
318
+ i++; // also skip the value
319
+ continue;
320
+ }
321
+ out.push(tok);
322
+ }
323
+ return out;
324
+ }
325
+
289
326
  // handleAsUser: when invoked with --as-user (typically via sudo), re-exec
290
327
  // the install as the named user after enabling linger. Skipped if not root
291
328
  // or no --as-user flag.
@@ -319,9 +356,9 @@ function handleAsUser(opts: GlobalOptions, argv: string[]): void {
319
356
  );
320
357
  }
321
358
 
322
- // Re-exec as target user with --as-user stripped from argv.
359
+ // Re-exec as target user.
323
360
  const self = process.argv[1]!;
324
- const newArgs = argv.filter((a) => a !== "--as-user" && a !== opts.asUser!);
361
+ const newArgs = stripAsUserFlag(argv);
325
362
  const result = spawnSync("sudo", ["-u", target, "--login", self, ...newArgs], {
326
363
  stdio: "inherit",
327
364
  env: process.env,
@@ -194,7 +194,6 @@ export async function runInstall(
194
194
  // this, `install systemd --user` produces units that die when the
195
195
  // user logs out — defeating the point of systemd mode. Best-effort.
196
196
  if (opts.scope === "user") {
197
- const userInfo = os.userInfo();
198
197
  const linger = Bun.spawnSync(["loginctl", "enable-linger", userInfo.username], {
199
198
  env: process.env,
200
199
  stdout: "pipe", stderr: "pipe",
@@ -506,18 +505,30 @@ interface BootstrapBotOpts {
506
505
  fetchImpl?: FetchLike;
507
506
  }
508
507
 
509
- // bootstrapBot: faithful port of install.sh's bot user + PAT flow.
510
- // Uses the operator's token-derived cookie to drive ework-web's admin API,
511
- // then logs in as the bot to mint a PAT. Step-by-step idempotence:
512
- // - create user: HTTP 303 = created, 400/409 = already exists (continue)
513
- // - login as bot: required to get a session cookie for /me/tokens/create
514
- // - mint PAT: scrape the token out of the HTML response (the only way
515
- // 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.
516
527
  async function bootstrapBot(opts: BootstrapBotOpts): Promise<string> {
517
528
  const fetchImpl = opts.fetchImpl ?? fetch;
518
529
  const botPassword = randomBytes(24).toString("hex");
519
530
 
520
- // 1. Create bot user via admin API.
531
+ // 1. Create bot user (best-effort).
521
532
  const createRes = await fetchImpl(`${opts.baseUrl}/admin/users/create`, {
522
533
  method: "POST",
523
534
  headers: { Cookie: opts.adminCookie, "Content-Type": "application/x-www-form-urlencoded" },
@@ -529,10 +540,35 @@ async function bootstrapBot(opts: BootstrapBotOpts): Promise<string> {
529
540
  }).toString(),
530
541
  redirect: "manual",
531
542
  });
532
- // 303 = created, 400/409 = already exists. Anything else = abort.
533
- if (![303, 400, 409].includes(createRes.status)) {
543
+ if (createRes.status !== 303) {
534
544
  const body = await createRes.text();
535
- 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}`);
536
572
  }
537
573
 
538
574
  // 2. Login as bot to get its session cookie.
@@ -576,8 +612,12 @@ async function bootstrapBot(opts: BootstrapBotOpts): Promise<string> {
576
612
  );
577
613
  }
578
614
  const patBody = await patRes.text();
579
- const match = patBody.match(/<input[^>]*id="t"[^>]*value="([a-f0-9]{40})"/)
580
- ?? patBody.match(/<input[^>]*value="([a-f0-9]{40})"[^>]*id="t"/);
615
+ // S-2: scrape regex is attribute-order-independent — accepts both
616
+ // `<input id="t" value="...">` and `<input value="..." id="t">`.
617
+ // Case-insensitive (i flag) so XHTML-style `<INPUT VALUE="...">` from
618
+ // ework-web template tweaks doesn't silently break scraping.
619
+ const match = patBody.match(/<input[^>]*id="t"[^>]*value="([a-f0-9]{40})"/i)
620
+ ?? patBody.match(/<input[^>]*value="([a-f0-9]{40})"[^>]*id="t"/i);
581
621
  if (!match || !match[1]) {
582
622
  throw new InstallError(`could not extract PAT from token-create response`);
583
623
  }
@@ -607,6 +647,33 @@ function parseFirstCookie(headers: Headers): string | null {
607
647
  return null;
608
648
  }
609
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
+
610
677
  function printSummary(logger: Logger, o: InstallOutcome): void {
611
678
  logger.hr();
612
679
  logger.ok(`install complete (${o.mode} mode)`);
package/src/env.ts CHANGED
@@ -8,6 +8,7 @@
8
8
 
9
9
  import fs from "node:fs";
10
10
  import path from "node:path";
11
+ import { randomBytes } from "node:crypto";
11
12
  import { keysForFile, type EnvFile, type InstallContext } from "./config.ts";
12
13
 
13
14
  export interface EnvMap {
@@ -46,12 +47,26 @@ export function parseEnvFile(content: string): ParsedEnvFile {
46
47
  if (eqIdx === -1) continue;
47
48
  const key = line.slice(0, eqIdx).trim();
48
49
  let value = line.slice(eqIdx + 1);
49
- // Strip matching surrounding quotes if present.
50
+ // Strip matching surrounding quotes if present. Also unescape common
51
+ // backslash sequences (\\, \", \') so embedded quotes survive the
52
+ // round-trip. Without unescaping, a value written as KEY="a\"b"
53
+ // parses as `a\"b` (literal backslash) — silent data corruption
54
+ // when an env value contains an embedded quote (URLs with apostrophes,
55
+ // file paths with spaces, etc).
50
56
  if (
51
57
  (value.startsWith('"') && value.endsWith('"')) ||
52
58
  (value.startsWith("'") && value.endsWith("'"))
53
59
  ) {
60
+ const quote = value[0]!;
54
61
  value = value.slice(1, -1);
62
+ if (quote === '"') {
63
+ value = value.replace(/\\(.)/g, (_m, ch: string) => {
64
+ if (ch === "n") return "\n";
65
+ if (ch === "t") return "\t";
66
+ if (ch === "r") return "\r";
67
+ return ch; // \\, \", \', \$, etc → drop the backslash
68
+ });
69
+ }
55
70
  }
56
71
  if (key) entries.set(key, value);
57
72
  }
@@ -119,7 +134,11 @@ export async function readEnvFile(filePath: string): Promise<ParsedEnvFile | nul
119
134
  export async function writeEnvFileAtomic(filePath: string, content: string): Promise<void> {
120
135
  const dir = path.dirname(filePath);
121
136
  await fs.promises.mkdir(dir, { recursive: true });
122
- const tmp = path.join(dir, `.env.tmp.${process.pid}.${Date.now()}`);
137
+ // Include randomBytes in the tmp name so two concurrent writes (e.g.
138
+ // install + config set racing on the same .env) don't clobber each
139
+ // other's tmp file. Date.now alone collides if both happen in the same ms.
140
+ const suffix = `${process.pid}.${Date.now()}.${randomBytes(4).toString("hex")}`;
141
+ const tmp = path.join(dir, `.env.tmp.${suffix}`);
123
142
  await fs.promises.writeFile(tmp, content, { mode: 0o600 });
124
143
  try {
125
144
  await fs.promises.rename(tmp, filePath);
package/src/log.ts CHANGED
@@ -12,7 +12,12 @@ interface WritableStreamLike {
12
12
  }
13
13
 
14
14
  function colorEnabled(stream: WritableStreamLike): boolean {
15
- if (process.env.NO_COLOR) return false;
15
+ // Per no-color.org spec: ANY presence of NO_COLOR in the environment
16
+ // (including empty string) disables color. The truthy check
17
+ // `if (process.env.NO_COLOR)` treats `NO_COLOR=` as "not set", which
18
+ // violates the spec and surprises users who export the empty value
19
+ // explicitly (e.g. shell rc files).
20
+ if ("NO_COLOR" in process.env) return false;
16
21
  if (process.env.FORCE_COLOR) return true;
17
22
  return stream.isTTY ?? false;
18
23
  }
package/src/paths.ts CHANGED
@@ -50,7 +50,14 @@ export interface ResolvePathsOptions {
50
50
  export function resolvePaths(opts: ResolvePathsOptions): PathConfig {
51
51
  const home = os.homedir();
52
52
  const xdgDataHome = process.env.XDG_DATA_HOME || path.join(home, ".local", "share");
53
- const xdgConfigHome = process.env.XDG_CONFIG_HOME || opts.configHome || path.join(home, ".config");
53
+ // opts.configHome takes precedence over XDG_CONFIG_HOME so tests can
54
+ // pin a config dir without polluting or reading the user's real
55
+ // ~/.config. The previous precedence (env || opts || default) meant
56
+ // a developer with XDG_CONFIG_HOME set couldn't override it from tests,
57
+ // and test runs would leak into the real config dir.
58
+ const xdgConfigHome = opts.configHome
59
+ || process.env.XDG_CONFIG_HOME
60
+ || path.join(home, ".config");
54
61
 
55
62
  const dataDir = opts.dataDir || path.join(xdgDataHome, "ework-aio");
56
63
  const webDataDir = path.join(dataDir, "ework-web");
package/src/pidfile.ts CHANGED
@@ -8,6 +8,7 @@
8
8
  import { spawn, type ChildProcess } from "node:child_process";
9
9
  import fs from "node:fs";
10
10
  import path from "node:path";
11
+ import { randomBytes } from "node:crypto";
11
12
 
12
13
  export interface StartProcessOptions {
13
14
  cmd: string;
@@ -83,7 +84,8 @@ export async function startProcess(opts: StartProcessOptions): Promise<StartProc
83
84
  export async function writePidFileAtomic(pidFile: string, pid: number): Promise<void> {
84
85
  const dir = path.dirname(pidFile);
85
86
  await fs.promises.mkdir(dir, { recursive: true });
86
- const tmp = path.join(dir, `.pid.tmp.${process.pid}.${Date.now()}`);
87
+ const suffix = `${process.pid}.${Date.now()}.${randomBytes(4).toString("hex")}`;
88
+ const tmp = path.join(dir, `.pid.tmp.${suffix}`);
87
89
  await fs.promises.writeFile(tmp, `${pid}\n`, { mode: 0o644 });
88
90
  try {
89
91
  await fs.promises.rename(tmp, pidFile);