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 +1 -1
- package/src/cli.ts +39 -2
- package/src/commands/install.ts +34 -6
- 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.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
|
|
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",
|
|
@@ -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
|
|
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
|
-
|
|
533
|
-
|
|
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
|
-
|
|
580
|
-
|
|
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
|
-
|
|
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);
|