@asc-agent/runtime 0.3.1 → 0.4.0

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.
Files changed (42) hide show
  1. package/README.md +1 -1
  2. package/dist/adapters/claude-code/install.d.ts +7 -0
  3. package/dist/adapters/claude-code/install.js +114 -38
  4. package/dist/adapters/claude-code/probe.js +6 -1
  5. package/dist/adapters/claude-code/session-start.d.ts +20 -0
  6. package/dist/adapters/claude-code/session-start.js +111 -0
  7. package/dist/adapters/jam/adapter.d.ts +46 -9
  8. package/dist/adapters/jam/adapter.js +88 -22
  9. package/dist/adapters/jam/mcp-client.js +5 -1
  10. package/dist/adapters/jam/setup.d.ts +62 -0
  11. package/dist/adapters/jam/setup.js +85 -0
  12. package/dist/adapters/service/launchd.d.ts +12 -0
  13. package/dist/adapters/service/launchd.js +80 -0
  14. package/dist/adapters/service/schtasks.d.ts +16 -0
  15. package/dist/adapters/service/schtasks.js +67 -0
  16. package/dist/adapters/service/systemd-user.d.ts +15 -0
  17. package/dist/adapters/service/systemd-user.js +95 -0
  18. package/dist/cli/asc.js +886 -147
  19. package/dist/composition/registry.js +11 -3
  20. package/dist/composition/runtime.d.ts +33 -0
  21. package/dist/composition/runtime.js +74 -0
  22. package/dist/core/attach/setup-plan.d.ts +73 -1
  23. package/dist/core/attach/setup-plan.js +56 -3
  24. package/dist/core/distribution/external-command.d.ts +22 -0
  25. package/dist/core/distribution/external-command.js +79 -0
  26. package/dist/core/distribution/persistent-runtime.d.ts +73 -0
  27. package/dist/core/distribution/persistent-runtime.js +49 -0
  28. package/dist/core/distribution/release.d.ts +3 -3
  29. package/dist/core/distribution/release.js +1 -1
  30. package/dist/core/operator/progress.js +3 -1
  31. package/dist/core/runtime/background.d.ts +104 -0
  32. package/dist/core/runtime/background.js +225 -0
  33. package/dist/core/runtime/front.d.ts +48 -0
  34. package/dist/core/runtime/front.js +34 -0
  35. package/dist/core/runtime/session.js +6 -1
  36. package/dist/core/runtime/workspaces.d.ts +39 -0
  37. package/dist/core/runtime/workspaces.js +64 -0
  38. package/dist/core/workspace/resolve.d.ts +36 -0
  39. package/dist/core/workspace/resolve.js +124 -3
  40. package/dist/ports/adapter.d.ts +12 -0
  41. package/dist/schemas/profile.d.ts +6 -6
  42. package/package.json +1 -1
@@ -10,6 +10,7 @@
10
10
  // stderr를 프로토콜로 읽지 않는다 — 진단용으로만 쓰고, 그것도 길이를 제한한다
11
11
  // 자격 값을 절대 기록하지 않는다 — 이 파일은 토큰을 받지도, 보지도, 남기지도 않는다
12
12
  import { spawn } from 'node:child_process';
13
+ import { resolveExternalCommand } from "../../core/distribution/external-command.js";
13
14
  const PROTOCOL_VERSION = '2024-11-05';
14
15
  /** 진단으로 남길 stderr 최대 길이. 무한히 모으면 그 자체가 새는 곳이 된다. */
15
16
  const STDERR_KEEP = 2000;
