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 +1 -1
- package/src/cli.ts +40 -3
- package/src/commands/install.ts +81 -14
- package/src/env.ts +21 -2
- package/src/log.ts +6 -1
- package/src/paths.ts +8 -1
- package/src/pidfile.ts +3 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ework-aio",
|
|
3
|
-
"version": "0.2.
|
|
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.
|
|
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
|
|
359
|
+
// Re-exec as target user.
|
|
323
360
|
const self = process.argv[1]!;
|
|
324
|
-
const newArgs = argv
|
|
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,
|
package/src/commands/install.ts
CHANGED
|
@@ -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
|
-
//
|
|
511
|
-
//
|
|
512
|
-
//
|
|
513
|
-
//
|
|
514
|
-
//
|
|
515
|
-
//
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
580
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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);
|