@gaonjs/cli 0.58.1 → 0.59.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.
package/dist/index.d.ts CHANGED
@@ -100,5 +100,11 @@ export declare function readPortFlag(argv: readonly string[]): number | undefine
100
100
  export declare function parseServeArgs(argv: readonly string[]): ServeCommandOptions;
101
101
  /** `gaon dev` argv → DevCommandOptions. 포트 검증은 fail-loud(결정 240). */
102
102
  export declare function parseDevArgs(argv: readonly string[]): DevCommandOptions;
103
+ /**
104
+ * 결정 448: 명령 공통 `--help`/`-h` 판정. `gaon test -- <인자>` 의 `--` 뒤는
105
+ * vitest 전달분이라 제외한다(패스스루 계약 유지 — `gaon test -- --help` 는
106
+ * vitest 의 도움말이지 gaon 의 도움말이 아니다).
107
+ */
108
+ export declare function helpRequested(argv: readonly string[]): boolean;
103
109
  /** CLI 진입점. argv 는 실행 인자(process.argv.slice(2))를 받는다. */
104
110
  export declare function runCli(argv: readonly string[], opts?: RunOptions): void;
package/dist/index.js CHANGED
@@ -141,7 +141,7 @@ function renderHelp(version = VERSION) {
141
141
  " gaon db seed domain/seed.ts 실행 (M8)",
142
142
  " gaon --json 같은 정보를 JSON 으로 출력",
143
143
  " gaon --version 버전 출력",
144
- " gaon --help 이 도움말",
144
+ " gaon --help 이 도움말 (어느 명령 뒤에 붙여도 부팅 없이 이 도움말 · 결정 448)",
145
145
  "",
146
146
  " 문서: " + HOMEPAGE,
147
147
  "",
@@ -360,6 +360,19 @@ export function parseDevArgs(argv) {
360
360
  host,
361
361
  };
362
362
  }
363
+ /**
364
+ * 결정 448: 명령 공통 `--help`/`-h` 판정. `gaon test -- <인자>` 의 `--` 뒤는
365
+ * vitest 전달분이라 제외한다(패스스루 계약 유지 — `gaon test -- --help` 는
366
+ * vitest 의 도움말이지 gaon 의 도움말이 아니다).
367
+ */
368
+ export function helpRequested(argv) {
369
+ const sepIdx = argv.indexOf("--");
370
+ const before = (flag) => {
371
+ const i = argv.indexOf(flag);
372
+ return i >= 0 && (sepIdx < 0 || i < sepIdx);
373
+ };
374
+ return before("--help") || before("-h");
375
+ }
363
376
  /** CLI 진입점. argv 는 실행 인자(process.argv.slice(2))를 받는다. */
