@weiping/wingline-core 0.0.0-stage → 0.1.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/LICENSE +21 -0
- package/dist/audit.d.ts +37 -0
- package/dist/audit.js +80 -0
- package/dist/brief/render.d.ts +5 -0
- package/dist/brief/render.js +28 -0
- package/dist/commands/about.d.ts +25 -0
- package/dist/commands/about.js +84 -0
- package/dist/commands/autostart/apply.d.ts +20 -0
- package/dist/commands/autostart/apply.js +85 -0
- package/dist/commands/autostart/index.d.ts +37 -0
- package/dist/commands/autostart/index.js +38 -0
- package/dist/commands/autostart/plan.d.ts +47 -0
- package/dist/commands/autostart/plan.js +127 -0
- package/dist/commands/autostart/status.d.ts +18 -0
- package/dist/commands/autostart/status.js +33 -0
- package/dist/commands/deps.d.ts +32 -0
- package/dist/commands/deps.js +30 -0
- package/dist/commands/dispatcher/advance.d.ts +26 -0
- package/dist/commands/dispatcher/advance.js +183 -0
- package/dist/commands/dispatcher/attention.d.ts +50 -0
- package/dist/commands/dispatcher/attention.js +115 -0
- package/dist/commands/dispatcher/controls.d.ts +25 -0
- package/dist/commands/dispatcher/controls.js +65 -0
- package/dist/commands/dispatcher/inbox.d.ts +40 -0
- package/dist/commands/dispatcher/inbox.js +144 -0
- package/dist/commands/dispatcher/lifecycle.d.ts +43 -0
- package/dist/commands/dispatcher/lifecycle.js +103 -0
- package/dist/commands/dispatcher/loop.d.ts +60 -0
- package/dist/commands/dispatcher/loop.js +285 -0
- package/dist/commands/dispatcher/orca-sync.d.ts +56 -0
- package/dist/commands/dispatcher/orca-sync.js +262 -0
- package/dist/commands/dispatcher/outlets.d.ts +38 -0
- package/dist/commands/dispatcher/outlets.js +71 -0
- package/dist/commands/dispatcher/screen-signals.d.ts +24 -0
- package/dist/commands/dispatcher/screen-signals.js +152 -0
- package/dist/commands/dispatcher/signal-summary.d.ts +9 -0
- package/dist/commands/dispatcher/signal-summary.js +28 -0
- package/dist/commands/dispatcher/signals.d.ts +59 -0
- package/dist/commands/dispatcher/signals.js +220 -0
- package/dist/commands/dispatcher/unread.d.ts +45 -0
- package/dist/commands/dispatcher/unread.js +78 -0
- package/dist/commands/down.d.ts +14 -0
- package/dist/commands/down.js +55 -0
- package/dist/commands/launch.d.ts +31 -0
- package/dist/commands/launch.js +77 -0
- package/dist/commands/locate.d.ts +3 -0
- package/dist/commands/locate.js +11 -0
- package/dist/commands/log.d.ts +56 -0
- package/dist/commands/log.js +129 -0
- package/dist/commands/ps.d.ts +16 -0
- package/dist/commands/ps.js +31 -0
- package/dist/commands/restore/find-session.d.ts +38 -0
- package/dist/commands/restore/find-session.js +96 -0
- package/dist/commands/restore/index.d.ts +76 -0
- package/dist/commands/restore/index.js +233 -0
- package/dist/commands/restore/posts.d.ts +72 -0
- package/dist/commands/restore/posts.js +216 -0
- package/dist/commands/restore/resume-command.d.ts +4 -0
- package/dist/commands/restore/resume-command.js +17 -0
- package/dist/commands/self.d.ts +40 -0
- package/dist/commands/self.js +75 -0
- package/dist/commands/send.d.ts +11 -0
- package/dist/commands/send.js +65 -0
- package/dist/commands/spec.d.ts +51 -0
- package/dist/commands/spec.js +66 -0
- package/dist/commands/task/claim.d.ts +17 -0
- package/dist/commands/task/claim.js +74 -0
- package/dist/commands/task/create.d.ts +18 -0
- package/dist/commands/task/create.js +92 -0
- package/dist/commands/task/dispatch.d.ts +37 -0
- package/dist/commands/task/dispatch.js +119 -0
- package/dist/commands/task/done.d.ts +26 -0
- package/dist/commands/task/done.js +244 -0
- package/dist/commands/task/escalate.d.ts +36 -0
- package/dist/commands/task/escalate.js +136 -0
- package/dist/commands/task/frozen.d.ts +4 -0
- package/dist/commands/task/frozen.js +3 -0
- package/dist/commands/task/gate-exec.d.ts +27 -0
- package/dist/commands/task/gate-exec.js +142 -0
- package/dist/commands/task/identity.d.ts +19 -0
- package/dist/commands/task/identity.js +39 -0
- package/dist/commands/task/ledger.d.ts +42 -0
- package/dist/commands/task/ledger.js +177 -0
- package/dist/commands/task/log.d.ts +12 -0
- package/dist/commands/task/log.js +12 -0
- package/dist/commands/task/notify.d.ts +20 -0
- package/dist/commands/task/notify.js +75 -0
- package/dist/commands/task/plan.d.ts +5 -0
- package/dist/commands/task/plan.js +17 -0
- package/dist/commands/task/preflight.d.ts +17 -0
- package/dist/commands/task/preflight.js +30 -0
- package/dist/commands/task/query.d.ts +102 -0
- package/dist/commands/task/query.js +273 -0
- package/dist/commands/task/quote.d.ts +5 -0
- package/dist/commands/task/quote.js +9 -0
- package/dist/commands/task/start.d.ts +10 -0
- package/dist/commands/task/start.js +41 -0
- package/dist/commands/up.d.ts +45 -0
- package/dist/commands/up.js +305 -0
- package/dist/commands/version-range.d.ts +2 -0
- package/dist/commands/version-range.js +29 -0
- package/dist/errors.d.ts +20 -0
- package/dist/errors.js +18 -0
- package/dist/index.d.ts +56 -0
- package/dist/index.js +56 -0
- package/dist/log/call-log.d.ts +19 -0
- package/dist/log/call-log.js +46 -0
- package/dist/orca/client.d.ts +19 -0
- package/dist/orca/client.js +273 -0
- package/dist/orca/cmd-escape.d.ts +4 -0
- package/dist/orca/cmd-escape.js +25 -0
- package/dist/orca/resolve.d.ts +14 -0
- package/dist/orca/resolve.js +33 -0
- package/dist/orca/run.d.ts +23 -0
- package/dist/orca/run.js +79 -0
- package/dist/orca/types.d.ts +251 -0
- package/dist/orca/types.js +1 -0
- package/dist/paths.d.ts +1 -0
- package/dist/paths.js +5 -0
- package/dist/registry/migrations.d.ts +2 -0
- package/dist/registry/migrations.js +81 -0
- package/dist/registry/registry.d.ts +349 -0
- package/dist/registry/registry.js +666 -0
- package/dist/screen/patterns.d.ts +23 -0
- package/dist/screen/patterns.js +58 -0
- package/dist/screen/reset-time.d.ts +6 -0
- package/dist/screen/reset-time.js +80 -0
- package/dist/server/api-read.d.ts +20 -0
- package/dist/server/api-read.js +169 -0
- package/dist/server/api-spec.d.ts +5 -0
- package/dist/server/api-spec.js +37 -0
- package/dist/server/api-write.d.ts +36 -0
- package/dist/server/api-write.js +256 -0
- package/dist/server/auth.d.ts +36 -0
- package/dist/server/auth.js +98 -0
- package/dist/server/control.d.ts +10 -0
- package/dist/server/control.js +47 -0
- package/dist/server/http.d.ts +26 -0
- package/dist/server/http.js +71 -0
- package/dist/server/index.d.ts +39 -0
- package/dist/server/index.js +341 -0
- package/dist/server/sse.d.ts +33 -0
- package/dist/server/sse.js +169 -0
- package/dist/server/static.d.ts +4 -0
- package/dist/server/static.js +139 -0
- package/dist/spec/draft.d.ts +24 -0
- package/dist/spec/draft.js +123 -0
- package/dist/spec/edit.d.ts +4 -0
- package/dist/spec/edit.js +200 -0
- package/dist/spec/hash.d.ts +2 -0
- package/dist/spec/hash.js +53 -0
- package/dist/spec/load.d.ts +77 -0
- package/dist/spec/load.js +256 -0
- package/dist/spec/plan-change.d.ts +21 -0
- package/dist/spec/plan-change.js +71 -0
- package/dist/spec/schema.d.ts +264 -0
- package/dist/spec/schema.js +75 -0
- package/dist/view/plain-text.d.ts +4 -0
- package/dist/view/plain-text.js +16 -0
- package/dist/view/snapshot.d.ts +8 -0
- package/dist/view/snapshot.js +50 -0
- package/package.json +24 -3
- package/README.md +0 -3
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 weiping liu
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/dist/audit.d.ts
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import type { CommandDeps } from './commands/deps.js';
|
|
2
|
+
/** 命令执行后的可见上下文:团队名有的要查注册表(任务 id、gate id),有的只能从结果里取(up)。 */
|
|
3
|
+
export interface AuditCtx {
|
|
4
|
+
deps: CommandDeps;
|
|
5
|
+
result?: unknown;
|
|
6
|
+
failed: boolean;
|
|
7
|
+
}
|
|
8
|
+
export interface AuditEntry<A> {
|
|
9
|
+
/** 审计 command 列里记的名字,用 CLI 风格(如 `task done`、`dispatcher freeze`)。 */
|
|
10
|
+
command: string;
|
|
11
|
+
team: (a: A, ctx: AuditCtx) => string;
|
|
12
|
+
/** 任务创建这类只有结果里才有 id 的命令,从 ctx.result 取。 */
|
|
13
|
+
target?: (a: A, ctx: AuditCtx) => string | null;
|
|
14
|
+
/** 只放位置参数与开关;正文类字段会在写入前统一 clip。ctx.result 可取结果侧的字段。 */
|
|
15
|
+
summary?: (a: A, ctx: AuditCtx) => Record<string, unknown>;
|
|
16
|
+
/** 返回 true 就不写这一行(如 restore --dry-run)。 */
|
|
17
|
+
skip?: (a: A) => boolean;
|
|
18
|
+
}
|
|
19
|
+
export type AuditTable<C> = {
|
|
20
|
+
[K in keyof C]?: AuditEntry<Parameters<Extract<C[K], (...x: any) => any>>[1]>;
|
|
21
|
+
};
|
|
22
|
+
/** CLI Commands 里属于写命令的字段:每一条都要在审计表里登记。 */
|
|
23
|
+
export declare const WRITE_COMMANDS: readonly string[];
|
|
24
|
+
export declare function clip(s: string, n?: number): string;
|
|
25
|
+
/** 命令一律吃 deps 打头;后面的参数不限于一个(follow 类的命令还要 emit 与信号)。 */
|
|
26
|
+
type Command = (deps: CommandDeps, ...a: any[]) => Promise<unknown>;
|
|
27
|
+
export interface AuditOptions {
|
|
28
|
+
/** command 列的前缀,给包在更大界面里的调用方(3B)区分来源用。 */
|
|
29
|
+
prefix?: string;
|
|
30
|
+
/** 命令参数里没有 `--as` 时的执行者。 */
|
|
31
|
+
actor?: string;
|
|
32
|
+
}
|
|
33
|
+
/** 表里有的命令包一层审计;表里没有的原样返回。 */
|
|
34
|
+
export declare function auditedCommands<C extends {
|
|
35
|
+
[K in keyof C]: Command;
|
|
36
|
+
}>(c: C, table: AuditTable<C>, o?: AuditOptions): C;
|
|
37
|
+
export {};
|
package/dist/audit.js
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { WinglineError } from './errors.js';
|
|
2
|
+
import { actorOf, resolveActor } from './commands/task/identity.js';
|
|
3
|
+
/** CLI Commands 里属于写命令的字段:每一条都要在审计表里登记。 */
|
|
4
|
+
export const WRITE_COMMANDS = [
|
|
5
|
+
'up', 'down', 'restore', 'send', 'createTask', 'claimTask', 'releaseTask', 'reassignTask', 'startTask', 'planTask',
|
|
6
|
+
'doneTask', 'escalateTask', 'resolveGate', 'recoverTask', 'dispatcherUp', 'dispatcherDown', 'freezePostCmd',
|
|
7
|
+
'unfreezePostCmd', 'hushCmd', 'unhushCmd', 'dispatcherReply',
|
|
8
|
+
];
|
|
9
|
+
/** 换行与控制字符换成空格(一一对应,长度不变),超长截断并标明原长。 */
|
|
10
|
+
const CONTROL = /[\x00-\x1f\x7f]/g;
|
|
11
|
+
export function clip(s, n = 60) {
|
|
12
|
+
const flat = s.replace(CONTROL, ' ');
|
|
13
|
+
return flat.length <= n ? flat : `${flat.slice(0, n)}…(共 ${flat.length} 字)`;
|
|
14
|
+
}
|
|
15
|
+
/** 摘要里的字符串统一截断;未填的开关不记。 */
|
|
16
|
+
function summaryOf(s) {
|
|
17
|
+
const out = {};
|
|
18
|
+
for (const [k, v] of Object.entries(s)) {
|
|
19
|
+
if (v === undefined || v === null)
|
|
20
|
+
continue;
|
|
21
|
+
out[k] = typeof v === 'string' ? clip(v) : v;
|
|
22
|
+
}
|
|
23
|
+
return out;
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* 写一行审计。任何一步出错都只 warn 一次:命令的结果与退出码不因此改变。
|
|
27
|
+
* 执行者在命令执行之后才解析,解析不出来记 `unknown`、不重试(Orca 不可达时不该再多一次失败调用)。
|
|
28
|
+
*/
|
|
29
|
+
async function write(deps, entry, a, outcome, o, result) {
|
|
30
|
+
try {
|
|
31
|
+
const as = a?.as ?? o.actor;
|
|
32
|
+
let actor = 'unknown';
|
|
33
|
+
try {
|
|
34
|
+
// 复审 I-1a:actor 是用户可控的 --as 值,压平控制字符,别让 wl log 里的行被伪造
|
|
35
|
+
actor = clip(actorOf(await resolveActor(deps, as)).actor);
|
|
36
|
+
}
|
|
37
|
+
catch {
|
|
38
|
+
// Orca 不可达:记 unknown
|
|
39
|
+
}
|
|
40
|
+
const ctx = { deps, result, failed: outcome !== 'ok' };
|
|
41
|
+
const detail = entry.summary ? summaryOf(entry.summary(a, ctx)) : undefined;
|
|
42
|
+
deps.registry.appendAudit({
|
|
43
|
+
team: entry.team(a, ctx),
|
|
44
|
+
actor,
|
|
45
|
+
command: o.prefix ? `${o.prefix} ${entry.command}` : entry.command,
|
|
46
|
+
target: entry.target?.(a, ctx) ?? null,
|
|
47
|
+
outcome,
|
|
48
|
+
...(detail !== undefined ? { detail } : {}),
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
catch (err) {
|
|
52
|
+
deps.warn(`审计写入失败:${err instanceof Error ? err.message : String(err)}`);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
/** 表里有的命令包一层审计;表里没有的原样返回。 */
|
|
56
|
+
export function auditedCommands(c, table, o = {}) {
|
|
57
|
+
const out = {};
|
|
58
|
+
for (const name of Object.keys(c)) {
|
|
59
|
+
const fn = c[name];
|
|
60
|
+
const entry = table[name];
|
|
61
|
+
if (!entry) {
|
|
62
|
+
out[name] = fn;
|
|
63
|
+
continue;
|
|
64
|
+
}
|
|
65
|
+
out[name] = async (deps, a) => {
|
|
66
|
+
if (entry.skip?.(a))
|
|
67
|
+
return fn(deps, a);
|
|
68
|
+
try {
|
|
69
|
+
const result = await fn(deps, a);
|
|
70
|
+
await write(deps, entry, a, 'ok', o, result);
|
|
71
|
+
return result;
|
|
72
|
+
}
|
|
73
|
+
catch (err) {
|
|
74
|
+
await write(deps, entry, a, err instanceof WinglineError ? `${err.kind}:${err.code}` : 'internal:unknown', o);
|
|
75
|
+
throw err;
|
|
76
|
+
}
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
return out;
|
|
80
|
+
}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import type { TeamSpec } from '../spec/load.js';
|
|
2
|
+
export declare const BRIEFING_LIMIT = 12000;
|
|
3
|
+
export declare function renderBriefing(spec: TeamSpec, postId: string, readFile?: (p: string) => string): string;
|
|
4
|
+
/** 上限检查在加了会话标记行之后做(spec:标记行计入启动包),任何 Orca 调用之前完成。 */
|
|
5
|
+
export declare function checkBriefingLimit(text: string, address: string): void;
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import { WinglineError } from '../errors.js';
|
|
3
|
+
export const BRIEFING_LIMIT = 12_000;
|
|
4
|
+
export function renderBriefing(spec, postId, readFile = (p) => fs.readFileSync(p, 'utf8')) {
|
|
5
|
+
const post = spec.posts.find((p) => p.id === postId);
|
|
6
|
+
if (!post)
|
|
7
|
+
throw new WinglineError('input', 'post_unknown', `团队 ${spec.name} 没有岗位 ${postId}`);
|
|
8
|
+
const roster = spec.posts
|
|
9
|
+
.map((p) => `- ${p.id}@${spec.name}${p.id === spec.lead ? '(牵头)' : ''} · ${p.agent}`)
|
|
10
|
+
.join('\n');
|
|
11
|
+
const sections = [
|
|
12
|
+
`你是 ${postId}@${spec.name},你的职责见下方岗位说明。`,
|
|
13
|
+
`## 团队名册\n${roster}`,
|
|
14
|
+
`## 团队规范\n${readFile(spec.normsPath).trim()}`,
|
|
15
|
+
...post.briefPaths.map((p) => `## 岗位说明\n${readFile(p).trim()}`),
|
|
16
|
+
`## 沟通方式\n用 \`wl send <地址> "<消息>"\` 联系其他岗位,例如 \`wl send ${spec.lead}@${spec.name} "..."\`。`,
|
|
17
|
+
];
|
|
18
|
+
const text = sections.join('\n\n');
|
|
19
|
+
return text;
|
|
20
|
+
}
|
|
21
|
+
/** 上限检查在加了会话标记行之后做(spec:标记行计入启动包),任何 Orca 调用之前完成。 */
|
|
22
|
+
export function checkBriefingLimit(text, address) {
|
|
23
|
+
if (text.length > BRIEFING_LIMIT) {
|
|
24
|
+
throw new WinglineError('input', 'briefing_too_long', `岗位 ${address} 的启动包 ${text.length} 字符,超过 ${BRIEFING_LIMIT}`, {
|
|
25
|
+
hint: '请精简 NORMS 或 brief 文件,雁阵不会截断启动包',
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import type { ConsoleFile } from '../server/control.js';
|
|
2
|
+
import type { CommandDeps } from './deps.js';
|
|
3
|
+
import { type DispatcherHealth } from './dispatcher/lifecycle.js';
|
|
4
|
+
export interface AboutItem {
|
|
5
|
+
key: string;
|
|
6
|
+
value: string | number | boolean | null;
|
|
7
|
+
source: 'env' | 'default' | 'registry' | 'file' | 'orca';
|
|
8
|
+
note?: string;
|
|
9
|
+
}
|
|
10
|
+
export interface AboutTeam {
|
|
11
|
+
name: string;
|
|
12
|
+
specPath: string;
|
|
13
|
+
status: string;
|
|
14
|
+
draft: boolean;
|
|
15
|
+
dispatcher: DispatcherHealth['state'];
|
|
16
|
+
}
|
|
17
|
+
/** 控制台“关于与诊断”页的数据:只读,取不到的项目显式标 null,不影响其余各项。 */
|
|
18
|
+
export declare function about(deps: CommandDeps, o: {
|
|
19
|
+
version: string;
|
|
20
|
+
consoleFile: ConsoleFile | null;
|
|
21
|
+
env?: NodeJS.ProcessEnv;
|
|
22
|
+
}): Promise<{
|
|
23
|
+
items: AboutItem[];
|
|
24
|
+
teams: AboutTeam[];
|
|
25
|
+
}>;
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { WinglineError } from '../errors.js';
|
|
4
|
+
import { draftPathOf } from '../spec/draft.js';
|
|
5
|
+
import { dispatcherHealth } from './dispatcher/lifecycle.js';
|
|
6
|
+
import { inRange } from './version-range.js';
|
|
7
|
+
/** 目录下所有文件的字节数之和;目录不存在算 0。 */
|
|
8
|
+
function dirBytes(dir) {
|
|
9
|
+
let total = 0;
|
|
10
|
+
let entries;
|
|
11
|
+
try {
|
|
12
|
+
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
13
|
+
}
|
|
14
|
+
catch {
|
|
15
|
+
return 0;
|
|
16
|
+
}
|
|
17
|
+
for (const e of entries) {
|
|
18
|
+
const p = path.join(dir, e.name);
|
|
19
|
+
try {
|
|
20
|
+
total += e.isDirectory() ? dirBytes(p) : fs.statSync(p).size;
|
|
21
|
+
}
|
|
22
|
+
catch {
|
|
23
|
+
// 读到一半被删掉的文件不算
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
return total;
|
|
27
|
+
}
|
|
28
|
+
async function orcaVersion(deps) {
|
|
29
|
+
try {
|
|
30
|
+
const v = await deps.orca.version();
|
|
31
|
+
return {
|
|
32
|
+
version: { key: 'orca.version', value: v, source: 'orca' },
|
|
33
|
+
verified: { key: 'orca.verified', value: inRange(v, deps.verifiedOrca), source: 'default', note: deps.verifiedOrca },
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
catch (err) {
|
|
37
|
+
const note = err instanceof WinglineError ? err.code : 'unknown';
|
|
38
|
+
return {
|
|
39
|
+
version: { key: 'orca.version', value: null, source: 'orca', note },
|
|
40
|
+
verified: { key: 'orca.verified', value: null, source: 'default', note: deps.verifiedOrca },
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
function orcaPathItem(deps, env) {
|
|
45
|
+
if (env.WINGLINE_ORCA)
|
|
46
|
+
return { key: 'orca.path', value: deps.orcaPath, source: 'env', note: 'WINGLINE_ORCA' };
|
|
47
|
+
if (env.ORCA_CLI_COMMAND)
|
|
48
|
+
return { key: 'orca.path', value: deps.orcaPath, source: 'env', note: 'ORCA_CLI_COMMAND' };
|
|
49
|
+
return { key: 'orca.path', value: deps.orcaPath, source: 'default', note: 'PATH' };
|
|
50
|
+
}
|
|
51
|
+
function consoleItems(f) {
|
|
52
|
+
if (!f)
|
|
53
|
+
return [];
|
|
54
|
+
return [
|
|
55
|
+
{ key: 'console.pid', value: f.pid, source: 'file' },
|
|
56
|
+
{ key: 'console.port', value: f.port, source: 'file' },
|
|
57
|
+
{ key: 'console.startedAt', value: f.startedAt, source: 'file' },
|
|
58
|
+
];
|
|
59
|
+
}
|
|
60
|
+
/** 控制台“关于与诊断”页的数据:只读,取不到的项目显式标 null,不影响其余各项。 */
|
|
61
|
+
export async function about(deps, o) {
|
|
62
|
+
const env = o.env ?? deps.env;
|
|
63
|
+
const orca = await orcaVersion(deps);
|
|
64
|
+
const items = [
|
|
65
|
+
{ key: 'wingline.version', value: o.version, source: 'default' },
|
|
66
|
+
orcaPathItem(deps, env),
|
|
67
|
+
orca.version,
|
|
68
|
+
orca.verified,
|
|
69
|
+
env.WINGLINE_HOME
|
|
70
|
+
? { key: 'home.path', value: deps.home, source: 'env', note: 'WINGLINE_HOME' }
|
|
71
|
+
: { key: 'home.path', value: deps.home, source: 'default' },
|
|
72
|
+
{ key: 'registry.migration', value: deps.registry.schemaVersion(), source: 'registry' },
|
|
73
|
+
{ key: 'logs.bytes', value: dirBytes(path.join(deps.home, 'logs')), source: 'file' },
|
|
74
|
+
...consoleItems(o.consoleFile),
|
|
75
|
+
];
|
|
76
|
+
const teams = deps.registry.listTeams().map((t) => ({
|
|
77
|
+
name: t.name,
|
|
78
|
+
specPath: t.specPath,
|
|
79
|
+
status: t.status,
|
|
80
|
+
draft: fs.existsSync(draftPathOf(t.specPath)),
|
|
81
|
+
dispatcher: dispatcherHealth(deps, t.name).state,
|
|
82
|
+
}));
|
|
83
|
+
return { items, teams };
|
|
84
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import type { AutostartPlan } from './plan.js';
|
|
3
|
+
export type Exec = (file: string, args: string[]) => Promise<{
|
|
4
|
+
code: number;
|
|
5
|
+
stdout: string;
|
|
6
|
+
stderr: string;
|
|
7
|
+
}>;
|
|
8
|
+
export type FsLike = Pick<typeof fs, 'mkdirSync' | 'writeFileSync' | 'readFileSync' | 'renameSync' | 'rmSync' | 'existsSync' | 'statSync'>;
|
|
9
|
+
/** autostart.log 超过这个大小,安装时先改名为 autostart.log.1(覆盖已有的 .1)。 */
|
|
10
|
+
export declare const LOG_ROTATE_BYTES: number;
|
|
11
|
+
export type Sleep = (ms: number) => Promise<void>;
|
|
12
|
+
/**
|
|
13
|
+
* 写文件(先写临时文件再改名)并注册;注册失败则恢复原来的文件并重新加载它(原来没有就删掉刚写的),不留半成品。
|
|
14
|
+
* Linux 上没有 systemd 用户会话 → autostart_unsupported,什么都不写。
|
|
15
|
+
*/
|
|
16
|
+
export declare function applyAutostart(plan: AutostartPlan, exec: Exec, fsx?: FsLike, sleep?: Sleep): Promise<void>;
|
|
17
|
+
/** 从服务管理器注销并删除自己的文件(日志留着);没安装过也算成功。 */
|
|
18
|
+
export declare function removeAutostart(plan: AutostartPlan, exec: Exec, fsx?: FsLike): Promise<{
|
|
19
|
+
removed: boolean;
|
|
20
|
+
}>;
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { WinglineError } from '../../errors.js';
|
|
4
|
+
/** autostart.log 超过这个大小,安装时先改名为 autostart.log.1(覆盖已有的 .1)。 */
|
|
5
|
+
export const LOG_ROTATE_BYTES = 1024 * 1024;
|
|
6
|
+
const RETRY_PAUSE_MS = 1000;
|
|
7
|
+
const defaultSleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
8
|
+
/** 命令起不来(ENOENT 等)按退出码 127 处理,不让它直接抛出去。 */
|
|
9
|
+
async function run(exec, c) {
|
|
10
|
+
try {
|
|
11
|
+
return await exec(c.file, c.args);
|
|
12
|
+
}
|
|
13
|
+
catch (err) {
|
|
14
|
+
return { code: 127, stdout: '', stderr: err instanceof Error ? err.message : String(err) };
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
/** 带 retries 的步骤失败后停一秒再试,最多多试 retries 次。 */
|
|
18
|
+
async function runStep(exec, c, sleep) {
|
|
19
|
+
let r = await run(exec, c);
|
|
20
|
+
for (let i = 0; r.code !== 0 && i < (c.retries ?? 0); i++) {
|
|
21
|
+
await sleep(RETRY_PAUSE_MS);
|
|
22
|
+
r = await run(exec, c);
|
|
23
|
+
}
|
|
24
|
+
return r;
|
|
25
|
+
}
|
|
26
|
+
function failure(c, r) {
|
|
27
|
+
const out = r.stderr.trim() || r.stdout.trim();
|
|
28
|
+
return new WinglineError('internal', 'autostart_failed', `${c.file} ${c.args.join(' ')} 失败(退出码 ${r.code})${out ? `:${out}` : ''}`, {
|
|
29
|
+
detail: { stdout: r.stdout, stderr: r.stderr },
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
function rotateLog(logPath, fsx) {
|
|
33
|
+
if (!fsx.existsSync(logPath) || fsx.statSync(logPath).size <= LOG_ROTATE_BYTES)
|
|
34
|
+
return;
|
|
35
|
+
fsx.rmSync(`${logPath}.1`, { force: true });
|
|
36
|
+
fsx.renameSync(logPath, `${logPath}.1`);
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* 写文件(先写临时文件再改名)并注册;注册失败则恢复原来的文件并重新加载它(原来没有就删掉刚写的),不留半成品。
|
|
40
|
+
* Linux 上没有 systemd 用户会话 → autostart_unsupported,什么都不写。
|
|
41
|
+
*/
|
|
42
|
+
export async function applyAutostart(plan, exec, fsx = fs, sleep = defaultSleep) {
|
|
43
|
+
if (plan.probe) {
|
|
44
|
+
const r = await run(exec, plan.probe);
|
|
45
|
+
if (r.code !== 0) {
|
|
46
|
+
throw new WinglineError('input', 'autostart_unsupported', '没有可用的 systemd 用户会话,无法安装自启动', {
|
|
47
|
+
hint: `登录后请手动执行 wl restore --all${r.stderr.trim() ? `(${r.stderr.trim()})` : ''}`,
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
fsx.mkdirSync(path.dirname(plan.file.path), { recursive: true });
|
|
52
|
+
fsx.mkdirSync(path.dirname(plan.logPath), { recursive: true });
|
|
53
|
+
rotateLog(plan.logPath, fsx);
|
|
54
|
+
const previous = fsx.existsSync(plan.file.path) ? fsx.readFileSync(plan.file.path, 'utf8') : null;
|
|
55
|
+
const tmp = `${plan.file.path}.tmp`;
|
|
56
|
+
try {
|
|
57
|
+
fsx.writeFileSync(tmp, plan.file.content);
|
|
58
|
+
fsx.renameSync(tmp, plan.file.path);
|
|
59
|
+
for (const c of plan.register) {
|
|
60
|
+
const r = await runStep(exec, c, sleep);
|
|
61
|
+
if (r.code !== 0 && !c.allowFail)
|
|
62
|
+
throw failure(c, r);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
catch (err) {
|
|
66
|
+
fsx.rmSync(tmp, { force: true });
|
|
67
|
+
if (previous === null)
|
|
68
|
+
fsx.rmSync(plan.file.path, { force: true });
|
|
69
|
+
else {
|
|
70
|
+
// 旧文件放回去,并把旧代理重新加载回来(register 里的 bootout 可能已经把它卸掉了);尽力而为,失败不再覆盖原来的错误
|
|
71
|
+
fsx.writeFileSync(plan.file.path, previous);
|
|
72
|
+
for (const c of plan.register)
|
|
73
|
+
await runStep(exec, c, sleep);
|
|
74
|
+
}
|
|
75
|
+
throw err instanceof WinglineError ? err : new WinglineError('internal', 'autostart_failed', `安装自启动失败:${err instanceof Error ? err.message : String(err)}`, { cause: err });
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
/** 从服务管理器注销并删除自己的文件(日志留着);没安装过也算成功。 */
|
|
79
|
+
export async function removeAutostart(plan, exec, fsx = fs) {
|
|
80
|
+
for (const c of plan.unregister)
|
|
81
|
+
await run(exec, c);
|
|
82
|
+
const removed = fsx.existsSync(plan.file.path);
|
|
83
|
+
fsx.rmSync(plan.file.path, { force: true });
|
|
84
|
+
return { removed };
|
|
85
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import type { CommandDeps } from '../deps.js';
|
|
2
|
+
import { type Exec, type FsLike } from './apply.js';
|
|
3
|
+
import { type AutostartStatus } from './status.js';
|
|
4
|
+
export { applyAutostart, removeAutostart, type Exec, type FsLike, type Sleep } from './apply.js';
|
|
5
|
+
export { installedCommand, planAutostart, planTargets, type AutostartPlan, type AutostartPlatform, type PlanOptions, type TargetOptions } from './plan.js';
|
|
6
|
+
export { autostartStatus, type AutostartStatus } from './status.js';
|
|
7
|
+
/** 登录时 Orca 常常还没起来:默认最多等 15 分钟。 */
|
|
8
|
+
export declare const DEFAULT_WAIT_ORCA = "15m";
|
|
9
|
+
/** 宿主环境(平台、用户、node、入口脚本、系统命令与文件系统),由 CLI 注入,测试里不碰真实系统。 */
|
|
10
|
+
export interface AutostartSys {
|
|
11
|
+
platform: NodeJS.Platform;
|
|
12
|
+
homeDir: string;
|
|
13
|
+
uid?: number;
|
|
14
|
+
user?: string;
|
|
15
|
+
node: string;
|
|
16
|
+
script: string;
|
|
17
|
+
exec: Exec;
|
|
18
|
+
fs: FsLike;
|
|
19
|
+
}
|
|
20
|
+
export declare function autostartInstall(deps: CommandDeps, sys: AutostartSys, a?: {
|
|
21
|
+
waitOrca?: string;
|
|
22
|
+
}): Promise<{
|
|
23
|
+
platform: import("./plan.js").AutostartPlatform;
|
|
24
|
+
file: string;
|
|
25
|
+
command: string[];
|
|
26
|
+
logPath: string;
|
|
27
|
+
waitOrca: string;
|
|
28
|
+
}>;
|
|
29
|
+
export declare function autostartUninstall(deps: CommandDeps, sys: AutostartSys): Promise<{
|
|
30
|
+
removed: boolean;
|
|
31
|
+
file: string;
|
|
32
|
+
}>;
|
|
33
|
+
export declare function autostartStatusOf(deps: CommandDeps, sys: AutostartSys): Promise<AutostartStatus & {
|
|
34
|
+
platform: string;
|
|
35
|
+
user?: string;
|
|
36
|
+
logPath: string;
|
|
37
|
+
}>;
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { parseDuration } from '../dispatcher/controls.js';
|
|
2
|
+
import { applyAutostart, removeAutostart } from './apply.js';
|
|
3
|
+
import { installedCommand, planAutostart, planTargets } from './plan.js';
|
|
4
|
+
import { autostartStatus } from './status.js';
|
|
5
|
+
export { applyAutostart, removeAutostart } from './apply.js';
|
|
6
|
+
export { installedCommand, planAutostart, planTargets } from './plan.js';
|
|
7
|
+
export { autostartStatus } from './status.js';
|
|
8
|
+
/** 登录时 Orca 常常还没起来:默认最多等 15 分钟。 */
|
|
9
|
+
export const DEFAULT_WAIT_ORCA = '15m';
|
|
10
|
+
function planOf(deps, sys, waitOrca) {
|
|
11
|
+
return planAutostart({
|
|
12
|
+
platform: sys.platform, homeDir: sys.homeDir, uid: sys.uid, user: sys.user, node: sys.node, script: sys.script,
|
|
13
|
+
home: deps.home, orca: deps.orcaPath, waitOrca,
|
|
14
|
+
});
|
|
15
|
+
}
|
|
16
|
+
/** 卸载与查看状态用:只要位置和系统命令,不要求安装命令里的路径可用。 */
|
|
17
|
+
function targetsOf(deps, sys) {
|
|
18
|
+
return planTargets({ platform: sys.platform, homeDir: sys.homeDir, uid: sys.uid, user: sys.user, home: deps.home });
|
|
19
|
+
}
|
|
20
|
+
export async function autostartInstall(deps, sys, a = {}) {
|
|
21
|
+
const waitOrca = a.waitOrca ?? DEFAULT_WAIT_ORCA;
|
|
22
|
+
parseDuration(waitOrca);
|
|
23
|
+
const plan = planOf(deps, sys, waitOrca);
|
|
24
|
+
await applyAutostart(plan, sys.exec, sys.fs);
|
|
25
|
+
return { platform: plan.platform, file: plan.file.path, command: installedCommand(plan.platform, plan.file.content), logPath: plan.logPath, waitOrca };
|
|
26
|
+
}
|
|
27
|
+
export async function autostartUninstall(deps, sys) {
|
|
28
|
+
const plan = targetsOf(deps, sys);
|
|
29
|
+
return { file: plan.file.path, ...(await removeAutostart(plan, sys.exec, sys.fs)) };
|
|
30
|
+
}
|
|
31
|
+
export async function autostartStatusOf(deps, sys) {
|
|
32
|
+
const plan = targetsOf(deps, sys);
|
|
33
|
+
const status = await autostartStatus(plan, sys.exec, {
|
|
34
|
+
readFile: (p) => sys.fs.readFileSync(p, 'utf8'),
|
|
35
|
+
exists: (p) => sys.fs.existsSync(p),
|
|
36
|
+
});
|
|
37
|
+
return { ...status, platform: plan.platform, ...(plan.user !== undefined ? { user: plan.user } : {}), logPath: plan.logPath };
|
|
38
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
export type AutostartPlatform = 'darwin' | 'linux';
|
|
2
|
+
interface Step {
|
|
3
|
+
file: string;
|
|
4
|
+
args: string[];
|
|
5
|
+
/** 失败不算错(例如 bootout 一个还没加载的代理)。 */
|
|
6
|
+
allowFail?: boolean;
|
|
7
|
+
/** 失败后再试几次(每次间隔 1 秒):bootout 返回时旧代理可能还没卸载完,紧接着的 bootstrap 会报 I/O 错误。 */
|
|
8
|
+
retries?: number;
|
|
9
|
+
}
|
|
10
|
+
export interface AutostartPlan {
|
|
11
|
+
platform: AutostartPlatform;
|
|
12
|
+
/** Linux:用来判断有没有 systemd 用户会话;失败 → autostart_unsupported。 */
|
|
13
|
+
probe?: Step;
|
|
14
|
+
file: {
|
|
15
|
+
path: string;
|
|
16
|
+
content: string;
|
|
17
|
+
};
|
|
18
|
+
logPath: string;
|
|
19
|
+
register: Step[];
|
|
20
|
+
unregister: Step[];
|
|
21
|
+
/** Linux:`loginctl show-user` 用。 */
|
|
22
|
+
user?: string;
|
|
23
|
+
}
|
|
24
|
+
export interface PlanOptions {
|
|
25
|
+
platform: NodeJS.Platform;
|
|
26
|
+
homeDir: string;
|
|
27
|
+
uid?: number;
|
|
28
|
+
user?: string;
|
|
29
|
+
node: string;
|
|
30
|
+
script: string;
|
|
31
|
+
/** WINGLINE_HOME */
|
|
32
|
+
home: string;
|
|
33
|
+
orca: string;
|
|
34
|
+
/** 如 `15m`,原样写进命令行。 */
|
|
35
|
+
waitOrca: string;
|
|
36
|
+
}
|
|
37
|
+
export type TargetOptions = Pick<PlanOptions, 'platform' | 'homeDir' | 'uid' | 'user' | 'home'>;
|
|
38
|
+
/**
|
|
39
|
+
* 文件位置、日志路径与要执行的系统命令(`file.content` 为空)。卸载和查看状态只需要这些:
|
|
40
|
+
* 不要求命令里的 node / orca 路径能用于安装,否则环境一变(orca 只剩裸名、node 搬家)反而卸载不了已经装好的文件。
|
|
41
|
+
*/
|
|
42
|
+
export declare function planTargets(o: TargetOptions): AutostartPlan;
|
|
43
|
+
/** 纯函数:生成文件内容与要执行的系统命令,不碰文件系统。 */
|
|
44
|
+
export declare function planAutostart(o: PlanOptions): AutostartPlan;
|
|
45
|
+
/** 从已安装的文件里读出它实际会执行的命令(`wl autostart status` 用它检查 node 与入口脚本还在不在)。 */
|
|
46
|
+
export declare function installedCommand(platform: AutostartPlatform, content: string): string[];
|
|
47
|
+
export {};
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import { WinglineError } from '../../errors.js';
|
|
3
|
+
import { parseDuration } from '../dispatcher/controls.js';
|
|
4
|
+
import { absoluteSelfCommand } from '../self.js';
|
|
5
|
+
const P = path.posix;
|
|
6
|
+
const LABEL = 'wingline.restore';
|
|
7
|
+
const UNIT = 'wingline-restore.service';
|
|
8
|
+
const SYSTEM_DIRS = ['/usr/local/bin', '/usr/bin', '/bin', '/usr/sbin', '/sbin'];
|
|
9
|
+
const xmlEscape = (s) => s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, ''');
|
|
10
|
+
const xmlUnescape = (s) => s.replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, "'").replace(/&/g, '&');
|
|
11
|
+
/** 登录时的 PATH 极简,orca 可能是 `#!/usr/bin/env node` 之类的脚本:显式带上 node 与 orca 所在目录(去重,node 在前)。 */
|
|
12
|
+
function pathEnv(node, orca) {
|
|
13
|
+
return [...new Set([P.dirname(node), P.dirname(orca), ...SYSTEM_DIRS])].join(':');
|
|
14
|
+
}
|
|
15
|
+
/** systemd 单元里的值:`\`、`"` 转义,`%` 写 `%%`。 */
|
|
16
|
+
const unitValue = (s) => s.replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/%/g, '%%');
|
|
17
|
+
/** ExecStart 里的一个词:含空白、引号、反斜杠时用双引号包裹;`%` 写 `%%`,`$` 写 `$$`。 */
|
|
18
|
+
function execWord(arg) {
|
|
19
|
+
const escaped = unitValue(arg).replace(/\$/g, () => '$$');
|
|
20
|
+
return /[\s"'\\]/.test(arg) ? `"${escaped}"` : escaped;
|
|
21
|
+
}
|
|
22
|
+
function plist(command, env, log) {
|
|
23
|
+
const s = (v) => `<string>${xmlEscape(v)}</string>`;
|
|
24
|
+
return [
|
|
25
|
+
'<?xml version="1.0" encoding="UTF-8"?>',
|
|
26
|
+
'<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">',
|
|
27
|
+
'<plist version="1.0">',
|
|
28
|
+
'<dict>',
|
|
29
|
+
' <key>Label</key>', ` ${s(LABEL)}`,
|
|
30
|
+
' <key>ProgramArguments</key>', ' <array>', ...command.map((c) => ` ${s(c)}`), ' </array>',
|
|
31
|
+
' <key>RunAtLoad</key>', ' <true/>',
|
|
32
|
+
' <key>EnvironmentVariables</key>', ' <dict>', ' <key>PATH</key>', ` ${s(env)}`, ' </dict>',
|
|
33
|
+
' <key>StandardOutPath</key>', ` ${s(log)}`,
|
|
34
|
+
' <key>StandardErrorPath</key>', ` ${s(log)}`,
|
|
35
|
+
'</dict>', '</plist>', '',
|
|
36
|
+
].join('\n');
|
|
37
|
+
}
|
|
38
|
+
function unitFile(command, env, log) {
|
|
39
|
+
const logPath = log.replace(/%/g, '%%');
|
|
40
|
+
return [
|
|
41
|
+
'[Unit]', 'Description=Wingline: restore teams after login', '',
|
|
42
|
+
'[Service]', 'Type=oneshot', `Environment="PATH=${unitValue(env)}"`, `ExecStart=${command.map(execWord).join(' ')}`,
|
|
43
|
+
`StandardOutput=append:${logPath}`, `StandardError=append:${logPath}`, '',
|
|
44
|
+
'[Install]', 'WantedBy=default.target', '',
|
|
45
|
+
].join('\n');
|
|
46
|
+
}
|
|
47
|
+
const unsupported = (why) => new WinglineError('input', 'autostart_unsupported', `这个平台暂不支持自启动:${why}`, { hint: '登录后请手动执行 wl restore --all' });
|
|
48
|
+
/**
|
|
49
|
+
* 文件位置、日志路径与要执行的系统命令(`file.content` 为空)。卸载和查看状态只需要这些:
|
|
50
|
+
* 不要求命令里的 node / orca 路径能用于安装,否则环境一变(orca 只剩裸名、node 搬家)反而卸载不了已经装好的文件。
|
|
51
|
+
*/
|
|
52
|
+
export function planTargets(o) {
|
|
53
|
+
if (o.platform !== 'darwin' && o.platform !== 'linux')
|
|
54
|
+
throw unsupported(o.platform === 'win32' ? 'Windows 要等探针 5 之后' : o.platform);
|
|
55
|
+
const logPath = P.join(o.home, 'logs', 'autostart.log');
|
|
56
|
+
if (o.platform === 'darwin') {
|
|
57
|
+
if (o.uid === undefined)
|
|
58
|
+
throw unsupported('无法确定用户 ID');
|
|
59
|
+
const file = P.join(o.homeDir, 'Library', 'LaunchAgents', `${LABEL}.plist`);
|
|
60
|
+
const bootout = { file: '/bin/launchctl', args: ['bootout', `gui/${o.uid}/${LABEL}`], allowFail: true };
|
|
61
|
+
return {
|
|
62
|
+
platform: 'darwin', file: { path: file, content: '' }, logPath,
|
|
63
|
+
register: [bootout, { file: '/bin/launchctl', args: ['bootstrap', `gui/${o.uid}`, file], retries: 2 }],
|
|
64
|
+
unregister: [bootout],
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
const systemctl = '/usr/bin/systemctl';
|
|
68
|
+
return {
|
|
69
|
+
platform: 'linux', ...(o.user !== undefined ? { user: o.user } : {}), logPath,
|
|
70
|
+
probe: { file: systemctl, args: ['--user', 'show-environment'] },
|
|
71
|
+
file: { path: P.join(o.homeDir, '.config', 'systemd', 'user', UNIT), content: '' },
|
|
72
|
+
register: [{ file: systemctl, args: ['--user', 'daemon-reload'] }, { file: systemctl, args: ['--user', 'enable', UNIT] }],
|
|
73
|
+
unregister: [{ file: systemctl, args: ['--user', 'disable', UNIT], allowFail: true }, { file: systemctl, args: ['--user', 'daemon-reload'], allowFail: true }],
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
/** 纯函数:生成文件内容与要执行的系统命令,不碰文件系统。 */
|
|
77
|
+
export function planAutostart(o) {
|
|
78
|
+
const plan = planTargets(o);
|
|
79
|
+
parseDuration(o.waitOrca);
|
|
80
|
+
const c = absoluteSelfCommand({
|
|
81
|
+
platform: o.platform, node: o.node, script: o.script, home: o.home, orca: o.orca,
|
|
82
|
+
args: ['restore', '--all', '--wait-orca', o.waitOrca, '--actor', 'autostart'],
|
|
83
|
+
});
|
|
84
|
+
const command = [c.file, ...c.args];
|
|
85
|
+
const env = pathEnv(o.node, o.orca);
|
|
86
|
+
const content = plan.platform === 'darwin' ? plist(command, env, plan.logPath) : unitFile(command, env, plan.logPath);
|
|
87
|
+
return { ...plan, file: { ...plan.file, content } };
|
|
88
|
+
}
|
|
89
|
+
/** 把 ExecStart 的一行拆回词(与 execWord 互逆)。 */
|
|
90
|
+
function tokenize(line) {
|
|
91
|
+
const out = [];
|
|
92
|
+
let cur = null;
|
|
93
|
+
let quoted = false;
|
|
94
|
+
for (let i = 0; i < line.length; i++) {
|
|
95
|
+
const c = line[i];
|
|
96
|
+
if (c === '\\' && quoted) {
|
|
97
|
+
cur = (cur ?? '') + (line[++i] ?? '');
|
|
98
|
+
continue;
|
|
99
|
+
}
|
|
100
|
+
if (c === '"') {
|
|
101
|
+
quoted = !quoted;
|
|
102
|
+
cur = cur ?? '';
|
|
103
|
+
continue;
|
|
104
|
+
}
|
|
105
|
+
if (!quoted && /\s/.test(c)) {
|
|
106
|
+
if (cur !== null)
|
|
107
|
+
out.push(cur);
|
|
108
|
+
cur = null;
|
|
109
|
+
continue;
|
|
110
|
+
}
|
|
111
|
+
if ((c === '%' || c === '$') && line[i + 1] === c)
|
|
112
|
+
i++;
|
|
113
|
+
cur = (cur ?? '') + c;
|
|
114
|
+
}
|
|
115
|
+
if (cur !== null)
|
|
116
|
+
out.push(cur);
|
|
117
|
+
return out;
|
|
118
|
+
}
|
|
119
|
+
/** 从已安装的文件里读出它实际会执行的命令(`wl autostart status` 用它检查 node 与入口脚本还在不在)。 */
|
|
120
|
+
export function installedCommand(platform, content) {
|
|
121
|
+
if (platform === 'darwin') {
|
|
122
|
+
const block = /<key>ProgramArguments<\/key>\s*<array>([\s\S]*?)<\/array>/.exec(content)?.[1] ?? '';
|
|
123
|
+
return [...block.matchAll(/<string>([\s\S]*?)<\/string>/g)].map((m) => xmlUnescape(m[1]));
|
|
124
|
+
}
|
|
125
|
+
const line = content.split(/\r?\n/).find((l) => l.startsWith('ExecStart='));
|
|
126
|
+
return line ? tokenize(line.slice('ExecStart='.length)) : [];
|
|
127
|
+
}
|