diff --git a/.changeset/scaffold-summary-names-every-written-path.md b/.changeset/scaffold-summary-names-every-written-path.md new file mode 100644 index 0000000000..46b180366d --- /dev/null +++ b/.changeset/scaffold-summary-names-every-written-path.md @@ -0,0 +1,30 @@ +--- +"create-objectstack": minor +--- + +`create-objectstack` now closes with a "Created files" summary derived from a +walk of the finished project directory, so it names everything the run wrote — +including the files written after the template copy (#10323). + +The old summary was the template copy's own list, printed before +` install` and before `npx skills add`. Measured against published +`create-objectstack@17.1.0` (`create-objectstack demo-app`, then a full walk of +the result): 12 entries printed, 18,045 paths on disk, **18,033 of them +unreachable from the summary** — `AGENTS.md`, `.github/copilot-instructions.md`, +`pnpm-lock.yaml`, `skills-lock.json`, `node_modules/`, and two ~968 KB trees of +agent instructions at `.agents/skills/` and `agent/skills/`. + +That mattered because the same run ends with the `skills` CLI printing *"Review +skills before use; they run with full agent permissions."* Advice to review +files the run never named, at paths it never showed, is advice a newcomer +cannot act on — the wrong failure direction for a security-flavoured warning. + +The list could not have been correct where it stood: two of the three write +phases belong to other processes, and the `skills` installer's destination set +moves with **its** releases, not ours. Reading the directory afterwards makes +the summary self-correcting instead. Large directories collapse to one line +carrying their path, entry count and size, so the bulk stays reviewable without +18,000 lines of output, and the paths the skills installer created are marked +`⚠ skills` with the permissions warning tied to them. + +Same run, after the change: 20 entries printed, **0 written paths unreachable**. diff --git a/packages/create-objectstack/src/created-summary.test.ts b/packages/create-objectstack/src/created-summary.test.ts new file mode 100644 index 0000000000..cdcc06eb62 --- /dev/null +++ b/packages/create-objectstack/src/created-summary.test.ts @@ -0,0 +1,271 @@ +// Copyright (c) 2026 ObjectStack contributors. Apache-2.0 license. +// +// Pins the PROPERTY the closing scaffold summary exists to hold: every path +// the run wrote is reachable from what the run printed — named outright, or +// lying beneath a directory line that is. +// +// ## Why a property and not a file count +// +// The defect this guards against was a hard-coded-by-construction list: the +// summary printed `copyDir`'s collected array, which is the template files and +// nothing else, so `AGENTS.md`, `.github/copilot-instructions.md`, +// `pnpm-lock.yaml`, `skills-lock.json`, `node_modules/` and two ~968 KB trees +// of agent instructions were written and never named. Measured against +// published `create-objectstack@17.1.0`: 12 entries printed, 18,045 paths on +// disk, 18,033 of them unreachable from the summary. +// +// An assertion of the shape "the summary lists 40 files" would fail the moment +// the template gains or loses a file, and would be re-baselined rather than +// investigated — which is the exact mechanism that produced the stale 12. So +// nothing below counts files. Each case builds a tree, summarizes it, and +// asserts reachability over whatever that tree happens to contain. +// +// ## Why synthetic trees rather than a real scaffold +// +// The real run's last two write phases are ` install` and +// `npx skills add …` — a package manager and a third-party CLI, both needing +// the network. A unit test that depended on them would be a network test that +// fails for reasons unrelated to this property. The shapes that matter are +// reproduced directly instead: a large tree that must collapse, a symlink farm +// that must be counted without being followed, a single-child chain that must +// compress, and a tree past the measurement budget. + +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; + +import { + summarizeTree, + unreachablePaths, + describeEntry, + formatBytes, + COLLAPSE_AT, + MEASURE_BUDGET, +} from './created-summary.js'; + +let root: string; + +/** The published catalog as measured — `Found 11 skills` in the real run. */ +const SKILLS = [ + 'objectstack-ai', + 'objectstack-api', + 'objectstack-automation', + 'objectstack-data', + 'objectstack-formula', + 'objectstack-i18n', + 'objectstack-platform', + 'objectstack-pm-dispatch', + 'objectstack-query', + 'objectstack-ui', + 'objectstack-upgrade', +]; + +/** Every file and symlink under `dir`, project-relative — what the summary must cover. */ +function walkWritten(dir: string, rel = '', out: string[] = []): string[] { + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + const r = rel ? `${rel}/${entry.name}` : entry.name; + if (entry.isSymbolicLink()) out.push(r); + else if (entry.isDirectory()) walkWritten(path.join(dir, entry.name), r, out); + else out.push(r); + } + return out; +} + +function write(rel: string, contents: string) { + const abs = path.join(root, rel); + fs.mkdirSync(path.dirname(abs), { recursive: true }); + fs.writeFileSync(abs, contents); +} + +beforeAll(() => { + root = fs.mkdtempSync(path.join(os.tmpdir(), 'created-summary-')); + + // The shape of a real scaffold, reproduced without the network. + // Phase 1 — template copy + identity rewrite + agent guides. + for (const f of [ + '.dockerignore', + '.gitignore', + 'AGENTS.md', + 'Dockerfile', + 'README.md', + 'docker-compose.yml', + 'objectstack.config.ts', + 'objectstack.manifest.json', + 'package.json', + 'pnpm-workspace.yaml', + 'tsconfig.json', + ]) { + write(f, `${f}\n`); + } + write('.github/copilot-instructions.md', 'copilot\n'); + write('src/objects/index.ts', 'export {};\n'); + write('src/objects/note.object.ts', 'export {};\n'); + + // Phase 2 — the package manager. + write('pnpm-lock.yaml', 'lockfileVersion: 9.0\n'.repeat(400)); + for (let i = 0; i < MEASURE_BUDGET + 50; i += 1) { + write(`node_modules/pkg-${i}/index.js`, 'module.exports = {};\n'); + } + + // Phase 3 — the skills installer: two real trees plus a symlink farm, the + // layout measured from `npx skills add … --all` (11 skills, 49 real files + // per tree, `.claude/skills/*` symlinked into `.agents/skills/`). The COUNT + // is faithful on purpose — a 3-skill fixture sits under COLLAPSE_AT and + // would exercise the enumerate path while the real tree takes the collapse + // path, testing the branch the product does not use. + write('skills-lock.json', '{"version":1}\n'); + for (const skill of SKILLS) { + for (const tree of ['.agents/skills', 'agent/skills']) { + write(`${tree}/${skill}/SKILL.md`, '---\nname: x\n---\n'.repeat(60)); + write(`${tree}/${skill}/references/guide.md`, 'guide\n'.repeat(60)); + } + fs.mkdirSync(path.join(root, '.claude/skills'), { recursive: true }); + fs.symlinkSync( + path.join('..', '..', '.agents', 'skills', skill), + path.join(root, '.claude/skills', skill), + ); + } +}); + +afterAll(() => { + fs.rmSync(root, { recursive: true, force: true }); +}); + +describe('created-summary — reachability', () => { + // Vacuity guard. Every assertion below is quantified over the tree, so a + // test that summarized the wrong directory would assert nothing at all and + // stay green. This proves the fixture really was built and really was read. + it('reads a tree with all three write phases in it', () => { + const written = walkWritten(root); + expect(written).toContain('AGENTS.md'); + expect(written).toContain('skills-lock.json'); + expect(written).toContain('pnpm-lock.yaml'); + expect(written.filter((p) => p.startsWith('.agents/')).length).toBeGreaterThan(0); + expect(written.filter((p) => p.startsWith('agent/')).length).toBeGreaterThan(0); + expect(written.filter((p) => p.startsWith('node_modules/')).length).toBeGreaterThan( + MEASURE_BUDGET, + ); + expect(summarizeTree(root).length).toBeGreaterThan(0); + }); + + // THE property. Not "the summary is long enough" — every single written + // path is reachable, whatever the tree happens to hold. + it('names, or covers by an ancestor line, every path on disk', () => { + const written = walkWritten(root); + const missed = unreachablePaths(summarizeTree(root), written); + expect( + missed, + `The scaffold summary would not disclose ${missed.length} written path(s), ` + + `e.g. ${missed.slice(0, 5).join(', ')}. Every path the run writes must be ` + + 'reachable from what it prints — a path nobody was shown is a path nobody ' + + 'can review.', + ).toEqual([]); + }); + + // The regression in its original form: the files written after the template + // copy are exactly the ones the old summary could not see. + it('discloses the post-copy writes the old list structurally could not', () => { + const entries = summarizeTree(root); + const missed = unreachablePaths(entries, [ + 'AGENTS.md', + '.github/copilot-instructions.md', + 'pnpm-lock.yaml', + 'skills-lock.json', + '.agents/skills/objectstack-ai/SKILL.md', + 'agent/skills/objectstack-ai/SKILL.md', + '.claude/skills/objectstack-ai', + 'node_modules/pkg-0/index.js', + ]); + expect(missed).toEqual([]); + }); + + it('is not vacuous — a path the summary does not cover is reported', () => { + // Without this, `unreachablePaths` returning `[]` unconditionally would + // make every assertion above pass while proving nothing. + const missed = unreachablePaths(summarizeTree(root), ['not-written-by-anyone.txt']); + expect(missed).toEqual(['not-written-by-anyone.txt']); + }); +}); + +describe('created-summary — readability', () => { + it('collapses big trees instead of enumerating them', () => { + const entries = summarizeTree(root); + // 11 skills x 2 files x 2 trees plus a 2050-entry node_modules: an + // enumeration would be thousands of lines. The bar is reachability AND a + // summary a human reads, so bulk arrives as directory lines. + expect(entries.length).toBeLessThan(60); + const dirs = entries.filter((e) => e.kind === 'dir').map((e) => e.path); + expect(dirs).toContain('node_modules/'); + }); + + it('compresses single-child chains down to the directory worth opening', () => { + // `.agents/` holds only `skills/`, so the line must read `.agents/skills/` + // — the path the "review your skills" advice actually sends people to. + const dirs = summarizeTree(root) + .filter((e) => e.kind === 'dir') + .map((e) => e.path); + expect(dirs).toContain('.agents/skills/'); + expect(dirs).toContain('agent/skills/'); + expect(dirs).not.toContain('.agents/'); + }); + + it('counts symlinks without following them', () => { + // `.claude/skills/*` are symlinks into `.agents/skills/`. Following them + // would double-count that tree and report a size the disk does not hold. + const claude = summarizeTree(root).find((e) => e.path === '.claude/skills/'); + const agents = summarizeTree(root).find((e) => e.path === '.agents/skills/'); + expect(claude, '.claude/skills/ must appear as its own line').toBeTruthy(); + expect(claude!.entries).toBe(SKILLS.length); + expect(claude!.bytes).toBeLessThan(agents!.bytes); + }); + + it('reports a lower bound rather than a wrong number past the budget', () => { + const nm = summarizeTree(root).find((e) => e.path === 'node_modules/')!; + expect(nm.truncated).toBe(true); + expect(describeEntry(nm)).toMatch(/^over [\d,]+ files$/); + // A truncated entry must not print a size: the walk stopped early, so any + // byte total it carries is a fraction presented as a whole. + expect(describeEntry(nm)).not.toMatch(/KB|MB|B$/); + }); + + it('describes a fully measured directory with both count and size', () => { + const skills = summarizeTree(root).find((e) => e.path === '.agents/skills/')!; + expect(skills.truncated).toBe(false); + expect(describeEntry(skills)).toMatch(/^\d+ files, [\d.]+ (B|KB|MB)$/); + }); + + it('enumerates small directories file by file', () => { + const paths = summarizeTree(root).map((e) => e.path); + expect(paths).toContain('src/objects/note.object.ts'); + expect(paths).toContain('.github/copilot-instructions.md'); + expect(paths).not.toContain('src/'); + }); + + it('formats byte counts at each magnitude', () => { + expect(formatBytes(46)).toBe('46 B'); + expect(formatBytes(4837)).toBe('4.7 KB'); + expect(formatBytes(991232)).toBe('968 KB'); + expect(formatBytes(5 * 1024 * 1024)).toBe('5.0 MB'); + }); + + it('agrees with its own collapse threshold', () => { + // Pins the rule, not a number: a directory at the threshold is + // enumerated, one past it collapses. + const small = fs.mkdtempSync(path.join(os.tmpdir(), 'summary-small-')); + try { + for (let i = 0; i < COLLAPSE_AT; i += 1) { + fs.mkdirSync(path.join(small, 'many'), { recursive: true }); + fs.writeFileSync(path.join(small, 'many', `f${i}.txt`), 'x'); + } + expect(summarizeTree(small).every((e) => e.kind === 'file')).toBe(true); + + fs.writeFileSync(path.join(small, 'many', 'one-more.txt'), 'x'); + const after = summarizeTree(small); + expect(after.map((e) => e.path)).toEqual(['many/']); + expect(unreachablePaths(after, walkWritten(small))).toEqual([]); + } finally { + fs.rmSync(small, { recursive: true, force: true }); + } + }); +}); diff --git a/packages/create-objectstack/src/created-summary.ts b/packages/create-objectstack/src/created-summary.ts new file mode 100644 index 0000000000..eec164d6b0 --- /dev/null +++ b/packages/create-objectstack/src/created-summary.ts @@ -0,0 +1,237 @@ +// Copyright (c) 2026 ObjectStack contributors. Apache-2.0 license. +// +// Builds the run's closing "Created files" summary by READING THE PROJECT +// DIRECTORY once every write has landed — never by accumulating a list as the +// writes happen. +// +// ## Why the source of the list is the whole fix +// +// The summary this replaces was `copyDir`'s `collected` array: the template +// files, and nothing else. Measured against published `create-objectstack@ +// 17.1.0` (`create-objectstack demo-app`, then a full walk of the result): +// +// printed summary entries : 12 +// paths written on disk : 18045 +// UNREACHABLE from summary: 18033 +// .agents/ 49 agent/ 49 .claude/ 11 +// .github/ 1 AGENTS.md 1 skills-lock.json 1 +// pnpm-lock.yaml 1 node_modules/ 17920 +// +// Two ~968 KB trees of agent instructions and both lockfiles landed on the +// user's disk unnamed — and the same run closes with the `skills` CLI printing +// "Review skills before use; they run with full agent permissions." Advice to +// review files the run never named is advice a newcomer cannot act on, and for +// a security-flavoured warning that is the wrong failure direction. +// +// The list could not have been right, because of WHERE it was built. Three +// phases write into the project, in this order: +// +// 1. template copy + identity rewrite + AGENTS.md/copilot-instructions.md +// 2. ` install` -> pnpm-lock.yaml, node_modules/ +// 3. `npx skills add … --all` -> .agents/, agent/, .claude/, +// skills-lock.json +// +// and the list was printed between (1) and (2). Phases 2 and 3 are third-party +// processes whose outputs this package does not choose and cannot enumerate +// ahead of time — the `skills` CLI fans out to every agent runtime it knows, +// and that set changes with ITS releases, not ours. So any hand-maintained +// list is not merely incomplete, it is unmaintainable: it drifts the next time +// a dependency learns a new destination, silently, in the one direction that +// hides files rather than inventing them. +// +// Reading the directory afterwards is what makes the summary self-correcting. +// A path that appears because some future dependency wrote it appears in the +// summary too, with nobody editing this file. +// +// ## The property, and why it is not "list everything" +// +// The bar is REACHABILITY: every path the run wrote must be findable from what +// the run printed. Enumerating 18,045 lines satisfies it and is unreadable, so +// a directory holding more than `COLLAPSE_AT` entries collapses to one line +// carrying its path, its entry count and its size — the reader is shown where +// the bulk landed and how much of it there is, which is exactly what "go +// review the skills" requires. `created-summary.test.ts` asserts the +// reachability property itself against a synthetic tree, never a file count: +// a count assertion would rot the moment the template changes, and a drifted +// hard-coded list is why this module exists. + +import fs from 'node:fs'; +import path from 'node:path'; + +/** A directory holding more than this many entries is collapsed to one line. */ +export const COLLAPSE_AT = 10; + +/** + * Entries this module is willing to `lstat` per top-level entry before it + * stops counting and reports a lower bound. + * + * The budget is PER TOP-LEVEL ENTRY, not global, and that is load-bearing: + * with one shared budget, `node_modules/` (17,920 paths in the measurement + * above) exhausts it before the walk reaches the project's own files, and the + * summary silently truncates the very content it exists to disclose. Whether + * that happened would depend on `readdir` order. + */ +export const MEASURE_BUDGET = 2000; + +export interface SummaryEntry { + /** Project-relative path. Directories carry a trailing `/`. */ + path: string; + kind: 'file' | 'dir'; + /** Files and symlinks in the subtree (always 1 for a file). */ + entries: number; + /** Total size in bytes. Meaningless when `truncated`. */ + bytes: number; + /** Measurement stopped at the budget — `entries` and `bytes` are lower bounds. */ + truncated: boolean; +} + +interface Node { + name: string; + dir: boolean; + entries: number; + bytes: number; + truncated: boolean; + children: Node[]; +} + +/** + * Walk one entry. Symlinks are counted as leaves and never followed — the + * `skills` CLI writes `.claude/skills/*` as symlinks into `.agents/skills/`, + * and following them would double-count the tree they point at. + */ +function scan(abs: string, name: string, budget: { left: number }): Node { + let st: fs.Stats; + try { + st = fs.lstatSync(abs); + } catch { + // Raced away between readdir and lstat — not ours to report. + return { name, dir: false, entries: 0, bytes: 0, truncated: false, children: [] }; + } + + if (!st.isDirectory()) { + budget.left -= 1; + return { name, dir: false, entries: 1, bytes: st.size, truncated: false, children: [] }; + } + + const node: Node = { name, dir: true, entries: 0, bytes: 0, truncated: false, children: [] }; + let dirents: fs.Dirent[]; + try { + dirents = fs.readdirSync(abs, { withFileTypes: true }); + } catch { + return node; // unreadable directory — still named by its parent's line + } + + for (const entry of dirents) { + if (budget.left <= 0) { + node.truncated = true; + break; + } + const child = scan(path.join(abs, entry.name), entry.name, budget); + node.entries += child.entries; + node.bytes += child.bytes; + if (child.truncated) node.truncated = true; + node.children.push(child); + } + return node; +} + +function byName(a: Node, b: Node): number { + return a.name.localeCompare(b.name, 'en'); +} + +function flatten(node: Node, prefix: string, out: SummaryEntry[]): void { + for (const child of [...node.children].sort(byName)) { + const rel = prefix + child.name; + + if (!child.dir) { + out.push({ path: rel, kind: 'file', entries: 1, bytes: child.bytes, truncated: false }); + continue; + } + + // Small enough to show file by file. + if (!child.truncated && child.entries <= COLLAPSE_AT) { + flatten(child, `${rel}/`, out); + continue; + } + + // Collapse — but walk down through single-child directory chains first, so + // the line names the directory a reader would actually open + // (`.agents/skills/`, not `.agents/`). + let deepest = child; + let shown = rel; + while (deepest.children.length === 1 && deepest.children[0].dir) { + deepest = deepest.children[0]; + shown = `${shown}/${deepest.name}`; + } + out.push({ + path: `${shown}/`, + kind: 'dir', + entries: deepest.entries, + bytes: deepest.bytes, + truncated: deepest.truncated, + }); + } +} + +/** + * Summarize everything under `root`, collapsing large directories. + * + * Returns files first (alphabetical), then collapsed directories + * (alphabetical), so the enumerated content reads as a list and the bulk + * trees read as a block with their sizes. + */ +export function summarizeTree(root: string): SummaryEntry[] { + let dirents: fs.Dirent[]; + try { + dirents = fs.readdirSync(root, { withFileTypes: true }); + } catch { + return []; + } + + const top: Node = { name: '', dir: true, entries: 0, bytes: 0, truncated: false, children: [] }; + for (const entry of dirents) { + // A fresh budget per top-level entry: see MEASURE_BUDGET. + top.children.push(scan(path.join(root, entry.name), entry.name, { left: MEASURE_BUDGET })); + } + + const out: SummaryEntry[] = []; + flatten(top, '', out); + return [ + ...out.filter((e) => e.kind === 'file'), + ...out.filter((e) => e.kind === 'dir'), + ]; +} + +/** Human-readable byte count. */ +export function formatBytes(bytes: number): string { + if (bytes < 1024) return `${bytes} B`; + const kb = bytes / 1024; + if (kb < 1024) return `${kb < 10 ? kb.toFixed(1) : Math.round(kb)} KB`; + const mb = kb / 1024; + return `${mb < 10 ? mb.toFixed(1) : Math.round(mb)} MB`; +} + +/** The measurement note that follows a collapsed directory's path. */ +export function describeEntry(entry: SummaryEntry): string { + if (entry.kind === 'file') return ''; + const noun = entry.entries === 1 ? 'file' : 'files'; + if (entry.truncated) return `over ${entry.entries.toLocaleString('en-US')} ${noun}`; + return `${entry.entries.toLocaleString('en-US')} ${noun}, ${formatBytes(entry.bytes)}`; +} + +/** + * The property this module exists to hold: every path in `written` is either + * named outright by a summary entry, or lies beneath a directory entry that + * is. Returns the paths that are NOT reachable — empty means the summary is + * complete. + * + * Exported because it is the assertion, and an assertion that lives only in a + * test file cannot be run against a real scaffold from anywhere else. + */ +export function unreachablePaths(entries: SummaryEntry[], written: string[]): string[] { + const named = new Set(entries.filter((e) => e.kind === 'file').map((e) => e.path)); + const dirs = entries.filter((e) => e.kind === 'dir').map((e) => e.path); + return written.filter( + (p) => !named.has(p) && !dirs.some((d) => `${p}/`.startsWith(d)), + ); +} diff --git a/packages/create-objectstack/src/index.ts b/packages/create-objectstack/src/index.ts index 64c457a93d..e54bfdcb47 100644 --- a/packages/create-objectstack/src/index.ts +++ b/packages/create-objectstack/src/index.ts @@ -44,6 +44,14 @@ * The `/skills` subpath scopes discovery to the curated, customer-published * catalog — repo-internal skills (e.g. under `.claude/skills/`) must never * reach scaffolded projects. + * + * Only THEN is the "Created files" summary printed, and it is a walk of the + * finished project directory rather than a list accumulated during the copy + * (created-summary.ts carries the measurement). Two of the three write phases + * belong to other processes — the package manager and the `skills` CLI — so a + * list assembled by this file before they run cannot name what they wrote, and + * the version that did so omitted AGENTS.md, both lockfiles and ~1.9 MB of + * agent instructions while the same run told the reader to review the skills. */ import { Command } from 'commander'; @@ -62,6 +70,7 @@ import { } from './rewrite-identity.js'; import { lookupTemplate, templateNames } from './template-registry.js'; import { readResolvedCliVersion, pinRuntimeImage } from './runtime-image.js'; +import { summarizeTree, describeEntry } from './created-summary.js'; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); @@ -287,6 +296,75 @@ function writeAgentGuides(targetDir: string, title: string, projectName: string) writeIfAbsent(copilotPath, rendered); } +// Top-level entry names in `dir`, or an empty set if it does not exist yet. +// Two snapshots of this are taken during a run: one before anything is +// written (does the summary get to say "Created files", or did the user +// scaffold into a directory that already had contents?) and one on either +// side of the skills step, whose difference is the set of paths the skills +// installer chose — which is how the security note below points at real paths +// without this file hard-coding a destination list that belongs to a +// dependency's release cycle, not to ours. +function topLevelNames(dir: string): Set { + try { + return new Set(fs.readdirSync(dir)); + } catch { + return new Set(); + } +} + +// Print the closing summary of what is now on disk. Derived from a walk of the +// project directory, so it names what the run ACTUALLY wrote — including the +// files written by ` install` and by the `skills` installer, neither of +// which this package can enumerate in advance. See created-summary.ts for the +// measurement that made a hand-accumulated list untenable. +function printCreatedSummary( + targetDir: string, + opts: { wasEmpty: boolean; skillPaths: Set }, +) { + const entries = summarizeTree(targetDir); + if (entries.length === 0) return; + + console.log( + chalk.bold(opts.wasEmpty ? ' Created files:' : ' Project contents:'), + ); + if (!opts.wasEmpty) { + console.log( + chalk.dim(' (the directory already had contents; this lists all of it)'), + ); + } + + const isSkillPath = (p: string) => opts.skillPaths.has(p.split('/')[0]); + const width = Math.min( + 44, + Math.max(...entries.map((e) => e.path.length)) + 2, + ); + + let flagged = false; + for (const entry of entries) { + const note = describeEntry(entry); + const flag = isSkillPath(entry.path); + if (flag) flagged = true; + const pad = note || flag ? entry.path.padEnd(width) : entry.path; + const line = ` + ${pad}${note ? chalk.dim(note) : ''}`; + console.log(chalk.green(line) + (flag ? chalk.yellow(' ⚠ skills') : '')); + } + + if (flagged) { + // The `skills` CLI closes with "Review skills before use; they run with + // full agent permissions." That advice was previously unactionable: it + // named no path, and neither did we. Now the paths are on screen directly + // above it, and this line ties the warning to them. + console.log(''); + console.log( + chalk.yellow(" ⚠ Skill files run with your coding agent's full permissions."), + ); + console.log( + chalk.dim(' Review the paths marked ⚠ above before letting an agent use them.'), + ); + } + console.log(''); +} + // Create a file only if it does not already exist, atomically — no time-of-check // to time-of-use gap between an existence test and the write. function writeIfAbsent(filePath: string, contents: string) { @@ -365,6 +443,13 @@ const program = new Command() } } + // Read BEFORE the first write. Scaffolding into the current directory is + // the one path that can land in a directory that already had contents + // (the emptiness refusal above is `!isCurrentDir`-gated), and the closing + // summary is a walk of the directory — so it must know whether it is + // entitled to call what it finds "Created files". + const targetWasEmpty = topLevelNames(targetDir).size === 0; + try { fs.mkdirSync(targetDir, { recursive: true }); @@ -372,13 +457,12 @@ const program = new Command() rewriteProjectIdentity(targetDir, projectName, namespace); - console.log(chalk.bold(' Created files:')); - for (const f of createdFiles.slice(0, 20)) { - console.log(chalk.green(` + ${f}`)); - } - if (createdFiles.length > 20) { - console.log(chalk.dim(` … and ${createdFiles.length - 20} more`)); - } + // Progress, not an inventory. The authoritative list is printed at the + // end, once ` install` and the skills installer have also written — + // a list printed here cannot name anything either of them creates, + // which is precisely how the previous summary came to omit AGENTS.md, + // both lockfiles and ~1.9 MB of agent instructions. + printSuccess(`Template files written (${createdFiles.length})`); console.log(''); if (!options.skipInstall) { @@ -418,6 +502,13 @@ const program = new Command() } } + // Which top-level paths belong to the skills install is measured, not + // assumed: `skills add --all` fans the catalog out to every agent + // runtime IT knows about (77 at the version measured), so the + // destination set moves with that package's releases. Diffing the + // directory across the call keeps the ⚠ marks correct without this file + // carrying a list it cannot keep current. + const beforeSkills = topLevelNames(targetDir); if (!options.skipInstall && !options.skipSkills) { printStep('Installing AI skills for your coding agent...'); try { @@ -434,6 +525,11 @@ const program = new Command() console.log(''); } } + const skillPaths = new Set( + [...topLevelNames(targetDir)].filter((p) => !beforeSkills.has(p)), + ); + + printCreatedSummary(targetDir, { wasEmpty: targetWasEmpty, skillPaths }); printSuccess('Environment created!'); console.log(''); diff --git a/packages/create-objectstack/src/template-consistency.test.ts b/packages/create-objectstack/src/template-consistency.test.ts index ef190a866e..41b0c5b662 100644 --- a/packages/create-objectstack/src/template-consistency.test.ts +++ b/packages/create-objectstack/src/template-consistency.test.ts @@ -288,7 +288,13 @@ describe('templates survive npm packing', () => { it('restores the aliased names when scaffolding', () => { const expected = walkRel(blankSrc).map(scaffoldedAs).sort(); expect(scaffolded.sort()).toEqual(expected); - // What the CLI prints as "Created files:" must match what it actually wrote. + // `copyDir`'s collected array must match what the copy actually wrote. + // It is NOT the "Created files:" summary and has not been since that + // summary became a walk of the finished project directory: the copy runs + // before ` install` and the skills installer, so a list built here + // could never name what they write (created-summary.ts carries the + // measurement). This still pins the copy — `loadBundled` returns it, and + // the run reports its length as the template-file count. expect(collected.sort()).toEqual(expected); });