@mriekki/project-forge 1.0.0 → 2.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +135 -135
- package/assets/components.json +33 -33
- package/assets/core/distribution.json +73 -66
- package/assets/deployment/distribution.json +54 -54
- package/assets/generated/ASSEMBLY.json +46 -11
- package/assets/generated/core/core/process/APPLICATION_VERSIONING.md +137 -0
- package/assets/generated/core/core/process/OPERATION_JOURNAL.md +140 -0
- package/assets/generated/core/core/process/PROJECT_FORGE_ENGINEERING_GUIDE.md +3 -3
- package/assets/generated/core/core/process/PROJECT_REQUIREMENTS_EXPORT.md +165 -0
- package/assets/generated/core/core/skills/build/SKILL.md +70 -0
- package/assets/generated/core/core/skills/deploy/SKILL.md +1 -1
- package/assets/generated/core/core/skills/deployment-readiness/SKILL.md +1 -1
- package/assets/generated/core/core/skills/developer-env-mode/SKILL.md +1 -1
- package/assets/generated/core/core/skills/export-requirements/SKILL.md +132 -0
- package/assets/generated/core/core/skills/progress/SKILL.md +2 -2
- package/assets/generated/core/core/skills/project-forge-update/SKILL.md +10 -10
- package/assets/generated/core/core/skills/replatform/SKILL.md +122 -0
- package/assets/generated/core/core/skills/run/SKILL.md +75 -0
- package/assets/generated/core/core/skills/setup/SKILL.md +3 -3
- package/assets/generated/core/core/skills/tear-down/SKILL.md +1 -1
- package/bin/project-forge.mjs +9 -9
- package/package.json +33 -33
- package/src/cli.mjs +150 -150
- package/src/engine.mjs +243 -243
- package/src/package-data.mjs +18 -18
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: run
|
|
3
|
+
description: "Run the current project locally through its repository-owned runtime command without conflating local execution with deployment."
|
|
4
|
+
argument-hint: "[target|all]"
|
|
5
|
+
user-invocable: true
|
|
6
|
+
disable-model-invocation: true
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
# Run
|
|
10
|
+
|
|
11
|
+
`/run` is the normal convenience entry point for starting the project locally. It resolves and delegates to repository-owned runtime commands rather than requiring the developer to remember framework-specific launch syntax.
|
|
12
|
+
|
|
13
|
+
## Contract
|
|
14
|
+
|
|
15
|
+
Invoking `/run` authorizes one bounded local start attempt for the requested target. It does not authorize setup, source edits, deployment, release publication, Production access, or persistent background services outside the repository's normal local-development contract.
|
|
16
|
+
|
|
17
|
+
Resolve the local runtime command from current repository evidence in this order:
|
|
18
|
+
|
|
19
|
+
1. an explicit repository-owned run/dev wrapper or documented command;
|
|
20
|
+
2. an explicit package/task script whose purpose is local run/dev;
|
|
21
|
+
3. an unambiguous ecosystem-native local entry point already present in the repository.
|
|
22
|
+
|
|
23
|
+
Do not guess. If the repository has multiple independent runtimes and no documented default, require a target such as `/run web`, `/run api`, or `/run all`.
|
|
24
|
+
|
|
25
|
+
## Preconditions
|
|
26
|
+
|
|
27
|
+
Before starting:
|
|
28
|
+
|
|
29
|
+
- identify repository root and current Git state;
|
|
30
|
+
- inspect `PROJECT_BOOTSTRAP.json` and scaffold/readiness evidence when present;
|
|
31
|
+
- verify the selected runtime entry point exists;
|
|
32
|
+
- inspect any documented local ports/health endpoints;
|
|
33
|
+
- do not create an intentionally deferred scaffold merely to make `/run` succeed.
|
|
34
|
+
|
|
35
|
+
If runtime source is intentionally deferred or absent, return `RUN NOT AVAILABLE` and identify the owning work item or missing marker when repository evidence provides it.
|
|
36
|
+
|
|
37
|
+
If workstation tools or dependencies are missing, recommend `/setup` instead of silently installing them.
|
|
38
|
+
|
|
39
|
+
## Execution
|
|
40
|
+
|
|
41
|
+
Start the process in an observable developer-controlled terminal/process surface. Do not hide a long-running application in an unexplained background process.
|
|
42
|
+
|
|
43
|
+
Report:
|
|
44
|
+
|
|
45
|
+
- target;
|
|
46
|
+
- resolved command identity;
|
|
47
|
+
- process/startup result;
|
|
48
|
+
- effective application release/build identity when the running application exposes it;
|
|
49
|
+
- local URL/port or health evidence only when repository/runtime evidence establishes it;
|
|
50
|
+
- how to stop the process.
|
|
51
|
+
|
|
52
|
+
When `/run all` is explicitly supported, start only the repository-defined set of local components and make each process/endpoint visible.
|
|
53
|
+
|
|
54
|
+
When the project implements `docs/process/APPLICATION_VERSIONING.md`, prefer runtime-observed version evidence over a source-file guess. Do not synthesize a build number or claim that a static source label proves the running artifact's identity.
|
|
55
|
+
|
|
56
|
+
## Operation journal
|
|
57
|
+
|
|
58
|
+
`/run` may write a concise sanitized startup receipt under `.project-forge/logs/run/` following `docs/process/OPERATION_JOURNAL.md`.
|
|
59
|
+
|
|
60
|
+
Record startup command identity, target, timestamps, startup/health result, observed application version/build identity, and endpoint when established. Do not duplicate or continuously capture unbounded application stdout/stderr into the Project Forge journal.
|
|
61
|
+
|
|
62
|
+
## Deployment boundary
|
|
63
|
+
|
|
64
|
+
`/run` is not `/deploy local`.
|
|
65
|
+
|
|
66
|
+
It must not:
|
|
67
|
+
|
|
68
|
+
- mutate remote environments;
|
|
69
|
+
- publish images/packages/releases;
|
|
70
|
+
- change deployment bindings;
|
|
71
|
+
- infer Production configuration;
|
|
72
|
+
- bump or rewrite release version state;
|
|
73
|
+
- stage, commit, push, merge, or clean branches.
|
|
74
|
+
|
|
75
|
+
A successful `/run` proves only that the requested local runtime started with the evidence observed in that session.
|
|
@@ -24,7 +24,7 @@ From the repository root inspect only what is needed:
|
|
|
24
24
|
|
|
25
25
|
- `DEV_ENVIRONMENT.json`;
|
|
26
26
|
- `DEPLOYMENT_TOPOLOGY.json` when present;
|
|
27
|
-
- `
|
|
27
|
+
- `PROJECT_BOOTSTRAP.json` as generation/provenance and initial topology fallback;
|
|
28
28
|
- `SCAFFOLD_PLAN.json` when present;
|
|
29
29
|
- `.github/project-forge-workspace.json` and Project Forge/component baselines when present;
|
|
30
30
|
- `scripts/setup.ps1` on Windows or `scripts/setup.sh` on macOS;
|
|
@@ -167,9 +167,9 @@ Prefer project-owned mutable deployment topology:
|
|
|
167
167
|
DEPLOYMENT_TOPOLOGY.json
|
|
168
168
|
```
|
|
169
169
|
|
|
170
|
-
Use `
|
|
170
|
+
Use `PROJECT_BOOTSTRAP.json` deployment topology only as the initial topology fallback.
|
|
171
171
|
|
|
172
|
-
`
|
|
172
|
+
`PROJECT_BOOTSTRAP.json` is generation/provenance evidence and should not be casually rewritten merely because a developer adds an environment later.
|
|
173
173
|
|
|
174
174
|
If an older POC has no `DEPLOYMENT_TOPOLOGY.json` and the developer chooses to add/change a logical environment, first materialize the currently approved deployment topology into the project-owned file, then make the scoped project configuration change there. Preserve the original Bootstrap provenance file.
|
|
175
175
|
|
|
@@ -29,7 +29,7 @@ Prefer:
|
|
|
29
29
|
DEPLOYMENT_TOPOLOGY.json
|
|
30
30
|
```
|
|
31
31
|
|
|
32
|
-
Use `
|
|
32
|
+
Use `PROJECT_BOOTSTRAP.json` deployment topology only as the initial topology fallback.
|
|
33
33
|
|
|
34
34
|
Resolve only configured logical environments and their current purpose/Production classification, placement/target binding, repository/application identity, and ownership evidence.
|
|
35
35
|
|
package/bin/project-forge.mjs
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
import { run } from '../src/cli.mjs';
|
|
3
|
-
|
|
4
|
-
try {
|
|
5
|
-
process.exitCode = await run();
|
|
6
|
-
} catch (error) {
|
|
7
|
-
console.error(`Project Forge error: ${error.message}`);
|
|
8
|
-
process.exitCode = 2;
|
|
9
|
-
}
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { run } from '../src/cli.mjs';
|
|
3
|
+
|
|
4
|
+
try {
|
|
5
|
+
process.exitCode = await run();
|
|
6
|
+
} catch (error) {
|
|
7
|
+
console.error(`Project Forge error: ${error.message}`);
|
|
8
|
+
process.exitCode = 2;
|
|
9
|
+
}
|
package/package.json
CHANGED
|
@@ -1,33 +1,33 @@
|
|
|
1
|
-
{
|
|
2
|
-
"name": "@mriekki/project-forge",
|
|
3
|
-
"version": "1.0
|
|
4
|
-
"description": "Public consumer distribution and updater for Project Forge engineering assets.",
|
|
5
|
-
"type": "module",
|
|
6
|
-
"bin": {
|
|
7
|
-
"project-forge": "bin/project-forge.mjs"
|
|
8
|
-
},
|
|
9
|
-
"files": [
|
|
10
|
-
"bin/",
|
|
11
|
-
"src/",
|
|
12
|
-
"assets/",
|
|
13
|
-
"README.md"
|
|
14
|
-
],
|
|
15
|
-
"scripts": {
|
|
16
|
-
"test": "node --test",
|
|
17
|
-
"assemble:assets": "node scripts/assemble-assets.mjs",
|
|
18
|
-
"verify:assets": "node scripts/verify-assets.mjs",
|
|
19
|
-
"release:prepare": "node scripts/prepare-release.mjs",
|
|
20
|
-
"prepack": "npm run verify:assets"
|
|
21
|
-
},
|
|
22
|
-
"engines": {
|
|
23
|
-
"node": ">=20"
|
|
24
|
-
},
|
|
25
|
-
"repository": {
|
|
26
|
-
"type": "git",
|
|
27
|
-
"url": "git+https://github.com/mriekki/project-forge-platform.git"
|
|
28
|
-
},
|
|
29
|
-
"publishConfig": {
|
|
30
|
-
"access": "public",
|
|
31
|
-
"provenance": true
|
|
32
|
-
}
|
|
33
|
-
}
|
|
1
|
+
{
|
|
2
|
+
"name": "@mriekki/project-forge",
|
|
3
|
+
"version": "2.1.0",
|
|
4
|
+
"description": "Public consumer distribution and updater for Project Forge engineering assets.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"project-forge": "bin/project-forge.mjs"
|
|
8
|
+
},
|
|
9
|
+
"files": [
|
|
10
|
+
"bin/",
|
|
11
|
+
"src/",
|
|
12
|
+
"assets/",
|
|
13
|
+
"README.md"
|
|
14
|
+
],
|
|
15
|
+
"scripts": {
|
|
16
|
+
"test": "node --test",
|
|
17
|
+
"assemble:assets": "node scripts/assemble-assets.mjs",
|
|
18
|
+
"verify:assets": "node scripts/verify-assets.mjs",
|
|
19
|
+
"release:prepare": "node scripts/prepare-release.mjs",
|
|
20
|
+
"prepack": "npm run verify:assets"
|
|
21
|
+
},
|
|
22
|
+
"engines": {
|
|
23
|
+
"node": ">=20"
|
|
24
|
+
},
|
|
25
|
+
"repository": {
|
|
26
|
+
"type": "git",
|
|
27
|
+
"url": "git+https://github.com/mriekki/project-forge-platform.git"
|
|
28
|
+
},
|
|
29
|
+
"publishConfig": {
|
|
30
|
+
"access": "public",
|
|
31
|
+
"provenance": true
|
|
32
|
+
}
|
|
33
|
+
}
|
package/src/cli.mjs
CHANGED
|
@@ -1,150 +1,150 @@
|
|
|
1
|
-
import fs from 'node:fs';
|
|
2
|
-
import path from 'node:path';
|
|
3
|
-
import { execFileSync } from 'node:child_process';
|
|
4
|
-
import readline from 'node:readline/promises';
|
|
5
|
-
import { stdin as input, stdout as output } from 'node:process';
|
|
6
|
-
import { applyProfile, checkProfile, loadProjectForgeBaseline, planProfile } from './engine.mjs';
|
|
7
|
-
import { loadPackageData } from './package-data.mjs';
|
|
8
|
-
|
|
9
|
-
function parseArgs(argv) {
|
|
10
|
-
const options = { acceptedRetired: new Map() };
|
|
11
|
-
const positional = [];
|
|
12
|
-
for (let i = 0; i < argv.length; i += 1) {
|
|
13
|
-
const arg = argv[i];
|
|
14
|
-
if (arg === '--help' || arg === '-h') options.help = true;
|
|
15
|
-
else if (arg === '--apply' || arg === '--yes') options.apply = true;
|
|
16
|
-
else if (arg === '--root') options.root = argv[++i];
|
|
17
|
-
else if (arg === '--profile') options.profile = argv[++i];
|
|
18
|
-
else if (arg === '--consumer') options.consumer = argv[++i];
|
|
19
|
-
else if (arg === '--consumer-baseline') options.consumerBaseline = argv[++i];
|
|
20
|
-
else if (arg === '--accept-retired') {
|
|
21
|
-
const value = argv[++i] ?? '';
|
|
22
|
-
const index = value.indexOf(':');
|
|
23
|
-
if (index <= 0) throw new Error('--accept-retired must use component:path');
|
|
24
|
-
const component = value.slice(0, index);
|
|
25
|
-
const retiredPath = value.slice(index + 1);
|
|
26
|
-
if (!options.acceptedRetired.has(component)) options.acceptedRetired.set(component, new Set());
|
|
27
|
-
options.acceptedRetired.get(component).add(retiredPath);
|
|
28
|
-
} else if (arg.startsWith('-')) throw new Error(`Unknown option: ${arg}`);
|
|
29
|
-
else positional.push(arg);
|
|
30
|
-
}
|
|
31
|
-
return { command: positional[0] ?? 'help', options };
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
function inferConsumer(root) {
|
|
35
|
-
try {
|
|
36
|
-
const origin = execFileSync('git', ['-C', root, 'config', '--get', 'remote.origin.url'], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim();
|
|
37
|
-
if (origin) {
|
|
38
|
-
const normalized = origin.replace(/\\/g, '/').replace(/\.git$/, '');
|
|
39
|
-
const match = normalized.match(/[:/]([^/:]+\/[^/]+)$/);
|
|
40
|
-
if (match) return match[1].split('/').pop();
|
|
41
|
-
}
|
|
42
|
-
} catch {}
|
|
43
|
-
return path.basename(root);
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
function printPlan(plan) {
|
|
47
|
-
console.log(`Project Forge profile: ${plan.profileName}`);
|
|
48
|
-
for (const component of plan.plans) {
|
|
49
|
-
console.log(`\n${component.componentId.toUpperCase()} ${component.component.commit}`);
|
|
50
|
-
for (const row of component.rows) console.log(`${row.state.padEnd(20)} ${row.path}`);
|
|
51
|
-
for (const retired of component.retired) console.log(`${retired.state.padEnd(20)} ${retired.path}`);
|
|
52
|
-
}
|
|
53
|
-
console.log(`\nUnsafe drift/conflicts: ${plan.unsafe.length}`);
|
|
54
|
-
for (const item of plan.unsafe) console.log(` - ${item.component}: ${item.state}: ${item.path}`);
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
async function shouldApply(options) {
|
|
58
|
-
if (options.apply) return true;
|
|
59
|
-
if (!input.isTTY || !output.isTTY) return false;
|
|
60
|
-
const rl = readline.createInterface({ input, output });
|
|
61
|
-
try {
|
|
62
|
-
const answer = (await rl.question('Apply this safe Project Forge asset plan? [y/N] ')).trim().toLowerCase();
|
|
63
|
-
return answer === 'y' || answer === 'yes';
|
|
64
|
-
} finally {
|
|
65
|
-
rl.close();
|
|
66
|
-
}
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
function usage(command) {
|
|
70
|
-
const commandUsage = {
|
|
71
|
-
install: 'project-forge install [--root PATH] [--profile NAME] [--apply|--yes] [--consumer NAME] [--consumer-baseline LABEL] [--accept-retired component:path]',
|
|
72
|
-
update: 'project-forge update [--root PATH] [--profile NAME] [--apply|--yes] [--accept-retired component:path]',
|
|
73
|
-
status: 'project-forge status [--root PATH] [--profile NAME]',
|
|
74
|
-
check: 'project-forge check [--root PATH] [--profile NAME]',
|
|
75
|
-
version: 'project-forge version'
|
|
76
|
-
};
|
|
77
|
-
|
|
78
|
-
if (commandUsage[command]) {
|
|
79
|
-
console.log(`@mriekki/project-forge\n\nUsage:\n ${commandUsage[command]}\n`);
|
|
80
|
-
return;
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
console.log(`@mriekki/project-forge\n\nCommands:\n install Preview/install bundled Project Forge assets\n update Preview/update to this package's bundled Project Forge assets\n status Show installed umbrella/component provenance\n check Verify managed files and baselines exactly match this package\n version Show package version\n\nCommon options:\n --root PATH\n --profile greenfield-project\n --apply | --yes\n --consumer NAME\n --consumer-baseline LABEL\n --accept-retired core:path\n -h, --help\n`);
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
export async function run(argv = process.argv.slice(2), packageData = loadPackageData()) {
|
|
87
|
-
const { command, options } = parseArgs(argv);
|
|
88
|
-
if (command === 'help') { usage(); return 0; }
|
|
89
|
-
if (options.help) {
|
|
90
|
-
if (!['install', 'update', 'status', 'check', 'version'].includes(command)) throw new Error(`Unknown command: ${command}`);
|
|
91
|
-
usage(command);
|
|
92
|
-
return 0;
|
|
93
|
-
}
|
|
94
|
-
if (command === 'version') { console.log(`${packageData.pkg.name} ${packageData.pkg.version}`); return 0; }
|
|
95
|
-
const root = path.resolve(options.root ?? process.cwd());
|
|
96
|
-
if (!fs.existsSync(root) || !fs.statSync(root).isDirectory()) throw new Error(`Repository root not found: ${root}`);
|
|
97
|
-
const installed = loadProjectForgeBaseline(root);
|
|
98
|
-
const profile = options.profile ?? installed.profile ?? packageData.assets.defaultProfile;
|
|
99
|
-
|
|
100
|
-
if (command === 'status') {
|
|
101
|
-
if (!Object.keys(installed).length) {
|
|
102
|
-
console.log(`Project Forge: not installed\nAvailable package: ${packageData.pkg.name}@${packageData.pkg.version}\nProfile: ${profile}`);
|
|
103
|
-
return 0;
|
|
104
|
-
}
|
|
105
|
-
console.log(`Project Forge package: ${installed.package}@${installed.version}`);
|
|
106
|
-
console.log(`Available package: ${packageData.pkg.name}@${packageData.pkg.version}`);
|
|
107
|
-
console.log(`Profile: ${installed.profile}`);
|
|
108
|
-
for (const [name, component] of Object.entries(installed.components ?? {})) {
|
|
109
|
-
console.log(`${name}: ${component.version ? component.version + ' @ ' : ''}${component.commit}`);
|
|
110
|
-
}
|
|
111
|
-
return 0;
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
if (command === 'check') {
|
|
115
|
-
const result = checkProfile(packageData, profile, root);
|
|
116
|
-
console.log(`Project Forge package baseline current: ${result.projectForgeCurrent ? 'yes' : 'no'}`);
|
|
117
|
-
for (const component of result.plan.plans) {
|
|
118
|
-
const clean = component.metadataCurrent && component.rows.every((row) => row.state === 'SAME') && component.retired.length === 0;
|
|
119
|
-
console.log(`${component.componentId}: ${clean ? 'PASS' : 'STALE OR DRIFTED'}`);
|
|
120
|
-
}
|
|
121
|
-
console.log(`Project Forge check: ${result.clean ? 'PASS' : 'STALE OR DRIFTED'}`);
|
|
122
|
-
return result.clean ? 0 : 1;
|
|
123
|
-
}
|
|
124
|
-
|
|
125
|
-
if (!['install', 'update'].includes(command)) throw new Error(`Unknown command: ${command}`);
|
|
126
|
-
if (command === 'install' && Object.keys(installed).length) throw new Error('PROJECT_FORGE_BASELINE.json already exists; use update instead of install');
|
|
127
|
-
if (command === 'update' && !Object.keys(installed).length) throw new Error('PROJECT_FORGE_BASELINE.json is missing; use install first');
|
|
128
|
-
if (installed.profile && installed.profile !== profile) throw new Error(`Installed Project Forge profile is ${installed.profile}; refusing implicit profile change to ${profile}`);
|
|
129
|
-
|
|
130
|
-
const plan = planProfile(packageData, profile, root, options.acceptedRetired);
|
|
131
|
-
printPlan(plan);
|
|
132
|
-
if (plan.unsafe.length) {
|
|
133
|
-
console.log('\nRefusing apply. Resolve drift/conflicts and preview again. No managed files were changed.');
|
|
134
|
-
return 2;
|
|
135
|
-
}
|
|
136
|
-
const apply = await shouldApply(options);
|
|
137
|
-
if (!apply) {
|
|
138
|
-
console.log('\nPreview only. Re-run with --apply, or run interactively and approve the prompt.');
|
|
139
|
-
return 0;
|
|
140
|
-
}
|
|
141
|
-
const baselineConsumers = [...new Set(plan.plans.map((item) => item.baseline.consumer).filter(Boolean))];
|
|
142
|
-
const baselineLabels = [...new Set(plan.plans.map((item) => item.baseline.consumerBaseline).filter(Boolean))];
|
|
143
|
-
if (baselineConsumers.length > 1) throw new Error(`Component baselines disagree on consumer identity: ${baselineConsumers.join(', ')}`);
|
|
144
|
-
if (baselineLabels.length > 1) throw new Error(`Component baselines disagree on consumer baseline: ${baselineLabels.join(', ')}`);
|
|
145
|
-
const consumer = options.consumer ?? installed.consumer ?? baselineConsumers[0] ?? inferConsumer(root);
|
|
146
|
-
const consumerBaseline = options.consumerBaseline ?? installed.consumerBaseline ?? baselineLabels[0] ?? `${packageData.pkg.name}@${packageData.pkg.version}`;
|
|
147
|
-
applyProfile(packageData, plan, root, consumer, consumerBaseline);
|
|
148
|
-
console.log(`\nProject Forge ${command}: PASS (${packageData.pkg.name}@${packageData.pkg.version})`);
|
|
149
|
-
return 0;
|
|
150
|
-
}
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { execFileSync } from 'node:child_process';
|
|
4
|
+
import readline from 'node:readline/promises';
|
|
5
|
+
import { stdin as input, stdout as output } from 'node:process';
|
|
6
|
+
import { applyProfile, checkProfile, loadProjectForgeBaseline, planProfile } from './engine.mjs';
|
|
7
|
+
import { loadPackageData } from './package-data.mjs';
|
|
8
|
+
|
|
9
|
+
function parseArgs(argv) {
|
|
10
|
+
const options = { acceptedRetired: new Map() };
|
|
11
|
+
const positional = [];
|
|
12
|
+
for (let i = 0; i < argv.length; i += 1) {
|
|
13
|
+
const arg = argv[i];
|
|
14
|
+
if (arg === '--help' || arg === '-h') options.help = true;
|
|
15
|
+
else if (arg === '--apply' || arg === '--yes') options.apply = true;
|
|
16
|
+
else if (arg === '--root') options.root = argv[++i];
|
|
17
|
+
else if (arg === '--profile') options.profile = argv[++i];
|
|
18
|
+
else if (arg === '--consumer') options.consumer = argv[++i];
|
|
19
|
+
else if (arg === '--consumer-baseline') options.consumerBaseline = argv[++i];
|
|
20
|
+
else if (arg === '--accept-retired') {
|
|
21
|
+
const value = argv[++i] ?? '';
|
|
22
|
+
const index = value.indexOf(':');
|
|
23
|
+
if (index <= 0) throw new Error('--accept-retired must use component:path');
|
|
24
|
+
const component = value.slice(0, index);
|
|
25
|
+
const retiredPath = value.slice(index + 1);
|
|
26
|
+
if (!options.acceptedRetired.has(component)) options.acceptedRetired.set(component, new Set());
|
|
27
|
+
options.acceptedRetired.get(component).add(retiredPath);
|
|
28
|
+
} else if (arg.startsWith('-')) throw new Error(`Unknown option: ${arg}`);
|
|
29
|
+
else positional.push(arg);
|
|
30
|
+
}
|
|
31
|
+
return { command: positional[0] ?? 'help', options };
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function inferConsumer(root) {
|
|
35
|
+
try {
|
|
36
|
+
const origin = execFileSync('git', ['-C', root, 'config', '--get', 'remote.origin.url'], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim();
|
|
37
|
+
if (origin) {
|
|
38
|
+
const normalized = origin.replace(/\\/g, '/').replace(/\.git$/, '');
|
|
39
|
+
const match = normalized.match(/[:/]([^/:]+\/[^/]+)$/);
|
|
40
|
+
if (match) return match[1].split('/').pop();
|
|
41
|
+
}
|
|
42
|
+
} catch {}
|
|
43
|
+
return path.basename(root);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function printPlan(plan) {
|
|
47
|
+
console.log(`Project Forge profile: ${plan.profileName}`);
|
|
48
|
+
for (const component of plan.plans) {
|
|
49
|
+
console.log(`\n${component.componentId.toUpperCase()} ${component.component.commit}`);
|
|
50
|
+
for (const row of component.rows) console.log(`${row.state.padEnd(20)} ${row.path}`);
|
|
51
|
+
for (const retired of component.retired) console.log(`${retired.state.padEnd(20)} ${retired.path}`);
|
|
52
|
+
}
|
|
53
|
+
console.log(`\nUnsafe drift/conflicts: ${plan.unsafe.length}`);
|
|
54
|
+
for (const item of plan.unsafe) console.log(` - ${item.component}: ${item.state}: ${item.path}`);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
async function shouldApply(options) {
|
|
58
|
+
if (options.apply) return true;
|
|
59
|
+
if (!input.isTTY || !output.isTTY) return false;
|
|
60
|
+
const rl = readline.createInterface({ input, output });
|
|
61
|
+
try {
|
|
62
|
+
const answer = (await rl.question('Apply this safe Project Forge asset plan? [y/N] ')).trim().toLowerCase();
|
|
63
|
+
return answer === 'y' || answer === 'yes';
|
|
64
|
+
} finally {
|
|
65
|
+
rl.close();
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function usage(command) {
|
|
70
|
+
const commandUsage = {
|
|
71
|
+
install: 'project-forge install [--root PATH] [--profile NAME] [--apply|--yes] [--consumer NAME] [--consumer-baseline LABEL] [--accept-retired component:path]',
|
|
72
|
+
update: 'project-forge update [--root PATH] [--profile NAME] [--apply|--yes] [--accept-retired component:path]',
|
|
73
|
+
status: 'project-forge status [--root PATH] [--profile NAME]',
|
|
74
|
+
check: 'project-forge check [--root PATH] [--profile NAME]',
|
|
75
|
+
version: 'project-forge version'
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
if (commandUsage[command]) {
|
|
79
|
+
console.log(`@mriekki/project-forge\n\nUsage:\n ${commandUsage[command]}\n`);
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
console.log(`@mriekki/project-forge\n\nCommands:\n install Preview/install bundled Project Forge assets\n update Preview/update to this package's bundled Project Forge assets\n status Show installed umbrella/component provenance\n check Verify managed files and baselines exactly match this package\n version Show package version\n\nCommon options:\n --root PATH\n --profile greenfield-project\n --apply | --yes\n --consumer NAME\n --consumer-baseline LABEL\n --accept-retired core:path\n -h, --help\n`);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export async function run(argv = process.argv.slice(2), packageData = loadPackageData()) {
|
|
87
|
+
const { command, options } = parseArgs(argv);
|
|
88
|
+
if (command === 'help') { usage(); return 0; }
|
|
89
|
+
if (options.help) {
|
|
90
|
+
if (!['install', 'update', 'status', 'check', 'version'].includes(command)) throw new Error(`Unknown command: ${command}`);
|
|
91
|
+
usage(command);
|
|
92
|
+
return 0;
|
|
93
|
+
}
|
|
94
|
+
if (command === 'version') { console.log(`${packageData.pkg.name} ${packageData.pkg.version}`); return 0; }
|
|
95
|
+
const root = path.resolve(options.root ?? process.cwd());
|
|
96
|
+
if (!fs.existsSync(root) || !fs.statSync(root).isDirectory()) throw new Error(`Repository root not found: ${root}`);
|
|
97
|
+
const installed = loadProjectForgeBaseline(root);
|
|
98
|
+
const profile = options.profile ?? installed.profile ?? packageData.assets.defaultProfile;
|
|
99
|
+
|
|
100
|
+
if (command === 'status') {
|
|
101
|
+
if (!Object.keys(installed).length) {
|
|
102
|
+
console.log(`Project Forge: not installed\nAvailable package: ${packageData.pkg.name}@${packageData.pkg.version}\nProfile: ${profile}`);
|
|
103
|
+
return 0;
|
|
104
|
+
}
|
|
105
|
+
console.log(`Project Forge package: ${installed.package}@${installed.version}`);
|
|
106
|
+
console.log(`Available package: ${packageData.pkg.name}@${packageData.pkg.version}`);
|
|
107
|
+
console.log(`Profile: ${installed.profile}`);
|
|
108
|
+
for (const [name, component] of Object.entries(installed.components ?? {})) {
|
|
109
|
+
console.log(`${name}: ${component.version ? component.version + ' @ ' : ''}${component.commit}`);
|
|
110
|
+
}
|
|
111
|
+
return 0;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
if (command === 'check') {
|
|
115
|
+
const result = checkProfile(packageData, profile, root);
|
|
116
|
+
console.log(`Project Forge package baseline current: ${result.projectForgeCurrent ? 'yes' : 'no'}`);
|
|
117
|
+
for (const component of result.plan.plans) {
|
|
118
|
+
const clean = component.metadataCurrent && component.rows.every((row) => row.state === 'SAME') && component.retired.length === 0;
|
|
119
|
+
console.log(`${component.componentId}: ${clean ? 'PASS' : 'STALE OR DRIFTED'}`);
|
|
120
|
+
}
|
|
121
|
+
console.log(`Project Forge check: ${result.clean ? 'PASS' : 'STALE OR DRIFTED'}`);
|
|
122
|
+
return result.clean ? 0 : 1;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
if (!['install', 'update'].includes(command)) throw new Error(`Unknown command: ${command}`);
|
|
126
|
+
if (command === 'install' && Object.keys(installed).length) throw new Error('PROJECT_FORGE_BASELINE.json already exists; use update instead of install');
|
|
127
|
+
if (command === 'update' && !Object.keys(installed).length) throw new Error('PROJECT_FORGE_BASELINE.json is missing; use install first');
|
|
128
|
+
if (installed.profile && installed.profile !== profile) throw new Error(`Installed Project Forge profile is ${installed.profile}; refusing implicit profile change to ${profile}`);
|
|
129
|
+
|
|
130
|
+
const plan = planProfile(packageData, profile, root, options.acceptedRetired);
|
|
131
|
+
printPlan(plan);
|
|
132
|
+
if (plan.unsafe.length) {
|
|
133
|
+
console.log('\nRefusing apply. Resolve drift/conflicts and preview again. No managed files were changed.');
|
|
134
|
+
return 2;
|
|
135
|
+
}
|
|
136
|
+
const apply = await shouldApply(options);
|
|
137
|
+
if (!apply) {
|
|
138
|
+
console.log('\nPreview only. Re-run with --apply, or run interactively and approve the prompt.');
|
|
139
|
+
return 0;
|
|
140
|
+
}
|
|
141
|
+
const baselineConsumers = [...new Set(plan.plans.map((item) => item.baseline.consumer).filter(Boolean))];
|
|
142
|
+
const baselineLabels = [...new Set(plan.plans.map((item) => item.baseline.consumerBaseline).filter(Boolean))];
|
|
143
|
+
if (baselineConsumers.length > 1) throw new Error(`Component baselines disagree on consumer identity: ${baselineConsumers.join(', ')}`);
|
|
144
|
+
if (baselineLabels.length > 1) throw new Error(`Component baselines disagree on consumer baseline: ${baselineLabels.join(', ')}`);
|
|
145
|
+
const consumer = options.consumer ?? installed.consumer ?? baselineConsumers[0] ?? inferConsumer(root);
|
|
146
|
+
const consumerBaseline = options.consumerBaseline ?? installed.consumerBaseline ?? baselineLabels[0] ?? `${packageData.pkg.name}@${packageData.pkg.version}`;
|
|
147
|
+
applyProfile(packageData, plan, root, consumer, consumerBaseline);
|
|
148
|
+
console.log(`\nProject Forge ${command}: PASS (${packageData.pkg.name}@${packageData.pkg.version})`);
|
|
149
|
+
return 0;
|
|
150
|
+
}
|