@starci/hfs 4.0.0 → 4.0.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/CHANGELOG.md CHANGED
@@ -1,5 +1,10 @@
1
1
  # Changelog
2
2
 
3
+ ## 4.0.1 - 2026-10-01
4
+
5
+ - Fixed: `hfs scaffold app` no longer writes a hand-made lockfile. The 4.0.0 stub held only the root entry, so `npm ci` in a new app failed with EUSAGE ("package.json and package-lock.json are not in sync"). Once the files are written, the scaffold runs `npm install --package-lock-only --ignore-scripts --no-audit --no-fund` in the new app root (registry or npm cache; no node_modules, no scripts), so the lockfile resolves every dependency of the root and its workspaces and `npm ci` accepts it. If npm cannot resolve it, the scaffold exits 2 with `HFS_SCAFFOLD_LOCK_FAILED`, names the step and removes the app it began: no stub lock and no app without a lock is left. There is no switch to skip the step.
6
+ - Fixed (false positive): R14 `HFS_DEP_VERSION_SKEW` does not report a bundled copy in the lockfile (`inBundle`, a package's bundleDependencies, e.g. the tslib inside `@tailwindcss/oxide-wasm32-wasi`): it ships inside its parent's tarball and nothing in the app can move it. A fresh scaffold's real lock has one.
7
+
3
8
  ## 4.0.0 - 2026-10-01
4
9
 
5
10
  - Added: the scaffolded root `package.json` (and its lockfile root entry) pins `@starci/test-world` from `knowledge/hfs/canon-pins.yaml` (1.0.0), the package whose `starci-test-stack` bin the managed `test:stack` script runs.
package/bin/hfs.mjs CHANGED
@@ -215,7 +215,7 @@ export async function main(argv, { stdout = (s) => process.stdout.write(s), stde
215
215
  const [kind, name, ...extra] = opts.positional;
216
216
  if (kind !== 'app' || !name || extra.length || opts.repo !== undefined) throw new Error('hfs scaffold takes `app <name> [--into <dir>]`');
217
217
  const { root: created, files } = scaffoldApp({ name, into: path.resolve(opts.into ?? process.cwd()), presets: presets ?? await scaffoldPresets() });
218
- stdout(`hfs scaffold app: created ${created} (${files.length} files); next: npm install, then npm run lint\n`);
218
+ stdout(`hfs scaffold app: created ${created} (${files.length} files); next: npm ci, then npm run lint\n`);
219
219
  return 0;
220
220
  }
221
221
  if (opts.positional.length !== 1) throw new Error('hfs explain takes exactly one path');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@starci/hfs",
3
- "version": "4.0.0",
3
+ "version": "4.0.1",
4
4
  "description": "The HFS command line of a StarCi app (one repository: the root, be/ and fe/): hfs lint, check, scaffold app, explain, sync and work-hygiene. Self-contained: it carries the runtime files it reads.",
5
5
  "type": "module",
6
6
  "license": "UNLICENSED",
@@ -24,19 +24,19 @@ pins:
24
24
  source: packages/grammar/package.json
25
25
  why: nivo-fe pins 0.4.11 and 0.5.0 in one workspace, starci-next-fe 0.5.1, miamia-fe 0.5.0; the runtime source is 0.8.0 (the brand layer sets `--font-sans` and `--font-mono`; the grammar reads them; 0.7.2 added the Input tel kind and IconButton disclosure props).
26
26
  '@starci/eslint-canon-be':
27
- version: 3.0.0
27
+ version: 3.0.1
28
28
  group: starci
29
29
  install: registry
30
30
  side: be
31
31
  source: packages/eslint/be/package.json
32
- why: '3.0.0: `loadHfs(import.meta.url)` of be/eslint.config.mjs finds the app-root hfs.json (kind app) and gives every linted file the view of the be side; the project graph is built per side. 2.0.0 (C0 release): starciBeConfig({ hfs: loadHfs(import.meta.url) }) typed factory and the BE-CONVENTION laws.'
32
+ why: '3.0.1: its bundled canon-pins copy pins hfs 4.0.1. 3.0.0: `loadHfs(import.meta.url)` of be/eslint.config.mjs finds the app-root hfs.json (kind app) and gives every linted file the view of the be side; the project graph is built per side. 2.0.0 (C0 release): starciBeConfig({ hfs: loadHfs(import.meta.url) }) typed factory and the BE-CONVENTION laws.'
33
33
  '@starci/eslint-canon-fe':
34
- version: 8.0.0
34
+ version: 8.0.1
35
35
  group: starci
36
36
  install: registry
37
37
  side: fe
38
38
  source: packages/eslint/fe/package.json
39
- why: '8.0.0: `loadHfs(import.meta.url)` of fe/eslint.config.mjs finds the app-root hfs.json (kind app) and gives every linted file the view of the fe side; the project graph is built per side. 7.0.0: the front end has no tests (FE_NO_TESTS R97 in hfs); no-vietnamese-in-source (R91).'
39
+ why: '8.0.1: its bundled canon-pins copy pins hfs 4.0.1. 8.0.0: `loadHfs(import.meta.url)` of fe/eslint.config.mjs finds the app-root hfs.json (kind app) and gives every linted file the view of the fe side; the project graph is built per side. 7.0.0: the front end has no tests (FE_NO_TESTS R97 in hfs); no-vietnamese-in-source (R91).'
40
40
  '@starci/stylelint-canon':
41
41
  version: 2.0.1
42
42
  group: starci
@@ -71,12 +71,12 @@ pins:
71
71
  source: packages/test-world/package.json
72
72
  why: 'the shared e2e library of every back end (R47, R48): the warm stack behind toxiproxy, the network-edge fakes, the Nest boot, the typed useTestWorld handle, useSandbox for contract specs, the outage lock and the starci-test-stack bin behind the managed test:stack script; a devDependency of every back end.'
73
73
  '@starci/hfs':
74
- version: 4.0.0
74
+ version: 4.0.1
75
75
  group: starci
76
76
  install: registry
77
77
  side: both
78
78
  source: packages/hfs/package.json
79
- why: '4.0.0: the app monorepo standard: one app repository with the root package.json, lockfile and hfs.json of kind app, and the be/ and fe/ sides; `hfs scaffold app <name>` makes it, `hfs lint` at the root lints be/** with the BE canon and fe/** with the FE canon; the standalone be/fe repository kinds and `hfs init` are deleted.'
79
+ why: '4.0.1: `hfs scaffold app` resolves the real lockfile with npm (`npm install --package-lock-only`) instead of writing a root-only stub that `npm ci` refuses. 4.0.0: the app monorepo standard: one app repository with the root package.json, lockfile and hfs.json of kind app, and the be/ and fe/ sides; `hfs scaffold app <name>` makes it, `hfs lint` at the root lints be/** with the BE canon and fe/** with the FE canon; the standalone be/fe repository kinds and `hfs init` are deleted.'
80
80
  # --- tooling
81
81
  typescript:
82
82
  version: 5.9.3
@@ -4,7 +4,9 @@
4
4
  // - a dependency the root `overrides` pins to a version (a string that is not a `$name` reference) is declared at that version everywhere
5
5
  // it is declared: an override the manifests disagree with is a second version in disguise;
6
6
  // - the lockfile (package-lock.json, read, never installed) holds no nested copy of a dependency the workspace declares:
7
- // `node_modules/<a>/node_modules/<name>` or `apps/<app>/node_modules/<name>` next to the hoisted `node_modules/<name>`.
7
+ // `node_modules/<a>/node_modules/<name>` or `apps/<app>/node_modules/<name>` next to the hoisted `node_modules/<name>`. A
8
+ // bundled copy (`inBundle`: it ships inside its parent's tarball, its bundleDependencies) is not one: no range, override or
9
+ // dedupe of the workspace can move it, so it is the parent's, not a second copy the workspace keeps.
8
10
  import { found, readJson } from './read.mjs';
9
11
 
10
12
  export const DEP_VERSION_SKEW = 'HFS_DEP_VERSION_SKEW';
@@ -54,7 +56,7 @@ export function depFindings({ repoRoot, files }) {
54
56
  }
55
57
  for (const [key, entry] of Object.entries(lock.packages)) {
56
58
  const nested = NESTED.exec(key);
57
- if (!nested || entry.link || HOISTED.test(key) || !declared.has(nested[1])) continue;
59
+ if (!nested || entry.link || entry.inBundle || HOISTED.test(key) || !declared.has(nested[1])) continue;
58
60
  const name = nested[1];
59
61
  findings.push(found(DEP_VERSION_SKEW, 'package-lock.json', `${key} is a nested copy of ${name}${entry.version ? ` ${entry.version}` : ''}${hoisted.has(name) ? ` next to the hoisted ${hoisted.get(name)}` : ''}; the workspace keeps one copy (align the ranges or add a root override)`, { dependency: name, lockPath: key, version: entry.version }));
60
62
  }
package/scaffold/app.mjs CHANGED
@@ -11,10 +11,13 @@
11
11
  //
12
12
  // The skeleton files are written once from templates/<app|be|fe>/skeleton ({{project}}, {{app}} and {{appPascal}} filled, a
13
13
  // `__app__` folder named after the side's app); the managed files are the render of `hfs sync` (sync/index.mjs), so a fresh app
14
- // is in sync by construction. Nothing is installed: `npm install` completes the lockfile, which is written as the root entry only.
15
- // An existing directory is refused, never merged into.
14
+ // is in sync by construction. The lockfile is never written by hand: once the files are written, npm resolves the real one
15
+ // (`npm install --package-lock-only`, no node_modules, no scripts), so `npm ci` installs the new app as it is. When npm cannot
16
+ // resolve it the scaffold fails (HFS_SCAFFOLD_LOCK_FAILED), names the step and removes the app it began, so no app without a lock
17
+ // and no stub lock is ever left. An existing directory is refused, never merged into.
16
18
  import fs from 'node:fs';
17
19
  import path from 'node:path';
20
+ import { spawnSync } from 'node:child_process';
18
21
  import { loadSlotManifest, resolveRepoDeclaration } from '../runtime/scripts/lib/hfs-slots.mjs';
19
22
  import { parseYaml } from '../runtime/engine/yaml.mjs';
20
23
  import { TEMPLATES_DIR, renderTargets, writeTargets } from '../sync/index.mjs';
@@ -148,11 +151,30 @@ function skeletonOf(scope, app, vars) {
148
151
  return files;
149
152
  }
150
153
 
154
+ /** The npm step that resolves the lockfile of a new app, exactly as the error names it. */
155
+ export const LOCK_STEP = 'npm install --package-lock-only --ignore-scripts --no-audit --no-fund';
156
+
157
+ /**
158
+ * Resolves the real package-lock.json of the app at `root` with npm (the registry and the npm cache; no node_modules and no
159
+ * lifecycle script). `{ ok: true }` or `{ ok: false, detail }`.
160
+ */
161
+ export function npmLock(root) {
162
+ // Through the shell (npm is npm.cmd on Windows, which runs only there); the command is the fixed literal LOCK_STEP.
163
+ const run = spawnSync(LOCK_STEP, { cwd: root, encoding: 'utf8', shell: true, windowsHide: true, timeout: 600_000 });
164
+ if (run.status === 0 && fs.existsSync(path.join(root, 'package-lock.json'))) return { ok: true };
165
+ const said = `${run.stderr ?? ''}\n${run.stdout ?? ''}`.split(/\r?\n/).map(line => line.trim()).filter(Boolean);
166
+ // npm's own error lines (`npm error code ETARGET`, `npm error notarget No matching version found for x@1.2.3.`), without the log pointer.
167
+ const errors = said.filter(line => /^npm (?:error|ERR!)/i.test(line) && !/complete log/i.test(line)).map(line => line.replace(/^npm (?:error|ERR!)\s*/i, ''));
168
+ const reason = run.error ? String(run.error.message) : errors.slice(0, 2).join('; ') || said[0] || 'no output';
169
+ return { ok: false, detail: `exit ${run.status ?? 'none'}: ${reason}` };
170
+ }
171
+
151
172
  /**
152
- * `hfs scaffold app <name>`: writes the new app under `into` and returns `{ root, files }` (app-relative paths, sorted). `presets`
153
- * is what sync loads from the installed @starci/jest-preset (the Sonar exclusions); the CLI passes the one it resolves.
173
+ * `hfs scaffold app <name>`: writes the new app under `into`, resolves its lockfile with npm (`lock`, npmLock), and returns
174
+ * `{ root, files }` (app-relative paths, sorted). `presets` is what sync loads from the installed @starci/jest-preset (the Sonar
175
+ * exclusions); the CLI passes the one it resolves. A failed lock step removes the app and throws HFS_SCAFFOLD_LOCK_FAILED.
154
176
  */
155
- export function scaffoldApp({ name, into, presets, manifest = loadSlotManifest() }) {
177
+ export function scaffoldApp({ name, into, presets, manifest = loadSlotManifest(), lock = npmLock }) {
156
178
  if (!NAME.test(String(name))) throw new ScaffoldError('HFS_SCAFFOLD_NAME_INVALID', `the app name ${name} must be kebab-case (a project name: ${NAME})`);
157
179
  const root = path.join(into, name);
158
180
  if (fs.existsSync(root)) throw new ScaffoldError('HFS_SCAFFOLD_EXISTS', `${root} already exists; hfs scaffold app never writes into an existing directory`);
@@ -163,7 +185,6 @@ export function scaffoldApp({ name, into, presets, manifest = loadSlotManifest()
163
185
  const files = [
164
186
  { path: 'hfs.json', content: jsonText(declaration) },
165
187
  { path: 'package.json', content: jsonText(pkg) },
166
- { path: 'package-lock.json', content: jsonText({ name, version: pkg.version, lockfileVersion: 3, requires: true, packages: { '': { name, version: pkg.version, dependencies: pkg.dependencies, devDependencies: pkg.devDependencies } } }) },
167
188
  { path: 'be/nest-cli.json', content: jsonText(nestCli(app)) },
168
189
  ...app.sides.fe.apps.map(entry => ({ path: `fe/apps/${entry.name}/tsconfig.json`, content: jsonText(nextAppTsconfig()) })),
169
190
  ...['app', 'be', 'fe'].flatMap(scope => skeletonOf(scope, app, { sonarGate: parseYaml(fs.readFileSync(SONAR_GATE_FILE, 'utf8')).gate.name })),
@@ -175,5 +196,10 @@ export function scaffoldApp({ name, into, presets, manifest = loadSlotManifest()
175
196
  }
176
197
  const targets = renderTargets(declaration, presets, { manifest });
177
198
  writeTargets(root, targets);
178
- return { root, files: [...new Set([...files.map(file => file.path), ...targets.map(target => target.path)])].sort() };
199
+ const locked = lock(root);
200
+ if (!locked.ok) {
201
+ fs.rmSync(root, { recursive: true, force: true });
202
+ throw new ScaffoldError('HFS_SCAFFOLD_LOCK_FAILED', `\`${LOCK_STEP}\` could not resolve the lockfile of ${root} (${locked.detail}); the app was removed. Check the network and the npm registry, then run hfs scaffold app ${name} again`);
203
+ }
204
+ return { root, files: [...new Set([...files.map(file => file.path), ...targets.map(target => target.path), 'package-lock.json'])].sort() };
179
205
  }