ework-aio 0.2.0 → 0.2.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ework-aio",
3
- "version": "0.2.0",
3
+ "version": "0.2.1",
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
@@ -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",
@@ -509,7 +508,12 @@ interface BootstrapBotOpts {
509
508
  // bootstrapBot: faithful port of install.sh's bot user + PAT flow.
510
509
  // Uses the operator's token-derived cookie to drive ework-web's admin API,
511
510
  // 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)
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.
513
517
  // - login as bot: required to get a session cookie for /me/tokens/create
514
518
  // - mint PAT: scrape the token out of the HTML response (the only way
515
519
  // ework-web exposes clear-text tokens, matching install.sh)
@@ -529,8 +533,28 @@ async function bootstrapBot(opts: BootstrapBotOpts): Promise<string> {
529
533
  }).toString(),
530
534
  redirect: "manual",
531
535
  });
532
- // 303 = created, 400/409 = already exists. Anything else = abort.
533
- if (![303, 400, 409].includes(createRes.status)) {
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) {
534
558
  const body = await createRes.text();
535
559
  throw new InstallError(`create bot user failed (HTTP ${createRes.status}): ${body.slice(0, 200)}`);
536
560
  }
@@ -576,8 +600,12 @@ async function bootstrapBot(opts: BootstrapBotOpts): Promise<string> {
576
600
  );
577
601
  }
578
602
  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"/);
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);
581
609
  if (!match || !match[1]) {
582
610
  throw new InstallError(`could not extract PAT from token-create response`);
583
611
  }
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);