@asc-agent/runtime 0.1.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/README.md +4 -1
- package/dist/adapters/claude-code/skill.js +9 -1
- package/dist/cli/asc.d.ts +4 -5
- package/dist/cli/asc.js +378 -149
- package/dist/core/attach/adopt.d.ts +38 -0
- package/dist/core/attach/adopt.js +104 -0
- package/dist/core/attach/bootstrap.d.ts +13 -3
- package/dist/core/attach/bootstrap.js +14 -17
- package/dist/core/attach/setup-plan.d.ts +10 -1
- package/dist/core/attach/setup-plan.js +36 -8
- package/dist/core/attach/setup.d.ts +10 -0
- package/dist/core/attach/setup.js +10 -0
- package/dist/core/distribution/node-runtime.d.ts +44 -0
- package/dist/core/distribution/node-runtime.js +75 -0
- package/dist/core/distribution/release.d.ts +3 -3
- package/dist/core/distribution/release.js +1 -1
- package/dist/core/monitor/observation.d.ts +2 -2
- package/dist/core/resolver/load.d.ts +12 -1
- package/dist/core/resolver/load.js +23 -2
- package/dist/core/resolver/profile-source.d.ts +29 -0
- package/dist/core/resolver/profile-source.js +139 -0
- package/dist/core/workspace/identity.d.ts +2 -2
- package/dist/core/workspace/index-store.d.ts +10 -10
- package/dist/schemas/profile.d.ts +21 -21
- package/package.json +55 -55
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/** git이 부르는 이름과 URL. 이름을 버리지 않는 이유는 `origin` 을 알아보기 위해서다. */
|
|
2
|
+
export type RemoteEntry = {
|
|
3
|
+
name: string;
|
|
4
|
+
url: string;
|
|
5
|
+
};
|
|
6
|
+
export type AdoptInput = {
|
|
7
|
+
/** 프로젝트 뿌리의 디렉터리 이름. remote가 없을 때 id와 repository의 근거가 된다. */
|
|
8
|
+
dirName: string;
|
|
9
|
+
remotes: readonly RemoteEntry[];
|
|
10
|
+
/** 사람이 `--id` 로 준 이름. 있으면 추론보다 앞선다. */
|
|
11
|
+
requestedId?: string;
|
|
12
|
+
/**
|
|
13
|
+
* 이 host를 어느 SCM 이름으로 적을 것인가. 모르면 `'git'` 이면 된다 — 그것이 사실이다.
|
|
14
|
+
* provider를 아는 쪽(조립부)이 넘긴다.
|
|
15
|
+
*/
|
|
16
|
+
scmForHost?: (host: string) => string;
|
|
17
|
+
};
|
|
18
|
+
export type AdoptedProfile = {
|
|
19
|
+
id: string;
|
|
20
|
+
/** `ProjectProfile` 로 파싱되는 객체. 파일에 그대로 쓴다. */
|
|
21
|
+
profile: Record<string, unknown>;
|
|
22
|
+
/** 추론하지 못해 비워 둔 것. 숨기지 않고 사람에게 보인다. */
|
|
23
|
+
warnings: string[];
|
|
24
|
+
};
|
|
25
|
+
export declare class AdoptError extends Error {
|
|
26
|
+
readonly code: 'NO_USABLE_ID';
|
|
27
|
+
constructor(code: 'NO_USABLE_ID', message: string);
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* 이름 하나를 id 문법으로 깎는다. 못 깎으면 `null` — 조용히 아무 이름이나 만들지 않는다.
|
|
31
|
+
* `.` 과 `..` 은 문법은 통과하지만 디렉터리로서 뜻이 다르므로 여기서 뺀다.
|
|
32
|
+
*/
|
|
33
|
+
export declare function toProfileId(name: string): string | null;
|
|
34
|
+
/**
|
|
35
|
+
* 이 저장소를 설명하는 최소 Profile. **파일을 읽지도 쓰지도 않는다** — 사실은 호출자가
|
|
36
|
+
* 관측해 넘기고, 여기서는 판단만 한다. 그래야 같은 판단을 테스트가 그대로 돌린다.
|
|
37
|
+
*/
|
|
38
|
+
export declare function buildAdoptedProfile(input: AdoptInput): AdoptedProfile;
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
// 지금 이 저장소를 설명하는 Profile을 만든다 (P0 — Two-URL agent bootstrap).
|
|
2
|
+
//
|
|
3
|
+
// **왜 필요한가**: 배포본에는 예시 Profile 둘뿐이고 둘 다 실 프로젝트용이 아니다. URL만
|
|
4
|
+
// 받은 agent는 `ASC_PROFILE_SELECTION_REQUIRED` 앞에서 고를 것이 없어 사람에게 되물었다.
|
|
5
|
+
// 되물음을 없애려면 "고른다"가 아니라 "만든다"가 있어야 한다.
|
|
6
|
+
//
|
|
7
|
+
// **무엇을 만드는가 — 추론 가능한 최소치뿐이다.** git remote에서 읽히는 사실
|
|
8
|
+
// (id · scm · repository)만 적는다. branch 정본·role 경계·정책은 이 저장소를 봐서
|
|
9
|
+
// 알 수 없다. 모르는 것을 그럴듯하게 채우면 두 가지가 같이 무너진다:
|
|
10
|
+
//
|
|
11
|
+
// canonical source를 지어내면 세션 발급이 그 정본을 실제로 읽으려 하고, 자격이 없는
|
|
12
|
+
// 기계에서 issue 자체가 실패한다 (session.ts §readBaselines)
|
|
13
|
+
// role 경계를 지어내면 지어낸 경계가 곧 사람이 겪는 SCOPE_ESCALATION이 된다 —
|
|
14
|
+
// FAIL 회차에서 번들 Profile이 정확히 그렇게 막았다
|
|
15
|
+
//
|
|
16
|
+
// 그래서 adopt의 산출물은 **시작점**이다. 팀의 정책은 팀이 이 파일을 키워서 정한다.
|
|
17
|
+
//
|
|
18
|
+
// **provider 어휘는 여기 없다** (C-09 §6.1). 어느 host가 어느 provider인지는 Adapter를
|
|
19
|
+
// 아는 쪽이 정하고, 여기서는 `scmForHost` 로 받는다 — core가 특정 provider 이름으로
|
|
20
|
+
// 갈라지기 시작하면 그 다음 provider는 반드시 이 파일을 고쳐야 들어온다.
|
|
21
|
+
import { normalizeRemote } from "../workspace/identity.js";
|
|
22
|
+
export class AdoptError extends Error {
|
|
23
|
+
code;
|
|
24
|
+
constructor(code, message) {
|
|
25
|
+
super(message);
|
|
26
|
+
this.name = 'AdoptError';
|
|
27
|
+
this.code = code;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
/** Profile id는 디렉터리 이름 한 칸이다 (profile-source.ts SAFE_ID와 같은 문법). */
|
|
31
|
+
const SAFE_ID = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
|
|
32
|
+
/**
|
|
33
|
+
* 이름 하나를 id 문법으로 깎는다. 못 깎으면 `null` — 조용히 아무 이름이나 만들지 않는다.
|
|
34
|
+
* `.` 과 `..` 은 문법은 통과하지만 디렉터리로서 뜻이 다르므로 여기서 뺀다.
|
|
35
|
+
*/
|
|
36
|
+
export function toProfileId(name) {
|
|
37
|
+
const cut = name
|
|
38
|
+
.trim()
|
|
39
|
+
.replace(/\.git$/, '')
|
|
40
|
+
.replace(/[^A-Za-z0-9._-]+/g, '-')
|
|
41
|
+
.replace(/^[^A-Za-z0-9]+/, '')
|
|
42
|
+
.replace(/-+$/, '');
|
|
43
|
+
if (!cut || cut === '.' || cut === '..' || !SAFE_ID.test(cut))
|
|
44
|
+
return null;
|
|
45
|
+
return cut;
|
|
46
|
+
}
|
|
47
|
+
/** `origin` 이 있으면 그것이다. 없으면 첫 번째 — 다만 그 사실을 경고로 남긴다. */
|
|
48
|
+
function primaryRemote(remotes) {
|
|
49
|
+
return remotes.find((r) => r.name === 'origin') ?? remotes[0];
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* 이 저장소를 설명하는 최소 Profile. **파일을 읽지도 쓰지도 않는다** — 사실은 호출자가
|
|
53
|
+
* 관측해 넘기고, 여기서는 판단만 한다. 그래야 같은 판단을 테스트가 그대로 돌린다.
|
|
54
|
+
*/
|
|
55
|
+
export function buildAdoptedProfile(input) {
|
|
56
|
+
const warnings = [];
|
|
57
|
+
const remote = primaryRemote(input.remotes);
|
|
58
|
+
const distinctUrls = new Set(input.remotes.map((r) => r.url));
|
|
59
|
+
if (remote && remote.name !== 'origin' && distinctUrls.size > 1) {
|
|
60
|
+
warnings.push(`No 'origin' remote — used '${remote.name}' (${remote.url}) to identify this project.`);
|
|
61
|
+
}
|
|
62
|
+
// `host/group/project` 로 정규화된다. 자격·포트·질의는 여기 들어오지 않는다.
|
|
63
|
+
const alias = remote ? normalizeRemote(remote.url) : null;
|
|
64
|
+
const slash = alias?.indexOf('/') ?? -1;
|
|
65
|
+
const host = alias && slash > 0 ? alias.slice(0, slash) : null;
|
|
66
|
+
// repository는 host를 뺀 `owner/repo` 다 — Adapter가 API에 그대로 넘기는 형태가 그것이다.
|
|
67
|
+
const repository = alias && slash > 0 ? alias.slice(slash + 1) : null;
|
|
68
|
+
const id = input.requestedId ?? (repository ? toProfileId(repository.split('/').pop()) : null) ?? toProfileId(input.dirName);
|
|
69
|
+
if (id === null) {
|
|
70
|
+
throw new AdoptError('NO_USABLE_ID', `Could not make a profile id out of '${input.dirName}' — pass one with --id <name> (letters, digits, '.', '_', '-').`);
|
|
71
|
+
}
|
|
72
|
+
if (input.requestedId !== undefined && !SAFE_ID.test(input.requestedId)) {
|
|
73
|
+
throw new AdoptError('NO_USABLE_ID', `'${input.requestedId}' is not a profile id — use a single name made of letters, digits, '.', '_' or '-'.`);
|
|
74
|
+
}
|
|
75
|
+
if (!repository) {
|
|
76
|
+
warnings.push(remote
|
|
77
|
+
? `Remote '${remote.url}' is not a shared repository address — this profile describes a local project.`
|
|
78
|
+
: 'This repository has no remote — this profile describes a local project.');
|
|
79
|
+
}
|
|
80
|
+
warnings.push('canonical.sources is empty: no branch is treated as the source of truth yet. Declare one before' +
|
|
81
|
+
' relying on canonical verification.');
|
|
82
|
+
warnings.push('policy.roleScopes is empty: no role is narrowed yet. Declare scopes when the team agrees on them.');
|
|
83
|
+
return {
|
|
84
|
+
id,
|
|
85
|
+
warnings,
|
|
86
|
+
profile: {
|
|
87
|
+
schemaVersion: 1,
|
|
88
|
+
$comment: 'Adopted from this repository by `asc profile adopt`. It carries only what a git remote can prove —' +
|
|
89
|
+
' the project identity. Canonical branches, role boundaries and policy are decisions this tool cannot' +
|
|
90
|
+
' make for you: add them here as the team agrees on them.',
|
|
91
|
+
id,
|
|
92
|
+
project: {
|
|
93
|
+
// 공유 주소가 없으면 로컬 프로젝트다. 있으면 host를 아는 쪽이 이름을 준다.
|
|
94
|
+
scm: host === null ? 'local' : (input.scmForHost?.(host) ?? 'git'),
|
|
95
|
+
repository: repository ?? `local/${toProfileId(input.dirName) ?? id}`,
|
|
96
|
+
},
|
|
97
|
+
canonical: {
|
|
98
|
+
$comment: 'Empty means canonical verification does not apply. Add a source (id, provider, remote, ref) to' +
|
|
99
|
+
' have sessions check the project against a real baseline.',
|
|
100
|
+
sources: [],
|
|
101
|
+
},
|
|
102
|
+
},
|
|
103
|
+
};
|
|
104
|
+
}
|
|
@@ -66,8 +66,13 @@ export type BootstrapPlan = {
|
|
|
66
66
|
};
|
|
67
67
|
export type BootstrapInput = {
|
|
68
68
|
cwd: string;
|
|
69
|
-
/** ASC 설치 경로. `profiles/` 를 여기서 읽는다. */
|
|
69
|
+
/** ASC 설치 경로. 내장 `profiles/` 를 여기서 읽는다. */
|
|
70
70
|
installRoot: string;
|
|
71
|
+
/**
|
|
72
|
+
* 사용자 소유 Profile 디렉터리(보통 `ASC_HOME/profiles`). **Surface가 정해서 넘긴다** —
|
|
73
|
+
* Core가 홈 경로를 스스로 알면 그 순간 저장소·사용자 공간의 경계를 Core가 쥐게 된다.
|
|
74
|
+
*/
|
|
75
|
+
externalProfileRoot?: string;
|
|
71
76
|
/**
|
|
72
77
|
* 이 위치가 붙어 있다면 그 runtime 뿌리. **Surface가 정해서 넘긴다** — Core가 여기서
|
|
73
78
|
* 다시 찾으면 저장소 안의 `.asc` 만 보게 되고, 그것이 기본이 아닌 지금은
|
|
@@ -94,8 +99,13 @@ export type BootstrapInput = {
|
|
|
94
99
|
/** 정책 질문 자체를 생략할지. 계획만 보려는 호출용이다. */
|
|
95
100
|
askPolicy?: boolean;
|
|
96
101
|
};
|
|
97
|
-
/**
|
|
98
|
-
|
|
102
|
+
/**
|
|
103
|
+
* 지금 고를 수 있는 Profile. 설치된 배포본 안과 **사용자 소유 공간** 둘 다 본다 —
|
|
104
|
+
* 팀이 나눠 갖는 실 Profile은 배포본에 들어가지 않으므로 후자가 없으면 후보가 없다.
|
|
105
|
+
*
|
|
106
|
+
* 없으면 빈 목록이다 — 그것도 사람이 알아야 할 사실이다.
|
|
107
|
+
*/
|
|
108
|
+
export declare function availableProfiles(installRoot: string, externalProfileRoot?: string): Promise<string[]>;
|
|
99
109
|
/** 감지하고 계획을 세운다. 파일은 하나도 건드리지 않는다. */
|
|
100
110
|
export declare function planBootstrap(input: BootstrapInput): Promise<BootstrapPlan>;
|
|
101
111
|
/** 사람이 읽는 계획. 무엇이 정해지지 않았는지가 먼저 오도록 짠다. */
|
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
import { readdir, readFile } from 'node:fs/promises';
|
|
14
14
|
import { join } from 'node:path';
|
|
15
15
|
import { availableCapabilities } from "../binding/types.js";
|
|
16
|
+
import { listProfileLocations } from "../resolver/profile-source.js";
|
|
16
17
|
import { discoverProjectRoot, exists } from "./init.js";
|
|
17
18
|
/**
|
|
18
19
|
* 정책은 사람이 정한다 (C-09 §7.1). adapter가 찾아 주는 것과 그것이 무슨 뜻인지 정하는 것은
|
|
@@ -25,22 +26,18 @@ export const POLICY_QUESTIONS = [
|
|
|
25
26
|
{ id: 'authority', question: 'decision authority는 누구인가' },
|
|
26
27
|
{ id: 'digest-channel', question: '기본 전달 채널은 무엇인가' },
|
|
27
28
|
];
|
|
28
|
-
/**
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
}
|
|
41
|
-
catch {
|
|
42
|
-
return [];
|
|
43
|
-
}
|
|
29
|
+
/**
|
|
30
|
+
* 지금 고를 수 있는 Profile. 설치된 배포본 안과 **사용자 소유 공간** 둘 다 본다 —
|
|
31
|
+
* 팀이 나눠 갖는 실 Profile은 배포본에 들어가지 않으므로 후자가 없으면 후보가 없다.
|
|
32
|
+
*
|
|
33
|
+
* 없으면 빈 목록이다 — 그것도 사람이 알아야 할 사실이다.
|
|
34
|
+
*/
|
|
35
|
+
export async function availableProfiles(installRoot, externalProfileRoot) {
|
|
36
|
+
const found = await listProfileLocations({
|
|
37
|
+
installRoot,
|
|
38
|
+
...(externalProfileRoot ? { externalRoot: externalProfileRoot } : {}),
|
|
39
|
+
});
|
|
40
|
+
return [...new Set(found.map((location) => location.id))].sort();
|
|
44
41
|
}
|
|
45
42
|
/** 붙어 있다면 무엇으로 붙었는가. lock을 읽지 못하면 붙이다 만 상태로 본다. */
|
|
46
43
|
async function attachedProfile(ascRoot) {
|
|
@@ -58,7 +55,7 @@ export async function planBootstrap(input) {
|
|
|
58
55
|
// 넘겨받은 것이 없을 때만 저장소 안을 본다 — 옛 경로를 계속 지원하되 기본으로 두지 않는다.
|
|
59
56
|
const ascRoot = input.ascRoot ?? join(projectRoot, '.asc');
|
|
60
57
|
const attached = await exists(ascRoot);
|
|
61
|
-
const candidates = await availableProfiles(input.installRoot);
|
|
58
|
+
const candidates = await availableProfiles(input.installRoot, input.externalProfileRoot);
|
|
62
59
|
const hosts = [...(input.hosts ?? [])];
|
|
63
60
|
const plan = input.bindings ?? { bindings: [] };
|
|
64
61
|
const locked = attached ? await attachedProfile(ascRoot) : null;
|
|
@@ -1,6 +1,15 @@
|
|
|
1
1
|
import type { StableInstallState } from '../distribution/runtime-install.ts';
|
|
2
2
|
/** 세상의 스냅샷. 읽기만 해서 만든다. */
|
|
3
3
|
export type SetupState = {
|
|
4
|
+
/**
|
|
5
|
+
* 이 판단이 **어느 실행물 안에서** 나오는가 (C-14 §3.4).
|
|
6
|
+
*
|
|
7
|
+
* `runtime` 이면 지금 도는 것이 곧 설치된 `asc` 다 — 그 사실은 관측할 것이 아니라
|
|
8
|
+
* 이미 아는 것이다. 예전에는 이 축이 없어 `stableRuntime` 의 부재를 "설치 안 됨"으로
|
|
9
|
+
* 읽었고, 그래서 설치된 `asc` 가 자기를 bootstrap이라고 말하며 agent에게 `npx …` 를
|
|
10
|
+
* 돌려줬다 (v0.2.0 registry 관측). 진입점 자체가 이미 답의 일부다.
|
|
11
|
+
*/
|
|
12
|
+
entry: 'runtime' | 'bootstrap';
|
|
4
13
|
projectRoot: string;
|
|
5
14
|
git: boolean;
|
|
6
15
|
/** 이미 붙어 있으면 그 runtime 뿌리. 없으면 안 붙은 것이다. */
|
|
@@ -67,7 +76,7 @@ export type SetupCode =
|
|
|
67
76
|
* agent는 `portable` 만 실행하면 되고, 산문을 읽을 필요가 없다.
|
|
68
77
|
*/
|
|
69
78
|
export type NextAction = {
|
|
70
|
-
type: 'select_profile' | 'install_runtime' | 'apply_setup' | 'proceed' | 'force_host_install';
|
|
79
|
+
type: 'select_profile' | 'adopt_profile' | 'install_runtime' | 'apply_setup' | 'proceed' | 'force_host_install';
|
|
71
80
|
display: string;
|
|
72
81
|
portable: string;
|
|
73
82
|
};
|
|
@@ -22,11 +22,22 @@ export function computeSetupPlan(state) {
|
|
|
22
22
|
];
|
|
23
23
|
// 지금 명령이 어디서 도는가. 설치된 `asc` 가 없으면 bootstrap이고, 그때 agent에게
|
|
24
24
|
// `asc …` 를 실행하라고 주면 안 된다 (C-14 §3.4 · 불변식 ⑯).
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
25
|
+
//
|
|
26
|
+
// **진입점이 먼저다.** `asc` 로 들어왔다면 그 `asc` 는 이미 이 machine에 있다 — 그것을
|
|
27
|
+
// npm에게 물어볼 이유가 없다. bootstrap으로 들어왔을 때만 설치 축이 판정에 쓰인다.
|
|
28
|
+
const mode = state.entry === 'runtime' || state.stableRuntime?.status === 'CURRENT'
|
|
29
|
+
? 'installed-runtime'
|
|
30
|
+
: 'bootstrap';
|
|
31
|
+
const command = (args) => {
|
|
32
|
+
// portable은 **agent가 그대로 실행하는 것**이므로 기계가 읽을 수 있는 형태로 끝나야
|
|
33
|
+
// 한다. `--json` 이 빠져 있으면 agent는 자기가 실행한 명령의 답을 산문으로 받는다 —
|
|
34
|
+
// "산문을 파싱하지 마라"고 적어 놓고 산문을 주는 꼴이었다. display는 사람 형태 그대로.
|
|
35
|
+
const machine = args.includes('--json') ? args : [...args, '--json'];
|
|
36
|
+
return {
|
|
37
|
+
display: shorthandCommand(args),
|
|
38
|
+
portable: mode === 'installed-runtime' ? shorthandCommand(machine) : portableCommand(machine),
|
|
39
|
+
};
|
|
40
|
+
};
|
|
30
41
|
const changes = [];
|
|
31
42
|
// stable runtime은 **프로젝트와도, profile 선택과도 무관하다.** 사람이 profile을
|
|
32
43
|
// 고르는 중이어도 이 설치는 안전한 준비이고, 그것 때문에 통째로 WAIT 하지 않는다
|
|
@@ -66,6 +77,11 @@ export function computeSetupPlan(state) {
|
|
|
66
77
|
}
|
|
67
78
|
}
|
|
68
79
|
if (state.ascRoot) {
|
|
80
|
+
// 붙어 있어도 **무엇을 고를 수 있었는지**는 사실이다. 사용자 소유 Profile을 새로 놓고
|
|
81
|
+
// 계획을 물었을 때 그것이 어디에도 안 보이면, 놓은 사람은 경로를 의심하게 된다.
|
|
82
|
+
if (state.profileCandidates.length > 0) {
|
|
83
|
+
evidence.push(`profile candidates=${state.profileCandidates.join(', ')}`);
|
|
84
|
+
}
|
|
69
85
|
return finish(changes, evidence, state, mode, command);
|
|
70
86
|
}
|
|
71
87
|
// 아직 안 붙었다. 무엇으로 붙을지는 사람이 정한다.
|
|
@@ -78,9 +94,21 @@ export function computeSetupPlan(state) {
|
|
|
78
94
|
changes,
|
|
79
95
|
requiresUserAction: true,
|
|
80
96
|
profiles: state.profileCandidates,
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
97
|
+
// 고를 것이 **없을** 수도 있다 — 배포본이 들고 있는 것은 예시뿐이고, 이 프로젝트를
|
|
98
|
+
// 설명하는 Profile은 아직 아무도 만들지 않았다. 그때 "골라라"만 주면 막다른 길이
|
|
99
|
+
// 된다(FAIL 회차에서 agent가 사람에게 되물은 자리). 만드는 길을 함께 든다.
|
|
100
|
+
//
|
|
101
|
+
// 순서는 **지금 무엇이 실제로 길을 여는가**로 정한다. 첫 action이 늘 같으면
|
|
102
|
+
// agent는 첫 줄만 보고 막힌 길로 간다.
|
|
103
|
+
...actions(mode, evidence, state.profileCandidates.length > 0
|
|
104
|
+
? [
|
|
105
|
+
{ type: 'select_profile', ...command(['setup', 'apply', '--profile', '<id>']) },
|
|
106
|
+
{ type: 'adopt_profile', ...command(['profile', 'adopt', '--json']) },
|
|
107
|
+
]
|
|
108
|
+
: [
|
|
109
|
+
{ type: 'adopt_profile', ...command(['profile', 'adopt', '--json']) },
|
|
110
|
+
{ type: 'select_profile', ...command(['setup', 'apply', '--profile', '<id>']) },
|
|
111
|
+
]),
|
|
84
112
|
};
|
|
85
113
|
}
|
|
86
114
|
if (state.scope === 'project') {
|
|
@@ -20,12 +20,22 @@ export type SetupGate = {
|
|
|
20
20
|
};
|
|
21
21
|
export type SetupStatus = {
|
|
22
22
|
attachment: AttachmentState;
|
|
23
|
+
/** 지금 붙어 있는 Profile과 그 출처. 붙지 않았으면 없다. */
|
|
24
|
+
profile?: {
|
|
25
|
+
id: string;
|
|
26
|
+
origin: 'built-in' | 'external';
|
|
27
|
+
};
|
|
23
28
|
/** 설정과 무관하게 지금 되는 것. */
|
|
24
29
|
ready: string[];
|
|
25
30
|
gates: SetupGate[];
|
|
26
31
|
};
|
|
27
32
|
export type SetupInput = {
|
|
28
33
|
attachment: AttachmentState;
|
|
34
|
+
/** 붙어 있는 Profile의 id와 출처. **Surface가 읽어 넘긴다** — Core는 경로를 모른다. */
|
|
35
|
+
profile?: {
|
|
36
|
+
id: string;
|
|
37
|
+
origin: 'built-in' | 'external';
|
|
38
|
+
};
|
|
29
39
|
/** 승인 권한자 매핑이 하나라도 있는가. */
|
|
30
40
|
hasApprovers: boolean;
|
|
31
41
|
/** override의 controller.identities가 채워졌는가. */
|
|
@@ -26,6 +26,7 @@ const RESOLVE_AGAIN = 'after editing, re-lock with `asc profile resolve --write`
|
|
|
26
26
|
export function assessSetup(input) {
|
|
27
27
|
return {
|
|
28
28
|
attachment: input.attachment,
|
|
29
|
+
...(input.profile ? { profile: input.profile } : {}),
|
|
29
30
|
ready: [...ALWAYS_READY],
|
|
30
31
|
gates: [approvalGate(input), monitorGate(input), externalWriteGate(input)],
|
|
31
32
|
};
|
|
@@ -116,6 +117,15 @@ export function renderSetup(status) {
|
|
|
116
117
|
case 'READY':
|
|
117
118
|
break;
|
|
118
119
|
}
|
|
120
|
+
// 어떤 Profile로 도는지, 그리고 그것이 **어디서 왔는지**. 배포본에 딸려 온 예시로
|
|
121
|
+
// 실 프로젝트를 돌리고 있는 상태를 사람이 모르고 지나가지 않게 한다.
|
|
122
|
+
if (status.profile) {
|
|
123
|
+
const origin = status.profile.origin === 'external'
|
|
124
|
+
? 'your own profile directory'
|
|
125
|
+
: 'bundled with the installed package';
|
|
126
|
+
lines.push(`profile: ${status.profile.id} — ${origin}`);
|
|
127
|
+
lines.push('');
|
|
128
|
+
}
|
|
119
129
|
lines.push('Working now:');
|
|
120
130
|
for (const item of status.ready)
|
|
121
131
|
lines.push(` ${item}`);
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import type { ProcessRunner } from './runtime-install.ts';
|
|
2
|
+
/** ASC가 요구하는 Node 하한. `engines` 와 같은 값이며 어긋나면 release:check가 잡는다. */
|
|
3
|
+
export declare const MINIMUM_NODE_MAJOR = 24;
|
|
4
|
+
export type NodeCandidate = {
|
|
5
|
+
path: string;
|
|
6
|
+
version: string;
|
|
7
|
+
};
|
|
8
|
+
export type NodeRuntimeCheck = {
|
|
9
|
+
ok: true;
|
|
10
|
+
version: string;
|
|
11
|
+
} | {
|
|
12
|
+
ok: false;
|
|
13
|
+
code: 'NODE_RUNTIME_REQUIRED';
|
|
14
|
+
version: string;
|
|
15
|
+
detail: string;
|
|
16
|
+
/**
|
|
17
|
+
* 이 machine에서 찾은, 하한을 넘는 Node. **비어 있는 것이 실패가 아니다** —
|
|
18
|
+
* 그때는 Node를 놓는 일 자체가 사람의 경계다.
|
|
19
|
+
*/
|
|
20
|
+
candidates: NodeCandidate[];
|
|
21
|
+
};
|
|
22
|
+
export type NodeRuntimeDeps = {
|
|
23
|
+
/** 지금 이 프로세스의 Node 버전 (`process.version` 형태: `v22.23.2`). */
|
|
24
|
+
version: string;
|
|
25
|
+
/** 후보 경로가 실제로 있는가. */
|
|
26
|
+
exists: (path: string) => boolean;
|
|
27
|
+
/** 디렉터리 목록. 없으면 빈 배열 — 없는 것은 오류가 아니다. */
|
|
28
|
+
list: (path: string) => string[];
|
|
29
|
+
/** `<node> -v` 를 돌린다. 실패는 후보 탈락일 뿐이다. */
|
|
30
|
+
run: ProcessRunner;
|
|
31
|
+
/** 사용자 홈. nvm 배치를 찾는 데만 쓴다. */
|
|
32
|
+
home: string;
|
|
33
|
+
/** 경로를 잇는다 — 호출자가 `node:path` 를 준다 (core는 파일시스템을 모른다). */
|
|
34
|
+
join: (...parts: string[]) => string;
|
|
35
|
+
};
|
|
36
|
+
/** `v22.23.2` · `22.23.2` 둘 다 받는다. 못 읽으면 `null` — 추측하지 않는다. */
|
|
37
|
+
export declare function majorOf(version: string): number | null;
|
|
38
|
+
/**
|
|
39
|
+
* 돌릴 수 있는가, 못 돌린다면 무엇이 있는가.
|
|
40
|
+
*
|
|
41
|
+
* 하한을 넘으면 **아무것도 하지 않는다** — 정상 경로에 I/O를 얹지 않는다. 못 넘을 때만
|
|
42
|
+
* 후보를 찾고, 후보에 대해서만 `-v` 를 묻는다.
|
|
43
|
+
*/
|
|
44
|
+
export declare function checkNodeRuntime(deps: NodeRuntimeDeps): Promise<NodeRuntimeCheck>;
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
// 이 프로세스를 돌리고 있는 Node가 ASC를 돌릴 수 있는가 (C-14 §3).
|
|
2
|
+
//
|
|
3
|
+
// **왜 판정이 필요한가**: `engines` 는 npm에게 하는 말이고, npm은 기본값에서 그것을
|
|
4
|
+
// 경고로만 낸다. 그래서 Node 22에서 설치하면 "무서운 경고 한 줄 → 그래도 돌아감"이 되고,
|
|
5
|
+
// 사용자는 자기가 지원 범위 안에 있는지 알 수 없다. 지원 하한은 결정적으로 답해야 한다.
|
|
6
|
+
//
|
|
7
|
+
// **여기서 하지 않는 것**: Node를 설치하지 않고, PATH·shell 설정을 고치지 않으며,
|
|
8
|
+
// version manager를 다루지 않는다. 이미 이 machine에 있는 것을 **best-effort로 찾아
|
|
9
|
+
// 알려 줄** 뿐이다 — 못 찾는 것은 정상이고, 그때는 사람이 답할 일이다.
|
|
10
|
+
import { RELEASE_VERSION } from "./release.js";
|
|
11
|
+
/** ASC가 요구하는 Node 하한. `engines` 와 같은 값이며 어긋나면 release:check가 잡는다. */
|
|
12
|
+
export const MINIMUM_NODE_MAJOR = 24;
|
|
13
|
+
/** `v22.23.2` · `22.23.2` 둘 다 받는다. 못 읽으면 `null` — 추측하지 않는다. */
|
|
14
|
+
export function majorOf(version) {
|
|
15
|
+
const match = /^v?(\d+)\./.exec(version.trim());
|
|
16
|
+
return match ? Number(match[1]) : null;
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* 이미 있는 Node를 찾아본다. **탐색이지 관리가 아니다.**
|
|
20
|
+
*
|
|
21
|
+
* Homebrew와 nvm의 관례적 배치만 본다. 여기에 없다고 해서 Node가 없는 것은 아니고,
|
|
22
|
+
* 그 경우 후보 없이 돌려주는 것이 정직한 답이다 — 없는 것을 지어내지 않는다.
|
|
23
|
+
*/
|
|
24
|
+
function candidatePaths(deps) {
|
|
25
|
+
const found = [];
|
|
26
|
+
// Homebrew: /opt/homebrew/opt/node@26/bin/node · /usr/local/opt/node/bin/node
|
|
27
|
+
for (const prefix of ['/opt/homebrew/opt', '/usr/local/opt']) {
|
|
28
|
+
for (const entry of deps.list(prefix)) {
|
|
29
|
+
if (!/^node(@\d+)?$/.test(entry))
|
|
30
|
+
continue;
|
|
31
|
+
const path = deps.join(prefix, entry, 'bin', 'node');
|
|
32
|
+
if (deps.exists(path))
|
|
33
|
+
found.push(path);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
// nvm: ~/.nvm/versions/node/v24.1.0/bin/node
|
|
37
|
+
const nvm = deps.join(deps.home, '.nvm', 'versions', 'node');
|
|
38
|
+
for (const entry of deps.list(nvm)) {
|
|
39
|
+
const path = deps.join(nvm, entry, 'bin', 'node');
|
|
40
|
+
if (deps.exists(path))
|
|
41
|
+
found.push(path);
|
|
42
|
+
}
|
|
43
|
+
return [...new Set(found)];
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* 돌릴 수 있는가, 못 돌린다면 무엇이 있는가.
|
|
47
|
+
*
|
|
48
|
+
* 하한을 넘으면 **아무것도 하지 않는다** — 정상 경로에 I/O를 얹지 않는다. 못 넘을 때만
|
|
49
|
+
* 후보를 찾고, 후보에 대해서만 `-v` 를 묻는다.
|
|
50
|
+
*/
|
|
51
|
+
export async function checkNodeRuntime(deps) {
|
|
52
|
+
const major = majorOf(deps.version);
|
|
53
|
+
if (major !== null && major >= MINIMUM_NODE_MAJOR)
|
|
54
|
+
return { ok: true, version: deps.version };
|
|
55
|
+
const candidates = [];
|
|
56
|
+
for (const path of candidatePaths(deps)) {
|
|
57
|
+
const probed = await deps.run(path, ['-v']);
|
|
58
|
+
if (!probed.ok)
|
|
59
|
+
continue;
|
|
60
|
+
const version = probed.stdout.trim().split(/\r?\n/)[0] ?? '';
|
|
61
|
+
const found = majorOf(version);
|
|
62
|
+
if (found !== null && found >= MINIMUM_NODE_MAJOR)
|
|
63
|
+
candidates.push({ path, version });
|
|
64
|
+
}
|
|
65
|
+
return {
|
|
66
|
+
ok: false,
|
|
67
|
+
code: 'NODE_RUNTIME_REQUIRED',
|
|
68
|
+
version: deps.version,
|
|
69
|
+
detail: `ASC ${RELEASE_VERSION} needs Node ${MINIMUM_NODE_MAJOR} or newer; this process is ${deps.version}.` +
|
|
70
|
+
(candidates.length > 0
|
|
71
|
+
? ' A newer Node is already on this machine — use it for the same command.'
|
|
72
|
+
: ' No newer Node was found in the usual places. Installing one is yours to do.'),
|
|
73
|
+
candidates,
|
|
74
|
+
};
|
|
75
|
+
}
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
export declare const RUNTIME_PACKAGE = "@asc-agent/runtime";
|
|
2
2
|
export declare const BOOTSTRAP_PACKAGE = "@asc-agent/bootstrap";
|
|
3
3
|
/** runtime과 bootstrap은 초기 release에서 lockstep이다. */
|
|
4
|
-
export declare const RELEASE_VERSION = "0.1
|
|
5
|
-
export declare const RUNTIME_SPEC = "@asc-agent/runtime@0.1
|
|
6
|
-
export declare const BOOTSTRAP_SPEC = "@asc-agent/bootstrap@0.1
|
|
4
|
+
export declare const RELEASE_VERSION = "0.2.1";
|
|
5
|
+
export declare const RUNTIME_SPEC = "@asc-agent/runtime@0.2.1";
|
|
6
|
+
export declare const BOOTSTRAP_SPEC = "@asc-agent/bootstrap@0.2.1";
|
|
7
7
|
/**
|
|
8
8
|
* 아직 설치되지 않은 machine에서 그대로 실행되는 형태 (C-14 §3.4).
|
|
9
9
|
*
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
export const RUNTIME_PACKAGE = '@asc-agent/runtime';
|
|
10
10
|
export const BOOTSTRAP_PACKAGE = '@asc-agent/bootstrap';
|
|
11
11
|
/** runtime과 bootstrap은 초기 release에서 lockstep이다. */
|
|
12
|
-
export const RELEASE_VERSION = '0.1
|
|
12
|
+
export const RELEASE_VERSION = '0.2.1';
|
|
13
13
|
export const RUNTIME_SPEC = `${RUNTIME_PACKAGE}@${RELEASE_VERSION}`;
|
|
14
14
|
export const BOOTSTRAP_SPEC = `${BOOTSTRAP_PACKAGE}@${RELEASE_VERSION}`;
|
|
15
15
|
/**
|
|
@@ -25,16 +25,16 @@ export declare const Observation: z.ZodObject<{
|
|
|
25
25
|
reference: string;
|
|
26
26
|
disposition: "INBOX" | "SHADOW";
|
|
27
27
|
evidence: string[];
|
|
28
|
+
lastSeenAt: string;
|
|
28
29
|
revisionMarker: string;
|
|
29
30
|
firstSeenAt: string;
|
|
30
|
-
lastSeenAt: string;
|
|
31
31
|
reason?: string | undefined;
|
|
32
32
|
}, {
|
|
33
33
|
reference: string;
|
|
34
34
|
disposition: "INBOX" | "SHADOW";
|
|
35
|
+
lastSeenAt: string;
|
|
35
36
|
revisionMarker: string;
|
|
36
37
|
firstSeenAt: string;
|
|
37
|
-
lastSeenAt: string;
|
|
38
38
|
evidence?: string[] | undefined;
|
|
39
39
|
reason?: string | undefined;
|
|
40
40
|
}>;
|
|
@@ -1,10 +1,13 @@
|
|
|
1
1
|
import { OperationalPreset, ProfileLock, ProjectProfile, UserOverride } from '../../schemas/profile.ts';
|
|
2
2
|
import type { MonitorConfig } from '../monitor/signals.ts';
|
|
3
3
|
import type { OwnershipMap } from '../policy/ownership.ts';
|
|
4
|
+
import { type ProfileOrigin } from './profile-source.ts';
|
|
4
5
|
import { type ConfigLayer, type ResolvedProfile } from './resolve.ts';
|
|
5
6
|
export type LoadedLayers = {
|
|
6
7
|
profile: ProjectProfile;
|
|
7
8
|
profileSource: string;
|
|
9
|
+
/** 그 Profile이 어디서 왔는가 — 배포본 안인가, 사용자 소유 공간인가. */
|
|
10
|
+
profileOrigin: ProfileOrigin;
|
|
8
11
|
preset?: OperationalPreset;
|
|
9
12
|
presetSource?: string;
|
|
10
13
|
override?: UserOverride;
|
|
@@ -23,12 +26,18 @@ export type ResolvedRuntime = {
|
|
|
23
26
|
};
|
|
24
27
|
export declare const digest: (value: unknown) => string;
|
|
25
28
|
/**
|
|
26
|
-
*
|
|
29
|
+
* Profile과 Preset을 읽고, 프로젝트에서 Override를 읽는다.
|
|
27
30
|
* Override가 없는 것은 정상이다 — 팀 설정만으로도 돌아가야 한다.
|
|
31
|
+
*
|
|
32
|
+
* Profile은 설치 경로 안일 수도, 사용자 소유 공간일 수도 있다. **어디서 고를지는
|
|
33
|
+
* 여기서 정하지 않는다** — profile-source.ts가 정하고 여기는 그 결과를 읽는다.
|
|
34
|
+
* Preset은 아직 배포본 안에만 있다(팀이 나눠 갖는 것은 Profile 쪽이다).
|
|
28
35
|
*/
|
|
29
36
|
export declare function loadLayers(input: {
|
|
30
37
|
installRoot: string;
|
|
31
38
|
profileId: string;
|
|
39
|
+
/** 사용자 소유 Profile 디렉터리. 보통 `ASC_HOME/profiles`. */
|
|
40
|
+
externalProfileRoot?: string;
|
|
32
41
|
presetId?: string;
|
|
33
42
|
overridePath?: string;
|
|
34
43
|
}): Promise<LoadedLayers>;
|
|
@@ -113,6 +122,8 @@ export type BootstrapOutcome = {
|
|
|
113
122
|
export declare function bootstrapGuard(input: {
|
|
114
123
|
ascRoot: string;
|
|
115
124
|
installRoot: string;
|
|
125
|
+
/** 사용자 소유 Profile 디렉터리. 붙을 때 쓴 것과 같은 자리를 봐야 한다. */
|
|
126
|
+
externalProfileRoot?: string;
|
|
116
127
|
profileId?: string;
|
|
117
128
|
presetId?: string;
|
|
118
129
|
capabilities: readonly string[];
|
|
@@ -13,6 +13,7 @@ import { constants } from 'node:fs';
|
|
|
13
13
|
import { access, mkdir, readFile, rename } from 'node:fs/promises';
|
|
14
14
|
import { join } from 'node:path';
|
|
15
15
|
import { OperationalPreset, ProfileLock, ProjectProfile, UserOverride } from "../../schemas/profile.js";
|
|
16
|
+
import { ProfileSourceError, resolveProfileLocation } from "./profile-source.js";
|
|
16
17
|
import { resolveProfile } from "./resolve.js";
|
|
17
18
|
import { satisfies } from "./version.js";
|
|
18
19
|
async function pathExists(path) {
|
|
@@ -29,12 +30,30 @@ async function readJson(path) {
|
|
|
29
30
|
return JSON.parse(await readFile(path, 'utf8'));
|
|
30
31
|
}
|
|
31
32
|
/**
|
|
32
|
-
*
|
|
33
|
+
* Profile과 Preset을 읽고, 프로젝트에서 Override를 읽는다.
|
|
33
34
|
* Override가 없는 것은 정상이다 — 팀 설정만으로도 돌아가야 한다.
|
|
35
|
+
*
|
|
36
|
+
* Profile은 설치 경로 안일 수도, 사용자 소유 공간일 수도 있다. **어디서 고를지는
|
|
37
|
+
* 여기서 정하지 않는다** — profile-source.ts가 정하고 여기는 그 결과를 읽는다.
|
|
38
|
+
* Preset은 아직 배포본 안에만 있다(팀이 나눠 갖는 것은 Profile 쪽이다).
|
|
34
39
|
*/
|
|
35
40
|
export async function loadLayers(input) {
|
|
36
|
-
const
|
|
41
|
+
const location = await resolveProfileLocation(input.profileId, {
|
|
42
|
+
installRoot: input.installRoot,
|
|
43
|
+
...(input.externalProfileRoot ? { externalRoot: input.externalProfileRoot } : {}),
|
|
44
|
+
});
|
|
45
|
+
const profileSource = location.path;
|
|
37
46
|
const profile = ProjectProfile.parse(await readJson(profileSource));
|
|
47
|
+
// 디렉터리 이름과 선언된 id가 다르면 여기서 멈춘다.
|
|
48
|
+
//
|
|
49
|
+
// 둘이 다르면 lock에는 **선언된 id**가 박히고 다음 실행은 그 id로 파일을 찾는다 —
|
|
50
|
+
// 그런 디렉터리는 없으므로 붙자마자 아무 명령도 돌지 않는 workspace가 된다.
|
|
51
|
+
// 독립 검증이 실물로 보인 결함이고, 되돌리려면 `.asc/` 를 손으로 지워야 했다.
|
|
52
|
+
if (profile.id !== input.profileId) {
|
|
53
|
+
throw new ProfileSourceError('PROFILE_ID_MISMATCH', `${profileSource} declares id '${profile.id}' but lives in a directory named ` +
|
|
54
|
+
`'${input.profileId}'. Make the two the same — everything after this point looks ` +
|
|
55
|
+
'the profile up by the id it declares.');
|
|
56
|
+
}
|
|
38
57
|
let preset;
|
|
39
58
|
let presetSource;
|
|
40
59
|
if (input.presetId) {
|
|
@@ -54,6 +73,7 @@ export async function loadLayers(input) {
|
|
|
54
73
|
return {
|
|
55
74
|
profile,
|
|
56
75
|
profileSource,
|
|
76
|
+
profileOrigin: location.origin,
|
|
57
77
|
...(preset ? { preset } : {}),
|
|
58
78
|
...(presetSource ? { presetSource } : {}),
|
|
59
79
|
...(override ? { override } : {}),
|
|
@@ -220,6 +240,7 @@ export async function bootstrapGuard(input) {
|
|
|
220
240
|
const locked = ProfileLock.parse(JSON.parse(lockedRaw));
|
|
221
241
|
const layers = await loadLayers({
|
|
222
242
|
installRoot: input.installRoot,
|
|
243
|
+
...(input.externalProfileRoot ? { externalProfileRoot: input.externalProfileRoot } : {}),
|
|
223
244
|
profileId: input.profileId ?? locked.profile.id,
|
|
224
245
|
...(input.presetId ?? locked.preset?.id ? { presetId: input.presetId ?? locked.preset.id } : {}),
|
|
225
246
|
overridePath: join(input.ascRoot, 'override.json'),
|