364
377
  export function runCli(argv, opts = {}) {
365
378
  const version = opts.version ?? VERSION;
@@ -370,6 +383,13 @@ export function runCli(argv, opts = {}) {
370
383
  // 명령이 추가될 때마다 또 빠지므로 여기서 공통화한다. loadDotEnv 는 이미
371
384
  // 설정된 env 를 덮지 않고 파일이 없으면 조용히 지나가 멱등하다(재호출 안전).
372
385
  loadDotEnv();
386
+ // 결정 448: `--help`/`-h` 는 **어느 명령에서든** 부팅·실행 대신 도움말을 낸다 —
387
+ // 종전엔 말미(명령 미매치 경로)에서만 처리해 `gaon serve --help`·`gaon hub --help`
388
+ // 가 실제 부팅을 시도했다(redis/NATS 미기동이면 접속 실패로 종료 · 실사용 보고).
389
+ if (helpRequested(argv)) {
390
+ process.stdout.write(renderHelp(version) + "\n");
391
+ return;
392
+ }
373
393
  // `gaon dev` — 통합 개발 오케스트레이션(M9-C · v0.15 §13.5). Docker Compose
374
394
  // 자동 기동 + .gaon 재생성 + serve·work·hub 자식(운영 3종 all-in-one · 결정 211)
375
395
  // + tsc/vue-tsc watch + 서버 재시작 워처. SIGINT/SIGTERM 시 순서대로 정리
@@ -39,6 +39,9 @@ export function channelScaffold(rawName, app, instance) {
39
39
  ` instance: true,`,
40
40
  ` // 인스턴스 단위 입장 판정 — ctx.instance 가 인스턴스 키(예: 매치 id·게시물 id)다.`,
41
41
  ` // 참가자 검증이 필요하면 DB 로 확인한다(예: 매치 참가자 테이블 조회).`,
42
+ ` // 함정(결정 447): 숫자 키를 DB id 로 파싱해 검증한다면 **정규형 비교**까지 —`,
43
+ ` // BigInt("042")·BigInt("0x2a") 도 42 라서, String(id) !== ctx.instance 면 거부해야`,
44
+ ` // 같은 방의 유령 인스턴스(로스터에 안 보이는 우회 접속)를 막는다(agents/realtime.md §2.7).`,
42
45
  ` authorize(ctx) {`,
43
46
  ` return ctx.user != null // 로그인 사용자만 — 공개 관전형이면 이 훅을 지운다`,
44
47
  ` },`,
@@ -209,10 +209,19 @@ import { MatchPlayer } from '../../../domain/models/MatchPlayer.js'
209
209
  export default channel({
210
210
  instance: true, // ← 파라미터화 선언
211
211
  async authorize(ctx) {
212
- // ctx.instance = 매치 id — 인스턴스 단위 입장 판정(참가자만)
212
+ // ctx.instance = 매치 id — 인스턴스 단위 입장 판정(참가자만).
213
+ // 결정 447: 숫자 키는 **정규형 비교까지** — BigInt("042")·BigInt("0x2a") 도 42 라서
214
+ // 파싱만 하면 같은 매치의 "유령 인스턴스"(격리는 따로, DB 는 같은 방)가 열린다.
213
215
  const u = ctx.user as { id: bigint } | null
214
216
  if (!u) return false
215
- return await MatchPlayer.where({ matchId: BigInt(ctx.instance), userId: u.id }).exists()
217
+ let matchId: bigint
218
+ try {
219
+ matchId = BigInt(ctx.instance)
220
+ } catch {
221
+ return false
222
+ }
223
+ if (String(matchId) !== ctx.instance) return false // 비정규 표기("042"·"0x2a"·공백) 거부
224
+ return await MatchPlayer.where({ matchId, userId: u.id }).exists()
216
225
  },
217
226
  onMessage(ctx, data) {
218
227
  // ctx.broadcast 는 자기 인스턴스(match:<id>)로 자동 스코프 — 다른 매치는 못 받는다
@@ -284,6 +293,13 @@ const draft = ref('')
284
293
  - **인스턴스 키는 임의 문자열이다**(128자 이하) — 매치 id·게시물 id 같은
285
294
  식별자를 그대로 쓴다. subject/KV 에는 identity 전체가 base64url 로 인코딩돼
286
295
  들어가므로 특수문자 주입 걱정이 없다.
296
+ - **숫자 키를 DB id 로 파싱해 검증한다면 정규형 비교까지(결정 447).** 격리는
297
+ 인스턴스 **문자열** 단위인데 `BigInt()`/`Number()` 파싱은 `"042"`·`"0x2a"`·
298
+ `" 42"` 를 전부 42 로 받으므로, 파싱 성공만 검사하면 인증 사용자가 같은 방의
299
+ **유령 인스턴스**를 열 수 있다 — 로스터·라이브에는 안 보이면서 `onMessage` 가
300
+ 같은 DB row(방 42)에 기록을 남긴다. 위 match 예시처럼 파싱 후
301
+ `String(id) !== ctx.instance → false` 로 정규형만 통과시킨다(uuid·slug 처럼
302
+ 문자열 그대로 조회하는 키는 해당 없음).
287
303
  - **`ctx.instance` 는 타입으로 갈린다** — `instance: true` 채널의 훅에서는
288
304
  `string`(항상 존재), 정적 채널에서는 `undefined`(string 으로 쓰면 컴파일 에러).
289
305
  - **`presenceOf(member)` 는 역방향 조회다** — 멤버(`user:<id>`·`conn:<uuid>`)가
@@ -353,7 +369,11 @@ import { instancesOf } from 'gaonjs/async'
353
369
  export default controller({
354
370
  async show() {
355
371
  // 영속 방이면 DB(Room.all())가, 일시적 방이면 instancesOf 가 첫 목록이다.
356
- return this.render('Lobby', { rooms: await instancesOf('match') })
372
+ // 결정 446: 인원 수·메타가 필요하면 옵션으로 동봉한다 — 방마다 presenceList
373
+ // 를 따로 부르는 N+1 을 만들지 말 것(counts 는 프레즌스 키 단일 스캔).
374
+ const rooms = await instancesOf('match', { counts: true })
375
+ // rooms = [{ instance: '42', members: 3 }, …] · { meta: true, counts: true } 조합도 된다
376
+ return this.render('Lobby', { rooms })
357
377
  },
358
378
  })
359
379
  ```
@@ -368,11 +388,11 @@ export default channel({})
368
388
  `room-closed` 를 반영한다 — 리스너의 `broadcast('lobby', …)` 단일 발행이 전
369
389
  서버 로비 구독자에게 팬아웃된다(§2.5).
370
390
 
371
- ### 2.9 룸 프리미티브 — 정원·kick·메타·입장순 (결정 444)
391
+ ### 2.9 룸 프리미티브 — 정원·kick·메타·입장순·멤버 이탈 (결정 444·445)
372
392
 
373
- 인스턴스 채널(§2.7) 위의 "방 운영" 표면 4종. 전부 도메인 중립이다 — 채팅
374
- (모더레이터 강퇴·방 인원 제한·방 규칙)과 게임(안티치트 축출·매치 정원·매치
375
- 설정)이 같은 프리미티브를 쓴다.
393
+ 인스턴스 채널(§2.7) 위의 "방 운영" 표면 5종. 전부 도메인 중립이다 — 채팅
394
+ (모더레이터 강퇴·방 인원 제한·방 규칙·방장 승계)과 게임(안티치트 축출·매치
395
+ 정원·매치 설정·호스트 승계)이 같은 프리미티브를 쓴다.
376
396
 
377
397
  **① 정원 `maxMembers` — 허브 원자 판정.** 정의에 선언하면 초과 입장이
378
398
  `4409` 로 거부된다(허브가 전역 로스터 단일 권위로 **동시 입장 race 없이**
@@ -424,7 +444,14 @@ await kick('room', `user:${userId}`, { instance: roomId, reason })
424
444
  async authorize(ctx) {
425
445
  const u = ctx.user as { id: bigint } | null
426
446
  if (!u) return false
427
- return !(await RoomBan.where({ roomId: BigInt(ctx.instance), userId: u.id }).exists())
447
+ let roomId: bigint
448
+ try {
449
+ roomId = BigInt(ctx.instance)
450
+ } catch {
451
+ return false
452
+ }
453
+ if (String(roomId) !== ctx.instance) return false // 정규형만(§2.7 유령 인스턴스 · 결정 447)
454
+ return !(await RoomBan.where({ roomId, userId: u.id }).exists())
428
455
  }
429
456
  ```
430
457
 
@@ -455,25 +482,60 @@ const rooms = await instancesOf('match', { meta: true }) // 로
455
482
  `presenceList`/`ctx.presence()`/클라 `members` 가 **입장순 정렬을 보장**
456
483
  하므로 "가장 먼저 들어온 남은 멤버" = `[0]` 이 전 서버 어디서나 결정적이다.
457
484
 
458
- **방장(호스트) 승계는 도메인 패턴이다** 프레임웍은 결정적 순서만 준다
459
- ("owner" 프리미티브 없음):
485
+ **⑤ `MemberLeft` 멤버 이탈의 race-free 앵커(결정 445).** 인스턴스 채널에서
486
+ 멤버가 **완전히 떠나면**(마지막 연결 소멸 — 멀티탭 중간 탭 닫힘은 침묵 · 서버
487
+ 프로세스 死 포함) 허브가 **로스터에서 제거한 뒤** 발행한다. 페이로드에 "누가
488
+ 나갔는가 + **제거가 반영된** 잔존 로스터(입장순)"가 실리므로, "떠난 뒤의
489
+ 로스터로 한 곳에서 결정" 하는 로직의 정본 앵커다. `InstanceOpened/Closed` 와
490
+ 같은 전달(워커 하나만 처리 · at-least-once — 핸들러 멱등):
460
491
 
461
492
  ```ts
462
- // 채팅 방장 / 게임 호스트 공통 골격: 메타 owner + 입장순 승격
463
- export default on(InstanceOpened, async ({ channel, instance }) => {
464
- if (channel !== 'match') return
465
- const first = (await presenceList('match', { instance }))[0] // 입장순 정렬 보장
466
- if (first) await setInstanceMeta('match', { owner: first.id }, { instance })
493
+ export default on(MemberLeft, async ({ channel, instance, member, roster }) => {
494
+ // roster = 제거 반영 후 잔존 멤버 [{ id, joinSeq? }] · joinSeq 오름차순.
495
+ // 빈 배열 = 마지막 이탈(InstanceClosed 발행되지만 컨슈머 축이 달라
496
+ // 상호 순서는 보장 없음 로스터 자체를 마지막 이탈 신호로 쓰라).
497
+ })
498
+ ```
499
+
500
+ - **채널 `onLeave` 안 `ctx.presence()` 로 이탈 후 로스터를 읽지 말 것** — leave
501
+ 는 훅 앞에서 **발신**될 뿐 KV 반영은 허브 비동기라, 떠나는 멤버 본인이 아직
502
+ 로스터에 보일 수 있다(로컬에선 대개 허브가 이겨 테스트는 통과하고, 부하·순단
503
+ 에서 뒤집히는 전형적 race — rooms 샘플 실측). 게다가 웹서버가 통째로 죽으면
504
+ `onLeave` 는 아예 돌지 않는다. 둘 다 `MemberLeft` 가 닫는다.
505
+ - **발화는 이탈만·인스턴스 채널만이다** — 입장 후 로직은 `onJoin`(연결)·
506
+ `InstanceOpened`(첫 점유)가 담당하고, 정적 채널은 전역 churn 고빈도 + 소멸
507
+ 앵커 부재로 발화하지 않는다(결정 442 스코프 동형).
508
+
509
+ **방장(호스트) 승계는 앱 도메인 패턴이다** — 프레임웍은 결정적 순서(joinSeq)와
510
+ race-free 앵커(MemberLeft)만 준다("owner" 프리미티브 없음):
511
+
512
+ ```ts
513
+ // domain/listeners/onRoomMemberLeft.ts — 채팅 방장 / 게임 호스트 공통 골격.
514
+ // 워커 하나만 처리하므로 멀티서버 동시 이탈에도 승계가 한 곳에서 결정된다.
515
+ import { on, MemberLeft, broadcast } from 'gaonjs/async'
516
+ import { Room } from '../models/Room.js'
517
+
518
+ export default on(MemberLeft, async ({ channel, instance, member, roster }) => {
519
+ if (channel !== 'room') return
520
+ if (roster.length === 0) return // 마지막 이탈 — 닫힘은 InstanceClosed 몫
521
+ const room = await Room.where('key', '=', instance).first()
522
+ if (!room) return
523
+ if (roster.some((m) => m.id === `user:${String(room.ownerId)}`)) return // 방장 건재
524
+ const next = roster[0] // 가장 먼저 들어온 남은 멤버(입장순 보장)
525
+ if (!next.id.startsWith('user:')) return
526
+ // 조건부 갱신(멱등) — at-least-once 재전달·경합에서 두 번 승격되지 않는다.
527
+ const changed = await Room.where('id', '=', room.id)
528
+ .where('ownerId', '=', room.ownerId)
529
+ .updateAll({ ownerId: BigInt(next.id.slice('user:'.length)) })
530
+ if (changed > 0) broadcast('room', { type: 'owner-changed', owner: next.id }, { instance })
467
531
  })
468
- // 승계(owner 이탈 감지 시 — 도메인 액션·리스너 어디서든):
469
- const roster = await presenceList('match', { instance })
470
- const next = roster[0] // 가장 먼저 들어온 남은 멤버
471
- if (next) {
472
- await setInstanceMeta('match', { owner: next.id }, { instance })
473
- broadcast('match', { type: 'owner-changed', owner: next.id }, { instance })
474
- }
475
532
  ```
476
533
 
534
+ 영속 방(위 예시)은 **DB 컬럼(rooms.ownerId)이 owner 의 정본**이다 — 메타
535
+ (`setInstanceMeta({ owner })`)는 닫힘 시 소멸하는 표시층이라, 방 row 가 남는
536
+ 도메인이면 DB 로 두고 메타는 로비 표시 등에만 쓴다. 일시적 방(row 없는 매치)
537
+ 이면 메타 owner 로 충분하다.
538
+
477
539
  ### 3. 프레즌스
478
540
 
479
541
  `ctx.presence()` 는 **전 서버의** 현재 접속자를 돌려준다. 목록의 권위는
@@ -757,9 +819,16 @@ export default channel({
757
819
  - **방 생성/삭제를 허브·채널 훅에서 직접 DB 로 밀지 말 것** — 훅(onJoin/onLeave)은
758
820
  연결 단위라 첫/마지막 판정이 서버 로컬에 갇히고(멀티서버에서 틀림), 허브는
759
821
  도메인을 모른다. 전역 첫/마지막은 생명주기 이벤트(§2.8)가 정답 경로다.
760
- 훅의 단위 경계 3층: **onJoin/onLeave = 연결(멀티탭이면 연결마다 ·
761
- `ctx.connectionId` 로 구분) / presence 델타 = 멤버(첫·마지막 연결) /
762
- InstanceOpened·Closed = 인스턴스(전역 점유·점유 0)**.
822
+ 훅의 단위 경계 3 + 멤버층 서버 이벤트: **onJoin/onLeave = 연결(멀티탭이면
823
+ 연결마다 · `ctx.connectionId` 로 구분) / presence 델타 = 멤버(첫·마지막 연결 ·
824
+ 클라 통지) / `MemberLeft` = 멤버 이탈의 서버측 앵커(§2.9 · 결정 445 ·
825
+ 로스터 반영 후 · 워커 1회 처리) / InstanceOpened·Closed = 인스턴스(전역 첫
826
+ 점유·점유 0)**.
827
+ - **이탈 후 로스터 판정을 `onLeave` + `ctx.presence()` 로 조립하지 말 것(결정 445)** —
828
+ leave 는 훅 앞에서 발신될 뿐 KV 반영은 허브 비동기라 이탈자 본인이 로스터에
829
+ 잔존할 수 있고(로컬에선 대개 통과하다 부하·순단에서 뒤집히는 race), 서버
830
+ 프로세스 死 경로에서는 훅이 아예 안 돈다. 방장 승계처럼 "떠난 뒤의 로스터로
831
+ 결정" 하는 로직은 `on(MemberLeft, …)` 리스너가 정본이다(§2.9 ⑤).
763
832
  - **`authorize` 안 `presenceList` 로 정원을 검사하지 말 것** — 웹서버 로컬 판정이라
764
833
  동시 입장 race 에서 초과 입장이 뚫린다. 정원은 **`maxMembers` 선언**(§2.9 ·
765
834
  허브 원자 판정 · 4409)이 정본이다.
@@ -803,6 +872,10 @@ export default channel({
803
872
  | 결정 440 | 파라미터화(인스턴스) 채널(§2.7) — `instance: true` 선언 · identity = `<이름>:<인스턴스>`(Phoenix topic 모델) · 전송 subject·프레즌스·인가·broadcast 네 축 identity 격리 · 역방향 인덱스(`member.<멤버>.<채널>`)로 `presenceOf(member)` 한 번 스캔 · `presenceList(name, {instance})` 서버 개시 로스터 · 선언·연결 부정합 = `4400` fail-loud(재연결 없음) · doctor `channel-instance-authorize` 경고 · 정적 채널은 identity=이름 그대로(와이어·KV 무변경) |
804
873
  | 결정 442 | 소켓 앱 DX 표면 완성(§2.8) — 인스턴스 생명주기 이벤트 `InstanceOpened`/`InstanceClosed`(허브 발행 · 활성 인스턴스 인덱스 `chan.<이름>.<인스턴스>` 가 dedup 앵커 · 리스너 durable 공유로 워커 하나만 처리) · `instancesOf(name)` 채널→활성 인스턴스 목록(로비 첫 렌더) · 로비 = 일반 채널 + `broadcast` 패턴(전용 API 신설 없음) · `gaon g channel <이름> [--instance]` 스캐폴드(정의 + 컴포저블) |
805
874
  | 결정 444 | 룸 프리미티브 Wave 2(§2.9) — 정원 `maxMembers`(허브 원자 판정 joinAck · 초과 `4409` 종단 · 멤버 수 기준 멀티탭 통과) · `kick(name, member, {instance, reason})`(userSubject 제어 봉투 · `4403` 종단 + `onKicked` · 반환 = present 수 · 마지막 멤버면 closed 자동) · ban = 3단 문서 패턴(DB + authorize + kick · 프리미티브 없음) · 인스턴스 메타(`instanceMeta` 훅 = open 앵커 1회 기록 · `setInstanceMeta` LWW 갱신 · 닫힘 자동 삭제 · 4096B · 정적 채널 미지원) · `joinSeq` 입장 순번(허브 부여 · `presenceList`/`members` 입장순 정렬 보장 · 방장 승계 패턴 기반) · `ctx.connectionId` |
875
+ | 결정 445 | `MemberLeft` 멤버 이탈 이벤트(§2.9 ⑤) — 허브가 로스터 **제거 후** 발행(제거 반영 로스터 동봉 · 입장순) · 마지막 연결에서만(멀티탭 침묵) · graceful·synced 대사·서버 死(cleanupServer)·failover 회수 전 경로 단일 수렴점(delMember) · 인스턴스 채널 한정·이탈만(444(F) 볼륨 우려 수용) · 워커 1회 처리(442 전달 동형) · 방장 승계 정본 앵커("onLeave + ctx.presence() 재조회" 패턴은 leave 발신≠반영 race + 서버 死 미커버로 반정본 명문화 — rooms 샘플 실측) |
876
+ | 결정 446 | `instancesOf(name, { counts: true })`(§2.8) — 인스턴스별 **멤버 수** 동봉(프레즌스 키 단일 스캔 · presenceStats 동형) · `{ meta: true, counts: true }` 조합 지원 · 로비 인원 수 per-인스턴스 `presenceList` N+1 제거 · 기존 시그니처 불변 |
877
+ | 결정 447 | 인스턴스 키 정규형 비교 정본화(§2.7·§2.9 ban) — 숫자 키를 `BigInt()`/`Number()` 로 파싱해 검증하는 authorize 는 `String(id) !== ctx.instance → false` 정규형 가드까지(비정규 표기 "042"·"0x2a" 가 로스터에 안 보이는 유령 인스턴스로 같은 DB row 에 기록을 남기는 우회 차단) · 프레임웍 자동 정규화 기각(키는 앱 정의 불투명 문자열 — uuid·slug 는 파싱 무관) |
878
+ | 결정 448 | CLI 명령 공통 `--help`(`packages/cli`) — `gaon serve --help`·`gaon hub --help` 가 부팅을 시도하던 것을 디스패치 전 공통 처리로 봉합 · `gaon test -- <인자>` 의 `--` 뒤는 패스스루 보존 |
806
879
 
807
880
  ## `@gaonjs/seal` 켠 앱의 채널
808
881
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gaonjs/cli",
3
- "version": "0.58.1",
3
+ "version": "0.59.0",
4
4
  "description": "Gaon CLI — 스캐폴딩·제너레이터·마이그레이션·dev/serve/work/hub·doctor·check (bin: gaon)",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -32,13 +32,13 @@
32
32
  "@modelcontextprotocol/sdk": "^1.29.0",
33
33
  "typescript": "^5.9.0",
34
34
  "vite": "^7.0.0",
35
- "@gaonjs/async": "0.21.0",
36
- "@gaonjs/config": "0.25.5",
37
- "@gaonjs/data": "0.25.3",
38
- "@gaonjs/i18n": "0.3.1",
35
+ "@gaonjs/async": "0.22.0",
39
36
  "@gaonjs/core": "0.3.0",
37
+ "@gaonjs/config": "0.25.6",
38
+ "@gaonjs/i18n": "0.3.1",
39
+ "@gaonjs/data": "0.25.3",
40
40
  "@gaonjs/mail": "0.5.1",
41
- "@gaonjs/web": "0.31.1"
41
+ "@gaonjs/web": "0.31.2"
42
42
  },
43
43
  "scripts": {
44
44
  "build": "node ../../node_modules/typescript/bin/tsc -p tsconfig.json && node -e \"const fs=require('fs');fs.cpSync('src/templates','dist/templates',{recursive:true,filter:(s)=>!s.endsWith('.ts')});fs.rmSync('dist/templates/index.ts',{force:true})\""