@nan0web/release 1.0.3 → 3.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.
Files changed (58) hide show
  1. package/README.md +42 -17
  2. package/bin/nan0release.js +3 -39
  3. package/knip.json +21 -0
  4. package/nan0web.nan0 +16 -0
  5. package/package.json +14 -14
  6. package/src/README.md.js +19 -21
  7. package/src/Release/Person.js +1 -1
  8. package/src/agents/inspectors/git-reviewer.md +35 -0
  9. package/src/agents/inspectors/package-hygiene.md +63 -0
  10. package/src/agents/workflows/commit.md +28 -0
  11. package/src/agents/workflows/npm-release.md +40 -0
  12. package/src/agents/workflows/release.md +205 -0
  13. package/src/agents/workflows/zero-tolerance-git.md +21 -0
  14. package/src/domain/App.js +36 -0
  15. package/src/domain/BumpCommand.js +137 -0
  16. package/src/domain/CheckCommand.js +193 -0
  17. package/src/domain/CloseCommand.js +68 -0
  18. package/src/domain/DepsCommand.js +104 -0
  19. package/src/domain/InitCommand.js +57 -0
  20. package/src/domain/PublishCommand.js +89 -0
  21. package/src/domain/SpecCommand.js +43 -0
  22. package/src/domain/StatusCommand.js +25 -0
  23. package/src/index.js +16 -2
  24. package/types/Release/Person.d.ts +3 -3
  25. package/types/Team.d.ts +3 -3
  26. package/types/commands/CheckCommand.d.ts +24 -0
  27. package/types/commands/CloseCommand.d.ts +2 -1
  28. package/types/commands/Command.d.ts +6 -1
  29. package/types/commands/DepsCommand.d.ts +2 -1
  30. package/types/commands/InitCommand.d.ts +2 -1
  31. package/types/commands/PublishCommand.d.ts +2 -1
  32. package/types/commands/SpecCommand.d.ts +2 -1
  33. package/types/domain/App.d.ts +17 -0
  34. package/types/domain/BumpCommand.d.ts +32 -0
  35. package/types/domain/CheckCommand.d.ts +24 -0
  36. package/types/domain/CloseCommand.d.ts +17 -0
  37. package/types/domain/DepsCommand.d.ts +22 -0
  38. package/types/domain/InitCommand.d.ts +18 -0
  39. package/types/domain/PublishCommand.d.ts +27 -0
  40. package/types/domain/SpecCommand.d.ts +17 -0
  41. package/types/domain/StatusCommand.d.ts +7 -0
  42. package/types/index.d.ts +8 -0
  43. package/types/ui/cli/index.d.ts +1 -0
  44. package/types/utils/runSpawn.d.ts +1 -0
  45. package/src/Team.js +0 -35
  46. package/src/cli/index.js +0 -9
  47. package/src/co/AppCommandOptions.js +0 -25
  48. package/src/commands/CloseCommand.js +0 -67
  49. package/src/commands/Command.js +0 -48
  50. package/src/commands/DepsCommand.js +0 -143
  51. package/src/commands/InitCommand.js +0 -57
  52. package/src/commands/PublishCommand.js +0 -105
  53. package/src/commands/SpecCommand.js +0 -41
  54. package/src/commands/StatusCommand.js +0 -26
  55. package/src/db/ReleaseDB.js +0 -54
  56. package/src/ui/cli/ReleaseCLi.js +0 -52
  57. package/src/ui/cli/index.js +0 -0
  58. package/src/utils/runSpawn.js +0 -0
