@tu-cis-courses/create-project-docs 1.0.0-beta.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Temple University CIS course documentation contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,21 @@
1
+ # @tu-cis-courses/create-project-docs
2
+
3
+ Create a project:
4
+
5
+ ```bash
6
+ npx @tu-cis-courses/create-project-docs@next new my-project
7
+ ```
8
+
9
+ Inside a generated documentation application:
10
+
11
+ ```bash
12
+ npm run docs:list
13
+ npm run docs:add requirements
14
+ npm run docs:add architecture
15
+ npm run docs:add testing
16
+ npm run docs:add api
17
+ npm run docs:upgrade
18
+ ```
19
+
20
+ Run `create-project-docs migrate` in an existing project to adopt managed
21
+ updates without replacing student-authored documentation files.
package/bin.js ADDED
@@ -0,0 +1,519 @@
1
+ #!/usr/bin/env node
2
+
3
+ const {spawnSync} = require('child_process');
4
+ const fs = require('fs');
5
+ const path = require('path');
6
+ const semver = require('semver');
7
+ const {
8
+ applyEntries,
9
+ createState,
10
+ findProjectRoot,
11
+ hashFile,
12
+ managedPath,
13
+ packageInfo,
14
+ payloadEntries,
15
+ readJson,
16
+ readState,
17
+ writeJsonAtomic,
18
+ writeState,
19
+ } = require('./src/project');
20
+
21
+ const TEMPLATE_PACKAGE = '@tu-cis-courses/docusaurus-template';
22
+ const CONTENT_PACKAGE = '@tu-cis-courses/docs-content-template';
23
+ const COMPONENTS_PACKAGE = '@tu-cis-courses/docusaurus-components';
24
+ const PRESET_PACKAGE = '@tu-cis-courses/docusaurus-preset';
25
+ const CLI_PACKAGE = '@tu-cis-courses/create-project-docs';
26
+ const args = process.argv.slice(2);
27
+
28
+ function usage() {
29
+ console.log(`Usage:
30
+ create-project-docs new <project-name> [--skip-install]
31
+ create-project-docs add [--path <directory>] [--skip-install]
32
+ create-project-docs section list
33
+ create-project-docs section add <requirements|architecture|testing|api>
34
+ create-project-docs section update [section]
35
+ create-project-docs update <runtime|template|content|all>
36
+ create-project-docs check [--startup]
37
+ create-project-docs migrate
38
+ create-project-docs doctor`);
39
+ }
40
+
41
+ function argumentValue(flag) {
42
+ const index = args.indexOf(flag);
43
+ return index === -1 ? null : args[index + 1] ?? null;
44
+ }
45
+
46
+ function packageManagerInstall(documentationDir) {
47
+ if (args.includes('--skip-install')) return;
48
+ const command = process.platform === 'win32' ? 'yarn.cmd' : 'yarn';
49
+ const result = spawnSync(command, ['install'], {cwd: documentationDir, stdio: 'inherit'});
50
+ if (result.status !== 0) throw new Error('yarn install failed.');
51
+ }
52
+
53
+ function installTemplateRelease(documentationDir, channel) {
54
+ const command = process.platform === 'win32' ? 'yarn.cmd' : 'yarn';
55
+ const result = spawnSync(command, ['add', '--dev', '--exact', `${TEMPLATE_PACKAGE}@${channel}`], {
56
+ cwd: documentationDir,
57
+ stdio: 'inherit',
58
+ });
59
+ if (result.status !== 0) throw new Error(`Unable to install the ${channel} documentation template.`);
60
+ }
61
+
62
+ function applyRecommendedVersions(packageJson, template) {
63
+ const recommended = template.manifest.recommended;
64
+ const scaffoldPackage = readJson(path.join(template.root, template.manifest.scaffold, 'documentation', 'package.json'));
65
+ packageJson.dependencies ??= {};
66
+ packageJson.devDependencies ??= {};
67
+ packageJson.scripts ??= {};
68
+ for (const [name, version] of Object.entries(scaffoldPackage.dependencies)) {
69
+ if (name.startsWith('@docusaurus/')) packageJson.dependencies[name] = recommended.docusaurus;
70
+ else packageJson.dependencies[name] ??= version;
71
+ }
72
+ for (const [name, version] of Object.entries(scaffoldPackage.devDependencies)) {
73
+ packageJson.devDependencies[name] ??= version;
74
+ }
75
+ packageJson.dependencies[COMPONENTS_PACKAGE] = recommended.docusaurusComponents;
76
+ packageJson.dependencies[PRESET_PACKAGE] = recommended.docusaurusPreset;
77
+ packageJson.devDependencies[CLI_PACKAGE] = recommended.createProjectDocs;
78
+ packageJson.devDependencies[CONTENT_PACKAGE] = recommended.docsContentTemplate;
79
+ packageJson.devDependencies[TEMPLATE_PACKAGE] = template.packageJson.version;
80
+ for (const name of Object.keys(packageJson.dependencies)) {
81
+ if (name.startsWith('@docusaurus/')) packageJson.dependencies[name] = recommended.docusaurus;
82
+ }
83
+ packageJson.resolutions = {...packageJson.resolutions, ...scaffoldPackage.resolutions};
84
+ packageJson.scripts.prestart ??= 'create-project-docs check --startup';
85
+ packageJson.scripts['docs:list'] = 'create-project-docs section list';
86
+ packageJson.scripts['docs:add'] = 'create-project-docs section add';
87
+ packageJson.scripts['docs:update'] = 'create-project-docs section update';
88
+ packageJson.scripts['docs:check'] = 'create-project-docs check';
89
+ packageJson.scripts['docs:upgrade'] = 'create-project-docs update all';
90
+ return recommended;
91
+ }
92
+
93
+ function resolveContent(projectRoot) {
94
+ const documentationDir = path.join(projectRoot, 'documentation');
95
+ const info = packageInfo(CONTENT_PACKAGE, [documentationDir, projectRoot]);
96
+ return {...info, manifest: readJson(path.join(info.root, 'manifest.json'))};
97
+ }
98
+
99
+ function resolveTemplate(projectRoot) {
100
+ const documentationDir = path.join(projectRoot, 'documentation');
101
+ const info = packageInfo(TEMPLATE_PACKAGE, [documentationDir, projectRoot]);
102
+ return {...info, manifest: readJson(path.join(info.root, 'manifest.json'))};
103
+ }
104
+
105
+ function templateEntries(template, projectRoot) {
106
+ const sourceRoot = path.join(template.root, template.manifest.scaffold);
107
+ return payloadEntries(sourceRoot, projectRoot).map((entry) => {
108
+ const relative = path.relative(sourceRoot, entry.source).split(path.sep).join('/');
109
+ const renamed = template.manifest.renames?.[relative];
110
+ return renamed ? {...entry, destination: path.join(projectRoot, renamed)} : entry;
111
+ });
112
+ }
113
+
114
+ function copyCore(projectRoot, state, content) {
115
+ const entries = content.manifest.core.map(({source, destination}) => ({
116
+ source: path.join(content.root, source),
117
+ destination: path.join(projectRoot, destination),
118
+ }));
119
+ return applyEntries({
120
+ projectRoot,
121
+ entries,
122
+ owner: 'content-core',
123
+ version: content.packageJson.version,
124
+ state,
125
+ });
126
+ }
127
+
128
+ function scaffoldProject(projectRoot, allowProjectFiles = false, announce = true) {
129
+ const template = resolveTemplate(projectRoot);
130
+ const content = resolveContent(projectRoot);
131
+ const state = createState();
132
+ const scaffoldResult = applyEntries({
133
+ projectRoot,
134
+ entries: templateEntries(template, projectRoot),
135
+ owner: 'template',
136
+ version: template.packageJson.version,
137
+ state,
138
+ });
139
+ if (scaffoldResult.conflicts.length && !allowProjectFiles) {
140
+ throw new Error('Target contains files managed by template.');
141
+ }
142
+ const coreResult = copyCore(projectRoot, state, content);
143
+ if (coreResult.conflicts.length && !allowProjectFiles) {
144
+ throw new Error('Target contains files managed by content template.');
145
+ }
146
+
147
+ state.packages = {
148
+ template: template.packageJson.version,
149
+ content: content.packageJson.version,
150
+ components: template.manifest.recommended.docusaurusComponents,
151
+ preset: template.manifest.recommended.docusaurusPreset,
152
+ cli: template.manifest.recommended.createProjectDocs,
153
+ };
154
+ writeState(projectRoot, state);
155
+ packageManagerInstall(path.join(projectRoot, 'documentation'));
156
+ if (announce) {
157
+ console.log(`Created documentation project at ${projectRoot}`);
158
+ if (scaffoldResult.conflicts.length + coreResult.conflicts.length > 0) {
159
+ console.log('Existing project files were preserved. Review .tu-cis-docs/updates.');
160
+ }
161
+ console.log('Run: cd documentation && npm start');
162
+ }
163
+ }
164
+
165
+ function sectionDefinition(manifest, requested) {
166
+ return Object.entries(manifest.sections).find(([id, section]) => (
167
+ id === requested || section.aliases?.includes(requested)
168
+ ));
169
+ }
170
+
171
+ function assertContentCompatibility(projectRoot, content) {
172
+ const documentationDir = path.join(projectRoot, 'documentation');
173
+ const requirements = [
174
+ [PRESET_PACKAGE, content.manifest.compatibility?.docusaurusPreset],
175
+ [COMPONENTS_PACKAGE, content.manifest.compatibility?.docusaurusComponents],
176
+ ];
177
+ for (const [packageName, range] of requirements) {
178
+ if (!range) continue;
179
+ const installed = packageInfo(packageName, [documentationDir, projectRoot]).packageJson.version;
180
+ if (!semver.satisfies(installed, range, {includePrerelease: true})) {
181
+ throw new Error(`${CONTENT_PACKAGE} requires ${packageName} ${range}; found ${installed}.`);
182
+ }
183
+ }
184
+ }
185
+
186
+ function listSections(projectRoot) {
187
+ const state = readState(projectRoot, false);
188
+ const content = resolveContent(projectRoot);
189
+ for (const [id, section] of Object.entries(content.manifest.sections)) {
190
+ const status = state.sections[id] ? `installed ${state.sections[id].sourceVersion}` : 'available';
191
+ console.log(`${id.padEnd(14)} ${status.padEnd(24)} ${section.title}`);
192
+ }
193
+ }
194
+
195
+ function addSection(projectRoot, requested) {
196
+ if (!requested) throw new Error('Missing section. Run npm run docs:list.');
197
+ const state = readState(projectRoot);
198
+ const content = resolveContent(projectRoot);
199
+ assertContentCompatibility(projectRoot, content);
200
+ const match = sectionDefinition(content.manifest, requested);
201
+ if (!match) throw new Error(`Unknown section: ${requested}`);
202
+ const [id, section] = match;
203
+ if (state.sections[id]) throw new Error(`${section.title} is already installed.`);
204
+ const destinationRoot = path.join(projectRoot, section.destination);
205
+ if (fs.existsSync(destinationRoot)) {
206
+ throw new Error(`${section.destination} already exists. Existing files were not changed.`);
207
+ }
208
+ const result = applyEntries({
209
+ projectRoot,
210
+ entries: payloadEntries(path.join(content.root, section.source), destinationRoot),
211
+ owner: `section:${id}`,
212
+ version: content.packageJson.version,
213
+ state,
214
+ });
215
+ if (result.conflicts.length) throw new Error('Section contains conflicts. Existing files were not changed.');
216
+ state.sections[id] = {sourceVersion: content.packageJson.version};
217
+ state.packages.content = content.packageJson.version;
218
+ writeState(projectRoot, state);
219
+ console.log(`Added ${section.title}.`);
220
+ }
221
+
222
+ function updateSections(projectRoot, requested) {
223
+ const state = readState(projectRoot);
224
+ const content = resolveContent(projectRoot);
225
+ assertContentCompatibility(projectRoot, content);
226
+ const previousAppliedVersion = state.packages.content;
227
+ const ids = requested
228
+ ? [sectionDefinition(content.manifest, requested)?.[0]].filter(Boolean)
229
+ : Object.keys(state.sections);
230
+ if (requested && ids.length === 0) throw new Error(`Unknown section: ${requested}`);
231
+ if (ids.length === 0) {
232
+ console.log('No sections installed.');
233
+ return {changed: 0, conflicts: 0};
234
+ }
235
+ let changed = 0;
236
+ let conflicts = 0;
237
+ for (const id of ids) {
238
+ if (!state.sections[id]) throw new Error(`${id} is not installed.`);
239
+ const section = content.manifest.sections[id];
240
+ const result = applyEntries({
241
+ projectRoot,
242
+ entries: payloadEntries(
243
+ path.join(content.root, section.source),
244
+ path.join(projectRoot, section.destination),
245
+ ),
246
+ owner: `section:${id}`,
247
+ version: content.packageJson.version,
248
+ state,
249
+ });
250
+ changed += result.changed.length;
251
+ conflicts += result.conflicts.length;
252
+ if (result.conflicts.length === 0) {
253
+ state.sections[id] = {sourceVersion: content.packageJson.version};
254
+ } else {
255
+ state.sections[id].targetVersion = content.packageJson.version;
256
+ }
257
+ }
258
+ state.packageTargets ??= {};
259
+ if (conflicts === 0) {
260
+ state.packages.content = content.packageJson.version;
261
+ delete state.packageTargets.content;
262
+ } else {
263
+ state.packages.content = previousAppliedVersion;
264
+ state.packageTargets.content = content.packageJson.version;
265
+ }
266
+ writeState(projectRoot, state);
267
+ console.log(`Updated ${changed} files. ${conflicts} conflicts preserved.`);
268
+ if (conflicts) console.log('Review .tu-cis-docs/updates.');
269
+ return {changed, conflicts};
270
+ }
271
+
272
+ function updateTemplate(projectRoot) {
273
+ const state = readState(projectRoot);
274
+ const template = resolveTemplate(projectRoot);
275
+ const result = applyEntries({
276
+ projectRoot,
277
+ entries: templateEntries(template, projectRoot),
278
+ owner: 'template',
279
+ version: template.packageJson.version,
280
+ state,
281
+ });
282
+ state.packageTargets ??= {};
283
+ if (result.conflicts.length === 0) {
284
+ state.packages.template = template.packageJson.version;
285
+ delete state.packageTargets.template;
286
+ } else {
287
+ state.packageTargets.template = template.packageJson.version;
288
+ }
289
+ writeState(projectRoot, state);
290
+ console.log(`Updated ${result.changed.length} template files. ${result.conflicts.length} conflicts preserved.`);
291
+ }
292
+
293
+ function updateRuntime(projectRoot) {
294
+ const documentationDir = path.join(projectRoot, 'documentation');
295
+ const packageJsonPath = managedPath(projectRoot, 'documentation/package.json');
296
+ const state = readState(projectRoot);
297
+ installTemplateRelease(documentationDir, state.channel ?? 'latest');
298
+ const packageJson = readJson(packageJsonPath);
299
+ const template = resolveTemplate(projectRoot);
300
+ const recommended = applyRecommendedVersions(packageJson, template);
301
+ writeJsonAtomic(packageJsonPath, packageJson);
302
+ packageManagerInstall(documentationDir);
303
+ state.packages = {
304
+ ...state.packages,
305
+ components: recommended.docusaurusComponents,
306
+ preset: recommended.docusaurusPreset,
307
+ cli: recommended.createProjectDocs,
308
+ };
309
+ writeState(projectRoot, state);
310
+ console.log('Updated runtime package versions.');
311
+ }
312
+
313
+ function updateContent(projectRoot) {
314
+ const state = readState(projectRoot);
315
+ const content = resolveContent(projectRoot);
316
+ assertContentCompatibility(projectRoot, content);
317
+ const previousAppliedVersion = state.packages.content;
318
+ const core = copyCore(projectRoot, state, content);
319
+ writeState(projectRoot, state);
320
+ const sections = updateSections(projectRoot);
321
+ const finalState = readState(projectRoot);
322
+ finalState.packageTargets ??= {};
323
+ if (core.conflicts.length + sections.conflicts > 0) {
324
+ finalState.packages.content = previousAppliedVersion;
325
+ finalState.packageTargets.content = content.packageJson.version;
326
+ } else {
327
+ finalState.packages.content = content.packageJson.version;
328
+ delete finalState.packageTargets.content;
329
+ }
330
+ writeState(projectRoot, finalState);
331
+ console.log(`Updated ${core.changed.length} core files. ${core.conflicts.length} conflicts preserved.`);
332
+ return {core, sections};
333
+ }
334
+
335
+ function doctor(projectRoot) {
336
+ const documentationDir = path.join(projectRoot, 'documentation');
337
+ const packageJsonPath = managedPath(projectRoot, 'documentation/package.json');
338
+ const checks = [
339
+ ['documentation application', fs.existsSync(packageJsonPath)],
340
+ ['managed state', fs.existsSync(path.join(projectRoot, '.tu-cis-docs', 'manifest.json'))],
341
+ ];
342
+ const packageJson = fs.existsSync(packageJsonPath) ? readJson(packageJsonPath) : {};
343
+ checks.push(['components package', Boolean(packageJson.dependencies?.[COMPONENTS_PACKAGE])]);
344
+ checks.push(['preset package', Boolean(packageJson.dependencies?.[PRESET_PACKAGE])]);
345
+ checks.push(['content template package', Boolean(packageJson.devDependencies?.[CONTENT_PACKAGE])]);
346
+ let failed = false;
347
+ for (const [label, passed] of checks) {
348
+ console.log(`${passed ? 'ok' : 'missing'}\t${label}`);
349
+ failed ||= !passed;
350
+ }
351
+ if (failed) process.exitCode = 1;
352
+ }
353
+
354
+ async function checkUpdates(projectRoot, startup) {
355
+ const state = readState(projectRoot, false);
356
+ const documentationDir = path.join(projectRoot, 'documentation');
357
+ const packageJsonPath = path.join(documentationDir, 'package.json');
358
+ if (!fs.existsSync(packageJsonPath)) {
359
+ if (!startup) console.log('No documentation application found.');
360
+ return;
361
+ }
362
+ const packageJson = readJson(packageJsonPath);
363
+ const installed = {
364
+ template: packageJson.devDependencies?.[TEMPLATE_PACKAGE],
365
+ content: packageJson.devDependencies?.[CONTENT_PACKAGE],
366
+ components: packageJson.dependencies?.[COMPONENTS_PACKAGE],
367
+ preset: packageJson.dependencies?.[PRESET_PACKAGE],
368
+ cli: packageJson.devDependencies?.[CLI_PACKAGE],
369
+ };
370
+ if (!startup) {
371
+ for (const [name, version] of Object.entries(installed)) {
372
+ console.log(`${name.padEnd(12)} ${version ?? 'not installed'}${state.packages[name] ? ` (applied ${state.packages[name]})` : ''}`);
373
+ }
374
+ }
375
+ if (process.env.CI || process.env.NO_UPDATE_NOTIFIER === '1') return;
376
+
377
+ const cachePath = managedPath(projectRoot, 'documentation/.cache/tu-cis-docs-update.json');
378
+ const maxAge = 24 * 60 * 60 * 1000;
379
+ let cached = null;
380
+ if (fs.existsSync(cachePath)) {
381
+ try { cached = readJson(cachePath); } catch {}
382
+ }
383
+ if (cached && Date.now() - cached.checkedAt < maxAge) {
384
+ if (cached.message) console.log(cached.message);
385
+ return;
386
+ }
387
+
388
+ try {
389
+ const controller = new AbortController();
390
+ const timeout = setTimeout(() => controller.abort(), 1500);
391
+ const registryName = encodeURIComponent(TEMPLATE_PACKAGE).replace('%2F', '%2f');
392
+ const tag = state.channel ?? 'latest';
393
+ const response = await fetch(`https://registry.npmjs.org/${registryName}/${tag}`, {signal: controller.signal});
394
+ clearTimeout(timeout);
395
+ if (!response.ok) return;
396
+ const latest = await response.json();
397
+ const current = installed.template;
398
+ const message = current && current !== latest.version
399
+ ? `[tu-cis-docs] Template update available: ${current} -> ${latest.version}. Run npm run docs:upgrade.`
400
+ : '';
401
+ writeJsonAtomic(cachePath, {checkedAt: Date.now(), message});
402
+ if (message) console.log(message);
403
+ } catch {
404
+ // Startup remains available offline.
405
+ }
406
+ }
407
+
408
+ function migrate(projectRoot) {
409
+ if (!fs.existsSync(path.join(projectRoot, 'documentation', 'package.json'))) {
410
+ throw new Error('No legacy documentation application found.');
411
+ }
412
+ if (fs.existsSync(path.join(projectRoot, '.tu-cis-docs', 'manifest.json'))) {
413
+ throw new Error('Project is already managed.');
414
+ }
415
+ const state = createState();
416
+ const content = resolveContent(projectRoot);
417
+ const template = resolveTemplate(projectRoot);
418
+ for (const {source, destination} of content.manifest.core) {
419
+ const sourcePath = path.join(content.root, source);
420
+ const destinationPath = path.join(projectRoot, destination);
421
+ if (!fs.existsSync(destinationPath)) continue;
422
+ const relative = path.relative(projectRoot, destinationPath).split(path.sep).join('/');
423
+ state.files[relative] = {
424
+ owner: 'content-core',
425
+ sourceVersion: content.packageJson.version,
426
+ sourceHash: hashFile(sourcePath),
427
+ };
428
+ }
429
+ for (const [id, section] of Object.entries(content.manifest.sections)) {
430
+ const destinationRoot = path.join(projectRoot, section.destination);
431
+ if (!fs.existsSync(destinationRoot)) continue;
432
+ let recognized = false;
433
+ for (const entry of payloadEntries(path.join(content.root, section.source), destinationRoot)) {
434
+ const relative = path.relative(projectRoot, entry.destination).split(path.sep).join('/');
435
+ if (fs.existsSync(entry.destination)) {
436
+ recognized = true;
437
+ state.files[relative] = {
438
+ owner: `section:${id}`,
439
+ sourceVersion: content.packageJson.version,
440
+ sourceHash: hashFile(entry.source),
441
+ };
442
+ }
443
+ }
444
+ if (!recognized) continue;
445
+ state.sections[id] = {sourceVersion: content.packageJson.version};
446
+ }
447
+ const documentationDir = path.join(projectRoot, 'documentation');
448
+ const packageJsonPath = managedPath(projectRoot, 'documentation/package.json');
449
+ const packageJson = readJson(packageJsonPath);
450
+ const recommended = applyRecommendedVersions(packageJson, template);
451
+ writeJsonAtomic(packageJsonPath, packageJson);
452
+ packageManagerInstall(documentationDir);
453
+ state.packages.components = recommended.docusaurusComponents;
454
+ state.packages.preset = recommended.docusaurusPreset;
455
+ state.packages.cli = recommended.createProjectDocs;
456
+ state.packages.content = content.packageJson.version;
457
+ writeState(projectRoot, state);
458
+ console.log('Initialized managed state. Existing files preserved. Run create-project-docs update template next.');
459
+ }
460
+
461
+ async function main() {
462
+ if (args.length === 0 || args.includes('--help') || args.includes('-h')) {
463
+ usage();
464
+ return;
465
+ }
466
+ const command = args[0];
467
+ if (command === 'new') {
468
+ const name = args[1];
469
+ if (!name || name.startsWith('-')) throw new Error('Missing project name.');
470
+ const target = path.resolve(name);
471
+ if (fs.existsSync(target)) throw new Error(`Target already exists: ${target}`);
472
+ const parent = path.dirname(target);
473
+ if (!fs.existsSync(parent)) throw new Error(`Parent directory does not exist: ${parent}`);
474
+ const temporaryTarget = fs.mkdtempSync(path.join(parent, '.create-project-docs-'));
475
+ try {
476
+ scaffoldProject(temporaryTarget, false, false);
477
+ fs.renameSync(temporaryTarget, target);
478
+ } catch (error) {
479
+ fs.rmSync(temporaryTarget, {recursive: true, force: true});
480
+ throw error;
481
+ }
482
+ console.log(`Created documentation project at ${target}`);
483
+ console.log('Run: cd documentation && npm start');
484
+ return;
485
+ }
486
+ if (command === 'add') {
487
+ const target = path.resolve(argumentValue('--path') ?? process.cwd());
488
+ if (!fs.existsSync(target)) throw new Error(`Target does not exist: ${target}`);
489
+ if (fs.existsSync(path.join(target, 'documentation'))) throw new Error('documentation already exists.');
490
+ scaffoldProject(target, true);
491
+ return;
492
+ }
493
+
494
+ const projectRoot = findProjectRoot(process.cwd());
495
+ if (command === 'section' && args[1] === 'list') return listSections(projectRoot);
496
+ if (command === 'section' && args[1] === 'add') return addSection(projectRoot, args[2]);
497
+ if (command === 'section' && args[1] === 'update') return updateSections(projectRoot, args[2]);
498
+ if (command === 'doctor') return doctor(projectRoot);
499
+ if (command === 'migrate') return migrate(projectRoot);
500
+ if (command === 'check') return checkUpdates(projectRoot, args.includes('--startup'));
501
+ if (command === 'update') {
502
+ const target = args[1];
503
+ if (target === 'runtime') return updateRuntime(projectRoot);
504
+ if (target === 'template') return updateTemplate(projectRoot);
505
+ if (target === 'content') return updateContent(projectRoot);
506
+ if (target === 'all') {
507
+ updateRuntime(projectRoot);
508
+ updateTemplate(projectRoot);
509
+ return updateContent(projectRoot);
510
+ }
511
+ }
512
+ usage();
513
+ process.exitCode = 1;
514
+ }
515
+
516
+ main().catch((error) => {
517
+ console.error(error.message);
518
+ process.exitCode = 1;
519
+ });
package/package.json ADDED
@@ -0,0 +1,27 @@
1
+ {
2
+ "name": "@tu-cis-courses/create-project-docs",
3
+ "version": "1.0.0-beta.1",
4
+ "description": "Create and update Temple University CIS Docusaurus documentation sites.",
5
+ "license": "MIT",
6
+ "bin": {
7
+ "create-project-docs": "bin.js"
8
+ },
9
+ "files": ["bin.js", "src", "README.md", "LICENSE"],
10
+ "scripts": {
11
+ "test": "node --test"
12
+ },
13
+ "publishConfig": {"access": "public", "provenance": true},
14
+ "repository": {
15
+ "type": "git",
16
+ "url": "git+https://github.com/ApplebaumIan/tu-cis-docs-packages.git",
17
+ "directory": "packages/create-project-docs"
18
+ },
19
+ "engines": {
20
+ "node": ">=20.0.0"
21
+ },
22
+ "dependencies": {
23
+ "@tu-cis-courses/docs-content-template": "1.0.0-beta.1",
24
+ "@tu-cis-courses/docusaurus-template": "1.0.0-beta.1",
25
+ "semver": "^7.7.2"
26
+ }
27
+ }
package/src/project.js ADDED
@@ -0,0 +1,210 @@
1
+ const crypto = require('crypto');
2
+ const fs = require('fs');
3
+ const path = require('path');
4
+ const {version: cliVersion} = require('../package.json');
5
+
6
+ const STATE_SCHEMA_VERSION = 1;
7
+ const STATE_DIRECTORY = '.tu-cis-docs';
8
+ const STATE_FILE = 'manifest.json';
9
+
10
+ function hashBuffer(value) {
11
+ return crypto.createHash('sha256').update(value).digest('hex');
12
+ }
13
+
14
+ function hashFile(filePath) {
15
+ return hashBuffer(fs.readFileSync(filePath));
16
+ }
17
+
18
+ function readJson(filePath) {
19
+ return JSON.parse(fs.readFileSync(filePath, 'utf8'));
20
+ }
21
+
22
+ function writeJsonAtomic(filePath, value) {
23
+ fs.mkdirSync(path.dirname(filePath), {recursive: true});
24
+ const temporaryPath = `${filePath}.tmp-${process.pid}-${crypto.randomBytes(6).toString('hex')}`;
25
+ try {
26
+ fs.writeFileSync(temporaryPath, `${JSON.stringify(value, null, 2)}\n`, {flag: 'wx'});
27
+ fs.renameSync(temporaryPath, filePath);
28
+ } finally {
29
+ if (fs.existsSync(temporaryPath)) fs.rmSync(temporaryPath);
30
+ }
31
+ }
32
+
33
+ function walkFiles(directory) {
34
+ if (!fs.existsSync(directory)) return [];
35
+
36
+ return fs.readdirSync(directory, {withFileTypes: true}).flatMap((entry) => {
37
+ const fullPath = path.join(directory, entry.name);
38
+ return entry.isDirectory() ? walkFiles(fullPath) : [fullPath];
39
+ });
40
+ }
41
+
42
+ function findProjectRoot(startDirectory) {
43
+ const start = path.resolve(startDirectory);
44
+ let current = start;
45
+ while (true) {
46
+ if (fs.existsSync(path.join(current, 'documentation', 'package.json'))) return current;
47
+ if (fs.existsSync(path.join(current, 'docusaurus.config.js'))) return path.dirname(current);
48
+ const parent = path.dirname(current);
49
+ if (parent === current) return start;
50
+ current = parent;
51
+ }
52
+ }
53
+
54
+ function statePath(projectRoot) {
55
+ return managedPath(projectRoot, path.join(STATE_DIRECTORY, STATE_FILE));
56
+ }
57
+
58
+ function createState() {
59
+ return {
60
+ schemaVersion: STATE_SCHEMA_VERSION,
61
+ channel: cliVersion.includes('-') ? 'next' : 'latest',
62
+ packages: {},
63
+ packageTargets: {},
64
+ sections: {},
65
+ files: {},
66
+ conflicts: {},
67
+ };
68
+ }
69
+
70
+ function readState(projectRoot, required = true) {
71
+ const filePath = statePath(projectRoot);
72
+ if (!fs.existsSync(filePath)) {
73
+ if (required) throw new Error('Project is not managed. Run create-project-docs migrate first.');
74
+ return createState();
75
+ }
76
+
77
+ const state = readJson(filePath);
78
+ if (state.schemaVersion !== STATE_SCHEMA_VERSION) {
79
+ throw new Error(`Unsupported state schema ${state.schemaVersion}. Update create-project-docs.`);
80
+ }
81
+ return state;
82
+ }
83
+
84
+ function writeState(projectRoot, state) {
85
+ writeJsonAtomic(statePath(projectRoot), state);
86
+ }
87
+
88
+ function relativePath(projectRoot, filePath) {
89
+ return path.relative(projectRoot, filePath).split(path.sep).join('/');
90
+ }
91
+
92
+ function managedPath(projectRoot, relative) {
93
+ if (!relative || path.isAbsolute(relative)) throw new Error(`Invalid managed path: ${relative}`);
94
+ const root = path.resolve(projectRoot);
95
+ const destination = path.resolve(root, relative);
96
+ if (!destination.startsWith(`${root}${path.sep}`)) throw new Error(`Invalid managed path: ${relative}`);
97
+
98
+ let current = root;
99
+ for (const segment of path.relative(root, destination).split(path.sep)) {
100
+ current = path.join(current, segment);
101
+ if (fs.existsSync(current) && fs.lstatSync(current).isSymbolicLink()) {
102
+ throw new Error(`Managed path cannot contain a symbolic link: ${relative}`);
103
+ }
104
+ }
105
+ return destination;
106
+ }
107
+
108
+ function payloadEntries(sourceRoot, destinationRoot) {
109
+ return walkFiles(sourceRoot).map((source) => ({
110
+ source,
111
+ destination: path.join(destinationRoot, path.relative(sourceRoot, source)),
112
+ }));
113
+ }
114
+
115
+ function stageConflict(projectRoot, owner, version, entry) {
116
+ const relative = relativePath(projectRoot, entry.destination);
117
+ const stagedPath = managedPath(
118
+ projectRoot,
119
+ path.join(
120
+ STATE_DIRECTORY,
121
+ 'updates',
122
+ `${owner.replace(/[^a-z0-9-]/gi, '-')}-${String(version).replace(/[^a-z0-9-]/gi, '-')}`,
123
+ relative,
124
+ ),
125
+ );
126
+ fs.mkdirSync(path.dirname(stagedPath), {recursive: true});
127
+ fs.copyFileSync(entry.source, stagedPath);
128
+ return relativePath(projectRoot, stagedPath);
129
+ }
130
+
131
+ function applyEntries({projectRoot, entries, owner, version, state}) {
132
+ const targetPaths = new Set(entries.map((entry) => relativePath(projectRoot, entry.destination)));
133
+ const conflicts = [];
134
+ const changed = [];
135
+
136
+ for (const entry of entries) {
137
+ const relative = relativePath(projectRoot, entry.destination);
138
+ const destination = managedPath(projectRoot, relative);
139
+ const previous = state.files[relative];
140
+ const sourceHash = hashFile(entry.source);
141
+ const destinationExists = fs.existsSync(destination);
142
+ const destinationHash = destinationExists ? hashFile(destination) : null;
143
+ const canWrite = !destinationExists
144
+ ? !previous
145
+ : destinationHash === sourceHash
146
+ || (previous && destinationHash === previous.sourceHash);
147
+
148
+ if (!canWrite) {
149
+ const staged = stageConflict(projectRoot, owner, version, entry);
150
+ conflicts.push({path: relative, staged});
151
+ state.conflicts[relative] = {owner, targetVersion: version, staged};
152
+ continue;
153
+ }
154
+
155
+ if (destinationHash !== sourceHash) {
156
+ fs.mkdirSync(path.dirname(destination), {recursive: true});
157
+ fs.copyFileSync(entry.source, destination);
158
+ changed.push(relative);
159
+ }
160
+ state.files[relative] = {owner, sourceVersion: version, sourceHash};
161
+ delete state.conflicts[relative];
162
+ }
163
+
164
+ for (const [relative, previous] of Object.entries(state.files)) {
165
+ if (previous.owner !== owner || targetPaths.has(relative)) continue;
166
+ const destination = managedPath(projectRoot, relative);
167
+ if (!fs.existsSync(destination)) {
168
+ delete state.files[relative];
169
+ continue;
170
+ }
171
+ if (hashFile(destination) === previous.sourceHash) {
172
+ fs.rmSync(destination);
173
+ delete state.files[relative];
174
+ changed.push(relative);
175
+ } else {
176
+ conflicts.push({path: relative, staged: null});
177
+ state.conflicts[relative] = {owner, targetVersion: version, staged: null, removal: true};
178
+ }
179
+ }
180
+
181
+ return {changed, conflicts};
182
+ }
183
+
184
+ function resolvePackageRoot(packageName, searchPaths = []) {
185
+ const packageJsonPath = require.resolve(`${packageName}/package.json`, {
186
+ paths: [...searchPaths, __dirname],
187
+ });
188
+ return path.dirname(packageJsonPath);
189
+ }
190
+
191
+ function packageInfo(packageName, searchPaths = []) {
192
+ const root = resolvePackageRoot(packageName, searchPaths);
193
+ return {root, packageJson: readJson(path.join(root, 'package.json'))};
194
+ }
195
+
196
+ module.exports = {
197
+ applyEntries,
198
+ createState,
199
+ findProjectRoot,
200
+ hashFile,
201
+ managedPath,
202
+ packageInfo,
203
+ payloadEntries,
204
+ readJson,
205
+ readState,
206
+ statePath,
207
+ walkFiles,
208
+ writeJsonAtomic,
209
+ writeState,
210
+ };