@@ -30,9 +31,12 @@ export class JamMcpClient {
30
31
  if (this.#ready && this.#child)
31
32
  return { ok: true, value: { name: '', version: '' } };
32
33
  try {
34
+ // Windows에서 `jam` 은 npm `.cmd` shim이다 — bare 이름 spawn은 ENOENT가 나고
35
+ // (asc init 실측: "spawn jam ENOENT"), 실행 형태를 해석해야 세 OS에서 같다.
36
+ const resolved = resolveExternalCommand(this.#deps.command, this.#deps.args ?? []);
33
37
  this.#child = this.#deps.spawnProcess
34
38
  ? this.#deps.spawnProcess()
35
- : spawn(this.#deps.command, [...(this.#deps.args ?? [])], {
39
+ : spawn(resolved.command, resolved.args, {
36
40
  stdio: ['pipe', 'pipe', 'pipe'],
37
41
  ...(this.#deps.cwd ? { cwd: this.#deps.cwd } : {}),
38
42
  });
@@ -0,0 +1,62 @@
1
+ /** JAM 의 zero-install 진입. **버전을 여기 박지 않는다** — JAM 이 말한 값을 쓴다. */
2
+ export declare const JAM_BOOTSTRAP = "@jam-mcp/bootstrap";
3
+ /**
4
+ * 정확한 버전으로 고정한 공식 명령.
5
+ *
6
+ * JAM 문서가 `@latest` 를 금지하고 정확한 핀을 요구한다. 그 버전을 ASC 가 정하면 두
7
+ * 제품의 릴리스가 묶이므로, **돌고 있는 JAM 이 스스로 말한 버전**(doctor 의
8
+ * `axes.packageVersion`)을 그대로 쓴다.
9
+ */
10
+ export declare function jamBootstrapCommand(version: string, args: readonly string[]): {
11
+ command: string;
12
+ args: string[];
13
+ };
14
+ /** `setup plan --json` 에서 우리가 읽는 부분. */
15
+ export type JamSetupPlan = {
16
+ status?: string;
17
+ /** 사람이 결정해야 하는 것이 남아 있는가. 남아 있으면 ASC 는 손대지 않는다. */
18
+ requiresUserAction?: boolean;
19
+ changes?: unknown[];
20
+ project?: {
21
+ key?: string;
22
+ keySource?: string;
23
+ };
24
+ code?: string;
25
+ error?: string;
26
+ };
27
+ export type JamHealOutcome =
28
+ /** 고칠 것이 없었다. */
29
+ {
30
+ kind: 'ALREADY_READY';
31
+ }
32
+ /** ASC 가 JAM 공식 setup 으로 고쳤다. */
33
+ | {
34
+ kind: 'HEALED';
35
+ changes: number;
36
+ }
37
+ /** 사람이 해야 한다 — 자격, 또는 진짜 프로젝트 선택. */
38
+ | {
39
+ kind: 'NEEDS_HUMAN';
40
+ detail: string;
41
+ }
42
+ /** 다시 돌려도 달라지지 않는다. */
43
+ | {
44
+ kind: 'FAILED';
45
+ detail: string;
46
+ };
47
+ export type JamSetupDeps = {
48
+ cwd: string;
49
+ /** JAM 이 말한 자기 버전. 없으면 부를 수 없다 — 버전을 지어내지 않는다. */
50
+ version: string;
51
+ /** 프로세스 실행 통로. 테스트가 실제 npx 를 부르지 않기 위한 주입점. */
52
+ exec?: (command: string, args: readonly string[], cwd: string) => Promise<string>;
53
+ };
54
+ /**
55
+ * 준비되지 않은 JAM 을 공식 경로로 되살린다.
56
+ *
57
+ * **계획을 먼저 본다.** 계획이 사람을 요구하면 적용하지 않는다 — 그것이 자격이거나 진짜
58
+ * 프로젝트 선택이고, 둘 다 ASC 가 대신할 수 없는 것이다 (설계 §9.4).
59
+ */
60
+ export declare function healJam(deps: JamSetupDeps): Promise<JamHealOutcome>;
61
+ /** 사람이 읽는 한 줄. 무엇을 했는지·무엇이 남았는지가 여기 있어야 한다. */
62
+ export declare function healLine(outcome: JamHealOutcome): string;
@@ -0,0 +1,85 @@
1
+ // JAM 준비 상태를 JAM 의 공식 경로로 되살린다 (설계 §9.2·§9.3).
2
+ //
3
+ // **진단도 수리도 JAM 이 한다.** ASC 가 하는 일은 셋뿐이다:
4
+ //
5
+ // 언제 부를지 정한다 Profile 이 work binding 을 선언했는데 JAM 이 준비되지 않았을 때
6
+ // 무엇을 부를지 고른다 JAM 이 스스로 말한 버전의 공식 bootstrap
7
+ // 어디서 멈출지 판단한다 사람만 할 수 있는 것은 사람에게 넘긴다
8
+ //
9
+ // ASC 는 Jira 토큰을 받지도 저장하지도 않고, 프로젝트 키를 만들어 내지도 않는다.
10
+ // 키는 Profile 에 이미 사람이 적어 둔 것이고, 그 결정을 다시 묻지 않는다.
11
+ import { execFile } from 'node:child_process';
12
+ import { promisify } from 'node:util';
13
+ const run = promisify(execFile);
14
+ /** JAM 의 zero-install 진입. **버전을 여기 박지 않는다** — JAM 이 말한 값을 쓴다. */
15
+ export const JAM_BOOTSTRAP = '@jam-mcp/bootstrap';
16
+ /**
17
+ * 정확한 버전으로 고정한 공식 명령.
18
+ *
19
+ * JAM 문서가 `@latest` 를 금지하고 정확한 핀을 요구한다. 그 버전을 ASC 가 정하면 두
20
+ * 제품의 릴리스가 묶이므로, **돌고 있는 JAM 이 스스로 말한 버전**(doctor 의
21
+ * `axes.packageVersion`)을 그대로 쓴다.
22
+ */
23
+ export function jamBootstrapCommand(version, args) {
24
+ return { command: 'npx', args: ['--yes', `${JAM_BOOTSTRAP}@${version}`, ...args] };
25
+ }
26
+ const defaultExec = async (command, args, cwd) => {
27
+ try {
28
+ const { stdout } = await run(command, [...args], { cwd, maxBuffer: 8 * 1024 * 1024 });
29
+ return stdout;
30
+ }
31
+ catch (error) {
32
+ // JAM 은 준비되지 않았을 때도 JSON 을 내면서 0 이 아닌 코드로 끝난다.
33
+ const stdout = error.stdout;
34
+ if (stdout)
35
+ return stdout;
36
+ throw error;
37
+ }
38
+ };
39
+ /**
40
+ * 준비되지 않은 JAM 을 공식 경로로 되살린다.
41
+ *
42
+ * **계획을 먼저 본다.** 계획이 사람을 요구하면 적용하지 않는다 — 그것이 자격이거나 진짜
43
+ * 프로젝트 선택이고, 둘 다 ASC 가 대신할 수 없는 것이다 (설계 §9.4).
44
+ */
45
+ export async function healJam(deps) {
46
+ const exec = deps.exec ?? defaultExec;
47
+ const read = async (args) => {
48
+ const { command, args: full } = jamBootstrapCommand(deps.version, args);
49
+ try {
50
+ return JSON.parse(await exec(command, full, deps.cwd));
51
+ }
52
+ catch (error) {
53
+ return { error: String(error.message ?? error).slice(0, 300) };
54
+ }
55
+ };
56
+ const plan = await read(['setup', 'plan', '--json']);
57
+ if (plan.error)
58
+ return { kind: 'FAILED', detail: `JAM setup plan 을 읽지 못했다 — ${plan.error}` };
59
+ if (plan.requiresUserAction) {
60
+ // 사람만 할 수 있는 것이 남았다. 대신 하지 않고, 무엇인지 그대로 전한다.
61
+ return { kind: 'NEEDS_HUMAN', detail: plan.code ?? plan.status ?? 'JAM setup requires a person' };
62
+ }
63
+ if ((plan.changes?.length ?? 0) === 0)
64
+ return { kind: 'ALREADY_READY' };
65
+ const applied = await read(['setup', 'apply', '--non-interactive', '--json']);
66
+ if (applied.error)
67
+ return { kind: 'FAILED', detail: `JAM setup apply 가 실패했다 — ${applied.error}` };
68
+ if (applied.requiresUserAction) {
69
+ return { kind: 'NEEDS_HUMAN', detail: applied.code ?? applied.status ?? 'JAM setup requires a person' };
70
+ }
71
+ return { kind: 'HEALED', changes: plan.changes?.length ?? 0 };
72
+ }
73
+ /** 사람이 읽는 한 줄. 무엇을 했는지·무엇이 남았는지가 여기 있어야 한다. */
74
+ export function healLine(outcome) {
75
+ switch (outcome.kind) {
76
+ case 'ALREADY_READY':
77
+ return 'JAM: already set up for this project';
78
+ case 'HEALED':
79
+ return `JAM: repaired through its own setup (${outcome.changes} change${outcome.changes === 1 ? '' : 's'})`;
80
+ case 'NEEDS_HUMAN':
81
+ return `JAM: needs you — ${outcome.detail}. ASC does not sign in for you.`;
82
+ case 'FAILED':
83
+ return `JAM: ${outcome.detail}`;
84
+ }
85
+ }
@@ -0,0 +1,12 @@
1
+ import { type PersistentRuntimeAdapter, type ServiceCommand } from '../../core/distribution/persistent-runtime.ts';
2
+ export declare const plistPath: (home?: string) => string;
3
+ /**
4
+ * plist 본문. **내용이 곧 비교 기준이다** — 같으면 CURRENT, 다르면 STALE 이다.
5
+ * digest 를 따로 두지 않는 이유: 파일이 우리가 만들 내용과 같은지 보는 것이 더 정확하다.
6
+ */
7
+ export declare function launchAgentPlist(command: ServiceCommand): string;
8
+ export type LaunchdDeps = {
9
+ home?: string;
10
+ exec?: (command: string, args: readonly string[]) => Promise<void>;
11
+ };
12
+ export declare function launchdAdapter(deps?: LaunchdDeps): PersistentRuntimeAdapter;
@@ -0,0 +1,80 @@
1
+ // macOS — user LaunchAgent (설계 §4.3).
2
+ //
3
+ // root daemon 이 아니라 **사용자 것**이다. `~/Library/LaunchAgents/` 에 살고 로그인 이후에
4
+ // 돈다. 그것이 이 계약의 경계이고, 그 위로 올리지 않는다 — 사람의 세션과 무관하게 도는
5
+ // 것은 사용자가 켜지 않은 상시 프로세스이며 다른 종류의 결정이다.
6
+ //
7
+ // `StartInterval` 로 짧은 회차를 반복시킨다. 계속 도는 프로세스를 등록하지 않는 이유는
8
+ // port 주석에 있다: 죽었을 때 되살리는 일을 OS 가 더 잘한다.
9
+ import { execFile } from 'node:child_process';
10
+ import { mkdir, readFile, rm, writeFile } from 'node:fs/promises';
11
+ import { homedir } from 'node:os';
12
+ import { dirname, join } from 'node:path';
13
+ import { promisify } from 'node:util';
14
+ import { SERVICE_LABEL, } from "../../core/distribution/persistent-runtime.js";
15
+ const run = promisify(execFile);
16
+ export const plistPath = (home = homedir()) => join(home, 'Library', 'LaunchAgents', `${SERVICE_LABEL}.plist`);
17
+ /** XML 이스케이프. 경로에 `&` 가 있는 기계가 실제로 있다. */
18
+ const xml = (value) => value.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
19
+ /**
20
+ * plist 본문. **내용이 곧 비교 기준이다** — 같으면 CURRENT, 다르면 STALE 이다.
21
+ * digest 를 따로 두지 않는 이유: 파일이 우리가 만들 내용과 같은지 보는 것이 더 정확하다.
22
+ */
23
+ export function launchAgentPlist(command) {
24
+ const args = [command.program, ...command.args].map((arg) => ` <string>${xml(arg)}</string>`).join('\n');
25
+ return `<?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>
30
+ <string>${SERVICE_LABEL}</string>
31
+ <key>ProgramArguments</key>
32
+ <array>
33
+ ${args}
34
+ </array>
35
+ <key>StartInterval</key>
36
+ <integer>${command.intervalSeconds}</integer>
37
+ <key>RunAtLoad</key>
38
+ <true/>
39
+ <key>ProcessType</key>
40
+ <string>Background</string>
41
+ </dict>
42
+ </plist>
43
+ `;
44
+ }
45
+ export function launchdAdapter(deps = {}) {
46
+ const home = deps.home ?? homedir();
47
+ const path = plistPath(home);
48
+ const exec = deps.exec ??
49
+ (async (command, args) => {
50
+ await run(command, [...args]);
51
+ });
52
+ return {
53
+ id: 'launchd',
54
+ async supported() {
55
+ return process.platform === 'darwin';
56
+ },
57
+ async status(command) {
58
+ const wanted = launchAgentPlist(command);
59
+ const existing = await readFile(path, 'utf8').catch(() => null);
60
+ if (existing === null)
61
+ return { kind: 'ABSENT' };
62
+ if (existing === wanted)
63
+ return { kind: 'CURRENT', detail: path };
64
+ // 우리 파일이 우리 내용과 다르다 — 경로나 간격이 바뀌었다는 뜻이다.
65
+ return { kind: 'STALE', detail: `${path} does not match the current runtime` };
66
+ },
67
+ async install(command) {
68
+ await mkdir(dirname(path), { recursive: true });
69
+ await writeFile(path, launchAgentPlist(command), 'utf8');
70
+ // 다시 읽히게 한다. 이미 없는 것을 unload 하는 것은 오류가 아니므로 삼킨다.
71
+ await exec('launchctl', ['unload', path]).catch(() => undefined);
72
+ await exec('launchctl', ['load', path]);
73
+ },
74
+ async uninstall() {
75
+ await exec('launchctl', ['unload', path]).catch(() => undefined);
76
+ // 우리가 만든 것만 지운다. 없으면 지울 것이 없다.
77
+ await rm(path, { force: true });
78
+ },
79
+ };
80
+ }
@@ -0,0 +1,16 @@
1
+ import { type PersistentRuntimeAdapter, type ServiceCommand } from '../../core/distribution/persistent-runtime.ts';
2
+ /** 작업 이름. `\` 없이 두면 루트 폴더에 만들어진다. */
3
+ export declare const TASK_NAME = "com.asc-agent.runtime";
4
+ /**
5
+ * `/TR` 에 들어갈 한 줄.
6
+ *
7
+ * schtasks 는 명령을 문자열 하나로 받는다 — 경로에 공백이 있으면 통째로 깨지므로
8
+ * 각 조각을 따옴표로 감싼다. 따옴표가 든 인자는 등록 자체를 깨뜨리므로 거른다.
9
+ */
10
+ export declare function taskRunLine(command: ServiceCommand): string;
11
+ /** 분 단위 반복. 1분 아래로는 Task Scheduler 가 받지 않는다. */
12
+ export declare const taskMinutes: (intervalSeconds: number) => number;
13
+ export type SchtasksDeps = {
14
+ exec?: (command: string, args: readonly string[]) => Promise<string>;
15
+ };
16
+ export declare function schtasksAdapter(deps?: SchtasksDeps): PersistentRuntimeAdapter;
@@ -0,0 +1,67 @@
1
+ // Windows — 사용자 Scheduled Task (설계 §4.3).
2
+ //
3
+ // 시스템 서비스가 아니라 **로그인한 사용자의 작업**이다. `/RU` 없이 만들면 현재 사용자로
4
+ // 등록되고, 그것이 이 계약의 경계다.
5
+ //
6
+ // 반복은 Task Scheduler 가 한다(`/SC MINUTE /MO n`). ASC 는 한 회차만 도는 명령을 준다.
7
+ import { execFile } from 'node:child_process';
8
+ import { promisify } from 'node:util';
9
+ import { SERVICE_LABEL, } from "../../core/distribution/persistent-runtime.js";
10
+ const run = promisify(execFile);
11
+ /** 작업 이름. `\` 없이 두면 루트 폴더에 만들어진다. */
12
+ export const TASK_NAME = SERVICE_LABEL;
13
+ /**
14
+ * `/TR` 에 들어갈 한 줄.
15
+ *
16
+ * schtasks 는 명령을 문자열 하나로 받는다 — 경로에 공백이 있으면 통째로 깨지므로
17
+ * 각 조각을 따옴표로 감싼다. 따옴표가 든 인자는 등록 자체를 깨뜨리므로 거른다.
18
+ */
19
+ export function taskRunLine(command) {
20
+ const quote = (value) => `\\"${value}\\"`;
21
+ return [command.program, ...command.args].map(quote).join(' ');
22
+ }
23
+ /** 분 단위 반복. 1분 아래로는 Task Scheduler 가 받지 않는다. */
24
+ export const taskMinutes = (intervalSeconds) => Math.max(1, Math.round(intervalSeconds / 60));
25
+ export function schtasksAdapter(deps = {}) {
26
+ const exec = deps.exec ??
27
+ (async (command, args) => {
28
+ const { stdout } = await run(command, [...args]);
29
+ return stdout;
30
+ });
31
+ return {
32
+ id: 'schtasks',
33
+ async supported() {
34
+ return process.platform === 'win32';
35
+ },
36
+ async status(command) {
37
+ const query = await exec('schtasks', ['/Query', '/TN', TASK_NAME, '/FO', 'LIST', '/V']).catch(() => null);
38
+ // 조회가 실패하는 것은 대개 "없다"이다. 없는 것과 못 읽은 것을 구분할 방법이
39
+ // schtasks 에는 없으므로, 없는 쪽으로 읽고 install 이 다시 판정하게 둔다.
40
+ if (query === null)
41
+ return { kind: 'ABSENT' };
42
+ const wanted = taskRunLine(command).replace(/\\"/g, '"');
43
+ // 조회 출력에 우리 명령이 그대로 들어 있는가. 없으면 낡은 등록이다.
44
+ return query.includes(wanted)
45
+ ? { kind: 'CURRENT', detail: TASK_NAME }
46
+ : { kind: 'STALE', detail: `${TASK_NAME} runs a different command` };
47
+ },
48
+ async install(command) {
49
+ // `/F` 로 덮어쓴다 — 같은 이름의 우리 등록을 지금 형태로 수렴시키는 것이다.
50
+ await exec('schtasks', [
51
+ '/Create',
52
+ '/F',
53
+ '/TN',
54
+ TASK_NAME,
55
+ '/TR',
56
+ taskRunLine(command),
57
+ '/SC',
58
+ 'MINUTE',
59
+ '/MO',
60
+ String(taskMinutes(command.intervalSeconds)),
61
+ ]);
62
+ },
63
+ async uninstall() {
64
+ await exec('schtasks', ['/Delete', '/TN', TASK_NAME, '/F']).catch(() => undefined);
65
+ },
66
+ };
67
+ }
@@ -0,0 +1,15 @@
1
+ import { type PersistentRuntimeAdapter, type ServiceCommand } from '../../core/distribution/persistent-runtime.ts';
2
+ export declare const unitDir: (home?: string) => string;
3
+ export declare const SERVICE_UNIT = "com.asc-agent.runtime.service";
4
+ export declare const TIMER_UNIT = "com.asc-agent.runtime.timer";
5
+ export declare function serviceUnit(command: ServiceCommand): string;
6
+ export declare function timerUnit(command: ServiceCommand): string;
7
+ export type SystemdDeps = {
8
+ home?: string;
9
+ exec?: (command: string, args: readonly string[]) => Promise<void>;
10
+ /** systemd --user 를 쓸 수 있는가. 테스트 주입점. */
11
+ available?: () => Promise<boolean>;
12
+ };
13
+ export declare function systemdUserAdapter(deps?: SystemdDeps): PersistentRuntimeAdapter;
14
+ /** 이 기계에 맞는 adapter. 모르면 `null` — 없는 것을 있는 척하지 않는다. */
15
+ export declare function serviceAdapterFor(platform: NodeJS.Platform): 'launchd' | 'schtasks' | 'systemd-user' | null;
@@ -0,0 +1,95 @@
1
+ // Linux — systemd --user (설계 §4.3).
2
+ //
3
+ // 사용자 세션에 묶인다. **lingering 을 조용히 켜지 않는다** — 그것은 "로그아웃해도 돈다"는
4
+ // 뜻이고, 사용자가 하지 않은 결정이다. 필요해지면 사람이 명시적으로 켠다.
5
+ //
6
+ // timer 가 주기를 갖고 service 가 한 회차를 돈다. 계속 도는 프로세스를 두지 않는 이유는
7
+ // port 주석과 같다.
8
+ import { execFile } from 'node:child_process';
9
+ import { mkdir, readFile, rm, writeFile } from 'node:fs/promises';
10
+ import { homedir } from 'node:os';
11
+ import { join } from 'node:path';
12
+ import { promisify } from 'node:util';
13
+ import { SERVICE_LABEL, } from "../../core/distribution/persistent-runtime.js";
14
+ const run = promisify(execFile);
15
+ export const unitDir = (home = homedir()) => join(home, '.config', 'systemd', 'user');
16
+ export const SERVICE_UNIT = `${SERVICE_LABEL}.service`;
17
+ export const TIMER_UNIT = `${SERVICE_LABEL}.timer`;
18
+ /** systemd 인자 인용. 공백이 든 경로가 통째로 깨지지 않게 한다. */
19
+ const quote = (value) => `"${value.replace(/(["\\])/g, '\\$1')}"`;
20
+ export function serviceUnit(command) {
21
+ return `[Unit]
22
+ Description=ASC persistent runtime
23
+
24
+ [Service]
25
+ Type=oneshot
26
+ ExecStart=${[command.program, ...command.args].map(quote).join(' ')}
27
+ `;
28
+ }
29
+ export function timerUnit(command) {
30
+ return `[Unit]
31
+ Description=ASC persistent runtime schedule
32
+
33
+ [Timer]
34
+ OnBootSec=${command.intervalSeconds}
35
+ OnUnitActiveSec=${command.intervalSeconds}
36
+ Unit=${SERVICE_UNIT}
37
+
38
+ [Install]
39
+ WantedBy=timers.target
40
+ `;
41
+ }
42
+ export function systemdUserAdapter(deps = {}) {
43
+ const home = deps.home ?? homedir();
44
+ const dir = unitDir(home);
45
+ const exec = deps.exec ??
46
+ (async (command, args) => {
47
+ await run(command, [...args]);
48
+ });
49
+ const available = deps.available ??
50
+ (async () => {
51
+ if (process.platform !== 'linux')
52
+ return false;
53
+ // 컨테이너처럼 user bus 가 없는 자리가 흔하다 — 있는 척하지 않는다.
54
+ return run('systemctl', ['--user', 'show-environment'])
55
+ .then(() => true)
56
+ .catch(() => false);
57
+ });
58
+ return {
59
+ id: 'systemd-user',
60
+ supported: available,
61
+ async status(command) {
62
+ const service = await readFile(join(dir, SERVICE_UNIT), 'utf8').catch(() => null);
63
+ const timer = await readFile(join(dir, TIMER_UNIT), 'utf8').catch(() => null);
64
+ if (service === null || timer === null)
65
+ return { kind: 'ABSENT' };
66
+ return service === serviceUnit(command) && timer === timerUnit(command)
67
+ ? { kind: 'CURRENT', detail: dir }
68
+ : { kind: 'STALE', detail: `${dir} does not match the current runtime` };
69
+ },
70
+ async install(command) {
71
+ await mkdir(dir, { recursive: true });
72
+ await writeFile(join(dir, SERVICE_UNIT), serviceUnit(command), 'utf8');
73
+ await writeFile(join(dir, TIMER_UNIT), timerUnit(command), 'utf8');
74
+ await exec('systemctl', ['--user', 'daemon-reload']);
75
+ await exec('systemctl', ['--user', 'enable', '--now', TIMER_UNIT]);
76
+ },
77
+ async uninstall() {
78
+ await exec('systemctl', ['--user', 'disable', '--now', TIMER_UNIT]).catch(() => undefined);
79
+ // 우리가 만든 것만 지운다.
80
+ await rm(join(dir, TIMER_UNIT), { force: true });
81
+ await rm(join(dir, SERVICE_UNIT), { force: true });
82
+ await exec('systemctl', ['--user', 'daemon-reload']).catch(() => undefined);
83
+ },
84
+ };
85
+ }
86
+ /** 이 기계에 맞는 adapter. 모르면 `null` — 없는 것을 있는 척하지 않는다. */
87
+ export function serviceAdapterFor(platform) {
88
+ if (platform === 'darwin')
89
+ return 'launchd';
90
+ if (platform === 'win32')
91
+ return 'schtasks';
92
+ if (platform === 'linux')
93
+ return 'systemd-user';
94
+ return null;
95
+ }