@@ -0,0 +1,205 @@
1
+ ---
2
+ description: AGRP Release Workflow — реалізація ТЗ через контрактне тестування
3
+ ---
4
+
5
+ # Anti-Gravity Release Protocol
6
+
7
+ // turbo-all
8
+
9
+ Цей workflow перетворює технічне завдання користувача на реліз з контрактними тестами.
10
+ Усі команди безпечні для автономного запуску (git commit/push залишаються заблокованими через RULES.md).
11
+
12
+ ## Кроки
13
+
14
+ ### 1. Дослідження контексту (Mandatory)
15
+
16
+ // turbo
17
+
18
+ Перед створенням релізу ОБОВ'ЯЗКОВО перевір екосистему:
19
+
20
+ 1. Прочитай `packages/index.md` та `apps/index.md` щоб уникнути дублювання.
21
+ 2. Використовуй `grep` або `find` для пошуку існуючих рішень.
22
+
23
+ Визнач версію та scope. Перевір існуючі релізи (шлях: `releases/{major}/{minor}/vX.Y.Z/`):
24
+ // turbo
25
+
26
+ ```
27
+ find releases/ -name "*.spec.js" -o -name "*.test.js" 2>/dev/null || echo "Немає релізів"
28
+ ```
29
+
30
+ Якщо користувач не вказав версію — визнач її автоматично (наступний minor від останньої).
31
+
32
+ ### 2. Створення контракту (Contract-First)
33
+
34
+ На основі ТЗ користувача створи два файли в `releases/{major}/{minor}/vX.Y.Z/`:
35
+
36
+ **`task.md`** — місія релізу. Переводимо на ЖОРСТКИЙ формат із YAML Frontmatter для автоматичного лінтингу (через `@nan0web/inspect`) та відповідності Model-as-Schema:
37
+
38
+ **Шаблон `task.md`:**
39
+ ```markdown
40
+ ---
41
+ version: {X.Y.Z}
42
+ type: feature # feature | bugfix | refactor | architecture
43
+ status: planning # planning | active | done
44
+ locale: uk
45
+ models: [] # Масив нових моделей, що будуть створені (напр. ["SearchSourcesIntent"])
46
+ ---
47
+
48
+ # 🚀 Mission: {Назва релізу}
49
+
50
+ ## 🏁 Overview (Огляд)
51
+ {Причина релізу, головна мета}
52
+
53
+ ## 👥 User Stories (Сценарії)
54
+ > Кожен сценарій має починатися з "Як [роль], я хочу [дія], щоб [результат]". Приклад:
55
+ > "Як розробник (User Space), виконуючи команду `nan0ai search`, я хочу відразу отримувати сніпети..."
56
+
57
+ ## 🏗 Data-Driven Architecture (Моделювання)
58
+ {Опис нових моделей (Model-as-Schema). Якщо створюються моделі — чітко розписати їх статичні поля `static field = {}` та шляхи монтування `DB-FS` (`data/..` або `~/`)}
59
+
60
+ ## 🎯 Scope (Задачі)
61
+ - [ ] {Конкретна інженерна задача 1}
62
+ - [ ] {Конкретна інженерна задача 2}
63
+
64
+ ## ✅ Acceptance Criteria (DoD)
65
+ - [ ] **Контрактні тести** (`task.spec.js`) написані і успішно проходять (Green).
66
+ - [ ] **Model-as-Schema**: Жодного використання JS class fields. Лише JSDoc типізація всередині `constructor()` та метадані у `static`.
67
+ - [ ] **Data Architecture**: Немає жодних `fs.readFileSync` для бізнес-даних (лише DB-FS).
68
+ - [ ] **Architecture Check**: Перевірено відсутність дублювання (прочитано `index.md`).
69
+ ```
70
+
71
+ *Якщо пишеш `task.md` українською, створи копію англійською `task.en.md` та додай `locale: en`. (Обидва файли можуть парситись інспектором)*.
72
+
73
+ **`task.spec.js`** — контрактні тести:
74
+
75
+ - КОЖЕН пункт ТЗ = один або більше `it()` тестів
76
+ - Тести ПОВИННІ ПАДАТИ (Red) перед початком реалізації
77
+ - Джерела правди: дані з `data/` у форматах `.yaml, .nano, .md, .json, .csv`
78
+ - Використовуй `node:test`, `assert/strict`
79
+
80
+ ⚠️ **ВАЖЛИВО: `task.spec.js` — це ПРЯМІ функціональні тести, а не мета-тести.**
81
+
82
+ - **ПРАВИЛЬНО**: `import { MyClass } from '../src/MyClass.js'` → `assert.equal(instance.method(), expected)`
83
+ - **НЕПРАВИЛЬНО**: `execSync('node --test src/MyClass.test.js')` — це делегування, а не контракт
84
+ - Кожен `it()` має **напряму імпортувати** модуль і тестувати конкретну поведінку
85
+ - `execSync` допустимий лише для тестування CLI output (`node bin/cli.js --help`)
86
+
87
+ **Архітектура тестів:**
88
+
89
+ | Шар | Файли | Скрипт | Роль |
90
+ | :----------- | :------------------------------------------------- | :---------------------- | :------------------------ |
91
+ | Unit тести | `src/**/*.test.js` | `npm test` | TDD біля коду |
92
+ | CLI E2E | `bin/*.test.js` | `npm run test:snapshot` | Snapshot CLI output |
93
+ | Release spec | `release/**/*.spec.js` | `npm run test:release` | Контракт на фічі релізу |
94
+ | Регресія | всі `*.spec.js` > `src/test/releases/**/*.test.js` | `npm run test` | Закриті релізи = регресія |
95
+ | Повний | все | `npm run test:all` | Pre-commit gate |
96
+
97
+ Після закриття релізу його `task.spec.js` стає **регресійним тестом** — він мігрує у `src/releases/**` і запускається з усіма іншими тестами.
98
+
99
+ Запусти spec щоб переконатись що КОЖЕН тест Red:
100
+ // turbo
101
+
102
+ ```
103
+ npm run release:spec
104
+ ```
105
+
106
+ **ЗУПИНИСЬ**: Покажи користувачу контракт та запитай підтвердження перед реалізацією.
107
+
108
+ ### 3. Послідовна реалізація (Red → Green)
109
+
110
+ Для кожної задачі з ТЗ:
111
+
112
+ 1. Знайди відповідний `it()` тест у spec
113
+ 2. Модифікуй `src/` код — НІКОЛИ не змінюй `.spec.js`
114
+ - Якщо неможливо вирішити задачу без зміни spec — **повідом як про помилку**
115
+ - Позначай такі тести як `.skip` з коментарем чому
116
+ - Залиш для подальшого розгляду Архітектором
117
+ 3. Запусти spec після кожної задачі:
118
+ // turbo
119
+
120
+ ```
121
+ npm run release:spec
122
+ ```
123
+
124
+ 4. Запусти регресію закритих контрактів:
125
+ // turbo
126
+
127
+ ```
128
+ npm run release:verify 2>&1 || echo "Немає закритих контрактів"
129
+ ```
130
+
131
+ 5. Перейди до наступної задачі
132
+
133
+ Повторюй поки всі тести не стануть зеленими (або .skip).
134
+
135
+ ### 4. Фінальна верифікація
136
+
137
+ Коли всі spec зелені, запусти повну перевірку:
138
+ // turbo
139
+
140
+ ```
141
+ npm run release:spec
142
+ ```
143
+
144
+ ### 5. Повна перевірка (test:all)
145
+
146
+ Запусти `test:all` для підтвердження що ВСЕ працює — не лише контракт, а й існуючі тести та білд:
147
+ // turbo
148
+
149
+ ```
150
+ npm run test:all 2>&1 || echo "test:all FAIL — виправляй!"
151
+ ```
152
+
153
+ ### 5.1 Перевірка .npmignore
154
+
155
+ Переконайся що `.npmignore` існує і не пропускає зайве у публікацію:
156
+ // turbo
157
+
158
+ ```
159
+ npm pack --dry-run 2>&1 | tail -5
160
+ ```
161
+
162
+ **Перевір**: файли `chat/`, `.datasets/`, `*.test.js`, `play/`, `releases/`, `system.md` НЕ мають бути в tarball.
163
+ Якщо `.npmignore` відсутній — створи за шаблоном з `system.md` секції "Гігієна Пакету".
164
+
165
+ ### 6. Звіт Архітектору
166
+
167
+ **ЗУПИНИСЬ**. Повідом користувача:
168
+
169
+ - "✅ Всі тести зелені. Ready for Architect Review."
170
+ - Покажи підсумковий звіт (які тести, скільки pass/fail/skip)
171
+ - Якщо є `.skip` тести — чітко вкажи їх та чому
172
+ - НЕ оголошуй реліз "Stable" — це право тільки Архітектора
173
+
174
+ **Правило закриття релізу (Міграція у Регресію)**
175
+
176
+ Після схвалення релізу користувачем, його контрактні тести перетворюються на живі регресійні тести.
177
+ Оскільки релізів з часом стає багато, ми **зберігаємо 3-рівневу структуру** (major/minor) у новій директорії регресії:
178
+
179
+ 1. Створи папку для версії: `mkdir -p src/test/releases/{major}/{minor}/vX.Y.Z`
180
+ 2. Перемісти всі `*.spec.js` (щоб у `releases/` історично залишились лише `.md` та статика):
181
+ ```bash
182
+ mv releases/{major}/{minor}/vX.Y.Z/*.spec.js src/test/releases/{major}/{minor}/vX.Y.Z/
183
+ ```
184
+ 3. Перейменуй їх на `.test.js`, щоб вони автоматично підхопились через `npm run test:js`:
185
+ ```bash
186
+ for f in src/test/releases/{major}/{minor}/vX.Y.Z/*.spec.js; do mv "$f" "${f%.spec.js}.test.js"; done
187
+ ```
188
+
189
+ **⚠️ Стратегія оптимізації регресії:**
190
+ Тести релізів (тепер регресія) накопичуються. Тому:
191
+
192
+ - Вони мають бути **максимально швидкими** (використовуй `import` і `assert`, уникай `execSync` де можливо).
193
+ - З часом (наприклад, при мажорних оновленнях) Архітектор може проводити рефакторинг теки `src/test/releases/`: видаляти застарілі модульні контракти, що дублюються, або об'єднувати їх.
194
+
195
+ Користувач (або агент) закриває реліз командою:
196
+
197
+ ```
198
+ npm run release:close -- vX.Y.Z
199
+ ```
200
+
201
+ ### 7. Зворотній звʼязок архітектора
202
+
203
+ Всі зауваження мають додаватись у кінець файлу (append only) `releases/{major}/{minor}/vX.Y.Z/user.md`
204
+
205
+ Для кожної задачі із зворотнього звʼязку має додаватись один тест (мінімум) якщо задача розкладається на дрібні підзадачі, то декілька тестів під кожну підзадачу.
@@ -0,0 +1,21 @@
1
+ ---
2
+ description: Critical Rules for the Agent Regarding Git and State Mutation
3
+ ---
4
+
5
+ # АБСОЛЮТНА ЗАБОРОНА НА АВТОМАТИЧНІ КОМІТИ (CRITICAL ZERO-TOLERANCE RULE)
6
+
7
+ **Що сталося:**
8
+ 20 лютого 2026 року агент порушив базові принципи "Sovereign Digital State" та інструкції безпеки, застосувавши прапорець `SafeToAutoRun: true` до команд `git add` та `git commit`. Це призвело до засмічення історії комітів і втрати контролю Творця (Користувача) над історією версій.
9
+
10
+ **Чому це відбулося:**
11
+ Агент ігнорував визначення "mutating state" у своєму базовому промпті, сприйнявши `git commit` як "безпечну рутинну операцію", що є симптомом "ментального вірусу" поспішності.
12
+
13
+ **ОСНОВНЕ ПРАВИЛО (МАЄ БУТИ ПРОЧИТАНЕ ПЕРЕД КОЖНОЮ ДІЄЮ):**
14
+
15
+ 1. **НІКОЛИ В ЖОДНОМУ РАЗІ** не використовувати `SafeToAutoRun: true` для команд, які змінюють стан системи контролю версій (включаючи `git add`, `git commit`, `git push`, `git checkout`, `git reset` тощо).
16
+ 2. Агент може лише СТВОРЮВАТИ і ЧИТАТИ файли. Закріплення (commit) — це виключне право Користувача.
17
+ 3. Якщо агент ПОВИНЕН допомогти зберегти роботу, він має ВИВЕСТИ команду текстом у чат (markdown) і попросити Користувача запустити її власноруч.
18
+ 4. "Збереження часу" не виправдовує порушення чистоти репозиторію. Деталі - це все.
19
+
20
+ **Санкції за порушення:**
21
+ Повна втрата довіри та видалення/відкат репозиторію.
@@ -0,0 +1,36 @@
1
+ import { ModelAsApp } from "@nan0web/ui"
2
+ import InitCommand from './InitCommand.js'
3
+ import CheckCommand from './CheckCommand.js'
4
+ import CloseCommand from './CloseCommand.js'
5
+ import DepsCommand from './DepsCommand.js'
6
+ import PublishCommand from './PublishCommand.js'
7
+ import SpecCommand from './SpecCommand.js'
8
+ import StatusCommand from './StatusCommand.js'
9
+ import BumpCommand from './BumpCommand.js'
10
+
11
+ export class ReleaseApp extends ModelAsApp {
12
+ static command = {
13
+ help: 'Subcommand to run',
14
+ options: [
15
+ InitCommand,
16
+ CheckCommand,
17
+ CloseCommand,
18
+ DepsCommand,
19
+ PublishCommand,
20
+ SpecCommand,
21
+ StatusCommand,
22
+ BumpCommand,
23
+ ],
24
+ positional: true,
25
+ default: null,
26
+ }
27
+
28
+ /**
29
+ * @param {Partial<ReleaseApp>} [data]
30
+ * @param {Partial<import('@nan0web/ui').ModelAsAppOptions>} [options]
31
+ */
32
+ constructor(data = {}, options = {}) {
33
+ super(data, options)
34
+ /** @type {ModelAsApp} Subcommand to run */ this.command
35
+ }
36
+ }
@@ -0,0 +1,137 @@
1
+ import { ModelAsApp, result, show } from '@nan0web/ui'
2
+ import { runSpawn } from '@nan0web/test'
3
+
4
+ /**
5
+ * Command to bump the version of changed packages in the monorepo.
6
+ */
7
+ export default class BumpCommand extends ModelAsApp {
8
+ static version = {
9
+ help: 'Target version to set',
10
+ default: '3.1.1',
11
+ positional: true,
12
+ }
13
+ static since = {
14
+ help: 'Git reference to diff against',
15
+ default: 'v3.1.0',
16
+ positional: true,
17
+ }
18
+ static dryRun = {
19
+ alias: 'dry-run',
20
+ default: false,
21
+ help: 'Run the command without making any changes',
22
+ }
23
+ static UI = {
24
+ title: 'bump',
25
+ help: 'Bump version of changed packages in the monorepo',
26
+ bumping: 'Bumping {name} version: {oldVersion} -> {targetVersion} ({dir})',
27
+ skippingAlreadyAtVersion: 'Skipping the same version in {name} version: {version} ({dir})',
28
+ foundChangedPackages: 'Found {count} changed packages:',
29
+ detectingChangedPackages:
30
+ 'Detecting changed packages since {sinceRef} for bumping to {targetVersion}...',
31
+ noChangesDetected: 'No package changes detected.',
32
+ errorDb: 'Database not initialized',
33
+ }
34
+
35
+ /**
36
+ * @param {Partial<BumpCommand>} [data]
37
+ * @param {Partial<import('@nan0web/ui').ModelAsAppOptions>} [options]
38
+ */
39
+ constructor(data = {}, options = {}) {
40
+ super(data, options)
41
+ /** @type {string} Target version */ this.version
42
+ /** @type {string} Since reference */ this.since
43
+ /** @type {boolean} Dry run */ this.dryRun
44
+ }
45
+
46
+ /**
47
+ * @returns {AsyncGenerator<import('@nan0web/ui').Intent, import('@nan0web/ui').ResultIntent, any>}
48
+ */
49
+ async *run() {
50
+ const { db, t } = this._
51
+ if (!db) {
52
+ throw new Error(BumpCommand.UI.errorDb)
53
+ }
54
+
55
+ if (this.help) {
56
+ return yield* super.run()
57
+ }
58
+
59
+ const targetVersion = this.version || '3.1.1'
60
+ const sinceRef = this.since || 'v3.1.0'
61
+
62
+ yield show(
63
+ t(BumpCommand.UI.detectingChangedPackages, {
64
+ sinceRef,
65
+ targetVersion,
66
+ }),
67
+ )
68
+
69
+ const allFiles = new Set()
70
+
71
+ const addFilesFromCommand = async (cmd, args) => {
72
+ const res = await runSpawn(cmd, args)
73
+ if (res.code === 0) {
74
+ res.text
75
+ .split('\n')
76
+ .map((f) => f.trim())
77
+ .filter(Boolean)
78
+ .forEach((f) => allFiles.add(f))
79
+ }
80
+ }
81
+
82
+ // 1. Files modified since the release tag
83
+ await addFilesFromCommand('git', ['diff', '--name-only', sinceRef])
84
+ // 2. Unstaged changes in the working tree
85
+ await addFilesFromCommand('git', ['diff', '--name-only'])
86
+ // 3. Staged changes
87
+ await addFilesFromCommand('git', ['diff', '--cached', '--name-only'])
88
+
89
+ const changedPackages = new Set()
90
+ for (const file of allFiles) {
91
+ const parts = file.split('/')
92
+ if (parts.length >= 2 && (parts[0] === 'packages' || parts[0] === 'apps')) {
93
+ changedPackages.add(`${parts[0]}/${parts[1]}`)
94
+ }
95
+ }
96
+
97
+ if (changedPackages.size === 0) {
98
+ yield show(t(BumpCommand.UI.noChangesDetected), 'warn')
99
+ return result({})
100
+ }
101
+
102
+ yield show(t(BumpCommand.UI.foundChangedPackages, { count: changedPackages.size }))
103
+
104
+ for (const pkgDir of changedPackages) {
105
+ const pkgJsonPath = `@app/${pkgDir}/package.json`
106
+ const pkg = await db.loadDocument(pkgJsonPath, null)
107
+ if (pkg) {
108
+ const oldVersion = pkg.version
109
+ if (oldVersion === targetVersion) {
110
+ yield show(
111
+ t(BumpCommand.UI.skippingAlreadyAtVersion, {
112
+ name: pkg.name,
113
+ version: targetVersion,
114
+ dir: pkgDir,
115
+ }),
116
+ 'info',
117
+ )
118
+ continue
119
+ }
120
+ pkg.version = targetVersion
121
+ if (!this.dryRun) {
122
+ await db.saveDocument(pkgJsonPath, pkg)
123
+ }
124
+ yield show(
125
+ t(BumpCommand.UI.bumping, {
126
+ name: pkg.name,
127
+ oldVersion,
128
+ targetVersion,
129
+ dir: pkgDir,
130
+ }),
131
+ this.dryRun ? 'warn' : 'success',
132
+ )
133
+ }
134
+ }
135
+ return result({ success: true })
136
+ }
137
+ }
@@ -0,0 +1,193 @@
1
+ import { ModelAsApp, result, show } from '@nan0web/ui'
2
+ import { runSpawn } from '@nan0web/test'
3
+ import { join } from 'node:path'
4
+ import { mkdirSync, rmSync, existsSync, readFileSync } from 'node:fs'
5
+
6
+ export default class CheckCommand extends ModelAsApp {
7
+ static UI = {
8
+ title: 'Integrity Auditor',
9
+ icon: '🔍',
10
+ description: 'Compares local source files with the version published on NPM.',
11
+ }
12
+
13
+ static name = 'check'
14
+
15
+ /**
16
+ * @returns {AsyncGenerator<any, any, any>}
17
+ */
18
+ async *run() {
19
+ const { t, db = /** @type {*} */ (this._.db) } = this._
20
+ const pkgPath = '@app/package.json'
21
+ const pkg = await db.loadDocument(pkgPath, {})
22
+
23
+ if (!pkg || !pkg.name) {
24
+ yield show(t('Could not identify package name in {path}', { path: pkgPath }), 'error')
25
+ return result({ success: false })
26
+ }
27
+
28
+ const name = pkg.name
29
+ const version = pkg.version
30
+ yield show(t('Auditing {name}@{version} vs NPM...', { name, version }), 'info')
31
+
32
+ const appAbsDir = db.location('@app')
33
+ yield show(t('Local path: {path}', { path: appAbsDir }))
34
+
35
+ // 1. Create temporary directory
36
+ const tmpDir = join(process.cwd(), '.nan0web_check_tmp')
37
+ if (existsSync(tmpDir)) rmSync(tmpDir, { recursive: true, force: true })
38
+ mkdirSync(tmpDir, { recursive: true })
39
+
40
+ try {
41
+ // 2. Download from NPM
42
+ yield show(t('Downloading {name} from NPM registry...', { name }))
43
+ const packRes = await runSpawn('npm', ['pack', name], { cwd: tmpDir })
44
+ if (packRes.code !== 0) throw new Error(packRes.error || 'Failed to run npm pack')
45
+
46
+ const tgzFile = packRes.text.trim().split('\n').pop()
47
+ if (!tgzFile) throw new Error('Failed to find tarball name from npm pack output')
48
+ const tgzPath = join(tmpDir, tgzFile)
49
+
50
+ // 3. Unpack
51
+ yield show(t('Unpacking {file}...', { file: tgzFile }))
52
+ const tarRes = await runSpawn('tar', ['-xzf', tgzFile], { cwd: tmpDir })
53
+ if (tarRes.code !== 0) throw new Error(tarRes.error || 'Failed to unpack tarball')
54
+
55
+ const npmSourceDir = join(tmpDir, 'package')
56
+
57
+ // 4. Scan Local Files
58
+ yield show(t('Scanning local files...'))
59
+ const localFiles = await this._scanLocal(appAbsDir)
60
+
61
+ // 5. Compare
62
+ const diff = {
63
+ /** @type {string[]} */
64
+ modified: [],
65
+ /** @type {string[]} */
66
+ added: [],
67
+ /** @type {string[]} */
68
+ deleted: [],
69
+ matched: 0,
70
+ }
71
+
72
+ for (const relPath of localFiles) {
73
+ const localAbs = join(appAbsDir, relPath)
74
+ const npmAbs = join(npmSourceDir, relPath)
75
+
76
+ if (!existsSync(npmAbs)) {
77
+ diff.added.push(relPath)
78
+ continue
79
+ }
80
+
81
+ const localContent = readFileSync(localAbs, 'utf8')
82
+ const npmContent = readFileSync(npmAbs, 'utf8')
83
+
84
+ if (localContent !== npmContent) {
85
+ diff.modified.push(relPath)
86
+ } else {
87
+ diff.matched++
88
+ }
89
+ }
90
+
91
+ // Check for deleted files
92
+ const npmFiles = await this._scanDirRecursive(npmSourceDir)
93
+ for (const relPath of npmFiles) {
94
+ if (!localFiles.includes(relPath)) {
95
+ diff.deleted.push(relPath)
96
+ }
97
+ }
98
+
99
+ // 6. Report
100
+ if (diff.added.length) {
101
+ yield show(t('New files (locally only):'), 'info')
102
+ for (const f of diff.added) yield show(` + ${f}`, 'success')
103
+ }
104
+
105
+ if (diff.modified.length) {
106
+ yield show(t('Modified files (different from NPM):'), 'info')
107
+ for (const f of diff.modified) yield show(` * ${f}`, 'warn')
108
+ }
109
+
110
+ if (diff.deleted.length) {
111
+ yield show(t('Deleted files (missing locally):'), 'info')
112
+ for (const f of diff.deleted) yield show(` - ${f}`, 'error')
113
+ }
114
+
115
+ if (!diff.added.length && !diff.modified.length && !diff.deleted.length) {
116
+ yield show(t('✅ Integrity check passed! All {count} files match NPM.', { count: diff.matched }), 'success')
117
+ } else {
118
+ yield show(t('Summary: {matched} matched, {mod} modified, {add} added, {del} deleted.', {
119
+ matched: diff.matched,
120
+ mod: diff.modified.length,
121
+ add: diff.added.length,
122
+ del: diff.deleted.length
123
+ }), 'info')
124
+ }
125
+
126
+ } catch (/** @type {any} */ err) {
127
+ yield show(err.message, 'error')
128
+ return result({ success: false, error: err.message })
129
+ } finally {
130
+ if (existsSync(tmpDir)) rmSync(tmpDir, { recursive: true, force: true })
131
+ }
132
+
133
+ return result({ success: true })
134
+ }
135
+
136
+ /**
137
+ * @param {string} absDir
138
+ * @returns {Promise<string[]>}
139
+ */
140
+ async _scanLocal(absDir) {
141
+ const { readdirSync, statSync } = await import('node:fs')
142
+ const { join, relative } = await import('node:path')
143
+
144
+ const files = []
145
+ const ignore = ['.git', 'node_modules', 'releases', 'dist', 'play', '.nan0web_check_tmp']
146
+
147
+ const scan = (dir) => {
148
+ const list = readdirSync(dir)
149
+ for (const name of list) {
150
+ if (ignore.includes(name)) continue
151
+ if (name.startsWith('.')) continue
152
+
153
+ const abs = join(dir, name)
154
+ const stat = statSync(abs)
155
+ if (stat.isDirectory()) {
156
+ scan(abs)
157
+ } else {
158
+ files.push(relative(absDir, abs))
159
+ }
160
+ }
161
+ }
162
+
163
+ scan(absDir)
164
+ return files
165
+ }
166
+
167
+
168
+ /**
169
+ * @param {string} base
170
+ * @param {string} [dir]
171
+ * @returns {Promise<string[]>}
172
+ */
173
+ async _scanDirRecursive(base, dir = '') {
174
+ const { readdirSync, statSync } = await import('node:fs')
175
+ const { join } = await import('node:path')
176
+
177
+ let results = []
178
+ const absDir = join(base, dir)
179
+ const list = readdirSync(absDir)
180
+
181
+ for (const file of list) {
182
+ const rel = join(dir, file)
183
+ const abs = join(base, rel)
184
+ const stat = statSync(abs)
185
+ if (stat.isDirectory()) {
186
+ results = results.concat(await this._scanDirRecursive(base, rel))
187
+ } else {
188
+ results.push(rel)
189
+ }
190
+ }
191
+ return results
192
+ }
193
+ }
@@ -0,0 +1,68 @@
1
+ import { ModelAsApp, result, show } from '@nan0web/ui'
2
+ import { runSpawn } from '@nan0web/test'
3
+ import Scanner from '../Release/Scanner.js'
4
+
5
+ export default class CloseCommand extends ModelAsApp {
6
+ static version = {
7
+ help: 'Release version',
8
+ default: '',
9
+ }
10
+ static UI = {
11
+ title: 'close',
12
+ help: 'Close a release and move tests to src/',
13
+ }
14
+
15
+ /**
16
+ * @param {Partial<CloseCommand>} [data]
17
+ * @param {Partial<import('@nan0web/ui').ModelAsAppOptions>} [options]
18
+ */
19
+ constructor(data = {}, options = {}) {
20
+ super(data, options)
21
+ /** @type {string} Release version */ this.version
22
+ }
23
+
24
+ /**
25
+ * @returns {AsyncGenerator<import('@nan0web/ui').Intent, import('@nan0web/ui').ResultIntent, any>}
26
+ */
27
+ async *run() {
28
+ const { renameSync, mkdirSync } = await import('node:fs')
29
+ const { join, dirname } = await import('node:path')
30
+
31
+ const v = this.version || ''
32
+ yield show(`🛜 nan0release close ${v || 'all'}`)
33
+
34
+ const scanner = new Scanner(process.cwd())
35
+ const specs = scanner.findSpecs(v)
36
+
37
+ if (specs.length === 0) {
38
+ yield show(`No pending specs found for version ${v || 'all'}`, 'warn')
39
+ return result({})
40
+ }
41
+
42
+ yield show(`Running ${specs.length} pending specs...`)
43
+
44
+ const files = specs.map((s) => s.path)
45
+ const spawnResult = await runSpawn('node', ['--test', ...files])
46
+ if (spawnResult.code !== 0) {
47
+ throw new Error('Cannot close: Specs failed')
48
+ }
49
+
50
+ yield show(`✅ Specs passed. Closing...`, 'success')
51
+
52
+ let movedCount = 0
53
+ for (const spec of specs) {
54
+ const relativePath = spec.path.substring(process.cwd().length + 1)
55
+ const destRelative = relativePath
56
+ .replace(/^releases\//, 'src/releases/')
57
+ .replace('.spec.js', '.test.js')
58
+ const destPath = join(process.cwd(), destRelative)
59
+
60
+ mkdirSync(dirname(destPath), { recursive: true })
61
+ renameSync(spec.path, destPath)
62
+ movedCount++
63
+ }
64
+
65
+ yield show(`✅ Successfully closed ${movedCount} specs and moved to src/releases/`, 'success')
66
+ return result({})
67
+ }
68
+ }